implementing --use-defaults
This commit is contained in:
+43
-25
@@ -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
@@ -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