@ultimat3/cli 1.1.0 → 2.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 (138) hide show
  1. package/CLAUDE.md +724 -0
  2. package/README.md +41 -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 +114 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +215 -0
  13. package/src/cmd-db.ts +332 -155
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +87 -17
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +64 -9
  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 +13 -7
  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 +17 -23
  33. package/src/cmd-verify.ts +177 -23
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +251 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +112 -0
  39. package/src/db-snapshot.ts +24 -0
  40. package/src/dev-assets.ts +86 -20
  41. package/src/dev-cache.ts +122 -0
  42. package/src/dev-dashboard.ts +19 -4
  43. package/src/dev-hooks.ts +27 -2
  44. package/src/dev-n-plus-one.ts +191 -0
  45. package/src/dev-queue.ts +105 -19
  46. package/src/dev-render.ts +158 -26
  47. package/src/dev-roles-fixture.ts +67 -0
  48. package/src/dev-roles.ts +186 -78
  49. package/src/dev-runtime.ts +117 -40
  50. package/src/dev-services.ts +15 -0
  51. package/src/dev-storage.ts +245 -0
  52. package/src/dev-sync.ts +107 -0
  53. package/src/dev-traces.ts +11 -3
  54. package/src/dispatch.ts +4 -2
  55. package/src/document-styles.ts +54 -0
  56. package/src/drift.ts +37 -9
  57. package/src/error-catalog.ts +7 -18
  58. package/src/error-codes.ts +186 -0
  59. package/src/error-contract.ts +29 -7
  60. package/src/error-fixes.ts +114 -0
  61. package/src/errors.ts +205 -140
  62. package/src/fix-command.ts +268 -0
  63. package/src/flag-number.ts +56 -0
  64. package/src/framework-scope.ts +49 -0
  65. package/src/generate-kinds.ts +97 -0
  66. package/src/guards.ts +186 -0
  67. package/src/index.ts +87 -14
  68. package/src/island-bundle.ts +166 -0
  69. package/src/island-routes.ts +50 -0
  70. package/src/jobs-driver.ts +33 -0
  71. package/src/jobs-json.ts +24 -0
  72. package/src/jobs-report.ts +17 -4
  73. package/src/mcp-db-target.ts +52 -27
  74. package/src/mcp-errors.ts +120 -19
  75. package/src/mcp-host.ts +44 -25
  76. package/src/messages.ts +81 -2
  77. package/src/metrics-endpoint.ts +73 -0
  78. package/src/migrations.ts +37 -4
  79. package/src/otlp-export.ts +64 -0
  80. package/src/output.ts +46 -16
  81. package/src/parse.ts +41 -3
  82. package/src/policy-facts.ts +38 -6
  83. package/src/policy-fixture.ts +14 -7
  84. package/src/prerender.ts +111 -2
  85. package/src/registry.ts +21 -3
  86. package/src/runtime-overrides.ts +66 -0
  87. package/src/safe-url-label.ts +24 -0
  88. package/src/scaffold-fixture.ts +10 -0
  89. package/src/scaffold-typecheck.ts +16 -38
  90. package/src/serve.ts +202 -18
  91. package/src/source-files.ts +4 -0
  92. package/src/statement-loop.ts +74 -0
  93. package/src/style-csp.ts +18 -0
  94. package/src/sync-authenticator.ts +59 -0
  95. package/src/templates/action.ts +15 -30
  96. package/src/templates/admin-page.ts +103 -0
  97. package/src/templates/admin.ts +11 -7
  98. package/src/templates/backfill.ts +212 -0
  99. package/src/templates/entity.ts +72 -31
  100. package/src/templates/guard.ts +143 -0
  101. package/src/templates/index.ts +12 -1
  102. package/src/templates/island.ts +67 -0
  103. package/src/templates/job.ts +53 -13
  104. package/src/templates/naming.ts +17 -1
  105. package/src/templates/policy.ts +35 -28
  106. package/src/templates/query.ts +24 -5
  107. package/src/templates/resource.ts +19 -11
  108. package/src/templates/route.ts +90 -15
  109. package/src/templates/scaffold-app.ts +142 -45
  110. package/src/templates/scaffold-claude-agents.ts +149 -0
  111. package/src/templates/scaffold-claude-commands.ts +221 -0
  112. package/src/templates/scaffold-claude.ts +134 -0
  113. package/src/templates/scaffold-container.ts +46 -2
  114. package/src/templates/scaffold-db-package.ts +91 -0
  115. package/src/templates/scaffold-docs.ts +24 -5
  116. package/src/templates/scaffold-domain-package.ts +90 -0
  117. package/src/templates/scaffold-env.ts +87 -0
  118. package/src/templates/scaffold-i18n.ts +4 -1
  119. package/src/templates/scaffold-mcp-package.ts +49 -0
  120. package/src/templates/scaffold-package-shape.ts +25 -4
  121. package/src/templates/scaffold-repo.ts +116 -257
  122. package/src/templates/scaffold-roles.ts +68 -0
  123. package/src/templates/scaffold-ui-package.ts +56 -0
  124. package/src/templates/slice-foundation.ts +88 -0
  125. package/src/templates/wrap.ts +95 -0
  126. package/src/test-counts.ts +35 -0
  127. package/src/test-select.ts +30 -15
  128. package/src/test-shards.ts +21 -3
  129. package/src/test-workers.ts +47 -0
  130. package/src/ts-scan.ts +271 -13
  131. package/src/tsconfig-references.ts +78 -0
  132. package/src/verify-floor.ts +133 -0
  133. package/src/verify-step.ts +19 -0
  134. package/src/verify-test-run.ts +72 -0
  135. package/src/verify-tests.ts +160 -71
  136. package/src/version-loader.ts +20 -3
  137. package/src/workspace-checks.ts +87 -16
  138. package/src/write-line.ts +34 -0
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
- import { configureTelemetry, noopExporter } from '@ultimat3/core';
12
- import type { Route } from '@ultimat3/http';
11
+ import { configureTelemetry, METRICS_PATH, noopExporter } from '@ultimat3/core';
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,26 @@ 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 { loopFacts, loopFinding, loopNotice } from './statement-loop';
35
46
 
36
47
  const DEFAULT_PORT = 3000;
37
48
 
@@ -41,7 +52,11 @@ export interface DevServer {
41
52
  readonly roles: readonly Role[];
42
53
  /** The manifest as it stands now — a reload that registers a new route moves it. */
43
54
  readonly buildId: string;
44
- /** Modules that would not import, primitives that would not register, reloads that would not build. */
55
+ /**
56
+ * Modules that would not import, primitives that would not register, reloads that would not
57
+ * build — and the statement loops this process has counted so far, which is what puts an N+1 in
58
+ * `x dev`'s own output and in `--json` without a channel of its own.
59
+ */
45
60
  readonly findings: readonly Finding[];
46
61
  readonly running: RunningRoles;
47
62
  readonly runtime: RunningServices;
@@ -55,6 +70,12 @@ interface DevState {
55
70
  reloads: number;
56
71
  /** A save that will not build. Replaced on every attempt, so a fixed file clears it. */
57
72
  reloadFinding: Finding | undefined;
73
+ /**
74
+ * The client entries, rebuilt on the same tick as the manifest. An island is the one module this
75
+ * process never imports, so a fresh `Bun.build` is the whole of its reload — no module cache to
76
+ * invalidate, which is exactly why editing one takes effect where editing a route does not.
77
+ */
78
+ islands: IslandBundle;
58
79
  }
59
80
 
60
81
  /** Debounced: a save that touches five files is one reload, not five. */
@@ -105,11 +126,19 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
105
126
  // what configures one, which is the whole reason `/_x/timeline` has anything to draw.
106
127
  const traces = createTraceRecorder();
107
128
  configureTelemetry({ exporter: traces.exporter });
129
+ // Installed at the same moment and for the same reason: an observer is the single switch that
130
+ // turns statement instrumentation on at all (`@ultimat3/db`'s `observe.ts`), so the timeline's
131
+ // SQL rows and the repeat counts arrive together rather than through two toggles. `serve.ts`
132
+ // installs neither — a production process pays the one `undefined` branch the seam costs
133
+ // uninstalled, and nothing more (axiom 6).
134
+ const statements = createStatementLedger();
135
+ setStatementObserver(statements.observer);
108
136
  const app = await loadApp(options.root);
109
137
  const state: DevState = {
110
138
  manifest: (await appManifest(options.root)).manifest,
111
139
  reloads: 0,
112
140
  reloadFinding: undefined,
141
+ islands: await buildIslands(options.root),
113
142
  };
114
143
  // The manifest's build id is a content hash of every fact below it, so a dev document's
115
144
  // `x-ultimate-build` header names the exact shape the client was served against. Pinned at
@@ -132,18 +161,25 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
132
161
  reloads: state.reloads,
133
162
  }),
134
163
  traces,
164
+ statements,
135
165
  ...envOf(options.env),
136
166
  };
137
167
  const panels = devPanels(dashboard).map((panel) => panel.key);
138
168
 
139
169
  const routes: readonly Route[] = [
140
170
  ...devDashboardRoutes(dashboard),
141
- ...listActions().map(toRoute),
171
+ // The same API table the container serves: a read that answers here and 404s in production
172
+ // is exactly the drift one composition exists to prevent.
173
+ ...apiRoutes(),
142
174
  // The image pipeline's only HTTP surface: the icons the web manifest declares, and the
143
175
  // variants every `srcset` promises. Mounted before the app's own routes so a page route can
144
176
  // never shadow `/icons` or `/media`.
145
177
  ...assetRoutes({ root: options.root, storage: runtime.storage }),
146
- ...appRoutes({ buildId }),
178
+ ...storageRoutes({ storage: runtime.storage }),
179
+ // The chunks the documents below name. Mounted before the app's routes for the reason
180
+ // `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
181
+ ...islandRoutes(() => state.islands),
182
+ ...appRoutes({ buildId, resolveIsland: (file) => state.islands.resolverFor(file) }),
147
183
  ];
148
184
 
149
185
  const running = await startRoles({
@@ -153,13 +189,28 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
153
189
  runtime,
154
190
  routes,
155
191
  env: options.env,
192
+ // Read from `app.config.ts` rather than threaded through `DevOptions`: it is the app's own
193
+ // declaration, and `x dev` and `serve.ts` must not be able to disagree about where the app's
194
+ // sign-in page is.
195
+ signInPath: await loadSignInPath(options.root),
196
+ // The one document this process serves that the app did not write; `startRoles` covers the
197
+ // app's own surfaces itself. `x dev` sends the policy report-only, so an uncovered `<style>`
198
+ // here is a console report rather than a blank page — which is how this reached production.
199
+ inlineStyles: [await devShellStyle()],
200
+ // The fourth surface, and the only one an author sees without leaving the page they broke:
201
+ // the overlay renders this request's own loops under the error it is already showing.
202
+ // `serve.ts` boots through the same `startRoles` and passes nothing, so production has no
203
+ // diagnostic to call.
204
+ devNotices: (ctx: RequestContext): readonly OverlayNotice[] =>
205
+ statements.repeatsFor(asCtx(ctx)).map(loopFacts).map(loopNotice),
156
206
  });
157
207
 
158
208
  const stopWatching = watchApp(options.root, (file) => {
159
209
  const started = performance.now();
160
- void appManifest(options.root)
161
- .then(({ manifest }) => {
210
+ void Promise.all([appManifest(options.root), buildIslands(options.root)])
211
+ .then(([{ manifest }, islands]) => {
162
212
  state.manifest = manifest;
213
+ state.islands = islands;
163
214
  state.reloads += 1;
164
215
  state.reloadFinding = undefined;
165
216
  options.onReload?.(file, Math.round(performance.now() - started));
@@ -178,12 +229,15 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
178
229
  get buildId(): string {
179
230
  return state.manifest.buildId;
180
231
  },
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.
232
+ // A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed and the
233
+ // loop the last request tripped, not the findings as they were when the route table was built.
234
+ // The loops come last and carry their request id, so a boot report reads as a boot report and
235
+ // a diagnostic that arrived a minute later reads as one too.
183
236
  get findings(): readonly Finding[] {
237
+ const loops = statements.repeats().map(loopFacts).map(loopFinding);
184
238
  return state.reloadFinding === undefined
185
- ? app.findings
186
- : [...app.findings, state.reloadFinding];
239
+ ? [...app.findings, ...loops]
240
+ : [...app.findings, state.reloadFinding, ...loops];
187
241
  },
188
242
  running,
189
243
  runtime,
@@ -197,6 +251,12 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
197
251
  // leaving it in place would keep every span of the next `startDev` in this process's buffer.
198
252
  configureTelemetry({ exporter: noopExporter });
199
253
  traces.reset();
254
+ // Released with the exporter, after the roles, for the same reason: a statement still in
255
+ // flight is observed by the ledger that counted the rest of its request. Leaving it
256
+ // installed would keep every statement of the next `startDev` in this process's counts —
257
+ // and, worse, keep instrumentation on in a process that is no longer a dev server.
258
+ setStatementObserver(undefined);
259
+ statements.reset();
200
260
  },
201
261
  };
202
262
  return server;
@@ -220,7 +280,13 @@ export const devCommand: CliCommand = {
220
280
  },
221
281
  async run(ctx: CommandContext): Promise<CommandResult> {
222
282
  const root = requireAppRoot('dev', ctx.cwd).dir;
223
- const port = Number.parseInt(flagString(ctx.args, 'port') ?? String(DEFAULT_PORT), 10);
283
+ // Validated, not `parseInt`'d: `x dev --port abc` handed `NaN` to `Bun.serve`, which binds an
284
+ // arbitrary port — a dev server reachable at an address nothing printed.
285
+ const port = intFlagOr(
286
+ ctx.args,
287
+ { name: 'port', command: 'dev', ...PORT_RANGE, example: `x dev --port ${DEFAULT_PORT}` },
288
+ DEFAULT_PORT,
289
+ );
224
290
  const roles = selectRoles(flagString(ctx.args, 'role'));
225
291
  const server = await startDev({
226
292
  root,
@@ -249,10 +315,14 @@ export const devCommand: CliCommand = {
249
315
  url: server.url,
250
316
  roles: [...server.roles],
251
317
  sync: server.running.syncUrl,
318
+ // The scrape target, on its own port for every role: what an operator points a Prometheus
319
+ // at, and the one url here that must NOT be behind the ingress the app's own url is.
320
+ metrics: `${server.running.metricsUrl}${METRICS_PATH}`,
252
321
  stateDir: server.services.stateDir,
253
- db: server.services.db.url,
254
- events: server.services.events.url,
255
- storage: server.services.storage.url,
322
+ // Redacted, for the reason the mail and cdn lines below already give and this line did
323
+ // not: `DATABASE_URL`, `NATS_URL` and `S3_ENDPOINT` all carry a password, and this object
324
+ // is printed, logged and scraped. `reportedUrls` is the one projection that may be shown.
325
+ ...reportedUrls(server.services),
256
326
  // The selecting env key, never the credential behind it: `SMTP_URL` carries a password
257
327
  // and this line is printed, logged and scraped.
258
328
  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,16 @@
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, PORT_RANGE } from './flag-number';
12
15
  import { msg } from './messages';
13
16
  import type { CommandResult, Finding } from './output';
14
- import { flagString } from './parse';
15
17
 
16
18
  /**
17
19
  * The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
@@ -26,11 +28,24 @@ export interface DoctorProbe {
26
28
  readonly port: number;
27
29
  /** True while cursors are signed with the key shipped in the published package. */
28
30
  readonly devCursorSecret: boolean;
31
+ /**
32
+ * True while the local disk WOULD sign upload grants with the key shipped in the published
33
+ * package. Same semantics as `devCursorSecret`, environment only — an app that passes an
34
+ * explicit `signingSecret` in `app.config.ts` never consults the env var, so this can read true
35
+ * for an app that is fine. The finding is worded as a condition to check, not a certainty.
36
+ */
37
+ readonly devStorageSecret: boolean;
29
38
  /** True when this process believes it is serving real clients. */
30
39
  readonly production: boolean;
31
40
  exists(relativePath: string): boolean;
32
41
  portFree(port: number): Promise<boolean>;
33
42
  drift(): Promise<readonly Finding[]>;
43
+ /**
44
+ * The other half of the migrations directory: a newest migration with no `.snapshot.json`, which
45
+ * is what `x db gen` refuses on. Separate from `drift()` because they are separate questions with
46
+ * separate remedies — one is "generate a migration", the other is "this migration is incomplete".
47
+ */
48
+ snapshots(): Promise<readonly Finding[]>;
34
49
  }
35
50
 
36
51
  const docs = (code: string): string => `https://ultimate.dev/errors/${code}`;
@@ -42,6 +57,9 @@ const finding = (code: string, cause: string, fix: string, at?: string): Finding
42
57
 
43
58
  export const OFFLINE_FALLBACK = 'apps/web/app/offline.tsx';
44
59
 
60
+ /** The port `x dev` binds by default, so the probe answers about the port the developer will use. */
61
+ const DEFAULT_DOCTOR_PORT = 3000;
62
+
45
63
  /**
46
64
  * Ordered cheapest-first so the first failure is usually the root cause: a wrong Bun explains
47
65
  * every other symptom, and running outside an app explains the rest.
@@ -88,6 +106,21 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
88
106
  ),
89
107
  );
90
108
  }
109
+ // The storage twin of the cursor key above, and the more expensive one to get wrong: the
110
+ // published string mints a signed `PUT` for any key with any `maxBytes` and `contentType`, and
111
+ // `acceptSignedUpload` trusts the signed constraints over the app's own `uploadPolicy`.
112
+ // Production only, for the reason the cursor check gives — every dev environment signs with the
113
+ // shipped key on purpose. `@ultimat3/storage` refuses this at construction; `x doctor` is what
114
+ // reports it before a deploy reaches the refusal.
115
+ if (probe.production && probe.devStorageSecret) {
116
+ findings.push(
117
+ finding(
118
+ 'X_STORAGE_SECRET_DEV',
119
+ `${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`,
120
+ 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
121
+ ),
122
+ );
123
+ }
91
124
  if (!(await probe.portFree(probe.port))) {
92
125
  findings.push(
93
126
  finding(
@@ -125,6 +158,10 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
125
158
  );
126
159
  }
127
160
  findings.push(...(await probe.drift()));
161
+ // Last, and it is why `X_CLI_UNEXPECTED`'s `fix: x doctor --json` is not a dead end on the path an
162
+ // author reaches it from: `x db gen` throwing `X_MIGRATION_SNAPSHOT_MISSING` used to be a
163
+ // condition this diagnostic could not see at all, so the fix line ran clean over a broken app.
164
+ findings.push(...(await probe.snapshots()));
128
165
  return findings;
129
166
  }
130
167
 
@@ -145,12 +182,19 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
145
182
  root,
146
183
  port,
147
184
  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',
185
+ devStorageSecret: usesDevStorageSecret(),
186
+ // `ULTIMATE_ENV`, through core — the one key that says which deploy this is, with `NODE_ENV`
187
+ // as its documented fallback. This read `X_ENV ?? NODE_ENV`, a spelling nothing else in the
188
+ // repo reads, so a deploy declaring production the framework's own way was told it was not
189
+ // production and skipped both secret findings; and the `??` short-circuited, so any non-empty
190
+ // `X_ENV` shadowed a real `NODE_ENV=production` too. The non-throwing variant because
191
+ // `ULTIMATE_ENV` is not in the env schema — nothing validates it at boot, and a diagnostic
192
+ // that crashes on a typo is the one thing worse than a diagnostic that misses.
193
+ production: tryResolveEnvironment() === 'production',
151
194
  exists: (relativePath) => (root === undefined ? false : existsSync(join(root, relativePath))),
152
195
  portFree,
153
- drift: async () => (root === undefined ? [] : checkDrift(root)),
196
+ drift: async () => (root === undefined ? [] : checkSourceDrift(root)),
197
+ snapshots: async () => (root === undefined ? [] : checkMigrationSnapshots(root)),
154
198
  };
155
199
  }
156
200
 
@@ -159,10 +203,21 @@ export const doctorCommand: CliCommand = {
159
203
  name: 'doctor',
160
204
  summary: 'environment, versions, drift, ports, PWA prerequisites — each with a fix command',
161
205
  usage: 'x doctor [--port 3000] [--json]',
162
- flags: [{ name: 'port', type: 'string', summary: 'port to test', default: '3000' }],
206
+ flags: [
207
+ {
208
+ name: 'port',
209
+ type: 'string',
210
+ summary: 'port to test',
211
+ default: String(DEFAULT_DOCTOR_PORT),
212
+ },
213
+ ],
163
214
  },
164
215
  async run(ctx: CommandContext): Promise<CommandResult> {
165
- const port = Number.parseInt(flagString(ctx.args, 'port') ?? '3000', 10);
216
+ const port = intFlagOr(
217
+ ctx.args,
218
+ { name: 'port', command: 'doctor', ...PORT_RANGE, example: 'x doctor --port 3000' },
219
+ DEFAULT_DOCTOR_PORT,
220
+ );
166
221
  const findings = await runDoctor(probeFor(ctx.cwd, ctx.bunVersion, port));
167
222
  return {
168
223
  ok: findings.length === 0,
package/src/cmd-env.ts ADDED
@@ -0,0 +1,95 @@
1
+ // `x env` — the two things a typed environment owes an agent: the committed `.env.example` that
2
+ // says which variables exist, and the answer to "does this process have them?". Both are
3
+ // projections of the one `defineEnv` declaration in `app.config.ts`; neither reads a second list.
4
+
5
+ // Bun ships no path-join primitive, and `.env.example` is written app-root-relative.
6
+ import { join } from 'node:path';
7
+ import { checkEnv, ENV_EXAMPLE_PATH, maskedEnvValues } from '@ultimat3/core';
8
+ import { ENV_SCHEMA_EXPORT, envExampleFor, loadEnvSchema } from './app-env';
9
+ import { APP_CONFIG_FILE, requireAppRoot } from './app-root';
10
+ import type { CliCommand, CommandContext } from './command';
11
+ import { EnvSchemaMissingError } from './errors';
12
+ import { msg } from './messages';
13
+ import type { CommandResult, Finding, JsonValue } from './output';
14
+
15
+ /**
16
+ * Every subcommand needs the declaration, and an app without one is a usage error rather than an
17
+ * empty success: `x env example` writing a two-comment file would look like it worked.
18
+ */
19
+ async function requireSchema(cwd: string, subcommand: string) {
20
+ const root = requireAppRoot(`env ${subcommand}`, cwd).dir;
21
+ const schema = await loadEnvSchema(root);
22
+ if (schema === undefined) throw new EnvSchemaMissingError({ subcommand });
23
+ return { root, schema };
24
+ }
25
+
26
+ async function writeExample(ctx: CommandContext): Promise<CommandResult> {
27
+ const { root, schema } = await requireSchema(ctx.cwd, 'example');
28
+ const contents = envExampleFor(schema);
29
+ const path = join(root, ENV_EXAMPLE_PATH);
30
+ const file = Bun.file(path);
31
+ const fresh = (await file.exists()) && (await file.text()) === contents;
32
+ if (!fresh) await Bun.write(path, contents);
33
+ const count = Object.keys(schema).length;
34
+ return {
35
+ ok: true,
36
+ command: 'env',
37
+ summary: fresh
38
+ ? msg('cli.env.fresh', { path: ENV_EXAMPLE_PATH })
39
+ : msg('cli.env.wrote', { path: ENV_EXAMPLE_PATH, count }),
40
+ data: { path: ENV_EXAMPLE_PATH, variables: count, written: !fresh },
41
+ };
42
+ }
43
+
44
+ /**
45
+ * The values are read from the real process environment, and only ever printed through
46
+ * `maskedEnvValues` — `checkEnv().values` holds the actual secrets because `defineEnv()` has to
47
+ * return them, and a `--json` report is the last place a DSN should appear in full.
48
+ */
49
+ async function checkProcessEnv(ctx: CommandContext): Promise<CommandResult> {
50
+ const { schema } = await requireSchema(ctx.cwd, 'check');
51
+ const report = checkEnv(schema);
52
+ const total = Object.keys(schema).length;
53
+ const findings: readonly Finding[] = report.issues.map((issue) => ({
54
+ code: 'X_ENV_MISSING',
55
+ cause: `${issue.key} is ${issue.reason} (expected ${issue.expected})`,
56
+ fix: issue.fix,
57
+ docs: 'https://ultimate.dev/errors/X_ENV_MISSING',
58
+ at: ENV_EXAMPLE_PATH,
59
+ }));
60
+ return {
61
+ ok: report.ok,
62
+ command: 'env',
63
+ summary: report.ok
64
+ ? msg('cli.env.checked', { count: total })
65
+ : msg('cli.env.invalid', { count: report.issues.length, total }),
66
+ findings,
67
+ data: {
68
+ variables: total,
69
+ values: maskedEnvValues(schema, report.values) as JsonValue,
70
+ },
71
+ exitCode: report.ok ? 0 : 1,
72
+ };
73
+ }
74
+
75
+ export const envCommand: CliCommand = {
76
+ spec: {
77
+ name: 'env',
78
+ summary: `the typed environment declared by ${ENV_SCHEMA_EXPORT} in ${APP_CONFIG_FILE}`,
79
+ usage: 'x env [check|example] [--json]',
80
+ requiresApp: true,
81
+ subcommands: ['check', 'example'],
82
+ // The bare `x env` answers the question the fix line on every `X_ENV_MISSING` in this
83
+ // framework already tells its reader to run.
84
+ defaultSubcommand: 'check',
85
+ flags: [],
86
+ },
87
+ async run(ctx: CommandContext): Promise<CommandResult> {
88
+ // `subcommand`, never `positionals[0]`: the parser has already lifted a declared subcommand
89
+ // out of the positionals, so reading the array here matches nothing and every invocation
90
+ // silently ran the default.
91
+ return (ctx.args.subcommand ?? 'check') === 'example'
92
+ ? writeExample(ctx)
93
+ : checkProcessEnv(ctx);
94
+ },
95
+ };