@kybernesis/create 0.15.6 → 0.15.8
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/doctor.js +52 -0
- package/dist/env-urls.d.ts +17 -0
- package/dist/env-urls.js +79 -0
- package/dist/on-line-version.d.ts +51 -0
- package/dist/on-line-version.js +87 -0
- package/dist/stale-sidecar.d.ts +51 -0
- package/dist/stale-sidecar.js +56 -0
- package/dist/upgrade.js +53 -3
- package/package.json +3 -2
package/dist/doctor.js
CHANGED
|
@@ -7,7 +7,9 @@ import { diagnoseManageRestart, findMatchingAgentServiceUnit } from "./systemd.j
|
|
|
7
7
|
import { inspectEveAgent } from "./agent-limits.js";
|
|
8
8
|
import { classifyModelReach } from "./model-reach.js";
|
|
9
9
|
import { countAbandonedRuns } from "./run-store.js";
|
|
10
|
+
import { describeStaleSidecar, newestPackageChange, staleSidecars } from "./stale-sidecar.js";
|
|
10
11
|
import { checkSelfVersion } from "./self-version.js";
|
|
12
|
+
import { envUrlProblems } from "./env-urls.js";
|
|
11
13
|
const MARK = {
|
|
12
14
|
pass: green("✓"),
|
|
13
15
|
warn: yellow("!"),
|
|
@@ -117,6 +119,17 @@ async function head(url, headers) {
|
|
|
117
119
|
return null;
|
|
118
120
|
}
|
|
119
121
|
}
|
|
122
|
+
/** Running units that belong to a Kybernesis agent on this host. */
|
|
123
|
+
function listAgentUnits() {
|
|
124
|
+
const out = capture("systemctl", ["list-units", "--type=service", "--state=running", "--no-legend", "--plain"]);
|
|
125
|
+
if (!out)
|
|
126
|
+
return [];
|
|
127
|
+
return out
|
|
128
|
+
.split("\n")
|
|
129
|
+
.map((line) => line.trim().split(/\s+/)[0] ?? "")
|
|
130
|
+
.filter((unit) => /-(agent|buzz-bridge)\.service$/.test(unit))
|
|
131
|
+
.map((unit) => unit.replace(/\.service$/, ""));
|
|
132
|
+
}
|
|
120
133
|
export async function doctor() {
|
|
121
134
|
const cwd = process.cwd();
|
|
122
135
|
const installedAgentService = findMatchingAgentServiceUnit(cwd);
|
|
@@ -132,6 +145,29 @@ export async function doctor() {
|
|
|
132
145
|
* them. Reported rather than fixed: the remedy restarts the agent, which is
|
|
133
146
|
* not something a diagnostic should do behind someone's back.
|
|
134
147
|
*/
|
|
148
|
+
/**
|
|
149
|
+
* Services still running the code they loaded at start.
|
|
150
|
+
*
|
|
151
|
+
* An agent host has more than one: deploy.sh restarts the agent, and the Buzz
|
|
152
|
+
* bridge is a separate unit it never touches. Both bridges on the reference
|
|
153
|
+
* fleet ran four days on a superseded package while the fix sat on disk, so
|
|
154
|
+
* two changes signed off as deployed were live nowhere. Nothing looked
|
|
155
|
+
* broken — the old code simply kept doing what it did.
|
|
156
|
+
*/
|
|
157
|
+
const packageChange = newestPackageChange(cwd);
|
|
158
|
+
const started = new Map();
|
|
159
|
+
for (const unit of listAgentUnits()) {
|
|
160
|
+
const stamp = capture("systemctl", ["show", "-p", "ActiveEnterTimestamp", "--value", unit])?.trim();
|
|
161
|
+
const at = stamp ? new Date(stamp) : null;
|
|
162
|
+
if (at && !Number.isNaN(at.getTime()))
|
|
163
|
+
started.set(unit, at);
|
|
164
|
+
}
|
|
165
|
+
for (const stale of staleSidecars({ started, packageChange })) {
|
|
166
|
+
add("fail", `${stale.service} is running superseded code`, describeStaleSidecar(stale));
|
|
167
|
+
}
|
|
168
|
+
if (started.size > 0 && staleSidecars({ started, packageChange }).length === 0) {
|
|
169
|
+
add("pass", `every service is running the code currently installed (${started.size} checked)`);
|
|
170
|
+
}
|
|
135
171
|
const runs = countAbandonedRuns(cwd);
|
|
136
172
|
if (runs) {
|
|
137
173
|
if (runs.abandoned === 0) {
|
|
@@ -180,6 +216,22 @@ export async function doctor() {
|
|
|
180
216
|
};
|
|
181
217
|
if (!existsSync(envPath))
|
|
182
218
|
add("warn", ".env.local missing", "copy .env.example and fill it");
|
|
219
|
+
/**
|
|
220
|
+
* Config that is shaped wrong rather than missing.
|
|
221
|
+
*
|
|
222
|
+
* A URL-valued setting with no scheme does not fail at start; it fails inside
|
|
223
|
+
* whatever background drain uses it, once per turn, forever. The reference
|
|
224
|
+
* fleet logged 121 of these and lost every analytics event of a deployment
|
|
225
|
+
* while reporting healthy throughout. The value alone is enough to know, so
|
|
226
|
+
* this costs nothing and lands before a client's first turn.
|
|
227
|
+
*/
|
|
228
|
+
const urlProblems = existsSync(envPath) ? envUrlProblems(readFileSync(envPath, "utf8")) : [];
|
|
229
|
+
for (const problem of urlProblems) {
|
|
230
|
+
add("fail", `${problem.name} cannot build a request (${problem.reason})`, problem.fix);
|
|
231
|
+
}
|
|
232
|
+
if (existsSync(envPath) && urlProblems.length === 0) {
|
|
233
|
+
add("pass", "every URL-valued setting is an absolute http(s) URL");
|
|
234
|
+
}
|
|
183
235
|
const githubCheck = githubToolsDoctorCheck(existsSync(join(cwd, "agent/extensions/github.ts")), env);
|
|
184
236
|
if (githubCheck)
|
|
185
237
|
add(githubCheck.verdict, githubCheck.label, githubCheck.detail);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Does this name hold a whole URL? */
|
|
2
|
+
export declare function expectsUrl(name: string): boolean;
|
|
3
|
+
export type EnvUrlProblem = {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
/** Why it cannot be used to build a request. Never includes the value. */
|
|
6
|
+
readonly reason: "no scheme" | "not a url" | "not http(s)";
|
|
7
|
+
readonly fix: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Check one value. Returns undefined when it is fine.
|
|
11
|
+
*
|
|
12
|
+
* The value never appears in the result: these files sit beside real secrets
|
|
13
|
+
* and a diagnostic that echoes them turns a config warning into a leak.
|
|
14
|
+
*/
|
|
15
|
+
export declare function checkEnvUrl(name: string, value: string): EnvUrlProblem | undefined;
|
|
16
|
+
/** Every URL-shaped value in this env text that cannot build a request. */
|
|
17
|
+
export declare function envUrlProblems(text: string): EnvUrlProblem[];
|
package/dist/env-urls.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { parseEnv } from "./util.js";
|
|
2
|
+
/**
|
|
3
|
+
* Environment values that are used to build a URL, and whether they can be.
|
|
4
|
+
*
|
|
5
|
+
* An SDK handed `POSTHOG_HOST=eu.i.posthog.com` does not complain at start. It
|
|
6
|
+
* fails once per turn, forever, inside a background drain: `Failed to parse URL
|
|
7
|
+
* from eu.i.posthog.com/batch/`. On the reference fleet that was 121 logged
|
|
8
|
+
* failures and every analytics event of the deployment silently discarded,
|
|
9
|
+
* while the agent itself looked perfectly healthy. Nothing surfaces it because
|
|
10
|
+
* nothing that matters to the turn depends on it.
|
|
11
|
+
*
|
|
12
|
+
* Hence checking the SHAPE of the value at rest, which costs nothing and finds
|
|
13
|
+
* it before a client's first turn.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Names whose value is unambiguously a whole URL.
|
|
17
|
+
*
|
|
18
|
+
* `_HOST` is deliberately NOT here: plenty of them legitimately hold a bare
|
|
19
|
+
* hostname (`SMTP_HOST`, `PGHOST`, `REDIS_HOST`), and flagging those would make
|
|
20
|
+
* doctor cry wolf on a correct config.
|
|
21
|
+
*/
|
|
22
|
+
const URL_SUFFIXES = ["_URL", "_ENDPOINT", "_ISSUER"];
|
|
23
|
+
/**
|
|
24
|
+
* `_HOST` names that specific SDKs use as a base URL, so they do need a scheme.
|
|
25
|
+
* An allowlist rather than a rule, because only the SDK knows which it is.
|
|
26
|
+
*/
|
|
27
|
+
const BASE_URL_HOSTS = new Set(["POSTHOG_HOST"]);
|
|
28
|
+
/** Does this name hold a whole URL? */
|
|
29
|
+
export function expectsUrl(name) {
|
|
30
|
+
if (BASE_URL_HOSTS.has(name))
|
|
31
|
+
return true;
|
|
32
|
+
return URL_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Check one value. Returns undefined when it is fine.
|
|
36
|
+
*
|
|
37
|
+
* The value never appears in the result: these files sit beside real secrets
|
|
38
|
+
* and a diagnostic that echoes them turns a config warning into a leak.
|
|
39
|
+
*/
|
|
40
|
+
export function checkEnvUrl(name, value) {
|
|
41
|
+
const trimmed = value.trim().replace(/^["']|["']$/g, "");
|
|
42
|
+
if (trimmed === "")
|
|
43
|
+
return undefined;
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = new URL(trimmed);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// A bare host is by far the common case, and the remedy differs from junk.
|
|
50
|
+
const bare = /^[a-z0-9.-]+(:\d+)?(\/.*)?$/i.test(trimmed);
|
|
51
|
+
return {
|
|
52
|
+
name,
|
|
53
|
+
reason: bare ? "no scheme" : "not a url",
|
|
54
|
+
fix: bare
|
|
55
|
+
? `${name} has no scheme, so anything built from it fails to parse. Prefix it with https://`
|
|
56
|
+
: `${name} is not a URL. Set it to an absolute https:// URL.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
60
|
+
return {
|
|
61
|
+
name,
|
|
62
|
+
reason: "not http(s)",
|
|
63
|
+
fix: `${name} is a ${parsed.protocol.replace(":", "")} URL. Set it to an absolute https:// URL.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
/** Every URL-shaped value in this env text that cannot build a request. */
|
|
69
|
+
export function envUrlProblems(text) {
|
|
70
|
+
const problems = [];
|
|
71
|
+
for (const [name, value] of Object.entries(parseEnv(text))) {
|
|
72
|
+
if (!expectsUrl(name))
|
|
73
|
+
continue;
|
|
74
|
+
const problem = checkEnvUrl(name, value);
|
|
75
|
+
if (problem)
|
|
76
|
+
problems.push(problem);
|
|
77
|
+
}
|
|
78
|
+
return problems;
|
|
79
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picking the right build of a per-eve-line package.
|
|
3
|
+
*
|
|
4
|
+
* `@kybernesis/*` packages ship one build per eve line, and the version numbers
|
|
5
|
+
* are NOT ordered by line: `@kybernesis/voice` 0.1.2 is the eve 0.49 build and
|
|
6
|
+
* was published AFTER 0.1.1, which is the 0.51 build. So `npm view <pkg>
|
|
7
|
+
* version` — npm's `latest` — is routinely the wrong package for a given host,
|
|
8
|
+
* and installing it puts an agent on a build compiled against an eve API it is
|
|
9
|
+
* not running.
|
|
10
|
+
*
|
|
11
|
+
* That failure is silent. The package installs, the agent builds, the service
|
|
12
|
+
* starts, and one code path breaks later at runtime. It is how
|
|
13
|
+
* `@kybernesis/notify` took an agent down and how two agents ran a 0.51 build of
|
|
14
|
+
* `@kybernesis/manage` on eve 0.49 for days without anyone noticing.
|
|
15
|
+
*
|
|
16
|
+
* So an upgrade must never ask for "latest". It must ask for the newest version
|
|
17
|
+
* whose declared eve peer range contains the eve this host actually runs.
|
|
18
|
+
*/
|
|
19
|
+
/** A published version and the eve range it declares. */
|
|
20
|
+
export interface Candidate {
|
|
21
|
+
version: string;
|
|
22
|
+
/** `peerDependencies.eve`, or undefined when the package declares none. */
|
|
23
|
+
peer?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Compare semver-ish versions numerically.
|
|
27
|
+
*
|
|
28
|
+
* Numerically, because these are picked by sorting and string order puts 0.8.9
|
|
29
|
+
* above 0.8.10 — which would hand a host the wrong build. A prerelease sorts
|
|
30
|
+
* BELOW its release (0.1.2-rc < 0.1.2), so a release candidate is never chosen
|
|
31
|
+
* over the release it precedes.
|
|
32
|
+
*/
|
|
33
|
+
export declare function versionCompare(a: string, b: string): number;
|
|
34
|
+
/**
|
|
35
|
+
* Whether an eve version falls inside a declared peer range.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately narrow: it understands the two forms these packages actually
|
|
38
|
+
* publish — `>=X <Y` and a caret — and returns false for anything it does not
|
|
39
|
+
* recognise. Guessing at a range it cannot parse is how a wrong build gets
|
|
40
|
+
* installed, and "I do not know" must not read as "yes".
|
|
41
|
+
*/
|
|
42
|
+
export declare function rangeContains(range: string | undefined, eve: string): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The newest published build that supports this host's eve.
|
|
45
|
+
*
|
|
46
|
+
* Returns undefined when no published version declares support — which is a
|
|
47
|
+
* real answer and must be surfaced, not smoothed over by falling back to
|
|
48
|
+
* latest. `@kybernesis/voice` had NO eve 0.49 build for weeks while two agents
|
|
49
|
+
* on 0.49 ran its 0.51 one.
|
|
50
|
+
*/
|
|
51
|
+
export declare function newestOnLine(candidates: readonly Candidate[], eve: string): string | undefined;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picking the right build of a per-eve-line package.
|
|
3
|
+
*
|
|
4
|
+
* `@kybernesis/*` packages ship one build per eve line, and the version numbers
|
|
5
|
+
* are NOT ordered by line: `@kybernesis/voice` 0.1.2 is the eve 0.49 build and
|
|
6
|
+
* was published AFTER 0.1.1, which is the 0.51 build. So `npm view <pkg>
|
|
7
|
+
* version` — npm's `latest` — is routinely the wrong package for a given host,
|
|
8
|
+
* and installing it puts an agent on a build compiled against an eve API it is
|
|
9
|
+
* not running.
|
|
10
|
+
*
|
|
11
|
+
* That failure is silent. The package installs, the agent builds, the service
|
|
12
|
+
* starts, and one code path breaks later at runtime. It is how
|
|
13
|
+
* `@kybernesis/notify` took an agent down and how two agents ran a 0.51 build of
|
|
14
|
+
* `@kybernesis/manage` on eve 0.49 for days without anyone noticing.
|
|
15
|
+
*
|
|
16
|
+
* So an upgrade must never ask for "latest". It must ask for the newest version
|
|
17
|
+
* whose declared eve peer range contains the eve this host actually runs.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Compare semver-ish versions numerically.
|
|
21
|
+
*
|
|
22
|
+
* Numerically, because these are picked by sorting and string order puts 0.8.9
|
|
23
|
+
* above 0.8.10 — which would hand a host the wrong build. A prerelease sorts
|
|
24
|
+
* BELOW its release (0.1.2-rc < 0.1.2), so a release candidate is never chosen
|
|
25
|
+
* over the release it precedes.
|
|
26
|
+
*/
|
|
27
|
+
export function versionCompare(a, b) {
|
|
28
|
+
const split = (v) => {
|
|
29
|
+
const dash = v.indexOf("-");
|
|
30
|
+
const core = (dash === -1 ? v : v.slice(0, dash)).split(".").map((p) => Number(p) || 0);
|
|
31
|
+
return { core, pre: dash !== -1 };
|
|
32
|
+
};
|
|
33
|
+
const pa = split(a);
|
|
34
|
+
const pb = split(b);
|
|
35
|
+
for (let i = 0; i < Math.max(pa.core.length, pb.core.length); i += 1) {
|
|
36
|
+
const x = pa.core[i] ?? 0;
|
|
37
|
+
const y = pb.core[i] ?? 0;
|
|
38
|
+
if (x !== y)
|
|
39
|
+
return x < y ? -1 : 1;
|
|
40
|
+
}
|
|
41
|
+
if (pa.pre === pb.pre)
|
|
42
|
+
return 0;
|
|
43
|
+
return pa.pre ? -1 : 1;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether an eve version falls inside a declared peer range.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately narrow: it understands the two forms these packages actually
|
|
49
|
+
* publish — `>=X <Y` and a caret — and returns false for anything it does not
|
|
50
|
+
* recognise. Guessing at a range it cannot parse is how a wrong build gets
|
|
51
|
+
* installed, and "I do not know" must not read as "yes".
|
|
52
|
+
*/
|
|
53
|
+
export function rangeContains(range, eve) {
|
|
54
|
+
if (!range)
|
|
55
|
+
return false;
|
|
56
|
+
const bounded = /^>=\s*([0-9][^\s]*)\s*<\s*([0-9][^\s]*)$/.exec(range.trim());
|
|
57
|
+
if (bounded) {
|
|
58
|
+
return versionCompare(eve, bounded[1]) >= 0 && versionCompare(eve, bounded[2]) < 0;
|
|
59
|
+
}
|
|
60
|
+
const caret = /^\^([0-9][^\s]*)$/.exec(range.trim());
|
|
61
|
+
if (caret) {
|
|
62
|
+
const base = caret[1].split(".");
|
|
63
|
+
const major = Number(base[0] ?? 0);
|
|
64
|
+
const minor = Number(base[1] ?? 0);
|
|
65
|
+
// ^0.x is minor-locked, as npm treats it.
|
|
66
|
+
const upper = major === 0 ? `0.${minor + 1}.0` : `${major + 1}.0.0`;
|
|
67
|
+
return versionCompare(eve, caret[1]) >= 0 && versionCompare(eve, upper) < 0;
|
|
68
|
+
}
|
|
69
|
+
const atLeast = /^>=\s*([0-9][^\s]*)$/.exec(range.trim());
|
|
70
|
+
if (atLeast)
|
|
71
|
+
return versionCompare(eve, atLeast[1]) >= 0;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The newest published build that supports this host's eve.
|
|
76
|
+
*
|
|
77
|
+
* Returns undefined when no published version declares support — which is a
|
|
78
|
+
* real answer and must be surfaced, not smoothed over by falling back to
|
|
79
|
+
* latest. `@kybernesis/voice` had NO eve 0.49 build for weeks while two agents
|
|
80
|
+
* on 0.49 ran its 0.51 one.
|
|
81
|
+
*/
|
|
82
|
+
export function newestOnLine(candidates, eve) {
|
|
83
|
+
const supported = candidates.filter((c) => rangeContains(c.peer, eve));
|
|
84
|
+
if (supported.length === 0)
|
|
85
|
+
return undefined;
|
|
86
|
+
return supported.sort((a, b) => versionCompare(a.version, b.version)).at(-1)?.version;
|
|
87
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Services that are still running the code they loaded at start, after the
|
|
3
|
+
* package on disk has moved underneath them.
|
|
4
|
+
*
|
|
5
|
+
* Node reads a module once, at process start. Upgrading a package therefore
|
|
6
|
+
* changes nothing in a service that was already running — it keeps executing
|
|
7
|
+
* the old code from memory while the new version sits on disk, indefinitely.
|
|
8
|
+
*
|
|
9
|
+
* An agent host has more than one service. `deploy.sh` restarts the agent, and
|
|
10
|
+
* the Buzz bridge is a separate long-lived unit it does not touch. On the
|
|
11
|
+
* reference fleet both bridges ran for four days on `@kybernesis/buzz` 0.9.2
|
|
12
|
+
* while 0.9.9 was installed, so two fixes that had been signed off as deployed
|
|
13
|
+
* were live nowhere. Nothing was broken enough to notice; the bridges simply
|
|
14
|
+
* kept doing what the old code did.
|
|
15
|
+
*
|
|
16
|
+
* Checking the FILES is what hid it. The only honest question is whether the
|
|
17
|
+
* process started after the package changed.
|
|
18
|
+
*/
|
|
19
|
+
export interface StaleSidecar {
|
|
20
|
+
service: string;
|
|
21
|
+
/** When the unit last started. */
|
|
22
|
+
startedAt: Date;
|
|
23
|
+
/** The newest package change it has not picked up. */
|
|
24
|
+
packageChangedAt: Date;
|
|
25
|
+
/** The package whose change is newest, for the message. */
|
|
26
|
+
packageName: string;
|
|
27
|
+
}
|
|
28
|
+
/** The newest mtime across installed @kybernesis packages, and which one it was. */
|
|
29
|
+
export declare function newestPackageChange(cwd: string): {
|
|
30
|
+
at: Date;
|
|
31
|
+
name: string;
|
|
32
|
+
} | null;
|
|
33
|
+
/**
|
|
34
|
+
* Compare each running unit's start time against the newest package change.
|
|
35
|
+
*
|
|
36
|
+
* `started` maps a unit name to when it started — supplied by the caller so
|
|
37
|
+
* this stays testable and does not shell out.
|
|
38
|
+
*
|
|
39
|
+
* A grace margin avoids flagging the restart that an install performs itself:
|
|
40
|
+
* a service restarted within a minute of the package landing has the new code.
|
|
41
|
+
*/
|
|
42
|
+
export declare function staleSidecars(input: {
|
|
43
|
+
started: ReadonlyMap<string, Date>;
|
|
44
|
+
packageChange: {
|
|
45
|
+
at: Date;
|
|
46
|
+
name: string;
|
|
47
|
+
} | null;
|
|
48
|
+
graceMs?: number;
|
|
49
|
+
}): StaleSidecar[];
|
|
50
|
+
/** One line per stale service, naming the fix. */
|
|
51
|
+
export declare function describeStaleSidecar(stale: StaleSidecar): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/** The newest mtime across installed @kybernesis packages, and which one it was. */
|
|
4
|
+
export function newestPackageChange(cwd) {
|
|
5
|
+
const scope = join(cwd, "node_modules", "@kybernesis");
|
|
6
|
+
let newest = null;
|
|
7
|
+
let entries;
|
|
8
|
+
try {
|
|
9
|
+
entries = readdirSync(scope);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
for (const name of entries) {
|
|
15
|
+
try {
|
|
16
|
+
// The manifest is rewritten on every install of that package, so its
|
|
17
|
+
// mtime is when the package last changed on this host.
|
|
18
|
+
const at = statSync(join(scope, name, "package.json")).mtime;
|
|
19
|
+
if (!newest || at > newest.at)
|
|
20
|
+
newest = { at, name: `@kybernesis/${name}` };
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// A half-removed package tells us nothing; skip it.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return newest;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Compare each running unit's start time against the newest package change.
|
|
30
|
+
*
|
|
31
|
+
* `started` maps a unit name to when it started — supplied by the caller so
|
|
32
|
+
* this stays testable and does not shell out.
|
|
33
|
+
*
|
|
34
|
+
* A grace margin avoids flagging the restart that an install performs itself:
|
|
35
|
+
* a service restarted within a minute of the package landing has the new code.
|
|
36
|
+
*/
|
|
37
|
+
export function staleSidecars(input) {
|
|
38
|
+
const change = input.packageChange;
|
|
39
|
+
if (!change)
|
|
40
|
+
return [];
|
|
41
|
+
const grace = input.graceMs ?? 60_000;
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const [service, startedAt] of input.started) {
|
|
44
|
+
if (startedAt.getTime() >= change.at.getTime() - grace)
|
|
45
|
+
continue;
|
|
46
|
+
out.push({ service, startedAt, packageChangedAt: change.at, packageName: change.name });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/** One line per stale service, naming the fix. */
|
|
51
|
+
export function describeStaleSidecar(stale) {
|
|
52
|
+
const started = stale.startedAt.toISOString().slice(0, 16).replace("T", " ");
|
|
53
|
+
const changed = stale.packageChangedAt.toISOString().slice(0, 16).replace("T", " ");
|
|
54
|
+
return (`${stale.service} started ${started} but ${stale.packageName} changed ${changed} — ` +
|
|
55
|
+
`it is still running the code it loaded at start. Restart it: sudo systemctl restart ${stale.service}`);
|
|
56
|
+
}
|
package/dist/upgrade.js
CHANGED
|
@@ -7,6 +7,7 @@ import { upsertEnv } from "./envfile.js";
|
|
|
7
7
|
import { reconcileHostArtifact } from "./host-artifacts.js";
|
|
8
8
|
import { assessPeerTree, staleRangeWarning } from "./peer-tree.js";
|
|
9
9
|
import { dockerPruneCronArtifact } from "./docker-prune-cron.js";
|
|
10
|
+
import { newestOnLine } from "./on-line-version.js";
|
|
10
11
|
import { findMatchingAgentServiceUnit, repairManageRestart } from "./systemd.js";
|
|
11
12
|
import { repairTerminalSandboxCleanupHooks } from "./sandbox-cleanup.js";
|
|
12
13
|
import { repairRemovedDefaultTools as repairRemovedDefaultTools_ } from "./removed-default-tools.js";
|
|
@@ -753,6 +754,44 @@ function installedPackageValue(cwd, name, expression) {
|
|
|
753
754
|
function targetEvePeer(name, version) {
|
|
754
755
|
return capture("npm", ["view", `${name}@${version}`, "peerDependencies.eve"])?.trim() || undefined;
|
|
755
756
|
}
|
|
757
|
+
/**
|
|
758
|
+
* The newest published build of a @kybernesis package that supports a given eve.
|
|
759
|
+
*
|
|
760
|
+
* NOT `npm view <name> version`. These packages ship one build per eve line and
|
|
761
|
+
* the versions are not ordered by line — voice 0.1.2 is the eve 0.49 build and
|
|
762
|
+
* was published after 0.1.1, the 0.51 build — so npm's `latest` is routinely the
|
|
763
|
+
* wrong package. Installing it puts the agent on a build compiled against an eve
|
|
764
|
+
* API it is not running, which fails at neither install nor build, only later
|
|
765
|
+
* and only on one path. That is how an 0.51 build of @kybernesis/manage ran on
|
|
766
|
+
* two eve 0.49 agents for days, and how @kybernesis/notify took one down.
|
|
767
|
+
*
|
|
768
|
+
* Returns undefined when nothing published supports this eve — a real answer,
|
|
769
|
+
* and one the caller must report rather than paper over.
|
|
770
|
+
*/
|
|
771
|
+
function newestBuildForEve(name, eve) {
|
|
772
|
+
const raw = capture("npm", ["view", name, "versions", "--json"]);
|
|
773
|
+
if (!raw)
|
|
774
|
+
return undefined;
|
|
775
|
+
let versions;
|
|
776
|
+
try {
|
|
777
|
+
const parsed = JSON.parse(raw);
|
|
778
|
+
versions = Array.isArray(parsed) ? parsed : [String(parsed)];
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
return undefined;
|
|
782
|
+
}
|
|
783
|
+
// Newest first, so the usual case stops after one lookup.
|
|
784
|
+
const ordered = [...versions].reverse();
|
|
785
|
+
const candidates = [];
|
|
786
|
+
for (const version of ordered) {
|
|
787
|
+
const peer = targetEvePeer(name, version);
|
|
788
|
+
candidates.push({ version, ...(peer ? { peer } : {}) });
|
|
789
|
+
const hit = newestOnLine(candidates, eve);
|
|
790
|
+
if (hit)
|
|
791
|
+
return hit;
|
|
792
|
+
}
|
|
793
|
+
return undefined;
|
|
794
|
+
}
|
|
756
795
|
function dependencyEntries(pkg) {
|
|
757
796
|
const entries = [];
|
|
758
797
|
for (const section of ["dependencies", "devDependencies"]) {
|
|
@@ -879,9 +918,20 @@ export async function upgrade(options = {}) {
|
|
|
879
918
|
continue;
|
|
880
919
|
}
|
|
881
920
|
installedVersions.set(name, installed);
|
|
882
|
-
|
|
921
|
+
// eve goes to the certified pin. Everything else goes to the newest build
|
|
922
|
+
// that supports the eve this host will be running — NOT npm's `latest`,
|
|
923
|
+
// which for a per-eve-line package is routinely a build for a different
|
|
924
|
+
// line and installs without complaint.
|
|
925
|
+
const target = name === "eve"
|
|
926
|
+
? EVE_VERSION
|
|
927
|
+
: name.startsWith("@kybernesis/")
|
|
928
|
+
? newestBuildForEve(name, EVE_VERSION)
|
|
929
|
+
: capture("npm", ["view", name, "version"])?.trim();
|
|
883
930
|
if (!target) {
|
|
884
|
-
console.log(` ${yellow("!")} ${name}:
|
|
931
|
+
console.log(` ${yellow("!")} ${name}: ` +
|
|
932
|
+
(name.startsWith("@kybernesis/")
|
|
933
|
+
? `no published build declares support for eve ${EVE_VERSION} — leaving it alone`
|
|
934
|
+
: "could not resolve target version"));
|
|
885
935
|
unresolved.add(name);
|
|
886
936
|
continue;
|
|
887
937
|
}
|
|
@@ -891,7 +941,7 @@ export async function upgrade(options = {}) {
|
|
|
891
941
|
targetPeers.set(name, targetEvePeer(name, target));
|
|
892
942
|
}
|
|
893
943
|
if (installed === target) {
|
|
894
|
-
console.log(` ${green("✓")} ${name}@${installed} ${dim(name === "eve" ? "(certified)" : "(latest)")}`);
|
|
944
|
+
console.log(` ${green("✓")} ${name}@${installed} ${dim(name === "eve" ? "(certified)" : name.startsWith("@kybernesis/") ? `(newest for eve ${EVE_VERSION})` : "(latest)")}`);
|
|
895
945
|
}
|
|
896
946
|
else if (name === "eve" && versionLt(EVE_VERSION, installed)) {
|
|
897
947
|
console.log(` ${yellow("!")} eve@${installed} is AHEAD of the certified ${EVE_VERSION} ${dim("— unsupported territory")}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kybernesis/create",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.8",
|
|
4
4
|
"description": "The Kybernesis agent scaffolder and FDE toolkit: one command to a governed, remembering, multiplayer, self-testing eve agent — plus doctor and upgrade.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -46,5 +46,6 @@
|
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public"
|
|
49
|
-
}
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {}
|
|
50
51
|
}
|