@ultimat3/cli 22.15.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 (267) hide show
  1. package/CLAUDE.md +36 -13
  2. package/README.md +31 -8
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/affected.ts +27 -35
  7. package/src/api-registration.ts +125 -11
  8. package/src/app-artifacts.ts +1 -1
  9. package/src/app-auth.ts +4 -27
  10. package/src/app-boundaries.ts +6 -20
  11. package/src/app-config-load.ts +51 -0
  12. package/src/app-env.ts +5 -11
  13. package/src/app-load.ts +55 -11
  14. package/src/app-manifest.ts +16 -8
  15. package/src/app-mcp.ts +10 -23
  16. package/src/app-permissions-borrowed.ts +129 -0
  17. package/src/app-permissions-site.ts +24 -0
  18. package/src/app-permissions.ts +0 -0
  19. package/src/app-reload-graph.ts +7 -30
  20. package/src/app-transport.ts +219 -0
  21. package/src/boundary-findings.ts +16 -4
  22. package/src/browser-transport.ts +160 -0
  23. package/src/budgets.ts +27 -34
  24. package/src/cdp-shot-driver.ts +3 -1
  25. package/src/cmd-auth-spec.ts +16 -0
  26. package/src/cmd-auth.ts +65 -0
  27. package/src/cmd-build-spec.ts +10 -3
  28. package/src/cmd-build.ts +107 -11
  29. package/src/cmd-db-backfill.ts +25 -12
  30. package/src/cmd-db.ts +66 -16
  31. package/src/cmd-deploy-helm.ts +7 -6
  32. package/src/cmd-deploy.ts +4 -4
  33. package/src/cmd-dev.ts +9 -8
  34. package/src/cmd-doctor-spec.ts +2 -9
  35. package/src/cmd-doctor.ts +75 -24
  36. package/src/cmd-env.ts +26 -2
  37. package/src/cmd-generate.ts +195 -47
  38. package/src/cmd-help.ts +19 -6
  39. package/src/cmd-i18n.ts +10 -9
  40. package/src/cmd-jobs-spec.ts +19 -3
  41. package/src/cmd-jobs.ts +136 -7
  42. package/src/cmd-manifest-spec.ts +1 -1
  43. package/src/cmd-manifest.ts +6 -25
  44. package/src/cmd-mcp.ts +46 -15
  45. package/src/cmd-new.ts +15 -1
  46. package/src/cmd-planned.ts +15 -8
  47. package/src/cmd-routes.ts +27 -3
  48. package/src/cmd-secrets-ring.ts +76 -0
  49. package/src/cmd-secrets-spec.ts +10 -2
  50. package/src/cmd-secrets.ts +95 -18
  51. package/src/cmd-shot-matrix.ts +1 -3
  52. package/src/cmd-shot.ts +12 -3
  53. package/src/cmd-tasks.ts +84 -15
  54. package/src/cmd-test-spec.ts +1 -7
  55. package/src/cmd-test.ts +13 -29
  56. package/src/cmd-verify.ts +13 -5
  57. package/src/comment-safe.ts +16 -0
  58. package/src/coverage-floor.ts +334 -0
  59. package/src/coverage-lcov.ts +193 -0
  60. package/src/coverage-source.ts +136 -0
  61. package/src/db-object-drift.ts +30 -0
  62. package/src/db-replay-engine.ts +115 -0
  63. package/src/db-schema-dump.ts +150 -0
  64. package/src/db-schema-refresh.ts +122 -0
  65. package/src/db-subscribes.ts +12 -4
  66. package/src/dev-dashboard-guard.ts +76 -0
  67. package/src/dev-dashboard.ts +51 -29
  68. package/src/dev-lock.ts +4 -7
  69. package/src/dev-route-table.ts +9 -0
  70. package/src/dispatch.ts +3 -6
  71. package/src/doctor-app-url.ts +39 -0
  72. package/src/doctor-auth.ts +95 -0
  73. package/src/doctor-coverage.ts +39 -0
  74. package/src/doctor-guards.ts +54 -0
  75. package/src/doctor-sealed.ts +132 -0
  76. package/src/document-graph.ts +82 -0
  77. package/src/drift-replica-identity.ts +42 -0
  78. package/src/error-catalog.ts +6 -9
  79. package/src/error-codes.ts +55 -3
  80. package/src/error-unthrown.ts +29 -11
  81. package/src/errors.ts +54 -83
  82. package/src/framework-schema-apply.ts +78 -0
  83. package/src/framework-schema-stamp.ts +96 -0
  84. package/src/framework-schema.ts +28 -2
  85. package/src/generate-files.ts +79 -12
  86. package/src/generate-grant-findings.ts +76 -0
  87. package/src/generate-grants.ts +77 -3
  88. package/src/generate-kinds.ts +52 -1
  89. package/src/generate-shadow.ts +46 -0
  90. package/src/generate-write.ts +62 -40
  91. package/src/generated-imports.ts +59 -0
  92. package/src/gh-target.ts +12 -7
  93. package/src/guard-sources.ts +104 -0
  94. package/src/guards.ts +23 -7
  95. package/src/handle-registration.ts +217 -0
  96. package/src/i18n-audit.ts +4 -1
  97. package/src/i18n-registration.ts +36 -0
  98. package/src/image-prepare.ts +51 -8
  99. package/src/import-closure.ts +165 -0
  100. package/src/index.ts +12 -5
  101. package/src/island-bundle.ts +3 -15
  102. package/src/island-store.ts +24 -16
  103. package/src/jobs-drain.ts +95 -21
  104. package/src/jobs-json.ts +50 -0
  105. package/src/jobs-report.ts +7 -4
  106. package/src/json-merge.ts +46 -0
  107. package/src/loopback-host.ts +13 -0
  108. package/src/manifest-staleness.ts +62 -0
  109. package/src/mcp-errors.ts +41 -4
  110. package/src/mcp-host.ts +16 -9
  111. package/src/mcp-test-run.ts +38 -0
  112. package/src/messages.ts +35 -0
  113. package/src/metrics-endpoint.ts +95 -24
  114. package/src/migration-extensions.ts +29 -0
  115. package/src/module-imports.ts +47 -0
  116. package/src/output.ts +24 -9
  117. package/src/page-navigation.ts +7 -23
  118. package/src/page-speculation.ts +5 -20
  119. package/src/permission-grants.ts +41 -9
  120. package/src/prerender-out.ts +34 -7
  121. package/src/prerender.ts +8 -5
  122. package/src/publish-closure.ts +101 -0
  123. package/src/pwa-artifacts.ts +26 -94
  124. package/src/registry.ts +2 -0
  125. package/src/revalidated-response.ts +4 -3
  126. package/src/role-load.ts +143 -0
  127. package/src/role-replicator.ts +16 -0
  128. package/src/role-start.ts +27 -5
  129. package/src/role-sync.ts +55 -20
  130. package/src/role-wake.ts +45 -0
  131. package/src/runtime-admin.ts +141 -0
  132. package/src/runtime-assets.ts +11 -7
  133. package/src/runtime-bindings.ts +4 -4
  134. package/src/runtime-cache.ts +38 -47
  135. package/src/runtime-hooks.ts +5 -2
  136. package/src/runtime-idempotency-scope.ts +23 -0
  137. package/src/runtime-isr.ts +36 -0
  138. package/src/runtime-jobs.ts +8 -33
  139. package/src/runtime-mfa-warning.ts +29 -0
  140. package/src/runtime-notify-retention.ts +10 -46
  141. package/src/runtime-overrides.ts +1 -1
  142. package/src/runtime-purge.ts +38 -16
  143. package/src/runtime-queue.ts +39 -11
  144. package/src/runtime-realtime.ts +9 -45
  145. package/src/runtime-render.ts +68 -35
  146. package/src/runtime-services.ts +22 -18
  147. package/src/runtime-storage.ts +10 -5
  148. package/src/scaffold-typecheck.ts +1 -0
  149. package/src/schema-diff.ts +16 -0
  150. package/src/schema-drift.ts +45 -15
  151. package/src/schema-dump-drift.ts +64 -0
  152. package/src/seo-routes.ts +80 -9
  153. package/src/serve-boot.ts +114 -158
  154. package/src/serve-drain.ts +9 -33
  155. package/src/serve-env.ts +9 -6
  156. package/src/serve-prebuilt-paths.ts +19 -0
  157. package/src/serve-prebuilt.ts +84 -0
  158. package/src/serve-web.ts +169 -0
  159. package/src/serve.ts +6 -9
  160. package/src/server-barrels.ts +54 -0
  161. package/src/shot-locale.ts +5 -23
  162. package/src/site-assets.ts +3 -5
  163. package/src/site-config.ts +9 -48
  164. package/src/site-seo.ts +1 -1
  165. package/src/sitemap-lastmod.ts +5 -0
  166. package/src/solid-loader.ts +12 -3
  167. package/src/source-edit.ts +49 -0
  168. package/src/source-files.ts +6 -0
  169. package/src/stored-object-headers.ts +43 -0
  170. package/src/sync-url.ts +2 -2
  171. package/src/tasks-facts.ts +3 -26
  172. package/src/templates/action.ts +147 -11
  173. package/src/templates/admin-catalog.ts +58 -0
  174. package/src/templates/admin-page.ts +36 -8
  175. package/src/templates/admin.ts +57 -17
  176. package/src/templates/backfill.ts +121 -87
  177. package/src/templates/entity.ts +140 -44
  178. package/src/templates/guard-animated-layout-property.ts +4 -11
  179. package/src/templates/guard-bare-error.ts +9 -13
  180. package/src/templates/guard-focus-visible.ts +4 -11
  181. package/src/templates/guard-image-dimensions.ts +6 -6
  182. package/src/templates/guard-island-without-states.ts +2 -4
  183. package/src/templates/guard-raw-breakpoint.ts +210 -0
  184. package/src/templates/guard-raw-colour.ts +4 -11
  185. package/src/templates/guard-raw-length.ts +375 -0
  186. package/src/templates/guard-raw-motion.ts +213 -0
  187. package/src/templates/guard-raw-shadow.ts +187 -0
  188. package/src/templates/guard-raw-z-index.ts +166 -0
  189. package/src/templates/guard-repo-raw-sql.ts +345 -0
  190. package/src/templates/guard-semantic-interactive.ts +6 -6
  191. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  192. package/src/templates/guard-undefined-style-class.ts +215 -0
  193. package/src/templates/guard-untranslated-string.ts +6 -6
  194. package/src/templates/guard-unzoned-date.ts +9 -13
  195. package/src/templates/index.ts +3 -2
  196. package/src/templates/island.ts +34 -51
  197. package/src/templates/job-body-test.ts +121 -0
  198. package/src/templates/job.ts +23 -8
  199. package/src/templates/naming.ts +28 -0
  200. package/src/templates/policy.ts +16 -9
  201. package/src/templates/query.ts +242 -26
  202. package/src/templates/resource-create.ts +62 -18
  203. package/src/templates/resource-form-island.ts +58 -80
  204. package/src/templates/resource-page.ts +269 -0
  205. package/src/templates/resource-service.ts +96 -0
  206. package/src/templates/resource.ts +86 -61
  207. package/src/templates/route.ts +41 -35
  208. package/src/templates/scaffold-api.ts +55 -1
  209. package/src/templates/scaffold-app.ts +177 -53
  210. package/src/templates/scaffold-auth.ts +83 -19
  211. package/src/templates/scaffold-browser-client.ts +90 -0
  212. package/src/templates/scaffold-container-compose.ts +10 -1
  213. package/src/templates/scaffold-container.ts +20 -2
  214. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  215. package/src/templates/scaffold-dashboard-example.ts +124 -31
  216. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  217. package/src/templates/scaffold-dashboard.ts +1 -2
  218. package/src/templates/scaffold-db-client.ts +144 -0
  219. package/src/templates/scaffold-db-package.ts +90 -8
  220. package/src/templates/scaffold-docs.ts +50 -5
  221. package/src/templates/scaffold-guards.ts +54 -11
  222. package/src/templates/scaffold-helm-fences.ts +76 -0
  223. package/src/templates/scaffold-helm-templates.ts +32 -7
  224. package/src/templates/scaffold-helm.ts +31 -1
  225. package/src/templates/scaffold-http.ts +9 -7
  226. package/src/templates/scaffold-repo.ts +61 -3
  227. package/src/templates/scaffold-roles.ts +56 -9
  228. package/src/templates/scaffold-shell.ts +74 -105
  229. package/src/templates/scaffold-site.ts +45 -19
  230. package/src/templates/scaffold-ui-package.ts +18 -0
  231. package/src/templates/slice-foundation.ts +46 -8
  232. package/src/test-counts.ts +14 -2
  233. package/src/test-ignores.ts +25 -0
  234. package/src/test-passes.ts +0 -15
  235. package/src/test-select.ts +5 -14
  236. package/src/test-shards.ts +41 -93
  237. package/src/test-workers.ts +26 -4
  238. package/src/theme-boot.ts +6 -31
  239. package/src/transport-calls.ts +105 -0
  240. package/src/trusted-hops-error.ts +17 -0
  241. package/src/unscanned-admin.ts +54 -0
  242. package/src/unthrown-codes.ts +26 -0
  243. package/src/verify-checks.ts +43 -53
  244. package/src/verify-coverage-run.ts +190 -0
  245. package/src/verify-deadline.ts +353 -0
  246. package/src/verify-floor.ts +36 -9
  247. package/src/verify-merge.ts +171 -45
  248. package/src/verify-part-step.ts +90 -0
  249. package/src/verify-progress.ts +25 -0
  250. package/src/verify-role-load-probe.ts +54 -0
  251. package/src/verify-role-load.ts +148 -0
  252. package/src/verify-run.ts +116 -37
  253. package/src/verify-shard.ts +33 -11
  254. package/src/verify-stalled.ts +152 -0
  255. package/src/verify-step.ts +42 -1
  256. package/src/verify-tests.ts +83 -8
  257. package/src/workspace-checks.ts +50 -8
  258. package/src/workspace-dep-edit.ts +127 -0
  259. package/src/build-errors.ts +0 -54
  260. package/src/cmd-dev-fixture.ts +0 -204
  261. package/src/dev-live-fixture.ts +0 -122
  262. package/src/policy-fixture.ts +0 -78
  263. package/src/realtime-browser-probe-fixture.ts +0 -9
  264. package/src/role-start-fixture.ts +0 -78
  265. package/src/scaffold-fixture.ts +0 -160
  266. package/src/templates/island-fixture.ts +0 -76
  267. package/src/verify-errors.ts +0 -49
@@ -25,25 +25,68 @@ import {
25
25
  SecretsPlaintextInvalidError,
26
26
  secretsFileExists,
27
27
  serializeSecretValues,
28
+ UltimateError,
28
29
  writeMasterKeyFile,
29
30
  writeSecretsFile,
30
31
  } from '@ultimat3/core';
31
32
  import { ENV_SCHEMA_EXPORT, loadEnvSchema } from './app-env';
32
33
  import { requireAppRoot } from './app-root';
34
+ import { appSecrets, dropRetired, retire, retiredKeyIds } from './cmd-secrets-ring';
33
35
  import { secretsSpec } from './cmd-secrets-spec';
34
36
  import type { CliCommand, CommandContext } from './command';
35
- import {
36
- MissingPositionalError,
37
- SecretsEditFailedError,
38
- SecretsEditorMissingError,
39
- SecretsExistsError,
40
- } from './errors';
37
+ import { MissingPositionalError } from './errors';
41
38
  import { msg } from './messages';
42
39
  import type { CommandResult, JsonValue } from './output';
40
+ import { flagString } from './parse';
43
41
  import { recoverRotation, rotateMasterKey } from './secrets-rotation';
44
42
  import { shredOnSignal } from './signal-shred';
45
43
  import { renderTable } from './table';
46
44
 
45
+ /**
46
+ * `x secrets edit` decrypts into a buffer and hands it to `$EDITOR`. There is no fallback editor:
47
+ * guessing one and opening a decrypted file in it is the last place a surprise belongs.
48
+ */
49
+ export class SecretsEditorMissingError extends UltimateError {
50
+ constructor(input: { vars: readonly string[] }) {
51
+ super({
52
+ code: 'X_SECRETS_EDITOR_MISSING',
53
+ cause: `x secrets edit opens the decrypted secrets in an editor and none of ${input.vars.join(', ')} is set`,
54
+ fix: 'EDITOR=nano x secrets edit',
55
+ });
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The editor exited non-zero — a crash, or a deliberate abort. The buffer is discarded either way
61
+ * and the committed file is left exactly as it was: resealing a buffer whose editor failed would
62
+ * commit whatever half-written state the crash left behind.
63
+ */
64
+ export class SecretsEditFailedError extends UltimateError {
65
+ constructor(input: { editor: string; code: number }) {
66
+ super({
67
+ code: 'X_SECRETS_EDIT_FAILED',
68
+ cause: `"${input.editor}" exited ${input.code}, so the decrypted buffer was discarded and the committed secrets file was not rewritten`,
69
+ fix: 'x secrets edit',
70
+ });
71
+ }
72
+ }
73
+
74
+ /**
75
+ * `x secrets init` would overwrite a file that already exists. `X_GENERATE_CONFLICT` is this
76
+ * package's own code for exactly that, and a second name for "a generator would clobber something"
77
+ * is the duplication the code registry exists to prevent. Losing a master key is unrecoverable —
78
+ * the committed file it opens is then ciphertext nobody can read again.
79
+ */
80
+ export class SecretsExistsError extends UltimateError {
81
+ constructor(input: { path: string; fix: string }) {
82
+ super({
83
+ code: 'X_GENERATE_CONFLICT',
84
+ cause: `${input.path} already exists, and x secrets init would replace it`,
85
+ fix: input.fix,
86
+ });
87
+ }
88
+ }
89
+
47
90
  export { SECRETS_SUBCOMMANDS } from './cmd-secrets-spec';
48
91
 
49
92
  /** In order of precedence. `VISUAL` outranks `EDITOR` by POSIX convention on an interactive tty. */
@@ -244,36 +287,65 @@ async function set(ctx: CommandContext, io: SecretsIo): Promise<CommandResult> {
244
287
  };
245
288
  }
246
289
 
290
+ /**
291
+ * The closing half of a rotation: one retired key leaves the ring, by id, once nothing sealed
292
+ * under it is left. No new key is generated and the key file is not touched — the committed file
293
+ * is resealed under the key already in force.
294
+ */
295
+ async function drop(session: Session, keyId: string): Promise<CommandResult> {
296
+ const { root, key } = session;
297
+ const after = await dropRetired(await readSecretsFile(root, key), keyId);
298
+ await writeSecretsFile(root, after, key);
299
+ const retired = await retiredKeyIds(after);
300
+ return {
301
+ ok: true,
302
+ command: 'secrets',
303
+ summary: msg('cli.secrets.dropped', { path: SECRETS_FILE, kid: keyId, count: retired.length }),
304
+ data: {
305
+ path: SECRETS_FILE,
306
+ keyId: await masterKeyIdOf(key),
307
+ dropped: keyId,
308
+ retiredKeyIds: [...retired],
309
+ },
310
+ };
311
+ }
312
+
247
313
  /**
248
314
  * A new master key over the same values: staged beside the old one, the file sealed with it, then
249
315
  * made live (`rotateMasterKey`). Sealed first with the key file written last, a crash between the
250
316
  * two left a committed file no key on disk could open.
317
+ *
318
+ * The key being replaced joins the ring INSIDE the sealed file (`cmd-secrets-ring.ts`), so a value
319
+ * `seal()` wrote under it still opens and a deploy is still handed exactly one key.
251
320
  */
252
321
  async function rotate(ctx: CommandContext): Promise<CommandResult> {
253
- const { root, key } = await open(ctx, 'rotate');
254
- const values = await readSecretsFile(root, key);
322
+ const session = await open(ctx, 'rotate');
323
+ const dropping = flagString(ctx.args, 'drop');
324
+ if (dropping !== undefined) return drop(session, dropping);
325
+ const { root, key } = session;
326
+ const values = retire(await readSecretsFile(root, key), key.hex);
255
327
  const previous = await masterKeyIdOf(key);
256
328
  // Ignored BEFORE any key file is written, staged one included; the order inside is
257
329
  // `secrets-rotation.ts`'s: stage the key, seal, then rename it live.
258
330
  await ensureIgnored(root);
259
331
  const next = await rotateMasterKey(root, values);
260
332
  const keyId = await masterKeyIdOf(next);
333
+ const count = Object.keys(appSecrets(values)).length;
261
334
  return {
262
335
  ok: true,
263
336
  command: 'secrets',
264
- summary: msg('cli.secrets.rotated', {
265
- path: SECRETS_FILE,
266
- from: previous,
267
- to: keyId,
268
- count: Object.keys(values).length,
269
- }),
270
- lines: [msg('cli.secrets.redeploy', { env: SECRETS_KEY_ENV, keyPath: SECRETS_KEY_FILE })],
337
+ summary: msg('cli.secrets.rotated', { path: SECRETS_FILE, from: previous, to: keyId, count }),
338
+ lines: [
339
+ msg('cli.secrets.redeploy', { env: SECRETS_KEY_ENV, keyPath: SECRETS_KEY_FILE }),
340
+ msg('cli.secrets.retired', { kid: previous }),
341
+ ],
271
342
  data: {
272
343
  path: SECRETS_FILE,
273
344
  keyPath: SECRETS_KEY_FILE,
274
345
  previousKeyId: previous,
275
346
  keyId,
276
- count: Object.keys(values).length,
347
+ count,
348
+ retiredKeyIds: [...(await retiredKeyIds(values))],
277
349
  },
278
350
  };
279
351
  }
@@ -285,9 +357,12 @@ async function rotate(ctx: CommandContext): Promise<CommandResult> {
285
357
  */
286
358
  async function show(ctx: CommandContext): Promise<CommandResult> {
287
359
  const { root, key } = await open(ctx, 'show');
288
- const values = await readSecretsFile(root, key);
360
+ const sealed = await readSecretsFile(root, key);
361
+ // The ring is the framework's own entry: reported by key id below, never as an app secret that
362
+ // `envSchema` forgot to declare.
363
+ const retired = await retiredKeyIds(sealed);
289
364
  const schema = await loadEnvSchema(root);
290
- const summaries = describeSecrets(values);
365
+ const summaries = describeSecrets(appSecrets(sealed));
291
366
  const keyId = await masterKeyIdOf(key);
292
367
  const declared = (name: string): boolean | undefined =>
293
368
  schema === undefined ? undefined : schema[name] !== undefined;
@@ -309,6 +384,7 @@ async function show(ctx: CommandContext): Promise<CommandResult> {
309
384
  : [
310
385
  msg('cli.secrets.undeclared', { count: undeclared.length, names: undeclared.join(', ') }),
311
386
  ]),
387
+ ...(retired.length === 0 ? [] : [msg('cli.secrets.ring', { kids: retired.join(', ') })]),
312
388
  ];
313
389
  return {
314
390
  ok: true,
@@ -322,6 +398,7 @@ async function show(ctx: CommandContext): Promise<CommandResult> {
322
398
  path: SECRETS_FILE,
323
399
  keySource: key.source,
324
400
  keyId,
401
+ retiredKeyIds: [...retired],
325
402
  count: summaries.length,
326
403
  secrets: summaries.map((entry) => ({
327
404
  name: entry.name,
@@ -5,6 +5,7 @@
5
5
 
6
6
  // why: no Bun native joins a path.
7
7
  import { join, relative } from 'node:path';
8
+ import { escapeHtml } from '@ultimat3/core';
8
9
  import { routeEntries } from '@ultimat3/render';
9
10
  import { loadApp } from './app-load';
10
11
  import type { ShotRun } from './cmd-shot';
@@ -94,9 +95,6 @@ export interface MatrixShot {
94
95
  readonly image: string;
95
96
  }
96
97
 
97
- const escapeHtml = (text: string): string =>
98
- text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
99
-
100
98
  /**
101
99
  * The contact sheet. System colours only (`Canvas`, `CanvasText`, `Mark`): a generated review page
102
100
  * has no design tokens to read, and a literal colour would be the one this repo refuses.
package/src/cmd-shot.ts CHANGED
@@ -339,6 +339,14 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
339
339
  }
340
340
  }
341
341
 
342
+ /**
343
+ * Where the artifacts land. A relative `--out` is a path the caller typed from where they stand,
344
+ * so it resolves against the cwd — `x build --out`'s rule. Against the app root it landed
345
+ * somewhere else whenever `x shot` ran from `apps/web`.
346
+ */
347
+ export const shotOutDir = (cwd: string, out: string | undefined, fallback: string): string =>
348
+ out === undefined ? fallback : resolve(cwd, out);
349
+
342
350
  export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
343
351
  ok: artifacts.verdict.ok,
344
352
  command: 'shot',
@@ -426,7 +434,8 @@ export const shotCommand: CliCommand = {
426
434
  const boot = (): Promise<ShotServer> => devServerFor(root, ctx.env, port);
427
435
  const shared = {
428
436
  root,
429
- ...(out === undefined ? {} : { out }),
437
+ // Absolute here, so the island sweep's own `resolve(root, out)` keeps the caller's path.
438
+ ...(out === undefined ? {} : { out: shotOutDir(ctx.cwd, out, root) }),
430
439
  settleMs,
431
440
  timeoutMs,
432
441
  ...(executablePath === undefined ? {} : { executablePath }),
@@ -465,7 +474,7 @@ export const shotCommand: CliCommand = {
465
474
  defaultLocale: app.defaultLocale,
466
475
  ...(theme === undefined ? {} : { themes: [theme] }),
467
476
  });
468
- const outDir = out === undefined ? join(root, MATRIX_DIR) : resolve(root, out);
477
+ const outDir = shotOutDir(ctx.cwd, out, join(root, MATRIX_DIR));
469
478
  return runShotMatrix({ cells, outDir, boot, shoot: runShot, base });
470
479
  }
471
480
  const shown = locale ?? app.defaultLocale;
@@ -474,7 +483,7 @@ export const shotCommand: CliCommand = {
474
483
  await runShot({
475
484
  ...base,
476
485
  route: path,
477
- outDir: out === undefined ? join(root, SHOT_DIR, shotSlug(path)) : resolve(root, out),
486
+ outDir: shotOutDir(ctx.cwd, out, join(root, SHOT_DIR, shotSlug(path))),
478
487
  boot,
479
488
  // Pinned whether or not `--locale` was given: the default locale, never the box's own.
480
489
  acceptLanguage: shown,
package/src/cmd-tasks.ts CHANGED
@@ -1,16 +1,19 @@
1
1
  // `x tasks [list|show <name>]` — introspect scheduled cron tasks: cadence, timezone, the jobs
2
- // each enqueues, and real next-occurrence instants from `@ultimat3/time`'s cron math instead of
3
- // an agent reading `0 3 * * *` and guessing. CLI wiring only; the pure computation lives in
4
- // `tasks-facts.ts` — the same split `cmd-jobs.ts` makes against `jobs-report.ts`.
2
+ // each enqueues, the occurrence the scheduler LAST dispatched (off the queue's own record) and
3
+ // real next-occurrence instants from `@ultimat3/time`'s cron math instead of an agent reading
4
+ // `0 3 * * *` and guessing. CLI wiring only; the pure computation lives in `tasks-facts.ts` — the
5
+ // same split `cmd-jobs.ts` makes against `jobs-report.ts`.
5
6
 
6
7
  import { nearestName, systemClock } from '@ultimat3/core';
7
- import type { TaskHandle } from '@ultimat3/jobs';
8
+ import type { JobDriver, TaskFire, TaskHandle } from '@ultimat3/jobs';
8
9
  import type { CronPhrases } from '@ultimat3/time';
10
+ import { fromEpochMs, isoInZone } from '@ultimat3/time';
9
11
  import { loadApp } from './app-load';
10
12
  import { requireAppRoot } from './app-root';
11
13
  import { tasksSpec } from './cmd-tasks-spec';
12
14
  import type { CliCommand, CommandContext } from './command';
13
15
  import { BadFlagError, DeclarationUnknownError } from './errors';
16
+ import { withJobDriver } from './jobs-driver';
14
17
  import { msg } from './messages';
15
18
  import type { CommandResult, Finding, JsonValue } from './output';
16
19
  import { flagString } from './parse';
@@ -24,7 +27,41 @@ import {
24
27
  taskShowFacts,
25
28
  } from './tasks-facts';
26
29
 
27
- const HEADER = ['name', 'cron', 'tz', 'catchUp', 'jobs', 'next'] as const;
30
+ const HEADER = ['name', 'cron', 'tz', 'catchUp', 'jobs', 'last', 'next'] as const;
31
+
32
+ /**
33
+ * When a task last fired, flat beside `nextMs` / `next`. All three are `null` for a task the
34
+ * scheduler has never dispatched — a key that is sometimes absent is a question asked two ways.
35
+ */
36
+ interface LastFire {
37
+ /** Epoch ms of the occurrence it was scheduled FOR — the instant `last` renders. */
38
+ readonly lastMs: number | null;
39
+ readonly last: string | null;
40
+ /** Epoch ms the scheduler dispatched it, on the queue's clock. Later than `lastMs` by its lag. */
41
+ readonly lastFiredAtMs: number | null;
42
+ }
43
+
44
+ const NEVER_FIRED: LastFire = { lastMs: null, last: null, lastFiredAtMs: null };
45
+
46
+ /**
47
+ * The last occurrence each task DISPATCHED, by task name — `JobIntrospection.taskFires()`, never
48
+ * the scheduler's watermark, which arming and skipping move too. A driver with no introspection
49
+ * answers none, and every task then reads as never fired.
50
+ */
51
+ async function lastFires(driver: JobDriver): Promise<ReadonlyMap<string, TaskFire>> {
52
+ const fires = (await driver.introspect?.taskFires()) ?? [];
53
+ return new Map(fires.map((fire) => [fire.task, fire]));
54
+ }
55
+
56
+ const lastFireOf = (fires: ReadonlyMap<string, TaskFire>, name: string, tz: string): LastFire => {
57
+ const fire = fires.get(name);
58
+ if (fire === undefined) return NEVER_FIRED;
59
+ return {
60
+ lastMs: fire.occurrenceMs,
61
+ last: isoInZone(fromEpochMs(fire.occurrenceMs), tz),
62
+ lastFiredAtMs: fire.firedAt,
63
+ };
64
+ };
28
65
 
29
66
  /**
30
67
  * The vocabulary `describeCron` interpolates. The cron *math* stays in `tasks-facts.ts` — it is
@@ -57,17 +94,25 @@ const detailLines = (payload: Readonly<Record<string, JsonValue>>): readonly str
57
94
 
58
95
  const jobsCell = (jobs: readonly string[]): string => (jobs.length === 0 ? '-' : jobs.join(','));
59
96
 
60
- const row = (fact: TaskFact): readonly string[] => [
97
+ const row = (fact: TaskFact & LastFire): readonly string[] => [
61
98
  fact.name,
62
99
  fact.cron,
63
100
  fact.tz,
64
101
  fact.catchUp,
65
102
  jobsCell(fact.jobs),
103
+ fact.last ?? '-',
66
104
  fact.next,
67
105
  ];
68
106
 
69
- function runList(nowMs: number, findings: readonly Finding[]): CommandResult {
70
- const facts = listTaskFacts(nowMs);
107
+ function runList(
108
+ nowMs: number,
109
+ fires: ReadonlyMap<string, TaskFire>,
110
+ findings: readonly Finding[],
111
+ ): CommandResult {
112
+ const facts = listTaskFacts(nowMs).map((fact) => ({
113
+ ...fact,
114
+ ...lastFireOf(fires, fact.name, fact.tz),
115
+ }));
71
116
  return {
72
117
  ok: findings.length === 0,
73
118
  command: 'tasks',
@@ -101,13 +146,23 @@ function requireHandle(ctx: CommandContext): TaskHandle {
101
146
  );
102
147
  }
103
148
 
104
- function runShow(ctx: CommandContext, nowMs: number, findings: readonly Finding[]): CommandResult {
105
- const handle = requireHandle(ctx);
106
- const count = parseCountFlag(flagString(ctx.args, 'count'));
107
- const { descriptor, describe, upcoming } = taskShowFacts(handle, nowMs, count, cronPhrases());
149
+ function runShow(
150
+ shown: { readonly handle: TaskHandle; readonly count: number },
151
+ nowMs: number,
152
+ fires: ReadonlyMap<string, TaskFire>,
153
+ findings: readonly Finding[],
154
+ ): CommandResult {
155
+ const { descriptor, describe, upcoming } = taskShowFacts(
156
+ shown.handle,
157
+ nowMs,
158
+ shown.count,
159
+ cronPhrases(),
160
+ );
108
161
  const first = upcoming[0];
162
+ const fired = lastFireOf(fires, descriptor.name, descriptor.tz);
109
163
  const lines = [
110
164
  ...detailLines(asJson(descriptor)),
165
+ ` last: ${fired.last ?? '-'}`,
111
166
  ` ${describe}`,
112
167
  ...upcoming.map((occurrence) => ` ${occurrence.at}`),
113
168
  ];
@@ -124,6 +179,7 @@ function runShow(ctx: CommandContext, nowMs: number, findings: readonly Finding[
124
179
  findings,
125
180
  data: {
126
181
  ...asJson(descriptor),
182
+ ...fired,
127
183
  describe,
128
184
  upcoming: upcoming.map((occurrence) => asJson(occurrence)),
129
185
  },
@@ -136,8 +192,21 @@ export const tasksCommand: CliCommand = {
136
192
  const root = requireAppRoot('tasks', ctx.cwd).dir;
137
193
  const { findings } = await loadApp(root);
138
194
  const nowMs = systemClock.now().getTime();
139
- return ctx.args.subcommand === 'show'
140
- ? runShow(ctx, nowMs, findings)
141
- : runList(nowMs, findings);
195
+ // Everything argv can get wrong is refused BEFORE the queue is opened: a typo in a task name
196
+ // must not cost a database boot to be told about.
197
+ const shown =
198
+ ctx.args.subcommand === 'show'
199
+ ? { handle: requireHandle(ctx), count: parseCountFlag(flagString(ctx.args, 'count')) }
200
+ : undefined;
201
+ // Nothing to join a fire to: an app that declares no task never pays for a queue.
202
+ if (shown === undefined && knownTaskNames().length === 0) {
203
+ return runList(nowMs, new Map(), findings);
204
+ }
205
+ return withJobDriver(root, ctx, async (driver) => {
206
+ const fires = await lastFires(driver);
207
+ return shown === undefined
208
+ ? runList(nowMs, fires, findings)
209
+ : runShow(shown, nowMs, fires, findings);
210
+ });
142
211
  },
143
212
  };
@@ -10,7 +10,7 @@ export const testSpec: CommandSpec = {
10
10
  name: 'test',
11
11
  summary:
12
12
  'run one test type — or the whole suite — across N workers, one isolated database per worker',
13
- usage: `x test [${TEST_TYPES.join('|')}] [--filter path[,path…]] [--allow-empty] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--isolate] [--json] [-- <bun test flags>]`,
13
+ usage: `x test [${TEST_TYPES.join('|')}] [--filter path[,path…]] [--allow-empty] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--isolate] [--json] [-- <bun test flags>]`,
14
14
  positionalChoices: TEST_TYPES,
15
15
  // The one command that hands a tail to another tool — `bun test` — and the reason
16
16
  // `CommandSpec.passthrough` exists: `x test unit -- --coverage --bail` parsed both flags and
@@ -28,12 +28,6 @@ export const testSpec: CommandSpec = {
28
28
  summary:
29
29
  'a fresh global per test file (bun test --isolate) — off by default since 22.7; x.verify.json "isolate": true makes it the repo default',
30
30
  },
31
- {
32
- name: 'worker',
33
- type: 'string',
34
- summary:
35
- 'run only shard I of an N-way split of the selection, serially — one CI job\u2019s share',
36
- },
37
31
  {
38
32
  name: 'filter',
39
33
  type: 'string',
package/src/cmd-test.ts CHANGED
@@ -40,22 +40,20 @@ import type { TestType } from './verify-tests';
40
40
  import { TEST_TYPES } from './verify-tests';
41
41
 
42
42
  /**
43
- * `--workers` and `--shard`. `Number.parseInt` alone accepted `4abc` and `4.9` as four, while
44
- * `cmd-verify.ts`'s own comment claimed `x test --workers` refused the same values `x verify`
45
- * does — so the two commands disagreed about the same flag. One reader now answers for both.
43
+ * `--workers`. `Number.parseInt` alone accepted `4abc` and `4.9` as four, while `cmd-verify.ts`'s
44
+ * own comment claimed `x test --workers` refused the same values `x verify` does — so the two
45
+ * commands disagreed about the same flag. One reader now answers for both.
46
46
  */
47
- const readIndex = (args: ParsedArgs, name: string, min: number): number | undefined =>
47
+ const readWorkers = (args: ParsedArgs): number | undefined =>
48
48
  readIntFlag(args, {
49
- name,
49
+ name: 'workers',
50
50
  command: 'test',
51
- min,
51
+ min: 1,
52
52
  // The ceiling the summary already claimed and the reader never enforced: `--workers 5000` was
53
53
  // accepted and the run clamps only to the file count, which is one Bun worker per test FILE,
54
- // each with the framework module graph and a cloned database. `--worker` is an index into an
55
- // N-way split, so the same bound holds it (the exact upper index is `workers - 1`, refused a
56
- // line below by the check that knows the real width).
54
+ // each with the framework module graph and a cloned database.
57
55
  max: WORKER_CEILING,
58
- example: `x test --${name} ${Math.max(min, 1)}`,
56
+ example: 'x test --workers 1',
59
57
  });
60
58
 
61
59
  /**
@@ -168,29 +166,17 @@ export const testCommand: CliCommand = {
168
166
  });
169
167
  }
170
168
  const files = sample === undefined ? selected : sampleFiles(selected, sample);
171
- const explicit = readIndex(ctx.args, 'workers', 1);
169
+ const explicit = readWorkers(ctx.args);
172
170
  const plan =
173
171
  explicit === undefined ? workerPlan(availableCpus(), totalMemory(), ctx.env) : undefined;
174
172
  const requested = explicit ?? (plan as WorkerPlan).workers;
175
173
  const floor = await readVerifyFloor(ctx.cwd);
176
174
  const isolate = flagBool(ctx.args, 'isolate') || floor?.isolate === true;
177
- // The width a `--worker` index is judged against, and nothing else: which files really run one
178
- // at a time is `test-passes.ts`, off the FILES rather than off the positional. This line read
179
- // the positional alone until 2026-09 and the comment here claimed it made a serial type serial
180
- // — true for `x test live`, false for the bare `x test --workers 8` that selects every type,
181
- // which ran the same `live` and `e2e` files eight at a time. It stays because a `--worker` run
182
- // is one process per CI JOB: sharding a serial type across N of them is the cluster-wide race
183
- // in a second disguise, and refusing the index is where that is caught.
175
+ // A serial type is one worker whatever `--workers` says. `test-passes.ts` makes the same call
176
+ // off the FILES, which is what decides which files really run one at a time; this keeps the
177
+ // width the run reports and reproduces with the one it ran at.
184
178
  const ceiling = type !== undefined && SERIAL_TYPES.includes(type) ? 1 : files.length;
185
179
  const workers = Math.max(1, Math.min(requested, ceiling));
186
- const only = readIndex(ctx.args, 'worker', 0);
187
- if (only !== undefined && only >= workers) {
188
- throw new BadFlagError({
189
- flag: 'worker',
190
- command: 'test',
191
- reason: `shard ${only} does not exist in a ${workers}-worker split (0..${workers - 1})`,
192
- });
193
- }
194
180
  const result = await runShards({
195
181
  root: ctx.cwd,
196
182
  runner: ctx.runner,
@@ -201,11 +187,9 @@ export const testCommand: CliCommand = {
201
187
  // A default-width run leases its workers from the machine pool, so a second `x test` or
202
188
  // gate on the same box runs narrower instead of doubling its memory.
203
189
  ...(plan === undefined ? {} : withLease(machineLease(plan.workers, ctx.env))),
204
- ...(only === undefined ? {} : { only }),
205
190
  ...(filter === undefined ? {} : { filter }),
206
191
  ...(type === undefined ? {} : { type }),
207
- // `kept` is the corpus the split saw; a `--worker` rerun must name it, not its own shard.
208
- // `selected`, not `discovered`: with `--affected` the sample was taken from the narrowed
192
+ // `kept` is the corpus the run saw. `selected`, not `discovered`: with `--affected` the sample was taken from the narrowed
209
193
  // set, and reporting the whole tree as its total would name a corpus no run ever had.
210
194
  ...(sample === undefined ? {} : { sample: { kept: files.length, total: selected.length } }),
211
195
  // The fourth input to the split. Without it a failing shard's `fix:` re-splits the whole
package/src/cmd-verify.ts CHANGED
@@ -20,15 +20,16 @@ import type { ParsedArgs } from './parse';
20
20
  import { flagBool, flagString } from './parse';
21
21
  import { WORKER_CEILING, WORKER_FLOOR } from './test-workers';
22
22
  import { VERIFY_STEPS } from './verify-checks';
23
- import { VerifyMergeInputError, VerifyShardInvalidError } from './verify-errors';
24
23
  import { readVerifyFloor } from './verify-floor';
25
24
  import type { VerifyPart } from './verify-merge';
26
- import { mergeParts, parsePart } from './verify-merge';
25
+ import { coverageRiders, mergeParts, parsePart, VerifyMergeInputError } from './verify-merge';
26
+ import { stepStream } from './verify-progress';
27
27
  import { runVerify } from './verify-run';
28
28
  import type { ShardSpec, Timings } from './verify-shard';
29
- import { assertShardable, parseShard, readTimings } from './verify-shard';
29
+ import { assertShardable, parseShard, readTimings, VerifyShardInvalidError } from './verify-shard';
30
30
  import type { VerifyStepName } from './verify-step';
31
31
  import { VERIFY_STEP_NAMES } from './verify-step';
32
+ import { writeErrorLine } from './write-line';
32
33
 
33
34
  // One import path for the gate, unchanged by the split: `index.ts`, `x build` and the MCP host all
34
35
  // reach the list and the runner through this module, and a second path to either would be the
@@ -55,6 +56,9 @@ export const verifyCommand: CliCommand = {
55
56
  ...(only === undefined ? {} : { only }),
56
57
  ...(shard === undefined ? {} : { shard }),
57
58
  ...(isolate === undefined ? {} : { isolate }),
59
+ // `--json` prints one document when the run ends; a line per finished step on stderr is
60
+ // what a cancelled CI job's log ends on (#589). stdout stays the single document.
61
+ ...stepStream(ctx.args.json, writeErrorLine),
58
62
  });
59
63
  return shard === undefined ? result : withShardData(result, shard);
60
64
  },
@@ -119,7 +123,10 @@ async function mergeCommand(
119
123
  }
120
124
  parts.push(parsePart(file, await handle.text()));
121
125
  }
122
- return mergeParts(parts, await readVerifyFloor(root));
126
+ const floor = await readVerifyFloor(root);
127
+ return mergeParts(parts, floor, VERIFY_STEP_NAMES, {
128
+ riders: await coverageRiders(parts, root, floor),
129
+ });
123
130
  }
124
131
 
125
132
  /**
@@ -142,8 +149,9 @@ export const readOnlySteps = (args: ParsedArgs): readonly VerifyStepName[] | und
142
149
  if (unknown.length === 0) {
143
150
  return VERIFY_STEP_NAMES.filter((name) => items.includes(name));
144
151
  }
152
+ // An empty item corrects to nothing — never to whichever step is nearest to ''.
145
153
  const corrected = items.map((item) =>
146
- isStepName(item) ? item : nearestName(item, VERIFY_STEP_NAMES),
154
+ isStepName(item) ? item : item === '' ? undefined : nearestName(item, VERIFY_STEP_NAMES),
147
155
  );
148
156
  const fix = corrected.every((item) => item !== undefined)
149
157
  ? `x verify --only ${renderFixShellArg([...new Set(corrected)].join(','), '<step,step>')} --json`
@@ -0,0 +1,16 @@
1
+ // A path read off the repository being scanned, made safe to ride in the `#` comment of a `fix:`
2
+ // line. One definition: every finding that names a scanned file in its fix writes it through this.
3
+
4
+ /**
5
+ * Control characters, `\u00xx`-escaped. The path is a value read off the repository being scanned,
6
+ * and it rides in a `#` comment: a directory holding a NEWLINE ends that comment, so everything
7
+ * after it is a second command in a line whose whole purpose is to be pasted into a shell.
8
+ * Escaped rather than deleted, because the comment still has to name the file the reader owns.
9
+ */
10
+ export const commentSafe = (path: string): string =>
11
+ [...path]
12
+ .map((char) => {
13
+ const code = char.codePointAt(0) ?? 0;
14
+ return code < 0x20 || code === 0x7f ? `\\u${code.toString(16).padStart(4, '0')}` : char;
15
+ })
16
+ .join('');