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.
@@ -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, { pinnedSpec: opts.pinnedSpec, log });
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 useShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(resolved.binPath);
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
+ }
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  const FALLBACK_PACKAGE_INFO = {
3
3
  name: "copillm",
4
- version: "0.2.5"
4
+ version: "0.2.7"
5
5
  };
6
6
  export function getPackageInfo() {
7
7
  const envName = cleanPackageValue(process.env.COPILLM_PACKAGE_NAME);
@@ -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 (skipped when user pinned a specific version)
43
- if (!pin.version && opts.preferPath !== false) {
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
- target = npmViewLatest(npmExe, pkg);
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 = binPathInPrefix(cachedDir, binName);
67
- if (cachedBin && fs.existsSync(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 = binPathInPrefix(finalDir, binName);
106
- if (recheckBin && fs.existsSync(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
- const stagingDir = path.join(agentRoot, `.staging-${sanitize(target)}-${process.pid}`);
118
- if (fs.existsSync(stagingDir)) {
119
- fs.rmSync(stagingDir, { recursive: true, force: true });
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(stagingDir, { recursive: true });
150
+ fs.mkdirSync(finalDir, { recursive: true });
122
151
  const spec = `${pkg}@${target}`;
123
- const installResult = spawnSync(npmExe, ["install", "--prefix", stagingDir, "--no-audit", "--no-fund", "--omit=dev", spec], {
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 stagedBin = binPathInPrefix(stagingDir, binName);
132
- if (!stagedBin || !fs.existsSync(stagedBin)) {
133
- throw new Error(`Installed package did not produce a ${binName} bin at ${stagingDir}`);
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 (fs.existsSync(finalDir)) {
139
- fs.rmSync(finalDir, { recursive: true, force: true });
166
+ if (probeVersion(installedBin) === null) {
167
+ cleanupFailedInstall(finalDir);
168
+ throw new Error(`Smoke test failed: ${installedBin} --version did not exit 0`);
140
169
  }
141
- fs.renameSync(stagingDir, finalDir);
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: finalBin,
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 = binPathInPrefix(dir, binName);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
5
5
  "license": "MIT",
6
6
  "type": "module",