implementing --use-defaults

This commit is contained in:
Benjamin Diedrichsen
2026-07-28 09:27:05 +02:00
parent 5ed68c0065
commit 30d93dddc5
11 changed files with 474 additions and 42 deletions
+43 -25
View File
@@ -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.
+26 -3
View File
@@ -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
+13
View File
@@ -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)
+27 -1
View File
@@ -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);
}
+44 -5
View File
@@ -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<Schema extends AnyObjectSchema = AnyObjectSchema> {
}
/**
* 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<Schema> {
try {
return this.manifest.schema.parse({});
} catch {
return {} as z.infer<Schema>;
const parsed = this.manifest.schema.safeParse({});
if (parsed.success) return parsed.data as z.infer<Schema>;
const defaults: Record<string, unknown> = {};
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>;
}
/**
* 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);
}
}
+10 -2
View File
@@ -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],
};
+11 -5
View File
@@ -169,13 +169,19 @@ export async function VariableAssignment<S extends AnyObjectSchema>(
variables: Variables
) {
const schema = cube.manifest.schema.shape;
const defaults = cube.getDefaults();
const defaults = cube.getDefaults() as Record<string, unknown>;
const params = variables.get(cube.id, 'params');
const resolved = variables.get(cube.id);
const variablesToConfigure: Record<string, unknown> = {};
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;
+78
View File
@@ -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');
});
});
@@ -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<string, unknown>) => (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(
+94
View File
@@ -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<any>) =>
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([]);
});
});
+35 -1
View File
@@ -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<string, any>;
/** Grabs the choices the last enquirer Form prompt was constructed with. */
const formChoices = () => {
const options = formCtor.mock.calls.at(-1)?.[0] as { choices: Record<string, any>[] };
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' });