impel-cli 0.12.0 → 0.13.0

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
@@ -153,7 +153,7 @@ impel agents sync [claude|codex|all] Sync explicit tenant agents into na
153
153
  impel status Launcher readiness + native mode + gateway reachability
154
154
  impel app install [target] [--tenant <org>] Install isolated apps for one tenant
155
155
  impel app update [target] [--tenant <org>] [--force]
156
- Refresh configs/models and rebuild only stale bundles;
156
+ `all` without --tenant updates every installed tenant;
157
157
  --force performs a clean full rebuild
158
158
  impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
159
159
  (--stale-only: no-op unless 6h+ since last sync)
@@ -167,7 +167,9 @@ impel help Show help
167
167
  impel --version Show version
168
168
 
169
169
  For app commands, `target` is `claude`, `chatgpt`/`codex`, or `all` (the
170
- default). `--tenant` defaults to the currently selected organization.
170
+ default). `impel app update all` without `--tenant` updates every locally
171
+ installed tenant. Other app commands, a specific update target, or an explicit
172
+ `--tenant` operate on one organization and default to the current selection.
171
173
 
172
174
  # `impel setup` no longer takes a per-tool target and never flips native
173
175
  # configs; flip a native tool explicitly with `impel use gateway [claude|codex]`.
@@ -239,10 +241,18 @@ before running another protected command.
239
241
  One command brings everything current, in dependency order: the CLI itself
240
242
  (`npm install -g impel-cli@latest` from npm, with no GitHub credentials),
241
243
  then — re-executing the freshly installed build — `impel app update all`
242
- (install the CLI-pinned vendor builds and rebuild only stale vendored Impel apps)
244
+ (update every locally installed tenant and rebuild only stale vendored Impel apps)
243
245
  followed by `impel skills sync all` and `impel agents sync all` across every
244
- managed profile. The app cascade skips its own skills/agents pass so those
245
- profiles are synced once, in the dedicated bounded-concurrency steps.
246
+ native and isolated CLI profile. Each tenant's app step syncs that tenant's app
247
+ profiles; the dedicated steps exclude apps, so every profile is synced once.
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.
246
256
 
247
257
  Update discovery compares the installed package version with npm's public
248
258
  `latest` metadata, cached for 6 hours in
@@ -737,17 +747,27 @@ impel app open all --tenant acme
737
747
  impel app open all --tenant globex
738
748
  ```
739
749
 
740
- Updates and uninstalls touch only the named tenant variant. The older global
741
- `Impel Claude.app` / `Impel ChatGPT.app` launcher is removed after the first
742
- successful tenant-specific rebuild.
743
-
744
- `impel app update` compares the installed bundle's CLI version, pinned vendor
745
- version, identity, profile paths, and ASAR compatibility hash before touching
746
- it. A current bundle is not closed, cloned, patched, or codesigned; its config
747
- and model catalog are still refreshed. If an interrupted update left a machine
748
- in a bad state, `impel app update --force` removes the selected launcher and
749
- performs a clean rebuild while sweeping abandoned `.tmp-*` and `.previous-*`
750
- staging directories.
750
+ Updates with `--tenant` and all uninstalls touch only the named tenant variant.
751
+ `impel app update all` without `--tenant` discovers every locally installed
752
+ tenant and updates each sequentially; it does not install variants for tenants
753
+ that have never been installed. The older global `Impel Claude.app` /
754
+ `Impel ChatGPT.app` launcher is removed after the first successful
755
+ tenant-specific rebuild.
756
+
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.
767
+
768
+ Skills plugin subprocesses run asynchronously, so the TTY progress indicator
769
+ continues animating while Claude or Codex refreshes a marketplace. Skill status
770
+ lines temporarily clear and redraw the spinner instead of overwriting it.
751
771
 
752
772
  The official Claude and ChatGPT apps remain separate installations and keep
753
773
  their normal accounts, updater, profiles, and bundle identities. Each CLI
@@ -767,7 +787,7 @@ For ChatGPT/Codex, the gateway controls which models and capabilities are
767
787
  enabled while the installer merges those entries with the vendor app's native
768
788
  catalog. The Impel copy relaxes the vendor renderer's ChatGPT-account-only Fast
769
789
  gate because gateway auth supplies the entitlement instead. The outer wrapper
770
- 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
771
791
  identity. This preserves model-specific instructions and exposes
772
792
  the same reasoning-effort and service-tier controls as the normal app, including
773
793
  Ultra where supported and Fast only on models that advertise the `priority`
@@ -777,10 +797,26 @@ Impel PAT. Config refreshes replace only the Impel-managed gateway block; plugin
777
797
  desktop preferences, projects, and other settings written by the isolated app
778
798
  are kept.
779
799
 
780
- Impel Claude and the outer Impel ChatGPT wrapper are ad-hoc signed locally after
781
- their bundle identities and launch wrappers are installed. The nested ChatGPT
782
- bundle retains OpenAI's signature. The Claude 3P config necessarily contains
783
- 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). macOS ties
807
+ every privacy (TCC) grant — microphone, screen recording, folder access,
808
+ Automation, accessibility — to the app's code-signing designated requirement. A
809
+ stable identity keeps that requirement constant across rebuilds, so the grants
810
+ you approve survive `impel app update` the way they do for a normal app instead
811
+ of re-prompting after every rebuild. The one-time switch from the old ad-hoc
812
+ signature to the stable identity triggers a single final round of prompts. If
813
+ the local signing toolchain is unavailable the CLI logs a notice and falls back
814
+ to ad-hoc signing (the previous behavior). Two environment overrides are
815
+ available: `IMPEL_CODESIGN_ADHOC=1` forces ad-hoc signing, and
816
+ `IMPEL_CODESIGN_IDENTITY=<name>` signs with a specific existing identity (e.g. a
817
+ real `Developer ID Application: …`).
818
+
819
+ The Claude 3P config necessarily contains the PAT because that app accepts a
784
820
  gateway API key rather than a token-helper command. It is stored in the same
785
821
  owner-only Impel config tree as the primary credential. ChatGPT/Codex uses a
786
822
  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.0",
3
+ "version": "0.13.0",
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.1";
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 });
package/src/cli.js CHANGED
@@ -49,8 +49,8 @@ Manage:
49
49
  Desktop apps (macOS and Windows):
50
50
  target is claude, chatgpt/codex, or all (default: all)
51
51
  impel app install [target] [--tenant <org>] Install that tenant's isolated app/profile
52
- impel app update [target] [--tenant <org>] Update profiles; rebuild only stale bundles
53
- (--force performs a clean full rebuild)
52
+ impel app update [target] [--tenant <org>] Update profiles; all without --tenant updates
53
+ every installed tenant (--force: clean rebuild)
54
54
  impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
55
55
  impel app status [target] [--tenant <org>] Show isolated app and vendor versions
56
56
  impel app uninstall [target] [--tenant <org>] [--keep-data]
@@ -0,0 +1,246 @@
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
+ const existing = findLocalIdentitySha(run, keychain);
147
+ if (existing) return localIdentity(existing, keychain);
148
+ }
149
+
150
+ const password = randomBytes(24).toString("hex");
151
+ createLocalSigningIdentity({ run, dir, keychain, password, fsImpl });
152
+ fsImpl.writeFileSync(passwordFile, `${password}\n`, { mode: 0o600 });
153
+
154
+ const sha = findLocalIdentitySha(run, keychain);
155
+ if (!sha) throw new Error("created signing identity was not found in its keychain");
156
+ ensureKeychainInSearchList(run, keychain);
157
+ return localIdentity(sha, keychain);
158
+ }
159
+
160
+ function localIdentity(sha, keychain) {
161
+ return {
162
+ mode: "local",
163
+ signArg: sha,
164
+ keychainArgs: ["--keychain", keychain],
165
+ descriptor: `local:${sha}`,
166
+ };
167
+ }
168
+
169
+ function findLocalIdentitySha(run, keychain) {
170
+ const result = run("/usr/bin/security", ["find-identity", "-v", "-p", "codesigning", keychain]);
171
+ if (result.status !== 0) return null;
172
+ return parseIdentitySha(result.stdout, SIGNING_IDENTITY_NAME);
173
+ }
174
+
175
+ function createLocalSigningIdentity({ run, dir, keychain, password, fsImpl }) {
176
+ const work = fsImpl.mkdtempSync(path.join(os.tmpdir(), "impel-codesign-"));
177
+ const keyPath = path.join(work, "key.pem");
178
+ const certPath = path.join(work, "cert.pem");
179
+ const p12Path = path.join(work, "identity.p12");
180
+ const p12Pass = crypto.randomBytes(18).toString("hex");
181
+ try {
182
+ // A self-signed leaf certificate with the codeSigning EKU. It is never
183
+ // trusted for distribution; it only needs to exist so codesign can bind a
184
+ // stable identity into the signature. Locally built, de-quarantined apps
185
+ // launch regardless of certificate trust (Gatekeeper only gates quarantined
186
+ // downloads), exactly as the ad-hoc bundles do today.
187
+ runOrThrow(run, "/usr/bin/openssl", [
188
+ "req", "-x509", "-newkey", "rsa:2048",
189
+ "-keyout", keyPath, "-out", certPath,
190
+ "-days", "3650", "-nodes",
191
+ "-subj", `/CN=${SIGNING_IDENTITY_NAME}`,
192
+ "-addext", "basicConstraints=critical,CA:false",
193
+ "-addext", "keyUsage=critical,digitalSignature",
194
+ "-addext", "extendedKeyUsage=critical,codeSigning",
195
+ ], "generate signing certificate");
196
+ runOrThrow(run, "/usr/bin/openssl", [
197
+ "pkcs12", "-export", "-out", p12Path,
198
+ "-inkey", keyPath, "-in", certPath,
199
+ "-passout", `pass:${p12Pass}`,
200
+ "-name", SIGNING_IDENTITY_NAME,
201
+ ], "package signing certificate");
202
+
203
+ if (!fsImpl.existsSync(keychain)) {
204
+ runOrThrow(run, "/usr/bin/security", ["create-keychain", "-p", password, keychain], "create signing keychain");
205
+ }
206
+ // No auto-lock: codesign must reach the private key non-interactively.
207
+ run("/usr/bin/security", ["set-keychain-settings", keychain]);
208
+ unlockKeychain(run, keychain, password);
209
+ runOrThrow(run, "/usr/bin/security", [
210
+ "import", p12Path, "-k", keychain, "-P", p12Pass, "-T", "/usr/bin/codesign",
211
+ ], "import signing identity");
212
+ // Grant codesign non-interactive access to the key so signing never pops a
213
+ // "codesign wants to use a key" GUI prompt. We own the keychain password.
214
+ runOrThrow(run, "/usr/bin/security", [
215
+ "set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", password, keychain,
216
+ ], "authorize codesign for the signing key");
217
+ } finally {
218
+ fsImpl.rmSync(work, { recursive: true, force: true });
219
+ }
220
+ }
221
+
222
+ function unlockKeychain(run, keychain, password) {
223
+ run("/usr/bin/security", ["unlock-keychain", "-p", password, keychain]);
224
+ }
225
+
226
+ // codesign resolves an identity by searching the user's keychain search list,
227
+ // so the dedicated keychain has to be on it. Append without disturbing the
228
+ // user's existing keychains (a bare `-s <one>` would replace the whole list).
229
+ function ensureKeychainInSearchList(run, keychain) {
230
+ const listed = run("/usr/bin/security", ["list-keychains", "-d", "user"]);
231
+ const current = String(listed.stdout || "")
232
+ .split("\n")
233
+ .map((line) => line.trim().replace(/^"|"$/gu, ""))
234
+ .filter(Boolean);
235
+ if (current.includes(keychain)) return;
236
+ run("/usr/bin/security", ["list-keychains", "-d", "user", "-s", ...current, keychain]);
237
+ }
238
+
239
+ function runOrThrow(run, command, args, description) {
240
+ const result = run(command, args);
241
+ if (result.status !== 0 || result.error) {
242
+ const detail = result.error?.message || String(result.stderr || "").trim() || `exit ${result.status}`;
243
+ throw new Error(`${description} failed: ${detail}`);
244
+ }
245
+ return result;
246
+ }
@@ -4,6 +4,7 @@
4
4
  import os from "node:os";
5
5
 
6
6
  import { agentProfileRoot, syncAgentProfiles } from "../agents.js";
7
+ import { parseFlags } from "../args.js";
7
8
  import { loadConfig, normalizeGatewayUrl, redactSecretText, resolveDefaultGateway } from "../config.js";
8
9
  import { ensureTenantSelection, tenantCredential } from "../tenants.js";
9
10
  import { managedSkillProfiles } from "./skills.js";
@@ -41,7 +42,8 @@ export function managedAgentProfiles(client, options = {}) {
41
42
  }
42
43
 
43
44
  export async function cmdAgents(argv, overrides = {}) {
44
- const [action = "sync", targetToken] = argv;
45
+ const [action = "sync", rawTarget, ...rest] = argv;
46
+ const targetToken = rawTarget?.startsWith("--") ? undefined : rawTarget;
45
47
  if (["help", "--help", "-h"].includes(action)) {
46
48
  console.log(HELP);
47
49
  return;
@@ -57,6 +59,8 @@ export async function cmdAgents(argv, overrides = {}) {
57
59
  process.exitCode = 1;
58
60
  return;
59
61
  }
62
+ const flagArgs = rawTarget?.startsWith("--") ? [rawTarget, ...rest] : rest;
63
+ const { flags } = parseFlags(flagArgs, { "skip-apps": { type: "boolean" } });
60
64
 
61
65
  const io = {
62
66
  loadConfig,
@@ -83,6 +87,7 @@ export async function cmdAgents(argv, overrides = {}) {
83
87
  tenantId,
84
88
  environment: io.environment,
85
89
  homeDir: io.homeDir,
90
+ includeApps: !flags["skip-apps"],
86
91
  }));
87
92
  await io.syncAgentProfiles({
88
93
  profiles,
@@ -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,
@@ -40,7 +41,7 @@ import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
40
41
  import { syncAgentProfilesSafe } from "../agents.js";
41
42
  import { secureManagedCodexHome } from "../codexSecurity.js";
42
43
  import { impelCliInvocation } from "../selfInvocation.js";
43
- import { withProgress } from "../progress.js";
44
+ import { createProgressLogger, withProgress } from "../progress.js";
44
45
  import {
45
46
  ensureWindowsClaudeApp,
46
47
  ensureWindowsChatGPTApp,
@@ -79,6 +80,102 @@ function windowsAppTargets(targetToken) {
79
80
  return null;
80
81
  }
81
82
 
83
+ function macTargetOrArtifactExists(paths, target, existsSync = fs.existsSync) {
84
+ const launcher = paths[target].launcher;
85
+ if (existsSync(launcher)) return true;
86
+ try {
87
+ const baseName = path.basename(launcher);
88
+ return fs.readdirSync(path.dirname(launcher)).some((entry) => (
89
+ entry.startsWith(`${baseName}.tmp-`) || entry.startsWith(`${baseName}.previous-`)
90
+ ));
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /** Locally installed tenant variants eligible for an all-target app update. */
97
+ export function installedAppTenantIds(targets, {
98
+ homeDir = os.homedir(),
99
+ platform = process.platform,
100
+ environment = process.env,
101
+ existsSync = fs.existsSync,
102
+ readdirSync = fs.readdirSync,
103
+ } = {}) {
104
+ const tenantsRoot = path.join(appPaths(homeDir).root, "tenants");
105
+ let entries;
106
+ try {
107
+ entries = readdirSync(tenantsRoot, { withFileTypes: true });
108
+ } catch (error) {
109
+ if (error?.code === "ENOENT") return [];
110
+ throw error;
111
+ }
112
+
113
+ const installed = [];
114
+ for (const entry of entries) {
115
+ if (!entry.isDirectory()) continue;
116
+ let tenantId;
117
+ try {
118
+ tenantId = normalizeTenantId(entry.name);
119
+ } catch {
120
+ continue;
121
+ }
122
+ const manifest = readTenantManifest(homeDir, tenantId);
123
+ const claudeUserData = platform === "win32"
124
+ ? windowsClaudeUserData(environment, tenantId)
125
+ : null;
126
+ const paths = appPaths(homeDir, tenantId, {
127
+ claudeUserData,
128
+ tenantName: manifest?.tenantName,
129
+ });
130
+ const hasTarget = targets.some((target) => {
131
+ if (platform !== "win32") return macTargetOrArtifactExists(paths, target, existsSync);
132
+ return target === "claude"
133
+ ? existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`))
134
+ : existsSync(path.join(paths.chatgpt.codexHome, "config.toml"));
135
+ });
136
+ if (hasTarget) installed.push(tenantId);
137
+ }
138
+ return installed.sort((left, right) => left.localeCompare(right));
139
+ }
140
+
141
+ async function maybeUpdateAllInstalledTenants(argv, overrides, platform) {
142
+ if (overrides.singleTenantUpdate) return null;
143
+ const [action = "status", rawTarget, ...rest] = argv;
144
+ if (action !== "update") return null;
145
+ const targetToken = rawTarget?.startsWith("--") ? undefined : rawTarget;
146
+ const targets = normalizeAppTarget(targetToken);
147
+ if (!targets || targets.length !== 2) return null;
148
+ const flagArgs = rawTarget?.startsWith("--") ? [rawTarget, ...rest] : rest;
149
+ const { flags } = parseFlags(flagArgs, {
150
+ "skip-vendor": { type: "boolean" },
151
+ "skip-skills": { type: "boolean" },
152
+ "skip-agents": { type: "boolean" },
153
+ force: { type: "boolean" },
154
+ tenant: { type: "string" },
155
+ });
156
+ if (flags.tenant) return null;
157
+
158
+ const discover = overrides.installedTenantIds || installedAppTenantIds;
159
+ const tenantIds = discover(targets, {
160
+ homeDir: overrides.homeDir || os.homedir(),
161
+ platform,
162
+ environment: overrides.environment || process.env,
163
+ });
164
+ if (tenantIds.length === 0) return null;
165
+
166
+ const log = overrides.log || console.log;
167
+ log(`Updating all managed app targets across ${tenantIds.length} installed tenant${tenantIds.length === 1 ? "" : "s"}: ${tenantIds.join(", ")}`);
168
+ const runTenantUpdate = overrides.runTenantUpdate || cmdApps;
169
+ for (const tenantId of tenantIds) {
170
+ const result = await runTenantUpdate(
171
+ ["update", "all", ...flagArgs, "--tenant", tenantId],
172
+ { ...overrides, singleTenantUpdate: true },
173
+ );
174
+ if (result === false) return false;
175
+ }
176
+ return true;
177
+ }
178
+
82
179
  function windowsProcessFailure(result) {
83
180
  if (result?.error?.message) return redactSecretText(result.error.message);
84
181
  if (Number.isInteger(result?.status)) return `exit code ${result.status}`;
@@ -304,8 +401,14 @@ export async function cmdWindowsApps(argv, overrides = {}) {
304
401
  console.log(`${verb} Impel ${target === "claude" ? "Claude" : "ChatGPT"} profile at ${profile}`);
305
402
  const { client, env, label } = appSkillTarget(target, tenantPaths);
306
403
  if (!flags["skip-skills"]) {
307
- await withProgress(`Syncing skills for ${label}`, () => (
308
- io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label })
404
+ await withProgress(`Syncing skills for ${label}`, (spinner) => (
405
+ io.syncSkills({
406
+ client,
407
+ gatewayUrl: resolveSkillsGateway(config.gatewayUrl),
408
+ env,
409
+ label,
410
+ logger: createProgressLogger(spinner),
411
+ })
309
412
  ));
310
413
  }
311
414
  if (target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
@@ -347,6 +450,8 @@ export async function cmdWindowsApps(argv, overrides = {}) {
347
450
 
348
451
  export async function cmdApps(argv, overrides = {}) {
349
452
  const platform = overrides.platform || process.platform;
453
+ const allTenantUpdate = await maybeUpdateAllInstalledTenants(argv, overrides, platform);
454
+ if (allTenantUpdate !== null) return allTenantUpdate;
350
455
  if (platform === "win32") return cmdWindowsApps(argv, overrides);
351
456
  if (platform !== "darwin") {
352
457
  console.error("impel app: isolated Impel desktop apps are unavailable on this platform.");
@@ -444,6 +549,7 @@ export async function cmdApps(argv, overrides = {}) {
444
549
  syncAgents: syncAgentProfilesSafe,
445
550
  secureCodexHome: secureManagedCodexHome,
446
551
  removeLauncher: (launcher) => fs.rmSync(launcher, { recursive: true, force: true }),
552
+ openLauncher: openManagedLauncher,
447
553
  log: (message) => console.log(message),
448
554
  ...overrides,
449
555
  };
@@ -485,13 +591,22 @@ export async function cmdApps(argv, overrides = {}) {
485
591
  );
486
592
  }
487
593
 
488
- // 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 = [];
489
598
  if (staleBundleTargets.length > 0) {
490
- await io.quitApps(staleBundleTargets, {
599
+ const quitResult = await io.quitApps(staleBundleTargets, {
491
600
  tenantId: config.tenantId,
492
601
  tenantName: config.tenantName,
493
602
  });
603
+ if (Array.isArray(quitResult)) closedApps = quitResult;
494
604
  }
605
+ const reopenTargets = new Set(
606
+ staleBundleTargets.filter((target) => closedApps.includes(
607
+ managedLauncherName(target, { tenantId: config.tenantId, tenantName: config.tenantName }),
608
+ )),
609
+ );
495
610
  if (force) {
496
611
  for (const status of statuses) io.removeLauncher(status.launcher);
497
612
  }
@@ -520,8 +635,8 @@ export async function cmdApps(argv, overrides = {}) {
520
635
  for (const item of installed) {
521
636
  const { client, env, label } = appSkillTarget(item.target, paths);
522
637
  if (!flags["skip-skills"]) {
523
- await withProgress(`Syncing skills for ${label}`, () => (
524
- io.syncSkills({ client, gatewayUrl, env, label })
638
+ await withProgress(`Syncing skills for ${label}`, (spinner) => (
639
+ io.syncSkills({ client, gatewayUrl, env, label, logger: createProgressLogger(spinner) })
525
640
  ));
526
641
  }
527
642
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
@@ -534,6 +649,15 @@ export async function cmdApps(argv, overrides = {}) {
534
649
  tenantId: config.tenantId,
535
650
  }));
536
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
+ }
537
661
  }
538
662
 
539
663
  /**
@@ -628,8 +752,8 @@ export async function provisionAndOpenManagedApps({
628
752
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
629
753
  for (const item of installed) {
630
754
  const { client, env, label } = appSkillTarget(item.target, paths);
631
- await withProgress(`Syncing skills for ${label}`, () => (
632
- io.syncSkills({ client, gatewayUrl, env, label })
755
+ await withProgress(`Syncing skills for ${label}`, (spinner) => (
756
+ io.syncSkills({ client, gatewayUrl, env, label, logger: createProgressLogger(spinner) })
633
757
  ));
634
758
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
635
759
  }
@@ -8,6 +8,7 @@ import fs from "node:fs";
8
8
  import os from "node:os";
9
9
  import path from "node:path";
10
10
 
11
+ import { parseFlags } from "../args.js";
11
12
  import { loadConfig } from "../config.js";
12
13
  import { CODEX_HOME } from "../codexSetup.js";
13
14
  import { tenantCliProfilePaths } from "../cliProfiles.js";
@@ -15,7 +16,7 @@ import { CLAUDE_CONFIG_ID, appPaths } from "../apps.js";
15
16
  import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
16
17
  import { ensureTenantSelection } from "../tenants.js";
17
18
  import { windowsClaudeUserData } from "../windowsApps.js";
18
- import { mapWithConcurrency, withProgress } from "../progress.js";
19
+ import { createProgressLogger, mapWithConcurrency, withProgress } from "../progress.js";
19
20
 
20
21
  const VALID_TARGETS = ["claude", "codex", "all"];
21
22
 
@@ -40,6 +41,7 @@ export function managedSkillProfiles(
40
41
  tenantId = null,
41
42
  platform = process.platform,
42
43
  environment = process.env,
44
+ includeApps = true,
43
45
  } = {},
44
46
  ) {
45
47
  const profiles = [];
@@ -59,7 +61,7 @@ export function managedSkillProfiles(
59
61
  const appInstalled = platform === "win32"
60
62
  ? existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`))
61
63
  : existsSync(paths.claude.launcher);
62
- if (appInstalled) {
64
+ if (includeApps && appInstalled) {
63
65
  profiles.push({ label: "Impel Claude app", env: { CLAUDE_CONFIG_DIR: paths.claude.userData } });
64
66
  }
65
67
  } else {
@@ -71,7 +73,7 @@ export function managedSkillProfiles(
71
73
  const appInstalled = platform === "win32"
72
74
  ? existsSync(path.join(paths.chatgpt.codexHome, "config.toml"))
73
75
  : existsSync(paths.chatgpt.launcher);
74
- if (appInstalled) {
76
+ if (includeApps && appInstalled) {
75
77
  profiles.push({ label: "Impel ChatGPT app", env: { CODEX_HOME: paths.chatgpt.codexHome } });
76
78
  }
77
79
  }
@@ -80,7 +82,8 @@ export function managedSkillProfiles(
80
82
  }
81
83
 
82
84
  export async function cmdSkills(argv, overrides = {}) {
83
- const [action = "sync", targetToken] = argv;
85
+ const [action = "sync", rawTarget, ...rest] = argv;
86
+ const targetToken = rawTarget?.startsWith("--") ? undefined : rawTarget;
84
87
  if (action !== "sync") {
85
88
  console.error(`impel skills: unknown action "${action}". Try \`impel skills sync [claude|codex|all]\`.`);
86
89
  process.exitCode = 1;
@@ -93,6 +96,8 @@ export async function cmdSkills(argv, overrides = {}) {
93
96
  process.exitCode = 1;
94
97
  return;
95
98
  }
99
+ const flagArgs = rawTarget?.startsWith("--") ? [rawTarget, ...rest] : rest;
100
+ const { flags } = parseFlags(flagArgs, { "skip-apps": { type: "boolean" } });
96
101
 
97
102
  const io = {
98
103
  loadConfig,
@@ -108,12 +113,21 @@ export async function cmdSkills(argv, overrides = {}) {
108
113
  : null;
109
114
  const clients = target === "all" ? ["claude", "codex"] : [target];
110
115
  const profiles = clients.flatMap((client) => (
111
- io.managedProfiles(client, { tenantId }).map((profile) => ({ client, profile }))
116
+ io.managedProfiles(client, {
117
+ tenantId,
118
+ includeApps: !flags["skip-apps"],
119
+ }).map((profile) => ({ client, profile }))
112
120
  ));
113
121
 
114
- await withProgress(`Syncing skills into ${profiles.length} managed profile${profiles.length === 1 ? "" : "s"}`, () => (
122
+ await withProgress(`Syncing skills into ${profiles.length} managed profile${profiles.length === 1 ? "" : "s"}`, (spinner) => (
115
123
  mapWithConcurrency(profiles, 4, ({ client, profile }) => (
116
- io.syncSkills({ client, gatewayUrl, env: profile.env, label: profile.label })
124
+ io.syncSkills({
125
+ client,
126
+ gatewayUrl,
127
+ env: profile.env,
128
+ label: profile.label,
129
+ logger: createProgressLogger(spinner),
130
+ })
117
131
  ))
118
132
  ));
119
133
  }
@@ -15,6 +15,7 @@ import { loadConfig, redactSecretText } from "../config.js";
15
15
  import { nativeCommandInvocation } from "../nativeProcess.js";
16
16
  import { windowsClaudeUserData } from "../windowsApps.js";
17
17
  import { withProgress } from "../progress.js";
18
+ import { installedAppTenantIds } from "./apps.js";
18
19
  import {
19
20
  fetchRemoteVersion,
20
21
  installedVersion,
@@ -29,9 +30,9 @@ const CLI_BIN = fileURLToPath(new URL("../../bin/impel.js", import.meta.url));
29
30
  const HELP = `impel update - update everything Impel in one command
30
31
 
31
32
  Reinstalls impel-cli from npm, then cascades with the NEW build:
32
- \`impel app update all\` (update the platform's vendor apps and managed app
33
- profiles) when Impel apps are installed, then \`impel skills sync all\` and
34
- \`impel agents sync all\` across every managed profile.
33
+ \`impel app update all\` (update every locally installed tenant's vendor apps
34
+ and managed app profiles), then \`impel skills sync all\` and
35
+ \`impel agents sync all\` across native and isolated CLI profiles.
35
36
 
36
37
  Usage:
37
38
  impel update Update the CLI, then cascade to apps, skills, and agents
@@ -94,27 +95,40 @@ export function defaultRunAppsUpdate({
94
95
  execPath = process.execPath,
95
96
  cliBin = CLI_BIN,
96
97
  } = {}) {
97
- const result = spawn(execPath, [cliBin, "app", "update", "all", "--skip-skills", "--skip-agents"], {
98
+ const result = spawn(execPath, [cliBin, "app", "update", "all"], {
98
99
  stdio: "inherit",
99
100
  });
100
101
  return result.status === 0;
101
102
  }
102
103
 
103
- function defaultRunSkillsSync() {
104
- const result = spawnSync(process.execPath, [CLI_BIN, "skills", "sync", "all"], {
104
+ export function defaultRunSkillsSync({
105
+ spawn = spawnSync,
106
+ execPath = process.execPath,
107
+ cliBin = CLI_BIN,
108
+ } = {}) {
109
+ const result = spawn(execPath, [cliBin, "skills", "sync", "all", "--skip-apps"], {
105
110
  stdio: "inherit",
106
111
  });
107
112
  return result.status === 0;
108
113
  }
109
114
 
110
- function defaultRunAgentsSync() {
111
- const result = spawnSync(process.execPath, [CLI_BIN, "agents", "sync", "all"], {
115
+ export function defaultRunAgentsSync({
116
+ spawn = spawnSync,
117
+ execPath = process.execPath,
118
+ cliBin = CLI_BIN,
119
+ } = {}) {
120
+ const result = spawn(execPath, [cliBin, "agents", "sync", "all", "--skip-apps"], {
112
121
  stdio: "inherit",
113
122
  });
114
123
  return result.status === 0;
115
124
  }
116
125
 
117
126
  function anyAppInstalled(homeDir = os.homedir()) {
127
+ if (installedAppTenantIds(["claude", "chatgpt"], {
128
+ homeDir,
129
+ platform: process.platform,
130
+ environment: process.env,
131
+ }).length > 0) return true;
118
132
  const config = loadConfig();
119
133
  const claudeUserData = process.platform === "win32"
120
134
  ? windowsClaudeUserData(process.env, config?.tenantId || null)
@@ -202,7 +216,7 @@ export async function cmdUpdate(argv, overrides = {}) {
202
216
  console.log(`CLI: updated${remote ? ` to v${remote}` : ""}.`);
203
217
  }
204
218
 
205
- // ── Cascade: apps, then skills across every managed profile ────────────
219
+ // ── Cascade: every tenant app, then non-app managed profiles ───────────
206
220
  let cascadeFailed = false;
207
221
  if (flags["skip-apps"]) {
208
222
  console.log("Apps: skipped (--skip-apps).");
@@ -212,21 +226,21 @@ export async function cmdUpdate(argv, overrides = {}) {
212
226
  console.log("Apps: none installed; skipping (run `impel app install` or `impel setup`).");
213
227
  } else {
214
228
  console.log(io.platform === "win32"
215
- ? "Apps: updating the signed Claude and ChatGPT vendor apps and isolated profiles…"
216
- : "Apps: refreshing profiles and rebuilding only stale app bundles…");
229
+ ? "Apps: updating every installed tenant's signed Claude and ChatGPT profiles…"
230
+ : "Apps: refreshing every installed tenant and rebuilding only stale app bundles…");
217
231
  if (!await withProgress("Updating managed desktop apps", () => io.runAppsUpdate())) {
218
232
  console.error("impel update: the app update failed; re-run `impel app update` after fixing the issue.");
219
233
  cascadeFailed = true;
220
234
  }
221
235
  }
222
236
 
223
- console.log("Skills: syncing every managed profile…");
237
+ console.log("Skills: syncing native and isolated CLI profiles…");
224
238
  if (!await withProgress("Syncing skills across managed profiles", () => io.runSkillsSync())) {
225
239
  console.error("impel update: skill sync failed; re-run `impel skills sync` after fixing the issue.");
226
240
  cascadeFailed = true;
227
241
  }
228
242
 
229
- console.log("Agents: syncing the selected tenant into every managed profile…");
243
+ console.log("Agents: syncing the selected tenant into native and isolated CLI profiles…");
230
244
  if (!await withProgress("Syncing agents across managed profiles", () => io.runAgentsSync())) {
231
245
  console.error("impel update: agent sync failed; re-run `impel agents sync` after fixing the issue.");
232
246
  cascadeFailed = true;
package/src/progress.js CHANGED
@@ -2,7 +2,9 @@ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "
2
2
 
3
3
  function noopSpinner() {
4
4
  return {
5
+ active: false,
5
6
  update() {},
7
+ writeLine() { return false; },
6
8
  succeed() {},
7
9
  fail() {},
8
10
  };
@@ -37,9 +39,16 @@ export function createSpinner(label, { stream = process.stderr } = {}) {
37
39
  const timer = setInterval(render, 80);
38
40
  timer.unref?.();
39
41
  return {
42
+ active: true,
40
43
  update(nextLabel) {
41
44
  if (!finished && nextLabel) currentLabel = String(nextLabel);
42
45
  },
46
+ writeLine(message) {
47
+ if (finished) return false;
48
+ stream.write(`\r\x1b[K${String(message)}\n`);
49
+ render();
50
+ return true;
51
+ },
43
52
  succeed(nextLabel) {
44
53
  finish("✓", nextLabel);
45
54
  },
@@ -49,6 +58,18 @@ export function createSpinner(label, { stream = process.stderr } = {}) {
49
58
  };
50
59
  }
51
60
 
61
+ /** Keep ordinary log lines from overwriting an active progress display. */
62
+ export function createProgressLogger(spinner, fallback = console) {
63
+ const write = (method, args) => {
64
+ const message = args.map((value) => String(value)).join(" ");
65
+ if (!spinner?.writeLine?.(message)) fallback[method](...args);
66
+ };
67
+ return {
68
+ log: (...args) => write("log", args),
69
+ warn: (...args) => write("warn", args),
70
+ };
71
+ }
72
+
52
73
  export async function withProgress(label, fn, options) {
53
74
  const spinner = createSpinner(label, options);
54
75
  try {
package/src/skills.js CHANGED
@@ -19,7 +19,7 @@
19
19
  // - Codex: `plugin marketplace add|upgrade`, `plugin add` (install + refresh)
20
20
  // so we keep a per-client command table rather than assuming one shape.
21
21
 
22
- import { spawnSync } from "node:child_process";
22
+ import { spawn } from "node:child_process";
23
23
 
24
24
  import { resolveDefaultGateway, normalizeGatewayUrl } from "./config.js";
25
25
  import { nativeCommandInvocation } from "./nativeProcess.js";
@@ -159,44 +159,95 @@ export function buildSkillCommands({ client, marketplaceSourceUrl: sourceUrl, ma
159
159
  return commands;
160
160
  }
161
161
 
162
- /** Default command runner: spawns the client binary non-interactively. */
163
- function defaultRun(bin, args, env) {
162
+ const SKILL_COMMAND_TIMEOUT_MS = 120_000;
163
+ const SKILL_COMMAND_OUTPUT_LIMIT = 10 * 1024 * 1024;
164
+
165
+ /** Default async command runner: keeps progress timers responsive while plugins sync. */
166
+ export function runSkillCommand(bin, args, env, {
167
+ spawnImpl = spawn,
168
+ timeoutMs = SKILL_COMMAND_TIMEOUT_MS,
169
+ } = {}) {
164
170
  const environment = { ...process.env, ...env };
165
171
  let invocation;
166
172
  try {
167
173
  invocation = nativeCommandInvocation(bin, args, environment);
168
174
  } catch (error) {
169
- return {
175
+ return Promise.resolve({
170
176
  ok: false,
171
177
  missing: false,
172
178
  status: null,
173
179
  stdout: "",
174
180
  stderr: error?.message || String(error),
175
- };
181
+ });
176
182
  }
177
- const result = spawnSync(invocation.command, invocation.args, {
178
- env: environment,
179
- encoding: "utf8",
180
- stdio: ["ignore", "pipe", "pipe"],
181
- timeout: 120000,
182
- windowsVerbatimArguments: invocation.windowsVerbatimArguments,
183
- });
184
- if (result.error) {
185
- return {
183
+
184
+ return new Promise((resolve) => {
185
+ let child;
186
+ try {
187
+ child = spawnImpl(invocation.command, invocation.args, {
188
+ env: environment,
189
+ stdio: ["ignore", "pipe", "pipe"],
190
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
191
+ });
192
+ } catch (error) {
193
+ resolve({
194
+ ok: false,
195
+ missing: error?.code === "ENOENT",
196
+ status: null,
197
+ stdout: "",
198
+ stderr: error?.message || String(error),
199
+ });
200
+ return;
201
+ }
202
+
203
+ let stdout = "";
204
+ let stderr = "";
205
+ let timedOut = false;
206
+ let settled = false;
207
+ const append = (current, chunk) => (
208
+ `${current}${String(chunk)}`.slice(-SKILL_COMMAND_OUTPUT_LIMIT)
209
+ );
210
+ child.stdout?.setEncoding?.("utf8");
211
+ child.stderr?.setEncoding?.("utf8");
212
+ child.stdout?.on("data", (chunk) => { stdout = append(stdout, chunk); });
213
+ child.stderr?.on("data", (chunk) => { stderr = append(stderr, chunk); });
214
+
215
+ const finish = (result) => {
216
+ if (settled) return;
217
+ settled = true;
218
+ clearTimeout(timeout);
219
+ resolve(result);
220
+ };
221
+ const timeout = setTimeout(() => {
222
+ timedOut = true;
223
+ try {
224
+ child.kill();
225
+ } catch {
226
+ // The process may have exited between the timer firing and kill().
227
+ }
228
+ finish({
229
+ ok: false,
230
+ missing: false,
231
+ status: null,
232
+ stdout,
233
+ stderr: `skill command timed out after ${timeoutMs}ms`,
234
+ });
235
+ }, timeoutMs);
236
+ child.on("error", (error) => finish({
186
237
  ok: false,
187
- missing: result.error.code === "ENOENT",
238
+ missing: error?.code === "ENOENT",
188
239
  status: null,
189
- stdout: "",
190
- stderr: result.error.message || String(result.error),
191
- };
192
- }
193
- return {
194
- ok: result.status === 0,
195
- missing: false,
196
- status: result.status,
197
- stdout: result.stdout || "",
198
- stderr: result.stderr || "",
199
- };
240
+ stdout,
241
+ stderr: error?.message || String(error),
242
+ }));
243
+ child.on("close", (status) => finish({
244
+ ok: !timedOut && status === 0,
245
+ missing: false,
246
+ status,
247
+ stdout,
248
+ stderr: timedOut ? `skill command timed out after ${timeoutMs}ms` : stderr,
249
+ }));
250
+ });
200
251
  }
201
252
 
202
253
  function firstLine(text) {
@@ -237,7 +288,7 @@ export async function syncSkills({
237
288
  env = {},
238
289
  label,
239
290
  fetchImpl = fetch,
240
- run = defaultRun,
291
+ run = runSkillCommand,
241
292
  logger = console,
242
293
  } = {}) {
243
294
  const spec = CLIENT_SPECS[client];
@@ -254,7 +305,7 @@ export async function syncSkills({
254
305
 
255
306
  try {
256
307
  // Confirm the binary and its `plugin` subcommand exist before doing anything.
257
- const help = run(spec.bin, ["plugin", "--help"], env);
308
+ const help = await run(spec.bin, ["plugin", "--help"], env);
258
309
  if (help.missing) {
259
310
  logger.warn(`impel: skipping skill sync for ${displayLabel} — \`${spec.bin}\` CLI not found on PATH.`);
260
311
  return { client, label: displayLabel, skipped: true, reason: "binary-missing" };
@@ -272,7 +323,7 @@ export async function syncSkills({
272
323
  logger.log(`Skills: syncing ${SKILL_PLUGIN_NAME} for ${displayLabel}…`);
273
324
  const failures = [];
274
325
  for (const command of commands) {
275
- const result = run(spec.bin, command.args, env);
326
+ const result = await run(spec.bin, command.args, env);
276
327
  if (result.missing) {
277
328
  failures.push({ phase: command.phase, reason: `\`${spec.bin}\` disappeared mid-sync` });
278
329
  break;