initial transfer
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Dynamic dependency resolution for cubes
|
||||
* @module cubes/dependencies
|
||||
*/
|
||||
|
||||
import { getLogger } from '@logtape/logtape';
|
||||
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';
|
||||
|
||||
const log = getLogger(['nopy', 'resolution']);
|
||||
|
||||
/**
|
||||
* Context for the resolution process
|
||||
*/
|
||||
export class BuildContext {
|
||||
public readonly deployCalls: DeployCall[] = [];
|
||||
public readonly cubeSessions: CubeSession[] = [];
|
||||
private readonly resolvedCubes = new Set<string>();
|
||||
|
||||
constructor(
|
||||
public readonly allCubes: Record<string, Cube>,
|
||||
public readonly variables: Variables,
|
||||
public readonly session: NopySession,
|
||||
public readonly config: NopyConfig,
|
||||
public readonly auth: {
|
||||
method: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
},
|
||||
public readonly options: {
|
||||
useDefaults?: boolean;
|
||||
isSessionReplay?: boolean;
|
||||
} = {}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolves a cube, its dependencies, and hooks recursively
|
||||
*/
|
||||
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}`);
|
||||
}
|
||||
|
||||
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: HookContext = {
|
||||
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
|
||||
*/
|
||||
private buildDeployCall(cube: Cube, host: string): void {
|
||||
const cubeId = cube.id;
|
||||
const callKey = `${cubeId}:${host}`;
|
||||
|
||||
if (this.resolvedCubes.has(callKey)) return;
|
||||
|
||||
const parts: string[] = [];
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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<Schema extends import('zod').z.AnyZodObject>(
|
||||
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
|
||||
): Manifest<Schema> {
|
||||
return Manifest(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for createManifest - for backwards compatibility with existing manifests
|
||||
*/
|
||||
export const manifest = createManifest;
|
||||
|
||||
/**
|
||||
* @deprecated Use createManifest or manifest instead
|
||||
*/
|
||||
export const ManifestFactory = createManifest;
|
||||
|
||||
export { Manifest } from './types.js';
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Nopy Cubes Module
|
||||
*
|
||||
* Self-contained deployment units for pyinfra automation.
|
||||
*
|
||||
* @module cubes
|
||||
*/
|
||||
|
||||
// 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';
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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, type LoadResult, type Manifest } 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(): string[] {
|
||||
let currentDir = process.cwd();
|
||||
const config = loadConfig();
|
||||
const dirSet = new Set<string>(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: Manifest): string | undefined {
|
||||
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(): Promise<LoadResult> {
|
||||
const cubesFolders = findCubeDirectories();
|
||||
const cubes: Record<string, Cube> = {};
|
||||
const errors: string[] = [];
|
||||
|
||||
async function scanDirectory(currentDir: string, baseDir: string): Promise<void> {
|
||||
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 as Manifest;
|
||||
|
||||
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: string): Promise<Cube | undefined> {
|
||||
const { cubes } = await loadCubes();
|
||||
return cubes[cubeName];
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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 function Manifest<Schema extends z.AnyZodObject>(
|
||||
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
|
||||
): Manifest<Schema> {
|
||||
return {
|
||||
id: opts.id ?? '',
|
||||
name: opts.name,
|
||||
schema: opts.schema ?? (z.object({}) as unknown as Schema),
|
||||
dependencies: opts.dependencies,
|
||||
before: opts.before ?? [],
|
||||
after: opts.after ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export namespace Manifest {
|
||||
/**
|
||||
* Internal create helper
|
||||
*/
|
||||
export function create<Schema extends z.AnyZodObject>(
|
||||
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
|
||||
): Manifest<Schema> {
|
||||
return Manifest(opts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fully loaded cube with its filesystem location and runtime state
|
||||
*/
|
||||
export class Cube<Schema extends z.AnyZodObject = z.AnyZodObject> {
|
||||
constructor(
|
||||
public readonly manifest: Manifest<Schema>,
|
||||
public readonly dir: string,
|
||||
public readonly deployScript: string
|
||||
) {}
|
||||
|
||||
get id(): string {
|
||||
return this.manifest.id;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.manifest.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns default values for the cube's schema
|
||||
*/
|
||||
getDefaults(): z.infer<Schema> {
|
||||
try {
|
||||
return this.manifest.schema.parse({});
|
||||
} catch {
|
||||
return {} as 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[];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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): string {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const charsetLength = charset.length;
|
||||
|
||||
// Use process.hrtime.bigint() for high-resolution time in nanoseconds
|
||||
let seed = Number(process.hrtime.bigint() % BigInt(Number.MAX_SAFE_INTEGER));
|
||||
|
||||
const randomString: string[] = [];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
// Simple linear congruential generator (LCG) for pseudo-randomness
|
||||
seed = (seed * 48271) % 2147483647;
|
||||
const index = seed % charsetLength;
|
||||
randomString.push(charset[index]);
|
||||
}
|
||||
|
||||
return randomString.join('');
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 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';
|
||||
export type { NopyOptions, NopyResult } from './nopy.main.js';
|
||||
|
||||
// Executor
|
||||
export {
|
||||
executeDeployCalls,
|
||||
outputExecutionPlan,
|
||||
summarizeResults,
|
||||
} from './nopy.executor.js';
|
||||
export type {
|
||||
DeployCall,
|
||||
ExecutionResult,
|
||||
ExecutionOptions,
|
||||
} from './nopy.executor.js';
|
||||
|
||||
// Workflow
|
||||
export {
|
||||
runWorkflow,
|
||||
runInteractiveWorkflow,
|
||||
runReplayWorkflow,
|
||||
runSessionReplayWorkflow,
|
||||
} 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';
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/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: { session: import('./nopy.session.js').NopySession } | undefined;
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Environment variable configuration
|
||||
*/
|
||||
export type TVariables = Record<string, string | number | boolean>;
|
||||
|
||||
export namespace Variables {
|
||||
export type ArtefactId = string;
|
||||
export type Scope = 'defaults' | 'prompts' | 'params';
|
||||
}
|
||||
|
||||
export class Variables {
|
||||
/** @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(readonly global: TVariables = {}) {}
|
||||
|
||||
assign(artefactId: Variables.ArtefactId, scope: Variables.Scope, values: TVariables = {}) {
|
||||
console.log('Assigning', artefactId, scope, values);
|
||||
if (!this[scope][artefactId]) {
|
||||
this[scope][artefactId] = values;
|
||||
} else {
|
||||
Object.assign(this[scope][artefactId], values);
|
||||
}
|
||||
}
|
||||
|
||||
get(artefactId: Variables.ArtefactId, scope?: Variables.Scope): TVariables {
|
||||
if (scope) {
|
||||
return this[scope][artefactId] || {};
|
||||
}
|
||||
return {
|
||||
...this.global,
|
||||
...this.defaults[artefactId],
|
||||
...this.prompts[artefactId],
|
||||
...this.params[artefactId],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Configuration loading and management
|
||||
* @module nopy.config
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: NopyConfig = {
|
||||
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(): string[] {
|
||||
const configPaths: string[] = [];
|
||||
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: unknown,
|
||||
childValue: unknown,
|
||||
strategy: ResolutionStrategy
|
||||
): unknown {
|
||||
// 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: Record<string, unknown> = { ...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: string): boolean {
|
||||
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: unknown, configDir: string): unknown {
|
||||
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: Record<string, unknown> = {};
|
||||
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: (keyof NopyConfig)[] = ['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: NopyConfigFile, configPath: string): NopyConfigFile {
|
||||
const configDir = path.dirname(configPath);
|
||||
const resolved: NopyConfigFile = {};
|
||||
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (key === 'resolution') {
|
||||
// Don't resolve the resolution config itself
|
||||
resolved[key] = value as ResolutionConfig;
|
||||
} else if (PATH_PROPERTIES.includes(key as keyof NopyConfig)) {
|
||||
// Only resolve paths for known path properties
|
||||
resolved[key as keyof NopyConfigFile] = resolveRelativePaths(value, configDir) as any;
|
||||
} else {
|
||||
// Copy other properties as-is (including hosts)
|
||||
resolved[key as keyof NopyConfigFile] = value as any;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a child config into a parent config
|
||||
*/
|
||||
function mergeConfigs(parent: NopyConfig, childFile: NopyConfigFile): NopyConfig {
|
||||
const resolution = childFile.resolution || {};
|
||||
const result: Record<string, unknown> = { ...parent };
|
||||
|
||||
for (const [key, value] of Object.entries(childFile)) {
|
||||
if (key === 'resolution') continue; // Skip resolution property itself
|
||||
|
||||
const strategy = resolution[key as keyof NopyConfig] || 'merge';
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, strategy);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result as unknown as NopyConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(): NopyConfig {
|
||||
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: NopyConfig = { ...DEFAULT_CONFIG };
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const rawConfig = JSON.parse(content) as NopyConfigFile;
|
||||
// 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(): string[] {
|
||||
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: Partial<NopyConfig>, configPath?: string): void {
|
||||
const savePath = configPath || path.resolve(process.cwd(), CONFIG_FILENAME);
|
||||
|
||||
// Try to load existing config from this specific file
|
||||
let existing: Partial<NopyConfig> = {};
|
||||
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?: LogConfig): string[] {
|
||||
const flags: string[] = [];
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
|
||||
// Re-export types for direct access
|
||||
export type {
|
||||
Hook,
|
||||
HookContext,
|
||||
Cube,
|
||||
Manifest,
|
||||
LoadResult,
|
||||
CubeVariables,
|
||||
} from './cubes/index.js';
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Pyinfra command execution
|
||||
* @module nopy.executor
|
||||
*/
|
||||
|
||||
import { getLogger } from '@logtape/logtape';
|
||||
import { execa } from 'execa';
|
||||
import type { DependencySpec } from './cubes/types.js';
|
||||
|
||||
const log = getLogger(['nopy', 'executor']);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a single deployment call
|
||||
*
|
||||
* @param call - The deployment call to execute
|
||||
* @returns Execution result
|
||||
*/
|
||||
async function executeCall(call: DeployCall): Promise<ExecutionResult> {
|
||||
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: DeployCall[], asJson?: boolean): void {
|
||||
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: DeployCall[],
|
||||
options: ExecutionOptions = {}
|
||||
): Promise<ExecutionResult[]> {
|
||||
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: ExecutionResult[] = [];
|
||||
|
||||
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: ExecutionResult[]): {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
totalDuration: number;
|
||||
failures: ExecutionResult[];
|
||||
} {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Session history management
|
||||
* @module nopy.history
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { NopySession } from './nopy.session.js';
|
||||
|
||||
/** Default number of sessions to keep in history */
|
||||
export const DEFAULT_HISTORY_SIZE = 10;
|
||||
|
||||
/** History file name */
|
||||
export 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 function getHistoryPath(): string {
|
||||
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(): SessionHistory {
|
||||
const historyPath = getHistoryPath();
|
||||
|
||||
if (!fs.existsSync(historyPath)) {
|
||||
return { entries: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(historyPath, 'utf-8');
|
||||
return JSON.parse(content) as SessionHistory;
|
||||
} catch {
|
||||
return { entries: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the session history to disk
|
||||
*
|
||||
* @param history - The history to save
|
||||
*/
|
||||
export function saveHistory(history: SessionHistory): void {
|
||||
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: NopySession, timestamp: string): string {
|
||||
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(): string {
|
||||
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: NopySession,
|
||||
maxEntries: number = DEFAULT_HISTORY_SIZE
|
||||
): HistoryEntry {
|
||||
const history = loadHistory();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const entry: HistoryEntry = {
|
||||
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(): HistoryEntry | undefined {
|
||||
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: string): HistoryEntry | undefined {
|
||||
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(): HistoryEntry[] {
|
||||
const history = loadHistory();
|
||||
return history.entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all session history
|
||||
*/
|
||||
export function clearHistory(): void {
|
||||
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: string): boolean {
|
||||
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: HistoryEntry[]): string {
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Main entry point for nopy
|
||||
* @module nopy.main
|
||||
*/
|
||||
|
||||
import { type LogRecord, 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 { type ExecutionResult, executeDeployCalls, summarizeResults } from './nopy.executor.js';
|
||||
import { DEFAULT_HISTORY_SIZE, addToHistory } from './nopy.history.js';
|
||||
import { type NopySession, saveSession } from './nopy.session.js';
|
||||
import { runWorkflow } from './nopy.workflow.js';
|
||||
|
||||
/**
|
||||
* Configures the logtape logger for console output
|
||||
*/
|
||||
function configureLogtape(): void {
|
||||
configure({
|
||||
sinks: {
|
||||
console: (() => {
|
||||
const formatter = getAnsiColorFormatter();
|
||||
return (record: LogRecord) => {
|
||||
const formatted = formatter(record);
|
||||
if (typeof formatted === 'string') {
|
||||
const msg = formatted.replace(/\r?\n$/, '');
|
||||
const props = record.properties as Record<string, unknown>;
|
||||
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: import('./nopy.config.js').NopyConfig, opts: { continueOnError: boolean }): void {
|
||||
const configPaths = getConfigPaths();
|
||||
const cwd = process.cwd();
|
||||
|
||||
const lines: string[] = [''];
|
||||
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'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 async function nopy(opts: NopyOptions = {}): Promise<NopyResult | undefined> {
|
||||
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: NopySession = {
|
||||
...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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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';
|
||||
import type { 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 {
|
||||
name: string;
|
||||
value: string;
|
||||
short: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to select cubes to execute with filtering support
|
||||
*/
|
||||
export async function CubeSelection(
|
||||
cubes: Record<string, Cube>
|
||||
): Promise<{ selectedCubes: string[] }> {
|
||||
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,
|
||||
}));
|
||||
|
||||
// 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: 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));
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return { selectedCubes: answers.selectedCubes };
|
||||
}
|
||||
|
||||
export async function AuthSelection(useAuthKey?: boolean): Promise<{
|
||||
authMethod: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}> {
|
||||
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 as { authMethod: string; username?: string; password?: string };
|
||||
}
|
||||
|
||||
export async function PasswordSelection(username: string): Promise<string> {
|
||||
const { password } = await inquirer.prompt([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: `Enter password for ${username}:`,
|
||||
},
|
||||
]);
|
||||
return password;
|
||||
}
|
||||
|
||||
export async function HostSelection(hosts: string[]): Promise<string> {
|
||||
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: unknown, zodType: z.ZodTypeAny): 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);
|
||||
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;
|
||||
}
|
||||
|
||||
interface FormChoice {
|
||||
name: string;
|
||||
message: string;
|
||||
initial: string;
|
||||
}
|
||||
|
||||
export async function VariableAssignment<S extends z.AnyZodObject>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(variablesToConfigure).length === 0) return;
|
||||
|
||||
const choices: FormChoice[] = 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 as any).Form({
|
||||
name: 'variables',
|
||||
message: `[${cube.id}] ${cube.name}\n (↑↓ navigate, Enter to submit)`,
|
||||
choices,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await form.run();
|
||||
const coercedResult: Record<string, any> = {};
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Session management for saving and replaying deployments
|
||||
* @module nopy.session
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
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;
|
||||
// Note: password is intentionally excluded for security
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 function saveSession(session: NopySession, filePath: string): void {
|
||||
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: string): Promise<NopySession> {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const fileUrl = `file://${absolutePath}`;
|
||||
|
||||
try {
|
||||
const module = (await import(fileUrl)) as { default?: NopySession };
|
||||
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: string): NopySession {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content) as NopySession;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: string): Promise<NopySession> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Session file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath);
|
||||
let session: NopySession;
|
||||
|
||||
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: string = process.cwd()): string[] {
|
||||
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: {
|
||||
name?: string;
|
||||
cubes: CubeSession[];
|
||||
hosts: string[];
|
||||
auth: AuthSession;
|
||||
env?: TVariables;
|
||||
}): NopySession {
|
||||
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: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const internalKeys = ['customize'];
|
||||
const filtered: Record<string, unknown> = {};
|
||||
|
||||
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: Record<string, unknown>,
|
||||
envVariables: Record<string, unknown>
|
||||
): { env: Record<string, unknown>; cubeVars: Record<string, unknown> } {
|
||||
const env: Record<string, unknown> = {};
|
||||
const cubeVars: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(allVariables)) {
|
||||
if (key in envVariables) {
|
||||
env[key] = value;
|
||||
} else {
|
||||
cubeVars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { env, cubeVars };
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Workflow logic for interactive and replay modes
|
||||
* @module nopy.workflow
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
const log = getLogger(['nopy', 'workflow']);
|
||||
|
||||
/**
|
||||
* 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 async function runInteractiveWorkflow(
|
||||
cubes: Record<string, Cube>,
|
||||
config: NopyConfig,
|
||||
options: WorkflowOptions = {}
|
||||
): Promise<WorkflowResult> {
|
||||
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' as const, username: undefined, password: undefined }
|
||||
: await AuthSelection(useAuthKey);
|
||||
|
||||
// Create session
|
||||
const session = createSession({
|
||||
cubes: [], // Will be populated during build
|
||||
hosts: [host],
|
||||
auth: {
|
||||
method: authResult.authMethod as AuthSession['method'],
|
||||
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: string,
|
||||
cubes: Record<string, Cube>,
|
||||
config: NopyConfig
|
||||
): Promise<WorkflowResult> {
|
||||
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: string | undefined;
|
||||
|
||||
// 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 as AuthSession['method'];
|
||||
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: NopySession,
|
||||
cubes: Record<string, Cube>,
|
||||
config: NopyConfig
|
||||
): Promise<WorkflowResult> {
|
||||
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: string | undefined;
|
||||
|
||||
// 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 as AuthSession['method'];
|
||||
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: string | undefined,
|
||||
cubes: Record<string, Cube>,
|
||||
config: NopyConfig,
|
||||
options: WorkflowOptions = {},
|
||||
replaySession?: NopySession
|
||||
): Promise<WorkflowResult> {
|
||||
if (replaySession) {
|
||||
return runSessionReplayWorkflow(replaySession, cubes, config);
|
||||
}
|
||||
if (sessionPath) {
|
||||
return runReplayWorkflow(sessionPath, cubes, config);
|
||||
}
|
||||
return runInteractiveWorkflow(cubes, config, options);
|
||||
}
|
||||
Reference in New Issue
Block a user