From 30d93dddc5a1f6045321ad306639d2e8edad88e4 Mon Sep 17 00:00:00 2001 From: Benjamin Diedrichsen Date: Tue, 28 Jul 2026 09:27:05 +0200 Subject: [PATCH] implementing --use-defaults --- DOCS-AUDIT.md | 68 +++++++++----- packages/nopy/README.md | 29 +++++- packages/nopy/docs/REFACTORING.md | 13 +++ packages/nopy/src/cubes/dependencies.ts | 28 +++++- packages/nopy/src/cubes/types.ts | 49 +++++++++- packages/nopy/src/nopy.common.ts | 12 ++- packages/nopy/src/nopy.prompts.ts | 16 +++- packages/nopy/tests/common.test.ts | 78 +++++++++++++++ .../tests/cubes.dependencies.edge.test.ts | 93 ++++++++++++++++++ packages/nopy/tests/cubes.types.test.ts | 94 +++++++++++++++++++ packages/nopy/tests/prompts.test.ts | 36 ++++++- 11 files changed, 474 insertions(+), 42 deletions(-) create mode 100644 packages/nopy/tests/common.test.ts create mode 100644 packages/nopy/tests/cubes.types.test.ts diff --git a/DOCS-AUDIT.md b/DOCS-AUDIT.md index 6e24a72..f3858fe 100644 --- a/DOCS-AUDIT.md +++ b/DOCS-AUDIT.md @@ -14,6 +14,10 @@ Severity is about what it costs a reader: Verified against the working tree at commit `fcc1817`. Line numbers are from that state. +Findings closed since are marked **✅ … fixed** and keep their original text as +the record of what was wrong. So far: §1.1 (`--use-defaults`), §2.2 +(`getDefaults()`), half of §2.1 (precedence), and one bullet of §6.4. + --- ## Contents @@ -34,7 +38,13 @@ state. These are the same class of problem as the `--parallel` flag that was removed earlier: documented in detail, absent from the source. -### 1.1 🔴 `-D, --use-defaults` does nothing +### 1.1 ✅ `-D, --use-defaults` does nothing — **fixed** + +> **Resolved.** The flag is now implemented; see `docs/REFACTORING.md` item 5. +> `BuildContext.resolveCube` skips the prompts, `env` in `.nopyrc.json` outranks +> the schema default so a non-interactive run can be configured, and a cube with +> a variable nothing can fill aborts the run by name instead of deploying it +> blank. The finding below is kept as the record of what was wrong. | | | |---|---| @@ -128,9 +138,16 @@ dependency detected". ## 2. Documented behaviour that differs from the code -### 2.1 🔴 Variable precedence is wrong in both directions +### 2.1 🟡 Variable precedence is wrong in both directions — **partly fixed** -`README.md:107-113` states: +> **Half resolved.** `env` now outranks the Zod defaults, as documented — this +> was a prerequisite for `--use-defaults` being configurable at all. The second +> pair was deliberately left: dependency/hook params still outrank prompts. In +> practice they do not compete, because `VariableAssignment` leaves out any key +> a dependency already supplied, so the operator is never asked about it. The +> README now describes that rather than the old claim. + +`README.md:107-113` stated: > **Priority order (lowest to highest):** > 1. Zod schema `.default()` values @@ -161,7 +178,13 @@ Two pairs are inverted, and both have consequences: `[['user:add', {USER: 'deploy'}]]` silently overrides what the operator just typed at the form. The docs promise the opposite. -### 2.2 🔴 "Every key ... is guaranteed to be present on `host.data`" — not when a field lacks `.default()` +### 2.2 ✅ "Every key ... is guaranteed to be present on `host.data`" — not when a field lacks `.default()` — **fixed** + +> **Resolved.** `getDefaults()` falls back to a per-field read, so one required +> field no longer wipes out the rest; `VariableAssignment` prompts for every +> schema key rather than only the defaulted ones; and `--use-defaults` refuses +> to deploy a cube whose required key nothing supplied. Verified against all 22 +> cubes in `cubes/`: 19 build a complete `-D` run, the 3 below abort by name. `README.md:99` states it outright; `README.md:105` claims defaults ensure "every cube has a predictable starting state". @@ -217,7 +240,7 @@ anyone copying it gets bare `UPDATE` / `PACKAGES` keys as prompt labels instead of the sentences they wrote. `docs/API.md:610` happens to use the working order — the two documents disagree, and neither mentions that it matters. -14 of the 21 cubes in `cubes/` are affected; among them +15 of the 22 cubes in `cubes/` are affected; among them `net:tailscale` (all 4 fields), `runtime:nodevm` (all 4), `user:add` (all 4), `ssh:keygen` (all 4) and `admin:locale` (all 4). @@ -442,10 +465,10 @@ packages/nopy/node_modules/@bitsquare/ → absent cubes/node_modules/ → absent ``` -`@bitsquare/nopy` is the documented half. **`zod` is the other half** — 19 of 21 +`@bitsquare/nopy` is the documented half. **`zod` is the other half** — 20 of 22 manifests `import { z } from 'zod'`, and that fails independently of the nopy link. Verified by loading every manifest with a resolver hook: with only -`@bitsquare/nopy` mapped, 19 of 21 fail `ERR_MODULE_NOT_FOUND: zod`. +`@bitsquare/nopy` mapped, 20 of 22 fail `ERR_MODULE_NOT_FOUND: zod`. Because `loadCubes` turns each failure into an `errors` entry and `nopy.main.ts:147-152` aborts when `errors.length > 0`, a fresh clone cannot run a single cube. Neither @@ -527,7 +550,7 @@ a project without `KEY_DIR` in their config gets `None`. ## 5. Cube documentation Two cubes have **no README at all**: `cubes/admin/hostname` and `cubes/git/clone` -(19 of 21 have one). +(20 of 22 have one). ### 5.1 🔴 `cubes/service/autostart/README.md` documents a different cube @@ -635,9 +658,11 @@ the three has to give. ### 6.4 🟡 Debug output left in the shipped code -- `nopy.common.ts:22` — `console.log('Assigning', artefactId, scope, values)` +- ~~`nopy.common.ts:22` — `console.log('Assigning', artefactId, scope, values)` fires on every variable assignment, printing values to the console. Combined - with §4.2 this is a second path by which secrets reach stdout. + with §4.2 this is a second path by which secrets reach stdout.~~ **Removed** + alongside the `--use-defaults` work; it would have made an unattended run + unreadable. The two other paths in §4.2 are untouched. - `keyman.encrypt.ts:19-20` — `console.log(tmpKeys); console.log(sshKeys);` before the prompt. @@ -683,19 +708,12 @@ Recording what was verified and found correct, so a future pass need not redo it ## Suggested order of attack -**1 — Decide on the three phantom features.** §1.1 (`-D`), §1.2 (`--json`) and -§1.3 (`log.*`) are all "documented, wired up, never read". Each is a small -implementation or a small deletion; what they cannot stay is documented as -working. `-D` is the most damaging, since it is advertised as the unattended path. - -**2 — Fix `getDefaults()` swallowing the schema (§2.2).** One `catch` block -silently disables three cubes today and will disable any cube whose author -forgets a `.default()`. Falling back per-field, or surfacing the parse error as a -load-time `errors` entry, both beat returning `{}`. - -**3 — Correct the precedence table (§2.1).** Four lines of README, but it -documents the system backwards in both directions, and the `env`-override promise -is the one people will build on. +**1 — ~~Decide on the three phantom features.~~ Two left.** §1.1 (`-D`) is +**done** — implemented, tested, and verified against every cube in `cubes/`. +That closed §2.2 and half of §2.1 with it, since neither could be left standing +under a run that never prompts. §1.2 (`--json`) and §1.3 (`log.*`) are still +"documented, wired up, never read": each is a small implementation or a small +deletion, but neither can stay documented as working. **4 — Decide the `.describe()`/`.default()` ordering (§2.3).** Either read through the `ZodDefault` wrapper in `nopy.prompts.ts`, or fix the ordering in all @@ -708,7 +726,7 @@ one whole module, and two functions describe code that no longer exists. worst — its README belongs to a different cube, and its `deploy.py` does not run at all (§6.1). -**7 — Secrets on stdout (§4.2, §6.4).** Drop the `console.log` in -`Variables.assign`, mask the password in the executor's debug line and in the +**7 — Secrets on stdout (§4.2, §6.4).** The `console.log` in `Variables.assign` +is gone. Still open: mask the password in the executor's debug line and in the dry-run plan, and pass `--user`/`--password` as argv rather than interpolating into a shell string. diff --git a/packages/nopy/README.md b/packages/nopy/README.md index 81ed6d3..2838c3c 100644 --- a/packages/nopy/README.md +++ b/packages/nopy/README.md @@ -108,10 +108,14 @@ Variable defaults are defined directly in the Zod schema using `.default()`. Thi 1. Zod schema `.default()` values 2. Global `env` from `.nopyrc.json` -3. Accumulated variables from dependencies -4. User prompts / session replay +3. User prompts, or the recorded answers on session replay +4. Variables passed in by a dependency or a hook -This allows cubes to ship with reasonable defaults while still allowing users to override them globally via `.nopyrc.json` or interactively during deployment. +This allows cubes to ship with reasonable defaults while still allowing users to override them globally via `.nopyrc.json` or interactively during deployment. Because `env` outranks the schema, `.nopyrc.json` is also what steers a run started with `--use-defaults`, which never prompts. + +3 and 4 rarely compete: a key a dependency supplies is left out of the prompt entirely, so the user is only ever asked about the keys nothing else has set. + +A field declared without `.default()` has none of sources 1 and 2 to fall back on. It is prompted for like any other, with an empty initial value — but a run that cannot prompt (`--use-defaults`) fails on it unless `env` or a dependency provides it. ### Configuration @@ -296,6 +300,25 @@ nopy install --use-defaults nopy install -D ``` +Skips the per-cube variable form. Every variable is taken from the sources that +need no interaction — the Zod `.default()`, `env` in `.nopyrc.json`, and values +handed over by a dependency or a hook — which is what makes `.nopyrc.json` the +place to configure an unattended run. + +Cube selection, host and authentication are still asked for; there is nowhere +else for them to come from. Pair `-D` with `-K` to skip the auth question too, +or with `-R` / `-H` / `-l`, which supply all three from the recorded session. + +A cube whose schema declares a field with **no** `.default()` cannot be filled in +this way, so the run stops before anything is deployed rather than passing the +variable as empty: + +``` +Error: Cube "net:wifi:connection" cannot run with --use-defaults: SSID, PASSWORD +have no default values. Set them under "env" in .nopyrc.json, pass them from a +dependency, or drop --use-defaults to be prompted. +``` + **Use SSH key authentication**: ```bash diff --git a/packages/nopy/docs/REFACTORING.md b/packages/nopy/docs/REFACTORING.md index d6af807..2e270d4 100644 --- a/packages/nopy/docs/REFACTORING.md +++ b/packages/nopy/docs/REFACTORING.md @@ -41,3 +41,16 @@ This document tracks the major refactoring of the `nopy` package. - `Cube` is a class encapsulating a `Manifest` and runtime info (`dir`, `deployScript`). - **Proposed Solution**: (Done) +### 5. Make `--use-defaults` operational +- **Status**: ✅ Completed +- **Goal**: Turn `-D` from a flag that was parsed and threaded through three layers but never read into a working non-interactive mode. +- **Rationale**: Unattended runs — CI, or provisioning a fresh box from a checked-in `.nopyrc.json` — are the reason the flag exists. It prompted anyway. +- **Context**: + - `BuildContext.resolveCube` branches on `options.useDefaults` and skips `VariableAssignment`. + - `Variables.get()` merge order corrected to defaults → global `env` → prompts → params. `env` used to lose to the schema default, which left a non-interactive run with no way to be configured at all. + - Replayed session values moved from the `defaults` scope to `prompts`, so they keep outranking `env` now that `env` sits higher. + - `Cube.getDefaults()` no longer discards every default when one field lacks `.default()`; it falls back to a per-field read. + - `Cube.requiredKeys()` added, and a `-D` run fails naming the unfillable variables instead of deploying a cube with them absent from `--data`. + - `VariableAssignment` offers every schema key, not only the ones carrying a default, and shows the value the run would actually use as the initial. +- **Proposed Solution**: (Done) + diff --git a/packages/nopy/src/cubes/dependencies.ts b/packages/nopy/src/cubes/dependencies.ts index 966ff4b..4f1517f 100644 --- a/packages/nopy/src/cubes/dependencies.ts +++ b/packages/nopy/src/cubes/dependencies.ts @@ -32,11 +32,32 @@ export class BuildContext { password?: string; }, public readonly options: { + /** Skip the variable prompts and take whatever the non-interactive scopes hold. */ useDefaults?: boolean; isSessionReplay?: boolean; } = {} ) {} + /** + * Fails a non-interactive run that cannot fill a required variable. + * + * Without this the cube would be deployed with the key simply absent from + * `--data`, and the deploy script would read `None` off `host.data`. + */ + private assertVariablesComplete(cube: Cube): void { + const resolved = this.variables.get(cube.id); + const missing = cube.requiredKeys().filter((key) => resolved[key] === undefined); + if (missing.length === 0) return; + + const [one, them] = + missing.length === 1 ? ['has no default value', 'it'] : ['have no default values', 'them']; + throw new Error( + `Cube "${cube.id}" cannot run with --use-defaults: ${missing.join(', ')} ${one}. ` + + `Set ${them} under "env" in .nopyrc.json, pass ${them} from a dependency, ` + + 'or drop --use-defaults to be prompted.' + ); + } + /** * Resolves a cube, its dependencies, and hooks recursively */ @@ -60,10 +81,15 @@ export class BuildContext { // 2. Variable collection if (this.options.isSessionReplay) { + // Recorded answers go back into the scope they came from, so a replay + // reproduces them even when `env` sets the same key to something else. const sessionCube = this.session.cubes.find((c) => c.key === cubeId); if (sessionCube) { - this.variables.assign(cubeId, 'defaults', sessionCube.variables); + this.variables.assign(cubeId, 'prompts', sessionCube.variables); } + } else if (this.options.useDefaults) { + log.debug('Skipping prompts, using defaults', { cubeId }); + this.assertVariablesComplete(cube); } else { await VariableAssignment(cube, this.variables); } diff --git a/packages/nopy/src/cubes/types.ts b/packages/nopy/src/cubes/types.ts index d902580..007dbc6 100644 --- a/packages/nopy/src/cubes/types.ts +++ b/packages/nopy/src/cubes/types.ts @@ -82,6 +82,24 @@ export namespace Manifest { } } +/** + * Reads the `.default()` off a schema field, unwrapping the wrappers that may + * sit above it (`.default().optional()`, `.default().nullable()`). + * + * Returns `undefined` for a field that declares no default — which is also how + * `requiredKeys()` recognises a field the user has to supply. + */ +function defaultValueOf(zodType: z.ZodType): unknown { + if (zodType instanceof z.ZodDefault) { + const { defaultValue } = zodType._def as { defaultValue: unknown }; + return typeof defaultValue === 'function' ? defaultValue() : defaultValue; + } + if (zodType instanceof z.ZodOptional || zodType instanceof z.ZodNullable) { + return defaultValueOf(zodType._def.innerType as z.ZodType); + } + return undefined; +} + /** * A fully loaded cube with its filesystem location and runtime state */ @@ -101,14 +119,35 @@ export class Cube { } /** - * Returns default values for the cube's schema + * Returns default values for the cube's schema. + * + * Parsing an empty object resolves every default in one go, but it fails + * outright as soon as one field has no `.default()`. Falling back to a + * per-field read keeps the defaults that *are* declared instead of dropping + * the whole set — a single required field used to leave the cube with no + * variables at all. */ getDefaults(): z.infer { - try { - return this.manifest.schema.parse({}); - } catch { - return {} as z.infer; + const parsed = this.manifest.schema.safeParse({}); + if (parsed.success) return parsed.data as z.infer; + + const defaults: Record = {}; + for (const [key, zodType] of Object.entries(this.manifest.schema.shape)) { + const value = defaultValueOf(zodType); + if (value !== undefined) defaults[key] = value; } + return defaults as z.infer; + } + + /** + * Schema keys that have to be supplied from somewhere: no `.default()`, and + * not optional. Nothing else can fill them in, so a run that cannot prompt + * has to fail rather than deploy a cube with the value missing. + */ + requiredKeys(): string[] { + return Object.entries(this.manifest.schema.shape) + .filter(([, zodType]) => !zodType.safeParse(undefined).success) + .map(([key]) => key); } } diff --git a/packages/nopy/src/nopy.common.ts b/packages/nopy/src/nopy.common.ts index bf6efb4..7311613 100644 --- a/packages/nopy/src/nopy.common.ts +++ b/packages/nopy/src/nopy.common.ts @@ -19,7 +19,6 @@ export class Variables { constructor(readonly global: TVariables = {}) {} assign(artefactId: Variables.ArtefactId, scope: Variables.Scope, values: TVariables = {}) { - console.log('Assigning', artefactId, scope, values); if (!this[scope][artefactId]) { this[scope][artefactId] = values; } else { @@ -27,13 +26,22 @@ export class Variables { } } + /** + * Merges the scopes for one cube, lowest precedence first: + * schema defaults → global `env` → prompts (or replayed session values) → + * params handed over by a dependency or a hook. + * + * Defaults sit at the bottom so `env` in `.nopyrc.json` can steer a run that + * never prompts (`--use-defaults`); a key that a dependency supplies is never + * prompted for, so prompts and params do not compete in practice. + */ get(artefactId: Variables.ArtefactId, scope?: Variables.Scope): TVariables { if (scope) { return this[scope][artefactId] || {}; } return { - ...this.global, ...this.defaults[artefactId], + ...this.global, ...this.prompts[artefactId], ...this.params[artefactId], }; diff --git a/packages/nopy/src/nopy.prompts.ts b/packages/nopy/src/nopy.prompts.ts index a9cdb09..b473acd 100644 --- a/packages/nopy/src/nopy.prompts.ts +++ b/packages/nopy/src/nopy.prompts.ts @@ -169,13 +169,19 @@ export async function VariableAssignment( variables: Variables ) { const schema = cube.manifest.schema.shape; - const defaults = cube.getDefaults(); + const defaults = cube.getDefaults() as Record; + const params = variables.get(cube.id, 'params'); + const resolved = variables.get(cube.id); const variablesToConfigure: Record = {}; - for (const [key, defaultValue] of Object.entries(defaults)) { - if (variables.get(cube.id, 'params')[key] === undefined) { - variablesToConfigure[key] = defaultValue; - } + // Every schema key is offered, not just the ones carrying a `.default()` — a + // field without one is precisely the field that has to be asked about. Keys a + // dependency or hook already supplied are left alone. The value shown is the + // one the run would otherwise use, so `env` from `.nopyrc.json` is visible + // (and editable) rather than silently overridden by whatever is typed. + for (const key of Object.keys(schema)) { + if (params[key] !== undefined) continue; + variablesToConfigure[key] = resolved[key] ?? defaults[key]; } if (Object.keys(variablesToConfigure).length === 0) return; diff --git a/packages/nopy/tests/common.test.ts b/packages/nopy/tests/common.test.ts new file mode 100644 index 0000000..674772e --- /dev/null +++ b/packages/nopy/tests/common.test.ts @@ -0,0 +1,78 @@ +/** + * Tests for the Variables scope container. + * + * The merge order is what makes a non-interactive run configurable, so it is + * pinned down here rather than left to the callers to demonstrate. + */ + +import { describe, expect, it } from 'vitest'; +import { Variables } from '../src/nopy.common.js'; + +describe('Variables.assign', () => { + it('creates the scope entry on first assignment and merges afterwards', () => { + const variables = new Variables(); + + variables.assign('cube-a', 'defaults', { A: 1 }); + variables.assign('cube-a', 'defaults', { B: 2 }); + + expect(variables.get('cube-a', 'defaults')).toEqual({ A: 1, B: 2 }); + }); + + it('defaults to an empty assignment', () => { + const variables = new Variables(); + + variables.assign('cube-a', 'prompts'); + + expect(variables.get('cube-a', 'prompts')).toEqual({}); + }); + + it('keeps scopes and cubes apart', () => { + const variables = new Variables(); + + variables.assign('cube-a', 'params', { A: 1 }); + + expect(variables.get('cube-a', 'prompts')).toEqual({}); + expect(variables.get('cube-b', 'params')).toEqual({}); + }); +}); + +describe('Variables.get precedence', () => { + it('lets global env override a schema default', () => { + const variables = new Variables({ PORT: 2222 }); + variables.assign('cube-a', 'defaults', { PORT: 22 }); + + expect(variables.get('cube-a').PORT).toBe(2222); + }); + + it('lets a prompt override global env', () => { + const variables = new Variables({ PORT: 2222 }); + variables.assign('cube-a', 'defaults', { PORT: 22 }); + variables.assign('cube-a', 'prompts', { PORT: 8080 }); + + expect(variables.get('cube-a').PORT).toBe(8080); + }); + + it('lets a dependency param override everything else', () => { + const variables = new Variables({ PORT: 2222 }); + variables.assign('cube-a', 'defaults', { PORT: 22 }); + variables.assign('cube-a', 'prompts', { PORT: 8080 }); + variables.assign('cube-a', 'params', { PORT: 9090 }); + + expect(variables.get('cube-a').PORT).toBe(9090); + }); + + it('merges keys from every scope', () => { + const variables = new Variables({ G: 'g' }); + variables.assign('cube-a', 'defaults', { D: 'd' }); + variables.assign('cube-a', 'prompts', { P: 'p' }); + variables.assign('cube-a', 'params', { X: 'x' }); + + expect(variables.get('cube-a')).toEqual({ G: 'g', D: 'd', P: 'p', X: 'x' }); + }); + + it('applies global env to every cube', () => { + const variables = new Variables({ SHARED: 'yes' }); + + expect(variables.get('anything').SHARED).toBe('yes'); + }); +}); diff --git a/packages/nopy/tests/cubes.dependencies.edge.test.ts b/packages/nopy/tests/cubes.dependencies.edge.test.ts index 0a0bead..f61729e 100644 --- a/packages/nopy/tests/cubes.dependencies.edge.test.ts +++ b/packages/nopy/tests/cubes.dependencies.edge.test.ts @@ -104,6 +104,99 @@ describe('BuildContext session replay', () => { }); }); +describe('BuildContext --use-defaults', () => { + const withDefaults = (cube: Cube, variables = new Variables(), cfg = config) => + new BuildContext( + { [cube.id]: cube }, + variables, + session(), + cfg, + { method: 'ssh' }, + { useDefaults: true } + ); + + it('skips the prompts and deploys the schema defaults', async () => { + const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') })); + const context = withDefaults(cube); + + await context.resolveCube('cube-a', 'host1'); + + expect(VariableAssignment).not.toHaveBeenCalled(); + expect(context.deployCalls[0].env.PORT).toBe('3000'); + }); + + it('lets global env steer the run', async () => { + const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') })); + const context = withDefaults(cube, new Variables({ PORT: '8080' })); + + await context.resolveCube('cube-a', 'host1'); + + expect(context.deployCalls[0].command.join(' ')).toContain('--data "PORT=8080"'); + }); + + it('refuses to run a cube whose variable nothing can supply', async () => { + const cube = testCube('cube-a', z.object({ SSID: z.string(), PSK: z.string() })); + const context = withDefaults(cube); + + await expect(context.resolveCube('cube-a', 'host1')).rejects.toThrow( + /Cube "cube-a" cannot run with --use-defaults: SSID, PSK have no default values/ + ); + expect(context.deployCalls).toHaveLength(0); + }); + + it('names a single missing variable in the singular', async () => { + const cube = testCube('cube-a', z.object({ SSID: z.string() })); + + await expect(withDefaults(cube).resolveCube('cube-a', 'host1')).rejects.toThrow( + 'SSID has no default value' + ); + }); + + it('accepts a required variable supplied by global env', async () => { + const cube = testCube('cube-a', z.object({ SSID: z.string() })); + const context = withDefaults(cube, new Variables({ SSID: 'home' })); + + await context.resolveCube('cube-a', 'host1'); + + expect(context.deployCalls[0].env.SSID).toBe('home'); + }); + + it('accepts a required variable supplied by a dependency', async () => { + const cube = testCube('cube-a', z.object({ SSID: z.string() })); + const context = withDefaults(cube); + + await context.resolveCube('cube-a', 'host1', { SSID: 'from-dep' }); + + expect(context.deployCalls[0].env.SSID).toBe('from-dep'); + }); + + it('still resolves dependencies and hooks', async () => { + const dep = testCube('dep'); + const main = new Cube( + Manifest.create({ + id: 'main', + name: 'Main', + schema: z.object({ FLAG: z.boolean().default(true) }), + dependencies: (vars: Record) => (vars.FLAG ? ['dep'] : []), + }), + '/test/main', + 'deploy.py' + ); + const context = new BuildContext( + { dep, main }, + new Variables(), + session(), + config, + { method: 'ssh' }, + { useDefaults: true } + ); + + await context.resolveCube('main', 'host1'); + + expect(context.deployCalls.map((c) => c.cube)).toEqual(['dep', 'main']); + }); +}); + describe('BuildContext command construction', () => { const build = (auth: { method: string; username?: string; password?: string }) => { const context = new BuildContext( diff --git a/packages/nopy/tests/cubes.types.test.ts b/packages/nopy/tests/cubes.types.test.ts new file mode 100644 index 0000000..420f193 --- /dev/null +++ b/packages/nopy/tests/cubes.types.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for the Cube runtime wrapper: default extraction and the required-key + * check that `--use-defaults` relies on. + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { Cube, Manifest } from '../src/cubes/types.js'; + +const cube = (schema: z.ZodObject) => + new Cube(Manifest.create({ id: 'c', name: 'C', schema }), '/cubes/c', 'deploy.py'); + +describe('Cube.getDefaults', () => { + it('resolves every default when the whole schema parses', () => { + const c = cube( + z.object({ + PORT: z.number().default(8080), + NAME: z.string().default('svc'), + }) + ); + + expect(c.getDefaults()).toEqual({ PORT: 8080, NAME: 'svc' }); + }); + + it('keeps the declared defaults when one field has none', () => { + const c = cube( + z.object({ + SSID: z.string(), + PRIORITY: z.number().default(10), + HIDDEN: z.boolean().default(false), + }) + ); + + expect(c.getDefaults()).toEqual({ PRIORITY: 10, HIDDEN: false }); + }); + + it('unwraps a default sitting under optional or nullable', () => { + const c = cube( + z.object({ + REQUIRED: z.string(), + A: z.number().default(1).optional(), + B: z.number().default(2).nullable(), + C: z.number().optional().default(3), + }) + ); + + expect(c.getDefaults()).toEqual({ A: 1, B: 2, C: 3 }); + }); + + it('evaluates a lazily declared default', () => { + const c = cube( + z.object({ REQUIRED: z.string(), TOKEN: z.string().default(() => 'generated') }) + ); + + expect(c.getDefaults()).toEqual({ TOKEN: 'generated' }); + }); + + it('omits an optional field that declares no default', () => { + const c = cube(z.object({ REQUIRED: z.string(), MAYBE: z.string().optional() })); + + expect(c.getDefaults()).toEqual({}); + }); + + it('returns an empty object for an empty schema', () => { + expect(cube(z.object({})).getDefaults()).toEqual({}); + }); +}); + +describe('Cube.requiredKeys', () => { + it('lists the fields with neither a default nor optionality', () => { + const c = cube( + z.object({ + SSID: z.string(), + PASSWORD: z.string(), + PRIORITY: z.number().default(10), + NOTE: z.string().optional(), + }) + ); + + expect(c.requiredKeys()).toEqual(['SSID', 'PASSWORD']); + }); + + it('treats a nullable field without a default as required', () => { + const c = cube(z.object({ MAYBE: z.string().nullable() })); + + expect(c.requiredKeys()).toEqual(['MAYBE']); + }); + + it('is empty when every field can fill itself in', () => { + const c = cube(z.object({ A: z.string().default('a'), B: z.string().optional() })); + + expect(c.requiredKeys()).toEqual([]); + }); +}); diff --git a/packages/nopy/tests/prompts.test.ts b/packages/nopy/tests/prompts.test.ts index 9d79cd1..83bbfd5 100644 --- a/packages/nopy/tests/prompts.test.ts +++ b/packages/nopy/tests/prompts.test.ts @@ -9,9 +9,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; -const { inquirerPrompt, formRun, autoCompleteRun, autoCompleteCtor } = vi.hoisted(() => ({ +const { inquirerPrompt, formRun, formCtor, autoCompleteRun, autoCompleteCtor } = vi.hoisted(() => ({ inquirerPrompt: vi.fn(), formRun: vi.fn(), + formCtor: vi.fn(), autoCompleteRun: vi.fn(), autoCompleteCtor: vi.fn(), })); @@ -23,6 +24,9 @@ vi.mock('enquirer', () => ({ default: { Form: class { run = formRun; + constructor(options: unknown) { + formCtor(options); + } }, AutoComplete: class { run = autoCompleteRun; @@ -50,6 +54,12 @@ const question = (name: string) => questions().find((q) => q.name === name); /** Grabs the options the last enquirer AutoComplete prompt was constructed with. */ const autoComplete = () => autoCompleteCtor.mock.calls.at(-1)?.[0] as Record; +/** Grabs the choices the last enquirer Form prompt was constructed with. */ +const formChoices = () => { + const options = formCtor.mock.calls.at(-1)?.[0] as { choices: Record[] }; + return options.choices; +}; + const cube = (id: string, name: string, schema = z.object({})) => new Cube(Manifest({ id, name, schema }), `/cubes/${id}`, 'deploy.py'); @@ -240,6 +250,30 @@ describe('VariableAssignment', () => { expect(variables.get('svc', 'prompts')).toEqual({}); }); + it('asks about a field that declares no default, with an empty initial value', async () => { + const required = z.object({ + SSID: z.string().describe('Network name'), + PRIORITY: z.number().default(10), + }); + formRun.mockResolvedValue({}); + + await VariableAssignment(cube('wifi', 'WiFi', required), new Variables()); + + expect(formChoices()).toEqual([ + { name: 'SSID', message: 'Network name', initial: '' }, + { name: 'PRIORITY', message: 'PRIORITY', initial: '10' }, + ]); + }); + + it('offers the value the run would use, not the bare schema default', async () => { + const variables = new Variables({ port: 2222 }); + formRun.mockResolvedValue({}); + + await VariableAssignment(cube('svc', 'Service', schema), variables); + + expect(formChoices().find((c) => c.name === 'port')?.initial).toBe('2222'); + }); + it('coerces answers using the schema and stores them under prompts', async () => { const variables = new Variables(); formRun.mockResolvedValue({ port: '9090', enabled: 'true', name: 'api' });