@1e0zj/dsh-plugin-mall 0.4.7 → 0.4.14
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/README.md +4 -3
- package/package.json +1 -1
- package/src/cli.js +928 -68
- package/src/client.js +25 -4
- package/src/guard.js +174 -6
- package/src/index.js +603 -42
- package/src/installer.js +39 -18
- package/src/restart-protocol.js +258 -0
- package/src/terminal.js +11 -0
package/src/installer.js
CHANGED
|
@@ -16,7 +16,8 @@ import { createHash } from "node:crypto";
|
|
|
16
16
|
import { dump, load } from "js-yaml";
|
|
17
17
|
import { DEFAULT_PROFILE_BUNDLES, PROFILE_TEMPLATES, initProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
18
18
|
import { describeBuildScripts, npmNameOf } from "./github.js";
|
|
19
|
-
import { clearPendingApprovalPause, commitPendingSnapshot, createProfileSnapshot, describeRollbackRebuild, markPendingApprovalPause, markPendingSnapshot, mcpEntryAuditForInstall, pausedCandidateBeforeState, pendingApprovalPaused, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot,
|
|
19
|
+
import { clearPendingApprovalPause, commitPendingSnapshot, createProfileSnapshot, describeRollbackRebuild, markPendingApprovalPause, markPendingSnapshot, mcpEntryAuditForInstall, pausedCandidateBeforeState, pendingApprovalPaused, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot, validatePendingProfile, validateRemoveCompletion } from "./guard.js";
|
|
20
|
+
import { stripTerminalControlSequences } from "./terminal.js";
|
|
20
21
|
|
|
21
22
|
// ── spec normalization ──────────────────────────────────────────────────────
|
|
22
23
|
|
|
@@ -630,12 +631,20 @@ const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
|
|
|
630
631
|
*/
|
|
631
632
|
function parseIgnoredBuilds(output) {
|
|
632
633
|
const found = new Map();
|
|
634
|
+
// pnpm enables colours even though stdout/stderr are pipes on some Windows
|
|
635
|
+
// setups (observed with pnpm 11). A reset code after the selector makes the
|
|
636
|
+
// anchored `@version` parser miss, so `node-pty@1.1.0\x1b[39m` used to be
|
|
637
|
+
// treated as an invalid package name and the approval pause became an
|
|
638
|
+
// ordinary failed install. Strip CSI terminal controls again here as a
|
|
639
|
+
// fail-closed parsing boundary; the stream capture already removes them from
|
|
640
|
+
// the plain-text job log shown to users.
|
|
641
|
+
const plainOutput = stripTerminalControlSequences(output);
|
|
633
642
|
// Only pnpm's own notice line is a parsing source. "allowBuilds" also
|
|
634
643
|
// appears in pnpm's advice/error text (never followed by a name list), and
|
|
635
644
|
// matching it fed error echoes into the allow-list, corrupting the YAML.
|
|
636
645
|
const pattern = /(?:Ignored build scripts|onlyBuiltDependencies)\s*:\s*([^\n]+)/gi;
|
|
637
646
|
let match;
|
|
638
|
-
while ((match = pattern.exec(
|
|
647
|
+
while ((match = pattern.exec(plainOutput)) !== null) {
|
|
639
648
|
for (const raw of match[1].split(",")) {
|
|
640
649
|
const candidate = raw.trim();
|
|
641
650
|
if (candidate.length === 0) continue;
|
|
@@ -1562,10 +1571,11 @@ function pendingMarkerPath(profileDir) {
|
|
|
1562
1571
|
function renderApprovalNeeded(spec, disclosure) {
|
|
1563
1572
|
const lines = [
|
|
1564
1573
|
`installing ${spec} requires running install-time code — approval needed.`,
|
|
1565
|
-
"No install script ran and no plugin code loaded. The
|
|
1566
|
-
"
|
|
1567
|
-
"
|
|
1568
|
-
"before the verified tree is rebuilt.
|
|
1574
|
+
"No install script ran and no plugin code loaded. The candidate is staged",
|
|
1575
|
+
"with its scripts blocked, and the original profile snapshot is retained.",
|
|
1576
|
+
"On approval, the materialized bytes and commands must match this disclosure",
|
|
1577
|
+
"before the verified tree is rebuilt. If you do not approve, restart dsh or",
|
|
1578
|
+
"run `dsh-plugin-guard guard recover` to roll the paused transaction back.",
|
|
1569
1579
|
"",
|
|
1570
1580
|
];
|
|
1571
1581
|
for (const entry of disclosure) {
|
|
@@ -1661,8 +1671,9 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1661
1671
|
const collected = [];
|
|
1662
1672
|
const deltaQueue = [];
|
|
1663
1673
|
const push = (text) => {
|
|
1664
|
-
|
|
1665
|
-
|
|
1674
|
+
const plainText = stripTerminalControlSequences(text);
|
|
1675
|
+
collected.push(plainText);
|
|
1676
|
+
deltaQueue.push(plainText);
|
|
1666
1677
|
};
|
|
1667
1678
|
|
|
1668
1679
|
const workspacePath = join(profileDir, "pnpm-workspace.yaml");
|
|
@@ -2154,7 +2165,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
|
|
|
2154
2165
|
}
|
|
2155
2166
|
const deltaQueue = [];
|
|
2156
2167
|
const push = (text) => {
|
|
2157
|
-
deltaQueue.push(text);
|
|
2168
|
+
deltaQueue.push(stripTerminalControlSequences(text));
|
|
2158
2169
|
};
|
|
2159
2170
|
let current = undefined;
|
|
2160
2171
|
let cancelRequested = false; // see endedByCancel: exit codes cannot tell us this on Windows
|
|
@@ -2221,7 +2232,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
|
|
|
2221
2232
|
try {
|
|
2222
2233
|
proc = (_spawn ?? spawn)(plan.command, ["remove", packageName, "--reporter=append-only"], {
|
|
2223
2234
|
cwd: profileDir,
|
|
2224
|
-
env: process.env,
|
|
2235
|
+
env: pnpmGuardEnv(process.env),
|
|
2225
2236
|
shell: plan.shell,
|
|
2226
2237
|
stdio: ["ignore", "pipe", "pipe"],
|
|
2227
2238
|
windowsHide: true,
|
|
@@ -2284,7 +2295,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
|
|
|
2284
2295
|
// 退出码 0 不等于卸干净了。落盘校验用的是启动恢复同一套判据:
|
|
2285
2296
|
// profile 整体仍然自洽,且这个包确实从清单和装配层里消失了。任何一条
|
|
2286
2297
|
// 不过就还原——一个「装着但坏」的 profile 比一个没卸掉的插件糟得多。
|
|
2287
|
-
const profileCheck =
|
|
2298
|
+
const profileCheck = validatePendingProfile(profileDir);
|
|
2288
2299
|
const removeCheck = validateRemoveCompletion(profileDir, packageName);
|
|
2289
2300
|
if (!profileCheck.ok || !removeCheck.ok) {
|
|
2290
2301
|
const blockers = [...profileCheck.issues, ...removeCheck.issues]
|
|
@@ -2703,7 +2714,10 @@ async function runTransactionFixtures() {
|
|
|
2703
2714
|
try {
|
|
2704
2715
|
materializeFakePackage(profileDir, "some-plugin", "1.0.0");
|
|
2705
2716
|
materializeFakePackage(profileDir, "node-pty", "1.0.0", { install: "node install.js" });
|
|
2706
|
-
|
|
2717
|
+
// pnpm 11 on Windows may colour stderr even when it is captured through a
|
|
2718
|
+
// pipe. In particular, the reset code lands directly after the selector.
|
|
2719
|
+
const colouredIgnoredBuilds = "Packages are cloned\n\u001b[31mIgnored build scripts: node-pty@1.0.0\u001b[39m\nDone\n";
|
|
2720
|
+
const { spawnFn, calls } = scriptedSpawn([{ code: 0, out: colouredIgnoredBuilds }]);
|
|
2707
2721
|
const producer = runInstall({
|
|
2708
2722
|
profile: "p",
|
|
2709
2723
|
spec: "some-plugin",
|
|
@@ -2725,7 +2739,7 @@ async function runTransactionFixtures() {
|
|
|
2725
2739
|
const output = producer.readOutput();
|
|
2726
2740
|
const markerBefore = pendingMarkerPath(profileDir);
|
|
2727
2741
|
check(
|
|
2728
|
-
"退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
|
|
2742
|
+
"退出码 0 + 彩色 Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
|
|
2729
2743
|
outcome.status === "failed"
|
|
2730
2744
|
&& Array.isArray(outcome.needsApproval)
|
|
2731
2745
|
&& outcome.needsApproval.some((entry) => entry.name === "node-pty")
|
|
@@ -2740,7 +2754,11 @@ async function runTransactionFixtures() {
|
|
|
2740
2754
|
&& entry.weeklyDownloads === 123)
|
|
2741
2755
|
&& calls.length === 1
|
|
2742
2756
|
&& existsSync(markerBefore)
|
|
2743
|
-
&& /
|
|
2757
|
+
&& /candidate is staged/.test(outcome.detail ?? "")
|
|
2758
|
+
&& !/profile was restored/.test(outcome.detail ?? "")
|
|
2759
|
+
&& /paused for build-script approval/.test(output)
|
|
2760
|
+
&& /Ignored build scripts: node-pty@1\.0\.0/.test(output)
|
|
2761
|
+
&& !output.includes("\u001b["),
|
|
2744
2762
|
`status=${outcome.status} calls=${calls.length} marker=${existsSync(markerBefore)}`,
|
|
2745
2763
|
);
|
|
2746
2764
|
check(
|
|
@@ -3413,7 +3431,7 @@ async function runTransactionFixtures() {
|
|
|
3413
3431
|
let snapshotsWhileRunning = [];
|
|
3414
3432
|
const { spawnFn } = scriptedSpawn([{
|
|
3415
3433
|
code: 0,
|
|
3416
|
-
out: "
|
|
3434
|
+
out: "\u001b[32mDone\u001b[39m\n",
|
|
3417
3435
|
// pnpm 真正卸掉:清单与目录都拿走,落盘校验才会通过。
|
|
3418
3436
|
beforeExit: () => {
|
|
3419
3437
|
snapshotsWhileRunning = listSnapshots();
|
|
@@ -3423,14 +3441,17 @@ async function runTransactionFixtures() {
|
|
|
3423
3441
|
rmSync(join(profileDir, "node_modules", "pkg-f"), { recursive: true, force: true });
|
|
3424
3442
|
},
|
|
3425
3443
|
}]);
|
|
3426
|
-
const
|
|
3444
|
+
const producer = runRemove({ profile: "p", packageName: "pkg-f", _profileDir: profileDir, _spawn: spawnFn });
|
|
3445
|
+
const outcome = await producer.done;
|
|
3446
|
+
const output = producer.readOutput();
|
|
3427
3447
|
const snapshotsLeft = listSnapshots();
|
|
3428
3448
|
check(
|
|
3429
|
-
"卸载成功 → marker 与 snapshot 都被提交清理(且快照确实创建过)",
|
|
3449
|
+
"卸载成功 → 日志去色,marker 与 snapshot 都被提交清理(且快照确实创建过)",
|
|
3430
3450
|
outcome.status === "completed"
|
|
3431
3451
|
&& snapshotsWhileRunning.length === 1
|
|
3432
3452
|
&& !existsSync(pendingMarkerPath(profileDir))
|
|
3433
|
-
&& snapshotsLeft.length === 0
|
|
3453
|
+
&& snapshotsLeft.length === 0
|
|
3454
|
+
&& output === "Done\n",
|
|
3434
3455
|
`status=${outcome.status} 运行中快照=${snapshotsWhileRunning.join(",")} marker=${existsSync(pendingMarkerPath(profileDir))} 残留快照=${snapshotsLeft.join(",")} detail=${JSON.stringify(outcome.detail)}`,
|
|
3435
3456
|
);
|
|
3436
3457
|
} finally {
|
package/src/restart-protocol.js
CHANGED
|
@@ -2,10 +2,33 @@
|
|
|
2
2
|
// standalone guard CLI. The parent must not infer readiness from a child pid:
|
|
3
3
|
// an incompatible CLI can spawn successfully and then die on argument parsing.
|
|
4
4
|
|
|
5
|
+
import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
|
|
5
7
|
export const RESTART_HELPER_READY_TYPE = "@1e0zj/dsh-plugin-mall:restart-helper-ready";
|
|
6
8
|
export const RESTART_HELPER_PROTOCOL_VERSION = 1;
|
|
7
9
|
export const RESTART_RESPONSE_DRAIN_MS = 1000;
|
|
8
10
|
|
|
11
|
+
// cmd.exe metacharacters. Rather than "escaping" these for a cmd round trip
|
|
12
|
+
// (cmd's quoting rules are famously inconsistent), the launch wrapper refuses
|
|
13
|
+
// them outright — a dsh invocation never needs them.
|
|
14
|
+
export const CMD_METACHAR_RE = /[&|<>^%!\r\n]/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Quote one token for a %ComSpec% /d /s /c command line. Follows the MSVCRT /
|
|
18
|
+
* CommandLineToArgvW rules (backslashes before a quote or the closing quote are
|
|
19
|
+
* doubled, quotes become \") and rejects cmd metacharacters instead of trying
|
|
20
|
+
* to escape them. The command after `--` is never concatenated unquoted.
|
|
21
|
+
*/
|
|
22
|
+
export function quoteCmdArg(token) {
|
|
23
|
+
const value = String(token ?? "");
|
|
24
|
+
if (value.length === 0) return '""';
|
|
25
|
+
if (CMD_METACHAR_RE.test(value)) {
|
|
26
|
+
throw new Error(`cannot quote safely for cmd.exe (shell metacharacter present): ${JSON.stringify(value)}`);
|
|
27
|
+
}
|
|
28
|
+
const escaped = value.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/, "$1$1");
|
|
29
|
+
return `"${escaped}"`;
|
|
30
|
+
}
|
|
31
|
+
|
|
9
32
|
export function createRestartHelperReadyMessage(awaitExitPid) {
|
|
10
33
|
return {
|
|
11
34
|
type: RESTART_HELPER_READY_TYPE,
|
|
@@ -142,3 +165,238 @@ export function superviseRestartHelper(child, {
|
|
|
142
165
|
state: () => phase,
|
|
143
166
|
};
|
|
144
167
|
}
|
|
168
|
+
|
|
169
|
+
// ── restart plan & file-channel handoff (Windows visible console) ────────────
|
|
170
|
+
//
|
|
171
|
+
// A visible restart launches the guard through `cmd /c start`: the guard runs
|
|
172
|
+
// in a brand-new console as a grandchild, so neither stdio nor an IPC channel
|
|
173
|
+
// connects it back to the old Host. The launch plan travels as a JSON file
|
|
174
|
+
// (never concatenated into the cmd command line), and readiness is
|
|
175
|
+
// acknowledged through a second file with the same semantics the IPC message
|
|
176
|
+
// carries.
|
|
177
|
+
|
|
178
|
+
export const RESTART_PLAN_TYPE = "@1e0zj/dsh-plugin-mall:restart-plan";
|
|
179
|
+
export const RESTART_PLAN_VERSION = 1;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Validate the payload of a restart plan file. Structural checks only:
|
|
183
|
+
* profile-name safety is enforced where the name builds paths (the guard's
|
|
184
|
+
* profileDirOf / the plugin's assertSafeProfileName), not here.
|
|
185
|
+
*/
|
|
186
|
+
export function validateRestartPlanPayload(value) {
|
|
187
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
188
|
+
return { ok: false, error: "restart plan is not a JSON object" };
|
|
189
|
+
}
|
|
190
|
+
if (value.version !== RESTART_PLAN_VERSION) {
|
|
191
|
+
return { ok: false, error: `restart plan version ${JSON.stringify(value.version)} is not supported (expected ${RESTART_PLAN_VERSION})` };
|
|
192
|
+
}
|
|
193
|
+
if (value.type !== RESTART_PLAN_TYPE) {
|
|
194
|
+
return { ok: false, error: `restart plan type ${JSON.stringify(value.type)} does not match ${RESTART_PLAN_TYPE}` };
|
|
195
|
+
}
|
|
196
|
+
for (const key of ["profile", "logPath", "readyFile", "cwd", "command"]) {
|
|
197
|
+
if (typeof value[key] !== "string" || value[key].length === 0) {
|
|
198
|
+
return { ok: false, error: `restart plan field ${JSON.stringify(key)} must be a non-empty string` };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!Number.isInteger(value.awaitExitPid) || value.awaitExitPid <= 0) {
|
|
202
|
+
return { ok: false, error: `restart plan awaitExitPid ${JSON.stringify(value.awaitExitPid)} must be a positive integer` };
|
|
203
|
+
}
|
|
204
|
+
if (!Array.isArray(value.args) || value.args.length === 0 || value.args.some((entry) => typeof entry !== "string")) {
|
|
205
|
+
return { ok: false, error: "restart plan args must be a non-empty array of strings" };
|
|
206
|
+
}
|
|
207
|
+
return { ok: true, plan: value };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Atomically publish the helper-ready handshake as a file: write a sibling
|
|
212
|
+
* .tmp, then rename onto the final path (nonce-unique, so it does not exist
|
|
213
|
+
* yet), so the parent never observes half-written JSON. The parent — and only
|
|
214
|
+
* the parent — deletes the file after consuming it; the writer never touches
|
|
215
|
+
* it again, which is what keeps a healthy handoff from racing its own cleanup.
|
|
216
|
+
*/
|
|
217
|
+
export function writeRestartHelperReadyFile(readyFile, { awaitExitPid, guardPid }) {
|
|
218
|
+
const message = {
|
|
219
|
+
type: RESTART_HELPER_READY_TYPE,
|
|
220
|
+
protocol: RESTART_HELPER_PROTOCOL_VERSION,
|
|
221
|
+
awaitExitPid,
|
|
222
|
+
guardPid,
|
|
223
|
+
};
|
|
224
|
+
const tmp = `${readyFile}.tmp`;
|
|
225
|
+
writeFileSync(tmp, `${JSON.stringify(message)}\n`);
|
|
226
|
+
renameSync(tmp, readyFile);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Read a ready file; a missing or unparseable file reads as "not yet". */
|
|
230
|
+
export function readRestartHelperReadyFile(readyFile) {
|
|
231
|
+
let text;
|
|
232
|
+
try {
|
|
233
|
+
text = readFileSync(readyFile, "utf8");
|
|
234
|
+
} catch {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
return JSON.parse(text);
|
|
239
|
+
} catch {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function defaultProbePid(pid) {
|
|
245
|
+
try {
|
|
246
|
+
process.kill(pid, 0);
|
|
247
|
+
return true;
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error?.code === "EPERM") return true; // exists, not ours to signal
|
|
250
|
+
// ESRCH — and anything unprobeable — reads as gone: fail closed and keep
|
|
251
|
+
// the old Host rather than trusting an uncertain probe.
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* File-channel twin of superviseRestartHelper, phase for phase: handshake
|
|
258
|
+
* (poll the ready file) → stability (probe the guard pid) → accepted (the RPC
|
|
259
|
+
* may answer now — but keep probing) → committed (onHostExit). Continuing to
|
|
260
|
+
* probe through accepted mirrors the IPC version's live exit listener: a guard
|
|
261
|
+
* that dies after the RPC answered still cancels the old Host's exit instead
|
|
262
|
+
* of leaving it gone with no successor.
|
|
263
|
+
*
|
|
264
|
+
* The guard pid is only ever killed from a payload whose type identifies it
|
|
265
|
+
* as one of our restart helpers; garbage or unrelated files never turn into a
|
|
266
|
+
* kill of an unrelated (possibly recycled) pid. `failFast` lets the caller
|
|
267
|
+
* surface an early external failure (e.g. cmd itself exited nonzero before
|
|
268
|
+
* the guard ever started) without waiting out the handshake timeout.
|
|
269
|
+
*/
|
|
270
|
+
export function superviseRestartHelperFile({
|
|
271
|
+
readyFile,
|
|
272
|
+
awaitExitPid,
|
|
273
|
+
handshakeTimeoutMs = 5000,
|
|
274
|
+
stabilityMs = 600,
|
|
275
|
+
responseDelayMs = RESTART_RESPONSE_DRAIN_MS,
|
|
276
|
+
pollMs = 100,
|
|
277
|
+
probe = defaultProbePid,
|
|
278
|
+
kill = (pid) => process.kill(pid),
|
|
279
|
+
onHostExit = () => process.exit(0),
|
|
280
|
+
onFailure = () => {},
|
|
281
|
+
} = {}) {
|
|
282
|
+
let phase = "handshake";
|
|
283
|
+
let deadlineTimer;
|
|
284
|
+
let pollTimer;
|
|
285
|
+
let probeTimer;
|
|
286
|
+
let guardPid;
|
|
287
|
+
let readySettled = false;
|
|
288
|
+
let resolveReady;
|
|
289
|
+
const ready = new Promise((resolvePromise) => { resolveReady = resolvePromise; });
|
|
290
|
+
|
|
291
|
+
const clearTimers = () => {
|
|
292
|
+
if (deadlineTimer !== undefined) {
|
|
293
|
+
clearTimeout(deadlineTimer);
|
|
294
|
+
deadlineTimer = undefined;
|
|
295
|
+
}
|
|
296
|
+
// pollTimer included: a terminal state must leave no polling interval
|
|
297
|
+
// behind, or the Host process can never exit naturally.
|
|
298
|
+
if (pollTimer !== undefined) {
|
|
299
|
+
clearInterval(pollTimer);
|
|
300
|
+
pollTimer = undefined;
|
|
301
|
+
}
|
|
302
|
+
if (probeTimer !== undefined) {
|
|
303
|
+
clearInterval(probeTimer);
|
|
304
|
+
probeTimer = undefined;
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
const bestEffortUnlink = () => {
|
|
308
|
+
try { unlinkSync(readyFile); } catch { /* nonce-named residue is inert */ }
|
|
309
|
+
};
|
|
310
|
+
const settleReady = (result) => {
|
|
311
|
+
if (readySettled) return;
|
|
312
|
+
readySettled = true;
|
|
313
|
+
resolveReady(result);
|
|
314
|
+
};
|
|
315
|
+
const terminateGuard = () => {
|
|
316
|
+
if (guardPid === undefined) return;
|
|
317
|
+
try { kill(guardPid); } catch { /* already gone */ }
|
|
318
|
+
};
|
|
319
|
+
const fail = (message, { terminate = false } = {}) => {
|
|
320
|
+
if (phase === "failed" || phase === "disposed" || phase === "committed") return;
|
|
321
|
+
const afterReady = readySettled;
|
|
322
|
+
phase = "failed";
|
|
323
|
+
clearTimers();
|
|
324
|
+
if (terminate) terminateGuard();
|
|
325
|
+
bestEffortUnlink();
|
|
326
|
+
settleReady({ ok: false, error: message });
|
|
327
|
+
try { onFailure(message, { afterReady }); } catch { /* diagnostics are best effort */ }
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
function acceptHandshake(message) {
|
|
331
|
+
phase = "stability";
|
|
332
|
+
clearTimers(); // the handshake deadline no longer applies
|
|
333
|
+
guardPid = message.guardPid;
|
|
334
|
+
bestEffortUnlink(); // the parent owns the ready file's lifecycle
|
|
335
|
+
probeTimer = setInterval(() => {
|
|
336
|
+
if (phase !== "stability" && phase !== "accepted") return;
|
|
337
|
+
if (!probe(message.guardPid)) {
|
|
338
|
+
fail(
|
|
339
|
+
`restart helper (pid ${message.guardPid}) exited ${phase === "stability" ? "during the stability window" : "after the handoff was accepted"}`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}, pollMs);
|
|
343
|
+
deadlineTimer = setTimeout(() => {
|
|
344
|
+
if (phase !== "stability") return;
|
|
345
|
+
phase = "accepted";
|
|
346
|
+
settleReady({ ok: true });
|
|
347
|
+
// Keep watching until committed; a death here cancels the pending exit.
|
|
348
|
+
deadlineTimer = setTimeout(() => {
|
|
349
|
+
if (phase !== "accepted") return;
|
|
350
|
+
phase = "committed";
|
|
351
|
+
clearTimers();
|
|
352
|
+
try {
|
|
353
|
+
onHostExit();
|
|
354
|
+
} catch (error) {
|
|
355
|
+
phase = "failed";
|
|
356
|
+
try { onFailure(`could not exit old Host: ${error?.message ?? String(error)}`, { afterReady: true }); } catch { /* best effort */ }
|
|
357
|
+
}
|
|
358
|
+
}, responseDelayMs);
|
|
359
|
+
}, stabilityMs);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function pollOnce() {
|
|
363
|
+
if (phase !== "handshake") return;
|
|
364
|
+
const message = readRestartHelperReadyFile(readyFile);
|
|
365
|
+
if (message?.type !== RESTART_HELPER_READY_TYPE) return; // missing/half-written/unrelated
|
|
366
|
+
if (!Number.isInteger(message.guardPid) || message.guardPid <= 0) return;
|
|
367
|
+
if (message.protocol !== RESTART_HELPER_PROTOCOL_VERSION || message.awaitExitPid !== awaitExitPid) {
|
|
368
|
+
// Identified as one of our helpers but speaking the wrong protocol or
|
|
369
|
+
// waiting for a different Host: stop it rather than let it linger.
|
|
370
|
+
guardPid = message.guardPid;
|
|
371
|
+
fail(
|
|
372
|
+
`restart helper protocol mismatch (expected v${RESTART_HELPER_PROTOCOL_VERSION} for pid ${awaitExitPid})`,
|
|
373
|
+
{ terminate: true },
|
|
374
|
+
);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
acceptHandshake(message);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
deadlineTimer = setTimeout(() => {
|
|
381
|
+
if (phase !== "handshake") return;
|
|
382
|
+
fail(`restart helper did not write ${readyFile} within ${handshakeTimeoutMs}ms`);
|
|
383
|
+
}, handshakeTimeoutMs);
|
|
384
|
+
pollTimer = setInterval(pollOnce, pollMs);
|
|
385
|
+
pollOnce(); // an already-present ready file must not wait out one interval
|
|
386
|
+
|
|
387
|
+
const dispose = () => {
|
|
388
|
+
if (phase === "failed" || phase === "disposed" || phase === "committed") return;
|
|
389
|
+
phase = "disposed";
|
|
390
|
+
clearTimers();
|
|
391
|
+
terminateGuard();
|
|
392
|
+
bestEffortUnlink();
|
|
393
|
+
settleReady({ ok: false, error: "restart handoff cancelled because the plugin unloaded" });
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
ready,
|
|
398
|
+
dispose,
|
|
399
|
+
failFast: (message) => fail(message, { terminate: true }),
|
|
400
|
+
state: () => phase,
|
|
401
|
+
};
|
|
402
|
+
}
|
package/src/terminal.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Job logs are rendered as plain text in the browser. Child processes such as
|
|
2
|
+
// pnpm may still emit terminal colour controls when their stdio is piped (for
|
|
3
|
+
// example when FORCE_COLOR is inherited), which the browser shows as `[96m`.
|
|
4
|
+
// Keep the sanitizer dependency-free and limited to CSI sequences: those cover
|
|
5
|
+
// pnpm's colours/progress controls without deleting ordinary user text.
|
|
6
|
+
|
|
7
|
+
const ANSI_CSI_RE = new RegExp("\\u001b\\[[0-?]*[ -/]*[@-~]", "g");
|
|
8
|
+
|
|
9
|
+
export function stripTerminalControlSequences(value) {
|
|
10
|
+
return String(value ?? "").replace(ANSI_CSI_RE, "");
|
|
11
|
+
}
|