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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.17.9",
3
+ "version": "0.17.11",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
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
- export const CURRENT_CONFIG_VERSION = 18;
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; the launch-time `app refresh` rewrites
1123
- // this config whenever those paths change.
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
  );
@@ -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)
@@ -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
- const allowed = confirmed(await io.promptText(
83
- `Allow the verified ${label} vendor ${context.mode === "update" ? "update" : "installation"}? [y/N] `,
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
  };
@@ -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 Claude profiles: ${windowsRemnants.length}`);
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
 
@@ -1,7 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
2
 
3
3
  import { parseFlags } from "../args.js";
4
- import { collectSessionHook, flushCollectedSession, readHookInput, sessionOutboxStatus } from "../sessionCollector.js";
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
- startDetachedFlush({
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
- tenant: flags.tenant,
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
- while (Date.now() < deadline) {
88
- const result = await flushCollectedSession({
89
- tenantId: flags.tenant,
90
- provider: flags.provider,
91
- sessionKey: flags.session,
92
- config,
93
- });
94
- if (result.pending === 0) break;
95
- await new Promise((resolve) => setTimeout(resolve, result.error || result.busy ? 1000 : 250));
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
  }
@@ -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
 
@@ -1,21 +1,71 @@
1
+ import path from "node:path";
1
2
  import { fileURLToPath } from "node:url";
2
3
 
3
- /** Stable entry point used by managed subprocess configs, including on Windows. */
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
- export function impelCliInvocation(args = []) {
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: [IMPEL_CLI_ENTRYPOINT, ...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
  }
@@ -471,7 +471,9 @@ function captureTranscript(root, sessionMeta, input) {
471
471
 
472
472
  function gitValue(cwd, args) {
473
473
  try {
474
- const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 1500, stdio: ["ignore", "pipe", "ignore"] });
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 {
@@ -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 { IMPEL_CLI_ENTRYPOINT } from "./selfInvocation.js";
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 = IMPEL_CLI_ENTRYPOINT,
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
- return Boolean(cache?.checkedAt && now - cache.checkedAt < UPDATE_CHECK_TTL_MS);
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) return null;
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 {