@ultimat3/cli 22.12.0 → 22.14.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/package.json +28 -28
- package/src/app-mcp.ts +129 -69
- package/src/budgets.ts +35 -2
- package/src/cmd-dev.ts +8 -3
- package/src/dev-child-watch.ts +144 -0
- package/src/dev-live-fixture.ts +122 -0
- package/src/dev-route-table.ts +4 -2
- package/src/dev-supervisor.ts +32 -3
- package/src/error-codes.ts +2 -0
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +234 -170
- package/src/island-identity.ts +158 -0
- package/src/island-link.ts +138 -0
- package/src/island-realtime.ts +28 -11
- package/src/island-routes.ts +3 -1
- package/src/island-store.ts +65 -8
- package/src/mcp-errors.ts +1 -0
- package/src/prerender.ts +6 -1
- package/src/sw-precache-plan.ts +6 -0
- package/src/worker-bundle.ts +1 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The `x dev` live tests' process discipline, in one place: spawn the supervisor, read its two
|
|
2
|
+
// streams, and — on every path, a failing one included — reap every process it started and prove
|
|
3
|
+
// none is left. Two children of `cmd-dev-restart.live.test.ts` ran for six hours with ppid 1 and a
|
|
4
|
+
// deleted cwd because the test's `finally` SIGKILLed the supervisor, which cannot forward a signal
|
|
5
|
+
// it never gets, and nothing then looked for what it had started (2026-09-29).
|
|
6
|
+
|
|
7
|
+
import { readdirSync, readlinkSync } from 'node:fs'; // why: /proc is read synchronously; Bun has no readlink.
|
|
8
|
+
|
|
9
|
+
/** One pump per stream — reading a stream twice closes its reader under the first `for await`. */
|
|
10
|
+
export function pump(stream: ReadableStream<Uint8Array>): { seen: () => string } {
|
|
11
|
+
const decoder = new TextDecoder();
|
|
12
|
+
let seen = '';
|
|
13
|
+
void (async () => {
|
|
14
|
+
for await (const chunk of stream) seen += decoder.decode(chunk, { stream: true });
|
|
15
|
+
})();
|
|
16
|
+
return { seen: () => seen };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Poll the buffer until `marker` shows up. The caller's own timeout is the deadline. */
|
|
20
|
+
export async function waitFor(output: { seen: () => string }, marker: string): Promise<string> {
|
|
21
|
+
for (;;) {
|
|
22
|
+
const seen = output.seen();
|
|
23
|
+
if (seen.includes(marker)) return seen;
|
|
24
|
+
await Bun.sleep(25);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether `pid` is still a process — signal 0 delivers nothing and only asks. */
|
|
29
|
+
export function alive(pid: number): boolean {
|
|
30
|
+
try {
|
|
31
|
+
process.kill(pid, 0);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Every descendant of `pid`, depth first — asked while `pid` lives, or its children are init's. */
|
|
39
|
+
export function descendantsOf(pid: number): readonly number[] {
|
|
40
|
+
const out = Bun.spawnSync(['pgrep', '-P', String(pid)]).stdout.toString();
|
|
41
|
+
const children = out
|
|
42
|
+
.split('\n')
|
|
43
|
+
.map((line) => Number(line.trim()))
|
|
44
|
+
.filter((child) => Number.isInteger(child) && child > 0);
|
|
45
|
+
return children.flatMap((child) => [child, ...descendantsOf(child)]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every process whose cwd is `dir` — a deleted one included, which Linux spells `<dir> (deleted)`.
|
|
50
|
+
* The one question that finds an orphan after its parent is gone, when no process tree reaches it.
|
|
51
|
+
* Empty where there is no `/proc` (macOS): the descendant walk is then the whole answer.
|
|
52
|
+
*/
|
|
53
|
+
export function processesIn(dir: string): readonly number[] {
|
|
54
|
+
let pids: readonly string[];
|
|
55
|
+
try {
|
|
56
|
+
pids = readdirSync('/proc').filter((name) => /^\d+$/.test(name));
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
const found: number[] = [];
|
|
61
|
+
for (const pid of pids) {
|
|
62
|
+
try {
|
|
63
|
+
const cwd = readlinkSync(`/proc/${pid}/cwd`);
|
|
64
|
+
if (cwd === dir || cwd === `${dir} (deleted)`) found.push(Number(pid));
|
|
65
|
+
} catch {
|
|
66
|
+
// Gone between the listing and the read, or not ours to read.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return found;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether anything still answers on `port` at the loopback address `x dev` binds. */
|
|
73
|
+
export async function listening(port: number): Promise<boolean> {
|
|
74
|
+
try {
|
|
75
|
+
const socket = await Bun.connect({
|
|
76
|
+
hostname: '127.0.0.1',
|
|
77
|
+
port,
|
|
78
|
+
socket: { data: () => undefined },
|
|
79
|
+
});
|
|
80
|
+
socket.end();
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* SIGKILL `pids`, then wait until each is gone. A test's last resort, run in `finally` — by then
|
|
89
|
+
* every graceful stop the test meant to prove has had its chance. `performance.now()`, never
|
|
90
|
+
* `Date.now()`: the test preload freezes the wall clock, and a deadline read off it never arrives.
|
|
91
|
+
*/
|
|
92
|
+
export async function reap(pids: readonly number[], timeoutMs = 10_000): Promise<void> {
|
|
93
|
+
for (const pid of pids) {
|
|
94
|
+
try {
|
|
95
|
+
process.kill(pid, 'SIGKILL');
|
|
96
|
+
} catch {
|
|
97
|
+
// Already gone.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const until = performance.now() + timeoutMs;
|
|
101
|
+
while (pids.some(alive) && performance.now() < until) await Bun.sleep(25);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** What was left: the pids still in `dir` and whether `port` still answers. Both empty is clean. */
|
|
105
|
+
export async function leftovers(
|
|
106
|
+
dir: string,
|
|
107
|
+
port: number | undefined,
|
|
108
|
+
): Promise<{ readonly pids: readonly number[]; readonly port: boolean }> {
|
|
109
|
+
return {
|
|
110
|
+
pids: processesIn(dir),
|
|
111
|
+
port: port === undefined ? false : await listening(port),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The net under a TIMED-OUT test: bun abandons its body without running `finally`, so an
|
|
117
|
+
* `afterEach` reaps whatever still runs in the fixture directory — the only handle left on an
|
|
118
|
+
* orphan whose parent is gone.
|
|
119
|
+
*/
|
|
120
|
+
export async function reapIn(dir: string): Promise<void> {
|
|
121
|
+
await reap(processesIn(dir));
|
|
122
|
+
}
|
package/src/dev-route-table.ts
CHANGED
|
@@ -58,8 +58,10 @@ export interface DevRouteTable {
|
|
|
58
58
|
readonly theme: ThemeBoot;
|
|
59
59
|
/** The app's own error pages' inline styles, admitted the same way. */
|
|
60
60
|
readonly errorStyles: readonly string[];
|
|
61
|
-
/** Where the app's MCP endpoint was mounted, or `
|
|
61
|
+
/** Where the app's default MCP endpoint was mounted, or `null`. */
|
|
62
62
|
readonly mcpPath: string | null;
|
|
63
|
+
/** Every mounted MCP endpoint's path, default first — one per population the app serves. */
|
|
64
|
+
readonly mcpPaths: readonly string[];
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
export async function devRouteTable(input: DevRouteTableInput): Promise<DevRouteTable> {
|
|
@@ -156,5 +158,5 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
|
|
|
156
158
|
}),
|
|
157
159
|
];
|
|
158
160
|
|
|
159
|
-
return { routes, theme, errorStyles, mcpPath: mcpMount.path };
|
|
161
|
+
return { routes, theme, errorStyles, mcpPath: mcpMount.path, mcpPaths: mcpMount.paths };
|
|
160
162
|
}
|
package/src/dev-supervisor.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { relative } from 'node:path';
|
|
|
10
10
|
import { drain } from '@ultimat3/core';
|
|
11
11
|
import type { StalePin } from './app-reload-graph';
|
|
12
12
|
import { devSpec } from './cmd-dev-spec';
|
|
13
|
+
import type { DevChildGone } from './dev-child-watch';
|
|
14
|
+
import { DEV_SUPERVISOR_PID_ENV, startDevChildWatch, supervisorPid } from './dev-child-watch';
|
|
13
15
|
import { devPortFor } from './dev-port';
|
|
14
16
|
import { msg } from './messages';
|
|
15
17
|
import type { Finding } from './output';
|
|
@@ -96,10 +98,16 @@ const spawnInherited = (
|
|
|
96
98
|
* SIGINT and SIGTERM are forwarded — a terminal's Ctrl-C reaches the child on its own too, and its
|
|
97
99
|
* drain is idempotent — and once one arrived no child is started again: a Ctrl-C during a restart
|
|
98
100
|
* is a stop, never a respawn.
|
|
101
|
+
*
|
|
102
|
+
* Every other way out stops the child too: an uncaught error or a `process.exit` here reaches the
|
|
103
|
+
* `exit` listener, which SIGTERMs a child still running. The one way no listener sees is a SIGKILL
|
|
104
|
+
* of this process — the child's own watch (`dev-child-watch.ts`) answers that, from its side, off
|
|
105
|
+
* the pid this loop hands it.
|
|
99
106
|
*/
|
|
100
107
|
export async function superviseDev(input: SuperviseDevInput): Promise<number> {
|
|
101
108
|
const spawn = input.spawn ?? spawnInherited;
|
|
102
109
|
let child: DevChild | undefined;
|
|
110
|
+
let running = false;
|
|
103
111
|
let stopping = false;
|
|
104
112
|
const forward = (signal: 'SIGINT' | 'SIGTERM') => (): void => {
|
|
105
113
|
stopping = true;
|
|
@@ -107,20 +115,28 @@ export async function superviseDev(input: SuperviseDevInput): Promise<number> {
|
|
|
107
115
|
};
|
|
108
116
|
const onInt = forward('SIGINT');
|
|
109
117
|
const onTerm = forward('SIGTERM');
|
|
118
|
+
const onExit = (): void => {
|
|
119
|
+
if (running) child?.kill('SIGTERM');
|
|
120
|
+
};
|
|
110
121
|
process.on('SIGINT', onInt);
|
|
111
122
|
process.on('SIGTERM', onTerm);
|
|
123
|
+
process.on('exit', onExit);
|
|
112
124
|
try {
|
|
113
125
|
for (;;) {
|
|
114
126
|
child = spawn([process.execPath, input.bin, ...input.argv], {
|
|
115
127
|
...input.env,
|
|
116
128
|
[DEV_CHILD_ENV]: '1',
|
|
129
|
+
[DEV_SUPERVISOR_PID_ENV]: String(process.pid),
|
|
117
130
|
});
|
|
131
|
+
running = true;
|
|
118
132
|
const code = await child.exited;
|
|
133
|
+
running = false;
|
|
119
134
|
if (code !== DEV_RESTART_EXIT_CODE || stopping) return code;
|
|
120
135
|
}
|
|
121
136
|
} finally {
|
|
122
137
|
process.off('SIGINT', onInt);
|
|
123
138
|
process.off('SIGTERM', onTerm);
|
|
139
|
+
process.off('exit', onExit);
|
|
124
140
|
}
|
|
125
141
|
}
|
|
126
142
|
|
|
@@ -156,23 +172,36 @@ export interface ChildRestart {
|
|
|
156
172
|
* Only a supervised child restarts: it says why on stderr (fd 1 may be `--json`'s one document),
|
|
157
173
|
* starts core's drain — the hold then releases the lock, the port and the embedded database — and
|
|
158
174
|
* exits `DEV_RESTART_EXIT_CODE` once released. Anything else keeps the finding on `/_x`.
|
|
175
|
+
*
|
|
176
|
+
* A child its supervisor named (`DEV_SUPERVISOR_PID_ENV`) also watches for that supervisor's death
|
|
177
|
+
* and its app root's, and stops on either — exit 1 for a root that is gone, which the supervisor,
|
|
178
|
+
* if it is still there, answers by stopping rather than respawning into nothing.
|
|
159
179
|
*/
|
|
160
180
|
export function childRestart(
|
|
161
181
|
root: string,
|
|
162
182
|
env: Readonly<Record<string, string | undefined>>,
|
|
183
|
+
watch: typeof startDevChildWatch = startDevChildWatch,
|
|
163
184
|
): ChildRestart {
|
|
164
185
|
if (env[DEV_CHILD_ENV] !== '1') return { options: {}, exit: () => undefined };
|
|
165
|
-
let
|
|
186
|
+
let leaving: 'restart' | DevChildGone | undefined;
|
|
187
|
+
const watched =
|
|
188
|
+
supervisorPid(env) === undefined
|
|
189
|
+
? undefined
|
|
190
|
+
: watch(root, env, (why) => {
|
|
191
|
+
leaving ??= why;
|
|
192
|
+
});
|
|
166
193
|
return {
|
|
167
194
|
options: {
|
|
168
195
|
onRestart: (pins) => {
|
|
169
196
|
process.stderr.write(`${msg('cli.dev.restart', { reason: restartReason(root, pins) })}\n`);
|
|
170
|
-
|
|
197
|
+
leaving ??= 'restart';
|
|
198
|
+
watched?.stopping(DEV_RESTART_EXIT_CODE);
|
|
171
199
|
void drain('restart');
|
|
172
200
|
},
|
|
173
201
|
},
|
|
174
202
|
exit: () => {
|
|
175
|
-
if (
|
|
203
|
+
if (leaving === 'restart') process.exit(DEV_RESTART_EXIT_CODE);
|
|
204
|
+
if (leaving === 'root') process.exit(1);
|
|
176
205
|
},
|
|
177
206
|
};
|
|
178
207
|
}
|
package/src/error-codes.ts
CHANGED
|
@@ -204,6 +204,7 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
204
204
|
'X_VERIFY_MERGE_INPUT',
|
|
205
205
|
'X_SITEMAP_EXTRA_INVALID',
|
|
206
206
|
'X_DEV_RESTART_REQUIRED',
|
|
207
|
+
'X_DEV_ROOT_GONE',
|
|
207
208
|
] as const;
|
|
208
209
|
|
|
209
210
|
/**
|
|
@@ -358,6 +359,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
358
359
|
X_SITEMAP_EXTRA_INVALID: 'a seo.sitemap.extra path names no public page',
|
|
359
360
|
X_DEV_RESTART_REQUIRED:
|
|
360
361
|
'a save reached a module that defines a primitive; only a new process serves it',
|
|
362
|
+
X_DEV_ROOT_GONE: 'the app root x dev serves was deleted or moved',
|
|
361
363
|
};
|
|
362
364
|
|
|
363
365
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
package/src/index.ts
CHANGED
|
@@ -107,7 +107,7 @@ export { ICON_BASE_PATH, ICON_SOURCE } from './icon-assets';
|
|
|
107
107
|
// `@ultimat3/testing` takes this function as its `build` parameter (issue #260). `discoverIslands`,
|
|
108
108
|
// `islandBundle`, `writeIslands`, `ISLAND_BASE_PATH` and `ISLAND_GLOB` stay internal: they are
|
|
109
109
|
// `x build`'s and `x dev`'s wiring, and every name here is a semver promise forever.
|
|
110
|
-
export type { IslandChunk } from './island-bundle';
|
|
110
|
+
export type { IslandChunk, SharedChunk } from './island-bundle';
|
|
111
111
|
export { buildIslands } from './island-bundle';
|
|
112
112
|
export { JOB_STATES } from './jobs-report';
|
|
113
113
|
export { renderJobTable } from './jobs-table';
|