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.
@@ -1,11 +1,7 @@
1
- // `impel setup` — end-to-end onboarding: token tenant platform clients →
2
- // verify. macOS installs the isolated desktop apps; Windows ensures the two
3
- // official vendor CLIs exist and prepares isolated CLI + desktop app profiles.
4
- // Native Claude Code/Codex profiles are never touched.
1
+ // `impel setup` — authenticate once, then converge every accessible tenant.
5
2
 
6
3
  import { parseFlags } from "../args.js";
7
4
  import {
8
- crossAppModelsEnabled,
9
5
  loadConfig,
10
6
  saveConfig,
11
7
  normalizeGatewayUrl,
@@ -15,104 +11,137 @@ import {
15
11
  redactSecretText,
16
12
  } from "../config.js";
17
13
  import { promptSecret, promptText } from "../prompt.js";
18
- import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "../cliProfiles.js";
19
14
  import { fetchTenants, normalizeTenantId, tenantCredential } from "../tenants.js";
20
- import { rewriteTenantTokenHelper } from "../apps.js";
21
- import { cmdApps } from "./apps.js";
22
- import { prepareWindowsClis, windowsCliInstallCommands } from "../windowsSetup.js";
15
+ import {
16
+ printReconciliationSummary,
17
+ reconcileAllTenants,
18
+ selectDefaultTenant,
19
+ } from "../provisioning.js";
20
+ import { prepareWindowsClis } from "../windowsSetup.js";
23
21
  import { runInstallRecovery } from "../installRecovery/engine.js";
22
+ import { restoreNativeProfiles } from "./use.js";
24
23
 
25
- const HELP = `impel setup - guided end-to-end gateway setup
24
+ const HELP = `impel setup - prepare every accessible Impel tenant
26
25
 
27
- Runs the whole onboarding in one command: store your Personal Access Token,
28
- pick your organization, prepare the Impel clients for this platform, and verify
29
- the gateway. On macOS this installs isolated desktop apps. On Windows it
30
- installs any missing official Claude Code/Codex packages, prepares the
31
- tenant-isolated CLI profiles, and installs isolated desktop app profiles.
32
- Native client profiles stay untouched and the vendor executables stay signed
33
- and unmodified.
26
+ Stores your Personal Access Token, discovers every tenant you can access,
27
+ prepares each tenant's isolated Claude and Codex CLI profiles, and installs
28
+ each supported desktop app on macOS and Windows. Personal Claude/Codex profiles
29
+ are never used for Impel sessions.
34
30
 
35
31
  Usage:
36
- impel setup Interactive wizard
37
- impel setup --pat <pat> Non-interactive; add --tenant to skip the picker
38
- impel setup --tenant <org> Preselect the organization
39
- impel setup --skip-apps Skip the desktop-app installation step
40
- impel setup --skip-clis On Windows, don't install missing vendor CLIs
41
- impel setup --repair Explicitly opt into sanitized assisted recovery on failure
42
- impel setup --no-recovery Disable local and hosted recovery for this run
32
+ impel setup Interactive setup
33
+ impel setup --pat <pat> Non-interactive authentication
34
+ impel setup --tenant <org> Choose the default tenant for CLI launches
35
+ impel setup --skip-apps Skip desktop apps
36
+ impel setup --skip-clis Windows: skip missing vendor CLI installation
37
+ impel setup --no-recovery Disable automatic install recovery
43
38
  `;
44
39
 
45
- /**
46
- * Pick the tenant for the wizard. Pure so it's testable: returns the tenant
47
- * for an explicit request (or throws if unavailable), auto-selects a sole
48
- * tenant, and otherwise resolves an interactive answer (number, id, slug, or
49
- * empty = default).
50
- */
40
+ /** Kept as a pure compatibility helper for callers/tests. */
51
41
  export function resolveTenantChoice(listing, { requested = null, answer = null, currentTenantId = null } = {}) {
52
- const byToken = (token) => {
53
- const normalized = normalizeTenantId(token);
54
- return listing.tenants.find((tenant) => tenant.id === normalized || tenant.slug === normalized) || null;
55
- };
56
-
57
- if (requested) {
58
- const tenant = byToken(requested);
59
- if (!tenant) throw new Error(`tenant "${requested}" is not available to this user`);
42
+ if (requested) return selectDefaultTenant(listing, { requested, currentTenantId });
43
+ if (answer && /^\d+$/u.test(answer)) {
44
+ const tenant = listing.tenants[Number(answer) - 1];
45
+ if (!tenant) throw new Error(`tenant "${answer}" is not available to this user`);
60
46
  return tenant;
61
47
  }
62
- if (listing.tenants.length === 1) return listing.tenants[0];
63
-
64
- const fallbackId = listing.tenants.some((tenant) => tenant.id === currentTenantId)
65
- ? currentTenantId
66
- : listing.defaultTenantId;
67
- if (answer === null || answer === "") {
68
- return listing.tenants.find((tenant) => tenant.id === fallbackId) || listing.tenants[0];
69
- }
70
- const index = /^\d+$/.test(answer) ? Number(answer) : NaN;
71
- if (Number.isInteger(index) && index >= 1 && index <= listing.tenants.length) {
72
- return listing.tenants[index - 1];
73
- }
74
- const tenant = byToken(answer);
75
- if (!tenant) throw new Error(`tenant "${answer}" is not available to this user`);
76
- return tenant;
48
+ if (answer) return selectDefaultTenant(listing, { requested: answer, currentTenantId });
49
+ return selectDefaultTenant(listing, { currentTenantId });
77
50
  }
78
51
 
79
- /** Best-effort gateway probe; any HTTP response means the gateway is routing. */
80
52
  async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
81
- const url = `${gatewayUrl}/anthropic/v1/messages`;
82
53
  const controller = new AbortController();
83
- const timeout = setTimeout(() => controller.abort(), 5000);
54
+ const timeout = setTimeout(() => controller.abort(), 5_000);
84
55
  try {
85
- const res = await fetchImpl(url, {
56
+ const response = await fetchImpl(`${gatewayUrl}/anthropic/v1/messages`, {
86
57
  method: "POST",
87
58
  headers: {
88
59
  "content-type": "application/json",
89
- ...(pat && tenantId ? { authorization: `Bearer ${tenantCredential(pat, tenantId)}` } : {}),
60
+ authorization: `Bearer ${tenantCredential(pat, tenantId)}`,
90
61
  },
91
62
  body: "{}",
92
63
  signal: controller.signal,
93
64
  });
94
- return { reachable: true, status: res.status, rejected: res.status === 401 || res.status === 403 };
95
- } catch (err) {
65
+ return { reachable: true, status: response.status, rejected: [401, 403].includes(response.status) };
66
+ } catch (error) {
96
67
  return {
97
68
  reachable: false,
98
- error: err?.name === "AbortError" ? "timed out after 5s" : redactSecretText(err?.message || err),
69
+ error: error?.name === "AbortError" ? "timed out after 5s" : redactSecretText(error?.message || error),
99
70
  };
100
71
  } finally {
101
72
  clearTimeout(timeout);
102
73
  }
103
74
  }
104
75
 
76
+ function markProbeFailures(report, probes) {
77
+ probes.forEach((probe, index) => {
78
+ if (probe.reachable && !probe.rejected) return;
79
+ const tenant = report.tenants[index];
80
+ tenant.cli = "failed";
81
+ tenant.status = "failed";
82
+ for (const client of Object.values(tenant.clients)) {
83
+ if (client.cli === "ready") client.cli = "failed";
84
+ }
85
+ tenant.errors.push(probe.rejected
86
+ ? `credential rejected (HTTP ${probe.status})`
87
+ : `gateway unreachable (${probe.error || "unknown error"})`);
88
+ });
89
+ report.passed = report.tenants.every((tenant) => (
90
+ ["ready", "unavailable"].includes(tenant.cli)
91
+ && ["ready", "unavailable"].includes(tenant.apps)
92
+ && ["ready", "unavailable"].includes(tenant.shell)
93
+ ));
94
+ return report;
95
+ }
96
+
97
+ function recomputeReport(report) {
98
+ report.passed = report.tenants.every((tenant) => (
99
+ ["ready", "unavailable"].includes(tenant.cli)
100
+ && ["ready", "unavailable"].includes(tenant.apps)
101
+ && ["ready", "unavailable"].includes(tenant.shell)
102
+ ));
103
+ return report;
104
+ }
105
+
106
+ function mergeTenantReport(report, replacement) {
107
+ const byId = new Map(replacement.tenants.map((tenant) => [tenant.tenantId, tenant]));
108
+ report.tenants = report.tenants.map((tenant) => byId.get(tenant.tenantId) || tenant);
109
+ return recomputeReport(report);
110
+ }
111
+
112
+ function describeWindowsCliFailure(prepared) {
113
+ const labels = { claude: "Claude Code", codex: "Codex" };
114
+ const details = [];
115
+ for (const tool of prepared.missingAfter || []) {
116
+ const installation = prepared.installations?.[tool];
117
+ const failure = installation?.failure;
118
+ const reason = failure?.message || failure?.code
119
+ || (Number.isInteger(failure?.status) ? `exit ${failure.status}` : "not discoverable after installation");
120
+ details.push(`${labels[tool] || tool}'s native installer failed: ${redactSecretText(reason)}`);
121
+ if (installation?.command) details.push(`manual ${tool} installer: ${redactSecretText(installation.command)}`);
122
+ }
123
+ return details.join("; ") || `missing vendor CLIs: ${(prepared.missingAfter || []).join(", ")}`;
124
+ }
125
+
126
+ function confirmed(answer) {
127
+ return /^(?:y|yes)$/iu.test(String(answer || "").trim());
128
+ }
129
+
105
130
  export async function cmdSetup(argv, overrides = {}) {
106
131
  const io = {
132
+ loadConfig,
133
+ saveConfig,
107
134
  promptSecret,
108
135
  promptText,
109
136
  fetchTenants,
110
- installApps: cmdApps,
111
137
  prepareWindowsClis,
138
+ reconcile: reconcileAllTenants,
112
139
  probe: probeGateway,
113
140
  recoverInstall: runInstallRecovery,
141
+ restoreNativeProfiles,
114
142
  isTTY: process.stdin.isTTY,
115
143
  platform: process.platform,
144
+ environment: process.env,
116
145
  ...overrides,
117
146
  };
118
147
  const { flags, positionals } = parseFlags(argv, {
@@ -122,7 +151,7 @@ export async function cmdSetup(argv, overrides = {}) {
122
151
  app: { type: "string" },
123
152
  "skip-apps": { type: "boolean" },
124
153
  "skip-clis": { type: "boolean" },
125
- repair: { type: "boolean" },
154
+ repair: { type: "boolean" }, // deprecated compatibility no-op
126
155
  "no-recovery": { type: "boolean" },
127
156
  help: { type: "boolean" },
128
157
  });
@@ -130,422 +159,277 @@ export async function cmdSetup(argv, overrides = {}) {
130
159
  console.log(HELP);
131
160
  return;
132
161
  }
133
- if (positionals.length > 0) {
134
- console.error(
135
- `impel setup: no longer takes a target ("${positionals[0]}"). Run bare \`impel setup\` — it installs the vendored Impel apps. To flip a native tool, use \`impel use gateway [claude|codex]\`.`
136
- );
162
+ if (positionals.length) {
163
+ console.error(`impel setup: unexpected argument "${redactSecretText(positionals[0])}"; run bare \`impel setup\`.`);
137
164
  process.exitCode = 1;
138
165
  return;
139
166
  }
140
167
 
141
- const existing = loadConfig();
168
+ const existing = io.loadConfig();
142
169
  const gatewayUrl = normalizeGatewayUrl(flags.gateway || existing?.gatewayUrl || resolveDefaultGateway());
143
170
  const appUrl = normalizeGatewayUrl(flags.app || existing?.appUrl || resolveDefaultAppUrl());
144
-
145
- console.log("Impel gateway setup");
146
- console.log(` gateway: ${gatewayUrl}`);
147
- console.log(` app: ${appUrl}`);
148
- console.log("");
149
-
150
- // ── Step 1: token ────────────────────────────────────────────────────────
151
171
  let pat = flags.pat;
152
172
  if (!pat && existing?.pat) {
153
- if (io.isTTY) {
154
- const entered = await io.promptSecret(
155
- `1/4 Token — one is already stored (${maskSecret(existing.pat)}). Press Enter to keep it, or paste a new one: `
156
- );
157
- pat = entered || existing.pat;
158
- } else {
159
- pat = existing.pat;
160
- }
161
- } else if (!pat) {
162
- if (!io.isTTY) {
163
- console.error("impel setup: no token stored and stdin is not a TTY; pass --pat <impel_pat_...>.");
164
- process.exitCode = 1;
165
- return;
166
- }
167
- console.log(`1/4 Token — create one at ${appUrl}/settings/gateway (step 3 on that page mints it).`);
168
- pat = await io.promptSecret(" Paste your Personal Access Token (impel_pat_...): ");
173
+ pat = io.isTTY
174
+ ? (await io.promptSecret(`Token (${maskSecret(existing.pat)} stored; Enter keeps it): `)) || existing.pat
175
+ : existing.pat;
176
+ } else if (!pat && io.isTTY) {
177
+ pat = await io.promptSecret("Impel Personal Access Token (impel_pat_...): ");
169
178
  }
170
179
  if (!pat) {
171
- console.error("impel setup: no token provided, aborting.");
180
+ console.error("impel setup: no token provided; pass --pat or run interactively.");
172
181
  process.exitCode = 1;
173
182
  return;
174
183
  }
175
- if (!pat.startsWith("impel_pat_")) {
176
- console.warn(
177
- `impel: warning: that token doesn't start with "impel_pat_" — double check you copied the right value.`
178
- );
179
- }
180
184
 
181
185
  const config = { ...(existing || {}), pat, gatewayUrl, appUrl, updatedAt: new Date().toISOString() };
182
- saveConfig(config);
183
- console.log(` stored in ~/.config/impel/config.json (mode 0600)`);
184
- console.log("");
185
-
186
- const setupFailures = [];
187
- const recordSetupFailure = (failure) => {
188
- setupFailures.push({
189
- platform: io.platform,
190
- architecture: process.arch,
191
- ...failure,
192
- });
193
- };
186
+ io.saveConfig(config);
187
+ console.log("Stored the Impel credential privately.");
194
188
 
195
- // ── Step 2: organization ─────────────────────────────────────────────────
196
- // A successful tenant fetch also proves the token: the app verifies it
197
- // against impel-identity before answering.
198
189
  let listing;
199
190
  try {
200
191
  listing = await io.fetchTenants(config);
201
192
  } catch (error) {
202
193
  console.error(`impel setup: could not verify the token (${redactSecretText(error?.message || error)})`);
203
- console.error(" Check the token and your network, then re-run `impel setup`.");
204
194
  process.exitCode = 1;
205
195
  return;
206
196
  }
197
+ const selected = selectDefaultTenant(listing, {
198
+ requested: flags.tenant || null,
199
+ currentTenantId: existing?.tenantId || null,
200
+ });
201
+ config.tenantId = selected.id;
202
+ config.tenantName = selected.name;
203
+ if (listing.productAccess) config.productAccess = listing.productAccess;
204
+ if (listing.scopes) config.scopes = listing.scopes;
205
+ config.tenantsUpdatedAt = new Date().toISOString();
206
+ io.saveConfig(config);
207
207
 
208
- let tenant;
208
+ const orderedTenants = [...listing.tenants].sort((left, right) => left.id.localeCompare(right.id));
209
+ console.log(`Accessible tenants (${orderedTenants.length}):`);
210
+ for (const tenant of orderedTenants) {
211
+ console.log(` ${tenant.id}${tenant.id === selected.id ? " (CLI default)" : ""} — ${tenant.name}`);
212
+ }
209
213
  try {
210
- if (flags.tenant || listing.tenants.length === 1 || !io.isTTY) {
211
- tenant = resolveTenantChoice(listing, {
212
- requested: flags.tenant || null,
213
- currentTenantId: existing?.tenantId || null,
214
- });
215
- } else {
216
- console.log("2/4 Organization");
217
- listing.tenants.forEach((candidate, index) => {
218
- const markers = [
219
- candidate.id === existing?.tenantId ? "current" : null,
220
- candidate.id === listing.defaultTenantId ? "default" : null,
221
- ].filter(Boolean);
222
- console.log(` [${index + 1}] ${candidate.id}${markers.length ? ` (${markers.join(", ")})` : ""}\t${candidate.name}`);
223
- });
224
- const fallback = resolveTenantChoice(listing, { currentTenantId: existing?.tenantId || null });
225
- const answer = await io.promptText(` Select [1-${listing.tenants.length}] (Enter for ${fallback.id}): `);
226
- tenant = resolveTenantChoice(listing, { answer, currentTenantId: existing?.tenantId || null });
227
- }
214
+ io.restoreNativeProfiles({ quiet: true });
228
215
  } catch (error) {
229
- console.error(`impel setup: ${redactSecretText(error?.message || error)}`);
230
- process.exitCode = 1;
231
- return;
216
+ console.warn(`Legacy native-profile cleanup was skipped safely (${redactSecretText(error?.message || error)}).`);
232
217
  }
233
218
 
234
- config.tenantId = tenant.id;
235
- config.tenantName = tenant.name;
236
- if (listing.productAccess) config.productAccess = listing.productAccess;
237
- else delete config.productAccess;
238
- if (listing.scopes) config.scopes = listing.scopes;
239
- else delete config.scopes;
240
- config.tenantsUpdatedAt = new Date().toISOString();
241
- saveConfig(config);
242
- console.log(` tenant: ${tenant.id} (${tenant.name})`);
243
- console.log("");
244
-
245
- // ── Step 3: apps ─────────────────────────────────────────────────────────
246
- // Order inside `impel app install`: close running Claude/ChatGPT apps (a
247
- // running app makes the install fail), bring the vendor apps to the verified
248
- // version, then build the vendored Impel bundles from that fresh copy.
249
- // Native Claude Code and Codex profiles are never touched.
250
- let platformSetupFailed = false;
251
- if (io.platform === "win32") {
252
- console.log(
253
- flags["skip-clis"]
254
- ? "3/4 Windows CLIs: preparing isolated profiles (vendor CLI installation skipped)…"
255
- : "3/4 Windows CLIs: ensuring Claude Code and Codex are installed, then preparing isolated profiles…"
219
+ const vendorAppDecisions = new Map();
220
+ const confirmVendorInstall = async (target, context) => {
221
+ if (vendorAppDecisions.has(target)) return vendorAppDecisions.get(target);
222
+ if (!io.isTTY) {
223
+ vendorAppDecisions.set(target, false);
224
+ return false;
225
+ }
226
+ const label = target === "claude" ? "Claude" : "ChatGPT/Codex";
227
+ const answer = await io.promptText(
228
+ `Allow the verified ${label} vendor ${context.mode === "update" ? "update" : "installation"}? [y/N] `,
256
229
  );
230
+ const allowed = confirmed(answer);
231
+ vendorAppDecisions.set(target, allowed);
232
+ return allowed;
233
+ };
234
+
235
+ let sharedFailure = null;
236
+ const prepareSharedClis = async ({ confirmedTools = null, inspectOnly = false } = {}) => {
237
+ if (io.platform !== "win32") return { binaries: {}, missingAfter: [] };
257
238
  try {
258
- const result = await io.prepareWindowsClis({
239
+ const inspected = await io.prepareWindowsClis({
259
240
  gatewayUrl,
260
- tenantId: tenant.id,
261
- skipInstall: Boolean(flags["skip-clis"]),
241
+ tenantId: selected.id,
242
+ skipInstall: true,
262
243
  });
263
- for (const [tool, label] of [["claude", "Claude Code"], ["codex", "Codex"]]) {
264
- const binary = result.binaries[tool];
265
- if (binary) console.log(` ✓ ${label}: ${redactSecretText(binary)}`);
266
- else console.log(` ✗ ${label}: not found`);
267
- }
268
- if (result.missingAfter.length > 0 && !flags["skip-clis"]) {
269
- platformSetupFailed = true;
270
- const fallbackCommands = windowsCliInstallCommands(result.missingAfter);
271
- for (const tool of result.missingAfter) {
272
- const label = tool === "claude" ? "Claude Code" : "Codex";
273
- const installation = result.installations?.[tool];
274
- const failure = installation?.failure;
275
- if (installation?.succeeded === false || (!installation && result.installSucceeded === false)) {
276
- const detail = failure?.message
277
- ? `: ${redactSecretText(failure.message)}`
278
- : Number.isInteger(failure?.status)
279
- ? ` (exit code ${failure.status})`
280
- : failure?.signal
281
- ? ` (signal ${failure.signal})`
282
- : "";
283
- console.error(` ${label}'s native installer failed${detail}.`);
284
- recordSetupFailure({
285
- step: `install.vendor_cli.${tool}`,
286
- command: installation?.command || result.installCommands?.[tool] || fallbackCommands[tool],
287
- exitCode: failure?.status,
288
- signal: failure?.signal,
289
- errorCode: failure?.code,
290
- message: failure?.message
291
- ? `${label}'s native installer failed: ${failure.message}`
292
- : `${label}'s native installer did not reach a verified state.`,
293
- });
294
- } else {
295
- console.error(` ${label}'s native installer completed, but Impel could not verify the command.`);
296
- recordSetupFailure({
297
- step: `verify.vendor_cli.${tool}`,
298
- command: installation?.command || result.installCommands?.[tool] || fallbackCommands[tool],
299
- message: `${label}'s installer completed but the command was not discoverable or did not pass its version check.`,
300
- });
244
+ let prepared = inspected;
245
+ if (inspected.missingAfter.length && !inspectOnly && !flags["skip-clis"]) {
246
+ let installTools = confirmedTools;
247
+ if (installTools === null) {
248
+ installTools = [];
249
+ if (io.isTTY) {
250
+ for (const tool of inspected.missingAfter) {
251
+ const label = tool === "claude" ? "Claude Code" : "Codex";
252
+ if (confirmed(await io.promptText(`Install the official ${label} CLI for this user? [y/N] `))) {
253
+ installTools.push(tool);
254
+ }
255
+ }
301
256
  }
302
- console.error(" Retry its official installer in the same PowerShell window:");
303
- console.error(` ${installation?.command || result.installCommands?.[tool] || fallbackCommands[tool]}`);
304
257
  }
305
- console.error(" Then close and reopen PowerShell and re-run `impel setup`.");
306
- } else if (result.missingAfter.length > 0) {
307
- console.log(" Vendor CLI installation was skipped; install it before using the matching launcher.");
308
- } else {
309
- console.log(" ✓ tenant-isolated Claude and Codex profiles are ready");
310
- }
311
- } catch (error) {
312
- platformSetupFailed = true;
313
- console.error(` ✗ Windows CLI setup failed (${redactSecretText(error?.message || error)})`);
314
- console.error(" Fix the issue, then re-run `impel setup`.");
315
- recordSetupFailure({
316
- step: "setup.windows_clis",
317
- errorCode: error?.code,
318
- message: `Windows CLI setup failed: ${error?.message || error}`,
319
- });
320
- }
321
- if (flags["skip-apps"]) {
322
- console.log(" Windows desktop apps: skipped (--skip-apps).");
323
- } else {
324
- console.log(" Windows desktop apps: installing signed vendor apps and isolated Impel profiles…");
325
- try {
326
- const installed = await io.installApps(["install", "all"]);
327
- if (installed === false) {
328
- platformSetupFailed = true;
329
- console.error(" ✗ Windows desktop app setup failed; fix the issue above, then re-run `impel app install all`.");
330
- recordSetupFailure({
331
- step: "install.vendor_app.all",
332
- message: "Windows desktop app setup returned an unsuccessful result.",
258
+ if (installTools.length) {
259
+ prepared = await io.prepareWindowsClis({
260
+ gatewayUrl,
261
+ tenantId: selected.id,
262
+ skipInstall: false,
263
+ installTools,
333
264
  });
334
- } else {
335
- console.log(" ✓ available Impel desktop app profiles are ready");
336
265
  }
337
- } catch (error) {
338
- platformSetupFailed = true;
339
- console.error(` ✗ Windows desktop app setup failed (${redactSecretText(error?.message || error)})`);
340
- console.error(" Fix the issue, then re-run `impel app install all`.");
341
- recordSetupFailure({
342
- step: "install.vendor_app.all",
343
- errorCode: error?.code,
344
- message: `Windows desktop app setup failed: ${error?.message || error}`,
345
- });
346
266
  }
347
- }
348
- } else if (flags["skip-apps"]) {
349
- console.log("3/4 Apps: skipped (--skip-apps). Install later with `impel app install`.");
350
- } else if (io.platform !== "darwin") {
351
- console.log(
352
- "3/4 Apps: skipped — isolated desktop apps are unavailable on this platform. Use `impel claude` / `impel codex` for isolated CLI sessions."
353
- );
354
- } else {
355
- console.log(
356
- "3/4 Installing the Impel desktop apps (running Claude/ChatGPT apps are closed, verified vendor builds installed first)…"
357
- );
358
- try {
359
- await io.installApps(["install", "all"]);
360
- console.log(" ✓ available Impel desktop apps installed");
267
+ if (prepared.missingAfter.length) {
268
+ sharedFailure = describeWindowsCliFailure(prepared);
269
+ return prepared;
270
+ }
271
+ sharedFailure = null;
272
+ return prepared;
361
273
  } catch (error) {
362
- platformSetupFailed = true;
363
- console.error(
364
- ` ✗ app installation failed (${redactSecretText(error?.message || error)})`
365
- );
366
- console.error(" Fix the issue, then re-run `impel app install`.");
367
- recordSetupFailure({
368
- step: "install.vendor_app.all",
369
- errorCode: error?.code,
370
- message: `Desktop app installation failed: ${error?.message || error}`,
371
- });
274
+ sharedFailure = redactSecretText(error?.message || error);
275
+ return { binaries: {}, missingAfter: ["claude", "codex"] };
372
276
  }
373
- }
374
- console.log("");
277
+ };
278
+ await prepareSharedClis();
375
279
 
376
- // ── Step 4: verify ───────────────────────────────────────────────────────
377
- const probe = await io.probe(gatewayUrl, pat, tenant.id);
378
- let verificationFailed = false;
379
- if (probe.reachable && !probe.rejected) {
380
- console.log(`4/4 Verify: ✓ gateway reachable (HTTP ${probe.status})`);
381
- } else if (probe.rejected) {
382
- verificationFailed = true;
383
- console.log(`4/4 Verify: ✗ gateway reachable but it rejected the token (HTTP ${probe.status}).`);
384
- console.log(" Mint a fresh token and re-run `impel setup`.");
385
- recordSetupFailure({
386
- step: "verify.gateway",
387
- exitCode: probe.status,
388
- message: `The gateway rejected the configured credential with HTTP ${probe.status}.`,
389
- });
390
- } else {
391
- verificationFailed = true;
392
- console.log(`4/4 Verify: gateway unreachable (${probe.error}).`);
393
- console.log(" Check your network, then run `impel status` to retry.");
394
- recordSetupFailure({
395
- step: "verify.gateway",
396
- message: `The gateway could not be reached: ${probe.error}.`,
397
- });
398
- }
280
+ const runConvergence = (
281
+ includeApps = !flags["skip-apps"],
282
+ tenants = orderedTenants,
283
+ confirmInstall = confirmVendorInstall,
284
+ ) => io.reconcile({
285
+ config,
286
+ listing: { ...listing, tenants },
287
+ mode: "setup",
288
+ includeApps,
289
+ platform: io.platform,
290
+ environment: io.environment,
291
+ confirmVendorInstall: confirmInstall,
292
+ }, {
293
+ ...(overrides.installApps ? { installApps: overrides.installApps } : {}),
294
+ ...(overrides.registerShellEntries ? { registerShellEntries: overrides.registerShellEntries } : {}),
295
+ ...(overrides.ensureClaude ? { ensureClaude: overrides.ensureClaude } : {}),
296
+ ...(overrides.ensureCodex ? { ensureCodex: overrides.ensureCodex } : {}),
297
+ ...(overrides.syncSkills ? { syncSkills: overrides.syncSkills } : {}),
298
+ ...(overrides.syncAgents ? { syncAgents: overrides.syncAgents } : {}),
299
+ ...(overrides.knownTenantIds ? { knownTenantIds: overrides.knownTenantIds } : {}),
300
+ ...(overrides.findBinary ? { findBinary: overrides.findBinary } : {}),
301
+ });
399
302
 
400
- if (platformSetupFailed || verificationFailed) {
401
- const aggregateFailure = {
402
- platform: io.platform,
403
- architecture: process.arch,
404
- step: setupFailures.length === 1 ? setupFailures[0].step : "setup.multiple",
405
- message:
406
- setupFailures.length === 1
407
- ? setupFailures[0].message
408
- : `${setupFailures.length} setup checks failed.`,
409
- diagnostics: Object.fromEntries(
410
- setupFailures.slice(0, 20).map((failure, index) => [
411
- `failure${index + 1}`,
412
- `${failure.step}: ${failure.message}`,
413
- ])
414
- ),
415
- };
303
+ const verifyConvergence = async (
304
+ includeApps = !flags["skip-apps"],
305
+ tenants = orderedTenants,
306
+ confirmInstall = confirmVendorInstall,
307
+ ) => {
308
+ const nextReport = await runConvergence(includeApps, tenants, confirmInstall);
309
+ const probes = await Promise.all(tenants.map((tenant) => io.probe(gatewayUrl, pat, tenant.id)));
310
+ return markProbeFailures(nextReport, probes);
311
+ };
312
+ let report = await verifyConvergence();
313
+ if (sharedFailure) report.passed = false;
416
314
 
417
- // Recovery ends as "fixed" only when every goal derived from the failed
418
- // steps passes a fresh local check. App installs have no cheap external
419
- // probe, so that goal tracks the most recent reviewed install result.
420
- const appInstallState = { ok: false };
421
- const installVendorApp = async (target) => {
422
- const installed = await io.installApps(["install", target]);
423
- appInstallState.ok = installed !== false;
424
- return installed;
425
- };
426
- const goals = [];
427
- if (io.platform === "win32" && setupFailures.some((failure) => /vendor_cli|windows_clis/iu.test(failure.step))) {
428
- goals.push({
429
- id: "windows-clis",
430
- description: "Claude Code and Codex are installed and pass their version checks",
431
- run: async () => {
432
- const checked = await io.prepareWindowsClis({
433
- gatewayUrl,
434
- tenantId: tenant.id,
435
- skipInstall: true,
436
- });
437
- return checked.missingAfter.length === 0
438
- ? true
439
- : { ok: false, detail: `still missing: ${checked.missingAfter.join(", ")}` };
315
+ if (sharedFailure && !flags["no-recovery"]) {
316
+ let sharedReady = false;
317
+ const recovery = await io.recoverInstall({
318
+ failure: {
319
+ scope: "shared",
320
+ platform: io.platform,
321
+ architecture: process.arch,
322
+ step: "setup.shared_vendor_clis",
323
+ message: sharedFailure,
324
+ },
325
+ config,
326
+ goals: [{
327
+ id: "shared-vendor-clis-ready",
328
+ description: "Shared vendor CLI prerequisites are installed and discoverable",
329
+ run: () => sharedReady,
330
+ }],
331
+ explicit: Boolean(flags.repair),
332
+ noRecovery: false,
333
+ actionContext: {
334
+ tenantId: selected.id,
335
+ installVendorClis: async (tool) => {
336
+ const prepared = await prepareSharedClis({ confirmedTools: [tool] });
337
+ sharedReady = !sharedFailure;
338
+ return prepared;
440
339
  },
441
- });
442
- }
443
- if (setupFailures.some((failure) => /vendor_app/iu.test(failure.step))) {
444
- goals.push({
445
- id: "desktop-apps",
446
- description: "The Impel desktop app profiles installed cleanly",
447
- run: () => appInstallState.ok === true,
448
- });
449
- }
450
- if (setupFailures.some((failure) => failure.step === "verify.gateway")) {
451
- goals.push({
452
- id: "gateway",
453
- description: "The gateway accepts the stored credential",
454
- run: async () => {
455
- const checked = await io.probe(gatewayUrl, pat, tenant.id);
456
- return checked.reachable && !checked.rejected
457
- ? true
458
- : { ok: false, detail: checked.error || `HTTP ${checked.status}` };
340
+ retryStep: async () => {
341
+ await prepareSharedClis({ inspectOnly: true });
342
+ sharedReady = !sharedFailure;
343
+ return sharedReady;
459
344
  },
460
- });
461
- }
345
+ },
346
+ }, overrides.recoveryOverrides || {});
347
+ if (recovery.fixed) await prepareSharedClis({ inspectOnly: true });
348
+ }
462
349
 
463
- try {
464
- const recovery = await io.recoverInstall(
465
- {
466
- failure: aggregateFailure,
467
- config,
468
- goals,
469
- explicit: Boolean(flags.repair),
470
- noRecovery: Boolean(flags["no-recovery"]),
471
- actionContext: {
472
- tenantId: tenant.id,
473
- probeGateway: () => io.probe(gatewayUrl, pat, tenant.id),
474
- installVendorClis: (tool) =>
475
- io.prepareWindowsClis({
476
- gatewayUrl,
477
- tenantId: tenant.id,
478
- skipInstall: false,
479
- installTools: [tool],
480
- }),
481
- repairProfiles: async () => {
482
- if (io.platform === "win32") {
483
- const result = await io.prepareWindowsClis({
484
- gatewayUrl,
485
- tenantId: tenant.id,
486
- skipInstall: true,
487
- });
488
- return result.missingAfter.length === 0;
489
- }
490
- ensureImpelClaudeProfile(gatewayUrl, tenant.id, {
491
- crossAppModels: crossAppModelsEnabled(config),
492
- });
493
- ensureImpelCodexProfile(gatewayUrl, tenant.id);
494
- // Also refresh the app token helper: a stale baked node/CLI
495
- // path there breaks every managed app's auth.
496
- rewriteTenantTokenHelper(tenant.id);
497
- return true;
498
- },
499
- installVendorApp,
500
- retryStep: async (step) => {
501
- if (/vendor_cli|windows_clis/iu.test(step || "")) {
502
- const result = await io.prepareWindowsClis({
503
- gatewayUrl,
504
- tenantId: tenant.id,
505
- skipInstall: false,
506
- });
507
- return result.missingAfter.length === 0;
508
- }
509
- if (/vendor_app/iu.test(step || "")) {
510
- return (await installVendorApp("all")) !== false;
511
- }
512
- if (/gateway/iu.test(step || "")) {
513
- const result = await io.probe(gatewayUrl, pat, tenant.id);
514
- return Boolean(result.reachable && !result.rejected);
515
- }
516
- return false;
350
+ if (!flags["no-recovery"]) {
351
+ for (const failed of report.tenants.filter((tenant) => tenant.status === "failed")) {
352
+ const tenant = orderedTenants.find((candidate) => candidate.id === failed.tenantId);
353
+ if (!tenant) continue;
354
+ let retryReport = null;
355
+ let retryPassed = false;
356
+ const retryTenant = async (confirmInstall) => {
357
+ retryReport = await verifyConvergence(!flags["skip-apps"], [tenant], confirmInstall);
358
+ retryPassed = retryReport.passed;
359
+ return retryPassed;
360
+ };
361
+ const retrySafeState = async () => {
362
+ const profileReport = await verifyConvergence(false, [tenant], async () => false);
363
+ const profile = profileReport.tenants[0];
364
+ const current = report.tenants.find((candidate) => candidate.tenantId === tenant.id);
365
+ const cliReady = ["ready", "unavailable"].includes(profile?.cli);
366
+ const nonCliReady = ["ready", "unavailable"].includes(current?.apps)
367
+ && ["ready", "unavailable"].includes(current?.shell);
368
+ retryPassed = cliReady && nonCliReady;
369
+ if (retryPassed) {
370
+ const combined = {
371
+ ...current,
372
+ cli: profile.cli,
373
+ clients: {
374
+ claude: { ...current.clients.claude, cli: profile.clients.claude.cli },
375
+ codex: { ...current.clients.codex, cli: profile.clients.codex.cli },
517
376
  },
377
+ errors: profile.errors,
378
+ status: "ready",
379
+ };
380
+ retryReport = { ...profileReport, tenants: [combined], passed: true };
381
+ }
382
+ return retryPassed;
383
+ };
384
+ await io.recoverInstall({
385
+ failure: {
386
+ scope: "tenant",
387
+ tenantId: tenant.id,
388
+ platform: io.platform,
389
+ architecture: process.arch,
390
+ step: "setup.tenant_convergence",
391
+ message: failed.errors.join("; ") || "Tenant readiness verification failed.",
392
+ diagnostics: { tenant: tenant.id },
393
+ },
394
+ config,
395
+ goals: [{
396
+ id: `tenant-${tenant.id}-ready`,
397
+ description: `Tenant ${tenant.id} is locally verified and launchable`,
398
+ run: () => retryPassed,
399
+ }],
400
+ explicit: Boolean(flags.repair),
401
+ noRecovery: false,
402
+ actionContext: {
403
+ tenantId: tenant.id,
404
+ probeGateway: () => io.probe(gatewayUrl, pat, tenant.id),
405
+ repairProfiles: async () => {
406
+ const profileReport = await verifyConvergence(false, [tenant]);
407
+ return ["ready", "unavailable"].includes(profileReport.tenants[0]?.cli);
518
408
  },
409
+ retryStep: () => retrySafeState(),
410
+ installVendorApp: (target) => retryTenant(async (product) => (
411
+ target === "all"
412
+ || product === target
413
+ || (target === "codex" && product === "chatgpt")
414
+ )),
519
415
  },
520
- {
521
- isTTY: io.isTTY,
522
- promptText: io.promptText,
523
- ...(overrides.recoveryOverrides || {}),
524
- }
525
- );
526
- // "fixed" is only reported after every goal passed a fresh local check
527
- // inside the engine, so no separate re-verification is needed here.
528
- if (recovery.fixed) {
529
- platformSetupFailed = false;
530
- verificationFailed = false;
531
- }
532
- } catch (error) {
533
- console.warn(`Install recovery could not start (${redactSecretText(error?.message || error)}).`);
416
+ }, overrides.recoveryOverrides || {});
417
+ if (retryReport) mergeTenantReport(report, retryReport);
534
418
  }
535
419
  }
420
+ recomputeReport(report);
421
+ report.passed = report.passed && !sharedFailure;
536
422
 
537
- if (platformSetupFailed || verificationFailed) process.exitCode = 1;
538
-
539
- console.log("");
540
- if (platformSetupFailed || verificationFailed) {
541
- console.log("Setup is incomplete; the token and tenant were saved, but the failed step above still needs attention.");
542
- console.log("Fix it and re-run `impel setup`.");
543
- return;
423
+ printReconciliationSummary(report);
424
+ console.log(`CLI tenant: ${selected.id}`);
425
+ console.log("Change it: impel tenant use <tenant>");
426
+ console.log("Launch: impel claude | impel codex");
427
+ if (!report.passed || sharedFailure) {
428
+ if (sharedFailure) console.error(`Shared setup failure: ${sharedFailure}`);
429
+ console.error("Setup is incomplete. Fix the reported issue or rerun `impel setup`.");
430
+ process.exitCode = 1;
431
+ return report;
544
432
  }
545
- console.log("You're set. Try:");
546
- if (io.platform === "darwin") console.log(" impel app open open the Impel desktop apps");
547
- if (io.platform === "win32") console.log(" impel app open all open Claude and ChatGPT with isolated Impel profiles");
548
- console.log(" impel claude isolated Claude Code through the gateway");
549
- console.log(" impel codex isolated Codex through the gateway");
550
- console.log(" impel status full diagnostics any time");
433
+ console.log("Setup complete for every accessible tenant.");
434
+ return report;
551
435
  }