@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/jobs-json.ts
CHANGED
|
@@ -13,7 +13,6 @@ import type {
|
|
|
13
13
|
StepTrace,
|
|
14
14
|
WorkerRecord,
|
|
15
15
|
} from '@ultimat3/jobs';
|
|
16
|
-
import type { DrainFailure, DrainSkip } from './jobs-drain';
|
|
17
16
|
import type { JsonValue } from './output';
|
|
18
17
|
|
|
19
18
|
function stepTraceToJson(step: StepTrace): JsonValue {
|
|
@@ -181,28 +180,3 @@ export function deadLetterToJson(entry: DeadLetterEntry): JsonValue {
|
|
|
181
180
|
retryCommand: entry.retryCommand,
|
|
182
181
|
};
|
|
183
182
|
}
|
|
184
|
-
|
|
185
|
-
export function drainFailureToJson(failure: DrainFailure): JsonValue {
|
|
186
|
-
return {
|
|
187
|
-
id: failure.id,
|
|
188
|
-
name: failure.name,
|
|
189
|
-
finding: {
|
|
190
|
-
code: failure.finding.code,
|
|
191
|
-
cause: failure.finding.cause,
|
|
192
|
-
fix: failure.finding.fix,
|
|
193
|
-
docs: failure.finding.docs ?? null,
|
|
194
|
-
at: failure.finding.at ?? null,
|
|
195
|
-
},
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** A candidate the drain refused to touch, and why — the half of the outcome `moved` cannot show. */
|
|
200
|
-
export function drainSkipToJson(skip: DrainSkip): JsonValue {
|
|
201
|
-
return {
|
|
202
|
-
id: skip.id,
|
|
203
|
-
name: skip.name,
|
|
204
|
-
queue: skip.queue,
|
|
205
|
-
state: skip.state,
|
|
206
|
-
reason: skip.reason,
|
|
207
|
-
};
|
|
208
|
-
}
|
package/src/jobs-report.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Pure, driver-injected job operations behind `x jobs`: flag parsing, plus ls / show / retry. No
|
|
2
|
-
// CLI parsing, no process I/O, no rendering — a test drives every path with `
|
|
3
|
-
// alone.
|
|
2
|
+
// CLI parsing, no process I/O, no rendering — a test drives every path with `memoryJobDriver()`
|
|
3
|
+
// alone. The `--json` shapes are `jobs-json.ts`, the table `jobs-table.ts`.
|
|
4
4
|
|
|
5
|
+
import type { Page } from '@ultimat3/core';
|
|
6
|
+
import { pageOf } from '@ultimat3/core';
|
|
5
7
|
import type {
|
|
6
8
|
BackfillProgress,
|
|
7
9
|
DeadLetterEntry,
|
|
@@ -13,6 +15,7 @@ import type {
|
|
|
13
15
|
QueueDepthReport,
|
|
14
16
|
} from '@ultimat3/jobs';
|
|
15
17
|
import {
|
|
18
|
+
DEFAULT_JOB_PAGE,
|
|
16
19
|
inspectBackfills,
|
|
17
20
|
inspectDeadLetters,
|
|
18
21
|
inspectJob,
|
|
@@ -20,19 +23,12 @@ import {
|
|
|
20
23
|
inspectQueues,
|
|
21
24
|
isJobState,
|
|
22
25
|
JOB_STATES,
|
|
26
|
+
jobCursor,
|
|
27
|
+
MAX_JOB_PAGE,
|
|
23
28
|
retryFromStep,
|
|
24
29
|
} from '@ultimat3/jobs';
|
|
25
30
|
import { BadFlagError, JobUnknownError } from './errors';
|
|
26
31
|
|
|
27
|
-
/**
|
|
28
|
-
* The queue's own vocabulary, re-exported rather than restated — this file carried a copy of it,
|
|
29
|
-
* and the copy was one member short. `cancelled` shipped in `@ultimat3/jobs` and never here, so
|
|
30
|
-
* `x jobs cancel` created a state `x jobs ls --state cancelled` then refused to filter on: two
|
|
31
|
-
* commands of one CLI disagreeing about what a job can be. Kept on this module's surface because
|
|
32
|
-
* `index.ts` exports it from here.
|
|
33
|
-
*/
|
|
34
|
-
export { JOB_STATES } from '@ultimat3/jobs';
|
|
35
|
-
|
|
36
32
|
export function parseStateFlag(value: string | undefined): JobState | undefined {
|
|
37
33
|
if (value === undefined) return undefined;
|
|
38
34
|
if (isJobState(value)) return value;
|
|
@@ -76,12 +72,32 @@ export interface JobsListFilter {
|
|
|
76
72
|
readonly after?: string | undefined;
|
|
77
73
|
}
|
|
78
74
|
|
|
79
|
-
|
|
75
|
+
/** ONE page of rows — core's `Page`, so `nextCursor` exists exactly when another row does. */
|
|
76
|
+
export type JobsListResult = Page<JobRecord> & {
|
|
80
77
|
readonly depth: QueueDepthReport;
|
|
81
|
-
readonly rows: readonly JobRecord[];
|
|
82
78
|
readonly deadLetters: readonly DeadLetterEntry[];
|
|
83
79
|
/** The sweeps still in flight — see below for why finished ones are not this command's answer. */
|
|
84
80
|
readonly backfills: readonly BackfillProgress[];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One page, and whether a row exists past it — asked of the queue, never guessed from a count.
|
|
85
|
+
* `next = rows.length === limit ? cursor : null` handed a FULL last page a cursor to an empty one.
|
|
86
|
+
* The page reads one row past its limit; at `MAX_JOB_PAGE` there is no room for that row (the
|
|
87
|
+
* queue refuses a bigger page), so a full maximal page asks for the one row after its last.
|
|
88
|
+
*/
|
|
89
|
+
async function jobPage(driver: JobDriver, filter: JobFilter): Promise<Page<JobRecord>> {
|
|
90
|
+
const size = filter.limit ?? DEFAULT_JOB_PAGE;
|
|
91
|
+
const ask = size < MAX_JOB_PAGE ? size + 1 : size;
|
|
92
|
+
const fetched = await inspectJobList(driver, { ...filter, limit: ask });
|
|
93
|
+
const rows = fetched.slice(0, size);
|
|
94
|
+
const last = rows.at(-1);
|
|
95
|
+
if (last === undefined || fetched.length < size) return pageOf(rows, null);
|
|
96
|
+
if (fetched.length > size) return pageOf(rows, jobCursor(last));
|
|
97
|
+
if (ask > size) return pageOf(rows, null);
|
|
98
|
+
const after = jobCursor(last);
|
|
99
|
+
const beyond = await inspectJobList(driver, { ...filter, limit: 1, after });
|
|
100
|
+
return pageOf(rows, beyond.length > 0 ? after : null);
|
|
85
101
|
}
|
|
86
102
|
|
|
87
103
|
/**
|
|
@@ -107,13 +123,13 @@ export async function listJobs(
|
|
|
107
123
|
...(limit === undefined ? {} : { limit }),
|
|
108
124
|
...(filter.after === undefined ? {} : { after: filter.after }),
|
|
109
125
|
};
|
|
110
|
-
const [depth,
|
|
126
|
+
const [depth, page, deadLetters, backfills] = await Promise.all([
|
|
111
127
|
inspectQueues(driver),
|
|
112
|
-
|
|
128
|
+
jobPage(driver, jobFilter),
|
|
113
129
|
inspectDeadLetters(driver),
|
|
114
130
|
inspectBackfills(driver, { status: 'running' }),
|
|
115
131
|
]);
|
|
116
|
-
return {
|
|
132
|
+
return { ...page, depth, deadLetters, backfills };
|
|
117
133
|
}
|
|
118
134
|
|
|
119
135
|
// ── show ──────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Running the app's own CLI in place of this one (`local-cli.ts` decides WHETHER). The parent only
|
|
2
|
+
// waits, so every stop signal it receives is the child's to act on: a supervisor or a closing
|
|
3
|
+
// terminal signals the pid it started, and that pid is this one. `bin.ts` exits with the answer.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The stop signals forwarded to the child at once. SIGINT is NOT one of them: Ctrl-C reaches the
|
|
7
|
+
* whole foreground process group, so the child already has it, and a second one landed inside its
|
|
8
|
+
* cleanup — `x secrets edit` died mid-shred with its decrypted buffer still on disk (audit M1).
|
|
9
|
+
*/
|
|
10
|
+
export const HAND_OFF_SIGNALS = ['SIGTERM', 'SIGHUP'] as const;
|
|
11
|
+
export type HandOffSignal = (typeof HAND_OFF_SIGNALS)[number] | 'SIGINT';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How long a SIGINT is held before it is forwarded. A Ctrl-C the group delivered ends the child
|
|
15
|
+
* well inside it — a shred is milliseconds — so it is never sent twice; a SIGINT aimed at this pid
|
|
16
|
+
* alone (`kill -INT`, a runtime whose STOPSIGNAL is SIGINT) reaches the child a second later
|
|
17
|
+
* instead of never. A child still draining past it gets one more SIGINT, which every drain here
|
|
18
|
+
* already absorbs (`signal-shred.ts` ignores repeats; `x dev`'s stop is armed once).
|
|
19
|
+
*/
|
|
20
|
+
export const INTERRUPT_GRACE_MS = 1000;
|
|
21
|
+
|
|
22
|
+
/** Arms `fire` after `ms`, answering its cancel. Injected by the test, so no verdict waits on a clock. */
|
|
23
|
+
export type Schedule = (fire: () => void, ms: number) => () => void;
|
|
24
|
+
|
|
25
|
+
/** The spawned CLI as the hand-off sees it — `Bun.spawn`'s answer, narrowed so a test can fake it. */
|
|
26
|
+
export interface HandOffChild {
|
|
27
|
+
readonly exited: Promise<number>;
|
|
28
|
+
kill(signal: HandOffSignal): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Untimed by design, and counted under `cli`'s pin in `scripts/lib/spawn-timeout-pins.ts`: the
|
|
32
|
+
// child IS the command the user typed (`x dev` serves until Ctrl-C), so its lifetime is theirs, and
|
|
33
|
+
// the forwarded stop signals below are what end it.
|
|
34
|
+
const spawnInherited = (
|
|
35
|
+
command: readonly string[],
|
|
36
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
37
|
+
): HandOffChild =>
|
|
38
|
+
Bun.spawn([...command], { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', env });
|
|
39
|
+
|
|
40
|
+
const scheduleTimer: Schedule = (fire, ms) => {
|
|
41
|
+
const timer = setTimeout(fire, ms);
|
|
42
|
+
return () => clearTimeout(timer);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface HandOffOptions {
|
|
46
|
+
readonly spawn?: typeof spawnInherited;
|
|
47
|
+
readonly schedule?: Schedule;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Spawns `command`, forwards SIGTERM and SIGHUP to it for as long as it runs — SIGINT only once the
|
|
52
|
+
* grace passes with the child still running — and answers its exit code: `128 + n` when a signal
|
|
53
|
+
* ended it, as `Bun.spawn`'s `exited` reports. `bin.ts` exits with that code.
|
|
54
|
+
*
|
|
55
|
+
* Without the listeners the default action ran: the parent died on the signal and the child —
|
|
56
|
+
* an `x dev` holding a port and an embedded Postgres, an `x db migrate` mid-transaction — ran on
|
|
57
|
+
* as an orphan nothing would stop. A listener is also what keeps the parent alive to report the
|
|
58
|
+
* child's own exit code, rather than 143 before the child had drained.
|
|
59
|
+
*/
|
|
60
|
+
export async function handOff(
|
|
61
|
+
command: readonly string[],
|
|
62
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
63
|
+
options: HandOffOptions = {},
|
|
64
|
+
): Promise<number> {
|
|
65
|
+
const child = (options.spawn ?? spawnInherited)(command, env);
|
|
66
|
+
const schedule = options.schedule ?? scheduleTimer;
|
|
67
|
+
const forwards = HAND_OFF_SIGNALS.map((signal) => {
|
|
68
|
+
const forward = (): void => child.kill(signal);
|
|
69
|
+
process.on(signal, forward);
|
|
70
|
+
return () => process.off(signal, forward);
|
|
71
|
+
});
|
|
72
|
+
// Whether the child already has this SIGINT is unknowable from here, so the grace decides: a
|
|
73
|
+
// repeat inside one window arms nothing new — one Ctrl-C never becomes two forwarded signals.
|
|
74
|
+
let cancelGrace: (() => void) | undefined;
|
|
75
|
+
const holdInterrupt = (): void => {
|
|
76
|
+
if (cancelGrace !== undefined) return;
|
|
77
|
+
cancelGrace = schedule(() => {
|
|
78
|
+
cancelGrace = undefined;
|
|
79
|
+
child.kill('SIGINT');
|
|
80
|
+
}, INTERRUPT_GRACE_MS);
|
|
81
|
+
};
|
|
82
|
+
process.on('SIGINT', holdInterrupt);
|
|
83
|
+
try {
|
|
84
|
+
return await child.exited;
|
|
85
|
+
} finally {
|
|
86
|
+
for (const release of forwards) release();
|
|
87
|
+
process.off('SIGINT', holdInterrupt);
|
|
88
|
+
cancelGrace?.();
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/mcp-errors.ts
CHANGED
|
@@ -69,7 +69,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
69
69
|
'x verify --json # the package-shape finding carries the dependency line to add',
|
|
70
70
|
X_PACKAGE_DUPLICATED:
|
|
71
71
|
'x i18n check --json # the finding names both copies and the package.json to pin',
|
|
72
|
-
X_SHOT_BROWSER_MISSING:
|
|
72
|
+
X_SHOT_BROWSER_MISSING:
|
|
73
|
+
'export CHROME_PATH=/usr/bin/google-chrome # macOS: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; Windows PowerShell: $env:CHROME_PATH = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"',
|
|
73
74
|
X_UI_SHOT_ROUTE_UNKNOWN:
|
|
74
75
|
'x routes --json # then call the ui.* tool with one of its path values',
|
|
75
76
|
X_UI_SHOT_ROUTE_UNBUDGETED:
|
|
@@ -293,6 +294,12 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
293
294
|
X_TRUSTED_PROXY_HOPS_INVALID: 'TRUSTED_PROXY_HOPS=1 ROLE=web bun apps/web/server.ts',
|
|
294
295
|
X_PERMISSION_BORROWED: 'x verify --only policy --json',
|
|
295
296
|
X_ERROR_CODE_UNTHROWN_STALE: 'x errors explain X_ERROR_CODE_UNTHROWN_STALE --json',
|
|
297
|
+
X_DEV_STATE_UNWRITABLE:
|
|
298
|
+
'ls -ld .x # make it writable by this user, on a filesystem that supports hard links, then re-run x dev',
|
|
299
|
+
X_PACKAGE_TEST_ONLY_SHIPPED:
|
|
300
|
+
'bun run x -- verify --only package-shape --json # then rename each listed module to <name>-fixture.ts and update its importers',
|
|
301
|
+
X_ISLAND_MODULE_DUPLICATED:
|
|
302
|
+
'gh issue create --repo developerz-ai/ultimate --title "X_ISLAND_MODULE_DUPLICATED" # paste this error\'s --json as the body: the framework bundled one of its own modules twice',
|
|
296
303
|
};
|
|
297
304
|
|
|
298
305
|
const isCliCode = (code: string): code is CliErrorCode =>
|
package/src/mcp-host.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
agentActor,
|
|
12
12
|
isUltimateError,
|
|
13
13
|
NotImplementedError,
|
|
14
|
+
renderFixShellArg,
|
|
14
15
|
renderThrowable,
|
|
15
16
|
UltimateError,
|
|
16
17
|
} from '@ultimat3/core';
|
|
@@ -34,8 +35,8 @@ import type {
|
|
|
34
35
|
VerifyResult,
|
|
35
36
|
VerifyStep,
|
|
36
37
|
} from '@ultimat3/mcp';
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
38
|
+
import { DEV_SCOPES, devHost, devMcpServer, frameworkIntrospection } from '@ultimat3/mcp';
|
|
39
|
+
import { describePages } from '@ultimat3/render';
|
|
39
40
|
import { loadApp } from './app-load';
|
|
40
41
|
import { appManifest, policyFacts } from './app-manifest';
|
|
41
42
|
import { runVerify, VERIFY_STEPS } from './cmd-verify';
|
|
@@ -293,7 +294,7 @@ function capabilities(
|
|
|
293
294
|
fix:
|
|
294
295
|
role === undefined
|
|
295
296
|
? `x dev > ${path} 2>&1`
|
|
296
|
-
: `mkdir -p ${join(dir, 'logs')} && x dev --role ${role} > ${path} 2>&1`,
|
|
297
|
+
: `mkdir -p ${renderFixShellArg(join(dir, 'logs'), '<the logs dir of the path the cause names>')} && x dev --role ${role} > ${path} 2>&1`,
|
|
297
298
|
});
|
|
298
299
|
}
|
|
299
300
|
return (await file.text()).trimEnd().split('\n').slice(-lines);
|
|
@@ -348,10 +349,10 @@ export async function createDevMcpServer(input: DevHostInput): Promise<CliMcpSer
|
|
|
348
349
|
const lazy = lazyServices(input);
|
|
349
350
|
const ui = uiCapabilities({ root: input.root, env: input.env });
|
|
350
351
|
const introspection = frameworkIntrospection({
|
|
351
|
-
routes: () =>
|
|
352
|
+
routes: () => describePages(),
|
|
352
353
|
policies: () => policyFacts(),
|
|
353
354
|
});
|
|
354
|
-
const server =
|
|
355
|
+
const server = devMcpServer({ host: devHost(introspection, capabilities(input, lazy, ui)) });
|
|
355
356
|
const caller = localCaller();
|
|
356
357
|
return {
|
|
357
358
|
server,
|
package/src/mcp-ui-inspect.ts
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import type { UiInspectInput, UiInspectResult, UiInspectSelector } from '@ultimat3/mcp';
|
|
13
13
|
import { UI_INSPECT_LIMITS } from '@ultimat3/mcp';
|
|
14
|
+
import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
|
|
14
15
|
import type { AxNode, ShotDriver, ShotPage } from './browser-launcher-port';
|
|
15
16
|
import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
|
|
16
|
-
import {
|
|
17
|
+
import { runShot, SHOT_DIR, shotSlug } from './cmd-shot';
|
|
17
18
|
import type { ShotServer } from './shot-server';
|
|
18
19
|
import type { ShotVerdict } from './shot-verdict';
|
|
19
20
|
import type { InspectProbe } from './ui-inspect-probe';
|
|
@@ -144,7 +145,7 @@ export async function inspectRoute(
|
|
|
144
145
|
outDir,
|
|
145
146
|
driver,
|
|
146
147
|
boot: deps.boot,
|
|
147
|
-
settleMs:
|
|
148
|
+
settleMs: IDLE_HYDRATE_TIMEOUT_MS,
|
|
148
149
|
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
149
150
|
fullPage: true,
|
|
150
151
|
colorScheme: input.colorScheme,
|
package/src/mcp-ui-interact.ts
CHANGED
|
@@ -14,9 +14,10 @@ import { join } from 'node:path';
|
|
|
14
14
|
import { isUltimateError, toUltimateError, UltimateError } from '@ultimat3/core';
|
|
15
15
|
import type { UiInteractInput, UiInteractResult, UiInteractStepResult } from '@ultimat3/mcp';
|
|
16
16
|
import { UI_INTERACT_LIMITS } from '@ultimat3/mcp';
|
|
17
|
+
import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
|
|
17
18
|
import type { ShotPage } from './browser-launcher-port';
|
|
18
19
|
import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
|
|
19
|
-
import {
|
|
20
|
+
import { runShot, SHOT_DIR, shotSlug } from './cmd-shot';
|
|
20
21
|
import type { InspectDeps, Seen } from './mcp-ui-inspect';
|
|
21
22
|
import { readInspect, selectorsOf } from './mcp-ui-inspect';
|
|
22
23
|
import { SETTLE_POLL_MS } from './shot-settle';
|
|
@@ -265,7 +266,7 @@ export async function interactRoute(
|
|
|
265
266
|
outDir,
|
|
266
267
|
driver,
|
|
267
268
|
boot: deps.boot,
|
|
268
|
-
settleMs:
|
|
269
|
+
settleMs: IDLE_HYDRATE_TIMEOUT_MS,
|
|
269
270
|
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
270
271
|
fullPage: input.fullPage,
|
|
271
272
|
colorScheme: input.colorScheme,
|
package/src/mcp-ui.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
// why: Bun exposes no path-join primitive, and the picture's directory is a path an agent opens.
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
-
import { UltimateError } from '@ultimat3/core';
|
|
13
|
+
import { finiteCount, UltimateError } from '@ultimat3/core';
|
|
14
14
|
import type {
|
|
15
15
|
UiDiffInput,
|
|
16
16
|
UiDiffResult,
|
|
@@ -23,11 +23,11 @@ import type {
|
|
|
23
23
|
UiShotInput,
|
|
24
24
|
UiShotResult,
|
|
25
25
|
} from '@ultimat3/mcp';
|
|
26
|
-
import {
|
|
26
|
+
import { describePages, IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
|
|
27
27
|
import { appBrowser } from './browser-launcher';
|
|
28
28
|
import type { ShotDriver } from './browser-launcher-port';
|
|
29
29
|
import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
|
|
30
|
-
import {
|
|
30
|
+
import { readRoute, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
|
|
31
31
|
import { islandShot } from './cmd-shot-island';
|
|
32
32
|
import { islandVerdictJson } from './island-verdict';
|
|
33
33
|
import { diffShots } from './mcp-ui-diff';
|
|
@@ -95,7 +95,7 @@ export interface UiHostInput {
|
|
|
95
95
|
readonly driver?: ((viewport: UiShotInput['viewport']) => Promise<ShotDriver>) | undefined;
|
|
96
96
|
/** Injected by a test: the route table, in place of the registry. */
|
|
97
97
|
readonly routes?: (() => readonly DeclaredRoute[]) | undefined;
|
|
98
|
-
/** Injected by a test: the settle wait, in place of `
|
|
98
|
+
/** Injected by a test: the settle wait, in place of `IDLE_HYDRATE_TIMEOUT_MS` (a real sleep per call). */
|
|
99
99
|
readonly settleMs?: number | undefined;
|
|
100
100
|
}
|
|
101
101
|
|
|
@@ -127,7 +127,7 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
127
127
|
// The same choice `x shot` makes from the environment: `PUPPETEER_EXECUTABLE_PATH`, a
|
|
128
128
|
// provider's CDP URL, or the launcher's own discovery.
|
|
129
129
|
const browser = () => shotBrowserChoice({ cdpFlag: undefined, browserFlag: undefined, env });
|
|
130
|
-
const routes = input.routes ??
|
|
130
|
+
const routes = input.routes ?? describePages;
|
|
131
131
|
// One browser per call, sized to the call: the injected driver in a test, `appBrowser` otherwise.
|
|
132
132
|
const driverFor = async (viewport: UiShotInput['viewport']): Promise<ShotDriver> => {
|
|
133
133
|
if (input.driver !== undefined) return input.driver(viewport);
|
|
@@ -165,7 +165,8 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
165
165
|
outDir,
|
|
166
166
|
driver,
|
|
167
167
|
boot,
|
|
168
|
-
|
|
168
|
+
// An agent's argument: a NaN or negative settle would wait forever or not at all.
|
|
169
|
+
settleMs: finiteCount('ui.shot', 'settleMs', input.settleMs ?? IDLE_HYDRATE_TIMEOUT_MS),
|
|
169
170
|
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
170
171
|
fullPage: shot.fullPage,
|
|
171
172
|
colorScheme: shot.colorScheme,
|
|
@@ -199,7 +200,7 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
199
200
|
root,
|
|
200
201
|
island: island.island,
|
|
201
202
|
...(island.state === undefined ? {} : { state: island.state }),
|
|
202
|
-
settleMs: input.settleMs ??
|
|
203
|
+
settleMs: finiteCount('ui.island', 'settleMs', input.settleMs ?? IDLE_HYDRATE_TIMEOUT_MS),
|
|
203
204
|
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
204
205
|
...(executablePath === undefined ? {} : { executablePath }),
|
|
205
206
|
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
package/src/measure-scope.ts
CHANGED
|
@@ -7,13 +7,8 @@
|
|
|
7
7
|
// `withInProcessFetch`, because no server is listening during a build (plan 101 slice 11 m).
|
|
8
8
|
|
|
9
9
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
10
|
-
import { measurementActor, runWithContext, withInProcessFetch } from '@ultimat3/core';
|
|
11
|
-
import {
|
|
12
|
-
createPipeline,
|
|
13
|
-
createRequestContext,
|
|
14
|
-
createRouter,
|
|
15
|
-
defineHttpConfig,
|
|
16
|
-
} from '@ultimat3/http';
|
|
10
|
+
import { actorLabel, measurementActor, runWithContext, withInProcessFetch } from '@ultimat3/core';
|
|
11
|
+
import { defineHttpConfig, httpPipeline, httpRouter, requestContext } from '@ultimat3/http';
|
|
17
12
|
import { apiRoutes } from './api-routes';
|
|
18
13
|
|
|
19
14
|
/** One build's measurement scope: its actor, and a runner that renders inside it. */
|
|
@@ -35,15 +30,15 @@ export async function measureScope(input: {
|
|
|
35
30
|
// No rate limit: every request here is this build's own render, and a limiter would need a
|
|
36
31
|
// deployment scope a build does not have (`X_RATE_LIMIT_SCOPE_UNSET`).
|
|
37
32
|
const config = defineHttpConfig({ buildId: input.buildId, rateLimit: { enabled: false } });
|
|
38
|
-
const ctx =
|
|
33
|
+
const ctx = requestContext({
|
|
39
34
|
url: new URL(input.origin),
|
|
40
35
|
method: 'GET',
|
|
41
36
|
role: 'web',
|
|
42
37
|
config,
|
|
43
38
|
});
|
|
44
39
|
ctx.actor = actor;
|
|
45
|
-
const pipeline =
|
|
46
|
-
table:
|
|
40
|
+
const pipeline = httpPipeline({
|
|
41
|
+
table: httpRouter(apiRoutes()),
|
|
47
42
|
config,
|
|
48
43
|
hooks: { authenticate: () => actor },
|
|
49
44
|
});
|
|
@@ -72,3 +67,13 @@ export async function withAppUrl<T>(origin: string, fn: () => Promise<T>): Promi
|
|
|
72
67
|
else process.env['APP_URL'] = before;
|
|
73
68
|
}
|
|
74
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The measurement actor as a refusal names it: core's `actorLabel` (kind, id, tenant) — the one
|
|
73
|
+
* actor format — plus the roles, which a role-gated page asks of it and `actorLabel` omits (#675).
|
|
74
|
+
* Never `facts`: this lands in a report and a gate finding.
|
|
75
|
+
*/
|
|
76
|
+
export function measurementActorLabel(actor: Actor): string {
|
|
77
|
+
const roles = actor.roles.length === 0 ? 'none' : actor.roles.join(', ');
|
|
78
|
+
return `${actorLabel(actor)} (roles: ${roles})`;
|
|
79
|
+
}
|
package/src/messages.ts
CHANGED
|
@@ -86,6 +86,9 @@ const CATALOG = {
|
|
|
86
86
|
'cli.dev.mail.refused': 'mail=refused({detail})',
|
|
87
87
|
'cli.dev.hmr': 'reloaded {file} in {ms}ms',
|
|
88
88
|
'cli.dev.restart': 'restarting: {reason} — a module that defines a primitive holds the old code',
|
|
89
|
+
// The restart's wait, said while it waits: a save never cancels a running job (issue #677).
|
|
90
|
+
'cli.dev.restartWaiting':
|
|
91
|
+
'restart waits for {count} running job(s) to finish — no new job is claimed; Ctrl-C stops now (a job still running is cut off at the drain deadline and requeued)',
|
|
89
92
|
// A hard kill leaves the lock behind and that is normal, not a fault — worth one line so a
|
|
90
93
|
// reader knows why the boot paused, and never a finding.
|
|
91
94
|
'cli.dev.staleLock': 'cleared a stale dev.lock — the previous x dev did not shut down cleanly',
|
|
@@ -143,9 +146,6 @@ const CATALOG = {
|
|
|
143
146
|
'cli.jobs.deadLetters': '{count} dead letter(s):',
|
|
144
147
|
'cli.jobs.depth':
|
|
145
148
|
'{ready} ready · {running} running · {delayed} delayed · {dead} dead across {queues} queue(s)',
|
|
146
|
-
'cli.jobs.drained': 'drained {count} job(s) from {from} to {to}',
|
|
147
|
-
'cli.jobs.drainedPartial':
|
|
148
|
-
'drained {count} job(s) from {from} to {to} — {skipped} left on {from}',
|
|
149
149
|
'cli.jobs.listed': '{count} job(s)',
|
|
150
150
|
'cli.jobs.nextPage': 'more — next page: x jobs ls --after {cursor}',
|
|
151
151
|
'cli.jobs.noError': 'no error recorded',
|
|
@@ -156,24 +156,30 @@ const CATALOG = {
|
|
|
156
156
|
'cli.jobs.resumed': 'queue {queue} resumed',
|
|
157
157
|
'cli.jobs.retried': 'job {id} re-queued — {state}',
|
|
158
158
|
'cli.jobs.shown': 'job {id} — {state}, attempt {attempt} of {attempts}',
|
|
159
|
-
'cli.jobs.skipped': '{count} job(s) left on {from} — re-run the drain once each is claimable:',
|
|
160
159
|
'cli.manifest.blocked': 'manifest not written — {count} module(s) did not load',
|
|
161
160
|
'cli.manifest.fresh': 'manifest is fresh',
|
|
162
161
|
'cli.manifest.stale': 'manifest is stale',
|
|
163
162
|
'cli.manifest.wrote': 'manifest written to {path} ({routes} routes, {actions} actions)',
|
|
164
163
|
'cli.mcp.serving': 'mcp {transport} serving {tools} tools',
|
|
165
164
|
'cli.mcp.scopes': ' scopes {scopes}',
|
|
166
|
-
// `
|
|
167
|
-
// and it is the only spelling that is right on a fresh clone — it
|
|
168
|
-
// `.env.development.local`, runs `x db gen "initial"` (the scaffold writes no
|
|
169
|
-
// drift step is red until it has), migrates and seeds. The four-command line
|
|
170
|
-
// `x dev` off a tree where nothing had installed the CLI yet, and skipped the
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
165
|
+
// `bun run setup` and nothing else: the scaffold ships it (`bin/setup.ts`), `README.md` and
|
|
166
|
+
// `bun run check` both name it, and it is the only spelling that is right on a fresh clone — it
|
|
167
|
+
// installs, writes `.env.development.local`, runs `x db gen "initial"` (the scaffold writes no
|
|
168
|
+
// migration, so the drift step is red until it has), migrates and seeds. The four-command line
|
|
169
|
+
// this replaced named `x dev` off a tree where nothing had installed the CLI yet, and skipped the
|
|
170
|
+
// seed entirely.
|
|
171
|
+
// `bun run dev`, never `x dev`: `bun install` links the binary into `./node_modules/.bin` and
|
|
172
|
+
// nowhere else, so a bare `x` is not on PATH in the shell it is pasted into (proved with
|
|
173
|
+
// `env -i PATH=… command -v x`); `bun run` puts that directory on PATH for the script. And
|
|
174
|
+
// `bun run`, never a `bin/` path: Bun runs the line identically in PowerShell, cmd and bash.
|
|
175
|
+
// `{cd}` is the resolved target as the caller's shell reaches it — `--dir` included, relative to
|
|
176
|
+
// the cwd when under it, shell-quoted (`cmd-new.ts`'s `cdTarget`). `cd {name}` entered the wrong
|
|
177
|
+
// directory, or none, whenever `--dir` put the app somewhere else.
|
|
178
|
+
'cli.new.done': 'created {name} — next: cd {cd} && bun run setup && bun run dev',
|
|
179
|
+
// `--dry-run`'s own summary: nothing landed, so "created" was a claim an agent branching on the
|
|
180
|
+
// summary would act on. `{dir}` is the resolved target, `--dir` included.
|
|
181
|
+
'cli.new.dryRun':
|
|
182
|
+
'would create {name} in {dir} — nothing written; after a real run: cd {cd} && bun run setup && bun run dev',
|
|
177
183
|
// The two prose lines of `x new`'s report. The `run: cd … && git init …` line beneath the second
|
|
178
184
|
// one stays inline in `cmd-new.ts`: it is an instruction to paste verbatim, and a translated
|
|
179
185
|
// command is a broken one — the same split `Finding.fix` already makes.
|
|
@@ -235,6 +241,10 @@ const CATALOG = {
|
|
|
235
241
|
'cli.shot.pageError': ' threw {message} {at}',
|
|
236
242
|
'cli.shot.picture': ' picture {path}',
|
|
237
243
|
'cli.shot.verdict': ' verdict {path}',
|
|
244
|
+
// `--viewport`: one route at several sizes. The summary is ONE size's verdict, so it says which
|
|
245
|
+
// size; each size line leads with its size and the per-size detail lines sit indented beneath.
|
|
246
|
+
'cli.shot.viewport.summary': '{size}: {summary}',
|
|
247
|
+
'cli.shot.viewport.line': '{size} {summary}',
|
|
238
248
|
// `--island`. A component capture reports per STATE, so the summary counts pictures and the
|
|
239
249
|
// lines name one state each; nothing here restates a fact `--json` does not carry.
|
|
240
250
|
'cli.shot.island.ok':
|
|
@@ -304,7 +314,7 @@ const CATALOG = {
|
|
|
304
314
|
// A skipped step is not a passed one, so the two counts never share a sentence — and the skipped
|
|
305
315
|
// ones are named, because "which suite has nothing to run here?" is the question a green gate
|
|
306
316
|
// over a missing suite has to answer on its own line. Whole sentences per case rather than a
|
|
307
|
-
// clause the caller glues on
|
|
317
|
+
// clause the caller glues on: a catalog clause cannot know the sentence it lands in.
|
|
308
318
|
'cli.verify.passSkipped':
|
|
309
319
|
'{passed} of {count} steps passed in {ms}ms — {skipped} skipped: {names}',
|
|
310
320
|
'cli.verify.failSkipped': '{failed} of {count} steps failed — {skipped} skipped: {names}',
|
|
@@ -315,6 +325,7 @@ const CATALOG = {
|
|
|
315
325
|
// tests ran. Each test's own name carries the reason it skipped, and `bun test` prints only the
|
|
316
326
|
// counts — so the line says how many and `x test <type>` prints the names.
|
|
317
327
|
'cli.verify.allSkipped': 'found {skipped} test(s) and every one skipped itself',
|
|
328
|
+
'cli.verify.warnings': '{count} warning(s) — --verbose or --json lists them',
|
|
318
329
|
// The other shape the same counts can take: a suite that reported no test at all, skipped ones
|
|
319
330
|
// included. Its own line and not `allSkipped` with a zero, because the repair differs — there is
|
|
320
331
|
// no skipped test to read a reason off, so the files the step selected hold no test.
|
package/src/metrics-endpoint.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
UltimateError,
|
|
19
19
|
} from '@ultimat3/core';
|
|
20
20
|
import { neighbouringPort } from './flag-number';
|
|
21
|
+
import { quoteArg } from './shell-quote';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* A port of its own, and NOT the role's HTTP port, for one reason the chart makes concrete:
|
|
@@ -48,7 +49,7 @@ export class MetricsPortInUseError extends UltimateError {
|
|
|
48
49
|
super({
|
|
49
50
|
code: 'X_PORT_IN_USE',
|
|
50
51
|
cause: `the metrics port ${input.port} is already bound, so no role could open its scrape listener`,
|
|
51
|
-
fix: `METRICS_PORT=${neighbouringPort(input.port)} x dev --json`,
|
|
52
|
+
fix: `METRICS_PORT=${quoteArg(String(neighbouringPort(input.port)))} x dev --json`,
|
|
52
53
|
});
|
|
53
54
|
}
|
|
54
55
|
}
|
|
@@ -114,7 +115,7 @@ export function bindScrapePort<T>(
|
|
|
114
115
|
logger.warn('ultimate metrics port taken', {
|
|
115
116
|
port,
|
|
116
117
|
cause: `the metrics port ${String(port)} is already bound — another x dev, or a Prometheus — so this process listens on a free one`,
|
|
117
|
-
fix: `METRICS_PORT=${String(neighbouringPort(port))} x dev --json`,
|
|
118
|
+
fix: `METRICS_PORT=${quoteArg(String(neighbouringPort(port)))} x dev --json`,
|
|
118
119
|
});
|
|
119
120
|
return bind(0);
|
|
120
121
|
}
|
|
@@ -24,6 +24,6 @@ export function migrationExtensions(migrations: readonly Migration[]): readonly
|
|
|
24
24
|
return [...names].sort();
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/** The same list for an app on disk — what an embedded boot hands `
|
|
27
|
+
/** The same list for an app on disk — what an embedded boot hands `pgliteClient`. */
|
|
28
28
|
export const appExtensions = async (root: string): Promise<readonly string[]> =>
|
|
29
29
|
migrationExtensions(await readMigrations(root));
|
package/src/otlp-export.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const METRIC_EXPORT_INTERVAL_MS = 60_000;
|
|
|
36
36
|
* merge into process-global state, so stopping the timer and dropping the drain hooks left the
|
|
37
37
|
* first boot's exporter configured: a second `serveApp` in the same process exported its spans
|
|
38
38
|
* into a released exporter — queued against a collector nothing will flush to, on a timer nothing
|
|
39
|
-
* clears. `
|
|
39
|
+
* clears. `dev-boot.ts`'s `stop()` hands back `noopExporter` for exactly this reason. Per signal and
|
|
40
40
|
* never unconditionally: `x dev` configures a trace RECORDER before calling this, and a boot that
|
|
41
41
|
* installed no exporter must not uninstall one it never owned.
|
|
42
42
|
*/
|
|
@@ -52,7 +52,7 @@ export function startOtlpExport(env: Env = process.env): () => void {
|
|
|
52
52
|
const exporter = otlpSpanExporter({ endpoint: traces });
|
|
53
53
|
configureTelemetry({ exporter });
|
|
54
54
|
// Pushed first, so the reversed run below applies it LAST — after the drain hook is dropped,
|
|
55
|
-
// the same order `
|
|
55
|
+
// the same order `dev-boot.ts` releases the recorder in.
|
|
56
56
|
releases.push(() => configureTelemetry({ exporter: noopExporter }));
|
|
57
57
|
releases.push(onShutdown('otlp-traces', () => exporter.shutdown(), { phase: 'close' }));
|
|
58
58
|
logger.info('ultimate otlp traces', { endpoint: traces });
|
package/src/output.ts
CHANGED
|
@@ -50,6 +50,12 @@ export interface StepResult {
|
|
|
50
50
|
* coverage is `data.coverage.unit`), where `--json` and the terminal both always have it.
|
|
51
51
|
*/
|
|
52
52
|
readonly output?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Advice that does not fail the step — a file inside `filesize`'s band below the ceiling. Unlike
|
|
55
|
+
* `output`, carried by both renderers on a PASS too: advice a green run hides reaches nobody
|
|
56
|
+
* until it has become the failure it warns about. One line of text per warning.
|
|
57
|
+
*/
|
|
58
|
+
readonly warnings?: readonly string[];
|
|
53
59
|
/** Worker processes the step used; `1` means it ran serially. Absent for a non-test step. */
|
|
54
60
|
readonly workers?: number;
|
|
55
61
|
/** Why the width is what it is — `4 workers (budget 4.0 GB)`. */
|
|
@@ -230,6 +236,14 @@ export function renderHuman(result: CommandResult, verbose = false): string {
|
|
|
230
236
|
` ${mark(step)} ${step.name.padEnd(18)} ${step.durationMs}ms${width(step)}${why(step)}`,
|
|
231
237
|
);
|
|
232
238
|
for (const finding of step.findings) out.push(renderFinding(finding, ' '));
|
|
239
|
+
// Escaped like every other line text reaches fd 1 through: a warning names a file path. A
|
|
240
|
+
// green terminal run shows the count, not the list: `filesize`'s band alone is ~150 lines,
|
|
241
|
+
// and a wall of advice on every run is read by nobody. `--json` always carries the list.
|
|
242
|
+
const warnings = step.warnings ?? [];
|
|
243
|
+
if (verbose) for (const warning of warnings) out.push(` ! ${singleLine(warning)}`);
|
|
244
|
+
else if (warnings.length > 0) {
|
|
245
|
+
out.push(` ! ${msg('cli.verify.warnings', { count: warnings.length })}`);
|
|
246
|
+
}
|
|
233
247
|
// NOT escaped, and that is the one exception: `output` is this process's own captured
|
|
234
248
|
// subprocess stdout — `bun test`'s colour is the reason a human reads it at all, and it is
|
|
235
249
|
// already split on its real newlines rather than carrying them inside one entry.
|
|
@@ -256,6 +270,10 @@ export function renderJson(result: CommandResult, verbose = false): string {
|
|
|
256
270
|
durationMs: step.durationMs,
|
|
257
271
|
skipped: step.skipped === true,
|
|
258
272
|
findings: step.findings,
|
|
273
|
+
// Never gated on `ok` or `--verbose`, unlike `output` below: see `StepResult.warnings`.
|
|
274
|
+
...(step.warnings === undefined || step.warnings.length === 0
|
|
275
|
+
? {}
|
|
276
|
+
: { warnings: [...step.warnings] }),
|
|
259
277
|
...(step.workers === undefined ? {} : { workers: step.workers }),
|
|
260
278
|
...(step.widthReason === undefined ? {} : { widthReason: step.widthReason }),
|
|
261
279
|
...(step.shard === undefined
|