@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,40 @@
1
+ // `x deploy`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-deploy.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ export const deploySpec: CommandSpec = {
7
+ name: 'deploy',
8
+ summary: 'run the container deploy plan: migrate first, then the serving roles',
9
+ usage:
10
+ 'x deploy --image repo/app:tag [--method compose|helm] [--release name] [--namespace ns] [--timeout 15m] [--dry-run] [--json]',
11
+ requiresApp: true,
12
+ flags: [
13
+ { name: 'image', type: 'string', summary: 'image reference to deploy' },
14
+ { name: 'method', type: 'string', summary: 'compose | helm', default: 'compose' },
15
+ { name: 'dry-run', type: 'boolean', summary: 'print the plan, run nothing' },
16
+ {
17
+ name: 'release',
18
+ type: 'string',
19
+ summary: 'helm release name (default: app.config.ts name)',
20
+ },
21
+ { name: 'namespace', type: 'string', summary: 'helm namespace (default: the kube context)' },
22
+ {
23
+ name: 'timeout',
24
+ type: 'string',
25
+ // No `default:` — the parser would then fill it on every compose deploy too, and the refusal
26
+ // of a helm-only flag there could not tell an operator's value from its own.
27
+ summary: 'how long helm waits for the migrate hook and the rollout (default 15m)',
28
+ },
29
+ // `--critical` was here and is gone. It parsed, it was echoed into the plan JSON as
30
+ // `critical: <bool>`, and no file in `packages/` read that field — so the flag changed
31
+ // nothing about what `x deploy` did, on either method. `flag-reads.ts`'s
32
+ // `X_CLI_FLAG_UNREAD` passed it, because that gate proves a flag is READ and this one was:
33
+ // into a field with no reader. It is not coming back: `@ultimat3/pwa`'s
34
+ // `updateSignal({ reason: 'security' })`, the call it was to have triggered, is **deleted**
35
+ // as of 9.0.0 for having had no runtime caller of its own, and nothing in the framework
36
+ // force-navigates a client. A deploy also has no channel to one — the plan is
37
+ // `docker compose up` / `helm upgrade`, and the client's build id is read by `http` (tier 2)
38
+ // and `sync` (tier 3), neither of which may import a tier-4 package to act on it.
39
+ ],
40
+ };
package/src/cmd-deploy.ts CHANGED
@@ -5,12 +5,22 @@
5
5
  import { join } from 'node:path';
6
6
  import { ERROR_DOCS_URL } from '@ultimat3/core';
7
7
  import { requireAppRoot } from './app-root';
8
+ import {
9
+ type HelmTarget,
10
+ helmUpgradeArgs,
11
+ readHelmTimeout,
12
+ readLabel,
13
+ readReleaseName,
14
+ readRollout,
15
+ } from './cmd-deploy-helm';
16
+ import { deploySpec } from './cmd-deploy-spec';
8
17
  import type { CliCommand, CommandContext } from './command';
9
18
  import { BadFlagError, UnknownCommandError } from './errors';
10
19
  import { msg } from './messages';
11
20
  import type { CommandResult, JsonValue } from './output';
12
21
  import { flagBool, flagString } from './parse';
13
22
  import { quoteArg } from './shell-quote';
23
+ import { PROD_ENV_FILE } from './templates/scaffold-container-compose';
14
24
 
15
25
  /**
16
26
  * Ordered, and the order is the design. `migrate` GATES — it runs to completion before anything
@@ -104,8 +114,40 @@ export function helmImageOverrides(image: string): readonly string[] {
104
114
  : ['--set', `image.repository=${repository}`, '--set', `image.tag=${tag}`];
105
115
  }
106
116
 
107
- export function planDeploy(image: string, method: DeployMethod, root: string): DeployPlan {
117
+ /** What was asked for: the method, and for helm the release it is aimed at. */
118
+ export type DeployRequest =
119
+ | { readonly method: 'compose' }
120
+ | ({ readonly method: 'helm' } & HelmTarget);
121
+
122
+ /**
123
+ * The plan for one method. A helm plan takes its target — release, namespace, timeout — because
124
+ * the release is read off `app.config.ts`, which only the command can import; the compose plan
125
+ * needs none, so its three-argument form is unchanged.
126
+ */
127
+ export function planDeploy(image: string, method: 'compose', root: string): DeployPlan;
128
+ export function planDeploy(
129
+ image: string,
130
+ method: 'helm',
131
+ root: string,
132
+ target: HelmTarget,
133
+ ): DeployPlan;
134
+ export function planDeploy(
135
+ image: string,
136
+ method: DeployMethod,
137
+ root: string,
138
+ target?: HelmTarget,
139
+ ): DeployPlan {
108
140
  if (method === 'helm') {
141
+ // A caller from plain JS can still reach this without one; `app` for every app is the
142
+ // defect the target exists to end, so it is refused rather than defaulted back.
143
+ if (target === undefined) {
144
+ throw new BadFlagError({
145
+ flag: 'release',
146
+ command: 'deploy',
147
+ reason: 'a helm plan names its release, and planDeploy was called with no target',
148
+ fix: 'x deploy --method helm --release my-app --json',
149
+ });
150
+ }
109
151
  // `repo@sha256:…` is a reference this chart cannot express: it renders `repository:tag` and
110
152
  // has no digest branch, so passing one through would deploy `repo@sha256:…:<appVersion>` —
111
153
  // a tag no registry has. Refused here rather than by a `helm upgrade` failing halfway.
@@ -120,19 +162,7 @@ export function planDeploy(image: string, method: DeployMethod, root: string): D
120
162
  return {
121
163
  image,
122
164
  env: {},
123
- steps: [
124
- {
125
- role: 'all',
126
- command: [
127
- 'helm',
128
- 'upgrade',
129
- '--install',
130
- 'app',
131
- join(root, 'docker', 'helm'),
132
- ...helmImageOverrides(image),
133
- ],
134
- },
135
- ],
165
+ steps: [{ role: 'all', command: helmUpgradeArgs(root, target, helmImageOverrides(image)) }],
136
166
  };
137
167
  }
138
168
  return {
@@ -146,6 +176,12 @@ export function planDeploy(image: string, method: DeployMethod, root: string): D
146
176
  command: [
147
177
  'docker',
148
178
  'compose',
179
+ // Compose interpolates `${SYNC_URL:?…}` and `${POSTGRES_PASSWORD:?…}` from the shell and
180
+ // `--env-file` only — never from a service's `env_file:`. Without this an operator who put
181
+ // them in `.env.production`, the one file the compose file tells them to fill, had every
182
+ // step die on a parse error. Global flag, so it precedes `-f`; the shell still wins over it.
183
+ '--env-file',
184
+ join(root, PROD_ENV_FILE),
149
185
  '-f',
150
186
  join(root, 'docker', 'docker-compose.prod.yml'),
151
187
  ONE_SHOT_ROLES.includes(role) ? 'run' : 'up',
@@ -164,33 +200,50 @@ export function planDeploy(image: string, method: DeployMethod, root: string): D
164
200
  * renderers and the failure `fix:` go through here, so the plan `--json` reports, the plan the
165
201
  * terminal shows and the line the refusal hands back can never name three different deployments.
166
202
  */
203
+ /** The flags only a helm deploy reads. Named once: the spec, the refusal and the reader agree. */
204
+ const HELM_ONLY_FLAGS = ['release', 'namespace', 'timeout'] as const;
205
+
206
+ /**
207
+ * The method and its target. A helm-only flag on the compose method is REFUSED, never dropped:
208
+ * `--namespace staging` on a compose deploy would otherwise run against the one box there is and
209
+ * report success, which is the silent direction every declared-and-ignored knob in this repo took.
210
+ */
211
+ export async function readDeployRequest(
212
+ ctx: CommandContext,
213
+ method: DeployMethod,
214
+ root: string,
215
+ ): Promise<DeployRequest> {
216
+ if (method === 'compose') {
217
+ const stray = HELM_ONLY_FLAGS.find((flag) => flagString(ctx.args, flag) !== undefined);
218
+ if (stray !== undefined) {
219
+ throw new BadFlagError({
220
+ flag: stray,
221
+ command: 'deploy',
222
+ reason: 'it applies to --method helm only; a compose deploy has one box and no release',
223
+ fix: 'x deploy --method helm --release my-app --namespace my-namespace --json',
224
+ });
225
+ }
226
+ return { method };
227
+ }
228
+ const namespace = flagString(ctx.args, 'namespace');
229
+ return {
230
+ method,
231
+ release: await readReleaseName(root, flagString(ctx.args, 'release')),
232
+ namespace: namespace === undefined ? undefined : readLabel('namespace', namespace),
233
+ timeout: readHelmTimeout(flagString(ctx.args, 'timeout')),
234
+ };
235
+ }
236
+
167
237
  const stepLine = (env: Readonly<Record<string, string>>, command: readonly string[]): string =>
168
- [...Object.entries(env).map(([name, value]) => `${name}=${quoteArg(value)}`), ...command].join(
169
- ' ',
170
- );
238
+ [
239
+ ...Object.entries(env).map(([name, value]) => `${name}=${quoteArg(value)}`),
240
+ // Every word quoted where it must be: the chart and compose paths are joined from the app
241
+ // root, and a root holding a space or a `$(` pasted back as two words, or as a second command.
242
+ ...command.map(quoteArg),
243
+ ].join(' ');
171
244
 
172
245
  export const deployCommand: CliCommand = {
173
- spec: {
174
- name: 'deploy',
175
- summary: 'run the container deploy plan: migrate first, then the serving roles',
176
- usage: 'x deploy --image repo/app:tag [--method compose|helm] [--dry-run] [--json]',
177
- requiresApp: true,
178
- flags: [
179
- { name: 'image', type: 'string', summary: 'image reference to deploy' },
180
- { name: 'method', type: 'string', summary: 'compose | helm', default: 'compose' },
181
- { name: 'dry-run', type: 'boolean', summary: 'print the plan, run nothing' },
182
- // `--critical` was here and is gone. It parsed, it was echoed into the plan JSON as
183
- // `critical: <bool>`, and no file in `packages/` read that field — so the flag changed
184
- // nothing about what `x deploy` did, on either method. `flag-reads.ts`'s
185
- // `X_CLI_FLAG_UNREAD` passed it, because that gate proves a flag is READ and this one was:
186
- // into a field with no reader. It is not coming back: `@ultimat3/pwa`'s
187
- // `updateSignal({ reason: 'security' })`, the call it was to have triggered, is **deleted**
188
- // as of 9.0.0 for having had no runtime caller of its own, and nothing in the framework
189
- // force-navigates a client. A deploy also has no channel to one — the plan is
190
- // `docker compose up` / `helm upgrade`, and the client's build id is read by `http` (tier 2)
191
- // and `sync` (tier 3), neither of which may import a tier-4 package to act on it.
192
- ],
193
- },
246
+ spec: deploySpec,
194
247
  async run(ctx: CommandContext): Promise<CommandResult> {
195
248
  const root = requireAppRoot('deploy', ctx.cwd).dir;
196
249
  const image = flagString(ctx.args, 'image') ?? 'ultimate-app:dev';
@@ -201,10 +254,27 @@ export const deployCommand: CliCommand = {
201
254
  // `docker/docker-compose.prod.yml`. An app that deleted the chart gets helm's own error through
202
255
  // X_DEPLOY_FAILED, whose fix is the exact command to rerun.
203
256
  const method = readMethod(flagString(ctx.args, 'method'));
204
- const plan = planDeploy(image, method, root);
205
- const planJson: JsonValue = {
257
+ const request = await readDeployRequest(ctx, method, root);
258
+ const plan =
259
+ request.method === 'helm'
260
+ ? planDeploy(image, 'helm', root, request)
261
+ : planDeploy(image, 'compose', root);
262
+ // What the helm flags resolved to — the release above all, which is read off `app.config.ts`
263
+ // and so is not something the operator typed. Absent on compose, which has no release.
264
+ const target: { readonly [key: string]: JsonValue } =
265
+ request.method === 'helm'
266
+ ? {
267
+ helm: {
268
+ release: request.release,
269
+ namespace: request.namespace ?? null,
270
+ timeout: request.timeout,
271
+ },
272
+ }
273
+ : {};
274
+ const planJson: { readonly [key: string]: JsonValue } = {
206
275
  image: plan.image,
207
276
  method,
277
+ ...target,
208
278
  // Reported, because it is what makes `image` above true on the compose method — a dry run
209
279
  // that names an image the steps do not carry is the defect this field closed.
210
280
  env: { ...plan.env },
@@ -224,8 +294,13 @@ export const deployCommand: CliCommand = {
224
294
  ),
225
295
  };
226
296
  }
297
+ // helm prints its release record (`--output json`), so the report carries helm's own verdict
298
+ // and revision rather than an exit code standing in for one. Compose has no such record.
299
+ let rollout: { readonly [key: string]: JsonValue } = {};
227
300
  for (const step of plan.steps) {
228
301
  const result = await ctx.runner(step.command, { cwd: root, env: plan.env });
302
+ if (request.method === 'helm')
303
+ rollout = { rollout: { ...readRollout(request, result.stdout) } };
229
304
  if (!result.ok) {
230
305
  return {
231
306
  ok: false,
@@ -239,7 +314,7 @@ export const deployCommand: CliCommand = {
239
314
  docs: ERROR_DOCS_URL,
240
315
  },
241
316
  ],
242
- data: planJson,
317
+ data: { ...planJson, ...rollout },
243
318
  };
244
319
  }
245
320
  }
@@ -247,7 +322,7 @@ export const deployCommand: CliCommand = {
247
322
  ok: true,
248
323
  command: 'deploy',
249
324
  summary: msg('cli.deploy.plan', { images: 1, roles }),
250
- data: planJson,
325
+ data: { ...planJson, ...rollout },
251
326
  };
252
327
  },
253
328
  };
@@ -0,0 +1,25 @@
1
+ // `x dev`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-dev.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+ import { DEV_ROLES } from './role-start-types';
6
+
7
+ export const devSpec: CommandSpec = {
8
+ name: 'dev',
9
+ summary: 'all roles in one process: embedded services, sub-second reload, /_x mounted',
10
+ usage: 'x dev [--port 3000] [--role web,worker] [--once] [--json]',
11
+ requiresApp: true,
12
+ flags: [
13
+ // No `default`: a default reads exactly like a value the caller typed, and it outranked
14
+ // `PORT` from the app's `.env.development` — `devPortFor` supplies the 3000 last.
15
+ { name: 'port', type: 'string', summary: 'HTTP port (default: PORT, else 3000)' },
16
+ {
17
+ name: 'role',
18
+ type: 'string',
19
+ // `replicator` is named because it is selectable and NOT default — it takes a replication
20
+ // slot on a shared database, which is not something every `x dev` should do by starting.
21
+ summary: `roles to run (default: all of ${DEV_ROLES.join(',')}; replicator is opt-in)`,
22
+ },
23
+ { name: 'once', type: 'boolean', summary: 'boot, report, exit — for smoke tests and CI' },
24
+ ],
25
+ };
package/src/cmd-dev.ts CHANGED
@@ -9,66 +9,60 @@ import { devShellStyle } from '@ultimat3/admin/dev';
9
9
  import type { Role } from '@ultimat3/core';
10
10
  import { configureTelemetry, METRICS_PATH, noopExporter } from '@ultimat3/core';
11
11
  import { setStatementObserver } from '@ultimat3/db';
12
- import type { OverlayNotice, RequestContext, Route } from '@ultimat3/http';
12
+ import type { OverlayNotice, RequestContext } from '@ultimat3/http';
13
13
  import { asCtx } from '@ultimat3/http';
14
14
  import type { Manifest } from '@ultimat3/manifest';
15
15
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
16
- import { describeRoutes } from '@ultimat3/render';
17
- import { apiRoutes } from './api-routes';
18
16
  import { loadSignInPath } from './app-auth';
19
17
  import { appManifest } from './app-manifest';
20
- import { mountAppMcp } from './app-mcp';
21
18
  import { requireAppRoot } from './app-root';
22
19
  import { loadAppRuntime } from './app-runtime';
20
+ import { devSpec } from './cmd-dev-spec';
23
21
  import type { CliCommand, CommandContext } from './command';
24
- import { assetRoutes } from './dev-assets';
25
22
  import type { DevDashboardInput, DevStatus } from './dev-dashboard';
26
- import { devDashboardRoutes, devPanels } from './dev-dashboard';
27
- import { declareDevEnvironment } from './dev-environment';
28
- import { liveFeedLabel } from './dev-live-feed';
23
+ import { devPanels } from './dev-dashboard';
24
+ import { declareDevEnvironment, needsDevEnvironmentDeclaration } from './dev-environment';
29
25
  import { clearLock, preflight, writeLock } from './dev-lock';
30
26
  import { createStatementLedger } from './dev-n-plus-one';
27
+ import { devPortFor } from './dev-port';
31
28
  import { coalesceReloads } from './dev-reload';
32
- import { appRoutes } from './dev-render';
33
- import { replicaOverrides } from './dev-replica';
34
- import type { RunningRoles } from './dev-roles';
35
- import { DEV_BINDING, DEV_ROLES, selectRoles, startRoles } from './dev-roles';
36
- import type { RunningServices } from './dev-runtime';
37
- import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './dev-runtime';
38
- import type { DevServices } from './dev-services';
39
- import { describeServices, reportedUrls, resolveServices } from './dev-services';
40
- import { storageRoutes } from './dev-storage';
29
+ import { devRouteTable } from './dev-route-table';
41
30
  import { createTraceRecorder } from './dev-traces';
42
31
  import { watchTree } from './dev-watch-tree';
43
- import { errorPageStyleSources } from './error-page-csp';
44
- import { intFlagOr, PORT_RANGE } from './flag-number';
45
32
  import { holdUntilShutdown } from './hold';
46
33
  import type { IslandBundle } from './island-bundle';
47
34
  import { buildIslands } from './island-bundle';
48
35
  import { FRAME_STYLE } from './island-harness';
49
- import { islandHarnessRoutes } from './island-harness-route';
50
- import { islandRoutes } from './island-routes';
51
- import { loadIslandStates } from './island-states-load';
52
36
  import { msg } from './messages';
53
37
  import type { CommandResult, Finding } from './output';
54
38
  import { findingFrom } from './output';
55
39
  import { flagString } from './parse';
56
- import { loadPwaArtifacts } from './pwa-artifacts';
57
- import { metricsPortFor } from './serve';
40
+ import type { RunningRoles } from './role-start';
41
+ import { DEV_BINDING, DEV_ROLES, selectRoles, startRoles } from './role-start';
42
+ import type { DevServices } from './runtime-bindings';
43
+ import {
44
+ describeServices,
45
+ reportedUrls,
46
+ resolveServices,
47
+ withRealtimeEvents,
48
+ } from './runtime-bindings';
49
+ import { liveFeedLabel } from './runtime-live-feed';
50
+ import { replicaOverrides } from './runtime-replica';
51
+ import type { RunningServices } from './runtime-services';
52
+ import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './runtime-services';
53
+ import { metricsPortFor, releaseBoot } from './serve';
58
54
  import { loopFacts, loopFinding, loopNotice } from './statement-loop';
59
- import { styleBundle } from './style-bundle';
60
- import { styleRoutes } from './style-routes';
61
- import { serviceWorkerArtifacts } from './sw-artifacts';
62
- import { serviceWorkerRoutes } from './sw-routes';
63
- import { loadThemeMode, themeBoot } from './theme-boot';
64
55
 
65
- const DEFAULT_PORT = 3000;
56
+ const _DEFAULT_PORT = 3000;
66
57
 
67
58
  export interface DevServer {
68
59
  readonly url: string;
69
60
  readonly services: DevServices;
70
61
  readonly roles: readonly Role[];
71
- /** The manifest as it stands now — a reload that registers a new route moves it. */
62
+ /**
63
+ * `BUILD_ID` when stamped — the id every response carries. Otherwise the manifest as it stands
64
+ * now, so a reload that registers a new route moves it.
65
+ */
72
66
  readonly buildId: string;
73
67
  /**
74
68
  * Modules that would not import, primitives that would not register, reloads that would not
@@ -133,13 +127,41 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
133
127
  // EVERY boot: a scratch server (`x shot`, `ui.shot`) boots here too, and without this a
134
128
  // fail-closed dev actor installs nothing — the picture is of a 401. Idempotent.
135
129
  declareDevEnvironment(options.env);
136
- const services = resolveServices(options.root, options.env);
137
- const runtime: RunningServices = await startServices(services, options.env);
130
+ // The declaration lands on `process.env`; the env this boot passes on must carry it too, or every
131
+ // service resolved from `options.env` still reads no `ULTIMATE_ENV`.
132
+ const env = needsDevEnvironmentDeclaration(options.env)
133
+ ? { ...options.env, ULTIMATE_ENV: 'development' }
134
+ : options.env;
135
+ const resolved = resolveServices(options.root, env);
136
+ const runtime: RunningServices = await startServices(resolved, env);
137
+ // The events binding the boot line reports is the bus the runtime chose, read off it.
138
+ const services = withRealtimeEvents(resolved, env, runtime.realtime);
139
+ // `serve.ts`'s `releaseBoot` shape: everything acquired from here on is released, newest first,
140
+ // if the boot throws — a failed `x dev` left PGlite holding `.x/pgdata` for the retry to meet.
141
+ const acquired: (() => void | Promise<void>)[] = [() => runtime.stop()];
142
+ try {
143
+ return await bootDev(options, services, runtime, acquired);
144
+ } catch (error) {
145
+ await releaseBoot(acquired);
146
+ throw error;
147
+ }
148
+ }
149
+
150
+ async function bootDev(
151
+ options: StartDevOptions,
152
+ services: ReturnType<typeof resolveServices>,
153
+ runtime: RunningServices,
154
+ acquired: (() => void | Promise<void>)[],
155
+ ): Promise<DevServer> {
138
156
  // Installed before the app loads, so a span opened during registration is already recorded.
139
157
  // Tracing is always on in the framework and free until an exporter is configured; `x dev` is
140
158
  // what configures one, which is the whole reason `/_x/timeline` has anything to draw.
141
159
  const traces = createTraceRecorder();
142
160
  configureTelemetry({ exporter: traces.exporter });
161
+ acquired.push(() => {
162
+ configureTelemetry({ exporter: noopExporter });
163
+ traces.reset();
164
+ });
143
165
  // Installed at the same moment and for the same reason: an observer is the single switch that
144
166
  // turns statement instrumentation on at all (`@ultimat3/db`'s `observe.ts`), so the timeline's
145
167
  // SQL rows and the repeat counts arrive together rather than through two toggles. `serve.ts`
@@ -147,6 +169,10 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
147
169
  // uninstalled, and nothing more (axiom 6).
148
170
  const statements = createStatementLedger();
149
171
  setStatementObserver(statements.observer);
172
+ acquired.push(() => {
173
+ setStatementObserver(undefined);
174
+ statements.reset();
175
+ });
150
176
  // ONE load at boot, the same call the rebuild below makes: the manifest and the findings are
151
177
  // two projections of one scan. Until 2026-09-07 this was `loadApp` for the findings and then
152
178
  // `appManifest` — which loads again — for the manifest, so a save landing between the two put
@@ -164,7 +190,12 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
164
190
  // boot on purpose: the header is handed to the HTTP config and the render modes once, and a
165
191
  // reload cannot re-pin it — `state.manifest.buildId` is what `/_x` and `--json` report, so a
166
192
  // divergence between the two is visible rather than silent, and a restart closes it.
167
- const buildId = state.manifest.buildId;
193
+ // `BUILD_ID` wins when set — `serve.ts`'s rule, so an e2e `deploy.newBuild()` can restart `x dev`
194
+ // as a new build with the same sources.
195
+ // Stamped, it is ALSO what `server.buildId` answers below: the process serves no other build.
196
+ const rawStamp = options.env['BUILD_ID'];
197
+ const stamped = rawStamp !== undefined && rawStamp !== '' ? rawStamp : undefined;
198
+ const buildId = stamped ?? state.manifest.buildId;
168
199
 
169
200
  let server: DevServer;
170
201
  // Read at request time, never captured at boot: `/_x/services` must report the reload counter
@@ -185,70 +216,14 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
185
216
  };
186
217
  const panels = devPanels(dashboard).map((panel) => panel.key);
187
218
 
188
- // Resolved once, before the first route: the manifest's bytes and the three head elements that
189
- // name it. `undefined` for an app that is not installable, and then nothing is mounted and no
190
- // document changes — the 0kb baseline is not spent on a `<link>` to a file that does not exist.
191
- const pwa = await loadPwaArtifacts(options.root);
192
- const theme = themeBoot(await loadThemeMode(options.root));
193
- const errorStyles = await errorPageStyleSources(options.root);
194
- // Built once at boot, from this process's own route table and island bundle. `x dev` rebuilds
195
- // islands on the watcher tick and the worker is NOT rebuilt with them, deliberately: a service
196
- // worker that changes under a page it already controls is the update path, and re-emitting one
197
- // per keystroke would exercise it on every save.
198
- const serviceWorker =
199
- pwa === undefined
200
- ? undefined
201
- : serviceWorkerArtifacts({
202
- pwa,
203
- buildId,
204
- routes: describeRoutes(),
205
- islands: state.islands,
206
- styles: styleBundle(),
207
- });
208
-
209
- // The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
210
- // call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
211
- // one of them asked. Warned once here when `expose` is true and nothing can be mounted.
212
- const mcpMount = await mountAppMcp(options.root);
213
- const routes: readonly Route[] = [
214
- ...devDashboardRoutes(dashboard),
215
- // The same API table the container serves: a read that answers here and 404s in production
216
- // is exactly the drift one composition exists to prevent.
217
- ...apiRoutes(),
218
- ...mcpMount.routes,
219
- // The image pipeline's only HTTP surface: the icons the web manifest declares, and the
220
- // variants every `srcset` promises. Mounted before the app's own routes so a page route can
221
- // never shadow `/icons` or `/media`.
222
- ...assetRoutes({
223
- root: options.root,
224
- storage: runtime.storage,
225
- ...(pwa === undefined ? {} : { pwa }),
226
- }),
227
- ...storageRoutes({ storage: runtime.storage }),
228
- // The chunks the documents below name. Mounted before the app's routes for the reason
229
- // `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
230
- ...islandRoutes(() => state.islands),
231
- // And the stylesheet every one of those documents links. Read through the getter for the
232
- // reason the islands are: a rebuilt island registers CSS, which mints a new URL, and a table
233
- // captured at boot would answer 404 for the href the document now carries.
234
- ...styleRoutes(() => styleBundle()),
235
- // `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
236
- // lives here rather than in a second server because everything it needs is in THIS process:
237
- // the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
238
- // may have. The states are read per REQUEST — an author editing a state and re-running the
239
- // command must not need a restart to see it.
240
- ...islandHarnessRoutes({
241
- islands: () => state.islands,
242
- states: () => loadIslandStates(options.root),
243
- }),
244
- ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
245
- ...appRoutes({
246
- buildId,
247
- resolveIsland: (file) => state.islands.resolverFor(file),
248
- themeHead: theme.head,
249
- ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
250
- }),
251
- ];
219
+ const { routes, theme, errorStyles, mcpPath } = await devRouteTable({
220
+ root: options.root,
221
+ env: options.env,
222
+ buildId,
223
+ storage: runtime.storage,
224
+ dashboard,
225
+ islands: () => state.islands,
226
+ });
252
227
 
253
228
  // The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
254
229
  // `runtime`: the replica scope in front, the app's own middleware behind it. Before this the
@@ -287,6 +262,7 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
287
262
  // standby — so this key does not exist on a homework app's boot at all.
288
263
  ...(replicaOverride === undefined ? {} : { overrides: replicaOverride }),
289
264
  });
265
+ acquired.push(() => running.stop());
290
266
 
291
267
  // One rebuild at a time, and the last save wins: a tick arriving mid-build coalesces into ONE
292
268
  // trailing rebuild instead of racing the one in flight for `state.manifest` and `state.islands`.
@@ -320,9 +296,9 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
320
296
  url: running.url ?? `http://localhost:${options.port}`,
321
297
  services,
322
298
  roles: running.roles,
323
- mcp: mcpMount.path,
299
+ mcp: mcpPath,
324
300
  get buildId(): string {
325
- return state.manifest.buildId;
301
+ return stamped ?? state.manifest.buildId;
326
302
  },
327
303
  // A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed and the
328
304
  // loop the last request tripped, not the findings as they were when the route table was built.
@@ -358,32 +334,12 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
358
334
  }
359
335
 
360
336
  export const devCommand: CliCommand = {
361
- spec: {
362
- name: 'dev',
363
- summary: 'all roles in one process: embedded services, sub-second reload, /_x mounted',
364
- usage: 'x dev [--port 3000] [--role web,worker] [--once] [--json]',
365
- requiresApp: true,
366
- flags: [
367
- { name: 'port', type: 'string', summary: 'HTTP port', default: String(DEFAULT_PORT) },
368
- {
369
- name: 'role',
370
- type: 'string',
371
- // `replicator` is named because it is selectable and NOT default — it takes a replication
372
- // slot on a shared database, which is not something every `x dev` should do by starting.
373
- summary: `roles to run (default: all of ${DEV_ROLES.join(',')}; replicator is opt-in)`,
374
- },
375
- { name: 'once', type: 'boolean', summary: 'boot, report, exit — for smoke tests and CI' },
376
- ],
377
- },
337
+ spec: devSpec,
378
338
  async run(ctx: CommandContext): Promise<CommandResult> {
379
339
  const root = requireAppRoot('dev', ctx.cwd).dir;
380
340
  // Validated, not `parseInt`'d: `x dev --port abc` handed `NaN` to `Bun.serve`, which binds an
381
341
  // arbitrary port — a dev server reachable at an address nothing printed.
382
- const port = intFlagOr(
383
- ctx.args,
384
- { name: 'port', command: 'dev', ...PORT_RANGE, example: `x dev --port ${DEFAULT_PORT}` },
385
- DEFAULT_PORT,
386
- );
342
+ const port = devPortFor(ctx.args, ctx.env);
387
343
  const roles = selectRoles(flagString(ctx.args, 'role'));
388
344
  // BEFORE anything boots. Both failures this catches were reachable and both reported the wrong
389
345
  // thing: a taken port surfaced as X_CLI_UNEXPECTED wrapping "Is port 3000 in use?" with a `fix:`
@@ -0,0 +1,16 @@
1
+ // `x docs`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-docs.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ /** Matches printed by default. Enough to choose between, few enough to read all of. */
7
+ export const DEFAULT_LIMIT = 5;
8
+
9
+ export const docsSpec: CommandSpec = {
10
+ name: 'docs',
11
+ summary: 'the framework docs, answered offline from the installed packages',
12
+ usage: 'x docs "<question|topic|symbol>" [--limit <n>] [--json]',
13
+ flags: [
14
+ { name: 'limit', type: 'string', summary: `matches to return (default: ${DEFAULT_LIMIT})` },
15
+ ],
16
+ };
package/src/cmd-docs.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import { ERROR_DOCS_URL } from '@ultimat3/core';
8
8
  import type { DocEntry, DocHit } from '@ultimat3/manifest';
9
9
  import { nearestTopics, scanInstalledDocs, searchDocs } from '@ultimat3/manifest';
10
+ import { DEFAULT_LIMIT, docsSpec } from './cmd-docs-spec';
10
11
  import type { CliCommand, CommandContext } from './command';
11
12
  import { MissingPositionalError } from './errors';
12
13
  import { frameworkScopeDir } from './framework-scope';
@@ -15,9 +16,6 @@ import { msg } from './messages';
15
16
  import type { CommandResult, Finding, JsonValue } from './output';
16
17
  import { flagString } from './parse';
17
18
 
18
- /** Matches printed by default. Enough to choose between, few enough to read all of. */
19
- const DEFAULT_LIMIT = 5;
20
-
21
19
  /** An install where the CLI cannot see its own dependency is broken, not merely undocumented. */
22
20
  const unresolvedFinding = (): Finding => ({
23
21
  code: 'X_CLI_UNEXPECTED',
@@ -115,14 +113,7 @@ function missResult(query: string, entries: readonly DocEntry[]): CommandResult
115
113
  }
116
114
 
117
115
  export const docsCommand: CliCommand = {
118
- spec: {
119
- name: 'docs',
120
- summary: 'the framework docs, answered offline from the installed packages',
121
- usage: 'x docs "<question|topic|symbol>" [--limit <n>] [--json]',
122
- flags: [
123
- { name: 'limit', type: 'string', summary: `matches to return (default: ${DEFAULT_LIMIT})` },
124
- ],
125
- },
116
+ spec: docsSpec,
126
117
  // `async` is load-bearing: a synchronous throw would escape every caller that awaits the
127
118
  // promise this signature promises, including the dispatcher's own error path.
128
119
  async run(ctx: CommandContext): Promise<CommandResult> {