impel-cli 0.17.16 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.17.16",
3
+ "version": "0.18.0",
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 {
package/src/apps.js CHANGED
@@ -209,7 +209,9 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
209
209
  // slow open path (and thus a full config rewrite) after a CLI update.
210
210
  // 19: Windows global installs bake the stable %LOCALAPPDATA% entry point into
211
211
  // hook/auth/MCP artifacts instead of the npm-prefix bin path.
212
- export const CURRENT_CONFIG_VERSION = 19;
212
+ // 20: managed Claude sandbox disabled by default; the rewrite strips the old
213
+ // strict sandbox keys from already-provisioned profiles.
214
+ export const CURRENT_CONFIG_VERSION = 20;
213
215
 
214
216
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
215
217
  // helper rebranding, and signing. A vendored bundle is rebuilt only when this
@@ -1,3 +1,7 @@
1
+ // Every sandbox key impel-cli has ever written into a managed profile. The
2
+ // active policy (below) sets only a subset; apply/restore use this full list to
3
+ // strip keys the policy no longer owns, so a profile provisioned by an older,
4
+ // stricter CLI is fully cleaned up rather than left with inert leftovers.
1
5
  const MANAGED_SANDBOX_KEYS = Object.freeze([
2
6
  "enabled",
3
7
  "failIfUnavailable",
@@ -18,20 +22,18 @@ function sameJsonValue(left, right) {
18
22
  }
19
23
 
20
24
  /**
21
- * Claude Code's strict, auto-allow sandbox profile.
25
+ * Impel's Claude sandbox policy: disabled.
22
26
  *
23
- * Native Windows does not implement Claude's sandbox. Keep it enabled so the
24
- * same profile becomes effective in WSL, but do not make native Windows fail
25
- * at startup solely because the platform cannot initialize it.
27
+ * Managed installs run Bash with the same unrestricted network and filesystem
28
+ * access as a plain Claude Code install. Only `enabled: false` is asserted; all
29
+ * other sandbox keys are left to the user and actively stripped from profiles
30
+ * an older, stricter CLI wrote (see MANAGED_SANDBOX_KEYS). The `platform`
31
+ * argument is retained for signature compatibility with callers and tests.
26
32
  */
33
+ // eslint-disable-next-line no-unused-vars
27
34
  export function impelClaudeSandboxPolicy(platform = process.platform) {
28
35
  return {
29
- enabled: true,
30
- failIfUnavailable: platform !== "win32",
31
- autoAllowBashIfSandboxed: true,
32
- allowUnsandboxedCommands: false,
33
- excludedCommands: [],
34
- ...(platform === "darwin" ? { enableWeakerNetworkIsolation: true } : {}),
36
+ enabled: false,
35
37
  };
36
38
  }
37
39
 
@@ -48,8 +50,13 @@ function applyImpelClaudeNetworkPermission(permissions) {
48
50
  export function applyImpelClaudeSandbox(settings, platform = process.platform) {
49
51
  const current = isJsonObject(settings.sandbox) ? settings.sandbox : {};
50
52
  const policy = impelClaudeSandboxPolicy(platform);
53
+ // Drop every key impel-cli has ever managed before re-applying the policy, so
54
+ // strict keys written by an older CLI (failIfUnavailable, allowUnsandboxed…)
55
+ // don't survive as leftovers alongside the new enabled:false.
56
+ const preserved = { ...current };
57
+ for (const key of MANAGED_SANDBOX_KEYS) delete preserved[key];
51
58
  settings.sandbox = {
52
- ...current,
59
+ ...preserved,
53
60
  ...policy,
54
61
  };
55
62
  settings.permissions = applyImpelClaudeNetworkPermission(settings.permissions);
@@ -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 {
@@ -446,7 +446,7 @@ export async function reconcileWindowsTenantApps({
446
446
  // Merge the sync env so an app-embedded binary (IMPEL_CODEX_BIN) satisfies
447
447
  // the availability check even when no standalone CLI is installed.
448
448
  if (io.findBinary(client, { ...environment, ...env }, "win32")) {
449
- await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
449
+ await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label, homeDir: os.homedir() });
450
450
  agentTargets.push(target);
451
451
  } else {
452
452
  io.log(`Skipping skill/agent sync for ${label} — no ${client === "claude" ? "Claude Code" : "Codex"} binary is available to run plugin commands.`);
@@ -553,7 +553,7 @@ export async function reconcileMacTenantApps({
553
553
  for (const item of installed) {
554
554
  const { client, env, label } = appSkillTarget(item.target, paths);
555
555
  if (io.findBinary(client, environment, "darwin")) {
556
- await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label });
556
+ await io.syncSkills({ client, gatewayUrl: resolveSkillsGateway(config.gatewayUrl), env, label, homeDir: os.homedir() });
557
557
  agentItems.push(item);
558
558
  }
559
559
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
@@ -794,6 +794,7 @@ export async function cmdWindowsApps(argv, overrides = {}) {
794
794
  gatewayUrl: resolveSkillsGateway(config.gatewayUrl),
795
795
  env,
796
796
  label,
797
+ homeDir: os.homedir(),
797
798
  logger: createProgressLogger(spinner),
798
799
  })
799
800
  ));
@@ -1036,7 +1037,7 @@ export async function cmdApps(argv, overrides = {}) {
1036
1037
  const { client, env, label } = appSkillTarget(item.target, paths);
1037
1038
  if (!flags["skip-skills"]) {
1038
1039
  await withProgress(`Syncing skills for ${label}`, (spinner) => (
1039
- io.syncSkills({ client, gatewayUrl, env, label, logger: createProgressLogger(spinner) })
1040
+ io.syncSkills({ client, gatewayUrl, env, label, homeDir: os.homedir(), logger: createProgressLogger(spinner) })
1040
1041
  ));
1041
1042
  }
1042
1043
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
@@ -1167,7 +1168,7 @@ export async function provisionAndOpenManagedApps({
1167
1168
  for (const item of installed) {
1168
1169
  const { client, env, label } = appSkillTarget(item.target, paths);
1169
1170
  await withProgress(`Syncing skills for ${label}`, (spinner) => (
1170
- io.syncSkills({ client, gatewayUrl, env, label, logger: createProgressLogger(spinner) })
1171
+ io.syncSkills({ client, gatewayUrl, env, label, homeDir: os.homedir(), logger: createProgressLogger(spinner) })
1171
1172
  ));
1172
1173
  if (item.target === "chatgpt") io.secureCodexHome(paths.chatgpt.codexHome);
1173
1174
  }
@@ -1331,7 +1332,7 @@ async function refreshApps(targets, { staleOnly = false, tenantId = null } = {},
1331
1332
  const gatewayUrl = resolveSkillsGateway(config.gatewayUrl);
1332
1333
  for (const status of supportedStatuses) {
1333
1334
  const { client, env, label } = appSkillTarget(status.target, tenantPaths);
1334
- await io.syncSkills({ client, gatewayUrl, env, label });
1335
+ await io.syncSkills({ client, gatewayUrl, env, label, homeDir: os.homedir() });
1335
1336
  if (status.target === "chatgpt") io.secureCodexHome(tenantPaths.chatgpt.codexHome);
1336
1337
  }
1337
1338
  await io.syncAgents({
@@ -20,6 +20,7 @@ import {
20
20
  } from "../provisioning.js";
21
21
  import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
22
22
  import { runInstallRecovery } from "../installRecovery/engine.js";
23
+ import { refreshUpdateCache, updateNoticeLine } from "../updates.js";
23
24
  import { restoreNativeProfiles } from "./use.js";
24
25
 
25
26
  const HELP = `impel setup - prepare every accessible Impel tenant
@@ -149,6 +150,8 @@ export async function cmdSetup(argv, overrides = {}) {
149
150
  isTTY: process.stdin.isTTY,
150
151
  platform: process.platform,
151
152
  environment: process.env,
153
+ refreshUpdateCache,
154
+ updateNoticeLine,
152
155
  ...overrides,
153
156
  };
154
157
  if (overrides.prepareWindowsClis && !overrides.preparePlatformClis) {
@@ -220,6 +223,25 @@ export async function cmdSetup(argv, overrides = {}) {
220
223
  for (const tenant of orderedTenants) {
221
224
  console.log(` ${tenant.id}${tenant.id === selected.id ? " (CLI default)" : ""} — ${tenant.name}`);
222
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
+ }
223
245
  try {
224
246
  io.restoreNativeProfiles({ quiet: true });
225
247
  } catch (error) {
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
 
@@ -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) {
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";
@@ -45,10 +46,13 @@ const BENIGN_OUTPUT = /already (exist|install|add|present|configur)|up[ -]?to[ -
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
 
47
48
  // Codex `plugin marketplace upgrade` refreshes only Git marketplaces. A
48
- // registration recorded with a non-git source_type (an older Codex, or a
49
- // git-less fallback at registration time) fails every upgrade with this error
50
- // until the marketplace is re-registered otherwise the profile keeps the
51
- // snapshot cloned at registration forever and every sync warns.
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.
52
56
  const NON_GIT_MARKETPLACE_RE = /not configured as a git marketplace/i;
53
57
  const MARKETPLACE_NOT_FOUND_RE = /not found|no marketplace/i;
54
58
 
@@ -225,6 +229,44 @@ export function withGitEnvironment(env = {}, {
225
229
  };
226
230
  }
227
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
+
228
270
  const SKILL_COMMAND_TIMEOUT_MS = 120_000;
229
271
  const SKILL_COMMAND_OUTPUT_LIMIT = 10 * 1024 * 1024;
230
272
 
@@ -239,6 +281,7 @@ export function runSkillCommand(bin, args, env, {
239
281
  spawnImpl = spawn,
240
282
  timeoutMs = SKILL_COMMAND_TIMEOUT_MS,
241
283
  platform = process.platform,
284
+ cwd = undefined,
242
285
  } = {}) {
243
286
  const environment = { ...process.env, ...env };
244
287
  let invocation;
@@ -260,6 +303,9 @@ export function runSkillCommand(bin, args, env, {
260
303
  child = spawnImpl(invocation.command, invocation.args, {
261
304
  env: environment,
262
305
  stdio: ["ignore", "pipe", "pipe"],
306
+ // A git-safe working directory (see skillSyncSpawnDirectory); undefined
307
+ // inherits the caller's cwd.
308
+ ...(cwd ? { cwd } : {}),
263
309
  windowsVerbatimArguments: invocation.windowsVerbatimArguments,
264
310
  // One update run spawns ~150 of these cmd.exe-wrapped plugin commands
265
311
  // across tenants; keep them off-screen on Windows.
@@ -356,14 +402,19 @@ async function runSkillCommandWithRetry(run, bin, args, env) {
356
402
  /**
357
403
  * Heal a Codex marketplace whose stored registration cannot be upgraded by
358
404
  * re-registering it: remove the stale record (which also drops its installed
359
- * plugins) and add the current source. The install command that follows the
360
- * refresh phase reinstalls the plugin from the fresh snapshot. Returns true
361
- * only when the re-registration fully succeeded.
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.
362
413
  */
363
- async function reregisterCodexMarketplace({ run, bin, env, marketplaceName, sourceUrl }) {
414
+ async function reregisterCodexMarketplace({ run, bin, env, marketplaceName, sourceUrl, refreshArgs, logger, label }) {
364
415
  const removed = await run(bin, ["plugin", "marketplace", "remove", marketplaceName], env);
365
416
  if (!removed.ok && !MARKETPLACE_NOT_FOUND_RE.test(`${removed.stdout}\n${removed.stderr}`)) {
366
- return false;
417
+ return { repaired: false, reason: `remove: ${firstLine(removed.stderr) || `exit ${removed.status}`}` };
367
418
  }
368
419
  const added = await runSkillCommandWithRetry(
369
420
  run,
@@ -371,7 +422,18 @@ async function reregisterCodexMarketplace({ run, bin, env, marketplaceName, sour
371
422
  ["plugin", "marketplace", "add", sourceUrl],
372
423
  env,
373
424
  );
374
- return added.ok;
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 };
375
437
  }
376
438
 
377
439
  const MARKETPLACE_FETCH_TIMEOUT_MS = 30_000;
@@ -436,6 +498,7 @@ export async function syncSkills({
436
498
  logger = console,
437
499
  platform = process.platform,
438
500
  findGit = findNativeBinary,
501
+ homeDir = null,
439
502
  } = {}) {
440
503
  const spec = CLIENT_SPECS[client];
441
504
  if (!spec) {
@@ -449,9 +512,16 @@ export async function syncSkills({
449
512
  return { client, label: displayLabel, skipped: true, reason: "disabled" };
450
513
  }
451
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
+
452
522
  try {
453
523
  // Confirm the binary and its `plugin` subcommand exist before doing anything.
454
- const help = await run(spec.bin, ["plugin", "--help"], env);
524
+ const help = await runCommand(spec.bin, ["plugin", "--help"], env);
455
525
  if (help.missing) {
456
526
  logger.warn(`impel: skipping skill sync for ${displayLabel} — \`${spec.bin}\` CLI not found on PATH.`);
457
527
  return { client, label: displayLabel, skipped: true, reason: "binary-missing" };
@@ -484,7 +554,7 @@ export async function syncSkills({
484
554
  marketplaceName,
485
555
  })[0];
486
556
  const registerResult = await runSkillCommandWithRetry(
487
- run,
557
+ runCommand,
488
558
  spec.bin,
489
559
  registerCommand.args,
490
560
  env,
@@ -503,7 +573,7 @@ export async function syncSkills({
503
573
  // gives us the same dynamic name so refreshes can still use
504
574
  // PLUGIN@MARKETPLACE.
505
575
  if (!marketplaceName && !registerResult.missing) {
506
- const listed = await run(spec.bin, ["plugin", "marketplace", "list", "--json"], env);
576
+ const listed = await runCommand(spec.bin, ["plugin", "marketplace", "list", "--json"], env);
507
577
  if (listed.ok) marketplaceName = resolveConfiguredMarketplaceName(listed.stdout, sourceUrl);
508
578
  }
509
579
 
@@ -514,7 +584,7 @@ export async function syncSkills({
514
584
  }).slice(1);
515
585
  for (const command of commands) {
516
586
  const result = await runSkillCommandWithRetry(
517
- run,
587
+ runCommand,
518
588
  spec.bin,
519
589
  command.args,
520
590
  env,
@@ -524,22 +594,29 @@ export async function syncSkills({
524
594
  break;
525
595
  }
526
596
  if (isBenign(result)) continue;
597
+ let reason = firstLine(result.stderr) || `exit ${result.status}`;
527
598
  if (
528
599
  client === "codex"
529
600
  && command.phase === "refresh-marketplace"
530
601
  && marketplaceName
531
602
  && NON_GIT_MARKETPLACE_RE.test(`${result.stdout}\n${result.stderr}`)
532
603
  ) {
533
- const reregistered = await reregisterCodexMarketplace({
534
- run,
604
+ const repair = await reregisterCodexMarketplace({
605
+ run: runCommand,
535
606
  bin: spec.bin,
536
607
  env,
537
608
  marketplaceName,
538
609
  sourceUrl,
610
+ refreshArgs: command.args,
611
+ logger,
612
+ label: displayLabel,
539
613
  });
540
- if (reregistered) continue;
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})`;
541
618
  }
542
- failures.push({ phase: command.phase, reason: firstLine(result.stderr) || `exit ${result.status}` });
619
+ failures.push({ phase: command.phase, reason });
543
620
  }
544
621
 
545
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