impel-cli 0.18.0 → 0.18.2

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 CHANGED
@@ -22,6 +22,11 @@ npm install --global impel-cli
22
22
 
23
23
  Node.js 18 or newer is required.
24
24
 
25
+ On macOS, setup and update verify Apple's Xcode Command Line Tools before any
26
+ profile, app, or skills work begins. If they are missing, Impel opens the
27
+ system installer with `xcode-select --install` and stops safely; finish that
28
+ installation, then rerun the same Impel command.
29
+
25
30
  Then run setup and paste an Impel Personal Access Token when prompted:
26
31
 
27
32
  ```sh
@@ -35,17 +40,19 @@ surfaces for each tenant. You do not need to repeat setup tenant by tenant.
35
40
 
36
41
  `impel setup`:
37
42
 
38
- 1. Stores the PAT in the private Impel config file.
39
- 2. Fetches the complete live tenant list.
40
- 3. Preserves the current CLI tenant when it is still accessible, or selects the
43
+ 1. On macOS, verifies the Xcode Command Line Tools and opens Apple's installer
44
+ before doing any other setup work when they are missing.
45
+ 2. Stores the PAT in the private Impel config file.
46
+ 3. Fetches the complete live tenant list.
47
+ 4. Preserves the current CLI tenant when it is still accessible, or selects the
41
48
  control-plane default.
42
- 4. Creates isolated Claude and Codex CLI profiles for every supported tenant.
43
- 5. On Windows machines without git, installs a pinned, checksum-verified
49
+ 5. Creates isolated Claude and Codex CLI profiles for every supported tenant.
50
+ 6. On Windows machines without git, installs a pinned, checksum-verified
44
51
  MinGit into `~/.config/impel/tools/git` (no admin rights, no system PATH
45
52
  change) so Claude and Codex can install the Impel skills marketplace.
46
- 6. Installs or repairs supported tenant desktop apps on macOS and Windows.
47
- 7. Registers tenant apps with Finder/Spotlight or Windows Start/Search.
48
- 8. Verifies every supported tenant surface and prints a tenant-sorted summary.
53
+ 7. Installs or repairs supported tenant desktop apps on macOS and Windows.
54
+ 8. Registers tenant apps with Finder/Spotlight or Windows Start/Search.
55
+ 9. Verifies every supported tenant surface and prints a tenant-sorted summary.
49
56
 
50
57
  Use `--tenant` only to choose the default for CLI launches. It does not limit
51
58
  which tenants setup prepares:
@@ -117,13 +124,15 @@ impel update
117
124
 
118
125
  Update:
119
126
 
120
- 1. Checks and installs the latest `impel-cli` package from npm.
121
- 2. Continues under the freshly installed build.
122
- 3. Fetches the current live tenant list.
123
- 4. Fully prepares tenants added since the previous run.
124
- 5. Repairs and upgrades existing tenants in place.
125
- 6. Re-registers operating-system launch entries.
126
- 7. Reports local tenants that are no longer accessible without deleting them.
127
+ 1. On macOS, verifies the Xcode Command Line Tools and opens Apple's installer
128
+ before doing any other update work when they are missing.
129
+ 2. Checks and installs the latest `impel-cli` package from npm.
130
+ 3. Continues under the freshly installed build.
131
+ 4. Fetches the current live tenant list.
132
+ 5. Fully prepares tenants added since the previous run.
133
+ 6. Repairs and upgrades existing tenants in place.
134
+ 7. Re-registers operating-system launch entries.
135
+ 8. Reports local tenants that are no longer accessible without deleting them.
127
136
 
128
137
  An update preserves histories, browser state, preferences, permissions, stable
129
138
  app identity, and user-authored settings outside Impel-managed blocks. Desktop
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
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
@@ -211,7 +211,9 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
211
211
  // hook/auth/MCP artifacts instead of the npm-prefix bin path.
212
212
  // 20: managed Claude sandbox disabled by default; the rewrite strips the old
213
213
  // strict sandbox keys from already-provisioned profiles.
214
- export const CURRENT_CONFIG_VERSION = 20;
214
+ // 21: Windows Codex hooks use the pinned client's PowerShell execution
215
+ // contract instead of cmd.exe quoting that failed every managed hook.
216
+ export const CURRENT_CONFIG_VERSION = 21;
215
217
 
216
218
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
217
219
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -724,7 +726,12 @@ export function installManagedAppFiles({
724
726
  if (resolvedVendorPaths[target] && isVendableVendorApp(target, resolvedVendorPaths[target])) {
725
727
  assertPinnedVendorAppVersion(target, resolvedVendorPaths[target]);
726
728
  }
727
- if (target === "chatgpt") writeVendoredChatGPTBundle(paths, resolvedVendorPaths.chatgpt, config.gatewayUrl);
729
+ if (target === "chatgpt") writeVendoredChatGPTBundle(
730
+ paths,
731
+ resolvedVendorPaths.chatgpt,
732
+ config.gatewayUrl,
733
+ homeDir,
734
+ );
728
735
  else writeVendoredClaudeBundle(
729
736
  paths,
730
737
  resolvedVendorPaths.claude,
@@ -1559,7 +1566,7 @@ function claudeUsageCompatibilityEnabled(gatewayUrl) {
1559
1566
  }
1560
1567
  }
1561
1568
 
1562
- function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1569
+ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl, homeDir) {
1563
1570
  if (!vendorPath) throw new Error("ChatGPT/Codex vendor app is unavailable");
1564
1571
  const definition = APP_DEFINITIONS.chatgpt;
1565
1572
  const executableName = definition.executableNames.find((name) => (
@@ -1598,7 +1605,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1598
1605
  const asarHash = asarHeaderHash(runtimeAsarPath);
1599
1606
  const vendorExecutable = path.join(vendorBundle, "Contents", "MacOS", executableName);
1600
1607
  const signingIdentity = resolveVendoredAppSigningIdentity(vendorExecutable);
1601
- const launchHost = ensureChatGPTLaunchHost();
1608
+ const launchHost = ensureChatGPTLaunchHost({ cacheDir: chatGPTLaunchHostCacheDir(homeDir) });
1602
1609
  writeAtomic(
1603
1610
  path.join(staging, "Contents", "Info.plist"),
1604
1611
  vendoredChatGPTWrapperPlist(vendorPath, asarHash, paths.chatgpt, launchHost ? "launch-host" : "launch"),
@@ -2081,7 +2088,7 @@ export function ensureChatGPTLaunchHost({
2081
2088
  platform = process.platform,
2082
2089
  osRelease = os.release(),
2083
2090
  compiler = "/usr/bin/cc",
2084
- cacheDir = fileURLToPath(new URL("../cache", import.meta.url)),
2091
+ cacheDir = chatGPTLaunchHostCacheDir(),
2085
2092
  warn = console.warn,
2086
2093
  } = {}) {
2087
2094
  if (platform !== "darwin") return null;
@@ -2125,6 +2132,10 @@ export function ensureChatGPTLaunchHost({
2125
2132
  }
2126
2133
  }
2127
2134
 
2135
+ export function chatGPTLaunchHostCacheDir(homeDir = os.homedir()) {
2136
+ return path.join(homeDir, ".config", "impel", "cache", "chatgpt-launch-host");
2137
+ }
2138
+
2128
2139
  function vendoredChatGPTWrapperPlist(vendorPath, asarHash, identity, executableName = "launch") {
2129
2140
  return `<?xml version="1.0" encoding="UTF-8"?>
2130
2141
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
package/src/cli.js CHANGED
@@ -20,6 +20,7 @@ import { cmdUpdate } from "./commands/update.js";
20
20
  import { cmdNuke } from "./commands/nuke.js";
21
21
  import { cmdExperimental } from "./commands/experimental.js";
22
22
  import { cmdConverge } from "./commands/converge.js";
23
+ import { ensureMacDeveloperTools } from "./macDeveloperTools.js";
23
24
  import { refuseElevatedMacExecution } from "./privileges.js";
24
25
 
25
26
  const HELP = `impel — isolated Impel workspaces for every tenant
@@ -82,6 +83,7 @@ function normalizeTarget(token) {
82
83
 
83
84
  export async function main(argv) {
84
85
  if (refuseElevatedMacExecution(argv)) return;
86
+ if (!ensureMacDeveloperTools(argv)) return;
85
87
  const [cmd, ...rest] = argv;
86
88
 
87
89
  switch (cmd) {
@@ -0,0 +1,65 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ const XCODE_SELECT = "/usr/bin/xcode-select";
4
+
5
+ function hasHelpFlag(argv) {
6
+ return argv[0] === "help" || argv.slice(1).some((arg) => ["--help", "-h"].includes(arg));
7
+ }
8
+
9
+ /**
10
+ * Commands that may clone a skills marketplace or build a managed macOS app.
11
+ * Read-only commands deliberately stay available while Apple's installer is
12
+ * pending, and `update --check` keeps its documented no-mutation contract.
13
+ */
14
+ export function commandRequiresMacDeveloperTools(argv = []) {
15
+ const [command, subcommand] = argv;
16
+ if (!command || hasHelpFlag(argv)) return false;
17
+
18
+ if (["setup", "_converge"].includes(command)) return true;
19
+ if (["update", "upgrade"].includes(command)) {
20
+ return !argv.includes("--check") && !argv.includes("--refresh-cache");
21
+ }
22
+ if (["skill", "skills"].includes(command)) return !subcommand || subcommand === "sync";
23
+ if (["app", "apps"].includes(command)) {
24
+ return ["install", "update", "open", "refresh"].includes(subcommand);
25
+ }
26
+ return false;
27
+ }
28
+
29
+ /**
30
+ * Ensure Apple's Command Line Tools are available before Impel mutates any
31
+ * lifecycle state that depends on git or the native compiler. The Apple
32
+ * installer runs asynchronously, so a newly requested install must stop this
33
+ * invocation and let the operator rerun it after installation finishes.
34
+ */
35
+ export function ensureMacDeveloperTools(argv = [], dependencies = {}) {
36
+ const io = {
37
+ platform: process.platform,
38
+ spawn: spawnSync,
39
+ log: (message) => console.log(message),
40
+ error: (message) => console.error(message),
41
+ setExitCode: (code) => {
42
+ process.exitCode = code;
43
+ },
44
+ ...dependencies,
45
+ };
46
+ if (io.platform !== "darwin" || !commandRequiresMacDeveloperTools(argv)) return true;
47
+
48
+ const selected = io.spawn(XCODE_SELECT, ["-p"], { stdio: "ignore" });
49
+ if (!selected.error && selected.status === 0) return true;
50
+
51
+ io.log("Xcode Command Line Tools are required; opening Apple's installer…");
52
+ const requested = io.spawn(XCODE_SELECT, ["--install"], { stdio: "inherit" });
53
+ if (requested.error) {
54
+ io.error(`impel: could not start the Xcode Command Line Tools installer (${requested.error.message || requested.error}).`);
55
+ io.error(" Run `xcode-select --install`, finish the system installation, then rerun this Impel command.");
56
+ } else if (requested.status !== 0) {
57
+ io.error("impel: the Xcode Command Line Tools installer did not start successfully.");
58
+ io.error(" Run `xcode-select --install`, finish the system installation, then rerun this Impel command.");
59
+ } else {
60
+ io.error("impel: finish installing the Xcode Command Line Tools in the system dialog, then rerun this command.");
61
+ io.error(" No Impel profiles, apps, or skills were changed.");
62
+ }
63
+ io.setExitCode(1);
64
+ return false;
65
+ }
@@ -141,10 +141,10 @@ function shellQuote(value) {
141
141
  return `'${String(value).replaceAll("'", `'"'"'`)}'`;
142
142
  }
143
143
 
144
- function windowsCommandQuote(value) {
144
+ function powershellQuote(value) {
145
145
  const text = String(value);
146
- if (/[\r\n\0%]/u.test(text)) throw new Error("cannot safely write this Windows hook command");
147
- return `"${text.replaceAll('"', '""')}"`;
146
+ if (/[\r\n\0]/u.test(text)) throw new Error("cannot safely write this Windows hook command");
147
+ return `'${text.replaceAll("'", "''")}'`;
148
148
  }
149
149
 
150
150
  function canonicalize(value) {
@@ -209,10 +209,11 @@ export function ensureCodexSessionHooks(codexHome, tenantId, surface = "codex_cl
209
209
  const document = readJsonObject(hooksPath);
210
210
  const invocation = managedInvocation("codex", surface, tenantId);
211
211
  const unixCommand = [invocation.command, ...invocation.args].map(shellQuote).join(" ");
212
- const windowsTokens = [invocation.command, ...invocation.args].map(windowsCommandQuote).join(" ");
213
- // Codex executes commandWindows with `cmd.exe /C`. The outer quote preserves
214
- // a quoted executable path when it contains spaces.
215
- const windowsCommand = `"${windowsTokens}"`;
212
+ const windowsTokens = [invocation.command, ...invocation.args].map(powershellQuote).join(" ");
213
+ // Codex runs commandWindows through the session's normal Windows shell,
214
+ // which is PowerShell. A quoted executable is only invoked when prefixed by
215
+ // the call operator; cmd.exe's doubled outer-quote form is invalid here.
216
+ const windowsCommand = `& ${windowsTokens}`;
216
217
  const handler = {
217
218
  type: "command",
218
219
  command: unixCommand,