@1e0zj/dsh-plugin-mall 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,12 +11,12 @@ Two surfaces: a **Settings → Plugins → Marketplace** tab in the dsh web UI,
11
11
  Curated lists only show what has been reviewed and merged. This marketplace is open by construction: **any repo tagged `topic:dsh-plugin` is discoverable the moment it is pushed** — no submission, no approval queue. To keep that openness usable:
12
12
 
13
13
  - **Automatic verification** — every search result's `package.json` is fetched (jsDelivr/raw dual-source CDN, no API quota) and checked for the official `dsh.bundle` / `dsh.client` manifest. Verified plugins get a green badge; the default "verified only" view filters out ~73% of topic noise (empty repos and unrelated projects riding the tag).
14
- - **Browse-time compatibility badges** — each card is also statically scanned against your profile before you click anything: declared conflicts, exclusive groups, loader-id collisions (from the repo's patch file), host-module shadowing, and peer/Node/OS ranges. Cards show 适配 / 有风险 / 冲突 / 适配未知 accordingly — advisory only; the install preflight remains the enforcing gate.
14
+ - **Browse-time compatibility badges** — each card is also statically scanned against your profile before you click anything: declared conflicts, exclusive groups, loader-id collisions (from the repo's patch file), rows the candidate's patch would switch off or reconfigure in the profile you already run, host-module shadowing, and peer/Node/OS ranges. Cards show 适配 / 有风险 / 冲突 / 适配未知 accordingly — advisory only; the install preflight remains the enforcing gate.
15
15
  - **Anti-squatting** — an install prefers the npm tarball only when the registry entry's `repository` URL points back to the same GitHub repo; anything else falls back to the explicit `github:` spec.
16
16
  - **npm-first installs** — registry tarballs are smaller than whole-repo GitHub downloads and come with integrity checks. Lookups follow the registry pnpm actually installs from (profile `.npmrc` → `pnpm config get registry` → npmjs), so a mirror user keeps npm-first instead of silently falling back to whole-repo clones.
17
17
  - **Update management** — installed plugins are compared against the registry `latest`; one-click update per plugin.
18
- - **Conflict guard** — every install runs an isolated preflight first: the candidate is installed with scripts disabled into a throwaway directory and scanned against the live profile for loader-id collisions, double mounts, host-module shadowing and version/OS/peer ranges. A hard conflict is blocked, warnings require explicit confirmation. The profile's load-bearing files are snapshotted before `pnpm` touches them and restored on failure. A pending install is resolved on the next start, however you start it: this plugin runs recovery as it loads, which only happens because dsh booted far enough to compose the profile. Starting through `guard launch` adds a grace window on top, so a plugin that boots and then crashes seconds later is rolled back and restarted once (see [Startup protection](#startup-protection-guard-cli)).
19
- - **Resilience** — rate-limit circuit breaker, GitHub's 1000-result search window handled gracefully, `corepack enable pnpm` self-heal when pnpm is missing, one-click dsh restart (loopback-only, `allowRestart: false` to disable).
18
+ - **Conflict guard** — every install runs an isolated preflight first: the candidate is installed with scripts disabled into a throwaway directory and scanned against the live profile for loader-id collisions, double mounts, host-module shadowing and version/OS/peer ranges. A patch layer is not read entry by entry but composed the way the loader applies it — the profile is assembled with and without the candidate's layer, in the position dsh would apply it, and the two trees are diffed. That composition is checked against the loader's own `applyEntryPatches` on every test run, over fixed shapes and 300 generated patch combinations, so "what dsh would boot" is not a guess. What gets reported is what would actually change: disabling, re-enabling or replacing the config of someone else's rows warns and names them, past ten such rows the candidate is a rival composition rather than an addition and is blocked, and a bundle that is itself another front door (own `bin`, terminal peers, no browser half) is blocked once it switches rows on or off or rewrites a protection row — the fingerprint corroborates, it never blocks on its own. A hard conflict is blocked, warnings require explicit confirmation. The profile's load-bearing files are snapshotted before `pnpm` touches them and restored on failure. A pending install is resolved on the next start, however you start it: this plugin runs recovery as it loads, which only happens because dsh booted far enough to compose the profile. Starting through `guard launch` adds a grace window on top, so a plugin that boots and then crashes seconds later is rolled back and restarted once (see [Startup protection](#startup-protection-guard-cli)).
19
+ - **Resilience** — rate-limit circuit breaker, GitHub's 1000-result search window handled gracefully, `corepack enable pnpm` self-heal when pnpm is missing, one-click dsh restart (loopback-only, `allowRestart: false` to disable). The successor waits for the outgoing host to be gone before it binds — starting into an occupied port would read as "the pending plugin crashed dsh" and roll back an install that was fine. It leaves no stray console behind and does not re-open the browser, because the page that asked for the restart is still there and reconnects on its own; whatever the restart prints lands in `<home>/guard/restart-<profile>.log`.
20
20
 
21
21
  ## Install
22
22
 
@@ -47,6 +47,13 @@ Restart dsh after installing.
47
47
  > spec (recipe in the 安装 section below). Do not hand-overwrite files under
48
48
  > `node_modules`: they are hard-linked into pnpm's global store, and any later
49
49
  > `pnpm add/remove` rebuilds the tree and restores them anyway.
50
+ >
51
+ > Re-packing and running `add` again does **nothing**: the `file:` spec and the
52
+ > version are unchanged, so pnpm calls it already installed and never compares
53
+ > the tarball's bytes. The command succeeds, `package.json` looks right, and
54
+ > `node_modules` still holds the previous build. Always `remove` before `add`,
55
+ > or give the test build its own version. Then restart dsh completely — the
56
+ > install ran inside the host process still executing the old code.
50
57
 
51
58
  ## Startup protection (guard CLI)
52
59
 
@@ -142,12 +149,12 @@ pnpm --dir <profile> add "github:omdsh-dev/dsh-at-file" --ignore-scripts
142
149
  与策展列表不同:**任何打上 `topic:dsh-plugin` 的仓库推送后立即可被发现**——无需投稿、无需审批。为保证开放性可用,做了这些事:
143
150
 
144
151
  - **自动验证**:逐仓库拉取 `package.json`(jsDelivr/raw 双源 CDN,不占 API 配额),按官方 `dsh.bundle` / `dsh.client` 声明打徽章;默认"只看已验证"视图过滤约 73% 的话题噪音
145
- - **浏览期适配徽章**:点安装之前,每张卡片就已对照你的 profile 做过一次静态扫描——声明冲突、独占组、loader-id 冲突(取自仓库补丁文件)、宿主模块遮蔽、peer/Node/OS 范围;卡片上直接显示 适配 / 有风险 / 冲突 / 适配未知。徽章只是提示,真正的拦截闸门仍是安装预检
152
+ - **浏览期适配徽章**:点安装之前,每张卡片就已对照你的 profile 做过一次静态扫描——声明冲突、独占组、loader-id 冲突(取自仓库补丁文件)、候选包的补丁会停用或改写你当前 profile 里的哪些行、宿主模块遮蔽、peer/Node/OS 范围;卡片上直接显示 适配 / 有风险 / 冲突 / 适配未知。徽章只是提示,真正的拦截闸门仍是安装预检
146
153
  - **防抢注**:仅当 npm registry 条目的 `repository` 指回同一 GitHub 仓库时才用 npm 安装,否则回退 `github:` 源
147
154
  - **npm 优先安装**:registry tarball 比整仓库下载更小且带完整性校验;查询用的 registry 跟随 pnpm 实际安装源(profile `.npmrc` → `pnpm config get registry` → npmjs),换了镜像也不会退化成整仓库克隆
148
155
  - **更新管理**:已装插件与 registry `latest` 比对,逐个一键更新
149
- - **冲突防护**:每次安装先跑隔离预检——候选包在一次性目录里以禁用脚本的方式装好后,对照 live profile 扫描 loader-id 冲突、重复挂载、宿主模块遮蔽和版本/OS/peer 范围;硬冲突直接拦截,警告需显式确认。安装前给 profile 的承重文件拍快照、失败即回滚;pending 安装在下次启动时自动了结,**不挑启动方式**:本插件加载时就跑恢复,而能加载本身就证明 dsh 已经组装好 profile、活到了这一步。经 `guard launch` 启动则多一层观察期——插件启动几秒后才崩的情况也能回滚并原样重启一次(见下方「启动保护」)。
150
- - **工程韧性**:限流熔断、GitHub 5xx/超时退避重试(504 瞬时故障不再直达用户)、GitHub 1000 条搜索上限优雅处理、pnpm 缺失时 `corepack` 自愈、一键重启 dsh(仅 loopback,可 `allowRestart: false` 关闭)
156
+ - **冲突防护**:每次安装先跑隔离预检——候选包在一次性目录里以禁用脚本的方式装好后,对照 live profile 扫描 loader-id 冲突、重复挂载、宿主模块遮蔽和版本/OS/peer 范围。补丁不是逐条读,而是**按 loader 的方式组装**——把候选包的层放进 dsh 会放的位置,装前装后各组装一棵树再逐行比对。这套组装每次跑测试都会和 loader 自己的 `applyEntryPatches` 对拍(固定形状 + 300 组随机生成的补丁组合),所以「dsh 会装出什么树」不是猜的。报出来的是真正会变的东西:停用、重新启用、或替换别人整块 config,都点名是哪几条 id 并警告;超过十条就不是叠加而是另一套组合,直接拦截;候选包本身就是另一套门面(自带 bin、依赖终端栈、没有浏览器半边)时,只要它停用/启用了行、或改了沙箱审批这类保护行,就直接拦截——指纹只做佐证,单凭它不拦。硬冲突直接拦截,警告需显式确认。安装前给 profile 的承重文件拍快照、失败即回滚;pending 安装在下次启动时自动了结,**不挑启动方式**:本插件加载时就跑恢复,而能加载本身就证明 dsh 已经组装好 profile、活到了这一步。经 `guard launch` 启动则多一层观察期——插件启动几秒后才崩的情况也能回滚并原样重启一次(见下方「启动保护」)。
157
+ - **工程韧性**:限流熔断、GitHub 5xx/超时退避重试(504 瞬时故障不再直达用户)、GitHub 1000 条搜索上限优雅处理、pnpm 缺失时 `corepack` 自愈、一键重启 dsh(仅 loopback,可 `allowRestart: false` 关闭)。重启会**等旧进程真正退出**再绑端口——抢在端口没释放时启动,会被判成「新装的插件把 dsh 搞崩了」,把一次本来正常的安装回滚掉。重启后不留控制台窗口、也不再重开浏览器:发起重启的那个页面还在,会自己重连;重启过程的输出写进 `<home>/guard/restart-<profile>.log`(想停 dsh 用任务管理器结束 node 进程)
151
158
 
152
159
  ## 安装
153
160
 
@@ -183,7 +190,16 @@ dsh plugin --profile web add link:C:\path\to\dsh-plugin-mall
183
190
  > ```
184
191
  >
185
192
  > 这样 pnpm 的规范副本本身就是新代码,后续任何 `pnpm add/remove` 重建依赖树都不会
186
- > 把它换掉;顺带还验证了 `files` 字段没漏文件。改完代码重新 `npm pack` + 重装即可。
193
+ > 把它换掉;顺带还验证了 `files` 字段没漏文件。
194
+ >
195
+ > **`remove` 那一步不能省。** 改完代码重新 `npm pack` 之后只跑 `add`,pnpm 会
196
+ > **什么都不做**:spec(`file:` 路径)和版本号都没变,它就判定「已经装好了」而
197
+ > 跳过,根本不去比对 tarball 的字节。表现是命令成功返回、`package.json` 看着也
198
+ > 对,但 `node_modules` 里还是上一版代码——排查时极难想到这一层。要么每次都
199
+ > `remove` + `add`,要么给测试包换一个版本号(如 `0.3.5-test.1`)。
200
+ >
201
+ > 同理,**装完必须完整重启 dsh**(不是刷新页面):卸载/安装是由**正在运行的那个
202
+ > 宿主进程**执行的,它内存里跑的还是旧代码。新装的代码要下一次启动才生效。
187
203
  >
188
204
  > **不要用直接覆盖 `node_modules` 里文件的办法。** 它有两个坑:
189
205
  > 一是 pnpm 装出来的文件是**硬链接**(与全局 store 共享 inode),直接 `cp` 覆盖会
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/cli.js CHANGED
@@ -637,6 +637,42 @@ function resolveWindowsCommand(command) {
637
637
  return command;
638
638
  }
639
639
 
640
+ /** How long `--await-exit` waits for the outgoing host before giving up. */
641
+ const AWAIT_EXIT_TIMEOUT_MS = 30000;
642
+ /** Settle time after the pid is gone, before the successor binds the port. */
643
+ const PORT_SETTLE_MS = 400;
644
+
645
+ const delay = (ms) => new Promise((resolvePromise) => { setTimeout(resolvePromise, ms); });
646
+
647
+ /**
648
+ * Poll until `pid` is gone, or the timeout elapses.
649
+ *
650
+ * `kill(pid, 0)` is the portable liveness probe: it delivers no signal and
651
+ * throws ESRCH once the process is gone. EPERM means it exists under another
652
+ * owner — still alive, so keep waiting rather than racing it.
653
+ *
654
+ * A pid is only meaningful because the caller is the process that spawned us
655
+ * and named itself. It can still be recycled in principle; the bounded wait
656
+ * and the refusal on timeout keep that from turning into a hang.
657
+ *
658
+ * @returns true when the process is gone, false on timeout.
659
+ */
660
+ async function waitForProcessExit(pid, timeoutMs, pollMs = 100) {
661
+ const target = Number(pid);
662
+ if (!Number.isInteger(target) || target <= 0) return true; // nothing to wait for
663
+ const deadline = Date.now() + timeoutMs;
664
+ for (;;) {
665
+ try {
666
+ process.kill(target, 0);
667
+ } catch (error) {
668
+ if (error?.code === "ESRCH") return true;
669
+ if (error?.code !== "EPERM") return true; // unprobeable: do not block the restart on it
670
+ }
671
+ if (Date.now() >= deadline) return false;
672
+ await delay(pollMs);
673
+ }
674
+ }
675
+
640
676
  /**
641
677
  * Spawn the command after `--` with inherited stdio. POSIX uses shell:false so
642
678
  * the argv reaches execvp untouched. On Windows a bare name is first resolved
@@ -644,8 +680,25 @@ function resolveWindowsCommand(command) {
644
680
  * so they go through %ComSpec% with every token strictly quoted; .exe/.com and
645
681
  * explicit paths spawn directly with shell:false.
646
682
  */
683
+ /**
684
+ * Whether to stop Windows conjuring a console for the child.
685
+ *
686
+ * Inherit the console we have; never create one we do not. Run from a terminal
687
+ * our stdio IS that terminal, the child attaches to it, and hiding would cost
688
+ * the interactive session its Ctrl+C. Run from the detached restart our stdio
689
+ * is the log file and we hold no console at all, so the child would be handed
690
+ * a brand-new window — blank, since its output goes to the log — which is the
691
+ * stray console a restart used to leave on screen. Every subprocess this
692
+ * package spawns already passes `windowsHide` (installer.js, six call sites),
693
+ * so nothing downstream depends on inheriting a visible console.
694
+ */
695
+ function hideChildConsole() {
696
+ return process.platform === "win32" && process.stdout.isTTY !== true;
697
+ }
698
+
647
699
  function spawnCommand(command, args) {
648
700
  const resolved = process.platform === "win32" ? resolveWindowsCommand(command) : command;
701
+ const windowsHide = hideChildConsole();
649
702
  if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(resolved)) {
650
703
  const comspec = process.env.ComSpec ?? "cmd.exe";
651
704
  const line = [resolved, ...args].map(quoteCmdArg).join(" ");
@@ -654,9 +707,9 @@ function spawnCommand(command, args) {
654
707
  // windowsVerbatimArguments passes the line to CreateProcess exactly as
655
708
  // built — otherwise libuv would re-quote it for CommandLineToArgvW and the
656
709
  // escaped quotes would break cmd's /s stripping.
657
- return spawn(comspec, ["/d", "/s", "/c", `"${line}"`], { shell: false, stdio: "inherit", env: process.env, windowsVerbatimArguments: true });
710
+ return spawn(comspec, ["/d", "/s", "/c", `"${line}"`], { shell: false, stdio: "inherit", env: process.env, windowsVerbatimArguments: true, windowsHide });
658
711
  }
659
- return spawn(resolved, args, { shell: false, stdio: "inherit", env: process.env });
712
+ return spawn(resolved, args, { shell: false, stdio: "inherit", env: process.env, windowsHide });
660
713
  }
661
714
 
662
715
  /** Forward SIGINT/SIGTERM to the child where the platform delivers them to us. */
@@ -831,10 +884,26 @@ function markerLooksValid(marker, profileDir, home) {
831
884
  * A corrupt or legacy (pre-v2) marker, or a failed static-recovery step, fails
832
885
  * closed: the command is NOT launched and no unvalidated path is deleted.
833
886
  */
834
- async function cmdLaunch({ profile, home, graceMs, commandArgv }) {
887
+ async function cmdLaunch({ profile, home, graceMs, commandArgv, awaitExitPid, _waitForExit = waitForProcessExit }) {
835
888
  const profileDir = profileDirOf(home, profile);
836
889
  const grace = graceMs ?? DEFAULT_GRACE_MS;
837
890
  const [command, ...args] = commandArgv;
891
+
892
+ // A restart hands us the pid of the host that is on its way out. Starting
893
+ // the successor while it still holds the listening port is not a race worth
894
+ // taking: the bind fails, the probation below reads that as "the pending
895
+ // install crashed dsh", and it rolls back an install that was fine. Wait for
896
+ // the process to be gone, then let the port settle.
897
+ //
898
+ // Refuse rather than start anyway if it outlives the timeout. Starting is
899
+ // the outcome that costs the user their install; not starting leaves the old
900
+ // host running and the marker pending, which the next launch resolves.
901
+ if (awaitExitPid !== undefined) {
902
+ if (!await _waitForExit(awaitExitPid, AWAIT_EXIT_TIMEOUT_MS)) {
903
+ 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)`);
904
+ }
905
+ await delay(PORT_SETTLE_MS);
906
+ }
838
907
  // Mirrors guard.js pendingPath(): <home>/guard/pending-<profile>.json.
839
908
  const markerPath = join(home, "guard", `pending-${profile}.json`);
840
909
 
@@ -915,7 +984,7 @@ Usage:
915
984
  node src/cli.js guard list [--home <dir>]
916
985
  node src/cli.js guard add <spec> --profile <name> [--home <dir>] [--accept-warnings]
917
986
  node src/cli.js guard remove <package> --profile <name> [--home <dir>]
918
- node src/cli.js guard launch --profile <name> [--home <dir>] [--grace-ms <ms>] -- <command> [args...]
987
+ node src/cli.js guard launch --profile <name> [--home <dir>] [--grace-ms <ms>] [--await-exit <pid>] -- <command> [args...]
919
988
  node src/cli.js guard self-test
920
989
 
921
990
  Commands:
@@ -956,7 +1025,7 @@ function parseArgs(argv) {
956
1025
  if (args[0] === "guard") args.shift(); // `node cli.js guard recover` / `node cli.js recover`
957
1026
  let command = args.shift() ?? "help";
958
1027
  if (command === "--help" || command === "-h") command = "help"; // `cli.js --help`
959
- const opts = { home: undefined, profile: undefined, graceMs: undefined, acceptWarnings: false, positionals: [], commandArgv: [] };
1028
+ const opts = { home: undefined, profile: undefined, graceMs: undefined, awaitExit: undefined, acceptWarnings: false, positionals: [], commandArgv: [] };
960
1029
  for (let index = 0; index < args.length; index++) {
961
1030
  const arg = args[index];
962
1031
  if (arg === "--") { opts.commandArgv = args.slice(index + 1); break; } // launch: the wrapped command, verbatim
@@ -968,6 +1037,8 @@ function parseArgs(argv) {
968
1037
  if (arg.startsWith("--profile=")) { opts.profile = arg.slice("--profile=".length); continue; }
969
1038
  if (arg === "--grace-ms") { opts.graceMs = args[++index]; continue; }
970
1039
  if (arg.startsWith("--grace-ms=")) { opts.graceMs = arg.slice("--grace-ms=".length); continue; }
1040
+ if (arg === "--await-exit") { opts.awaitExit = args[++index]; continue; }
1041
+ if (arg.startsWith("--await-exit=")) { opts.awaitExit = arg.slice("--await-exit=".length); continue; }
971
1042
  if (arg === "--all") { opts.all = true; continue; }
972
1043
  if (arg.startsWith("-")) throw new Error(`unknown option ${JSON.stringify(arg)}`);
973
1044
  opts.positionals.push(arg);
@@ -1038,7 +1109,12 @@ async function main(argv) {
1038
1109
  graceMs = Number(parsed.graceMs);
1039
1110
  if (!Number.isFinite(graceMs) || graceMs < 0) throw usageError(`--grace-ms must be a non-negative number, got ${JSON.stringify(parsed.graceMs)}`);
1040
1111
  }
1041
- process.exitCode = await cmdLaunch({ profile: parsed.profile, home, graceMs, commandArgv: parsed.commandArgv });
1112
+ let awaitExitPid;
1113
+ if (parsed.awaitExit !== undefined) {
1114
+ awaitExitPid = Number(parsed.awaitExit);
1115
+ if (!Number.isInteger(awaitExitPid) || awaitExitPid <= 0) throw usageError(`--await-exit must be a positive process id, got ${JSON.stringify(parsed.awaitExit)}`);
1116
+ }
1117
+ process.exitCode = await cmdLaunch({ profile: parsed.profile, home, graceMs, commandArgv: parsed.commandArgv, awaitExitPid });
1042
1118
  return;
1043
1119
  }
1044
1120
  case "self-test": {
@@ -1101,6 +1177,78 @@ async function selfTest() {
1101
1177
  if (p2.graceMs !== "0" || p2.commandArgv[0] !== "dsh.cmd" || p2.commandArgv.length !== 2) throw new Error("parseArgs launch =fixture failed");
1102
1178
  const p3 = parseArgs(["launch", "--profile", "web", "--", "--weird-but-verbatim"]);
1103
1179
  if (p3.commandArgv[0] !== "--weird-but-verbatim") throw new Error("parseArgs should pass post-`--` args through verbatim");
1180
+ const p4 = parseArgs(["launch", "--profile", "web", "--await-exit", "4321", "--", "dsh"]);
1181
+ if (p4.awaitExit !== "4321") throw new Error("parseArgs --await-exit fixture failed");
1182
+ const p5 = parseArgs(["launch", "--profile=web", "--await-exit=99", "--", "dsh"]);
1183
+ if (p5.awaitExit !== "99") throw new Error("parseArgs --await-exit= fixture failed");
1184
+ // A pid after `--` belongs to the wrapped command, not to us.
1185
+ const p6 = parseArgs(["launch", "--profile", "web", "--", "dsh", "--await-exit", "7"]);
1186
+ if (p6.awaitExit !== undefined || p6.commandArgv.join(" ") !== "dsh --await-exit 7") throw new Error("--await-exit after `--` must stay with the wrapped command");
1187
+ }
1188
+
1189
+ // `--await-exit`: the successor must not start while the outgoing host is
1190
+ // still holding the port. A pid that never goes away is refused outright —
1191
+ // starting anyway is what costs the user their install, because the failed
1192
+ // bind reads as "the pending plugin crashed dsh" and rolls it back.
1193
+ {
1194
+ const p = join(root, "profiles", "await-exit");
1195
+ mkdirSync(p, { recursive: true });
1196
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
1197
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
1198
+ const home = root;
1199
+
1200
+ let launched = false;
1201
+ const neverExits = async () => false;
1202
+ let refused = false;
1203
+ try {
1204
+ await cmdLaunch({
1205
+ profile: "await-exit",
1206
+ home,
1207
+ commandArgv: [process.execPath, "-e", "launched=1"],
1208
+ awaitExitPid: 999999,
1209
+ _waitForExit: neverExits,
1210
+ });
1211
+ } catch (error) {
1212
+ refused = /still running after/.test(error.message);
1213
+ }
1214
+ if (!refused) throw new Error("a pid that outlives the timeout must refuse the launch, not race it");
1215
+
1216
+ // …and a pid that does go away lets the command through.
1217
+ const exitsPromptly = async () => true;
1218
+ const code = await cmdLaunch({
1219
+ profile: "await-exit",
1220
+ home,
1221
+ commandArgv: [process.execPath, "-e", "process.exit(0)"],
1222
+ awaitExitPid: 999999,
1223
+ _waitForExit: exitsPromptly,
1224
+ });
1225
+ launched = code === 0;
1226
+ if (!launched) throw new Error("once the outgoing pid is gone the command must run");
1227
+
1228
+ // waitForProcessExit itself: this process is alive, a pid that cannot
1229
+ // exist is gone, and a nonsense pid is not something to wait on.
1230
+ if (await waitForProcessExit(process.pid, 120, 20)) throw new Error("waitForProcessExit must not report a live process as gone");
1231
+ if (!await waitForProcessExit(0x7ffffff0, 500, 20)) throw new Error("waitForProcessExit must report an absent pid as gone");
1232
+ if (!await waitForProcessExit(undefined, 500, 20)) throw new Error("waitForProcessExit must not block on a missing pid");
1233
+ }
1234
+
1235
+ // hideChildConsole: inherit the console we have, never create one we do
1236
+ // not. Getting this backwards is visible either way — a stray blank window
1237
+ // after every restart, or an interactive `guard launch` that has lost its
1238
+ // Ctrl+C. Windows-only by construction; asserted on both platforms so the
1239
+ // Linux CI still pins the POSIX half.
1240
+ {
1241
+ const realIsTty = process.stdout.isTTY;
1242
+ try {
1243
+ Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
1244
+ if (hideChildConsole()) throw new Error("a child of an interactive terminal must inherit its console, not be hidden from it");
1245
+ Object.defineProperty(process.stdout, "isTTY", { value: undefined, configurable: true });
1246
+ if (hideChildConsole() !== (process.platform === "win32")) {
1247
+ throw new Error("with no console of our own the child must not be given a new window on Windows (and the flag is meaningless elsewhere)");
1248
+ }
1249
+ } finally {
1250
+ Object.defineProperty(process.stdout, "isTTY", { value: realIsTty, configurable: true });
1251
+ }
1104
1252
  }
1105
1253
 
1106
1254
  // quoteCmdArg: strict MSVCRT/CommandLineToArgvW quoting; cmd metacharacters
@@ -1333,9 +1481,11 @@ async function selfTest() {
1333
1481
  if (!refused || !rolledBack) throw new Error("cmdRemove partial dsh reconcile must roll back despite generic validation passing");
1334
1482
  }
1335
1483
 
1336
- // Startup must apply the same remove-completion check before probation;
1337
- // otherwise a long-running app would commit a crash-partial remove after
1338
- // the grace period merely because generic validation says "loadable".
1484
+ // Startup must apply the remove-completion check before probation, so a
1485
+ // long-running app cannot commit a crash-partial remove once the grace
1486
+ // period elapses. The leftover bundle entry fails generic validation too
1487
+ // (a layer without a dsh.bundle manifest stops dsh at startup); the
1488
+ // remove-specific check is what names the actual cause.
1339
1489
  {
1340
1490
  const launchHome = join(root, "remove-launch-home");
1341
1491
  const launchProfile = join(launchHome, "profiles", "web");
@@ -1353,7 +1503,7 @@ async function selfTest() {
1353
1503
  dependencies: {},
1354
1504
  dsh: { profile: { bundles: ["victim"] } },
1355
1505
  }));
1356
- if (!validateInstalledProfile(launchProfile).ok) throw new Error("launch partial-remove fixture must reproduce generic false-safe validation");
1506
+ if (validateInstalledProfile(launchProfile).ok) throw new Error("launch partial-remove fixture must fail validation before probation");
1357
1507
  const exitCode = await cmdLaunch({
1358
1508
  profile: "web",
1359
1509
  home: launchHome,