impel-cli 0.7.1 → 0.7.3
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 +31 -22
- package/package.json +1 -1
- package/src/apps.js +298 -59
- package/src/commands/apps.js +6 -6
- package/src/commands/setup.js +2 -2
- package/src/commands/tenant.js +1 -0
- package/src/commands/update.js +1 -1
package/README.md
CHANGED
|
@@ -184,7 +184,7 @@ impel auth --pat impel_pat_xxxxxxxxxxxx --gateway https://gateway.useimpel.com -
|
|
|
184
184
|
One command brings everything current, in dependency order: the CLI itself
|
|
185
185
|
(`npm install -g impel-cli@latest` from npm, with no GitHub credentials),
|
|
186
186
|
then — re-executing the freshly installed build — `impel app update all`
|
|
187
|
-
(close running apps,
|
|
187
|
+
(close running apps, install the CLI-pinned vendor builds, rebuild the vendored Impel apps)
|
|
188
188
|
and `impel skills sync all` across every managed profile.
|
|
189
189
|
|
|
190
190
|
Update discovery compares the installed package version with npm's public
|
|
@@ -240,7 +240,9 @@ On macOS, desktop app state is isolated under
|
|
|
240
240
|
`~/.config/impel/apps/tenants/<org>/`. The app bundle in `~/Applications` is a
|
|
241
241
|
launcher for the currently opened tenant; `impel app open` refreshes that
|
|
242
242
|
launcher, the tenant-scoped bearer, model catalog, and specialist MCP arguments
|
|
243
|
-
before starting a new isolated instance.
|
|
243
|
+
before starting a new isolated instance. Switching tenants never deletes local
|
|
244
|
+
history or projects; switch back to the original tenant to reopen that tenant's
|
|
245
|
+
preserved profile. Windows Claude Desktop keeps each
|
|
244
246
|
tenant under `%LOCALAPPDATA%\Claude-3p\Impel\<org>` and launches the unchanged
|
|
245
247
|
signed vendor executable with that profile selected. Windows ChatGPT keeps its
|
|
246
248
|
Codex and browser state under the same tenant-scoped Impel apps root used on
|
|
@@ -585,12 +587,14 @@ configuration:
|
|
|
585
587
|
`~/.config/impel/apps/claude`. A narrowly version-checked compatibility patch
|
|
586
588
|
connects Claude's native plan-usage meter to the authenticated aggregate
|
|
587
589
|
Claude subscription pool exposed by the Impel gateway.
|
|
588
|
-
- Impel ChatGPT
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
590
|
+
- Impel ChatGPT wraps an APFS-cloned official app while preserving the nested
|
|
591
|
+
OpenAI-signed bundle and executable. Its outer resource root contains the
|
|
592
|
+
narrowly patched renderer ASAR used at runtime. The wrapper sets `CODEX_HOME`,
|
|
593
|
+
passes a separate Chromium `--user-data-dir`, and writes its provider config
|
|
594
|
+
and model catalog below `~/.config/impel/apps/chatgpt`. It also supplies a
|
|
595
|
+
process-scoped, Codex-compatible wrapper around the Impel PAT so native
|
|
596
|
+
`/status` requests can read the organization's aggregated subscription pool
|
|
597
|
+
from the gateway. Its generated Codex config uses the same snapshot and
|
|
594
598
|
subprocess-environment hardening as `impel codex`; install, update, and every
|
|
595
599
|
direct app launch also remove older snapshots for the selected profile, while
|
|
596
600
|
app install/update/open sweeps inactive tenant and legacy Impel app profiles.
|
|
@@ -598,22 +602,25 @@ configuration:
|
|
|
598
602
|
normal vendor app's browser/profile data.
|
|
599
603
|
|
|
600
604
|
The official Claude and ChatGPT apps remain separate installations and keep
|
|
601
|
-
their normal accounts, updater, profiles, and bundle identities.
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
605
|
+
their normal accounts, updater, profiles, and bundle identities. Each CLI
|
|
606
|
+
release pins exact, tested vendor builds (v0.7.2 pins Claude `1.20186.9` and
|
|
607
|
+
ChatGPT `26.707.72221`) and their archive SHA-256 digests. The installer reuses
|
|
608
|
+
an existing official app only when its version exactly matches; otherwise it
|
|
609
|
+
downloads the pinned signed archive into Impel's managed vendor cache under
|
|
610
|
+
`~/.config/impel/vendor`, verifies its digest, bundle version, layout, and code
|
|
611
|
+
signature, and only then rebuilds the isolated app. It never clones a moving
|
|
612
|
+
Homebrew or vendor “latest” release, and it never downgrades or replaces the
|
|
613
|
+
user's normal vendor app. A future app version requires a new tested CLI release
|
|
614
|
+
and pin update. Config refreshes do not download or replace apps. If the model
|
|
615
|
+
endpoint is temporarily unavailable, the installer retains functionality using
|
|
616
|
+
the CLI's bundled catalog.
|
|
611
617
|
|
|
612
618
|
For ChatGPT/Codex, the gateway controls which models and capabilities are
|
|
613
619
|
enabled while the installer merges those entries with the vendor app's native
|
|
614
620
|
catalog. The Impel copy relaxes the vendor renderer's ChatGPT-account-only Fast
|
|
615
|
-
gate because gateway auth supplies the entitlement instead. The
|
|
616
|
-
ad-hoc signed locally
|
|
621
|
+
gate because gateway auth supplies the entitlement instead. The outer wrapper
|
|
622
|
+
is then ad-hoc signed locally without replacing the nested OpenAI signing
|
|
623
|
+
identity. This preserves model-specific instructions and exposes
|
|
617
624
|
the same reasoning-effort and service-tier controls as the normal app, including
|
|
618
625
|
Ultra where supported and Fast only on models that advertise the `priority`
|
|
619
626
|
tier. Inference uses an explicit non-WebSocket Impel model provider so Codex
|
|
@@ -622,8 +629,10 @@ Impel PAT. Config refreshes replace only the Impel-managed gateway block; plugin
|
|
|
622
629
|
desktop preferences, projects, and other settings written by the isolated app
|
|
623
630
|
are kept.
|
|
624
631
|
|
|
625
|
-
|
|
626
|
-
launch wrappers are installed. The
|
|
632
|
+
Impel Claude and the outer Impel ChatGPT wrapper are ad-hoc signed locally after
|
|
633
|
+
their bundle identities and launch wrappers are installed. The nested ChatGPT
|
|
634
|
+
bundle retains OpenAI's signature. The Claude 3P config necessarily contains
|
|
635
|
+
the PAT because that app accepts a
|
|
627
636
|
gateway API key rather than a token-helper command. It is stored in the same
|
|
628
637
|
owner-only Impel config tree as the primary credential. ChatGPT/Codex uses a
|
|
629
638
|
command-backed helper and does not copy the PAT into TOML. The installable app
|
package/package.json
CHANGED
package/src/apps.js
CHANGED
|
@@ -21,18 +21,50 @@ const CLI_VERSION = JSON.parse(
|
|
|
21
21
|
const APP_DEFINITIONS = {
|
|
22
22
|
claude: {
|
|
23
23
|
label: "Impel Claude",
|
|
24
|
-
cask: "claude",
|
|
25
24
|
names: ["Claude.app"],
|
|
26
25
|
executableNames: ["Claude"],
|
|
27
26
|
},
|
|
28
27
|
chatgpt: {
|
|
29
28
|
label: "Impel ChatGPT",
|
|
30
|
-
cask: "codex-app",
|
|
31
29
|
names: ["ChatGPT.app", "Codex.app"],
|
|
32
30
|
executableNames: ["ChatGPT", "Codex"],
|
|
33
31
|
},
|
|
34
32
|
};
|
|
35
33
|
|
|
34
|
+
// Vendor updates can change minified renderer contracts without notice. Each
|
|
35
|
+
// CLI release therefore installs and accepts only the exact app builds tested
|
|
36
|
+
// with its compatibility patches. Updating a pin requires a new CLI release.
|
|
37
|
+
export const PINNED_VENDOR_APPS = Object.freeze({
|
|
38
|
+
claude: Object.freeze({
|
|
39
|
+
version: "1.20186.9",
|
|
40
|
+
bundleName: "Claude.app",
|
|
41
|
+
downloads: Object.freeze({
|
|
42
|
+
arm64: Object.freeze({
|
|
43
|
+
url: "https://downloads.claude.ai/releases/darwin/universal/1.20186.9/Claude-69f150a4c9316d5c8cd7b9f130ed583d15c0383e.zip",
|
|
44
|
+
sha256: "9364648c74bd525aa293a28103eb37262a5553de21c7ca907766d7fc40623015",
|
|
45
|
+
}),
|
|
46
|
+
x64: Object.freeze({
|
|
47
|
+
url: "https://downloads.claude.ai/releases/darwin/universal/1.20186.9/Claude-69f150a4c9316d5c8cd7b9f130ed583d15c0383e.zip",
|
|
48
|
+
sha256: "9364648c74bd525aa293a28103eb37262a5553de21c7ca907766d7fc40623015",
|
|
49
|
+
}),
|
|
50
|
+
}),
|
|
51
|
+
}),
|
|
52
|
+
chatgpt: Object.freeze({
|
|
53
|
+
version: "26.707.72221",
|
|
54
|
+
bundleName: "ChatGPT.app",
|
|
55
|
+
downloads: Object.freeze({
|
|
56
|
+
arm64: Object.freeze({
|
|
57
|
+
url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-arm64-26.707.72221.zip",
|
|
58
|
+
sha256: "d47a2c0dfb07afec21c415fcec672035a2e90410ecb7c6dc60141518fba524e0",
|
|
59
|
+
}),
|
|
60
|
+
x64: Object.freeze({
|
|
61
|
+
url: "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-darwin-x64-26.707.72221.zip",
|
|
62
|
+
sha256: "cadfdc8e1efa809b021bcf4466ae8c115ffdd280f1a467b518eca8ce8f6717fe",
|
|
63
|
+
}),
|
|
64
|
+
}),
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
|
|
36
68
|
const STANDARD_REASONING_LEVELS = [
|
|
37
69
|
{ effort: "low", description: "Fast responses with lighter reasoning" },
|
|
38
70
|
{ effort: "medium", description: "Balances speed and reasoning depth for everyday tasks" },
|
|
@@ -42,6 +74,8 @@ const STANDARD_REASONING_LEVELS = [
|
|
|
42
74
|
const MAX_REASONING_LEVEL = { effort: "max", description: "Maximum reasoning depth for the hardest problems" };
|
|
43
75
|
const ULTRA_REASONING_LEVEL = { effort: "ultra", description: "Maximum reasoning with automatic task delegation" };
|
|
44
76
|
const FAST_SERVICE_TIER = { id: "priority", name: "Fast", description: "1.5x speed, increased usage" };
|
|
77
|
+
const FAST_MODE_AUTH_GATE = /([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)&&!([A-Za-z_$][\w$]*)&&([A-Za-z_$][\w$]*)!=null&&\4\?\.requirements\?\.featureRequirements\?\.fast_mode!==!1/gu;
|
|
78
|
+
const CHATGPT_RESOURCE_ROOT = "linked_vendor_resources_with_patched_asar";
|
|
45
79
|
const CLAUDE_PLAN_USAGE_GUARD = "if(!Ze().hasOrgPolicyBackend())return;if((r=fr())";
|
|
46
80
|
const IMPEL_CLAUDE_PLAN_USAGE_GUARD = `${"if(!wze())return;".padEnd("if(!Ze().hasOrgPolicyBackend())return;".length, " ")}if((r=fr())`;
|
|
47
81
|
const CLAUDE_PLAN_USAGE_REQUEST = 'P.net.fetch(`${ht()}/api/organizations/${o}/usage`,{signal:AbortSignal.timeout(mcr)})';
|
|
@@ -110,16 +144,42 @@ export function bundleIsCurrent(status) {
|
|
|
110
144
|
compatibility
|
|
111
145
|
&& compatibility.cliVersion === CLI_VERSION
|
|
112
146
|
&& compatibility.vendorVersion === status.vendorVersion
|
|
147
|
+
&& compatibility.profileRoot === status.profileRoot
|
|
148
|
+
&& (status.target !== "chatgpt" || (
|
|
149
|
+
compatibility.codexHome === status.codexHome
|
|
150
|
+
&& compatibility.browserData === status.browserData
|
|
151
|
+
))
|
|
152
|
+
&& managedBundleMatchesCompatibility(status, compatibility)
|
|
113
153
|
&& (status.target !== "chatgpt" || (
|
|
114
154
|
compatibility.vendorSignaturesPreserved === true
|
|
115
|
-
&& compatibility.resourceRoot ===
|
|
155
|
+
&& compatibility.resourceRoot === CHATGPT_RESOURCE_ROOT
|
|
156
|
+
&& compatibility.patches?.fastModeForGatewayAuth > 0
|
|
116
157
|
)),
|
|
117
158
|
);
|
|
118
159
|
}
|
|
119
160
|
|
|
161
|
+
function managedBundleMatchesCompatibility(status, compatibility) {
|
|
162
|
+
try {
|
|
163
|
+
const managedVendorBundle = status.target === "claude"
|
|
164
|
+
? status.launcher
|
|
165
|
+
: APP_DEFINITIONS.chatgpt.names
|
|
166
|
+
.map((name) => path.join(status.launcher, "Contents", "Resources", name))
|
|
167
|
+
.find((candidate) => fs.existsSync(candidate));
|
|
168
|
+
const runtimeAsar = path.join(status.launcher, "Contents", "Resources", "app.asar");
|
|
169
|
+
return Boolean(
|
|
170
|
+
managedVendorBundle
|
|
171
|
+
&& bundleVersion(managedVendorBundle) === compatibility.vendorVersion
|
|
172
|
+
&& compatibility.asarHeaderSha256
|
|
173
|
+
&& asarHeaderHash(runtimeAsar) === compatibility.asarHeaderSha256
|
|
174
|
+
);
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
120
180
|
// Install/update must not run while the vendor or Impel-managed apps are open:
|
|
121
|
-
//
|
|
122
|
-
//
|
|
181
|
+
// Cloning/replacing a bundle under a running instance produces broken copies
|
|
182
|
+
// and codesign failures.
|
|
123
183
|
|
|
124
184
|
/** osascript application names whose bundles install/update will touch. */
|
|
125
185
|
export function blockingAppNames(targets) {
|
|
@@ -269,6 +329,9 @@ export function installManagedAppFiles({
|
|
|
269
329
|
// token helper, catalog, and manifest only. Bundle swaps require the app
|
|
270
330
|
// to be closed (see quitBlockingApps), so those paths never attempt one.
|
|
271
331
|
if (bundleTargets.has(target)) {
|
|
332
|
+
if (resolvedVendorPaths[target] && isVendableVendorApp(target, resolvedVendorPaths[target])) {
|
|
333
|
+
assertPinnedVendorAppVersion(target, resolvedVendorPaths[target]);
|
|
334
|
+
}
|
|
272
335
|
if (target === "chatgpt") writeVendoredChatGPTBundle(paths, resolvedVendorPaths.chatgpt, config.gatewayUrl);
|
|
273
336
|
else writeVendoredClaudeBundle(paths, resolvedVendorPaths.claude, config.gatewayUrl);
|
|
274
337
|
}
|
|
@@ -289,8 +352,11 @@ export function installManagedAppFiles({
|
|
|
289
352
|
return installed;
|
|
290
353
|
}
|
|
291
354
|
|
|
292
|
-
function vendorSearchRoots(homeDir) {
|
|
293
|
-
|
|
355
|
+
function vendorSearchRoots(homeDir, target) {
|
|
356
|
+
const roots = ["/Applications", path.join(homeDir, "Applications")];
|
|
357
|
+
const pin = PINNED_VENDOR_APPS[target];
|
|
358
|
+
if (pin) roots.push(path.join(homeDir, ".config", "impel", "vendor", target, pin.version));
|
|
359
|
+
return roots;
|
|
294
360
|
}
|
|
295
361
|
|
|
296
362
|
export function readBundleIdentifier(appPath) {
|
|
@@ -320,19 +386,26 @@ export function isVendableVendorApp(target, appPath) {
|
|
|
320
386
|
);
|
|
321
387
|
}
|
|
322
388
|
|
|
323
|
-
export function findVendorApp(target, homeDir = os.homedir(), {
|
|
389
|
+
export function findVendorApp(target, homeDir = os.homedir(), {
|
|
390
|
+
roots = vendorSearchRoots(homeDir, target),
|
|
391
|
+
version = PINNED_VENDOR_APPS[target]?.version,
|
|
392
|
+
} = {}) {
|
|
324
393
|
const definition = APP_DEFINITIONS[target];
|
|
325
394
|
for (const base of roots) {
|
|
326
395
|
for (const name of definition.names) {
|
|
327
396
|
const candidate = path.join(base, name);
|
|
328
|
-
if (
|
|
397
|
+
if (
|
|
398
|
+
fs.existsSync(candidate)
|
|
399
|
+
&& isVendableVendorApp(target, candidate)
|
|
400
|
+
&& (!version || bundleVersion(candidate) === version)
|
|
401
|
+
) return candidate;
|
|
329
402
|
}
|
|
330
403
|
}
|
|
331
404
|
return null;
|
|
332
405
|
}
|
|
333
406
|
|
|
334
407
|
/** Name-matched bundles that are NOT vendable — surfaced so "why was my app ignored?" is answerable. */
|
|
335
|
-
export function rejectedVendorCandidates(target, homeDir = os.homedir(), { roots = vendorSearchRoots(homeDir) } = {}) {
|
|
408
|
+
export function rejectedVendorCandidates(target, homeDir = os.homedir(), { roots = vendorSearchRoots(homeDir, target) } = {}) {
|
|
336
409
|
const definition = APP_DEFINITIONS[target];
|
|
337
410
|
const rejected = [];
|
|
338
411
|
for (const base of roots) {
|
|
@@ -346,33 +419,103 @@ export function rejectedVendorCandidates(target, homeDir = os.homedir(), { roots
|
|
|
346
419
|
return rejected;
|
|
347
420
|
}
|
|
348
421
|
|
|
349
|
-
|
|
422
|
+
function unpinnedVendorCandidates(target, homeDir = os.homedir(), { roots = vendorSearchRoots(homeDir, target) } = {}) {
|
|
350
423
|
const definition = APP_DEFINITIONS[target];
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
424
|
+
const expectedVersion = PINNED_VENDOR_APPS[target].version;
|
|
425
|
+
const candidates = [];
|
|
426
|
+
for (const base of roots) {
|
|
427
|
+
for (const name of definition.names) {
|
|
428
|
+
const candidate = path.join(base, name);
|
|
429
|
+
if (fs.existsSync(candidate) && isVendableVendorApp(target, candidate)) {
|
|
430
|
+
const version = bundleVersion(candidate);
|
|
431
|
+
if (version !== expectedVersion) candidates.push({ path: candidate, version });
|
|
432
|
+
}
|
|
360
433
|
}
|
|
361
|
-
const upgraded = spawnSync(brew, ["upgrade", "--cask", definition.cask], { stdio: "inherit" });
|
|
362
|
-
return { path: findVendorApp(target, homeDir) || current, action: upgraded.status === 0 ? "updated" : "update-failed" };
|
|
363
434
|
}
|
|
435
|
+
return candidates;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function assertPinnedVendorAppVersion(target, appPath) {
|
|
439
|
+
const expected = PINNED_VENDOR_APPS[target]?.version;
|
|
440
|
+
const actual = bundleVersion(appPath);
|
|
441
|
+
if (!expected || actual !== expected) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`${APP_DEFINITIONS[target]?.label || target} vendor version ${actual || "unknown"} is unsupported; impel-cli v${CLI_VERSION} requires the verified ${expected || "pinned"} build`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
return actual;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function installPinnedVendorApp(target, homeDir) {
|
|
450
|
+
const pin = PINNED_VENDOR_APPS[target];
|
|
451
|
+
const download = pin?.downloads?.[process.arch];
|
|
452
|
+
if (!pin || !download) throw new Error(`${target}: no verified vendor download is available for ${process.arch}`);
|
|
453
|
+
|
|
454
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), `impel-${target}-vendor-`));
|
|
455
|
+
const archive = path.join(tempRoot, `${target}.zip`);
|
|
456
|
+
const extractedRoot = path.join(tempRoot, "extracted");
|
|
457
|
+
try {
|
|
458
|
+
console.log(`${target}: downloading verified vendor app ${pin.version}…`);
|
|
459
|
+
const fetched = spawnSync(
|
|
460
|
+
"/usr/bin/curl",
|
|
461
|
+
["--fail", "--location", "--progress-bar", "--retry", "2", "--output", archive, download.url],
|
|
462
|
+
{ stdio: ["ignore", "inherit", "inherit"] },
|
|
463
|
+
);
|
|
464
|
+
if (fetched.status !== 0 || fetched.error) {
|
|
465
|
+
throw new Error(`${target}: failed to download verified vendor app ${pin.version}`);
|
|
466
|
+
}
|
|
467
|
+
const actualHash = sha256(fs.readFileSync(archive));
|
|
468
|
+
if (actualHash !== download.sha256) {
|
|
469
|
+
throw new Error(`${target}: vendor archive integrity check failed for ${pin.version}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
fs.mkdirSync(extractedRoot, { recursive: true });
|
|
473
|
+
const extracted = spawnSync("/usr/bin/ditto", ["-x", "-k", archive, extractedRoot], { encoding: "utf8" });
|
|
474
|
+
if (extracted.status !== 0 || extracted.error) {
|
|
475
|
+
throw new Error(`${target}: failed to extract verified vendor app ${pin.version}`);
|
|
476
|
+
}
|
|
477
|
+
const extractedApp = path.join(extractedRoot, pin.bundleName);
|
|
478
|
+
if (!isVendableVendorApp(target, extractedApp)) {
|
|
479
|
+
throw new Error(`${target}: verified vendor archive ${pin.version} has an unsupported app layout`);
|
|
480
|
+
}
|
|
481
|
+
assertPinnedVendorAppVersion(target, extractedApp);
|
|
482
|
+
const verified = spawnSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", extractedApp], { encoding: "utf8" });
|
|
483
|
+
if (verified.status !== 0 || verified.error) {
|
|
484
|
+
throw new Error(`${target}: vendor signature verification failed for ${pin.version}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const destination = path.join(
|
|
488
|
+
homeDir, ".config", "impel", "vendor", target, pin.version, pin.bundleName,
|
|
489
|
+
);
|
|
490
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
491
|
+
replaceDirectory(destination, extractedApp);
|
|
492
|
+
return destination;
|
|
493
|
+
} finally {
|
|
494
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function ensureVendorApp(target, { homeDir = os.homedir() } = {}) {
|
|
499
|
+
const definition = APP_DEFINITIONS[target];
|
|
500
|
+
const pin = PINNED_VENDOR_APPS[target];
|
|
501
|
+
const current = findVendorApp(target, homeDir);
|
|
502
|
+
if (current) return { path: current, action: "existing", note: `verified ${pin.version}` };
|
|
364
503
|
|
|
365
504
|
// Name-collision transparency: e.g. OpenAI's native ChatGPT chat app
|
|
366
|
-
// (com.openai.chat)
|
|
367
|
-
// app and cannot be vendored — say so instead of silently installing.
|
|
505
|
+
// (com.openai.chat) is NOT the Electron app and cannot be vendored.
|
|
368
506
|
for (const candidate of rejectedVendorCandidates(target, homeDir)) {
|
|
369
507
|
console.log(
|
|
370
|
-
`${target}: ignoring ${candidate.path} (${candidate.bundleId || "unknown bundle id"}) — not a vendorable ${definition.label.replace("Impel ", "")} desktop app
|
|
508
|
+
`${target}: ignoring ${candidate.path} (${candidate.bundleId || "unknown bundle id"}) — not a vendorable ${definition.label.replace("Impel ", "")} desktop app`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
for (const candidate of unpinnedVendorCandidates(target, homeDir)) {
|
|
512
|
+
console.log(
|
|
513
|
+
`${target}: ignoring ${candidate.path} (${candidate.version || "unknown version"}) — this CLI is pinned to verified version ${pin.version}`,
|
|
371
514
|
);
|
|
372
515
|
}
|
|
373
|
-
|
|
374
|
-
const installed =
|
|
375
|
-
return { path:
|
|
516
|
+
|
|
517
|
+
const installed = installPinnedVendorApp(target, homeDir);
|
|
518
|
+
return { path: installed, action: "installed", note: `verified ${pin.version}` };
|
|
376
519
|
}
|
|
377
520
|
|
|
378
521
|
export function appStatus(targets, homeDir = os.homedir(), tenantId = null) {
|
|
@@ -386,6 +529,9 @@ export function appStatus(targets, homeDir = os.homedir(), tenantId = null) {
|
|
|
386
529
|
launcherInstalled: fs.existsSync(paths[target].launcher),
|
|
387
530
|
vendorPath,
|
|
388
531
|
vendorVersion: vendorPath ? bundleVersion(vendorPath) : null,
|
|
532
|
+
profileRoot: target === "claude" ? paths.claude.userData : paths.chatgpt.root,
|
|
533
|
+
codexHome: target === "chatgpt" ? paths.chatgpt.codexHome : null,
|
|
534
|
+
browserData: target === "chatgpt" ? paths.chatgpt.browserData : null,
|
|
389
535
|
configPath: target === "claude"
|
|
390
536
|
? path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)
|
|
391
537
|
: path.join(paths.chatgpt.codexHome, "config.toml"),
|
|
@@ -749,12 +895,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
|
|
|
749
895
|
IMPEL_CLAUDE_PLAN_USAGE_GUARD,
|
|
750
896
|
"Claude plan-usage eligibility guard",
|
|
751
897
|
);
|
|
752
|
-
planUsageRequestPatchCount =
|
|
753
|
-
asarPath,
|
|
754
|
-
CLAUDE_PLAN_USAGE_REQUEST,
|
|
755
|
-
IMPEL_CLAUDE_PLAN_USAGE_REQUEST,
|
|
756
|
-
"Claude plan-usage gateway request",
|
|
757
|
-
);
|
|
898
|
+
planUsageRequestPatchCount = patchClaudePlanUsageRequest(asarPath);
|
|
758
899
|
}
|
|
759
900
|
const newAsarHash = asarHeaderHash(asarPath);
|
|
760
901
|
|
|
@@ -825,22 +966,27 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
825
966
|
if (!fs.existsSync(asarPath) || !fs.existsSync(plistPath)) {
|
|
826
967
|
const bundleId = readBundleIdentifier(vendorPath);
|
|
827
968
|
const hint = bundleId === "com.openai.chat"
|
|
828
|
-
? " — this is OpenAI's ChatGPT
|
|
969
|
+
? " — this is OpenAI's non-Electron ChatGPT app, not the supported desktop build; re-run `impel app install chatgpt`"
|
|
829
970
|
: bundleId
|
|
830
971
|
? ` (bundle id ${bundleId})`
|
|
831
972
|
: "";
|
|
832
973
|
throw new Error(`unsupported ChatGPT/Codex bundle layout in ${vendorPath}${hint}`);
|
|
833
974
|
}
|
|
834
975
|
|
|
835
|
-
const asarHash = asarHeaderHash(asarPath);
|
|
836
|
-
|
|
837
976
|
const macos = path.join(staging, "Contents", "MacOS");
|
|
838
|
-
writeAtomic(path.join(staging, "Contents", "Info.plist"), vendoredChatGPTWrapperPlist(vendorPath), 0o644);
|
|
839
977
|
const vendorIcon = path.join(vendorBundle, "Contents", "Resources", "electron.icns");
|
|
840
978
|
if (fs.existsSync(vendorIcon)) {
|
|
841
979
|
fs.copyFileSync(vendorIcon, path.join(staging, "Contents", "Resources", "electron.icns"));
|
|
842
980
|
}
|
|
843
981
|
linkVendoredChatGPTResources(staging, vendorBundleName);
|
|
982
|
+
const runtimeAsarPath = path.join(staging, "Contents", "Resources", "app.asar");
|
|
983
|
+
const fastModePatchCount = patchFastModeAuthGate(runtimeAsarPath);
|
|
984
|
+
const asarHash = asarHeaderHash(runtimeAsarPath);
|
|
985
|
+
writeAtomic(
|
|
986
|
+
path.join(staging, "Contents", "Info.plist"),
|
|
987
|
+
vendoredChatGPTWrapperPlist(vendorPath, asarHash),
|
|
988
|
+
0o644,
|
|
989
|
+
);
|
|
844
990
|
writeAtomic(
|
|
845
991
|
path.join(macos, "launch"),
|
|
846
992
|
vendoredChatGPTLauncher(paths, vendorBundleName, executableName, gatewayUrl),
|
|
@@ -851,9 +997,12 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
851
997
|
cliVersion: CLI_VERSION,
|
|
852
998
|
vendorVersion: bundleVersion(vendorPath),
|
|
853
999
|
sourceBundle: vendorPath,
|
|
854
|
-
|
|
1000
|
+
profileRoot: paths.chatgpt.root,
|
|
1001
|
+
codexHome: paths.chatgpt.codexHome,
|
|
1002
|
+
browserData: paths.chatgpt.browserData,
|
|
1003
|
+
patches: { fastModeForGatewayAuth: fastModePatchCount, desktopAPIForGatewayAuth: 0 },
|
|
855
1004
|
vendorSignaturesPreserved: true,
|
|
856
|
-
resourceRoot:
|
|
1005
|
+
resourceRoot: CHATGPT_RESOURCE_ROOT,
|
|
857
1006
|
asarHeaderSha256: asarHash,
|
|
858
1007
|
}, null, 2) + "\n", 0o644);
|
|
859
1008
|
|
|
@@ -870,11 +1019,17 @@ function linkVendoredChatGPTResources(bundle, vendorBundleName) {
|
|
|
870
1019
|
const vendorResources = path.join(outerResources, vendorBundleName, "Contents", "Resources");
|
|
871
1020
|
for (const entry of fs.readdirSync(vendorResources, { withFileTypes: true })) {
|
|
872
1021
|
// The wrapper owns its icon and compatibility record. Every other vendor
|
|
873
|
-
// resource is exposed at the outer Electron resource root
|
|
874
|
-
//
|
|
1022
|
+
// resource is exposed at the outer Electron resource root, leaving the
|
|
1023
|
+
// OpenAI-signed nested bundle untouched. app.asar is the sole copied file
|
|
1024
|
+
// because the runtime needs the narrowly scoped compatibility patch below.
|
|
875
1025
|
if (entry.name === "electron.icns") continue;
|
|
876
1026
|
const target = path.join(vendorBundleName, "Contents", "Resources", entry.name);
|
|
877
|
-
|
|
1027
|
+
const destination = path.join(outerResources, entry.name);
|
|
1028
|
+
// Electron resolves app.asar from the outer wrapper's resource root. Keep
|
|
1029
|
+
// the nested OpenAI bundle byte-for-byte signed, but use a patched copy at
|
|
1030
|
+
// runtime so gateway-authenticated profiles retain the native Fast control.
|
|
1031
|
+
if (entry.name === "app.asar") fs.copyFileSync(path.join(vendorResources, entry.name), destination);
|
|
1032
|
+
else fs.symlinkSync(target, destination);
|
|
878
1033
|
}
|
|
879
1034
|
}
|
|
880
1035
|
|
|
@@ -883,17 +1038,88 @@ function patchFixedWidthAsarString(asarPath, original, replacement, label) {
|
|
|
883
1038
|
throw new Error(`${label} replacement must preserve the vendor ASAR byte length`);
|
|
884
1039
|
}
|
|
885
1040
|
const archive = fs.readFileSync(asarPath);
|
|
886
|
-
const metadata = readAsarMetadata(archive);
|
|
887
1041
|
const source = archive.toString("latin1");
|
|
888
|
-
const
|
|
1042
|
+
const patches = [];
|
|
889
1043
|
for (let offset = source.indexOf(original); offset !== -1; offset = source.indexOf(original, offset + original.length)) {
|
|
890
|
-
|
|
1044
|
+
patches.push({ offset, original, replacement });
|
|
1045
|
+
}
|
|
1046
|
+
return applyFixedWidthAsarPatches(asarPath, archive, patches, label);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function patchClaudePlanUsageRequest(asarPath) {
|
|
1050
|
+
const archive = fs.readFileSync(asarPath);
|
|
1051
|
+
const source = archive.toString("latin1");
|
|
1052
|
+
const legacyPatches = [];
|
|
1053
|
+
for (
|
|
1054
|
+
let offset = source.indexOf(CLAUDE_PLAN_USAGE_REQUEST);
|
|
1055
|
+
offset !== -1;
|
|
1056
|
+
offset = source.indexOf(CLAUDE_PLAN_USAGE_REQUEST, offset + CLAUDE_PLAN_USAGE_REQUEST.length)
|
|
1057
|
+
) {
|
|
1058
|
+
legacyPatches.push({
|
|
1059
|
+
offset,
|
|
1060
|
+
original: CLAUDE_PLAN_USAGE_REQUEST,
|
|
1061
|
+
replacement: IMPEL_CLAUDE_PLAN_USAGE_REQUEST,
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
if (legacyPatches.length > 0) {
|
|
1065
|
+
return applyFixedWidthAsarPatches(
|
|
1066
|
+
asarPath, archive, legacyPatches, "Claude plan-usage gateway request",
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
const credentialAccessor = source.match(
|
|
1070
|
+
/function ([A-Za-z_$][\w$]*)\(e\)\{var t;return [\s\S]{0,500}?\[custom-3p\] Credentials loaded from managed config/u,
|
|
1071
|
+
)?.[1];
|
|
1072
|
+
if (!credentialAccessor) {
|
|
1073
|
+
return applyFixedWidthAsarPatches(asarPath, archive, [], "Claude plan-usage gateway request");
|
|
891
1074
|
}
|
|
892
|
-
|
|
1075
|
+
const configAccessorPattern = new RegExp(
|
|
1076
|
+
`${escapeRegex(credentialAccessor)}\\(([A-Za-z_$][\\w$]*)\\(\\)\\)`,
|
|
1077
|
+
"u",
|
|
1078
|
+
);
|
|
1079
|
+
const configAccessor = source.match(configAccessorPattern)?.[1];
|
|
1080
|
+
if (!configAccessor) {
|
|
1081
|
+
return applyFixedWidthAsarPatches(asarPath, archive, [], "Claude plan-usage gateway request");
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const requestPattern = /([A-Za-z_$][\w$]*)\.net\.fetch\(`\$\{[A-Za-z_$][\w$]*\(\)\}\/api\/organizations\/\$\{[A-Za-z_$][\w$]*\}\/usage`,\{signal:AbortSignal\.timeout\([A-Za-z_$][\w$]*\)\}\)/gu;
|
|
1085
|
+
const patches = [...source.matchAll(requestPattern)].map((match) => {
|
|
1086
|
+
const original = match[0];
|
|
1087
|
+
const replacement = `${match[1]}.net.fetch("https://gateway.useimpel.com/ui/a/u",{headers:{key:${credentialAccessor}(${configAccessor}()).apiKey}})`;
|
|
1088
|
+
if (replacement.length > original.length) {
|
|
1089
|
+
throw new Error("Claude plan-usage gateway request replacement no longer fits the vendor ASAR contract");
|
|
1090
|
+
}
|
|
1091
|
+
return { offset: match.index, original, replacement: replacement.padEnd(original.length, " ") };
|
|
1092
|
+
});
|
|
1093
|
+
return applyFixedWidthAsarPatches(asarPath, archive, patches, "Claude plan-usage gateway request");
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function patchFastModeAuthGate(asarPath) {
|
|
1097
|
+
const archive = fs.readFileSync(asarPath);
|
|
1098
|
+
const source = archive.toString("latin1");
|
|
1099
|
+
const patches = [...source.matchAll(FAST_MODE_AUTH_GATE)].map((match) => {
|
|
1100
|
+
const replacement = `${match[1]}=!${match[3]}`;
|
|
1101
|
+
return {
|
|
1102
|
+
offset: match.index,
|
|
1103
|
+
original: match[0],
|
|
1104
|
+
replacement: replacement.padEnd(match[0].length, " "),
|
|
1105
|
+
};
|
|
1106
|
+
});
|
|
1107
|
+
return applyFixedWidthAsarPatches(asarPath, archive, patches, "ChatGPT/Codex Fast-mode eligibility contract");
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function applyFixedWidthAsarPatches(asarPath, archive, patches, label) {
|
|
1111
|
+
if (patches.length === 0) {
|
|
893
1112
|
throw new Error(`this desktop app version has an unsupported ${label}; update impel-cli before installing it`);
|
|
894
1113
|
}
|
|
895
|
-
const
|
|
896
|
-
const
|
|
1114
|
+
const metadata = readAsarMetadata(archive);
|
|
1115
|
+
const patchedArchive = Buffer.from(archive);
|
|
1116
|
+
for (const { offset, original, replacement } of patches) {
|
|
1117
|
+
if (Buffer.byteLength(original, "latin1") !== Buffer.byteLength(replacement, "latin1")) {
|
|
1118
|
+
throw new Error(`${label} replacement must preserve the vendor ASAR byte length`);
|
|
1119
|
+
}
|
|
1120
|
+
Buffer.from(replacement, "latin1").copy(patchedArchive, offset);
|
|
1121
|
+
}
|
|
1122
|
+
const entries = new Set(patches.map(({ offset }) => findAsarEntry(metadata.header, metadata.dataOffset, offset)));
|
|
897
1123
|
for (const entry of entries) updateAsarEntryIntegrity(patchedArchive, metadata.dataOffset, entry);
|
|
898
1124
|
const header = Buffer.from(JSON.stringify(metadata.header));
|
|
899
1125
|
if (header.length !== metadata.headerLength) {
|
|
@@ -901,7 +1127,7 @@ function patchFixedWidthAsarString(asarPath, original, replacement, label) {
|
|
|
901
1127
|
}
|
|
902
1128
|
header.copy(patchedArchive, 16);
|
|
903
1129
|
fs.writeFileSync(asarPath, patchedArchive);
|
|
904
|
-
return
|
|
1130
|
+
return patches.length;
|
|
905
1131
|
}
|
|
906
1132
|
|
|
907
1133
|
function cloneAppBundle(source, destination) {
|
|
@@ -1039,7 +1265,7 @@ function rebrandElectronHelpers(bundle, { fromName, toName, fromIdentifier, toId
|
|
|
1039
1265
|
}
|
|
1040
1266
|
}
|
|
1041
1267
|
|
|
1042
|
-
function vendoredChatGPTWrapperPlist(vendorPath) {
|
|
1268
|
+
function vendoredChatGPTWrapperPlist(vendorPath, asarHash) {
|
|
1043
1269
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1044
1270
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1045
1271
|
<plist version="1.0"><dict>
|
|
@@ -1050,6 +1276,7 @@ function vendoredChatGPTWrapperPlist(vendorPath) {
|
|
|
1050
1276
|
<key>CFBundleName</key><string>Impel ChatGPT</string>
|
|
1051
1277
|
<key>CFBundlePackageType</key><string>APPL</string>
|
|
1052
1278
|
<key>CFBundleShortVersionString</key><string>${xmlEscape(bundleVersion(vendorPath) || "1")}</string>
|
|
1279
|
+
<key>ElectronAsarIntegrity</key><dict><key>Resources/app.asar</key><dict><key>algorithm</key><string>SHA256</string><key>hash</key><string>${xmlEscape(asarHash)}</string></dict></dict>
|
|
1053
1280
|
<key>LSMinimumSystemVersion</key><string>12.0</string>
|
|
1054
1281
|
<key>NSHighResolutionCapable</key><true/>
|
|
1055
1282
|
</dict></plist>
|
|
@@ -1191,9 +1418,26 @@ function isMachO(file) {
|
|
|
1191
1418
|
}
|
|
1192
1419
|
|
|
1193
1420
|
function asarHeaderHash(file) {
|
|
1194
|
-
const
|
|
1195
|
-
|
|
1196
|
-
|
|
1421
|
+
const handle = fs.openSync(file, "r");
|
|
1422
|
+
try {
|
|
1423
|
+
const prefix = Buffer.alloc(16);
|
|
1424
|
+
if (fs.readSync(handle, prefix, 0, prefix.length, 0) !== prefix.length) {
|
|
1425
|
+
throw new Error("desktop app ASAR header is invalid");
|
|
1426
|
+
}
|
|
1427
|
+
const headerLength = prefix.readUInt32LE(12);
|
|
1428
|
+
const dataOffset = 8 + prefix.readUInt32LE(4);
|
|
1429
|
+
const fileSize = fs.fstatSync(handle).size;
|
|
1430
|
+
if (headerLength <= 0 || 16 + headerLength > dataOffset || dataOffset > fileSize) {
|
|
1431
|
+
throw new Error("desktop app ASAR offsets are invalid");
|
|
1432
|
+
}
|
|
1433
|
+
const header = Buffer.alloc(headerLength);
|
|
1434
|
+
if (fs.readSync(handle, header, 0, header.length, 16) !== header.length) {
|
|
1435
|
+
throw new Error("desktop app ASAR header is truncated");
|
|
1436
|
+
}
|
|
1437
|
+
return sha256(header);
|
|
1438
|
+
} finally {
|
|
1439
|
+
fs.closeSync(handle);
|
|
1440
|
+
}
|
|
1197
1441
|
}
|
|
1198
1442
|
|
|
1199
1443
|
function sha256(contents) {
|
|
@@ -1241,11 +1485,6 @@ function bundleVersion(bundle) {
|
|
|
1241
1485
|
}
|
|
1242
1486
|
}
|
|
1243
1487
|
|
|
1244
|
-
function findCommand(command) {
|
|
1245
|
-
const result = spawnSync("/usr/bin/which", [command], { encoding: "utf8" });
|
|
1246
|
-
return result.status === 0 ? result.stdout.trim() : null;
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
1488
|
function shellQuote(value) {
|
|
1250
1489
|
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
1251
1490
|
}
|
package/src/commands/apps.js
CHANGED
|
@@ -351,20 +351,20 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
351
351
|
}
|
|
352
352
|
|
|
353
353
|
const config = await selectedAppConfig(targets);
|
|
354
|
+
console.log(`Tenant: ${config.tenantId} (desktop history is isolated per tenant).`);
|
|
354
355
|
|
|
355
|
-
// A running vendor or Impel app makes the
|
|
356
|
+
// A running vendor or Impel app makes the pinned vendor install / bundle swap fail.
|
|
356
357
|
await quitBlockingApps(targets);
|
|
357
358
|
|
|
358
359
|
const vendorPaths = {};
|
|
359
360
|
if (!flags["skip-vendor"]) {
|
|
360
361
|
for (const target of targets) {
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
const result = ensureVendorApp(target, { update: true });
|
|
362
|
+
// Reuse or install only the exact vendor build verified by this CLI.
|
|
363
|
+
// Moving "latest" releases are never cloned into a managed bundle.
|
|
364
|
+
const result = ensureVendorApp(target);
|
|
365
365
|
vendorPaths[target] = result.path;
|
|
366
366
|
console.log(`${target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
|
|
367
|
-
if (!result.path) throw new Error(`${target} vendor app is unavailable;
|
|
367
|
+
if (!result.path) throw new Error(`${target} verified vendor app is unavailable; retry the pinned download`);
|
|
368
368
|
}
|
|
369
369
|
}
|
|
370
370
|
|
package/src/commands/setup.js
CHANGED
|
@@ -225,7 +225,7 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
225
225
|
|
|
226
226
|
// ── Step 3: apps ─────────────────────────────────────────────────────────
|
|
227
227
|
// Order inside `impel app install`: close running Claude/ChatGPT apps (a
|
|
228
|
-
// running app makes the install fail), bring the vendor apps to the
|
|
228
|
+
// running app makes the install fail), bring the vendor apps to the verified
|
|
229
229
|
// version, then build the vendored Impel bundles from that fresh copy.
|
|
230
230
|
// Native Claude Code and Codex profiles are never touched.
|
|
231
231
|
let platformSetupFailed = false;
|
|
@@ -301,7 +301,7 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
301
301
|
);
|
|
302
302
|
} else {
|
|
303
303
|
console.log(
|
|
304
|
-
"3/4 Installing the Impel desktop apps (running Claude/ChatGPT apps are closed, vendor
|
|
304
|
+
"3/4 Installing the Impel desktop apps (running Claude/ChatGPT apps are closed, verified vendor builds installed first)…"
|
|
305
305
|
);
|
|
306
306
|
try {
|
|
307
307
|
await io.installApps(["install", "all"]);
|
package/src/commands/tenant.js
CHANGED
|
@@ -65,6 +65,7 @@ export async function cmdTenant(argv) {
|
|
|
65
65
|
if (!tenantId) throw new Error("tenant use requires an org slug; run `impel tenant list`");
|
|
66
66
|
const { tenant } = await selectTenant(config, tenantId);
|
|
67
67
|
console.log(`Tenant -> ${tenant.id} (${tenant.name})`);
|
|
68
|
+
console.log("Local CLI and desktop history in other tenants is preserved and remains available when you switch back.");
|
|
68
69
|
|
|
69
70
|
const launch = flags.launch;
|
|
70
71
|
if (!launch) {
|
package/src/commands/update.js
CHANGED
|
@@ -196,7 +196,7 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
196
196
|
} else {
|
|
197
197
|
console.log(io.platform === "win32"
|
|
198
198
|
? "Apps: updating the signed Claude and ChatGPT vendor apps and isolated profiles…"
|
|
199
|
-
: "Apps: updating (running apps are closed, vendor
|
|
199
|
+
: "Apps: updating (running apps are closed, verified vendor builds installed, bundles rebuilt)…");
|
|
200
200
|
if (!io.runAppsUpdate()) {
|
|
201
201
|
console.error("impel update: the app update failed; re-run `impel app update` after fixing the issue.");
|
|
202
202
|
cascadeFailed = true;
|