opencode-plugin-flow 5.3.1 → 5.3.2

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/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  readdir,
12
12
  rename,
13
13
  rm,
14
+ rmdir,
14
15
  writeFile
15
16
  } from "node:fs/promises";
16
17
  import { homedir } from "node:os";
@@ -26,6 +27,93 @@ import {
26
27
  } from "node:path";
27
28
  import { fileURLToPath } from "node:url";
28
29
 
30
+ // src/platform/opencode/leadership.ts
31
+ var REGISTRY_KIND = "opencode-plugin-flow.runtime-leadership";
32
+ var MAX_VERSION_LENGTH = 256;
33
+ var FLOW_LEADERSHIP_REGISTRY_SYMBOL = Symbol.for(REGISTRY_KIND);
34
+ var SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
35
+ function parseSemanticVersion(version) {
36
+ if (version.length === 0 || version.length > MAX_VERSION_LENGTH)
37
+ return null;
38
+ const match = SEMVER_PATTERN.exec(version);
39
+ const major = match?.[1];
40
+ const minor = match?.[2];
41
+ const patch = match?.[3];
42
+ if (major === undefined || minor === undefined || patch === undefined) {
43
+ return null;
44
+ }
45
+ const prereleaseText = match?.[4];
46
+ const prerelease = prereleaseText ? prereleaseText.split(".").map((identifier) => /^\d+$/.test(identifier) ? { kind: "numeric", value: BigInt(identifier) } : { kind: "text", value: identifier }) : null;
47
+ return {
48
+ major: BigInt(major),
49
+ minor: BigInt(minor),
50
+ patch: BigInt(patch),
51
+ prerelease
52
+ };
53
+ }
54
+ function compareBigInts(left, right) {
55
+ if (left < right)
56
+ return -1;
57
+ if (left > right)
58
+ return 1;
59
+ return 0;
60
+ }
61
+ function compareText(left, right) {
62
+ if (left < right)
63
+ return -1;
64
+ if (left > right)
65
+ return 1;
66
+ return 0;
67
+ }
68
+ function comparePrerelease(left, right) {
69
+ if (left === null && right === null)
70
+ return 0;
71
+ if (left === null)
72
+ return 1;
73
+ if (right === null)
74
+ return -1;
75
+ const length = Math.max(left.length, right.length);
76
+ for (let index = 0;index < length; index += 1) {
77
+ const leftIdentifier = left[index];
78
+ const rightIdentifier = right[index];
79
+ if (leftIdentifier === undefined)
80
+ return -1;
81
+ if (rightIdentifier === undefined)
82
+ return 1;
83
+ if (leftIdentifier.kind === "numeric" && rightIdentifier.kind === "numeric") {
84
+ const comparison2 = compareBigInts(leftIdentifier.value, rightIdentifier.value);
85
+ if (comparison2 !== 0)
86
+ return comparison2;
87
+ continue;
88
+ }
89
+ if (leftIdentifier.kind === "numeric")
90
+ return -1;
91
+ if (rightIdentifier.kind === "numeric")
92
+ return 1;
93
+ const comparison = compareText(leftIdentifier.value, rightIdentifier.value);
94
+ if (comparison !== 0)
95
+ return comparison;
96
+ }
97
+ return 0;
98
+ }
99
+ function compareSemanticVersions(left, right) {
100
+ const leftVersion = parseSemanticVersion(left);
101
+ const rightVersion = parseSemanticVersion(right);
102
+ if (!leftVersion || !rightVersion) {
103
+ throw new TypeError("Flow leadership versions must be valid exact semantic versions.");
104
+ }
105
+ for (const [leftPart, rightPart] of [
106
+ [leftVersion.major, rightVersion.major],
107
+ [leftVersion.minor, rightVersion.minor],
108
+ [leftVersion.patch, rightVersion.patch]
109
+ ]) {
110
+ const comparison = compareBigInts(leftPart, rightPart);
111
+ if (comparison !== 0)
112
+ return comparison;
113
+ }
114
+ return comparePrerelease(leftVersion.prerelease, rightVersion.prerelease);
115
+ }
116
+
29
117
  // src/version.ts
30
118
  import { createRequire } from "node:module";
31
119
  function resolveFlowPluginVersion() {
@@ -182,6 +270,15 @@ async function optionalLstat(path) {
182
270
  throw error;
183
271
  }
184
272
  }
273
+ async function removeEmptyDirectory(path) {
274
+ try {
275
+ await rmdir(path);
276
+ } catch (error) {
277
+ const code = error.code;
278
+ if (code !== "ENOENT" && code !== "ENOTEMPTY")
279
+ throw error;
280
+ }
281
+ }
185
282
  async function symlinkInPathRange(safetyRoot, target) {
186
283
  const root = normalize(safetyRoot);
187
284
  const destination = normalize(target);
@@ -497,6 +594,31 @@ function parseOwnedWrapper(content) {
497
594
  }
498
595
  return { kind: "owned", version };
499
596
  }
597
+ function legacyFlowWrapperContent(version) {
598
+ return [
599
+ "const flowPluginUrl = new URL(",
600
+ ` "../.cache/opencode/packages/${FLOW_PACKAGE_NAME}@${version}/node_modules/${FLOW_PACKAGE_NAME}/dist/index.js",`,
601
+ ` \`file://\${process.env.HOME}/\`,`,
602
+ ")",
603
+ "",
604
+ "export default async function flowPlugin(input, options) {",
605
+ ' process.env.BUN_BE_BUN = "1"',
606
+ " const { default: plugin } = await import(flowPluginUrl.href)",
607
+ " return plugin(input, options)",
608
+ "}",
609
+ ""
610
+ ].join(`
611
+ `);
612
+ }
613
+ function parseLegacyFlowWrapper(path, content) {
614
+ const normalized = content.replaceAll(`\r
615
+ `, `
616
+ `);
617
+ const version = hintedFlowVersion(normalized);
618
+ if (!version || basename(path) !== `flow-${version}-wrapper.js`)
619
+ return null;
620
+ return normalized === legacyFlowWrapperContent(version) ? { version } : null;
621
+ }
500
622
  function hintedFlowVersion(content) {
501
623
  const candidate = new RegExp(`${FLOW_PACKAGE_NAME.replaceAll("-", "\\-")}@([^/\\s"']+)`).exec(content)?.[1];
502
624
  return candidate && isExactFlowVersion(candidate) ? candidate : null;
@@ -575,6 +697,19 @@ async function classifyLocalPlugin(path, source, scope, target, specifier) {
575
697
  reason: owned.version === target ? "local wrapper duplicates the canonical npm activation source" : "local wrapper activates another Flow version"
576
698
  };
577
699
  }
700
+ const legacy = parseLegacyFlowWrapper(path, content);
701
+ if (legacy) {
702
+ return {
703
+ source,
704
+ scope,
705
+ path,
706
+ specifier,
707
+ resolvedVersion: legacy.version,
708
+ ownership: "legacy-flow-wrapper",
709
+ status: "conflict",
710
+ reason: "exact known legacy Flow wrapper must be removed"
711
+ };
712
+ }
578
713
  const flowLike = looksLikeFlowPath(specifier) || looksLikeFlowPath(path) || content.includes(FLOW_PACKAGE_NAME) || content.includes(OWNED_WRAPPER_MARKER);
579
714
  if (!flowLike)
580
715
  return null;
@@ -1005,12 +1140,18 @@ async function checkFlowActivation(options) {
1005
1140
  };
1006
1141
  }
1007
1142
  issues.push(...cache.issues);
1143
+ issues.push(...await activationJournalIssues(paths, options.ignoreRecoveryRunId));
1008
1144
  const limitations = await activationLimitations(paths);
1009
1145
  const reasons = activationReasons(records, cache.artifacts, issues, target);
1010
1146
  return {
1011
1147
  mode: "check",
1012
1148
  project: paths.project,
1013
1149
  target,
1150
+ coverage: {
1151
+ globalSources: true,
1152
+ selectedProject: paths.project,
1153
+ otherProjectTrees: false
1154
+ },
1014
1155
  paths,
1015
1156
  records,
1016
1157
  cacheArtifacts: cache.artifacts,
@@ -1113,14 +1254,15 @@ function wrapperMutationSafetyRoot(paths, wrapper) {
1113
1254
  function uniqueOwnedWrappers(records) {
1114
1255
  const wrappers = new Map;
1115
1256
  for (const record of records) {
1116
- if (record.ownership !== "marker-owned-wrapper" || record.resolvedVersion === null) {
1257
+ if (record.ownership !== "marker-owned-wrapper" && record.ownership !== "legacy-flow-wrapper" || record.resolvedVersion === null) {
1117
1258
  continue;
1118
1259
  }
1119
1260
  wrappers.set(record.path, {
1120
1261
  path: record.path,
1121
1262
  scope: record.scope,
1122
1263
  source: record.source,
1123
- version: record.resolvedVersion
1264
+ version: record.resolvedVersion,
1265
+ ownership: record.ownership
1124
1266
  });
1125
1267
  }
1126
1268
  return [...wrappers.values()];
@@ -1138,7 +1280,7 @@ function removableConfigEntry(entry, descriptor, records) {
1138
1280
  } catch {
1139
1281
  return false;
1140
1282
  }
1141
- return records.some((record) => record.source === descriptor.source && record.specifier === specifier && record.path === localPath && record.ownership === "marker-owned-wrapper");
1283
+ return records.some((record) => record.source === descriptor.source && record.specifier === specifier && record.path === localPath && (record.ownership === "marker-owned-wrapper" || record.ownership === "legacy-flow-wrapper"));
1142
1284
  }
1143
1285
  function activationRefusals(before) {
1144
1286
  return [
@@ -1147,6 +1289,18 @@ function activationRefusals(before) {
1147
1289
  ...before.cacheArtifacts.filter((artifact) => artifact.status === "ambiguous").map((artifact) => `${artifact.path}: ${artifact.reason ?? "ambiguous cache artifact refused"}`)
1148
1290
  ];
1149
1291
  }
1292
+ function downgradeRefusals(before) {
1293
+ const newerVersions = new Set;
1294
+ for (const version of [
1295
+ ...before.records.map((record) => record.resolvedVersion),
1296
+ ...before.cacheArtifacts.map((artifact) => artifact.resolvedVersion)
1297
+ ]) {
1298
+ if (version && compareSemanticVersions(version, before.target) > 0) {
1299
+ newerVersions.add(version);
1300
+ }
1301
+ }
1302
+ return [...newerVersions].sort((left, right) => compareSemanticVersions(right, left)).map((version) => `refusing to replace newer installed Flow ${version} with older target ${before.target}; run ${FLOW_PACKAGE_NAME}@latest instead`);
1303
+ }
1150
1304
  async function assertUnchangedConfig(snapshot) {
1151
1305
  await assertSafeMutationPath(snapshot.descriptor.safetyRoot, snapshot.descriptor.path);
1152
1306
  const metadata = await optionalLstat(snapshot.descriptor.path);
@@ -1233,11 +1387,153 @@ async function writeJournal(journalPath, journal) {
1233
1387
  await rm(temporaryPath, { force: true });
1234
1388
  }
1235
1389
  }
1390
+ var TERMINAL_JOURNAL_STATES = new Set([
1391
+ "complete",
1392
+ "rolled-back"
1393
+ ]);
1394
+ function isRecord(value) {
1395
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1396
+ }
1397
+ function parseActivationJournal(content, journalPath) {
1398
+ let value;
1399
+ try {
1400
+ value = JSON.parse(content);
1401
+ } catch (error) {
1402
+ throw new Error(`${journalPath}: recovery journal is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
1403
+ }
1404
+ if (!isRecord(value) || typeof value.runId !== "string" || value.runId !== basename(dirname(journalPath)) || typeof value.state !== "string") {
1405
+ throw new Error(`${journalPath}: recovery journal schema is invalid`);
1406
+ }
1407
+ if (value.format === "flow-activation-journal-v1") {
1408
+ if (![
1409
+ "prepared",
1410
+ "applying",
1411
+ "complete",
1412
+ "failed",
1413
+ "rolled-back",
1414
+ "rollback-failed"
1415
+ ].includes(value.state)) {
1416
+ throw new Error(`${journalPath}: legacy recovery journal state is invalid`);
1417
+ }
1418
+ return value;
1419
+ }
1420
+ if (value.format !== "flow-activation-journal-v2" || typeof value.createdAt !== "string" || typeof value.project !== "string" || !isAbsolute(value.project) || typeof value.target !== "string" || !isExactFlowVersion(value.target) || value.scope !== "global" && value.scope !== "project" || ![
1421
+ "prepared",
1422
+ "applying",
1423
+ "committed",
1424
+ "complete",
1425
+ "failed",
1426
+ "cleanup-failed",
1427
+ "rolled-back",
1428
+ "rollback-failed"
1429
+ ].includes(value.state) || !Array.isArray(value.actions)) {
1430
+ throw new Error(`${journalPath}: recovery journal schema is invalid`);
1431
+ }
1432
+ for (const action of value.actions) {
1433
+ if (!isRecord(action) || !["rewrite-config", "remove-wrapper", "remove-cache"].includes(String(action.action)) || typeof action.path !== "string" || !isAbsolute(action.path) || !["pending", "complete", "rolled-back", "rollback-failed"].includes(String(action.state))) {
1434
+ throw new Error(`${journalPath}: recovery journal action is invalid`);
1435
+ }
1436
+ }
1437
+ if (value.ownerPid !== undefined && (!Number.isSafeInteger(value.ownerPid) || Number(value.ownerPid) <= 0)) {
1438
+ throw new Error(`${journalPath}: recovery journal owner pid is invalid`);
1439
+ }
1440
+ return value;
1441
+ }
1442
+ async function readActivationJournalEntries(paths) {
1443
+ try {
1444
+ await assertSafeMutationPath(dirname(paths.configRoot), paths.journalRoot);
1445
+ } catch (error) {
1446
+ return [
1447
+ {
1448
+ journalPath: paths.journalRoot,
1449
+ error: error instanceof Error ? error.message : String(error)
1450
+ }
1451
+ ];
1452
+ }
1453
+ const rootMetadata = await optionalLstat(paths.journalRoot);
1454
+ if (!rootMetadata)
1455
+ return [];
1456
+ if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
1457
+ return [
1458
+ {
1459
+ journalPath: paths.journalRoot,
1460
+ error: `${paths.journalRoot}: recovery root is not a real directory`
1461
+ }
1462
+ ];
1463
+ }
1464
+ const entries = [];
1465
+ for (const directory of await readdir(paths.journalRoot, {
1466
+ withFileTypes: true
1467
+ })) {
1468
+ const runRoot = join(paths.journalRoot, directory.name);
1469
+ const journalPath = join(runRoot, "journal.json");
1470
+ if (directory.isSymbolicLink()) {
1471
+ entries.push({
1472
+ journalPath,
1473
+ error: `${runRoot}: symbolic recovery directory refused`
1474
+ });
1475
+ continue;
1476
+ }
1477
+ if (!directory.isDirectory() || !await optionalLstat(journalPath)) {
1478
+ continue;
1479
+ }
1480
+ try {
1481
+ const content = await readRegularFileWithoutFollowing(journalPath, MAX_LOCAL_PLUGIN_BYTES);
1482
+ entries.push({
1483
+ journalPath,
1484
+ journal: parseActivationJournal(content, journalPath)
1485
+ });
1486
+ } catch (error) {
1487
+ entries.push({
1488
+ journalPath,
1489
+ error: error instanceof Error ? error.message : String(error)
1490
+ });
1491
+ }
1492
+ }
1493
+ return entries.sort((left, right) => left.journalPath.localeCompare(right.journalPath));
1494
+ }
1495
+ function processIsAlive(pid) {
1496
+ try {
1497
+ process.kill(pid, 0);
1498
+ return true;
1499
+ } catch (error) {
1500
+ return error.code === "EPERM";
1501
+ }
1502
+ }
1503
+ async function activationJournalIssues(paths, ignoreRunId) {
1504
+ const issues = [];
1505
+ for (const entry of await readActivationJournalEntries(paths)) {
1506
+ if (entry.error) {
1507
+ issues.push({
1508
+ source: "recovery",
1509
+ path: entry.journalPath,
1510
+ code: "incomplete-recovery",
1511
+ message: entry.error
1512
+ });
1513
+ continue;
1514
+ }
1515
+ const journal = entry.journal;
1516
+ if (journal.runId === ignoreRunId || TERMINAL_JOURNAL_STATES.has(journal.state)) {
1517
+ continue;
1518
+ }
1519
+ const activeOwner = journal.format === "flow-activation-journal-v2" && journal.ownerPid !== undefined && processIsAlive(journal.ownerPid);
1520
+ issues.push({
1521
+ source: "recovery",
1522
+ path: entry.journalPath,
1523
+ code: "incomplete-recovery",
1524
+ message: activeOwner ? `activation recovery is still owned by running process ${journal.ownerPid}` : journal.format === "flow-activation-journal-v1" ? `legacy activation recovery is incomplete in state ${journal.state}; follow that journal's manual recovery guidance before installing` : journal.state === "rollback-failed" ? "activation rollback previously failed and requires the journal's manual recovery guidance" : `activation recovery is incomplete in state ${journal.state}; rerun install to reconcile it before evaluating success`
1525
+ });
1526
+ }
1527
+ return issues;
1528
+ }
1236
1529
  async function verifyOwnedWrapper(wrapper) {
1237
1530
  const content = await readRegularFileWithoutFollowing(wrapper.path, MAX_LOCAL_PLUGIN_BYTES);
1238
- const parsed = parseOwnedWrapper(content);
1239
- if (parsed.kind !== "owned" || parsed.version !== wrapper.version) {
1240
- throw new Error(`${wrapper.path}: owned wrapper changed while activation was running`);
1531
+ const version = wrapper.ownership === "marker-owned-wrapper" ? (() => {
1532
+ const parsed = parseOwnedWrapper(content);
1533
+ return parsed.kind === "owned" ? parsed.version : null;
1534
+ })() : parseLegacyFlowWrapper(wrapper.path, content)?.version;
1535
+ if (version !== wrapper.version) {
1536
+ throw new Error(`${wrapper.path}: removable wrapper changed while activation was running`);
1241
1537
  }
1242
1538
  }
1243
1539
  async function verifyCacheArtifact(artifact, target) {
@@ -1247,19 +1543,19 @@ async function verifyCacheArtifact(artifact, target) {
1247
1543
  }
1248
1544
  }
1249
1545
  async function replaceKnownConfigContent(options) {
1250
- await assertSafeMutationPath(options.snapshot.descriptor.safetyRoot, options.snapshot.descriptor.path);
1251
- const current = await readRegularFileWithoutFollowing(options.snapshot.descriptor.path);
1546
+ await assertSafeMutationPath(options.descriptor.safetyRoot, options.descriptor.path);
1547
+ const current = await readRegularFileWithoutFollowing(options.descriptor.path);
1252
1548
  if (sha256(current) !== options.expectedDigest) {
1253
- throw new Error(`${options.snapshot.descriptor.path}: automatic restore refused because the applied config changed`);
1549
+ throw new Error(`${options.descriptor.path}: automatic restore refused because the applied config changed`);
1254
1550
  }
1255
- const temporaryPath = join(dirname(options.snapshot.descriptor.path), `.${basename(options.snapshot.descriptor.path)}.restore-${options.runId}.tmp`);
1551
+ const temporaryPath = join(dirname(options.descriptor.path), `.${basename(options.descriptor.path)}.restore-${options.runId}.tmp`);
1256
1552
  try {
1257
1553
  await writeFile(temporaryPath, options.content, {
1258
1554
  encoding: "utf8",
1259
1555
  flag: "wx",
1260
1556
  mode: options.mode
1261
1557
  });
1262
- await rename(temporaryPath, options.snapshot.descriptor.path);
1558
+ await rename(temporaryPath, options.descriptor.path);
1263
1559
  } finally {
1264
1560
  await rm(temporaryPath, { force: true });
1265
1561
  }
@@ -1295,18 +1591,18 @@ async function rollbackCompletedActions(options) {
1295
1591
  throw new Error("config backup is missing");
1296
1592
  const backup = await readRegularFileWithoutFollowing(action.backupPath);
1297
1593
  await replaceKnownConfigContent({
1298
- snapshot,
1594
+ descriptor: snapshot.descriptor,
1299
1595
  expectedDigest: action.appliedDigest,
1300
1596
  content: backup,
1301
1597
  mode: action.originalMode ?? snapshot.mode,
1302
1598
  runId: options.runId
1303
1599
  });
1304
1600
  }
1305
- } else if (action.action === "quarantine-wrapper" || action.action === "quarantine-cache") {
1306
- if (!action.recoveryPath)
1307
- throw new Error("recovery path is missing");
1601
+ } else if (action.action === "remove-wrapper" || action.action === "remove-cache") {
1602
+ if (!action.stagingPath)
1603
+ throw new Error("staging path is missing");
1308
1604
  let safetyRoot;
1309
- if (action.action === "quarantine-cache") {
1605
+ if (action.action === "remove-cache") {
1310
1606
  safetyRoot = dirname(options.paths.cacheRoot);
1311
1607
  } else {
1312
1608
  const wrapper = options.wrappers.find((candidate) => candidate.path === action.path);
@@ -1315,16 +1611,16 @@ async function rollbackCompletedActions(options) {
1315
1611
  safetyRoot = wrapperMutationSafetyRoot(options.paths, wrapper);
1316
1612
  }
1317
1613
  await assertSafeMutationPath(safetyRoot, action.path);
1318
- await assertSafeMutationPath(safetyRoot, action.recoveryPath);
1614
+ await assertSafeMutationPath(safetyRoot, action.stagingPath);
1319
1615
  if (await optionalLstat(action.path)) {
1320
1616
  throw new Error("original path is occupied; automatic restore refused");
1321
1617
  }
1322
- const recovery = await optionalLstat(action.recoveryPath);
1323
- if (!recovery || recovery.isSymbolicLink()) {
1324
- throw new Error("quarantined artifact is missing or symbolic");
1618
+ const staged = await optionalLstat(action.stagingPath);
1619
+ if (!staged || staged.isSymbolicLink()) {
1620
+ throw new Error("staged artifact is missing or symbolic");
1325
1621
  }
1326
1622
  await mkdir(dirname(action.path), { recursive: true });
1327
- await rename(action.recoveryPath, action.path);
1623
+ await rename(action.stagingPath, action.path);
1328
1624
  }
1329
1625
  action.state = "rolled-back";
1330
1626
  } catch (error) {
@@ -1341,14 +1637,264 @@ async function rollbackCompletedActions(options) {
1341
1637
  }
1342
1638
  return failures;
1343
1639
  }
1640
+ function journalConfigDescriptor(paths, action) {
1641
+ const descriptor = configDescriptors(paths).find((candidate) => candidate.path === action.path && (action.source === undefined || candidate.source === action.source));
1642
+ if (!descriptor) {
1643
+ throw new Error(`${action.path}: journal config path is outside inventory`);
1644
+ }
1645
+ return descriptor;
1646
+ }
1647
+ function journalOwnedWrapper(paths, journal, action) {
1648
+ if (action.ownership !== "marker-owned-wrapper" && action.ownership !== "legacy-flow-wrapper" || typeof action.resolvedVersion !== "string" || !isExactFlowVersion(action.resolvedVersion) || typeof action.source !== "string" || typeof action.scope !== "string") {
1649
+ throw new Error(`${action.path}: wrapper recovery metadata is incomplete`);
1650
+ }
1651
+ const wrapper = {
1652
+ path: action.path,
1653
+ source: action.source,
1654
+ scope: action.scope,
1655
+ version: action.resolvedVersion,
1656
+ ownership: action.ownership
1657
+ };
1658
+ const pluginDirectory = pluginDirectoryDescriptors(paths).find((descriptor) => descriptor.source === wrapper.source && descriptor.scope === wrapper.scope && dirname(wrapper.path) === descriptor.path);
1659
+ const config = configDescriptors(paths).find((descriptor) => {
1660
+ if (descriptor.source !== wrapper.source || descriptor.scope !== wrapper.scope) {
1661
+ return false;
1662
+ }
1663
+ const fromRoot = relative(descriptor.safetyRoot, wrapper.path);
1664
+ return fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot);
1665
+ });
1666
+ const safetyRoot = pluginDirectory?.safetyRoot ?? config?.safetyRoot;
1667
+ if (!safetyRoot) {
1668
+ throw new Error(`${action.path}: wrapper path is outside inventory`);
1669
+ }
1670
+ const expectedStagingPath = join(wrapperRecoveryRoot(paths, wrapper), journal.runId, sha256(action.path).slice(0, 12), basename(action.path));
1671
+ if (action.stagingPath !== expectedStagingPath) {
1672
+ throw new Error(`${action.path}: wrapper staging path does not match journal`);
1673
+ }
1674
+ return { wrapper, safetyRoot };
1675
+ }
1676
+ function journalCacheArtifact(paths, journal, action) {
1677
+ if (typeof action.specifier !== "string" || action.specifier !== FLOW_PACKAGE_NAME && !action.specifier.startsWith(`${FLOW_PACKAGE_NAME}@`) || action.specifier.includes("/") || action.specifier.includes("\\") || typeof action.resolvedVersion !== "string" || !isExactFlowVersion(action.resolvedVersion) || action.path !== join(paths.packageCacheRoot, action.specifier)) {
1678
+ throw new Error(`${action.path}: cache recovery metadata is incomplete`);
1679
+ }
1680
+ const expectedStagingPath = join(paths.cacheRecoveryRoot, journal.runId, sha256(action.path).slice(0, 12), basename(action.path));
1681
+ if (action.stagingPath !== expectedStagingPath) {
1682
+ throw new Error(`${action.path}: cache staging path does not match journal`);
1683
+ }
1684
+ return {
1685
+ path: action.path,
1686
+ specifier: action.specifier,
1687
+ resolvedVersion: action.resolvedVersion,
1688
+ status: "inactive"
1689
+ };
1690
+ }
1691
+ async function rollbackInterruptedConfigAction(options) {
1692
+ const { action, journal, journalPath, paths } = options;
1693
+ const descriptor = journalConfigDescriptor(paths, action);
1694
+ await assertSafeMutationPath(descriptor.safetyRoot, action.path);
1695
+ const currentMetadata = await optionalLstat(action.path);
1696
+ if (currentMetadata?.isSymbolicLink() || currentMetadata && !currentMetadata.isFile()) {
1697
+ throw new Error(`${action.path}: interrupted config is not a regular file`);
1698
+ }
1699
+ const current = currentMetadata ? await readRegularFileWithoutFollowing(action.path) : null;
1700
+ if (action.originalAbsent === true) {
1701
+ const expectedRecoveryPath = join(dirname(action.path), ".flow-activation-recovery", journal.runId, `${basename(action.path)}-${sha256(action.path).slice(0, 12)}`);
1702
+ if (action.recoveryPath !== expectedRecoveryPath) {
1703
+ throw new Error(`${action.path}: created-config recovery path is invalid`);
1704
+ }
1705
+ if (current === null)
1706
+ return;
1707
+ if (!action.appliedDigest || sha256(current) !== action.appliedDigest) {
1708
+ throw new Error(`${action.path}: interrupted created config changed; automatic recovery refused`);
1709
+ }
1710
+ await assertSafeMutationPath(descriptor.safetyRoot, action.recoveryPath);
1711
+ if (await optionalLstat(action.recoveryPath)) {
1712
+ throw new Error(`${action.path}: created-config recovery path is occupied`);
1713
+ }
1714
+ await mkdir(dirname(action.recoveryPath), {
1715
+ recursive: true,
1716
+ mode: 448
1717
+ });
1718
+ await rename(action.path, action.recoveryPath);
1719
+ return;
1720
+ }
1721
+ const expectedBackupPath = join(dirname(journalPath), "configs", `${descriptor.source}-${sha256(action.path).slice(0, 12)}.backup`);
1722
+ if (action.backupPath !== expectedBackupPath) {
1723
+ throw new Error(`${action.path}: config backup path is invalid`);
1724
+ }
1725
+ const backup = await readRegularFileWithoutFollowing(action.backupPath, MAX_LOCAL_PLUGIN_BYTES);
1726
+ if (current === null) {
1727
+ throw new Error(`${action.path}: interrupted config is missing`);
1728
+ }
1729
+ if (sha256(current) === sha256(backup))
1730
+ return;
1731
+ if (!action.appliedDigest || sha256(current) !== action.appliedDigest) {
1732
+ throw new Error(`${action.path}: interrupted config changed; automatic recovery refused`);
1733
+ }
1734
+ await replaceKnownConfigContent({
1735
+ descriptor,
1736
+ expectedDigest: action.appliedDigest,
1737
+ content: backup,
1738
+ mode: action.originalMode ?? 384,
1739
+ runId: journal.runId
1740
+ });
1741
+ }
1742
+ async function rollbackInterruptedRemovalAction(options) {
1743
+ const { action, journal, paths } = options;
1744
+ let safetyRoot;
1745
+ let verifyStaged;
1746
+ if (action.action === "remove-wrapper") {
1747
+ const { wrapper, safetyRoot: wrapperSafetyRoot } = journalOwnedWrapper(paths, journal, action);
1748
+ safetyRoot = wrapperSafetyRoot;
1749
+ verifyStaged = () => verifyOwnedWrapper({ ...wrapper, path: action.stagingPath });
1750
+ } else {
1751
+ const artifact = journalCacheArtifact(paths, journal, action);
1752
+ safetyRoot = dirname(paths.cacheRoot);
1753
+ verifyStaged = () => verifyCacheArtifact({ ...artifact, path: action.stagingPath }, journal.target);
1754
+ }
1755
+ await assertSafeMutationPath(safetyRoot, action.path);
1756
+ await assertSafeMutationPath(safetyRoot, action.stagingPath);
1757
+ const original = await optionalLstat(action.path);
1758
+ const staged = await optionalLstat(action.stagingPath);
1759
+ if (original && staged) {
1760
+ throw new Error(`${action.path}: both original and staged artifacts exist`);
1761
+ }
1762
+ if (original) {
1763
+ if (original.isSymbolicLink()) {
1764
+ throw new Error(`${action.path}: restored artifact is symbolic`);
1765
+ }
1766
+ return;
1767
+ }
1768
+ if (!staged || staged.isSymbolicLink()) {
1769
+ throw new Error(`${action.path}: interrupted staged artifact is missing`);
1770
+ }
1771
+ await verifyStaged();
1772
+ await mkdir(dirname(action.path), { recursive: true });
1773
+ await rename(action.stagingPath, action.path);
1774
+ }
1775
+ async function rollbackInterruptedJournal(journal, journalPath, paths) {
1776
+ for (const action of journal.actions.toReversed()) {
1777
+ if (action.state === "rolled-back")
1778
+ continue;
1779
+ if (action.action === "rewrite-config") {
1780
+ await rollbackInterruptedConfigAction({
1781
+ journal,
1782
+ journalPath,
1783
+ action,
1784
+ paths
1785
+ });
1786
+ } else {
1787
+ await rollbackInterruptedRemovalAction({ journal, action, paths });
1788
+ }
1789
+ action.state = "rolled-back";
1790
+ delete action.error;
1791
+ await writeJournal(journalPath, journal);
1792
+ }
1793
+ journal.state = "rolled-back";
1794
+ delete journal.ownerPid;
1795
+ delete journal.error;
1796
+ await writeJournal(journalPath, journal);
1797
+ }
1798
+ async function finishCommittedJournalCleanup(journal, journalPath, paths) {
1799
+ const removalActions = journal.actions.filter((action) => action.action === "remove-wrapper" || action.action === "remove-cache");
1800
+ for (const action of removalActions) {
1801
+ let safetyRoot;
1802
+ let verifyStaged;
1803
+ if (action.action === "remove-wrapper") {
1804
+ const { wrapper, safetyRoot: wrapperSafetyRoot } = journalOwnedWrapper(paths, journal, action);
1805
+ safetyRoot = wrapperSafetyRoot;
1806
+ verifyStaged = () => verifyOwnedWrapper({ ...wrapper, path: action.stagingPath });
1807
+ } else {
1808
+ const artifact = journalCacheArtifact(paths, journal, action);
1809
+ safetyRoot = dirname(paths.cacheRoot);
1810
+ verifyStaged = () => verifyCacheArtifact({ ...artifact, path: action.stagingPath }, journal.target);
1811
+ }
1812
+ await assertSafeMutationPath(safetyRoot, action.path);
1813
+ await assertSafeMutationPath(safetyRoot, action.stagingPath);
1814
+ if (await optionalLstat(action.path)) {
1815
+ throw new Error(`${action.path}: obsolete original path reappeared after activation commit`);
1816
+ }
1817
+ if (await optionalLstat(action.stagingPath)) {
1818
+ await verifyStaged();
1819
+ await rm(action.stagingPath, {
1820
+ recursive: action.action === "remove-cache"
1821
+ });
1822
+ }
1823
+ if (await optionalLstat(action.stagingPath)) {
1824
+ throw new Error(`${action.path}: obsolete staged artifact still exists`);
1825
+ }
1826
+ action.deleted = true;
1827
+ await removeEmptyDirectory(dirname(action.stagingPath));
1828
+ await writeJournal(journalPath, journal);
1829
+ }
1830
+ for (const runDirectory of new Set(removalActions.map((action) => dirname(dirname(action.stagingPath))))) {
1831
+ await removeEmptyDirectory(runDirectory);
1832
+ }
1833
+ await removeEmptyDirectory(paths.cacheRecoveryRoot);
1834
+ journal.state = "complete";
1835
+ delete journal.ownerPid;
1836
+ delete journal.error;
1837
+ await writeJournal(journalPath, journal);
1838
+ }
1839
+ async function reconcileIncompleteActivationJournals(paths, pathOptions) {
1840
+ const failures = [];
1841
+ for (const entry of await readActivationJournalEntries(paths)) {
1842
+ if (entry.error) {
1843
+ failures.push(`recovery journal could not be inspected safely at ${entry.journalPath}: ${entry.error}`);
1844
+ continue;
1845
+ }
1846
+ const journal = entry.journal;
1847
+ if (TERMINAL_JOURNAL_STATES.has(journal.state))
1848
+ continue;
1849
+ if (journal.format === "flow-activation-journal-v1") {
1850
+ failures.push(`${entry.journalPath}: legacy activation recovery is incomplete in state ${journal.state}; follow its manual recovery guidance before retrying`);
1851
+ continue;
1852
+ }
1853
+ if (journal.ownerPid !== undefined && processIsAlive(journal.ownerPid)) {
1854
+ failures.push(`${entry.journalPath}: activation is still owned by running process ${journal.ownerPid}`);
1855
+ continue;
1856
+ }
1857
+ if (journal.state === "rollback-failed") {
1858
+ failures.push(`${entry.journalPath}: previous rollback failed; follow its manual recovery guidance before retrying`);
1859
+ continue;
1860
+ }
1861
+ const journalPaths = resolveActivationPaths(journal.project, pathOptions);
1862
+ if (journalPaths.journalRoot !== paths.journalRoot) {
1863
+ failures.push(`${entry.journalPath}: journal resolves to a different recovery root`);
1864
+ continue;
1865
+ }
1866
+ try {
1867
+ if (journal.state === "committed" || journal.state === "cleanup-failed") {
1868
+ await finishCommittedJournalCleanup(journal, entry.journalPath, journalPaths);
1869
+ } else {
1870
+ await rollbackInterruptedJournal(journal, entry.journalPath, journalPaths);
1871
+ }
1872
+ } catch (error) {
1873
+ journal.state = journal.state === "committed" || journal.state === "cleanup-failed" ? "cleanup-failed" : "rollback-failed";
1874
+ journal.error = error instanceof Error ? error.message : String(error);
1875
+ delete journal.ownerPid;
1876
+ try {
1877
+ await writeJournal(entry.journalPath, journal);
1878
+ } catch {}
1879
+ failures.push(`${entry.journalPath}: ${journal.error}`);
1880
+ }
1881
+ }
1882
+ return failures;
1883
+ }
1344
1884
  async function applyFlowActivation(options) {
1345
1885
  const target = resolveActivationTarget(options.target);
1886
+ const paths = resolveActivationPaths(options.project, options.paths);
1887
+ const recoveryRefusals = options.apply === true ? await reconcileIncompleteActivationJournals(paths, options.paths) : [];
1346
1888
  const before = await checkFlowActivation({
1347
1889
  project: options.project,
1348
1890
  target,
1349
1891
  ...options.paths ? { paths: options.paths } : {}
1350
1892
  });
1351
- const refusals = activationRefusals(before);
1893
+ const refusals = [
1894
+ ...recoveryRefusals,
1895
+ ...activationRefusals(before),
1896
+ ...downgradeRefusals(before)
1897
+ ];
1352
1898
  const snapshots = [];
1353
1899
  for (const descriptor of configDescriptors(before.paths)) {
1354
1900
  try {
@@ -1375,7 +1921,7 @@ async function applyFlowActivation(options) {
1375
1921
  await assertSafeMutationPath(safetyRoot, wrapper.path);
1376
1922
  await assertSafeMutationPath(safetyRoot, recoveryRoot);
1377
1923
  if (!await pathCanBeMoved(wrapper.path, recoveryRoot)) {
1378
- throw new Error(`${wrapper.path}: marker-owned wrapper or recovery parent is not writable; archive it manually and rerun activation-apply`);
1924
+ throw new Error(`${wrapper.path}: removable wrapper or staging parent is not writable; remove it manually and rerun activation-apply`);
1379
1925
  }
1380
1926
  wrappers.push(wrapper);
1381
1927
  } catch (error) {
@@ -1444,17 +1990,17 @@ async function applyFlowActivation(options) {
1444
1990
  const canonicalSnapshot = snapshots.find((snapshot) => snapshot.descriptor.path === canonicalPath);
1445
1991
  for (const wrapper of wrappers) {
1446
1992
  plan.push({
1447
- action: "quarantine-wrapper",
1993
+ action: "remove-wrapper",
1448
1994
  scope: wrapper.scope,
1449
1995
  path: wrapper.path,
1450
- detail: "move marker-proven wrapper outside OpenCode plugin discovery"
1996
+ detail: "permanently remove the proven Flow wrapper after reversible staging and activation verification"
1451
1997
  });
1452
1998
  }
1453
1999
  for (const artifact of inactiveCache) {
1454
2000
  plan.push({
1455
- action: "quarantine-cache",
2001
+ action: "remove-cache",
1456
2002
  path: artifact.path,
1457
- detail: `move proven inactive Flow ${artifact.resolvedVersion} cache artifact; never clear the cache root`
2003
+ detail: `permanently remove proven inactive Flow ${artifact.resolvedVersion} after reversible staging; preserve the cache root and unrelated packages`
1458
2004
  });
1459
2005
  }
1460
2006
  const base = {
@@ -1480,6 +2026,8 @@ async function applyFlowActivation(options) {
1480
2026
  const action = {
1481
2027
  action: "rewrite-config",
1482
2028
  path: snapshot.descriptor.path,
2029
+ source: snapshot.descriptor.source,
2030
+ scope: snapshot.descriptor.scope,
1483
2031
  originalAbsent: !snapshot.exists,
1484
2032
  originalMode: snapshot.mode,
1485
2033
  state: "pending"
@@ -1493,24 +2041,31 @@ async function applyFlowActivation(options) {
1493
2041
  }
1494
2042
  for (const wrapper of wrappers) {
1495
2043
  actions.push({
1496
- action: "quarantine-wrapper",
2044
+ action: "remove-wrapper",
1497
2045
  path: wrapper.path,
1498
- recoveryPath: join(wrapperRecoveryRoot(before.paths, wrapper), runId, `${basename(wrapper.path)}-${sha256(wrapper.path).slice(0, 12)}`),
2046
+ source: wrapper.source,
2047
+ scope: wrapper.scope,
2048
+ resolvedVersion: wrapper.version,
2049
+ ownership: wrapper.ownership,
2050
+ stagingPath: join(wrapperRecoveryRoot(before.paths, wrapper), runId, sha256(wrapper.path).slice(0, 12), basename(wrapper.path)),
1499
2051
  state: "pending"
1500
2052
  });
1501
2053
  }
1502
2054
  for (const artifact of inactiveCache) {
1503
2055
  actions.push({
1504
- action: "quarantine-cache",
2056
+ action: "remove-cache",
1505
2057
  path: artifact.path,
1506
- recoveryPath: join(before.paths.cacheRecoveryRoot, runId, `${basename(artifact.path)}-${sha256(artifact.path).slice(0, 12)}`),
2058
+ ...artifact.resolvedVersion ? { resolvedVersion: artifact.resolvedVersion } : {},
2059
+ specifier: artifact.specifier,
2060
+ stagingPath: join(before.paths.cacheRecoveryRoot, runId, sha256(artifact.path).slice(0, 12), basename(artifact.path)),
1507
2061
  state: "pending"
1508
2062
  });
1509
2063
  }
1510
2064
  const journal = {
1511
- format: "flow-activation-journal-v1",
2065
+ format: "flow-activation-journal-v2",
1512
2066
  runId,
1513
2067
  createdAt: new Date().toISOString(),
2068
+ ownerPid: process.pid,
1514
2069
  project: before.project,
1515
2070
  target,
1516
2071
  scope: options.scope,
@@ -1529,6 +2084,8 @@ async function applyFlowActivation(options) {
1529
2084
  refusals: [...new Set([...base.refusals, message])]
1530
2085
  };
1531
2086
  }
2087
+ let removalCommitStarted = false;
2088
+ let committedAfter;
1532
2089
  try {
1533
2090
  for (const snapshot of changedSnapshots) {
1534
2091
  await assertUnchangedConfig(snapshot);
@@ -1560,68 +2117,116 @@ async function applyFlowActivation(options) {
1560
2117
  for (const snapshot of nonTargetConfigs) {
1561
2118
  const entries = nextEntries.get(snapshot.descriptor.path) ?? [];
1562
2119
  const content = updatedConfigContent(snapshot, entries);
1563
- await atomicWriteConfig(snapshot, content, runId);
1564
2120
  const action = actions.find((candidate) => candidate.action === "rewrite-config" && candidate.path === snapshot.descriptor.path);
1565
2121
  if (action) {
1566
2122
  action.appliedDigest = sha256(content);
1567
- action.state = "complete";
1568
2123
  }
1569
2124
  await writeJournal(journalPath, journal);
2125
+ await atomicWriteConfig(snapshot, content, runId);
2126
+ if (action)
2127
+ action.state = "complete";
2128
+ await writeJournal(journalPath, journal);
1570
2129
  await options.afterMutation?.(plan.find((operation) => operation.action === "rewrite-config" && operation.path === snapshot.descriptor.path));
1571
2130
  }
1572
2131
  for (const wrapper of wrappers) {
1573
- const action = actions.find((candidate) => candidate.action === "quarantine-wrapper" && candidate.path === wrapper.path);
1574
- if (!action?.recoveryPath) {
1575
- throw new Error(`${wrapper.path}: missing wrapper recovery path`);
2132
+ const action = actions.find((candidate) => candidate.action === "remove-wrapper" && candidate.path === wrapper.path);
2133
+ if (!action?.stagingPath) {
2134
+ throw new Error(`${wrapper.path}: missing wrapper staging path`);
1576
2135
  }
1577
2136
  await verifyOwnedWrapper(wrapper);
1578
2137
  await assertSafeMutationPath(wrapperMutationSafetyRoot(before.paths, wrapper), wrapper.path);
1579
- await mkdir(dirname(action.recoveryPath), {
2138
+ await assertSafeMutationPath(wrapperMutationSafetyRoot(before.paths, wrapper), action.stagingPath);
2139
+ await mkdir(dirname(action.stagingPath), {
1580
2140
  recursive: true,
1581
2141
  mode: 448
1582
2142
  });
1583
- await rename(wrapper.path, action.recoveryPath);
2143
+ await rename(wrapper.path, action.stagingPath);
1584
2144
  action.state = "complete";
1585
2145
  await writeJournal(journalPath, journal);
1586
- await options.afterMutation?.(plan.find((operation) => operation.action === "quarantine-wrapper" && operation.path === wrapper.path));
2146
+ await options.afterMutation?.(plan.find((operation) => operation.action === "remove-wrapper" && operation.path === wrapper.path));
1587
2147
  }
1588
2148
  for (const artifact of inactiveCache) {
1589
- const action = actions.find((candidate) => candidate.action === "quarantine-cache" && candidate.path === artifact.path);
1590
- if (!action?.recoveryPath) {
1591
- throw new Error(`${artifact.path}: missing cache recovery path`);
2149
+ const action = actions.find((candidate) => candidate.action === "remove-cache" && candidate.path === artifact.path);
2150
+ if (!action?.stagingPath) {
2151
+ throw new Error(`${artifact.path}: missing cache staging path`);
1592
2152
  }
1593
2153
  await verifyCacheArtifact(artifact, target);
1594
2154
  await assertSafeMutationPath(dirname(before.paths.cacheRoot), artifact.path);
1595
- await mkdir(dirname(action.recoveryPath), {
2155
+ await assertSafeMutationPath(dirname(before.paths.cacheRoot), action.stagingPath);
2156
+ await mkdir(dirname(action.stagingPath), {
1596
2157
  recursive: true,
1597
2158
  mode: 448
1598
2159
  });
1599
- await rename(artifact.path, action.recoveryPath);
2160
+ await rename(artifact.path, action.stagingPath);
1600
2161
  action.state = "complete";
1601
2162
  await writeJournal(journalPath, journal);
1602
- await options.afterMutation?.(plan.find((operation) => operation.action === "quarantine-cache" && operation.path === artifact.path));
2163
+ await options.afterMutation?.(plan.find((operation) => operation.action === "remove-cache" && operation.path === artifact.path));
1603
2164
  }
1604
2165
  if (targetConfig) {
1605
2166
  const targetEntries = nextEntries.get(targetConfig.descriptor.path) ?? [];
1606
2167
  const targetContent = updatedConfigContent(targetConfig, targetEntries);
1607
- await atomicWriteConfig(targetConfig, targetContent, runId);
1608
2168
  const targetAction = actions.find((candidate) => candidate.action === "rewrite-config" && candidate.path === targetConfig.descriptor.path);
1609
2169
  if (targetAction) {
1610
2170
  targetAction.appliedDigest = sha256(targetContent);
1611
- targetAction.state = "complete";
1612
2171
  }
1613
2172
  await writeJournal(journalPath, journal);
2173
+ await atomicWriteConfig(targetConfig, targetContent, runId);
2174
+ if (targetAction)
2175
+ targetAction.state = "complete";
2176
+ await writeJournal(journalPath, journal);
1614
2177
  await options.afterMutation?.(plan.find((operation) => operation.action === "rewrite-config" && operation.path === targetConfig.descriptor.path));
1615
2178
  }
1616
2179
  const after = await checkFlowActivation({
1617
2180
  project: before.project,
1618
2181
  target,
2182
+ ignoreRecoveryRunId: runId,
1619
2183
  ...options.paths ? { paths: options.paths } : {}
1620
2184
  });
1621
2185
  if (!after.singleVersionSatisfied) {
1622
2186
  throw new Error(`post-apply inventory did not prove a single version: ${after.reasons.join("; ")}`);
1623
2187
  }
2188
+ committedAfter = after;
2189
+ for (const wrapper of wrappers) {
2190
+ const action = actions.find((candidate) => candidate.action === "remove-wrapper" && candidate.path === wrapper.path);
2191
+ if (!action?.stagingPath) {
2192
+ throw new Error(`${wrapper.path}: missing staged wrapper at commit`);
2193
+ }
2194
+ await verifyOwnedWrapper({ ...wrapper, path: action.stagingPath });
2195
+ }
2196
+ for (const artifact of inactiveCache) {
2197
+ const action = actions.find((candidate) => candidate.action === "remove-cache" && candidate.path === artifact.path);
2198
+ if (!action?.stagingPath) {
2199
+ throw new Error(`${artifact.path}: missing staged cache at commit`);
2200
+ }
2201
+ await verifyCacheArtifact({ ...artifact, path: action.stagingPath }, target);
2202
+ }
2203
+ const removalActions = actions.filter((candidate) => candidate.action === "remove-wrapper" || candidate.action === "remove-cache");
2204
+ if (removalActions.length > 0) {
2205
+ journal.state = "committed";
2206
+ await writeJournal(journalPath, journal);
2207
+ removalCommitStarted = true;
2208
+ await options.afterRemovalCommit?.();
2209
+ }
2210
+ for (const action of removalActions) {
2211
+ if (!action.stagingPath) {
2212
+ throw new Error(`${action.path}: missing staging path at deletion`);
2213
+ }
2214
+ await rm(action.stagingPath, {
2215
+ recursive: action.action === "remove-cache"
2216
+ });
2217
+ if (await optionalLstat(action.stagingPath)) {
2218
+ throw new Error(`${action.path}: staged obsolete artifact still exists`);
2219
+ }
2220
+ action.deleted = true;
2221
+ await removeEmptyDirectory(dirname(action.stagingPath));
2222
+ await writeJournal(journalPath, journal);
2223
+ }
2224
+ for (const runDirectory of new Set(removalActions.flatMap((action) => action.stagingPath ? [dirname(dirname(action.stagingPath))] : []))) {
2225
+ await removeEmptyDirectory(runDirectory);
2226
+ }
2227
+ await removeEmptyDirectory(before.paths.cacheRecoveryRoot);
1624
2228
  journal.state = "complete";
2229
+ delete journal.ownerPid;
1625
2230
  await writeJournal(journalPath, journal);
1626
2231
  return {
1627
2232
  ...base,
@@ -1631,6 +2236,29 @@ async function applyFlowActivation(options) {
1631
2236
  refusals: []
1632
2237
  };
1633
2238
  } catch (error) {
2239
+ if (removalCommitStarted) {
2240
+ journal.state = "cleanup-failed";
2241
+ delete journal.ownerPid;
2242
+ journal.error = error instanceof Error ? error.message : String(error);
2243
+ try {
2244
+ await writeJournal(journalPath, journal);
2245
+ } catch {}
2246
+ return {
2247
+ ...base,
2248
+ status: "refused",
2249
+ recovery: { runId, journalPath },
2250
+ ...committedAfter ? { after: committedAfter } : {},
2251
+ failure: {
2252
+ message: journal.error,
2253
+ recoveryState: "cleanup-failed",
2254
+ guidance: [
2255
+ "The newest Flow activation is committed and remains authoritative; do not restore an older config or plugin source.",
2256
+ `Inspect ${journalPath} and permanently delete each remaining remove-wrapper or remove-cache stagingPath after verifying it still contains only the recorded obsolete Flow version.`
2257
+ ]
2258
+ },
2259
+ refusals: [journal.error]
2260
+ };
2261
+ }
1634
2262
  journal.state = "failed";
1635
2263
  journal.error = error instanceof Error ? error.message : String(error);
1636
2264
  try {
@@ -1646,6 +2274,7 @@ async function applyFlowActivation(options) {
1646
2274
  });
1647
2275
  const recoveryState = rollbackFailures.length === 0 ? "rolled-back" : "rollback-failed";
1648
2276
  journal.state = recoveryState;
2277
+ delete journal.ownerPid;
1649
2278
  if (rollbackFailures.length > 0) {
1650
2279
  journal.error = `${journal.error ?? "apply failed"}; rollback: ${rollbackFailures.join("; ")}`;
1651
2280
  }
@@ -1653,11 +2282,11 @@ async function applyFlowActivation(options) {
1653
2282
  await writeJournal(journalPath, journal);
1654
2283
  } catch {}
1655
2284
  const guidance = recoveryState === "rolled-back" ? [
1656
- "All completed mutations were restored from exact backups or quarantine renames.",
2285
+ "All completed mutations were restored from exact backups or reversible staging moves.",
1657
2286
  `Inspect ${journalPath} and resolve the recorded failure before retrying.`
1658
2287
  ] : [
1659
2288
  "Stop OpenCode before manual recovery.",
1660
- `Inspect ${journalPath}; for rollback-failed actions, restore backupPath to path or rename recoveryPath back to path only after verifying the destination is absent or unchanged.`,
2289
+ `Inspect ${journalPath}; for rollback-failed actions, restore backupPath to path or rename stagingPath back to path only after verifying the destination is absent or unchanged.`,
1661
2290
  "Do not delete the recovery directory until activation-check succeeds."
1662
2291
  ];
1663
2292
  return {
@@ -1706,7 +2335,7 @@ var NO_FOLLOW2 = constants2.O_NOFOLLOW ?? 0;
1706
2335
  var SUPPORTED_LEGACY_MAJOR = "4";
1707
2336
  var POST_MOVE_VERIFICATION_ATTEMPTS = 4;
1708
2337
  var POST_MOVE_VERIFICATION_RETRY_MS = 25;
1709
- var SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
2338
+ var SEMVER_PATTERN2 = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
1710
2339
  function configuredHome2() {
1711
2340
  return process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || homedir2();
1712
2341
  }
@@ -1799,7 +2428,7 @@ function parseMarker(content) {
1799
2428
  return { version, files };
1800
2429
  }
1801
2430
  function assertSupportedLegacyVersion(version) {
1802
- const match = SEMVER_PATTERN.exec(version);
2431
+ const match = SEMVER_PATTERN2.exec(version);
1803
2432
  if (!match) {
1804
2433
  throw new Error(`marker version '${version}' is not a valid semantic version`);
1805
2434
  }
@@ -1983,17 +2612,19 @@ async function cleanupLegacySkills(options) {
1983
2612
  function usage() {
1984
2613
  return [
1985
2614
  "usage:",
2615
+ " opencode-plugin-flow install --project <absolute-path> --scope <global|project> [--json]",
1986
2616
  " opencode-plugin-flow activation-check --project <absolute-path> [--target <exact-version>] [--json]",
1987
2617
  " opencode-plugin-flow activation-apply --project <absolute-path> --scope <global|project> [--target <exact-version>] [--apply] [--json]",
1988
2618
  " opencode-plugin-flow legacy-cleanup <--dry-run|--apply> [--json]",
1989
2619
  "",
1990
2620
  "commands:",
1991
- " activation-check Inventory all OpenCode Flow activation sources and cache artifacts",
2621
+ " install Converge immediately to this package's exact version and remove proven older copies",
2622
+ " activation-check Inventory global sources, one selected project, and cache artifacts",
1992
2623
  " activation-apply Plan a single-version activation; mutate only with --apply",
1993
2624
  " legacy-cleanup Inspect or archive marker-proven legacy global Flow skills",
1994
2625
  "",
1995
2626
  "activation options:",
1996
- " --project <path> Absolute project/worktree path whose sources are inventoried",
2627
+ " --project <path> Absolute project/worktree path; other project trees are not scanned",
1997
2628
  " --scope <scope> Config that receives the one canonical exact npm pin",
1998
2629
  " --target <version> Exact version only; defaults to this package's embedded version",
1999
2630
  " --apply Create backups/journal and apply the activation plan",
@@ -2045,6 +2676,8 @@ function writeActivationCheck(report, json) {
2045
2676
  process.stdout.write(`Flow activation check: ${report.singleVersionSatisfied ? "satisfied" : "not satisfied"}
2046
2677
  `);
2047
2678
  process.stdout.write(`- project: ${report.project}
2679
+ `);
2680
+ process.stdout.write(`- coverage: global sources plus the selected project; other project trees are not scanned
2048
2681
  `);
2049
2682
  process.stdout.write(`- target: opencode-plugin-flow@${report.target}
2050
2683
  `);
@@ -2085,18 +2718,25 @@ function writeActivationApply(report, json) {
2085
2718
  process.stdout.write(`Flow activation ${report.mode}: ${report.status}
2086
2719
  `);
2087
2720
  process.stdout.write(`- project: ${report.project}
2721
+ `);
2722
+ process.stdout.write(`- coverage: global sources plus the selected project; other project trees are not scanned
2088
2723
  `);
2089
2724
  process.stdout.write(`- canonical scope: ${report.scope}
2090
2725
  `);
2091
2726
  process.stdout.write(`- target: opencode-plugin-flow@${report.target}
2092
2727
  `);
2093
- for (const operation of report.plan) {
2094
- process.stdout.write(`- ${operation.action}: ${operation.path}
2095
- ${operation.detail}
2096
- `);
2097
- }
2098
2728
  for (const refusal of report.refusals) {
2099
2729
  process.stdout.write(`- refused: ${refusal}
2730
+ `);
2731
+ }
2732
+ if (report.status === "refused" && report.plan.length > 0) {
2733
+ process.stdout.write(`- blocked plan (not executed):
2734
+ `);
2735
+ }
2736
+ for (const operation of report.plan) {
2737
+ const action = report.status === "refused" ? `would-${operation.action}` : operation.action;
2738
+ process.stdout.write(`- ${action}: ${operation.path}
2739
+ ${operation.detail}
2100
2740
  `);
2101
2741
  }
2102
2742
  if (report.recovery) {
@@ -2204,6 +2844,29 @@ async function runActivationApply(flags) {
2204
2844
  if (report.status === "refused")
2205
2845
  process.exitCode = 1;
2206
2846
  }
2847
+ async function runInstall(flags) {
2848
+ const parsed = parseActivationFlags(flags);
2849
+ const scope = activationScope(parsed?.scope);
2850
+ if (!parsed || parsed.apply || parsed.target || !parsed.project && !parsed.help || !scope && !parsed.help) {
2851
+ process.stderr.write(`${usage()}
2852
+ `);
2853
+ process.exitCode = 2;
2854
+ return;
2855
+ }
2856
+ if (parsed.help) {
2857
+ process.stdout.write(`${usage()}
2858
+ `);
2859
+ return;
2860
+ }
2861
+ const report = await applyFlowActivation({
2862
+ project: parsed.project,
2863
+ scope,
2864
+ apply: true
2865
+ });
2866
+ writeActivationApply(report, parsed.json);
2867
+ if (report.status === "refused")
2868
+ process.exitCode = 1;
2869
+ }
2207
2870
  async function runLegacyCleanup(flags) {
2208
2871
  const knownFlags = new Set(["--dry-run", "--apply", "--json"]);
2209
2872
  const validFlags = flags.every((flag) => knownFlags.has(flag));
@@ -2237,6 +2900,10 @@ async function main(argv) {
2237
2900
  await runActivationCheck(flags);
2238
2901
  return;
2239
2902
  }
2903
+ if (command === "install") {
2904
+ await runInstall(flags);
2905
+ return;
2906
+ }
2240
2907
  if (command === "activation-apply") {
2241
2908
  await runActivationApply(flags);
2242
2909
  return;
@@ -2255,4 +2922,4 @@ main(process.argv).catch((error) => {
2255
2922
  process.exitCode = 1;
2256
2923
  });
2257
2924
 
2258
- //# debugId=963853A3888CECEA64756E2164756E21
2925
+ //# debugId=C55B4CE51FD9A58B64756E2164756E21