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
+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;