implementing --use-defaults
This commit is contained in:
+26
-3
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user