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

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 +154 -23
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.6",
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
@@ -246,7 +257,10 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
246
257
  // and the tenant manifest so unsupported hosts keep Chat and Code usable.
247
258
  // 25: remove the unproven Windows Cowork capability gate from beta.0 profiles
248
259
  // and restore the reviewed always-enabled managed policy.
249
- export const CURRENT_CONFIG_VERSION = 25;
260
+ // 26: remember the managed provider defaults and move legacy GPT-5.5 profiles
261
+ // onto GPT-5.6 Sol so ChatGPT's compact Work picker does not fall back to its
262
+ // unsupported-selection "Reset to default" treatment.
263
+ export const CURRENT_CONFIG_VERSION = 26;
250
264
 
251
265
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
252
266
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -745,6 +759,7 @@ export function installManagedAppFiles({
745
759
  target,
746
760
  vendorPaths[target] || findVendorApp(target, homeDir),
747
761
  ]));
762
+ const previousManifest = readTenantManifest(homeDir, config.tenantId);
748
763
  const vendorCodexModels = targets.includes("chatgpt")
749
764
  ? readVendorCodexModels(resolvedVendorPaths.chatgpt)
750
765
  : new Map();
@@ -795,6 +810,7 @@ export function installManagedAppFiles({
795
810
  vendorCodexModels,
796
811
  chatgptInvocations,
797
812
  generatedAt,
813
+ previousManifest,
798
814
  );
799
815
  if (RUNTIME_BRAND.features.sessions) ensureCodexSessionHooks(paths.chatgpt.codexHome, config.tenantId, "codex_desktop");
800
816
  }
@@ -806,7 +822,7 @@ export function installManagedAppFiles({
806
822
  }
807
823
 
808
824
  writeAtomic(path.join(paths.tenantRoot, "manifest.json"), JSON.stringify({
809
- schemaVersion: 6,
825
+ schemaVersion: 7,
810
826
  configVersion: CURRENT_CONFIG_VERSION,
811
827
  modelCatalogVersion: 3,
812
828
  gatewayUrl: config.gatewayUrl,
@@ -819,6 +835,10 @@ export function installManagedAppFiles({
819
835
  models,
820
836
  config,
821
837
  ),
838
+ modelDefaults: {
839
+ claude: models.find((model) => model.provider === "claude" && model.default)?.id ?? null,
840
+ chatgpt: models.find((model) => model.provider === "codex" && model.default)?.id ?? null,
841
+ },
822
842
  models: models.map((model) => model.id),
823
843
  updatedAt: generatedAt,
824
844
  }, null, 2) + "\n", 0o600);
@@ -972,7 +992,6 @@ function installPinnedVendorApp(target, homeDir) {
972
992
  homeDir, ".config", RUNTIME_BRAND.cli.configNamespace, "vendor", target, pin.version, pin.bundleName,
973
993
  );
974
994
  fs.mkdirSync(path.dirname(destination), { recursive: true });
975
- sweepStaleBundleArtifacts(path.dirname(destination), path.basename(destination));
976
995
  replaceDirectory(destination, extractedApp);
977
996
  return destination;
978
997
  } finally {
@@ -1153,6 +1172,7 @@ function writeChatGPTConfig(
1153
1172
  vendorCodexModels,
1154
1173
  invocations = null,
1155
1174
  generatedAt = new Date().toISOString(),
1175
+ previousManifest = null,
1156
1176
  ) {
1157
1177
  const experimental = crossAppModelsEnabled(config);
1158
1178
  const orderedModels = experimental
@@ -1168,8 +1188,28 @@ function writeChatGPTConfig(
1168
1188
  const currentToml = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
1169
1189
  const configuredModel = readTopLevelTomlString(currentToml, "model");
1170
1190
  const defaultModelID = orderedModels.find((model) => model.provider === "codex" && model.default)?.id;
1171
- const selectedModel = codexModels.find((model) => model.slug === configuredModel)
1172
- || codexModels.find((model) => model.slug === defaultModelID)
1191
+ const defaultModel = codexModels.find((model) => model.slug === defaultModelID);
1192
+ const previousDefaultModelID = previousManifest?.modelDefaults?.chatgpt;
1193
+ const followsPreviousManagedDefault = (
1194
+ typeof previousDefaultModelID === "string"
1195
+ && configuredModel === previousDefaultModelID
1196
+ && configuredModel !== defaultModelID
1197
+ );
1198
+ // Profiles written before the default-model field existed cannot distinguish
1199
+ // an old managed default from an explicit user choice. GPT-5.5 is the one
1200
+ // observed legacy default that ChatGPT 26.727 renders as an unsupported
1201
+ // compact-picker selection; migrate it once, while preserving every other
1202
+ // explicit model choice.
1203
+ const hasLegacyCompactPickerDefault = (
1204
+ previousDefaultModelID == null
1205
+ && Number(previousManifest?.configVersion ?? 0) < CURRENT_CONFIG_VERSION
1206
+ && configuredModel === "gpt-5.5"
1207
+ && defaultModelID === "gpt-5.6-sol"
1208
+ );
1209
+ const selectedModel = (followsPreviousManagedDefault || hasLegacyCompactPickerDefault
1210
+ ? defaultModel
1211
+ : codexModels.find((model) => model.slug === configuredModel))
1212
+ || defaultModel
1173
1213
  || codexModels[0];
1174
1214
  if (!selectedModel) throw new Error("no Codex models are available for Impel ChatGPT");
1175
1215
  const configuredEffort = readTopLevelTomlString(currentToml, "model_reasoning_effort");
@@ -1529,6 +1569,12 @@ exec "$NODE" "$CLI" token${tenantArgs}
1529
1569
  }
1530
1570
 
1531
1571
  function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageName) {
1572
+ return withBundleMutationLock(paths.claude.launcher, () => (
1573
+ writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStorageName)
1574
+ ));
1575
+ }
1576
+
1577
+ function writeVendoredClaudeBundleLocked(paths, vendorPath, gatewayUrl, safeStorageName) {
1532
1578
  if (!vendorPath) throw new Error("Claude vendor app is unavailable");
1533
1579
  if (!isVendableVendorApp("claude", vendorPath)) {
1534
1580
  const bundleId = readBundleIdentifier(vendorPath);
@@ -1542,9 +1588,8 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1542
1588
  if (!executableName) throw new Error(`Claude executable is missing from ${vendorPath}`);
1543
1589
 
1544
1590
  const bundle = paths.claude.launcher;
1545
- const staging = `${bundle}.tmp-${process.pid}`;
1546
1591
  sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
1547
- fs.rmSync(staging, { recursive: true, force: true });
1592
+ const staging = uniqueBundleArtifactPath(bundle, "tmp");
1548
1593
  cloneAppBundle(vendorPath, staging);
1549
1594
  try {
1550
1595
  const plistPath = path.join(staging, "Contents", "Info.plist");
@@ -1626,9 +1671,9 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1626
1671
  }, null, 2) + "\n", 0o644);
1627
1672
 
1628
1673
  signVendoredApp(staging, vendorExecutable, "Claude", signingIdentity);
1629
- replaceDirectory(bundle, staging);
1674
+ replaceDirectoryUnlocked(bundle, staging);
1630
1675
  } catch (error) {
1631
- fs.rmSync(staging, { recursive: true, force: true });
1676
+ removeBundleArtifact(staging);
1632
1677
  throw error;
1633
1678
  }
1634
1679
  }
@@ -1642,6 +1687,12 @@ function claudeUsageCompatibilityEnabled(gatewayUrl) {
1642
1687
  }
1643
1688
 
1644
1689
  function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1690
+ return withBundleMutationLock(paths.chatgpt.launcher, () => (
1691
+ writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir)
1692
+ ));
1693
+ }
1694
+
1695
+ function writeVendoredChatGPTBundleLocked(paths, vendorPath, gatewayUrl, homeDir) {
1645
1696
  if (!vendorPath) throw new Error("ChatGPT/Codex vendor app is unavailable");
1646
1697
  const definition = APP_DEFINITIONS.chatgpt;
1647
1698
  const executableName = definition.executableNames.find((name) => (
@@ -1650,11 +1701,10 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1650
1701
  if (!executableName) throw new Error(`ChatGPT/Codex executable is missing from ${vendorPath}`);
1651
1702
 
1652
1703
  const bundle = paths.chatgpt.launcher;
1653
- const staging = `${bundle}.tmp-${process.pid}`;
1704
+ sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
1705
+ const staging = uniqueBundleArtifactPath(bundle, "tmp");
1654
1706
  const vendorBundleName = path.basename(vendorPath);
1655
1707
  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
1708
  cloneAppBundle(vendorPath, vendorBundle);
1659
1709
  try {
1660
1710
  const asarPath = path.join(vendorBundle, "Contents", "Resources", "app.asar");
@@ -1730,9 +1780,9 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1730
1780
  }, null, 2) + "\n", 0o644);
1731
1781
 
1732
1782
  signVendoredApp(staging, vendorExecutable, "ChatGPT", signingIdentity);
1733
- replaceDirectory(bundle, staging);
1783
+ replaceDirectoryUnlocked(bundle, staging);
1734
1784
  } catch (error) {
1735
- fs.rmSync(staging, { recursive: true, force: true });
1785
+ removeBundleArtifact(staging);
1736
1786
  throw error;
1737
1787
  }
1738
1788
  }
@@ -2536,24 +2586,105 @@ export function sweepStaleBundleArtifacts(dir, baseName) {
2536
2586
  throw error;
2537
2587
  }
2538
2588
  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 });
2589
+ const removed = [];
2590
+ for (const entry of entries.filter((candidate) => prefixes.some((prefix) => candidate.startsWith(prefix)))) {
2591
+ if (removeBundleArtifact(path.join(dir, entry))) removed.push(entry);
2542
2592
  }
2543
2593
  return removed;
2544
2594
  }
2545
2595
 
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);
2596
+ function uniqueBundleArtifactPath(destination, kind) {
2597
+ return `${destination}.${kind}-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
2598
+ }
2599
+
2600
+ function removeBundleArtifact(candidate) {
2601
+ try {
2602
+ fs.rmSync(candidate, BUNDLE_ARTIFACT_REMOVE_OPTIONS);
2603
+ return !fs.existsSync(candidate);
2604
+ } catch {
2605
+ return false;
2606
+ }
2607
+ }
2608
+
2609
+ export function withBundleMutationLock(destination, run) {
2610
+ const lockPath = path.join(
2611
+ path.dirname(destination),
2612
+ `.${path.basename(destination)}.impel-install.lock`,
2613
+ );
2614
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
2615
+
2616
+ if (process.platform !== "darwin") {
2617
+ if (portableBundleMutationLocks.has(lockPath)) {
2618
+ throw new Error(`another Impel app update is already replacing ${path.basename(destination)}`);
2619
+ }
2620
+ const descriptor = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_RDWR, 0o600);
2621
+ portableBundleMutationLocks.add(lockPath);
2622
+ try {
2623
+ return run();
2624
+ } finally {
2625
+ portableBundleMutationLocks.delete(lockPath);
2626
+ fs.closeSync(descriptor);
2627
+ }
2628
+ }
2629
+
2630
+ const deadline = Date.now() + BUNDLE_LOCK_WAIT_MS;
2631
+ let descriptor;
2632
+ while (descriptor === undefined) {
2633
+ try {
2634
+ descriptor = fs.openSync(
2635
+ lockPath,
2636
+ fs.constants.O_CREAT | fs.constants.O_RDWR | fs.constants.O_NONBLOCK | MACOS_O_EXLOCK,
2637
+ 0o600,
2638
+ );
2639
+ } catch (error) {
2640
+ if (!["EAGAIN", "EWOULDBLOCK"].includes(error?.code)) throw error;
2641
+ if (Date.now() >= deadline) {
2642
+ throw new Error(`timed out waiting for another Impel app update to replace ${path.basename(destination)}`);
2643
+ }
2644
+ Atomics.wait(BUNDLE_LOCK_SLEEP, 0, 0, BUNDLE_LOCK_POLL_MS);
2645
+ }
2646
+ }
2647
+ try {
2648
+ return run();
2649
+ } finally {
2650
+ fs.closeSync(descriptor);
2651
+ }
2652
+ }
2653
+
2654
+ function replaceDirectoryUnlocked(destination, staging, {
2655
+ removeArtifact = removeBundleArtifact,
2656
+ } = {}) {
2657
+ const backup = fs.existsSync(destination)
2658
+ ? uniqueBundleArtifactPath(destination, "previous")
2659
+ : null;
2660
+ if (backup) fs.renameSync(destination, backup);
2550
2661
  try {
2551
2662
  fs.renameSync(staging, destination);
2552
- fs.rmSync(backup, { recursive: true, force: true });
2553
2663
  } catch (error) {
2554
- if (!fs.existsSync(destination) && fs.existsSync(backup)) fs.renameSync(backup, destination);
2664
+ if (backup && !fs.existsSync(destination) && fs.existsSync(backup)) {
2665
+ fs.renameSync(backup, destination);
2666
+ }
2555
2667
  throw error;
2556
2668
  }
2669
+
2670
+ // The new bundle is committed once staging has been renamed into place.
2671
+ // Finder, LaunchServices, or a just-closed helper can briefly keep changing
2672
+ // the old tree and make recursive removal end with ENOTEMPTY. Do not turn
2673
+ // that post-commit housekeeping race into a failed tenant update: the next
2674
+ // sweep will retry any backup that remains.
2675
+ if (backup) {
2676
+ try {
2677
+ removeArtifact(backup);
2678
+ } catch {
2679
+ // Cleanup is best-effort after the committed rename.
2680
+ }
2681
+ }
2682
+ }
2683
+
2684
+ export function replaceDirectory(destination, staging, options = {}) {
2685
+ return withBundleMutationLock(destination, () => (
2686
+ replaceDirectoryUnlocked(destination, staging, options)
2687
+ ));
2557
2688
  }
2558
2689
 
2559
2690
  function isGatewayModel(model) {