@ultimat3/cli 21.0.0 → 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 (234) hide show
  1. package/CLAUDE.md +84 -1650
  2. package/README.md +40 -2
  3. package/package.json +30 -30
  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-load.ts +37 -5
  8. package/src/app-openapi.ts +27 -0
  9. package/src/app-permissions.ts +0 -0
  10. package/src/app-root.ts +1 -1
  11. package/src/async-pages.ts +32 -0
  12. package/src/bin.ts +7 -1
  13. package/src/boundary-findings.ts +36 -0
  14. package/src/browser-launcher-fake-html.ts +80 -0
  15. package/src/browser-launcher-fake.ts +165 -0
  16. package/src/browser-launcher-port.ts +159 -0
  17. package/src/browser-launcher.ts +26 -133
  18. package/src/budgets.ts +11 -1
  19. package/src/cdp-shot-a11y.ts +77 -0
  20. package/src/cdp-shot-clock.ts +14 -0
  21. package/src/cdp-shot-driver.ts +150 -0
  22. package/src/cdp-shot-element.ts +147 -0
  23. package/src/cdp-shot-errors.ts +62 -0
  24. package/src/cdp-shot-keys.ts +152 -0
  25. package/src/cdp-shot-page.ts +230 -0
  26. package/src/cdp-shot-watch.ts +241 -0
  27. package/src/cmd-affected-spec.ts +24 -0
  28. package/src/cmd-affected.ts +3 -19
  29. package/src/cmd-build-spec.ts +16 -0
  30. package/src/cmd-build.ts +46 -20
  31. package/src/cmd-ci-spec.ts +24 -0
  32. package/src/cmd-ci.ts +17 -21
  33. package/src/cmd-db-branch.ts +2 -2
  34. package/src/cmd-db-spec.ts +99 -0
  35. package/src/cmd-db.ts +15 -94
  36. package/src/cmd-deploy-helm.ts +136 -0
  37. package/src/cmd-deploy-spec.ts +40 -0
  38. package/src/cmd-deploy.ts +111 -43
  39. package/src/cmd-dev-spec.ts +25 -0
  40. package/src/cmd-dev.ts +55 -36
  41. package/src/cmd-docs-spec.ts +16 -0
  42. package/src/cmd-docs.ts +2 -11
  43. package/src/cmd-doctor-spec.ts +21 -0
  44. package/src/cmd-doctor.ts +4 -18
  45. package/src/cmd-env-spec.ts +18 -0
  46. package/src/cmd-env.ts +4 -13
  47. package/src/cmd-errors-spec.ts +23 -0
  48. package/src/cmd-errors.ts +3 -17
  49. package/src/cmd-fix-spec.ts +18 -0
  50. package/src/cmd-fix.ts +4 -13
  51. package/src/cmd-generate-spec.ts +31 -0
  52. package/src/cmd-generate.ts +46 -42
  53. package/src/cmd-i18n-spec.ts +16 -0
  54. package/src/cmd-i18n.ts +7 -14
  55. package/src/cmd-jobs-spec.ts +56 -0
  56. package/src/cmd-jobs.ts +3 -50
  57. package/src/cmd-manifest-spec.ts +15 -0
  58. package/src/cmd-manifest.ts +19 -20
  59. package/src/cmd-mcp-spec.ts +21 -0
  60. package/src/cmd-mcp.ts +2 -15
  61. package/src/cmd-new-spec.ts +33 -0
  62. package/src/cmd-new.ts +38 -33
  63. package/src/cmd-policy-spec.ts +13 -0
  64. package/src/cmd-policy.ts +2 -9
  65. package/src/cmd-pr-spec.ts +39 -0
  66. package/src/cmd-pr.ts +5 -65
  67. package/src/cmd-registries-spec.ts +32 -0
  68. package/src/cmd-registries.ts +4 -25
  69. package/src/cmd-routes-spec.ts +12 -0
  70. package/src/cmd-routes.ts +2 -7
  71. package/src/cmd-secrets-spec.ts +19 -0
  72. package/src/cmd-secrets.ts +22 -34
  73. package/src/cmd-shot-island.ts +12 -13
  74. package/src/cmd-shot-spec.ts +53 -0
  75. package/src/cmd-shot.ts +11 -60
  76. package/src/cmd-tasks-spec.ts +21 -0
  77. package/src/cmd-tasks.ts +2 -16
  78. package/src/cmd-test-spec.ts +54 -0
  79. package/src/cmd-test.ts +4 -49
  80. package/src/cmd-verify-spec.ts +28 -0
  81. package/src/cmd-verify.ts +3 -23
  82. package/src/db-seed.ts +4 -2
  83. package/src/dev-dashboard.ts +4 -4
  84. package/src/dev-lock.ts +18 -2
  85. package/src/dev-port.ts +21 -0
  86. package/src/dev-route-table.ts +3 -3
  87. package/src/dispatch.ts +12 -5
  88. package/src/document-styles.ts +1 -1
  89. package/src/drift.ts +4 -18
  90. package/src/error-catalog.ts +16 -9
  91. package/src/error-codes.ts +28 -30
  92. package/src/error-pages.ts +19 -7
  93. package/src/errors.ts +14 -0
  94. package/src/favicon.ts +2 -2
  95. package/src/fix-imports.ts +1 -1
  96. package/src/fix-scan.ts +2 -9
  97. package/src/flag-reads.ts +1 -2
  98. package/src/foreign-text.ts +36 -0
  99. package/src/framework-schema.ts +5 -6
  100. package/src/generate-feature.ts +42 -0
  101. package/src/generate-files.ts +7 -3
  102. package/src/generate-grants.ts +83 -0
  103. package/src/generate-kinds.ts +56 -4
  104. package/src/i18n-index.ts +59 -10
  105. package/src/icon-assets.ts +1 -1
  106. package/src/image-prepare.ts +14 -0
  107. package/src/import-scan.ts +63 -0
  108. package/src/index.ts +67 -348
  109. package/src/invocation-flags.ts +26 -0
  110. package/src/island-bundle.ts +6 -1
  111. package/src/island-capture.ts +6 -6
  112. package/src/island-harness-script.ts +6 -2
  113. package/src/island-realtime.ts +15 -8
  114. package/src/island-shot.ts +5 -0
  115. package/src/island-store.ts +131 -0
  116. package/src/island-verdict.ts +1 -1
  117. package/src/job-registration.ts +42 -0
  118. package/src/jobs-driver.ts +2 -2
  119. package/src/load-findings.ts +51 -0
  120. package/src/mcp-db-target.ts +1 -1
  121. package/src/mcp-errors.ts +28 -27
  122. package/src/mcp-host.ts +27 -12
  123. package/src/mcp-ui-diff.ts +27 -0
  124. package/src/mcp-ui-inspect.ts +4 -4
  125. package/src/mcp-ui-interact.ts +21 -15
  126. package/src/mcp-ui.ts +19 -15
  127. package/src/measure-database.ts +73 -0
  128. package/src/measure-paths.ts +74 -0
  129. package/src/measure-scope.ts +74 -0
  130. package/src/messages.ts +1 -3
  131. package/src/metrics-endpoint.ts +1 -1
  132. package/src/otlp-export.ts +1 -1
  133. package/src/output.ts +6 -0
  134. package/src/permission-grants.ts +86 -0
  135. package/src/prerender-out.ts +25 -0
  136. package/src/prerender.ts +121 -102
  137. package/src/pwa-artifacts.ts +3 -3
  138. package/src/reexport-manifest.ts +2 -1
  139. package/src/registry.ts +80 -56
  140. package/src/role-realtime.ts +36 -0
  141. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  142. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  143. package/src/role-start-types.ts +112 -0
  144. package/src/{dev-roles.ts → role-start.ts} +42 -115
  145. package/src/{dev-sync.ts → role-sync.ts} +18 -8
  146. package/src/root-env.ts +67 -0
  147. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  148. package/src/{dev-services.ts → runtime-bindings.ts} +39 -16
  149. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  150. package/src/runtime-jobs.ts +87 -0
  151. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +18 -5
  152. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  153. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  154. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  155. package/src/runtime-realtime.ts +55 -0
  156. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  157. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  158. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  159. package/src/scaffold-fixture.ts +28 -6
  160. package/src/scaffold-typecheck.ts +6 -3
  161. package/src/schema-drift.ts +7 -1
  162. package/src/script-csp.ts +5 -2
  163. package/src/secrets-rotation.ts +59 -0
  164. package/src/serve-boot.ts +192 -0
  165. package/src/serve-drain.ts +24 -0
  166. package/src/serve-entry.ts +6 -0
  167. package/src/serve-env.ts +116 -0
  168. package/src/serve-types.ts +55 -0
  169. package/src/serve.ts +44 -347
  170. package/src/shot-server.ts +2 -2
  171. package/src/shot-settle.ts +10 -1
  172. package/src/shot-theme.ts +3 -3
  173. package/src/shot-verdict.ts +16 -7
  174. package/src/signal-shred.ts +27 -0
  175. package/src/solid-loader.ts +26 -2
  176. package/src/static-report.ts +8 -1
  177. package/src/templates/action.ts +30 -16
  178. package/src/templates/entity.ts +12 -7
  179. package/src/templates/index.ts +1 -1
  180. package/src/templates/job.ts +10 -7
  181. package/src/templates/policy.ts +20 -2
  182. package/src/templates/resource-create.ts +127 -0
  183. package/src/templates/resource-form-island.ts +49 -12
  184. package/src/templates/resource.ts +10 -4
  185. package/src/templates/scaffold-app.ts +4 -1
  186. package/src/templates/scaffold-auth.ts +3 -1
  187. package/src/templates/scaffold-container-compose.ts +184 -0
  188. package/src/templates/scaffold-container.ts +24 -159
  189. package/src/templates/scaffold-dashboard-example.ts +2 -2
  190. package/src/templates/scaffold-db-package.ts +16 -3
  191. package/src/templates/scaffold-demo-org.ts +41 -0
  192. package/src/templates/scaffold-entries.ts +1 -1
  193. package/src/templates/scaffold-helm-templates.ts +66 -7
  194. package/src/templates/scaffold-helm.ts +27 -5
  195. package/src/templates/scaffold-i18n.ts +15 -10
  196. package/src/templates/scaffold-repo.ts +13 -10
  197. package/src/templates/scaffold-roles.ts +38 -10
  198. package/src/templates/slice-foundation.ts +1 -1
  199. package/src/templates/wrap.ts +4 -1
  200. package/src/test-passes.ts +2 -1
  201. package/src/test-workers.ts +26 -0
  202. package/src/ts-scan.ts +3 -6
  203. package/src/tsconfig-references.ts +1 -2
  204. package/src/verify-checks.ts +41 -39
  205. package/src/verify-e2e.ts +10 -7
  206. package/src/verify-run.ts +11 -1
  207. package/src/verify-step.ts +3 -3
  208. package/src/verify-tests.ts +1 -26
  209. package/src/verify-typecheck.ts +28 -0
  210. package/src/web-binding.ts +2 -2
  211. package/src/workspace-graph.ts +10 -33
  212. package/src/cdp-browser.ts +0 -94
  213. package/src/cdp-connection.ts +0 -247
  214. package/src/cdp-e2e-page.ts +0 -180
  215. package/src/cdp-e2e-session.ts +0 -199
  216. package/src/cdp-errors.ts +0 -56
  217. package/src/cdp-launch.ts +0 -193
  218. package/src/cdp-offline-script.ts +0 -73
  219. package/src/cdp-pipe.ts +0 -77
  220. package/src/e2e-app.ts +0 -103
  221. package/src/e2e-browser-handle.ts +0 -55
  222. package/src/e2e-dom-fixture.ts +0 -117
  223. package/src/e2e-driver.ts +0 -117
  224. package/src/e2e-errors.ts +0 -117
  225. package/src/e2e-evaluate.ts +0 -156
  226. package/src/e2e-locator.ts +0 -86
  227. package/src/e2e-page.ts +0 -153
  228. package/src/e2e-preload.ts +0 -64
  229. package/src/e2e-probe.ts +0 -23
  230. package/src/e2e-selection.ts +0 -182
  231. package/src/e2e-spawn.ts +0 -169
  232. package/src/measurement-actor.ts +0 -26
  233. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
  234. /package/src/{dev-render.ts → runtime-render.ts} +0 -0
package/src/cmd-new.ts CHANGED
@@ -7,13 +7,16 @@ import { chmod } from 'node:fs/promises';
7
7
  import { isAbsolute, join, resolve } from 'node:path';
8
8
  import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
9
9
  import { dedupe } from './cmd-generate';
10
+ import { newSpec } from './cmd-new-spec';
10
11
  import type { CliCommand, CommandContext } from './command';
11
12
  import { invocationOf } from './command';
12
- import { AppNameIsPathError, MissingPositionalError } from './errors';
13
+ import { AppNameEmptyError, AppNameIsPathError, MissingPositionalError } from './errors';
13
14
  import type { Runner } from './exec';
15
+ import { reproducedFlags } from './invocation-flags';
14
16
  import { msg } from './messages';
15
17
  import type { CommandResult } from './output';
16
18
  import { flagBool, flagString } from './parse';
19
+ import { quoteArg } from './shell-quote';
17
20
  import type { GeneratedFile } from './templates';
18
21
  import { appFiles, EXECUTABLE_FILES, names, repoFiles, resourceFiles } from './templates';
19
22
  import { loadVersion } from './version-loader';
@@ -59,6 +62,17 @@ const GIT_STEPS: readonly (readonly string[])[] = [
59
62
  /** What `--no-git` records, so `data.git` has the same shape whichever way the flag went. */
60
63
  const NO_GIT: RepositoryInit = { initialized: false, committed: false, problem: SKIPPED };
61
64
 
65
+ /** `--force` into a directory that already existed: its files are not ours to commit. */
66
+ const PRE_EXISTING: RepositoryInit = {
67
+ initialized: false,
68
+ committed: false,
69
+ problem: 'the directory already existed, so nothing was committed — its files are yours',
70
+ };
71
+
72
+ /** Every flag the caller set, in the spelling that reproduces it (`invocation-flags.ts`). */
73
+ const callerFlags = (ctx: CommandContext): readonly string[] =>
74
+ reproducedFlags(newCommand.spec, ctx.args);
75
+
62
76
  /**
63
77
  * A scaffold is a REPOSITORY, because three surfaces of this CLI already assume one and answered
64
78
  * `not a git repository` in a fresh app: `x affected`, `x ci` and `x pr`. It was four —
@@ -151,34 +165,7 @@ export function appNamePath(raw: string): { parent: string; base: string } | und
151
165
  }
152
166
 
153
167
  export const newCommand: CliCommand = {
154
- spec: {
155
- name: 'new',
156
- summary: 'scaffold a new Ultimate monorepo that already runs',
157
- // Every flag the table below declares, in the spelling that turns it off where the default is
158
- // on: the usage line offered `--no-example` while the table listed `--example`, and a reader
159
- // had to reconcile the two to answer "which one do I get if I type neither".
160
- usage: 'x new <name> [--dir path] [--no-example] [--no-git] [--dry-run] [--force] [--json]',
161
- flags: [
162
- { name: 'dir', type: 'string', summary: 'parent directory (default: cwd)' },
163
- {
164
- // The summary carries the default and the negation because the page has to answer "which
165
- // one do I get if I type neither": the usage line offered `--no-example`, this table said
166
- // `--example`, and `default: true` is a field only `--json` renders. 136 files against 109.
167
- name: 'example',
168
- type: 'boolean',
169
- summary: 'include the example feature slice (default: on; --no-example for an empty app/)',
170
- default: true,
171
- },
172
- {
173
- name: 'git',
174
- type: 'boolean',
175
- summary: 'git init and commit the scaffold (default: on; --no-git for a bare directory)',
176
- default: true,
177
- },
178
- { name: 'dry-run', type: 'boolean', summary: 'print the file list, write nothing' },
179
- { name: 'force', type: 'boolean', summary: 'write into a directory that already exists' },
180
- ],
181
- },
168
+ spec: newSpec,
182
169
  async run(ctx: CommandContext): Promise<CommandResult> {
183
170
  const raw = ctx.args.positionals[0];
184
171
  // The class, not a hand-built finding with the same code: `MissingPositionalError` is what
@@ -202,6 +189,13 @@ export const newCommand: CliCommand = {
202
189
  throw new AppNameIsPathError({ name: raw, invocation: invocationOf(ctx, 'new'), ...path });
203
190
  }
204
191
  const app = names(raw);
192
+ if (app.kebab === '') {
193
+ throw new AppNameEmptyError({
194
+ name: raw,
195
+ invocation: invocationOf(ctx, 'new'),
196
+ flags: callerFlags(ctx),
197
+ });
198
+ }
205
199
  const target = resolve(parentDir(ctx.cwd, flagString(ctx.args, 'dir')), app.kebab);
206
200
  const options: NewAppOptions = { name: raw, example: ctx.args.flags.get('example') !== false };
207
201
 
@@ -224,22 +218,33 @@ export const newCommand: CliCommand = {
224
218
  {
225
219
  code: 'X_GENERATE_CONFLICT',
226
220
  cause: `${target} already exists`,
227
- fix: `x new ${app.kebab} --force, or choose another name`,
221
+ // The invocation the caller ran, with every flag it set: dropping `--dir` wrote a
222
+ // second app into the cwd, and the literal `x` is not installed under create-ultimate.
223
+ fix: `${[invocationOf(ctx, 'new'), quoteArg(app.kebab), ...callerFlags(ctx), '--force'].join(' ')} # or choose another name`,
228
224
  docs: ERROR_DOCS_URL,
229
225
  at: target,
230
226
  },
231
227
  ],
232
228
  };
233
229
  }
230
+ // Read BEFORE writing: `--force` into a directory that already held files must never
231
+ // `git add -A && git commit` them — they are the user's, and `x new` did not write them.
232
+ const existed = existsSync(target);
234
233
  const written = await writeNewApp(target, options);
235
234
  const git =
236
- ctx.args.flags.get('git') === false ? NO_GIT : await initRepository(ctx.runner, target);
235
+ ctx.args.flags.get('git') === false
236
+ ? NO_GIT
237
+ : existed
238
+ ? PRE_EXISTING
239
+ : await initRepository(ctx.runner, target);
237
240
  const lines = [msg('cli.new.wrote', { count: written.files.length, dir: target })];
238
- if (git.problem !== null && git.problem !== SKIPPED) {
241
+ if (git.problem !== null && git.problem !== SKIPPED && git !== PRE_EXISTING) {
239
242
  lines.push(msg('cli.new.noRepository', { problem: git.problem }));
240
243
  // Raw, unlike the two prose lines around it: this one is an instruction to run verbatim, and
241
244
  // a translated command is a broken one (`packages/cli/CLAUDE.md`).
242
- lines.push(` run: cd ${target} && git init && git add -A && git commit -m 'x new'`);
245
+ lines.push(
246
+ ` run: cd ${quoteArg(target)} && git init && git add -A && git commit -m 'x new'`,
247
+ );
243
248
  }
244
249
  return {
245
250
  ok: true,
@@ -0,0 +1,13 @@
1
+ // `x policy`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-policy.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ export const policySpec: CommandSpec = {
7
+ name: 'policy',
8
+ summary: 'which clause decided a permission, and why',
9
+ usage: 'x policy [list|explain <subject>] [--json]',
10
+ requiresApp: true,
11
+ subcommands: ['list', 'explain'],
12
+ defaultSubcommand: 'list',
13
+ };
package/src/cmd-policy.ts CHANGED
@@ -5,11 +5,11 @@
5
5
  import { nearestName } from '@ultimat3/core';
6
6
  import { loadApp } from './app-load';
7
7
  import { requireAppRoot } from './app-root';
8
+ import { policySpec } from './cmd-policy-spec';
8
9
  import type { CliCommand, CommandContext } from './command';
9
10
  import { DeclarationUnknownError, MissingPositionalError } from './errors';
10
11
  import { msg } from './messages';
11
12
  import type { CommandResult, Finding, JsonValue } from './output';
12
-
13
13
  import type { DeclarationExplanation } from './policy-facts';
14
14
  import { explainPolicy, knownPolicySubjects, listPolicy } from './policy-facts';
15
15
  import { renderTable } from './table';
@@ -123,14 +123,7 @@ function runExplain(ctx: CommandContext, findings: readonly Finding[]): CommandR
123
123
  }
124
124
 
125
125
  export const policyCommand: CliCommand = {
126
- spec: {
127
- name: 'policy',
128
- summary: 'which clause decided a permission, and why',
129
- usage: 'x policy [list|explain <subject>] [--json]',
130
- requiresApp: true,
131
- subcommands: ['list', 'explain'],
132
- defaultSubcommand: 'list',
133
- },
126
+ spec: policySpec,
134
127
  async run(ctx: CommandContext): Promise<CommandResult> {
135
128
  const root = requireAppRoot('policy', ctx.cwd).dir;
136
129
  const { findings } = await loadApp(root);
@@ -0,0 +1,39 @@
1
+ // `x pr`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-pr.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ export const PR_SUBCOMMANDS = ['review', 'resolve', 'reply'] as const;
7
+
8
+ export const prSpec: CommandSpec = {
9
+ name: 'pr',
10
+ summary: 'inline review threads: list them with their ids, resolve one, reply in one',
11
+ usage:
12
+ 'x pr review [--pr <n>] [--repo owner/name] [--all] [--full] | x pr resolve <thread-id> | x pr reply <thread-id> --body "…"',
13
+ subcommands: PR_SUBCOMMANDS,
14
+ flags: [
15
+ { name: 'repo', type: 'string', summary: 'owner/name; the checkout own remote by default' },
16
+ { name: 'pr', type: 'string', summary: 'pull request number; this branch own by default' },
17
+ // Scoped to the subcommand each summary already names: `resolve` and `reply` WRITE to
18
+ // somebody else's pull request, and a flag they silently ignore is a flag whose caller
19
+ // believed it did something to a request that cannot be re-run.
20
+ {
21
+ name: 'all',
22
+ type: 'boolean',
23
+ summary: 'review: resolved threads too, not just open ones',
24
+ subcommands: ['review'],
25
+ },
26
+ {
27
+ name: 'full',
28
+ type: 'boolean',
29
+ summary: 'review: whole comment bodies, never truncated',
30
+ subcommands: ['review'],
31
+ },
32
+ {
33
+ name: 'body',
34
+ type: 'string',
35
+ summary: 'reply: the comment text to post in the thread',
36
+ subcommands: ['reply'],
37
+ },
38
+ ],
39
+ };
package/src/cmd-pr.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // `reviewDecision` outlives the push that answered it, and a RESOLVED thread is a closed
6
6
  // conversation rather than a fixed finding.
7
7
 
8
+ import { PR_SUBCOMMANDS, prSpec } from './cmd-pr-spec';
8
9
  import type { CliCommand, CommandContext } from './command';
9
10
  import {
10
11
  BadFlagError,
@@ -13,6 +14,7 @@ import {
13
14
  UnknownCommandError,
14
15
  } from './errors';
15
16
  import { parseIntFlag } from './flag-number';
17
+ import { commentBlock } from './foreign-text';
16
18
  import { PrNotFoundError, resolvePrNumber, resolveRepo } from './gh-target';
17
19
  import { msg } from './messages';
18
20
  import type { CommandResult, JsonValue } from './output';
@@ -20,7 +22,7 @@ import { flagBool, flagString } from './parse';
20
22
  import type { PrReviewReport, PrThread } from './pr-threads';
21
23
  import { fetchReviewReport, replyToThread, resolveThread, THREAD_PAGE } from './pr-threads';
22
24
 
23
- export const PR_SUBCOMMANDS = ['review', 'resolve', 'reply'] as const;
25
+ export { PR_SUBCOMMANDS } from './cmd-pr-spec';
24
26
 
25
27
  /**
26
28
  * Every catalog key this command renders, declared. `msg()` answers `⟦key⟧` for a key nobody
@@ -51,38 +53,7 @@ export const PR_MESSAGE_KEYS = [
51
53
  export const BODY_LINES = 20;
52
54
 
53
55
  export const prCommand: CliCommand = {
54
- spec: {
55
- name: 'pr',
56
- summary: 'inline review threads: list them with their ids, resolve one, reply in one',
57
- usage:
58
- 'x pr review [--pr <n>] [--repo owner/name] [--all] [--full] | x pr resolve <thread-id> | x pr reply <thread-id> --body "…"',
59
- subcommands: PR_SUBCOMMANDS,
60
- flags: [
61
- { name: 'repo', type: 'string', summary: 'owner/name; the checkout own remote by default' },
62
- { name: 'pr', type: 'string', summary: 'pull request number; this branch own by default' },
63
- // Scoped to the subcommand each summary already names: `resolve` and `reply` WRITE to
64
- // somebody else's pull request, and a flag they silently ignore is a flag whose caller
65
- // believed it did something to a request that cannot be re-run.
66
- {
67
- name: 'all',
68
- type: 'boolean',
69
- summary: 'review: resolved threads too, not just open ones',
70
- subcommands: ['review'],
71
- },
72
- {
73
- name: 'full',
74
- type: 'boolean',
75
- summary: 'review: whole comment bodies, never truncated',
76
- subcommands: ['review'],
77
- },
78
- {
79
- name: 'body',
80
- type: 'string',
81
- summary: 'reply: the comment text to post in the thread',
82
- subcommands: ['reply'],
83
- },
84
- ],
85
- },
56
+ spec: prSpec,
86
57
  async run(ctx: CommandContext): Promise<CommandResult> {
87
58
  // No `defaultSubcommand`: `resolve` and `reply` both WRITE to a pull request, so "whatever the
88
59
  // caller left out" is not a safe guess for any of the three.
@@ -201,38 +172,7 @@ function threadLines(thread: PrThread, bodyLines: number): readonly string[] {
201
172
  return out;
202
173
  }
203
174
 
204
- const BLOCK_OPEN = '<comment id=';
205
- const BLOCK_CLOSE = '</comment>';
206
-
207
- /**
208
- * The fence, as a READER would parse it rather than as this file spells it.
209
- *
210
- * Two literal `replaceAll`s were the whole neutralisation, and markup is not spelled one way:
211
- * `</comment >`, `</COMMENT>` and `< comment id=` all end or open a block for anything reading
212
- * tags, and none of the three matched. One pattern over `<`, an optional `/`, and whitespace
213
- * around a case-insensitive `comment` covers every spelling of the delimiter; the escape goes on
214
- * the `<`, so what the reviewer wrote after it survives byte for byte.
215
- */
216
- const BLOCK_DELIMITER = /<(\s*\/?\s*comment\b)/gi;
217
-
218
- /**
219
- * One comment body, fenced and labelled with the thread id it came from — `@ultimat3/ai`'s
220
- * `documentBlock` (`rag.ts`), applied to the other place foreign text enters an agent's context.
221
- * `x pr review` exists because an agent cannot read the GitHub web UI, and a review body is
222
- * written by anyone who can comment on the pull request: rendered as bare indented text it arrived
223
- * in that agent's context indistinguishable from the command's own output, which is prompt
224
- * injection with a shell attached.
225
- *
226
- * The fence is neutralised INSIDE the payload rather than deleted, so every word the reviewer
227
- * wrote still reads, and the label is stripped of the three characters that would end the
228
- * attribute. Influence only, and deliberately not sold as more: a fence tells a reader this text
229
- * is data, and it can never stop one that decides otherwise.
230
- */
231
- export function commentBlock(id: string, lines: readonly string[]): readonly string[] {
232
- const label = id.replaceAll('"', "'").replaceAll('>', ')').replaceAll('<', '(');
233
- const body = lines.map((line) => line.replace(BLOCK_DELIMITER, '<\\$1'));
234
- return [`${BLOCK_OPEN}"${label}">`, ...body, BLOCK_CLOSE];
235
- }
175
+ export { commentBlock };
236
176
 
237
177
  /**
238
178
  * A review body is prose written for a browser: the ones in this repo run to six thousand
@@ -0,0 +1,32 @@
1
+ // `x actions`, `x queries` and `x entities`' declarations, apart from their body: the parser, `x help`
2
+ // and the `errors` step read them without loading `cmd-registries.ts` and the three registries it
3
+ // projects, which `registry.ts` imports only when one of the commands runs.
4
+
5
+ import type { CommandSpec } from './parse';
6
+
7
+ export const actionsSpec: CommandSpec = {
8
+ name: 'actions',
9
+ summary: 'the action registry: input/output schema, policy, tags, MCP exposure',
10
+ usage: 'x actions [list|describe <name>] [--json]',
11
+ subcommands: ['list', 'describe'],
12
+ defaultSubcommand: 'list',
13
+ requiresApp: true,
14
+ };
15
+
16
+ export const queriesSpec: CommandSpec = {
17
+ name: 'queries',
18
+ summary: 'the query registry: schema, policy, live, cache tags',
19
+ usage: 'x queries [list|describe <name>] [--json]',
20
+ subcommands: ['list', 'describe'],
21
+ defaultSubcommand: 'list',
22
+ requiresApp: true,
23
+ };
24
+
25
+ export const entitiesSpec: CommandSpec = {
26
+ name: 'entities',
27
+ summary: 'the entity registry: columns, invariants, indexes, tenancy',
28
+ usage: 'x entities [list|describe <name>] [--json]',
29
+ subcommands: ['list', 'describe'],
30
+ defaultSubcommand: 'list',
31
+ requiresApp: true,
32
+ };
@@ -13,12 +13,12 @@ import type { AnyQuery, QueryDescriptor } from '@ultimat3/query';
13
13
  import { describeQueries, getQuery } from '@ultimat3/query';
14
14
  import { loadApp } from './app-load';
15
15
  import { requireAppRoot } from './app-root';
16
+ import { actionsSpec, entitiesSpec, queriesSpec } from './cmd-registries-spec';
16
17
  import type { CliCommand, CommandContext } from './command';
17
18
  import { DeclarationUnknownError, MissingPositionalError } from './errors';
18
19
  import { msg } from './messages';
19
20
  import type { CommandResult, Finding, JsonValue } from './output';
20
21
  import type { CommandSpec } from './parse';
21
-
22
22
  import { renderTable } from './table';
23
23
 
24
24
  /**
@@ -53,14 +53,7 @@ interface RegistryKind<D extends { readonly name: string }, Raw extends { descri
53
53
  const ACTIONS: RegistryKind<ActionDescriptor, AnyAction> = {
54
54
  kind: 'actions',
55
55
  singular: 'action',
56
- spec: {
57
- name: 'actions',
58
- summary: 'the action registry: input/output schema, policy, tags, MCP exposure',
59
- usage: 'x actions [list|describe <name>] [--json]',
60
- subcommands: ['list', 'describe'],
61
- defaultSubcommand: 'list',
62
- requiresApp: true,
63
- },
56
+ spec: actionsSpec,
64
57
  header: ['name', 'verb', 'resource', 'path', 'capability', 'mcp'],
65
58
  list: describeActions,
66
59
  find: getAction,
@@ -71,14 +64,7 @@ const ACTIONS: RegistryKind<ActionDescriptor, AnyAction> = {
71
64
  const QUERIES: RegistryKind<QueryDescriptor, AnyQuery> = {
72
65
  kind: 'queries',
73
66
  singular: 'query',
74
- spec: {
75
- name: 'queries',
76
- summary: 'the query registry: schema, policy, live, cache tags',
77
- usage: 'x queries [list|describe <name>] [--json]',
78
- subcommands: ['list', 'describe'],
79
- defaultSubcommand: 'list',
80
- requiresApp: true,
81
- },
67
+ spec: queriesSpec,
82
68
  header: ['name', 'live', 'capability', 'tags', 'ttlMs'],
83
69
  list: describeQueries,
84
70
  find: getQuery,
@@ -97,14 +83,7 @@ const QUERIES: RegistryKind<QueryDescriptor, AnyQuery> = {
97
83
  const ENTITIES: RegistryKind<EntityDescription, RegistryEntry> = {
98
84
  kind: 'entities',
99
85
  singular: 'entity',
100
- spec: {
101
- name: 'entities',
102
- summary: 'the entity registry: columns, invariants, indexes, tenancy',
103
- usage: 'x entities [list|describe <name>] [--json]',
104
- subcommands: ['list', 'describe'],
105
- defaultSubcommand: 'list',
106
- requiresApp: true,
107
- },
86
+ spec: entitiesSpec,
108
87
  header: ['name', 'table', 'columns', 'invariants', 'indexes', 'orgScoped'],
109
88
  list: describeEntities,
110
89
  find: getEntity,
@@ -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