@prisma-next/cli 0.5.0-dev.2 → 0.5.0-dev.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +54 -21
  2. package/dist/agent-skill-mongo.md +63 -31
  3. package/dist/agent-skill-postgres.md +1 -1
  4. package/dist/{cli-errors-C0JhVj0c.d.mts → cli-errors-BJLUczXT.d.mts} +1 -0
  5. package/dist/cli-errors-By1iVE3z.mjs +34 -0
  6. package/dist/cli-errors-By1iVE3z.mjs.map +1 -0
  7. package/dist/cli.mjs +127 -13
  8. package/dist/cli.mjs.map +1 -1
  9. package/dist/{client-TG7rbCWT.mjs → client-enZIahga.mjs} +20 -5
  10. package/dist/client-enZIahga.mjs.map +1 -0
  11. package/dist/commands/contract-emit.d.mts.map +1 -1
  12. package/dist/commands/contract-emit.mjs +7 -2
  13. package/dist/commands/contract-infer.mjs +8 -2
  14. package/dist/commands/db-init.mjs +9 -8
  15. package/dist/commands/db-init.mjs.map +1 -1
  16. package/dist/commands/db-schema.mjs +8 -5
  17. package/dist/commands/db-schema.mjs.map +1 -1
  18. package/dist/commands/db-sign.mjs +8 -7
  19. package/dist/commands/db-sign.mjs.map +1 -1
  20. package/dist/commands/db-update.mjs +9 -8
  21. package/dist/commands/db-update.mjs.map +1 -1
  22. package/dist/commands/db-verify.mjs +10 -9
  23. package/dist/commands/db-verify.mjs.map +1 -1
  24. package/dist/commands/migration-apply.d.mts +1 -1
  25. package/dist/commands/migration-apply.d.mts.map +1 -1
  26. package/dist/commands/migration-apply.mjs +15 -38
  27. package/dist/commands/migration-apply.mjs.map +1 -1
  28. package/dist/commands/migration-new.d.mts.map +1 -1
  29. package/dist/commands/migration-new.mjs +24 -28
  30. package/dist/commands/migration-new.mjs.map +1 -1
  31. package/dist/commands/migration-plan.d.mts +6 -3
  32. package/dist/commands/migration-plan.d.mts.map +1 -1
  33. package/dist/commands/migration-plan.mjs +38 -38
  34. package/dist/commands/migration-plan.mjs.map +1 -1
  35. package/dist/commands/migration-ref.d.mts +6 -4
  36. package/dist/commands/migration-ref.d.mts.map +1 -1
  37. package/dist/commands/migration-ref.mjs +31 -40
  38. package/dist/commands/migration-ref.mjs.map +1 -1
  39. package/dist/commands/migration-show.d.mts +4 -4
  40. package/dist/commands/migration-show.d.mts.map +1 -1
  41. package/dist/commands/migration-show.mjs +19 -26
  42. package/dist/commands/migration-show.mjs.map +1 -1
  43. package/dist/commands/migration-status.d.mts +5 -4
  44. package/dist/commands/migration-status.d.mts.map +1 -1
  45. package/dist/commands/migration-status.mjs +7 -2
  46. package/dist/{config-loader-_W4T21X1.mjs → config-loader-ih8ViDb_.mjs} +2 -2
  47. package/dist/config-loader-ih8ViDb_.mjs.map +1 -0
  48. package/dist/config-loader.mjs +1 -1
  49. package/dist/contract-emit-DS5NzZh2.mjs +6 -0
  50. package/dist/contract-emit-DWtGQYCD.mjs +150 -0
  51. package/dist/contract-emit-DWtGQYCD.mjs.map +1 -0
  52. package/dist/contract-emit-RZBWzkop.mjs +329 -0
  53. package/dist/contract-emit-RZBWzkop.mjs.map +1 -0
  54. package/dist/{contract-enrichment-CGW6mm-E.mjs → contract-enrichment-4Ptgw3Pe.mjs} +1 -1
  55. package/dist/{contract-enrichment-CGW6mm-E.mjs.map → contract-enrichment-4Ptgw3Pe.mjs.map} +1 -1
  56. package/dist/{contract-infer-BP3DrGgz.mjs → contract-infer-BjzkcwQt.mjs} +5 -5
  57. package/dist/{contract-infer-BP3DrGgz.mjs.map → contract-infer-BjzkcwQt.mjs.map} +1 -1
  58. package/dist/exports/control-api.d.mts +41 -16
  59. package/dist/exports/control-api.d.mts.map +1 -1
  60. package/dist/exports/control-api.mjs +7 -5
  61. package/dist/exports/index.mjs +8 -3
  62. package/dist/exports/index.mjs.map +1 -1
  63. package/dist/exports/init-output.d.mts +39 -0
  64. package/dist/exports/init-output.d.mts.map +1 -0
  65. package/dist/exports/init-output.mjs +3 -0
  66. package/dist/{extract-operation-statements-DZUJNmL3.mjs → extract-operation-statements-CU-Pp4-N.mjs} +2 -2
  67. package/dist/{extract-operation-statements-DZUJNmL3.mjs.map → extract-operation-statements-CU-Pp4-N.mjs.map} +1 -1
  68. package/dist/{extract-sql-ddl-DDMX-9mz.mjs → extract-sql-ddl-Bm0Mm0IT.mjs} +1 -1
  69. package/dist/{extract-sql-ddl-DDMX-9mz.mjs.map → extract-sql-ddl-Bm0Mm0IT.mjs.map} +1 -1
  70. package/dist/{framework-components-DfZKQBQ2.mjs → framework-components-Bgcre3Z6.mjs} +2 -2
  71. package/dist/{framework-components-DfZKQBQ2.mjs.map → framework-components-Bgcre3Z6.mjs.map} +1 -1
  72. package/dist/init-C-H-if1m.mjs +2062 -0
  73. package/dist/init-C-H-if1m.mjs.map +1 -0
  74. package/dist/{inspect-live-schema-DWzf4Q_m.mjs → inspect-live-schema-QklSDLt_.mjs} +6 -6
  75. package/dist/{inspect-live-schema-DWzf4Q_m.mjs.map → inspect-live-schema-QklSDLt_.mjs.map} +1 -1
  76. package/dist/migration-cli.mjs +15 -8
  77. package/dist/migration-cli.mjs.map +1 -1
  78. package/dist/{migration-command-scaffold-CLMD302g.mjs → migration-command-scaffold-BfloSWPZ.mjs} +7 -7
  79. package/dist/{migration-command-scaffold-CLMD302g.mjs.map → migration-command-scaffold-BfloSWPZ.mjs.map} +1 -1
  80. package/dist/{migration-status-B0HLF7So.mjs → migration-status-C5VYA5r9.mjs} +21 -35
  81. package/dist/migration-status-C5VYA5r9.mjs.map +1 -0
  82. package/dist/{migrations-B0dOQlk0.mjs → migrations-CSaDHNpB.mjs} +3 -3
  83. package/dist/migrations-CSaDHNpB.mjs.map +1 -0
  84. package/dist/output-BiO7kt87.mjs +103 -0
  85. package/dist/output-BiO7kt87.mjs.map +1 -0
  86. package/dist/{progress-adapter-B-YvmcDu.mjs → progress-adapter-DgRGldpT.mjs} +1 -1
  87. package/dist/{progress-adapter-B-YvmcDu.mjs.map → progress-adapter-DgRGldpT.mjs.map} +1 -1
  88. package/dist/quick-reference-mongo.md +34 -13
  89. package/dist/quick-reference-postgres.md +11 -9
  90. package/dist/{result-handler-CIyu0Pdt.mjs → result-handler-BmVh8AeV.mjs} +12 -93
  91. package/dist/result-handler-BmVh8AeV.mjs.map +1 -0
  92. package/dist/{terminal-ui-C5k88MmW.mjs → terminal-ui-u2YgKghu.mjs} +76 -2
  93. package/dist/terminal-ui-u2YgKghu.mjs.map +1 -0
  94. package/dist/{verify-BxiVp50b.mjs → verify-BumcH6Ry.mjs} +2 -2
  95. package/dist/{verify-BxiVp50b.mjs.map → verify-BumcH6Ry.mjs.map} +1 -1
  96. package/package.json +20 -15
  97. package/src/commands/contract-emit.ts +67 -163
  98. package/src/commands/init/detect-pnpm-catalog.ts +141 -0
  99. package/src/commands/init/errors.ts +254 -0
  100. package/src/commands/init/exit-codes.ts +62 -0
  101. package/src/commands/init/hygiene-gitattributes.ts +97 -0
  102. package/src/commands/init/hygiene-gitignore.ts +48 -0
  103. package/src/commands/init/hygiene-package-scripts.ts +91 -0
  104. package/src/commands/init/index.ts +112 -7
  105. package/src/commands/init/init.ts +766 -144
  106. package/src/commands/init/inputs.ts +421 -0
  107. package/src/commands/init/output.ts +147 -0
  108. package/src/commands/init/probe-db.ts +308 -0
  109. package/src/commands/init/reinit-cleanup.ts +83 -0
  110. package/src/commands/init/templates/agent-skill-mongo.md +63 -31
  111. package/src/commands/init/templates/agent-skill-postgres.md +1 -1
  112. package/src/commands/init/templates/agent-skill.ts +25 -3
  113. package/src/commands/init/templates/code-templates.ts +125 -32
  114. package/src/commands/init/templates/env.ts +80 -0
  115. package/src/commands/init/templates/quick-reference-mongo.md +34 -13
  116. package/src/commands/init/templates/quick-reference-postgres.md +11 -9
  117. package/src/commands/init/templates/quick-reference.ts +42 -3
  118. package/src/commands/init/templates/tsconfig.ts +167 -5
  119. package/src/commands/migration-apply.ts +15 -50
  120. package/src/commands/migration-new.ts +24 -28
  121. package/src/commands/migration-plan.ts +58 -42
  122. package/src/commands/migration-ref.ts +40 -54
  123. package/src/commands/migration-show.ts +27 -28
  124. package/src/commands/migration-status.ts +33 -50
  125. package/src/config-path-validation.ts +0 -1
  126. package/src/control-api/operations/contract-emit.ts +198 -115
  127. package/src/control-api/operations/migration-apply.ts +15 -0
  128. package/src/control-api/types.ts +22 -3
  129. package/src/exports/control-api.ts +2 -1
  130. package/src/exports/init-output.ts +10 -0
  131. package/src/migration-cli.ts +16 -9
  132. package/src/utils/cli-errors.ts +45 -1
  133. package/src/utils/command-helpers.ts +13 -26
  134. package/src/utils/emit-queue.ts +26 -0
  135. package/src/utils/formatters/graph-migration-mapper.ts +2 -2
  136. package/src/utils/formatters/migrations.ts +2 -2
  137. package/src/utils/publish-contract-artifact-pair.ts +134 -0
  138. package/dist/cli-errors-DHq6GQGu.mjs +0 -5
  139. package/dist/client-TG7rbCWT.mjs.map +0 -1
  140. package/dist/config-loader-_W4T21X1.mjs.map +0 -1
  141. package/dist/contract-emit-CNYyzJwF.mjs +0 -195
  142. package/dist/contract-emit-CNYyzJwF.mjs.map +0 -1
  143. package/dist/contract-emit-CQfj7xJn.mjs +0 -122
  144. package/dist/contract-emit-CQfj7xJn.mjs.map +0 -1
  145. package/dist/contract-emit-fhNwwhkQ.mjs +0 -4
  146. package/dist/init-CQfo_4Ro.mjs +0 -430
  147. package/dist/init-CQfo_4Ro.mjs.map +0 -1
  148. package/dist/migration-status-B0HLF7So.mjs.map +0 -1
  149. package/dist/migrations-B0dOQlk0.mjs.map +0 -1
  150. package/dist/result-handler-CIyu0Pdt.mjs.map +0 -1
  151. package/dist/terminal-ui-C5k88MmW.mjs.map +0 -1
  152. package/dist/validate-contract-deps-esa-VQ0h.mjs +0 -37
  153. package/dist/validate-contract-deps-esa-VQ0h.mjs.map +0 -1
@@ -1,8 +1,11 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { promisify } from 'node:util';
4
4
  import * as clack from '@clack/prompts';
5
- import { dirname, extname, isAbsolute, join, normalize } from 'pathe';
5
+ import { dirname, isAbsolute, join } from 'pathe';
6
+ import { CliStructuredError } from '../../utils/cli-errors';
7
+ import { formatErrorJson, formatErrorOutput } from '../../utils/formatters/errors';
8
+ import type { GlobalFlags } from '../../utils/global-flags';
6
9
  import { TerminalUI } from '../../utils/terminal-ui';
7
10
  import {
8
11
  detectPackageManager,
@@ -10,194 +13,813 @@ import {
10
13
  formatAddDevArgs,
11
14
  formatRunCommand,
12
15
  hasProjectManifest,
16
+ type PackageManager,
13
17
  } from './detect-package-manager';
14
- import { agentSkillMd } from './templates/agent-skill';
18
+ import { detectPnpmCatalogOverrides, type PnpmCatalogOverride } from './detect-pnpm-catalog';
19
+ import {
20
+ errorInitEmitFailed,
21
+ errorInitInstallFailed,
22
+ errorInitInvalidManifest,
23
+ errorInitInvalidTsconfig,
24
+ errorInitMissingManifest,
25
+ errorInitProbeFailed,
26
+ } from './errors';
15
27
  import {
16
- type AuthoringId,
17
- configFile,
18
- dbFile,
19
- defaultSchemaPath,
20
- starterSchema,
21
- type TargetId,
22
- targetPackageName,
23
- } from './templates/code-templates';
28
+ INIT_EXIT_EMIT_FAILED,
29
+ INIT_EXIT_INSTALL_FAILED,
30
+ INIT_EXIT_INTERNAL_ERROR,
31
+ INIT_EXIT_OK,
32
+ INIT_EXIT_PRECONDITION,
33
+ INIT_EXIT_USER_ABORTED,
34
+ } from './exit-codes';
35
+ import { mergeGitattributes, requiredGitattributesLines } from './hygiene-gitattributes';
36
+ import { mergeGitignore } from './hygiene-gitignore';
37
+ import { mergePackageScripts, REQUIRED_SCRIPTS } from './hygiene-package-scripts';
38
+ import { type InitFlagOptions, type ResolvedInitInputs, resolveInitInputs } from './inputs';
39
+ import {
40
+ buildNextSteps,
41
+ formatInitJson,
42
+ type InitOutput,
43
+ InitOutputSchema,
44
+ renderInitOutro,
45
+ } from './output';
46
+ import { type ProbeOutcome, type ProbeOverrides, probeServerVersion } from './probe-db';
47
+ import { findStaleArtefacts, removeDependency } from './reinit-cleanup';
48
+ import { agentSkillMd } from './templates/agent-skill';
49
+ import { configFile, dbFile, starterSchema, targetPackageName } from './templates/code-templates';
50
+ import { envExampleContent, envFileContent, MIN_SERVER_VERSION } from './templates/env';
24
51
  import { quickReferenceMd } from './templates/quick-reference';
25
- import { defaultTsConfig, mergeTsConfig } from './templates/tsconfig';
26
-
27
- export interface InitOptions {
28
- readonly noInstall?: boolean;
29
- }
52
+ import { defaultTsConfig, mergeTsConfig, TsConfigParseError } from './templates/tsconfig';
30
53
 
31
54
  interface FileEntry {
32
55
  readonly path: string;
33
56
  readonly content: string;
57
+ /**
58
+ * Optional human-mode message printed *after* the file is written —
59
+ * matches the legacy `Updated tsconfig.json with required compiler
60
+ * options.` line emitted when an existing tsconfig is merged. Kept
61
+ * with the entry so the precondition phase decides what to say and
62
+ * the write phase remains a dumb loop (FR6.2 atomicity).
63
+ */
64
+ readonly logMessage?: string;
65
+ }
66
+
67
+ interface InstallReport {
68
+ readonly skipped: boolean;
69
+ readonly deps: readonly string[];
70
+ readonly devDeps: readonly string[];
71
+ readonly warnings: readonly string[];
34
72
  }
35
73
 
36
- export async function runInit(baseDir: string, options: InitOptions): Promise<void> {
37
- const ui = new TerminalUI();
74
+ /**
75
+ * Runs the `init` command end-to-end and returns the exit code. Catches
76
+ * structured CLI errors raised at every phase (input resolution, install,
77
+ * emit) and renders them via the same UI surface as success output
78
+ * (`--json` to stdout, human to stderr). Exit codes follow the documented
79
+ * stable set in `./exit-codes.ts` (FR1.6) and the
80
+ * [Style Guide § Exit Codes](../../../../../../../docs/CLI%20Style%20Guide.md#exit-codes).
81
+ *
82
+ * Layered for testability: the action handler in `./index.ts` is
83
+ * responsible for parsing flags and constructing `runOptions`; this
84
+ * function does no flag parsing of its own.
85
+ */
86
+ export async function runInit(
87
+ baseDir: string,
88
+ runOptions: {
89
+ readonly options: InitFlagOptions;
90
+ readonly flags: GlobalFlags;
91
+ /**
92
+ * Whether `init` may render an interactive prompt. Decoupled from
93
+ * `flags.interactive` (which gates `TerminalUI` decoration / stdout
94
+ * mode) — see [Style Guide § Interactivity](../../../../../../../docs/CLI%20Style%20Guide.md#interactivity).
95
+ */
96
+ readonly canPrompt: boolean;
97
+ /**
98
+ * FR8.3 — test-only seam for the optional database version probe.
99
+ * Production callers omit this; tests inject stub `probePostgres` /
100
+ * `probeMongo` functions so the probe contract (env handling,
101
+ * comparator, message formatting, `--strict-probe` escalation) can
102
+ * be exercised without a live database. Never read at runtime by a
103
+ * user invocation of the CLI.
104
+ */
105
+ readonly probeOverrides?: ProbeOverrides;
106
+ },
107
+ ): Promise<number> {
108
+ const { options, flags, canPrompt, probeOverrides } = runOptions;
109
+ const ui = new TerminalUI({ color: flags.color, interactive: flags.interactive });
110
+ const warnings: string[] = [];
111
+ const filesWritten: string[] = [];
112
+ const filesDeleted: string[] = [];
38
113
 
39
- clack.intro('prisma-next init', { output: process.stderr });
114
+ if (!flags.json && !flags.quiet) {
115
+ clack.intro('prisma-next init', { output: process.stderr });
116
+ }
40
117
 
41
118
  if (!hasProjectManifest(baseDir)) {
42
- ui.error(
43
- 'No package.json or deno.json found. Initialize your project first (e.g. npm init or deno init), then re-run prisma-next init.',
44
- );
45
- process.exit(1);
119
+ return emitError(ui, flags, errorInitMissingManifest());
120
+ }
121
+
122
+ let inputs: ResolvedInitInputs;
123
+ try {
124
+ inputs = await resolveInitInputs({ baseDir, options, flags, canPrompt });
125
+ } catch (error) {
126
+ if (CliStructuredError.is(error)) {
127
+ return emitError(ui, flags, error);
128
+ }
129
+ throw error;
46
130
  }
47
131
 
48
132
  const pm = await detectPackageManager(baseDir);
49
133
  const pkgRun = formatRunCommand(pm, 'prisma-next', '').trimEnd();
50
134
 
51
- if (existsSync(join(baseDir, 'prisma-next.config.ts'))) {
52
- const reinit = await clack.confirm({
53
- message:
54
- 'This project is already initialized. Re-initialize? This will overwrite all generated files.',
55
- initialValue: false,
56
- output: process.stderr,
57
- });
58
- if (clack.isCancel(reinit) || !reinit) {
59
- clack.cancel('Init cancelled.', { output: process.stderr });
60
- process.exit(0);
61
- }
62
- }
63
-
64
- const targetResult = await clack.select({
65
- message: 'What database are you using?',
66
- options: [
67
- { value: 'postgres' as TargetId, label: 'PostgreSQL' },
68
- { value: 'mongo' as TargetId, label: 'MongoDB' },
69
- ],
70
- output: process.stderr,
71
- });
72
- if (clack.isCancel(targetResult)) {
73
- clack.cancel('Init cancelled.', { output: process.stderr });
74
- process.exit(0);
75
- }
76
- const target = targetResult as TargetId;
77
-
78
- const authoringResult = await clack.select({
79
- message: 'How do you want to write your schema?',
80
- options: [
81
- { value: 'psl' as AuthoringId, label: 'Prisma Schema Language (.prisma)' },
82
- { value: 'typescript' as AuthoringId, label: 'TypeScript (.ts)' },
83
- ],
84
- output: process.stderr,
85
- });
86
- if (clack.isCancel(authoringResult)) {
87
- clack.cancel('Init cancelled.', { output: process.stderr });
88
- process.exit(0);
89
- }
90
- const authoring = authoringResult as AuthoringId;
91
-
92
- const schemaPathResult = await clack.text({
93
- message: 'Where should the schema file go?',
94
- initialValue: defaultSchemaPath(authoring),
95
- validate(value = '') {
96
- const trimmed = value.trim();
97
- if (trimmed.length === 0) return 'Path cannot be empty';
98
- if (trimmed.endsWith('/') || trimmed.endsWith('\\'))
99
- return 'Path must be a file, not a directory';
100
- if (!extname(trimmed)) return 'Path must include a file extension (e.g. .prisma or .ts)';
101
- return undefined;
135
+ const schemaDir = dirname(inputs.schemaPath);
136
+ const configContractPath = isAbsolute(inputs.schemaPath)
137
+ ? inputs.schemaPath
138
+ : `./${inputs.schemaPath}`;
139
+
140
+ // -----------------------------------------------------------------
141
+ // Precondition phase (FR6.2 / NFR3 atomicity)
142
+ //
143
+ // Read every file we may need to merge with, parse it, compute the
144
+ // merged content, and accumulate the full set of writes — *before*
145
+ // touching the filesystem. A failure here (malformed package.json,
146
+ // unparseable tsconfig.json, …) returns a structured error and the
147
+ // user's project on disk stays byte-identical to its pre-init state.
148
+ // -----------------------------------------------------------------
149
+ const filesToWrite: FileEntry[] = [
150
+ { path: inputs.schemaPath, content: starterSchema(inputs.target, inputs.authoring) },
151
+ {
152
+ path: 'prisma-next.config.ts',
153
+ content: configFile(inputs.target, configContractPath),
154
+ },
155
+ { path: join(schemaDir, 'db.ts'), content: dbFile(inputs.target) },
156
+ {
157
+ path: 'prisma-next.md',
158
+ content: quickReferenceMd(inputs.target, inputs.authoring, inputs.schemaPath, pkgRun),
102
159
  },
103
- output: process.stderr,
104
- });
105
- if (clack.isCancel(schemaPathResult)) {
106
- clack.cancel('Init cancelled.', { output: process.stderr });
107
- process.exit(0);
108
- }
109
- const schemaPath = normalize((schemaPathResult as string).trim());
110
-
111
- const schemaDir = dirname(schemaPath);
112
- const configPath = isAbsolute(schemaPath) ? schemaPath : `./${schemaPath}`;
113
-
114
- const files: FileEntry[] = [
115
- { path: schemaPath, content: starterSchema(target, authoring) },
116
- { path: 'prisma-next.config.ts', content: configFile(target, configPath) },
117
- { path: join(schemaDir, 'db.ts'), content: dbFile(target) },
118
- { path: 'prisma-next.md', content: quickReferenceMd(target, schemaPath, pkgRun) },
119
160
  {
120
161
  path: '.agents/skills/prisma-next/SKILL.md',
121
- content: agentSkillMd(target, schemaPath, pkgRun),
162
+ content: agentSkillMd(inputs.target, inputs.authoring, inputs.schemaPath, pkgRun),
122
163
  },
164
+ { path: '.env.example', content: envExampleContent(inputs.target) },
123
165
  ];
124
166
 
125
- for (const file of files) {
126
- const fullPath = join(baseDir, file.path);
127
- mkdirSync(dirname(fullPath), { recursive: true });
128
- writeFileSync(fullPath, file.content, 'utf-8');
167
+ // FR9.1 on re-init, queue the previously-emitted contract artefacts
168
+ // for deletion so a target switch (or schema-shape change) does not
169
+ // leave a stale `contract.json` / `contract.d.ts` next to the new
170
+ // schema source. Detection is filesystem-only (no parsing of the
171
+ // previous config) so the cleanup is safe to run before the write
172
+ // phase: each path is checked for existence in the precondition,
173
+ // and missing-on-disk-at-write-time is tolerated.
174
+ const filesToDelete: string[] = inputs.reinit ? [...findStaleArtefacts(baseDir, schemaDir)] : [];
175
+
176
+ // FR3.2: a real `.env` is only written when the user opted in. Never
177
+ // overwrite an existing `.env` — secrets live there and clobbering
178
+ // them is the most damaging possible side-effect of `init`.
179
+ if (inputs.writeEnv) {
180
+ if (!existsSync(join(baseDir, '.env'))) {
181
+ filesToWrite.push({ path: '.env', content: envFileContent(inputs.target) });
182
+ } else {
183
+ warnings.push(
184
+ '.env already exists; leaving it untouched. Compare with .env.example for any new keys.',
185
+ );
186
+ }
129
187
  }
130
188
 
189
+ // FR2.2 / FR6.1: tsconfig.json gets the minimum compiler options the
190
+ // scaffolded files need. JSONC (TS's actual configured dialect) is
191
+ // accepted; an unparseable file is mapped to a structured
192
+ // precondition error (5011) rather than crashing mid-write.
131
193
  const tsconfigPath = join(baseDir, 'tsconfig.json');
132
194
  if (existsSync(tsconfigPath)) {
133
195
  const existing = readFileSync(tsconfigPath, 'utf-8');
134
- writeFileSync(tsconfigPath, mergeTsConfig(existing), 'utf-8');
135
- ui.log('Updated tsconfig.json with required compiler options.');
196
+ let merged: string;
197
+ try {
198
+ merged = mergeTsConfig(existing);
199
+ } catch (err) {
200
+ if (err instanceof TsConfigParseError) {
201
+ return emitError(
202
+ ui,
203
+ flags,
204
+ errorInitInvalidTsconfig({ path: 'tsconfig.json', cause: err.message }),
205
+ );
206
+ }
207
+ throw err;
208
+ }
209
+ filesToWrite.push({
210
+ path: 'tsconfig.json',
211
+ content: merged,
212
+ logMessage: 'Updated tsconfig.json with required compiler options.',
213
+ });
136
214
  } else {
137
- writeFileSync(tsconfigPath, defaultTsConfig(), 'utf-8');
215
+ filesToWrite.push({ path: 'tsconfig.json', content: defaultTsConfig() });
138
216
  }
139
217
 
140
- const emitCommand = formatRunCommand(pm, 'prisma-next', 'contract emit');
218
+ // FR3.3: idempotent .gitignore append only what's missing.
219
+ const gitignorePath = join(baseDir, '.gitignore');
220
+ const existingGitignore = existsSync(gitignorePath)
221
+ ? readFileSync(gitignorePath, 'utf-8')
222
+ : undefined;
223
+ const newGitignore = mergeGitignore(existingGitignore);
224
+ if (newGitignore !== null) {
225
+ filesToWrite.push({ path: '.gitignore', content: newGitignore });
226
+ }
141
227
 
142
- if (options.noInstall) {
143
- const pkg = targetPackageName(target);
144
- ui.note(
145
- [
146
- 'Run the following commands to complete setup:',
147
- '',
148
- ' 1. Install dependencies:',
149
- ` ${pm} ${formatAddArgs(pm, [pkg, 'dotenv']).join(' ')}`,
150
- ` ${pm} ${formatAddDevArgs(pm, ['prisma-next']).join(' ')}`,
151
- '',
152
- ' 2. Emit the contract:',
153
- ` ${emitCommand}`,
154
- ].join('\n'),
155
- 'Manual steps',
228
+ // FR3.4: idempotent .gitattributes — linguist-generated entries for
229
+ // the emitted artefacts so GitHub diff stats / code review collapse
230
+ // them by default.
231
+ const gitattributesPath = join(baseDir, '.gitattributes');
232
+ const existingGitattributes = existsSync(gitattributesPath)
233
+ ? readFileSync(gitattributesPath, 'utf-8')
234
+ : undefined;
235
+ const newGitattributes = mergeGitattributes(
236
+ existingGitattributes,
237
+ requiredGitattributesLines(schemaDir, inputs.target),
238
+ );
239
+ if (newGitattributes !== null) {
240
+ filesToWrite.push({ path: '.gitattributes', content: newGitattributes });
241
+ }
242
+
243
+ // Read + parse package.json once for both the FR3.5 scripts merge and
244
+ // the FR2.1 `@types/node`-presence check. A malformed manifest is
245
+ // mapped to a structured precondition error (5010) rather than the
246
+ // generic INTERNAL_ERROR fallback so CI/agents can branch on it.
247
+ const packageJsonPath = join(baseDir, 'package.json');
248
+ let parsedPackageJson: Record<string, unknown> | null = null;
249
+ if (existsSync(packageJsonPath)) {
250
+ const pkgRaw = readFileSync(packageJsonPath, 'utf-8');
251
+ try {
252
+ parsedPackageJson = JSON.parse(pkgRaw) as Record<string, unknown>;
253
+ } catch (err) {
254
+ if (err instanceof SyntaxError) {
255
+ return emitError(
256
+ ui,
257
+ flags,
258
+ errorInitInvalidManifest({ path: 'package.json', cause: err.message }),
259
+ );
260
+ }
261
+ throw err;
262
+ }
263
+
264
+ // package.json edits are chained: FR9.2 facade-dep removal first
265
+ // (so the script merge sees the cleaned `dependencies` and rounds
266
+ // out a single re-stringification), then FR3.5 / FR9.3 idempotent
267
+ // scripts merge with collision detection.
268
+ let workingPkg = pkgRaw;
269
+ let pkgChanged = false;
270
+ if (inputs.removePreviousFacade !== null) {
271
+ const next = removeDependency(workingPkg, inputs.removePreviousFacade);
272
+ if (next !== null) {
273
+ workingPkg = next;
274
+ pkgChanged = true;
275
+ }
276
+ }
277
+ const { content: nextPkg, warnings: scriptWarnings } = mergePackageScripts(
278
+ workingPkg,
279
+ REQUIRED_SCRIPTS,
156
280
  );
157
- } else {
158
- const pkg = targetPackageName(target);
159
- const spinner = ui.spinner();
160
- let installSucceeded = false;
281
+ if (nextPkg !== null) {
282
+ workingPkg = nextPkg;
283
+ pkgChanged = true;
284
+ }
285
+ if (pkgChanged) {
286
+ filesToWrite.push({ path: 'package.json', content: workingPkg });
287
+ }
288
+ warnings.push(...scriptWarnings);
289
+ }
161
290
 
162
- const exec = promisify(execFile);
291
+ // -----------------------------------------------------------------
292
+ // Write phase — every input has been parsed and every merged file is
293
+ // staged. From here on, failures are only possible at the
294
+ // install/emit stages, which the spec treats as discrete subsequent
295
+ // phases (FR6.3): scaffold files remain on disk so the user can fix
296
+ // and retry.
297
+ // -----------------------------------------------------------------
298
+ for (const file of filesToWrite) {
299
+ const fullPath = join(baseDir, file.path);
300
+ mkdirSync(dirname(fullPath), { recursive: true });
301
+ writeFileSync(fullPath, file.content, 'utf-8');
302
+ filesWritten.push(file.path);
303
+ if (file.logMessage !== undefined && !flags.json && !flags.quiet) {
304
+ ui.log(file.logMessage);
305
+ }
306
+ }
163
307
 
164
- spinner.start(`Installing ${pkg}, dotenv, and prisma-next...`);
308
+ // FR9.1 delete stale artefacts after the new templates are written.
309
+ // Order is intentional: the names do not collide with `filesToWrite`
310
+ // (we never write `contract.json` from this command — that's `contract
311
+ // emit`'s job), so deletion *after* the writes guarantees we never
312
+ // remove a file we just produced. `existsSync` was checked in the
313
+ // precondition phase, but a concurrent `git checkout` could have
314
+ // already removed the file — `unlinkSync` would then throw ENOENT,
315
+ // which we tolerate as the user-visible end state we wanted anyway.
316
+ for (const rel of filesToDelete) {
317
+ const fullPath = join(baseDir, rel);
318
+ if (!existsSync(fullPath)) {
319
+ continue;
320
+ }
165
321
  try {
166
- await exec(pm, formatAddArgs(pm, [pkg, 'dotenv']), { cwd: baseDir });
167
- await exec(pm, formatAddDevArgs(pm, ['prisma-next']), { cwd: baseDir });
168
- spinner.stop(`Installed ${pkg}, dotenv, and prisma-next`);
169
- installSucceeded = true;
322
+ unlinkSync(fullPath);
323
+ filesDeleted.push(rel);
170
324
  } catch (err) {
171
- spinner.stop('Installation failed');
172
- const stderr =
173
- err instanceof Error && 'stderr' in err ? (err as { stderr: string }).stderr : '';
174
- ui.warn(
325
+ if (!(err instanceof Error && 'code' in err && (err as { code: string }).code === 'ENOENT')) {
326
+ throw err;
327
+ }
328
+ }
329
+ }
330
+
331
+ const emitCommand = formatRunCommand(pm, 'prisma-next', 'contract emit');
332
+
333
+ let install: InstallReport;
334
+ try {
335
+ install = await runInstall({
336
+ baseDir,
337
+ pm,
338
+ target: inputs.target,
339
+ install: inputs.install,
340
+ flags,
341
+ ui,
342
+ filesWritten,
343
+ hasTypesNode:
344
+ parsedPackageJson !== null ? hasDirectDep(parsedPackageJson, '@types/node') : false,
345
+ });
346
+ } catch (error) {
347
+ if (CliStructuredError.is(error)) {
348
+ return emitError(ui, flags, error);
349
+ }
350
+ throw error;
351
+ }
352
+ warnings.push(...install.warnings);
353
+
354
+ let contractEmitted = false;
355
+ if (!install.skipped) {
356
+ try {
357
+ await runEmit({ baseDir, ui, filesWritten, emitCommand });
358
+ contractEmitted = true;
359
+ } catch (error) {
360
+ if (CliStructuredError.is(error)) {
361
+ return emitError(ui, flags, error);
362
+ }
363
+ throw error;
364
+ }
365
+ }
366
+
367
+ // FR8.3 — optional database version probe. Strictly opt-in: we never
368
+ // open a network connection to the user's database without
369
+ // `--probe-db`. The probe runs after install + emit so the target
370
+ // driver (`pg` / `mongodb`) is guaranteed present in node_modules
371
+ // for the CJS `createRequire` resolution.
372
+ if (inputs.probeDb) {
373
+ const outcome = await probeServerVersion(
374
+ {
375
+ baseDir,
376
+ target: inputs.target,
377
+ databaseUrl: process.env['DATABASE_URL'],
378
+ minVersion: MIN_SERVER_VERSION[inputs.target],
379
+ },
380
+ probeOverrides ?? {},
381
+ );
382
+ const escalated = applyProbeOutcome(outcome, {
383
+ strictProbe: inputs.strictProbe,
384
+ warnings,
385
+ });
386
+ if (escalated !== null) {
387
+ return emitError(ui, flags, errorInitProbeFailed({ cause: escalated, filesWritten }));
388
+ }
389
+ }
390
+
391
+ const output: InitOutput = {
392
+ ok: true,
393
+ target: inputs.target === 'mongo' ? 'mongodb' : 'postgres',
394
+ authoring: inputs.authoring,
395
+ schemaPath: inputs.schemaPath,
396
+ filesWritten,
397
+ filesDeleted,
398
+ packagesInstalled: {
399
+ skipped: install.skipped,
400
+ deps: [...install.deps],
401
+ devDeps: [...install.devDeps],
402
+ },
403
+ contractEmitted,
404
+ nextSteps: buildNextSteps({
405
+ target: inputs.target === 'mongo' ? 'mongodb' : 'postgres',
406
+ contractEmitted,
407
+ emitCommand,
408
+ schemaPath: inputs.schemaPath,
409
+ }),
410
+ warnings,
411
+ };
412
+
413
+ // Validate the success document at the boundary so a regression in any
414
+ // upstream branch (templates, schema, install report) shows up as a
415
+ // typed runtime failure here instead of an opaque consumer-side parse
416
+ // error. The schema is also exported on the package surface for
417
+ // downstream consumers.
418
+ const validated = InitOutputSchema(output);
419
+ if (validated instanceof Error || (validated as { problems?: unknown }).problems !== undefined) {
420
+ // Route through `emitError` rather than throwing: the bare throw
421
+ // bypassed `--json` envelope formatting and `exitCodeForError`, so a
422
+ // 5009 regression would surface as an uncaught exception in
423
+ // commander instead of the documented `INTERNAL_ERROR` envelope on
424
+ // the right channel.
425
+ return emitError(
426
+ ui,
427
+ flags,
428
+ new CliStructuredError('5009', 'Init produced an invalid output document', {
429
+ domain: 'CLI',
430
+ why: `The success document failed schema validation: ${String(validated)}`,
431
+ fix: 'This is a bug in prisma-next. Please report it with the full `-v` output.',
432
+ docsUrl: 'https://prisma-next.dev/docs/cli/init',
433
+ }),
434
+ );
435
+ }
436
+
437
+ if (flags.json) {
438
+ ui.output(formatInitJson(output));
439
+ } else {
440
+ renderInitOutro(ui, output, flags);
441
+ if (!flags.quiet) {
442
+ clack.outro('Done. Open prisma-next.md to get started.', { output: process.stderr });
443
+ }
444
+ }
445
+
446
+ return INIT_EXIT_OK;
447
+ }
448
+
449
+ /**
450
+ * Renders a structured CLI error to the right channel and returns the exit
451
+ * code derived from the error's PN code. JSON-mode errors go to stdout
452
+ * (so consumers always parse from one place); human-mode errors go to
453
+ * stderr. Mirrors `handleResult` but returns init-specific exit codes
454
+ * rather than the CLI/RUN binary.
455
+ */
456
+ function emitError(ui: TerminalUI, flags: GlobalFlags, error: CliStructuredError): number {
457
+ const envelope = error.toEnvelope();
458
+ if (flags.json) {
459
+ ui.output(formatErrorJson(envelope));
460
+ } else {
461
+ ui.error(formatErrorOutput(envelope, flags));
462
+ }
463
+ return exitCodeForError(error);
464
+ }
465
+
466
+ /**
467
+ * Maps a structured init error to its documented exit code. Centralised so
468
+ * the error → exit-code contract lives next to the codes themselves.
469
+ *
470
+ * `5009` (and the unknown-code default branch) routes to
471
+ * `INIT_EXIT_INTERNAL_ERROR` because those represent prisma-next bugs the
472
+ * user did not cause — surfacing them as `PRECONDITION` would mislead
473
+ * automation into thinking the caller mis-invoked the CLI.
474
+ *
475
+ * See [exit-codes.ts](./exit-codes.ts) for the canonical list and
476
+ * [Style Guide § Exit Codes](../../../../../../../docs/CLI%20Style%20Guide.md#exit-codes)
477
+ * for the reservation policy.
478
+ *
479
+ * Exported for unit tests so the mapping can be asserted without
480
+ * round-tripping a full `runInit` invocation.
481
+ */
482
+ export function exitCodeForError(error: { readonly code: string }): number {
483
+ switch (error.code) {
484
+ case '5001': // missing manifest — precondition
485
+ case '5002': // re-init needs --force — precondition
486
+ case '5003': // missing flags — precondition
487
+ case '5004': // invalid flag value — precondition
488
+ case '5005': // --strict-probe without --probe-db — precondition
489
+ case '5010': // invalid manifest (malformed package.json) — precondition
490
+ case '5011': // invalid tsconfig (unparseable JSONC) — precondition
491
+ case '5012': // probe failed under --strict-probe — precondition
492
+ return INIT_EXIT_PRECONDITION;
493
+ case '5006': // user aborted interactive prompt
494
+ return INIT_EXIT_USER_ABORTED;
495
+ case '5007': // install failed
496
+ return INIT_EXIT_INSTALL_FAILED;
497
+ case '5008': // emit failed
498
+ return INIT_EXIT_EMIT_FAILED;
499
+ case '5009': // invalid output document — internal bug in prisma-next
500
+ return INIT_EXIT_INTERNAL_ERROR;
501
+ default:
502
+ // Any unexpected code is treated as an internal bug rather than
503
+ // mis-routed to PRECONDITION. Adding a new code requires an
504
+ // explicit case above.
505
+ return INIT_EXIT_INTERNAL_ERROR;
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Folds a `ProbeOutcome` into init's warning channel and returns the
511
+ * fatal cause string when `--strict-probe` should escalate. Mirrors
512
+ * the FR8.3 contract:
513
+ *
514
+ * - `ok` — informational; nothing surfaced unless verbose. (We could
515
+ * plumb a `note` here, but the spec only requires the warning side
516
+ * of the contract; an "all good" line would just be noise on the
517
+ * common path.)
518
+ * - `below-minimum` — warning regardless of `--strict-probe`. The
519
+ * probe ran successfully and found an old server; that is not a
520
+ * probe *failure* (which is what `--strict-probe` escalates), it
521
+ * is the probe doing its job.
522
+ * - `no-database-url` / `connection-failed` / `driver-missing` —
523
+ * warning by default, fatal under `--strict-probe`.
524
+ *
525
+ * Exported for unit tests so the branching contract can be asserted
526
+ * without spinning up a full `runInit` round trip.
527
+ */
528
+ export function applyProbeOutcome(
529
+ outcome: ProbeOutcome,
530
+ ctx: { readonly strictProbe: boolean; readonly warnings: string[] },
531
+ ): string | null {
532
+ switch (outcome.kind) {
533
+ case 'ok':
534
+ return null;
535
+ case 'below-minimum':
536
+ ctx.warnings.push(outcome.message);
537
+ return null;
538
+ case 'no-database-url':
539
+ case 'connection-failed':
540
+ case 'driver-missing':
541
+ if (ctx.strictProbe) {
542
+ return outcome.message;
543
+ }
544
+ ctx.warnings.push(outcome.message);
545
+ return null;
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Drives the `pnpm add` / `npm install` step. Failures are escalated to
551
+ * a structured `errorInitInstallFailed` (exit code 4) — the spec treats
552
+ * an unrecoverable install as a hard outcome rather than a warning so
553
+ * CI/agents can branch on the exit code (FR1.6).
554
+ *
555
+ * For pnpm specifically, we additionally implement the FR7.2 fallback:
556
+ * if pnpm fails with a recognised workspace/catalog resolution error
557
+ * class (typically caused by a registry version that leaked
558
+ * `workspace:*` or `catalog:` specifiers), we retry the install using
559
+ * `npm` and surface a non-fatal warning explaining the swap.
560
+ */
561
+ async function runInstall(ctx: {
562
+ readonly baseDir: string;
563
+ readonly pm: Awaited<ReturnType<typeof detectPackageManager>>;
564
+ readonly target: ResolvedInitInputs['target'];
565
+ readonly install: boolean;
566
+ readonly flags: GlobalFlags;
567
+ readonly ui: TerminalUI;
568
+ readonly filesWritten: readonly string[];
569
+ /**
570
+ * FR2.1 — set when the user already declares `@types/node` directly in
571
+ * `dependencies` or `devDependencies`. We then skip adding it so a
572
+ * pinned major (e.g. `^18` for a Node 18 runtime) survives `init`
573
+ * unchanged. Transitive presence is intentionally ignored: detecting
574
+ * it requires lockfile introspection and the realistic clobber risk
575
+ * is the direct-pin case.
576
+ */
577
+ readonly hasTypesNode: boolean;
578
+ }): Promise<InstallReport> {
579
+ const { baseDir, pm, target, install, flags, ui, filesWritten, hasTypesNode } = ctx;
580
+ const pkg = targetPackageName(target);
581
+ const deps = [pkg, 'dotenv'];
582
+ // FR2.1: under `moduleResolution: 'bundler'` (FR2.2) the scaffolded
583
+ // `db.ts` / `prisma-next.config.ts` reference `process.env`, which
584
+ // only typechecks with Node's ambient types in the resolution graph.
585
+ // Pin it as a devDep rather than relying on a transitive resolution
586
+ // through `dotenv` (whose types bundle is internal and not guaranteed
587
+ // across versions). Skip when the user already declares `@types/node`
588
+ // directly so a pinned major (e.g. `^18` for a Node 18 runtime) is
589
+ // preserved. Listed last so the install log still leads with the
590
+ // user-relevant `prisma-next` line.
591
+ const devDeps = hasTypesNode ? ['prisma-next'] : ['prisma-next', '@types/node'];
592
+
593
+ const addCommand = `${pm} ${formatAddArgs(pm, deps).join(' ')}`;
594
+ const addDevCommand = `${pm} ${formatAddDevArgs(pm, devDeps).join(' ')}`;
595
+ const emitCommand = formatRunCommand(pm, 'prisma-next', 'contract emit');
596
+
597
+ // FR7.3 / Spec Decision 8 — honour-and-warn: if the surrounding pnpm
598
+ // workspace pins one of our packages via the catalog, surface a
599
+ // structured warning so the user knows the catalog version (not the
600
+ // published `latest`) is what ends up installed. We collect the
601
+ // warning whether or not we actually run install — the override
602
+ // applies to a manual install too — but only when pnpm is the chosen
603
+ // PM (catalog: specifiers are pnpm-specific).
604
+ const catalogWarnings = pm === 'pnpm' ? buildCatalogWarnings(baseDir, [...deps, ...devDeps]) : [];
605
+
606
+ if (!install) {
607
+ if (!flags.json && !flags.quiet) {
608
+ ui.note(
175
609
  [
176
- 'Could not install dependencies automatically.',
177
- ...(stderr ? [` ${stderr.trim()}`] : []),
610
+ 'Run the following commands to complete setup:',
178
611
  '',
179
- 'Run manually:',
180
- ` ${pm} ${formatAddArgs(pm, [pkg, 'dotenv']).join(' ')}`,
181
- ` ${pm} ${formatAddDevArgs(pm, ['prisma-next']).join(' ')}`,
612
+ ' 1. Install dependencies:',
613
+ ` ${addCommand}`,
614
+ ` ${addDevCommand}`,
615
+ '',
616
+ ' 2. Emit the contract:',
617
+ ` ${emitCommand}`,
182
618
  ].join('\n'),
619
+ 'Manual steps',
183
620
  );
184
621
  }
622
+ return { skipped: true, deps: [], devDeps: [], warnings: catalogWarnings };
623
+ }
185
624
 
186
- if (installSucceeded) {
187
- spinner.start('Emitting contract...');
625
+ const exec = promisify(execFile);
626
+ const runPair = async (manager: PackageManager): Promise<void> => {
627
+ await exec(manager, formatAddArgs(manager, deps), { cwd: baseDir });
628
+ await exec(manager, formatAddDevArgs(manager, devDeps), { cwd: baseDir });
629
+ };
630
+
631
+ const allPackages = [...deps, ...devDeps].join(', ');
632
+ const spinner = ui.spinner();
633
+ spinner.start(`Installing ${allPackages}...`);
634
+ try {
635
+ await runPair(pm);
636
+ spinner.stop(`Installed ${allPackages}`);
637
+ return { skipped: false, deps, devDeps, warnings: catalogWarnings };
638
+ } catch (err) {
639
+ const stderrText = redactSecrets(readChildStderr(err));
640
+
641
+ // FR7.2: detect a recognised pnpm workspace/catalog resolution error
642
+ // and fall back to npm. Limited to pnpm specifically; npm/yarn/bun/deno
643
+ // failures escalate straight to a structured install error.
644
+ if (pm === 'pnpm' && isRecognisedPnpmResolutionError(stderrText)) {
645
+ spinner.message(
646
+ 'pnpm could not resolve a workspace/catalog dependency, retrying with npm...',
647
+ );
188
648
  try {
189
- const { executeContractEmit } = await import('../../control-api/operations/contract-emit');
190
- const configFilePath = join(baseDir, 'prisma-next.config.ts');
191
- await executeContractEmit({ configPath: configFilePath });
192
- spinner.stop('Contract emitted');
193
- } catch {
194
- spinner.stop('Contract emission failed');
195
- ui.warn(
196
- ['Could not emit contract automatically. Run manually:', ` ${emitCommand}`].join('\n'),
197
- );
649
+ await runPair('npm');
650
+ spinner.stop(`Installed ${allPackages} via npm (pnpm fallback)`);
651
+ const fallbackWarning = [
652
+ 'pnpm could not install: a published Prisma Next dependency leaked a `workspace:*` or `catalog:` specifier.',
653
+ 'Falling back to `npm install` so init can complete.',
654
+ stderrText ? ` pnpm error: ${stderrText.trim().split('\n')[0]}` : '',
655
+ 'Once the offending package republishes a clean version, re-run `pnpm install` to switch back.',
656
+ ]
657
+ .filter(Boolean)
658
+ .join('\n');
659
+ return {
660
+ skipped: false,
661
+ deps,
662
+ devDeps,
663
+ // The pnpm fallback fired, so the workspace catalog is not the
664
+ // version that was actually installed (npm bypassed pnpm's
665
+ // resolver). Surface the fallback warning but suppress the
666
+ // catalog-honour warning to avoid a contradictory message
667
+ // pair.
668
+ warnings: [fallbackWarning],
669
+ };
670
+ } catch (npmErr) {
671
+ spinner.stop('Installation failed');
672
+ const npmStderr = redactSecrets(readChildStderr(npmErr));
673
+ throw errorInitInstallFailed({
674
+ addCommand,
675
+ addDevCommand,
676
+ emitCommand,
677
+ filesWritten,
678
+ stderrLines: [stderrText, npmStderr],
679
+ });
198
680
  }
199
681
  }
682
+
683
+ spinner.stop('Installation failed');
684
+ throw errorInitInstallFailed({
685
+ addCommand,
686
+ addDevCommand,
687
+ emitCommand,
688
+ filesWritten,
689
+ stderrLines: [stderrText],
690
+ });
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Builds the FR7.3 catalog-honoured warning(s) for the surrounding pnpm
696
+ * workspace, if any. Returns an empty array when no `pnpm-workspace.yaml`
697
+ * exists in any ancestor or when the workspace's catalog has no entry
698
+ * for any of the packages `init` is about to install.
699
+ *
700
+ * Exported for unit tests.
701
+ */
702
+ export function buildCatalogWarnings(
703
+ baseDir: string,
704
+ packages: readonly string[],
705
+ ): readonly string[] {
706
+ const result = detectPnpmCatalogOverrides(baseDir, packages);
707
+ if (result === null || result.entries.length === 0) {
708
+ return [];
200
709
  }
710
+ return [formatCatalogWarning(result.workspaceFile, result.entries)];
711
+ }
712
+
713
+ function formatCatalogWarning(
714
+ workspaceFile: string,
715
+ entries: readonly PnpmCatalogOverride[],
716
+ ): string {
717
+ const list = entries.map((entry) => ` • ${entry.name}: ${entry.version}`).join('\n');
718
+ return [
719
+ 'pnpm workspace catalog overrides detected — pnpm will install these versions instead of `latest`:',
720
+ list,
721
+ `Catalog source: ${workspaceFile}`,
722
+ 'To use the published `latest` instead, remove or update the catalog entry, then re-run `pnpm install`.',
723
+ ].join('\n');
724
+ }
725
+
726
+ /**
727
+ * Recognised pnpm error signatures that justify a fallback to npm.
728
+ *
729
+ * These patterns indicate the published artefact itself is at fault
730
+ * (a leaked `workspace:*` or `catalog:` specifier), not the user's
731
+ * environment — pnpm is faithfully reporting "I cannot resolve this
732
+ * registry version", and npm is willing to install it because npm
733
+ * doesn't care about the protocol prefix when there's a fallback range.
734
+ *
735
+ * Exported for unit tests; do not depend on this from outside the init
736
+ * command.
737
+ */
738
+ export function isRecognisedPnpmResolutionError(stderr: string): boolean {
739
+ if (!stderr) return false;
740
+ return (
741
+ stderr.includes('ERR_PNPM_WORKSPACE_PKG_NOT_FOUND') ||
742
+ stderr.includes('ERR_PNPM_NO_MATCHING_VERSION') ||
743
+ /No matching version found for .* in the catalog/i.test(stderr) ||
744
+ /workspace:[^\s]+ is not a valid (version|spec)/i.test(stderr) ||
745
+ /catalog:[^\s]* is not a valid (version|spec)/i.test(stderr)
746
+ );
747
+ }
748
+
749
+ /**
750
+ * FR2.1 — true when the parsed `package.json` declares `name` directly
751
+ * in either `dependencies` or `devDependencies`. We deliberately don't
752
+ * inspect `peerDependencies` (irrelevant for a leaf project) or the
753
+ * lockfile (transitive presence is brittle to detect and not the
754
+ * realistic clobber-risk path).
755
+ *
756
+ * Exported for unit tests.
757
+ */
758
+ export function hasDirectDep(parsed: Record<string, unknown>, name: string): boolean {
759
+ for (const field of ['dependencies', 'devDependencies'] as const) {
760
+ const value = parsed[field];
761
+ if (value !== null && typeof value === 'object' && name in value) {
762
+ return true;
763
+ }
764
+ }
765
+ return false;
766
+ }
767
+
768
+ function readChildStderr(err: unknown): string {
769
+ if (err instanceof Error && 'stderr' in err) {
770
+ return String((err as { stderr: string }).stderr ?? '');
771
+ }
772
+ return '';
773
+ }
774
+
775
+ /**
776
+ * Redacts userinfo (`user:password@`) from any URL-shaped substring inside
777
+ * package-manager stderr before we surface it in a warning or error
778
+ * meta. pnpm and npm both include the offending registry URL in resolve
779
+ * errors, and that URL can carry an auth token (e.g. corporate registry
780
+ * mirrors that bake `_authToken` into the URL). The Style Guide
781
+ * (Testing & Accessibility — "Security: never print secrets") requires
782
+ * we never surface those.
783
+ *
784
+ * Exported for unit tests.
785
+ */
786
+ export function redactSecrets(stderr: string): string {
787
+ if (!stderr) return stderr;
788
+ // Match `scheme://userinfo@host…` and replace the userinfo with `***`.
789
+ return stderr.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/@\s]+)@/g, '$1***@');
790
+ }
791
+
792
+ /**
793
+ * Drives `prisma-next contract emit` against the freshly scaffolded
794
+ * project. On failure, throws `errorInitEmitFailed` with the underlying
795
+ * cause embedded in `meta.cause` so the user can re-run with `-v` to see
796
+ * the full envelope and follow the fix steps. Maps to exit code
797
+ * `5 = EMIT_FAILED` (FR1.6).
798
+ */
799
+ async function runEmit(ctx: {
800
+ readonly baseDir: string;
801
+ readonly ui: TerminalUI;
802
+ readonly filesWritten: readonly string[];
803
+ readonly emitCommand: string;
804
+ }): Promise<void> {
805
+ const spinner = ctx.ui.spinner();
806
+ spinner.start('Emitting contract...');
807
+ try {
808
+ const { executeContractEmit } = await import('../../control-api/operations/contract-emit');
809
+ const configFilePath = join(ctx.baseDir, 'prisma-next.config.ts');
810
+ await executeContractEmit({ configPath: configFilePath });
811
+ spinner.stop('Contract emitted');
812
+ } catch (err) {
813
+ spinner.stop('Contract emission failed');
814
+ throw errorInitEmitFailed({
815
+ emitCommand: ctx.emitCommand,
816
+ filesWritten: ctx.filesWritten,
817
+ cause: causeMessage(err),
818
+ });
819
+ }
820
+ }
201
821
 
202
- clack.outro('Done! Open prisma-next.md to get started.', { output: process.stderr });
822
+ function causeMessage(err: unknown): string {
823
+ if (err instanceof Error) return err.message;
824
+ return String(err);
203
825
  }