impel-cli 0.17.15 → 0.17.17
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/agents.js +2 -1
- package/src/cliProfiles.js +2 -1
- package/src/commands/apps.js +83 -41
- package/src/commands/converge.js +17 -6
- package/src/commands/launch.js +6 -1
- package/src/commands/setup.js +52 -8
- package/src/config.js +17 -5
- package/src/macSetup.js +3 -0
- package/src/nativeProcess.js +39 -9
- package/src/provisioning.js +36 -10
- package/src/skills.js +126 -6
- package/src/updates.js +11 -2
- package/src/windowsFs.js +44 -0
- package/src/windowsGit.js +3 -1
- package/src/windowsSetup.js +4 -0
package/package.json
CHANGED
package/src/agents.js
CHANGED
|
@@ -12,6 +12,7 @@ import path from "node:path";
|
|
|
12
12
|
import { normalizeGatewayUrl, redactSecretText } from "./config.js";
|
|
13
13
|
import { impelMcpInvocation } from "./selfInvocation.js";
|
|
14
14
|
import { normalizeTenantId } from "./tenants.js";
|
|
15
|
+
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
15
16
|
|
|
16
17
|
export const AGENT_SYNC_TTL_MS = 6 * 60 * 60 * 1000;
|
|
17
18
|
export const MANAGED_AGENT_DIRECTORY = "impel-managed";
|
|
@@ -52,7 +53,7 @@ function atomicPrivateWrite(filePath, contents) {
|
|
|
52
53
|
const temporaryPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
53
54
|
try {
|
|
54
55
|
fs.writeFileSync(temporaryPath, contents, { mode: 0o600 });
|
|
55
|
-
|
|
56
|
+
renameWithWindowsRetry(temporaryPath, filePath);
|
|
56
57
|
try {
|
|
57
58
|
fs.chmodSync(filePath, 0o600);
|
|
58
59
|
} catch {
|
package/src/cliProfiles.js
CHANGED
|
@@ -12,6 +12,7 @@ import { CONFIG_DIR } from "./config.js";
|
|
|
12
12
|
import { normalizeTenantId } from "./tenants.js";
|
|
13
13
|
import { impelCliInvocation, impelMcpInvocation } from "./selfInvocation.js";
|
|
14
14
|
import { applyImpelClaudeSandbox } from "./claudeSandbox.js";
|
|
15
|
+
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
15
16
|
import { ensureClaudeSessionHooks, ensureCodexSessionHooks } from "./sessionHooks.js";
|
|
16
17
|
|
|
17
18
|
export const IMPEL_CLI_PROFILES_DIR = path.join(CONFIG_DIR, "cli");
|
|
@@ -55,7 +56,7 @@ function writePrivateFile(filePath, contents) {
|
|
|
55
56
|
const temporaryPath = `${filePath}.tmp-${process.pid}`;
|
|
56
57
|
try {
|
|
57
58
|
fs.writeFileSync(temporaryPath, contents, { mode: 0o600 });
|
|
58
|
-
|
|
59
|
+
renameWithWindowsRetry(temporaryPath, filePath);
|
|
59
60
|
try {
|
|
60
61
|
fs.chmodSync(filePath, 0o600);
|
|
61
62
|
} catch {
|
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,
|
|
@@ -415,7 +446,7 @@ export async function reconcileWindowsTenantApps({
|
|
|
415
446
|
// Merge the sync env so an app-embedded binary (IMPEL_CODEX_BIN) satisfies
|
|
416
447
|
// the availability check even when no standalone CLI is installed.
|
|
417
448
|
if (io.findBinary(client, { ...environment, ...env }, "win32")) {
|
|
418
|
-
await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
|
|
449
|
+
await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label, homeDir: os.homedir() });
|
|
419
450
|
agentTargets.push(target);
|
|
420
451
|
} else {
|
|
421
452
|
io.log(`Skipping skill/agent sync for ${label} — no ${client === "claude" ? "Claude Code" : "Codex"} binary is available to run plugin commands.`);
|
|
@@ -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,20 +540,20 @@ 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) {
|
|
513
554
|
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
514
555
|
if (io.findBinary(client, environment, "darwin")) {
|
|
515
|
-
await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
|
|
556
|
+
await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label, homeDir: os.homedir() });
|
|
516
557
|
agentItems.push(item);
|
|
517
558
|
}
|
|
518
559
|
if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
|
|
@@ -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 = {}) {
|
|
@@ -753,6 +794,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
753
794
|
gatewayUrl: resolveSkillsGateway(config.gatewayUrl),
|
|
754
795
|
env,
|
|
755
796
|
label,
|
|
797
|
+
homeDir: os.homedir(),
|
|
756
798
|
logger: createProgressLogger(spinner),
|
|
757
799
|
})
|
|
758
800
|
));
|
|
@@ -995,7 +1037,7 @@ export async function cmdApps(argv, overrides = {}) {
|
|
|
995
1037
|
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
996
1038
|
if (!flags["skip-skills"]) {
|
|
997
1039
|
await withProgress(`Syncing skills for ${label}`, (spinner) => (
|
|
998
|
-
io.syncSkills({ client, gatewayUrl, env, label, logger: createProgressLogger(spinner) })
|
|
1040
|
+
io.syncSkills({ client, gatewayUrl, env, label, homeDir: os.homedir(), logger: createProgressLogger(spinner) })
|
|
999
1041
|
));
|
|
1000
1042
|
}
|
|
1001
1043
|
if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
|
|
@@ -1126,7 +1168,7 @@ export async function provisionAndOpenManagedApps({
|
|
|
1126
1168
|
for (const item of installed) {
|
|
1127
1169
|
const { client, env, label } = appSkillTarget(item.target, paths);
|
|
1128
1170
|
await withProgress(`Syncing skills for ${label}`, (spinner) => (
|
|
1129
|
-
io.syncSkills({ client, gatewayUrl, env, label, logger: createProgressLogger(spinner) })
|
|
1171
|
+
io.syncSkills({ client, gatewayUrl, env, label, homeDir: os.homedir(), logger: createProgressLogger(spinner) })
|
|
1130
1172
|
));
|
|
1131
1173
|
if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
|
|
1132
1174
|
}
|
|
@@ -1290,7 +1332,7 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {},
|
|
|
1290
1332
|
const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
|
|
1291
1333
|
for (const status of supportedStatuses) {
|
|
1292
1334
|
const { client, env, label } = appSkillTarget(status.target, tenantPaths);
|
|
1293
|
-
await io.syncSkills({ client, gatewayUrl, env, label });
|
|
1335
|
+
await io.syncSkills({ client, gatewayUrl, env, label, homeDir: os.homedir() });
|
|
1294
1336
|
if (status.target === "chatgpt") io.secureCodexHome(tenantPaths.chatgpt.codexHome);
|
|
1295
1337
|
}
|
|
1296
1338
|
await io.syncAgents({
|
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,9 +16,11 @@ 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";
|
|
23
|
+
import { refreshUpdateCache, updateNoticeLine } from "../updates.js";
|
|
22
24
|
import { restoreNativeProfiles } from "./use.js";
|
|
23
25
|
|
|
24
26
|
const HELP = `impel setup - prepare every accessible Impel tenant
|
|
@@ -49,9 +51,9 @@ export function resolveTenantChoice(listing, { requested = null, answer = null,
|
|
|
49
51
|
return selectDefaultTenant(listing, { currentTenantId });
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
async function
|
|
54
|
+
async function probeGatewayOnce(gatewayUrl, pat, tenantId, fetchImpl, timeoutMs) {
|
|
53
55
|
const controller = new AbortController();
|
|
54
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
56
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
55
57
|
try {
|
|
56
58
|
const response = await fetchImpl(`${gatewayUrl}/v1/models`, {
|
|
57
59
|
headers: {
|
|
@@ -69,13 +71,28 @@ async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
|
|
|
69
71
|
} catch (error) {
|
|
70
72
|
return {
|
|
71
73
|
reachable: false,
|
|
72
|
-
error: error?.name === "AbortError"
|
|
74
|
+
error: error?.name === "AbortError"
|
|
75
|
+
? `timed out after ${Math.round(timeoutMs / 1000)}s`
|
|
76
|
+
: redactSecretText(error?.message || error),
|
|
73
77
|
};
|
|
74
78
|
} finally {
|
|
75
79
|
clearTimeout(timeout);
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
82
|
|
|
83
|
+
// A single short probe misreports a cold gateway (serverless cold start plus
|
|
84
|
+
// long-haul latency) as unreachable, and that transient verdict gets baked
|
|
85
|
+
// into the convergence failure message. Retry once with a longer deadline
|
|
86
|
+
// before concluding the gateway is down.
|
|
87
|
+
export async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch, timeoutsMs = [5_000, 10_000]) {
|
|
88
|
+
let probe = { reachable: false, error: "not probed" };
|
|
89
|
+
for (const timeoutMs of timeoutsMs) {
|
|
90
|
+
probe = await probeGatewayOnce(gatewayUrl, pat, tenantId, fetchImpl, timeoutMs);
|
|
91
|
+
if (probe.reachable) return probe;
|
|
92
|
+
}
|
|
93
|
+
return probe;
|
|
94
|
+
}
|
|
95
|
+
|
|
79
96
|
function markProbeFailures(report, probes) {
|
|
80
97
|
probes.forEach((probe, index) => {
|
|
81
98
|
if (probe.reachable && !probe.rejected && probe.healthy !== false) return;
|
|
@@ -133,6 +150,8 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
133
150
|
isTTY: process.stdin.isTTY,
|
|
134
151
|
platform: process.platform,
|
|
135
152
|
environment: process.env,
|
|
153
|
+
refreshUpdateCache,
|
|
154
|
+
updateNoticeLine,
|
|
136
155
|
...overrides,
|
|
137
156
|
};
|
|
138
157
|
if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
|
|
@@ -204,6 +223,25 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
204
223
|
for (const tenant of orderedTenants) {
|
|
205
224
|
console.log(` ${tenant.id}${tenant.id === selected.id ? " (CLI default)" : ""} — ${tenant.name}`);
|
|
206
225
|
}
|
|
226
|
+
|
|
227
|
+
// Setup is where a stale CLI hurts most (it re-hits installer bugs newer
|
|
228
|
+
// releases already fixed), and on a first run the launch-time notice cache
|
|
229
|
+
// is still empty — so check synchronously here. The token was just verified
|
|
230
|
+
// over the network, and the registry fetch is bounded (10s) and best-effort.
|
|
231
|
+
// TTY-gated like maybePrintUpdateNotice: the nudge is for a human who can
|
|
232
|
+
// stop and update, not for scripted/CI setups.
|
|
233
|
+
if (io.isTTY && !["1", "true"].includes(io.environment.IMPEL_SKIP_UPDATE_CHECK)) {
|
|
234
|
+
try {
|
|
235
|
+
const updateCache = await io.refreshUpdateCache();
|
|
236
|
+
const updateNotice = io.updateNoticeLine({ cache: updateCache });
|
|
237
|
+
if (updateNotice) {
|
|
238
|
+
console.warn(updateNotice);
|
|
239
|
+
console.warn("A newer impel-cli may already fix setup issues — consider updating first, then re-running `impel setup`.");
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
// Never block setup on the update check.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
207
245
|
try {
|
|
208
246
|
io.restoreNativeProfiles({ quiet: true });
|
|
209
247
|
} catch (error) {
|
|
@@ -406,11 +444,17 @@ export async function cmdSetup(argv, overrides = {}) {
|
|
|
406
444
|
return ["ready", "unavailable"].includes(profileReport.tenants[0]?.cli);
|
|
407
445
|
},
|
|
408
446
|
retryStep: () => retrySafeState(),
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
447
|
+
// Succeed when the requested vendor app converged, even if the
|
|
448
|
+
// sibling app still fails: the goal check keeps demanding full
|
|
449
|
+
// tenant readiness, so recovery can approve one app at a time.
|
|
450
|
+
installVendorApp: async (target) => {
|
|
451
|
+
await retryTenant(async (product) => (
|
|
452
|
+
target === "all"
|
|
453
|
+
|| product === target
|
|
454
|
+
|| (target === "codex" && product === "chatgpt")
|
|
455
|
+
));
|
|
456
|
+
return vendorAppTargetReady(retryReport, target);
|
|
457
|
+
},
|
|
414
458
|
},
|
|
415
459
|
}, overrides.recoveryOverrides || {});
|
|
416
460
|
if (retryReport) mergeTenantReport(report, retryReport);
|
package/src/config.js
CHANGED
|
@@ -15,6 +15,8 @@ import fs from "node:fs";
|
|
|
15
15
|
import os from "node:os";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
|
|
18
|
+
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
19
|
+
|
|
18
20
|
export const DEFAULT_GATEWAY_URL = "https://gateway.useimpel.com";
|
|
19
21
|
export const DEFAULT_APP_URL = "https://www.useimpel.com";
|
|
20
22
|
const LEGACY_GATEWAY_URLS = new Set(["https://gateway.useimpel.ai"]);
|
|
@@ -68,12 +70,22 @@ export function saveConfig(config) {
|
|
|
68
70
|
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
69
71
|
const json = JSON.stringify(config, null, 2) + "\n";
|
|
70
72
|
const tmpPath = `${CONFIG_PATH}.tmp-${process.pid}`;
|
|
71
|
-
fs.writeFileSync(tmpPath, json, { mode: 0o600 });
|
|
72
|
-
fs.renameSync(tmpPath, CONFIG_PATH);
|
|
73
73
|
try {
|
|
74
|
-
fs.
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
fs.writeFileSync(tmpPath, json, { mode: 0o600 });
|
|
75
|
+
renameWithWindowsRetry(tmpPath, CONFIG_PATH);
|
|
76
|
+
try {
|
|
77
|
+
fs.chmodSync(CONFIG_PATH, 0o600);
|
|
78
|
+
} catch {
|
|
79
|
+
// best-effort on platforms (e.g. Windows) where chmod is a no-op
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
try {
|
|
83
|
+
// The rename removed the tmp file in the normal case; never leave
|
|
84
|
+
// config.json.tmp-* litter behind a failed swap.
|
|
85
|
+
fs.rmSync(tmpPath, { force: true });
|
|
86
|
+
} catch {
|
|
87
|
+
// Cleanup must never mask the write/rename outcome.
|
|
88
|
+
}
|
|
77
89
|
}
|
|
78
90
|
}
|
|
79
91
|
|
package/src/macSetup.js
CHANGED
|
@@ -233,6 +233,8 @@ export async function prepareMacClis({
|
|
|
233
233
|
gatewayUrl,
|
|
234
234
|
env: { CLAUDE_CONFIG_DIR: claudeProfile.configDir },
|
|
235
235
|
label: "Impel isolated Claude (impel claude)",
|
|
236
|
+
// Lets Windows spawns use a git-safe cwd (see skillSyncSpawnDirectory).
|
|
237
|
+
homeDir: os.homedir(),
|
|
236
238
|
});
|
|
237
239
|
}
|
|
238
240
|
if (binaries.codex) {
|
|
@@ -241,6 +243,7 @@ export async function prepareMacClis({
|
|
|
241
243
|
gatewayUrl,
|
|
242
244
|
env: { CODEX_HOME: codexProfile.codexHome },
|
|
243
245
|
label: "Impel isolated Codex (impel codex)",
|
|
246
|
+
homeDir: os.homedir(),
|
|
244
247
|
});
|
|
245
248
|
}
|
|
246
249
|
|
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
|
@@ -123,6 +123,8 @@ async function prepareTenantCli(config, tenant, io, binaries) {
|
|
|
123
123
|
gatewayUrl,
|
|
124
124
|
env: definition.env(profile),
|
|
125
125
|
label: `Impel ${client === "claude" ? "Claude" : "Codex"} CLI (${tenant.id})`,
|
|
126
|
+
// Lets Windows spawns use a git-safe cwd (see skillSyncSpawnDirectory).
|
|
127
|
+
homeDir: os.homedir(),
|
|
126
128
|
});
|
|
127
129
|
clients[client] = { status: "ready", root, error: null };
|
|
128
130
|
} catch (error) {
|
|
@@ -247,6 +249,7 @@ export async function reconcileAllTenants({
|
|
|
247
249
|
for (const tenant of liveTenants) {
|
|
248
250
|
const result = results.get(tenant.id);
|
|
249
251
|
let supported = new Set();
|
|
252
|
+
let failedTargets = new Set();
|
|
250
253
|
try {
|
|
251
254
|
const installed = await io.reconcileApps({
|
|
252
255
|
baseConfig: config,
|
|
@@ -260,10 +263,17 @@ export async function reconcileAllTenants({
|
|
|
260
263
|
confirmVendorInstall,
|
|
261
264
|
}, overrides.appOverrides || {});
|
|
262
265
|
supported = new Set(installed.targets || []);
|
|
266
|
+
const failedApps = installed.failed || [];
|
|
267
|
+
failedTargets = new Set(failedApps.map((failure) => failure.target));
|
|
263
268
|
for (const target of supported) vendorPreparedTargets.add(target);
|
|
264
|
-
result.clients.claude.app = supported.has("claude")
|
|
265
|
-
|
|
266
|
-
|
|
269
|
+
result.clients.claude.app = supported.has("claude")
|
|
270
|
+
? "ready"
|
|
271
|
+
: failedTargets.has("claude") ? "failed" : "unsupported";
|
|
272
|
+
result.clients.codex.app = supported.has("chatgpt")
|
|
273
|
+
? "ready"
|
|
274
|
+
: failedTargets.has("chatgpt") ? "failed" : "unsupported";
|
|
275
|
+
result.apps = failedTargets.size ? "failed" : supported.size ? "ready" : "unavailable";
|
|
276
|
+
for (const failure of failedApps) result.errors.push(redactSecretText(failure.error));
|
|
267
277
|
} catch (error) {
|
|
268
278
|
result.apps = "failed";
|
|
269
279
|
for (const client of Object.values(result.clients)) {
|
|
@@ -276,9 +286,9 @@ export async function reconcileAllTenants({
|
|
|
276
286
|
}
|
|
277
287
|
|
|
278
288
|
if (supported.size === 0) {
|
|
279
|
-
result.shell = "unavailable";
|
|
280
|
-
result.clients.claude.shell = "unsupported";
|
|
281
|
-
result.clients.codex.shell = "unsupported";
|
|
289
|
+
result.shell = failedTargets.size ? "failed" : "unavailable";
|
|
290
|
+
result.clients.claude.shell = failedTargets.has("claude") ? "failed" : "unsupported";
|
|
291
|
+
result.clients.codex.shell = failedTargets.has("chatgpt") ? "failed" : "unsupported";
|
|
282
292
|
continue;
|
|
283
293
|
}
|
|
284
294
|
try {
|
|
@@ -293,12 +303,15 @@ export async function reconcileAllTenants({
|
|
|
293
303
|
const entryProducts = new Set(entries.map((entry) => entry.product));
|
|
294
304
|
result.clients.claude.shell = supported.has("claude")
|
|
295
305
|
? (entryProducts.has("claude") ? "ready" : "failed")
|
|
296
|
-
: "unsupported";
|
|
306
|
+
: failedTargets.has("claude") ? "failed" : "unsupported";
|
|
297
307
|
result.clients.codex.shell = supported.has("chatgpt")
|
|
298
308
|
? (entryProducts.has("chatgpt") ? "ready" : "failed")
|
|
299
|
-
: "unsupported";
|
|
300
|
-
|
|
301
|
-
|
|
309
|
+
: failedTargets.has("chatgpt") ? "failed" : "unsupported";
|
|
310
|
+
const registered = [...supported].every((target) => entryProducts.has(target));
|
|
311
|
+
// A failed vendor target has no launchable entry, so the tenant's
|
|
312
|
+
// launch surface stays failed even when every present app registered.
|
|
313
|
+
result.shell = registered && !failedTargets.size ? "ready" : "failed";
|
|
314
|
+
if (!registered) throw new Error("operating-system app registration did not complete");
|
|
302
315
|
} catch (error) {
|
|
303
316
|
result.shell = "failed";
|
|
304
317
|
for (const client of Object.values(result.clients)) {
|
|
@@ -327,6 +340,19 @@ export async function reconcileAllTenants({
|
|
|
327
340
|
};
|
|
328
341
|
}
|
|
329
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Whether a single-tenant reconcile report shows the requested vendor app
|
|
345
|
+
* (claude, codex, or all) as converged. "unsupported" counts as converged:
|
|
346
|
+
* the tenant catalog offers nothing to install for that product.
|
|
347
|
+
*/
|
|
348
|
+
export function vendorAppTargetReady(report, target) {
|
|
349
|
+
const clients = report?.tenants?.[0]?.clients;
|
|
350
|
+
if (!clients) return false;
|
|
351
|
+
const converged = (client) => ["ready", "unsupported"].includes(client?.app);
|
|
352
|
+
if (target === "all") return converged(clients.claude) && converged(clients.codex);
|
|
353
|
+
return converged(target === "claude" ? clients.claude : clients.codex);
|
|
354
|
+
}
|
|
355
|
+
|
|
330
356
|
export function printReconciliationSummary(report, log = console.log) {
|
|
331
357
|
log("Tenant readiness:");
|
|
332
358
|
for (const tenant of report.tenants) {
|
package/src/skills.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// so we keep a per-client command table rather than assuming one shape.
|
|
21
21
|
|
|
22
22
|
import { spawn } from "node:child_process";
|
|
23
|
+
import fs from "node:fs";
|
|
23
24
|
import path from "node:path";
|
|
24
25
|
|
|
25
26
|
import { resolveDefaultGateway, normalizeGatewayUrl } from "./config.js";
|
|
@@ -44,6 +45,17 @@ export const SKILLS_FALLBACK_GATEWAY_URL = "https://gateway.useimpel.ai";
|
|
|
44
45
|
const BENIGN_OUTPUT = /already (exist|install|add|present|configur)|up[ -]?to[ -]?date|no changes|nothing to (do|update)/i;
|
|
45
46
|
const TRANSIENT_SKILL_OUTPUT = /timed? out|timeout of \d+ms exceeded|ECONNRESET|ETIMEDOUT|EAI_AGAIN|network error|failed to download/i;
|
|
46
47
|
|
|
48
|
+
// Codex `plugin marketplace upgrade` refreshes only Git marketplaces. A
|
|
49
|
+
// registration recorded with a non-git source_type (impel-cli ≤0.6.1
|
|
50
|
+
// registered from the marketplace.json manifest URL instead of the Git source
|
|
51
|
+
// root, and a git-less machine falls back the same way) fails every upgrade
|
|
52
|
+
// with this error until the marketplace is re-registered — re-`add`ing the
|
|
53
|
+
// same name is an "already exists" no-op, so without the reregister heal the
|
|
54
|
+
// profile keeps the snapshot recorded at registration forever and every sync
|
|
55
|
+
// warns.
|
|
56
|
+
const NON_GIT_MARKETPLACE_RE = /not configured as a git marketplace/i;
|
|
57
|
+
const MARKETPLACE_NOT_FOUND_RE = /not found|no marketplace/i;
|
|
58
|
+
|
|
47
59
|
const CLIENT_SPECS = {
|
|
48
60
|
claude: {
|
|
49
61
|
bin: "claude",
|
|
@@ -217,6 +229,44 @@ export function withGitEnvironment(env = {}, {
|
|
|
217
229
|
};
|
|
218
230
|
}
|
|
219
231
|
|
|
232
|
+
/**
|
|
233
|
+
* A git-safe working directory for the vendor plugin commands on Windows.
|
|
234
|
+
*
|
|
235
|
+
* Claude Code resolves `git` with `where.exe` and rejects any candidate whose
|
|
236
|
+
* path sits INSIDE the process working directory — including subdirectories —
|
|
237
|
+
* as a planted-binary defense ("Command 'git' not found or is in an unsafe
|
|
238
|
+
* location (current directory)"). `impel setup` is typically run from
|
|
239
|
+
* %USERPROFILE%, which contains every per-user git install: the Impel-managed
|
|
240
|
+
* MinGit under ~/.config/impel/tools/git, Git for Windows under
|
|
241
|
+
* %LOCALAPPDATA%\Programs\Git, scoop shims, all of them. From a home-directory
|
|
242
|
+
* shell the vendor CLI therefore rejects a perfectly good git and every plugin
|
|
243
|
+
* clone fails, even though withGitEnvironment put git on the child's PATH.
|
|
244
|
+
*
|
|
245
|
+
* Plugin syncing never depends on the caller's cwd (the commands only touch
|
|
246
|
+
* the profile named by CLAUDE_CONFIG_DIR/CODEX_HOME), so spawn them from a
|
|
247
|
+
* managed, always-empty directory instead: it contains no binaries and is
|
|
248
|
+
* never an ancestor of a git install. Returns null off Windows, when the
|
|
249
|
+
* caller did not opt in with a home directory, or when the directory cannot
|
|
250
|
+
* be created (spawns then inherit the caller's cwd, today's behavior).
|
|
251
|
+
*/
|
|
252
|
+
export function skillSyncSpawnDirectory({
|
|
253
|
+
platform = process.platform,
|
|
254
|
+
homeDir = null,
|
|
255
|
+
mkdir = fs.mkdirSync,
|
|
256
|
+
} = {}) {
|
|
257
|
+
if (platform !== "win32" || !homeDir) return null;
|
|
258
|
+
// Platform-native join: identical to win32 join on a real Windows machine,
|
|
259
|
+
// and produces a creatable path when tests exercise the win32 branch on a
|
|
260
|
+
// POSIX temp home.
|
|
261
|
+
const directory = path.join(homeDir, ".config", "impel", "tools", "spawn-cwd");
|
|
262
|
+
try {
|
|
263
|
+
mkdir(directory, { recursive: true });
|
|
264
|
+
return directory;
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
220
270
|
const SKILL_COMMAND_TIMEOUT_MS = 120_000;
|
|
221
271
|
const SKILL_COMMAND_OUTPUT_LIMIT = 10 * 1024 * 1024;
|
|
222
272
|
|
|
@@ -231,6 +281,7 @@ export function runSkillCommand(bin, args, env, {
|
|
|
231
281
|
spawnImpl = spawn,
|
|
232
282
|
timeoutMs = SKILL_COMMAND_TIMEOUT_MS,
|
|
233
283
|
platform = process.platform,
|
|
284
|
+
cwd = undefined,
|
|
234
285
|
} = {}) {
|
|
235
286
|
const environment = { ...process.env, ...env };
|
|
236
287
|
let invocation;
|
|
@@ -252,6 +303,9 @@ export function runSkillCommand(bin, args, env, {
|
|
|
252
303
|
child = spawnImpl(invocation.command, invocation.args, {
|
|
253
304
|
env: environment,
|
|
254
305
|
stdio: ["ignore", "pipe", "pipe"],
|
|
306
|
+
// A git-safe working directory (see skillSyncSpawnDirectory); undefined
|
|
307
|
+
// inherits the caller's cwd.
|
|
308
|
+
...(cwd ? { cwd } : {}),
|
|
255
309
|
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
256
310
|
// One update run spawns ~150 of these cmd.exe-wrapped plugin commands
|
|
257
311
|
// across tenants; keep them off-screen on Windows.
|
|
@@ -345,6 +399,43 @@ async function runSkillCommandWithRetry(run, bin, args, env) {
|
|
|
345
399
|
return run(bin, args, env);
|
|
346
400
|
}
|
|
347
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Heal a Codex marketplace whose stored registration cannot be upgraded by
|
|
404
|
+
* re-registering it: remove the stale record (which also drops its installed
|
|
405
|
+
* plugins), add the current Git source, then prove the refresh that just
|
|
406
|
+
* failed now works by re-running the exact same command. The install command
|
|
407
|
+
* that follows the refresh phase reinstalls the plugin from the fresh
|
|
408
|
+
* snapshot.
|
|
409
|
+
*
|
|
410
|
+
* Returns `{ repaired: true }` when every step landed, so the caller can drop
|
|
411
|
+
* the recorded failure; otherwise `{ repaired: false, reason }` names the step
|
|
412
|
+
* that broke so the sync warning stops being undiagnosable from user logs.
|
|
413
|
+
*/
|
|
414
|
+
async function reregisterCodexMarketplace({ run, bin, env, marketplaceName, sourceUrl, refreshArgs, logger, label }) {
|
|
415
|
+
const removed = await run(bin, ["plugin", "marketplace", "remove", marketplaceName], env);
|
|
416
|
+
if (!removed.ok && !MARKETPLACE_NOT_FOUND_RE.test(`${removed.stdout}\n${removed.stderr}`)) {
|
|
417
|
+
return { repaired: false, reason: `remove: ${firstLine(removed.stderr) || `exit ${removed.status}`}` };
|
|
418
|
+
}
|
|
419
|
+
const added = await runSkillCommandWithRetry(
|
|
420
|
+
run,
|
|
421
|
+
bin,
|
|
422
|
+
["plugin", "marketplace", "add", sourceUrl],
|
|
423
|
+
env,
|
|
424
|
+
);
|
|
425
|
+
// "already exists" after a reported-successful remove means the stale record
|
|
426
|
+
// survived; that is a failed repair, not a benign outcome — so isBenign is
|
|
427
|
+
// deliberately NOT used here.
|
|
428
|
+
if (!added.ok) {
|
|
429
|
+
return { repaired: false, reason: `re-add: ${firstLine(added.stderr) || `exit ${added.status}`}` };
|
|
430
|
+
}
|
|
431
|
+
const refreshed = await runSkillCommandWithRetry(run, bin, refreshArgs, env);
|
|
432
|
+
if (!isBenign(refreshed)) {
|
|
433
|
+
return { repaired: false, reason: `re-refresh: ${firstLine(refreshed.stderr) || `exit ${refreshed.status}`}` };
|
|
434
|
+
}
|
|
435
|
+
logger.log(`Skills: re-registered the ${marketplaceName} marketplace for ${label} from its Git source.`);
|
|
436
|
+
return { repaired: true };
|
|
437
|
+
}
|
|
438
|
+
|
|
348
439
|
const MARKETPLACE_FETCH_TIMEOUT_MS = 30_000;
|
|
349
440
|
|
|
350
441
|
// The gateway serves marketplace.json from a serverless function with an 8-15s
|
|
@@ -407,6 +498,7 @@ export async function syncSkills({
|
|
|
407
498
|
logger = console,
|
|
408
499
|
platform = process.platform,
|
|
409
500
|
findGit = findNativeBinary,
|
|
501
|
+
homeDir = null,
|
|
410
502
|
} = {}) {
|
|
411
503
|
const spec = CLIENT_SPECS[client];
|
|
412
504
|
if (!spec) {
|
|
@@ -420,9 +512,16 @@ export async function syncSkills({
|
|
|
420
512
|
return { client, label: displayLabel, skipped: true, reason: "disabled" };
|
|
421
513
|
}
|
|
422
514
|
|
|
515
|
+
// Callers that manage real profiles pass their home directory so Windows
|
|
516
|
+
// spawns happen from a git-safe cwd (see skillSyncSpawnDirectory).
|
|
517
|
+
const spawnDirectory = skillSyncSpawnDirectory({ platform, homeDir });
|
|
518
|
+
const runCommand = spawnDirectory
|
|
519
|
+
? (commandBin, commandArgs, commandEnv) => run(commandBin, commandArgs, commandEnv, { cwd: spawnDirectory })
|
|
520
|
+
: run;
|
|
521
|
+
|
|
423
522
|
try {
|
|
424
523
|
// Confirm the binary and its `plugin` subcommand exist before doing anything.
|
|
425
|
-
const help = await
|
|
524
|
+
const help = await runCommand(spec.bin, ["plugin", "--help"], env);
|
|
426
525
|
if (help.missing) {
|
|
427
526
|
logger.warn(`impel: skipping skill sync for ${displayLabel} — \`${spec.bin}\` CLI not found on PATH.`);
|
|
428
527
|
return { client, label: displayLabel, skipped: true, reason: "binary-missing" };
|
|
@@ -455,7 +554,7 @@ export async function syncSkills({
|
|
|
455
554
|
marketplaceName,
|
|
456
555
|
})[0];
|
|
457
556
|
const registerResult = await runSkillCommandWithRetry(
|
|
458
|
-
|
|
557
|
+
runCommand,
|
|
459
558
|
spec.bin,
|
|
460
559
|
registerCommand.args,
|
|
461
560
|
env,
|
|
@@ -474,7 +573,7 @@ export async function syncSkills({
|
|
|
474
573
|
// gives us the same dynamic name so refreshes can still use
|
|
475
574
|
// PLUGIN@MARKETPLACE.
|
|
476
575
|
if (!marketplaceName && !registerResult.missing) {
|
|
477
|
-
const listed = await
|
|
576
|
+
const listed = await runCommand(spec.bin, ["plugin", "marketplace", "list", "--json"], env);
|
|
478
577
|
if (listed.ok) marketplaceName = resolveConfiguredMarketplaceName(listed.stdout, sourceUrl);
|
|
479
578
|
}
|
|
480
579
|
|
|
@@ -485,7 +584,7 @@ export async function syncSkills({
|
|
|
485
584
|
}).slice(1);
|
|
486
585
|
for (const command of commands) {
|
|
487
586
|
const result = await runSkillCommandWithRetry(
|
|
488
|
-
|
|
587
|
+
runCommand,
|
|
489
588
|
spec.bin,
|
|
490
589
|
command.args,
|
|
491
590
|
env,
|
|
@@ -494,9 +593,30 @@ export async function syncSkills({
|
|
|
494
593
|
failures.push({ phase: command.phase, reason: `\`${spec.bin}\` disappeared mid-sync` });
|
|
495
594
|
break;
|
|
496
595
|
}
|
|
497
|
-
if (
|
|
498
|
-
|
|
596
|
+
if (isBenign(result)) continue;
|
|
597
|
+
let reason = firstLine(result.stderr) || `exit ${result.status}`;
|
|
598
|
+
if (
|
|
599
|
+
client === "codex"
|
|
600
|
+
&& command.phase === "refresh-marketplace"
|
|
601
|
+
&& marketplaceName
|
|
602
|
+
&& NON_GIT_MARKETPLACE_RE.test(`${result.stdout}\n${result.stderr}`)
|
|
603
|
+
) {
|
|
604
|
+
const repair = await reregisterCodexMarketplace({
|
|
605
|
+
run: runCommand,
|
|
606
|
+
bin: spec.bin,
|
|
607
|
+
env,
|
|
608
|
+
marketplaceName,
|
|
609
|
+
sourceUrl,
|
|
610
|
+
refreshArgs: command.args,
|
|
611
|
+
logger,
|
|
612
|
+
label: displayLabel,
|
|
613
|
+
});
|
|
614
|
+
if (repair.repaired) continue;
|
|
615
|
+
// Keep the original refresh error primary, but name the repair step
|
|
616
|
+
// that broke so a failed heal is diagnosable from user logs.
|
|
617
|
+
reason += `; marketplace re-registration failed (${repair.reason})`;
|
|
499
618
|
}
|
|
619
|
+
failures.push({ phase: command.phase, reason });
|
|
500
620
|
}
|
|
501
621
|
|
|
502
622
|
if (failures.length > 0) {
|
package/src/updates.js
CHANGED
|
@@ -13,6 +13,7 @@ import { spawn } from "node:child_process";
|
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
|
|
15
15
|
import { CONFIG_DIR } from "./config.js";
|
|
16
|
+
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
16
17
|
|
|
17
18
|
export const UPDATE_CACHE_PATH = path.join(CONFIG_DIR, "update-check.json");
|
|
18
19
|
export const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
|
|
@@ -117,8 +118,16 @@ export function writeUpdateCache(patch) {
|
|
|
117
118
|
const next = { ...(readUpdateCache() || {}), ...patch };
|
|
118
119
|
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
119
120
|
const tmp = `${UPDATE_CACHE_PATH}.tmp-${process.pid}`;
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
try {
|
|
122
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
123
|
+
renameWithWindowsRetry(tmp, UPDATE_CACHE_PATH);
|
|
124
|
+
} finally {
|
|
125
|
+
try {
|
|
126
|
+
fs.rmSync(tmp, { force: true });
|
|
127
|
+
} catch {
|
|
128
|
+
// Cleanup must never mask the write/rename outcome.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
122
131
|
return next;
|
|
123
132
|
}
|
|
124
133
|
|
package/src/windowsFs.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Windows-aware rename for the CLI's atomic tmp→destination file swaps.
|
|
2
|
+
//
|
|
3
|
+
// fs.renameSync over an existing file is atomic-enough on every platform impel
|
|
4
|
+
// supports, but on Windows the destination is frequently held open for a
|
|
5
|
+
// moment by antivirus/search-indexer services scanning the bytes that were
|
|
6
|
+
// JUST written — the rename then fails with EPERM/EACCES/EBUSY even though
|
|
7
|
+
// nothing is wrong with either file. (Observed in the field: `impel setup`
|
|
8
|
+
// saves config.json three times in quick succession and the third rename lost
|
|
9
|
+
// the race to a scanner, aborting legacy-profile cleanup and leaving a
|
|
10
|
+
// config.json.tmp-* file behind.) Retry briefly on exactly those transient
|
|
11
|
+
// codes before giving up — the same class of retry graceful-fs and npm apply
|
|
12
|
+
// to Windows renames. Non-Windows platforms never retry: there the codes
|
|
13
|
+
// indicate real permission problems that must surface immediately.
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
|
|
17
|
+
const TRANSIENT_WINDOWS_RENAME_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
18
|
+
const RENAME_ATTEMPTS = 8;
|
|
19
|
+
const RENAME_BACKOFF_STEP_MS = 50; // 50, 100, … 350ms between tries: ~1.4s worst case
|
|
20
|
+
|
|
21
|
+
// Dependency-free synchronous sleep: Atomics.wait blocks without spinning the
|
|
22
|
+
// CPU, and these writers are all synchronous call paths.
|
|
23
|
+
function sleepSync(ms) {
|
|
24
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Rename `from` onto `to`, retrying transient Windows sharing violations. */
|
|
28
|
+
export function renameWithWindowsRetry(from, to, {
|
|
29
|
+
platform = process.platform,
|
|
30
|
+
rename = fs.renameSync,
|
|
31
|
+
sleep = sleepSync,
|
|
32
|
+
attempts = RENAME_ATTEMPTS,
|
|
33
|
+
} = {}) {
|
|
34
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
35
|
+
try {
|
|
36
|
+
rename(from, to);
|
|
37
|
+
return;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const transient = platform === "win32" && TRANSIENT_WINDOWS_RENAME_CODES.has(error?.code);
|
|
40
|
+
if (!transient || attempt >= attempts) throw error;
|
|
41
|
+
sleep(RENAME_BACKOFF_STEP_MS * attempt);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/windowsGit.js
CHANGED
|
@@ -14,6 +14,7 @@ import os from "node:os";
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
|
|
16
16
|
import { nativeCommandInvocation, nativeSpawnInvocation } from "./nativeProcess.js";
|
|
17
|
+
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* The exact MinGit release the CLI may install. Never use `latest` or an
|
|
@@ -169,7 +170,8 @@ export async function provisionWindowsGit({
|
|
|
169
170
|
// Replace atomically-enough: verified staging swaps in via a same-volume
|
|
170
171
|
// rename, so discovery never sees a half-extracted tree.
|
|
171
172
|
fs.rmSync(target, { recursive: true, force: true });
|
|
172
|
-
|
|
173
|
+
// Defender loves scanning freshly extracted executables; ride out the lock.
|
|
174
|
+
renameWithWindowsRetry(staging, target);
|
|
173
175
|
const binary = managedGitBinary(target);
|
|
174
176
|
logger.log(`Git: MinGit ${pin.version} ready (${binary}).`);
|
|
175
177
|
return { installed: true, binary, version: pin.version };
|
package/src/windowsSetup.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
|
|
3
4
|
import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "./cliProfiles.js";
|
|
4
5
|
import {
|
|
@@ -200,6 +201,8 @@ export async function prepareWindowsClis({
|
|
|
200
201
|
gatewayUrl,
|
|
201
202
|
env: { CLAUDE_CONFIG_DIR: claudeProfile.configDir },
|
|
202
203
|
label: "Impel isolated Claude (impel claude)",
|
|
204
|
+
// Lets Windows spawns use a git-safe cwd (see skillSyncSpawnDirectory).
|
|
205
|
+
homeDir: os.homedir(),
|
|
203
206
|
});
|
|
204
207
|
}
|
|
205
208
|
if (binaries.codex) {
|
|
@@ -208,6 +211,7 @@ export async function prepareWindowsClis({
|
|
|
208
211
|
gatewayUrl,
|
|
209
212
|
env: { CODEX_HOME: codexProfile.codexHome },
|
|
210
213
|
label: "Impel isolated Codex (impel codex)",
|
|
214
|
+
homeDir: os.homedir(),
|
|
211
215
|
});
|
|
212
216
|
}
|
|
213
217
|
|