@ultimat3/cli 2.0.0 → 4.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 (75) hide show
  1. package/CLAUDE.md +109 -13
  2. package/README.md +1 -0
  3. package/package.json +24 -24
  4. package/src/budgets.ts +31 -8
  5. package/src/cmd-db-branch.ts +6 -2
  6. package/src/cmd-db.ts +138 -10
  7. package/src/cmd-deploy.ts +42 -14
  8. package/src/cmd-dev.ts +9 -2
  9. package/src/cmd-docs.ts +7 -3
  10. package/src/cmd-doctor.ts +16 -7
  11. package/src/cmd-fix.ts +15 -3
  12. package/src/cmd-generate.ts +29 -4
  13. package/src/cmd-help.ts +25 -4
  14. package/src/cmd-i18n.ts +8 -5
  15. package/src/cmd-jobs.ts +6 -5
  16. package/src/cmd-mcp.ts +16 -12
  17. package/src/cmd-new.ts +10 -14
  18. package/src/cmd-planned.ts +13 -0
  19. package/src/cmd-policy.ts +8 -6
  20. package/src/cmd-registries.ts +7 -6
  21. package/src/cmd-routes.ts +27 -4
  22. package/src/cmd-secrets.ts +6 -6
  23. package/src/cmd-test.ts +14 -3
  24. package/src/cmd-verify.ts +80 -10
  25. package/src/command.ts +10 -2
  26. package/src/db-branch.ts +18 -0
  27. package/src/db-generate.ts +38 -6
  28. package/src/db-seed.ts +294 -0
  29. package/src/dev-assets.ts +22 -3
  30. package/src/dev-cache.ts +9 -9
  31. package/src/dev-render.ts +6 -1
  32. package/src/dev-roles.ts +5 -3
  33. package/src/dev-runtime.ts +2 -2
  34. package/src/dev-storage.ts +6 -4
  35. package/src/dev-traces.ts +26 -4
  36. package/src/dispatch.ts +33 -4
  37. package/src/drift.ts +41 -1
  38. package/src/error-catalog.ts +1 -0
  39. package/src/error-codes.ts +11 -0
  40. package/src/error-contract.ts +31 -4
  41. package/src/exec.ts +42 -8
  42. package/src/fix-command.ts +9 -2
  43. package/src/fix-imports.ts +118 -0
  44. package/src/fix-scan.ts +251 -0
  45. package/src/flag-number.ts +11 -0
  46. package/src/flag-reads.ts +114 -0
  47. package/src/i18n-audit.ts +2 -1
  48. package/src/index.ts +19 -5
  49. package/src/jobs-drain.ts +6 -1
  50. package/src/mcp-errors.ts +13 -0
  51. package/src/mcp-host.ts +4 -2
  52. package/src/messages.ts +15 -0
  53. package/src/metrics-endpoint.ts +60 -13
  54. package/src/otlp-export.ts +14 -0
  55. package/src/parse.ts +6 -1
  56. package/src/seo-meta.ts +105 -0
  57. package/src/serve.ts +15 -3
  58. package/src/shell-quote.ts +15 -0
  59. package/src/templates/action.ts +39 -7
  60. package/src/templates/backfill.ts +3 -1
  61. package/src/templates/index.ts +10 -1
  62. package/src/templates/job.ts +6 -2
  63. package/src/templates/query.ts +6 -1
  64. package/src/templates/route.ts +18 -9
  65. package/src/templates/scaffold-api.ts +100 -0
  66. package/src/templates/scaffold-app.ts +8 -48
  67. package/src/templates/scaffold-container.ts +44 -9
  68. package/src/templates/scaffold-helm-templates.ts +327 -0
  69. package/src/templates/scaffold-helm.ts +144 -0
  70. package/src/templates/scaffold-repo.ts +25 -8
  71. package/src/test-shards.ts +1 -10
  72. package/src/test-workers.ts +4 -1
  73. package/src/ts-scan.ts +25 -176
  74. package/src/tsconfig-references.ts +27 -2
  75. package/src/verify-step.ts +5 -0
package/src/cmd-deploy.ts CHANGED
@@ -2,11 +2,10 @@
2
2
  // not know the name of a cloud, a KV store or an edge runtime (axiom 7). What it emits is a plan
3
3
  // anything that runs containers can execute.
4
4
 
5
- import { existsSync } from 'node:fs';
6
5
  import { join } from 'node:path';
7
6
  import { requireAppRoot } from './app-root';
8
7
  import type { CliCommand, CommandContext } from './command';
9
- import { BadFlagError, CliNotImplementedError } from './errors';
8
+ import { BadFlagError, UnknownCommandError } from './errors';
10
9
  import { msg } from './messages';
11
10
  import type { CommandResult, JsonValue } from './output';
12
11
  import { flagBool, flagString } from './parse';
@@ -28,11 +27,35 @@ import { flagBool, flagString } from './parse';
28
27
  * the serving roles were asked to start and not after they are ready. The barrier that makes
29
28
  * "after" true is declarative and belongs to the compose file, not to this plan: the `backfill`
30
29
  * service needs `depends_on: { web: { condition: service_healthy } }`, which `docker compose run`
31
- * honours. Both compose definitions — `docker/docker-compose.prod.yml` and the one
32
- * `templates/scaffold-container.ts` scaffolds — still owe that service and that condition.
30
+ * honours. Both compose definitions carry it — `docker/docker-compose.prod.yml`'s `backfill` and
31
+ * the one `templates/scaffold-container.ts` scaffolds, which also gates on `migrate` completing.
32
+ * This paragraph said they "still owe" both for as long as they have had them.
33
33
  */
34
34
  export const DEPLOY_ROLES = ['migrate', 'web', 'sync', 'worker', 'scheduler', 'backfill'] as const;
35
35
 
36
+ /** The two ways to run the plan. Closed, and read three ways: the default, the check, the refusal. */
37
+ export const DEPLOY_METHODS = ['compose', 'helm'] as const;
38
+
39
+ export type DeployMethod = (typeof DEPLOY_METHODS)[number];
40
+
41
+ /**
42
+ * Refused, never defaulted. `=== 'helm' ? 'helm' : 'compose'` made every other spelling a Compose
43
+ * deploy that reported `ok: true` and `method: "compose"` — so `x deploy --method helmm` (or
44
+ * `Helm`, or `kubectl`) ran the six-step Compose plan against a cluster whose operator had asked
45
+ * for a Helm upgrade, and the report agreed with the plan rather than with the request.
46
+ * `cmd-build.ts`'s `readTarget` is the same shape for the same reason.
47
+ */
48
+ export function readMethod(raw: string | undefined): DeployMethod {
49
+ const methods: readonly string[] = DEPLOY_METHODS;
50
+ if (raw === undefined) return 'compose';
51
+ if (methods.includes(raw)) return raw as DeployMethod;
52
+ throw new UnknownCommandError({
53
+ path: `deploy --method ${raw}`,
54
+ known: DEPLOY_METHODS,
55
+ suggestion: 'deploy --method compose',
56
+ });
57
+ }
58
+
36
59
  /** The roles that run to completion and exit, as against the ones that stay up serving. */
37
60
  const ONE_SHOT_ROLES: readonly string[] = ['migrate', 'backfill'];
38
61
 
@@ -62,7 +85,7 @@ export function helmImageOverrides(image: string): readonly string[] {
62
85
  : ['--set', `image.repository=${repository}`, '--set', `image.tag=${tag}`];
63
86
  }
64
87
 
65
- export function planDeploy(image: string, method: 'compose' | 'helm', root: string): DeployPlan {
88
+ export function planDeploy(image: string, method: DeployMethod, root: string): DeployPlan {
66
89
  if (method === 'helm') {
67
90
  // `repo@sha256:…` is a reference this chart cannot express: it renders `repository:tag` and
68
91
  // has no digest branch, so passing one through would deploy `repo@sha256:…:<appVersion>` —
@@ -119,24 +142,29 @@ export const deployCommand: CliCommand = {
119
142
  { name: 'image', type: 'string', summary: 'image reference to deploy' },
120
143
  { name: 'method', type: 'string', summary: 'compose | helm', default: 'compose' },
121
144
  { name: 'dry-run', type: 'boolean', summary: 'print the plan, run nothing' },
122
- { name: 'critical', type: 'boolean', summary: 'security deploy: forces clients to reload' },
145
+ // `--critical` was here and is gone. It parsed, it was echoed into the plan JSON as
146
+ // `critical: <bool>`, and no file in `packages/` read that field — so the flag changed
147
+ // nothing about what `x deploy` did, on either method. `flag-reads.ts`'s
148
+ // `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.
123
152
  ],
124
153
  },
125
154
  async run(ctx: CommandContext): Promise<CommandResult> {
126
155
  const root = requireAppRoot('deploy', ctx.cwd).dir;
127
156
  const image = flagString(ctx.args, 'image') ?? 'ultimate-app:dev';
128
- const method = flagString(ctx.args, 'method') === 'helm' ? 'helm' : 'compose';
129
- if (method === 'helm' && !existsSync(join(root, 'docker', 'helm'))) {
130
- throw new CliNotImplementedError({
131
- feature: 'helm deploy without docker/helm in the app',
132
- fix: 'copy docker/helm from the framework repo, or use: x deploy --method compose',
133
- });
134
- }
157
+ // No "is there a chart?" branch. It threw X_NOT_IMPLEMENTED — "this build does not implement
158
+ // helm" — over a build that implements it completely (`planDeploy` above); what was missing was
159
+ // a FILE, and its fix said to copy it from the framework repository, which `packages/cli`'s
160
+ // `files:` ships in no tarball. `x new` writes `docker/helm` now, the way it has always written
161
+ // `docker/docker-compose.prod.yml`. An app that deleted the chart gets helm's own error through
162
+ // X_DEPLOY_FAILED, whose fix is the exact command to rerun.
163
+ const method = readMethod(flagString(ctx.args, 'method'));
135
164
  const plan = planDeploy(image, method, root);
136
165
  const planJson: JsonValue = {
137
166
  image: plan.image,
138
167
  method,
139
- critical: flagBool(ctx.args, 'critical'),
140
168
  steps: plan.steps.map((step) => ({ role: step.role, command: step.command.join(' ') })),
141
169
  };
142
170
  if (flagBool(ctx.args, 'dry-run')) {
package/src/cmd-dev.ts CHANGED
@@ -42,6 +42,7 @@ import { msg } from './messages';
42
42
  import type { CommandResult, Finding } from './output';
43
43
  import { findingFrom } from './output';
44
44
  import { flagString } from './parse';
45
+ import { metricsPortFor } from './serve';
45
46
  import { loopFacts, loopFinding, loopNotice } from './statement-loop';
46
47
 
47
48
  const DEFAULT_PORT = 3000;
@@ -185,6 +186,10 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
185
186
  const running = await startRoles({
186
187
  roles: options.roles ?? DEV_ROLES,
187
188
  port: options.port,
189
+ // `serve.ts`'s expression, called rather than restated: `METRICS_PORT` was read in the
190
+ // container and ignored here, so the scrape port an operator moved was the one port `x dev`
191
+ // could not move — and the second `x dev` on a box died binding the hardcoded 9090.
192
+ metricsPort: metricsPortFor(options.env, options.port),
188
193
  buildId,
189
194
  runtime,
190
195
  routes,
@@ -266,14 +271,16 @@ export const devCommand: CliCommand = {
266
271
  spec: {
267
272
  name: 'dev',
268
273
  summary: 'all roles in one process: embedded services, sub-second reload, /_x mounted',
269
- usage: 'x dev [--port 3000] [--role web,worker] [--json]',
274
+ usage: 'x dev [--port 3000] [--role web,worker] [--once] [--json]',
270
275
  requiresApp: true,
271
276
  flags: [
272
277
  { name: 'port', type: 'string', summary: 'HTTP port', default: String(DEFAULT_PORT) },
273
278
  {
274
279
  name: 'role',
275
280
  type: 'string',
276
- summary: `roles to run (default: all of ${DEV_ROLES.join(',')})`,
281
+ // `replicator` is named because it is selectable and NOT default — it takes a replication
282
+ // slot on a shared database, which is not something every `x dev` should do by starting.
283
+ summary: `roles to run (default: all of ${DEV_ROLES.join(',')}; replicator is opt-in)`,
277
284
  },
278
285
  { name: 'once', type: 'boolean', summary: 'boot, report, exit — for smoke tests and CI' },
279
286
  ],
package/src/cmd-docs.ts CHANGED
@@ -9,8 +9,10 @@ import { nearestTopics, scanInstalledDocs, searchDocs } from '@ultimat3/manifest
9
9
  import type { CliCommand, CommandContext } from './command';
10
10
  import { MissingPositionalError } from './errors';
11
11
  import { frameworkScopeDir } from './framework-scope';
12
+ import { parseLimitFlag } from './jobs-report';
12
13
  import { msg } from './messages';
13
14
  import type { CommandResult, Finding, JsonValue } from './output';
15
+ import { flagString } from './parse';
14
16
 
15
17
  /** Matches printed by default. Enough to choose between, few enough to read all of. */
16
18
  const DEFAULT_LIMIT = 5;
@@ -150,9 +152,11 @@ export const docsCommand: CliCommand = {
150
152
  }
151
153
 
152
154
  const entries = await scanInstalledDocs(scope);
153
- const rawLimit = ctx.args.flags.get('limit');
154
- const parsed = typeof rawLimit === 'string' ? Number.parseInt(rawLimit, 10) : Number.NaN;
155
- const limit = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIMIT;
155
+ // `x jobs ls --limit`'s reader, and its `command` parameter exists for exactly this second
156
+ // caller. A local `Number.parseInt` accepted `--limit 1e9` as 1 and answered with one match,
157
+ // and fell silently through to the default for `abc`, `0` and `-3` — a bound other than the one
158
+ // typed, from the same binary that refuses all four one command over.
159
+ const limit = parseLimitFlag(flagString(ctx.args, 'limit'), 'docs') ?? DEFAULT_LIMIT;
156
160
  const hits = searchDocs(entries, query, limit);
157
161
  if (hits.length === 0) return missResult(query, entries);
158
162
 
package/src/cmd-doctor.ts CHANGED
@@ -11,9 +11,10 @@ import type { CliCommand, CommandContext } from './command';
11
11
  import { checkMigrationSnapshots } from './db-snapshot';
12
12
  import { ICON_SOURCE } from './dev-assets';
13
13
  import { checkSourceDrift } from './drift';
14
- import { intFlagOr, PORT_RANGE } from './flag-number';
14
+ import { intFlagOr, neighbouringPort, PORT_RANGE } from './flag-number';
15
15
  import { msg } from './messages';
16
16
  import type { CommandResult, Finding } from './output';
17
+ import type { ParsedArgs } from './parse';
17
18
 
18
19
  /**
19
20
  * The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
@@ -126,7 +127,7 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
126
127
  finding(
127
128
  'X_PORT_IN_USE',
128
129
  `port ${probe.port} is already listening`,
129
- `x dev --port ${probe.port + 1}`,
130
+ `x dev --port ${neighbouringPort(probe.port)}`,
130
131
  ),
131
132
  );
132
133
  }
@@ -165,6 +166,18 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
165
166
  return findings;
166
167
  }
167
168
 
169
+ /**
170
+ * The port to TEST, and the one place `x doctor` reads it. `PORT_RANGE.min` is 0 because `x dev
171
+ * --port 0` means "let the kernel pick"; here 0 means nothing, and `Bun.serve({ port: 0 })` always
172
+ * succeeds — so the port check could not fail, which is worse than not running it.
173
+ */
174
+ export const doctorPort = (args: ParsedArgs): number =>
175
+ intFlagOr(
176
+ args,
177
+ { name: 'port', command: 'doctor', ...PORT_RANGE, min: 1, example: 'x doctor --port 3000' },
178
+ DEFAULT_DOCTOR_PORT,
179
+ );
180
+
168
181
  const portFree = async (port: number): Promise<boolean> => {
169
182
  try {
170
183
  const server = Bun.serve({ port, fetch: () => new Response('') });
@@ -213,11 +226,7 @@ export const doctorCommand: CliCommand = {
213
226
  ],
214
227
  },
215
228
  async run(ctx: CommandContext): Promise<CommandResult> {
216
- const port = intFlagOr(
217
- ctx.args,
218
- { name: 'port', command: 'doctor', ...PORT_RANGE, example: 'x doctor --port 3000' },
219
- DEFAULT_DOCTOR_PORT,
220
- );
229
+ const port = doctorPort(ctx.args);
221
230
  const findings = await runDoctor(probeFor(ctx.cwd, ctx.bunVersion, port));
222
231
  return {
223
232
  ok: findings.length === 0,
package/src/cmd-fix.ts CHANGED
@@ -8,7 +8,7 @@ import { requireAppRoot } from './app-root';
8
8
  import type { BoundaryCut } from './boundary-cuts';
9
9
  import { planBoundaryCuts } from './boundary-cuts';
10
10
  import type { CliCommand, CommandContext } from './command';
11
- import { BadFlagError, FixTargetUnknownError } from './errors';
11
+ import { BadFlagError, FixTargetUnknownError, MissingPositionalError } from './errors';
12
12
  import { msg } from './messages';
13
13
  import type { CommandResult, Finding, JsonValue } from './output';
14
14
  import { nearest } from './parse';
@@ -103,10 +103,22 @@ export const fixCommand: CliCommand = {
103
103
  },
104
104
  async run(ctx: CommandContext): Promise<CommandResult> {
105
105
  const root = requireAppRoot('fix', ctx.cwd).dir;
106
+ // Refused before the scan, never defaulted to `''`: an empty string reached `resolveTarget` as
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
109
+ // `x routes --json` for a caller who had simply not said which file.
110
+ const file = ctx.args.positionals[0];
111
+ if (file === undefined) {
112
+ throw new MissingPositionalError({
113
+ command: 'fix boundary',
114
+ positional: 'file',
115
+ example: 'x routes --json # every registered route file, app-root-relative',
116
+ });
117
+ }
106
118
  const files = await readAppSources(root);
107
119
  const target = resolveTarget(
108
- ctx.args.positionals[0] ?? '',
109
- files.map((file) => file.path),
120
+ file,
121
+ files.map((source) => source.path),
110
122
  );
111
123
  const cuts = planBoundaryCuts(target, appImportGraph(files));
112
124
 
@@ -286,7 +286,12 @@ type WritePlan =
286
286
  | { readonly kind: 'skip' }
287
287
  | { readonly kind: 'conflict'; readonly finding: Finding };
288
288
 
289
- function planFile(file: GeneratedFile, absolute: string, force: boolean): WritePlan {
289
+ function planFile(
290
+ file: GeneratedFile,
291
+ absolute: string,
292
+ force: boolean,
293
+ invocation: string,
294
+ ): WritePlan {
290
295
  // A foundation file belongs to the slice, not to the generator that needs it: several generators
291
296
  // emit the same `repo.ts`, so an existing one is the author's — never a conflict, and never
292
297
  // overwritten, `--force` included. `--force` is about the primitive the author named; clobbering
@@ -303,7 +308,10 @@ function planFile(file: GeneratedFile, absolute: string, force: boolean): WriteP
303
308
  finding: {
304
309
  code: 'X_GENERATE_CONFLICT',
305
310
  cause: `${file.path} already exists`,
306
- fix: `x g --force to overwrite, or pass a different name`,
311
+ // The caller's own invocation, not `x g <kind>`: `x g --force` is X_CLI_UNKNOWN_COMMAND
312
+ // when run, and a `fix:` is copied and pasted verbatim. Same construction as
313
+ // `generate-kinds.ts`'s `assertSurfaceSupported`.
314
+ fix: `${invocation} --force # overwrites ${file.path}, or pass a different name`,
307
315
  docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
308
316
  at: file.path,
309
317
  },
@@ -325,12 +333,20 @@ export async function writeFiles(
325
333
  root: string,
326
334
  files: readonly GeneratedFile[],
327
335
  force: boolean,
336
+ /**
337
+ * The command line that produced these files, so a conflict's `fix:` can hand it back with
338
+ * `--force` on the end. Optional for a caller assembling files itself; the fallback is the
339
+ * shape, not a runnable line, and every generator path supplies the real one.
340
+ */
341
+ invocation = 'x g <kind> <name>',
328
342
  ): Promise<WriteReport> {
329
343
  const plans: WritePlan[] = [];
330
344
  for (const file of files) {
331
345
  const absolute = containedPath(root, file.path);
332
346
  plans.push(
333
- file.merge === 'json' ? await planJsonMerge(file, absolute) : planFile(file, absolute, force),
347
+ file.merge === 'json'
348
+ ? await planJsonMerge(file, absolute)
349
+ : planFile(file, absolute, force, invocation),
334
350
  );
335
351
  }
336
352
  const conflicts = plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
@@ -381,6 +397,10 @@ export const generateCommand: CliCommand = {
381
397
  // drifted — it omitted `backfill` — and a usage line that can disagree with the list it
382
398
  // describes is exactly the second source of truth axiom 2 forbids.
383
399
  usage: `x g ${GENERATORS.join('|')} <name> [--feature f]`,
400
+ // Declared from the SAME constant `readKind` validates against: without it `fix-command.ts`
401
+ // has no set to judge the word after `x g`, and two shipped `@ultimat3/admin` fix lines said
402
+ // `x g migration` — a generator that has never existed — straight through the `errors` gate.
403
+ positionalChoices: GENERATORS,
384
404
  requiresApp: true,
385
405
  flags: [
386
406
  { name: 'feature', type: 'string', summary: 'feature slice to write into' },
@@ -425,7 +445,12 @@ export const generateCommand: CliCommand = {
425
445
  lines: files.map((file) => msg('cli.file.added', { path: file.path })),
426
446
  };
427
447
  }
428
- const report = await writeFiles(root, files, flagBool(ctx.args, 'force'));
448
+ const report = await writeFiles(
449
+ root,
450
+ files,
451
+ flagBool(ctx.args, 'force'),
452
+ `x g ${kind} ${name}`,
453
+ );
429
454
  // A locale's catalog existing on disk and the app being able to select it are two different
430
455
  // facts — see `syncI18nIndex`. Runs before the manifest load below so a route or resource
431
456
  // this same invocation just wrote never gets projected against a stale catalog registration.
package/src/cmd-help.ts CHANGED
@@ -14,10 +14,31 @@ const flagLine = (flag: FlagSpec): string => {
14
14
  return ` ${short}${name.padEnd(24)} ${flag.summary}`;
15
15
  };
16
16
 
17
+ /**
18
+ * The one resolution of a topic, read by both renderers. `--json` filtered on `spec.name === topic`
19
+ * of its own, so the two disagreed about exactly the inputs a caller is least sure of: `x help
20
+ * generate --json` answered `[]` — "that command does not exist" — while the page beside it printed
21
+ * `g`, and `x help nosuch --json` answered `[]` while the page printed the whole catalogue.
22
+ */
23
+ const specFor = (
24
+ specs: readonly CommandSpec[],
25
+ topic: string | undefined,
26
+ ): CommandSpec | undefined =>
27
+ topic === undefined
28
+ ? undefined
29
+ : specs.find((entry) => entry.name === topic || (entry.aliases ?? []).includes(topic));
30
+
31
+ /** What `--json` reports: the one resolved command, or — as the human render does — all of them. */
32
+ export function helpTopic(
33
+ specs: readonly CommandSpec[],
34
+ topic: string | undefined,
35
+ ): readonly CommandSpec[] {
36
+ const spec = specFor(specs, topic);
37
+ return spec === undefined ? specs : [spec];
38
+ }
39
+
17
40
  export function renderHelp(specs: readonly CommandSpec[], topic: string | undefined): string[] {
18
- const spec = specs.find(
19
- (entry) => entry.name === topic || (entry.aliases ?? []).includes(topic ?? ''),
20
- );
41
+ const spec = specFor(specs, topic);
21
42
  if (spec === undefined) {
22
43
  // `cli.hint.help` is deliberately absent from this list: it is the command's own `summary`, and
23
44
  // `renderHuman` prints every line and THEN the summary — so the catalogue ended with the same
@@ -77,7 +98,7 @@ export function createHelpCommand(specs: () => readonly CommandSpec[]): CliComma
77
98
  command: 'help',
78
99
  summary: msg('cli.hint.help'),
79
100
  lines: renderHelp(all, topic),
80
- data: topic === undefined ? catalogue(all) : catalogue(all.filter((s) => s.name === topic)),
101
+ data: catalogue(helpTopic(all, topic)),
81
102
  };
82
103
  },
83
104
  };
package/src/cmd-i18n.ts CHANGED
@@ -12,7 +12,7 @@ import { catalogKeys, catalogMissingKeys } from '@ultimat3/i18n';
12
12
  import { loadApp } from './app-load';
13
13
  import { requireAppRoot } from './app-root';
14
14
  import type { CliCommand, CommandContext } from './command';
15
- import { BadFlagError, CatalogExistsError } from './errors';
15
+ import { BadFlagError, CatalogExistsError, MissingPositionalError } from './errors';
16
16
  import {
17
17
  auditApp,
18
18
  loadCatalogs,
@@ -64,10 +64,13 @@ async function writeNewCatalog(absolute: string, locale: string, contents: strin
64
64
  function requireLocalePositional(ctx: CommandContext, sub: string): string {
65
65
  const raw = ctx.args.positionals[0];
66
66
  if (raw === undefined) {
67
- throw new BadFlagError({
68
- flag: 'locale',
69
- command: 'i18n',
70
- reason: `"x i18n ${sub}" needs a locale: x i18n ${sub} <locale>`,
67
+ // The locale is a positional. `--locale on "x i18n"` is the cause a MALFORMED one takes, from
68
+ // the shared validator below — and there the flag name is what `resolveLocales` was told to
69
+ // report; here there is no value at all, and naming a flag sends the retry to a flag loop.
70
+ throw new MissingPositionalError({
71
+ command: `i18n ${sub}`,
72
+ positional: 'locale',
73
+ example: `x i18n ${sub} es`,
71
74
  });
72
75
  }
73
76
  return raw;
package/src/cmd-jobs.ts CHANGED
@@ -8,7 +8,7 @@ import type { JobDriver } from '@ultimat3/jobs';
8
8
  import { cancelJob, createMemoryDriver, createNatsDriver, createRedisDriver } from '@ultimat3/jobs';
9
9
  import { requireAppRoot } from './app-root';
10
10
  import type { CliCommand, CommandContext } from './command';
11
- import { BadFlagError, JobUnknownError } from './errors';
11
+ import { BadFlagError, JobUnknownError, MissingPositionalError } from './errors';
12
12
  import { drainJobs } from './jobs-drain';
13
13
  import { withJobDriver } from './jobs-driver';
14
14
  import {
@@ -33,10 +33,11 @@ const DRAIN_TARGETS = ['memory', 'redis', 'nats'] as const;
33
33
  function requireIdPositional(ctx: CommandContext, sub: string): string {
34
34
  const id = ctx.args.positionals[0];
35
35
  if (id === undefined) {
36
- throw new BadFlagError({
37
- flag: 'id',
38
- command: 'jobs',
39
- reason: `"x jobs ${sub}" needs a job id: x jobs ${sub} <id>`,
36
+ // `--id on "x jobs"` is a flag `x jobs` does not declare; the id is a positional and says so.
37
+ throw new MissingPositionalError({
38
+ command: `jobs ${sub}`,
39
+ positional: 'id',
40
+ example: 'x jobs ls --json',
40
41
  });
41
42
  }
42
43
  return id;
package/src/cmd-mcp.ts CHANGED
@@ -8,6 +8,7 @@ import { mcpHttpRoute, serveStdio } from '@ultimat3/mcp';
8
8
  import { requireAppRoot } from './app-root';
9
9
  import type { CliCommand, CommandContext } from './command';
10
10
  import { BadFlagError } from './errors';
11
+ import { intFlagOr, PORT_RANGE } from './flag-number';
11
12
  import { holdUntilShutdown } from './hold';
12
13
  import type { CliMcpServer } from './mcp-host';
13
14
  import { createDevMcpServer, DEV_TOOL_SCOPES } from './mcp-host';
@@ -128,19 +129,22 @@ async function serveOverStdio(host: CliMcpServer): Promise<CommandResult> {
128
129
  };
129
130
  }
130
131
 
131
- function readPort(ctx: CommandContext): number {
132
- const raw = flagString(ctx.args, 'port') ?? String(DEFAULT_PORT);
133
- const port = Number.parseInt(raw, 10);
134
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
135
- throw new BadFlagError({
136
- flag: 'port',
132
+ /**
133
+ * `flag-number.ts`'s reader, never a bare `Number.parseInt`: the range check alone accepted every
134
+ * prefix parse, so `--port 1e5` bound port 1 and `--port 0x10` bound 0 — a socket at an address the
135
+ * operator did not type, on the transport whose whole output is the url it is reachable at.
136
+ */
137
+ const readPort = (ctx: CommandContext): number =>
138
+ intFlagOr(
139
+ ctx.args,
140
+ {
141
+ name: 'port',
137
142
  command: 'mcp serve',
138
- reason: `expects a port in 0..65535, got "${raw}"`,
139
- fix: `x mcp serve --transport http --port ${DEFAULT_PORT}`,
140
- });
141
- }
142
- return port;
143
- }
143
+ ...PORT_RANGE,
144
+ example: `x mcp serve --transport http --port ${DEFAULT_PORT}`,
145
+ },
146
+ DEFAULT_PORT,
147
+ );
144
148
 
145
149
  export const mcpCommand: CliCommand = {
146
150
  spec: {
package/src/cmd-new.ts CHANGED
@@ -7,6 +7,7 @@ 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 { MissingPositionalError } from './errors';
10
11
  import { msg } from './messages';
11
12
  import type { CommandResult } from './output';
12
13
  import { flagBool, flagString } from './parse';
@@ -25,7 +26,7 @@ export function planNewApp(options: NewAppOptions): readonly GeneratedFile[] {
25
26
  const app = names(options.name);
26
27
  const files: GeneratedFile[] = [
27
28
  ...repoFiles(app, loadVersion(), options.example),
28
- ...appFiles(app),
29
+ ...appFiles(app, options.example),
29
30
  ];
30
31
  if (options.example) {
31
32
  files.push(...resourceFiles('post', { surfaceDir: 'apps/web/app', feature: 'post' }));
@@ -67,7 +68,7 @@ export const newCommand: CliCommand = {
67
68
  spec: {
68
69
  name: 'new',
69
70
  summary: 'scaffold a new Ultimate monorepo that already runs',
70
- usage: 'x new <name> [--dir path] [--no-example] [--dry-run] [--json]',
71
+ usage: 'x new <name> [--dir path] [--no-example] [--dry-run] [--force] [--json]',
71
72
  flags: [
72
73
  { name: 'dir', type: 'string', summary: 'parent directory (default: cwd)' },
73
74
  {
@@ -82,20 +83,15 @@ export const newCommand: CliCommand = {
82
83
  },
83
84
  async run(ctx: CommandContext): Promise<CommandResult> {
84
85
  const raw = ctx.args.positionals[0];
86
+ // The class, not a hand-built finding with the same code: `MissingPositionalError` is what
87
+ // names the missing POSITIONAL, and a finding assembled here is a second, unenforced copy of
88
+ // a cause the class already writes — one that said "x new needs a name" and not what a name is.
85
89
  if (raw === undefined) {
86
- return {
87
- ok: false,
90
+ throw new MissingPositionalError({
88
91
  command: 'new',
89
- summary: msg('cli.usage'),
90
- findings: [
91
- {
92
- code: 'X_CLI_BAD_FLAG',
93
- cause: 'x new needs a name',
94
- fix: 'x new myapp',
95
- docs: 'https://ultimate.dev/errors/X_CLI_BAD_FLAG',
96
- },
97
- ],
98
- };
92
+ positional: 'name',
93
+ example: 'x new myapp',
94
+ });
99
95
  }
100
96
  const app = names(raw);
101
97
  const target = resolve(parentDir(ctx.cwd, flagString(ctx.args, 'dir')), app.kebab);
@@ -91,6 +91,19 @@ export const PLANNED_COMMANDS: readonly PlannedCommand[] = [
91
91
  },
92
92
  ];
93
93
 
94
+ /**
95
+ * The planned command a resolved command NAME belongs to, if any. Exported for `dispatch.ts`'s
96
+ * parse-failure branch: the parser refuses an undeclared flag before any `run` is reached, and a
97
+ * planned command declares only the four global flags — so `x logs tail --follow` answered
98
+ * X_CLI_BAD_FLAG listing "known: json, help, cwd, verbose", a flag set belonging to a command that
99
+ * does not exist yet, while `x logs tail` answered the honest X_NOT_IMPLEMENTED one invocation away.
100
+ *
101
+ * Takes the name `commandFor` resolved, never the raw word: aliases are that function's business
102
+ * and a second matcher here would be a second answer to "which command did they type".
103
+ */
104
+ export const plannedCommandFor = (name: string | undefined): PlannedCommand | undefined =>
105
+ PLANNED_COMMANDS.find((planned) => planned.name === name);
106
+
94
107
  export interface PlannedSubcommand {
95
108
  readonly command: string;
96
109
  readonly subcommand: string;
package/src/cmd-policy.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import { loadApp } from './app-load';
6
6
  import { requireAppRoot } from './app-root';
7
7
  import type { CliCommand, CommandContext } from './command';
8
- import { BadFlagError, DeclarationUnknownError } from './errors';
8
+ import { DeclarationUnknownError, MissingPositionalError } from './errors';
9
9
  import { msg } from './messages';
10
10
  import type { CommandResult, Finding, JsonValue } from './output';
11
11
  import { nearest } from './parse';
@@ -74,11 +74,13 @@ function declarationLines(declaration: DeclarationExplanation): readonly string[
74
74
  function requireSubject(ctx: CommandContext): string {
75
75
  const name = ctx.args.positionals[0];
76
76
  if (name === undefined) {
77
- throw new BadFlagError({
78
- flag: 'subject',
79
- command: 'policy',
80
- reason: 'x policy explain <subject> needs a permission, action, query or route path',
81
- fix: 'x policy list --json',
77
+ // Never `BadFlagError`: its cause read `--subject on "x policy"`, and the next thing an agent
78
+ // typed was `x policy explain --subject posts:read`, which is a second X_CLI_BAD_FLAG for a
79
+ // flag this command does not declare. The positional is what is missing, so it is what is named.
80
+ throw new MissingPositionalError({
81
+ command: 'policy explain',
82
+ positional: 'subject',
83
+ example: 'x policy list --json',
82
84
  });
83
85
  }
84
86
  return name;
@@ -13,7 +13,7 @@ import { describeQueries, getQuery } from '@ultimat3/query';
13
13
  import { loadApp } from './app-load';
14
14
  import { requireAppRoot } from './app-root';
15
15
  import type { CliCommand, CommandContext } from './command';
16
- import { BadFlagError, DeclarationUnknownError } from './errors';
16
+ import { DeclarationUnknownError, MissingPositionalError } from './errors';
17
17
  import { msg } from './messages';
18
18
  import type { CommandResult, Finding, JsonValue } from './output';
19
19
  import type { CommandSpec } from './parse';
@@ -143,11 +143,12 @@ function describeResult<D extends { readonly name: string }, Raw extends { descr
143
143
  ): CommandResult {
144
144
  const name = ctx.args.positionals[0];
145
145
  if (name === undefined) {
146
- throw new BadFlagError({
147
- flag: 'name',
148
- command: kind.kind,
149
- reason: `x ${kind.kind} describe <name> needs a name`,
150
- fix: `x ${kind.kind} list --json`,
146
+ // A positional, so `MissingPositionalError` — `--name on "x actions"` named a flag no registry
147
+ // command declares, and reading it as one is a second refusal for the first one's advice.
148
+ throw new MissingPositionalError({
149
+ command: `${kind.kind} describe`,
150
+ positional: 'name',
151
+ example: `x ${kind.kind} list --json`,
151
152
  });
152
153
  }
153
154
  const raw = kind.find(name);