@ultimat3/cli 24.0.0 → 25.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.
- package/CLAUDE.md +15 -8
- package/README.md +30 -5
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +3 -1
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +3 -3
- package/src/app-env.ts +21 -14
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +2 -2
- package/src/app-mcp.ts +25 -27
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +2 -8
- package/src/app-permissions-site.ts +31 -7
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +115 -121
- package/src/cdp-shot-driver.ts +17 -0
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth.ts +2 -2
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +89 -9
- package/src/cmd-db-backfill.ts +1 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +5 -2
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -357
- package/src/cmd-doctor.ts +16 -66
- package/src/cmd-env.ts +29 -14
- package/src/cmd-generate.ts +12 -2
- package/src/cmd-i18n.ts +11 -8
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +18 -137
- package/src/cmd-mcp.ts +25 -7
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +9 -0
- package/src/cmd-routes.ts +3 -3
- package/src/cmd-shot-matrix.ts +21 -4
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +35 -39
- package/src/cmd-test.ts +1 -1
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +19 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +1 -1
- package/src/dev-dashboard.ts +5 -1
- package/src/dev-lock.ts +96 -24
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +14 -7
- package/src/doctor-auth.ts +4 -4
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +2 -2
- package/src/drift.ts +29 -6
- package/src/error-codes.ts +6 -0
- package/src/error-page-csp.ts +14 -15
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-kinds.ts +3 -17
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +50 -1
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +21 -8
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +54 -64
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +32 -16
- package/src/local-cli-handoff.ts +90 -0
- package/src/mcp-errors.ts +8 -1
- package/src/mcp-host.ts +6 -5
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +27 -16
- package/src/metrics-endpoint.ts +3 -2
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +18 -0
- package/src/page-speculation.ts +14 -11
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +63 -98
- package/src/pwa-artifacts.ts +8 -1
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/retry-memo.ts +1 -1
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +110 -31
- package/src/role-start.ts +64 -85
- package/src/role-sync.ts +65 -6
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +15 -2
- package/src/runtime-bindings.ts +23 -3
- package/src/runtime-cache.ts +8 -8
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +2 -2
- package/src/runtime-jobs.ts +78 -18
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +2 -2
- package/src/runtime-overrides.ts +3 -3
- package/src/runtime-purge.ts +1 -1
- package/src/runtime-queue.ts +12 -12
- package/src/runtime-realtime.ts +5 -3
- package/src/runtime-render.ts +77 -82
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +33 -13
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +3 -3
- package/src/schema-diff.ts +1 -1
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +2 -2
- package/src/serve-boot.ts +22 -11
- package/src/serve-drain.ts +3 -1
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +19 -8
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -15
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-seo.ts +6 -4
- package/src/source-files.ts +13 -3
- package/src/static-report.ts +5 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +1 -1
- package/src/templates/action.ts +4 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +23 -8
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +2 -1
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container.ts +12 -6
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-templates.ts +74 -18
- package/src/templates/scaffold-helm.ts +31 -4
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +18 -15
- package/src/templates/scaffold-shell.ts +17 -2
- package/src/test-only-shipped.ts +193 -0
- package/src/test-select.ts +2 -1
- package/src/test-shards.ts +1 -4
- package/src/theme-boot.ts +2 -2
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/verify-checks.ts +12 -2
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +87 -10
- package/src/verify-merge.ts +48 -16
- package/src/verify-part-step.ts +10 -2
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +3 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +0 -1
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/jobs-drain.ts +0 -230
- package/src/scaffold-typecheck.ts +0 -227
- 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:
|
|
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
|
|
296
|
-
// string
|
|
297
|
-
|
|
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
|
}
|
package/src/cmd-jobs-spec.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
45
|
-
//
|
|
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
|
|
2
|
-
// `@ultimat3/jobs`'s
|
|
3
|
-
//
|
|
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 {
|
|
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 {
|
|
37
|
+
import { flagString } from './parse';
|
|
45
38
|
|
|
46
|
-
export {
|
|
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.
|
|
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
|
-
//
|
|
164
|
-
//
|
|
165
|
-
const
|
|
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
|
-
|
|
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
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
/**
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 {
|
|
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,
|
|
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 `
|
|
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.
|
|
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
|
-
|
|
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
|
};
|
package/src/cmd-planned.ts
CHANGED
|
@@ -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 `
|
|
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 {
|
|
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 =
|
|
100
|
+
const routes = describePages().filter(
|
|
101
101
|
(route) => surface === undefined || route.surface === surface,
|
|
102
102
|
);
|
|
103
103
|
return {
|
package/src/cmd-shot-matrix.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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} · ${
|
|
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
|
})),
|
package/src/cmd-shot-spec.ts
CHANGED
|
@@ -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 ×
|
|
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
|