impel-cli 0.17.9 → 0.17.11
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/bin/impel.js +11 -0
- package/package.json +1 -1
- package/src/apps.js +7 -3
- package/src/commands/apps.js +17 -0
- package/src/commands/converge.js +16 -3
- package/src/commands/nuke.js +6 -1
- package/src/commands/sessions.js +44 -13
- package/src/commands/update.js +36 -1
- package/src/selfInvocation.js +55 -5
- package/src/sessionCollector.js +42 -1
- package/src/shellEntries.js +2 -2
- package/src/skills.js +3 -0
- package/src/stableEntrypoint.js +194 -0
- package/src/updates.js +20 -2
package/bin/impel.js
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { main } from "../src/cli.js";
|
|
3
|
+
import { ensureWindowsStableEntrypointQuietly } from "../src/stableEntrypoint.js";
|
|
4
|
+
|
|
5
|
+
// Every real impel process starts here — including vendor-driven token/MCP/
|
|
6
|
+
// hook invocations and the post-update `_converge` cascade (which only spawns
|
|
7
|
+
// after postInstallCliVersion verified the install). Refreshing the stable
|
|
8
|
+
// Windows entry point first therefore guarantees managed artifacts always
|
|
9
|
+
// point at a shim that resolves a working install. Global installs only:
|
|
10
|
+
// checkout runs never repoint the machine-wide shim (see stableEntrypoint.js).
|
|
11
|
+
if (process.platform === "win32" && process.env.IMPEL_SKIP_ENTRYPOINT_REFRESH !== "1") {
|
|
12
|
+
ensureWindowsStableEntrypointQuietly();
|
|
13
|
+
}
|
|
3
14
|
|
|
4
15
|
main(process.argv.slice(2)).catch((err) => {
|
|
5
16
|
console.error(`impel: ${err?.stack || err?.message || err}`);
|
package/package.json
CHANGED
package/src/apps.js
CHANGED
|
@@ -207,7 +207,9 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
|
|
|
207
207
|
|
|
208
208
|
// Bump when the written config/manifest schema changes; a mismatch forces the
|
|
209
209
|
// slow open path (and thus a full config rewrite) after a CLI update.
|
|
210
|
-
|
|
210
|
+
// 19: Windows global installs bake the stable %LOCALAPPDATA% entry point into
|
|
211
|
+
// hook/auth/MCP artifacts instead of the npm-prefix bin path.
|
|
212
|
+
export const CURRENT_CONFIG_VERSION = 19;
|
|
211
213
|
|
|
212
214
|
// Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
|
|
213
215
|
// helper rebranding, and signing. A vendored bundle is rebuilt only when this
|
|
@@ -1119,8 +1121,10 @@ function writeChatGPTConfig(
|
|
|
1119
1121
|
const auth = invocations?.auth || { command: paths.tokenHelper, args: null };
|
|
1120
1122
|
// Finder/Dock-launched apps inherit a minimal PATH without npm/nvm bin
|
|
1121
1123
|
// directories, so a PATH-relative "impel" never spawns there. Bake the
|
|
1122
|
-
// absolute node + CLI invocation
|
|
1123
|
-
//
|
|
1124
|
+
// absolute node + CLI invocation (on Windows global installs the stable
|
|
1125
|
+
// %LOCALAPPDATA% entry point, which survives npm's non-atomic package
|
|
1126
|
+
// replacement); the launch-time `app refresh` rewrites this config whenever
|
|
1127
|
+
// those paths change.
|
|
1124
1128
|
const mcpInvocation = impelCliInvocation(
|
|
1125
1129
|
config.tenantId ? ["mcp", "--tenant", config.tenantId] : ["mcp"]
|
|
1126
1130
|
);
|
package/src/commands/apps.js
CHANGED
|
@@ -623,6 +623,23 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
623
623
|
|
|
624
624
|
try {
|
|
625
625
|
if (action === "open") maybePrintUpdateNotice();
|
|
626
|
+
// Background token-helper refreshes pass --stale-only; honor the manifest
|
|
627
|
+
// TTL here like the darwin refresh path does, so every vendor token call
|
|
628
|
+
// does not become a full catalog fetch + profile rewrite (and its child
|
|
629
|
+
// process fan-out) on Windows.
|
|
630
|
+
if (action === "refresh" && flags["stale-only"]) {
|
|
631
|
+
const stored = loadConfig();
|
|
632
|
+
if (stored?.pat) {
|
|
633
|
+
const staleTenantId = flags.tenant
|
|
634
|
+
? normalizeTenantId(flags.tenant)
|
|
635
|
+
: stored.tenantId || null;
|
|
636
|
+
const stalePaths = appPaths(io.homeDir, staleTenantId, {
|
|
637
|
+
claudeUserData: io.claudeUserData(io.environment, staleTenantId),
|
|
638
|
+
tenantName: staleTenantId === stored.tenantId ? stored.tenantName : null,
|
|
639
|
+
});
|
|
640
|
+
if (manifestIsFresh(stalePaths, stored)) return true;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
626
643
|
const config = await io.selectedConfig(targets, flags.tenant || null);
|
|
627
644
|
const catalog = await withProgress("Fetching the tenant model catalog", () => (
|
|
628
645
|
fetchWindowsCatalog(config, io)
|
package/src/commands/converge.js
CHANGED
|
@@ -79,9 +79,22 @@ export async function cmdConverge(argv = [], overrides = {}) {
|
|
|
79
79
|
return false;
|
|
80
80
|
}
|
|
81
81
|
const label = target === "claude" ? "Claude" : "ChatGPT/Codex";
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
)
|
|
82
|
+
// Decline by default after 60s: an unanswered prompt in a forgotten
|
|
83
|
+
// terminal must not park the whole convergence (with its child processes
|
|
84
|
+
// and any background respawns) indefinitely. The prompt says so, and the
|
|
85
|
+
// question is re-asked on the next explicit run. The timer is cleared on
|
|
86
|
+
// answer (not unref'd) so the auto-decline reliably fires even when the
|
|
87
|
+
// prompt is the only thing keeping the event loop alive.
|
|
88
|
+
const allowed = confirmed(await new Promise((resolve) => {
|
|
89
|
+
const timer = setTimeout(() => resolve(""), io.vendorPromptTimeoutMs ?? 60_000);
|
|
90
|
+
const settle = (answer) => {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
resolve(answer);
|
|
93
|
+
};
|
|
94
|
+
Promise.resolve(io.promptText(
|
|
95
|
+
`Allow the verified ${label} vendor ${context.mode === "update" ? "update" : "installation"}? [y/N] (auto-N in 60s) `,
|
|
96
|
+
)).then(settle, () => settle(""));
|
|
97
|
+
}));
|
|
85
98
|
vendorAppDecisions.set(target, allowed);
|
|
86
99
|
return allowed;
|
|
87
100
|
};
|
package/src/commands/nuke.js
CHANGED
|
@@ -16,6 +16,7 @@ import { CONFIG_DIR } from "../config.js";
|
|
|
16
16
|
import { parseFlags } from "../args.js";
|
|
17
17
|
import { promptText } from "../prompt.js";
|
|
18
18
|
import { appProcessPattern } from "../apps.js";
|
|
19
|
+
import { windowsStableEntrypointRoot } from "../selfInvocation.js";
|
|
19
20
|
import { windowsClaudeUserData } from "../windowsApps.js";
|
|
20
21
|
|
|
21
22
|
// Launcher artifacts the CLI may have written into ~/Applications: the
|
|
@@ -121,6 +122,10 @@ function windowsProfileRemnants(appsRoot, environment) {
|
|
|
121
122
|
// Windows vendor install absent; nothing tenant-scoped to remove.
|
|
122
123
|
}
|
|
123
124
|
}
|
|
125
|
+
// The stable entry point (%LOCALAPPDATA%\Impel) that managed vendor
|
|
126
|
+
// artifacts execute; a clean reinstall rewrites it on the next impel run.
|
|
127
|
+
const entrypointRoot = windowsStableEntrypointRoot(environment);
|
|
128
|
+
if (entrypointRoot && fs.existsSync(entrypointRoot)) remnants.push(entrypointRoot);
|
|
124
129
|
return remnants;
|
|
125
130
|
}
|
|
126
131
|
|
|
@@ -182,7 +187,7 @@ export async function cmdNuke(argv = [], overrides = {}) {
|
|
|
182
187
|
if (launchers.length) io.log(` App launchers and staging artifacts: ${launchers.length}`);
|
|
183
188
|
if (libraryRemnants.length) io.log(` macOS caches/preferences/state entries: ${libraryRemnants.length}`);
|
|
184
189
|
if (keychainItems.length) io.log(` Keychain Safe Storage items: ${keychainItems.length}`);
|
|
185
|
-
if (windowsRemnants.length) io.log(` Windows managed
|
|
190
|
+
if (windowsRemnants.length) io.log(` Windows managed profiles and entry point: ${windowsRemnants.length}`);
|
|
186
191
|
if (configDirExists) io.log(` CLI state, profiles, vendor cache, and auth: ${io.configDir}`);
|
|
187
192
|
io.log("Vendor apps and native ~/.claude and ~/.codex profiles are not touched.");
|
|
188
193
|
|
package/src/commands/sessions.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
|
|
3
3
|
import { parseFlags } from "../args.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
clearSessionFlushLock,
|
|
6
|
+
collectSessionHook,
|
|
7
|
+
flushCollectedSession,
|
|
8
|
+
readHookInput,
|
|
9
|
+
sessionFlushLockIsFresh,
|
|
10
|
+
sessionFlushLockPath,
|
|
11
|
+
sessionOutboxStatus,
|
|
12
|
+
touchSessionFlushLock,
|
|
13
|
+
} from "../sessionCollector.js";
|
|
5
14
|
import { loadConfig, redactSecretText } from "../config.js";
|
|
6
15
|
import { impelCliInvocation } from "../selfInvocation.js";
|
|
7
16
|
|
|
@@ -57,11 +66,21 @@ export async function cmdSessions(argv) {
|
|
|
57
66
|
flush: false,
|
|
58
67
|
});
|
|
59
68
|
if (config && (config.tenantId === flags.tenant || process.env.IMPEL_SESSIONS_DEV_ORG_ID)) {
|
|
60
|
-
|
|
69
|
+
// Hooks fire on every session event; only spawn a flush child when no
|
|
70
|
+
// live one is already polling this session's outbox (heartbeat lock).
|
|
71
|
+
const lock = sessionFlushLockPath({
|
|
72
|
+
tenantId: flags.tenant,
|
|
61
73
|
provider: flags.provider,
|
|
62
|
-
|
|
63
|
-
session: String(input.session_id || ""),
|
|
74
|
+
sessionKey: String(input.session_id || ""),
|
|
64
75
|
});
|
|
76
|
+
if (!sessionFlushLockIsFresh(lock)) {
|
|
77
|
+
touchSessionFlushLock(lock);
|
|
78
|
+
startDetachedFlush({
|
|
79
|
+
provider: flags.provider,
|
|
80
|
+
tenant: flags.tenant,
|
|
81
|
+
session: String(input.session_id || ""),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
65
84
|
}
|
|
66
85
|
} catch (error) {
|
|
67
86
|
// Session persistence is observational. A collector outage must never
|
|
@@ -83,16 +102,28 @@ export async function cmdSessions(argv) {
|
|
|
83
102
|
if (!flags.provider || !flags.session || !flags.tenant) return;
|
|
84
103
|
if (!["claude_code", "codex"].includes(flags.provider)) return;
|
|
85
104
|
if (!config || flags.tenant !== config.tenantId && !process.env.IMPEL_SESSIONS_DEV_ORG_ID) return;
|
|
105
|
+
const lock = sessionFlushLockPath({
|
|
106
|
+
tenantId: flags.tenant,
|
|
107
|
+
provider: flags.provider,
|
|
108
|
+
sessionKey: flags.session,
|
|
109
|
+
});
|
|
86
110
|
const deadline = Date.now() + 90_000;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
try {
|
|
112
|
+
while (Date.now() < deadline) {
|
|
113
|
+
// Refresh the heartbeat so hook dispatch keeps skipping extra spawns
|
|
114
|
+
// while this child is alive.
|
|
115
|
+
touchSessionFlushLock(lock);
|
|
116
|
+
const result = await flushCollectedSession({
|
|
117
|
+
tenantId: flags.tenant,
|
|
118
|
+
provider: flags.provider,
|
|
119
|
+
sessionKey: flags.session,
|
|
120
|
+
config,
|
|
121
|
+
});
|
|
122
|
+
if (result.pending === 0) break;
|
|
123
|
+
await new Promise((resolve) => setTimeout(resolve, result.error || result.busy ? 1000 : 250));
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
clearSessionFlushLock(lock);
|
|
96
127
|
}
|
|
97
128
|
return;
|
|
98
129
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `impel update` — update the CLI, then let the freshly installed build
|
|
2
2
|
// reconcile every tenant and managed surface from the live control-plane list.
|
|
3
3
|
|
|
4
|
+
import fs from "node:fs";
|
|
4
5
|
import { spawnSync } from "node:child_process";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
|
|
@@ -20,6 +21,21 @@ import {
|
|
|
20
21
|
|
|
21
22
|
const CLI_BIN = fileURLToPath(new URL("../../bin/impel.js", import.meta.url));
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The version now on disk at the package that owns CLI_BIN, read FRESH (never
|
|
26
|
+
* from this process's cached module graph). After `npm install -g` this is the
|
|
27
|
+
* ground truth for whether the cascade would re-execute new code.
|
|
28
|
+
*/
|
|
29
|
+
export function postInstallCliVersion() {
|
|
30
|
+
try {
|
|
31
|
+
const packagePath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
|
32
|
+
const version = JSON.parse(fs.readFileSync(packagePath, "utf8"))?.version;
|
|
33
|
+
return typeof version === "string" && version.trim() ? version.trim() : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
const HELP = `impel update - update everything Impel in one command
|
|
24
40
|
|
|
25
41
|
Reinstalls impel-cli from npm, then uses the new build to discover every
|
|
@@ -83,7 +99,10 @@ function defaultSelfUpdate(spec) {
|
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
// The cascading steps re-execute the (freshly installed) CLI binary so the
|
|
86
|
-
// NEW code performs them, not the process that started the update.
|
|
102
|
+
// NEW code performs them, not the process that started the update. On
|
|
103
|
+
// Windows this is also what refreshes the stable %LOCALAPPDATA% entry point:
|
|
104
|
+
// bin/impel.js rewrites it at startup, so the shim is only ever updated by a
|
|
105
|
+
// build that the postInstallCliVersion guard below already verified.
|
|
87
106
|
export function defaultRunConvergence({
|
|
88
107
|
skipApps = false,
|
|
89
108
|
skipClis = false,
|
|
@@ -141,6 +160,7 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
141
160
|
platform: process.platform,
|
|
142
161
|
progress: withProgress,
|
|
143
162
|
recoverInstall: runInstallRecovery,
|
|
163
|
+
postInstallVersion: postInstallCliVersion,
|
|
144
164
|
loadConfig,
|
|
145
165
|
...overrides,
|
|
146
166
|
};
|
|
@@ -261,6 +281,21 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
261
281
|
}
|
|
262
282
|
console.log("CLI: recovery verified the update path.");
|
|
263
283
|
}
|
|
284
|
+
// npm exiting 0 is NOT proof the RUNNING install was updated: with multiple
|
|
285
|
+
// Node installs / npm prefixes (common on Windows), the install can land in
|
|
286
|
+
// a different global prefix while CLI_BIN — the path the cascade re-executes
|
|
287
|
+
// — still holds the old build. Cascading then runs old code that believes
|
|
288
|
+
// an update is still pending, which is how unbounded respawn storms start.
|
|
289
|
+
// Probe the version at CLI_BIN fresh from disk and refuse to cascade on skew.
|
|
290
|
+
const postInstall = io.postInstallVersion();
|
|
291
|
+
if (remote && postInstall !== remote) {
|
|
292
|
+
console.error(`impel update: npm reported success but the running CLI still resolves v${postInstall ?? "?"} (expected v${remote}).`);
|
|
293
|
+
console.error(` Running CLI: ${CLI_BIN}`);
|
|
294
|
+
console.error(" This usually means the `impel` on PATH belongs to a different npm prefix than `npm prefix -g`.");
|
|
295
|
+
console.error(" Fix: run `npm prefix -g`, confirm it owns the `impel` shim on PATH, then `npm install --global impel-cli@latest` there.");
|
|
296
|
+
process.exitCode = 1;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
264
299
|
console.log(`CLI: updated${remote ? ` to v${remote}` : ""}.`);
|
|
265
300
|
}
|
|
266
301
|
|
package/src/selfInvocation.js
CHANGED
|
@@ -1,21 +1,71 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { fileURLToPath } from "node:url";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
import { environmentValue } from "./nativeProcess.js";
|
|
5
|
+
|
|
6
|
+
/** The running package's own bin script, used directly for live child spawns. */
|
|
4
7
|
export const IMPEL_CLI_ENTRYPOINT = fileURLToPath(new URL("../bin/impel.js", import.meta.url));
|
|
5
8
|
|
|
6
|
-
|
|
9
|
+
// Matches a real package install (npm/pnpm/volta all place the package under
|
|
10
|
+
// node_modules/impel-cli; an unsupported project-local install matches too,
|
|
11
|
+
// and the shim's run-time re-resolution still heals it toward a global
|
|
12
|
+
// install). A git checkout or worktree never matches, so development runs
|
|
13
|
+
// keep baking their own path instead of repointing the machine-wide stable
|
|
14
|
+
// entry at a checkout.
|
|
15
|
+
const GLOBAL_INSTALL_RE = /[\\/]node_modules[\\/]impel-cli[\\/]bin[\\/]impel\.js$/iu;
|
|
16
|
+
|
|
17
|
+
export function runningFromGlobalInstall(entrypoint = IMPEL_CLI_ENTRYPOINT) {
|
|
18
|
+
return GLOBAL_INSTALL_RE.test(entrypoint);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Where the Windows stable entry point lives: outside every npm prefix. */
|
|
22
|
+
export function windowsStableEntrypointPath(environment = process.env) {
|
|
23
|
+
const localAppData = environmentValue(environment, "LOCALAPPDATA");
|
|
24
|
+
if (typeof localAppData !== "string" || !localAppData.trim()) return null;
|
|
25
|
+
return path.join(localAppData, "Impel", "bin", "impel-entry.cjs");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The directory `impel nuke` removes to erase the stable entry point. */
|
|
29
|
+
export function windowsStableEntrypointRoot(environment = process.env) {
|
|
30
|
+
const entrypoint = windowsStableEntrypointPath(environment);
|
|
31
|
+
return entrypoint ? path.dirname(path.dirname(entrypoint)) : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The entry point baked into managed artifacts that vendor apps execute later
|
|
36
|
+
* (session hooks, Codex auth/MCP commands, Start-menu shortcuts).
|
|
37
|
+
*
|
|
38
|
+
* On Windows, `npm install -g` replaces the global package directory
|
|
39
|
+
* non-atomically, so artifacts that point straight into the npm prefix all
|
|
40
|
+
* break at once mid-install and vendor retry policies fan out fail-fast
|
|
41
|
+
* children (the 2026-07 console-window storm). Global installs therefore bake
|
|
42
|
+
* the stable `impel-entry.cjs` shim, which lives outside the prefix and
|
|
43
|
+
* re-resolves the current install at run time. Everywhere else — macOS/Linux
|
|
44
|
+
* (npm swaps are atomic-enough and the sh token helper already re-resolves)
|
|
45
|
+
* and development checkouts — the running package's own path is used.
|
|
46
|
+
*/
|
|
47
|
+
export function managedCliEntrypoint({
|
|
48
|
+
platform = process.platform,
|
|
49
|
+
environment = process.env,
|
|
50
|
+
entrypoint = IMPEL_CLI_ENTRYPOINT,
|
|
51
|
+
} = {}) {
|
|
52
|
+
if (platform !== "win32" || !runningFromGlobalInstall(entrypoint)) return entrypoint;
|
|
53
|
+
return windowsStableEntrypointPath(environment) || entrypoint;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function impelCliInvocation(args = [], options = {}) {
|
|
7
57
|
return {
|
|
8
58
|
command: process.execPath,
|
|
9
|
-
args: [
|
|
59
|
+
args: [managedCliEntrypoint(options), ...args],
|
|
10
60
|
};
|
|
11
61
|
}
|
|
12
62
|
|
|
13
63
|
export const IMPEL_MANAGED_MCP_ENV = "IMPEL_MANAGED_MCP";
|
|
14
64
|
|
|
15
|
-
export function impelMcpInvocation(args = []) {
|
|
65
|
+
export function impelMcpInvocation(args = [], options = {}) {
|
|
16
66
|
return {
|
|
17
67
|
type: "stdio",
|
|
18
|
-
...impelCliInvocation(["mcp", ...args]),
|
|
68
|
+
...impelCliInvocation(["mcp", ...args], options),
|
|
19
69
|
env: { [IMPEL_MANAGED_MCP_ENV]: "1" },
|
|
20
70
|
};
|
|
21
71
|
}
|
package/src/sessionCollector.js
CHANGED
|
@@ -471,7 +471,9 @@ function captureTranscript(root, sessionMeta, input) {
|
|
|
471
471
|
|
|
472
472
|
function gitValue(cwd, args) {
|
|
473
473
|
try {
|
|
474
|
-
|
|
474
|
+
// windowsHide: this runs inside console-less detached flush children on
|
|
475
|
+
// Windows, where each git.exe would otherwise allocate a visible console.
|
|
476
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 1500, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
|
|
475
477
|
return result.status === 0 ? result.stdout.trim().slice(0, 2048) : "";
|
|
476
478
|
} catch {
|
|
477
479
|
return "";
|
|
@@ -983,6 +985,45 @@ export async function readHookInput(stream = process.stdin) {
|
|
|
983
985
|
return value;
|
|
984
986
|
}
|
|
985
987
|
|
|
988
|
+
/**
|
|
989
|
+
* Heartbeat lock for the detached `sessions flush` child. Hooks fire on every
|
|
990
|
+
* session event; without this, each event stacks another detached child that
|
|
991
|
+
* polls the same outbox for up to 90 s — hundreds of concurrent processes on a
|
|
992
|
+
* busy session. The flush child refreshes the mtime while polling; hook
|
|
993
|
+
* dispatch skips the spawn while the heartbeat is fresh.
|
|
994
|
+
*/
|
|
995
|
+
export function sessionFlushLockPath({ tenantId, provider, sessionKey }) {
|
|
996
|
+
// NOT "flush.lock": acquireLock(root, "flush") owns that exact path (as a
|
|
997
|
+
// directory) for outbox batch mutual exclusion; this heartbeat is a separate,
|
|
998
|
+
// advisory spawn-rate limiter and must never collide with it.
|
|
999
|
+
return path.join(sessionDirectory(tenantId, provider, sessionKey), "flush-heartbeat");
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
export function touchSessionFlushLock(target) {
|
|
1003
|
+
try {
|
|
1004
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
1005
|
+
fs.writeFileSync(target, `${process.pid}\n`, { mode: 0o600 });
|
|
1006
|
+
} catch {
|
|
1007
|
+
// Lock upkeep is best-effort; a missing lock only allows an extra child.
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
export function sessionFlushLockIsFresh(target, maxAgeMs = 15_000, now = Date.now()) {
|
|
1012
|
+
try {
|
|
1013
|
+
return now - fs.statSync(target).mtimeMs < maxAgeMs;
|
|
1014
|
+
} catch {
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
export function clearSessionFlushLock(target) {
|
|
1020
|
+
try {
|
|
1021
|
+
fs.rmSync(target, { force: true });
|
|
1022
|
+
} catch {
|
|
1023
|
+
// Stale locks expire via mtime anyway.
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
986
1027
|
export function sessionOutboxStatus({ tenantId, provider, sessionKey }) {
|
|
987
1028
|
const root = sessionDirectory(tenantId, provider, sessionKey);
|
|
988
1029
|
return {
|
package/src/shellEntries.js
CHANGED
|
@@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
import { appPaths, CLAUDE_CONFIG_ID } from "./apps.js";
|
|
7
7
|
import { redactSecretText } from "./config.js";
|
|
8
8
|
import { environmentValue, nativeCommandInvocation, resolveNativeBinary } from "./nativeProcess.js";
|
|
9
|
-
import {
|
|
9
|
+
import { managedCliEntrypoint } from "./selfInvocation.js";
|
|
10
10
|
import { normalizeTenantId } from "./tenants.js";
|
|
11
11
|
import { windowsClaudeUserData } from "./windowsApps.js";
|
|
12
12
|
|
|
@@ -53,7 +53,7 @@ export function registerWindowsTenantShortcut({
|
|
|
53
53
|
environment = process.env,
|
|
54
54
|
run = spawnSync,
|
|
55
55
|
execPath = process.execPath,
|
|
56
|
-
cliEntrypoint =
|
|
56
|
+
cliEntrypoint = managedCliEntrypoint({ platform: "win32", environment }),
|
|
57
57
|
iconPath = null,
|
|
58
58
|
mkdirSync = fs.mkdirSync,
|
|
59
59
|
}) {
|
package/src/skills.js
CHANGED
|
@@ -212,6 +212,9 @@ export function runSkillCommand(bin, args, env, {
|
|
|
212
212
|
env: environment,
|
|
213
213
|
stdio: ["ignore", "pipe", "pipe"],
|
|
214
214
|
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
215
|
+
// One update run spawns ~150 of these cmd.exe-wrapped plugin commands
|
|
216
|
+
// across tenants; keep them off-screen on Windows.
|
|
217
|
+
windowsHide: true,
|
|
215
218
|
});
|
|
216
219
|
} catch (error) {
|
|
217
220
|
resolve({
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
IMPEL_CLI_ENTRYPOINT,
|
|
6
|
+
runningFromGlobalInstall,
|
|
7
|
+
windowsStableEntrypointPath,
|
|
8
|
+
} from "./selfInvocation.js";
|
|
9
|
+
import { redactSecretText } from "./config.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render the stable Windows entry point (`%LOCALAPPDATA%\Impel\bin\impel-entry.cjs`).
|
|
13
|
+
*
|
|
14
|
+
* Managed vendor artifacts persist `node <this file> <args…>` instead of a
|
|
15
|
+
* path inside the npm global prefix. The shim lives outside every prefix so
|
|
16
|
+
* `npm install -g impel-cli` never deletes it, prefers the baked install it
|
|
17
|
+
* was written for, re-resolves other well-known global locations when that
|
|
18
|
+
* install moved, and turns the non-atomic mid-install window into one bounded
|
|
19
|
+
* slow attempt instead of a fail-fast child per vendor retry.
|
|
20
|
+
*
|
|
21
|
+
* The rendered source is dependency-free CJS and must stay runnable on every
|
|
22
|
+
* supported Node (>=18). Output is deterministic for a given baked path.
|
|
23
|
+
*/
|
|
24
|
+
export function renderStableEntrypoint(cliEntrypoint = IMPEL_CLI_ENTRYPOINT) {
|
|
25
|
+
return `#!/usr/bin/env node
|
|
26
|
+
"use strict";
|
|
27
|
+
// Managed by impel-cli — stable Impel entry point.
|
|
28
|
+
//
|
|
29
|
+
// Vendor apps (Impel Claude, Impel ChatGPT) persist absolute invocations of
|
|
30
|
+
// this file in their managed profiles. It resolves the current impel-cli
|
|
31
|
+
// global install at run time, so replacing or moving the npm global package
|
|
32
|
+
// never strands the invocations baked into those profiles.
|
|
33
|
+
|
|
34
|
+
const fs = require("node:fs");
|
|
35
|
+
const path = require("node:path");
|
|
36
|
+
const { pathToFileURL } = require("node:url");
|
|
37
|
+
const { spawnSync } = require("node:child_process");
|
|
38
|
+
|
|
39
|
+
const BAKED_CLI = ${JSON.stringify(cliEntrypoint)};
|
|
40
|
+
const CLI_SUFFIX = path.join("node_modules", "impel-cli", "bin", "impel.js");
|
|
41
|
+
|
|
42
|
+
function waitBudgetMs() {
|
|
43
|
+
const raw = Number.parseInt(process.env.IMPEL_ENTRYPOINT_WAIT_MS || "", 10);
|
|
44
|
+
if (Number.isFinite(raw) && raw >= 0) return Math.min(raw, 60000);
|
|
45
|
+
return 8000;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function candidateCliPaths() {
|
|
49
|
+
const environment = process.env;
|
|
50
|
+
const prefixes = [
|
|
51
|
+
environment.APPDATA ? path.join(environment.APPDATA, "npm") : null,
|
|
52
|
+
path.dirname(process.execPath),
|
|
53
|
+
environment.NVM_SYMLINK || null,
|
|
54
|
+
];
|
|
55
|
+
const candidates = [BAKED_CLI];
|
|
56
|
+
for (const prefix of prefixes) {
|
|
57
|
+
if (prefix) candidates.push(path.join(prefix, CLI_SUFFIX));
|
|
58
|
+
}
|
|
59
|
+
return Array.from(new Set(candidates));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isFile(candidate) {
|
|
63
|
+
try {
|
|
64
|
+
return fs.statSync(candidate).isFile();
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// "Complete enough to import": npm extracts package files one by one, so an
|
|
71
|
+
// existing bin script may still sit in a half-written tree. Requiring the
|
|
72
|
+
// package manifest and main source root too rejects most torn states.
|
|
73
|
+
function isCompleteInstall(cliPath) {
|
|
74
|
+
try {
|
|
75
|
+
const root = path.dirname(path.dirname(cliPath));
|
|
76
|
+
if (!isFile(cliPath) || !isFile(path.join(root, "src", "cli.js"))) return false;
|
|
77
|
+
return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).name === "impel-cli";
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolveCompleteCli() {
|
|
84
|
+
for (const candidate of candidateCliPaths()) {
|
|
85
|
+
if (isCompleteInstall(candidate)) return candidate;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sleep(ms) {
|
|
91
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function main() {
|
|
95
|
+
// \`npm install -g\` replaces the global package non-atomically; waiting a
|
|
96
|
+
// bounded moment rides out that window instead of failing instantly at
|
|
97
|
+
// whatever cadence the calling vendor app retries.
|
|
98
|
+
const deadline = Date.now() + waitBudgetMs();
|
|
99
|
+
let cli = resolveCompleteCli();
|
|
100
|
+
while (!cli && Date.now() < deadline) {
|
|
101
|
+
await sleep(250);
|
|
102
|
+
cli = resolveCompleteCli();
|
|
103
|
+
}
|
|
104
|
+
// Layout drift in a future package (no src/cli.js) would never satisfy the
|
|
105
|
+
// completeness probe; fall back to any existing bin script at the deadline.
|
|
106
|
+
if (!cli) cli = candidateCliPaths().find(isFile) || null;
|
|
107
|
+
if (!cli) {
|
|
108
|
+
process.stderr.write(
|
|
109
|
+
"impel-entry: no impel-cli install found (checked " + candidateCliPaths().join("; ") + "). " +
|
|
110
|
+
"Run \`npm install -g impel-cli\`, then \`impel setup\`.\\n"
|
|
111
|
+
);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
process.argv[1] = cli;
|
|
115
|
+
try {
|
|
116
|
+
await import(pathToFileURL(cli).href);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// The tree can heal between the completeness probe and the import, but
|
|
119
|
+
// this process's module cache may pin the torn state. A single fresh
|
|
120
|
+
// child re-reads the healed tree; a genuinely broken install fails once
|
|
121
|
+
// more there and its exit code propagates.
|
|
122
|
+
const rerun = spawnSync(process.execPath, [cli].concat(process.argv.slice(2)), {
|
|
123
|
+
stdio: "inherit",
|
|
124
|
+
windowsHide: true,
|
|
125
|
+
});
|
|
126
|
+
if (typeof rerun.status === "number") process.exit(rerun.status);
|
|
127
|
+
process.stderr.write(
|
|
128
|
+
"impel-entry: could not start " + cli + " (" +
|
|
129
|
+
(error && error.message ? error.message : String(error)) + ")\\n"
|
|
130
|
+
);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
main().catch((error) => {
|
|
136
|
+
process.stderr.write(
|
|
137
|
+
"impel-entry: " + (error && error.message ? error.message : String(error)) + "\\n"
|
|
138
|
+
);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
});
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Write the stable entry point if it is missing or stale. Only a real global
|
|
146
|
+
* package install may write it — a checkout or worktree run must never
|
|
147
|
+
* repoint the machine-wide shim at itself — and the caller is expected to be
|
|
148
|
+
* a build that is actually executing, which is what makes its own install
|
|
149
|
+
* path verified-good (`impel update` additionally refuses to cascade into a
|
|
150
|
+
* fresh build until postInstallCliVersion proves the install landed).
|
|
151
|
+
*/
|
|
152
|
+
export function ensureWindowsStableEntrypoint({
|
|
153
|
+
environment = process.env,
|
|
154
|
+
entrypoint = IMPEL_CLI_ENTRYPOINT,
|
|
155
|
+
} = {}) {
|
|
156
|
+
if (!runningFromGlobalInstall(entrypoint)) return null;
|
|
157
|
+
const target = windowsStableEntrypointPath(environment);
|
|
158
|
+
if (!target) return null;
|
|
159
|
+
const content = renderStableEntrypoint(entrypoint);
|
|
160
|
+
try {
|
|
161
|
+
if (fs.readFileSync(target, "utf8") === content) return { path: target, written: false };
|
|
162
|
+
} catch {
|
|
163
|
+
// Missing or unreadable: rewrite below.
|
|
164
|
+
}
|
|
165
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
166
|
+
const temporaryPath = `${target}.tmp-${process.pid}`;
|
|
167
|
+
try {
|
|
168
|
+
fs.writeFileSync(temporaryPath, content, { mode: 0o700 });
|
|
169
|
+
fs.renameSync(temporaryPath, target);
|
|
170
|
+
} finally {
|
|
171
|
+
try {
|
|
172
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
173
|
+
} catch {
|
|
174
|
+
// The successful rename already removed the temporary file.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { path: target, written: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Startup variant: a locked or unwritable shim must not fail the command —
|
|
182
|
+
* the previous shim still resolves the current install at run time — but the
|
|
183
|
+
* operator should see why it could not be refreshed.
|
|
184
|
+
*/
|
|
185
|
+
export function ensureWindowsStableEntrypointQuietly(options = {}) {
|
|
186
|
+
try {
|
|
187
|
+
return ensureWindowsStableEntrypoint(options);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.error(
|
|
190
|
+
`impel: could not refresh the stable Windows entry point (${redactSecretText(error?.message || error)})`
|
|
191
|
+
);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
package/src/updates.js
CHANGED
|
@@ -122,8 +122,15 @@ export function writeUpdateCache(patch) {
|
|
|
122
122
|
return next;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// A failed registry check must still count as "we tried": without stamping the
|
|
126
|
+
// failure, a machine with broken registry access has a permanently stale cache
|
|
127
|
+
// and EVERY TTY command respawns the detached refresh child — which on Windows
|
|
128
|
+
// used to mean one more console window per command, forever.
|
|
129
|
+
const UPDATE_CHECK_FAILURE_BACKOFF_MS = 60 * 60 * 1000;
|
|
130
|
+
|
|
125
131
|
export function cacheIsFresh(cache, now = Date.now()) {
|
|
126
|
-
|
|
132
|
+
if (cache?.checkedAt && now - cache.checkedAt < UPDATE_CHECK_TTL_MS) return true;
|
|
133
|
+
return Boolean(cache?.lastFailedAt && now - cache.lastFailedAt < UPDATE_CHECK_FAILURE_BACKOFF_MS);
|
|
127
134
|
}
|
|
128
135
|
|
|
129
136
|
/** Fetch npm's latest version and record it; returns the updated cache (or null). */
|
|
@@ -131,7 +138,14 @@ export async function refreshUpdateCache(dependencies = {}) {
|
|
|
131
138
|
const fetchLatest = dependencies.fetchRemoteVersion || fetchRemoteVersion;
|
|
132
139
|
const writeCache = dependencies.writeCache || writeUpdateCache;
|
|
133
140
|
const remoteVersion = await fetchLatest();
|
|
134
|
-
if (!remoteVersion)
|
|
141
|
+
if (!remoteVersion) {
|
|
142
|
+
try {
|
|
143
|
+
writeCache({ lastFailedAt: Date.now() });
|
|
144
|
+
} catch {
|
|
145
|
+
// The stamp is a spawn-rate limiter, not required state.
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
135
149
|
return writeCache({ remoteVersion, checkedAt: Date.now() });
|
|
136
150
|
}
|
|
137
151
|
|
|
@@ -178,6 +192,10 @@ function spawnDetached(args) {
|
|
|
178
192
|
const child = spawn(process.execPath, [path.join(CLI_ROOT, "bin", "impel.js"), ...args], {
|
|
179
193
|
detached: true,
|
|
180
194
|
stdio: "ignore",
|
|
195
|
+
// Windows: a detached console-subsystem child gets its OWN console — a
|
|
196
|
+
// visible Command Prompt window per spawn — unless it is hidden. This is
|
|
197
|
+
// the same option startDetachedFlush already passes.
|
|
198
|
+
windowsHide: true,
|
|
181
199
|
});
|
|
182
200
|
child.unref();
|
|
183
201
|
} catch {
|