copillm 0.2.5 → 0.2.7
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/dist/cli/launchAgent.js +22 -6
- package/dist/cli/packageInfo.js +1 -1
- package/dist/cli/resolveAgent.js +74 -32
- package/dist/cli/windowsSpawn.js +71 -0
- package/package.json +1 -1
package/dist/cli/launchAgent.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { resolveAgent } from "./resolveAgent.js";
|
|
2
|
+
import { spawnAgent } from "./windowsSpawn.js";
|
|
3
3
|
export async function launchAgent(opts) {
|
|
4
4
|
const log = opts.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
5
5
|
let resolved;
|
|
6
6
|
try {
|
|
7
|
-
resolved = await resolveAgent(opts.agent, {
|
|
7
|
+
resolved = await resolveAgent(opts.agent, {
|
|
8
|
+
pinnedSpec: opts.pinnedSpec,
|
|
9
|
+
preferPath: useSystemAgentOptIn(),
|
|
10
|
+
log
|
|
11
|
+
});
|
|
8
12
|
}
|
|
9
13
|
catch (error) {
|
|
10
14
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -14,11 +18,9 @@ export async function launchAgent(opts) {
|
|
|
14
18
|
}
|
|
15
19
|
log(resolved.displayLine);
|
|
16
20
|
const childEnv = { ...process.env, ...opts.env };
|
|
17
|
-
const
|
|
18
|
-
const child = spawn(resolved.binPath, opts.args, {
|
|
21
|
+
const child = spawnAgent(resolved.binPath, opts.args, {
|
|
19
22
|
stdio: "inherit",
|
|
20
|
-
env: childEnv
|
|
21
|
-
shell: useShell
|
|
23
|
+
env: childEnv
|
|
22
24
|
});
|
|
23
25
|
return new Promise((resolve, reject) => {
|
|
24
26
|
child.once("error", reject);
|
|
@@ -65,3 +67,17 @@ function installHint(agent) {
|
|
|
65
67
|
" npm i -g @anthropic-ai/claude-code"
|
|
66
68
|
].join("\n");
|
|
67
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Whether the user has opted in to letting copillm fall back to a system-installed
|
|
72
|
+
* coding-agent binary on PATH. Off by default — copillm uses its own cache and
|
|
73
|
+
* downloads on demand so the executed version is deterministic.
|
|
74
|
+
*
|
|
75
|
+
* Opt in by setting `COPILLM_USE_SYSTEM_AGENT` to `1`, `true`, or `yes`
|
|
76
|
+
* (case-insensitive).
|
|
77
|
+
*/
|
|
78
|
+
function useSystemAgentOptIn() {
|
|
79
|
+
const raw = process.env.COPILLM_USE_SYSTEM_AGENT;
|
|
80
|
+
if (!raw)
|
|
81
|
+
return false;
|
|
82
|
+
return /^(1|true|yes)$/i.test(raw.trim());
|
|
83
|
+
}
|
package/dist/cli/packageInfo.js
CHANGED
package/dist/cli/resolveAgent.js
CHANGED
|
@@ -39,8 +39,12 @@ export async function resolveAgent(agent, opts = {}) {
|
|
|
39
39
|
const pkg = pin.packageName;
|
|
40
40
|
const binName = AGENT_REGISTRY[agent].binName;
|
|
41
41
|
const agentRoot = path.join(cacheRoot, agent);
|
|
42
|
-
// 1. PATH lookup (
|
|
43
|
-
|
|
42
|
+
// 1. PATH lookup (opt-in only).
|
|
43
|
+
// PATH lookup is OFF by default so the running agent version is always the one copillm
|
|
44
|
+
// manages in its cache. Users who want to fall back to a system-installed binary can opt
|
|
45
|
+
// in via the COPILLM_USE_SYSTEM_AGENT env var (wired in launchAgent.ts) or by passing
|
|
46
|
+
// `preferPath: true` directly. Pinned versions always skip this branch.
|
|
47
|
+
if (!pin.version && opts.preferPath === true) {
|
|
44
48
|
const found = findOnPath(binName);
|
|
45
49
|
if (found) {
|
|
46
50
|
const v = probeVersion(found) ?? "unknown";
|
|
@@ -55,16 +59,25 @@ export async function resolveAgent(agent, opts = {}) {
|
|
|
55
59
|
};
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
|
-
// 2. Determine target version
|
|
62
|
+
// 2. Determine target version. If we can reach npm we ask for `latest`;
|
|
63
|
+
// otherwise we fall through to whatever's already cached so the user can
|
|
64
|
+
// keep working when the registry is unreachable (corp proxy, npm outage,
|
|
65
|
+
// airplane mode, etc.).
|
|
59
66
|
let target = pin.version;
|
|
67
|
+
let viewError = null;
|
|
60
68
|
if (!target && !opts.offline) {
|
|
61
|
-
|
|
69
|
+
try {
|
|
70
|
+
target = npmViewLatest(npmExe, pkg);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
viewError = err instanceof Error ? err : new Error(String(err));
|
|
74
|
+
}
|
|
62
75
|
}
|
|
63
76
|
// 3. Cache lookup
|
|
64
77
|
if (target) {
|
|
65
78
|
const cachedDir = path.join(agentRoot, target);
|
|
66
|
-
const cachedBin =
|
|
67
|
-
if (cachedBin
|
|
79
|
+
const cachedBin = findReadyCachedBin(cachedDir, binName);
|
|
80
|
+
if (cachedBin) {
|
|
68
81
|
return {
|
|
69
82
|
source: "cache",
|
|
70
83
|
binPath: cachedBin,
|
|
@@ -77,8 +90,13 @@ export async function resolveAgent(agent, opts = {}) {
|
|
|
77
90
|
}
|
|
78
91
|
}
|
|
79
92
|
else {
|
|
93
|
+
// Either --offline or we couldn't reach npm to ask "what's latest?".
|
|
94
|
+
// Use the newest known-good install on disk.
|
|
80
95
|
const last = pickLastCached(agentRoot, binName);
|
|
81
96
|
if (last) {
|
|
97
|
+
if (viewError) {
|
|
98
|
+
log(`\u26a0 could not reach npm registry to check for updates (${viewError.message}); using cached ${binName} v${last.version}`);
|
|
99
|
+
}
|
|
82
100
|
return {
|
|
83
101
|
source: "cache",
|
|
84
102
|
binPath: last.binPath,
|
|
@@ -89,12 +107,22 @@ export async function resolveAgent(agent, opts = {}) {
|
|
|
89
107
|
displayLine: `\u2192 ${binName} (cached fallback, ${displayPath(last.dir)}, v${last.version})`
|
|
90
108
|
};
|
|
91
109
|
}
|
|
110
|
+
if (viewError) {
|
|
111
|
+
throw new Error(`${binName} not installed and could not reach npm registry to download it: ${viewError.message}`);
|
|
112
|
+
}
|
|
92
113
|
throw new Error(`${binName} not installed and no cache available (offline).`);
|
|
93
114
|
}
|
|
94
115
|
if (opts.offline) {
|
|
95
116
|
throw new Error(`${binName}@${target} not in cache and --offline is set.`);
|
|
96
117
|
}
|
|
97
|
-
// 4. Install
|
|
118
|
+
// 4. Install. We install *directly* into the canonical version directory
|
|
119
|
+
// and write `version.txt` LAST as the "install complete" marker.
|
|
120
|
+
// findReadyCachedBin requires both the bin and the marker, so any crash
|
|
121
|
+
// before the marker is written leaves the tree visible as incomplete and
|
|
122
|
+
// the next run cleans it up + re-installs. Avoids the older staging+rename
|
|
123
|
+
// pattern, which had to retry rename-of-directory on Windows when AV or
|
|
124
|
+
// npm post-install workers transiently held handles on freshly-written
|
|
125
|
+
// files.
|
|
98
126
|
log(`\u2192 ${binName} (installing ${pkg}@${target} into ${displayPath(agentRoot)} \u2026)`);
|
|
99
127
|
fs.mkdirSync(agentRoot, { recursive: true });
|
|
100
128
|
const lockFile = path.join(agentRoot, ".lock");
|
|
@@ -102,8 +130,8 @@ export async function resolveAgent(agent, opts = {}) {
|
|
|
102
130
|
try {
|
|
103
131
|
// Re-check after acquiring lock — another invocation may have just installed it.
|
|
104
132
|
const finalDir = path.join(agentRoot, target);
|
|
105
|
-
const recheckBin =
|
|
106
|
-
if (recheckBin
|
|
133
|
+
const recheckBin = findReadyCachedBin(finalDir, binName);
|
|
134
|
+
if (recheckBin) {
|
|
107
135
|
return {
|
|
108
136
|
source: "cache",
|
|
109
137
|
binPath: recheckBin,
|
|
@@ -114,40 +142,37 @@ export async function resolveAgent(agent, opts = {}) {
|
|
|
114
142
|
displayLine: `\u2192 ${binName} (cached, ${displayPath(finalDir)}, v${target})`
|
|
115
143
|
};
|
|
116
144
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
145
|
+
// Wipe any partial state (missing marker means a previous attempt was
|
|
146
|
+
// interrupted) before we re-run npm into the same prefix.
|
|
147
|
+
if (fs.existsSync(finalDir)) {
|
|
148
|
+
fs.rmSync(finalDir, { recursive: true, force: true });
|
|
120
149
|
}
|
|
121
|
-
fs.mkdirSync(
|
|
150
|
+
fs.mkdirSync(finalDir, { recursive: true });
|
|
122
151
|
const spec = `${pkg}@${target}`;
|
|
123
|
-
const installResult = spawnSync(npmExe, ["install", "--prefix",
|
|
152
|
+
const installResult = spawnSync(npmExe, ["install", "--prefix", finalDir, "--no-audit", "--no-fund", "--omit=dev", spec], {
|
|
124
153
|
stdio: ["ignore", "inherit", "inherit"],
|
|
125
154
|
shell: process.platform === "win32"
|
|
126
155
|
});
|
|
127
156
|
if (installResult.status !== 0) {
|
|
157
|
+
cleanupFailedInstall(finalDir);
|
|
128
158
|
const msg = installResult.error ? `: ${installResult.error.message}` : "";
|
|
129
159
|
throw new Error(`npm install ${spec} failed (exit ${installResult.status})${msg}`);
|
|
130
160
|
}
|
|
131
|
-
const
|
|
132
|
-
if (!
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (probeVersion(stagedBin) === null) {
|
|
136
|
-
throw new Error(`Smoke test failed: ${stagedBin} --version did not exit 0`);
|
|
161
|
+
const installedBin = binPathInPrefix(finalDir, binName);
|
|
162
|
+
if (!installedBin || !fs.existsSync(installedBin)) {
|
|
163
|
+
cleanupFailedInstall(finalDir);
|
|
164
|
+
throw new Error(`Installed package did not produce a ${binName} bin at ${finalDir}`);
|
|
137
165
|
}
|
|
138
|
-
if (
|
|
139
|
-
|
|
166
|
+
if (probeVersion(installedBin) === null) {
|
|
167
|
+
cleanupFailedInstall(finalDir);
|
|
168
|
+
throw new Error(`Smoke test failed: ${installedBin} --version did not exit 0`);
|
|
140
169
|
}
|
|
141
|
-
|
|
170
|
+
// Marker file: MUST be the last write. Cache-hit checks key off this.
|
|
142
171
|
fs.writeFileSync(path.join(finalDir, "version.txt"), `${target}\n`);
|
|
143
172
|
const pruned = pruneSiblings(agentRoot, target);
|
|
144
|
-
const finalBin = binPathInPrefix(finalDir, binName);
|
|
145
|
-
if (!finalBin) {
|
|
146
|
-
throw new Error(`Final install missing bin at ${finalDir}`);
|
|
147
|
-
}
|
|
148
173
|
return {
|
|
149
174
|
source: "installed",
|
|
150
|
-
binPath:
|
|
175
|
+
binPath: installedBin,
|
|
151
176
|
version: target,
|
|
152
177
|
packageName: pkg,
|
|
153
178
|
cacheDir: finalDir,
|
|
@@ -159,6 +184,26 @@ export async function resolveAgent(agent, opts = {}) {
|
|
|
159
184
|
releaseFileLock(lockFile);
|
|
160
185
|
}
|
|
161
186
|
}
|
|
187
|
+
function cleanupFailedInstall(dir) {
|
|
188
|
+
try {
|
|
189
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// Best-effort: a stuck handle here means the next run will retry the
|
|
193
|
+
// cleanup before reinstalling. Worst case the user gets a clearer
|
|
194
|
+
// "rmSync failed" error on the next attempt.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function findReadyCachedBin(dir, binName) {
|
|
198
|
+
const bin = binPathInPrefix(dir, binName);
|
|
199
|
+
if (!bin || !fs.existsSync(bin))
|
|
200
|
+
return null;
|
|
201
|
+
// version.txt is written LAST, after the smoke test passes. Missing
|
|
202
|
+
// marker = partial/aborted install; do not treat as a cache hit.
|
|
203
|
+
if (!fs.existsSync(path.join(dir, "version.txt")))
|
|
204
|
+
return null;
|
|
205
|
+
return bin;
|
|
206
|
+
}
|
|
162
207
|
function defaultNpmExecutable() {
|
|
163
208
|
return process.env.COPILLM_NPM_EXECUTABLE && process.env.COPILLM_NPM_EXECUTABLE.trim().length > 0
|
|
164
209
|
? process.env.COPILLM_NPM_EXECUTABLE
|
|
@@ -241,7 +286,7 @@ function pickLastCached(agentRoot, binName) {
|
|
|
241
286
|
.sort((a, b) => compareVersionsDescending(a, b));
|
|
242
287
|
for (const v of versions) {
|
|
243
288
|
const dir = path.join(agentRoot, v);
|
|
244
|
-
const bin =
|
|
289
|
+
const bin = findReadyCachedBin(dir, binName);
|
|
245
290
|
if (bin)
|
|
246
291
|
return { dir, binPath: bin, version: v };
|
|
247
292
|
}
|
|
@@ -347,6 +392,3 @@ function displayPath(p) {
|
|
|
347
392
|
}
|
|
348
393
|
return p;
|
|
349
394
|
}
|
|
350
|
-
function sanitize(s) {
|
|
351
|
-
return s.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
352
|
-
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
const META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
|
|
3
|
+
function escapeArgument(arg, doubleEscapeMetaChars) {
|
|
4
|
+
let escaped = `${arg}`;
|
|
5
|
+
escaped = escaped.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"');
|
|
6
|
+
escaped = escaped.replace(/(?=(\\+?)?)\1$/, "$1$1");
|
|
7
|
+
escaped = `"${escaped}"`;
|
|
8
|
+
escaped = escaped.replace(META_CHARS, "^$1");
|
|
9
|
+
if (doubleEscapeMetaChars) {
|
|
10
|
+
escaped = escaped.replace(META_CHARS, "^$1");
|
|
11
|
+
}
|
|
12
|
+
return escaped;
|
|
13
|
+
}
|
|
14
|
+
function escapeCommand(command) {
|
|
15
|
+
return command.replace(META_CHARS, "^$1");
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build the `cmd.exe /d /s /c "..."` invocation we need to run a `.cmd` /
|
|
19
|
+
* `.bat` file safely on Windows.
|
|
20
|
+
*
|
|
21
|
+
* Background: Node's `child_process.spawn` cannot directly exec a batch file
|
|
22
|
+
* (CreateProcess only understands real PE binaries), and `shell: true` is now
|
|
23
|
+
* deprecated when combined with an args array because Node performs no
|
|
24
|
+
* escaping (see Node DEP0190). The accepted alternative — long used by
|
|
25
|
+
* cross-spawn and npm's own bin shims — is to spawn `cmd.exe` ourselves,
|
|
26
|
+
* pre-quote the command line, and set `windowsVerbatimArguments: true` so
|
|
27
|
+
* Node hands the buffer to Windows untouched.
|
|
28
|
+
*
|
|
29
|
+
* The quoting follows the well-known two-layer algorithm:
|
|
30
|
+
* 1. Apply Microsoft's CommandLineToArgvW rules (backslash/quote dance) so
|
|
31
|
+
* that the underlying program parses each argument back into the values
|
|
32
|
+
* we passed in.
|
|
33
|
+
* 2. Escape cmd.exe metacharacters (`^ & | < > ( ) % ! ;` etc.) with `^` so
|
|
34
|
+
* they don't get interpreted by the shell before the program sees them.
|
|
35
|
+
*
|
|
36
|
+
* `doubleEscape` is needed when the target is an npm-generated `.cmd` shim
|
|
37
|
+
* (which itself spawns a nested cmd.exe via `CALL` on older npm versions, or
|
|
38
|
+
* via subshell composition); each cmd.exe parse strips one layer of `^`, so
|
|
39
|
+
* we apply it twice to survive the round trip. We default to true for
|
|
40
|
+
* `.cmd`/`.bat` because every agent we launch is installed via npm.
|
|
41
|
+
*/
|
|
42
|
+
export function buildWindowsCmdInvocation(file, args, doubleEscape = true) {
|
|
43
|
+
const escapedCommand = escapeCommand(file);
|
|
44
|
+
const escapedArgs = args.map((a) => escapeArgument(a, doubleEscape));
|
|
45
|
+
const commandLine = [escapedCommand, ...escapedArgs].join(" ");
|
|
46
|
+
const comspec = process.env.ComSpec || process.env.comspec || "cmd.exe";
|
|
47
|
+
return {
|
|
48
|
+
command: comspec,
|
|
49
|
+
args: ["/d", "/s", "/c", `"${commandLine}"`]
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Spawn a child process, transparently routing `.cmd` / `.bat` files through
|
|
54
|
+
* `cmd.exe` with safe quoting on Windows. Non-Windows platforms and real
|
|
55
|
+
* `.exe` / `.com` binaries go through a direct `spawn` with no shell flag.
|
|
56
|
+
*
|
|
57
|
+
* Mirrors the surface of `child_process.spawn(file, args, options)` but
|
|
58
|
+
* never sets `shell: true` and therefore never triggers Node's DEP0190
|
|
59
|
+
* deprecation warning.
|
|
60
|
+
*/
|
|
61
|
+
export function spawnAgent(file, args, options) {
|
|
62
|
+
if (process.platform !== "win32" || !/\.(cmd|bat)$/i.test(file)) {
|
|
63
|
+
return spawn(file, args, { ...options, shell: false });
|
|
64
|
+
}
|
|
65
|
+
const { command, args: cmdArgs } = buildWindowsCmdInvocation(file, args);
|
|
66
|
+
return spawn(command, cmdArgs, {
|
|
67
|
+
...options,
|
|
68
|
+
shell: false,
|
|
69
|
+
windowsVerbatimArguments: true
|
|
70
|
+
});
|
|
71
|
+
}
|