@prisma-next/cli 0.5.0-dev.3 → 0.5.0-dev.30
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.
- package/README.md +56 -21
- package/dist/agent-skill-mongo.md +63 -31
- package/dist/agent-skill-postgres.md +1 -1
- package/dist/cli-errors-By1iVE3z.mjs +34 -0
- package/dist/cli-errors-By1iVE3z.mjs.map +1 -0
- package/dist/{cli-errors-C0JhVj0c.d.mts → cli-errors-DDeVsP2Y.d.mts} +1 -0
- package/dist/cli.mjs +131 -15
- package/dist/cli.mjs.map +1 -1
- package/dist/{client-TG7rbCWT.mjs → client-keSCAgjW.mjs} +43 -19
- package/dist/client-keSCAgjW.mjs.map +1 -0
- package/dist/commands/contract-emit.d.mts.map +1 -1
- package/dist/commands/contract-emit.mjs +7 -2
- package/dist/commands/contract-infer.d.mts.map +1 -1
- package/dist/commands/contract-infer.mjs +8 -2
- package/dist/commands/db-init.d.mts.map +1 -1
- package/dist/commands/db-init.mjs +11 -9
- package/dist/commands/db-init.mjs.map +1 -1
- package/dist/commands/db-schema.mjs +8 -5
- package/dist/commands/db-schema.mjs.map +1 -1
- package/dist/commands/db-sign.mjs +8 -7
- package/dist/commands/db-sign.mjs.map +1 -1
- package/dist/commands/db-update.mjs +10 -9
- package/dist/commands/db-update.mjs.map +1 -1
- package/dist/commands/db-verify.mjs +10 -9
- package/dist/commands/db-verify.mjs.map +1 -1
- package/dist/commands/migration-apply.d.mts +2 -2
- package/dist/commands/migration-apply.d.mts.map +1 -1
- package/dist/commands/migration-apply.mjs +15 -38
- package/dist/commands/migration-apply.mjs.map +1 -1
- package/dist/commands/migration-new.d.mts.map +1 -1
- package/dist/commands/migration-new.mjs +24 -30
- package/dist/commands/migration-new.mjs.map +1 -1
- package/dist/commands/migration-plan.d.mts +14 -5
- package/dist/commands/migration-plan.d.mts.map +1 -1
- package/dist/commands/migration-plan.mjs +45 -47
- package/dist/commands/migration-plan.mjs.map +1 -1
- package/dist/commands/migration-ref.d.mts +6 -4
- package/dist/commands/migration-ref.d.mts.map +1 -1
- package/dist/commands/migration-ref.mjs +31 -40
- package/dist/commands/migration-ref.mjs.map +1 -1
- package/dist/commands/migration-show.d.mts +13 -7
- package/dist/commands/migration-show.d.mts.map +1 -1
- package/dist/commands/migration-show.mjs +28 -29
- package/dist/commands/migration-show.mjs.map +1 -1
- package/dist/commands/migration-status.d.mts +5 -4
- package/dist/commands/migration-status.d.mts.map +1 -1
- package/dist/commands/migration-status.mjs +7 -2
- package/dist/{config-loader-_W4T21X1.mjs → config-loader-ih8ViDb_.mjs} +2 -2
- package/dist/config-loader-ih8ViDb_.mjs.map +1 -0
- package/dist/config-loader.mjs +1 -1
- package/dist/contract-emit-DS5NzZh2.mjs +6 -0
- package/dist/contract-emit-DWtGQYCD.mjs +150 -0
- package/dist/contract-emit-DWtGQYCD.mjs.map +1 -0
- package/dist/contract-emit-RZBWzkop.mjs +329 -0
- package/dist/contract-emit-RZBWzkop.mjs.map +1 -0
- package/dist/{contract-enrichment-CGW6mm-E.mjs → contract-enrichment-4Ptgw3Pe.mjs} +1 -1
- package/dist/{contract-enrichment-CGW6mm-E.mjs.map → contract-enrichment-4Ptgw3Pe.mjs.map} +1 -1
- package/dist/{contract-infer-BP3DrGgz.mjs → contract-infer-GztVCOCJ.mjs} +11 -19
- package/dist/contract-infer-GztVCOCJ.mjs.map +1 -0
- package/dist/exports/control-api.d.mts +78 -21
- package/dist/exports/control-api.d.mts.map +1 -1
- package/dist/exports/control-api.mjs +7 -5
- package/dist/exports/index.mjs +8 -3
- package/dist/exports/index.mjs.map +1 -1
- package/dist/exports/init-output.d.mts +39 -0
- package/dist/exports/init-output.d.mts.map +1 -0
- package/dist/exports/init-output.mjs +3 -0
- package/dist/{framework-components-DfZKQBQ2.mjs → framework-components-Bgcre3Z6.mjs} +2 -2
- package/dist/{framework-components-DfZKQBQ2.mjs.map → framework-components-Bgcre3Z6.mjs.map} +1 -1
- package/dist/init-DAbQMxIR.mjs +2062 -0
- package/dist/init-DAbQMxIR.mjs.map +1 -0
- package/dist/{inspect-live-schema-DWzf4Q_m.mjs → inspect-live-schema-BaR9ISwa.mjs} +9 -9
- package/dist/inspect-live-schema-BaR9ISwa.mjs.map +1 -0
- package/dist/migration-cli.d.mts +41 -11
- package/dist/migration-cli.d.mts.map +1 -1
- package/dist/migration-cli.mjs +308 -84
- package/dist/migration-cli.mjs.map +1 -1
- package/dist/{migration-command-scaffold-CLMD302g.mjs → migration-command-scaffold-D1dWuEWQ.mjs} +7 -7
- package/dist/{migration-command-scaffold-CLMD302g.mjs.map → migration-command-scaffold-D1dWuEWQ.mjs.map} +1 -1
- package/dist/{migration-status-B0HLF7So.mjs → migration-status-CP5k8O5i.mjs} +21 -35
- package/dist/migration-status-CP5k8O5i.mjs.map +1 -0
- package/dist/{migrations-B0dOQlk0.mjs → migrations-MEoKMiV5.mjs} +42 -21
- package/dist/migrations-MEoKMiV5.mjs.map +1 -0
- package/dist/output-BpcQrnnq.mjs +103 -0
- package/dist/output-BpcQrnnq.mjs.map +1 -0
- package/dist/{progress-adapter-B-YvmcDu.mjs → progress-adapter-DgRGldpT.mjs} +1 -1
- package/dist/{progress-adapter-B-YvmcDu.mjs.map → progress-adapter-DgRGldpT.mjs.map} +1 -1
- package/dist/quick-reference-mongo.md +34 -13
- package/dist/quick-reference-postgres.md +11 -9
- package/dist/{result-handler-CIyu0Pdt.mjs → result-handler-BmVh8AeV.mjs} +12 -93
- package/dist/result-handler-BmVh8AeV.mjs.map +1 -0
- package/dist/{terminal-ui-C5k88MmW.mjs → terminal-ui-u2YgKghu.mjs} +76 -2
- package/dist/terminal-ui-u2YgKghu.mjs.map +1 -0
- package/dist/{verify-BxiVp50b.mjs → verify-BT9tgCOH.mjs} +2 -2
- package/dist/{verify-BxiVp50b.mjs.map → verify-BT9tgCOH.mjs.map} +1 -1
- package/package.json +21 -15
- package/src/cli.ts +32 -6
- package/src/commands/contract-emit.ts +67 -163
- package/src/commands/contract-infer.ts +7 -20
- package/src/commands/db-init.ts +1 -0
- package/src/commands/db-update.ts +1 -1
- package/src/commands/init/detect-pnpm-catalog.ts +141 -0
- package/src/commands/init/errors.ts +254 -0
- package/src/commands/init/exit-codes.ts +62 -0
- package/src/commands/init/hygiene-gitattributes.ts +97 -0
- package/src/commands/init/hygiene-gitignore.ts +48 -0
- package/src/commands/init/hygiene-package-scripts.ts +91 -0
- package/src/commands/init/index.ts +112 -7
- package/src/commands/init/init.ts +766 -144
- package/src/commands/init/inputs.ts +421 -0
- package/src/commands/init/output.ts +147 -0
- package/src/commands/init/probe-db.ts +308 -0
- package/src/commands/init/reinit-cleanup.ts +83 -0
- package/src/commands/init/templates/agent-skill-mongo.md +63 -31
- package/src/commands/init/templates/agent-skill-postgres.md +1 -1
- package/src/commands/init/templates/agent-skill.ts +25 -3
- package/src/commands/init/templates/code-templates.ts +125 -32
- package/src/commands/init/templates/env.ts +80 -0
- package/src/commands/init/templates/quick-reference-mongo.md +34 -13
- package/src/commands/init/templates/quick-reference-postgres.md +11 -9
- package/src/commands/init/templates/quick-reference.ts +42 -3
- package/src/commands/init/templates/tsconfig.ts +167 -5
- package/src/commands/inspect-live-schema.ts +10 -5
- package/src/commands/migration-apply.ts +16 -51
- package/src/commands/migration-new.ts +26 -32
- package/src/commands/migration-plan.ts +80 -55
- package/src/commands/migration-ref.ts +40 -54
- package/src/commands/migration-show.ts +53 -36
- package/src/commands/migration-status.ts +33 -50
- package/src/config-path-validation.ts +0 -1
- package/src/control-api/client.ts +21 -0
- package/src/control-api/operations/contract-emit.ts +198 -115
- package/src/control-api/operations/db-init.ts +8 -5
- package/src/control-api/operations/db-update.ts +8 -5
- package/src/control-api/operations/migration-apply.ts +29 -9
- package/src/control-api/types.ts +61 -7
- package/src/exports/control-api.ts +2 -1
- package/src/exports/init-output.ts +10 -0
- package/src/migration-cli.ts +445 -122
- package/src/utils/cli-errors.ts +49 -2
- package/src/utils/command-helpers.ts +13 -26
- package/src/utils/emit-queue.ts +26 -0
- package/src/utils/formatters/graph-migration-mapper.ts +2 -2
- package/src/utils/formatters/migrations.ts +62 -26
- package/src/utils/publish-contract-artifact-pair.ts +134 -0
- package/dist/cli-errors-DHq6GQGu.mjs +0 -5
- package/dist/client-TG7rbCWT.mjs.map +0 -1
- package/dist/config-loader-_W4T21X1.mjs.map +0 -1
- package/dist/contract-emit-CNYyzJwF.mjs +0 -195
- package/dist/contract-emit-CNYyzJwF.mjs.map +0 -1
- package/dist/contract-emit-CQfj7xJn.mjs +0 -122
- package/dist/contract-emit-CQfj7xJn.mjs.map +0 -1
- package/dist/contract-emit-fhNwwhkQ.mjs +0 -4
- package/dist/contract-infer-BP3DrGgz.mjs.map +0 -1
- package/dist/extract-operation-statements-DZUJNmL3.mjs +0 -13
- package/dist/extract-operation-statements-DZUJNmL3.mjs.map +0 -1
- package/dist/extract-sql-ddl-DDMX-9mz.mjs +0 -26
- package/dist/extract-sql-ddl-DDMX-9mz.mjs.map +0 -1
- package/dist/init-CQfo_4Ro.mjs +0 -430
- package/dist/init-CQfo_4Ro.mjs.map +0 -1
- package/dist/inspect-live-schema-DWzf4Q_m.mjs.map +0 -1
- package/dist/migration-status-B0HLF7So.mjs.map +0 -1
- package/dist/migrations-B0dOQlk0.mjs.map +0 -1
- package/dist/result-handler-CIyu0Pdt.mjs.map +0 -1
- package/dist/terminal-ui-C5k88MmW.mjs.map +0 -1
- package/dist/validate-contract-deps-esa-VQ0h.mjs +0 -37
- package/dist/validate-contract-deps-esa-VQ0h.mjs.map +0 -1
- package/src/control-api/operations/extract-operation-statements.ts +0 -14
- package/src/control-api/operations/extract-sql-ddl.ts +0 -47
|
@@ -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,
|
|
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 {
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from './
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
215
|
+
filesToWrite.push({ path: 'tsconfig.json', content: defaultTsConfig() });
|
|
138
216
|
}
|
|
139
217
|
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
spinner.stop(`Installed ${pkg}, dotenv, and prisma-next`);
|
|
169
|
-
installSucceeded = true;
|
|
322
|
+
unlinkSync(fullPath);
|
|
323
|
+
filesDeleted.push(rel);
|
|
170
324
|
} catch (err) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
'
|
|
177
|
-
...(stderr ? [` ${stderr.trim()}`] : []),
|
|
610
|
+
'Run the following commands to complete setup:',
|
|
178
611
|
'',
|
|
179
|
-
'
|
|
180
|
-
`
|
|
181
|
-
`
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
822
|
+
function causeMessage(err: unknown): string {
|
|
823
|
+
if (err instanceof Error) return err.message;
|
|
824
|
+
return String(err);
|
|
203
825
|
}
|