@ultimat3/cli 1.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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +60 -0
  4. package/src/app-agents-md.ts +27 -0
  5. package/src/app-boundaries.ts +206 -0
  6. package/src/app-evals.ts +74 -0
  7. package/src/app-load.ts +136 -0
  8. package/src/app-manifest.ts +137 -0
  9. package/src/app-openapi.ts +12 -0
  10. package/src/app-root.ts +57 -0
  11. package/src/bin.ts +17 -0
  12. package/src/boundary-cuts.ts +219 -0
  13. package/src/budgets.ts +92 -0
  14. package/src/cmd-build.ts +109 -0
  15. package/src/cmd-db.ts +187 -0
  16. package/src/cmd-deploy.ts +124 -0
  17. package/src/cmd-dev.ts +286 -0
  18. package/src/cmd-doctor.ts +178 -0
  19. package/src/cmd-errors.ts +99 -0
  20. package/src/cmd-fix.ts +126 -0
  21. package/src/cmd-generate.ts +434 -0
  22. package/src/cmd-help.ts +94 -0
  23. package/src/cmd-i18n.ts +212 -0
  24. package/src/cmd-jobs.ts +237 -0
  25. package/src/cmd-manifest.ts +97 -0
  26. package/src/cmd-mcp.ts +176 -0
  27. package/src/cmd-new.ts +133 -0
  28. package/src/cmd-planned.ts +119 -0
  29. package/src/cmd-policy.ts +136 -0
  30. package/src/cmd-registries.ts +195 -0
  31. package/src/cmd-routes.ts +73 -0
  32. package/src/cmd-tasks.ts +151 -0
  33. package/src/cmd-test.ts +109 -0
  34. package/src/cmd-verify.ts +265 -0
  35. package/src/command.ts +33 -0
  36. package/src/dev-assets.ts +177 -0
  37. package/src/dev-dashboard.ts +242 -0
  38. package/src/dev-hooks.ts +51 -0
  39. package/src/dev-policy.ts +82 -0
  40. package/src/dev-queue.ts +109 -0
  41. package/src/dev-render.ts +129 -0
  42. package/src/dev-replicator.ts +92 -0
  43. package/src/dev-roles.ts +246 -0
  44. package/src/dev-runtime.ts +203 -0
  45. package/src/dev-services.ts +75 -0
  46. package/src/dev-traces.ts +141 -0
  47. package/src/dispatch.ts +98 -0
  48. package/src/drift.ts +86 -0
  49. package/src/error-catalog.ts +156 -0
  50. package/src/error-contract.ts +212 -0
  51. package/src/errors.ts +367 -0
  52. package/src/exec.ts +70 -0
  53. package/src/hold.ts +48 -0
  54. package/src/i18n-audit.ts +183 -0
  55. package/src/index.ts +179 -0
  56. package/src/jobs-drain.ts +151 -0
  57. package/src/jobs-json.ts +134 -0
  58. package/src/jobs-report.ts +132 -0
  59. package/src/jobs-table.ts +34 -0
  60. package/src/json-merge.ts +40 -0
  61. package/src/mcp-db-target.ts +50 -0
  62. package/src/mcp-errors.ts +99 -0
  63. package/src/mcp-host.ts +282 -0
  64. package/src/mcp-test-output.ts +57 -0
  65. package/src/messages.ts +119 -0
  66. package/src/output.ts +174 -0
  67. package/src/parse.ts +243 -0
  68. package/src/policy-facts.ts +196 -0
  69. package/src/policy-fixture.ts +71 -0
  70. package/src/registry.ts +73 -0
  71. package/src/scaffold-fixture.ts +69 -0
  72. package/src/scaffold-typecheck.ts +240 -0
  73. package/src/source-files.ts +38 -0
  74. package/src/table.ts +19 -0
  75. package/src/tasks-facts.ts +113 -0
  76. package/src/templates/action.ts +193 -0
  77. package/src/templates/admin.ts +46 -0
  78. package/src/templates/catalog-json.ts +17 -0
  79. package/src/templates/entity.ts +157 -0
  80. package/src/templates/index.ts +23 -0
  81. package/src/templates/job.ts +148 -0
  82. package/src/templates/locales.ts +93 -0
  83. package/src/templates/naming.ts +97 -0
  84. package/src/templates/policy.ts +120 -0
  85. package/src/templates/query.ts +116 -0
  86. package/src/templates/resource.ts +199 -0
  87. package/src/templates/route.ts +138 -0
  88. package/src/templates/scaffold-app.ts +320 -0
  89. package/src/templates/scaffold-docs.ts +156 -0
  90. package/src/templates/scaffold-i18n.ts +149 -0
  91. package/src/templates/scaffold-icon.ts +54 -0
  92. package/src/templates/scaffold-package-shape.ts +49 -0
  93. package/src/templates/scaffold-repo.ts +427 -0
  94. package/src/test-select.ts +130 -0
  95. package/src/test-shards.ts +188 -0
  96. package/src/thrown-by.ts +24 -0
  97. package/src/ts-scan.ts +217 -0
  98. package/src/verify-step.ts +83 -0
  99. package/src/verify-tests.ts +166 -0
  100. package/src/version-loader.ts +16 -0
  101. package/src/workspace-checks.ts +288 -0
@@ -0,0 +1,134 @@
1
+ // Every `--json` projection behind `x jobs`, and nothing else. Split out of `jobs-report.ts`:
2
+ // `data` must be plain JSON — no `undefined`, no bare `unknown` — and that rule is only
3
+ // enforceable if the projections sit together where one missing `?? null` is visible.
4
+
5
+ import type {
6
+ DeadLetterEntry,
7
+ JobRecord,
8
+ JobTrace,
9
+ QueueDepthReport,
10
+ QueueStats,
11
+ StepTrace,
12
+ } from '@ultimat3/jobs';
13
+ import type { DrainFailure, DrainSkip } from './jobs-drain';
14
+ import type { JsonValue } from './output';
15
+
16
+ function stepTraceToJson(step: StepTrace): JsonValue {
17
+ return {
18
+ name: step.name,
19
+ status: step.status,
20
+ startedAt: step.startedAt,
21
+ completedAt: step.completedAt,
22
+ wakeAt: step.wakeAt,
23
+ durationMs: step.durationMs,
24
+ attempts: step.attempts,
25
+ error: step.error,
26
+ };
27
+ }
28
+
29
+ export function jobTraceToJson(trace: JobTrace): JsonValue {
30
+ return {
31
+ id: trace.id,
32
+ name: trace.name,
33
+ queue: trace.queue,
34
+ state: trace.state,
35
+ attempt: trace.attempt,
36
+ maxAttempts: trace.maxAttempts,
37
+ idempotencyKey: trace.idempotencyKey,
38
+ runId: trace.runId,
39
+ runAt: trace.runAt,
40
+ lastError: trace.lastError,
41
+ tenantId: trace.tenantId,
42
+ steps: trace.steps.map(stepTraceToJson),
43
+ retryDelaysMs: trace.retryDelaysMs.map((ms) => ms),
44
+ };
45
+ }
46
+
47
+ /**
48
+ * `input` is deliberately excluded: it is `unknown` at the driver boundary (an app-defined
49
+ * payload, not something this package can prove is JSON-safe), and `JobTrace` already sets the
50
+ * precedent of leaving it out of every JSON-facing projection.
51
+ */
52
+ export function jobRecordToJson(record: JobRecord): JsonValue {
53
+ return {
54
+ id: record.id,
55
+ name: record.name,
56
+ queue: record.queue,
57
+ state: record.state,
58
+ attempt: record.attempt,
59
+ maxAttempts: record.maxAttempts,
60
+ idempotencyKey: record.idempotencyKey,
61
+ runId: record.runId,
62
+ runAt: record.runAt,
63
+ createdAt: record.createdAt,
64
+ updatedAt: record.updatedAt,
65
+ tenantId: record.tenantId ?? null,
66
+ lastError: record.lastError ?? null,
67
+ claimedBy: record.claimedBy ?? null,
68
+ visibleAt: record.visibleAt ?? null,
69
+ };
70
+ }
71
+
72
+ function queueStatsToJson(stats: QueueStats): JsonValue {
73
+ return {
74
+ queue: stats.queue,
75
+ ready: stats.ready,
76
+ delayed: stats.delayed,
77
+ running: stats.running,
78
+ suspended: stats.suspended,
79
+ dead: stats.dead,
80
+ oldestReadyMs: stats.oldestReadyMs,
81
+ };
82
+ }
83
+
84
+ export function depthToJson(depth: QueueDepthReport): JsonValue {
85
+ return {
86
+ driver: depth.driver,
87
+ queues: depth.queues.map(queueStatsToJson),
88
+ totals: {
89
+ ready: depth.totals.ready,
90
+ delayed: depth.totals.delayed,
91
+ running: depth.totals.running,
92
+ suspended: depth.totals.suspended,
93
+ dead: depth.totals.dead,
94
+ },
95
+ oldestReadyMs: depth.oldestReadyMs,
96
+ };
97
+ }
98
+
99
+ export function deadLetterToJson(entry: DeadLetterEntry): JsonValue {
100
+ return {
101
+ id: entry.id,
102
+ name: entry.name,
103
+ queue: entry.queue,
104
+ attempt: entry.attempt,
105
+ lastError: entry.lastError,
106
+ failedAt: entry.failedAt,
107
+ retryCommand: entry.retryCommand,
108
+ };
109
+ }
110
+
111
+ export function drainFailureToJson(failure: DrainFailure): JsonValue {
112
+ return {
113
+ id: failure.id,
114
+ name: failure.name,
115
+ finding: {
116
+ code: failure.finding.code,
117
+ cause: failure.finding.cause,
118
+ fix: failure.finding.fix,
119
+ docs: failure.finding.docs ?? null,
120
+ at: failure.finding.at ?? null,
121
+ },
122
+ };
123
+ }
124
+
125
+ /** A candidate the drain refused to touch, and why — the half of the outcome `moved` cannot show. */
126
+ export function drainSkipToJson(skip: DrainSkip): JsonValue {
127
+ return {
128
+ id: skip.id,
129
+ name: skip.name,
130
+ queue: skip.queue,
131
+ state: skip.state,
132
+ reason: skip.reason,
133
+ };
134
+ }
@@ -0,0 +1,132 @@
1
+ // Pure, driver-injected job operations behind `x jobs`: flag parsing, plus ls / show / retry. No
2
+ // CLI parsing, no process I/O, no rendering — a test drives every path with `createMemoryDriver()`
3
+ // alone. Drain is `jobs-drain.ts`, the `--json` shapes `jobs-json.ts`, the table `jobs-table.ts`.
4
+
5
+ import type {
6
+ DeadLetterEntry,
7
+ JobDriver,
8
+ JobFilter,
9
+ JobRecord,
10
+ JobState,
11
+ JobTrace,
12
+ QueueDepthReport,
13
+ } from '@ultimat3/jobs';
14
+ import {
15
+ inspectDeadLetters,
16
+ inspectJob,
17
+ inspectJobList,
18
+ inspectQueues,
19
+ retryFromStep,
20
+ } from '@ultimat3/jobs';
21
+ import { BadFlagError, JobUnknownError } from './errors';
22
+
23
+ /** Mirrors `JobState` from `@ultimat3/jobs`, which exports the type but no runtime list. */
24
+ export const JOB_STATES: readonly JobState[] = [
25
+ 'ready',
26
+ 'delayed',
27
+ 'running',
28
+ 'suspended',
29
+ 'done',
30
+ 'failed',
31
+ 'dead',
32
+ ];
33
+
34
+ const isJobState = (value: string): value is JobState =>
35
+ (JOB_STATES as readonly string[]).includes(value);
36
+
37
+ export function parseStateFlag(value: string | undefined): JobState | undefined {
38
+ if (value === undefined) return undefined;
39
+ if (isJobState(value)) return value;
40
+ throw new BadFlagError({
41
+ flag: 'state',
42
+ command: 'jobs',
43
+ reason: `unknown state "${value}" (known: ${JOB_STATES.join(', ')})`,
44
+ });
45
+ }
46
+
47
+ /**
48
+ * A digit string is not yet a limit: past `Number.MAX_SAFE_INTEGER` the parse silently lands on a
49
+ * different integer, and `1e400`-shaped input yields `Infinity`. Either way the driver would be
50
+ * handed a bound other than the one typed, so the safe-integer check is the flag's real contract.
51
+ */
52
+ export function parseLimitFlag(value: string | undefined): number | undefined {
53
+ if (value === undefined) return undefined;
54
+ const digits = value.trim();
55
+ const limit = /^\d+$/.test(digits) ? Number(digits) : Number.NaN;
56
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
57
+ throw new BadFlagError({
58
+ flag: 'limit',
59
+ command: 'jobs',
60
+ reason: `expects an integer from 1 to ${Number.MAX_SAFE_INTEGER}, got "${value}"`,
61
+ });
62
+ }
63
+ return limit;
64
+ }
65
+
66
+ // ── ls ────────────────────────────────────────────────────────────────────
67
+
68
+ export interface JobsListFilter {
69
+ readonly queue?: string | undefined;
70
+ readonly state?: string | undefined;
71
+ readonly name?: string | undefined;
72
+ readonly limit?: string | undefined;
73
+ }
74
+
75
+ export interface JobsListResult {
76
+ readonly depth: QueueDepthReport;
77
+ readonly rows: readonly JobRecord[];
78
+ readonly deadLetters: readonly DeadLetterEntry[];
79
+ }
80
+
81
+ /**
82
+ * The depth report AND the filtered rows, plus dead letters unconditionally: a dead job that a
83
+ * `--state ready` filter (or the default 100-row cap) pushes out of view is the exact failure
84
+ * mode this command exists to prevent.
85
+ */
86
+ export async function listJobs(
87
+ driver: JobDriver,
88
+ filter: JobsListFilter = {},
89
+ ): Promise<JobsListResult> {
90
+ const state = parseStateFlag(filter.state);
91
+ const limit = parseLimitFlag(filter.limit);
92
+ const jobFilter: JobFilter = {
93
+ ...(filter.queue === undefined ? {} : { queue: filter.queue }),
94
+ ...(filter.name === undefined ? {} : { name: filter.name }),
95
+ ...(state === undefined ? {} : { state }),
96
+ ...(limit === undefined ? {} : { limit }),
97
+ };
98
+ const [depth, rows, deadLetters] = await Promise.all([
99
+ inspectQueues(driver),
100
+ inspectJobList(driver, jobFilter),
101
+ inspectDeadLetters(driver),
102
+ ]);
103
+ return { depth, rows, deadLetters };
104
+ }
105
+
106
+ // ── show ──────────────────────────────────────────────────────────────────
107
+
108
+ export async function showJob(driver: JobDriver, id: string): Promise<JobTrace> {
109
+ const trace = await inspectJob(driver, id);
110
+ if (trace === undefined) throw new JobUnknownError({ id, driver: driver.name });
111
+ return trace;
112
+ }
113
+
114
+ // ── retry ─────────────────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Existence is checked up front so an unknown id always surfaces as `X_JOB_UNKNOWN`: the
118
+ * concrete drivers (pg, memory) throw their own error from inside `requeue()` for a missing
119
+ * row, and that error is not this command's contract — `retryFromStep`'s documented `undefined`
120
+ * return is, and the driver never reaches it once the row is already known absent.
121
+ */
122
+ export async function retryJob(
123
+ driver: JobDriver,
124
+ id: string,
125
+ fromStep?: string,
126
+ ): Promise<JobTrace> {
127
+ const existing = await inspectJob(driver, id);
128
+ if (existing === undefined) throw new JobUnknownError({ id, driver: driver.name });
129
+ const trace = await retryFromStep(driver, id, fromStep);
130
+ if (trace === undefined) throw new JobUnknownError({ id, driver: driver.name });
131
+ return trace;
132
+ }
@@ -0,0 +1,34 @@
1
+ // The `x jobs ls` terminal table, and nothing else. Split out of `jobs-report.ts` so that
2
+ // deciding how a value LOOKS lives apart from deciding what the queue DOES: a rendering rule
3
+ // (column order, padding, the run-at unit) is reviewed here, once, with no queue logic around it.
4
+
5
+ import type { JobRecord } from '@ultimat3/jobs';
6
+
7
+ const HEADER = ['id', 'name', 'queue', 'state', 'attempt', 'run-at-ms'] as const;
8
+
9
+ /**
10
+ * Fixed-width columns, same padding idiom as `renderRouteTable` in `cmd-routes.ts`.
11
+ *
12
+ * `run-at-ms` is the raw epoch, deliberately NOT a formatted date. The repo forbids formatting a
13
+ * date without an explicit IANA `timeZone`, and not formatting at all is the one rendering with
14
+ * no zone to get wrong. Formatting it properly would mean `@ultimat3/time`, which is not a
15
+ * declared dependency of this package and whose formatters need a locale the CLI has no concept
16
+ * of — and its output would no longer sort. `--json` already emits `runAt` as this same number,
17
+ * so the two renders of one command stay comparable; `x jobs show <id>` is the readable view.
18
+ */
19
+ export function renderJobTable(rows: readonly JobRecord[]): readonly string[] {
20
+ const body = rows.map((row) => [
21
+ row.id,
22
+ row.name,
23
+ row.queue,
24
+ row.state,
25
+ String(row.attempt),
26
+ String(row.runAt),
27
+ ]);
28
+ const widths = HEADER.map((title, index) =>
29
+ Math.max(title.length, ...body.map((row) => (row[index] ?? '').length)),
30
+ );
31
+ const line = (cells: readonly string[]): string =>
32
+ cells.map((value, index) => value.padEnd(widths[index] ?? 0)).join(' ');
33
+ return [line(HEADER), ...body.map(line)];
34
+ }
@@ -0,0 +1,40 @@
1
+ // The one union behind every `merge: 'json'` generated file. Deep, because a catalog is authored
2
+ // nested (`{ site: { home: { title } } }`) — a shallow spread of two generators' contributions
3
+ // under the same top-level key drops one of them entirely, and neither generator can see the other.
4
+
5
+ /** A parsed JSON object: the only shape a `merge: 'json'` file is ever allowed to hold. */
6
+ export type JsonObject = Record<string, unknown>;
7
+
8
+ const isObject = (value: unknown): value is JsonObject =>
9
+ typeof value === 'object' && value !== null && !Array.isArray(value);
10
+
11
+ /**
12
+ * `incoming` merged under `held`, leaf by leaf. `held` always wins — on disk it is the file a
13
+ * human may have translated, and in `dedupe` it is the contribution that got there first — so a
14
+ * merge only ever *adds* keys. A branch meeting a leaf is the same conflict either direction:
15
+ * `held` keeps its shape, because overwriting it is the data loss this never does.
16
+ *
17
+ * `gained` is whether anything was actually added, so a caller can leave a file untouched rather
18
+ * than rewrite it byte-identically and claim it as written.
19
+ */
20
+ export function mergeJsonDeep(
21
+ held: JsonObject,
22
+ incoming: JsonObject,
23
+ ): { merged: JsonObject; gained: boolean } {
24
+ const merged: JsonObject = { ...held };
25
+ let gained = false;
26
+ for (const [key, value] of Object.entries(incoming)) {
27
+ if (!Object.hasOwn(held, key)) {
28
+ merged[key] = value;
29
+ gained = true;
30
+ continue;
31
+ }
32
+ const current = held[key];
33
+ if (!isObject(current) || !isObject(value)) continue;
34
+ const nested = mergeJsonDeep(current, value);
35
+ if (!nested.gained) continue;
36
+ merged[key] = nested.merged;
37
+ gained = true;
38
+ }
39
+ return { merged, gained };
40
+ }
@@ -0,0 +1,50 @@
1
+ // Which database the MCP dev host is pointed at, and whether that database is a branch. `db.migrate`
2
+ // decides from this alone, so the reading has to be exact: a wrong `branch` is a migration against a
3
+ // database somebody else is using.
4
+
5
+ import { basename, join } from 'node:path';
6
+ import { pgliteDataDir } from '@ultimat3/db';
7
+ import type { DatabaseTarget } from '@ultimat3/mcp';
8
+ import type { DevServices } from './dev-services';
9
+
10
+ /**
11
+ * `production` is always false: this target is whatever `x dev` resolved — embedded PGlite under
12
+ * `.x/`, or the `DATABASE_URL` of a developer's shell. Production is reached through `ROLE=migrate`
13
+ * in a deploy hook, never through MCP. What actually stops a migration against a shared database is
14
+ * `branch`, which is null unless the name says otherwise.
15
+ */
16
+ export function databaseTarget(services: DevServices): DatabaseTarget {
17
+ const url = services.db.url;
18
+ return services.db.mode === 'embedded'
19
+ ? { label: url, branch: pgliteBranch(url, services.stateDir), production: false }
20
+ : { label: safeLabel(url), branch: postgresBranch(url), production: false };
21
+ }
22
+
23
+ /** An external `DATABASE_URL` may carry credentials, and this string gets printed. */
24
+ function safeLabel(url: string): string {
25
+ try {
26
+ const parsed = new URL(url);
27
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
28
+ } catch {
29
+ return 'external database';
30
+ }
31
+ }
32
+
33
+ /** `x db branch <name>` names an external clone `<source>_branch_<name>` (`branchDatabaseName`). */
34
+ function postgresBranch(url: string): string | null {
35
+ let database: string;
36
+ try {
37
+ database = new URL(url).pathname.replace(/^\//, '');
38
+ } catch {
39
+ return null;
40
+ }
41
+ return /_branch_(.+)$/.exec(database)?.[1] ?? null;
42
+ }
43
+
44
+ /** `branchPglite` copies `<stateDir>/pgdata` to `<stateDir>/pgdata-<name>`; the dev dir is no branch. */
45
+ function pgliteBranch(url: string, stateDir: string): string | null {
46
+ const dir = pgliteDataDir(url);
47
+ const dev = join(stateDir, 'pgdata');
48
+ if (dir === dev || basename(dir) === basename(dev)) return null;
49
+ return dir.startsWith(`${dev}-`) ? dir.slice(dev.length + 1) : null;
50
+ }
@@ -0,0 +1,99 @@
1
+ // `errors.explain`: one runnable command per error code. Its own file because the CLI's fix table
2
+ // is a contract — a code without a command is the thing "errors are instructions" exists to
3
+ // prevent, and the typed record below is what makes forgetting one a build error.
4
+
5
+ import { describeErrorCode, hasErrorCode, listErrorCodes } from '@ultimat3/core';
6
+ import type { ErrorExplanation } from '@ultimat3/mcp';
7
+ import type { CliErrorCode } from './errors';
8
+ import { CLI_ERROR_CODES, docsFor } from './errors';
9
+
10
+ /**
11
+ * One runnable command per CLI code. Typed over `CliErrorCode`, so a new code fails the build.
12
+ *
13
+ * Every `x` invocation here carries `--json`, because that is the flag the whole CLI is built
14
+ * around (`GLOBAL_FLAGS` in `parse.ts`, axiom 4): the agent that was handed one of these fixes ran
15
+ * a machine-readable command to get here, and a fix that drops back to prose breaks the loop it is
16
+ * meant to close. `bun`, `bunx` and the gate scripts keep their own surfaces — `--json` is the
17
+ * `x` CLI's contract, not a universal one.
18
+ */
19
+ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
20
+ X_CLI_UNKNOWN_COMMAND: 'x help --json',
21
+ X_CLI_BAD_FLAG: 'x help <command> --json',
22
+ X_VERIFY_FAILED: 'x verify --json',
23
+ X_NOT_IN_APP: 'x new myapp --json && cd myapp',
24
+ X_BUN_VERSION: 'bun upgrade',
25
+ X_NOT_IMPLEMENTED: 'x doctor --json',
26
+ X_TEST_NO_FILES: 'x test --cwd <repo root> --json',
27
+ X_TEST_SHARD_FAILED: 'x test --workers 1 --json',
28
+ X_SCAFFOLD_PATH_ESCAPE: 'x g route <name> --json # a path with no ".." segment',
29
+ X_GENERATE_JSON_INVALID:
30
+ 'bun test packages/cli/src/cmd-generate.test.ts # the error names the template to fix',
31
+ X_APP_PACKAGE_INVALID: 'bun pm pkg set name=<app> version=0.1.0',
32
+ X_ERROR_CODE_UNKNOWN: 'x errors list --json',
33
+ X_DECLARATION_UNKNOWN: 'x actions list --json',
34
+ X_JOB_UNKNOWN: 'x jobs ls --json',
35
+ X_FIX_TARGET_UNKNOWN: 'x fix boundary apps/web/site/page.tsx --json',
36
+ X_ERROR_FIX_INVALID: 'x verify --json # the finding names the file, the line and the fix text',
37
+ X_ERROR_CODE_UNDOCUMENTED: 'x verify --json # the finding names the code and the missing page',
38
+ X_ERROR_CODE_UNREGISTERED:
39
+ 'x errors list --json # register the code in its package src/errors.ts, or move its row under "Reserved codes"',
40
+ X_CLI_UNEXPECTED: 'x doctor --json',
41
+ X_TYPECHECK_FAILED: 'bunx tsc -b --pretty false',
42
+ X_LINT_FAILED: 'bunx biome check --write .',
43
+ X_TEST_FAILED: 'x test --json # the finding carries the exact bun test invocation that failed',
44
+ X_FILE_TOO_LONG: 'x verify --json # the finding names the file to split',
45
+ X_PACKAGE_SHAPE: 'bun run scripts/new-package.ts <pkg> --only <file>',
46
+ X_RELEASE_VERSION_SKEW: 'bun run scripts/release.ts --bump patch --dry-run --json',
47
+ X_MANIFEST_STALE: 'x manifest --json',
48
+ X_BUDGET_UNMEASURED: 'x build --json && x verify --json',
49
+ X_BUILD_FAILED: 'x build --json # the finding names the failing step',
50
+ X_DEPLOY_FAILED: 'x deploy --json # the finding carries the command to re-run directly',
51
+ X_GENERATE_CONFLICT: 'x g <kind> <name> --force --json',
52
+ X_PORT_IN_USE: 'x dev --port 3001 --json',
53
+ // Not `x db status`: there is no such subcommand (`x db` is gen, migrate, reset, studio, branch),
54
+ // so the fix answered a failed step with X_CLI_UNKNOWN_COMMAND. `x doctor` is what reports
55
+ // reachability and drift, and is already this table's answer for X_DB_STUDIO_FAILED.
56
+ X_DB_GEN_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
57
+ X_DB_MIGRATE_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
58
+ X_DB_BRANCH_FAILED: 'x db branch ls --json',
59
+ X_DB_STUDIO_FAILED: 'x doctor --json',
60
+ X_BOUNDARY_SITE_TO_APP: 'x fix boundary <file> --json',
61
+ X_BOUNDARY_SHARED_LEAF: 'x fix boundary <file> --json',
62
+ X_BOUNDARY_APP_TO_API: 'x fix boundary <file> --json',
63
+ X_BOUNDARY_ROUTE_TO_DB: 'x fix boundary <file> --json',
64
+ X_BOUNDARY_SERVICE_TO_HTTP: 'x fix boundary <file> --json',
65
+ };
66
+
67
+ const isCliCode = (code: string): code is CliErrorCode =>
68
+ (CLI_ERROR_CODES as readonly string[]).includes(code);
69
+
70
+ /**
71
+ * `undefined` for a code nobody registered — the tool then answers "unknown error code", which
72
+ * beats an invented explanation. The framework-wide registry holds a title and a docs URL but no
73
+ * fix (a thrown error carries its own), so a non-CLI code points at the gate that surfaces it.
74
+ */
75
+ export function explainErrorCode(code: string): ErrorExplanation | undefined {
76
+ const cli = isCliCode(code);
77
+ if (!cli && !hasErrorCode(code)) return undefined;
78
+ const described = describeErrorCode(code);
79
+ return {
80
+ code,
81
+ cause: described.title,
82
+ fix: cli ? CLI_FIXES[code] : 'x verify --json',
83
+ docs: cli ? docsFor(code) : described.docs,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Every code an agent can be handed, in one sorted list. Reads the framework-wide registry rather
89
+ * than a second table: `errors.ts` registers the CLI's own titles at import, so a code that is
90
+ * missing here is a code nobody registered — which is exactly what the list should show.
91
+ */
92
+ export function explainEveryErrorCode(): readonly ErrorExplanation[] {
93
+ const explained: ErrorExplanation[] = [];
94
+ for (const entry of listErrorCodes()) {
95
+ const explanation = explainErrorCode(entry.code);
96
+ if (explanation !== undefined) explained.push(explanation);
97
+ }
98
+ return explained;
99
+ }