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 +13 -9
- package/package.json +1 -1
- package/src/apps.js +56 -5
- package/src/commands/setup.js +74 -25
- package/src/commands/update.js +27 -4
- package/src/installRecovery/checkpoint.js +5 -3
- package/src/installRecovery/engine.js +437 -0
- package/src/installRecovery/inference.js +167 -0
- package/src/installRecovery/redact.js +14 -3
- package/src/installRecovery/tools.js +648 -0
- package/src/installRecovery/actions.js +0 -440
- package/src/installRecovery/client.js +0 -84
- package/src/installRecovery/loop.js +0 -258
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
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:
|
|
930
|
-
args:
|
|
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
|
-
|
|
1169
|
-
|
|
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
|
}
|
package/src/commands/setup.js
CHANGED
|
@@ -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/
|
|
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
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
467
|
-
|
|
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)}).`);
|
package/src/commands/update.js
CHANGED
|
@@ -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/
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
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 ===
|
|
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 {
|