@1e0zj/dsh-plugin-mall 0.3.2 → 0.3.4
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 +76 -13
- package/cordis.patch.yml +13 -2
- package/package.json +1 -1
- package/src/cli.js +48 -0
- package/src/client.js +12 -4
- package/src/guard.js +470 -24
- package/src/index.js +304 -31
- package/src/installer.js +133 -20
package/src/guard.js
CHANGED
|
@@ -1008,6 +1008,32 @@ function addedDependencyNames(pending, originalDependencies = pending?.dependenc
|
|
|
1008
1008
|
// that used to live there. Restoring manifest + lockfile is then only half the
|
|
1009
1009
|
// job: the tree must be rebuilt from the restored lockfile, or the profile is
|
|
1010
1010
|
// left declaring a dependency nothing provides.
|
|
1011
|
+
//
|
|
1012
|
+
// WHEN the reconcile short-circuits (the reason the per-package add fallback
|
|
1013
|
+
// below exists at all). pnpm decides "up to date" by comparing its virtual
|
|
1014
|
+
// store bookkeeping (node_modules/.pnpm/lock.yaml) against the profile's
|
|
1015
|
+
// pnpm-lock.yaml — and reconcileNodeModules deletes node_modules/<name>
|
|
1016
|
+
// WITHOUT touching either. So the outcome hinges on how far the failed install
|
|
1017
|
+
// got before the rollback:
|
|
1018
|
+
//
|
|
1019
|
+
// pnpm add SUCCEEDED (e.g. it installed the new version and only then
|
|
1020
|
+
// stopped at the build-script approval gate) — .pnpm/lock.yaml already
|
|
1021
|
+
// records the new version, the restored pnpm-lock.yaml records the old
|
|
1022
|
+
// one, they disagree, pnpm does the work and relinks the old copy. The
|
|
1023
|
+
// fallback never runs.
|
|
1024
|
+
//
|
|
1025
|
+
// pnpm add FAILED EARLY (or never ran) — .pnpm/lock.yaml still matches the
|
|
1026
|
+
// restored pnpm-lock.yaml, so pnpm answers `install --frozen` with exit 0
|
|
1027
|
+
// and does nothing while the package it was asked about is gone. Only the
|
|
1028
|
+
// per-package add relinks it.
|
|
1029
|
+
//
|
|
1030
|
+
// This is why an approval-pause rollback is the WRONG scenario to validate the
|
|
1031
|
+
// fallback with: it is precisely the branch that never reaches it (confirmed on
|
|
1032
|
+
// a real profile — reconcile exit 0, package restored, fallback untouched). To
|
|
1033
|
+
// exercise it, reproduce the second row: install the package, leave
|
|
1034
|
+
// .pnpm/lock.yaml in agreement with pnpm-lock.yaml, mark a pending UPDATE
|
|
1035
|
+
// transaction, and roll back without running any pnpm in between. Both the `^`
|
|
1036
|
+
// range and the github: pinning paths were verified that way.
|
|
1011
1037
|
|
|
1012
1038
|
/**
|
|
1013
1039
|
* Env for any pnpm the guard (or its callers) spawns: peer auto-install stays
|
|
@@ -1062,26 +1088,86 @@ function fallbackAddArgs(target) {
|
|
|
1062
1088
|
}
|
|
1063
1089
|
|
|
1064
1090
|
/**
|
|
1065
|
-
* The
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
*
|
|
1091
|
+
* The lockfile's pinned resolution for one direct dependency, or undefined. In
|
|
1092
|
+
* a rollback the lockfile has just been restored from the snapshot, so this IS
|
|
1093
|
+
* the exact thing the rollback is trying to get back — not a guess. For a
|
|
1094
|
+
* semver dependency it is the resolved version (`0.13.1`); for a git-hosted one
|
|
1095
|
+
* it is the resolved tarball URL carrying the commit sha, which is precisely
|
|
1096
|
+
* what makes a moving `github:` spec pinnable.
|
|
1097
|
+
*
|
|
1098
|
+
* Only the profile's own importer (`.`) is read — a dsh profile is a single
|
|
1099
|
+
* package, never a workspace. A version carrying pnpm's peer suffix
|
|
1100
|
+
* (`1.2.3(react@18.0.0)`, emitted when a peer is resolved from inside the
|
|
1101
|
+
* project) is not a legal add spec; it is returned as-is and the caller's
|
|
1102
|
+
* assertSafeSpec rejects it on the parens, so the fallback fails closed rather
|
|
1103
|
+
* than adding something wrong. Profiles install with auto-install-peers off and
|
|
1104
|
+
* take their peers from the host, so this has not been observed in practice.
|
|
1072
1105
|
*/
|
|
1073
|
-
function
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1106
|
+
function pinnedLockfileVersion(profileDir, name) {
|
|
1107
|
+
try {
|
|
1108
|
+
const doc = load(readFileSync(join(profileDir, "pnpm-lock.yaml"), "utf8"));
|
|
1109
|
+
const version = doc?.importers?.["."]?.dependencies?.[name]?.version;
|
|
1110
|
+
return typeof version === "string" && version.length > 0 ? version : undefined;
|
|
1111
|
+
} catch {
|
|
1112
|
+
return undefined;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** The target when it survives the spec blacklist, undefined when it does not. */
|
|
1117
|
+
function safeAddTarget(target) {
|
|
1079
1118
|
try {
|
|
1080
1119
|
assertSafeSpec(target);
|
|
1120
|
+
return target;
|
|
1081
1121
|
} catch {
|
|
1082
1122
|
return undefined;
|
|
1083
1123
|
}
|
|
1084
|
-
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* The argv target for a fallback `pnpm add` of one restored dependency, or
|
|
1128
|
+
* undefined when that spec cannot be added offline and safely.
|
|
1129
|
+
*
|
|
1130
|
+
* - `file:`/`link:` paths add by the spec itself: a local path is not a moving
|
|
1131
|
+
* target, it names one fixed thing.
|
|
1132
|
+
* - `github:owner/repo` MUST be pinned to the lockfile's resolution and is
|
|
1133
|
+
* never added by the spec itself. A bare github spec means "whatever HEAD is
|
|
1134
|
+
* now", but a rollback needs "what I had" — and the freshest thing in pnpm's
|
|
1135
|
+
* resolution cache and store is exactly the commit the failed update just
|
|
1136
|
+
* fetched, i.e. the version being rolled back FROM. Adding the bare spec
|
|
1137
|
+
* would relink that commit, and candidateRestoredCompatible cannot catch it:
|
|
1138
|
+
* a non-semver spec has no range to check, so a present package passes on
|
|
1139
|
+
* name alone. The rollback would then clear the marker and delete the
|
|
1140
|
+
* snapshot, leaving node_modules on the rejected version, the lockfile
|
|
1141
|
+
* claiming the old one, and no recovery evidence at all — a fail-OPEN worse
|
|
1142
|
+
* than not trying. The pinned tarball URL carries the commit sha, so pnpm
|
|
1143
|
+
* either relinks that exact commit from the store or exits nonzero.
|
|
1144
|
+
* - Semver ranges: `name@range` only when the range carries no shell
|
|
1145
|
+
* metacharacters — and `^` (the near-universal pnpm save prefix!) is one
|
|
1146
|
+
* (cmd's escape character: it mangles the argv through the shell-wrapped
|
|
1147
|
+
* spawn, so assertSafeSpec refuses it). For those — including multi-clause
|
|
1148
|
+
* ranges like `^1.0.0 || ^2.0.0` — the target becomes
|
|
1149
|
+
* `name@<lockfile pinned version>`: the lockfile is the authority this
|
|
1150
|
+
* rollback just restored, so its pinned version is by definition a legal
|
|
1151
|
+
* restore target for any range.
|
|
1152
|
+
*
|
|
1153
|
+
* Everything that cannot be pinned stays fail-closed (marker + snapshot kept
|
|
1154
|
+
* for `guard recover` or manual repair), which is the whole point: an unpinned
|
|
1155
|
+
* guess is not a recovery.
|
|
1156
|
+
*/
|
|
1157
|
+
function fallbackAddTarget(name, spec, profileDir) {
|
|
1158
|
+
const range = String(spec ?? "");
|
|
1159
|
+
if (range.length === 0) return undefined;
|
|
1160
|
+
if (/^(?:file:|link:)/i.test(range)) return safeAddTarget(range);
|
|
1161
|
+
const isGit = /^github:/i.test(range);
|
|
1162
|
+
if (!isGit) {
|
|
1163
|
+
if (validRange(range) === null) return undefined;
|
|
1164
|
+
// A shell-safe range splices directly; `^` and friends fall through.
|
|
1165
|
+
const direct = safeAddTarget(`${name}@${range}`);
|
|
1166
|
+
if (direct !== undefined) return direct;
|
|
1167
|
+
}
|
|
1168
|
+
const pinned = pinnedLockfileVersion(profileDir, name);
|
|
1169
|
+
if (pinned === undefined) return undefined;
|
|
1170
|
+
return safeAddTarget(`${name}@${pinned}`);
|
|
1085
1171
|
}
|
|
1086
1172
|
|
|
1087
1173
|
/**
|
|
@@ -1263,6 +1349,14 @@ export function rollbackPendingSnapshot(profileDir) {
|
|
|
1263
1349
|
// newly added candidate (absent from the restored manifest) needs nothing
|
|
1264
1350
|
// reinstalled: removing its node_modules entry above is sufficient.
|
|
1265
1351
|
// Without a lockfile a frozen install can never succeed, so it is skipped.
|
|
1352
|
+
// What the rebuild actually did, reported back to the caller. A successful
|
|
1353
|
+
// rollback used to be completely silent: reconcile and the per-package add
|
|
1354
|
+
// leave no trace, so after the fact nobody can tell which one relinked the
|
|
1355
|
+
// package — or whether either ran at all. That matters here more than usual,
|
|
1356
|
+
// because the add fallback exists precisely for the case where reconcile
|
|
1357
|
+
// silently no-ops, and "the profile looks right afterwards" does not
|
|
1358
|
+
// distinguish the two.
|
|
1359
|
+
const rebuild = { reconcile: undefined, fallback: [] };
|
|
1266
1360
|
let attempt;
|
|
1267
1361
|
if (isRemove) {
|
|
1268
1362
|
// A failed/no-op remove commonly leaves the original direct package fully
|
|
@@ -1277,6 +1371,7 @@ export function rollbackPendingSnapshot(profileDir) {
|
|
|
1277
1371
|
} else if (wasUpdate && pending.files?.["pnpm-lock.yaml"]?.present === true) {
|
|
1278
1372
|
attempt = runReconcileInstall(profileDir);
|
|
1279
1373
|
}
|
|
1374
|
+
if (attempt !== undefined) rebuild.reconcile = { exitCode: attempt.exitCode };
|
|
1280
1375
|
|
|
1281
1376
|
// Before clearing marker/snapshot after rollback, strictly verify ALL
|
|
1282
1377
|
// direct dependencies declared by the restored manifest exist in that
|
|
@@ -1302,15 +1397,21 @@ export function rollbackPendingSnapshot(profileDir) {
|
|
|
1302
1397
|
// whatever pnpm wrote: the add is only the means to relink node_modules,
|
|
1303
1398
|
// the snapshot stays authoritative for the declaration files.
|
|
1304
1399
|
for (const depName of [...unsatisfied]) {
|
|
1305
|
-
const target = fallbackAddTarget(depName, restoredDependencies[depName]);
|
|
1306
|
-
if (target === undefined)
|
|
1400
|
+
const target = fallbackAddTarget(depName, restoredDependencies[depName], profileDir);
|
|
1401
|
+
if (target === undefined) {
|
|
1402
|
+
// Not offline-addable (an unpinnable spec, no lockfile entry) — record
|
|
1403
|
+
// the refusal too, it is the reason the throw below is about to fire.
|
|
1404
|
+
rebuild.fallback.push({ name: depName, target: undefined, exitCode: undefined, restored: false });
|
|
1405
|
+
continue; // fail closed below
|
|
1406
|
+
}
|
|
1307
1407
|
const addAttempt = runFallbackAdd(profileDir, target);
|
|
1408
|
+
let restored = false;
|
|
1308
1409
|
if (addAttempt.exitCode === 0) {
|
|
1309
1410
|
restoreProfileSnapshot(pending);
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
}
|
|
1411
|
+
restored = candidateRestoredCompatible(profileDir, depName, restoredDependencies[depName]);
|
|
1412
|
+
if (restored) unsatisfied.splice(unsatisfied.indexOf(depName), 1);
|
|
1313
1413
|
}
|
|
1414
|
+
rebuild.fallback.push({ name: depName, target, exitCode: addAttempt.exitCode, restored });
|
|
1314
1415
|
}
|
|
1315
1416
|
}
|
|
1316
1417
|
|
|
@@ -1334,7 +1435,25 @@ export function rollbackPendingSnapshot(profileDir) {
|
|
|
1334
1435
|
|
|
1335
1436
|
rmSync(pendingPath(profileDir), { force: true });
|
|
1336
1437
|
rmSync(pending.dir, { recursive: true, force: true });
|
|
1337
|
-
return pending;
|
|
1438
|
+
return { ...pending, rebuild };
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* One line describing what a rollback's node_modules rebuild did, or undefined
|
|
1443
|
+
* when there was nothing to rebuild (a fresh install's rollback only prunes).
|
|
1444
|
+
* Kept next to the producer so the CLI and the plugin's startup recovery report
|
|
1445
|
+
* it identically.
|
|
1446
|
+
*/
|
|
1447
|
+
export function describeRollbackRebuild(rebuild) {
|
|
1448
|
+
if (rebuild === null || typeof rebuild !== "object") return undefined;
|
|
1449
|
+
const parts = [];
|
|
1450
|
+
if (rebuild.reconcile !== undefined) parts.push(`reconcile exit ${rebuild.reconcile.exitCode}`);
|
|
1451
|
+
for (const entry of rebuild.fallback ?? []) {
|
|
1452
|
+
parts.push(entry.target === undefined
|
|
1453
|
+
? `add ${entry.name}: refused (no pinnable offline target)`
|
|
1454
|
+
: `add ${entry.target}: exit ${entry.exitCode}${entry.restored ? ", restored" : ", NOT restored"}`);
|
|
1455
|
+
}
|
|
1456
|
+
return parts.length > 0 ? parts.join("; ") : undefined;
|
|
1338
1457
|
}
|
|
1339
1458
|
|
|
1340
1459
|
// ── pending-snapshot recovery (startup + external CLI) ───────────────────────
|
|
@@ -1426,11 +1545,149 @@ export function validateRemoveCompletion(profileDir, candidateName) {
|
|
|
1426
1545
|
* @param profileDir - the profile directory (may come straight from the marker).
|
|
1427
1546
|
* @returns {{action: "none"|"committed"|"rolled-back", issues?, removed?}}
|
|
1428
1547
|
*/
|
|
1548
|
+
// ── approval-pause mark ──────────────────────────────────────────────────────
|
|
1549
|
+
//
|
|
1550
|
+
// A needsApproval pause otherwise lives only in console output: the marker
|
|
1551
|
+
// carries no trace of it, so a restart that passes the STATIC validation would
|
|
1552
|
+
// commit the new version with its build scripts never approved — a natively
|
|
1553
|
+
// built plugin is then left installed-but-broken and the rollback snapshot is
|
|
1554
|
+
// deleted. Both recovery commit points (recoverProfile here, cli.js's
|
|
1555
|
+
// commitLaunchSnapshot) must check this mark and roll back instead.
|
|
1556
|
+
//
|
|
1557
|
+
// Deliberately NOT mirrored into snapshot.json (it is written before the
|
|
1558
|
+
// transaction begins and cannot know about a later pause) and NOT a
|
|
1559
|
+
// SNAPSHOT_VERSION bump (a bump would fail-close every marker already written
|
|
1560
|
+
// by earlier versions, pushing users from auto-recoverable to manual).
|
|
1561
|
+
// sanitizeSnapshot ignores unknown metadata fields, so a missing `paused`
|
|
1562
|
+
// simply reads as "not paused" and old markers keep their behavior. Tampering
|
|
1563
|
+
// with the mark is fail-safe: forging it forces a rollback (refuses the new
|
|
1564
|
+
// plugin); deleting it restores the pre-mark behavior.
|
|
1565
|
+
|
|
1566
|
+
/** The pause record on a validated pending marker, or undefined. */
|
|
1567
|
+
export function pendingApprovalPaused(pending) {
|
|
1568
|
+
const paused = pending?.metadata?.paused;
|
|
1569
|
+
return paused !== null && typeof paused === "object" ? paused : undefined;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* How the profile looked BEFORE a paused install began, for the one package
|
|
1574
|
+
* that install is about, or undefined when nothing is paused (or the snapshot
|
|
1575
|
+
* cannot be read, in which case callers should leave their own view alone).
|
|
1576
|
+
*
|
|
1577
|
+
* A paused transaction has already written its half: `pnpm add` swapped
|
|
1578
|
+
* node_modules/<name> to the new version and the manifest declares it, but the
|
|
1579
|
+
* build scripts were never approved, dsh has not loaded any of it, and the next
|
|
1580
|
+
* startup rolls the whole thing back. Reporting that half-state as "installed"
|
|
1581
|
+
* inverts the truth for the user — an UPDATE shows the new version number while
|
|
1582
|
+
* the old one is what is actually running and what a restart will restore. So
|
|
1583
|
+
* the marketplace lists this package the way the snapshot has it instead:
|
|
1584
|
+
*
|
|
1585
|
+
* present: true — it was already installed (an update). Show `version`, the
|
|
1586
|
+
* version the snapshot's lockfile pins: what runs now and
|
|
1587
|
+
* what a restart goes back to.
|
|
1588
|
+
* present: false — it was not installed at all (a fresh install). It should
|
|
1589
|
+
* not appear in the list; nothing about it took effect.
|
|
1590
|
+
*
|
|
1591
|
+
* @returns {{name: string, present: boolean, spec?: string, version?: string}|undefined}
|
|
1592
|
+
*/
|
|
1593
|
+
export function pausedCandidateBeforeState(profileDir) {
|
|
1594
|
+
let pending;
|
|
1595
|
+
try {
|
|
1596
|
+
pending = readValidatedPendingSnapshot(profileDir);
|
|
1597
|
+
} catch {
|
|
1598
|
+
return undefined;
|
|
1599
|
+
}
|
|
1600
|
+
if (pending === undefined || pendingApprovalPaused(pending) === undefined) return undefined;
|
|
1601
|
+
const name = pending.preflight?.candidate?.name ?? pending.candidate?.name;
|
|
1602
|
+
if (typeof name !== "string" || name.length === 0) return undefined;
|
|
1603
|
+
let spec;
|
|
1604
|
+
try {
|
|
1605
|
+
// The snapshot's manifest — not the live one, which the paused install
|
|
1606
|
+
// already rewrote — decides whether this package existed beforehand.
|
|
1607
|
+
spec = readJson(join(pending.dir, "package.json"))?.dependencies?.[name];
|
|
1608
|
+
} catch {
|
|
1609
|
+
return undefined; // unreadable snapshot: do not touch the caller's view
|
|
1610
|
+
}
|
|
1611
|
+
if (spec === undefined) return { name, present: false };
|
|
1612
|
+
// The snapshot's lockfile pins what that install would have kept running.
|
|
1613
|
+
return { name, present: true, spec, version: pinnedLockfileVersion(pending.dir, name) };
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* Validate the pending marker, let `mutate` edit its metadata, write it back.
|
|
1618
|
+
* The whole read-mutate-write sits under ONE try: the marker can disappear or
|
|
1619
|
+
* be replaced between the validating read and the write (an install pausing
|
|
1620
|
+
* while startup recovery consumes the same marker), and a pause mark is never
|
|
1621
|
+
* worth turning that race into a thrown error in the middle of an install.
|
|
1622
|
+
* Both callers below promise a boolean, so the failure is reported that way.
|
|
1623
|
+
* @param mutate - returns true when it changed something worth persisting.
|
|
1624
|
+
* @returns true when the marker was rewritten; false when there was nothing to
|
|
1625
|
+
* do or anything failed — never creates or heals a marker.
|
|
1626
|
+
*/
|
|
1627
|
+
function updatePendingMarkerMetadata(profileDir, mutate) {
|
|
1628
|
+
try {
|
|
1629
|
+
if (readValidatedPendingSnapshot(profileDir) === undefined) return false;
|
|
1630
|
+
const markerPath = pendingPath(profileDir);
|
|
1631
|
+
const marker = readJson(markerPath);
|
|
1632
|
+
if (marker === null || typeof marker !== "object") return false;
|
|
1633
|
+
// Validation above already guarantees metadata is a plain object.
|
|
1634
|
+
const metadata = marker.metadata ?? {};
|
|
1635
|
+
if (mutate(metadata) !== true) return false;
|
|
1636
|
+
marker.metadata = metadata;
|
|
1637
|
+
writeFileSync(markerPath, JSON.stringify(marker, undefined, 2) + "\n");
|
|
1638
|
+
return true;
|
|
1639
|
+
} catch {
|
|
1640
|
+
return false;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* Mark the profile's existing pending marker as paused at the approval gate.
|
|
1646
|
+
* @returns true when a marker was marked; false when there is nothing to mark
|
|
1647
|
+
* (no marker) or it fails validation (fail closed — never create or heal one).
|
|
1648
|
+
*/
|
|
1649
|
+
export function markPendingApprovalPause(profileDir, reason = "paused for build-script approval") {
|
|
1650
|
+
return updatePendingMarkerMetadata(profileDir, (metadata) => {
|
|
1651
|
+
metadata.paused = { reason, at: Date.now() };
|
|
1652
|
+
return true;
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* Clear the approval-pause mark: a token retry resumed the transaction, so its
|
|
1658
|
+
* eventual completion must commit normally instead of being rolled back.
|
|
1659
|
+
* @returns true when a mark was removed, false when there was none to remove.
|
|
1660
|
+
*/
|
|
1661
|
+
export function clearPendingApprovalPause(profileDir) {
|
|
1662
|
+
return updatePendingMarkerMetadata(profileDir, (metadata) => {
|
|
1663
|
+
// Same notion of "a mark exists" as pendingApprovalPaused: a non-object
|
|
1664
|
+
// reads as not paused, so there is nothing to clear.
|
|
1665
|
+
if (metadata.paused === null || typeof metadata.paused !== "object") return false;
|
|
1666
|
+
delete metadata.paused;
|
|
1667
|
+
return true;
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1429
1671
|
export function recoverProfile(profileDir) {
|
|
1430
1672
|
const pending = readValidatedPendingSnapshot(profileDir);
|
|
1431
1673
|
if (pending === undefined) return { action: "none" };
|
|
1432
|
-
const validation = validateInstalledProfile(profileDir);
|
|
1433
1674
|
const isRemove = pending.operation === "remove";
|
|
1675
|
+
// 批准闸暂停后被放弃:静态校验过得去也不许提交——那会把「构建脚本从未
|
|
1676
|
+
// 批准」的新版本以已提交状态留下(原生构建插件装着但坏),且快照被删、
|
|
1677
|
+
// 回滚目标消失。一律回滚到第一次安装前。
|
|
1678
|
+
const pause = pendingApprovalPaused(pending);
|
|
1679
|
+
if (!isRemove && pause !== undefined) {
|
|
1680
|
+
const rolled = rollbackPendingSnapshot(profileDir);
|
|
1681
|
+
return {
|
|
1682
|
+
action: "rolled-back",
|
|
1683
|
+
reason: "批准闸暂停后被放弃(构建脚本未获批准),已回滚到安装前状态",
|
|
1684
|
+
issues: [issue("warn", "approval-paused-abandoned", "批准闸暂停后被放弃,已回滚",
|
|
1685
|
+
`安装停在构建脚本批准处未被批准(${pause.reason}),profile 已回滚到安装前状态`)],
|
|
1686
|
+
removed: addedDependencyNames(pending),
|
|
1687
|
+
rebuild: rolled?.rebuild,
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
const validation = validateInstalledProfile(profileDir);
|
|
1434
1691
|
const candidateName = pending.preflight?.candidate?.name ?? pending.candidate?.name;
|
|
1435
1692
|
const removeValidation = isRemove
|
|
1436
1693
|
? validateRemoveCompletion(pending.profileDir, candidateName)
|
|
@@ -1441,8 +1698,20 @@ export function recoverProfile(profileDir) {
|
|
|
1441
1698
|
}
|
|
1442
1699
|
const recoveryIssues = [...validation.issues, ...removeValidation.issues];
|
|
1443
1700
|
const added = isRemove ? [] : addedDependencyNames(pending);
|
|
1444
|
-
rollbackPendingSnapshot(profileDir);
|
|
1445
|
-
|
|
1701
|
+
const rolled = rollbackPendingSnapshot(profileDir);
|
|
1702
|
+
// The blockers are the reason; naming them beats the old generic wording,
|
|
1703
|
+
// which said "profile failed validation" for every rollback including the
|
|
1704
|
+
// ones that were not validation failures at all.
|
|
1705
|
+
const blockers = recoveryIssues.filter((entry) => entry.severity === "block");
|
|
1706
|
+
return {
|
|
1707
|
+
action: "rolled-back",
|
|
1708
|
+
reason: blockers.length > 0
|
|
1709
|
+
? `profile 静态校验未通过:${blockers.map((entry) => entry.title).join("; ")}`
|
|
1710
|
+
: "profile 静态校验未通过",
|
|
1711
|
+
issues: recoveryIssues,
|
|
1712
|
+
removed: added,
|
|
1713
|
+
rebuild: rolled?.rebuild,
|
|
1714
|
+
};
|
|
1446
1715
|
}
|
|
1447
1716
|
|
|
1448
1717
|
/** Recover every profile with a pending marker under a dsh home. */
|
|
@@ -1682,6 +1951,105 @@ async function selfTest() {
|
|
|
1682
1951
|
if (existsSync(snap.dir)) throw new Error("recoverProfile commit should delete the snapshot dir");
|
|
1683
1952
|
}
|
|
1684
1953
|
|
|
1954
|
+
// Approval-pause mark, part 1: a paused marker must NEVER commit on
|
|
1955
|
+
// recovery — not even when the static validation would pass (the version
|
|
1956
|
+
// sits there with its build scripts never approved; committing would drop
|
|
1957
|
+
// the only rollback snapshot). Recovery rolls back to the pre-install
|
|
1958
|
+
// state. Layout: the candidate is a NEW dependency (snapshot has none), so
|
|
1959
|
+
// the rollback only prunes node_modules and never spawns pnpm.
|
|
1960
|
+
{
|
|
1961
|
+
const p = join(root, "profiles", "approval-pause");
|
|
1962
|
+
mkdirSync(join(p, "node_modules", "good"), { recursive: true });
|
|
1963
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
|
|
1964
|
+
writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
|
|
1965
|
+
if (markPendingApprovalPause(p) !== false) throw new Error("markPendingApprovalPause without a marker must return false, not create one");
|
|
1966
|
+
const snap = createProfileSnapshot(p, { fixture: true });
|
|
1967
|
+
markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "bundle" } } });
|
|
1968
|
+
// 暂停现场:pnpm 已把候选装上、声明也写了——静态校验完全过得去,
|
|
1969
|
+
// 这正是危险所在(提交 = 脚本从未批准的版本以已提交状态留下)。
|
|
1970
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "^2.0.0" } }));
|
|
1971
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
|
|
1972
|
+
if (markPendingApprovalPause(p) !== true) throw new Error("markPendingApprovalPause must mark an existing marker");
|
|
1973
|
+
const pausedMarker = readJson(pendingPath(p));
|
|
1974
|
+
if (pausedMarker?.metadata?.paused?.reason !== "paused for build-script approval") throw new Error("the pause mark must persist on the marker file");
|
|
1975
|
+
const recPaused = recoverProfile(p);
|
|
1976
|
+
if (recPaused.action !== "rolled-back") throw new Error(`a paused marker must roll back even when validation would pass, got ${recPaused.action}`);
|
|
1977
|
+
if (!recPaused.issues.some((entry) => entry.code === "approval-paused-abandoned")) throw new Error("the rollback must carry the approval-paused-abandoned issue");
|
|
1978
|
+
if (readJson(join(p, "package.json")).dependencies?.good !== undefined) throw new Error("rollback must restore the pre-install manifest (no candidate)");
|
|
1979
|
+
if (existsSync(join(p, "node_modules", "good"))) throw new Error("rollback must prune the never-approved candidate");
|
|
1980
|
+
if (readPendingSnapshot(p) !== undefined) throw new Error("the rolled-back pause must consume its marker");
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// pausedCandidateBeforeState: what the marketplace must SHOW while an
|
|
1984
|
+
// install sits paused. The half-written profile says the new version is
|
|
1985
|
+
// installed; the truth is that nothing took effect and a restart undoes it.
|
|
1986
|
+
{
|
|
1987
|
+
// An UPDATE: the package existed before, so the list keeps showing the
|
|
1988
|
+
// version that is actually running — the snapshot's pinned one.
|
|
1989
|
+
const p = join(root, "profiles", "paused-view-update");
|
|
1990
|
+
mkdirSync(join(p, "node_modules", "good"), { recursive: true });
|
|
1991
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "^1.0.0" } }));
|
|
1992
|
+
writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
|
|
1993
|
+
writeFileSync(join(p, "pnpm-lock.yaml"), [
|
|
1994
|
+
"lockfileVersion: '9.0'", "importers:", " .:", " dependencies:",
|
|
1995
|
+
" good:", " specifier: ^1.0.0", " version: 1.0.0", "",
|
|
1996
|
+
].join("\n"));
|
|
1997
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0" }));
|
|
1998
|
+
const snap = createProfileSnapshot(p, { spec: "good@2.0.0" });
|
|
1999
|
+
markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
|
|
2000
|
+
if (pausedCandidateBeforeState(p) !== undefined) throw new Error("a marker with no pause mark must not rewrite the view");
|
|
2001
|
+
// The paused half: pnpm already swapped in 2.0.0 and the manifest says so.
|
|
2002
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "^2.0.0" } }));
|
|
2003
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
|
|
2004
|
+
markPendingApprovalPause(p);
|
|
2005
|
+
const view = pausedCandidateBeforeState(p);
|
|
2006
|
+
if (view?.name !== "good" || view.present !== true) throw new Error(`a paused update must report the package as previously present, got ${JSON.stringify(view)}`);
|
|
2007
|
+
if (view.version !== "1.0.0") throw new Error(`the reported version must be the snapshot's pin (what actually runs), got ${JSON.stringify(view.version)}`);
|
|
2008
|
+
if (view.spec !== "^1.0.0") throw new Error(`the reported spec must be the snapshot's, got ${JSON.stringify(view.spec)}`);
|
|
2009
|
+
rmSync(pendingPath(p), { force: true });
|
|
2010
|
+
rmSync(snap.dir, { recursive: true, force: true });
|
|
2011
|
+
}
|
|
2012
|
+
{
|
|
2013
|
+
// A FRESH install: the package did not exist before, so it must drop out
|
|
2014
|
+
// of the list entirely — nothing about it took effect.
|
|
2015
|
+
const p = join(root, "profiles", "paused-view-fresh");
|
|
2016
|
+
mkdirSync(join(p, "node_modules"), { recursive: true });
|
|
2017
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
|
|
2018
|
+
writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
|
|
2019
|
+
const snap = createProfileSnapshot(p, { spec: "good@2.0.0" });
|
|
2020
|
+
markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
|
|
2021
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "^2.0.0" } }));
|
|
2022
|
+
mkdirSync(join(p, "node_modules", "good"), { recursive: true });
|
|
2023
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
|
|
2024
|
+
markPendingApprovalPause(p);
|
|
2025
|
+
const view = pausedCandidateBeforeState(p);
|
|
2026
|
+
if (view?.name !== "good" || view.present !== false) throw new Error(`a paused fresh install must report the package as absent beforehand, got ${JSON.stringify(view)}`);
|
|
2027
|
+
if (view.version !== undefined) throw new Error("an absent package has no version to show");
|
|
2028
|
+
rmSync(pendingPath(p), { force: true });
|
|
2029
|
+
rmSync(snap.dir, { recursive: true, force: true });
|
|
2030
|
+
if (pausedCandidateBeforeState(p) !== undefined) throw new Error("no marker means no view rewrite");
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// Approval-pause mark, part 2: a cleared mark (token retry resumed and
|
|
2034
|
+
// finished the transaction) commits normally — the mark must not outlive
|
|
2035
|
+
// the transaction it belonged to.
|
|
2036
|
+
{
|
|
2037
|
+
const p = join(root, "profiles", "approval-pause-cleared");
|
|
2038
|
+
mkdirSync(join(p, "node_modules", "good"), { recursive: true });
|
|
2039
|
+
writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { good: "1.0.0" }, dsh: { profile: { bundles: ["good"] } } }));
|
|
2040
|
+
writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
|
|
2041
|
+
writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
|
|
2042
|
+
writeFileSync(join(p, "node_modules", "good", "cordis.patch.yml"), "- insert:\n - id: good\n name: good\n");
|
|
2043
|
+
const snap = createProfileSnapshot(p, { fixture: true });
|
|
2044
|
+
markPendingSnapshot(snap, { spec: "good", preflight: { candidate: { name: "good", version: "1.0.0", kind: "bundle" } } });
|
|
2045
|
+
markPendingApprovalPause(p);
|
|
2046
|
+
if (clearPendingApprovalPause(p) !== true) throw new Error("clearPendingApprovalPause must remove an existing mark");
|
|
2047
|
+
const rec = recoverProfile(p);
|
|
2048
|
+
if (rec.action !== "committed") throw new Error(`after the mark is cleared a healthy install must commit, got ${rec.action}`);
|
|
2049
|
+
if (readPendingSnapshot(p) !== undefined) throw new Error("the commit must clear the marker");
|
|
2050
|
+
if (clearPendingApprovalPause(p) !== false) throw new Error("clearPendingApprovalPause without a mark must return false");
|
|
2051
|
+
}
|
|
2052
|
+
|
|
1685
2053
|
// Remove rollback, no-op failure: the official command failed before
|
|
1686
2054
|
// touching the package. Rollback must preserve the healthy direct package
|
|
1687
2055
|
// and must not run the install/update path's candidate pruning logic.
|
|
@@ -2021,6 +2389,69 @@ async function selfTest() {
|
|
|
2021
2389
|
if (!args.includes("--ignore-scripts")) throw new Error("probe args must keep install scripts disabled");
|
|
2022
2390
|
}
|
|
2023
2391
|
|
|
2392
|
+
// fallbackAddTarget (pure): what one restored dependency may be offline
|
|
2393
|
+
// re-added as. `^` ranges (pnpm's near-universal save prefix) cannot be
|
|
2394
|
+
// spliced into a shell-wrapped argv (cmd eats the caret), so they resolve
|
|
2395
|
+
// to the lockfile's pinned version — exactly what a rollback is restoring
|
|
2396
|
+
// to. A `github:` spec resolves to its pinned tarball URL for a different
|
|
2397
|
+
// and sharper reason: the bare spec means "HEAD now", and the freshest
|
|
2398
|
+
// thing in pnpm's cache/store is the commit the failed update just fetched,
|
|
2399
|
+
// so adding it bare would relink the very version being rolled back FROM —
|
|
2400
|
+
// and a non-semver spec has no range for candidateRestoredCompatible to
|
|
2401
|
+
// check, so that wrong copy would pass on name alone, clear the marker and
|
|
2402
|
+
// delete the snapshot. Pinning is what keeps the fallback fail-closed.
|
|
2403
|
+
{
|
|
2404
|
+
const lockRoot = join(root, "profiles", "fbtarget");
|
|
2405
|
+
mkdirSync(lockRoot, { recursive: true });
|
|
2406
|
+
const tarball = "https://codeload.github.com/owner/repo/tar.gz/898369ece56ae6ec41afd8e014f187bb5b723409";
|
|
2407
|
+
writeFileSync(join(lockRoot, "pnpm-lock.yaml"), [
|
|
2408
|
+
"lockfileVersion: '9.0'",
|
|
2409
|
+
"importers:",
|
|
2410
|
+
" .:",
|
|
2411
|
+
" dependencies:",
|
|
2412
|
+
" good:",
|
|
2413
|
+
" specifier: ^1.0.0",
|
|
2414
|
+
" version: 1.0.0",
|
|
2415
|
+
" hosted:",
|
|
2416
|
+
" specifier: github:owner/repo",
|
|
2417
|
+
` version: ${tarball}`,
|
|
2418
|
+
" peered:",
|
|
2419
|
+
" specifier: ^1.0.0",
|
|
2420
|
+
" version: 1.0.0(@deepseek-ai/cordis@4.0.1)",
|
|
2421
|
+
"",
|
|
2422
|
+
].join("\n"));
|
|
2423
|
+
if (fallbackAddTarget("good", "1.0.0", lockRoot) !== "good@1.0.0") throw new Error("a plain range splices directly");
|
|
2424
|
+
if (fallbackAddTarget("good", "^1.0.0", lockRoot) !== "good@1.0.0") throw new Error("a ^-range must resolve to the lockfile pinned version");
|
|
2425
|
+
if (fallbackAddTarget("good", "^1.0.0", join(root, "profiles", "no-lock-here")) !== undefined) {
|
|
2426
|
+
throw new Error("a ^-range without a readable lockfile must stay fail-closed");
|
|
2427
|
+
}
|
|
2428
|
+
// A bare github spec must NEVER be added as itself: pinning to the
|
|
2429
|
+
// lockfile's tarball URL is the only thing that names the old commit.
|
|
2430
|
+
if (fallbackAddTarget("hosted", "github:owner/repo", lockRoot) !== `hosted@${tarball}`) {
|
|
2431
|
+
throw new Error("a github spec must resolve to the lockfile pinned tarball, never to the moving bare spec");
|
|
2432
|
+
}
|
|
2433
|
+
if (fallbackAddTarget("hosted", "github:owner/repo", join(root, "profiles", "no-lock-here")) !== undefined) {
|
|
2434
|
+
throw new Error("a github spec without a readable lockfile must stay fail-closed, not fall back to the bare spec");
|
|
2435
|
+
}
|
|
2436
|
+
if (fallbackAddTarget("absent", "github:owner/absent", lockRoot) !== undefined) {
|
|
2437
|
+
throw new Error("a github spec with no lockfile entry must stay fail-closed");
|
|
2438
|
+
}
|
|
2439
|
+
// pnpm's peer suffix is not a legal add spec — the parens hit the spec
|
|
2440
|
+
// blacklist, so the fallback fails closed instead of adding something odd.
|
|
2441
|
+
if (fallbackAddTarget("peered", "^1.0.0", lockRoot) !== undefined) {
|
|
2442
|
+
throw new Error("a peer-suffixed pinned version must stay fail-closed");
|
|
2443
|
+
}
|
|
2444
|
+
if (fallbackAddTarget("good", "file:D:\\pkg.tgz", lockRoot) !== "file:D:\\pkg.tgz") throw new Error("local file target expected");
|
|
2445
|
+
// 多区间 range 直拼必被拒(空格/管道),但 lockfile 的 pinned 对任何
|
|
2446
|
+
// range 都是合法恢复目标(lockfile 即权威)——同样走 pinned,
|
|
2447
|
+
// 只有 lockfile 不可读/无该条目才 fail-closed。
|
|
2448
|
+
if (fallbackAddTarget("good", "^1.0.0 || ^2.0.0", lockRoot) !== "good@1.0.0") throw new Error("a multi-clause range must resolve to the lockfile pinned version");
|
|
2449
|
+
if (fallbackAddTarget("good", "^1.0.0 || ^2.0.0", join(root, "profiles", "no-lock-here")) !== undefined) {
|
|
2450
|
+
throw new Error("a multi-clause range without a readable lockfile must stay fail-closed");
|
|
2451
|
+
}
|
|
2452
|
+
if (fallbackAddTarget("good", "", lockRoot) !== undefined) throw new Error("an empty spec has no target");
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2024
2455
|
// pnpmSpawnPlan (pure, plus a real spawn on Windows): the .cmd shim path
|
|
2025
2456
|
// must carry its own quotes. Node's shell:true joins command and args
|
|
2026
2457
|
// without per-argument quoting, so `D:\Program Files\nodejs\pnpm.CMD`
|
|
@@ -2188,12 +2619,27 @@ async function selfTest() {
|
|
|
2188
2619
|
markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
|
|
2189
2620
|
const previousPath = process.env.PATH;
|
|
2190
2621
|
process.env.PATH = `${binDir}${delimiter}${previousPath ?? ""}`;
|
|
2622
|
+
let rolled;
|
|
2191
2623
|
try {
|
|
2192
|
-
rollbackPendingSnapshot(p);
|
|
2624
|
+
rolled = rollbackPendingSnapshot(p);
|
|
2193
2625
|
} finally {
|
|
2194
2626
|
if (previousPath === undefined) delete process.env.PATH;
|
|
2195
2627
|
else process.env.PATH = previousPath;
|
|
2196
2628
|
}
|
|
2629
|
+
// The rebuild report is the ONLY way to tell "reconcile relinked it" from
|
|
2630
|
+
// "reconcile no-opped and the add fallback saved it" after the fact —
|
|
2631
|
+
// both leave an identical-looking profile behind.
|
|
2632
|
+
if (rolled?.rebuild?.reconcile?.exitCode !== 0) throw new Error("the rebuild report must record the no-op reconcile and its exit code");
|
|
2633
|
+
if (rolled.rebuild.fallback.length !== 1) throw new Error(`the rebuild report must record exactly one fallback add, got ${rolled.rebuild.fallback.length}`);
|
|
2634
|
+
const [addReport] = rolled.rebuild.fallback;
|
|
2635
|
+
if (addReport.name !== "good" || addReport.target !== "good@1.0.0" || addReport.exitCode !== 0 || addReport.restored !== true) {
|
|
2636
|
+
throw new Error(`the fallback report must name the package, target, exit code and outcome, got ${JSON.stringify(addReport)}`);
|
|
2637
|
+
}
|
|
2638
|
+
const described = describeRollbackRebuild(rolled.rebuild);
|
|
2639
|
+
if (!/reconcile exit 0/.test(described) || !/add good@1\.0\.0: exit 0, restored/.test(described)) {
|
|
2640
|
+
throw new Error(`describeRollbackRebuild must render both steps, got ${JSON.stringify(described)}`);
|
|
2641
|
+
}
|
|
2642
|
+
if (describeRollbackRebuild(undefined) !== undefined) throw new Error("no rebuild means no line to print");
|
|
2197
2643
|
if (readPendingSnapshot(p) !== undefined) throw new Error("an add-fallback-rescued rollback must clear the marker");
|
|
2198
2644
|
if (existsSync(snap.dir)) throw new Error("an add-fallback-rescued rollback must delete the snapshot dir");
|
|
2199
2645
|
if (readJson(join(p, "node_modules", "good", "package.json")).version !== "1.0.0") throw new Error("the add fallback must relink the old version");
|