impel-cli 0.17.1 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,7 @@ Useful setup options:
56
56
  ```sh
57
57
  impel setup --pat <pat> # non-interactive credential input
58
58
  impel setup --skip-apps # prepare CLI profiles only
59
- impel setup --skip-clis # Windows: do not install missing vendor CLIs
59
+ impel setup --skip-clis # do not install missing vendor CLIs
60
60
  impel setup --no-recovery # disable local and assisted recovery for this run
61
61
  ```
62
62
 
@@ -133,6 +133,7 @@ Useful update options:
133
133
  ```sh
134
134
  impel update --check # inspect npm availability without changing state
135
135
  impel update --skip-apps # reconcile CLI profiles only
136
+ impel update --skip-clis # do not install missing vendor CLIs
136
137
  impel update --no-recovery # disable recovery for this run
137
138
  ```
138
139
 
@@ -231,6 +232,8 @@ compatibility for one release, but no command can enable native routing.
231
232
 
232
233
  ### macOS
233
234
 
235
+ - Missing Claude Code and Codex CLIs can be installed for the signed-in user
236
+ through the vendors' checksum-verifying native installers after confirmation.
234
237
  - Tenant apps live in `~/Applications`.
235
238
  - LaunchServices registration is non-launching and idempotent.
236
239
  - Existing bundle fingerprints and stable signing identity prevent unnecessary
@@ -308,3 +311,18 @@ run a real package manager, or modify native personal profiles.
308
311
 
309
312
  CI runs on Ubuntu, macOS, and Windows with supported Node versions. See
310
313
  [RELEASING.md](RELEASING.md) for the npm release process.
314
+
315
+ The macOS config-contract job downloads the exact ChatGPT archive declared in
316
+ `PINNED_VENDOR_APPS`, verifies its checksum, signature, app version, and
317
+ embedded Codex version, then validates a deterministic generated profile with
318
+ Codex's strict config loader. A ChatGPT pin update must also update the embedded
319
+ Codex version and the reviewed config fixture when its contract changes.
320
+
321
+ The pinned Claude contract job likewise force-downloads the exact desktop app,
322
+ verifies the embedded Claude Code release manifest, builds the managed bundle
323
+ with ad-hoc signing, and checks deterministic generated desktop and Claude Code
324
+ settings against a reviewed fixture. The manifest-selected Claude Code runtime
325
+ runs `doctor` in bare mode against an isolated profile; CI intercepts the
326
+ `security` command so the check cannot read, reset, unlock, or modify a
327
+ Keychain. A Claude pin update must also update its `claudeCodeVersion` and
328
+ `claudeCodeCommit` pins and reviewed contract fixture.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
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
@@ -43,6 +43,8 @@ const APP_DEFINITIONS = {
43
43
  export const PINNED_VENDOR_APPS = Object.freeze({
44
44
  claude: Object.freeze({
45
45
  version: "1.20186.9",
46
+ claudeCodeVersion: "2.1.209",
47
+ claudeCodeCommit: "0fe048596fd45e79e99353cbe2c3d1b1ac069568",
46
48
  bundleName: "Claude.app",
47
49
  downloads: Object.freeze({
48
50
  arm64: Object.freeze({
@@ -57,6 +59,7 @@ export const PINNED_VENDOR_APPS = Object.freeze({
57
59
  }),
58
60
  chatgpt: Object.freeze({
59
61
  version: "26.707.72221",
62
+ codexVersion: "0.144.2",
60
63
  bundleName: "ChatGPT.app",
61
64
  downloads: Object.freeze({
62
65
  arm64: Object.freeze({
@@ -589,6 +592,7 @@ export function installManagedAppFiles({
589
592
  writeTokenHelperFile = true,
590
593
  claudeUserData = null,
591
594
  chatgptInvocations = null,
595
+ generatedAt = new Date().toISOString(),
592
596
  }) {
593
597
  // true = rebuild every target's bundle; false = configs only; an array
594
598
  // limits the expensive rebuild to just the stale targets.
@@ -621,7 +625,14 @@ export function installManagedAppFiles({
621
625
  ensureClaudeSessionHooks(paths.claude.userData, config.tenantId, "claude_desktop");
622
626
  }
623
627
  else {
624
- writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
628
+ writeChatGPTConfig(
629
+ paths,
630
+ config,
631
+ models,
632
+ vendorCodexModels,
633
+ chatgptInvocations,
634
+ generatedAt,
635
+ );
625
636
  ensureCodexSessionHooks(paths.chatgpt.codexHome, config.tenantId, "codex_desktop");
626
637
  }
627
638
  // Non-bundle targets are the background-refresh / fast-open path: configs,
@@ -666,7 +677,7 @@ export function installManagedAppFiles({
666
677
  config,
667
678
  ),
668
679
  models: models.map((model) => model.id),
669
- updatedAt: new Date().toISOString(),
680
+ updatedAt: generatedAt,
670
681
  }, null, 2) + "\n", 0o600);
671
682
  return installed;
672
683
  }
@@ -815,10 +826,13 @@ function installPinnedVendorApp(target, homeDir) {
815
826
  }
816
827
  }
817
828
 
818
- export function ensureVendorApp(target, { homeDir = os.homedir() } = {}) {
829
+ export function ensureVendorApp(target, {
830
+ homeDir = os.homedir(),
831
+ forceDownload = false,
832
+ } = {}) {
819
833
  const definition = APP_DEFINITIONS[target];
820
834
  const pin = PINNED_VENDOR_APPS[target];
821
- const current = findVendorApp(target, homeDir);
835
+ const current = forceDownload ? null : findVendorApp(target, homeDir);
822
836
  if (current) return { path: current, action: "existing", note: `verified ${pin.version}` };
823
837
 
824
838
  // Name-collision transparency: e.g. OpenAI's native ChatGPT chat app
@@ -974,7 +988,14 @@ function writeClaudeDefaultApp(paths) {
974
988
  writeAtomic(configPath, JSON.stringify(next, null, 2) + "\n", 0o600);
975
989
  }
976
990
 
977
- function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocations = null) {
991
+ function writeChatGPTConfig(
992
+ paths,
993
+ config,
994
+ models,
995
+ vendorCodexModels,
996
+ invocations = null,
997
+ generatedAt = new Date().toISOString(),
998
+ ) {
978
999
  const experimental = crossAppModelsEnabled(config);
979
1000
  const orderedModels = experimental
980
1001
  ? [...models.filter((model) => model.provider === "codex"), ...models.filter((model) => model.provider === "claude")]
@@ -1008,7 +1029,7 @@ function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocation
1008
1029
  ? configuredTier
1009
1030
  : (configuredTier === "fast" && supportedTiers.includes("priority") ? "priority" : null);
1010
1031
  const catalog = {
1011
- fetched_at: new Date().toISOString(),
1032
+ fetched_at: generatedAt,
1012
1033
  client_version: "impel-managed-2",
1013
1034
  models: codexModels,
1014
1035
  };
@@ -1209,18 +1230,27 @@ function codexCatalogEntry(model, index, vendorModel) {
1209
1230
  function readVendorCodexModels(vendorPath) {
1210
1231
  const binary = vendorPath && path.join(vendorPath, "Contents", "Resources", "codex");
1211
1232
  if (!binary || !fs.existsSync(binary)) return new Map();
1212
- const env = { ...process.env };
1213
- delete env.CODEX_HOME;
1214
- for (const args of [["debug", "models"], ["debug", "models", "--bundled"]]) {
1215
- const result = spawnSync(binary, args, { encoding: "utf8", env, maxBuffer: 20 * 1024 * 1024 });
1216
- if (result.status !== 0) continue;
1233
+ const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "impel-vendor-codex-models-"));
1234
+ try {
1235
+ // Vendor enrichment must come only from the exact bundled release. Reading
1236
+ // the operator's native CODEX_HOME here would make generated app configs
1237
+ // depend on unrelated local state and could trigger an online catalog read.
1238
+ const env = { ...process.env, CODEX_HOME: codexHome };
1239
+ const result = spawnSync(binary, ["debug", "models", "--bundled"], {
1240
+ encoding: "utf8",
1241
+ env,
1242
+ maxBuffer: 20 * 1024 * 1024,
1243
+ });
1244
+ if (result.status !== 0) return new Map();
1217
1245
  try {
1218
1246
  const payload = JSON.parse(result.stdout);
1219
1247
  const models = Array.isArray(payload) ? payload : payload.models;
1220
1248
  if (Array.isArray(models)) return new Map(models.map((model) => [model.slug, model]));
1221
1249
  } catch {
1222
- // Fall through to the bundled catalog or the Impel-generated schema.
1250
+ // Fall through to the Impel-generated schema.
1223
1251
  }
1252
+ } finally {
1253
+ fs.rmSync(codexHome, { recursive: true, force: true });
1224
1254
  }
1225
1255
  return new Map();
1226
1256
  }
@@ -1373,11 +1403,13 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1373
1403
  });
1374
1404
 
1375
1405
  const macos = path.join(staging, "Contents", "MacOS");
1406
+ const vendorExecutable = path.join(macos, executableName);
1407
+ const signingIdentity = resolveVendoredAppSigningIdentity(vendorExecutable);
1376
1408
  writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
1377
1409
  schemaVersion: 4,
1378
1410
  cliVersion: CLI_VERSION,
1379
1411
  bundleFingerprint: BUNDLE_BUILD_FINGERPRINT,
1380
- signingMode: desiredSigningMode(),
1412
+ signingMode: signingModeForIdentity(signingIdentity),
1381
1413
  vendorVersion: bundleVersion(vendorPath),
1382
1414
  sourceBundle: vendorPath,
1383
1415
  bundleIdentifier: paths.claude.bundleIdentifier,
@@ -1403,7 +1435,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl, safeStorageNam
1403
1435
  asarHeaderSha256: newAsarHash,
1404
1436
  }, null, 2) + "\n", 0o644);
1405
1437
 
1406
- signVendoredApp(staging, path.join(macos, executableName), "Claude");
1438
+ signVendoredApp(staging, vendorExecutable, "Claude", signingIdentity);
1407
1439
  replaceDirectory(bundle, staging);
1408
1440
  } catch (error) {
1409
1441
  fs.rmSync(staging, { recursive: true, force: true });
@@ -1456,6 +1488,8 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1456
1488
  const runtimeAsarPath = path.join(staging, "Contents", "Resources", "app.asar");
1457
1489
  const fastModePatchCount = patchFastModeAuthGate(runtimeAsarPath);
1458
1490
  const asarHash = asarHeaderHash(runtimeAsarPath);
1491
+ const vendorExecutable = path.join(vendorBundle, "Contents", "MacOS", executableName);
1492
+ const signingIdentity = resolveVendoredAppSigningIdentity(vendorExecutable);
1459
1493
  const launchHost = ensureChatGPTLaunchHost();
1460
1494
  writeAtomic(
1461
1495
  path.join(staging, "Contents", "Info.plist"),
@@ -1487,7 +1521,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1487
1521
  schemaVersion: 3,
1488
1522
  cliVersion: CLI_VERSION,
1489
1523
  bundleFingerprint: BUNDLE_BUILD_FINGERPRINT,
1490
- signingMode: desiredSigningMode(),
1524
+ signingMode: signingModeForIdentity(signingIdentity),
1491
1525
  vendorVersion: bundleVersion(vendorPath),
1492
1526
  sourceBundle: vendorPath,
1493
1527
  bundleIdentifier: paths.chatgpt.bundleIdentifier,
@@ -1505,7 +1539,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1505
1539
  asarHeaderSha256: asarHash,
1506
1540
  }, null, 2) + "\n", 0o644);
1507
1541
 
1508
- signVendoredApp(staging, path.join(vendorBundle, "Contents", "MacOS", executableName), "ChatGPT");
1542
+ signVendoredApp(staging, vendorExecutable, "ChatGPT", signingIdentity);
1509
1543
  replaceDirectory(bundle, staging);
1510
1544
  } catch (error) {
1511
1545
  fs.rmSync(staging, { recursive: true, force: true });
@@ -2131,6 +2165,16 @@ if (process.type === "browser" && displayName) {
2131
2165
  `;
2132
2166
  }
2133
2167
 
2168
+ function resolveVendoredAppSigningIdentity(vendorExecutable) {
2169
+ if (process.platform !== "darwin" || !isMachO(vendorExecutable)) return null;
2170
+ return resolveSigningIdentity();
2171
+ }
2172
+
2173
+ export function signingModeForIdentity(identity) {
2174
+ if (!identity) return desiredSigningMode();
2175
+ return identity.mode === "external" ? identity.descriptor : identity.mode;
2176
+ }
2177
+
2134
2178
  function signVendoredApp(bundle, vendorExecutable, label, identity = null) {
2135
2179
  if (process.platform !== "darwin" || !isMachO(vendorExecutable)) return;
2136
2180
  // Resolve (and, on first use, create) the signing identity only once we know
package/src/cli.js CHANGED
@@ -19,6 +19,7 @@ import { cmdSessions } from "./commands/sessions.js";
19
19
  import { cmdUpdate } from "./commands/update.js";
20
20
  import { cmdExperimental } from "./commands/experimental.js";
21
21
  import { cmdConverge } from "./commands/converge.js";
22
+ import { refuseElevatedMacExecution } from "./privileges.js";
22
23
 
23
24
  const HELP = `impel — isolated Impel workspaces for every tenant
24
25
 
@@ -75,6 +76,7 @@ function normalizeTarget(token) {
75
76
  }
76
77
 
77
78
  export async function main(argv) {
79
+ if (refuseElevatedMacExecution(argv)) return;
78
80
  const [cmd, ...rest] = argv;
79
81
 
80
82
  switch (cmd) {
package/src/codesign.js CHANGED
@@ -117,7 +117,10 @@ export function resolveSigningIdentity(deps = {}) {
117
117
  }
118
118
 
119
119
  try {
120
- const identity = ensureLocalSigningIdentity({ run, configDir, fsImpl, randomBytes });
120
+ const identity = withSigningIdentityLock(
121
+ { configDir, fsImpl },
122
+ () => ensureLocalSigningIdentity({ run, configDir, fsImpl, randomBytes }),
123
+ );
121
124
  if (cache) cachedIdentity = identity;
122
125
  return identity;
123
126
  } catch (error) {
@@ -142,25 +145,33 @@ function ensureLocalSigningIdentity({ run, configDir, fsImpl, randomBytes }) {
142
145
  // therefore the designated requirement) stays stable across updates.
143
146
  if (fsImpl.existsSync(keychain) && fsImpl.existsSync(passwordFile)) {
144
147
  const password = fsImpl.readFileSync(passwordFile, "utf8").trim();
145
- unlockKeychain(run, keychain, password);
146
- ensureKeychainInSearchList(run, keychain);
147
- const existing = findLocalIdentitySha(run, keychain);
148
- if (existing) return localIdentity(existing, keychain);
149
-
150
- // v0.13.0 imported a self-signed certificate but never established code-
151
- // signing trust for it. `security find-identity -v` therefore reported
152
- // zero valid identities and every rebuild fell back to ad-hoc signing.
153
- // Repair that exact state in place so the already-generated identity stays
154
- // stable instead of accumulating a second certificate with the same name.
155
- trustExistingLocalCertificate({ run, keychain, fsImpl });
156
- const repaired = findLocalIdentitySha(run, keychain);
157
- if (repaired) return localIdentity(repaired, keychain);
158
- throw new Error("existing signing identity could not be trusted");
148
+ try {
149
+ if (!password) throw new Error("stored signing keychain password is empty");
150
+ unlockKeychain(run, keychain, password);
151
+ runOrThrow(run, "/usr/bin/security", ["set-keychain-settings", keychain], "configure signing keychain");
152
+ ensureKeychainInSearchList(run, keychain);
153
+ let existing = findLocalIdentitySha(run, keychain);
154
+ if (existing) return localIdentity(existing, keychain);
155
+
156
+ // Repair identities created before trust was established, plus imports
157
+ // interrupted after the private key reached the keychain.
158
+ authorizeCodesignKey(run, keychain, password);
159
+ trustExistingLocalCertificate({ run, keychain, fsImpl });
160
+ existing = findLocalIdentitySha(run, keychain);
161
+ if (existing) return localIdentity(existing, keychain);
162
+ throw new Error("existing signing identity could not be trusted");
163
+ } catch {
164
+ // This keychain and password file are entirely Impel-owned. A mismatched,
165
+ // empty, or half-imported pair can never provide stable identity, so reset
166
+ // it atomically instead of falling back forever on every future rebuild.
167
+ resetLocalSigningIdentity({ run, keychain, passwordFile, fsImpl });
168
+ }
169
+ } else if (fsImpl.existsSync(keychain) || fsImpl.existsSync(passwordFile)) {
170
+ resetLocalSigningIdentity({ run, keychain, passwordFile, fsImpl });
159
171
  }
160
172
 
161
173
  const password = randomBytes(24).toString("hex");
162
- createLocalSigningIdentity({ run, dir, keychain, password, fsImpl });
163
- fsImpl.writeFileSync(passwordFile, `${password}\n`, { mode: 0o600 });
174
+ createLocalSigningIdentity({ run, dir, keychain, passwordFile, password, fsImpl });
164
175
 
165
176
  ensureKeychainInSearchList(run, keychain);
166
177
  const sha = findLocalIdentitySha(run, keychain);
@@ -183,7 +194,7 @@ function findLocalIdentitySha(run, keychain) {
183
194
  return parseIdentitySha(result.stdout, SIGNING_IDENTITY_NAME);
184
195
  }
185
196
 
186
- function createLocalSigningIdentity({ run, dir, keychain, password, fsImpl }) {
197
+ function createLocalSigningIdentity({ run, dir, keychain, passwordFile, password, fsImpl }) {
187
198
  const work = fsImpl.mkdtempSync(path.join(os.tmpdir(), "impel-codesign-"));
188
199
  const keyPath = path.join(work, "key.pem");
189
200
  const certPath = path.join(work, "cert.pem");
@@ -211,26 +222,89 @@ function createLocalSigningIdentity({ run, dir, keychain, password, fsImpl }) {
211
222
  "-name", SIGNING_IDENTITY_NAME,
212
223
  ], "package signing certificate");
213
224
 
214
- if (!fsImpl.existsSync(keychain)) {
215
- runOrThrow(run, "/usr/bin/security", ["create-keychain", "-p", password, keychain], "create signing keychain");
225
+ runOrThrow(run, "/usr/bin/security", ["create-keychain", "-p", password, keychain], "create signing keychain");
226
+ try {
227
+ writePrivateAtomic(fsImpl, passwordFile, `${password}\n`);
228
+ } catch (error) {
229
+ resetLocalSigningIdentity({ run, keychain, passwordFile, fsImpl });
230
+ throw error;
216
231
  }
217
232
  // No auto-lock: codesign must reach the private key non-interactively.
218
- run("/usr/bin/security", ["set-keychain-settings", keychain]);
233
+ runOrThrow(run, "/usr/bin/security", ["set-keychain-settings", keychain], "configure signing keychain");
219
234
  unlockKeychain(run, keychain, password);
220
235
  runOrThrow(run, "/usr/bin/security", [
221
236
  "import", p12Path, "-k", keychain, "-P", p12Pass, "-T", "/usr/bin/codesign",
222
237
  ], "import signing identity");
223
238
  // Grant codesign non-interactive access to the key so signing never pops a
224
239
  // "codesign wants to use a key" GUI prompt. We own the keychain password.
225
- runOrThrow(run, "/usr/bin/security", [
226
- "set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", password, keychain,
227
- ], "authorize codesign for the signing key");
240
+ authorizeCodesignKey(run, keychain, password);
228
241
  trustLocalCertificate(run, keychain, certPath);
229
242
  } finally {
230
243
  fsImpl.rmSync(work, { recursive: true, force: true });
231
244
  }
232
245
  }
233
246
 
247
+ function authorizeCodesignKey(run, keychain, password) {
248
+ runOrThrow(run, "/usr/bin/security", [
249
+ "set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", password, keychain,
250
+ ], "authorize codesign for the signing key");
251
+ }
252
+
253
+ function writePrivateAtomic(fsImpl, filePath, contents) {
254
+ const temporary = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
255
+ try {
256
+ fsImpl.writeFileSync(temporary, contents, { mode: 0o600 });
257
+ fsImpl.renameSync(temporary, filePath);
258
+ fsImpl.chmodSync?.(filePath, 0o600);
259
+ } finally {
260
+ fsImpl.rmSync(temporary, { force: true });
261
+ }
262
+ }
263
+
264
+ function resetLocalSigningIdentity({ run, keychain, passwordFile, fsImpl }) {
265
+ if (fsImpl.existsSync(keychain)) {
266
+ run("/usr/bin/security", ["delete-keychain", keychain]);
267
+ }
268
+ fsImpl.rmSync(keychain, { force: true });
269
+ fsImpl.rmSync(passwordFile, { force: true });
270
+ }
271
+
272
+ const SIGNING_LOCK_STALE_MS = 5 * 60 * 1_000;
273
+ const SIGNING_LOCK_WAIT_MS = 15_000;
274
+ const SIGNING_LOCK_POLL_MS = 100;
275
+ const SIGNING_LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(4));
276
+
277
+ function withSigningIdentityLock({ configDir, fsImpl }, run) {
278
+ const dir = path.join(configDir, "codesign");
279
+ const lock = path.join(dir, ".identity-lock");
280
+ fsImpl.mkdirSync(dir, { recursive: true, mode: 0o700 });
281
+ const deadline = Date.now() + SIGNING_LOCK_WAIT_MS;
282
+ while (true) {
283
+ try {
284
+ fsImpl.mkdirSync(lock, { mode: 0o700 });
285
+ break;
286
+ } catch (error) {
287
+ if (error?.code !== "EEXIST") throw error;
288
+ try {
289
+ if (Date.now() - fsImpl.statSync(lock).mtimeMs > SIGNING_LOCK_STALE_MS) {
290
+ fsImpl.rmSync(lock, { recursive: true, force: true });
291
+ continue;
292
+ }
293
+ } catch (statError) {
294
+ if (statError?.code !== "ENOENT") throw statError;
295
+ continue;
296
+ }
297
+ if (Date.now() >= deadline) throw new Error("timed out waiting for another Impel signing operation");
298
+ Atomics.wait(SIGNING_LOCK_SLEEP, 0, 0, SIGNING_LOCK_POLL_MS);
299
+ }
300
+ }
301
+ try {
302
+ return run();
303
+ } finally {
304
+ fsImpl.rmSync(lock, { recursive: true, force: true });
305
+ }
306
+ }
307
+
234
308
  function unlockKeychain(run, keychain, password) {
235
309
  runOrThrow(
236
310
  run,
@@ -43,6 +43,7 @@ import { syncAgentProfilesSafe } from "../agents.js";
43
43
  import { secureManagedCodexHome } from "../codexSecurity.js";
44
44
  import { impelCliInvocation } from "../selfInvocation.js";
45
45
  import { createProgressLogger, withProgress } from "../progress.js";
46
+ import { findNativeBinary } from "../nativeProcess.js";
46
47
  import {
47
48
  ensureWindowsClaudeApp,
48
49
  ensureWindowsChatGPTApp,
@@ -325,6 +326,7 @@ export async function reconcileWindowsTenantApps({
325
326
  syncSkills: syncSkillsSafe,
326
327
  syncAgents: syncAgentProfilesSafe,
327
328
  existsSync: fs.existsSync,
329
+ findBinary: findNativeBinary,
328
330
  log: (message) => console.log(message),
329
331
  ...overrides,
330
332
  };
@@ -375,17 +377,23 @@ export async function reconcileWindowsTenantApps({
375
377
  homeDir,
376
378
  environment,
377
379
  });
380
+ const agentTargets = [];
378
381
  for (const target of actionTargets) {
379
382
  const { client, env, label } = appSkillTarget(target, paths);
380
- await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
383
+ if (io.findBinary(client, environment, "win32")) {
384
+ await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
385
+ agentTargets.push(target);
386
+ }
381
387
  if (target === "chatgpt") secureManagedCodexHome(paths.chatgpt.codexHome);
382
388
  }
383
- await io.syncAgents({
384
- profiles: actionTargets.map((target) => appAgentProfile(target, paths)),
385
- gatewayUrl: config.gatewayUrl,
386
- credential: config.pat,
387
- tenantId: config.tenantId,
388
- });
389
+ if (agentTargets.length) {
390
+ await io.syncAgents({
391
+ profiles: agentTargets.map((target) => appAgentProfile(target, paths)),
392
+ gatewayUrl: config.gatewayUrl,
393
+ credential: config.pat,
394
+ tenantId: config.tenantId,
395
+ });
396
+ }
389
397
  const verification = {
390
398
  claude: !actionTargets.includes("claude") || io.existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)),
391
399
  chatgpt: !actionTargets.includes("chatgpt") || io.existsSync(path.join(paths.chatgpt.codexHome, "config.toml")),
@@ -404,6 +412,7 @@ export async function reconcileMacTenantApps({
404
412
  skipVendorTargets = [],
405
413
  confirmVendorInstall = async () => true,
406
414
  homeDir = os.homedir(),
415
+ environment = process.env,
407
416
  } = {}, overrides = {}) {
408
417
  const io = {
409
418
  status: appStatus,
@@ -417,6 +426,7 @@ export async function reconcileMacTenantApps({
417
426
  secureCodexHome: secureManagedCodexHome,
418
427
  openLauncher: openManagedLauncher,
419
428
  existsSync: fs.existsSync,
429
+ findBinary: findNativeBinary,
420
430
  log: (message) => console.log(message),
421
431
  ...overrides,
422
432
  };
@@ -462,17 +472,23 @@ export async function reconcileMacTenantApps({
462
472
  writeBundles: staleTargets,
463
473
  });
464
474
  const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
475
+ const agentItems = [];
465
476
  for (const item of installed) {
466
477
  const { client, env, label } = appSkillTarget(item.target, paths);
467
- await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
478
+ if (io.findBinary(client, environment, "darwin")) {
479
+ await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
480
+ agentItems.push(item);
481
+ }
468
482
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
469
483
  }
470
- await io.syncAgents({
471
- profiles: installed.map((item) => appAgentProfile(item.target, paths)),
472
- gatewayUrl: config.gatewayUrl,
473
- credential: config.pat,
474
- tenantId: config.tenantId,
475
- });
484
+ if (agentItems.length) {
485
+ await io.syncAgents({
486
+ profiles: agentItems.map((item) => appAgentProfile(item.target, paths)),
487
+ gatewayUrl: config.gatewayUrl,
488
+ credential: config.pat,
489
+ tenantId: config.tenantId,
490
+ });
491
+ }
476
492
  for (const item of installed) {
477
493
  if (reopenTargets.has(item.target)) io.openLauncher(item.launcher);
478
494
  }
@@ -5,6 +5,7 @@ import { runInstallRecovery } from "../installRecovery/engine.js";
5
5
  import { printReconciliationSummary, reconcileAllTenants, selectDefaultTenant } from "../provisioning.js";
6
6
  import { fetchTenants } from "../tenants.js";
7
7
  import { promptText } from "../prompt.js";
8
+ import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
8
9
  import { restoreNativeProfiles } from "./use.js";
9
10
 
10
11
  function confirmed(answer) {
@@ -24,6 +25,7 @@ function mergeTenantReport(report, replacement) {
24
25
 
25
26
  export async function cmdConverge(argv = [], overrides = {}) {
26
27
  const skipApps = argv.includes("--skip-apps");
28
+ const skipClis = argv.includes("--skip-clis");
27
29
  const noRecovery = argv.includes("--no-recovery");
28
30
  const io = {
29
31
  loadConfig,
@@ -36,8 +38,12 @@ export async function cmdConverge(argv = [], overrides = {}) {
36
38
  isTTY: process.stdin.isTTY,
37
39
  platform: process.platform,
38
40
  environment: process.env,
41
+ preparePlatformClis,
39
42
  ...overrides,
40
43
  };
44
+ if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
45
+ io.preparePlatformClis = overrides.prepareWindowsClis;
46
+ }
41
47
  const config = io.loadConfig();
42
48
  if (!config?.pat) {
43
49
  console.error("impel update: not authenticated; run `impel setup` first.");
@@ -93,8 +99,94 @@ export async function cmdConverge(argv = [], overrides = {}) {
93
99
  environment: io.environment,
94
100
  confirmVendorInstall: confirmInstall,
95
101
  }, overrides.reconcileOverrides || {});
102
+
103
+ let sharedFailure = null;
104
+ const prepareSharedClis = async ({ confirmedTools = null, inspectOnly = false } = {}) => {
105
+ try {
106
+ const inspected = await io.preparePlatformClis({
107
+ gatewayUrl: config.gatewayUrl,
108
+ tenantId: selected.id,
109
+ platform: io.platform,
110
+ skipInstall: true,
111
+ });
112
+ let prepared = inspected;
113
+ if (inspected.missingAfter.length && !inspectOnly && !skipClis) {
114
+ let installTools = confirmedTools;
115
+ if (installTools === null) {
116
+ installTools = [];
117
+ if (io.isTTY) {
118
+ for (const tool of inspected.missingAfter) {
119
+ if (!inspected.installCommands?.[tool]) continue;
120
+ const label = tool === "claude" ? "Claude Code" : "Codex";
121
+ if (confirmed(await io.promptText(`Install the official ${label} CLI for this user? [y/N] `))) {
122
+ installTools.push(tool);
123
+ }
124
+ }
125
+ }
126
+ }
127
+ if (installTools.length) {
128
+ prepared = await io.preparePlatformClis({
129
+ gatewayUrl: config.gatewayUrl,
130
+ tenantId: selected.id,
131
+ platform: io.platform,
132
+ skipInstall: false,
133
+ installTools,
134
+ });
135
+ }
136
+ }
137
+ sharedFailure = prepared.missingAfter.length ? describeCliFailure(prepared) : null;
138
+ return prepared;
139
+ } catch (error) {
140
+ sharedFailure = redactSecretText(error?.message || error);
141
+ return { binaries: {}, missingAfter: ["claude", "codex"] };
142
+ }
143
+ };
144
+
145
+ await prepareSharedClis();
96
146
  let report = await run();
97
- if (!report.passed && !noRecovery) {
147
+ if (sharedFailure && !noRecovery) {
148
+ let sharedReady = false;
149
+ const recovery = await io.recoverInstall({
150
+ failure: {
151
+ scope: "shared",
152
+ platform: io.platform,
153
+ architecture: process.arch,
154
+ step: "update.shared_vendor_clis",
155
+ message: sharedFailure,
156
+ },
157
+ config,
158
+ goals: [{
159
+ id: "shared-vendor-clis-ready",
160
+ description: "Shared vendor CLI prerequisites are installed and discoverable",
161
+ run: () => sharedReady,
162
+ }],
163
+ explicit: true,
164
+ noRecovery: false,
165
+ actionContext: {
166
+ tenantId: selected.id,
167
+ installVendorClis: async (tool) => {
168
+ await prepareSharedClis({ confirmedTools: [tool] });
169
+ sharedReady = !sharedFailure;
170
+ return io.preparePlatformClis({
171
+ gatewayUrl: config.gatewayUrl,
172
+ tenantId: selected.id,
173
+ platform: io.platform,
174
+ skipInstall: true,
175
+ });
176
+ },
177
+ retryStep: async () => {
178
+ await prepareSharedClis({ inspectOnly: true });
179
+ sharedReady = !sharedFailure;
180
+ return sharedReady;
181
+ },
182
+ },
183
+ }, overrides.recoveryOverrides || {});
184
+ if (recovery.fixed) {
185
+ await prepareSharedClis({ inspectOnly: true });
186
+ report = await run();
187
+ }
188
+ }
189
+ if (!sharedFailure && !report.passed && !noRecovery) {
98
190
  for (const failed of report.tenants.filter((tenant) => tenant.status === "failed")) {
99
191
  const tenant = listing.tenants.find((candidate) => candidate.id === failed.tenantId);
100
192
  if (!tenant) continue;
@@ -165,7 +257,8 @@ export async function cmdConverge(argv = [], overrides = {}) {
165
257
  }
166
258
  printReconciliationSummary(report);
167
259
  console.log(`CLI tenant: ${selected.id} (change with \`impel tenant use <tenant>\`)`);
168
- if (!report.passed) {
260
+ if (!report.passed || sharedFailure) {
261
+ if (sharedFailure) console.error(`Shared update failure: ${sharedFailure}`);
169
262
  console.error("impel update: one or more tenant surfaces remain incomplete.");
170
263
  process.exitCode = 1;
171
264
  return false;