@zenera/cli 1.1.10 → 1.1.12
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 +69 -15
- package/dist/banner.js +69 -47
- package/dist/cache.d.ts +98 -0
- package/dist/cache.js +301 -0
- package/dist/catalog.d.ts +3 -0
- package/dist/catalog.js +35 -11
- package/dist/commands/cache.d.ts +7 -0
- package/dist/commands/cache.js +245 -0
- package/dist/commands/check.js +6 -3
- package/dist/commands/index.js +5 -0
- package/dist/commands/init.js +8 -4
- package/dist/commands/key.js +68 -14
- package/dist/commands/memory.d.ts +3 -0
- package/dist/commands/memory.js +375 -0
- package/dist/commands/models.js +17 -1
- package/dist/commands/run.js +11 -3
- package/dist/commands/sandbox.js +70 -23
- package/dist/engine.js +14 -9
- package/dist/home.d.ts +2 -2
- package/dist/home.js +2 -2
- package/dist/keys.d.ts +22 -0
- package/dist/keys.js +105 -2
- package/dist/lib.d.ts +1 -0
- package/dist/lib.js +1 -0
- package/dist/liveness.js +11 -0
- package/dist/main.js +9 -0
- package/dist/podman.js +43 -9
- package/dist/projects.js +10 -2
- package/dist/resolve.d.ts +4 -0
- package/dist/resolve.js +43 -17
- package/dist/scaffold.js +22 -1
- package/dist/term.d.ts +23 -2
- package/dist/term.js +215 -8
- package/dist/tui/app.d.ts +10 -0
- package/dist/tui/app.js +478 -54
- package/dist/tui/theme.d.ts +13 -2
- package/dist/tui/theme.js +26 -13
- package/dist/tui/wrap.d.ts +92 -0
- package/dist/tui/wrap.js +147 -2
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +87 -2
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +200 -55
- package/templates/editor/.github/prompts/new-agent.prompt.md +5 -2
- package/templates/editor/.github/prompts/review-project.prompt.md +5 -0
- package/templates/editor/.github/prompts/sync-with-spec.prompt.md +11 -4
- package/templates/editor/.github/skills/zen-cli/SKILL.md +3 -0
- package/templates/editor/.github/skills/zen-cli/references/faker.md +18 -8
- package/templates/editor/.github/skills/zen-cli/references/keys.md +7 -7
- package/templates/editor/.github/skills/zen-cli/references/memory.md +113 -0
- package/templates/editor/.github/skills/zen-cli/references/rag.md +2 -2
- package/templates/editor/.github/skills/zen-cli/references/run.md +3 -1
- package/templates/editor/.github/skills/zen-memory/SKILL.md +555 -0
- package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +575 -0
- package/templates/editor/.github/skills/{api-schema-index → zen-rag-schema}/SKILL.md +4 -4
- package/templates/editor/.vscode/settings.json +4 -2
- package/templates/parts/exa.prompt.md.tmpl +4 -0
- package/templates/parts/exa.spec.md.tmpl +2 -0
- package/templates/project/INSTRUCTIONS.md +8 -0
- package/templates/project/SPECIFICATION.md.tmpl +47 -0
- package/templates/project/agents/prompts/{default.md → default.md.tmpl} +4 -0
- package/templates/project/agents.yaml.tmpl +4 -0
- package/templates/project/gitignore +7 -1
- package/templates/project/scripts/_setup.sh +101 -0
- package/templates/project/SPECIFICATION.md +0 -6
package/dist/commands/run.js
CHANGED
|
@@ -104,13 +104,14 @@ export const run = {
|
|
|
104
104
|
await start(engine, {
|
|
105
105
|
readOnly: Boolean(values['read-only']),
|
|
106
106
|
theme: values.theme,
|
|
107
|
+
started: { created: where.created, freshWorkspace: where.freshWorkspace },
|
|
107
108
|
});
|
|
108
109
|
return;
|
|
109
110
|
}
|
|
110
111
|
if (!prompt) {
|
|
111
112
|
throw usageError('nothing to ask', 'give a prompt, or pipe one in');
|
|
112
113
|
}
|
|
113
|
-
await once(engine, prompt, values, ctx.json, ctx.cwd);
|
|
114
|
+
await once(engine, prompt, values, ctx.json, ctx.cwd, where);
|
|
114
115
|
}
|
|
115
116
|
finally {
|
|
116
117
|
await engine.close();
|
|
@@ -120,7 +121,7 @@ export const run = {
|
|
|
120
121
|
// ---------------------------------------------------------------------------
|
|
121
122
|
// One shot
|
|
122
123
|
// ---------------------------------------------------------------------------
|
|
123
|
-
async function once(engine, prompt, values, asJson, cwd) {
|
|
124
|
+
async function once(engine, prompt, values, asJson, cwd, where) {
|
|
124
125
|
const narrator = new Narrator({
|
|
125
126
|
quiet: Boolean(values.quiet) || asJson,
|
|
126
127
|
live: Boolean(process.stderr.isTTY),
|
|
@@ -131,7 +132,14 @@ async function once(engine, prompt, values, asJson, cwd) {
|
|
|
131
132
|
const onInterrupt = () => stopping.abort();
|
|
132
133
|
process.once('SIGINT', onInterrupt);
|
|
133
134
|
if (!values.quiet && !asJson) {
|
|
134
|
-
|
|
135
|
+
// Two questions were just answered, possibly without being asked. Say
|
|
136
|
+
// which way they went: a run that quietly resumed the wrong session, or
|
|
137
|
+
// wrote into the directory you were standing in, is only explainable
|
|
138
|
+
// afterwards, and this is the one line where it is cheap to say.
|
|
139
|
+
note(`${bold(engine.name)} ${dim(where.created ? 'new session' : 'continuing')} ` +
|
|
140
|
+
`${dim(engine.session.id)}`);
|
|
141
|
+
note(dim(`${where.freshWorkspace ? 'new directory' : 'workspace'} ` +
|
|
142
|
+
`${display(engine.workspace, cwd)}`));
|
|
135
143
|
}
|
|
136
144
|
let outcome;
|
|
137
145
|
try {
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -2,8 +2,7 @@ import { readProjectConfig } from '@zenera/neo';
|
|
|
2
2
|
import { parse } from "../args.js";
|
|
3
3
|
import { resolveBuild } from "../image.js";
|
|
4
4
|
import { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, } from "../podman.js";
|
|
5
|
-
import { dirSize, isProjectDir, Registry, sessionIds } from "../projects.js";
|
|
6
|
-
import { project as findProject } from "../resolve.js";
|
|
5
|
+
import { current as currentProject, dirSize, isProjectDir, open as openProject, Registry, sessionIds, } from "../projects.js";
|
|
7
6
|
import { ago, bold, bytes, dim, green, json, note, red, table, usageError, write, writeAll, yellow, } from "../term.js";
|
|
8
7
|
const USAGE = 'zen sandbox [status|up|pull|clean|disk] [options]';
|
|
9
8
|
/** Enough to see the pattern; the rest are a number. */
|
|
@@ -29,9 +28,13 @@ export const sandbox = {
|
|
|
29
28
|
' clean Remove every container this CLI created.',
|
|
30
29
|
' disk What the engine and every known project occupy.',
|
|
31
30
|
'',
|
|
32
|
-
' --project <name|dir> Which project the image
|
|
31
|
+
' --project <name|dir> Which project the image and containers belong to.',
|
|
33
32
|
' --image <ref> Use this image instead of the project\u2019s.',
|
|
34
33
|
'',
|
|
34
|
+
'Without --project this is the project you are standing in, and standing',
|
|
35
|
+
'nowhere is an answer: `status` then reports the engine and every container',
|
|
36
|
+
'on the machine, since that is all there is to say.',
|
|
37
|
+
'',
|
|
35
38
|
'None of this is required. A run does all of it on its own, the first',
|
|
36
39
|
'time an agent that can reach a shell is about to start one.',
|
|
37
40
|
],
|
|
@@ -47,18 +50,21 @@ export const sandbox = {
|
|
|
47
50
|
if (positionals.length > 1) {
|
|
48
51
|
throw usageError('one subcommand at a time', USAGE);
|
|
49
52
|
}
|
|
50
|
-
// `clean` and `disk` are machine-wide questions, so
|
|
51
|
-
// project they
|
|
53
|
+
// `clean` and `disk` are machine-wide questions, so looking for a
|
|
54
|
+
// project they do not use would be reading something for nothing.
|
|
52
55
|
const scoped = what === 'status' || what === 'up' || what === 'pull';
|
|
53
|
-
const found =
|
|
56
|
+
const found = scoped ? await projectSandbox(ctx.cwd, values) : undefined;
|
|
54
57
|
const image = values.image ?? found?.image;
|
|
55
|
-
const build = found?.build;
|
|
58
|
+
const build = values.image ? undefined : found?.build;
|
|
56
59
|
switch (what) {
|
|
57
60
|
case 'status':
|
|
58
|
-
return status(
|
|
61
|
+
return status(found, image, ctx.json);
|
|
59
62
|
case 'up':
|
|
60
63
|
return up(image, build, ctx.json, ctx.json);
|
|
61
64
|
case 'pull':
|
|
65
|
+
if (!image) {
|
|
66
|
+
throw usageError('no project here, so there is no image to pull', 'name one with --project, or an image with --image');
|
|
67
|
+
}
|
|
62
68
|
return up(image, build, true, ctx.json, true);
|
|
63
69
|
case 'clean':
|
|
64
70
|
return clean(ctx.json);
|
|
@@ -68,27 +74,51 @@ export const sandbox = {
|
|
|
68
74
|
},
|
|
69
75
|
};
|
|
70
76
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
77
|
+
* Which project this is about: the flag, or the directory the command was run
|
|
78
|
+
* in. It deliberately never asks. `zen sandbox status` run from anywhere at
|
|
79
|
+
* all is a useful thing, so standing outside every project is an answer — the
|
|
80
|
+
* engine is still there to report on — and a question with a list of every
|
|
81
|
+
* project on the machine is not one a *reading* has any business asking.
|
|
82
|
+
*
|
|
83
|
+
* Notably it also does not go through `target`: reading a setting must not
|
|
84
|
+
* create a session.
|
|
75
85
|
*/
|
|
76
86
|
async function projectSandbox(cwd, values) {
|
|
87
|
+
const found = values.project ? await openProject(values.project) : await currentProject(cwd);
|
|
88
|
+
if (!found) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
77
91
|
try {
|
|
78
|
-
const found = await findProject({ cwd, project: values.project, yes: true });
|
|
79
92
|
const { root, config } = readProjectConfig(found.dir);
|
|
80
93
|
const build = resolveBuild(root, config.sandbox);
|
|
81
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
dir: found.dir,
|
|
96
|
+
name: found.name,
|
|
97
|
+
image: build?.tag ?? config.sandbox?.image,
|
|
98
|
+
build,
|
|
99
|
+
};
|
|
82
100
|
}
|
|
83
101
|
catch {
|
|
84
|
-
|
|
102
|
+
// A project whose configuration does not read is `zen check`'s to
|
|
103
|
+
// report; here it only means there is no image to name.
|
|
104
|
+
return { dir: found.dir, name: found.name };
|
|
85
105
|
}
|
|
86
106
|
}
|
|
87
|
-
async function status(
|
|
107
|
+
async function status(project, image, asJson) {
|
|
108
|
+
const build = project?.build;
|
|
88
109
|
const found = await podmanStatus({ image });
|
|
89
|
-
const
|
|
110
|
+
const all = found.ready ? await ownedContainers(found.engine) : [];
|
|
111
|
+
// A container belongs to a session, and a session belongs to a project, so
|
|
112
|
+
// a report about one project must not list another's — or the faker's,
|
|
113
|
+
// which wears the same label and belongs to no project at all.
|
|
114
|
+
const containers = project ? ofProject(project.dir, all) : all;
|
|
90
115
|
if (asJson) {
|
|
91
|
-
json({
|
|
116
|
+
json({
|
|
117
|
+
...found,
|
|
118
|
+
project: project?.name ?? null,
|
|
119
|
+
dockerfile: build?.dockerfile ?? null,
|
|
120
|
+
containers,
|
|
121
|
+
});
|
|
92
122
|
return;
|
|
93
123
|
}
|
|
94
124
|
const mark = (ok) => (ok ? green('ok') : red('no'));
|
|
@@ -98,18 +128,31 @@ async function status(image, build, asJson) {
|
|
|
98
128
|
write(`${bold('machine')} ${found.machine.name} ${state}`);
|
|
99
129
|
}
|
|
100
130
|
write(`${bold('responds')} ${mark(found.ready)}`);
|
|
131
|
+
if (project) {
|
|
132
|
+
write(`${bold('project')} ${project.name} ${dim(project.dir)}`);
|
|
133
|
+
}
|
|
101
134
|
if (found.image) {
|
|
102
135
|
write(`${bold('image')} ${found.image} ${mark(Boolean(found.imagePresent))}`);
|
|
103
136
|
}
|
|
104
137
|
if (build) {
|
|
105
138
|
write(`${bold('dockerfile')} ${dim(build.dockerfile)}`);
|
|
106
139
|
}
|
|
107
|
-
writeAll(containerLines(containers));
|
|
140
|
+
writeAll(containerLines(containers, project?.name));
|
|
108
141
|
if (!found.installed || !found.ready) {
|
|
109
142
|
note('');
|
|
110
143
|
note(dim('run `zen sandbox up` to fix what can be fixed.'));
|
|
111
144
|
}
|
|
112
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* The containers this project's sessions made. A container carries the session
|
|
148
|
+
* id that made it in a label, and a session id is a directory name under the
|
|
149
|
+
* project — the same attribution `disk` does for every project at once, so the
|
|
150
|
+
* two cannot disagree.
|
|
151
|
+
*/
|
|
152
|
+
function ofProject(dir, containers) {
|
|
153
|
+
const sessions = new Set(sessionIds(dir));
|
|
154
|
+
return containers.filter((c) => c.key !== undefined && sessions.has(c.key));
|
|
155
|
+
}
|
|
113
156
|
/**
|
|
114
157
|
* One per line rather than one long line, because there is normally more than
|
|
115
158
|
* one and the interesting part — how old, and whether anything is still up —
|
|
@@ -117,11 +160,15 @@ async function status(image, build, asJson) {
|
|
|
117
160
|
*
|
|
118
161
|
* The trailing note is there because the count surprises people: a container
|
|
119
162
|
* is per *session*, not per project, and `persist: true` is what leaves the
|
|
120
|
-
* stopped ones behind.
|
|
163
|
+
* stopped ones behind. `scope` names the project these belong to, and saying
|
|
164
|
+
* so is half the answer — the other half is that there are more elsewhere.
|
|
121
165
|
*/
|
|
122
|
-
function containerLines(containers) {
|
|
166
|
+
function containerLines(containers, scope) {
|
|
167
|
+
const tail = scope
|
|
168
|
+
? `one per session in ${scope}, kept by \`persist: true\` — all of them: zen sandbox disk`
|
|
169
|
+
: 'one per session, kept by `persist: true` — see: zen sandbox disk';
|
|
123
170
|
if (containers.length === 0) {
|
|
124
|
-
return [`${bold('containers')} ${dim('none')}`];
|
|
171
|
+
return [`${bold('containers')} ${dim(scope ? `none in ${scope}` : 'none')}`];
|
|
125
172
|
}
|
|
126
173
|
const running = containers.filter((c) => c.state === 'running').length;
|
|
127
174
|
const head = `${bold('containers')} ${containers.length} ${dim(running ? `· ${running} running` : '· none running')}`;
|
|
@@ -138,7 +185,7 @@ function containerLines(containers) {
|
|
|
138
185
|
head,
|
|
139
186
|
...table(rows),
|
|
140
187
|
...(rest > 0 ? [`${INDENT}${dim(`+${rest} more`)}`] : []),
|
|
141
|
-
`${INDENT}${dim(
|
|
188
|
+
`${INDENT}${dim(tail)}`,
|
|
142
189
|
];
|
|
143
190
|
}
|
|
144
191
|
async function up(image, build, yes, asJson, rebuild = false) {
|
package/dist/engine.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AgentRunner,
|
|
1
|
+
import { AgentRunner, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, Workspace, assertState, buildRunReport, exaTools, lastText, loadProject, readProjectConfig, renderReportHtml, sandboxTools, turns, workspaceTools, } from '@zenera/neo';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { writeFile } from 'node:fs/promises';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
@@ -27,7 +27,6 @@ export async function open(opts) {
|
|
|
27
27
|
const meta = await readSessionMeta(opts.session);
|
|
28
28
|
const workspace = resolve(meta.workspace);
|
|
29
29
|
const payloads = new FilePayloadStore({ dir: opts.session.blobs, id: 'file' });
|
|
30
|
-
const memory = [new FileMemoryStore({ dir: opts.session.memory, id: 'file' })];
|
|
31
30
|
// The config is read before the project is loaded because the sandbox is
|
|
32
31
|
// configured by it and the tools have to exist before selectors can be
|
|
33
32
|
// resolved against them. They are always registered, whether or not anyone
|
|
@@ -35,6 +34,7 @@ export async function open(opts) {
|
|
|
35
34
|
// fails to load with "unknown tool", which would be a lie.
|
|
36
35
|
let sandbox;
|
|
37
36
|
let project;
|
|
37
|
+
let files;
|
|
38
38
|
try {
|
|
39
39
|
const { root, config } = readProjectConfig(opts.project.dir);
|
|
40
40
|
// Assets and the skill catalog are mounted for both, under one name.
|
|
@@ -49,17 +49,21 @@ export async function open(opts) {
|
|
|
49
49
|
keys: opts.keys,
|
|
50
50
|
mounts,
|
|
51
51
|
});
|
|
52
|
+
const workspaceOptions = {
|
|
53
|
+
root: workspace,
|
|
54
|
+
readOnly: opts.readOnly,
|
|
55
|
+
mount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
|
|
56
|
+
mounts,
|
|
57
|
+
};
|
|
58
|
+
// The same view the file tools have, so a path the agent just wrote
|
|
59
|
+
// resolves the same way when it asks memory to keep a copy.
|
|
60
|
+
files = new Workspace(workspaceOptions);
|
|
52
61
|
project = await loadProject(opts.project.dir, {
|
|
53
62
|
tools: [
|
|
54
63
|
// Both groups are pointed at one directory, so both are told the
|
|
55
64
|
// one name for it: whatever `run_command` prints a path as,
|
|
56
65
|
// `read_file` accepts.
|
|
57
|
-
...workspaceTools(
|
|
58
|
-
root: workspace,
|
|
59
|
-
readOnly: opts.readOnly,
|
|
60
|
-
mount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
|
|
61
|
-
mounts,
|
|
62
|
-
}),
|
|
66
|
+
...workspaceTools(workspaceOptions),
|
|
63
67
|
...sandboxTools(sandbox.pool),
|
|
64
68
|
// Registered whether or not a key exists: the credential is
|
|
65
69
|
// read when a tool is called, so a project that names `exa:*`
|
|
@@ -69,7 +73,7 @@ export async function open(opts) {
|
|
|
69
73
|
],
|
|
70
74
|
skillsAt: SKILLS_MOUNT,
|
|
71
75
|
payloads,
|
|
72
|
-
|
|
76
|
+
resolveFile: (path) => files.within(path),
|
|
73
77
|
});
|
|
74
78
|
}
|
|
75
79
|
catch (err) {
|
|
@@ -105,6 +109,7 @@ export async function open(opts) {
|
|
|
105
109
|
lock,
|
|
106
110
|
close: async () => {
|
|
107
111
|
await teardown(sandbox.pool);
|
|
112
|
+
project.close();
|
|
108
113
|
lock.release();
|
|
109
114
|
},
|
|
110
115
|
};
|
package/dist/home.d.ts
CHANGED
|
@@ -5,8 +5,8 @@ export declare const paths: {
|
|
|
5
5
|
keys: () => string;
|
|
6
6
|
keyDir: () => string;
|
|
7
7
|
faker: () => string;
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/** work already done and worth keeping: vectors, parses, model listings */
|
|
9
|
+
cache: () => string;
|
|
10
10
|
};
|
|
11
11
|
/** Creates a directory owner-only, and leaves an existing one's mode alone. */
|
|
12
12
|
export declare function ensureDir(dir: string, mode?: number): string;
|
package/dist/home.js
CHANGED
|
@@ -26,8 +26,8 @@ export const paths = {
|
|
|
26
26
|
keys: () => join(home(), 'keys.json'),
|
|
27
27
|
keyDir: () => join(home(), 'keys'),
|
|
28
28
|
faker: () => join(home(), 'faker'),
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/** work already done and worth keeping: vectors, parses, model listings */
|
|
30
|
+
cache: () => join(home(), 'cache'),
|
|
31
31
|
};
|
|
32
32
|
/** Creates a directory owner-only, and leaves an existing one's mode alone. */
|
|
33
33
|
export function ensureDir(dir, mode = DIR_MODE) {
|
package/dist/keys.d.ts
CHANGED
|
@@ -103,6 +103,17 @@ export declare function parseRef(ref: string): {
|
|
|
103
103
|
provider: KeyOwner;
|
|
104
104
|
name?: string;
|
|
105
105
|
};
|
|
106
|
+
/** Which of a provider's forms a raw value is, decided by the value itself. */
|
|
107
|
+
export declare function formOf(provider: KeyOwner, raw: string): CredentialForm;
|
|
108
|
+
/**
|
|
109
|
+
* Throws on a name that is neither an endpoint nor shaped like a region — `usa`,
|
|
110
|
+
* or `us-central` with its digit missing — because those are 404s at the first
|
|
111
|
+
* call, a long way from the command that caused them. Returns false for a
|
|
112
|
+
* well-formed name that is merely unrecognised, which the caller reports and
|
|
113
|
+
* stores anyway: Google adds regions, and a list compiled here would be wrong
|
|
114
|
+
* before it was old.
|
|
115
|
+
*/
|
|
116
|
+
export declare function checkRegion(region: string): boolean;
|
|
106
117
|
/** What a credential cannot say about itself. */
|
|
107
118
|
export interface KeyMeta {
|
|
108
119
|
/** GCP project id, for a Vertex service account */
|
|
@@ -144,6 +155,17 @@ export declare class KeyStore {
|
|
|
144
155
|
fileOf(entry: KeyEntry): string;
|
|
145
156
|
/** The plaintext an entry stands for — the only way out of the store. */
|
|
146
157
|
reveal(entry: KeyEntry): string;
|
|
158
|
+
/**
|
|
159
|
+
* Which GCP project a Vertex entry addresses, and how that was decided —
|
|
160
|
+
* the same order the library resolves it in, so what is shown is what will
|
|
161
|
+
* be called. A service-account file states its own project, and nothing was
|
|
162
|
+
* ever stored for it, so an entry with no `project` is not an entry with no
|
|
163
|
+
* project: it is one whose project is only knowable by reading the file.
|
|
164
|
+
*/
|
|
165
|
+
projectOf(entry: KeyEntry): {
|
|
166
|
+
id: string;
|
|
167
|
+
from: 'env' | 'stored' | 'file';
|
|
168
|
+
} | undefined;
|
|
147
169
|
/**
|
|
148
170
|
* What the library would see. Real environment variables win, so CI,
|
|
149
171
|
* `docker run -e` and a one-off `OPENAI_API_KEY=… zen run` all behave
|
package/dist/keys.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EXA_API_KEY_ENV } from '@zenera/neo';
|
|
2
|
-
import { chmodSync, copyFileSync, existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, copyFileSync, existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
5
5
|
import { assertPrivate, ensureDir, paths, readJson, writeJson } from "./home.js";
|
|
@@ -169,7 +169,7 @@ export function parseRef(ref) {
|
|
|
169
169
|
return { provider, name };
|
|
170
170
|
}
|
|
171
171
|
/** Which of a provider's forms a raw value is, decided by the value itself. */
|
|
172
|
-
function formOf(provider, raw) {
|
|
172
|
+
export function formOf(provider, raw) {
|
|
173
173
|
const { forms } = SHAPES[provider];
|
|
174
174
|
if (forms.length === 1) {
|
|
175
175
|
return forms[0];
|
|
@@ -177,6 +177,71 @@ function formOf(provider, raw) {
|
|
|
177
177
|
const wanted = raw.length < 4096 && existsSync(resolve(raw)) ? 'file' : 'secret';
|
|
178
178
|
return forms.find((f) => f.holds === wanted) ?? forms[0];
|
|
179
179
|
}
|
|
180
|
+
const REGION = /^[a-z]+-[a-z]+[0-9]+$/;
|
|
181
|
+
const REGIONS = new Set([
|
|
182
|
+
'africa-south1',
|
|
183
|
+
'asia-east1',
|
|
184
|
+
'asia-east2',
|
|
185
|
+
'asia-northeast1',
|
|
186
|
+
'asia-northeast2',
|
|
187
|
+
'asia-northeast3',
|
|
188
|
+
'asia-south1',
|
|
189
|
+
'asia-south2',
|
|
190
|
+
'asia-southeast1',
|
|
191
|
+
'asia-southeast2',
|
|
192
|
+
'asia-southeast3',
|
|
193
|
+
'australia-southeast1',
|
|
194
|
+
'australia-southeast2',
|
|
195
|
+
'europe-central2',
|
|
196
|
+
'europe-north1',
|
|
197
|
+
'europe-southwest1',
|
|
198
|
+
'europe-west1',
|
|
199
|
+
'europe-west2',
|
|
200
|
+
'europe-west3',
|
|
201
|
+
'europe-west4',
|
|
202
|
+
'europe-west6',
|
|
203
|
+
'europe-west8',
|
|
204
|
+
'europe-west9',
|
|
205
|
+
'europe-west10',
|
|
206
|
+
'europe-west12',
|
|
207
|
+
'me-central1',
|
|
208
|
+
'me-central2',
|
|
209
|
+
'me-west1',
|
|
210
|
+
'northamerica-northeast1',
|
|
211
|
+
'northamerica-northeast2',
|
|
212
|
+
'northamerica-south1',
|
|
213
|
+
'southamerica-east1',
|
|
214
|
+
'southamerica-west1',
|
|
215
|
+
'us-central1',
|
|
216
|
+
'us-east1',
|
|
217
|
+
'us-east4',
|
|
218
|
+
'us-east5',
|
|
219
|
+
'us-south1',
|
|
220
|
+
'us-west1',
|
|
221
|
+
'us-west2',
|
|
222
|
+
'us-west3',
|
|
223
|
+
'us-west4',
|
|
224
|
+
]);
|
|
225
|
+
/**
|
|
226
|
+
* Endpoints that route across regions rather than naming one. `us` and `eu` keep
|
|
227
|
+
* processing inside their territory; `global` does not promise that, and takes
|
|
228
|
+
* whatever capacity is free.
|
|
229
|
+
*/
|
|
230
|
+
const ENDPOINTS = new Set(['global', 'us', 'eu']);
|
|
231
|
+
/**
|
|
232
|
+
* Throws on a name that is neither an endpoint nor shaped like a region — `usa`,
|
|
233
|
+
* or `us-central` with its digit missing — because those are 404s at the first
|
|
234
|
+
* call, a long way from the command that caused them. Returns false for a
|
|
235
|
+
* well-formed name that is merely unrecognised, which the caller reports and
|
|
236
|
+
* stores anyway: Google adds regions, and a list compiled here would be wrong
|
|
237
|
+
* before it was old.
|
|
238
|
+
*/
|
|
239
|
+
export function checkRegion(region) {
|
|
240
|
+
if (!ENDPOINTS.has(region) && !REGION.test(region)) {
|
|
241
|
+
throw usageError(`"${region}" is not a region`, 'give a region such as us-central1, or the endpoints global, us or eu');
|
|
242
|
+
}
|
|
243
|
+
return ENDPOINTS.has(region) || REGIONS.has(region);
|
|
244
|
+
}
|
|
180
245
|
// ---------------------------------------------------------------------------
|
|
181
246
|
// Store
|
|
182
247
|
// ---------------------------------------------------------------------------
|
|
@@ -300,6 +365,30 @@ export class KeyStore {
|
|
|
300
365
|
reveal(entry) {
|
|
301
366
|
return entry.holds === 'file' ? this.fileOf(entry) : entry.value;
|
|
302
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Which GCP project a Vertex entry addresses, and how that was decided —
|
|
370
|
+
* the same order the library resolves it in, so what is shown is what will
|
|
371
|
+
* be called. A service-account file states its own project, and nothing was
|
|
372
|
+
* ever stored for it, so an entry with no `project` is not an entry with no
|
|
373
|
+
* project: it is one whose project is only knowable by reading the file.
|
|
374
|
+
*/
|
|
375
|
+
projectOf(entry) {
|
|
376
|
+
if (entry.provider !== 'vertex') {
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
const fromEnv = process.env.GOOGLE_CLOUD_PROJECT;
|
|
380
|
+
if (fromEnv) {
|
|
381
|
+
return { id: fromEnv, from: 'env' };
|
|
382
|
+
}
|
|
383
|
+
if (entry.project) {
|
|
384
|
+
return { id: entry.project, from: 'stored' };
|
|
385
|
+
}
|
|
386
|
+
if (entry.holds !== 'file') {
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
const id = projectIdOf(this.fileOf(entry));
|
|
390
|
+
return id ? { id, from: 'file' } : undefined;
|
|
391
|
+
}
|
|
303
392
|
/**
|
|
304
393
|
* What the library would see. Real environment variables win, so CI,
|
|
305
394
|
* `docker run -e` and a one-off `OPENAI_API_KEY=… zen run` all behave
|
|
@@ -350,6 +439,20 @@ export class KeyStore {
|
|
|
350
439
|
return target;
|
|
351
440
|
}
|
|
352
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* `project_id` out of a service-account file. Express-mode keys, gcloud user
|
|
444
|
+
* ADC and metadata credentials carry none, so finding nothing is an answer.
|
|
445
|
+
*/
|
|
446
|
+
function projectIdOf(path) {
|
|
447
|
+
try {
|
|
448
|
+
const key = JSON.parse(readFileSync(path, 'utf8'));
|
|
449
|
+
const id = key.project_id;
|
|
450
|
+
return typeof id === 'string' && id.trim() ? id.trim() : undefined;
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
353
456
|
// ---------------------------------------------------------------------------
|
|
354
457
|
// Display
|
|
355
458
|
// ---------------------------------------------------------------------------
|
package/dist/lib.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { extract, invokedAs, one, parse, split, type Parsed, type Split } from './args.ts';
|
|
2
2
|
export { printBanner, type BannerText } from './banner.ts';
|
|
3
|
+
export { Cache, items as cacheItems, cacheKey, kinds as cacheKinds, clear as clearCache, NO_CACHE, sweep as sweepCache, type CacheEntry, type CacheItem, type CacheKind, type CacheOptions, type CacheStore, type Swept, } from './cache.ts';
|
|
3
4
|
export { CATALOG_TTL_MS, CURATED, fetchCatalog, loadCatalog, loadCatalogs, matches, PREFERRED, type Catalog, type CatalogEntry, type CatalogOptions, type Filters, type Role, } from './catalog.ts';
|
|
4
5
|
export type { Command, Context } from './command.ts';
|
|
5
6
|
export { assertPrivate, ensureDir, ensureHome, home, paths, readJson, writeJson } from './home.ts';
|
package/dist/lib.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
24
|
export { extract, invokedAs, one, parse, split } from "./args.js";
|
|
25
25
|
export { printBanner } from "./banner.js";
|
|
26
|
+
export { Cache, items as cacheItems, cacheKey, kinds as cacheKinds, clear as clearCache, NO_CACHE, sweep as sweepCache, } from "./cache.js";
|
|
26
27
|
export { CATALOG_TTL_MS, CURATED, fetchCatalog, loadCatalog, loadCatalogs, matches, PREFERRED, } from "./catalog.js";
|
|
27
28
|
export { assertPrivate, ensureDir, ensureHome, home, paths, readJson, writeJson } from "./home.js";
|
|
28
29
|
export { assertNotEmpty, assertOwner, assertUsable, describe, envNames, envOf, form, isOwner, isProvider, keyId, KeyStore, mask, OWNERS, parseRef, PROVIDERS, SERVICES, SHAPES, } from "./keys.js";
|
package/dist/liveness.js
CHANGED
|
@@ -107,6 +107,17 @@ export function classify(err) {
|
|
|
107
107
|
if (UNREACHED.some((needle) => haystack.includes(needle))) {
|
|
108
108
|
return { state: 'unknown', at, detail: 'could not reach the provider' };
|
|
109
109
|
}
|
|
110
|
+
// A json api that answers in html was never reached: the hostname was
|
|
111
|
+
// wrong. Vertex builds its hostname out of the location, so a misspelt
|
|
112
|
+
// region lands here — as `<!DOCTYPE html>`, which explains nothing.
|
|
113
|
+
if (haystack.includes('<!doctype html') || haystack.includes('<html')) {
|
|
114
|
+
return {
|
|
115
|
+
state: 'unknown',
|
|
116
|
+
at,
|
|
117
|
+
detail: 'a web page came back, not the api — the endpoint does not exist',
|
|
118
|
+
fix: 'check the location: vertex builds its hostname from it',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
110
121
|
// 429 means the credential authenticated and then got rate limited, which
|
|
111
122
|
// is a live key having a bad day.
|
|
112
123
|
if (status === 429) {
|
package/dist/main.js
CHANGED
|
@@ -151,5 +151,14 @@ async function usage(name) {
|
|
|
151
151
|
write(` -C, --directory <d> ${dim('Act as if run in <d>.')}`);
|
|
152
152
|
write(`\n${dim(`Start with ${cyan(`${NAME} init`)}, then ${cyan(`${NAME} run`)}.`)}`);
|
|
153
153
|
}
|
|
154
|
+
// `zen memory ls | head` closes the pipe while we are still writing to it, and
|
|
155
|
+
// an unhandled EPIPE turns that ordinary shell idiom into a stack trace. The
|
|
156
|
+
// reader having left is not our error, so we stop quietly.
|
|
157
|
+
process.stdout.on('error', (err) => {
|
|
158
|
+
if (err.code !== 'EPIPE') {
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
process.exit(0);
|
|
162
|
+
});
|
|
154
163
|
process.exitCode = await main(process.argv.slice(2));
|
|
155
164
|
//# sourceMappingURL=main.js.map
|
package/dist/podman.js
CHANGED
|
@@ -2,7 +2,7 @@ import { runProcess, SandboxError } from '@zenera/neo';
|
|
|
2
2
|
import { readdirSync, statSync } from 'node:fs';
|
|
3
3
|
import { homedir, platform } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { CliError, confirm, dim, EXIT, isInteractive, note } from "./term.js";
|
|
5
|
+
import { CliError, confirm, dim, EXIT, isInteractive, note, progress } from "./term.js";
|
|
6
6
|
const DEFAULT_MACHINE_CPUS = 2;
|
|
7
7
|
const DEFAULT_MACHINE_MEMORY = 2048;
|
|
8
8
|
/** Starting a virtual machine and pulling an image are both slow on purpose. */
|
|
@@ -73,7 +73,7 @@ async function preflight(opts) {
|
|
|
73
73
|
const present = await call(['image', 'exists', opts.image], 30_000);
|
|
74
74
|
if (present.code !== 0) {
|
|
75
75
|
note(dim(`pulling ${opts.image} — this happens once`));
|
|
76
|
-
const pulled = await stream(engine, ['pull', opts.image], run);
|
|
76
|
+
const pulled = await stream(engine, ['pull', opts.image], run, 'pulling');
|
|
77
77
|
if (pulled.code !== 0) {
|
|
78
78
|
throw sandboxError(`could not pull ${opts.image}`, first(pulled) || 'check the image name and your network');
|
|
79
79
|
}
|
|
@@ -99,7 +99,7 @@ async function install(engine, opts, run) {
|
|
|
99
99
|
throw sandboxError('podman is not installed', how);
|
|
100
100
|
}
|
|
101
101
|
note(dim('installing podman — this takes a few minutes'));
|
|
102
|
-
const done = await stream('brew', ['install', 'podman'], run);
|
|
102
|
+
const done = await stream('brew', ['install', 'podman'], run, 'installing podman');
|
|
103
103
|
if (done.code !== 0) {
|
|
104
104
|
throw sandboxError('could not install podman', first(done) || how);
|
|
105
105
|
}
|
|
@@ -127,7 +127,7 @@ async function machine(engine, call, opts, run) {
|
|
|
127
127
|
const cpus = String(opts.cpus ?? DEFAULT_MACHINE_CPUS);
|
|
128
128
|
const memory = String(opts.memory ?? DEFAULT_MACHINE_MEMORY);
|
|
129
129
|
note(dim(`initialising the podman machine (${cpus} cpus, ${memory} MiB) — once per host`));
|
|
130
|
-
const created = await stream(engine, ['machine', 'init', '--cpus', cpus, '--memory', memory], run);
|
|
130
|
+
const created = await stream(engine, ['machine', 'init', '--cpus', cpus, '--memory', memory], run, 'initialising the machine');
|
|
131
131
|
if (created.code !== 0) {
|
|
132
132
|
throw sandboxError('could not create the podman machine', first(created));
|
|
133
133
|
}
|
|
@@ -143,7 +143,7 @@ async function machine(engine, call, opts, run) {
|
|
|
143
143
|
args.push(chosen.Name);
|
|
144
144
|
}
|
|
145
145
|
note(dim('starting the podman machine'));
|
|
146
|
-
const started = await stream(engine, args, run, SLOW_MS);
|
|
146
|
+
const started = await stream(engine, args, run, 'starting the machine', SLOW_MS);
|
|
147
147
|
if (started.code !== 0 && !/already running/i.test(started.stderr)) {
|
|
148
148
|
throw sandboxError('could not start the podman machine', first(started));
|
|
149
149
|
}
|
|
@@ -197,8 +197,8 @@ async function build(engine, spec, run, force = false) {
|
|
|
197
197
|
}
|
|
198
198
|
note(dim(`${force ? 'rebuilding' : 'building'} the sandbox image from ${spec.dockerfile}`));
|
|
199
199
|
note(dim(' the agent runs its commands inside a container, so the image is needed first'));
|
|
200
|
-
note(dim(' this takes a few minutes; later runs reuse it
|
|
201
|
-
const built = await stream(engine, ['build', '--tag', spec.tag, '--file', spec.dockerfile, spec.context], run, BUILD_MS);
|
|
200
|
+
note(dim(' this takes a few minutes; later runs reuse it'));
|
|
201
|
+
const built = await stream(engine, ['build', '--tag', spec.tag, '--file', spec.dockerfile, spec.context], run, 'building the sandbox image', BUILD_MS);
|
|
202
202
|
if (built.code !== 0) {
|
|
203
203
|
throw new BuildError(`could not build ${spec.dockerfile}`, EXIT.sandbox, last(built) || 'run the build by hand to see what the engine says');
|
|
204
204
|
}
|
|
@@ -384,13 +384,20 @@ function children(dir) {
|
|
|
384
384
|
}
|
|
385
385
|
}
|
|
386
386
|
// ---------------------------------------------------------------------------
|
|
387
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
388
|
+
const FRAME_MS = 100;
|
|
387
389
|
/**
|
|
388
390
|
* Long steps print as they go. A five-minute pull with no output is
|
|
389
391
|
* indistinguishable from a hang, and the one thing worse than waiting is not
|
|
390
392
|
* knowing whether you are waiting.
|
|
393
|
+
*
|
|
394
|
+
* The engine's own output is buffered until it exits, so while the step runs
|
|
395
|
+
* the only honest thing to show is that it is still running: a spinner and a
|
|
396
|
+
* clock, both of which have to move without any event to move them. Off
|
|
397
|
+
* without a terminal — a CI log wants one line per fact, not ten a second.
|
|
391
398
|
*/
|
|
392
|
-
async function stream(bin, args, run, timeoutMs = SLOW_MS) {
|
|
393
|
-
const res = await run(bin, args, { timeoutMs });
|
|
399
|
+
async function stream(bin, args, run, label, timeoutMs = SLOW_MS) {
|
|
400
|
+
const res = await ticking(label, run(bin, args, { timeoutMs }));
|
|
394
401
|
for (const line of res.stderr.split('\n').slice(-3)) {
|
|
395
402
|
if (line.trim()) {
|
|
396
403
|
note(dim(` ${line.trim()}`));
|
|
@@ -398,6 +405,33 @@ async function stream(bin, args, run, timeoutMs = SLOW_MS) {
|
|
|
398
405
|
}
|
|
399
406
|
return res;
|
|
400
407
|
}
|
|
408
|
+
async function ticking(label, work) {
|
|
409
|
+
if (!process.stderr.isTTY) {
|
|
410
|
+
return work;
|
|
411
|
+
}
|
|
412
|
+
const bar = progress();
|
|
413
|
+
const startedAt = Date.now();
|
|
414
|
+
let frame = 0;
|
|
415
|
+
const timer = setInterval(() => {
|
|
416
|
+
const spin = SPINNER[frame++ % SPINNER.length];
|
|
417
|
+
bar.update(dim(` ${spin} ${label} ${elapsed(Date.now() - startedAt)}`));
|
|
418
|
+
}, FRAME_MS);
|
|
419
|
+
timer.unref();
|
|
420
|
+
try {
|
|
421
|
+
return await work;
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
clearInterval(timer);
|
|
425
|
+
bar.done();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function elapsed(ms) {
|
|
429
|
+
const total = Math.floor(ms / 1000);
|
|
430
|
+
const seconds = total % 60;
|
|
431
|
+
return total < 60
|
|
432
|
+
? `${seconds}s`
|
|
433
|
+
: `${Math.floor(total / 60)}m ${String(seconds).padStart(2, '0')}s`;
|
|
434
|
+
}
|
|
401
435
|
function first(res) {
|
|
402
436
|
return (res.stderr.trim() || res.stdout.trim()).split('\n')[0] ?? '';
|
|
403
437
|
}
|