@ultimat3/cli 22.11.0 → 22.12.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-reload-graph.ts +51 -9
- package/src/bin.ts +14 -0
- package/src/cmd-dev.ts +31 -9
- package/src/dev-port.ts +1 -1
- package/src/dev-supervisor.ts +178 -0
- package/src/error-codes.ts +3 -0
- package/src/mcp-errors.ts +2 -0
- package/src/messages.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.12.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,33 +38,33 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@babel/core": "^8.0.1",
|
|
41
|
-
"@ultimat3/action": "22.
|
|
42
|
-
"@ultimat3/admin": "22.
|
|
43
|
-
"@ultimat3/ai": "22.
|
|
44
|
-
"@ultimat3/auth": "22.
|
|
45
|
-
"@ultimat3/cache": "22.
|
|
46
|
-
"@ultimat3/core": "22.
|
|
47
|
-
"@ultimat3/db": "22.
|
|
48
|
-
"@ultimat3/entity": "22.
|
|
49
|
-
"@ultimat3/flags": "22.
|
|
50
|
-
"@ultimat3/http": "22.
|
|
51
|
-
"@ultimat3/i18n": "22.
|
|
52
|
-
"@ultimat3/jobs": "22.
|
|
53
|
-
"@ultimat3/mail": "22.
|
|
54
|
-
"@ultimat3/manifest": "22.
|
|
55
|
-
"@ultimat3/mcp": "22.
|
|
56
|
-
"@ultimat3/money": "22.
|
|
57
|
-
"@ultimat3/notify": "22.
|
|
58
|
-
"@ultimat3/policy": "22.
|
|
59
|
-
"@ultimat3/pwa": "22.
|
|
60
|
-
"@ultimat3/query": "22.
|
|
61
|
-
"@ultimat3/realtime": "22.
|
|
62
|
-
"@ultimat3/render": "22.
|
|
63
|
-
"@ultimat3/schema": "22.
|
|
64
|
-
"@ultimat3/seo": "22.
|
|
65
|
-
"@ultimat3/storage": "22.
|
|
66
|
-
"@ultimat3/testing": "22.
|
|
67
|
-
"@ultimat3/time": "22.
|
|
41
|
+
"@ultimat3/action": "22.12.0",
|
|
42
|
+
"@ultimat3/admin": "22.12.0",
|
|
43
|
+
"@ultimat3/ai": "22.12.0",
|
|
44
|
+
"@ultimat3/auth": "22.12.0",
|
|
45
|
+
"@ultimat3/cache": "22.12.0",
|
|
46
|
+
"@ultimat3/core": "22.12.0",
|
|
47
|
+
"@ultimat3/db": "22.12.0",
|
|
48
|
+
"@ultimat3/entity": "22.12.0",
|
|
49
|
+
"@ultimat3/flags": "22.12.0",
|
|
50
|
+
"@ultimat3/http": "22.12.0",
|
|
51
|
+
"@ultimat3/i18n": "22.12.0",
|
|
52
|
+
"@ultimat3/jobs": "22.12.0",
|
|
53
|
+
"@ultimat3/mail": "22.12.0",
|
|
54
|
+
"@ultimat3/manifest": "22.12.0",
|
|
55
|
+
"@ultimat3/mcp": "22.12.0",
|
|
56
|
+
"@ultimat3/money": "22.12.0",
|
|
57
|
+
"@ultimat3/notify": "22.12.0",
|
|
58
|
+
"@ultimat3/policy": "22.12.0",
|
|
59
|
+
"@ultimat3/pwa": "22.12.0",
|
|
60
|
+
"@ultimat3/query": "22.12.0",
|
|
61
|
+
"@ultimat3/realtime": "22.12.0",
|
|
62
|
+
"@ultimat3/render": "22.12.0",
|
|
63
|
+
"@ultimat3/schema": "22.12.0",
|
|
64
|
+
"@ultimat3/seo": "22.12.0",
|
|
65
|
+
"@ultimat3/storage": "22.12.0",
|
|
66
|
+
"@ultimat3/testing": "22.12.0",
|
|
67
|
+
"@ultimat3/time": "22.12.0",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/app-reload-graph.ts
CHANGED
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
// A module that DEFINES a primitive is PINNED and never leaves the registry: its exports are held
|
|
17
17
|
// by every importer and by a registry that refuses a second definition of one name (an entity is
|
|
18
18
|
// `X_ENTITY_DUPLICATE`; a second action instance is a handler nobody routes to). The closure stops
|
|
19
|
-
// at a pinned module
|
|
19
|
+
// at a pinned module — and RECORDS it (`takeStalePins`): a pinned module above a change still holds
|
|
20
|
+
// the old instance of what changed (a slice's service under its query, an admin page's view under
|
|
21
|
+
// `defineAdmin`), so only a new process serves the save. `x dev` restarts on it (`dev-supervisor.ts`).
|
|
20
22
|
|
|
21
23
|
// why: Bun ships no path API; import specifiers resolve against the importing file's directory.
|
|
22
24
|
import { dirname, resolve } from 'node:path';
|
|
@@ -37,6 +39,27 @@ const nodes = new Map<string, Node>();
|
|
|
37
39
|
/** Reverse edges: file → the files that import it. */
|
|
38
40
|
const importers = new Map<string, Set<string>>();
|
|
39
41
|
const pinned = new Set<string>();
|
|
42
|
+
|
|
43
|
+
/** A pinned module that still holds the old instance of `changed` — or IS `changed`. */
|
|
44
|
+
export interface StalePin {
|
|
45
|
+
/** The saved file, absolute. */
|
|
46
|
+
readonly changed: string;
|
|
47
|
+
/** The module that defines a primitive and was not re-evaluated, absolute. */
|
|
48
|
+
readonly pinned: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What the last rescans' closures stopped at, until `takeStalePins` reads it. */
|
|
52
|
+
let stale: StalePin[] = [];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every pinned module a rescan since the last call could not refresh, sorted by pinned path, and
|
|
56
|
+
* forgets them. Empty: everything the saves reached was re-evaluated and the process serves it.
|
|
57
|
+
*/
|
|
58
|
+
export function takeStalePins(): readonly StalePin[] {
|
|
59
|
+
const taken = stale;
|
|
60
|
+
stale = [];
|
|
61
|
+
return taken;
|
|
62
|
+
}
|
|
40
63
|
/**
|
|
41
64
|
* Edges cost a transpile scan and a resolve per import (~300 ms over notificado.co's 1200 modules),
|
|
42
65
|
* so only the one process that rescans pays it: `x dev` turns this on before its first scan. Off,
|
|
@@ -54,6 +77,7 @@ export function resetReloadGraph(): void {
|
|
|
54
77
|
nodes.clear();
|
|
55
78
|
importers.clear();
|
|
56
79
|
pinned.clear();
|
|
80
|
+
stale = [];
|
|
57
81
|
}
|
|
58
82
|
|
|
59
83
|
/** Marks a module that defines a primitive: never evicted, and the closure stops at it. */
|
|
@@ -185,18 +209,36 @@ async function changedFiles(root: string): Promise<string[]> {
|
|
|
185
209
|
return changed;
|
|
186
210
|
}
|
|
187
211
|
|
|
188
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* `changed` and everything above it, stopping at — and excluding — every pinned module. Each
|
|
214
|
+
* pinned module a walk stopped at is recorded against the change that reached it, once.
|
|
215
|
+
*/
|
|
189
216
|
function closure(changed: readonly string[]): Set<string> {
|
|
190
217
|
const dirty = new Set<string>();
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
218
|
+
const reached = new Map<string, string>();
|
|
219
|
+
for (const origin of changed) {
|
|
220
|
+
if (pinned.has(origin)) {
|
|
221
|
+
if (!reached.has(origin)) reached.set(origin, origin);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const seen = new Set<string>();
|
|
225
|
+
const stack = [origin];
|
|
226
|
+
while (stack.length > 0) {
|
|
227
|
+
const path = stack.pop();
|
|
228
|
+
if (path === undefined || seen.has(path)) continue;
|
|
229
|
+
seen.add(path);
|
|
230
|
+
dirty.add(path);
|
|
231
|
+
for (const importer of importers.get(path) ?? []) {
|
|
232
|
+
if (!pinned.has(importer)) stack.push(importer);
|
|
233
|
+
else if (!reached.has(importer)) reached.set(importer, origin);
|
|
234
|
+
}
|
|
198
235
|
}
|
|
199
236
|
}
|
|
237
|
+
const held = new Set(stale.map((entry) => entry.pinned));
|
|
238
|
+
const found = [...reached]
|
|
239
|
+
.filter(([module]) => !held.has(module))
|
|
240
|
+
.map(([module, origin]) => ({ changed: origin, pinned: module }));
|
|
241
|
+
stale = [...stale, ...found].sort((a, b) => (a.pinned < b.pinned ? -1 : 1));
|
|
200
242
|
return dirty;
|
|
201
243
|
}
|
|
202
244
|
|
package/src/bin.ts
CHANGED
|
@@ -33,6 +33,20 @@ if (local !== undefined) {
|
|
|
33
33
|
process.exit(await child.exited);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// `x dev` supervises a child that serves (`dev-supervisor.ts`): a save that reaches a module which
|
|
37
|
+
// defines a primitive is a restart, since no re-import in one process can serve it. After the
|
|
38
|
+
// handoff above, so the supervisor and its children are the app's own CLI.
|
|
39
|
+
// Imported only for `dev`: every other command's process must not load, or register, anything more.
|
|
40
|
+
if (Bun.argv[2] === 'dev') {
|
|
41
|
+
const { devSupervision, freeDevPort, superviseDev } = await import('./dev-supervisor');
|
|
42
|
+
const supervision = devSupervision(Bun.argv.slice(2), Bun.env, freeDevPort);
|
|
43
|
+
if (supervision !== undefined) {
|
|
44
|
+
process.exit(
|
|
45
|
+
await superviseDev({ bin: import.meta.path, argv: supervision.argv, env: Bun.env }),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
const code = await dispatch({
|
|
37
51
|
argv: Bun.argv.slice(2),
|
|
38
52
|
cwd: process.cwd(),
|
package/src/cmd-dev.ts
CHANGED
|
@@ -16,7 +16,8 @@ import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
|
16
16
|
import { createIsrController } from '@ultimat3/render/server';
|
|
17
17
|
import { loadSignInPath } from './app-auth';
|
|
18
18
|
import { appManifest } from './app-manifest';
|
|
19
|
-
import {
|
|
19
|
+
import type { StalePin } from './app-reload-graph';
|
|
20
|
+
import { enableReloadTracking, takeStalePins } from './app-reload-graph';
|
|
20
21
|
import { requireAppRoot } from './app-root';
|
|
21
22
|
import { loadAppRuntime } from './app-runtime';
|
|
22
23
|
import { devSpec } from './cmd-dev-spec';
|
|
@@ -29,6 +30,7 @@ import { createStatementLedger } from './dev-n-plus-one';
|
|
|
29
30
|
import { devPortFor } from './dev-port';
|
|
30
31
|
import { coalesceReloads } from './dev-reload';
|
|
31
32
|
import { devRouteTable } from './dev-route-table';
|
|
33
|
+
import { childRestart, restartFinding } from './dev-supervisor';
|
|
32
34
|
import { createTraceRecorder } from './dev-traces';
|
|
33
35
|
import { watchTree } from './dev-watch-tree';
|
|
34
36
|
import { holdUntilShutdown } from './hold';
|
|
@@ -108,6 +110,12 @@ export interface StartDevOptions {
|
|
|
108
110
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
109
111
|
readonly roles?: readonly Role[];
|
|
110
112
|
readonly onReload?: (file: string, durationMs: number) => void;
|
|
113
|
+
/**
|
|
114
|
+
* A save reached a module that defines a primitive, so this process cannot serve it
|
|
115
|
+
* (`takeStalePins`). Given, it is called INSTEAD of `onReload` — `x dev`'s child drains and exits
|
|
116
|
+
* for its supervisor to boot a fresh one. Absent, the save is `X_DEV_RESTART_REQUIRED` on `/_x`.
|
|
117
|
+
*/
|
|
118
|
+
readonly onRestart?: (pins: readonly StalePin[]) => void;
|
|
111
119
|
}
|
|
112
120
|
|
|
113
121
|
/**
|
|
@@ -294,8 +302,14 @@ async function bootDev(
|
|
|
294
302
|
state.islands = islands;
|
|
295
303
|
for (const path of isr.store().paths()) isr.store().delete(path);
|
|
296
304
|
state.reloads += 1;
|
|
297
|
-
|
|
298
|
-
|
|
305
|
+
// Read on EVERY rebuild, so a pin one save reached is never reported against the next save.
|
|
306
|
+
const pins = takeStalePins();
|
|
307
|
+
state.reloadFinding =
|
|
308
|
+
pins.length > 0 && options.onRestart === undefined
|
|
309
|
+
? restartFinding(options.root, pins)
|
|
310
|
+
: undefined;
|
|
311
|
+
if (pins.length > 0 && options.onRestart !== undefined) options.onRestart(pins);
|
|
312
|
+
else options.onReload?.(file, Math.round(performance.now() - started));
|
|
299
313
|
},
|
|
300
314
|
// Same rule as a module that will not import: a save the manifest cannot be rebuilt from is
|
|
301
315
|
// a finding on `/_x`, never an unhandled rejection that takes the dev server down.
|
|
@@ -375,6 +389,7 @@ export const devCommand: CliCommand = {
|
|
|
375
389
|
// The directory is CLAIMED from here down, so a boot that throws has to give it back — the
|
|
376
390
|
// `releaseBoot` shape, with one acquisition. Without it the first failed `x dev` in a shell
|
|
377
391
|
// refuses every later one with a pid that is no longer running.
|
|
392
|
+
const restart = childRestart(root, ctx.env);
|
|
378
393
|
const server = await startDev({
|
|
379
394
|
root,
|
|
380
395
|
port,
|
|
@@ -384,6 +399,8 @@ export const devCommand: CliCommand = {
|
|
|
384
399
|
if (!ctx.args.json)
|
|
385
400
|
process.stdout.write(`${msg('cli.dev.hmr', { file, ms: durationMs })}\n`);
|
|
386
401
|
},
|
|
402
|
+
// Supervised, a save this process cannot serve is a drain and an exit for a fresh child.
|
|
403
|
+
...restart.options,
|
|
387
404
|
}).catch((error: unknown) => {
|
|
388
405
|
release();
|
|
389
406
|
throw error;
|
|
@@ -461,12 +478,17 @@ export const devCommand: CliCommand = {
|
|
|
461
478
|
// directory locked by a process that no longer exists.
|
|
462
479
|
return {
|
|
463
480
|
...result,
|
|
464
|
-
hold: holdUntilShutdown(
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
481
|
+
hold: holdUntilShutdown(
|
|
482
|
+
'dev',
|
|
483
|
+
async () => {
|
|
484
|
+
// The lock first: a stop() that throws must not leave a file claiming this pid still owns
|
|
485
|
+
// the directory, because the next boot would then refuse for a process that is gone.
|
|
486
|
+
clearLock(services.stateDir);
|
|
487
|
+
await server.stop();
|
|
488
|
+
},
|
|
489
|
+
// Released — the port, the lock and the embedded database are free for the next child.
|
|
490
|
+
{ exit: restart.exit },
|
|
491
|
+
),
|
|
470
492
|
};
|
|
471
493
|
},
|
|
472
494
|
};
|
package/src/dev-port.ts
CHANGED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// `x dev` as two processes: this one supervises, a child serves. A save that reaches a module which
|
|
2
|
+
// DEFINES a primitive (`app-reload-graph.ts`'s pinned modules — a query over a slice's service, an
|
|
3
|
+
// admin page's view under `defineAdmin`) cannot be served by re-importing: the registries and the
|
|
4
|
+
// route table hold that module's first instance. The child exits `DEV_RESTART_EXIT_CODE` instead
|
|
5
|
+
// of logging "reloaded" over stale code, and this loop boots a fresh one on the same port. Every
|
|
6
|
+
// other save stays the in-process reload it was — a restart is paid only where nothing else works.
|
|
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
|
+
import type { StalePin } from './app-reload-graph';
|
|
12
|
+
import { devSpec } from './cmd-dev-spec';
|
|
13
|
+
import { devPortFor } from './dev-port';
|
|
14
|
+
import { msg } from './messages';
|
|
15
|
+
import type { Finding } from './output';
|
|
16
|
+
import { parseArgs } from './parse';
|
|
17
|
+
|
|
18
|
+
/** Set on the child: it serves, and a pinned save makes it exit for a restart. */
|
|
19
|
+
export const DEV_CHILD_ENV = 'ULTIMATE_DEV_CHILD';
|
|
20
|
+
/** `EX_TEMPFAIL`: "try again" — the one exit code the supervisor answers with a new child. */
|
|
21
|
+
export const DEV_RESTART_EXIT_CODE = 75;
|
|
22
|
+
|
|
23
|
+
/** What the supervisor runs the child with: the argv, its port pinned. */
|
|
24
|
+
export interface DevSupervision {
|
|
25
|
+
readonly argv: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether this `x …` invocation is a supervised `x dev`, and with what child argv. Not for the
|
|
30
|
+
* child itself, `--once` (it boots, reports and exits — nothing to restart), `--help`, or an argv
|
|
31
|
+
* the parse refuses (`dispatch` reports that, in the child's place). `--port 0` is pinned to one
|
|
32
|
+
* free port here, so a restarted child binds the address the first one printed.
|
|
33
|
+
*/
|
|
34
|
+
export function devSupervision(
|
|
35
|
+
argv: readonly string[],
|
|
36
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
37
|
+
pickPort: () => number,
|
|
38
|
+
): DevSupervision | undefined {
|
|
39
|
+
if (argv[0] !== 'dev' || env[DEV_CHILD_ENV] === '1') return undefined;
|
|
40
|
+
try {
|
|
41
|
+
const args = parseArgs(argv, [devSpec]);
|
|
42
|
+
if (args.help || args.flags.get('once') === true) return undefined;
|
|
43
|
+
return { argv: devPortFor(args, env) === 0 ? withPort(argv, pickPort()) : argv };
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A port nothing holds right now, asked of the OS — what `--port 0` is pinned to. */
|
|
50
|
+
export function freeDevPort(): number {
|
|
51
|
+
const probe = Bun.serve({ port: 0, fetch: () => new Response() });
|
|
52
|
+
const port = probe.port ?? 0;
|
|
53
|
+
probe.stop(true);
|
|
54
|
+
return port;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `argv` with every `--port` removed and `--port <port>` appended. */
|
|
58
|
+
function withPort(argv: readonly string[], port: number): readonly string[] {
|
|
59
|
+
const out: string[] = [];
|
|
60
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
61
|
+
const token = argv[index] ?? '';
|
|
62
|
+
if (token === '--port') {
|
|
63
|
+
index += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (token.startsWith('--port=')) continue;
|
|
67
|
+
out.push(token);
|
|
68
|
+
}
|
|
69
|
+
return [...out, '--port', String(port)];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The child as the loop sees it — `Bun.spawn`'s answer, narrowed so a test can hand one in. */
|
|
73
|
+
export interface DevChild {
|
|
74
|
+
readonly exited: Promise<number>;
|
|
75
|
+
kill(signal: 'SIGINT' | 'SIGTERM'): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SuperviseDevInput {
|
|
79
|
+
readonly bin: string;
|
|
80
|
+
readonly argv: readonly string[];
|
|
81
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
82
|
+
readonly spawn?: (
|
|
83
|
+
command: readonly string[],
|
|
84
|
+
env: Record<string, string | undefined>,
|
|
85
|
+
) => DevChild;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const spawnInherited = (
|
|
89
|
+
command: readonly string[],
|
|
90
|
+
env: Record<string, string | undefined>,
|
|
91
|
+
): DevChild =>
|
|
92
|
+
Bun.spawn([...command], { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', env });
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Runs children until one exits with anything but `DEV_RESTART_EXIT_CODE`, and answers that code.
|
|
96
|
+
* SIGINT and SIGTERM are forwarded — a terminal's Ctrl-C reaches the child on its own too, and its
|
|
97
|
+
* drain is idempotent — and once one arrived no child is started again: a Ctrl-C during a restart
|
|
98
|
+
* is a stop, never a respawn.
|
|
99
|
+
*/
|
|
100
|
+
export async function superviseDev(input: SuperviseDevInput): Promise<number> {
|
|
101
|
+
const spawn = input.spawn ?? spawnInherited;
|
|
102
|
+
let child: DevChild | undefined;
|
|
103
|
+
let stopping = false;
|
|
104
|
+
const forward = (signal: 'SIGINT' | 'SIGTERM') => (): void => {
|
|
105
|
+
stopping = true;
|
|
106
|
+
child?.kill(signal);
|
|
107
|
+
};
|
|
108
|
+
const onInt = forward('SIGINT');
|
|
109
|
+
const onTerm = forward('SIGTERM');
|
|
110
|
+
process.on('SIGINT', onInt);
|
|
111
|
+
process.on('SIGTERM', onTerm);
|
|
112
|
+
try {
|
|
113
|
+
for (;;) {
|
|
114
|
+
child = spawn([process.execPath, input.bin, ...input.argv], {
|
|
115
|
+
...input.env,
|
|
116
|
+
[DEV_CHILD_ENV]: '1',
|
|
117
|
+
});
|
|
118
|
+
const code = await child.exited;
|
|
119
|
+
if (code !== DEV_RESTART_EXIT_CODE || stopping) return code;
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
process.off('SIGINT', onInt);
|
|
123
|
+
process.off('SIGTERM', onTerm);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `apps/web/app/greet/service.ts under apps/web/app/greet/queries.ts`, one per pinned module. */
|
|
128
|
+
export function restartReason(root: string, pins: readonly StalePin[]): string {
|
|
129
|
+
return pins
|
|
130
|
+
.map(({ changed, pinned }) =>
|
|
131
|
+
changed === pinned
|
|
132
|
+
? relative(root, pinned)
|
|
133
|
+
: `${relative(root, changed)} under ${relative(root, pinned)}`,
|
|
134
|
+
)
|
|
135
|
+
.join(', ');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `/_x`'s finding for a process nothing supervises (an embedded `startDev()` with no `onRestart`). */
|
|
139
|
+
export function restartFinding(root: string, pins: readonly StalePin[]): Finding {
|
|
140
|
+
const first = pins[0];
|
|
141
|
+
return {
|
|
142
|
+
code: 'X_DEV_RESTART_REQUIRED',
|
|
143
|
+
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`,
|
|
144
|
+
fix: 'restart x dev (a supervised `x dev` restarts itself; an embedded startDev() passes onRestart)',
|
|
145
|
+
at: first === undefined ? '' : relative(root, first.pinned),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The child's half: what `startDev` is handed, and what the hold calls once released. */
|
|
150
|
+
export interface ChildRestart {
|
|
151
|
+
readonly options: { readonly onRestart?: (pins: readonly StalePin[]) => void };
|
|
152
|
+
readonly exit: (code: number) => void;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Only a supervised child restarts: it says why on stderr (fd 1 may be `--json`'s one document),
|
|
157
|
+
* starts core's drain — the hold then releases the lock, the port and the embedded database — and
|
|
158
|
+
* exits `DEV_RESTART_EXIT_CODE` once released. Anything else keeps the finding on `/_x`.
|
|
159
|
+
*/
|
|
160
|
+
export function childRestart(
|
|
161
|
+
root: string,
|
|
162
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
163
|
+
): ChildRestart {
|
|
164
|
+
if (env[DEV_CHILD_ENV] !== '1') return { options: {}, exit: () => undefined };
|
|
165
|
+
let restarting = false;
|
|
166
|
+
return {
|
|
167
|
+
options: {
|
|
168
|
+
onRestart: (pins) => {
|
|
169
|
+
process.stderr.write(`${msg('cli.dev.restart', { reason: restartReason(root, pins) })}\n`);
|
|
170
|
+
restarting = true;
|
|
171
|
+
void drain('restart');
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
exit: () => {
|
|
175
|
+
if (restarting) process.exit(DEV_RESTART_EXIT_CODE);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/error-codes.ts
CHANGED
|
@@ -203,6 +203,7 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
203
203
|
'X_VERIFY_MERGE_INCOMPLETE',
|
|
204
204
|
'X_VERIFY_MERGE_INPUT',
|
|
205
205
|
'X_SITEMAP_EXTRA_INVALID',
|
|
206
|
+
'X_DEV_RESTART_REQUIRED',
|
|
206
207
|
] as const;
|
|
207
208
|
|
|
208
209
|
/**
|
|
@@ -355,6 +356,8 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
355
356
|
X_VERIFY_MERGE_INCOMPLETE: 'x verify merge was handed parts that do not add up to the whole gate',
|
|
356
357
|
X_VERIFY_MERGE_INPUT: 'a part handed to x verify merge is not an x verify --json document',
|
|
357
358
|
X_SITEMAP_EXTRA_INVALID: 'a seo.sitemap.extra path names no public page',
|
|
359
|
+
X_DEV_RESTART_REQUIRED:
|
|
360
|
+
'a save reached a module that defines a primitive; only a new process serves it',
|
|
358
361
|
};
|
|
359
362
|
|
|
360
363
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
package/src/mcp-errors.ts
CHANGED
|
@@ -253,6 +253,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
253
253
|
'x verify --only budgets --json # after moving each await in Page into export const load',
|
|
254
254
|
X_SITEMAP_EXTRA_INVALID:
|
|
255
255
|
'x routes --json # then list in seo.sitemap.extra only a path a registered app/ route without a policy answers',
|
|
256
|
+
X_DEV_RESTART_REQUIRED:
|
|
257
|
+
'x dev --json # a fresh process; the supervised x dev restarts itself on such a save',
|
|
256
258
|
};
|
|
257
259
|
|
|
258
260
|
const isCliCode = (code: string): code is CliErrorCode =>
|
package/src/messages.ts
CHANGED
|
@@ -76,6 +76,7 @@ const CATALOG = {
|
|
|
76
76
|
'cli.dev.mail.external': 'mail=external({driver} via {detail})',
|
|
77
77
|
'cli.dev.mail.refused': 'mail=refused({detail})',
|
|
78
78
|
'cli.dev.hmr': 'reloaded {file} in {ms}ms',
|
|
79
|
+
'cli.dev.restart': 'restarting: {reason} — a module that defines a primitive holds the old code',
|
|
79
80
|
// A hard kill leaves the lock behind and that is normal, not a fault — worth one line so a
|
|
80
81
|
// reader knows why the boot paused, and never a finding.
|
|
81
82
|
'cli.dev.staleLock': 'cleared a stale dev.lock — the previous x dev did not shut down cleanly',
|