@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.
- 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 +66 -87
- package/src/role-sync.ts +65 -6
- package/src/role-wake.ts +2 -3
- 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 +19 -30
- 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 +36 -15
- 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/dev-supervisor.ts
CHANGED
|
@@ -5,17 +5,23 @@
|
|
|
5
5
|
// of logging "reloaded" over stale code, and this loop boots a fresh one on the same port. Every
|
|
6
6
|
// other save stays the in-process reload it was — a restart is paid only where nothing else works.
|
|
7
7
|
|
|
8
|
-
// why: Bun has no path API; the restart line names files relative to the app root.
|
|
9
|
-
import { relative } from 'node:path';
|
|
10
|
-
import { drain } from '@ultimat3/core';
|
|
11
8
|
import type { StalePin } from './app-reload-graph';
|
|
12
9
|
import { devSpec } from './cmd-dev-spec';
|
|
13
|
-
import type { DevChildGone } from './dev-child-watch';
|
|
14
|
-
import {
|
|
10
|
+
import type { DevChildGone, DevDrainMessage } from './dev-child-watch';
|
|
11
|
+
import {
|
|
12
|
+
DEV_SUPERVISOR_PID_ENV,
|
|
13
|
+
devHardExitMs,
|
|
14
|
+
startDevChildWatch,
|
|
15
|
+
supervisorPid,
|
|
16
|
+
} from './dev-child-watch';
|
|
15
17
|
import { devPortFor } from './dev-port';
|
|
18
|
+
import { type RetiringWorker, retireThenDrain } from './dev-restart-retire';
|
|
19
|
+
import { PORT_RANGE } from './flag-number';
|
|
16
20
|
import { msg } from './messages';
|
|
17
21
|
import type { Finding } from './output';
|
|
18
22
|
import { parseArgs } from './parse';
|
|
23
|
+
import { posixRelative } from './posix-path';
|
|
24
|
+
import { writeErrorLine } from './write-line';
|
|
19
25
|
|
|
20
26
|
/** Set on the child: it serves, and a pinned save makes it exit for a restart. */
|
|
21
27
|
export const DEV_CHILD_ENV = 'ULTIMATE_DEV_CHILD';
|
|
@@ -48,12 +54,43 @@ export function devSupervision(
|
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
/** A port
|
|
52
|
-
|
|
57
|
+
/** A port the OS hands out for `0`, released at once — the candidate `freeDevPort` checks. */
|
|
58
|
+
const kernelPort = (): number => {
|
|
53
59
|
const probe = Bun.serve({ port: 0, fetch: () => new Response() });
|
|
54
60
|
const port = probe.port ?? 0;
|
|
55
61
|
probe.stop(true);
|
|
56
62
|
return port;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Whether `port` can be bound right now: `Bun.serve` refusing it is the answer, not an error. */
|
|
66
|
+
const portBindable = (port: number): boolean => {
|
|
67
|
+
try {
|
|
68
|
+
Bun.serve({ port, fetch: () => new Response() }).stop(true);
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Kernel candidates tried for a free PAIR before the last one is handed back unchecked. */
|
|
76
|
+
const PAIR_ATTEMPTS = 16;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A port whose PAIR nothing holds right now — what `--port 0` is pinned to. `x dev` binds the web
|
|
80
|
+
* role on `port` and the sync role on `port + 1` (`syncPortFor`), so a port the kernel says is
|
|
81
|
+
* free proves nothing about its neighbour: asking for one alone failed the boot `X_PORT_IN_USE`
|
|
82
|
+
* on a busy box whenever the neighbour was taken (`cmd-dev-orphan.live.test.ts`, 2026-10-07).
|
|
83
|
+
*/
|
|
84
|
+
export function freeDevPort(
|
|
85
|
+
pick: () => number = kernelPort,
|
|
86
|
+
bindable: (port: number) => boolean = portBindable,
|
|
87
|
+
): number {
|
|
88
|
+
let port = 0;
|
|
89
|
+
for (let attempt = 0; attempt < PAIR_ATTEMPTS; attempt += 1) {
|
|
90
|
+
port = pick();
|
|
91
|
+
if (port > 0 && port < PORT_RANGE.max && bindable(port + 1)) return port;
|
|
92
|
+
}
|
|
93
|
+
return port;
|
|
57
94
|
}
|
|
58
95
|
|
|
59
96
|
/** `argv` with every `--port` removed and `--port <port>` appended. */
|
|
@@ -75,6 +112,35 @@ function withPort(argv: readonly string[], port: number): readonly string[] {
|
|
|
75
112
|
export interface DevChild {
|
|
76
113
|
readonly exited: Promise<number>;
|
|
77
114
|
kill(signal: 'SIGINT' | 'SIGTERM'): void;
|
|
115
|
+
/** The IPC channel; absent or closed, `stopChild` falls back to the signal. */
|
|
116
|
+
send?(message: DevDrainMessage): void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Asks the child to drain: a `drain` message over IPC, on every platform — on Windows
|
|
121
|
+
* `kill('SIGTERM')` is TerminateProcess, which skips the drain and leaves the embedded database
|
|
122
|
+
* unclean. The signal only when there is no channel to ask over (it closed, or a test's child) —
|
|
123
|
+
* or once `afterMs` has passed with the child still running: one still booting has no listener
|
|
124
|
+
* yet and drops the message, and a stop that never lands is a supervisor waiting forever.
|
|
125
|
+
*/
|
|
126
|
+
export function stopChild(
|
|
127
|
+
child: DevChild,
|
|
128
|
+
signal: 'SIGINT' | 'SIGTERM',
|
|
129
|
+
afterMs: number = devHardExitMs(),
|
|
130
|
+
): void {
|
|
131
|
+
if (child.send !== undefined) {
|
|
132
|
+
try {
|
|
133
|
+
child.send({ type: 'x-dev-drain', signal });
|
|
134
|
+
const fallback = setTimeout(() => child.kill(signal), afterMs);
|
|
135
|
+
// Never what keeps this process alive: awaiting the child's exit is.
|
|
136
|
+
fallback.unref();
|
|
137
|
+
void child.exited.then(() => clearTimeout(fallback));
|
|
138
|
+
return;
|
|
139
|
+
} catch {
|
|
140
|
+
// The channel closed under us: the child is exiting or gone, and the signal is all that's left.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
child.kill(signal);
|
|
78
144
|
}
|
|
79
145
|
|
|
80
146
|
export interface SuperviseDevInput {
|
|
@@ -91,16 +157,23 @@ const spawnInherited = (
|
|
|
91
157
|
command: readonly string[],
|
|
92
158
|
env: Record<string, string | undefined>,
|
|
93
159
|
): DevChild =>
|
|
94
|
-
Bun.spawn([...command], {
|
|
160
|
+
Bun.spawn([...command], {
|
|
161
|
+
stdin: 'inherit',
|
|
162
|
+
stdout: 'inherit',
|
|
163
|
+
stderr: 'inherit',
|
|
164
|
+
env,
|
|
165
|
+
// The channel `stopChild` drains over; the child sends nothing back.
|
|
166
|
+
ipc: () => undefined,
|
|
167
|
+
});
|
|
95
168
|
|
|
96
169
|
/**
|
|
97
170
|
* Runs children until one exits with anything but `DEV_RESTART_EXIT_CODE`, and answers that code.
|
|
98
|
-
* SIGINT and SIGTERM are forwarded — a terminal's Ctrl-C reaches
|
|
99
|
-
* drain is idempotent — and once one arrived no child is started
|
|
100
|
-
* is a stop, never a respawn.
|
|
171
|
+
* SIGINT and SIGTERM are forwarded as a `drain` message (`stopChild`) — a terminal's Ctrl-C reaches
|
|
172
|
+
* the child on its own too, and its drain is idempotent — and once one arrived no child is started
|
|
173
|
+
* again: a Ctrl-C during a restart is a stop, never a respawn.
|
|
101
174
|
*
|
|
102
175
|
* Every other way out stops the child too: an uncaught error or a `process.exit` here reaches the
|
|
103
|
-
* `exit` listener, which
|
|
176
|
+
* `exit` listener, which asks a child still running to drain. The one way no listener sees is a SIGKILL
|
|
104
177
|
* of this process — the child's own watch (`dev-child-watch.ts`) answers that, from its side, off
|
|
105
178
|
* the pid this loop hands it.
|
|
106
179
|
*/
|
|
@@ -111,12 +184,12 @@ export async function superviseDev(input: SuperviseDevInput): Promise<number> {
|
|
|
111
184
|
let stopping = false;
|
|
112
185
|
const forward = (signal: 'SIGINT' | 'SIGTERM') => (): void => {
|
|
113
186
|
stopping = true;
|
|
114
|
-
child
|
|
187
|
+
if (child !== undefined) stopChild(child, signal);
|
|
115
188
|
};
|
|
116
189
|
const onInt = forward('SIGINT');
|
|
117
190
|
const onTerm = forward('SIGTERM');
|
|
118
191
|
const onExit = (): void => {
|
|
119
|
-
if (running) child
|
|
192
|
+
if (running && child !== undefined) stopChild(child, 'SIGTERM');
|
|
120
193
|
};
|
|
121
194
|
process.on('SIGINT', onInt);
|
|
122
195
|
process.on('SIGTERM', onTerm);
|
|
@@ -145,8 +218,8 @@ export function restartReason(root: string, pins: readonly StalePin[]): string {
|
|
|
145
218
|
return pins
|
|
146
219
|
.map(({ changed, pinned }) =>
|
|
147
220
|
changed === pinned
|
|
148
|
-
?
|
|
149
|
-
: `${
|
|
221
|
+
? posixRelative(root, pinned)
|
|
222
|
+
: `${posixRelative(root, changed)} under ${posixRelative(root, pinned)}`,
|
|
150
223
|
)
|
|
151
224
|
.join(', ');
|
|
152
225
|
}
|
|
@@ -158,7 +231,7 @@ export function restartFinding(root: string, pins: readonly StalePin[]): Finding
|
|
|
158
231
|
code: 'X_DEV_RESTART_REQUIRED',
|
|
159
232
|
cause: `${restartReason(root, pins)} — a module that defines a primitive still holds the code before the save, and re-importing it would register a second definition`,
|
|
160
233
|
fix: 'restart x dev (a supervised `x dev` restarts itself; an embedded startDev() passes onRestart)',
|
|
161
|
-
at: first === undefined ? '' :
|
|
234
|
+
at: first === undefined ? '' : posixRelative(root, first.pinned),
|
|
162
235
|
};
|
|
163
236
|
}
|
|
164
237
|
|
|
@@ -166,12 +239,20 @@ export function restartFinding(root: string, pins: readonly StalePin[]): Finding
|
|
|
166
239
|
export interface ChildRestart {
|
|
167
240
|
readonly options: { readonly onRestart?: (pins: readonly StalePin[]) => void };
|
|
168
241
|
readonly exit: (code: number) => void;
|
|
242
|
+
/**
|
|
243
|
+
* The worker this child booted, handed over once `startDev` returns: a restart retires it before
|
|
244
|
+
* the drain, so a save never cancels a running job (`dev-restart-retire.ts`). `null` for a child
|
|
245
|
+
* running no worker role.
|
|
246
|
+
*/
|
|
247
|
+
readonly adopt: (worker: RetiringWorker | null) => void;
|
|
169
248
|
}
|
|
170
249
|
|
|
171
250
|
/**
|
|
172
251
|
* Only a supervised child restarts: it says why on stderr (fd 1 may be `--json`'s one document),
|
|
173
|
-
*
|
|
174
|
-
*
|
|
252
|
+
* retires its worker — every running job finishes, none is claimed — then starts core's drain (the
|
|
253
|
+
* hold then releases the lock, the port and the embedded database) and exits
|
|
254
|
+
* `DEV_RESTART_EXIT_CODE` once released. One restart per child however many saves land while it
|
|
255
|
+
* waits. Anything else keeps the finding on `/_x`.
|
|
175
256
|
*
|
|
176
257
|
* A child its supervisor named (`DEV_SUPERVISOR_PID_ENV`) also watches for that supervisor's death
|
|
177
258
|
* and its app root's, and stops on either — exit 1 for a root that is gone, which the supervisor,
|
|
@@ -181,9 +262,14 @@ export function childRestart(
|
|
|
181
262
|
root: string,
|
|
182
263
|
env: Readonly<Record<string, string | undefined>>,
|
|
183
264
|
watch: typeof startDevChildWatch = startDevChildWatch,
|
|
265
|
+
retire: (worker: RetiringWorker | null) => Promise<void> = retireThenDrain,
|
|
184
266
|
): ChildRestart {
|
|
185
|
-
if (env[DEV_CHILD_ENV] !== '1')
|
|
267
|
+
if (env[DEV_CHILD_ENV] !== '1') {
|
|
268
|
+
return { options: {}, exit: () => undefined, adopt: () => undefined };
|
|
269
|
+
}
|
|
186
270
|
let leaving: 'restart' | DevChildGone | undefined;
|
|
271
|
+
let worker: RetiringWorker | null = null;
|
|
272
|
+
let restarting = false;
|
|
187
273
|
const watched =
|
|
188
274
|
supervisorPid(env) === undefined
|
|
189
275
|
? undefined
|
|
@@ -193,12 +279,18 @@ export function childRestart(
|
|
|
193
279
|
return {
|
|
194
280
|
options: {
|
|
195
281
|
onRestart: (pins) => {
|
|
196
|
-
|
|
282
|
+
writeErrorLine(msg('cli.dev.restart', { reason: restartReason(root, pins) }));
|
|
197
283
|
leaving ??= 'restart';
|
|
198
284
|
watched?.stopping(DEV_RESTART_EXIT_CODE);
|
|
199
|
-
|
|
285
|
+
if (restarting) return;
|
|
286
|
+
restarting = true;
|
|
287
|
+
// Never rejects: a failed retire is logged and the drain still runs (`retireThenDrain`).
|
|
288
|
+
void retire(worker);
|
|
200
289
|
},
|
|
201
290
|
},
|
|
291
|
+
adopt: (booted) => {
|
|
292
|
+
worker = booted;
|
|
293
|
+
},
|
|
202
294
|
exit: () => {
|
|
203
295
|
if (leaving === 'restart') process.exit(DEV_RESTART_EXIT_CODE);
|
|
204
296
|
if (leaving === 'root') process.exit(1);
|
package/src/dev-traces.ts
CHANGED
|
@@ -127,49 +127,82 @@ function toTrace(root: ReadableSpan, spans: readonly ReadableSpan[]): RequestTra
|
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
129
|
* Spans end innermost-first, so a trace is only whole once its root arrives — which is also the
|
|
130
|
-
* moment the request finished.
|
|
131
|
-
*
|
|
130
|
+
* moment the request finished. Until then it is OPEN: unclassified, because the span that says
|
|
131
|
+
* "request" or "background" is the last one to end. A trace whose root arrives and is NOT a
|
|
132
|
+
* request's is forgotten the moment it ends; one whose root IS a request's is RETAINED.
|
|
133
|
+
*
|
|
134
|
+
* Two maps, two quotas, because only retained requests are what the limit promises to keep. One
|
|
135
|
+
* shared buffer let an open trace evict a retained request: at limit 1, a worker's `job.poll`
|
|
136
|
+
* ended its `db.update` first, that provisional trace took the only slot, and discarding
|
|
137
|
+
* `job.poll` a moment later could not bring the request back.
|
|
132
138
|
*/
|
|
133
139
|
export function createTraceRecorder(options: { limit?: number } = {}): TraceRecorder {
|
|
134
|
-
// `
|
|
140
|
+
// `buffer.size > NaN` is false on every pass, so an unchecked limit does not widen the buffer —
|
|
135
141
|
// it deletes the eviction loop, and a dev session then holds every span of every request it has
|
|
136
142
|
// ever seen. At least 1: a recorder that retains nothing is what `/_x/timeline` reads.
|
|
137
143
|
const limit = finiteCount('createTraceRecorder', 'limit', options.limit ?? DEFAULT_LIMIT, 1);
|
|
138
|
-
// Insertion-ordered: the oldest trace id is the first key, which is the one eviction drops.
|
|
139
|
-
const
|
|
144
|
+
// Insertion-ordered, both: the oldest trace id is the first key, which is the one eviction drops.
|
|
145
|
+
const retained = new Map<string, ReadableSpan[]>();
|
|
146
|
+
// Bounded too, by the same figure but separately: a root that never ends (a span leaked by app
|
|
147
|
+
// code) would otherwise hold its children forever. Overflowing it costs an in-flight trace its
|
|
148
|
+
// early spans, never a finished request.
|
|
149
|
+
const open = new Map<string, ReadableSpan[]>();
|
|
150
|
+
|
|
151
|
+
const admit = (buffer: Map<string, ReadableSpan[]>, traceId: string, spans: ReadableSpan[]) => {
|
|
152
|
+
buffer.set(traceId, spans);
|
|
153
|
+
// Bounded by TRACE, not by span: dropping half a request would leave a flame with holes.
|
|
154
|
+
// The cost is stated rather than capped — one trace's span array has no bound of its own, so
|
|
155
|
+
// a request issuing 50k statements holds 50k `ReadableSpan`s until it is evicted. That is a
|
|
156
|
+
// dev-only recorder (`serve.ts` installs none), and a per-trace cap would silently produce
|
|
157
|
+
// the holed flame this bound exists to prevent.
|
|
158
|
+
while (buffer.size > limit) {
|
|
159
|
+
const oldest = buffer.keys().next();
|
|
160
|
+
if (oldest.done === true) break;
|
|
161
|
+
buffer.delete(oldest.value);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
140
164
|
|
|
141
165
|
const record = (span: ReadableSpan): void => {
|
|
142
166
|
const traceId = span.context.traceId;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
167
|
+
// Late spans of a request already retained (work it handed off under its own trace id) join it.
|
|
168
|
+
const kept = retained.get(traceId);
|
|
169
|
+
if (kept !== undefined) {
|
|
170
|
+
kept.push(span);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// Taken out and put back last: an open trace still receiving spans is never the oldest key,
|
|
174
|
+
// so the open bound evicts the trace that went quiet, not the one mid-flight.
|
|
175
|
+
const spans = open.get(traceId) ?? [];
|
|
176
|
+
open.delete(traceId);
|
|
177
|
+
// A request's root: the trace is classified and complete, so it moves into the quota.
|
|
178
|
+
if (isHttpRoot(span)) {
|
|
179
|
+
spans.push(span);
|
|
180
|
+
admit(retained, traceId, spans);
|
|
156
181
|
return;
|
|
157
182
|
}
|
|
183
|
+
// A root that is not a request's — no parent, no HTTP facts — ends its trace, and the trace is
|
|
184
|
+
// a scheduler tick or a worker's job: not the panel's to show. Dropped here, not filtered at
|
|
185
|
+
// read time, because idle background roles minting a few per second would otherwise churn
|
|
186
|
+
// the buffers for nothing.
|
|
187
|
+
if (span.parentSpanId === undefined) return;
|
|
158
188
|
spans.push(span);
|
|
189
|
+
if (spans.length === 1) admit(open, traceId, spans);
|
|
190
|
+
else open.set(traceId, spans);
|
|
159
191
|
};
|
|
160
192
|
|
|
161
193
|
return {
|
|
162
194
|
exporter: { export: record },
|
|
163
195
|
traces(): readonly RequestTrace[] {
|
|
164
196
|
const traces: RequestTrace[] = [];
|
|
165
|
-
for (const spans of
|
|
197
|
+
for (const spans of retained.values()) {
|
|
166
198
|
const root = httpRootOf(spans);
|
|
167
199
|
if (root !== undefined) traces.push(toTrace(root, spans));
|
|
168
200
|
}
|
|
169
201
|
return traces.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
170
202
|
},
|
|
171
203
|
reset(): void {
|
|
172
|
-
|
|
204
|
+
retained.clear();
|
|
205
|
+
open.clear();
|
|
173
206
|
},
|
|
174
207
|
};
|
|
175
208
|
}
|
package/src/dispatch.ts
CHANGED
|
@@ -16,10 +16,14 @@ import { exitCodeFor, findingFrom, render } from './output';
|
|
|
16
16
|
import type { ParsedArgs } from './parse';
|
|
17
17
|
import { parseArgs, wantsJson } from './parse';
|
|
18
18
|
import { commandFor, SPECS } from './registry';
|
|
19
|
-
import {
|
|
19
|
+
import { applyRootEnv, rootEnvChanges } from './root-env';
|
|
20
20
|
|
|
21
21
|
export interface DispatchOptions {
|
|
22
22
|
readonly argv: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* The directory the PROCESS started in (`bin.ts` passes `process.cwd()`): `--cwd` resolves
|
|
25
|
+
* against it, and it is the only directory whose `.env*` Bun loaded on its own.
|
|
26
|
+
*/
|
|
23
27
|
readonly cwd: string;
|
|
24
28
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
25
29
|
readonly bunVersion: string;
|
|
@@ -122,16 +126,19 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
122
126
|
options.cwd,
|
|
123
127
|
typeof args.flags.get('cwd') === 'string' ? String(args.flags.get('cwd')) : undefined,
|
|
124
128
|
);
|
|
125
|
-
// The ROOT's `.env*`, not the cwd's: Bun loaded the cwd's at startup
|
|
126
|
-
// `apps/web` finds its root by walking up.
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
// The ROOT's `.env*`, not the cwd's: Bun loaded the PROCESS cwd's at startup (`options.cwd`,
|
|
130
|
+
// before `--cwd` moved it), and a command run from `apps/web` finds its root by walking up.
|
|
131
|
+
// Applied to the process env too when that IS the env passed in (`applyRootEnv`), so a package
|
|
132
|
+
// reading `Bun.env` directly sees what the command sees — another app's keys removed included.
|
|
133
|
+
const env = applyRootEnv(
|
|
134
|
+
options.env,
|
|
135
|
+
await rootEnvChanges({ cwd, processCwd: options.cwd, env: options.env }),
|
|
136
|
+
);
|
|
130
137
|
const ctx: CommandContext = {
|
|
131
138
|
args: helpArgs,
|
|
132
139
|
cwd,
|
|
133
140
|
runner: options.runner ?? exec,
|
|
134
|
-
env
|
|
141
|
+
env,
|
|
135
142
|
bunVersion: options.bunVersion,
|
|
136
143
|
...(options.invocation === undefined ? {} : { invocation: options.invocation }),
|
|
137
144
|
};
|
package/src/doctor-auth.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// release reads any more. Reaching the server is `authStorageProbe`, deciding is
|
|
4
4
|
// `authStorageFindings` — the split `doctor-sealed.ts` makes.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { countUnsealedMfaSecrets, postgresAuthAdapter } from '@ultimat3/auth';
|
|
7
7
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import { postgresClient, raw } from '@ultimat3/db';
|
|
9
9
|
import type { Finding } from './output';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -71,7 +71,7 @@ export function authStorageFindings(fact: AuthStorageFact): readonly Finding[] {
|
|
|
71
71
|
*/
|
|
72
72
|
export async function authStorageProbe(url: string | undefined): Promise<AuthStorageFact> {
|
|
73
73
|
if (url === undefined || url.trim() === '') return NO_AUTH_STORAGE_FACT;
|
|
74
|
-
const client =
|
|
74
|
+
const client = postgresClient({ url, applicationName: 'x-doctor' });
|
|
75
75
|
try {
|
|
76
76
|
const present = async (table: string): Promise<boolean> => {
|
|
77
77
|
const rows = await client.query<{ readonly present: boolean }>(
|
|
@@ -84,7 +84,7 @@ export async function authStorageProbe(url: string | undefined): Promise<AuthSto
|
|
|
84
84
|
if (await present(table)) retiredTables.push(table);
|
|
85
85
|
}
|
|
86
86
|
const unsealedMfaSecrets = (await present('x_users'))
|
|
87
|
-
? await countUnsealedMfaSecrets({ adapter:
|
|
87
|
+
? await countUnsealedMfaSecrets({ adapter: postgresAuthAdapter(client) })
|
|
88
88
|
: 0;
|
|
89
89
|
return { unsealedMfaSecrets, retiredTables };
|
|
90
90
|
} catch {
|
package/src/doctor-offline.ts
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
// diagnostic over.
|
|
5
5
|
|
|
6
6
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
7
|
+
import { describePages } from '@ultimat3/render';
|
|
8
8
|
import { loadApp } from './app-load';
|
|
9
9
|
import { APP_CONFIG_FILE } from './app-root';
|
|
10
10
|
import type { Finding } from './output';
|
|
11
11
|
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
12
|
+
import { quoteArg } from './shell-quote';
|
|
12
13
|
|
|
13
14
|
/** A route as this check reads one: the URL it answers, and the surface that answers it. */
|
|
14
15
|
export interface NavigableRoute {
|
|
@@ -46,7 +47,7 @@ export interface OfflineFallbackFact {
|
|
|
46
47
|
* below already refuses `--surface app`, so the check and the remedy disagreed about one code.
|
|
47
48
|
*
|
|
48
49
|
* Residual, and NOT closed by this: a `site/` route declaring `render: 'ssr'` is not prerendered
|
|
49
|
-
* either. `NavigableRoute` carries no render mode, and `
|
|
50
|
+
* either. `NavigableRoute` carries no render mode, and `describePages()` has one — the narrower
|
|
50
51
|
* check belongs with it.
|
|
51
52
|
*/
|
|
52
53
|
const NAVIGABLE: ReadonlySet<string> = new Set(['site']);
|
|
@@ -100,13 +101,13 @@ export function offlineFallbackFinding(fact: OfflineFallbackFact): Finding | und
|
|
|
100
101
|
const name = GENERATABLE.exec(fallback)?.[1];
|
|
101
102
|
return name === undefined
|
|
102
103
|
? finding(cause, `set pwa.offline.fallback in ${APP_CONFIG_FILE} to a path a route serves`)
|
|
103
|
-
: finding(cause, `x g route ${name} --surface site`);
|
|
104
|
+
: finding(cause, `x g route ${quoteArg(name)} --surface site`);
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
/**
|
|
107
108
|
* The fact, read off a real app root. Both halves come from the framework's own answers — the
|
|
108
109
|
* config through `loadPwaArtifacts` (the one reader of that file) and the routes through
|
|
109
|
-
* `
|
|
110
|
+
* `describePages()` (the projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all
|
|
110
111
|
* built from), so this check and the service worker cannot disagree about which routes exist.
|
|
111
112
|
*/
|
|
112
113
|
export async function offlineFallbackProbe(root: string): Promise<OfflineFallbackFact> {
|
|
@@ -117,6 +118,6 @@ export async function offlineFallbackProbe(root: string): Promise<OfflineFallbac
|
|
|
117
118
|
routes:
|
|
118
119
|
app.findings.length > 0
|
|
119
120
|
? undefined
|
|
120
|
-
:
|
|
121
|
+
: describePages().map((route) => ({ path: route.path, surface: route.surface })),
|
|
121
122
|
};
|
|
122
123
|
}
|
package/src/document-options.ts
CHANGED
|
@@ -32,6 +32,13 @@ export interface DocumentOptions {
|
|
|
32
32
|
* decided by `app.config.ts`, which the boot read and the renderer cannot.
|
|
33
33
|
*/
|
|
34
34
|
readonly themeHead?: string;
|
|
35
|
+
/**
|
|
36
|
+
* The app's brand `<style>` from `theme-brand.ts`, or absent for an app with no
|
|
37
|
+
* `apps/web/shared/theme.ts`. Rendered AFTER the surface stylesheet `<link>`, never in the head
|
|
38
|
+
* block `themeHead` rides: the brand answers `theme.scss` at equal specificity, so only a later
|
|
39
|
+
* position in the cascade lets it win.
|
|
40
|
+
*/
|
|
41
|
+
readonly brandHead?: string;
|
|
35
42
|
/**
|
|
36
43
|
* The page's sync target — `pageSync(…).head` — rendered as render's `clientSyncTags` on every
|
|
37
44
|
* document this process serves. Principal-free, so a shareable document carries it too; absent
|
|
@@ -13,8 +13,8 @@ import { findingFrom } from './output';
|
|
|
13
13
|
export type ReplicaIdentityWanted = (tables: ReadonlySet<string>) => readonly string[];
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* The app's own answer:
|
|
17
|
-
*
|
|
16
|
+
* The app's own answer: every params channel's `records` tables, with its live queries'
|
|
17
|
+
* `subscribes:` validated but never granted (#518), read off the registries `loadApp` filled. One function with `db-generate.ts`, so the gate
|
|
18
18
|
* can never expect an identity the generator would not grant, or miss one it would.
|
|
19
19
|
*/
|
|
20
20
|
export const appReplicaIdentity: ReplicaIdentityWanted = (tables) =>
|
package/src/drift.ts
CHANGED
|
@@ -53,16 +53,39 @@ async function declaredSchemaJson(root: string): Promise<string> {
|
|
|
53
53
|
*/
|
|
54
54
|
export async function schemaHash(root: string): Promise<string> {
|
|
55
55
|
const glob = new Bun.Glob(SCHEMA_GLOB);
|
|
56
|
-
const
|
|
56
|
+
const sources: SchemaSource[] = [];
|
|
57
57
|
for await (const path of glob.scan({ cwd: root, absolute: false })) {
|
|
58
|
-
if (
|
|
58
|
+
if (path.includes('.test.')) continue;
|
|
59
|
+
sources.push({ path, text: await Bun.file(join(root, path)).text() });
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
+
return hashSchemaSources(await declaredSchemaJson(root), sources);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One file under `SCHEMA_GLOB`: its path relative to the app root, as the OS spelled it. */
|
|
65
|
+
export interface SchemaSource {
|
|
66
|
+
readonly path: string;
|
|
67
|
+
readonly text: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The hash itself, pure. The sidecar is committed and compared on every OS, so what a checkout
|
|
72
|
+
* spells differently is folded away first: `\` separators (a Windows glob) become `/` BEFORE the
|
|
73
|
+
* sort — `seed2.ts` and `seed/index.ts` order differently under the two — and CRLF text (Git for
|
|
74
|
+
* Windows' `core.autocrlf=true`) becomes LF. An LF tree on POSIX is hashed exactly as before, so no
|
|
75
|
+
* sidecar already recorded moves.
|
|
76
|
+
*/
|
|
77
|
+
export function hashSchemaSources(schemaJson: string, sources: readonly SchemaSource[]): string {
|
|
78
|
+
const files = sources
|
|
79
|
+
.map(({ path, text }) => ({
|
|
80
|
+
path: path.replaceAll('\\', '/'),
|
|
81
|
+
text: text.replaceAll('\r\n', '\n'),
|
|
82
|
+
}))
|
|
83
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
61
84
|
const hasher = new Bun.CryptoHasher('sha256');
|
|
62
|
-
hasher.update(
|
|
63
|
-
for (const path of
|
|
85
|
+
hasher.update(schemaJson);
|
|
86
|
+
for (const { path, text } of files) {
|
|
64
87
|
hasher.update(path);
|
|
65
|
-
hasher.update(
|
|
88
|
+
hasher.update(text);
|
|
66
89
|
}
|
|
67
90
|
return hasher.digest('hex').slice(0, 16);
|
|
68
91
|
}
|
package/src/error-codes.ts
CHANGED
|
@@ -228,6 +228,9 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
228
228
|
'X_TRUSTED_PROXY_HOPS_INVALID',
|
|
229
229
|
'X_PERMISSION_BORROWED',
|
|
230
230
|
'X_ERROR_CODE_UNTHROWN_STALE',
|
|
231
|
+
'X_DEV_STATE_UNWRITABLE',
|
|
232
|
+
'X_PACKAGE_TEST_ONLY_SHIPPED',
|
|
233
|
+
'X_ISLAND_MODULE_DUPLICATED',
|
|
231
234
|
] as const;
|
|
232
235
|
|
|
233
236
|
/**
|
|
@@ -412,6 +415,9 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
412
415
|
X_PERMISSION_BORROWED: 'an app rule requires a permission only an imported package declared',
|
|
413
416
|
X_ERROR_CODE_UNTHROWN_STALE:
|
|
414
417
|
'a code UNTHROWN_CODES lists as thrown by nothing is thrown again — delete it from UNTHROWN_CODES in packages/cli/src/unthrown-codes.ts',
|
|
418
|
+
X_DEV_STATE_UNWRITABLE: 'x dev cannot claim its state directory',
|
|
419
|
+
X_PACKAGE_TEST_ONLY_SHIPPED: 'a test-only module ships in the tarball',
|
|
420
|
+
X_ISLAND_MODULE_DUPLICATED: 'An island chunk bundles one module more than once',
|
|
415
421
|
};
|
|
416
422
|
|
|
417
423
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
package/src/error-page-csp.ts
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// `apps/web/site/errors/<status>.html
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// the page rendered unstyled; `x dev` sends the policy report-only, so the same page looked fine
|
|
6
|
-
// on every author's machine. Hashed at boot, like the hydration runtime: a hash is a function of
|
|
7
|
-
// the body, and these files do not change while a container runs.
|
|
1
|
+
// The inline `<style>` bodies of an app's own error pages, for `style-src` to admit. Such a page
|
|
2
|
+
// (`apps/web/site/errors/<status>.html`, served verbatim by `error-pages.ts`) carries its own
|
|
3
|
+
// `<style>` — no stylesheet bundle is linked from it — and under the container's enforced policy an
|
|
4
|
+
// unadmitted block is a page rendered unstyled; `x dev` is report-only, so it looked fine there.
|
|
8
5
|
|
|
9
6
|
// why: Bun exposes no path-join primitive, and the directory is app-root-relative — the same
|
|
10
7
|
// necessity `error-pages.ts` records.
|
|
11
8
|
import { join } from 'node:path';
|
|
12
|
-
import { cspHashSource } from '@ultimat3/http';
|
|
13
9
|
import { ERROR_PAGE_DIR } from './error-pages';
|
|
14
10
|
|
|
15
11
|
/**
|
|
@@ -26,18 +22,21 @@ export function inlineStyleBodies(html: string): readonly string[] {
|
|
|
26
22
|
}
|
|
27
23
|
|
|
28
24
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
25
|
+
* Every distinct `<style>` body across every error page the app ships, sorted — BODIES, because
|
|
26
|
+
* `startRoles({ inlineStyles })` hashes what it is given (`style-csp.ts`). This returned the hashes
|
|
27
|
+
* until 2026-10, and the boot hashed them again: the policy admitted the hash of a hash, and every
|
|
28
|
+
* app error page carrying a `<style>` rendered unstyled under the container's enforced CSP. Read
|
|
29
|
+
* once at boot: a page an author drops in while `x dev` runs is served (the reader is per request)
|
|
30
|
+
* but not yet admitted, which the report-only policy there turns into a console report.
|
|
32
31
|
*/
|
|
33
|
-
export async function
|
|
32
|
+
export async function errorPageStyleBodies(root: string): Promise<readonly string[]> {
|
|
34
33
|
const dir = join(root, ERROR_PAGE_DIR);
|
|
35
|
-
const
|
|
34
|
+
const bodies = new Set<string>();
|
|
36
35
|
for (const name of await htmlFilesIn(dir)) {
|
|
37
36
|
const html = await Bun.file(join(dir, name)).text();
|
|
38
|
-
for (const body of inlineStyleBodies(html))
|
|
37
|
+
for (const body of inlineStyleBodies(html)) bodies.add(body);
|
|
39
38
|
}
|
|
40
|
-
return [...
|
|
39
|
+
return [...bodies].sort();
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
/** `Bun.Glob.scan` throws `ENOENT` on a missing directory, and no error pages is the common case. */
|
package/src/exec.ts
CHANGED
|
@@ -32,6 +32,12 @@ export interface ExecOptions {
|
|
|
32
32
|
*/
|
|
33
33
|
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
34
34
|
readonly stdin?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Told the child's pid the moment it exists. A gate step's deadline records it here
|
|
37
|
+
* (`verify-deadline.ts`): on Windows no process's environment is readable, so the tag sweep
|
|
38
|
+
* finds nothing and the pid recorded at spawn is the only handle on what the step started.
|
|
39
|
+
*/
|
|
40
|
+
readonly onSpawn?: (pid: number) => void;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
export type Runner = (command: readonly string[], options: ExecOptions) => Promise<ExecResult>;
|
|
@@ -112,6 +118,8 @@ export const exec: Runner = async (command, options) => {
|
|
|
112
118
|
});
|
|
113
119
|
}
|
|
114
120
|
const proc = spawnOrRefuse([head, ...rest], options);
|
|
121
|
+
// Outside the refusal: a throwing observer is the caller's bug, never "could not run".
|
|
122
|
+
options.onSpawn?.(proc.pid);
|
|
115
123
|
const [stdout, stderr, code] = await Promise.all([
|
|
116
124
|
new Response(proc.stdout).text(),
|
|
117
125
|
new Response(proc.stderr).text(),
|
package/src/favicon.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// it against the root is string work no `Bun.file` overload does — the same necessity
|
|
8
8
|
// `runtime-assets.ts` records for `ICON_SOURCE`.
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import { blankRaster, encodeImage } from '@ultimat3/core';
|
|
11
11
|
import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
|
|
12
12
|
import { applyCacheHeaders } from '@ultimat3/http';
|
|
13
13
|
|
|
@@ -47,7 +47,7 @@ const FAVICON_CACHE: CacheHint = { mode: 'public', maxAgeSeconds: 3600 };
|
|
|
47
47
|
* is a third rung under a mechanism that has exactly two.
|
|
48
48
|
*/
|
|
49
49
|
export function defaultFavicon(): Uint8Array {
|
|
50
|
-
const raster =
|
|
50
|
+
const raster = blankRaster(DEFAULT_SIZE, DEFAULT_SIZE, 'favicon');
|
|
51
51
|
const { pixels } = raster;
|
|
52
52
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
53
53
|
pixels[i] = MARK_LEVEL;
|