@zenera/cli 1.1.36 → 1.1.39

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.
Files changed (72) hide show
  1. package/README.md +158 -900
  2. package/dist/archive.js +1 -1
  3. package/dist/batch.js +2 -3
  4. package/dist/cache.js +1 -1
  5. package/dist/candidates.js +1 -1
  6. package/dist/catalog.d.ts +1 -1
  7. package/dist/catalog.js +1 -1
  8. package/dist/commands/cache.js +1 -1
  9. package/dist/commands/check.js +1 -3
  10. package/dist/commands/export.js +1 -3
  11. package/dist/commands/import.js +1 -2
  12. package/dist/commands/index.js +6 -7
  13. package/dist/commands/init.js +1 -4
  14. package/dist/commands/inspect.d.ts +1 -1
  15. package/dist/commands/inspect.js +1 -4
  16. package/dist/commands/key.js +1 -2
  17. package/dist/commands/list.js +4 -5
  18. package/dist/commands/memory.js +1 -2
  19. package/dist/commands/meta.js +2 -4
  20. package/dist/commands/models.d.ts +1 -1
  21. package/dist/commands/models.js +1 -2
  22. package/dist/commands/run.js +2 -2
  23. package/dist/commands/sandbox.js +6 -7
  24. package/dist/engine.d.ts +3 -90
  25. package/dist/engine.js +16 -302
  26. package/dist/external.js +1 -1
  27. package/dist/history.js +1 -1
  28. package/dist/lib.d.ts +1 -2
  29. package/dist/lib.js +1 -2
  30. package/dist/liveness.d.ts +1 -1
  31. package/dist/liveness.js +1 -1
  32. package/dist/meta/host.d.ts +1 -2
  33. package/dist/meta/host.js +1 -2
  34. package/dist/modelref.d.ts +1 -1
  35. package/dist/podman.d.ts +1 -1
  36. package/dist/podman.js +4 -4
  37. package/dist/resolve.d.ts +2 -2
  38. package/dist/resolve.js +2 -3
  39. package/dist/resources.d.ts +2 -2
  40. package/dist/resources.js +6 -6
  41. package/dist/sandbox.d.ts +2 -60
  42. package/dist/sandbox.js +1 -200
  43. package/dist/term.d.ts +1 -22
  44. package/dist/term.js +3 -26
  45. package/dist/tui/app.js +1 -1
  46. package/dist/tui/models.d.ts +1 -1
  47. package/dist/validate.d.ts +1 -2
  48. package/dist/validate.js +1 -3
  49. package/package.json +7 -6
  50. package/templates/editor/.github/skills/zen-cli/SKILL.md +3 -3
  51. package/templates/editor/.github/skills/zen-cli/references/frame.md +2 -2
  52. package/templates/editor/.github/skills/zen-cli/references/{faker.md → gym.md} +45 -40
  53. package/templates/editor/.github/skills/zen-cli/references/keys.md +2 -2
  54. package/templates/editor/.github/skills/zen-cli/references/models.md +1 -1
  55. package/templates/editor/.github/skills/zen-cli/references/rag.md +1 -1
  56. package/templates/editor/.vscode/agents.schema.json +1 -1
  57. package/dist/audit.d.ts +0 -58
  58. package/dist/audit.js +0 -148
  59. package/dist/env.d.ts +0 -20
  60. package/dist/env.js +0 -65
  61. package/dist/home.d.ts +0 -35
  62. package/dist/home.js +0 -112
  63. package/dist/ids.d.ts +0 -12
  64. package/dist/ids.js +0 -44
  65. package/dist/image.d.ts +0 -16
  66. package/dist/image.js +0 -85
  67. package/dist/keys.d.ts +0 -269
  68. package/dist/keys.js +0 -624
  69. package/dist/projects.d.ts +0 -91
  70. package/dist/projects.js +0 -303
  71. package/dist/session.d.ts +0 -112
  72. package/dist/session.js +0 -188
package/dist/audit.d.ts DELETED
@@ -1,58 +0,0 @@
1
- import { type ModelRequirement } from '@zenera/neo';
2
- import { type KeyOwner, type KeyStore } from './keys.ts';
3
- /** Which key of the config named a reference: `model:`/`models:` or `embedding:`/`embeddings:`. */
4
- export type DeclaredRole = 'model' | 'embedding';
5
- export interface ModelIssue {
6
- /** the model as the config names it: an alias, or the ref itself */
7
- name: string;
8
- /** what the config declared it for */
9
- role: DeclaredRole;
10
- /** the provider it resolves to */
11
- provider: string;
12
- /** the variable that would carry the credential */
13
- env: string;
14
- /** `missing` — nothing to authenticate with. `dead` — rejected when checked.
15
- * `blocked` — accepted, and then refused by the account behind it. */
16
- reason: 'missing' | 'dead' | 'blocked';
17
- /** the provider's own words, when it was the one to say no */
18
- detail?: string;
19
- /** what to do about a refusal that a new key would not fix */
20
- fix?: string;
21
- /** the keyring provider the fix names, when the kind is one of them */
22
- add?: KeyOwner;
23
- }
24
- /**
25
- * Whether a requirement is actually met, and under which variable.
26
- *
27
- * `satisfied` is the library's answer and it is about api keys, which is the
28
- * wrong question for Vertex: that kind is `keyOptional` because it can
29
- * authenticate from a service-account file instead. So both of its variables
30
- * are looked for, along with the file `gcloud` leaves behind.
31
- *
32
- * The variable named back is the one that is actually set, because a report
33
- * that named `VERTEX_API_KEY` while the audit silently checked
34
- * `GOOGLE_APPLICATION_CREDENTIALS` would be two answers to one question, and
35
- * the wrong one is the one people would act on. With nothing set there is no
36
- * such answer, so it names the form the provider is usually reached by.
37
- */
38
- export declare function credentialFor(need: ModelRequirement): {
39
- env: string;
40
- present: boolean;
41
- };
42
- /**
43
- * Reports the models a run could not reach. Best effort by design: a config
44
- * that will not parse, or that names a provider that does not exist, is the
45
- * loader's to report — precisely, and with the offending key named. Guessing
46
- * at it here would only produce a worse version of the same message.
47
- *
48
- * Call *after* `KeyStore.materialize()`, so the keyring's keys are as visible
49
- * here as they will be to the library.
50
- */
51
- export declare function auditModels(projectDir: string, store: KeyStore): ModelIssue[];
52
- /**
53
- * One line, and the command that fixes it. A warning nobody can act on is
54
- * noise, so the fix is part of the sentence rather than something to go and
55
- * look up.
56
- */
57
- export declare function describeIssue(issue: ModelIssue): string;
58
- //# sourceMappingURL=audit.d.ts.map
package/dist/audit.js DELETED
@@ -1,148 +0,0 @@
1
- import { projectRegistry, readProjectConfig, } from '@zenera/neo';
2
- import { envNames, form, gcloudAdc, isOwner, isProvider, SHAPES, } from "./keys.js";
3
- import { bold, dim } from "./term.js";
4
- /**
5
- * Every model the project names, keyed by how one would refer to it.
6
- *
7
- * The `models:` table comes first so a declared alias keeps its own name in the
8
- * report; a `model:` that names one collapses onto it rather than appearing
9
- * twice, and a `model:` that names no alias is a shorthand standing for itself.
10
- *
11
- * Embeddings are walked the same way and kept in their own list: an alias may
12
- * legitimately appear under both keys meaning two different things, and they
13
- * are two credentials to check either way.
14
- */
15
- function declared(config) {
16
- // The schema widens `reasoningEffort` to `string` on purpose, which is the
17
- // one thing keeping a config's model spec from being a `ModelSpec`. The
18
- // loader hands the same cast to `models.model()` for the same reason.
19
- const models = new Map(Object.entries(config.models ?? {}));
20
- const add = (into, ref) => {
21
- if (ref && !into.has(ref)) {
22
- into.set(ref, ref);
23
- }
24
- };
25
- add(models, config.model);
26
- for (const agent of config.agents) {
27
- add(models, agent.model);
28
- }
29
- const embeddings = new Map(Object.entries(config.embeddings ?? {}));
30
- add(embeddings, config.embedding);
31
- return new Map([
32
- ['model', models],
33
- ['embedding', embeddings],
34
- ]);
35
- }
36
- /**
37
- * Whether a requirement is actually met, and under which variable.
38
- *
39
- * `satisfied` is the library's answer and it is about api keys, which is the
40
- * wrong question for Vertex: that kind is `keyOptional` because it can
41
- * authenticate from a service-account file instead. So both of its variables
42
- * are looked for, along with the file `gcloud` leaves behind.
43
- *
44
- * The variable named back is the one that is actually set, because a report
45
- * that named `VERTEX_API_KEY` while the audit silently checked
46
- * `GOOGLE_APPLICATION_CREDENTIALS` would be two answers to one question, and
47
- * the wrong one is the one people would act on. With nothing set there is no
48
- * such answer, so it names the form the provider is usually reached by.
49
- */
50
- export function credentialFor(need) {
51
- if (need.gateway) {
52
- return { env: need.apiKeyEnv, present: need.satisfied };
53
- }
54
- const provider = isProvider(need.kind) ? need.kind : undefined;
55
- // A provider with a file form can authenticate without an api key, which is
56
- // the only thing `satisfied` knows how to look for.
57
- if (provider && SHAPES[provider].forms.some((f) => f.holds === 'file')) {
58
- const set = envNames(provider).find((name) => process.env[name]);
59
- return { env: set ?? form(provider).env, present: Boolean(set) || Boolean(gcloudAdc()) };
60
- }
61
- return { env: need.apiKeyEnv, present: need.satisfied };
62
- }
63
- /**
64
- * Reports the models a run could not reach. Best effort by design: a config
65
- * that will not parse, or that names a provider that does not exist, is the
66
- * loader's to report — precisely, and with the offending key named. Guessing
67
- * at it here would only produce a worse version of the same message.
68
- *
69
- * Call *after* `KeyStore.materialize()`, so the keyring's keys are as visible
70
- * here as they will be to the library.
71
- */
72
- export function auditModels(projectDir, store) {
73
- let config;
74
- try {
75
- config = readProjectConfig(projectDir).config;
76
- }
77
- catch {
78
- return [];
79
- }
80
- const registry = projectRegistry(config);
81
- const issues = [];
82
- for (const [role, refs] of declared(config)) {
83
- for (const [name, ref] of refs) {
84
- let need;
85
- try {
86
- need = registry.requirement(ref);
87
- }
88
- catch {
89
- continue;
90
- }
91
- const { env, present } = credentialFor(need);
92
- const provider = need.gateway && isOwner(need.gateway)
93
- ? need.gateway
94
- : isProvider(need.kind)
95
- ? need.kind
96
- : undefined;
97
- if (!present) {
98
- issues.push({
99
- name,
100
- role,
101
- provider: need.provider,
102
- env,
103
- reason: 'missing',
104
- add: provider,
105
- });
106
- continue;
107
- }
108
- // A key that the provider itself rejected last time it was asked.
109
- // Said as a warning rather than an error because a key can be
110
- // reinstated between the check and the run, and a stale verdict must
111
- // not be the thing that stops a run from being attempted.
112
- const check = provider ? store.active(provider)?.check : undefined;
113
- if (check?.state === 'dead' || check?.state === 'blocked') {
114
- issues.push({
115
- name,
116
- role,
117
- provider: need.provider,
118
- env,
119
- reason: check.state,
120
- detail: check.detail,
121
- fix: check.fix,
122
- add: provider,
123
- });
124
- }
125
- }
126
- }
127
- return issues;
128
- }
129
- /**
130
- * One line, and the command that fixes it. A warning nobody can act on is
131
- * noise, so the fix is part of the sentence rather than something to go and
132
- * look up.
133
- */
134
- export function describeIssue(issue) {
135
- const what = `${bold(issue.name)} (${issue.provider}${issue.role === 'embedding' ? ', embedding' : ''})`;
136
- if (issue.reason === 'missing') {
137
- const fix = issue.add ? `zen key add ${issue.add}` : `set ${issue.env}`;
138
- return `${what} has no credential — ${issue.env} is not set; ${dim(fix)}`;
139
- }
140
- const why = issue.detail ? `: ${issue.detail}` : '';
141
- if (issue.reason === 'blocked') {
142
- const list = `zen models ls --${issue.role === 'embedding' ? 'embeddings' : 'chat'}`;
143
- return `${what} authenticated and was then refused${why} — ${dim(issue.fix ?? list)}`;
144
- }
145
- const fix = `zen key check ${issue.add ?? ''}`.trim();
146
- return `${what} was rejected when last checked${why} — ${dim(fix)}`;
147
- }
148
- //# sourceMappingURL=audit.js.map
package/dist/env.d.ts DELETED
@@ -1,20 +0,0 @@
1
- /** The file, relative to the project directory. */
2
- export declare const ENV_FILE = ".env";
3
- export interface ProjectEnv {
4
- /** the file it was read from */
5
- path: string;
6
- /** every name the file declares, whether or not its value won */
7
- names: string[];
8
- }
9
- /**
10
- * Reads `<dir>/.env` into `process.env`, without overwriting anything already
11
- * set, and returns the names it found. A project with no such file is not a
12
- * problem to report — most have none — so the answer is simply `undefined`.
13
- *
14
- * The names are returned in full, including the ones the environment already
15
- * answered: the variable is in play either way, and the sandbox forwards by
16
- * name, so what it needs to know is which names matter and not which of the
17
- * two sources supplied them.
18
- */
19
- export declare function loadProjectEnv(dir: string): ProjectEnv | undefined;
20
- //# sourceMappingURL=env.d.ts.map
package/dist/env.js DELETED
@@ -1,65 +0,0 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { isAbsolute, join, resolve } from 'node:path';
3
- import { parseEnv } from 'node:util';
4
- import { FILE_ENVS } from "./keys.js";
5
- // ---------------------------------------------------------------------------
6
- // The project's `.env`
7
- //
8
- // A project is a directory, and some of what it needs to run is particular to
9
- // it: the token for the service it talks to, the base url of the environment
10
- // it talks to it in. The keyring answers "what can this machine pay for"; this
11
- // answers "what does this project need", and the two are different questions
12
- // often enough to be worth two places.
13
- //
14
- // Precedence is the same one everything else here uses — the real environment
15
- // always wins. What is already set in the shell is left alone, this file fills
16
- // the gaps, and the keyring fills whatever is still missing after that. So a
17
- // variable exported for one command still overrides the file, which is the
18
- // behaviour anybody typing `FOO=bar zen run` is expecting.
19
- //
20
- // The names, not the values, are what leaves this module: they are what the
21
- // sandbox forwards, and forwarding by name is what keeps a secret out of an
22
- // argv. See `forwarded()` in sandbox.ts for the rest of that story.
23
- // ---------------------------------------------------------------------------
24
- /** The file, relative to the project directory. */
25
- export const ENV_FILE = '.env';
26
- /**
27
- * Reads `<dir>/.env` into `process.env`, without overwriting anything already
28
- * set, and returns the names it found. A project with no such file is not a
29
- * problem to report — most have none — so the answer is simply `undefined`.
30
- *
31
- * The names are returned in full, including the ones the environment already
32
- * answered: the variable is in play either way, and the sandbox forwards by
33
- * name, so what it needs to know is which names matter and not which of the
34
- * two sources supplied them.
35
- */
36
- export function loadProjectEnv(dir) {
37
- const path = join(dir, ENV_FILE);
38
- if (!existsSync(path)) {
39
- return undefined;
40
- }
41
- const names = [];
42
- for (const [name, value] of Object.entries(parseEnv(readFileSync(path, 'utf8')))) {
43
- if (value === undefined) {
44
- continue;
45
- }
46
- names.push(name);
47
- // An empty string is not an answer: tools test for absence, and a
48
- // commented-out line someone half-filled in should not shadow a key
49
- // the keyring has.
50
- if (!process.env[name]) {
51
- process.env[name] = located(dir, name, value);
52
- }
53
- }
54
- return { path, names };
55
- }
56
- /**
57
- * A service-account file is normally kept beside the `.env` that names it, so
58
- * a relative path there means relative to the *project* — not to wherever the
59
- * command happened to be run, which is what both the Google SDK and podman's
60
- * bind mount would otherwise make of it.
61
- */
62
- function located(dir, name, value) {
63
- return FILE_ENVS.includes(name) && !isAbsolute(value) ? resolve(dir, value) : value;
64
- }
65
- //# sourceMappingURL=env.js.map
package/dist/home.d.ts DELETED
@@ -1,35 +0,0 @@
1
- export declare function home(): string;
2
- export declare const paths: {
3
- home: typeof home;
4
- projects: () => string;
5
- keys: () => string;
6
- keyDir: () => string;
7
- faker: () => string;
8
- /** what the meta agent runs on, which is a choice about a tool, not a project */
9
- meta: () => string;
10
- /** work already done and worth keeping: vectors, parses, model listings */
11
- cache: () => string;
12
- };
13
- /** Creates a directory owner-only, and leaves an existing one's mode alone. */
14
- export declare function ensureDir(dir: string, mode?: number): string;
15
- export declare function ensureHome(): string;
16
- /**
17
- * Refuses to read a credential file that anyone else can read, the way `ssh`
18
- * does. A world-readable key is not a warning-level event: warnings are
19
- * ignored, and the whole point of the store is that the secret is not lying
20
- * around in the open.
21
- *
22
- * Windows reports a mode that means nothing here, so the check is POSIX-only.
23
- */
24
- export declare function assertPrivate(path: string): void;
25
- export declare function readJson<T>(path: string, fallback: T): Promise<T>;
26
- /**
27
- * Write to a sibling and rename. A crash then leaves either the old file or the
28
- * new one, never a half-written registry — which matters more here than it
29
- * looks, because the alternative is a corrupt `keys.json` locking someone out
30
- * of every provider at once.
31
- *
32
- * Synchronous on purpose: this is also called from exit paths.
33
- */
34
- export declare function writeJson(path: string, value: unknown, mode?: number): void;
35
- //# sourceMappingURL=home.d.ts.map
package/dist/home.js DELETED
@@ -1,112 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { chmodSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
- import { readFile } from 'node:fs/promises';
4
- import { homedir } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { CliError, EXIT } from "./term.js";
7
- // ---------------------------------------------------------------------------
8
- // The home directory
9
- //
10
- // ~/.zenera/neo holds exactly two kinds of thing: credentials, which belong to
11
- // the machine and never to a project, and an index of where projects are, which
12
- // is a convenience and is allowed to be wrong.
13
- //
14
- // `ZENERA_HOME` moves the whole tree. That is what makes any of this testable
15
- // and what lets CI start from an empty one.
16
- // ---------------------------------------------------------------------------
17
- const DIR_MODE = 0o700;
18
- const FILE_MODE = 0o600;
19
- export function home() {
20
- const override = process.env.ZENERA_HOME?.trim();
21
- return override ? override : join(homedir(), '.zenera', 'neo');
22
- }
23
- export const paths = {
24
- home,
25
- projects: () => join(home(), 'projects.json'),
26
- keys: () => join(home(), 'keys.json'),
27
- keyDir: () => join(home(), 'keys'),
28
- faker: () => join(home(), 'faker'),
29
- /** what the meta agent runs on, which is a choice about a tool, not a project */
30
- meta: () => join(home(), 'meta.json'),
31
- /** work already done and worth keeping: vectors, parses, model listings */
32
- cache: () => join(home(), 'cache'),
33
- };
34
- /** Creates a directory owner-only, and leaves an existing one's mode alone. */
35
- export function ensureDir(dir, mode = DIR_MODE) {
36
- mkdirSync(dir, { recursive: true, mode });
37
- return dir;
38
- }
39
- export function ensureHome() {
40
- return ensureDir(home());
41
- }
42
- // ---------------------------------------------------------------------------
43
- // Permissions
44
- // ---------------------------------------------------------------------------
45
- /**
46
- * Refuses to read a credential file that anyone else can read, the way `ssh`
47
- * does. A world-readable key is not a warning-level event: warnings are
48
- * ignored, and the whole point of the store is that the secret is not lying
49
- * around in the open.
50
- *
51
- * Windows reports a mode that means nothing here, so the check is POSIX-only.
52
- */
53
- export function assertPrivate(path) {
54
- if (process.platform === 'win32') {
55
- return;
56
- }
57
- let mode;
58
- try {
59
- mode = statSync(path).mode;
60
- }
61
- catch {
62
- return; // absent is fine; it will be created with the right mode
63
- }
64
- const open = mode & 0o077;
65
- if (open !== 0) {
66
- const octal = (mode & 0o777).toString(8).padStart(3, '0');
67
- throw new CliError(`permissions ${octal} on ${path} are too open`, EXIT.credentials, `run: chmod 600 ${path}`);
68
- }
69
- }
70
- // ---------------------------------------------------------------------------
71
- // JSON files
72
- // ---------------------------------------------------------------------------
73
- export async function readJson(path, fallback) {
74
- let text;
75
- try {
76
- text = await readFile(path, 'utf8');
77
- }
78
- catch (err) {
79
- if (err.code === 'ENOENT') {
80
- return fallback;
81
- }
82
- throw err;
83
- }
84
- try {
85
- return JSON.parse(text);
86
- }
87
- catch {
88
- throw new CliError(`${path} is not valid JSON`, EXIT.invalid, 'fix or delete the file');
89
- }
90
- }
91
- /**
92
- * Write to a sibling and rename. A crash then leaves either the old file or the
93
- * new one, never a half-written registry — which matters more here than it
94
- * looks, because the alternative is a corrupt `keys.json` locking someone out
95
- * of every provider at once.
96
- *
97
- * Synchronous on purpose: this is also called from exit paths.
98
- */
99
- export function writeJson(path, value, mode = FILE_MODE) {
100
- ensureDir(dirname(path));
101
- const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
102
- try {
103
- writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode });
104
- chmodSync(tmp, mode);
105
- renameSync(tmp, path);
106
- }
107
- catch (err) {
108
- rmSync(tmp, { force: true });
109
- throw err;
110
- }
111
- }
112
- //# sourceMappingURL=home.js.map
package/dist/ids.d.ts DELETED
@@ -1,12 +0,0 @@
1
- export declare function stamp(now?: Date): string;
2
- /**
3
- * Ids arrive from `--session` and `--run` and become path segments, so they are
4
- * checked rather than trusted. The shape has no `.` and no separator, which
5
- * makes traversal unrepresentable rather than merely rejected.
6
- */
7
- export declare function isStamp(value: string): boolean;
8
- /** Human-readable form of a stamp: `2026-08-25 14:30:12`. */
9
- export declare function stampDate(id: string): string;
10
- /** ISO instant a stamp names, for `ago()`. Local time in, local time out. */
11
- export declare function stampInstant(id: string): string | undefined;
12
- //# sourceMappingURL=ids.d.ts.map
package/dist/ids.js DELETED
@@ -1,44 +0,0 @@
1
- import { randomBytes } from 'node:crypto';
2
- // ---------------------------------------------------------------------------
3
- // Identifiers
4
- //
5
- // `YYYYMMDD-HHMMSS-xxxx`, local time, four hex characters of entropy.
6
- //
7
- // Sortable as a plain string, readable without a decoder, and collision-free
8
- // when two runs start inside the same second. An epoch integer gives up the
9
- // first two properties and a uuid gives up all three.
10
- // ---------------------------------------------------------------------------
11
- const STAMP = /^\d{8}-\d{6}-[0-9a-f]{4}$/;
12
- export function stamp(now = new Date()) {
13
- const p = (n, w = 2) => String(n).padStart(w, '0');
14
- const date = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}`;
15
- const time = `${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
16
- return `${date}-${time}-${randomBytes(2).toString('hex')}`;
17
- }
18
- /**
19
- * Ids arrive from `--session` and `--run` and become path segments, so they are
20
- * checked rather than trusted. The shape has no `.` and no separator, which
21
- * makes traversal unrepresentable rather than merely rejected.
22
- */
23
- export function isStamp(value) {
24
- return STAMP.test(value);
25
- }
26
- /** Human-readable form of a stamp: `2026-08-25 14:30:12`. */
27
- export function stampDate(id) {
28
- if (!isStamp(id)) {
29
- return id;
30
- }
31
- const [d, t] = id.split('-');
32
- return (`${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)} ` +
33
- `${t.slice(0, 2)}:${t.slice(2, 4)}:${t.slice(4, 6)}`);
34
- }
35
- /** ISO instant a stamp names, for `ago()`. Local time in, local time out. */
36
- export function stampInstant(id) {
37
- if (!isStamp(id)) {
38
- return undefined;
39
- }
40
- const [d, t] = id.split('-');
41
- const at = new Date(Number(d.slice(0, 4)), Number(d.slice(4, 6)) - 1, Number(d.slice(6, 8)), Number(t.slice(0, 2)), Number(t.slice(2, 4)), Number(t.slice(4, 6)));
42
- return Number.isNaN(at.getTime()) ? undefined : at.toISOString();
43
- }
44
- //# sourceMappingURL=ids.js.map
package/dist/image.d.ts DELETED
@@ -1,16 +0,0 @@
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 DELETED
@@ -1,85 +0,0 @@
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