impel-cli 0.16.5 → 0.17.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.16.5",
4
- "description": "Configure Claude Code and Codex CLI to talk to Impel's gateway, authenticated by an Impel Personal Access Token",
3
+ "version": "0.17.1",
4
+ "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "impel": "bin/impel.js"
package/src/cli.js CHANGED
@@ -18,59 +18,40 @@ import { cmdSetup } from "./commands/setup.js";
18
18
  import { cmdSessions } from "./commands/sessions.js";
19
19
  import { cmdUpdate } from "./commands/update.js";
20
20
  import { cmdExperimental } from "./commands/experimental.js";
21
+ import { cmdConverge } from "./commands/converge.js";
21
22
 
22
- const HELP = `impel — the Impel gateway CLI: isolated Claude Code, Codex, and desktop apps
23
+ const HELP = `impel — isolated Impel workspaces for every tenant
23
24
 
24
25
  Get started:
25
- impel setup Guided end-to-end onboarding: token, tenant,
26
- platform clients, gateway verification
27
- (--pat <pat> --tenant <org> --skip-apps/--skip-clis
28
- --repair/--no-recovery)
29
- impel update Update everything: the CLI itself, then the
30
- Impel apps, skills, and agents (--check, --skip-apps)
31
-
32
- Run:
26
+ impel setup Authenticate and prepare every accessible tenant
27
+ (--pat <pat>, --tenant <org>, --no-recovery)
28
+ impel update Update the CLI and reconcile every tenant
29
+ (--check, --skip-apps, --no-recovery)
30
+
31
+ Work:
33
32
  impel claude [args...] Launch Claude Code with an isolated Impel profile
34
33
  impel codex [args...] Launch Codex with an isolated Impel profile
35
- impel app open [target] [--tenant <org>] Install/configure on first use, then open the app(s)
36
- impel status Tenant, PAT, per-tool mode, gateway reachability
37
- impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
34
+ impel tenant list List accessible organizations
35
+ impel tenant current Show the CLI's current organization
36
+ impel tenant use <org> Select the organization for CLI launches
38
37
 
39
- Manage:
40
- impel auth [--pat <pat>] Store your PAT + URLs (--gateway/--app <url>)
38
+ Workspace:
39
+ impel tasks list|get|create|update|delete Work with Impel tickets (aliases: task, ticket[s])
40
+
41
+ Account:
41
42
  impel pat create --label <label> Mint a personal PAT (--org/--scopes/--ttl-days)
42
43
  impel pat create --agent <agent-id> Mint an org-scoped PAT associated with an agent
43
44
  impel pat revoke <token-id> --yes Irreversibly revoke one of your PATs
44
- impel tenant list|current|use <org> List or select the active organization
45
- impel tasks list|get|create|update|delete CRUD Impel tickets (aliases: task, ticket[s])
46
- impel token [--tenant <org>] Print the selected-tenant bearer
47
- impel mcp Run the local Impel MCP stdio bridge
48
- impel skills sync [claude|codex|all] Sync shared skills into managed clients
49
- impel agents sync [claude|codex|all] Sync explicit tenant agents into native clients
50
-
51
- Desktop apps (macOS and Windows):
52
- target is claude, chatgpt/codex, or all (default: all)
53
- impel app install [target] [--tenant <org>] Install that tenant's isolated app/profile
54
- impel app update [target] [--tenant <org>] Update profiles; all without --tenant updates
55
- every installed tenant (--force: clean rebuild)
56
- impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
57
- impel app status [target] [--tenant <org>] Show isolated app and vendor versions
58
- impel app uninstall [target] [--tenant <org>] [--keep-data]
59
- Remove only that tenant's managed apps/data
60
-
61
- Native profile switching (advanced; setup never touches native tools):
62
- impel use gateway [claude|codex|all] Route native Claude Code/Codex through the gateway
63
- impel use account [claude|codex|all] Revert to your own Anthropic/OpenAI login
64
- impel on / impel off Aliases for the two commands above
45
+
46
+ Diagnostics:
47
+ impel status Authentication, current tenant, and local readiness
48
+ impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
65
49
 
66
50
  impel help Show this help
67
51
  impel --version Show the installed version
68
52
 
69
53
  Env:
70
- IMPEL_GATEWAY_URL Default gateway URL when --gateway isn't passed.
71
- IMPEL_APP_URL Default app/control-plane URL for setup, auth, PATs, and tasks.
72
54
  IMPEL_SKIP_UPDATE_CHECK=1 Silence launch-time update notices.
73
- IMPEL_SKIP_AGENT_SYNC=1 Keep the last native-agent catalog without refreshing it.
74
55
  IMPEL_DISABLE_INSTALL_RECOVERY=1 Disable local and hosted install recovery.
75
56
 
76
57
  Config file:
@@ -159,6 +140,8 @@ export async function main(argv) {
159
140
  case "agent":
160
141
  return cmdAgents(rest);
161
142
 
143
+ // Compatibility shims retained for one release. They can only refuse an
144
+ // old enable request or restore legacy Impel-owned native mutations.
162
145
  case "on":
163
146
  return runUse("gateway", rest[0]);
164
147
 
@@ -169,7 +152,7 @@ export async function main(argv) {
169
152
  const [mode, targetToken] = rest;
170
153
  if (mode !== "gateway" && mode !== "account") {
171
154
  console.error(`impel use: expected \`gateway\` or \`account\`, got "${mode ?? ""}".`);
172
- console.error(" Try `impel use gateway` (alias `impel on`) or `impel use account` (alias `impel off`).");
155
+ console.error(" Native switching has been removed; use `impel claude` or `impel codex`.");
173
156
  process.exitCode = 1;
174
157
  return;
175
158
  }
@@ -178,14 +161,23 @@ export async function main(argv) {
178
161
 
179
162
  case "setup":
180
163
  // The old per-tool aliases (setup claude|codex-cli|codex-app) flipped
181
- // native configs; that swap is no longer part of setup. cmdSetup rejects
182
- // positional targets with a pointer to `impel use gateway`.
164
+ // native configs; that swap is no longer part of setup.
183
165
  return cmdSetup(rest);
184
166
 
185
167
  case "update":
186
168
  case "upgrade":
187
169
  return cmdUpdate(rest);
188
170
 
171
+ // Hidden continuation executed by the freshly installed CLI build.
172
+ case "_converge":
173
+ return cmdConverge(rest);
174
+
175
+ // Hidden OS-shell launcher. Setup/update guarantees the app is ready.
176
+ case "_app-launch": {
177
+ const [target = "all", ...args] = rest;
178
+ return cmdApps(["open", target, ...args]);
179
+ }
180
+
189
181
  // Intentionally omitted from the public help while the contract is gated
190
182
  // server-side and limited to Impel-managed desktop apps.
191
183
  case "experimental":
@@ -180,6 +180,21 @@ export function installedAppTenantIds(targets, {
180
180
  return installed.sort((left, right) => left.localeCompare(right));
181
181
  }
182
182
 
183
+ async function maybeRunLegacyUpdateHandoff(argv, overrides) {
184
+ if (overrides.singleTenantUpdate) return null;
185
+ const [action, target, ...rest] = argv;
186
+ if (action !== "update" || target !== "all" || rest.length > 0) return null;
187
+
188
+ // impel-cli <=0.16.5 re-executes a newly installed build with this exact
189
+ // command. Route that cross-version handoff through the current full-tenant
190
+ // reconciler so first-time tenant targets are not limited to local installs.
191
+ const runConvergence = overrides.runAllTenantConvergence || (async () => {
192
+ const { cmdConverge } = await import("./converge.js");
193
+ return cmdConverge([]);
194
+ });
195
+ return runConvergence();
196
+ }
197
+
183
198
  async function maybeUpdateAllInstalledTenants(argv, overrides, platform) {
184
199
  if (overrides.singleTenantUpdate) return null;
185
200
  const [action = "status", rawTarget, ...rest] = argv;
@@ -265,6 +280,221 @@ function configureWindowsApps(config, targets, vendorPaths, catalog, io) {
265
280
  return { catalog, userData };
266
281
  }
267
282
 
283
+ function explicitTenantAppConfig(baseConfig, tenant, targets) {
284
+ if (!baseConfig?.pat || !baseConfig?.gatewayUrl) throw new Error("an authenticated Impel config is required");
285
+ const tenantId = normalizeTenantId(tenant?.id || tenant?.slug);
286
+ const providers = crossAppModelsEnabled(baseConfig)
287
+ ? ["claude", "codex"]
288
+ : targets.map((target) => (target === "claude" ? "claude" : "codex"));
289
+ assertProviderScopes(baseConfig.scopes, providers, { requireLive: true });
290
+ return {
291
+ ...baseConfig,
292
+ tenantId,
293
+ tenantName: tenant?.name || tenantId,
294
+ pat: tenantCredential(baseConfig.pat, tenantId),
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Reconcile one explicit tenant's Windows app profiles without parsing CLI
300
+ * arguments or changing the globally selected tenant. Shared vendor work is
301
+ * skipped after the first tenant by passing skipVendor.
302
+ */
303
+ export async function reconcileWindowsTenantApps({
304
+ baseConfig,
305
+ tenant,
306
+ mode = "update",
307
+ targets = ["claude", "chatgpt"],
308
+ skipVendor = false,
309
+ skipVendorTargets = [],
310
+ confirmVendorInstall = async () => true,
311
+ homeDir = os.homedir(),
312
+ environment = process.env,
313
+ } = {}, overrides = {}) {
314
+ const io = {
315
+ findClaudeApp: findWindowsClaudeApp,
316
+ ensureClaudeApp: ensureWindowsClaudeApp,
317
+ claudeUserData: windowsClaudeUserData,
318
+ findChatGPTApp: findWindowsChatGPTApp,
319
+ ensureChatGPTApp: ensureWindowsChatGPTApp,
320
+ findManagedChatGPTApp: findManagedWindowsChatGPTApp,
321
+ stageChatGPTApp: stageWindowsChatGPTApp,
322
+ chatGPTStageIsCurrent: windowsChatGPTStageIsCurrent,
323
+ fetchModels: fetchGatewayModels,
324
+ installFiles: installManagedAppFiles,
325
+ syncSkills: syncSkillsSafe,
326
+ syncAgents: syncAgentProfilesSafe,
327
+ existsSync: fs.existsSync,
328
+ log: (message) => console.log(message),
329
+ ...overrides,
330
+ };
331
+ const config = explicitTenantAppConfig(baseConfig, tenant, targets);
332
+ const catalog = await fetchWindowsCatalog(config, io);
333
+ const actionTargets = selectCatalogAppTargets(targets, catalog.models, config, { log: io.log });
334
+ const supported = new Set(actionTargets);
335
+ const unsupported = targets.filter((target) => !supported.has(target));
336
+ const userData = io.claudeUserData(environment, config.tenantId);
337
+ const paths = appPaths(homeDir, config.tenantId, { claudeUserData: userData, tenantName: config.tenantName });
338
+ const vendorPaths = {};
339
+ const preparedTargets = new Set(skipVendor ? actionTargets : skipVendorTargets);
340
+
341
+ for (const target of actionTargets) {
342
+ const isClaude = target === "claude";
343
+ const find = isClaude ? io.findClaudeApp : io.findChatGPTApp;
344
+ const ensure = isClaude ? io.ensureClaudeApp : io.ensureChatGPTApp;
345
+ let binary = find(environment);
346
+ if (!preparedTargets.has(target)) {
347
+ const confirmed = await confirmVendorInstall(target, { platform: "win32", mode });
348
+ if (!confirmed) {
349
+ throw new Error(`${isClaude ? "Claude" : "ChatGPT/Codex"} vendor ${mode === "update" ? "update" : "installation"} requires confirmation`);
350
+ }
351
+ const vendor = ensure({ update: mode === "update" }, { environment });
352
+ binary = vendor.binary;
353
+ if (!binary) {
354
+ const label = isClaude ? "Claude" : "ChatGPT/Codex";
355
+ throw new Error(`${label} vendor installation failed (${windowsProcessFailure(vendor.result)})`);
356
+ }
357
+ }
358
+ if (binary) vendorPaths[target] = binary;
359
+ }
360
+ if (actionTargets.includes("claude") && !vendorPaths.claude) {
361
+ throw new Error("Claude vendor app is unavailable");
362
+ }
363
+ if (actionTargets.includes("chatgpt")) {
364
+ let managed = io.findManagedChatGPTApp(paths.root);
365
+ const stale = vendorPaths.chatgpt && !io.chatGPTStageIsCurrent(vendorPaths.chatgpt, paths.root);
366
+ if (!managed || stale) {
367
+ if (!vendorPaths.chatgpt) throw new Error("ChatGPT/Codex vendor app is unavailable");
368
+ managed = io.stageChatGPTApp(vendorPaths.chatgpt, paths.root);
369
+ }
370
+ if (!managed) throw new Error("the managed ChatGPT/Codex app could not be staged");
371
+ }
372
+
373
+ configureWindowsApps(config, actionTargets, vendorPaths, catalog, {
374
+ ...io,
375
+ homeDir,
376
+ environment,
377
+ });
378
+ for (const target of actionTargets) {
379
+ const { client, env, label } = appSkillTarget(target, paths);
380
+ await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
381
+ if (target === "chatgpt") secureManagedCodexHome(paths.chatgpt.codexHome);
382
+ }
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
+ const verification = {
390
+ claude: !actionTargets.includes("claude") || io.existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)),
391
+ chatgpt: !actionTargets.includes("chatgpt") || io.existsSync(path.join(paths.chatgpt.codexHome, "config.toml")),
392
+ };
393
+ if (!Object.values(verification).every(Boolean)) throw new Error("Windows app profile verification failed");
394
+ return { tenantId: config.tenantId, targets: actionTargets, unsupported, paths, verification };
395
+ }
396
+
397
+ /** Reconcile one explicit tenant's macOS app bundles and profiles. */
398
+ export async function reconcileMacTenantApps({
399
+ baseConfig,
400
+ tenant,
401
+ mode = "update",
402
+ targets = ["claude", "chatgpt"],
403
+ skipVendor = false,
404
+ skipVendorTargets = [],
405
+ confirmVendorInstall = async () => true,
406
+ homeDir = os.homedir(),
407
+ } = {}, overrides = {}) {
408
+ const io = {
409
+ status: appStatus,
410
+ bundleCurrent: bundleIsCurrent,
411
+ ensureVendor: ensureVendorApp,
412
+ fetchModels: fetchGatewayModels,
413
+ quitApps: quitBlockingApps,
414
+ installFiles: installManagedAppFiles,
415
+ syncSkills: syncSkillsSafe,
416
+ syncAgents: syncAgentProfilesSafe,
417
+ secureCodexHome: secureManagedCodexHome,
418
+ openLauncher: openManagedLauncher,
419
+ existsSync: fs.existsSync,
420
+ log: (message) => console.log(message),
421
+ ...overrides,
422
+ };
423
+ const config = explicitTenantAppConfig(baseConfig, tenant, targets);
424
+ let catalog;
425
+ try {
426
+ catalog = await io.fetchModels(config);
427
+ } catch (error) {
428
+ throw new Error(`tenant model catalog is unavailable (${redactSecretText(error?.message || error)}); no Impel app files were changed`);
429
+ }
430
+ const actionTargets = selectCatalogAppTargets(targets, catalog.models, config, { log: io.log });
431
+ const supported = new Set(actionTargets);
432
+ const unsupported = targets.filter((target) => !supported.has(target));
433
+ const vendorPaths = {};
434
+ const statuses = io.status(actionTargets, homeDir, config.tenantId, config.tenantName);
435
+ for (const status of statuses) vendorPaths[status.target] ||= status.vendorPath;
436
+ const preparedTargets = new Set(skipVendor ? actionTargets : skipVendorTargets);
437
+ {
438
+ for (const target of actionTargets.filter((target) => !preparedTargets.has(target))) {
439
+ if (vendorPaths[target]) continue;
440
+ const confirmed = await confirmVendorInstall(target, { platform: "darwin", mode });
441
+ if (!confirmed) throw new Error(`${target} vendor installation requires confirmation`);
442
+ const result = await io.ensureVendor(target, { homeDir });
443
+ vendorPaths[target] = result.path;
444
+ if (!result.path) throw new Error(`${target} verified vendor app is unavailable; retry the pinned download`);
445
+ }
446
+ }
447
+ const staleTargets = statuses.filter((status) => !io.bundleCurrent(status)).map((status) => status.target);
448
+ let closedApps = [];
449
+ if (staleTargets.length) {
450
+ const result = await io.quitApps(staleTargets, { tenantId: config.tenantId, tenantName: config.tenantName });
451
+ if (Array.isArray(result)) closedApps = result;
452
+ }
453
+ const reopenTargets = new Set(staleTargets.filter((target) => closedApps.includes(
454
+ managedLauncherName(target, { tenantId: config.tenantId, tenantName: config.tenantName }),
455
+ )));
456
+ const installed = await io.installFiles({
457
+ config,
458
+ targets: actionTargets,
459
+ models: catalog.models,
460
+ homeDir,
461
+ vendorPaths,
462
+ writeBundles: staleTargets,
463
+ });
464
+ const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
465
+ for (const item of installed) {
466
+ const { client, env, label } = appSkillTarget(item.target, paths);
467
+ await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
468
+ if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
469
+ }
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
+ });
476
+ for (const item of installed) {
477
+ if (reopenTargets.has(item.target)) io.openLauncher(item.launcher);
478
+ }
479
+ const verification = Object.fromEntries(actionTargets.map((target) => [
480
+ target,
481
+ io.existsSync(paths[target].launcher) && io.existsSync(
482
+ target === "claude"
483
+ ? path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)
484
+ : path.join(paths.chatgpt.codexHome, "config.toml"),
485
+ ),
486
+ ]));
487
+ if (!Object.values(verification).every(Boolean)) throw new Error("macOS app bundle/profile verification failed");
488
+ return { tenantId: config.tenantId, targets: actionTargets, unsupported, paths, verification, installed };
489
+ }
490
+
491
+ export function reconcileTenantApps(options = {}, overrides = {}) {
492
+ const platform = options.platform || process.platform;
493
+ if (platform === "win32") return reconcileWindowsTenantApps(options, overrides);
494
+ if (platform === "darwin") return reconcileMacTenantApps(options, overrides);
495
+ return Promise.resolve({ tenantId: options.tenant?.id, targets: [], unsupported: ["claude", "chatgpt"], paths: null, verification: {} });
496
+ }
497
+
268
498
  export async function cmdWindowsApps(argv, overrides = {}) {
269
499
  const [action = "status", rawTarget, ...rest] = argv;
270
500
  const targetToken = rawTarget?.startsWith("--") ? undefined : rawTarget;
@@ -497,6 +727,8 @@ export async function cmdWindowsApps(argv, overrides = {}) {
497
727
 
498
728
  export async function cmdApps(argv, overrides = {}) {
499
729
  const platform = overrides.platform || process.platform;
730
+ const legacyUpdateHandoff = await maybeRunLegacyUpdateHandoff(argv, overrides);
731
+ if (legacyUpdateHandoff !== null) return legacyUpdateHandoff;
500
732
  const allTenantUpdate = await maybeUpdateAllInstalledTenants(argv, overrides, platform);
501
733
  if (allTenantUpdate !== null) return allTenantUpdate;
502
734
  if (platform === "win32") return cmdWindowsApps(argv, overrides);
@@ -0,0 +1,174 @@
1
+ // Hidden fresh-build continuation used by `impel update`.
2
+
3
+ import { loadConfig, saveConfig, redactSecretText } from "../config.js";
4
+ import { runInstallRecovery } from "../installRecovery/engine.js";
5
+ import { printReconciliationSummary, reconcileAllTenants, selectDefaultTenant } from "../provisioning.js";
6
+ import { fetchTenants } from "../tenants.js";
7
+ import { promptText } from "../prompt.js";
8
+ import { restoreNativeProfiles } from "./use.js";
9
+
10
+ function confirmed(answer) {
11
+ return /^(?:y|yes)$/iu.test(String(answer || "").trim());
12
+ }
13
+
14
+ function mergeTenantReport(report, replacement) {
15
+ const byId = new Map(replacement.tenants.map((tenant) => [tenant.tenantId, tenant]));
16
+ report.tenants = report.tenants.map((tenant) => byId.get(tenant.tenantId) || tenant);
17
+ report.passed = report.tenants.every((tenant) => (
18
+ ["ready", "unavailable"].includes(tenant.cli)
19
+ && ["ready", "unavailable"].includes(tenant.apps)
20
+ && ["ready", "unavailable"].includes(tenant.shell)
21
+ ));
22
+ return report;
23
+ }
24
+
25
+ export async function cmdConverge(argv = [], overrides = {}) {
26
+ const skipApps = argv.includes("--skip-apps");
27
+ const noRecovery = argv.includes("--no-recovery");
28
+ const io = {
29
+ loadConfig,
30
+ saveConfig,
31
+ fetchTenants,
32
+ reconcile: reconcileAllTenants,
33
+ recoverInstall: runInstallRecovery,
34
+ restoreNativeProfiles,
35
+ promptText,
36
+ isTTY: process.stdin.isTTY,
37
+ platform: process.platform,
38
+ environment: process.env,
39
+ ...overrides,
40
+ };
41
+ const config = io.loadConfig();
42
+ if (!config?.pat) {
43
+ console.error("impel update: not authenticated; run `impel setup` first.");
44
+ process.exitCode = 1;
45
+ return false;
46
+ }
47
+ let listing;
48
+ try {
49
+ listing = await io.fetchTenants(config);
50
+ } catch (error) {
51
+ console.error(`impel update: tenant discovery failed (${redactSecretText(error?.message || error)})`);
52
+ process.exitCode = 1;
53
+ return false;
54
+ }
55
+ const selected = selectDefaultTenant(listing, { currentTenantId: config.tenantId });
56
+ config.tenantId = selected.id;
57
+ config.tenantName = selected.name;
58
+ if (listing.productAccess) config.productAccess = listing.productAccess;
59
+ if (listing.scopes) config.scopes = listing.scopes;
60
+ config.tenantsUpdatedAt = new Date().toISOString();
61
+ io.saveConfig(config);
62
+ try {
63
+ io.restoreNativeProfiles({ quiet: true });
64
+ } catch (error) {
65
+ console.warn(`Legacy native-profile cleanup was skipped safely (${redactSecretText(error?.message || error)}).`);
66
+ }
67
+
68
+ const vendorAppDecisions = new Map();
69
+ const confirmVendorInstall = async (target, context) => {
70
+ if (vendorAppDecisions.has(target)) return vendorAppDecisions.get(target);
71
+ if (!io.isTTY) {
72
+ vendorAppDecisions.set(target, false);
73
+ return false;
74
+ }
75
+ const label = target === "claude" ? "Claude" : "ChatGPT/Codex";
76
+ const allowed = confirmed(await io.promptText(
77
+ `Allow the verified ${label} vendor ${context.mode === "update" ? "update" : "installation"}? [y/N] `,
78
+ ));
79
+ vendorAppDecisions.set(target, allowed);
80
+ return allowed;
81
+ };
82
+
83
+ const run = (
84
+ tenants = listing.tenants,
85
+ includeApps = !skipApps,
86
+ confirmInstall = confirmVendorInstall,
87
+ ) => io.reconcile({
88
+ config,
89
+ listing: { ...listing, tenants },
90
+ mode: "update",
91
+ includeApps,
92
+ platform: io.platform,
93
+ environment: io.environment,
94
+ confirmVendorInstall: confirmInstall,
95
+ }, overrides.reconcileOverrides || {});
96
+ let report = await run();
97
+ if (!report.passed && !noRecovery) {
98
+ for (const failed of report.tenants.filter((tenant) => tenant.status === "failed")) {
99
+ const tenant = listing.tenants.find((candidate) => candidate.id === failed.tenantId);
100
+ if (!tenant) continue;
101
+ let retryReport = null;
102
+ let retryPassed = false;
103
+ const retryTenant = async (confirmInstall) => {
104
+ retryReport = await run([tenant], !skipApps, confirmInstall);
105
+ retryPassed = retryReport.passed;
106
+ return retryPassed;
107
+ };
108
+ const retrySafeState = async () => {
109
+ const profileReport = await run([tenant], false, async () => false);
110
+ const profile = profileReport.tenants[0];
111
+ const current = report.tenants.find((candidate) => candidate.tenantId === tenant.id);
112
+ const cliReady = ["ready", "unavailable"].includes(profile?.cli);
113
+ const nonCliReady = ["ready", "unavailable"].includes(current?.apps)
114
+ && ["ready", "unavailable"].includes(current?.shell);
115
+ retryPassed = cliReady && nonCliReady;
116
+ if (retryPassed) {
117
+ const combined = {
118
+ ...current,
119
+ cli: profile.cli,
120
+ clients: {
121
+ claude: { ...current.clients.claude, cli: profile.clients.claude.cli },
122
+ codex: { ...current.clients.codex, cli: profile.clients.codex.cli },
123
+ },
124
+ errors: profile.errors,
125
+ status: "ready",
126
+ };
127
+ retryReport = { ...profileReport, tenants: [combined], passed: true };
128
+ }
129
+ return retryPassed;
130
+ };
131
+ await io.recoverInstall({
132
+ failure: {
133
+ scope: "tenant",
134
+ tenantId: tenant.id,
135
+ platform: io.platform,
136
+ architecture: process.arch,
137
+ step: "update.tenant_convergence",
138
+ message: failed.errors.join("; ") || "Tenant update verification failed.",
139
+ diagnostics: { tenant: tenant.id },
140
+ },
141
+ config,
142
+ goals: [{
143
+ id: `tenant-${tenant.id}-ready`,
144
+ description: `Tenant ${tenant.id} is upgraded and locally verified`,
145
+ run: () => retryPassed,
146
+ }],
147
+ explicit: true,
148
+ noRecovery: false,
149
+ actionContext: {
150
+ tenantId: tenant.id,
151
+ repairProfiles: async () => {
152
+ const profileReport = await run([tenant], false, async () => false);
153
+ return ["ready", "unavailable"].includes(profileReport.tenants[0]?.cli);
154
+ },
155
+ retryStep: () => retrySafeState(),
156
+ installVendorApp: (target) => retryTenant(async (product) => (
157
+ target === "all"
158
+ || product === target
159
+ || (target === "codex" && product === "chatgpt")
160
+ )),
161
+ },
162
+ }, overrides.recoveryOverrides || {});
163
+ if (retryReport) mergeTenantReport(report, retryReport);
164
+ }
165
+ }
166
+ printReconciliationSummary(report);
167
+ console.log(`CLI tenant: ${selected.id} (change with \`impel tenant use <tenant>\`)`);
168
+ if (!report.passed) {
169
+ console.error("impel update: one or more tenant surfaces remain incomplete.");
170
+ process.exitCode = 1;
171
+ return false;
172
+ }
173
+ return true;
174
+ }