impel-cli 0.7.3 → 0.8.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 +55 -21
- package/package.json +1 -1
- package/src/apps.js +109 -34
- package/src/cli.js +8 -6
- package/src/commands/apps.js +127 -32
- package/src/commands/auth.js +4 -0
- package/src/commands/setup.js +1 -0
- package/src/commands/update.js +5 -1
- package/src/config.js +1 -0
- package/src/tenants.js +11 -0
- package/src/updates.js +7 -2
package/README.md
CHANGED
|
@@ -145,18 +145,22 @@ impel off [claude|codex|all] Alias for `impel use account`
|
|
|
145
145
|
impel skills sync [claude|codex|all] Sync Bifrost shared skills into managed clients (default: all)
|
|
146
146
|
|
|
147
147
|
impel status Launcher readiness + native mode + gateway reachability
|
|
148
|
-
impel app install [
|
|
149
|
-
impel app update [
|
|
150
|
-
impel app refresh [
|
|
148
|
+
impel app install [target] [--tenant <org>] Install isolated apps for one tenant
|
|
149
|
+
impel app update [target] [--tenant <org>] Refresh that tenant's apps, configs, and models
|
|
150
|
+
impel app refresh [target] [--tenant <org>] Configs/catalog/skills only; safe while apps run
|
|
151
151
|
(--stale-only: no-op unless 6h+ since last sync)
|
|
152
|
-
impel app status [
|
|
153
|
-
impel app open [
|
|
152
|
+
impel app status [target] [--tenant <org>] Show tenant launcher/vendor status
|
|
153
|
+
impel app open [target] [--tenant <org>] Launch isolated instances (fast path: opens
|
|
154
154
|
immediately when nothing changed since last open;
|
|
155
155
|
a detached stale-only refresh keeps things current)
|
|
156
|
-
impel app uninstall [target] [--keep-data]
|
|
156
|
+
impel app uninstall [target] [--tenant <org>] [--keep-data]
|
|
157
|
+
Remove one tenant's Impel-managed app files
|
|
157
158
|
impel help Show help
|
|
158
159
|
impel --version Show version
|
|
159
160
|
|
|
161
|
+
For app commands, `target` is `claude`, `chatgpt`/`codex`, or `all` (the
|
|
162
|
+
default). `--tenant` defaults to the currently selected organization.
|
|
163
|
+
|
|
160
164
|
# `impel setup` no longer takes a per-tool target and never flips native
|
|
161
165
|
# configs; flip a native tool explicitly with `impel use gateway [claude|codex]`.
|
|
162
166
|
```
|
|
@@ -237,12 +241,14 @@ not bleed between tenants. `impel tenant use <org> --launch claude|codex|apps`
|
|
|
237
241
|
selects and launches in one command.
|
|
238
242
|
|
|
239
243
|
On macOS, desktop app state is isolated under
|
|
240
|
-
`~/.config/impel/apps/tenants/<org>/`.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
`~/.config/impel/apps/tenants/<org>/`. Every installed tenant keeps its own app
|
|
245
|
+
bundles in `~/Applications`, with names such as `Impel Claude (Acme)` and
|
|
246
|
+
`Impel ChatGPT (Acme)`, stable tenant-specific bundle identifiers, and fixed
|
|
247
|
+
tenant profile/auth paths. Different tenants can stay open concurrently.
|
|
248
|
+
`impel app open --tenant <org>` refreshes and launches a tenant directly without
|
|
249
|
+
changing the globally selected tenant. Switching tenants never deletes local
|
|
250
|
+
history or projects; each tenant's preserved profile reopens independently.
|
|
251
|
+
Windows Claude Desktop keeps each
|
|
246
252
|
tenant under `%LOCALAPPDATA%\Claude-3p\Impel\<org>` and launches the unchanged
|
|
247
253
|
signed vendor executable with that profile selected. Windows ChatGPT keeps its
|
|
248
254
|
Codex and browser state under the same tenant-scoped Impel apps root used on
|
|
@@ -577,21 +583,24 @@ app-target names.
|
|
|
577
583
|
|
|
578
584
|
### macOS Claude and ChatGPT
|
|
579
585
|
|
|
580
|
-
`impel app install` creates
|
|
581
|
-
|
|
582
|
-
|
|
586
|
+
`impel app install` creates tenant-specific apps such as
|
|
587
|
+
`Impel Claude (acme).app` and `Impel ChatGPT (acme).app` in `~/Applications`.
|
|
588
|
+
Their visible bundle names use the organization display name, for example
|
|
589
|
+
`Impel Claude (Acme)` and `Impel ChatGPT (Acme)`. Each tenant/product pair has
|
|
590
|
+
a stable, unique bundle identifier and gateway configuration:
|
|
583
591
|
|
|
584
592
|
- Impel Claude is an APFS-cloned vendored copy of the official app with its own
|
|
585
593
|
bundle and helper identities. Its LaunchServices environment sets the
|
|
586
|
-
vendor-supported `CLAUDE_USER_DATA_DIR` and writes the 3P gateway
|
|
587
|
-
`~/.config/impel/apps/claude`. A narrowly
|
|
588
|
-
connects Claude's native plan-usage meter
|
|
589
|
-
Claude subscription pool exposed by the Impel
|
|
594
|
+
vendor-supported `CLAUDE_USER_DATA_DIR` and writes the 3P gateway
|
|
595
|
+
`configLibrary` below `~/.config/impel/apps/tenants/<org>/claude`. A narrowly
|
|
596
|
+
version-checked compatibility patch connects Claude's native plan-usage meter
|
|
597
|
+
to the authenticated aggregate Claude subscription pool exposed by the Impel
|
|
598
|
+
gateway.
|
|
590
599
|
- Impel ChatGPT wraps an APFS-cloned official app while preserving the nested
|
|
591
600
|
OpenAI-signed bundle and executable. Its outer resource root contains the
|
|
592
601
|
narrowly patched renderer ASAR used at runtime. The wrapper sets `CODEX_HOME`,
|
|
593
602
|
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
|
|
603
|
+
and model catalog below `~/.config/impel/apps/tenants/<org>/chatgpt`. It also supplies a
|
|
595
604
|
process-scoped, Codex-compatible wrapper around the Impel PAT so native
|
|
596
605
|
`/status` requests can read the organization's aggregated subscription pool
|
|
597
606
|
from the gateway. Its generated Codex config uses the same snapshot and
|
|
@@ -601,9 +610,22 @@ configuration:
|
|
|
601
610
|
- Neither app changes `~/.claude`, `~/.claude.json`, `~/.codex`, or the
|
|
602
611
|
normal vendor app's browser/profile data.
|
|
603
612
|
|
|
613
|
+
Install or open several tenants without changing the current selection:
|
|
614
|
+
|
|
615
|
+
```sh
|
|
616
|
+
impel app install all --tenant acme
|
|
617
|
+
impel app install all --tenant globex
|
|
618
|
+
impel app open all --tenant acme
|
|
619
|
+
impel app open all --tenant globex
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
Updates and uninstalls touch only the named tenant variant. The older global
|
|
623
|
+
`Impel Claude.app` / `Impel ChatGPT.app` launcher is removed after the first
|
|
624
|
+
successful tenant-specific rebuild.
|
|
625
|
+
|
|
604
626
|
The official Claude and ChatGPT apps remain separate installations and keep
|
|
605
627
|
their normal accounts, updater, profiles, and bundle identities. Each CLI
|
|
606
|
-
release pins exact, tested vendor builds (v0.
|
|
628
|
+
release pins exact, tested vendor builds (v0.8.0 pins Claude `1.20186.9` and
|
|
607
629
|
ChatGPT `26.707.72221`) and their archive SHA-256 digests. The installer reuses
|
|
608
630
|
an existing official app only when its version exactly matches; otherwise it
|
|
609
631
|
downloads the pinned signed archive into Impel's managed vendor cache under
|
|
@@ -715,3 +737,15 @@ OpenAI Responses front door for SDK/Eve traffic and may normalize the body
|
|
|
715
737
|
- https://developers.openai.com/codex/config-advanced
|
|
716
738
|
- https://developers.openai.com/codex/auth
|
|
717
739
|
- https://developers.openai.com/codex/ide
|
|
740
|
+
|
|
741
|
+
## Sources consulted for concurrent tenant apps
|
|
742
|
+
|
|
743
|
+
- Electron v43.1.0 release: https://github.com/electron/electron/releases/tag/v43.1.0
|
|
744
|
+
- Electron applies `--user-data-dir` before app JavaScript:
|
|
745
|
+
https://github.com/electron/electron/blob/b5c102b3f0f7e9c5be064ba337a3547a83cf7d09/shell/app/electron_main_delegate.cc#L247-L256
|
|
746
|
+
- Electron scopes its single-instance lock to the resolved user-data directory:
|
|
747
|
+
https://github.com/electron/electron/blob/b5c102b3f0f7e9c5be064ba337a3547a83cf7d09/shell/browser/api/electron_api_app.cc#L1049-L1073
|
|
748
|
+
- Electron macOS bundle rebranding requirements:
|
|
749
|
+
https://github.com/electron/electron/blob/b5c102b3f0f7e9c5be064ba337a3547a83cf7d09/docs/tutorial/application-distribution.md#L79-L96
|
|
750
|
+
- Apple `CFBundleIdentifier` reference:
|
|
751
|
+
https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundleidentifier
|
package/package.json
CHANGED
package/src/apps.js
CHANGED
|
@@ -21,11 +21,13 @@ const CLI_VERSION = JSON.parse(
|
|
|
21
21
|
const APP_DEFINITIONS = {
|
|
22
22
|
claude: {
|
|
23
23
|
label: "Impel Claude",
|
|
24
|
+
bundleIdentifier: "com.useimpel.claude",
|
|
24
25
|
names: ["Claude.app"],
|
|
25
26
|
executableNames: ["Claude"],
|
|
26
27
|
},
|
|
27
28
|
chatgpt: {
|
|
28
29
|
label: "Impel ChatGPT",
|
|
30
|
+
bundleIdentifier: "com.useimpel.chatgpt",
|
|
29
31
|
names: ["ChatGPT.app", "Codex.app"],
|
|
30
32
|
executableNames: ["ChatGPT", "Codex"],
|
|
31
33
|
},
|
|
@@ -103,9 +105,44 @@ export function normalizeAppTarget(value) {
|
|
|
103
105
|
return null;
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
function tenantAppName(value, fallback) {
|
|
109
|
+
const name = redactSecretText(String(value || fallback))
|
|
110
|
+
.normalize("NFC")
|
|
111
|
+
.replace(/[/:]/gu, "-")
|
|
112
|
+
.replace(/\s+/gu, " ")
|
|
113
|
+
.trim()
|
|
114
|
+
.slice(0, 128);
|
|
115
|
+
return name || fallback;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Stable macOS name and LaunchServices identity for one tenant-bound app. */
|
|
119
|
+
export function managedAppIdentity(target, tenantId = null, tenantName = null) {
|
|
120
|
+
const definition = APP_DEFINITIONS[target];
|
|
121
|
+
if (!definition) throw new Error(`unknown app target "${redactSecretText(target)}"`);
|
|
122
|
+
if (!tenantId) {
|
|
123
|
+
return {
|
|
124
|
+
displayName: definition.label,
|
|
125
|
+
launcherName: `${definition.label}.app`,
|
|
126
|
+
bundleIdentifier: definition.bundleIdentifier,
|
|
127
|
+
helperBundleIdentifier: `${definition.bundleIdentifier}.helper`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const normalizedTenantId = normalizeTenantId(tenantId);
|
|
131
|
+
const displayName = `${definition.label} (${tenantAppName(tenantName, normalizedTenantId)})`;
|
|
132
|
+
const tenantKey = crypto.createHash("sha256").update(normalizedTenantId, "utf8").digest("hex").slice(0, 20);
|
|
133
|
+
return {
|
|
134
|
+
displayName,
|
|
135
|
+
// Keep the on-disk path stable if an organization is renamed; Finder,
|
|
136
|
+
// Launchpad, and the Dock use CFBundleDisplayName below for the human name.
|
|
137
|
+
launcherName: `${definition.label} (${normalizedTenantId}).app`,
|
|
138
|
+
bundleIdentifier: `${definition.bundleIdentifier}.tenant.${tenantKey}`,
|
|
139
|
+
helperBundleIdentifier: `${definition.bundleIdentifier}.tenant.${tenantKey}.helper`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
// Bump when the written config/manifest schema changes; a mismatch forces the
|
|
107
144
|
// slow open path (and thus a full config rewrite) after a CLI update.
|
|
108
|
-
export const CURRENT_CONFIG_VERSION =
|
|
145
|
+
export const CURRENT_CONFIG_VERSION = 9;
|
|
109
146
|
|
|
110
147
|
/** Parse the tenant's install manifest, or null when absent/corrupt. */
|
|
111
148
|
export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
|
|
@@ -144,6 +181,8 @@ export function bundleIsCurrent(status) {
|
|
|
144
181
|
compatibility
|
|
145
182
|
&& compatibility.cliVersion === CLI_VERSION
|
|
146
183
|
&& compatibility.vendorVersion === status.vendorVersion
|
|
184
|
+
&& compatibility.bundleIdentifier === status.bundleIdentifier
|
|
185
|
+
&& compatibility.displayName === status.label
|
|
147
186
|
&& compatibility.profileRoot === status.profileRoot
|
|
148
187
|
&& (status.target !== "chatgpt" || (
|
|
149
188
|
compatibility.codexHome === status.codexHome
|
|
@@ -182,24 +221,28 @@ function managedBundleMatchesCompatibility(status, compatibility) {
|
|
|
182
221
|
// and codesign failures.
|
|
183
222
|
|
|
184
223
|
/** osascript application names whose bundles install/update will touch. */
|
|
185
|
-
export function blockingAppNames(targets) {
|
|
224
|
+
export function blockingAppNames(targets, { tenantId = null, tenantName = null } = {}) {
|
|
186
225
|
const names = [];
|
|
187
226
|
for (const target of targets) {
|
|
188
227
|
const definition = APP_DEFINITIONS[target];
|
|
189
228
|
for (const bundleName of definition.names) names.push(bundleName.replace(/\.app$/u, ""));
|
|
190
|
-
names.push(
|
|
229
|
+
names.push(managedAppIdentity(target, tenantId, tenantName).launcherName.replace(/\.app$/u, ""));
|
|
230
|
+
// The pre-v0.8 launcher was global. Close it too while a tenant-specific
|
|
231
|
+
// bundle replaces/migrates it, but never close another tenant's variant.
|
|
232
|
+
if (tenantId) names.push(definition.label);
|
|
191
233
|
}
|
|
192
|
-
return names;
|
|
234
|
+
return [...new Set(names)];
|
|
193
235
|
}
|
|
194
236
|
|
|
195
237
|
/**
|
|
196
238
|
* pgrep/pkill -f pattern for one app's processes. Anchored on
|
|
197
|
-
* "/<Name>.app
|
|
198
|
-
*
|
|
239
|
+
* "/<Name>.app/.../Contents/MacOS/" so "Claude" never matches the
|
|
240
|
+
* space-prefixed tenant launcher (and vice versa), while nested Electron
|
|
241
|
+
* executables and helpers still count as processes belonging to the wrapper.
|
|
199
242
|
*/
|
|
200
243
|
export function appProcessPattern(name) {
|
|
201
244
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
202
|
-
return `/${escaped}\\.app/Contents/MacOS/`;
|
|
245
|
+
return `/${escaped}\\.app/(.*/)?Contents/MacOS/`;
|
|
203
246
|
}
|
|
204
247
|
|
|
205
248
|
const QUIT_POLL_MS = 500;
|
|
@@ -210,6 +253,8 @@ const QUIT_POLL_MS = 500;
|
|
|
210
253
|
* telling the user to close it themselves if even that fails.
|
|
211
254
|
*/
|
|
212
255
|
export async function quitBlockingApps(targets, {
|
|
256
|
+
tenantId = null,
|
|
257
|
+
tenantName = null,
|
|
213
258
|
log = console.log,
|
|
214
259
|
isRunning = (name) =>
|
|
215
260
|
spawnSync("/usr/bin/pgrep", ["-f", appProcessPattern(name)], { stdio: "ignore" }).status === 0,
|
|
@@ -234,7 +279,7 @@ export async function quitBlockingApps(targets, {
|
|
|
234
279
|
return !isRunning(name);
|
|
235
280
|
}
|
|
236
281
|
|
|
237
|
-
for (const name of blockingAppNames(targets)) {
|
|
282
|
+
for (const name of blockingAppNames(targets, { tenantId, tenantName })) {
|
|
238
283
|
if (!isRunning(name)) continue;
|
|
239
284
|
log(`${name} is running; closing it before touching app bundles…`);
|
|
240
285
|
requestQuit(name);
|
|
@@ -246,26 +291,33 @@ export async function quitBlockingApps(targets, {
|
|
|
246
291
|
}
|
|
247
292
|
}
|
|
248
293
|
|
|
249
|
-
export function appPaths(homeDir = os.homedir(), tenantId = null, {
|
|
294
|
+
export function appPaths(homeDir = os.homedir(), tenantId = null, {
|
|
295
|
+
claudeUserData = null,
|
|
296
|
+
tenantName = null,
|
|
297
|
+
} = {}) {
|
|
250
298
|
const root = process.env.IMPEL_APP_HOME || path.join(homeDir, ".config", "impel", "apps");
|
|
251
299
|
const tenantRoot = tenantId
|
|
252
300
|
? path.join(root, "tenants", normalizeTenantId(tenantId))
|
|
253
301
|
: root;
|
|
302
|
+
const claudeIdentity = managedAppIdentity("claude", tenantId, tenantName);
|
|
303
|
+
const chatgptIdentity = managedAppIdentity("chatgpt", tenantId, tenantName);
|
|
254
304
|
return {
|
|
255
305
|
root,
|
|
256
306
|
tenantRoot,
|
|
257
307
|
tokenHelper: path.join(tenantRoot, "bin", "token"),
|
|
258
308
|
claude: {
|
|
309
|
+
...claudeIdentity,
|
|
259
310
|
root: claudeUserData || path.join(tenantRoot, "claude"),
|
|
260
311
|
userData: claudeUserData || path.join(tenantRoot, "claude", "user-data"),
|
|
261
|
-
launcher: path.join(homeDir, "Applications",
|
|
312
|
+
launcher: path.join(homeDir, "Applications", claudeIdentity.launcherName),
|
|
262
313
|
},
|
|
263
314
|
chatgpt: {
|
|
315
|
+
...chatgptIdentity,
|
|
264
316
|
root: path.join(tenantRoot, "chatgpt"),
|
|
265
317
|
codexHome: path.join(tenantRoot, "chatgpt", "codex-home"),
|
|
266
318
|
browserData: path.join(tenantRoot, "chatgpt", "browser-data"),
|
|
267
319
|
catalog: path.join(tenantRoot, "chatgpt", "codex-home", "models.json"),
|
|
268
|
-
launcher: path.join(homeDir, "Applications",
|
|
320
|
+
launcher: path.join(homeDir, "Applications", chatgptIdentity.launcherName),
|
|
269
321
|
},
|
|
270
322
|
};
|
|
271
323
|
}
|
|
@@ -309,7 +361,10 @@ export function installManagedAppFiles({
|
|
|
309
361
|
// true = rebuild every target's bundle; false = configs only; an array
|
|
310
362
|
// limits the expensive rebuild to just the stale targets.
|
|
311
363
|
const bundleTargets = new Set(writeBundles === true ? targets : writeBundles || []);
|
|
312
|
-
const paths = appPaths(homeDir, config.tenantId, {
|
|
364
|
+
const paths = appPaths(homeDir, config.tenantId, {
|
|
365
|
+
claudeUserData,
|
|
366
|
+
tenantName: config.tenantName,
|
|
367
|
+
});
|
|
313
368
|
if (targets.includes("chatgpt")) secureAllManagedCodexHomes({ appsRoot: paths.root });
|
|
314
369
|
fs.mkdirSync(paths.tenantRoot, { recursive: true, mode: 0o700 });
|
|
315
370
|
if (writeTokenHelperFile) writeTokenHelper(paths.tokenHelper, config.tenantId);
|
|
@@ -334,16 +389,22 @@ export function installManagedAppFiles({
|
|
|
334
389
|
}
|
|
335
390
|
if (target === "chatgpt") writeVendoredChatGPTBundle(paths, resolvedVendorPaths.chatgpt, config.gatewayUrl);
|
|
336
391
|
else writeVendoredClaudeBundle(paths, resolvedVendorPaths.claude, config.gatewayUrl);
|
|
392
|
+
if (config.tenantId) {
|
|
393
|
+
// A successful tenant-specific rebuild supersedes the old global
|
|
394
|
+
// launcher. Other tenant launchers have distinct names and survive.
|
|
395
|
+
fs.rmSync(appPaths(homeDir)[target].launcher, { recursive: true, force: true });
|
|
396
|
+
}
|
|
337
397
|
}
|
|
338
398
|
installed.push({ target, launcher: paths[target].launcher });
|
|
339
399
|
}
|
|
340
400
|
|
|
341
401
|
writeAtomic(path.join(paths.tenantRoot, "manifest.json"), JSON.stringify({
|
|
342
|
-
schemaVersion:
|
|
402
|
+
schemaVersion: 4,
|
|
343
403
|
configVersion: CURRENT_CONFIG_VERSION,
|
|
344
404
|
modelCatalogVersion: 3,
|
|
345
405
|
gatewayUrl: config.gatewayUrl,
|
|
346
406
|
tenantId: config.tenantId,
|
|
407
|
+
tenantName: config.tenantName || config.tenantId || null,
|
|
347
408
|
experiments: { crossAppModels: crossAppModelsEnabled(config) },
|
|
348
409
|
targets,
|
|
349
410
|
models: models.map((model) => model.id),
|
|
@@ -518,18 +579,22 @@ export function ensureVendorApp(target, { homeDir = os.homedir() } = {}) {
|
|
|
518
579
|
return { path: installed, action: "installed", note: `verified ${pin.version}` };
|
|
519
580
|
}
|
|
520
581
|
|
|
521
|
-
export function appStatus(targets, homeDir = os.homedir(), tenantId = null) {
|
|
522
|
-
const
|
|
582
|
+
export function appStatus(targets, homeDir = os.homedir(), tenantId = null, tenantName = null) {
|
|
583
|
+
const manifestName = tenantId && !tenantName
|
|
584
|
+
? readTenantManifest(homeDir, tenantId)?.tenantName
|
|
585
|
+
: null;
|
|
586
|
+
const paths = appPaths(homeDir, tenantId, { tenantName: tenantName || manifestName });
|
|
523
587
|
return targets.map((target) => {
|
|
524
588
|
const vendorPath = findVendorApp(target, homeDir);
|
|
525
589
|
return {
|
|
526
590
|
target,
|
|
527
|
-
label:
|
|
591
|
+
label: paths[target].displayName,
|
|
528
592
|
launcher: paths[target].launcher,
|
|
529
593
|
launcherInstalled: fs.existsSync(paths[target].launcher),
|
|
594
|
+
bundleIdentifier: paths[target].bundleIdentifier,
|
|
595
|
+
profileRoot: target === "claude" ? paths.claude.userData : paths.chatgpt.root,
|
|
530
596
|
vendorPath,
|
|
531
597
|
vendorVersion: vendorPath ? bundleVersion(vendorPath) : null,
|
|
532
|
-
profileRoot: target === "claude" ? paths.claude.userData : paths.chatgpt.root,
|
|
533
598
|
codexHome: target === "chatgpt" ? paths.chatgpt.codexHome : null,
|
|
534
599
|
browserData: target === "chatgpt" ? paths.chatgpt.browserData : null,
|
|
535
600
|
configPath: target === "claude"
|
|
@@ -543,9 +608,10 @@ export function removeManagedApps(targets, {
|
|
|
543
608
|
homeDir = os.homedir(),
|
|
544
609
|
keepData = false,
|
|
545
610
|
tenantId = null,
|
|
611
|
+
tenantName = null,
|
|
546
612
|
claudeUserData = null,
|
|
547
613
|
} = {}) {
|
|
548
|
-
const paths = appPaths(homeDir, tenantId, { claudeUserData });
|
|
614
|
+
const paths = appPaths(homeDir, tenantId, { claudeUserData, tenantName });
|
|
549
615
|
for (const target of targets) {
|
|
550
616
|
fs.rmSync(paths[target].launcher, { recursive: true, force: true });
|
|
551
617
|
if (!keepData) fs.rmSync(paths[target].root, { recursive: true, force: true });
|
|
@@ -852,13 +918,14 @@ function writeTokenHelper(target, tenantId) {
|
|
|
852
918
|
const node = shellQuote(process.execPath);
|
|
853
919
|
const cli = shellQuote(cliPath);
|
|
854
920
|
const tenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
|
|
921
|
+
const refreshTenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
|
|
855
922
|
// The apps invoke this helper whenever they need auth, which makes it the
|
|
856
923
|
// one hook that fires on Finder/Dock launches too. Piggyback a detached,
|
|
857
924
|
// TTL-gated config/catalog/skills refresh (`app refresh --stale-only`) so
|
|
858
925
|
// the managed apps keep themselves current without touching stdout — the
|
|
859
926
|
// caller consumes stdout as the token.
|
|
860
927
|
const script = `#!/bin/sh
|
|
861
|
-
(${node} ${cli} app refresh --stale-only </dev/null >/dev/null 2>&1 &)
|
|
928
|
+
(${node} ${cli} app refresh --stale-only${refreshTenantArgs} </dev/null >/dev/null 2>&1 &)
|
|
862
929
|
exec ${node} ${cli} token${tenantArgs}
|
|
863
930
|
`;
|
|
864
931
|
writeAtomic(target, script, 0o700);
|
|
@@ -900,27 +967,28 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
|
|
|
900
967
|
const newAsarHash = asarHeaderHash(asarPath);
|
|
901
968
|
|
|
902
969
|
updateBundleIdentity(plistPath, {
|
|
903
|
-
displayName:
|
|
970
|
+
displayName: paths.claude.displayName,
|
|
904
971
|
executable: executableName,
|
|
905
|
-
identifier:
|
|
906
|
-
name:
|
|
972
|
+
identifier: paths.claude.bundleIdentifier,
|
|
973
|
+
name: paths.claude.displayName,
|
|
907
974
|
});
|
|
908
975
|
updateAsarIntegrityPlist(plistPath, oldAsarHash, newAsarHash);
|
|
909
976
|
setPlistEnvironmentString(plistPath, "CLAUDE_USER_DATA_DIR", paths.claude.userData);
|
|
910
977
|
rebrandElectronHelpers(staging, {
|
|
911
978
|
fromName: "Claude",
|
|
912
|
-
toName:
|
|
979
|
+
toName: paths.claude.displayName,
|
|
913
980
|
fromIdentifier: "com.anthropic.claudefordesktop.helper",
|
|
914
|
-
toIdentifier:
|
|
981
|
+
toIdentifier: paths.claude.helperBundleIdentifier,
|
|
915
982
|
});
|
|
916
983
|
|
|
917
984
|
const macos = path.join(staging, "Contents", "MacOS");
|
|
918
985
|
writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
|
|
919
|
-
schemaVersion:
|
|
986
|
+
schemaVersion: 2,
|
|
920
987
|
cliVersion: CLI_VERSION,
|
|
921
988
|
vendorVersion: bundleVersion(vendorPath),
|
|
922
989
|
sourceBundle: vendorPath,
|
|
923
|
-
bundleIdentifier:
|
|
990
|
+
bundleIdentifier: paths.claude.bundleIdentifier,
|
|
991
|
+
displayName: paths.claude.displayName,
|
|
924
992
|
profileRoot: paths.claude.userData,
|
|
925
993
|
launchMode: "LSEnvironment",
|
|
926
994
|
patches: {
|
|
@@ -984,7 +1052,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
984
1052
|
const asarHash = asarHeaderHash(runtimeAsarPath);
|
|
985
1053
|
writeAtomic(
|
|
986
1054
|
path.join(staging, "Contents", "Info.plist"),
|
|
987
|
-
vendoredChatGPTWrapperPlist(vendorPath, asarHash),
|
|
1055
|
+
vendoredChatGPTWrapperPlist(vendorPath, asarHash, paths.chatgpt),
|
|
988
1056
|
0o644,
|
|
989
1057
|
);
|
|
990
1058
|
writeAtomic(
|
|
@@ -993,10 +1061,12 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
993
1061
|
0o755,
|
|
994
1062
|
);
|
|
995
1063
|
writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
|
|
996
|
-
schemaVersion:
|
|
1064
|
+
schemaVersion: 2,
|
|
997
1065
|
cliVersion: CLI_VERSION,
|
|
998
1066
|
vendorVersion: bundleVersion(vendorPath),
|
|
999
1067
|
sourceBundle: vendorPath,
|
|
1068
|
+
bundleIdentifier: paths.chatgpt.bundleIdentifier,
|
|
1069
|
+
displayName: paths.chatgpt.displayName,
|
|
1000
1070
|
profileRoot: paths.chatgpt.root,
|
|
1001
1071
|
codexHome: paths.chatgpt.codexHome,
|
|
1002
1072
|
browserData: paths.chatgpt.browserData,
|
|
@@ -1265,15 +1335,15 @@ function rebrandElectronHelpers(bundle, { fromName, toName, fromIdentifier, toId
|
|
|
1265
1335
|
}
|
|
1266
1336
|
}
|
|
1267
1337
|
|
|
1268
|
-
function vendoredChatGPTWrapperPlist(vendorPath, asarHash) {
|
|
1338
|
+
function vendoredChatGPTWrapperPlist(vendorPath, asarHash, identity) {
|
|
1269
1339
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1270
1340
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1271
1341
|
<plist version="1.0"><dict>
|
|
1272
|
-
<key>CFBundleDisplayName</key><string
|
|
1342
|
+
<key>CFBundleDisplayName</key><string>${xmlEscape(identity.displayName)}</string>
|
|
1273
1343
|
<key>CFBundleExecutable</key><string>launch</string>
|
|
1274
1344
|
<key>CFBundleIconFile</key><string>electron.icns</string>
|
|
1275
|
-
<key>CFBundleIdentifier</key><string
|
|
1276
|
-
<key>CFBundleName</key><string
|
|
1345
|
+
<key>CFBundleIdentifier</key><string>${xmlEscape(identity.bundleIdentifier)}</string>
|
|
1346
|
+
<key>CFBundleName</key><string>${xmlEscape(identity.displayName)}</string>
|
|
1277
1347
|
<key>CFBundlePackageType</key><string>APPL</string>
|
|
1278
1348
|
<key>CFBundleShortVersionString</key><string>${xmlEscape(bundleVersion(vendorPath) || "1")}</string>
|
|
1279
1349
|
<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>
|
|
@@ -1372,9 +1442,14 @@ function signVendoredClaudeCapabilities(bundle) {
|
|
|
1372
1442
|
try {
|
|
1373
1443
|
const frameworks = path.join(bundle, "Contents", "Frameworks");
|
|
1374
1444
|
const helpers = fs.readdirSync(frameworks, { withFileTypes: true })
|
|
1375
|
-
.filter((entry) =>
|
|
1445
|
+
.filter((entry) => (
|
|
1446
|
+
entry.isDirectory()
|
|
1447
|
+
&& entry.name.startsWith("Impel Claude")
|
|
1448
|
+
&& entry.name.includes(" Helper")
|
|
1449
|
+
&& entry.name.endsWith(".app")
|
|
1450
|
+
));
|
|
1376
1451
|
for (const helper of helpers) {
|
|
1377
|
-
const entitlements = helper.name
|
|
1452
|
+
const entitlements = helper.name.endsWith(" Helper.app") ? fullPath : jitPath;
|
|
1378
1453
|
const result = codesign([
|
|
1379
1454
|
"--force", "--deep", "--options", "runtime", "--entitlements", entitlements, "--sign", "-",
|
|
1380
1455
|
path.join(frameworks, helper.name),
|
package/src/cli.js
CHANGED
|
@@ -28,7 +28,7 @@ Get started:
|
|
|
28
28
|
Run:
|
|
29
29
|
impel claude [args...] Launch Claude Code with an isolated Impel profile
|
|
30
30
|
impel codex [args...] Launch Codex with an isolated Impel profile
|
|
31
|
-
impel app open [
|
|
31
|
+
impel app open [target] [--tenant <org>] Open that tenant's isolated desktop app(s)
|
|
32
32
|
impel status Tenant, PAT, per-tool mode, gateway reachability
|
|
33
33
|
impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
|
|
34
34
|
|
|
@@ -41,11 +41,13 @@ Manage:
|
|
|
41
41
|
impel skills sync [claude|codex|all] Sync shared skills into managed clients
|
|
42
42
|
|
|
43
43
|
Desktop apps (macOS and Windows):
|
|
44
|
-
|
|
45
|
-
impel app
|
|
46
|
-
impel app
|
|
47
|
-
impel app
|
|
48
|
-
impel app
|
|
44
|
+
target is claude, chatgpt/codex, or all (default: all)
|
|
45
|
+
impel app install [target] [--tenant <org>] Install that tenant's isolated app/profile
|
|
46
|
+
impel app update [target] [--tenant <org>] Update vendor apps and managed profiles
|
|
47
|
+
impel app refresh [target] [--tenant <org>] Configs/catalog/skills only; safe while apps run
|
|
48
|
+
impel app status [target] [--tenant <org>] Show isolated app and vendor versions
|
|
49
|
+
impel app uninstall [target] [--tenant <org>] [--keep-data]
|
|
50
|
+
Remove only that tenant's managed apps/data
|
|
49
51
|
|
|
50
52
|
Native profile switching (advanced; setup never touches native tools):
|
|
51
53
|
impel use gateway [claude|codex|all] Route native Claude Code/Codex through the gateway
|
package/src/commands/apps.js
CHANGED
|
@@ -15,7 +15,13 @@ import {
|
|
|
15
15
|
refreshUpdateCache,
|
|
16
16
|
spawnDetachedAppRefresh,
|
|
17
17
|
} from "../updates.js";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
assertProviderScopes,
|
|
20
|
+
ensureTenantSelection,
|
|
21
|
+
fetchTenants,
|
|
22
|
+
normalizeTenantId,
|
|
23
|
+
tenantCredential,
|
|
24
|
+
} from "../tenants.js";
|
|
19
25
|
import {
|
|
20
26
|
CURRENT_CONFIG_VERSION,
|
|
21
27
|
CLAUDE_CONFIG_ID,
|
|
@@ -111,6 +117,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
111
117
|
"skip-vendor": { type: "boolean" },
|
|
112
118
|
"keep-data": { type: "boolean" },
|
|
113
119
|
"stale-only": { type: "boolean" },
|
|
120
|
+
tenant: { type: "string" },
|
|
114
121
|
});
|
|
115
122
|
const io = {
|
|
116
123
|
environment: process.env,
|
|
@@ -140,13 +147,20 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
140
147
|
if (overrides.launchApp) io.launchClaudeApp = overrides.launchApp;
|
|
141
148
|
if (overrides.userData) io.claudeUserData = overrides.userData;
|
|
142
149
|
const stored = loadConfig();
|
|
143
|
-
const
|
|
144
|
-
|
|
150
|
+
const localTenantId = flags.tenant
|
|
151
|
+
? normalizeTenantId(flags.tenant)
|
|
152
|
+
: stored?.tenantId || null;
|
|
153
|
+
const localTenantName = localTenantId === stored?.tenantId ? stored?.tenantName : null;
|
|
154
|
+
const storedUserData = io.claudeUserData(io.environment, localTenantId);
|
|
155
|
+
const paths = appPaths(io.homeDir, localTenantId, {
|
|
156
|
+
claudeUserData: storedUserData,
|
|
157
|
+
tenantName: localTenantName,
|
|
158
|
+
});
|
|
145
159
|
const claudeConfigPath = path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`);
|
|
146
160
|
const chatgptConfigPath = path.join(paths.chatgpt.codexHome, "config.toml");
|
|
147
161
|
|
|
148
162
|
if (action === "status") {
|
|
149
|
-
console.log(`Tenant: ${
|
|
163
|
+
console.log(`Tenant: ${localTenantId || "not selected"}`);
|
|
150
164
|
if (targets.includes("claude")) {
|
|
151
165
|
console.log("Impel Claude:");
|
|
152
166
|
console.log(` profile: ${fs.existsSync(claudeConfigPath) ? paths.claude.userData : "not installed"}`);
|
|
@@ -167,7 +181,8 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
167
181
|
io.removeApps(targets, {
|
|
168
182
|
homeDir: io.homeDir,
|
|
169
183
|
keepData: Boolean(flags["keep-data"]),
|
|
170
|
-
tenantId:
|
|
184
|
+
tenantId: localTenantId,
|
|
185
|
+
tenantName: localTenantName,
|
|
171
186
|
claudeUserData: storedUserData,
|
|
172
187
|
});
|
|
173
188
|
if (targets.includes("chatgpt")) io.removeManagedChatGPTApp(paths.root);
|
|
@@ -186,9 +201,12 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
186
201
|
|
|
187
202
|
try {
|
|
188
203
|
if (action === "open") maybePrintUpdateNotice();
|
|
189
|
-
const config = await io.selectedConfig(targets);
|
|
204
|
+
const config = await io.selectedConfig(targets, flags.tenant || null);
|
|
190
205
|
const actionUserData = io.claudeUserData(io.environment, config.tenantId);
|
|
191
|
-
const actionPaths = appPaths(io.homeDir, config.tenantId, {
|
|
206
|
+
const actionPaths = appPaths(io.homeDir, config.tenantId, {
|
|
207
|
+
claudeUserData: actionUserData,
|
|
208
|
+
tenantName: config.tenantName,
|
|
209
|
+
});
|
|
192
210
|
const vendorPaths = {};
|
|
193
211
|
for (const target of targets) {
|
|
194
212
|
const isClaude = target === "claude";
|
|
@@ -230,7 +248,10 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
230
248
|
}
|
|
231
249
|
|
|
232
250
|
const { userData } = configureWindowsApps(config, targets, vendorPaths, catalog, io);
|
|
233
|
-
const tenantPaths = appPaths(io.homeDir, config.tenantId, {
|
|
251
|
+
const tenantPaths = appPaths(io.homeDir, config.tenantId, {
|
|
252
|
+
claudeUserData: userData,
|
|
253
|
+
tenantName: config.tenantName,
|
|
254
|
+
});
|
|
234
255
|
if (action === "install" || action === "update" || action === "refresh") {
|
|
235
256
|
const verb = action === "install" ? "Installed" : action === "update" ? "Updated" : "Refreshed";
|
|
236
257
|
for (const target of targets) {
|
|
@@ -282,17 +303,25 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
282
303
|
"skip-vendor": { type: "boolean" },
|
|
283
304
|
"keep-data": { type: "boolean" },
|
|
284
305
|
"stale-only": { type: "boolean" },
|
|
306
|
+
tenant: { type: "string" },
|
|
285
307
|
});
|
|
286
308
|
|
|
287
|
-
if (action === "status") return printStatus(targets);
|
|
309
|
+
if (action === "status") return printStatus(targets, flags.tenant || null);
|
|
288
310
|
if (action === "refresh") {
|
|
289
|
-
return refreshApps(targets, {
|
|
311
|
+
return refreshApps(targets, {
|
|
312
|
+
staleOnly: Boolean(flags["stale-only"]),
|
|
313
|
+
tenantId: flags.tenant || null,
|
|
314
|
+
});
|
|
290
315
|
}
|
|
291
316
|
if (action === "uninstall") {
|
|
292
317
|
const config = loadConfig();
|
|
318
|
+
const tenantId = flags.tenant
|
|
319
|
+
? normalizeTenantId(flags.tenant)
|
|
320
|
+
: config?.tenantId || null;
|
|
293
321
|
removeManagedApps(targets, {
|
|
294
322
|
keepData: Boolean(flags["keep-data"]),
|
|
295
|
-
tenantId
|
|
323
|
+
tenantId,
|
|
324
|
+
tenantName: tenantId === config?.tenantId ? config?.tenantName : null,
|
|
296
325
|
});
|
|
297
326
|
for (const target of targets) console.log(`Removed Impel ${target} app${flags["keep-data"] ? " (kept isolated data)" : " and isolated data"}.`);
|
|
298
327
|
return;
|
|
@@ -304,19 +333,36 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
304
333
|
// credentials, there is nothing to fetch or rebuild — launch immediately
|
|
305
334
|
// and let a detached stale-only refresh keep configs/catalog/skills
|
|
306
335
|
// converging in the background.
|
|
307
|
-
const
|
|
336
|
+
const stored = loadConfig();
|
|
337
|
+
const tenantId = flags.tenant
|
|
338
|
+
? normalizeTenantId(flags.tenant)
|
|
339
|
+
: stored?.tenantId || null;
|
|
340
|
+
const manifest = tenantId ? readTenantManifest(os.homedir(), tenantId) : null;
|
|
341
|
+
const openConfig = stored && tenantId
|
|
342
|
+
? {
|
|
343
|
+
...stored,
|
|
344
|
+
tenantId,
|
|
345
|
+
tenantName: tenantId === stored.tenantId
|
|
346
|
+
? manifest?.tenantName || stored.tenantName || tenantId
|
|
347
|
+
: manifest?.tenantName || tenantId,
|
|
348
|
+
}
|
|
349
|
+
: stored;
|
|
350
|
+
const fastLaunchers = fastOpenLaunchers(targets, { config: openConfig });
|
|
308
351
|
if (fastLaunchers) {
|
|
309
|
-
spawnDetachedAppRefresh();
|
|
352
|
+
spawnDetachedAppRefresh(tenantId);
|
|
310
353
|
for (const launcher of fastLaunchers) {
|
|
311
354
|
spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" });
|
|
312
355
|
}
|
|
313
356
|
return;
|
|
314
357
|
}
|
|
315
358
|
|
|
316
|
-
const config = await selectedAppConfig(targets);
|
|
317
|
-
const statuses = appStatus(targets, os.homedir(), config.tenantId);
|
|
359
|
+
const config = await selectedAppConfig(targets, flags.tenant || null);
|
|
360
|
+
const statuses = appStatus(targets, os.homedir(), config.tenantId, config.tenantName);
|
|
361
|
+
const legacyPaths = appPaths(os.homedir());
|
|
318
362
|
for (const status of statuses) {
|
|
319
|
-
if (!status.launcherInstalled
|
|
363
|
+
if (!status.launcherInstalled && !fs.existsSync(legacyPaths[status.target].launcher)) {
|
|
364
|
+
throw new Error(`${status.label} is not installed; run \`impel app install ${status.target}\``);
|
|
365
|
+
}
|
|
320
366
|
if (!status.vendorPath) throw new Error(`${status.label} vendor app is unavailable; reinstall the vendor app first`);
|
|
321
367
|
}
|
|
322
368
|
let catalog;
|
|
@@ -332,7 +378,12 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
332
378
|
const staleBundleTargets = statuses
|
|
333
379
|
.filter((status) => !bundleIsCurrent(status))
|
|
334
380
|
.map((status) => status.target);
|
|
335
|
-
if (staleBundleTargets.length > 0)
|
|
381
|
+
if (staleBundleTargets.length > 0) {
|
|
382
|
+
await quitBlockingApps(staleBundleTargets, {
|
|
383
|
+
tenantId: config.tenantId,
|
|
384
|
+
tenantName: config.tenantName,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
336
387
|
installManagedAppFiles({
|
|
337
388
|
config,
|
|
338
389
|
targets,
|
|
@@ -341,7 +392,7 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
341
392
|
vendorPaths: Object.fromEntries(statuses.map((status) => [status.target, status.vendorPath])),
|
|
342
393
|
writeBundles: staleBundleTargets,
|
|
343
394
|
});
|
|
344
|
-
for (const status of appStatus(targets, os.homedir(), config.tenantId)) {
|
|
395
|
+
for (const status of appStatus(targets, os.homedir(), config.tenantId, config.tenantName)) {
|
|
345
396
|
spawnSync("/usr/bin/open", ["-n", status.launcher], { stdio: "inherit" });
|
|
346
397
|
}
|
|
347
398
|
return;
|
|
@@ -350,11 +401,14 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
350
401
|
throw new Error(`unknown app action "${action}"; use install, update, refresh, status, open, or uninstall`);
|
|
351
402
|
}
|
|
352
403
|
|
|
353
|
-
const config = await selectedAppConfig(targets);
|
|
404
|
+
const config = await selectedAppConfig(targets, flags.tenant || null);
|
|
354
405
|
console.log(`Tenant: ${config.tenantId} (desktop history is isolated per tenant).`);
|
|
355
406
|
|
|
356
407
|
// A running vendor or Impel app makes the pinned vendor install / bundle swap fail.
|
|
357
|
-
await quitBlockingApps(targets
|
|
408
|
+
await quitBlockingApps(targets, {
|
|
409
|
+
tenantId: config.tenantId,
|
|
410
|
+
tenantName: config.tenantName,
|
|
411
|
+
});
|
|
358
412
|
|
|
359
413
|
const vendorPaths = {};
|
|
360
414
|
if (!flags["skip-vendor"]) {
|
|
@@ -382,7 +436,7 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
382
436
|
|
|
383
437
|
// Best-effort: sync the Bifrost shared skills into each installed isolated app
|
|
384
438
|
// profile. Never fails the install/update.
|
|
385
|
-
const paths = appPaths(os.homedir(), config.tenantId);
|
|
439
|
+
const paths = appPaths(os.homedir(), config.tenantId, { tenantName: config.tenantName });
|
|
386
440
|
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
387
441
|
for (const item of installed) {
|
|
388
442
|
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
@@ -415,7 +469,12 @@ export function fastOpenLaunchers(targets, { homeDir = os.homedir(), config = lo
|
|
|
415
469
|
return null;
|
|
416
470
|
}
|
|
417
471
|
const launchers = [];
|
|
418
|
-
for (const status of statuses ?? appStatus(
|
|
472
|
+
for (const status of statuses ?? appStatus(
|
|
473
|
+
targets,
|
|
474
|
+
homeDir,
|
|
475
|
+
config.tenantId,
|
|
476
|
+
config.tenantName || manifest.tenantName,
|
|
477
|
+
)) {
|
|
419
478
|
if (!bundleIsCurrent(status)) return null;
|
|
420
479
|
if (!fs.existsSync(status.configPath)) return null;
|
|
421
480
|
if (status.target === "claude" && !claudeConfigIsCurrent(status.configPath, config, gatewayUrl)) {
|
|
@@ -445,19 +504,29 @@ function claudeConfigIsCurrent(configPath, config, gatewayUrl) {
|
|
|
445
504
|
// run (config writes only, never a bundle swap, never quits anything) and
|
|
446
505
|
// silent on any failure: it is invoked detached from the apps' token helper,
|
|
447
506
|
// so there is no user to talk to and nothing may leak to stdout.
|
|
448
|
-
async function refreshApps(targets, { staleOnly = false } = {}) {
|
|
507
|
+
async function refreshApps(targets, { staleOnly = false, tenantId = null } = {}) {
|
|
449
508
|
const stored = loadConfig();
|
|
450
509
|
if (!stored?.pat) return;
|
|
451
|
-
const
|
|
510
|
+
const requestedTenantId = tenantId
|
|
511
|
+
? normalizeTenantId(tenantId)
|
|
512
|
+
: stored.tenantId || null;
|
|
513
|
+
const manifest = requestedTenantId
|
|
514
|
+
? readTenantManifest(os.homedir(), requestedTenantId)
|
|
515
|
+
: null;
|
|
516
|
+
const paths = appPaths(os.homedir(), requestedTenantId, {
|
|
517
|
+
tenantName: requestedTenantId === stored.tenantId
|
|
518
|
+
? stored.tenantName || manifest?.tenantName
|
|
519
|
+
: manifest?.tenantName,
|
|
520
|
+
});
|
|
452
521
|
if (staleOnly && manifestIsFresh(paths, stored)) return;
|
|
453
522
|
|
|
454
523
|
let config;
|
|
455
524
|
try {
|
|
456
|
-
config = await selectedAppConfig(targets);
|
|
525
|
+
config = await selectedAppConfig(targets, tenantId ? requestedTenantId : null);
|
|
457
526
|
} catch {
|
|
458
527
|
return;
|
|
459
528
|
}
|
|
460
|
-
const statuses = appStatus(targets, os.homedir(), config.tenantId);
|
|
529
|
+
const statuses = appStatus(targets, os.homedir(), config.tenantId, config.tenantName);
|
|
461
530
|
const installedTargets = statuses.filter((status) => status.launcherInstalled);
|
|
462
531
|
if (installedTargets.length === 0) return;
|
|
463
532
|
|
|
@@ -476,7 +545,7 @@ async function refreshApps(targets, { staleOnly = false } = {}) {
|
|
|
476
545
|
writeBundles: false,
|
|
477
546
|
});
|
|
478
547
|
|
|
479
|
-
const tenantPaths = appPaths(os.homedir(), config.tenantId);
|
|
548
|
+
const tenantPaths = appPaths(os.homedir(), config.tenantId, { tenantName: config.tenantName });
|
|
480
549
|
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
481
550
|
for (const status of installedTargets) {
|
|
482
551
|
const { client, env, label } = appSkillTarget(status.target, tenantPaths);
|
|
@@ -505,10 +574,28 @@ function manifestIsFresh(paths, config, now = Date.now()) {
|
|
|
505
574
|
}
|
|
506
575
|
}
|
|
507
576
|
|
|
508
|
-
async function selectedAppConfig(targets) {
|
|
577
|
+
async function selectedAppConfig(targets, tenantOverride = null) {
|
|
509
578
|
const config = loadConfig();
|
|
510
579
|
if (!config?.pat || !config?.gatewayUrl) throw new Error("run `impel auth` before using Impel apps");
|
|
511
|
-
|
|
580
|
+
let selected;
|
|
581
|
+
if (tenantOverride) {
|
|
582
|
+
const requested = normalizeTenantId(tenantOverride);
|
|
583
|
+
const listing = await fetchTenants(config);
|
|
584
|
+
const tenant = listing.tenants.find((candidate) => (
|
|
585
|
+
candidate.id === requested || candidate.slug === requested
|
|
586
|
+
));
|
|
587
|
+
if (!tenant) throw new Error(`tenant "${requested}" is not available to this user`);
|
|
588
|
+
selected = {
|
|
589
|
+
config,
|
|
590
|
+
tenantId: tenant.id,
|
|
591
|
+
tenantName: tenant.name,
|
|
592
|
+
tenant,
|
|
593
|
+
productAccess: listing.productAccess,
|
|
594
|
+
scopes: listing.scopes,
|
|
595
|
+
};
|
|
596
|
+
} else {
|
|
597
|
+
selected = await ensureTenantSelection(config, { refresh: true });
|
|
598
|
+
}
|
|
512
599
|
if (!selected.productAccess) {
|
|
513
600
|
throw new Error("the control plane did not return a live product access entitlement; retry after it is upgraded");
|
|
514
601
|
}
|
|
@@ -520,16 +607,24 @@ async function selectedAppConfig(targets) {
|
|
|
520
607
|
return {
|
|
521
608
|
...config,
|
|
522
609
|
tenantId,
|
|
610
|
+
tenantName: selected.tenantName || selected.tenant?.name || tenantId,
|
|
523
611
|
productAccess: selected.productAccess,
|
|
524
612
|
scopes: selected.scopes,
|
|
525
613
|
pat: tenantCredential(config.pat, tenantId),
|
|
526
614
|
};
|
|
527
615
|
}
|
|
528
616
|
|
|
529
|
-
function printStatus(targets) {
|
|
617
|
+
function printStatus(targets, tenantOverride = null) {
|
|
530
618
|
const config = loadConfig();
|
|
531
|
-
|
|
532
|
-
|
|
619
|
+
const tenantId = tenantOverride
|
|
620
|
+
? normalizeTenantId(tenantOverride)
|
|
621
|
+
: config?.tenantId || null;
|
|
622
|
+
const manifest = tenantId ? readTenantManifest(os.homedir(), tenantId) : null;
|
|
623
|
+
const tenantName = tenantId === config?.tenantId
|
|
624
|
+
? manifest?.tenantName || config?.tenantName
|
|
625
|
+
: manifest?.tenantName;
|
|
626
|
+
if (tenantId) console.log(`Tenant: ${tenantId}${tenantName && tenantName !== tenantId ? ` (${tenantName})` : ""}`);
|
|
627
|
+
for (const status of appStatus(targets, os.homedir(), tenantId, tenantName)) {
|
|
533
628
|
console.log(`${status.label}:`);
|
|
534
629
|
console.log(` launcher: ${status.launcherInstalled ? status.launcher : "not installed"}`);
|
|
535
630
|
console.log(` vendor: ${status.vendorPath || "not installed"}${status.vendorVersion ? ` (${status.vendorVersion})` : ""}`);
|
package/src/commands/auth.js
CHANGED
|
@@ -53,6 +53,8 @@ export async function cmdAuth(argv) {
|
|
|
53
53
|
? existing.tenantId
|
|
54
54
|
: listing.defaultTenantId
|
|
55
55
|
);
|
|
56
|
+
config.tenantName = listing.tenants.find((tenant) => tenant.id === config.tenantId)?.name
|
|
57
|
+
|| config.tenantId;
|
|
56
58
|
if (listing.productAccess) config.productAccess = listing.productAccess;
|
|
57
59
|
else delete config.productAccess;
|
|
58
60
|
if (listing.scopes) config.scopes = listing.scopes;
|
|
@@ -62,8 +64,10 @@ export async function cmdAuth(argv) {
|
|
|
62
64
|
if (flags.tenant) throw error;
|
|
63
65
|
if (existing?.tenantId && existing.pat === pat) {
|
|
64
66
|
config.tenantId = existing.tenantId;
|
|
67
|
+
config.tenantName = existing.tenantName || existing.tenantId;
|
|
65
68
|
} else {
|
|
66
69
|
delete config.tenantId;
|
|
70
|
+
delete config.tenantName;
|
|
67
71
|
delete config.productAccess;
|
|
68
72
|
delete config.scopes;
|
|
69
73
|
}
|
package/src/commands/setup.js
CHANGED
|
@@ -214,6 +214,7 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
config.tenantId = tenant.id;
|
|
217
|
+
config.tenantName = tenant.name;
|
|
217
218
|
if (listing.productAccess) config.productAccess = listing.productAccess;
|
|
218
219
|
else delete config.productAccess;
|
|
219
220
|
if (listing.scopes) config.scopes = listing.scopes;
|
package/src/commands/update.js
CHANGED
|
@@ -118,8 +118,12 @@ function anyAppInstalled(homeDir = os.homedir()) {
|
|
|
118
118
|
|| fs.existsSync(path.join(paths.chatgpt.codexHome, "config.toml"))
|
|
119
119
|
);
|
|
120
120
|
}
|
|
121
|
+
const legacyPaths = appPaths(homeDir);
|
|
121
122
|
return (
|
|
122
|
-
fs.existsSync(paths.claude.launcher)
|
|
123
|
+
fs.existsSync(paths.claude.launcher)
|
|
124
|
+
|| fs.existsSync(paths.chatgpt.launcher)
|
|
125
|
+
|| fs.existsSync(legacyPaths.claude.launcher)
|
|
126
|
+
|| fs.existsSync(legacyPaths.chatgpt.launcher)
|
|
123
127
|
);
|
|
124
128
|
}
|
|
125
129
|
|
package/src/config.js
CHANGED
package/src/tenants.js
CHANGED
|
@@ -148,6 +148,12 @@ export async function ensureTenantSelection(config, { refresh = false } = {}) {
|
|
|
148
148
|
return {
|
|
149
149
|
config,
|
|
150
150
|
tenantId,
|
|
151
|
+
tenantName: config.tenantName || tenantId,
|
|
152
|
+
tenant: {
|
|
153
|
+
id: tenantId,
|
|
154
|
+
slug: tenantId,
|
|
155
|
+
name: config.tenantName || tenantId,
|
|
156
|
+
},
|
|
151
157
|
tenants: null,
|
|
152
158
|
defaultTenantId: null,
|
|
153
159
|
productAccess: normalizeProductAccess(config.productAccess, { allowMissing: true }),
|
|
@@ -159,7 +165,9 @@ export async function ensureTenantSelection(config, { refresh = false } = {}) {
|
|
|
159
165
|
const current = config.tenantId && listing.tenants.some((tenant) => tenant.id === config.tenantId)
|
|
160
166
|
? config.tenantId
|
|
161
167
|
: listing.defaultTenantId;
|
|
168
|
+
const tenant = listing.tenants.find((candidate) => candidate.id === current);
|
|
162
169
|
config.tenantId = current;
|
|
170
|
+
config.tenantName = tenant.name;
|
|
163
171
|
if (listing.productAccess) config.productAccess = listing.productAccess;
|
|
164
172
|
else delete config.productAccess;
|
|
165
173
|
if (listing.scopes) config.scopes = listing.scopes;
|
|
@@ -169,6 +177,8 @@ export async function ensureTenantSelection(config, { refresh = false } = {}) {
|
|
|
169
177
|
return {
|
|
170
178
|
config,
|
|
171
179
|
tenantId: current,
|
|
180
|
+
tenantName: tenant.name,
|
|
181
|
+
tenant,
|
|
172
182
|
tenants: listing.tenants,
|
|
173
183
|
defaultTenantId: listing.defaultTenantId,
|
|
174
184
|
productAccess: listing.productAccess,
|
|
@@ -184,6 +194,7 @@ export async function selectTenant(config, tenantId) {
|
|
|
184
194
|
throw new Error(`tenant "${selected}" is not available to this user`);
|
|
185
195
|
}
|
|
186
196
|
config.tenantId = tenant.id;
|
|
197
|
+
config.tenantName = tenant.name;
|
|
187
198
|
if (listing.productAccess) config.productAccess = listing.productAccess;
|
|
188
199
|
else delete config.productAccess;
|
|
189
200
|
if (listing.scopes) config.scopes = listing.scopes;
|
package/src/updates.js
CHANGED
|
@@ -164,8 +164,13 @@ export function spawnDetachedCacheRefresh() {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
/** Background config/catalog/skills convergence; no-ops inside the TTL. */
|
|
167
|
-
export function spawnDetachedAppRefresh() {
|
|
168
|
-
spawnDetached([
|
|
167
|
+
export function spawnDetachedAppRefresh(tenantId = null) {
|
|
168
|
+
spawnDetached([
|
|
169
|
+
"app",
|
|
170
|
+
"refresh",
|
|
171
|
+
"--stale-only",
|
|
172
|
+
...(tenantId ? ["--tenant", tenantId] : []),
|
|
173
|
+
]);
|
|
169
174
|
}
|
|
170
175
|
|
|
171
176
|
function spawnDetached(args) {
|