impel-cli 0.7.2 → 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 -19
- package/package.json +1 -1
- package/src/apps.js +119 -33
- package/src/cli.js +8 -6
- package/src/commands/apps.js +128 -32
- package/src/commands/auth.js +4 -0
- package/src/commands/setup.js +1 -0
- package/src/commands/tenant.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,10 +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
|
+
`~/.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
|
|
244
252
|
tenant under `%LOCALAPPDATA%\Claude-3p\Impel\<org>` and launches the unchanged
|
|
245
253
|
signed vendor executable with that profile selected. Windows ChatGPT keeps its
|
|
246
254
|
Codex and browser state under the same tenant-scoped Impel apps root used on
|
|
@@ -575,21 +583,24 @@ app-target names.
|
|
|
575
583
|
|
|
576
584
|
### macOS Claude and ChatGPT
|
|
577
585
|
|
|
578
|
-
`impel app install` creates
|
|
579
|
-
|
|
580
|
-
|
|
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:
|
|
581
591
|
|
|
582
592
|
- Impel Claude is an APFS-cloned vendored copy of the official app with its own
|
|
583
593
|
bundle and helper identities. Its LaunchServices environment sets the
|
|
584
|
-
vendor-supported `CLAUDE_USER_DATA_DIR` and writes the 3P gateway
|
|
585
|
-
`~/.config/impel/apps/claude`. A narrowly
|
|
586
|
-
connects Claude's native plan-usage meter
|
|
587
|
-
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.
|
|
588
599
|
- Impel ChatGPT wraps an APFS-cloned official app while preserving the nested
|
|
589
600
|
OpenAI-signed bundle and executable. Its outer resource root contains the
|
|
590
601
|
narrowly patched renderer ASAR used at runtime. The wrapper sets `CODEX_HOME`,
|
|
591
602
|
passes a separate Chromium `--user-data-dir`, and writes its provider config
|
|
592
|
-
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
|
|
593
604
|
process-scoped, Codex-compatible wrapper around the Impel PAT so native
|
|
594
605
|
`/status` requests can read the organization's aggregated subscription pool
|
|
595
606
|
from the gateway. Its generated Codex config uses the same snapshot and
|
|
@@ -599,9 +610,22 @@ configuration:
|
|
|
599
610
|
- Neither app changes `~/.claude`, `~/.claude.json`, `~/.codex`, or the
|
|
600
611
|
normal vendor app's browser/profile data.
|
|
601
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
|
+
|
|
602
626
|
The official Claude and ChatGPT apps remain separate installations and keep
|
|
603
627
|
their normal accounts, updater, profiles, and bundle identities. Each CLI
|
|
604
|
-
release pins exact, tested vendor builds (v0.
|
|
628
|
+
release pins exact, tested vendor builds (v0.8.0 pins Claude `1.20186.9` and
|
|
605
629
|
ChatGPT `26.707.72221`) and their archive SHA-256 digests. The installer reuses
|
|
606
630
|
an existing official app only when its version exactly matches; otherwise it
|
|
607
631
|
downloads the pinned signed archive into Impel's managed vendor cache under
|
|
@@ -713,3 +737,15 @@ OpenAI Responses front door for SDK/Eve traffic and may normalize the body
|
|
|
713
737
|
- https://developers.openai.com/codex/config-advanced
|
|
714
738
|
- https://developers.openai.com/codex/auth
|
|
715
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,13 @@ 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
|
|
186
|
+
&& compatibility.profileRoot === status.profileRoot
|
|
187
|
+
&& (status.target !== "chatgpt" || (
|
|
188
|
+
compatibility.codexHome === status.codexHome
|
|
189
|
+
&& compatibility.browserData === status.browserData
|
|
190
|
+
))
|
|
147
191
|
&& managedBundleMatchesCompatibility(status, compatibility)
|
|
148
192
|
&& (status.target !== "chatgpt" || (
|
|
149
193
|
compatibility.vendorSignaturesPreserved === true
|
|
@@ -177,24 +221,28 @@ function managedBundleMatchesCompatibility(status, compatibility) {
|
|
|
177
221
|
// and codesign failures.
|
|
178
222
|
|
|
179
223
|
/** osascript application names whose bundles install/update will touch. */
|
|
180
|
-
export function blockingAppNames(targets) {
|
|
224
|
+
export function blockingAppNames(targets, { tenantId = null, tenantName = null } = {}) {
|
|
181
225
|
const names = [];
|
|
182
226
|
for (const target of targets) {
|
|
183
227
|
const definition = APP_DEFINITIONS[target];
|
|
184
228
|
for (const bundleName of definition.names) names.push(bundleName.replace(/\.app$/u, ""));
|
|
185
|
-
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);
|
|
186
233
|
}
|
|
187
|
-
return names;
|
|
234
|
+
return [...new Set(names)];
|
|
188
235
|
}
|
|
189
236
|
|
|
190
237
|
/**
|
|
191
238
|
* pgrep/pkill -f pattern for one app's processes. Anchored on
|
|
192
|
-
* "/<Name>.app
|
|
193
|
-
*
|
|
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.
|
|
194
242
|
*/
|
|
195
243
|
export function appProcessPattern(name) {
|
|
196
244
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
197
|
-
return `/${escaped}\\.app/Contents/MacOS/`;
|
|
245
|
+
return `/${escaped}\\.app/(.*/)?Contents/MacOS/`;
|
|
198
246
|
}
|
|
199
247
|
|
|
200
248
|
const QUIT_POLL_MS = 500;
|
|
@@ -205,6 +253,8 @@ const QUIT_POLL_MS = 500;
|
|
|
205
253
|
* telling the user to close it themselves if even that fails.
|
|
206
254
|
*/
|
|
207
255
|
export async function quitBlockingApps(targets, {
|
|
256
|
+
tenantId = null,
|
|
257
|
+
tenantName = null,
|
|
208
258
|
log = console.log,
|
|
209
259
|
isRunning = (name) =>
|
|
210
260
|
spawnSync("/usr/bin/pgrep", ["-f", appProcessPattern(name)], { stdio: "ignore" }).status === 0,
|
|
@@ -229,7 +279,7 @@ export async function quitBlockingApps(targets, {
|
|
|
229
279
|
return !isRunning(name);
|
|
230
280
|
}
|
|
231
281
|
|
|
232
|
-
for (const name of blockingAppNames(targets)) {
|
|
282
|
+
for (const name of blockingAppNames(targets, { tenantId, tenantName })) {
|
|
233
283
|
if (!isRunning(name)) continue;
|
|
234
284
|
log(`${name} is running; closing it before touching app bundles…`);
|
|
235
285
|
requestQuit(name);
|
|
@@ -241,26 +291,33 @@ export async function quitBlockingApps(targets, {
|
|
|
241
291
|
}
|
|
242
292
|
}
|
|
243
293
|
|
|
244
|
-
export function appPaths(homeDir = os.homedir(), tenantId = null, {
|
|
294
|
+
export function appPaths(homeDir = os.homedir(), tenantId = null, {
|
|
295
|
+
claudeUserData = null,
|
|
296
|
+
tenantName = null,
|
|
297
|
+
} = {}) {
|
|
245
298
|
const root = process.env.IMPEL_APP_HOME || path.join(homeDir, ".config", "impel", "apps");
|
|
246
299
|
const tenantRoot = tenantId
|
|
247
300
|
? path.join(root, "tenants", normalizeTenantId(tenantId))
|
|
248
301
|
: root;
|
|
302
|
+
const claudeIdentity = managedAppIdentity("claude", tenantId, tenantName);
|
|
303
|
+
const chatgptIdentity = managedAppIdentity("chatgpt", tenantId, tenantName);
|
|
249
304
|
return {
|
|
250
305
|
root,
|
|
251
306
|
tenantRoot,
|
|
252
307
|
tokenHelper: path.join(tenantRoot, "bin", "token"),
|
|
253
308
|
claude: {
|
|
309
|
+
...claudeIdentity,
|
|
254
310
|
root: claudeUserData || path.join(tenantRoot, "claude"),
|
|
255
311
|
userData: claudeUserData || path.join(tenantRoot, "claude", "user-data"),
|
|
256
|
-
launcher: path.join(homeDir, "Applications",
|
|
312
|
+
launcher: path.join(homeDir, "Applications", claudeIdentity.launcherName),
|
|
257
313
|
},
|
|
258
314
|
chatgpt: {
|
|
315
|
+
...chatgptIdentity,
|
|
259
316
|
root: path.join(tenantRoot, "chatgpt"),
|
|
260
317
|
codexHome: path.join(tenantRoot, "chatgpt", "codex-home"),
|
|
261
318
|
browserData: path.join(tenantRoot, "chatgpt", "browser-data"),
|
|
262
319
|
catalog: path.join(tenantRoot, "chatgpt", "codex-home", "models.json"),
|
|
263
|
-
launcher: path.join(homeDir, "Applications",
|
|
320
|
+
launcher: path.join(homeDir, "Applications", chatgptIdentity.launcherName),
|
|
264
321
|
},
|
|
265
322
|
};
|
|
266
323
|
}
|
|
@@ -304,7 +361,10 @@ export function installManagedAppFiles({
|
|
|
304
361
|
// true = rebuild every target's bundle; false = configs only; an array
|
|
305
362
|
// limits the expensive rebuild to just the stale targets.
|
|
306
363
|
const bundleTargets = new Set(writeBundles === true ? targets : writeBundles || []);
|
|
307
|
-
const paths = appPaths(homeDir, config.tenantId, {
|
|
364
|
+
const paths = appPaths(homeDir, config.tenantId, {
|
|
365
|
+
claudeUserData,
|
|
366
|
+
tenantName: config.tenantName,
|
|
367
|
+
});
|
|
308
368
|
if (targets.includes("chatgpt")) secureAllManagedCodexHomes({ appsRoot: paths.root });
|
|
309
369
|
fs.mkdirSync(paths.tenantRoot, { recursive: true, mode: 0o700 });
|
|
310
370
|
if (writeTokenHelperFile) writeTokenHelper(paths.tokenHelper, config.tenantId);
|
|
@@ -329,16 +389,22 @@ export function installManagedAppFiles({
|
|
|
329
389
|
}
|
|
330
390
|
if (target === "chatgpt") writeVendoredChatGPTBundle(paths, resolvedVendorPaths.chatgpt, config.gatewayUrl);
|
|
331
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
|
+
}
|
|
332
397
|
}
|
|
333
398
|
installed.push({ target, launcher: paths[target].launcher });
|
|
334
399
|
}
|
|
335
400
|
|
|
336
401
|
writeAtomic(path.join(paths.tenantRoot, "manifest.json"), JSON.stringify({
|
|
337
|
-
schemaVersion:
|
|
402
|
+
schemaVersion: 4,
|
|
338
403
|
configVersion: CURRENT_CONFIG_VERSION,
|
|
339
404
|
modelCatalogVersion: 3,
|
|
340
405
|
gatewayUrl: config.gatewayUrl,
|
|
341
406
|
tenantId: config.tenantId,
|
|
407
|
+
tenantName: config.tenantName || config.tenantId || null,
|
|
342
408
|
experiments: { crossAppModels: crossAppModelsEnabled(config) },
|
|
343
409
|
targets,
|
|
344
410
|
models: models.map((model) => model.id),
|
|
@@ -513,17 +579,24 @@ export function ensureVendorApp(target, { homeDir = os.homedir() } = {}) {
|
|
|
513
579
|
return { path: installed, action: "installed", note: `verified ${pin.version}` };
|
|
514
580
|
}
|
|
515
581
|
|
|
516
|
-
export function appStatus(targets, homeDir = os.homedir(), tenantId = null) {
|
|
517
|
-
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 });
|
|
518
587
|
return targets.map((target) => {
|
|
519
588
|
const vendorPath = findVendorApp(target, homeDir);
|
|
520
589
|
return {
|
|
521
590
|
target,
|
|
522
|
-
label:
|
|
591
|
+
label: paths[target].displayName,
|
|
523
592
|
launcher: paths[target].launcher,
|
|
524
593
|
launcherInstalled: fs.existsSync(paths[target].launcher),
|
|
594
|
+
bundleIdentifier: paths[target].bundleIdentifier,
|
|
595
|
+
profileRoot: target === "claude" ? paths.claude.userData : paths.chatgpt.root,
|
|
525
596
|
vendorPath,
|
|
526
597
|
vendorVersion: vendorPath ? bundleVersion(vendorPath) : null,
|
|
598
|
+
codexHome: target === "chatgpt" ? paths.chatgpt.codexHome : null,
|
|
599
|
+
browserData: target === "chatgpt" ? paths.chatgpt.browserData : null,
|
|
527
600
|
configPath: target === "claude"
|
|
528
601
|
? path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)
|
|
529
602
|
: path.join(paths.chatgpt.codexHome, "config.toml"),
|
|
@@ -535,9 +608,10 @@ export function removeManagedApps(targets, {
|
|
|
535
608
|
homeDir = os.homedir(),
|
|
536
609
|
keepData = false,
|
|
537
610
|
tenantId = null,
|
|
611
|
+
tenantName = null,
|
|
538
612
|
claudeUserData = null,
|
|
539
613
|
} = {}) {
|
|
540
|
-
const paths = appPaths(homeDir, tenantId, { claudeUserData });
|
|
614
|
+
const paths = appPaths(homeDir, tenantId, { claudeUserData, tenantName });
|
|
541
615
|
for (const target of targets) {
|
|
542
616
|
fs.rmSync(paths[target].launcher, { recursive: true, force: true });
|
|
543
617
|
if (!keepData) fs.rmSync(paths[target].root, { recursive: true, force: true });
|
|
@@ -844,13 +918,14 @@ function writeTokenHelper(target, tenantId) {
|
|
|
844
918
|
const node = shellQuote(process.execPath);
|
|
845
919
|
const cli = shellQuote(cliPath);
|
|
846
920
|
const tenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
|
|
921
|
+
const refreshTenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
|
|
847
922
|
// The apps invoke this helper whenever they need auth, which makes it the
|
|
848
923
|
// one hook that fires on Finder/Dock launches too. Piggyback a detached,
|
|
849
924
|
// TTL-gated config/catalog/skills refresh (`app refresh --stale-only`) so
|
|
850
925
|
// the managed apps keep themselves current without touching stdout — the
|
|
851
926
|
// caller consumes stdout as the token.
|
|
852
927
|
const script = `#!/bin/sh
|
|
853
|
-
(${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 &)
|
|
854
929
|
exec ${node} ${cli} token${tenantArgs}
|
|
855
930
|
`;
|
|
856
931
|
writeAtomic(target, script, 0o700);
|
|
@@ -892,27 +967,28 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
|
|
|
892
967
|
const newAsarHash = asarHeaderHash(asarPath);
|
|
893
968
|
|
|
894
969
|
updateBundleIdentity(plistPath, {
|
|
895
|
-
displayName:
|
|
970
|
+
displayName: paths.claude.displayName,
|
|
896
971
|
executable: executableName,
|
|
897
|
-
identifier:
|
|
898
|
-
name:
|
|
972
|
+
identifier: paths.claude.bundleIdentifier,
|
|
973
|
+
name: paths.claude.displayName,
|
|
899
974
|
});
|
|
900
975
|
updateAsarIntegrityPlist(plistPath, oldAsarHash, newAsarHash);
|
|
901
976
|
setPlistEnvironmentString(plistPath, "CLAUDE_USER_DATA_DIR", paths.claude.userData);
|
|
902
977
|
rebrandElectronHelpers(staging, {
|
|
903
978
|
fromName: "Claude",
|
|
904
|
-
toName:
|
|
979
|
+
toName: paths.claude.displayName,
|
|
905
980
|
fromIdentifier: "com.anthropic.claudefordesktop.helper",
|
|
906
|
-
toIdentifier:
|
|
981
|
+
toIdentifier: paths.claude.helperBundleIdentifier,
|
|
907
982
|
});
|
|
908
983
|
|
|
909
984
|
const macos = path.join(staging, "Contents", "MacOS");
|
|
910
985
|
writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
|
|
911
|
-
schemaVersion:
|
|
986
|
+
schemaVersion: 2,
|
|
912
987
|
cliVersion: CLI_VERSION,
|
|
913
988
|
vendorVersion: bundleVersion(vendorPath),
|
|
914
989
|
sourceBundle: vendorPath,
|
|
915
|
-
bundleIdentifier:
|
|
990
|
+
bundleIdentifier: paths.claude.bundleIdentifier,
|
|
991
|
+
displayName: paths.claude.displayName,
|
|
916
992
|
profileRoot: paths.claude.userData,
|
|
917
993
|
launchMode: "LSEnvironment",
|
|
918
994
|
patches: {
|
|
@@ -976,7 +1052,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
976
1052
|
const asarHash = asarHeaderHash(runtimeAsarPath);
|
|
977
1053
|
writeAtomic(
|
|
978
1054
|
path.join(staging, "Contents", "Info.plist"),
|
|
979
|
-
vendoredChatGPTWrapperPlist(vendorPath, asarHash),
|
|
1055
|
+
vendoredChatGPTWrapperPlist(vendorPath, asarHash, paths.chatgpt),
|
|
980
1056
|
0o644,
|
|
981
1057
|
);
|
|
982
1058
|
writeAtomic(
|
|
@@ -985,10 +1061,15 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
985
1061
|
0o755,
|
|
986
1062
|
);
|
|
987
1063
|
writeAtomic(path.join(staging, "Contents", "Resources", "impel-compatibility.json"), JSON.stringify({
|
|
988
|
-
schemaVersion:
|
|
1064
|
+
schemaVersion: 2,
|
|
989
1065
|
cliVersion: CLI_VERSION,
|
|
990
1066
|
vendorVersion: bundleVersion(vendorPath),
|
|
991
1067
|
sourceBundle: vendorPath,
|
|
1068
|
+
bundleIdentifier: paths.chatgpt.bundleIdentifier,
|
|
1069
|
+
displayName: paths.chatgpt.displayName,
|
|
1070
|
+
profileRoot: paths.chatgpt.root,
|
|
1071
|
+
codexHome: paths.chatgpt.codexHome,
|
|
1072
|
+
browserData: paths.chatgpt.browserData,
|
|
992
1073
|
patches: { fastModeForGatewayAuth: fastModePatchCount, desktopAPIForGatewayAuth: 0 },
|
|
993
1074
|
vendorSignaturesPreserved: true,
|
|
994
1075
|
resourceRoot: CHATGPT_RESOURCE_ROOT,
|
|
@@ -1254,15 +1335,15 @@ function rebrandElectronHelpers(bundle, { fromName, toName, fromIdentifier, toId
|
|
|
1254
1335
|
}
|
|
1255
1336
|
}
|
|
1256
1337
|
|
|
1257
|
-
function vendoredChatGPTWrapperPlist(vendorPath, asarHash) {
|
|
1338
|
+
function vendoredChatGPTWrapperPlist(vendorPath, asarHash, identity) {
|
|
1258
1339
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1259
1340
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1260
1341
|
<plist version="1.0"><dict>
|
|
1261
|
-
<key>CFBundleDisplayName</key><string
|
|
1342
|
+
<key>CFBundleDisplayName</key><string>${xmlEscape(identity.displayName)}</string>
|
|
1262
1343
|
<key>CFBundleExecutable</key><string>launch</string>
|
|
1263
1344
|
<key>CFBundleIconFile</key><string>electron.icns</string>
|
|
1264
|
-
<key>CFBundleIdentifier</key><string
|
|
1265
|
-
<key>CFBundleName</key><string
|
|
1345
|
+
<key>CFBundleIdentifier</key><string>${xmlEscape(identity.bundleIdentifier)}</string>
|
|
1346
|
+
<key>CFBundleName</key><string>${xmlEscape(identity.displayName)}</string>
|
|
1266
1347
|
<key>CFBundlePackageType</key><string>APPL</string>
|
|
1267
1348
|
<key>CFBundleShortVersionString</key><string>${xmlEscape(bundleVersion(vendorPath) || "1")}</string>
|
|
1268
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>
|
|
@@ -1361,9 +1442,14 @@ function signVendoredClaudeCapabilities(bundle) {
|
|
|
1361
1442
|
try {
|
|
1362
1443
|
const frameworks = path.join(bundle, "Contents", "Frameworks");
|
|
1363
1444
|
const helpers = fs.readdirSync(frameworks, { withFileTypes: true })
|
|
1364
|
-
.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
|
+
));
|
|
1365
1451
|
for (const helper of helpers) {
|
|
1366
|
-
const entitlements = helper.name
|
|
1452
|
+
const entitlements = helper.name.endsWith(" Helper.app") ? fullPath : jitPath;
|
|
1367
1453
|
const result = codesign([
|
|
1368
1454
|
"--force", "--deep", "--options", "runtime", "--entitlements", entitlements, "--sign", "-",
|
|
1369
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,10 +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);
|
|
405
|
+
console.log(`Tenant: ${config.tenantId} (desktop history is isolated per tenant).`);
|
|
354
406
|
|
|
355
407
|
// A running vendor or Impel app makes the pinned vendor install / bundle swap fail.
|
|
356
|
-
await quitBlockingApps(targets
|
|
408
|
+
await quitBlockingApps(targets, {
|
|
409
|
+
tenantId: config.tenantId,
|
|
410
|
+
tenantName: config.tenantName,
|
|
411
|
+
});
|
|
357
412
|
|
|
358
413
|
const vendorPaths = {};
|
|
359
414
|
if (!flags["skip-vendor"]) {
|
|
@@ -381,7 +436,7 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
381
436
|
|
|
382
437
|
// Best-effort: sync the Bifrost shared skills into each installed isolated app
|
|
383
438
|
// profile. Never fails the install/update.
|
|
384
|
-
const paths = appPaths(os.homedir(), config.tenantId);
|
|
439
|
+
const paths = appPaths(os.homedir(), config.tenantId, { tenantName: config.tenantName });
|
|
385
440
|
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
386
441
|
for (const item of installed) {
|
|
387
442
|
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
@@ -414,7 +469,12 @@ export function fastOpenLaunchers(targets, { homeDir = os.homedir(), config = lo
|
|
|
414
469
|
return null;
|
|
415
470
|
}
|
|
416
471
|
const launchers = [];
|
|
417
|
-
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
|
+
)) {
|
|
418
478
|
if (!bundleIsCurrent(status)) return null;
|
|
419
479
|
if (!fs.existsSync(status.configPath)) return null;
|
|
420
480
|
if (status.target === "claude" && !claudeConfigIsCurrent(status.configPath, config, gatewayUrl)) {
|
|
@@ -444,19 +504,29 @@ function claudeConfigIsCurrent(configPath, config, gatewayUrl) {
|
|
|
444
504
|
// run (config writes only, never a bundle swap, never quits anything) and
|
|
445
505
|
// silent on any failure: it is invoked detached from the apps' token helper,
|
|
446
506
|
// so there is no user to talk to and nothing may leak to stdout.
|
|
447
|
-
async function refreshApps(targets, { staleOnly = false } = {}) {
|
|
507
|
+
async function refreshApps(targets, { staleOnly = false, tenantId = null } = {}) {
|
|
448
508
|
const stored = loadConfig();
|
|
449
509
|
if (!stored?.pat) return;
|
|
450
|
-
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
|
+
});
|
|
451
521
|
if (staleOnly && manifestIsFresh(paths, stored)) return;
|
|
452
522
|
|
|
453
523
|
let config;
|
|
454
524
|
try {
|
|
455
|
-
config = await selectedAppConfig(targets);
|
|
525
|
+
config = await selectedAppConfig(targets, tenantId ? requestedTenantId : null);
|
|
456
526
|
} catch {
|
|
457
527
|
return;
|
|
458
528
|
}
|
|
459
|
-
const statuses = appStatus(targets, os.homedir(), config.tenantId);
|
|
529
|
+
const statuses = appStatus(targets, os.homedir(), config.tenantId, config.tenantName);
|
|
460
530
|
const installedTargets = statuses.filter((status) => status.launcherInstalled);
|
|
461
531
|
if (installedTargets.length === 0) return;
|
|
462
532
|
|
|
@@ -475,7 +545,7 @@ async function refreshApps(targets, { staleOnly = false } = {}) {
|
|
|
475
545
|
writeBundles: false,
|
|
476
546
|
});
|
|
477
547
|
|
|
478
|
-
const tenantPaths = appPaths(os.homedir(), config.tenantId);
|
|
548
|
+
const tenantPaths = appPaths(os.homedir(), config.tenantId, { tenantName: config.tenantName });
|
|
479
549
|
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
480
550
|
for (const status of installedTargets) {
|
|
481
551
|
const { client, env, label } = appSkillTarget(status.target, tenantPaths);
|
|
@@ -504,10 +574,28 @@ function manifestIsFresh(paths, config, now = Date.now()) {
|
|
|
504
574
|
}
|
|
505
575
|
}
|
|
506
576
|
|
|
507
|
-
async function selectedAppConfig(targets) {
|
|
577
|
+
async function selectedAppConfig(targets, tenantOverride = null) {
|
|
508
578
|
const config = loadConfig();
|
|
509
579
|
if (!config?.pat || !config?.gatewayUrl) throw new Error("run `impel auth` before using Impel apps");
|
|
510
|
-
|
|
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
|
+
}
|
|
511
599
|
if (!selected.productAccess) {
|
|
512
600
|
throw new Error("the control plane did not return a live product access entitlement; retry after it is upgraded");
|
|
513
601
|
}
|
|
@@ -519,16 +607,24 @@ async function selectedAppConfig(targets) {
|
|
|
519
607
|
return {
|
|
520
608
|
...config,
|
|
521
609
|
tenantId,
|
|
610
|
+
tenantName: selected.tenantName || selected.tenant?.name || tenantId,
|
|
522
611
|
productAccess: selected.productAccess,
|
|
523
612
|
scopes: selected.scopes,
|
|
524
613
|
pat: tenantCredential(config.pat, tenantId),
|
|
525
614
|
};
|
|
526
615
|
}
|
|
527
616
|
|
|
528
|
-
function printStatus(targets) {
|
|
617
|
+
function printStatus(targets, tenantOverride = null) {
|
|
529
618
|
const config = loadConfig();
|
|
530
|
-
|
|
531
|
-
|
|
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)) {
|
|
532
628
|
console.log(`${status.label}:`);
|
|
533
629
|
console.log(` launcher: ${status.launcherInstalled ? status.launcher : "not installed"}`);
|
|
534
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/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
|
@@ -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) {
|