@ultimat3/cli 20.2.1 → 22.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 (237) hide show
  1. package/CLAUDE.md +84 -1582
  2. package/README.md +40 -2
  3. package/package.json +31 -31
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-env.ts +2 -2
  8. package/src/app-load.ts +37 -5
  9. package/src/app-openapi.ts +27 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-root.ts +1 -1
  12. package/src/async-pages.ts +32 -0
  13. package/src/bin.ts +7 -1
  14. package/src/boundary-findings.ts +36 -0
  15. package/src/browser-launcher-fake-html.ts +80 -0
  16. package/src/browser-launcher-fake.ts +165 -0
  17. package/src/browser-launcher-port.ts +159 -0
  18. package/src/browser-launcher.ts +26 -133
  19. package/src/budgets.ts +56 -13
  20. package/src/build-errors.ts +54 -0
  21. package/src/cdp-shot-a11y.ts +77 -0
  22. package/src/cdp-shot-clock.ts +14 -0
  23. package/src/cdp-shot-driver.ts +150 -0
  24. package/src/cdp-shot-element.ts +147 -0
  25. package/src/cdp-shot-errors.ts +62 -0
  26. package/src/cdp-shot-keys.ts +152 -0
  27. package/src/cdp-shot-page.ts +230 -0
  28. package/src/cdp-shot-watch.ts +241 -0
  29. package/src/cmd-affected-spec.ts +24 -0
  30. package/src/cmd-affected.ts +3 -19
  31. package/src/cmd-build-spec.ts +16 -0
  32. package/src/cmd-build.ts +46 -20
  33. package/src/cmd-ci-spec.ts +24 -0
  34. package/src/cmd-ci.ts +17 -21
  35. package/src/cmd-db-branch.ts +2 -2
  36. package/src/cmd-db-spec.ts +99 -0
  37. package/src/cmd-db.ts +15 -94
  38. package/src/cmd-deploy-helm.ts +136 -0
  39. package/src/cmd-deploy-spec.ts +40 -0
  40. package/src/cmd-deploy.ts +117 -42
  41. package/src/cmd-dev-spec.ts +25 -0
  42. package/src/cmd-dev.ts +78 -122
  43. package/src/cmd-docs-spec.ts +16 -0
  44. package/src/cmd-docs.ts +2 -11
  45. package/src/cmd-doctor-spec.ts +21 -0
  46. package/src/cmd-doctor.ts +4 -18
  47. package/src/cmd-env-spec.ts +18 -0
  48. package/src/cmd-env.ts +4 -13
  49. package/src/cmd-errors-spec.ts +23 -0
  50. package/src/cmd-errors.ts +3 -17
  51. package/src/cmd-fix-spec.ts +18 -0
  52. package/src/cmd-fix.ts +4 -13
  53. package/src/cmd-generate-spec.ts +31 -0
  54. package/src/cmd-generate.ts +46 -42
  55. package/src/cmd-i18n-spec.ts +16 -0
  56. package/src/cmd-i18n.ts +7 -14
  57. package/src/cmd-jobs-spec.ts +56 -0
  58. package/src/cmd-jobs.ts +3 -50
  59. package/src/cmd-manifest-spec.ts +15 -0
  60. package/src/cmd-manifest.ts +19 -20
  61. package/src/cmd-mcp-spec.ts +21 -0
  62. package/src/cmd-mcp.ts +2 -15
  63. package/src/cmd-new-spec.ts +33 -0
  64. package/src/cmd-new.ts +38 -33
  65. package/src/cmd-policy-spec.ts +13 -0
  66. package/src/cmd-policy.ts +2 -9
  67. package/src/cmd-pr-spec.ts +39 -0
  68. package/src/cmd-pr.ts +5 -65
  69. package/src/cmd-registries-spec.ts +32 -0
  70. package/src/cmd-registries.ts +4 -25
  71. package/src/cmd-routes-spec.ts +12 -0
  72. package/src/cmd-routes.ts +2 -7
  73. package/src/cmd-secrets-spec.ts +19 -0
  74. package/src/cmd-secrets.ts +22 -34
  75. package/src/cmd-shot-island.ts +12 -13
  76. package/src/cmd-shot-spec.ts +53 -0
  77. package/src/cmd-shot.ts +11 -60
  78. package/src/cmd-tasks-spec.ts +21 -0
  79. package/src/cmd-tasks.ts +2 -16
  80. package/src/cmd-test-spec.ts +54 -0
  81. package/src/cmd-test.ts +4 -49
  82. package/src/cmd-verify-spec.ts +28 -0
  83. package/src/cmd-verify.ts +3 -23
  84. package/src/db-seed.ts +4 -2
  85. package/src/dev-dashboard.ts +4 -4
  86. package/src/dev-lock.ts +18 -2
  87. package/src/dev-port.ts +21 -0
  88. package/src/dev-route-table.ts +119 -0
  89. package/src/dispatch.ts +12 -5
  90. package/src/document-styles.ts +1 -1
  91. package/src/drift.ts +4 -18
  92. package/src/error-catalog.ts +16 -9
  93. package/src/error-codes.ts +33 -28
  94. package/src/error-pages.ts +19 -7
  95. package/src/error-unthrown.ts +130 -0
  96. package/src/errors.ts +22 -29
  97. package/src/favicon.ts +2 -2
  98. package/src/fix-imports.ts +1 -1
  99. package/src/fix-scan.ts +2 -9
  100. package/src/flag-reads.ts +1 -2
  101. package/src/foreign-text.ts +36 -0
  102. package/src/framework-schema.ts +5 -6
  103. package/src/generate-feature.ts +42 -0
  104. package/src/generate-files.ts +7 -3
  105. package/src/generate-grants.ts +83 -0
  106. package/src/generate-kinds.ts +56 -4
  107. package/src/i18n-index.ts +59 -10
  108. package/src/icon-assets.ts +1 -1
  109. package/src/image-prepare.ts +14 -0
  110. package/src/import-scan.ts +63 -0
  111. package/src/index.ts +67 -334
  112. package/src/invocation-flags.ts +26 -0
  113. package/src/island-bundle.ts +38 -11
  114. package/src/island-capture.ts +6 -6
  115. package/src/island-harness-script.ts +6 -2
  116. package/src/island-realtime.ts +98 -0
  117. package/src/island-shot.ts +5 -0
  118. package/src/island-store.ts +131 -0
  119. package/src/island-verdict.ts +2 -2
  120. package/src/job-registration.ts +42 -0
  121. package/src/jobs-driver.ts +2 -2
  122. package/src/live-routes.ts +82 -42
  123. package/src/load-findings.ts +51 -0
  124. package/src/mcp-db-target.ts +1 -1
  125. package/src/mcp-errors.ts +30 -26
  126. package/src/mcp-host.ts +27 -12
  127. package/src/mcp-ui-diff.ts +27 -0
  128. package/src/mcp-ui-inspect.ts +4 -4
  129. package/src/mcp-ui-interact.ts +21 -15
  130. package/src/mcp-ui.ts +19 -15
  131. package/src/measure-database.ts +73 -0
  132. package/src/measure-paths.ts +74 -0
  133. package/src/measure-scope.ts +74 -0
  134. package/src/messages.ts +1 -3
  135. package/src/metrics-endpoint.ts +1 -1
  136. package/src/otlp-export.ts +1 -1
  137. package/src/output.ts +6 -0
  138. package/src/page-sync.ts +54 -0
  139. package/src/permission-grants.ts +86 -0
  140. package/src/prerender-out.ts +25 -0
  141. package/src/prerender.ts +121 -102
  142. package/src/pwa-artifacts.ts +3 -3
  143. package/src/realtime-browser-probe-fixture.ts +2 -2
  144. package/src/reexport-manifest.ts +2 -1
  145. package/src/registry.ts +80 -56
  146. package/src/role-realtime.ts +36 -0
  147. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  148. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  149. package/src/role-start-types.ts +112 -0
  150. package/src/{dev-roles.ts → role-start.ts} +42 -115
  151. package/src/{dev-sync.ts → role-sync.ts} +23 -11
  152. package/src/root-env.ts +67 -0
  153. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  154. package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
  155. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  156. package/src/runtime-jobs.ts +87 -0
  157. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
  158. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  159. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  160. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  161. package/src/runtime-realtime.ts +55 -0
  162. package/src/{dev-render.ts → runtime-render.ts} +119 -20
  163. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  164. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  165. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  166. package/src/scaffold-fixture.ts +28 -6
  167. package/src/scaffold-typecheck.ts +6 -3
  168. package/src/schema-drift.ts +7 -1
  169. package/src/script-csp.ts +5 -2
  170. package/src/secrets-rotation.ts +59 -0
  171. package/src/serve-boot.ts +192 -0
  172. package/src/serve-drain.ts +24 -0
  173. package/src/serve-entry.ts +6 -0
  174. package/src/serve-env.ts +116 -0
  175. package/src/serve-types.ts +55 -0
  176. package/src/serve.ts +44 -338
  177. package/src/shot-server.ts +2 -2
  178. package/src/shot-settle.ts +10 -1
  179. package/src/shot-theme.ts +3 -3
  180. package/src/shot-verdict.ts +16 -7
  181. package/src/signal-shred.ts +27 -0
  182. package/src/solid-loader.ts +26 -2
  183. package/src/static-report.ts +8 -1
  184. package/src/sw-artifacts.ts +13 -3
  185. package/src/sync-url.ts +31 -0
  186. package/src/templates/action.ts +30 -16
  187. package/src/templates/entity.ts +12 -7
  188. package/src/templates/index.ts +1 -1
  189. package/src/templates/job.ts +10 -7
  190. package/src/templates/policy.ts +20 -2
  191. package/src/templates/resource-create.ts +127 -0
  192. package/src/templates/resource-form-island.ts +76 -30
  193. package/src/templates/resource.ts +10 -4
  194. package/src/templates/route.ts +3 -0
  195. package/src/templates/scaffold-app.ts +4 -1
  196. package/src/templates/scaffold-auth.ts +3 -1
  197. package/src/templates/scaffold-container-compose.ts +184 -0
  198. package/src/templates/scaffold-container.ts +25 -145
  199. package/src/templates/scaffold-dashboard-example.ts +2 -2
  200. package/src/templates/scaffold-db-package.ts +16 -3
  201. package/src/templates/scaffold-demo-org.ts +41 -0
  202. package/src/templates/scaffold-entries.ts +1 -1
  203. package/src/templates/scaffold-env.ts +6 -0
  204. package/src/templates/scaffold-helm-templates.ts +66 -7
  205. package/src/templates/scaffold-helm.ts +27 -5
  206. package/src/templates/scaffold-i18n.ts +15 -10
  207. package/src/templates/scaffold-repo.ts +13 -10
  208. package/src/templates/scaffold-roles.ts +38 -10
  209. package/src/templates/slice-foundation.ts +1 -1
  210. package/src/templates/wrap.ts +4 -1
  211. package/src/test-passes.ts +2 -1
  212. package/src/test-workers.ts +26 -0
  213. package/src/ts-scan.ts +3 -6
  214. package/src/tsconfig-references.ts +1 -2
  215. package/src/verify-checks.ts +41 -39
  216. package/src/verify-e2e.ts +41 -0
  217. package/src/verify-run.ts +115 -50
  218. package/src/verify-step.ts +3 -3
  219. package/src/verify-tests.ts +22 -30
  220. package/src/verify-typecheck.ts +28 -0
  221. package/src/web-binding.ts +2 -2
  222. package/src/worker-bundle.ts +192 -0
  223. package/src/workspace-graph.ts +10 -33
  224. package/src/cdp-browser.ts +0 -100
  225. package/src/cdp-connection.ts +0 -211
  226. package/src/cdp-e2e-page.ts +0 -209
  227. package/src/cdp-errors.ts +0 -56
  228. package/src/cdp-launch.ts +0 -139
  229. package/src/e2e-dom-fixture.ts +0 -117
  230. package/src/e2e-driver.ts +0 -97
  231. package/src/e2e-errors.ts +0 -103
  232. package/src/e2e-evaluate.ts +0 -156
  233. package/src/e2e-locator.ts +0 -86
  234. package/src/e2e-page.ts +0 -150
  235. package/src/e2e-selection.ts +0 -182
  236. package/src/measurement-actor.ts +0 -26
  237. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
@@ -0,0 +1,12 @@
1
+ // `x routes`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-routes.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ export const routesSpec: CommandSpec = {
7
+ name: 'routes',
8
+ summary: 'the route table: path, surface, render mode, hydrate, offline',
9
+ usage: 'x routes [--surface site|app|api|shared] [--json]',
10
+ requiresApp: true,
11
+ flags: [{ name: 'surface', type: 'string', summary: 'filter by surface' }],
12
+ };
package/src/cmd-routes.ts CHANGED
@@ -8,6 +8,7 @@ import type { RouteDescriptor, Surface } from '@ultimat3/render';
8
8
  import { describeRoutes, SURFACES } from '@ultimat3/render';
9
9
  import { loadApp } from './app-load';
10
10
  import { requireAppRoot } from './app-root';
11
+ import { routesSpec } from './cmd-routes-spec';
11
12
  import type { CliCommand, CommandContext } from './command';
12
13
  import { BadFlagError } from './errors';
13
14
  import { msg } from './messages';
@@ -65,13 +66,7 @@ export function readSurfaceFilter(raw: string | undefined): Surface | undefined
65
66
  }
66
67
 
67
68
  export const routesCommand: CliCommand = {
68
- spec: {
69
- name: 'routes',
70
- summary: 'the route table: path, surface, render mode, hydrate, offline',
71
- usage: 'x routes [--surface site|app|api|shared] [--json]',
72
- requiresApp: true,
73
- flags: [{ name: 'surface', type: 'string', summary: 'filter by surface' }],
74
- },
69
+ spec: routesSpec,
75
70
  async run(ctx: CommandContext): Promise<CommandResult> {
76
71
  const root = requireAppRoot('routes', ctx.cwd).dir;
77
72
  // Read before the app is loaded: a typo must not cost a boot to report, the rule `x mcp`'s
@@ -0,0 +1,19 @@
1
+ // `x secrets`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-secrets.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import { ENV_SCHEMA_EXPORT } from './app-env';
5
+ import type { CommandSpec } from './parse';
6
+
7
+ export const SECRETS_SUBCOMMANDS = ['show', 'init', 'edit', 'set', 'rotate'] as const;
8
+
9
+ export const secretsSpec: CommandSpec = {
10
+ name: 'secrets',
11
+ summary: `the committed encrypted secrets, decrypted into the ${ENV_SCHEMA_EXPORT} variables of the same names`,
12
+ usage: 'x secrets [show|init|edit|set <NAME>|rotate] [--json]',
13
+ requiresApp: true,
14
+ subcommands: [...SECRETS_SUBCOMMANDS],
15
+ // The bare `x secrets` answers without a key ever leaving the file. Declared, not inherited
16
+ // from the array's order — `init`, `edit`, `set` and `rotate` all write.
17
+ defaultSubcommand: 'show',
18
+ flags: [],
19
+ };
@@ -30,6 +30,7 @@ import {
30
30
  } from '@ultimat3/core';
31
31
  import { ENV_SCHEMA_EXPORT, loadEnvSchema } from './app-env';
32
32
  import { requireAppRoot } from './app-root';
33
+ import { secretsSpec } from './cmd-secrets-spec';
33
34
  import type { CliCommand, CommandContext } from './command';
34
35
  import {
35
36
  MissingPositionalError,
@@ -39,9 +40,11 @@ import {
39
40
  } from './errors';
40
41
  import { msg } from './messages';
41
42
  import type { CommandResult, JsonValue } from './output';
43
+ import { recoverRotation, rotateMasterKey } from './secrets-rotation';
44
+ import { shredOnSignal } from './signal-shred';
42
45
  import { renderTable } from './table';
43
46
 
44
- export const SECRETS_SUBCOMMANDS = ['show', 'init', 'edit', 'set', 'rotate'] as const;
47
+ export { SECRETS_SUBCOMMANDS } from './cmd-secrets-spec';
45
48
 
46
49
  /** In order of precedence. `VISUAL` outranks `EDITOR` by POSIX convention on an interactive tty. */
47
50
  export const EDITOR_VARS = ['VISUAL', 'EDITOR'] as const;
@@ -74,9 +77,10 @@ interface Session {
74
77
  readonly key: MasterKeyRef;
75
78
  }
76
79
 
77
- const open = (ctx: CommandContext, subcommand: string): Session => {
80
+ /** The root and its key — after finishing or abandoning a rotation a crash interrupted. */
81
+ const open = async (ctx: CommandContext, subcommand: string): Promise<Session> => {
78
82
  const root = requireAppRoot(`secrets ${subcommand}`, ctx.cwd).dir;
79
- return { root, key: requireMasterKey(root, ctx.env) };
83
+ return { root, key: await recoverRotation(root, requireMasterKey(root, ctx.env)) };
80
84
  };
81
85
 
82
86
  const names = (values: SecretValues): readonly string[] => Object.keys(values).sort();
@@ -148,7 +152,7 @@ async function init(ctx: CommandContext): Promise<CommandResult> {
148
152
  * of the two harms. Leaving decrypted values on disk so they can be recovered is the larger one.
149
153
  */
150
154
  async function edit(ctx: CommandContext, io: SecretsIo): Promise<CommandResult> {
151
- const { root, key } = open(ctx, 'edit');
155
+ const { root, key } = await open(ctx, 'edit');
152
156
  const editor = EDITOR_VARS.map((name) => ctx.env[name]).find(
153
157
  (value): value is string => value !== undefined && value.trim().length > 0,
154
158
  );
@@ -157,9 +161,9 @@ async function edit(ctx: CommandContext, io: SecretsIo): Promise<CommandResult>
157
161
  const dir = await mkdtemp(join(tmpdir(), 'ultimate-secrets-'));
158
162
  const buffer = join(dir, 'secrets.json');
159
163
  const shred = (): void => rmSync(dir, { recursive: true, force: true });
160
- // A `finally` does not run for a signal, and Ctrl-C inside an editor kills this process too.
161
- process.once('SIGINT', shred);
162
- process.once('SIGTERM', shred);
164
+ // A `finally` does not run for a signal, and Ctrl-C inside an editor kills this process too —
165
+ // after the shred, which `shredOnSignal` runs and then re-raises the signal for.
166
+ const unlisten = shredOnSignal(shred);
163
167
  try {
164
168
  await Bun.write(buffer, serializeSecretValues(before));
165
169
  // `$EDITOR` may carry flags (`code --wait`), and there is no shell here to split them.
@@ -195,8 +199,7 @@ async function edit(ctx: CommandContext, io: SecretsIo): Promise<CommandResult>
195
199
  };
196
200
  } finally {
197
201
  shred();
198
- process.off('SIGINT', shred);
199
- process.off('SIGTERM', shred);
202
+ unlisten();
200
203
  }
201
204
  }
202
205
 
@@ -217,7 +220,7 @@ async function set(ctx: CommandContext, io: SecretsIo): Promise<CommandResult> {
217
220
  example: 'printf %s "$TOKEN" | x secrets set STRIPE_KEY --json',
218
221
  });
219
222
  }
220
- const { root, key } = open(ctx, 'set');
223
+ const { root, key } = await open(ctx, 'set');
221
224
  const before = await readSecretsFile(root, key);
222
225
  // Exactly one trailing newline, because `echo` adds one and a secret with a stray `\n` fails
223
226
  // against the service it authenticates to with an error that names nothing.
@@ -242,23 +245,18 @@ async function set(ctx: CommandContext, io: SecretsIo): Promise<CommandResult> {
242
245
  }
243
246
 
244
247
  /**
245
- * A new master key over the same values. The committed file is written FIRST and the key file last,
246
- * because only one of the two can be recovered: `secrets.enc.json` is in git, and a master key that
247
- * is half-overwritten is gone. Interrupted between the two writes, the old key still on disk meets
248
- * a file it cannot open — `X_SECRETS_KEY_MISMATCH`, whose fix restores the file from git.
248
+ * A new master key over the same values: staged beside the old one, the file sealed with it, then
249
+ * made live (`rotateMasterKey`). Sealed first with the key file written last, a crash between the
250
+ * two left a committed file no key on disk could open.
249
251
  */
250
252
  async function rotate(ctx: CommandContext): Promise<CommandResult> {
251
- const { root, key } = open(ctx, 'rotate');
253
+ const { root, key } = await open(ctx, 'rotate');
252
254
  const values = await readSecretsFile(root, key);
253
255
  const previous = await masterKeyIdOf(key);
254
- const next: MasterKeyRef = {
255
- hex: generateMasterKey(),
256
- source: 'file',
257
- at: masterKeyPath(root),
258
- };
259
- await writeSecretsFile(root, values, next);
256
+ // Ignored BEFORE any key file is written, staged one included; the order inside is
257
+ // `secrets-rotation.ts`'s: stage the key, seal, then rename it live.
260
258
  await ensureIgnored(root);
261
- writeMasterKeyFile(root, next.hex);
259
+ const next = await rotateMasterKey(root, values);
262
260
  const keyId = await masterKeyIdOf(next);
263
261
  return {
264
262
  ok: true,
@@ -286,7 +284,7 @@ async function rotate(ctx: CommandContext): Promise<CommandResult> {
286
284
  * `x secrets edit`. A `--reveal` flag would be a second path that `--json` could not honour.
287
285
  */
288
286
  async function show(ctx: CommandContext): Promise<CommandResult> {
289
- const { root, key } = open(ctx, 'show');
287
+ const { root, key } = await open(ctx, 'show');
290
288
  const values = await readSecretsFile(root, key);
291
289
  const schema = await loadEnvSchema(root);
292
290
  const summaries = describeSecrets(values);
@@ -337,17 +335,7 @@ async function show(ctx: CommandContext): Promise<CommandResult> {
337
335
 
338
336
  export function createSecretsCommand(io: SecretsIo): CliCommand {
339
337
  return {
340
- spec: {
341
- name: 'secrets',
342
- summary: `the committed encrypted secrets, decrypted into the ${ENV_SCHEMA_EXPORT} variables of the same names`,
343
- usage: 'x secrets [show|init|edit|set <NAME>|rotate] [--json]',
344
- requiresApp: true,
345
- subcommands: [...SECRETS_SUBCOMMANDS],
346
- // The bare `x secrets` answers without a key ever leaving the file. Declared, not inherited
347
- // from the array's order — `init`, `edit`, `set` and `rotate` all write.
348
- defaultSubcommand: 'show',
349
- flags: [],
350
- },
338
+ spec: secretsSpec,
351
339
  async run(ctx: CommandContext): Promise<CommandResult> {
352
340
  switch (ctx.args.subcommand ?? 'show') {
353
341
  case 'init':
@@ -9,10 +9,10 @@
9
9
 
10
10
  // why: no Bun native joins or resolves a path; `--out` is resolved against the app root.
11
11
  import { join, resolve } from 'node:path';
12
- import type { ScrapeDriver } from '@ultimat3/scraping';
13
12
  import type { IslandStatesManifest, IslandViewport } from '@ultimat3/testing';
14
13
  import { findIslandStates } from '@ultimat3/testing';
15
14
  import { appBrowser } from './browser-launcher';
15
+ import type { ShotDriver } from './browser-launcher-port';
16
16
  import { BadFlagError } from './errors';
17
17
  import type { IslandBrowser } from './island-shot';
18
18
  import { ISLAND_SHOT_DIR, runIslandShot, runIslandSweep } from './island-shot';
@@ -39,19 +39,18 @@ export function islandBrowser(input: {
39
39
  readonly executablePath?: string | undefined;
40
40
  readonly cdpUrl?: string | undefined;
41
41
  }): IslandBrowser {
42
- const byViewport = new Map<string, Promise<ScrapeDriver>>();
43
- return (viewport: IslandViewport): Promise<ScrapeDriver> => {
42
+ const byViewport = new Map<string, Promise<ShotDriver>>();
43
+ return (viewport: IslandViewport): Promise<ShotDriver> => {
44
44
  const key = `${viewport.width}x${viewport.height}`;
45
- const held = byViewport.get(key);
46
- if (held !== undefined) return held;
47
- const started = appBrowser({
48
- root: input.root,
49
- ...(input.executablePath === undefined ? {} : { executablePath: input.executablePath }),
50
- ...(input.cdpUrl === undefined ? {} : { cdpUrl: input.cdpUrl }),
51
- viewport: { width: viewport.width, height: viewport.height },
52
- });
53
- byViewport.set(key, started);
54
- return started;
45
+ const held =
46
+ byViewport.get(key) ??
47
+ appBrowser({
48
+ ...(input.executablePath === undefined ? {} : { executablePath: input.executablePath }),
49
+ ...(input.cdpUrl === undefined ? {} : { cdpUrl: input.cdpUrl }),
50
+ viewport: { width: viewport.width, height: viewport.height },
51
+ });
52
+ byViewport.set(key, held);
53
+ return held;
55
54
  };
56
55
  }
57
56
 
@@ -0,0 +1,53 @@
1
+ // `x shot`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-shot.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ export const shotSpec: CommandSpec = {
7
+ name: 'shot',
8
+ summary: 'photograph one route, one island in a state it declares, or every island in the app',
9
+ usage:
10
+ 'x shot <route> | --island <name> [--state <id>] | --all-islands [--port 0] [--out <dir>] [--settle 2000] [--json]',
11
+ requiresApp: true,
12
+ flags: [
13
+ { name: 'port', type: 'string', summary: 'dev port (0 lets the kernel pick a free one)' },
14
+ { name: 'out', type: 'string', summary: 'where shot.png and verdict.json are written' },
15
+ { name: 'full', type: 'boolean', summary: 'whole page, not the fold', default: true },
16
+ { name: 'settle', type: 'string', summary: 'ms to wait after load before capturing' },
17
+ { name: 'timeout', type: 'string', summary: 'ms one navigation may take' },
18
+ { name: 'browser', type: 'string', summary: 'Chrome or Chromium binary to launch' },
19
+ {
20
+ name: 'cdp-url',
21
+ type: 'string',
22
+ summary: 'attach to a browser somebody else is running (a provider session, a sidecar)',
23
+ },
24
+ { name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
25
+ {
26
+ name: 'theme',
27
+ type: 'string',
28
+ summary: "light or dark, stored as the visitor's choice; absent is the app's own default",
29
+ },
30
+ // A FLAG on `x shot` and never a second command: photographing a route and photographing a
31
+ // component are one job with two subjects, and a parallel command would be the second path
32
+ // axiom 1 refuses.
33
+ {
34
+ name: 'island',
35
+ type: 'string',
36
+ summary: 'photograph one island in every state it declares',
37
+ },
38
+ {
39
+ name: 'state',
40
+ type: 'string',
41
+ summary: 'one declared state of that island, not all of them',
42
+ },
43
+ // Its own SPELLING and never `--island` with no value: the parser refuses a bare `--island`
44
+ // ("expects a value") and `--island=` is an empty name, so "every island" had no form a
45
+ // reader could type that could not be read as a mistyped one. A boolean cannot be confused
46
+ // with a name, and `x shot --all-islands` says what it does beside `x shot --island <name>`.
47
+ {
48
+ name: 'all-islands',
49
+ type: 'boolean',
50
+ summary: 'every island in the app, in every state it declares, plus an index.md',
51
+ },
52
+ ],
53
+ };
package/src/cmd-shot.ts CHANGED
@@ -8,10 +8,10 @@
8
8
  import { mkdirSync } from 'node:fs';
9
9
  import { join, resolve } from 'node:path';
10
10
  import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
11
- import type { ColorScheme, ScrapeDriver, ScrapePage, ScrapeSession } from '@ultimat3/scraping';
12
- import { DEFAULT_PAGE_TIMEOUT_MS, systemScrapeClock } from '@ultimat3/scraping';
13
11
  import { requireAppRoot } from './app-root';
14
12
  import { appBrowser } from './browser-launcher';
13
+ import type { ShotColorScheme, ShotDriver, ShotPage, ShotSession } from './browser-launcher-port';
14
+ import { DEFAULT_PAGE_TIMEOUT_MS, systemShotClock } from './cdp-shot-clock';
15
15
  import {
16
16
  islandShot,
17
17
  islandShotResult,
@@ -22,6 +22,7 @@ import {
22
22
  refuseSweepWithRoute,
23
23
  refuseSweepWithState,
24
24
  } from './cmd-shot-island';
25
+ import { shotSpec } from './cmd-shot-spec';
25
26
  import type { CliCommand, CommandContext } from './command';
26
27
  import { BadFlagError, MissingPositionalError } from './errors';
27
28
  import { intFlagOr, PORT_RANGE } from './flag-number';
@@ -181,7 +182,7 @@ const intFlag = (
181
182
  export interface ShotRun {
182
183
  readonly route: string;
183
184
  readonly outDir: string;
184
- readonly driver: ScrapeDriver;
185
+ readonly driver: ShotDriver;
185
186
  readonly boot: () => Promise<ShotServer>;
186
187
  readonly settleMs: number;
187
188
  readonly timeoutMs: number;
@@ -200,7 +201,7 @@ export interface ShotRun {
200
201
  * neither: the box's own preference and the app's own default — what `x shot` has always done,
201
202
  * and the point of `defaultMode` — and `ui.shot` names one explicitly for exactly that reason.
202
203
  */
203
- readonly colorScheme?: ColorScheme | undefined;
204
+ readonly colorScheme?: ShotColorScheme | undefined;
204
205
  readonly now?: (() => Date) | undefined;
205
206
  /**
206
207
  * Something to do with the page AFTER the islands settled and BEFORE the picture — `ui.inspect`
@@ -209,7 +210,7 @@ export interface ShotRun {
209
210
  * caller who never calls it gets the count from the first settle.
210
211
  */
211
212
  readonly act?:
212
- | ((page: ScrapePage, settle: () => Promise<IslandCount | null>) => Promise<void>)
213
+ | ((page: ShotPage, settle: () => Promise<IslandCount | null>) => Promise<void>)
213
214
  | undefined;
214
215
  }
215
216
 
@@ -226,7 +227,7 @@ const quietly = async (stop: () => Promise<void>): Promise<void> => {
226
227
  */
227
228
  export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
228
229
  const server = await options.boot();
229
- let session: ScrapeSession | undefined;
230
+ let session: ShotSession | undefined;
230
231
  try {
231
232
  const requestedUrl = new URL(options.route, server.url).toString();
232
233
  session = await options.driver.open({
@@ -235,7 +236,7 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
235
236
  // inside your network is the widest SSRF surface an app can own, and a screenshot command is
236
237
  // not the place to open it by default. Every refusal lands in the verdict's `refused` count.
237
238
  rules: { allowHosts: allowHostsFrom(server.url, options.extraHosts) },
238
- clock: systemScrapeClock,
239
+ clock: systemShotClock,
239
240
  timeoutMs: options.timeoutMs,
240
241
  });
241
242
  const page = session.page;
@@ -312,54 +313,7 @@ export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
312
313
  });
313
314
 
314
315
  export const shotCommand: CliCommand = {
315
- spec: {
316
- name: 'shot',
317
- summary: 'photograph one route, one island in a state it declares, or every island in the app',
318
- usage:
319
- 'x shot <route> | --island <name> [--state <id>] | --all-islands [--port 0] [--out <dir>] [--settle 2000] [--json]',
320
- requiresApp: true,
321
- flags: [
322
- { name: 'port', type: 'string', summary: 'dev port (0 lets the kernel pick a free one)' },
323
- { name: 'out', type: 'string', summary: 'where shot.png and verdict.json are written' },
324
- { name: 'full', type: 'boolean', summary: 'whole page, not the fold', default: true },
325
- { name: 'settle', type: 'string', summary: 'ms to wait after load before capturing' },
326
- { name: 'timeout', type: 'string', summary: 'ms one navigation may take' },
327
- { name: 'browser', type: 'string', summary: 'browser executable puppeteer-core launches' },
328
- {
329
- name: 'cdp-url',
330
- type: 'string',
331
- summary: 'attach to a browser somebody else is running (a provider session, a sidecar)',
332
- },
333
- { name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
334
- {
335
- name: 'theme',
336
- type: 'string',
337
- summary: "light or dark, stored as the visitor's choice; absent is the app's own default",
338
- },
339
- // A FLAG on `x shot` and never a second command: photographing a route and photographing a
340
- // component are one job with two subjects, and a parallel command would be the second path
341
- // axiom 1 refuses.
342
- {
343
- name: 'island',
344
- type: 'string',
345
- summary: 'photograph one island in every state it declares',
346
- },
347
- {
348
- name: 'state',
349
- type: 'string',
350
- summary: 'one declared state of that island, not all of them',
351
- },
352
- // Its own SPELLING and never `--island` with no value: the parser refuses a bare `--island`
353
- // ("expects a value") and `--island=` is an empty name, so "every island" had no form a
354
- // reader could type that could not be read as a mistyped one. A boolean cannot be confused
355
- // with a name, and `x shot --all-islands` says what it does beside `x shot --island <name>`.
356
- {
357
- name: 'all-islands',
358
- type: 'boolean',
359
- summary: 'every island in the app, in every state it declares, plus an index.md',
360
- },
361
- ],
362
- },
316
+ spec: shotSpec,
363
317
  async run(ctx: CommandContext): Promise<CommandResult> {
364
318
  const root = requireAppRoot('shot', ctx.cwd).dir;
365
319
  // Every value read before anything boots: a typo must not cost a browser and a dev server to
@@ -396,8 +350,7 @@ export const shotCommand: CliCommand = {
396
350
  // Which browser this run gets — start one here, or attach to one somebody else is running.
397
351
  // Decided by `shot-browser.ts` over plain inputs, and decided HERE, before a dev server or a
398
352
  // provider session exists to pay for a typo. It also PROBES for an installed Chrome and refuses
399
- // when there is none: `puppeteer-core` bundles no browser, so a missing one used to surface as
400
- // a library throw one embedded Postgres later.
353
+ // when there is none, so a missing browser costs no embedded Postgres boot.
401
354
  const { cdpUrl, executablePath } = shotBrowserChoice({
402
355
  cdpFlag: flagString(ctx.args, 'cdp-url'),
403
356
  browserFlag: flagString(ctx.args, 'browser'),
@@ -423,10 +376,8 @@ export const shotCommand: CliCommand = {
423
376
  await islandShot({ ...shared, island, ...(state === undefined ? {} : { state }) }),
424
377
  );
425
378
  }
426
- // Resolved before the boot for the same reason: an app with no browser installed must not pay
427
- // an embedded Postgres to be told to run `bun add -d puppeteer-core`.
379
+ // Built before the boot, for the same reason.
428
380
  const driver = await appBrowser({
429
- root,
430
381
  ...(executablePath === undefined ? {} : { executablePath }),
431
382
  ...(cdpUrl === undefined ? {} : { cdpUrl }),
432
383
  });
@@ -0,0 +1,21 @@
1
+ // `x tasks`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-tasks.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ export const tasksSpec: CommandSpec = {
7
+ name: 'tasks',
8
+ summary: 'cron tasks, their timezone and their next run',
9
+ usage: 'x tasks [list|show <name>] [--count n] [--json]',
10
+ requiresApp: true,
11
+ subcommands: ['list', 'show'],
12
+ defaultSubcommand: 'list',
13
+ flags: [
14
+ {
15
+ name: 'count',
16
+ type: 'string',
17
+ summary: 'show: how many upcoming occurrences to list',
18
+ subcommands: ['show'],
19
+ },
20
+ ],
21
+ };
package/src/cmd-tasks.ts CHANGED
@@ -8,6 +8,7 @@ import type { TaskHandle } from '@ultimat3/jobs';
8
8
  import type { CronPhrases } from '@ultimat3/time';
9
9
  import { loadApp } from './app-load';
10
10
  import { requireAppRoot } from './app-root';
11
+ import { tasksSpec } from './cmd-tasks-spec';
11
12
  import type { CliCommand, CommandContext } from './command';
12
13
  import { BadFlagError, DeclarationUnknownError } from './errors';
13
14
  import { msg } from './messages';
@@ -130,22 +131,7 @@ function runShow(ctx: CommandContext, nowMs: number, findings: readonly Finding[
130
131
  }
131
132
 
132
133
  export const tasksCommand: CliCommand = {
133
- spec: {
134
- name: 'tasks',
135
- summary: 'cron tasks, their timezone and their next run',
136
- usage: 'x tasks [list|show <name>] [--count n] [--json]',
137
- requiresApp: true,
138
- subcommands: ['list', 'show'],
139
- defaultSubcommand: 'list',
140
- flags: [
141
- {
142
- name: 'count',
143
- type: 'string',
144
- summary: 'show: how many upcoming occurrences to list',
145
- subcommands: ['show'],
146
- },
147
- ],
148
- },
134
+ spec: tasksSpec,
149
135
  async run(ctx: CommandContext): Promise<CommandResult> {
150
136
  const root = requireAppRoot('tasks', ctx.cwd).dir;
151
137
  const { findings } = await loadApp(root);
@@ -0,0 +1,54 @@
1
+ // `x test`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-test.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import { TEST_TYPES } from '@ultimat3/testing/test-types';
5
+ import { DEFAULT_BASE } from './affected';
6
+ import type { CommandSpec } from './parse';
7
+ import { SERIAL_TYPES, WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
8
+
9
+ export const testSpec: CommandSpec = {
10
+ name: 'test',
11
+ summary:
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 text] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json] [-- <bun test flags>]`,
14
+ positionalChoices: TEST_TYPES,
15
+ // The one command that hands a tail to another tool — `bun test` — and the reason
16
+ // `CommandSpec.passthrough` exists: `x test unit -- --coverage --bail` parsed both flags and
17
+ // dropped both, so a run that measured no coverage reported exactly what a coverage run does.
18
+ passthrough: true,
19
+ flags: [
20
+ {
21
+ name: 'workers',
22
+ type: 'string',
23
+ summary: `bun worker count (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING}); clamped to the file count, and to 1 for ${SERIAL_TYPES.join(' and ')}`,
24
+ },
25
+ {
26
+ name: 'worker',
27
+ type: 'string',
28
+ summary:
29
+ 'run only shard I of an N-way split of the selection, serially — one CI job\u2019s share',
30
+ },
31
+ { name: 'filter', type: 'string', summary: 'only files whose path contains this substring' },
32
+ {
33
+ name: 'sample',
34
+ type: 'string',
35
+ summary:
36
+ 'run at most N files of the selected type — a fast signal for the eval loop, never a gate',
37
+ },
38
+ {
39
+ name: 'affected',
40
+ type: 'boolean',
41
+ summary: 'only the workspaces a diff touches, and everything that depends on one of them',
42
+ },
43
+ {
44
+ name: 'base',
45
+ type: 'string',
46
+ summary: `--affected: git ref to diff against, merge-base style (default: ${DEFAULT_BASE})`,
47
+ },
48
+ {
49
+ name: 'dirty',
50
+ type: 'boolean',
51
+ summary: '--affected: also count uncommitted work, whichever agent in this checkout made it',
52
+ },
53
+ ],
54
+ };
package/src/cmd-test.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { AffectedScope } from './affected';
9
9
  import { affectedScope, affectedScopeJson, DEFAULT_BASE, inScope } from './affected';
10
+ import { testSpec } from './cmd-test-spec';
10
11
  import type { CliCommand, CommandContext } from './command';
11
12
  import { ok } from './command';
12
13
  import { BadFlagError, NoTestFilesError } from './errors';
@@ -18,9 +19,9 @@ import { flagBool, flagString } from './parse';
18
19
  import { quoteArg } from './shell-quote';
19
20
  import { discoverTests, missingSelection, readSample, readType, sampleFiles } from './test-select';
20
21
  import { runShards } from './test-shards';
21
- import { defaultWorkers, WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
22
+ import { defaultWorkers, SERIAL_TYPES, WORKER_CEILING } from './test-workers';
22
23
  import type { TestType } from './verify-tests';
23
- import { SERIAL_TYPES, TEST_TYPES } from './verify-tests';
24
+ import { TEST_TYPES } from './verify-tests';
24
25
 
25
26
  /**
26
27
  * `--workers` and `--shard`. `Number.parseInt` alone accepted `4abc` and `4.9` as four, while
@@ -100,53 +101,7 @@ const withScope = (result: CommandResult, scope: AffectedScope): CommandResult =
100
101
  });
101
102
 
102
103
  export const testCommand: CliCommand = {
103
- spec: {
104
- name: 'test',
105
- summary:
106
- 'run one test type — or the whole suite — across N workers, one isolated database per worker',
107
- usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json] [-- <bun test flags>]`,
108
- positionalChoices: TEST_TYPES,
109
- // The one command that hands a tail to another tool — `bun test` — and the reason
110
- // `CommandSpec.passthrough` exists: `x test unit -- --coverage --bail` parsed both flags and
111
- // dropped both, so a run that measured no coverage reported exactly what a coverage run does.
112
- passthrough: true,
113
- flags: [
114
- {
115
- name: 'workers',
116
- type: 'string',
117
- summary: `bun worker count (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING}); clamped to the file count, and to 1 for ${SERIAL_TYPES.join(' and ')}`,
118
- },
119
- {
120
- name: 'worker',
121
- type: 'string',
122
- summary:
123
- 'run only shard I of an N-way split of the selection, serially — one CI job\u2019s share',
124
- },
125
- { name: 'filter', type: 'string', summary: 'only files whose path contains this substring' },
126
- {
127
- name: 'sample',
128
- type: 'string',
129
- summary:
130
- 'run at most N files of the selected type — a fast signal for the eval loop, never a gate',
131
- },
132
- {
133
- name: 'affected',
134
- type: 'boolean',
135
- summary: 'only the workspaces a diff touches, and everything that depends on one of them',
136
- },
137
- {
138
- name: 'base',
139
- type: 'string',
140
- summary: `--affected: git ref to diff against, merge-base style (default: ${DEFAULT_BASE})`,
141
- },
142
- {
143
- name: 'dirty',
144
- type: 'boolean',
145
- summary:
146
- '--affected: also count uncommitted work, whichever agent in this checkout made it',
147
- },
148
- ],
149
- },
104
+ spec: testSpec,
150
105
  async run(ctx: CommandContext): Promise<CommandResult> {
151
106
  const type = readOnlyType(ctx.args.positionals);
152
107
  const filter = flagString(ctx.args, 'filter');
@@ -0,0 +1,28 @@
1
+ // `x verify`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-verify.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+ import { WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
6
+
7
+ export const verifySpec: CommandSpec = {
8
+ name: 'verify',
9
+ summary: 'the gate: typecheck, lint, boundaries, all tests, drift, contract, budgets',
10
+ usage: 'x verify [--only <step>] [--workers N] [--json]',
11
+ requiresApp: true,
12
+ // Two flags, and only one of them narrows. `--workers` changes how wide the test steps
13
+ // spread, never which steps run. `--only` runs one step and says so in both renderers —
14
+ // never silently, which is the whole of what makes it safe to have.
15
+ flags: [
16
+ {
17
+ name: 'workers',
18
+ type: 'string',
19
+ summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
20
+ },
21
+ {
22
+ name: 'only',
23
+ type: 'string',
24
+ summary:
25
+ 'run ONE step by name — an iteration loop, NOT A GATE RUN; the gate is this command with no flag',
26
+ },
27
+ ],
28
+ };