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
2 changes: 1 addition & 1 deletion 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/install.test.ts ./tests/node-version.test.ts ./tests/setup-prisma.test.ts ./tests/telemetry.test.ts",
"test:unit": "bun test ./tests/dependencies.test.ts ./tests/deploy-with-composer.test.ts ./tests/initialize-git.test.ts ./tests/install.test.ts ./tests/node-version.test.ts ./tests/setup-prisma.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 Down
1 change: 1 addition & 0 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ async function executeCreateContext(
template: context.template,
createdProjectPath: context.targetDirectory,
includeDevNextStep: true,
initializeGit: !context.targetPathState.exists || context.targetPathState.isEmptyDirectory,
progressSpinner: createSpinner,
});

Expand Down
125 changes: 107 additions & 18 deletions src/tasks/deploy-with-composer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { cancel, isCancel, log, select, spinner } from "@clack/prompts";
import { cancel, isCancel, log, select, spinner, taskLog } from "@clack/prompts";
import { execa } from "execa";
import { createInterface } from "node:readline";

import { PRISMA_PLATFORM_CLI_PACKAGE } from "../constants/dependencies";
import type { PackageManager } from "../types";
Expand Down Expand Up @@ -44,6 +45,13 @@ type ProjectShowResult = {
project: { id: string; name: string } | null;
};

type ProjectListResult = {
items: Array<{
id: string;
name: string;
}>;
};

type ComposerDeployCommandResult = {
summary: {
app: string;
Expand All @@ -64,13 +72,17 @@ export type ComposerDeployResult = {
};
};

function redactSecrets(message: string): string {
export function redactSecrets(message: string): string {
return message
.replace(/\b((?:prisma\+)?postgres(?:ql)?:\/\/)[^\s'"]+/gi, "$1<redacted>")
.replace(
/\b([A-Z0-9_]*(?:DATABASE_URL|TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*=)[^\s]+/g,
/\b((?:(?:prisma\+)?postgres(?:ql)?|mongodb(?:\+srv)?):\/\/)[^\s'"]+/gi,
"$1<redacted>",
);
)
.replace(
/\b([A-Z0-9_]*(?:MONGODB_(?:URL|URI)|DATABASE_URL|TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s]+)/gi,
"$1<redacted>",
)
.replace(/(\bAuthorization\s*:\s*Bearer\s+)[^\s'"]+/gi, "$1<redacted>");
}

function getErrorMessage(error: unknown): string {
Expand Down Expand Up @@ -123,22 +135,28 @@ async function runPrismaJsonCommand<Result>(options: {
packageManager: PackageManager;
projectDir: string;
args: string[];
forwardStderr?: boolean;
onStderrLine?: (line: string) => void;
}): Promise<Result> {
const invocation = getPrismaCliArgs(options.packageManager, [
...options.args,
"--json",
"--no-interactive",
]);
const result = await execa(invocation.command, invocation.args, {
const subprocess = execa(invocation.command, invocation.args, {
cwd: options.projectDir,
env: process.env,
reject: false,
});

if (options.forwardStderr && result.stderr) {
process.stderr.write(result.stderr.endsWith("\n") ? result.stderr : `${result.stderr}\n`);
}
const stderrLines =
options.onStderrLine && subprocess.stderr
? (async () => {
const lines = createInterface({ input: subprocess.stderr });
for await (const line of lines) {
if (line.trim()) options.onStderrLine?.(line);
}
})()
: Promise.resolve();
const [result] = await Promise.all([subprocess, stderrLines]);

let envelope: PrismaCliEnvelope<Result>;
try {
Expand All @@ -159,6 +177,36 @@ async function runPrismaJsonCommand<Result>(options: {
return envelope.result;
}

export function findProjectNameCollisions(
projects: ProjectListResult["items"],
appName: string,
): ProjectListResult["items"] {
return projects.filter((project) => project.name === appName);
}

async function ensureProjectNameAvailable(options: {
appName: string;
packageManager: PackageManager;
projectDir: string;
workspace: PrismaWorkspace;
}): Promise<void> {
const result = await runPrismaJsonCommand<ProjectListResult>({
packageManager: options.packageManager,
projectDir: options.projectDir,
args: ["project", "list"],
});
const collisions = findProjectNameCollisions(result.items, options.appName);
if (collisions.length === 0) return;

const projectIds = collisions.map((project) => project.id).join(", ");
throw new Error(
`A Prisma project named "${options.appName}" already exists in workspace ${workspaceLabel(
options.workspace,
)} (${options.workspace.id}). Choose a different project name or delete the existing project ` +
`(${projectIds}) in Prisma Console, then retry.`,
);
}

async function ensureAuthentication(
packageManager: PackageManager,
projectDir: string,
Expand Down Expand Up @@ -326,7 +374,11 @@ async function getProjectDetails(options: {
}
}

export async function deployWithComposer(options: {
/**
* Performs the optional one-shot deployment at the end of a create-prisma scaffold.
* Generated projects use their own `deploy` script for every subsequent deployment.
*/
export async function deployNewProjectWithComposer(options: {
appName: string;
packageManager: PackageManager;
projectDir: string;
Expand All @@ -335,6 +387,7 @@ export async function deployWithComposer(options: {
workspace?: string;
}): Promise<ComposerDeployResult | undefined> {
const progress = options.verbose ? undefined : spinner();
let deploymentLog: ReturnType<typeof taskLog> | undefined;
let progressRunning = false;
const showProgress = (message: string) => {
if (!progress) return;
Expand Down Expand Up @@ -373,6 +426,15 @@ export async function deployWithComposer(options: {
});
if (!selectedWorkspace) return;

showProgress("Checking Prisma project name...");
if (options.verbose) log.step("Checking Prisma project name.");
await ensureProjectNameAvailable({
appName: options.appName,
packageManager: options.packageManager,
projectDir: options.projectDir,
workspace: selectedWorkspace,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

showProgress("Building for deployment...");
if (options.verbose) log.step("Building for deployment.");
const build = getRunScriptArgs(options.packageManager, "build");
Expand All @@ -382,26 +444,48 @@ export async function deployWithComposer(options: {
stdio: options.verbose ? "inherit" : "pipe",
});

showProgress("Deploying to Prisma...");
if (options.verbose) log.step("Deploying to Prisma.");
clearProgress();
const deployCommand = getPackageExecutionCommand(options.packageManager, [
PRISMA_PLATFORM_CLI_PACKAGE,
"deploy",
"module.ts",
]);
if (options.verbose) {
log.step(`Deploying to Prisma with ${deployCommand}.`);
} else {
deploymentLog = taskLog({ title: "Deploying to Prisma...", limit: 10 });
deploymentLog.message(`$ ${deployCommand}`);
}
const deployment = parseComposerDeployResult(
await runPrismaJsonCommand<ComposerDeployCommandResult>({
packageManager: options.packageManager,
projectDir: options.projectDir,
args: ["deploy", "module.ts"],
forwardStderr: options.verbose,
onStderrLine: (line) => {
const redactedLine = redactSecrets(line);
if (options.verbose) {
process.stderr.write(`${redactedLine}\n`);
} else {
deploymentLog?.message(redactedLine);
}
},
}),
);
const appName = deployment?.appName ?? options.appName;

showProgress("Loading deployment details...");
if (options.verbose) {
log.step("Loading deployment details.");
} else {
deploymentLog?.message("Loading deployment details...");
}
const details = await getProjectDetails({
packageManager: options.packageManager,
projectDir: options.projectDir,
appName,
});

progress?.stop("Deployed to Prisma.");
deploymentLog?.success("Deployed to Prisma.");
deploymentLog = undefined;
progressRunning = false;
if (options.verbose) log.success("Deployed to Prisma.");
const workspace = details?.workspace ?? selectedWorkspace;
Expand All @@ -412,7 +496,12 @@ export async function deployWithComposer(options: {
project: details?.project ?? { name: appName },
};
} catch (error) {
progress?.error("Deployment failed.");
if (deploymentLog) {
deploymentLog.error("Deployment failed.");
deploymentLog = undefined;
} else {
progress?.error("Deployment failed.");
}
progressRunning = false;
log.error(`Deploy failed: ${getErrorMessage(error)}`);
return;
Expand Down
53 changes: 53 additions & 0 deletions src/tasks/initialize-git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { execa } from "execa";
import fs from "fs-extra";
import path from "node:path";

export type GitInitializationResult =
| { status: "initialized" }
| { status: "already-in-repository" }
| { status: "skipped"; reason: string };

function errorMessage(error: unknown): string {
if (error instanceof Error && "stderr" in error) {
const stderr = String((error as { stderr?: string }).stderr ?? "").trim();
if (stderr) return stderr;
}
return error instanceof Error ? error.message : String(error);
}

/**
* Initializes a standalone scaffold as a Git repository and records its generated files.
* Projects created inside an existing repository remain part of that repository.
*/
export async function initializeGitRepository(
projectDir: string,
env: NodeJS.ProcessEnv = process.env,
): Promise<GitInitializationResult> {
try {
const existing = await execa("git", ["rev-parse", "--is-inside-work-tree"], {
cwd: projectDir,
env,
reject: false,
});
if (existing.exitCode === 0 && existing.stdout.trim() === "true") {
return { status: "already-in-repository" };
}
} catch (error) {
return { status: "skipped", reason: errorMessage(error) };
}

let initialized = false;
try {
await execa("git", ["init"], { cwd: projectDir, env });
initialized = true;
await execa("git", ["add", "--all"], { cwd: projectDir, env });
await execa("git", ["commit", "--no-verify", "-m", "Initial commit from create-prisma"], {
cwd: projectDir,
env,
});
return { status: "initialized" };
} catch (error) {
if (initialized) await fs.remove(path.join(projectDir, ".git"));
return { status: "skipped", reason: errorMessage(error) };
}
}
21 changes: 19 additions & 2 deletions src/tasks/setup-prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import {
getPackageExecutionArgs,
getRunScriptCommand,
} from "../utils/package-manager";
import { deployWithComposer, type ComposerDeployResult } from "./deploy-with-composer";
import { deployNewProjectWithComposer, type ComposerDeployResult } from "./deploy-with-composer";
import { initializeGitRepository, type GitInitializationResult } from "./initialize-git";
import { installProjectDependencies, writePrismaDependencies } from "./install";

const DEFAULT_DATABASE_PROVIDER: DatabaseProvider = "postgres";
Expand All @@ -40,6 +41,7 @@ type PrismaSetupRunOptions = {
template?: CreateTemplate;
createdProjectPath?: string;
includeDevNextStep?: boolean;
initializeGit?: boolean;
progressSpinner?: ReturnType<typeof spinner>;
};

Expand Down Expand Up @@ -393,6 +395,7 @@ export async function executePrismaSetupContext(
const template = options.template ?? "minimal";
const progress = context.verbose ? undefined : (options.progressSpinner ?? spinner());
const ownsProgress = progress !== undefined && !options.progressSpinner;
let gitInitialization: GitInitializationResult | undefined;
if (ownsProgress) progress.start("Creating Prisma 8 project...");

try {
Expand All @@ -415,6 +418,10 @@ export async function executePrismaSetupContext(
);
await ensureComposerTypeScriptOptions(projectDir);
if (context.databaseProvider === "mongo") await ensureMongoEnvironment(projectDir);
if (context.packageManager !== "deno") {
await ensureGitignoreEntry(projectDir, "/.alchemy");
await ensureGitignoreEntry(projectDir, "/.prisma-composer");
}

progress?.message(
`Installing dependencies with ${getInstallCommand(context.packageManager)}...`,
Expand All @@ -428,7 +435,17 @@ export async function executePrismaSetupContext(

progress?.message("Generating Prisma 8 contract artifacts...");
await emitContract(context, projectDir);

if (options.initializeGit) {
progress?.message("Initializing Git repository...");
gitInitialization = await initializeGitRepository(projectDir);
}
progress?.stop("Prisma 8 project ready.");
if (gitInitialization?.status === "initialized" && context.verbose) {
log.success("Initialized Git repository with an initial commit.");
} else if (gitInitialization?.status === "skipped") {
log.warn(`Could not initialize Git repository: ${gitInitialization.reason}`);
}
} catch (error) {
progress?.error("Could not create Prisma 8 project.");
cancel(getCommandErrorMessage(error));
Expand All @@ -437,7 +454,7 @@ export async function executePrismaSetupContext(

let deployment: ComposerDeployResult | undefined;
if (context.shouldDeploy) {
deployment = await deployWithComposer({
deployment = await deployNewProjectWithComposer({
appName: projectName,
packageManager: context.packageManager,
projectDir,
Expand Down
Loading
Loading