Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Create a Prisma 8 app with Prisma Composer built in.

Requires Node.js 22.22 or newer.

## Quick start

Use your package manager:
Expand Down Expand Up @@ -81,6 +83,13 @@ older cached version. Prisma Compute does not support Deno deployments yet.
- `--verbose`
- `--json`

Without `--force`, non-empty directories are left unchanged. Interactive runs offer to create
an available name, choose another name, or cancel. An existing app without `prisma.config.ts`
also offers to add Prisma through the official `prisma orm init` command. This runs ORM setup
only, not starter generation or deployment; Prisma's own overwrite-consent prompts remain active.
The default project name skips occupied paths. `--yes`, `--json` and non-TTY collisions still fail
without modifying the existing directory.

### JSON output for agents and automation

Use `--json` when another program is driving `create-prisma`:
Expand Down Expand Up @@ -110,4 +119,4 @@ bun run build

## Telemetry

Published builds may send anonymous usage telemetry. It never includes project names, file paths, or database URLs. Failure events carry only stable identifiers such as exit codes and tool error codes, never messages or command output. Disable it with `DO_NOT_TRACK`, `CREATE_PRISMA_DISABLE_TELEMETRY`, or `CREATE_PRISMA_TELEMETRY_DISABLED`.
Published builds may send anonymous usage telemetry. It never includes project names, file paths, or database URLs. Failure events carry only stable identifiers such as exit codes and tool error codes, never messages or command output. A run rejected for a non-empty directory reports only whether that directory held an existing app, a Prisma project, or other files. Disable it with `DO_NOT_TRACK`, `CREATE_PRISMA_DISABLE_TELEMETRY`, or `CREATE_PRISMA_TELEMETRY_DISABLED`.
426 changes: 241 additions & 185 deletions bun.lock

Large diffs are not rendered by default.

34 changes: 17 additions & 17 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"dev": "tsdown --watch",
"start": "bun run ./dist/cli.mjs",
"test": "bun run test:unit && bun run test:e2e",
"test:unit": "bun test ./tests/dependencies.test.ts ./tests/deploy-with-composer.test.ts ./tests/initialize-git.test.ts ./tests/install.test.ts ./tests/json-output.test.ts ./tests/node-version.test.ts ./tests/package-manager-error-code.test.ts ./tests/setup-prisma.test.ts ./tests/telemetry-client.test.ts ./tests/telemetry.test.ts",
"test:unit": "bun test ./tests/create-context.test.ts ./tests/dependencies.test.ts ./tests/deploy-with-composer.test.ts ./tests/initialize-git.test.ts ./tests/install.test.ts ./tests/json-output.test.ts ./tests/node-version.test.ts ./tests/package-manager-error-code.test.ts ./tests/setup-prisma.test.ts ./tests/telemetry-client.test.ts ./tests/telemetry.test.ts",
"test:e2e": "bun test --timeout 180000 ./tests/e2e/create-prisma.e2e.test.ts",
"check": "bun run format:check && bun run lint",
"lint": "oxlint . --deny-warnings",
Expand All @@ -51,27 +51,27 @@
"release-notes": "bunx changelogithub"
},
"dependencies": {
"@clack/prompts": "^1.7.0",
"@effect/platform-node-shared": "4.0.0-rc.112",
"effect": "4.0.0-rc.112",
"execa": "^9.6.1",
"handlebars": "^4.7.8",
"posthog-node": "^5.28.2"
"@clack/prompts": "^1.8.1",
"@effect/platform-node-shared": "4.0.1",
"effect": "4.0.1",
"execa": "^10.0.1",
"handlebars": "^4.7.10",
"posthog-node": "^5.55.0"
},
"devDependencies": {
"@prisma/dev": "0.24.7",
"@types/bun": "^1.3.9",
"@types/node": "^25.3.0",
"changelogithub": "^14.0.0",
"mongodb-memory-server": "11.1.0",
"oxfmt": "^0.37.0",
"oxlint": "^1.52.0",
"tsdown": "^0.20.3",
"typescript": "^5.9.3"
"@prisma/dev": "0.25.2",
"@types/bun": "^1.4.2",
"@types/node": "^26.6.4",
"changelogithub": "^15.0.5",
"mongodb-memory-server": "11.3.0",
"oxfmt": "^0.72.0",
"oxlint": "^1.87.0",
"tsdown": "^0.23.0",
"typescript": "^7.0.2"
},
"engines": {
"bun": ">=1.3.0",
"node": ">=22.18.0"
"node": ">=22.22.0"
},
"packageManager": "bun@1.4.1"
}
224 changes: 198 additions & 26 deletions src/commands/create-context.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
import { cancel, isCancel, select, text } from "@clack/prompts";
import { cancel, isCancel, log, select, text } from "@clack/prompts";
import { Effect, FileSystem, Schema } from "effect";
import path from "node:path";
import type { Writable } from "node:stream";

import { CreateCancellationError, CreateFailure } from "../create-outcome";
import {
CreateCancellationError,
CreateFailure,
type CreateTargetDirectoryKind,
} from "../create-outcome";
import type { CreateProjectResult } from "../result";
import { collectPrismaSetupContextEffect, type PrismaSetupContext } from "../tasks/setup-prisma";
import { CreateTemplateSchema, type CreateCommandInput, type CreateTemplate } from "../types";
import {
CreateTemplateSchema,
type CreateCommandInput,
type CreateTemplate,
type DatabaseProvider,
type PackageManager,
} from "../types";
import { resolveExecutionSettings } from "../ui/output";
import { detectPackageManagerEffect, getPackageExecutionCommand } from "../utils/package-manager";

const DEFAULT_PROJECT_NAME = "my-app";
const DEFAULT_TEMPLATE: CreateTemplate = "minimal";
const EXISTING_PROJECT_DOCS_URL = {
postgres: "https://www.prisma.io/docs/prisma-orm/add-to-existing-project/postgresql",
mongo: "https://www.prisma.io/docs/prisma-orm/add-to-existing-project/mongodb",
} satisfies Record<DatabaseProvider, string>;

export type CreateTargetPathState = {
exists: boolean;
Expand All @@ -19,6 +34,7 @@ export type CreateTargetPathState = {
};

export type CreatePromptContext = {
operation?: "create";
targetDirectory: string;
targetPathState: CreateTargetPathState;
force: boolean;
Expand All @@ -27,6 +43,16 @@ export type CreatePromptContext = {
prismaSetupContext: PrismaSetupContext;
};

export type ExistingAppContext = {
operation: "orm_init";
targetDirectory: string;
targetPathState: CreateTargetPathState;
projectPackageName: string;
packageManager: PackageManager;
};

export type CreateContext = CreatePromptContext | ExistingAppContext;

const toPackageName = (projectName: string) =>
projectName
.toLowerCase()
Expand All @@ -53,12 +79,15 @@ export const createProjectResult = (context: CreatePromptContext): CreateProject
packageManager: context.prismaSetupContext.packageManager,
});

const promptForProjectName = Effect.fn("Prompts.projectName")(function* (output: Writable) {
const promptForProjectName = Effect.fn("Prompts.projectName")(function* (
output: Writable,
initialValue = DEFAULT_PROJECT_NAME,
) {
const value = yield* Effect.tryPromise(() =>
text({
message: "Project name",
placeholder: DEFAULT_PROJECT_NAME,
initialValue: DEFAULT_PROJECT_NAME,
placeholder: initialValue,
initialValue,
validate: validateProjectName,
output,
}),
Expand All @@ -70,6 +99,65 @@ const promptForProjectName = Effect.fn("Prompts.projectName")(function* (output:
return String(value).trim();
});

const findAvailableProjectName = Effect.fn("Create.availableName")(function* (
projectName: string,
counter = 0,
) {
const base = projectName === "." ? DEFAULT_PROJECT_NAME : projectName;
while (true) {
const candidate = counter === 0 ? base : `${base}-${counter}`;
const state = yield* inspectTargetPath(path.resolve(process.cwd(), candidate));
if (!state.exists || state.isEmptyDirectory) return candidate;
counter += 1;
}
});

const promptForDirectoryConflict = Effect.fn("Prompts.directoryConflict")(function* (
projectName: string,
failure: CreateFailure,
output: Writable,
) {
const availableName = yield* findAvailableProjectName(projectName, 1);
yield* Effect.sync(() =>
log.warn(
failure.reason === "target_path_not_directory"
? `Path "${projectName}" is not a directory.`
: `Directory "${projectName}" is not empty.`,
{ output },
),
);
const value = yield* Effect.tryPromise(() =>
select({
message: "What would you like to do?",
initialValue: "create",
options: [
{
value: "create",
label: `Create "${availableName}"`,
hint: "Leave existing files unchanged",
},
...(failure.targetDirectoryKind === "existing_app"
? [
{
value: "orm_init",
label: "Add Prisma to the existing app",
hint: "ORM setup only; no starter or deployment",
},
]
: []),
{ value: "rename", label: "Choose another name" },
{ value: "cancel", label: "Cancel" },
],
output,
}),
);
if (isCancel(value) || value === "cancel") {
yield* Effect.sync(() => cancel("Operation cancelled.", { output }));
return yield* new CreateCancellationError({ stage: "directory_conflict" });
}
return { action: value, availableName };
});

const promptForCreateTemplate = Effect.fn("Prompts.template")(function* (output: Writable) {
const value = yield* Effect.tryPromise(() =>
select({
Expand Down Expand Up @@ -139,49 +227,133 @@ const inspectTargetPath = Effect.fn("Create.inspectTargetPath")(function* (targe
};
});

export const collectCreateContext = Effect.fn("Create.collectContext")(function* (
input: CreateCommandInput,
const inspectTargetDirectoryKind = Effect.fn("Create.inspectTargetDirectoryKind")(function* (
targetDirectory: string,
) {
const fs = yield* FileSystem.FileSystem;
if (yield* fs.exists(path.join(targetDirectory, "prisma.config.ts"))) {
return "prisma_project" satisfies CreateTargetDirectoryKind;
}
if (yield* fs.exists(path.join(targetDirectory, "package.json"))) {
return "existing_app" satisfies CreateTargetDirectoryKind;
}
return "other" satisfies CreateTargetDirectoryKind;
});

const describeNonEmptyTarget = Effect.fn("Create.describeNonEmptyTarget")(function* (
targetDirectory: string,
kind: CreateTargetDirectoryKind,
provider: DatabaseProvider | undefined,
) {
const displayPath = formatPathForDisplay(targetDirectory);
switch (kind) {
case "existing_app": {
const initCommand = getPackageExecutionCommand(
yield* detectPackageManagerEffect(targetDirectory),
["prisma@latest", "orm", "init"],
);
const docsUrl = provider
? EXISTING_PROJECT_DOCS_URL[provider]
: "https://www.prisma.io/docs/cli/orm-init";
return `Target directory ${displayPath} already contains an app. Run \`${initCommand}\` there to add Prisma (${docsUrl}), or choose a different name for a new app. --force overwrites starter and Prisma files.`;
}
case "prisma_project":
return `Target directory ${displayPath} already contains a Prisma project. Use the Prisma CLI, or choose a different name. To resume an interrupted scaffold, rerun with --force; it overwrites starter and Prisma files.`;
case "other":
return `Target directory ${displayPath} is not empty. Choose a different project name. --force overwrites starter and Prisma files.`;
}
});

type CreateTarget = { targetDirectory: string; targetPathState: CreateTargetPathState };

const resolveCreateTarget = Effect.fn("Create.resolveTarget")(function* (
projectName: string,
options: { force: boolean; provider: DatabaseProvider | undefined },
) {
const force = input.force === true;
const { output, useDefaults } = resolveExecutionSettings(input);
const projectName = String(
input.name ?? (useDefaults ? DEFAULT_PROJECT_NAME : yield* promptForProjectName(output)),
).trim();
const validationError = validateProjectName(projectName);
if (validationError) {
yield* Effect.sync(() => cancel(validationError, { output }));
return yield* new CreateFailure({
return new CreateFailure({
stage: "collect_context",
reason: "invalid_project_name",
message: validationError,
errorReported: true,
});
}

const template =
input.template ?? (useDefaults ? DEFAULT_TEMPLATE : yield* promptForCreateTemplate(output));
const targetDirectory = path.resolve(process.cwd(), projectName);
const targetPathState = yield* inspectTargetPath(targetDirectory);
if (targetPathState.exists && !targetPathState.isDirectory) {
const message = `Target path ${formatPathForDisplay(targetDirectory)} already exists and is not a directory. Choose a different project name.`;
yield* Effect.sync(() => cancel(message, { output }));
return yield* new CreateFailure({
return new CreateFailure({
stage: "collect_context",
reason: "target_path_not_directory",
message,
message: `Target path ${formatPathForDisplay(targetDirectory)} already exists and is not a directory. Choose a different project name.`,
errorReported: true,
});
}
if (targetPathState.exists && !targetPathState.isEmptyDirectory && !force) {
const message = `Target directory ${formatPathForDisplay(targetDirectory)} is not empty. Use --force to continue.`;
yield* Effect.sync(() => cancel(message, { output }));
return yield* new CreateFailure({
if (targetPathState.exists && !targetPathState.isEmptyDirectory && !options.force) {
const kind = yield* inspectTargetDirectoryKind(targetDirectory);
return new CreateFailure({
stage: "collect_context",
reason: "target_directory_not_empty",
message,
message: yield* describeNonEmptyTarget(targetDirectory, kind, options.provider),
errorReported: true,
targetDirectoryKind: kind,
});
}
return { targetDirectory, targetPathState } satisfies CreateTarget;
});

export const collectCreateContext = Effect.fn("Create.collectContext")(function* (
input: CreateCommandInput,
) {
const force = input.force === true;
const { output, useDefaults } = resolveExecutionSettings(input);
const interactive = !useDefaults && process.stdin.isTTY === true;
const targetOptions = { force, provider: input.provider };
let projectName = String(
input.name ??
(useDefaults
? DEFAULT_PROJECT_NAME
: yield* promptForProjectName(
output,
force ? DEFAULT_PROJECT_NAME : yield* findAvailableProjectName(DEFAULT_PROJECT_NAME),
)),
).trim();
let target = yield* resolveCreateTarget(projectName, targetOptions);
while (target instanceof CreateFailure && interactive) {
if (target.reason === "invalid_project_name") {
const { message } = target;
yield* Effect.sync(() => log.warn(message, { output }));
projectName = yield* promptForProjectName(output);
} else {
const choice = yield* promptForDirectoryConflict(projectName, target, output);
if (choice.action === "orm_init") {
const targetDirectory = path.resolve(process.cwd(), projectName);
return {
operation: "orm_init",
targetDirectory,
targetPathState: yield* inspectTargetPath(targetDirectory),
projectPackageName: toPackageName(path.basename(targetDirectory)),
packageManager:
input.packageManager ?? (yield* detectPackageManagerEffect(targetDirectory)),
} satisfies ExistingAppContext;
}
projectName =
choice.action === "rename"
? yield* promptForProjectName(output, choice.availableName)
: choice.availableName;
}
target = yield* resolveCreateTarget(projectName, targetOptions);
}
if (target instanceof CreateFailure) {
const { message } = target;
yield* Effect.sync(() => cancel(message, { output }));
return yield* target;
}
const { targetDirectory, targetPathState } = target;

const template =
input.template ?? (useDefaults ? DEFAULT_TEMPLATE : yield* promptForCreateTemplate(output));

if (force && targetPathState.exists) {
const migrations = yield* inspectTargetPath(path.join(targetDirectory, "migrations"));
Expand Down
Loading
Loading