[wip] cubes packaging and distribution via registry
Publish snapshot / snapshot (push) Successful in 1m21s

This commit is contained in:
Benjamin Diedrichsen
2026-07-28 09:37:42 +02:00
parent 30d93dddc5
commit ac050c4459
13 changed files with 414 additions and 103 deletions
+51 -2
View File
@@ -136,14 +136,63 @@ describe('loader edge cases', () => {
expect(errors[0]).toMatch(/Failed to load manifest/);
});
it('reports duplicate cube ids', async () => {
it('reports duplicate cube ids, naming every directory that claims one', async () => {
cube('first', 'export default { id: "dup", name: "First" }');
cube('second', 'export default { id: "dup", name: "Second" }');
const { cubes, errors } = await loadCubes();
expect(Object.keys(cubes)).toEqual(['dup']);
expect(errors[0]).toMatch(/Duplicate cube id 'dup'/);
expect(errors).toHaveLength(1);
expect(errors[0]).toMatch(/Duplicate cube id 'dup' from 2 sources/);
expect(errors[0]).toContain(path.join(tmpDir, 'first'));
expect(errors[0]).toContain(path.join(tmpDir, 'second'));
});
it('keeps scanning below a duplicate instead of dropping the subtree', async () => {
cube('first', 'export default { id: "dup", name: "First" }');
cube('second', 'export default { id: "dup", name: "Second" }');
cube('second/inner', 'export default { id: "buried", name: "Buried" }');
const { cubes, errors } = await loadCubes();
expect(cubes.buried).toBeDefined();
expect(errors).toHaveLength(1);
});
it('reports the same duplicate whichever root is scanned first', async () => {
cube('a/one', 'export default { id: "dup", name: "One" }');
cube('b/two', 'export default { id: "dup", name: "Two" }');
const roots = [path.join(tmpDir, 'a'), path.join(tmpDir, 'b')];
fs.writeFileSync(path.join(tmpDir, '.nopyrc.json'), JSON.stringify({ cubeDirs: roots }));
const forwards = await loadCubes();
fs.writeFileSync(
path.join(tmpDir, '.nopyrc.json'),
JSON.stringify({ cubeDirs: [...roots].reverse() })
);
const backwards = await loadCubes();
expect(forwards.errors[0]).toContain(path.join(tmpDir, 'a', 'one'));
expect(forwards.errors[0]).toContain(path.join(tmpDir, 'b', 'two'));
expect(backwards.errors).toHaveLength(1);
expect(new Set(backwards.errors[0].split('\n'))).toEqual(
new Set(forwards.errors[0].split('\n'))
);
});
it('does not call one directory a duplicate of itself when two roots reach it', async () => {
cube('nested/one', 'export default { id: "once", name: "Once" }');
fs.writeFileSync(
path.join(tmpDir, '.nopyrc.json'),
JSON.stringify({ cubeDirs: ['./', './nested'] })
);
const { cubes, errors } = await loadCubes();
expect(errors).toEqual([]);
expect(cubes.once).toBeDefined();
});
it('skips hidden and node_modules directories', async () => {
+17
View File
@@ -6,6 +6,7 @@
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { Cube, Manifest } from '../src/cubes/types.js';
import { foreignZodSchema } from './helpers/foreign-zod.js';
const cube = (schema: z.ZodObject<any>) =>
new Cube(Manifest.create({ id: 'c', name: 'C', schema }), '/cubes/c', 'deploy.py');
@@ -64,6 +65,22 @@ describe('Cube.getDefaults', () => {
it('returns an empty object for an empty schema', () => {
expect(cube(z.object({})).getDefaults()).toEqual({});
});
it('reads defaults off a schema built by a different copy of zod', () => {
// The per-field fallback reads zod's internals directly. Under `instanceof`
// a foreign schema yields no defaults at all, without erroring.
const c = cube(
foreignZodSchema(
z.object({
REQUIRED: z.string(),
PRIORITY: z.number().default(10),
NESTED: z.number().default(2).optional(),
})
)
);
expect(c.getDefaults()).toEqual({ PRIORITY: 10, NESTED: 2 });
});
});
describe('Cube.requiredKeys', () => {
@@ -0,0 +1,29 @@
/**
* A schema that behaves like zod's but does not share zod's prototypes.
*
* Once cubes arrive from `node_modules`, the schema a manifest builds may come
* from a *second* copy of zod — its own dependency, or one shipped inside a
* bundle. Such a schema is structurally identical and `instanceof` blind to it.
* Rebuilding the nodes as plain objects reproduces that from inside a single
* process, so anything that reads zod's internals stays pinned to `def.type`.
*/
import type { z } from 'zod';
/** Strips the prototype off a schema node and everything it wraps. */
function strip(node: unknown): unknown {
const def = { ...(node as { def: Record<string, unknown> }).def };
if (def.innerType) def.innerType = strip(def.innerType);
return { def };
}
export function foreignZodSchema<S extends z.ZodObject<any>>(schema: S): S {
return {
// Parsing is not what is under test — delegate it and keep the real
// behaviour, so only the introspection path sees the foreign nodes.
safeParse: (value: unknown) => schema.safeParse(value),
shape: Object.fromEntries(
Object.entries(schema.shape).map(([key, node]) => [key, strip(node)])
),
} as unknown as S;
}
+28 -2
View File
@@ -46,6 +46,7 @@ import {
PasswordSelection,
VariableAssignment,
} from '../src/nopy.prompts.js';
import { foreignZodSchema } from './helpers/foreign-zod.js';
/** Grabs the single question object passed to the last inquirer.prompt call. */
const questions = () => inquirerPrompt.mock.calls.at(-1)?.[0] as Record<string, any>[];
@@ -310,13 +311,14 @@ describe('VariableAssignment', () => {
const nullableSchema = z.object({
maybe: z.number().nullable().default(1),
opt: z.number().optional().default(2),
given: z.number().nullable().default(3),
});
const variables = new Variables();
formRun.mockResolvedValue({ maybe: 'null', opt: '7' });
formRun.mockResolvedValue({ maybe: 'null', opt: '7', given: '42' });
await VariableAssignment(cube('svc', 'Service', nullableSchema), variables);
expect(variables.get('svc', 'prompts')).toEqual({ maybe: null, opt: 7 });
expect(variables.get('svc', 'prompts')).toEqual({ maybe: null, opt: 7, given: 42 });
});
it('treats an empty string as null for a nullable field', async () => {
@@ -356,4 +358,28 @@ describe('VariableAssignment', () => {
).resolves.toBeUndefined();
expect(variables.get('svc', 'prompts')).toEqual({});
});
it('coerces against a schema built by a different copy of zod', async () => {
// Guards the discriminant in `coerceValue`: under `instanceof` every check
// here returns false and the answers stay strings, silently.
const variables = new Variables();
formRun.mockResolvedValue({ port: '9090', enabled: 'true', maybe: '' });
await VariableAssignment(
cube(
'svc',
'Service',
foreignZodSchema(
z.object({
port: z.number().default(8080),
enabled: z.boolean().default(false),
maybe: z.number().nullable().default(1),
})
)
),
variables
);
expect(variables.get('svc', 'prompts')).toEqual({ port: 9090, enabled: true, maybe: null });
});
});