@ultimat3/cli 23.0.0 → 24.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 (149) hide show
  1. package/CLAUDE.md +15 -7
  2. package/README.md +3 -3
  3. package/package.json +28 -28
  4. package/src/affected.ts +27 -35
  5. package/src/api-registration.ts +89 -0
  6. package/src/app-artifacts.ts +1 -1
  7. package/src/app-auth.ts +4 -27
  8. package/src/app-config-load.ts +51 -0
  9. package/src/app-env.ts +5 -11
  10. package/src/app-manifest.ts +2 -8
  11. package/src/app-mcp.ts +10 -23
  12. package/src/app-permissions-borrowed.ts +129 -0
  13. package/src/app-permissions-site.ts +24 -0
  14. package/src/app-permissions.ts +7 -17
  15. package/src/budgets.ts +4 -35
  16. package/src/cdp-shot-driver.ts +3 -1
  17. package/src/cmd-auth-spec.ts +16 -0
  18. package/src/cmd-auth.ts +65 -0
  19. package/src/cmd-build.ts +64 -15
  20. package/src/cmd-db-backfill.ts +25 -12
  21. package/src/cmd-db.ts +6 -5
  22. package/src/cmd-deploy-helm.ts +7 -6
  23. package/src/cmd-deploy.ts +4 -4
  24. package/src/cmd-dev.ts +9 -8
  25. package/src/cmd-doctor-spec.ts +2 -9
  26. package/src/cmd-doctor.ts +41 -14
  27. package/src/cmd-env.ts +26 -2
  28. package/src/cmd-generate.ts +79 -32
  29. package/src/cmd-help.ts +19 -6
  30. package/src/cmd-i18n.ts +10 -9
  31. package/src/cmd-jobs.ts +18 -6
  32. package/src/cmd-manifest-spec.ts +1 -1
  33. package/src/cmd-manifest.ts +6 -25
  34. package/src/cmd-mcp.ts +46 -15
  35. package/src/cmd-planned.ts +15 -8
  36. package/src/cmd-routes.ts +12 -2
  37. package/src/cmd-secrets.ts +47 -6
  38. package/src/cmd-shot-matrix.ts +1 -3
  39. package/src/cmd-shot.ts +12 -3
  40. package/src/cmd-tasks.ts +2 -2
  41. package/src/cmd-test-spec.ts +1 -7
  42. package/src/cmd-test.ts +13 -29
  43. package/src/cmd-verify.ts +4 -4
  44. package/src/db-subscribes.ts +12 -4
  45. package/src/dev-dashboard-guard.ts +76 -0
  46. package/src/dev-dashboard.ts +51 -29
  47. package/src/dev-lock.ts +4 -7
  48. package/src/dispatch.ts +3 -6
  49. package/src/doctor-app-url.ts +39 -0
  50. package/src/doctor-auth.ts +95 -0
  51. package/src/drift-replica-identity.ts +42 -0
  52. package/src/error-catalog.ts +6 -9
  53. package/src/error-codes.ts +23 -3
  54. package/src/error-unthrown.ts +29 -11
  55. package/src/errors.ts +54 -83
  56. package/src/framework-schema-apply.ts +78 -0
  57. package/src/framework-schema-stamp.ts +96 -0
  58. package/src/framework-schema.ts +13 -2
  59. package/src/generate-files.ts +4 -3
  60. package/src/generate-kinds.ts +33 -1
  61. package/src/generate-shadow.ts +46 -0
  62. package/src/generate-write.ts +47 -13
  63. package/src/gh-target.ts +12 -7
  64. package/src/i18n-audit.ts +4 -1
  65. package/src/index.ts +1 -3
  66. package/src/island-bundle.ts +3 -15
  67. package/src/jobs-drain.ts +82 -15
  68. package/src/jobs-report.ts +4 -4
  69. package/src/loopback-host.ts +13 -0
  70. package/src/manifest-staleness.ts +62 -0
  71. package/src/mcp-errors.ts +12 -3
  72. package/src/mcp-host.ts +16 -9
  73. package/src/mcp-test-run.ts +38 -0
  74. package/src/messages.ts +2 -0
  75. package/src/metrics-endpoint.ts +25 -1
  76. package/src/output.ts +6 -0
  77. package/src/page-navigation.ts +7 -23
  78. package/src/page-speculation.ts +5 -20
  79. package/src/prerender-out.ts +34 -7
  80. package/src/prerender.ts +8 -5
  81. package/src/pwa-artifacts.ts +26 -94
  82. package/src/registry.ts +2 -0
  83. package/src/revalidated-response.ts +4 -3
  84. package/src/role-replicator.ts +16 -0
  85. package/src/role-start.ts +9 -4
  86. package/src/role-sync.ts +55 -20
  87. package/src/runtime-assets.ts +11 -7
  88. package/src/runtime-bindings.ts +4 -4
  89. package/src/runtime-cache.ts +38 -47
  90. package/src/runtime-idempotency-scope.ts +23 -0
  91. package/src/runtime-isr.ts +36 -0
  92. package/src/runtime-jobs.ts +8 -33
  93. package/src/runtime-mfa-warning.ts +29 -0
  94. package/src/runtime-notify-retention.ts +10 -46
  95. package/src/runtime-overrides.ts +1 -1
  96. package/src/runtime-purge.ts +38 -16
  97. package/src/runtime-queue.ts +31 -10
  98. package/src/runtime-realtime.ts +9 -45
  99. package/src/runtime-render.ts +62 -35
  100. package/src/runtime-services.ts +20 -17
  101. package/src/runtime-storage.ts +10 -5
  102. package/src/schema-diff.ts +16 -0
  103. package/src/schema-drift.ts +45 -15
  104. package/src/seo-routes.ts +80 -9
  105. package/src/serve-boot.ts +34 -6
  106. package/src/serve-drain.ts +9 -33
  107. package/src/serve-env.ts +9 -6
  108. package/src/serve-web.ts +12 -7
  109. package/src/shot-locale.ts +5 -23
  110. package/src/site-assets.ts +3 -5
  111. package/src/site-config.ts +9 -48
  112. package/src/site-seo.ts +1 -1
  113. package/src/sitemap-lastmod.ts +5 -0
  114. package/src/solid-loader.ts +12 -3
  115. package/src/source-files.ts +6 -0
  116. package/src/stored-object-headers.ts +43 -0
  117. package/src/sync-url.ts +2 -2
  118. package/src/tasks-facts.ts +3 -29
  119. package/src/templates/action.ts +3 -0
  120. package/src/templates/naming.ts +28 -0
  121. package/src/templates/route.ts +3 -6
  122. package/src/templates/scaffold-app.ts +6 -2
  123. package/src/templates/scaffold-container-compose.ts +10 -1
  124. package/src/templates/scaffold-container.ts +4 -2
  125. package/src/templates/scaffold-db-client.ts +4 -2
  126. package/src/templates/scaffold-helm-fences.ts +76 -0
  127. package/src/templates/scaffold-helm-templates.ts +32 -7
  128. package/src/templates/scaffold-helm.ts +31 -1
  129. package/src/templates/scaffold-repo.ts +9 -1
  130. package/src/templates/scaffold-shell.ts +39 -36
  131. package/src/test-counts.ts +2 -6
  132. package/src/test-ignores.ts +25 -0
  133. package/src/test-passes.ts +0 -15
  134. package/src/test-select.ts +5 -14
  135. package/src/test-shards.ts +41 -93
  136. package/src/test-workers.ts +26 -4
  137. package/src/theme-boot.ts +6 -31
  138. package/src/trusted-hops-error.ts +17 -0
  139. package/src/unthrown-codes.ts +26 -0
  140. package/src/verify-checks.ts +17 -46
  141. package/src/verify-deadline.ts +130 -33
  142. package/src/verify-merge.ts +60 -27
  143. package/src/verify-part-step.ts +90 -0
  144. package/src/verify-run.ts +29 -8
  145. package/src/verify-shard.ts +13 -2
  146. package/src/verify-stalled.ts +152 -0
  147. package/src/verify-tests.ts +5 -2
  148. package/src/build-errors.ts +0 -54
  149. package/src/verify-errors.ts +0 -58
@@ -22,8 +22,7 @@
22
22
  // POSIX by construction), `join`/`basename` the filesystem side.
23
23
  import { basename, join, posix, relative, sep } from 'node:path';
24
24
  import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
25
- import { APP_CONFIG_EXPORT } from './app-auth';
26
- import { APP_CONFIG_FILE } from './app-root';
25
+ import { loadAppConfig } from './app-config-load';
27
26
  import { IslandBuildFailedError } from './errors';
28
27
  import { describeBuildError, sourcesContentOf, stableChunk, stripDebugId } from './island-identity';
29
28
  import type { BuiltOutput, LinkedFile } from './island-link';
@@ -357,20 +356,9 @@ export interface BuildIslandsOptions {
357
356
  readonly sharedChunks?: boolean;
358
357
  }
359
358
 
360
- /**
361
- * `islands.sharedChunks` as `defineConfig` validated it — `false` for an app with no config file or
362
- * none of it. Structural, never `instanceof`, for `loadNavigation`'s reason.
363
- */
359
+ /** `islands.sharedChunks` off the one loader (`app-config-load.ts`) — `false` with no config file. */
364
360
  export async function loadSharedChunks(root: string): Promise<boolean> {
365
- const configPath = join(root, APP_CONFIG_FILE);
366
- if (!(await Bun.file(configPath).exists())) return false;
367
- const config: unknown = ((await import(configPath)) as Record<string, unknown>)[
368
- APP_CONFIG_EXPORT
369
- ];
370
- if (typeof config !== 'object' || config === null) return false;
371
- const islands: unknown = (config as Record<string, unknown>)['islands'];
372
- if (typeof islands !== 'object' || islands === null) return false;
373
- return (islands as Record<string, unknown>)['sharedChunks'] === true;
361
+ return (await loadAppConfig(root))?.islands.sharedChunks ?? false;
374
362
  }
375
363
 
376
364
  /** Build every island in the app. An app with none returns an empty bundle and costs one glob. */
package/src/jobs-drain.ts CHANGED
@@ -2,9 +2,9 @@
2
2
  // it is the only command here that WRITES to two drivers at once, and the ordering rule that makes
3
3
  // that safe — lease, copy steps, enqueue, then ack — has to be readable in one screen.
4
4
 
5
- import { uuid } from '@ultimat3/core';
5
+ import { logger, uuid } from '@ultimat3/core';
6
6
  import type { ClaimedJob, JobDriver, JobRecord, JobState } from '@ultimat3/jobs';
7
- import { claimOf, inspectJobList } from '@ultimat3/jobs';
7
+ import { claimOf, inspectJobList, jobCursor, MAX_JOB_PAGE } from '@ultimat3/jobs';
8
8
  import type { Finding } from './output';
9
9
  import { findingFrom } from './output';
10
10
 
@@ -12,10 +12,10 @@ import { findingFrom } from './output';
12
12
  const PENDING_STATES: readonly JobState[] = ['ready', 'delayed', 'suspended'];
13
13
 
14
14
  /**
15
- * The lease has to outlive the WHOLE transfer loop, not one record: the batch is claimed up
16
- * front, and a lease expiring mid-drain would hand a half-transferred job back to a source
17
- * worker. The snapshot is bounded by the drivers' own 100-row-per-state list cap, so this is a
18
- * ceiling over a few hundred sequential enqueues rather than a guess about one.
15
+ * The lease has to outlive the transfer of the whole BATCH it covers: a batch is claimed up front,
16
+ * and a lease expiring mid-batch would hand a half-transferred job back to a source worker. A
17
+ * batch is at most `MAX_JOB_PAGE` rows, so this is a ceiling over a couple of hundred sequential
18
+ * enqueues rather than a guess about however many the queue holds.
19
19
  */
20
20
  const DRAIN_LEASE_MS = 300_000;
21
21
 
@@ -54,19 +54,56 @@ export interface DrainOutcome {
54
54
  * left alone. `queues` is always explicit because the pg driver reads an empty list as
55
55
  * `['default']` rather than as "every queue".
56
56
  */
57
- function leaseCandidates(
57
+ function leaseBatch(
58
58
  source: JobDriver,
59
59
  candidates: readonly JobRecord[],
60
+ limit: number,
60
61
  ): Promise<readonly ClaimedJob[]> {
61
- if (candidates.length === 0) return Promise.resolve([]);
62
+ if (candidates.length === 0 || limit <= 0) return Promise.resolve([]);
62
63
  return source.claim({
63
64
  queues: [...new Set(candidates.map((record) => record.queue))],
64
- limit: candidates.length,
65
+ limit,
65
66
  visibilityTimeoutMs: DRAIN_LEASE_MS,
66
67
  workerId: `x-jobs-drain:${uuid()}`,
68
+ // A claim BURIES a row whose lease lapsed on its final attempt, whoever makes it — this one
69
+ // too. The drain is an operator's tool with no worker context, so it runs no `onSettled`
70
+ // (at most once across a crash, as documented) and names no `dropExhausted`: said out loud
71
+ // here, because an unlistened burial is a row that went `dead` with nothing anywhere saying so.
72
+ onExhausted: (dead) => {
73
+ for (const row of dead) {
74
+ logger.error('jobs.claim.exhausted', {
75
+ workerId: 'x-jobs-drain',
76
+ job: row.name,
77
+ jobId: row.id,
78
+ attempt: row.attempt,
79
+ maxAttempts: row.maxAttempts,
80
+ error: row.lastError ?? '',
81
+ });
82
+ }
83
+ },
67
84
  });
68
85
  }
69
86
 
87
+ /**
88
+ * Every row in `state`, page by page on the keyset cursor until a short page: one page was the
89
+ * drivers' default 100, so a drain of 350 ready jobs moved 100 and left 250 on the source.
90
+ */
91
+ async function everyRow(source: JobDriver, state: JobState): Promise<readonly JobRecord[]> {
92
+ const rows: JobRecord[] = [];
93
+ let after: string | undefined;
94
+ for (;;) {
95
+ const page = await inspectJobList(source, {
96
+ state,
97
+ limit: MAX_JOB_PAGE,
98
+ ...(after === undefined ? {} : { after }),
99
+ });
100
+ rows.push(...page);
101
+ const last = page.at(-1);
102
+ if (page.length < MAX_JOB_PAGE || last === undefined) return rows;
103
+ after = jobCursor(last);
104
+ }
105
+ }
106
+
70
107
  /** Steps carry their own `runId`, so a copy lands under the same key the target job resumes on. */
71
108
  async function copySteps(source: JobDriver, target: JobDriver, runId: string): Promise<void> {
72
109
  for (const record of await source.steps.list(runId)) await target.steps.put(record);
@@ -114,18 +151,49 @@ export async function drainJobs(
114
151
  target: JobDriver,
115
152
  dryRun: boolean,
116
153
  ): Promise<DrainOutcome> {
117
- const lists = await Promise.all(PENDING_STATES.map((state) => inspectJobList(source, { state })));
154
+ const lists = await Promise.all(PENDING_STATES.map((state) => everyRow(source, state)));
118
155
  const candidates = lists.flat();
119
156
  const base = { from: source.name, to: target.name, candidates };
120
157
  if (dryRun) return { ...base, dryRun: true, moved: [], skipped: [], failures: [] };
121
158
 
122
- const leased = await leaseCandidates(source, candidates);
123
- const held = new Set(leased.map((record) => record.id));
124
159
  const found = new Map(candidates.map((record) => [record.id, record]));
125
- const skipped = candidates.filter((record) => !held.has(record.id)).map(toSkip);
126
-
160
+ const held = new Set<string>();
127
161
  const moved: JobRecord[] = [];
128
162
  const failures: DrainFailure[] = [];
163
+ // Batch by batch, each under its own lease, until the claims stop or the snapshot's count is
164
+ // spent — a claim may lease a row enqueued after the snapshot, and that row spends the budget
165
+ // too, so a queue that keeps filling cannot keep the drain running.
166
+ let budget = candidates.length;
167
+ while (budget > 0) {
168
+ const leased = await leaseBatch(source, candidates, Math.min(budget, MAX_JOB_PAGE));
169
+ if (leased.length === 0) break;
170
+ // A row the target already refused is tried once per drain: handed straight back, spending
171
+ // no budget and adding no second failure for the same id.
172
+ const refused = new Set(failures.map((failure) => failure.id));
173
+ const fresh = leased.filter((record) => !refused.has(record.id));
174
+ for (const record of leased) if (refused.has(record.id)) await releaseLease(source, record);
175
+ if (fresh.length === 0) break;
176
+ budget -= fresh.length;
177
+ for (const record of fresh) held.add(record.id);
178
+ const before = moved.length;
179
+ await transfer(source, target, fresh, found, moved, failures);
180
+ // A batch that moved nothing handed its leases back, and the next claim would take the same
181
+ // rows to the same refusing target: stop, and let the failures already counted say why.
182
+ if (moved.length === before) break;
183
+ }
184
+ const skipped = candidates.filter((record) => !held.has(record.id)).map(toSkip);
185
+ return { ...base, dryRun: false, moved, skipped, failures };
186
+ }
187
+
188
+ /** One leased batch onto the target: steps, then the job, then the ack — per record. */
189
+ async function transfer(
190
+ source: JobDriver,
191
+ target: JobDriver,
192
+ leased: readonly ClaimedJob[],
193
+ found: ReadonlyMap<string, JobRecord>,
194
+ moved: JobRecord[],
195
+ failures: DrainFailure[],
196
+ ): Promise<void> {
129
197
  for (const record of leased) {
130
198
  let enqueued = false;
131
199
  try {
@@ -159,5 +227,4 @@ export async function drainJobs(
159
227
  failures.push({ id: record.id, name: record.name, finding: findingFrom(error) });
160
228
  }
161
229
  }
162
- return { ...base, dryRun: false, moved, skipped, failures };
163
230
  }
@@ -127,10 +127,10 @@ export async function showJob(driver: JobDriver, id: string): Promise<JobTrace>
127
127
  // ── retry ─────────────────────────────────────────────────────────────────
128
128
 
129
129
  /**
130
- * Existence is checked up front so an unknown id always surfaces as `X_JOB_UNKNOWN`: the
131
- * concrete drivers (pg, memory) throw their own error from inside `requeue()` for a missing
132
- * row, and that error is not this command's contract — `retryFromStep`'s documented `undefined`
133
- * return is, and the driver never reaches it once the row is already known absent.
130
+ * Existence is checked up front so an unknown id always surfaces as `X_JOB_UNKNOWN`: both
131
+ * drivers answer `X_JOB_NOT_FOUND` from inside `requeue()` for a missing row, and that code is
132
+ * the driver's contract, not this command's — `retryFromStep`'s documented `undefined` return
133
+ * is, and the driver never reaches it once the row is already known absent.
134
134
  */
135
135
  export async function retryJob(
136
136
  driver: JobDriver,
@@ -0,0 +1,13 @@
1
+ // Whether a hostname is this machine: `localhost`, a `*.localhost` name (RFC 6761 reserves both
2
+ // to loopback) or a loopback address literal. One answer for `x dev`'s Host check and `x doctor`'s
3
+ // APP_URL rule, which ask the same question about two different headers.
4
+
5
+ import { classifyAddress } from '@ultimat3/core';
6
+
7
+ /** `hostname` as `URL` spells it — `[::1]` bracketed, no port. */
8
+ export const isLoopbackHostname = (hostname: string): boolean => {
9
+ const name = hostname.toLowerCase();
10
+ return (
11
+ name === 'localhost' || name.endsWith('.localhost') || classifyAddress(name) === 'loopback'
12
+ );
13
+ };
@@ -0,0 +1,62 @@
1
+ // Do the committed generated files still describe the code? ONE check, asked by `x manifest
2
+ // --check` and by the gate's `manifest` step through this function — two commands answering
3
+ // differently about one file is itself the drift these files exist to prevent.
4
+
5
+ // why: Bun has no synchronous existence probe, and `hasCommittedContract` guards an app load.
6
+ import { existsSync } from 'node:fs';
7
+ // why: Bun exposes no path-join primitive; Bun.file takes a path already joined.
8
+ import { join } from 'node:path';
9
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
10
+ import type { Manifest } from '@ultimat3/manifest';
11
+ import { assertNoDrift, MANIFEST_FILENAME } from '@ultimat3/manifest';
12
+ import { OPENAPI_FILE, openApiStaleness } from './app-openapi';
13
+ import { APP_CONFIG_FILE } from './app-root';
14
+ import type { Finding } from './output';
15
+ import { findingFrom } from './output';
16
+
17
+ /** Whether there is any committed contract here to judge — the cheap question, asked first. */
18
+ export const hasCommittedContract = (root: string): boolean =>
19
+ existsSync(join(root, MANIFEST_FILENAME)) || existsSync(join(root, OPENAPI_FILE));
20
+
21
+ /**
22
+ * `x.manifest.json` was never written. An app root only: the framework monorepo emits
23
+ * `framework.manifest.json` and has no `x.manifest.json` to be missing. `AGENTS.md` tells an agent
24
+ * that facts live in this file, and after `x new` and every generator nothing had run the one
25
+ * command that writes it while the gate reported green (#F7).
26
+ */
27
+ export function manifestMissingFindings(root: string): readonly Finding[] {
28
+ if (!existsSync(join(root, APP_CONFIG_FILE))) return [];
29
+ if (existsSync(join(root, MANIFEST_FILENAME))) return [];
30
+ return [
31
+ {
32
+ code: 'X_MANIFEST_MISSING',
33
+ cause: `${MANIFEST_FILENAME} does not exist, so every fact an agent reads about this app — route table, action schemas, policies, error codes — is unavailable`,
34
+ fix: 'x manifest',
35
+ docs: ERROR_DOCS_URL,
36
+ at: MANIFEST_FILENAME,
37
+ },
38
+ ];
39
+ }
40
+
41
+ /**
42
+ * Every way the committed files disagree with `manifest`, the app as the code declares it now:
43
+ * `x.manifest.json` missing (`X_MANIFEST_MISSING`) or drifted (`X_MANIFEST_DRIFT` — a body that no
44
+ * longer hashes to its own `buildId` included), and `openapi.json` or a bearer mount's document
45
+ * stale (`X_MANIFEST_STALE`). The spec is judged whether or not the manifest is there: it is a
46
+ * published contract on its own, and the typed client is generated from it.
47
+ */
48
+ export async function manifestStaleness(
49
+ root: string,
50
+ manifest: Manifest,
51
+ ): Promise<readonly Finding[]> {
52
+ const path = join(root, MANIFEST_FILENAME);
53
+ const findings: Finding[] = [...manifestMissingFindings(root)];
54
+ if (existsSync(path)) {
55
+ try {
56
+ await assertNoDrift({ manifest, path });
57
+ } catch (error) {
58
+ findings.push({ ...findingFrom(error), at: MANIFEST_FILENAME });
59
+ }
60
+ }
61
+ return [...findings, ...(await openApiStaleness(root, manifest))];
62
+ }
package/src/mcp-errors.ts CHANGED
@@ -51,7 +51,9 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
51
51
  'x verify --only unit --json # the finding carries the measured numbers and the "coverage" line to add to x.verify.json',
52
52
  X_COVERAGE_FLOOR_STALE:
53
53
  'x verify --only unit --json # the finding carries the measured numbers; raise "coverage" in x.verify.json to them',
54
- X_TEST_SHARD_FAILED: 'x test --workers 1 --json',
54
+ // Unthrown since `--worker` was deleted, and kept only because this record is typed over every
55
+ // registered code: `--shard i/n` is the one way to rerun a slice.
56
+ X_TEST_SHARD_FAILED: 'x verify --only unit --shard 1/2 --json',
55
57
  X_SCAFFOLD_PATH_ESCAPE: 'x g route posts --json # a path with no ".." segment',
56
58
  X_GENERATE_JSON_INVALID:
57
59
  'bun test packages/cli/src/cmd-generate.test.ts # the error names the template to fix',
@@ -118,8 +120,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
118
120
  'x errors list --json # register the code in its package src/errors.ts, or move its row under "Reserved codes"',
119
121
  X_ERROR_CODE_UNRESOLVED:
120
122
  'x verify --json # the finding names the file, the line and the name it could not resolve',
121
- X_ERROR_CODE_UNTHROWN:
122
- 'x errors explain X_ERROR_CODE_UNTHROWN --json # then mark the row "registered, thrown by nothing since <version>"',
123
+ X_ERROR_CODE_UNTHROWN: 'x errors explain X_ERROR_CODE_UNTHROWN --json',
123
124
  X_CLI_UNEXPECTED: 'x doctor --json',
124
125
  // `-p .`: an app root has no `references`, so the gate's own step runs this form (#450).
125
126
  X_TYPECHECK_FAILED: 'bunx tsc -p . --pretty false',
@@ -284,6 +285,14 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
284
285
  'edit docker/Dockerfile: add RUN bun node_modules/@ultimat3/cli/src/bin.ts build --target prebuilt after COPY . . in the runtime stage, then rebuild the image',
285
286
  X_ADMIN_UNSCANNED:
286
287
  'git mv apps/admin/src/index.ts apps/admin/app/admin/admin.ts # then repoint its relative imports and x verify --only manifest --json',
288
+ X_FRAMEWORK_TABLE_ORPHANED: 'x doctor --json',
289
+ X_BUILD_OUT_UNSAFE: 'x build --target static --out .x/static --json',
290
+ X_FRAMEWORK_SCHEMA_UNAPPLIED: 'x db migrate --json',
291
+ X_APP_URL_PORT_MISMATCH: 'export APP_URL=http://localhost:3000',
292
+ X_DEV_HOST_REFUSED: "curl -sS -H 'accept: application/json' http://localhost:3000/_x",
293
+ X_TRUSTED_PROXY_HOPS_INVALID: 'TRUSTED_PROXY_HOPS=1 ROLE=web bun apps/web/server.ts',
294
+ X_PERMISSION_BORROWED: 'x verify --only policy --json',
295
+ X_ERROR_CODE_UNTHROWN_STALE: 'x errors explain X_ERROR_CODE_UNTHROWN_STALE --json',
287
296
  };
288
297
 
289
298
  const isCliCode = (code: string): code is CliErrorCode =>
package/src/mcp-host.ts CHANGED
@@ -7,7 +7,13 @@
7
7
  // or prints inside a `fix:` an operator runs — the dev log, a per-role log, the committed
8
8
  // manifest — and string concatenation would answer a different path on a Windows checkout.
9
9
  import { join } from 'node:path';
10
- import { agentActor, isUltimateError, renderThrowable, UltimateError } from '@ultimat3/core';
10
+ import {
11
+ agentActor,
12
+ isUltimateError,
13
+ NotImplementedError,
14
+ renderThrowable,
15
+ UltimateError,
16
+ } from '@ultimat3/core';
11
17
  import type { DbClient } from '@ultimat3/db';
12
18
  import {
13
19
  ensureReadOnlyRole,
@@ -35,12 +41,11 @@ import { appManifest, policyFacts } from './app-manifest';
35
41
  import { runVerify, VERIFY_STEPS } from './cmd-verify';
36
42
  import { declareDevEnvironment } from './dev-environment';
37
43
  import { loadCodeFixes } from './error-fixes';
38
- import { BadFlagError, CliNotImplementedError } from './errors';
44
+ import { BadFlagError } from './errors';
39
45
  import type { Runner } from './exec';
40
- import { execOutput } from './exec';
41
46
  import { databaseTarget } from './mcp-db-target';
42
47
  import { explainErrorCode } from './mcp-errors';
43
- import { parseBunTest } from './mcp-test-output';
48
+ import { testRunOf } from './mcp-test-run';
44
49
  import { type UiCapabilities, uiCapabilities } from './mcp-ui';
45
50
  import { readMigrations } from './migrations';
46
51
  import { retryMemo } from './retry-memo';
@@ -107,8 +112,9 @@ export function lazyServices(input: DevHostInput): LazyServices {
107
112
  // A boot started after close() would hold the PGlite data directory for the life of the
108
113
  // process with nobody left to stop it — the host is closed, so the call is the bug.
109
114
  if (closed) {
110
- throw new CliNotImplementedError({
111
- feature: 'an MCP tool that needs the database after the host closed',
115
+ throw new NotImplementedError({
116
+ cause:
117
+ 'an MCP tool that needs the database after the host closed is not implemented in this build',
112
118
  fix: 'x mcp serve --transport stdio # keep the host open for the whole session',
113
119
  });
114
120
  }
@@ -271,7 +277,8 @@ function capabilities(
271
277
  cwd: root,
272
278
  ...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
273
279
  });
274
- return parseBunTest(execOutput(result), result.durationMs);
280
+ // The whole result, exit code included: `0 fail` beside `1 error` is a red run.
281
+ return testRunOf(result);
275
282
  },
276
283
 
277
284
  async tailLogs(lines: number, role: string | undefined) {
@@ -281,8 +288,8 @@ function capabilities(
281
288
  if (!(await file.exists())) {
282
289
  // `@ultimat3/core`'s `logger` is a module const with no sink seam, so there is nothing to
283
290
  // intercept in-process — a log on disk is the only honest source, and the fix creates one.
284
- throw new CliNotImplementedError({
285
- feature: `logs.tail without ${path}`,
291
+ throw new NotImplementedError({
292
+ cause: `logs.tail without ${path} is not implemented in this build`,
286
293
  fix:
287
294
  role === undefined
288
295
  ? `x dev > ${path} 2>&1`
@@ -0,0 +1,38 @@
1
+ // `test.run`'s answer from the whole `bun test` result, not from its pass/fail lines alone: bun
2
+ // counts an error raised OUTSIDE any test on a line of its own (`1 error`) beside `0 fail`, and
3
+ // says the run failed only through its exit code.
4
+
5
+ import type { TestRun } from '@ultimat3/mcp';
6
+ import type { ExecResult } from './exec';
7
+ import { execOutput } from './exec';
8
+ import { parseBunTest } from './mcp-test-output';
9
+
10
+ /** Bun's own `N error` / `N errors` summary line: the last one, as `parseBunTest` reads the rest. */
11
+ export const errorsIn = (output: string): number => {
12
+ const last = [...output.matchAll(/^\s*(\d+)\s+errors?\s*$/gm)].at(-1);
13
+ return last === undefined ? 0 : Number.parseInt(last[1] ?? '0', 10);
14
+ };
15
+
16
+ /**
17
+ * Every error outside a test is one failure, carried with the run's own message; a non-zero exit
18
+ * with nothing else counted is one more, so a red process can never answer `failed: 0`.
19
+ */
20
+ export function testRunOf(result: ExecResult): TestRun {
21
+ const output = execOutput(result);
22
+ const run = parseBunTest(output, result.durationMs);
23
+ const errors = errorsIn(output);
24
+ const outside = Array.from({ length: errors }, () => ({
25
+ test: 'bun test',
26
+ message: 'an error outside any test — a file that threw while loading, or a failed hook',
27
+ }));
28
+ const counted = run.failed + errors;
29
+ const exitOnly =
30
+ result.code !== 0 && counted === 0
31
+ ? [{ test: 'bun test', message: `bun test exited ${String(result.code)} with no failure` }]
32
+ : [];
33
+ return {
34
+ ...run,
35
+ failed: counted + exitOnly.length,
36
+ failures: [...run.failures, ...outside, ...exitOnly],
37
+ };
38
+ }
package/src/messages.ts CHANGED
@@ -197,6 +197,8 @@ const CATALOG = {
197
197
  'cli.registry.described': '{kind} {name}',
198
198
  'cli.routes.count': '{count} routes',
199
199
  'cli.routes.empty': 'no routes in the manifest — run `x manifest` first',
200
+ 'cli.auth.sealed':
201
+ 'sealed {sealed} second-factor secret(s); {already} already sealed, {skipped} changed mid-run and left alone',
200
202
  'cli.tasks.count': '{count} task(s)',
201
203
  'cli.tasks.shown': '{name} — {cron} ({tz}), next {next}',
202
204
  'cli.affected.count':
@@ -5,11 +5,14 @@
5
5
 
6
6
  import {
7
7
  finiteCount,
8
+ type HealthPayload,
9
+ healthzPayload,
8
10
  logger,
9
11
  METRICS_CONTENT_TYPE,
10
12
  METRICS_PATH,
11
13
  markListening,
12
14
  metricsText,
15
+ readyzPayload,
13
16
  startProcessMetrics,
14
17
  stringField,
15
18
  UltimateError,
@@ -123,6 +126,18 @@ export interface MetricsEndpoint {
123
126
  stop(): void;
124
127
  }
125
128
 
129
+ /**
130
+ * `/healthz` and `/readyz` for the roles whose only socket this is — `worker`, `scheduler`,
131
+ * `replicator` — so the replicator's readiness check (`role-replicator.ts`) is read by a probe.
132
+ * The verdict only, never the report: this port is unauthenticated, and check names are topology.
133
+ */
134
+ function healthResponse(payload: HealthPayload, role: string): Response {
135
+ return Response.json(
136
+ { state: payload.body.state, ready: payload.body.ready, role },
137
+ { status: payload.status, headers: { 'cache-control': 'no-store' } },
138
+ );
139
+ }
140
+
126
141
  /**
127
142
  * Answers outside the request pipeline, exactly as `/healthz` and `/readyz` do in
128
143
  * `@ultimat3/http`'s `server.ts`: no auth, no rate limit, no locale negotiation. A saturated or
@@ -142,7 +157,16 @@ export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): Metr
142
157
  port: candidate,
143
158
  hostname: options.hostname ?? 'localhost',
144
159
  fetch(request: Request): Response {
145
- if (new URL(request.url).pathname !== METRICS_PATH) {
160
+ const url = new URL(request.url);
161
+ const role = options.role ?? 'unknown';
162
+ if (url.pathname === '/healthz') return healthResponse(healthzPayload(), role);
163
+ if (url.pathname === '/readyz') {
164
+ return healthResponse(
165
+ readyzPayload({ deep: url.searchParams.get('deep') === '1' }),
166
+ role,
167
+ );
168
+ }
169
+ if (url.pathname !== METRICS_PATH) {
146
170
  return new Response('not found', { status: 404 });
147
171
  }
148
172
  // `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot
package/src/output.ts CHANGED
@@ -29,6 +29,12 @@ export interface Finding {
29
29
  * and must never be run as this CLI's own instruction.
30
30
  */
31
31
  readonly source?: 'ci-log';
32
+ /**
33
+ * Structured facts behind `cause`, for a `--json` reader that would otherwise parse the sentence:
34
+ * `X_VERIFY_STEP_TIMEOUT` carries the processes it killed and the files still in flight. Never
35
+ * the only home of a fact — the human render prints `cause` and `fix`, and both already say it.
36
+ */
37
+ readonly meta?: { readonly [key: string]: JsonValue };
32
38
  }
33
39
 
34
40
  export interface StepResult {
@@ -6,12 +6,9 @@
6
6
  // check a soft visit gets before `load`. No surface opted in: nothing is built, served or named — a
7
7
  // `site/` page that did not ask stays 0kb (axiom 6).
8
8
 
9
- // why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
10
- // necessity `theme-boot.ts` records.
11
- import { join } from 'node:path';
12
9
  import { clientScopeOf } from '@ultimat3/auth';
13
10
  import type { Ctx, NavigationSurface } from '@ultimat3/core';
14
- import { CLIENT_NAVIGATION_SCOPE_HEADER, NAVIGATION_SURFACES } from '@ultimat3/core';
11
+ import { CLIENT_NAVIGATION_SCOPE_HEADER } from '@ultimat3/core';
15
12
  import type { Route, RouteNavigation, UltimateRequest } from '@ultimat3/http';
16
13
  import { locationFor, navigationPurpose, relocate } from '@ultimat3/http';
17
14
  import type { ClientNavigationHead, HeadTag, RouteEntry } from '@ultimat3/render';
@@ -22,8 +19,7 @@ import {
22
19
  routeEntries,
23
20
  } from '@ultimat3/render';
24
21
  import { ssrHeaders } from '@ultimat3/render/server';
25
- import { APP_CONFIG_EXPORT } from './app-auth';
26
- import { APP_CONFIG_FILE } from './app-root';
22
+ import { loadAppConfig } from './app-config-load';
27
23
  import { FrameworkScriptBuildFailedError } from './errors';
28
24
  import {
29
25
  buildNavigationScript,
@@ -56,26 +52,14 @@ export interface NavigationDeclaration {
56
52
  readonly surfaces: readonly NavigationSurface[];
57
53
  }
58
54
 
59
- const isRecord = (value: unknown): value is Record<string, unknown> =>
60
- typeof value === 'object' && value !== null;
61
-
62
- const isSurface = (value: unknown): value is NavigationSurface =>
63
- NAVIGATION_SURFACES.some((surface) => surface === value);
64
-
65
55
  /**
66
- * `navigation.client` and `name`, as `defineConfig` validated them — no surfaces when the app says
67
- * nothing or has no config file. Structural, never `instanceof`, for `loadThemeMode`'s reason.
56
+ * `navigation.client` and `name`, off the one loader (`app-config-load.ts`) — no surfaces, and the
57
+ * app called `app`, for a root with no config file.
68
58
  */
69
59
  export async function loadNavigation(root: string): Promise<NavigationDeclaration> {
70
- const configPath = join(root, APP_CONFIG_FILE);
71
- if (!(await Bun.file(configPath).exists())) return { app: 'app', surfaces: [] };
72
- const module = (await import(configPath)) as Record<string, unknown>;
73
- const config = module[APP_CONFIG_EXPORT];
74
- if (!isRecord(config)) return { app: 'app', surfaces: [] };
75
- const app = typeof config['name'] === 'string' ? config['name'] : 'app';
76
- const navigation = config['navigation'];
77
- const client = isRecord(navigation) ? navigation['client'] : undefined;
78
- return { app, surfaces: Array.isArray(client) ? client.filter(isSurface) : [] };
60
+ const config = await loadAppConfig(root);
61
+ if (config === undefined) return { app: 'app', surfaces: [] };
62
+ return { app: config.name, surfaces: config.navigation.client };
79
63
  }
80
64
 
81
65
  /**
@@ -5,11 +5,8 @@
5
5
  // the next page's document on pointer rest, so a full-page navigation between prerendered pages
6
6
  // paints from memory — and the page still ships 0kb of JavaScript (axiom 6).
7
7
 
8
- // why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
9
- // necessity `theme-boot.ts` records.
10
- import { join } from 'node:path';
11
8
  import type { NavigationSurface, SpeculationConfig } from '@ultimat3/core';
12
- import { DEFAULT_SPECULATION, localeSegment, resolveSpeculation } from '@ultimat3/core';
9
+ import { DEFAULT_SPECULATION, localeSegment } from '@ultimat3/core';
13
10
  import { cspHashSource } from '@ultimat3/http';
14
11
  import { localeConfig, routedLocales } from '@ultimat3/i18n';
15
12
  import type { RouteDescriptor, RouteEntry } from '@ultimat3/render';
@@ -21,26 +18,14 @@ import {
21
18
  speculationRulesBody,
22
19
  speculationRulesTag,
23
20
  } from '@ultimat3/render';
24
- import { APP_CONFIG_EXPORT } from './app-auth';
25
- import { APP_CONFIG_FILE } from './app-root';
26
-
27
- const isRecord = (value: unknown): value is Record<string, unknown> =>
28
- typeof value === 'object' && value !== null;
21
+ import { loadAppConfig } from './app-config-load';
29
22
 
30
23
  /**
31
- * `navigation.speculation` — the default (on, `'moderate'`) when the app says nothing or has no
32
- * config file. Structural, for `loadThemeMode`'s reason, and so possibly NOT what `defineConfig`
33
- * validated: what it finds goes through core's `resolveSpeculation`, the one validator, which
34
- * REFUSES (`X_CONFIG_INVALID`) a value `defineConfig` would refuse. Never coerced: an `'eager'`
35
- * quietly served as `'moderate'` is a rule the app did not write.
24
+ * `navigation.speculation` off the one loader (`app-config-load.ts`), which holds it to core's
25
+ * `resolveSpeculation` — the default (on, `'moderate'`) for a root with no config file.
36
26
  */
37
27
  export async function loadSpeculation(root: string): Promise<SpeculationConfig> {
38
- const configPath = join(root, APP_CONFIG_FILE);
39
- if (!(await Bun.file(configPath).exists())) return DEFAULT_SPECULATION;
40
- const module = (await import(configPath)) as Record<string, unknown>;
41
- const config = module[APP_CONFIG_EXPORT];
42
- const navigation = isRecord(config) ? config['navigation'] : undefined;
43
- return resolveSpeculation(isRecord(navigation) ? navigation['speculation'] : undefined);
28
+ return (await loadAppConfig(root))?.navigation.speculation ?? DEFAULT_SPECULATION;
44
29
  }
45
30
 
46
31
  /**
@@ -1,25 +1,52 @@
1
1
  // The export directory is the build's own, and emptied before every build: `prerenderSite` only
2
2
  // ever ADDED files, so a route deleted from the app kept shipping its last HTML from `.x/static`.
3
- // Refused when `out` is the app root or above it — an `rm -rf` there deletes the app.
3
+ // Emptied only when it is provably the build's: absent, empty, or marked by an earlier build —
4
+ // `--out apps` deleted the app's pages and `--out .x` its embedded dev database.
4
5
 
5
- import { rm } from 'node:fs/promises'; // why: Bun has no recursive directory delete.
6
- import { isAbsolute, relative, resolve, sep } from 'node:path'; // why: Bun ships no path resolve/relative.
6
+ import { lstat, readdir, rm } from 'node:fs/promises'; // why: Bun has no recursive delete or readdir.
7
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'; // why: Bun ships no path resolve/relative.
7
8
  import { UltimateError } from '@ultimat3/core';
8
9
 
10
+ /** Written into every export directory a build empties: the proof the next build may empty it. */
11
+ export const EXPORT_MARKER = '.x-export';
12
+
13
+ /**
14
+ * The export directory `x build --target static` chooses itself. Exempt from the marker: a build
15
+ * from before the marker existed left it full and unmarked, and the build owns this path by name.
16
+ */
17
+ const DEFAULT_EXPORT = join('.x', 'static');
18
+
19
+ const MARKER_TEXT =
20
+ 'Written by x build --target static. This directory is emptied before every static build.\n';
21
+
9
22
  /** True when `dir` is `root` itself or one of its ancestors. */
10
23
  const containsRoot = (dir: string, root: string): boolean => {
11
24
  const up = relative(resolve(dir), resolve(root));
12
25
  return up === '' || (up !== '..' && !up.startsWith(`..${sep}`) && !isAbsolute(up));
13
26
  };
14
27
 
15
- /** Empties `out` for a fresh export, or refuses a directory that holds the app. */
28
+ /** Why `out` is not the build's to empty, or `undefined` when it is. */
29
+ async function unsafeReason(out: string, root: string): Promise<string | undefined> {
30
+ if (containsRoot(out, root)) return 'is the app root or holds it';
31
+ const stat = await lstat(out).catch(() => undefined);
32
+ if (stat === undefined) return undefined;
33
+ if (!stat.isDirectory()) return 'is not a directory';
34
+ const entries = await readdir(out);
35
+ if (entries.length === 0 || entries.includes(EXPORT_MARKER)) return undefined;
36
+ if (resolve(out) === resolve(root, DEFAULT_EXPORT)) return undefined;
37
+ return `already holds ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} and no ${EXPORT_MARKER} a previous build wrote`;
38
+ }
39
+
40
+ /** Empties `out` for a fresh export and marks it, or refuses a directory the build did not write. */
16
41
  export async function clearPrerenderOut(out: string, root: string): Promise<void> {
17
- if (containsRoot(out, root)) {
42
+ const reason = await unsafeReason(out, root);
43
+ if (reason !== undefined) {
18
44
  throw new UltimateError({
19
- code: 'X_CLI_BAD_FLAG',
20
- cause: `the static export directory ${resolve(out)} is the app root or holds it, and every build empties its export directory first`,
45
+ code: 'X_BUILD_OUT_UNSAFE',
46
+ cause: `the static export directory ${resolve(out)} ${reason}, and every build empties its export directory first`,
21
47
  fix: 'x build --target static --out .x/static --json',
22
48
  });
23
49
  }
24
50
  await rm(out, { recursive: true, force: true });
51
+ await Bun.write(join(out, EXPORT_MARKER), MARKER_TEXT);
25
52
  }