@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.
- package/CLAUDE.md +109 -13
- package/README.md +1 -0
- package/package.json +24 -24
- package/src/budgets.ts +31 -8
- package/src/cmd-db-branch.ts +6 -2
- package/src/cmd-db.ts +138 -10
- package/src/cmd-deploy.ts +42 -14
- package/src/cmd-dev.ts +9 -2
- package/src/cmd-docs.ts +7 -3
- package/src/cmd-doctor.ts +16 -7
- package/src/cmd-fix.ts +15 -3
- package/src/cmd-generate.ts +29 -4
- package/src/cmd-help.ts +25 -4
- package/src/cmd-i18n.ts +8 -5
- package/src/cmd-jobs.ts +6 -5
- package/src/cmd-mcp.ts +16 -12
- package/src/cmd-new.ts +10 -14
- package/src/cmd-planned.ts +13 -0
- package/src/cmd-policy.ts +8 -6
- package/src/cmd-registries.ts +7 -6
- package/src/cmd-routes.ts +27 -4
- package/src/cmd-secrets.ts +6 -6
- package/src/cmd-test.ts +14 -3
- package/src/cmd-verify.ts +80 -10
- package/src/command.ts +10 -2
- package/src/db-branch.ts +18 -0
- package/src/db-generate.ts +38 -6
- package/src/db-seed.ts +294 -0
- package/src/dev-assets.ts +22 -3
- package/src/dev-cache.ts +9 -9
- package/src/dev-render.ts +6 -1
- package/src/dev-roles.ts +5 -3
- package/src/dev-runtime.ts +2 -2
- package/src/dev-storage.ts +6 -4
- package/src/dev-traces.ts +26 -4
- package/src/dispatch.ts +33 -4
- package/src/drift.ts +41 -1
- package/src/error-catalog.ts +1 -0
- package/src/error-codes.ts +11 -0
- package/src/error-contract.ts +31 -4
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +9 -2
- package/src/fix-imports.ts +118 -0
- package/src/fix-scan.ts +251 -0
- package/src/flag-number.ts +11 -0
- package/src/flag-reads.ts +114 -0
- package/src/i18n-audit.ts +2 -1
- package/src/index.ts +19 -5
- package/src/jobs-drain.ts +6 -1
- package/src/mcp-errors.ts +13 -0
- package/src/mcp-host.ts +4 -2
- package/src/messages.ts +15 -0
- package/src/metrics-endpoint.ts +60 -13
- package/src/otlp-export.ts +14 -0
- package/src/parse.ts +6 -1
- package/src/seo-meta.ts +105 -0
- package/src/serve.ts +15 -3
- package/src/shell-quote.ts +15 -0
- package/src/templates/action.ts +39 -7
- package/src/templates/backfill.ts +3 -1
- package/src/templates/index.ts +10 -1
- package/src/templates/job.ts +6 -2
- package/src/templates/query.ts +6 -1
- package/src/templates/route.ts +18 -9
- package/src/templates/scaffold-api.ts +100 -0
- package/src/templates/scaffold-app.ts +8 -48
- package/src/templates/scaffold-container.ts +44 -9
- package/src/templates/scaffold-helm-templates.ts +327 -0
- package/src/templates/scaffold-helm.ts +144 -0
- package/src/templates/scaffold-repo.ts +25 -8
- package/src/test-shards.ts +1 -10
- package/src/test-workers.ts +4 -1
- package/src/ts-scan.ts +25 -176
- package/src/tsconfig-references.ts +27 -2
- package/src/verify-step.ts +5 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// A flag a command declares and nothing reads. The parser accepts every declared flag, so a flag
|
|
2
|
+
// with no reader is not a parse error and not a type error — it is a promise in the help text with
|
|
3
|
+
// no code behind it, and only a rule over the two halves together can see that.
|
|
4
|
+
//
|
|
5
|
+
// The bound of this rule, stated where it is enforced: it sees the flag NAME reaching a reader,
|
|
6
|
+
// not the value reaching an effect. `x deploy --critical` satisfied it by being written into the
|
|
7
|
+
// plan JSON, where nothing read the field; that flag is deleted rather than wired, and a second
|
|
8
|
+
// one of its shape would pass here too.
|
|
9
|
+
|
|
10
|
+
// `join`/`relative` are `node:`-only by necessity: Bun exposes no path-join primitive.
|
|
11
|
+
import { join, relative } from 'node:path';
|
|
12
|
+
import { docsFor } from './error-codes';
|
|
13
|
+
import type { Finding } from './output';
|
|
14
|
+
import type { CommandSpec, FlagSpec } from './parse';
|
|
15
|
+
import { GLOBAL_FLAGS } from './parse';
|
|
16
|
+
import { stripComments } from './ts-scan';
|
|
17
|
+
|
|
18
|
+
/** A flag as declared, with the command that declares it. */
|
|
19
|
+
export interface DeclaredFlag {
|
|
20
|
+
readonly command: string;
|
|
21
|
+
readonly flag: FlagSpec;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every flag a command declares. The global four are excluded: `--json`, `--help`, `--cwd` and
|
|
26
|
+
* `--verbose` are the parser's and the dispatcher's, read once for every command rather than by
|
|
27
|
+
* the command that lists them, and a per-command rule would report all 30 of them as unread.
|
|
28
|
+
*/
|
|
29
|
+
export function declaredFlags(specs: readonly CommandSpec[]): readonly DeclaredFlag[] {
|
|
30
|
+
const global = new Set(GLOBAL_FLAGS.map((flag) => flag.name));
|
|
31
|
+
return specs.flatMap((spec) =>
|
|
32
|
+
(spec.flags ?? [])
|
|
33
|
+
.filter((flag) => !global.has(flag.name))
|
|
34
|
+
.map((flag) => ({ command: spec.name, flag })),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A flag name is `[a-z][a-z-]*`, so nothing in it is a regex metacharacter to escape. */
|
|
39
|
+
const literalOf = (name: string): RegExp => new RegExp(`(['"\`])${name}\\1`, 'g');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The declaration itself, which is never a read. `{ name: 'critical', … }` and its `short:` twin
|
|
43
|
+
* are the two places the name appears as a spec field; every other occurrence of the bare literal
|
|
44
|
+
* is a reader — `flagBool(ctx.args, 'critical')`, a table key, a constant the reader indexes with.
|
|
45
|
+
*/
|
|
46
|
+
const declarationOf = (name: string): RegExp =>
|
|
47
|
+
new RegExp(`(?:name|short)\\s*:\\s*(['"\`])${name}\\1`, 'g');
|
|
48
|
+
|
|
49
|
+
const countIn = (text: string, pattern: RegExp): number => [...text.matchAll(pattern)].length;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether this file reads the flag, as against merely declaring it. Deliberately generous: a flag
|
|
53
|
+
* consumed only by being echoed into `--json`, or read through a shared constant rather than by
|
|
54
|
+
* name at the call site, is still read — the rule exists to catch a flag NOTHING mentions, and a
|
|
55
|
+
* gate that guessed at intent would report findings about working commands.
|
|
56
|
+
*/
|
|
57
|
+
export const readsFlag = (text: string, name: string): boolean =>
|
|
58
|
+
countIn(text, literalOf(name)) > countIn(text, declarationOf(name));
|
|
59
|
+
|
|
60
|
+
const declaresFlag = (text: string, name: string): boolean =>
|
|
61
|
+
countIn(text, declarationOf(name)) > 0;
|
|
62
|
+
|
|
63
|
+
const unreadFinding = (declared: DeclaredFlag, at: string): Finding => ({
|
|
64
|
+
code: 'X_CLI_FLAG_UNREAD',
|
|
65
|
+
cause: `x ${declared.command} declares --${declared.flag.name} ("${declared.flag.summary}") and no file in the CLI's source reads it, so the flag parses and changes nothing`,
|
|
66
|
+
fix: `read it in ${at} with flag${declared.flag.type === 'boolean' ? 'Bool' : 'String'}(ctx.args, '${declared.flag.name}'), or delete it from the spec's flags`,
|
|
67
|
+
docs: docsFor('X_CLI_FLAG_UNREAD'),
|
|
68
|
+
at,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every declared flag held to one rule: something reads it.
|
|
73
|
+
*
|
|
74
|
+
* Scans source rather than the runtime, because "is this value ever consumed?" is not a question
|
|
75
|
+
* a `run` can be asked without running it — and running every command is not a check, it is the
|
|
76
|
+
* program. Comments are stripped first: a flag named only in the prose above the spec is not read,
|
|
77
|
+
* and a scanner that counted it would pass exactly the flags most likely to be dead.
|
|
78
|
+
*/
|
|
79
|
+
export async function checkFlagReads(
|
|
80
|
+
specs: readonly CommandSpec[],
|
|
81
|
+
srcDir: string,
|
|
82
|
+
): Promise<readonly Finding[]> {
|
|
83
|
+
const paths: string[] = [];
|
|
84
|
+
try {
|
|
85
|
+
for await (const path of new Bun.Glob('**/*.ts').scan({ cwd: srcDir, absolute: false })) {
|
|
86
|
+
if (!/\.test\.tsx?$/.test(path)) paths.push(path);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// The directory is not there. `Bun.Glob.scan` raises rather than yielding nothing, so the
|
|
90
|
+
// absent case has to be caught here — see the `texts.size` guard below for why it answers [].
|
|
91
|
+
// The scan is ALL that is inside the `try`, deliberately: a file the scan found and this
|
|
92
|
+
// cannot read must propagate, or an unreadable source answers "no findings" and the rule
|
|
93
|
+
// reports green over the half it could not see.
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const texts = new Map<string, string>();
|
|
97
|
+
for (const path of paths) {
|
|
98
|
+
texts.set(path, stripComments(await Bun.file(join(srcDir, path)).text()));
|
|
99
|
+
}
|
|
100
|
+
// No CLI source under this root: the rule holds two halves against each other and only one is
|
|
101
|
+
// here, so there is nothing it can decide. Derived, not "is this the framework repo" — the same
|
|
102
|
+
// condition `scripts/release-workflow.ts` uses for a tree with no publishable workspace. Scanning
|
|
103
|
+
// on would report EVERY declared flag as unread, which is the false-positive direction and the
|
|
104
|
+
// one that trains a reader to ignore the check.
|
|
105
|
+
if (texts.size === 0) return [];
|
|
106
|
+
const findings: Finding[] = [];
|
|
107
|
+
for (const declared of declaredFlags(specs)) {
|
|
108
|
+
const name = declared.flag.name;
|
|
109
|
+
if ([...texts.values()].some((text) => readsFlag(text, name))) continue;
|
|
110
|
+
const declaringFile = [...texts].find(([, text]) => declaresFlag(text, name))?.[0];
|
|
111
|
+
findings.push(unreadFinding(declared, join(relative('', srcDir), declaringFile ?? '')));
|
|
112
|
+
}
|
|
113
|
+
return findings;
|
|
114
|
+
}
|
package/src/i18n-audit.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// root-relative POSIX shape every CLI-reported path is keyed by.
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
10
|
import { join, relative, sep } from 'node:path';
|
|
11
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
11
12
|
import type { Catalog, Extraction, ExtractReport, Locale } from '@ultimat3/i18n';
|
|
12
13
|
import {
|
|
13
14
|
auditCatalogs,
|
|
@@ -51,7 +52,7 @@ function parseCatalogJson(path: string, raw: string): unknown {
|
|
|
51
52
|
try {
|
|
52
53
|
return JSON.parse(raw);
|
|
53
54
|
} catch (error) {
|
|
54
|
-
throw catalogInvalid(path,
|
|
55
|
+
throw catalogInvalid(path, renderThrowable(error));
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
|
package/src/index.ts
CHANGED
|
@@ -75,7 +75,7 @@ export {
|
|
|
75
75
|
pgliteBranchName,
|
|
76
76
|
previewUrl,
|
|
77
77
|
} from './db-branch';
|
|
78
|
-
export type { GeneratedFiles, GenerateMigrationOptions } from './db-generate';
|
|
78
|
+
export type { GeneratedFiles, GenerateMigrationOptions, GenerateOutcome } from './db-generate';
|
|
79
79
|
export { generateAppMigration, migrationSql } from './db-generate';
|
|
80
80
|
export type { AssetRoutesOptions } from './dev-assets';
|
|
81
81
|
export {
|
|
@@ -99,8 +99,14 @@ export type { DevServices, ServiceBinding } from './dev-services';
|
|
|
99
99
|
export { describeServices, resolveServices } from './dev-services';
|
|
100
100
|
export type { DispatchOptions } from './dispatch';
|
|
101
101
|
export { dispatch } from './dispatch';
|
|
102
|
-
export type { DeclaredEntityCount } from './drift';
|
|
103
|
-
export {
|
|
102
|
+
export type { DeclaredEntityCount, HashReconciliation } from './drift';
|
|
103
|
+
export {
|
|
104
|
+
checkSourceDrift,
|
|
105
|
+
reconcileSchemaHash,
|
|
106
|
+
recordedHashes,
|
|
107
|
+
schemaHash,
|
|
108
|
+
writeSchemaHash,
|
|
109
|
+
} from './drift';
|
|
104
110
|
export type { ErrorCatalog } from './error-catalog';
|
|
105
111
|
export {
|
|
106
112
|
buildErrorCatalog,
|
|
@@ -111,12 +117,14 @@ export {
|
|
|
111
117
|
} from './error-catalog';
|
|
112
118
|
export type { CliErrorCode } from './error-codes';
|
|
113
119
|
export { CLI_ERROR_CODES, CLI_ERROR_TITLES } from './error-codes';
|
|
120
|
+
export type { ErrorFixReport } from './error-contract';
|
|
114
121
|
export {
|
|
115
122
|
BANNED_PHRASES,
|
|
116
123
|
COMMAND_TOKENS,
|
|
117
124
|
checkErrorCodeDocs,
|
|
118
125
|
checkErrorCodeRegistry,
|
|
119
126
|
checkErrorFixes,
|
|
127
|
+
checkErrorFixReport,
|
|
120
128
|
collectDeclaredCodes,
|
|
121
129
|
documentedCodes,
|
|
122
130
|
fixProblem,
|
|
@@ -161,6 +169,12 @@ export {
|
|
|
161
169
|
fixCitations,
|
|
162
170
|
loadCommandCatalog,
|
|
163
171
|
} from './fix-command';
|
|
172
|
+
export type { HelperResolver } from './fix-imports';
|
|
173
|
+
export { candidatePaths, createHelperResolver, scanImports } from './fix-imports';
|
|
174
|
+
export type { FixHelper, FixScan } from './fix-scan';
|
|
175
|
+
export { scanFixes, scanFixHelpers, scanFixSites } from './fix-scan';
|
|
176
|
+
export type { DeclaredFlag } from './flag-reads';
|
|
177
|
+
export { checkFlagReads, declaredFlags, readsFlag } from './flag-reads';
|
|
164
178
|
export type { Guard } from './guards';
|
|
165
179
|
export { findingProblem, GUARD_DIR, guardFindings, guardPaths } from './guards';
|
|
166
180
|
export type { DrainFailure, DrainOutcome, DrainSkip } from './jobs-drain';
|
|
@@ -213,6 +227,7 @@ export {
|
|
|
213
227
|
runRole,
|
|
214
228
|
serveApp,
|
|
215
229
|
} from './serve';
|
|
230
|
+
export { quoteArg } from './shell-quote';
|
|
216
231
|
export {
|
|
217
232
|
eachSourceFile,
|
|
218
233
|
isGenerated,
|
|
@@ -225,7 +240,7 @@ export { countsOf } from './test-counts';
|
|
|
225
240
|
export type { TestFile } from './test-select';
|
|
226
241
|
export { belongsToType, discoverTests, sampleFiles } from './test-select';
|
|
227
242
|
export type { ReproduceOptions, RunShardsOptions, Shard } from './test-shards';
|
|
228
|
-
export { planShards,
|
|
243
|
+
export { planShards, reproduceFor, runShards, shardArgs } from './test-shards';
|
|
229
244
|
export { availableCpus, defaultWorkers, WORKER_CEILING } from './test-workers';
|
|
230
245
|
export type { CodeFixSite, CodeSite, FixSite, SourceSite } from './ts-scan';
|
|
231
246
|
export {
|
|
@@ -234,7 +249,6 @@ export {
|
|
|
234
249
|
scanBorrowedCodes,
|
|
235
250
|
scanCodeFixSites,
|
|
236
251
|
scanCodes,
|
|
237
|
-
scanFixes,
|
|
238
252
|
stripComments,
|
|
239
253
|
} from './ts-scan';
|
|
240
254
|
// The one spelling rule for a `references` entry. Exported because the two gate scripts ask the
|
package/src/jobs-drain.ts
CHANGED
|
@@ -75,7 +75,12 @@ async function copySteps(source: JobDriver, target: JobDriver, runId: string): P
|
|
|
75
75
|
/**
|
|
76
76
|
* Hand a leased job back exactly as the drain found it. `countsAsAttempt: false` is the point:
|
|
77
77
|
* a transfer that failed is not a failed attempt, and burning one per `x jobs drain` retry would
|
|
78
|
-
* dead-letter a job nobody ever ran.
|
|
78
|
+
* dead-letter a job nobody ever ran.
|
|
79
|
+
*
|
|
80
|
+
* It returns to `ready`, which is what "as the drain found it" means — the drain leased a ready
|
|
81
|
+
* job and could not move it. It used to land in `suspended`, not by intent but because
|
|
82
|
+
* `countsAsAttempt: false` was the only bit the drivers had and `step.sleep` had claimed it;
|
|
83
|
+
* `NackOptions.park` now carries the suspension, so the two callers no longer share one meaning.
|
|
79
84
|
*/
|
|
80
85
|
async function releaseLease(source: JobDriver, id: string): Promise<void> {
|
|
81
86
|
try {
|
package/src/mcp-errors.ts
CHANGED
|
@@ -22,6 +22,11 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
22
22
|
// Runnable first, the narrowing behind a `#`: `x help <command> --json` pasted into a shell
|
|
23
23
|
// is a redirect, not a command, and this table is copied verbatim by whoever reads it.
|
|
24
24
|
X_CLI_BAD_FLAG: 'x help --json # then narrow to the command the cause names',
|
|
25
|
+
// Not an `x` command: this rule is about the CLI's OWN declarations, it can only fire in this
|
|
26
|
+
// repo, and the suite that applies it is what reproduces the finding. A placeholder command
|
|
27
|
+
// would fail this table's own no-`<placeholder>` rule, and rightly — it would not run.
|
|
28
|
+
X_CLI_FLAG_UNREAD:
|
|
29
|
+
'bun test packages/cli/src/flag-reads.test.ts # the finding names the flag and the file to read it in',
|
|
25
30
|
X_VERIFY_FAILED: 'x verify --json',
|
|
26
31
|
X_NOT_IN_APP: 'x new myapp --json && cd myapp',
|
|
27
32
|
X_BUN_VERSION: 'bun upgrade',
|
|
@@ -93,6 +98,14 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
93
98
|
X_DB_MIGRATE_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
|
|
94
99
|
X_DB_BRANCH_FAILED: 'x db branch ls --json',
|
|
95
100
|
X_DB_STUDIO_FAILED: 'x doctor --json',
|
|
101
|
+
// Runnable first, the narrowing behind a `#`, exactly as X_CLI_UNKNOWN_COMMAND above: naming
|
|
102
|
+
// the tier IS the consent, and which seed to consent to is the one thing this table cannot
|
|
103
|
+
// know — a bare `x db seed --tier dev` would seed every dev fixture in production to answer a
|
|
104
|
+
// refusal about one. The dry run is what lists them, and the raised error's own `fix:` already
|
|
105
|
+
// carries the fully named invocation. `ULTIMATE_SEED_TIER=<tier>` is the other half of the
|
|
106
|
+
// consent and stays in the cause: it is the answer only for a container with a fixed argv.
|
|
107
|
+
X_SEED_ENVIRONMENT:
|
|
108
|
+
'x db seed --dry-run --json # then name the tier: x db seed <name> --tier dev --json',
|
|
96
109
|
X_BOUNDARY_SITE_TO_APP:
|
|
97
110
|
'x verify --json # then: x fix boundary <the file the finding names> --json',
|
|
98
111
|
X_BOUNDARY_SHARED_LEAF:
|
package/src/mcp-host.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// is a second catalog of routes, entities, actions, queries or jobs.
|
|
5
5
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { agentActor, isUltimateError, UltimateError } from '@ultimat3/core';
|
|
7
|
+
import { agentActor, isUltimateError, renderThrowable, UltimateError } from '@ultimat3/core';
|
|
8
8
|
import type { DbClient } from '@ultimat3/db';
|
|
9
9
|
import {
|
|
10
10
|
ensureReadOnlyRole,
|
|
@@ -199,7 +199,9 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
|
|
|
199
199
|
if (isUltimateError(error)) throw error;
|
|
200
200
|
throw new UltimateError({
|
|
201
201
|
code: 'X_DB_MIGRATE_FAILED',
|
|
202
|
-
|
|
202
|
+
// The blessed total renderer: `String(error)` runs the value's own `toString`, and
|
|
203
|
+
// this is the last hop before an agent is handed the three-line result.
|
|
204
|
+
cause: renderThrowable(error),
|
|
203
205
|
fix: 'x db reset',
|
|
204
206
|
});
|
|
205
207
|
}
|
package/src/messages.ts
CHANGED
|
@@ -46,10 +46,22 @@ const CATALOG = {
|
|
|
46
46
|
'cli.db.branch.unknown': '-',
|
|
47
47
|
'cli.db.gen.failed': 'migration not generated',
|
|
48
48
|
'cli.db.gen.unchanged': 'entities and migrations agree — nothing to generate',
|
|
49
|
+
// A THIRD outcome, and it is neither of the other two: nothing to generate, but the sidecar the
|
|
50
|
+
// `drift` step reads did move — an edit under `packages/db/src` that implies no DDL. Rendering it
|
|
51
|
+
// as `written` would name a migration nobody can apply; as `unchanged`, it would hide a file this
|
|
52
|
+
// command wrote. `GeneratedFiles.outcome` is what `--json` carries the same distinction on.
|
|
53
|
+
'cli.db.gen.recorded': 'no migration needed — schema hash re-recorded in {file}',
|
|
49
54
|
'cli.db.gen.written': 'migration {id} generated',
|
|
50
55
|
'cli.db.migrate.applied': 'migrations applied',
|
|
51
56
|
'cli.db.migrate.failed': 'migration failed',
|
|
52
57
|
'cli.db.reset.done': 'database reset and migrated',
|
|
58
|
+
// Every seed counted per outcome, exactly as the backfill summary is: a replayed seed writes
|
|
59
|
+
// nothing and skips everything, and a total that hid that would make the second run look idle.
|
|
60
|
+
'cli.db.seed.done':
|
|
61
|
+
'{count} seed(s): {inserted} inserted, {updated} updated, {skipped} already stored',
|
|
62
|
+
'cli.db.seed.dryRun': '{count} seed(s) would run — nothing written while --dry-run is set',
|
|
63
|
+
'cli.db.seed.failed': '{failed} of {count} seed(s) failed',
|
|
64
|
+
'cli.db.seed.none': 'no seed matched — nothing to run',
|
|
53
65
|
'cli.dev.ready': 'dev ready on {url} — /_x mounted ({panels} panels), {services}',
|
|
54
66
|
// The mail and CDN halves of that boot line. Rendered text, so it lives here — while
|
|
55
67
|
// `describeMail`/`describeCdn` keep the same wording as the fixed vocabulary `x dev --json`
|
|
@@ -158,6 +170,9 @@ const CATALOG = {
|
|
|
158
170
|
'cli.verify.passSkipped':
|
|
159
171
|
'{passed} of {count} steps passed in {ms}ms — {skipped} skipped: {names}',
|
|
160
172
|
'cli.verify.failSkipped': '{failed} of {count} steps failed — {skipped} skipped: {names}',
|
|
173
|
+
// The `errors` step's own coverage, in `output`: a scan without a parser reads most fix lines
|
|
174
|
+
// and not all of them, and a step that reports findings alone claims a completeness it lacks.
|
|
175
|
+
'cli.verify.fixCoverage': 'checked {checked} fix line(s), could not read {unreadable}',
|
|
161
176
|
'cli.verify.serial': 'serial',
|
|
162
177
|
'cli.verify.workers': '{workers} workers',
|
|
163
178
|
'cli.env.checked': '{count} declared variable(s), all present and valid',
|
package/src/metrics-endpoint.ts
CHANGED
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
METRICS_PATH,
|
|
9
9
|
markListening,
|
|
10
10
|
metricsText,
|
|
11
|
+
stringField,
|
|
12
|
+
UltimateError,
|
|
11
13
|
} from '@ultimat3/core';
|
|
14
|
+
import { docsFor } from './error-codes';
|
|
15
|
+
import { neighbouringPort } from './flag-number';
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
18
|
* A port of its own, and NOT the role's HTTP port, for one reason the chart makes concrete:
|
|
@@ -25,6 +29,37 @@ import {
|
|
|
25
29
|
*/
|
|
26
30
|
export const DEFAULT_METRICS_PORT = 9090;
|
|
27
31
|
|
|
32
|
+
/**
|
|
33
|
+
* `X_PORT_IN_USE` is the code the CLI already registers for "this dev port is taken", and the
|
|
34
|
+
* scrape port is one — a synonym here would be a second code for one condition. The fix moves the
|
|
35
|
+
* port rather than naming a process to kill, because `METRICS_PORT` is the one knob both `x dev`
|
|
36
|
+
* and the container read (`serve.ts`'s `metricsPortFromEnv`).
|
|
37
|
+
*
|
|
38
|
+
* The port it names comes from `neighbouringPort`, never `port + 1`: at the top of the range
|
|
39
|
+
* that is 65536, and an instruction that cannot run is the failure this code exists to end.
|
|
40
|
+
*/
|
|
41
|
+
export class MetricsPortInUseError extends UltimateError {
|
|
42
|
+
constructor(input: { port: number }) {
|
|
43
|
+
super({
|
|
44
|
+
code: 'X_PORT_IN_USE',
|
|
45
|
+
cause: `the metrics port ${input.port} is already bound, so no role could open its scrape listener`,
|
|
46
|
+
fix: `METRICS_PORT=${neighbouringPort(input.port)} x dev --json`,
|
|
47
|
+
docs: docsFor('X_PORT_IN_USE'),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bun surfaces the bind failure as an `Error` carrying the libc code; nothing else is ours. Read
|
|
54
|
+
* through `stringField`, never `error instanceof Error` plus a property access: both run on a value
|
|
55
|
+
* this process did not build, and either can throw one line before the guard that was meant to make
|
|
56
|
+
* the path safe. Exported because whether the kernel refuses a second bind is the OS's business,
|
|
57
|
+
* not this package's — the contract worth pinning is that an EADDRINUSE-shaped throw becomes a
|
|
58
|
+
* coded refusal, and that is testable without racing a socket.
|
|
59
|
+
*/
|
|
60
|
+
export const isAddressInUse = (error: unknown): boolean =>
|
|
61
|
+
stringField(error, 'code') === 'EADDRINUSE';
|
|
62
|
+
|
|
28
63
|
export interface MetricsEndpointOptions {
|
|
29
64
|
/** 0 asks the kernel for an ephemeral port, which is what a test wants. */
|
|
30
65
|
readonly port?: number;
|
|
@@ -45,20 +80,32 @@ export interface MetricsEndpoint {
|
|
|
45
80
|
* signal at the moment of load is worse than no autoscaler.
|
|
46
81
|
*/
|
|
47
82
|
export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): MetricsEndpoint {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
83
|
+
const port = options.port ?? DEFAULT_METRICS_PORT;
|
|
84
|
+
// `startRoles` opens this FIRST, before any role, so `Bun.serve`'s own bare `Error` was what a
|
|
85
|
+
// second `x dev` on one machine reported: no code, no fix, at the boot path this package owns.
|
|
86
|
+
// The return type is inferred, keeping `Bun.serve`'s own shape stated once.
|
|
87
|
+
function listen() {
|
|
88
|
+
try {
|
|
89
|
+
return Bun.serve({
|
|
90
|
+
port,
|
|
91
|
+
hostname: options.hostname ?? 'localhost',
|
|
92
|
+
fetch(request: Request): Response {
|
|
93
|
+
if (new URL(request.url).pathname !== METRICS_PATH) {
|
|
94
|
+
return new Response('not found', { status: 404 });
|
|
95
|
+
}
|
|
96
|
+
// `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot
|
|
97
|
+
// steal each other's samples — but a cache would hand the second one a stale window.
|
|
98
|
+
return new Response(metricsText(), {
|
|
99
|
+
headers: { 'content-type': METRICS_CONTENT_TYPE, 'cache-control': 'no-store' },
|
|
100
|
+
});
|
|
101
|
+
},
|
|
59
102
|
});
|
|
60
|
-
}
|
|
61
|
-
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (!isAddressInUse(error)) throw error;
|
|
105
|
+
throw new MetricsPortInUseError({ port });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const server = listen();
|
|
62
109
|
// Same rule as every other socket the framework opens: announce it, so a request back to it is
|
|
63
110
|
// recognisably this process calling itself rather than egress the test seal must refuse.
|
|
64
111
|
const stopListening = markListening(server.url.origin);
|
package/src/otlp-export.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
configureMetrics,
|
|
8
8
|
configureTelemetry,
|
|
9
9
|
logger,
|
|
10
|
+
noopExporter,
|
|
11
|
+
noopMetricExporter,
|
|
10
12
|
onShutdown,
|
|
11
13
|
otlpMetricExporter,
|
|
12
14
|
otlpSpanExporter,
|
|
@@ -29,6 +31,14 @@ export const METRIC_EXPORT_INTERVAL_MS = 60_000;
|
|
|
29
31
|
* Both are registered with `onShutdown(..., { phase: 'close' })`: the last spans of a drain are
|
|
30
32
|
* the ones that explain the drain, and a process that exits with a full queue loses exactly the
|
|
31
33
|
* window an operator went looking for.
|
|
34
|
+
*
|
|
35
|
+
* The release UNINSTALLS what it installed, per signal. `configureTelemetry`/`configureMetrics`
|
|
36
|
+
* merge into process-global state, so stopping the timer and dropping the drain hooks left the
|
|
37
|
+
* first boot's exporter configured: a second `serveApp` in the same process exported its spans
|
|
38
|
+
* into a released exporter — queued against a collector nothing will flush to, on a timer nothing
|
|
39
|
+
* clears. `cmd-dev.ts`'s `stop()` hands back `noopExporter` for exactly this reason. Per signal and
|
|
40
|
+
* never unconditionally: `x dev` configures a trace RECORDER before calling this, and a boot that
|
|
41
|
+
* installed no exporter must not uninstall one it never owned.
|
|
32
42
|
*/
|
|
33
43
|
export function startOtlpExport(env: Env = process.env): () => void {
|
|
34
44
|
const releases: (() => void)[] = [];
|
|
@@ -41,6 +51,9 @@ export function startOtlpExport(env: Env = process.env): () => void {
|
|
|
41
51
|
if (traces !== undefined) {
|
|
42
52
|
const exporter = otlpSpanExporter({ endpoint: traces });
|
|
43
53
|
configureTelemetry({ exporter });
|
|
54
|
+
// Pushed first, so the reversed run below applies it LAST — after the drain hook is dropped,
|
|
55
|
+
// the same order `cmd-dev.ts` releases the recorder in.
|
|
56
|
+
releases.push(() => configureTelemetry({ exporter: noopExporter }));
|
|
44
57
|
releases.push(onShutdown('otlp-traces', () => exporter.shutdown(), { phase: 'close' }));
|
|
45
58
|
logger.info('ultimate otlp traces', { endpoint: traces });
|
|
46
59
|
}
|
|
@@ -49,6 +62,7 @@ export function startOtlpExport(env: Env = process.env): () => void {
|
|
|
49
62
|
if (metrics !== undefined) {
|
|
50
63
|
const exporter = otlpMetricExporter({ endpoint: metrics });
|
|
51
64
|
configureMetrics({ exporter });
|
|
65
|
+
releases.push(() => configureMetrics({ exporter: noopMetricExporter }));
|
|
52
66
|
// The push loop, and not only the exporter: `configureMetrics` names where a snapshot goes
|
|
53
67
|
// and nothing decides when one is taken, so without this the collector receives one export —
|
|
54
68
|
// the drain's — for the whole life of the process.
|
package/src/parse.ts
CHANGED
|
@@ -47,7 +47,12 @@ export interface CommandSpec {
|
|
|
47
47
|
*/
|
|
48
48
|
readonly subcommandPositionals?: Readonly<Record<string, readonly string[]>>;
|
|
49
49
|
readonly flags?: readonly FlagSpec[];
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Command needs an app root (`app.config.ts`). The dispatcher enforces it — `dispatch.ts`, before
|
|
52
|
+
* `target.run` — and until 2026-08 nothing read this field at all: the guarantee was kept only by
|
|
53
|
+
* each of the 17 declaring commands remembering to call `requireAppRoot` itself, so a new command
|
|
54
|
+
* that declared it and forgot the call ran outside an app with no refusal.
|
|
55
|
+
*/
|
|
51
56
|
readonly requiresApp?: boolean;
|
|
52
57
|
}
|
|
53
58
|
|
package/src/seo-meta.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Single responsibility: the app's `site/` routes as `@ultimat3/seo` reads them.
|
|
2
|
+
//
|
|
3
|
+
// The two shapes only meet here. `RouteRecord.meta` is a STATIC object, and `defineRoute({ meta })`
|
|
4
|
+
// is an async function of the route's own data — so somebody has to call one to get the other, and
|
|
5
|
+
// this is the only tier that can see both: `@ultimat3/seo` is tier 1 and may not import the route
|
|
6
|
+
// registry, which is tier 4.
|
|
7
|
+
|
|
8
|
+
import { metaContextFor, type RouteEntry, routeDataFor, routeEntries } from '@ultimat3/render';
|
|
9
|
+
import type { RouteRecord } from '@ultimat3/seo';
|
|
10
|
+
import { loadApp } from './app-load';
|
|
11
|
+
import type { Finding } from './output';
|
|
12
|
+
import { findingFrom } from './output';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Why a `site/` route's metadata cannot be read without running the app.
|
|
16
|
+
*
|
|
17
|
+
* Neither is a defect — both are routes whose `<head>` is a function of data that does not exist
|
|
18
|
+
* until a request does. They are reported rather than dropped, because a gate that silently checks
|
|
19
|
+
* two of five routes and says "ok" is worse than one that says which three it could not reach.
|
|
20
|
+
*/
|
|
21
|
+
export type UnresolvedReason = 'declares-load' | 'dynamic';
|
|
22
|
+
|
|
23
|
+
export interface UnresolvedRoute {
|
|
24
|
+
readonly path: string;
|
|
25
|
+
readonly file: string;
|
|
26
|
+
readonly reason: UnresolvedReason;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SiteMetaScan {
|
|
30
|
+
/** Routes whose meta resolved, in the shape `validateMeta` takes. */
|
|
31
|
+
readonly records: readonly RouteRecord[];
|
|
32
|
+
readonly unresolved: readonly UnresolvedRoute[];
|
|
33
|
+
/** A route whose `meta()` THREW — a page that cannot render its own head. */
|
|
34
|
+
readonly findings: readonly Finding[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The origin `meta` is called with. Reserved by RFC 6761 and resolvable by nothing, deliberately:
|
|
39
|
+
* an app has no configured base URL (`packages/core/src/config.ts` declares none), so any real
|
|
40
|
+
* origin here would be this file inventing one — and a `canonical` compared against an invented
|
|
41
|
+
* origin is a finding nobody can act on. `validateMeta` is therefore called with no `baseUrl` and
|
|
42
|
+
* skips canonical checks; `absoluteUrl` never sees this string.
|
|
43
|
+
*/
|
|
44
|
+
const PROBE_ORIGIN = 'https://verify.invalid';
|
|
45
|
+
|
|
46
|
+
const reasonFor = (entry: RouteEntry): UnresolvedReason | undefined => {
|
|
47
|
+
if (entry.pattern.keys.length > 0) return 'dynamic';
|
|
48
|
+
// A `load` is a database read. Running one inside `x verify` would make the gate need a live
|
|
49
|
+
// database to answer a question about text, and would run app queries nobody asked for.
|
|
50
|
+
if (entry.config.load !== undefined) return 'declares-load';
|
|
51
|
+
return undefined;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read every `site/` route's metadata, without rendering and without touching a database.
|
|
56
|
+
*
|
|
57
|
+
* `routeDataFor` hands a no-`load` route its own context back as the data — that is exactly what
|
|
58
|
+
* `defineRoute`'s `LoadRequirement` guarantees — so `meta` gets the same argument here that it gets
|
|
59
|
+
* in `x dev` and in the prerenderer, from the same two builders both of those use.
|
|
60
|
+
*/
|
|
61
|
+
export async function scanSiteMeta(root: string): Promise<SiteMetaScan> {
|
|
62
|
+
await loadApp(root);
|
|
63
|
+
return await readSiteMeta();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The same scan over the registry as it stands, without loading anything.
|
|
68
|
+
*
|
|
69
|
+
* Split from `scanSiteMeta` so the resolution rules are testable against routes registered by hand:
|
|
70
|
+
* the half worth pinning is which routes are reachable and what happens when one throws, and
|
|
71
|
+
* neither of those is a fact about globbing a directory.
|
|
72
|
+
*/
|
|
73
|
+
export async function readSiteMeta(): Promise<SiteMetaScan> {
|
|
74
|
+
const records: RouteRecord[] = [];
|
|
75
|
+
const unresolved: UnresolvedRoute[] = [];
|
|
76
|
+
const findings: Finding[] = [];
|
|
77
|
+
|
|
78
|
+
for (const entry of routeEntries()) {
|
|
79
|
+
if (entry.surface !== 'site') continue;
|
|
80
|
+
const reason = reasonFor(entry);
|
|
81
|
+
if (reason !== undefined) {
|
|
82
|
+
unresolved.push({ path: entry.path, file: entry.file, reason });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const ctx = { url: `${PROBE_ORIGIN}${entry.path}`, params: {} };
|
|
86
|
+
try {
|
|
87
|
+
const meta = await entry.config.meta(
|
|
88
|
+
metaContextFor(ctx, await routeDataFor(entry.config, ctx)),
|
|
89
|
+
);
|
|
90
|
+
records.push({
|
|
91
|
+
path: entry.path,
|
|
92
|
+
file: entry.file,
|
|
93
|
+
surface: 'site',
|
|
94
|
+
render: entry.config.render,
|
|
95
|
+
meta,
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// `findingFrom`, not a code of this file's own: a `meta` that throws an `UltimateError` has
|
|
99
|
+
// already said what broke and how to fix it, and a wrapper would bury both. Anything else
|
|
100
|
+
// becomes `X_CLI_UNEXPECTED` through core's total renderer.
|
|
101
|
+
findings.push({ ...findingFrom(error), at: entry.file });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { records, unresolved, findings };
|
|
105
|
+
}
|
package/src/serve.ts
CHANGED
|
@@ -86,6 +86,20 @@ export function metricsPortFromEnv(env: Env): number {
|
|
|
86
86
|
return portValue(env, 'METRICS_PORT', DEFAULT_METRICS_PORT);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The scrape port a boot uses, given the app port it already resolved. One expression, and it is
|
|
91
|
+
* exported because `x dev` is the second caller: `cmd-dev.ts` passed no `metricsPort` at all, so
|
|
92
|
+
* `METRICS_PORT` was honoured in the container and ignored on a laptop — the dev/prod parity break
|
|
93
|
+
* `dev-roles.ts`'s own header forbids, and a second copy of this rule would be the same break
|
|
94
|
+
* one edit later.
|
|
95
|
+
*
|
|
96
|
+
* An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
|
|
97
|
+
* fixed 9090 would fail the next suite to boot beside it. An environment that names the port still
|
|
98
|
+
* wins — that is the deploy talking.
|
|
99
|
+
*/
|
|
100
|
+
export const metricsPortFor = (env: Env, port: number, override?: number): number =>
|
|
101
|
+
override ?? (port === 0 && env['METRICS_PORT'] === undefined ? 0 : metricsPortFromEnv(env));
|
|
102
|
+
|
|
89
103
|
/**
|
|
90
104
|
* The one env var that turns error monitoring on, and the only vendor-shaped name in the boot
|
|
91
105
|
* path. Not a platform primitive (axiom 7): the value is a URL to whatever the operator runs, the
|
|
@@ -302,9 +316,7 @@ async function bootRoles(boot: {
|
|
|
302
316
|
// An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
|
|
303
317
|
// fixed 9090 would fail the next suite to boot beside it. An environment that names the port
|
|
304
318
|
// still wins — that is the deploy talking.
|
|
305
|
-
const metricsPort =
|
|
306
|
-
options.metricsPort ??
|
|
307
|
-
(port === 0 && options.env['METRICS_PORT'] === undefined ? 0 : metricsPortFromEnv(options.env));
|
|
319
|
+
const metricsPort = metricsPortFor(options.env, port, options.metricsPort);
|
|
308
320
|
const running = await startRoles({
|
|
309
321
|
roles: [role],
|
|
310
322
|
port,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// POSIX single-quoting for a value the CLI pastes into a line a reader runs — a `fix:`, a
|
|
2
|
+
// reproduce command. Its own module, and not `test-shards.ts` where it started, because the
|
|
3
|
+
// subprocess boundary needs it too and `test-shards.ts` imports `exec.ts`: one leaf both can
|
|
4
|
+
// reach is the alternative to an import cycle or a second quoter.
|
|
5
|
+
|
|
6
|
+
const SHELL_SAFE = /^[\w@%+=:,./-]+$/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A program name, a `--filter` or a path holding a space, a `$` or a `;` pastes back as two
|
|
10
|
+
* arguments or as a second command, so an unquoted line runs something other than what it claims.
|
|
11
|
+
* `'\''` is the only escape a single-quoted string has. A shell-safe value is left alone, so the
|
|
12
|
+
* common case stays readable.
|
|
13
|
+
*/
|
|
14
|
+
export const quoteArg = (value: string): string =>
|
|
15
|
+
SHELL_SAFE.test(value) ? value : `'${value.split("'").join("'\\''")}'`;
|