diff --git a/src/commands/create.ts b/src/commands/create.ts index 30fb56f..7b5187c 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -1,5 +1,6 @@ -import { cancel, intro, isCancel, log, select, spinner, text } from "@clack/prompts"; +import { cancel, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts"; import fs from "fs-extra"; +import os from "node:os"; import path from "node:path"; import { scaffoldCreateTemplate } from "../templates/render-create-template"; @@ -18,6 +19,12 @@ import { collectCreateAddonSetupContext, executeCreateAddonSetupContext, } from "../tasks/setup-addons"; +import { + collectComputeDeployContext, + executeComputeDeployContext, + type ComputeDeployContext, + type ComputeDeployResult, +} from "../tasks/deploy-to-compute"; import { trackCreateCompleted, trackCreateFailed, @@ -43,6 +50,7 @@ export type CreatePromptContext = { projectPackageName: string; prismaSetupContext: PrismaSetupContext; addonSetupContext?: CreateAddonSetupContext; + computeDeployContext?: ComputeDeployContext; }; type ExecuteCreateContextResult = @@ -84,6 +92,27 @@ function validateProjectName(value: string | undefined): string | undefined { return undefined; } +function formatDeployEnvFile(databaseUrl: string): string { + if (/[\r\n]/.test(databaseUrl)) { + throw new Error("DATABASE_URL must be a single-line value."); + } + + return `DATABASE_URL=${databaseUrl}\n`; +} + +async function createDeployEnvFile(databaseUrl: string): Promise<{ + path: string; + cleanup(): Promise; +}> { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "create-prisma-compute-env-")); + const envFilePath = path.join(tempDir, ".env"); + await fs.writeFile(envFilePath, formatDeployEnvFile(databaseUrl), "utf8"); + return { + path: envFilePath, + cleanup: () => fs.remove(tempDir), + }; +} + async function promptForProjectName(): Promise { const projectName = await text({ message: "Project name", @@ -305,14 +334,27 @@ async function collectCreateContext( return; } + const projectPackageName = toPackageName(path.basename(targetDirectory)); + + const computeDeployContext = await collectComputeDeployContext(input, { + template, + packageManager: prismaSetupContext.packageManager, + useDefaults, + defaultServiceName: projectPackageName, + }); + if (computeDeployContext === undefined) { + return; + } + return { targetDirectory, targetPathState, force, template, - projectPackageName: toPackageName(path.basename(targetDirectory)), + projectPackageName, prismaSetupContext, addonSetupContext: addonSetupContext ?? undefined, + computeDeployContext: computeDeployContext ?? undefined, }; } @@ -329,6 +371,7 @@ async function executeCreateContext( schemaPreset: context.prismaSetupContext.schemaPreset, provider: context.prismaSetupContext.databaseProvider, packageManager: context.prismaSetupContext.packageManager, + compute: Boolean(context.computeDeployContext), }); scaffoldSpinner.stop("Project files scaffolded."); } catch (error) { @@ -385,14 +428,15 @@ async function executeCreateContext( } } + let prismaResult: Awaited>; try { - const didSetupPrisma = await executePrismaSetupContext(context.prismaSetupContext, { + prismaResult = await executePrismaSetupContext(context.prismaSetupContext, { prependNextSteps: nextSteps, projectDir: context.targetDirectory, includeDevNextStep: true, }); - if (!didSetupPrisma) { + if (!prismaResult.ok) { return { ok: false, stage: "prisma_setup", @@ -406,5 +450,54 @@ async function executeCreateContext( }; } + let deployResult: ComputeDeployResult | undefined; + if (context.computeDeployContext) { + let deployEnvFile: Awaited> | undefined; + try { + if (prismaResult.databaseUrl) { + deployEnvFile = await createDeployEnvFile(prismaResult.databaseUrl); + } + + const result = await executeComputeDeployContext({ + context: context.computeDeployContext, + projectDir: context.targetDirectory, + envFilePath: deployEnvFile?.path, + }); + if (!result.ok && !result.cancelled) { + return { + ok: false, + stage: "compute_deploy", + error: result.error, + }; + } + if (result.ok) { + deployResult = result.result; + } + } catch (error) { + return { + ok: false, + stage: "compute_deploy", + error, + }; + } finally { + if (deployEnvFile) { + await deployEnvFile.cleanup().catch(() => undefined); + } + } + } + + const summaryLines: string[] = []; + summaryLines.push(`Setup complete.${prismaResult.warningSection}`); + if (deployResult) { + summaryLines.push( + "", + "Deployed to Prisma Compute:", + `- Service URL: ${deployResult.serviceUrl}`, + `- Version URL: ${deployResult.versionUrl}`, + ); + } + summaryLines.push("", "Next steps:", prismaResult.nextSteps.join("\n")); + outro(summaryLines.join("\n")); + return { ok: true }; } diff --git a/src/tasks/deploy-to-compute.ts b/src/tasks/deploy-to-compute.ts new file mode 100644 index 0000000..f572ebc --- /dev/null +++ b/src/tasks/deploy-to-compute.ts @@ -0,0 +1,507 @@ +import { cancel, confirm, isCancel, log, select, spinner, text } from "@clack/prompts"; +import { execa, type Options as ExecaOptions } from "execa"; + +import { + isComputeDeployableTemplate, + type CreateCommandInput, + type CreateTemplate, + type PackageManager, +} from "../types"; +import { getPackageExecutionArgs, getPackageExecutionCommand } from "../utils/package-manager"; + +// Mirrors sdk/src/types.ts:KNOWN_REGION_IDS. Drift risk: low — region list rarely +// changes; if it does, this falls out of sync until someone updates it. +const COMPUTE_REGIONS = [ + "us-east-1", + "us-west-1", + "eu-west-3", + "eu-central-1", + "ap-northeast-1", + "ap-southeast-1", +] as const; +const COMPUTE_CLI_PACKAGE = "@prisma/compute-cli"; + +type ComputeProject = { + id: string; + name: string; + defaultRegion?: string; +}; +type ProjectJsonResult = + | { ok: true; data: ComputeProject } + | { ok: false; error: { message?: string; name?: string } }; +type ProjectSelection = { type: "create" } | { type: "existing"; project: ComputeProject }; + +export type ComputeDeployContext = { + template: CreateTemplate; + packageManager: PackageManager; + projectId: string; + serviceName: string; + region: string; +}; + +export type ComputeDeployResult = { + serviceUrl: string; + versionUrl: string; + serviceId: string; + versionId: string; + projectId: string; + region: string; +}; + +function getComputeCliCommand(packageManager: PackageManager): string { + return getPackageExecutionCommand(packageManager, [COMPUTE_CLI_PACKAGE]); +} + +function runComputeCli(packageManager: PackageManager, args: string[], options: ExecaOptions = {}) { + const execution = getPackageExecutionArgs(packageManager, [COMPUTE_CLI_PACKAGE, ...args]); + return execa(execution.command, execution.args, options); +} + +async function isAuthenticated(packageManager: PackageManager): Promise { + try { + await runComputeCli(packageManager, ["projects", "list", "--json"], { stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +async function ensureComputeCliAvailable(packageManager: PackageManager): Promise { + try { + await runComputeCli(packageManager, ["--help"], { stdio: "pipe" }); + return true; + } catch (error) { + const command = getComputeCliCommand(packageManager); + const isMissing = + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: string }).code === "ENOENT"; + if (isMissing) { + log.warn(`Could not find the selected package manager. Re-run ${command} manually.`); + return false; + } + log.warn( + `Could not run ${command}${error instanceof Error ? `: ${redactSecrets(error.message)}` : "."}`, + ); + return false; + } +} + +async function fetchProjects(packageManager: PackageManager): Promise { + const { stdout } = await runComputeCli(packageManager, ["projects", "list", "--json"], { + stdio: "pipe", + }); + if (typeof stdout !== "string") { + throw new Error("Failed to list Compute projects: invalid command output"); + } + + const parsed = JSON.parse(stdout) as + | { ok: true; data: ComputeProject[] } + | { ok: false; error: { message?: string } }; + if (!parsed.ok) { + throw new Error(parsed.error?.message ?? "Failed to list Compute projects"); + } + return parsed.data; +} + +function parseProjectJson(stdout: unknown): ProjectJsonResult | null { + if (typeof stdout !== "string" || stdout.trim().length === 0) { + return null; + } + + try { + return JSON.parse(stdout) as ProjectJsonResult; + } catch { + return null; + } +} + +async function createComputeProject( + packageManager: PackageManager, + name: string, +): Promise { + try { + const { stdout } = await runComputeCli(packageManager, [ + "projects", + "create", + "--json", + "--name", + name, + ]); + const parsed = parseProjectJson(stdout); + if (!parsed) { + throw new Error("Could not parse Compute project creation output."); + } + if (!parsed.ok) { + throw new Error(parsed.error.message ?? "Failed to create Compute project."); + } + return parsed.data; + } catch (error) { + const parsed = parseProjectJson((error as { stdout?: unknown })?.stdout); + if (parsed?.ok) { + return parsed.data; + } + if (parsed && !parsed.ok) { + throw new Error(redactSecrets(parsed.error.message ?? "Failed to create Compute project.")); + } + throw new Error(getErrorMessage(error)); + } +} + +async function promptForNewProjectName(defaultProjectName: string): Promise { + const projectNameInput = await text({ + message: "Compute project name", + placeholder: defaultProjectName, + initialValue: defaultProjectName, + validate: (value) => { + if (!value || value.trim().length === 0) { + return "Project name is required"; + } + return undefined; + }, + }); + if (isCancel(projectNameInput)) { + cancel("Operation cancelled."); + return undefined; + } + return projectNameInput.trim(); +} + +async function createProjectFromPrompt(options: { + packageManager: PackageManager; + defaultProjectName: string; +}): Promise { + const projectName = await promptForNewProjectName(options.defaultProjectName); + if (!projectName) { + return undefined; + } + + const createSpinner = spinner(); + createSpinner.start("Creating Compute project..."); + try { + const project = await createComputeProject(options.packageManager, projectName); + createSpinner.stop(`Created Compute project: ${project.name} (${project.id})`); + return project; + } catch (error) { + createSpinner.error( + `Could not create Compute project${error instanceof Error ? `: ${redactSecrets(error.message)}` : "."}`, + ); + return undefined; + } +} + +export async function collectComputeDeployContext( + input: CreateCommandInput, + options: { + template: CreateTemplate; + packageManager: PackageManager; + useDefaults: boolean; + defaultServiceName: string; + }, +): Promise { + if (!isComputeDeployableTemplate(options.template)) { + return null; + } + + if (input.deploy === false) { + return null; + } + + let wantsDeploy: boolean; + if (input.deploy === true) { + wantsDeploy = true; + } else if (options.useDefaults) { + return null; + } else { + const confirmed = await confirm({ + message: "Deploy to Prisma Compute now?", + initialValue: true, + }); + if (isCancel(confirmed)) { + cancel("Operation cancelled."); + return undefined; + } + wantsDeploy = confirmed; + } + + if (!wantsDeploy) return null; + + if (!(await ensureComputeCliAvailable(options.packageManager))) { + if (input.deploy === true) { + throw createExplicitDeployError("the Compute CLI is not available"); + } + return null; + } + + if (!(await isAuthenticated(options.packageManager))) { + log.info("Authenticating with Prisma Compute..."); + try { + await runComputeCli(options.packageManager, ["login"], { stdio: "inherit" }); + } catch (error) { + log.warn( + `Compute login was not completed${error instanceof Error ? `: ${redactSecrets(error.message)}` : "."}`, + ); + if (input.deploy === true) { + throw createExplicitDeployError("authentication failed", error); + } + return null; + } + } + + let projects: ComputeProject[]; + try { + projects = await fetchProjects(options.packageManager); + } catch (error) { + log.warn( + `Could not list Compute projects${error instanceof Error ? `: ${redactSecrets(error.message)}` : "."}`, + ); + if (input.deploy === true) { + throw createExplicitDeployError("could not list Compute projects", error); + } + return null; + } + + let selectedProject: ComputeProject | undefined; + if (projects.length === 1) { + // biome-ignore lint/style/noNonNullAssertion: length === 1 + const only = projects[0]!; + const shouldUseExistingProject = await confirm({ + message: `Use Compute project ${only.name}?`, + initialValue: true, + }); + if (isCancel(shouldUseExistingProject)) { + cancel("Operation cancelled."); + return undefined; + } + if (shouldUseExistingProject) { + selectedProject = only; + } else { + selectedProject = await createProjectFromPrompt({ + packageManager: options.packageManager, + defaultProjectName: options.defaultServiceName, + }); + } + } else if (projects.length > 1) { + const sortedProjects = projects.slice().sort((a, b) => a.name.localeCompare(b.name)); + const selection = await select({ + message: "Select Compute project", + options: [ + { value: { type: "create" }, label: "Create new project" }, + ...sortedProjects.map((project) => ({ + value: { type: "existing" as const, project }, + label: project.name, + hint: project.id, + })), + ], + }); + if (isCancel(selection)) { + cancel("Operation cancelled."); + return undefined; + } + selectedProject = + selection.type === "create" + ? await createProjectFromPrompt({ + packageManager: options.packageManager, + defaultProjectName: options.defaultServiceName, + }) + : selection.project; + } else { + log.info("No Compute projects found."); + selectedProject = await createProjectFromPrompt({ + packageManager: options.packageManager, + defaultProjectName: options.defaultServiceName, + }); + } + + if (!selectedProject) { + if (input.deploy === true) { + throw createExplicitDeployError("no Compute project was selected or created"); + } + return null; + } + + const serviceNameInput = await text({ + message: "Service name", + placeholder: options.defaultServiceName, + initialValue: options.defaultServiceName, + validate: (value) => { + if (!value || value.trim().length === 0) { + return "Service name is required"; + } + return undefined; + }, + }); + if (isCancel(serviceNameInput)) { + cancel("Operation cancelled."); + return undefined; + } + const serviceName = serviceNameInput.trim(); + + const region = await select({ + message: "Region", + initialValue: selectedProject?.defaultRegion ?? COMPUTE_REGIONS[0], + options: COMPUTE_REGIONS.map((id) => ({ value: id, label: id })), + }); + if (isCancel(region)) { + cancel("Operation cancelled."); + return undefined; + } + + return { + template: options.template, + packageManager: options.packageManager, + projectId: selectedProject.id, + serviceName, + region, + }; +} + +type DeployJsonOk = { + ok: true; + data: { + projectId: string; + serviceId: string; + serviceName: string; + region: string; + versionId: string; + versionEndpointDomain: string; + serviceEndpointDomain: string | null; + }; +}; + +type DeployJsonErr = { + ok: false; + error: { message?: string; name?: string }; +}; + +type DeployJsonResult = DeployJsonOk | DeployJsonErr; + +function redactSecrets(message: string): string { + return message + .replace( + /(['"])([A-Z0-9_]*(?:DATABASE_URL|DIRECT_URL|TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY|ACCESS_KEY)[A-Z0-9_]*=)(.*?)\1/g, + "$1$2$1", + ) + .replace( + /\b([A-Z0-9_]*(?:DATABASE_URL|DIRECT_URL|TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY|ACCESS_KEY)[A-Z0-9_]*=)[^\s]+/g, + "$1", + ); +} + +function parseDeployJson(stdout: unknown): DeployJsonResult | null { + if (typeof stdout !== "string" || stdout.trim().length === 0) { + return null; + } + + try { + return JSON.parse(stdout) as DeployJsonResult; + } catch { + return null; + } +} + +function getErrorMessage(error: unknown): string { + return redactSecrets(error instanceof Error ? error.message : String(error)); +} + +function createDeployError(message: string | undefined): Error { + return new Error(redactSecrets(message ?? "unknown error")); +} + +function createExplicitDeployError(reason: string, error?: unknown): Error { + const detail = error instanceof Error ? `: ${redactSecrets(error.message)}` : ""; + return new Error(`Deploy requested but ${reason}${detail}`); +} + +function toUrl(domainOrUrl: string): string { + return /^https?:\/\//.test(domainOrUrl) ? domainOrUrl : `https://${domainOrUrl}`; +} + +function toComputeDeployResult(data: DeployJsonOk["data"]): ComputeDeployResult { + const serviceDomain = data.serviceEndpointDomain ?? data.versionEndpointDomain; + return { + serviceUrl: toUrl(serviceDomain), + versionUrl: toUrl(data.versionEndpointDomain), + serviceId: data.serviceId, + versionId: data.versionId, + projectId: data.projectId, + region: data.region, + }; +} + +export async function executeComputeDeployContext(params: { + context: ComputeDeployContext; + projectDir: string; + envFilePath?: string; + envVars?: Record; +}): Promise< + { ok: true; result: ComputeDeployResult } | { ok: false; cancelled: boolean; error?: unknown } +> { + const args = [ + "deploy", + "--json", + "--project", + params.context.projectId, + "--service-name", + params.context.serviceName, + "--region", + params.context.region, + ]; + + if (params.envFilePath) { + args.push("--env", params.envFilePath); + } + + for (const [key, value] of Object.entries(params.envVars ?? {})) { + args.push("--env", `${key}=${value}`); + } + + const deploySpinner = spinner(); + deploySpinner.start("Deploying to Prisma Compute..."); + + try { + const { stdout, exitCode } = await runComputeCli(params.context.packageManager, args, { + cwd: params.projectDir, + reject: false, + stdio: ["ignore", "pipe", "pipe"], + }); + + const parsed = parseDeployJson(stdout); + if (!parsed) { + deploySpinner.error("Deploy failed: could not parse compute deploy output."); + return { ok: false, cancelled: false, error: new Error("Invalid compute deploy output") }; + } + + if (exitCode !== 0 || !parsed.ok) { + const error = createDeployError(parsed.ok ? "Compute deploy failed." : parsed.error.message); + deploySpinner.error(`Deploy failed: ${error.message}`); + return { ok: false, cancelled: false, error }; + } + + deploySpinner.stop("Deployed to Prisma Compute."); + return { + ok: true, + result: toComputeDeployResult(parsed.data), + }; + } catch (error) { + const parsed = parseDeployJson((error as { stdout?: unknown })?.stdout); + if (parsed?.ok) { + deploySpinner.stop("Deployed to Prisma Compute."); + return { + ok: true, + result: toComputeDeployResult(parsed.data), + }; + } + + if (parsed && !parsed.ok) { + const deployError = createDeployError(parsed.error.message); + deploySpinner.error(`Deploy failed: ${deployError.message}`); + return { ok: false, cancelled: false, error: deployError }; + } + + const message = getErrorMessage(error); + deploySpinner.error(`Deploy failed${message ? `: ${message}` : "."}`); + return { ok: false, cancelled: false, error: new Error(message) }; + } +} diff --git a/src/tasks/setup-prisma.ts b/src/tasks/setup-prisma.ts index e4be080..4e04391 100644 --- a/src/tasks/setup-prisma.ts +++ b/src/tasks/setup-prisma.ts @@ -1,4 +1,4 @@ -import { cancel, confirm, isCancel, log, outro, select, spinner } from "@clack/prompts"; +import { cancel, confirm, isCancel, log, select, spinner } from "@clack/prompts"; import { execa } from "execa"; import fs from "fs-extra"; import path from "node:path"; @@ -54,6 +54,7 @@ export type PrismaSetupContext = { shouldUsePrismaPostgres: boolean; packageManager: PackageManager; shouldInstall: boolean; + shouldMigrateAndSeed: boolean; }; type FinalizePrismaOptions = { @@ -68,6 +69,7 @@ const DEFAULT_SCHEMA_PRESET: SchemaPreset = "empty"; const DEFAULT_PRISMA_POSTGRES = true; const DEFAULT_INSTALL = true; const DEFAULT_GENERATE = true; +const DEFAULT_MIGRATE_AND_SEED = true; const requiredPrismaFileGroups = [ ["prisma/schema.prisma", "packages/db/prisma/schema.prisma"], @@ -191,6 +193,20 @@ async function promptForDependencyInstall( return Boolean(shouldInstall); } +async function promptForMigrateAndSeed(): Promise { + const shouldMigrateAndSeed = await confirm({ + message: "Run an initial migration and seed your database now?", + initialValue: DEFAULT_MIGRATE_AND_SEED, + }); + + if (isCancel(shouldMigrateAndSeed)) { + cancel("Operation cancelled."); + return undefined; + } + + return Boolean(shouldMigrateAndSeed); +} + async function promptForPrismaPostgres(): Promise { const shouldUsePrismaPostgres = await confirm({ message: "Use Prisma Postgres and auto-generate DATABASE_URL with create-db?", @@ -265,6 +281,17 @@ export async function collectPrismaSetupContext( return; } + // migrate + seed needs deps installed and a generated client; skip the prompt + // if either is off, since the user opted out of the prerequisites. + const canMigrateAndSeed = shouldInstall && shouldGenerate; + const shouldMigrateAndSeed = !canMigrateAndSeed + ? false + : (input.migrateAndSeed ?? + (useDefaults ? DEFAULT_MIGRATE_AND_SEED : await promptForMigrateAndSeed())); + if (shouldMigrateAndSeed === undefined) { + return; + } + return { projectDir, verbose, @@ -275,6 +302,7 @@ export async function collectPrismaSetupContext( shouldUsePrismaPostgres, packageManager, shouldInstall, + shouldMigrateAndSeed, }; } @@ -620,6 +648,7 @@ async function generatePrismaClientForContext( function buildWarningLines( provisionWarning: string | undefined, generateWarning: string | undefined, + migrateAndSeedWarning?: string, ): string[] { const warningLines: string[] = []; @@ -629,6 +658,9 @@ function buildWarningLines( if (generateWarning) { warningLines.push(`- ${generateWarning}`); } + if (migrateAndSeedWarning) { + warningLines.push(`- ${migrateAndSeedWarning}`); + } return warningLines; } @@ -637,8 +669,10 @@ function buildNextStepsForContext(opts: { context: PrismaSetupContext; options: PrismaSetupRunOptions; didGenerateClient: boolean; + didMigrate: boolean; + didSeed: boolean; }): string[] { - const { context, options, didGenerateClient } = opts; + const { context, options, didGenerateClient, didMigrate, didSeed } = opts; const nextSteps: string[] = [...(options.prependNextSteps ?? [])]; if (!context.shouldInstall) { @@ -647,8 +681,12 @@ function buildNextStepsForContext(opts: { if (!didGenerateClient || !context.shouldGenerate) { nextSteps.push(`- ${getRunScriptCommand(context.packageManager, "db:generate")}`); } - nextSteps.push(`- ${getRunScriptCommand(context.packageManager, "db:migrate")}`); - nextSteps.push(`- ${getRunScriptCommand(context.packageManager, "db:seed")}`); + if (!didMigrate) { + nextSteps.push(`- ${getRunScriptCommand(context.packageManager, "db:migrate")}`); + } + if (!didSeed) { + nextSteps.push(`- ${getRunScriptCommand(context.packageManager, "db:seed")}`); + } if (options.includeDevNextStep) { nextSteps.push(`- ${getRunScriptCommand(context.packageManager, "dev")}`); } @@ -656,24 +694,33 @@ function buildNextStepsForContext(opts: { return nextSteps; } +export type PrismaSetupResult = + | { ok: false } + | { + ok: true; + nextSteps: string[]; + warningSection: string; + databaseUrl?: string; + }; + export async function executePrismaSetupContext( context: PrismaSetupContext, options: PrismaSetupRunOptions = {}, -): Promise { +): Promise { const projectDir = path.resolve(options.projectDir ?? context.projectDir); const provisionResult = await provisionPrismaPostgresIfNeeded(context, projectDir); if (!provisionResult) { - return false; + return { ok: false }; } const didWriteDependencies = await writeDependenciesForContext(context, projectDir); if (!didWriteDependencies) { - return false; + return { ok: false }; } const dependenciesInstalled = await installDependenciesForContext(context, projectDir); if (!dependenciesInstalled) { - return false; + return { ok: false }; } const didFinalizePrismaFiles = await finalizePrismaFilesForContext( @@ -682,24 +729,119 @@ export async function executePrismaSetupContext( provisionResult, ); if (!didFinalizePrismaFiles) { - return false; + return { ok: false }; } + const databaseUrl = + provisionResult.databaseUrl ?? + context.databaseUrl ?? + getDefaultDatabaseUrl(context.databaseProvider); + const generateResult = await generatePrismaClientForContext(context, projectDir); - const warningLines = buildWarningLines(provisionResult.warning, generateResult.warning); + const migrateAndSeedResult = await migrateAndSeedIfRequested(context, projectDir, { + databaseUrl, + didGenerateClient: generateResult.didGenerateClient, + }); + + const warningLines = buildWarningLines( + provisionResult.warning, + generateResult.warning, + migrateAndSeedResult.warning, + ); const nextSteps = buildNextStepsForContext({ context, options, didGenerateClient: generateResult.didGenerateClient, + didMigrate: migrateAndSeedResult.didMigrate, + didSeed: migrateAndSeedResult.didSeed, }); const warningSection = warningLines.length > 0 ? `\n\n${warningLines.join("\n")}` : ""; - outro(`Setup complete.${warningSection} + return { + ok: true, + nextSteps, + warningSection, + databaseUrl, + }; +} + +async function migrateAndSeedIfRequested( + context: PrismaSetupContext, + projectDir: string, + options: { databaseUrl?: string; didGenerateClient: boolean }, +): Promise<{ didMigrate: boolean; didSeed: boolean; warning?: string }> { + const prismaProjectDir = await resolvePrismaProjectDir(projectDir); + + if (!context.shouldMigrateAndSeed) { + return { didMigrate: false, didSeed: false }; + } + if (!options.didGenerateClient) { + return { + didMigrate: false, + didSeed: false, + warning: "Skipped migrate + seed because the Prisma Client was not generated.", + }; + } + if (!options.databaseUrl) { + return { + didMigrate: false, + didSeed: false, + warning: "Skipped migrate + seed because no DATABASE_URL is available.", + }; + } + + const migrateInvocation = getPrismaCliArgs(context.packageManager, [ + "migrate", + "dev", + "--name", + "init", + ]); + const seedInvocation = getPrismaCliArgs(context.packageManager, ["db", "seed"]); -Next steps: -${nextSteps.join("\n")}`); + const migrateSpinner = spinner(); + migrateSpinner.start("Creating and applying initial migration..."); + let didMigrate = false; + try { + // Just-provisioned Prisma Postgres can briefly reject connections (P1017). + // Give it a moment before the first connection attempt. + if (context.shouldUsePrismaPostgres) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + await execa(migrateInvocation.command, migrateInvocation.args, { + cwd: prismaProjectDir, + stdio: context.verbose ? "inherit" : "pipe", + }); + migrateSpinner.stop("Initial migration applied."); + didMigrate = true; + } catch (error) { + migrateSpinner.error(`Migration failed${error instanceof Error ? `: ${error.message}` : "."}`); + return { + didMigrate: false, + didSeed: false, + warning: "Migration failed; run `prisma migrate dev --name init` manually.", + }; + } + + const seedSpinner = spinner(); + seedSpinner.start("Seeding database..."); + let didSeed = false; + try { + await execa(seedInvocation.command, seedInvocation.args, { + cwd: prismaProjectDir, + stdio: context.verbose ? "inherit" : "pipe", + }); + seedSpinner.stop("Database seeded."); + didSeed = true; + } catch (error) { + seedSpinner.error(`Seed failed${error instanceof Error ? `: ${error.message}` : "."}`); + return { + didMigrate, + didSeed: false, + warning: "Seed failed; run `prisma db seed` manually.", + }; + } - return true; + return { didMigrate, didSeed }; } diff --git a/src/telemetry/create.ts b/src/telemetry/create.ts index d200d27..b6c9b8a 100644 --- a/src/telemetry/create.ts +++ b/src/telemetry/create.ts @@ -9,6 +9,7 @@ export type CreateTelemetryFailureStage = | "scaffold_template" | "addons" | "prisma_setup" + | "compute_deploy" | "unknown"; function getRequestedAddons(input: CreateCommandInput): string[] { diff --git a/src/templates/render-create-template.ts b/src/templates/render-create-template.ts index 56c69ab..9d3a33f 100644 --- a/src/templates/render-create-template.ts +++ b/src/templates/render-create-template.ts @@ -6,6 +6,7 @@ type CreateTemplateContext = { provider: DatabaseProvider; schemaPreset: SchemaPreset; packageManager?: PackageManager; + compute: boolean; }; function getCreateTemplateDir(template: CreateTemplate): string { @@ -16,13 +17,15 @@ function createTemplateContext( projectName: string, provider: DatabaseProvider, schemaPreset: SchemaPreset, - packageManager?: PackageManager, + packageManager: PackageManager | undefined, + compute: boolean, ): CreateTemplateContext { return { projectName, provider, schemaPreset, packageManager, + compute, }; } @@ -33,10 +36,17 @@ export async function scaffoldCreateTemplate(opts: { provider: DatabaseProvider; schemaPreset: SchemaPreset; packageManager?: PackageManager; + compute?: boolean; }): Promise { const { projectDir, projectName, template, provider, schemaPreset, packageManager } = opts; const templateRoot = getCreateTemplateDir(template); - const context = createTemplateContext(projectName, provider, schemaPreset, packageManager); + const context = createTemplateContext( + projectName, + provider, + schemaPreset, + packageManager, + opts.compute === true, + ); await renderTemplateTree({ templateRoot, outputDir: projectDir, diff --git a/src/templates/shared.ts b/src/templates/shared.ts index a758078..69f9c4d 100644 --- a/src/templates/shared.ts +++ b/src/templates/shared.ts @@ -10,7 +10,7 @@ import { getRuntimeScriptCommand, getRunScriptCommand, } from "../utils/package-manager"; -import { requiresDotenvConfigImport } from "../utils/runtime"; +import { requiresDotenvConfigImport, requiresPrismaConfigDotenvImport } from "../utils/runtime"; function getOptionalHashString( hash: Handlebars.HelperOptions["hash"], @@ -39,6 +39,10 @@ Handlebars.registerHelper( "requiresDotenvConfigImport", (packageManager: PackageManager | undefined) => requiresDotenvConfigImport(packageManager), ); +Handlebars.registerHelper( + "requiresPrismaConfigDotenvImport", + (packageManager: PackageManager | undefined) => requiresPrismaConfigDotenvImport(packageManager), +); Handlebars.registerHelper( "runtimeScript", ( diff --git a/src/types.ts b/src/types.ts index 6770e15..5671042 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,12 @@ export const PrismaSetupOptionsSchema = z.object({ databaseUrl: DatabaseUrlSchema.optional().describe("DATABASE_URL value"), install: z.boolean().optional().describe("Install dependencies with selected package manager"), generate: z.boolean().optional().describe("Generate Prisma Client after scaffolding"), + migrateAndSeed: z + .boolean() + .optional() + .describe( + "Run prisma migrate dev --name init and then prisma db seed after generating the client", + ), schemaPreset: SchemaPresetSchema.optional().describe( "Schema preset to scaffold in prisma/schema.prisma", ), @@ -89,9 +95,23 @@ export const CreateScaffoldOptionsSchema = z.object({ skills: z.boolean().optional().describe("Enable skills addon"), mcp: z.boolean().optional().describe("Enable MCP addon"), extension: z.boolean().optional().describe("Enable extension addon"), + deploy: z.boolean().optional().describe("Deploy the scaffolded project to Prisma Compute"), force: z.boolean().optional().describe("Allow scaffolding into a non-empty target directory"), }); +export const COMPUTE_DEPLOYABLE_TEMPLATES: ReadonlySet = new Set([ + "hono", + "elysia", + "next", + "astro", + "nuxt", + "tanstack-start", +]); + +export function isComputeDeployableTemplate(template: CreateTemplate): boolean { + return COMPUTE_DEPLOYABLE_TEMPLATES.has(template); +} + export const CreateCommandInputSchema = PrismaSetupCommandInputSchema.extend( CreateScaffoldOptionsSchema.shape, ); diff --git a/src/utils/package-manager.ts b/src/utils/package-manager.ts index 9240507..f3a6601 100644 --- a/src/utils/package-manager.ts +++ b/src/utils/package-manager.ts @@ -281,10 +281,6 @@ export function getPrismaCliArgs( packageManager: PackageManager, prismaArgs: string[], ): CommandAndArgs { - if (packageManager === "bun") { - return getPackageExecutionArgs(packageManager, ["--bun", "prisma", ...prismaArgs]); - } - if (packageManager === "deno") { return { command: "deno", diff --git a/src/utils/runtime.ts b/src/utils/runtime.ts index 1c65ade..edd7e0b 100644 --- a/src/utils/runtime.ts +++ b/src/utils/runtime.ts @@ -7,3 +7,9 @@ export function usesNodeStyleRuntime(packageManager: PackageManager | undefined) export function requiresDotenvConfigImport(packageManager: PackageManager | undefined): boolean { return usesNodeStyleRuntime(packageManager); } + +export function requiresPrismaConfigDotenvImport( + packageManager: PackageManager | undefined, +): boolean { + return packageManager !== "deno"; +} diff --git a/templates/create/astro/astro.config.mjs b/templates/create/astro/astro.config.mjs deleted file mode 100644 index 17f6a62..0000000 --- a/templates/create/astro/astro.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-check -import { defineConfig } from "astro/config"; - -// https://astro.build/config -export default defineConfig({}); diff --git a/templates/create/astro/astro.config.mjs.hbs b/templates/create/astro/astro.config.mjs.hbs new file mode 100644 index 0000000..0809532 --- /dev/null +++ b/templates/create/astro/astro.config.mjs.hbs @@ -0,0 +1,10 @@ +// @ts-check +import { defineConfig } from "astro/config"; +import node from "@astrojs/node"; + +// https://astro.build/config +export default defineConfig({ + output: "server", + adapter: node({ mode: "standalone" }), + server: { host: true }, +}); diff --git a/templates/create/astro/package.json.hbs b/templates/create/astro/package.json.hbs index d15f961..3e04380 100644 --- a/templates/create/astro/package.json.hbs +++ b/templates/create/astro/package.json.hbs @@ -12,7 +12,8 @@ "astro": "astro" }, "dependencies": { - "astro": "^5.17.1" + "astro": "^5.17.1", + "@astrojs/node": "^9.5.5" }, "devDependencies": { "@types/node": "^24.3.0", diff --git a/templates/create/elysia/package.json.hbs b/templates/create/elysia/package.json.hbs index c847dfd..263df32 100644 --- a/templates/create/elysia/package.json.hbs +++ b/templates/create/elysia/package.json.hbs @@ -5,6 +5,7 @@ "packageManager": "{{packageManagerManifestValue packageManager}}", {{/if}} "type": "module", + "main": "src/index.ts", "scripts": { "dev": "{{runtimeScript packageManager "dev" "src/index.ts" "dist/src/index.js" denoFlags="--unstable-net"}}", "build": "{{runtimeScript packageManager "build" "src/index.ts" "dist/src/index.js"}}", diff --git a/templates/create/elysia/prisma.config.ts.hbs b/templates/create/elysia/prisma.config.ts.hbs index 84a43f7..d39f498 100644 --- a/templates/create/elysia/prisma.config.ts.hbs +++ b/templates/create/elysia/prisma.config.ts.hbs @@ -1,4 +1,4 @@ -{{#if (requiresDotenvConfigImport packageManager)}} +{{#if (requiresPrismaConfigDotenvImport packageManager)}} import "dotenv/config"; {{/if}} import { defineConfig, env } from "prisma/config"; diff --git a/templates/create/elysia/src/index.ts.hbs b/templates/create/elysia/src/index.ts.hbs index 35e0b82..2c1b875 100644 --- a/templates/create/elysia/src/index.ts.hbs +++ b/templates/create/elysia/src/index.ts.hbs @@ -13,7 +13,7 @@ import { prisma } from "./lib/prisma{{#if (eq packageManager "deno")}}.ts{{/if}} const rawPort = ({{#if (eq packageManager "deno")}}Deno.env.get("PORT"){{else}}process.env.PORT{{/if}} ?? "").trim(); const parsedPort = rawPort.length > 0 ? Number(rawPort) : Number.NaN; const port = - Number.isInteger(parsedPort) && parsedPort >= 0 && parsedPort <= 65535 ? parsedPort : 3000; + Number.isInteger(parsedPort) && parsedPort >= 0 && parsedPort <= 65535 ? parsedPort : 8080; const app = new Elysia({{#if (eq packageManager "deno")}}{{else}}{ adapter: node() }{{/if}}) .get("/", () => { diff --git a/templates/create/hono/package.json.hbs b/templates/create/hono/package.json.hbs index 3fed7f2..573e17f 100644 --- a/templates/create/hono/package.json.hbs +++ b/templates/create/hono/package.json.hbs @@ -5,6 +5,7 @@ "packageManager": "{{packageManagerManifestValue packageManager}}", {{/if}} "type": "module", + "main": "src/index.ts", "scripts": { "dev": "{{runtimeScript packageManager "dev" "src/index.ts" "dist/src/index.js"}}", "build": "{{runtimeScript packageManager "build" "src/index.ts" "dist/src/index.js"}}", diff --git a/templates/create/hono/prisma.config.ts.hbs b/templates/create/hono/prisma.config.ts.hbs index 84a43f7..d39f498 100644 --- a/templates/create/hono/prisma.config.ts.hbs +++ b/templates/create/hono/prisma.config.ts.hbs @@ -1,4 +1,4 @@ -{{#if (requiresDotenvConfigImport packageManager)}} +{{#if (requiresPrismaConfigDotenvImport packageManager)}} import "dotenv/config"; {{/if}} import { defineConfig, env } from "prisma/config"; diff --git a/templates/create/hono/src/index.ts.hbs b/templates/create/hono/src/index.ts.hbs index e620ab8..2858833 100644 --- a/templates/create/hono/src/index.ts.hbs +++ b/templates/create/hono/src/index.ts.hbs @@ -31,7 +31,7 @@ app.get("/users", async (c) => { const rawPort = ({{#if (eq packageManager "deno")}}Deno.env.get("PORT"){{else}}process.env.PORT{{/if}} ?? "").trim(); const parsedPort = rawPort.length > 0 ? Number(rawPort) : Number.NaN; const port = - Number.isInteger(parsedPort) && parsedPort >= 0 && parsedPort <= 65535 ? parsedPort : 3000; + Number.isInteger(parsedPort) && parsedPort >= 0 && parsedPort <= 65535 ? parsedPort : 8080; serve({ fetch: app.fetch, port, diff --git a/templates/create/nest/prisma.config.ts.hbs b/templates/create/nest/prisma.config.ts.hbs index 84a43f7..d39f498 100644 --- a/templates/create/nest/prisma.config.ts.hbs +++ b/templates/create/nest/prisma.config.ts.hbs @@ -1,4 +1,4 @@ -{{#if (requiresDotenvConfigImport packageManager)}} +{{#if (requiresPrismaConfigDotenvImport packageManager)}} import "dotenv/config"; {{/if}} import { defineConfig, env } from "prisma/config"; diff --git a/templates/create/next/next.config.ts b/templates/create/next/next.config.ts.hbs similarity index 72% rename from templates/create/next/next.config.ts rename to templates/create/next/next.config.ts.hbs index d742c15..68a6c64 100644 --- a/templates/create/next/next.config.ts +++ b/templates/create/next/next.config.ts.hbs @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - // Add Next config here when needed. + output: "standalone", }; export default nextConfig; diff --git a/templates/create/tanstack-start/package.json.hbs b/templates/create/tanstack-start/package.json.hbs index 35a3b58..1927030 100644 --- a/templates/create/tanstack-start/package.json.hbs +++ b/templates/create/tanstack-start/package.json.hbs @@ -7,27 +7,27 @@ {{/if}} "type": "module", "scripts": { - "dev": "vite dev --port 3000", + "dev": "vite dev", "build": "vite build", + "start": "node .output/server/index.mjs", "preview": "vite preview", "typecheck": "tsc --noEmit" }, "dependencies": { - "@tanstack/react-router": "^1.132.0", - "@tanstack/react-start": "^1.132.0", + "@tanstack/react-router": "^1.167.42", + "@tanstack/react-start": "^1.167.42", + "nitro": "^3.0.260415-beta", "react": "^19.2.0", "react-dom": "^19.2.0" }, "devDependencies": { - "@tanstack/router-plugin": "^1.132.0", "@types/node": "^24.3.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^6.0.0", "tsx": "^4.7.1", "typescript": "^5.9.3", - "vite": "^7.3.1", - "vite-tsconfig-paths": "^5.1.4" + "vite": "^8.0.8" } } diff --git a/templates/create/tanstack-start/vite.config.ts b/templates/create/tanstack-start/vite.config.ts index 9de7e28..0b23849 100644 --- a/templates/create/tanstack-start/vite.config.ts +++ b/templates/create/tanstack-start/vite.config.ts @@ -1,8 +1,16 @@ import { defineConfig } from "vite"; import viteReact from "@vitejs/plugin-react"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; -import tsconfigPaths from "vite-tsconfig-paths"; +import { nitro } from "nitro/vite"; export default defineConfig({ - plugins: [tsconfigPaths({ projects: ["./tsconfig.json"] }), tanstackStart(), viteReact()], + resolve: { + tsconfigPaths: true, + }, + plugins: [ + tanstackStart(), + nitro(), + // react's vite plugin must come after start's vite plugin + viteReact(), + ], });