nopy: add create-cube command scaffolding a cube from bundled templates
Publish snapshot / snapshot (push) Successful in 1m17s
Publish snapshot / snapshot (push) Successful in 1m17s
Gathers id, name and directory from flags or prompts (only what the flags do not supply), then writes manifest.mjs + deploy.py from templates under src/templates/cube. Templates are named *.example.* so the template directory itself can never match the loader's manifest+deploy pair rule. The scaffold refuses a directory that already holds cube files by the loader's own patterns, checks the id against the loaded cube set (best effort, exempting the target directory so --force re-scaffolds work), and warns when the target lands outside every configured cube directory. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApuW1MMGK2pUQ9mTVRTxc5
This commit is contained in:
co-authored by
Claude Fable 5
parent
568d4c83ff
commit
8973ff7113
@@ -43,7 +43,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rm -rf dist .tsbuildinfo",
|
"clean": "rm -rf dist .tsbuildinfo",
|
||||||
"build": "tsc && cp src/cubes/*.mjs dist/cubes/ && mkdir -p dist/templates && cp src/templates/*.md dist/templates/",
|
"build": "tsc && cp src/cubes/*.mjs dist/cubes/ && mkdir -p dist/templates && cp -R src/templates/. dist/templates/",
|
||||||
"prepack": "pnpm run build",
|
"prepack": "pnpm run build",
|
||||||
"link:local": "pnpm run build && npm link",
|
"link:local": "pnpm run build && npm link",
|
||||||
"nopy": "tsx src/nopy.cli.ts",
|
"nopy": "tsx src/nopy.cli.ts",
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ export type {
|
|||||||
} from './nopy.config.js';
|
} from './nopy.config.js';
|
||||||
// Configuration
|
// Configuration
|
||||||
export { getConfigPaths, loadConfig, logConfigToFlags, saveConfig } from './nopy.config.js';
|
export { getConfigPaths, loadConfig, logConfigToFlags, saveConfig } from './nopy.config.js';
|
||||||
|
export type { CreateCubeOptions } from './nopy.create-cube.js';
|
||||||
|
// Cube scaffolding
|
||||||
|
export {
|
||||||
|
assertCubeIdAvailable,
|
||||||
|
createCube,
|
||||||
|
cubeDirWarning,
|
||||||
|
DEPLOY_FILENAME,
|
||||||
|
formatCreateCubeResults,
|
||||||
|
MANIFEST_FILENAME,
|
||||||
|
suggestCubeDir,
|
||||||
|
validateCubeId,
|
||||||
|
} from './nopy.create-cube.js';
|
||||||
// Backwards compatibility - cubes namespace
|
// Backwards compatibility - cubes namespace
|
||||||
export { cubes } from './nopy.cubes.js';
|
export { cubes } from './nopy.cubes.js';
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -7,7 +7,15 @@
|
|||||||
|
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
|
import type { NopyConfig } from './nopy.config.js';
|
||||||
import { loadConfig } from './nopy.config.js';
|
import { loadConfig } from './nopy.config.js';
|
||||||
|
import {
|
||||||
|
assertCubeIdAvailable,
|
||||||
|
createCube,
|
||||||
|
cubeDirWarning,
|
||||||
|
formatCreateCubeResults,
|
||||||
|
suggestCubeDir,
|
||||||
|
} from './nopy.create-cube.js';
|
||||||
import { reportError } from './nopy.errors.js';
|
import { reportError } from './nopy.errors.js';
|
||||||
import { exitWithFarewell, installGracefulExit, isCancellation } from './nopy.exit.js';
|
import { exitWithFarewell, installGracefulExit, isCancellation } from './nopy.exit.js';
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +27,7 @@ import {
|
|||||||
} from './nopy.history.js';
|
} from './nopy.history.js';
|
||||||
import { formatInitResults, initProject } from './nopy.init.js';
|
import { formatInitResults, initProject } from './nopy.init.js';
|
||||||
import { nopy } from './nopy.main.js';
|
import { nopy } from './nopy.main.js';
|
||||||
|
import { CubeScaffoldPrompts } from './nopy.prompts.js';
|
||||||
import type { Channel } from './nopy.update.js';
|
import type { Channel } from './nopy.update.js';
|
||||||
import { formatCommand, selfUpdate, updateNotice } from './nopy.update.js';
|
import { formatCommand, selfUpdate, updateNotice } from './nopy.update.js';
|
||||||
|
|
||||||
@@ -60,6 +69,7 @@ program
|
|||||||
`
|
`
|
||||||
Examples:
|
Examples:
|
||||||
$ nopy init Set up this directory (.nopyrc.json + NOPY.LLM.md)
|
$ nopy init Set up this directory (.nopyrc.json + NOPY.LLM.md)
|
||||||
|
$ nopy create-cube Scaffold a new cube (manifest.mjs + deploy.py)
|
||||||
$ nopy Interactive cube selection and deployment
|
$ nopy Interactive cube selection and deployment
|
||||||
$ nopy -R Repeat the last deployment session
|
$ nopy -R Repeat the last deployment session
|
||||||
$ nopy -H <id> Run a specific session from history
|
$ nopy -H <id> Run a specific session from history
|
||||||
@@ -178,6 +188,44 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('create-cube [dir]')
|
||||||
|
.description('Scaffold a new cube (manifest.mjs + deploy.py) from the bundled templates')
|
||||||
|
.option('--id <id>', 'Cube id, e.g. net:tailscale')
|
||||||
|
.option('--name <name>', 'Human-readable cube name')
|
||||||
|
.option('-f, --force', 'Overwrite existing cube files')
|
||||||
|
.action(async (dirArg: string | undefined, options) => {
|
||||||
|
try {
|
||||||
|
// Config is optional here, unlike `install`: create-cube works in a bare
|
||||||
|
// directory too; the config only improves the suggested location.
|
||||||
|
let config: NopyConfig | undefined;
|
||||||
|
try {
|
||||||
|
config = loadConfig();
|
||||||
|
} catch {
|
||||||
|
config = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const answers = await CubeScaffoldPrompts(
|
||||||
|
{ id: options.id, name: options.name, dir: dirArg },
|
||||||
|
(id) => suggestCubeDir(id, config)
|
||||||
|
);
|
||||||
|
|
||||||
|
await assertCubeIdAvailable(answers.id, answers.dir);
|
||||||
|
const results = createCube({ ...answers, force: options.force });
|
||||||
|
console.log(
|
||||||
|
formatCreateCubeResults(results, {
|
||||||
|
id: answers.id,
|
||||||
|
warning: cubeDirWarning(answers.dir),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (isCancellation(error)) exitWithFarewell();
|
||||||
|
|
||||||
|
reportError(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('history')
|
.command('history')
|
||||||
.description('List session history')
|
.description('List session history')
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* Cube scaffolding — `nopy create-cube`
|
||||||
|
* @module nopy.create-cube
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { findCubeDirectories, loadCubes } from './cubes/index.js';
|
||||||
|
import type { NopyConfig } from './nopy.config.js';
|
||||||
|
import { NopyUsageError } from './nopy.errors.js';
|
||||||
|
import { type InitFileResult, writeGuarded } from './nopy.init.js';
|
||||||
|
|
||||||
|
/** What the scaffold writes — the loader's two exact-name candidates. */
|
||||||
|
export const MANIFEST_FILENAME = 'manifest.mjs';
|
||||||
|
export const DEPLOY_FILENAME = 'deploy.py';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Templates resolved relative to this module, so the same path works from
|
||||||
|
* `src/` (tsx, vitest) and from `dist/` (the build copies `src/templates`
|
||||||
|
* alongside). Named `*.example.*` because the loader declares any directory
|
||||||
|
* holding a manifest **and** a deploy script a cube — under their real names
|
||||||
|
* the template directory itself would be one, and a `cubeDirs` entry sweeping
|
||||||
|
* this package would deploy the template.
|
||||||
|
*/
|
||||||
|
const TEMPLATES: Record<string, URL> = {
|
||||||
|
[MANIFEST_FILENAME]: new URL('./templates/cube/manifest.example.mjs', import.meta.url),
|
||||||
|
[DEPLOY_FILENAME]: new URL('./templates/cube/deploy.example.py', import.meta.url),
|
||||||
|
};
|
||||||
|
|
||||||
|
const CUBE_ID_PATTERN = /^[a-z0-9][a-z0-9_.:-]*$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why `id` cannot name a cube, or `undefined` when it can. Returns the message
|
||||||
|
* rather than throwing so a prompt can use it as an inline `validate` while
|
||||||
|
* {@link createCube} turns it into the error it is.
|
||||||
|
*/
|
||||||
|
export function validateCubeId(id: string): string | undefined {
|
||||||
|
if (!id.trim()) return 'Cube id is required';
|
||||||
|
if (!CUBE_ID_PATTERN.test(id)) {
|
||||||
|
return `Cube id may hold letters, digits and ":-_." — like "net:tailscale" or "apt"`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the prompt suggests putting a cube: the first configured cube
|
||||||
|
* directory (falling back to `./cubes`) plus the id with each `:` segment as a
|
||||||
|
* subdirectory, so `net:tailscale` lands in `cubes/net/tailscale`. Ids are
|
||||||
|
* flat and need not mirror the path — this is a suggestion, not a rule.
|
||||||
|
* Returned relative to the working directory when it is under it, because
|
||||||
|
* that is the form a prompt default should show.
|
||||||
|
*/
|
||||||
|
export function suggestCubeDir(id: string, config?: Pick<NopyConfig, 'cubeDirs'>): string {
|
||||||
|
const base = config?.cubeDirs?.[0] ?? path.resolve(process.cwd(), 'cubes');
|
||||||
|
const target = path.join(base, ...id.split(':').filter(Boolean));
|
||||||
|
const relative = path.relative(process.cwd(), target);
|
||||||
|
return relative.startsWith('..') ? target : relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files that already make `dir` a cube, by the loader's own patterns — not
|
||||||
|
* just the two exact names the scaffold writes. A `foo.manifest.mjs` already
|
||||||
|
* present would leave the directory with two manifests and the loader picking
|
||||||
|
* whichever `readdir` returns first, so it has to block the scaffold too.
|
||||||
|
*/
|
||||||
|
function existingCubeFiles(dir: string): string[] {
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
return fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter(
|
||||||
|
(name) =>
|
||||||
|
name === MANIFEST_FILENAME ||
|
||||||
|
name.endsWith('.manifest.mjs') ||
|
||||||
|
name === DEPLOY_FILENAME ||
|
||||||
|
name.endsWith('.deploy.py')
|
||||||
|
)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes a value for splicing into a single-quoted string literal in the
|
||||||
|
* manifest template — the cube name is free text, and an apostrophe in it
|
||||||
|
* must not produce a manifest that does not parse.
|
||||||
|
*/
|
||||||
|
function jsEscape(value: string): string {
|
||||||
|
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCubeOptions {
|
||||||
|
/** Cube id, e.g. `net:tailscale`. */
|
||||||
|
id: string;
|
||||||
|
/** Human-readable name, shown in the cube list. */
|
||||||
|
name: string;
|
||||||
|
/** Target directory; created if missing. Relative paths resolve against cwd. */
|
||||||
|
dir: string;
|
||||||
|
/** Overwrite existing cube files. */
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses an id another cube already claims — at creation time, rather than
|
||||||
|
* as the loader's hard duplicate error on the next run. Best-effort: no
|
||||||
|
* config, or a loader that cannot run, skips the check (the loader still
|
||||||
|
* catches the collision later). A claim by the target directory itself is the
|
||||||
|
* `--force` re-scaffold case, not a collision.
|
||||||
|
*/
|
||||||
|
export async function assertCubeIdAvailable(id: string, dir: string): Promise<void> {
|
||||||
|
let cubes: Awaited<ReturnType<typeof loadCubes>>['cubes'];
|
||||||
|
try {
|
||||||
|
({ cubes } = await loadCubes());
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimant = cubes[id];
|
||||||
|
if (!claimant || path.resolve(claimant.dir) === path.resolve(dir)) return;
|
||||||
|
|
||||||
|
const from =
|
||||||
|
claimant.source.type === 'package' ? `package ${claimant.source.packageName}` : claimant.dir;
|
||||||
|
throw new NopyUsageError(`Cube id "${id}" is already claimed by ${from}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hint when a cube lands where the loader will never look, or `undefined`
|
||||||
|
* when it is discoverable (or there is no config to consult — a bare
|
||||||
|
* directory gets the next-steps line about `.nopyrc.json` instead of a
|
||||||
|
* warning about one that does not exist).
|
||||||
|
*/
|
||||||
|
export function cubeDirWarning(dir: string): string | undefined {
|
||||||
|
let roots: string[];
|
||||||
|
try {
|
||||||
|
roots = findCubeDirectories();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = path.resolve(dir);
|
||||||
|
const inside = roots.some((root) => {
|
||||||
|
const relative = path.relative(path.resolve(root), target);
|
||||||
|
return !relative.startsWith('..') && !path.isAbsolute(relative);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inside) return undefined;
|
||||||
|
return (
|
||||||
|
`Note: ${dir} is outside every configured cube directory — ` +
|
||||||
|
'add it to "cubeDirs" in .nopyrc.json or nopy will not find it.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scaffolds a cube directory from the bundled templates: `manifest.mjs` with
|
||||||
|
* the id and name spliced in, plus a minimal `deploy.py`. The result is
|
||||||
|
* loadable as-is; the schema is an example to replace.
|
||||||
|
*/
|
||||||
|
export function createCube(options: CreateCubeOptions): InitFileResult[] {
|
||||||
|
const idError = validateCubeId(options.id);
|
||||||
|
if (idError) throw new NopyUsageError(idError);
|
||||||
|
if (!options.name.trim()) throw new NopyUsageError('Cube name is required');
|
||||||
|
|
||||||
|
const dir = path.resolve(options.dir);
|
||||||
|
const force = options.force ?? false;
|
||||||
|
|
||||||
|
const existing = existingCubeFiles(dir);
|
||||||
|
if (existing.length > 0 && !force) {
|
||||||
|
throw new NopyUsageError(
|
||||||
|
`${dir} already holds cube files (${existing.join(', ')}). ` +
|
||||||
|
`Use --force to overwrite ${MANIFEST_FILENAME} and ${DEPLOY_FILENAME}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
return Object.entries(TEMPLATES).map(([filename, url]) => {
|
||||||
|
// Function replacements, so a `$` in a cube name is never expanded as a
|
||||||
|
// replacement pattern.
|
||||||
|
const content = fs
|
||||||
|
.readFileSync(fileURLToPath(url), 'utf-8')
|
||||||
|
.replace(/__CUBE_ID__/g, () => jsEscape(options.id))
|
||||||
|
.replace(/__CUBE_NAME__/g, () => jsEscape(options.name));
|
||||||
|
return writeGuarded(path.join(dir, filename), content, force);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The report `create-cube` prints. Lives here rather than in the CLI because
|
||||||
|
* the CLI is excluded from coverage.
|
||||||
|
*/
|
||||||
|
export function formatCreateCubeResults(
|
||||||
|
results: InitFileResult[],
|
||||||
|
options: { id: string; warning?: string }
|
||||||
|
): string {
|
||||||
|
const lines = results.map((result) =>
|
||||||
|
result.status === 'skipped'
|
||||||
|
? ` exists, skipped ${result.file} (use --force to overwrite)`
|
||||||
|
: ` ${result.status.padEnd(15)} ${result.file}`
|
||||||
|
);
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'Next steps:',
|
||||||
|
` 1. Declare the cube's variables in ${MANIFEST_FILENAME} — the schema is an example`,
|
||||||
|
` 2. Write the deployment in ${DEPLOY_FILENAME}; every schema key arrives on host.data`,
|
||||||
|
` 3. Run \`nopy\` and select ${options.id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options.warning) lines.push('', options.warning);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -52,7 +52,12 @@ export interface InitOptions {
|
|||||||
dir?: string;
|
dir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeGuarded(filePath: string, content: string, force: boolean): InitFileResult {
|
/**
|
||||||
|
* Writes `filePath` unless it already exists and `force` is unset, and says
|
||||||
|
* which of the three it was. Shared with `create-cube`, which scaffolds under
|
||||||
|
* the same skip/overwrite rules.
|
||||||
|
*/
|
||||||
|
export function writeGuarded(filePath: string, content: string, force: boolean): InitFileResult {
|
||||||
const existed = fs.existsSync(filePath);
|
const existed = fs.existsSync(filePath);
|
||||||
if (existed && !force) {
|
if (existed && !force) {
|
||||||
return { file: path.basename(filePath), path: filePath, status: 'skipped' };
|
return { file: path.basename(filePath), path: filePath, status: 'skipped' };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import inquirer from 'inquirer';
|
|||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import { type AnyObjectSchema, type Cube, zodInner, zodKind } from './cubes/index.js';
|
import { type AnyObjectSchema, type Cube, zodInner, zodKind } from './cubes/index.js';
|
||||||
import type { Variables } from './nopy.common.js';
|
import type { Variables } from './nopy.common.js';
|
||||||
|
import { validateCubeId } from './nopy.create-cube.js';
|
||||||
|
|
||||||
interface CubeChoice {
|
interface CubeChoice {
|
||||||
/** Submitted value — enquirer returns the `name` of each selected choice. */
|
/** Submitted value — enquirer returns the `name` of each selected choice. */
|
||||||
@@ -202,6 +203,55 @@ export async function HostSelection(hosts: string[]): Promise<string> {
|
|||||||
return selectedHost.customHost ?? selectedHost.host;
|
return selectedHost.customHost ?? selectedHost.host;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What `create-cube` needs to know before it can scaffold. */
|
||||||
|
export interface CubeScaffoldAnswers {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
dir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks for whatever `create-cube` was not already told on the command line —
|
||||||
|
* a flag that was given is never re-asked. The directory default is derived
|
||||||
|
* from the id, which may itself have just been typed, hence the function
|
||||||
|
* rather than a precomputed value.
|
||||||
|
*/
|
||||||
|
export async function CubeScaffoldPrompts(
|
||||||
|
given: Partial<CubeScaffoldAnswers>,
|
||||||
|
suggestDir: (id: string) => string
|
||||||
|
): Promise<CubeScaffoldAnswers> {
|
||||||
|
const answers = await inquirer.prompt([
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'id',
|
||||||
|
message: 'Cube id (flat, e.g. net:tailscale):',
|
||||||
|
when: () => !given.id,
|
||||||
|
validate: (value: string) => validateCubeId(value) ?? true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'name',
|
||||||
|
message: 'Cube name (the label shown in the cube list):',
|
||||||
|
when: () => !given.name,
|
||||||
|
validate: (value: string) => value.trim().length > 0 || 'Required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'dir',
|
||||||
|
message: 'Directory to scaffold:',
|
||||||
|
when: () => !given.dir,
|
||||||
|
default: (soFar: { id?: string }) => suggestDir(given.id ?? soFar.id ?? ''),
|
||||||
|
validate: (value: string) => value.trim().length > 0 || 'Required',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: given.id ?? answers.id,
|
||||||
|
name: given.name ?? answers.name,
|
||||||
|
dir: given.dir ?? answers.dir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns a form answer — always a string — back into what the schema declares.
|
* Turns a form answer — always a string — back into what the schema declares.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ output. Exit code is `1` if any cube failed, `0` otherwise.
|
|||||||
```
|
```
|
||||||
nopy [install] interactive: pick cubes, host, auth, variables
|
nopy [install] interactive: pick cubes, host, auth, variables
|
||||||
nopy init write a starter .nopyrc.json and this guide (-f overwrites)
|
nopy init write a starter .nopyrc.json and this guide (-f overwrites)
|
||||||
|
nopy create-cube [dir] scaffold a cube (manifest.mjs + deploy.py); prompts for
|
||||||
|
what --id and --name do not supply (-f overwrites)
|
||||||
nopy history list recorded sessions (--json for machine-readable)
|
nopy history list recorded sessions (--json for machine-readable)
|
||||||
nopy clear-history delete all recorded sessions
|
nopy clear-history delete all recorded sessions
|
||||||
nopy self-update update nopy on its release channel (--dry-run, --force,
|
nopy self-update update nopy on its release channel (--dry-run, --force,
|
||||||
@@ -156,6 +158,10 @@ path; it comes from `manifest.id`, falling back to an `[id]` prefix in
|
|||||||
|
|
||||||
## Authoring a cube
|
## Authoring a cube
|
||||||
|
|
||||||
|
`nopy create-cube --id myapp:caddy-site --name "Serve the app behind Caddy"`
|
||||||
|
scaffolds the layout below with a loadable example schema to replace — fully
|
||||||
|
non-interactive when both flags and the directory argument are given.
|
||||||
|
|
||||||
Layout:
|
Layout:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# __CUBE_ID__ — __CUBE_NAME__
|
||||||
|
#
|
||||||
|
# Runs with the cube directory as its working directory. Every key in the
|
||||||
|
# manifest's schema arrives on host.data, already parsed by pyinfra — a
|
||||||
|
# boolean is a bool and a numeric string an int, not a string.
|
||||||
|
from pyinfra import host
|
||||||
|
from pyinfra.operations import server
|
||||||
|
|
||||||
|
GREETING = host.data.GREETING
|
||||||
|
|
||||||
|
server.shell(
|
||||||
|
name="Print the greeting",
|
||||||
|
commands=[f"echo '{GREETING}'"],
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Manifest } from '@bitsquare/nopy-cubes';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export default Manifest({
|
||||||
|
id: '__CUBE_ID__',
|
||||||
|
name: '__CUBE_NAME__',
|
||||||
|
// dependencies: () => ['apt:essentials'], // cubes to deploy first
|
||||||
|
// secrets: ['API_TOKEN'], // schema keys to mask and never persist
|
||||||
|
schema: z.object({
|
||||||
|
GREETING: z
|
||||||
|
.string()
|
||||||
|
.describe('Message the deploy prints on the host')
|
||||||
|
.default('hello from __CUBE_ID__'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Tests for nopy.create-cube.
|
||||||
|
*
|
||||||
|
* The contract under test is not "two files appear" but "the loader accepts
|
||||||
|
* what the scaffold wrote": the round-trip through `loadCubes()` is what
|
||||||
|
* proves the templates and the loader agree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { loadCubes } from '../src/cubes/index.js';
|
||||||
|
import {
|
||||||
|
assertCubeIdAvailable,
|
||||||
|
createCube,
|
||||||
|
cubeDirWarning,
|
||||||
|
DEPLOY_FILENAME,
|
||||||
|
formatCreateCubeResults,
|
||||||
|
MANIFEST_FILENAME,
|
||||||
|
suggestCubeDir,
|
||||||
|
validateCubeId,
|
||||||
|
} from '../src/nopy.create-cube.js';
|
||||||
|
import { NopyUsageError } from '../src/nopy.errors.js';
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
let originalCwd: string;
|
||||||
|
let originalHome: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalCwd = process.cwd();
|
||||||
|
// realpath: os.tmpdir() is a symlink on macOS, and paths reported back by
|
||||||
|
// process.cwd() after a chdir are resolved — comparisons need one form.
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'nopy-create-cube-'));
|
||||||
|
process.chdir(tmpDir);
|
||||||
|
// Point HOME at an empty directory so a developer's ~/.nopyrc.json cannot
|
||||||
|
// leak extra cube roots into the "no config anywhere" assertions.
|
||||||
|
originalHome = process.env.HOME;
|
||||||
|
process.env.HOME = tmpDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
process.env.HOME = originalHome;
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeConfig(config: Record<string, unknown>, dir = tmpDir): void {
|
||||||
|
fs.writeFileSync(path.join(dir, '.nopyrc.json'), JSON.stringify(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('validateCubeId', () => {
|
||||||
|
it('accepts the shapes the core bundle uses', () => {
|
||||||
|
for (const id of ['apt', 'net:tailscale', 'user:add', 'a1-b_c.d']) {
|
||||||
|
expect(validateCubeId(id)).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the problem for ids the loader or shell would choke on', () => {
|
||||||
|
for (const id of ['', ' ', ':leading', 'has space', 'net/tailscale', '[bracketed]']) {
|
||||||
|
expect(validateCubeId(id)).toBeTypeOf('string');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createCube', () => {
|
||||||
|
it('writes a manifest and deploy script with every token replaced', () => {
|
||||||
|
const dir = path.join(tmpDir, 'cubes', 'net', 'hello');
|
||||||
|
const results = createCube({ id: 'net:hello', name: 'Say hello', dir });
|
||||||
|
|
||||||
|
expect(results.map((r) => r.status)).toEqual(['created', 'created']);
|
||||||
|
expect(results.map((r) => r.file)).toEqual([MANIFEST_FILENAME, DEPLOY_FILENAME]);
|
||||||
|
|
||||||
|
for (const file of [MANIFEST_FILENAME, DEPLOY_FILENAME]) {
|
||||||
|
const content = fs.readFileSync(path.join(dir, file), 'utf-8');
|
||||||
|
expect(content).not.toContain('__CUBE_ID__');
|
||||||
|
expect(content).not.toContain('__CUBE_NAME__');
|
||||||
|
expect(content).toContain('net:hello');
|
||||||
|
}
|
||||||
|
expect(fs.readFileSync(path.join(dir, MANIFEST_FILENAME), 'utf-8')).toContain('Say hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid id and an empty name as usage errors', () => {
|
||||||
|
expect(() => createCube({ id: 'has space', name: 'x', dir: tmpDir })).toThrow(NopyUsageError);
|
||||||
|
expect(() => createCube({ id: 'ok', name: ' ', dir: tmpDir })).toThrow(NopyUsageError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a directory that is already a cube, naming the files', () => {
|
||||||
|
const dir = path.join(tmpDir, 'occupied');
|
||||||
|
fs.mkdirSync(dir);
|
||||||
|
fs.writeFileSync(path.join(dir, 'my.manifest.mjs'), 'export default {}');
|
||||||
|
fs.writeFileSync(path.join(dir, 'my.deploy.py'), '# deploy');
|
||||||
|
|
||||||
|
expect(() => createCube({ id: 'x', name: 'X', dir })).toThrow(/my\.manifest\.mjs/);
|
||||||
|
// A lone deploy script blocks too — scaffolding next to it would leave the
|
||||||
|
// loader with two deploy candidates and readdir order picking one.
|
||||||
|
const half = path.join(tmpDir, 'half');
|
||||||
|
fs.mkdirSync(half);
|
||||||
|
fs.writeFileSync(path.join(half, DEPLOY_FILENAME), '# deploy');
|
||||||
|
expect(() => createCube({ id: 'x', name: 'X', dir: half })).toThrow(NopyUsageError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites with force and reports it', () => {
|
||||||
|
const dir = path.join(tmpDir, 'again');
|
||||||
|
createCube({ id: 'again', name: 'First', dir });
|
||||||
|
const results = createCube({ id: 'again', name: 'Second', dir, force: true });
|
||||||
|
|
||||||
|
expect(results.map((r) => r.status)).toEqual(['overwritten', 'overwritten']);
|
||||||
|
expect(fs.readFileSync(path.join(dir, MANIFEST_FILENAME), 'utf-8')).toContain('Second');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scaffolded cube', () => {
|
||||||
|
it('is discovered by the loader with the declared id, name and schema', async () => {
|
||||||
|
writeConfig({ cubeDirs: ['./cubes'] });
|
||||||
|
createCube({
|
||||||
|
id: 'net:hello',
|
||||||
|
// The apostrophe is the point: free text spliced into a single-quoted
|
||||||
|
// string literal must still parse.
|
||||||
|
name: "Bob's greeting",
|
||||||
|
dir: path.join(tmpDir, 'cubes', 'net', 'hello'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { cubes, errors } = await loadCubes();
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
const cube = cubes['net:hello'];
|
||||||
|
expect(cube).toBeDefined();
|
||||||
|
expect(cube.name).toBe("Bob's greeting");
|
||||||
|
expect(cube.schemaKeys()).toContain('GREETING');
|
||||||
|
expect(cube.getDefaults().GREETING).toContain('net:hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('suggestCubeDir', () => {
|
||||||
|
it('derives a path under ./cubes from the id when there is no config', () => {
|
||||||
|
expect(suggestCubeDir('net:tailscale')).toBe(path.join('cubes', 'net', 'tailscale'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the first configured cube directory, relative to cwd when under it', () => {
|
||||||
|
const config = { cubeDirs: [path.join(tmpDir, 'deploy', 'cubes')] };
|
||||||
|
expect(suggestCubeDir('apt', config)).toBe(path.join('deploy', 'cubes', 'apt'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays absolute when the cube directory is outside cwd', () => {
|
||||||
|
const elsewhere = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'nopy-elsewhere-'));
|
||||||
|
try {
|
||||||
|
const suggested = suggestCubeDir('apt', { cubeDirs: [elsewhere] });
|
||||||
|
expect(path.isAbsolute(suggested)).toBe(true);
|
||||||
|
expect(suggested).toBe(path.join(elsewhere, 'apt'));
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(elsewhere, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cubeDirWarning', () => {
|
||||||
|
it('is silent without a config to consult', () => {
|
||||||
|
expect(cubeDirWarning(path.join(tmpDir, 'cubes', 'x'))).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is silent for a directory the loader will scan', () => {
|
||||||
|
writeConfig({ cubeDirs: ['./cubes'] });
|
||||||
|
expect(cubeDirWarning(path.join(tmpDir, 'cubes', 'net', 'x'))).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when the loader will never look there', () => {
|
||||||
|
writeConfig({ cubeDirs: ['./cubes'] });
|
||||||
|
const outside = path.join(tmpDir, 'elsewhere', 'x');
|
||||||
|
expect(cubeDirWarning(outside)).toContain('cubeDirs');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertCubeIdAvailable', () => {
|
||||||
|
it('resolves when there is no config to check against', async () => {
|
||||||
|
await expect(assertCubeIdAvailable('x', path.join(tmpDir, 'x'))).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves for an unclaimed id', async () => {
|
||||||
|
writeConfig({ cubeDirs: ['./cubes'] });
|
||||||
|
await expect(
|
||||||
|
assertCubeIdAvailable('free', path.join(tmpDir, 'cubes', 'free'))
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an id another directory already claims', async () => {
|
||||||
|
writeConfig({ cubeDirs: ['./cubes'] });
|
||||||
|
createCube({ id: 'taken', name: 'Taken', dir: path.join(tmpDir, 'cubes', 'taken') });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertCubeIdAvailable('taken', path.join(tmpDir, 'cubes', 'other'))
|
||||||
|
).rejects.toThrow(/already claimed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates the claim coming from the target directory itself', async () => {
|
||||||
|
writeConfig({ cubeDirs: ['./cubes'] });
|
||||||
|
const dir = path.join(tmpDir, 'cubes', 'mine');
|
||||||
|
createCube({ id: 'mine', name: 'Mine', dir });
|
||||||
|
|
||||||
|
// The --force re-scaffold case: the id is "claimed", but by the very cube
|
||||||
|
// being recreated.
|
||||||
|
await expect(assertCubeIdAvailable('mine', dir)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatCreateCubeResults', () => {
|
||||||
|
const results = [
|
||||||
|
{ file: MANIFEST_FILENAME, path: `/x/${MANIFEST_FILENAME}`, status: 'created' as const },
|
||||||
|
{ file: DEPLOY_FILENAME, path: `/x/${DEPLOY_FILENAME}`, status: 'created' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('reports the files and the next steps', () => {
|
||||||
|
const output = formatCreateCubeResults(results, { id: 'net:hello' });
|
||||||
|
|
||||||
|
expect(output).toContain(MANIFEST_FILENAME);
|
||||||
|
expect(output).toContain(DEPLOY_FILENAME);
|
||||||
|
expect(output).toContain('Next steps:');
|
||||||
|
expect(output).toContain('net:hello');
|
||||||
|
expect(output).not.toContain('Note:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends the discoverability warning when there is one', () => {
|
||||||
|
const warning = 'Note: /x is outside every configured cube directory';
|
||||||
|
expect(formatCreateCubeResults(results, { id: 'x', warning })).toContain(warning);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('points skipped files at --force', () => {
|
||||||
|
const output = formatCreateCubeResults(
|
||||||
|
[{ file: MANIFEST_FILENAME, path: `/x/${MANIFEST_FILENAME}`, status: 'skipped' as const }],
|
||||||
|
{ id: 'x' }
|
||||||
|
);
|
||||||
|
expect(output).toContain('exists, skipped');
|
||||||
|
expect(output).toContain('--force');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,7 @@ import { Cube, Manifest } from '@bitsquare/nopy-cubes';
|
|||||||
import { Variables } from '../src/nopy.common.js';
|
import { Variables } from '../src/nopy.common.js';
|
||||||
import {
|
import {
|
||||||
AuthSelection,
|
AuthSelection,
|
||||||
|
CubeScaffoldPrompts,
|
||||||
CubeSelection,
|
CubeSelection,
|
||||||
HostSelection,
|
HostSelection,
|
||||||
PasswordSelection,
|
PasswordSelection,
|
||||||
@@ -532,3 +533,50 @@ describe('VariableAssignment', () => {
|
|||||||
expect(variables.get('svc')).toEqual({ port: 9090, enabled: true, maybe: null });
|
expect(variables.get('svc')).toEqual({ port: 9090, enabled: true, maybe: null });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CubeScaffoldPrompts', () => {
|
||||||
|
const suggestDir = vi.fn((id: string) => `cubes/${id}`);
|
||||||
|
|
||||||
|
it('passes fully-given answers through without asking anything', async () => {
|
||||||
|
inquirerPrompt.mockResolvedValue({});
|
||||||
|
|
||||||
|
const given = { id: 'net:x', name: 'X', dir: 'cubes/net/x' };
|
||||||
|
await expect(CubeScaffoldPrompts(given, suggestDir)).resolves.toEqual(given);
|
||||||
|
|
||||||
|
for (const q of questions()) {
|
||||||
|
expect(q.when()).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks only for what is missing and merges the answers', async () => {
|
||||||
|
inquirerPrompt.mockResolvedValue({ name: 'Typed name', dir: 'typed/dir' });
|
||||||
|
|
||||||
|
const result = await CubeScaffoldPrompts({ id: 'net:x' }, suggestDir);
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: 'net:x', name: 'Typed name', dir: 'typed/dir' });
|
||||||
|
expect(question('id')?.when()).toBe(false);
|
||||||
|
expect(question('name')?.when()).toBe(true);
|
||||||
|
expect(question('dir')?.when()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wires the id validation into the prompt', async () => {
|
||||||
|
inquirerPrompt.mockResolvedValue({ id: 'ok', name: 'n', dir: 'd' });
|
||||||
|
|
||||||
|
await CubeScaffoldPrompts({}, suggestDir);
|
||||||
|
|
||||||
|
const validate = question('id')?.validate;
|
||||||
|
expect(validate('net:x')).toBe(true);
|
||||||
|
expect(validate('has space')).toBeTypeOf('string');
|
||||||
|
expect(question('name')?.validate(' ')).toBeTypeOf('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the directory default from the id, typed or given', async () => {
|
||||||
|
inquirerPrompt.mockResolvedValue({ id: 'typed:id', name: 'n', dir: 'd' });
|
||||||
|
|
||||||
|
await CubeScaffoldPrompts({}, suggestDir);
|
||||||
|
expect(question('dir')?.default({ id: 'typed:id' })).toBe('cubes/typed:id');
|
||||||
|
|
||||||
|
await CubeScaffoldPrompts({ id: 'given:id' }, suggestDir);
|
||||||
|
expect(question('dir')?.default({})).toBe('cubes/given:id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user