streamline package naming

This commit is contained in:
Benjamin Diedrichsen
2026-07-29 13:07:34 +02:00
parent 1ba1c2a32a
commit 7e703c93b1
100 changed files with 141 additions and 139 deletions
+21
View File
@@ -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.
+69
View File
@@ -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
+60
View File
@@ -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"
}
}
+30
View File
@@ -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';
+31
View File
@@ -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';
+224
View File
@@ -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[];
}
+38
View File
@@ -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;
}
+171
View File
@@ -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([]);
});
});
+44
View File
@@ -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('');
});
});
+15
View File
@@ -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": []
}
+25
View File
@@ -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,
},
},
},
});