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 +1 -1
- package/src/agents.js +2 -1
- package/src/apps.js +3 -1
- package/src/claudeSandbox.js +18 -11
- package/src/cliProfiles.js +2 -1
- package/src/commands/apps.js +6 -5
- package/src/commands/setup.js +22 -0
- package/src/config.js +17 -5
- package/src/macSetup.js +3 -0
- package/src/provisioning.js +2 -0
- package/src/skills.js +95 -18
- 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/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
|
-
|
|
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
|
package/src/claudeSandbox.js
CHANGED
|
@@ -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
|
-
*
|
|
25
|
+
* Impel's Claude sandbox policy: disabled.
|
|
22
26
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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:
|
|
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
|
-
...
|
|
59
|
+
...preserved,
|
|
53
60
|
...policy,
|
|
54
61
|
};
|
|
55
62
|
settings.permissions = applyImpelClaudeNetworkPermission(settings.permissions);
|
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
|
@@ -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({
|
package/src/commands/setup.js
CHANGED
|
@@ -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.
|
|
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/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) {
|
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 (
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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)
|
|
360
|
-
*
|
|
361
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|