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
+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' });