@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.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +60 -0
- package/src/app-agents-md.ts +27 -0
- package/src/app-boundaries.ts +206 -0
- package/src/app-evals.ts +74 -0
- package/src/app-load.ts +136 -0
- package/src/app-manifest.ts +137 -0
- package/src/app-openapi.ts +12 -0
- package/src/app-root.ts +57 -0
- package/src/bin.ts +17 -0
- package/src/boundary-cuts.ts +219 -0
- package/src/budgets.ts +92 -0
- package/src/cmd-build.ts +109 -0
- package/src/cmd-db.ts +187 -0
- package/src/cmd-deploy.ts +124 -0
- package/src/cmd-dev.ts +286 -0
- package/src/cmd-doctor.ts +178 -0
- package/src/cmd-errors.ts +99 -0
- package/src/cmd-fix.ts +126 -0
- package/src/cmd-generate.ts +434 -0
- package/src/cmd-help.ts +94 -0
- package/src/cmd-i18n.ts +212 -0
- package/src/cmd-jobs.ts +237 -0
- package/src/cmd-manifest.ts +97 -0
- package/src/cmd-mcp.ts +176 -0
- package/src/cmd-new.ts +133 -0
- package/src/cmd-planned.ts +119 -0
- package/src/cmd-policy.ts +136 -0
- package/src/cmd-registries.ts +195 -0
- package/src/cmd-routes.ts +73 -0
- package/src/cmd-tasks.ts +151 -0
- package/src/cmd-test.ts +109 -0
- package/src/cmd-verify.ts +265 -0
- package/src/command.ts +33 -0
- package/src/dev-assets.ts +177 -0
- package/src/dev-dashboard.ts +242 -0
- package/src/dev-hooks.ts +51 -0
- package/src/dev-policy.ts +82 -0
- package/src/dev-queue.ts +109 -0
- package/src/dev-render.ts +129 -0
- package/src/dev-replicator.ts +92 -0
- package/src/dev-roles.ts +246 -0
- package/src/dev-runtime.ts +203 -0
- package/src/dev-services.ts +75 -0
- package/src/dev-traces.ts +141 -0
- package/src/dispatch.ts +98 -0
- package/src/drift.ts +86 -0
- package/src/error-catalog.ts +156 -0
- package/src/error-contract.ts +212 -0
- package/src/errors.ts +367 -0
- package/src/exec.ts +70 -0
- package/src/hold.ts +48 -0
- package/src/i18n-audit.ts +183 -0
- package/src/index.ts +179 -0
- package/src/jobs-drain.ts +151 -0
- package/src/jobs-json.ts +134 -0
- package/src/jobs-report.ts +132 -0
- package/src/jobs-table.ts +34 -0
- package/src/json-merge.ts +40 -0
- package/src/mcp-db-target.ts +50 -0
- package/src/mcp-errors.ts +99 -0
- package/src/mcp-host.ts +282 -0
- package/src/mcp-test-output.ts +57 -0
- package/src/messages.ts +119 -0
- package/src/output.ts +174 -0
- package/src/parse.ts +243 -0
- package/src/policy-facts.ts +196 -0
- package/src/policy-fixture.ts +71 -0
- package/src/registry.ts +73 -0
- package/src/scaffold-fixture.ts +69 -0
- package/src/scaffold-typecheck.ts +240 -0
- package/src/source-files.ts +38 -0
- package/src/table.ts +19 -0
- package/src/tasks-facts.ts +113 -0
- package/src/templates/action.ts +193 -0
- package/src/templates/admin.ts +46 -0
- package/src/templates/catalog-json.ts +17 -0
- package/src/templates/entity.ts +157 -0
- package/src/templates/index.ts +23 -0
- package/src/templates/job.ts +148 -0
- package/src/templates/locales.ts +93 -0
- package/src/templates/naming.ts +97 -0
- package/src/templates/policy.ts +120 -0
- package/src/templates/query.ts +116 -0
- package/src/templates/resource.ts +199 -0
- package/src/templates/route.ts +138 -0
- package/src/templates/scaffold-app.ts +320 -0
- package/src/templates/scaffold-docs.ts +156 -0
- package/src/templates/scaffold-i18n.ts +149 -0
- package/src/templates/scaffold-icon.ts +54 -0
- package/src/templates/scaffold-package-shape.ts +49 -0
- package/src/templates/scaffold-repo.ts +427 -0
- package/src/test-select.ts +130 -0
- package/src/test-shards.ts +188 -0
- package/src/thrown-by.ts +24 -0
- package/src/ts-scan.ts +217 -0
- package/src/verify-step.ts +83 -0
- package/src/verify-tests.ts +166 -0
- package/src/version-loader.ts +16 -0
- package/src/workspace-checks.ts +288 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The error contract, enforced (axiom 3: a convention that is not a build error does not exist).
|
|
2
|
+
// Axiom 4 says an error is an instruction, so a `fix:` an agent cannot act on is a defect in the
|
|
3
|
+
// error, not a matter of taste — and a code no reference page documents strands whoever hits it.
|
|
4
|
+
// Both halves are decidable before the code runs, which is why they are a gate step and not a
|
|
5
|
+
// runtime assertion nobody sees until the failure they describe has already happened.
|
|
6
|
+
|
|
7
|
+
// `join` is `node:`-only by necessity: Bun exposes no path-join primitive.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { docsFor } from './errors';
|
|
10
|
+
import type { Finding } from './output';
|
|
11
|
+
import { eachSourceFile, isGenerated, isTest } from './source-files';
|
|
12
|
+
import type { CodeSite, FixSite } from './ts-scan';
|
|
13
|
+
import { isCodeRegistry, scanBorrowedCodes, scanCodes, scanFixes } from './ts-scan';
|
|
14
|
+
|
|
15
|
+
/** Advice, not instruction. The list is the one in `docs/architecture/04-error-contract.md`. */
|
|
16
|
+
export const BANNED_PHRASES: readonly RegExp[] = [
|
|
17
|
+
/\bcheck(s|ed|ing)?\b/i,
|
|
18
|
+
/\bmake sure\b/i,
|
|
19
|
+
/\btry(ing)?\b/i,
|
|
20
|
+
/\bsee the docs?\b/i,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What makes a fix actionable as written: the `x` CLI, a tool the machine already has, a call the
|
|
25
|
+
* reader can paste, or a file they can open. A banned phrase is only a failure without one of
|
|
26
|
+
* these — "check the gateway, then: x actions describe posts.publish --json" names the observation
|
|
27
|
+
* *and* the command, which is the shape the contract wants.
|
|
28
|
+
*/
|
|
29
|
+
export const COMMAND_TOKENS: readonly RegExp[] = [
|
|
30
|
+
/(?:^|[\s;|&("'`])x\s+[a-z][a-z-]*/,
|
|
31
|
+
/\b(?:bun|bunx|npm|npx|node|git|docker|kubectl|helm|psql|curl|openssl|biome|tsc)\b/,
|
|
32
|
+
/\b[A-Za-z_$][\w$]*\(/,
|
|
33
|
+
/[\w.@-]*\/[\w.@-]+\.(?:ts|tsx|js|json|md|toml|yaml|yml|css|scss|sql)\b/,
|
|
34
|
+
/\b(?:app\.config\.ts|package\.json|tsconfig\.json|bunfig\.toml|\.env(?:\.[\w.-]+)?)\b/,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `${…}` holds a value only the throw site knows, so it is blanked before the rule runs. Without
|
|
39
|
+
* this, `check egress to ${new URL(url).host}` reads as a call expression and passes as a command
|
|
40
|
+
* — the interpolation would launder pure advice into an instruction.
|
|
41
|
+
*/
|
|
42
|
+
export const staticFix = (raw: string): string => raw.replaceAll(/\$\{[^}]*\}/g, '<value>');
|
|
43
|
+
|
|
44
|
+
/** The rule, on one fix line. `undefined` means it holds. */
|
|
45
|
+
export function fixProblem(raw: string): string | undefined {
|
|
46
|
+
const fix = staticFix(raw);
|
|
47
|
+
if (fix.trim() === '') return 'the fix line is empty';
|
|
48
|
+
const banned = BANNED_PHRASES.find((phrase) => phrase.test(fix));
|
|
49
|
+
if (banned === undefined) return undefined;
|
|
50
|
+
if (COMMAND_TOKENS.some((token) => token.test(fix))) return undefined;
|
|
51
|
+
return `fix "${fix}" says "${fix.match(banned)?.[0] ?? ''}" and names no command, call or file`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fixFinding = (site: FixSite, problem: string): Finding => ({
|
|
55
|
+
code: 'X_ERROR_FIX_INVALID',
|
|
56
|
+
cause: problem,
|
|
57
|
+
fix: `rewrite the fix at ${site.at}:${site.line} as a command to run, a call to paste, or an edit naming a file`,
|
|
58
|
+
docs: docsFor('X_ERROR_FIX_INVALID'),
|
|
59
|
+
at: `${site.at}:${site.line}`,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** Every `fix:` an agent can be handed, read out of shipped source and held to the rule. */
|
|
63
|
+
export async function checkErrorFixes(root: string): Promise<readonly Finding[]> {
|
|
64
|
+
const findings: Finding[] = [];
|
|
65
|
+
for await (const path of eachSourceFile(root)) {
|
|
66
|
+
if (isTest(path) || isGenerated(path)) continue;
|
|
67
|
+
for (const site of scanFixes(await Bun.file(join(root, path)).text(), path)) {
|
|
68
|
+
const problem = fixProblem(site.fix);
|
|
69
|
+
if (problem !== undefined) findings.push(fixFinding(site, problem));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return findings;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A code is documented when the reference page names it. Deliberately not "owns a table row": the
|
|
77
|
+
* page legitimately groups near-identical codes onto one row, and a rule that forbade that would
|
|
78
|
+
* be a rule about formatting rather than about coverage.
|
|
79
|
+
*/
|
|
80
|
+
export const documentedCodes = (markdown: string): ReadonlySet<string> =>
|
|
81
|
+
new Set([...markdown.matchAll(/`(X_[A-Z0-9_]+)`/g)].map((match) => match[1] as string));
|
|
82
|
+
|
|
83
|
+
const undocumentedFinding = (code: string, at: string, line: number, page: string): Finding => ({
|
|
84
|
+
code: 'X_ERROR_CODE_UNDOCUMENTED',
|
|
85
|
+
cause: `${code} is declared at ${at}:${line} and ${page} has no entry for it`,
|
|
86
|
+
fix: `add a row for ${code} to ${page}, with its cause and the command that fixes it`,
|
|
87
|
+
docs: docsFor('X_ERROR_CODE_UNDOCUMENTED'),
|
|
88
|
+
at: page,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The heading below which the reference stops making live claims. What follows is a code that is
|
|
93
|
+
* reserved (documented, nothing throws it yet) or a superseded name kept so an old log line still
|
|
94
|
+
* resolves. Both earn their rows; neither is a code an agent can be handed today, which is the
|
|
95
|
+
* only thing the registry rule below is about.
|
|
96
|
+
*/
|
|
97
|
+
export const RESERVED_HEADING = '## Reserved codes';
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The codes the reference presents as live: every one named above the reserved section.
|
|
101
|
+
*
|
|
102
|
+
* The heading is matched as a whole line, never as a substring. The same text is quoted in prose,
|
|
103
|
+
* in a fenced sample and in this file's own `unregisteredFinding` fix — and `indexOf` would cut the
|
|
104
|
+
* page at the first of those mentions, silently exempting every live code below it from the
|
|
105
|
+
* registry rule. A gate that stops reading halfway through the page reads green over the half it
|
|
106
|
+
* never saw.
|
|
107
|
+
*/
|
|
108
|
+
export function liveCodes(markdown: string): ReadonlySet<string> {
|
|
109
|
+
const lines = markdown.split('\n');
|
|
110
|
+
const cut = lines.findIndex((line) => line.trim() === RESERVED_HEADING);
|
|
111
|
+
return documentedCodes(cut === -1 ? markdown : lines.slice(0, cut).join('\n'));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const unregisteredFinding = (code: string, page: string): Finding => ({
|
|
115
|
+
code: 'X_ERROR_CODE_UNREGISTERED',
|
|
116
|
+
cause: `${page} documents ${code} as a live code and nothing registers it, so "x errors explain ${code}" refuses a code this page promises`,
|
|
117
|
+
fix: `register ${code} through registerErrorCodes() in its package's src/errors.ts, or move its row under "${RESERVED_HEADING}" in ${page}`,
|
|
118
|
+
docs: docsFor('X_ERROR_CODE_UNREGISTERED'),
|
|
119
|
+
at: page,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The other direction of the same contract, and the half nothing enforced: a code the reference
|
|
124
|
+
* documents that no package registers. `X_ERROR_CODE_UNDOCUMENTED` stops a shipped code losing its
|
|
125
|
+
* page; this stops a page inventing a code — a row an agent reads, acts on, and then cannot look
|
|
126
|
+
* up, because `x errors explain` answers from the registry and the registry never heard of it.
|
|
127
|
+
*
|
|
128
|
+
* `known` is what the host can answer for: the process-wide registry, plus whatever codes the host
|
|
129
|
+
* repo's own gate scripts declare (`X_ROADMAP_*` and friends never ship, so no package may own
|
|
130
|
+
* them). A missing page is `checkErrorCodeDocs`'s finding to report, not a second copy here.
|
|
131
|
+
*/
|
|
132
|
+
export async function checkErrorCodeRegistry(
|
|
133
|
+
root: string,
|
|
134
|
+
page: string,
|
|
135
|
+
known: ReadonlySet<string>,
|
|
136
|
+
): Promise<readonly Finding[]> {
|
|
137
|
+
const reference = Bun.file(join(root, page));
|
|
138
|
+
if (!(await reference.exists())) return [];
|
|
139
|
+
return [...liveCodes(await reference.text())]
|
|
140
|
+
.filter((code) => !known.has(code))
|
|
141
|
+
.sort()
|
|
142
|
+
.map((code) => unregisteredFinding(code, page));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* How strong a claim one site has on being *the* declaration of its code. A package declares the
|
|
147
|
+
* codes it owns in its own registry (`docs/architecture/04-error-contract.md`), and names the ones
|
|
148
|
+
* it borrows in that same file — so a registry that owns the code outranks a throw site, and a
|
|
149
|
+
* registry that has said the code is somebody else's ranks below both.
|
|
150
|
+
*/
|
|
151
|
+
const claim = (site: CodeSite, borrowed: ReadonlySet<string>): number => {
|
|
152
|
+
if (!isCodeRegistry(site.at)) return 1;
|
|
153
|
+
return borrowed.has(site.code) ? 0 : 2;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The stronger claim wins; equal claims settle by path, then line. Deliberately not glob order:
|
|
158
|
+
* `Bun.Glob` yields in directory order, which differs by filesystem, and a committed manifest
|
|
159
|
+
* keyed on the answer would drift between two machines reading the same tree.
|
|
160
|
+
*/
|
|
161
|
+
const declarationOf = (a: [CodeSite, number], b: [CodeSite, number]): [CodeSite, number] => {
|
|
162
|
+
if (a[1] !== b[1]) return a[1] > b[1] ? a : b;
|
|
163
|
+
if (a[0].at !== b[0].at) return a[0].at < b[0].at ? a : b;
|
|
164
|
+
return a[0].line <= b[0].line ? a : b;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Every `X_*` code shipped source declares, with the file and line that declares it — one walk of
|
|
169
|
+
* the whole source set, sorted by code, one entry per code. The answer to "which codes exist?"
|
|
170
|
+
* has exactly one implementation: the docs check below reads it, and so does the framework's
|
|
171
|
+
* generated manifest. A scanner that looked only at a package's own `src/errors.ts` would miss
|
|
172
|
+
* every code a gate script or a non-registry module throws, and two lists that disagree mean
|
|
173
|
+
* whichever one a reader trusts is the wrong one.
|
|
174
|
+
*/
|
|
175
|
+
export async function collectDeclaredCodes(root: string): Promise<readonly CodeSite[]> {
|
|
176
|
+
const sites = new Map<string, [CodeSite, number]>();
|
|
177
|
+
for await (const source of eachSourceFile(root)) {
|
|
178
|
+
if (isTest(source) || isGenerated(source)) continue;
|
|
179
|
+
const text = await Bun.file(join(root, source)).text();
|
|
180
|
+
const borrowed = scanBorrowedCodes(text);
|
|
181
|
+
for (const site of scanCodes(text, source)) {
|
|
182
|
+
const found: [CodeSite, number] = [site, claim(site, borrowed)];
|
|
183
|
+
const seen = sites.get(site.code);
|
|
184
|
+
sites.set(site.code, seen === undefined ? found : declarationOf(seen, found));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return [...sites.values()].map(([site]) => site).sort((a, b) => a.code.localeCompare(b.code));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Every `X_*` code shipped source declares must appear on the repo's error reference. The page is
|
|
192
|
+
* the host repo's to name — a framework monorepo publishes one, a generated app does not — which
|
|
193
|
+
* is why this arrives through the same host-check seam the tier table uses on `boundaries`.
|
|
194
|
+
*/
|
|
195
|
+
export async function checkErrorCodeDocs(root: string, page: string): Promise<readonly Finding[]> {
|
|
196
|
+
const reference = Bun.file(join(root, page));
|
|
197
|
+
if (!(await reference.exists())) {
|
|
198
|
+
return [
|
|
199
|
+
{
|
|
200
|
+
code: 'X_ERROR_CODE_UNDOCUMENTED',
|
|
201
|
+
cause: `the error reference ${page} does not exist, so no code can be documented`,
|
|
202
|
+
fix: `create ${page} with a row per X_* code, or stop naming it as the error reference`,
|
|
203
|
+
docs: docsFor('X_ERROR_CODE_UNDOCUMENTED'),
|
|
204
|
+
at: page,
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
const documented = documentedCodes(await reference.text());
|
|
209
|
+
return (await collectDeclaredCodes(root))
|
|
210
|
+
.filter((site) => !documented.has(site.code))
|
|
211
|
+
.map((site) => undocumentedFinding(site.code, site.at, site.line, page));
|
|
212
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// The X_* codes owned by @ultimat3/cli. Every one names the exact command that resolves it,
|
|
2
|
+
// because the CLI is the surface an agent reads first — a failure here has to be actionable
|
|
3
|
+
// without a doc lookup or a second round-trip.
|
|
4
|
+
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
5
|
+
|
|
6
|
+
/** Codes this package declares and owns. */
|
|
7
|
+
export const CLI_OWNED_ERROR_CODES = [
|
|
8
|
+
'X_CLI_UNKNOWN_COMMAND',
|
|
9
|
+
'X_CLI_BAD_FLAG',
|
|
10
|
+
'X_VERIFY_FAILED',
|
|
11
|
+
'X_NOT_IN_APP',
|
|
12
|
+
'X_BUN_VERSION',
|
|
13
|
+
'X_TEST_NO_FILES',
|
|
14
|
+
'X_TEST_SHARD_FAILED',
|
|
15
|
+
'X_SCAFFOLD_PATH_ESCAPE',
|
|
16
|
+
'X_GENERATE_JSON_INVALID',
|
|
17
|
+
'X_APP_PACKAGE_INVALID',
|
|
18
|
+
'X_ERROR_CODE_UNKNOWN',
|
|
19
|
+
'X_DECLARATION_UNKNOWN',
|
|
20
|
+
'X_JOB_UNKNOWN',
|
|
21
|
+
'X_FIX_TARGET_UNKNOWN',
|
|
22
|
+
'X_ERROR_FIX_INVALID',
|
|
23
|
+
'X_ERROR_CODE_UNDOCUMENTED',
|
|
24
|
+
'X_ERROR_CODE_UNREGISTERED',
|
|
25
|
+
// Reported as `Finding`s rather than thrown, and unregistered until now because of it — so
|
|
26
|
+
// `x errors explain X_TYPECHECK_FAILED` refused a code `x verify` had just printed. A finding
|
|
27
|
+
// carries an `X_*` code to the same reader a throw does; the registry is what makes that code
|
|
28
|
+
// explainable, unique and documented-or-fail, so a code the CLI emits is a code the CLI owns.
|
|
29
|
+
'X_CLI_UNEXPECTED',
|
|
30
|
+
'X_TYPECHECK_FAILED',
|
|
31
|
+
'X_LINT_FAILED',
|
|
32
|
+
'X_TEST_FAILED',
|
|
33
|
+
'X_FILE_TOO_LONG',
|
|
34
|
+
'X_PACKAGE_SHAPE',
|
|
35
|
+
'X_RELEASE_VERSION_SKEW',
|
|
36
|
+
'X_MANIFEST_STALE',
|
|
37
|
+
'X_BUDGET_UNMEASURED',
|
|
38
|
+
'X_BUILD_FAILED',
|
|
39
|
+
'X_DEPLOY_FAILED',
|
|
40
|
+
'X_GENERATE_CONFLICT',
|
|
41
|
+
'X_PORT_IN_USE',
|
|
42
|
+
'X_DB_GEN_FAILED',
|
|
43
|
+
'X_DB_MIGRATE_FAILED',
|
|
44
|
+
'X_DB_BRANCH_FAILED',
|
|
45
|
+
'X_DB_STUDIO_FAILED',
|
|
46
|
+
// The five app-surface boundary codes. `@ultimat3/render` owns the *rule* (`checkSurfaceBoundary`)
|
|
47
|
+
// and the CLI owns the diagnostic, because `x verify` and `x fix boundary` are the two commands
|
|
48
|
+
// that report it — see `app-boundaries.ts`, which holds the one rule-to-code table.
|
|
49
|
+
'X_BOUNDARY_SITE_TO_APP',
|
|
50
|
+
'X_BOUNDARY_SHARED_LEAF',
|
|
51
|
+
'X_BOUNDARY_APP_TO_API',
|
|
52
|
+
'X_BOUNDARY_ROUTE_TO_DB',
|
|
53
|
+
'X_BOUNDARY_SERVICE_TO_HTTP',
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s — `CliNotImplementedError` and every planned command
|
|
58
|
+
* throw it, and none of them may declare a title for it. The CLI is the process that imports every
|
|
59
|
+
* package (`error-catalog.ts`), so a title declared twice here is the one that would win by load
|
|
60
|
+
* order rather than by ownership.
|
|
61
|
+
*/
|
|
62
|
+
export const CLI_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
|
|
63
|
+
|
|
64
|
+
/** Every code the CLI can throw: the ones it owns plus the one it borrows. */
|
|
65
|
+
export const CLI_ERROR_CODES = [...CLI_OWNED_ERROR_CODES, ...CLI_BORROWED_ERROR_CODES] as const;
|
|
66
|
+
|
|
67
|
+
export type CliOwnedErrorCode = (typeof CLI_OWNED_ERROR_CODES)[number];
|
|
68
|
+
export type CliErrorCode = (typeof CLI_ERROR_CODES)[number];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Registered titles, so `x errors list` enumerates the CLI's codes alongside every other
|
|
72
|
+
* package's instead of leaving a hole an agent has to read source to fill. Typed over
|
|
73
|
+
* `CliOwnedErrorCode`, so adding a code without a title is a build error.
|
|
74
|
+
*/
|
|
75
|
+
export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
76
|
+
X_CLI_UNKNOWN_COMMAND: 'not a command in the registry',
|
|
77
|
+
X_CLI_BAD_FLAG: 'unknown flag, missing value, or a value the command refuses',
|
|
78
|
+
X_VERIFY_FAILED: 'at least one x verify step failed',
|
|
79
|
+
X_NOT_IN_APP: 'the command needs an app root and found none',
|
|
80
|
+
X_BUN_VERSION: 'Bun is older than the framework floor',
|
|
81
|
+
X_TEST_NO_FILES: 'the test selection matched no files',
|
|
82
|
+
X_TEST_SHARD_FAILED: 'a test shard exited non-zero',
|
|
83
|
+
X_SCAFFOLD_PATH_ESCAPE: 'a generated path resolves outside the directory it is written into',
|
|
84
|
+
X_GENERATE_JSON_INVALID: "a generator's own merge: 'json' output does not parse as a JSON object",
|
|
85
|
+
X_APP_PACKAGE_INVALID: "the app's package.json supplies no name and version",
|
|
86
|
+
X_ERROR_CODE_UNKNOWN: 'no package registered this error code',
|
|
87
|
+
X_DECLARATION_UNKNOWN: 'no declaration with this name is registered',
|
|
88
|
+
X_JOB_UNKNOWN: 'the queue holds no job with this id',
|
|
89
|
+
X_FIX_TARGET_UNKNOWN: 'the named file is not one of the app source files',
|
|
90
|
+
X_ERROR_FIX_INVALID: "an error's fix line is not a runnable instruction",
|
|
91
|
+
X_ERROR_CODE_UNDOCUMENTED: 'a shipped error code has no row in the error reference',
|
|
92
|
+
X_ERROR_CODE_UNREGISTERED: 'the error reference documents a code no package registers',
|
|
93
|
+
X_CLI_UNEXPECTED: 'the CLI itself failed',
|
|
94
|
+
X_TYPECHECK_FAILED: 'tsc failed',
|
|
95
|
+
X_LINT_FAILED: 'Biome failed',
|
|
96
|
+
X_TEST_FAILED: 'a test type failed',
|
|
97
|
+
X_FILE_TOO_LONG: 'a source file is over 500 lines',
|
|
98
|
+
X_PACKAGE_SHAPE: 'a workspace package is missing a contract file',
|
|
99
|
+
X_RELEASE_VERSION_SKEW: 'a workspace is not at the lockstep version',
|
|
100
|
+
X_MANIFEST_STALE: 'openapi.json is stale',
|
|
101
|
+
X_BUDGET_UNMEASURED: 'a route declares a budget the build never measured',
|
|
102
|
+
X_BUILD_FAILED: 'x build failed',
|
|
103
|
+
X_DEPLOY_FAILED: 'a deploy step failed',
|
|
104
|
+
X_GENERATE_CONFLICT: 'a generator would overwrite a file',
|
|
105
|
+
X_PORT_IN_USE: 'the dev port is taken',
|
|
106
|
+
X_DB_GEN_FAILED: 'x db gen failed',
|
|
107
|
+
X_DB_MIGRATE_FAILED: 'x db migrate failed',
|
|
108
|
+
X_DB_BRANCH_FAILED: 'an x db branch step failed',
|
|
109
|
+
X_DB_STUDIO_FAILED: 'x db studio failed',
|
|
110
|
+
X_BOUNDARY_SITE_TO_APP: 'site/ imported app/',
|
|
111
|
+
X_BOUNDARY_SHARED_LEAF: 'shared/ imported a surface',
|
|
112
|
+
X_BOUNDARY_APP_TO_API: 'app/ imported api/ at runtime',
|
|
113
|
+
X_BOUNDARY_ROUTE_TO_DB: 'a route touched the database',
|
|
114
|
+
X_BOUNDARY_SERVICE_TO_HTTP: 'a service imported HTTP',
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
|
118
|
+
// X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
|
|
119
|
+
registerErrorCodes(
|
|
120
|
+
Object.fromEntries(Object.entries(CLI_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
export const docsFor = (code: CliErrorCode): string => `https://ultimate.dev/errors/${code}`;
|
|
124
|
+
|
|
125
|
+
/** An unknown command or subcommand. Carries a suggestion so the retry is one keystroke away. */
|
|
126
|
+
export class UnknownCommandError extends UltimateError {
|
|
127
|
+
constructor(input: { path: string; known: readonly string[]; suggestion?: string }) {
|
|
128
|
+
super({
|
|
129
|
+
code: 'X_CLI_UNKNOWN_COMMAND',
|
|
130
|
+
cause: `"x ${input.path}" is not a command (known: ${input.known.join(', ')})`,
|
|
131
|
+
fix: input.suggestion === undefined ? 'x help' : `x ${input.suggestion}`,
|
|
132
|
+
docs: docsFor('X_CLI_UNKNOWN_COMMAND'),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* An unknown flag, a missing value, a value on a boolean flag, or a value the command refuses.
|
|
139
|
+
* `fix` defaults to the command's help; a caller that knows the working invocation passes it,
|
|
140
|
+
* because a runnable command beats a page to read.
|
|
141
|
+
*/
|
|
142
|
+
export class BadFlagError extends UltimateError {
|
|
143
|
+
constructor(input: { flag: string; command: string; reason: string; fix?: string }) {
|
|
144
|
+
super({
|
|
145
|
+
code: 'X_CLI_BAD_FLAG',
|
|
146
|
+
cause: `--${input.flag} on "x ${input.command}": ${input.reason}`,
|
|
147
|
+
fix: input.fix ?? `x ${input.command} --help`,
|
|
148
|
+
docs: docsFor('X_CLI_BAD_FLAG'),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** At least one `x verify` step failed. The step findings carry the per-step fixes. */
|
|
154
|
+
export class VerifyFailedError extends UltimateError {
|
|
155
|
+
constructor(input: { failed: readonly string[] }) {
|
|
156
|
+
super({
|
|
157
|
+
code: 'X_VERIFY_FAILED',
|
|
158
|
+
cause: `${input.failed.length} verify step(s) failed: ${input.failed.join(', ')}`,
|
|
159
|
+
fix: 'x verify --json',
|
|
160
|
+
docs: docsFor('X_VERIFY_FAILED'),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The command needs an app root (a directory containing `app.config.ts`) and found none. */
|
|
166
|
+
export class NotInAppError extends UltimateError {
|
|
167
|
+
constructor(input: { command: string; from: string }) {
|
|
168
|
+
super({
|
|
169
|
+
code: 'X_NOT_IN_APP',
|
|
170
|
+
cause: `"x ${input.command}" must run inside an Ultimate app; no app.config.ts at or above ${input.from}`,
|
|
171
|
+
fix: 'x new myapp && cd myapp',
|
|
172
|
+
docs: docsFor('X_NOT_IN_APP'),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Bun is older than the framework floor. Nothing else can be trusted until this is fixed. */
|
|
178
|
+
export class BunVersionError extends UltimateError {
|
|
179
|
+
constructor(input: { found: string; required: string }) {
|
|
180
|
+
super({
|
|
181
|
+
code: 'X_BUN_VERSION',
|
|
182
|
+
cause: `Bun ${input.found} is older than the required ${input.required}`,
|
|
183
|
+
fix: 'bun upgrade',
|
|
184
|
+
docs: docsFor('X_BUN_VERSION'),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `x test` discovered nothing. A green run over zero files is the most expensive false pass, so
|
|
191
|
+
* the selection that found nothing is named in full — a caller that cannot see whether the type
|
|
192
|
+
* or the filter emptied the set has to guess which one to drop.
|
|
193
|
+
*/
|
|
194
|
+
export class NoTestFilesError extends UltimateError {
|
|
195
|
+
constructor(input: { root: string; type?: string; filter?: string }) {
|
|
196
|
+
const parts = [
|
|
197
|
+
input.type === undefined ? undefined : `of type ${input.type}`,
|
|
198
|
+
input.filter === undefined ? undefined : `matching "${input.filter}"`,
|
|
199
|
+
].filter((part): part is string => part !== undefined);
|
|
200
|
+
const where = parts.length === 0 ? '' : ` ${parts.join(' ')}`;
|
|
201
|
+
super({
|
|
202
|
+
code: 'X_TEST_NO_FILES',
|
|
203
|
+
cause: `no *.test.ts files${where} under ${input.root}`,
|
|
204
|
+
fix: parts.length === 0 ? 'x test --cwd <repo root>' : 'x test',
|
|
205
|
+
docs: docsFor('X_TEST_NO_FILES'),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A generated path resolves outside the directory it is being written into — the scaffold gate's
|
|
212
|
+
* sandbox, the app root `x g` writes into, or the catalog root a `--locales` segment names. `..`
|
|
213
|
+
* or a separator would put template output on the developer's real disk, so it fails before the
|
|
214
|
+
* write, not after. `fix` names the invocation that works when the caller knows it.
|
|
215
|
+
*/
|
|
216
|
+
export class ScaffoldPathEscapeError extends UltimateError {
|
|
217
|
+
constructor(input: { path: string; dir: string; fix?: string }) {
|
|
218
|
+
super({
|
|
219
|
+
code: 'X_SCAFFOLD_PATH_ESCAPE',
|
|
220
|
+
cause: `generated path "${input.path}" resolves outside ${input.dir}`,
|
|
221
|
+
fix:
|
|
222
|
+
input.fix ??
|
|
223
|
+
`make the path relative to the app root with no ".." segment, then re-run: bun test packages/cli/src/scaffold-typecheck.contract.test.ts`,
|
|
224
|
+
docs: docsFor('X_SCAFFOLD_PATH_ESCAPE'),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* A `merge: 'json'` `GeneratedFile` whose own `contents` do not parse as a JSON object — a bug in
|
|
231
|
+
* the template that produced it, not a recoverable end-user situation. `dedupe()` (`cmd-generate.ts`)
|
|
232
|
+
* throws this before the bad contributor can be silently treated as `{}` and merged into (or
|
|
233
|
+
* written as) a catalog with attribution to nobody.
|
|
234
|
+
*/
|
|
235
|
+
export class GenerateJsonInvalidError extends UltimateError {
|
|
236
|
+
constructor(input: { path: string }) {
|
|
237
|
+
super({
|
|
238
|
+
code: 'X_GENERATE_JSON_INVALID',
|
|
239
|
+
cause: `${input.path} is declared merge: 'json' but the generator's own contents for it do not parse as a JSON object`,
|
|
240
|
+
fix: `fix the template that emits ${input.path}, then re-run: bun test packages/cli/src/cmd-generate.test.ts`,
|
|
241
|
+
docs: docsFor('X_GENERATE_JSON_INVALID'),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* `x i18n add <locale>` refuses to clobber a catalog that already exists — a human translation lost
|
|
248
|
+
* to a second run is unrecoverable. `X_GENERATE_CONFLICT` is this package's own code, used until now
|
|
249
|
+
* only as a `Finding` literal inside `cmd-generate.ts`'s `writeFiles`; this is the same registered
|
|
250
|
+
* code thrown as a real `UltimateError`. The path arrives already computed rather than derived from
|
|
251
|
+
* `catalogPath` here: `templates/locales.ts` imports this file, so calling back into it would close
|
|
252
|
+
* an import cycle.
|
|
253
|
+
*/
|
|
254
|
+
export class CatalogExistsError extends UltimateError {
|
|
255
|
+
constructor(input: { locale: string; path: string }) {
|
|
256
|
+
super({
|
|
257
|
+
code: 'X_GENERATE_CONFLICT',
|
|
258
|
+
cause: `${input.path} already exists`,
|
|
259
|
+
fix: `x i18n sync ${input.locale}`,
|
|
260
|
+
docs: docsFor('X_GENERATE_CONFLICT'),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The app's `package.json` cannot supply a name and a version. Defaulting to `app@0.0.0` would put
|
|
267
|
+
* a fabricated identity into `x.manifest.json`, whose version IS the semver compatibility gate —
|
|
268
|
+
* so the contract would be overwritten with a lie no downstream check could catch.
|
|
269
|
+
*/
|
|
270
|
+
export class AppPackageInvalidError extends UltimateError {
|
|
271
|
+
constructor(input: { path: string; problem: string }) {
|
|
272
|
+
super({
|
|
273
|
+
code: 'X_APP_PACKAGE_INVALID',
|
|
274
|
+
cause: `${input.path} ${input.problem}, so the manifest has no app name or version to gate on`,
|
|
275
|
+
fix: 'bun pm pkg set name=<app> version=0.1.0',
|
|
276
|
+
docs: docsFor('X_APP_PACKAGE_INVALID'),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* `x errors explain` was handed a code no package registered. Inventing an explanation is the one
|
|
283
|
+
* answer worse than none: an agent would act on it. The suggestion makes the retry one keystroke.
|
|
284
|
+
*/
|
|
285
|
+
export class ErrorCodeUnknownError extends UltimateError {
|
|
286
|
+
constructor(input: { code: string; suggestion?: string }) {
|
|
287
|
+
super({
|
|
288
|
+
code: 'X_ERROR_CODE_UNKNOWN',
|
|
289
|
+
cause: `"${input.code}" is not a registered error code`,
|
|
290
|
+
fix:
|
|
291
|
+
input.suggestion === undefined
|
|
292
|
+
? 'x errors list --json'
|
|
293
|
+
: `x errors explain ${input.suggestion}`,
|
|
294
|
+
docs: docsFor('X_ERROR_CODE_UNKNOWN'),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* `x actions|queries|entities describe <name>` named a declaration the registries do not hold —
|
|
301
|
+
* a typo, or a module that never imported. `known` is the count, not the list: a 200-action app
|
|
302
|
+
* would bury the fix line under names nobody asked for, and `list` is one command away.
|
|
303
|
+
*/
|
|
304
|
+
export class DeclarationUnknownError extends UltimateError {
|
|
305
|
+
constructor(input: {
|
|
306
|
+
kind: string;
|
|
307
|
+
singular: string;
|
|
308
|
+
name: string;
|
|
309
|
+
known: readonly string[];
|
|
310
|
+
suggestion?: string;
|
|
311
|
+
/** The subcommand that takes one name. `describe` for the registries, `show` for `x tasks`. */
|
|
312
|
+
verb?: string;
|
|
313
|
+
}) {
|
|
314
|
+
super({
|
|
315
|
+
code: 'X_DECLARATION_UNKNOWN',
|
|
316
|
+
cause: `no ${input.singular} named "${input.name}" is registered (${input.known.length} known)`,
|
|
317
|
+
fix:
|
|
318
|
+
input.suggestion === undefined
|
|
319
|
+
? `x ${input.kind} list --json`
|
|
320
|
+
: `x ${input.kind} ${input.verb ?? 'describe'} ${input.suggestion}`,
|
|
321
|
+
docs: docsFor('X_DECLARATION_UNKNOWN'),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** `x jobs show|retry <id>` against an id the queue does not hold — wrong id, or already reaped. */
|
|
327
|
+
export class JobUnknownError extends UltimateError {
|
|
328
|
+
constructor(input: { id: string; driver: string }) {
|
|
329
|
+
super({
|
|
330
|
+
code: 'X_JOB_UNKNOWN',
|
|
331
|
+
cause: `the "${input.driver}" queue holds no job with id "${input.id}"`,
|
|
332
|
+
fix: 'x jobs ls --json',
|
|
333
|
+
docs: docsFor('X_JOB_UNKNOWN'),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* `x fix boundary <file>` was pointed at something outside the app's surface graph. `suggestion`
|
|
340
|
+
* is the nearest real path: repeating the caller's own failing argument back at them as the fix
|
|
341
|
+
* is the shape "errors are instructions" exists to ban.
|
|
342
|
+
*/
|
|
343
|
+
export class FixTargetUnknownError extends UltimateError {
|
|
344
|
+
constructor(input: { file: string; scanned: number; suggestion?: string }) {
|
|
345
|
+
super({
|
|
346
|
+
code: 'X_FIX_TARGET_UNKNOWN',
|
|
347
|
+
cause: `"${input.file}" is not one of the ${input.scanned} source file(s) under apps/*/{site,app,api,shared}`,
|
|
348
|
+
fix:
|
|
349
|
+
input.suggestion === undefined
|
|
350
|
+
? 'x routes --json # every registered route file, app-root-relative'
|
|
351
|
+
: `x fix boundary ${input.suggestion}`,
|
|
352
|
+
docs: docsFor('X_FIX_TARGET_UNKNOWN'),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** An interface-complete command path whose remote/native half is not written yet. */
|
|
358
|
+
export class CliNotImplementedError extends UltimateError {
|
|
359
|
+
constructor(input: { feature: string; fix: string }) {
|
|
360
|
+
super({
|
|
361
|
+
code: 'X_NOT_IMPLEMENTED',
|
|
362
|
+
cause: `${input.feature} is not implemented in this build`,
|
|
363
|
+
fix: input.fix,
|
|
364
|
+
docs: docsFor('X_NOT_IMPLEMENTED'),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
package/src/exec.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The single subprocess boundary for the CLI. Every shell-out goes through `exec` so timing,
|
|
2
|
+
// output capture and the "command not found" failure mode are identical everywhere, and so a
|
|
3
|
+
// test can substitute a fake runner instead of spawning anything.
|
|
4
|
+
|
|
5
|
+
// `UltimateError` straight from core rather than a class in `./errors`: this module is imported by
|
|
6
|
+
// every command, and `./errors` runs `registerErrorCodes` on import — a subprocess boundary must
|
|
7
|
+
// not decide when the CLI's registry is populated. `X_CLI_UNEXPECTED` is owned there all the same.
|
|
8
|
+
import { UltimateError } from '@ultimat3/core';
|
|
9
|
+
|
|
10
|
+
export interface ExecResult {
|
|
11
|
+
readonly command: readonly string[];
|
|
12
|
+
readonly code: number;
|
|
13
|
+
readonly ok: boolean;
|
|
14
|
+
readonly stdout: string;
|
|
15
|
+
readonly stderr: string;
|
|
16
|
+
readonly durationMs: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ExecOptions {
|
|
20
|
+
readonly cwd: string;
|
|
21
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
22
|
+
readonly stdin?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type Runner = (command: readonly string[], options: ExecOptions) => Promise<ExecResult>;
|
|
26
|
+
|
|
27
|
+
/** performance.now(), not Date.now(): the test preload freezes the wall clock on purpose. */
|
|
28
|
+
const now = (): number => performance.now();
|
|
29
|
+
|
|
30
|
+
export const exec: Runner = async (command, options) => {
|
|
31
|
+
const started = now();
|
|
32
|
+
const [head, ...rest] = command;
|
|
33
|
+
// A caller bug, never a user's: an empty argv reaches `Bun.spawn` as "spawn nothing" and there is
|
|
34
|
+
// no shell-out to report on. Coded like every other CLI failure, because a bare Error here would
|
|
35
|
+
// surface as an unexplained crash from the one boundary every command goes through.
|
|
36
|
+
if (head === undefined) {
|
|
37
|
+
throw new UltimateError({
|
|
38
|
+
code: 'X_CLI_UNEXPECTED',
|
|
39
|
+
cause: 'exec() was called with an empty command, so there is no program to spawn',
|
|
40
|
+
fix: 'pass the program as the first element: exec(["bun", "test"], { cwd })',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const proc = Bun.spawn([head, ...rest], {
|
|
44
|
+
cwd: options.cwd,
|
|
45
|
+
env: options.env === undefined ? Bun.env : { ...Bun.env, ...options.env },
|
|
46
|
+
stdin: options.stdin === undefined ? 'ignore' : new TextEncoder().encode(options.stdin),
|
|
47
|
+
stdout: 'pipe',
|
|
48
|
+
stderr: 'pipe',
|
|
49
|
+
});
|
|
50
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
51
|
+
new Response(proc.stdout).text(),
|
|
52
|
+
new Response(proc.stderr).text(),
|
|
53
|
+
proc.exited,
|
|
54
|
+
]);
|
|
55
|
+
return {
|
|
56
|
+
command,
|
|
57
|
+
code,
|
|
58
|
+
ok: code === 0,
|
|
59
|
+
stdout,
|
|
60
|
+
stderr,
|
|
61
|
+
durationMs: Math.round(now() - started),
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Merged stream, trimmed — what a human wants to read under a failed step. */
|
|
66
|
+
export const execOutput = (result: ExecResult): string =>
|
|
67
|
+
[result.stdout, result.stderr]
|
|
68
|
+
.filter((part) => part.trim().length > 0)
|
|
69
|
+
.join('\n')
|
|
70
|
+
.trimEnd();
|