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