Add release pipeline and upgrade toolchain to TypeScript 7
Publish snapshot / snapshot (push) Failing after 1m58s

Publishing infrastructure
- Three Gitea workflows: ci.yml (PRs, non-main pushes), publish-snapshot.yml
  (main -> Gitea under dist-tag @main) and release.yml (tags -> Gitea + npmjs)
- Tag-driven releases as <package-dir>-v<version>; the manifest stays the
  source of truth and release.yml refuses to run if tag and manifest disagree
- Every publish is idempotent: each step checks the registry first, so a run
  that fails on the second registry can simply be re-run
- Hard coverage gate (85% branches) shared by CI, the pre-push hook and local
  runs, since the thresholds live in vitest.config.ts rather than a CI flag
- README.PUBLISH.md documents the whole mechanism

Toolchain
- TypeScript 7 native compiler; drop tsgo and ts-node, use tsx for dev runs
- Biome 1.9 -> 2.x, Vitest 1 -> 4, zod 3 -> 4, inquirer 8 -> 14, pnpm 11.17.0
- Replace inquirer-checkbox-plus-prompt, which is peer-capped at inquirer <9,
  with enquirer's AutoComplete; the CubeSelection contract is unchanged
- Stand in for zod 4's removed z.AnyZodObject with a local AnyObjectSchema

Repo hygiene
- Stop tracking dist/; ignore coverage/, *.tsbuildinfo, .npmrc* and release.json
- Drop package-lock.json in favour of pnpm-lock.yaml

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Diedrichsen
2026-07-27 15:17:14 +02:00
parent 736c01216a
commit 587ff2cf47
126 changed files with 6065 additions and 7544 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.
-1
View File
@@ -1,5 +1,4 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
name: '[apt-all] Test dependencies',
@@ -1,5 +1,4 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
name: '[apt-more] Test dependencies',
-46
View File
@@ -1,46 +0,0 @@
/**
* Dynamic dependency resolution for cubes
* @module cubes/dependencies
*/
import type { Variables } from '../nopy.common.js';
import type { NopyConfig } from '../nopy.config.js';
import type { DeployCall } from '../nopy.executor.js';
import { type CubeSession, type NopySession } from '../nopy.session.js';
import type { Cube, CubeVariables } from './types.js';
/**
* Context for the resolution process
*/
export declare class BuildContext {
readonly allCubes: Record<string, Cube>;
readonly variables: Variables;
readonly session: NopySession;
readonly config: NopyConfig;
readonly auth: {
method: string;
username?: string;
password?: string;
};
readonly options: {
useDefaults?: boolean;
isSessionReplay?: boolean;
};
readonly deployCalls: DeployCall[];
readonly cubeSessions: CubeSession[];
private readonly resolvedCubes;
constructor(allCubes: Record<string, Cube>, variables: Variables, session: NopySession, config: NopyConfig, auth: {
method: string;
username?: string;
password?: string;
}, options?: {
useDefaults?: boolean;
isSessionReplay?: boolean;
});
/**
* Resolves a cube, its dependencies, and hooks recursively
*/
resolveCube(cubeId: string, host: string, overrides?: CubeVariables): Promise<void>;
/**
* Builds and stores a deployment call for a resolved cube
*/
private buildDeployCall;
}
-114
View File
@@ -1,114 +0,0 @@
/**
* Dynamic dependency resolution for cubes
* @module cubes/dependencies
*/
import { getLogger } from '@logtape/logtape';
import { VariableAssignment } from '../nopy.prompts.js';
const log = getLogger(['nopy', 'resolution']);
/**
* Context for the resolution process
*/
export class BuildContext {
allCubes;
variables;
session;
config;
auth;
options;
deployCalls = [];
cubeSessions = [];
resolvedCubes = new Set();
constructor(allCubes, variables, session, config, auth, options = {}) {
this.allCubes = allCubes;
this.variables = variables;
this.session = session;
this.config = config;
this.auth = auth;
this.options = options;
}
/**
* Resolves a cube, its dependencies, and hooks recursively
*/
async resolveCube(cubeId, host, overrides = {}) {
const cube = this.allCubes[cubeId];
if (!cube) {
throw new Error(`Cube not found: ${cubeId}`);
}
log.debug('Resolving cube', { cubeId, host });
// 1. Assign overrides and defaults
if (Object.keys(overrides).length > 0) {
this.variables.assign(cubeId, 'params', overrides);
}
this.variables.assign(cubeId, 'defaults', cube.getDefaults());
// 2. Variable collection
if (this.options.isSessionReplay) {
const sessionCube = this.session.cubes.find(c => c.key === cubeId);
if (sessionCube) {
this.variables.assign(cubeId, 'defaults', sessionCube.variables);
}
}
else {
await VariableAssignment(cube, this.variables);
}
const currentVars = this.variables.get(cubeId);
const hookCtx = {
exec: (id, vars) => this.resolveCube(id, host, vars),
};
// 3. Execute 'before' hooks
if (cube.manifest.before) {
for (const hook of cube.manifest.before) {
await hook(hookCtx, currentVars);
}
}
// 4. Resolve dynamic dependencies
const depSpecs = cube.manifest.dependencies?.(currentVars) ?? [];
for (const spec of depSpecs) {
const depId = typeof spec === 'string' ? spec : spec[0];
const depVars = typeof spec === 'string' ? {} : (spec[1] ?? {});
await this.resolveCube(depId, host, depVars);
}
// 5. Build the deployment call
this.buildDeployCall(cube, host);
// 6. Execute 'after' hooks
if (cube.manifest.after) {
for (const hook of cube.manifest.after) {
await hook(hookCtx, currentVars);
}
}
}
/**
* Builds and stores a deployment call for a resolved cube
*/
buildDeployCall(cube, host) {
const cubeId = cube.id;
const callKey = `${cubeId}:${host}`;
if (this.resolvedCubes.has(callKey))
return;
const parts = [];
if (this.auth.method === 'password' && this.auth.username && this.auth.password) {
parts.push(`--user ${this.auth.username} --password ${this.auth.password}`);
}
const cubeVars = this.variables.get(cubeId);
Object.entries(cubeVars).forEach(([key, value]) => {
parts.push(`--data "${key}=${value}"`);
});
parts.push(`--chdir ${cube.dir}`);
parts.push(`${cube.dir}/${cube.deployScript}`);
const command = ['pyinfra', host, '-y', ...parts];
this.deployCalls.push({
cube: cubeId,
host,
cwd: cube.dir,
command,
env: cubeVars,
dependencies: [],
});
if (!this.cubeSessions.some(s => s.key === cubeId)) {
this.cubeSessions.push({
key: cubeId,
variables: this.variables.get(cubeId, 'prompts'),
});
}
this.resolvedCubes.add(callKey);
}
}
-21
View File
@@ -1,21 +0,0 @@
/**
* Factory functions for creating cube configurations
* @module cubes/factories
*/
import { 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 declare function createManifest<Schema extends import('zod').z.AnyZodObject>(opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>): Manifest<Schema>;
/**
* Alias for createManifest - for backwards compatibility with existing manifests
*/
export declare const manifest: typeof createManifest;
/**
* @deprecated Use createManifest or manifest instead
*/
export declare const ManifestFactory: typeof createManifest;
export { Manifest } from './types.js';
-23
View File
@@ -1,23 +0,0 @@
/**
* Factory functions for creating cube configurations
* @module cubes/factories
*/
import { 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(opts) {
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';
-13
View File
@@ -1,13 +0,0 @@
/**
* Nopy Cubes Module
*
* Self-contained deployment units for pyinfra automation.
*
* @module cubes
*/
export { Cube, Manifest, } from './types.js';
export type { Hook, HookContext, LoadResult, CubeVariables, DependencySpec, } from './types.js';
export { createManifest, manifest, } from './factories.js';
export { loadCubes, findCubeDirectories, getCube, } from './loader.js';
export { BuildContext, } from './dependencies.js';
export { uniqid } from './utils.js';
-17
View File
@@ -1,17 +0,0 @@
/**
* Nopy Cubes Module
*
* Self-contained deployment units for pyinfra automation.
*
* @module cubes
*/
// Types
export { Cube, Manifest, } from './types.js';
// Factory functions
export { createManifest, manifest, } from './factories.js';
// Loader
export { loadCubes, findCubeDirectories, getCube, } from './loader.js';
// Dependencies
export { BuildContext, } from './dependencies.js';
// Utilities
export { uniqid } from './utils.js';
-22
View File
@@ -1,22 +0,0 @@
/**
* Cube discovery and loading from the filesystem
* @module cubes/loader
*/
import { Cube, type LoadResult } from './types.js';
/**
* Traverses upwards from the current working directory to the root
* and collects all directories that contain a `.npcubes` marker file.
*
* Also includes directories specified in the `.nopyrc.json` configuration.
*
* @returns Array of absolute paths to directories containing cubes
*/
export declare function findCubeDirectories(): string[];
/**
* Loads all cubes from discovered cube directories.
*/
export declare function loadCubes(): Promise<LoadResult>;
/**
* Gets information about a single cube by name.
*/
export declare function getCube(cubeName: string): Promise<Cube | undefined>;
-102
View File
@@ -1,102 +0,0 @@
/**
* Cube discovery and loading from the filesystem
* @module cubes/loader
*/
import path from 'node:path';
import { z } from 'zod';
import { fs } from 'zx';
import { loadConfig } from '../nopy.config.js';
import { Cube } from './types.js';
/**
* Traverses upwards from the current working directory to the root
* and collects all directories that contain a `.npcubes` marker file.
*
* Also includes directories specified in the `.nopyrc.json` configuration.
*
* @returns Array of absolute paths to directories containing cubes
*/
export function findCubeDirectories() {
let currentDir = process.cwd();
const config = loadConfig();
const dirSet = new Set(config.cubeDirs.map((dir) => path.resolve(process.cwd(), dir)));
while (true) {
const targetFile = path.join(currentDir, '.npcubes');
if (fs.existsSync(targetFile) && fs.statSync(targetFile).isFile()) {
dirSet.add(currentDir);
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
break; // Stop when reaching the root
}
currentDir = parentDir;
}
return [...dirSet];
}
/**
* Extracts cube ID from name pattern [id] or explicit id field
*/
function extractCubeId(manifest) {
if (manifest.id)
return manifest.id;
const match = manifest.name.match(/^\[([^\]]+)\]/);
return match ? match[1] : undefined;
}
/**
* Loads all cubes from discovered cube directories.
*/
export async function loadCubes() {
const cubesFolders = findCubeDirectories();
const cubes = {};
const errors = [];
async function scanDirectory(currentDir, baseDir) {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
const files = entries.filter((e) => e.isFile());
const manifestFile = files.find((f) => f.name === 'manifest.mjs' || f.name.endsWith('.manifest.mjs'));
const deployFile = files.find((f) => f.name === 'deploy.py' || f.name.endsWith('.deploy.py'));
if (manifestFile && deployFile) {
const cubePath = currentDir;
const manifestPath = path.join(cubePath, manifestFile.name);
try {
const manifest = (await import(manifestPath)).default;
if (!manifest || typeof manifest !== 'object') {
errors.push(`Invalid manifest export in ${manifestPath}`);
}
else if (!manifest.name) {
errors.push(`Invalid manifest format in ${manifestPath}: missing 'name'`);
}
else {
const cubeId = extractCubeId(manifest) || path.basename(cubePath);
if (cubes[cubeId]) {
errors.push(`Duplicate cube id '${cubeId}'`);
return;
}
// Ensure basic properties
manifest.id = cubeId;
manifest.schema = manifest.schema ?? z.object({});
cubes[cubeId] = new Cube(manifest, cubePath, deployFile.name);
}
}
catch (err) {
errors.push(`Failed to load manifest ${manifestPath}: ${err}`);
}
}
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
await scanDirectory(path.join(currentDir, entry.name), baseDir);
}
}
}
await Promise.all(cubesFolders.map(async (folder) => {
if (fs.existsSync(folder)) {
await scanDirectory(folder, folder);
}
}));
return { cubes, errors };
}
/**
* Gets information about a single cube by name.
*/
export async function getCube(cubeName) {
const { cubes } = await loadCubes();
return cubes[cubeName];
}
-74
View File
@@ -1,74 +0,0 @@
/**
* Type definitions for Nopy cubes
* @module cubes/types
*/
import { z } from 'zod';
/**
* 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 z.AnyZodObject = z.AnyZodObject> = (ctx: HookContext, variables: z.infer<Schema>) => void | Promise<void>;
/**
* User-defined specification for a cube
*/
export interface Manifest<Schema extends z.AnyZodObject = z.AnyZodObject> {
/** 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;
/** 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 declare function Manifest<Schema extends z.AnyZodObject>(opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>): Manifest<Schema>;
export declare namespace Manifest {
/**
* Internal create helper
*/
function create<Schema extends z.AnyZodObject>(opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>): Manifest<Schema>;
}
/**
* A fully loaded cube with its filesystem location and runtime state
*/
export declare class Cube<Schema extends z.AnyZodObject = z.AnyZodObject> {
readonly manifest: Manifest<Schema>;
readonly dir: string;
readonly deployScript: string;
constructor(manifest: Manifest<Schema>, dir: string, deployScript: string);
get id(): string;
get name(): string;
/**
* Returns default values for the cube's schema
*/
getDefaults(): z.infer<Schema>;
}
/**
* 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[];
}
-57
View File
@@ -1,57 +0,0 @@
/**
* Type definitions for Nopy cubes
* @module cubes/types
*/
import { z } from 'zod';
/**
* Factory function and namespace for Manifest
*/
export function Manifest(opts) {
return {
id: opts.id ?? '',
name: opts.name,
schema: opts.schema ?? z.object({}),
dependencies: opts.dependencies,
before: opts.before ?? [],
after: opts.after ?? [],
};
}
(function (Manifest) {
/**
* Internal create helper
*/
function create(opts) {
return Manifest(opts);
}
Manifest.create = create;
})(Manifest || (Manifest = {}));
/**
* A fully loaded cube with its filesystem location and runtime state
*/
export class Cube {
manifest;
dir;
deployScript;
constructor(manifest, dir, deployScript) {
this.manifest = manifest;
this.dir = dir;
this.deployScript = deployScript;
}
get id() {
return this.manifest.id;
}
get name() {
return this.manifest.name;
}
/**
* Returns default values for the cube's schema
*/
getDefaults() {
try {
return this.manifest.schema.parse({});
}
catch {
return {};
}
}
}
-20
View File
@@ -1,20 +0,0 @@
/**
* Utility functions for cubes
* @module cubes/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 declare function uniqid(length?: number): string;
-33
View File
@@ -1,33 +0,0 @@
/**
* Utility functions for cubes
* @module cubes/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) {
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 = [];
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('');
}
-20
View File
@@ -1,20 +0,0 @@
/**
* Nopy - A CLI tool for pyinfra script management and execution
*
* @packageDocumentation
*/
export * from './cubes/index.js';
export { cubes } from './nopy.cubes.js';
export { nopy } from './nopy.main.js';
export type { NopyOptions, NopyResult } from './nopy.main.js';
export { executeDeployCalls, outputExecutionPlan, summarizeResults, } from './nopy.executor.js';
export type { DeployCall, ExecutionResult, ExecutionOptions, } from './nopy.executor.js';
export { runWorkflow, runInteractiveWorkflow, runReplayWorkflow, runSessionReplayWorkflow, } from './nopy.workflow.js';
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
export { CubeSelection, AuthSelection, HostSelection, VariableAssignment, PasswordSelection, } from './nopy.prompts.js';
export { loadSession, saveSession, createSession, listSessions, filterInternalVariables, separateEnvAndCubeVariables, } from './nopy.session.js';
export type { NopySession, CubeSession, AuthSession } from './nopy.session.js';
export { loadHistory, saveHistory, addToHistory, getLastSession, getSessionById, listHistory, clearHistory, removeFromHistory, formatHistoryList, getHistoryPath, DEFAULT_HISTORY_SIZE, HISTORY_FILE, } from './nopy.history.js';
export type { HistoryEntry, SessionHistory } from './nopy.history.js';
export { loadConfig, saveConfig, logConfigToFlags, getConfigPaths } from './nopy.config.js';
export type { NopyConfig, NopyConfigFile, LogConfig, LogVerbosity, HistoryConfig, ExecutionConfig, ResolutionStrategy, ResolutionConfig, } from './nopy.config.js';
-23
View File
@@ -1,23 +0,0 @@
/**
* Nopy - A CLI tool for pyinfra script management and execution
*
* @packageDocumentation
*/
// Cubes module
export * from './cubes/index.js';
// Backwards compatibility - cubes namespace
export { cubes } from './nopy.cubes.js';
// Main entry point
export { nopy } from './nopy.main.js';
// Executor
export { executeDeployCalls, outputExecutionPlan, summarizeResults, } from './nopy.executor.js';
// Workflow
export { runWorkflow, runInteractiveWorkflow, runReplayWorkflow, runSessionReplayWorkflow, } from './nopy.workflow.js';
// Prompts
export { CubeSelection, AuthSelection, HostSelection, VariableAssignment, PasswordSelection, } from './nopy.prompts.js';
// Session management
export { loadSession, saveSession, createSession, listSessions, filterInternalVariables, separateEnvAndCubeVariables, } from './nopy.session.js';
// History management
export { loadHistory, saveHistory, addToHistory, getLastSession, getSessionById, listHistory, clearHistory, removeFromHistory, formatHistoryList, getHistoryPath, DEFAULT_HISTORY_SIZE, HISTORY_FILE, } from './nopy.history.js';
// Configuration
export { loadConfig, saveConfig, logConfigToFlags, getConfigPaths } from './nopy.config.js';
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env node
/**
* Nopy CLI - pyinfra deployment management
* @module nopy.cli
*/
export {};
-127
View File
@@ -1,127 +0,0 @@
#!/usr/bin/env node
/**
* Nopy CLI - pyinfra deployment management
* @module nopy.cli
*/
import { Command } from 'commander';
import { loadConfig } from './nopy.config.js';
import { clearHistory, formatHistoryList, getLastSession, getSessionById, listHistory, } from './nopy.history.js';
import { nopy } from './nopy.main.js';
const program = new Command();
const config = loadConfig();
program
.name('nopy')
.version('1.0.0')
.description('A CLI tool for pyinfra script management and execution.')
.addHelpText('after', `
Examples:
$ nopy Interactive cube selection and deployment
$ nopy -R Repeat the last deployment session
$ nopy -H <id> Run a specific session from history
$ nopy -l session.json Load and replay a saved session file
$ nopy -s session.json Save session to file after deployment
$ nopy -n Dry run (show plan without executing)
$ nopy -P Print deploy commands only
$ nopy history List all saved sessions
$ nopy clear-history Clear session history
Session Replay:
Sessions are automatically saved to history after each deployment.
Use 'nopy history' to see available sessions and their IDs.
Use 'nopy -R' to quickly repeat the last session.
Use 'nopy -H <id>' to run any session from history.
`);
program
.command('install', { isDefault: true })
.description('Install cubes on a given host')
.alias('i')
.option('-D, --use-defaults', 'Run cubes with default values without prompts')
.option('-K, --auth-method-key', 'Use SSH key authentication')
.option('-R, --repeat-last', 'Repeat the last session from history')
.option('-H, --history <id>', 'Run a specific session from history by ID')
.option('-s, --save-session <path>', 'Save session to file for later replay')
.option('-l, --load-session <path>', 'Load and replay session from file')
.option('-n, --dry-run', 'Show execution plan without running')
.option('-P, --print-only', 'Print deploy commands and exit (no execution)')
.option('-c, --continue-on-error', 'Continue executing after failures')
.option('-j, --json', 'Output results as JSON')
.option('--no-history', 'Do not save this session to history')
.action(async (options) => {
// Apply config defaults
const execConfig = config.execution ?? {};
const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false;
try {
// Handle session replay
const loadSessionPath = options.loadSession;
let sessionToReplay;
if (options.repeatLast) {
const lastEntry = getLastSession();
if (!lastEntry) {
console.error('No sessions in history. Run a deployment first.');
process.exit(1);
}
sessionToReplay = lastEntry;
console.log(`Repeating: ${lastEntry.name}\n`);
}
else if (options.history) {
const entry = getSessionById(options.history);
if (!entry) {
console.error(`Session not found: ${options.history}`);
console.error('Use "nopy history" to list available sessions.');
process.exit(1);
}
sessionToReplay = entry;
console.log(`Running: ${entry.name}\n`);
}
const result = await nopy({
useDefaults: options.useDefaults,
useAuthKey: options.authMethodKey,
saveSession: options.saveSession,
loadSession: loadSessionPath,
replaySession: sessionToReplay?.session,
dryRun: options.dryRun,
printOnly: options.printOnly,
continueOnError,
jsonOutput: options.json,
saveToHistory: options.history !== false && !options.dryRun,
});
// Exit with error code if deployment failed
if (result && !result.success) {
process.exit(1);
}
}
catch (error) {
if (options.json) {
console.log(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : String(error),
}, null, 2));
}
else {
console.error('Error:', error instanceof Error ? error.message : error, error);
}
process.exit(1);
}
});
program
.command('history')
.description('List session history')
.alias('h')
.option('-j, --json', 'Output as JSON')
.action((options) => {
const entries = listHistory();
if (options.json) {
console.log(JSON.stringify(entries, null, 2));
}
else {
console.log(formatHistoryList(entries));
}
});
program
.command('clear-history')
.description('Clear all session history')
.action(() => {
clearHistory();
console.log('Session history cleared.');
});
program.parse();
-20
View File
@@ -1,20 +0,0 @@
/**
* Environment variable configuration
*/
export type TVariables = Record<string, string | number | boolean>;
export declare namespace Variables {
type ArtefactId = string;
type Scope = 'defaults' | 'prompts' | 'params';
}
export declare class Variables {
readonly global: TVariables;
/** @summary env as configured in cube or session script */
defaults: Record<Variables.ArtefactId, TVariables>;
/** @summary env as configured via prompts */
prompts: Record<Variables.ArtefactId, TVariables>;
/** @summary env as handed via params (on hook calls) */
params: Record<Variables.ArtefactId, TVariables>;
constructor(global?: TVariables);
assign(artefactId: Variables.ArtefactId, scope: Variables.Scope, values?: TVariables): void;
get(artefactId: Variables.ArtefactId, scope?: Variables.Scope): TVariables;
}
-32
View File
@@ -1,32 +0,0 @@
export class Variables {
global;
/** @summary env as configured in cube or session script */
defaults = {};
/** @summary env as configured via prompts */
prompts = {};
/** @summary env as handed via params (on hook calls) */
params = {};
constructor(global = {}) {
this.global = global;
}
assign(artefactId, scope, values = {}) {
console.log('Assigning', artefactId, scope, values);
if (!this[scope][artefactId]) {
this[scope][artefactId] = values;
}
else {
Object.assign(this[scope][artefactId], values);
}
}
get(artefactId, scope) {
if (scope) {
return this[scope][artefactId] || {};
}
return {
...this.global,
...this.defaults[artefactId],
...this.prompts[artefactId],
...this.params[artefactId],
};
}
}
-114
View File
@@ -1,114 +0,0 @@
/**
* Configuration loading and management
* @module nopy.config
*/
import type { TVariables } from './nopy.common.js';
/**
* Log verbosity levels for pyinfra output
*/
export type LogVerbosity = 'silent' | 'info' | 'verbose' | 'trace';
/**
* Logging configuration
*/
export interface LogConfig {
/** Output verbosity level */
verbosity?: LogVerbosity;
/** Enable pyinfra debug logging */
debug?: boolean;
}
/**
* History configuration
*/
export interface HistoryConfig {
/** Maximum number of sessions to keep in history (default: 10) */
maxSessions?: number;
/** Whether to auto-save sessions to history (default: true) */
autoSave?: boolean;
}
/**
* Execution configuration
*/
export interface ExecutionConfig {
/** Continue executing after a cube fails (default: false) */
continueOnError?: boolean;
}
/**
* Resolution strategy for merging config properties
* - 'merge': Arrays are concatenated, objects are deep merged (default)
* - 'override': Child value completely replaces parent value
*/
export type ResolutionStrategy = 'merge' | 'override';
/**
* Resolution configuration for customizing merge behavior
*/
export type ResolutionConfig = {
[K in keyof NopyConfig]?: ResolutionStrategy;
};
/**
* Raw config file structure (includes resolution)
*/
export interface NopyConfigFile extends Partial<NopyConfig> {
/** Customize merge behavior for specific properties */
resolution?: ResolutionConfig;
}
/**
* Nopy configuration file structure
*/
export interface NopyConfig {
/** Available host addresses */
hosts: string[];
/** Directories to search for cubes */
cubeDirs: string[];
/** Global environment variables */
env: TVariables;
/** Logging configuration */
log?: LogConfig;
/** Session history configuration */
history?: HistoryConfig;
/** Execution configuration */
execution?: ExecutionConfig;
}
/**
* Loads the nopy configuration
*
* Searches for `.nopyrc.json` by traversing upwards from cwd to root.
* Multiple config files are merged, with child configs overriding parent configs.
*
* Use the `resolution` property to customize merge behavior:
* ```json
* {
* "hosts": ["local-host"],
* "resolution": {
* "hosts": "override"
* }
* }
* ```
*
* @returns The merged configuration
* @throws Error if no config file is found
*/
export declare function loadConfig(): NopyConfig;
/**
* Gets the paths of all discovered config files (for debugging)
*/
export declare function getConfigPaths(): string[];
/**
* Saves configuration to a file
*
* @param data - Configuration data to save
* @param configPath - Path to save to (defaults to cwd/.nopyrc.json)
*/
export declare function saveConfig(data: Partial<NopyConfig>, configPath?: string): void;
/**
* Converts log configuration to pyinfra command line flags
*
* @param logConfig - Log configuration with verbosity and debug settings
* @returns Array of pyinfra flags
*
* @example
* ```typescript
* const flags = logConfigToFlags({ verbosity: 'verbose', debug: true });
* // Returns: ['-vv', '--debug']
* ```
*/
export declare function logConfigToFlags(logConfig?: LogConfig): string[];
-263
View File
@@ -1,263 +0,0 @@
/**
* Configuration loading and management
* @module nopy.config
*/
import fs from 'node:fs';
import path from 'node:path';
/**
* Default configuration
*/
const DEFAULT_CONFIG = {
hosts: [],
cubeDirs: [],
env: {},
};
const CONFIG_FILENAME = '.nopyrc.json';
/**
* Finds all config files by traversing upwards from cwd to root
* Returns configs in order from root to cwd (parent first, child last)
*/
function findConfigFiles() {
const configPaths = [];
let currentDir = process.cwd();
// Traverse upwards
while (true) {
const configPath = path.join(currentDir, CONFIG_FILENAME);
if (fs.existsSync(configPath)) {
configPaths.unshift(configPath); // Add to front (root first)
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
break; // Reached root
}
currentDir = parentDir;
}
// Also check home directory (lowest priority)
const homeConfig = path.join(process.env.HOME || '', CONFIG_FILENAME);
if (homeConfig && fs.existsSync(homeConfig) && !configPaths.includes(homeConfig)) {
configPaths.unshift(homeConfig);
}
return configPaths;
}
/**
* Deep merges two values based on resolution strategy
*/
function mergeValue(parentValue, childValue, strategy) {
// Override strategy: child replaces parent completely
if (strategy === 'override') {
return childValue;
}
// Merge strategy (default)
if (Array.isArray(parentValue) && Array.isArray(childValue)) {
// Concatenate arrays, remove duplicates for primitives
const combined = [...parentValue, ...childValue];
if (combined.every((v) => typeof v !== 'object')) {
return [...new Set(combined)];
}
return combined;
}
if (typeof parentValue === 'object' &&
parentValue !== null &&
typeof childValue === 'object' &&
childValue !== null &&
!Array.isArray(parentValue) &&
!Array.isArray(childValue)) {
// Deep merge objects
const result = { ...parentValue };
for (const [key, value] of Object.entries(childValue)) {
if (key in result) {
result[key] = mergeValue(result[key], value, 'merge');
}
else {
result[key] = value;
}
}
return result;
}
// Primitives: child overrides parent
return childValue;
}
/**
* Checks if a string looks like a relative path
*/
function isRelativePath(value) {
return (value.startsWith('./') ||
value.startsWith('../') ||
// Also match paths without ./ prefix that don't look like URLs or absolute paths
(!value.startsWith('/') &&
!value.startsWith('~') &&
!value.includes('://') &&
(value.includes('/') || value.endsWith('.json') || value.endsWith('.yml'))));
}
/**
* Resolves relative paths in a value based on config file location
*/
function resolveRelativePaths(value, configDir) {
if (typeof value === 'string') {
if (isRelativePath(value)) {
return path.resolve(configDir, value);
}
return value;
}
if (Array.isArray(value)) {
return value.map((item) => resolveRelativePaths(item, configDir));
}
if (typeof value === 'object' && value !== null) {
const result = {};
for (const [key, val] of Object.entries(value)) {
result[key] = resolveRelativePaths(val, configDir);
}
return result;
}
return value;
}
/**
* Properties that contain filesystem paths and should have relative paths resolved
*/
const PATH_PROPERTIES = ['cubeDirs'];
/**
* Resolves relative paths in a config file based on its location
* Only resolves paths for properties that are known to contain filesystem paths
*/
function resolveConfigPaths(config, configPath) {
const configDir = path.dirname(configPath);
const resolved = {};
for (const [key, value] of Object.entries(config)) {
if (key === 'resolution') {
// Don't resolve the resolution config itself
resolved[key] = value;
}
else if (PATH_PROPERTIES.includes(key)) {
// Only resolve paths for known path properties
resolved[key] = resolveRelativePaths(value, configDir);
}
else {
// Copy other properties as-is (including hosts)
resolved[key] = value;
}
}
return resolved;
}
/**
* Merges a child config into a parent config
*/
function mergeConfigs(parent, childFile) {
const resolution = childFile.resolution || {};
const result = { ...parent };
for (const [key, value] of Object.entries(childFile)) {
if (key === 'resolution')
continue; // Skip resolution property itself
const strategy = resolution[key] || 'merge';
if (key in result) {
result[key] = mergeValue(result[key], value, strategy);
}
else {
result[key] = value;
}
}
return result;
}
/**
* Loads the nopy configuration
*
* Searches for `.nopyrc.json` by traversing upwards from cwd to root.
* Multiple config files are merged, with child configs overriding parent configs.
*
* Use the `resolution` property to customize merge behavior:
* ```json
* {
* "hosts": ["local-host"],
* "resolution": {
* "hosts": "override"
* }
* }
* ```
*
* @returns The merged configuration
* @throws Error if no config file is found
*/
export function loadConfig() {
const configPaths = findConfigFiles();
if (configPaths.length === 0) {
throw new Error(`No ${CONFIG_FILENAME} found. Create one in your project directory or any parent directory.`);
}
// Start with defaults and merge each config file
let config = { ...DEFAULT_CONFIG };
for (const configPath of configPaths) {
try {
const content = fs.readFileSync(configPath, 'utf-8');
const rawConfig = JSON.parse(content);
// Resolve relative paths based on config file location
const resolvedConfig = resolveConfigPaths(rawConfig, configPath);
config = mergeConfigs(config, resolvedConfig);
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to load config ${configPath}: ${message}`);
}
}
return config;
}
/**
* Gets the paths of all discovered config files (for debugging)
*/
export function getConfigPaths() {
return findConfigFiles();
}
/**
* Saves configuration to a file
*
* @param data - Configuration data to save
* @param configPath - Path to save to (defaults to cwd/.nopyrc.json)
*/
export function saveConfig(data, configPath) {
const savePath = configPath || path.resolve(process.cwd(), CONFIG_FILENAME);
// Try to load existing config from this specific file
let existing = {};
if (fs.existsSync(savePath)) {
try {
existing = JSON.parse(fs.readFileSync(savePath, 'utf-8'));
}
catch {
// Ignore parse errors, start fresh
}
}
const merged = { ...existing, ...data };
fs.writeFileSync(savePath, JSON.stringify(merged, null, 2));
}
/**
* Converts log configuration to pyinfra command line flags
*
* @param logConfig - Log configuration with verbosity and debug settings
* @returns Array of pyinfra flags
*
* @example
* ```typescript
* const flags = logConfigToFlags({ verbosity: 'verbose', debug: true });
* // Returns: ['-vv', '--debug']
* ```
*/
export function logConfigToFlags(logConfig) {
const flags = [];
const verbosity = logConfig?.verbosity ?? 'silent';
// Add verbosity flags
switch (verbosity) {
case 'silent':
// No verbosity flags
break;
case 'info':
flags.push('-v'); // Print meta information
break;
case 'verbose':
flags.push('-vv'); // Print meta + input data
break;
case 'trace':
flags.push('-vvv'); // Print meta + input + output
break;
}
// Add debug flag if enabled
if (logConfig?.debug) {
flags.push('--debug');
}
return flags;
}
-22
View File
@@ -1,22 +0,0 @@
/**
* Backwards compatibility re-export
*
* This file maintains the `cubes` namespace for existing code.
* New code should import directly from './cubes/index.js'
*
* @deprecated Import from './cubes/index.js' instead
*/
import * as cubesModule from './cubes/index.js';
export declare const cubes: {
Cube: typeof cubesModule.Cube;
Manifest: typeof cubesModule.Manifest;
createManifest: typeof cubesModule.createManifest;
manifest: typeof cubesModule.createManifest;
loadCubes: typeof cubesModule.loadCubes;
getCube: typeof cubesModule.getCube;
BuildContext: typeof cubesModule.BuildContext;
uniqid: typeof cubesModule.uniqid;
load: typeof cubesModule.loadCubes;
findCubeDirectories: typeof cubesModule.findCubeDirectories;
};
export type { Hook, HookContext, Cube, Manifest, LoadResult, CubeVariables, } from './cubes/index.js';
-16
View File
@@ -1,16 +0,0 @@
/**
* Backwards compatibility re-export
*
* This file maintains the `cubes` namespace for existing code.
* New code should import directly from './cubes/index.js'
*
* @deprecated Import from './cubes/index.js' instead
*/
import * as cubesModule from './cubes/index.js';
export const cubes = {
// Runtime exports
...cubesModule,
// Aliases for backwards compatibility
load: cubesModule.loadCubes,
findCubeDirectories: cubesModule.findCubeDirectories,
};
-92
View File
@@ -1,92 +0,0 @@
/**
* Pyinfra command execution
* @module nopy.executor
*/
import type { DependencySpec } from './cubes/types.js';
/**
* A deployment command ready for execution
*/
export interface DeployCall {
/** Cube being deployed */
cube: string;
/** Target host */
host: string;
/** Working directory for execution */
cwd: string;
/** Full command array */
command: string[];
/** Environment variables for the cube */
env: Record<string, unknown>;
/** Cube dependencies */
dependencies: DependencySpec[];
}
/**
* Result of executing a deployment command
*/
export interface ExecutionResult {
/** Cube that was deployed */
cube: string;
/** Target host */
host: string;
/** Whether execution succeeded */
success: boolean;
/** Execution duration in milliseconds */
duration: number;
/** Standard output (if captured) */
stdout?: string;
/** Standard error (if captured) */
stderr?: string;
/** Error if execution failed */
error?: Error;
}
/**
* Options for deployment execution
*/
export interface ExecutionOptions {
/** Continue executing remaining cubes after failure */
continueOnError?: boolean;
/** Show what would be executed without running */
dryRun?: boolean;
/** Callback for progress updates */
onProgress?: (result: ExecutionResult, completed: number, total: number) => void;
/** Callback when execution starts */
onStart?: (cube: string, host: string) => void;
}
/**
* Outputs the execution plan without running (dry run)
*
* @param calls - Array of deployment calls
* @param asJson - Output as JSON instead of text
*/
export declare function outputExecutionPlan(calls: DeployCall[], asJson?: boolean): void;
/**
* Executes an array of deployment calls
*
* @param calls - Array of deployment calls to execute
* @param options - Execution options
* @returns Array of execution results
*
* @example
* ```typescript
* const results = await executeDeployCalls(calls, {
* continueOnError: false,
* onProgress: (result, completed, total) => {
* console.log(`${completed}/${total} complete`);
* },
* });
* ```
*/
export declare function executeDeployCalls(calls: DeployCall[], options?: ExecutionOptions): Promise<ExecutionResult[]>;
/**
* Generates a summary of execution results
*
* @param results - Array of execution results
* @returns Summary object
*/
export declare function summarizeResults(results: ExecutionResult[]): {
total: number;
successful: number;
failed: number;
totalDuration: number;
failures: ExecutionResult[];
};
-138
View File
@@ -1,138 +0,0 @@
/**
* Pyinfra command execution
* @module nopy.executor
*/
import { getLogger } from '@logtape/logtape';
import { execa } from 'execa';
const log = getLogger(['nopy', 'executor']);
/**
* Executes a single deployment call
*
* @param call - The deployment call to execute
* @returns Execution result
*/
async function executeCall(call) {
const startTime = Date.now();
const commandStr = call.command.join(' ');
try {
log.info(`Executing: ${call.cube} -> ${call.host}`);
log.debug(`Command: ${commandStr}`);
// Inherit stdio for live output
await execa({ shell: true })(commandStr, {
cwd: call.cwd,
stdio: 'inherit',
});
return {
cube: call.cube,
host: call.host,
success: true,
duration: Date.now() - startTime,
};
}
catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
log.error(`Failed: ${call.cube} -> ${call.host}`, { error: err.message });
return {
cube: call.cube,
host: call.host,
success: false,
duration: Date.now() - startTime,
error: err,
};
}
}
/**
* Outputs the execution plan without running (dry run)
*
* @param calls - Array of deployment calls
* @param asJson - Output as JSON instead of text
*/
export function outputExecutionPlan(calls, asJson) {
if (asJson) {
const plan = calls.map((call) => ({
cube: call.cube,
host: call.host,
command: call.command.join(' '),
variables: call.env,
}));
console.log(JSON.stringify({ plan }, null, 2));
return;
}
console.log('\n=== Execution Plan (Dry Run) ===\n');
for (let i = 0; i < calls.length; i++) {
const call = calls[i];
console.log(`Step ${i + 1}: ${call.cube} -> ${call.host}`);
console.log(` Command: ${call.command.join(' ')}`);
const envKeys = Object.keys(call.env);
if (envKeys.length > 0) {
console.log(' Variables:');
for (const [key, value] of Object.entries(call.env)) {
// Mask sensitive values
const displayValue = key.toLowerCase().includes('password') ? '********' : String(value);
console.log(` ${key}=${displayValue}`);
}
}
console.log();
}
console.log(`Total: ${calls.length} command(s)\n`);
console.log('Run without --dry-run to execute.\n');
}
/**
* Executes an array of deployment calls
*
* @param calls - Array of deployment calls to execute
* @param options - Execution options
* @returns Array of execution results
*
* @example
* ```typescript
* const results = await executeDeployCalls(calls, {
* continueOnError: false,
* onProgress: (result, completed, total) => {
* console.log(`${completed}/${total} complete`);
* },
* });
* ```
*/
export async function executeDeployCalls(calls, options = {}) {
if (calls.length === 0) {
log.info('No deployment calls to execute');
return [];
}
if (options.dryRun) {
outputExecutionPlan(calls);
return [];
}
log.info(`Executing ${calls.length} deployment call(s)`);
const results = [];
for (let i = 0; i < calls.length; i++) {
const call = calls[i];
options.onStart?.(call.cube, call.host);
const result = await executeCall(call);
results.push(result);
options.onProgress?.(result, i + 1, calls.length);
if (!result.success && !options.continueOnError) {
log.warn(`Stopping execution due to failure in ${call.cube}`);
break;
}
}
return results;
}
/**
* Generates a summary of execution results
*
* @param results - Array of execution results
* @returns Summary object
*/
export function summarizeResults(results) {
const successful = results.filter((r) => r.success);
const failed = results.filter((r) => !r.success);
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
return {
total: results.length,
successful: successful.length,
failed: failed.length,
totalDuration,
failures: failed,
};
}
-90
View File
@@ -1,90 +0,0 @@
/**
* Session history management
* @module nopy.history
*/
import type { NopySession } from './nopy.session.js';
/** Default number of sessions to keep in history */
export declare const DEFAULT_HISTORY_SIZE = 10;
/** History file name */
export declare const HISTORY_FILE = ".nopy.history.json";
/**
* A session entry in history
*/
export interface HistoryEntry {
/** Unique identifier (timestamp-based) */
id: string;
/** Human-readable name (timestamp + cube names) */
name: string;
/** ISO timestamp when session was executed */
timestamp: string;
/** The full session data */
session: NopySession;
}
/**
* History file structure
*/
export interface SessionHistory {
/** Array of session entries, newest first */
entries: HistoryEntry[];
}
/**
* Gets the path to the history file
*/
export declare function getHistoryPath(): string;
/**
* Loads the session history from disk
*
* @returns The session history or empty history if file doesn't exist
*/
export declare function loadHistory(): SessionHistory;
/**
* Saves the session history to disk
*
* @param history - The history to save
*/
export declare function saveHistory(history: SessionHistory): void;
/**
* Adds a session to the history
*
* @param session - The session to add
* @param maxEntries - Maximum number of entries to keep
* @returns The created history entry
*/
export declare function addToHistory(session: NopySession, maxEntries?: number): HistoryEntry;
/**
* Gets the most recent session from history
*
* @returns The last session or undefined if history is empty
*/
export declare function getLastSession(): HistoryEntry | undefined;
/**
* Gets a session by ID
*
* @param id - The session ID
* @returns The session entry or undefined
*/
export declare function getSessionById(id: string): HistoryEntry | undefined;
/**
* Lists all sessions in history
*
* @returns Array of history entries, newest first
*/
export declare function listHistory(): HistoryEntry[];
/**
* Clears all session history
*/
export declare function clearHistory(): void;
/**
* Removes a specific session from history
*
* @param id - The session ID to remove
* @returns true if removed, false if not found
*/
export declare function removeFromHistory(id: string): boolean;
/**
* Formats history entries for display
*
* @param entries - History entries to format
* @returns Formatted string for console output
*/
export declare function formatHistoryList(entries: HistoryEntry[]): string;
-170
View File
@@ -1,170 +0,0 @@
/**
* Session history management
* @module nopy.history
*/
import fs from 'node:fs';
import path from 'node:path';
/** Default number of sessions to keep in history */
export const DEFAULT_HISTORY_SIZE = 10;
/** History file name */
export const HISTORY_FILE = '.nopy.history.json';
/**
* Gets the path to the history file
*/
export function getHistoryPath() {
return path.resolve(process.cwd(), HISTORY_FILE);
}
/**
* Loads the session history from disk
*
* @returns The session history or empty history if file doesn't exist
*/
export function loadHistory() {
const historyPath = getHistoryPath();
if (!fs.existsSync(historyPath)) {
return { entries: [] };
}
try {
const content = fs.readFileSync(historyPath, 'utf-8');
return JSON.parse(content);
}
catch {
return { entries: [] };
}
}
/**
* Saves the session history to disk
*
* @param history - The history to save
*/
export function saveHistory(history) {
const historyPath = getHistoryPath();
fs.writeFileSync(historyPath, JSON.stringify(history, null, 2), 'utf-8');
}
/**
* Generates a history entry name from session data
*
* Format: "YYYY-MM-DD HH:mm - cube1, cube2, ..."
*
* @param session - The session to name
* @param timestamp - ISO timestamp
* @returns Human-readable name
*/
function generateEntryName(session, timestamp) {
const date = new Date(timestamp);
const dateStr = date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const cubeNames = session.cubes.map((c) => c.key).join(', ');
const truncatedCubes = cubeNames.length > 40 ? `${cubeNames.substring(0, 37)}...` : cubeNames;
const hosts = session.hosts?.join(', ') || 'no host';
const truncatedHosts = hosts.length > 20 ? `${hosts.substring(0, 17)}...` : hosts;
return `${dateStr} - ${truncatedCubes}${truncatedHosts}`;
}
/**
* Generates a unique ID for a history entry
*/
function generateEntryId() {
return Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
}
/**
* Adds a session to the history
*
* @param session - The session to add
* @param maxEntries - Maximum number of entries to keep
* @returns The created history entry
*/
export function addToHistory(session, maxEntries = DEFAULT_HISTORY_SIZE) {
const history = loadHistory();
const timestamp = new Date().toISOString();
const entry = {
id: generateEntryId(),
name: generateEntryName(session, timestamp),
timestamp,
session,
};
// Add to beginning (newest first)
history.entries.unshift(entry);
// Trim to max size
if (history.entries.length > maxEntries) {
history.entries = history.entries.slice(0, maxEntries);
}
saveHistory(history);
return entry;
}
/**
* Gets the most recent session from history
*
* @returns The last session or undefined if history is empty
*/
export function getLastSession() {
const history = loadHistory();
return history.entries[0];
}
/**
* Gets a session by ID
*
* @param id - The session ID
* @returns The session entry or undefined
*/
export function getSessionById(id) {
const history = loadHistory();
return history.entries.find((e) => e.id === id);
}
/**
* Lists all sessions in history
*
* @returns Array of history entries, newest first
*/
export function listHistory() {
const history = loadHistory();
return history.entries;
}
/**
* Clears all session history
*/
export function clearHistory() {
saveHistory({ entries: [] });
}
/**
* Removes a specific session from history
*
* @param id - The session ID to remove
* @returns true if removed, false if not found
*/
export function removeFromHistory(id) {
const history = loadHistory();
const initialLength = history.entries.length;
history.entries = history.entries.filter((e) => e.id !== id);
if (history.entries.length < initialLength) {
saveHistory(history);
return true;
}
return false;
}
/**
* Formats history entries for display
*
* @param entries - History entries to format
* @returns Formatted string for console output
*/
export function formatHistoryList(entries) {
if (entries.length === 0) {
return 'No sessions in history.';
}
const lines = ['', 'Session History:', ''];
entries.forEach((entry, index) => {
const marker = index === 0 ? '→' : ' ';
lines.push(` ${marker} [${index + 1}] ${entry.name}`);
lines.push(` ID: ${entry.id}`);
});
lines.push('');
lines.push(`Total: ${entries.length} session(s)`);
lines.push('');
return lines.join('\n');
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Main entry point for nopy
* @module nopy.main
*/
import { type ExecutionResult } from './nopy.executor.js';
import { type NopySession } from './nopy.session.js';
/**
* Options for the nopy main function
*/
export interface NopyOptions {
useDefaults?: boolean;
useAuthKey?: boolean;
saveSession?: string;
loadSession?: string;
replaySession?: NopySession;
dryRun?: boolean;
printOnly?: boolean;
continueOnError?: boolean;
jsonOutput?: boolean;
saveToHistory?: boolean;
}
/**
* Result of a nopy execution
*/
export interface NopyResult {
success: boolean;
results: ExecutionResult[];
summary: {
total: number;
successful: number;
failed: number;
totalDuration: number;
};
}
/**
* Main entry point for nopy deployments
*/
export declare function nopy(opts?: NopyOptions): Promise<NopyResult | undefined>;
-163
View File
@@ -1,163 +0,0 @@
/**
* Main entry point for nopy
* @module nopy.main
*/
import { configure, getAnsiColorFormatter, getLogger } from '@logtape/logtape';
import { loadCubes } from './cubes/index.js';
import { BuildContext } from './cubes/dependencies.js';
import { Variables } from './nopy.common.js';
import { getConfigPaths, loadConfig } from './nopy.config.js';
import { executeDeployCalls, summarizeResults } from './nopy.executor.js';
import { DEFAULT_HISTORY_SIZE, addToHistory } from './nopy.history.js';
import { saveSession } from './nopy.session.js';
import { runWorkflow } from './nopy.workflow.js';
/**
* Configures the logtape logger for console output
*/
function configureLogtape() {
configure({
sinks: {
console: (() => {
const formatter = getAnsiColorFormatter();
return (record) => {
const formatted = formatter(record);
if (typeof formatted === 'string') {
const msg = formatted.replace(/\r?\n$/, '');
const props = record.properties;
console.log(msg, ...Object.values(props));
}
};
})(),
},
loggers: [
{
category: ['logtape', 'meta'],
level: 'error',
sinks: ['console'],
},
{
category: 'nopy',
level: 'debug',
sinks: ['console'],
},
],
});
}
// Initialize logging
configureLogtape();
/**
* Prints the active configuration summary
*/
function printActiveConfig(config, opts) {
const configPaths = getConfigPaths();
const cwd = process.cwd();
const lines = [''];
lines.push(' Configuration');
lines.push(' ─────────────');
const relativePaths = configPaths.map((p) => {
if (p.startsWith(cwd))
return `.${p.slice(cwd.length)}`;
if (p.startsWith(process.env.HOME || ''))
return `~${p.slice((process.env.HOME || '').length)}`;
return p;
});
lines.push(` Config: ${relativePaths.join(' → ')}`);
if (config.hosts.length > 0)
lines.push(` Hosts: ${config.hosts.join(', ')}`);
if (config.cubeDirs.length > 0)
lines.push(` Cube dirs: ${config.cubeDirs.join(', ')}`);
if (opts.continueOnError)
lines.push(' Execution: continue-on-error');
const envEntries = Object.entries(config.env);
if (envEntries.length > 0) {
lines.push(' Env vars:');
for (const [key, value] of envEntries) {
const isEmpty = value === null || value === undefined || value === '';
lines.push(` ${key}: ${isEmpty ? '<EMPTY>' : '<VALUE>'}`);
}
}
lines.push('');
console.log(lines.join('\n'));
}
/**
* Main entry point for nopy deployments
*/
export async function nopy(opts = {}) {
const { useDefaults = false, useAuthKey, saveSession: saveSessionPath, loadSession: loadSessionPath, replaySession, dryRun = false, printOnly = false, continueOnError = false, jsonOutput = false, saveToHistory = true, } = opts;
const log = getLogger(['nopy']);
const config = loadConfig();
if (!jsonOutput && !replaySession && !loadSessionPath) {
printActiveConfig(config, { continueOnError });
}
const { cubes, errors } = await loadCubes();
const variables = new Variables(config.env);
if (errors.length > 0) {
log.error('Errors found during cube loading:');
errors.forEach((error) => log.error(error));
if (jsonOutput)
console.log(JSON.stringify({ success: false, errors }, null, 2));
return undefined;
}
const workflow = await runWorkflow(loadSessionPath, cubes, config, { useDefaults, useAuthKey }, replaySession);
// Step 3: Build deployment calls using BuildContext
const context = new BuildContext(cubes, variables, workflow.session, config, {
method: workflow.authMethod,
username: workflow.username,
password: workflow.password,
}, {
useDefaults,
isSessionReplay: workflow.isReplay,
});
for (const host of workflow.session.hosts) {
for (const cubeId of workflow.selectedCubes) {
await context.resolveCube(cubeId, host);
}
}
const sessionForSaving = {
...workflow.session,
cubes: context.cubeSessions,
env: variables.get('global'),
};
if (saveSessionPath && !workflow.isReplay) {
saveSession(sessionForSaving, saveSessionPath);
}
if (saveToHistory && !dryRun && !workflow.isReplay && context.deployCalls.length > 0) {
const historySize = config.history?.maxSessions ?? DEFAULT_HISTORY_SIZE;
if (config.history?.autoSave !== false) {
addToHistory(sessionForSaving, historySize);
}
}
if (printOnly) {
console.log('\n Deploy Commands\n ───────────────\n');
for (const call of context.deployCalls) {
console.log(` # ${call.cube} -> ${call.host}`);
console.log(` ${call.command.join(' ')}\n`);
}
return {
success: true,
results: [],
summary: { total: context.deployCalls.length, successful: 0, failed: 0, totalDuration: 0 },
};
}
const results = await executeDeployCalls(context.deployCalls, {
dryRun,
continueOnError,
onProgress: (result, completed, total) => {
if (!jsonOutput) {
const status = result.success ? '✓' : '✗';
log.info(`[${completed}/${total}] ${status} ${result.cube} -> ${result.host}`);
}
},
});
const summary = summarizeResults(results);
return {
success: summary.failed === 0,
results,
summary: {
total: summary.total,
successful: summary.successful,
failed: summary.failed,
totalDuration: summary.totalDuration,
},
};
}
-21
View File
@@ -1,21 +0,0 @@
/**
* Interactive prompts for nopy CLI
* @module nopy.prompts
*/
import { z } from 'zod';
import type { Cube } from './cubes/index.js';
import type { Variables } from './nopy.common.js';
/**
* Prompts the user to select cubes to execute with filtering support
*/
export declare function CubeSelection(cubes: Record<string, Cube>): Promise<{
selectedCubes: string[];
}>;
export declare function AuthSelection(useAuthKey?: boolean): Promise<{
authMethod: string;
username?: string;
password?: string;
}>;
export declare function PasswordSelection(username: string): Promise<string>;
export declare function HostSelection(hosts: string[]): Promise<string>;
export declare function VariableAssignment<S extends z.AnyZodObject>(cube: Cube<S>, variables: Variables): Promise<void>;
-174
View File
@@ -1,174 +0,0 @@
/**
* Interactive prompts for nopy CLI
* @module nopy.prompts
*/
// @ts-ignore - no types available
import Enquirer from 'enquirer';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
// @ts-ignore - no types available
import CheckboxPlus from 'inquirer-checkbox-plus-prompt';
import { z } from 'zod';
// Register the checkbox-plus prompt type for filterable multi-select
inquirer.registerPrompt('checkbox-plus', CheckboxPlus);
/**
* Prompts the user to select cubes to execute with filtering support
*/
export async function CubeSelection(cubes) {
const cubeChoices = Object.values(cubes)
.sort((a, b) => a.id.localeCompare(b.id))
.map((cube) => ({
name: `${cube.id} - ${cube.name}`,
value: cube.id,
short: cube.id,
}));
// Clear terminal and move cursor to top
process.stdout.write('\x1B[2J\x1B[0f');
const terminalHeight = process.stdout.rows || 24;
const pageSize = Math.max(10, terminalHeight - 5);
console.log('\n Cube Selection\n');
console.log(' Type to filter • Space to select • Enter to confirm\n');
const answers = await inquirer.prompt([
{
type: 'checkbox-plus',
name: 'selectedCubes',
message: 'Select cubes:',
pageSize,
highlight: true,
searchable: true,
source: (_answersSoFar, input) => {
const searchTerm = input || '';
if (!searchTerm)
return Promise.resolve(cubeChoices);
const results = fuzzy.filter(searchTerm, cubeChoices, {
extract: (choice) => choice.name,
});
return Promise.resolve(results.map((r) => r.original));
},
},
]);
return { selectedCubes: answers.selectedCubes };
}
export async function AuthSelection(useAuthKey) {
if (useAuthKey)
return { authMethod: 'ssh-key' };
const answers = await inquirer.prompt([
{
type: 'list',
name: 'authMethod',
message: 'Select authentication method:',
choices: ['ssh-key', 'password'],
},
{
type: 'input',
name: 'username',
message: 'Enter username:',
when: (answers) => answers.authMethod !== 'ssh-key',
},
{
type: 'password',
name: 'password',
message: 'Enter password:',
when: (answers) => answers.authMethod !== 'ssh-key',
},
]);
return answers;
}
export async function PasswordSelection(username) {
const { password } = await inquirer.prompt([
{
type: 'password',
name: 'password',
message: `Enter password for ${username}:`,
},
]);
return password;
}
export async function HostSelection(hosts) {
const selectedHost = await inquirer.prompt([
{
type: 'list',
name: 'host',
message: 'Select host from inventory',
choices: ['docker', 'vagrant', ...hosts, 'custom'],
},
{
type: 'input',
name: 'customHost',
message: 'Specify custom host address:',
when: (answers) => answers.host === 'custom',
},
{
type: 'input',
name: 'vagrantVM',
message: 'Specify vagrant machine:',
default: 'default',
when: (answers) => answers.host === 'vagrant',
},
{
type: 'input',
name: 'dockerContainer',
message: 'Specify docker container name:',
when: (answers) => answers.host === 'runtime:docker',
},
]);
if (selectedHost.host === 'vagrant')
return `@vagrant/${selectedHost.vagrantVM}`;
if (selectedHost.host === 'runtime:docker')
return `@docker/${selectedHost.dockerContainer}`;
return selectedHost.customHost ?? selectedHost.host;
}
function coerceValue(value, zodType) {
if (typeof value !== 'string')
return value;
if (zodType instanceof z.ZodDefault)
return coerceValue(value, zodType._def.innerType);
if (zodType instanceof z.ZodOptional)
return coerceValue(value, zodType._def.innerType);
if (zodType instanceof z.ZodNullable) {
if (value === 'null' || value === '')
return null;
return coerceValue(value, zodType._def.innerType);
}
if (zodType instanceof z.ZodBoolean)
return value === 'true' || value === 'yes' || value === '1';
if (zodType instanceof z.ZodNumber) {
const num = Number(value);
return Number.isNaN(num) ? value : num;
}
return value;
}
export async function VariableAssignment(cube, variables) {
const schema = cube.manifest.schema.shape;
const defaults = cube.getDefaults();
const variablesToConfigure = {};
for (const [key, defaultValue] of Object.entries(defaults)) {
if (variables.get(cube.id, 'params')[key] === undefined) {
variablesToConfigure[key] = defaultValue;
}
}
if (Object.keys(variablesToConfigure).length === 0)
return;
const choices = Object.entries(variablesToConfigure).map(([key, value]) => {
const zodType = schema[key];
const description = zodType?.description || key;
return { name: key, message: description, initial: String(value ?? '') };
});
const form = new Enquirer.Form({
name: 'variables',
message: `[${cube.id}] ${cube.name}\n (↑↓ navigate, Enter to submit)`,
choices,
});
try {
const result = await form.run();
const coercedResult = {};
for (const [key, value] of Object.entries(result)) {
const zodType = schema[key];
coercedResult[key] = zodType ? coerceValue(value, zodType) : value;
}
variables.assign(cube.id, 'prompts', coercedResult);
}
catch {
// User cancelled
}
}
-112
View File
@@ -1,112 +0,0 @@
/**
* Session management for saving and replaying deployments
* @module nopy.session
*/
import type { TVariables } from './nopy.common.js';
/**
* Primitive value types that can be stored in session variables
*/
export type SessionValue = string | number | boolean | null | undefined;
/**
* Record of session variables
*/
export type SessionVariables = Record<string, unknown>;
/**
* Configuration for a single cube within a session
*/
export interface CubeSession {
/** Cube identifier */
key: string;
/** Cube-specific variables */
variables: TVariables;
}
/**
* Authentication configuration for a session
*/
export interface AuthSession {
/** Authentication method */
method: 'ssh-key' | 'password' | 'ssh';
/** Username for authentication (password auth only) */
username?: string;
}
/**
* Complete session configuration
*/
export interface NopySession {
/** Optional session name */
name?: string;
/** Array of cube configurations */
cubes: CubeSession[];
/** Target hosts */
hosts?: string[];
/** Authentication configuration */
auth: AuthSession;
/** Global environment variables */
env?: TVariables;
}
/**
* Saves a session to a JSON file
*
* @param session - The session to save
* @param filePath - Path to save the session file
*
* @example
* ```typescript
* saveSession(session, './my-deployment.nopysession.json');
* ```
*/
export declare function saveSession(session: NopySession, filePath: string): void;
/**
* Loads a session from a JSON or MJS file
*
* @param filePath - Path to the session file (.json or .mjs)
* @returns The loaded session
* @throws Error if file not found or invalid format
*
* @example
* ```typescript
* const session = await loadSession('./deployment.nopysession.json');
* ```
*/
export declare function loadSession(filePath: string): Promise<NopySession>;
/**
* Lists all session files in a directory
*
* @param dirPath - Directory to search for session files
* @returns Array of session file paths
*/
export declare function listSessions(dirPath?: string): string[];
/**
* Creates a session object from runtime data
*
* @param params - Session parameters
* @returns A NopySession object
*/
export declare function createSession(params: {
name?: string;
cubes: CubeSession[];
hosts: string[];
auth: AuthSession;
env?: TVariables;
}): NopySession;
/**
* Filters out internal variables from cube variables
*
* Internal variables are those used by the prompts system
* and should not be saved in session files.
*
* @param variables - Variables object
* @returns Filtered variables without internal keys
*/
export declare function filterInternalVariables(variables: Record<string, unknown>): Record<string, unknown>;
/**
* Separates environment variables from cube-specific variables
*
* @param allVariables - All variables including env and cube-specific
* @param envVariables - Known environment variables from config
* @returns Object with separate env and cube variables
*/
export declare function separateEnvAndCubeVariables(allVariables: Record<string, unknown>, envVariables: Record<string, unknown>): {
env: Record<string, unknown>;
cubeVars: Record<string, unknown>;
};
-166
View File
@@ -1,166 +0,0 @@
/**
* Session management for saving and replaying deployments
* @module nopy.session
*/
import fs from 'node:fs';
import path from 'node:path';
/**
* Saves a session to a JSON file
*
* @param session - The session to save
* @param filePath - Path to save the session file
*
* @example
* ```typescript
* saveSession(session, './my-deployment.nopysession.json');
* ```
*/
export function saveSession(session, filePath) {
const sessionToSave = {
...session,
};
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(sessionToSave, null, 2), 'utf-8');
}
/**
* Loads a session from an MJS file
*
* @param filePath - Path to the MJS session file
* @returns The loaded session
*/
async function loadSessionFromMJS(filePath) {
const absolutePath = path.resolve(filePath);
const fileUrl = `file://${absolutePath}`;
try {
const module = (await import(fileUrl));
const session = module.default;
if (!session) {
throw new Error('MJS file must export a default object');
}
return session;
}
catch (error) {
throw new Error(`Failed to load MJS session: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Loads a session from a JSON file
*
* @param filePath - Path to the JSON session file
* @returns The loaded session
*/
function loadSessionFromJSON(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content);
}
/**
* Loads a session from a JSON or MJS file
*
* @param filePath - Path to the session file (.json or .mjs)
* @returns The loaded session
* @throws Error if file not found or invalid format
*
* @example
* ```typescript
* const session = await loadSession('./deployment.nopysession.json');
* ```
*/
export async function loadSession(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`Session file not found: ${filePath}`);
}
const ext = path.extname(filePath);
let session;
if (ext === '.mjs') {
session = await loadSessionFromMJS(filePath);
}
else if (ext === '.json') {
session = loadSessionFromJSON(filePath);
}
else {
throw new Error(`Unsupported session file format: ${ext}. Use .json or .mjs`);
}
// Validate required fields
if (!session.cubes || !Array.isArray(session.cubes)) {
throw new Error('Invalid session format: missing or invalid "cubes" field');
}
if (session.hosts && !Array.isArray(session.hosts)) {
throw new Error('Invalid session format: invalid "hosts" field');
}
if (!session.auth) {
throw new Error('Invalid session format: missing "auth" field');
}
return session;
}
/**
* Lists all session files in a directory
*
* @param dirPath - Directory to search for session files
* @returns Array of session file paths
*/
export function listSessions(dirPath = process.cwd()) {
if (!fs.existsSync(dirPath)) {
return [];
}
const files = fs.readdirSync(dirPath);
return files
.filter((file) => file.endsWith('.session.json') || file.endsWith('.session.mjs'))
.map((file) => path.join(dirPath, file));
}
/**
* Creates a session object from runtime data
*
* @param params - Session parameters
* @returns A NopySession object
*/
export function createSession(params) {
return {
name: params.name,
cubes: params.cubes,
hosts: params.hosts,
auth: params.auth,
env: params.env,
};
}
/**
* Filters out internal variables from cube variables
*
* Internal variables are those used by the prompts system
* and should not be saved in session files.
*
* @param variables - Variables object
* @returns Filtered variables without internal keys
*/
export function filterInternalVariables(variables) {
const internalKeys = ['customize'];
const filtered = {};
for (const [key, value] of Object.entries(variables)) {
if (!internalKeys.includes(key)) {
filtered[key] = value;
}
}
return filtered;
}
/**
* Separates environment variables from cube-specific variables
*
* @param allVariables - All variables including env and cube-specific
* @param envVariables - Known environment variables from config
* @returns Object with separate env and cube variables
*/
export function separateEnvAndCubeVariables(allVariables, envVariables) {
const env = {};
const cubeVars = {};
for (const [key, value] of Object.entries(allVariables)) {
if (key in envVariables) {
env[key] = value;
}
else {
cubeVars[key] = value;
}
}
return { env, cubeVars };
}
-49
View File
@@ -1,49 +0,0 @@
/**
* Workflow logic for interactive and replay modes
* @module nopy.workflow
*/
import type { Cube } from './cubes/index.js';
import type { NopyConfig } from './nopy.config.js';
import { type NopySession } from './nopy.session.js';
/**
* Options for workflow execution
*/
export interface WorkflowOptions {
/** Use defaults without prompting */
useDefaults?: boolean;
/** Force SSH key authentication */
useAuthKey?: boolean;
}
/**
* Result of running a workflow
*/
export interface WorkflowResult {
/** The session configuration */
session: NopySession;
/** Target cubes selected for execution */
selectedCubes: string[];
/** Authentication method used */
authMethod: string;
/** Username if applicable */
username?: string;
/** Password if applicable */
password?: string;
/** Whether this is a session replay */
isReplay: boolean;
}
/**
* Runs the interactive workflow for cube selection and configuration
*/
export declare function runInteractiveWorkflow(cubes: Record<string, Cube>, config: NopyConfig, options?: WorkflowOptions): Promise<WorkflowResult>;
/**
* Runs the replay workflow from a saved session file
*/
export declare function runReplayWorkflow(sessionPath: string, cubes: Record<string, Cube>, config: NopyConfig): Promise<WorkflowResult>;
/**
* Runs replay workflow from a session object (from history)
*/
export declare function runSessionReplayWorkflow(session: NopySession, cubes: Record<string, Cube>, config: NopyConfig): Promise<WorkflowResult>;
/**
* Determines the appropriate workflow based on options
*/
export declare function runWorkflow(sessionPath: string | undefined, cubes: Record<string, Cube>, config: NopyConfig, options?: WorkflowOptions, replaySession?: NopySession): Promise<WorkflowResult>;
-149
View File
@@ -1,149 +0,0 @@
/**
* Workflow logic for interactive and replay modes
* @module nopy.workflow
*/
import { getLogger } from '@logtape/logtape';
import { AuthSelection, CubeSelection, HostSelection, PasswordSelection } from './nopy.prompts.js';
import { createSession, loadSession } from './nopy.session.js';
const log = getLogger(['nopy', 'workflow']);
/**
* Runs the interactive workflow for cube selection and configuration
*/
export async function runInteractiveWorkflow(cubes, config, options = {}) {
const { useAuthKey } = options;
// Step 1: Select cubes
const { selectedCubes } = await CubeSelection(cubes);
log.info('Selected cubes', { selectedCubes });
if (selectedCubes.length === 0) {
log.warn('No cubes selected');
}
// Step 2: Select host
const host = await HostSelection(config.hosts);
// Step 3: Select authentication
const isLocalHost = host.includes('@vagrant') || host.includes('@docker');
const authResult = isLocalHost
? { authMethod: 'ssh', username: undefined, password: undefined }
: await AuthSelection(useAuthKey);
// Create session
const session = createSession({
cubes: [], // Will be populated during build
hosts: [host],
auth: {
method: authResult.authMethod,
username: authResult.username,
},
env: config.env,
});
return {
session,
selectedCubes,
authMethod: authResult.authMethod,
username: authResult.username,
password: authResult.password,
isReplay: false,
};
}
/**
* Runs the replay workflow from a saved session file
*/
export async function runReplayWorkflow(sessionPath, cubes, config) {
log.info('Loading session from', { path: sessionPath });
const session = await loadSession(sessionPath);
log.info('Session loaded', { name: session.name, cubeCount: session.cubes.length });
// Validate cubes exist
for (const cubeSession of session.cubes) {
if (!cubes[cubeSession.key]) {
log.warn(`Cube from session not found: ${cubeSession.key}`);
}
}
// Handle missing hosts
if (!session.hosts || session.hosts.length === 0) {
log.info('No hosts in session, prompting for selection');
const host = await HostSelection(config.hosts);
session.hosts = [host];
}
// Extract auth details
let authMethod = session.auth.method;
let username = session.auth.username;
let password;
// Prompt for password if needed (passwords are never stored)
if (authMethod === 'password') {
if (username) {
password = await PasswordSelection(username);
}
else {
log.info('Password auth requires username, prompting');
const authResult = await AuthSelection(false);
authMethod = authResult.authMethod;
username = authResult.username;
password = authResult.password;
}
}
// Target cubes are those in the session
const selectedCubes = session.cubes.map((c) => c.key);
return {
session,
selectedCubes,
authMethod,
username,
password,
isReplay: true,
};
}
/**
* Runs replay workflow from a session object (from history)
*/
export async function runSessionReplayWorkflow(session, cubes, config) {
log.info('Replaying session from history', { cubeCount: session.cubes.length });
// Validate cubes exist
for (const cubeSession of session.cubes) {
if (!cubes[cubeSession.key]) {
log.warn(`Cube from session not found: ${cubeSession.key}`);
}
}
// Handle missing hosts
if (!session.hosts || session.hosts.length === 0) {
log.info('No hosts in session, prompting for selection');
const host = await HostSelection(config.hosts);
session.hosts = [host];
}
// Extract auth details
let authMethod = session.auth.method;
let username = session.auth.username;
let password;
// Prompt for password if needed (passwords are never stored)
if (authMethod === 'password') {
if (username) {
password = await PasswordSelection(username);
}
else {
log.info('Password auth requires username, prompting');
const authResult = await AuthSelection(false);
authMethod = authResult.authMethod;
username = authResult.username;
password = authResult.password;
}
}
// Target cubes are those in the session
const selectedCubes = session.cubes.map((c) => c.key);
return {
session,
selectedCubes,
authMethod,
username,
password,
isReplay: true,
};
}
/**
* Determines the appropriate workflow based on options
*/
export async function runWorkflow(sessionPath, cubes, config, options = {}, replaySession) {
if (replaySession) {
return runSessionReplayWorkflow(replaySession, cubes, config);
}
if (sessionPath) {
return runReplayWorkflow(sessionPath, cubes, config);
}
return runInteractiveWorkflow(cubes, config, options);
}
+55 -26
View File
@@ -1,44 +1,73 @@
{
"name": "@bitstack/nopy",
"description": "A system to simplify pyinfra script management and execution.",
"type": "module",
"version": "1.0.0",
"private": true,
"description": "A system to simplify pyinfra script management and execution.",
"keywords": [
"pyinfra",
"deployment",
"cli",
"infrastructure"
],
"license": "MIT",
"author": "bitsquare",
"bin": "./dist/nopy.cli.js",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://gitea.bitsquare.dev/BitSquare/ansiblings.git",
"directory": "packages/nopy"
},
"homepage": "https://gitea.bitsquare.dev/BitSquare/ansiblings/src/branch/main/packages/nopy",
"bugs": {
"url": "https://gitea.bitsquare.dev/BitSquare/ansiblings/issues"
},
"engines": {
"node": ">=22"
},
"bin": {
"nopy": "./dist/nopy.cli.js"
},
"exports": {
".": "./dist/index.js"
".": {
"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",
"build": "tsgo && chmod +x dist/nopy.cli.js && npm link",
"build:legacy": "tsc && chmod +x dist/nopy.cli.js && npm link",
"prepublishOnly": "npm run build",
"nopy": "node --loader ts-node/esm src/nopy.cli.ts",
"debug": "node --inspect-brk --loader ts-node/esm src/nopy.cli.ts",
"clean": "rm -rf dist .tsbuildinfo",
"build": "tsc",
"prepack": "pnpm run build",
"link:local": "pnpm run build && npm link",
"nopy": "tsx src/nopy.cli.ts",
"debug": "tsx --inspect-brk src/nopy.cli.ts",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:integration": "vitest run --pool=forks",
"test:watch": "vitest"
},
"files": ["dist/"],
"dependencies": {
"@logtape/logtape": "^0.8.0",
"commander": "^13.1.0",
"@logtape/logtape": "^2.2.4",
"commander": "^15.0.0",
"enquirer": "^2.4.1",
"execa": "9.5.2",
"execa": "^10.0.0",
"fuzzy": "^0.1.3",
"inquirer": "8.2.4",
"inquirer-checkbox-plus-prompt": "^1.0.1",
"ts-node": ">=10.9.1",
"typescript": ">=5.6.3",
"yaml": "^2.8.2",
"zod": "^3.24.1",
"zx": "^8.3.0"
"inquirer": "^14.0.2",
"zod": "^4.4.3",
"zx": "^8.8.5"
},
"devDependencies": {
"@types/inquirer": "^8.2.10",
"@types/node": "^20.0.0",
"@types/uniqid": "^5.3.4",
"vitest": "^1.6.0"
"@types/node": "^26.1.1",
"@vitest/coverage-v8": "^4.1.10",
"tsx": "^4.23.1",
"typescript": "^7.0.2",
"vitest": "^4.1.10"
}
}
+10 -6
View File
@@ -8,8 +8,8 @@ import type { Variables } from '../nopy.common.js';
import type { NopyConfig } from '../nopy.config.js';
import type { DeployCall } from '../nopy.executor.js';
import { VariableAssignment } from '../nopy.prompts.js';
import { type CubeSession, type NopySession } from '../nopy.session.js';
import type { Cube, CubeVariables, DependencySpec, HookContext } from './types.js';
import type { CubeSession, NopySession } from '../nopy.session.js';
import type { Cube, CubeVariables, HookContext } from './types.js';
const log = getLogger(['nopy', 'resolution']);
@@ -40,7 +40,11 @@ export class BuildContext {
/**
* Resolves a cube, its dependencies, and hooks recursively
*/
public async resolveCube(cubeId: string, host: string, overrides: CubeVariables = {}): Promise<void> {
public async resolveCube(
cubeId: string,
host: string,
overrides: CubeVariables = {}
): Promise<void> {
const cube = this.allCubes[cubeId];
if (!cube) {
throw new Error(`Cube not found: ${cubeId}`);
@@ -56,7 +60,7 @@ export class BuildContext {
// 2. Variable collection
if (this.options.isSessionReplay) {
const sessionCube = this.session.cubes.find(c => c.key === cubeId);
const sessionCube = this.session.cubes.find((c) => c.key === cubeId);
if (sessionCube) {
this.variables.assign(cubeId, 'defaults', sessionCube.variables);
}
@@ -101,7 +105,7 @@ export class BuildContext {
private buildDeployCall(cube: Cube, host: string): void {
const cubeId = cube.id;
const callKey = `${cubeId}:${host}`;
if (this.resolvedCubes.has(callKey)) return;
const parts: string[] = [];
@@ -128,7 +132,7 @@ export class BuildContext {
dependencies: [],
});
if (!this.cubeSessions.some(s => s.key === cubeId)) {
if (!this.cubeSessions.some((s) => s.key === cubeId)) {
this.cubeSessions.push({
key: cubeId,
variables: this.variables.get(cubeId, 'prompts'),
+2 -2
View File
@@ -3,7 +3,7 @@
* @module cubes/factories
*/
import { Manifest } from './types.js';
import { type AnyObjectSchema, Manifest } from './types.js';
/**
* Creates a manifest configuration for a cube
@@ -11,7 +11,7 @@ import { Manifest } from './types.js';
* @param opts - Manifest options including name, schema, dependencies, and hooks
* @returns Manifest configuration object
*/
export function createManifest<Schema extends import('zod').z.AnyZodObject>(
export function createManifest<Schema extends AnyObjectSchema>(
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
): Manifest<Schema> {
return Manifest(opts);
+21 -26
View File
@@ -6,37 +6,32 @@
* @module cubes
*/
// Dependencies
export { BuildContext } from './dependencies.js';
// Factory functions
export {
createManifest,
manifest,
} from './factories.js';
// Loader
export {
findCubeDirectories,
getCube,
loadCubes,
} from './loader.js';
export type {
AnyObjectSchema,
CubeVariables,
DependencySpec,
Hook,
HookContext,
LoadResult,
} from './types.js';
// Types
export {
Cube,
Manifest,
} from './types.js';
export type {
Hook,
HookContext,
LoadResult,
CubeVariables,
DependencySpec,
} from './types.js';
// Factory functions
export {
createManifest,
manifest,
} from './factories.js';
// Loader
export {
loadCubes,
findCubeDirectories,
getCube,
} from './loader.js';
// Dependencies
export {
BuildContext,
} from './dependencies.js';
// Utilities
export { uniqid } from './utils.js';
+1 -1
View File
@@ -80,7 +80,7 @@ export async function loadCubes(): Promise<LoadResult> {
errors.push(`Invalid manifest format in ${manifestPath}: missing 'name'`);
} else {
const cubeId = extractCubeId(manifest) || path.basename(cubePath);
if (cubes[cubeId]) {
errors.push(`Duplicate cube id '${cubeId}'`);
return;
+12 -5
View File
@@ -5,6 +5,13 @@
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
*/
@@ -25,7 +32,7 @@ export interface HookContext {
/**
* Hook function type for before/after cube execution
*/
export type Hook<Schema extends z.AnyZodObject = z.AnyZodObject> = (
export type Hook<Schema extends AnyObjectSchema = AnyObjectSchema> = (
ctx: HookContext,
variables: z.infer<Schema>
) => void | Promise<void>;
@@ -33,7 +40,7 @@ export type Hook<Schema extends z.AnyZodObject = z.AnyZodObject> = (
/**
* User-defined specification for a cube
*/
export interface Manifest<Schema extends z.AnyZodObject = z.AnyZodObject> {
export interface Manifest<Schema extends AnyObjectSchema = AnyObjectSchema> {
/** Unique identifier for the cube (used for dependency references) */
id: string;
/** Human-readable name of the cube */
@@ -51,7 +58,7 @@ export interface Manifest<Schema extends z.AnyZodObject = z.AnyZodObject> {
/**
* Factory function and namespace for Manifest
*/
export function Manifest<Schema extends z.AnyZodObject>(
export function Manifest<Schema extends AnyObjectSchema>(
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
): Manifest<Schema> {
return {
@@ -68,7 +75,7 @@ export namespace Manifest {
/**
* Internal create helper
*/
export function create<Schema extends z.AnyZodObject>(
export function create<Schema extends AnyObjectSchema>(
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
): Manifest<Schema> {
return Manifest(opts);
@@ -78,7 +85,7 @@ export namespace Manifest {
/**
* A fully loaded cube with its filesystem location and runtime state
*/
export class Cube<Schema extends z.AnyZodObject = z.AnyZodObject> {
export class Cube<Schema extends AnyObjectSchema = AnyObjectSchema> {
constructor(
public readonly manifest: Manifest<Schema>,
public readonly dir: string,
+56 -64
View File
@@ -6,81 +6,73 @@
// Cubes module
export * from './cubes/index.js';
export type {
ExecutionConfig,
HistoryConfig,
LogConfig,
LogVerbosity,
NopyConfig,
NopyConfigFile,
ResolutionConfig,
ResolutionStrategy,
} from './nopy.config.js';
// Configuration
export { getConfigPaths, loadConfig, logConfigToFlags, saveConfig } from './nopy.config.js';
// Backwards compatibility - cubes namespace
export { cubes } from './nopy.cubes.js';
// Main entry point
export { nopy } from './nopy.main.js';
export type { NopyOptions, NopyResult } from './nopy.main.js';
export type {
DeployCall,
ExecutionOptions,
ExecutionResult,
} from './nopy.executor.js';
// Executor
export {
executeDeployCalls,
outputExecutionPlan,
summarizeResults,
} from './nopy.executor.js';
export type {
DeployCall,
ExecutionResult,
ExecutionOptions,
} from './nopy.executor.js';
export type { HistoryEntry, SessionHistory } from './nopy.history.js';
// History management
export {
addToHistory,
clearHistory,
DEFAULT_HISTORY_SIZE,
formatHistoryList,
getHistoryPath,
getLastSession,
getSessionById,
HISTORY_FILE,
listHistory,
loadHistory,
removeFromHistory,
saveHistory,
} from './nopy.history.js';
export type { NopyOptions, NopyResult } from './nopy.main.js';
// Main entry point
export { nopy } from './nopy.main.js';
// Prompts
export {
AuthSelection,
CubeSelection,
HostSelection,
PasswordSelection,
VariableAssignment,
} from './nopy.prompts.js';
export type { AuthSession, CubeSession, NopySession } from './nopy.session.js';
// Session management
export {
createSession,
filterInternalVariables,
listSessions,
loadSession,
saveSession,
separateEnvAndCubeVariables,
} from './nopy.session.js';
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
// Workflow
export {
runWorkflow,
runInteractiveWorkflow,
runReplayWorkflow,
runSessionReplayWorkflow,
runWorkflow,
} from './nopy.workflow.js';
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
// Prompts
export {
CubeSelection,
AuthSelection,
HostSelection,
VariableAssignment,
PasswordSelection,
} from './nopy.prompts.js';
// Session management
export {
loadSession,
saveSession,
createSession,
listSessions,
filterInternalVariables,
separateEnvAndCubeVariables,
} from './nopy.session.js';
export type { NopySession, CubeSession, AuthSession } from './nopy.session.js';
// History management
export {
loadHistory,
saveHistory,
addToHistory,
getLastSession,
getSessionById,
listHistory,
clearHistory,
removeFromHistory,
formatHistoryList,
getHistoryPath,
DEFAULT_HISTORY_SIZE,
HISTORY_FILE,
} from './nopy.history.js';
export type { HistoryEntry, SessionHistory } from './nopy.history.js';
// Configuration
export { loadConfig, saveConfig, logConfigToFlags, getConfigPaths } from './nopy.config.js';
export type {
NopyConfig,
NopyConfigFile,
LogConfig,
LogVerbosity,
HistoryConfig,
ExecutionConfig,
ResolutionStrategy,
ResolutionConfig,
} from './nopy.config.js';
+6 -4
View File
@@ -5,6 +5,7 @@
* @module nopy.cli
*/
import { createRequire } from 'node:module';
import { Command } from 'commander';
import { loadConfig } from './nopy.config.js';
import {
@@ -16,12 +17,13 @@ import {
} from './nopy.history.js';
import { nopy } from './nopy.main.js';
const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
const program = new Command();
const config = loadConfig();
program
.name('nopy')
.version('1.0.0')
.version(version)
.description('A CLI tool for pyinfra script management and execution.')
.addHelpText(
'after',
@@ -61,8 +63,8 @@ program
.option('-j, --json', 'Output results as JSON')
.option('--no-history', 'Do not save this session to history')
.action(async (options) => {
// Apply config defaults
const execConfig = config.execution ?? {};
// Loaded lazily so that --help/--version work outside a configured project.
const execConfig = loadConfig().execution ?? {};
const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false;
try {
+3 -3
View File
@@ -20,10 +20,10 @@ export const cubes = {
// Re-export types for direct access
export type {
Cube,
CubeVariables,
Hook,
HookContext,
Cube,
Manifest,
LoadResult,
CubeVariables,
Manifest,
} from './cubes/index.js';
+17 -8
View File
@@ -3,13 +3,13 @@
* @module nopy.main
*/
import { type LogRecord, configure, getAnsiColorFormatter, getLogger } from '@logtape/logtape';
import { loadCubes } from './cubes/index.js';
import { configure, getAnsiColorFormatter, getLogger, type LogRecord } from '@logtape/logtape';
import { BuildContext } from './cubes/dependencies.js';
import { loadCubes } from './cubes/index.js';
import { Variables } from './nopy.common.js';
import { getConfigPaths, loadConfig } from './nopy.config.js';
import { type ExecutionResult, executeDeployCalls, summarizeResults } from './nopy.executor.js';
import { DEFAULT_HISTORY_SIZE, addToHistory } from './nopy.history.js';
import { addToHistory, DEFAULT_HISTORY_SIZE } from './nopy.history.js';
import { type NopySession, saveSession } from './nopy.session.js';
import { runWorkflow } from './nopy.workflow.js';
@@ -34,12 +34,12 @@ function configureLogtape(): void {
loggers: [
{
category: ['logtape', 'meta'],
level: 'error',
lowestLevel: 'error',
sinks: ['console'],
},
{
category: 'nopy',
level: 'debug',
lowestLevel: 'debug',
sinks: ['console'],
},
],
@@ -52,7 +52,10 @@ configureLogtape();
/**
* Prints the active configuration summary
*/
function printActiveConfig(config: import('./nopy.config.js').NopyConfig, opts: { continueOnError: boolean }): void {
function printActiveConfig(
config: import('./nopy.config.js').NopyConfig,
opts: { continueOnError: boolean }
): void {
const configPaths = getConfigPaths();
const cwd = process.cwd();
@@ -143,12 +146,18 @@ export async function nopy(opts: NopyOptions = {}): Promise<NopyResult | undefin
if (errors.length > 0) {
log.error('Errors found during cube loading:');
errors.forEach((error) => log.error(error));
for (const error of errors) log.error(error);
if (jsonOutput) console.log(JSON.stringify({ success: false, errors }, null, 2));
return undefined;
}
const workflow = await runWorkflow(loadSessionPath, cubes, config, { useDefaults, useAuthKey }, replaySession);
const workflow = await runWorkflow(
loadSessionPath,
cubes,
config,
{ useDefaults, useAuthKey },
replaySession
);
// Step 3: Build deployment calls using BuildContext
const context = new BuildContext(
+36 -34
View File
@@ -3,23 +3,31 @@
* @module nopy.prompts
*/
// @ts-ignore - no types available
import Enquirer from 'enquirer';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
// @ts-ignore - no types available
import CheckboxPlus from 'inquirer-checkbox-plus-prompt';
import { z } from 'zod';
import type { Cube } from './cubes/index.js';
import type { AnyObjectSchema, Cube } from './cubes/index.js';
import type { Variables } from './nopy.common.js';
// Register the checkbox-plus prompt type for filterable multi-select
inquirer.registerPrompt('checkbox-plus', CheckboxPlus);
interface CubeChoice {
/** Submitted value — enquirer returns the `name` of each selected choice. */
name: string;
value: string;
short: string;
/** Label rendered in the list. */
message: string;
}
/**
* Fuzzy-filters the cube list against what the user has typed so far.
*
* Handed to enquirer as `suggest`, which calls it on every keystroke with the
* current input and the full choice list.
*/
function suggestCubes(input: string | undefined, choices: CubeChoice[]): CubeChoice[] {
if (!input) return choices;
return fuzzy
.filter(input, choices, { extract: (choice: CubeChoice) => choice.message })
.map((result) => result.original);
}
/**
@@ -31,9 +39,8 @@ export async function CubeSelection(
const cubeChoices: CubeChoice[] = Object.values(cubes)
.sort((a, b) => a.id.localeCompare(b.id))
.map((cube) => ({
name: `${cube.id} - ${cube.name}`,
value: cube.id,
short: cube.id,
name: cube.id,
message: `${cube.id} - ${cube.name}`,
}));
// Clear terminal and move cursor to top
@@ -45,26 +52,21 @@ export async function CubeSelection(
console.log('\n Cube Selection\n');
console.log(' Type to filter • Space to select • Enter to confirm\n');
const answers = await inquirer.prompt([
{
type: 'checkbox-plus',
name: 'selectedCubes',
message: 'Select cubes:',
pageSize,
highlight: true,
searchable: true,
source: (_answersSoFar: unknown, input: string | undefined) => {
const searchTerm = input || '';
if (!searchTerm) return Promise.resolve(cubeChoices);
const results = fuzzy.filter(searchTerm, cubeChoices, {
extract: (choice: CubeChoice) => choice.name,
});
return Promise.resolve(results.map((r) => r.original));
},
},
]);
const prompt = new (Enquirer as any).AutoComplete({
name: 'selectedCubes',
message: 'Select cubes:',
limit: pageSize,
multiple: true,
choices: cubeChoices,
suggest: suggestCubes,
});
return { selectedCubes: answers.selectedCubes };
try {
return { selectedCubes: await prompt.run() };
} catch {
// User cancelled
return { selectedCubes: [] };
}
}
export async function AuthSelection(useAuthKey?: boolean): Promise<{
@@ -140,7 +142,7 @@ export async function HostSelection(hosts: string[]): Promise<string> {
return selectedHost.customHost ?? selectedHost.host;
}
function coerceValue(value: unknown, zodType: z.ZodTypeAny): unknown {
function coerceValue(value: unknown, zodType: z.core.$ZodType): unknown {
if (typeof value !== 'string') return value;
if (zodType instanceof z.ZodDefault) return coerceValue(value, zodType._def.innerType);
if (zodType instanceof z.ZodOptional) return coerceValue(value, zodType._def.innerType);
@@ -162,14 +164,14 @@ interface FormChoice {
initial: string;
}
export async function VariableAssignment<S extends z.AnyZodObject>(
export async function VariableAssignment<S extends AnyObjectSchema>(
cube: Cube<S>,
variables: Variables
) {
const schema = cube.manifest.schema.shape;
const defaults = cube.getDefaults();
const variablesToConfigure: Record<string, unknown> = {};
for (const [key, defaultValue] of Object.entries(defaults)) {
if (variables.get(cube.id, 'params')[key] === undefined) {
variablesToConfigure[key] = defaultValue;
+1 -1
View File
@@ -7,7 +7,7 @@ import { getLogger } from '@logtape/logtape';
import type { Cube } from './cubes/index.js';
import type { NopyConfig } from './nopy.config.js';
import { AuthSelection, CubeSelection, HostSelection, PasswordSelection } from './nopy.prompts.js';
import { type AuthSession, type NopySession, createSession, loadSession } from './nopy.session.js';
import { type AuthSession, createSession, loadSession, type NopySession } from './nopy.session.js';
const log = getLogger(['nopy', 'workflow']);
+266
View File
@@ -0,0 +1,266 @@
/**
* Tests for nopy.config loading, merging and path resolution.
*
* findConfigFiles() walks from cwd up to the filesystem root and also consults
* $HOME, so every test runs inside a fresh mkdtemp directory with HOME pointed
* at an empty directory. Without that, a developer's own ~/.nopyrc.json would
* leak into the merge result and make these tests machine-dependent.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getConfigPaths, loadConfig, type NopyConfigFile, saveConfig } from '../src/nopy.config.js';
describe('config loading', () => {
let originalCwd: string;
let originalHome: string | undefined;
let rootDir: string;
let emptyHome: string;
const write = (dir: string, config: NopyConfigFile) => {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, '.nopyrc.json'), JSON.stringify(config, null, 2));
};
beforeEach(() => {
originalCwd = process.cwd();
originalHome = process.env.HOME;
rootDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-config-')));
emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-home-')));
process.env.HOME = emptyHome;
process.chdir(rootDir);
});
afterEach(() => {
process.chdir(originalCwd);
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
fs.rmSync(rootDir, { recursive: true, force: true });
fs.rmSync(emptyHome, { recursive: true, force: true });
});
describe('discovery', () => {
it('throws a helpful error when no config exists anywhere', () => {
expect(() => loadConfig()).toThrow(/No \.nopyrc\.json found/);
});
it('loads a config from the current directory', () => {
write(rootDir, { hosts: ['web-1'] });
expect(loadConfig().hosts).toEqual(['web-1']);
});
it('applies defaults for properties the file omits', () => {
write(rootDir, { hosts: ['web-1'] });
const config = loadConfig();
expect(config.cubeDirs).toEqual([]);
expect(config.env).toEqual({});
});
it('finds a config in a parent directory', () => {
write(rootDir, { hosts: ['parent-host'] });
const child = path.join(rootDir, 'a', 'b');
fs.mkdirSync(child, { recursive: true });
process.chdir(child);
expect(loadConfig().hosts).toEqual(['parent-host']);
});
it('picks up $HOME config at the lowest priority', () => {
write(emptyHome, { hosts: ['home-host'] });
write(rootDir, { hosts: ['project-host'] });
// Root-first ordering means the home value is merged in first.
expect(loadConfig().hosts).toEqual(['home-host', 'project-host']);
});
it('does not duplicate the home config when cwd is $HOME', () => {
write(emptyHome, { hosts: ['home-host'] });
process.chdir(emptyHome);
expect(getConfigPaths().filter((p) => p.startsWith(emptyHome))).toHaveLength(1);
expect(loadConfig().hosts).toEqual(['home-host']);
});
it('tolerates an unset HOME', () => {
process.env.HOME = '';
write(rootDir, { hosts: ['web-1'] });
expect(loadConfig().hosts).toEqual(['web-1']);
});
it('reports discovered config paths parent-first', () => {
write(rootDir, { hosts: ['parent'] });
const child = path.join(rootDir, 'child');
write(child, { hosts: ['child'] });
process.chdir(child);
const paths = getConfigPaths();
expect(paths).toEqual([path.join(rootDir, '.nopyrc.json'), path.join(child, '.nopyrc.json')]);
});
it('wraps malformed JSON with the offending path', () => {
fs.writeFileSync(path.join(rootDir, '.nopyrc.json'), '{ not valid json');
expect(() => loadConfig()).toThrow(/Failed to load config .*\.nopyrc\.json/);
});
});
describe('merge strategy', () => {
const nested = () => {
const child = path.join(rootDir, 'child');
fs.mkdirSync(child, { recursive: true });
return child;
};
it('concatenates arrays and de-duplicates primitives', () => {
const child = nested();
write(rootDir, { hosts: ['a', 'b'] });
write(child, { hosts: ['b', 'c'] });
process.chdir(child);
expect(loadConfig().hosts).toEqual(['a', 'b', 'c']);
});
it('replaces arrays entirely under the override strategy', () => {
const child = nested();
write(rootDir, { hosts: ['a', 'b'] });
write(child, { hosts: ['only-me'], resolution: { hosts: 'override' } });
process.chdir(child);
expect(loadConfig().hosts).toEqual(['only-me']);
});
it('deep merges nested objects', () => {
const child = nested();
write(rootDir, { env: { SHARED: 'parent', ONLY_PARENT: 'p' } });
write(child, { env: { SHARED: 'child', ONLY_CHILD: 'c' } });
process.chdir(child);
expect(loadConfig().env).toEqual({
SHARED: 'child',
ONLY_PARENT: 'p',
ONLY_CHILD: 'c',
});
});
it('lets a child primitive override a parent primitive', () => {
const child = nested();
write(rootDir, { log: { verbosity: 'info', debug: true } });
write(child, { log: { verbosity: 'trace' } });
process.chdir(child);
expect(loadConfig().log).toEqual({ verbosity: 'trace', debug: true });
});
it('adds properties the parent never defined', () => {
const child = nested();
write(rootDir, { hosts: ['a'] });
write(child, { execution: { continueOnError: true } });
process.chdir(child);
expect(loadConfig().execution).toEqual({ continueOnError: true });
});
it('keeps arrays of objects without de-duplicating them', () => {
const child = nested();
write(rootDir, { env: { list: [{ a: 1 }] } as never });
write(child, { env: { list: [{ a: 1 }] } as never });
process.chdir(child);
expect((loadConfig().env as Record<string, unknown>).list).toHaveLength(2);
});
it('never surfaces the resolution key in the merged config', () => {
write(rootDir, { hosts: ['a'], resolution: { hosts: 'override' } });
expect(loadConfig()).not.toHaveProperty('resolution');
});
});
describe('relative path resolution', () => {
it('resolves ./ cubeDirs against the config file location', () => {
write(rootDir, { cubeDirs: ['./cubes'] });
expect(loadConfig().cubeDirs).toEqual([path.join(rootDir, 'cubes')]);
});
it('resolves ../ cubeDirs against the config file location', () => {
const child = path.join(rootDir, 'child');
write(child, { cubeDirs: ['../shared-cubes'] });
process.chdir(child);
expect(loadConfig().cubeDirs).toEqual([path.join(rootDir, 'shared-cubes')]);
});
it('resolves bare paths containing a separator', () => {
write(rootDir, { cubeDirs: ['nested/cubes'] });
expect(loadConfig().cubeDirs).toEqual([path.join(rootDir, 'nested', 'cubes')]);
});
it('leaves absolute cubeDirs untouched', () => {
write(rootDir, { cubeDirs: ['/opt/cubes'] });
expect(loadConfig().cubeDirs).toEqual(['/opt/cubes']);
});
it('leaves ~ and URL-like values untouched', () => {
write(rootDir, { cubeDirs: ['~/cubes', 'https://example.com/cubes'] });
expect(loadConfig().cubeDirs).toEqual(['~/cubes', 'https://example.com/cubes']);
});
it('leaves a bare single-segment name untouched', () => {
write(rootDir, { cubeDirs: ['cubes'] });
expect(loadConfig().cubeDirs).toEqual(['cubes']);
});
it('does not resolve paths for non-path properties such as hosts', () => {
write(rootDir, { hosts: ['@docker/ubuntu', './not-a-path'] });
expect(loadConfig().hosts).toEqual(['@docker/ubuntu', './not-a-path']);
});
it('resolves each config file against its own directory', () => {
const child = path.join(rootDir, 'child');
write(rootDir, { cubeDirs: ['./cubes'] });
write(child, { cubeDirs: ['./cubes'] });
process.chdir(child);
expect(loadConfig().cubeDirs).toEqual([
path.join(rootDir, 'cubes'),
path.join(child, 'cubes'),
]);
});
});
describe('saveConfig', () => {
it('writes a new config file at the given path', () => {
const target = path.join(rootDir, 'custom.json');
saveConfig({ hosts: ['web-1'] }, target);
expect(JSON.parse(fs.readFileSync(target, 'utf-8'))).toEqual({ hosts: ['web-1'] });
});
it('defaults to .nopyrc.json in the cwd', () => {
saveConfig({ hosts: ['web-1'] });
const written = path.join(rootDir, '.nopyrc.json');
expect(fs.existsSync(written)).toBe(true);
expect(JSON.parse(fs.readFileSync(written, 'utf-8')).hosts).toEqual(['web-1']);
});
it('shallow merges over an existing file', () => {
write(rootDir, { hosts: ['old'], env: { KEEP: '1' } });
saveConfig({ hosts: ['new'] });
const result = JSON.parse(fs.readFileSync(path.join(rootDir, '.nopyrc.json'), 'utf-8'));
expect(result).toEqual({ hosts: ['new'], env: { KEEP: '1' } });
});
it('starts fresh when the existing file is unparseable', () => {
fs.writeFileSync(path.join(rootDir, '.nopyrc.json'), '{{{ broken');
saveConfig({ hosts: ['new'] });
const result = JSON.parse(fs.readFileSync(path.join(rootDir, '.nopyrc.json'), 'utf-8'));
expect(result).toEqual({ hosts: ['new'] });
});
});
});
+1 -1
View File
@@ -3,7 +3,7 @@
*/
import { describe, expect, it } from 'vitest';
import { type LogConfig, logConfigToFlags } from '../src/nopy.config.js';
import { logConfigToFlags } from '../src/nopy.config.js';
describe('logConfigToFlags', () => {
it('returns empty array for silent verbosity', () => {
@@ -0,0 +1,184 @@
/**
* Edge cases for BuildContext: unknown cubes, session replay and auth flags.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import { BuildContext } from '../src/cubes/dependencies.js';
import { Cube, Manifest } from '../src/cubes/types.js';
import { Variables } from '../src/nopy.common.js';
import type { NopyConfig } from '../src/nopy.config.js';
import type { NopySession } from '../src/nopy.session.js';
vi.mock('../src/nopy.prompts.js', async () => {
const actual = await vi.importActual('../src/nopy.prompts.js');
return { ...actual, VariableAssignment: vi.fn() };
});
import { VariableAssignment } from '../src/nopy.prompts.js';
const testCube = (id: string, schema = z.object({})) =>
new Cube(Manifest.create({ id, name: `Test ${id}`, schema }), `/test/${id}`, 'deploy.py');
const config = { env: {} } as NopyConfig;
const session = (cubes: NopySession['cubes'] = []) => ({ cubes }) as NopySession;
beforeEach(() => {
vi.clearAllMocks();
});
describe('BuildContext error handling', () => {
it('throws when the requested cube does not exist', async () => {
const context = new BuildContext({}, new Variables(), session(), config, { method: 'ssh' });
await expect(context.resolveCube('ghost', 'host1')).rejects.toThrow('Cube not found: ghost');
});
it('throws when a dependency does not exist', async () => {
const cubeB = new Cube(
Manifest.create({
id: 'cube-b',
name: 'B',
schema: z.object({}),
dependencies: () => ['ghost'],
}),
'/test/cube-b',
'deploy.py'
);
const context = new BuildContext({ 'cube-b': cubeB }, new Variables(), session(), config, {
method: 'ssh',
});
await expect(context.resolveCube('cube-b', 'host1')).rejects.toThrow('Cube not found: ghost');
});
});
describe('BuildContext session replay', () => {
it('takes variables from the session instead of prompting', async () => {
const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') }));
const vars = new Variables();
const context = new BuildContext(
{ 'cube-a': cube },
vars,
session([{ key: 'cube-a', variables: { PORT: '9090' } }]),
config,
{ method: 'ssh' },
{ isSessionReplay: true }
);
await context.resolveCube('cube-a', 'host1');
expect(VariableAssignment).not.toHaveBeenCalled();
expect(context.deployCalls[0].env.PORT).toBe('9090');
});
it('falls back to schema defaults when the session has no entry for the cube', async () => {
const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') }));
const context = new BuildContext(
{ 'cube-a': cube },
new Variables(),
session([{ key: 'other', variables: { PORT: '9090' } }]),
config,
{ method: 'ssh' },
{ isSessionReplay: true }
);
await context.resolveCube('cube-a', 'host1');
expect(VariableAssignment).not.toHaveBeenCalled();
expect(context.deployCalls[0].env.PORT).toBe('3000');
});
it('prompts when not replaying', async () => {
const context = new BuildContext(
{ 'cube-a': testCube('cube-a') },
new Variables(),
session(),
config,
{ method: 'ssh' }
);
await context.resolveCube('cube-a', 'host1');
expect(VariableAssignment).toHaveBeenCalled();
});
});
describe('BuildContext command construction', () => {
const build = (auth: { method: string; username?: string; password?: string }) => {
const context = new BuildContext(
{ 'cube-a': testCube('cube-a') },
new Variables(),
session(),
config,
auth
);
return context.resolveCube('cube-a', 'host1').then(() => context);
};
it('adds --user/--password for complete password auth', async () => {
const context = await build({ method: 'password', username: 'deploy', password: 'pw' });
expect(context.deployCalls[0].command.join(' ')).toContain('--user deploy --password pw');
});
it('omits credentials for ssh auth', async () => {
const context = await build({ method: 'ssh' });
expect(context.deployCalls[0].command.join(' ')).not.toContain('--user');
});
it('omits credentials when the password is missing', async () => {
const context = await build({ method: 'password', username: 'deploy' });
expect(context.deployCalls[0].command.join(' ')).not.toContain('--user');
});
it('omits credentials when the username is missing', async () => {
const context = await build({ method: 'password', password: 'pw' });
expect(context.deployCalls[0].command.join(' ')).not.toContain('--user');
});
it('passes cube variables as --data flags and points at the deploy script', async () => {
const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') }));
const context = new BuildContext({ 'cube-a': cube }, new Variables(), session(), config, {
method: 'ssh',
});
await context.resolveCube('cube-a', 'host1');
const command = context.deployCalls[0].command.join(' ');
expect(command).toContain('--data "PORT=3000"');
expect(command).toContain('--chdir /test/cube-a');
expect(command).toContain('/test/cube-a/deploy.py');
expect(context.deployCalls[0].cwd).toBe('/test/cube-a');
});
it('builds a separate call per host but records the cube session once', async () => {
const context = new BuildContext(
{ 'cube-a': testCube('cube-a') },
new Variables(),
session(),
config,
{ method: 'ssh' }
);
await context.resolveCube('cube-a', 'host1');
await context.resolveCube('cube-a', 'host2');
expect(context.deployCalls.map((c) => c.host)).toEqual(['host1', 'host2']);
expect(context.cubeSessions).toHaveLength(1);
});
it('applies caller overrides as params', async () => {
const cube = testCube('cube-a', z.object({ PORT: z.string().default('3000') }));
const context = new BuildContext({ 'cube-a': cube }, new Variables(), session(), config, {
method: 'ssh',
});
await context.resolveCube('cube-a', 'host1', { PORT: '8080' });
expect(context.deployCalls[0].env.PORT).toBe('8080');
});
});
+27 -15
View File
@@ -35,7 +35,9 @@ describe('BuildContext.resolveCube', () => {
const cubeA = createTestCube('cube-a');
const cubes = { 'cube-a': cubeA };
const vars = new Variables();
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' });
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, {
method: 'ssh',
});
await context.resolveCube('cube-a', 'host1');
@@ -49,7 +51,9 @@ describe('BuildContext.resolveCube', () => {
const cubeB = createTestCube('cube-b', () => ['cube-a']);
const cubes = { 'cube-a': cubeA, 'cube-b': cubeB };
const vars = new Variables();
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' });
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, {
method: 'ssh',
});
await context.resolveCube('cube-b', 'host1');
@@ -62,35 +66,41 @@ describe('BuildContext.resolveCube', () => {
it('resolves dynamic dependencies based on variables', async () => {
const cubeA = createTestCube('cube-a');
const cubeB = createTestCube('cube-b');
const cubeC = createTestCube('cube-c', (vars) => vars.USE_A ? ['cube-a'] : ['cube-b']);
const cubeC = createTestCube('cube-c', (vars) => (vars.USE_A ? ['cube-a'] : ['cube-b']));
cubeC.manifest.schema = z.object({ USE_A: z.boolean().default(true) });
const cubes = { 'cube-a': cubeA, 'cube-b': cubeB, 'cube-c': cubeC };
// Test with USE_A = true
const vars1 = new Variables();
const context1 = new BuildContext(cubes, vars1, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' });
const context1 = new BuildContext(cubes, vars1, { cubes: [] } as any, { env: {} } as any, {
method: 'ssh',
});
await context1.resolveCube('cube-c', 'host1');
expect(context1.deployCalls.map(c => c.cube)).toEqual(['cube-a', 'cube-c']);
expect(context1.deployCalls.map((c) => c.cube)).toEqual(['cube-a', 'cube-c']);
// Test with USE_A = false
const vars2 = new Variables();
vars2.assign('cube-c', 'params', { USE_A: false });
const context2 = new BuildContext(cubes, vars2, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' });
const context2 = new BuildContext(cubes, vars2, { cubes: [] } as any, { env: {} } as any, {
method: 'ssh',
});
await context2.resolveCube('cube-c', 'host1');
expect(context2.deployCalls.map(c => c.cube)).toEqual(['cube-b', 'cube-c']);
expect(context2.deployCalls.map((c) => c.cube)).toEqual(['cube-b', 'cube-c']);
});
it('passes variables to dependencies', async () => {
const cubeA = createTestCube('cube-a');
cubeA.manifest.schema = z.object({ VAR: z.string() });
const cubeB = createTestCube('cube-b', () => [['cube-a', { VAR: 'from-b' }]]);
const cubes = { 'cube-a': cubeA, 'cube-b': cubeB };
const vars = new Variables();
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' });
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, {
method: 'ssh',
});
await context.resolveCube('cube-b', 'host1');
@@ -102,14 +112,16 @@ describe('BuildContext.resolveCube', () => {
const cubeA = createTestCube('cube-a');
const cubeB = createTestCube('cube-b', () => ['cube-a']);
const cubeC = createTestCube('cube-c', () => ['cube-a', 'cube-b']);
const cubes = { 'cube-a': cubeA, 'cube-b': cubeB, 'cube-c': cubeC };
const vars = new Variables();
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' });
const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, {
method: 'ssh',
});
await context.resolveCube('cube-c', 'host1');
// Execution order: cube-a, cube-b, cube-c
expect(context.deployCalls.map(c => c.cube)).toEqual(['cube-a', 'cube-b', 'cube-c']);
expect(context.deployCalls.map((c) => c.cube)).toEqual(['cube-a', 'cube-b', 'cube-c']);
});
});
@@ -0,0 +1,184 @@
/**
* Error and discovery edge cases for cubes/loader.
*
* Runs against a real temp directory because loadCubes() dynamically imports
* manifest files — there is no seam worth faking here.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { findCubeDirectories, getCube, loadCubes } from '../src/cubes/loader.js';
describe('loader edge cases', () => {
let originalCwd: string;
let originalHome: string | undefined;
let tmpDir: string;
let emptyHome: string;
const cube = (dir: string, manifest: string, deployName = 'deploy.py') => {
fs.mkdirSync(path.join(tmpDir, dir), { recursive: true });
fs.writeFileSync(path.join(tmpDir, dir, 'manifest.mjs'), manifest);
fs.writeFileSync(path.join(tmpDir, dir, deployName), '# deploy');
};
beforeEach(() => {
originalCwd = process.cwd();
originalHome = process.env.HOME;
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-loader-')));
emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-loader-home-')));
process.env.HOME = emptyHome;
process.chdir(tmpDir);
fs.writeFileSync(path.join(tmpDir, '.nopyrc.json'), JSON.stringify({ cubeDirs: ['./'] }));
});
afterEach(() => {
process.chdir(originalCwd);
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.rmSync(emptyHome, { recursive: true, force: true });
});
describe('findCubeDirectories', () => {
it('includes directories from cubeDirs', () => {
expect(findCubeDirectories()).toContain(tmpDir);
});
it('includes directories marked with a .npcubes file', () => {
fs.writeFileSync(path.join(tmpDir, '.nopyrc.json'), JSON.stringify({ cubeDirs: [] }));
fs.writeFileSync(path.join(tmpDir, '.npcubes'), '');
const nested = path.join(tmpDir, 'a', 'b');
fs.mkdirSync(nested, { recursive: true });
process.chdir(nested);
expect(findCubeDirectories()).toContain(tmpDir);
});
it('does not treat a .npcubes directory as a marker', () => {
fs.writeFileSync(path.join(tmpDir, '.nopyrc.json'), JSON.stringify({ cubeDirs: [] }));
fs.mkdirSync(path.join(tmpDir, '.npcubes'));
expect(findCubeDirectories()).not.toContain(tmpDir);
});
it('de-duplicates a directory listed twice', () => {
fs.writeFileSync(
path.join(tmpDir, '.nopyrc.json'),
JSON.stringify({ cubeDirs: ['./', tmpDir] })
);
fs.writeFileSync(path.join(tmpDir, '.npcubes'), '');
expect(findCubeDirectories().filter((d) => d === tmpDir)).toHaveLength(1);
});
});
describe('loadCubes', () => {
it('derives the id from a [bracket] name prefix', async () => {
cube('bracketed', 'export default { name: "[apt:base] Apt Base" }');
const { cubes, errors } = await loadCubes();
expect(errors).toEqual([]);
expect(cubes['apt:base'].name).toBe('[apt:base] Apt Base');
});
it('falls back to the directory name when no id is derivable', async () => {
cube('fallback-id', 'export default { name: "No Id Here" }');
const { cubes } = await loadCubes();
expect(cubes['fallback-id']).toBeDefined();
});
it('defaults the schema when the manifest omits one', async () => {
cube('no-schema', 'export default { id: "no-schema", name: "No Schema" }');
const { cubes } = await loadCubes();
expect(cubes['no-schema'].getDefaults()).toEqual({});
});
it('reports a manifest whose default export is not an object', async () => {
cube('bad-export', 'export default "just a string"');
const { cubes, errors } = await loadCubes();
expect(cubes['bad-export']).toBeUndefined();
expect(errors[0]).toMatch(/Invalid manifest export/);
});
it('reports a manifest with no default export', async () => {
cube('no-export', 'export const nothing = 1;');
const { errors } = await loadCubes();
expect(errors[0]).toMatch(/Invalid manifest export/);
});
it('reports a manifest missing a name', async () => {
cube('no-name', 'export default { id: "no-name" }');
const { errors } = await loadCubes();
expect(errors[0]).toMatch(/missing 'name'/);
});
it('reports a manifest that fails to import', async () => {
cube('broken', 'this is not valid javascript !!!');
const { errors } = await loadCubes();
expect(errors[0]).toMatch(/Failed to load manifest/);
});
it('reports duplicate cube ids', async () => {
cube('first', 'export default { id: "dup", name: "First" }');
cube('second', 'export default { id: "dup", name: "Second" }');
const { cubes, errors } = await loadCubes();
expect(Object.keys(cubes)).toEqual(['dup']);
expect(errors[0]).toMatch(/Duplicate cube id 'dup'/);
});
it('skips hidden and node_modules directories', async () => {
cube('.hidden/inner', 'export default { id: "hidden", name: "Hidden" }');
cube('node_modules/pkg', 'export default { id: "vendored", name: "Vendored" }');
cube('visible', 'export default { id: "visible", name: "Visible" }');
const { cubes } = await loadCubes();
expect(Object.keys(cubes)).toEqual(['visible']);
});
it('ignores configured cube directories that do not exist', async () => {
fs.writeFileSync(
path.join(tmpDir, '.nopyrc.json'),
JSON.stringify({ cubeDirs: ['./', './does-not-exist'] })
);
cube('visible', 'export default { id: "visible", name: "Visible" }');
const { cubes, errors } = await loadCubes();
expect(errors).toEqual([]);
expect(cubes.visible).toBeDefined();
});
});
describe('getCube', () => {
it('returns a single cube by id', async () => {
cube('one', 'export default { id: "one", name: "One" }');
await expect(getCube('one')).resolves.toMatchObject({ id: 'one' });
});
it('returns undefined for an unknown id', async () => {
await expect(getCube('nope')).resolves.toBeUndefined();
});
});
});
+1 -1
View File
@@ -99,7 +99,7 @@ describe('loadCubes (Integration)', () => {
await fs.mkdirp('only-deploy');
await fs.writeFile('only-deploy/deploy.py', '# deploy');
const { cubes, errors } = await loadCubes();
const { cubes } = await loadCubes();
expect(Object.keys(cubes)).not.toContain('only-manifest');
expect(Object.keys(cubes)).not.toContain('only-deploy');
@@ -0,0 +1,147 @@
/**
* Tests for the executeDeployCalls path of nopy.executor.
*
* execa is mocked so no pyinfra process is ever spawned. Note the shape:
* the module calls execa({ shell: true })(command, opts), so the mock is a
* factory returning the runner.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const runner = vi.fn();
vi.mock('execa', () => ({
execa: vi.fn(() => runner),
}));
import { execa } from 'execa';
import { type DeployCall, executeDeployCalls } from '../src/nopy.executor.js';
const call = (cube: string, host = 'web-1'): DeployCall => ({
cube,
host,
cwd: `/cubes/${cube}`,
command: ['pyinfra', host, '-y', `${cube}.deploy.py`],
env: {},
dependencies: [],
});
beforeEach(() => {
vi.clearAllMocks();
runner.mockResolvedValue({ exitCode: 0 });
});
describe('executeDeployCalls', () => {
it('returns early without spawning anything for an empty list', async () => {
const results = await executeDeployCalls([]);
expect(results).toEqual([]);
expect(runner).not.toHaveBeenCalled();
});
it('prints the plan and skips execution on a dry run', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const results = await executeDeployCalls([call('cube-a')], { dryRun: true });
expect(results).toEqual([]);
expect(runner).not.toHaveBeenCalled();
expect(logSpy.mock.calls.map((c) => c[0]).join('\n')).toContain('Execution Plan');
logSpy.mockRestore();
});
it('runs the joined command in the call cwd with inherited stdio', async () => {
await executeDeployCalls([call('cube-a')]);
expect(execa).toHaveBeenCalledWith({ shell: true });
expect(runner).toHaveBeenCalledWith('pyinfra web-1 -y cube-a.deploy.py', {
cwd: '/cubes/cube-a',
stdio: 'inherit',
});
});
it('reports success with a non-negative duration', async () => {
const [result] = await executeDeployCalls([call('cube-a')]);
expect(result.success).toBe(true);
expect(result.cube).toBe('cube-a');
expect(result.host).toBe('web-1');
expect(result.duration).toBeGreaterThanOrEqual(0);
expect(result.error).toBeUndefined();
});
it('captures a thrown Error as a failed result rather than rejecting', async () => {
runner.mockRejectedValue(new Error('exit code 1'));
const [result] = await executeDeployCalls([call('cube-a')]);
expect(result.success).toBe(false);
expect(result.error).toBeInstanceOf(Error);
expect(result.error?.message).toBe('exit code 1');
});
it('wraps a non-Error rejection into an Error', async () => {
runner.mockRejectedValue('boom');
const [result] = await executeDeployCalls([call('cube-a')]);
expect(result.error).toBeInstanceOf(Error);
expect(result.error?.message).toBe('boom');
});
it('stops after the first failure by default', async () => {
runner.mockRejectedValueOnce(new Error('nope')).mockResolvedValue({ exitCode: 0 });
const results = await executeDeployCalls([call('cube-a'), call('cube-b')]);
expect(results).toHaveLength(1);
expect(results[0].cube).toBe('cube-a');
expect(runner).toHaveBeenCalledTimes(1);
});
it('keeps going past a failure when continueOnError is set', async () => {
runner.mockRejectedValueOnce(new Error('nope')).mockResolvedValue({ exitCode: 0 });
const results = await executeDeployCalls([call('cube-a'), call('cube-b')], {
continueOnError: true,
});
expect(results).toHaveLength(2);
expect(results.map((r) => r.success)).toEqual([false, true]);
});
it('invokes onStart before each call', async () => {
const onStart = vi.fn();
await executeDeployCalls([call('cube-a'), call('cube-b', 'web-2')], { onStart });
expect(onStart.mock.calls).toEqual([
['cube-a', 'web-1'],
['cube-b', 'web-2'],
]);
});
it('invokes onProgress with running completed/total counts', async () => {
const onProgress = vi.fn();
await executeDeployCalls([call('cube-a'), call('cube-b')], { onProgress });
expect(onProgress).toHaveBeenCalledTimes(2);
expect(onProgress.mock.calls[0].slice(1)).toEqual([1, 2]);
expect(onProgress.mock.calls[1].slice(1)).toEqual([2, 2]);
});
it('reports progress for the failing call before stopping', async () => {
const onProgress = vi.fn();
runner.mockRejectedValue(new Error('nope'));
await executeDeployCalls([call('cube-a'), call('cube-b')], { onProgress });
expect(onProgress).toHaveBeenCalledTimes(1);
expect(onProgress.mock.calls[0][0].success).toBe(false);
});
it('works without any callbacks supplied', async () => {
await expect(executeDeployCalls([call('cube-a')])).resolves.toHaveLength(1);
});
});
+7 -1
View File
@@ -2,7 +2,7 @@
* Tests for nopy.executor module
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
type DeployCall,
type ExecutionResult,
@@ -104,6 +104,12 @@ describe('outputExecutionPlan', () => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
// vitest reuses an existing spy rather than re-wrapping, so recorded calls
// would otherwise leak from one test into the next.
afterEach(() => {
vi.restoreAllMocks();
});
it('outputs text format by default', () => {
const calls = [createTestCall('cube-a', 'host1')];
+3 -3
View File
@@ -7,17 +7,17 @@ import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
HISTORY_FILE,
type HistoryEntry,
type SessionHistory,
addToHistory,
clearHistory,
formatHistoryList,
getLastSession,
getSessionById,
HISTORY_FILE,
type HistoryEntry,
listHistory,
loadHistory,
removeFromHistory,
type SessionHistory,
saveHistory,
} from '../src/nopy.history.js';
import type { NopySession } from '../src/nopy.session.js';
+15 -11
View File
@@ -24,24 +24,28 @@ describe('Cube Hooks', () => {
const cubes: Record<string, Cube> = {
main: createMockCube('main', 'Main Cube', {
before: [async ({ exec }) => {
order.push('main:before');
await exec('before-hook', {});
}],
after: [async ({ exec }) => {
order.push('main:after');
await exec('after-hook', {});
}],
before: [
async ({ exec }) => {
order.push('main:before');
await exec('before-hook', {});
},
],
after: [
async ({ exec }) => {
order.push('main:after');
await exec('after-hook', {});
},
],
dependencies: () => ['dep'],
}),
'before-hook': createMockCube('before-hook', 'Before Hook'),
'after-hook': createMockCube('after-hook', 'After Hook'),
'dep': createMockCube('dep', 'Dependency'),
dep: createMockCube('dep', 'Dependency'),
};
// Note: buildDeployCall also records the main cube execution
// We can't easily spy on buildDeployCall, but we can see the resulting deployCalls order
const vars = new Variables();
const context = new BuildContext(
cubes,
@@ -53,7 +57,7 @@ describe('Cube Hooks', () => {
await context.resolveCube('main', 'host1');
const callOrder = context.deployCalls.map(c => c.cube);
const callOrder = context.deployCalls.map((c) => c.cube);
// Expected order:
// 1. main:before (hook runs)
+371
View File
@@ -0,0 +1,371 @@
/**
* Tests for the nopy() orchestrator.
*
* Every collaborator is mocked: this module's job is wiring and branching, and
* the pieces it wires (config loading, cube loading, dependency resolution,
* execution) are covered by their own suites.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { NopyConfig } from '../src/nopy.config.js';
import type { DeployCall } from '../src/nopy.executor.js';
import type { NopySession } from '../src/nopy.session.js';
// vi.mock factories are hoisted above module scope, so everything they close
// over has to be created inside vi.hoisted.
const {
state,
resolveCube,
loadCubes,
loadConfig,
getConfigPaths,
runWorkflow,
executeDeployCalls,
addToHistory,
saveSession,
} = vi.hoisted(() => {
const state = {
config: {} as NopyConfig,
loadResult: { cubes: {} as Record<string, unknown>, errors: [] as string[] },
deployCalls: [] as DeployCall[],
cubeSessions: [] as unknown[],
};
return {
state,
resolveCube: vi.fn(),
loadCubes: vi.fn(async () => state.loadResult),
loadConfig: vi.fn(() => state.config),
getConfigPaths: vi.fn(() => ['/project/.nopyrc.json']),
runWorkflow: vi.fn(),
executeDeployCalls: vi.fn(async () => [] as unknown[]),
addToHistory: vi.fn(),
saveSession: vi.fn(),
};
});
vi.mock('../src/cubes/index.js', () => ({ loadCubes }));
vi.mock('../src/nopy.config.js', () => ({ loadConfig, getConfigPaths }));
vi.mock('../src/nopy.workflow.js', () => ({ runWorkflow }));
vi.mock('../src/nopy.history.js', () => ({ addToHistory, DEFAULT_HISTORY_SIZE: 10 }));
vi.mock('../src/nopy.session.js', () => ({ saveSession }));
vi.mock('../src/cubes/dependencies.js', () => ({
BuildContext: class {
resolveCube = resolveCube;
get deployCalls() {
return state.deployCalls;
}
get cubeSessions() {
return state.cubeSessions;
}
},
}));
vi.mock('../src/nopy.executor.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/nopy.executor.js')>();
return { ...actual, executeDeployCalls };
});
import { nopy } from '../src/nopy.main.js';
const session = (): NopySession =>
({
version: '1.0',
name: 'test',
createdAt: '2026-01-01T00:00:00.000Z',
cubes: [],
hosts: ['web-1'],
auth: { method: 'ssh-key' },
env: {},
}) as NopySession;
const call = (cube: string): DeployCall => ({
cube,
host: 'web-1',
cwd: `/cubes/${cube}`,
command: ['pyinfra', 'web-1', '-y', `${cube}.deploy.py`],
env: {},
dependencies: [],
});
let logSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
state.config = { hosts: ['web-1'], cubeDirs: [], env: {} };
state.loadResult = { cubes: { 'cube-a': {} }, errors: [] };
state.deployCalls = [call('cube-a')];
state.cubeSessions = [{ key: 'cube-a', variables: {} }];
runWorkflow.mockResolvedValue({
session: session(),
selectedCubes: ['cube-a'],
authMethod: 'ssh-key',
isReplay: false,
});
executeDeployCalls.mockResolvedValue([
{ cube: 'cube-a', host: 'web-1', success: true, duration: 10 },
]);
});
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
describe('nopy', () => {
it('runs the happy path and reports success', async () => {
const result = await nopy();
expect(result?.success).toBe(true);
expect(result?.summary).toEqual({
total: 1,
successful: 1,
failed: 0,
totalDuration: 10,
});
expect(resolveCube).toHaveBeenCalledWith('cube-a', 'web-1');
});
it('reports failure when any call fails', async () => {
executeDeployCalls.mockResolvedValue([
{ cube: 'cube-a', host: 'web-1', success: false, duration: 5, error: new Error('x') },
]);
const result = await nopy();
expect(result?.success).toBe(false);
expect(result?.summary.failed).toBe(1);
});
it('resolves every cube against every host', async () => {
runWorkflow.mockResolvedValue({
session: { ...session(), hosts: ['web-1', 'web-2'] },
selectedCubes: ['cube-a', 'cube-b'],
authMethod: 'ssh-key',
isReplay: false,
});
await nopy();
expect(resolveCube).toHaveBeenCalledTimes(4);
});
describe('cube loading errors', () => {
it('aborts and returns undefined', async () => {
state.loadResult = { cubes: {}, errors: ['bad manifest'] };
const result = await nopy();
expect(result).toBeUndefined();
expect(runWorkflow).not.toHaveBeenCalled();
});
it('emits the errors as JSON when jsonOutput is set', async () => {
state.loadResult = { cubes: {}, errors: ['bad manifest'] };
await nopy({ jsonOutput: true });
const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string);
expect(payload).toEqual({ success: false, errors: ['bad manifest'] });
});
});
describe('config banner', () => {
it('prints the active configuration in interactive mode', async () => {
state.config = {
hosts: ['web-1'],
cubeDirs: ['/cubes'],
env: { TOKEN: 'secret', EMPTY: '' },
};
await nopy({ continueOnError: true });
const text = output();
expect(text).toContain('Configuration');
expect(text).toContain('Hosts:');
expect(text).toContain('Cube dirs:');
expect(text).toContain('continue-on-error');
// Values are never echoed, only their presence.
expect(text).toContain('TOKEN: <VALUE>');
expect(text).toContain('EMPTY: <EMPTY>');
expect(text).not.toContain('secret');
});
it('omits empty sections', async () => {
state.config = { hosts: [], cubeDirs: [], env: {} };
await nopy();
const text = output();
expect(text).toContain('Configuration');
expect(text).not.toContain('Hosts:');
expect(text).not.toContain('Cube dirs:');
expect(text).not.toContain('Env vars:');
});
it('shortens paths under cwd and under HOME', async () => {
getConfigPaths.mockReturnValue([
`${process.env.HOME}/.nopyrc.json`,
`${process.cwd()}/.nopyrc.json`,
'/etc/nopy/.nopyrc.json',
]);
await nopy();
const text = output();
expect(text).toContain('~/.nopyrc.json');
expect(text).toContain('./.nopyrc.json');
expect(text).toContain('/etc/nopy/.nopyrc.json');
});
it('is suppressed for JSON output', async () => {
await nopy({ jsonOutput: true });
expect(output()).not.toContain('Configuration');
});
it('is suppressed when replaying a session object', async () => {
await nopy({ replaySession: session() });
expect(output()).not.toContain('Configuration');
});
it('is suppressed when replaying a session file', async () => {
await nopy({ loadSession: '/tmp/s.json' });
expect(output()).not.toContain('Configuration');
});
});
describe('session persistence', () => {
it('saves the session when a path is given', async () => {
await nopy({ saveSession: '/tmp/out.json' });
expect(saveSession).toHaveBeenCalledTimes(1);
const [written, path] = saveSession.mock.calls[0];
expect(path).toBe('/tmp/out.json');
expect(written.cubes).toEqual(state.cubeSessions);
});
it('does not save a replayed session back to file', async () => {
runWorkflow.mockResolvedValue({
session: session(),
selectedCubes: ['cube-a'],
authMethod: 'ssh-key',
isReplay: true,
});
await nopy({ saveSession: '/tmp/out.json' });
expect(saveSession).not.toHaveBeenCalled();
});
it('does not save when no path is given', async () => {
await nopy();
expect(saveSession).not.toHaveBeenCalled();
});
});
describe('history', () => {
it('records the session with the default size', async () => {
await nopy();
expect(addToHistory).toHaveBeenCalledTimes(1);
expect(addToHistory.mock.calls[0][1]).toBe(10);
});
it('honours a configured maxSessions', async () => {
state.config = { ...state.config, history: { maxSessions: 3 } };
await nopy();
expect(addToHistory.mock.calls[0][1]).toBe(3);
});
it('respects autoSave: false', async () => {
state.config = { ...state.config, history: { autoSave: false } };
await nopy();
expect(addToHistory).not.toHaveBeenCalled();
});
it('skips history on a dry run', async () => {
await nopy({ dryRun: true });
expect(addToHistory).not.toHaveBeenCalled();
});
it('skips history when the caller opts out', async () => {
await nopy({ saveToHistory: false });
expect(addToHistory).not.toHaveBeenCalled();
});
it('skips history for a replay', async () => {
runWorkflow.mockResolvedValue({
session: session(),
selectedCubes: ['cube-a'],
authMethod: 'ssh-key',
isReplay: true,
});
await nopy();
expect(addToHistory).not.toHaveBeenCalled();
});
it('skips history when nothing would be deployed', async () => {
state.deployCalls = [];
await nopy();
expect(addToHistory).not.toHaveBeenCalled();
});
});
describe('printOnly', () => {
it('prints commands and never executes', async () => {
await nopy({ printOnly: true });
const text = output();
expect(text).toContain('Deploy Commands');
expect(text).toContain('# cube-a -> web-1');
expect(text).toContain('pyinfra web-1 -y cube-a.deploy.py');
expect(executeDeployCalls).not.toHaveBeenCalled();
});
it('reports the command count as the summary total', async () => {
const result = await nopy({ printOnly: true });
expect(result).toEqual({
success: true,
results: [],
summary: { total: 1, successful: 0, failed: 0, totalDuration: 0 },
});
});
});
describe('execution options', () => {
it('forwards dryRun and continueOnError to the executor', async () => {
await nopy({ dryRun: true, continueOnError: true });
const [, options] = executeDeployCalls.mock.calls[0];
expect(options.dryRun).toBe(true);
expect(options.continueOnError).toBe(true);
});
it('logs progress lines in interactive mode', async () => {
await nopy();
const [, options] = executeDeployCalls.mock.calls[0];
options.onProgress({ cube: 'cube-a', host: 'web-1', success: true }, 1, 1);
options.onProgress({ cube: 'cube-b', host: 'web-1', success: false }, 1, 1);
// Exercises both the ✓ and ✗ branches; logtape writes via console.log.
expect(logSpy).toHaveBeenCalled();
});
it('stays silent on progress when jsonOutput is set', async () => {
await nopy({ jsonOutput: true });
const [, options] = executeDeployCalls.mock.calls[0];
const before = logSpy.mock.calls.length;
options.onProgress({ cube: 'cube-a', host: 'web-1', success: true }, 1, 1);
expect(logSpy.mock.calls.length).toBe(before);
});
});
});
+325
View File
@@ -0,0 +1,325 @@
/**
* Tests for nopy.prompts.
*
* inquirer and enquirer are mocked so nothing touches a TTY. What is actually
* under test is the logic wrapped around them: choice construction, the `when`
* predicates, host-string mapping and zod-driven value coercion.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
const { inquirerPrompt, formRun, autoCompleteRun, autoCompleteCtor } = vi.hoisted(() => ({
inquirerPrompt: vi.fn(),
formRun: vi.fn(),
autoCompleteRun: vi.fn(),
autoCompleteCtor: vi.fn(),
}));
vi.mock('inquirer', () => ({
default: { prompt: inquirerPrompt },
}));
vi.mock('enquirer', () => ({
default: {
Form: class {
run = formRun;
},
AutoComplete: class {
run = autoCompleteRun;
constructor(options: unknown) {
autoCompleteCtor(options);
}
},
},
}));
import { Cube, Manifest } from '../src/cubes/types.js';
import { Variables } from '../src/nopy.common.js';
import {
AuthSelection,
CubeSelection,
HostSelection,
PasswordSelection,
VariableAssignment,
} from '../src/nopy.prompts.js';
/** Grabs the single question object passed to the last inquirer.prompt call. */
const questions = () => inquirerPrompt.mock.calls.at(-1)?.[0] as Record<string, any>[];
const question = (name: string) => questions().find((q) => q.name === name);
/** Grabs the options the last enquirer AutoComplete prompt was constructed with. */
const autoComplete = () => autoCompleteCtor.mock.calls.at(-1)?.[0] as Record<string, any>;
const cube = (id: string, name: string, schema = z.object({})) =>
new Cube(Manifest({ id, name, schema }), `/cubes/${id}`, 'deploy.py');
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
});
describe('CubeSelection', () => {
const cubes = {
b: cube('cube-b', 'Beta'),
a: cube('cube-a', 'Alpha'),
};
it('returns the selection', async () => {
autoCompleteRun.mockResolvedValue(['cube-a']);
await expect(CubeSelection(cubes)).resolves.toEqual({ selectedCubes: ['cube-a'] });
});
it('sorts choices by cube id', async () => {
autoCompleteRun.mockResolvedValue([]);
await CubeSelection(cubes);
const { choices } = autoComplete();
expect(choices.map((c: { name: string }) => c.name)).toEqual(['cube-a', 'cube-b']);
expect(choices[0].message).toBe('cube-a - Alpha');
});
it('returns every choice for an undefined filter', async () => {
autoCompleteRun.mockResolvedValue([]);
await CubeSelection(cubes);
const { choices, suggest } = autoComplete();
expect(suggest(undefined, choices)).toHaveLength(2);
expect(suggest('', choices)).toHaveLength(2);
});
it('fuzzy filters on the visible label', async () => {
autoCompleteRun.mockResolvedValue([]);
await CubeSelection(cubes);
const { choices, suggest } = autoComplete();
expect(suggest('Alph', choices).map((c: { name: string }) => c.name)).toEqual(['cube-a']);
});
it('derives page size from the terminal height', async () => {
autoCompleteRun.mockResolvedValue([]);
const rows = process.stdout.rows;
Object.defineProperty(process.stdout, 'rows', { value: 40, configurable: true });
await CubeSelection(cubes);
expect(autoComplete().limit).toBe(35);
// Falls back to a floor of 10 on a short (or unknown) terminal.
Object.defineProperty(process.stdout, 'rows', { value: 0, configurable: true });
await CubeSelection(cubes);
expect(autoComplete().limit).toBe(19);
Object.defineProperty(process.stdout, 'rows', { value: rows, configurable: true });
});
it('selects nothing when the user cancels', async () => {
autoCompleteRun.mockRejectedValue(new Error('cancelled'));
await expect(CubeSelection(cubes)).resolves.toEqual({ selectedCubes: [] });
});
});
describe('AuthSelection', () => {
it('short-circuits to ssh-key without prompting', async () => {
await expect(AuthSelection(true)).resolves.toEqual({ authMethod: 'ssh-key' });
expect(inquirerPrompt).not.toHaveBeenCalled();
});
it('prompts when no key is forced', async () => {
inquirerPrompt.mockResolvedValue({ authMethod: 'password', username: 'u', password: 'p' });
await expect(AuthSelection()).resolves.toEqual({
authMethod: 'password',
username: 'u',
password: 'p',
});
});
it('asks for credentials only when the method is not ssh-key', async () => {
inquirerPrompt.mockResolvedValue({ authMethod: 'ssh-key' });
await AuthSelection(false);
expect(question('username')?.when({ authMethod: 'password' })).toBe(true);
expect(question('username')?.when({ authMethod: 'ssh-key' })).toBe(false);
expect(question('password')?.when({ authMethod: 'password' })).toBe(true);
expect(question('password')?.when({ authMethod: 'ssh-key' })).toBe(false);
});
});
describe('PasswordSelection', () => {
it('returns the entered password', async () => {
inquirerPrompt.mockResolvedValue({ password: 'hunter2' });
await expect(PasswordSelection('deploy')).resolves.toBe('hunter2');
expect(question('password')?.message).toContain('deploy');
});
});
describe('HostSelection', () => {
it('offers the configured hosts alongside the built-ins', async () => {
inquirerPrompt.mockResolvedValue({ host: 'web-1' });
await HostSelection(['web-1', 'web-2']);
expect(question('host')?.choices).toEqual(['docker', 'vagrant', 'web-1', 'web-2', 'custom']);
});
it('returns a plain host as-is', async () => {
inquirerPrompt.mockResolvedValue({ host: 'web-1' });
await expect(HostSelection(['web-1'])).resolves.toBe('web-1');
});
it('returns the custom address when custom is chosen', async () => {
inquirerPrompt.mockResolvedValue({ host: 'custom', customHost: '10.0.0.5' });
await expect(HostSelection([])).resolves.toBe('10.0.0.5');
});
it('prefixes a vagrant machine', async () => {
inquirerPrompt.mockResolvedValue({ host: 'vagrant', vagrantVM: 'builder' });
await expect(HostSelection([])).resolves.toBe('@vagrant/builder');
});
it('prefixes a docker container', async () => {
inquirerPrompt.mockResolvedValue({ host: 'runtime:docker', dockerContainer: 'box' });
await expect(HostSelection([])).resolves.toBe('@docker/box');
});
it('gates the follow-up questions on the chosen host', async () => {
inquirerPrompt.mockResolvedValue({ host: 'web-1' });
await HostSelection([]);
expect(question('customHost')?.when({ host: 'custom' })).toBe(true);
expect(question('customHost')?.when({ host: 'web-1' })).toBe(false);
expect(question('vagrantVM')?.when({ host: 'vagrant' })).toBe(true);
expect(question('vagrantVM')?.when({ host: 'web-1' })).toBe(false);
expect(question('dockerContainer')?.when({ host: 'runtime:docker' })).toBe(true);
expect(question('dockerContainer')?.when({ host: 'web-1' })).toBe(false);
});
});
describe('VariableAssignment', () => {
const schema = z.object({
port: z.number().default(8080).describe('Listen port'),
enabled: z.boolean().default(false),
name: z.string().default('svc'),
});
it('does nothing when every default is already supplied as a param', async () => {
const variables = new Variables();
variables.assign('svc', 'params', { port: 1, enabled: true, name: 'x' });
await VariableAssignment(cube('svc', 'Service', schema), variables);
expect(formRun).not.toHaveBeenCalled();
});
it('does nothing for a cube with no defaults', async () => {
await VariableAssignment(cube('bare', 'Bare'), new Variables());
expect(formRun).not.toHaveBeenCalled();
});
it('only asks about the variables still missing', async () => {
const variables = new Variables();
variables.assign('svc', 'params', { port: 9090 });
formRun.mockResolvedValue({});
await VariableAssignment(cube('svc', 'Service', schema), variables);
expect(formRun).toHaveBeenCalled();
expect(variables.get('svc', 'prompts')).toEqual({});
});
it('coerces answers using the schema and stores them under prompts', async () => {
const variables = new Variables();
formRun.mockResolvedValue({ port: '9090', enabled: 'true', name: 'api' });
await VariableAssignment(cube('svc', 'Service', schema), variables);
expect(variables.get('svc', 'prompts')).toEqual({
port: 9090,
enabled: true,
name: 'api',
});
});
it('leaves an unparseable number as the raw string', async () => {
const variables = new Variables();
formRun.mockResolvedValue({ port: 'not-a-number', enabled: 'no', name: 'api' });
await VariableAssignment(cube('svc', 'Service', schema), variables);
expect(variables.get('svc', 'prompts').port).toBe('not-a-number');
expect(variables.get('svc', 'prompts').enabled).toBe(false);
});
it('accepts yes and 1 as truthy booleans', async () => {
const variables = new Variables();
formRun.mockResolvedValue({ port: '1', enabled: 'yes', name: 'api' });
await VariableAssignment(cube('svc', 'Service', schema), variables);
expect(variables.get('svc', 'prompts').enabled).toBe(true);
});
it('unwraps optional and nullable schema types', async () => {
const nullableSchema = z.object({
maybe: z.number().nullable().default(1),
opt: z.number().optional().default(2),
});
const variables = new Variables();
formRun.mockResolvedValue({ maybe: 'null', opt: '7' });
await VariableAssignment(cube('svc', 'Service', nullableSchema), variables);
expect(variables.get('svc', 'prompts')).toEqual({ maybe: null, opt: 7 });
});
it('treats an empty string as null for a nullable field', async () => {
const nullableSchema = z.object({ maybe: z.number().nullable().default(1) });
const variables = new Variables();
formRun.mockResolvedValue({ maybe: '' });
await VariableAssignment(cube('svc', 'Service', nullableSchema), variables);
expect(variables.get('svc', 'prompts').maybe).toBe(null);
});
it('passes non-string answers through untouched', async () => {
const variables = new Variables();
formRun.mockResolvedValue({ port: 9090, enabled: true, name: 'api' });
await VariableAssignment(cube('svc', 'Service', schema), variables);
expect(variables.get('svc', 'prompts').port).toBe(9090);
});
it('keeps answers for keys the schema does not describe', async () => {
const variables = new Variables();
formRun.mockResolvedValue({ port: '1', enabled: 'true', name: 'api', extra: 'kept' });
await VariableAssignment(cube('svc', 'Service', schema), variables);
expect(variables.get('svc', 'prompts').extra).toBe('kept');
});
it('assigns nothing when the user cancels the form', async () => {
const variables = new Variables();
formRun.mockRejectedValue(new Error('cancelled'));
await expect(
VariableAssignment(cube('svc', 'Service', schema), variables)
).resolves.toBeUndefined();
expect(variables.get('svc', 'prompts')).toEqual({});
});
});
+1 -1
View File
@@ -7,11 +7,11 @@ import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
type NopySession,
createSession,
filterInternalVariables,
listSessions,
loadSession,
type NopySession,
saveSession,
separateEnvAndCubeVariables,
} from '../src/nopy.session.js';
+312
View File
@@ -0,0 +1,312 @@
/**
* Tests for nopy.workflow module.
*
* The prompt layer is the only I/O in this module, so mocking nopy.prompts
* exercises every branch without touching a TTY.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../src/nopy.prompts.js', () => ({
CubeSelection: vi.fn(),
HostSelection: vi.fn(),
AuthSelection: vi.fn(),
PasswordSelection: vi.fn(),
}));
vi.mock('../src/nopy.session.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/nopy.session.js')>();
return { ...actual, loadSession: vi.fn() };
});
import type { Cube } from '../src/cubes/index.js';
import type { NopyConfig } from '../src/nopy.config.js';
import {
AuthSelection,
CubeSelection,
HostSelection,
PasswordSelection,
} from '../src/nopy.prompts.js';
import type { NopySession } from '../src/nopy.session.js';
import { loadSession } from '../src/nopy.session.js';
import {
runInteractiveWorkflow,
runReplayWorkflow,
runSessionReplayWorkflow,
runWorkflow,
} from '../src/nopy.workflow.js';
const mockCubeSelection = vi.mocked(CubeSelection);
const mockHostSelection = vi.mocked(HostSelection);
const mockAuthSelection = vi.mocked(AuthSelection);
const mockPasswordSelection = vi.mocked(PasswordSelection);
const mockLoadSession = vi.mocked(loadSession);
const config: NopyConfig = {
hosts: ['web-1', 'web-2'],
cubeDirs: [],
env: { GLOBAL: 'value' },
};
const cubes = {
'cube-a': { id: 'cube-a', name: 'Cube A' } as Cube,
};
const session = (overrides: Partial<NopySession> = {}): NopySession =>
({
version: '1.0',
name: 'test-session',
createdAt: '2026-01-01T00:00:00.000Z',
cubes: [{ key: 'cube-a', variables: {} }],
hosts: ['web-1'],
auth: { method: 'ssh-key' },
env: {},
...overrides,
}) as NopySession;
beforeEach(() => {
vi.clearAllMocks();
mockCubeSelection.mockResolvedValue({ selectedCubes: ['cube-a'] });
mockHostSelection.mockResolvedValue('web-1');
mockAuthSelection.mockResolvedValue({ authMethod: 'ssh-key' });
mockPasswordSelection.mockResolvedValue('s3cret');
});
describe('runInteractiveWorkflow', () => {
it('collects cubes, host and auth into a fresh session', async () => {
const result = await runInteractiveWorkflow(cubes, config);
expect(result.selectedCubes).toEqual(['cube-a']);
expect(result.authMethod).toBe('ssh-key');
expect(result.isReplay).toBe(false);
expect(result.session.hosts).toEqual(['web-1']);
expect(result.session.env).toEqual({ GLOBAL: 'value' });
expect(mockHostSelection).toHaveBeenCalledWith(config.hosts);
});
it('forwards useAuthKey to the auth prompt', async () => {
await runInteractiveWorkflow(cubes, config, { useAuthKey: true });
expect(mockAuthSelection).toHaveBeenCalledWith(true);
});
it('carries username and password through from password auth', async () => {
mockAuthSelection.mockResolvedValue({
authMethod: 'password',
username: 'deploy',
password: 'hunter2',
});
const result = await runInteractiveWorkflow(cubes, config);
expect(result.username).toBe('deploy');
expect(result.password).toBe('hunter2');
expect(result.session.auth.username).toBe('deploy');
});
it('skips the auth prompt entirely for vagrant hosts', async () => {
mockHostSelection.mockResolvedValue('@vagrant/default');
const result = await runInteractiveWorkflow(cubes, config);
expect(mockAuthSelection).not.toHaveBeenCalled();
expect(result.authMethod).toBe('ssh');
expect(result.username).toBeUndefined();
});
it('skips the auth prompt entirely for docker hosts', async () => {
mockHostSelection.mockResolvedValue('@docker/box');
const result = await runInteractiveWorkflow(cubes, config);
expect(mockAuthSelection).not.toHaveBeenCalled();
expect(result.authMethod).toBe('ssh');
});
it('proceeds when the user selects nothing', async () => {
mockCubeSelection.mockResolvedValue({ selectedCubes: [] });
const result = await runInteractiveWorkflow(cubes, config);
expect(result.selectedCubes).toEqual([]);
});
it('never stores a password on the session', async () => {
mockAuthSelection.mockResolvedValue({
authMethod: 'password',
username: 'deploy',
password: 'hunter2',
});
const result = await runInteractiveWorkflow(cubes, config);
expect(JSON.stringify(result.session)).not.toContain('hunter2');
});
});
describe('runReplayWorkflow', () => {
it('replays a session file without prompting', async () => {
mockLoadSession.mockResolvedValue(session());
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
expect(mockLoadSession).toHaveBeenCalledWith('/tmp/s.json');
expect(result.isReplay).toBe(true);
expect(result.selectedCubes).toEqual(['cube-a']);
expect(mockHostSelection).not.toHaveBeenCalled();
expect(mockPasswordSelection).not.toHaveBeenCalled();
});
it('tolerates a session referencing an unknown cube', async () => {
mockLoadSession.mockResolvedValue(
session({ cubes: [{ key: 'ghost-cube', variables: {} }] } as Partial<NopySession>)
);
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
expect(result.selectedCubes).toEqual(['ghost-cube']);
});
it('prompts for a host when the session has none', async () => {
mockLoadSession.mockResolvedValue(session({ hosts: [] }));
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
expect(mockHostSelection).toHaveBeenCalledWith(config.hosts);
expect(result.session.hosts).toEqual(['web-1']);
});
it('prompts for a host when hosts is missing entirely', async () => {
mockLoadSession.mockResolvedValue(session({ hosts: undefined }));
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
expect(result.session.hosts).toEqual(['web-1']);
});
it('re-prompts only for the password when a username is stored', async () => {
mockLoadSession.mockResolvedValue(
session({ auth: { method: 'password', username: 'deploy' } })
);
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
expect(mockPasswordSelection).toHaveBeenCalledWith('deploy');
expect(mockAuthSelection).not.toHaveBeenCalled();
expect(result.password).toBe('s3cret');
expect(result.username).toBe('deploy');
});
it('falls back to the full auth prompt when the username is missing', async () => {
mockLoadSession.mockResolvedValue(session({ auth: { method: 'password' } }));
mockAuthSelection.mockResolvedValue({
authMethod: 'password',
username: 'recovered',
password: 'fresh',
});
const result = await runReplayWorkflow('/tmp/s.json', cubes, config);
expect(mockAuthSelection).toHaveBeenCalledWith(false);
expect(mockPasswordSelection).not.toHaveBeenCalled();
expect(result.username).toBe('recovered');
expect(result.password).toBe('fresh');
});
it('propagates load failures', async () => {
mockLoadSession.mockRejectedValue(new Error('missing file'));
await expect(runReplayWorkflow('/tmp/nope.json', cubes, config)).rejects.toThrow(
'missing file'
);
});
});
describe('runSessionReplayWorkflow', () => {
it('replays an in-memory session without prompting', async () => {
const result = await runSessionReplayWorkflow(session(), cubes, config);
expect(result.isReplay).toBe(true);
expect(result.selectedCubes).toEqual(['cube-a']);
expect(mockLoadSession).not.toHaveBeenCalled();
expect(mockHostSelection).not.toHaveBeenCalled();
});
it('tolerates a session referencing an unknown cube', async () => {
const result = await runSessionReplayWorkflow(
session({ cubes: [{ key: 'ghost-cube', variables: {} }] } as Partial<NopySession>),
cubes,
config
);
expect(result.selectedCubes).toEqual(['ghost-cube']);
});
it('prompts for a host when the session has none', async () => {
const result = await runSessionReplayWorkflow(session({ hosts: [] }), cubes, config);
expect(mockHostSelection).toHaveBeenCalled();
expect(result.session.hosts).toEqual(['web-1']);
});
it('prompts for a host when hosts is missing entirely', async () => {
const result = await runSessionReplayWorkflow(session({ hosts: undefined }), cubes, config);
expect(result.session.hosts).toEqual(['web-1']);
});
it('re-prompts only for the password when a username is stored', async () => {
const result = await runSessionReplayWorkflow(
session({ auth: { method: 'password', username: 'deploy' } }),
cubes,
config
);
expect(mockPasswordSelection).toHaveBeenCalledWith('deploy');
expect(result.password).toBe('s3cret');
});
it('falls back to the full auth prompt when the username is missing', async () => {
mockAuthSelection.mockResolvedValue({
authMethod: 'password',
username: 'recovered',
password: 'fresh',
});
const result = await runSessionReplayWorkflow(
session({ auth: { method: 'password' } }),
cubes,
config
);
expect(mockAuthSelection).toHaveBeenCalledWith(false);
expect(result.username).toBe('recovered');
});
});
describe('runWorkflow dispatch', () => {
it('prefers an in-memory replay session over everything else', async () => {
const result = await runWorkflow('/tmp/s.json', cubes, config, {}, session());
expect(result.isReplay).toBe(true);
expect(mockLoadSession).not.toHaveBeenCalled();
expect(mockCubeSelection).not.toHaveBeenCalled();
});
it('uses the session file when no in-memory session is given', async () => {
mockLoadSession.mockResolvedValue(session());
const result = await runWorkflow('/tmp/s.json', cubes, config);
expect(mockLoadSession).toHaveBeenCalledWith('/tmp/s.json');
expect(result.isReplay).toBe(true);
expect(mockCubeSelection).not.toHaveBeenCalled();
});
it('falls back to the interactive workflow', async () => {
const result = await runWorkflow(undefined, cubes, config, { useAuthKey: true });
expect(result.isReplay).toBe(false);
expect(mockCubeSelection).toHaveBeenCalled();
expect(mockAuthSelection).toHaveBeenCalledWith(true);
});
});
+2 -2
View File
@@ -1,13 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo",
"tsBuildInfoFile": ".tsbuildinfo",
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2020"],
"composite": true,
"module": "NodeNext",
"types": ["jest", "node"]
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["coverage", "node_modules", "dist"],
+16 -2
View File
@@ -8,9 +8,23 @@ export default defineConfig({
include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
reporter: ['text', 'json-summary', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/nopy.cli.ts'],
exclude: [
'src/**/*.test.ts',
// Pure re-export barrels: no logic to cover.
'src/index.ts',
'src/cubes/index.ts',
'src/nopy.cubes.ts',
// Commander wiring only; behaviour lives in the modules it calls.
'src/nopy.cli.ts',
],
thresholds: {
branches: 85,
functions: 85,
lines: 80,
statements: 80,
},
},
},
});