@agentconnect.md/cli 0.0.0 → 1.13.0-rc.11
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/LICENSE +201 -0
- package/dist/index.js +624 -19
- package/dist/index.js.map +1 -1
- package/package.json +10 -11
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$
|
|
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$
|
|
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
|
|
14170
|
-
|
|
14171
|
-
|
|
14172
|
-
|
|
14173
|
-
|
|
14174
|
-
|
|
14175
|
-
|
|
14176
|
-
|
|
14177
|
-
|
|
14178
|
-
|
|
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 {
|