@kybernesis/create 0.13.4 → 0.14.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/README.md +6 -0
- package/dist/add.d.ts +6 -0
- package/dist/add.js +32 -1
- package/dist/cli.js +1 -0
- package/dist/credential.js +5 -3
- package/dist/docker-templates.d.ts +46 -0
- package/dist/docker-templates.js +254 -0
- package/dist/doctor.d.ts +27 -0
- package/dist/doctor.js +61 -11
- package/dist/host-artifacts.d.ts +24 -0
- package/dist/host-artifacts.js +57 -0
- package/dist/init.js +15 -9
- package/dist/removed-default-tools.d.ts +29 -0
- package/dist/removed-default-tools.js +86 -0
- package/dist/sandbox-cleanup.d.ts +18 -0
- package/dist/sandbox-cleanup.js +57 -0
- package/dist/systemd.d.ts +41 -0
- package/dist/systemd.js +136 -0
- package/dist/templates.d.ts +12 -0
- package/dist/templates.js +53 -2
- package/dist/upgrade.js +124 -42
- package/dist/util.d.ts +1 -1
- package/dist/util.js +1 -1
- package/package.json +4 -3
- package/skills/fde-engagement/references/playbook.md +3 -3
- package/skills/kybernesis-packages/SKILL.md +5 -2
package/README.md
CHANGED
|
@@ -54,6 +54,12 @@ behind, typechecks, then **runs the eval suite as the gate** — prints
|
|
|
54
54
|
"deploy" only on green. This is the maintenance-retainer loop as a command:
|
|
55
55
|
package improvement → `kyb upgrade` per client → green → `npx eve deploy`.
|
|
56
56
|
|
|
57
|
+
On self-hosted agents, upgrade also reconciles package-owned host artifacts. It
|
|
58
|
+
refreshes a stale `/etc/cron.daily/kyb-docker-prune` and an existing generated
|
|
59
|
+
systemd unit, warning before replacement and printing an exact manual repair
|
|
60
|
+
command if non-interactive sudo is unavailable. A missing systemd unit is not
|
|
61
|
+
created implicitly, and refreshing an existing unit does not restart the agent.
|
|
62
|
+
|
|
57
63
|
## Design notes
|
|
58
64
|
|
|
59
65
|
- **Zero runtime dependencies** — node builtins only (`spawnSync`, `readline`,
|
package/dist/add.d.ts
CHANGED
|
@@ -15,3 +15,9 @@
|
|
|
15
15
|
* already an agent, which is all "add" ever meant.
|
|
16
16
|
*/
|
|
17
17
|
export declare function add(what: string | undefined, rest: string[]): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* `kyb add memory` — recall before every turn, from Arcana, as an eve memory
|
|
20
|
+
* slot. Needs eve ≥ 0.49 (the memory API) and the Arcana key and workspace
|
|
21
|
+
* the agent already has; refuses to overwrite a slot someone has authored.
|
|
22
|
+
*/
|
|
23
|
+
export declare function addMemory(cwd?: string): void;
|
package/dist/add.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, join, resolve } from "node:path";
|
|
3
3
|
import { bold, dim, green, red, run, yellow } from "./util.js";
|
|
4
|
-
import { CHANNEL_KINDS, channelPlan } from "./templates.js";
|
|
4
|
+
import { CHANNEL_KINDS, arcanaMemorySlotTs, channelPlan } from "./templates.js";
|
|
5
5
|
/**
|
|
6
6
|
* `kyb add channel <kind>` — put another surface on an agent that already exists.
|
|
7
7
|
*
|
|
@@ -19,9 +19,12 @@ import { CHANNEL_KINDS, channelPlan } from "./templates.js";
|
|
|
19
19
|
* already an agent, which is all "add" ever meant.
|
|
20
20
|
*/
|
|
21
21
|
export async function add(what, rest) {
|
|
22
|
+
if (what === "memory")
|
|
23
|
+
return addMemory();
|
|
22
24
|
if (what !== "channel") {
|
|
23
25
|
console.error(red(`kyb add: don't know how to add "${what ?? ""}".`));
|
|
24
26
|
console.log(dim(` kyb add channel <${CHANNEL_KINDS.filter((k) => k !== "none").join("|")}>`));
|
|
27
|
+
console.log(dim(` kyb add memory ${dim("— an eve 0.49 memory slot backed by Arcana (recall before every turn)")}`));
|
|
25
28
|
process.exitCode = 1;
|
|
26
29
|
return;
|
|
27
30
|
}
|
|
@@ -108,3 +111,31 @@ function appendEnvExample(dir, kind, lines) {
|
|
|
108
111
|
writeFileSync(path, `${existing.replace(/\n*$/, "\n")}\n# ${kind}\n${fresh.join("\n")}\n`);
|
|
109
112
|
return fresh.length;
|
|
110
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* `kyb add memory` — recall before every turn, from Arcana, as an eve memory
|
|
116
|
+
* slot. Needs eve ≥ 0.49 (the memory API) and the Arcana key and workspace
|
|
117
|
+
* the agent already has; refuses to overwrite a slot someone has authored.
|
|
118
|
+
*/
|
|
119
|
+
export function addMemory(cwd = resolve(process.cwd())) {
|
|
120
|
+
if (!existsSync(join(cwd, "package.json")) || !existsSync(join(cwd, "agent"))) {
|
|
121
|
+
console.error(red("kyb add memory: run this inside an agent directory."));
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
console.log(bold("kyb add memory"));
|
|
126
|
+
if (existsSync(join(cwd, "agent/memory.ts"))) {
|
|
127
|
+
console.log(yellow(" agent/memory.ts exists (the single-slot form); eve allows one form or the other. Leaving it alone."));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const target = join(cwd, "agent/memory/arcana.ts");
|
|
131
|
+
if (existsSync(target)) {
|
|
132
|
+
console.log(yellow(" agent/memory/arcana.ts already exists — leaving it alone."));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const extensionMounted = existsSync(join(cwd, "agent/extensions/arcana.ts"));
|
|
136
|
+
mkdirSync(join(cwd, "agent/memory"), { recursive: true });
|
|
137
|
+
writeFileSync(target, arcanaMemorySlotTs({ extensionMounted }));
|
|
138
|
+
console.log(green(" + agent/memory/arcana.ts"));
|
|
139
|
+
console.log(dim(` recall before every turn from ARCANA_COMPANY_WORKSPACE; capture off; ${extensionMounted ? "tools from the extension" : "remember/recall/search as memory__* tools"}`));
|
|
140
|
+
console.log(dim(" needs eve >= 0.49 and @kybernesis/arcana >= 0.4 — kyb upgrade carries both. Then: npx eve info"));
|
|
141
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -194,6 +194,7 @@ ${dim(" npm i -g @kybernesis/create@latest")}
|
|
|
194
194
|
--yes ${dim("no prompts; take flags and defaults")}
|
|
195
195
|
${bold("kyb add channel <kind>")}
|
|
196
196
|
${dim("slack|imessage|telegram|discord|web — writes the channel, deps and env")}
|
|
197
|
+
${bold("kyb add memory")} ${dim("eve 0.49 memory slot backed by Arcana: recall before every turn")}
|
|
197
198
|
${bold("kyb doctor")} preflight checks (keys, issuer, envs, discovery)
|
|
198
199
|
${bold("kyb skills")} install/refresh the FDE skill suite for Claude Code
|
|
199
200
|
--global ${dim("install to ~/.claude/skills instead of this repo")}
|
package/dist/credential.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { upsertEnv } from "./envfile.js";
|
|
6
|
+
import { systemdRestartCommand } from "./systemd.js";
|
|
6
7
|
import { signIn } from "./register.js";
|
|
7
8
|
import { bold, dim, green, red, yellow } from "./util.js";
|
|
8
9
|
/**
|
|
@@ -135,14 +136,15 @@ export async function credential(options) {
|
|
|
135
136
|
return;
|
|
136
137
|
}
|
|
137
138
|
console.log(dim(" Restarting so the agent reads it …"));
|
|
139
|
+
const restart = systemdRestartCommand(name);
|
|
138
140
|
try {
|
|
139
|
-
execFileSync("ssh", [target,
|
|
141
|
+
execFileSync("ssh", [target, `${restart} >/dev/null 2>&1 &`], {
|
|
140
142
|
encoding: "utf8",
|
|
141
143
|
});
|
|
142
|
-
console.log(green(" ✓ restart triggered"));
|
|
144
|
+
console.log(green(" ✓ systemd restart triggered"));
|
|
143
145
|
}
|
|
144
146
|
catch {
|
|
145
|
-
console.log(yellow(` ! could not restart — run: ssh ${target} '
|
|
147
|
+
console.log(yellow(` ! could not restart — run: ssh ${target} '${restart}'`));
|
|
146
148
|
}
|
|
147
149
|
console.log(dim(`\n Check what it can now reach:\n` +
|
|
148
150
|
` ssh ${target} 'cd ~/${name} && CRED=$(grep -m1 "^KYBERNESIS_AGENT_CREDENTIAL=" .env.local | cut -d= -f2- | tr -d "\\"") && curl -s -H "authorization: Bearer $CRED" ${issuer}/api/agent/peers'\n`));
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export declare const DOCKER_TEMPLATE_MARKER_DIRECTORY = ".eve/sandbox-cache/docker/templates";
|
|
2
|
+
export declare const DOCKER_TEMPLATE_IMAGE_REPOSITORY = "eve-sandbox-template";
|
|
3
|
+
export interface DockerTemplateFileSystem {
|
|
4
|
+
exists(path: string): boolean;
|
|
5
|
+
readFile(path: string): string;
|
|
6
|
+
readDir(path: string): readonly {
|
|
7
|
+
name: string;
|
|
8
|
+
isDirectory: boolean;
|
|
9
|
+
isFile: boolean;
|
|
10
|
+
}[];
|
|
11
|
+
mtimeMs(path: string): number;
|
|
12
|
+
}
|
|
13
|
+
export interface DockerCommandResult {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
stderr?: string;
|
|
16
|
+
}
|
|
17
|
+
export type DockerCommand = (command: string, args: readonly string[]) => DockerCommandResult | Promise<DockerCommandResult>;
|
|
18
|
+
export interface DockerTemplateIssue {
|
|
19
|
+
kind: "missing-marker" | "missing-image" | "docker-error";
|
|
20
|
+
subject: string;
|
|
21
|
+
detail: string;
|
|
22
|
+
}
|
|
23
|
+
export type DockerTemplateInspection = {
|
|
24
|
+
status: "skipped";
|
|
25
|
+
sandboxes: readonly string[];
|
|
26
|
+
images: readonly string[];
|
|
27
|
+
issues: readonly [];
|
|
28
|
+
} | {
|
|
29
|
+
status: "present";
|
|
30
|
+
sandboxes: readonly string[];
|
|
31
|
+
images: readonly string[];
|
|
32
|
+
issues: readonly [];
|
|
33
|
+
} | {
|
|
34
|
+
status: "failed";
|
|
35
|
+
sandboxes: readonly string[];
|
|
36
|
+
images: readonly string[];
|
|
37
|
+
issues: readonly DockerTemplateIssue[];
|
|
38
|
+
};
|
|
39
|
+
export interface InspectDockerTemplatesOptions {
|
|
40
|
+
appDir: string;
|
|
41
|
+
fs?: DockerTemplateFileSystem;
|
|
42
|
+
runDocker?: DockerCommand;
|
|
43
|
+
dockerPath?: string;
|
|
44
|
+
}
|
|
45
|
+
export declare function discoverDockerTemplateSandboxes(appDir: string, fs?: DockerTemplateFileSystem): string[];
|
|
46
|
+
export declare function inspectDockerTemplates(options: InspectDockerTemplatesOptions): Promise<DockerTemplateInspection>;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
export const DOCKER_TEMPLATE_MARKER_DIRECTORY = ".eve/sandbox-cache/docker/templates";
|
|
5
|
+
export const DOCKER_TEMPLATE_IMAGE_REPOSITORY = "eve-sandbox-template";
|
|
6
|
+
const SANDBOX_EXTENSIONS = ["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
|
|
7
|
+
const REPAIR_GUIDANCE = "Run `eve build`, then restart so sandbox prewarm runs before serving traffic.";
|
|
8
|
+
const nodeFileSystem = {
|
|
9
|
+
exists(path) {
|
|
10
|
+
try {
|
|
11
|
+
statSync(path);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
readFile: (path) => readFileSync(path, "utf8"),
|
|
19
|
+
readDir: (path) => readdirSync(path, { withFileTypes: true }).map((entry) => ({
|
|
20
|
+
name: entry.name,
|
|
21
|
+
isDirectory: entry.isDirectory(),
|
|
22
|
+
isFile: entry.isFile(),
|
|
23
|
+
})),
|
|
24
|
+
mtimeMs: (path) => statSync(path).mtimeMs,
|
|
25
|
+
};
|
|
26
|
+
function defaultDockerCommand(command, args) {
|
|
27
|
+
const result = spawnSync(command, [...args], { encoding: "utf8" });
|
|
28
|
+
return {
|
|
29
|
+
ok: result.status === 0,
|
|
30
|
+
stderr: [result.stderr, result.error?.message].filter(Boolean).join("\n").trim(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function stripComments(source) {
|
|
34
|
+
let output = "";
|
|
35
|
+
let state = "code";
|
|
36
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
37
|
+
const char = source[index];
|
|
38
|
+
const next = source[index + 1];
|
|
39
|
+
if (state === "line") {
|
|
40
|
+
if (char === "\n") {
|
|
41
|
+
output += char;
|
|
42
|
+
state = "code";
|
|
43
|
+
}
|
|
44
|
+
else
|
|
45
|
+
output += " ";
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (state === "block") {
|
|
49
|
+
if (char === "*" && next === "/") {
|
|
50
|
+
output += " ";
|
|
51
|
+
index += 1;
|
|
52
|
+
state = "code";
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
output += char === "\n" ? "\n" : " ";
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (state === "code") {
|
|
59
|
+
if (char === "/" && next === "/") {
|
|
60
|
+
output += " ";
|
|
61
|
+
index += 1;
|
|
62
|
+
state = "line";
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (char === "/" && next === "*") {
|
|
66
|
+
output += " ";
|
|
67
|
+
index += 1;
|
|
68
|
+
state = "block";
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (char === "'")
|
|
72
|
+
state = "single";
|
|
73
|
+
else if (char === '"')
|
|
74
|
+
state = "double";
|
|
75
|
+
else if (char === "`")
|
|
76
|
+
state = "template";
|
|
77
|
+
output += char;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
output += char;
|
|
81
|
+
if (char === "\\") {
|
|
82
|
+
if (next !== undefined) {
|
|
83
|
+
output += next;
|
|
84
|
+
index += 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else if ((state === "single" && char === "'") ||
|
|
88
|
+
(state === "double" && char === '"') ||
|
|
89
|
+
(state === "template" && char === "`")) {
|
|
90
|
+
state = "code";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return output;
|
|
94
|
+
}
|
|
95
|
+
function escapeRegExp(value) {
|
|
96
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
97
|
+
}
|
|
98
|
+
function usesDockerBackend(source) {
|
|
99
|
+
const code = stripComments(source);
|
|
100
|
+
const calls = [];
|
|
101
|
+
for (const match of code.matchAll(/import\s*\{([^}]+)\}\s*from\s*["']eve\/sandbox\/docker["']/g)) {
|
|
102
|
+
for (const specifier of match[1].split(",")) {
|
|
103
|
+
const imported = /^\s*docker(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*$/.exec(specifier);
|
|
104
|
+
if (imported)
|
|
105
|
+
calls.push(imported[1] ?? "docker");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const match of code.matchAll(/import\s*\*\s*as\s*([A-Za-z_$][\w$]*)\s*from\s*["']eve\/sandbox\/docker["']/g)) {
|
|
109
|
+
calls.push(`${match[1]}.docker`);
|
|
110
|
+
}
|
|
111
|
+
if (calls.length === 0)
|
|
112
|
+
return false;
|
|
113
|
+
const callPattern = calls.map(escapeRegExp).join("|");
|
|
114
|
+
if (new RegExp(`\\bbackend\\s*:\\s*(?:${callPattern})\\s*\\(`).test(code))
|
|
115
|
+
return true;
|
|
116
|
+
const dockerVariables = new Set();
|
|
117
|
+
for (const match of code.matchAll(new RegExp(`\\b(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*(?:${callPattern})\\s*\\(`, "g"))) {
|
|
118
|
+
dockerVariables.add(match[1]);
|
|
119
|
+
}
|
|
120
|
+
for (const variable of dockerVariables) {
|
|
121
|
+
const escaped = escapeRegExp(variable);
|
|
122
|
+
if (new RegExp(`\\bbackend\\s*:\\s*${escaped}\\b`).test(code))
|
|
123
|
+
return true;
|
|
124
|
+
if (new RegExp(`[{,]\\s*backend\\s*[,}]`).test(code) && variable === "backend")
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
function hasWorkspaceResources(fs, sandboxFile) {
|
|
130
|
+
const sandboxDir = dirname(sandboxFile);
|
|
131
|
+
if (basename(sandboxDir) !== "sandbox")
|
|
132
|
+
return false;
|
|
133
|
+
const workspace = join(sandboxDir, "workspace");
|
|
134
|
+
try {
|
|
135
|
+
return fs.readDir(workspace).length > 0;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function requiresTemplate(fs, sandboxFile, source) {
|
|
142
|
+
return /\b(?:async\s+)?bootstrap\s*(?:\(|:)/.test(stripComments(source)) || hasWorkspaceResources(fs, sandboxFile);
|
|
143
|
+
}
|
|
144
|
+
function sandboxFiles(fs, agentDir) {
|
|
145
|
+
const files = [];
|
|
146
|
+
for (const extension of SANDBOX_EXTENSIONS) {
|
|
147
|
+
for (const path of [
|
|
148
|
+
join(agentDir, `sandbox.${extension}`),
|
|
149
|
+
join(agentDir, "sandbox", `sandbox.${extension}`),
|
|
150
|
+
]) {
|
|
151
|
+
if (fs.exists(path))
|
|
152
|
+
files.push(path);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return files;
|
|
156
|
+
}
|
|
157
|
+
export function discoverDockerTemplateSandboxes(appDir, fs = nodeFileSystem) {
|
|
158
|
+
const appAgentDir = join(appDir, "agent");
|
|
159
|
+
const discovered = [];
|
|
160
|
+
const visitAgent = (agentDir) => {
|
|
161
|
+
for (const sandboxFile of sandboxFiles(fs, agentDir)) {
|
|
162
|
+
let source;
|
|
163
|
+
try {
|
|
164
|
+
source = fs.readFile(sandboxFile);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (usesDockerBackend(source) && requiresTemplate(fs, sandboxFile, source)) {
|
|
170
|
+
discovered.push(relative(appDir, sandboxFile));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const subagentsDir = join(agentDir, "subagents");
|
|
174
|
+
let entries;
|
|
175
|
+
try {
|
|
176
|
+
entries = fs.readDir(subagentsDir);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
if (entry.isDirectory)
|
|
183
|
+
visitAgent(join(subagentsDir, entry.name));
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
if (fs.exists(appAgentDir))
|
|
187
|
+
visitAgent(appAgentDir);
|
|
188
|
+
return discovered.sort();
|
|
189
|
+
}
|
|
190
|
+
function markerReference(fs, markerPath) {
|
|
191
|
+
try {
|
|
192
|
+
const content = fs.readFile(markerPath).trim();
|
|
193
|
+
if (content)
|
|
194
|
+
return content;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Eve's marker basename is also the image tag, so unreadable content has a safe fallback.
|
|
198
|
+
}
|
|
199
|
+
return `${DOCKER_TEMPLATE_IMAGE_REPOSITORY}:${basename(markerPath)}`;
|
|
200
|
+
}
|
|
201
|
+
function isMissingImage(stderr) {
|
|
202
|
+
return /no such (?:image|object)/i.test(stderr);
|
|
203
|
+
}
|
|
204
|
+
export async function inspectDockerTemplates(options) {
|
|
205
|
+
const fs = options.fs ?? nodeFileSystem;
|
|
206
|
+
const sandboxes = discoverDockerTemplateSandboxes(options.appDir, fs);
|
|
207
|
+
if (sandboxes.length === 0)
|
|
208
|
+
return { status: "skipped", sandboxes, images: [], issues: [] };
|
|
209
|
+
const markerDir = join(options.appDir, DOCKER_TEMPLATE_MARKER_DIRECTORY);
|
|
210
|
+
let markers = [];
|
|
211
|
+
try {
|
|
212
|
+
markers = fs.readDir(markerDir)
|
|
213
|
+
.filter((entry) => entry.isFile)
|
|
214
|
+
.map((entry) => ({ path: join(markerDir, entry.name), mtimeMs: fs.mtimeMs(join(markerDir, entry.name)) }))
|
|
215
|
+
.sort((left, right) => right.mtimeMs - left.mtimeMs || left.path.localeCompare(right.path))
|
|
216
|
+
.slice(0, sandboxes.length);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
markers = [];
|
|
220
|
+
}
|
|
221
|
+
const images = markers.map((marker) => markerReference(fs, marker.path));
|
|
222
|
+
const issues = [];
|
|
223
|
+
for (const sandbox of sandboxes.slice(markers.length)) {
|
|
224
|
+
issues.push({
|
|
225
|
+
kind: "missing-marker",
|
|
226
|
+
subject: sandbox,
|
|
227
|
+
detail: `No current Docker template marker exists for ${sandbox}. ${REPAIR_GUIDANCE}`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const runDocker = options.runDocker ?? defaultDockerCommand;
|
|
231
|
+
const dockerPath = options.dockerPath ?? process.env.EVE_DOCKER_PATH ?? "docker";
|
|
232
|
+
for (const image of images) {
|
|
233
|
+
let result;
|
|
234
|
+
try {
|
|
235
|
+
result = await runDocker(dockerPath, ["image", "inspect", image]);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
result = { ok: false, stderr: error instanceof Error ? error.message : String(error) };
|
|
239
|
+
}
|
|
240
|
+
if (result.ok)
|
|
241
|
+
continue;
|
|
242
|
+
const stderr = result.stderr?.trim() || "Docker returned an unknown error";
|
|
243
|
+
issues.push({
|
|
244
|
+
kind: isMissingImage(stderr) ? "missing-image" : "docker-error",
|
|
245
|
+
subject: image,
|
|
246
|
+
detail: isMissingImage(stderr)
|
|
247
|
+
? `Docker template image ${image} is missing. ${REPAIR_GUIDANCE}`
|
|
248
|
+
: `Could not inspect Docker template image ${image}: ${stderr}. ${REPAIR_GUIDANCE}`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return issues.length === 0
|
|
252
|
+
? { status: "present", sandboxes, images, issues: [] }
|
|
253
|
+
: { status: "failed", sandboxes, images, issues };
|
|
254
|
+
}
|
package/dist/doctor.d.ts
CHANGED
|
@@ -1 +1,28 @@
|
|
|
1
|
+
type Verdict = "pass" | "warn" | "fail";
|
|
2
|
+
export interface Check {
|
|
3
|
+
verdict: Verdict;
|
|
4
|
+
label: string;
|
|
5
|
+
detail?: string;
|
|
6
|
+
}
|
|
7
|
+
/** The shape @kybernesis/exe's inspector returns; declared here so create keeps zero runtime deps. */
|
|
8
|
+
export interface DockerTemplateInspection {
|
|
9
|
+
status: "skipped" | "present" | "failed";
|
|
10
|
+
sandboxes: readonly string[];
|
|
11
|
+
images: readonly string[];
|
|
12
|
+
issues: readonly {
|
|
13
|
+
kind: "missing-marker" | "missing-image" | "incomplete-set" | "docker-error";
|
|
14
|
+
subject: string;
|
|
15
|
+
detail: string;
|
|
16
|
+
}[];
|
|
17
|
+
}
|
|
18
|
+
export declare function dockerTemplateDoctorChecks(result: DockerTemplateInspection): Check[];
|
|
19
|
+
/**
|
|
20
|
+
* The inspector lives in @kybernesis/exe, next to the runtime that owns the
|
|
21
|
+
* templates; create already resolves that package's scripts out of
|
|
22
|
+
* node_modules at runtime, and does the same here rather than carry a second
|
|
23
|
+
* copy of a heuristic this subtle. No exe package means no Docker host, and
|
|
24
|
+
* no check.
|
|
25
|
+
*/
|
|
26
|
+
export declare function inspectDockerTemplatesViaExe(cwd: string): Promise<DockerTemplateInspection | null>;
|
|
1
27
|
export declare function doctor(): Promise<void>;
|
|
28
|
+
export {};
|
package/dist/doctor.js
CHANGED
|
@@ -1,11 +1,49 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
3
4
|
import { bold, capture, dim, green, parseEnv, red, yellow } from "./util.js";
|
|
5
|
+
import { diagnoseManageRestart, findMatchingAgentServiceUnit } from "./systemd.js";
|
|
4
6
|
const MARK = {
|
|
5
7
|
pass: green("✓"),
|
|
6
8
|
warn: yellow("!"),
|
|
7
9
|
fail: red("✗"),
|
|
8
10
|
};
|
|
11
|
+
export function dockerTemplateDoctorChecks(result) {
|
|
12
|
+
if (result.status === "skipped")
|
|
13
|
+
return [];
|
|
14
|
+
if (result.status === "present") {
|
|
15
|
+
return [{
|
|
16
|
+
verdict: "pass",
|
|
17
|
+
label: `Docker sandbox templates provisioned (${result.images.length}/${result.sandboxes.length})`,
|
|
18
|
+
}];
|
|
19
|
+
}
|
|
20
|
+
return result.issues.map((issue) => ({
|
|
21
|
+
// A set the newest build did not fully cover still serves: the uncovered
|
|
22
|
+
// scope builds on first use. A current marker with no image, or a daemon
|
|
23
|
+
// that will not answer, is a fault.
|
|
24
|
+
verdict: issue.kind === "incomplete-set" ? "warn" : "fail",
|
|
25
|
+
label: issue.kind === "missing-marker"
|
|
26
|
+
? `Docker sandbox template unresolved: ${issue.subject}`
|
|
27
|
+
: issue.kind === "incomplete-set"
|
|
28
|
+
? `Docker sandbox templates incomplete: ${issue.subject}`
|
|
29
|
+
: `Docker sandbox template unavailable: ${issue.subject}`,
|
|
30
|
+
detail: issue.detail,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The inspector lives in @kybernesis/exe, next to the runtime that owns the
|
|
35
|
+
* templates; create already resolves that package's scripts out of
|
|
36
|
+
* node_modules at runtime, and does the same here rather than carry a second
|
|
37
|
+
* copy of a heuristic this subtle. No exe package means no Docker host, and
|
|
38
|
+
* no check.
|
|
39
|
+
*/
|
|
40
|
+
export async function inspectDockerTemplatesViaExe(cwd) {
|
|
41
|
+
const module = join(cwd, "node_modules/@kybernesis/exe/dist/docker-templates.js");
|
|
42
|
+
if (!existsSync(module))
|
|
43
|
+
return null;
|
|
44
|
+
const { inspectDockerTemplates } = (await import(pathToFileURL(module).href));
|
|
45
|
+
return inspectDockerTemplates({ appDir: cwd });
|
|
46
|
+
}
|
|
9
47
|
async function head(url, headers) {
|
|
10
48
|
try {
|
|
11
49
|
const res = await fetch(url, { headers, signal: AbortSignal.timeout(6000) });
|
|
@@ -17,6 +55,7 @@ async function head(url, headers) {
|
|
|
17
55
|
}
|
|
18
56
|
export async function doctor() {
|
|
19
57
|
const cwd = process.cwd();
|
|
58
|
+
const installedAgentService = findMatchingAgentServiceUnit(cwd);
|
|
20
59
|
const checks = [];
|
|
21
60
|
const add = (verdict, label, detail) => checks.push({ verdict, label, detail });
|
|
22
61
|
// ── project shape ──────────────────────────────────────────────────────
|
|
@@ -209,6 +248,10 @@ export async function doctor() {
|
|
|
209
248
|
const selfHosted = Boolean(deps["@kybernesis/exe"]) ||
|
|
210
249
|
existsSync(join(cwd, "agent/sandbox/sandbox.ts")) &&
|
|
211
250
|
readFileSync(join(cwd, "agent/sandbox/sandbox.ts"), "utf8").includes("docker(");
|
|
251
|
+
const templateInspection = await inspectDockerTemplatesViaExe(cwd);
|
|
252
|
+
for (const check of templateInspection ? dockerTemplateDoctorChecks(templateInspection) : []) {
|
|
253
|
+
add(check.verdict, check.label, check.detail);
|
|
254
|
+
}
|
|
212
255
|
if (selfHosted) {
|
|
213
256
|
// Vercel Connect needs Vercel OIDC — it CANNOT work off-Vercel, for Slack,
|
|
214
257
|
// the Vercel MCP connection, or anything else. Every such connection has to
|
|
@@ -243,11 +286,21 @@ export async function doctor() {
|
|
|
243
286
|
* operator that warnings here are decoration.
|
|
244
287
|
*/
|
|
245
288
|
const supervisor = join(cwd, "scripts/eve-server.sh");
|
|
246
|
-
if (
|
|
247
|
-
|
|
289
|
+
if (installedAgentService) {
|
|
290
|
+
if (installedAgentService.contents.includes("ExecStartPre=/bin/bash -lc 'set -a && . ./.env.local && set +a && npx eve build'")) {
|
|
291
|
+
add("pass", `systemd ${installedAgentService.values.name}-agent builds before every start`);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
add("fail", `systemd ${installedAgentService.values.name}-agent has no build-before-start gate`, "run kyb upgrade to refresh an unchanged package unit; if customized, move overrides to `sudo systemctl edit " +
|
|
295
|
+
`${installedAgentService.values.name}-agent` +
|
|
296
|
+
"` and follow the exact repair command it prints");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
else if (existsSync(supervisor)) {
|
|
300
|
+
add("pass", "scripts/eve-server.sh present (exports .env.local, builds if stale, starts via the eve CLI)");
|
|
248
301
|
}
|
|
249
302
|
else {
|
|
250
|
-
add("warn", "self-hosted: export .env.local into the server process", "eve start does NOT read it;
|
|
303
|
+
add("warn", "self-hosted: export .env.local into the server process", "eve start does NOT read it; install systemd with node_modules/@kybernesis/exe/scripts/install-service.sh");
|
|
251
304
|
add("warn", "self-hosted: start via `npx eve start`, not `node .output/server/index.mjs`", "sandbox templates are prewarmed by the CLI; starting the server directly skips prewarm and every sandbox tool fails with SandboxTemplateNotProvisionedError");
|
|
252
305
|
}
|
|
253
306
|
/**
|
|
@@ -287,7 +340,7 @@ export async function doctor() {
|
|
|
287
340
|
const job = capture("sh", ["-c", "test -x /etc/cron.daily/kyb-docker-prune && echo yes"])?.trim();
|
|
288
341
|
const percent = Number(capture("sh", ["-c", "df / | awk 'NR==2{print $5}' | tr -d '%'"])?.trim() ?? 0);
|
|
289
342
|
if (job !== "yes") {
|
|
290
|
-
add("warn", "no daily docker reclaim on this host", "sandbox
|
|
343
|
+
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");
|
|
291
344
|
}
|
|
292
345
|
else if (percent >= 80) {
|
|
293
346
|
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");
|
|
@@ -376,13 +429,10 @@ export async function doctor() {
|
|
|
376
429
|
// remaining requirement (a writable working copy) is a property of the
|
|
377
430
|
// host, and on a read-only bundle the routes refuse with that reason.
|
|
378
431
|
const manageFile = join(cwd, "agent/channels/kyb.ts");
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
else {
|
|
384
|
-
add("warn", "management routes have no restartCommand", "installing edits this repo and rebuilds; without a restart the response says a restart is still required. Set restartCommand in agent/channels/kyb.ts (self-hosted: \"bash scripts/eve-server.sh restart\")");
|
|
385
|
-
}
|
|
432
|
+
const manageSource = existsSync(manageFile) ? readFileSync(manageFile, "utf8") : "";
|
|
433
|
+
const restartCommand = /\brestartCommand\s*:\s*["'`]([^"'`]+)["'`]/m.exec(manageSource)?.[1];
|
|
434
|
+
const diagnosis = diagnoseManageRestart(installedAgentService?.values.name ?? null, restartCommand);
|
|
435
|
+
add(diagnosis.verdict, diagnosis.label, diagnosis.detail);
|
|
386
436
|
}
|
|
387
437
|
// ── engineer subagent (build capability scoped to a subagent) ──────────
|
|
388
438
|
const builderDir = join(cwd, "agent/subagents/builder");
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type HostArtifactInspection = {
|
|
2
|
+
state: "missing";
|
|
3
|
+
} | {
|
|
4
|
+
state: "current";
|
|
5
|
+
} | {
|
|
6
|
+
state: "drifted";
|
|
7
|
+
differences: Array<"content" | "mode">;
|
|
8
|
+
} | {
|
|
9
|
+
state: "unreadable";
|
|
10
|
+
error: Error;
|
|
11
|
+
};
|
|
12
|
+
export type HostArtifactReconcileResult = "missing" | "current" | "updated" | "failed" | "unreadable";
|
|
13
|
+
export interface HostArtifactOptions {
|
|
14
|
+
targetPath: string;
|
|
15
|
+
desiredContent: Uint8Array;
|
|
16
|
+
expectedMode: number;
|
|
17
|
+
installIfMissing: boolean;
|
|
18
|
+
owner: string;
|
|
19
|
+
update: () => boolean;
|
|
20
|
+
manualCommand: string;
|
|
21
|
+
log?: (message: string) => void;
|
|
22
|
+
}
|
|
23
|
+
export declare function inspectHostArtifact(targetPath: string, desiredContent: Uint8Array, expectedMode: number): HostArtifactInspection;
|
|
24
|
+
export declare function reconcileHostArtifact(options: HostArtifactOptions): HostArtifactReconcileResult;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
function asError(error) {
|
|
3
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
4
|
+
}
|
|
5
|
+
export function inspectHostArtifact(targetPath, desiredContent, expectedMode) {
|
|
6
|
+
let stat;
|
|
7
|
+
try {
|
|
8
|
+
stat = statSync(targetPath);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error.code === "ENOENT")
|
|
12
|
+
return { state: "missing" };
|
|
13
|
+
return { state: "unreadable", error: asError(error) };
|
|
14
|
+
}
|
|
15
|
+
let installed;
|
|
16
|
+
try {
|
|
17
|
+
installed = readFileSync(targetPath);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
return { state: "unreadable", error: asError(error) };
|
|
21
|
+
}
|
|
22
|
+
const differences = [];
|
|
23
|
+
if (!installed.equals(Buffer.from(desiredContent)))
|
|
24
|
+
differences.push("content");
|
|
25
|
+
if ((stat.mode & 0o777) !== expectedMode)
|
|
26
|
+
differences.push("mode");
|
|
27
|
+
return differences.length === 0 ? { state: "current" } : { state: "drifted", differences };
|
|
28
|
+
}
|
|
29
|
+
export function reconcileHostArtifact(options) {
|
|
30
|
+
const log = options.log ?? console.log;
|
|
31
|
+
const before = inspectHostArtifact(options.targetPath, options.desiredContent, options.expectedMode);
|
|
32
|
+
if (before.state === "current")
|
|
33
|
+
return "current";
|
|
34
|
+
if (before.state === "missing" && !options.installIfMissing)
|
|
35
|
+
return "missing";
|
|
36
|
+
if (before.state === "unreadable") {
|
|
37
|
+
log(` ! could not verify ${options.targetPath} against ${options.owner}: ${before.error.message}\n` +
|
|
38
|
+
` Check it manually. To restore the package-owned file, run:\n` +
|
|
39
|
+
` ${options.manualCommand}`);
|
|
40
|
+
return "unreadable";
|
|
41
|
+
}
|
|
42
|
+
if (before.state === "drifted") {
|
|
43
|
+
log(` ! ${options.targetPath} differs from ${options.owner} ` +
|
|
44
|
+
`(${before.differences.join(" and ")}); replacing the installed file.`);
|
|
45
|
+
}
|
|
46
|
+
if (!options.update()) {
|
|
47
|
+
log(` ! could not update ${options.targetPath}. Run:\n ${options.manualCommand}`);
|
|
48
|
+
return "failed";
|
|
49
|
+
}
|
|
50
|
+
const after = inspectHostArtifact(options.targetPath, options.desiredContent, options.expectedMode);
|
|
51
|
+
if (after.state !== "current") {
|
|
52
|
+
log(` ! ${options.targetPath} is still not current after the update. Run:\n ${options.manualCommand}`);
|
|
53
|
+
return "failed";
|
|
54
|
+
}
|
|
55
|
+
log(` + ${before.state === "missing" ? "installed" : "refreshed"} ${options.targetPath} from ${options.owner}`);
|
|
56
|
+
return "updated";
|
|
57
|
+
}
|