@ultimat3/cli 24.0.0 → 25.1.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 (243) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +66 -87
  130. package/src/role-sync.ts +65 -6
  131. package/src/role-wake.ts +2 -3
  132. package/src/root-env.ts +157 -28
  133. package/src/route-islands.ts +69 -0
  134. package/src/runtime-admin.ts +4 -1
  135. package/src/runtime-assets.ts +15 -2
  136. package/src/runtime-bindings.ts +23 -3
  137. package/src/runtime-cache.ts +8 -8
  138. package/src/runtime-hooks.ts +2 -1
  139. package/src/runtime-isr-outcome.ts +100 -0
  140. package/src/runtime-isr.ts +2 -2
  141. package/src/runtime-jobs.ts +78 -18
  142. package/src/runtime-live-feed.ts +1 -1
  143. package/src/runtime-mail.ts +26 -0
  144. package/src/runtime-mfa-warning.ts +2 -2
  145. package/src/runtime-overrides.ts +3 -3
  146. package/src/runtime-purge.ts +1 -1
  147. package/src/runtime-queue.ts +19 -30
  148. package/src/runtime-realtime.ts +5 -3
  149. package/src/runtime-render.ts +77 -82
  150. package/src/runtime-replica.ts +3 -8
  151. package/src/runtime-services.ts +36 -15
  152. package/src/runtime-storage-upload.ts +2 -2
  153. package/src/runtime-storage.ts +3 -3
  154. package/src/schema-diff.ts +1 -1
  155. package/src/secrets-rotation.ts +8 -9
  156. package/src/seo-routes.ts +2 -2
  157. package/src/serve-boot.ts +22 -11
  158. package/src/serve-drain.ts +3 -1
  159. package/src/serve-retire.ts +95 -0
  160. package/src/serve-web.ts +19 -8
  161. package/src/serve.ts +27 -10
  162. package/src/server-barrels.ts +6 -3
  163. package/src/shot-browser.ts +1 -1
  164. package/src/shot-locale.ts +6 -15
  165. package/src/shot-settle.ts +3 -3
  166. package/src/shot-theme.ts +1 -1
  167. package/src/shot-viewport.ts +152 -0
  168. package/src/signal-shred.ts +26 -12
  169. package/src/site-asset-routes.ts +2 -2
  170. package/src/site-seo.ts +6 -4
  171. package/src/source-files.ts +13 -3
  172. package/src/static-report.ts +5 -0
  173. package/src/style-routes.ts +2 -2
  174. package/src/sw-artifacts.ts +8 -8
  175. package/src/sw-precache-plan.ts +34 -4
  176. package/src/sync-authenticator.ts +3 -3
  177. package/src/sync-url.ts +1 -1
  178. package/src/templates/action.ts +4 -4
  179. package/src/templates/backfill.ts +4 -4
  180. package/src/templates/entity.ts +2 -2
  181. package/src/templates/github/ci.yml.ts +28 -18
  182. package/src/templates/github/image.yml.ts +92 -0
  183. package/src/templates/github/runner.ts +18 -0
  184. package/src/templates/guard-bare-error.ts +16 -2
  185. package/src/templates/guard-unzoned-date.ts +56 -6
  186. package/src/templates/index.ts +1 -1
  187. package/src/templates/job-body-test.ts +2 -2
  188. package/src/templates/job.ts +6 -6
  189. package/src/templates/query.ts +9 -8
  190. package/src/templates/resource-page.ts +9 -7
  191. package/src/templates/resource-service.ts +2 -2
  192. package/src/templates/route.ts +23 -8
  193. package/src/templates/scaffold-api.ts +2 -3
  194. package/src/templates/scaffold-app.ts +2 -1
  195. package/src/templates/scaffold-auth.ts +205 -70
  196. package/src/templates/scaffold-bin.ts +122 -0
  197. package/src/templates/scaffold-claude-commands.ts +11 -11
  198. package/src/templates/scaffold-container.ts +12 -6
  199. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  200. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  201. package/src/templates/scaffold-dashboard-example.ts +112 -172
  202. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  203. package/src/templates/scaffold-dashboard.ts +9 -5
  204. package/src/templates/scaffold-db-package.ts +2 -2
  205. package/src/templates/scaffold-docs.ts +20 -77
  206. package/src/templates/scaffold-domain-package.ts +0 -4
  207. package/src/templates/scaffold-entries.ts +4 -3
  208. package/src/templates/scaffold-env.ts +6 -3
  209. package/src/templates/scaffold-gitattributes.ts +59 -0
  210. package/src/templates/scaffold-helm-templates.ts +74 -18
  211. package/src/templates/scaffold-helm.ts +31 -4
  212. package/src/templates/scaffold-http.ts +1 -1
  213. package/src/templates/scaffold-i18n.ts +15 -2
  214. package/src/templates/scaffold-icon.ts +2 -2
  215. package/src/templates/scaffold-repo.ts +18 -15
  216. package/src/templates/scaffold-shell.ts +17 -2
  217. package/src/test-only-shipped.ts +193 -0
  218. package/src/test-select.ts +2 -1
  219. package/src/test-shards.ts +1 -4
  220. package/src/theme-boot.ts +2 -2
  221. package/src/theme-brand.ts +66 -0
  222. package/src/trusted-hops.ts +49 -0
  223. package/src/unscanned-admin.ts +2 -2
  224. package/src/verify-checks.ts +12 -2
  225. package/src/verify-coverage-run.ts +3 -2
  226. package/src/verify-deadline.ts +87 -10
  227. package/src/verify-merge.ts +48 -16
  228. package/src/verify-part-step.ts +10 -2
  229. package/src/verify-role-load-probe.ts +12 -2
  230. package/src/verify-role-load.ts +9 -4
  231. package/src/verify-run.ts +3 -0
  232. package/src/verify-step.ts +2 -0
  233. package/src/verify-test-run.ts +3 -2
  234. package/src/verify-tests.ts +0 -1
  235. package/src/worker-bundle.ts +2 -2
  236. package/src/workspace-checks.ts +53 -7
  237. package/src/workspace-graph.ts +3 -2
  238. package/src/write-line.ts +82 -7
  239. package/src/browser-launcher-fake-html.ts +0 -80
  240. package/src/browser-launcher-fake.ts +0 -168
  241. package/src/jobs-drain.ts +0 -230
  242. package/src/scaffold-typecheck.ts +0 -227
  243. package/src/thrown-by.ts +0 -24
package/src/cmd-i18n.ts CHANGED
@@ -185,7 +185,7 @@ async function runAdd(root: string, ctx: CommandContext): Promise<CommandResult>
185
185
  const path = catalogPath(locale);
186
186
  const app = await loadApp(root);
187
187
  const catalogs = await loadCatalogs(root);
188
- const from = resolveDefaultLocale(app.defaultLocale, catalogs);
188
+ const from = resolveDefaultLocale(app.locales.defaultLocale, catalogs);
189
189
  const seeded = seedCatalog(from === undefined ? {} : (catalogs[from] ?? {}));
190
190
  await writeNewCatalog(join(root, path), locale, serializeCatalog(seeded));
191
191
  // The second half of adding a locale, and without it this command SHIPPED A RED GATE: the file
@@ -259,13 +259,15 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
259
259
  }
260
260
 
261
261
  const app = await loadApp(root);
262
- const from = resolveDefaultLocale(app.defaultLocale, catalogs);
262
+ const from = resolveDefaultLocale(app.locales.defaultLocale, catalogs);
263
263
  // `from === locale` is the default locale asked to sync itself, and `undefined` is an app with
264
264
  // no resolvable default at all — one branch, because both have no catalog to merge from.
265
+ // A real merge copies the default locale's strings MARKED (`seedCatalog`, as `x i18n add` does):
266
+ // copied bare they rendered English to every `fr` reader under a green gate.
265
267
  const seeded = from === undefined || from === locale;
266
268
  const source = seeded
267
269
  ? placeholderCatalog(await untranslatedKeys(root, catalogs, locale))
268
- : (catalogs[from] ?? {});
270
+ : seedCatalog(catalogs[from] ?? {});
269
271
  const { merged, added } = syncCatalog(target, source);
270
272
  if (added.length > 0) await Bun.write(join(root, catalogPath(locale)), serializeCatalog(merged));
271
273
  // Unconditional, and not only when keys moved: a catalog that reached disk some other way — a
@@ -282,8 +284,8 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
282
284
  command: 'i18n',
283
285
  summary: msg('cli.i18n.synced', { locale, from: from ?? locale, added: added.length, total }),
284
286
  // The keys themselves, raw — `runCheck` lists gaps the same way, because a key is a value an
285
- // author copies and never prose the catalog owns.
286
- lines: seeded ? added.map((key) => ` ${key}`) : [],
287
+ // author copies and never prose the catalog owns. Every added key is one a human still owes.
288
+ lines: added.map((key) => ` ${key}`),
287
289
  findings,
288
290
  data: {
289
291
  locale,
@@ -292,9 +294,10 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
292
294
  total,
293
295
  path: catalogPath(locale),
294
296
  registered,
295
- // Which of `added` still need a human. Empty on a real merge, where every value is a real
296
- // string copied from the default locale — `--json` must be able to tell the two apart.
297
- placeholders: seeded ? added : [],
297
+ // Which of `added` still need a human: all of them. `⟦key⟧` where there was no catalog to
298
+ // copy from, `⟦<default string>⟧` on a real merge — both are what `x i18n check` counts as
299
+ // missing until replaced, so `--json` never reports a copy as a translation.
300
+ placeholders: added,
298
301
  },
299
302
  };
300
303
  }
@@ -15,17 +15,14 @@ export const JOBS_SUBCOMMANDS = [
15
15
  'drain',
16
16
  ] as const;
17
17
 
18
- /**
19
- * The drivers a drain may move work ONTO — every one of them durable, and that is the whole rule.
20
- * Closed, and read three ways: the flag summary, the refusal, and the `memory` case below.
21
- */
22
- export const DRAIN_TARGETS = ['redis', 'nats'] as const;
23
-
24
18
  export const jobsSpec: CommandSpec = {
25
19
  name: 'jobs',
26
- summary: 'list, show, retry, cancel, remove and promote jobs; pause and resume a queue; drain',
20
+ // Never ENDING in "(planned)": that suffix is how `x help` and the planned-table tests tell a
21
+ // planned COMMAND from a shipped one, and `x jobs` ships — only its `drain` does not.
22
+ summary:
23
+ 'list, show, retry, cancel, remove and promote jobs; pause and resume a queue (drain is planned)',
27
24
  usage:
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]',
25
+ 'x jobs [ls|show <id>|retry <id>|cancel <id>|rm <id>|promote <id>|pause <queue>|resume <queue>|drain] [--queue q] [--state s] [--name n] [--limit n] [--after cursor] [--from-step name] [--reason text] [--json]',
29
26
  requiresApp: true,
30
27
  subcommands: JOBS_SUBCOMMANDS,
31
28
  // The bare `x jobs` lists; it never retries, cancels or drains anything.
@@ -41,8 +38,8 @@ export const jobsSpec: CommandSpec = {
41
38
  summary: 'ls: the next page — the cursor the previous page printed',
42
39
  subcommands: ['ls'],
43
40
  },
44
- // Each of these is read by ONE subcommand — `retryJob`, `cancelJob`, `runDrain` — and says
45
- // so in its own summary. The scope is what makes the parser refuse it anywhere else instead
41
+ // Each of these is read by ONE subcommand — `retryJob`, `cancelJob` — and says so in its own
42
+ // summary. The scope is what makes the parser refuse it anywhere else instead
46
43
  // of accepting it and ignoring it: `x db gen --dry-run` parsed and wrote the migration.
47
44
  {
48
45
  name: 'from-step',
@@ -56,17 +53,5 @@ export const jobsSpec: CommandSpec = {
56
53
  summary: 'cancel: why, recorded on the job',
57
54
  subcommands: ['cancel'],
58
55
  },
59
- {
60
- name: 'to',
61
- type: 'string',
62
- summary: `drain: target driver — ${DRAIN_TARGETS.join(', ')}`,
63
- subcommands: ['drain'],
64
- },
65
- {
66
- name: 'dry-run',
67
- type: 'boolean',
68
- summary: 'drain: report the plan, move nothing',
69
- subcommands: ['drain'],
70
- },
71
56
  ],
72
57
  };
package/src/cmd-jobs.ts CHANGED
@@ -1,6 +1,6 @@
1
- // `x jobs ls|show|retry|cancel|rm|promote|pause|resume|drain` — introspect and recover the job queue, bound to
2
- // `@ultimat3/jobs`'s
3
- // own introspection so the CLI, `/_x` and MCP report identically. This file is CLI wiring only:
1
+ // `x jobs ls|show|retry|cancel|rm|promote|pause|resume` — introspect and recover the job queue,
2
+ // bound to `@ultimat3/jobs`'s own introspection so the CLI, `/_x` and MCP report identically;
3
+ // `drain` is planned (`cmd-planned.ts`): Postgres is the one durable driver. 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
 
@@ -8,10 +8,6 @@ import { renderFixShellArg } from '@ultimat3/core';
8
8
  import type { JobDriver } from '@ultimat3/jobs';
9
9
  import {
10
10
  cancelJob,
11
- createNatsDriver,
12
- createRedisDriver,
13
- DEFAULT_JOB_PAGE,
14
- jobCursor,
15
11
  MAX_JOB_PAGE,
16
12
  pauseQueue,
17
13
  promoteJob,
@@ -20,18 +16,15 @@ import {
20
16
  } from '@ultimat3/jobs';
21
17
  import { loadApp } from './app-load';
22
18
  import { requireAppRoot } from './app-root';
23
- import { DRAIN_TARGETS, jobsSpec } from './cmd-jobs-spec';
19
+ import { jobsSpec } from './cmd-jobs-spec';
20
+ import { plannedSubcommand } from './cmd-planned';
24
21
  import type { CliCommand, CommandContext } from './command';
25
22
  import { BadFlagError, JobUnknownError, MissingPositionalError } from './errors';
26
- import type { DrainOutcome } from './jobs-drain';
27
- import { drainJobs } from './jobs-drain';
28
23
  import { withJobDriver } from './jobs-driver';
29
24
  import {
30
25
  backfillToJson,
31
26
  deadLetterToJson,
32
27
  depthToJson,
33
- drainFailureToJson,
34
- drainSkipToJson,
35
28
  jobRecordToJson,
36
29
  jobTraceToJson,
37
30
  pausedToJson,
@@ -41,30 +34,9 @@ import { listJobs, retryJob, showJob } from './jobs-report';
41
34
  import { renderJobTable } from './jobs-table';
42
35
  import { msg } from './messages';
43
36
  import type { CommandResult } from './output';
44
- import { flagBool, flagString } from './parse';
37
+ import { flagString } from './parse';
45
38
 
46
- export { DRAIN_TARGETS, JOBS_SUBCOMMANDS } from './cmd-jobs-spec';
47
-
48
- /**
49
- * `memory` was on that list until 2026-09 and could not be: `createMemoryDriver()` is a `Map` in
50
- * THIS process, so `x jobs drain --to memory` enqueued each job into it and then `ack`ed the
51
- * durable row off the source. Reproduced against two real drivers — source ready 1 -> 0, target
52
- * ready 1, `ok: true` — and the target dies with the command. `wiki/CLI-Reference.md` said the
53
- * crash window "duplicates a job … instead of losing it"; this target lost every one of them.
54
- *
55
- * Refused by NAME rather than folded into the unknown-value message, for `cmd-deploy.ts`'s
56
- * `readMethod` reason: `--to memory` is a spelling that used to work, so a reader who types it is
57
- * owed the fact that it moved work into a process that is about to exit, not a list of words.
58
- */
59
- function refuseMemoryTarget(): never {
60
- throw new BadFlagError({
61
- flag: 'to',
62
- command: 'jobs',
63
- reason:
64
- 'memory is a Map inside this process — the drain would ack every durable row and lose the copy when the command exits',
65
- fix: 'x jobs drain --to redis --json # or --to nats; --dry-run reports the plan and moves nothing',
66
- });
67
- }
39
+ export { JOBS_SUBCOMMANDS } from './cmd-jobs-spec';
68
40
 
69
41
  /**
70
42
  * A uuid in any spelling Postgres' `uuid` input accepts: canonical, upper-case, unhyphenated,
@@ -114,42 +86,10 @@ function refuseOversizedPage(limit: string | undefined): void {
114
86
  command: 'jobs',
115
87
  reason: `one page holds at most ${MAX_JOB_PAGE} jobs`,
116
88
  // 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`,
89
+ fix: `x jobs ls --limit ${renderFixShellArg(String(MAX_JOB_PAGE), '200')} --json # then pass its data.nextCursor as --after while data.hasMore is true`,
118
90
  });
119
91
  }
120
92
 
121
- function requireEnvUrl(env: CommandContext['env'], name: string, target: string): string {
122
- const value = env[name];
123
- if (value === undefined || value.trim().length === 0) {
124
- throw new BadFlagError({
125
- flag: 'to',
126
- command: 'jobs',
127
- reason: `--to ${target} needs ${name} set in the environment`,
128
- });
129
- }
130
- return value;
131
- }
132
-
133
- /**
134
- * `redis`/`nats` are honest `X_NOT_IMPLEMENTED` stubs in `@ultimat3/jobs` — building one here is
135
- * fine even though every `enqueue` on it will fail; `drainJobs` reports that per record. What is
136
- * NOT fine is a target that accepts every enqueue and then vanishes, which is why `memory` is
137
- * refused first and by name rather than falling into the closed-set message below.
138
- * Exported so a test can drive the `--to`/env-var validation without a driver or a boot.
139
- */
140
- export function buildDrainTarget(to: string | undefined, env: CommandContext['env']): JobDriver {
141
- if (to === 'memory') refuseMemoryTarget();
142
- if (to === undefined || !(DRAIN_TARGETS as readonly string[]).includes(to)) {
143
- throw new BadFlagError({
144
- flag: 'to',
145
- command: 'jobs',
146
- reason: `expects one of: ${DRAIN_TARGETS.join(', ')}`,
147
- });
148
- }
149
- if (to === 'redis') return createRedisDriver({ url: requireEnvUrl(env, 'REDIS_URL', 'redis') });
150
- return createNatsDriver({ servers: [requireEnvUrl(env, 'NATS_URL', 'nats')] });
151
- }
152
-
153
93
  async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
154
94
  const limit = flagString(ctx.args, 'limit');
155
95
  refuseOversizedPage(limit);
@@ -160,11 +100,9 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
160
100
  limit,
161
101
  after: flagString(ctx.args, 'after'),
162
102
  });
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);
103
+ // `nextCursor` exists exactly when a row past this page does (`listJobs` asks the queue), so a
104
+ // full last page prints no "next page" a caller would follow to an empty one.
105
+ const next = result.nextCursor;
168
106
  const [paused, workers] = await Promise.all([
169
107
  driver.introspect?.pausedQueues() ?? [],
170
108
  driver.introspect?.workers() ?? [],
@@ -215,7 +153,8 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
215
153
  rows: result.rows.map(jobRecordToJson),
216
154
  deadLetters: result.deadLetters.map(deadLetterToJson),
217
155
  backfills: result.backfills.map(backfillToJson),
218
- next,
156
+ nextCursor: result.nextCursor,
157
+ hasMore: result.hasMore,
219
158
  pausedQueues: paused.map(pausedToJson),
220
159
  workers: workers.map(workerToJson),
221
160
  },
@@ -310,59 +249,6 @@ async function runPause(
310
249
  };
311
250
  }
312
251
 
313
- /**
314
- * A skipped candidate is not an error — a job whose `runAt` has not arrived is unclaimable by
315
- * design — so it carries no `X_*` finding. It still fails the command: `x jobs drain` is run to
316
- * empty a driver, and a partial move that exited 0 would read as "the queue is clear".
317
- *
318
- * Exported, and separate from the flag reading above it, because every target the flag now accepts
319
- * needs a server: a test can produce a real outcome from two drivers and render THAT, where
320
- * driving the whole command would need a redis or a nats to move anything at all.
321
- */
322
- export function drainResult(outcome: DrainOutcome): CommandResult {
323
- const dryRun = outcome.dryRun;
324
- const findings = outcome.failures.map((failure) => failure.finding);
325
- const lines: string[] = [];
326
- if (outcome.skipped.length > 0) {
327
- const count = outcome.skipped.length;
328
- lines.push(` ${msg('cli.jobs.skipped', { count, from: outcome.from })}`);
329
- for (const skip of outcome.skipped) {
330
- lines.push(` ${skip.id} ${skip.name} (${skip.queue}) ${skip.state} ${skip.reason}`);
331
- }
332
- }
333
- const partial = outcome.skipped.length > 0;
334
- return {
335
- ok: findings.length === 0 && !partial,
336
- command: 'jobs',
337
- summary: msg(partial ? 'cli.jobs.drainedPartial' : 'cli.jobs.drained', {
338
- count: dryRun ? outcome.candidates.length : outcome.moved.length,
339
- from: outcome.from,
340
- to: outcome.to,
341
- skipped: outcome.skipped.length,
342
- }),
343
- lines,
344
- findings,
345
- data: {
346
- from: outcome.from,
347
- to: outcome.to,
348
- dryRun: outcome.dryRun,
349
- candidates: outcome.candidates.map(jobRecordToJson),
350
- moved: outcome.moved.map(jobRecordToJson),
351
- skipped: outcome.skipped.map(drainSkipToJson),
352
- failures: outcome.failures.map(drainFailureToJson),
353
- },
354
- };
355
- }
356
-
357
- /** The move, then the render. The target is built ABOVE `withJobDriver` — see `run` below. */
358
- async function runDrain(
359
- driver: JobDriver,
360
- target: JobDriver,
361
- ctx: CommandContext,
362
- ): Promise<CommandResult> {
363
- return drainResult(await drainJobs(driver, target, flagBool(ctx.args, 'dry-run')));
364
- }
365
-
366
252
  /** The subcommands that answer a `JobTrace`. `ls`, `rm`, `promote` and the rest read rows only. */
367
253
  const TRACE_SUBCOMMANDS: ReadonlySet<string> = new Set(['show', 'retry', 'cancel']);
368
254
 
@@ -371,13 +257,11 @@ export const jobsCommand: CliCommand = {
371
257
  async run(ctx: CommandContext): Promise<CommandResult> {
372
258
  const root = requireAppRoot('jobs', ctx.cwd).dir;
373
259
  const sub = ctx.args.subcommand ?? 'ls';
374
- // BEFORE `withJobDriver`, which boots the SOURCE queue and pings it. `--to` is a flag, so
375
- // whether it names a durable driver is answerable with no server at all — and reading it
376
- // inside meant `x jobs drain --to memory` on a box whose database is down reported the boot
377
- // failure instead of `X_CLI_BAD_FLAG`, i.e. the operator repaired Postgres to be told the
378
- // word they typed was refused by name. It also opens a connection to a target the command
379
- // then refuses, which is a socket nothing closes.
380
- const target = sub === 'drain' ? buildDrainTarget(flagString(ctx.args, 'to'), ctx.env) : null;
260
+ // BEFORE `withJobDriver`, which boots the source queue: the answer needs no server, so a box
261
+ // whose database is down gets it rather than the boot failure of a queue never to be used —
262
+ // and nothing is leased. 24.x's `--to`/`--dry-run` are deleted, so the parser refuses them
263
+ // (`X_CLI_BAD_FLAG`); only a bare `x jobs drain` reaches this answer.
264
+ if (sub === 'drain') throw plannedSubcommand('jobs', 'drain');
381
265
  // A TRACE is the queue row projected through the job's own declaration — its concurrency key,
382
266
  // its retry schedule — and a declaration exists in this process only once the app is loaded.
383
267
  // Unloaded, `show` answered `concurrencyKey: null` and `retryDelaysMs: []` for every job of
@@ -391,13 +275,10 @@ export const jobsCommand: CliCommand = {
391
275
  if (sub === 'rm') return runRm(driver, ctx);
392
276
  if (sub === 'promote') return runPromote(driver, ctx);
393
277
  if (sub === 'pause' || sub === 'resume') return runPause(driver, ctx, sub);
394
- if (target !== null) return runDrain(driver, target, ctx);
395
278
  return runLs(driver, ctx);
396
279
  });
397
280
  },
398
281
  };
399
282
 
400
- export type { DrainFailure, DrainOutcome, DrainSkip } from './jobs-drain';
401
- export { drainJobs } from './jobs-drain';
402
283
  export type { JobsListFilter, JobsListResult } from './jobs-report';
403
284
  export { listJobs, retryJob, showJob } from './jobs-report';
package/src/cmd-mcp.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  timingSafeEqual,
11
11
  UltimateError,
12
12
  } from '@ultimat3/core';
13
- import { mcpHttpRoute, serveStdio } from '@ultimat3/mcp';
13
+ import { mcpHttpRoute, type StdioTransportInput, serveStdio } from '@ultimat3/mcp';
14
14
  import { requireAppRoot } from './app-root';
15
15
  import { DEFAULT_PORT, mcpSpec } from './cmd-mcp-spec';
16
16
  import type { CliCommand, CommandContext } from './command';
@@ -37,8 +37,12 @@ const isTransport = (value: string): value is Transport =>
37
37
 
38
38
  /** The catalog, from the server's own registry — never a list kept here. */
39
39
  async function catalog(host: CliMcpServer): Promise<CommandResult> {
40
- const tools = host.server.list(host.caller);
41
- await host.close();
40
+ let tools: ReturnType<CliMcpServer['server']['list']>;
41
+ try {
42
+ tools = host.server.list(host.caller);
43
+ } finally {
44
+ await host.close();
45
+ }
42
46
  return {
43
47
  ok: true,
44
48
  command: 'mcp',
@@ -162,10 +166,24 @@ export const stdioResult = (tools: number): CommandResult => ({
162
166
  stream: 'stderr',
163
167
  });
164
168
 
165
- /** stdout is the WIRE: `serveStdio` owns fd 1 for as long as the peer holds stdin open. */
166
- async function serveOverStdio(host: CliMcpServer): Promise<CommandResult> {
167
- await serveStdio({ server: host.server, caller: host.caller });
168
- await host.close();
169
+ /** The pipe a session reads, when it is not this process's own stdin — a test's script. */
170
+ export type StdioPipe = Pick<StdioTransportInput, 'input' | 'write'>;
171
+
172
+ /**
173
+ * stdout is the WIRE: `serveStdio` owns fd 1 for as long as the peer holds stdin open. Exported
174
+ * with `pipe` so a test can end the session with a throw instead of a closed stdin.
175
+ */
176
+ export async function serveOverStdio(
177
+ host: CliMcpServer,
178
+ pipe: StdioPipe = {},
179
+ ): Promise<CommandResult> {
180
+ // `finally`, as `startMcpHttp`'s refused bind does: a session that throws must not leave the
181
+ // host's PGlite data directory held by a process about to report the failure.
182
+ try {
183
+ await serveStdio({ server: host.server, caller: host.caller, ...pipe });
184
+ } finally {
185
+ await host.close();
186
+ }
169
187
  return stdioResult(host.tools.length);
170
188
  }
171
189
 
package/src/cmd-new.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  // Interactive-free: every choice is a flag with a default, because an agent cannot answer prompts.
4
4
 
5
5
  import { existsSync } from 'node:fs';
6
- import { chmod } from 'node:fs/promises';
7
- import { isAbsolute, join, resolve } from 'node:path';
6
+ import { isAbsolute, join, relative, resolve } from 'node:path';
8
7
  import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
8
+ import { AppNameInvalidError, appNameIssue } from './app-name';
9
9
  import { dedupe } from './cmd-generate';
10
10
  import { newSpec } from './cmd-new-spec';
11
11
  import type { CliCommand, CommandContext } from './command';
@@ -18,7 +18,7 @@ import type { CommandResult } from './output';
18
18
  import { flagBool, flagString } from './parse';
19
19
  import { quoteArg } from './shell-quote';
20
20
  import type { GeneratedFile } from './templates';
21
- import { appFiles, EXECUTABLE_FILES, names, repoFiles, resourceFiles } from './templates';
21
+ import { appFiles, names, repoFiles, resourceFiles } from './templates';
22
22
  import { loadVersion } from './version-loader';
23
23
 
24
24
  export interface NewAppOptions {
@@ -102,6 +102,15 @@ export async function initRepository(runner: Runner, dir: string): Promise<Repos
102
102
  return { initialized: true, committed: true, problem: null };
103
103
  }
104
104
 
105
+ /** `path` relative to `cwd` when it lies under it, else absolute — never a `../../` walk. */
106
+ const displayPath = (cwd: string, path: string): string => {
107
+ const rel = relative(cwd, path);
108
+ return rel === '' || rel.startsWith('..') || isAbsolute(rel) ? path : rel;
109
+ };
110
+
111
+ /** The `cd` operand of the next-steps line: the target as typed from `cwd`, pasteable verbatim. */
112
+ const cdTarget = (cwd: string, target: string): string => quoteArg(displayPath(cwd, target));
113
+
105
114
  /** Pure: the complete file list for a new app, so `--dry-run` and the test see the same thing. */
106
115
  export function planNewApp(options: NewAppOptions): readonly GeneratedFile[] {
107
116
  const app = names(options.name);
@@ -145,13 +154,12 @@ export interface WrittenApp {
145
154
  * a migration whose snapshot never existed is what made the app's first two database commands
146
155
  * refuse each other — `x db migrate` naming `x db gen`, and `x db gen` refusing a sidecar version
147
156
  * control never had. The consequence is deliberate: `x verify`'s `drift` step is red on a pristine
148
- * scaffold until `x db gen "initial"` runs — which `bin/setup` does, and which is what
157
+ * scaffold until `x db gen "initial"` runs — which `bun run setup` does, and which is what
149
158
  * `cli.new.done` tells the author to run.
150
159
  */
151
160
  export async function writeNewApp(target: string, options: NewAppOptions): Promise<WrittenApp> {
152
161
  const files = planNewApp(options);
153
162
  for (const file of files) await Bun.write(join(target, file.path), file.contents);
154
- for (const path of EXECUTABLE_FILES) await chmod(join(target, path), 0o755);
155
163
  return { dir: target, files: files.map((file) => file.path) };
156
164
  }
157
165
 
@@ -210,6 +218,18 @@ export const newCommand: CliCommand = {
210
218
  flags: callerFlags(ctx),
211
219
  });
212
220
  }
221
+ // After the slug exists and before anything is planned: the slug is `app.config.ts`'s `name`,
222
+ // and a name core's `defineConfig` refuses is an app that throws at its own first import.
223
+ const issue = appNameIssue(app.kebab);
224
+ if (issue !== undefined) {
225
+ throw new AppNameInvalidError({
226
+ name: raw,
227
+ slug: app.kebab,
228
+ issue,
229
+ invocation: invocationOf(ctx, 'new'),
230
+ flags: callerFlags(ctx),
231
+ });
232
+ }
213
233
  const target = resolve(parentDir(ctx.cwd, flagString(ctx.args, 'dir')), app.kebab);
214
234
  const options: NewAppOptions = { name: raw, example: ctx.args.flags.get('example') !== false };
215
235
 
@@ -218,9 +238,17 @@ export const newCommand: CliCommand = {
218
238
  return {
219
239
  ok: true,
220
240
  command: 'new',
221
- summary: msg('cli.new.done', { name: app.kebab }),
241
+ summary: msg('cli.new.dryRun', {
242
+ name: app.kebab,
243
+ dir: target,
244
+ cd: cdTarget(ctx.cwd, target),
245
+ }),
222
246
  data: { dir: target, files: files.map((file) => file.path), dryRun: true },
223
- lines: files.map((file) => msg('cli.file.added', { path: `${app.kebab}/${file.path}` })),
247
+ // Where each file WOULD land, as the caller would reach it: relative to the cwd, `--dir`
248
+ // included — `${app.kebab}/…` named the wrong place whenever `--dir` was set.
249
+ lines: files.map((file) =>
250
+ msg('cli.file.added', { path: displayPath(ctx.cwd, join(target, file.path)) }),
251
+ ),
224
252
  };
225
253
  }
226
254
  if (existsSync(target) && !flagBool(ctx.args, 'force')) {
@@ -263,7 +291,7 @@ export const newCommand: CliCommand = {
263
291
  return {
264
292
  ok: true,
265
293
  command: 'new',
266
- summary: msg('cli.new.done', { name: app.kebab }),
294
+ summary: msg('cli.new.done', { name: app.kebab, cd: cdTarget(ctx.cwd, target) }),
267
295
  data: { dir: written.dir, files: written.files, git },
268
296
  lines,
269
297
  };
@@ -127,6 +127,15 @@ export const PLANNED_SUBCOMMANDS: readonly PlannedSubcommand[] = [
127
127
  subcommand: 'studio',
128
128
  fix: 'x dev # then the db panel at /_x: schema, rows, and a guarded SQL console',
129
129
  },
130
+ // Planned because Postgres is the one durable job driver: there is nowhere to drain to. The two
131
+ // `--to` values, `redis` and `nats`, were `X_NOT_IMPLEMENTED` stubs on every method, so a drain
132
+ // leased the production batch for five minutes, failed each enqueue and nacked it back. 25.0.0
133
+ // deleted both stubs and the unreachable drain body; a real second driver writes a new one.
134
+ {
135
+ command: 'jobs',
136
+ subcommand: 'drain',
137
+ fix: 'x jobs ls --json # the queue as it stands; Postgres is the only durable driver, so there is nowhere to drain to',
138
+ },
130
139
  ];
131
140
 
132
141
  /**
package/src/cmd-routes.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  // `x routes` — the route table as a table, or as JSON. Replaces grepping a router directory, which
2
2
  // is what an agent does when the framework has no answer to "what URLs exist".
3
3
  //
4
- // The rows are `@ultimat3/render`'s own `describeRoutes()`: the CLI prints the route table, it
4
+ // The rows are `@ultimat3/render`'s own `describePages()`: the CLI prints the route table, it
5
5
  // does not keep a second one.
6
6
 
7
7
  import type { RouteDescriptor, Surface } from '@ultimat3/render';
8
- import { describeRoutes, SURFACES } from '@ultimat3/render';
8
+ import { describePages, SURFACES } from '@ultimat3/render';
9
9
  import { loadApp } from './app-load';
10
10
  import { requireAppRoot } from './app-root';
11
11
  import { routesSpec } from './cmd-routes-spec';
@@ -97,7 +97,7 @@ export const routesCommand: CliCommand = {
97
97
  // `--transport` already follows.
98
98
  const surface = readSurfaceFilter(flagString(ctx.args, 'surface'));
99
99
  const { findings } = await loadApp(root);
100
- const routes = describeRoutes().filter(
100
+ const routes = describePages().filter(
101
101
  (route) => surface === undefined || route.surface === surface,
102
102
  );
103
103
  return {
@@ -9,11 +9,13 @@ import { escapeHtml } from '@ultimat3/core';
9
9
  import { routeEntries } from '@ultimat3/render';
10
10
  import { loadApp } from './app-load';
11
11
  import type { ShotRun } from './cmd-shot';
12
+ import { BadFlagError } from './errors';
12
13
  import type { CommandResult } from './output';
13
14
  import { localizedShotPath } from './shot-locale';
14
15
  import type { ShotServer } from './shot-server';
15
16
  import { SHOT_DIR } from './shot-server';
16
17
  import type { ShotArtifacts } from './shot-verdict';
18
+ import { viewportDir } from './shot-viewport';
17
19
 
18
20
  export const MATRIX_DIR = join(SHOT_DIR, 'matrix');
19
21
  export const MATRIX_INDEX = 'index.html';
@@ -21,7 +23,10 @@ export const MATRIX_INDEX = 'index.html';
21
23
  /** The two themes the boot honours from storage. */
22
24
  export const MATRIX_THEMES: readonly ('light' | 'dark')[] = ['light', 'dark'];
23
25
 
24
- /** A phone and a desktop, each with the height its class of device is photographed at. */
26
+ /**
27
+ * A phone and a desktop, each with the height its class of device is photographed at. The default
28
+ * only: `x shot --matrix --viewport <w>x<h>[,…]` replaces the pair (`shot-viewport.ts`).
29
+ */
25
30
  export const MATRIX_VIEWPORTS: readonly { readonly width: number; readonly height: number }[] = [
26
31
  { width: 390, height: 844 },
27
32
  { width: 1440, height: 900 },
@@ -35,10 +40,20 @@ export interface MatrixCell {
35
40
  readonly viewport: { readonly width: number; readonly height: number };
36
41
  /** What the browser opens: the route, prefixed for a non-default locale. */
37
42
  readonly path: string;
38
- /** Relative to the matrix directory: `<route-slug>/<locale>/<theme>-<width>`. */
43
+ /** Relative to the matrix directory: `<route-slug>/<locale>/<theme>-<width>x<height>`. */
39
44
  readonly dir: string;
40
45
  }
41
46
 
47
+ /** The matrix photographs ROUTES; `--island`/`--all-islands` beside it is two subjects. */
48
+ export function refuseMatrixWithComponent(): never {
49
+ throw new BadFlagError({
50
+ flag: 'matrix',
51
+ command: 'shot',
52
+ reason: 'photographs every site route; --island and --all-islands photograph components',
53
+ fix: 'x shot --matrix --json',
54
+ });
55
+ }
56
+
42
57
  /** A route a matrix can photograph without inventing a parameter: no `:param`, no `*rest`. */
43
58
  export const isMatrixRoute = (path: string): boolean => !/[:*[]/.test(path);
44
59
 
@@ -79,7 +94,8 @@ export function planShotMatrix(input: MatrixPlanInput): readonly MatrixCell[] {
79
94
  theme,
80
95
  viewport,
81
96
  path: localizedShotPath(route, locale, input.defaultLocale),
82
- dir: join(slug(route), locale, `${theme}-${String(viewport.width)}`),
97
+ // The whole size, never the width alone: `--viewport 390x844,390x664` is two pictures.
98
+ dir: join(slug(route), locale, `${theme}-${viewportDir(viewport)}`),
83
99
  });
84
100
  }
85
101
  }
@@ -106,7 +122,7 @@ export function contactSheet(shots: readonly MatrixShot[]): string {
106
122
  .filter((shot) => shot.cell.route === route)
107
123
  .map((shot) => {
108
124
  const { locale, theme, viewport, path } = shot.cell;
109
- const label = `${locale} · ${theme} · ${String(viewport.width)}px${shot.ok ? '' : ' · FAILED'}`;
125
+ const label = `${locale} · ${theme} · ${viewportDir(viewport)}${shot.ok ? '' : ' · FAILED'}`;
110
126
  return (
111
127
  `<figure${shot.ok ? '' : ' class="failed"'}><a href="${escapeHtml(shot.image)}">` +
112
128
  `<img src="${escapeHtml(shot.image)}" alt="${escapeHtml(`${path} ${label}`)}" loading="lazy" ` +
@@ -191,6 +207,7 @@ export async function runShotMatrix(run: MatrixRun): Promise<CommandResult> {
191
207
  locale: shot.cell.locale,
192
208
  theme: shot.cell.theme,
193
209
  width: shot.cell.viewport.width,
210
+ height: shot.cell.viewport.height,
194
211
  image: shot.image,
195
212
  ok: shot.ok,
196
213
  })),
@@ -7,7 +7,7 @@ export const shotSpec: CommandSpec = {
7
7
  name: 'shot',
8
8
  summary: 'photograph one route, one island in a state it declares, or every island in the app',
9
9
  usage:
10
- 'x shot <route> [--locale <l>] [--theme light|dark] | --matrix [<route>] | --island <name> [--state <id>] | --all-islands [--cookie <name=value>] [--expect-status 404] [--port 0] [--out <dir>] [--settle 2000] [--json]',
10
+ 'x shot <route> [--locale <l>] [--theme light|dark] [--viewport 390x844,1440x900] | --matrix [<route>] | --island <name> [--state <id>] | --all-islands [--cookie <name=value>] [--expect-status 404] [--port 0] [--out <dir>] [--settle 2000] [--json]',
11
11
  requiresApp: true,
12
12
  flags: [
13
13
  { name: 'port', type: 'string', summary: 'dev port (0 lets the kernel pick a free one)' },
@@ -51,7 +51,15 @@ export const shotSpec: CommandSpec = {
51
51
  name: 'matrix',
52
52
  type: 'boolean',
53
53
  summary:
54
- 'every site route × locale × light/dark × 390/1440 px into .x/shot/matrix/, plus an index.html contact sheet',
54
+ 'every site route × locale × light/dark × 390x844/1440x900 (or --viewport) into .x/shot/matrix/, plus an index.html contact sheet',
55
+ },
56
+ // One flag, read as a list, rather than a repeatable one: the parser keeps the last value of a
57
+ // repeated flag, so `--viewport a --viewport b` would photograph `b` alone and say nothing.
58
+ {
59
+ name: 'viewport',
60
+ type: 'string',
61
+ summary:
62
+ '<w>x<h>[,<w>x<h>] — a route once per size into <out>/<w>x<h>/; with --matrix, its sizes',
55
63
  },
56
64
  // A FLAG on `x shot` and never a second command: photographing a route and photographing a
57
65
  // component are one job with two subjects, and a parallel command would be the second path