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 +57 -21
- package/package.json +1 -1
- package/src/apps.js +69 -26
- package/src/cli.js +2 -2
- package/src/codesign.js +246 -0
- package/src/commands/agents.js +6 -1
- package/src/commands/apps.js +133 -9
- package/src/commands/skills.js +21 -7
- package/src/commands/update.js +27 -13
- package/src/progress.js +21 -0
- package/src/skills.js +80 -29
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
|
-
|
|
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).
|
|
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
|
-
(
|
|
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
|
-
|
|
245
|
-
profiles
|
|
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.
|
|
741
|
-
`
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
`
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
|
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
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
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
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
|
1578
|
-
//
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
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",
|
|
1583
|
-
: ["--force", "--deep",
|
|
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
|
-
//
|
|
1590
|
-
//
|
|
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,
|
|
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,
|
|
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;
|
|
53
|
-
(--force
|
|
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]
|
package/src/codesign.js
ADDED
|
@@ -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
|
+
}
|
package/src/commands/agents.js
CHANGED
|
@@ -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",
|
|
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,
|
package/src/commands/apps.js
CHANGED
|
@@ -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({
|
|
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
|
|
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
|
}
|
package/src/commands/skills.js
CHANGED
|
@@ -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",
|
|
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, {
|
|
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({
|
|
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
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -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
|
|
33
|
-
|
|
34
|
-
\`impel agents sync all\` across
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
216
|
-
: "Apps: refreshing
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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:
|
|
238
|
+
missing: error?.code === "ENOENT",
|
|
188
239
|
status: null,
|
|
189
|
-
stdout
|
|
190
|
-
stderr:
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 =
|
|
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;
|