copillm 0.4.0 → 0.4.1

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.
@@ -19,6 +19,8 @@ import { isDevModeActive } from "../shared/devMode.js";
19
19
  import { writeCommandOutput, writeHealthOutput } from "../shared/output.js";
20
20
  import { spawnDetachedDaemon } from "../daemon/spawnDetached.js";
21
21
  import { resolveRestartDecision } from "../daemon/restart.js";
22
+ import { selfUpdateToLatest, describeSelfUpdate } from "../daemon/selfUpdate.js";
23
+ import { getPackageInfo } from "../packageInfo.js";
22
24
  export function register(program) {
23
25
  program
24
26
  .command("start")
@@ -182,6 +184,16 @@ export function register(program) {
182
184
  const detectedDebug = lockSnapshot.state === "running" ? await probeDebugEndpoint(lockSnapshot.port) : false;
183
185
  const decision = resolveRestartDecision({ lock: lockSnapshot, detectedDebug, forceDebug });
184
186
  enableRuntimeDebug(decision.debug);
187
+ // Self-update to the latest published version before respawning. The old
188
+ // daemon is still serving here, so an `npm install -g` (which overwrites
189
+ // the files the new daemon will be spawned from) only updates the code
190
+ // the *next* daemon runs, never the one currently up. Best-effort: a
191
+ // failure or offline registry just restarts on the installed version.
192
+ const selfUpdate = await selfUpdateToLatest(getPackageInfo());
193
+ const selfUpdateNote = describeSelfUpdate(selfUpdate, getPackageInfo().name);
194
+ if (selfUpdateNote && !opts.json) {
195
+ process.stderr.write(`${selfUpdateNote}\n`);
196
+ }
185
197
  if (decision.action === "restart" && decision.previousPid !== null) {
186
198
  await stopByPid(decision.previousPid);
187
199
  }
@@ -194,7 +206,8 @@ export function register(program) {
194
206
  const started = await spawnDetachedDaemon({ debug: decision.debug, forcePort: decision.forcePort });
195
207
  await emitDetachedStartOutput(opts, started, decision.debug, "restarted", {
196
208
  previousPid: decision.previousPid,
197
- claudeCache: cache
209
+ claudeCache: cache,
210
+ selfUpdate
198
211
  });
199
212
  });
200
213
  program
@@ -425,6 +438,9 @@ async function emitDetachedStartOutput(opts, started, debug, mode, extra) {
425
438
  if (extra?.claudeCache !== undefined) {
426
439
  payload.claude_cache = extra.claudeCache;
427
440
  }
441
+ if (extra?.selfUpdate !== undefined) {
442
+ payload.self_update = extra.selfUpdate;
443
+ }
428
444
  writeCommandOutput(opts, banner, payload);
429
445
  }
430
446
  /** Narrow the lock inspection down to the shape `resolveRestartDecision` needs. */
@@ -0,0 +1,87 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { fetchLatestNpmVersion, isNewerVersion } from "../updateNotifier.js";
4
+ export async function selfUpdateToLatest(packageInfo, deps = {}) {
5
+ const env = deps.env ?? process.env;
6
+ const moduleUrl = deps.moduleUrl ?? import.meta.url;
7
+ if (!isGlobalNpmRuntime(moduleUrl, packageInfo.name)) {
8
+ return { status: "skipped", reason: "not-global" };
9
+ }
10
+ const fetchLatest = deps.fetchLatest ??
11
+ ((name) => fetchLatestNpmVersion(name, { registryUrl: env.COPILLM_UPDATE_REGISTRY_URL }));
12
+ let latest = null;
13
+ try {
14
+ latest = await fetchLatest(packageInfo.name);
15
+ }
16
+ catch {
17
+ latest = null;
18
+ }
19
+ if (!latest) {
20
+ return { status: "skipped", reason: "no-latest" };
21
+ }
22
+ if (!isNewerVersion(latest, packageInfo.version)) {
23
+ return { status: "up-to-date", version: packageInfo.version };
24
+ }
25
+ const runInstall = deps.runInstall ?? defaultRunInstall;
26
+ let outcome;
27
+ try {
28
+ outcome = runInstall(packageInfo.name, latest);
29
+ }
30
+ catch (error) {
31
+ outcome = { ok: false, detail: error instanceof Error ? error.message : "npm install failed" };
32
+ }
33
+ if (!outcome.ok) {
34
+ return { status: "failed", from: packageInfo.version, to: latest, detail: outcome.detail };
35
+ }
36
+ return { status: "updated", from: packageInfo.version, to: latest };
37
+ }
38
+ /** One-line human summary, or null when there's nothing worth printing. */
39
+ export function describeSelfUpdate(result, packageName) {
40
+ switch (result.status) {
41
+ case "updated":
42
+ return `Updated ${packageName} ${result.from} -> ${result.to}.`;
43
+ case "up-to-date":
44
+ return `${packageName} is already up to date (${result.version}).`;
45
+ case "failed":
46
+ return `Self-update to ${result.to} failed; continuing on ${result.from}. (${result.detail})`;
47
+ case "skipped":
48
+ // `no-latest` means the registry was unreachable — worth a quiet note;
49
+ // `not-global` (dev/dist runs) stays silent.
50
+ return result.reason === "no-latest"
51
+ ? "Could not check for updates; continuing on the installed version."
52
+ : null;
53
+ }
54
+ }
55
+ function defaultRunInstall(packageName, version) {
56
+ const isWindows = process.platform === "win32";
57
+ const result = spawnSync("npm", ["install", "-g", `${packageName}@${version}`], {
58
+ encoding: "utf8",
59
+ timeout: 120_000,
60
+ stdio: ["ignore", "ignore", "pipe"],
61
+ shell: isWindows
62
+ });
63
+ if (result.error) {
64
+ return { ok: false, detail: result.error.message };
65
+ }
66
+ if (result.status === 0) {
67
+ return { ok: true, detail: "" };
68
+ }
69
+ const stderr = (result.stderr ?? "").trim();
70
+ const tail = stderr ? stderr.split("\n").slice(-2).join(" ") : `npm exited with code ${result.status ?? "null"}`;
71
+ return { ok: false, detail: tail };
72
+ }
73
+ function isGlobalNpmRuntime(moduleUrl, packageName) {
74
+ // Prefer a real filesystem path, but fall back to the raw URL when
75
+ // `fileURLToPath` can't convert it (e.g. a non-Windows-style `file://` URL on
76
+ // Windows). We only need to scan for the `node_modules/<pkg>` marker, and the
77
+ // regex handles both `\` and `/` separators so the check is platform-agnostic.
78
+ let candidate;
79
+ try {
80
+ candidate = fileURLToPath(moduleUrl);
81
+ }
82
+ catch {
83
+ candidate = moduleUrl;
84
+ }
85
+ const normalized = candidate.replace(/\\/g, "/").toLowerCase();
86
+ return normalized.includes(`/node_modules/${packageName.toLowerCase()}/`);
87
+ }
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  const FALLBACK_PACKAGE_INFO = {
3
3
  name: "copillm",
4
- version: "0.4.0"
4
+ version: "0.4.1"
5
5
  };
6
6
  export function getPackageInfo() {
7
7
  const envName = cleanPackageValue(process.env.COPILLM_PACKAGE_NAME);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
5
5
  "license": "MIT",
6
6
  "type": "module",