@zenera/cli 1.1.0 → 1.1.2
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/commands/check.js +47 -14
- package/dist/commands/sandbox.js +226 -22
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +9 -2
- package/dist/image.d.ts +16 -0
- package/dist/image.js +85 -0
- package/dist/lib.d.ts +1 -1
- package/dist/lib.js +1 -1
- package/dist/main.js +0 -0
- package/dist/podman.d.ts +57 -1
- package/dist/podman.js +177 -12
- package/dist/projects.d.ts +18 -0
- package/dist/projects.js +60 -1
- package/dist/sandbox.d.ts +12 -1
- package/dist/sandbox.js +35 -6
- package/dist/scaffold.d.ts +6 -0
- package/dist/scaffold.js +49 -11
- package/dist/term.d.ts +2 -0
- package/dist/term.js +14 -0
- package/dist/validate.d.ts +20 -3
- package/dist/validate.js +306 -11
- package/package.json +2 -2
- package/templates/.github/copilot-instructions.md +154 -42
- package/templates/.github/prompts/new-skill.prompt.md +13 -6
- package/templates/sandbox/Dockerfile +19 -0
package/dist/commands/check.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { basename, resolve } from 'node:path';
|
|
1
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
2
2
|
import { one, parse } from "../args.js";
|
|
3
3
|
import { KeyStore } from "../keys.js";
|
|
4
4
|
import { Registry } from "../projects.js";
|
|
5
5
|
import { project as resolveProject } from "../resolve.js";
|
|
6
|
-
import { bold, count, cyan, dim, green, invalidError, json, red, table, write, writeAll, yellow, } from "../term.js";
|
|
6
|
+
import { bold, count, cyan, dim, green, invalidError, json, progress, red, table, write, writeAll, yellow, } from "../term.js";
|
|
7
7
|
import { validateProject, } from "../validate.js";
|
|
8
|
-
const USAGE = 'zen check [dir] [--project <name|dir>] [--strict] [--quiet]';
|
|
8
|
+
const USAGE = 'zen check [dir] [--project <name|dir>] [--no-sandbox] [--strict] [--quiet]';
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
// zen check
|
|
11
11
|
//
|
|
@@ -16,19 +16,27 @@ const USAGE = 'zen check [dir] [--project <name|dir>] [--strict] [--quiet]';
|
|
|
16
16
|
// stable code and a fix, and the whole thing goes to stdout — it is the answer,
|
|
17
17
|
// not narration.
|
|
18
18
|
//
|
|
19
|
-
// Nothing
|
|
20
|
-
//
|
|
21
|
-
//
|
|
19
|
+
// Nothing is contacted or paid for. The one thing that *runs* is the sandbox:
|
|
20
|
+
// the project's image is built and one command is executed in it, because a
|
|
21
|
+
// Dockerfile that does not build is a broken project and nothing short of
|
|
22
|
+
// building it says so. It happens against a temporary directory, the container
|
|
23
|
+
// is removed on the way out, and `--no-sandbox` skips it — so the report is
|
|
24
|
+
// still worth having on the machine that has no container engine at all.
|
|
22
25
|
// ---------------------------------------------------------------------------
|
|
23
26
|
export const check = {
|
|
24
27
|
summary: 'Validate agents.yaml and every file it names, and report in full.',
|
|
25
28
|
usage: USAGE,
|
|
26
29
|
details: [
|
|
27
|
-
'Checks the whole project
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
30
|
+
'Checks the whole project: the configuration parses and satisfies the',
|
|
31
|
+
'schema, every prompt, skill and catalog it names is on disk, hand-offs',
|
|
32
|
+
'and forks name agents that exist, tool selectors resolve, skills bind to',
|
|
33
|
+
'a catalog that holds them, and the models it declares have a credential',
|
|
34
|
+
'on this machine.',
|
|
35
|
+
'',
|
|
36
|
+
'It also builds the sandbox image and runs one command in it, against a',
|
|
37
|
+
'temporary directory rather than your workspace. That is the only thing',
|
|
38
|
+
'it starts, and --no-sandbox skips it. No container engine is a warning,',
|
|
39
|
+
'not an error.',
|
|
32
40
|
'',
|
|
33
41
|
'Unlike a run, it does not stop at the first problem — the report lists',
|
|
34
42
|
'everything it found, each with a code and the fix for it.',
|
|
@@ -39,6 +47,7 @@ export const check = {
|
|
|
39
47
|
run: async (ctx) => {
|
|
40
48
|
const { values, positionals } = parse(ctx.args, {
|
|
41
49
|
project: { type: 'string' },
|
|
50
|
+
'no-sandbox': { type: 'boolean' },
|
|
42
51
|
strict: { type: 'boolean' },
|
|
43
52
|
quiet: { type: 'boolean' },
|
|
44
53
|
}, USAGE);
|
|
@@ -58,12 +67,18 @@ export const check = {
|
|
|
58
67
|
// the same key by the time the library asks.
|
|
59
68
|
const keys = await KeyStore.open();
|
|
60
69
|
keys.materialize();
|
|
70
|
+
const bar = progress();
|
|
61
71
|
const report = await validateProject({
|
|
62
72
|
dir,
|
|
63
73
|
name,
|
|
64
74
|
registered: entry !== undefined,
|
|
65
75
|
keys,
|
|
76
|
+
sandbox: {
|
|
77
|
+
enabled: !values['no-sandbox'],
|
|
78
|
+
onProgress: (what) => bar.update(dim(what)),
|
|
79
|
+
},
|
|
66
80
|
});
|
|
81
|
+
bar.done();
|
|
67
82
|
if (ctx.json) {
|
|
68
83
|
json(report);
|
|
69
84
|
}
|
|
@@ -121,12 +136,22 @@ function render(report) {
|
|
|
121
136
|
? `${count(report.skills.entries.length, 'skill')} in ${report.skills.dirs.join(', ')}`
|
|
122
137
|
: 'no catalog')}`);
|
|
123
138
|
if (report.skills.entries.length) {
|
|
124
|
-
|
|
139
|
+
// A folder skill's own files travel with it: they are mounted at
|
|
140
|
+
// /skills/<folder> and the body may point the model straight at them,
|
|
141
|
+
// so say what is there and under which name.
|
|
142
|
+
const rows = table(report.skills.entries.map((s) => [
|
|
125
143
|
` ${s.name}`,
|
|
126
144
|
dim(s.path),
|
|
127
145
|
dim(s.tools?.length ? `unlocks ${s.tools.join(' ')}` : ''),
|
|
128
146
|
dim(s.usedBy.length ? `used by ${s.usedBy.join(', ')}` : 'unused'),
|
|
129
|
-
]))
|
|
147
|
+
]));
|
|
148
|
+
report.skills.entries.forEach((s, i) => {
|
|
149
|
+
push(rows[i] ?? '');
|
|
150
|
+
if (s.files?.length) {
|
|
151
|
+
const at = basename(dirname(s.path));
|
|
152
|
+
push(dim(` /skills/${at}/ ${s.files.join(' ')}`));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
130
155
|
}
|
|
131
156
|
// Models ----------------------------------------------------------------
|
|
132
157
|
push('', bold('Models'));
|
|
@@ -156,13 +181,21 @@ function render(report) {
|
|
|
156
181
|
push('', bold('Sandbox'));
|
|
157
182
|
push(...table([
|
|
158
183
|
[' image', report.sandbox.image ?? dim('the default image')],
|
|
184
|
+
...(report.sandbox.dockerfile
|
|
185
|
+
? [[' built from', report.sandbox.dockerfile]]
|
|
186
|
+
: []),
|
|
159
187
|
[
|
|
160
188
|
' reached by',
|
|
161
189
|
report.sandbox.used
|
|
162
190
|
? 'at least one agent has the shell tools'
|
|
163
191
|
: dim('nothing — the block is declared but no agent can run a command'),
|
|
164
192
|
],
|
|
165
|
-
[
|
|
193
|
+
[
|
|
194
|
+
' tried',
|
|
195
|
+
report.sandbox.probed
|
|
196
|
+
? green('built, started, and a command ran in it')
|
|
197
|
+
: dim('no — nothing was built or started'),
|
|
198
|
+
],
|
|
166
199
|
]));
|
|
167
200
|
}
|
|
168
201
|
// Findings --------------------------------------------------------------
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { readProjectConfig } from '@zenera/neo';
|
|
2
2
|
import { parse } from "../args.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveBuild } from "../image.js";
|
|
4
|
+
import { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, } from "../podman.js";
|
|
5
|
+
import { dirSize, isProjectDir, Registry, sessionIds } from "../projects.js";
|
|
4
6
|
import { project as findProject } from "../resolve.js";
|
|
5
|
-
import { bold, dim, green, json, note, red, usageError, write, yellow } from "../term.js";
|
|
6
|
-
const USAGE = 'zen sandbox [status|up|pull|clean] [options]';
|
|
7
|
+
import { ago, bold, bytes, dim, green, json, note, red, table, usageError, write, writeAll, yellow, } from "../term.js";
|
|
8
|
+
const USAGE = 'zen sandbox [status|up|pull|clean|disk] [options]';
|
|
9
|
+
/** Enough to see the pattern; the rest are a number. */
|
|
10
|
+
const LISTED = 6;
|
|
11
|
+
/** Under the labels, which is where the eye already is. */
|
|
12
|
+
const INDENT = ' '.repeat(11);
|
|
7
13
|
// ---------------------------------------------------------------------------
|
|
8
14
|
// The container engine, on its own
|
|
9
15
|
//
|
|
@@ -18,9 +24,10 @@ export const sandbox = {
|
|
|
18
24
|
usage: USAGE,
|
|
19
25
|
details: [
|
|
20
26
|
' status What is installed, running and pulled. Changes nothing.',
|
|
21
|
-
' up Install if asked, start the machine, pull the image.',
|
|
22
|
-
' pull Just the image.',
|
|
27
|
+
' up Install if asked, start the machine, pull or build the image.',
|
|
28
|
+
' pull Just the image: pulled, or built from the project\u2019s Dockerfile.',
|
|
23
29
|
' clean Remove every container this CLI created.',
|
|
30
|
+
' disk What the engine and every known project occupy.',
|
|
24
31
|
'',
|
|
25
32
|
' --project <name|dir> Which project the image comes from.',
|
|
26
33
|
' --image <ref> Use this image instead of the project\u2019s.',
|
|
@@ -34,22 +41,29 @@ export const sandbox = {
|
|
|
34
41
|
image: { type: 'string' },
|
|
35
42
|
}, USAGE);
|
|
36
43
|
const what = positionals[0] ?? 'status';
|
|
37
|
-
if (!['status', 'up', 'pull', 'clean'].includes(what)) {
|
|
44
|
+
if (!['status', 'up', 'pull', 'clean', 'disk'].includes(what)) {
|
|
38
45
|
throw usageError(`unknown subcommand: ${what}`, USAGE);
|
|
39
46
|
}
|
|
40
47
|
if (positionals.length > 1) {
|
|
41
48
|
throw usageError('one subcommand at a time', USAGE);
|
|
42
49
|
}
|
|
43
|
-
|
|
50
|
+
// `clean` and `disk` are machine-wide questions, so asking which
|
|
51
|
+
// project they mean would be asking something they do not use.
|
|
52
|
+
const scoped = what === 'status' || what === 'up' || what === 'pull';
|
|
53
|
+
const found = values.image || !scoped ? undefined : await projectSandbox(ctx.cwd, values);
|
|
54
|
+
const image = values.image ?? found?.image;
|
|
55
|
+
const build = found?.build;
|
|
44
56
|
switch (what) {
|
|
45
57
|
case 'status':
|
|
46
|
-
return status(image, ctx.json);
|
|
58
|
+
return status(image, build, ctx.json);
|
|
47
59
|
case 'up':
|
|
48
|
-
return up(image, ctx.json, ctx.json);
|
|
60
|
+
return up(image, build, ctx.json, ctx.json);
|
|
49
61
|
case 'pull':
|
|
50
|
-
return up(image, true, ctx.json);
|
|
62
|
+
return up(image, build, true, ctx.json, true);
|
|
51
63
|
case 'clean':
|
|
52
64
|
return clean(ctx.json);
|
|
65
|
+
case 'disk':
|
|
66
|
+
return disk(ctx.json);
|
|
53
67
|
}
|
|
54
68
|
},
|
|
55
69
|
};
|
|
@@ -59,20 +73,22 @@ export const sandbox = {
|
|
|
59
73
|
* not a failure — it just means there is no image to report on. Notably this
|
|
60
74
|
* does *not* go through `target`: reading a setting must not create a session.
|
|
61
75
|
*/
|
|
62
|
-
async function
|
|
76
|
+
async function projectSandbox(cwd, values) {
|
|
63
77
|
try {
|
|
64
78
|
const found = await findProject({ cwd, project: values.project, yes: true });
|
|
65
|
-
|
|
79
|
+
const { root, config } = readProjectConfig(found.dir);
|
|
80
|
+
const build = resolveBuild(root, config.sandbox);
|
|
81
|
+
return { image: build?.tag ?? config.sandbox?.image, build };
|
|
66
82
|
}
|
|
67
83
|
catch {
|
|
68
84
|
return undefined;
|
|
69
85
|
}
|
|
70
86
|
}
|
|
71
|
-
async function status(image, asJson) {
|
|
87
|
+
async function status(image, build, asJson) {
|
|
72
88
|
const found = await podmanStatus({ image });
|
|
73
89
|
const containers = found.ready ? await ownedContainers(found.engine) : [];
|
|
74
90
|
if (asJson) {
|
|
75
|
-
json({ ...found, containers });
|
|
91
|
+
json({ ...found, dockerfile: build?.dockerfile ?? null, containers });
|
|
76
92
|
return;
|
|
77
93
|
}
|
|
78
94
|
const mark = (ok) => (ok ? green('ok') : red('no'));
|
|
@@ -85,28 +101,216 @@ async function status(image, asJson) {
|
|
|
85
101
|
if (found.image) {
|
|
86
102
|
write(`${bold('image')} ${found.image} ${mark(Boolean(found.imagePresent))}`);
|
|
87
103
|
}
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
if (build) {
|
|
105
|
+
write(`${bold('dockerfile')} ${dim(build.dockerfile)}`);
|
|
106
|
+
}
|
|
107
|
+
writeAll(containerLines(containers));
|
|
90
108
|
if (!found.installed || !found.ready) {
|
|
91
109
|
note('');
|
|
92
110
|
note(dim('run `zen sandbox up` to fix what can be fixed.'));
|
|
93
111
|
}
|
|
94
112
|
}
|
|
95
|
-
|
|
96
|
-
|
|
113
|
+
/**
|
|
114
|
+
* One per line rather than one long line, because there is normally more than
|
|
115
|
+
* one and the interesting part — how old, and whether anything is still up —
|
|
116
|
+
* is at the end of a name too long to scan.
|
|
117
|
+
*
|
|
118
|
+
* The trailing note is there because the count surprises people: a container
|
|
119
|
+
* is per *session*, not per project, and `persist: true` is what leaves the
|
|
120
|
+
* stopped ones behind.
|
|
121
|
+
*/
|
|
122
|
+
function containerLines(containers) {
|
|
123
|
+
if (containers.length === 0) {
|
|
124
|
+
return [`${bold('containers')} ${dim('none')}`];
|
|
125
|
+
}
|
|
126
|
+
const running = containers.filter((c) => c.state === 'running').length;
|
|
127
|
+
const head = `${bold('containers')} ${containers.length} ${dim(running ? `· ${running} running` : '· none running')}`;
|
|
128
|
+
const rows = containers
|
|
129
|
+
.slice(0, LISTED)
|
|
130
|
+
.map((c) => [
|
|
131
|
+
INDENT.slice(2),
|
|
132
|
+
c.name,
|
|
133
|
+
c.state === 'running' ? green('running') : dim(c.state),
|
|
134
|
+
dim(ago(c.createdAt)),
|
|
135
|
+
]);
|
|
136
|
+
const rest = containers.length - LISTED;
|
|
137
|
+
return [
|
|
138
|
+
head,
|
|
139
|
+
...table(rows),
|
|
140
|
+
...(rest > 0 ? [`${INDENT}${dim(`+${rest} more`)}`] : []),
|
|
141
|
+
`${INDENT}${dim('one per session, kept by `persist: true` — see: zen sandbox disk')}`,
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
async function up(image, build, yes, asJson, rebuild = false) {
|
|
145
|
+
await ensurePodmanReady({ image, build, yes, rebuild });
|
|
97
146
|
if (asJson) {
|
|
98
|
-
json({ ready: true, image });
|
|
147
|
+
json({ ready: true, image, dockerfile: build?.dockerfile ?? null });
|
|
99
148
|
return;
|
|
100
149
|
}
|
|
101
150
|
write(`${green('ready')}${image ? ` ${dim(image)}` : ''}`);
|
|
102
151
|
}
|
|
103
152
|
async function clean(asJson) {
|
|
104
|
-
const
|
|
153
|
+
const containers = await ownedContainers(undefined, undefined, { sizes: true });
|
|
154
|
+
const names = containers.map((c) => c.name);
|
|
155
|
+
const freed = containers.reduce((n, c) => n + (c.size ?? 0), 0);
|
|
105
156
|
await removeContainers(names);
|
|
106
157
|
if (asJson) {
|
|
107
|
-
json({ removed: names });
|
|
158
|
+
json({ removed: names, freed });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (names.length === 0) {
|
|
162
|
+
write(dim('nothing to remove'));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
write(`removed ${names.length} ${dim(`· ${bytes(freed)} freed`)}`);
|
|
166
|
+
write(dim('images are left alone — see: zen sandbox disk'));
|
|
167
|
+
}
|
|
168
|
+
async function disk(asJson) {
|
|
169
|
+
const found = await podmanStatus();
|
|
170
|
+
const [usage, containers] = found.ready
|
|
171
|
+
? await Promise.all([
|
|
172
|
+
engineDisk(found.engine),
|
|
173
|
+
ownedContainers(found.engine, undefined, { sizes: true }),
|
|
174
|
+
])
|
|
175
|
+
: [undefined, []];
|
|
176
|
+
const { projects, loose } = await projectDisk(containers);
|
|
177
|
+
if (asJson) {
|
|
178
|
+
json({ engine: found.engine, ready: found.ready, ...usage, projects, unclaimed: loose });
|
|
108
179
|
return;
|
|
109
180
|
}
|
|
110
|
-
|
|
181
|
+
if (usage) {
|
|
182
|
+
write(`${bold('engine')} ${found.engine} ${dim(found.version ?? '')}`);
|
|
183
|
+
writeAll(engineRows(usage));
|
|
184
|
+
write('');
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
write(dim(`${found.engine} did not answer — projects only`));
|
|
188
|
+
write('');
|
|
189
|
+
}
|
|
190
|
+
writeAll(projectRows(projects, loose));
|
|
191
|
+
if (usage && usage.images.reclaimable > 0) {
|
|
192
|
+
write('');
|
|
193
|
+
write(dim(`zen sandbox clean every container above`));
|
|
194
|
+
write(dim(`podman image prune -a ${bytes(usage.images.reclaimable)} of unused images`));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function engineRows(usage) {
|
|
198
|
+
// Dimming an empty cell is not empty: it is two escape codes of nothing,
|
|
199
|
+
// which `table` cannot trim and which leave trailing whitespace behind.
|
|
200
|
+
const hint = (s) => (s ? dim(s) : '');
|
|
201
|
+
const rows = [
|
|
202
|
+
[
|
|
203
|
+
bold('images'),
|
|
204
|
+
String(usage.images.count),
|
|
205
|
+
bytes(usage.images.size),
|
|
206
|
+
hint(usage.images.reclaimable > 0 ? `${bytes(usage.images.reclaimable)} unused` : ''),
|
|
207
|
+
],
|
|
208
|
+
[
|
|
209
|
+
bold('containers'),
|
|
210
|
+
String(usage.containers.count),
|
|
211
|
+
bytes(usage.containers.size),
|
|
212
|
+
hint(usage.containers.active > 0 ? `${usage.containers.active} running` : ''),
|
|
213
|
+
],
|
|
214
|
+
[bold('volumes'), String(usage.volumes.count), bytes(usage.volumes.size), ''],
|
|
215
|
+
];
|
|
216
|
+
if (usage.store) {
|
|
217
|
+
rows.push([
|
|
218
|
+
bold('store'),
|
|
219
|
+
'',
|
|
220
|
+
bytes(usage.store.used),
|
|
221
|
+
hint(`of ${bytes(usage.store.capacity)}`),
|
|
222
|
+
]);
|
|
223
|
+
}
|
|
224
|
+
if (usage.image) {
|
|
225
|
+
// The one number that is actually gone from this host's disk. It is
|
|
226
|
+
// larger than the store's own `used` because freeing blocks inside the
|
|
227
|
+
// machine does not hand them back until something trims them.
|
|
228
|
+
rows.push([
|
|
229
|
+
bold('on this host'),
|
|
230
|
+
'',
|
|
231
|
+
bytes(usage.image.allocated),
|
|
232
|
+
hint(`${usage.image.name} disk image, which never shrinks on its own`),
|
|
233
|
+
]);
|
|
234
|
+
}
|
|
235
|
+
return table(rows);
|
|
236
|
+
}
|
|
237
|
+
function projectRows(projects, loose) {
|
|
238
|
+
if (projects.length === 0 && loose.length === 0) {
|
|
239
|
+
return [dim('no projects yet')];
|
|
240
|
+
}
|
|
241
|
+
const rows = [
|
|
242
|
+
[
|
|
243
|
+
bold('PROJECT'),
|
|
244
|
+
bold('SESSIONS'),
|
|
245
|
+
bold('ON DISK'),
|
|
246
|
+
bold('CONTAINERS'),
|
|
247
|
+
bold('IN PODMAN'),
|
|
248
|
+
'',
|
|
249
|
+
],
|
|
250
|
+
];
|
|
251
|
+
for (const p of projects) {
|
|
252
|
+
const style = p.present ? (s) => s : dim;
|
|
253
|
+
rows.push([
|
|
254
|
+
style(p.name),
|
|
255
|
+
style(String(p.sessions)),
|
|
256
|
+
style(bytes(p.files)),
|
|
257
|
+
style(p.containers ? String(p.containers) : dim('—')),
|
|
258
|
+
style(p.layers ? bytes(p.layers) : dim('—')),
|
|
259
|
+
p.present ? '' : dim('(missing)'),
|
|
260
|
+
]);
|
|
261
|
+
}
|
|
262
|
+
if (loose.length > 0) {
|
|
263
|
+
// Containers whose session directory is gone, and faker's, which are
|
|
264
|
+
// labelled the same way and belong to no project at all.
|
|
265
|
+
const size = loose.reduce((n, c) => n + (c.size ?? 0), 0);
|
|
266
|
+
rows.push([
|
|
267
|
+
dim('(unclaimed)'),
|
|
268
|
+
dim('—'),
|
|
269
|
+
dim('—'),
|
|
270
|
+
dim(String(loose.length)),
|
|
271
|
+
dim(bytes(size)),
|
|
272
|
+
dim('no session owns these'),
|
|
273
|
+
]);
|
|
274
|
+
}
|
|
275
|
+
const total = (pick) => projects.reduce((n, p) => n + pick(p), 0);
|
|
276
|
+
rows.push([
|
|
277
|
+
bold('total'),
|
|
278
|
+
bold(String(total((p) => p.sessions))),
|
|
279
|
+
bold(bytes(total((p) => p.files))),
|
|
280
|
+
bold(String(total((p) => p.containers) + loose.length)),
|
|
281
|
+
bold(bytes(total((p) => p.layers) + loose.reduce((n, c) => n + (c.size ?? 0), 0))),
|
|
282
|
+
'',
|
|
283
|
+
]);
|
|
284
|
+
return table(rows);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Containers carry the session id that made them, and a session id is a
|
|
288
|
+
* directory name under a project — so the label is enough to attribute one,
|
|
289
|
+
* with no second index to keep in step with reality.
|
|
290
|
+
*/
|
|
291
|
+
async function projectDisk(containers) {
|
|
292
|
+
const registry = await Registry.open();
|
|
293
|
+
const claimed = new Set();
|
|
294
|
+
const projects = [];
|
|
295
|
+
for (const entry of registry.entries) {
|
|
296
|
+
const present = isProjectDir(entry.path);
|
|
297
|
+
const sessions = new Set(present ? sessionIds(entry.path) : []);
|
|
298
|
+
const mine = containers.filter((c) => c.key !== undefined && sessions.has(c.key));
|
|
299
|
+
for (const c of mine) {
|
|
300
|
+
claimed.add(c.name);
|
|
301
|
+
}
|
|
302
|
+
projects.push({
|
|
303
|
+
name: entry.name,
|
|
304
|
+
path: entry.path,
|
|
305
|
+
present,
|
|
306
|
+
sessions: sessions.size,
|
|
307
|
+
files: present ? dirSize(entry.path) : 0,
|
|
308
|
+
containers: mine.length,
|
|
309
|
+
layers: mine.reduce((n, c) => n + (c.size ?? 0), 0),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
// By the column that is shown, so the order is one a reader can check.
|
|
313
|
+
projects.sort((a, b) => b.files - a.files);
|
|
314
|
+
return { projects, loose: containers.filter((c) => !claimed.has(c.name)) };
|
|
111
315
|
}
|
|
112
316
|
//# sourceMappingURL=sandbox.js.map
|
package/dist/engine.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AgentRunner, type AgentEvent, type AgentProject, type AgentState, type Input, type RunResult } from '@zenera/neo';
|
|
2
|
-
import type
|
|
2
|
+
import { type Project } from './projects.ts';
|
|
3
3
|
import { type SandboxSetup } from './sandbox.ts';
|
|
4
4
|
import { type Held, type RunPaths, type SessionPaths } from './session.ts';
|
|
5
5
|
export interface EngineOptions {
|
package/dist/engine.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { AgentRunner, FileMemoryStore, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, assertState, buildRunReport, exaTools, lastText, loadProject, readProjectConfig, renderReportHtml, sandboxTools, turns, workspaceTools, } from '@zenera/neo';
|
|
1
2
|
import { existsSync } from 'node:fs';
|
|
2
3
|
import { writeFile } from 'node:fs/promises';
|
|
3
4
|
import { resolve } from 'node:path';
|
|
4
|
-
import { AgentRunner, FileMemoryStore, FilePayloadStore, SANDBOX_MOUNT, assertState, buildRunReport, exaTools, lastText, loadProject, readProjectConfig, renderReportHtml, sandboxTools, turns, workspaceTools, } from '@zenera/neo';
|
|
5
5
|
import { auditModels, describeIssue } from "./audit.js";
|
|
6
6
|
import { readJson, writeJson } from "./home.js";
|
|
7
7
|
import { KeyStore, assertUsable } from "./keys.js";
|
|
8
|
+
import { projectMounts } from "./projects.js";
|
|
8
9
|
import { buildSandbox, preflight, teardown, usesSandbox } from "./sandbox.js";
|
|
9
10
|
import { acquire, createRun, readSessionMeta, writeSessionMeta, } from "./session.js";
|
|
10
11
|
import { CliError, EXIT, invalidError, warn } from "./term.js";
|
|
@@ -35,13 +36,17 @@ export async function open(opts) {
|
|
|
35
36
|
let sandbox;
|
|
36
37
|
let project;
|
|
37
38
|
try {
|
|
38
|
-
const { config } = readProjectConfig(opts.project.dir);
|
|
39
|
+
const { root, config } = readProjectConfig(opts.project.dir);
|
|
40
|
+
// Assets and the skill catalog are mounted for both, under one name.
|
|
41
|
+
const mounts = projectMounts(root, config);
|
|
39
42
|
sandbox = buildSandbox({
|
|
40
43
|
config,
|
|
44
|
+
root,
|
|
41
45
|
session: opts.session,
|
|
42
46
|
workspace,
|
|
43
47
|
readOnly: opts.readOnly,
|
|
44
48
|
image: opts.image,
|
|
49
|
+
mounts,
|
|
45
50
|
});
|
|
46
51
|
project = await loadProject(opts.project.dir, {
|
|
47
52
|
tools: [
|
|
@@ -52,6 +57,7 @@ export async function open(opts) {
|
|
|
52
57
|
root: workspace,
|
|
53
58
|
readOnly: opts.readOnly,
|
|
54
59
|
mount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
|
|
60
|
+
mounts,
|
|
55
61
|
}),
|
|
56
62
|
...sandboxTools(sandbox.pool),
|
|
57
63
|
// Registered whether or not a key exists: the credential is
|
|
@@ -60,6 +66,7 @@ export async function open(opts) {
|
|
|
60
66
|
// turn that tried.
|
|
61
67
|
...exaTools(),
|
|
62
68
|
],
|
|
69
|
+
skillsAt: SKILLS_MOUNT,
|
|
63
70
|
payloads,
|
|
64
71
|
memory,
|
|
65
72
|
});
|
package/dist/image.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type SandboxConfig } from '@zenera/neo';
|
|
2
|
+
export interface ResolvedBuild {
|
|
3
|
+
/** the image reference to run, and to build under */
|
|
4
|
+
tag: string;
|
|
5
|
+
/** absolute path to the Dockerfile */
|
|
6
|
+
dockerfile: string;
|
|
7
|
+
/** absolute path to the build context */
|
|
8
|
+
context: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* What the config's `build:` block means on this machine, or nothing if it has
|
|
12
|
+
* none. Both paths are resolved against the project root and refused if they
|
|
13
|
+
* escape it — a project is data someone else may have written.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveBuild(root: string, config?: SandboxConfig): ResolvedBuild | undefined;
|
|
16
|
+
//# sourceMappingURL=image.d.ts.map
|
package/dist/image.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { projectDir, projectFile } from '@zenera/neo';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Building the sandbox image
|
|
7
|
+
//
|
|
8
|
+
// `image:` names something to pull; `build:` names a Dockerfile to build. The
|
|
9
|
+
// library never learns the difference — it is handed a resolved reference and
|
|
10
|
+
// runs it — because building is a host concern with a container engine
|
|
11
|
+
// attached to it, and that is the line this CLI exists on the other side of.
|
|
12
|
+
//
|
|
13
|
+
// The tag is a function of what goes into the image, and it has to be: the
|
|
14
|
+
// container's name in @zenera/neo hashes `spec.image`, so a tag that stayed put
|
|
15
|
+
// while the Dockerfile changed would leave a `persist: true` container running
|
|
16
|
+
// last week's filesystem, with nothing anywhere saying so. Hashing the context
|
|
17
|
+
// as well as the Dockerfile means an edit to either yields a new tag, a new
|
|
18
|
+
// container name, and a build — which is the only honest answer.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/** Not scoped: a `/` or an `@` is not a legal image tag. */
|
|
21
|
+
const TAG = 'localhost/zenera-sandbox';
|
|
22
|
+
/**
|
|
23
|
+
* A build context is meant to be small — a Dockerfile and whatever it copies.
|
|
24
|
+
* Hashing a directory someone pointed at their whole home folder would hang
|
|
25
|
+
* before it was wrong, so it stops and says which key to narrow.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_CONTEXT_FILES = 2_000;
|
|
28
|
+
/**
|
|
29
|
+
* What the config's `build:` block means on this machine, or nothing if it has
|
|
30
|
+
* none. Both paths are resolved against the project root and refused if they
|
|
31
|
+
* escape it — a project is data someone else may have written.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveBuild(root, config) {
|
|
34
|
+
if (!config?.build) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const dockerfile = projectFile(root, config.build.dockerfile, 'sandbox.build.dockerfile');
|
|
38
|
+
const context = config.build.context
|
|
39
|
+
? projectDir(root, config.build.context, 'sandbox.build.context')
|
|
40
|
+
: dirname(dockerfile);
|
|
41
|
+
return { tag: `${TAG}:${digest(dockerfile, context)}`, dockerfile, context };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The content address of a build: the Dockerfile, then every file the build can
|
|
45
|
+
* see, by path and by content.
|
|
46
|
+
*
|
|
47
|
+
* `.dockerignore` is not read. The engine honours it and we do not, so an
|
|
48
|
+
* ignored file that changes yields a new tag and a build that produces the same
|
|
49
|
+
* image — wasteful, never wrong, and the alternative is reimplementing a match
|
|
50
|
+
* syntax whose disagreements would be silent.
|
|
51
|
+
*/
|
|
52
|
+
function digest(dockerfile, context) {
|
|
53
|
+
const hash = createHash('sha256');
|
|
54
|
+
hash.update(readFileSync(dockerfile));
|
|
55
|
+
for (const rel of walk(context)) {
|
|
56
|
+
// The separator is hashed as posix so the same tree tags the same on
|
|
57
|
+
// any host.
|
|
58
|
+
hash.update(`\0${rel.split(sep).join('/')}\0`);
|
|
59
|
+
hash.update(readFileSync(join(context, rel)));
|
|
60
|
+
}
|
|
61
|
+
return hash.digest('hex').slice(0, 12);
|
|
62
|
+
}
|
|
63
|
+
/** Every file under `dir`, relative and sorted, so the digest is stable. */
|
|
64
|
+
function walk(dir) {
|
|
65
|
+
const found = [];
|
|
66
|
+
const pending = [dir];
|
|
67
|
+
while (pending.length > 0) {
|
|
68
|
+
const at = pending.pop();
|
|
69
|
+
for (const entry of readdirSync(at, { withFileTypes: true })) {
|
|
70
|
+
const path = join(at, entry.name);
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
pending.push(path);
|
|
73
|
+
}
|
|
74
|
+
else if (entry.isFile()) {
|
|
75
|
+
found.push(relative(dir, path));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (found.length > MAX_CONTEXT_FILES) {
|
|
79
|
+
throw new Error(`sandbox.build.context: ${dir} holds more than ${MAX_CONTEXT_FILES} files — ` +
|
|
80
|
+
'point `context:` at the directory the build actually copies from');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return found.sort();
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=image.js.map
|
package/dist/lib.d.ts
CHANGED
|
@@ -4,6 +4,6 @@ export type { Command, Context } from './command.ts';
|
|
|
4
4
|
export { assertPrivate, ensureDir, ensureHome, home, paths, readJson, writeJson } from './home.ts';
|
|
5
5
|
export { assertNotEmpty, assertOwner, assertUsable, describe, isOwner, isProvider, keyId, KeyStore, mask, OWNERS, parseRef, PROVIDERS, SERVICES, SHAPES, type KeyCheck, type KeyEntry, type KeyOwner, type Liveness, type Provider, type Service, } from './keys.ts';
|
|
6
6
|
export { probe, probeAll } from './liveness.ts';
|
|
7
|
-
export { ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, type OwnedContainer, type PodmanOptions, type PodmanStatus, } from './podman.ts';
|
|
7
|
+
export { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, type DiskLine, type EngineDisk, type OwnedContainer, type PodmanOptions, type PodmanStatus, } from './podman.ts';
|
|
8
8
|
export { ago, bold, CliError, count, credentialError, cyan, dim, EXIT, fail, green, invalidError, isInteractive, json, note, pad, red, table, usageError, warn, write, writeAll, yellow, type ExitCode, } from './term.ts';
|
|
9
9
|
//# sourceMappingURL=lib.d.ts.map
|
package/dist/lib.js
CHANGED
|
@@ -26,6 +26,6 @@ export { printBanner } from "./banner.js";
|
|
|
26
26
|
export { assertPrivate, ensureDir, ensureHome, home, paths, readJson, writeJson } from "./home.js";
|
|
27
27
|
export { assertNotEmpty, assertOwner, assertUsable, describe, isOwner, isProvider, keyId, KeyStore, mask, OWNERS, parseRef, PROVIDERS, SERVICES, SHAPES, } from "./keys.js";
|
|
28
28
|
export { probe, probeAll } from "./liveness.js";
|
|
29
|
-
export { ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, } from "./podman.js";
|
|
29
|
+
export { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, } from "./podman.js";
|
|
30
30
|
export { ago, bold, CliError, count, credentialError, cyan, dim, EXIT, fail, green, invalidError, isInteractive, json, note, pad, red, table, usageError, warn, write, writeAll, yellow, } from "./term.js";
|
|
31
31
|
//# sourceMappingURL=lib.js.map
|
package/dist/main.js
CHANGED
|
File without changes
|