Skip to content

Commit 5fd0545

Browse files
fix: make Composer deployment progress actionable (#63)
* fix: make Composer deployment progress actionable * refactor: clarify one-shot deployment boundary * feat: initialize new projects as git repositories * fix: redact credentials from deployment logs
1 parent 40cfbbc commit 5fd0545

7 files changed

Lines changed: 307 additions & 21 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"dev": "tsdown --watch",
3838
"start": "bun run ./dist/cli.mjs",
3939
"test": "bun run test:unit && bun run test:e2e",
40-
"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",
40+
"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",
4141
"test:e2e": "bun test --timeout 180000 ./tests/e2e/create-prisma.e2e.test.ts",
4242
"check": "bun run format:check && bun run lint",
4343
"lint": "oxlint . --deny-warnings",

src/commands/create.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ async function executeCreateContext(
382382
template: context.template,
383383
createdProjectPath: context.targetDirectory,
384384
includeDevNextStep: true,
385+
initializeGit: !context.targetPathState.exists || context.targetPathState.isEmptyDirectory,
385386
progressSpinner: createSpinner,
386387
});
387388

src/tasks/deploy-with-composer.ts

Lines changed: 107 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { cancel, isCancel, log, select, spinner } from "@clack/prompts";
1+
import { cancel, isCancel, log, select, spinner, taskLog } from "@clack/prompts";
22
import { execa } from "execa";
3+
import { createInterface } from "node:readline";
34

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

48+
type ProjectListResult = {
49+
items: Array<{
50+
id: string;
51+
name: string;
52+
}>;
53+
};
54+
4755
type ComposerDeployCommandResult = {
4856
summary: {
4957
app: string;
@@ -64,13 +72,17 @@ export type ComposerDeployResult = {
6472
};
6573
};
6674

67-
function redactSecrets(message: string): string {
75+
export function redactSecrets(message: string): string {
6876
return message
69-
.replace(/\b((?:prisma\+)?postgres(?:ql)?:\/\/)[^\s'"]+/gi, "$1<redacted>")
7077
.replace(
71-
/\b([A-Z0-9_]*(?:DATABASE_URL|TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*=)[^\s]+/g,
78+
/\b((?:(?:prisma\+)?postgres(?:ql)?|mongodb(?:\+srv)?):\/\/)[^\s'"]+/gi,
7279
"$1<redacted>",
73-
);
80+
)
81+
.replace(
82+
/\b([A-Z0-9_]*(?:MONGODB_(?:URL|URI)|DATABASE_URL|TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s]+)/gi,
83+
"$1<redacted>",
84+
)
85+
.replace(/(\bAuthorization\s*:\s*Bearer\s+)[^\s'"]+/gi, "$1<redacted>");
7486
}
7587

7688
function getErrorMessage(error: unknown): string {
@@ -123,22 +135,28 @@ async function runPrismaJsonCommand<Result>(options: {
123135
packageManager: PackageManager;
124136
projectDir: string;
125137
args: string[];
126-
forwardStderr?: boolean;
138+
onStderrLine?: (line: string) => void;
127139
}): Promise<Result> {
128140
const invocation = getPrismaCliArgs(options.packageManager, [
129141
...options.args,
130142
"--json",
131143
"--no-interactive",
132144
]);
133-
const result = await execa(invocation.command, invocation.args, {
145+
const subprocess = execa(invocation.command, invocation.args, {
134146
cwd: options.projectDir,
135147
env: process.env,
136148
reject: false,
137149
});
138-
139-
if (options.forwardStderr && result.stderr) {
140-
process.stderr.write(result.stderr.endsWith("\n") ? result.stderr : `${result.stderr}\n`);
141-
}
150+
const stderrLines =
151+
options.onStderrLine && subprocess.stderr
152+
? (async () => {
153+
const lines = createInterface({ input: subprocess.stderr });
154+
for await (const line of lines) {
155+
if (line.trim()) options.onStderrLine?.(line);
156+
}
157+
})()
158+
: Promise.resolve();
159+
const [result] = await Promise.all([subprocess, stderrLines]);
142160

143161
let envelope: PrismaCliEnvelope<Result>;
144162
try {
@@ -159,6 +177,36 @@ async function runPrismaJsonCommand<Result>(options: {
159177
return envelope.result;
160178
}
161179

180+
export function findProjectNameCollisions(
181+
projects: ProjectListResult["items"],
182+
appName: string,
183+
): ProjectListResult["items"] {
184+
return projects.filter((project) => project.name === appName);
185+
}
186+
187+
async function ensureProjectNameAvailable(options: {
188+
appName: string;
189+
packageManager: PackageManager;
190+
projectDir: string;
191+
workspace: PrismaWorkspace;
192+
}): Promise<void> {
193+
const result = await runPrismaJsonCommand<ProjectListResult>({
194+
packageManager: options.packageManager,
195+
projectDir: options.projectDir,
196+
args: ["project", "list"],
197+
});
198+
const collisions = findProjectNameCollisions(result.items, options.appName);
199+
if (collisions.length === 0) return;
200+
201+
const projectIds = collisions.map((project) => project.id).join(", ");
202+
throw new Error(
203+
`A Prisma project named "${options.appName}" already exists in workspace ${workspaceLabel(
204+
options.workspace,
205+
)} (${options.workspace.id}). Choose a different project name or delete the existing project ` +
206+
`(${projectIds}) in Prisma Console, then retry.`,
207+
);
208+
}
209+
162210
async function ensureAuthentication(
163211
packageManager: PackageManager,
164212
projectDir: string,
@@ -326,7 +374,11 @@ async function getProjectDetails(options: {
326374
}
327375
}
328376

329-
export async function deployWithComposer(options: {
377+
/**
378+
* Performs the optional one-shot deployment at the end of a create-prisma scaffold.
379+
* Generated projects use their own `deploy` script for every subsequent deployment.
380+
*/
381+
export async function deployNewProjectWithComposer(options: {
330382
appName: string;
331383
packageManager: PackageManager;
332384
projectDir: string;
@@ -335,6 +387,7 @@ export async function deployWithComposer(options: {
335387
workspace?: string;
336388
}): Promise<ComposerDeployResult | undefined> {
337389
const progress = options.verbose ? undefined : spinner();
390+
let deploymentLog: ReturnType<typeof taskLog> | undefined;
338391
let progressRunning = false;
339392
const showProgress = (message: string) => {
340393
if (!progress) return;
@@ -373,6 +426,15 @@ export async function deployWithComposer(options: {
373426
});
374427
if (!selectedWorkspace) return;
375428

429+
showProgress("Checking Prisma project name...");
430+
if (options.verbose) log.step("Checking Prisma project name.");
431+
await ensureProjectNameAvailable({
432+
appName: options.appName,
433+
packageManager: options.packageManager,
434+
projectDir: options.projectDir,
435+
workspace: selectedWorkspace,
436+
});
437+
376438
showProgress("Building for deployment...");
377439
if (options.verbose) log.step("Building for deployment.");
378440
const build = getRunScriptArgs(options.packageManager, "build");
@@ -382,26 +444,48 @@ export async function deployWithComposer(options: {
382444
stdio: options.verbose ? "inherit" : "pipe",
383445
});
384446

385-
showProgress("Deploying to Prisma...");
386-
if (options.verbose) log.step("Deploying to Prisma.");
447+
clearProgress();
448+
const deployCommand = getPackageExecutionCommand(options.packageManager, [
449+
PRISMA_PLATFORM_CLI_PACKAGE,
450+
"deploy",
451+
"module.ts",
452+
]);
453+
if (options.verbose) {
454+
log.step(`Deploying to Prisma with ${deployCommand}.`);
455+
} else {
456+
deploymentLog = taskLog({ title: "Deploying to Prisma...", limit: 10 });
457+
deploymentLog.message(`$ ${deployCommand}`);
458+
}
387459
const deployment = parseComposerDeployResult(
388460
await runPrismaJsonCommand<ComposerDeployCommandResult>({
389461
packageManager: options.packageManager,
390462
projectDir: options.projectDir,
391463
args: ["deploy", "module.ts"],
392-
forwardStderr: options.verbose,
464+
onStderrLine: (line) => {
465+
const redactedLine = redactSecrets(line);
466+
if (options.verbose) {
467+
process.stderr.write(`${redactedLine}\n`);
468+
} else {
469+
deploymentLog?.message(redactedLine);
470+
}
471+
},
393472
}),
394473
);
395474
const appName = deployment?.appName ?? options.appName;
396475

397-
showProgress("Loading deployment details...");
476+
if (options.verbose) {
477+
log.step("Loading deployment details.");
478+
} else {
479+
deploymentLog?.message("Loading deployment details...");
480+
}
398481
const details = await getProjectDetails({
399482
packageManager: options.packageManager,
400483
projectDir: options.projectDir,
401484
appName,
402485
});
403486

404-
progress?.stop("Deployed to Prisma.");
487+
deploymentLog?.success("Deployed to Prisma.");
488+
deploymentLog = undefined;
405489
progressRunning = false;
406490
if (options.verbose) log.success("Deployed to Prisma.");
407491
const workspace = details?.workspace ?? selectedWorkspace;
@@ -412,7 +496,12 @@ export async function deployWithComposer(options: {
412496
project: details?.project ?? { name: appName },
413497
};
414498
} catch (error) {
415-
progress?.error("Deployment failed.");
499+
if (deploymentLog) {
500+
deploymentLog.error("Deployment failed.");
501+
deploymentLog = undefined;
502+
} else {
503+
progress?.error("Deployment failed.");
504+
}
416505
progressRunning = false;
417506
log.error(`Deploy failed: ${getErrorMessage(error)}`);
418507
return;

src/tasks/initialize-git.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { execa } from "execa";
2+
import fs from "fs-extra";
3+
import path from "node:path";
4+
5+
export type GitInitializationResult =
6+
| { status: "initialized" }
7+
| { status: "already-in-repository" }
8+
| { status: "skipped"; reason: string };
9+
10+
function errorMessage(error: unknown): string {
11+
if (error instanceof Error && "stderr" in error) {
12+
const stderr = String((error as { stderr?: string }).stderr ?? "").trim();
13+
if (stderr) return stderr;
14+
}
15+
return error instanceof Error ? error.message : String(error);
16+
}
17+
18+
/**
19+
* Initializes a standalone scaffold as a Git repository and records its generated files.
20+
* Projects created inside an existing repository remain part of that repository.
21+
*/
22+
export async function initializeGitRepository(
23+
projectDir: string,
24+
env: NodeJS.ProcessEnv = process.env,
25+
): Promise<GitInitializationResult> {
26+
try {
27+
const existing = await execa("git", ["rev-parse", "--is-inside-work-tree"], {
28+
cwd: projectDir,
29+
env,
30+
reject: false,
31+
});
32+
if (existing.exitCode === 0 && existing.stdout.trim() === "true") {
33+
return { status: "already-in-repository" };
34+
}
35+
} catch (error) {
36+
return { status: "skipped", reason: errorMessage(error) };
37+
}
38+
39+
let initialized = false;
40+
try {
41+
await execa("git", ["init"], { cwd: projectDir, env });
42+
initialized = true;
43+
await execa("git", ["add", "--all"], { cwd: projectDir, env });
44+
await execa("git", ["commit", "--no-verify", "-m", "Initial commit from create-prisma"], {
45+
cwd: projectDir,
46+
env,
47+
});
48+
return { status: "initialized" };
49+
} catch (error) {
50+
if (initialized) await fs.remove(path.join(projectDir, ".git"));
51+
return { status: "skipped", reason: errorMessage(error) };
52+
}
53+
}

src/tasks/setup-prisma.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import {
2222
getPackageExecutionArgs,
2323
getRunScriptCommand,
2424
} from "../utils/package-manager";
25-
import { deployWithComposer, type ComposerDeployResult } from "./deploy-with-composer";
25+
import { deployNewProjectWithComposer, type ComposerDeployResult } from "./deploy-with-composer";
26+
import { initializeGitRepository, type GitInitializationResult } from "./initialize-git";
2627
import { installProjectDependencies, writePrismaDependencies } from "./install";
2728

2829
const DEFAULT_DATABASE_PROVIDER: DatabaseProvider = "postgres";
@@ -40,6 +41,7 @@ type PrismaSetupRunOptions = {
4041
template?: CreateTemplate;
4142
createdProjectPath?: string;
4243
includeDevNextStep?: boolean;
44+
initializeGit?: boolean;
4345
progressSpinner?: ReturnType<typeof spinner>;
4446
};
4547

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

398401
try {
@@ -415,6 +418,10 @@ export async function executePrismaSetupContext(
415418
);
416419
await ensureComposerTypeScriptOptions(projectDir);
417420
if (context.databaseProvider === "mongo") await ensureMongoEnvironment(projectDir);
421+
if (context.packageManager !== "deno") {
422+
await ensureGitignoreEntry(projectDir, "/.alchemy");
423+
await ensureGitignoreEntry(projectDir, "/.prisma-composer");
424+
}
418425

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

429436
progress?.message("Generating Prisma 8 contract artifacts...");
430437
await emitContract(context, projectDir);
438+
439+
if (options.initializeGit) {
440+
progress?.message("Initializing Git repository...");
441+
gitInitialization = await initializeGitRepository(projectDir);
442+
}
431443
progress?.stop("Prisma 8 project ready.");
444+
if (gitInitialization?.status === "initialized" && context.verbose) {
445+
log.success("Initialized Git repository with an initial commit.");
446+
} else if (gitInitialization?.status === "skipped") {
447+
log.warn(`Could not initialize Git repository: ${gitInitialization.reason}`);
448+
}
432449
} catch (error) {
433450
progress?.error("Could not create Prisma 8 project.");
434451
cancel(getCommandErrorMessage(error));
@@ -437,7 +454,7 @@ export async function executePrismaSetupContext(
437454

438455
let deployment: ComposerDeployResult | undefined;
439456
if (context.shouldDeploy) {
440-
deployment = await deployWithComposer({
457+
deployment = await deployNewProjectWithComposer({
441458
appName: projectName,
442459
packageManager: context.packageManager,
443460
projectDir,

0 commit comments

Comments
 (0)