impel-cli 0.12.1 → 0.13.1

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
@@ -246,6 +246,14 @@ followed by `impel skills sync all` and `impel agents sync all` across every
246
246
  native and isolated CLI profile. Each tenant's app step syncs that tenant's app
247
247
  profiles; the dedicated steps exclude apps, so every profile is synced once.
248
248
 
249
+ Most updates are in place — the desktop apps are **not** closed. A vendored
250
+ bundle is only rebuilt (and therefore only quit) when the bundle-building logic
251
+ or the pinned vendor version actually changes, not on every CLI release, so a
252
+ routine update just rewrites configs, skills, and agents while the apps keep
253
+ running. When a rebuild genuinely is required, `impel app update` closes only
254
+ the app whose bundle it will swap and **reopens it afterward**, so the swap
255
+ lands without you relaunching by hand. Apps you had already closed stay closed.
256
+
249
257
  Update discovery compares the installed package version with npm's public
250
258
  `latest` metadata, cached for 6 hours in
251
259
  `~/.config/impel/update-check.json`. It does not invoke Git or contact GitHub.
@@ -746,13 +754,16 @@ that have never been installed. The older global `Impel Claude.app` /
746
754
  `Impel ChatGPT.app` launcher is removed after the first successful
747
755
  tenant-specific rebuild.
748
756
 
749
- `impel app update` compares the installed bundle's CLI version, pinned vendor
750
- version, identity, profile paths, and ASAR compatibility hash before touching
751
- it. A current bundle is not closed, cloned, patched, or codesigned; its config
752
- and model catalog are still refreshed. If an interrupted update left a machine
753
- in a bad state, `impel app update --force` removes the selected launcher and
754
- performs a clean rebuild while sweeping abandoned `.tmp-*` and `.previous-*`
755
- staging directories.
757
+ `impel app update` compares the installed bundle's build fingerprint, signing
758
+ mode, pinned vendor version, identity, profile paths, and ASAR compatibility
759
+ hash before touching it. The build fingerprint tracks the bundle-BUILDING logic
760
+ (asar patches, plist rewrites, signing), not the CLI version, so a CLI release
761
+ that doesn't change bundle bytes leaves installed bundles and their macOS
762
+ permission grants alone. A current bundle is not closed, cloned, patched, or
763
+ codesigned; its config and model catalog are still refreshed. If an interrupted
764
+ update left a machine in a bad state, `impel app update --force` removes the
765
+ selected launcher and performs a clean rebuild while sweeping abandoned
766
+ `.tmp-*` and `.previous-*` staging directories.
756
767
 
757
768
  Skills plugin subprocesses run asynchronously, so the TTY progress indicator
758
769
  continues animating while Claude or Codex refreshes a marketplace. Skill status
@@ -776,7 +787,7 @@ For ChatGPT/Codex, the gateway controls which models and capabilities are
776
787
  enabled while the installer merges those entries with the vendor app's native
777
788
  catalog. The Impel copy relaxes the vendor renderer's ChatGPT-account-only Fast
778
789
  gate because gateway auth supplies the entitlement instead. The outer wrapper
779
- is then ad-hoc signed locally without replacing the nested OpenAI signing
790
+ is then signed locally (see below) without replacing the nested OpenAI signing
780
791
  identity. This preserves model-specific instructions and exposes
781
792
  the same reasoning-effort and service-tier controls as the normal app, including
782
793
  Ultra where supported and Fast only on models that advertise the `priority`
@@ -786,10 +797,28 @@ Impel PAT. Config refreshes replace only the Impel-managed gateway block; plugin
786
797
  desktop preferences, projects, and other settings written by the isolated app
787
798
  are kept.
788
799
 
789
- Impel Claude and the outer Impel ChatGPT wrapper are ad-hoc signed locally after
790
- their bundle identities and launch wrappers are installed. The nested ChatGPT
791
- bundle retains OpenAI's signature. The Claude 3P config necessarily contains
792
- the PAT because that app accepts a
800
+ Impel Claude and the outer Impel ChatGPT wrapper are signed locally after their
801
+ bundle identities and launch wrappers are installed. The nested ChatGPT bundle
802
+ retains OpenAI's signature.
803
+
804
+ By default the CLI signs each rebuild with a **stable per-machine identity** — a
805
+ self-signed certificate created once in a dedicated keychain under
806
+ `~/.config/impel/codesign` (no Apple Developer account is involved). The
807
+ certificate is trusted locally for the code-signing policy only; it is not a
808
+ TLS or document-signing authority. macOS ties
809
+ every privacy (TCC) grant — microphone, screen recording, folder access,
810
+ Automation, accessibility — to the app's code-signing designated requirement. A
811
+ stable identity keeps that requirement constant across rebuilds, so the grants
812
+ you approve survive `impel app update` the way they do for a normal app instead
813
+ of re-prompting after every rebuild. The one-time switch from the old ad-hoc
814
+ signature to the stable identity triggers a single final round of prompts. If
815
+ the local signing toolchain is unavailable the CLI logs a notice and falls back
816
+ to ad-hoc signing (the previous behavior). Two environment overrides are
817
+ available: `IMPEL_CODESIGN_ADHOC=1` forces ad-hoc signing, and
818
+ `IMPEL_CODESIGN_IDENTITY=<name>` signs with a specific existing identity (e.g. a
819
+ real `Developer ID Application: …`).
820
+
821
+ The Claude 3P config necessarily contains the PAT because that app accepts a
793
822
  gateway API key rather than a token-helper command. It is stored in the same
794
823
  owner-only Impel config tree as the primary credential. ChatGPT/Codex uses a
795
824
  command-backed helper and does not copy the PAT into TOML. The installable app
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "Configure Claude Code and Codex CLI to talk to Impel's gateway, authenticated by an Impel Personal Access Token",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from "./codexSecurity.js";
12
12
  import { normalizeTenantId } from "./tenants.js";
13
13
  import { crossAppModelsEnabled, redactSecretText } from "./config.js";
14
+ import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
14
15
 
15
16
  export const CLAUDE_CONFIG_ID = "1ced0000-0000-4000-8000-000000000001";
16
17
  const CHATGPT_CONFIG_START = "# >>> impel app managed gateway >>>";
@@ -144,6 +145,15 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
144
145
  // slow open path (and thus a full config rewrite) after a CLI update.
145
146
  export const CURRENT_CONFIG_VERSION = 10;
146
147
 
148
+ // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
149
+ // helper rebranding, and signing. A vendored bundle is rebuilt only when this
150
+ // changes, NOT on every CLI release, so routine CLI updates (bug fixes, new
151
+ // commands, config tweaks) no longer quit and re-sign the running desktop apps
152
+ // — which is what made every `impel update` re-trigger macOS permission
153
+ // prompts. Bump this ONLY when a code change alters the bytes of a built
154
+ // bundle; leave it alone for changes that don't touch bundle contents.
155
+ export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-16.2";
156
+
147
157
  /** Parse the tenant's install manifest, or null when absent/corrupt. */
148
158
  export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
149
159
  try {
@@ -169,17 +179,21 @@ export function readBundleCompatibility(launcher) {
169
179
  }
170
180
 
171
181
  /**
172
- * A vendored bundle needs no rebuild when it was built by this CLI version
173
- * from the vendor app version currently on disk. Rebuilds are the expensive
174
- * part of install/open (bundle clone + asar patch + deep codesign), so this
175
- * check is what makes repeat opens fast.
182
+ * A vendored bundle needs no rebuild when it was built by the SAME bundle
183
+ * logic (BUNDLE_BUILD_FINGERPRINT) and signing mode, from the vendor app
184
+ * version currently on disk. Keying on the bundle fingerprint rather than the
185
+ * CLI version is deliberate: rebuilds are the expensive part of install/open
186
+ * (bundle clone + asar patch + deep codesign) AND they force the running apps
187
+ * closed, so a routine CLI update that doesn't change bundle bytes must leave
188
+ * the installed bundles — and their macOS permission grants — untouched.
176
189
  */
177
- export function bundleIsCurrent(status) {
190
+ export function bundleIsCurrent(status, { signingMode = desiredSigningMode() } = {}) {
178
191
  if (!status.launcherInstalled || !status.vendorPath || !status.vendorVersion) return false;
179
192
  const compatibility = readBundleCompatibility(status.launcher);
180
193
  return Boolean(
181
194
  compatibility
182
- && compatibility.cliVersion === CLI_VERSION
195
+ && compatibility.bundleFingerprint === BUNDLE_BUILD_FINGERPRINT
196
+ && compatibility.signingMode === signingMode
183
197
  && compatibility.vendorVersion === status.vendorVersion
184
198
  && compatibility.bundleIdentifier === status.bundleIdentifier
185
199
  && compatibility.displayName === status.label
@@ -251,7 +265,10 @@ const QUIT_POLL_MS = 500;
251
265
  /**
252
266
  * Closes every running app whose bundle the given targets will replace:
253
267
  * graceful AppleScript quit first, SIGTERM if it lingers, and a hard error
254
- * telling the user to close it themselves if even that fails.
268
+ * telling the user to close it themselves if even that fails. Returns the app
269
+ * names that were actually running and got closed, so callers can reopen the
270
+ * managed launchers afterward (an update the user did not ask to also relaunch
271
+ * leaves already-closed apps closed).
255
272
  */
256
273
  export async function quitBlockingApps(targets, {
257
274
  tenantId = null,
@@ -280,16 +297,28 @@ export async function quitBlockingApps(targets, {
280
297
  return !isRunning(name);
281
298
  }
282
299
 
300
+ const closed = [];
283
301
  for (const name of blockingAppNames(targets, { tenantId, tenantName })) {
284
302
  if (!isRunning(name)) continue;
285
303
  log(`${name} is running; closing it before touching app bundles…`);
286
304
  requestQuit(name);
287
- if (await goneWithin(name, quitWaitMs)) continue;
305
+ if (await goneWithin(name, quitWaitMs)) { closed.push(name); continue; }
288
306
  log(`${name} did not quit gracefully; terminating it…`);
289
307
  forceQuit(name);
290
- if (await goneWithin(name, killWaitMs)) continue;
308
+ if (await goneWithin(name, killWaitMs)) { closed.push(name); continue; }
291
309
  throw new Error(`${name} is still running; close it manually, then re-run`);
292
310
  }
311
+ return closed;
312
+ }
313
+
314
+ /**
315
+ * The managed (Impel-branded) launcher name for a target, as it appears in
316
+ * blockingAppNames / quitBlockingApps output. Used to decide which just-closed
317
+ * apps to reopen after an update — only the managed launchers, never a user's
318
+ * separate vendor Claude/ChatGPT.
319
+ */
320
+ export function managedLauncherName(target, { tenantId = null, tenantName = null } = {}) {
321
+ return managedAppIdentity(target, tenantId, tenantName).launcherName.replace(/\.app$/u, "");
293
322
  }
294
323
 
295
324
  export function appPaths(homeDir = os.homedir(), tenantId = null, {
@@ -1016,8 +1045,10 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
1016
1045
 
1017
1046
  const macos = path.join(staging, "Contents", "MacOS");
1018
1047
  writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
1019
- schemaVersion: 2,
1048
+ schemaVersion: 3,
1020
1049
  cliVersion: CLI_VERSION,
1050
+ bundleFingerprint: BUNDLE_BUILD_FINGERPRINT,
1051
+ signingMode: desiredSigningMode(),
1021
1052
  vendorVersion: bundleVersion(vendorPath),
1022
1053
  sourceBundle: vendorPath,
1023
1054
  bundleIdentifier: paths.claude.bundleIdentifier,
@@ -1100,8 +1131,10 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
1100
1131
  0o644,
1101
1132
  );
1102
1133
  writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
1103
- schemaVersion: 2,
1134
+ schemaVersion: 3,
1104
1135
  cliVersion: CLI_VERSION,
1136
+ bundleFingerprint: BUNDLE_BUILD_FINGERPRINT,
1137
+ signingMode: desiredSigningMode(),
1105
1138
  vendorVersion: bundleVersion(vendorPath),
1106
1139
  sourceBundle: vendorPath,
1107
1140
  bundleIdentifier: paths.chatgpt.bundleIdentifier,
@@ -1559,35 +1592,43 @@ if (process.type === "browser" && displayName) {
1559
1592
  `;
1560
1593
  }
1561
1594
 
1562
- function signVendoredApp(bundle, vendorExecutable, label) {
1595
+ function signVendoredApp(bundle, vendorExecutable, label, identity = null) {
1563
1596
  if (process.platform !== "darwin" || !isMachO(vendorExecutable)) return;
1597
+ // Resolve (and, on first use, create) the signing identity only once we know
1598
+ // we will actually sign — this keeps the guard above side-effect-free.
1599
+ identity ||= resolveSigningIdentity();
1564
1600
  spawnSync("/usr/bin/xattr", ["-dr", "com.apple.quarantine", bundle], { stdio: "ignore" });
1565
- let signed = codesign(vendoredAppSigningArgs(bundle, label));
1601
+ let signed = codesign(vendoredAppSigningArgs(bundle, label, identity));
1566
1602
  if (signed.status !== 0) throw new Error(`failed to sign vendored Impel ${label} app: ${signed.stderr.trim()}`);
1567
1603
  if (label === "Claude") {
1568
- signed = signVendoredClaudeCapabilities(bundle);
1604
+ signed = signVendoredClaudeCapabilities(bundle, identity);
1569
1605
  if (signed.status !== 0) throw new Error(`failed to restore vendored Impel Claude capabilities: ${signed.stderr.trim()}`);
1570
1606
  }
1571
1607
  const verified = spawnSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", bundle], { encoding: "utf8" });
1572
1608
  if (verified.status !== 0) throw new Error(`vendored Impel ${label} signature verification failed: ${verified.stderr.trim()}`);
1573
1609
  }
1574
1610
 
1575
- export function vendoredAppSigningArgs(bundle, label) {
1611
+ export function vendoredAppSigningArgs(bundle, label, identity = ADHOC_IDENTITY) {
1612
+ // A stable local signing identity keeps the bundle's code-signing designated
1613
+ // requirement constant across rebuilds, so macOS retains the user's TCC
1614
+ // permission grants after every `impel app update` (see src/codesign.js).
1615
+ //
1576
1616
  // Browser control authenticates the ChatGPT process by OpenAI's code-signing
1577
- // identity. Deep ad-hoc signing replaces that identity on every nested code
1578
- // object, including Contents/MacOS/ChatGPT, so sign only the modified outer
1579
- // bundle and preserve the vendor signatures within it. Claude deliberately
1580
- // retains its separate deep-signing and entitlement-restoration flow below.
1617
+ // identity. Deep signing replaces that identity on every nested code object,
1618
+ // including Contents/MacOS/ChatGPT, so sign only the modified outer bundle
1619
+ // and preserve the vendor signatures within it. Claude deliberately retains
1620
+ // its separate deep-signing and entitlement-restoration flow below.
1581
1621
  return label === "ChatGPT"
1582
- ? ["--force", "--sign", "-", bundle]
1583
- : ["--force", "--deep", "--sign", "-", bundle];
1622
+ ? ["--force", ...codesignIdentityArgs(identity), bundle]
1623
+ : ["--force", "--deep", ...codesignIdentityArgs(identity), bundle];
1584
1624
  }
1585
1625
 
1586
- function signVendoredClaudeCapabilities(bundle) {
1626
+ function signVendoredClaudeCapabilities(bundle, identity = ADHOC_IDENTITY) {
1587
1627
  const fullEntitlements = entitlementPlist({
1588
1628
  "com.apple.security.cs.allow-jit": true,
1589
- // Ad-hoc signatures have no common Apple Team ID, so hardened runtime
1590
- // otherwise refuses to map the app's separately signed Electron framework.
1629
+ // A local self-signed (or ad-hoc) signature shares no Apple Team ID with
1630
+ // Anthropic's separately signed Electron framework, so hardened runtime
1631
+ // otherwise refuses to map it. Library validation stays disabled for both.
1591
1632
  "com.apple.security.cs.disable-library-validation": true,
1592
1633
  "com.apple.security.device.audio-input": true,
1593
1634
  "com.apple.security.device.bluetooth": true,
@@ -1618,13 +1659,15 @@ function signVendoredClaudeCapabilities(bundle) {
1618
1659
  for (const helper of helpers) {
1619
1660
  const entitlements = helper.name.endsWith(" Helper.app") ? fullPath : jitPath;
1620
1661
  const result = codesign([
1621
- "--force", "--deep", "--options", "runtime", "--entitlements", entitlements, "--sign", "-",
1662
+ "--force", "--deep", "--options", "runtime", "--entitlements", entitlements,
1663
+ ...codesignIdentityArgs(identity),
1622
1664
  path.join(frameworks, helper.name),
1623
1665
  ]);
1624
1666
  if (result.status !== 0) return result;
1625
1667
  }
1626
1668
  return codesign([
1627
- "--force", "--options", "runtime", "--entitlements", fullPath, "--sign", "-", bundle,
1669
+ "--force", "--options", "runtime", "--entitlements", fullPath,
1670
+ ...codesignIdentityArgs(identity), bundle,
1628
1671
  ]);
1629
1672
  } finally {
1630
1673
  fs.rmSync(fullPath, { force: true });
@@ -0,0 +1,297 @@
1
+ // Stable local code-signing identity for the managed macOS app bundles.
2
+ //
3
+ // macOS ties every TCC privacy grant (microphone, screen recording, folder
4
+ // access, Automation/AppleScript, accessibility, …) to the app's code-signing
5
+ // "designated requirement". An ad-hoc signature (`codesign --sign -`) has no
6
+ // stable identity: its requirement degenerates to the binary's own cdhash,
7
+ // which changes on every rebuild. So each time `impel app update` re-signed a
8
+ // rebuilt bundle, macOS saw a brand-new app and re-prompted for every
9
+ // permission the user had already granted.
10
+ //
11
+ // Signing rebuilds with a stable per-machine self-signed certificate instead
12
+ // gives the bundle a constant designated requirement, so TCC grants survive
13
+ // updates the way they do for a normal app. The certificate lives in a
14
+ // dedicated keychain under the Impel config dir; no Apple Developer account is
15
+ // involved (these bundles are patched and built on the user's own machine, so
16
+ // Developer ID signing is impossible anyway).
17
+ //
18
+ // Everything here is best-effort: if any step fails — no `security`/`openssl`,
19
+ // a locked keychain, an unusual environment — we fall back to ad-hoc signing,
20
+ // which is exactly today's behavior. Updates never fail because signing could
21
+ // not be upgraded.
22
+
23
+ import fs from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import crypto from "node:crypto";
27
+ import { spawnSync } from "node:child_process";
28
+
29
+ import { CONFIG_DIR } from "./config.js";
30
+
31
+ export const SIGNING_IDENTITY_NAME = "Impel Local Codesigning";
32
+
33
+ // Ad-hoc: what every managed bundle was signed with before stable identities.
34
+ export const ADHOC_IDENTITY = Object.freeze({
35
+ mode: "adhoc",
36
+ signArg: "-",
37
+ keychainArgs: Object.freeze([]),
38
+ descriptor: "adhoc",
39
+ });
40
+
41
+ /** `codesign` arguments that select the given identity (and its keychain). */
42
+ export function codesignIdentityArgs(identity = ADHOC_IDENTITY) {
43
+ return [...identity.keychainArgs, "--sign", identity.signArg];
44
+ }
45
+
46
+ /**
47
+ * The coarse signing mode the current environment WANTS, without creating or
48
+ * touching any certificate. `bundleIsCurrent` compares this against the mode
49
+ * recorded when a bundle was built, so a change in signing preference (or an
50
+ * old ad-hoc bundle that predates stable signing) forces exactly one rebuild.
51
+ * Kept cheap on purpose: resolving the concrete local identity would create a
52
+ * keychain, which must not happen during a read-only currency check.
53
+ */
54
+ export function desiredSigningMode(env = process.env, platform = process.platform) {
55
+ if (platform !== "darwin") return "adhoc";
56
+ if (truthyEnv(env.IMPEL_CODESIGN_ADHOC)) return "adhoc";
57
+ if (env.IMPEL_CODESIGN_IDENTITY) return `external:${env.IMPEL_CODESIGN_IDENTITY}`;
58
+ return "local";
59
+ }
60
+
61
+ function truthyEnv(value) {
62
+ return value === "1" || value === "true" || value === "yes";
63
+ }
64
+
65
+ function defaultRun(command, args, { input } = {}) {
66
+ return spawnSync(command, args, { encoding: "utf8", input });
67
+ }
68
+
69
+ function escapeRegExp(value) {
70
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
71
+ }
72
+
73
+ // find-identity prints lines like ` 1) 0A1B…40hex "Impel Local Codesigning"`.
74
+ function parseIdentitySha(output, name) {
75
+ const pattern = new RegExp(`\\)\\s+([0-9A-Fa-f]{40})\\s+"${escapeRegExp(name)}"`, "u");
76
+ const match = String(output || "").match(pattern);
77
+ return match ? match[1] : null;
78
+ }
79
+
80
+ let cachedIdentity = null;
81
+
82
+ /**
83
+ * Resolve the signing identity to use for managed bundles, creating the stable
84
+ * local certificate on first use. Cached per process. Returns an identity
85
+ * object whose `signArg`/`keychainArgs` feed straight into `codesign`.
86
+ *
87
+ * Env overrides:
88
+ * IMPEL_CODESIGN_ADHOC=1 force ad-hoc signing (the pre-1.0 behavior)
89
+ * IMPEL_CODESIGN_IDENTITY=<id> sign with a specific existing identity
90
+ * (e.g. a real "Developer ID Application: …")
91
+ */
92
+ export function resolveSigningIdentity(deps = {}) {
93
+ const {
94
+ env = process.env,
95
+ platform = process.platform,
96
+ run = defaultRun,
97
+ configDir = CONFIG_DIR,
98
+ fsImpl = fs,
99
+ warn = (message) => console.warn(message),
100
+ randomBytes = crypto.randomBytes,
101
+ cache = true,
102
+ } = deps;
103
+
104
+ if (cache && cachedIdentity) return cachedIdentity;
105
+ if (platform !== "darwin") return ADHOC_IDENTITY;
106
+ if (truthyEnv(env.IMPEL_CODESIGN_ADHOC)) return ADHOC_IDENTITY;
107
+
108
+ if (env.IMPEL_CODESIGN_IDENTITY) {
109
+ const identity = {
110
+ mode: "external",
111
+ signArg: env.IMPEL_CODESIGN_IDENTITY,
112
+ keychainArgs: [],
113
+ descriptor: `external:${env.IMPEL_CODESIGN_IDENTITY}`,
114
+ };
115
+ if (cache) cachedIdentity = identity;
116
+ return identity;
117
+ }
118
+
119
+ try {
120
+ const identity = ensureLocalSigningIdentity({ run, configDir, fsImpl, randomBytes });
121
+ if (cache) cachedIdentity = identity;
122
+ return identity;
123
+ } catch (error) {
124
+ warn(`impel: falling back to ad-hoc app signing (${error?.message || error}); permission prompts may repeat after updates.`);
125
+ if (cache) cachedIdentity = ADHOC_IDENTITY;
126
+ return ADHOC_IDENTITY;
127
+ }
128
+ }
129
+
130
+ /** For tests: drop the per-process memo so env overrides take effect again. */
131
+ export function resetSigningIdentityCache() {
132
+ cachedIdentity = null;
133
+ }
134
+
135
+ function ensureLocalSigningIdentity({ run, configDir, fsImpl, randomBytes }) {
136
+ const dir = path.join(configDir, "codesign");
137
+ const keychain = path.join(dir, "impel-codesign.keychain-db");
138
+ const passwordFile = path.join(dir, "keychain.pass");
139
+ fsImpl.mkdirSync(dir, { recursive: true, mode: 0o700 });
140
+
141
+ // Reuse an already-created identity: the whole point is that the cert (and
142
+ // therefore the designated requirement) stays stable across updates.
143
+ if (fsImpl.existsSync(keychain) && fsImpl.existsSync(passwordFile)) {
144
+ const password = fsImpl.readFileSync(passwordFile, "utf8").trim();
145
+ unlockKeychain(run, keychain, password);
146
+ ensureKeychainInSearchList(run, keychain);
147
+ const existing = findLocalIdentitySha(run, keychain);
148
+ if (existing) return localIdentity(existing, keychain);
149
+
150
+ // v0.13.0 imported a self-signed certificate but never established code-
151
+ // signing trust for it. `security find-identity -v` therefore reported
152
+ // zero valid identities and every rebuild fell back to ad-hoc signing.
153
+ // Repair that exact state in place so the already-generated identity stays
154
+ // stable instead of accumulating a second certificate with the same name.
155
+ trustExistingLocalCertificate({ run, keychain, fsImpl });
156
+ const repaired = findLocalIdentitySha(run, keychain);
157
+ if (repaired) return localIdentity(repaired, keychain);
158
+ throw new Error("existing signing identity could not be trusted");
159
+ }
160
+
161
+ const password = randomBytes(24).toString("hex");
162
+ createLocalSigningIdentity({ run, dir, keychain, password, fsImpl });
163
+ fsImpl.writeFileSync(passwordFile, `${password}\n`, { mode: 0o600 });
164
+
165
+ ensureKeychainInSearchList(run, keychain);
166
+ const sha = findLocalIdentitySha(run, keychain);
167
+ if (!sha) throw new Error("created signing identity was not found in its keychain");
168
+ return localIdentity(sha, keychain);
169
+ }
170
+
171
+ function localIdentity(sha, keychain) {
172
+ return {
173
+ mode: "local",
174
+ signArg: sha,
175
+ keychainArgs: ["--keychain", keychain],
176
+ descriptor: `local:${sha}`,
177
+ };
178
+ }
179
+
180
+ function findLocalIdentitySha(run, keychain) {
181
+ const result = run("/usr/bin/security", ["find-identity", "-v", "-p", "codesigning", keychain]);
182
+ if (result.status !== 0) return null;
183
+ return parseIdentitySha(result.stdout, SIGNING_IDENTITY_NAME);
184
+ }
185
+
186
+ function createLocalSigningIdentity({ run, dir, keychain, password, fsImpl }) {
187
+ const work = fsImpl.mkdtempSync(path.join(os.tmpdir(), "impel-codesign-"));
188
+ const keyPath = path.join(work, "key.pem");
189
+ const certPath = path.join(work, "cert.pem");
190
+ const p12Path = path.join(work, "identity.p12");
191
+ const p12Pass = crypto.randomBytes(18).toString("hex");
192
+ try {
193
+ // A self-signed leaf certificate with the codeSigning EKU. It is never
194
+ // trusted for distribution; it only needs to exist so codesign can bind a
195
+ // stable identity into the signature. Locally built, de-quarantined apps
196
+ // launch regardless of certificate trust (Gatekeeper only gates quarantined
197
+ // downloads), exactly as the ad-hoc bundles do today.
198
+ runOrThrow(run, "/usr/bin/openssl", [
199
+ "req", "-x509", "-newkey", "rsa:2048",
200
+ "-keyout", keyPath, "-out", certPath,
201
+ "-days", "3650", "-nodes",
202
+ "-subj", `/CN=${SIGNING_IDENTITY_NAME}`,
203
+ "-addext", "basicConstraints=critical,CA:false",
204
+ "-addext", "keyUsage=critical,digitalSignature",
205
+ "-addext", "extendedKeyUsage=critical,codeSigning",
206
+ ], "generate signing certificate");
207
+ runOrThrow(run, "/usr/bin/openssl", [
208
+ "pkcs12", "-export", "-out", p12Path,
209
+ "-inkey", keyPath, "-in", certPath,
210
+ "-passout", `pass:${p12Pass}`,
211
+ "-name", SIGNING_IDENTITY_NAME,
212
+ ], "package signing certificate");
213
+
214
+ if (!fsImpl.existsSync(keychain)) {
215
+ runOrThrow(run, "/usr/bin/security", ["create-keychain", "-p", password, keychain], "create signing keychain");
216
+ }
217
+ // No auto-lock: codesign must reach the private key non-interactively.
218
+ run("/usr/bin/security", ["set-keychain-settings", keychain]);
219
+ unlockKeychain(run, keychain, password);
220
+ runOrThrow(run, "/usr/bin/security", [
221
+ "import", p12Path, "-k", keychain, "-P", p12Pass, "-T", "/usr/bin/codesign",
222
+ ], "import signing identity");
223
+ // Grant codesign non-interactive access to the key so signing never pops a
224
+ // "codesign wants to use a key" GUI prompt. We own the keychain password.
225
+ runOrThrow(run, "/usr/bin/security", [
226
+ "set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", password, keychain,
227
+ ], "authorize codesign for the signing key");
228
+ trustLocalCertificate(run, keychain, certPath);
229
+ } finally {
230
+ fsImpl.rmSync(work, { recursive: true, force: true });
231
+ }
232
+ }
233
+
234
+ function unlockKeychain(run, keychain, password) {
235
+ runOrThrow(
236
+ run,
237
+ "/usr/bin/security",
238
+ ["unlock-keychain", "-p", password, keychain],
239
+ "unlock signing keychain",
240
+ );
241
+ }
242
+
243
+ function trustExistingLocalCertificate({ run, keychain, fsImpl }) {
244
+ const exported = run("/usr/bin/security", [
245
+ "find-certificate", "-p", "-c", SIGNING_IDENTITY_NAME, keychain,
246
+ ]);
247
+ if (exported.status !== 0 || exported.error || !String(exported.stdout || "").includes("BEGIN CERTIFICATE")) {
248
+ throw new Error("existing signing certificate was not found in its keychain");
249
+ }
250
+ const certPath = path.join(os.tmpdir(), `impel-codesign-cert-${process.pid}.pem`);
251
+ fsImpl.writeFileSync(certPath, exported.stdout, { mode: 0o600 });
252
+ try {
253
+ trustLocalCertificate(run, keychain, certPath);
254
+ } finally {
255
+ fsImpl.rmSync(certPath, { force: true });
256
+ }
257
+ }
258
+
259
+ function trustLocalCertificate(run, keychain, certPath) {
260
+ // Limit trust to the code-signing policy. This is a local build identity,
261
+ // not a TLS/document-signing CA, and it remains isolated in Impel's own
262
+ // keychain rather than the user's login keychain.
263
+ runOrThrow(run, "/usr/bin/security", [
264
+ "add-trusted-cert", "-r", "trustRoot", "-p", "codeSign", "-k", keychain, certPath,
265
+ ], "trust local code-signing certificate");
266
+ }
267
+
268
+ // codesign resolves an identity by searching the user's keychain search list,
269
+ // so the dedicated keychain has to be on it. Append without disturbing the
270
+ // user's existing keychains (a bare `-s <one>` would replace the whole list).
271
+ function ensureKeychainInSearchList(run, keychain) {
272
+ const listed = run("/usr/bin/security", ["list-keychains", "-d", "user"]);
273
+ if (listed.status !== 0 || listed.error) {
274
+ const detail = listed.error?.message || String(listed.stderr || "").trim() || `exit ${listed.status}`;
275
+ throw new Error(`read user keychain search list failed: ${detail}`);
276
+ }
277
+ const current = String(listed.stdout || "")
278
+ .split("\n")
279
+ .map((line) => line.trim().replace(/^"|"$/gu, ""))
280
+ .filter(Boolean);
281
+ if (current.includes(keychain)) return;
282
+ runOrThrow(
283
+ run,
284
+ "/usr/bin/security",
285
+ ["list-keychains", "-d", "user", "-s", ...current, keychain],
286
+ "add Impel signing keychain to the user search list",
287
+ );
288
+ }
289
+
290
+ function runOrThrow(run, command, args, description) {
291
+ const result = run(command, args);
292
+ if (result.status !== 0 || result.error) {
293
+ const detail = result.error?.message || String(result.stderr || "").trim() || `exit ${result.status}`;
294
+ throw new Error(`${description} failed: ${detail}`);
295
+ }
296
+ return result;
297
+ }
@@ -31,6 +31,7 @@ import {
31
31
  ensureVendorApp,
32
32
  fetchGatewayModels,
33
33
  installManagedAppFiles,
34
+ managedLauncherName,
34
35
  normalizeAppTarget,
35
36
  quitBlockingApps,
36
37
  readTenantManifest,
@@ -548,6 +549,7 @@ export async function cmdApps(argv, overrides = {}) {
548
549
  syncAgents: syncAgentProfilesSafe,
549
550
  secureCodexHome: secureManagedCodexHome,
550
551
  removeLauncher: (launcher) => fs.rmSync(launcher, { recursive: true, force: true }),
552
+ openLauncher: openManagedLauncher,
551
553
  log: (message) => console.log(message),
552
554
  ...overrides,
553
555
  };
@@ -589,13 +591,22 @@ export async function cmdApps(argv, overrides = {}) {
589
591
  );
590
592
  }
591
593
 
592
- // A running vendor or Impel app only needs to close when its bundle will be swapped.
594
+ // A running vendor or Impel app only needs to close when its bundle will be
595
+ // swapped. Remember which managed launchers we closed so the update can
596
+ // reopen them afterward and feel in-place (the fast path never quits at all).
597
+ let closedApps = [];
593
598
  if (staleBundleTargets.length > 0) {
594
- await io.quitApps(staleBundleTargets, {
599
+ const quitResult = await io.quitApps(staleBundleTargets, {
595
600
  tenantId: config.tenantId,
596
601
  tenantName: config.tenantName,
597
602
  });
603
+ if (Array.isArray(quitResult)) closedApps = quitResult;
598
604
  }
605
+ const reopenTargets = new Set(
606
+ staleBundleTargets.filter((target) => closedApps.includes(
607
+ managedLauncherName(target, { tenantId: config.tenantId, tenantName: config.tenantName }),
608
+ )),
609
+ );
599
610
  if (force) {
600
611
  for (const status of statuses) io.removeLauncher(status.launcher);
601
612
  }
@@ -638,6 +649,15 @@ export async function cmdApps(argv, overrides = {}) {
638
649
  tenantId: config.tenantId,
639
650
  }));
640
651
  }
652
+
653
+ // Reopen the managed apps we had to close for the bundle swap, so the update
654
+ // lands in place from the user's point of view. Apps that were already
655
+ // closed (not in reopenTargets) stay closed.
656
+ for (const item of installed) {
657
+ if (!reopenTargets.has(item.target)) continue;
658
+ io.log(`Reopening ${item.launcher} after the update…`);
659
+ io.openLauncher(item.launcher);
660
+ }
641
661
  }
642
662
 
643
663
  /**
@@ -165,6 +165,7 @@ export async function cmdUpdate(argv, overrides = {}) {
165
165
  runAgentsSync: defaultRunAgentsSync,
166
166
  appsInstalled: anyAppInstalled,
167
167
  platform: process.platform,
168
+ progress: withProgress,
168
169
  ...overrides,
169
170
  };
170
171
  const { flags } = parseFlags(argv, {
@@ -185,7 +186,7 @@ export async function cmdUpdate(argv, overrides = {}) {
185
186
  }
186
187
 
187
188
  const current = io.installedVersion();
188
- const remote = await withProgress("Checking npm for impel-cli updates", () => io.fetchRemoteVersion());
189
+ const remote = await io.progress("Checking npm for impel-cli updates", () => io.fetchRemoteVersion());
189
190
  if (remote) io.writeCache({ remoteVersion: remote, checkedAt: Date.now() });
190
191
 
191
192
  console.log(`impel-cli v${current ?? "?"}`);
@@ -204,7 +205,7 @@ export async function cmdUpdate(argv, overrides = {}) {
204
205
  console.log("CLI: already up to date.");
205
206
  } else {
206
207
  console.log("CLI: installing the latest build…");
207
- if (!await withProgress("Installing the latest impel-cli build", () => io.selfUpdate(updateInstallSpec()))) {
208
+ if (!await io.progress("Installing the latest impel-cli build", () => io.selfUpdate(updateInstallSpec()))) {
208
209
  console.error("impel update: `npm install -g` failed; the CLI was not updated.");
209
210
  if (io.platform === "win32") {
210
211
  console.error(" Verify `npm --version` in PowerShell, then retry `impel update`.");
@@ -228,20 +229,24 @@ export async function cmdUpdate(argv, overrides = {}) {
228
229
  console.log(io.platform === "win32"
229
230
  ? "Apps: updating every installed tenant's signed Claude and ChatGPT profiles…"
230
231
  : "Apps: refreshing every installed tenant and rebuilding only stale app bundles…");
231
- if (!await withProgress("Updating managed desktop apps", () => io.runAppsUpdate())) {
232
+ // These child commands inherit the terminal and render their own progress
233
+ // and log lines. Wrapping them in another spinner makes both processes
234
+ // write the same terminal row, producing glued output such as
235
+ // "Updating managed desktop apps (...)Updating all managed...".
236
+ if (!await io.runAppsUpdate()) {
232
237
  console.error("impel update: the app update failed; re-run `impel app update` after fixing the issue.");
233
238
  cascadeFailed = true;
234
239
  }
235
240
  }
236
241
 
237
242
  console.log("Skills: syncing native and isolated CLI profiles…");
238
- if (!await withProgress("Syncing skills across managed profiles", () => io.runSkillsSync())) {
243
+ if (!await io.runSkillsSync()) {
239
244
  console.error("impel update: skill sync failed; re-run `impel skills sync` after fixing the issue.");
240
245
  cascadeFailed = true;
241
246
  }
242
247
 
243
248
  console.log("Agents: syncing the selected tenant into native and isolated CLI profiles…");
244
- if (!await withProgress("Syncing agents across managed profiles", () => io.runAgentsSync())) {
249
+ if (!await io.runAgentsSync()) {
245
250
  console.error("impel update: agent sync failed; re-run `impel agents sync` after fixing the issue.");
246
251
  cascadeFailed = true;
247
252
  }