@ultimat3/cli 2.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CLAUDE.md +109 -13
  2. package/README.md +1 -0
  3. package/package.json +24 -24
  4. package/src/budgets.ts +31 -8
  5. package/src/cmd-db-branch.ts +6 -2
  6. package/src/cmd-db.ts +138 -10
  7. package/src/cmd-deploy.ts +42 -14
  8. package/src/cmd-dev.ts +9 -2
  9. package/src/cmd-docs.ts +7 -3
  10. package/src/cmd-doctor.ts +16 -7
  11. package/src/cmd-fix.ts +15 -3
  12. package/src/cmd-generate.ts +29 -4
  13. package/src/cmd-help.ts +25 -4
  14. package/src/cmd-i18n.ts +8 -5
  15. package/src/cmd-jobs.ts +6 -5
  16. package/src/cmd-mcp.ts +16 -12
  17. package/src/cmd-new.ts +10 -14
  18. package/src/cmd-planned.ts +13 -0
  19. package/src/cmd-policy.ts +8 -6
  20. package/src/cmd-registries.ts +7 -6
  21. package/src/cmd-routes.ts +27 -4
  22. package/src/cmd-secrets.ts +6 -6
  23. package/src/cmd-test.ts +14 -3
  24. package/src/cmd-verify.ts +80 -10
  25. package/src/command.ts +10 -2
  26. package/src/db-branch.ts +18 -0
  27. package/src/db-generate.ts +38 -6
  28. package/src/db-seed.ts +294 -0
  29. package/src/dev-assets.ts +22 -3
  30. package/src/dev-cache.ts +9 -9
  31. package/src/dev-render.ts +6 -1
  32. package/src/dev-roles.ts +5 -3
  33. package/src/dev-runtime.ts +2 -2
  34. package/src/dev-storage.ts +6 -4
  35. package/src/dev-traces.ts +26 -4
  36. package/src/dispatch.ts +33 -4
  37. package/src/drift.ts +41 -1
  38. package/src/error-catalog.ts +1 -0
  39. package/src/error-codes.ts +11 -0
  40. package/src/error-contract.ts +31 -4
  41. package/src/exec.ts +42 -8
  42. package/src/fix-command.ts +9 -2
  43. package/src/fix-imports.ts +118 -0
  44. package/src/fix-scan.ts +251 -0
  45. package/src/flag-number.ts +11 -0
  46. package/src/flag-reads.ts +114 -0
  47. package/src/i18n-audit.ts +2 -1
  48. package/src/index.ts +19 -5
  49. package/src/jobs-drain.ts +6 -1
  50. package/src/mcp-errors.ts +13 -0
  51. package/src/mcp-host.ts +4 -2
  52. package/src/messages.ts +15 -0
  53. package/src/metrics-endpoint.ts +60 -13
  54. package/src/otlp-export.ts +14 -0
  55. package/src/parse.ts +6 -1
  56. package/src/seo-meta.ts +105 -0
  57. package/src/serve.ts +15 -3
  58. package/src/shell-quote.ts +15 -0
  59. package/src/templates/action.ts +39 -7
  60. package/src/templates/backfill.ts +3 -1
  61. package/src/templates/index.ts +10 -1
  62. package/src/templates/job.ts +6 -2
  63. package/src/templates/query.ts +6 -1
  64. package/src/templates/route.ts +18 -9
  65. package/src/templates/scaffold-api.ts +100 -0
  66. package/src/templates/scaffold-app.ts +8 -48
  67. package/src/templates/scaffold-container.ts +44 -9
  68. package/src/templates/scaffold-helm-templates.ts +327 -0
  69. package/src/templates/scaffold-helm.ts +144 -0
  70. package/src/templates/scaffold-repo.ts +25 -8
  71. package/src/test-shards.ts +1 -10
  72. package/src/test-workers.ts +4 -1
  73. package/src/ts-scan.ts +25 -176
  74. package/src/tsconfig-references.ts +27 -2
  75. package/src/verify-step.ts +5 -0
@@ -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, error instanceof Error ? error.message : String(error));
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 { checkSourceDrift, recordedHashes, schemaHash, writeSchemaHash } from './drift';
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, quoteArg, reproduceFor, runShards, shardArgs } from './test-shards';
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. It parks as `suspended`, which every driver claims.
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
- cause: error instanceof Error ? error.message : String(error),
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',
@@ -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 server = Bun.serve({
49
- port: options.port ?? DEFAULT_METRICS_PORT,
50
- hostname: options.hostname ?? 'localhost',
51
- fetch(request: Request): Response {
52
- if (new URL(request.url).pathname !== METRICS_PATH) {
53
- return new Response('not found', { status: 404 });
54
- }
55
- // `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot steal
56
- // each other's samples — but a cache would hand the second one a stale window.
57
- return new Response(metricsText(), {
58
- headers: { 'content-type': METRICS_CONTENT_TYPE, 'cache-control': 'no-store' },
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);
@@ -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
- /** Command needs an app root (`app.config.ts`) — the dispatcher enforces it. */
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
 
@@ -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("'\\''")}'`;