streamline package naming
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 bitsquare
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,69 @@
|
||||
# @bitsquare/nopy-cubes
|
||||
|
||||
The authoring surface for [nopy](https://www.npmjs.com/package/@bitsquare/nopy)
|
||||
cubes — the `Manifest` factory, the `Cube` class, and the types around them.
|
||||
|
||||
A cube manifest ships nothing but data, so it should not have to depend on a CLI
|
||||
to describe itself. This package is what a **cube bundle** depends on: no
|
||||
`commander`, no `inquirer`, no `execa`, no process spawning. `@bitsquare/nopy`
|
||||
re-exports everything here, so a manifest that already imports from
|
||||
`@bitsquare/nopy` keeps working unchanged.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
pnpm add @bitsquare/nopy-cubes zod
|
||||
```
|
||||
|
||||
`zod` is a **peer dependency** on purpose: the manifest, the schema it builds and
|
||||
the `Manifest` factory should all see the same copy.
|
||||
|
||||
## Writing a manifest
|
||||
|
||||
```js
|
||||
// cubes/net/tailscale/manifest.mjs
|
||||
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default Manifest({
|
||||
id: 'net:tailscale',
|
||||
name: 'Tailscale',
|
||||
schema: z.object({
|
||||
AUTH_KEY: z.string().describe('Tailscale auth key'),
|
||||
ACCEPT_ROUTES: z.boolean().describe('Accept advertised routes').default(true),
|
||||
}),
|
||||
secrets: ['AUTH_KEY'],
|
||||
dependencies: (vars) => (vars.ACCEPT_ROUTES ? ['net:ip-forwarding'] : []),
|
||||
before: [async (ctx, vars) => ctx.exec('apt:essentials', {})],
|
||||
});
|
||||
```
|
||||
|
||||
Every schema field should carry a `.describe()` — nopy uses it as the prompt
|
||||
label — and a `.default()` wherever a sensible one exists, so `--use-defaults`
|
||||
can run the cube without prompting.
|
||||
|
||||
`secrets` names the schema keys that hold sensitive values. Nopy keeps those out
|
||||
of session and history files and masks them in every command it prints; it does
|
||||
not infer them, so a key nothing declares is recorded and printed in the clear.
|
||||
Each entry must be a key of `schema` — naming anything else is a manifest error.
|
||||
Give a secret a placeholder `.default()` rather than a real credential: a default
|
||||
lives in the manifest, where none of that protection reaches it.
|
||||
|
||||
The manifest lives next to a `deploy.py` in the same directory; together they
|
||||
make a cube. See the
|
||||
[nopy README](https://www.npmjs.com/package/@bitsquare/nopy) for the full cube
|
||||
contract and for how to publish a directory of cubes as a bundle.
|
||||
|
||||
## Exports
|
||||
|
||||
| Export | What it is |
|
||||
| ----------------------------------- | -------------------------------------------------------------- |
|
||||
| `Manifest(opts)` | Builds a manifest, filling in `id`, `schema`, `secrets`, `before`, `after` |
|
||||
| `createManifest` / `manifest` | Aliases of `Manifest` |
|
||||
| `Cube` | A loaded manifest plus its directory; `getDefaults()`, `requiredKeys()`, `secrets`, `isSecret()` |
|
||||
| `zodKind` / `zodInner` | Instance-agnostic zod introspection, safe across zod copies |
|
||||
| `AnyObjectSchema`, `CubeVariables`, `DependencySpec`, `Hook`, `HookContext`, `CubeSource`, `LoadResult` | types |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@bitsquare/nopy-cubes",
|
||||
"version": "0.5.0",
|
||||
"description": "Authoring types for nopy cubes: the Manifest factory and the Cube contract.",
|
||||
"keywords": [
|
||||
"nopy",
|
||||
"pyinfra",
|
||||
"deployment",
|
||||
"infrastructure"
|
||||
],
|
||||
"license": "MIT",
|
||||
"author": "bitsquare",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://gitea.bitsquare.dev/BitSquare/ansiblings.git",
|
||||
"directory": "packages/nopy-cubes"
|
||||
},
|
||||
"homepage": "https://gitea.bitsquare.dev/BitSquare/ansiblings/src/branch/main/packages/nopy-cubes",
|
||||
"bugs": {
|
||||
"url": "https://gitea.bitsquare.dev/BitSquare/ansiblings/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist .tsbuildinfo",
|
||||
"build": "tsc",
|
||||
"prepack": "pnpm run build",
|
||||
"link:local": "pnpm run build && npm link",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Factory functions for creating cube configurations
|
||||
* @module factories
|
||||
*/
|
||||
|
||||
import { type AnyObjectSchema, Manifest } from './types.js';
|
||||
|
||||
/**
|
||||
* Creates a manifest configuration for a cube
|
||||
*
|
||||
* @param opts - Manifest options including name, schema, dependencies, and hooks
|
||||
* @returns Manifest configuration object
|
||||
*/
|
||||
export function createManifest<Schema extends AnyObjectSchema>(
|
||||
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
|
||||
): Manifest<Schema> {
|
||||
return Manifest(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for createManifest - for backwards compatibility with existing manifests
|
||||
*/
|
||||
export const manifest = createManifest;
|
||||
|
||||
/**
|
||||
* @deprecated Use createManifest or manifest instead
|
||||
*/
|
||||
export const ManifestFactory = createManifest;
|
||||
|
||||
export { Manifest } from './types.js';
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @bitsquare/nopy-cubes — the authoring surface for nopy cubes.
|
||||
*
|
||||
* Everything a `manifest.mjs` needs and nothing else: no CLI, no prompts, no
|
||||
* process spawning. `@bitsquare/nopy` re-exports all of it, so a manifest can
|
||||
* import from either package.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export {
|
||||
createManifest,
|
||||
ManifestFactory,
|
||||
manifest,
|
||||
} from './factories.js';
|
||||
export type {
|
||||
AnyObjectSchema,
|
||||
CubeSource,
|
||||
CubeVariables,
|
||||
DependencySpec,
|
||||
Hook,
|
||||
HookContext,
|
||||
LoadResult,
|
||||
} from './types.js';
|
||||
export {
|
||||
Cube,
|
||||
Manifest,
|
||||
zodInner,
|
||||
zodKind,
|
||||
} from './types.js';
|
||||
export { uniqid } from './utils.js';
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Type definitions for Nopy cubes
|
||||
* @module types
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Any object schema, whatever its shape.
|
||||
*
|
||||
* Stands in for zod 3's `z.AnyZodObject`, which zod 4 removed.
|
||||
*/
|
||||
export type AnyObjectSchema = z.ZodObject<Record<string, z.ZodType<any>>>;
|
||||
|
||||
/**
|
||||
* Variables that can be passed to a cube
|
||||
*/
|
||||
export type CubeVariables = Record<string, string | number | boolean>;
|
||||
|
||||
/**
|
||||
* A dependency specification
|
||||
*/
|
||||
export type DependencySpec = string | [id: string, variables?: CubeVariables];
|
||||
|
||||
/**
|
||||
* Context passed to cube hooks for executing other cubes
|
||||
*/
|
||||
export interface HookContext {
|
||||
exec: (key: string, variables: CubeVariables) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook function type for before/after cube execution
|
||||
*/
|
||||
export type Hook<Schema extends AnyObjectSchema = AnyObjectSchema> = (
|
||||
ctx: HookContext,
|
||||
variables: z.infer<Schema>
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* User-defined specification for a cube
|
||||
*/
|
||||
export interface Manifest<Schema extends AnyObjectSchema = AnyObjectSchema> {
|
||||
/** Unique identifier for the cube (used for dependency references) */
|
||||
id: string;
|
||||
/** Human-readable name of the cube */
|
||||
name: string;
|
||||
/** Zod schema for validating cube variables */
|
||||
schema: Schema;
|
||||
/**
|
||||
* Schema keys holding secrets. Their values are never written to a session
|
||||
* file, and are masked wherever a command or a variable would be printed.
|
||||
*
|
||||
* A plain array rather than schema-level metadata on purpose: `.meta()` and
|
||||
* `.describe()` both store into zod's global registry, which is per-copy — a
|
||||
* manifest that builds its schema with its own zod writes the marker into a
|
||||
* registry this process cannot read. A missed `.describe()` costs an ugly
|
||||
* prompt label; a missed secret marker writes a password to disk, so this one
|
||||
* cannot be allowed to fail open. See {@link zodKind} for the same hazard.
|
||||
*/
|
||||
secrets?: string[];
|
||||
/** Dynamic dependency resolver based on collected variables */
|
||||
dependencies?: (variables: z.infer<Schema>) => DependencySpec[];
|
||||
/** Hooks to run before cube execution */
|
||||
before?: Hook<Schema>[];
|
||||
/** Hooks to run after cube execution */
|
||||
after?: Hook<Schema>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function and namespace for Manifest
|
||||
*/
|
||||
export function Manifest<Schema extends AnyObjectSchema>(
|
||||
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
|
||||
): Manifest<Schema> {
|
||||
return {
|
||||
id: opts.id ?? '',
|
||||
name: opts.name,
|
||||
schema: opts.schema ?? (z.object({}) as unknown as Schema),
|
||||
secrets: opts.secrets ?? [],
|
||||
dependencies: opts.dependencies,
|
||||
before: opts.before ?? [],
|
||||
after: opts.after ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export namespace Manifest {
|
||||
/**
|
||||
* Internal create helper
|
||||
*/
|
||||
export function create<Schema extends AnyObjectSchema>(
|
||||
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
|
||||
): Manifest<Schema> {
|
||||
return Manifest(opts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* zod's runtime discriminant for a schema node, as a plain string.
|
||||
*
|
||||
* `instanceof z.ZodDefault` compares against the *running* copy of zod. A cube
|
||||
* manifest is free to build its schema with a different copy — its own
|
||||
* dependency, or one shipped inside a bundle — and then every `instanceof`
|
||||
* quietly returns false and the caller falls through to a wrong answer instead
|
||||
* of failing. `def.type` holds across instances, so nothing here may go back to
|
||||
* `instanceof`.
|
||||
*/
|
||||
export function zodKind(zodType: unknown): string {
|
||||
return (zodType as { def: { type: string } }).def.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type a wrapper wraps — `.default()`, `.optional()`, `.nullable()`.
|
||||
* Only call this for a node whose {@link zodKind} is one of those.
|
||||
*/
|
||||
export function zodInner(zodType: unknown): z.ZodType {
|
||||
return (zodType as { def: { innerType: z.ZodType } }).def.innerType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const kind = zodKind(zodType);
|
||||
if (kind === 'default') {
|
||||
// zod 4 exposes `defaultValue` as a getter that already invokes a lazily
|
||||
// declared default; the function branch is insurance against that changing.
|
||||
const { defaultValue } = (zodType as unknown as { def: { defaultValue: unknown } }).def;
|
||||
return typeof defaultValue === 'function' ? defaultValue() : defaultValue;
|
||||
}
|
||||
if (kind === 'optional' || kind === 'nullable') {
|
||||
return defaultValueOf(zodInner(zodType));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a cube was discovered.
|
||||
*
|
||||
* Worth carrying because a cube's own directory does not say how it got into
|
||||
* the run: `/…/node_modules/@acme/cubes-net/cubes/x` could equally have come
|
||||
* from a `cubeDirs` entry pointing straight at it.
|
||||
*/
|
||||
export type CubeSource =
|
||||
/** Found under a `cubeDirs` entry or a `.npcubes` marker, at `dir`. */
|
||||
| { type: 'dir'; dir: string }
|
||||
/** Contributed by a package named in `cubePackages`. */
|
||||
| { type: 'package'; packageName: string; dir: string };
|
||||
|
||||
/**
|
||||
* A fully loaded cube with its filesystem location and runtime state
|
||||
*/
|
||||
export class Cube<Schema extends AnyObjectSchema = AnyObjectSchema> {
|
||||
constructor(
|
||||
public readonly manifest: Manifest<Schema>,
|
||||
public readonly dir: string,
|
||||
public readonly deployScript: string,
|
||||
/** Defaults to the cube's own directory, for cubes built by hand. */
|
||||
public readonly source: CubeSource = { type: 'dir', dir }
|
||||
) {}
|
||||
|
||||
get id(): string {
|
||||
return this.manifest.id;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.manifest.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
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);
|
||||
}
|
||||
|
||||
/** Schema keys the manifest declared as secrets. */
|
||||
get secrets(): string[] {
|
||||
return this.manifest.secrets ?? [];
|
||||
}
|
||||
|
||||
isSecret(key: string): boolean {
|
||||
return this.secrets.includes(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of loading cubes from the filesystem
|
||||
*/
|
||||
export interface LoadResult {
|
||||
/** Map of cube key to Cube object */
|
||||
cubes: Record<string, Cube>;
|
||||
/** List of errors encountered during loading */
|
||||
errors: string[];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Utility functions for cubes
|
||||
* @module utils
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generates a random string of the specified length using the current nanotime as a seed.
|
||||
*
|
||||
* Uses a simple Linear Congruential Generator (LCG) seeded with high-resolution time.
|
||||
* Suitable for generating unique identifiers, not for cryptographic purposes.
|
||||
*
|
||||
* @param length - The desired length of the random string (default: 5)
|
||||
* @returns A random alphanumeric string of the specified length
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const id = uniqid(); // e.g., "Kx7Pm"
|
||||
* const longId = uniqid(10); // e.g., "Kx7PmQr2Yw"
|
||||
* ```
|
||||
*/
|
||||
export function uniqid(length = 5): string {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const charsetLength = charset.length;
|
||||
|
||||
// Use process.hrtime.bigint() for high-resolution time in nanoseconds
|
||||
let seed = Number(process.hrtime.bigint() % BigInt(Number.MAX_SAFE_INTEGER));
|
||||
|
||||
const randomString: string[] = [];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
// Simple linear congruential generator (LCG) for pseudo-randomness
|
||||
seed = (seed * 48271) % 2147483647;
|
||||
const index = seed % charsetLength;
|
||||
randomString.push(charset[index]);
|
||||
}
|
||||
|
||||
return randomString.join('');
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Tests for the manifest factories
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { createManifest, manifest } from '../src/factories.js';
|
||||
|
||||
describe('createManifest', () => {
|
||||
it('creates manifest with basic properties', () => {
|
||||
const m = createManifest({
|
||||
id: 'test-cube',
|
||||
name: 'Test Cube',
|
||||
});
|
||||
|
||||
expect(m.id).toBe('test-cube');
|
||||
expect(m.name).toBe('Test Cube');
|
||||
expect(m.schema).toBeDefined();
|
||||
expect(m.before).toEqual([]);
|
||||
expect(m.after).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts schema', () => {
|
||||
const schema = z.object({
|
||||
VERSION: z.string().default('1.0'),
|
||||
});
|
||||
|
||||
const m = createManifest({
|
||||
name: 'Test Cube',
|
||||
schema,
|
||||
});
|
||||
|
||||
expect(m.schema).toBe(schema);
|
||||
});
|
||||
|
||||
it('manifest is alias for createManifest', () => {
|
||||
expect(manifest).toBe(createManifest);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 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/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');
|
||||
|
||||
describe('Cube', () => {
|
||||
it('reads id and name off the manifest', () => {
|
||||
const c = cube(z.object({}));
|
||||
|
||||
expect(c.id).toBe('c');
|
||||
expect(c.name).toBe('C');
|
||||
});
|
||||
|
||||
it('defaults its source to its own directory', () => {
|
||||
// What the loader overrides when a cube arrives from a package; a cube
|
||||
// built by hand still has to answer the question.
|
||||
expect(cube(z.object({})).source).toEqual({ type: 'dir', dir: '/cubes/c' });
|
||||
});
|
||||
|
||||
it('keeps the source it was constructed with', () => {
|
||||
const source = { type: 'package' as const, packageName: '@acme/cubes-net', dir: '/pkg/cubes' };
|
||||
const c = new Cube(
|
||||
Manifest.create({ id: 'c', name: 'C' }),
|
||||
'/pkg/cubes/c',
|
||||
'deploy.py',
|
||||
source
|
||||
);
|
||||
|
||||
expect(c.source).toBe(source);
|
||||
});
|
||||
});
|
||||
|
||||
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({});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cube.secrets', () => {
|
||||
it('is empty when the manifest declares none', () => {
|
||||
const c = cube(z.object({ PASSWORD: z.string().default('x') }));
|
||||
|
||||
expect(c.secrets).toEqual([]);
|
||||
// No name-based guessing: only what the manifest says.
|
||||
expect(c.isSecret('PASSWORD')).toBe(false);
|
||||
});
|
||||
|
||||
it('reports what the manifest declared', () => {
|
||||
const c = new Cube(
|
||||
Manifest.create({
|
||||
id: 'c',
|
||||
name: 'C',
|
||||
schema: z.object({ USER: z.string(), PASSWORD: z.string() }),
|
||||
secrets: ['PASSWORD'],
|
||||
}),
|
||||
'/cubes/c',
|
||||
'deploy.py'
|
||||
);
|
||||
|
||||
expect(c.secrets).toEqual(['PASSWORD']);
|
||||
expect(c.isSecret('PASSWORD')).toBe(true);
|
||||
expect(c.isSecret('USER')).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to an empty list on a manifest built by hand', () => {
|
||||
const c = new Cube({ id: 'c', name: 'C', schema: z.object({}) }, '/cubes/c', 'deploy.py');
|
||||
|
||||
expect(c.secrets).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Tests for the uniqid helper
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { uniqid } from '../src/utils.js';
|
||||
|
||||
describe('uniqid', () => {
|
||||
it('generates string of default length (5)', () => {
|
||||
const id = uniqid();
|
||||
expect(id).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('generates string of specified length', () => {
|
||||
expect(uniqid(10)).toHaveLength(10);
|
||||
expect(uniqid(3)).toHaveLength(3);
|
||||
expect(uniqid(20)).toHaveLength(20);
|
||||
});
|
||||
|
||||
it('generates alphanumeric characters only', () => {
|
||||
const id = uniqid(100);
|
||||
expect(id).toMatch(/^[A-Za-z0-9]+$/);
|
||||
});
|
||||
|
||||
it('generates different values on subsequent calls', () => {
|
||||
const ids = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
ids.add(uniqid(10));
|
||||
}
|
||||
// Should have many unique values (some collisions possible but unlikely)
|
||||
expect(ids.size).toBeGreaterThan(90);
|
||||
});
|
||||
|
||||
it('handles edge case of length 1', () => {
|
||||
const id = uniqid(1);
|
||||
expect(id).toHaveLength(1);
|
||||
expect(id).toMatch(/^[A-Za-z0-9]$/);
|
||||
});
|
||||
|
||||
it('handles edge case of length 0', () => {
|
||||
const id = uniqid(0);
|
||||
expect(id).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": ".tsbuildinfo",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2020"],
|
||||
"composite": true,
|
||||
"module": "NodeNext",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["coverage", "node_modules", "dist"],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json-summary', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.test.ts',
|
||||
// Pure re-export barrel: no logic to cover.
|
||||
'src/index.ts',
|
||||
],
|
||||
thresholds: {
|
||||
branches: 85,
|
||||
functions: 85,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user