@1e0zj/dsh-plugin-mall 0.4.12 → 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/package.json +1 -1
- package/src/cli.js +4 -3
- package/src/client.js +1 -2
- package/src/guard.js +174 -6
- package/src/index.js +12 -14
- package/src/installer.js +39 -18
- package/src/terminal.js +11 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
resolveDshHome,
|
|
62
62
|
rollbackPendingSnapshot,
|
|
63
63
|
validateInstalledProfile,
|
|
64
|
+
validatePendingProfile,
|
|
64
65
|
validateRemoveCompletion,
|
|
65
66
|
} from "./guard.js";
|
|
66
67
|
// github.js imports node builtins only — the host-independence of this CLI is
|
|
@@ -502,7 +503,7 @@ async function cmdAdd({ spec, profile, home, acceptWarnings }) {
|
|
|
502
503
|
// rolled back immediately; otherwise the marker stays pending and the next
|
|
503
504
|
// dsh startup (or `guard recover`) commits it once the plugin actually
|
|
504
505
|
// loads.
|
|
505
|
-
const validation =
|
|
506
|
+
const validation = validatePendingProfile(profileDir);
|
|
506
507
|
if (result.exitCode === 0 && validation.ok) {
|
|
507
508
|
console.log(`[guard] installed ${spec} into profile "${profile}".`);
|
|
508
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.");
|
|
@@ -596,7 +597,7 @@ async function cmdRemove({
|
|
|
596
597
|
|
|
597
598
|
let validation;
|
|
598
599
|
try {
|
|
599
|
-
validation =
|
|
600
|
+
validation = validatePendingProfile(profileDir);
|
|
600
601
|
} catch (error) {
|
|
601
602
|
rollbackAndThrow(`remove completed but static profile validation threw: ${error.message}`);
|
|
602
603
|
}
|
|
@@ -1327,7 +1328,7 @@ async function cmdLaunch({
|
|
|
1327
1328
|
}
|
|
1328
1329
|
let validation;
|
|
1329
1330
|
try {
|
|
1330
|
-
validation =
|
|
1331
|
+
validation = validatePendingProfile(profileDir, marker);
|
|
1331
1332
|
} catch (error) {
|
|
1332
1333
|
throw new Error(`static validation of profile "${profile}" failed — refusing to launch: ${error.message}`);
|
|
1333
1334
|
}
|
package/src/client.js
CHANGED
|
@@ -443,7 +443,7 @@ window.__ModuleLoader__.load({
|
|
|
443
443
|
meta: clip(props.spec || "", 50),
|
|
444
444
|
actions: actions,
|
|
445
445
|
},
|
|
446
|
-
|
|
446
|
+
h("div", { className: "mkt_desc" }, "pnpm 已拦截下列安装脚本,尚未执行任何插件代码。允许后会先复核制品再运行;暂不允许时,重启 dsh 会自动撤回这次安装。"),
|
|
447
447
|
pkgs.map(function (p, index) {
|
|
448
448
|
var facts = [];
|
|
449
449
|
if (typeof p.weeklyDownloads === "number") facts.push("周下载 " + p.weeklyDownloads.toLocaleString());
|
|
@@ -609,7 +609,6 @@ window.__ModuleLoader__.load({
|
|
|
609
609
|
job.needsApproval && job.needsApproval.length > 0
|
|
610
610
|
? h(ApprovalRequest, {
|
|
611
611
|
spec: job.spec,
|
|
612
|
-
detail: job.detail,
|
|
613
612
|
needsApproval: job.needsApproval,
|
|
614
613
|
busy: props.approving === job.spec,
|
|
615
614
|
onApprove: function (names) {
|
package/src/guard.js
CHANGED
|
@@ -24,8 +24,10 @@ import {
|
|
|
24
24
|
import { homedir, tmpdir } from "node:os";
|
|
25
25
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
26
26
|
import { createRequire } from "node:module";
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
27
28
|
import { JSON_SCHEMA, Type, load } from "js-yaml";
|
|
28
29
|
import { satisfies, validRange } from "semver";
|
|
30
|
+
import { stripTerminalControlSequences } from "./terminal.js";
|
|
29
31
|
|
|
30
32
|
// Official patches (e.g. @deepseek-ai/dsh-base, dsh-web-app) mark raw JS
|
|
31
33
|
// expressions with the scalar tag `!!js`. Construct the loader's own marker —
|
|
@@ -1450,6 +1452,84 @@ export function validateInstalledProfile(profileDir) {
|
|
|
1450
1452
|
};
|
|
1451
1453
|
}
|
|
1452
1454
|
|
|
1455
|
+
/** Stable identity for one installed-profile blocker across a transaction. */
|
|
1456
|
+
function staticBlockerFingerprint(entry) {
|
|
1457
|
+
const canonical = (value) => {
|
|
1458
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
1459
|
+
if (value !== null && typeof value === "object") {
|
|
1460
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
|
1461
|
+
}
|
|
1462
|
+
return value;
|
|
1463
|
+
};
|
|
1464
|
+
return createHash("sha256").update(JSON.stringify(canonical(entry))).digest("hex");
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function staticBlockerFingerprints(validation) {
|
|
1468
|
+
return validation.issues
|
|
1469
|
+
.filter((entry) => entry.severity === "block")
|
|
1470
|
+
.map(staticBlockerFingerprint);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Validate the live profile relative to the state captured before a pending
|
|
1475
|
+
* transaction. An unchanged blocker that was already present did not come
|
|
1476
|
+
* from this install/remove and must not make the transaction roll back. New
|
|
1477
|
+
* markers carry the baseline; old markers do not, and deliberately retain the
|
|
1478
|
+
* old fail-closed behaviour.
|
|
1479
|
+
*/
|
|
1480
|
+
export function validatePendingProfile(profileDir, pending = readValidatedPendingSnapshot(profileDir)) {
|
|
1481
|
+
const validation = validateInstalledProfile(profileDir);
|
|
1482
|
+
if (!Array.isArray(pending?.baselineBlockers)) {
|
|
1483
|
+
return { ...validation, allIssues: validation.issues, preexistingIssues: [] };
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const candidateName = pending.preflight?.candidate?.name ?? pending.candidate?.name;
|
|
1487
|
+
const remaining = new Map();
|
|
1488
|
+
for (const fingerprint of pending.baselineBlockers) {
|
|
1489
|
+
remaining.set(fingerprint, (remaining.get(fingerprint) ?? 0) + 1);
|
|
1490
|
+
}
|
|
1491
|
+
const issues = [];
|
|
1492
|
+
const preexistingIssues = [];
|
|
1493
|
+
for (const entry of validation.issues) {
|
|
1494
|
+
if (entry.severity !== "block") {
|
|
1495
|
+
issues.push(entry);
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
// Updating the package that owns (or participates in) an old blocker must
|
|
1499
|
+
// prove that blocker was actually fixed. Only unrelated historical issues
|
|
1500
|
+
// receive baseline treatment.
|
|
1501
|
+
const candidateRelated = entry.package === candidateName
|
|
1502
|
+
|| (Array.isArray(entry.conflictsWith) && entry.conflictsWith.includes(candidateName));
|
|
1503
|
+
if (candidateRelated) {
|
|
1504
|
+
issues.push(entry);
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1507
|
+
const fingerprint = staticBlockerFingerprint(entry);
|
|
1508
|
+
const count = remaining.get(fingerprint) ?? 0;
|
|
1509
|
+
if (count > 0) {
|
|
1510
|
+
preexistingIssues.push(entry);
|
|
1511
|
+
remaining.set(fingerprint, count - 1);
|
|
1512
|
+
} else {
|
|
1513
|
+
issues.push(entry);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
const blockers = issues.filter((entry) => entry.severity === "block");
|
|
1517
|
+
const warnings = issues.filter((entry) => entry.severity === "warn");
|
|
1518
|
+
const verdict = blockers.length > 0 ? "blocked" : warnings.length > 0 ? "warning" : "safe";
|
|
1519
|
+
return {
|
|
1520
|
+
ok: blockers.length === 0,
|
|
1521
|
+
verdict,
|
|
1522
|
+
issues,
|
|
1523
|
+
allIssues: validation.issues,
|
|
1524
|
+
preexistingIssues,
|
|
1525
|
+
summary: blockers.length > 0
|
|
1526
|
+
? `发现 ${blockers.length} 个本次新增阻断问题、${preexistingIssues.length} 个既有阻断问题、${warnings.length} 个警告`
|
|
1527
|
+
: preexistingIssues.length > 0
|
|
1528
|
+
? `本次变更未新增阻断问题(保留 ${preexistingIssues.length} 个既有阻断问题)`
|
|
1529
|
+
: validation.summary,
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1453
1533
|
/**
|
|
1454
1534
|
* Remove leftover `node_modules` entries for packages that are no longer part
|
|
1455
1535
|
* of the profile's declared dependencies. pnpm hoists each direct dependency
|
|
@@ -1723,7 +1803,7 @@ function spawnCapture(command, args, options, onOutput, { signal, treeKill = fal
|
|
|
1723
1803
|
let child;
|
|
1724
1804
|
const chunks = [];
|
|
1725
1805
|
const push = (value) => {
|
|
1726
|
-
const text = value
|
|
1806
|
+
const text = stripTerminalControlSequences(value);
|
|
1727
1807
|
chunks.push(text);
|
|
1728
1808
|
onOutput?.(text);
|
|
1729
1809
|
};
|
|
@@ -1831,7 +1911,7 @@ export async function preflightInstall({ profileDir, spec, onOutput, signal }) {
|
|
|
1831
1911
|
const result = await spawnCapture(
|
|
1832
1912
|
plan.command,
|
|
1833
1913
|
probeAddArgs(spec),
|
|
1834
|
-
{ cwd: probeDir, env: process.env, shell: plan.shell },
|
|
1914
|
+
{ cwd: probeDir, env: pnpmGuardEnv(process.env), shell: plan.shell },
|
|
1835
1915
|
onOutput,
|
|
1836
1916
|
{ signal, treeKill: plan.treeKill },
|
|
1837
1917
|
);
|
|
@@ -1998,6 +2078,9 @@ function assertStoredTransactionMatches(pending) {
|
|
|
1998
2078
|
if (pending.operation === "remove" && stored.metadata.packageName !== pending.metadata.packageName) {
|
|
1999
2079
|
throw new Error("pending remove packageName does not match snapshot.json — refusing to act on it (left untouched for manual inspection)");
|
|
2000
2080
|
}
|
|
2081
|
+
if (JSON.stringify(stored.baselineBlockers) !== JSON.stringify(pending.baselineBlockers)) {
|
|
2082
|
+
throw new Error("pending marker baseline blockers do not match snapshot.json — refusing to act on it (left untouched for manual inspection)");
|
|
2083
|
+
}
|
|
2001
2084
|
}
|
|
2002
2085
|
|
|
2003
2086
|
/**
|
|
@@ -2037,6 +2120,12 @@ function sanitizeSnapshot(marker, home) {
|
|
|
2037
2120
|
if (!Array.isArray(marker.dependencies) || marker.dependencies.some((name) => typeof name !== "string" || !NPM_PACKAGE_NAME_RE.test(name))) {
|
|
2038
2121
|
throw new Error("pending marker has missing or corrupt dependency metadata — refusing to act on it (left untouched for manual inspection)");
|
|
2039
2122
|
}
|
|
2123
|
+
if (marker.baselineBlockers !== undefined && (
|
|
2124
|
+
!Array.isArray(marker.baselineBlockers)
|
|
2125
|
+
|| marker.baselineBlockers.some((fingerprint) => typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint))
|
|
2126
|
+
)) {
|
|
2127
|
+
throw new Error("pending marker has corrupt baseline blocker metadata — refusing to act on it (left untouched for manual inspection)");
|
|
2128
|
+
}
|
|
2040
2129
|
validatePendingTransaction(marker);
|
|
2041
2130
|
const profileDir = resolve(String(marker.profileDir ?? ""));
|
|
2042
2131
|
const profilesRoot = resolve(join(home, "profiles"));
|
|
@@ -2090,7 +2179,12 @@ export function createProfileSnapshot(profileDir, metadata = {}) {
|
|
|
2090
2179
|
} catch {
|
|
2091
2180
|
/* manifest unreadable — the rest of the snapshot still captures the bytes */
|
|
2092
2181
|
}
|
|
2093
|
-
|
|
2182
|
+
// Static validation after an install must judge what THIS transaction
|
|
2183
|
+
// changed. Without this baseline, an unrelated legacy plugin that already
|
|
2184
|
+
// shadowed host modules made every later update look guilty and forced the
|
|
2185
|
+
// marketplace itself back to its old version on restart.
|
|
2186
|
+
const baselineBlockers = staticBlockerFingerprints(validateInstalledProfile(profileDir));
|
|
2187
|
+
const snapshot = { version: SNAPSHOT_VERSION, id, dir, profileDir, createdAt: Date.now(), files, dependencies, baselineBlockers, operation: normalizedMetadata.operation, metadata: normalizedMetadata };
|
|
2094
2188
|
writeFileSync(join(dir, "snapshot.json"), JSON.stringify(snapshot, undefined, 2) + "\n");
|
|
2095
2189
|
return snapshot;
|
|
2096
2190
|
}
|
|
@@ -2127,7 +2221,7 @@ export function markPendingSnapshot(snapshot, record = {}) {
|
|
|
2127
2221
|
if (existsSync(markerPath)) {
|
|
2128
2222
|
throw new Error(`profile already has a pending install marker at ${markerPath} — run \`dsh-plugin-guard guard recover\` (or let dsh startup recovery consume it) before installing again`);
|
|
2129
2223
|
}
|
|
2130
|
-
const pending = { ...snapshot, ...record, pendingAt: Date.now() };
|
|
2224
|
+
const pending = { ...snapshot, ...record, baselineBlockers: snapshot.baselineBlockers, pendingAt: Date.now() };
|
|
2131
2225
|
// Reject producer bugs before persisting them. The read/recovery boundary
|
|
2132
2226
|
// repeats this validation because the marker is attacker-controllable.
|
|
2133
2227
|
validatePendingTransaction(pending);
|
|
@@ -2212,6 +2306,13 @@ function addedDependencyNames(pending, originalDependencies = pending?.dependenc
|
|
|
2212
2306
|
export function pnpmGuardEnv(base = process.env) {
|
|
2213
2307
|
return {
|
|
2214
2308
|
...base,
|
|
2309
|
+
// Browser jobs render plain text rather than a terminal. Prevent pnpm from
|
|
2310
|
+
// emitting colours at the source; output capture also strips CSI controls
|
|
2311
|
+
// defensively in case a child ignores these conventional switches.
|
|
2312
|
+
NO_COLOR: "1",
|
|
2313
|
+
FORCE_COLOR: "0",
|
|
2314
|
+
npm_config_color: "false",
|
|
2315
|
+
NPM_CONFIG_COLOR: "false",
|
|
2215
2316
|
npm_config_auto_install_peers: "false",
|
|
2216
2317
|
NPM_CONFIG_AUTO_INSTALL_PEERS: "false",
|
|
2217
2318
|
};
|
|
@@ -2855,7 +2956,7 @@ export function recoverProfile(profileDir) {
|
|
|
2855
2956
|
rebuild: rolled?.rebuild,
|
|
2856
2957
|
};
|
|
2857
2958
|
}
|
|
2858
|
-
const validation =
|
|
2959
|
+
const validation = validatePendingProfile(profileDir, pending);
|
|
2859
2960
|
const candidateName = pending.preflight?.candidate?.name ?? pending.candidate?.name;
|
|
2860
2961
|
const removeValidation = isRemove
|
|
2861
2962
|
? validateRemoveCompletion(pending.profileDir, candidateName)
|
|
@@ -3958,6 +4059,58 @@ async function selfTest() {
|
|
|
3958
4059
|
if (existsSync(snap.dir)) throw new Error("recoverProfile commit should delete the snapshot dir");
|
|
3959
4060
|
}
|
|
3960
4061
|
|
|
4062
|
+
// A blocker that predates the transaction is still a real profile problem,
|
|
4063
|
+
// but it is not evidence that this update broke the profile. This is the
|
|
4064
|
+
// reported marketplace self-update failure: an unrelated legacy plugin
|
|
4065
|
+
// shadows host modules, so the new mall version used to be rolled back.
|
|
4066
|
+
{
|
|
4067
|
+
const p = join(root, "profiles", "preexisting-blocker");
|
|
4068
|
+
mkdirSync(join(p, "node_modules", "legacy"), { recursive: true });
|
|
4069
|
+
mkdirSync(join(p, "node_modules", "good"), { recursive: true });
|
|
4070
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { legacy: "1.0.0", good: "1.0.0" } }));
|
|
4071
|
+
writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
|
|
4072
|
+
writeFileSync(join(p, "node_modules", "legacy", "package.json"), JSON.stringify({
|
|
4073
|
+
name: "legacy", version: "1.0.0", dependencies: { "@deepseek-ai/dsh-llm": "*" },
|
|
4074
|
+
}));
|
|
4075
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0" }));
|
|
4076
|
+
if (validateInstalledProfile(p).issues.filter((entry) => entry.code === "host-module-shadow").length !== 1) {
|
|
4077
|
+
throw new Error("pre-existing blocker fixture prerequisite failed");
|
|
4078
|
+
}
|
|
4079
|
+
const snap = createProfileSnapshot(p, { fixture: true });
|
|
4080
|
+
markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
|
|
4081
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { legacy: "1.0.0", good: "2.0.0" } }));
|
|
4082
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
|
|
4083
|
+
const relative = validatePendingProfile(p);
|
|
4084
|
+
if (!relative.ok || relative.preexistingIssues.length !== 1 || relative.issues.some((entry) => entry.severity === "block")) {
|
|
4085
|
+
throw new Error(`unchanged pre-existing blocker must not condemn this update: ${JSON.stringify(relative)}`);
|
|
4086
|
+
}
|
|
4087
|
+
const rec = recoverProfile(p);
|
|
4088
|
+
if (rec.action !== "committed") throw new Error(`unchanged pre-existing blocker should commit this update, got ${rec.action}`);
|
|
4089
|
+
if (readJson(join(p, "node_modules", "good", "package.json")).version !== "2.0.0") {
|
|
4090
|
+
throw new Error("committing past a pre-existing blocker must keep the updated candidate");
|
|
4091
|
+
}
|
|
4092
|
+
console.log("PASS pending 基线:既有宿主模块 blocker 不再回滚无关更新");
|
|
4093
|
+
|
|
4094
|
+
const candidateProfile = join(root, "profiles", "candidate-own-blocker");
|
|
4095
|
+
mkdirSync(join(candidateProfile, "node_modules", "legacy"), { recursive: true });
|
|
4096
|
+
writeFileSync(join(candidateProfile, "package.json"), JSON.stringify({ dependencies: { legacy: "1.0.0" } }));
|
|
4097
|
+
writeFileSync(join(candidateProfile, "cordis.patch.yml"), "[]\n");
|
|
4098
|
+
writeFileSync(join(candidateProfile, "node_modules", "legacy", "package.json"), JSON.stringify({
|
|
4099
|
+
name: "legacy", version: "1.0.0", dependencies: { "@deepseek-ai/dsh-llm": "*" },
|
|
4100
|
+
}));
|
|
4101
|
+
const ownSnap = createProfileSnapshot(candidateProfile, { fixture: true });
|
|
4102
|
+
markPendingSnapshot(ownSnap, { spec: "legacy@2.0.0", preflight: { candidate: { name: "legacy", version: "2.0.0", kind: "plain" } } });
|
|
4103
|
+
writeFileSync(join(candidateProfile, "node_modules", "legacy", "package.json"), JSON.stringify({
|
|
4104
|
+
name: "legacy", version: "2.0.0", dependencies: { "@deepseek-ai/dsh-llm": "*" },
|
|
4105
|
+
}));
|
|
4106
|
+
const own = validatePendingProfile(candidateProfile);
|
|
4107
|
+
if (own.ok || !own.issues.some((entry) => entry.code === "host-module-shadow")) {
|
|
4108
|
+
throw new Error("an updated candidate must not inherit an exemption for its own old blocker");
|
|
4109
|
+
}
|
|
4110
|
+
commitPendingSnapshot(candidateProfile);
|
|
4111
|
+
console.log("PASS pending 基线:候选自身的既有 blocker 必须在更新中修复");
|
|
4112
|
+
}
|
|
4113
|
+
|
|
3961
4114
|
// Approval-pause mark, part 1: a paused marker must NEVER commit on
|
|
3962
4115
|
// recovery — not even when the static validation would pass (the version
|
|
3963
4116
|
// sits there with its build scripts never approved; committing would drop
|
|
@@ -4602,6 +4755,21 @@ async function selfTest() {
|
|
|
4602
4755
|
if (env.npm_config_auto_install_peers !== "false" || env.NPM_CONFIG_AUTO_INSTALL_PEERS !== "false") {
|
|
4603
4756
|
throw new Error("pnpmGuardEnv must disable peer auto-install");
|
|
4604
4757
|
}
|
|
4758
|
+
if (env.NO_COLOR !== "1" || env.FORCE_COLOR !== "0"
|
|
4759
|
+
|| env.npm_config_color !== "false" || env.NPM_CONFIG_COLOR !== "false") {
|
|
4760
|
+
throw new Error("pnpmGuardEnv must disable terminal colours for browser job logs");
|
|
4761
|
+
}
|
|
4762
|
+
|
|
4763
|
+
let streamed = "";
|
|
4764
|
+
const coloured = await spawnCapture(
|
|
4765
|
+
process.execPath,
|
|
4766
|
+
["-e", "process.stdout.write('\\u001b[96mprobe ok\\u001b[39m\\n')"],
|
|
4767
|
+
{ env: process.env, shell: false },
|
|
4768
|
+
(text) => { streamed += text; },
|
|
4769
|
+
);
|
|
4770
|
+
if (coloured.exitCode !== 0 || coloured.output !== "probe ok\n" || streamed !== "probe ok\n") {
|
|
4771
|
+
throw new Error(`spawnCapture must strip terminal controls from captured and streamed logs: ${JSON.stringify({ coloured, streamed })}`);
|
|
4772
|
+
}
|
|
4605
4773
|
}
|
|
4606
4774
|
|
|
4607
4775
|
// candidateRestoredCompatible: version vs the restored dependency spec.
|
|
@@ -5279,4 +5447,4 @@ if (process.argv[1]?.endsWith("guard.js") && process.argv.includes("--self-test"
|
|
|
5279
5447
|
console.error(`FAIL ${error.stack ?? error.message}`);
|
|
5280
5448
|
process.exitCode = 1;
|
|
5281
5449
|
});
|
|
5282
|
-
}
|
|
5450
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1026,13 +1026,12 @@ export function resolveRestartLaunchPlan({ profile, config = {}, isWindows }) {
|
|
|
1026
1026
|
|
|
1027
1027
|
const nodePath = process.execPath;
|
|
1028
1028
|
const originalDshArgs = process.argv.slice(2);
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
1034
|
-
const
|
|
1035
|
-
const dshArgs = suppressOpen ? [...originalDshArgs, "--no-open"] : [...originalDshArgs];
|
|
1029
|
+
// Restart the exact command the user started. `--no-open` belongs to newer
|
|
1030
|
+
// Web profiles, not to the stable dsh launcher contract; adding it here made
|
|
1031
|
+
// older hosts reject the successor with "unknown option '--no-open'" after
|
|
1032
|
+
// the outgoing process had already exited. A duplicate browser tab is less
|
|
1033
|
+
// harmful than inventing an argv capability the running host never proved.
|
|
1034
|
+
const dshArgs = [...originalDshArgs];
|
|
1036
1035
|
// The outgoing host names itself so `guard launch` can wait for it to be
|
|
1037
1036
|
// gone before binding the port — see --await-exit in cli.js.
|
|
1038
1037
|
const args = [cliPath, "guard", "launch", "--profile", name, "--await-exit", String(process.pid), "--", nodePath, dshEntry, ...dshArgs];
|
|
@@ -1045,7 +1044,6 @@ export function resolveRestartLaunchPlan({ profile, config = {}, isWindows }) {
|
|
|
1045
1044
|
dshEntry,
|
|
1046
1045
|
dshArgs,
|
|
1047
1046
|
profile: name,
|
|
1048
|
-
suppressedBrowserOpen: suppressOpen,
|
|
1049
1047
|
awaitExitPid: process.pid,
|
|
1050
1048
|
};
|
|
1051
1049
|
}
|
|
@@ -3350,15 +3348,15 @@ export async function runSelfTests() {
|
|
|
3350
3348
|
const dshArgs = plan.args.slice(dashDash + 3); // -- node <dshEntry> …
|
|
3351
3349
|
check("重启带 --await-exit 且是本进程 pid", plan.args[plan.args.indexOf("--await-exit") + 1] === String(process.pid) && plan.awaitExitPid === process.pid);
|
|
3352
3350
|
check("--await-exit 排在 `--` 之前(是 guard 的参数,不是 dsh 的)", plan.args.indexOf("--await-exit") < dashDash);
|
|
3353
|
-
check("
|
|
3354
|
-
check("原始 dsh 参数原样保留", dshArgs.
|
|
3351
|
+
check("重启不注入宿主版本相关参数", dshArgs.join(" ") === "--profile web" && !dshArgs.includes("--no-open"));
|
|
3352
|
+
check("原始 dsh 参数原样保留", dshArgs.join(" ") === "--profile web");
|
|
3355
3353
|
check("plan 附带可见模式所需的 dshArgs", plan.dshArgs.join(" ") === dshArgs.join(" "));
|
|
3356
3354
|
|
|
3357
|
-
//
|
|
3355
|
+
// 用户自己传给宿主的参数仍逐字保留。
|
|
3358
3356
|
process.argv = [process.execPath, "/x/bin.js", "--profile", "web", "--no-open"];
|
|
3359
3357
|
const already = resolveRestartLaunchPlan({ profile: "web", config: { allowRestart: true } });
|
|
3360
3358
|
const alreadyArgs = already.ok ? already.args.slice(already.args.indexOf("--") + 3) : [];
|
|
3361
|
-
check("
|
|
3359
|
+
check("用户原有 --no-open 原样保留", already.ok && alreadyArgs.filter((a) => a === "--no-open").length === 1);
|
|
3362
3360
|
} else {
|
|
3363
3361
|
// 裸检出里解析不到官方 dsh 入口,plan 只能 fail——说清楚,别假装验过。
|
|
3364
3362
|
console.log(` SKIP 重启 argv fixture(${plan.error})`);
|
|
@@ -3546,7 +3544,7 @@ export async function runSelfTests() {
|
|
|
3546
3544
|
readyFile: join(fileRoot, "r1.json"),
|
|
3547
3545
|
cwd: "C:/w",
|
|
3548
3546
|
command: "C:/node/node.exe",
|
|
3549
|
-
args: ["C:/dsh/index.js", "web"
|
|
3547
|
+
args: ["C:/dsh/index.js", "web"],
|
|
3550
3548
|
};
|
|
3551
3549
|
check("plan payload 校验通过", validateRestartPlanPayload(basePlan).ok === true);
|
|
3552
3550
|
for (const [override, needle] of [
|
|
@@ -3744,7 +3742,7 @@ export async function runSelfTests() {
|
|
|
3744
3742
|
nodePath: process.execPath,
|
|
3745
3743
|
cliPath: join(visibleRoot, "cli.js"),
|
|
3746
3744
|
dshEntry: join(visibleRoot, "dsh-entry.js"),
|
|
3747
|
-
dshArgs: ["--profile", "web"
|
|
3745
|
+
dshArgs: ["--profile", "web"],
|
|
3748
3746
|
profile: "web",
|
|
3749
3747
|
awaitExitPid: 4242,
|
|
3750
3748
|
};
|
package/src/installer.js
CHANGED
|
@@ -16,7 +16,8 @@ import { createHash } from "node:crypto";
|
|
|
16
16
|
import { dump, load } from "js-yaml";
|
|
17
17
|
import { DEFAULT_PROFILE_BUNDLES, PROFILE_TEMPLATES, initProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
18
18
|
import { describeBuildScripts, npmNameOf } from "./github.js";
|
|
19
|
-
import { clearPendingApprovalPause, commitPendingSnapshot, createProfileSnapshot, describeRollbackRebuild, markPendingApprovalPause, markPendingSnapshot, mcpEntryAuditForInstall, pausedCandidateBeforeState, pendingApprovalPaused, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot,
|
|
19
|
+
import { clearPendingApprovalPause, commitPendingSnapshot, createProfileSnapshot, describeRollbackRebuild, markPendingApprovalPause, markPendingSnapshot, mcpEntryAuditForInstall, pausedCandidateBeforeState, pendingApprovalPaused, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot, validatePendingProfile, validateRemoveCompletion } from "./guard.js";
|
|
20
|
+
import { stripTerminalControlSequences } from "./terminal.js";
|
|
20
21
|
|
|
21
22
|
// ── spec normalization ──────────────────────────────────────────────────────
|
|
22
23
|
|
|
@@ -630,12 +631,20 @@ const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
|
|
|
630
631
|
*/
|
|
631
632
|
function parseIgnoredBuilds(output) {
|
|
632
633
|
const found = new Map();
|
|
634
|
+
// pnpm enables colours even though stdout/stderr are pipes on some Windows
|
|
635
|
+
// setups (observed with pnpm 11). A reset code after the selector makes the
|
|
636
|
+
// anchored `@version` parser miss, so `node-pty@1.1.0\x1b[39m` used to be
|
|
637
|
+
// treated as an invalid package name and the approval pause became an
|
|
638
|
+
// ordinary failed install. Strip CSI terminal controls again here as a
|
|
639
|
+
// fail-closed parsing boundary; the stream capture already removes them from
|
|
640
|
+
// the plain-text job log shown to users.
|
|
641
|
+
const plainOutput = stripTerminalControlSequences(output);
|
|
633
642
|
// Only pnpm's own notice line is a parsing source. "allowBuilds" also
|
|
634
643
|
// appears in pnpm's advice/error text (never followed by a name list), and
|
|
635
644
|
// matching it fed error echoes into the allow-list, corrupting the YAML.
|
|
636
645
|
const pattern = /(?:Ignored build scripts|onlyBuiltDependencies)\s*:\s*([^\n]+)/gi;
|
|
637
646
|
let match;
|
|
638
|
-
while ((match = pattern.exec(
|
|
647
|
+
while ((match = pattern.exec(plainOutput)) !== null) {
|
|
639
648
|
for (const raw of match[1].split(",")) {
|
|
640
649
|
const candidate = raw.trim();
|
|
641
650
|
if (candidate.length === 0) continue;
|
|
@@ -1562,10 +1571,11 @@ function pendingMarkerPath(profileDir) {
|
|
|
1562
1571
|
function renderApprovalNeeded(spec, disclosure) {
|
|
1563
1572
|
const lines = [
|
|
1564
1573
|
`installing ${spec} requires running install-time code — approval needed.`,
|
|
1565
|
-
"No install script ran and no plugin code loaded. The
|
|
1566
|
-
"
|
|
1567
|
-
"
|
|
1568
|
-
"before the verified tree is rebuilt.
|
|
1574
|
+
"No install script ran and no plugin code loaded. The candidate is staged",
|
|
1575
|
+
"with its scripts blocked, and the original profile snapshot is retained.",
|
|
1576
|
+
"On approval, the materialized bytes and commands must match this disclosure",
|
|
1577
|
+
"before the verified tree is rebuilt. If you do not approve, restart dsh or",
|
|
1578
|
+
"run `dsh-plugin-guard guard recover` to roll the paused transaction back.",
|
|
1569
1579
|
"",
|
|
1570
1580
|
];
|
|
1571
1581
|
for (const entry of disclosure) {
|
|
@@ -1661,8 +1671,9 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1661
1671
|
const collected = [];
|
|
1662
1672
|
const deltaQueue = [];
|
|
1663
1673
|
const push = (text) => {
|
|
1664
|
-
|
|
1665
|
-
|
|
1674
|
+
const plainText = stripTerminalControlSequences(text);
|
|
1675
|
+
collected.push(plainText);
|
|
1676
|
+
deltaQueue.push(plainText);
|
|
1666
1677
|
};
|
|
1667
1678
|
|
|
1668
1679
|
const workspacePath = join(profileDir, "pnpm-workspace.yaml");
|
|
@@ -2154,7 +2165,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
|
|
|
2154
2165
|
}
|
|
2155
2166
|
const deltaQueue = [];
|
|
2156
2167
|
const push = (text) => {
|
|
2157
|
-
deltaQueue.push(text);
|
|
2168
|
+
deltaQueue.push(stripTerminalControlSequences(text));
|
|
2158
2169
|
};
|
|
2159
2170
|
let current = undefined;
|
|
2160
2171
|
let cancelRequested = false; // see endedByCancel: exit codes cannot tell us this on Windows
|
|
@@ -2221,7 +2232,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
|
|
|
2221
2232
|
try {
|
|
2222
2233
|
proc = (_spawn ?? spawn)(plan.command, ["remove", packageName, "--reporter=append-only"], {
|
|
2223
2234
|
cwd: profileDir,
|
|
2224
|
-
env: process.env,
|
|
2235
|
+
env: pnpmGuardEnv(process.env),
|
|
2225
2236
|
shell: plan.shell,
|
|
2226
2237
|
stdio: ["ignore", "pipe", "pipe"],
|
|
2227
2238
|
windowsHide: true,
|
|
@@ -2284,7 +2295,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
|
|
|
2284
2295
|
// 退出码 0 不等于卸干净了。落盘校验用的是启动恢复同一套判据:
|
|
2285
2296
|
// profile 整体仍然自洽,且这个包确实从清单和装配层里消失了。任何一条
|
|
2286
2297
|
// 不过就还原——一个「装着但坏」的 profile 比一个没卸掉的插件糟得多。
|
|
2287
|
-
const profileCheck =
|
|
2298
|
+
const profileCheck = validatePendingProfile(profileDir);
|
|
2288
2299
|
const removeCheck = validateRemoveCompletion(profileDir, packageName);
|
|
2289
2300
|
if (!profileCheck.ok || !removeCheck.ok) {
|
|
2290
2301
|
const blockers = [...profileCheck.issues, ...removeCheck.issues]
|
|
@@ -2703,7 +2714,10 @@ async function runTransactionFixtures() {
|
|
|
2703
2714
|
try {
|
|
2704
2715
|
materializeFakePackage(profileDir, "some-plugin", "1.0.0");
|
|
2705
2716
|
materializeFakePackage(profileDir, "node-pty", "1.0.0", { install: "node install.js" });
|
|
2706
|
-
|
|
2717
|
+
// pnpm 11 on Windows may colour stderr even when it is captured through a
|
|
2718
|
+
// pipe. In particular, the reset code lands directly after the selector.
|
|
2719
|
+
const colouredIgnoredBuilds = "Packages are cloned\n\u001b[31mIgnored build scripts: node-pty@1.0.0\u001b[39m\nDone\n";
|
|
2720
|
+
const { spawnFn, calls } = scriptedSpawn([{ code: 0, out: colouredIgnoredBuilds }]);
|
|
2707
2721
|
const producer = runInstall({
|
|
2708
2722
|
profile: "p",
|
|
2709
2723
|
spec: "some-plugin",
|
|
@@ -2725,7 +2739,7 @@ async function runTransactionFixtures() {
|
|
|
2725
2739
|
const output = producer.readOutput();
|
|
2726
2740
|
const markerBefore = pendingMarkerPath(profileDir);
|
|
2727
2741
|
check(
|
|
2728
|
-
"退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
|
|
2742
|
+
"退出码 0 + 彩色 Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
|
|
2729
2743
|
outcome.status === "failed"
|
|
2730
2744
|
&& Array.isArray(outcome.needsApproval)
|
|
2731
2745
|
&& outcome.needsApproval.some((entry) => entry.name === "node-pty")
|
|
@@ -2740,7 +2754,11 @@ async function runTransactionFixtures() {
|
|
|
2740
2754
|
&& entry.weeklyDownloads === 123)
|
|
2741
2755
|
&& calls.length === 1
|
|
2742
2756
|
&& existsSync(markerBefore)
|
|
2743
|
-
&& /
|
|
2757
|
+
&& /candidate is staged/.test(outcome.detail ?? "")
|
|
2758
|
+
&& !/profile was restored/.test(outcome.detail ?? "")
|
|
2759
|
+
&& /paused for build-script approval/.test(output)
|
|
2760
|
+
&& /Ignored build scripts: node-pty@1\.0\.0/.test(output)
|
|
2761
|
+
&& !output.includes("\u001b["),
|
|
2744
2762
|
`status=${outcome.status} calls=${calls.length} marker=${existsSync(markerBefore)}`,
|
|
2745
2763
|
);
|
|
2746
2764
|
check(
|
|
@@ -3413,7 +3431,7 @@ async function runTransactionFixtures() {
|
|
|
3413
3431
|
let snapshotsWhileRunning = [];
|
|
3414
3432
|
const { spawnFn } = scriptedSpawn([{
|
|
3415
3433
|
code: 0,
|
|
3416
|
-
out: "
|
|
3434
|
+
out: "\u001b[32mDone\u001b[39m\n",
|
|
3417
3435
|
// pnpm 真正卸掉:清单与目录都拿走,落盘校验才会通过。
|
|
3418
3436
|
beforeExit: () => {
|
|
3419
3437
|
snapshotsWhileRunning = listSnapshots();
|
|
@@ -3423,14 +3441,17 @@ async function runTransactionFixtures() {
|
|
|
3423
3441
|
rmSync(join(profileDir, "node_modules", "pkg-f"), { recursive: true, force: true });
|
|
3424
3442
|
},
|
|
3425
3443
|
}]);
|
|
3426
|
-
const
|
|
3444
|
+
const producer = runRemove({ profile: "p", packageName: "pkg-f", _profileDir: profileDir, _spawn: spawnFn });
|
|
3445
|
+
const outcome = await producer.done;
|
|
3446
|
+
const output = producer.readOutput();
|
|
3427
3447
|
const snapshotsLeft = listSnapshots();
|
|
3428
3448
|
check(
|
|
3429
|
-
"卸载成功 → marker 与 snapshot 都被提交清理(且快照确实创建过)",
|
|
3449
|
+
"卸载成功 → 日志去色,marker 与 snapshot 都被提交清理(且快照确实创建过)",
|
|
3430
3450
|
outcome.status === "completed"
|
|
3431
3451
|
&& snapshotsWhileRunning.length === 1
|
|
3432
3452
|
&& !existsSync(pendingMarkerPath(profileDir))
|
|
3433
|
-
&& snapshotsLeft.length === 0
|
|
3453
|
+
&& snapshotsLeft.length === 0
|
|
3454
|
+
&& output === "Done\n",
|
|
3434
3455
|
`status=${outcome.status} 运行中快照=${snapshotsWhileRunning.join(",")} marker=${existsSync(pendingMarkerPath(profileDir))} 残留快照=${snapshotsLeft.join(",")} detail=${JSON.stringify(outcome.detail)}`,
|
|
3435
3456
|
);
|
|
3436
3457
|
} finally {
|
package/src/terminal.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Job logs are rendered as plain text in the browser. Child processes such as
|
|
2
|
+
// pnpm may still emit terminal colour controls when their stdio is piped (for
|
|
3
|
+
// example when FORCE_COLOR is inherited), which the browser shows as `[96m`.
|
|
4
|
+
// Keep the sanitizer dependency-free and limited to CSI sequences: those cover
|
|
5
|
+
// pnpm's colours/progress controls without deleting ordinary user text.
|
|
6
|
+
|
|
7
|
+
const ANSI_CSI_RE = new RegExp("\\u001b\\[[0-?]*[ -/]*[@-~]", "g");
|
|
8
|
+
|
|
9
|
+
export function stripTerminalControlSequences(value) {
|
|
10
|
+
return String(value ?? "").replace(ANSI_CSI_RE, "");
|
|
11
|
+
}
|