impel-cli 0.17.10 → 0.17.12

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.10",
3
+ "version": "0.17.12",
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
  );
@@ -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
 
@@ -6,6 +6,7 @@ import {
6
6
  ensureTenantSelection,
7
7
  PAT_SCOPE_TASKS,
8
8
  PRODUCT_ACCESS_GATEWAY,
9
+ PRODUCT_ACCESS_IDENTITY,
9
10
  PRODUCT_ACCESS_WORKSPACE,
10
11
  } from "../tenants.js";
11
12
 
@@ -85,6 +86,9 @@ async function requestJson({ flags, path, query, method = "GET", body }) {
85
86
  if (selected.productAccess === PRODUCT_ACCESS_GATEWAY) {
86
87
  fail("impel tasks: Gateway members do not have workspace task access.");
87
88
  }
89
+ if (selected.productAccess === PRODUCT_ACCESS_IDENTITY) {
90
+ fail("impel tasks: Identity members do not have workspace task access.");
91
+ }
88
92
  if (selected.productAccess !== PRODUCT_ACCESS_WORKSPACE) {
89
93
  fail("impel tasks: the control plane did not return a live Workspace entitlement; retry after it is upgraded.");
90
94
  }
@@ -99,7 +99,10 @@ function defaultSelfUpdate(spec) {
99
99
  }
100
100
 
101
101
  // The cascading steps re-execute the (freshly installed) CLI binary so the
102
- // 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.
103
106
  export function defaultRunConvergence({
104
107
  skipApps = false,
105
108
  skipClis = false,
@@ -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
  }
@@ -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
  }) {
@@ -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/tenants.js CHANGED
@@ -7,11 +7,19 @@ import {
7
7
 
8
8
  export const TENANT_CREDENTIAL_PREFIX = "impel_tenant_";
9
9
  export const PRODUCT_ACCESS_WORKSPACE = "workspace";
10
+ export const PRODUCT_ACCESS_IDENTITY = "identity";
10
11
  export const PRODUCT_ACCESS_GATEWAY = "gateway";
11
12
  export const PAT_SCOPE_CLAUDE = "claude-code-gateway";
12
13
  export const PAT_SCOPE_CODEX = "codex-gateway";
13
14
  export const PAT_SCOPE_TASKS = "tasks";
14
- const PRODUCT_ACCESS_VALUES = new Set([PRODUCT_ACCESS_WORKSPACE, PRODUCT_ACCESS_GATEWAY]);
15
+ // Additive entitlement hierarchy: Gateway < Identity + Gateway < Workspace.
16
+ // Identity and Gateway members share the same gateway scopes and neither can
17
+ // authorize workspace tasks; only Workspace members do.
18
+ const PRODUCT_ACCESS_VALUES = new Set([
19
+ PRODUCT_ACCESS_WORKSPACE,
20
+ PRODUCT_ACCESS_IDENTITY,
21
+ PRODUCT_ACCESS_GATEWAY,
22
+ ]);
15
23
  const PROVIDER_SCOPE = Object.freeze({ claude: PAT_SCOPE_CLAUDE, codex: PAT_SCOPE_CODEX });
16
24
  const TENANT_ID_RE = /^[A-Za-z0-9_.-]{1,128}$/u;
17
25
  const PAT_SCOPE_RE = /^[a-z0-9][a-z0-9:._-]{0,63}$/u;
@@ -27,6 +35,7 @@ export function normalizeProductAccess(value, { allowMissing = false } = {}) {
27
35
 
28
36
  export function productAccessLabel(value) {
29
37
  if (value === PRODUCT_ACCESS_WORKSPACE) return "Workspace member";
38
+ if (value === PRODUCT_ACCESS_IDENTITY) return "Identity + Gateway member";
30
39
  if (value === PRODUCT_ACCESS_GATEWAY) return "Gateway member";
31
40
  return "Unknown";
32
41
  }