@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
package/src/cmd-jobs.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import type { JobDriver } from '@ultimat3/jobs';
8
8
  import { cancelJob, createNatsDriver, createRedisDriver } from '@ultimat3/jobs';
9
9
  import { requireAppRoot } from './app-root';
10
+ import { DRAIN_TARGETS, jobsSpec } from './cmd-jobs-spec';
10
11
  import type { CliCommand, CommandContext } from './command';
11
12
  import { BadFlagError, JobUnknownError, MissingPositionalError } from './errors';
12
13
  import type { DrainOutcome } from './jobs-drain';
@@ -27,13 +28,7 @@ import { msg } from './messages';
27
28
  import type { CommandResult } from './output';
28
29
  import { flagBool, flagString } from './parse';
29
30
 
30
- export const JOBS_SUBCOMMANDS = ['ls', 'show', 'retry', 'cancel', 'drain'] as const;
31
-
32
- /**
33
- * The drivers a drain may move work ONTO — every one of them durable, and that is the whole rule.
34
- * Closed, and read three ways: the flag summary, the refusal, and the `memory` case below.
35
- */
36
- export const DRAIN_TARGETS = ['redis', 'nats'] as const;
31
+ export { DRAIN_TARGETS, JOBS_SUBCOMMANDS } from './cmd-jobs-spec';
37
32
 
38
33
  /**
39
34
  * `memory` was on that list until 2026-09 and could not be: `createMemoryDriver()` is a `Map` in
@@ -252,49 +247,7 @@ async function runDrain(
252
247
  }
253
248
 
254
249
  export const jobsCommand: CliCommand = {
255
- spec: {
256
- name: 'jobs',
257
- summary: 'list, show, retry, cancel and drain the job queue',
258
- usage:
259
- 'x jobs [ls|show <id>|retry <id>|cancel <id>|drain --to <driver>] [--queue q] [--state s] [--limit n] [--from-step name] [--reason text] [--to driver] [--dry-run] [--json]',
260
- requiresApp: true,
261
- subcommands: JOBS_SUBCOMMANDS,
262
- // The bare `x jobs` lists; it never retries, cancels or drains anything.
263
- defaultSubcommand: 'ls',
264
- flags: [
265
- { name: 'queue', type: 'string', summary: 'filter by queue name' },
266
- { name: 'state', type: 'string', summary: 'filter by job state' },
267
- { name: 'limit', type: 'string', summary: 'max rows to return' },
268
- { name: 'name', type: 'string', summary: 'filter by job name' },
269
- // Each of these is read by ONE subcommand — `retryJob`, `cancelJob`, `runDrain` — and says
270
- // so in its own summary. The scope is what makes the parser refuse it anywhere else instead
271
- // of accepting it and ignoring it: `x db gen --dry-run` parsed and wrote the migration.
272
- {
273
- name: 'from-step',
274
- type: 'string',
275
- summary: 'retry: drop this step so it re-executes',
276
- subcommands: ['retry'],
277
- },
278
- {
279
- name: 'reason',
280
- type: 'string',
281
- summary: 'cancel: why, recorded on the job',
282
- subcommands: ['cancel'],
283
- },
284
- {
285
- name: 'to',
286
- type: 'string',
287
- summary: `drain: target driver — ${DRAIN_TARGETS.join(', ')}`,
288
- subcommands: ['drain'],
289
- },
290
- {
291
- name: 'dry-run',
292
- type: 'boolean',
293
- summary: 'drain: report the plan, move nothing',
294
- subcommands: ['drain'],
295
- },
296
- ],
297
- },
250
+ spec: jobsSpec,
298
251
  async run(ctx: CommandContext): Promise<CommandResult> {
299
252
  const root = requireAppRoot('jobs', ctx.cwd).dir;
300
253
  const sub = ctx.args.subcommand ?? 'ls';
@@ -0,0 +1,15 @@
1
+ // `x manifest`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-manifest.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ export const manifestSpec: CommandSpec = {
7
+ name: 'manifest',
8
+ summary: 'regenerate x.manifest.json and openapi.json from the code',
9
+ usage: 'x manifest [--check] [--json]',
10
+ requiresApp: true,
11
+ flags: [
12
+ { name: 'check', type: 'boolean', summary: 'fail if the committed files are stale' },
13
+ { name: 'openapi', type: 'boolean', summary: 'also write openapi.json', default: true },
14
+ ],
15
+ };
@@ -5,9 +5,11 @@
5
5
  import { join } from 'node:path';
6
6
  import type { Manifest } from '@ultimat3/manifest';
7
7
  import { assertNoDrift, MANIFEST_FILENAME } from '@ultimat3/manifest';
8
- import { appManifest, writeAppManifest } from './app-manifest';
9
- import { OPENAPI_FILE, openApiJson } from './app-openapi';
8
+ import { writeAppArtifacts } from './app-artifacts';
9
+ import { appManifest } from './app-manifest';
10
+ import { openApiStaleness } from './app-openapi';
10
11
  import { requireAppRoot } from './app-root';
12
+ import { manifestSpec } from './cmd-manifest-spec';
11
13
  import type { CliCommand, CommandContext } from './command';
12
14
  import { msg } from './messages';
13
15
  import type { CommandResult, Finding, JsonValue } from './output';
@@ -41,28 +43,24 @@ async function staleness(root: string, manifest: Manifest): Promise<Finding | un
41
43
  }
42
44
 
43
45
  export const manifestCommand: CliCommand = {
44
- spec: {
45
- name: 'manifest',
46
- summary: 'regenerate x.manifest.json and openapi.json from the code',
47
- usage: 'x manifest [--check] [--json]',
48
- requiresApp: true,
49
- flags: [
50
- { name: 'check', type: 'boolean', summary: 'fail if the committed files are stale' },
51
- { name: 'openapi', type: 'boolean', summary: 'also write openapi.json', default: true },
52
- ],
53
- },
46
+ spec: manifestSpec,
54
47
  async run(ctx: CommandContext): Promise<CommandResult> {
55
48
  const root = requireAppRoot('manifest', ctx.cwd).dir;
56
49
  const { manifest, findings } = await appManifest(root);
57
50
  const counts = countsOf(manifest);
58
51
 
59
52
  if (flagBool(ctx.args, 'check')) {
60
- const stale = await staleness(root, manifest);
53
+ // BOTH files the command writes: `--check` compared only `x.manifest.json`, so a stale
54
+ // `openapi.json` — the one the typed client is generated from — read as fresh.
55
+ const stale = [
56
+ ...[await staleness(root, manifest)].filter((one) => one !== undefined),
57
+ ...(await openApiStaleness(root, manifest)),
58
+ ];
61
59
  return {
62
- ok: stale === undefined && findings.length === 0,
60
+ ok: stale.length === 0 && findings.length === 0,
63
61
  command: 'manifest',
64
- summary: stale === undefined ? msg('cli.manifest.fresh') : msg('cli.manifest.stale'),
65
- findings: stale === undefined ? findings : [...findings, stale],
62
+ summary: stale.length === 0 ? msg('cli.manifest.fresh') : msg('cli.manifest.stale'),
63
+ findings: [...findings, ...stale],
66
64
  data: { buildId: manifest.buildId, counts },
67
65
  };
68
66
  }
@@ -79,10 +77,11 @@ export const manifestCommand: CliCommand = {
79
77
  };
80
78
  }
81
79
 
82
- const path = await writeAppManifest(root, manifest);
83
- if (ctx.args.flags.get('openapi') !== false) {
84
- await Bun.write(join(root, OPENAPI_FILE), openApiJson(manifest));
85
- }
80
+ await writeAppArtifacts(root, manifest, {
81
+ openapi: ctx.args.flags.get('openapi') !== false,
82
+ onlyExisting: false,
83
+ });
84
+ const path = join(root, MANIFEST_FILENAME);
86
85
  return {
87
86
  ok: true,
88
87
  command: 'manifest',
@@ -0,0 +1,21 @@
1
+ // `x mcp`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-mcp.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ export const DEFAULT_PORT = 9229;
7
+
8
+ export const mcpSpec: CommandSpec = {
9
+ name: 'mcp',
10
+ summary: 'serve the dev tools: routes, schema, policies, db, queues, logs, tests, verify',
11
+ usage: 'x mcp tools | x mcp serve [--transport stdio|http] [--port 9229] [--json]',
12
+ requiresApp: true,
13
+ subcommands: ['serve', 'tools'],
14
+ // No default, deliberately: `wiki/CLI-Reference.md` already says to write `x mcp serve` rather
15
+ // than a bare `x mcp`, and the parser's old first-element guess made the bare form START A
16
+ // SERVER — the one thing a word typed by mistake must not do. Refusing enforces the guidance.
17
+ flags: [
18
+ { name: 'transport', type: 'string', summary: 'stdio | http', default: 'stdio' },
19
+ { name: 'port', type: 'string', summary: 'HTTP port', default: String(DEFAULT_PORT) },
20
+ ],
21
+ };
package/src/cmd-mcp.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import { markListening, nanoid, timingSafeEqual } from '@ultimat3/core';
7
7
  import { mcpHttpRoute, serveStdio } from '@ultimat3/mcp';
8
8
  import { requireAppRoot } from './app-root';
9
+ import { DEFAULT_PORT, mcpSpec } from './cmd-mcp-spec';
9
10
  import type { CliCommand, CommandContext } from './command';
10
11
  import { BadFlagError } from './errors';
11
12
  import { intFlagOr, PORT_RANGE } from './flag-number';
@@ -16,7 +17,6 @@ import { msg } from './messages';
16
17
  import type { CommandResult } from './output';
17
18
  import { flagString } from './parse';
18
19
 
19
- const DEFAULT_PORT = 9229;
20
20
  const TRANSPORTS = ['stdio', 'http'] as const;
21
21
  type Transport = (typeof TRANSPORTS)[number];
22
22
 
@@ -156,20 +156,7 @@ const readPort = (ctx: CommandContext): number =>
156
156
  );
157
157
 
158
158
  export const mcpCommand: CliCommand = {
159
- spec: {
160
- name: 'mcp',
161
- summary: 'serve the dev tools: routes, schema, policies, db, queues, logs, tests, verify',
162
- usage: 'x mcp tools | x mcp serve [--transport stdio|http] [--port 9229] [--json]',
163
- requiresApp: true,
164
- subcommands: ['serve', 'tools'],
165
- // No default, deliberately: `wiki/CLI-Reference.md` already says to write `x mcp serve` rather
166
- // than a bare `x mcp`, and the parser's old first-element guess made the bare form START A
167
- // SERVER — the one thing a word typed by mistake must not do. Refusing enforces the guidance.
168
- flags: [
169
- { name: 'transport', type: 'string', summary: 'stdio | http', default: 'stdio' },
170
- { name: 'port', type: 'string', summary: 'HTTP port', default: String(DEFAULT_PORT) },
171
- ],
172
- },
159
+ spec: mcpSpec,
173
160
  async run(ctx: CommandContext): Promise<CommandResult> {
174
161
  const root = requireAppRoot('mcp', ctx.cwd).dir;
175
162
  const transport = flagString(ctx.args, 'transport') ?? 'stdio';
@@ -0,0 +1,33 @@
1
+ // `x new`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-new.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ export const newSpec: CommandSpec = {
7
+ name: 'new',
8
+ summary: 'scaffold a new Ultimate monorepo that already runs',
9
+ // Every flag the table below declares, in the spelling that turns it off where the default is
10
+ // on: the usage line offered `--no-example` while the table listed `--example`, and a reader
11
+ // had to reconcile the two to answer "which one do I get if I type neither".
12
+ usage: 'x new <name> [--dir path] [--no-example] [--no-git] [--dry-run] [--force] [--json]',
13
+ flags: [
14
+ { name: 'dir', type: 'string', summary: 'parent directory (default: cwd)' },
15
+ {
16
+ // The summary carries the default and the negation because the page has to answer "which
17
+ // one do I get if I type neither": the usage line offered `--no-example`, this table said
18
+ // `--example`, and `default: true` is a field only `--json` renders. 136 files against 109.
19
+ name: 'example',
20
+ type: 'boolean',
21
+ summary: 'include the example feature slice (default: on; --no-example for an empty app/)',
22
+ default: true,
23
+ },
24
+ {
25
+ name: 'git',
26
+ type: 'boolean',
27
+ summary: 'git init and commit the scaffold (default: on; --no-git for a bare directory)',
28
+ default: true,
29
+ },
30
+ { name: 'dry-run', type: 'boolean', summary: 'print the file list, write nothing' },
31
+ { name: 'force', type: 'boolean', summary: 'write into a directory that already exists' },
32
+ ],
33
+ };
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,