@ultimat3/cli 7.0.0 → 8.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.
- package/CLAUDE.md +15 -1
- package/README.md +8 -3
- package/package.json +25 -25
- package/src/app-boundaries.ts +55 -5
- package/src/bin.ts +6 -3
- package/src/ci-log.ts +0 -0
- package/src/cmd-db-backfill.ts +240 -0
- package/src/cmd-db-branch.ts +3 -2
- package/src/cmd-db.ts +35 -156
- package/src/cmd-deploy.ts +37 -3
- package/src/cmd-dev.ts +7 -1
- package/src/cmd-errors.ts +2 -3
- package/src/cmd-fix.ts +3 -3
- package/src/cmd-i18n.ts +67 -5
- package/src/cmd-jobs.ts +27 -4
- package/src/cmd-mcp.ts +18 -9
- package/src/cmd-new.ts +91 -4
- package/src/cmd-policy.ts +3 -2
- package/src/cmd-pr.ts +55 -4
- package/src/cmd-registries.ts +3 -2
- package/src/cmd-shot.ts +68 -6
- package/src/cmd-tasks.ts +9 -4
- package/src/cmd-verify.ts +47 -6
- package/src/dev-cache.ts +1 -1
- package/src/dev-lock.ts +124 -12
- package/src/dev-queue.ts +12 -7
- package/src/dev-replicator.ts +3 -7
- package/src/dev-roles-fixture.ts +1 -1
- package/src/dev-roles.ts +40 -8
- package/src/dev-runtime.ts +96 -4
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- package/src/drift.ts +52 -7
- package/src/error-codes.ts +5 -0
- package/src/framework-scope.ts +57 -5
- package/src/generate-kinds.ts +19 -1
- package/src/i18n-registration.ts +67 -4
- package/src/index.ts +1 -1
- package/src/jobs-report.ts +10 -13
- package/src/mcp-errors.ts +3 -0
- package/src/messages.ts +12 -0
- package/src/output.ts +22 -2
- package/src/parse.ts +81 -37
- package/src/realtime-browser-probe-fixture.ts +9 -0
- package/src/runtime-overrides.ts +11 -3
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +27 -4
- package/src/sync-authenticator.ts +86 -14
- package/src/templates/guard-bare-error.ts +122 -0
- package/src/templates/guard-raw-colour.ts +138 -0
- package/src/templates/guard-untranslated-string.ts +138 -0
- package/src/templates/guard-unzoned-date.ts +142 -0
- package/src/templates/index.ts +3 -0
- package/src/templates/island.ts +2 -1
- package/src/templates/route.ts +1 -1
- package/src/templates/scaffold-app.ts +3 -82
- package/src/templates/scaffold-container.ts +30 -4
- package/src/templates/scaffold-db-package.ts +14 -6
- package/src/templates/scaffold-docs.ts +24 -13
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-repo.ts +37 -6
- package/src/test-select.ts +4 -3
- package/src/verify-run.ts +25 -3
- package/src/verify-step.ts +11 -2
- package/src/verify-tests.ts +11 -3
- 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
|
|
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',
|
|
@@ -165,6 +194,9 @@ export const deployCommand: CliCommand = {
|
|
|
165
194
|
const planJson: JsonValue = {
|
|
166
195
|
image: plan.image,
|
|
167
196
|
method,
|
|
197
|
+
// Reported, because it is what makes `image` above true on the compose method — a dry run
|
|
198
|
+
// that names an image the steps do not carry is the defect this field closed.
|
|
199
|
+
env: { ...plan.env },
|
|
168
200
|
steps: plan.steps.map((step) => ({ role: step.role, command: step.command.join(' ') })),
|
|
169
201
|
};
|
|
170
202
|
if (flagBool(ctx.args, 'dry-run')) {
|
|
@@ -173,11 +205,13 @@ export const deployCommand: CliCommand = {
|
|
|
173
205
|
command: 'deploy',
|
|
174
206
|
summary: msg('cli.deploy.plan', { images: 1, roles: DEPLOY_ROLES.join(',') }),
|
|
175
207
|
data: planJson,
|
|
176
|
-
lines: plan.steps.map(
|
|
208
|
+
lines: plan.steps.map(
|
|
209
|
+
(step) => ` ${step.role.padEnd(10)} ${stepLine(plan.env, step.command)}`,
|
|
210
|
+
),
|
|
177
211
|
};
|
|
178
212
|
}
|
|
179
213
|
for (const step of plan.steps) {
|
|
180
|
-
const result = await ctx.runner(step.command, { cwd: root });
|
|
214
|
+
const result = await ctx.runner(step.command, { cwd: root, env: plan.env });
|
|
181
215
|
if (!result.ok) {
|
|
182
216
|
return {
|
|
183
217
|
ok: false,
|
|
@@ -187,7 +221,7 @@ export const deployCommand: CliCommand = {
|
|
|
187
221
|
{
|
|
188
222
|
code: 'X_DEPLOY_FAILED',
|
|
189
223
|
cause: `role "${step.role}" step exited ${result.code}`,
|
|
190
|
-
fix: `${step.command
|
|
224
|
+
fix: `${stepLine(plan.env, step.command)} # run it directly to see the full output`,
|
|
191
225
|
docs: 'https://ultimate.dev/errors/X_DEPLOY_FAILED',
|
|
192
226
|
},
|
|
193
227
|
],
|
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 =
|
|
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
|
-
|
|
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 `
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
{
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
/**
|