@zenera/cli 1.1.0 → 1.1.3

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 (61) hide show
  1. package/README.md +88 -11
  2. package/dist/audit.d.ts +8 -6
  3. package/dist/audit.js +14 -22
  4. package/dist/commands/check.js +79 -19
  5. package/dist/commands/init.js +71 -11
  6. package/dist/commands/key.js +126 -36
  7. package/dist/commands/models.js +3 -3
  8. package/dist/commands/open.js +2 -2
  9. package/dist/commands/run.js +3 -0
  10. package/dist/commands/sandbox.js +226 -22
  11. package/dist/engine.d.ts +3 -1
  12. package/dist/engine.js +10 -2
  13. package/dist/image.d.ts +16 -0
  14. package/dist/image.js +85 -0
  15. package/dist/keys.d.ts +95 -12
  16. package/dist/keys.js +175 -34
  17. package/dist/lib.d.ts +2 -2
  18. package/dist/lib.js +2 -2
  19. package/dist/liveness.d.ts +16 -6
  20. package/dist/liveness.js +74 -23
  21. package/dist/main.js +0 -0
  22. package/dist/podman.d.ts +57 -1
  23. package/dist/podman.js +177 -12
  24. package/dist/projects.d.ts +18 -0
  25. package/dist/projects.js +60 -1
  26. package/dist/sandbox.d.ts +14 -1
  27. package/dist/sandbox.js +88 -8
  28. package/dist/scaffold.d.ts +21 -15
  29. package/dist/scaffold.js +133 -167
  30. package/dist/term.d.ts +2 -0
  31. package/dist/term.js +14 -0
  32. package/dist/validate.d.ts +20 -3
  33. package/dist/validate.js +309 -14
  34. package/package.json +2 -18
  35. package/templates/{.github → editor/.github}/copilot-instructions.md +161 -48
  36. package/templates/{.github → editor/.github}/prompts/new-skill.prompt.md +13 -6
  37. package/templates/editor/.github/skills/api-schema-index/SKILL.md +292 -0
  38. package/templates/editor/.github/skills/zen-cli/SKILL.md +74 -0
  39. package/templates/editor/.github/skills/zen-cli/references/check.md +92 -0
  40. package/templates/editor/.github/skills/zen-cli/references/faker.md +111 -0
  41. package/templates/editor/.github/skills/zen-cli/references/frame.md +119 -0
  42. package/templates/editor/.github/skills/zen-cli/references/inspect.md +61 -0
  43. package/templates/editor/.github/skills/zen-cli/references/keys.md +114 -0
  44. package/templates/editor/.github/skills/zen-cli/references/projects.md +99 -0
  45. package/templates/editor/.github/skills/zen-cli/references/rag.md +159 -0
  46. package/templates/editor/.github/skills/zen-cli/references/run.md +104 -0
  47. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +91 -0
  48. package/templates/editor/.vscode/settings.json +6 -0
  49. package/templates/parts/exa.yaml.tmpl +5 -0
  50. package/templates/parts/model.yaml.tmpl +4 -0
  51. package/templates/parts/models.yaml.tmpl +10 -0
  52. package/templates/project/INSTRUCTIONS.md +7 -0
  53. package/templates/project/SPECIFICATION.md +6 -0
  54. package/templates/project/agents/prompts/default.md +15 -0
  55. package/templates/project/agents.yaml.tmpl +44 -0
  56. package/templates/project/assets/README.md +12 -0
  57. package/templates/project/gitignore +9 -0
  58. package/templates/project/sandbox/Dockerfile +21 -0
  59. package/templates/.github/skills/zen-cli/SKILL.md +0 -110
  60. /package/templates/{.github → editor/.github}/prompts/new-agent.prompt.md +0 -0
  61. /package/templates/{.github → editor/.github}/prompts/review-project.prompt.md +0 -0
package/dist/keys.js CHANGED
@@ -1,6 +1,7 @@
1
- import { chmodSync, copyFileSync, existsSync, statSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
3
1
  import { EXA_API_KEY_ENV } from '@zenera/neo';
2
+ import { chmodSync, copyFileSync, existsSync, statSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { isAbsolute, join, resolve } from 'node:path';
4
5
  import { assertPrivate, ensureDir, paths, readJson, writeJson } from "./home.js";
5
6
  import { CliError, EXIT, credentialError, usageError } from "./term.js";
6
7
  // ---------------------------------------------------------------------------
@@ -33,55 +34,108 @@ export const SERVICES = ['exa'];
33
34
  /** Anything the keyring can hold a credential for. */
34
35
  export const OWNERS = [...PROVIDERS, ...SERVICES];
35
36
  /**
36
- * Vertex is the odd one. The GenAI SDK resolves Application Default
37
- * Credentials itself, so what is stored is a service-account *file* and what is
38
- * exported is a path not a key. Pretending otherwise would mean inventing a
39
- * credential shape Google does not have.
37
+ * Vertex is the odd one, and it is odd twice.
38
+ *
39
+ * Its usual credential is not a key at all: the GenAI SDK resolves Application
40
+ * Default Credentials itself, so what is stored is a service-account *file* and
41
+ * what is exported is a path. But it also accepts an express-mode api key,
42
+ * which is an ordinary secret under an entirely different variable. The two are
43
+ * alternatives — express mode addresses no project — so which form a credential
44
+ * is gets decided per entry rather than per provider.
40
45
  */
41
46
  export const SHAPES = {
42
47
  openai: {
43
48
  kind: 'model',
44
- env: 'OPENAI_API_KEY',
45
- holds: 'secret',
46
49
  label: 'OpenAI',
47
- where: 'https://platform.openai.com/api-keys',
50
+ forms: [
51
+ {
52
+ holds: 'secret',
53
+ env: 'OPENAI_API_KEY',
54
+ where: 'https://platform.openai.com/api-keys',
55
+ },
56
+ ],
48
57
  },
49
58
  anthropic: {
50
59
  kind: 'model',
51
- env: 'ANTHROPIC_API_KEY',
52
- holds: 'secret',
53
60
  label: 'Anthropic',
54
- where: 'https://console.anthropic.com/settings/keys',
61
+ forms: [
62
+ {
63
+ holds: 'secret',
64
+ env: 'ANTHROPIC_API_KEY',
65
+ where: 'https://console.anthropic.com/settings/keys',
66
+ },
67
+ ],
55
68
  },
56
69
  google: {
57
70
  kind: 'model',
58
- env: 'GEMINI_API_KEY',
59
- holds: 'secret',
60
71
  label: 'Google AI Studio',
61
- where: 'https://aistudio.google.com/apikey',
72
+ forms: [
73
+ {
74
+ holds: 'secret',
75
+ env: 'GEMINI_API_KEY',
76
+ where: 'https://aistudio.google.com/apikey',
77
+ },
78
+ ],
62
79
  },
63
80
  vertex: {
64
81
  kind: 'model',
65
- env: 'GOOGLE_APPLICATION_CREDENTIALS',
66
- holds: 'file',
67
82
  label: 'Vertex AI',
68
- where: 'a service-account JSON key from the GCP console',
83
+ forms: [
84
+ {
85
+ holds: 'file',
86
+ env: 'GOOGLE_APPLICATION_CREDENTIALS',
87
+ where: 'a service-account JSON key from the GCP console',
88
+ },
89
+ {
90
+ holds: 'secret',
91
+ env: 'VERTEX_API_KEY',
92
+ where: 'an express-mode key from https://console.cloud.google.com/vertex-ai',
93
+ },
94
+ ],
69
95
  },
70
96
  openrouter: {
71
97
  kind: 'model',
72
- env: 'OPENROUTER_API_KEY',
73
- holds: 'secret',
74
98
  label: 'OpenRouter',
75
- where: 'https://openrouter.ai/settings/keys',
99
+ forms: [
100
+ {
101
+ holds: 'secret',
102
+ env: 'OPENROUTER_API_KEY',
103
+ where: 'https://openrouter.ai/settings/keys',
104
+ },
105
+ ],
76
106
  },
77
107
  exa: {
78
108
  kind: 'service',
79
- env: EXA_API_KEY_ENV,
80
- holds: 'secret',
81
109
  label: 'Exa',
82
- where: 'https://dashboard.exa.ai/api-keys',
110
+ forms: [
111
+ {
112
+ holds: 'secret',
113
+ env: EXA_API_KEY_ENV,
114
+ where: 'https://dashboard.exa.ai/api-keys',
115
+ },
116
+ ],
83
117
  },
84
118
  };
119
+ /**
120
+ * The form a provider is usually reached by — for the questions that have to
121
+ * have one answer, like which variable to name when nothing is set yet.
122
+ */
123
+ export function form(provider) {
124
+ return SHAPES[provider].forms[0];
125
+ }
126
+ /** Every variable a provider's credential could arrive in, usual one first. */
127
+ export function envNames(provider) {
128
+ return SHAPES[provider].forms.map((f) => f.env);
129
+ }
130
+ /** The variable this particular credential occupies. */
131
+ export function envOf(entry) {
132
+ if (entry.env) {
133
+ return entry.env;
134
+ }
135
+ // Entries written before a provider had two forms carry no `env` of their own.
136
+ const forms = SHAPES[entry.provider].forms;
137
+ return (forms.find((f) => f.holds === entry.holds) ?? forms[0]).env;
138
+ }
85
139
  export function isProvider(name) {
86
140
  return PROVIDERS.includes(name);
87
141
  }
@@ -114,6 +168,15 @@ export function parseRef(ref) {
114
168
  }
115
169
  return { provider, name };
116
170
  }
171
+ /** Which of a provider's forms a raw value is, decided by the value itself. */
172
+ function formOf(provider, raw) {
173
+ const { forms } = SHAPES[provider];
174
+ if (forms.length === 1) {
175
+ return forms[0];
176
+ }
177
+ const wanted = raw.length < 4096 && existsSync(resolve(raw)) ? 'file' : 'secret';
178
+ return forms.find((f) => f.holds === wanted) ?? forms[0];
179
+ }
117
180
  // ---------------------------------------------------------------------------
118
181
  // Store
119
182
  // ---------------------------------------------------------------------------
@@ -160,17 +223,27 @@ export class KeyStore {
160
223
  * directory: the point of a store is that the credential survives the
161
224
  * original being moved, renamed or cleaned up, and a stored path that
162
225
  * silently stops resolving is worse than no store at all.
226
+ *
227
+ * Which form a value is, when the provider accepts two, is read off the
228
+ * value: a path that is there is a credentials file, and anything else is a
229
+ * secret. Asking would be a flag to get wrong, and a service-account key
230
+ * and an api key are not mistakable for one another.
163
231
  */
164
- add(provider, name, raw) {
232
+ add(provider, name, raw, meta = {}) {
165
233
  if (!NAME.test(name)) {
166
234
  throw usageError(`"${name}" is not a usable key name`, 'letters, digits, - and _ only');
167
235
  }
168
- const shape = SHAPES[provider];
236
+ const form = formOf(provider, raw);
169
237
  const entry = {
170
238
  provider,
171
239
  name,
172
- holds: shape.holds,
173
- value: shape.holds === 'file' ? this.#absorb(provider, name, raw) : raw,
240
+ holds: form.holds,
241
+ value: form.holds === 'file' ? this.#absorb(provider, name, raw) : raw,
242
+ env: form.env,
243
+ // Express mode addresses no project, so carrying one would only ever
244
+ // be a way to build the combination the service refuses.
245
+ ...(form.holds === 'file' && meta.project ? { project: meta.project } : {}),
246
+ ...(form.holds === 'file' && meta.location ? { location: meta.location } : {}),
174
247
  addedAt: new Date().toISOString(),
175
248
  };
176
249
  const at = this.#file.entries.findIndex((e) => e.provider === provider && e.name === name);
@@ -216,9 +289,12 @@ export class KeyStore {
216
289
  ensureDir(paths.home());
217
290
  writeJson(paths.keys(), this.#file);
218
291
  }
219
- /** Absolute path behind a file-shaped entry. */
292
+ /**
293
+ * Absolute path behind a file-shaped entry. Stored entries name a file in
294
+ * the key directory; an ambient one already knows where it is.
295
+ */
220
296
  fileOf(entry) {
221
- return join(paths.keyDir(), entry.value);
297
+ return isAbsolute(entry.value) ? entry.value : join(paths.keyDir(), entry.value);
222
298
  }
223
299
  /** The plaintext an entry stands for — the only way out of the store. */
224
300
  reveal(entry) {
@@ -239,11 +315,18 @@ export class KeyStore {
239
315
  if (!entry) {
240
316
  continue;
241
317
  }
242
- const { env: name } = SHAPES[provider];
243
- if (process.env[name]) {
318
+ // Either variable being set means this provider is already answered
319
+ // for, so the stored alternative must not be exported alongside it.
320
+ if (envNames(provider).some((name) => process.env[name])) {
244
321
  continue;
245
322
  }
246
- env[name] = this.reveal(entry);
323
+ env[envOf(entry)] = this.reveal(entry);
324
+ if (entry.project && !process.env.GOOGLE_CLOUD_PROJECT) {
325
+ env.GOOGLE_CLOUD_PROJECT = entry.project;
326
+ }
327
+ if (entry.location && !process.env.GOOGLE_CLOUD_LOCATION) {
328
+ env.GOOGLE_CLOUD_LOCATION = entry.location;
329
+ }
247
330
  }
248
331
  return env;
249
332
  }
@@ -284,6 +367,64 @@ export function mask(secret) {
284
367
  export function describe(store, entry) {
285
368
  return entry.holds === 'file' ? store.fileOf(entry) : mask(entry.value);
286
369
  }
370
+ /** How an ambient credential is named on the command line: it has no key name. */
371
+ export function ambientId(cred) {
372
+ return cred.env ? `${cred.provider}/$${cred.env}` : `${cred.provider}/adc`;
373
+ }
374
+ /**
375
+ * Where `gcloud auth application-default login` leaves its credentials. The
376
+ * GenAI SDK reads this without being told to, so it counts even though nothing
377
+ * in the environment mentions it.
378
+ */
379
+ export function gcloudAdc() {
380
+ const dir = process.env.CLOUDSDK_CONFIG ?? join(homedir(), '.config', 'gcloud');
381
+ const path = join(dir, 'application_default_credentials.json');
382
+ return existsSync(path) ? path : undefined;
383
+ }
384
+ export function ambient(store, only) {
385
+ const found = [];
386
+ for (const provider of only ?? OWNERS) {
387
+ for (const form of SHAPES[provider].forms) {
388
+ const value = process.env[form.env];
389
+ if (!value) {
390
+ continue;
391
+ }
392
+ // `materialize()` puts the store's own entries here. Reporting one
393
+ // of those as ambient would double-count it, and would claim the
394
+ // environment as a source that would survive removing the entry.
395
+ const active = store.active(provider);
396
+ if (active && envOf(active) === form.env && store.reveal(active) === value) {
397
+ continue;
398
+ }
399
+ found.push({ provider, env: form.env, holds: form.holds, value });
400
+ }
401
+ }
402
+ const adc = (only ?? OWNERS).includes('vertex') ? gcloudAdc() : undefined;
403
+ if (adc && !process.env.GOOGLE_APPLICATION_CREDENTIALS) {
404
+ found.push({ provider: 'vertex', holds: 'file', value: adc });
405
+ }
406
+ return found;
407
+ }
408
+ /**
409
+ * Every credential variable this process is carrying, whatever put it there.
410
+ *
411
+ * Read off the environment rather than off the store, and deliberately: by the
412
+ * time anyone asks, `materialize()` has already run, so the environment is the
413
+ * union of the keyring and whatever the shell brought — which is exactly the
414
+ * set of credentials the run is actually using.
415
+ */
416
+ export function credentials() {
417
+ const found = [];
418
+ for (const provider of OWNERS) {
419
+ for (const form of SHAPES[provider].forms) {
420
+ const value = process.env[form.env];
421
+ if (value) {
422
+ found.push({ env: form.env, holds: form.holds, value });
423
+ }
424
+ }
425
+ }
426
+ return found;
427
+ }
287
428
  // ---------------------------------------------------------------------------
288
429
  // Gate
289
430
  // ---------------------------------------------------------------------------
@@ -296,7 +437,7 @@ export function describe(store, entry) {
296
437
  * one clear error here for an obscure one on the first turn.
297
438
  */
298
439
  export function assertUsable(store) {
299
- const reachable = PROVIDERS.filter((p) => process.env[SHAPES[p].env] || store.active(p) !== undefined);
440
+ const reachable = PROVIDERS.filter((p) => envNames(p).some((name) => process.env[name]) || store.active(p) !== undefined);
300
441
  if (reachable.length === 0) {
301
442
  throw credentialError('no credentials for any provider', 'add one with: zen key add openai');
302
443
  }
package/dist/lib.d.ts CHANGED
@@ -2,8 +2,8 @@ export { extract, invokedAs, one, parse, split, type Parsed, type Split } from '
2
2
  export { printBanner, type BannerText } from './banner.ts';
3
3
  export type { Command, Context } from './command.ts';
4
4
  export { assertPrivate, ensureDir, ensureHome, home, paths, readJson, writeJson } from './home.ts';
5
- export { assertNotEmpty, assertOwner, assertUsable, describe, isOwner, isProvider, keyId, KeyStore, mask, OWNERS, parseRef, PROVIDERS, SERVICES, SHAPES, type KeyCheck, type KeyEntry, type KeyOwner, type Liveness, type Provider, type Service, } from './keys.ts';
5
+ export { assertNotEmpty, assertOwner, assertUsable, describe, envNames, envOf, form, isOwner, isProvider, keyId, KeyStore, mask, OWNERS, parseRef, PROVIDERS, SERVICES, SHAPES, type CredentialForm, type KeyCheck, type KeyEntry, type KeyOwner, type Liveness, type Provider, type Service, } from './keys.ts';
6
6
  export { probe, probeAll } from './liveness.ts';
7
- export { ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, type OwnedContainer, type PodmanOptions, type PodmanStatus, } from './podman.ts';
7
+ export { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, type DiskLine, type EngineDisk, type OwnedContainer, type PodmanOptions, type PodmanStatus, } from './podman.ts';
8
8
  export { ago, bold, CliError, count, credentialError, cyan, dim, EXIT, fail, green, invalidError, isInteractive, json, note, pad, red, table, usageError, warn, write, writeAll, yellow, type ExitCode, } from './term.ts';
9
9
  //# sourceMappingURL=lib.d.ts.map
package/dist/lib.js CHANGED
@@ -24,8 +24,8 @@
24
24
  export { extract, invokedAs, one, parse, split } from "./args.js";
25
25
  export { printBanner } from "./banner.js";
26
26
  export { assertPrivate, ensureDir, ensureHome, home, paths, readJson, writeJson } from "./home.js";
27
- export { assertNotEmpty, assertOwner, assertUsable, describe, isOwner, isProvider, keyId, KeyStore, mask, OWNERS, parseRef, PROVIDERS, SERVICES, SHAPES, } from "./keys.js";
27
+ export { assertNotEmpty, assertOwner, assertUsable, describe, envNames, envOf, form, isOwner, isProvider, keyId, KeyStore, mask, OWNERS, parseRef, PROVIDERS, SERVICES, SHAPES, } from "./keys.js";
28
28
  export { probe, probeAll } from "./liveness.js";
29
- export { ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, } from "./podman.js";
29
+ export { engineDisk, ensurePodmanReady, ownedContainers, podmanStatus, removeContainers, } from "./podman.js";
30
30
  export { ago, bold, CliError, count, credentialError, cyan, dim, EXIT, fail, green, invalidError, isInteractive, json, note, pad, red, table, usageError, warn, write, writeAll, yellow, } from "./term.js";
31
31
  //# sourceMappingURL=lib.js.map
@@ -6,18 +6,28 @@ import { type KeyCheck, type KeyEntry, type KeyStore } from './keys.ts';
6
6
  * The client is built through the library's own registry rather than by
7
7
  * requiring the SDKs directly, so a missing optional dependency produces the
8
8
  * library's "run: npm i openai" message instead of a raw MODULE_NOT_FOUND.
9
+ *
10
+ * A secret is handed to the registry as a value rather than exported first,
11
+ * which is what lets several of these run at once: two probes sharing
12
+ * `process.env` would each read the other's key.
9
13
  */
10
14
  export declare function probe(store: KeyStore, entry: KeyEntry): Promise<KeyCheck>;
11
15
  /**
12
- * Probes many entries, one at a time. In flight together would be quicker, but
13
- * `probe` reaches the SDKs the only way they can be reached through
14
- * `process.env` and two probes sharing that variable would each read the
15
- * other's key. One at a time is also what makes progress reportable: there is
16
- * exactly one answer being waited on, and `onProbe` can name it.
16
+ * Probes many entries together, because they are independent questions asked
17
+ * of different vendors and the answer is a round trip apiece: in sequence,
18
+ * five keys is five deadlines end to end, and `zen init` spends them all
19
+ * before it writes a file.
20
+ *
21
+ * The exception is a credential the SDK can only be given through the
22
+ * environment — the vertex service-account file — since two of those in flight
23
+ * would each read the other's path. Those go one at a time, after the rest.
24
+ *
25
+ * `onProbe` therefore reports what has *finished* rather than what is being
26
+ * waited on; with several in the air there is no single one to name.
17
27
  *
18
28
  * Pairs rather than a map, because the caller needs the entry itself to record
19
29
  * the result against, and a map keyed by a string would only have to be
20
30
  * un-joined again.
21
31
  */
22
- export declare function probeAll(store: KeyStore, entries: readonly KeyEntry[], onProbe?: (entry: KeyEntry, index: number, total: number) => void): Promise<[KeyEntry, KeyCheck][]>;
32
+ export declare function probeAll(store: KeyStore, entries: readonly KeyEntry[], onProbe?: (entry: KeyEntry, done: number, total: number) => void): Promise<[KeyEntry, KeyCheck][]>;
23
33
  //# sourceMappingURL=liveness.d.ts.map
package/dist/liveness.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { EXA_BASE_URL, ModelRegistry } from '@zenera/neo';
2
- import { SHAPES, } from "./keys.js";
2
+ import { envOf, SHAPES, } from "./keys.js";
3
3
  // ---------------------------------------------------------------------------
4
4
  // Liveness
5
5
  //
@@ -94,18 +94,61 @@ async function within(work) {
94
94
  * The client is built through the library's own registry rather than by
95
95
  * requiring the SDKs directly, so a missing optional dependency produces the
96
96
  * library's "run: npm i openai" message instead of a raw MODULE_NOT_FOUND.
97
+ *
98
+ * A secret is handed to the registry as a value rather than exported first,
99
+ * which is what lets several of these run at once: two probes sharing
100
+ * `process.env` would each read the other's key.
97
101
  */
98
102
  export async function probe(store, entry) {
99
103
  const shape = SHAPES[entry.provider];
100
104
  if (shape.kind === 'service') {
101
105
  return probeService(entry.provider, store.reveal(entry));
102
106
  }
103
- const previous = process.env[shape.env];
104
- process.env[shape.env] = store.reveal(entry);
107
+ const provider = entry.provider;
108
+ const credential = store.reveal(entry);
109
+ // What the entry holds, not what its provider usually holds: a Vertex
110
+ // express key is a secret like any other, and passing it as a value is what
111
+ // lets it be asked at the same time as the rest.
112
+ if (entry.holds !== 'file') {
113
+ return ask(provider, { kind: provider, apiKey: credential });
114
+ }
115
+ // A service-account file is the exception, and `probeAll` knows it:
116
+ // Application Default Credentials are found through the environment or not
117
+ // at all. So this one is exported, asked, and put back — alone.
118
+ const name = envOf(entry);
119
+ const restore = exportTemporarily({
120
+ [name]: credential,
121
+ ...(entry.project ? { GOOGLE_CLOUD_PROJECT: entry.project } : {}),
122
+ ...(entry.location ? { GOOGLE_CLOUD_LOCATION: entry.location } : {}),
123
+ });
124
+ try {
125
+ return await ask(provider, { kind: provider });
126
+ }
127
+ finally {
128
+ restore();
129
+ }
130
+ }
131
+ /** Sets variables, and hands back the undo. */
132
+ function exportTemporarily(vars) {
133
+ const previous = new Map(Object.keys(vars).map((name) => [name, process.env[name]]));
134
+ Object.assign(process.env, vars);
135
+ return () => {
136
+ for (const [name, value] of previous) {
137
+ if (value === undefined) {
138
+ delete process.env[name];
139
+ }
140
+ else {
141
+ process.env[name] = value;
142
+ }
143
+ }
144
+ };
145
+ }
146
+ /** One authenticated round trip, and what its silence or refusal means. */
147
+ async function ask(provider, spec) {
105
148
  try {
106
149
  const registry = new ModelRegistry();
107
- registry.provider('probe', { kind: entry.provider });
108
- await within(authenticate(entry.provider, registry.client('probe')));
150
+ registry.provider('probe', spec);
151
+ await within(authenticate(provider, registry.client('probe')));
109
152
  return { state: 'live', at: new Date().toISOString() };
110
153
  }
111
154
  catch (err) {
@@ -118,14 +161,6 @@ export async function probe(store, entry) {
118
161
  }
119
162
  return classify(err);
120
163
  }
121
- finally {
122
- if (previous === undefined) {
123
- delete process.env[shape.env];
124
- }
125
- else {
126
- process.env[shape.env] = previous;
127
- }
128
- }
129
164
  }
130
165
  /**
131
166
  * A service has no model catalog to list, and its cheapest endpoint is one that
@@ -200,22 +235,38 @@ async function authenticate(provider, client) {
200
235
  void result;
201
236
  }
202
237
  /**
203
- * Probes many entries, one at a time. In flight together would be quicker, but
204
- * `probe` reaches the SDKs the only way they can be reached through
205
- * `process.env` and two probes sharing that variable would each read the
206
- * other's key. One at a time is also what makes progress reportable: there is
207
- * exactly one answer being waited on, and `onProbe` can name it.
238
+ * Probes many entries together, because they are independent questions asked
239
+ * of different vendors and the answer is a round trip apiece: in sequence,
240
+ * five keys is five deadlines end to end, and `zen init` spends them all
241
+ * before it writes a file.
242
+ *
243
+ * The exception is a credential the SDK can only be given through the
244
+ * environment — the vertex service-account file — since two of those in flight
245
+ * would each read the other's path. Those go one at a time, after the rest.
246
+ *
247
+ * `onProbe` therefore reports what has *finished* rather than what is being
248
+ * waited on; with several in the air there is no single one to name.
208
249
  *
209
250
  * Pairs rather than a map, because the caller needs the entry itself to record
210
251
  * the result against, and a map keyed by a string would only have to be
211
252
  * un-joined again.
212
253
  */
213
254
  export async function probeAll(store, entries, onProbe) {
214
- const out = [];
215
- for (const [index, entry] of entries.entries()) {
216
- onProbe?.(entry, index, entries.length);
217
- out.push([entry, await probe(store, entry)]);
255
+ const exclusive = (entry) => entry.holds === 'file';
256
+ const results = new Map();
257
+ let done = 0;
258
+ const record = (entry, check) => {
259
+ results.set(entry, check);
260
+ onProbe?.(entry, ++done, entries.length);
261
+ };
262
+ await Promise.all(entries
263
+ .filter((entry) => !exclusive(entry))
264
+ .map(async (entry) => record(entry, await probe(store, entry))));
265
+ for (const entry of entries.filter(exclusive)) {
266
+ record(entry, await probe(store, entry));
218
267
  }
219
- return out;
268
+ // Back into the caller's order: which one answered first is an accident of
269
+ // the network, and a list that reshuffles itself between runs is unreadable.
270
+ return entries.map((entry) => [entry, results.get(entry)]);
220
271
  }
221
272
  //# sourceMappingURL=liveness.js.map
package/dist/main.js CHANGED
File without changes
package/dist/podman.d.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import { runProcess } from '@zenera/neo';
2
+ import type { ResolvedBuild } from './image.ts';
3
+ import { CliError } from './term.ts';
2
4
  export interface PodmanOptions {
3
5
  /** the image the project needs on disk before the first run */
4
6
  image?: string;
7
+ /** a Dockerfile to build into `image`, instead of a registry to pull it from */
8
+ build?: ResolvedBuild;
9
+ /** build even when the tag is already on disk; `zen sandbox pull` */
10
+ rebuild?: boolean;
5
11
  /** machine size, when one has to be created */
6
12
  cpus?: number;
7
13
  /** MiB */
@@ -28,12 +34,36 @@ export interface PodmanStatus {
28
34
  imagePresent?: boolean;
29
35
  }
30
36
  export declare function ensurePodmanReady(opts?: PodmanOptions): Promise<void>;
37
+ /**
38
+ * Builds the project's Dockerfile under its tag.
39
+ *
40
+ * Run every time rather than skipped when the tag already exists, because the
41
+ * tag is a hash of the Dockerfile and its context and the engine's layer cache
42
+ * is a hash of the same thing plus the base image. Asking it is cheap, and it
43
+ * is the only thing that notices when `FROM node:24` starts meaning a different
44
+ * node:24.
45
+ */
46
+ /**
47
+ * A build that ran and failed, rather than a host that could not be asked.
48
+ *
49
+ * The difference is invisible at the command line — both stop the run and
50
+ * print why — but it decides everything for `zen check`: a Dockerfile that
51
+ * does not build is a broken project, and a laptop without podman on it is
52
+ * not. Only one of them is an error.
53
+ */
54
+ export declare class BuildError extends CliError {
55
+ }
31
56
  /** What `zn sandbox status` prints. Changes nothing, and never throws. */
32
57
  export declare function podmanStatus(opts?: PodmanOptions): Promise<PodmanStatus>;
33
58
  export interface OwnedContainer {
34
59
  name: string;
35
60
  /** podman's own word: `running`, `exited`, `created`, `paused` */
36
61
  state: string;
62
+ /** the session that owns it, from the `zenera.key` label */
63
+ key?: string;
64
+ createdAt?: string;
65
+ /** bytes written on top of the image; only present when `sizes` is asked for */
66
+ size?: number;
37
67
  }
38
68
  /**
39
69
  * Containers this CLI created, whatever session they belong to, and whether
@@ -41,6 +71,32 @@ export interface OwnedContainer {
41
71
  * *stopped* container behind, and a listing that only showed running ones
42
72
  * would say nothing is there while the disk says otherwise.
43
73
  */
44
- export declare function ownedContainers(engine?: string, exec?: import("@zenera/neo").Runner): Promise<OwnedContainer[]>;
74
+ export declare function ownedContainers(engine?: string, exec?: import("@zenera/neo").Runner, opts?: {
75
+ sizes?: boolean;
76
+ }): Promise<OwnedContainer[]>;
45
77
  export declare function removeContainers(names: readonly string[], engine?: string, exec?: import("@zenera/neo").Runner): Promise<void>;
78
+ export interface DiskLine {
79
+ count: number;
80
+ active: number;
81
+ size: number;
82
+ reclaimable: number;
83
+ }
84
+ export interface EngineDisk {
85
+ images: DiskLine;
86
+ containers: DiskLine;
87
+ volumes: DiskLine;
88
+ /** the filesystem images and layers are kept on — inside the machine, if there is one */
89
+ store?: {
90
+ used: number;
91
+ capacity: number;
92
+ };
93
+ /** the machine's disk image, as it costs this host; absent on Linux */
94
+ image?: {
95
+ name: string;
96
+ path: string;
97
+ allocated: number;
98
+ };
99
+ }
100
+ /** What the engine is using. Never throws: a disk report is not worth a crash. */
101
+ export declare function engineDisk(engine?: string, exec?: import("@zenera/neo").Runner): Promise<EngineDisk | undefined>;
46
102
  //# sourceMappingURL=podman.d.ts.map