@ultimat3/cli 22.15.0 → 23.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 (169) hide show
  1. package/CLAUDE.md +23 -8
  2. package/README.md +28 -5
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/api-registration.ts +36 -11
  7. package/src/app-boundaries.ts +6 -20
  8. package/src/app-load.ts +55 -11
  9. package/src/app-manifest.ts +14 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-reload-graph.ts +7 -30
  12. package/src/app-transport.ts +219 -0
  13. package/src/boundary-findings.ts +16 -4
  14. package/src/browser-transport.ts +160 -0
  15. package/src/budgets.ts +25 -1
  16. package/src/cmd-build-spec.ts +10 -3
  17. package/src/cmd-build.ts +56 -9
  18. package/src/cmd-db.ts +60 -11
  19. package/src/cmd-doctor.ts +35 -11
  20. package/src/cmd-generate.ts +118 -17
  21. package/src/cmd-jobs-spec.ts +19 -3
  22. package/src/cmd-jobs.ts +120 -3
  23. package/src/cmd-new.ts +15 -1
  24. package/src/cmd-routes.ts +15 -1
  25. package/src/cmd-secrets-ring.ts +76 -0
  26. package/src/cmd-secrets-spec.ts +10 -2
  27. package/src/cmd-secrets.ts +48 -12
  28. package/src/cmd-tasks.ts +84 -15
  29. package/src/cmd-verify.ts +10 -2
  30. package/src/comment-safe.ts +16 -0
  31. package/src/coverage-floor.ts +334 -0
  32. package/src/coverage-lcov.ts +193 -0
  33. package/src/coverage-source.ts +136 -0
  34. package/src/db-object-drift.ts +30 -0
  35. package/src/db-replay-engine.ts +115 -0
  36. package/src/db-schema-dump.ts +150 -0
  37. package/src/db-schema-refresh.ts +122 -0
  38. package/src/dev-dashboard.ts +2 -2
  39. package/src/dev-route-table.ts +9 -0
  40. package/src/doctor-coverage.ts +39 -0
  41. package/src/doctor-guards.ts +54 -0
  42. package/src/doctor-sealed.ts +132 -0
  43. package/src/document-graph.ts +82 -0
  44. package/src/error-codes.ts +32 -0
  45. package/src/framework-schema.ts +15 -0
  46. package/src/generate-files.ts +76 -10
  47. package/src/generate-grant-findings.ts +76 -0
  48. package/src/generate-grants.ts +77 -3
  49. package/src/generate-kinds.ts +19 -0
  50. package/src/generate-write.ts +15 -27
  51. package/src/generated-imports.ts +59 -0
  52. package/src/guard-sources.ts +104 -0
  53. package/src/guards.ts +23 -7
  54. package/src/handle-registration.ts +217 -0
  55. package/src/i18n-registration.ts +36 -0
  56. package/src/image-prepare.ts +51 -8
  57. package/src/import-closure.ts +165 -0
  58. package/src/index.ts +11 -2
  59. package/src/island-store.ts +24 -16
  60. package/src/jobs-drain.ts +14 -7
  61. package/src/jobs-json.ts +50 -0
  62. package/src/jobs-report.ts +3 -0
  63. package/src/json-merge.ts +46 -0
  64. package/src/mcp-errors.ts +29 -1
  65. package/src/messages.ts +33 -0
  66. package/src/metrics-endpoint.ts +71 -24
  67. package/src/migration-extensions.ts +29 -0
  68. package/src/module-imports.ts +47 -0
  69. package/src/output.ts +18 -9
  70. package/src/permission-grants.ts +41 -9
  71. package/src/publish-closure.ts +101 -0
  72. package/src/role-load.ts +143 -0
  73. package/src/role-start.ts +18 -1
  74. package/src/role-wake.ts +45 -0
  75. package/src/runtime-admin.ts +141 -0
  76. package/src/runtime-hooks.ts +5 -2
  77. package/src/runtime-queue.ts +8 -1
  78. package/src/runtime-render.ts +6 -0
  79. package/src/runtime-services.ts +2 -1
  80. package/src/scaffold-typecheck.ts +1 -0
  81. package/src/schema-dump-drift.ts +64 -0
  82. package/src/serve-boot.ts +85 -157
  83. package/src/serve-prebuilt-paths.ts +19 -0
  84. package/src/serve-prebuilt.ts +84 -0
  85. package/src/serve-web.ts +164 -0
  86. package/src/serve.ts +6 -9
  87. package/src/server-barrels.ts +54 -0
  88. package/src/source-edit.ts +49 -0
  89. package/src/tasks-facts.ts +4 -1
  90. package/src/templates/action.ts +144 -11
  91. package/src/templates/admin-catalog.ts +58 -0
  92. package/src/templates/admin-page.ts +36 -8
  93. package/src/templates/admin.ts +57 -17
  94. package/src/templates/backfill.ts +121 -87
  95. package/src/templates/entity.ts +140 -44
  96. package/src/templates/guard-animated-layout-property.ts +4 -11
  97. package/src/templates/guard-bare-error.ts +9 -13
  98. package/src/templates/guard-focus-visible.ts +4 -11
  99. package/src/templates/guard-image-dimensions.ts +6 -6
  100. package/src/templates/guard-island-without-states.ts +2 -4
  101. package/src/templates/guard-raw-breakpoint.ts +210 -0
  102. package/src/templates/guard-raw-colour.ts +4 -11
  103. package/src/templates/guard-raw-length.ts +375 -0
  104. package/src/templates/guard-raw-motion.ts +213 -0
  105. package/src/templates/guard-raw-shadow.ts +187 -0
  106. package/src/templates/guard-raw-z-index.ts +166 -0
  107. package/src/templates/guard-repo-raw-sql.ts +345 -0
  108. package/src/templates/guard-semantic-interactive.ts +6 -6
  109. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  110. package/src/templates/guard-undefined-style-class.ts +215 -0
  111. package/src/templates/guard-untranslated-string.ts +6 -6
  112. package/src/templates/guard-unzoned-date.ts +9 -13
  113. package/src/templates/index.ts +3 -2
  114. package/src/templates/island.ts +34 -51
  115. package/src/templates/job-body-test.ts +121 -0
  116. package/src/templates/job.ts +23 -8
  117. package/src/templates/policy.ts +16 -9
  118. package/src/templates/query.ts +242 -26
  119. package/src/templates/resource-create.ts +62 -18
  120. package/src/templates/resource-form-island.ts +58 -80
  121. package/src/templates/resource-page.ts +269 -0
  122. package/src/templates/resource-service.ts +96 -0
  123. package/src/templates/resource.ts +86 -61
  124. package/src/templates/route.ts +38 -29
  125. package/src/templates/scaffold-api.ts +55 -1
  126. package/src/templates/scaffold-app.ts +171 -51
  127. package/src/templates/scaffold-auth.ts +83 -19
  128. package/src/templates/scaffold-browser-client.ts +90 -0
  129. package/src/templates/scaffold-container.ts +16 -0
  130. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  131. package/src/templates/scaffold-dashboard-example.ts +124 -31
  132. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  133. package/src/templates/scaffold-dashboard.ts +1 -2
  134. package/src/templates/scaffold-db-client.ts +142 -0
  135. package/src/templates/scaffold-db-package.ts +90 -8
  136. package/src/templates/scaffold-docs.ts +50 -5
  137. package/src/templates/scaffold-guards.ts +54 -11
  138. package/src/templates/scaffold-http.ts +9 -7
  139. package/src/templates/scaffold-repo.ts +52 -2
  140. package/src/templates/scaffold-roles.ts +56 -9
  141. package/src/templates/scaffold-shell.ts +36 -70
  142. package/src/templates/scaffold-site.ts +45 -19
  143. package/src/templates/scaffold-ui-package.ts +18 -0
  144. package/src/templates/slice-foundation.ts +46 -8
  145. package/src/test-counts.ts +18 -2
  146. package/src/transport-calls.ts +105 -0
  147. package/src/unscanned-admin.ts +54 -0
  148. package/src/verify-checks.ts +26 -7
  149. package/src/verify-coverage-run.ts +190 -0
  150. package/src/verify-deadline.ts +256 -0
  151. package/src/verify-errors.ts +15 -6
  152. package/src/verify-floor.ts +36 -9
  153. package/src/verify-merge.ts +117 -24
  154. package/src/verify-progress.ts +25 -0
  155. package/src/verify-role-load-probe.ts +54 -0
  156. package/src/verify-role-load.ts +148 -0
  157. package/src/verify-run.ts +93 -35
  158. package/src/verify-shard.ts +20 -9
  159. package/src/verify-step.ts +42 -1
  160. package/src/verify-tests.ts +78 -6
  161. package/src/workspace-checks.ts +50 -8
  162. package/src/workspace-dep-edit.ts +127 -0
  163. package/src/cmd-dev-fixture.ts +0 -204
  164. package/src/dev-live-fixture.ts +0 -122
  165. package/src/policy-fixture.ts +0 -78
  166. package/src/realtime-browser-probe-fixture.ts +0 -9
  167. package/src/role-start-fixture.ts +0 -78
  168. package/src/scaffold-fixture.ts +0 -160
  169. package/src/templates/island-fixture.ts +0 -76
@@ -3,7 +3,17 @@
3
3
 
4
4
  import type { CommandSpec } from './parse';
5
5
 
6
- export const JOBS_SUBCOMMANDS = ['ls', 'show', 'retry', 'cancel', 'drain'] as const;
6
+ export const JOBS_SUBCOMMANDS = [
7
+ 'ls',
8
+ 'show',
9
+ 'retry',
10
+ 'cancel',
11
+ 'rm',
12
+ 'promote',
13
+ 'pause',
14
+ 'resume',
15
+ 'drain',
16
+ ] as const;
7
17
 
8
18
  /**
9
19
  * The drivers a drain may move work ONTO — every one of them durable, and that is the whole rule.
@@ -13,9 +23,9 @@ export const DRAIN_TARGETS = ['redis', 'nats'] as const;
13
23
 
14
24
  export const jobsSpec: CommandSpec = {
15
25
  name: 'jobs',
16
- summary: 'list, show, retry, cancel and drain the job queue',
26
+ summary: 'list, show, retry, cancel, remove and promote jobs; pause and resume a queue; drain',
17
27
  usage:
18
- 'x jobs [ls|show <id>|retry <id>|cancel <id>|drain --to <driver>] [--queue q] [--state s] [--limit n] [--from-step name] [--reason text] [--to driver] [--dry-run] [--json]',
28
+ 'x jobs [ls|show <id>|retry <id>|cancel <id>|rm <id>|promote <id>|pause <queue>|resume <queue>|drain --to <driver>] [--queue q] [--state s] [--name n] [--limit n] [--after cursor] [--from-step name] [--reason text] [--to driver] [--dry-run] [--json]',
19
29
  requiresApp: true,
20
30
  subcommands: JOBS_SUBCOMMANDS,
21
31
  // The bare `x jobs` lists; it never retries, cancels or drains anything.
@@ -25,6 +35,12 @@ export const jobsSpec: CommandSpec = {
25
35
  { name: 'state', type: 'string', summary: 'filter by job state' },
26
36
  { name: 'limit', type: 'string', summary: 'max rows to return' },
27
37
  { name: 'name', type: 'string', summary: 'filter by job name' },
38
+ {
39
+ name: 'after',
40
+ type: 'string',
41
+ summary: 'ls: the next page — the cursor the previous page printed',
42
+ subcommands: ['ls'],
43
+ },
28
44
  // Each of these is read by ONE subcommand — `retryJob`, `cancelJob`, `runDrain` — and says
29
45
  // so in its own summary. The scope is what makes the parser refuse it anywhere else instead
30
46
  // of accepting it and ignoring it: `x db gen --dry-run` parsed and wrote the migration.
package/src/cmd-jobs.ts CHANGED
@@ -1,11 +1,24 @@
1
- // `x jobs ls|show|retry|cancel|drain` — introspect and recover the job queue, bound to
1
+ // `x jobs ls|show|retry|cancel|rm|promote|pause|resume|drain` — introspect and recover the job queue, bound to
2
2
  // `@ultimat3/jobs`'s
3
3
  // own introspection so the CLI, `/_x` and MCP report identically. This file is CLI wiring only:
4
4
  // the driver-injected logic is `jobs-report.ts`, the `--json` shapes `jobs-json.ts`, the table
5
5
  // `jobs-table.ts`, and getting hold of the queue at all is `jobs-driver.ts` — shared with `x db`.
6
6
 
7
+ import { renderFixShellArg } from '@ultimat3/core';
7
8
  import type { JobDriver } from '@ultimat3/jobs';
8
- import { cancelJob, createNatsDriver, createRedisDriver } from '@ultimat3/jobs';
9
+ import {
10
+ cancelJob,
11
+ createNatsDriver,
12
+ createRedisDriver,
13
+ DEFAULT_JOB_PAGE,
14
+ jobCursor,
15
+ MAX_JOB_PAGE,
16
+ pauseQueue,
17
+ promoteJob,
18
+ removeJob,
19
+ resumeQueue,
20
+ } from '@ultimat3/jobs';
21
+ import { loadApp } from './app-load';
9
22
  import { requireAppRoot } from './app-root';
10
23
  import { DRAIN_TARGETS, jobsSpec } from './cmd-jobs-spec';
11
24
  import type { CliCommand, CommandContext } from './command';
@@ -21,6 +34,8 @@ import {
21
34
  drainSkipToJson,
22
35
  jobRecordToJson,
23
36
  jobTraceToJson,
37
+ pausedToJson,
38
+ workerToJson,
24
39
  } from './jobs-json';
25
40
  import { listJobs, retryJob, showJob } from './jobs-report';
26
41
  import { renderJobTable } from './jobs-table';
@@ -64,6 +79,33 @@ function requireIdPositional(ctx: CommandContext, sub: string): string {
64
79
  return id;
65
80
  }
66
81
 
82
+ function requireQueuePositional(ctx: CommandContext, sub: string): string {
83
+ const queue = ctx.args.positionals[0];
84
+ if (queue === undefined) {
85
+ throw new MissingPositionalError({
86
+ command: `jobs ${sub}`,
87
+ positional: 'queue',
88
+ example: `x jobs ${sub} default --json`,
89
+ });
90
+ }
91
+ return queue;
92
+ }
93
+
94
+ /**
95
+ * A page past the queue's own bound is refused HERE, as a flag error with the command that walks
96
+ * instead — `list()` would refuse it too, in the vocabulary of a caller holding a driver.
97
+ */
98
+ function refuseOversizedPage(limit: string | undefined): void {
99
+ if (limit === undefined || !/^\d+$/.test(limit) || Number(limit) <= MAX_JOB_PAGE) return;
100
+ throw new BadFlagError({
101
+ flag: 'limit',
102
+ command: 'jobs',
103
+ reason: `one page holds at most ${MAX_JOB_PAGE} jobs`,
104
+ // The bound is the queue's own constant, screened like any value spliced into a command.
105
+ fix: `x jobs ls --limit ${renderFixShellArg(String(MAX_JOB_PAGE), '200')} --json # then pass its data.next as --after for the page that follows`,
106
+ });
107
+ }
108
+
67
109
  function requireEnvUrl(env: CommandContext['env'], name: string, target: string): string {
68
110
  const value = env[name];
69
111
  if (value === undefined || value.trim().length === 0) {
@@ -97,16 +139,34 @@ export function buildDrainTarget(to: string | undefined, env: CommandContext['en
97
139
  }
98
140
 
99
141
  async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
142
+ const limit = flagString(ctx.args, 'limit');
143
+ refuseOversizedPage(limit);
100
144
  const result = await listJobs(driver, {
101
145
  queue: flagString(ctx.args, 'queue'),
102
146
  state: flagString(ctx.args, 'state'),
103
147
  name: flagString(ctx.args, 'name'),
104
- limit: flagString(ctx.args, 'limit'),
148
+ limit,
149
+ after: flagString(ctx.args, 'after'),
105
150
  });
151
+ // A FULL page may have another behind it: `next` is the cursor that asks for it, and `null`
152
+ // says this was the last one — so a walk never has to guess from a row count.
153
+ const size = limit === undefined ? DEFAULT_JOB_PAGE : Number(limit);
154
+ const last = result.rows[result.rows.length - 1];
155
+ const next = last === undefined || result.rows.length < size ? null : jobCursor(last);
156
+ const [paused, workers] = await Promise.all([
157
+ driver.introspect?.pausedQueues() ?? [],
158
+ driver.introspect?.workers() ?? [],
159
+ ]);
106
160
  const lines = [` ${msg('cli.jobs.listed', { count: result.rows.length })}`];
107
161
  if (result.rows.length > 0) {
108
162
  lines.push(...renderJobTable(result.rows).map((line) => ` ${line}`));
109
163
  }
164
+ if (next !== null) lines.push(` ${msg('cli.jobs.nextPage', { cursor: next })}`);
165
+ if (paused.length > 0) {
166
+ lines.push(
167
+ ` ${msg('cli.jobs.pausedQueues', { queues: paused.map((q) => q.name).join(', ') })}`,
168
+ );
169
+ }
110
170
  if (result.deadLetters.length > 0) {
111
171
  lines.push(` ${msg('cli.jobs.deadLetters', { count: result.deadLetters.length })}`);
112
172
  for (const entry of result.deadLetters) {
@@ -143,6 +203,9 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
143
203
  rows: result.rows.map(jobRecordToJson),
144
204
  deadLetters: result.deadLetters.map(deadLetterToJson),
145
205
  backfills: result.backfills.map(backfillToJson),
206
+ next,
207
+ pausedQueues: paused.map(pausedToJson),
208
+ workers: workers.map(workerToJson),
146
209
  },
147
210
  };
148
211
  }
@@ -193,6 +256,48 @@ async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<Comman
193
256
  };
194
257
  }
195
258
 
259
+ /** Exit 0 means the row is gone. An id nobody queued is `X_JOB_UNKNOWN`; a running one refuses. */
260
+ async function runRm(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
261
+ const id = requireIdPositional(ctx, 'rm');
262
+ const removed = await removeJob(driver, id);
263
+ if (removed === undefined) throw new JobUnknownError({ id, driver: driver.name });
264
+ return {
265
+ ok: true,
266
+ command: 'jobs',
267
+ summary: msg('cli.jobs.removed', { id: removed.id, state: removed.state }),
268
+ data: jobRecordToJson(removed),
269
+ };
270
+ }
271
+
272
+ async function runPromote(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
273
+ const promoted = await promoteJob(driver, requireIdPositional(ctx, 'promote'));
274
+ return {
275
+ ok: true,
276
+ command: 'jobs',
277
+ summary: msg('cli.jobs.promoted', { id: promoted.id }),
278
+ data: jobRecordToJson(promoted),
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Both verbs answer the list as the queue NOW holds it, read back after the write: the pause is a
284
+ * row every worker's next claim reads, so "paused" here is paused fleet-wide within one poll.
285
+ */
286
+ async function runPause(
287
+ driver: JobDriver,
288
+ ctx: CommandContext,
289
+ sub: 'pause' | 'resume',
290
+ ): Promise<CommandResult> {
291
+ const queue = requireQueuePositional(ctx, sub);
292
+ const paused = await (sub === 'pause' ? pauseQueue(driver, queue) : resumeQueue(driver, queue));
293
+ return {
294
+ ok: true,
295
+ command: 'jobs',
296
+ summary: msg(sub === 'pause' ? 'cli.jobs.paused' : 'cli.jobs.resumed', { queue }),
297
+ data: { queue, paused: sub === 'pause', pausedQueues: paused.map(pausedToJson) },
298
+ };
299
+ }
300
+
196
301
  /**
197
302
  * A skipped candidate is not an error — a job whose `runAt` has not arrived is unclaimable by
198
303
  * design — so it carries no `X_*` finding. It still fails the command: `x jobs drain` is run to
@@ -246,6 +351,9 @@ async function runDrain(
246
351
  return drainResult(await drainJobs(driver, target, flagBool(ctx.args, 'dry-run')));
247
352
  }
248
353
 
354
+ /** The subcommands that answer a `JobTrace`. `ls`, `rm`, `promote` and the rest read rows only. */
355
+ const TRACE_SUBCOMMANDS: ReadonlySet<string> = new Set(['show', 'retry', 'cancel']);
356
+
249
357
  export const jobsCommand: CliCommand = {
250
358
  spec: jobsSpec,
251
359
  async run(ctx: CommandContext): Promise<CommandResult> {
@@ -258,10 +366,19 @@ export const jobsCommand: CliCommand = {
258
366
  // word they typed was refused by name. It also opens a connection to a target the command
259
367
  // then refuses, which is a socket nothing closes.
260
368
  const target = sub === 'drain' ? buildDrainTarget(flagString(ctx.args, 'to'), ctx.env) : null;
369
+ // A TRACE is the queue row projected through the job's own declaration — its concurrency key,
370
+ // its retry schedule — and a declaration exists in this process only once the app is loaded.
371
+ // Unloaded, `show` answered `concurrencyKey: null` and `retryDelaysMs: []` for every job of
372
+ // every app. The findings are deliberately dropped: a trace is read to debug a stuck queue,
373
+ // so an app that half-loads degrades those two fields and still answers.
374
+ if (TRACE_SUBCOMMANDS.has(sub)) await loadApp(root);
261
375
  return withJobDriver(root, ctx, (driver) => {
262
376
  if (sub === 'show') return runShow(driver, ctx);
263
377
  if (sub === 'retry') return runRetry(driver, ctx);
264
378
  if (sub === 'cancel') return runCancel(driver, ctx);
379
+ if (sub === 'rm') return runRm(driver, ctx);
380
+ if (sub === 'promote') return runPromote(driver, ctx);
381
+ if (sub === 'pause' || sub === 'resume') return runPause(driver, ctx, sub);
265
382
  if (target !== null) return runDrain(driver, target, ctx);
266
383
  return runLs(driver, ctx);
267
384
  });
package/src/cmd-new.ts CHANGED
@@ -110,7 +110,21 @@ export function planNewApp(options: NewAppOptions): readonly GeneratedFile[] {
110
110
  ...appFiles(app, options.example),
111
111
  ];
112
112
  if (options.example) {
113
- files.push(...resourceFiles('post', { surfaceDir: 'apps/web/app', feature: 'post' }));
113
+ files.push(
114
+ ...resourceFiles('post', {
115
+ surfaceDir: 'apps/web/app',
116
+ feature: 'post',
117
+ // The package `repoFiles` just named: the slice's `repo.ts` imports the typed handle from it.
118
+ dbModule: `@${app.kebab}/db`,
119
+ // And the catalog package beside it: every component the slice writes reads its strings
120
+ // through `useT()` from the module that registers them — the idiom `x g resource` writes
121
+ // into this same app, so the scaffold shows one way and not two (issue #249).
122
+ catalogModule: `@${app.kebab}/i18n`,
123
+ // `appFiles` above writes `apps/web/shared/shell.tsx`, so the page sits in the app frame —
124
+ // the same answer `x g resource` reads off the disk of this app afterwards.
125
+ shell: true,
126
+ }),
127
+ );
114
128
  }
115
129
  // `repoFiles`' own catalog entry and the example resource's both target the same flat catalog
116
130
  // file, so this has to be the merge-aware dedupe — the one `cmd-generate.ts` uses for `x g` —
package/src/cmd-routes.ts CHANGED
@@ -15,6 +15,16 @@ import { msg } from './messages';
15
15
  import type { CommandResult, JsonValue } from './output';
16
16
  import { flagString } from './parse';
17
17
 
18
+ /**
19
+ * Where a route comes from. A file route names its file; a MOUNTED one names the call that
20
+ * mounted it and every permission that gates it — there is no `page.tsx` to name, and printing the
21
+ * package would send a reader looking for a route file inside `node_modules`.
22
+ */
23
+ const declaredBy = (route: RouteDescriptor): string =>
24
+ route.mount === undefined
25
+ ? route.file
26
+ : `${route.mount.by}() · ${route.mount.permissions.join(' + ')}`;
27
+
18
28
  /** Fixed-width columns so the output diffs cleanly between runs and between machines. */
19
29
  export function renderRouteTable(routes: readonly RouteDescriptor[]): readonly string[] {
20
30
  const rows = routes.map((route) => [
@@ -23,7 +33,7 @@ export function renderRouteTable(routes: readonly RouteDescriptor[]): readonly s
23
33
  route.mode,
24
34
  route.hydrate,
25
35
  route.offline,
26
- route.file,
36
+ declaredBy(route),
27
37
  ]);
28
38
  const header = ['path', 'surface', 'render', 'hydrate', 'offline', 'file'];
29
39
  const widths = header.map((title, index) =>
@@ -43,6 +53,10 @@ const routeJson = (routes: readonly RouteDescriptor[]): JsonValue =>
43
53
  hydrate: route.hydrate,
44
54
  offline: route.offline,
45
55
  budget: { js: route.budgetJs, lcp: route.budgetLcp },
56
+ // Absent on a file route, never `null`: only a mounted route has the fact.
57
+ ...(route.mount === undefined
58
+ ? {}
59
+ : { mount: { by: route.mount.by, permissions: [...route.mount.permissions] } }),
46
60
  }));
47
61
 
48
62
  /**
@@ -0,0 +1,76 @@
1
+ // `x secrets rotate`'s key ring: the retired master keys kept in the sealed file so a value
2
+ // `seal()` wrote under the previous key still opens after a rotation. Pure over the decrypted
3
+ // map — `cmd-secrets.ts` owns the files and the terminal; `@ultimat3/core` owns the variable's
4
+ // name and how a process reads it.
5
+
6
+ import type { SecretValues } from '@ultimat3/core';
7
+ import {
8
+ masterKeyId,
9
+ parseMasterKey,
10
+ SECRETS_FILE,
11
+ SECRETS_RETIRED_KEYS_ENV,
12
+ splitRetiredKeys,
13
+ } from '@ultimat3/core';
14
+ import { BadFlagError } from './errors';
15
+ import { quoteArg } from './shell-quote';
16
+
17
+ /** The app's own secrets: the ring is the framework's entry, never one `envSchema` declares. */
18
+ export function appSecrets(values: SecretValues): SecretValues {
19
+ const { [SECRETS_RETIRED_KEYS_ENV]: _ring, ...rest } = values;
20
+ return rest;
21
+ }
22
+
23
+ const withRing = (values: SecretValues, ring: readonly string[]): SecretValues =>
24
+ ring.length === 0
25
+ ? appSecrets(values)
26
+ : { ...values, [SECRETS_RETIRED_KEYS_ENV]: ring.join(',') };
27
+
28
+ const idOf = (hex: string, index: number): Promise<string> =>
29
+ masterKeyId(
30
+ parseMasterKey(
31
+ hex,
32
+ `${SECRETS_RETIRED_KEYS_ENV} (entry ${index + 1}) in ${SECRETS_FILE}`,
33
+ SECRETS_RETIRED_KEYS_ENV,
34
+ ),
35
+ );
36
+
37
+ /** The ids of the keys the file still declares as retired, newest first. Safe to print. */
38
+ export function retiredKeyIds(values: SecretValues): Promise<readonly string[]> {
39
+ return Promise.all(splitRetiredKeys(values[SECRETS_RETIRED_KEYS_ENV]).map(idOf));
40
+ }
41
+
42
+ /**
43
+ * The values a rotation seals: the same ones, with the key being replaced put at the head of the
44
+ * ring. Kept by DEFAULT and never behind a flag — the rotation overwrites the only copy of the old
45
+ * key on disk, so a rotation that did not keep it would make every sealed value unreadable with
46
+ * nothing left to recover it from.
47
+ */
48
+ export function retire(values: SecretValues, previousHex: string): SecretValues {
49
+ const ring = splitRetiredKeys(values[SECRETS_RETIRED_KEYS_ENV]);
50
+ return withRing(values, [previousHex, ...ring.filter((hex) => hex !== previousHex)]);
51
+ }
52
+
53
+ /** The values without one retired key, named by id — or `X_CLI_BAD_FLAG` listing the ids there are. */
54
+ export async function dropRetired(values: SecretValues, keyId: string): Promise<SecretValues> {
55
+ const ring = splitRetiredKeys(values[SECRETS_RETIRED_KEYS_ENV]);
56
+ const ids = await retiredKeyIds(values);
57
+ if (!ids.includes(keyId)) {
58
+ const [first] = ids;
59
+ throw new BadFlagError({
60
+ flag: 'drop',
61
+ command: 'secrets rotate',
62
+ reason:
63
+ first === undefined
64
+ ? `${SECRETS_FILE} declares no retired master key, so there is none to drop`
65
+ : `no retired master key has that id — ${SECRETS_FILE} declares ${ids.join(', ')}`,
66
+ fix:
67
+ first === undefined
68
+ ? 'x secrets show --json'
69
+ : `x secrets rotate --drop ${quoteArg(first)} --json`,
70
+ });
71
+ }
72
+ return withRing(
73
+ values,
74
+ ring.filter((_hex, index) => ids[index] !== keyId),
75
+ );
76
+ }
@@ -9,11 +9,19 @@ export const SECRETS_SUBCOMMANDS = ['show', 'init', 'edit', 'set', 'rotate'] as
9
9
  export const secretsSpec: CommandSpec = {
10
10
  name: 'secrets',
11
11
  summary: `the committed encrypted secrets, decrypted into the ${ENV_SCHEMA_EXPORT} variables of the same names`,
12
- usage: 'x secrets [show|init|edit|set <NAME>|rotate] [--json]',
12
+ usage: 'x secrets [show|init|edit|set <NAME>|rotate [--drop <keyId>]] [--json]',
13
13
  requiresApp: true,
14
14
  subcommands: [...SECRETS_SUBCOMMANDS],
15
15
  // The bare `x secrets` answers without a key ever leaving the file. Declared, not inherited
16
16
  // from the array's order — `init`, `edit`, `set` and `rotate` all write.
17
17
  defaultSubcommand: 'show',
18
- flags: [],
18
+ flags: [
19
+ {
20
+ name: 'drop',
21
+ type: 'string',
22
+ summary:
23
+ 'rotate: remove one retired master key by id once every value sealed under it is re-sealed; generates no new key',
24
+ subcommands: ['rotate'],
25
+ },
26
+ ],
19
27
  };
@@ -30,6 +30,7 @@ import {
30
30
  } from '@ultimat3/core';
31
31
  import { ENV_SCHEMA_EXPORT, loadEnvSchema } from './app-env';
32
32
  import { requireAppRoot } from './app-root';
33
+ import { appSecrets, dropRetired, retire, retiredKeyIds } from './cmd-secrets-ring';
33
34
  import { secretsSpec } from './cmd-secrets-spec';
34
35
  import type { CliCommand, CommandContext } from './command';
35
36
  import {
@@ -40,6 +41,7 @@ import {
40
41
  } from './errors';
41
42
  import { msg } from './messages';
42
43
  import type { CommandResult, JsonValue } from './output';
44
+ import { flagString } from './parse';
43
45
  import { recoverRotation, rotateMasterKey } from './secrets-rotation';
44
46
  import { shredOnSignal } from './signal-shred';
45
47
  import { renderTable } from './table';
@@ -244,36 +246,65 @@ async function set(ctx: CommandContext, io: SecretsIo): Promise<CommandResult> {
244
246
  };
245
247
  }
246
248
 
249
+ /**
250
+ * The closing half of a rotation: one retired key leaves the ring, by id, once nothing sealed
251
+ * under it is left. No new key is generated and the key file is not touched — the committed file
252
+ * is resealed under the key already in force.
253
+ */
254
+ async function drop(session: Session, keyId: string): Promise<CommandResult> {
255
+ const { root, key } = session;
256
+ const after = await dropRetired(await readSecretsFile(root, key), keyId);
257
+ await writeSecretsFile(root, after, key);
258
+ const retired = await retiredKeyIds(after);
259
+ return {
260
+ ok: true,
261
+ command: 'secrets',
262
+ summary: msg('cli.secrets.dropped', { path: SECRETS_FILE, kid: keyId, count: retired.length }),
263
+ data: {
264
+ path: SECRETS_FILE,
265
+ keyId: await masterKeyIdOf(key),
266
+ dropped: keyId,
267
+ retiredKeyIds: [...retired],
268
+ },
269
+ };
270
+ }
271
+
247
272
  /**
248
273
  * A new master key over the same values: staged beside the old one, the file sealed with it, then
249
274
  * made live (`rotateMasterKey`). Sealed first with the key file written last, a crash between the
250
275
  * two left a committed file no key on disk could open.
276
+ *
277
+ * The key being replaced joins the ring INSIDE the sealed file (`cmd-secrets-ring.ts`), so a value
278
+ * `seal()` wrote under it still opens and a deploy is still handed exactly one key.
251
279
  */
252
280
  async function rotate(ctx: CommandContext): Promise<CommandResult> {
253
- const { root, key } = await open(ctx, 'rotate');
254
- const values = await readSecretsFile(root, key);
281
+ const session = await open(ctx, 'rotate');
282
+ const dropping = flagString(ctx.args, 'drop');
283
+ if (dropping !== undefined) return drop(session, dropping);
284
+ const { root, key } = session;
285
+ const values = retire(await readSecretsFile(root, key), key.hex);
255
286
  const previous = await masterKeyIdOf(key);
256
287
  // Ignored BEFORE any key file is written, staged one included; the order inside is
257
288
  // `secrets-rotation.ts`'s: stage the key, seal, then rename it live.
258
289
  await ensureIgnored(root);
259
290
  const next = await rotateMasterKey(root, values);
260
291
  const keyId = await masterKeyIdOf(next);
292
+ const count = Object.keys(appSecrets(values)).length;
261
293
  return {
262
294
  ok: true,
263
295
  command: 'secrets',
264
- summary: msg('cli.secrets.rotated', {
265
- path: SECRETS_FILE,
266
- from: previous,
267
- to: keyId,
268
- count: Object.keys(values).length,
269
- }),
270
- lines: [msg('cli.secrets.redeploy', { env: SECRETS_KEY_ENV, keyPath: SECRETS_KEY_FILE })],
296
+ summary: msg('cli.secrets.rotated', { path: SECRETS_FILE, from: previous, to: keyId, count }),
297
+ lines: [
298
+ msg('cli.secrets.redeploy', { env: SECRETS_KEY_ENV, keyPath: SECRETS_KEY_FILE }),
299
+ msg('cli.secrets.retired', { kid: previous }),
300
+ ],
271
301
  data: {
272
302
  path: SECRETS_FILE,
273
303
  keyPath: SECRETS_KEY_FILE,
274
304
  previousKeyId: previous,
275
305
  keyId,
276
- count: Object.keys(values).length,
306
+ count,
307
+ retiredKeyIds: [...(await retiredKeyIds(values))],
277
308
  },
278
309
  };
279
310
  }
@@ -285,9 +316,12 @@ async function rotate(ctx: CommandContext): Promise<CommandResult> {
285
316
  */
286
317
  async function show(ctx: CommandContext): Promise<CommandResult> {
287
318
  const { root, key } = await open(ctx, 'show');
288
- const values = await readSecretsFile(root, key);
319
+ const sealed = await readSecretsFile(root, key);
320
+ // The ring is the framework's own entry: reported by key id below, never as an app secret that
321
+ // `envSchema` forgot to declare.
322
+ const retired = await retiredKeyIds(sealed);
289
323
  const schema = await loadEnvSchema(root);
290
- const summaries = describeSecrets(values);
324
+ const summaries = describeSecrets(appSecrets(sealed));
291
325
  const keyId = await masterKeyIdOf(key);
292
326
  const declared = (name: string): boolean | undefined =>
293
327
  schema === undefined ? undefined : schema[name] !== undefined;
@@ -309,6 +343,7 @@ async function show(ctx: CommandContext): Promise<CommandResult> {
309
343
  : [
310
344
  msg('cli.secrets.undeclared', { count: undeclared.length, names: undeclared.join(', ') }),
311
345
  ]),
346
+ ...(retired.length === 0 ? [] : [msg('cli.secrets.ring', { kids: retired.join(', ') })]),
312
347
  ];
313
348
  return {
314
349
  ok: true,
@@ -322,6 +357,7 @@ async function show(ctx: CommandContext): Promise<CommandResult> {
322
357
  path: SECRETS_FILE,
323
358
  keySource: key.source,
324
359
  keyId,
360
+ retiredKeyIds: [...retired],
325
361
  count: summaries.length,
326
362
  secrets: summaries.map((entry) => ({
327
363
  name: entry.name,