impel-cli 0.20.38 → 0.20.40
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 +4 -3
- package/RELEASE_NOTES.md +36 -0
- package/bin/impel.js +10 -1
- package/docs/native-agent-host-capability-matrix.md +4 -3
- package/package.json +1 -1
- package/scripts/capture-release-fixtures.mjs +56 -0
- package/scripts/clean-machine/README.md +51 -0
- package/scripts/clean-machine/run-clean-windows.sh +115 -0
- package/scripts/clean-machine/userdata.ps1.template +102 -0
- package/scripts/regen-managed-artifacts.mjs +475 -0
- package/scripts/verify-vendor-pins.mjs +207 -0
- package/src/agents.js +47 -15
- package/src/apps.js +270 -82
- package/src/cliProfiles.js +1 -1
- package/src/codexSecurity.js +21 -0
- package/src/commands/apps.js +25 -2
- package/src/commands/converge.js +17 -1
- package/src/commands/mcp.js +1 -1
- package/src/commands/sessions.js +44 -11
- package/src/commands/setup.js +17 -1
- package/src/commands/token.js +23 -3
- package/src/commands/update.js +144 -16
- package/src/installRecovery/engine.js +45 -0
- package/src/macSetup.js +26 -8
- package/src/modelCatalog.js +103 -0
- package/src/nativeAgentTransport.js +13 -4
- package/src/platformSetup.js +9 -3
- package/src/vendorCliBinaries.js +36 -7
- package/src/verbatimRelay.js +9 -1
- package/src/windowsSetup.js +20 -0
- package/src/codexCliCatalog.js +0 -56
package/src/commands/apps.js
CHANGED
|
@@ -32,8 +32,11 @@ import {
|
|
|
32
32
|
ensureVendorApp,
|
|
33
33
|
fetchGatewayModels,
|
|
34
34
|
installManagedAppFiles,
|
|
35
|
+
MANAGED_RUNTIME_PREFLIGHT_EXIT_CODE,
|
|
35
36
|
managedChatGPTConfigDrifted,
|
|
37
|
+
managedClaudeConfigDrifted,
|
|
36
38
|
managedLauncherName,
|
|
39
|
+
managedRuntimePreflight,
|
|
37
40
|
normalizeAppTarget,
|
|
38
41
|
quitBlockingApps,
|
|
39
42
|
readTenantManifest,
|
|
@@ -294,7 +297,18 @@ function windowsProcessFailure(result) {
|
|
|
294
297
|
export function openManagedLauncher(launcher, {
|
|
295
298
|
spawn = spawnSync,
|
|
296
299
|
reportError = console.error,
|
|
300
|
+
preflight = managedRuntimePreflight,
|
|
297
301
|
} = {}) {
|
|
302
|
+
// Runtime-presence preflight (the v0.20.32 exit-127 rule): never hand a
|
|
303
|
+
// managed bundle whose vendored runtime is missing or off-pin to the OS —
|
|
304
|
+
// that launch dies with an unactionable bare failure. One actionable line
|
|
305
|
+
// and a distinct exit code instead; unmanaged bundles are not judged here.
|
|
306
|
+
const runtime = preflight(launcher);
|
|
307
|
+
if (runtime && runtime.ok === false) {
|
|
308
|
+
reportError(runtime.message);
|
|
309
|
+
process.exitCode = MANAGED_RUNTIME_PREFLIGHT_EXIT_CODE;
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
298
312
|
const result = spawn("/usr/bin/open", ["-n", launcher], { encoding: "utf8" });
|
|
299
313
|
if (!result?.error && result?.status === 0) return true;
|
|
300
314
|
const detail = result?.error?.message
|
|
@@ -740,7 +754,11 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
740
754
|
claudeUserData: io.claudeUserData(io.environment, staleTenantId),
|
|
741
755
|
tenantName: staleTenantId === stored.tenantId ? stored.tenantName : null,
|
|
742
756
|
});
|
|
743
|
-
if (
|
|
757
|
+
if (
|
|
758
|
+
manifestIsFresh(stalePaths, stored)
|
|
759
|
+
&& !managedChatGPTConfigDrifted(stalePaths)
|
|
760
|
+
&& !managedClaudeConfigDrifted(stalePaths)
|
|
761
|
+
) return true;
|
|
744
762
|
}
|
|
745
763
|
}
|
|
746
764
|
const config = await io.selectedConfig(targets, flags.tenant || null);
|
|
@@ -1329,7 +1347,12 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {},
|
|
|
1329
1347
|
? stored.tenantName || manifest?.tenantName
|
|
1330
1348
|
: manifest?.tenantName,
|
|
1331
1349
|
});
|
|
1332
|
-
if (
|
|
1350
|
+
if (
|
|
1351
|
+
staleOnly
|
|
1352
|
+
&& manifestIsFresh(paths, stored)
|
|
1353
|
+
&& !managedChatGPTConfigDrifted(paths)
|
|
1354
|
+
&& !managedClaudeConfigDrifted(paths)
|
|
1355
|
+
) return;
|
|
1333
1356
|
|
|
1334
1357
|
let config;
|
|
1335
1358
|
try {
|
package/src/commands/converge.js
CHANGED
|
@@ -214,7 +214,23 @@ export async function cmdConverge(argv = [], overrides = {}) {
|
|
|
214
214
|
goals: [{
|
|
215
215
|
id: "shared-vendor-clis-ready",
|
|
216
216
|
description: "Shared vendor CLI prerequisites are installed and discoverable",
|
|
217
|
-
|
|
217
|
+
// Live probe, not a session snapshot: a vendor CLI installed outside
|
|
218
|
+
// this process (for example by the user in another terminal while the
|
|
219
|
+
// recovery session runs) must count as complete the moment the goal
|
|
220
|
+
// is evaluated, so recovery never re-issues an already-finished step.
|
|
221
|
+
run: async () => {
|
|
222
|
+
if (sharedReady) return true;
|
|
223
|
+
const prepared = await io.preparePlatformClis({
|
|
224
|
+
gatewayUrl: config.gatewayUrl,
|
|
225
|
+
tenantId: selected.id,
|
|
226
|
+
platform: io.platform,
|
|
227
|
+
skipInstall: true,
|
|
228
|
+
// Goals re-run before every system-risk step: the probe must be
|
|
229
|
+
// pure discovery — no installs, profile writes, or skill syncs.
|
|
230
|
+
inspectOnly: true,
|
|
231
|
+
});
|
|
232
|
+
return prepared.missingAfter.length === 0;
|
|
233
|
+
},
|
|
218
234
|
}],
|
|
219
235
|
explicit: true,
|
|
220
236
|
noRecovery: false,
|
package/src/commands/mcp.js
CHANGED
|
@@ -263,7 +263,7 @@ export function runNativeAgentMcpServer({
|
|
|
263
263
|
let value;
|
|
264
264
|
if (name === NATIVE_AGENT_ANSWER_TOOL) {
|
|
265
265
|
if (mode !== "answer") throw new Error("this native-agent binding cannot answer directly");
|
|
266
|
-
value = await transport.answer(args, { signal: controller.signal });
|
|
266
|
+
value = await transport.answer(args, { signal: controller.signal, onProgress });
|
|
267
267
|
} else if (name === NATIVE_AGENT_RUN_TOOL) {
|
|
268
268
|
if (mode === "recovery") throw new Error("retired native-agent bindings cannot start new runs");
|
|
269
269
|
if (mode === "answer") throw new Error("direct-answer native-agent bindings cannot start durable runs");
|
package/src/commands/sessions.js
CHANGED
|
@@ -2,7 +2,8 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import { parseFlags } from "../args.js";
|
|
5
|
-
import { appPaths, managedChatGPTConfigDrifted } from "../apps.js";
|
|
5
|
+
import { appPaths, managedChatGPTConfigDrifted, managedClaudeConfigDrifted } from "../apps.js";
|
|
6
|
+
import { windowsClaudeUserData } from "../windowsApps.js";
|
|
6
7
|
import {
|
|
7
8
|
clearSessionFlushLock,
|
|
8
9
|
collectSessionHook,
|
|
@@ -32,23 +33,36 @@ const SPEC = {
|
|
|
32
33
|
[MANAGED_SESSION_HOOK_FLAG]: { type: "boolean" },
|
|
33
34
|
};
|
|
34
35
|
|
|
36
|
+
// The Claude drift check reads the Claude app profile, which lives under
|
|
37
|
+
// %LOCALAPPDATA% on Windows rather than the darwin tenant root.
|
|
38
|
+
function managedAppDriftPaths(tenantId) {
|
|
39
|
+
return appPaths(undefined, tenantId, process.platform === "win32"
|
|
40
|
+
? { claudeUserData: windowsClaudeUserData(process.env, tenantId) }
|
|
41
|
+
: {});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function anyManagedAppConfigDrifted(paths) {
|
|
45
|
+
return managedChatGPTConfigDrifted(paths) || managedClaudeConfigDrifted(paths);
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
/**
|
|
36
|
-
* Repair a managed
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
49
|
+
* Repair a managed app profile a vendor just rewrote out from under the
|
|
50
|
+
* gateway. Hooks and the token helper are the only Impel code guaranteed to
|
|
51
|
+
* run while an app is broken (a drifted config can stop calling the provider
|
|
52
|
+
* token helper entirely), so both check for drift and kick one detached
|
|
53
|
+
* stale-only refresh — which the drift-aware staleness gate turns into a real
|
|
54
|
+
* repair. The heartbeat lock keeps repeated events from stacking refresh
|
|
55
|
+
* children while one is already running. Content-checks both managed
|
|
56
|
+
* surfaces (ChatGPT/Codex and Claude) by default.
|
|
43
57
|
*/
|
|
44
|
-
export function
|
|
45
|
-
drifted =
|
|
58
|
+
export function maybeRepairManagedApps(tenantId, {
|
|
59
|
+
drifted = anyManagedAppConfigDrifted,
|
|
46
60
|
lockIsFresh = sessionFlushLockIsFresh,
|
|
47
61
|
touchLock = touchSessionFlushLock,
|
|
48
62
|
spawnRefresh = spawnDetachedAppRefresh,
|
|
49
63
|
} = {}) {
|
|
50
64
|
if (!tenantId) return false;
|
|
51
|
-
const paths =
|
|
65
|
+
const paths = managedAppDriftPaths(tenantId);
|
|
52
66
|
if (!drifted(paths)) return false;
|
|
53
67
|
const lock = path.join(paths.tenantRoot, "app-repair-heartbeat");
|
|
54
68
|
if (lockIsFresh(lock, 60_000)) return false;
|
|
@@ -57,6 +71,19 @@ export function maybeRepairManagedCodexApp(tenantId, {
|
|
|
57
71
|
return true;
|
|
58
72
|
}
|
|
59
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The codex session-hook entry point: same repair, scoped to ChatGPT/Codex
|
|
76
|
+
* drift exactly as before the helper was generalized (the codex hook fires on
|
|
77
|
+
* codex events; Claude drift is healed by the token helper and the Claude
|
|
78
|
+
* session hooks' own refresh channel).
|
|
79
|
+
*/
|
|
80
|
+
export function maybeRepairManagedCodexApp(tenantId, overrides = {}) {
|
|
81
|
+
return maybeRepairManagedApps(tenantId, {
|
|
82
|
+
drifted: managedChatGPTConfigDrifted,
|
|
83
|
+
...overrides,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
60
87
|
function startDetachedFlush({ provider, tenant, session }) {
|
|
61
88
|
const invocation = impelCliInvocation([
|
|
62
89
|
"sessions",
|
|
@@ -101,6 +128,12 @@ export async function cmdSessions(argv) {
|
|
|
101
128
|
flush: false,
|
|
102
129
|
});
|
|
103
130
|
if (flags.provider === "codex") maybeRepairManagedCodexApp(flags.tenant);
|
|
131
|
+
// The Claude mirror of the codex repair above: Claude Desktop rewrites
|
|
132
|
+
// its co-owned profile files too, and its session hooks are the only
|
|
133
|
+
// Impel code guaranteed to still run afterward.
|
|
134
|
+
if (flags.provider === "claude_code") {
|
|
135
|
+
maybeRepairManagedApps(flags.tenant, { drifted: managedClaudeConfigDrifted });
|
|
136
|
+
}
|
|
104
137
|
if (config && (config.tenantId === flags.tenant || environmentValue("SESSIONS_DEV_ORG_ID"))) {
|
|
105
138
|
// Hooks fire on every session event; only spawn a flush child when no
|
|
106
139
|
// live one is already polling this session's outbox (heartbeat lock).
|
package/src/commands/setup.js
CHANGED
|
@@ -370,7 +370,23 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
370
370
|
goals: [{
|
|
371
371
|
id: "shared-vendor-clis-ready",
|
|
372
372
|
description: "Shared vendor CLI prerequisites are installed and discoverable",
|
|
373
|
-
|
|
373
|
+
// Live probe, not a session snapshot: a vendor CLI installed outside
|
|
374
|
+
// this process (for example by the user in another terminal while the
|
|
375
|
+
// recovery session runs) must count as complete the moment the goal
|
|
376
|
+
// is evaluated, so recovery never re-issues an already-finished step.
|
|
377
|
+
run: async () => {
|
|
378
|
+
if (sharedReady) return true;
|
|
379
|
+
const prepared = await io.preparePlatformClis({
|
|
380
|
+
gatewayUrl,
|
|
381
|
+
tenantId: selected.id,
|
|
382
|
+
platform: io.platform,
|
|
383
|
+
skipInstall: true,
|
|
384
|
+
// Goals re-run before every system-risk step: the probe must be
|
|
385
|
+
// pure discovery — no installs, profile writes, or skill syncs.
|
|
386
|
+
inspectOnly: true,
|
|
387
|
+
});
|
|
388
|
+
return prepared.missingAfter.length === 0;
|
|
389
|
+
},
|
|
374
390
|
}],
|
|
375
391
|
explicit: Boolean(flags.repair),
|
|
376
392
|
noRecovery: false,
|
package/src/commands/token.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { loadConfig } from "../config.js";
|
|
2
2
|
import { parseFlags } from "../args.js";
|
|
3
3
|
import { ensureTenantSelection, normalizeTenantId, tenantCredential } from "../tenants.js";
|
|
4
|
+
import { maybeRepairManagedApps } from "./sessions.js";
|
|
4
5
|
import { RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
5
6
|
|
|
6
7
|
// This is the `apiKeyHelper` / auth-command contract: stdout (and only
|
|
7
8
|
// stdout) must be exactly the bearer token, nothing else. Both Claude Code's
|
|
8
9
|
// apiKeyHelper and Codex CLI's `model_providers.<id>.auth.command` call this.
|
|
9
|
-
export async function cmdToken(argv = []) {
|
|
10
|
+
export async function cmdToken(argv = [], overrides = {}) {
|
|
11
|
+
const io = {
|
|
12
|
+
loadConfig,
|
|
13
|
+
ensureTenantSelection,
|
|
14
|
+
repairManagedApps: maybeRepairManagedApps,
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
10
17
|
const { flags } = parseFlags(argv, { tenant: { type: "string" } });
|
|
11
|
-
const config = loadConfig();
|
|
18
|
+
const config = io.loadConfig();
|
|
12
19
|
if (!config?.pat) {
|
|
13
20
|
process.stderr.write(`${RUNTIME_BRAND.cli.command}: not authenticated. Run \`${RUNTIME_BRAND.cli.command} setup\` (or \`${RUNTIME_BRAND.cli.command} auth\`) first.\n`);
|
|
14
21
|
process.exitCode = 1;
|
|
@@ -17,8 +24,21 @@ export async function cmdToken(argv = []) {
|
|
|
17
24
|
try {
|
|
18
25
|
const tenantId = flags.tenant
|
|
19
26
|
? normalizeTenantId(flags.tenant)
|
|
20
|
-
: (await ensureTenantSelection(config)).tenantId;
|
|
27
|
+
: (await io.ensureTenantSelection(config)).tenantId;
|
|
21
28
|
process.stdout.write(`${tenantCredential(config.pat, tenantId)}\n`);
|
|
29
|
+
// Drift-aware self-heal, strictly AFTER the token bytes are on stdout: the
|
|
30
|
+
// vendor apps call this helper on every auth, which makes it an Impel
|
|
31
|
+
// entry point that keeps running even after a vendor settings-writer
|
|
32
|
+
// rewrites a managed profile (a drifted config once stopped invoking the
|
|
33
|
+
// rest of the heal channel entirely). Kick the same detached,
|
|
34
|
+
// heartbeat-locked stale-only refresh the session hooks use — for both
|
|
35
|
+
// Codex and Claude drift. It must never write to stdout, never block the
|
|
36
|
+
// emission above, and never change this command's exit code.
|
|
37
|
+
try {
|
|
38
|
+
io.repairManagedApps(tenantId);
|
|
39
|
+
} catch {
|
|
40
|
+
// Opportunistic only; the token contract is already fulfilled.
|
|
41
|
+
}
|
|
22
42
|
} catch (error) {
|
|
23
43
|
process.stderr.write(`${RUNTIME_BRAND.cli.command}: ${error.message}\n`);
|
|
24
44
|
process.exitCode = 1;
|
package/src/commands/update.js
CHANGED
|
@@ -10,7 +10,7 @@ import { loadConfig, redactSecretText } from "../config.js";
|
|
|
10
10
|
import { nativeCommandInvocation } from "../nativeProcess.js";
|
|
11
11
|
import { withProgress } from "../progress.js";
|
|
12
12
|
import { runInstallRecovery } from "../installRecovery/engine.js";
|
|
13
|
-
import { brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
13
|
+
import { brandedEnvironmentName, brandedText, RUNTIME_BRAND } from "../runtimeBrand.js";
|
|
14
14
|
import { IMPEL_CLI_ENTRYPOINT } from "../selfInvocation.js";
|
|
15
15
|
import {
|
|
16
16
|
fetchRemoteVersion,
|
|
@@ -18,12 +18,20 @@ import {
|
|
|
18
18
|
isNewerVersion,
|
|
19
19
|
refreshUpdateCache,
|
|
20
20
|
updateInstallSpec,
|
|
21
|
+
updatePackage,
|
|
21
22
|
updateTagForVersion,
|
|
22
23
|
writeUpdateCache,
|
|
23
24
|
} from "../updates.js";
|
|
24
25
|
|
|
25
26
|
const CLI_BIN = IMPEL_CLI_ENTRYPOINT;
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Loop guard for the fresh-build continuation: `_converge` children run with
|
|
30
|
+
* this marker so a nested `impel update` inside the cascade can never start
|
|
31
|
+
* another self-update (and thus another cascade) recursively.
|
|
32
|
+
*/
|
|
33
|
+
export const UPDATE_CASCADE_ENV = brandedEnvironmentName("UPDATE_CASCADE");
|
|
34
|
+
|
|
27
35
|
/**
|
|
28
36
|
* The version now on disk at the package that owns CLI_BIN, read FRESH (never
|
|
29
37
|
* from this process's cached module graph). After `npm install -g` this is the
|
|
@@ -39,6 +47,28 @@ export function postInstallCliVersion() {
|
|
|
39
47
|
}
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Whether the build at CLI_BIN actually RUNS and reports the expected
|
|
52
|
+
* version. A bare package.json read is not proof after a failed
|
|
53
|
+
* `npm install -g`: the global replace is non-atomic (notably on Windows),
|
|
54
|
+
* so a half-written package can carry the new manifest with broken code.
|
|
55
|
+
*/
|
|
56
|
+
export function postInstallCliRunnable(expectedVersion, { spawn = spawnSync, execPath = process.execPath } = {}) {
|
|
57
|
+
if (typeof expectedVersion !== "string" || !expectedVersion.trim()) return false;
|
|
58
|
+
try {
|
|
59
|
+
const run = spawn(execPath, [CLI_BIN, "--version"], {
|
|
60
|
+
encoding: "utf8",
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
timeout: 15_000,
|
|
63
|
+
windowsHide: true,
|
|
64
|
+
});
|
|
65
|
+
if (run.status !== 0 || run.error) return false;
|
|
66
|
+
return String(run.stdout || "").trim() === expectedVersion.trim();
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
42
72
|
const HELP = brandedText(`impel update - update everything Impel in one command
|
|
43
73
|
|
|
44
74
|
Reinstalls impel-cli from npm, then uses the new build to discover every
|
|
@@ -101,11 +131,67 @@ function defaultSelfUpdate(spec) {
|
|
|
101
131
|
return installUpdateFromRegistry(spec);
|
|
102
132
|
}
|
|
103
133
|
|
|
134
|
+
/**
|
|
135
|
+
* After `npm install -g` exits 0 but the RUNNING CLI's own package still
|
|
136
|
+
* resolves the old version (npm-prefix skew: nvm/Homebrew node transitions,
|
|
137
|
+
* npm 11 dangling prepare-script symlinks), locate the build npm actually
|
|
138
|
+
* installed so the cascade can run under it. Fail-closed: the entry point
|
|
139
|
+
* must live inside the global root of the same `npm` the installer ran, and
|
|
140
|
+
* its package.json must resolve exactly the version npm reported for the
|
|
141
|
+
* update tag; anything else returns null and the caller keeps the hard error.
|
|
142
|
+
*/
|
|
143
|
+
export function resolveFreshGlobalEntrypoint(targetVersion, dependencies = {}) {
|
|
144
|
+
const io = {
|
|
145
|
+
spawnSync,
|
|
146
|
+
platform: process.platform,
|
|
147
|
+
environment: process.env,
|
|
148
|
+
readFileSync: fs.readFileSync,
|
|
149
|
+
existsSync: fs.existsSync,
|
|
150
|
+
...dependencies,
|
|
151
|
+
};
|
|
152
|
+
if (typeof targetVersion !== "string" || !targetVersion.trim()) return null;
|
|
153
|
+
try {
|
|
154
|
+
const invocation = nativeCommandInvocation("npm", ["root", "-g"], io.environment, io.platform);
|
|
155
|
+
const run = io.spawnSync(invocation.command, invocation.args, {
|
|
156
|
+
encoding: "utf8",
|
|
157
|
+
env: io.environment,
|
|
158
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
159
|
+
timeout: 15_000,
|
|
160
|
+
windowsHide: true,
|
|
161
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
162
|
+
});
|
|
163
|
+
if (run.status !== 0 || run.error) return null;
|
|
164
|
+
const globalRoot = String(run.stdout || "").trim();
|
|
165
|
+
if (!globalRoot || !path.isAbsolute(globalRoot)) return null;
|
|
166
|
+
// Containment is enforced at every hop: the package directory must stay
|
|
167
|
+
// inside the npm global root (an env-supplied package name cannot
|
|
168
|
+
// traverse out), the declared bin must stay inside the package, and the
|
|
169
|
+
// real (symlink-resolved) entry point must still live under the real
|
|
170
|
+
// global root. Anything else fails closed to the caller's hard error.
|
|
171
|
+
const packageRoot = path.resolve(path.join(globalRoot, ...updatePackage().split("/")));
|
|
172
|
+
if (!packageRoot.startsWith(path.resolve(globalRoot) + path.sep)) return null;
|
|
173
|
+
const pkg = JSON.parse(io.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
174
|
+
if (pkg?.version !== targetVersion) return null;
|
|
175
|
+
const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.[RUNTIME_BRAND.cli.command];
|
|
176
|
+
if (typeof bin !== "string" || !bin.trim()) return null;
|
|
177
|
+
const entrypoint = path.resolve(packageRoot, bin);
|
|
178
|
+
if (!entrypoint.startsWith(packageRoot + path.sep)) return null;
|
|
179
|
+
if (!io.existsSync(entrypoint)) return null;
|
|
180
|
+
const realEntrypoint = (io.realpathSync || fs.realpathSync)(entrypoint);
|
|
181
|
+
const realRoot = (io.realpathSync || fs.realpathSync)(globalRoot);
|
|
182
|
+
if (!realEntrypoint.startsWith(realRoot + path.sep)) return null;
|
|
183
|
+
return entrypoint;
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
104
189
|
// The cascading steps re-execute the (freshly installed) CLI binary so the
|
|
105
190
|
// NEW code performs them, not the process that started the update. On
|
|
106
191
|
// Windows this is also what refreshes the stable %LOCALAPPDATA% entry point:
|
|
107
192
|
// bin/impel.js rewrites it at startup, so the shim is only ever updated by a
|
|
108
|
-
// build
|
|
193
|
+
// build verified either by the postInstallCliVersion guard below or by
|
|
194
|
+
// resolveFreshGlobalEntrypoint's fail-closed skew resolution.
|
|
109
195
|
export function defaultRunConvergence({
|
|
110
196
|
skipApps = false,
|
|
111
197
|
skipClis = false,
|
|
@@ -113,6 +199,7 @@ export function defaultRunConvergence({
|
|
|
113
199
|
spawn = spawnSync,
|
|
114
200
|
execPath = process.execPath,
|
|
115
201
|
cliBin = CLI_BIN,
|
|
202
|
+
environment = process.env,
|
|
116
203
|
} = {}) {
|
|
117
204
|
const args = [cliBin, "_converge"];
|
|
118
205
|
if (skipApps) args.push("--skip-apps");
|
|
@@ -120,6 +207,7 @@ export function defaultRunConvergence({
|
|
|
120
207
|
if (noRecovery) args.push("--no-recovery");
|
|
121
208
|
const result = spawn(execPath, args, {
|
|
122
209
|
stdio: "inherit",
|
|
210
|
+
env: { ...environment, [UPDATE_CASCADE_ENV]: "1" },
|
|
123
211
|
});
|
|
124
212
|
return result.status === 0;
|
|
125
213
|
}
|
|
@@ -161,9 +249,12 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
161
249
|
runSkillsSync: defaultRunSkillsSync,
|
|
162
250
|
runAgentsSync: defaultRunAgentsSync,
|
|
163
251
|
platform: process.platform,
|
|
252
|
+
environment: process.env,
|
|
164
253
|
progress: withProgress,
|
|
165
254
|
recoverInstall: runInstallRecovery,
|
|
166
255
|
postInstallVersion: postInstallCliVersion,
|
|
256
|
+
postInstallRunnable: postInstallCliRunnable,
|
|
257
|
+
resolveFreshInstall: resolveFreshGlobalEntrypoint,
|
|
167
258
|
loadConfig,
|
|
168
259
|
...overrides,
|
|
169
260
|
};
|
|
@@ -225,10 +316,22 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
225
316
|
}
|
|
226
317
|
|
|
227
318
|
// ── CLI ────────────────────────────────────────────────────────────────
|
|
319
|
+
let cascadeEntrypoint = null;
|
|
228
320
|
if (!remote) {
|
|
229
321
|
console.warn("CLI: npm update check unavailable; keeping the installed build.");
|
|
230
322
|
} else if (upToDate) {
|
|
231
323
|
console.log("CLI: already up to date.");
|
|
324
|
+
} else if (io.environment?.[UPDATE_CASCADE_ENV] === "1") {
|
|
325
|
+
// Loop guard: a self-update must never start from inside its own
|
|
326
|
+
// fresh-build continuation, or a buggy cascade could respawn forever.
|
|
327
|
+
// Degrade rather than abort: an inherited marker (e.g. a terminal opened
|
|
328
|
+
// under a converge child) must not permanently brick `impel update` —
|
|
329
|
+
// convergence still runs; only the self-update is withheld.
|
|
330
|
+
console.warn(
|
|
331
|
+
`impel update: skipping the self-update because ${UPDATE_CASCADE_ENV} is set `
|
|
332
|
+
+ "(update cascade in progress, or inherited from one). Convergence continues; "
|
|
333
|
+
+ `for a self-update, unset ${UPDATE_CASCADE_ENV} or open a fresh terminal.`,
|
|
334
|
+
);
|
|
232
335
|
} else {
|
|
233
336
|
console.log(`CLI: installing the npm ${updateTag} build…`);
|
|
234
337
|
if (!await io.progress(`Installing the npm ${updateTag} impel-cli build`, () => io.selfUpdate(installSpec))) {
|
|
@@ -240,8 +343,11 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
240
343
|
const config = io.loadConfig();
|
|
241
344
|
let recovered = false;
|
|
242
345
|
if (config?.pat && config?.tenantId) {
|
|
243
|
-
// The goal
|
|
244
|
-
//
|
|
346
|
+
// The goal passes on the most recent reviewed retry of the npm
|
|
347
|
+
// install OR on a live disk probe of the package that owns the
|
|
348
|
+
// running CLI: when the user completes the printed manual
|
|
349
|
+
// `npm install --global …` in another terminal mid-recovery, the goal
|
|
350
|
+
// must observe that completion instead of re-issuing the same step.
|
|
245
351
|
const updateState = { ok: false };
|
|
246
352
|
try {
|
|
247
353
|
const recovery = await io.recoverInstall(
|
|
@@ -259,7 +365,10 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
259
365
|
{
|
|
260
366
|
id: "cli-update",
|
|
261
367
|
description: `The global ${RUNTIME_BRAND.cli.packageName} npm update completed successfully`,
|
|
262
|
-
run: () => updateState.ok === true
|
|
368
|
+
run: () => updateState.ok === true
|
|
369
|
+
|| (remote != null
|
|
370
|
+
&& io.postInstallVersion() === remote
|
|
371
|
+
&& io.postInstallRunnable(remote)),
|
|
263
372
|
},
|
|
264
373
|
],
|
|
265
374
|
explicit: Boolean(flags.repair),
|
|
@@ -296,19 +405,35 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
296
405
|
console.log("CLI: recovery verified the update path.");
|
|
297
406
|
}
|
|
298
407
|
// npm exiting 0 is NOT proof the RUNNING install was updated: with multiple
|
|
299
|
-
// Node installs / npm prefixes (
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
408
|
+
// Node installs / npm prefixes (nvm/Homebrew node transitions on macOS,
|
|
409
|
+
// prefix skew on Windows), the install can land in a different global
|
|
410
|
+
// prefix while CLI_BIN — the path the cascade re-executes — still holds
|
|
411
|
+
// the old build. Cascading old code that believes an update is still
|
|
412
|
+
// pending is how unbounded respawn storms start, so the cascade may only
|
|
413
|
+
// ever run a build that provably resolves the target version. Probe the
|
|
414
|
+
// version at CLI_BIN fresh from disk; on skew, locate and verify the
|
|
415
|
+
// build npm actually installed and hand the continuation to THAT binary.
|
|
416
|
+
// (The previous behavior — abort with exit 1 — left the machine
|
|
417
|
+
// half-updated: CLI updated in the other prefix, no tenant/app/skills
|
|
418
|
+
// convergence, and a rerun from the same terminal repeated the abort via
|
|
419
|
+
// the shell's stale command hash. That is the field-reported "run
|
|
420
|
+
// `impel update` twice, sometimes in separate terminals".)
|
|
304
421
|
const postInstall = io.postInstallVersion();
|
|
305
422
|
if (remote && postInstall !== remote) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
423
|
+
cascadeEntrypoint = io.resolveFreshInstall(remote);
|
|
424
|
+
if (!cascadeEntrypoint) {
|
|
425
|
+
console.error(`impel update: npm reported success but the running CLI still resolves v${postInstall ?? "?"} (expected v${remote}), and the freshly installed build could not be located and verified.`);
|
|
426
|
+
console.error(` Running CLI: ${CLI_BIN}`);
|
|
427
|
+
console.error(" This usually means the `impel` on PATH belongs to a different npm prefix than `npm prefix -g`.");
|
|
428
|
+
console.error(` Fix: run \`npm prefix -g\`, confirm it owns the \`impel\` shim on PATH, then \`npm install --global ${installSpec}\` there.`);
|
|
429
|
+
process.exitCode = 1;
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
console.warn(`impel update: the running CLI at ${CLI_BIN} still resolves v${postInstall ?? "?"}; continuing with the freshly installed v${remote}.`);
|
|
433
|
+
console.warn(` Fresh build: ${cascadeEntrypoint}`);
|
|
434
|
+
console.warn(io.platform === "win32"
|
|
435
|
+
? " Your current shell may still launch the old install. Open a new terminal, and align the `impel` on PATH with `npm prefix -g`."
|
|
436
|
+
: " Your current shell may still launch the old install. Run `hash -r` (zsh/bash) or open a new terminal, and align the `impel` on PATH with `npm prefix -g`.");
|
|
312
437
|
}
|
|
313
438
|
console.log(`CLI: updated${remote ? ` to v${remote}` : ""}.`);
|
|
314
439
|
}
|
|
@@ -318,6 +443,9 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
318
443
|
skipApps: Boolean(flags["skip-apps"]),
|
|
319
444
|
skipClis: Boolean(flags["skip-clis"]),
|
|
320
445
|
noRecovery: Boolean(flags["no-recovery"]),
|
|
446
|
+
// On npm-prefix skew the continuation must re-exec the verified fresh
|
|
447
|
+
// build; everywhere else CLI_BIN already holds the new bytes.
|
|
448
|
+
...(cascadeEntrypoint ? { cliBin: cascadeEntrypoint } : {}),
|
|
321
449
|
};
|
|
322
450
|
if (!await io.runConvergence(convergenceArgs)) {
|
|
323
451
|
console.error("impel update: tenant convergence failed; rerun `impel update` after addressing the reported issue.");
|
|
@@ -358,6 +358,27 @@ export async function runInstallRecovery(options, overrides = {}) {
|
|
|
358
358
|
continue;
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
|
+
// A blocked report gets one live goal pass before it stands: when
|
|
362
|
+
// every health check succeeds (a skipped step, or the user
|
|
363
|
+
// finishing the work in another terminal), the machine is healthy
|
|
364
|
+
// regardless of what the model concluded — mirroring the
|
|
365
|
+
// budget-exhaustion upgrade below.
|
|
366
|
+
if (status === "blocked") {
|
|
367
|
+
goalReport = await runGoals(session.goals);
|
|
368
|
+
if (goalReport.passed) {
|
|
369
|
+
finished = {
|
|
370
|
+
status: "fixed",
|
|
371
|
+
summary: "Every goal health check passes; the reported blocker is no longer present.",
|
|
372
|
+
userAction: null,
|
|
373
|
+
};
|
|
374
|
+
toolResults.push({
|
|
375
|
+
type: "tool_result",
|
|
376
|
+
tool_use_id: call.id,
|
|
377
|
+
content: "Outcome recorded.",
|
|
378
|
+
});
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
361
382
|
finished = {
|
|
362
383
|
status,
|
|
363
384
|
summary: String(call.input?.summary || "").slice(0, 2_000),
|
|
@@ -371,6 +392,24 @@ export async function runInstallRecovery(options, overrides = {}) {
|
|
|
371
392
|
break;
|
|
372
393
|
}
|
|
373
394
|
|
|
395
|
+
// Live re-probe before any system-risk step: when every goal health
|
|
396
|
+
// check already passes, the state this step would produce is already
|
|
397
|
+
// present on the machine (typically because the user completed the
|
|
398
|
+
// instruction in another terminal mid-session). Skip the step instead
|
|
399
|
+
// of re-prompting for an installer the environment no longer needs.
|
|
400
|
+
if (installRecoveryToolRisk(call.name) === "system" && session.goals.length) {
|
|
401
|
+
const live = await runGoals(session.goals);
|
|
402
|
+
if (live.passed) {
|
|
403
|
+
io.log(`Install recovery: skipping ${call.name} — every goal health check already passes.`);
|
|
404
|
+
toolResults.push({
|
|
405
|
+
type: "tool_result",
|
|
406
|
+
tool_use_id: call.id,
|
|
407
|
+
content: "Skipped: every goal health check now passes — this step is already satisfied on this machine. Verify with check_health and finish with report_outcome.",
|
|
408
|
+
});
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
374
413
|
const result = await executeCall(session, call.name, call.input);
|
|
375
414
|
if (result.outcome === "declined" && installRecoveryToolRisk(call.name) === "system") {
|
|
376
415
|
declinedSystemCalls += 1;
|
|
@@ -397,6 +436,12 @@ export async function runInstallRecovery(options, overrides = {}) {
|
|
|
397
436
|
}
|
|
398
437
|
|
|
399
438
|
if (declinedSystemCalls >= 2) {
|
|
439
|
+
// Same live upgrade as the blocked and budget paths: declining a
|
|
440
|
+
// repair the machine no longer needs must not read as a failure.
|
|
441
|
+
goalReport = await runGoals(session.goals);
|
|
442
|
+
if (goalReport.passed) {
|
|
443
|
+
return terminalReturn(session, "fixed", "The installation passes all health checks.");
|
|
444
|
+
}
|
|
400
445
|
return terminalReturn(
|
|
401
446
|
session,
|
|
402
447
|
"aborted",
|
package/src/macSetup.js
CHANGED
|
@@ -7,7 +7,7 @@ import { spawnSync } from "node:child_process";
|
|
|
7
7
|
import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "./cliProfiles.js";
|
|
8
8
|
import { findNativeBinary } from "./nativeProcess.js";
|
|
9
9
|
import { syncSkillsSafe } from "./skills.js";
|
|
10
|
-
import { verifyReviewedMacVendorCli } from "./vendorCliBinaries.js";
|
|
10
|
+
import { findReviewedVendorCliBinary, verifyReviewedMacVendorCli } from "./vendorCliBinaries.js";
|
|
11
11
|
import { PINNED_VENDOR_CLI_VERSIONS } from "./vendorCliVersions.js";
|
|
12
12
|
|
|
13
13
|
const MAX_INSTALLER_BYTES = 512 * 1024;
|
|
@@ -49,13 +49,14 @@ export const MAC_CLI_INSTALLERS = Object.freeze({
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
function detectMacClis(find, environment, verify) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
// Detection must share the launcher's reviewed resolution (including the
|
|
53
|
+
// side-by-side fallback after vendor auto-update drift); a raw PATH probe
|
|
54
|
+
// here would classify a drifted-but-launchable install as missing and
|
|
55
|
+
// reinstall it on every setup/update pass.
|
|
56
|
+
const detect = (tool) => findReviewedVendorCliBinary(tool, environment, "darwin", "IMPEL", {
|
|
57
|
+
find,
|
|
58
|
+
verify,
|
|
59
|
+
});
|
|
59
60
|
return { claude: detect("claude"), codex: detect("codex") };
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -171,6 +172,7 @@ export async function prepareMacClis({
|
|
|
171
172
|
gatewayUrl,
|
|
172
173
|
tenantId,
|
|
173
174
|
skipInstall = false,
|
|
175
|
+
inspectOnly = false,
|
|
174
176
|
installTools = ["claude", "codex"],
|
|
175
177
|
} = {}, dependencies = {}) {
|
|
176
178
|
const io = {
|
|
@@ -193,6 +195,22 @@ export async function prepareMacClis({
|
|
|
193
195
|
if (requested.size !== installTools.length || [...requested].some((tool) => !io.installers[tool])) {
|
|
194
196
|
throw new Error("installTools must contain unique supported macOS CLI names");
|
|
195
197
|
}
|
|
198
|
+
// A pure discovery probe: recovery goals and health checks re-evaluate
|
|
199
|
+
// this repeatedly, so it must never install, write profiles, or sync
|
|
200
|
+
// skills — only report what is currently discoverable.
|
|
201
|
+
if (inspectOnly) {
|
|
202
|
+
return {
|
|
203
|
+
binaries: before,
|
|
204
|
+
missingBefore,
|
|
205
|
+
missingAfter: [...missingBefore],
|
|
206
|
+
installAttempted: false,
|
|
207
|
+
installSucceeded: null,
|
|
208
|
+
installFailure: null,
|
|
209
|
+
installations: {},
|
|
210
|
+
installCommands: {},
|
|
211
|
+
profiles: null,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
196
214
|
|
|
197
215
|
const installations = {};
|
|
198
216
|
if (missingBefore.length && !skipInstall) {
|