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