impel-cli 0.9.0 → 0.9.2
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 +32 -14
- package/package.json +1 -1
- package/src/cli.js +2 -1
- package/src/commands/apps.js +115 -37
- package/src/commands/pat.js +99 -13
package/README.md
CHANGED
|
@@ -124,6 +124,7 @@ impel auth [--pat <pat>] [--gateway <url>] [--app <url>]
|
|
|
124
124
|
impel pat create --label <label> [options] Mint a personal PAT through the control plane
|
|
125
125
|
impel pat create --agent <agent-id> [options]
|
|
126
126
|
Mint a PAT associated with an org agent
|
|
127
|
+
impel pat revoke <token-id> --yes Irreversibly revoke one of your PATs
|
|
127
128
|
impel token [--tenant <org>] Print a selected-tenant bearer
|
|
128
129
|
impel mcp Run the local MCP bridge used by Claude/Codex
|
|
129
130
|
impel claude [claude args...] Launch isolated Impel Claude Code
|
|
@@ -153,8 +154,8 @@ impel app update [target] [--tenant <org>] Refresh that tenant's apps, configs
|
|
|
153
154
|
impel app refresh [target] [--tenant <org>] Configs/catalog/skills only; safe while apps run
|
|
154
155
|
(--stale-only: no-op unless 6h+ since last sync)
|
|
155
156
|
impel app status [target] [--tenant <org>] Show tenant launcher/vendor status
|
|
156
|
-
impel app open [target] [--tenant <org>]
|
|
157
|
-
|
|
157
|
+
impel app open [target] [--tenant <org>] Install/configure a tenant on first use, then launch
|
|
158
|
+
it (later opens are immediate when nothing changed;
|
|
158
159
|
a detached stale-only refresh keeps things current)
|
|
159
160
|
impel app uninstall [target] [--tenant <org>] [--keep-data]
|
|
160
161
|
Remove one tenant's Impel-managed app files
|
|
@@ -186,7 +187,7 @@ Stores your PAT, gateway base URL, and app/control-plane URL in `~/.config/impel
|
|
|
186
187
|
impel auth --pat impel_pat_xxxxxxxxxxxx --gateway https://gateway.useimpel.com --app https://www.useimpel.com
|
|
187
188
|
```
|
|
188
189
|
|
|
189
|
-
### `impel pat create ...`
|
|
190
|
+
### `impel pat create ...` / `impel pat revoke ...`
|
|
190
191
|
|
|
191
192
|
Mints a new PAT through the app/control-plane API, authenticated by the PAT
|
|
192
193
|
already stored by `impel auth`. Identity remains the minting authority: the
|
|
@@ -215,6 +216,20 @@ the config file. To rotate this CLI onto it, explicitly run
|
|
|
215
216
|
`impel auth --pat <new-pat>` after copying it. `--json` includes the raw secret,
|
|
216
217
|
so treat redirected output as a credential.
|
|
217
218
|
|
|
219
|
+
Revoke a token by the token ID printed when it was created:
|
|
220
|
+
|
|
221
|
+
```sh
|
|
222
|
+
impel pat revoke personal1 --yes
|
|
223
|
+
impel pat revoke personal1 --yes --json
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Revocation is immediate and irreversible, so `--yes` is required. A full PAT is
|
|
227
|
+
also accepted for recovery from older mint output, but using the token ID avoids
|
|
228
|
+
putting a secret in shell history. The CLI extracts the ID without echoing the
|
|
229
|
+
secret, authenticates with the currently stored PAT, and leaves the local config
|
|
230
|
+
unchanged. If you revoke the stored PAT itself, authenticate again with a live PAT
|
|
231
|
+
before running another protected command.
|
|
232
|
+
|
|
218
233
|
### `impel update`
|
|
219
234
|
|
|
220
235
|
One command brings everything current, in dependency order: the CLI itself
|
|
@@ -277,9 +292,13 @@ On macOS, desktop app state is isolated under
|
|
|
277
292
|
bundles in `~/Applications`, with names such as `Impel Claude (Acme)` and
|
|
278
293
|
`Impel ChatGPT (Acme)`, stable tenant-specific bundle identifiers, and fixed
|
|
279
294
|
tenant profile/auth paths. Different tenants can stay open concurrently.
|
|
280
|
-
`impel app open --tenant <org>`
|
|
281
|
-
|
|
282
|
-
|
|
295
|
+
`impel app open --tenant <org>` installs and configures a missing tenant variant
|
|
296
|
+
on first use, then launches it directly without changing the globally selected
|
|
297
|
+
tenant. Later opens use the existing isolated app and profile. Switching tenants
|
|
298
|
+
never deletes local history or projects; each tenant's preserved profile reopens
|
|
299
|
+
independently. The first-use setup still requires an authenticated user PAT and
|
|
300
|
+
live membership in the requested organization; it does not create a PAT per
|
|
301
|
+
tenant.
|
|
283
302
|
Windows Claude Desktop keeps each
|
|
284
303
|
tenant under `%LOCALAPPDATA%\Claude-3p\Impel\<org>` and launches the unchanged
|
|
285
304
|
signed vendor executable with that profile selected. Windows ChatGPT keeps its
|
|
@@ -445,8 +464,9 @@ the selected tenant's 3P gateway configuration beneath Anthropic's reserved
|
|
|
445
464
|
normal Claude profile unchanged. `impel app install codex` installs/updates
|
|
446
465
|
OpenAI's `OpenAI.Codex` Store package, stages its unchanged signed app payload
|
|
447
466
|
inside Impel's shared app cache, and writes separate Codex and Chromium
|
|
448
|
-
profiles. `impel app open claude` and `impel app open codex`
|
|
449
|
-
|
|
467
|
+
profiles. `impel app open claude` and `impel app open codex` install a missing
|
|
468
|
+
official app and configure the selected tenant on first use, then launch the
|
|
469
|
+
isolated profile without changing either normal vendor profile.
|
|
450
470
|
|
|
451
471
|
For Claude, the Impel PAT is supplied only to the launched child process as an
|
|
452
472
|
authentication token and disappears when that process exits; it is never
|
|
@@ -587,10 +607,8 @@ Codex CLI and the Codex IDE extension / desktop app share the same
|
|
|
587
607
|
### Windows Claude and ChatGPT Desktop
|
|
588
608
|
|
|
589
609
|
```powershell
|
|
590
|
-
impel app install claude
|
|
591
610
|
impel app open claude
|
|
592
611
|
impel app status claude
|
|
593
|
-
impel app install codex
|
|
594
612
|
impel app open codex
|
|
595
613
|
impel app status codex
|
|
596
614
|
```
|
|
@@ -615,7 +633,8 @@ app-target names.
|
|
|
615
633
|
|
|
616
634
|
### macOS Claude and ChatGPT
|
|
617
635
|
|
|
618
|
-
`impel app
|
|
636
|
+
The first `impel app open --tenant <org>` (or an explicit `impel app install`)
|
|
637
|
+
creates tenant-specific apps such as
|
|
619
638
|
`Impel Claude (acme).app` and `Impel ChatGPT (acme).app` in `~/Applications`.
|
|
620
639
|
Their visible bundle names use the organization display name, for example
|
|
621
640
|
`Impel Claude (Acme)` and `Impel ChatGPT (Acme)`. Each tenant/product pair has
|
|
@@ -642,11 +661,10 @@ a stable, unique bundle identifier and gateway configuration:
|
|
|
642
661
|
- Neither app changes `~/.claude`, `~/.claude.json`, `~/.codex`, or the
|
|
643
662
|
normal vendor app's browser/profile data.
|
|
644
663
|
|
|
645
|
-
|
|
664
|
+
Open several tenants without changing the current selection. A missing variant
|
|
665
|
+
is installed and configured automatically before it opens:
|
|
646
666
|
|
|
647
667
|
```sh
|
|
648
|
-
impel app install all --tenant acme
|
|
649
|
-
impel app install all --tenant globex
|
|
650
668
|
impel app open all --tenant acme
|
|
651
669
|
impel app open all --tenant globex
|
|
652
670
|
```
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -29,7 +29,7 @@ Get started:
|
|
|
29
29
|
Run:
|
|
30
30
|
impel claude [args...] Launch Claude Code with an isolated Impel profile
|
|
31
31
|
impel codex [args...] Launch Codex with an isolated Impel profile
|
|
32
|
-
impel app open [target] [--tenant <org>]
|
|
32
|
+
impel app open [target] [--tenant <org>] Install/configure on first use, then open the app(s)
|
|
33
33
|
impel status Tenant, PAT, per-tool mode, gateway reachability
|
|
34
34
|
impel doctor [--tenant <org>|--all-tenants] Synthetic provider, routing, and latency checks
|
|
35
35
|
|
|
@@ -37,6 +37,7 @@ Manage:
|
|
|
37
37
|
impel auth [--pat <pat>] Store your PAT + URLs (--gateway/--app <url>)
|
|
38
38
|
impel pat create --label <label> Mint a personal PAT (--org/--scopes/--ttl-days)
|
|
39
39
|
impel pat create --agent <agent-id> Mint an org-scoped PAT associated with an agent
|
|
40
|
+
impel pat revoke <token-id> --yes Irreversibly revoke one of your PATs
|
|
40
41
|
impel tenant list|current|use <org> List or select the active organization
|
|
41
42
|
impel tasks list|get|create|update|delete CRUD Impel tickets (aliases: task, ticket[s])
|
|
42
43
|
impel token [--tenant <org>] Print the selected-tenant bearer
|
package/src/commands/apps.js
CHANGED
|
@@ -207,13 +207,26 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
207
207
|
claudeUserData: actionUserData,
|
|
208
208
|
tenantName: config.tenantName,
|
|
209
209
|
});
|
|
210
|
+
const profileWasInstalled = {
|
|
211
|
+
claude: fs.existsSync(path.join(
|
|
212
|
+
actionPaths.claude.userData,
|
|
213
|
+
"configLibrary",
|
|
214
|
+
`${CLAUDE_CONFIG_ID}.json`,
|
|
215
|
+
)),
|
|
216
|
+
chatgpt: fs.existsSync(path.join(actionPaths.chatgpt.codexHome, "config.toml")),
|
|
217
|
+
};
|
|
210
218
|
const vendorPaths = {};
|
|
211
219
|
for (const target of targets) {
|
|
212
220
|
const isClaude = target === "claude";
|
|
213
221
|
const find = isClaude ? io.findClaudeApp : io.findChatGPTApp;
|
|
214
222
|
const ensure = isClaude ? io.ensureClaudeApp : io.ensureChatGPTApp;
|
|
215
223
|
let binary = find(io.environment);
|
|
216
|
-
|
|
224
|
+
const shouldEnsureVendor = !flags["skip-vendor"] && (
|
|
225
|
+
action === "install"
|
|
226
|
+
|| action === "update"
|
|
227
|
+
|| (action === "open" && !binary)
|
|
228
|
+
);
|
|
229
|
+
if (shouldEnsureVendor) {
|
|
217
230
|
const vendor = ensure({ update: action === "update" }, { environment: io.environment });
|
|
218
231
|
binary = vendor.binary;
|
|
219
232
|
console.log(`${target}: vendor app ${vendor.action}`);
|
|
@@ -252,9 +265,12 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
252
265
|
claudeUserData: userData,
|
|
253
266
|
tenantName: config.tenantName,
|
|
254
267
|
});
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
268
|
+
const configuredTargets = action === "open"
|
|
269
|
+
? targets.filter((target) => !profileWasInstalled[target])
|
|
270
|
+
: targets;
|
|
271
|
+
if (action === "install" || action === "update" || action === "refresh" || configuredTargets.length > 0) {
|
|
272
|
+
const verb = action === "update" ? "Updated" : action === "refresh" ? "Refreshed" : "Installed";
|
|
273
|
+
for (const target of configuredTargets) {
|
|
258
274
|
const profile = target === "claude" ? tenantPaths.claude.userData : tenantPaths.chatgpt.root;
|
|
259
275
|
console.log(`${verb} Impel ${target === "claude" ? "Claude" : "ChatGPT"} profile at ${profile}`);
|
|
260
276
|
const { client, env, label } = appSkillTarget(target, tenantPaths);
|
|
@@ -358,43 +374,12 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
358
374
|
|
|
359
375
|
const config = await selectedAppConfig(targets, flags.tenant || null);
|
|
360
376
|
const statuses = appStatus(targets, os.homedir(), config.tenantId, config.tenantName);
|
|
361
|
-
|
|
362
|
-
for (const status of statuses) {
|
|
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
|
-
}
|
|
366
|
-
if (!status.vendorPath) throw new Error(`${status.label} vendor app is unavailable; reinstall the vendor app first`);
|
|
367
|
-
}
|
|
368
|
-
let catalog;
|
|
369
|
-
try {
|
|
370
|
-
catalog = await fetchGatewayModels(config);
|
|
371
|
-
} catch (error) {
|
|
372
|
-
throw new Error(
|
|
373
|
-
`tenant model catalog is unavailable (${redactSecretText(error.message)}); the selected tenant app was not opened`,
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
// Only stale bundles pay the clone + codesign rebuild; a bundle swap needs
|
|
377
|
-
// its app closed, and quitting is fine here since we relaunch right after.
|
|
378
|
-
const staleBundleTargets = statuses
|
|
379
|
-
.filter((status) => !bundleIsCurrent(status))
|
|
380
|
-
.map((status) => status.target);
|
|
381
|
-
if (staleBundleTargets.length > 0) {
|
|
382
|
-
await quitBlockingApps(staleBundleTargets, {
|
|
383
|
-
tenantId: config.tenantId,
|
|
384
|
-
tenantName: config.tenantName,
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
installManagedAppFiles({
|
|
377
|
+
await provisionAndOpenManagedApps({
|
|
388
378
|
config,
|
|
389
379
|
targets,
|
|
390
|
-
|
|
380
|
+
statuses,
|
|
391
381
|
homeDir: os.homedir(),
|
|
392
|
-
vendorPaths: Object.fromEntries(statuses.map((status) => [status.target, status.vendorPath])),
|
|
393
|
-
writeBundles: staleBundleTargets,
|
|
394
382
|
});
|
|
395
|
-
for (const status of appStatus(targets, os.homedir(), config.tenantId, config.tenantName)) {
|
|
396
|
-
spawnSync("/usr/bin/open", ["-n", status.launcher], { stdio: "inherit" });
|
|
397
|
-
}
|
|
398
383
|
return;
|
|
399
384
|
}
|
|
400
385
|
if (action !== "install" && action !== "update") {
|
|
@@ -445,6 +430,99 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
445
430
|
}
|
|
446
431
|
}
|
|
447
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Complete the slow `app open` path. A first open is intentionally equivalent
|
|
435
|
+
* to an install for the selected tenant: verify or install the pinned vendor,
|
|
436
|
+
* build the isolated bundle/profile, sync skills, and only then launch it.
|
|
437
|
+
* Existing current installs never reach this helper because fastOpenLaunchers
|
|
438
|
+
* handles them without network or rebuild work.
|
|
439
|
+
*/
|
|
440
|
+
export async function provisionAndOpenManagedApps({
|
|
441
|
+
config,
|
|
442
|
+
targets,
|
|
443
|
+
statuses,
|
|
444
|
+
homeDir = os.homedir(),
|
|
445
|
+
}, overrides = {}) {
|
|
446
|
+
const io = {
|
|
447
|
+
fetchModels: fetchGatewayModels,
|
|
448
|
+
ensureVendor: ensureVendorApp,
|
|
449
|
+
bundleCurrent: bundleIsCurrent,
|
|
450
|
+
quitApps: quitBlockingApps,
|
|
451
|
+
installFiles: installManagedAppFiles,
|
|
452
|
+
syncSkills: syncSkillsSafe,
|
|
453
|
+
secureCodexHome: secureManagedCodexHome,
|
|
454
|
+
openLauncher: (launcher) => spawnSync("/usr/bin/open", ["-n", launcher], { stdio: "inherit" }),
|
|
455
|
+
log: (message) => console.log(message),
|
|
456
|
+
...overrides,
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
// Resolve the live tenant catalog before installing or changing anything.
|
|
460
|
+
// This keeps unavailable/unauthorized tenants fail-closed on first use.
|
|
461
|
+
let catalog;
|
|
462
|
+
try {
|
|
463
|
+
catalog = await io.fetchModels(config);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
`tenant model catalog is unavailable (${redactSecretText(error?.message || error)}); the selected tenant app was not opened`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const vendorPaths = {};
|
|
471
|
+
for (const status of statuses) {
|
|
472
|
+
let vendorPath = status.vendorPath;
|
|
473
|
+
if (!vendorPath) {
|
|
474
|
+
const result = io.ensureVendor(status.target, { homeDir });
|
|
475
|
+
vendorPath = result.path;
|
|
476
|
+
io.log(`${status.target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
|
|
477
|
+
if (!vendorPath) {
|
|
478
|
+
throw new Error(`${status.target} verified vendor app is unavailable; retry the pinned download`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
vendorPaths[status.target] = vendorPath;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Only stale or absent bundles pay the clone + codesign rebuild. The model
|
|
485
|
+
// catalog and vendor checks above finish before any running apps are closed.
|
|
486
|
+
const staleBundleTargets = statuses
|
|
487
|
+
.filter((status) => !io.bundleCurrent(status))
|
|
488
|
+
.map((status) => status.target);
|
|
489
|
+
if (staleBundleTargets.length > 0) {
|
|
490
|
+
await io.quitApps(staleBundleTargets, {
|
|
491
|
+
tenantId: config.tenantId,
|
|
492
|
+
tenantName: config.tenantName,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const installed = io.installFiles({
|
|
497
|
+
config,
|
|
498
|
+
targets,
|
|
499
|
+
models: catalog.models,
|
|
500
|
+
homeDir,
|
|
501
|
+
vendorPaths,
|
|
502
|
+
writeBundles: staleBundleTargets,
|
|
503
|
+
});
|
|
504
|
+
const newlyInstalled = new Set(
|
|
505
|
+
statuses.filter((status) => !status.launcherInstalled).map((status) => status.target),
|
|
506
|
+
);
|
|
507
|
+
for (const item of installed) {
|
|
508
|
+
if (newlyInstalled.has(item.target)) {
|
|
509
|
+
io.log(`Installed and configured ${item.launcher} for tenant ${config.tenantId}.`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Match explicit install setup without penalizing repeat opens: this helper
|
|
514
|
+
// only runs when the zero-network fast path has detected drift or first use.
|
|
515
|
+
const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
|
|
516
|
+
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
517
|
+
for (const item of installed) {
|
|
518
|
+
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
519
|
+
await io.syncSkills({ client, gatewayUrl, env, label });
|
|
520
|
+
if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
|
|
521
|
+
}
|
|
522
|
+
for (const item of installed) await io.openLauncher(item.launcher);
|
|
523
|
+
return installed;
|
|
524
|
+
}
|
|
525
|
+
|
|
448
526
|
const REFRESH_TTL_MS = 6 * 60 * 60 * 1000;
|
|
449
527
|
|
|
450
528
|
/**
|
package/src/commands/pat.js
CHANGED
|
@@ -16,8 +16,9 @@ import {
|
|
|
16
16
|
const AGENT_PAT_LABEL_PREFIX = "Agent · ";
|
|
17
17
|
const MAX_PAT_LABEL_LENGTH = 120;
|
|
18
18
|
const MAX_PAT_TTL_DAYS = 365;
|
|
19
|
-
const
|
|
19
|
+
const PAT_COLLECTION_PATH = "/api/cli/pats";
|
|
20
20
|
const PAT_TOKEN_RE = /^impel_pat_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u;
|
|
21
|
+
const PAT_TOKEN_INPUT_RE = /^impel_pat_([A-Za-z0-9_-]{8,128})\.[A-Za-z0-9_-]+$/u;
|
|
21
22
|
const PAT_TOKEN_ID_RE = /^[A-Za-z0-9_-]{8,128}$/u;
|
|
22
23
|
const AGENT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,111}$/u;
|
|
23
24
|
const CONTROL_CHARACTER_RE = /[\u0000-\u001F\u007F-\u009F]/u;
|
|
@@ -33,13 +34,28 @@ const FLAG_NAMES = new Set([
|
|
|
33
34
|
"scopes",
|
|
34
35
|
"tenant",
|
|
35
36
|
"ttl-days",
|
|
37
|
+
"yes",
|
|
36
38
|
]);
|
|
37
39
|
|
|
38
|
-
const
|
|
40
|
+
const CREATE_FLAG_NAMES = new Set([
|
|
41
|
+
"agent",
|
|
42
|
+
"app",
|
|
43
|
+
"help",
|
|
44
|
+
"json",
|
|
45
|
+
"label",
|
|
46
|
+
"org",
|
|
47
|
+
"scopes",
|
|
48
|
+
"tenant",
|
|
49
|
+
"ttl-days",
|
|
50
|
+
]);
|
|
51
|
+
const REVOKE_FLAG_NAMES = new Set(["app", "help", "json", "yes"]);
|
|
52
|
+
|
|
53
|
+
const HELP = `impel pat - manage personal access tokens through Impel
|
|
39
54
|
|
|
40
55
|
Usage:
|
|
41
56
|
impel pat create --label <label> [options]
|
|
42
57
|
impel pat create --agent <agent-id> [options]
|
|
58
|
+
impel pat revoke <token-id> --yes [options]
|
|
43
59
|
|
|
44
60
|
Aliases:
|
|
45
61
|
impel pats ...
|
|
@@ -52,11 +68,13 @@ Options:
|
|
|
52
68
|
Defaults are chosen by the control plane for your access tier.
|
|
53
69
|
--ttl-days <days> Expiry from 1 to ${MAX_PAT_TTL_DAYS} days. Default: 90.
|
|
54
70
|
--app <url> Override the app/control-plane URL for this command.
|
|
55
|
-
--json Print the
|
|
71
|
+
--json Print the result as JSON.
|
|
72
|
+
--yes Confirm irreversible PAT revocation.
|
|
56
73
|
|
|
57
74
|
The stored PAT authenticates this request. The control plane verifies the owner,
|
|
58
75
|
organization membership, requested scopes, and agent access before identity mints
|
|
59
|
-
the new secret.
|
|
76
|
+
the new secret or revokes the selected token. New PATs are shown once and are not
|
|
77
|
+
stored automatically. Revoke accepts a token id or a full PAT and never echoes a secret.
|
|
60
78
|
`;
|
|
61
79
|
|
|
62
80
|
class PatCommandError extends Error {}
|
|
@@ -76,9 +94,17 @@ function flagSpec() {
|
|
|
76
94
|
scopes: { type: "string" },
|
|
77
95
|
tenant: { type: "string" },
|
|
78
96
|
"ttl-days": { type: "string" },
|
|
97
|
+
yes: { type: "boolean" },
|
|
79
98
|
};
|
|
80
99
|
}
|
|
81
100
|
|
|
101
|
+
function rejectUnsupportedFlags(flags, allowed, action) {
|
|
102
|
+
const unsupported = Object.keys(flags).find((name) => !allowed.has(name));
|
|
103
|
+
if (unsupported) {
|
|
104
|
+
fail(`impel pat ${action}: option --${redactSecretText(unsupported)} is not supported.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
82
108
|
function requireConfig(flags) {
|
|
83
109
|
const config = loadConfig();
|
|
84
110
|
if (!config?.pat) {
|
|
@@ -135,6 +161,18 @@ function requestedTtlDays(value) {
|
|
|
135
161
|
return days;
|
|
136
162
|
}
|
|
137
163
|
|
|
164
|
+
function requestedTokenId(value) {
|
|
165
|
+
if (value === undefined) {
|
|
166
|
+
fail("impel pat revoke: missing token id.");
|
|
167
|
+
}
|
|
168
|
+
const requested = String(value).trim();
|
|
169
|
+
const tokenId = PAT_TOKEN_INPUT_RE.exec(requested)?.[1] || requested;
|
|
170
|
+
if (!PAT_TOKEN_ID_RE.test(tokenId)) {
|
|
171
|
+
fail("impel pat revoke: expected a valid token id or full Impel PAT.");
|
|
172
|
+
}
|
|
173
|
+
return tokenId;
|
|
174
|
+
}
|
|
175
|
+
|
|
138
176
|
function requestedSubject(flags) {
|
|
139
177
|
const agentId = flags.agent === undefined ? null : String(flags.agent).trim();
|
|
140
178
|
const personalLabel = flags.label === undefined ? null : String(flags.label).trim();
|
|
@@ -215,19 +253,19 @@ function normalizeCreatedPat(payload, expected) {
|
|
|
215
253
|
};
|
|
216
254
|
}
|
|
217
255
|
|
|
218
|
-
async function
|
|
256
|
+
async function requestControlPlane({ appUrl, currentPat, path, method, body, feature }) {
|
|
219
257
|
const controller = new AbortController();
|
|
220
258
|
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
221
259
|
let response;
|
|
222
260
|
try {
|
|
223
|
-
response = await fetch(new URL(
|
|
224
|
-
method
|
|
261
|
+
response = await fetch(new URL(path, appUrl), {
|
|
262
|
+
method,
|
|
225
263
|
headers: {
|
|
226
264
|
accept: "application/json",
|
|
227
265
|
authorization: `Bearer ${currentPat}`,
|
|
228
|
-
"content-type": "application/json",
|
|
266
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
229
267
|
},
|
|
230
|
-
body: JSON.stringify(body),
|
|
268
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
231
269
|
signal: controller.signal,
|
|
232
270
|
});
|
|
233
271
|
} catch (error) {
|
|
@@ -245,13 +283,24 @@ async function createPat({ appUrl, currentPat, body }) {
|
|
|
245
283
|
payload = null;
|
|
246
284
|
}
|
|
247
285
|
if (!response.ok) {
|
|
248
|
-
if (response.status === 404) {
|
|
249
|
-
fail(`impel pat: PAT
|
|
286
|
+
if (response.status === 404 && !payload?.error) {
|
|
287
|
+
fail(`impel pat: PAT ${feature} is not available on ${appUrl}; update the Impel control plane and try again.`);
|
|
250
288
|
}
|
|
251
289
|
const message = redactSecretText(payload?.error || `${response.status} ${response.statusText}`.trim());
|
|
252
290
|
fail(`impel pat: ${message}`);
|
|
253
291
|
}
|
|
292
|
+
return payload;
|
|
293
|
+
}
|
|
254
294
|
|
|
295
|
+
async function createPat({ appUrl, currentPat, body }) {
|
|
296
|
+
const payload = await requestControlPlane({
|
|
297
|
+
appUrl,
|
|
298
|
+
currentPat,
|
|
299
|
+
path: PAT_COLLECTION_PATH,
|
|
300
|
+
method: "POST",
|
|
301
|
+
body,
|
|
302
|
+
feature: "minting",
|
|
303
|
+
});
|
|
255
304
|
const created = normalizeCreatedPat(payload, body);
|
|
256
305
|
if (!created) {
|
|
257
306
|
fail("impel pat: the control plane returned an invalid PAT creation response; the secret was not displayed.");
|
|
@@ -259,6 +308,17 @@ async function createPat({ appUrl, currentPat, body }) {
|
|
|
259
308
|
return created;
|
|
260
309
|
}
|
|
261
310
|
|
|
311
|
+
async function revokePat({ appUrl, currentPat, tokenId }) {
|
|
312
|
+
await requestControlPlane({
|
|
313
|
+
appUrl,
|
|
314
|
+
currentPat,
|
|
315
|
+
path: `${PAT_COLLECTION_PATH}/${encodeURIComponent(tokenId)}`,
|
|
316
|
+
method: "DELETE",
|
|
317
|
+
feature: "revocation",
|
|
318
|
+
});
|
|
319
|
+
return { revoked: true, tokenId };
|
|
320
|
+
}
|
|
321
|
+
|
|
262
322
|
function printCreatedPat(created, subject, json) {
|
|
263
323
|
if (json) {
|
|
264
324
|
console.log(JSON.stringify({
|
|
@@ -274,6 +334,7 @@ function printCreatedPat(created, subject, json) {
|
|
|
274
334
|
} else {
|
|
275
335
|
console.log(`Created personal PAT ${JSON.stringify(created.label)} in ${created.orgId}.`);
|
|
276
336
|
}
|
|
337
|
+
console.log(`Token ID: ${created.tokenId}`);
|
|
277
338
|
console.log(`Scopes: ${created.scopes.join(", ") || "none"}`);
|
|
278
339
|
console.log(`Expires: ${created.expiresAt === null ? "never" : new Date(created.expiresAt).toISOString()}`);
|
|
279
340
|
console.log("");
|
|
@@ -281,15 +342,23 @@ function printCreatedPat(created, subject, json) {
|
|
|
281
342
|
console.log(created.token);
|
|
282
343
|
}
|
|
283
344
|
|
|
345
|
+
function printRevokedPat(revoked, json) {
|
|
346
|
+
if (json) {
|
|
347
|
+
console.log(JSON.stringify(revoked, null, 2));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
console.log(`Revoked PAT ${revoked.tokenId}.`);
|
|
351
|
+
}
|
|
352
|
+
|
|
284
353
|
export async function cmdPat(argv) {
|
|
285
354
|
const [action, ...rest] = argv;
|
|
286
355
|
if (!action || action === "help" || action === "--help" || action === "-h") {
|
|
287
356
|
console.log(HELP);
|
|
288
357
|
return;
|
|
289
358
|
}
|
|
290
|
-
if (action !== "create" && action !== "mint") {
|
|
359
|
+
if (action !== "create" && action !== "mint" && action !== "revoke") {
|
|
291
360
|
console.error(`impel pat: unknown subcommand "${redactSecretText(action)}".`);
|
|
292
|
-
console.error(" Use `impel pat create --label <label
|
|
361
|
+
console.error(" Use `impel pat create --label <label>`, `impel pat create --agent <agent-id>`, or `impel pat revoke <token-id> --yes`.");
|
|
293
362
|
process.exitCode = 1;
|
|
294
363
|
return;
|
|
295
364
|
}
|
|
@@ -302,6 +371,23 @@ export async function cmdPat(argv) {
|
|
|
302
371
|
}
|
|
303
372
|
const unknownFlag = Object.keys(flags).find((name) => !FLAG_NAMES.has(name));
|
|
304
373
|
if (unknownFlag) fail(`impel pat: unknown option --${redactSecretText(unknownFlag)}.`);
|
|
374
|
+
|
|
375
|
+
if (action === "revoke") {
|
|
376
|
+
rejectUnsupportedFlags(flags, REVOKE_FLAG_NAMES, "revoke");
|
|
377
|
+
const tokenId = requestedTokenId(positionals[0]);
|
|
378
|
+
if (positionals.length > 1) {
|
|
379
|
+
fail(`impel pat revoke: unexpected argument "${redactSecretText(positionals[1])}".`);
|
|
380
|
+
}
|
|
381
|
+
if (flags.yes !== true) {
|
|
382
|
+
fail("impel pat revoke: refusing to revoke without --yes.");
|
|
383
|
+
}
|
|
384
|
+
const { appUrl, config } = requireConfig(flags);
|
|
385
|
+
const revoked = await revokePat({ appUrl, currentPat: config.pat, tokenId });
|
|
386
|
+
printRevokedPat(revoked, flags.json === true);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
rejectUnsupportedFlags(flags, CREATE_FLAG_NAMES, "create");
|
|
305
391
|
if (positionals.length > 0) {
|
|
306
392
|
fail(`impel pat create: unexpected argument "${redactSecretText(positionals[0])}".`);
|
|
307
393
|
}
|