copillm 0.4.2 → 0.4.4
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/commands/daemon.js +60 -1
- package/dist/cli/daemon/probes.js +3 -2
- package/dist/cli/daemon/versionStatus.js +63 -0
- package/dist/cli/index.js +1 -1
- package/dist/cli/updateNotifier.js +18 -13
- package/dist/{cli → config}/packageInfo.js +1 -1
- package/dist/server/proxy.js +6 -3
- package/dist/server/routes/debug.js +1 -0
- package/dist/server/routes/health.js +12 -6
- package/package.json +1 -1
|
@@ -20,7 +20,8 @@ import { writeCommandOutput, writeHealthOutput } from "../shared/output.js";
|
|
|
20
20
|
import { spawnDetachedDaemon } from "../daemon/spawnDetached.js";
|
|
21
21
|
import { resolveRestartDecision } from "../daemon/restart.js";
|
|
22
22
|
import { selfUpdateToLatest, describeSelfUpdate } from "../daemon/selfUpdate.js";
|
|
23
|
-
import {
|
|
23
|
+
import { computeVersionStatus } from "../daemon/versionStatus.js";
|
|
24
|
+
import { getPackageInfo } from "../../config/packageInfo.js";
|
|
24
25
|
export function register(program) {
|
|
25
26
|
program
|
|
26
27
|
.command("start")
|
|
@@ -267,11 +268,13 @@ export function register(program) {
|
|
|
267
268
|
.command("status")
|
|
268
269
|
.description("Show daemon status")
|
|
269
270
|
.option("--json", "JSON output")
|
|
271
|
+
.option("--no-registry-check", "Skip the npm registry lookup for latest-version comparison (also via NO_UPDATE_NOTIFIER / COPILLM_UPDATE_CHECK=0)")
|
|
270
272
|
.action(async (opts) => {
|
|
271
273
|
const config = loadConfig();
|
|
272
274
|
const lockState = inspectLock();
|
|
273
275
|
const checkedAtIso = new Date().toISOString();
|
|
274
276
|
const uptimeSeconds = lockState.state === "running" ? computeUptimeSeconds(lockState.lock.started_at_iso) : null;
|
|
277
|
+
const cliPackageInfo = getPackageInfo();
|
|
275
278
|
// inspectStoredCredential never returns the token itself, so it's safe to
|
|
276
279
|
// include the result in the status payload.
|
|
277
280
|
let authInfo;
|
|
@@ -303,6 +306,16 @@ export function register(program) {
|
|
|
303
306
|
health_state: null,
|
|
304
307
|
health_error: null,
|
|
305
308
|
health_status: "unknown",
|
|
309
|
+
// Version reporting — populated below. cli_version is always known
|
|
310
|
+
// (read from this CLI binary). daemon_version is `null` until the
|
|
311
|
+
// /healthz probe completes; older daemons (predating the field) also
|
|
312
|
+
// report null. latest_version is best-effort and `null` if the
|
|
313
|
+
// registry lookup is skipped or fails.
|
|
314
|
+
daemon_version: null,
|
|
315
|
+
cli_version: cliPackageInfo.version,
|
|
316
|
+
latest_version: null,
|
|
317
|
+
update_available: false,
|
|
318
|
+
version_hint: null,
|
|
306
319
|
checked_at_iso: checkedAtIso,
|
|
307
320
|
stale_reason: lockState.state === "stale" ? lockState.reason : null
|
|
308
321
|
};
|
|
@@ -313,7 +326,19 @@ export function register(program) {
|
|
|
313
326
|
status.health_check_status_code = health.statusCode;
|
|
314
327
|
status.health_state = health.status;
|
|
315
328
|
status.health_error = health.error;
|
|
329
|
+
status.daemon_version = health.version;
|
|
316
330
|
}
|
|
331
|
+
// Commander turns `--no-registry-check` into `opts.registryCheck === false`.
|
|
332
|
+
const versionStatus = await computeVersionStatus({
|
|
333
|
+
cliPackageInfo,
|
|
334
|
+
daemonVersion: status.daemon_version,
|
|
335
|
+
daemonRunning: status.running,
|
|
336
|
+
noRegistryCheck: opts.registryCheck === false
|
|
337
|
+
});
|
|
338
|
+
status.daemon_version = versionStatus.daemon_version;
|
|
339
|
+
status.latest_version = versionStatus.latest_version;
|
|
340
|
+
status.update_available = versionStatus.update_available;
|
|
341
|
+
status.version_hint = versionStatus.hint;
|
|
317
342
|
if (opts.json) {
|
|
318
343
|
process.stdout.write(JSON.stringify(status, null, 2) + "\n");
|
|
319
344
|
return;
|
|
@@ -332,6 +357,7 @@ export function register(program) {
|
|
|
332
357
|
process.stdout.write(` error=${status.health_error}`);
|
|
333
358
|
}
|
|
334
359
|
process.stdout.write("\n");
|
|
360
|
+
writeVersionLine(status);
|
|
335
361
|
if (status.bearer_ttl_seconds !== null) {
|
|
336
362
|
process.stdout.write(`bearer_ttl_seconds: ${status.bearer_ttl_seconds}\n`);
|
|
337
363
|
}
|
|
@@ -344,10 +370,12 @@ export function register(program) {
|
|
|
344
370
|
}
|
|
345
371
|
if (lockState.state === "stale") {
|
|
346
372
|
process.stdout.write(`stale lock (${lockState.reason})\n`);
|
|
373
|
+
writeVersionLine(status);
|
|
347
374
|
writeAuthStatusLine(authInfo);
|
|
348
375
|
return;
|
|
349
376
|
}
|
|
350
377
|
process.stdout.write("not running\n");
|
|
378
|
+
writeVersionLine(status);
|
|
351
379
|
writeAuthStatusLine(authInfo);
|
|
352
380
|
});
|
|
353
381
|
program
|
|
@@ -453,6 +481,37 @@ function toDaemonLockState(lockState) {
|
|
|
453
481
|
}
|
|
454
482
|
return { state: "missing" };
|
|
455
483
|
}
|
|
484
|
+
/**
|
|
485
|
+
* Render the `version: ...` line for `copillm status` human output.
|
|
486
|
+
*
|
|
487
|
+
* When the daemon is running and reports the same version as this CLI, the
|
|
488
|
+
* line is just `version: 0.4.3` — the common, boring case. Mismatches surface
|
|
489
|
+
* inline so users see immediately whether they need to restart (cli > daemon)
|
|
490
|
+
* or `npm install -g copillm` (latest > cli). When the daemon is stopped, the
|
|
491
|
+
* line only reports the CLI's version, since there is no live daemon version
|
|
492
|
+
* to compare against.
|
|
493
|
+
*/
|
|
494
|
+
function writeVersionLine(status) {
|
|
495
|
+
let line = "version: ";
|
|
496
|
+
if (status.running && status.daemon_version !== null) {
|
|
497
|
+
line += status.daemon_version;
|
|
498
|
+
if (status.daemon_version !== status.cli_version) {
|
|
499
|
+
line += ` (cli ${status.cli_version})`;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
else if (status.running && status.daemon_version === null) {
|
|
503
|
+
// Either an older daemon that predates this field, or a malformed
|
|
504
|
+
// /healthz response. The version_hint below will prompt a restart.
|
|
505
|
+
line += `? (cli ${status.cli_version})`;
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
line += `cli ${status.cli_version}`;
|
|
509
|
+
}
|
|
510
|
+
if (status.version_hint) {
|
|
511
|
+
line += ` — ${status.version_hint}`;
|
|
512
|
+
}
|
|
513
|
+
process.stdout.write(line + "\n");
|
|
514
|
+
}
|
|
456
515
|
/**
|
|
457
516
|
* Load the shared credential/config/discovery context for `copillm start`'s
|
|
458
517
|
* codex + pi init steps, ONLY when at least one of them is going to run.
|
|
@@ -94,7 +94,8 @@ export async function probeHealth(port, options) {
|
|
|
94
94
|
statusCode: response.status,
|
|
95
95
|
status: typeof payload.status === "string" ? payload.status : null,
|
|
96
96
|
error: typeof payload.error === "string" ? payload.error : null,
|
|
97
|
-
bearerTtlSeconds: response.ok && typeof payload.bearer_ttl_seconds === "number" ? payload.bearer_ttl_seconds : null
|
|
97
|
+
bearerTtlSeconds: response.ok && typeof payload.bearer_ttl_seconds === "number" ? payload.bearer_ttl_seconds : null,
|
|
98
|
+
version: typeof payload.version === "string" && payload.version.length > 0 ? payload.version : null
|
|
98
99
|
},
|
|
99
100
|
failed: false
|
|
100
101
|
};
|
|
@@ -104,7 +105,7 @@ export async function probeHealth(port, options) {
|
|
|
104
105
|
}
|
|
105
106
|
}, options);
|
|
106
107
|
return result.failed
|
|
107
|
-
? { ok: false, bearerTtlSeconds: null, statusCode: null, status: null, error: "health_probe_failed" }
|
|
108
|
+
? { ok: false, bearerTtlSeconds: null, statusCode: null, status: null, error: "health_probe_failed", version: null }
|
|
108
109
|
: result.ok;
|
|
109
110
|
}
|
|
110
111
|
export async function readLiveLock() {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { fetchLatestNpmVersion, isNewerVersion, parseBooleanOverride } from "../updateNotifier.js";
|
|
2
|
+
const DEFAULT_REGISTRY_TIMEOUT_MS = 1_500;
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the three version data points (daemon, cli, latest-on-npm) and
|
|
5
|
+
* compute the `copillm status` actionable hint from them. Pulled out of the
|
|
6
|
+
* status command so it stays unit-testable end-to-end without spinning up a
|
|
7
|
+
* real proxy.
|
|
8
|
+
*
|
|
9
|
+
* Opt-out precedence (any of these skips the npm lookup):
|
|
10
|
+
* - `noRegistryCheck` argument (driven by the `--no-registry-check` flag)
|
|
11
|
+
* - `NO_UPDATE_NOTIFIER` env (matches the update-notifier convention)
|
|
12
|
+
* - `COPILLM_UPDATE_CHECK=0|false|no|off`
|
|
13
|
+
*
|
|
14
|
+
* The registry lookup itself is best-effort: timeouts / network errors
|
|
15
|
+
* silently yield `latest_version: null`. We do *not* surface a "registry
|
|
16
|
+
* unreachable" message in status — that would be more noise than signal for
|
|
17
|
+
* a daemon-status command.
|
|
18
|
+
*/
|
|
19
|
+
export async function computeVersionStatus(options) {
|
|
20
|
+
const env = options.env ?? process.env;
|
|
21
|
+
const cliVersion = options.cliPackageInfo.version;
|
|
22
|
+
const daemonVersion = options.daemonVersion;
|
|
23
|
+
const registryCheckDisabled = options.noRegistryCheck === true ||
|
|
24
|
+
"NO_UPDATE_NOTIFIER" in env ||
|
|
25
|
+
parseBooleanOverride(env.COPILLM_UPDATE_CHECK) === false;
|
|
26
|
+
let latestVersion = null;
|
|
27
|
+
if (!registryCheckDisabled) {
|
|
28
|
+
latestVersion = await fetchLatestNpmVersion(options.cliPackageInfo.name, {
|
|
29
|
+
fetchImpl: options.fetchImpl,
|
|
30
|
+
registryUrl: options.registryUrl ?? env.COPILLM_UPDATE_REGISTRY_URL,
|
|
31
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_REGISTRY_TIMEOUT_MS
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
const daemonStale = options.daemonRunning && daemonVersion !== null && isNewerVersion(cliVersion, daemonVersion);
|
|
35
|
+
const cliStale = latestVersion !== null && isNewerVersion(latestVersion, cliVersion);
|
|
36
|
+
const daemonReportsNoVersion = options.daemonRunning && daemonVersion === null;
|
|
37
|
+
return {
|
|
38
|
+
daemon_version: daemonVersion,
|
|
39
|
+
cli_version: cliVersion,
|
|
40
|
+
latest_version: latestVersion,
|
|
41
|
+
update_available: daemonStale || cliStale,
|
|
42
|
+
hint: buildVersionHint({ daemonStale, cliStale, daemonReportsNoVersion, cliVersion, latestVersion })
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Pure helper. Public for direct unit testing of the messaging matrix.
|
|
47
|
+
*/
|
|
48
|
+
export function buildVersionHint(input) {
|
|
49
|
+
const parts = [];
|
|
50
|
+
if (input.cliStale && input.latestVersion !== null) {
|
|
51
|
+
parts.push(`newer version available: v${input.latestVersion} (npm install -g copillm)`);
|
|
52
|
+
}
|
|
53
|
+
if (input.daemonStale) {
|
|
54
|
+
parts.push(`restart to apply cli v${input.cliVersion}`);
|
|
55
|
+
}
|
|
56
|
+
if (parts.length === 0 && input.daemonReportsNoVersion) {
|
|
57
|
+
return "restart to start reporting version";
|
|
58
|
+
}
|
|
59
|
+
if (parts.length === 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return parts.join("; ");
|
|
63
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import * as piCmd from "./commands/agents/pi.js";
|
|
|
11
11
|
import * as copilotCmd from "./commands/agents/copilot.js";
|
|
12
12
|
import { setRootLogger, setRootProgram } from "./shared/debug.js";
|
|
13
13
|
import { applyDevModeEnv } from "./shared/devMode.js";
|
|
14
|
-
import { getPackageInfo } from "
|
|
14
|
+
import { getPackageInfo } from "../config/packageInfo.js";
|
|
15
15
|
import { maybeNotifyAboutUpdate } from "./updateNotifier.js";
|
|
16
16
|
const logger = createLogger();
|
|
17
17
|
const program = new Command();
|
|
@@ -67,6 +67,24 @@ export function distTagsUrl(packageName, registryUrl = DEFAULT_REGISTRY_URL) {
|
|
|
67
67
|
export function isNewerVersion(candidate, current) {
|
|
68
68
|
return compareSemver(candidate, current) > 0;
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Parse `"1" | "true" | "yes" | "on"` → true, `"0" | "false" | "no" | "off"` → false,
|
|
72
|
+
* anything else → null. Exposed so the status command can mirror the
|
|
73
|
+
* `COPILLM_UPDATE_CHECK` opt-out semantics without re-implementing the parsing.
|
|
74
|
+
*/
|
|
75
|
+
export function parseBooleanOverride(value) {
|
|
76
|
+
if (value === undefined) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const normalized = value.trim().toLowerCase();
|
|
80
|
+
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
if (["0", "false", "no", "off"].includes(normalized)) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
70
88
|
function shouldRunUpdateCheck(opts) {
|
|
71
89
|
if (opts.stderr.isTTY !== true)
|
|
72
90
|
return false;
|
|
@@ -168,19 +186,6 @@ function notifyIfNewer(stderr, packageInfo, latestVersion) {
|
|
|
168
186
|
function hasArg(argv, arg) {
|
|
169
187
|
return argv.slice(2).includes(arg);
|
|
170
188
|
}
|
|
171
|
-
function parseBooleanOverride(value) {
|
|
172
|
-
if (value === undefined) {
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
175
|
-
const normalized = value.trim().toLowerCase();
|
|
176
|
-
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
177
|
-
return true;
|
|
178
|
-
}
|
|
179
|
-
if (["0", "false", "no", "off"].includes(normalized)) {
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
189
|
function isTruthyCi(value) {
|
|
185
190
|
if (value === undefined) {
|
|
186
191
|
return false;
|
package/dist/server/proxy.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { getPackageInfo } from "../config/packageInfo.js";
|
|
3
4
|
import { singleAccountResolver } from "./accountResolver.js";
|
|
4
5
|
import { attachRequestLifecycle, isBenignSocketError, safeEnd, safeSendJson } from "./requestLifecycle.js";
|
|
5
6
|
import { InvalidRequestShapeError, JsonRequestParseError, RequestBodyTooLargeError } from "./errors.js";
|
|
@@ -11,6 +12,7 @@ import { handleProxyForward } from "./routes/proxyForward.js";
|
|
|
11
12
|
import { isLocalRequest, resolveRoute, safePathname } from "./routes/shared.js";
|
|
12
13
|
export async function startProxyServer(input) {
|
|
13
14
|
const debugEnabled = input.debug === true;
|
|
15
|
+
const packageVersion = getPackageInfo().version;
|
|
14
16
|
const resolver = input.accountResolver ??
|
|
15
17
|
singleAccountResolver({
|
|
16
18
|
tokenManager: input.tokenManager,
|
|
@@ -57,10 +59,10 @@ export async function startProxyServer(input) {
|
|
|
57
59
|
}
|
|
58
60
|
switch (route.kind) {
|
|
59
61
|
case "livez":
|
|
60
|
-
handleLivez(res);
|
|
62
|
+
handleLivez(res, { version: packageVersion });
|
|
61
63
|
return;
|
|
62
64
|
case "healthz":
|
|
63
|
-
await handleHealthz(res, resolver.default.tokenManager);
|
|
65
|
+
await handleHealthz(res, resolver.default.tokenManager, { version: packageVersion });
|
|
64
66
|
return;
|
|
65
67
|
case "models":
|
|
66
68
|
await handleModels(res, route.kind, resolver.default);
|
|
@@ -86,7 +88,8 @@ export async function startProxyServer(input) {
|
|
|
86
88
|
tokenManager: resolver.default.tokenManager,
|
|
87
89
|
githubToken: resolver.default.githubToken,
|
|
88
90
|
port: input.port,
|
|
89
|
-
accounts: resolver.describe()
|
|
91
|
+
accounts: resolver.describe(),
|
|
92
|
+
packageVersion
|
|
90
93
|
});
|
|
91
94
|
return;
|
|
92
95
|
case "not_found":
|
|
@@ -35,6 +35,7 @@ export async function handleDebug(res, input) {
|
|
|
35
35
|
port: input.port,
|
|
36
36
|
pid: process.pid,
|
|
37
37
|
node_version: process.version,
|
|
38
|
+
version: input.packageVersion ?? null,
|
|
38
39
|
started_at_iso: DAEMON_STARTED_AT_ISO,
|
|
39
40
|
uptime_seconds: uptimeSeconds,
|
|
40
41
|
account_type: input.config.accountType,
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { healthFailure } from "../errors.js";
|
|
2
2
|
import { safeSendJson } from "../requestLifecycle.js";
|
|
3
3
|
const HEALTH_REFRESH_THRESHOLD_SECONDS = 60;
|
|
4
|
-
export function handleLivez(res) {
|
|
5
|
-
safeSendJson(res, 200, {
|
|
4
|
+
export function handleLivez(res, meta) {
|
|
5
|
+
safeSendJson(res, 200, {
|
|
6
|
+
status: "ok",
|
|
7
|
+
uptime_seconds: Math.floor(process.uptime()),
|
|
8
|
+
version: meta.version
|
|
9
|
+
});
|
|
6
10
|
}
|
|
7
|
-
export async function handleHealthz(res, tokenManager) {
|
|
11
|
+
export async function handleHealthz(res, tokenManager, meta) {
|
|
8
12
|
const ttl = tokenManager.expiresInSeconds();
|
|
9
13
|
if (ttl !== null && ttl > HEALTH_REFRESH_THRESHOLD_SECONDS) {
|
|
10
14
|
safeSendJson(res, 200, {
|
|
11
15
|
status: "ok",
|
|
12
16
|
token_state: "fresh",
|
|
13
17
|
refresh_threshold_seconds: HEALTH_REFRESH_THRESHOLD_SECONDS,
|
|
14
|
-
bearer_ttl_seconds: ttl
|
|
18
|
+
bearer_ttl_seconds: ttl,
|
|
19
|
+
version: meta.version
|
|
15
20
|
});
|
|
16
21
|
return;
|
|
17
22
|
}
|
|
@@ -22,11 +27,12 @@ export async function handleHealthz(res, tokenManager) {
|
|
|
22
27
|
status: "ok",
|
|
23
28
|
token_state: "refreshed",
|
|
24
29
|
refresh_threshold_seconds: HEALTH_REFRESH_THRESHOLD_SECONDS,
|
|
25
|
-
bearer_ttl_seconds: refreshedTtl
|
|
30
|
+
bearer_ttl_seconds: refreshedTtl,
|
|
31
|
+
version: meta.version
|
|
26
32
|
});
|
|
27
33
|
}
|
|
28
34
|
catch (error) {
|
|
29
35
|
const failed = healthFailure(error);
|
|
30
|
-
safeSendJson(res, failed.httpStatus, failed.payload);
|
|
36
|
+
safeSendJson(res, failed.httpStatus, { ...failed.payload, version: meta.version });
|
|
31
37
|
}
|
|
32
38
|
}
|