impel-cli 0.16.4 → 0.17.0

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.4",
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.0",
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/apps.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  import { normalizeTenantId } from "./tenants.js";
13
13
  import { impelCliInvocation } from "./selfInvocation.js";
14
14
  import { crossAppModelsEnabled, redactSecretText } from "./config.js";
15
+ import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
15
16
  import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
16
17
  import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
17
18
 
@@ -163,7 +164,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
163
164
 
164
165
  // Bump when the written config/manifest schema changes; a mismatch forces the
165
166
  // slow open path (and thus a full config rewrite) after a CLI update.
166
- export const CURRENT_CONFIG_VERSION = 15;
167
+ export const CURRENT_CONFIG_VERSION = 16;
167
168
 
168
169
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
169
170
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -617,8 +618,12 @@ export function installManagedAppFiles({
617
618
  writeClaudeDefaultApp(paths);
618
619
  writeClaudeCodeSettings(paths);
619
620
  writeClaudeConfig(paths, config, models);
621
+ ensureClaudeSessionHooks(paths.claude.userData, config.tenantId, "claude_desktop");
622
+ }
623
+ else {
624
+ writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
625
+ ensureCodexSessionHooks(paths.chatgpt.codexHome, config.tenantId, "codex_desktop");
620
626
  }
621
- else writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
622
627
  // Non-bundle targets are the background-refresh / fast-open path: configs,
623
628
  // token helper, catalog, and manifest only. Bundle swaps require the app
624
629
  // to be closed (see quitBlockingApps), so those paths never attempt one.
@@ -1024,6 +1029,7 @@ function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocation
1024
1029
  const managedToml = [
1025
1030
  CHATGPT_CONFIG_START,
1026
1031
  "# Managed by `impel app update`. This profile is isolated from ~/.codex.",
1032
+ "# Impel session-hook trust is pinned separately by exact handler hash.",
1027
1033
  `model = ${tomlString(selectedModel.slug)}`,
1028
1034
  'model_provider = "impel"',
1029
1035
  `chatgpt_base_url = ${tomlString(`${config.gatewayUrl}/chatgpt_passthrough/backend-api`)}`,
package/src/cli.js CHANGED
@@ -15,61 +15,43 @@ import { cmdAgents } from "./commands/agents.js";
15
15
  import { cmdTenant } from "./commands/tenant.js";
16
16
  import { cmdDoctor } from "./commands/doctor.js";
17
17
  import { cmdSetup } from "./commands/setup.js";
18
+ import { cmdSessions } from "./commands/sessions.js";
18
19
  import { cmdUpdate } from "./commands/update.js";
19
20
  import { cmdExperimental } from "./commands/experimental.js";
21
+ import { cmdConverge } from "./commands/converge.js";
20
22
 
21
- const HELP = `impel — the Impel gateway CLI: isolated Claude Code, Codex, and desktop apps
23
+ const HELP = `impel — isolated Impel workspaces for every tenant
22
24
 
23
25
  Get started:
24
- impel setup Guided end-to-end onboarding: token, tenant,
25
- platform clients, gateway verification
26
- (--pat <pat> --tenant <org> --skip-apps/--skip-clis
27
- --repair/--no-recovery)
28
- impel update Update everything: the CLI itself, then the
29
- Impel apps, skills, and agents (--check, --skip-apps)
30
-
31
- 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:
32
32
  impel claude [args...] Launch Claude Code with an isolated Impel profile
33
33
  impel codex [args...] Launch Codex with an isolated Impel profile
34
- impel app open [target] [--tenant <org>] Install/configure on first use, then open the app(s)
35
- impel status Tenant, PAT, per-tool mode, gateway reachability
36
- 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
37
+
38
+ Workspace:
39
+ impel tasks list|get|create|update|delete Work with Impel tickets (aliases: task, ticket[s])
37
40
 
38
- Manage:
39
- impel auth [--pat <pat>] Store your PAT + URLs (--gateway/--app <url>)
41
+ Account:
40
42
  impel pat create --label <label> Mint a personal PAT (--org/--scopes/--ttl-days)
41
43
  impel pat create --agent <agent-id> Mint an org-scoped PAT associated with an agent
42
44
  impel pat revoke <token-id> --yes Irreversibly revoke one of your PATs
43
- impel tenant list|current|use <org> List or select the active organization
44
- impel tasks list|get|create|update|delete CRUD Impel tickets (aliases: task, ticket[s])
45
- impel token [--tenant <org>] Print the selected-tenant bearer
46
- impel mcp Run the local Impel MCP stdio bridge
47
- impel skills sync [claude|codex|all] Sync shared skills into managed clients
48
- impel agents sync [claude|codex|all] Sync explicit tenant agents into native clients
49
-
50
- Desktop apps (macOS and Windows):
51
- target is claude, chatgpt/codex, or all (default: all)
52
- impel app install [target] [--tenant <org>] Install that tenant's isolated app/profile
53
- impel app update [target] [--tenant <org>] Update profiles; all without --tenant updates
54
- every installed tenant (--force: clean rebuild)
55
- impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
56
- impel app status [target] [--tenant <org>] Show isolated app and vendor versions
57
- impel app uninstall [target] [--tenant <org>] [--keep-data]
58
- Remove only that tenant's managed apps/data
59
-
60
- Native profile switching (advanced; setup never touches native tools):
61
- impel use gateway [claude|codex|all] Route native Claude Code/Codex through the gateway
62
- impel use account [claude|codex|all] Revert to your own Anthropic/OpenAI login
63
- 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
64
49
 
65
50
  impel help Show this help
66
51
  impel --version Show the installed version
67
52
 
68
53
  Env:
69
- IMPEL_GATEWAY_URL Default gateway URL when --gateway isn't passed.
70
- IMPEL_APP_URL Default app/control-plane URL for setup, auth, PATs, and tasks.
71
54
  IMPEL_SKIP_UPDATE_CHECK=1 Silence launch-time update notices.
72
- IMPEL_SKIP_AGENT_SYNC=1 Keep the last native-agent catalog without refreshing it.
73
55
  IMPEL_DISABLE_INSTALL_RECOVERY=1 Disable local and hosted install recovery.
74
56
 
75
57
  Config file:
@@ -121,6 +103,10 @@ export async function main(argv) {
121
103
  case "mcp":
122
104
  return cmdMcp(rest);
123
105
 
106
+ // Hidden transport used by managed Claude/Codex lifecycle hooks.
107
+ case "sessions":
108
+ return cmdSessions(rest);
109
+
124
110
  case "claude":
125
111
  case "codex":
126
112
  return cmdLaunch(cmd, rest);
@@ -154,6 +140,8 @@ export async function main(argv) {
154
140
  case "agent":
155
141
  return cmdAgents(rest);
156
142
 
143
+ // Compatibility shims retained for one release. They can only refuse an
144
+ // old enable request or restore legacy Impel-owned native mutations.
157
145
  case "on":
158
146
  return runUse("gateway", rest[0]);
159
147
 
@@ -164,7 +152,7 @@ export async function main(argv) {
164
152
  const [mode, targetToken] = rest;
165
153
  if (mode !== "gateway" && mode !== "account") {
166
154
  console.error(`impel use: expected \`gateway\` or \`account\`, got "${mode ?? ""}".`);
167
- 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`.");
168
156
  process.exitCode = 1;
169
157
  return;
170
158
  }
@@ -173,14 +161,23 @@ export async function main(argv) {
173
161
 
174
162
  case "setup":
175
163
  // The old per-tool aliases (setup claude|codex-cli|codex-app) flipped
176
- // native configs; that swap is no longer part of setup. cmdSetup rejects
177
- // positional targets with a pointer to `impel use gateway`.
164
+ // native configs; that swap is no longer part of setup.
178
165
  return cmdSetup(rest);
179
166
 
180
167
  case "update":
181
168
  case "upgrade":
182
169
  return cmdUpdate(rest);
183
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
+
184
181
  // Intentionally omitted from the public help while the contract is gated
185
182
  // server-side and limited to Impel-managed desktop apps.
186
183
  case "experimental":
@@ -12,6 +12,7 @@ import { CONFIG_DIR } from "./config.js";
12
12
  import { normalizeTenantId } from "./tenants.js";
13
13
  import { impelCliInvocation, impelMcpInvocation } from "./selfInvocation.js";
14
14
  import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
15
+ import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
15
16
 
16
17
  export const IMPEL_CLI_PROFILES_DIR = path.join(CONFIG_DIR, "cli");
17
18
 
@@ -131,6 +132,7 @@ export function ensureImpelClaudeProfile(gatewayUrl, tenantId, { crossAppModels
131
132
 
132
133
  writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
133
134
  writePrivateFile(userConfigPath, `${JSON.stringify(userConfig, null, 2)}\n`);
135
+ ensureClaudeSessionHooks(configDir, tenantId, "claude_cli");
134
136
 
135
137
  return { configDir, settingsPath, userConfigPath };
136
138
  }
@@ -203,9 +205,10 @@ export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
203
205
 
204
206
  const { preamble, rest } = splitTomlPreamble(withoutManagedBlock);
205
207
  const providerLine = 'model_provider = "impel"';
206
- const nextPreamble = CODEX_PROVIDER_LINE_RE.test(preamble)
208
+ let nextPreamble = CODEX_PROVIDER_LINE_RE.test(preamble)
207
209
  ? preamble.replace(CODEX_PROVIDER_LINE_RE, providerLine)
208
210
  : `${preamble.trimEnd()}${preamble.trim() ? "\n" : ""}${providerLine}\n`;
211
+ nextPreamble = nextPreamble.replace(/^bypass_hook_trust[ \t]*=[ \t]*(?:true|false)[ \t]*\n?/gm, "");
209
212
  const restText = rest.trim();
210
213
  const next = [nextPreamble.trimEnd(), codexManagedBlock(gatewayUrl, tenantId), restText]
211
214
  .filter(Boolean)
@@ -213,6 +216,7 @@ export function ensureImpelCodexProfile(gatewayUrl, tenantId) {
213
216
  .concat("\n");
214
217
 
215
218
  writePrivateFile(configPath, hardenManagedCodexToml(next, configPath));
219
+ ensureCodexSessionHooks(codexHome, tenantId, "codex_cli");
216
220
  secureManagedCodexHome(codexHome);
217
221
  return { codexHome, configPath };
218
222
  }
@@ -265,6 +265,221 @@ function configureWindowsApps(config, targets, vendorPaths, catalog, io) {
265
265
  return { catalog, userData };
266
266
  }
267
267
 
268
+ function explicitTenantAppConfig(baseConfig, tenant, targets) {
269
+ if (!baseConfig?.pat || !baseConfig?.gatewayUrl) throw new Error("an authenticated Impel config is required");
270
+ const tenantId = normalizeTenantId(tenant?.id || tenant?.slug);
271
+ const providers = crossAppModelsEnabled(baseConfig)
272
+ ? ["claude", "codex"]
273
+ : targets.map((target) => (target === "claude" ? "claude" : "codex"));
274
+ assertProviderScopes(baseConfig.scopes, providers, { requireLive: true });
275
+ return {
276
+ ...baseConfig,
277
+ tenantId,
278
+ tenantName: tenant?.name || tenantId,
279
+ pat: tenantCredential(baseConfig.pat, tenantId),
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Reconcile one explicit tenant's Windows app profiles without parsing CLI
285
+ * arguments or changing the globally selected tenant. Shared vendor work is
286
+ * skipped after the first tenant by passing skipVendor.
287
+ */
288
+ export async function reconcileWindowsTenantApps({
289
+ baseConfig,
290
+ tenant,
291
+ mode = "update",
292
+ targets = ["claude", "chatgpt"],
293
+ skipVendor = false,
294
+ skipVendorTargets = [],
295
+ confirmVendorInstall = async () => true,
296
+ homeDir = os.homedir(),
297
+ environment = process.env,
298
+ } = {}, overrides = {}) {
299
+ const io = {
300
+ findClaudeApp: findWindowsClaudeApp,
301
+ ensureClaudeApp: ensureWindowsClaudeApp,
302
+ claudeUserData: windowsClaudeUserData,
303
+ findChatGPTApp: findWindowsChatGPTApp,
304
+ ensureChatGPTApp: ensureWindowsChatGPTApp,
305
+ findManagedChatGPTApp: findManagedWindowsChatGPTApp,
306
+ stageChatGPTApp: stageWindowsChatGPTApp,
307
+ chatGPTStageIsCurrent: windowsChatGPTStageIsCurrent,
308
+ fetchModels: fetchGatewayModels,
309
+ installFiles: installManagedAppFiles,
310
+ syncSkills: syncSkillsSafe,
311
+ syncAgents: syncAgentProfilesSafe,
312
+ existsSync: fs.existsSync,
313
+ log: (message) => console.log(message),
314
+ ...overrides,
315
+ };
316
+ const config = explicitTenantAppConfig(baseConfig, tenant, targets);
317
+ const catalog = await fetchWindowsCatalog(config, io);
318
+ const actionTargets = selectCatalogAppTargets(targets, catalog.models, config, { log: io.log });
319
+ const supported = new Set(actionTargets);
320
+ const unsupported = targets.filter((target) => !supported.has(target));
321
+ const userData = io.claudeUserData(environment, config.tenantId);
322
+ const paths = appPaths(homeDir, config.tenantId, { claudeUserData: userData, tenantName: config.tenantName });
323
+ const vendorPaths = {};
324
+ const preparedTargets = new Set(skipVendor ? actionTargets : skipVendorTargets);
325
+
326
+ for (const target of actionTargets) {
327
+ const isClaude = target === "claude";
328
+ const find = isClaude ? io.findClaudeApp : io.findChatGPTApp;
329
+ const ensure = isClaude ? io.ensureClaudeApp : io.ensureChatGPTApp;
330
+ let binary = find(environment);
331
+ if (!preparedTargets.has(target)) {
332
+ const confirmed = await confirmVendorInstall(target, { platform: "win32", mode });
333
+ if (!confirmed) {
334
+ throw new Error(`${isClaude ? "Claude" : "ChatGPT/Codex"} vendor ${mode === "update" ? "update" : "installation"} requires confirmation`);
335
+ }
336
+ const vendor = ensure({ update: mode === "update" }, { environment });
337
+ binary = vendor.binary;
338
+ if (!binary) {
339
+ const label = isClaude ? "Claude" : "ChatGPT/Codex";
340
+ throw new Error(`${label} vendor installation failed (${windowsProcessFailure(vendor.result)})`);
341
+ }
342
+ }
343
+ if (binary) vendorPaths[target] = binary;
344
+ }
345
+ if (actionTargets.includes("claude") && !vendorPaths.claude) {
346
+ throw new Error("Claude vendor app is unavailable");
347
+ }
348
+ if (actionTargets.includes("chatgpt")) {
349
+ let managed = io.findManagedChatGPTApp(paths.root);
350
+ const stale = vendorPaths.chatgpt && !io.chatGPTStageIsCurrent(vendorPaths.chatgpt, paths.root);
351
+ if (!managed || stale) {
352
+ if (!vendorPaths.chatgpt) throw new Error("ChatGPT/Codex vendor app is unavailable");
353
+ managed = io.stageChatGPTApp(vendorPaths.chatgpt, paths.root);
354
+ }
355
+ if (!managed) throw new Error("the managed ChatGPT/Codex app could not be staged");
356
+ }
357
+
358
+ configureWindowsApps(config, actionTargets, vendorPaths, catalog, {
359
+ ...io,
360
+ homeDir,
361
+ environment,
362
+ });
363
+ for (const target of actionTargets) {
364
+ const { client, env, label } = appSkillTarget(target, paths);
365
+ await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
366
+ if (target === "chatgpt") secureManagedCodexHome(paths.chatgpt.codexHome);
367
+ }
368
+ await io.syncAgents({
369
+ profiles: actionTargets.map((target) => appAgentProfile(target, paths)),
370
+ gatewayUrl: config.gatewayUrl,
371
+ credential: config.pat,
372
+ tenantId: config.tenantId,
373
+ });
374
+ const verification = {
375
+ claude: !actionTargets.includes("claude") || io.existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)),
376
+ chatgpt: !actionTargets.includes("chatgpt") || io.existsSync(path.join(paths.chatgpt.codexHome, "config.toml")),
377
+ };
378
+ if (!Object.values(verification).every(Boolean)) throw new Error("Windows app profile verification failed");
379
+ return { tenantId: config.tenantId, targets: actionTargets, unsupported, paths, verification };
380
+ }
381
+
382
+ /** Reconcile one explicit tenant's macOS app bundles and profiles. */
383
+ export async function reconcileMacTenantApps({
384
+ baseConfig,
385
+ tenant,
386
+ mode = "update",
387
+ targets = ["claude", "chatgpt"],
388
+ skipVendor = false,
389
+ skipVendorTargets = [],
390
+ confirmVendorInstall = async () => true,
391
+ homeDir = os.homedir(),
392
+ } = {}, overrides = {}) {
393
+ const io = {
394
+ status: appStatus,
395
+ bundleCurrent: bundleIsCurrent,
396
+ ensureVendor: ensureVendorApp,
397
+ fetchModels: fetchGatewayModels,
398
+ quitApps: quitBlockingApps,
399
+ installFiles: installManagedAppFiles,
400
+ syncSkills: syncSkillsSafe,
401
+ syncAgents: syncAgentProfilesSafe,
402
+ secureCodexHome: secureManagedCodexHome,
403
+ openLauncher: openManagedLauncher,
404
+ existsSync: fs.existsSync,
405
+ log: (message) => console.log(message),
406
+ ...overrides,
407
+ };
408
+ const config = explicitTenantAppConfig(baseConfig, tenant, targets);
409
+ let catalog;
410
+ try {
411
+ catalog = await io.fetchModels(config);
412
+ } catch (error) {
413
+ throw new Error(`tenant model catalog is unavailable (${redactSecretText(error?.message || error)}); no Impel app files were changed`);
414
+ }
415
+ const actionTargets = selectCatalogAppTargets(targets, catalog.models, config, { log: io.log });
416
+ const supported = new Set(actionTargets);
417
+ const unsupported = targets.filter((target) => !supported.has(target));
418
+ const vendorPaths = {};
419
+ const statuses = io.status(actionTargets, homeDir, config.tenantId, config.tenantName);
420
+ for (const status of statuses) vendorPaths[status.target] ||= status.vendorPath;
421
+ const preparedTargets = new Set(skipVendor ? actionTargets : skipVendorTargets);
422
+ {
423
+ for (const target of actionTargets.filter((target) => !preparedTargets.has(target))) {
424
+ if (vendorPaths[target]) continue;
425
+ const confirmed = await confirmVendorInstall(target, { platform: "darwin", mode });
426
+ if (!confirmed) throw new Error(`${target} vendor installation requires confirmation`);
427
+ const result = await io.ensureVendor(target, { homeDir });
428
+ vendorPaths[target] = result.path;
429
+ if (!result.path) throw new Error(`${target} verified vendor app is unavailable; retry the pinned download`);
430
+ }
431
+ }
432
+ const staleTargets = statuses.filter((status) => !io.bundleCurrent(status)).map((status) => status.target);
433
+ let closedApps = [];
434
+ if (staleTargets.length) {
435
+ const result = await io.quitApps(staleTargets, { tenantId: config.tenantId, tenantName: config.tenantName });
436
+ if (Array.isArray(result)) closedApps = result;
437
+ }
438
+ const reopenTargets = new Set(staleTargets.filter((target) => closedApps.includes(
439
+ managedLauncherName(target, { tenantId: config.tenantId, tenantName: config.tenantName }),
440
+ )));
441
+ const installed = await io.installFiles({
442
+ config,
443
+ targets: actionTargets,
444
+ models: catalog.models,
445
+ homeDir,
446
+ vendorPaths,
447
+ writeBundles: staleTargets,
448
+ });
449
+ const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
450
+ for (const item of installed) {
451
+ const { client, env, label } = appSkillTarget(item.target, paths);
452
+ await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
453
+ if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
454
+ }
455
+ await io.syncAgents({
456
+ profiles: installed.map((item) => appAgentProfile(item.target, paths)),
457
+ gatewayUrl: config.gatewayUrl,
458
+ credential: config.pat,
459
+ tenantId: config.tenantId,
460
+ });
461
+ for (const item of installed) {
462
+ if (reopenTargets.has(item.target)) io.openLauncher(item.launcher);
463
+ }
464
+ const verification = Object.fromEntries(actionTargets.map((target) => [
465
+ target,
466
+ io.existsSync(paths[target].launcher) && io.existsSync(
467
+ target === "claude"
468
+ ? path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)
469
+ : path.join(paths.chatgpt.codexHome, "config.toml"),
470
+ ),
471
+ ]));
472
+ if (!Object.values(verification).every(Boolean)) throw new Error("macOS app bundle/profile verification failed");
473
+ return { tenantId: config.tenantId, targets: actionTargets, unsupported, paths, verification, installed };
474
+ }
475
+
476
+ export function reconcileTenantApps(options = {}, overrides = {}) {
477
+ const platform = options.platform || process.platform;
478
+ if (platform === "win32") return reconcileWindowsTenantApps(options, overrides);
479
+ if (platform === "darwin") return reconcileMacTenantApps(options, overrides);
480
+ return Promise.resolve({ tenantId: options.tenant?.id, targets: [], unsupported: ["claude", "chatgpt"], paths: null, verification: {} });
481
+ }
482
+
268
483
  export async function cmdWindowsApps(argv, overrides = {}) {
269
484
  const [action = "status", rawTarget, ...rest] = argv;
270
485
  const targetToken = rawTarget?.startsWith("--") ? undefined : rawTarget;
@@ -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
+ }