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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.17.15",
3
+ "version": "0.17.17",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
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
- fs.renameSync(temporaryPath, filePath);
56
+ renameWithWindowsRetry(temporaryPath, filePath);
56
57
  try {
57
58
  fs.chmodSync(filePath, 0o600);
58
59
  } catch {
@@ -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
- fs.renameSync(temporaryPath, filePath);
59
+ renameWithWindowsRetry(temporaryPath, filePath);
59
60
  try {
60
61
  fs.chmodSync(filePath, 0o600);
61
62
  } catch {
@@ -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)) return `exit code ${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
- throw new Error(`${isClaude ? "Claude" : "ChatGPT/Codex"} vendor ${mode === "update" ? "update" : "installation"} requires confirmation`);
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
- const label = isClaude ? "Claude" : "ChatGPT/Codex";
385
- throw new Error(`${label} vendor installation failed (${windowsProcessFailure(vendor.result)})`);
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
- throw new Error("Claude vendor app is unavailable");
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
- let managed = io.findManagedChatGPTApp(paths.root);
395
- const stale = vendorPaths.chatgpt && !io.chatGPTStageIsCurrent(vendorPaths.chatgpt, paths.root);
396
- if (!managed || stale) {
397
- if (!vendorPaths.chatgpt) throw new Error("ChatGPT/Codex vendor app is unavailable");
398
- managed = io.stageChatGPTApp(vendorPaths.chatgpt, paths.root);
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
- configureWindowsApps(config, actionTargets, vendorPaths, catalog, {
404
- ...io,
405
- homeDir,
406
- environment,
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 actionTargets) {
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: !actionTargets.includes("claude") || io.existsSync(path.join(paths.claude.userData, "configLibrary", `${CLAUDE_CONFIG_ID}.json`)),
435
- chatgpt: !actionTargets.includes("chatgpt") || io.existsSync(path.join(paths.chatgpt.codexHome, "config.toml")),
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: actionTargets, unsupported, paths, verification };
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
- for (const target of actionTargets.filter((target) => !preparedTargets.has(target))) {
485
- if (vendorPaths[target]) continue;
486
- const confirmed = await confirmVendorInstall(target, { platform: "darwin", mode });
487
- if (!confirmed) throw new Error(`${target} vendor installation requires confirmation`);
488
- const result = await io.ensureVendor(target, { homeDir });
489
- vendorPaths[target] = result.path;
490
- if (!result.path) throw new Error(`${target} verified vendor app is unavailable; retry the pinned download`);
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 staleTargets = statuses.filter((status) => !io.bundleCurrent(status)).map((status) => status.target);
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: actionTargets,
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(actionTargets.map((target) => [
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: actionTargets, unsupported, paths, verification, installed };
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({
@@ -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 { printReconciliationSummary, reconcileAllTenants, selectDefaultTenant } from "../provisioning.js";
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
- installVendorApp: (target) => retryTenant(async (product) => (
262
- target === "all"
263
- || product === target
264
- || (target === "codex" && product === "chatgpt")
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);
@@ -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 { escapeWindowsBatchArgument, nativeSpawnInvocation, resolveNativeBinary } from "../nativeProcess.js";
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
 
@@ -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 probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch) {
54
+ async function probeGatewayOnce(gatewayUrl, pat, tenantId, fetchImpl, timeoutMs) {
53
55
  const controller = new AbortController();
54
- const timeout = setTimeout(() => controller.abort(), 5_000);
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" ? "timed out after 5s" : redactSecretText(error?.message || error),
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
- installVendorApp: (target) => retryTenant(async (product) => (
410
- target === "all"
411
- || product === target
412
- || (target === "codex" && product === "chatgpt")
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.chmodSync(CONFIG_PATH, 0o600);
75
- } catch {
76
- // best-effort on platforms (e.g. Windows) where chmod is a no-op
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
 
@@ -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
- return false;
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 escaped
187
- // twice because npm shims forward them through `%*`, causing a second parse.
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
- export function escapeWindowsBatchArgument(value) {
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
- escaped = escaped.replace(WINDOWS_SHELL_META_RE, "^$1");
201
- return escaped.replace(WINDOWS_SHELL_META_RE, "^$1");
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" || (!WINDOWS_BATCH_EXTENSION_RE.test(binary) && !bareWindowsCommand)) {
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",
@@ -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") ? "ready" : "unsupported";
265
- result.clients.codex.app = supported.has("chatgpt") ? "ready" : "unsupported";
266
- result.apps = supported.size ? "ready" : "unavailable";
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
- result.shell = [...supported].every((target) => entryProducts.has(target)) ? "ready" : "failed";
301
- if (result.shell === "failed") throw new Error("operating-system app registration did not complete");
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 run(spec.bin, ["plugin", "--help"], env);
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
- run,
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 run(spec.bin, ["plugin", "marketplace", "list", "--json"], env);
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
- run,
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 (!isBenign(result)) {
498
- failures.push({ phase: command.phase, reason: firstLine(result.stderr) || `exit ${result.status}` });
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
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
121
- fs.renameSync(tmp, UPDATE_CACHE_PATH);
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
 
@@ -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
- fs.renameSync(staging, target);
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 };
@@ -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