@ultimat3/cli 1.2.0 → 3.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 (141) hide show
  1. package/CLAUDE.md +761 -0
  2. package/README.md +42 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +134 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +219 -0
  13. package/src/cmd-db.ts +458 -153
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +92 -18
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +74 -10
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +14 -8
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +29 -24
  33. package/src/cmd-verify.ts +197 -25
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +269 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +144 -0
  39. package/src/db-seed.ts +294 -0
  40. package/src/db-snapshot.ts +24 -0
  41. package/src/dev-assets.ts +108 -23
  42. package/src/dev-cache.ts +122 -0
  43. package/src/dev-dashboard.ts +19 -4
  44. package/src/dev-hooks.ts +27 -2
  45. package/src/dev-n-plus-one.ts +191 -0
  46. package/src/dev-queue.ts +105 -19
  47. package/src/dev-render.ts +158 -26
  48. package/src/dev-roles-fixture.ts +67 -0
  49. package/src/dev-roles.ts +167 -78
  50. package/src/dev-runtime.ts +117 -40
  51. package/src/dev-services.ts +15 -0
  52. package/src/dev-storage.ts +247 -0
  53. package/src/dev-sync.ts +107 -0
  54. package/src/dev-traces.ts +37 -7
  55. package/src/dispatch.ts +4 -2
  56. package/src/document-styles.ts +54 -0
  57. package/src/drift.ts +78 -10
  58. package/src/error-catalog.ts +8 -18
  59. package/src/error-codes.ts +192 -0
  60. package/src/error-contract.ts +29 -7
  61. package/src/error-fixes.ts +114 -0
  62. package/src/errors.ts +201 -138
  63. package/src/exec.ts +42 -8
  64. package/src/fix-command.ts +268 -0
  65. package/src/flag-number.ts +67 -0
  66. package/src/framework-scope.ts +49 -0
  67. package/src/generate-kinds.ts +97 -0
  68. package/src/guards.ts +186 -0
  69. package/src/index.ts +92 -15
  70. package/src/island-bundle.ts +166 -0
  71. package/src/island-routes.ts +50 -0
  72. package/src/jobs-driver.ts +33 -0
  73. package/src/jobs-json.ts +24 -0
  74. package/src/jobs-report.ts +17 -4
  75. package/src/mcp-db-target.ts +52 -27
  76. package/src/mcp-errors.ts +128 -19
  77. package/src/mcp-host.ts +44 -25
  78. package/src/messages.ts +93 -2
  79. package/src/metrics-endpoint.ts +64 -16
  80. package/src/migrations.ts +37 -4
  81. package/src/otlp-export.ts +64 -0
  82. package/src/output.ts +46 -16
  83. package/src/parse.ts +41 -3
  84. package/src/policy-facts.ts +38 -6
  85. package/src/policy-fixture.ts +14 -7
  86. package/src/prerender.ts +111 -2
  87. package/src/registry.ts +21 -3
  88. package/src/runtime-overrides.ts +66 -0
  89. package/src/safe-url-label.ts +24 -0
  90. package/src/scaffold-fixture.ts +10 -0
  91. package/src/scaffold-typecheck.ts +16 -38
  92. package/src/serve.ts +185 -13
  93. package/src/shell-quote.ts +15 -0
  94. package/src/source-files.ts +4 -0
  95. package/src/statement-loop.ts +74 -0
  96. package/src/style-csp.ts +18 -0
  97. package/src/sync-authenticator.ts +59 -0
  98. package/src/templates/action.ts +15 -30
  99. package/src/templates/admin-page.ts +103 -0
  100. package/src/templates/admin.ts +11 -7
  101. package/src/templates/backfill.ts +212 -0
  102. package/src/templates/entity.ts +72 -31
  103. package/src/templates/guard.ts +143 -0
  104. package/src/templates/index.ts +12 -1
  105. package/src/templates/island.ts +67 -0
  106. package/src/templates/job.ts +53 -13
  107. package/src/templates/naming.ts +17 -1
  108. package/src/templates/policy.ts +35 -28
  109. package/src/templates/query.ts +24 -5
  110. package/src/templates/resource.ts +19 -11
  111. package/src/templates/route.ts +90 -15
  112. package/src/templates/scaffold-app.ts +142 -45
  113. package/src/templates/scaffold-claude-agents.ts +149 -0
  114. package/src/templates/scaffold-claude-commands.ts +221 -0
  115. package/src/templates/scaffold-claude.ts +134 -0
  116. package/src/templates/scaffold-container.ts +46 -2
  117. package/src/templates/scaffold-db-package.ts +91 -0
  118. package/src/templates/scaffold-docs.ts +24 -5
  119. package/src/templates/scaffold-domain-package.ts +90 -0
  120. package/src/templates/scaffold-env.ts +87 -0
  121. package/src/templates/scaffold-i18n.ts +4 -1
  122. package/src/templates/scaffold-mcp-package.ts +49 -0
  123. package/src/templates/scaffold-package-shape.ts +25 -4
  124. package/src/templates/scaffold-repo.ts +116 -257
  125. package/src/templates/scaffold-roles.ts +68 -0
  126. package/src/templates/scaffold-ui-package.ts +56 -0
  127. package/src/templates/slice-foundation.ts +88 -0
  128. package/src/templates/wrap.ts +95 -0
  129. package/src/test-counts.ts +35 -0
  130. package/src/test-select.ts +30 -15
  131. package/src/test-shards.ts +20 -11
  132. package/src/test-workers.ts +50 -0
  133. package/src/ts-scan.ts +284 -15
  134. package/src/tsconfig-references.ts +103 -0
  135. package/src/verify-floor.ts +133 -0
  136. package/src/verify-step.ts +19 -0
  137. package/src/verify-test-run.ts +72 -0
  138. package/src/verify-tests.ts +160 -71
  139. package/src/version-loader.ts +20 -3
  140. package/src/workspace-checks.ts +87 -16
  141. package/src/write-line.ts +34 -0
package/src/cmd-deploy.ts CHANGED
@@ -6,12 +6,35 @@ import { existsSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { requireAppRoot } from './app-root';
8
8
  import type { CliCommand, CommandContext } from './command';
9
- import { CliNotImplementedError } from './errors';
9
+ import { BadFlagError, CliNotImplementedError } from './errors';
10
10
  import { msg } from './messages';
11
11
  import type { CommandResult, JsonValue } from './output';
12
12
  import { flagBool, flagString } from './parse';
13
13
 
14
- export const DEPLOY_ROLES = ['migrate', 'web', 'sync', 'worker', 'scheduler'] as const;
14
+ /**
15
+ * Ordered, and the order is the design. `migrate` GATES — it runs to completion before anything
16
+ * serves, and a schema difference after it fails the deploy. `backfill` is last and TRIGGERS: a
17
+ * data sweep put inside a release gate holds the deploy open while a slow UPDATE runs against a
18
+ * database still serving the PREVIOUS release, so it runs after the new pods are up and the
19
+ * workers already draining the queue are what perform it. That is also why it is not wired into
20
+ * `runMigrations()` and never will be.
21
+ *
22
+ * `backfill` is a one-shot like `migrate`, so it takes the same `run --rm` shape; the compose
23
+ * service behind it runs `x db backfill --all --write --json` rather than a `ROLE`, because
24
+ * `@ultimat3/core`'s `ROLES` is a closed list of process shapes and a sweep trigger is a command.
25
+ *
26
+ * ORDER HERE IS NECESSARY AND NOT SUFFICIENT. `docker compose up -d` returns when a container has
27
+ * STARTED, not when the application inside it is serving, so this list alone puts the trigger after
28
+ * the serving roles were asked to start and not after they are ready. The barrier that makes
29
+ * "after" true is declarative and belongs to the compose file, not to this plan: the `backfill`
30
+ * service needs `depends_on: { web: { condition: service_healthy } }`, which `docker compose run`
31
+ * honours. Both compose definitions — `docker/docker-compose.prod.yml` and the one
32
+ * `templates/scaffold-container.ts` scaffolds — still owe that service and that condition.
33
+ */
34
+ export const DEPLOY_ROLES = ['migrate', 'web', 'sync', 'worker', 'scheduler', 'backfill'] as const;
35
+
36
+ /** The roles that run to completion and exit, as against the ones that stay up serving. */
37
+ const ONE_SHOT_ROLES: readonly string[] = ['migrate', 'backfill'];
15
38
 
16
39
  export interface DeployPlan {
17
40
  readonly image: string;
@@ -19,8 +42,39 @@ export interface DeployPlan {
19
42
  readonly steps: readonly { readonly role: string; readonly command: readonly string[] }[];
20
43
  }
21
44
 
45
+ /**
46
+ * The chart declares `image` as a MAP — `repository`, `tag`, `pullPolicy` — and `_helpers.tpl`
47
+ * renders `printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag)`.
48
+ * `--set image=<ref>` replaces that map with a string, so every workload template fails on
49
+ * `.repository` and the deploy that was asked to ship one image ships nothing. The reference is
50
+ * split into the two keys the chart actually reads; a reference with no tag sets only the
51
+ * repository, which leaves the chart's own `default .Chart.AppVersion` in force.
52
+ *
53
+ * The last `:` after the last `/`, because a registry may carry a port: `localhost:5000/app` is a
54
+ * repository with no tag and `localhost:5000/app:1.2.3` is the same repository with one.
55
+ */
56
+ export function helmImageOverrides(image: string): readonly string[] {
57
+ const colon = image.lastIndexOf(':');
58
+ const tag = colon > image.lastIndexOf('/') ? image.slice(colon + 1) : '';
59
+ const repository = tag === '' ? image : image.slice(0, colon);
60
+ return tag === ''
61
+ ? ['--set', `image.repository=${repository}`]
62
+ : ['--set', `image.repository=${repository}`, '--set', `image.tag=${tag}`];
63
+ }
64
+
22
65
  export function planDeploy(image: string, method: 'compose' | 'helm', root: string): DeployPlan {
23
66
  if (method === 'helm') {
67
+ // `repo@sha256:…` is a reference this chart cannot express: it renders `repository:tag` and
68
+ // has no digest branch, so passing one through would deploy `repo@sha256:…:<appVersion>` —
69
+ // a tag no registry has. Refused here rather than by a `helm upgrade` failing halfway.
70
+ if (image.lastIndexOf('@') > image.lastIndexOf('/')) {
71
+ throw new BadFlagError({
72
+ flag: 'image',
73
+ command: 'deploy',
74
+ reason: `"${image}" pins a digest, and docker/helm renders repository:tag with no digest branch`,
75
+ fix: `x deploy --method helm --image ${image.slice(0, image.lastIndexOf('@'))}:<tag> --json`,
76
+ });
77
+ }
24
78
  return {
25
79
  image,
26
80
  steps: [
@@ -32,8 +86,7 @@ export function planDeploy(image: string, method: 'compose' | 'helm', root: stri
32
86
  '--install',
33
87
  'app',
34
88
  join(root, 'docker', 'helm'),
35
- '--set',
36
- `image=${image}`,
89
+ ...helmImageOverrides(image),
37
90
  ],
38
91
  },
39
92
  ],
@@ -48,8 +101,8 @@ export function planDeploy(image: string, method: 'compose' | 'helm', root: stri
48
101
  'compose',
49
102
  '-f',
50
103
  join(root, 'docker', 'docker-compose.prod.yml'),
51
- role === 'migrate' ? 'run' : 'up',
52
- role === 'migrate' ? '--rm' : '-d',
104
+ ONE_SHOT_ROLES.includes(role) ? 'run' : 'up',
105
+ ONE_SHOT_ROLES.includes(role) ? '--rm' : '-d',
53
106
  role,
54
107
  ],
55
108
  })),
package/src/cmd-dev.ts CHANGED
@@ -6,12 +6,16 @@
6
6
 
7
7
  import { watch } from 'node:fs';
8
8
  import { join } from 'node:path';
9
- import { listActions, toRoute } from '@ultimat3/action';
9
+ import { devShellStyle } from '@ultimat3/admin/dev';
10
10
  import type { Role } from '@ultimat3/core';
11
11
  import { configureTelemetry, METRICS_PATH, noopExporter } from '@ultimat3/core';
12
- import type { Route } from '@ultimat3/http';
12
+ import { setStatementObserver } from '@ultimat3/db';
13
+ import type { OverlayNotice, RequestContext, Route } from '@ultimat3/http';
14
+ import { asCtx } from '@ultimat3/http';
13
15
  import type { Manifest } from '@ultimat3/manifest';
14
16
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
17
+ import { apiRoutes } from './api-routes';
18
+ import { loadSignInPath } from './app-auth';
15
19
  import { loadApp } from './app-load';
16
20
  import { appManifest } from './app-manifest';
17
21
  import { requireAppRoot } from './app-root';
@@ -19,19 +23,27 @@ import type { CliCommand, CommandContext } from './command';
19
23
  import { assetRoutes } from './dev-assets';
20
24
  import type { DevDashboardInput, DevStatus } from './dev-dashboard';
21
25
  import { devDashboardRoutes, devPanels } from './dev-dashboard';
26
+ import { createStatementLedger } from './dev-n-plus-one';
22
27
  import { appRoutes } from './dev-render';
23
28
  import type { RunningRoles } from './dev-roles';
24
29
  import { DEV_ROLES, selectRoles, startRoles } from './dev-roles';
25
30
  import type { RunningServices } from './dev-runtime';
26
31
  import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './dev-runtime';
27
32
  import type { DevServices } from './dev-services';
28
- import { describeServices, resolveServices } from './dev-services';
33
+ import { describeServices, reportedUrls, resolveServices } from './dev-services';
34
+ import { storageRoutes } from './dev-storage';
29
35
  import { createTraceRecorder } from './dev-traces';
36
+ import { intFlagOr, PORT_RANGE } from './flag-number';
30
37
  import { holdUntilShutdown } from './hold';
38
+ import type { IslandBundle } from './island-bundle';
39
+ import { buildIslands } from './island-bundle';
40
+ import { islandRoutes } from './island-routes';
31
41
  import { msg } from './messages';
32
42
  import type { CommandResult, Finding } from './output';
33
43
  import { findingFrom } from './output';
34
44
  import { flagString } from './parse';
45
+ import { metricsPortFor } from './serve';
46
+ import { loopFacts, loopFinding, loopNotice } from './statement-loop';
35
47
 
36
48
  const DEFAULT_PORT = 3000;
37
49
 
@@ -41,7 +53,11 @@ export interface DevServer {
41
53
  readonly roles: readonly Role[];
42
54
  /** The manifest as it stands now — a reload that registers a new route moves it. */
43
55
  readonly buildId: string;
44
- /** Modules that would not import, primitives that would not register, reloads that would not build. */
56
+ /**
57
+ * Modules that would not import, primitives that would not register, reloads that would not
58
+ * build — and the statement loops this process has counted so far, which is what puts an N+1 in
59
+ * `x dev`'s own output and in `--json` without a channel of its own.
60
+ */
45
61
  readonly findings: readonly Finding[];
46
62
  readonly running: RunningRoles;
47
63
  readonly runtime: RunningServices;
@@ -55,6 +71,12 @@ interface DevState {
55
71
  reloads: number;
56
72
  /** A save that will not build. Replaced on every attempt, so a fixed file clears it. */
57
73
  reloadFinding: Finding | undefined;
74
+ /**
75
+ * The client entries, rebuilt on the same tick as the manifest. An island is the one module this
76
+ * process never imports, so a fresh `Bun.build` is the whole of its reload — no module cache to
77
+ * invalidate, which is exactly why editing one takes effect where editing a route does not.
78
+ */
79
+ islands: IslandBundle;
58
80
  }
59
81
 
60
82
  /** Debounced: a save that touches five files is one reload, not five. */
@@ -105,11 +127,19 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
105
127
  // what configures one, which is the whole reason `/_x/timeline` has anything to draw.
106
128
  const traces = createTraceRecorder();
107
129
  configureTelemetry({ exporter: traces.exporter });
130
+ // Installed at the same moment and for the same reason: an observer is the single switch that
131
+ // turns statement instrumentation on at all (`@ultimat3/db`'s `observe.ts`), so the timeline's
132
+ // SQL rows and the repeat counts arrive together rather than through two toggles. `serve.ts`
133
+ // installs neither — a production process pays the one `undefined` branch the seam costs
134
+ // uninstalled, and nothing more (axiom 6).
135
+ const statements = createStatementLedger();
136
+ setStatementObserver(statements.observer);
108
137
  const app = await loadApp(options.root);
109
138
  const state: DevState = {
110
139
  manifest: (await appManifest(options.root)).manifest,
111
140
  reloads: 0,
112
141
  reloadFinding: undefined,
142
+ islands: await buildIslands(options.root),
113
143
  };
114
144
  // The manifest's build id is a content hash of every fact below it, so a dev document's
115
145
  // `x-ultimate-build` header names the exact shape the client was served against. Pinned at
@@ -132,34 +162,60 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
132
162
  reloads: state.reloads,
133
163
  }),
134
164
  traces,
165
+ statements,
135
166
  ...envOf(options.env),
136
167
  };
137
168
  const panels = devPanels(dashboard).map((panel) => panel.key);
138
169
 
139
170
  const routes: readonly Route[] = [
140
171
  ...devDashboardRoutes(dashboard),
141
- ...listActions().map(toRoute),
172
+ // The same API table the container serves: a read that answers here and 404s in production
173
+ // is exactly the drift one composition exists to prevent.
174
+ ...apiRoutes(),
142
175
  // The image pipeline's only HTTP surface: the icons the web manifest declares, and the
143
176
  // variants every `srcset` promises. Mounted before the app's own routes so a page route can
144
177
  // never shadow `/icons` or `/media`.
145
178
  ...assetRoutes({ root: options.root, storage: runtime.storage }),
146
- ...appRoutes({ buildId }),
179
+ ...storageRoutes({ storage: runtime.storage }),
180
+ // The chunks the documents below name. Mounted before the app's routes for the reason
181
+ // `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
182
+ ...islandRoutes(() => state.islands),
183
+ ...appRoutes({ buildId, resolveIsland: (file) => state.islands.resolverFor(file) }),
147
184
  ];
148
185
 
149
186
  const running = await startRoles({
150
187
  roles: options.roles ?? DEV_ROLES,
151
188
  port: options.port,
189
+ // `serve.ts`'s expression, called rather than restated: `METRICS_PORT` was read in the
190
+ // container and ignored here, so the scrape port an operator moved was the one port `x dev`
191
+ // could not move — and the second `x dev` on a box died binding the hardcoded 9090.
192
+ metricsPort: metricsPortFor(options.env, options.port),
152
193
  buildId,
153
194
  runtime,
154
195
  routes,
155
196
  env: options.env,
197
+ // Read from `app.config.ts` rather than threaded through `DevOptions`: it is the app's own
198
+ // declaration, and `x dev` and `serve.ts` must not be able to disagree about where the app's
199
+ // sign-in page is.
200
+ signInPath: await loadSignInPath(options.root),
201
+ // The one document this process serves that the app did not write; `startRoles` covers the
202
+ // app's own surfaces itself. `x dev` sends the policy report-only, so an uncovered `<style>`
203
+ // here is a console report rather than a blank page — which is how this reached production.
204
+ inlineStyles: [await devShellStyle()],
205
+ // The fourth surface, and the only one an author sees without leaving the page they broke:
206
+ // the overlay renders this request's own loops under the error it is already showing.
207
+ // `serve.ts` boots through the same `startRoles` and passes nothing, so production has no
208
+ // diagnostic to call.
209
+ devNotices: (ctx: RequestContext): readonly OverlayNotice[] =>
210
+ statements.repeatsFor(asCtx(ctx)).map(loopFacts).map(loopNotice),
156
211
  });
157
212
 
158
213
  const stopWatching = watchApp(options.root, (file) => {
159
214
  const started = performance.now();
160
- void appManifest(options.root)
161
- .then(({ manifest }) => {
215
+ void Promise.all([appManifest(options.root), buildIslands(options.root)])
216
+ .then(([{ manifest }, islands]) => {
162
217
  state.manifest = manifest;
218
+ state.islands = islands;
163
219
  state.reloads += 1;
164
220
  state.reloadFinding = undefined;
165
221
  options.onReload?.(file, Math.round(performance.now() - started));
@@ -178,12 +234,15 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
178
234
  get buildId(): string {
179
235
  return state.manifest.buildId;
180
236
  },
181
- // A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed, not the
182
- // findings as they were when the route table was built.
237
+ // A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed and the
238
+ // loop the last request tripped, not the findings as they were when the route table was built.
239
+ // The loops come last and carry their request id, so a boot report reads as a boot report and
240
+ // a diagnostic that arrived a minute later reads as one too.
183
241
  get findings(): readonly Finding[] {
242
+ const loops = statements.repeats().map(loopFacts).map(loopFinding);
184
243
  return state.reloadFinding === undefined
185
- ? app.findings
186
- : [...app.findings, state.reloadFinding];
244
+ ? [...app.findings, ...loops]
245
+ : [...app.findings, state.reloadFinding, ...loops];
187
246
  },
188
247
  running,
189
248
  runtime,
@@ -197,6 +256,12 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
197
256
  // leaving it in place would keep every span of the next `startDev` in this process's buffer.
198
257
  configureTelemetry({ exporter: noopExporter });
199
258
  traces.reset();
259
+ // Released with the exporter, after the roles, for the same reason: a statement still in
260
+ // flight is observed by the ledger that counted the rest of its request. Leaving it
261
+ // installed would keep every statement of the next `startDev` in this process's counts —
262
+ // and, worse, keep instrumentation on in a process that is no longer a dev server.
263
+ setStatementObserver(undefined);
264
+ statements.reset();
200
265
  },
201
266
  };
202
267
  return server;
@@ -206,21 +271,29 @@ export const devCommand: CliCommand = {
206
271
  spec: {
207
272
  name: 'dev',
208
273
  summary: 'all roles in one process: embedded services, sub-second reload, /_x mounted',
209
- usage: 'x dev [--port 3000] [--role web,worker] [--json]',
274
+ usage: 'x dev [--port 3000] [--role web,worker] [--once] [--json]',
210
275
  requiresApp: true,
211
276
  flags: [
212
277
  { name: 'port', type: 'string', summary: 'HTTP port', default: String(DEFAULT_PORT) },
213
278
  {
214
279
  name: 'role',
215
280
  type: 'string',
216
- summary: `roles to run (default: all of ${DEV_ROLES.join(',')})`,
281
+ // `replicator` is named because it is selectable and NOT default — it takes a replication
282
+ // slot on a shared database, which is not something every `x dev` should do by starting.
283
+ summary: `roles to run (default: all of ${DEV_ROLES.join(',')}; replicator is opt-in)`,
217
284
  },
218
285
  { name: 'once', type: 'boolean', summary: 'boot, report, exit — for smoke tests and CI' },
219
286
  ],
220
287
  },
221
288
  async run(ctx: CommandContext): Promise<CommandResult> {
222
289
  const root = requireAppRoot('dev', ctx.cwd).dir;
223
- const port = Number.parseInt(flagString(ctx.args, 'port') ?? String(DEFAULT_PORT), 10);
290
+ // Validated, not `parseInt`'d: `x dev --port abc` handed `NaN` to `Bun.serve`, which binds an
291
+ // arbitrary port — a dev server reachable at an address nothing printed.
292
+ const port = intFlagOr(
293
+ ctx.args,
294
+ { name: 'port', command: 'dev', ...PORT_RANGE, example: `x dev --port ${DEFAULT_PORT}` },
295
+ DEFAULT_PORT,
296
+ );
224
297
  const roles = selectRoles(flagString(ctx.args, 'role'));
225
298
  const server = await startDev({
226
299
  root,
@@ -253,9 +326,10 @@ export const devCommand: CliCommand = {
253
326
  // at, and the one url here that must NOT be behind the ingress the app's own url is.
254
327
  metrics: `${server.running.metricsUrl}${METRICS_PATH}`,
255
328
  stateDir: server.services.stateDir,
256
- db: server.services.db.url,
257
- events: server.services.events.url,
258
- storage: server.services.storage.url,
329
+ // Redacted, for the reason the mail and cdn lines below already give and this line did
330
+ // not: `DATABASE_URL`, `NATS_URL` and `S3_ENDPOINT` all carry a password, and this object
331
+ // is printed, logged and scraped. `reportedUrls` is the one projection that may be shown.
332
+ ...reportedUrls(server.services),
259
333
  // The selecting env key, never the credential behind it: `SMTP_URL` carries a password
260
334
  // and this line is printed, logged and scraped.
261
335
  mail: describeMail(server.runtime),
@@ -0,0 +1,167 @@
1
+ // `x docs <question>` — the framework's documentation, answered offline from what is installed.
2
+ //
3
+ // One step, no filename known in advance, no network. An agent that has a question and no path
4
+ // should not have to guess which of 29 packages holds the answer, and it must never be handed a
5
+ // URL: `node_modules` already contains every doc, because the published artifact IS the source.
6
+
7
+ import type { DocEntry, DocHit } from '@ultimat3/manifest';
8
+ import { nearestTopics, scanInstalledDocs, searchDocs } from '@ultimat3/manifest';
9
+ import type { CliCommand, CommandContext } from './command';
10
+ import { MissingPositionalError } from './errors';
11
+ import { frameworkScopeDir } from './framework-scope';
12
+ import { msg } from './messages';
13
+ import type { CommandResult, Finding, JsonValue } from './output';
14
+
15
+ /** Matches printed by default. Enough to choose between, few enough to read all of. */
16
+ const DEFAULT_LIMIT = 5;
17
+
18
+ /** An install where the CLI cannot see its own dependency is broken, not merely undocumented. */
19
+ const unresolvedFinding = (): Finding => ({
20
+ code: 'X_CLI_UNEXPECTED',
21
+ cause: '@ultimat3/core does not resolve from the installed CLI, so no docs could be read',
22
+ fix: 'bun install && x doctor --json',
23
+ docs: 'https://ultimate.dev/errors/X_CLI_UNEXPECTED',
24
+ at: import.meta.dir,
25
+ });
26
+
27
+ const asJson = (hit: DocHit): JsonValue => ({
28
+ topic: hit.entry.topic,
29
+ package: hit.entry.package,
30
+ version: hit.entry.version,
31
+ kind: hit.entry.kind,
32
+ title: hit.entry.title,
33
+ text: hit.entry.text,
34
+ symbols: [...hit.entry.symbols],
35
+ source: hit.entry.source,
36
+ matched: [...hit.matched],
37
+ score: hit.score,
38
+ });
39
+
40
+ /** The exact path an agent opens next — package-relative is ambiguous across 29 packages. */
41
+ const locate = (entry: DocEntry): string => `${entry.package}/${entry.source}`;
42
+
43
+ function humanLines(hits: readonly DocHit[]): readonly string[] {
44
+ const lines: string[] = [];
45
+ for (const hit of hits) {
46
+ lines.push(` ${hit.entry.topic} ${locate(hit.entry)}`);
47
+ // The title is the package's own header comment, quoted verbatim — source text, not this
48
+ // command's prose, so it never goes through the catalog.
49
+ if (hit.entry.title !== '') lines.push(` ${hit.entry.title}`);
50
+ if (hit.entry.symbols.length > 0) {
51
+ lines.push(
52
+ ` ${msg('cli.docs.exports', { list: hit.entry.symbols.slice(0, 12).join(', ') })}`,
53
+ );
54
+ }
55
+ }
56
+ return lines;
57
+ }
58
+
59
+ /**
60
+ * The two commands that answer what `x docs` does not. The invocation stays inline and only its
61
+ * explanation is translated: a `fix:`-style command is copied and run verbatim, and a translated
62
+ * `x errors list --json` is a broken command (the same reason `Finding.fix` is exempt).
63
+ */
64
+ const alsoTry = (): readonly string[] => [
65
+ ` x errors list --json # ${msg('cli.docs.tryErrors')}`,
66
+ ` x actions list --json # ${msg('cli.docs.tryActions')}`,
67
+ ];
68
+
69
+ /**
70
+ * An `X_*` code is not a documentation question — `x errors explain` already answers it offline,
71
+ * with a runnable fix, and refuses a code nobody registered. Pointing at it costs the agent one
72
+ * step and beats ranking a code against prose that merely mentions it (axiom 1: one way).
73
+ */
74
+ const CODE_QUERY = /\bX_[A-Z0-9_]{3,}\b/;
75
+
76
+ /**
77
+ * Answered before anything is scanned, because the contract above is only true if nothing else
78
+ * runs. Ranking a code against prose returned five files for `X_DB_DRIFT` that merely contain
79
+ * "db" and "drift", with the redirect buried under them — and a code that matched nothing at all
80
+ * fell into `missResult`, which never carried the redirect. Returning here closes both, and skips
81
+ * a scan of every installed package for a question that was never about documentation.
82
+ */
83
+ function codeResult(code: string, query: string): CommandResult {
84
+ return {
85
+ ok: true,
86
+ command: 'docs',
87
+ summary: msg('cli.docs.code', { code }),
88
+ lines: [` x errors explain ${code} --json`],
89
+ data: { matches: [], suggestions: [], redirect: code, query },
90
+ };
91
+ }
92
+
93
+ /**
94
+ * A miss is still an instruction (axiom 4). Topics that half-matched come first; when nothing
95
+ * related at all, the honest fallback is the list of packages actually installed — the universe
96
+ * the question could have been about — rather than five topics picked for sharing letters.
97
+ */
98
+ function missResult(query: string, entries: readonly DocEntry[]): CommandResult {
99
+ const suggestions = nearestTopics(entries, query);
100
+ const packages = [...new Set(entries.map((entry) => entry.package))].sort();
101
+ const next =
102
+ suggestions.length > 0
103
+ ? suggestions.map((topic) => ` x docs ${topic} --json`)
104
+ : [` ${msg('cli.docs.installed', { list: packages.join(' ') })}`];
105
+ return {
106
+ ok: false,
107
+ command: 'docs',
108
+ summary: msg('cli.docs.none', { query }),
109
+ lines: [...next, ...alsoTry()],
110
+ data: { matches: [], suggestions: [...suggestions], packages, query },
111
+ };
112
+ }
113
+
114
+ export const docsCommand: CliCommand = {
115
+ spec: {
116
+ name: 'docs',
117
+ summary: 'the framework docs, answered offline from the installed packages',
118
+ usage: 'x docs "<question|topic|symbol>" [--limit <n>] [--json]',
119
+ flags: [
120
+ { name: 'limit', type: 'string', summary: `matches to return (default: ${DEFAULT_LIMIT})` },
121
+ ],
122
+ },
123
+ // `async` is load-bearing: a synchronous throw would escape every caller that awaits the
124
+ // promise this signature promises, including the dispatcher's own error path.
125
+ async run(ctx: CommandContext): Promise<CommandResult> {
126
+ // Joined, not `[0]`: an unquoted question arrives as many positionals, and answering only the
127
+ // first word is the failure an agent cannot see — it gets a plausible answer to "how".
128
+ const query = ctx.args.positionals.join(' ').trim();
129
+ if (query === '') {
130
+ throw new MissingPositionalError({
131
+ command: 'docs',
132
+ positional: 'question',
133
+ example: 'x docs "how does job() retry" --json',
134
+ });
135
+ }
136
+
137
+ const code = CODE_QUERY.exec(query)?.[0];
138
+ if (code !== undefined) return codeResult(code, query);
139
+
140
+ const scope = frameworkScopeDir();
141
+ if (scope === undefined) {
142
+ const finding = unresolvedFinding();
143
+ return {
144
+ ok: false,
145
+ command: 'docs',
146
+ summary: msg('cli.docs.unresolved'),
147
+ findings: [finding],
148
+ data: { matches: [], suggestions: [], query },
149
+ };
150
+ }
151
+
152
+ const entries = await scanInstalledDocs(scope);
153
+ const rawLimit = ctx.args.flags.get('limit');
154
+ const parsed = typeof rawLimit === 'string' ? Number.parseInt(rawLimit, 10) : Number.NaN;
155
+ const limit = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIMIT;
156
+ const hits = searchDocs(entries, query, limit);
157
+ if (hits.length === 0) return missResult(query, entries);
158
+
159
+ return {
160
+ ok: true,
161
+ command: 'docs',
162
+ summary: msg('cli.docs.found', { count: hits.length, query }),
163
+ lines: [...humanLines(hits)],
164
+ data: { matches: hits.map(asJson), suggestions: [], query },
165
+ };
166
+ },
167
+ };
package/src/cmd-doctor.ts CHANGED
@@ -4,14 +4,17 @@
4
4
 
5
5
  import { existsSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
- import { usesDevCursorSecret } from '@ultimat3/core';
7
+ import { tryResolveEnvironment, usesDevCursorSecret } from '@ultimat3/core';
8
+ import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
8
9
  import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
9
10
  import type { CliCommand, CommandContext } from './command';
11
+ import { checkMigrationSnapshots } from './db-snapshot';
10
12
  import { ICON_SOURCE } from './dev-assets';
11
- import { checkDrift } from './drift';
13
+ import { checkSourceDrift } from './drift';
14
+ import { intFlagOr, neighbouringPort, PORT_RANGE } from './flag-number';
12
15
  import { msg } from './messages';
13
16
  import type { CommandResult, Finding } from './output';
14
- import { flagString } from './parse';
17
+ import type { ParsedArgs } from './parse';
15
18
 
16
19
  /**
17
20
  * The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
@@ -26,11 +29,24 @@ export interface DoctorProbe {
26
29
  readonly port: number;
27
30
  /** True while cursors are signed with the key shipped in the published package. */
28
31
  readonly devCursorSecret: boolean;
32
+ /**
33
+ * True while the local disk WOULD sign upload grants with the key shipped in the published
34
+ * package. Same semantics as `devCursorSecret`, environment only — an app that passes an
35
+ * explicit `signingSecret` in `app.config.ts` never consults the env var, so this can read true
36
+ * for an app that is fine. The finding is worded as a condition to check, not a certainty.
37
+ */
38
+ readonly devStorageSecret: boolean;
29
39
  /** True when this process believes it is serving real clients. */
30
40
  readonly production: boolean;
31
41
  exists(relativePath: string): boolean;
32
42
  portFree(port: number): Promise<boolean>;
33
43
  drift(): Promise<readonly Finding[]>;
44
+ /**
45
+ * The other half of the migrations directory: a newest migration with no `.snapshot.json`, which
46
+ * is what `x db gen` refuses on. Separate from `drift()` because they are separate questions with
47
+ * separate remedies — one is "generate a migration", the other is "this migration is incomplete".
48
+ */
49
+ snapshots(): Promise<readonly Finding[]>;
34
50
  }
35
51
 
36
52
  const docs = (code: string): string => `https://ultimate.dev/errors/${code}`;
@@ -42,6 +58,9 @@ const finding = (code: string, cause: string, fix: string, at?: string): Finding
42
58
 
43
59
  export const OFFLINE_FALLBACK = 'apps/web/app/offline.tsx';
44
60
 
61
+ /** The port `x dev` binds by default, so the probe answers about the port the developer will use. */
62
+ const DEFAULT_DOCTOR_PORT = 3000;
63
+
45
64
  /**
46
65
  * Ordered cheapest-first so the first failure is usually the root cause: a wrong Bun explains
47
66
  * every other symptom, and running outside an app explains the rest.
@@ -88,12 +107,27 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
88
107
  ),
89
108
  );
90
109
  }
110
+ // The storage twin of the cursor key above, and the more expensive one to get wrong: the
111
+ // published string mints a signed `PUT` for any key with any `maxBytes` and `contentType`, and
112
+ // `acceptSignedUpload` trusts the signed constraints over the app's own `uploadPolicy`.
113
+ // Production only, for the reason the cursor check gives — every dev environment signs with the
114
+ // shipped key on purpose. `@ultimat3/storage` refuses this at construction; `x doctor` is what
115
+ // reports it before a deploy reaches the refusal.
116
+ if (probe.production && probe.devStorageSecret) {
117
+ findings.push(
118
+ finding(
119
+ 'X_STORAGE_SECRET_DEV',
120
+ `${STORAGE_SIGNING_SECRET_KEY} is unset or holds the shipped development key, so a local-disk deploy would accept forged upload grants that override its own uploadPolicy`,
121
+ 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
122
+ ),
123
+ );
124
+ }
91
125
  if (!(await probe.portFree(probe.port))) {
92
126
  findings.push(
93
127
  finding(
94
128
  'X_PORT_IN_USE',
95
129
  `port ${probe.port} is already listening`,
96
- `x dev --port ${probe.port + 1}`,
130
+ `x dev --port ${neighbouringPort(probe.port)}`,
97
131
  ),
98
132
  );
99
133
  }
@@ -125,9 +159,25 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
125
159
  );
126
160
  }
127
161
  findings.push(...(await probe.drift()));
162
+ // Last, and it is why `X_CLI_UNEXPECTED`'s `fix: x doctor --json` is not a dead end on the path an
163
+ // author reaches it from: `x db gen` throwing `X_MIGRATION_SNAPSHOT_MISSING` used to be a
164
+ // condition this diagnostic could not see at all, so the fix line ran clean over a broken app.
165
+ findings.push(...(await probe.snapshots()));
128
166
  return findings;
129
167
  }
130
168
 
169
+ /**
170
+ * The port to TEST, and the one place `x doctor` reads it. `PORT_RANGE.min` is 0 because `x dev
171
+ * --port 0` means "let the kernel pick"; here 0 means nothing, and `Bun.serve({ port: 0 })` always
172
+ * succeeds — so the port check could not fail, which is worse than not running it.
173
+ */
174
+ export const doctorPort = (args: ParsedArgs): number =>
175
+ intFlagOr(
176
+ args,
177
+ { name: 'port', command: 'doctor', ...PORT_RANGE, min: 1, example: 'x doctor --port 3000' },
178
+ DEFAULT_DOCTOR_PORT,
179
+ );
180
+
131
181
  const portFree = async (port: number): Promise<boolean> => {
132
182
  try {
133
183
  const server = Bun.serve({ port, fetch: () => new Response('') });
@@ -145,12 +195,19 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
145
195
  root,
146
196
  port,
147
197
  devCursorSecret: usesDevCursorSecret(),
148
- // `X_ENV` first, then `NODE_ENV`: the order `@ultimat3/admin`'s dev-server guard already
149
- // reads them in, and a second order would be a second convention.
150
- production: (Bun.env['X_ENV'] ?? Bun.env['NODE_ENV']) === 'production',
198
+ devStorageSecret: usesDevStorageSecret(),
199
+ // `ULTIMATE_ENV`, through core — the one key that says which deploy this is, with `NODE_ENV`
200
+ // as its documented fallback. This read `X_ENV ?? NODE_ENV`, a spelling nothing else in the
201
+ // repo reads, so a deploy declaring production the framework's own way was told it was not
202
+ // production and skipped both secret findings; and the `??` short-circuited, so any non-empty
203
+ // `X_ENV` shadowed a real `NODE_ENV=production` too. The non-throwing variant because
204
+ // `ULTIMATE_ENV` is not in the env schema — nothing validates it at boot, and a diagnostic
205
+ // that crashes on a typo is the one thing worse than a diagnostic that misses.
206
+ production: tryResolveEnvironment() === 'production',
151
207
  exists: (relativePath) => (root === undefined ? false : existsSync(join(root, relativePath))),
152
208
  portFree,
153
- drift: async () => (root === undefined ? [] : checkDrift(root)),
209
+ drift: async () => (root === undefined ? [] : checkSourceDrift(root)),
210
+ snapshots: async () => (root === undefined ? [] : checkMigrationSnapshots(root)),
154
211
  };
155
212
  }
156
213
 
@@ -159,10 +216,17 @@ export const doctorCommand: CliCommand = {
159
216
  name: 'doctor',
160
217
  summary: 'environment, versions, drift, ports, PWA prerequisites — each with a fix command',
161
218
  usage: 'x doctor [--port 3000] [--json]',
162
- flags: [{ name: 'port', type: 'string', summary: 'port to test', default: '3000' }],
219
+ flags: [
220
+ {
221
+ name: 'port',
222
+ type: 'string',
223
+ summary: 'port to test',
224
+ default: String(DEFAULT_DOCTOR_PORT),
225
+ },
226
+ ],
163
227
  },
164
228
  async run(ctx: CommandContext): Promise<CommandResult> {
165
- const port = Number.parseInt(flagString(ctx.args, 'port') ?? '3000', 10);
229
+ const port = doctorPort(ctx.args);
166
230
  const findings = await runDoctor(probeFor(ctx.cwd, ctx.bunVersion, port));
167
231
  return {
168
232
  ok: findings.length === 0,