@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.
- package/README.md +158 -900
- package/dist/archive.js +1 -1
- package/dist/batch.js +2 -3
- package/dist/cache.js +1 -1
- package/dist/candidates.js +1 -1
- package/dist/catalog.d.ts +1 -1
- package/dist/catalog.js +1 -1
- package/dist/commands/cache.js +1 -1
- package/dist/commands/check.js +1 -3
- package/dist/commands/export.js +1 -3
- package/dist/commands/import.js +1 -2
- package/dist/commands/index.js +6 -7
- package/dist/commands/init.js +1 -4
- package/dist/commands/inspect.d.ts +1 -1
- package/dist/commands/inspect.js +1 -4
- package/dist/commands/key.js +1 -2
- package/dist/commands/list.js +4 -5
- package/dist/commands/memory.js +1 -2
- package/dist/commands/meta.js +2 -4
- package/dist/commands/models.d.ts +1 -1
- package/dist/commands/models.js +1 -2
- package/dist/commands/run.js +2 -2
- package/dist/commands/sandbox.js +6 -7
- package/dist/engine.d.ts +3 -90
- package/dist/engine.js +16 -302
- package/dist/external.js +1 -1
- package/dist/history.js +1 -1
- package/dist/lib.d.ts +1 -2
- package/dist/lib.js +1 -2
- package/dist/liveness.d.ts +1 -1
- package/dist/liveness.js +1 -1
- package/dist/meta/host.d.ts +1 -2
- package/dist/meta/host.js +1 -2
- package/dist/modelref.d.ts +1 -1
- package/dist/podman.d.ts +1 -1
- package/dist/podman.js +4 -4
- package/dist/resolve.d.ts +2 -2
- package/dist/resolve.js +2 -3
- package/dist/resources.d.ts +2 -2
- package/dist/resources.js +6 -6
- package/dist/sandbox.d.ts +2 -60
- package/dist/sandbox.js +1 -200
- package/dist/term.d.ts +1 -22
- package/dist/term.js +3 -26
- package/dist/tui/app.js +1 -1
- package/dist/tui/models.d.ts +1 -1
- package/dist/validate.d.ts +1 -2
- package/dist/validate.js +1 -3
- package/package.json +7 -6
- package/templates/editor/.github/skills/zen-cli/SKILL.md +3 -3
- package/templates/editor/.github/skills/zen-cli/references/frame.md +2 -2
- package/templates/editor/.github/skills/zen-cli/references/{faker.md → gym.md} +45 -40
- package/templates/editor/.github/skills/zen-cli/references/keys.md +2 -2
- package/templates/editor/.github/skills/zen-cli/references/models.md +1 -1
- package/templates/editor/.github/skills/zen-cli/references/rag.md +1 -1
- package/templates/editor/.vscode/agents.schema.json +1 -1
- package/dist/audit.d.ts +0 -58
- package/dist/audit.js +0 -148
- package/dist/env.d.ts +0 -20
- package/dist/env.js +0 -65
- package/dist/home.d.ts +0 -35
- package/dist/home.js +0 -112
- package/dist/ids.d.ts +0 -12
- package/dist/ids.js +0 -44
- package/dist/image.d.ts +0 -16
- package/dist/image.js +0 -85
- package/dist/keys.d.ts +0 -269
- package/dist/keys.js +0 -624
- package/dist/projects.d.ts +0 -91
- package/dist/projects.js +0 -303
- package/dist/session.d.ts +0 -112
- 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
|