impel-cli 0.15.3 → 0.16.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/README.md CHANGED
@@ -115,15 +115,19 @@ checksum-verifying native installers, so Claude Code does not inherit its newer
115
115
  Node.js requirement from the deprecated npm package. On Linux, it prepares the
116
116
  isolated command-line workflow.
117
117
 
118
- If setup or the CLI self-update fails, Impel first runs a deterministic local
119
- check. In an interactive terminal it then offers assisted recovery; use
120
- `impel setup --repair` (or `impel update --repair`) to opt in explicitly. Before
121
- upload, the CLI prints the exact sanitized payload. The hosted agent returns a
122
- reviewed action ID, and the CLI runs its allowlisted command locally; free-form
123
- model commands are rejected and every mutation requires confirmation. Sessions
124
- expire after 15 minutes, mutation capabilities are
125
- single-use, and a private checkpoint lets `--repair` resume an interrupted run.
126
- Use `--no-recovery` or `IMPEL_DISABLE_INSTALL_RECOVERY=1` to keep recovery off.
118
+ If setup or the CLI self-update fails, Impel first runs deterministic local
119
+ repairs for known failure fingerprints offline, with no upload. If the
120
+ failure persists, an interactive terminal offers assisted recovery (use
121
+ `impel setup --repair` or `impel update --repair` to opt in explicitly): a
122
+ bounded local loop where a gateway-hosted model selects typed diagnostic and
123
+ repair tools and the CLI validates, approves, and executes them on this
124
+ machine. Before the first upload, the CLI prints the exact sanitized payload.
125
+ Free-form model commands are impossible by construction; repairs limited to
126
+ Impel-owned files need one approval per session, while vendor installers and
127
+ PATH edits always confirm individually. Recovery only reports success after
128
+ the failed steps re-pass their local health checks, and it stops after 12
129
+ model turns or 10 minutes. Use `--no-recovery` or
130
+ `IMPEL_DISABLE_INSTALL_RECOVERY=1` to keep recovery off.
127
131
 
128
132
  ### If something does not work
129
133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.15.3",
3
+ "version": "0.16.1",
4
4
  "description": "Configure Claude Code and Codex CLI to talk to Impel's gateway, authenticated by an Impel Personal Access Token",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  secureManagedCodexHome,
11
11
  } from "./codexSecurity.js";
12
12
  import { normalizeTenantId } from "./tenants.js";
13
+ import { impelCliInvocation } from "./selfInvocation.js";
13
14
  import { crossAppModelsEnabled, redactSecretText } from "./config.js";
14
15
  import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
15
16
 
@@ -925,9 +926,16 @@ function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocation
925
926
  };
926
927
  writeAtomic(paths.chatgpt.catalog, JSON.stringify(catalog, null, 2) + "\n", 0o600);
927
928
  const auth = invocations?.auth || { command: paths.tokenHelper, args: null };
929
+ // Finder/Dock-launched apps inherit a minimal PATH without npm/nvm bin
930
+ // directories, so a PATH-relative "impel" never spawns there. Bake the
931
+ // absolute node + CLI invocation; the launch-time `app refresh` rewrites
932
+ // this config whenever those paths change.
933
+ const mcpInvocation = impelCliInvocation(
934
+ config.tenantId ? ["mcp", "--tenant", config.tenantId] : ["mcp"]
935
+ );
928
936
  const mcp = invocations?.mcp || {
929
- command: "impel",
930
- args: config.tenantId ? ["mcp", "--tenant", config.tenantId] : ["mcp"],
937
+ command: mcpInvocation.command,
938
+ args: [...mcpInvocation.args],
931
939
  };
932
940
 
933
941
  const managedToml = [
@@ -1153,20 +1161,63 @@ function readTopLevelTomlString(toml, key) {
1153
1161
  }
1154
1162
  }
1155
1163
 
1164
+ /**
1165
+ * Rewrite one tenant's app token helper with the current node/CLI paths.
1166
+ * Cheap and Impel-owned, so install recovery can use it to heal helpers whose
1167
+ * baked interpreter or checkout paths went stale.
1168
+ */
1169
+ export function rewriteTenantTokenHelper(tenantId, homeDir = os.homedir()) {
1170
+ const paths = appPaths(homeDir, tenantId);
1171
+ if (!fs.existsSync(path.dirname(paths.tokenHelper))) return false;
1172
+ writeTokenHelper(paths.tokenHelper, tenantId);
1173
+ return true;
1174
+ }
1175
+
1156
1176
  function writeTokenHelper(target, tenantId) {
1157
1177
  const cliPath = fileURLToPath(new URL("../bin/impel.js", import.meta.url));
1158
1178
  const node = shellQuote(process.execPath);
1159
1179
  const cli = shellQuote(cliPath);
1160
1180
  const tenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
1161
- const refreshTenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
1162
1181
  // The apps invoke this helper whenever they need auth, which makes it the
1163
1182
  // one hook that fires on Finder/Dock launches too. Piggyback a detached,
1164
1183
  // TTL-gated config/catalog/skills refresh (`app refresh --stale-only`) so
1165
1184
  // the managed apps keep themselves current without touching stdout — the
1166
1185
  // caller consumes stdout as the token.
1186
+ //
1187
+ // The baked interpreter/CLI paths are only first choices: an nvm upgrade or
1188
+ // a moved checkout deletes them, and a helper that hardcodes them strands
1189
+ // every managed app with no auth (observed as "provider auth command exited
1190
+ // with status 1" outages). Resolve again at call time before giving up, and
1191
+ // prefer the global npm install next to whichever node is found so the
1192
+ // refresh keeps rewriting this helper with current paths.
1167
1193
  const script = `#!/bin/sh
1168
- (${node} ${cli} app refresh --stale-only${refreshTenantArgs} </dev/null >/dev/null 2>&1 &)
1169
- exec ${node} ${cli} token${tenantArgs}
1194
+ # Managed by impel-cli; regenerated by \`impel app refresh\`.
1195
+ NODE=${node}
1196
+ CLI=${cli}
1197
+ if ! [ -x "$NODE" ]; then
1198
+ NODE="$(command -v node 2>/dev/null || true)"
1199
+ fi
1200
+ if ! [ -x "$NODE" ]; then
1201
+ for candidate in /opt/homebrew/bin/node /usr/local/bin/node "$HOME"/.nvm/versions/node/*/bin/node; do
1202
+ if [ -x "$candidate" ]; then NODE="$candidate"; fi
1203
+ done
1204
+ fi
1205
+ if ! [ -x "$NODE" ]; then
1206
+ echo 'impel token helper: no usable node runtime found; re-run \`impel setup\`' >&2
1207
+ exit 1
1208
+ fi
1209
+ if ! [ -f "$CLI" ]; then
1210
+ CLI="$(dirname "$NODE")/../lib/node_modules/impel-cli/bin/impel.js"
1211
+ fi
1212
+ if ! [ -f "$CLI" ]; then
1213
+ CLI="$(command -v impel 2>/dev/null || true)"
1214
+ fi
1215
+ if [ -z "$CLI" ] || ! [ -f "$CLI" ]; then
1216
+ echo 'impel token helper: impel-cli not found; re-run \`impel setup\`' >&2
1217
+ exit 1
1218
+ fi
1219
+ ("$NODE" "$CLI" app refresh --stale-only${tenantArgs} </dev/null >/dev/null 2>&1 &)
1220
+ exec "$NODE" "$CLI" token${tenantArgs}
1170
1221
  `;
1171
1222
  writeAtomic(target, script, 0o700);
1172
1223
  }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { parseFlags } from "../args.js";
7
7
  import {
8
+ crossAppModelsEnabled,
8
9
  loadConfig,
9
10
  saveConfig,
10
11
  normalizeGatewayUrl,
@@ -14,10 +15,12 @@ import {
14
15
  redactSecretText,
15
16
  } from "../config.js";
16
17
  import { promptSecret, promptText } from "../prompt.js";
18
+ import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "../cliProfiles.js";
17
19
  import { fetchTenants, normalizeTenantId, tenantCredential } from "../tenants.js";
20
+ import { rewriteTenantTokenHelper } from "../apps.js";
18
21
  import { cmdApps } from "./apps.js";
19
22
  import { prepareWindowsClis, windowsCliInstallCommands } from "../windowsSetup.js";
20
- import { runInstallRecovery } from "../installRecovery/loop.js";
23
+ import { runInstallRecovery } from "../installRecovery/engine.js";
21
24
 
22
25
  const HELP = `impel setup - guided end-to-end gateway setup
23
26
 
@@ -410,14 +413,63 @@ export async function cmdSetup(argv, overrides = {}) {
410
413
  ])
411
414
  ),
412
415
  };
416
+
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(", ")}` };
440
+ },
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}` };
459
+ },
460
+ });
461
+ }
462
+
413
463
  try {
414
464
  const recovery = await io.recoverInstall(
415
465
  {
416
466
  failure: aggregateFailure,
417
467
  config,
468
+ goals,
418
469
  explicit: Boolean(flags.repair),
419
470
  noRecovery: Boolean(flags["no-recovery"]),
420
471
  actionContext: {
472
+ tenantId: tenant.id,
421
473
  probeGateway: () => io.probe(gatewayUrl, pat, tenant.id),
422
474
  installVendorClis: (tool) =>
423
475
  io.prepareWindowsClis({
@@ -427,15 +479,24 @@ export async function cmdSetup(argv, overrides = {}) {
427
479
  installTools: [tool],
428
480
  }),
429
481
  repairProfiles: async () => {
430
- if (io.platform !== "win32") return false;
431
- const result = await io.prepareWindowsClis({
432
- gatewayUrl,
433
- tenantId: tenant.id,
434
- skipInstall: true,
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),
435
492
  });
436
- return result.missingAfter.length === 0;
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;
437
498
  },
438
- installVendorApp: (target) => io.installApps(["install", target]),
499
+ installVendorApp,
439
500
  retryStep: async (step) => {
440
501
  if (/vendor_cli|windows_clis/iu.test(step || "")) {
441
502
  const result = await io.prepareWindowsClis({
@@ -446,7 +507,7 @@ export async function cmdSetup(argv, overrides = {}) {
446
507
  return result.missingAfter.length === 0;
447
508
  }
448
509
  if (/vendor_app/iu.test(step || "")) {
449
- return (await io.installApps(["install", "all"])) !== false;
510
+ return (await installVendorApp("all")) !== false;
450
511
  }
451
512
  if (/gateway/iu.test(step || "")) {
452
513
  const result = await io.probe(gatewayUrl, pat, tenant.id);
@@ -462,23 +523,11 @@ export async function cmdSetup(argv, overrides = {}) {
462
523
  ...(overrides.recoveryOverrides || {}),
463
524
  }
464
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.
465
528
  if (recovery.fixed) {
466
- if (io.platform === "win32" && setupFailures.some((failure) => /vendor_cli|windows_clis/iu.test(failure.step))) {
467
- const checked = await io.prepareWindowsClis({
468
- gatewayUrl,
469
- tenantId: tenant.id,
470
- skipInstall: true,
471
- });
472
- platformSetupFailed = checked.missingAfter.length > 0;
473
- } else {
474
- platformSetupFailed = false;
475
- }
476
- if (setupFailures.some((failure) => failure.step === "verify.gateway")) {
477
- const checked = await io.probe(gatewayUrl, pat, tenant.id);
478
- verificationFailed = !checked.reachable || checked.rejected;
479
- } else {
480
- verificationFailed = false;
481
- }
529
+ platformSetupFailed = false;
530
+ verificationFailed = false;
482
531
  }
483
532
  } catch (error) {
484
533
  console.warn(`Install recovery could not start (${redactSecretText(error?.message || error)}).`);
@@ -16,7 +16,7 @@ import { nativeCommandInvocation } from "../nativeProcess.js";
16
16
  import { windowsClaudeUserData } from "../windowsApps.js";
17
17
  import { withProgress } from "../progress.js";
18
18
  import { installedAppTenantIds } from "./apps.js";
19
- import { runInstallRecovery } from "../installRecovery/loop.js";
19
+ import { runInstallRecovery } from "../installRecovery/engine.js";
20
20
  import {
21
21
  fetchRemoteVersion,
22
22
  installedVersion,
@@ -220,7 +220,10 @@ export async function cmdUpdate(argv, overrides = {}) {
220
220
  }
221
221
  const config = io.loadConfig();
222
222
  let recovered = false;
223
- if (config?.pat && config?.tenantId && config?.appUrl) {
223
+ if (config?.pat && config?.tenantId) {
224
+ // The goal tracks the most recent reviewed retry of the npm install:
225
+ // recovery may only end "fixed" once that retry actually succeeded.
226
+ const updateState = { ok: false };
224
227
  try {
225
228
  const recovery = await io.recoverInstall(
226
229
  {
@@ -232,11 +235,31 @@ export async function cmdUpdate(argv, overrides = {}) {
232
235
  message: "The npm-verified global impel-cli update failed.",
233
236
  },
234
237
  config,
238
+ goals: [
239
+ {
240
+ id: "cli-update",
241
+ description: "The global impel-cli npm update completed successfully",
242
+ run: () => updateState.ok === true,
243
+ },
244
+ ],
235
245
  explicit: Boolean(flags.repair),
236
246
  noRecovery: Boolean(flags["no-recovery"]),
237
247
  actionContext: {
238
- retryStep: async () =>
239
- io.selfUpdate(updateInstallSpec()) === true,
248
+ tenantId: config.tenantId,
249
+ probeGateway: async () => {
250
+ try {
251
+ const response = await fetch(`${config.gatewayUrl}/health`, {
252
+ signal: AbortSignal.timeout(5000),
253
+ });
254
+ return { reachable: true, status: response.status, rejected: false };
255
+ } catch (error) {
256
+ return { reachable: false, error: redactSecretText(error?.message || error) };
257
+ }
258
+ },
259
+ retryStep: async () => {
260
+ updateState.ok = io.selfUpdate(updateInstallSpec()) === true;
261
+ return updateState.ok;
262
+ },
240
263
  },
241
264
  },
242
265
  overrides.recoveryOverrides || {}
@@ -11,10 +11,9 @@ export const INSTALL_RECOVERY_CHECKPOINT_PATH = path.join(
11
11
  function validCheckpoint(value) {
12
12
  return Boolean(
13
13
  value &&
14
- value.protocolVersion === 1 &&
15
- typeof value.sessionId === "string" &&
16
- typeof value.recoveryToken === "string" &&
14
+ value.protocolVersion === 2 &&
17
15
  typeof value.fingerprint === "string" &&
16
+ typeof value.status === "string" &&
18
17
  typeof value.expiresAt === "number"
19
18
  );
20
19
  }
@@ -37,8 +36,11 @@ export function saveInstallRecoveryCheckpoint(
37
36
  ) {
38
37
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
39
38
  const temporary = `${filePath}.tmp-${process.pid}`;
39
+ fs.rmSync(temporary, { force: true });
40
+ // "wx" refuses to follow a pre-planted symlink at the predictable temp path.
40
41
  fs.writeFileSync(temporary, `${JSON.stringify(checkpoint, null, 2)}\n`, {
41
42
  mode: 0o600,
43
+ flag: "wx",
42
44
  });
43
45
  fs.renameSync(temporary, filePath);
44
46
  try {