initial transfer

This commit is contained in:
Benjamin Diedrichsen
2026-07-27 13:09:00 +02:00
parent 9f25d48dc2
commit 736c01216a
191 changed files with 17622 additions and 136 deletions
+46
View File
@@ -0,0 +1,46 @@
/**
* 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
@@ -0,0 +1,114 @@
/**
* 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
@@ -0,0 +1,21 @@
/**
* 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
@@ -0,0 +1,23 @@
/**
* 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
@@ -0,0 +1,13 @@
/**
* 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
@@ -0,0 +1,17 @@
/**
* 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
@@ -0,0 +1,22 @@
/**
* 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
@@ -0,0 +1,102 @@
/**
* 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
@@ -0,0 +1,74 @@
/**
* 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
@@ -0,0 +1,57 @@
/**
* 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
@@ -0,0 +1,20 @@
/**
* 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
@@ -0,0 +1,33 @@
/**
* 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
@@ -0,0 +1,20 @@
/**
* 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
@@ -0,0 +1,23 @@
/**
* 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
@@ -0,0 +1,6 @@
#!/usr/bin/env node
/**
* Nopy CLI - pyinfra deployment management
* @module nopy.cli
*/
export {};
+127
View File
@@ -0,0 +1,127 @@
#!/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
@@ -0,0 +1,20 @@
/**
* 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
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,114 @@
/**
* 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
@@ -0,0 +1,263 @@
/**
* 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
@@ -0,0 +1,22 @@
/**
* 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
@@ -0,0 +1,16 @@
/**
* 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
@@ -0,0 +1,92 @@
/**
* 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
@@ -0,0 +1,138 @@
/**
* 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
@@ -0,0 +1,90 @@
/**
* 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
@@ -0,0 +1,170 @@
/**
* 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
@@ -0,0 +1,38 @@
/**
* 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
@@ -0,0 +1,163 @@
/**
* 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
@@ -0,0 +1,21 @@
/**
* 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
@@ -0,0 +1,174 @@
/**
* 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
@@ -0,0 +1,112 @@
/**
* 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
@@ -0,0 +1,166 @@
/**
* 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
@@ -0,0 +1,49 @@
/**
* 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
@@ -0,0 +1,149 @@
/**
* 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);
}