diff --git a/packages/nopy/package.json b/packages/nopy/package.json index c0052ca..2e4cd73 100644 --- a/packages/nopy/package.json +++ b/packages/nopy/package.json @@ -43,7 +43,7 @@ }, "scripts": { "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", "link:local": "pnpm run build && npm link", "nopy": "tsx src/nopy.cli.ts", diff --git a/packages/nopy/src/index.ts b/packages/nopy/src/index.ts index ab1a77f..9c04b1a 100644 --- a/packages/nopy/src/index.ts +++ b/packages/nopy/src/index.ts @@ -22,6 +22,18 @@ export type { } from './nopy.config.js'; // Configuration 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 export { cubes } from './nopy.cubes.js'; export type { diff --git a/packages/nopy/src/nopy.cli.ts b/packages/nopy/src/nopy.cli.ts index 4dd418c..2e5df98 100644 --- a/packages/nopy/src/nopy.cli.ts +++ b/packages/nopy/src/nopy.cli.ts @@ -7,7 +7,15 @@ import { createRequire } from 'node:module'; import { Command } from 'commander'; +import type { NopyConfig } 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 { exitWithFarewell, installGracefulExit, isCancellation } from './nopy.exit.js'; import { @@ -19,6 +27,7 @@ import { } from './nopy.history.js'; import { formatInitResults, initProject } from './nopy.init.js'; import { nopy } from './nopy.main.js'; +import { CubeScaffoldPrompts } from './nopy.prompts.js'; import type { Channel } from './nopy.update.js'; import { formatCommand, selfUpdate, updateNotice } from './nopy.update.js'; @@ -60,6 +69,7 @@ program ` Examples: $ 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 -R Repeat the last deployment session $ nopy -H 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 ', 'Cube id, e.g. net:tailscale') + .option('--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 .command('history') .description('List session history') diff --git a/packages/nopy/src/nopy.create-cube.ts b/packages/nopy/src/nopy.create-cube.ts new file mode 100644 index 0000000..9369e03 --- /dev/null +++ b/packages/nopy/src/nopy.create-cube.ts @@ -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 = { + [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): 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 { + let cubes: Awaited>['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'); +} diff --git a/packages/nopy/src/nopy.init.ts b/packages/nopy/src/nopy.init.ts index 9b35715..a66f57a 100644 --- a/packages/nopy/src/nopy.init.ts +++ b/packages/nopy/src/nopy.init.ts @@ -52,7 +52,12 @@ export interface InitOptions { 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); if (existed && !force) { return { file: path.basename(filePath), path: filePath, status: 'skipped' }; diff --git a/packages/nopy/src/nopy.prompts.ts b/packages/nopy/src/nopy.prompts.ts index 200b3ac..f964974 100644 --- a/packages/nopy/src/nopy.prompts.ts +++ b/packages/nopy/src/nopy.prompts.ts @@ -9,6 +9,7 @@ import inquirer from 'inquirer'; import type { z } from 'zod'; import { type AnyObjectSchema, type Cube, zodInner, zodKind } from './cubes/index.js'; import type { Variables } from './nopy.common.js'; +import { validateCubeId } from './nopy.create-cube.js'; interface CubeChoice { /** Submitted value — enquirer returns the `name` of each selected choice. */ @@ -202,6 +203,55 @@ export async function HostSelection(hosts: string[]): Promise { 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, + suggestDir: (id: string) => string +): Promise { + 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. * diff --git a/packages/nopy/src/templates/NOPY.LLM.md b/packages/nopy/src/templates/NOPY.LLM.md index e03a6c0..3dd44f3 100644 --- a/packages/nopy/src/templates/NOPY.LLM.md +++ b/packages/nopy/src/templates/NOPY.LLM.md @@ -67,6 +67,8 @@ output. Exit code is `1` if any cube failed, `0` otherwise. ``` nopy [install] interactive: pick cubes, host, auth, variables 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 clear-history delete all recorded sessions 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 +`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: ``` diff --git a/packages/nopy/src/templates/cube/deploy.example.py b/packages/nopy/src/templates/cube/deploy.example.py new file mode 100644 index 0000000..6e3724a --- /dev/null +++ b/packages/nopy/src/templates/cube/deploy.example.py @@ -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}'"], +) diff --git a/packages/nopy/src/templates/cube/manifest.example.mjs b/packages/nopy/src/templates/cube/manifest.example.mjs new file mode 100644 index 0000000..3a3668a --- /dev/null +++ b/packages/nopy/src/templates/cube/manifest.example.mjs @@ -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__'), + }), +}); diff --git a/packages/nopy/tests/create-cube.test.ts b/packages/nopy/tests/create-cube.test.ts new file mode 100644 index 0000000..bbb8b51 --- /dev/null +++ b/packages/nopy/tests/create-cube.test.ts @@ -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, 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'); + }); +}); diff --git a/packages/nopy/tests/prompts.test.ts b/packages/nopy/tests/prompts.test.ts index 3760f91..1553759 100644 --- a/packages/nopy/tests/prompts.test.ts +++ b/packages/nopy/tests/prompts.test.ts @@ -41,6 +41,7 @@ import { Cube, Manifest } from '@bitsquare/nopy-cubes'; import { Variables } from '../src/nopy.common.js'; import { AuthSelection, + CubeScaffoldPrompts, CubeSelection, HostSelection, PasswordSelection, @@ -532,3 +533,50 @@ describe('VariableAssignment', () => { 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'); + }); +});