@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.
@@ -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 here is contacted, started or paid for. That is the point: the check
20
- // has to work on the machine that is not set up yet, which is the machine that
21
- // most needs it.
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 without running anything: the configuration',
28
- 'parses and satisfies the schema, every prompt, skill and catalog it',
29
- 'names is on disk, hand-offs and forks name agents that exist, tool',
30
- 'selectors resolve, skills bind to a catalog that holds them, and the',
31
- 'models it declares have a credential on this machine.',
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
- push(...table(report.skills.entries.map((s) => [
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
- [' requires', dim('podman on this machine: zen sandbox status')],
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 --------------------------------------------------------------
@@ -1,9 +1,15 @@
1
1
  import { readProjectConfig } from '@zenera/neo';
2
2
  import { parse } from "../args.js";
3
- import { ensurePodmanReady, ownedContainers, podmanStatus, removeContainers } from "../podman.js";
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
- const image = values.image ?? (await projectImage(ctx.cwd, values));
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 projectImage(cwd, values) {
76
+ async function projectSandbox(cwd, values) {
63
77
  try {
64
78
  const found = await findProject({ cwd, project: values.project, yes: true });
65
- return readProjectConfig(found.dir).config.sandbox?.image;
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
- const listed = containers.map((c) => c.state === 'running' ? `${c.name} ${green('running')}` : `${c.name} ${dim(c.state)}`);
89
- write(`${bold('containers')} ${listed.length ? listed.join(', ') : dim('none')}`);
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
- async function up(image, yes, asJson) {
96
- await ensurePodmanReady({ image, yes });
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 names = (await ownedContainers()).map((c) => c.name);
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
- write(names.length ? `removed ${names.length}: ${names.join(', ')}` : dim('nothing to remove'));
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 { Project } from './projects.ts';
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
  });
@@ -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