impel-cli 0.11.2 → 0.12.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 +28 -10
- package/package.json +1 -1
- package/src/agents.js +43 -9
- package/src/apps.js +51 -2
- package/src/cli.js +2 -1
- package/src/commands/apps.js +138 -47
- package/src/commands/skills.js +19 -8
- package/src/commands/update.js +13 -8
- package/src/progress.js +85 -0
package/README.md
CHANGED
|
@@ -152,7 +152,9 @@ impel agents sync [claude|codex|all] Sync explicit tenant agents into na
|
|
|
152
152
|
|
|
153
153
|
impel status Launcher readiness + native mode + gateway reachability
|
|
154
154
|
impel app install [target] [--tenant <org>] Install isolated apps for one tenant
|
|
155
|
-
impel app update [target] [--tenant <org>]
|
|
155
|
+
impel app update [target] [--tenant <org>] [--force]
|
|
156
|
+
Refresh configs/models and rebuild only stale bundles;
|
|
157
|
+
--force performs a clean full rebuild
|
|
156
158
|
impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
|
|
157
159
|
(--stale-only: no-op unless 6h+ since last sync)
|
|
158
160
|
impel app status [target] [--tenant <org>] Show tenant launcher/vendor status
|
|
@@ -237,9 +239,10 @@ before running another protected command.
|
|
|
237
239
|
One command brings everything current, in dependency order: the CLI itself
|
|
238
240
|
(`npm install -g impel-cli@latest` from npm, with no GitHub credentials),
|
|
239
241
|
then — re-executing the freshly installed build — `impel app update all`
|
|
240
|
-
(
|
|
242
|
+
(install the CLI-pinned vendor builds and rebuild only stale vendored Impel apps)
|
|
241
243
|
followed by `impel skills sync all` and `impel agents sync all` across every
|
|
242
|
-
managed profile.
|
|
244
|
+
managed profile. The app cascade skips its own skills/agents pass so those
|
|
245
|
+
profiles are synced once, in the dedicated bounded-concurrency steps.
|
|
243
246
|
|
|
244
247
|
Update discovery compares the installed package version with npm's public
|
|
245
248
|
`latest` metadata, cached for 6 hours in
|
|
@@ -608,6 +611,12 @@ MCP subprocess definition so they also work in isolated desktop profiles. They
|
|
|
608
611
|
never contain the PAT; the subprocess reads it from Impel's owner-only config
|
|
609
612
|
when invoked.
|
|
610
613
|
|
|
614
|
+
Visible agent names come from the registry title. Codex keeps that title as the
|
|
615
|
+
custom-agent name; Claude uses the lowercase, hyphenated form required by its
|
|
616
|
+
native agent schema. The generated filenames remain stable tenant-and-agent
|
|
617
|
+
identifiers so title changes update in place without colliding with user files.
|
|
618
|
+
Duplicate or built-in-reserved titles receive a deterministic disambiguator.
|
|
619
|
+
|
|
611
620
|
The sync writes atomically and records a manifest beside the generated files.
|
|
612
621
|
It removes only stale filenames from that manifest, so user-authored agents are
|
|
613
622
|
never deleted. Automatic refreshes are best-effort and retain the last good
|
|
@@ -623,13 +632,13 @@ Invocation uses the clients' native agent behavior:
|
|
|
623
632
|
|
|
624
633
|
```text
|
|
625
634
|
# Claude Code / Claude Desktop Code tab: guaranteed explicit selection
|
|
626
|
-
@agent-
|
|
635
|
+
@agent-research-agent investigate the dependency change
|
|
627
636
|
|
|
628
637
|
# Claude blocking CLI
|
|
629
|
-
claude --agent
|
|
638
|
+
claude --agent research-agent -p "investigate the dependency change"
|
|
630
639
|
|
|
631
640
|
# Codex CLI / ChatGPT desktop Codex task
|
|
632
|
-
Explicitly use the configured
|
|
641
|
+
Explicitly use the configured Research Agent for this request and wait for it.
|
|
633
642
|
```
|
|
634
643
|
|
|
635
644
|
Claude exposes native custom agents in its `@` typeahead. Current Codex releases
|
|
@@ -698,10 +707,11 @@ a stable, unique bundle identifier and gateway configuration:
|
|
|
698
707
|
- Impel Claude is an APFS-cloned vendored copy of the official app with its own
|
|
699
708
|
bundle and helper identities. Its LaunchServices environment sets the
|
|
700
709
|
vendor-supported `CLAUDE_USER_DATA_DIR` and writes the 3P gateway
|
|
701
|
-
`configLibrary` below `~/.config/impel/apps/tenants/<org>/claude`. A
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
710
|
+
`configLibrary` below `~/.config/impel/apps/tenants/<org>/claude`. A new
|
|
711
|
+
isolated profile defaults to the Code app while preserving any app selection
|
|
712
|
+
the user makes afterward. A narrowly version-checked compatibility patch
|
|
713
|
+
connects Claude's native plan-usage meter to the authenticated aggregate
|
|
714
|
+
Claude subscription pool exposed by the Impel gateway.
|
|
705
715
|
- Impel ChatGPT wraps an APFS-cloned official app while preserving the nested
|
|
706
716
|
OpenAI-signed bundle and executable. A wrapper-owned startup preload pins
|
|
707
717
|
Electron's application name before the native menu is created, so macOS
|
|
@@ -731,6 +741,14 @@ Updates and uninstalls touch only the named tenant variant. The older global
|
|
|
731
741
|
`Impel Claude.app` / `Impel ChatGPT.app` launcher is removed after the first
|
|
732
742
|
successful tenant-specific rebuild.
|
|
733
743
|
|
|
744
|
+
`impel app update` compares the installed bundle's CLI version, pinned vendor
|
|
745
|
+
version, identity, profile paths, and ASAR compatibility hash before touching
|
|
746
|
+
it. A current bundle is not closed, cloned, patched, or codesigned; its config
|
|
747
|
+
and model catalog are still refreshed. If an interrupted update left a machine
|
|
748
|
+
in a bad state, `impel app update --force` removes the selected launcher and
|
|
749
|
+
performs a clean rebuild while sweeping abandoned `.tmp-*` and `.previous-*`
|
|
750
|
+
staging directories.
|
|
751
|
+
|
|
734
752
|
The official Claude and ChatGPT apps remain separate installations and keep
|
|
735
753
|
their normal accounts, updater, profiles, and bundle identities. Each CLI
|
|
736
754
|
release pins exact, tested vendor builds (v0.10.0 pins Claude `1.20186.9` and
|
package/package.json
CHANGED
package/src/agents.js
CHANGED
|
@@ -29,6 +29,10 @@ const NATIVE_AGENT_TOOL_NAMES = [
|
|
|
29
29
|
const SAFE_AGENT_ID_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
|
|
30
30
|
const SAFE_SCOPE_PARAM_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
|
|
31
31
|
const MAX_CATALOG_ITEMS = 500;
|
|
32
|
+
const RESERVED_AGENT_NAMES = Object.freeze({
|
|
33
|
+
claude: new Set(["explore", "general-purpose", "plan"]),
|
|
34
|
+
codex: new Set(["default", "explorer", "worker"]),
|
|
35
|
+
});
|
|
32
36
|
|
|
33
37
|
function privateDirectory(directory) {
|
|
34
38
|
if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink()) {
|
|
@@ -262,29 +266,58 @@ export async function fetchNativeAgentCatalog({
|
|
|
262
266
|
);
|
|
263
267
|
}
|
|
264
268
|
|
|
265
|
-
function slug(value) {
|
|
269
|
+
function slug(value, fallback = "agent") {
|
|
266
270
|
return String(value)
|
|
267
271
|
.toLowerCase()
|
|
268
272
|
.replace(/[^a-z0-9]+/gu, "-")
|
|
269
273
|
.replace(/^-+|-+$/gu, "")
|
|
270
|
-
.slice(0, 48) ||
|
|
274
|
+
.slice(0, 48) || fallback;
|
|
271
275
|
}
|
|
272
276
|
|
|
273
|
-
function
|
|
277
|
+
function agentBindingHash(agent) {
|
|
278
|
+
return crypto.createHash("sha256")
|
|
279
|
+
.update(`${agent.scopeParam}:${agent.agentId}`)
|
|
280
|
+
.digest("hex")
|
|
281
|
+
.slice(0, 8);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function generatedAgentFileStems(tenantId, agents) {
|
|
274
285
|
const used = new Map();
|
|
275
286
|
return agents.map((agent) => {
|
|
276
287
|
const base = `impel-${slug(tenantId)}-${slug(agent.agentId)}`.slice(0, 63).replace(/-+$/u, "");
|
|
277
288
|
const previous = used.get(base);
|
|
278
289
|
used.set(base, (previous || 0) + 1);
|
|
279
290
|
if (!previous) return base;
|
|
280
|
-
const suffix =
|
|
281
|
-
.update(`${agent.scopeParam}:${agent.agentId}`)
|
|
282
|
-
.digest("hex")
|
|
283
|
-
.slice(0, 8);
|
|
291
|
+
const suffix = agentBindingHash(agent);
|
|
284
292
|
return `${base.slice(0, 54)}-${suffix}`;
|
|
285
293
|
});
|
|
286
294
|
}
|
|
287
295
|
|
|
296
|
+
function generatedClientAgentNames(client, agents) {
|
|
297
|
+
const claudeNames = agents.map((agent) => slug(agent.title, slug(agent.agentId)));
|
|
298
|
+
const collisionKeys = agents.map((agent, index) => client === "claude"
|
|
299
|
+
? claudeNames[index]
|
|
300
|
+
: slug(agent.title, agent.title.normalize("NFKC").toLocaleLowerCase("en-US")));
|
|
301
|
+
const collisionKeyCounts = collisionKeys.reduce((counts, key) => {
|
|
302
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
303
|
+
return counts;
|
|
304
|
+
}, new Map());
|
|
305
|
+
|
|
306
|
+
return agents.map((agent, index) => {
|
|
307
|
+
const visualKey = claudeNames[index];
|
|
308
|
+
const duplicate = collisionKeyCounts.get(collisionKeys[index]) > 1;
|
|
309
|
+
const reserved = RESERVED_AGENT_NAMES[client].has(visualKey);
|
|
310
|
+
if (client === "claude") {
|
|
311
|
+
const base = reserved ? `impel-${visualKey}` : visualKey;
|
|
312
|
+
if (duplicate) return `${base.slice(0, 54)}-${agentBindingHash(agent)}`;
|
|
313
|
+
return base;
|
|
314
|
+
}
|
|
315
|
+
const base = reserved ? `Impel ${agent.title}` : agent.title;
|
|
316
|
+
if (duplicate) return `${base} (${agentBindingHash(agent)})`;
|
|
317
|
+
return base;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
288
321
|
function adapterInstructions(tenantId, agent) {
|
|
289
322
|
const toolName = nativeToolName;
|
|
290
323
|
const contextRequirement = agent.requiredContext.length
|
|
@@ -360,14 +393,15 @@ function renderCodexAgent({ tenantId, agent, name, invocation }) {
|
|
|
360
393
|
export function renderManagedAgents(client, tenantId, agents, invocation = impelMcpInvocation(["--tenant", tenantId])) {
|
|
361
394
|
if (client !== "claude" && client !== "codex") throw new Error(`unknown agent client ${client}`);
|
|
362
395
|
const normalizedTenant = normalizeTenantId(tenantId);
|
|
363
|
-
const
|
|
396
|
+
const fileStems = generatedAgentFileStems(normalizedTenant, agents);
|
|
397
|
+
const names = generatedClientAgentNames(client, agents);
|
|
364
398
|
return agents.map((agent, index) => {
|
|
365
399
|
const name = names[index];
|
|
366
400
|
const extension = client === "claude" ? ".md" : ".toml";
|
|
367
401
|
const contents = client === "claude"
|
|
368
402
|
? renderClaudeAgent({ tenantId: normalizedTenant, agent, name, invocation })
|
|
369
403
|
: renderCodexAgent({ tenantId: normalizedTenant, agent, name, invocation });
|
|
370
|
-
return { agentId: agent.agentId, name, fileName: `${
|
|
404
|
+
return { agentId: agent.agentId, name, fileName: `${fileStems[index]}${extension}`, contents };
|
|
371
405
|
});
|
|
372
406
|
}
|
|
373
407
|
|
package/src/apps.js
CHANGED
|
@@ -142,7 +142,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
|
|
|
142
142
|
|
|
143
143
|
// Bump when the written config/manifest schema changes; a mismatch forces the
|
|
144
144
|
// slow open path (and thus a full config rewrite) after a CLI update.
|
|
145
|
-
export const CURRENT_CONFIG_VERSION =
|
|
145
|
+
export const CURRENT_CONFIG_VERSION = 10;
|
|
146
146
|
|
|
147
147
|
/** Parse the tenant's install manifest, or null when absent/corrupt. */
|
|
148
148
|
export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
|
|
@@ -379,7 +379,10 @@ export function installManagedAppFiles({
|
|
|
379
379
|
|
|
380
380
|
const installed = [];
|
|
381
381
|
for (const target of targets) {
|
|
382
|
-
if (target === "claude")
|
|
382
|
+
if (target === "claude") {
|
|
383
|
+
writeClaudeDefaultApp(paths);
|
|
384
|
+
writeClaudeConfig(paths, config, models);
|
|
385
|
+
}
|
|
383
386
|
else writeChatGPTConfig(paths, config, models, vendorCodexModels, chatgptInvocations);
|
|
384
387
|
// Non-bundle targets are the background-refresh / fast-open path: configs,
|
|
385
388
|
// token helper, catalog, and manifest only. Bundle swaps require the app
|
|
@@ -550,6 +553,7 @@ function installPinnedVendorApp(target, homeDir) {
|
|
|
550
553
|
homeDir, ".config", "impel", "vendor", target, pin.version, pin.bundleName,
|
|
551
554
|
);
|
|
552
555
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
556
|
+
sweepStaleBundleArtifacts(path.dirname(destination), path.basename(destination));
|
|
553
557
|
replaceDirectory(destination, extractedApp);
|
|
554
558
|
return destination;
|
|
555
559
|
} finally {
|
|
@@ -646,6 +650,33 @@ function writeClaudeConfig(paths, config, models) {
|
|
|
646
650
|
}, null, 2) + "\n", 0o600);
|
|
647
651
|
}
|
|
648
652
|
|
|
653
|
+
function writeClaudeDefaultApp(paths) {
|
|
654
|
+
const configPath = path.join(paths.claude.userData, "claude_desktop_config.json");
|
|
655
|
+
let current = {};
|
|
656
|
+
if (fs.existsSync(configPath)) {
|
|
657
|
+
try {
|
|
658
|
+
current = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
659
|
+
} catch {
|
|
660
|
+
throw new Error(`Claude desktop config is not valid JSON: ${configPath}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
|
664
|
+
throw new Error(`Claude desktop config must contain a JSON object: ${configPath}`);
|
|
665
|
+
}
|
|
666
|
+
const preferences = current.preferences ?? {};
|
|
667
|
+
if (!preferences || typeof preferences !== "object" || Array.isArray(preferences)) {
|
|
668
|
+
throw new Error(`Claude desktop preferences must contain a JSON object: ${configPath}`);
|
|
669
|
+
}
|
|
670
|
+
// Claude persists the selected top-level app as `preferences.sidebarMode`.
|
|
671
|
+
// Seed Code for a new isolated profile, then leave subsequent user choices
|
|
672
|
+
// alone during install, update, and background config refreshes.
|
|
673
|
+
if (Object.hasOwn(preferences, "sidebarMode")) return;
|
|
674
|
+
writeAtomic(configPath, JSON.stringify({
|
|
675
|
+
...current,
|
|
676
|
+
preferences: { ...preferences, sidebarMode: "code" },
|
|
677
|
+
}, null, 2) + "\n", 0o600);
|
|
678
|
+
}
|
|
679
|
+
|
|
649
680
|
function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocations = null) {
|
|
650
681
|
const experimental = crossAppModelsEnabled(config);
|
|
651
682
|
const orderedModels = experimental
|
|
@@ -941,6 +972,7 @@ function writeVendoredClaudeBundle(paths, vendorPath, gatewayUrl) {
|
|
|
941
972
|
|
|
942
973
|
const bundle = paths.claude.launcher;
|
|
943
974
|
const staging = `${bundle}.tmp-${process.pid}`;
|
|
975
|
+
sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
|
|
944
976
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
945
977
|
cloneAppBundle(vendorPath, staging);
|
|
946
978
|
try {
|
|
@@ -1027,6 +1059,7 @@ function writeVendoredChatGPTBundle(paths, vendorPath, gatewayUrl) {
|
|
|
1027
1059
|
const staging = `${bundle}.tmp-${process.pid}`;
|
|
1028
1060
|
const vendorBundleName = path.basename(vendorPath);
|
|
1029
1061
|
const vendorBundle = path.join(staging, "Contents", "Resources", vendorBundleName);
|
|
1062
|
+
sweepStaleBundleArtifacts(path.dirname(bundle), path.basename(bundle));
|
|
1030
1063
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
1031
1064
|
cloneAppBundle(vendorPath, vendorBundle);
|
|
1032
1065
|
try {
|
|
@@ -1653,6 +1686,22 @@ function sha256(contents) {
|
|
|
1653
1686
|
return crypto.createHash("sha256").update(contents).digest("hex");
|
|
1654
1687
|
}
|
|
1655
1688
|
|
|
1689
|
+
export function sweepStaleBundleArtifacts(dir, baseName) {
|
|
1690
|
+
let entries;
|
|
1691
|
+
try {
|
|
1692
|
+
entries = fs.readdirSync(dir);
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
if (error?.code === "ENOENT") return [];
|
|
1695
|
+
throw error;
|
|
1696
|
+
}
|
|
1697
|
+
const prefixes = [`${baseName}.tmp-`, `${baseName}.previous-`];
|
|
1698
|
+
const removed = entries.filter((entry) => prefixes.some((prefix) => entry.startsWith(prefix)));
|
|
1699
|
+
for (const entry of removed) {
|
|
1700
|
+
fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
|
|
1701
|
+
}
|
|
1702
|
+
return removed;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1656
1705
|
function replaceDirectory(destination, staging) {
|
|
1657
1706
|
const backup = `${destination}.previous-${process.pid}`;
|
|
1658
1707
|
fs.rmSync(backup, { recursive: true, force: true });
|
package/src/cli.js
CHANGED
|
@@ -49,7 +49,8 @@ Manage:
|
|
|
49
49
|
Desktop apps (macOS and Windows):
|
|
50
50
|
target is claude, chatgpt/codex, or all (default: all)
|
|
51
51
|
impel app install [target] [--tenant <org>] Install that tenant's isolated app/profile
|
|
52
|
-
impel app update [target] [--tenant <org>] Update
|
|
52
|
+
impel app update [target] [--tenant <org>] Update profiles; rebuild only stale bundles
|
|
53
|
+
(--force performs a clean full rebuild)
|
|
53
54
|
impel app refresh [target] [--tenant <org>] Configs/catalog/skills/agents only; safe while apps run
|
|
54
55
|
impel app status [target] [--tenant <org>] Show isolated app and vendor versions
|
|
55
56
|
impel app uninstall [target] [--tenant <org>] [--keep-data]
|
package/src/commands/apps.js
CHANGED
|
@@ -40,6 +40,7 @@ import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
|
|
|
40
40
|
import { syncAgentProfilesSafe } from "../agents.js";
|
|
41
41
|
import { secureManagedCodexHome } from "../codexSecurity.js";
|
|
42
42
|
import { impelCliInvocation } from "../selfInvocation.js";
|
|
43
|
+
import { withProgress } from "../progress.js";
|
|
43
44
|
import {
|
|
44
45
|
ensureWindowsClaudeApp,
|
|
45
46
|
ensureWindowsChatGPTApp,
|
|
@@ -85,6 +86,20 @@ function windowsProcessFailure(result) {
|
|
|
85
86
|
return "no exit status";
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
export function openManagedLauncher(launcher, {
|
|
90
|
+
spawn = spawnSync,
|
|
91
|
+
reportError = console.error,
|
|
92
|
+
} = {}) {
|
|
93
|
+
const result = spawn("/usr/bin/open", ["-n", launcher], { encoding: "utf8" });
|
|
94
|
+
if (!result?.error && result?.status === 0) return true;
|
|
95
|
+
const detail = result?.error?.message
|
|
96
|
+
|| String(result?.stderr || "").trim()
|
|
97
|
+
|| (Number.isInteger(result?.status) ? `exit code ${result.status}` : "no exit status");
|
|
98
|
+
reportError(`impel app: could not open ${launcher}: ${redactSecretText(detail)}`);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
async function fetchWindowsCatalog(config, io) {
|
|
89
104
|
try {
|
|
90
105
|
return await io.fetchModels(config);
|
|
@@ -123,8 +138,11 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
123
138
|
const flagArgs = rawTarget?.startsWith("--") ? [rawTarget, ...rest] : rest;
|
|
124
139
|
const { flags } = parseFlags(flagArgs, {
|
|
125
140
|
"skip-vendor": { type: "boolean" },
|
|
141
|
+
"skip-skills": { type: "boolean" },
|
|
142
|
+
"skip-agents": { type: "boolean" },
|
|
126
143
|
"keep-data": { type: "boolean" },
|
|
127
144
|
"stale-only": { type: "boolean" },
|
|
145
|
+
force: { type: "boolean" },
|
|
128
146
|
tenant: { type: "string" },
|
|
129
147
|
});
|
|
130
148
|
const io = {
|
|
@@ -253,7 +271,9 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
253
271
|
if (targets.includes("claude") && action !== "refresh" && !vendorPaths.claude) {
|
|
254
272
|
throw new Error("Claude vendor app is unavailable; run `impel app install claude` or install it from https://claude.com/download");
|
|
255
273
|
}
|
|
256
|
-
const catalog = await
|
|
274
|
+
const catalog = await withProgress("Fetching the tenant model catalog", () => (
|
|
275
|
+
fetchWindowsCatalog(config, io)
|
|
276
|
+
));
|
|
257
277
|
let managedChatGPT = targets.includes("chatgpt")
|
|
258
278
|
? io.findManagedChatGPTApp(actionPaths.root)
|
|
259
279
|
: null;
|
|
@@ -283,15 +303,21 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
283
303
|
const profile = target === "claude" ? tenantPaths.claude.userData : tenantPaths.chatgpt.root;
|
|
284
304
|
console.log(`${verb} Impel ${target === "claude" ? "Claude" : "ChatGPT"} profile at ${profile}`);
|
|
285
305
|
const { client, env, label } = appSkillTarget(target, tenantPaths);
|
|
286
|
-
|
|
306
|
+
if (!flags["skip-skills"]) {
|
|
307
|
+
await withProgress(`Syncing skills for ${label}`, () => (
|
|
308
|
+
io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label })
|
|
309
|
+
));
|
|
310
|
+
}
|
|
287
311
|
if (target === "chatgpt") secureManagedCodexHome(tenantPaths.chatgpt.codexHome);
|
|
288
312
|
}
|
|
289
|
-
|
|
290
|
-
profiles
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
313
|
+
if (!flags["skip-agents"]) {
|
|
314
|
+
await withProgress("Syncing app agent profiles", () => io.syncAgents({
|
|
315
|
+
profiles: targets.map((target) => appAgentProfile(target, tenantPaths)),
|
|
316
|
+
gatewayUrl: config.gatewayUrl,
|
|
317
|
+
credential: config.pat,
|
|
318
|
+
tenantId: config.tenantId,
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
295
321
|
console.log(`Models: ${catalog.models.length} from ${catalog.source}. The signed vendor apps and their normal profiles were not changed.`);
|
|
296
322
|
}
|
|
297
323
|
if (action === "open") {
|
|
@@ -320,8 +346,9 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
320
346
|
}
|
|
321
347
|
|
|
322
348
|
export async function cmdApps(argv, overrides = {}) {
|
|
323
|
-
|
|
324
|
-
if (
|
|
349
|
+
const platform = overrides.platform || process.platform;
|
|
350
|
+
if (platform === "win32") return cmdWindowsApps(argv, overrides);
|
|
351
|
+
if (platform !== "darwin") {
|
|
325
352
|
console.error("impel app: isolated Impel desktop apps are unavailable on this platform.");
|
|
326
353
|
process.exitCode = 1;
|
|
327
354
|
return;
|
|
@@ -332,8 +359,11 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
332
359
|
const flagArgs = targetToken?.startsWith("--") ? [targetToken, ...rest] : rest;
|
|
333
360
|
const { flags } = parseFlags(flagArgs, {
|
|
334
361
|
"skip-vendor": { type: "boolean" },
|
|
362
|
+
"skip-skills": { type: "boolean" },
|
|
363
|
+
"skip-agents": { type: "boolean" },
|
|
335
364
|
"keep-data": { type: "boolean" },
|
|
336
365
|
"stale-only": { type: "boolean" },
|
|
366
|
+
force: { type: "boolean" },
|
|
337
367
|
tenant: { type: "string" },
|
|
338
368
|
});
|
|
339
369
|
|
|
@@ -382,7 +412,7 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
382
412
|
if (fastLaunchers) {
|
|
383
413
|
spawnDetachedAppRefresh(tenantId);
|
|
384
414
|
for (const launcher of fastLaunchers) {
|
|
385
|
-
|
|
415
|
+
openManagedLauncher(launcher);
|
|
386
416
|
}
|
|
387
417
|
return;
|
|
388
418
|
}
|
|
@@ -401,55 +431,109 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
401
431
|
throw new Error(`unknown app action "${action}"; use install, update, refresh, status, open, or uninstall`);
|
|
402
432
|
}
|
|
403
433
|
|
|
404
|
-
const
|
|
434
|
+
const io = {
|
|
435
|
+
homeDir: os.homedir(),
|
|
436
|
+
selectedConfig: selectedAppConfig,
|
|
437
|
+
status: appStatus,
|
|
438
|
+
bundleCurrent: bundleIsCurrent,
|
|
439
|
+
ensureVendor: ensureVendorApp,
|
|
440
|
+
fetchModels: fetchGatewayModels,
|
|
441
|
+
quitApps: quitBlockingApps,
|
|
442
|
+
installFiles: installManagedAppFiles,
|
|
443
|
+
syncSkills: syncSkillsSafe,
|
|
444
|
+
syncAgents: syncAgentProfilesSafe,
|
|
445
|
+
secureCodexHome: secureManagedCodexHome,
|
|
446
|
+
removeLauncher: (launcher) => fs.rmSync(launcher, { recursive: true, force: true }),
|
|
447
|
+
log: (message) => console.log(message),
|
|
448
|
+
...overrides,
|
|
449
|
+
};
|
|
450
|
+
const config = await io.selectedConfig(targets, flags.tenant || null);
|
|
405
451
|
console.log(`Tenant: ${config.tenantId} (desktop history is isolated per tenant).`);
|
|
406
452
|
|
|
407
|
-
// A running vendor or Impel app makes the pinned vendor install / bundle swap fail.
|
|
408
|
-
await quitBlockingApps(targets, {
|
|
409
|
-
tenantId: config.tenantId,
|
|
410
|
-
tenantName: config.tenantName,
|
|
411
|
-
});
|
|
412
|
-
|
|
413
453
|
const vendorPaths = {};
|
|
414
454
|
if (!flags["skip-vendor"]) {
|
|
415
455
|
for (const target of targets) {
|
|
416
456
|
// Reuse or install only the exact vendor build verified by this CLI.
|
|
417
457
|
// Moving "latest" releases are never cloned into a managed bundle.
|
|
418
|
-
const result =
|
|
458
|
+
const result = await withProgress(`Preparing the verified ${target} vendor app`, () => (
|
|
459
|
+
io.ensureVendor(target, { homeDir: io.homeDir })
|
|
460
|
+
));
|
|
419
461
|
vendorPaths[target] = result.path;
|
|
420
|
-
|
|
462
|
+
io.log(`${target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
|
|
421
463
|
if (!result.path) throw new Error(`${target} verified vendor app is unavailable; retry the pinned download`);
|
|
422
464
|
}
|
|
423
465
|
}
|
|
424
466
|
|
|
467
|
+
const statuses = io.status(
|
|
468
|
+
targets,
|
|
469
|
+
io.homeDir,
|
|
470
|
+
config.tenantId,
|
|
471
|
+
config.tenantName,
|
|
472
|
+
);
|
|
473
|
+
for (const status of statuses) vendorPaths[status.target] ||= status.vendorPath;
|
|
474
|
+
const force = Boolean(flags.force);
|
|
475
|
+
const staleBundleTargets = force
|
|
476
|
+
? [...targets]
|
|
477
|
+
: statuses.filter((status) => !io.bundleCurrent(status)).map((status) => status.target);
|
|
478
|
+
|
|
425
479
|
let catalog;
|
|
426
480
|
try {
|
|
427
|
-
catalog = await
|
|
481
|
+
catalog = await withProgress("Fetching the tenant model catalog", () => io.fetchModels(config));
|
|
428
482
|
} catch (error) {
|
|
429
483
|
throw new Error(
|
|
430
484
|
`tenant model catalog is unavailable (${redactSecretText(error.message)}); no Impel app files were changed`,
|
|
431
485
|
);
|
|
432
486
|
}
|
|
433
|
-
|
|
434
|
-
|
|
487
|
+
|
|
488
|
+
// A running vendor or Impel app only needs to close when its bundle will be swapped.
|
|
489
|
+
if (staleBundleTargets.length > 0) {
|
|
490
|
+
await io.quitApps(staleBundleTargets, {
|
|
491
|
+
tenantId: config.tenantId,
|
|
492
|
+
tenantName: config.tenantName,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
if (force) {
|
|
496
|
+
for (const status of statuses) io.removeLauncher(status.launcher);
|
|
497
|
+
}
|
|
498
|
+
const installed = await withProgress(
|
|
499
|
+
staleBundleTargets.length > 0 ? "Rebuilding managed app bundles" : "Updating managed app profiles",
|
|
500
|
+
() => io.installFiles({
|
|
501
|
+
config,
|
|
502
|
+
targets,
|
|
503
|
+
models: catalog.models,
|
|
504
|
+
homeDir: io.homeDir,
|
|
505
|
+
vendorPaths,
|
|
506
|
+
writeBundles: staleBundleTargets,
|
|
507
|
+
}),
|
|
508
|
+
);
|
|
509
|
+
const rebuilt = new Set(staleBundleTargets);
|
|
510
|
+
for (const item of installed) {
|
|
511
|
+
const verb = action === "install" ? "Installed" : "Updated";
|
|
512
|
+
io.log(`${verb} ${item.launcher}${rebuilt.has(item.target) ? "" : " (bundle already current)"}`);
|
|
513
|
+
}
|
|
435
514
|
console.log(`Models: ${catalog.models.length} from ${catalog.source}. Normal ~/.claude and ~/.codex profiles were not changed.`);
|
|
436
515
|
|
|
437
516
|
// Best-effort: sync the Bifrost shared skills into each installed isolated app
|
|
438
517
|
// profile. Never fails the install/update.
|
|
439
|
-
const paths = appPaths(
|
|
518
|
+
const paths = appPaths(io.homeDir, config.tenantId, { tenantName: config.tenantName });
|
|
440
519
|
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
441
520
|
for (const item of installed) {
|
|
442
521
|
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
443
|
-
|
|
444
|
-
|
|
522
|
+
if (!flags["skip-skills"]) {
|
|
523
|
+
await withProgress(`Syncing skills for ${label}`, () => (
|
|
524
|
+
io.syncSkills({ client, gatewayUrl, env, label })
|
|
525
|
+
));
|
|
526
|
+
}
|
|
527
|
+
if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
|
|
528
|
+
}
|
|
529
|
+
if (!flags["skip-agents"]) {
|
|
530
|
+
await withProgress("Syncing app agent profiles", () => io.syncAgents({
|
|
531
|
+
profiles: installed.map((item) => appAgentProfile(item.target, paths)),
|
|
532
|
+
gatewayUrl: config.gatewayUrl,
|
|
533
|
+
credential: config.pat,
|
|
534
|
+
tenantId: config.tenantId,
|
|
535
|
+
}));
|
|
445
536
|
}
|
|
446
|
-
const syncAgents = overrides.syncAgents || syncAgentProfilesSafe;
|
|
447
|
-
await syncAgents({
|
|
448
|
-
profiles: installed.map((item) => appAgentProfile(item.target, paths)),
|
|
449
|
-
gatewayUrl: config.gatewayUrl,
|
|
450
|
-
credential: config.pat,
|
|
451
|
-
tenantId: config.tenantId,
|
|
452
|
-
});
|
|
453
537
|
}
|
|
454
538
|
|
|
455
539
|
/**
|
|
@@ -474,7 +558,7 @@ export async function provisionAndOpenManagedApps({
|
|
|
474
558
|
syncSkills: syncSkillsSafe,
|
|
475
559
|
syncAgents: syncAgentProfilesSafe,
|
|
476
560
|
secureCodexHome: secureManagedCodexHome,
|
|
477
|
-
openLauncher:
|
|
561
|
+
openLauncher: openManagedLauncher,
|
|
478
562
|
log: (message) => console.log(message),
|
|
479
563
|
...overrides,
|
|
480
564
|
};
|
|
@@ -483,7 +567,7 @@ export async function provisionAndOpenManagedApps({
|
|
|
483
567
|
// This keeps unavailable/unauthorized tenants fail-closed on first use.
|
|
484
568
|
let catalog;
|
|
485
569
|
try {
|
|
486
|
-
catalog = await io.fetchModels(config);
|
|
570
|
+
catalog = await withProgress("Fetching the tenant model catalog", () => io.fetchModels(config));
|
|
487
571
|
} catch (error) {
|
|
488
572
|
throw new Error(
|
|
489
573
|
`tenant model catalog is unavailable (${redactSecretText(error?.message || error)}); the selected tenant app was not opened`,
|
|
@@ -494,7 +578,9 @@ export async function provisionAndOpenManagedApps({
|
|
|
494
578
|
for (const status of statuses) {
|
|
495
579
|
let vendorPath = status.vendorPath;
|
|
496
580
|
if (!vendorPath) {
|
|
497
|
-
const result =
|
|
581
|
+
const result = await withProgress(`Preparing the verified ${status.target} vendor app`, () => (
|
|
582
|
+
io.ensureVendor(status.target, { homeDir })
|
|
583
|
+
));
|
|
498
584
|
vendorPath = result.path;
|
|
499
585
|
io.log(`${status.target}: vendor app ${result.action}${result.note ? ` (${result.note})` : ""}`);
|
|
500
586
|
if (!vendorPath) {
|
|
@@ -516,14 +602,17 @@ export async function provisionAndOpenManagedApps({
|
|
|
516
602
|
});
|
|
517
603
|
}
|
|
518
604
|
|
|
519
|
-
const installed =
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
605
|
+
const installed = await withProgress(
|
|
606
|
+
staleBundleTargets.length > 0 ? "Rebuilding managed app bundles" : "Updating managed app profiles",
|
|
607
|
+
() => io.installFiles({
|
|
608
|
+
config,
|
|
609
|
+
targets,
|
|
610
|
+
models: catalog.models,
|
|
611
|
+
homeDir,
|
|
612
|
+
vendorPaths,
|
|
613
|
+
writeBundles: staleBundleTargets,
|
|
614
|
+
}),
|
|
615
|
+
);
|
|
527
616
|
const newlyInstalled = new Set(
|
|
528
617
|
statuses.filter((status) => !status.launcherInstalled).map((status) => status.target),
|
|
529
618
|
);
|
|
@@ -539,15 +628,17 @@ export async function provisionAndOpenManagedApps({
|
|
|
539
628
|
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
540
629
|
for (const item of installed) {
|
|
541
630
|
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
542
|
-
await
|
|
631
|
+
await withProgress(`Syncing skills for ${label}`, () => (
|
|
632
|
+
io.syncSkills({ client, gatewayUrl, env, label })
|
|
633
|
+
));
|
|
543
634
|
if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
|
|
544
635
|
}
|
|
545
|
-
await io.syncAgents({
|
|
636
|
+
await withProgress("Syncing app agent profiles", () => io.syncAgents({
|
|
546
637
|
profiles: installed.map((item) => appAgentProfile(item.target, paths)),
|
|
547
638
|
gatewayUrl: config.gatewayUrl,
|
|
548
639
|
credential: config.pat,
|
|
549
640
|
tenantId: config.tenantId,
|
|
550
|
-
});
|
|
641
|
+
}));
|
|
551
642
|
for (const item of installed) await io.openLauncher(item.launcher);
|
|
552
643
|
return installed;
|
|
553
644
|
}
|
package/src/commands/skills.js
CHANGED
|
@@ -15,6 +15,7 @@ import { CLAUDE_CONFIG_ID, appPaths } from "../apps.js";
|
|
|
15
15
|
import { resolveSkillsGateway, syncSkillsSafe } from "../skills.js";
|
|
16
16
|
import { ensureTenantSelection } from "../tenants.js";
|
|
17
17
|
import { windowsClaudeUserData } from "../windowsApps.js";
|
|
18
|
+
import { mapWithConcurrency, withProgress } from "../progress.js";
|
|
18
19
|
|
|
19
20
|
const VALID_TARGETS = ["claude", "codex", "all"];
|
|
20
21
|
|
|
@@ -78,7 +79,7 @@ export function managedSkillProfiles(
|
|
|
78
79
|
return profiles;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
export async function cmdSkills(argv) {
|
|
82
|
+
export async function cmdSkills(argv, overrides = {}) {
|
|
82
83
|
const [action = "sync", targetToken] = argv;
|
|
83
84
|
if (action !== "sync") {
|
|
84
85
|
console.error(`impel skills: unknown action "${action}". Try \`impel skills sync [claude|codex|all]\`.`);
|
|
@@ -93,16 +94,26 @@ export async function cmdSkills(argv) {
|
|
|
93
94
|
return;
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
const
|
|
97
|
+
const io = {
|
|
98
|
+
loadConfig,
|
|
99
|
+
ensureTenantSelection,
|
|
100
|
+
managedProfiles: managedSkillProfiles,
|
|
101
|
+
syncSkills: syncSkillsSafe,
|
|
102
|
+
...overrides,
|
|
103
|
+
};
|
|
104
|
+
const config = io.loadConfig();
|
|
97
105
|
const gatewayUrl = resolveSkillsGateway(config?.gatewayUrl);
|
|
98
106
|
const tenantId = config?.pat
|
|
99
|
-
? (await ensureTenantSelection(config)).tenantId
|
|
107
|
+
? (await io.ensureTenantSelection(config)).tenantId
|
|
100
108
|
: null;
|
|
101
109
|
const clients = target === "all" ? ["claude", "codex"] : [target];
|
|
110
|
+
const profiles = clients.flatMap((client) => (
|
|
111
|
+
io.managedProfiles(client, { tenantId }).map((profile) => ({ client, profile }))
|
|
112
|
+
));
|
|
102
113
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
await withProgress(`Syncing skills into ${profiles.length} managed profile${profiles.length === 1 ? "" : "s"}`, () => (
|
|
115
|
+
mapWithConcurrency(profiles, 4, ({ client, profile }) => (
|
|
116
|
+
io.syncSkills({ client, gatewayUrl, env: profile.env, label: profile.label })
|
|
117
|
+
))
|
|
118
|
+
));
|
|
108
119
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -14,6 +14,7 @@ import { CLAUDE_CONFIG_ID, appPaths } from "../apps.js";
|
|
|
14
14
|
import { loadConfig, redactSecretText } from "../config.js";
|
|
15
15
|
import { nativeCommandInvocation } from "../nativeProcess.js";
|
|
16
16
|
import { windowsClaudeUserData } from "../windowsApps.js";
|
|
17
|
+
import { withProgress } from "../progress.js";
|
|
17
18
|
import {
|
|
18
19
|
fetchRemoteVersion,
|
|
19
20
|
installedVersion,
|
|
@@ -88,8 +89,12 @@ function defaultSelfUpdate(spec) {
|
|
|
88
89
|
|
|
89
90
|
// The cascading steps re-execute the (freshly installed) CLI binary so the
|
|
90
91
|
// NEW code performs them, not the process that started the update.
|
|
91
|
-
function defaultRunAppsUpdate(
|
|
92
|
-
|
|
92
|
+
export function defaultRunAppsUpdate({
|
|
93
|
+
spawn = spawnSync,
|
|
94
|
+
execPath = process.execPath,
|
|
95
|
+
cliBin = CLI_BIN,
|
|
96
|
+
} = {}) {
|
|
97
|
+
const result = spawn(execPath, [cliBin, "app", "update", "all", "--skip-skills", "--skip-agents"], {
|
|
93
98
|
stdio: "inherit",
|
|
94
99
|
});
|
|
95
100
|
return result.status === 0;
|
|
@@ -166,7 +171,7 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
166
171
|
}
|
|
167
172
|
|
|
168
173
|
const current = io.installedVersion();
|
|
169
|
-
const remote = await io.fetchRemoteVersion();
|
|
174
|
+
const remote = await withProgress("Checking npm for impel-cli updates", () => io.fetchRemoteVersion());
|
|
170
175
|
if (remote) io.writeCache({ remoteVersion: remote, checkedAt: Date.now() });
|
|
171
176
|
|
|
172
177
|
console.log(`impel-cli v${current ?? "?"}`);
|
|
@@ -185,7 +190,7 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
185
190
|
console.log("CLI: already up to date.");
|
|
186
191
|
} else {
|
|
187
192
|
console.log("CLI: installing the latest build…");
|
|
188
|
-
if (!io.selfUpdate(updateInstallSpec())) {
|
|
193
|
+
if (!await withProgress("Installing the latest impel-cli build", () => io.selfUpdate(updateInstallSpec()))) {
|
|
189
194
|
console.error("impel update: `npm install -g` failed; the CLI was not updated.");
|
|
190
195
|
if (io.platform === "win32") {
|
|
191
196
|
console.error(" Verify `npm --version` in PowerShell, then retry `impel update`.");
|
|
@@ -208,21 +213,21 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
208
213
|
} else {
|
|
209
214
|
console.log(io.platform === "win32"
|
|
210
215
|
? "Apps: updating the signed Claude and ChatGPT vendor apps and isolated profiles…"
|
|
211
|
-
: "Apps:
|
|
212
|
-
if (!io.runAppsUpdate()) {
|
|
216
|
+
: "Apps: refreshing profiles and rebuilding only stale app bundles…");
|
|
217
|
+
if (!await withProgress("Updating managed desktop apps", () => io.runAppsUpdate())) {
|
|
213
218
|
console.error("impel update: the app update failed; re-run `impel app update` after fixing the issue.");
|
|
214
219
|
cascadeFailed = true;
|
|
215
220
|
}
|
|
216
221
|
}
|
|
217
222
|
|
|
218
223
|
console.log("Skills: syncing every managed profile…");
|
|
219
|
-
if (!io.runSkillsSync()) {
|
|
224
|
+
if (!await withProgress("Syncing skills across managed profiles", () => io.runSkillsSync())) {
|
|
220
225
|
console.error("impel update: skill sync failed; re-run `impel skills sync` after fixing the issue.");
|
|
221
226
|
cascadeFailed = true;
|
|
222
227
|
}
|
|
223
228
|
|
|
224
229
|
console.log("Agents: syncing the selected tenant into every managed profile…");
|
|
225
|
-
if (!io.runAgentsSync()) {
|
|
230
|
+
if (!await withProgress("Syncing agents across managed profiles", () => io.runAgentsSync())) {
|
|
226
231
|
console.error("impel update: agent sync failed; re-run `impel agents sync` after fixing the issue.");
|
|
227
232
|
cascadeFailed = true;
|
|
228
233
|
}
|
package/src/progress.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
2
|
+
|
|
3
|
+
function noopSpinner() {
|
|
4
|
+
return {
|
|
5
|
+
update() {},
|
|
6
|
+
succeed() {},
|
|
7
|
+
fail() {},
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render a small stderr-only spinner without changing machine-readable stdout.
|
|
13
|
+
* Non-interactive processes and detached app refreshes get a no-op object.
|
|
14
|
+
*/
|
|
15
|
+
export function createSpinner(label, { stream = process.stderr } = {}) {
|
|
16
|
+
if (!stream?.isTTY || process.env.IMPEL_NO_PROGRESS === "1") return noopSpinner();
|
|
17
|
+
|
|
18
|
+
let currentLabel = String(label);
|
|
19
|
+
let frame = 0;
|
|
20
|
+
let finished = false;
|
|
21
|
+
const startedAt = Date.now();
|
|
22
|
+
const render = () => {
|
|
23
|
+
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
24
|
+
stream.write(`\r${FRAMES[frame % FRAMES.length]} ${currentLabel} (${elapsed}s)\x1b[K`);
|
|
25
|
+
frame += 1;
|
|
26
|
+
};
|
|
27
|
+
const finish = (symbol, nextLabel) => {
|
|
28
|
+
if (finished) return;
|
|
29
|
+
finished = true;
|
|
30
|
+
clearInterval(timer);
|
|
31
|
+
if (nextLabel) currentLabel = String(nextLabel);
|
|
32
|
+
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
33
|
+
stream.write(`\r${symbol} ${currentLabel} (${elapsed}s)\x1b[K\n`);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
render();
|
|
37
|
+
const timer = setInterval(render, 80);
|
|
38
|
+
timer.unref?.();
|
|
39
|
+
return {
|
|
40
|
+
update(nextLabel) {
|
|
41
|
+
if (!finished && nextLabel) currentLabel = String(nextLabel);
|
|
42
|
+
},
|
|
43
|
+
succeed(nextLabel) {
|
|
44
|
+
finish("✓", nextLabel);
|
|
45
|
+
},
|
|
46
|
+
fail(nextLabel) {
|
|
47
|
+
finish("✗", nextLabel);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function withProgress(label, fn, options) {
|
|
53
|
+
const spinner = createSpinner(label, options);
|
|
54
|
+
try {
|
|
55
|
+
const result = await fn(spinner);
|
|
56
|
+
spinner.succeed();
|
|
57
|
+
return result;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
spinner.fail();
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Run independent jobs with bounded concurrency while preserving result order. */
|
|
65
|
+
export async function mapWithConcurrency(items, limit, fn) {
|
|
66
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
67
|
+
throw new RangeError("concurrency limit must be a positive integer");
|
|
68
|
+
}
|
|
69
|
+
const values = Array.from(items);
|
|
70
|
+
const results = new Array(values.length);
|
|
71
|
+
let nextIndex = 0;
|
|
72
|
+
|
|
73
|
+
async function worker() {
|
|
74
|
+
while (nextIndex < values.length) {
|
|
75
|
+
const index = nextIndex;
|
|
76
|
+
nextIndex += 1;
|
|
77
|
+
results[index] = await fn(values[index], index);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await Promise.all(
|
|
82
|
+
Array.from({ length: Math.min(limit, values.length) }, () => worker()),
|
|
83
|
+
);
|
|
84
|
+
return results;
|
|
85
|
+
}
|