@ultimat3/cli 7.0.0 → 9.0.0

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 (78) hide show
  1. package/CLAUDE.md +24 -4
  2. package/README.md +8 -3
  3. package/package.json +26 -25
  4. package/src/app-boundaries.ts +55 -5
  5. package/src/app-load.ts +7 -0
  6. package/src/bin.ts +6 -3
  7. package/src/ci-log.ts +0 -0
  8. package/src/cmd-db-backfill.ts +240 -0
  9. package/src/cmd-db-branch.ts +3 -2
  10. package/src/cmd-db.ts +35 -156
  11. package/src/cmd-deploy.ts +43 -6
  12. package/src/cmd-dev.ts +7 -1
  13. package/src/cmd-errors.ts +2 -3
  14. package/src/cmd-fix.ts +3 -3
  15. package/src/cmd-i18n.ts +67 -5
  16. package/src/cmd-jobs.ts +27 -4
  17. package/src/cmd-mcp.ts +18 -9
  18. package/src/cmd-new.ts +91 -4
  19. package/src/cmd-policy.ts +3 -2
  20. package/src/cmd-pr.ts +55 -4
  21. package/src/cmd-registries.ts +3 -2
  22. package/src/cmd-shot.ts +68 -6
  23. package/src/cmd-tasks.ts +9 -4
  24. package/src/cmd-verify.ts +47 -6
  25. package/src/dev-assets.ts +4 -7
  26. package/src/dev-cache.ts +130 -33
  27. package/src/dev-lock.ts +124 -12
  28. package/src/dev-purge.ts +120 -0
  29. package/src/dev-queue.ts +39 -9
  30. package/src/dev-render.ts +11 -14
  31. package/src/dev-replicator.ts +3 -7
  32. package/src/dev-roles-fixture.ts +1 -1
  33. package/src/dev-roles.ts +40 -8
  34. package/src/dev-runtime.ts +137 -6
  35. package/src/dev-sync.ts +9 -4
  36. package/src/dispatch.ts +35 -5
  37. package/src/document-styles.ts +2 -1
  38. package/src/drift.ts +52 -7
  39. package/src/error-codes.ts +5 -0
  40. package/src/framework-scope.ts +57 -5
  41. package/src/generate-kinds.ts +19 -1
  42. package/src/i18n-registration.ts +67 -4
  43. package/src/index.ts +1 -1
  44. package/src/island-bundle.ts +2 -6
  45. package/src/island-styles.ts +1 -1
  46. package/src/jobs-report.ts +10 -13
  47. package/src/mcp-errors.ts +3 -0
  48. package/src/messages.ts +12 -0
  49. package/src/output.ts +22 -2
  50. package/src/parse.ts +81 -37
  51. package/src/prerender.ts +2 -1
  52. package/src/realtime-browser-probe-fixture.ts +9 -0
  53. package/src/runtime-overrides.ts +12 -4
  54. package/src/serve.ts +1 -1
  55. package/src/shot-settle.ts +57 -0
  56. package/src/shot-verdict.ts +27 -4
  57. package/src/solid-loader.ts +1 -1
  58. package/src/style-csp.ts +2 -1
  59. package/src/sync-authenticator.ts +86 -14
  60. package/src/templates/guard-bare-error.ts +122 -0
  61. package/src/templates/guard-raw-colour.ts +138 -0
  62. package/src/templates/guard-untranslated-string.ts +138 -0
  63. package/src/templates/guard-unzoned-date.ts +142 -0
  64. package/src/templates/index.ts +3 -0
  65. package/src/templates/island.ts +2 -1
  66. package/src/templates/route.ts +1 -1
  67. package/src/templates/scaffold-app.ts +3 -82
  68. package/src/templates/scaffold-container.ts +30 -4
  69. package/src/templates/scaffold-db-package.ts +14 -6
  70. package/src/templates/scaffold-docs.ts +34 -16
  71. package/src/templates/scaffold-entries.ts +131 -0
  72. package/src/templates/scaffold-guards.ts +26 -0
  73. package/src/templates/scaffold-repo.ts +40 -7
  74. package/src/test-select.ts +4 -3
  75. package/src/verify-run.ts +25 -3
  76. package/src/verify-step.ts +11 -2
  77. package/src/verify-tests.ts +11 -3
  78. package/src/write-line.ts +23 -5
package/src/cmd-db.ts CHANGED
@@ -14,24 +14,11 @@ import { join } from 'node:path';
14
14
  import { resolveEnvironment } from '@ultimat3/core';
15
15
  import { type DriftReport, driftError, withTransaction } from '@ultimat3/db';
16
16
  import { postgresDriver } from '@ultimat3/entity';
17
- import { BackfillPendingError } from '@ultimat3/jobs';
18
- import { loadApp } from './app-load';
19
17
  import { requireAppRoot } from './app-root';
18
+ import { runBackfillCommand } from './cmd-db-backfill';
20
19
  import { runBranchCommand } from './cmd-db-branch';
21
20
  import { plannedSubcommand } from './cmd-planned';
22
21
  import type { CliCommand, CommandContext } from './command';
23
- import type { BackfillAction, BackfillPlanRow } from './db-backfill';
24
- import {
25
- listBackfills,
26
- pendingReport,
27
- pendingToJson,
28
- planToJson,
29
- readAppliedMigrations,
30
- renderBackfillTable,
31
- renderPendingTable,
32
- renderPlanTable,
33
- runBackfills,
34
- } from './db-backfill';
35
22
  import { BRANCH_SUBCOMMANDS } from './db-branch';
36
23
  import { stepFinding } from './db-finding';
37
24
  import { generateAppMigration } from './db-generate';
@@ -46,14 +33,8 @@ import {
46
33
  selectSeeds,
47
34
  } from './db-seed';
48
35
  import { resolveServices } from './dev-services';
49
- import {
50
- BadFlagError,
51
- CliNotImplementedError,
52
- MissingSubcommandError,
53
- UnknownCommandError,
54
- } from './errors';
36
+ import { CliNotImplementedError, MissingSubcommandError, UnknownCommandError } from './errors';
55
37
  import { withJobDriver } from './jobs-driver';
56
- import { backfillToJson } from './jobs-json';
57
38
  import { msg } from './messages';
58
39
  import type { CommandResult, Finding } from './output';
59
40
  import { findingFrom } from './output';
@@ -82,6 +63,9 @@ export const dbCommand: CliCommand = {
82
63
  // is what lets the `errors` step resolve `x db branch ls` — a fix line three shipped errors
83
64
  // hand out, which read `ls` as a branch name and cloned a database until 1.2.x.
84
65
  subcommandPositionals: { branch: BRANCH_SUBCOMMANDS },
66
+ // Each flag whose summary begins `<subcommand>:` declares that scope, and the parser refuses
67
+ // it anywhere else: `x db gen --dry-run` used to parse, reach `runGen` and WRITE the
68
+ // migration. `cmd-db.test.ts` pins summary and scope to the same fact.
85
69
  flags: [
86
70
  {
87
71
  name: 'name',
@@ -92,31 +76,56 @@ export const dbCommand: CliCommand = {
92
76
  name: 'tier',
93
77
  type: 'string',
94
78
  summary: 'seed: which tier to run — reference or dev; also ULTIMATE_SEED_TIER',
79
+ subcommands: ['seed'],
95
80
  },
96
81
  {
97
82
  name: 'dry-run',
98
83
  type: 'boolean',
99
84
  summary: 'seed: report what each seed would write, and write nothing',
85
+ subcommands: ['seed'],
86
+ },
87
+ {
88
+ name: 'list',
89
+ type: 'boolean',
90
+ summary: 'backfill: print the x_backfills ledger',
91
+ subcommands: ['backfill'],
100
92
  },
101
- { name: 'list', type: 'boolean', summary: 'backfill: print the x_backfills ledger' },
102
93
  {
103
94
  name: 'pending',
104
95
  type: 'boolean',
105
96
  summary: 'backfill: declared minus completed; non-zero exit when anything is unswept',
97
+ subcommands: ['backfill'],
98
+ },
99
+ {
100
+ name: 'all',
101
+ type: 'boolean',
102
+ summary: 'backfill: every pending sweep, isolated per name',
103
+ subcommands: ['backfill'],
104
+ },
105
+ {
106
+ name: 'write',
107
+ type: 'boolean',
108
+ summary: 'backfill: enqueue the pass; dry run without it',
109
+ subcommands: ['backfill'],
106
110
  },
107
- { name: 'all', type: 'boolean', summary: 'backfill: every pending sweep, isolated per name' },
108
- { name: 'write', type: 'boolean', summary: 'backfill: enqueue the pass; dry run without it' },
109
111
  {
110
112
  name: 'force',
111
113
  type: 'boolean',
112
114
  summary: 'backfill: sweep a name the ledger records as completed, as a NEW ledger row',
115
+ subcommands: ['backfill'],
113
116
  },
114
117
  {
115
118
  name: 'status',
116
119
  type: 'string',
117
120
  summary: 'backfill: filter by running, completed or failed',
121
+ subcommands: ['backfill'],
122
+ },
123
+ {
124
+ name: 'limit',
125
+ type: 'string',
126
+ summary: 'backfill: max ledger rows to return',
127
+ subcommands: ['backfill'],
118
128
  },
119
- { name: 'limit', type: 'string', summary: 'backfill: max ledger rows to return' },
120
129
  // Declared because `X_MIGRATION_IRREVERSIBLE`'s own fix line names it. A `fix:` is copied
121
130
  // and run verbatim, so a flag the parser refuses would make the error unfollowable.
122
131
  {
@@ -141,7 +150,7 @@ export const dbCommand: CliCommand = {
141
150
  if (sub === 'reset') return runReset(ctx, root);
142
151
  if (sub === 'seed') return runSeed(ctx, root);
143
152
  if (sub === 'studio') throw plannedSubcommand('db', 'studio');
144
- if (sub === 'backfill') return runBackfill(ctx, root);
153
+ if (sub === 'backfill') return runBackfillCommand(ctx, root);
145
154
  if (sub === 'branch') return runBranchCommand(ctx, root);
146
155
 
147
156
  // Never a fall-through. This used to end `return runBranch(ctx, root, argument ?? 'preview')`,
@@ -360,133 +369,3 @@ function seedPassResult(
360
369
  data: seedPassToJson(rows),
361
370
  };
362
371
  }
363
-
364
- /**
365
- * Four shapes, one subcommand: `--list` reads the `x_backfills` ledger, `--pending` diffs it
366
- * against what the app DECLARED, and `<name>` / `--all` gate a pass and put it on the queue. A
367
- * bare `x db backfill` is still refused rather than defaulted — the four answer four different
368
- * questions, and picking one for the operator is the ambiguity axiom 1 exists to refuse.
369
- *
370
- * An empty ledger is `ok: true`. "Nothing has swept this database yet" is an answer to the
371
- * question asked, and a command that failed over it would be unrunnable on a fresh app.
372
- */
373
- async function runBackfill(ctx: CommandContext, root: string): Promise<CommandResult> {
374
- if (flagBool(ctx.args, 'list')) return runBackfillList(ctx, root);
375
- const all = flagBool(ctx.args, 'all');
376
- const name = ctx.args.positionals[0] ?? flagString(ctx.args, 'name');
377
- if (flagBool(ctx.args, 'pending')) return runBackfillPending(ctx, root);
378
- if (all) return runBackfillPass(ctx, root, 'all');
379
- if (name !== undefined) return runBackfillPass(ctx, root, [name]);
380
- throw new BadFlagError({
381
- flag: 'list',
382
- command: 'db',
383
- reason:
384
- 'x db backfill needs a shape: --list (the ledger), --pending (declared minus completed), <name> or --all (run one, or every pending one)',
385
- fix: 'x db backfill --pending --json',
386
- });
387
- }
388
-
389
- async function runBackfillList(ctx: CommandContext, root: string): Promise<CommandResult> {
390
- return withJobDriver(root, ctx, async (driver) => {
391
- const rows = await listBackfills(driver, {
392
- name: flagString(ctx.args, 'name'),
393
- status: flagString(ctx.args, 'status'),
394
- limit: flagString(ctx.args, 'limit'),
395
- });
396
- return {
397
- ok: true,
398
- command: 'db',
399
- summary:
400
- rows.length === 0
401
- ? msg('cli.db.backfill.empty')
402
- : msg('cli.db.backfill.listed', { count: rows.length }),
403
- lines: rows.length === 0 ? [] : renderBackfillTable(rows).map((line) => ` ${line}`),
404
- data: rows.map(backfillToJson),
405
- };
406
- });
407
- }
408
-
409
- /**
410
- * The alarm the framework did not have. Non-zero when anything is unswept, so a cron or a deploy
411
- * check can read the exit code — a `--json` nobody has to parse to know something is wrong.
412
- * `loadApp` first: importing the app's modules IS the declaration, and a diff run without it
413
- * would report a clean database against an empty declaration list.
414
- */
415
- async function runBackfillPending(ctx: CommandContext, root: string): Promise<CommandResult> {
416
- await loadApp(root);
417
- const environment = resolveEnvironment({ env: ctx.env });
418
- return withJobDriver(root, ctx, async (driver) => {
419
- const report = await pendingReport(driver, environment);
420
- return {
421
- ok: report.pending.length === 0,
422
- command: 'db',
423
- summary:
424
- report.pending.length === 0
425
- ? msg('cli.db.backfill.swept', { declared: report.rows.length })
426
- : msg('cli.db.backfill.pending', {
427
- count: report.pending.length,
428
- declared: report.rows.length,
429
- }),
430
- findings: report.pending.map((row) =>
431
- findingFrom(new BackfillPendingError({ backfill: row.name, environment })),
432
- ),
433
- lines: report.rows.length === 0 ? [] : renderPendingTable(report).map((line) => ` ${line}`),
434
- data: pendingToJson(report),
435
- };
436
- });
437
- }
438
-
439
- /**
440
- * DRY RUN by default: `--write` is never implied, because the alternative is a command whose
441
- * inspection form writes to a production table. What `--write` does is ENQUEUE — the queue is a
442
- * job's execution surface, so the sweep runs on the workers already serving the new release
443
- * rather than inside this process.
444
- */
445
- async function runBackfillPass(
446
- ctx: CommandContext,
447
- root: string,
448
- names: readonly string[] | 'all',
449
- ): Promise<CommandResult> {
450
- await loadApp(root);
451
- const environment = resolveEnvironment({ env: ctx.env });
452
- const write = flagBool(ctx.args, 'write');
453
- return withJobDriver(root, ctx, async (driver) => {
454
- const rows = await runBackfills({
455
- driver,
456
- names,
457
- write,
458
- force: flagBool(ctx.args, 'force'),
459
- environment,
460
- appliedMigrations: await readAppliedMigrations(),
461
- });
462
- return backfillPassResult(rows, write);
463
- });
464
- }
465
-
466
- /**
467
- * A blocked or deduped name is a finding and a non-zero exit, and every OTHER name still ran —
468
- * that isolation is what stops one wedged cleanup blocking every later one forever.
469
- */
470
- function backfillPassResult(rows: readonly BackfillPlanRow[], write: boolean): CommandResult {
471
- const findings = rows.flatMap((row) => (row.finding === null ? [] : [row.finding]));
472
- // Counted per action, never derived from the total: a deduped pass is neither enqueued nor
473
- // blocked, and `rows.length - enqueued` reported it as blocked while `--json` reported it as
474
- // deduped. `planToJson` is the same list, so the two renders now add up to the same run.
475
- const tally = (action: BackfillAction): number =>
476
- rows.filter((row) => row.action === action).length;
477
- return {
478
- ok: findings.length === 0,
479
- command: 'db',
480
- summary: write
481
- ? msg('cli.db.backfill.planned', {
482
- count: rows.length,
483
- enqueued: tally('enqueued'),
484
- deduped: tally('deduped'),
485
- blocked: tally('blocked'),
486
- })
487
- : msg('cli.db.backfill.dryRun', { count: rows.length }),
488
- findings,
489
- lines: rows.length === 0 ? [] : renderPlanTable(rows).map((line) => ` ${line}`),
490
- data: planToJson(rows),
491
- };
492
- }
package/src/cmd-deploy.ts CHANGED
@@ -9,6 +9,7 @@ import { BadFlagError, UnknownCommandError } from './errors';
9
9
  import { msg } from './messages';
10
10
  import type { CommandResult, JsonValue } from './output';
11
11
  import { flagBool, flagString } from './parse';
12
+ import { quoteArg } from './shell-quote';
12
13
 
13
14
  /**
14
15
  * Ordered, and the order is the design. `migrate` GATES — it runs to completion before anything
@@ -63,6 +64,16 @@ export interface DeployPlan {
63
64
  readonly image: string;
64
65
  /** Ordered: migrate runs to completion before any role that serves traffic starts. */
65
66
  readonly steps: readonly { readonly role: string; readonly command: readonly string[] }[];
67
+ /**
68
+ * The environment every step runs with — how the COMPOSE method carries the image, because
69
+ * `docker-compose.prod.yml` resolves each service from `${IMAGE:-ultimate-app:latest}` and
70
+ * `docker compose` takes no image argument. Without it `--image` decided nothing: the plan
71
+ * reported the reference the operator asked for while the six steps read `IMAGE` off the
72
+ * ambient environment, or deployed `ultimate-app:latest` where it was unset. Helm carries none
73
+ * — the chart reads `--set image.repository/tag`, and an env var it never looks at would be a
74
+ * second answer to which image is being deployed.
75
+ */
76
+ readonly env: Readonly<Record<string, string>>;
66
77
  }
67
78
 
68
79
  /**
@@ -100,6 +111,7 @@ export function planDeploy(image: string, method: DeployMethod, root: string): D
100
111
  }
101
112
  return {
102
113
  image,
114
+ env: {},
103
115
  steps: [
104
116
  {
105
117
  role: 'all',
@@ -117,6 +129,10 @@ export function planDeploy(image: string, method: DeployMethod, root: string): D
117
129
  }
118
130
  return {
119
131
  image,
132
+ // The one place the compose file's own variable is named. `docker/docker-compose.prod.yml`'s
133
+ // header documents `IMAGE=… docker compose …` as the way to run it by hand; this is that line,
134
+ // performed.
135
+ env: { IMAGE: image },
120
136
  steps: DEPLOY_ROLES.map((role) => ({
121
137
  role,
122
138
  command: [
@@ -132,6 +148,19 @@ export function planDeploy(image: string, method: DeployMethod, root: string): D
132
148
  };
133
149
  }
134
150
 
151
+ /**
152
+ * One step, as the line an operator would type — the environment first, then the command.
153
+ *
154
+ * `plan.env` is the compose file's own `IMAGE=…` variable, and it is what makes `--image` true on
155
+ * that method: a rendered line without it deploys `docker-compose.prod.yml`'s DEFAULT image. Both
156
+ * renderers and the failure `fix:` go through here, so the plan `--json` reports, the plan the
157
+ * terminal shows and the line the refusal hands back can never name three different deployments.
158
+ */
159
+ const stepLine = (env: Readonly<Record<string, string>>, command: readonly string[]): string =>
160
+ [...Object.entries(env).map(([name, value]) => `${name}=${quoteArg(value)}`), ...command].join(
161
+ ' ',
162
+ );
163
+
135
164
  export const deployCommand: CliCommand = {
136
165
  spec: {
137
166
  name: 'deploy',
@@ -146,9 +175,12 @@ export const deployCommand: CliCommand = {
146
175
  // `critical: <bool>`, and no file in `packages/` read that field — so the flag changed
147
176
  // nothing about what `x deploy` did, on either method. `flag-reads.ts`'s
148
177
  // `X_CLI_FLAG_UNREAD` passed it, because that gate proves a flag is READ and this one was:
149
- // into a field with no reader. Forcing a reload is `@ultimat3/pwa`'s
150
- // `updateSignal({ reason: 'security' })`, which has no runtime caller either; a flag that
151
- // triggers it is a change in that package, and this was not it.
178
+ // into a field with no reader. It is not coming back: `@ultimat3/pwa`'s
179
+ // `updateSignal({ reason: 'security' })`, the call it was to have triggered, is **deleted**
180
+ // as of 9.0.0 for having had no runtime caller of its own, and nothing in the framework
181
+ // force-navigates a client. A deploy also has no channel to one — the plan is
182
+ // `docker compose up` / `helm upgrade`, and the client's build id is read by `http` (tier 2)
183
+ // and `sync` (tier 3), neither of which may import a tier-4 package to act on it.
152
184
  ],
153
185
  },
154
186
  async run(ctx: CommandContext): Promise<CommandResult> {
@@ -165,6 +197,9 @@ export const deployCommand: CliCommand = {
165
197
  const planJson: JsonValue = {
166
198
  image: plan.image,
167
199
  method,
200
+ // Reported, because it is what makes `image` above true on the compose method — a dry run
201
+ // that names an image the steps do not carry is the defect this field closed.
202
+ env: { ...plan.env },
168
203
  steps: plan.steps.map((step) => ({ role: step.role, command: step.command.join(' ') })),
169
204
  };
170
205
  if (flagBool(ctx.args, 'dry-run')) {
@@ -173,11 +208,13 @@ export const deployCommand: CliCommand = {
173
208
  command: 'deploy',
174
209
  summary: msg('cli.deploy.plan', { images: 1, roles: DEPLOY_ROLES.join(',') }),
175
210
  data: planJson,
176
- lines: plan.steps.map((step) => ` ${step.role.padEnd(10)} ${step.command.join(' ')}`),
211
+ lines: plan.steps.map(
212
+ (step) => ` ${step.role.padEnd(10)} ${stepLine(plan.env, step.command)}`,
213
+ ),
177
214
  };
178
215
  }
179
216
  for (const step of plan.steps) {
180
- const result = await ctx.runner(step.command, { cwd: root });
217
+ const result = await ctx.runner(step.command, { cwd: root, env: plan.env });
181
218
  if (!result.ok) {
182
219
  return {
183
220
  ok: false,
@@ -187,7 +224,7 @@ export const deployCommand: CliCommand = {
187
224
  {
188
225
  code: 'X_DEPLOY_FAILED',
189
226
  cause: `role "${step.role}" step exited ${result.code}`,
190
- fix: `${step.command.join(' ')} # run it directly to see the full output`,
227
+ fix: `${stepLine(plan.env, step.command)} # run it directly to see the full output`,
191
228
  docs: 'https://ultimate.dev/errors/X_DEPLOY_FAILED',
192
229
  },
193
230
  ],
package/src/cmd-dev.ts CHANGED
@@ -302,7 +302,7 @@ export const devCommand: CliCommand = {
302
302
  // `fix:` named `x dev`. Neither is discoverable from the message; both are trivial once the
303
303
  // preflight has the state directory and the port in front of it.
304
304
  const services = resolveServices(root, ctx.env);
305
- const { clearedStale } = await preflight({
305
+ const { clearedStale, release } = await preflight({
306
306
  stateDir: services.stateDir,
307
307
  port,
308
308
  // The address the web role will actually bind, never a wider one: probing `0.0.0.0` would
@@ -310,6 +310,9 @@ export const devCommand: CliCommand = {
310
310
  hostname: DEV_BINDING.hostname,
311
311
  embeddedDb: services.db.mode === 'embedded',
312
312
  });
313
+ // The directory is CLAIMED from here down, so a boot that throws has to give it back — the
314
+ // `releaseBoot` shape, with one acquisition. Without it the first failed `x dev` in a shell
315
+ // refuses every later one with a pid that is no longer running.
313
316
  const server = await startDev({
314
317
  root,
315
318
  port,
@@ -319,6 +322,9 @@ export const devCommand: CliCommand = {
319
322
  if (!ctx.args.json)
320
323
  process.stdout.write(`${msg('cli.dev.hmr', { file, ms: durationMs })}\n`);
321
324
  },
325
+ }).catch((error: unknown) => {
326
+ release();
327
+ throw error;
322
328
  });
323
329
  const result: CommandResult = {
324
330
  ok: server.findings.length === 0,
package/src/cmd-errors.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { singleLine } from '@ultimat3/core';
1
+ import { nearestName, singleLine } from '@ultimat3/core';
2
2
  // `x errors explain <CODE>` / `x errors list` — the error table, programmatically. An agent that
3
3
  // hits an `X_*` code should not have to leave the terminal to learn what it means, and a code it
4
4
  // invented should come back refused: the answer to an unregistered code is "no such code", never
@@ -13,7 +13,6 @@ import { ErrorCodeUnknownError, MissingPositionalError } from './errors';
13
13
  import { explainErrorCode, explainEveryErrorCode } from './mcp-errors';
14
14
  import { msg } from './messages';
15
15
  import type { CommandResult, JsonValue } from './output';
16
- import { nearest } from './parse';
17
16
 
18
17
  export const ERRORS_SUBCOMMANDS = ['explain', 'list'] as const;
19
18
 
@@ -44,7 +43,7 @@ const detailLines = (explanation: ErrorExplanation): readonly string[] => [
44
43
  function explainOne(code: string): CommandResult {
45
44
  const explanation = explainErrorCode(code);
46
45
  if (explanation === undefined) {
47
- const suggestion = nearest(
46
+ const suggestion = nearestName(
48
47
  code,
49
48
  explainEveryErrorCode().map((entry) => entry.code),
50
49
  );
package/src/cmd-fix.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // (`docs/architecture/02-boundaries.md`) — a caller runs the printed edit, or the generated
4
4
  // `git mv`, itself.
5
5
 
6
+ import { nearestName } from '@ultimat3/core';
6
7
  import { appImportGraph, readAppSources } from './app-boundaries';
7
8
  import { requireAppRoot } from './app-root';
8
9
  import type { BoundaryCut } from './boundary-cuts';
@@ -11,7 +12,6 @@ import type { CliCommand, CommandContext } from './command';
11
12
  import { BadFlagError, FixTargetUnknownError, MissingPositionalError } from './errors';
12
13
  import { msg } from './messages';
13
14
  import type { CommandResult, Finding, JsonValue } from './output';
14
- import { nearest } from './parse';
15
15
 
16
16
  export type { BoundaryCut };
17
17
  export { planBoundaryCuts };
@@ -39,7 +39,7 @@ function resolveTarget(input: string, paths: readonly string[]): string {
39
39
  // Compare on the last segment too: a wrong directory is the common miss, and edit distance
40
40
  // over the whole path would score every file in the right directory as equally far away.
41
41
  const suggestion =
42
- nearest(input, [...paths]) ??
42
+ nearestName(input, [...paths]) ??
43
43
  paths.find((path) => path.split('/').at(-1) === input.split('/').at(-1));
44
44
  throw new FixTargetUnknownError({
45
45
  file: input,
@@ -105,7 +105,7 @@ export const fixCommand: CliCommand = {
105
105
  const root = requireAppRoot('fix', ctx.cwd).dir;
106
106
  // Refused before the scan, never defaulted to `''`: an empty string reached `resolveTarget` as
107
107
  // a file NAME, so a bare `x fix` answered X_FIX_TARGET_UNKNOWN with `"" is not one of the 42
108
- // source file(s)…` — and `nearest('')` almost never suggests anything, so the fix degraded to
108
+ // source file(s)…` — and `nearestName('')` almost never suggests anything, so the fix degraded to
109
109
  // `x routes --json` for a caller who had simply not said which file.
110
110
  const file = ctx.args.positionals[0];
111
111
  if (file === undefined) {
package/src/cmd-i18n.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  // would, and `node:path` because Bun exposes no path API to build what either of them takes.
9
9
  import { type FileHandle, mkdir, open } from 'node:fs/promises';
10
10
  import { dirname, join } from 'node:path';
11
- import { catalogKeys } from '@ultimat3/i18n';
11
+ import type { Catalog } from '@ultimat3/i18n';
12
+ import { auditCatalogs, catalogKeys } from '@ultimat3/i18n';
12
13
  import { loadApp } from './app-load';
13
14
  import { requireAppRoot } from './app-root';
14
15
  import type { CliCommand, CommandContext } from './command';
@@ -17,11 +18,17 @@ import {
17
18
  auditApp,
18
19
  loadCatalogs,
19
20
  resolveDefaultLocale,
21
+ scanSource,
20
22
  seedCatalog,
21
23
  serializeCatalog,
22
24
  syncCatalog,
23
25
  } from './i18n-audit';
24
- import { checkRegistration, missingKeyFindings } from './i18n-registration';
26
+ import {
27
+ checkRegistration,
28
+ loudMiss,
29
+ missingKeyFindings,
30
+ withPlaceholdersMissing,
31
+ } from './i18n-registration';
25
32
  import { msg } from './messages';
26
33
  import type { CommandResult, Finding, JsonValue } from './output';
27
34
  import { renderTable } from './table';
@@ -102,7 +109,11 @@ function resolveOneLocale(ctx: CommandContext, sub: string): string {
102
109
  }
103
110
 
104
111
  async function runCheck(root: string): Promise<CommandResult> {
105
- const { report, catalogs, extraction, ignoreUnused } = await auditApp(root);
112
+ const { report: audited, catalogs, extraction, ignoreUnused } = await auditApp(root);
113
+ // Corrected once, before anything reads it: the table's `missing` column, the summary count, the
114
+ // findings and `--json`'s `data` are four projections of one report, and a placeholder counted in
115
+ // only some of them is the command disagreeing with itself about the same app.
116
+ const report = withPlaceholdersMissing(audited, catalogs);
106
117
  // The runtime question, asked after the file question and never instead of it: a catalog can be
107
118
  // complete on disk, used everywhere in source, and reach no registry at all (issue #249).
108
119
  const registration = await checkRegistration({ root, catalogs, extraction, ignoreUnused });
@@ -182,6 +193,40 @@ async function runAdd(root: string, ctx: CommandContext): Promise<CommandResult>
182
193
  };
183
194
  }
184
195
 
196
+ /**
197
+ * `x i18n sync <defaultLocale>` merges the default locale into itself, so `added` was empty **by
198
+ * construction** — exit 0, "0 key(s) added", and `x i18n check` still red over the same keys. That
199
+ * command is `X_CATALOG_MISSING_KEYS`'s own `fix:`, and `i18n` is a step of the gate, so the only
200
+ * escape left was a hand edit nothing named.
201
+ *
202
+ * The source of truth for the default locale is not another catalog — there is none above it — it
203
+ * is the SOURCE: every key `t()` calls that this catalog does not define, seeded with `loudMiss`,
204
+ * the marker `@ultimat3/i18n` already renders for an absent key. The author is then one obvious
205
+ * edit per key from done, with each key sitting exactly where that edit goes — and the gate stays
206
+ * RED until they make it, because `withPlaceholdersMissing` counts every one as still missing.
207
+ *
208
+ * The seeding and the refusal read ONE definition of the marker (`i18n-registration.ts`): two
209
+ * spellings would be a placeholder this command writes and the gate cannot see.
210
+ *
211
+ * The list is the `missing` one `x i18n check` prints and `X_CATALOG_MISSING_KEYS` names, read
212
+ * through the same two functions the check reads it through: `auditCatalogs` rather than
213
+ * `missingFrom`, because a `pl` catalog defining `items_many` and no bare `items` is complete and a
214
+ * plain key diff would seed a placeholder over it, then `withPlaceholdersMissing` so "missing"
215
+ * means here exactly what it means to the gate.
216
+ */
217
+ async function untranslatedKeys(
218
+ root: string,
219
+ catalogs: Readonly<Record<string, Catalog>>,
220
+ locale: string,
221
+ ): Promise<readonly string[]> {
222
+ const extraction = await scanSource(root);
223
+ const report = withPlaceholdersMissing(auditCatalogs({ extraction, catalogs }), catalogs);
224
+ return report.locales.find((audit) => audit.locale === locale)?.missing ?? [];
225
+ }
226
+
227
+ const placeholderCatalog = (keys: readonly string[]): Catalog =>
228
+ Object.fromEntries(keys.map((key) => [key, loudMiss(key)]));
229
+
185
230
  async function runSync(root: string, ctx: CommandContext): Promise<CommandResult> {
186
231
  const locale = resolveOneLocale(ctx, 'sync');
187
232
  const catalogs = await loadCatalogs(root);
@@ -197,7 +242,12 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
197
242
 
198
243
  const app = await loadApp(root);
199
244
  const from = resolveDefaultLocale(app.defaultLocale, catalogs);
200
- const source = from === undefined ? {} : (catalogs[from] ?? {});
245
+ // `from === locale` is the default locale asked to sync itself, and `undefined` is an app with
246
+ // no resolvable default at all — one branch, because both have no catalog to merge from.
247
+ const seeded = from === undefined || from === locale;
248
+ const source = seeded
249
+ ? placeholderCatalog(await untranslatedKeys(root, catalogs, locale))
250
+ : (catalogs[from] ?? {});
201
251
  const { merged, added } = syncCatalog(target, source);
202
252
  if (added.length > 0) await Bun.write(join(root, catalogPath(locale)), serializeCatalog(merged));
203
253
 
@@ -208,8 +258,20 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
208
258
  ok: true,
209
259
  command: 'i18n',
210
260
  summary: msg('cli.i18n.synced', { locale, from: from ?? locale, added: added.length, total }),
261
+ // The keys themselves, raw — `runCheck` lists gaps the same way, because a key is a value an
262
+ // author copies and never prose the catalog owns.
263
+ lines: seeded ? added.map((key) => ` ${key}`) : [],
211
264
  findings: app.findings,
212
- data: { locale, from: from ?? locale, added, total, path: catalogPath(locale) },
265
+ data: {
266
+ locale,
267
+ from: from ?? locale,
268
+ added,
269
+ total,
270
+ path: catalogPath(locale),
271
+ // Which of `added` still need a human. Empty on a real merge, where every value is a real
272
+ // string copied from the default locale — `--json` must be able to tell the two apart.
273
+ placeholders: seeded ? added : [],
274
+ },
213
275
  };
214
276
  }
215
277
 
package/src/cmd-jobs.ts CHANGED
@@ -227,10 +227,33 @@ export const jobsCommand: CliCommand = {
227
227
  { name: 'state', type: 'string', summary: 'filter by job state' },
228
228
  { name: 'limit', type: 'string', summary: 'max rows to return' },
229
229
  { name: 'name', type: 'string', summary: 'filter by job name' },
230
- { name: 'from-step', type: 'string', summary: 'retry: drop this step so it re-executes' },
231
- { name: 'reason', type: 'string', summary: 'cancel: why, recorded on the job' },
232
- { name: 'to', type: 'string', summary: 'drain target driver: memory, redis, nats' },
233
- { name: 'dry-run', type: 'boolean', summary: 'drain: report the plan, move nothing' },
230
+ // Each of these is read by ONE subcommand — `retryJob`, `cancelJob`, `runDrain` — and says
231
+ // so in its own summary. The scope is what makes the parser refuse it anywhere else instead
232
+ // of accepting it and ignoring it: `x db gen --dry-run` parsed and wrote the migration.
233
+ {
234
+ name: 'from-step',
235
+ type: 'string',
236
+ summary: 'retry: drop this step so it re-executes',
237
+ subcommands: ['retry'],
238
+ },
239
+ {
240
+ name: 'reason',
241
+ type: 'string',
242
+ summary: 'cancel: why, recorded on the job',
243
+ subcommands: ['cancel'],
244
+ },
245
+ {
246
+ name: 'to',
247
+ type: 'string',
248
+ summary: 'drain: target driver — memory, redis, nats',
249
+ subcommands: ['drain'],
250
+ },
251
+ {
252
+ name: 'dry-run',
253
+ type: 'boolean',
254
+ summary: 'drain: report the plan, move nothing',
255
+ subcommands: ['drain'],
256
+ },
234
257
  ],
235
258
  },
236
259
  async run(ctx: CommandContext): Promise<CommandResult> {
package/src/cmd-mcp.ts CHANGED
@@ -114,19 +114,28 @@ export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
114
114
  }
115
115
 
116
116
  /**
117
- * stdout is the WIRE. `dispatch.ts` renders a `CommandResult` only after `run` resolves, and this
118
- * resolves when the peer closes stdin — so the command's own output cannot reach stdout while a
119
- * session is live, and nothing here writes to it directly.
117
+ * What the session reports when it is over — on STDERR, which is the half this file's header
118
+ * claimed and did not have. `dispatch` renders a `CommandResult` only after `run` resolves, and
119
+ * this resolves when the peer closes stdin, so nothing lands mid-session; but fd 1 under this
120
+ * transport carries JSON-RPC frames, and `✓ mcp stdio serving 13 tools` arriving on it after the
121
+ * loop is a malformed frame to a peer still draining, and a second document under `--json`.
122
+ *
123
+ * Its own function so the addressing is testable without a live peer: `serveStdio` resolves only
124
+ * when stdin closes, so a test that had to reach it could not assert anything about it.
120
125
  */
126
+ export const stdioResult = (tools: number): CommandResult => ({
127
+ ok: true,
128
+ command: 'mcp',
129
+ summary: msg('cli.mcp.serving', { transport: 'stdio', tools }),
130
+ data: { transport: 'stdio', tools },
131
+ stream: 'stderr',
132
+ });
133
+
134
+ /** stdout is the WIRE: `serveStdio` owns fd 1 for as long as the peer holds stdin open. */
121
135
  async function serveOverStdio(host: CliMcpServer): Promise<CommandResult> {
122
136
  await serveStdio({ server: host.server, caller: host.caller });
123
137
  await host.close();
124
- return {
125
- ok: true,
126
- command: 'mcp',
127
- summary: msg('cli.mcp.serving', { transport: 'stdio', tools: host.tools.length }),
128
- data: { transport: 'stdio', tools: host.tools.length },
129
- };
138
+ return stdioResult(host.tools.length);
130
139
  }
131
140
 
132
141
  /**