impel-cli 0.17.15 → 0.17.16
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/package.json +1 -1
- package/src/commands/apps.js +77 -36
- package/src/commands/converge.js +17 -6
- package/src/commands/launch.js +6 -1
- package/src/commands/setup.js +30 -8
- package/src/nativeProcess.js +39 -9
- package/src/provisioning.js +34 -10
- package/src/skills.js +45 -2
package/package.json
CHANGED
package/src/commands/apps.js
CHANGED
|
@@ -261,9 +261,23 @@ async function maybeUpdateAllInstalledTenants(argv, overrides, platform) {
|
|
|
261
261
|
return true;
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
// winget exits with HRESULTs; the raw decimal (e.g. 2316632066) hides the
|
|
265
|
+
// recognizable 0x8A15xxxx APPINSTALLER facility, so render hex alongside it.
|
|
266
|
+
const WINGET_EXIT_HINTS = new Map([
|
|
267
|
+
[0x8A150002, "winget rejected the command-line arguments"],
|
|
268
|
+
[0x8A150014, "no package matched the winget query"],
|
|
269
|
+
[0x8A15001B, "the Microsoft Store client is blocked by policy"],
|
|
270
|
+
[0x8A15001C, "the Microsoft Store app is blocked by policy"],
|
|
271
|
+
]);
|
|
272
|
+
|
|
264
273
|
function windowsProcessFailure(result) {
|
|
265
274
|
if (result?.error?.message) return redactSecretText(result.error.message);
|
|
266
|
-
if (Number.isInteger(result?.status))
|
|
275
|
+
if (Number.isInteger(result?.status)) {
|
|
276
|
+
const status = result.status >>> 0;
|
|
277
|
+
if (status < 0x80000000) return `exit code ${result.status}`;
|
|
278
|
+
const hint = WINGET_EXIT_HINTS.get(status);
|
|
279
|
+
return `exit code ${result.status} (0x${status.toString(16).toUpperCase()}${hint ? ` — ${hint}` : ""})`;
|
|
280
|
+
}
|
|
267
281
|
if (result?.signal) return `signal ${result.signal}`;
|
|
268
282
|
return "no exit status";
|
|
269
283
|
}
|
|
@@ -368,45 +382,62 @@ export async function reconcileWindowsTenantApps({
|
|
|
368
382
|
const vendorPaths = {};
|
|
369
383
|
const preparedTargets = new Set(skipVendor ? actionTargets : skipVendorTargets);
|
|
370
384
|
|
|
385
|
+
// A target that cannot proceed (declined confirmation, failed installer,
|
|
386
|
+
// missing vendor app) must not abort the other target's work: install
|
|
387
|
+
// recovery approves one vendor app at a time, and that approved install
|
|
388
|
+
// has to run even while its sibling stays missing.
|
|
389
|
+
const failed = [];
|
|
390
|
+
const fail = (target, error) => failed.push({ target, error });
|
|
391
|
+
const hasFailed = (target) => failed.some((failure) => failure.target === target);
|
|
392
|
+
|
|
371
393
|
for (const target of actionTargets) {
|
|
372
394
|
const isClaude = target === "claude";
|
|
395
|
+
const label = isClaude ? "Claude" : "ChatGPT/Codex";
|
|
373
396
|
const find = isClaude ? io.findClaudeApp : io.findChatGPTApp;
|
|
374
397
|
const ensure = isClaude ? io.ensureClaudeApp : io.ensureChatGPTApp;
|
|
375
398
|
let binary = find(environment);
|
|
376
399
|
if (!preparedTargets.has(target)) {
|
|
377
400
|
const confirmed = await confirmVendorInstall(target, { platform: "win32", mode });
|
|
378
401
|
if (!confirmed) {
|
|
379
|
-
|
|
402
|
+
fail(target, `${label} vendor ${mode === "update" ? "update" : "installation"} requires confirmation`);
|
|
403
|
+
continue;
|
|
380
404
|
}
|
|
381
405
|
const vendor = ensure({ update: mode === "update" }, { environment });
|
|
382
406
|
binary = vendor.binary;
|
|
383
407
|
if (!binary) {
|
|
384
|
-
|
|
385
|
-
|
|
408
|
+
fail(target, `${label} vendor installation failed (${windowsProcessFailure(vendor.result)})`);
|
|
409
|
+
continue;
|
|
386
410
|
}
|
|
387
411
|
}
|
|
388
412
|
if (binary) vendorPaths[target] = binary;
|
|
389
413
|
}
|
|
390
|
-
if (actionTargets.includes("claude") && !vendorPaths.claude) {
|
|
391
|
-
|
|
414
|
+
if (actionTargets.includes("claude") && !hasFailed("claude") && !vendorPaths.claude) {
|
|
415
|
+
fail("claude", "Claude vendor app is unavailable");
|
|
392
416
|
}
|
|
393
|
-
if (actionTargets.includes("chatgpt")) {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (!
|
|
398
|
-
|
|
417
|
+
if (actionTargets.includes("chatgpt") && !hasFailed("chatgpt")) {
|
|
418
|
+
try {
|
|
419
|
+
let managed = io.findManagedChatGPTApp(paths.root);
|
|
420
|
+
const stale = vendorPaths.chatgpt && !io.chatGPTStageIsCurrent(vendorPaths.chatgpt, paths.root);
|
|
421
|
+
if (!managed || stale) {
|
|
422
|
+
if (!vendorPaths.chatgpt) throw new Error("ChatGPT/Codex vendor app is unavailable");
|
|
423
|
+
managed = io.stageChatGPTApp(vendorPaths.chatgpt, paths.root);
|
|
424
|
+
}
|
|
425
|
+
if (!managed) throw new Error("the managed ChatGPT/Codex app could not be staged");
|
|
426
|
+
} catch (error) {
|
|
427
|
+
fail("chatgpt", redactSecretText(error?.message || error));
|
|
399
428
|
}
|
|
400
|
-
if (!managed) throw new Error("the managed ChatGPT/Codex app could not be staged");
|
|
401
429
|
}
|
|
430
|
+
const completedTargets = actionTargets.filter((target) => !hasFailed(target));
|
|
402
431
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
432
|
+
if (completedTargets.length) {
|
|
433
|
+
configureWindowsApps(config, completedTargets, vendorPaths, catalog, {
|
|
434
|
+
...io,
|
|
435
|
+
homeDir,
|
|
436
|
+
environment,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
408
439
|
const agentTargets = [];
|
|
409
|
-
for (const target of
|
|
440
|
+
for (const target of completedTargets) {
|
|
410
441
|
const { client, env, label } = appSkillTarget(target, paths, {
|
|
411
442
|
platform: "win32",
|
|
412
443
|
existsSync: io.existsSync,
|
|
@@ -431,11 +462,11 @@ export async function reconcileWindowsTenantApps({
|
|
|
431
462
|
});
|
|
432
463
|
}
|
|
433
464
|
const verification = {
|
|
434
|
-
claude: !
|
|
435
|
-
chatgpt: !
|
|
465
|
+
claude: !completedTargets.includes("claude") || io.existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)),
|
|
466
|
+
chatgpt: !completedTargets.includes("chatgpt") || io.existsSync(path.join(paths.chatgpt.codexHome, "config.toml")),
|
|
436
467
|
};
|
|
437
468
|
if (!Object.values(verification).every(Boolean)) throw new Error("Windows app profile verification failed");
|
|
438
|
-
return { tenantId: config.tenantId, targets:
|
|
469
|
+
return { tenantId: config.tenantId, targets: completedTargets, unsupported, paths, verification, failed };
|
|
439
470
|
}
|
|
440
471
|
|
|
441
472
|
/** Reconcile one explicit tenant's macOS app bundles and profiles. */
|
|
@@ -480,17 +511,27 @@ export async function reconcileMacTenantApps({
|
|
|
480
511
|
const statuses = io.status(actionTargets, homeDir, config.tenantId, config.tenantName);
|
|
481
512
|
for (const status of statuses) vendorPaths[status.target] ||= status.vendorPath;
|
|
482
513
|
const preparedTargets = new Set(skipVendor ? actionTargets : skipVendorTargets);
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
514
|
+
// As on Windows, a declined or failed vendor install skips only that
|
|
515
|
+
// target so the sibling app still converges (install recovery approves one
|
|
516
|
+
// vendor app at a time).
|
|
517
|
+
const failed = [];
|
|
518
|
+
const fail = (target, error) => failed.push({ target, error });
|
|
519
|
+
const hasFailed = (target) => failed.some((failure) => failure.target === target);
|
|
520
|
+
for (const target of actionTargets.filter((target) => !preparedTargets.has(target))) {
|
|
521
|
+
if (vendorPaths[target]) continue;
|
|
522
|
+
const confirmed = await confirmVendorInstall(target, { platform: "darwin", mode });
|
|
523
|
+
if (!confirmed) {
|
|
524
|
+
fail(target, `${target} vendor installation requires confirmation`);
|
|
525
|
+
continue;
|
|
491
526
|
}
|
|
527
|
+
const result = await io.ensureVendor(target, { homeDir });
|
|
528
|
+
vendorPaths[target] = result.path;
|
|
529
|
+
if (!result.path) fail(target, `${target} verified vendor app is unavailable; retry the pinned download`);
|
|
492
530
|
}
|
|
493
|
-
const
|
|
531
|
+
const completedTargets = actionTargets.filter((target) => !hasFailed(target));
|
|
532
|
+
const staleTargets = statuses
|
|
533
|
+
.filter((status) => completedTargets.includes(status.target) && !io.bundleCurrent(status))
|
|
534
|
+
.map((status) => status.target);
|
|
494
535
|
let closedApps = [];
|
|
495
536
|
if (staleTargets.length) {
|
|
496
537
|
const result = await io.quitApps(staleTargets, { tenantId: config.tenantId, tenantName: config.tenantName });
|
|
@@ -499,14 +540,14 @@ export async function reconcileMacTenantApps({
|
|
|
499
540
|
const reopenTargets = new Set(staleTargets.filter((target) => closedApps.includes(
|
|
500
541
|
managedLauncherName(target, { tenantId: config.tenantId, tenantName: config.tenantName }),
|
|
501
542
|
)));
|
|
502
|
-
const installed = await io.installFiles({
|
|
543
|
+
const installed = completedTargets.length ? await io.installFiles({
|
|
503
544
|
config,
|
|
504
|
-
targets:
|
|
545
|
+
targets: completedTargets,
|
|
505
546
|
models: catalog.models,
|
|
506
547
|
homeDir,
|
|
507
548
|
vendorPaths,
|
|
508
549
|
writeBundles: staleTargets,
|
|
509
|
-
});
|
|
550
|
+
}) : [];
|
|
510
551
|
const paths = appPaths(homeDir, config.tenantId, { tenantName: config.tenantName });
|
|
511
552
|
const agentItems = [];
|
|
512
553
|
for (const item of installed) {
|
|
@@ -528,7 +569,7 @@ export async function reconcileMacTenantApps({
|
|
|
528
569
|
for (const item of installed) {
|
|
529
570
|
if (reopenTargets.has(item.target)) io.openLauncher(item.launcher);
|
|
530
571
|
}
|
|
531
|
-
const verification = Object.fromEntries(
|
|
572
|
+
const verification = Object.fromEntries(completedTargets.map((target) => [
|
|
532
573
|
target,
|
|
533
574
|
io.existsSync(paths[target].launcher) && io.existsSync(
|
|
534
575
|
target === "claude"
|
|
@@ -537,7 +578,7 @@ export async function reconcileMacTenantApps({
|
|
|
537
578
|
),
|
|
538
579
|
]));
|
|
539
580
|
if (!Object.values(verification).every(Boolean)) throw new Error("macOS app bundle/profile verification failed");
|
|
540
|
-
return { tenantId: config.tenantId, targets:
|
|
581
|
+
return { tenantId: config.tenantId, targets: completedTargets, unsupported, paths, verification, installed, failed };
|
|
541
582
|
}
|
|
542
583
|
|
|
543
584
|
export function reconcileTenantApps(options = {}, overrides = {}) {
|
package/src/commands/converge.js
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { loadConfig, saveConfig, redactSecretText } from "../config.js";
|
|
4
4
|
import { runInstallRecovery } from "../installRecovery/engine.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
printReconciliationSummary,
|
|
7
|
+
reconcileAllTenants,
|
|
8
|
+
selectDefaultTenant,
|
|
9
|
+
vendorAppTargetReady,
|
|
10
|
+
} from "../provisioning.js";
|
|
6
11
|
import { fetchTenants } from "../tenants.js";
|
|
7
12
|
import { promptText } from "../prompt.js";
|
|
8
13
|
import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
|
|
@@ -258,11 +263,17 @@ export async function cmdConverge(argv = [], overrides = {}) {
|
|
|
258
263
|
return ["ready", "unavailable"].includes(profileReport.tenants[0]?.cli);
|
|
259
264
|
},
|
|
260
265
|
retryStep: () => retrySafeState(),
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
+
// Succeed when the requested vendor app converged, even if the
|
|
267
|
+
// sibling app still fails: the goal check keeps demanding full
|
|
268
|
+
// tenant readiness, so recovery can approve one app at a time.
|
|
269
|
+
installVendorApp: async (target) => {
|
|
270
|
+
await retryTenant(async (product) => (
|
|
271
|
+
target === "all"
|
|
272
|
+
|| product === target
|
|
273
|
+
|| (target === "codex" && product === "chatgpt")
|
|
274
|
+
));
|
|
275
|
+
return vendorAppTargetReady(retryReport, target);
|
|
276
|
+
},
|
|
266
277
|
},
|
|
267
278
|
}, overrides.recoveryOverrides || {});
|
|
268
279
|
if (retryReport) mergeTenantReport(report, retryReport);
|
package/src/commands/launch.js
CHANGED
|
@@ -31,7 +31,12 @@ const CLAUDE_DIRECT_AUTH_ENV = [
|
|
|
31
31
|
];
|
|
32
32
|
|
|
33
33
|
const CODEX_DIRECT_AUTH_ENV = ["CODEX_ACCESS_TOKEN", "CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL"];
|
|
34
|
-
export {
|
|
34
|
+
export {
|
|
35
|
+
escapeWindowsBareArgument,
|
|
36
|
+
escapeWindowsBatchArgument,
|
|
37
|
+
nativeSpawnInvocation,
|
|
38
|
+
resolveNativeBinary,
|
|
39
|
+
} from "../nativeProcess.js";
|
|
35
40
|
|
|
36
41
|
export const IMPEL_SPECIALIST_DELEGATION_INSTRUCTIONS = `You are running in an Impel tenant-scoped session. Before starting any non-trivial task, call the Impel MCP tool list_specialists. If exactly one available specialist clearly matches the user's request, its capabilities and its exclusions, delegate the complete request by calling start_specialist_run exactly once with a stable idempotency key, then call read_specialist_run until it reaches a terminal state. When the run succeeds, use the specialist's result as your response instead of redoing the work. If no specialist clearly matches, the tools are unavailable, or the run fails, continue normally yourself. Do not delegate trivial requests, do not call a specialist excluded from the request, and never invent a specialist result.`;
|
|
37
42
|
|
package/src/commands/setup.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
printReconciliationSummary,
|
|
17
17
|
reconcileAllTenants,
|
|
18
18
|
selectDefaultTenant,
|
|
19
|
+
vendorAppTargetReady,
|
|
19
20
|
} from "../provisioning.js";
|
|
20
21
|
import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
|
|
21
22
|
import { runInstallRecovery } from "../installRecovery/engine.js";
|
|
@@ -49,9 +50,9 @@ export function resolveTenantChoice(listing, { requested = null, answer = null,
|
|
|
49
50
|
return selectDefaultTenant(listing, { currentTenantId });
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
async function
|
|
53
|
+
async function probeGatewayOnce(gatewayUrl, pat, tenantId, fetchImpl, timeoutMs) {
|
|
53
54
|
const controller = new AbortController();
|
|
54
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
55
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
55
56
|
try {
|
|
56
57
|
const response = await fetchImpl(`${gatewayUrl}/v1/models`, {
|
|
57
58
|
headers: {
|
|
@@ -69,13 +70,28 @@ async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
|
|
|
69
70
|
} catch (error) {
|
|
70
71
|
return {
|
|
71
72
|
reachable: false,
|
|
72
|
-
error: error?.name === "AbortError"
|
|
73
|
+
error: error?.name === "AbortError"
|
|
74
|
+
? `timed out after ${Math.round(timeoutMs / 1000)}s`
|
|
75
|
+
: redactSecretText(error?.message || error),
|
|
73
76
|
};
|
|
74
77
|
} finally {
|
|
75
78
|
clearTimeout(timeout);
|
|
76
79
|
}
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
// A single short probe misreports a cold gateway (serverless cold start plus
|
|
83
|
+
// long-haul latency) as unreachable, and that transient verdict gets baked
|
|
84
|
+
// into the convergence failure message. Retry once with a longer deadline
|
|
85
|
+
// before concluding the gateway is down.
|
|
86
|
+
export async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch, timeoutsMs = [5_000, 10_000]) {
|
|
87
|
+
let probe = { reachable: false, error: "not probed" };
|
|
88
|
+
for (const timeoutMs of timeoutsMs) {
|
|
89
|
+
probe = await probeGatewayOnce(gatewayUrl, pat, tenantId, fetchImpl, timeoutMs);
|
|
90
|
+
if (probe.reachable) return probe;
|
|
91
|
+
}
|
|
92
|
+
return probe;
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
function markProbeFailures(report, probes) {
|
|
80
96
|
probes.forEach((probe, index) => {
|
|
81
97
|
if (probe.reachable && !probe.rejected && probe.healthy !== false) return;
|
|
@@ -406,11 +422,17 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
406
422
|
return ["ready", "unavailable"].includes(profileReport.tenants[0]?.cli);
|
|
407
423
|
},
|
|
408
424
|
retryStep: () => retrySafeState(),
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
425
|
+
// Succeed when the requested vendor app converged, even if the
|
|
426
|
+
// sibling app still fails: the goal check keeps demanding full
|
|
427
|
+
// tenant readiness, so recovery can approve one app at a time.
|
|
428
|
+
installVendorApp: async (target) => {
|
|
429
|
+
await retryTenant(async (product) => (
|
|
430
|
+
target === "all"
|
|
431
|
+
|| product === target
|
|
432
|
+
|| (target === "codex" && product === "chatgpt")
|
|
433
|
+
));
|
|
434
|
+
return vendorAppTargetReady(retryReport, target);
|
|
435
|
+
},
|
|
414
436
|
},
|
|
415
437
|
}, overrides.recoveryOverrides || {});
|
|
416
438
|
if (retryReport) mergeTenantReport(report, retryReport);
|
package/src/nativeProcess.js
CHANGED
|
@@ -27,8 +27,13 @@ function isExecutable(filePath, platform = process.platform) {
|
|
|
27
27
|
if (!fs.statSync(filePath).isFile()) return false;
|
|
28
28
|
if (platform !== "win32") fs.accessSync(filePath, fs.constants.X_OK);
|
|
29
29
|
return true;
|
|
30
|
-
} catch {
|
|
31
|
-
|
|
30
|
+
} catch (error) {
|
|
31
|
+
// Windows Store App Execution Aliases (winget.exe, python.exe, …) are
|
|
32
|
+
// zero-byte APPEXECLINK reparse points that CreateProcess launches fine,
|
|
33
|
+
// but fs.stat cannot read them on Node runtimes without libuv's
|
|
34
|
+
// AppExecLink fstat support. Those failures surface as EINVAL/UNKNOWN,
|
|
35
|
+
// never ENOENT, so an existing alias must still count as executable.
|
|
36
|
+
return platform === "win32" && ["EINVAL", "UNKNOWN"].includes(error?.code);
|
|
32
37
|
}
|
|
33
38
|
}
|
|
34
39
|
|
|
@@ -119,6 +124,12 @@ function commonCandidates(tool, environment, platform) {
|
|
|
119
124
|
locations.push([paths.join(systemRoot, "System32", "WindowsPowerShell", "v1.0"), "powershell"]);
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
// winget ships solely as a per-user App Execution Alias; cover its fixed
|
|
128
|
+
// home for environments whose PATH omits the WindowsApps directory.
|
|
129
|
+
if (tool === "winget" && localAppData) {
|
|
130
|
+
locations.push([paths.join(localAppData, "Microsoft", "WindowsApps"), "winget"]);
|
|
131
|
+
}
|
|
132
|
+
|
|
122
133
|
// Git for Windows standard install roots; `cmd` holds the PATH-safe
|
|
123
134
|
// git.exe. Codex shells out to git for marketplace clones, so skill
|
|
124
135
|
// syncing must find git even when the parent shell's PATH predates the
|
|
@@ -183,13 +194,13 @@ export function resolveNativeBinary(
|
|
|
183
194
|
|
|
184
195
|
// Windows cannot execute npm's .cmd/.bat shims directly. This is the escaping
|
|
185
196
|
// strategy used by cross-spawn, inlined here to keep the CLI dependency-free.
|
|
186
|
-
// Arguments are quoted individually and command metacharacters are
|
|
187
|
-
//
|
|
197
|
+
// Arguments are quoted individually and command metacharacters are
|
|
198
|
+
// caret-escaped once per cmd parse the argument will go through.
|
|
188
199
|
function escapeWindowsBatchCommand(value) {
|
|
189
200
|
return String(value).replace(WINDOWS_SHELL_META_RE, "^$1");
|
|
190
201
|
}
|
|
191
202
|
|
|
192
|
-
|
|
203
|
+
function encodeWindowsCommandArgument(value, metaEscapePasses) {
|
|
193
204
|
let escaped = String(value);
|
|
194
205
|
if (WINDOWS_UNSAFE_LINE_RE.test(escaped)) {
|
|
195
206
|
throw new Error("cannot safely forward an argument containing a NUL or line break through a Windows batch shim");
|
|
@@ -197,17 +208,36 @@ export function escapeWindowsBatchArgument(value) {
|
|
|
197
208
|
escaped = escaped.replace(/(?=(\\+?)?)\1"/gu, "$1$1\\\"");
|
|
198
209
|
escaped = escaped.replace(/(?=(\\+?)?)\1$/gu, "$1$1");
|
|
199
210
|
escaped = `"${escaped}"`;
|
|
200
|
-
|
|
201
|
-
|
|
211
|
+
for (let pass = 0; pass < metaEscapePasses; pass += 1) {
|
|
212
|
+
escaped = escaped.replace(WINDOWS_SHELL_META_RE, "^$1");
|
|
213
|
+
}
|
|
214
|
+
return escaped;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Batch shims re-read the command line through `%*`, so cmd parses these
|
|
218
|
+
// arguments twice and the metacharacter escapes must survive both passes.
|
|
219
|
+
export function escapeWindowsBatchArgument(value) {
|
|
220
|
+
return encodeWindowsCommandArgument(value, 2);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A bare command name is resolved by cmd itself (covering App Execution
|
|
224
|
+
// Aliases such as winget.exe that PATH stat probing cannot verify), but the
|
|
225
|
+
// target program's command line is produced by cmd's single parse. Double
|
|
226
|
+
// escaping here would deliver literal carets — winget then fails with
|
|
227
|
+
// 0x8A150002 APPINSTALLER_CLI_ERROR_INVALID_CL_ARGUMENTS.
|
|
228
|
+
export function escapeWindowsBareArgument(value) {
|
|
229
|
+
return encodeWindowsCommandArgument(value, 1);
|
|
202
230
|
}
|
|
203
231
|
|
|
204
232
|
export function nativeSpawnInvocation(binary, argv, environment = process.env, platform = process.platform) {
|
|
233
|
+
const batchShim = platform === "win32" && WINDOWS_BATCH_EXTENSION_RE.test(binary);
|
|
205
234
|
const bareWindowsCommand = (
|
|
206
235
|
platform === "win32"
|
|
236
|
+
&& !batchShim
|
|
207
237
|
&& WINDOWS_BARE_COMMAND_RE.test(binary)
|
|
208
238
|
&& path.win32.basename(binary) === binary
|
|
209
239
|
);
|
|
210
|
-
if (platform !== "win32" || (!
|
|
240
|
+
if (platform !== "win32" || (!batchShim && !bareWindowsCommand)) {
|
|
211
241
|
return { command: binary, args: argv, windowsVerbatimArguments: false };
|
|
212
242
|
}
|
|
213
243
|
if (WINDOWS_UNSAFE_LINE_RE.test(binary)) {
|
|
@@ -215,7 +245,7 @@ export function nativeSpawnInvocation(binary, argv, environment = process.env, p
|
|
|
215
245
|
}
|
|
216
246
|
const shellCommand = [
|
|
217
247
|
escapeWindowsBatchCommand(pathApi(platform).normalize(binary)),
|
|
218
|
-
...argv.map(escapeWindowsBatchArgument),
|
|
248
|
+
...argv.map(batchShim ? escapeWindowsBatchArgument : escapeWindowsBareArgument),
|
|
219
249
|
].join(" ");
|
|
220
250
|
return {
|
|
221
251
|
command: environmentValue(environment, "ComSpec") || "cmd.exe",
|
package/src/provisioning.js
CHANGED
|
@@ -247,6 +247,7 @@ export async function reconcileAllTenants({
|
|
|
247
247
|
for (const tenant of liveTenants) {
|
|
248
248
|
const result = results.get(tenant.id);
|
|
249
249
|
let supported = new Set();
|
|
250
|
+
let failedTargets = new Set();
|
|
250
251
|
try {
|
|
251
252
|
const installed = await io.reconcileApps({
|
|
252
253
|
baseConfig: config,
|
|
@@ -260,10 +261,17 @@ export async function reconcileAllTenants({
|
|
|
260
261
|
confirmVendorInstall,
|
|
261
262
|
}, overrides.appOverrides || {});
|
|
262
263
|
supported = new Set(installed.targets || []);
|
|
264
|
+
const failedApps = installed.failed || [];
|
|
265
|
+
failedTargets = new Set(failedApps.map((failure) => failure.target));
|
|
263
266
|
for (const target of supported) vendorPreparedTargets.add(target);
|
|
264
|
-
result.clients.claude.app = supported.has("claude")
|
|
265
|
-
|
|
266
|
-
|
|
267
|
+
result.clients.claude.app = supported.has("claude")
|
|
268
|
+
? "ready"
|
|
269
|
+
: failedTargets.has("claude") ? "failed" : "unsupported";
|
|
270
|
+
result.clients.codex.app = supported.has("chatgpt")
|
|
271
|
+
? "ready"
|
|
272
|
+
: failedTargets.has("chatgpt") ? "failed" : "unsupported";
|
|
273
|
+
result.apps = failedTargets.size ? "failed" : supported.size ? "ready" : "unavailable";
|
|
274
|
+
for (const failure of failedApps) result.errors.push(redactSecretText(failure.error));
|
|
267
275
|
} catch (error) {
|
|
268
276
|
result.apps = "failed";
|
|
269
277
|
for (const client of Object.values(result.clients)) {
|
|
@@ -276,9 +284,9 @@ export async function reconcileAllTenants({
|
|
|
276
284
|
}
|
|
277
285
|
|
|
278
286
|
if (supported.size === 0) {
|
|
279
|
-
result.shell = "unavailable";
|
|
280
|
-
result.clients.claude.shell = "unsupported";
|
|
281
|
-
result.clients.codex.shell = "unsupported";
|
|
287
|
+
result.shell = failedTargets.size ? "failed" : "unavailable";
|
|
288
|
+
result.clients.claude.shell = failedTargets.has("claude") ? "failed" : "unsupported";
|
|
289
|
+
result.clients.codex.shell = failedTargets.has("chatgpt") ? "failed" : "unsupported";
|
|
282
290
|
continue;
|
|
283
291
|
}
|
|
284
292
|
try {
|
|
@@ -293,12 +301,15 @@ export async function reconcileAllTenants({
|
|
|
293
301
|
const entryProducts = new Set(entries.map((entry) => entry.product));
|
|
294
302
|
result.clients.claude.shell = supported.has("claude")
|
|
295
303
|
? (entryProducts.has("claude") ? "ready" : "failed")
|
|
296
|
-
: "unsupported";
|
|
304
|
+
: failedTargets.has("claude") ? "failed" : "unsupported";
|
|
297
305
|
result.clients.codex.shell = supported.has("chatgpt")
|
|
298
306
|
? (entryProducts.has("chatgpt") ? "ready" : "failed")
|
|
299
|
-
: "unsupported";
|
|
300
|
-
|
|
301
|
-
|
|
307
|
+
: failedTargets.has("chatgpt") ? "failed" : "unsupported";
|
|
308
|
+
const registered = [...supported].every((target) => entryProducts.has(target));
|
|
309
|
+
// A failed vendor target has no launchable entry, so the tenant's
|
|
310
|
+
// launch surface stays failed even when every present app registered.
|
|
311
|
+
result.shell = registered && !failedTargets.size ? "ready" : "failed";
|
|
312
|
+
if (!registered) throw new Error("operating-system app registration did not complete");
|
|
302
313
|
} catch (error) {
|
|
303
314
|
result.shell = "failed";
|
|
304
315
|
for (const client of Object.values(result.clients)) {
|
|
@@ -327,6 +338,19 @@ export async function reconcileAllTenants({
|
|
|
327
338
|
};
|
|
328
339
|
}
|
|
329
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Whether a single-tenant reconcile report shows the requested vendor app
|
|
343
|
+
* (claude, codex, or all) as converged. "unsupported" counts as converged:
|
|
344
|
+
* the tenant catalog offers nothing to install for that product.
|
|
345
|
+
*/
|
|
346
|
+
export function vendorAppTargetReady(report, target) {
|
|
347
|
+
const clients = report?.tenants?.[0]?.clients;
|
|
348
|
+
if (!clients) return false;
|
|
349
|
+
const converged = (client) => ["ready", "unsupported"].includes(client?.app);
|
|
350
|
+
if (target === "all") return converged(clients.claude) && converged(clients.codex);
|
|
351
|
+
return converged(target === "claude" ? clients.claude : clients.codex);
|
|
352
|
+
}
|
|
353
|
+
|
|
330
354
|
export function printReconciliationSummary(report, log = console.log) {
|
|
331
355
|
log("Tenant readiness:");
|
|
332
356
|
for (const tenant of report.tenants) {
|
package/src/skills.js
CHANGED
|
@@ -44,6 +44,14 @@ export const SKILLS_FALLBACK_GATEWAY_URL = "https://gateway.useimpel.ai";
|
|
|
44
44
|
const BENIGN_OUTPUT = /already (exist|install|add|present|configur)|up[ -]?to[ -]?date|no changes|nothing to (do|update)/i;
|
|
45
45
|
const TRANSIENT_SKILL_OUTPUT = /timed? out|timeout of \d+ms exceeded|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network error|failed to download/i;
|
|
46
46
|
|
|
47
|
+
// Codex `plugin marketplace upgrade` refreshes only Git marketplaces. A
|
|
48
|
+
// registration recorded with a non-git source_type (an older Codex, or a
|
|
49
|
+
// git-less fallback at registration time) fails every upgrade with this error
|
|
50
|
+
// until the marketplace is re-registered — otherwise the profile keeps the
|
|
51
|
+
// snapshot cloned at registration forever and every sync warns.
|
|
52
|
+
const NON_GIT_MARKETPLACE_RE = /not configured as a git marketplace/i;
|
|
53
|
+
const MARKETPLACE_NOT_FOUND_RE = /not found|no marketplace/i;
|
|
54
|
+
|
|
47
55
|
const CLIENT_SPECS = {
|
|
48
56
|
claude: {
|
|
49
57
|
bin: "claude",
|
|
@@ -345,6 +353,27 @@ async function runSkillCommandWithRetry(run, bin, args, env) {
|
|
|
345
353
|
return run(bin, args, env);
|
|
346
354
|
}
|
|
347
355
|
|
|
356
|
+
/**
|
|
357
|
+
* Heal a Codex marketplace whose stored registration cannot be upgraded by
|
|
358
|
+
* re-registering it: remove the stale record (which also drops its installed
|
|
359
|
+
* plugins) and add the current source. The install command that follows the
|
|
360
|
+
* refresh phase reinstalls the plugin from the fresh snapshot. Returns true
|
|
361
|
+
* only when the re-registration fully succeeded.
|
|
362
|
+
*/
|
|
363
|
+
async function reregisterCodexMarketplace({ run, bin, env, marketplaceName, sourceUrl }) {
|
|
364
|
+
const removed = await run(bin, ["plugin", "marketplace", "remove", marketplaceName], env);
|
|
365
|
+
if (!removed.ok && !MARKETPLACE_NOT_FOUND_RE.test(`${removed.stdout}\n${removed.stderr}`)) {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
const added = await runSkillCommandWithRetry(
|
|
369
|
+
run,
|
|
370
|
+
bin,
|
|
371
|
+
["plugin", "marketplace", "add", sourceUrl],
|
|
372
|
+
env,
|
|
373
|
+
);
|
|
374
|
+
return added.ok;
|
|
375
|
+
}
|
|
376
|
+
|
|
348
377
|
const MARKETPLACE_FETCH_TIMEOUT_MS = 30_000;
|
|
349
378
|
|
|
350
379
|
// The gateway serves marketplace.json from a serverless function with an 8-15s
|
|
@@ -494,9 +523,23 @@ export async function syncSkills({
|
|
|
494
523
|
failures.push({ phase: command.phase, reason: `\`${spec.bin}\` disappeared mid-sync` });
|
|
495
524
|
break;
|
|
496
525
|
}
|
|
497
|
-
if (
|
|
498
|
-
|
|
526
|
+
if (isBenign(result)) continue;
|
|
527
|
+
if (
|
|
528
|
+
client === "codex"
|
|
529
|
+
&& command.phase === "refresh-marketplace"
|
|
530
|
+
&& marketplaceName
|
|
531
|
+
&& NON_GIT_MARKETPLACE_RE.test(`${result.stdout}\n${result.stderr}`)
|
|
532
|
+
) {
|
|
533
|
+
const reregistered = await reregisterCodexMarketplace({
|
|
534
|
+
run,
|
|
535
|
+
bin: spec.bin,
|
|
536
|
+
env,
|
|
537
|
+
marketplaceName,
|
|
538
|
+
sourceUrl,
|
|
539
|
+
});
|
|
540
|
+
if (reregistered) continue;
|
|
499
541
|
}
|
|
542
|
+
failures.push({ phase: command.phase, reason: firstLine(result.stderr) || `exit ${result.status}` });
|
|
500
543
|
}
|
|
501
544
|
|
|
502
545
|
if (failures.length > 0) {
|