@kybernesis/create 0.14.9 → 0.14.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.
@@ -0,0 +1,8 @@
1
+ export declare function registeredDockerPruneAppDirs(installedContent: string | null): string[];
2
+ export interface DockerPruneCronArtifact {
3
+ content: Buffer;
4
+ appDirs: string[];
5
+ manualCommand: string;
6
+ }
7
+ /** Render the package script with a managed, mergeable app-directory header. */
8
+ export declare function dockerPruneCronArtifact(packageSource: string, currentAppDir: string, installedContent: string | null, targetPath: string): DockerPruneCronArtifact;
@@ -0,0 +1,38 @@
1
+ const MANAGED_PREFIX = "# kyb-managed-eve-app-dirs: ";
2
+ function shellSingleQuote(value) {
3
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
4
+ }
5
+ export function registeredDockerPruneAppDirs(installedContent) {
6
+ if (!installedContent)
7
+ return [];
8
+ const line = installedContent.split("\n").find((candidate) => candidate.startsWith(MANAGED_PREFIX));
9
+ if (!line)
10
+ return [];
11
+ try {
12
+ const parsed = JSON.parse(line.slice(MANAGED_PREFIX.length));
13
+ return Array.isArray(parsed) && parsed.every((value) => typeof value === "string") ? parsed : [];
14
+ }
15
+ catch {
16
+ return [];
17
+ }
18
+ }
19
+ /** Render the package script with a managed, mergeable app-directory header. */
20
+ export function dockerPruneCronArtifact(packageSource, currentAppDir, installedContent, targetPath) {
21
+ const appDirs = [...new Set([...registeredDockerPruneAppDirs(installedContent), currentAppDir])];
22
+ const newline = packageSource.indexOf("\n");
23
+ const shebang = newline === -1 ? packageSource : packageSource.slice(0, newline);
24
+ const body = newline === -1 ? "" : packageSource.slice(newline + 1);
25
+ const joined = appDirs.join(":");
26
+ const content = Buffer.from(`${shebang}\n` +
27
+ `${MANAGED_PREFIX}${JSON.stringify(appDirs)}\n` +
28
+ `if [ -z "\${EVE_APP_DIRS:-}" ]; then EVE_APP_DIRS=${shellSingleQuote(joined)}; fi\n` +
29
+ `export EVE_APP_DIRS\n` +
30
+ body);
31
+ const encoded = content.toString("base64");
32
+ return {
33
+ content,
34
+ appDirs,
35
+ manualCommand: `printf %s ${shellSingleQuote(encoded)} | base64 -d | ` +
36
+ `sudo install -m 0755 /dev/stdin ${shellSingleQuote(targetPath)}`,
37
+ };
38
+ }
package/dist/doctor.d.ts CHANGED
@@ -22,6 +22,7 @@ export interface DockerTemplateInspection {
22
22
  export declare function dockerTemplateDoctorChecks(result: DockerTemplateInspection): Check[];
23
23
  export declare function modelReachDoctorCheck(authoredSource: string | null, compiledRouting: CompiledModelRouting): Check;
24
24
  export declare function agentInputLimitDoctorCheck(limit: AgentInputLimit): Check;
25
+ export declare function diskUsageDoctorCheck(percent: number, dockerSummary: string | null): Check;
25
26
  /**
26
27
  * The inspector lives in @kybernesis/exe, next to the runtime that owns the
27
28
  * templates; create already resolves that package's scripts out of
package/dist/doctor.js CHANGED
@@ -84,6 +84,15 @@ export function agentInputLimitDoctorCheck(limit) {
84
84
  };
85
85
  }
86
86
  }
87
+ export function diskUsageDoctorCheck(percent, dockerSummary) {
88
+ if (percent < 85)
89
+ return { verdict: "pass", label: `disk ${percent}% used` };
90
+ const detail = `Docker: ${dockerSummary?.trim() || "docker system df unavailable"}. ` +
91
+ `Run: sudo /etc/cron.daily/kyb-docker-prune`;
92
+ if (percent < 95)
93
+ return { verdict: "warn", label: `disk ${percent}% used`, detail };
94
+ return { verdict: "fail", label: `disk ${percent}% used`, detail };
95
+ }
87
96
  /**
88
97
  * The inspector lives in @kybernesis/exe, next to the runtime that owns the
89
98
  * templates; create already resolves that package's scripts out of
@@ -406,14 +415,17 @@ export async function doctor() {
406
415
  const job = capture("sh", ["-c", "test -x /etc/cron.daily/kyb-docker-prune && echo yes"])?.trim();
407
416
  const percent = Number(capture("sh", ["-c", "df / | awk 'NR==2{print $5}' | tr -d '%'"])?.trim() ?? 0);
408
417
  if (job !== "yes") {
409
- add("warn", "no daily docker reclaim on this host", "sandbox templates, build cache, and generic stopped containers accumulate; durable sessions stay protected and terminal hooks remove closed sessions; `kyb upgrade` installs /etc/cron.daily/kyb-docker-prune");
410
- }
411
- else if (percent >= 80) {
412
- add("fail", `disk ${percent}% full despite the reclaim job`, "run it now: sudo /etc/cron.daily/kyb-docker-prune, and check what else is on this host");
418
+ add("warn", "no daily docker reclaim on this host", "sandbox templates, build cache, and generic stopped containers accumulate; `kyb upgrade` installs /etc/cron.daily/kyb-docker-prune");
413
419
  }
414
420
  else {
415
- add("pass", `daily docker reclaim installed (disk ${percent}% used)`);
421
+ add("pass", "daily docker reclaim installed");
416
422
  }
423
+ const dockerSummary = capture("sh", [
424
+ "-c",
425
+ "docker system df 2>/dev/null | head -3 | paste -sd ';' -",
426
+ ]);
427
+ const diskCheck = diskUsageDoctorCheck(percent, dockerSummary);
428
+ add(diskCheck.verdict, diskCheck.label, diskCheck.detail);
417
429
  }
418
430
  /**
419
431
  * Instructions that name a tool the agent does not have.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Who, exactly, is unhappy about the installed eve.
3
+ *
4
+ * @remarks
5
+ * `kyb upgrade` refuses to touch a project whose peer tree is broken (KYB-538),
6
+ * because changing a project on a bad tree is how a half-upgraded host happens.
7
+ * That gate read `npm ls eve`'s exit code and nothing else, so any package
8
+ * anywhere in the tree could veto the whole upgrade.
9
+ *
10
+ * On 2026-09-08 one did. `@github-tools/eve-extension` still declares
11
+ * `>=0.44.0 <0.48.0` while every Kybernesis package declares the 0.49 line the
12
+ * host actually runs. The upgrade aborted before `repairHostArtifacts`, so
13
+ * kyber did not receive the KYB-542 reclaim job it was being upgraded for, and
14
+ * the job had to be installed by hand.
15
+ *
16
+ * A third-party range that trails the framework is not a broken install. A
17
+ * Kybernesis package disagreeing with the framework it is pinned against is.
18
+ * So the verdict distinguishes them, and anything it cannot attribute is
19
+ * treated as fatal rather than waved through.
20
+ */
21
+ export interface PeerTreeVerdict {
22
+ /** Nothing in the tree objects to the installed version. */
23
+ readonly ok: boolean;
24
+ /** Packages whose declared range excludes the installed version, deduplicated and sorted. */
25
+ readonly offenders: readonly string[];
26
+ /** An objection from a Kybernesis package, or one that could not be attributed. */
27
+ readonly fatal: boolean;
28
+ }
29
+ /**
30
+ * Read `npm ls <pkg> --json` and say whether the upgrade may proceed.
31
+ *
32
+ * Unparseable output is fatal: not knowing the shape of the tree is exactly the
33
+ * case the gate exists for.
34
+ */
35
+ export declare function assessPeerTree(stdout: string): PeerTreeVerdict;
36
+ /** What to print when a third-party range trails the framework but nothing else does. */
37
+ export declare function staleRangeWarning(offenders: readonly string[], dependency: string): string;
@@ -0,0 +1,62 @@
1
+ const OWNED_SCOPE = "@kybernesis/";
2
+ /** Every `from node_modules/<pkg>` in one npm `invalid` string. */
3
+ function requiredBy(invalid) {
4
+ const found = [];
5
+ const pattern = /from node_modules\/((?:@[^/\s,]+\/)?[^/\s,]+)/g;
6
+ let match = pattern.exec(invalid);
7
+ while (match !== null) {
8
+ if (match[1])
9
+ found.push(match[1]);
10
+ match = pattern.exec(invalid);
11
+ }
12
+ return found;
13
+ }
14
+ function collect(node, offenders, unattributed) {
15
+ if (!node || typeof node !== "object")
16
+ return;
17
+ const record = node;
18
+ if (typeof record.invalid === "string" && record.invalid.length > 0) {
19
+ const owners = requiredBy(record.invalid);
20
+ // An objection nobody signed is not evidence that the tree is fine.
21
+ if (owners.length === 0)
22
+ unattributed.seen = true;
23
+ for (const owner of owners)
24
+ offenders.add(owner);
25
+ }
26
+ const dependencies = record.dependencies;
27
+ if (dependencies && typeof dependencies === "object") {
28
+ for (const child of Object.values(dependencies)) {
29
+ collect(child, offenders, unattributed);
30
+ }
31
+ }
32
+ }
33
+ /**
34
+ * Read `npm ls <pkg> --json` and say whether the upgrade may proceed.
35
+ *
36
+ * Unparseable output is fatal: not knowing the shape of the tree is exactly the
37
+ * case the gate exists for.
38
+ */
39
+ export function assessPeerTree(stdout) {
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(stdout);
43
+ }
44
+ catch {
45
+ return { ok: false, offenders: [], fatal: true };
46
+ }
47
+ const offenders = new Set();
48
+ const unattributed = { seen: false };
49
+ collect(parsed, offenders, unattributed);
50
+ if (offenders.size === 0 && !unattributed.seen)
51
+ return { ok: true, offenders: [], fatal: false };
52
+ const sorted = [...offenders].sort();
53
+ const ours = sorted.some((name) => name.startsWith(OWNED_SCOPE));
54
+ return { ok: false, offenders: sorted, fatal: ours || unattributed.seen };
55
+ }
56
+ /** What to print when a third-party range trails the framework but nothing else does. */
57
+ export function staleRangeWarning(offenders, dependency) {
58
+ const names = offenders.join(", ");
59
+ return (`${names} declare${offenders.length === 1 ? "s" : ""} a ${dependency} range that excludes the installed version. ` +
60
+ `Every Kybernesis package agrees with it, and the runtime already runs on it, so the upgrade continues. ` +
61
+ `Upgrade or replace ${offenders.length === 1 ? "that package" : "those packages"} when a compatible release exists.`);
62
+ }
package/dist/upgrade.js CHANGED
@@ -1,9 +1,12 @@
1
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
+ import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { pathToFileURL } from "node:url";
5
6
  import { upsertEnv } from "./envfile.js";
6
7
  import { reconcileHostArtifact } from "./host-artifacts.js";
8
+ import { assessPeerTree, staleRangeWarning } from "./peer-tree.js";
9
+ import { dockerPruneCronArtifact } from "./docker-prune-cron.js";
7
10
  import { findMatchingAgentServiceUnit, repairManageRestart } from "./systemd.js";
8
11
  import { repairTerminalSandboxCleanupHooks } from "./sandbox-cleanup.js";
9
12
  import { repairRemovedDefaultTools as repairRemovedDefaultTools_ } from "./removed-default-tools.js";
@@ -246,7 +249,7 @@ function repairHostArtifacts(cwd, deps) {
246
249
  return;
247
250
  const scripts = join(cwd, "node_modules/@kybernesis/exe/scripts");
248
251
  const pruneSource = join(scripts, "docker-prune.sh");
249
- const pruneTarget = "/etc/cron.daily/kyb-docker-prune";
252
+ const pruneTarget = process.env.KYB_DOCKER_PRUNE_TARGET ?? "/etc/cron.daily/kyb-docker-prune";
250
253
  const hasDocker = capture("sh", ["-c", "command -v docker >/dev/null && echo yes"])?.trim() === "yes";
251
254
  // The host start script is package-owned too. It carried a 45-second
252
255
  // verdict clock that called a cold sandbox template build a failure, and no
@@ -276,18 +279,37 @@ function repairHostArtifacts(cwd, deps) {
276
279
  });
277
280
  }
278
281
  if (existsSync(pruneSource)) {
282
+ let installedPrune = null;
283
+ try {
284
+ if (existsSync(pruneTarget))
285
+ installedPrune = readFileSync(pruneTarget, "utf8");
286
+ }
287
+ catch {
288
+ // reconcileHostArtifact reports the unreadable target and refuses to replace it.
289
+ }
290
+ const pruneArtifact = dockerPruneCronArtifact(readFileSync(pruneSource, "utf8"), cwd, installedPrune, pruneTarget);
279
291
  const pruneResult = reconcileHostArtifact({
280
292
  targetPath: pruneTarget,
281
- desiredContent: readFileSync(pruneSource),
293
+ desiredContent: pruneArtifact.content,
282
294
  expectedMode: 0o755,
283
295
  installIfMissing: hasDocker,
284
- owner: "@kybernesis/exe/scripts/docker-prune.sh",
285
- update: () => run("sudo", ["-n", "install", "-m", "0755", pruneSource, pruneTarget], {
286
- cwd,
287
- allowFail: true,
288
- quiet: true,
289
- }),
290
- manualCommand: `sudo install -m 0755 ${shellQuote(pruneSource)} ${pruneTarget}`,
296
+ owner: "@kybernesis/exe/scripts/docker-prune.sh with registered app directories",
297
+ update: () => {
298
+ const stageDir = mkdtempSync(join(tmpdir(), "kyb-docker-prune-"));
299
+ const staged = join(stageDir, "docker-prune.sh");
300
+ try {
301
+ writeFileSync(staged, pruneArtifact.content, { mode: 0o755 });
302
+ return run("sudo", ["-n", "install", "-m", "0755", staged, pruneTarget], {
303
+ cwd,
304
+ allowFail: true,
305
+ quiet: true,
306
+ });
307
+ }
308
+ finally {
309
+ rmSync(stageDir, { recursive: true, force: true });
310
+ }
311
+ },
312
+ manualCommand: pruneArtifact.manualCommand,
291
313
  });
292
314
  if ((pruneResult === "current" || pruneResult === "updated") && existsSync("/etc/cron.weekly/docker-prune")) {
293
315
  const removed = run("sudo", ["-n", "rm", "-f", "/etc/cron.weekly/docker-prune"], {
@@ -800,9 +822,22 @@ export async function upgrade(options = {}) {
800
822
  }
801
823
  const validation = commandResult("npm", ["ls", "eve"], cwd);
802
824
  if (validation.status !== 0) {
803
- fail("npm ls eve failed. The installed peer tree is not valid.");
804
- recoveryGuidance(targets, stoppedBridge ?? undefined);
805
- return;
825
+ /**
826
+ * Judge WHO objects, not merely that something did.
827
+ *
828
+ * A third-party extension whose declared range trails the framework is not
829
+ * a broken install, and letting it veto the upgrade left kyber without the
830
+ * host artifacts it was being upgraded for (KYB-548). A Kybernesis package
831
+ * disagreeing with the framework it is pinned against still stops
832
+ * everything, and so does an objection we cannot attribute.
833
+ */
834
+ const verdict = assessPeerTree(commandResult("npm", ["ls", "eve", "--json"], cwd, false).stdout);
835
+ if (verdict.fatal || verdict.ok) {
836
+ fail("npm ls eve failed. The installed peer tree is not valid.");
837
+ recoveryGuidance(targets, stoppedBridge ?? undefined);
838
+ return;
839
+ }
840
+ console.log(yellow(` ! ${staleRangeWarning(verdict.offenders, "eve")}`));
806
841
  }
807
842
  if (stoppedBridge && !startStoppedBridge(cwd, stoppedBridge))
808
843
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/create",
3
- "version": "0.14.9",
3
+ "version": "0.14.11",
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",