impel-cli 0.18.15-beta.4 → 0.18.15-beta.5

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/apps.js +120 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.18.15-beta.4",
3
+ "version": "0.18.15-beta.5",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -37,6 +37,17 @@ const APP_DEFINITIONS = {
37
37
  executableNames: ["ChatGPT", "Codex"],
38
38
  },
39
39
  };
40
+ const BUNDLE_ARTIFACT_REMOVE_OPTIONS = Object.freeze({
41
+ recursive: true,
42
+ force: true,
43
+ maxRetries: 5,
44
+ retryDelay: 25,
45
+ });
46
+ const BUNDLE_LOCK_WAIT_MS = 120_000;
47
+ const BUNDLE_LOCK_POLL_MS = 100;
48
+ const BUNDLE_LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(4));
49
+ const MACOS_O_EXLOCK = 0x00000020;
50
+ const portableBundleMutationLocks = new Set();
40
51
 
41
52
  // Vendor updates can change minified renderer contracts without notice. Each
42
53
  // CLI release therefore installs and accepts only the exact app builds tested
@@ -972,7 +983,6 @@ function installPinnedVendorApp(target, homeDir) {
972
983
  homeDir, ".config", RUNTIME_BRAND.cli.configNamespace, "vendor", target, pin.version, pin.bundleName,
973
984
  );
974
985
  fs.mkdirSync(path.dirname(destination), { recursive: true });
975
- sweepStaleBundleArtifacts(path.dirname(destination), path.basename(destination));
976
986
  replaceDirectory(destination, extractedApp);
977
987
  return destination;
978
988
  } finally {
@@ -1529,6 +1539,12 @@ exec "$NODE" "$CLI" token${tenantArgs}
1529
1539
  }
1530
1540
 
1531
1541
  function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageName) {
1542
+ return withBundleMutationLock(paths.claude.launcher, () => (
1543
+ writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStorageName)
1544
+ ));
1545
+ }
1546
+
1547
+ function writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStorageName) {
1532
1548
  if (!vendorPath) throw new Error("Claude vendor app is unavailable");
1533
1549
  if (!isVendableVendorApp("claude", vendorPath)) {
1534
1550
  const bundleId = readBundleIdentifier(vendorPath);
@@ -1542,9 +1558,8 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1542
1558
  if (!executableName) throw new Error(`Claude executable is missing from ${vendorPath}`);
1543
1559
 
1544
1560
  const bundle = paths.claude.launcher;
1545
- const staging = `${bundle}.tmp-${process.pid}`;
1546
1561
  sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
1547
- fs.rmSync(staging, { recursive: true, force: true });
1562
+ const staging = uniqueBundleArtifactPath(bundle, "tmp");
1548
1563
  cloneAppBundle(vendorPath, staging);
1549
1564
  try {
1550
1565
  const plistPath = path.join(staging, "Contents", "Info.plist");
@@ -1626,9 +1641,9 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1626
1641
  }, null, 2) + "\n", 0o644);
1627
1642
 
1628
1643
  signVendoredApp(staging, vendorExecutable, "Claude", signingIdentity);
1629
- replaceDirectory(bundle, staging);
1644
+ replaceDirectoryUnlocked(bundle, staging);
1630
1645
  } catch (error) {
1631
- fs.rmSync(staging, { recursive: true, force: true });
1646
+ removeBundleArtifact(staging);
1632
1647
  throw error;
1633
1648
  }
1634
1649
  }
@@ -1642,6 +1657,12 @@ function claudeUsageCompatibilityEnabled(gatewayUrl) {
1642
1657
  }
1643
1658
 
1644
1659
  function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1660
+ return withBundleMutationLock(paths.chatgpt.launcher, () => (
1661
+ writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir)
1662
+ ));
1663
+ }
1664
+
1665
+ function writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir) {
1645
1666
  if (!vendorPath) throw new Error("ChatGPT/Codex vendor app is unavailable");
1646
1667
  const definition = APP_DEFINITIONS.chatgpt;
1647
1668
  const executableName = definition.executableNames.find((name) => (
@@ -1650,11 +1671,10 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1650
1671
  if (!executableName) throw new Error(`ChatGPT/Codex executable is missing from ${vendorPath}`);
1651
1672
 
1652
1673
  const bundle = paths.chatgpt.launcher;
1653
- const staging = `${bundle}.tmp-${process.pid}`;
1674
+ sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
1675
+ const staging = uniqueBundleArtifactPath(bundle, "tmp");
1654
1676
  const vendorBundleName = path.basename(vendorPath);
1655
1677
  const vendorBundle = path.join(staging, "Contents", "Resources", vendorBundleName);
1656
- sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
1657
- fs.rmSync(staging, { recursive: true, force: true });
1658
1678
  cloneAppBundle(vendorPath, vendorBundle);
1659
1679
  try {
1660
1680
  const asarPath = path.join(vendorBundle, "Contents", "Resources", "app.asar");
@@ -1730,9 +1750,9 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1730
1750
  }, null, 2) + "\n", 0o644);
1731
1751
 
1732
1752
  signVendoredApp(staging, vendorExecutable, "ChatGPT", signingIdentity);
1733
- replaceDirectory(bundle, staging);
1753
+ replaceDirectoryUnlocked(bundle, staging);
1734
1754
  } catch (error) {
1735
- fs.rmSync(staging, { recursive: true, force: true });
1755
+ removeBundleArtifact(staging);
1736
1756
  throw error;
1737
1757
  }
1738
1758
  }
@@ -2536,24 +2556,105 @@ export function sweepStaleBundleArtifacts(dir, baseName) {
2536
2556
  throw error;
2537
2557
  }
2538
2558
  const prefixes = [`${baseName}.tmp-`, `${baseName}.previous-`];
2539
- const removed = entries.filter((entry) => prefixes.some((prefix) => entry.startsWith(prefix)));
2540
- for (const entry of removed) {
2541
- fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
2559
+ const removed = [];
2560
+ for (const entry of entries.filter((candidate) => prefixes.some((prefix) => candidate.startsWith(prefix)))) {
2561
+ if (removeBundleArtifact(path.join(dir, entry))) removed.push(entry);
2542
2562
  }
2543
2563
  return removed;
2544
2564
  }
2545
2565
 
2546
- function replaceDirectory(destination, staging) {
2547
- const backup = `${destination}.previous-${process.pid}`;
2548
- fs.rmSync(backup, { recursive: true, force: true });
2549
- if (fs.existsSync(destination)) fs.renameSync(destination, backup);
2566
+ function uniqueBundleArtifactPath(destination, kind) {
2567
+ return `${destination}.${kind}-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
2568
+ }
2569
+
2570
+ function removeBundleArtifact(candidate) {
2571
+ try {
2572
+ fs.rmSync(candidate, BUNDLE_ARTIFACT_REMOVE_OPTIONS);
2573
+ return !fs.existsSync(candidate);
2574
+ } catch {
2575
+ return false;
2576
+ }
2577
+ }
2578
+
2579
+ export function withBundleMutationLock(destination, run) {
2580
+ const lockPath = path.join(
2581
+ path.dirname(destination),
2582
+ `.${path.basename(destination)}.impel-install.lock`,
2583
+ );
2584
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
2585
+
2586
+ if (process.platform !== "darwin") {
2587
+ if (portableBundleMutationLocks.has(lockPath)) {
2588
+ throw new Error(`another Impel app update is already replacing ${path.basename(destination)}`);
2589
+ }
2590
+ const descriptor = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_RDWR, 0o600);
2591
+ portableBundleMutationLocks.add(lockPath);
2592
+ try {
2593
+ return run();
2594
+ } finally {
2595
+ portableBundleMutationLocks.delete(lockPath);
2596
+ fs.closeSync(descriptor);
2597
+ }
2598
+ }
2599
+
2600
+ const deadline = Date.now() + BUNDLE_LOCK_WAIT_MS;
2601
+ let descriptor;
2602
+ while (descriptor === undefined) {
2603
+ try {
2604
+ descriptor = fs.openSync(
2605
+ lockPath,
2606
+ fs.constants.O_CREAT | fs.constants.O_RDWR | fs.constants.O_NONBLOCK | MACOS_O_EXLOCK,
2607
+ 0o600,
2608
+ );
2609
+ } catch (error) {
2610
+ if (!["EAGAIN", "EWOULDBLOCK"].includes(error?.code)) throw error;
2611
+ if (Date.now() >= deadline) {
2612
+ throw new Error(`timed out waiting for another Impel app update to replace ${path.basename(destination)}`);
2613
+ }
2614
+ Atomics.wait(BUNDLE_LOCK_SLEEP, 0, 0, BUNDLE_LOCK_POLL_MS);
2615
+ }
2616
+ }
2617
+ try {
2618
+ return run();
2619
+ } finally {
2620
+ fs.closeSync(descriptor);
2621
+ }
2622
+ }
2623
+
2624
+ function replaceDirectoryUnlocked(destination, staging, {
2625
+ removeArtifact = removeBundleArtifact,
2626
+ } = {}) {
2627
+ const backup = fs.existsSync(destination)
2628
+ ? uniqueBundleArtifactPath(destination, "previous")
2629
+ : null;
2630
+ if (backup) fs.renameSync(destination, backup);
2550
2631
  try {
2551
2632
  fs.renameSync(staging, destination);
2552
- fs.rmSync(backup, { recursive: true, force: true });
2553
2633
  } catch (error) {
2554
- if (!fs.existsSync(destination) && fs.existsSync(backup)) fs.renameSync(backup, destination);
2634
+ if (backup && !fs.existsSync(destination) && fs.existsSync(backup)) {
2635
+ fs.renameSync(backup, destination);
2636
+ }
2555
2637
  throw error;
2556
2638
  }
2639
+
2640
+ // The new bundle is committed once staging has been renamed into place.
2641
+ // Finder, LaunchServices, or a just-closed helper can briefly keep changing
2642
+ // the old tree and make recursive removal end with ENOTEMPTY. Do not turn
2643
+ // that post-commit housekeeping race into a failed tenant update: the next
2644
+ // sweep will retry any backup that remains.
2645
+ if (backup) {
2646
+ try {
2647
+ removeArtifact(backup);
2648
+ } catch {
2649
+ // Cleanup is best-effort after the committed rename.
2650
+ }
2651
+ }
2652
+ }
2653
+
2654
+ export function replaceDirectory(destination, staging, options = {}) {
2655
+ return withBundleMutationLock(destination, () => (
2656
+ replaceDirectoryUnlocked(destination, staging, options)
2657
+ ));
2557
2658
  }
2558
2659
 
2559
2660
  function isGatewayModel(model) {