@ultimat3/cli 22.15.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 (267) hide show
  1. package/CLAUDE.md +36 -13
  2. package/README.md +31 -8
  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/affected.ts +27 -35
  7. package/src/api-registration.ts +125 -11
  8. package/src/app-artifacts.ts +1 -1
  9. package/src/app-auth.ts +4 -27
  10. package/src/app-boundaries.ts +6 -20
  11. package/src/app-config-load.ts +51 -0
  12. package/src/app-env.ts +5 -11
  13. package/src/app-load.ts +55 -11
  14. package/src/app-manifest.ts +16 -8
  15. package/src/app-mcp.ts +10 -23
  16. package/src/app-permissions-borrowed.ts +129 -0
  17. package/src/app-permissions-site.ts +24 -0
  18. package/src/app-permissions.ts +0 -0
  19. package/src/app-reload-graph.ts +7 -30
  20. package/src/app-transport.ts +219 -0
  21. package/src/boundary-findings.ts +16 -4
  22. package/src/browser-transport.ts +160 -0
  23. package/src/budgets.ts +27 -34
  24. package/src/cdp-shot-driver.ts +3 -1
  25. package/src/cmd-auth-spec.ts +16 -0
  26. package/src/cmd-auth.ts +65 -0
  27. package/src/cmd-build-spec.ts +10 -3
  28. package/src/cmd-build.ts +107 -11
  29. package/src/cmd-db-backfill.ts +25 -12
  30. package/src/cmd-db.ts +66 -16
  31. package/src/cmd-deploy-helm.ts +7 -6
  32. package/src/cmd-deploy.ts +4 -4
  33. package/src/cmd-dev.ts +9 -8
  34. package/src/cmd-doctor-spec.ts +2 -9
  35. package/src/cmd-doctor.ts +75 -24
  36. package/src/cmd-env.ts +26 -2
  37. package/src/cmd-generate.ts +195 -47
  38. package/src/cmd-help.ts +19 -6
  39. package/src/cmd-i18n.ts +10 -9
  40. package/src/cmd-jobs-spec.ts +19 -3
  41. package/src/cmd-jobs.ts +136 -7
  42. package/src/cmd-manifest-spec.ts +1 -1
  43. package/src/cmd-manifest.ts +6 -25
  44. package/src/cmd-mcp.ts +46 -15
  45. package/src/cmd-new.ts +15 -1
  46. package/src/cmd-planned.ts +15 -8
  47. package/src/cmd-routes.ts +27 -3
  48. package/src/cmd-secrets-ring.ts +76 -0
  49. package/src/cmd-secrets-spec.ts +10 -2
  50. package/src/cmd-secrets.ts +95 -18
  51. package/src/cmd-shot-matrix.ts +1 -3
  52. package/src/cmd-shot.ts +12 -3
  53. package/src/cmd-tasks.ts +84 -15
  54. package/src/cmd-test-spec.ts +1 -7
  55. package/src/cmd-test.ts +13 -29
  56. package/src/cmd-verify.ts +13 -5
  57. package/src/comment-safe.ts +16 -0
  58. package/src/coverage-floor.ts +334 -0
  59. package/src/coverage-lcov.ts +193 -0
  60. package/src/coverage-source.ts +136 -0
  61. package/src/db-object-drift.ts +30 -0
  62. package/src/db-replay-engine.ts +115 -0
  63. package/src/db-schema-dump.ts +150 -0
  64. package/src/db-schema-refresh.ts +122 -0
  65. package/src/db-subscribes.ts +12 -4
  66. package/src/dev-dashboard-guard.ts +76 -0
  67. package/src/dev-dashboard.ts +51 -29
  68. package/src/dev-lock.ts +4 -7
  69. package/src/dev-route-table.ts +9 -0
  70. package/src/dispatch.ts +3 -6
  71. package/src/doctor-app-url.ts +39 -0
  72. package/src/doctor-auth.ts +95 -0
  73. package/src/doctor-coverage.ts +39 -0
  74. package/src/doctor-guards.ts +54 -0
  75. package/src/doctor-sealed.ts +132 -0
  76. package/src/document-graph.ts +82 -0
  77. package/src/drift-replica-identity.ts +42 -0
  78. package/src/error-catalog.ts +6 -9
  79. package/src/error-codes.ts +55 -3
  80. package/src/error-unthrown.ts +29 -11
  81. package/src/errors.ts +54 -83
  82. package/src/framework-schema-apply.ts +78 -0
  83. package/src/framework-schema-stamp.ts +96 -0
  84. package/src/framework-schema.ts +28 -2
  85. package/src/generate-files.ts +79 -12
  86. package/src/generate-grant-findings.ts +76 -0
  87. package/src/generate-grants.ts +77 -3
  88. package/src/generate-kinds.ts +52 -1
  89. package/src/generate-shadow.ts +46 -0
  90. package/src/generate-write.ts +62 -40
  91. package/src/generated-imports.ts +59 -0
  92. package/src/gh-target.ts +12 -7
  93. package/src/guard-sources.ts +104 -0
  94. package/src/guards.ts +23 -7
  95. package/src/handle-registration.ts +217 -0
  96. package/src/i18n-audit.ts +4 -1
  97. package/src/i18n-registration.ts +36 -0
  98. package/src/image-prepare.ts +51 -8
  99. package/src/import-closure.ts +165 -0
  100. package/src/index.ts +12 -5
  101. package/src/island-bundle.ts +3 -15
  102. package/src/island-store.ts +24 -16
  103. package/src/jobs-drain.ts +95 -21
  104. package/src/jobs-json.ts +50 -0
  105. package/src/jobs-report.ts +7 -4
  106. package/src/json-merge.ts +46 -0
  107. package/src/loopback-host.ts +13 -0
  108. package/src/manifest-staleness.ts +62 -0
  109. package/src/mcp-errors.ts +41 -4
  110. package/src/mcp-host.ts +16 -9
  111. package/src/mcp-test-run.ts +38 -0
  112. package/src/messages.ts +35 -0
  113. package/src/metrics-endpoint.ts +95 -24
  114. package/src/migration-extensions.ts +29 -0
  115. package/src/module-imports.ts +47 -0
  116. package/src/output.ts +24 -9
  117. package/src/page-navigation.ts +7 -23
  118. package/src/page-speculation.ts +5 -20
  119. package/src/permission-grants.ts +41 -9
  120. package/src/prerender-out.ts +34 -7
  121. package/src/prerender.ts +8 -5
  122. package/src/publish-closure.ts +101 -0
  123. package/src/pwa-artifacts.ts +26 -94
  124. package/src/registry.ts +2 -0
  125. package/src/revalidated-response.ts +4 -3
  126. package/src/role-load.ts +143 -0
  127. package/src/role-replicator.ts +16 -0
  128. package/src/role-start.ts +27 -5
  129. package/src/role-sync.ts +55 -20
  130. package/src/role-wake.ts +45 -0
  131. package/src/runtime-admin.ts +141 -0
  132. package/src/runtime-assets.ts +11 -7
  133. package/src/runtime-bindings.ts +4 -4
  134. package/src/runtime-cache.ts +38 -47
  135. package/src/runtime-hooks.ts +5 -2
  136. package/src/runtime-idempotency-scope.ts +23 -0
  137. package/src/runtime-isr.ts +36 -0
  138. package/src/runtime-jobs.ts +8 -33
  139. package/src/runtime-mfa-warning.ts +29 -0
  140. package/src/runtime-notify-retention.ts +10 -46
  141. package/src/runtime-overrides.ts +1 -1
  142. package/src/runtime-purge.ts +38 -16
  143. package/src/runtime-queue.ts +39 -11
  144. package/src/runtime-realtime.ts +9 -45
  145. package/src/runtime-render.ts +68 -35
  146. package/src/runtime-services.ts +22 -18
  147. package/src/runtime-storage.ts +10 -5
  148. package/src/scaffold-typecheck.ts +1 -0
  149. package/src/schema-diff.ts +16 -0
  150. package/src/schema-drift.ts +45 -15
  151. package/src/schema-dump-drift.ts +64 -0
  152. package/src/seo-routes.ts +80 -9
  153. package/src/serve-boot.ts +114 -158
  154. package/src/serve-drain.ts +9 -33
  155. package/src/serve-env.ts +9 -6
  156. package/src/serve-prebuilt-paths.ts +19 -0
  157. package/src/serve-prebuilt.ts +84 -0
  158. package/src/serve-web.ts +169 -0
  159. package/src/serve.ts +6 -9
  160. package/src/server-barrels.ts +54 -0
  161. package/src/shot-locale.ts +5 -23
  162. package/src/site-assets.ts +3 -5
  163. package/src/site-config.ts +9 -48
  164. package/src/site-seo.ts +1 -1
  165. package/src/sitemap-lastmod.ts +5 -0
  166. package/src/solid-loader.ts +12 -3
  167. package/src/source-edit.ts +49 -0
  168. package/src/source-files.ts +6 -0
  169. package/src/stored-object-headers.ts +43 -0
  170. package/src/sync-url.ts +2 -2
  171. package/src/tasks-facts.ts +3 -26
  172. package/src/templates/action.ts +147 -11
  173. package/src/templates/admin-catalog.ts +58 -0
  174. package/src/templates/admin-page.ts +36 -8
  175. package/src/templates/admin.ts +57 -17
  176. package/src/templates/backfill.ts +121 -87
  177. package/src/templates/entity.ts +140 -44
  178. package/src/templates/guard-animated-layout-property.ts +4 -11
  179. package/src/templates/guard-bare-error.ts +9 -13
  180. package/src/templates/guard-focus-visible.ts +4 -11
  181. package/src/templates/guard-image-dimensions.ts +6 -6
  182. package/src/templates/guard-island-without-states.ts +2 -4
  183. package/src/templates/guard-raw-breakpoint.ts +210 -0
  184. package/src/templates/guard-raw-colour.ts +4 -11
  185. package/src/templates/guard-raw-length.ts +375 -0
  186. package/src/templates/guard-raw-motion.ts +213 -0
  187. package/src/templates/guard-raw-shadow.ts +187 -0
  188. package/src/templates/guard-raw-z-index.ts +166 -0
  189. package/src/templates/guard-repo-raw-sql.ts +345 -0
  190. package/src/templates/guard-semantic-interactive.ts +6 -6
  191. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  192. package/src/templates/guard-undefined-style-class.ts +215 -0
  193. package/src/templates/guard-untranslated-string.ts +6 -6
  194. package/src/templates/guard-unzoned-date.ts +9 -13
  195. package/src/templates/index.ts +3 -2
  196. package/src/templates/island.ts +34 -51
  197. package/src/templates/job-body-test.ts +121 -0
  198. package/src/templates/job.ts +23 -8
  199. package/src/templates/naming.ts +28 -0
  200. package/src/templates/policy.ts +16 -9
  201. package/src/templates/query.ts +242 -26
  202. package/src/templates/resource-create.ts +62 -18
  203. package/src/templates/resource-form-island.ts +58 -80
  204. package/src/templates/resource-page.ts +269 -0
  205. package/src/templates/resource-service.ts +96 -0
  206. package/src/templates/resource.ts +86 -61
  207. package/src/templates/route.ts +41 -35
  208. package/src/templates/scaffold-api.ts +55 -1
  209. package/src/templates/scaffold-app.ts +177 -53
  210. package/src/templates/scaffold-auth.ts +83 -19
  211. package/src/templates/scaffold-browser-client.ts +90 -0
  212. package/src/templates/scaffold-container-compose.ts +10 -1
  213. package/src/templates/scaffold-container.ts +20 -2
  214. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  215. package/src/templates/scaffold-dashboard-example.ts +124 -31
  216. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  217. package/src/templates/scaffold-dashboard.ts +1 -2
  218. package/src/templates/scaffold-db-client.ts +144 -0
  219. package/src/templates/scaffold-db-package.ts +90 -8
  220. package/src/templates/scaffold-docs.ts +50 -5
  221. package/src/templates/scaffold-guards.ts +54 -11
  222. package/src/templates/scaffold-helm-fences.ts +76 -0
  223. package/src/templates/scaffold-helm-templates.ts +32 -7
  224. package/src/templates/scaffold-helm.ts +31 -1
  225. package/src/templates/scaffold-http.ts +9 -7
  226. package/src/templates/scaffold-repo.ts +61 -3
  227. package/src/templates/scaffold-roles.ts +56 -9
  228. package/src/templates/scaffold-shell.ts +74 -105
  229. package/src/templates/scaffold-site.ts +45 -19
  230. package/src/templates/scaffold-ui-package.ts +18 -0
  231. package/src/templates/slice-foundation.ts +46 -8
  232. package/src/test-counts.ts +14 -2
  233. package/src/test-ignores.ts +25 -0
  234. package/src/test-passes.ts +0 -15
  235. package/src/test-select.ts +5 -14
  236. package/src/test-shards.ts +41 -93
  237. package/src/test-workers.ts +26 -4
  238. package/src/theme-boot.ts +6 -31
  239. package/src/transport-calls.ts +105 -0
  240. package/src/trusted-hops-error.ts +17 -0
  241. package/src/unscanned-admin.ts +54 -0
  242. package/src/unthrown-codes.ts +26 -0
  243. package/src/verify-checks.ts +43 -53
  244. package/src/verify-coverage-run.ts +190 -0
  245. package/src/verify-deadline.ts +353 -0
  246. package/src/verify-floor.ts +36 -9
  247. package/src/verify-merge.ts +171 -45
  248. package/src/verify-part-step.ts +90 -0
  249. package/src/verify-progress.ts +25 -0
  250. package/src/verify-role-load-probe.ts +54 -0
  251. package/src/verify-role-load.ts +148 -0
  252. package/src/verify-run.ts +116 -37
  253. package/src/verify-shard.ts +33 -11
  254. package/src/verify-stalled.ts +152 -0
  255. package/src/verify-step.ts +42 -1
  256. package/src/verify-tests.ts +83 -8
  257. package/src/workspace-checks.ts +50 -8
  258. package/src/workspace-dep-edit.ts +127 -0
  259. package/src/build-errors.ts +0 -54
  260. package/src/cmd-dev-fixture.ts +0 -204
  261. package/src/dev-live-fixture.ts +0 -122
  262. package/src/policy-fixture.ts +0 -78
  263. package/src/realtime-browser-probe-fixture.ts +0 -9
  264. package/src/role-start-fixture.ts +0 -78
  265. package/src/scaffold-fixture.ts +0 -160
  266. package/src/templates/island-fixture.ts +0 -76
  267. package/src/verify-errors.ts +0 -49
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';
@@ -51,7 +66,18 @@ function refuseMemoryTarget(): never {
51
66
  });
52
67
  }
53
68
 
54
- 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 {
55
81
  const id = ctx.args.positionals[0];
56
82
  if (id === undefined) {
57
83
  // `--id on "x jobs"` is a flag `x jobs` does not declare; the id is a positional and says so.
@@ -61,9 +87,37 @@ function requireIdPositional(ctx: CommandContext, sub: string): string {
61
87
  example: 'x jobs ls --json',
62
88
  });
63
89
  }
90
+ if (!JOB_ID.test(id)) throw new JobUnknownError({ id, driver: driver.name });
64
91
  return id;
65
92
  }
66
93
 
94
+ function requireQueuePositional(ctx: CommandContext, sub: string): string {
95
+ const queue = ctx.args.positionals[0];
96
+ if (queue === undefined) {
97
+ throw new MissingPositionalError({
98
+ command: `jobs ${sub}`,
99
+ positional: 'queue',
100
+ example: `x jobs ${sub} default --json`,
101
+ });
102
+ }
103
+ return queue;
104
+ }
105
+
106
+ /**
107
+ * A page past the queue's own bound is refused HERE, as a flag error with the command that walks
108
+ * instead — `list()` would refuse it too, in the vocabulary of a caller holding a driver.
109
+ */
110
+ function refuseOversizedPage(limit: string | undefined): void {
111
+ if (limit === undefined || !/^\d+$/.test(limit) || Number(limit) <= MAX_JOB_PAGE) return;
112
+ throw new BadFlagError({
113
+ flag: 'limit',
114
+ command: 'jobs',
115
+ reason: `one page holds at most ${MAX_JOB_PAGE} jobs`,
116
+ // The bound is the queue's own constant, screened like any value spliced into a command.
117
+ fix: `x jobs ls --limit ${renderFixShellArg(String(MAX_JOB_PAGE), '200')} --json # then pass its data.next as --after for the page that follows`,
118
+ });
119
+ }
120
+
67
121
  function requireEnvUrl(env: CommandContext['env'], name: string, target: string): string {
68
122
  const value = env[name];
69
123
  if (value === undefined || value.trim().length === 0) {
@@ -97,16 +151,34 @@ export function buildDrainTarget(to: string | undefined, env: CommandContext['en
97
151
  }
98
152
 
99
153
  async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
154
+ const limit = flagString(ctx.args, 'limit');
155
+ refuseOversizedPage(limit);
100
156
  const result = await listJobs(driver, {
101
157
  queue: flagString(ctx.args, 'queue'),
102
158
  state: flagString(ctx.args, 'state'),
103
159
  name: flagString(ctx.args, 'name'),
104
- limit: flagString(ctx.args, 'limit'),
160
+ limit,
161
+ after: flagString(ctx.args, 'after'),
105
162
  });
163
+ // A FULL page may have another behind it: `next` is the cursor that asks for it, and `null`
164
+ // says this was the last one — so a walk never has to guess from a row count.
165
+ const size = limit === undefined ? DEFAULT_JOB_PAGE : Number(limit);
166
+ const last = result.rows[result.rows.length - 1];
167
+ const next = last === undefined || result.rows.length < size ? null : jobCursor(last);
168
+ const [paused, workers] = await Promise.all([
169
+ driver.introspect?.pausedQueues() ?? [],
170
+ driver.introspect?.workers() ?? [],
171
+ ]);
106
172
  const lines = [` ${msg('cli.jobs.listed', { count: result.rows.length })}`];
107
173
  if (result.rows.length > 0) {
108
174
  lines.push(...renderJobTable(result.rows).map((line) => ` ${line}`));
109
175
  }
176
+ if (next !== null) lines.push(` ${msg('cli.jobs.nextPage', { cursor: next })}`);
177
+ if (paused.length > 0) {
178
+ lines.push(
179
+ ` ${msg('cli.jobs.pausedQueues', { queues: paused.map((q) => q.name).join(', ') })}`,
180
+ );
181
+ }
110
182
  if (result.deadLetters.length > 0) {
111
183
  lines.push(` ${msg('cli.jobs.deadLetters', { count: result.deadLetters.length })}`);
112
184
  for (const entry of result.deadLetters) {
@@ -143,12 +215,15 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
143
215
  rows: result.rows.map(jobRecordToJson),
144
216
  deadLetters: result.deadLetters.map(deadLetterToJson),
145
217
  backfills: result.backfills.map(backfillToJson),
218
+ next,
219
+ pausedQueues: paused.map(pausedToJson),
220
+ workers: workers.map(workerToJson),
146
221
  },
147
222
  };
148
223
  }
149
224
 
150
225
  async function runShow(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
151
- const trace = await showJob(driver, requireIdPositional(ctx, 'show'));
226
+ const trace = await showJob(driver, requireIdPositional(ctx, 'show', driver));
152
227
  return {
153
228
  ok: true,
154
229
  command: 'jobs',
@@ -163,7 +238,7 @@ async function runShow(driver: JobDriver, ctx: CommandContext): Promise<CommandR
163
238
  }
164
239
 
165
240
  async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
166
- const id = requireIdPositional(ctx, 'retry');
241
+ const id = requireIdPositional(ctx, 'retry', driver);
167
242
  const trace = await retryJob(driver, id, flagString(ctx.args, 'from-step'));
168
243
  return {
169
244
  ok: true,
@@ -180,7 +255,7 @@ async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<Command
180
255
  * The trace is rendered by the same projection `show` and `retry` use — one shape for one job.
181
256
  */
182
257
  async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
183
- const id = requireIdPositional(ctx, 'cancel');
258
+ const id = requireIdPositional(ctx, 'cancel', driver);
184
259
  const trace = await cancelJob(driver, id, flagString(ctx.args, 'reason'));
185
260
  // `cancelJob` re-reads the job after cancelling, so `undefined` would mean the row vanished
186
261
  // between the two — reported as the same refusal rather than rendered as a success with no job.
@@ -193,6 +268,48 @@ async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<Comman
193
268
  };
194
269
  }
195
270
 
271
+ /** Exit 0 means the row is gone. An id nobody queued is `X_JOB_UNKNOWN`; a running one refuses. */
272
+ async function runRm(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
273
+ const id = requireIdPositional(ctx, 'rm', driver);
274
+ const removed = await removeJob(driver, id);
275
+ if (removed === undefined) throw new JobUnknownError({ id, driver: driver.name });
276
+ return {
277
+ ok: true,
278
+ command: 'jobs',
279
+ summary: msg('cli.jobs.removed', { id: removed.id, state: removed.state }),
280
+ data: jobRecordToJson(removed),
281
+ };
282
+ }
283
+
284
+ async function runPromote(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
285
+ const promoted = await promoteJob(driver, requireIdPositional(ctx, 'promote', driver));
286
+ return {
287
+ ok: true,
288
+ command: 'jobs',
289
+ summary: msg('cli.jobs.promoted', { id: promoted.id }),
290
+ data: jobRecordToJson(promoted),
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Both verbs answer the list as the queue NOW holds it, read back after the write: the pause is a
296
+ * row every worker's next claim reads, so "paused" here is paused fleet-wide within one poll.
297
+ */
298
+ async function runPause(
299
+ driver: JobDriver,
300
+ ctx: CommandContext,
301
+ sub: 'pause' | 'resume',
302
+ ): Promise<CommandResult> {
303
+ const queue = requireQueuePositional(ctx, sub);
304
+ const paused = await (sub === 'pause' ? pauseQueue(driver, queue) : resumeQueue(driver, queue));
305
+ return {
306
+ ok: true,
307
+ command: 'jobs',
308
+ summary: msg(sub === 'pause' ? 'cli.jobs.paused' : 'cli.jobs.resumed', { queue }),
309
+ data: { queue, paused: sub === 'pause', pausedQueues: paused.map(pausedToJson) },
310
+ };
311
+ }
312
+
196
313
  /**
197
314
  * A skipped candidate is not an error — a job whose `runAt` has not arrived is unclaimable by
198
315
  * design — so it carries no `X_*` finding. It still fails the command: `x jobs drain` is run to
@@ -246,6 +363,9 @@ async function runDrain(
246
363
  return drainResult(await drainJobs(driver, target, flagBool(ctx.args, 'dry-run')));
247
364
  }
248
365
 
366
+ /** The subcommands that answer a `JobTrace`. `ls`, `rm`, `promote` and the rest read rows only. */
367
+ const TRACE_SUBCOMMANDS: ReadonlySet<string> = new Set(['show', 'retry', 'cancel']);
368
+
249
369
  export const jobsCommand: CliCommand = {
250
370
  spec: jobsSpec,
251
371
  async run(ctx: CommandContext): Promise<CommandResult> {
@@ -258,10 +378,19 @@ export const jobsCommand: CliCommand = {
258
378
  // word they typed was refused by name. It also opens a connection to a target the command
259
379
  // then refuses, which is a socket nothing closes.
260
380
  const target = sub === 'drain' ? buildDrainTarget(flagString(ctx.args, 'to'), ctx.env) : null;
381
+ // A TRACE is the queue row projected through the job's own declaration — its concurrency key,
382
+ // its retry schedule — and a declaration exists in this process only once the app is loaded.
383
+ // Unloaded, `show` answered `concurrencyKey: null` and `retryDelaysMs: []` for every job of
384
+ // every app. The findings are deliberately dropped: a trace is read to debug a stuck queue,
385
+ // so an app that half-loads degrades those two fields and still answers.
386
+ if (TRACE_SUBCOMMANDS.has(sub)) await loadApp(root);
261
387
  return withJobDriver(root, ctx, (driver) => {
262
388
  if (sub === 'show') return runShow(driver, ctx);
263
389
  if (sub === 'retry') return runRetry(driver, ctx);
264
390
  if (sub === 'cancel') return runCancel(driver, ctx);
391
+ if (sub === 'rm') return runRm(driver, ctx);
392
+ if (sub === 'promote') return runPromote(driver, ctx);
393
+ if (sub === 'pause' || sub === 'resume') return runPause(driver, ctx, sub);
265
394
  if (target !== null) return runDrain(driver, target, ctx);
266
395
  return runLs(driver, ctx);
267
396
  });
@@ -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',
package/src/cmd-mcp.ts CHANGED
@@ -3,17 +3,24 @@
3
3
  // supplies only the app, the caller and the socket. A tool answered here would be a second answer
4
4
  // to a question the framework already answers.
5
5
 
6
- import { markListening, nanoid, timingSafeEqual } from '@ultimat3/core';
6
+ import {
7
+ markListening,
8
+ nanoid,
9
+ renderFixShellArg,
10
+ timingSafeEqual,
11
+ UltimateError,
12
+ } from '@ultimat3/core';
7
13
  import { mcpHttpRoute, serveStdio } from '@ultimat3/mcp';
8
14
  import { requireAppRoot } from './app-root';
9
15
  import { DEFAULT_PORT, mcpSpec } from './cmd-mcp-spec';
10
16
  import type { CliCommand, CommandContext } from './command';
11
17
  import { BadFlagError } from './errors';
12
- import { intFlagOr, PORT_RANGE } from './flag-number';
18
+ import { intFlagOr, neighbouringPort, PORT_RANGE } from './flag-number';
13
19
  import { holdUntilShutdown } from './hold';
14
20
  import type { CliMcpServer } from './mcp-host';
15
21
  import { createDevMcpServer, DEV_TOOL_SCOPES } from './mcp-host';
16
22
  import { msg } from './messages';
23
+ import { isAddressInUse } from './metrics-endpoint';
17
24
  import type { CommandResult } from './output';
18
25
  import { flagString } from './parse';
19
26
 
@@ -56,6 +63,21 @@ const notFound = (path: string): Response =>
56
63
  { status: 404, headers: { 'content-type': 'application/json' } },
57
64
  );
58
65
 
66
+ /**
67
+ * The MCP port is bound already — by another `x mcp serve`, or by the inspector, whose default
68
+ * 9229 this one shares. `metrics-endpoint.ts`'s pattern: an `EADDRINUSE` throw becomes the
69
+ * framework's `X_PORT_IN_USE`, with the invocation one port over.
70
+ */
71
+ export class McpPortInUseError extends UltimateError {
72
+ constructor(input: { port: number }) {
73
+ super({
74
+ code: 'X_PORT_IN_USE',
75
+ cause: `port ${String(input.port)} is already bound, so x mcp serve could not open its HTTP transport`,
76
+ fix: `x mcp serve --transport http --port ${renderFixShellArg(String(neighbouringPort(input.port)), '9230')}`,
77
+ });
78
+ }
79
+ }
80
+
59
81
  /** A running HTTP transport. `stop()` releases the socket AND the host's lazily booted services. */
60
82
  export interface McpHttpServer {
61
83
  readonly result: CommandResult;
@@ -70,7 +92,7 @@ export interface McpHttpServer {
70
92
  * directory outlive `run()` otherwise, and nothing — a test, an embedding caller, or a signal —
71
93
  * could ever release them.
72
94
  */
73
- export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
95
+ export async function startMcpHttp(host: CliMcpServer, port: number): Promise<McpHttpServer> {
74
96
  const token = nanoid(32);
75
97
  const route = mcpHttpRoute({
76
98
  server: host.server,
@@ -83,17 +105,26 @@ export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
83
105
  ? { actor: host.caller.actor, scopes: DEV_TOOL_SCOPES }
84
106
  : null,
85
107
  });
86
- const handle = Bun.serve({
87
- port,
88
- hostname: 'localhost',
89
- fetch: (request: Request): Response | Promise<Response> => {
90
- const url = new URL(request.url);
91
- if (request.method !== route.method || url.pathname !== route.path) {
92
- return notFound(route.path);
93
- }
94
- return route.handle(request);
95
- },
96
- });
108
+ let handle: ReturnType<typeof Bun.serve>;
109
+ try {
110
+ handle = Bun.serve({
111
+ port,
112
+ hostname: 'localhost',
113
+ fetch: (request: Request, server): Response | Promise<Response> => {
114
+ const url = new URL(request.url);
115
+ if (request.method !== route.method || url.pathname !== route.path) {
116
+ return notFound(route.path);
117
+ }
118
+ // The socket's peer: failed tokens are metered per address, and only the server knows it.
119
+ return route.handle(request, { address: server.requestIP(request)?.address });
120
+ },
121
+ });
122
+ } catch (error) {
123
+ // The host booted before the bind; a refusal that left it open would leak its database.
124
+ await host.close();
125
+ if (isAddressInUse(error)) throw new McpPortInUseError({ port });
126
+ throw error;
127
+ }
97
128
  // Announces the socket as this process's own, so a caller on it is never mistaken for egress.
98
129
  const stopListening = markListening(handle.url.origin);
99
130
  const url = `${handle.url.origin}${route.path}`;
@@ -179,7 +210,7 @@ export const mcpCommand: CliCommand = {
179
210
  // starts is what releases the socket and the database. Registering a shutdown hook and
180
211
  // returning was the older shape — nothing installed a signal handler, so the hook was never
181
212
  // reached and the exit code closed the socket the line above had just announced.
182
- const served = startMcpHttp(host, port);
213
+ const served = await startMcpHttp(host, port);
183
214
  return { ...served.result, hold: holdUntilShutdown('mcp', () => served.stop()) };
184
215
  },
185
216
  };
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` —
@@ -3,8 +3,8 @@
3
3
  // does not exist", which is false and sends an agent looking for a typo. X_NOT_IMPLEMENTED plus a
4
4
  // fix naming the closest shipped command says the true thing, and `x help` lists them honestly.
5
5
 
6
+ import { NotImplementedError } from '@ultimat3/core';
6
7
  import type { CliCommand } from './command';
7
- import { CliNotImplementedError } from './errors';
8
8
  import type { CommandResult } from './output';
9
9
  import type { CommandSpec } from './parse';
10
10
 
@@ -134,18 +134,28 @@ export const PLANNED_SUBCOMMANDS: readonly PlannedSubcommand[] = [
134
134
  * the error rather than throwing it, because a `run` that throws synchronously escapes the
135
135
  * promise its signature promises — the caller does `throw plannedSubcommand(...)`.
136
136
  */
137
- export function plannedSubcommand(command: string, subcommand: string): CliNotImplementedError {
137
+ export function plannedSubcommand(command: string, subcommand: string): NotImplementedError {
138
138
  const planned = PLANNED_SUBCOMMANDS.find(
139
139
  (entry) => entry.command === command && entry.subcommand === subcommand,
140
140
  );
141
- return new CliNotImplementedError({
142
- feature: `x ${command} ${subcommand}`,
141
+ return new NotImplementedError({
142
+ cause: `x ${command} ${subcommand} is not implemented in this build`,
143
143
  // `x help <command>`, not `x <command> --help`: for a command that declares subcommands and no
144
144
  // default, the latter is refused by the parser before help is ever rendered.
145
145
  fix: planned?.fix ?? `x help ${command}`,
146
146
  });
147
147
  }
148
148
 
149
+ /**
150
+ * What every invocation of a planned command answers — its `run`, and the dispatcher when the
151
+ * parser refused a flag before any `run` was reached. One builder, so the two cannot drift.
152
+ */
153
+ export const plannedCommandError = (planned: PlannedCommand): NotImplementedError =>
154
+ new NotImplementedError({
155
+ cause: `x ${planned.name} is not implemented in this build`,
156
+ fix: planned.fix,
157
+ });
158
+
149
159
  const specFor = (planned: PlannedCommand): CommandSpec => ({
150
160
  name: planned.name,
151
161
  summary: `${planned.summary} (planned)`,
@@ -167,10 +177,7 @@ const toCommand = (planned: PlannedCommand): CliCommand => ({
167
177
  // `async` is load-bearing: a synchronous throw would escape every caller that awaits the
168
178
  // promise this signature promises, including the dispatcher's own error path.
169
179
  async run(): Promise<CommandResult> {
170
- throw new CliNotImplementedError({
171
- feature: `x ${planned.name}`,
172
- fix: planned.fix,
173
- });
180
+ throw plannedCommandError(planned);
174
181
  },
175
182
  });
176
183
 
package/src/cmd-routes.ts CHANGED
@@ -10,11 +10,21 @@ import { loadApp } from './app-load';
10
10
  import { requireAppRoot } from './app-root';
11
11
  import { routesSpec } from './cmd-routes-spec';
12
12
  import type { CliCommand, CommandContext } from './command';
13
- import { BadFlagError } from './errors';
13
+ import { BadFlagError, UnknownCommandError } from './errors';
14
14
  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) =>
@@ -42,7 +52,11 @@ const routeJson = (routes: readonly RouteDescriptor[]): JsonValue =>
42
52
  render: route.mode,
43
53
  hydrate: route.hydrate,
44
54
  offline: route.offline,
45
- budget: { js: route.budgetJs, lcp: route.budgetLcp },
55
+ budget: { js: route.budgetJs },
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
  /**
@@ -69,6 +83,16 @@ export const routesCommand: CliCommand = {
69
83
  spec: routesSpec,
70
84
  async run(ctx: CommandContext): Promise<CommandResult> {
71
85
  const root = requireAppRoot('routes', ctx.cwd).dir;
86
+ // No positional: `x routes list --json` ran only because a stray word was ignored, which is
87
+ // how a retired spelling stayed in the route-miss fix lines. Refused before the app loads.
88
+ const stray = ctx.args.positionals[0];
89
+ if (stray !== undefined) {
90
+ throw new UnknownCommandError({
91
+ path: `routes ${stray}`,
92
+ known: ['routes'],
93
+ suggestion: 'routes --json',
94
+ });
95
+ }
72
96
  // Read before the app is loaded: a typo must not cost a boot to report, the rule `x mcp`'s
73
97
  // `--transport` already follows.
74
98
  const surface = readSurfaceFilter(flagString(ctx.args, 'surface'));
@@ -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
  };