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