@1e0zj/dsh-plugin-mall 0.4.7 → 0.4.12

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/src/cli.js CHANGED
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { spawn } from "node:child_process";
28
28
  import {
29
+ createWriteStream,
29
30
  existsSync,
30
31
  lstatSync,
31
32
  mkdirSync,
@@ -34,9 +35,12 @@ import {
34
35
  realpathSync,
35
36
  rmSync,
36
37
  symlinkSync,
38
+ unlinkSync,
37
39
  writeFileSync,
38
40
  } from "node:fs";
39
41
  import { tmpdir } from "node:os";
42
+ import { EventEmitter } from "node:events";
43
+ import { Writable } from "node:stream";
40
44
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
41
45
  import { fileURLToPath } from "node:url";
42
46
  import {
@@ -62,7 +66,7 @@ import {
62
66
  // github.js imports node builtins only — the host-independence of this CLI is
63
67
  // preserved (it must keep working when the dsh host itself is broken).
64
68
  import { npmPackageInfo } from "./github.js";
65
- import { createRestartHelperReadyMessage } from "./restart-protocol.js";
69
+ import { createRestartHelperReadyMessage, quoteCmdArg, RESTART_PLAN_TYPE, validateRestartPlanPayload, writeRestartHelperReadyFile } from "./restart-protocol.js";
66
70
 
67
71
  /**
68
72
  * Pin a bare package name to name@latest: pnpm's minimumReleaseAge policy
@@ -98,6 +102,17 @@ export async function announceRestartHelperReady(awaitExitPid, send = process.se
98
102
  return true;
99
103
  }
100
104
 
105
+ /**
106
+ * File-channel announcement for the visible-console restart: `cmd /c start`
107
+ * gives this process no IPC link back to the Web Host, so the same handshake
108
+ * message is published as a file instead. Unlike announceRestartHelperReady,
109
+ * a missing channel is impossible here — a failed write rejects and the launch
110
+ * fails closed rather than letting the Web parent time out blind.
111
+ */
112
+ export async function announceRestartHandoffViaFile(plan, awaitExitPid) {
113
+ await writeRestartHelperReadyFile(plan.readyFile, { awaitExitPid, guardPid: process.pid });
114
+ }
115
+
101
116
  // ── small helpers ────────────────────────────────────────────────────────────
102
117
 
103
118
  // Same shell-metacharacter blocklist as installer.assertSafeSpec. The install
@@ -600,26 +615,9 @@ async function cmdRemove({
600
615
 
601
616
  const DEFAULT_GRACE_MS = 10000;
602
617
 
603
- // cmd.exe metacharacters. Rather than "escaping" these for a cmd round trip
604
- // (cmd's quoting rules are famously inconsistent), the launch wrapper refuses
605
- // them outright a dsh invocation never needs them.
606
- const CMD_METACHAR_RE = /[&|<>^%!\r\n]/;
607
-
608
- /**
609
- * Quote one token for a %ComSpec% /d /s /c command line. Follows the MSVCRT /
610
- * CommandLineToArgvW rules (backslashes before a quote or the closing quote are
611
- * doubled, quotes become \") and rejects cmd metacharacters instead of trying
612
- * to escape them. The command after `--` is never concatenated unquoted.
613
- */
614
- function quoteCmdArg(token) {
615
- const value = String(token ?? "");
616
- if (value.length === 0) return '""';
617
- if (CMD_METACHAR_RE.test(value)) {
618
- throw new Error(`cannot quote safely for cmd.exe (shell metacharacter present): ${JSON.stringify(value)}`);
619
- }
620
- const escaped = value.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/, "$1$1");
621
- return `"${escaped}"`;
622
- }
618
+ // quoteCmdArg/CMD_METACHAR_RE live in restart-protocol.js: the Web plugin needs
619
+ // the same strict quoting to build the `cmd /c start` line for a visible
620
+ // restart, so the rules must never drift between the two call sites.
623
621
 
624
622
  /** Case-insensitive env lookup (Windows env keys are case-insensitive). */
625
623
  function envValue(name) {
@@ -674,9 +672,12 @@ const delay = (ms) => new Promise((resolvePromise) => { setTimeout(resolvePromis
674
672
  * and named itself. It can still be recycled in principle; the bounded wait
675
673
  * and the refusal on timeout keep that from turning into a hang.
676
674
  *
677
- * @returns true when the process is gone, false on timeout.
675
+ * `shouldAbort` (the Ctrl+C watcher's flag) cuts the wait short: the caller
676
+ * reads it and refuses the launch instead of outwaiting a cancelled restart.
677
+ *
678
+ * @returns true when the process is gone, false on timeout or abort.
678
679
  */
679
- async function waitForProcessExit(pid, timeoutMs, pollMs = 100) {
680
+ async function waitForProcessExit(pid, timeoutMs, pollMs = 100, shouldAbort = undefined) {
680
681
  const target = Number(pid);
681
682
  if (!Number.isInteger(target) || target <= 0) return true; // nothing to wait for
682
683
  const deadline = Date.now() + timeoutMs;
@@ -687,6 +688,7 @@ async function waitForProcessExit(pid, timeoutMs, pollMs = 100) {
687
688
  if (error?.code === "ESRCH") return true;
688
689
  if (error?.code !== "EPERM") return true; // unprobeable: do not block the restart on it
689
690
  }
691
+ if (shouldAbort !== undefined && shouldAbort()) return false;
690
692
  if (Date.now() >= deadline) return false;
691
693
  await delay(pollMs);
692
694
  }
@@ -744,6 +746,283 @@ function forwardSignals(child) {
744
746
  };
745
747
  }
746
748
 
749
+ // ── tee runner (visible-console restart) ─────────────────────────────────────
750
+
751
+ /**
752
+ * The tee pipes the child's stdout away from the console, so color/TTY
753
+ * detection inside dsh would dim itself. Nudge it back unless the user opted
754
+ * out with NO_COLOR.
755
+ */
756
+ function teeEnv(base) {
757
+ if (base.NO_COLOR !== undefined) return base;
758
+ return { ...base, FORCE_COLOR: "1" };
759
+ }
760
+
761
+ /**
762
+ * The visible-console spawn: stdout/stderr are piped to the tee runner, and
763
+ * stdin is deliberately NOT the console. A live test showed why: dsh (or a
764
+ * component inside it) calls setRawMode on an inherited TTY stdin, and raw
765
+ * mode clears ENABLE_PROCESSED_INPUT console-wide — from then on a real
766
+ * Ctrl+C never becomes a CTRL_C_EVENT and NOTHING on the console (guard
767
+ * included) can receive it. With stdin ignored, no TTY ever reaches the
768
+ * child, processed input stays on, and Ctrl+C reaches every attached process
769
+ * — which is how the guard's interrupt handling works at all. The wrapped
770
+ * command is a web host; it has no use for window stdin.
771
+ */
772
+ function spawnTeeCommand(command, args, { cwd }) {
773
+ const resolved = process.platform === "win32" ? resolveWindowsCommand(command) : command;
774
+ // Mark the chain: the wrapped dsh has a PIPE for stdout (the tee), so the
775
+ // "interactive terminal" signal is gone even though it lives in a console
776
+ // window. The env flag lets its own restarts stay visible.
777
+ const env = { ...teeEnv(process.env), DSH_PLUGIN_MALL_VISIBLE_CONSOLE: "1" };
778
+ const stdio = ["ignore", "pipe", "pipe"];
779
+ if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(resolved)) {
780
+ const comspec = process.env.ComSpec ?? "cmd.exe";
781
+ const line = [resolved, ...args].map(quoteCmdArg).join(" ");
782
+ return spawn(comspec, ["/d", "/s", "/c", `"${line}"`], { shell: false, stdio, cwd, env, windowsVerbatimArguments: true, windowsHide: false });
783
+ }
784
+ return spawn(resolved, args, { shell: false, stdio, cwd, env, windowsHide: false });
785
+ }
786
+
787
+ function defaultOpenTeeLog(logPath) {
788
+ mkdirSync(dirname(logPath), { recursive: true });
789
+ return createWriteStream(logPath, { flags: "a" });
790
+ }
791
+
792
+ /**
793
+ * Tee runner for the visible-console restart: everything the wrapped command
794
+ * prints is mirrored to BOTH this guard's console (the window `cmd /c start`
795
+ * allocated) and the restart log. A log that cannot be opened, or that fails
796
+ * mid-run, only costs the log — the window keeps showing output and the
797
+ * child's exit code is untouched.
798
+ *
799
+ * Backpressure: while EITHER target is saturated BOTH source pipes stay
800
+ * paused (pausing just one would leave the other free to keep filling the
801
+ * saturated target); each target resumes the pair once it drains.
802
+ */
803
+ function createTeeRunner({ logPath, cwd, _stdout = process.stdout, _openLog = defaultOpenTeeLog }) {
804
+ let logStream;
805
+ let broken = false;
806
+ try {
807
+ logStream = _openLog(logPath);
808
+ } catch (error) {
809
+ broken = true;
810
+ _stdout.write(`[guard] warning: cannot open restart log ${logPath} (${error.message}) — continuing without it\n`);
811
+ }
812
+ let consoleSaturated = false;
813
+ let logSaturated = false;
814
+ let paused = false;
815
+ const activeStreams = new Set();
816
+ const onConsoleDrain = () => { consoleSaturated = false; maybeResume(); };
817
+ const onLogDrain = () => { logSaturated = false; maybeResume(); };
818
+ _stdout.on("drain", onConsoleDrain);
819
+ if (logStream !== undefined) {
820
+ logStream.on("drain", onLogDrain);
821
+ logStream.on("error", (error) => {
822
+ if (broken) return;
823
+ broken = true;
824
+ _stdout.write(`[guard] warning: restart log ${logPath} failed (${error.message}) — continuing without it\n`);
825
+ // A failed stream never drains. Clear the log-side backpressure or the
826
+ // paused source pipes stay paused forever and the wrapped dsh hangs.
827
+ logSaturated = false;
828
+ maybeResume();
829
+ });
830
+ }
831
+ function maybeResume() {
832
+ if (!paused || consoleSaturated || logSaturated) return;
833
+ paused = false;
834
+ for (const stream of activeStreams) stream.resume();
835
+ }
836
+ function onData(chunk) {
837
+ const consoleOk = _stdout.write(chunk);
838
+ const logOk = broken || logStream === undefined ? true : logStream.write(chunk);
839
+ if (consoleOk && logOk) return;
840
+ if (!consoleOk) consoleSaturated = true;
841
+ if (!logOk) logSaturated = true;
842
+ if (!paused) {
843
+ paused = true;
844
+ for (const stream of activeStreams) stream.pause();
845
+ }
846
+ }
847
+ let closed = false;
848
+ return {
849
+ get broken() { return broken; },
850
+ spawn(command, args) { return spawnTeeCommand(command, args, { cwd }); },
851
+ attach(child) {
852
+ const streams = [child.stdout, child.stderr].filter((stream) => stream !== null && stream !== undefined);
853
+ for (const stream of streams) {
854
+ activeStreams.add(stream);
855
+ stream.on("data", onData);
856
+ }
857
+ return () => {
858
+ for (const stream of streams) {
859
+ stream.removeListener("data", onData);
860
+ activeStreams.delete(stream);
861
+ }
862
+ maybeResume();
863
+ };
864
+ },
865
+ // tee waits for `close`, not `exit`: the pipes still carry in-flight
866
+ // output after exit, and ending the log before they drain would lose it.
867
+ waitFor(child) {
868
+ return new Promise((resolvePromise) => {
869
+ child.once("error", (error) => resolvePromise({ error }));
870
+ child.once("close", (code, signal) => resolvePromise({ code, signal }));
871
+ });
872
+ },
873
+ // Guard's own [guard] lines share the same two targets. The classic IO
874
+ // path routes by level (stdout/stderr); in a console window both land on
875
+ // the same screen, so the tee keeps a single stream.
876
+ say(text) {
877
+ const line = `${text}\n`;
878
+ _stdout.write(line);
879
+ if (!broken && logStream !== undefined) logStream.write(line);
880
+ },
881
+ async close() {
882
+ if (closed) return;
883
+ closed = true;
884
+ _stdout.removeListener("drain", onConsoleDrain);
885
+ if (logStream === undefined) return;
886
+ logStream.removeListener("drain", onLogDrain);
887
+ await new Promise((resolvePromise) => {
888
+ try {
889
+ logStream.end(() => resolvePromise());
890
+ } catch {
891
+ resolvePromise();
892
+ }
893
+ });
894
+ },
895
+ };
896
+ }
897
+
898
+ /** The launch IO every run shares: spawn, attach signals, wait for exit. */
899
+ const classicIO = {
900
+ spawn: spawnCommand,
901
+ attach: (child) => forwardSignals(child),
902
+ waitFor: waitForExit,
903
+ say: (text, level) => { console[level === "error" ? "error" : "log"](text); },
904
+ };
905
+
906
+ // ── console Ctrl handling (visible restart, Windows) ─────────────────────────
907
+
908
+ // NTSTATUS 0xC000013A: how a console process exits when it honored
909
+ // CTRL_C_EVENT. Depending on the libuv version Node reports it as exit code
910
+ // or maps it to SIGINT — both spellings are honored below.
911
+ const STATUS_CONTROL_C_EXIT = 3221225786;
912
+
913
+ /**
914
+ * Tear down the whole process TREE of a wrapped command that ignored its
915
+ * Ctrl+C. TerminateProcess (child.kill) does not cascade: dsh's graceful
916
+ * shutdown can hang on in-flight jobs, and even a killed dsh leaves its
917
+ * grandchildren (pnpm, job subprocesses) as orphans still attached to the
918
+ * console — which is exactly the "window will not close" a user sees after
919
+ * pressing Ctrl+C. taskkill /T /F tears the tree down in one step, the same
920
+ * escalation the official dsh-subprocess-local applies for console-wide
921
+ * teardown. The pid is always our own child's integer pid, never a string.
922
+ */
923
+ function defaultKillProcessTree(pid) {
924
+ if (process.platform !== "win32") {
925
+ try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ }
926
+ return;
927
+ }
928
+ const killer = spawn(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `taskkill /PID ${pid} /T /F >nul 2>&1`], { shell: false, stdio: "ignore", windowsHide: true });
929
+ killer.once("error", () => {
930
+ try { process.kill(pid); } catch { /* already gone */ }
931
+ });
932
+ }
933
+
934
+ /**
935
+ * Ctrl+C on a console reaches every attached process: the wrapped dsh gets
936
+ * the event directly. This watcher's job is only on the GUARD's side —
937
+ *
938
+ * - keep it alive (a Node process without a SIGINT handler exits at once,
939
+ * orphaning nothing but losing the log flush and the exit-code report);
940
+ * - remember the run was interrupted. On Windows a killed child reports
941
+ * code 1 / no signal, which probation would otherwise read as "the
942
+ * pending install crashed dsh" and roll back a perfectly good install;
943
+ * - escalate: if the child has not quit on its own within forceGraceMs,
944
+ * terminate the whole tree so the listening port is released AND the
945
+ * console window can actually close; a second Ctrl+C escalates at once;
946
+ * - SIGHUP (the window's close button): the OS is about to kill the whole
947
+ * console unconditionally — hold the handler open only so the normal
948
+ * cleanup path (log close) can finish before that lands.
949
+ */
950
+ function createInterruptWatcher({ forceGraceMs = 5000, _killTree = defaultKillProcessTree } = {}) {
951
+ let interrupted = false;
952
+ let child;
953
+ let forceTimer;
954
+ const abortListeners = new Set();
955
+ const terminate = () => {
956
+ if (child === undefined) return;
957
+ try {
958
+ _killTree(child.pid);
959
+ } catch {
960
+ try { child.kill(); } catch { /* already gone */ }
961
+ }
962
+ };
963
+ const signal = () => {
964
+ for (const listener of abortListeners) {
965
+ try { listener(); } catch { /* best effort */ }
966
+ }
967
+ if (interrupted) {
968
+ terminate();
969
+ return;
970
+ }
971
+ interrupted = true;
972
+ if (child !== undefined) armForceKill();
973
+ };
974
+ function armForceKill() {
975
+ if (forceTimer !== undefined || child === undefined) return;
976
+ forceTimer = setTimeout(terminate, forceGraceMs);
977
+ if (typeof forceTimer.unref === "function") forceTimer.unref();
978
+ }
979
+ const onHup = () => { /* see the doc comment: let cleanup run, OS decides */ };
980
+ process.on("SIGINT", signal);
981
+ process.on("SIGBREAK", signal);
982
+ process.on("SIGHUP", onHup);
983
+ return {
984
+ interrupted: () => interrupted,
985
+ noteChild(nextChild) {
986
+ child = nextChild;
987
+ if (interrupted) armForceKill(); // defensive: a spawn after a cancel
988
+ },
989
+ onAbort(listener) { abortListeners.add(listener); },
990
+ offAbort(listener) { abortListeners.delete(listener); },
991
+ unlisten() {
992
+ process.removeListener("SIGINT", signal);
993
+ process.removeListener("SIGBREAK", signal);
994
+ process.removeListener("SIGHUP", onHup);
995
+ if (forceTimer !== undefined) clearTimeout(forceTimer);
996
+ },
997
+ };
998
+ }
999
+
1000
+ /** Sleep for inspection pauses; any further Ctrl+C (via the watcher) ends it. */
1001
+ function waitForSignalOrTimeout(ms, watcher) {
1002
+ return new Promise((resolvePromise) => {
1003
+ const timer = setTimeout(finish, ms);
1004
+ const onAbort = () => { finish(); };
1005
+ function finish() {
1006
+ clearTimeout(timer);
1007
+ if (watcher !== undefined) watcher.offAbort(onAbort);
1008
+ resolvePromise();
1009
+ }
1010
+ if (watcher !== undefined) watcher.onAbort(onAbort);
1011
+ });
1012
+ }
1013
+
1014
+ /**
1015
+ * Was the wrapped command interrupted from outside rather than crashed?
1016
+ * POSIX says SIGINT/SIGTERM; Windows says "killed" (code 1, no signal) or
1017
+ * STATUS_CONTROL_C_EXIT — there the watcher's flag is the reliable spelling
1018
+ * and the NTSTATUS is belt-and-braces.
1019
+ */
1020
+ function isInterruptedOutcome(outcome, watcher) {
1021
+ if (outcome.signal === "SIGINT" || outcome.signal === "SIGTERM") return true;
1022
+ if (outcome.code === STATUS_CONTROL_C_EXIT) return true;
1023
+ return watcher !== undefined && watcher.interrupted();
1024
+ }
1025
+
747
1026
  function waitForExit(child) {
748
1027
  return new Promise((resolvePromise) => {
749
1028
  child.on("error", (error) => resolvePromise({ error }));
@@ -759,15 +1038,26 @@ function exitCodeOf(result) {
759
1038
  return 1;
760
1039
  }
761
1040
 
762
- /** Run the command once with no probation; resolve with its exit result. */
763
- async function runPlain(command, args) {
764
- const child = spawnCommand(command, args);
765
- const unforward = forwardSignals(child);
766
- const result = await waitForExit(child);
767
- unforward();
1041
+ /**
1042
+ * Run the wrapped command once and resolve with its exit result. Every launch
1043
+ * mode routes through here — the plain terminal, the detached background
1044
+ * restart, and the tee'd visible console — so probation's rollback-and-restart
1045
+ * keeps whatever IO the launch started with (the retried dsh must be teed
1046
+ * exactly like the first one).
1047
+ */
1048
+ async function runCommand(command, args, io = classicIO) {
1049
+ const child = io.spawn(command, args);
1050
+ const cleanup = io.attach(child);
1051
+ const result = await io.waitFor(child);
1052
+ if (typeof cleanup === "function") await cleanup();
768
1053
  return result;
769
1054
  }
770
1055
 
1056
+ /** Run the command once with no probation; resolve with its exit result. */
1057
+ async function runPlain(command, args, io = classicIO) {
1058
+ return runCommand(command, args, io);
1059
+ }
1060
+
771
1061
  /**
772
1062
  * Commit the pending snapshot once startup probation passes. A commit failure
773
1063
  * is a warning, not a launch failure — the process is already running and
@@ -778,7 +1068,7 @@ async function runPlain(command, args) {
778
1068
  * Roll it back to the pre-install snapshot instead (a rollback failure keeps
779
1069
  * the marker for the next attempt, same as recoverProfile).
780
1070
  */
781
- function commitLaunchSnapshot(profileDir) {
1071
+ function commitLaunchSnapshot(profileDir, say = (text, level) => { console[level === "error" ? "error" : "log"](text); }) {
782
1072
  try {
783
1073
  let pending;
784
1074
  try {
@@ -788,13 +1078,13 @@ function commitLaunchSnapshot(profileDir) {
788
1078
  }
789
1079
  if (pending !== undefined && pendingApprovalPaused(pending) !== undefined) {
790
1080
  rollbackPendingSnapshot(profileDir);
791
- console.log(`[guard] startup probation passed, but the install was abandoned at the approval gate — profile rolled back for ${profileDir}`);
1081
+ say(`[guard] startup probation passed, but the install was abandoned at the approval gate — profile rolled back for ${profileDir}`);
792
1082
  return;
793
1083
  }
794
1084
  commitPendingSnapshot(profileDir);
795
- console.log(`[guard] startup probation passed — pending snapshot committed for ${profileDir}`);
1085
+ say(`[guard] startup probation passed — pending snapshot committed for ${profileDir}`);
796
1086
  } catch (error) {
797
- console.error(`[guard] warning: could not commit the pending snapshot for ${profileDir}: ${error.message} — the marker stays pending`);
1087
+ say(`[guard] warning: could not commit the pending snapshot for ${profileDir}: ${error.message} — the marker stays pending`, "error");
798
1088
  }
799
1089
  }
800
1090
 
@@ -804,10 +1094,10 @@ function commitLaunchSnapshot(profileDir) {
804
1094
  * means it stayed alive through it (the snapshot is committed at that point)
805
1095
  * and the wrapper kept waiting for it.
806
1096
  */
807
- async function runProbation({ profileDir, command, args, graceMs }) {
808
- const child = spawnCommand(command, args);
809
- const unforward = forwardSignals(child);
810
- const exited = waitForExit(child);
1097
+ async function runProbation({ profileDir, command, args, graceMs, io = classicIO }) {
1098
+ const child = io.spawn(command, args);
1099
+ const cleanup = io.attach(child);
1100
+ const exited = io.waitFor(child);
811
1101
  let timer;
812
1102
  const grace = new Promise((resolvePromise) => { timer = setTimeout(() => resolvePromise("grace"), graceMs); });
813
1103
  const first = await Promise.race([
@@ -816,12 +1106,12 @@ async function runProbation({ profileDir, command, args, graceMs }) {
816
1106
  ]);
817
1107
  clearTimeout(timer);
818
1108
  if (first.phase === "before-grace") {
819
- unforward();
1109
+ if (typeof cleanup === "function") await cleanup();
820
1110
  return first;
821
1111
  }
822
- commitLaunchSnapshot(profileDir);
1112
+ commitLaunchSnapshot(profileDir, io.say);
823
1113
  const result = await exited;
824
- unforward();
1114
+ if (typeof cleanup === "function") await cleanup();
825
1115
  return { phase: "after-grace", ...result };
826
1116
  }
827
1117
 
@@ -889,6 +1179,60 @@ function markerLooksValid(marker, profileDir, home) {
889
1179
  return true;
890
1180
  }
891
1181
 
1182
+ /**
1183
+ * Read and validate a restart plan file, then consume it. The plan is the
1184
+ * visible-restart launch source: `cmd /c start` cannot carry the wrapped
1185
+ * argv, so the Web Host writes it to a nonce-named JSON file and this CLI
1186
+ * reads it back — never through a shell string. A plan that fails validation
1187
+ * (or does not parse) is refused and LEFT ON DISK for diagnosis; only a
1188
+ * successfully loaded plan is deleted. Residue is inert: the file name is
1189
+ * unique per request.
1190
+ */
1191
+ function loadRestartPlan(planFile) {
1192
+ let raw;
1193
+ try {
1194
+ raw = readFileSync(planFile, "utf8");
1195
+ } catch (error) {
1196
+ throw new Error(`cannot read restart plan ${planFile}: ${error.message}`);
1197
+ }
1198
+ let value;
1199
+ try {
1200
+ value = JSON.parse(raw);
1201
+ } catch (error) {
1202
+ throw new Error(`restart plan ${planFile} is not valid JSON (${error.message}) — file left for inspection`);
1203
+ }
1204
+ const verdict = validateRestartPlanPayload(value);
1205
+ if (!verdict.ok) {
1206
+ throw new Error(`${verdict.error} — in ${planFile} (file left for inspection)`);
1207
+ }
1208
+ try { unlinkSync(planFile); } catch { /* nonce-named residue is inert */ }
1209
+ return verdict.plan;
1210
+ }
1211
+
1212
+ /**
1213
+ * Resolve what `guard launch` should run from the parsed CLI. Two exclusive
1214
+ * sources: the classic `--profile … -- command argv` form, or a `--plan-file`
1215
+ * that carries all three. Anything overlapping the plan form is a usage error
1216
+ * — two sources for the same fact is how they drift apart.
1217
+ */
1218
+ function resolveLaunchInvocation(parsed) {
1219
+ if (parsed.planFile !== undefined) {
1220
+ if (parsed.profile !== undefined || parsed.awaitExit !== undefined || parsed.commandArgv.length !== 0) {
1221
+ throw usageError("--plan-file carries --profile, --await-exit and the wrapped command; do not pass them separately (see --help)");
1222
+ }
1223
+ const plan = loadRestartPlan(parsed.planFile);
1224
+ return { profile: plan.profile, commandArgv: [plan.command, ...plan.args], awaitExitPid: plan.awaitExitPid, plan };
1225
+ }
1226
+ if (parsed.profile === undefined) throw usageError("launch needs --profile <name> (see --help)");
1227
+ if (parsed.commandArgv.length === 0) throw usageError("launch needs a command after `--` (see --help)");
1228
+ let awaitExitPid;
1229
+ if (parsed.awaitExit !== undefined) {
1230
+ awaitExitPid = Number(parsed.awaitExit);
1231
+ if (!Number.isInteger(awaitExitPid) || awaitExitPid <= 0) throw usageError(`--await-exit must be a positive process id, got ${JSON.stringify(parsed.awaitExit)}`);
1232
+ }
1233
+ return { profile: parsed.profile, commandArgv: parsed.commandArgv, awaitExitPid };
1234
+ }
1235
+
892
1236
  /**
893
1237
  * `guard launch`: start dsh (or any command) wrapped in startup probation for
894
1238
  * the profile's pending install, if one exists.
@@ -909,12 +1253,31 @@ async function cmdLaunch({
909
1253
  graceMs,
910
1254
  commandArgv,
911
1255
  awaitExitPid,
1256
+ plan,
1257
+ tee,
1258
+ interrupt,
912
1259
  _waitForExit = waitForProcessExit,
913
- _onAwaitExit = announceRestartHelperReady,
1260
+ _onAwaitExit = plan === undefined ? announceRestartHelperReady : (pid) => announceRestartHandoffViaFile(plan, pid),
914
1261
  }) {
915
1262
  const profileDir = profileDirOf(home, profile);
916
1263
  const grace = graceMs ?? DEFAULT_GRACE_MS;
917
1264
  const [command, ...args] = commandArgv;
1265
+ // The visible-console launch passes its tee runner: spawns pipe through it,
1266
+ // [guard] lines are mirrored to the window and the log, and probation's
1267
+ // retry-once reuses the same tee. The classic launch keeps classicIO — its
1268
+ // console.log/console.error split (stdout vs stderr) is unchanged behavior.
1269
+ const io = tee ?? classicIO;
1270
+ const say = io.say;
1271
+ // The Ctrl watcher needs to know the live child (second Ctrl+C escalates
1272
+ // to a kill) — wrap attach so every spawn, probation's retry included,
1273
+ // registers it.
1274
+ const watchedIO = interrupt === undefined ? io : {
1275
+ ...io,
1276
+ attach(child) {
1277
+ interrupt.noteChild(child);
1278
+ return io.attach(child);
1279
+ },
1280
+ };
918
1281
 
919
1282
  // A restart hands us the pid of the host that is on its way out. Starting
920
1283
  // the successor while it still holds the listening port is not a race worth
@@ -928,12 +1291,30 @@ async function cmdLaunch({
928
1291
  if (awaitExitPid !== undefined) {
929
1292
  // This is the exact handoff boundary: arguments and profile are valid, and
930
1293
  // the helper is about to block on the outgoing Host. The Web parent waits
931
- // for this IPC acknowledgement before it allows itself to exit.
1294
+ // for this acknowledgement — over IPC on the background path, or through
1295
+ // the ready file on the visible-console path — before it allows itself to
1296
+ // exit. A failed announcement rejects here and nothing starts.
1297
+ if (tee !== undefined) tee.say(`[guard] waiting for outgoing dsh (pid ${awaitExitPid}) to exit — close this window to cancel…`);
932
1298
  await _onAwaitExit(awaitExitPid);
933
- if (!await _waitForExit(awaitExitPid, AWAIT_EXIT_TIMEOUT_MS)) {
1299
+ const gone = await _waitForExit(awaitExitPid, AWAIT_EXIT_TIMEOUT_MS, 100, interrupt === undefined ? undefined : interrupt.interrupted);
1300
+ if (!gone) {
1301
+ if (interrupt !== undefined && interrupt.interrupted()) {
1302
+ throw new Error(`interrupted while waiting for the outgoing dsh (pid ${awaitExitPid}) — no successor was started`);
1303
+ }
934
1304
  throw new Error(`process ${awaitExitPid} was still running after ${AWAIT_EXIT_TIMEOUT_MS}ms — refusing to start a second host that would collide with it on the listening port (the old one is still up; nothing was changed)`);
935
1305
  }
936
1306
  await delay(PORT_SETTLE_MS);
1307
+ // Cancel sentinel: the Web parent cannot kill this guard (cmd /c start
1308
+ // hid the pid), so on a handoff it gave up waiting for it drops
1309
+ // <readyFile>.cancel. A slow guard waking up here must not start a second
1310
+ // successor next to the retry's one — that port collision is exactly what
1311
+ // probation would misread as a bad install. The sweep never touches a
1312
+ // fresh sentinel (it may belong to a guard that has not woken yet);
1313
+ // consuming it here deletes it so it does not linger.
1314
+ if (plan !== undefined && existsSync(`${plan.readyFile}.cancel`)) {
1315
+ try { unlinkSync(`${plan.readyFile}.cancel`); } catch { /* best effort */ }
1316
+ throw new Error("this restart was cancelled by the Web parent (the handoff timed out) — not starting a successor");
1317
+ }
937
1318
  }
938
1319
  // Mirrors guard.js pendingPath(): <home>/guard/pending-<profile>.json.
939
1320
  const markerPath = join(home, "guard", `pending-${profile}.json`);
@@ -961,29 +1342,32 @@ async function cmdLaunch({
961
1342
  } catch (error) {
962
1343
  throw new Error(`profile "${profile}" failed static validation and rollback failed — refusing to launch: ${error.message}`);
963
1344
  }
964
- console.error(`[guard] profile "${profile}" failed static validation — rolled back before launch:`);
965
- for (const entry of [...validation.issues, ...removeValidation.issues]) console.error(renderIssue(entry));
1345
+ say(`[guard] profile "${profile}" failed static validation — rolled back before launch:`, "error");
1346
+ for (const entry of [...validation.issues, ...removeValidation.issues]) say(renderIssue(entry), "error");
966
1347
  } else {
967
1348
  pending = true;
968
1349
  }
969
1350
  }
970
1351
 
971
1352
  if (!pending) {
972
- const result = await runPlain(command, args);
973
- if (result.error !== undefined) console.error(`[guard] failed to start ${command}: ${result.error.message}`);
1353
+ const result = await runPlain(command, args, watchedIO);
1354
+ if (result.error !== undefined) say(`[guard] failed to start ${command}: ${result.error.message}`, "error");
1355
+ if (isInterruptedOutcome(result, interrupt)) return result.signal === "SIGTERM" ? 143 : 130;
974
1356
  return exitCodeOf(result);
975
1357
  }
976
1358
 
977
- const outcome = await runProbation({ profileDir, command, args, graceMs: grace });
1359
+ const outcome = await runProbation({ profileDir, command, args, graceMs: grace, io: watchedIO });
978
1360
  if (outcome.phase === "after-grace") return exitCodeOf(outcome);
979
- if (outcome.signal === "SIGINT" || outcome.signal === "SIGTERM") {
980
- // Interrupted from outside (Ctrl+C / service stop): not an install failure —
981
- // leave the marker pending for the next launch, propagate the convention.
982
- return exitCodeOf(outcome);
1361
+ if (isInterruptedOutcome(outcome, interrupt)) {
1362
+ // Interrupted from outside (Ctrl+C / service stop): not an install
1363
+ // failure — leave the marker pending for the next launch and keep the
1364
+ // 130/143 convention. This check precedes the exit-0 commit on purpose:
1365
+ // a dsh that exits 0 after a Ctrl+C has proven nothing about loading.
1366
+ return outcome.signal === "SIGTERM" ? 143 : 130;
983
1367
  }
984
1368
  if (outcome.error === undefined && outcome.code === 0) {
985
1369
  // One-shot command that finished successfully inside the grace window.
986
- commitLaunchSnapshot(profileDir);
1370
+ commitLaunchSnapshot(profileDir, say);
987
1371
  return 0;
988
1372
  }
989
1373
 
@@ -998,9 +1382,10 @@ async function cmdLaunch({
998
1382
  } catch (error) {
999
1383
  throw new Error(`the command ${why} within the grace period, but rollback failed — refusing to restart: ${error.message}`);
1000
1384
  }
1001
- console.error(`[guard] the command ${why} within the ${grace}ms grace period — profile "${profile}" rolled back, restarting once with the restored state`);
1002
- const retry = await runPlain(command, args);
1003
- if (retry.error !== undefined) console.error(`[guard] failed to restart ${command}: ${retry.error.message}`);
1385
+ say(`[guard] the command ${why} within the ${grace}ms grace period — profile "${profile}" rolled back, restarting once with the restored state`, "error");
1386
+ const retry = await runPlain(command, args, watchedIO);
1387
+ if (retry.error !== undefined) say(`[guard] failed to restart ${command}: ${retry.error.message}`, "error");
1388
+ if (isInterruptedOutcome(retry, interrupt)) return retry.signal === "SIGTERM" ? 143 : 130;
1004
1389
  return exitCodeOf(retry);
1005
1390
  }
1006
1391
 
@@ -1016,6 +1401,7 @@ Usage:
1016
1401
  node src/cli.js guard add <spec> --profile <name> [--home <dir>] [--accept-warnings]
1017
1402
  node src/cli.js guard remove <package> --profile <name> [--home <dir>]
1018
1403
  node src/cli.js guard launch --profile <name> [--home <dir>] [--grace-ms <ms>] [--await-exit <pid>] -- <command> [args...]
1404
+ node src/cli.js guard launch --plan-file <path> [--home <dir>] [--grace-ms <ms>]
1019
1405
  node src/cli.js guard self-test
1020
1406
 
1021
1407
  Commands:
@@ -1043,7 +1429,11 @@ Commands:
1043
1429
  period rolls the profile back and restarts the exact command once
1044
1430
  with the restored state (never loops). A corrupt or legacy (pre-v2)
1045
1431
  marker fails closed: the command is not launched and nothing is
1046
- deleted.
1432
+ deleted. --plan-file loads the wrapped command from a restart
1433
+ plan JSON instead of \`--\` (it carries --profile, --await-exit
1434
+ and the command itself; passing those alongside is a usage
1435
+ error) and is consumed on load — an invalid plan is refused and
1436
+ left on disk for inspection.
1047
1437
  self-test run offline fixtures (no network, no pnpm/dsh).
1048
1438
 
1049
1439
  Exit codes: 0 ok, 1 blocked/rolled back/failed, 2 usage error; launch preserves
@@ -1056,7 +1446,7 @@ function parseArgs(argv) {
1056
1446
  if (args[0] === "guard") args.shift(); // `node cli.js guard recover` / `node cli.js recover`
1057
1447
  let command = args.shift() ?? "help";
1058
1448
  if (command === "--help" || command === "-h") command = "help"; // `cli.js --help`
1059
- const opts = { home: undefined, profile: undefined, graceMs: undefined, awaitExit: undefined, acceptWarnings: false, positionals: [], commandArgv: [] };
1449
+ const opts = { home: undefined, profile: undefined, graceMs: undefined, awaitExit: undefined, planFile: undefined, acceptWarnings: false, positionals: [], commandArgv: [] };
1060
1450
  for (let index = 0; index < args.length; index++) {
1061
1451
  const arg = args[index];
1062
1452
  if (arg === "--") { opts.commandArgv = args.slice(index + 1); break; } // launch: the wrapped command, verbatim
@@ -1070,6 +1460,8 @@ function parseArgs(argv) {
1070
1460
  if (arg.startsWith("--grace-ms=")) { opts.graceMs = arg.slice("--grace-ms=".length); continue; }
1071
1461
  if (arg === "--await-exit") { opts.awaitExit = args[++index]; continue; }
1072
1462
  if (arg.startsWith("--await-exit=")) { opts.awaitExit = arg.slice("--await-exit=".length); continue; }
1463
+ if (arg === "--plan-file") { opts.planFile = args[++index]; continue; }
1464
+ if (arg.startsWith("--plan-file=")) { opts.planFile = arg.slice("--plan-file=".length); continue; }
1073
1465
  if (arg === "--all") { opts.all = true; continue; }
1074
1466
  if (arg.startsWith("-")) throw new Error(`unknown option ${JSON.stringify(arg)}`);
1075
1467
  opts.positionals.push(arg);
@@ -1094,6 +1486,13 @@ async function main(argv) {
1094
1486
  return;
1095
1487
  }
1096
1488
  const home = parsed.home !== undefined ? resolve(parsed.home) : resolveDshHome();
1489
+ // The visible-restart tee outlives cmdLaunch: main's catch can still write
1490
+ // the failure into the restart log (the window would otherwise flash away)
1491
+ // before the log is closed and flushed. The Ctrl watcher belongs to the
1492
+ // same lifetime — Windows only, so every other platform's signal behavior
1493
+ // stays exactly as it was.
1494
+ let launchTee;
1495
+ let launchInterrupt;
1097
1496
  try {
1098
1497
  switch (parsed.command) {
1099
1498
  case "validate": {
@@ -1133,19 +1532,17 @@ async function main(argv) {
1133
1532
  return;
1134
1533
  }
1135
1534
  case "launch": {
1136
- if (parsed.profile === undefined) throw usageError("launch needs --profile <name> (see --help)");
1137
- if (parsed.commandArgv.length === 0) throw usageError("launch needs a command after `--` (see --help)");
1138
1535
  let graceMs;
1139
1536
  if (parsed.graceMs !== undefined) {
1140
1537
  graceMs = Number(parsed.graceMs);
1141
1538
  if (!Number.isFinite(graceMs) || graceMs < 0) throw usageError(`--grace-ms must be a non-negative number, got ${JSON.stringify(parsed.graceMs)}`);
1142
1539
  }
1143
- let awaitExitPid;
1144
- if (parsed.awaitExit !== undefined) {
1145
- awaitExitPid = Number(parsed.awaitExit);
1146
- if (!Number.isInteger(awaitExitPid) || awaitExitPid <= 0) throw usageError(`--await-exit must be a positive process id, got ${JSON.stringify(parsed.awaitExit)}`);
1540
+ const invocation = resolveLaunchInvocation(parsed);
1541
+ if (invocation.plan !== undefined) {
1542
+ launchTee = createTeeRunner({ logPath: invocation.plan.logPath, cwd: invocation.plan.cwd });
1543
+ if (process.platform === "win32") launchInterrupt = createInterruptWatcher();
1147
1544
  }
1148
- process.exitCode = await cmdLaunch({ profile: parsed.profile, home, graceMs, commandArgv: parsed.commandArgv, awaitExitPid });
1545
+ process.exitCode = await cmdLaunch({ ...invocation, home, graceMs, tee: launchTee, interrupt: launchInterrupt });
1149
1546
  return;
1150
1547
  }
1151
1548
  case "self-test": {
@@ -1156,8 +1553,19 @@ async function main(argv) {
1156
1553
  throw usageError(`unknown command ${JSON.stringify(parsed.command)} (see --help)`);
1157
1554
  }
1158
1555
  } catch (error) {
1556
+ if (launchTee !== undefined) launchTee.say(`error: ${error.message}`, "error");
1159
1557
  console.error(`error: ${error.message}`);
1160
1558
  process.exitCode = error instanceof UsageError ? 2 : 1;
1559
+ // A visible window that vanishes takes its diagnosis with it: hold it
1560
+ // briefly so the error can be read (a working console signal cuts the
1561
+ // wait short).
1562
+ if (launchTee !== undefined) {
1563
+ launchTee.say("[guard] this window closes in 10s so the error above can be read…");
1564
+ await waitForSignalOrTimeout(10000, launchInterrupt);
1565
+ }
1566
+ } finally {
1567
+ if (launchInterrupt !== undefined) launchInterrupt.unlisten();
1568
+ if (launchTee !== undefined) await launchTee.close();
1161
1569
  }
1162
1570
  }
1163
1571
 
@@ -1229,6 +1637,88 @@ async function selfTest() {
1229
1637
  }
1230
1638
  }
1231
1639
 
1640
+ // --plan-file: the visible-restart launch source. Parsing accepts both
1641
+ // forms; resolution treats the plan as the single source of truth, drinks
1642
+ // it on load, and refuses to mix it with --profile/--await-exit/`--`.
1643
+ {
1644
+ const p7 = parseArgs(["launch", "--plan-file", "C:/r/plan.json"]);
1645
+ if (p7.planFile !== "C:/r/plan.json") throw new Error("parseArgs --plan-file fixture failed");
1646
+ const p8 = parseArgs(["launch", "--plan-file=C:/r/plan.json"]);
1647
+ if (p8.planFile !== "C:/r/plan.json") throw new Error("parseArgs --plan-file= fixture failed");
1648
+
1649
+ const planDir = join(root, "restart-plans");
1650
+ mkdirSync(planDir, { recursive: true });
1651
+ const writePlan = (name, overrides = {}) => {
1652
+ const path = join(planDir, name);
1653
+ writeFileSync(path, JSON.stringify({
1654
+ version: 1,
1655
+ type: RESTART_PLAN_TYPE,
1656
+ profile: "web",
1657
+ awaitExitPid: 4321,
1658
+ logPath: join(planDir, "restart-web.log"),
1659
+ readyFile: join(planDir, "ready.json"),
1660
+ cwd: root,
1661
+ command: process.execPath,
1662
+ args: ["-e", "process.exit(0)"],
1663
+ ...overrides,
1664
+ }));
1665
+ return path;
1666
+ };
1667
+
1668
+ const goodPath = writePlan("good.json");
1669
+ const invocation = resolveLaunchInvocation({ planFile: goodPath, commandArgv: [] });
1670
+ if (invocation.profile !== "web" || invocation.awaitExitPid !== 4321) throw new Error("plan invocation must carry the plan's profile and pid");
1671
+ if (invocation.commandArgv[0] !== process.execPath || invocation.commandArgv[2] !== "process.exit(0)") throw new Error("plan invocation must derive the wrapped command from the plan");
1672
+ if (existsSync(goodPath)) throw new Error("a successfully loaded plan must be consumed (deleted)");
1673
+
1674
+ for (const [name, overrides] of [["bad-args.json", { args: [] }], ["bad-version.json", { version: 2 }]]) {
1675
+ const badPath = writePlan(name, overrides);
1676
+ let refused = false;
1677
+ try {
1678
+ resolveLaunchInvocation({ planFile: badPath, commandArgv: [] });
1679
+ } catch (error) {
1680
+ refused = !(error instanceof UsageError) && /restart plan/.test(error.message);
1681
+ }
1682
+ if (!refused) throw new Error(`an invalid plan (${name}) must fail closed`);
1683
+ if (!existsSync(badPath)) throw new Error(`an invalid plan (${name}) must be left on disk for inspection`);
1684
+ }
1685
+ const notJsonPath = join(planDir, "not-json.json");
1686
+ writeFileSync(notJsonPath, "{ half-written");
1687
+ let notJsonRefused = false;
1688
+ try {
1689
+ resolveLaunchInvocation({ planFile: notJsonPath, commandArgv: [] });
1690
+ } catch (error) {
1691
+ notJsonRefused = /not valid JSON/.test(error.message);
1692
+ }
1693
+ if (!notJsonRefused || !existsSync(notJsonPath)) throw new Error("unparseable plan JSON must be refused and preserved");
1694
+ let missingRefused = false;
1695
+ try {
1696
+ resolveLaunchInvocation({ planFile: join(planDir, "nope.json"), commandArgv: [] });
1697
+ } catch (error) {
1698
+ missingRefused = /cannot read restart plan/.test(error.message);
1699
+ }
1700
+ if (!missingRefused) throw new Error("a missing plan file must fail with a clear error");
1701
+
1702
+ for (const extra of [{ profile: "web" }, { awaitExit: "4321" }, { commandArgv: ["dsh"] }]) {
1703
+ let usageErrorSeen = false;
1704
+ try {
1705
+ resolveLaunchInvocation({ planFile: writePlan("exclusive.json"), commandArgv: [], ...extra });
1706
+ } catch (error) {
1707
+ usageErrorSeen = error instanceof UsageError;
1708
+ }
1709
+ if (!usageErrorSeen) throw new Error(`--plan-file with ${JSON.stringify(Object.keys(extra))} must be a usage error`);
1710
+ }
1711
+ const classic = resolveLaunchInvocation({ profile: "web", commandArgv: ["dsh"], awaitExit: undefined, planFile: undefined });
1712
+ if (classic.profile !== "web" || classic.commandArgv.join(" ") !== "dsh" || classic.plan !== undefined) throw new Error("the classic launch form must resolve unchanged");
1713
+ let classicUsage = false;
1714
+ try {
1715
+ resolveLaunchInvocation({ profile: undefined, commandArgv: [], awaitExit: undefined, planFile: undefined });
1716
+ } catch (error) {
1717
+ classicUsage = error instanceof UsageError;
1718
+ }
1719
+ if (!classicUsage) throw new Error("classic launch without --profile must stay a usage error");
1720
+ }
1721
+
1232
1722
  // `--await-exit`: the successor must not start while the outgoing host is
1233
1723
  // still holding the port. A pid that never goes away is refused outright —
1234
1724
  // starting anyway is what costs the user their install, because the failed
@@ -1278,6 +1768,85 @@ async function selfTest() {
1278
1768
  if (!await waitForProcessExit(undefined, 500, 20)) throw new Error("waitForProcessExit must not block on a missing pid");
1279
1769
  }
1280
1770
 
1771
+ // --plan-file launch mode: the handoff announcement moves to the ready
1772
+ // file (there is no IPC channel under `cmd /c start`) while everything
1773
+ // else — waiting for the outgoing pid, running the wrapped command —
1774
+ // behaves exactly like the classic form. An announcement that cannot be
1775
+ // written must reject the launch: the Web parent would otherwise time out
1776
+ // blind while no successor ever started.
1777
+ {
1778
+ const p = join(root, "profiles", "plan-launch");
1779
+ mkdirSync(p, { recursive: true });
1780
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
1781
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1782
+ const planDir = join(root, "restart-plans-live");
1783
+ mkdirSync(planDir, { recursive: true });
1784
+ const readyOut = join(planDir, "ready-out.json");
1785
+ const plan = {
1786
+ version: 1,
1787
+ type: RESTART_PLAN_TYPE,
1788
+ profile: "plan-launch",
1789
+ awaitExitPid: 8642,
1790
+ logPath: join(planDir, "restart-plan-launch.log"),
1791
+ readyFile: readyOut,
1792
+ cwd: root,
1793
+ command: process.execPath,
1794
+ args: ["-e", "process.exit(0)"],
1795
+ };
1796
+ const code = await cmdLaunch({
1797
+ profile: plan.profile,
1798
+ home: root,
1799
+ commandArgv: [plan.command, ...plan.args],
1800
+ awaitExitPid: plan.awaitExitPid,
1801
+ plan,
1802
+ _waitForExit: async () => true,
1803
+ });
1804
+ if (code !== 0) throw new Error("a plan-file launch must run the wrapped command");
1805
+ if (!existsSync(readyOut)) throw new Error("plan-file launch must publish the ready file before waiting");
1806
+ const readyPayload = JSON.parse(readFileSync(readyOut, "utf8"));
1807
+ if (readyPayload.type !== "@1e0zj/dsh-plugin-mall:restart-helper-ready"
1808
+ || readyPayload.protocol !== 1 || readyPayload.awaitExitPid !== plan.awaitExitPid
1809
+ || readyPayload.guardPid !== process.pid) {
1810
+ throw new Error("the ready file must carry the helper-ready message with this guard's pid");
1811
+ }
1812
+
1813
+ const badPlan = { ...plan, readyFile: join(planDir, "no-such-dir", "ready.json") };
1814
+ let announceFailed = false;
1815
+ try {
1816
+ await cmdLaunch({
1817
+ profile: badPlan.profile,
1818
+ home: root,
1819
+ commandArgv: [badPlan.command, ...badPlan.args],
1820
+ awaitExitPid: badPlan.awaitExitPid,
1821
+ plan: badPlan,
1822
+ _waitForExit: async () => true,
1823
+ });
1824
+ } catch {
1825
+ announceFailed = true;
1826
+ }
1827
+ if (!announceFailed) throw new Error("a ready-file write failure must reject the launch (fail closed)");
1828
+
1829
+ // Cancel sentinel: a guard the Web parent gave up on must not start a
1830
+ // successor after its await-exit wait ends — the retry's guard will.
1831
+ // Consuming the sentinel deletes it, so it does not linger.
1832
+ writeFileSync(`${readyOut}.cancel`, "cancelled (fixture)\n");
1833
+ let cancelled = false;
1834
+ try {
1835
+ await cmdLaunch({
1836
+ profile: plan.profile,
1837
+ home: root,
1838
+ commandArgv: [plan.command, ...plan.args],
1839
+ awaitExitPid: plan.awaitExitPid,
1840
+ plan,
1841
+ _waitForExit: async () => true,
1842
+ });
1843
+ } catch (error) {
1844
+ cancelled = /cancelled by the Web parent/.test(error.message);
1845
+ }
1846
+ if (!cancelled) throw new Error("a cancelled handoff must refuse to start a successor after the wait");
1847
+ if (existsSync(`${readyOut}.cancel`)) throw new Error("consuming the cancel sentinel must delete it");
1848
+ }
1849
+
1281
1850
  // hideChildConsole: inherit the console we have, never create one we do
1282
1851
  // not. Getting this backwards is visible either way — a stray blank window
1283
1852
  // after every restart, or an interactive `guard launch` that has lost its
@@ -1297,6 +1866,296 @@ async function selfTest() {
1297
1866
  }
1298
1867
  }
1299
1868
 
1869
+ // Tee runner (visible restart): the wrapped command's output must reach
1870
+ // BOTH the console target and the log byte-for-byte, the exit code must
1871
+ // pass through, everything must be flushed before close() settles, a
1872
+ // broken log must not change the outcome, and backpressure must not lose
1873
+ // data. Classic IO keeps its exact behavior (existing fixtures above).
1874
+ {
1875
+ const teeDir = join(root, "tee");
1876
+ mkdirSync(teeDir, { recursive: true });
1877
+
1878
+ // Do not call process.exit() after the large write: on Linux that
1879
+ // truncates the CHILD's pipe before the tee can ever read it (the exact
1880
+ // false failure was 146176/628890 bytes in both destinations). Setting
1881
+ // exitCode preserves the nonzero result while Node drains stdout first.
1882
+ const generate = "const lines = []; for (let i = 0; i < 20000; i++) lines.push('line-' + i + '-' + 'x'.repeat(20)); process.stdout.write(lines.join('\\n') + '\\n'); process.exitCode = 7;";
1883
+ const expected = `${Array.from({ length: 20000 }, (_, index) => `line-${index}-${"x".repeat(20)}`).join("\n")}\n`;
1884
+ const fastChunks = [];
1885
+ const fastOut = new Writable({
1886
+ write(chunk, encoding, callback) { fastChunks.push(Buffer.from(chunk)); callback(); },
1887
+ });
1888
+ const runner = createTeeRunner({ logPath: join(teeDir, "restart-tee.log"), cwd: root, _stdout: fastOut });
1889
+ const child = runner.spawn(process.execPath, ["-e", generate]);
1890
+ // stdin must NOT be the console: an inherited TTY stdin lets the wrapped
1891
+ // dsh flip the console into raw mode, which kills Ctrl+C for EVERYONE
1892
+ // attached (live-verified: consoleMode was 0x0000 in the first test).
1893
+ if (child.stdin !== null) throw new Error("the tee'd child must not own the console stdin");
1894
+ const cleanup = runner.attach(child);
1895
+ const result = await runner.waitFor(child);
1896
+ if (typeof cleanup === "function") await cleanup();
1897
+ await runner.close();
1898
+ if (result.code !== 7) throw new Error("tee must preserve the child's exit code");
1899
+ const logged = readFileSync(join(teeDir, "restart-tee.log"), "utf8");
1900
+ const seen = Buffer.concat(fastChunks).toString("utf8");
1901
+ if (logged !== expected || seen !== expected) throw new Error(`tee must be byte-identical on both targets (log ${logged.length}, console ${seen.length}, expected ${expected.length})`);
1902
+ if (runner.broken) throw new Error("a healthy tee log must not be marked broken");
1903
+
1904
+ const errRunner = createTeeRunner({
1905
+ logPath: join(teeDir, "restart-err.log"),
1906
+ cwd: root,
1907
+ _stdout: new Writable({ write(chunk, encoding, callback) { callback(); } }),
1908
+ });
1909
+ const errChild = errRunner.spawn(process.execPath, ["-e", "process.stderr.write('ERR-OUT\\n'); process.stdout.write('OUT\\n'); process.exit(0)"]);
1910
+ const errCleanup = errRunner.attach(errChild);
1911
+ await errRunner.waitFor(errChild);
1912
+ if (typeof errCleanup === "function") await errCleanup();
1913
+ await errRunner.close();
1914
+ const errLogged = readFileSync(join(teeDir, "restart-err.log"), "utf8");
1915
+ if (!errLogged.includes("ERR-OUT") || !errLogged.includes("OUT")) throw new Error("tee must mirror stderr as well as stdout");
1916
+
1917
+ // The tee'd child must carry the visible-chain flag: its own restart
1918
+ // decision depends on it (stdout is a pipe here, the TTY is gone).
1919
+ const chainRunner = createTeeRunner({
1920
+ logPath: join(teeDir, "chain.log"),
1921
+ cwd: root,
1922
+ _stdout: new Writable({ write(chunk, encoding, callback) { callback(); } }),
1923
+ });
1924
+ const chainChild = chainRunner.spawn(process.execPath, ["-e", "console.log(process.env.DSH_PLUGIN_MALL_VISIBLE_CONSOLE ?? 'unset')"]);
1925
+ const chainCleanup = chainRunner.attach(chainChild);
1926
+ await chainRunner.waitFor(chainChild);
1927
+ if (typeof chainCleanup === "function") await chainCleanup();
1928
+ await chainRunner.close();
1929
+ if (!readFileSync(join(teeDir, "chain.log"), "utf8").includes("1")) throw new Error("the tee'd child must inherit DSH_PLUGIN_MALL_VISIBLE_CONSOLE=1");
1930
+
1931
+ const consoleChunks = [];
1932
+ const captureOut = new Writable({
1933
+ write(chunk, encoding, callback) { consoleChunks.push(Buffer.from(chunk)); callback(); },
1934
+ });
1935
+ const brokenRunner = createTeeRunner({
1936
+ logPath: join(teeDir, "never.log"),
1937
+ cwd: root,
1938
+ _stdout: captureOut,
1939
+ _openLog: () => { throw new Error("disk on fire"); },
1940
+ });
1941
+ const brokenChild = brokenRunner.spawn(process.execPath, ["-e", "process.stdout.write('STILL-HERE\\n'); process.exit(5)"]);
1942
+ const brokenCleanup = brokenRunner.attach(brokenChild);
1943
+ const brokenResult = await brokenRunner.waitFor(brokenChild);
1944
+ if (typeof brokenCleanup === "function") await brokenCleanup();
1945
+ await brokenRunner.close();
1946
+ const consoleText = Buffer.concat(consoleChunks).toString("utf8");
1947
+ if (brokenResult.code !== 5 || !brokenRunner.broken) throw new Error("a failed log must be console-only and must not affect the child");
1948
+ if (!consoleText.includes("STILL-HERE") || !consoleText.includes("disk on fire")) throw new Error("a failed log must warn on the console and keep showing output");
1949
+
1950
+ // A log that fails WHILE SATURATED must release the backpressure: a
1951
+ // dead stream never drains, and the paused source pipes would otherwise
1952
+ // stay paused forever — the wrapped dsh would hang mid-output.
1953
+ {
1954
+ let errorSink = null;
1955
+ const evilLog = new EventEmitter();
1956
+ evilLog.write = () => false; // always saturated, never drains
1957
+ const recoverChunks = [];
1958
+ const recoverOut = new Writable({
1959
+ write(chunk, encoding, callback) { recoverChunks.push(Buffer.from(chunk)); callback(); },
1960
+ });
1961
+ const recoverRunner = createTeeRunner({
1962
+ logPath: join(teeDir, "evil.log"),
1963
+ cwd: root,
1964
+ _stdout: recoverOut,
1965
+ _openLog: () => {
1966
+ errorSink = evilLog;
1967
+ return evilLog;
1968
+ },
1969
+ });
1970
+ const recoverChild = recoverRunner.spawn(process.execPath, ["-e", `
1971
+ process.stdout.write('BEFORE-ERROR\\n');
1972
+ setTimeout(() => { process.stdout.write('AFTER-ERROR\\n'); process.exit(3); }, 700);
1973
+ `]);
1974
+ const recoverCleanup = recoverRunner.attach(recoverChild);
1975
+ // The first data block saturates the log and pauses the pipes; only
1976
+ // THEN does the log fail — the second block can only arrive if the
1977
+ // error path released the backpressure and resumed them.
1978
+ recoverChild.stdout.once("data", () => {
1979
+ setImmediate(() => { evilLog.emit("error", new Error("ENOSPC: log disk full")); });
1980
+ });
1981
+ const recoverResult = await recoverRunner.waitFor(recoverChild);
1982
+ if (typeof recoverCleanup === "function") await recoverCleanup();
1983
+ await recoverRunner.close();
1984
+ const recoverText = Buffer.concat(recoverChunks).toString("utf8");
1985
+ if (recoverResult.code !== 3 || !recoverRunner.broken) throw new Error("a mid-run log failure must not affect the child");
1986
+ if (!recoverText.includes("BEFORE-ERROR") || !recoverText.includes("AFTER-ERROR")) throw new Error("a mid-run log failure must resume the paused pipes — output after the error must still arrive");
1987
+ }
1988
+
1989
+ // The slow target is a plain EventEmitter, not a Writable: _write is a
1990
+ // serialization point (queued writes never run once the releaser stops),
1991
+ // which would lose TAIL bytes here and misblame the tee. write() always
1992
+ // reports saturated, every drain is hand-released — the strictest
1993
+ // pause/resume cycle the runner can be put through.
1994
+ const slowChunks = [];
1995
+ const pendingDrains = [];
1996
+ const slowOut = new EventEmitter();
1997
+ slowOut.write = (chunk) => {
1998
+ slowChunks.push(Buffer.from(chunk));
1999
+ pendingDrains.push(() => slowOut.emit("drain"));
2000
+ return false;
2001
+ };
2002
+ const slowRunner = createTeeRunner({ logPath: join(teeDir, "slow.log"), cwd: root, _stdout: slowOut });
2003
+ // The child paces itself on its own backpressure and exits naturally —
2004
+ // process.exit() would truncate its in-flight stdout, which is a child
2005
+ // bug the tee must not be blamed for.
2006
+ const slowChild = slowRunner.spawn(process.execPath, ["-e", `
2007
+ const chunk = 'y'.repeat(4096) + '\\n';
2008
+ let index = 0;
2009
+ function pump() {
2010
+ while (index < 100) {
2011
+ index += 1;
2012
+ if (!process.stdout.write(chunk)) { process.stdout.once('drain', pump); return; }
2013
+ }
2014
+ }
2015
+ pump();
2016
+ `]);
2017
+ const slowCleanup = slowRunner.attach(slowChild);
2018
+ const slowDone = slowRunner.waitFor(slowChild);
2019
+ const releaser = setInterval(() => { const release = pendingDrains.shift(); if (release !== undefined) release(); }, 1);
2020
+ const slowResult = await slowDone;
2021
+ clearInterval(releaser);
2022
+ if (typeof slowCleanup === "function") await slowCleanup();
2023
+ await slowRunner.close();
2024
+ const total = 100 * (4096 + 1);
2025
+ const slowSeen = slowChunks.reduce((sum, chunk) => sum + chunk.length, 0);
2026
+ const slowLogged = readFileSync(join(teeDir, "slow.log"), "utf8");
2027
+ if (slowResult.code !== 0 || slowSeen !== total || slowLogged.length !== total) {
2028
+ throw new Error(`backpressure must not lose data (console ${slowSeen}, log ${slowLogged.length}, expected ${total})`);
2029
+ }
2030
+ }
2031
+
2032
+ // Console Ctrl watcher: the first Ctrl+C only marks the run interrupted;
2033
+ // after forceGraceMs (or a second Ctrl+C) the child's whole process TREE
2034
+ // is torn down (a bare kill leaves dsh's grandchildren attached to the
2035
+ // console — the "window will not close" case). waitForProcessExit honors
2036
+ // the flag.
2037
+ {
2038
+ const fakeChild = { pid: 31337, killCalls: 0, kill() { this.killCalls += 1; } };
2039
+ const treeKills = [];
2040
+ const watcher = createInterruptWatcher({ forceGraceMs: 25, _killTree: (pid) => treeKills.push(pid) });
2041
+ watcher.noteChild(fakeChild);
2042
+ process.emit("SIGINT");
2043
+ if (!watcher.interrupted()) throw new Error("the first Ctrl+C must mark the watcher interrupted (and not kill yet)");
2044
+ if (fakeChild.killCalls !== 0 || treeKills.length !== 0) throw new Error("the first Ctrl+C must give the child its own chance to exit");
2045
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 60));
2046
+ if (treeKills.length !== 1 || treeKills[0] !== 31337) throw new Error("the force-deadline must tear down the tree of a child that ignored Ctrl+C");
2047
+ process.emit("SIGINT");
2048
+ if (treeKills.length !== 2) throw new Error("a second Ctrl+C must escalate at once");
2049
+ let aborted = false;
2050
+ watcher.onAbort(() => { aborted = true; });
2051
+ process.emit("SIGBREAK");
2052
+ if (!aborted) throw new Error("inspection pauses must be cut short by any further console signal");
2053
+ watcher.unlisten();
2054
+ if (await waitForProcessExit(process.pid, 5000, 10, () => true) !== false) throw new Error("waitForProcessExit must abort on shouldAbort instead of waiting out the timeout");
2055
+ if (!await waitForProcessExit(0x7ffffff1, 500, 20, () => false)) throw new Error("waitForProcessExit without an abort keeps its liveness semantics");
2056
+ }
2057
+
2058
+ // The anti-misrollback pin: a Ctrl+C during the grace window is NOT a
2059
+ // crash. On Windows the killed child reports code 1 / no signal — without
2060
+ // the watcher's flag probation would roll back a healthy install and
2061
+ // restart dsh in a window the user just tried to close.
2062
+ {
2063
+ const mkInterruptProfile = (homeName) => {
2064
+ const ihome = join(root, homeName);
2065
+ const iprofile = join(ihome, "profiles", "web");
2066
+ const goodDir = join(iprofile, "node_modules", "good");
2067
+ mkdirSync(goodDir, { recursive: true });
2068
+ writeFileSync(join(iprofile, "package.json"), JSON.stringify({
2069
+ dependencies: { good: "1.0.0" },
2070
+ dsh: { profile: { bundles: ["good"] } },
2071
+ }));
2072
+ writeFileSync(join(iprofile, "cordis.patch.yml"), "[]\n");
2073
+ writeFileSync(join(goodDir, "package.json"), JSON.stringify({ name: "good", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
2074
+ writeFileSync(join(goodDir, "cordis.patch.yml"), "- insert:\n - id: good-row\n name: good\n");
2075
+ const ipending = createProfileSnapshot(iprofile, { spec: "good" });
2076
+ markPendingSnapshot(ipending, { spec: "good@1.0.0", preflight: { candidate: { name: "good", version: "1.0.0", kind: "bundle" } } });
2077
+ if (!validateInstalledProfile(iprofile).ok) throw new Error("interrupt fixture prerequisite: the profile must pass static validation");
2078
+ return { ihome, iprofile };
2079
+ };
2080
+
2081
+ // interrupted: exit 130, marker stays pending, nothing rolls back
2082
+ const { ihome } = mkInterruptProfile("interrupt-home");
2083
+ const interruptedWatcher = createInterruptWatcher({ forceGraceMs: 60000 });
2084
+ process.emit("SIGINT");
2085
+ const interruptedCode = await cmdLaunch({
2086
+ profile: "web",
2087
+ home: ihome,
2088
+ graceMs: 4000,
2089
+ commandArgv: [process.execPath, "-e", "process.exit(1)"],
2090
+ interrupt: interruptedWatcher,
2091
+ });
2092
+ interruptedWatcher.unlisten();
2093
+ if (interruptedCode !== 130) throw new Error(`an interrupted run must exit 130, got ${interruptedCode}`);
2094
+ if (readPendingSnapshot(join(ihome, "profiles", "web")) === undefined) throw new Error("an interrupted run must leave the pending marker for the next launch");
2095
+ if (!existsSync(join(ihome, "profiles", "web", "node_modules", "good", "package.json"))) throw new Error("an interrupted run must not roll the install back");
2096
+
2097
+ // The same crash WITHOUT an interrupt must not read as 130: offline the
2098
+ // rollback-and-restart may legitimately fail (no pnpm to reconcile
2099
+ // node_modules), which throws — both spellings prove the interrupt
2100
+ // branch was NOT taken. A marker-free run pins the pure exit-code split.
2101
+ const plain = mkInterruptProfile("interrupt-control-home");
2102
+ let plainReturned;
2103
+ let plainCode;
2104
+ try {
2105
+ plainCode = await cmdLaunch({
2106
+ profile: "web",
2107
+ home: plain.ihome,
2108
+ graceMs: 4000,
2109
+ commandArgv: [process.execPath, "-e", "process.exit(1)"],
2110
+ });
2111
+ plainReturned = true;
2112
+ } catch {
2113
+ plainReturned = false; // offline rollback limits, not the pin under test
2114
+ }
2115
+ if (plainReturned && plainCode === 130) throw new Error("a genuine grace-window crash must not report the interrupt convention 130");
2116
+
2117
+ const bareHome = join(root, "interrupt-bare-home");
2118
+ const bareProfile = join(bareHome, "profiles", "web");
2119
+ mkdirSync(bareProfile, { recursive: true });
2120
+ writeFileSync(join(bareProfile, "package.json"), JSON.stringify({ dependencies: {} }));
2121
+ writeFileSync(join(bareProfile, "cordis.patch.yml"), "[]\n");
2122
+ const barePlain = await cmdLaunch({
2123
+ profile: "web",
2124
+ home: bareHome,
2125
+ commandArgv: [process.execPath, "-e", "process.exit(1)"],
2126
+ });
2127
+ if (barePlain !== 1) throw new Error(`a plain crash without an interrupt keeps the child's code, got ${barePlain}`);
2128
+ const bareWatcher = createInterruptWatcher({ forceGraceMs: 60000 });
2129
+ process.emit("SIGINT");
2130
+ const bareInterrupted = await cmdLaunch({
2131
+ profile: "web",
2132
+ home: bareHome,
2133
+ commandArgv: [process.execPath, "-e", "process.exit(1)"],
2134
+ interrupt: bareWatcher,
2135
+ });
2136
+ bareWatcher.unlisten();
2137
+ if (bareInterrupted !== 130) throw new Error(`an interrupted plain run reports 130, got ${bareInterrupted}`);
2138
+
2139
+ // interrupted while waiting for the outgoing pid: refuse, don't outwait
2140
+ const waitingWatcher = createInterruptWatcher({ forceGraceMs: 60000 });
2141
+ process.emit("SIGINT");
2142
+ let cancelled = false;
2143
+ try {
2144
+ await cmdLaunch({
2145
+ profile: "web",
2146
+ home: plain.ihome,
2147
+ commandArgv: [process.execPath, "-e", "process.exit(0)"],
2148
+ awaitExitPid: 999999,
2149
+ interrupt: waitingWatcher,
2150
+ _waitForExit: async (pid, timeoutMs, pollMs, shouldAbort) => (shouldAbort?.() === true ? false : true),
2151
+ });
2152
+ } catch (error) {
2153
+ cancelled = /interrupted while waiting/.test(error.message);
2154
+ }
2155
+ waitingWatcher.unlisten();
2156
+ if (!cancelled) throw new Error("a Ctrl+C during the await-exit wait must cancel the launch with a clear message");
2157
+ }
2158
+
1300
2159
  // quoteCmdArg: strict MSVCRT/CommandLineToArgvW quoting; cmd metacharacters
1301
2160
  // are refused rather than escaped.
1302
2161
  {