@ultimat3/cli 1.2.0 → 3.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 (141) hide show
  1. package/CLAUDE.md +761 -0
  2. package/README.md +42 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +134 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +219 -0
  13. package/src/cmd-db.ts +458 -153
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +92 -18
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +74 -10
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +14 -8
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +29 -24
  33. package/src/cmd-verify.ts +197 -25
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +269 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +144 -0
  39. package/src/db-seed.ts +294 -0
  40. package/src/db-snapshot.ts +24 -0
  41. package/src/dev-assets.ts +108 -23
  42. package/src/dev-cache.ts +122 -0
  43. package/src/dev-dashboard.ts +19 -4
  44. package/src/dev-hooks.ts +27 -2
  45. package/src/dev-n-plus-one.ts +191 -0
  46. package/src/dev-queue.ts +105 -19
  47. package/src/dev-render.ts +158 -26
  48. package/src/dev-roles-fixture.ts +67 -0
  49. package/src/dev-roles.ts +167 -78
  50. package/src/dev-runtime.ts +117 -40
  51. package/src/dev-services.ts +15 -0
  52. package/src/dev-storage.ts +247 -0
  53. package/src/dev-sync.ts +107 -0
  54. package/src/dev-traces.ts +37 -7
  55. package/src/dispatch.ts +4 -2
  56. package/src/document-styles.ts +54 -0
  57. package/src/drift.ts +78 -10
  58. package/src/error-catalog.ts +8 -18
  59. package/src/error-codes.ts +192 -0
  60. package/src/error-contract.ts +29 -7
  61. package/src/error-fixes.ts +114 -0
  62. package/src/errors.ts +201 -138
  63. package/src/exec.ts +42 -8
  64. package/src/fix-command.ts +268 -0
  65. package/src/flag-number.ts +67 -0
  66. package/src/framework-scope.ts +49 -0
  67. package/src/generate-kinds.ts +97 -0
  68. package/src/guards.ts +186 -0
  69. package/src/index.ts +92 -15
  70. package/src/island-bundle.ts +166 -0
  71. package/src/island-routes.ts +50 -0
  72. package/src/jobs-driver.ts +33 -0
  73. package/src/jobs-json.ts +24 -0
  74. package/src/jobs-report.ts +17 -4
  75. package/src/mcp-db-target.ts +52 -27
  76. package/src/mcp-errors.ts +128 -19
  77. package/src/mcp-host.ts +44 -25
  78. package/src/messages.ts +93 -2
  79. package/src/metrics-endpoint.ts +64 -16
  80. package/src/migrations.ts +37 -4
  81. package/src/otlp-export.ts +64 -0
  82. package/src/output.ts +46 -16
  83. package/src/parse.ts +41 -3
  84. package/src/policy-facts.ts +38 -6
  85. package/src/policy-fixture.ts +14 -7
  86. package/src/prerender.ts +111 -2
  87. package/src/registry.ts +21 -3
  88. package/src/runtime-overrides.ts +66 -0
  89. package/src/safe-url-label.ts +24 -0
  90. package/src/scaffold-fixture.ts +10 -0
  91. package/src/scaffold-typecheck.ts +16 -38
  92. package/src/serve.ts +185 -13
  93. package/src/shell-quote.ts +15 -0
  94. package/src/source-files.ts +4 -0
  95. package/src/statement-loop.ts +74 -0
  96. package/src/style-csp.ts +18 -0
  97. package/src/sync-authenticator.ts +59 -0
  98. package/src/templates/action.ts +15 -30
  99. package/src/templates/admin-page.ts +103 -0
  100. package/src/templates/admin.ts +11 -7
  101. package/src/templates/backfill.ts +212 -0
  102. package/src/templates/entity.ts +72 -31
  103. package/src/templates/guard.ts +143 -0
  104. package/src/templates/index.ts +12 -1
  105. package/src/templates/island.ts +67 -0
  106. package/src/templates/job.ts +53 -13
  107. package/src/templates/naming.ts +17 -1
  108. package/src/templates/policy.ts +35 -28
  109. package/src/templates/query.ts +24 -5
  110. package/src/templates/resource.ts +19 -11
  111. package/src/templates/route.ts +90 -15
  112. package/src/templates/scaffold-app.ts +142 -45
  113. package/src/templates/scaffold-claude-agents.ts +149 -0
  114. package/src/templates/scaffold-claude-commands.ts +221 -0
  115. package/src/templates/scaffold-claude.ts +134 -0
  116. package/src/templates/scaffold-container.ts +46 -2
  117. package/src/templates/scaffold-db-package.ts +91 -0
  118. package/src/templates/scaffold-docs.ts +24 -5
  119. package/src/templates/scaffold-domain-package.ts +90 -0
  120. package/src/templates/scaffold-env.ts +87 -0
  121. package/src/templates/scaffold-i18n.ts +4 -1
  122. package/src/templates/scaffold-mcp-package.ts +49 -0
  123. package/src/templates/scaffold-package-shape.ts +25 -4
  124. package/src/templates/scaffold-repo.ts +116 -257
  125. package/src/templates/scaffold-roles.ts +68 -0
  126. package/src/templates/scaffold-ui-package.ts +56 -0
  127. package/src/templates/slice-foundation.ts +88 -0
  128. package/src/templates/wrap.ts +95 -0
  129. package/src/test-counts.ts +35 -0
  130. package/src/test-select.ts +30 -15
  131. package/src/test-shards.ts +20 -11
  132. package/src/test-workers.ts +50 -0
  133. package/src/ts-scan.ts +284 -15
  134. package/src/tsconfig-references.ts +103 -0
  135. package/src/verify-floor.ts +133 -0
  136. package/src/verify-step.ts +19 -0
  137. package/src/verify-test-run.ts +72 -0
  138. package/src/verify-tests.ts +160 -71
  139. package/src/version-loader.ts +20 -3
  140. package/src/workspace-checks.ts +87 -16
  141. package/src/write-line.ts +34 -0
package/src/cmd-i18n.ts CHANGED
@@ -201,6 +201,8 @@ export const i18nCommand: CliCommand = {
201
201
  usage: 'x i18n [check|add <locale>|sync <locale>] [--json]',
202
202
  requiresApp: true,
203
203
  subcommands: I18N_SUBCOMMANDS,
204
+ // The bare `x i18n` audits; `add` and `sync` write catalogs and must be asked for.
205
+ defaultSubcommand: 'check',
204
206
  },
205
207
  async run(ctx: CommandContext): Promise<CommandResult> {
206
208
  const root = requireAppRoot('i18n', ctx.cwd).dir;
package/src/cmd-jobs.ts CHANGED
@@ -1,17 +1,18 @@
1
- // `x jobs ls|show|retry|drain` — introspect and recover the job queue, bound to `@ultimat3/jobs`'s
1
+ // `x jobs ls|show|retry|cancel|drain` — introspect and recover the job queue, bound to
2
+ // `@ultimat3/jobs`'s
2
3
  // own introspection so the CLI, `/_x` and MCP report identically. This file is CLI wiring only:
3
4
  // the driver-injected logic is `jobs-report.ts`, the `--json` shapes `jobs-json.ts`, the table
4
- // `jobs-table.ts`.
5
+ // `jobs-table.ts`, and getting hold of the queue at all is `jobs-driver.ts` — shared with `x db`.
5
6
 
6
7
  import type { JobDriver } from '@ultimat3/jobs';
7
- import { createMemoryDriver, createNatsDriver, createRedisDriver, jobDriver } from '@ultimat3/jobs';
8
+ import { cancelJob, createMemoryDriver, createNatsDriver, createRedisDriver } from '@ultimat3/jobs';
8
9
  import { requireAppRoot } from './app-root';
9
10
  import type { CliCommand, CommandContext } from './command';
10
- import { startQueue } from './dev-queue';
11
- import { resolveServices } from './dev-services';
12
- import { BadFlagError } from './errors';
11
+ import { BadFlagError, JobUnknownError } from './errors';
13
12
  import { drainJobs } from './jobs-drain';
13
+ import { withJobDriver } from './jobs-driver';
14
14
  import {
15
+ backfillToJson,
15
16
  deadLetterToJson,
16
17
  depthToJson,
17
18
  drainFailureToJson,
@@ -25,33 +26,10 @@ import { msg } from './messages';
25
26
  import type { CommandResult } from './output';
26
27
  import { flagBool, flagString } from './parse';
27
28
 
28
- export const JOBS_SUBCOMMANDS = ['ls', 'show', 'retry', 'drain'] as const;
29
+ export const JOBS_SUBCOMMANDS = ['ls', 'show', 'retry', 'cancel', 'drain'] as const;
29
30
 
30
31
  const DRAIN_TARGETS = ['memory', 'redis', 'nats'] as const;
31
32
 
32
- /**
33
- * `x jobs` needs the app's real driver. Reuse an already-running one first — inside `x dev` or
34
- * `x mcp serve`, `jobDriver()` is already set and booting a second queue on top of it would talk
35
- * to the wrong database. Otherwise boot just the db + jobs half (`startQueue`, not the full
36
- * `startServices`: this command touches no transport, storage or mail) and always release it, or
37
- * a CLI that exits holding the PGlite lock breaks the next command run against this app.
38
- */
39
- async function withDriver(
40
- root: string,
41
- ctx: CommandContext,
42
- fn: (driver: JobDriver) => Promise<CommandResult>,
43
- ): Promise<CommandResult> {
44
- const ambient = jobDriver();
45
- if (ambient !== undefined) return fn(ambient);
46
- const services = resolveServices(root, ctx.env);
47
- const queue = await startQueue(services);
48
- try {
49
- return await fn(queue.jobs);
50
- } finally {
51
- await queue.stop();
52
- }
53
- }
54
-
55
33
  function requireIdPositional(ctx: CommandContext, sub: string): string {
56
34
  const id = ctx.args.positionals[0];
57
35
  if (id === undefined) {
@@ -114,6 +92,17 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
114
92
  );
115
93
  }
116
94
  }
95
+ // Same shape as the dead-letter section above, and here for the same reason: a sweep in flight
96
+ // is a fact the depth counts cannot show. Name, rows so far and cursor, because "how far has it
97
+ // got" is the whole question — the finished passes are `x db backfill --list`'s answer.
98
+ if (result.backfills.length > 0) {
99
+ lines.push(` ${msg('cli.jobs.backfills', { count: result.backfills.length })}`);
100
+ for (const pass of result.backfills) {
101
+ const cursor = pass.cursor ?? msg('cli.jobs.backfillNoCursor');
102
+ const progress = msg('cli.jobs.backfillRow', { name: pass.name, rows: pass.rows, cursor });
103
+ lines.push(` ${pass.runId} ${progress}`);
104
+ }
105
+ }
117
106
  return {
118
107
  ok: true,
119
108
  command: 'jobs',
@@ -129,6 +118,7 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
129
118
  depth: depthToJson(result.depth),
130
119
  rows: result.rows.map(jobRecordToJson),
131
120
  deadLetters: result.deadLetters.map(deadLetterToJson),
121
+ backfills: result.backfills.map(backfillToJson),
132
122
  },
133
123
  };
134
124
  }
@@ -159,6 +149,26 @@ async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<Command
159
149
  };
160
150
  }
161
151
 
152
+ /**
153
+ * There is no silent-success path here, and that is the whole reason this subcommand can exist as
154
+ * four lines: `cancelJob` throws `X_JOB_NOT_CANCELLABLE` for a job that has already finished and
155
+ * for a driver with no `cancel` at all, so an exit code of 0 means the job is genuinely stopped.
156
+ * The trace is rendered by the same projection `show` and `retry` use — one shape for one job.
157
+ */
158
+ async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
159
+ const id = requireIdPositional(ctx, 'cancel');
160
+ const trace = await cancelJob(driver, id, flagString(ctx.args, 'reason'));
161
+ // `cancelJob` re-reads the job after cancelling, so `undefined` would mean the row vanished
162
+ // between the two — reported as the same refusal rather than rendered as a success with no job.
163
+ if (trace === undefined) throw new JobUnknownError({ id, driver: driver.name });
164
+ return {
165
+ ok: true,
166
+ command: 'jobs',
167
+ summary: msg('cli.jobs.cancelled', { id: trace.id, state: trace.state }),
168
+ data: jobTraceToJson(trace),
169
+ };
170
+ }
171
+
162
172
  /**
163
173
  * A skipped candidate is not an error — a job whose `runAt` has not arrived is unclaimable by
164
174
  * design — so it carries no `X_*` finding. It still fails the command: `x jobs drain` is run to
@@ -204,17 +214,20 @@ async function runDrain(driver: JobDriver, ctx: CommandContext): Promise<Command
204
214
  export const jobsCommand: CliCommand = {
205
215
  spec: {
206
216
  name: 'jobs',
207
- summary: 'list, show, retry and drain the job queue',
217
+ summary: 'list, show, retry, cancel and drain the job queue',
208
218
  usage:
209
- 'x jobs [ls|show <id>|retry <id>|drain --to <driver>] [--queue q] [--state s] [--limit n] [--from-step name] [--to driver] [--dry-run] [--json]',
219
+ 'x jobs [ls|show <id>|retry <id>|cancel <id>|drain --to <driver>] [--queue q] [--state s] [--limit n] [--from-step name] [--reason text] [--to driver] [--dry-run] [--json]',
210
220
  requiresApp: true,
211
221
  subcommands: JOBS_SUBCOMMANDS,
222
+ // The bare `x jobs` lists; it never retries, cancels or drains anything.
223
+ defaultSubcommand: 'ls',
212
224
  flags: [
213
225
  { name: 'queue', type: 'string', summary: 'filter by queue name' },
214
226
  { name: 'state', type: 'string', summary: 'filter by job state' },
215
227
  { name: 'limit', type: 'string', summary: 'max rows to return' },
216
228
  { name: 'name', type: 'string', summary: 'filter by job name' },
217
229
  { name: 'from-step', type: 'string', summary: 'retry: drop this step so it re-executes' },
230
+ { name: 'reason', type: 'string', summary: 'cancel: why, recorded on the job' },
218
231
  { name: 'to', type: 'string', summary: 'drain target driver: memory, redis, nats' },
219
232
  { name: 'dry-run', type: 'boolean', summary: 'drain: report the plan, move nothing' },
220
233
  ],
@@ -222,9 +235,10 @@ export const jobsCommand: CliCommand = {
222
235
  async run(ctx: CommandContext): Promise<CommandResult> {
223
236
  const root = requireAppRoot('jobs', ctx.cwd).dir;
224
237
  const sub = ctx.args.subcommand ?? 'ls';
225
- return withDriver(root, ctx, (driver) => {
238
+ return withJobDriver(root, ctx, (driver) => {
226
239
  if (sub === 'show') return runShow(driver, ctx);
227
240
  if (sub === 'retry') return runRetry(driver, ctx);
241
+ if (sub === 'cancel') return runCancel(driver, ctx);
228
242
  if (sub === 'drain') return runDrain(driver, ctx);
229
243
  return runLs(driver, ctx);
230
244
  });
package/src/cmd-mcp.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // supplies only the app, the caller and the socket. A tool answered here would be a second answer
4
4
  // to a question the framework already answers.
5
5
 
6
- import { markListening, nanoid } from '@ultimat3/core';
6
+ import { markListening, nanoid, timingSafeEqual } from '@ultimat3/core';
7
7
  import { mcpHttpRoute, serveStdio } from '@ultimat3/mcp';
8
8
  import { requireAppRoot } from './app-root';
9
9
  import type { CliCommand, CommandContext } from './command';
@@ -73,8 +73,14 @@ export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
73
73
  const token = nanoid(32);
74
74
  const route = mcpHttpRoute({
75
75
  server: host.server,
76
+ // `timingSafeEqual`, not `===`: this was the only secret comparison in the framework that
77
+ // short-circuited on the first differing character. Localhost and a per-process `nanoid(32)`
78
+ // make it hard to exploit and neither makes it correct — an exception nobody can point at is
79
+ // an exception the next transport copies.
76
80
  resolveToken: (candidate) =>
77
- candidate === token ? { actor: host.caller.actor, scopes: DEV_TOOL_SCOPES } : null,
81
+ timingSafeEqual(candidate, token)
82
+ ? { actor: host.caller.actor, scopes: DEV_TOOL_SCOPES }
83
+ : null,
78
84
  });
79
85
  const handle = Bun.serve({
80
86
  port,
@@ -143,6 +149,9 @@ export const mcpCommand: CliCommand = {
143
149
  usage: 'x mcp tools | x mcp serve [--transport stdio|http] [--port 9229] [--json]',
144
150
  requiresApp: true,
145
151
  subcommands: ['serve', 'tools'],
152
+ // No default, deliberately: `wiki/CLI-Reference.md` already says to write `x mcp serve` rather
153
+ // than a bare `x mcp`, and the parser's old first-element guess made the bare form START A
154
+ // SERVER — the one thing a word typed by mistake must not do. Refusing enforces the guidance.
146
155
  flags: [
147
156
  { name: 'transport', type: 'string', summary: 'stdio | http', default: 'stdio' },
148
157
  { name: 'port', type: 'string', summary: 'HTTP port', default: String(DEFAULT_PORT) },
package/src/cmd-new.ts CHANGED
@@ -7,7 +7,6 @@ import { chmod } from 'node:fs/promises';
7
7
  import { isAbsolute, join, resolve } from 'node:path';
8
8
  import { dedupe } from './cmd-generate';
9
9
  import type { CliCommand, CommandContext } from './command';
10
- import { writeSchemaHash } from './drift';
11
10
  import { msg } from './messages';
12
11
  import type { CommandResult } from './output';
13
12
  import { flagBool, flagString } from './parse';
@@ -40,16 +39,23 @@ export function planNewApp(options: NewAppOptions): readonly GeneratedFile[] {
40
39
  export interface WrittenApp {
41
40
  readonly dir: string;
42
41
  readonly files: readonly string[];
43
- readonly schemaHash: string;
44
42
  }
45
43
 
44
+ /**
45
+ * Every byte it writes is in `planNewApp`, so `--dry-run` and the disk cannot disagree.
46
+ *
47
+ * It writes NO migration and no `.hash`: `x db gen` is the one writer of `packages/db/migrations`,
48
+ * and it writes `.sql`, `.snapshot.json` and `.hash` together. A scaffold that wrote a `.hash` for
49
+ * a migration whose snapshot never existed is what made the app's first two database commands
50
+ * refuse each other — `x db migrate` naming `x db gen`, and `x db gen` refusing a sidecar version
51
+ * control never had. The consequence is deliberate: `x verify`'s `drift` step is red on a pristine
52
+ * scaffold until `x db gen "initial"` runs, which is what `cli.new.done` tells the author to do.
53
+ */
46
54
  export async function writeNewApp(target: string, options: NewAppOptions): Promise<WrittenApp> {
47
55
  const files = planNewApp(options);
48
56
  for (const file of files) await Bun.write(join(target, file.path), file.contents);
49
57
  for (const path of EXECUTABLE_FILES) await chmod(join(target, path), 0o755);
50
- // Record the schema hash beside the initial migration so `x verify` sees no drift on run one.
51
- const hash = await writeSchemaHash(target, '0000_initial');
52
- return { dir: target, files: files.map((file) => file.path), schemaHash: hash };
58
+ return { dir: target, files: files.map((file) => file.path) };
53
59
  }
54
60
 
55
61
  function parentDir(cwd: string, dirFlag: string | undefined): string {
@@ -61,7 +67,7 @@ export const newCommand: CliCommand = {
61
67
  spec: {
62
68
  name: 'new',
63
69
  summary: 'scaffold a new Ultimate monorepo that already runs',
64
- usage: 'x new <name> [--dir path] [--no-example] [--dry-run] [--json]',
70
+ usage: 'x new <name> [--dir path] [--no-example] [--dry-run] [--force] [--json]',
65
71
  flags: [
66
72
  { name: 'dir', type: 'string', summary: 'parent directory (default: cwd)' },
67
73
  {
@@ -102,7 +108,7 @@ export const newCommand: CliCommand = {
102
108
  command: 'new',
103
109
  summary: msg('cli.new.done', { name: app.kebab }),
104
110
  data: { dir: target, files: files.map((file) => file.path), dryRun: true },
105
- lines: files.map((file) => ` + ${app.kebab}/${file.path}`),
111
+ lines: files.map((file) => msg('cli.file.added', { path: `${app.kebab}/${file.path}` })),
106
112
  };
107
113
  }
108
114
  if (existsSync(target) && !flagBool(ctx.args, 'force')) {
@@ -126,7 +132,7 @@ export const newCommand: CliCommand = {
126
132
  ok: true,
127
133
  command: 'new',
128
134
  summary: msg('cli.new.done', { name: app.kebab }),
129
- data: { dir: written.dir, files: written.files, schemaHash: written.schemaHash },
135
+ data: { dir: written.dir, files: written.files },
130
136
  lines: [` ${written.files.length} files in ${target}`],
131
137
  };
132
138
  },
@@ -34,7 +34,7 @@ export const PLANNED_COMMANDS: readonly PlannedCommand[] = [
34
34
  name: 'branch',
35
35
  summary: 'copy-on-write branch environments with a preview URL',
36
36
  usage: 'x branch [<name>|rm <name>] [--json]',
37
- fix: 'x db branch <name> # the database half, shipped today',
37
+ fix: 'x db branch ls --json # the database half: ls, create <name>, drop <name>',
38
38
  },
39
39
  {
40
40
  name: 'status',
@@ -48,13 +48,6 @@ export const PLANNED_COMMANDS: readonly PlannedCommand[] = [
48
48
  usage: 'x upgrade [--dry-run] [--json]',
49
49
  fix: 'bun update --latest && x verify',
50
50
  },
51
- {
52
- name: 'env',
53
- summary: 'validate the typed env; --fix writes the missing keys',
54
- usage: 'x env check [--fix] [--json]',
55
- subcommands: ['check'],
56
- fix: 'x doctor --json # reports the env problems it can already see',
57
- },
58
51
  {
59
52
  name: 'logs',
60
53
  summary: 'structured logs and OTel spans, filterable',
@@ -76,12 +69,18 @@ export const PLANNED_COMMANDS: readonly PlannedCommand[] = [
76
69
  subcommands: ['eval', 'cache', 'reindex'],
77
70
  fix: 'x test eval --json # every eval, scored against its committed baseline',
78
71
  },
72
+ // `registerCurrency()` ships `As of 2026-08`, and this row is deliberately NOT deleted with it.
73
+ // What is planned is a GENERATOR — the one thing a process that exits can contribute, since a
74
+ // registration has to land in the app's own source to survive the run — and a generator that
75
+ // emits the one call is not a second path any more than `x g route` is a second way to declare a
76
+ // route (axiom 1). The whole table is shaped this way: `x status` beside `x doctor`, `x cache`
77
+ // beside the `/_x` panel. What changed is only that the `fix:` is now true.
79
78
  {
80
79
  name: 'money',
81
80
  summary: 'extend the currency table',
82
81
  usage: 'x money add-currency <ISO> --exponent <n> [--json]',
83
82
  subcommands: ['add-currency'],
84
- fix: 'x manifest --json # currencies ship in @ultimat3/money; add yours in app.config.ts',
83
+ fix: "x errors explain X_CURRENCY_UNKNOWN --json # extend it in code: registerCurrency({ code: 'GHS', exponent: 2, name: 'Ghana Cedi' }) at boot",
85
84
  },
86
85
  {
87
86
  name: 'config',
@@ -92,11 +91,57 @@ export const PLANNED_COMMANDS: readonly PlannedCommand[] = [
92
91
  },
93
92
  ];
94
93
 
94
+ export interface PlannedSubcommand {
95
+ readonly command: string;
96
+ readonly subcommand: string;
97
+ /** Runnable today, and closer to the answer than nothing. Never a doc link. */
98
+ readonly fix: string;
99
+ }
100
+
101
+ /**
102
+ * A subcommand of a shipped command that this build does not implement. Same promise as the table
103
+ * above, one level down: `x db studio` stays in `x db`'s subcommand list, so the parser accepts it
104
+ * and `x help db` still lists it, and it exits X_NOT_IMPLEMENTED naming what to run instead.
105
+ *
106
+ * `studio` is here because the migration engine is `@ultimat3/db`'s and only that. It used to
107
+ * shell out to `bunx drizzle-kit studio` — a second schema tool, fetched unpinned at run time,
108
+ * declared in no `package.json` — while every other `x db` subcommand went through the framework's
109
+ * own ledger. One subcommand is not worth a second engine.
110
+ */
111
+ export const PLANNED_SUBCOMMANDS: readonly PlannedSubcommand[] = [
112
+ {
113
+ command: 'db',
114
+ subcommand: 'studio',
115
+ fix: 'x dev # then the db panel at /_x: schema, rows, and a guarded SQL console',
116
+ },
117
+ ];
118
+
119
+ /**
120
+ * Thrown by the owning command, so the subcommand fails exactly where it would have run. Returns
121
+ * the error rather than throwing it, because a `run` that throws synchronously escapes the
122
+ * promise its signature promises — the caller does `throw plannedSubcommand(...)`.
123
+ */
124
+ export function plannedSubcommand(command: string, subcommand: string): CliNotImplementedError {
125
+ const planned = PLANNED_SUBCOMMANDS.find(
126
+ (entry) => entry.command === command && entry.subcommand === subcommand,
127
+ );
128
+ return new CliNotImplementedError({
129
+ feature: `x ${command} ${subcommand}`,
130
+ // `x help <command>`, not `x <command> --help`: for a command that declares subcommands and no
131
+ // default, the latter is refused by the parser before help is ever rendered.
132
+ fix: planned?.fix ?? `x help ${command}`,
133
+ });
134
+ }
135
+
95
136
  const specFor = (planned: PlannedCommand): CommandSpec => ({
96
137
  name: planned.name,
97
138
  summary: `${planned.summary} (planned)`,
98
139
  usage: planned.usage,
99
- ...(planned.subcommands === undefined ? {} : { subcommands: planned.subcommands }),
140
+ // A planned command answers X_NOT_IMPLEMENTED however it is invoked, so the bare form must
141
+ // reach its `run` rather than being refused for a subcommand that does not exist yet.
142
+ ...(planned.subcommands === undefined
143
+ ? {}
144
+ : { subcommands: planned.subcommands, defaultSubcommand: planned.subcommands[0] }),
100
145
  });
101
146
 
102
147
  /**
package/src/cmd-policy.ts CHANGED
@@ -126,6 +126,7 @@ export const policyCommand: CliCommand = {
126
126
  usage: 'x policy [list|explain <subject>] [--json]',
127
127
  requiresApp: true,
128
128
  subcommands: ['list', 'explain'],
129
+ defaultSubcommand: 'list',
129
130
  },
130
131
  async run(ctx: CommandContext): Promise<CommandResult> {
131
132
  const root = requireAppRoot('policy', ctx.cwd).dir;
@@ -57,6 +57,7 @@ const ACTIONS: RegistryKind<ActionDescriptor, AnyAction> = {
57
57
  summary: 'the action registry: input/output schema, policy, tags, MCP exposure',
58
58
  usage: 'x actions [list|describe <name>] [--json]',
59
59
  subcommands: ['list', 'describe'],
60
+ defaultSubcommand: 'list',
60
61
  requiresApp: true,
61
62
  },
62
63
  header: ['name', 'verb', 'resource', 'path', 'capability', 'mcp'],
@@ -74,6 +75,7 @@ const QUERIES: RegistryKind<QueryDescriptor, AnyQuery> = {
74
75
  summary: 'the query registry: schema, policy, live, cache tags',
75
76
  usage: 'x queries [list|describe <name>] [--json]',
76
77
  subcommands: ['list', 'describe'],
78
+ defaultSubcommand: 'list',
77
79
  requiresApp: true,
78
80
  },
79
81
  header: ['name', 'live', 'capability', 'tags', 'ttlMs'],
@@ -99,6 +101,7 @@ const ENTITIES: RegistryKind<EntityDescription, RegistryEntry> = {
99
101
  summary: 'the entity registry: columns, invariants, indexes, tenancy',
100
102
  usage: 'x entities [list|describe <name>] [--json]',
101
103
  subcommands: ['list', 'describe'],
104
+ defaultSubcommand: 'list',
102
105
  requiresApp: true,
103
106
  },
104
107
  header: ['name', 'table', 'columns', 'invariants', 'indexes', 'orgScoped'],