copillm 0.2.6 → 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,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  const FALLBACK_PACKAGE_INFO = {
3
3
  name: "copillm",
4
- version: "0.2.6"
4
+ version: "0.2.7"
5
5
  };
6
6
  export function getPackageInfo() {
7
7
  const envName = cleanPackageValue(process.env.COPILLM_PACKAGE_NAME);
@@ -59,16 +59,25 @@ export async function resolveAgent(agent, opts = {}) {
59
59
  };
60
60
  }
61
61
  }
62
- // 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.).
63
66
  let target = pin.version;
67
+ let viewError = null;
64
68
  if (!target && !opts.offline) {
65
- 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
+ }
66
75
  }
67
76
  // 3. Cache lookup
68
77
  if (target) {
69
78
  const cachedDir = path.join(agentRoot, target);
70
- const cachedBin = binPathInPrefix(cachedDir, binName);
71
- if (cachedBin && fs.existsSync(cachedBin)) {
79
+ const cachedBin = findReadyCachedBin(cachedDir, binName);
80
+ if (cachedBin) {
72
81
  return {
73
82
  source: "cache",
74
83
  binPath: cachedBin,
@@ -81,8 +90,13 @@ export async function resolveAgent(agent, opts = {}) {
81
90
  }
82
91
  }
83
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.
84
95
  const last = pickLastCached(agentRoot, binName);
85
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
+ }
86
100
  return {
87
101
  source: "cache",
88
102
  binPath: last.binPath,
@@ -93,12 +107,22 @@ export async function resolveAgent(agent, opts = {}) {
93
107
  displayLine: `\u2192 ${binName} (cached fallback, ${displayPath(last.dir)}, v${last.version})`
94
108
  };
95
109
  }
110
+ if (viewError) {
111
+ throw new Error(`${binName} not installed and could not reach npm registry to download it: ${viewError.message}`);
112
+ }
96
113
  throw new Error(`${binName} not installed and no cache available (offline).`);
97
114
  }
98
115
  if (opts.offline) {
99
116
  throw new Error(`${binName}@${target} not in cache and --offline is set.`);
100
117
  }
101
- // 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.
102
126
  log(`\u2192 ${binName} (installing ${pkg}@${target} into ${displayPath(agentRoot)} \u2026)`);
103
127
  fs.mkdirSync(agentRoot, { recursive: true });
104
128
  const lockFile = path.join(agentRoot, ".lock");
@@ -106,8 +130,8 @@ export async function resolveAgent(agent, opts = {}) {
106
130
  try {
107
131
  // Re-check after acquiring lock — another invocation may have just installed it.
108
132
  const finalDir = path.join(agentRoot, target);
109
- const recheckBin = binPathInPrefix(finalDir, binName);
110
- if (recheckBin && fs.existsSync(recheckBin)) {
133
+ const recheckBin = findReadyCachedBin(finalDir, binName);
134
+ if (recheckBin) {
111
135
  return {
112
136
  source: "cache",
113
137
  binPath: recheckBin,
@@ -118,40 +142,37 @@ export async function resolveAgent(agent, opts = {}) {
118
142
  displayLine: `\u2192 ${binName} (cached, ${displayPath(finalDir)}, v${target})`
119
143
  };
120
144
  }
121
- const stagingDir = path.join(agentRoot, `.staging-${sanitize(target)}-${process.pid}`);
122
- if (fs.existsSync(stagingDir)) {
123
- 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 });
124
149
  }
125
- fs.mkdirSync(stagingDir, { recursive: true });
150
+ fs.mkdirSync(finalDir, { recursive: true });
126
151
  const spec = `${pkg}@${target}`;
127
- 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], {
128
153
  stdio: ["ignore", "inherit", "inherit"],
129
154
  shell: process.platform === "win32"
130
155
  });
131
156
  if (installResult.status !== 0) {
157
+ cleanupFailedInstall(finalDir);
132
158
  const msg = installResult.error ? `: ${installResult.error.message}` : "";
133
159
  throw new Error(`npm install ${spec} failed (exit ${installResult.status})${msg}`);
134
160
  }
135
- const stagedBin = binPathInPrefix(stagingDir, binName);
136
- if (!stagedBin || !fs.existsSync(stagedBin)) {
137
- throw new Error(`Installed package did not produce a ${binName} bin at ${stagingDir}`);
138
- }
139
- if (probeVersion(stagedBin) === null) {
140
- 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}`);
141
165
  }
142
- if (fs.existsSync(finalDir)) {
143
- 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`);
144
169
  }
145
- fs.renameSync(stagingDir, finalDir);
170
+ // Marker file: MUST be the last write. Cache-hit checks key off this.
146
171
  fs.writeFileSync(path.join(finalDir, "version.txt"), `${target}\n`);
147
172
  const pruned = pruneSiblings(agentRoot, target);
148
- const finalBin = binPathInPrefix(finalDir, binName);
149
- if (!finalBin) {
150
- throw new Error(`Final install missing bin at ${finalDir}`);
151
- }
152
173
  return {
153
174
  source: "installed",
154
- binPath: finalBin,
175
+ binPath: installedBin,
155
176
  version: target,
156
177
  packageName: pkg,
157
178
  cacheDir: finalDir,
@@ -163,6 +184,26 @@ export async function resolveAgent(agent, opts = {}) {
163
184
  releaseFileLock(lockFile);
164
185
  }
165
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
+ }
166
207
  function defaultNpmExecutable() {
167
208
  return process.env.COPILLM_NPM_EXECUTABLE && process.env.COPILLM_NPM_EXECUTABLE.trim().length > 0
168
209
  ? process.env.COPILLM_NPM_EXECUTABLE
@@ -245,7 +286,7 @@ function pickLastCached(agentRoot, binName) {
245
286
  .sort((a, b) => compareVersionsDescending(a, b));
246
287
  for (const v of versions) {
247
288
  const dir = path.join(agentRoot, v);
248
- const bin = binPathInPrefix(dir, binName);
289
+ const bin = findReadyCachedBin(dir, binName);
249
290
  if (bin)
250
291
  return { dir, binPath: bin, version: v };
251
292
  }
@@ -351,6 +392,3 @@ function displayPath(p) {
351
392
  }
352
393
  return p;
353
394
  }
354
- function sanitize(s) {
355
- return s.replace(/[^A-Za-z0-9._-]/g, "_");
356
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
5
5
  "license": "MIT",
6
6
  "type": "module",