@agentconnect.md/cli 0.0.0 → 1.13.0

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/index.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import fs, { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
- import path, { dirname, join, resolve } from "node:path";
3
+ import fs, { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
4
+ import path, { basename, dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { EventEmitter } from "node:events";
7
- import childProcess, { execFile, spawn } from "node:child_process";
7
+ import childProcess, { execFile, execFileSync, spawn } from "node:child_process";
8
8
  import process$1 from "node:process";
9
9
  import { stripVTControlCharacters } from "node:util";
10
10
  import { homedir } from "node:os";
11
- import { randomUUID } from "node:crypto";
11
+ import { createHash, randomUUID } from "node:crypto";
12
12
  import { createInterface } from "node:readline";
13
13
  //#region \0rolldown/runtime.js
14
14
  var __create = Object.create;
@@ -3004,6 +3004,14 @@ function logsDir(root) {
3004
3004
  function daemonLogPath(root) {
3005
3005
  return join(root, "logs", "daemon.log");
3006
3006
  }
3007
+ /** `<root>/versions` — parent of every installed daemon version directory. */
3008
+ function versionsDir(root) {
3009
+ return join(root, "versions");
3010
+ }
3011
+ /** `<root>/versions/<v>` — one extracted, self-contained daemon bundle. */
3012
+ function versionDir(root, version) {
3013
+ return join(versionsDir(root), version);
3014
+ }
3007
3015
  /** `<root>/current` — the symlink that names the active version. */
3008
3016
  function currentLink(root) {
3009
3017
  return join(root, "current");
@@ -3024,6 +3032,14 @@ function currentDistEntry(root) {
3024
3032
  function currentEntry(root) {
3025
3033
  return process.env.AGENTCONNECT_DAEMON_ENTRY ?? currentDistEntry(root);
3026
3034
  }
3035
+ /** `<root>/versions.json` — CLI-private metadata (channel, previous, history). */
3036
+ function versionsJsonPath(root) {
3037
+ return join(root, "versions.json");
3038
+ }
3039
+ /** `<root>/versions.lock` — inter-process writer mutex for the version store (§5.5). */
3040
+ function versionsLockPath(root) {
3041
+ return join(root, "versions.lock");
3042
+ }
3027
3043
  /**
3028
3044
  * `<root>/cli-entry` — a pointer file holding the absolute path to the CLI's own
3029
3045
  * dist entry, so a service/foreground daemon can locate the CLI to run an
@@ -8274,7 +8290,11 @@ const AgentActivate = object({
8274
8290
  crons: array(CronUpsert),
8275
8291
  /** Prove the requested workspace can be materialized before activation ACK.
8276
8292
  * Used by scratch→GitHub conversion so a failed clone can be rolled back. */
8277
- prepareWorkspace: boolean().optional()
8293
+ prepareWorkspace: boolean().optional(),
8294
+ /** Reconcile the daemon-local workspace to the authoritative mode/repo/branch.
8295
+ * The daemon preserves the checkout when that materialization is unchanged,
8296
+ * and replaces its contents when it changed. */
8297
+ reconcileWorkspace: boolean().optional()
8278
8298
  });
8279
8299
  const AgentLaunched = object({
8280
8300
  agentId: string().uuid(),
@@ -11941,7 +11961,7 @@ var require_websocket = /* @__PURE__ */ __commonJSMin(((exports, module) => {
11941
11961
  const http$1 = __require("http");
11942
11962
  const net = __require("net");
11943
11963
  const tls = __require("tls");
11944
- const { randomBytes, createHash: createHash$1 } = __require("crypto");
11964
+ const { randomBytes, createHash: createHash$2 } = __require("crypto");
11945
11965
  const { Duplex: Duplex$2, Readable } = __require("stream");
11946
11966
  const { URL: URL$1 } = __require("url");
11947
11967
  const PerMessageDeflate = require_permessage_deflate();
@@ -12609,7 +12629,7 @@ var require_websocket = /* @__PURE__ */ __commonJSMin(((exports, module) => {
12609
12629
  abortHandshake(websocket, socket, "Invalid Upgrade header");
12610
12630
  return;
12611
12631
  }
12612
- const digest = createHash$1("sha1").update(key + GUID).digest("base64");
12632
+ const digest = createHash$2("sha1").update(key + GUID).digest("base64");
12613
12633
  if (res.headers["sec-websocket-accept"] !== digest) {
12614
12634
  abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header");
12615
12635
  return;
@@ -13083,7 +13103,7 @@ var require_websocket_server = /* @__PURE__ */ __commonJSMin(((exports, module)
13083
13103
  const EventEmitter$1 = __require("events");
13084
13104
  const http = __require("http");
13085
13105
  const { Duplex } = __require("stream");
13086
- const { createHash } = __require("crypto");
13106
+ const { createHash: createHash$1 } = __require("crypto");
13087
13107
  const extension = require_extension();
13088
13108
  const PerMessageDeflate = require_permessage_deflate();
13089
13109
  const subprotocol = require_subprotocol();
@@ -13352,7 +13372,7 @@ var require_websocket_server = /* @__PURE__ */ __commonJSMin(((exports, module)
13352
13372
  "HTTP/1.1 101 Switching Protocols",
13353
13373
  "Upgrade: websocket",
13354
13374
  "Connection: Upgrade",
13355
- `Sec-WebSocket-Accept: ${createHash("sha1").update(key + GUID).digest("base64")}`
13375
+ `Sec-WebSocket-Accept: ${createHash$1("sha1").update(key + GUID).digest("base64")}`
13356
13376
  ];
13357
13377
  const ws = new this.options.WebSocket(null, void 0, this.options);
13358
13378
  if (protocols.size) {
@@ -14040,6 +14060,562 @@ async function runLogin(opts, partial = {}) {
14040
14060
  await deps.runForeground();
14041
14061
  }
14042
14062
  //#endregion
14063
+ //#region src/node-engines.ts
14064
+ /**
14065
+ * Minimal `engines.node` range check (cli-daemon-split.md §5.1 step 3) — enough
14066
+ * for the simple ranges npm packages actually use (`>=24`, `>=24.0.0`, `24 || 25`)
14067
+ * without pulling in a full semver dependency. Comparators within a group are
14068
+ * ANDed; `||` separates OR groups. Pre-release tags on the Node version are
14069
+ * ignored. An unparseable range returns `true` (accept) rather than blocking on
14070
+ * an exotic spec the caller never emits.
14071
+ */
14072
+ function parseVersion(v) {
14073
+ const m = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(v.trim());
14074
+ if (!m) return null;
14075
+ return [
14076
+ Number(m[1]),
14077
+ Number(m[2] ?? 0),
14078
+ Number(m[3] ?? 0)
14079
+ ];
14080
+ }
14081
+ function cmp(a, b) {
14082
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
14083
+ return 0;
14084
+ }
14085
+ function satisfiesComparator(comparator, version) {
14086
+ const m = /^(>=|<=|>|<|=|\^|~)?\s*(.+)$/.exec(comparator.trim());
14087
+ if (!m) return true;
14088
+ const op = m[1] ?? "=";
14089
+ const target = parseVersion(m[2]);
14090
+ if (!target) return true;
14091
+ const c = cmp(version, target);
14092
+ switch (op) {
14093
+ case ">=": return c >= 0;
14094
+ case ">": return c > 0;
14095
+ case "<=": return c <= 0;
14096
+ case "<": return c < 0;
14097
+ case "^": return version[0] === target[0] && c >= 0;
14098
+ case "~": return version[0] === target[0] && version[1] === target[1] && c >= 0;
14099
+ default: return c === 0;
14100
+ }
14101
+ }
14102
+ /** Whether `nodeVersion` satisfies the `engines.node` `range`. */
14103
+ function nodeSatisfies(range, nodeVersion) {
14104
+ if (!range || range.trim() === "" || range.trim() === "*") return true;
14105
+ const version = parseVersion(nodeVersion);
14106
+ if (!version) return true;
14107
+ return range.split("||").some((group) => {
14108
+ const comparators = group.trim().split(/\s+/).filter(Boolean);
14109
+ if (comparators.length === 0) return true;
14110
+ return comparators.every((c) => satisfiesComparator(c, version));
14111
+ });
14112
+ }
14113
+ //#endregion
14114
+ //#region src/registry.ts
14115
+ /**
14116
+ * npm registry client for daemon version resolution + download (cli-daemon-split.md
14117
+ * §5.1, §9). Security boundary: the CLI only ever fetches the ONE fixed package
14118
+ * name from its configured registry — never a CP-supplied URL/name/tarball. The
14119
+ * registry base is CLI-local config (`AGENTCONNECT_NPM_REGISTRY`), defaulting to
14120
+ * npmjs.org; it is deliberately NOT sourced from the Control Plane.
14121
+ */
14122
+ const DAEMON_PKG = "@agentconnect.md/daemon";
14123
+ function registryBase() {
14124
+ return (process.env.AGENTCONNECT_NPM_REGISTRY ?? "https://registry.npmjs.org").replace(/\/+$/, "");
14125
+ }
14126
+ async function fetchPackument(pkg) {
14127
+ const url = `${registryBase()}/${pkg.replace("/", "%2F")}`;
14128
+ const res = await fetch(url, { headers: { accept: "application/json" } });
14129
+ if (!res.ok) throw new Error(`registry lookup failed for ${pkg}: HTTP ${res.status} ${res.statusText} (${url})`);
14130
+ return await res.json();
14131
+ }
14132
+ /**
14133
+ * Resolve which daemon version to install: an explicit `--to <version>` (must
14134
+ * exist), otherwise the channel's dist-tag (stable → `latest`, rc → `rc`).
14135
+ */
14136
+ async function resolveDaemonTarget(opts) {
14137
+ const pkg = await fetchPackument(DAEMON_PKG);
14138
+ const versions = pkg.versions ?? {};
14139
+ let version;
14140
+ if (opts.to) {
14141
+ if (!versions[opts.to]) throw new Error(`${DAEMON_PKG}@${opts.to} does not exist in the registry`);
14142
+ version = opts.to;
14143
+ } else {
14144
+ const tag = opts.channel === "rc" ? "rc" : "latest";
14145
+ const tagged = pkg["dist-tags"]?.[tag];
14146
+ if (!tagged) throw new Error(`${DAEMON_PKG} has no '${tag}' dist-tag in the registry`);
14147
+ version = tagged;
14148
+ }
14149
+ const entry = versions[version];
14150
+ if (!entry?.dist?.tarball) throw new Error(`${DAEMON_PKG}@${version} has no downloadable tarball`);
14151
+ return {
14152
+ version,
14153
+ tarball: entry.dist.tarball,
14154
+ integrity: entry.dist.integrity,
14155
+ shasum: entry.dist.shasum,
14156
+ enginesNode: entry.engines?.node
14157
+ };
14158
+ }
14159
+ async function downloadTarball(url) {
14160
+ const res = await fetch(url);
14161
+ if (!res.ok) throw new Error(`tarball download failed: HTTP ${res.status} ${res.statusText} (${url})`);
14162
+ return Buffer.from(await res.arrayBuffer());
14163
+ }
14164
+ /**
14165
+ * Verify a downloaded tarball against the registry's `dist.integrity` (SSRI,
14166
+ * `sha512-<base64>`), falling back to the legacy `dist.shasum` (sha1 hex). Throws
14167
+ * on mismatch or when neither is available (never install unverified bytes).
14168
+ */
14169
+ function verifyTarball(buf, target) {
14170
+ if (target.integrity) {
14171
+ const [algo, expected] = target.integrity.split("-", 2);
14172
+ if (!algo || !expected) throw new Error(`malformed integrity string: ${target.integrity}`);
14173
+ const actual = createHash(algo).update(buf).digest("base64");
14174
+ if (actual !== expected) throw new Error(`integrity check failed (${algo}): expected ${expected}, got ${actual}`);
14175
+ return;
14176
+ }
14177
+ if (target.shasum) {
14178
+ const actual = createHash("sha1").update(buf).digest("hex");
14179
+ if (actual !== target.shasum) throw new Error(`shasum check failed: expected ${target.shasum}, got ${actual}`);
14180
+ return;
14181
+ }
14182
+ throw new Error("registry entry has neither integrity nor shasum — refusing to install unverified bytes");
14183
+ }
14184
+ //#endregion
14185
+ //#region src/version-store.ts
14186
+ /**
14187
+ * The on-disk daemon version store (cli-daemon-split.md §3/§5). Layout under
14188
+ * `<root>`:
14189
+ * versions/<v>/ — one extracted, self-contained daemon bundle
14190
+ * current -> versions/<v> — the active version (atomic symlink)
14191
+ * versions.json — CLI-private metadata (channel + rollback target)
14192
+ *
14193
+ * All MUTATING operations here must run inside the version lock (version-lock.ts);
14194
+ * the read helpers (`listInstalled`, `currentVersion`, `readMeta`) do not lock —
14195
+ * symlink/rename atomicity is enough for readers.
14196
+ */
14197
+ const DEFAULT_META = {
14198
+ channel: "stable",
14199
+ previous: null
14200
+ };
14201
+ function readMeta(root) {
14202
+ const file = versionsJsonPath(root);
14203
+ if (!existsSync(file)) return { ...DEFAULT_META };
14204
+ try {
14205
+ const raw = JSON.parse(readFileSync(file, "utf8"));
14206
+ return {
14207
+ channel: raw.channel === "rc" ? "rc" : "stable",
14208
+ previous: typeof raw.previous === "string" ? raw.previous : null
14209
+ };
14210
+ } catch {
14211
+ return { ...DEFAULT_META };
14212
+ }
14213
+ }
14214
+ /** Persist metadata atomically (tmp + rename). Call inside the version lock. */
14215
+ function writeMeta(root, meta) {
14216
+ const file = versionsJsonPath(root);
14217
+ mkdirSync(dirname(file), { recursive: true });
14218
+ const tmp = `${file}.tmp`;
14219
+ writeFileSync(tmp, JSON.stringify(meta, null, 2) + "\n");
14220
+ renameSync(tmp, file);
14221
+ }
14222
+ /** Installed version directory names, newest-mtime-irrelevant (sorted for stable output). */
14223
+ function listInstalled(root) {
14224
+ const dir = versionsDir(root);
14225
+ if (!existsSync(dir)) return [];
14226
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.endsWith(".tmp")).map((e) => e.name).sort();
14227
+ }
14228
+ /** The active version (basename of the `current` symlink target), or null. */
14229
+ function currentVersion(root) {
14230
+ const link = currentLink(root);
14231
+ try {
14232
+ return basename(readlinkSync(link));
14233
+ } catch {
14234
+ return null;
14235
+ }
14236
+ }
14237
+ function isInstalled(root, version) {
14238
+ return listInstalled(root).includes(version);
14239
+ }
14240
+ //#endregion
14241
+ //#region src/install.ts
14242
+ /**
14243
+ * Install a daemon version into the store (cli-daemon-split.md §5.1): resolve from
14244
+ * the registry → verify integrity → engines check → extract to `versions/<v>.tmp`
14245
+ * → atomic rename to `versions/<v>`. Idempotent: an already-installed version is a
14246
+ * no-op success. Must run inside the version lock (version-lock.ts).
14247
+ */
14248
+ /** Resolve the version to install/upgrade to, without downloading. */
14249
+ function resolveTarget(opts) {
14250
+ return resolveDaemonTarget(opts);
14251
+ }
14252
+ /**
14253
+ * Ensure `target.version` is installed under `versions/<v>`. Returns the version.
14254
+ * If already present, returns immediately (idempotent). `onLog` surfaces progress.
14255
+ */
14256
+ async function installTarget(root, target, onLog = () => {}) {
14257
+ if (isInstalled(root, target.version)) {
14258
+ onLog(`daemon ${target.version} already installed`);
14259
+ return target.version;
14260
+ }
14261
+ if (!nodeSatisfies(target.enginesNode, process.versions.node)) throw new Error(`daemon ${target.version} requires Node ${target.enginesNode} but this CLI runs on ${process.versions.node} — upgrade Node first`);
14262
+ onLog(`downloading daemon ${target.version}…`);
14263
+ const buf = await downloadTarball(target.tarball);
14264
+ verifyTarball(buf, target);
14265
+ const dest = versionDir(root, target.version);
14266
+ const tmp = `${dest}.tmp`;
14267
+ mkdirSync(versionsDir(root), { recursive: true });
14268
+ rmSync(tmp, {
14269
+ recursive: true,
14270
+ force: true
14271
+ });
14272
+ mkdirSync(tmp, { recursive: true });
14273
+ try {
14274
+ execFileSync("tar", [
14275
+ "-xzf",
14276
+ "-",
14277
+ "-C",
14278
+ tmp,
14279
+ "--strip-components=1"
14280
+ ], { input: buf });
14281
+ const manifest = JSON.parse(readFileSync(`${tmp}/package.json`, "utf8"));
14282
+ if (!nodeSatisfies(manifest.engines?.node, process.versions.node)) throw new Error(`daemon ${target.version} requires Node ${manifest.engines?.node} but this CLI runs on ${process.versions.node}`);
14283
+ if (!existsSync(`${tmp}/dist/index.js`)) throw new Error(`daemon ${target.version} tarball has no dist/index.js — refusing to install`);
14284
+ if (existsSync(dest)) rmSync(tmp, {
14285
+ recursive: true,
14286
+ force: true
14287
+ });
14288
+ else renameSync(tmp, dest);
14289
+ } catch (err) {
14290
+ rmSync(tmp, {
14291
+ recursive: true,
14292
+ force: true
14293
+ });
14294
+ throw err;
14295
+ }
14296
+ onLog(`installed daemon ${target.version}`);
14297
+ return target.version;
14298
+ }
14299
+ //#endregion
14300
+ //#region src/health.ts
14301
+ const defaultDelay = (ms) => new Promise((r) => setTimeout(r, ms));
14302
+ /**
14303
+ * Sample the service status a few times over `settleMs`; healthy iff it is running
14304
+ * with a single stable PID throughout. Injectable `status`/`delay` keep it testable.
14305
+ */
14306
+ async function checkServiceHealthy(status, opts = {}) {
14307
+ const samples = Math.max(2, opts.samples ?? 3);
14308
+ const settleMs = opts.settleMs ?? 6e3;
14309
+ const delay = opts.delay ?? defaultDelay;
14310
+ const gap = Math.max(0, Math.floor(settleMs / samples));
14311
+ let firstPid;
14312
+ for (let i = 0; i < samples; i++) {
14313
+ if (i > 0) await delay(gap);
14314
+ const s = await status();
14315
+ if (!s.running) return {
14316
+ healthy: false,
14317
+ reason: `service not running (${s.label})`
14318
+ };
14319
+ if (!s.pid) return {
14320
+ healthy: false,
14321
+ reason: "service running but reported no pid"
14322
+ };
14323
+ if (firstPid === void 0) firstPid = s.pid;
14324
+ else if (s.pid !== firstPid) return {
14325
+ healthy: false,
14326
+ reason: `pid changed ${firstPid} → ${s.pid} (crash-looping)`
14327
+ };
14328
+ }
14329
+ return {
14330
+ healthy: true,
14331
+ reason: `stable pid ${firstPid}`
14332
+ };
14333
+ }
14334
+ //#endregion
14335
+ //#region src/version-ops.ts
14336
+ /**
14337
+ * Activation + cleanup for the version store (cli-daemon-split.md §5). Both MUTATE
14338
+ * and must run inside the version lock (version-lock.ts).
14339
+ */
14340
+ /**
14341
+ * Atomically point `current` at an installed version (symlink + rename). Records
14342
+ * the version it replaced as `previous` (the rollback target). No-op if already
14343
+ * current. §5 / §5.4.
14344
+ */
14345
+ function useVersion(root, version) {
14346
+ if (!isInstalled(root, version)) throw new Error(`daemon ${version} is not installed — run \`agentconnect version install ${version}\` first`);
14347
+ const prev = currentVersion(root);
14348
+ if (prev === version) return;
14349
+ const link = currentLink(root);
14350
+ const tmp = `${link}.tmp`;
14351
+ rmSync(tmp, { force: true });
14352
+ symlinkSync(`versions/${version}`, tmp);
14353
+ renameSync(tmp, link);
14354
+ if (prev && prev !== version) writeMeta(root, {
14355
+ ...readMeta(root),
14356
+ previous: prev
14357
+ });
14358
+ }
14359
+ /**
14360
+ * Remove old installed versions, keeping the newest `keep` prunable ones. `current`
14361
+ * and `previous` (the rollback target) are NEVER removed. §5.4.
14362
+ */
14363
+ function pruneVersions(root, keep = 2) {
14364
+ const meta = readMeta(root);
14365
+ const cur = currentVersion(root);
14366
+ const protectedSet = new Set([cur, meta.previous].filter((v) => Boolean(v)));
14367
+ const toRemove = listInstalled(root).filter((v) => !protectedSet.has(v)).map((v) => ({
14368
+ v,
14369
+ mtime: statSync(versionDir(root, v)).mtimeMs
14370
+ })).sort((a, b) => b.mtime - a.mtime).slice(keep).map((x) => x.v);
14371
+ for (const v of toRemove) rmSync(versionDir(root, v), {
14372
+ recursive: true,
14373
+ force: true
14374
+ });
14375
+ return toRemove;
14376
+ }
14377
+ //#endregion
14378
+ //#region src/upgrade.ts
14379
+ /**
14380
+ * `agentconnect upgrade` orchestration (cli-daemon-split.md §5.2): install the
14381
+ * target → flip `current` → optionally restart → health-check → auto-rollback on
14382
+ * failure. The caller wraps this in the version lock; the steps here assume it is
14383
+ * held. Dependencies are injected so the control flow (esp. rollback) is testable
14384
+ * without a registry or a real service.
14385
+ */
14386
+ function realUpgradeDeps(root, log) {
14387
+ return {
14388
+ resolve: resolveTarget,
14389
+ install: installTarget,
14390
+ serviceInstalled: () => resolveController({ root }).isInstalled(),
14391
+ restartService: async () => {
14392
+ const c = resolveController({ root });
14393
+ await c.down();
14394
+ await c.up();
14395
+ },
14396
+ health: () => checkServiceHealthy(() => resolveController({ root }).status()),
14397
+ log
14398
+ };
14399
+ }
14400
+ async function upgrade(root, opts, deps) {
14401
+ const meta = readMeta(root);
14402
+ const channel = opts.channel ?? meta.channel;
14403
+ const target = await deps.resolve({
14404
+ to: opts.to,
14405
+ channel
14406
+ });
14407
+ await deps.install(root, target, deps.log);
14408
+ const before = currentVersion(root);
14409
+ if (before === target.version && !opts.restart) {
14410
+ deps.log(`already on ${target.version}`);
14411
+ return;
14412
+ }
14413
+ useVersion(root, target.version);
14414
+ if (opts.channel && opts.channel !== meta.channel) writeMeta(root, {
14415
+ ...readMeta(root),
14416
+ channel: opts.channel
14417
+ });
14418
+ deps.log(`current → ${target.version}`);
14419
+ if (!opts.restart) {
14420
+ deps.log("not restarting (pass --restart to apply now); the new version takes effect on the next daemon restart");
14421
+ return;
14422
+ }
14423
+ if (!deps.serviceInstalled()) {
14424
+ deps.log("no OS service installed — current switched, but nothing to restart (foreground run applies it on relaunch)");
14425
+ return;
14426
+ }
14427
+ await deps.restartService();
14428
+ const h = await deps.health();
14429
+ if (h.healthy) {
14430
+ deps.log(`upgraded to ${target.version} — healthy (${h.reason})`);
14431
+ return;
14432
+ }
14433
+ deps.log(`health check failed (${h.reason}) — rolling back`);
14434
+ if (!before) throw new Error(`upgrade to ${target.version} failed its health check and there is no previous version to roll back to`);
14435
+ useVersion(root, before);
14436
+ await deps.restartService();
14437
+ throw new Error(`upgrade to ${target.version} failed its health check — rolled back to ${before}`);
14438
+ }
14439
+ //#endregion
14440
+ //#region src/version-lock.ts
14441
+ /**
14442
+ * Root-scoped inter-process writer lock for the version store (cli-daemon-split.md
14443
+ * §5.5). Every MUTATING version command (install / use / prune / upgrade), whether
14444
+ * a local invocation or the CLI a daemon spawns for a remote upgrade, holds this
14445
+ * lock across its whole transaction so they can't corrupt `current` /
14446
+ * `versions.json` by interleaving.
14447
+ *
14448
+ * Distinct from the daemon singleton lock (packages/daemon/src/lock.ts): that one
14449
+ * is held by a running daemon (and is held during a remote upgrade), whereas this
14450
+ * guards the version store — different resources, different locks.
14451
+ *
14452
+ * Stale recovery: holder LIVENESS is the only preemption criterion — never lock
14453
+ * age. `kill(pid, 0)` → ESRCH means dead; any other errno means alive. A recorded
14454
+ * process start time guards against PID reuse (a live PID whose start time no
14455
+ * longer matches is a different process, so the lock is stale).
14456
+ */
14457
+ /** Best-effort process start time, to distinguish a reused PID. Empty on failure. */
14458
+ function procStartTime(pid) {
14459
+ try {
14460
+ return execFileSync("ps", [
14461
+ "-o",
14462
+ "lstart=",
14463
+ "-p",
14464
+ String(pid)
14465
+ ], { encoding: "utf8" }).trim();
14466
+ } catch {
14467
+ return "";
14468
+ }
14469
+ }
14470
+ /** True if the PID is a live process. ESRCH ⇒ dead; EPERM/other ⇒ alive. */
14471
+ function isAlive(pid) {
14472
+ try {
14473
+ process.kill(pid, 0);
14474
+ return true;
14475
+ } catch (err) {
14476
+ return err.code !== "ESRCH";
14477
+ }
14478
+ }
14479
+ /** A held lock is stale (safe to preempt) only if its holder process is gone or
14480
+ * its PID has been reused by a different process. */
14481
+ function isStale(holder) {
14482
+ if (!isAlive(holder.pid)) return true;
14483
+ const now = procStartTime(holder.pid);
14484
+ if (holder.procStart && now && holder.procStart !== now) return true;
14485
+ return false;
14486
+ }
14487
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
14488
+ const POLL_MS = 500;
14489
+ const DEFAULT_WAIT_MS = 15 * 6e4;
14490
+ const STALE_WARN_MS = 30 * 6e4;
14491
+ function tryClaim(path, op) {
14492
+ let fd;
14493
+ try {
14494
+ fd = openSync(path, "wx");
14495
+ } catch (err) {
14496
+ if (err.code === "EEXIST") return false;
14497
+ throw err;
14498
+ }
14499
+ const content = {
14500
+ pid: process.pid,
14501
+ op,
14502
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
14503
+ procStart: procStartTime(process.pid)
14504
+ };
14505
+ try {
14506
+ writeSync(fd, JSON.stringify(content));
14507
+ } finally {
14508
+ closeSync(fd);
14509
+ }
14510
+ return true;
14511
+ }
14512
+ function readHolder(path) {
14513
+ try {
14514
+ const raw = JSON.parse(readFileSync(path, "utf8"));
14515
+ if (typeof raw.pid !== "number") return null;
14516
+ return {
14517
+ pid: raw.pid,
14518
+ op: typeof raw.op === "string" ? raw.op : "unknown",
14519
+ startedAt: typeof raw.startedAt === "string" ? raw.startedAt : "",
14520
+ procStart: typeof raw.procStart === "string" ? raw.procStart : ""
14521
+ };
14522
+ } catch {
14523
+ return null;
14524
+ }
14525
+ }
14526
+ /** Atomically move a stale lock aside so a fresh claim can be made. Racing
14527
+ * preemptors are fine: the rename or the subsequent O_EXCL claim decides one winner. */
14528
+ function preempt(path) {
14529
+ try {
14530
+ renameSync(path, `${path}.stale`);
14531
+ } catch {}
14532
+ }
14533
+ /**
14534
+ * Run `fn` while holding the version-store lock. `wait: true` blocks (polling)
14535
+ * until the lock frees or `waitMs` elapses; `wait: false` fails fast if a live
14536
+ * process holds it. The lock is always released if we own it.
14537
+ */
14538
+ async function withVersionLock(root, op, fn, opts = {}) {
14539
+ const path = versionsLockPath(root);
14540
+ const wait = opts.wait ?? false;
14541
+ const deadline = Date.now() + (opts.waitMs ?? DEFAULT_WAIT_MS);
14542
+ let warned = false;
14543
+ for (;;) {
14544
+ if (tryClaim(path, op)) break;
14545
+ const holder = readHolder(path);
14546
+ if (!holder || isStale(holder)) {
14547
+ preempt(path);
14548
+ continue;
14549
+ }
14550
+ if (!wait) throw new Error(`another version operation is in progress (pid ${holder.pid}, op ${holder.op}, since ${holder.startedAt}) — retry once it finishes`);
14551
+ if (!warned && Date.now() - new Date(holder.startedAt).getTime() > STALE_WARN_MS) {
14552
+ console.error(`agentconnect: waiting on a long-running version lock (pid ${holder.pid}, op ${holder.op})`);
14553
+ warned = true;
14554
+ }
14555
+ if (Date.now() > deadline) throw new Error(`timed out waiting for the version lock held by pid ${holder.pid} (op ${holder.op})`);
14556
+ await delay(POLL_MS);
14557
+ }
14558
+ try {
14559
+ return await fn();
14560
+ } finally {
14561
+ if (readHolder(path)?.pid === process.pid) try {
14562
+ unlinkSync(path);
14563
+ } catch {}
14564
+ }
14565
+ }
14566
+ //#endregion
14567
+ //#region src/version-commands.ts
14568
+ /**
14569
+ * Command handlers for `agentconnect version …` + `upgrade` (cli-daemon-split.md
14570
+ * §5). Every mutating handler runs its whole transaction inside the version lock
14571
+ * (§5.5); `list` reads without locking. These are thin over the version-store /
14572
+ * install / upgrade modules and are what index.ts wires to commander.
14573
+ */
14574
+ const note = (m) => console.log(m);
14575
+ function versionList(root) {
14576
+ const meta = readMeta(root);
14577
+ const installed = listInstalled(root);
14578
+ const cur = currentVersion(root);
14579
+ console.log(`channel: ${meta.channel}`);
14580
+ if (installed.length === 0) {
14581
+ console.log("installed: none — run `agentconnect version install --channel stable`");
14582
+ return;
14583
+ }
14584
+ console.log("installed:");
14585
+ for (const v of installed) {
14586
+ const tags = [v === cur ? "current" : "", v === meta.previous ? "previous" : ""].filter(Boolean).join(", ");
14587
+ console.log(` ${v}${tags ? ` (${tags})` : ""}`);
14588
+ }
14589
+ }
14590
+ async function versionInstall(root, opts) {
14591
+ await withVersionLock(root, "install", async () => {
14592
+ const channel = opts.channel ?? readMeta(root).channel;
14593
+ await installTarget(root, await resolveTarget({
14594
+ to: opts.to,
14595
+ channel
14596
+ }), note);
14597
+ if (opts.channel) writeMeta(root, {
14598
+ ...readMeta(root),
14599
+ channel: opts.channel
14600
+ });
14601
+ });
14602
+ }
14603
+ async function versionUse(root, version) {
14604
+ await withVersionLock(root, "use", () => {
14605
+ useVersion(root, version);
14606
+ note(`current → ${version}`);
14607
+ });
14608
+ }
14609
+ async function versionPrune(root, keep) {
14610
+ await withVersionLock(root, "prune", () => {
14611
+ const removed = pruneVersions(root, keep);
14612
+ note(removed.length ? `pruned ${removed.length}: ${removed.join(", ")}` : "nothing to prune");
14613
+ }, { wait: true });
14614
+ }
14615
+ async function runUpgrade(root, opts) {
14616
+ await withVersionLock(root, "upgrade", () => upgrade(root, opts, realUpgradeDeps(root, note)), { wait: true });
14617
+ }
14618
+ //#endregion
14043
14619
  //#region src/index.ts
14044
14620
  /** Light scan for the global `--root` flag before commander parses — needed by
14045
14621
  * the delegation/run paths (which never build a commander program) and by the
@@ -14166,16 +14742,45 @@ async function main() {
14166
14742
  fail("login", err);
14167
14743
  }
14168
14744
  });
14169
- const notYet = (name) => {
14170
- console.error(`agentconnect ${name}: daemon version management arrives in P2 (not yet implemented)`);
14171
- return process.exit(1);
14172
- };
14173
- const version = program.command("version").description("Manage installed daemon versions (P2)");
14174
- version.command("list").description("List installed daemon versions (P2)").action(() => notYet("version list"));
14175
- version.command("install <version>").description("Download and unpack a daemon version (P2)").action(() => notYet("version install"));
14176
- version.command("use <version>").description("Activate an installed daemon version (P2)").action(() => notYet("version use"));
14177
- version.command("prune").description("Remove old daemon versions (P2)").action(() => notYet("version prune"));
14178
- program.command("upgrade").description("Install the latest daemon version and restart (P2)").action(() => notYet("upgrade"));
14745
+ const asChannel = (c) => c === "stable" || c === "rc" ? c : c === void 0 ? void 0 : fail("version", /* @__PURE__ */ new Error(`unknown channel '${c}' (use stable|rc)`));
14746
+ const version = program.command("version").description("Manage installed daemon versions");
14747
+ version.action(() => versionList(root));
14748
+ version.command("list").description("List installed daemon versions and the active one").action(() => versionList(root));
14749
+ version.command("install [version]").description("Download and unpack a daemon version (defaults to the channel dist-tag)").option("--channel <channel>", "stable|rc (default: the stored channel)").action(async (v, o) => {
14750
+ try {
14751
+ await versionInstall(root, {
14752
+ to: v,
14753
+ channel: asChannel(o.channel)
14754
+ });
14755
+ } catch (err) {
14756
+ fail("version install", err);
14757
+ }
14758
+ });
14759
+ version.command("use <version>").description("Activate an installed daemon version (does not restart)").action(async (v) => {
14760
+ try {
14761
+ await versionUse(root, v);
14762
+ } catch (err) {
14763
+ fail("version use", err);
14764
+ }
14765
+ });
14766
+ version.command("prune").description("Remove old daemon versions, keeping the newest N (current/previous always kept)").option("--keep <n>", "how many prunable versions to keep", "2").action(async (o) => {
14767
+ try {
14768
+ await versionPrune(root, Math.max(0, Number(o.keep) || 0));
14769
+ } catch (err) {
14770
+ fail("version prune", err);
14771
+ }
14772
+ });
14773
+ program.command("upgrade").description("Install the latest daemon version, switch to it, and (with --restart) restart + health-check").option("--to <version>", "upgrade to a specific version instead of the channel latest").option("--channel <channel>", "stable|rc (default: the stored channel)").option("--restart", "restart the service now and roll back if it fails its health check").action(async (o) => {
14774
+ try {
14775
+ await runUpgrade(root, {
14776
+ to: o.to,
14777
+ channel: asChannel(o.channel),
14778
+ restart: Boolean(o.restart)
14779
+ });
14780
+ } catch (err) {
14781
+ fail("upgrade", err);
14782
+ }
14783
+ });
14179
14784
  await program.parseAsync();
14180
14785
  }
14181
14786
  try {