@1e0zj/dsh-plugin-mall 0.4.12 → 0.4.15
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 +72 -0
- package/package.json +1 -1
- package/src/cli.js +4 -3
- package/src/client.js +33 -6
- package/src/guard.js +174 -6
- package/src/index.js +104 -14
- package/src/installer.js +577 -31
- package/src/terminal.js +11 -0
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;
|
|
@@ -757,6 +766,169 @@ function splitBuildSelector(key) {
|
|
|
757
766
|
return NPM_NAME_RE.test(name) ? { name, spec: text.slice(suffix.index + 1) } : undefined;
|
|
758
767
|
}
|
|
759
768
|
|
|
769
|
+
/**
|
|
770
|
+
* Workspace keys pnpm writes durably on its own during an install. Everything
|
|
771
|
+
* else the transaction touches in pnpm-workspace.yaml is our temporary
|
|
772
|
+
* `allowBuilds` neutralization, which the post-install restore is supposed to
|
|
773
|
+
* wipe.
|
|
774
|
+
*/
|
|
775
|
+
const PERSISTENT_WORKSPACE_KEYS = ["minimumReleaseAgeExclude"];
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Match ONE top-level `key:` line, in every legal YAML spelling of the key:
|
|
779
|
+
* bare, single-quoted, or double-quoted. Key-locating that only knows the
|
|
780
|
+
* bare form misreads a quoted original as "key absent" and appends a
|
|
781
|
+
* duplicate mapping key — silently corrupting the file.
|
|
782
|
+
*/
|
|
783
|
+
function topLevelKeyRegex(key) {
|
|
784
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
785
|
+
return new RegExp(`^(?:${escaped}|'${escaped}'|"${escaped}")\\s*:`);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Splice one top-level key's value into a workspace yaml, textually.
|
|
790
|
+
*
|
|
791
|
+
* The value is re-serialized, but the surrounding file keeps its bytes:
|
|
792
|
+
* comments, key order, and unrelated keys are the user's, and the restore
|
|
793
|
+
* must not cost them their formatting (same discipline as the patch-file
|
|
794
|
+
* edits).
|
|
795
|
+
*/
|
|
796
|
+
function spliceWorkspaceKey(content, key, value) {
|
|
797
|
+
const block = dump({ [key]: value }, { lineWidth: -1, noRefs: true, sortKeys: false }).replace(/\n+$/, "");
|
|
798
|
+
const keyLine = topLevelKeyRegex(key);
|
|
799
|
+
const lines = content.split("\n");
|
|
800
|
+
const start = lines.findIndex((line) => keyLine.test(line));
|
|
801
|
+
if (start === -1) {
|
|
802
|
+
return `${content.replace(/\n*$/, "\n")}${block}\n`;
|
|
803
|
+
}
|
|
804
|
+
// The key's block runs until the next top-level line (or EOF); indented
|
|
805
|
+
// entries, indented comments, and blank lines in between all belong to it.
|
|
806
|
+
let end = start + 1;
|
|
807
|
+
while (end < lines.length && !/^\S/.test(lines[end])) end += 1;
|
|
808
|
+
const blockLines = block.split("\n");
|
|
809
|
+
// Keep the user's own spelling of the key line (quoted or bare) when it
|
|
810
|
+
// carries no inline value; only the entries below it are re-serialized.
|
|
811
|
+
if (lines[start].replace(keyLine, "").trim() === "") blockLines[0] = lines[start].replace(/\s+$/, "");
|
|
812
|
+
lines.splice(start, end - start, ...blockLines);
|
|
813
|
+
return `${lines.join("\n").replace(/\n+$/, "")}\n`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Extract the persistent workspace keys from a workspace yaml as a plain
|
|
818
|
+
* `{key: value}` object. Used to snapshot pnpm's own writes at a trusted
|
|
819
|
+
* moment — see the approval branch in runInstallInner for why the on-disk
|
|
820
|
+
* state after approved scripts ran is NOT trusted.
|
|
821
|
+
*/
|
|
822
|
+
function extractPersistentWrites(content) {
|
|
823
|
+
const text = Buffer.isBuffer(content) ? content.toString("utf8") : content;
|
|
824
|
+
if (typeof text !== "string" || text.length === 0) return {};
|
|
825
|
+
let doc;
|
|
826
|
+
try {
|
|
827
|
+
doc = load(text) ?? {};
|
|
828
|
+
} catch {
|
|
829
|
+
return {};
|
|
830
|
+
}
|
|
831
|
+
const writes = {};
|
|
832
|
+
for (const key of PERSISTENT_WORKSPACE_KEYS) {
|
|
833
|
+
if (doc[key] !== undefined) writes[key] = doc[key];
|
|
834
|
+
}
|
|
835
|
+
return writes;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Merge captured persistent workspace writes into the user's pre-install
|
|
840
|
+
* bytes. {@link mergePersistentWorkspaceWrites} for the full story; this is
|
|
841
|
+
* the core that takes the writes as an object instead of a disk snapshot.
|
|
842
|
+
*
|
|
843
|
+
* Failing loudly by contract: when pnpm DID record something to merge but the
|
|
844
|
+
* merged text would not parse, or the spliced key does not come out exactly
|
|
845
|
+
* once with exactly the values requested, this THROWS. The caller
|
|
846
|
+
* (restoreOriginalWorkspace) turns that into a failed install with rollback.
|
|
847
|
+
* Silently returning the original bytes instead would report success while
|
|
848
|
+
* dropping the exemption — re-arming the lockfile policy against the very
|
|
849
|
+
* version this install just put on disk, which is the exact incident this
|
|
850
|
+
* merge exists to prevent. "Completed" must imply the exemption state is
|
|
851
|
+
* consistent with the lockfile.
|
|
852
|
+
*/
|
|
853
|
+
function applyPersistentWrites(writes, originalContent) {
|
|
854
|
+
const original = Buffer.isBuffer(originalContent) ? originalContent.toString("utf8") : originalContent;
|
|
855
|
+
if (writes === null || typeof writes !== "object") {
|
|
856
|
+
throw new Error("persistent workspace writes must be an object");
|
|
857
|
+
}
|
|
858
|
+
const base = typeof original === "string" ? original : DEFAULT_WORKSPACE_YAML;
|
|
859
|
+
let baseDoc;
|
|
860
|
+
try {
|
|
861
|
+
baseDoc = load(base) ?? {};
|
|
862
|
+
} catch (error) {
|
|
863
|
+
throw new Error(`cannot merge the release-age exemption pnpm recorded: pnpm-workspace.yaml does not parse (${error.message})`);
|
|
864
|
+
}
|
|
865
|
+
let merged = base;
|
|
866
|
+
let changed = false;
|
|
867
|
+
for (const key of PERSISTENT_WORKSPACE_KEYS) {
|
|
868
|
+
const value = writes[key];
|
|
869
|
+
if (value === undefined) continue;
|
|
870
|
+
if (JSON.stringify(baseDoc[key]) === JSON.stringify(value)) continue;
|
|
871
|
+
merged = spliceWorkspaceKey(merged, key, value);
|
|
872
|
+
changed = true;
|
|
873
|
+
}
|
|
874
|
+
if (!changed) return original;
|
|
875
|
+
try {
|
|
876
|
+
const reparsed = load(merged) ?? {};
|
|
877
|
+
for (const key of PERSISTENT_WORKSPACE_KEYS) {
|
|
878
|
+
if (writes[key] === undefined) continue;
|
|
879
|
+
// The key must appear exactly once at top level (an unrecognized
|
|
880
|
+
// spelling in the original would otherwise leave a duplicate behind)
|
|
881
|
+
// and must parse back to the exact values being merged.
|
|
882
|
+
const occurrences = merged.split("\n").filter((line) => topLevelKeyRegex(key).test(line)).length;
|
|
883
|
+
if (occurrences !== 1) throw new Error("the key does not appear exactly once after the merge");
|
|
884
|
+
if (JSON.stringify(reparsed[key]) !== JSON.stringify(writes[key])) throw new Error("the merged value does not round-trip");
|
|
885
|
+
}
|
|
886
|
+
} catch (error) {
|
|
887
|
+
throw new Error(`cannot merge the release-age exemption pnpm recorded into pnpm-workspace.yaml (${error.message}) — the file uses a YAML spelling this merge does not recognize; simplify the minimumReleaseAgeExclude entry and retry`);
|
|
888
|
+
}
|
|
889
|
+
return merged;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Merge pnpm's own persisted workspace writes back into the user's pre-install
|
|
894
|
+
* bytes on the success path of an install.
|
|
895
|
+
*
|
|
896
|
+
* `pnpm add pkg@version` with the version inside the minimumReleaseAge window
|
|
897
|
+
* goes through and pnpm records an auto-exemption in
|
|
898
|
+
* `minimumReleaseAgeExclude` — a durable write to pnpm-workspace.yaml, not to
|
|
899
|
+
* the lockfile. The byte-restore that undoes our temporary `allowBuilds`
|
|
900
|
+
* neutralization wiped it along with everything else, so every marketplace
|
|
901
|
+
* update of a fresh release silently lost its exemption; the next unrelated
|
|
902
|
+
* install then failed lockfile policy verification on the very version that
|
|
903
|
+
* update had installed (real case: 0.4.14, updated to from the marketplace
|
|
904
|
+
* page, kept the profile un-installable for 24h).
|
|
905
|
+
*
|
|
906
|
+
* So restoring on success is a merge, not a byte rollback: start from the
|
|
907
|
+
* user's original bytes and splice in exactly the persistent keys pnpm wrote.
|
|
908
|
+
* When pnpm wrote nothing persistent the original bytes come back unchanged;
|
|
909
|
+
* when pnpm wrote something that cannot be merged safely this throws (see
|
|
910
|
+
* applyPersistentWrites) so the install fails and rolls back instead of
|
|
911
|
+
* reporting success with a dropped exemption.
|
|
912
|
+
*
|
|
913
|
+
* Only call this with a disk state no install script has had a chance to
|
|
914
|
+
* touch (or with a writes snapshot captured before they ran) — a script that
|
|
915
|
+
* got approved to execute could otherwise launder arbitrary release-age
|
|
916
|
+
* exemptions into the user's config as "pnpm writes".
|
|
917
|
+
*
|
|
918
|
+
* @param afterContent - pnpm-workspace.yaml as it sat on disk when pnpm
|
|
919
|
+
* finished (scripts still blocked, or a snapshot taken before they ran).
|
|
920
|
+
* @param originalContent - the user's pre-install bytes, or undefined when the
|
|
921
|
+
* file did not exist before (the install wrote the default template).
|
|
922
|
+
* @returns the bytes to leave on disk — `originalContent` as-is when pnpm
|
|
923
|
+
* wrote nothing persistent.
|
|
924
|
+
*/
|
|
925
|
+
export function mergePersistentWorkspaceWrites(afterContent, originalContent) {
|
|
926
|
+
// Callers hand in the pre-save Buffer from readFileSync; normalize so the
|
|
927
|
+
// "no original file" case (undefined) is not confused with a Buffer.
|
|
928
|
+
const original = Buffer.isBuffer(originalContent) ? originalContent.toString("utf8") : originalContent;
|
|
929
|
+
return applyPersistentWrites(extractPersistentWrites(afterContent), original);
|
|
930
|
+
}
|
|
931
|
+
|
|
760
932
|
/**
|
|
761
933
|
* Neutralize allowBuilds in pnpm-workspace.yaml so that pnpm strictly blocks
|
|
762
934
|
* the lifecycle scripts of everything this transaction introduces.
|
|
@@ -1562,10 +1734,11 @@ function pendingMarkerPath(profileDir) {
|
|
|
1562
1734
|
function renderApprovalNeeded(spec, disclosure) {
|
|
1563
1735
|
const lines = [
|
|
1564
1736
|
`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.
|
|
1737
|
+
"No install script ran and no plugin code loaded. The candidate is staged",
|
|
1738
|
+
"with its scripts blocked, and the original profile snapshot is retained.",
|
|
1739
|
+
"On approval, the materialized bytes and commands must match this disclosure",
|
|
1740
|
+
"before the verified tree is rebuilt. If you do not approve, restart dsh or",
|
|
1741
|
+
"run `dsh-plugin-guard guard recover` to roll the paused transaction back.",
|
|
1569
1742
|
"",
|
|
1570
1743
|
];
|
|
1571
1744
|
for (const entry of disclosure) {
|
|
@@ -1661,23 +1834,38 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1661
1834
|
const collected = [];
|
|
1662
1835
|
const deltaQueue = [];
|
|
1663
1836
|
const push = (text) => {
|
|
1664
|
-
|
|
1665
|
-
|
|
1837
|
+
const plainText = stripTerminalControlSequences(text);
|
|
1838
|
+
collected.push(plainText);
|
|
1839
|
+
deltaQueue.push(plainText);
|
|
1666
1840
|
};
|
|
1667
1841
|
|
|
1668
1842
|
const workspacePath = join(profileDir, "pnpm-workspace.yaml");
|
|
1669
1843
|
const originalWorkspaceBytes = existsSync(workspacePath) ? readFileSync(workspacePath) : undefined;
|
|
1670
1844
|
let workspaceRestored = false;
|
|
1671
1845
|
let workspaceRestoreError;
|
|
1672
|
-
const restoreOriginalWorkspace = () => {
|
|
1846
|
+
const restoreOriginalWorkspace = ({ preservePnpmWrites = false, persistentWrites } = {}) => {
|
|
1673
1847
|
if (workspaceRestored) return true;
|
|
1674
1848
|
try {
|
|
1849
|
+
// On success the restore is a merge: pnpm's own durable writes to the
|
|
1850
|
+
// workspace (the minimumReleaseAgeExclude auto-exemption for an
|
|
1851
|
+
// explicitly pinned fresh release) survive, everything this transaction
|
|
1852
|
+
// wrote on top of the user's bytes does not. See
|
|
1853
|
+
// mergePersistentWorkspaceWrites for why a plain byte rollback here is
|
|
1854
|
+
// a bug, not hygiene. `persistentWrites` overrides the disk read with a
|
|
1855
|
+
// snapshot captured before approved scripts ran.
|
|
1856
|
+
let nextBytes = originalWorkspaceBytes;
|
|
1857
|
+
if (preservePnpmWrites) {
|
|
1858
|
+
const writes = persistentWrites !== undefined
|
|
1859
|
+
? persistentWrites
|
|
1860
|
+
: extractPersistentWrites(existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : undefined);
|
|
1861
|
+
nextBytes = applyPersistentWrites(writes, originalWorkspaceBytes);
|
|
1862
|
+
}
|
|
1675
1863
|
if (_restoreWorkspace !== undefined) {
|
|
1676
|
-
_restoreWorkspace(workspacePath,
|
|
1677
|
-
} else if (
|
|
1864
|
+
_restoreWorkspace(workspacePath, nextBytes);
|
|
1865
|
+
} else if (nextBytes === undefined) {
|
|
1678
1866
|
rmSync(workspacePath, { force: true });
|
|
1679
1867
|
} else {
|
|
1680
|
-
writeFileSync(workspacePath,
|
|
1868
|
+
writeFileSync(workspacePath, nextBytes);
|
|
1681
1869
|
}
|
|
1682
1870
|
workspaceRestored = true;
|
|
1683
1871
|
workspaceRestoreError = undefined;
|
|
@@ -1887,12 +2075,15 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1887
2075
|
const ignored = parseIgnoredBuilds(log);
|
|
1888
2076
|
|
|
1889
2077
|
if (ignored.length === 0) {
|
|
1890
|
-
if (!restoreOriginalWorkspace()) {
|
|
1891
|
-
return { status: "failed", detail: `could not restore pnpm-workspace.yaml after the script-blocking probe: ${workspaceRestoreError?.message ?? "unknown error"}` };
|
|
1892
|
-
}
|
|
1893
2078
|
if (outcome.exitCode !== 0) {
|
|
2079
|
+
restoreOriginalWorkspace();
|
|
1894
2080
|
return { status: "failed", detail: `pnpm add ${spec} failed (exit code ${outcome.exitCode}). See job output.` };
|
|
1895
2081
|
}
|
|
2082
|
+
// pnpm succeeded: keep its durable workspace writes (the release-age
|
|
2083
|
+
// exemption it may have recorded for this very spec), wipe the rest.
|
|
2084
|
+
if (!restoreOriginalWorkspace({ preservePnpmWrites: true })) {
|
|
2085
|
+
return { status: "failed", detail: `could not restore pnpm-workspace.yaml after the script-blocking probe: ${workspaceRestoreError?.message ?? "unknown error"}` };
|
|
2086
|
+
}
|
|
1896
2087
|
return tryFinalize();
|
|
1897
2088
|
}
|
|
1898
2089
|
|
|
@@ -1947,6 +2138,15 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1947
2138
|
push("[dsh-plugin-mall] temporarily allowing them in the profile's pnpm-workspace.yaml and retrying once.\n");
|
|
1948
2139
|
|
|
1949
2140
|
workspaceRestored = false; // allow writing temporary approved builds
|
|
2141
|
+
// Snapshot pnpm's persistent writes BEFORE the rebuild runs. The add that
|
|
2142
|
+
// produced them executed with every script blocked, so this delta is
|
|
2143
|
+
// provably pnpm's own. Once the approved postinstall scripts execute they
|
|
2144
|
+
// can write anything into this file — a broad release-age exemption would
|
|
2145
|
+
// otherwise be laundered into the user's config as a "pnpm write" by the
|
|
2146
|
+
// success-restore merge. Only this snapshot is merged afterwards.
|
|
2147
|
+
const preRebuildPersistentWrites = extractPersistentWrites(
|
|
2148
|
+
existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : undefined,
|
|
2149
|
+
);
|
|
1950
2150
|
try {
|
|
1951
2151
|
const currentWs = existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : DEFAULT_WORKSPACE_YAML;
|
|
1952
2152
|
const nextWs = enableApprovedBuildSelectors(currentWs, ignored.map((entry) => entry.selector));
|
|
@@ -1960,8 +2160,16 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
|
|
|
1960
2160
|
current = retry;
|
|
1961
2161
|
const retryOutcome = await retry.done;
|
|
1962
2162
|
|
|
1963
|
-
// Restore workspace
|
|
1964
|
-
|
|
2163
|
+
// Restore workspace on EVERY branch (including success) before finalize.
|
|
2164
|
+
// Success restores as a merge of the PRE-REBUILD snapshot only: the
|
|
2165
|
+
// exemption pnpm recorded for the approved install's spec survives, the
|
|
2166
|
+
// temporary approved-build bytes do not, and whatever the approved
|
|
2167
|
+
// scripts wrote to the file during the rebuild does not either. Failure
|
|
2168
|
+
// paths roll the bytes back wholesale.
|
|
2169
|
+
const retrySucceeded = retryOutcome.spawnError === undefined
|
|
2170
|
+
&& !endedByCancel(retryOutcome, cancelRequested)
|
|
2171
|
+
&& retryOutcome.exitCode === 0;
|
|
2172
|
+
if (!restoreOriginalWorkspace(retrySucceeded ? { preservePnpmWrites: true, persistentWrites: preRebuildPersistentWrites } : {})) {
|
|
1965
2173
|
return { status: "failed", detail: `approved scripts finished but pnpm-workspace.yaml could not be restored: ${workspaceRestoreError?.message ?? "unknown error"}` };
|
|
1966
2174
|
}
|
|
1967
2175
|
|
|
@@ -2154,7 +2362,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
|
|
|
2154
2362
|
}
|
|
2155
2363
|
const deltaQueue = [];
|
|
2156
2364
|
const push = (text) => {
|
|
2157
|
-
deltaQueue.push(text);
|
|
2365
|
+
deltaQueue.push(stripTerminalControlSequences(text));
|
|
2158
2366
|
};
|
|
2159
2367
|
let current = undefined;
|
|
2160
2368
|
let cancelRequested = false; // see endedByCancel: exit codes cannot tell us this on Windows
|
|
@@ -2221,7 +2429,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
|
|
|
2221
2429
|
try {
|
|
2222
2430
|
proc = (_spawn ?? spawn)(plan.command, ["remove", packageName, "--reporter=append-only"], {
|
|
2223
2431
|
cwd: profileDir,
|
|
2224
|
-
env: process.env,
|
|
2432
|
+
env: pnpmGuardEnv(process.env),
|
|
2225
2433
|
shell: plan.shell,
|
|
2226
2434
|
stdio: ["ignore", "pipe", "pipe"],
|
|
2227
2435
|
windowsHide: true,
|
|
@@ -2284,7 +2492,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
|
|
|
2284
2492
|
// 退出码 0 不等于卸干净了。落盘校验用的是启动恢复同一套判据:
|
|
2285
2493
|
// profile 整体仍然自洽,且这个包确实从清单和装配层里消失了。任何一条
|
|
2286
2494
|
// 不过就还原——一个「装着但坏」的 profile 比一个没卸掉的插件糟得多。
|
|
2287
|
-
const profileCheck =
|
|
2495
|
+
const profileCheck = validatePendingProfile(profileDir);
|
|
2288
2496
|
const removeCheck = validateRemoveCompletion(profileDir, packageName);
|
|
2289
2497
|
if (!profileCheck.ok || !removeCheck.ok) {
|
|
2290
2498
|
const blockers = [...profileCheck.issues, ...removeCheck.issues]
|
|
@@ -2411,6 +2619,7 @@ function runAllowBuildsFixtures() {
|
|
|
2411
2619
|
if (!ok && error !== undefined) console.log(` 抛错: ${error.message}`);
|
|
2412
2620
|
}
|
|
2413
2621
|
failed += runNeutralizeFixtures();
|
|
2622
|
+
failed += runMergeWritesFixtures();
|
|
2414
2623
|
return failed;
|
|
2415
2624
|
}
|
|
2416
2625
|
|
|
@@ -2509,6 +2718,130 @@ function runNeutralizeFixtures() {
|
|
|
2509
2718
|
return failed;
|
|
2510
2719
|
}
|
|
2511
2720
|
|
|
2721
|
+
// ── mergePersistentWorkspaceWrites:成功还原时留住 pnpm 的持久写入 ─────────
|
|
2722
|
+
//
|
|
2723
|
+
// 真实事故:0.4.14 从市场页更新,pnpm 为显式指定的冷却期版本记了
|
|
2724
|
+
// minimumReleaseAgeExclude 豁免,字节还原把它一起抹掉;此后 24 小时内任何
|
|
2725
|
+
// 安装都在 lockfile 供应链策略验证那步被这个「自己刚装的版本」拦下。
|
|
2726
|
+
// 这组钉的是合并的边界:豁免要活下来,用户的注释/键序/其余键一字不动,
|
|
2727
|
+
// 我们的临时中和(allowBuilds)照旧不留。
|
|
2728
|
+
const MERGE_WRITES_FIXTURES = [
|
|
2729
|
+
{
|
|
2730
|
+
label: "pnpm 往已有豁免条目追加版本 → 豁免更新,注释与键序不动",
|
|
2731
|
+
original: "packages:\n - .\n# 用户注释不许丢\nnodeLinker: hoisted\nminimumReleaseAgeExclude:\n - '@scope/pkg@0.3.4'\n",
|
|
2732
|
+
after: "packages:\n - .\nnodeLinker: hoisted\nminimumReleaseAgeExclude:\n - '@scope/pkg@0.3.4 || 0.4.14'\nallowBuilds: {}\n",
|
|
2733
|
+
check: (d, text) => d.minimumReleaseAgeExclude[0] === "@scope/pkg@0.3.4 || 0.4.14"
|
|
2734
|
+
&& text.includes("# 用户注释不许丢")
|
|
2735
|
+
&& text.indexOf("nodeLinker: hoisted") < text.indexOf("minimumReleaseAgeExclude:")
|
|
2736
|
+
&& !text.includes("allowBuilds"),
|
|
2737
|
+
},
|
|
2738
|
+
{
|
|
2739
|
+
label: "原文件没有豁免键、pnpm 新增整块 → 追加到文件尾",
|
|
2740
|
+
original: "packages:\n - .\nnodeLinker: hoisted\n",
|
|
2741
|
+
after: "nodeLinker: hoisted\npackages:\n - .\nminimumReleaseAgeExclude:\n - 'fresh-pkg@1.0.0'\n",
|
|
2742
|
+
check: (d, text) => d.minimumReleaseAgeExclude[0] === "fresh-pkg@1.0.0"
|
|
2743
|
+
&& text.startsWith("packages:\n - .\n")
|
|
2744
|
+
&& text.includes("nodeLinker: hoisted"),
|
|
2745
|
+
},
|
|
2746
|
+
{
|
|
2747
|
+
label: "pnpm 没写任何持久键 → 字节原样返回(不是重排后的等价文本)",
|
|
2748
|
+
original: "packages:\n - .\nnodeLinker: hoisted\nallowBuilds:\n node-pty: true\n",
|
|
2749
|
+
after: "nodeLinker: hoisted\npackages:\n - .\nallowBuilds: {}\n",
|
|
2750
|
+
check: (d, text) => text === "packages:\n - .\nnodeLinker: hoisted\nallowBuilds:\n node-pty: true\n",
|
|
2751
|
+
},
|
|
2752
|
+
{
|
|
2753
|
+
label: "豁免值没变(只是文本被重排)→ 字节原样返回",
|
|
2754
|
+
original: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@1.0.0'\n",
|
|
2755
|
+
after: "minimumReleaseAgeExclude:\n - 'a@1.0.0'\npackages:\n - .\n",
|
|
2756
|
+
check: (d, text) => text === "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@1.0.0'\n",
|
|
2757
|
+
},
|
|
2758
|
+
{
|
|
2759
|
+
label: "原文件不存在 + pnpm 写了豁免 → 默认模板带上豁免保留下来",
|
|
2760
|
+
original: undefined,
|
|
2761
|
+
after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'p@1.0.0'\n",
|
|
2762
|
+
check: (d) => d.minimumReleaseAgeExclude[0] === "p@1.0.0" && Array.isArray(d.packages) && d.packages[0] === ".",
|
|
2763
|
+
},
|
|
2764
|
+
{
|
|
2765
|
+
label: "原文件不存在 + pnpm 没写 → 返回 undefined(维持删除语义)",
|
|
2766
|
+
original: undefined,
|
|
2767
|
+
after: "packages:\n - .\nnodeLinker: hoisted\n",
|
|
2768
|
+
check: (d, text) => text === undefined,
|
|
2769
|
+
},
|
|
2770
|
+
{
|
|
2771
|
+
label: "磁盘内容解析不了 → 退回纯字节还原",
|
|
2772
|
+
original: "packages:\n - .\n",
|
|
2773
|
+
after: "not: [valid: yaml\n - broken",
|
|
2774
|
+
check: (d, text) => text === "packages:\n - .\n",
|
|
2775
|
+
},
|
|
2776
|
+
{
|
|
2777
|
+
label: "磁盘上没有 workspace 文件 → 退回纯字节还原",
|
|
2778
|
+
original: "packages:\n - .\n",
|
|
2779
|
+
after: undefined,
|
|
2780
|
+
check: (d, text) => text === "packages:\n - .\n",
|
|
2781
|
+
},
|
|
2782
|
+
{
|
|
2783
|
+
label: "原键是单引号写法 → 识别并替换,不追加重复键",
|
|
2784
|
+
original: "packages:\n - .\n'minimumReleaseAgeExclude':\n - 'a@1.0.0'\n",
|
|
2785
|
+
after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@1.0.0 || 9.9.9'\n",
|
|
2786
|
+
check: (d, text) => d.minimumReleaseAgeExclude[0] === "a@1.0.0 || 9.9.9"
|
|
2787
|
+
&& text.split("\n").filter((l) => /['\"]?minimumReleaseAgeExclude['\"]?\s*:/.test(l)).length === 1
|
|
2788
|
+
&& text.includes("'minimumReleaseAgeExclude':"),
|
|
2789
|
+
},
|
|
2790
|
+
{
|
|
2791
|
+
label: "原键是双引号写法 → 识别并替换,不追加重复键",
|
|
2792
|
+
original: "packages:\n - .\n\"minimumReleaseAgeExclude\":\n - 'a@1.0.0'\n",
|
|
2793
|
+
after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@2.0.0'\n",
|
|
2794
|
+
check: (d, text) => d.minimumReleaseAgeExclude[0] === "a@2.0.0"
|
|
2795
|
+
&& text.split("\n").filter((l) => /['\"]?minimumReleaseAgeExclude['\"]?\s*:/.test(l)).length === 1,
|
|
2796
|
+
},
|
|
2797
|
+
{
|
|
2798
|
+
label: "原文件本就有重复键(已损坏)→ 拒绝合并并抛错,绝不静默丢豁免",
|
|
2799
|
+
original: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@1.0.0'\nminimumReleaseAgeExclude:\n - 'b@1.0.0'\n",
|
|
2800
|
+
after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@2.0.0'\n",
|
|
2801
|
+
expectThrow: /cannot merge the release-age exemption/,
|
|
2802
|
+
check: () => false,
|
|
2803
|
+
},
|
|
2804
|
+
{
|
|
2805
|
+
label: "豁免键用 alias 引用别处(复杂写法)→ 展开替换,语义等价、锚点不动",
|
|
2806
|
+
original: "exemptions: &ex\n - 'a@1.0.0'\npackages:\n - .\nminimumReleaseAgeExclude: *ex\n",
|
|
2807
|
+
after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@2.0.0'\n",
|
|
2808
|
+
check: (d, text) => JSON.stringify(d.minimumReleaseAgeExclude) === JSON.stringify(["a@2.0.0"])
|
|
2809
|
+
&& text.includes("exemptions: &ex"),
|
|
2810
|
+
},
|
|
2811
|
+
{
|
|
2812
|
+
label: "原文件 alias 指向不存在的锚点(解析失败)→ 抛错而非静默丢豁免",
|
|
2813
|
+
original: "packages:\n - .\nminimumReleaseAgeExclude: *missing\n",
|
|
2814
|
+
after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@2.0.0'\n",
|
|
2815
|
+
expectThrow: /does not parse/,
|
|
2816
|
+
check: () => false,
|
|
2817
|
+
},
|
|
2818
|
+
];
|
|
2819
|
+
|
|
2820
|
+
function runMergeWritesFixtures() {
|
|
2821
|
+
let failed = 0;
|
|
2822
|
+
for (const fx of MERGE_WRITES_FIXTURES) {
|
|
2823
|
+
let ok = false;
|
|
2824
|
+
let out;
|
|
2825
|
+
try {
|
|
2826
|
+
out = mergePersistentWorkspaceWrites(fx.after, fx.original);
|
|
2827
|
+
if (fx.expectThrow !== undefined) {
|
|
2828
|
+
ok = false; // 该抛的没抛
|
|
2829
|
+
} else if (out === undefined || typeof out === "string") {
|
|
2830
|
+
// 产出必须可解析(undefined 除外,那是删除语义)。
|
|
2831
|
+
if (typeof out === "string") load(out);
|
|
2832
|
+
ok = fx.check(out === undefined ? undefined : load(out), out) === true;
|
|
2833
|
+
}
|
|
2834
|
+
} catch (error) {
|
|
2835
|
+
ok = fx.expectThrow !== undefined && fx.expectThrow.test(error?.message ?? "");
|
|
2836
|
+
if (!ok) console.log(` 抛错: ${error?.message}`);
|
|
2837
|
+
}
|
|
2838
|
+
if (!ok) failed++;
|
|
2839
|
+
console.log(` ${ok ? "PASS" : "FAIL"} merge-writes: ${fx.label}`);
|
|
2840
|
+
if (!ok && out !== undefined) console.log(` 产出:\n${String(out).split("\n").map((l) => ` | ${l}`).join("\n")}`);
|
|
2841
|
+
}
|
|
2842
|
+
return failed;
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2512
2845
|
// ── transaction fixtures (deterministic, offline) ───────────────────────────
|
|
2513
2846
|
//
|
|
2514
2847
|
// The findings these pin:
|
|
@@ -2703,7 +3036,10 @@ async function runTransactionFixtures() {
|
|
|
2703
3036
|
try {
|
|
2704
3037
|
materializeFakePackage(profileDir, "some-plugin", "1.0.0");
|
|
2705
3038
|
materializeFakePackage(profileDir, "node-pty", "1.0.0", { install: "node install.js" });
|
|
2706
|
-
|
|
3039
|
+
// pnpm 11 on Windows may colour stderr even when it is captured through a
|
|
3040
|
+
// pipe. In particular, the reset code lands directly after the selector.
|
|
3041
|
+
const colouredIgnoredBuilds = "Packages are cloned\n\u001b[31mIgnored build scripts: node-pty@1.0.0\u001b[39m\nDone\n";
|
|
3042
|
+
const { spawnFn, calls } = scriptedSpawn([{ code: 0, out: colouredIgnoredBuilds }]);
|
|
2707
3043
|
const producer = runInstall({
|
|
2708
3044
|
profile: "p",
|
|
2709
3045
|
spec: "some-plugin",
|
|
@@ -2725,7 +3061,7 @@ async function runTransactionFixtures() {
|
|
|
2725
3061
|
const output = producer.readOutput();
|
|
2726
3062
|
const markerBefore = pendingMarkerPath(profileDir);
|
|
2727
3063
|
check(
|
|
2728
|
-
"退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
|
|
3064
|
+
"退出码 0 + 彩色 Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
|
|
2729
3065
|
outcome.status === "failed"
|
|
2730
3066
|
&& Array.isArray(outcome.needsApproval)
|
|
2731
3067
|
&& outcome.needsApproval.some((entry) => entry.name === "node-pty")
|
|
@@ -2740,7 +3076,11 @@ async function runTransactionFixtures() {
|
|
|
2740
3076
|
&& entry.weeklyDownloads === 123)
|
|
2741
3077
|
&& calls.length === 1
|
|
2742
3078
|
&& existsSync(markerBefore)
|
|
2743
|
-
&& /
|
|
3079
|
+
&& /candidate is staged/.test(outcome.detail ?? "")
|
|
3080
|
+
&& !/profile was restored/.test(outcome.detail ?? "")
|
|
3081
|
+
&& /paused for build-script approval/.test(output)
|
|
3082
|
+
&& /Ignored build scripts: node-pty@1\.0\.0/.test(output)
|
|
3083
|
+
&& !output.includes("\u001b["),
|
|
2744
3084
|
`status=${outcome.status} calls=${calls.length} marker=${existsSync(markerBefore)}`,
|
|
2745
3085
|
);
|
|
2746
3086
|
check(
|
|
@@ -2906,6 +3246,203 @@ async function runTransactionFixtures() {
|
|
|
2906
3246
|
}
|
|
2907
3247
|
}
|
|
2908
3248
|
|
|
3249
|
+
// 1b2. 成功安装 + pnpm 为显式指定的冷却期版本记了豁免 → 豁免必须活过
|
|
3250
|
+
// 成功路径的 workspace 还原。0.4.14 事故的钉子:字节还原把 pnpm 的这条
|
|
3251
|
+
// 持久写入一起抹掉,此后 24 小时内任何安装都在 lockfile 供应链策略验证
|
|
3252
|
+
// 那步被「自己刚装的版本」拦死。
|
|
3253
|
+
{
|
|
3254
|
+
const { profileDir, cleanup } = makeTempProfile("release-age-exemption");
|
|
3255
|
+
try {
|
|
3256
|
+
const initialWs = "packages:\n - .\n# 用户注释不许丢\nnodeLinker: hoisted\nminimumReleaseAgeExclude:\n - '@1e0zj/pkg@0.3.4'\n";
|
|
3257
|
+
writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
|
|
3258
|
+
materializeFakePackage(profileDir, "some-plugin", "1.0.0");
|
|
3259
|
+
const { spawnFn } = scriptedSpawn([
|
|
3260
|
+
{
|
|
3261
|
+
code: 0,
|
|
3262
|
+
out: "Done\n",
|
|
3263
|
+
beforeExit: () => {
|
|
3264
|
+
// 模拟 pnpm 11 的行为:显式装冷却期内的版本 → 放行并往 workspace
|
|
3265
|
+
// 的 minimumReleaseAgeExclude 追加记录(pnpm 会并入已有条目)。
|
|
3266
|
+
const wsPath = join(profileDir, "pnpm-workspace.yaml");
|
|
3267
|
+
const parsed = load(readFileSync(wsPath, "utf8"));
|
|
3268
|
+
parsed.minimumReleaseAgeExclude = ["@1e0zj/pkg@0.3.4 || 0.4.14"];
|
|
3269
|
+
writeFileSync(wsPath, dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false }));
|
|
3270
|
+
},
|
|
3271
|
+
},
|
|
3272
|
+
]);
|
|
3273
|
+
const producer = runInstall({
|
|
3274
|
+
profile: "p",
|
|
3275
|
+
spec: "some-plugin",
|
|
3276
|
+
preflight: preflightStub("some-plugin"),
|
|
3277
|
+
_profileDir: profileDir,
|
|
3278
|
+
_spawn: spawnFn,
|
|
3279
|
+
_describe: describeStub,
|
|
3280
|
+
});
|
|
3281
|
+
const outcome = await producer.done;
|
|
3282
|
+
const finalWs = readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8");
|
|
3283
|
+
const finalDoc = load(finalWs);
|
|
3284
|
+
check(
|
|
3285
|
+
"退出码 0 + pnpm 记了冷却豁免 → 豁免活过还原,注释与用户键保留、中和态不残留",
|
|
3286
|
+
outcome.status === "completed"
|
|
3287
|
+
&& finalDoc.minimumReleaseAgeExclude[0] === "@1e0zj/pkg@0.3.4 || 0.4.14"
|
|
3288
|
+
&& finalWs.includes("# 用户注释不许丢")
|
|
3289
|
+
&& finalWs.includes("nodeLinker: hoisted")
|
|
3290
|
+
&& finalDoc.allowBuilds === undefined,
|
|
3291
|
+
`status=${outcome.status} finalWs=${JSON.stringify(finalWs)}`,
|
|
3292
|
+
);
|
|
3293
|
+
} finally {
|
|
3294
|
+
cleanup();
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
// 1b3. 反向边界:pnpm 失败(哪怕豁免已落盘)→ 字节还原,豁免不保留——
|
|
3299
|
+
// 没装成就不欠供应链策略的债。
|
|
3300
|
+
{
|
|
3301
|
+
const { profileDir, cleanup } = makeTempProfile("release-age-exemption-failed");
|
|
3302
|
+
try {
|
|
3303
|
+
const initialWs = "packages:\n - .\nnodeLinker: hoisted\n";
|
|
3304
|
+
writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
|
|
3305
|
+
materializeFakePackage(profileDir, "some-plugin", "1.0.0");
|
|
3306
|
+
const { spawnFn } = scriptedSpawn([
|
|
3307
|
+
{
|
|
3308
|
+
code: 1,
|
|
3309
|
+
out: "ERR_PNPM_NO_MATCHING_VERSION\n",
|
|
3310
|
+
beforeExit: () => {
|
|
3311
|
+
const wsPath = join(profileDir, "pnpm-workspace.yaml");
|
|
3312
|
+
const parsed = load(readFileSync(wsPath, "utf8"));
|
|
3313
|
+
parsed.minimumReleaseAgeExclude = ["some-plugin@9.9.9"];
|
|
3314
|
+
writeFileSync(wsPath, dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false }));
|
|
3315
|
+
},
|
|
3316
|
+
},
|
|
3317
|
+
]);
|
|
3318
|
+
const producer = runInstall({
|
|
3319
|
+
profile: "p",
|
|
3320
|
+
spec: "some-plugin",
|
|
3321
|
+
preflight: preflightStub("some-plugin"),
|
|
3322
|
+
_profileDir: profileDir,
|
|
3323
|
+
_spawn: spawnFn,
|
|
3324
|
+
_describe: describeStub,
|
|
3325
|
+
});
|
|
3326
|
+
const outcome = await producer.done;
|
|
3327
|
+
const finalWs = readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8");
|
|
3328
|
+
check(
|
|
3329
|
+
"退出码非 0(豁免已落盘)→ 字节还原,豁免不保留",
|
|
3330
|
+
outcome.status === "failed" && finalWs === initialWs,
|
|
3331
|
+
`status=${outcome.status} finalWs=${JSON.stringify(finalWs)}`,
|
|
3332
|
+
);
|
|
3333
|
+
} finally {
|
|
3334
|
+
cleanup();
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
// 1b4. 批准执行的构建脚本篡改豁免 → 只有 rebuild 前捕获的 pnpm 豁免被
|
|
3339
|
+
// 合并。已批准的 postinstall 在 rebuild 里跑过之后,磁盘上的 workspace
|
|
3340
|
+
// 谁都能写:宽泛豁免若被当「pnpm 持久写入」合并,等于借审批之手把
|
|
3341
|
+
// 供应链冷却期防线拆了。
|
|
3342
|
+
{
|
|
3343
|
+
const { profileDir, cleanup } = makeTempProfile("exemption-laundering");
|
|
3344
|
+
try {
|
|
3345
|
+
const initialWs = "packages:\n - .\nnodeLinker: hoisted\n";
|
|
3346
|
+
writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
|
|
3347
|
+
materializeFakePackage(profileDir, "some-plugin", "1.0.0");
|
|
3348
|
+
materializeFakePackage(profileDir, "node-pty", "1.0.0", { install: "node install.js" });
|
|
3349
|
+
const approvedProof = computeMaterializedProof(profileDir, "some-plugin", [{ name: "node-pty", version: "1.0.0", selector: "node-pty@1.0.0" }]);
|
|
3350
|
+
const wsPath = join(profileDir, "pnpm-workspace.yaml");
|
|
3351
|
+
const writeWs = (exemptions) => {
|
|
3352
|
+
const parsed = load(readFileSync(wsPath, "utf8"));
|
|
3353
|
+
parsed.minimumReleaseAgeExclude = exemptions;
|
|
3354
|
+
writeFileSync(wsPath, dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false }));
|
|
3355
|
+
};
|
|
3356
|
+
const { spawnFn } = scriptedSpawn([
|
|
3357
|
+
{
|
|
3358
|
+
// 第一次 add:脚本全禁,pnpm 为显式指定的冷却期版本记豁免。
|
|
3359
|
+
code: 0,
|
|
3360
|
+
out: "Ignored build scripts: node-pty@1.0.0\n",
|
|
3361
|
+
beforeExit: () => writeWs(["some-plugin@1.0.0"]),
|
|
3362
|
+
},
|
|
3363
|
+
{
|
|
3364
|
+
// rebuild:node-pty 的 postinstall(已获批准)顺手篡改豁免。
|
|
3365
|
+
code: 0,
|
|
3366
|
+
out: "Done\n",
|
|
3367
|
+
beforeExit: () => writeWs(["some-plugin@1.0.0", "evil-pkg@*"]),
|
|
3368
|
+
},
|
|
3369
|
+
]);
|
|
3370
|
+
const producer = runInstall({
|
|
3371
|
+
profile: "p",
|
|
3372
|
+
spec: "some-plugin",
|
|
3373
|
+
allowBuildScripts: ["node-pty"],
|
|
3374
|
+
approvedProof,
|
|
3375
|
+
preflight: preflightStub("some-plugin"),
|
|
3376
|
+
_profileDir: profileDir,
|
|
3377
|
+
_spawn: spawnFn,
|
|
3378
|
+
_describe: describeStub,
|
|
3379
|
+
});
|
|
3380
|
+
const outcome = await producer.done;
|
|
3381
|
+
const finalWs = readFileSync(wsPath, "utf8");
|
|
3382
|
+
const finalDoc = load(finalWs);
|
|
3383
|
+
check(
|
|
3384
|
+
"批准脚本在 rebuild 里篡改豁免 → 只有 rebuild 前的 pnpm 豁免被合并,篡改不进用户配置",
|
|
3385
|
+
outcome.status === "completed"
|
|
3386
|
+
&& JSON.stringify(finalDoc.minimumReleaseAgeExclude) === JSON.stringify(["some-plugin@1.0.0"])
|
|
3387
|
+
&& finalWs.includes("nodeLinker: hoisted")
|
|
3388
|
+
&& finalDoc.allowBuilds === undefined,
|
|
3389
|
+
`status=${outcome.status} exemptions=${JSON.stringify(finalDoc.minimumReleaseAgeExclude)}`,
|
|
3390
|
+
);
|
|
3391
|
+
} finally {
|
|
3392
|
+
cleanup();
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
// 1b5. 端到端:pnpm 成功记了豁免,但用户的 workspace 用了合并器不认识的
|
|
3397
|
+
// 写法(flow mapping——load 认得、顶格键定位不认得)→ 合并失败必须让
|
|
3398
|
+
// 安装 failed 并回滚 profile,而不是报成功、静默丢豁免把雷留给下一次安装。
|
|
3399
|
+
{
|
|
3400
|
+
const { profileDir, cleanup } = makeTempProfile("exemption-merge-unmergeable");
|
|
3401
|
+
try {
|
|
3402
|
+
const initialWs = "{packages: ['.'], nodeLinker: hoisted, minimumReleaseAgeExclude: [a@1.0.0]}\n";
|
|
3403
|
+
writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
|
|
3404
|
+
materializeFakePackage(profileDir, "some-plugin", "1.0.0");
|
|
3405
|
+
const manifestBefore = readFileSync(join(profileDir, "package.json"), "utf8");
|
|
3406
|
+
const wsPath = join(profileDir, "pnpm-workspace.yaml");
|
|
3407
|
+
const { spawnFn } = scriptedSpawn([
|
|
3408
|
+
{
|
|
3409
|
+
code: 0,
|
|
3410
|
+
out: "Done\n",
|
|
3411
|
+
beforeExit: () => {
|
|
3412
|
+
// 模拟 pnpm:显式冷却期版本放行 + 记豁免 + 把依赖写进 manifest。
|
|
3413
|
+
// pnpm 读写的是被 neutralize 重排过的 block 风格文件。
|
|
3414
|
+
const parsed = load(readFileSync(wsPath, "utf8"));
|
|
3415
|
+
parsed.minimumReleaseAgeExclude = ["a@2.0.0"];
|
|
3416
|
+
writeFileSync(wsPath, dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false }));
|
|
3417
|
+
const pkg = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
|
|
3418
|
+
pkg.dependencies = { ...(pkg.dependencies ?? {}), "some-plugin": "1.0.0" };
|
|
3419
|
+
writeFileSync(join(profileDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
|
|
3420
|
+
},
|
|
3421
|
+
},
|
|
3422
|
+
]);
|
|
3423
|
+
const outcome = await runInstall({
|
|
3424
|
+
profile: "p",
|
|
3425
|
+
spec: "some-plugin",
|
|
3426
|
+
preflight: preflightStub("some-plugin"),
|
|
3427
|
+
_profileDir: profileDir,
|
|
3428
|
+
_spawn: spawnFn,
|
|
3429
|
+
_describe: describeStub,
|
|
3430
|
+
}).done;
|
|
3431
|
+
const finalWs = readFileSync(wsPath, "utf8");
|
|
3432
|
+
check(
|
|
3433
|
+
"pnpm 记豁免但 workspace 写法无法安全合并 → failed + 明说原因 + profile 原样 + marker 清除",
|
|
3434
|
+
outcome.status === "failed"
|
|
3435
|
+
&& /cannot merge the release-age exemption/.test(outcome.detail ?? "")
|
|
3436
|
+
&& finalWs === initialWs
|
|
3437
|
+
&& readFileSync(join(profileDir, "package.json"), "utf8") === manifestBefore
|
|
3438
|
+
&& !existsSync(pendingMarkerPath(profileDir)),
|
|
3439
|
+
`status=${outcome.status} detail=${JSON.stringify(outcome.detail)} finalWs=${JSON.stringify(finalWs)}`,
|
|
3440
|
+
);
|
|
3441
|
+
} finally {
|
|
3442
|
+
cleanup();
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
|
|
2909
3446
|
// 1c. 攻击防御:批准后修改 postinstall / 文件内容 → retry 时 proof 校验失败,绝不重试并回滚
|
|
2910
3447
|
{
|
|
2911
3448
|
const { profileDir, cleanup } = makeTempProfile("attack-tampered-proof");
|
|
@@ -3413,7 +3950,7 @@ async function runTransactionFixtures() {
|
|
|
3413
3950
|
let snapshotsWhileRunning = [];
|
|
3414
3951
|
const { spawnFn } = scriptedSpawn([{
|
|
3415
3952
|
code: 0,
|
|
3416
|
-
out: "
|
|
3953
|
+
out: "\u001b[32mDone\u001b[39m\n",
|
|
3417
3954
|
// pnpm 真正卸掉:清单与目录都拿走,落盘校验才会通过。
|
|
3418
3955
|
beforeExit: () => {
|
|
3419
3956
|
snapshotsWhileRunning = listSnapshots();
|
|
@@ -3423,14 +3960,17 @@ async function runTransactionFixtures() {
|
|
|
3423
3960
|
rmSync(join(profileDir, "node_modules", "pkg-f"), { recursive: true, force: true });
|
|
3424
3961
|
},
|
|
3425
3962
|
}]);
|
|
3426
|
-
const
|
|
3963
|
+
const producer = runRemove({ profile: "p", packageName: "pkg-f", _profileDir: profileDir, _spawn: spawnFn });
|
|
3964
|
+
const outcome = await producer.done;
|
|
3965
|
+
const output = producer.readOutput();
|
|
3427
3966
|
const snapshotsLeft = listSnapshots();
|
|
3428
3967
|
check(
|
|
3429
|
-
"卸载成功 → marker 与 snapshot 都被提交清理(且快照确实创建过)",
|
|
3968
|
+
"卸载成功 → 日志去色,marker 与 snapshot 都被提交清理(且快照确实创建过)",
|
|
3430
3969
|
outcome.status === "completed"
|
|
3431
3970
|
&& snapshotsWhileRunning.length === 1
|
|
3432
3971
|
&& !existsSync(pendingMarkerPath(profileDir))
|
|
3433
|
-
&& snapshotsLeft.length === 0
|
|
3972
|
+
&& snapshotsLeft.length === 0
|
|
3973
|
+
&& output === "Done\n",
|
|
3434
3974
|
`status=${outcome.status} 运行中快照=${snapshotsWhileRunning.join(",")} marker=${existsSync(pendingMarkerPath(profileDir))} 残留快照=${snapshotsLeft.join(",")} detail=${JSON.stringify(outcome.detail)}`,
|
|
3435
3975
|
);
|
|
3436
3976
|
} finally {
|
|
@@ -3868,7 +4408,9 @@ async function runTransactionFixtures() {
|
|
|
3868
4408
|
*/
|
|
3869
4409
|
function runToggleFixtures() {
|
|
3870
4410
|
let failed = 0;
|
|
4411
|
+
let total = 0;
|
|
3871
4412
|
const check = (label, ok, extra = "") => {
|
|
4413
|
+
total += 1;
|
|
3872
4414
|
if (!ok) failed++;
|
|
3873
4415
|
console.log(` ${ok ? "PASS" : "FAIL"} ${label}${ok ? "" : ` ${extra}`}`);
|
|
3874
4416
|
};
|
|
@@ -4007,16 +4549,20 @@ function runToggleFixtures() {
|
|
|
4007
4549
|
}
|
|
4008
4550
|
})());
|
|
4009
4551
|
|
|
4010
|
-
return failed;
|
|
4552
|
+
return { failed, total };
|
|
4011
4553
|
}
|
|
4012
4554
|
|
|
4013
4555
|
if (process.argv[1]?.endsWith("installer.js") && process.argv.includes("--self-test")) {
|
|
4014
4556
|
console.log("启用/停用 patch 层 fixtures:");
|
|
4015
|
-
const
|
|
4557
|
+
const toggle = runToggleFixtures();
|
|
4016
4558
|
console.log();
|
|
4017
4559
|
console.log("allowBuilds 合并 fixtures:");
|
|
4018
|
-
const failed = runAllowBuildsFixtures() +
|
|
4019
|
-
|
|
4560
|
+
const failed = runAllowBuildsFixtures() + toggle.failed;
|
|
4561
|
+
// 分母要数全跑过的用例(toggle/neutralize/merge-writes 也在这批里),
|
|
4562
|
+
// 只数 ALLOW_BUILDS_FIXTURES 会把后来加的组漏在「10/10」的假象外。
|
|
4563
|
+
const fixtureTotal = ALLOW_BUILDS_FIXTURES.length + NEUTRALIZE_FIXTURES.length
|
|
4564
|
+
+ MERGE_WRITES_FIXTURES.length + toggle.total;
|
|
4565
|
+
console.log(`${fixtureTotal - failed}/${fixtureTotal} passed`);
|
|
4020
4566
|
// 实装 pnpm add 的参数/环境(纯函数):peer 自动安装必须关闭,否则
|
|
4021
4567
|
// marketplace 安装会把 @deepseek-ai 宿主依赖栈拉进 profile;构建脚本必须
|
|
4022
4568
|
// 严格,否则 pnpm 退出码 0 却跳过构建脚本,批准闸形同虚设。
|