@ultimat3/cli 22.14.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 (174) hide show
  1. package/CLAUDE.md +25 -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 +35 -3
  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-dev.ts +2 -2
  20. package/src/cmd-doctor.ts +35 -11
  21. package/src/cmd-generate.ts +118 -17
  22. package/src/cmd-jobs-spec.ts +19 -3
  23. package/src/cmd-jobs.ts +120 -3
  24. package/src/cmd-new.ts +15 -1
  25. package/src/cmd-routes.ts +15 -1
  26. package/src/cmd-secrets-ring.ts +76 -0
  27. package/src/cmd-secrets-spec.ts +10 -2
  28. package/src/cmd-secrets.ts +48 -12
  29. package/src/cmd-tasks.ts +84 -15
  30. package/src/cmd-verify.ts +10 -2
  31. package/src/comment-safe.ts +16 -0
  32. package/src/coverage-floor.ts +334 -0
  33. package/src/coverage-lcov.ts +193 -0
  34. package/src/coverage-source.ts +136 -0
  35. package/src/db-object-drift.ts +30 -0
  36. package/src/db-replay-engine.ts +115 -0
  37. package/src/db-schema-dump.ts +150 -0
  38. package/src/db-schema-refresh.ts +122 -0
  39. package/src/dev-dashboard.ts +2 -2
  40. package/src/dev-route-table.ts +30 -6
  41. package/src/doctor-coverage.ts +39 -0
  42. package/src/doctor-guards.ts +54 -0
  43. package/src/doctor-sealed.ts +132 -0
  44. package/src/document-graph.ts +82 -0
  45. package/src/document-options.ts +64 -0
  46. package/src/error-codes.ts +32 -0
  47. package/src/framework-schema.ts +15 -0
  48. package/src/generate-files.ts +76 -10
  49. package/src/generate-grant-findings.ts +76 -0
  50. package/src/generate-grants.ts +77 -3
  51. package/src/generate-kinds.ts +19 -0
  52. package/src/generate-write.ts +15 -27
  53. package/src/generated-imports.ts +59 -0
  54. package/src/guard-sources.ts +104 -0
  55. package/src/guards.ts +23 -7
  56. package/src/handle-registration.ts +217 -0
  57. package/src/i18n-registration.ts +36 -0
  58. package/src/image-prepare.ts +51 -8
  59. package/src/import-closure.ts +165 -0
  60. package/src/index.ts +11 -2
  61. package/src/island-store.ts +24 -16
  62. package/src/jobs-drain.ts +14 -7
  63. package/src/jobs-json.ts +50 -0
  64. package/src/jobs-report.ts +3 -0
  65. package/src/json-merge.ts +46 -0
  66. package/src/mcp-errors.ts +29 -1
  67. package/src/messages.ts +33 -0
  68. package/src/metrics-endpoint.ts +71 -24
  69. package/src/migration-extensions.ts +29 -0
  70. package/src/module-imports.ts +47 -0
  71. package/src/output.ts +18 -9
  72. package/src/page-speculation.ts +124 -0
  73. package/src/permission-grants.ts +41 -9
  74. package/src/prerender.ts +9 -5
  75. package/src/publish-closure.ts +101 -0
  76. package/src/role-load.ts +143 -0
  77. package/src/role-start.ts +18 -1
  78. package/src/role-wake.ts +45 -0
  79. package/src/runtime-admin.ts +141 -0
  80. package/src/runtime-hooks.ts +5 -2
  81. package/src/runtime-queue.ts +8 -1
  82. package/src/runtime-render.ts +36 -60
  83. package/src/runtime-services.ts +2 -1
  84. package/src/scaffold-typecheck.ts +1 -0
  85. package/src/schema-dump-drift.ts +64 -0
  86. package/src/serve-boot.ts +85 -147
  87. package/src/serve-prebuilt-paths.ts +19 -0
  88. package/src/serve-prebuilt.ts +84 -0
  89. package/src/serve-web.ts +164 -0
  90. package/src/serve.ts +6 -9
  91. package/src/server-barrels.ts +54 -0
  92. package/src/source-edit.ts +49 -0
  93. package/src/static-document.ts +108 -0
  94. package/src/tasks-facts.ts +4 -1
  95. package/src/templates/action.ts +144 -11
  96. package/src/templates/admin-catalog.ts +58 -0
  97. package/src/templates/admin-page.ts +36 -8
  98. package/src/templates/admin.ts +57 -17
  99. package/src/templates/backfill.ts +121 -87
  100. package/src/templates/entity.ts +140 -44
  101. package/src/templates/guard-animated-layout-property.ts +4 -11
  102. package/src/templates/guard-bare-error.ts +9 -13
  103. package/src/templates/guard-focus-visible.ts +4 -11
  104. package/src/templates/guard-image-dimensions.ts +6 -6
  105. package/src/templates/guard-island-without-states.ts +2 -4
  106. package/src/templates/guard-raw-breakpoint.ts +210 -0
  107. package/src/templates/guard-raw-colour.ts +4 -11
  108. package/src/templates/guard-raw-length.ts +375 -0
  109. package/src/templates/guard-raw-motion.ts +213 -0
  110. package/src/templates/guard-raw-shadow.ts +187 -0
  111. package/src/templates/guard-raw-z-index.ts +166 -0
  112. package/src/templates/guard-repo-raw-sql.ts +345 -0
  113. package/src/templates/guard-semantic-interactive.ts +6 -6
  114. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  115. package/src/templates/guard-undefined-style-class.ts +215 -0
  116. package/src/templates/guard-untranslated-string.ts +6 -6
  117. package/src/templates/guard-unzoned-date.ts +9 -13
  118. package/src/templates/index.ts +3 -2
  119. package/src/templates/island.ts +34 -51
  120. package/src/templates/job-body-test.ts +121 -0
  121. package/src/templates/job.ts +23 -8
  122. package/src/templates/policy.ts +16 -9
  123. package/src/templates/query.ts +242 -26
  124. package/src/templates/resource-create.ts +62 -18
  125. package/src/templates/resource-form-island.ts +58 -80
  126. package/src/templates/resource-page.ts +269 -0
  127. package/src/templates/resource-service.ts +96 -0
  128. package/src/templates/resource.ts +86 -61
  129. package/src/templates/route.ts +38 -29
  130. package/src/templates/scaffold-api.ts +55 -1
  131. package/src/templates/scaffold-app.ts +171 -51
  132. package/src/templates/scaffold-auth.ts +83 -19
  133. package/src/templates/scaffold-browser-client.ts +90 -0
  134. package/src/templates/scaffold-container.ts +16 -0
  135. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  136. package/src/templates/scaffold-dashboard-example.ts +124 -31
  137. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  138. package/src/templates/scaffold-dashboard.ts +1 -2
  139. package/src/templates/scaffold-db-client.ts +142 -0
  140. package/src/templates/scaffold-db-package.ts +90 -8
  141. package/src/templates/scaffold-docs.ts +50 -5
  142. package/src/templates/scaffold-guards.ts +54 -11
  143. package/src/templates/scaffold-http.ts +9 -7
  144. package/src/templates/scaffold-repo.ts +52 -2
  145. package/src/templates/scaffold-roles.ts +56 -9
  146. package/src/templates/scaffold-shell.ts +36 -70
  147. package/src/templates/scaffold-site.ts +45 -19
  148. package/src/templates/scaffold-ui-package.ts +18 -0
  149. package/src/templates/slice-foundation.ts +46 -8
  150. package/src/test-counts.ts +18 -2
  151. package/src/transport-calls.ts +105 -0
  152. package/src/unscanned-admin.ts +54 -0
  153. package/src/verify-checks.ts +26 -7
  154. package/src/verify-coverage-run.ts +190 -0
  155. package/src/verify-deadline.ts +256 -0
  156. package/src/verify-errors.ts +15 -6
  157. package/src/verify-floor.ts +36 -9
  158. package/src/verify-merge.ts +117 -24
  159. package/src/verify-progress.ts +25 -0
  160. package/src/verify-role-load-probe.ts +54 -0
  161. package/src/verify-role-load.ts +148 -0
  162. package/src/verify-run.ts +93 -35
  163. package/src/verify-shard.ts +20 -9
  164. package/src/verify-step.ts +42 -1
  165. package/src/verify-tests.ts +78 -6
  166. package/src/workspace-checks.ts +50 -8
  167. package/src/workspace-dep-edit.ts +127 -0
  168. package/src/cmd-dev-fixture.ts +0 -204
  169. package/src/dev-live-fixture.ts +0 -122
  170. package/src/policy-fixture.ts +0 -78
  171. package/src/realtime-browser-probe-fixture.ts +0 -9
  172. package/src/role-start-fixture.ts +0 -78
  173. package/src/scaffold-fixture.ts +0 -160
  174. package/src/templates/island-fixture.ts +0 -76
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { existsSync } from 'node:fs';
6
6
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
7
+ import { registerAdminResources } from './admin-registration';
7
8
  import { registerGeneratedPrimitives } from './api-registration';
8
9
  import { writeAppArtifacts } from './app-artifacts';
9
10
  import { appManifest } from './app-manifest';
@@ -13,18 +14,21 @@ import type { CliCommand, CommandContext } from './command';
13
14
  import { invocationOf } from './command';
14
15
  import { assertFeatureExists } from './generate-feature';
15
16
  import { generate, sliceDir } from './generate-files';
17
+ import { ungrantedByGenerator } from './generate-grant-findings';
16
18
  import { grantGeneratedPermissions } from './generate-grants';
17
19
  import type { Generator } from './generate-kinds';
18
20
  import { readFeature, readKind, readName, readPermission, readSurface } from './generate-kinds';
19
21
  import { containedPath, writeFiles } from './generate-write';
22
+ import { declareGeneratedImports } from './generated-imports';
23
+ import { registerGeneratedEntities, resolveDbModule } from './handle-registration';
20
24
  import { resolveCatalogModule } from './i18n-audit';
21
- import { syncI18nIndex } from './i18n-index';
25
+ import { catalogLocales, syncI18nIndex } from './i18n-index';
22
26
  import { reproducedFlags } from './invocation-flags';
23
27
  import { msg } from './messages';
24
28
  import type { CommandResult, Finding } from './output';
25
29
  import { flagBool, flagList, flagString } from './parse';
26
30
  import { quoteArg } from './shell-quote';
27
- import { kebab, resolveLocales } from './templates';
31
+ import { kebab, names, resolveLocales } from './templates';
28
32
 
29
33
  // One import path for the generator, unchanged by the split: `index.ts`, `x new` and the scaffold
30
34
  // fixture reach the kinds, the pure file list and the writer through this module, and a second path
@@ -46,7 +50,7 @@ export const generateCommand: CliCommand = {
46
50
  // Both flags are resolved before a single file is planned: a bad surface or a locale that is
47
51
  // really a path fails here, with nothing written and nothing to undo.
48
52
  const surface = readSurface(flagString(ctx.args, 'surface'), kind, name);
49
- const locales = resolveLocales(flagList(ctx.args, 'locales'));
53
+ const locales = resolveLocales(await namedOrAppLocales(root, flagList(ctx.args, 'locales')));
50
54
  const at = flagString(ctx.args, 'at');
51
55
  // Read with the two above, and refused here for their reason: the value is spliced into the
52
56
  // emitted source three times, and a value that is not a `<resource>:<verb>` is a page the app
@@ -55,6 +59,10 @@ export const generateCommand: CliCommand = {
55
59
  // Read before a file is planned, like the flags above: which module a generated component
56
60
  // imports `useT()` from is a fact about THIS app, and `generate` is a pure function.
57
61
  const catalogModule = await resolveCatalogModule(root);
62
+ // And for the same reason: which package a generated `repo.ts` imports the typed handle from.
63
+ const dbModule = await resolveDbModule(root);
64
+ // And whether the app has the frame a resource's page sits in.
65
+ const shell = existsSync(containedPath(root, SHELL_MODULE));
58
66
  // Read for the same reason: which errors the slice declares is written on THIS app's disk.
59
67
  const slice = sliceDir(surface, kebab(featureFlag ?? name));
60
68
  // A named slice that is not there is refused, never invented (X_FEATURE_UNKNOWN).
@@ -78,6 +86,8 @@ export const generateCommand: CliCommand = {
78
86
  admin: flagBool(ctx.args, 'admin'),
79
87
  locales,
80
88
  ...(catalogModule === undefined ? {} : { catalogModule }),
89
+ ...(dbModule === undefined ? {} : { dbModule }),
90
+ shell,
81
91
  });
82
92
  if (flagBool(ctx.args, 'dry-run')) {
83
93
  return {
@@ -97,13 +107,29 @@ export const generateCommand: CliCommand = {
97
107
  ...reproducedFlags(generateCommand.spec, ctx.args),
98
108
  ].join(' ');
99
109
  const report = await writeFiles(root, files, flagBool(ctx.args, 'force'), invocation);
100
- // The two edits a generated primitive needs outside its own slice, performed rather than left
101
- // as findings: a declared permission granted to a role, and a job listed in `defineApi`.
102
- // Before the manifest load below, so the projection sees both.
110
+ // The three edits a generated primitive needs outside its own slice, performed rather than
111
+ // left as findings: a declared permission granted to a role, a job listed in `defineApi`, and
112
+ // an entity added to the typed handle its `repo.ts` reads through. Before the manifest load
113
+ // below, so the projection sees all three — a repo reading a table the handle lacks would not
114
+ // load at all.
115
+ const handle = await registerGeneratedEntities(root, report.written, dbModule);
116
+ // And a fourth, for `--admin`: the override it wrote, listed where `defineAdmin()` reads it.
117
+ const adminWiring = await registerAdminResources(root, report.written);
118
+ // And a fifth, last because the others add imports too: every sibling workspace a written file
119
+ // imports, declared in the manifest it landed under — or `package-shape` refuses the output.
103
120
  const edited = [
104
- ...(await grantGeneratedPermissions(root, report.written)),
105
- ...(await registerGeneratedPrimitives(root, report.written)),
121
+ ...new Set([
122
+ ...(await grantGeneratedPermissions(root, report.written)),
123
+ ...(await registerGeneratedPrimitives(root, report.written)),
124
+ ...handle.edited,
125
+ ...adminWiring.edited,
126
+ ...(await declareGeneratedImports(root, report.written)),
127
+ ]),
106
128
  ];
129
+ // A grant that edit could not place is said so, with the role each permission belongs to: an
130
+ // app whose role map is not the scaffold's got no grant and no word, and a 403 on every
131
+ // endpoint the run had just written.
132
+ const ungranted = await ungrantedByGenerator(root, report.written);
107
133
  // A locale's catalog existing on disk and the app being able to select it are two different
108
134
  // facts — see `syncI18nIndex`. Runs before the manifest load below so a route or resource
109
135
  // this same invocation just wrote never gets projected against a stale catalog registration.
@@ -133,17 +159,36 @@ export const generateCommand: CliCommand = {
133
159
  buildId = manifest.buildId;
134
160
  } else loadFailures.push(...findings);
135
161
  }
136
- const findings = [...report.conflicts, ...indexSync.findings, ...loadFailures];
162
+ const findings = [
163
+ ...report.conflicts,
164
+ ...handle.findings,
165
+ ...ungranted,
166
+ ...adminWiring.findings,
167
+ ...indexSync.findings,
168
+ ...loadFailures,
169
+ ];
137
170
  // One list behind all three renderings. The manifest was printed as a `+` line while the count
138
171
  // beside it came from `report.written` alone, so `x g island` said "wrote 2 file(s)" over three
139
172
  // lines — and `--json` carried the shorter list, which is the drift `--json` exists to prevent.
140
173
  const written = [...report.written, ...edited, ...artifacts];
174
+ // Named only over a clean run: with a finding, the finding's own `fix:` is the next command.
175
+ const table = names(isEntityKind(kind) ? name : (featureFlag ?? name)).table;
176
+ const next = findings.length === 0 ? nextSteps(report.written, edited, table) : [];
141
177
  return {
142
178
  ok: findings.length === 0,
143
179
  command: 'g',
144
- summary: msg('cli.generate.wrote', { count: written.length, kind, name }),
180
+ summary:
181
+ next.length === 0
182
+ ? msg('cli.generate.wrote', { count: written.length, kind, name })
183
+ : msg('cli.generate.wroteNext', {
184
+ count: written.length,
185
+ kind,
186
+ name,
187
+ next: next.join(' && '),
188
+ }),
145
189
  data: {
146
190
  files: written,
191
+ ...(next.length === 0 ? {} : { next }),
147
192
  ...(buildId === undefined ? {} : { manifest: { buildId } }),
148
193
  },
149
194
  lines: written.map((path) => msg('cli.file.added', { path })),
@@ -152,6 +197,59 @@ export const generateCommand: CliCommand = {
152
197
  },
153
198
  };
154
199
 
200
+ /**
201
+ * `--locales` when the caller named any, and otherwise every locale the app already has a catalog
202
+ * for: a key written to `en` alone in an app that also ships `es` renders ⟦key⟧ for every Spanish
203
+ * reader. A stem that is not a locale tag is some other JSON file, and not a catalog to write to.
204
+ */
205
+ async function namedOrAppLocales(
206
+ root: string,
207
+ named: readonly string[],
208
+ ): Promise<readonly string[]> {
209
+ if (named.length > 0) return named;
210
+ return (await catalogLocales(root)).filter((stem) => {
211
+ try {
212
+ return Intl.getCanonicalLocales(stem).length === 1;
213
+ } catch {
214
+ return false;
215
+ }
216
+ });
217
+ }
218
+
219
+ const isEntityKind = (kind: Generator): boolean => kind === 'entity' || kind === 'resource';
220
+
221
+ /**
222
+ * What a run that declared a table owes before the app can read it, in the order it has to
223
+ * happen — each one a command that runs as printed. `bunx x`, never a bare `x`: `bun install`
224
+ * links the binary into `./node_modules/.bin` and nowhere on PATH.
225
+ *
226
+ * `bun install` leads when a manifest gained a workspace edge: the handle imports the entity from
227
+ * the web workspace, and an edge only a manifest declares is not linked until the install runs.
228
+ * `x db gen` then writes the migration AND `packages/db/schema/`, which the `drift` step holds.
229
+ */
230
+ export function nextSteps(
231
+ written: readonly string[],
232
+ edited: readonly string[],
233
+ table: string,
234
+ ): readonly string[] {
235
+ if (!written.some((path) => path.endsWith('/entity.ts'))) return [];
236
+ return [
237
+ ...(edited.some((path) => path.endsWith('package.json')) ? ['bun install'] : []),
238
+ `bunx x db gen "create ${table}"`,
239
+ 'bunx x db migrate',
240
+ // A page that mounts an island has a byte budget only a build can weigh — measured on `x new`'s
241
+ // app (`RESOURCE_PAGE_BUDGET`), and an app that charges more per document is over it on this
242
+ // run rather than on the next `bin/check`.
243
+ ...(written.some((path) => path.endsWith('/page.tsx')) ? [MEASURE_PAGE] : []),
244
+ ];
245
+ }
246
+
247
+ /** The two commands that weigh a page against its budget: the build writes the bytes it reads. */
248
+ export const MEASURE_PAGE = 'bunx x build --target static && bunx x verify --only budgets';
249
+
250
+ /** The frame `x new` writes: a resource's page renders inside it when the app has one. */
251
+ export const SHELL_MODULE = 'apps/web/shared/shell.tsx';
252
+
155
253
  /**
156
254
  * The slice's `errors.ts`, for the two generators whose template throws from it. Only those two:
157
255
  * a route or an island names no slice, and the "feature" the fallback derives for them is a path
@@ -167,20 +265,23 @@ async function readSliceErrors(
167
265
  return existsSync(file) ? await Bun.file(file).text() : undefined;
168
266
  }
169
267
 
268
+ /** The generators whose output depends on what the slice's `entity.ts` / `repo.ts` declare. */
269
+ const READS_SLICE: ReadonlySet<Generator> = new Set(['job', 'task', 'action', 'mutator', 'query']);
270
+
170
271
  /**
171
- * `job`, `task`, `action` and `mutator`: the slice's `entity.ts`/`repo.ts` as they stand on disk, absent when the
172
- * generator's kind is neither or the file does not exist yet. `readSliceErrors`'s reason —
173
- * whichever generator reads it decides on THIS app's disk, not on a default the template assumes.
272
+ * The slice's `entity.ts`/`repo.ts` as they stand on disk, absent when the generator's kind reads
273
+ * neither or the file does not exist yet. `readSliceErrors`'s reason — whichever generator reads
274
+ * it decides on THIS app's disk, not on a default the template assumes. `query` reads them for its
275
+ * test alone: whether it may store a row is a fact about the entity an author may have reshaped.
276
+ * Exported so a test can ask which kinds read the disk without running a whole generation.
174
277
  */
175
- async function readSliceFile(
278
+ export async function readSliceFile(
176
279
  root: string,
177
280
  kind: Generator,
178
281
  slice: string,
179
282
  name: 'entity.ts' | 'repo.ts',
180
283
  ): Promise<string | undefined> {
181
- if (kind !== 'job' && kind !== 'task' && kind !== 'action' && kind !== 'mutator') {
182
- return undefined;
183
- }
284
+ if (!READS_SLICE.has(kind)) return undefined;
184
285
  const file = containedPath(root, `${slice}/${name}`);
185
286
  return existsSync(file) ? await Bun.file(file).text() : undefined;
186
287
  }
@@ -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
  };