@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/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 argv target for a fallback `pnpm add` of one restored dependency, or
1066
- * undefined when that spec cannot be added offline and safely. Semver ranges
1067
- * become `name@range`, local file:/link: paths add by the spec itself;
1068
- * github:/git+ specs need the network or git and stay fail-closed, and
1069
- * anything carrying shell metacharacters (a multi-clause range like
1070
- * `^1.0.0 || ^2.0.0` contains spaces and pipes) is skipped — the
1071
- * shell-wrapped pnpm spawn joins argv with spaces without quoting.
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 fallbackAddTarget(name, spec) {
1074
- const range = String(spec ?? "");
1075
- if (range.length === 0) return undefined;
1076
- const isLocal = /^(?:file:|link:)/i.test(range);
1077
- if (!isLocal && validRange(range) === null) return undefined;
1078
- const target = isLocal ? range : `${name}@${range}`;
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
- return target;
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) continue; // not offline-addable — fail closed below
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
- if (candidateRestoredCompatible(profileDir, depName, restoredDependencies[depName])) {
1311
- unsatisfied.splice(unsatisfied.indexOf(depName), 1);
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
- return { action: "rolled-back", issues: recoveryIssues, removed: added };
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");