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
109 changes: 80 additions & 29 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@ import fs from "fs-extra";
import path from "node:path";
import type { Writable } from "node:stream";

import {
CreateCancellationError,
getCreateFailureReason,
type CreateFailureReason,
type CreateFailureStage,
} from "../create-outcome";
import {
CREATE_PRISMA_RESULT_SCHEMA_VERSION,
createCommandFailureResult,
type CreateCommandResult,
type CreateProjectResult,
} from "../result";
import {
trackCreateCompleted,
trackCreateFailed,
type CreateTelemetryFailureStage,
} from "../telemetry";
import { trackCreateCancelled, trackCreateCompleted, trackCreateFailed } from "../telemetry";
import { scaffoldCreateFrameworkTemplate } from "../templates/render-create-template";
import { writeCreateTemplateDependencies } from "../tasks/install";
import type { PrismaSetupContext } from "../tasks/setup-prisma";
Expand Down Expand Up @@ -51,14 +53,22 @@ type ExecuteCreateContextResult =
| { ok: true; result: CreateCommandResult }
| {
ok: false;
stage: CreateTelemetryFailureStage;
cancelled: true;
stage: "select_workspace";
errorReported?: boolean;
}
| {
ok: false;
cancelled?: false;
stage: CreateFailureStage;
reason: CreateFailureReason;
error?: unknown;
errorReported?: boolean;
};

type CollectCreateContextResult =
| { ok: true; context: CreatePromptContext }
| { ok: false; message: string };
| { ok: false; message: string; reason: CreateFailureReason };

function toPackageName(projectName: string): string {
return (
Expand Down Expand Up @@ -102,7 +112,7 @@ function getProjectResult(context: CreatePromptContext): CreateProjectResult {
};
}

async function promptForProjectName(output: Writable): Promise<string | undefined> {
async function promptForProjectName(output: Writable): Promise<string> {
const projectName = await text({
message: "Project name",
placeholder: DEFAULT_PROJECT_NAME,
Expand All @@ -113,13 +123,13 @@ async function promptForProjectName(output: Writable): Promise<string | undefine

if (isCancel(projectName)) {
cancel("Operation cancelled.", { output });
return undefined;
throw new CreateCancellationError("project_name");
}

return String(projectName).trim();
}

async function promptForCreateTemplate(output: Writable): Promise<CreateTemplate | undefined> {
async function promptForCreateTemplate(output: Writable): Promise<CreateTemplate> {
const template = await select({
message: "Select template",
initialValue: DEFAULT_TEMPLATE,
Expand Down Expand Up @@ -175,7 +185,7 @@ async function promptForCreateTemplate(output: Writable): Promise<CreateTemplate

if (isCancel(template)) {
cancel("Operation cancelled.", { output });
return undefined;
throw new CreateCancellationError("template");
}

return CreateTemplateSchema.parse(template);
Expand Down Expand Up @@ -213,7 +223,8 @@ export async function runCreateCommand(
const startedAt = Date.now();
let input: CreateCommandInput = {};
let context: CreatePromptContext | undefined;
let failureStage: CreateTelemetryFailureStage = "validate_input";
let failureStage: CreateFailureStage = "validate_input";
let failureReason: CreateFailureReason = "invalid_input";
const { output } = resolveExecutionSettings(rawInput);

try {
Expand All @@ -225,12 +236,19 @@ export async function runCreateCommand(
const message = getUnsupportedNodeMessage();
cancel(message, { output });
process.exitCode = 1;
await trackCreateFailed({
input,
durationMs: Date.now() - startedAt,
stage: failureStage,
reason: "unsupported_node_version",
});
return createCommandFailureResult(failureStage, message);
}

intro(getCreatePrismaIntro(), { output });

failureStage = "collect_context";
failureReason = "unexpected_error";
const collected = await collectCreateContext(input);
if (!collected.ok) {
process.exitCode = 1;
Expand All @@ -239,6 +257,7 @@ export async function runCreateCommand(
input,
durationMs: Date.now() - startedAt,
stage: failureStage,
reason: collected.reason,
});
return result;
}
Expand All @@ -248,6 +267,19 @@ export async function runCreateCommand(
const executionResult = await executeCreateContext(context);
if (!executionResult.ok) {
process.exitCode = 1;
if (executionResult.cancelled) {
await trackCreateCancelled({
input,
context,
durationMs: Date.now() - startedAt,
stage: executionResult.stage,
});
return createCommandFailureResult(
executionResult.stage,
"Operation cancelled.",
getProjectResult(context),
);
}
const message = executionResult.error
? getErrorMessage(executionResult.error)
: "Project setup did not complete.";
Expand All @@ -261,6 +293,7 @@ export async function runCreateCommand(
durationMs: Date.now() - startedAt,
error: executionResult.error,
stage: executionResult.stage,
reason: executionResult.reason,
});
return createCommandFailureResult(executionResult.stage, message, getProjectResult(context));
}
Expand All @@ -273,6 +306,19 @@ export async function runCreateCommand(
return executionResult.result;
} catch (error) {
process.exitCode = 1;
if (error instanceof CreateCancellationError) {
await trackCreateCancelled({
input,
context,
durationMs: Date.now() - startedAt,
stage: error.stage,
});
return createCommandFailureResult(
error.stage,
error.message,
context ? getProjectResult(context) : undefined,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
const message = getErrorMessage(error);
cancel(`Create command failed: ${message}`, { output });
await trackCreateFailed({
Expand All @@ -281,6 +327,7 @@ export async function runCreateCommand(
durationMs: Date.now() - startedAt,
error,
stage: failureStage,
reason: getCreateFailureReason(error, failureReason),
});
return createCommandFailureResult(
failureStage,
Expand All @@ -298,48 +345,40 @@ async function collectCreateContext(

const projectNameInput =
input.name ?? (useDefaults ? DEFAULT_PROJECT_NAME : await promptForProjectName(output));
if (projectNameInput === undefined) {
return { ok: false, message: "Operation cancelled." };
}

const projectName = String(projectNameInput).trim();
const projectNameValidationError = validateProjectName(projectName);
if (projectNameValidationError) {
cancel(projectNameValidationError, { output });
return { ok: false, message: projectNameValidationError };
return {
ok: false,
message: projectNameValidationError,
reason: "invalid_project_name",
};
}

const template =
input.template ?? (useDefaults ? DEFAULT_TEMPLATE : await promptForCreateTemplate(output));
if (!template) {
return { ok: false, message: "Operation cancelled." };
}

const targetDirectory = path.resolve(process.cwd(), projectName);
const targetPathState = await 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.`;
cancel(message, { output });
return { ok: false, message };
return { ok: false, message, reason: "target_path_not_directory" };
}
if (targetPathState.exists && !targetPathState.isEmptyDirectory && !force) {
const message = `Target directory ${formatPathForDisplay(
targetDirectory,
)} is not empty. Use --force to continue.`;
cancel(message, { output });
return { ok: false, message };
return { ok: false, message, reason: "target_directory_not_empty" };
}

const prismaSetupContext = await collectPrismaSetupContext(input, {
projectDir: targetDirectory,
template,
});
if (!prismaSetupContext) {
return { ok: false, message: "Operation cancelled." };
}

return {
ok: true,
context: {
Expand Down Expand Up @@ -382,6 +421,7 @@ async function executeCreateContext(
return {
ok: false,
stage: "scaffold_template",
reason: "template_scaffold_failed",
error,
};
}
Expand All @@ -397,6 +437,7 @@ async function executeCreateContext(
return {
ok: false,
stage: "scaffold_template",
reason: "template_scaffold_failed",
error,
};
}
Expand Down Expand Up @@ -430,9 +471,18 @@ async function executeCreateContext(
});

if (!setupResult.ok) {
if (setupResult.cancelled) {
return {
ok: false,
cancelled: true,
stage: setupResult.stage,
errorReported: setupResult.errorReported,
};
}
return {
ok: false,
stage: "prisma_setup",
stage: setupResult.stage,
reason: setupResult.reason,
error: setupResult.error,
errorReported: setupResult.errorReported,
};
Expand All @@ -456,7 +506,8 @@ async function executeCreateContext(
createSpinner?.error("Could not create Prisma 8 project.");
return {
ok: false,
stage: "prisma_setup",
stage: "unknown",
reason: getCreateFailureReason(error, "unexpected_error"),
error,
};
}
Expand Down
80 changes: 80 additions & 0 deletions src/create-outcome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
export type CreateFailureStage =
| "validate_input"
| "collect_context"
| "scaffold_template"
| "initialize_prisma"
| "configure_project"
| "install_dependencies"
| "initialize_agent_skills"
| "emit_contract"
| "plan_migration"
| "initialize_git"
| "authenticate"
| "select_workspace"
| "check_project_name"
| "build"
| "composer_deploy"
| "unknown";

export type CreateFailureReason =
| "invalid_input"
| "unsupported_node_version"
| "invalid_project_name"
| "target_path_not_directory"
| "target_directory_not_empty"
| "unsupported_configuration"
| "template_scaffold_failed"
| "prisma_init_failed"
| "project_configuration_failed"
| "dependency_install_failed"
| "agent_skills_init_failed"
| "contract_emit_failed"
| "migration_plan_failed"
| "git_initialization_failed"
| "prisma_auth_command_failed"
| "not_authenticated"
| "authentication_failed"
| "workspace_missing"
| "workspace_mismatch"
| "workspace_selection_failed"
| "project_lookup_failed"
| "project_name_collision"
| "build_failed"
| "composer_deploy_failed"
| "unexpected_error";

export type CreateCancellationStage =
| "project_name"
| "template"
| "database_provider"
| "authoring_style"
| "package_manager"
| "deployment_intent"
| "select_workspace";

export class CreateCancellationError extends Error {
readonly stage: CreateCancellationStage;

constructor(stage: CreateCancellationStage) {
super("Operation cancelled.");
this.name = "CreateCancellationError";
this.stage = stage;
}
}

export class ClassifiedCreateError extends Error {
readonly reason: CreateFailureReason;

constructor(reason: CreateFailureReason, message: string, options?: ErrorOptions) {
super(message, options);
this.name = "ClassifiedCreateError";
this.reason = reason;
}
}

export function getCreateFailureReason(
error: unknown,
fallback: CreateFailureReason,
): CreateFailureReason {
return error instanceof ClassifiedCreateError ? error.reason : fallback;
}
7 changes: 5 additions & 2 deletions src/result.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CreateTelemetryFailureStage } from "./telemetry";
import type { CreateCancellationStage, CreateFailureStage } from "./create-outcome";
import type { ComposerDeployResult } from "./tasks/deploy-with-composer";
import type { AuthoringStyle, CreateTemplate, DatabaseProvider, PackageManager } from "./types";

Expand All @@ -18,7 +18,10 @@ export type CreateProjectResult = {
packageManager: PackageManager;
};

export type CreateCommandFailureStage = CreateTelemetryFailureStage | "parse_arguments";
export type CreateCommandFailureStage =
| CreateFailureStage
| CreateCancellationStage
| "parse_arguments";

export type CreateCommandSuccessResult = {
schemaVersion: typeof CREATE_PRISMA_RESULT_SCHEMA_VERSION;
Expand Down
Loading
Loading