@prisma-next/cli 0.5.0-dev.25 → 0.5.0-dev.26
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 +2 -0
- package/dist/cli-errors-By1iVE3z.mjs.map +1 -1
- package/dist/cli.mjs +5 -11
- package/dist/cli.mjs.map +1 -1
- package/dist/commands/contract-emit.mjs +0 -5
- package/dist/commands/contract-infer.mjs +0 -6
- package/dist/commands/db-init.mjs +0 -1
- package/dist/commands/db-init.mjs.map +1 -1
- package/dist/commands/db-schema.mjs +0 -3
- package/dist/commands/db-schema.mjs.map +1 -1
- package/dist/commands/db-sign.mjs +0 -1
- package/dist/commands/db-sign.mjs.map +1 -1
- package/dist/commands/db-update.mjs +0 -1
- package/dist/commands/db-update.mjs.map +1 -1
- package/dist/commands/db-verify.mjs +0 -1
- package/dist/commands/db-verify.mjs.map +1 -1
- package/dist/commands/migration-apply.mjs +0 -1
- package/dist/commands/migration-apply.mjs.map +1 -1
- package/dist/commands/migration-status.mjs +0 -5
- package/dist/{contract-emit-DS5NzZh2.mjs → contract-emit-LjzCoicC.mjs} +0 -2
- package/dist/exports/control-api.mjs +0 -2
- package/dist/exports/index.mjs +0 -5
- package/dist/exports/index.mjs.map +1 -1
- package/dist/{init-C-H-if1m.mjs → init-BKgjxw6r.mjs} +2 -2
- package/dist/{init-C-H-if1m.mjs.map → init-BKgjxw6r.mjs.map} +1 -1
- package/dist/migration-cli.d.mts +41 -11
- package/dist/migration-cli.d.mts.map +1 -1
- package/dist/migration-cli.mjs +281 -72
- package/dist/migration-cli.mjs.map +1 -1
- package/package.json +16 -15
- package/src/cli.ts +32 -6
- package/src/migration-cli.ts +414 -106
- package/src/utils/cli-errors.ts +4 -1
package/src/cli.ts
CHANGED
|
@@ -50,8 +50,18 @@ program.configureOutput({
|
|
|
50
50
|
writeErr: () => {
|
|
51
51
|
// Suppress all default error output - we handle errors in exitOverride
|
|
52
52
|
},
|
|
53
|
-
writeOut: () => {
|
|
54
|
-
//
|
|
53
|
+
writeOut: (str) => {
|
|
54
|
+
// Commander routes explicitly-requested `--help` (success-path help)
|
|
55
|
+
// through writeOut; per the Style Guide § Output Conventions rule 8,
|
|
56
|
+
// user-requested help is data and goes to stdout. Error-path help
|
|
57
|
+
// (e.g. usage shown after an unknown command) goes through writeErr,
|
|
58
|
+
// which stays suppressed because we render that ourselves with the
|
|
59
|
+
// matching error envelope.
|
|
60
|
+
//
|
|
61
|
+
// Explicit `--version` is short-circuited before `program.parse()`
|
|
62
|
+
// (see the argv pre-scan at the bottom of this file), so it does not
|
|
63
|
+
// reach this writer.
|
|
64
|
+
process.stdout.write(str);
|
|
55
65
|
},
|
|
56
66
|
});
|
|
57
67
|
|
|
@@ -261,14 +271,25 @@ const helpCommand = new Command('help')
|
|
|
261
271
|
.action(() => {
|
|
262
272
|
const flags = parseGlobalFlags({});
|
|
263
273
|
const helpText = formatRootHelp({ program, flags });
|
|
264
|
-
//
|
|
265
|
-
|
|
274
|
+
// The `help` command was invoked explicitly: help is the data the
|
|
275
|
+
// caller asked for. Per Style Guide § Output Conventions rule 8,
|
|
276
|
+
// explicit help goes to stdout with exit code 0.
|
|
277
|
+
process.stdout.write(`${helpText}\n`);
|
|
266
278
|
process.exit(0);
|
|
267
279
|
});
|
|
268
280
|
|
|
269
281
|
program.addCommand(helpCommand);
|
|
270
282
|
|
|
271
|
-
// Set help as the default action when no command is provided
|
|
283
|
+
// Set help as the default action when no command is provided. The user
|
|
284
|
+
// did not invoke `--help`; we are voluntarily showing usage to help them
|
|
285
|
+
// recover from an underspecified invocation, so the help text is
|
|
286
|
+
// decoration around an implicit "what did you want me to do?" and goes
|
|
287
|
+
// to stderr (Style Guide § Output Conventions rule 8).
|
|
288
|
+
//
|
|
289
|
+
// FOLLOW-UP: the exit code here is 0 today, but a no-arg invocation is
|
|
290
|
+
// arguably a usage error (PRECONDITION → exit 2) for consistency with
|
|
291
|
+
// the unknown-command path. Out of scope for the explicit-help routing
|
|
292
|
+
// work; revisit when tightening exit-code semantics across the CLI.
|
|
272
293
|
program.action(() => {
|
|
273
294
|
const flags = parseGlobalFlags({});
|
|
274
295
|
const helpText = formatRootHelp({ program, flags });
|
|
@@ -304,7 +325,12 @@ if (args.length > 0) {
|
|
|
304
325
|
process.stderr.write(`${helpText}\n`);
|
|
305
326
|
process.exit(2);
|
|
306
327
|
} else if (command.commands.length > 0 && args.length === 1) {
|
|
307
|
-
// Parent command called with no subcommand
|
|
328
|
+
// Parent command called with no subcommand. Same shape as the
|
|
329
|
+
// no-args case above: the user did not request help, we are
|
|
330
|
+
// voluntarily rendering it as decoration around an underspecified
|
|
331
|
+
// invocation, so it goes to stderr per Style Guide § Output
|
|
332
|
+
// Conventions rule 8. Exit code 0 today; the FOLLOW-UP note on
|
|
333
|
+
// `program.action` applies here too (arguably should be 2).
|
|
308
334
|
const flags = parseGlobalFlags({});
|
|
309
335
|
const helpText = formatCommandHelp({ command, flags });
|
|
310
336
|
process.stderr.write(`${helpText}\n`);
|
package/src/migration-cli.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* entrypoint (`node migration.ts`), the CLI:
|
|
16
16
|
*
|
|
17
17
|
* 1. Detects whether the file is the direct entrypoint (no-op when imported).
|
|
18
|
-
* 2. Parses CLI args (`--help`, `--dry-run`, `--config <path>`)
|
|
18
|
+
* 2. Parses CLI args (`--help`, `--dry-run`, `--config <path>`) via
|
|
19
|
+
* [clipanion](https://github.com/arcanis/clipanion).
|
|
19
20
|
* 3. Loads the project's `prisma-next.config.ts` via the same `loadConfig`
|
|
20
21
|
* the CLI commands use, walking up from the migration file's directory.
|
|
21
22
|
* 4. Probe-instantiates the migration class without a stack so it can read
|
|
@@ -34,21 +35,27 @@
|
|
|
34
35
|
* on-disk persistence. `@prisma-next/migration-tools` owns the pure
|
|
35
36
|
* conversion from a `Migration` instance to artifact strings; `Migration`
|
|
36
37
|
* stays a pure abstract class.
|
|
38
|
+
*
|
|
39
|
+
* Parser library: clipanion (chosen over Commander/citty/`node:util.parseArgs`
|
|
40
|
+
* for its in-process testability and runtime-agnostic execution surface; see
|
|
41
|
+
* `docs/architecture docs/research/commander-friction-points.md` for the
|
|
42
|
+
* evaluation rubric and the durable rationale that drove the choice).
|
|
37
43
|
*/
|
|
38
44
|
|
|
39
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
45
|
+
import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
46
|
+
import type { Writable } from 'node:stream';
|
|
40
47
|
import { fileURLToPath } from 'node:url';
|
|
41
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
CliStructuredError,
|
|
50
|
+
errorMigrationCliInvalidConfigArg,
|
|
51
|
+
errorMigrationCliUnknownFlag,
|
|
52
|
+
} from '@prisma-next/errors/control';
|
|
42
53
|
import { errorMigrationTargetMismatch } from '@prisma-next/errors/migration';
|
|
43
54
|
import { createControlStack } from '@prisma-next/framework-components/control';
|
|
44
55
|
import { errorInvalidJson, MigrationToolsError } from '@prisma-next/migration-tools/errors';
|
|
45
56
|
import type { MigrationMetadata } from '@prisma-next/migration-tools/metadata';
|
|
46
|
-
import {
|
|
47
|
-
|
|
48
|
-
isDirectEntrypoint,
|
|
49
|
-
type Migration,
|
|
50
|
-
printMigrationHelp,
|
|
51
|
-
} from '@prisma-next/migration-tools/migration';
|
|
57
|
+
import { buildMigrationArtifacts, type Migration } from '@prisma-next/migration-tools/migration';
|
|
58
|
+
import { Cli, Command, Option, UsageError } from 'clipanion';
|
|
52
59
|
import { dirname, join } from 'pathe';
|
|
53
60
|
import { loadConfig } from './config-loader';
|
|
54
61
|
|
|
@@ -70,54 +77,75 @@ import { loadConfig } from './config-loader';
|
|
|
70
77
|
// biome-ignore lint/suspicious/noExplicitAny: see JSDoc - rest args with any are the idiomatic TS pattern for accepting arbitrary subclass constructor signatures
|
|
71
78
|
export type MigrationConstructor = new (...args: any[]) => Migration;
|
|
72
79
|
|
|
73
|
-
interface ParsedArgs {
|
|
74
|
-
readonly help: boolean;
|
|
75
|
-
readonly dryRun: boolean;
|
|
76
|
-
readonly configPath: string | undefined;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
80
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
81
|
+
* Stream surface accepted by `MigrationCLI.run`'s `options.stdout` /
|
|
82
|
+
* `options.stderr`. Aliases node's `Writable` because clipanion's
|
|
83
|
+
* `BaseContext.stdout`/`stderr` are typed as `Writable`, and the CLI
|
|
84
|
+
* forwards the injected streams into clipanion's context.
|
|
84
85
|
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* (
|
|
88
|
-
* either drop dry-run handling or serialize against the wrong project.
|
|
86
|
+
* `process.stdout` and `process.stderr` are `Writable`-shaped, so the
|
|
87
|
+
* default-fallback path remains a no-op for existing two-argument
|
|
88
|
+
* callers like `MigrationCLI.run(import.meta.url, MyMigration)`.
|
|
89
89
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* CLI library"). Until that lands the surface is intentionally tiny.
|
|
90
|
+
* Tests inject a `Writable` subclass that captures chunks for
|
|
91
|
+
* assertions.
|
|
93
92
|
*/
|
|
94
|
-
|
|
95
|
-
let help = false;
|
|
96
|
-
let dryRun = false;
|
|
97
|
-
let configPath: string | undefined;
|
|
98
|
-
|
|
99
|
-
for (let i = 0; i < argv.length; i++) {
|
|
100
|
-
const arg = argv[i]!;
|
|
101
|
-
if (arg === '--help' || arg === '-h') {
|
|
102
|
-
help = true;
|
|
103
|
-
} else if (arg === '--dry-run') {
|
|
104
|
-
dryRun = true;
|
|
105
|
-
} else if (arg === '--config') {
|
|
106
|
-
const next = argv[i + 1];
|
|
107
|
-
if (next === undefined) {
|
|
108
|
-
throw errorMigrationCliInvalidConfigArg();
|
|
109
|
-
}
|
|
110
|
-
if (next.startsWith('-')) {
|
|
111
|
-
throw errorMigrationCliInvalidConfigArg({ nextToken: next });
|
|
112
|
-
}
|
|
113
|
-
configPath = next;
|
|
114
|
-
i++;
|
|
115
|
-
} else if (arg.startsWith('--config=')) {
|
|
116
|
-
configPath = arg.slice('--config='.length);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
93
|
+
export type MigrationCliWritable = Writable;
|
|
119
94
|
|
|
120
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Flags exposed by the migration-file CLI.
|
|
97
|
+
*
|
|
98
|
+
* Must stay in sync with the `Option` declarations on
|
|
99
|
+
* `MigrationFileCommand` below. This list is rendered in the
|
|
100
|
+
* `errorMigrationCliUnknownFlag` envelope's `fix` text and `meta`,
|
|
101
|
+
* so order matters for user-visible output (declaration order is the
|
|
102
|
+
* order users see when they run `--help`).
|
|
103
|
+
*/
|
|
104
|
+
const KNOWN_FLAGS: readonly string[] = ['--help', '--dry-run', '--config'];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The clipanion command that owns the migration-file CLI's option
|
|
108
|
+
* declarations. The class is internal — `MigrationCLI.run` is the
|
|
109
|
+
* stable public surface. Adding a flag here automatically updates
|
|
110
|
+
* `--help` rendering and the `KNOWN_FLAGS` list (the latter must be
|
|
111
|
+
* updated in tandem).
|
|
112
|
+
*/
|
|
113
|
+
class MigrationFileCommand extends Command {
|
|
114
|
+
static override paths = [Command.Default];
|
|
115
|
+
|
|
116
|
+
static override usage = Command.Usage({
|
|
117
|
+
description: 'Self-emit ops.json and migration.json from a class-flow migration',
|
|
118
|
+
details: `
|
|
119
|
+
Loads the project's prisma-next.config.ts, assembles a ControlStack
|
|
120
|
+
from the configured target/adapter/extensions, and serializes the
|
|
121
|
+
migration's operations + metadata next to this file.
|
|
122
|
+
`,
|
|
123
|
+
examples: [
|
|
124
|
+
['Self-emit ops.json + migration.json next to migration.ts', '$0'],
|
|
125
|
+
['Preview without writing files', '$0 --dry-run'],
|
|
126
|
+
['Use a non-default config path', '$0 --config ./custom.config.ts'],
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
dryRun = Option.Boolean('--dry-run', false, {
|
|
131
|
+
description: 'Print operations to stdout without writing files',
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
config = Option.String('--config', {
|
|
135
|
+
description: 'Path to prisma-next.config.ts',
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Unused: orchestration runs inside `MigrationCLI.run` so error
|
|
140
|
+
* routing stays under our control (clipanion's `cli.run` writes
|
|
141
|
+
* error output to `context.stdout`, but our contract requires
|
|
142
|
+
* structured errors on stderr). `cli.process` is used to parse
|
|
143
|
+
* argv into a populated `MigrationFileCommand` instance whose
|
|
144
|
+
* fields drive the orchestration directly.
|
|
145
|
+
*/
|
|
146
|
+
override async execute(): Promise<number> {
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
121
149
|
}
|
|
122
150
|
|
|
123
151
|
/**
|
|
@@ -135,66 +163,297 @@ function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
|
135
163
|
// biome-ignore lint/complexity/noStaticOnlyClass: see JSDoc - intentional class facade for the migration-file CLI surface; future methods will share state derived from argv/config.
|
|
136
164
|
export class MigrationCLI {
|
|
137
165
|
/**
|
|
138
|
-
* Orchestrates a class-flow `migration.ts` script run.
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
166
|
+
* Orchestrates a class-flow `migration.ts` script run.
|
|
167
|
+
*
|
|
168
|
+
* The third argument is the in-process testability surface: callers
|
|
169
|
+
* (and tests) may inject `argv`, `stdout`, and `stderr` instead of
|
|
170
|
+
* relying on `process.argv` / `process.stdout` / `process.stderr`.
|
|
171
|
+
* Each option defaults to its `process` global when omitted, so
|
|
172
|
+
* existing two-argument call sites
|
|
173
|
+
* (`MigrationCLI.run(import.meta.url, MyMigration)`) continue to
|
|
174
|
+
* compile and behave identically.
|
|
175
|
+
*
|
|
176
|
+
* Returns the exit code so the caller can branch on it. Also writes
|
|
177
|
+
* the same code to `process.exitCode` so script-style callers that
|
|
178
|
+
* don't await the return value still surface a non-zero exit when
|
|
179
|
+
* something fails.
|
|
143
180
|
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
181
|
+
* Exit codes:
|
|
182
|
+
* - 0 — success, or `--help`, or imported-not-entrypoint no-op.
|
|
183
|
+
* - 1 — runtime/orchestration error (config not found, target
|
|
184
|
+
* mismatch, etc.).
|
|
185
|
+
* - 2 — usage error (unknown flag, malformed `--config`). Aligns
|
|
186
|
+
* with `docs/CLI Style Guide.md` § Exit Codes.
|
|
148
187
|
*/
|
|
149
|
-
static async run(
|
|
150
|
-
|
|
151
|
-
|
|
188
|
+
static async run(
|
|
189
|
+
importMetaUrl: string,
|
|
190
|
+
MigrationClass: MigrationConstructor,
|
|
191
|
+
options: {
|
|
192
|
+
readonly argv?: readonly string[];
|
|
193
|
+
readonly stdout?: MigrationCliWritable;
|
|
194
|
+
readonly stderr?: MigrationCliWritable;
|
|
195
|
+
} = {},
|
|
196
|
+
): Promise<number> {
|
|
197
|
+
if (!importMetaUrl) {
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
152
200
|
|
|
153
|
-
|
|
154
|
-
|
|
201
|
+
const argv = options.argv ?? process.argv;
|
|
202
|
+
const stdout = options.stdout ?? process.stdout;
|
|
203
|
+
const stderr = options.stderr ?? process.stderr;
|
|
155
204
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
205
|
+
if (!isDirectEntrypoint(importMetaUrl, argv)) {
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
160
208
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
209
|
+
const exitCode = await orchestrate(importMetaUrl, MigrationClass, {
|
|
210
|
+
argv,
|
|
211
|
+
stdout,
|
|
212
|
+
stderr,
|
|
213
|
+
});
|
|
214
|
+
// Preserve any pre-existing non-zero `process.exitCode` set by code
|
|
215
|
+
// running alongside `MigrationCLI.run` (an unhandled rejection
|
|
216
|
+
// upstream, an explicit `process.exitCode = N` from another
|
|
217
|
+
// module). Overwriting it with our success would mask the upstream
|
|
218
|
+
// failure for script-style callers that don't await the return
|
|
219
|
+
// value. Failures we return here are still surfaced — non-zero
|
|
220
|
+
// codes always win over the prior status — but successes never
|
|
221
|
+
// clear it.
|
|
222
|
+
if (exitCode !== 0 || !process.exitCode) {
|
|
223
|
+
process.exitCode = exitCode;
|
|
224
|
+
}
|
|
225
|
+
return exitCode;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Argv-aware variant of the entrypoint guard. The shared
|
|
231
|
+
* `@prisma-next/migration-tools` helper of the same name reads
|
|
232
|
+
* `process.argv[1]` directly, which doesn't compose with the new
|
|
233
|
+
* in-process testability surface (tests inject `argv` without mutating
|
|
234
|
+
* the process global). Inlined here so the migration-file CLI's check
|
|
235
|
+
* follows the injected `argv[1]` consistently.
|
|
236
|
+
*/
|
|
237
|
+
function isDirectEntrypoint(importMetaUrl: string, argv: readonly string[]): boolean {
|
|
238
|
+
const argv1 = argv[1];
|
|
239
|
+
if (!argv1) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
return realpathSync(fileURLToPath(importMetaUrl)) === realpathSync(argv1);
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Argv-and-stream-driven orchestration body. Pulled out of the static
|
|
251
|
+
* method so the entrypoint guard / process-default plumbing stays
|
|
252
|
+
* separate from the parse + load + serialize steps.
|
|
253
|
+
*/
|
|
254
|
+
async function orchestrate(
|
|
255
|
+
importMetaUrl: string,
|
|
256
|
+
MigrationClass: MigrationConstructor,
|
|
257
|
+
ctx: {
|
|
258
|
+
readonly argv: readonly string[];
|
|
259
|
+
readonly stdout: MigrationCliWritable;
|
|
260
|
+
readonly stderr: MigrationCliWritable;
|
|
261
|
+
},
|
|
262
|
+
): Promise<number> {
|
|
263
|
+
const cli = Cli.from([MigrationFileCommand], {
|
|
264
|
+
binaryName: 'migration.ts',
|
|
265
|
+
binaryLabel: 'Migration file CLI',
|
|
266
|
+
});
|
|
184
267
|
|
|
185
|
-
|
|
186
|
-
|
|
268
|
+
const input = ctx.argv.slice(2);
|
|
269
|
+
|
|
270
|
+
// Pre-scan for malformed `--config` (no value, or value-shaped-as-flag)
|
|
271
|
+
// before delegating to clipanion. The legacy parser surfaced both as
|
|
272
|
+
// `errorMigrationCliInvalidConfigArg` (`PN-CLI-4012`); pre-scanning
|
|
273
|
+
// here keeps that contract independent of how clipanion classifies
|
|
274
|
+
// the error internally (it variably throws `UnknownSyntaxError` or
|
|
275
|
+
// accepts the flag-shaped token as the value depending on what other
|
|
276
|
+
// options are registered).
|
|
277
|
+
const configError = detectInvalidConfig(input);
|
|
278
|
+
if (configError) {
|
|
279
|
+
writeStructuredError(ctx.stderr, configError);
|
|
280
|
+
return 2;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let parsed: MigrationFileCommand;
|
|
284
|
+
try {
|
|
285
|
+
const command = cli.process({
|
|
286
|
+
input: [...input],
|
|
287
|
+
context: { stdout: ctx.stdout, stderr: ctx.stderr },
|
|
288
|
+
});
|
|
289
|
+
if (!(command instanceof MigrationFileCommand)) {
|
|
290
|
+
// The only registered command class is `MigrationFileCommand`;
|
|
291
|
+
// any other concrete type indicates clipanion emitted its
|
|
292
|
+
// built-in `HelpCommand`. Render usage directly so we don't
|
|
293
|
+
// depend on calling `cli.run` (which routes errors to stdout —
|
|
294
|
+
// wrong stream for our contract).
|
|
295
|
+
ctx.stdout.write(cli.usage(MigrationFileCommand, { detailed: true }));
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
parsed = command;
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return renderParseError(err, input, ctx.stderr);
|
|
301
|
+
}
|
|
187
302
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
303
|
+
if (parsed.help) {
|
|
304
|
+
ctx.stdout.write(cli.usage(MigrationFileCommand, { detailed: true }));
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
await runMigration(importMetaUrl, MigrationClass, parsed, ctx);
|
|
310
|
+
return 0;
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (CliStructuredError.is(err)) {
|
|
313
|
+
writeStructuredError(ctx.stderr, err);
|
|
314
|
+
} else if (MigrationToolsError.is(err)) {
|
|
315
|
+
// Migration-tools errors (e.g. `errorInvalidJson` thrown by
|
|
316
|
+
// `readExistingMetadata` when migration.json is malformed) carry
|
|
317
|
+
// their own `code`/`why`/`fix` shape. Render them with the same
|
|
318
|
+
// visual structure as `CliStructuredError` so consumers grepping
|
|
319
|
+
// for `MIGRATION.<CODE>` see consistent output across surfaces.
|
|
320
|
+
const fix = err.fix ? `\n${err.fix}` : '';
|
|
321
|
+
ctx.stderr.write(`${err.code}: ${err.message}\n${err.why}${fix}\n`);
|
|
322
|
+
} else {
|
|
323
|
+
ctx.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
324
|
+
}
|
|
325
|
+
return 1;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Returns an `errorMigrationCliInvalidConfigArg` envelope when `input`
|
|
331
|
+
* contains a malformed `--config`:
|
|
332
|
+
*
|
|
333
|
+
* - `--config` as the last token (no value follows).
|
|
334
|
+
* - `--config <flag>` where `<flag>` starts with `-` (silently
|
|
335
|
+
* consuming the next flag would either drop the flag or serialize
|
|
336
|
+
* against the wrong project).
|
|
337
|
+
* - `--config <empty>` where the value is the empty string. Shells
|
|
338
|
+
* expand `--config ""` (or `--config "$UNSET_VAR"`) into a real
|
|
339
|
+
* empty argv token; treating that as a usage error here surfaces
|
|
340
|
+
* `PN-CLI-4012` instead of a less actionable loader error on an
|
|
341
|
+
* empty path.
|
|
342
|
+
* - `--config=` (the equals form with an empty value). Same shape as
|
|
343
|
+
* the empty-string case above; the user expressed intent to override
|
|
344
|
+
* the config path but the override is empty.
|
|
345
|
+
*
|
|
346
|
+
* `--config=<value>` and `--config <value>` with a non-empty value are
|
|
347
|
+
* both valid (and the equals form's value is allowed to start with
|
|
348
|
+
* `-` — the `=` makes the binding explicit).
|
|
349
|
+
*/
|
|
350
|
+
function detectInvalidConfig(input: readonly string[]): CliStructuredError | null {
|
|
351
|
+
for (let i = 0; i < input.length; i++) {
|
|
352
|
+
const token = input[i];
|
|
353
|
+
if (token === '--config') {
|
|
354
|
+
const next = input[i + 1];
|
|
355
|
+
if (next === undefined || next === '') {
|
|
356
|
+
return errorMigrationCliInvalidConfigArg();
|
|
357
|
+
}
|
|
358
|
+
if (next.startsWith('-')) {
|
|
359
|
+
return errorMigrationCliInvalidConfigArg({ nextToken: next });
|
|
194
360
|
}
|
|
195
|
-
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (token === '--config=') {
|
|
364
|
+
return errorMigrationCliInvalidConfigArg();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Translate clipanion's parse-time errors into the project's structured
|
|
372
|
+
* error envelopes.
|
|
373
|
+
*
|
|
374
|
+
* - `UnknownSyntaxError` covers both unknown flags (`--frobnicate`) and
|
|
375
|
+
* the bare-trailing `--config` case (where arity-1 needs a value but
|
|
376
|
+
* none was supplied). Distinguished by inspecting the input array.
|
|
377
|
+
* - `UsageError` covers schema/validator failures from typanion. None
|
|
378
|
+
* of the migration-file CLI's options have validators today, but we
|
|
379
|
+
* still translate it as a usage error (exit 2) for forward-compat.
|
|
380
|
+
* - Anything else re-throws — caller's outer catch will surface it as
|
|
381
|
+
* exit 1 (runtime error).
|
|
382
|
+
*/
|
|
383
|
+
function renderParseError(
|
|
384
|
+
err: unknown,
|
|
385
|
+
input: readonly string[],
|
|
386
|
+
stderr: MigrationCliWritable,
|
|
387
|
+
): number {
|
|
388
|
+
if (isUnknownSyntaxError(err)) {
|
|
389
|
+
const flag = findOffendingFlag(input);
|
|
390
|
+
writeStructuredError(stderr, errorMigrationCliUnknownFlag({ flag, knownFlags: KNOWN_FLAGS }));
|
|
391
|
+
return 2;
|
|
392
|
+
}
|
|
393
|
+
if (err instanceof UsageError) {
|
|
394
|
+
// typanion validator failures and similar usage errors. None of
|
|
395
|
+
// the migration-file CLI's options have validators today, so this
|
|
396
|
+
// branch is forward-compat scaffolding — kept so that a future
|
|
397
|
+
// option declaration with a validator routes through the same PN
|
|
398
|
+
// envelope path rather than escaping as exit 1.
|
|
399
|
+
writeStructuredError(stderr, errorMigrationCliInvalidConfigArg({ nextToken: err.message }));
|
|
400
|
+
return 2;
|
|
401
|
+
}
|
|
402
|
+
throw err;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Duck-type check for clipanion's `UnknownSyntaxError`: the class is
|
|
407
|
+
* thrown by the parser but is not re-exported from the package's main
|
|
408
|
+
* entry (only `UsageError` is — see clipanion's `advanced/index.d.ts`).
|
|
409
|
+
* Identified by `name === 'UnknownSyntaxError'` and the
|
|
410
|
+
* `clipanion.type === 'none'` discriminator that clipanion's
|
|
411
|
+
* `ErrorWithMeta` interface guarantees.
|
|
412
|
+
*/
|
|
413
|
+
function isUnknownSyntaxError(err: unknown): err is Error {
|
|
414
|
+
if (!(err instanceof Error) || err.name !== 'UnknownSyntaxError') {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
// clipanion's `ErrorWithMeta` interface guarantees a `clipanion` field with
|
|
418
|
+
// a `type` discriminator on every error it throws. Read it via a structural
|
|
419
|
+
// shape rather than importing the class (it's not re-exported from the
|
|
420
|
+
// package main).
|
|
421
|
+
const meta = (err as { clipanion?: { type?: string } }).clipanion;
|
|
422
|
+
return typeof meta === 'object' && meta !== null && meta.type === 'none';
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Best-effort: pull the first input token that doesn't match a known
|
|
427
|
+
* flag. Falls back to the first token when we can't pinpoint it. The
|
|
428
|
+
* returned name is rendered into the user-visible PN-CLI-4013 envelope
|
|
429
|
+
* (`Unknown flag \`<name>\``) and round-tripped via `meta.flag` so
|
|
430
|
+
* agent consumers can render their own "did you mean" suggestions.
|
|
431
|
+
*/
|
|
432
|
+
function findOffendingFlag(input: readonly string[]): string {
|
|
433
|
+
for (const token of input) {
|
|
434
|
+
if (!token.startsWith('-')) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const head = token.split('=', 1)[0] ?? token;
|
|
438
|
+
if (!KNOWN_FLAGS.includes(head) && head !== '-h') {
|
|
439
|
+
return head;
|
|
196
440
|
}
|
|
197
441
|
}
|
|
442
|
+
return input[0] ?? '';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Write a `CliStructuredError` envelope to the given stream. Format
|
|
447
|
+
* matches the legacy hand-rolled writer (`message: why`) so the rest of
|
|
448
|
+
* the project's error rendering stays consistent across surfaces. The
|
|
449
|
+
* full PN code (`PN-<domain>-<code>`) is included so consumers can
|
|
450
|
+
* grep for stable identifiers.
|
|
451
|
+
*/
|
|
452
|
+
function writeStructuredError(stream: MigrationCliWritable, err: CliStructuredError): void {
|
|
453
|
+
const envelope = err.toEnvelope();
|
|
454
|
+
const why = envelope.why ?? envelope.summary;
|
|
455
|
+
const fix = envelope.fix ? `\n${envelope.fix}` : '';
|
|
456
|
+
stream.write(`${envelope.code}: ${envelope.summary}\n${why}${fix}\n`);
|
|
198
457
|
}
|
|
199
458
|
|
|
200
459
|
/**
|
|
@@ -250,20 +509,69 @@ function serializeMigrationToDisk(
|
|
|
250
509
|
instance: Migration,
|
|
251
510
|
migrationDir: string,
|
|
252
511
|
dryRun: boolean,
|
|
512
|
+
stdout: MigrationCliWritable,
|
|
253
513
|
): void {
|
|
254
514
|
const metadataPath = join(migrationDir, 'migration.json');
|
|
255
515
|
const existing = readExistingMetadata(metadataPath);
|
|
256
516
|
const { opsJson, metadataJson } = buildMigrationArtifacts(instance, existing);
|
|
257
517
|
|
|
258
518
|
if (dryRun) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
519
|
+
stdout.write(`--- migration.json ---\n${metadataJson}\n`);
|
|
520
|
+
stdout.write('--- ops.json ---\n');
|
|
521
|
+
stdout.write(`${opsJson}\n`);
|
|
262
522
|
return;
|
|
263
523
|
}
|
|
264
524
|
|
|
265
525
|
writeFileSync(join(migrationDir, 'ops.json'), opsJson);
|
|
266
526
|
writeFileSync(metadataPath, metadataJson);
|
|
267
527
|
|
|
268
|
-
|
|
528
|
+
stdout.write(`Wrote ops.json + migration.json to ${migrationDir}\n`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Inner orchestration: load config, probe-construct the migration,
|
|
533
|
+
* verify target, assemble the stack, construct with the stack, persist.
|
|
534
|
+
*
|
|
535
|
+
* Throws `CliStructuredError` for known failure modes (config not
|
|
536
|
+
* found, target mismatch); the outer `orchestrate` translates those to
|
|
537
|
+
* exit 1.
|
|
538
|
+
*/
|
|
539
|
+
async function runMigration(
|
|
540
|
+
importMetaUrl: string,
|
|
541
|
+
MigrationClass: MigrationConstructor,
|
|
542
|
+
parsed: MigrationFileCommand,
|
|
543
|
+
ctx: {
|
|
544
|
+
readonly stdout: MigrationCliWritable;
|
|
545
|
+
readonly stderr: MigrationCliWritable;
|
|
546
|
+
},
|
|
547
|
+
): Promise<void> {
|
|
548
|
+
const migrationFile = fileURLToPath(importMetaUrl);
|
|
549
|
+
const migrationDir = dirname(migrationFile);
|
|
550
|
+
|
|
551
|
+
const config = await loadConfig(parsed.config);
|
|
552
|
+
|
|
553
|
+
// Probe-instantiate without a stack so we can read `targetId` before
|
|
554
|
+
// any target-specific constructor side effects (e.g.
|
|
555
|
+
// `PostgresMigration`'s `stack.adapter.create(stack)`) run. Concrete
|
|
556
|
+
// subclasses are required to accept the no-arg form; the abstract
|
|
557
|
+
// `Migration` constructor declares `stack?` and target subclasses
|
|
558
|
+
// (Postgres, Mongo) propagate that optionality. This makes the
|
|
559
|
+
// target-mismatch guard fail fast with `PN-MIG-2006` before any
|
|
560
|
+
// stack-driven adapter construction begins, even if the wrong-target
|
|
561
|
+
// adapter's `create` would otherwise succeed and silently misshapen
|
|
562
|
+
// the stored adapter cast.
|
|
563
|
+
const probe = new MigrationClass();
|
|
564
|
+
|
|
565
|
+
if (probe.targetId !== config.target.targetId) {
|
|
566
|
+
throw errorMigrationTargetMismatch({
|
|
567
|
+
migrationTargetId: probe.targetId,
|
|
568
|
+
configTargetId: config.target.targetId,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const stack = createControlStack(config);
|
|
573
|
+
const instance = new MigrationClass(stack);
|
|
574
|
+
|
|
575
|
+
serializeMigrationToDisk(instance, migrationDir, parsed.dryRun, ctx.stdout);
|
|
576
|
+
void ctx.stderr;
|
|
269
577
|
}
|
package/src/utils/cli-errors.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Re-export all domain error factories from @prisma-next/errors for convenience.
|
|
3
|
-
* CLI-specific errors (e.g., Commander
|
|
3
|
+
* CLI-specific errors (e.g., Commander argument validation in the main CLI, or
|
|
4
|
+
* clipanion parse errors in the migration-file CLI) can be added here if needed.
|
|
4
5
|
*/
|
|
5
6
|
export type { CliErrorConflict, CliErrorEnvelope } from '@prisma-next/errors/control';
|
|
6
7
|
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
errorFamilyReadMarkerSqlRequired,
|
|
17
18
|
errorFileNotFound,
|
|
18
19
|
errorMigrationCliInvalidConfigArg,
|
|
20
|
+
errorMigrationCliUnknownFlag,
|
|
19
21
|
errorMigrationPlanningFailed,
|
|
20
22
|
errorQueryRunnerFactoryRequired,
|
|
21
23
|
errorTargetMigrationNotSupported,
|
|
@@ -36,6 +38,7 @@ export {
|
|
|
36
38
|
errorFamilyReadMarkerSqlRequired,
|
|
37
39
|
errorFileNotFound,
|
|
38
40
|
errorMigrationCliInvalidConfigArg,
|
|
41
|
+
errorMigrationCliUnknownFlag,
|
|
39
42
|
errorMigrationPlanningFailed,
|
|
40
43
|
errorQueryRunnerFactoryRequired,
|
|
41
44
|
errorTargetMigrationNotSupported,
|