@ultimat3/cli 23.0.0 → 24.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 (149) hide show
  1. package/CLAUDE.md +15 -7
  2. package/README.md +3 -3
  3. package/package.json +28 -28
  4. package/src/affected.ts +27 -35
  5. package/src/api-registration.ts +89 -0
  6. package/src/app-artifacts.ts +1 -1
  7. package/src/app-auth.ts +4 -27
  8. package/src/app-config-load.ts +51 -0
  9. package/src/app-env.ts +5 -11
  10. package/src/app-manifest.ts +2 -8
  11. package/src/app-mcp.ts +10 -23
  12. package/src/app-permissions-borrowed.ts +129 -0
  13. package/src/app-permissions-site.ts +24 -0
  14. package/src/app-permissions.ts +7 -17
  15. package/src/budgets.ts +4 -35
  16. package/src/cdp-shot-driver.ts +3 -1
  17. package/src/cmd-auth-spec.ts +16 -0
  18. package/src/cmd-auth.ts +65 -0
  19. package/src/cmd-build.ts +64 -15
  20. package/src/cmd-db-backfill.ts +25 -12
  21. package/src/cmd-db.ts +6 -5
  22. package/src/cmd-deploy-helm.ts +7 -6
  23. package/src/cmd-deploy.ts +4 -4
  24. package/src/cmd-dev.ts +9 -8
  25. package/src/cmd-doctor-spec.ts +2 -9
  26. package/src/cmd-doctor.ts +41 -14
  27. package/src/cmd-env.ts +26 -2
  28. package/src/cmd-generate.ts +79 -32
  29. package/src/cmd-help.ts +19 -6
  30. package/src/cmd-i18n.ts +10 -9
  31. package/src/cmd-jobs.ts +18 -6
  32. package/src/cmd-manifest-spec.ts +1 -1
  33. package/src/cmd-manifest.ts +6 -25
  34. package/src/cmd-mcp.ts +46 -15
  35. package/src/cmd-planned.ts +15 -8
  36. package/src/cmd-routes.ts +12 -2
  37. package/src/cmd-secrets.ts +47 -6
  38. package/src/cmd-shot-matrix.ts +1 -3
  39. package/src/cmd-shot.ts +12 -3
  40. package/src/cmd-tasks.ts +2 -2
  41. package/src/cmd-test-spec.ts +1 -7
  42. package/src/cmd-test.ts +13 -29
  43. package/src/cmd-verify.ts +4 -4
  44. package/src/db-subscribes.ts +12 -4
  45. package/src/dev-dashboard-guard.ts +76 -0
  46. package/src/dev-dashboard.ts +51 -29
  47. package/src/dev-lock.ts +4 -7
  48. package/src/dispatch.ts +3 -6
  49. package/src/doctor-app-url.ts +39 -0
  50. package/src/doctor-auth.ts +95 -0
  51. package/src/drift-replica-identity.ts +42 -0
  52. package/src/error-catalog.ts +6 -9
  53. package/src/error-codes.ts +23 -3
  54. package/src/error-unthrown.ts +29 -11
  55. package/src/errors.ts +54 -83
  56. package/src/framework-schema-apply.ts +78 -0
  57. package/src/framework-schema-stamp.ts +96 -0
  58. package/src/framework-schema.ts +13 -2
  59. package/src/generate-files.ts +4 -3
  60. package/src/generate-kinds.ts +33 -1
  61. package/src/generate-shadow.ts +46 -0
  62. package/src/generate-write.ts +47 -13
  63. package/src/gh-target.ts +12 -7
  64. package/src/i18n-audit.ts +4 -1
  65. package/src/index.ts +1 -3
  66. package/src/island-bundle.ts +3 -15
  67. package/src/jobs-drain.ts +82 -15
  68. package/src/jobs-report.ts +4 -4
  69. package/src/loopback-host.ts +13 -0
  70. package/src/manifest-staleness.ts +62 -0
  71. package/src/mcp-errors.ts +12 -3
  72. package/src/mcp-host.ts +16 -9
  73. package/src/mcp-test-run.ts +38 -0
  74. package/src/messages.ts +2 -0
  75. package/src/metrics-endpoint.ts +25 -1
  76. package/src/output.ts +6 -0
  77. package/src/page-navigation.ts +7 -23
  78. package/src/page-speculation.ts +5 -20
  79. package/src/prerender-out.ts +34 -7
  80. package/src/prerender.ts +8 -5
  81. package/src/pwa-artifacts.ts +26 -94
  82. package/src/registry.ts +2 -0
  83. package/src/revalidated-response.ts +4 -3
  84. package/src/role-replicator.ts +16 -0
  85. package/src/role-start.ts +9 -4
  86. package/src/role-sync.ts +55 -20
  87. package/src/runtime-assets.ts +11 -7
  88. package/src/runtime-bindings.ts +4 -4
  89. package/src/runtime-cache.ts +38 -47
  90. package/src/runtime-idempotency-scope.ts +23 -0
  91. package/src/runtime-isr.ts +36 -0
  92. package/src/runtime-jobs.ts +8 -33
  93. package/src/runtime-mfa-warning.ts +29 -0
  94. package/src/runtime-notify-retention.ts +10 -46
  95. package/src/runtime-overrides.ts +1 -1
  96. package/src/runtime-purge.ts +38 -16
  97. package/src/runtime-queue.ts +31 -10
  98. package/src/runtime-realtime.ts +9 -45
  99. package/src/runtime-render.ts +62 -35
  100. package/src/runtime-services.ts +20 -17
  101. package/src/runtime-storage.ts +10 -5
  102. package/src/schema-diff.ts +16 -0
  103. package/src/schema-drift.ts +45 -15
  104. package/src/seo-routes.ts +80 -9
  105. package/src/serve-boot.ts +34 -6
  106. package/src/serve-drain.ts +9 -33
  107. package/src/serve-env.ts +9 -6
  108. package/src/serve-web.ts +12 -7
  109. package/src/shot-locale.ts +5 -23
  110. package/src/site-assets.ts +3 -5
  111. package/src/site-config.ts +9 -48
  112. package/src/site-seo.ts +1 -1
  113. package/src/sitemap-lastmod.ts +5 -0
  114. package/src/solid-loader.ts +12 -3
  115. package/src/source-files.ts +6 -0
  116. package/src/stored-object-headers.ts +43 -0
  117. package/src/sync-url.ts +2 -2
  118. package/src/tasks-facts.ts +3 -29
  119. package/src/templates/action.ts +3 -0
  120. package/src/templates/naming.ts +28 -0
  121. package/src/templates/route.ts +3 -6
  122. package/src/templates/scaffold-app.ts +6 -2
  123. package/src/templates/scaffold-container-compose.ts +10 -1
  124. package/src/templates/scaffold-container.ts +4 -2
  125. package/src/templates/scaffold-db-client.ts +4 -2
  126. package/src/templates/scaffold-helm-fences.ts +76 -0
  127. package/src/templates/scaffold-helm-templates.ts +32 -7
  128. package/src/templates/scaffold-helm.ts +31 -1
  129. package/src/templates/scaffold-repo.ts +9 -1
  130. package/src/templates/scaffold-shell.ts +39 -36
  131. package/src/test-counts.ts +2 -6
  132. package/src/test-ignores.ts +25 -0
  133. package/src/test-passes.ts +0 -15
  134. package/src/test-select.ts +5 -14
  135. package/src/test-shards.ts +41 -93
  136. package/src/test-workers.ts +26 -4
  137. package/src/theme-boot.ts +6 -31
  138. package/src/trusted-hops-error.ts +17 -0
  139. package/src/unthrown-codes.ts +26 -0
  140. package/src/verify-checks.ts +17 -46
  141. package/src/verify-deadline.ts +130 -33
  142. package/src/verify-merge.ts +60 -27
  143. package/src/verify-part-step.ts +90 -0
  144. package/src/verify-run.ts +29 -8
  145. package/src/verify-shard.ts +13 -2
  146. package/src/verify-stalled.ts +152 -0
  147. package/src/verify-tests.ts +5 -2
  148. package/src/build-errors.ts +0 -54
  149. package/src/verify-errors.ts +0 -58
package/src/cmd-deploy.ts CHANGED
@@ -176,10 +176,10 @@ export function planDeploy(
176
176
  command: [
177
177
  'docker',
178
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.
179
+ // Compose interpolates `${SYNC_URL:?…}`, `${APP_URL:?…}` and `${POSTGRES_PASSWORD:?…}` from
180
+ // the shell and `--env-file` only — never from a service's `env_file:`. Without this an
181
+ // operator who put them in `.env.production`, the one file the compose file tells them to
182
+ // fill, had every step die on a parse error. Global flag, so it precedes `-f`; the shell still wins over it.
183
183
  '--env-file',
184
184
  join(root, PROD_ENV_FILE),
185
185
  '-f',
package/src/cmd-dev.ts CHANGED
@@ -13,7 +13,6 @@ 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 { createIsrController } from '@ultimat3/render/server';
17
16
  import { loadSignInPath } from './app-auth';
18
17
  import { appManifest } from './app-manifest';
19
18
  import type { StalePin } from './app-reload-graph';
@@ -50,6 +49,7 @@ import {
50
49
  resolveServices,
51
50
  withRealtimeEvents,
52
51
  } from './runtime-bindings';
52
+ import { attachedIsr } from './runtime-isr';
53
53
  import { liveFeedLabel } from './runtime-live-feed';
54
54
  import type { RuntimeOverrides } from './runtime-overrides';
55
55
  import { replicaOverrides } from './runtime-replica';
@@ -239,9 +239,10 @@ async function bootDev(
239
239
  };
240
240
  const panels = devPanels(dashboard).map((panel) => panel.key);
241
241
 
242
- // `x dev`'s own, so a reload can empty it: an `isr` page's first render was otherwise served for
243
- // its whole ttl after every save, whatever the modules behind it now said.
244
- const isr = createIsrController({ buildId });
242
+ // `x dev`'s own, so a reload can empty it (a save otherwise kept a page's first render for its
243
+ // ttl); attached, so a tag bust reaches it too. Released with the roles.
244
+ const { isr, release: releaseIsr } = attachedIsr({ buildId });
245
+ acquired.push(releaseIsr);
245
246
  const { routes, theme, speculation, errorStyles, mcpPath, mcpPaths } = await devRouteTable({
246
247
  isr,
247
248
  root: options.root,
@@ -350,16 +351,16 @@ async function bootDev(
350
351
  async stop() {
351
352
  watcher.close();
352
353
  await running.stop();
354
+ releaseIsr();
353
355
  await runtime.stop();
354
356
  // Released after the roles: a span opened by an in-flight request still has an exporter to
355
357
  // end into. `configureTelemetry` merges, so handing back the noop is how it is uninstalled —
356
358
  // leaving it in place would keep every span of the next `startDev` in this process's buffer.
357
359
  configureTelemetry({ exporter: noopExporter });
358
360
  traces.reset();
359
- // Released with the exporter, after the roles, for the same reason: a statement still in
360
- // flight is observed by the ledger that counted the rest of its request. Leaving it
361
- // installed would keep every statement of the next `startDev` in this process's counts —
362
- // and, worse, keep instrumentation on in a process that is no longer a dev server.
361
+ // With the exporter, for the same reason: an in-flight statement is observed by the ledger
362
+ // that counted its request. Left installed, the next `startDev`'s statements land in these
363
+ // counts — and instrumentation stays on in a process that is no longer a dev server.
363
364
  setStatementObserver(undefined);
364
365
  statements.reset();
365
366
  },
@@ -3,19 +3,12 @@
3
3
 
4
4
  import type { CommandSpec } from './parse';
5
5
 
6
- /** The port `x dev` binds by default, so the probe answers about the port the developer will use. */
7
- export const DEFAULT_DOCTOR_PORT = 3000;
8
-
9
6
  export const doctorSpec: CommandSpec = {
10
7
  name: 'doctor',
11
8
  summary: 'environment, versions, drift, ports, PWA prerequisites — each with a fix command',
12
9
  usage: 'x doctor [--port 3000] [--json]',
13
10
  flags: [
14
- {
15
- name: 'port',
16
- type: 'string',
17
- summary: 'port to test',
18
- default: String(DEFAULT_DOCTOR_PORT),
19
- },
11
+ // No `default`: it would be indistinguishable from a typed value, and `PORT` could never win.
12
+ { name: 'port', type: 'string', summary: 'port to test (default: PORT, then 3000)' },
20
13
  ],
21
14
  };
package/src/cmd-doctor.ts CHANGED
@@ -19,16 +19,18 @@ import {
19
19
  } from '@ultimat3/db';
20
20
  import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
21
21
  import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
22
- import { DEFAULT_DOCTOR_PORT, doctorSpec } from './cmd-doctor-spec';
22
+ import { doctorSpec } from './cmd-doctor-spec';
23
23
  import type { CliCommand, CommandContext } from './command';
24
24
  import { checkMigrationSnapshots } from './db-snapshot';
25
+ import { appUrlFindings } from './doctor-app-url';
26
+ import { type AuthStorageFact, authStorageFindings, authStorageProbe } from './doctor-auth';
25
27
  import { coverageExcludesProbe, withCoverageExcludes } from './doctor-coverage';
26
28
  import { shippedGuardsProbe, withGuardListing } from './doctor-guards';
27
29
  import type { OfflineFallbackFact } from './doctor-offline';
28
30
  import { offlineFallbackFinding, offlineFallbackProbe } from './doctor-offline';
29
31
  import type { SealedKeysFact } from './doctor-sealed';
30
32
  import { sealedKeyFindings, sealedKeysProbe } from './doctor-sealed';
31
- import { intFlagOr, PORT_RANGE, portPairAfter } from './flag-number';
33
+ import { PORT_RANGE, portPairAfter, readIntFlag } from './flag-number';
32
34
  import { ICON_SOURCE } from './icon-assets';
33
35
  import { msg } from './messages';
34
36
  import type { CommandResult, Finding } from './output';
@@ -37,6 +39,7 @@ import type { ParsedArgs } from './parse';
37
39
  import { portFree } from './port-probe';
38
40
  import { syncPortFor } from './role-sync';
39
41
  import { checkMigrationDrift } from './schema-drift';
42
+ import { portFromEnv } from './serve-env';
40
43
 
41
44
  /**
42
45
  * The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
@@ -49,6 +52,8 @@ export interface DoctorProbe {
49
52
  /** App root, or undefined when the command runs outside an app. */
50
53
  readonly root: string | undefined;
51
54
  readonly port: number;
55
+ /** `APP_URL` as this environment declares it; `undefined` when unset. */
56
+ readonly appUrl: string | undefined;
52
57
  /** True while cursors are signed with the key shipped in the published package. */
53
58
  readonly devCursorSecret: boolean;
54
59
  /**
@@ -101,6 +106,11 @@ export interface DoctorProbe {
101
106
  * a retired key beside a sealed column means is `sealedKeyFindings`' pure rule.
102
107
  */
103
108
  sealedKeys(): Promise<SealedKeysFact>;
109
+ /**
110
+ * What an upgrade left in the auth tables for an operator to finish: unsealed second-factor
111
+ * secrets, and tables no release reads. Asked of an external database only.
112
+ */
113
+ authStorage(): Promise<AuthStorageFact>;
104
114
  }
105
115
 
106
116
  /** What `x doctor` reads about the embedded database, without opening it. */
@@ -257,6 +267,7 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
257
267
  );
258
268
  }
259
269
  findings.push(...(await portFindings(probe)));
270
+ findings.push(...appUrlFindings(probe.appUrl, probe.port));
260
271
  // `@ultimat3/pwa`'s own codes, not CLI twins of them. `X_PWA_NO_ICON_SOURCE` and
261
272
  // `X_PWA_NO_FALLBACK` used to be declared here for the same two conditions the package already
262
273
  // names — two codes for one condition, one of them registered by nobody, so `x errors explain`
@@ -293,20 +304,28 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
293
304
  findings.push(...(await probe.snapshots()));
294
305
  // Sealed columns against the key ring: no key at all, or a rotation whose re-seal is pending.
295
306
  findings.push(...sealedKeyFindings(await probe.sealedKeys()));
307
+ findings.push(...authStorageFindings(await probe.authStorage()));
296
308
  return findings;
297
309
  }
298
310
 
299
311
  /**
300
- * The port to TEST, and the one place `x doctor` reads it. `PORT_RANGE.min` is 0 because `x dev
301
- * --port 0` means "let the kernel pick"; here 0 means nothing, and `Bun.serve({ port: 0 })` always
302
- * succeeds — so the port check could not fail, which is worse than not running it.
312
+ * The port to TEST, and the one place `x doctor` reads it: `devPortFor`'s order — `--port`, then
313
+ * `PORT` (`portFromEnv`, the one reader of it), then 3000 — so the probe answers about the port
314
+ * `x dev` will bind. It probed 3000 under `PORT=4000` while dev bound 4000 and 4001. `PORT_RANGE.min`
315
+ * is 0 because `x dev --port 0` means "let the kernel pick"; here 0 means nothing, and
316
+ * `Bun.serve({ port: 0 })` always succeeds — so the port check could not fail.
303
317
  */
304
- export const doctorPort = (args: ParsedArgs): number =>
305
- intFlagOr(
306
- args,
307
- { name: 'port', command: 'doctor', ...PORT_RANGE, min: 1, example: 'x doctor --port 3000' },
308
- DEFAULT_DOCTOR_PORT,
309
- );
318
+ export const doctorPort = (
319
+ args: ParsedArgs,
320
+ env: Readonly<Record<string, string | undefined>>,
321
+ ): number =>
322
+ readIntFlag(args, {
323
+ name: 'port',
324
+ command: 'doctor',
325
+ ...PORT_RANGE,
326
+ min: 1,
327
+ example: 'x doctor --port 3000',
328
+ }) ?? portFromEnv(env);
310
329
 
311
330
  /**
312
331
  * `DATABASE_URL` as BOTH halves of the database question read it: unset and blank are one case.
@@ -375,12 +394,19 @@ async function probeDatabase(url: string | undefined): Promise<Finding | null> {
375
394
  }
376
395
  }
377
396
 
378
- export function probeFor(cwd: string, bunVersion: string, port: number): DoctorProbe {
397
+ export function probeFor(
398
+ cwd: string,
399
+ bunVersion: string,
400
+ port: number,
401
+ // The environment `doctorPort` read `PORT` from, so `APP_URL` is judged against the same one.
402
+ env: Readonly<Record<string, string | undefined>> = process.env,
403
+ ): DoctorProbe {
379
404
  const root = findAppRoot(cwd)?.dir;
380
405
  return {
381
406
  bunVersion,
382
407
  root,
383
408
  port,
409
+ appUrl: env['APP_URL'],
384
410
  devCursorSecret: usesDevCursorSecret(),
385
411
  devStorageSecret: usesDevStorageSecret(),
386
412
  // `ULTIMATE_ENV`, through core — the one key that says which deploy this is, with `NODE_ENV`
@@ -408,14 +434,15 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
408
434
  root === undefined ? { fallback: null, routes: undefined } : offlineFallbackProbe(root),
409
435
  sealedKeys: async () =>
410
436
  root === undefined ? { columns: undefined, keys: undefined } : sealedKeysProbe(root),
437
+ authStorage: () => authStorageProbe(process.env['DATABASE_URL']),
411
438
  };
412
439
  }
413
440
 
414
441
  export const doctorCommand: CliCommand = {
415
442
  spec: doctorSpec,
416
443
  async run(ctx: CommandContext): Promise<CommandResult> {
417
- const port = doctorPort(ctx.args);
418
- const probe = probeFor(ctx.cwd, ctx.bunVersion, port);
444
+ const port = doctorPort(ctx.args, ctx.env);
445
+ const probe = probeFor(ctx.cwd, ctx.bunVersion, port, ctx.env);
419
446
  const findings = await runDoctor(probe);
420
447
  const listed = withGuardListing(
421
448
  {
package/src/cmd-env.ts CHANGED
@@ -4,15 +4,39 @@
4
4
 
5
5
  // Bun ships no path-join primitive, and `.env.example` is written app-root-relative.
6
6
  import { join } from 'node:path';
7
- import { checkEnv, ENV_EXAMPLE_PATH, ERROR_DOCS_URL, maskedEnvValues } from '@ultimat3/core';
7
+ import {
8
+ checkEnv,
9
+ ENV_EXAMPLE_PATH,
10
+ ERROR_DOCS_URL,
11
+ maskedEnvValues,
12
+ UltimateError,
13
+ } from '@ultimat3/core';
8
14
  import { envExampleFor, loadEnvSchema } from './app-env';
9
15
  import { requireAppRoot } from './app-root';
10
16
  import { envSpec } from './cmd-env-spec';
11
17
  import type { CliCommand, CommandContext } from './command';
12
- import { EnvSchemaMissingError } from './errors';
13
18
  import { msg } from './messages';
14
19
  import type { CommandResult, Finding, JsonValue } from './output';
15
20
 
21
+ /**
22
+ * `x env` was run in an app whose `app.config.ts` exports no `envSchema`. Not a silent success:
23
+ * writing a `.env.example` with no variables in it, or reporting "0 declared variables, all
24
+ * present", both read as a working environment declaration to whoever runs the command next.
25
+ *
26
+ * `X_CONFIG_INVALID` is core's code for "a configuration this process cannot boot on — env or
27
+ * `app.config.ts`", which is exactly this; the CLI names it in `CLI_BORROWED_ERROR_CODES` rather
28
+ * than minting a synonym.
29
+ */
30
+ export class EnvSchemaMissingError extends UltimateError {
31
+ constructor(input: { subcommand: string }) {
32
+ super({
33
+ code: 'X_CONFIG_INVALID',
34
+ cause: `x env ${input.subcommand} needs the env declaration, and app.config.ts exports no "envSchema"`,
35
+ fix: "add to app.config.ts: export const envSchema = { DATABASE_URL: { type: 'url', description: 'Postgres connection URL' } } satisfies EnvSchema; export const env = defineEnv(envSchema);",
36
+ });
37
+ }
38
+ }
39
+
16
40
  /**
17
41
  * Every subcommand needs the declaration, and an app without one is a usage error rather than an
18
42
  * empty success: `x env example` writing a two-comment file would look like it worked.
@@ -5,7 +5,7 @@
5
5
  import { existsSync } from 'node:fs';
6
6
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
7
7
  import { registerAdminResources } from './admin-registration';
8
- import { registerGeneratedPrimitives } from './api-registration';
8
+ import { indexBindingFindings, registerGeneratedPrimitives } from './api-registration';
9
9
  import { writeAppArtifacts } from './app-artifacts';
10
10
  import { appManifest } from './app-manifest';
11
11
  import { requireAppRoot } from './app-root';
@@ -18,7 +18,8 @@ import { ungrantedByGenerator } from './generate-grant-findings';
18
18
  import { grantGeneratedPermissions } from './generate-grants';
19
19
  import type { Generator } from './generate-kinds';
20
20
  import { readFeature, readKind, readName, readPermission, readSurface } from './generate-kinds';
21
- import { containedPath, writeFiles } from './generate-write';
21
+ import { refuseShadowedTypes } from './generate-shadow';
22
+ import { containedPath, planWrites, writeFiles } from './generate-write';
22
23
  import { declareGeneratedImports } from './generated-imports';
23
24
  import { registerGeneratedEntities, resolveDbModule } from './handle-registration';
24
25
  import { resolveCatalogModule } from './i18n-audit';
@@ -28,6 +29,7 @@ import { msg } from './messages';
28
29
  import type { CommandResult, Finding } from './output';
29
30
  import { flagBool, flagList, flagString } from './parse';
30
31
  import { quoteArg } from './shell-quote';
32
+ import type { GeneratedFile } from './templates';
31
33
  import { kebab, names, resolveLocales } from './templates';
32
34
 
33
35
  // One import path for the generator, unchanged by the split: `index.ts`, `x new` and the scaffold
@@ -40,6 +42,24 @@ export { GENERATORS } from './generate-kinds';
40
42
  export type { WriteReport } from './generate-write';
41
43
  export { dedupe, writeFiles } from './generate-write';
42
44
 
45
+ /**
46
+ * Names to suggest when the API index already holds the requested one, in order: the feature's
47
+ * prefix, the kind's suffix, then that suffix numbered — kebab output, so each pastes into a shell
48
+ * as one argument. The caller re-plans each and offers the first the index does not hold either.
49
+ */
50
+ const freeNames = (
51
+ kind: Generator,
52
+ name: string,
53
+ feature: string | undefined,
54
+ ): readonly string[] => {
55
+ const suffixed = `${kebab(name)}-${kind.split(':').at(-1) ?? kind}`;
56
+ return [
57
+ ...(feature === undefined ? [] : [`${kebab(feature)}-${kebab(name)}`]),
58
+ suffixed,
59
+ ...Array.from({ length: 8 }, (_unused, index) => `${suffixed}-${index + 2}`),
60
+ ];
61
+ };
62
+
43
63
  export const generateCommand: CliCommand = {
44
64
  spec: generateSpec,
45
65
  async run(ctx: CommandContext): Promise<CommandResult> {
@@ -72,41 +92,68 @@ export const generateCommand: CliCommand = {
72
92
  // THIS feature's own `entity.ts`/`repo.ts`, not a default the template gets to assume.
73
93
  const sliceEntity = await readSliceFile(root, kind, slice, 'entity.ts');
74
94
  const sliceRepo = await readSliceFile(root, kind, slice, 'repo.ts');
75
- const files = generate({
76
- kind,
77
- name,
78
- ...(featureFlag === undefined ? {} : { feature: featureFlag }),
79
- ...(sliceErrors === undefined ? {} : { sliceErrors }),
80
- ...(sliceEntity === undefined ? {} : { sliceEntity }),
81
- ...(sliceRepo === undefined ? {} : { sliceRepo }),
82
- ...(at === undefined ? {} : { at }),
83
- ...(permission === undefined ? {} : { permission }),
84
- surface,
85
- live: flagBool(ctx.args, 'live'),
86
- admin: flagBool(ctx.args, 'admin'),
87
- locales,
88
- ...(catalogModule === undefined ? {} : { catalogModule }),
89
- ...(dbModule === undefined ? {} : { dbModule }),
90
- shell,
91
- });
95
+ const planFor = (planned: string) =>
96
+ generate({
97
+ kind,
98
+ name: planned,
99
+ ...(featureFlag === undefined ? {} : { feature: featureFlag }),
100
+ ...(sliceErrors === undefined ? {} : { sliceErrors }),
101
+ ...(sliceEntity === undefined ? {} : { sliceEntity }),
102
+ ...(sliceRepo === undefined ? {} : { sliceRepo }),
103
+ ...(at === undefined ? {} : { at }),
104
+ ...(permission === undefined ? {} : { permission }),
105
+ surface,
106
+ live: flagBool(ctx.args, 'live'),
107
+ admin: flagBool(ctx.args, 'admin'),
108
+ locales,
109
+ ...(catalogModule === undefined ? {} : { catalogModule }),
110
+ ...(dbModule === undefined ? {} : { dbModule }),
111
+ shell,
112
+ });
113
+ const files = planFor(name);
114
+ // On the planned files, before a dry run answers or anything lands: a name whose type spelling
115
+ // the emitted code also uses as a global is a slice that does not compile.
116
+ refuseShadowedTypes(files, kind, name);
117
+ // The caller's own invocation, EVERY flag it set included: without `--feature` the fix wrote
118
+ // a second slice beside the one that conflicted.
119
+ const flags = reproducedFlags(generateCommand.spec, ctx.args);
120
+ const invocation = [invocationOf(ctx, 'g'), kind, quoteArg(name), ...flags].join(' ');
121
+ // A module the API index would list under a name it already holds: refused before anything is
122
+ // written, with the same run under a name that is free — re-planned and re-checked, so the
123
+ // suggestion is never itself a conflict.
124
+ const pathsOf = (planned: readonly GeneratedFile[]) => planned.map((file) => file.path);
125
+ const held = await indexBindingFindings(root, pathsOf(files), '');
126
+ let bindings: readonly Finding[] = [];
127
+ if (held.length > 0) {
128
+ let free = freeNames(kind, name, featureFlag).at(-1) ?? name;
129
+ for (const candidate of freeNames(kind, name, featureFlag)) {
130
+ if ((await indexBindingFindings(root, pathsOf(planFor(candidate)), '')).length > 0)
131
+ continue;
132
+ free = candidate;
133
+ break;
134
+ }
135
+ const renamed = [invocationOf(ctx, 'g'), kind, quoteArg(free), ...flags].join(' ');
136
+ bindings = held.map((finding) => ({ ...finding, fix: renamed }));
137
+ }
138
+ const force = flagBool(ctx.args, 'force');
92
139
  if (flagBool(ctx.args, 'dry-run')) {
140
+ // The write plan, never the bare file list: what the real run would write, skip and refuse.
141
+ const plan = await planWrites(root, files, force, invocation);
142
+ const findings = [...plan.conflicts, ...bindings];
143
+ const planned = findings.length === 0 ? plan.written : [];
93
144
  return {
94
- ok: true,
145
+ ok: findings.length === 0,
95
146
  command: 'g',
96
- summary: msg('cli.generate.planned', { count: files.length, kind, name }),
97
- data: { files: files.map((file) => file.path), dryRun: true },
98
- lines: files.map((file) => msg('cli.file.added', { path: file.path })),
147
+ summary: msg('cli.generate.planned', { count: planned.length, kind, name }),
148
+ data: { files: planned, dryRun: true },
149
+ lines: planned.map((file) => msg('cli.file.added', { path: file })),
150
+ findings,
99
151
  };
100
152
  }
101
- // The caller's own invocation, EVERY flag it set included: without `--feature` the fix wrote
102
- // a second slice beside the one that conflicted.
103
- const invocation = [
104
- invocationOf(ctx, 'g'),
105
- kind,
106
- quoteArg(name),
107
- ...reproducedFlags(generateCommand.spec, ctx.args),
108
- ].join(' ');
109
- const report = await writeFiles(root, files, flagBool(ctx.args, 'force'), invocation);
153
+ const report =
154
+ bindings.length > 0
155
+ ? { written: [], conflicts: bindings }
156
+ : await writeFiles(root, files, force, invocation);
110
157
  // The three edits a generated primitive needs outside its own slice, performed rather than
111
158
  // left as findings: a declared permission granted to a role, a job listed in `defineApi`, and
112
159
  // an entity added to the typed handle its `repo.ts` reads through. Before the manifest load
package/src/cmd-help.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  // never describe a flag that does not exist. Built as a factory over the spec list to keep the
3
3
  // registry acyclic.
4
4
 
5
+ import { nearestName } from '@ultimat3/core';
5
6
  import type { CliCommand, CommandContext } from './command';
7
+ import { UnknownCommandError } from './errors';
6
8
  import { msg } from './messages';
7
9
  import type { CommandResult, JsonValue } from './output';
8
10
  import type { CommandSpec, FlagSpec } from './parse';
@@ -18,17 +20,28 @@ const flagLine = (flag: FlagSpec): string => {
18
20
  * The one resolution of a topic, read by both renderers. `--json` filtered on `spec.name === topic`
19
21
  * of its own, so the two disagreed about exactly the inputs a caller is least sure of: `x help
20
22
  * generate --json` answered `[]` — "that command does not exist" — while the page beside it printed
21
- * `g`, and `x help nosuch --json` answered `[]` while the page printed the whole catalogue.
23
+ * `g`. A topic naming no command is refused, as `x <topic>` itself is: answering the catalogue
24
+ * with exit 0 read as that command's usage to a caller that never compared the names.
22
25
  */
23
26
  const specFor = (
24
27
  specs: readonly CommandSpec[],
25
28
  topic: string | undefined,
26
- ): CommandSpec | undefined =>
27
- topic === undefined
28
- ? undefined
29
- : specs.find((entry) => entry.name === topic || (entry.aliases ?? []).includes(topic));
29
+ ): CommandSpec | undefined => {
30
+ if (topic === undefined) return undefined;
31
+ const found = specs.find(
32
+ (entry) => entry.name === topic || (entry.aliases ?? []).includes(topic),
33
+ );
34
+ if (found !== undefined) return found;
35
+ const names = specs.map((entry) => entry.name);
36
+ const nearest = nearestName(topic, [...names, ...specs.flatMap((entry) => entry.aliases ?? [])]);
37
+ throw new UnknownCommandError({
38
+ path: `help ${topic}`,
39
+ known: names,
40
+ suggestion: nearest === undefined ? 'help' : `help ${nearest}`,
41
+ });
42
+ };
30
43
 
31
- /** What `--json` reports: the one resolved command, or — as the human render does — all of them. */
44
+ /** What `--json` reports: the one resolved command, or — with no topic — all of them. */
32
45
  export function helpTopic(
33
46
  specs: readonly CommandSpec[],
34
47
  topic: string | undefined,
package/src/cmd-i18n.ts CHANGED
@@ -196,14 +196,15 @@ async function runAdd(root: string, ctx: CommandContext): Promise<CommandResult>
196
196
  const { registered, findings: indexFindings } = await syncI18nIndex(root);
197
197
 
198
198
  const keys = catalogKeys(seeded).length;
199
+ // The catalog is on disk either way, but a finding fails the run: a `packages/i18n/src/index.ts`
200
+ // that would not import is why `from` may be the framework's `en` instead of the app's own
201
+ // default, and an index the locale could not be registered in is a red `i18n` gate step.
202
+ const findings = [...indexFindings, ...app.findings];
199
203
  return {
200
- // The catalog is on disk, so the command did what it was asked. `loadApp`'s findings ride along
201
- // rather than flip that: a `packages/i18n/src/index.ts` that would not import is why `from` may
202
- // be the framework's `en` instead of the app's own default, and silence there is the bug.
203
- ok: true,
204
+ ok: findings.length === 0,
204
205
  command: 'i18n',
205
206
  summary: msg('cli.i18n.added', { locale, keys, from: from ?? locale }),
206
- findings: [...indexFindings, ...app.findings],
207
+ findings,
207
208
  // `registered` is false only for an app with no `packages/i18n` at all — a fact a caller has
208
209
  // to be able to read, because it is the one case where the locale is on disk and unselectable.
209
210
  data: { locale, from: from ?? locale, keys, path, registered },
@@ -274,16 +275,16 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
274
275
  const { registered, findings: indexFindings } = await syncI18nIndex(root);
275
276
 
276
277
  const total = catalogKeys(merged).length;
278
+ // Same as `runAdd`: the merge landed, and a finding still fails the run.
279
+ const findings = [...indexFindings, ...app.findings];
277
280
  return {
278
- // Same as `runAdd`: the merge landed, and `loadApp`'s findings say whether `from` is the app's
279
- // own default or the framework's fallback standing in for an i18n module that would not import.
280
- ok: true,
281
+ ok: findings.length === 0,
281
282
  command: 'i18n',
282
283
  summary: msg('cli.i18n.synced', { locale, from: from ?? locale, added: added.length, total }),
283
284
  // The keys themselves, raw — `runCheck` lists gaps the same way, because a key is a value an
284
285
  // author copies and never prose the catalog owns.
285
286
  lines: seeded ? added.map((key) => ` ${key}`) : [],
286
- findings: [...indexFindings, ...app.findings],
287
+ findings,
287
288
  data: {
288
289
  locale,
289
290
  from: from ?? locale,
package/src/cmd-jobs.ts CHANGED
@@ -66,7 +66,18 @@ function refuseMemoryTarget(): never {
66
66
  });
67
67
  }
68
68
 
69
- function requireIdPositional(ctx: CommandContext, sub: string): string {
69
+ /**
70
+ * A uuid in any spelling Postgres' `uuid` input accepts: canonical, upper-case, unhyphenated,
71
+ * braced. Every driver mints its ids with core's `uuid()`, and `x_jobs.id` is a `uuid` column.
72
+ */
73
+ const JOB_ID = /^\{?[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}\}?$/i;
74
+
75
+ /**
76
+ * The id, parsed at the door — admin's `readableId` rule: an id no driver could hold is a job that
77
+ * does not exist, answered before a statement is sent. `x jobs show nosuch` reached Postgres and
78
+ * came back a raw `X_DB_STATEMENT_FAILED [22P02]` whose fix was a psql session.
79
+ */
80
+ function requireIdPositional(ctx: CommandContext, sub: string, driver: JobDriver): string {
70
81
  const id = ctx.args.positionals[0];
71
82
  if (id === undefined) {
72
83
  // `--id on "x jobs"` is a flag `x jobs` does not declare; the id is a positional and says so.
@@ -76,6 +87,7 @@ function requireIdPositional(ctx: CommandContext, sub: string): string {
76
87
  example: 'x jobs ls --json',
77
88
  });
78
89
  }
90
+ if (!JOB_ID.test(id)) throw new JobUnknownError({ id, driver: driver.name });
79
91
  return id;
80
92
  }
81
93
 
@@ -211,7 +223,7 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
211
223
  }
212
224
 
213
225
  async function runShow(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
214
- const trace = await showJob(driver, requireIdPositional(ctx, 'show'));
226
+ const trace = await showJob(driver, requireIdPositional(ctx, 'show', driver));
215
227
  return {
216
228
  ok: true,
217
229
  command: 'jobs',
@@ -226,7 +238,7 @@ async function runShow(driver: JobDriver, ctx: CommandContext): Promise<CommandR
226
238
  }
227
239
 
228
240
  async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
229
- const id = requireIdPositional(ctx, 'retry');
241
+ const id = requireIdPositional(ctx, 'retry', driver);
230
242
  const trace = await retryJob(driver, id, flagString(ctx.args, 'from-step'));
231
243
  return {
232
244
  ok: true,
@@ -243,7 +255,7 @@ async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<Command
243
255
  * The trace is rendered by the same projection `show` and `retry` use — one shape for one job.
244
256
  */
245
257
  async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
246
- const id = requireIdPositional(ctx, 'cancel');
258
+ const id = requireIdPositional(ctx, 'cancel', driver);
247
259
  const trace = await cancelJob(driver, id, flagString(ctx.args, 'reason'));
248
260
  // `cancelJob` re-reads the job after cancelling, so `undefined` would mean the row vanished
249
261
  // between the two — reported as the same refusal rather than rendered as a success with no job.
@@ -258,7 +270,7 @@ async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<Comman
258
270
 
259
271
  /** Exit 0 means the row is gone. An id nobody queued is `X_JOB_UNKNOWN`; a running one refuses. */
260
272
  async function runRm(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
261
- const id = requireIdPositional(ctx, 'rm');
273
+ const id = requireIdPositional(ctx, 'rm', driver);
262
274
  const removed = await removeJob(driver, id);
263
275
  if (removed === undefined) throw new JobUnknownError({ id, driver: driver.name });
264
276
  return {
@@ -270,7 +282,7 @@ async function runRm(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
270
282
  }
271
283
 
272
284
  async function runPromote(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
273
- const promoted = await promoteJob(driver, requireIdPositional(ctx, 'promote'));
285
+ const promoted = await promoteJob(driver, requireIdPositional(ctx, 'promote', driver));
274
286
  return {
275
287
  ok: true,
276
288
  command: 'jobs',
@@ -6,7 +6,7 @@ import type { CommandSpec } from './parse';
6
6
  export const manifestSpec: CommandSpec = {
7
7
  name: 'manifest',
8
8
  summary: 'regenerate x.manifest.json and openapi.json from the code',
9
- usage: 'x manifest [--check] [--json]',
9
+ usage: 'x manifest [--check] [--no-openapi] [--json]',
10
10
  requiresApp: true,
11
11
  flags: [
12
12
  { name: 'check', type: 'boolean', summary: 'fail if the committed files are stale' },
@@ -4,16 +4,15 @@
4
4
 
5
5
  import { join } from 'node:path';
6
6
  import type { Manifest } from '@ultimat3/manifest';
7
- import { assertNoDrift, MANIFEST_FILENAME } from '@ultimat3/manifest';
7
+ import { MANIFEST_FILENAME } from '@ultimat3/manifest';
8
8
  import { writeAppArtifacts } from './app-artifacts';
9
9
  import { appManifest } from './app-manifest';
10
- import { openApiStaleness } from './app-openapi';
11
10
  import { requireAppRoot } from './app-root';
12
11
  import { manifestSpec } from './cmd-manifest-spec';
13
12
  import type { CliCommand, CommandContext } from './command';
13
+ import { manifestStaleness } from './manifest-staleness';
14
14
  import { msg } from './messages';
15
- import type { CommandResult, Finding, JsonValue } from './output';
16
- import { findingFrom } from './output';
15
+ import type { CommandResult, JsonValue } from './output';
17
16
  import { flagBool } from './parse';
18
17
 
19
18
  const countsOf = (manifest: Manifest): JsonValue => ({
@@ -27,21 +26,6 @@ const countsOf = (manifest: Manifest): JsonValue => ({
27
26
  policies: manifest.policies.length,
28
27
  });
29
28
 
30
- /**
31
- * The identical comparison `x verify`'s `manifest` step makes, through the identical function.
32
- * A buildId equality test here would answer "fresh" for a hand-edited file whose body no longer
33
- * hashes to the id it carries — and two commands giving two answers about one file is itself the
34
- * drift the manifest exists to prevent.
35
- */
36
- async function staleness(root: string, manifest: Manifest): Promise<Finding | undefined> {
37
- try {
38
- await assertNoDrift({ manifest, path: join(root, MANIFEST_FILENAME) });
39
- return undefined;
40
- } catch (error) {
41
- return { ...findingFrom(error), at: MANIFEST_FILENAME };
42
- }
43
- }
44
-
45
29
  export const manifestCommand: CliCommand = {
46
30
  spec: manifestSpec,
47
31
  async run(ctx: CommandContext): Promise<CommandResult> {
@@ -50,12 +34,9 @@ export const manifestCommand: CliCommand = {
50
34
  const counts = countsOf(manifest);
51
35
 
52
36
  if (flagBool(ctx.args, 'check')) {
53
- // BOTH files the command writes: `--check` compared only `x.manifest.json`, so a stale
54
- // `openapi.json` — the one the typed client is generated from — read as fresh.
55
- const stale = [
56
- ...[await staleness(root, manifest)].filter((one) => one !== undefined),
57
- ...(await openApiStaleness(root, manifest)),
58
- ];
37
+ // The gate's own check, through its function (`manifest-staleness.ts`): both files this
38
+ // command writes, and the same code for each condition `x verify`'s `manifest` step gives.
39
+ const stale = await manifestStaleness(root, manifest);
59
40
  return {
60
41
  ok: stale.length === 0 && findings.length === 0,
61
42
  command: 'manifest',