initial transfer
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
# Keyman - SSH Key Management with Age Encryption
|
||||
|
||||
Keyman is a simple command line tool built around the `age` encryption tool. It allows you to manage SSH keys in public GitHub repositories securely by encrypting the private keys.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 Encrypt SSH private keys with age encryption
|
||||
- 📁 Organized vault structure: `vault/keys/` for encrypted keys, `vault/tmp/` for decrypted keys
|
||||
- ⚙️ Configurable via `.keymanrc.json` with sensible defaults
|
||||
- 🔍 Interactive CLI for encrypting, decrypting, and listing keys
|
||||
- 🔄 Support for key rotation
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Generate Age Encryption Key
|
||||
|
||||
```bash
|
||||
# Create vault structure
|
||||
mkdir -p vault/keys vault/tmp
|
||||
|
||||
# Generate age encryption key (keep this secret!)
|
||||
age-keygen -o vault/age.key
|
||||
|
||||
# Add to .gitignore
|
||||
echo "vault/age.key" >> .gitignore
|
||||
echo "vault/tmp/" >> .gitignore
|
||||
```
|
||||
|
||||
### 2. Generate SSH Keys
|
||||
|
||||
```bash
|
||||
# Generate SSH key pair
|
||||
ssh-keygen -t ed25519 -f vault/tmp/id_deploy -N "" -C "deploy@myapp.dev"
|
||||
```
|
||||
|
||||
### 3. Run Keyman
|
||||
|
||||
```bash
|
||||
# Run keyman interactively
|
||||
VAULT_ROOT=./vault keyman
|
||||
|
||||
# Or if you have .keymanrc.json configured, just run:
|
||||
keyman
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Keyman uses sensible defaults but can be customized via `.keymanrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"vaultRoot": "./vault",
|
||||
"keysDir": "keys",
|
||||
"tmpDir": "tmp",
|
||||
"ageKeyFile": "age.key"
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Priority
|
||||
|
||||
1. **VAULT_ROOT** environment variable (highest priority)
|
||||
2. **.keymanrc.json** file (searched from current directory upward)
|
||||
3. **Default values** (lowest priority)
|
||||
|
||||
### Default Values
|
||||
|
||||
- `vaultRoot`: `"vault"`
|
||||
- `keysDir`: `"keys"`
|
||||
- `tmpDir`: `"tmp"`
|
||||
- `ageKeyFile`: `"age.key"`
|
||||
|
||||
## Vault Structure
|
||||
|
||||
```
|
||||
project/
|
||||
├── vault/
|
||||
│ ├── age.key # Master encryption key (NEVER commit!)
|
||||
│ ├── keys/ # Encrypted keys (safe to commit)
|
||||
│ │ └── deploy/ # Each key has its own folder
|
||||
│ │ ├── id_deploy.pub # Public key
|
||||
│ │ └── id_deploy.age # Encrypted private key
|
||||
│ └── tmp/ # Decrypted keys (NEVER commit!)
|
||||
│ ├── id_deploy # Decrypted private key
|
||||
│ └── id_deploy.pub # Public key
|
||||
└── .keymanrc.json # Configuration (optional)
|
||||
```
|
||||
|
||||
## Operations
|
||||
|
||||
Keyman provides an interactive menu-driven interface with the following operations:
|
||||
|
||||
- **📋 List keys** - Compact view showing all keys with checkbox indicators for their locations
|
||||
- **🔒 Encrypt keys** - Encrypt SSH keys from `vault/tmp/` and store in `vault/keys/`
|
||||
- **🔓 Decrypt keys** - Decrypt keys from `vault/keys/` to `vault/tmp/` or `~/.ssh/`
|
||||
- **❌ Quit** - Exit the program
|
||||
|
||||
After completing any operation, keyman automatically returns to the main menu, allowing you to perform multiple operations in a single session without restarting the tool.
|
||||
|
||||
### List Keys Output
|
||||
|
||||
The list command shows a compact, unified view of all SSH keys with their locations:
|
||||
|
||||
```
|
||||
🔑 SSH Keys:
|
||||
|
||||
Key Name [Vault] [Tmp] [.ssh]
|
||||
──────────────────────────────────────────────────────────
|
||||
✅ id_deploy (.pub) [✓] [ ] [✓]
|
||||
🔓 id_github (.pub) [✓] [✓] [ ]
|
||||
🔒 id_backup (.pub) [✓] [ ] [ ]
|
||||
⚠️ id_local (.pub) [ ] [ ] [✓]
|
||||
|
||||
Legend:
|
||||
✅ = Managed (encrypted in vault + active in .ssh)
|
||||
🔓 = Decrypted (in vault + decrypted to tmp)
|
||||
🔒 = Encrypted only (in vault, not decrypted)
|
||||
⚠️ = Unmanaged (in .ssh or tmp, not encrypted in vault)
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Public keys are indicated with `(.pub)` suffix instead of separate entries
|
||||
- Status emoji shows management state at a glance
|
||||
- Checkboxes `[✓]` show presence in three locations:
|
||||
- **[Vault]** - Encrypted in vault/keys/
|
||||
- **[Tmp]** - Decrypted in vault/tmp/
|
||||
- **[.ssh]** - Active in ~/.ssh/
|
||||
- Alphabetically sorted for easy scanning
|
||||
- New **🔓** status for keys decrypted to tmp but not yet in .ssh
|
||||
|
||||
## Example Usage
|
||||
|
||||
```bash
|
||||
# Using environment variable
|
||||
VAULT_ROOT=../../vault keyman
|
||||
|
||||
# Using default configuration
|
||||
keyman
|
||||
|
||||
# Keyman will show:
|
||||
# 📁 Vault Root: /path/to/vault
|
||||
# 🔑 Keys Directory: /path/to/vault/keys
|
||||
# 📂 Temp Directory: /path/to/vault/tmp
|
||||
# 🔐 Age Key: /path/to/vault/age.key
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Never commit** `vault/age.key` or `vault/tmp/` to version control
|
||||
2. **Always backup** your `age.key` securely (password manager, encrypted USB)
|
||||
3. **Commit** `vault/keys/` - encrypted keys are safe to share
|
||||
4. **Use environment variables** for CI/CD: `VAULT_ROOT=/path/to/vault keyman`
|
||||
5. **Keep .keymanrc.json** in your project root for team consistency
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
export * from './keyman.main.js';
|
||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
export * from './keyman.main.js';
|
||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
export {};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
import { keyman } from './keyman.main.js';
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--print-config')) {
|
||||
const config = loadConfig();
|
||||
const paths = resolveConfigPaths(config);
|
||||
console.log(JSON.stringify(paths));
|
||||
process.exit(0);
|
||||
}
|
||||
keyman();
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { z } from 'zod';
|
||||
/**
|
||||
* Configuration schema for keyman
|
||||
*/
|
||||
declare const KeymanConfigSchema: z.ZodObject<{
|
||||
vaultRoot: z.ZodDefault<z.ZodString>;
|
||||
keysDir: z.ZodDefault<z.ZodString>;
|
||||
tmpDir: z.ZodDefault<z.ZodString>;
|
||||
ageKeyFile: z.ZodDefault<z.ZodString>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
vaultRoot: string;
|
||||
keysDir: string;
|
||||
tmpDir: string;
|
||||
ageKeyFile: string;
|
||||
}, {
|
||||
vaultRoot?: string | undefined;
|
||||
keysDir?: string | undefined;
|
||||
tmpDir?: string | undefined;
|
||||
ageKeyFile?: string | undefined;
|
||||
}>;
|
||||
export type KeymanConfig = z.infer<typeof KeymanConfigSchema>;
|
||||
/**
|
||||
* 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 KeymanResolutionConfig = {
|
||||
[K in keyof KeymanConfig]?: ResolutionStrategy;
|
||||
};
|
||||
/**
|
||||
* Raw config file structure (includes resolution)
|
||||
*/
|
||||
export interface KeymanConfigFile extends Partial<KeymanConfig> {
|
||||
/** Customize merge behavior for specific properties */
|
||||
resolution?: KeymanResolutionConfig;
|
||||
}
|
||||
/**
|
||||
* Loads configuration from .keymanrc.json files
|
||||
*
|
||||
* Searches for `.keymanrc.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
|
||||
* {
|
||||
* "vaultRoot": "../vault",
|
||||
* "resolution": {
|
||||
* "vaultRoot": "override"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns Validated keyman configuration
|
||||
*/
|
||||
export declare function loadConfig(): KeymanConfig;
|
||||
/**
|
||||
* Resolves configuration paths relative to VAULT_ROOT or current directory
|
||||
* @param config The keyman configuration
|
||||
* @returns Resolved absolute paths
|
||||
*/
|
||||
export declare function resolveConfigPaths(config: KeymanConfig): {
|
||||
vaultRoot: string;
|
||||
keysDir: string;
|
||||
tmpDir: string;
|
||||
keyPath: string;
|
||||
};
|
||||
/**
|
||||
* Gets the paths of all discovered config files (for debugging)
|
||||
* @returns Array of paths to .keymanrc.json files, ordered from root to cwd
|
||||
*/
|
||||
export declare function getConfigPaths(): string[];
|
||||
export {};
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
/**
|
||||
* Configuration schema for keyman
|
||||
*/
|
||||
const KeymanConfigSchema = z.object({
|
||||
vaultRoot: z.string().default('vault'),
|
||||
keysDir: z.string().default('keys'),
|
||||
tmpDir: z.string().default('tmp'),
|
||||
ageKeyFile: z.string().default('age.key'),
|
||||
});
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
const DEFAULT_CONFIG = {
|
||||
vaultRoot: 'vault',
|
||||
keysDir: 'keys',
|
||||
tmpDir: 'tmp',
|
||||
ageKeyFile: 'age.key',
|
||||
};
|
||||
const CONFIG_FILENAME = '.keymanrc.json';
|
||||
/**
|
||||
* Path-based properties that should be resolved relative to config file location
|
||||
*/
|
||||
const PATH_PROPERTIES = ['vaultRoot'];
|
||||
/**
|
||||
* Resolves path properties in a config object relative to the config file's directory
|
||||
* @param configFile The raw config file contents
|
||||
* @param configDir Directory containing the config file
|
||||
* @returns Config with path properties resolved to absolute paths
|
||||
*/
|
||||
function resolvePathsRelativeToConfig(configFile, configDir) {
|
||||
const resolved = { ...configFile };
|
||||
for (const prop of PATH_PROPERTIES) {
|
||||
const value = configFile[prop];
|
||||
if (typeof value === 'string' && !path.isAbsolute(value)) {
|
||||
resolved[prop] = path.resolve(configDir, value);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
/**
|
||||
* Finds all config files by traversing upwards from cwd to root
|
||||
* Returns configs in order from root to cwd (parent first, child last)
|
||||
* @param startDir Directory to start searching from
|
||||
* @returns Array of paths to .keymanrc.json files
|
||||
*/
|
||||
function findConfigFiles(startDir) {
|
||||
const configPaths = [];
|
||||
let currentDir = startDir;
|
||||
// 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(os.homedir(), CONFIG_FILENAME);
|
||||
if (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;
|
||||
}
|
||||
/**
|
||||
* 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 configuration from .keymanrc.json files
|
||||
*
|
||||
* Searches for `.keymanrc.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
|
||||
* {
|
||||
* "vaultRoot": "../vault",
|
||||
* "resolution": {
|
||||
* "vaultRoot": "override"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns Validated keyman configuration
|
||||
*/
|
||||
export function loadConfig() {
|
||||
const startDir = process.cwd();
|
||||
const configPaths = findConfigFiles(startDir);
|
||||
if (configPaths.length === 0) {
|
||||
console.error('ℹ️ No .keymanrc.json found, using default configuration');
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
// 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 path properties relative to the config file's directory
|
||||
const configDir = path.dirname(configPath);
|
||||
const resolvedConfig = resolvePathsRelativeToConfig(rawConfig, configDir);
|
||||
config = mergeConfigs(config, resolvedConfig);
|
||||
console.error(`✅ Loaded configuration from ${configPath}`);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
console.warn(`⚠️ Skipping invalid JSON in ${configPath}: ${error.message}`);
|
||||
}
|
||||
else {
|
||||
console.warn(`⚠️ Skipping config ${configPath}: ${error}`);
|
||||
}
|
||||
// Continue with other configs instead of failing entirely
|
||||
}
|
||||
}
|
||||
// Validate the final merged result
|
||||
try {
|
||||
return KeymanConfigSchema.parse(config);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error('❌ ERROR: Invalid merged configuration:');
|
||||
error.errors.forEach((err) => {
|
||||
console.error(` - ${err.path.join('.')}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
console.error('ℹ️ Falling back to default configuration');
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Resolves configuration paths relative to VAULT_ROOT or current directory
|
||||
* @param config The keyman configuration
|
||||
* @returns Resolved absolute paths
|
||||
*/
|
||||
export function resolveConfigPaths(config) {
|
||||
// VAULT_ROOT environment variable takes precedence
|
||||
const vaultRoot = path.resolve(process.env.VAULT_ROOT ?? config.vaultRoot);
|
||||
return {
|
||||
vaultRoot,
|
||||
keysDir: path.resolve(vaultRoot, config.keysDir),
|
||||
tmpDir: path.resolve(vaultRoot, config.tmpDir),
|
||||
keyPath: path.resolve(vaultRoot, config.ageKeyFile),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Gets the paths of all discovered config files (for debugging)
|
||||
* @returns Array of paths to .keymanrc.json files, ordered from root to cwd
|
||||
*/
|
||||
export function getConfigPaths() {
|
||||
return findConfigFiles(process.cwd());
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function copyKey(sshDir: string, tmpDir: string): Promise<void>;
|
||||
Vendored
+50
@@ -0,0 +1,50 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
export async function copyKey(sshDir, tmpDir) {
|
||||
const getKeys = (dir) => {
|
||||
if (!fs.existsSync(dir))
|
||||
return [];
|
||||
return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
||||
};
|
||||
const sshKeys = getKeys(sshDir);
|
||||
const tmpKeys = getKeys(tmpDir);
|
||||
const keys = [...new Set([...sshKeys, ...tmpKeys])];
|
||||
if (keys.length === 0) {
|
||||
console.log('⚠️ No SSH keys found.');
|
||||
return;
|
||||
}
|
||||
const { selectedKey } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'selectedKey',
|
||||
message: 'Select key to copy public key from:',
|
||||
choices: keys,
|
||||
},
|
||||
]);
|
||||
// Determine location of the public key
|
||||
// Prefer tmpDir if it exists there, otherwise sshDir
|
||||
let pubKeyPath = path.join(tmpDir, `${selectedKey}.pub`);
|
||||
if (!fs.existsSync(pubKeyPath)) {
|
||||
pubKeyPath = path.join(sshDir, `${selectedKey}.pub`);
|
||||
}
|
||||
if (!fs.existsSync(pubKeyPath)) {
|
||||
console.error(`❌ Public key not found for ${selectedKey}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pubKeyContent = fs.readFileSync(pubKeyPath, 'utf-8').trim();
|
||||
// Detect OS and use appropriate clipboard command
|
||||
// Since the environment is Darwin, we prioritize pbcopy, but we can add others for completeness or use a simple check.
|
||||
// For this specific request on Darwin:
|
||||
const proc = execa('pbcopy');
|
||||
proc.stdin?.write(pubKeyContent);
|
||||
proc.stdin?.end();
|
||||
await proc;
|
||||
console.log(`✅ Public key for ${selectedKey} copied to clipboard!`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`❌ Failed to copy to clipboard: ${error}`);
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function decryptKeys(sshDir: string, vaultDir: string, ageKey: string): Promise<void>;
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
export async function decryptKeys(sshDir, vaultDir, ageKey) {
|
||||
const keyDir = path.join(vaultDir, 'keys');
|
||||
const vaultKeys = fs.readdirSync(keyDir).filter((key) => {
|
||||
const keyfile = path.join(keyDir, key, `id_${key}.age`);
|
||||
console.log(keyfile);
|
||||
return fs.existsSync(keyfile);
|
||||
});
|
||||
if (vaultKeys.length === 0) {
|
||||
console.log('⚠️ No encrypted keys found.');
|
||||
return;
|
||||
}
|
||||
const { selectedKeys, decryptMode } = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'selectedKeys',
|
||||
message: 'Select keys to decrypt:',
|
||||
choices: vaultKeys,
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
name: 'decryptMode',
|
||||
message: 'Choose decryption location:',
|
||||
choices: ['Local (vault/tmp)', 'SSH (~/.ssh)'],
|
||||
},
|
||||
]);
|
||||
for (const key of selectedKeys) {
|
||||
const encryptedKey = path.join(keyDir, key, `id_${key}.age`);
|
||||
const publicKey = path.join(keyDir, key, `id_${key}.pub`);
|
||||
const privateKeyOut = decryptMode === 'Local (vault/tmp)'
|
||||
? path.join(vaultDir, 'tmp', `id_${key}`)
|
||||
: path.join(sshDir, `id_${key}`);
|
||||
const publicKeyOut = decryptMode === 'Local (vault/tmp)'
|
||||
? path.join(vaultDir, 'tmp', `id_${key}.pub`)
|
||||
: path.join(sshDir, `id_${key}.pub`);
|
||||
// Decrypt key
|
||||
await execa('age', ['-d', '-i', ageKey, '-o', privateKeyOut, encryptedKey]);
|
||||
await execa('cp', [publicKey, publicKeyOut]);
|
||||
await execa('chmod', ['600', privateKeyOut]);
|
||||
console.log(`✅ Decrypted: ${privateKeyOut}`);
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function encryptKeys(sshDir: string, vaultDir: string, tmpDir: string, pubkey: string): Promise<void>;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
export async function encryptKeys(sshDir, vaultDir, tmpDir, pubkey) {
|
||||
const sshKeys = fs
|
||||
.readdirSync(sshDir)
|
||||
.filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
||||
const tmpKeys = fs
|
||||
.readdirSync(tmpDir)
|
||||
.filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
||||
console.log(tmpKeys);
|
||||
console.log(sshKeys);
|
||||
const keys = [...new Set([...sshKeys, ...tmpKeys])];
|
||||
if (keys.length === 0) {
|
||||
console.log('⚠️ No private SSH keys found to encrypt.');
|
||||
return;
|
||||
}
|
||||
const { selectedKeys } = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'selectedKeys',
|
||||
message: 'Select SSH keys to encrypt:',
|
||||
choices: keys,
|
||||
},
|
||||
]);
|
||||
for (const key of selectedKeys) {
|
||||
const keyPath = path.join(tmpKeys.includes(key) ? tmpDir : sshDir, key);
|
||||
const vaultPath = path.join(vaultDir, 'keys', key.replace('id_', ''));
|
||||
fs.mkdirSync(vaultPath, { recursive: true });
|
||||
// Encrypt key using `age`
|
||||
await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${key}.age`), keyPath]);
|
||||
// Copy public key and create README
|
||||
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${key}.pub`));
|
||||
console.log(`🔒 Encrypted and stored: ${vaultPath}/${key}`);
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function generateKey(tmpDir: string, keysDir: string, pubkey: string): Promise<void>;
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
export async function generateKey(tmpDir, keysDir, pubkey) {
|
||||
const { algorithm } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'algorithm',
|
||||
message: 'Select algorithm:',
|
||||
choices: ['ed25519', 'rsa'],
|
||||
default: 'ed25519',
|
||||
},
|
||||
]);
|
||||
const { keyName } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'keyName',
|
||||
message: 'Enter key name:',
|
||||
validate: (input) => (input.trim() !== '' ? true : 'Key name cannot be empty'),
|
||||
},
|
||||
]);
|
||||
const { password } = await inquirer.prompt([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: 'Enter passphrase (leave empty for no passphrase):',
|
||||
mask: '*',
|
||||
},
|
||||
]);
|
||||
const { identity } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'identity',
|
||||
message: 'Enter key identity (comment):',
|
||||
},
|
||||
]);
|
||||
const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`;
|
||||
const keyPath = path.join(tmpDir, fileName);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
console.error(`❌ Error: Key file ${fileName} already exists in ${tmpDir}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
console.log(`Generating ${algorithm} key pair...`);
|
||||
const args = ['-t', algorithm, '-f', keyPath, '-N', password, '-C', identity];
|
||||
if (algorithm === 'rsa') {
|
||||
args.push('-b', '4096');
|
||||
}
|
||||
await execa('ssh-keygen', args);
|
||||
console.log(`✅ Key generated: ${keyPath}`);
|
||||
// Encrypt the key
|
||||
const folderName = fileName.replace('id_', '');
|
||||
const vaultPath = path.join(keysDir, folderName);
|
||||
fs.mkdirSync(vaultPath, { recursive: true });
|
||||
// Encrypt key using `age`
|
||||
await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${fileName}.age`), keyPath]);
|
||||
// Copy public key
|
||||
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${fileName}.pub`));
|
||||
console.log(`🔒 Encrypted and stored: ${vaultPath}`);
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`❌ Error generating/encrypting key: ${error}`);
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function listKeys(sshDir: string, vaultDir: string, tmpDir: string): Promise<void>;
|
||||
Vendored
+115
@@ -0,0 +1,115 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
export async function listKeys(sshDir, vaultDir, tmpDir) {
|
||||
console.log('\n📂 Checking keys in:');
|
||||
console.log(` SSH: ${sshDir}`);
|
||||
console.log(` Vault: ${vaultDir}`);
|
||||
console.log(` Tmp: ${tmpDir}\n`);
|
||||
const keyMap = new Map();
|
||||
// Scan SSH directory
|
||||
if (fs.existsSync(sshDir)) {
|
||||
const sshFiles = fs.readdirSync(sshDir).filter((file) => file.startsWith('id_'));
|
||||
for (const file of sshFiles) {
|
||||
const keyName = file.replace(/\.pub$/, '');
|
||||
const isPub = file.endsWith('.pub');
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: !isPub,
|
||||
hasSshPub: isPub,
|
||||
inVault: false,
|
||||
inTmp: false,
|
||||
hasTmpPub: false,
|
||||
});
|
||||
}
|
||||
else {
|
||||
const key = keyMap.get(keyName);
|
||||
if (isPub) {
|
||||
key.hasSshPub = true;
|
||||
}
|
||||
else {
|
||||
key.inSsh = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scan tmp directory
|
||||
if (fs.existsSync(tmpDir)) {
|
||||
const tmpFiles = fs.readdirSync(tmpDir).filter((file) => file.startsWith('id_'));
|
||||
for (const file of tmpFiles) {
|
||||
const keyName = file.replace(/\.pub$/, '');
|
||||
const isPub = file.endsWith('.pub');
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: false,
|
||||
hasSshPub: false,
|
||||
inVault: false,
|
||||
inTmp: !isPub,
|
||||
hasTmpPub: isPub,
|
||||
});
|
||||
}
|
||||
else {
|
||||
const key = keyMap.get(keyName);
|
||||
if (isPub) {
|
||||
key.hasTmpPub = true;
|
||||
}
|
||||
else {
|
||||
key.inTmp = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scan vault directory
|
||||
if (fs.existsSync(vaultDir)) {
|
||||
const vaultDirs = fs.readdirSync(vaultDir).filter((dir) => {
|
||||
const stat = fs.statSync(path.join(vaultDir, dir));
|
||||
return stat.isDirectory();
|
||||
});
|
||||
for (const dir of vaultDirs) {
|
||||
const keyName = `id_${dir}`;
|
||||
const encryptedPath = path.join(vaultDir, dir, `${keyName}.age`);
|
||||
if (fs.existsSync(encryptedPath)) {
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: false,
|
||||
hasSshPub: false,
|
||||
inVault: true,
|
||||
inTmp: false,
|
||||
hasTmpPub: false,
|
||||
});
|
||||
}
|
||||
else {
|
||||
keyMap.get(keyName).inVault = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Display results
|
||||
if (keyMap.size === 0) {
|
||||
console.log('⚠️ No SSH keys found.\n');
|
||||
return;
|
||||
}
|
||||
console.log('🔑 SSH Keys:\n');
|
||||
console.log(' Key Name [Vault] [Tmp] [.ssh]');
|
||||
console.log(` ${'─'.repeat(58)}`);
|
||||
const sortedKeys = Array.from(keyMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const key of sortedKeys) {
|
||||
const vaultMark = key.inVault ? '✓' : ' ';
|
||||
const tmpMark = key.inTmp ? '✓' : ' ';
|
||||
const sshMark = key.inSsh ? '✓' : ' ';
|
||||
// Show (.pub) if present in any location
|
||||
const hasPub = key.hasSshPub || key.hasTmpPub;
|
||||
const pubIndicator = hasPub ? ' (.pub)' : '';
|
||||
// Determine status
|
||||
const status = key.inVault && key.inSsh ? '✅' : key.inVault && key.inTmp ? '🔓' : key.inVault ? '🔒' : '⚠️ ';
|
||||
const namePart = `${key.name}${pubIndicator}`.padEnd(32);
|
||||
console.log(` ${status} ${namePart} [${vaultMark}] [${tmpMark}] [${sshMark}]`);
|
||||
}
|
||||
console.log('\n Legend:');
|
||||
console.log(' ✅ = Managed (encrypted in vault + active in .ssh)');
|
||||
console.log(' 🔓 = Decrypted (in vault + decrypted to tmp)');
|
||||
console.log(' 🔒 = Encrypted only (in vault, not decrypted)');
|
||||
console.log(' ⚠️ = Unmanaged (in .ssh or tmp, not encrypted in vault)\n');
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function keyman(): Promise<void>;
|
||||
Vendored
+79
@@ -0,0 +1,79 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import inquirer from 'inquirer';
|
||||
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
import { copyKey } from './keyman.copy.js';
|
||||
import { decryptKeys } from './keyman.decrypt.js';
|
||||
import { encryptKeys } from './keyman.encrypt.js';
|
||||
import { generateKey } from './keyman.generate.js';
|
||||
import { listKeys } from './keyman.list.js';
|
||||
import { extractAgePublicKey } from './keyman.utils.js';
|
||||
// 🔹 Main function to resolve paths and manage flow
|
||||
export async function keyman() {
|
||||
// Load configuration from .keymanrc.json or use defaults
|
||||
const config = loadConfig();
|
||||
const paths = resolveConfigPaths(config);
|
||||
console.log(`\n📁 Vault Root: ${paths.vaultRoot}`);
|
||||
console.log(`🔑 Keys Directory: ${paths.keysDir}`);
|
||||
console.log(`📂 Temp Directory: ${paths.tmpDir}`);
|
||||
console.log(`🔐 Age Key: ${paths.keyPath}\n`);
|
||||
// Get USER input
|
||||
const { user } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'user',
|
||||
message: 'Specify USER (default: @current):',
|
||||
default: '@current',
|
||||
},
|
||||
]);
|
||||
const homeDir = user === '@current' ? process.env.HOME || '' : `/home/${user}`;
|
||||
if (!homeDir) {
|
||||
console.error('Error: Unable to determine HOME directory.');
|
||||
process.exit(1);
|
||||
}
|
||||
const sshDir = path.join(homeDir, '.ssh');
|
||||
fs.mkdirSync(paths.vaultRoot, { recursive: true });
|
||||
fs.mkdirSync(paths.tmpDir, { recursive: true });
|
||||
// Main loop - keep showing menu until user quits
|
||||
let running = true;
|
||||
while (running) {
|
||||
console.log(`\n${'='.repeat(50)}`);
|
||||
// 🔹 Show category selection
|
||||
const { category } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'category',
|
||||
message: 'Select operation:',
|
||||
choices: [
|
||||
{ name: '📋 List keys', value: 'list' },
|
||||
{ name: '📝 Copy public key', value: 'copy' },
|
||||
{ name: '🆕 Generate key', value: 'generate' },
|
||||
{ name: '🔒 Encrypt keys', value: 'encrypt' },
|
||||
{ name: '🔓 Decrypt keys', value: 'decrypt' },
|
||||
{ name: '❌ Quit', value: 'quit' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
switch (category) {
|
||||
case 'list':
|
||||
await listKeys(sshDir, paths.keysDir, paths.tmpDir);
|
||||
break;
|
||||
case 'copy':
|
||||
await copyKey(sshDir, paths.tmpDir);
|
||||
break;
|
||||
case 'generate':
|
||||
await generateKey(paths.tmpDir, paths.keysDir, extractAgePublicKey(paths.keyPath));
|
||||
break;
|
||||
case 'encrypt':
|
||||
await encryptKeys(sshDir, paths.vaultRoot, paths.tmpDir, extractAgePublicKey(paths.keyPath));
|
||||
break;
|
||||
case 'decrypt':
|
||||
await decryptKeys(sshDir, paths.vaultRoot, paths.keyPath);
|
||||
break;
|
||||
case 'quit':
|
||||
console.log('\n👋 Goodbye!\n');
|
||||
running = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Extracts the public key from an age key file.
|
||||
* @param keyFilePath Path to the age key file.
|
||||
* @returns The public key as a string, or null if not found.
|
||||
*/
|
||||
export declare function extractAgePublicKey(keyFilePath: string): string | null;
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
import fs from 'node:fs';
|
||||
/**
|
||||
* Extracts the public key from an age key file.
|
||||
* @param keyFilePath Path to the age key file.
|
||||
* @returns The public key as a string, or null if not found.
|
||||
*/
|
||||
export function extractAgePublicKey(keyFilePath) {
|
||||
if (!fs.existsSync(keyFilePath)) {
|
||||
console.error(`❌ ERROR: Age key file not found at ${keyFilePath}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const fileContents = fs.readFileSync(keyFilePath, 'utf-8');
|
||||
const publicKeyMatch = fileContents.match(/^# public key:\s*(age1[^\s]+)/m);
|
||||
return publicKeyMatch ? publicKeyMatch[1] : null;
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`❌ ERROR: Failed to read key file - ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@bitstack/keyman",
|
||||
"description": "A system to simplify ssh key management",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"author": "bitsquare",
|
||||
"bin": "dist/keyman.cli.js",
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"build": "tsgo && chmod +x dist/keyman.cli.js && npm link",
|
||||
"build:legacy": "tsc && chmod +x dist/keyman.cli.js && npm link",
|
||||
"prepublishOnly": "npm run build",
|
||||
"keyman": "node --loader ts-node/esm src/keyman.bin.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=21.0.0"
|
||||
},
|
||||
"files": ["dist/"],
|
||||
"dependencies": {
|
||||
"execa": "9.5.2",
|
||||
"inquirer": "8.2.4",
|
||||
"ts-node": ">=10.9.1",
|
||||
"typed-dotenv": "10.0.2",
|
||||
"typescript": ">=5.6.3",
|
||||
"zod": "^3.24.1",
|
||||
"zx": "^8.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
export * from './keyman.main.js';
|
||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
import { keyman } from './keyman.main.js';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes('--print-config')) {
|
||||
const config = loadConfig();
|
||||
const paths = resolveConfigPaths(config);
|
||||
console.log(JSON.stringify(paths));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
keyman();
|
||||
@@ -0,0 +1,267 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Configuration schema for keyman
|
||||
*/
|
||||
const KeymanConfigSchema = z.object({
|
||||
vaultRoot: z.string().default('vault'),
|
||||
keysDir: z.string().default('keys'),
|
||||
tmpDir: z.string().default('tmp'),
|
||||
ageKeyFile: z.string().default('age.key'),
|
||||
});
|
||||
|
||||
export type KeymanConfig = z.infer<typeof KeymanConfigSchema>;
|
||||
|
||||
/**
|
||||
* 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 KeymanResolutionConfig = {
|
||||
[K in keyof KeymanConfig]?: ResolutionStrategy;
|
||||
};
|
||||
|
||||
/**
|
||||
* Raw config file structure (includes resolution)
|
||||
*/
|
||||
export interface KeymanConfigFile extends Partial<KeymanConfig> {
|
||||
/** Customize merge behavior for specific properties */
|
||||
resolution?: KeymanResolutionConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
const DEFAULT_CONFIG: KeymanConfig = {
|
||||
vaultRoot: 'vault',
|
||||
keysDir: 'keys',
|
||||
tmpDir: 'tmp',
|
||||
ageKeyFile: 'age.key',
|
||||
};
|
||||
|
||||
const CONFIG_FILENAME = '.keymanrc.json';
|
||||
|
||||
/**
|
||||
* Path-based properties that should be resolved relative to config file location
|
||||
*/
|
||||
const PATH_PROPERTIES: (keyof KeymanConfig)[] = ['vaultRoot'];
|
||||
|
||||
/**
|
||||
* Resolves path properties in a config object relative to the config file's directory
|
||||
* @param configFile The raw config file contents
|
||||
* @param configDir Directory containing the config file
|
||||
* @returns Config with path properties resolved to absolute paths
|
||||
*/
|
||||
function resolvePathsRelativeToConfig(
|
||||
configFile: KeymanConfigFile,
|
||||
configDir: string
|
||||
): KeymanConfigFile {
|
||||
const resolved = { ...configFile };
|
||||
|
||||
for (const prop of PATH_PROPERTIES) {
|
||||
const value = configFile[prop];
|
||||
if (typeof value === 'string' && !path.isAbsolute(value)) {
|
||||
resolved[prop] = path.resolve(configDir, value);
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all config files by traversing upwards from cwd to root
|
||||
* Returns configs in order from root to cwd (parent first, child last)
|
||||
* @param startDir Directory to start searching from
|
||||
* @returns Array of paths to .keymanrc.json files
|
||||
*/
|
||||
function findConfigFiles(startDir: string): string[] {
|
||||
const configPaths: string[] = [];
|
||||
let currentDir = startDir;
|
||||
|
||||
// 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(os.homedir(), CONFIG_FILENAME);
|
||||
if (fs.existsSync(homeConfig) && !configPaths.includes(homeConfig)) {
|
||||
configPaths.unshift(homeConfig);
|
||||
}
|
||||
|
||||
return configPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merges two values based on resolution strategy
|
||||
*/
|
||||
function mergeValue(
|
||||
parentValue: unknown,
|
||||
childValue: unknown,
|
||||
strategy: ResolutionStrategy
|
||||
): unknown {
|
||||
// Override strategy: child replaces parent completely
|
||||
if (strategy === 'override') {
|
||||
return childValue;
|
||||
}
|
||||
|
||||
// Merge strategy (default)
|
||||
if (Array.isArray(parentValue) && Array.isArray(childValue)) {
|
||||
// Concatenate arrays, remove duplicates for primitives
|
||||
const combined = [...parentValue, ...childValue];
|
||||
if (combined.every((v) => typeof v !== 'object')) {
|
||||
return [...new Set(combined)];
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parentValue === 'object' &&
|
||||
parentValue !== null &&
|
||||
typeof childValue === 'object' &&
|
||||
childValue !== null &&
|
||||
!Array.isArray(parentValue) &&
|
||||
!Array.isArray(childValue)
|
||||
) {
|
||||
// Deep merge objects
|
||||
const result: Record<string, unknown> = { ...parentValue };
|
||||
for (const [key, value] of Object.entries(childValue)) {
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, 'merge');
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Primitives: child overrides parent
|
||||
return childValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a child config into a parent config
|
||||
*/
|
||||
function mergeConfigs(parent: KeymanConfig, childFile: KeymanConfigFile): KeymanConfig {
|
||||
const resolution = childFile.resolution || {};
|
||||
const result: Record<string, unknown> = { ...parent };
|
||||
|
||||
for (const [key, value] of Object.entries(childFile)) {
|
||||
if (key === 'resolution') continue; // Skip resolution property itself
|
||||
|
||||
const strategy = resolution[key as keyof KeymanConfig] || 'merge';
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, strategy);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result as unknown as KeymanConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads configuration from .keymanrc.json files
|
||||
*
|
||||
* Searches for `.keymanrc.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
|
||||
* {
|
||||
* "vaultRoot": "../vault",
|
||||
* "resolution": {
|
||||
* "vaultRoot": "override"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns Validated keyman configuration
|
||||
*/
|
||||
export function loadConfig(): KeymanConfig {
|
||||
const startDir = process.cwd();
|
||||
const configPaths = findConfigFiles(startDir);
|
||||
|
||||
if (configPaths.length === 0) {
|
||||
console.error('ℹ️ No .keymanrc.json found, using default configuration');
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
// Start with defaults and merge each config file
|
||||
let config: KeymanConfig = { ...DEFAULT_CONFIG };
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const rawConfig = JSON.parse(content) as KeymanConfigFile;
|
||||
// Resolve path properties relative to the config file's directory
|
||||
const configDir = path.dirname(configPath);
|
||||
const resolvedConfig = resolvePathsRelativeToConfig(rawConfig, configDir);
|
||||
config = mergeConfigs(config, resolvedConfig);
|
||||
console.error(`✅ Loaded configuration from ${configPath}`);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
console.warn(`⚠️ Skipping invalid JSON in ${configPath}: ${error.message}`);
|
||||
} else {
|
||||
console.warn(`⚠️ Skipping config ${configPath}: ${error}`);
|
||||
}
|
||||
// Continue with other configs instead of failing entirely
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the final merged result
|
||||
try {
|
||||
return KeymanConfigSchema.parse(config);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error('❌ ERROR: Invalid merged configuration:');
|
||||
error.errors.forEach((err) => {
|
||||
console.error(` - ${err.path.join('.')}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
console.error('ℹ️ Falling back to default configuration');
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves configuration paths relative to VAULT_ROOT or current directory
|
||||
* @param config The keyman configuration
|
||||
* @returns Resolved absolute paths
|
||||
*/
|
||||
export function resolveConfigPaths(config: KeymanConfig) {
|
||||
// VAULT_ROOT environment variable takes precedence
|
||||
const vaultRoot = path.resolve(process.env.VAULT_ROOT ?? config.vaultRoot);
|
||||
|
||||
return {
|
||||
vaultRoot,
|
||||
keysDir: path.resolve(vaultRoot, config.keysDir),
|
||||
tmpDir: path.resolve(vaultRoot, config.tmpDir),
|
||||
keyPath: path.resolve(vaultRoot, config.ageKeyFile),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the paths of all discovered config files (for debugging)
|
||||
* @returns Array of paths to .keymanrc.json files, ordered from root to cwd
|
||||
*/
|
||||
export function getConfigPaths(): string[] {
|
||||
return findConfigFiles(process.cwd());
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
|
||||
export async function copyKey(sshDir: string, tmpDir: string) {
|
||||
const getKeys = (dir: string) => {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
||||
};
|
||||
|
||||
const sshKeys = getKeys(sshDir);
|
||||
const tmpKeys = getKeys(tmpDir);
|
||||
|
||||
const keys = [...new Set([...sshKeys, ...tmpKeys])];
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.log('⚠️ No SSH keys found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedKey } = await inquirer.prompt<{ selectedKey: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'selectedKey',
|
||||
message: 'Select key to copy public key from:',
|
||||
choices: keys,
|
||||
},
|
||||
]);
|
||||
|
||||
// Determine location of the public key
|
||||
// Prefer tmpDir if it exists there, otherwise sshDir
|
||||
let pubKeyPath = path.join(tmpDir, `${selectedKey}.pub`);
|
||||
if (!fs.existsSync(pubKeyPath)) {
|
||||
pubKeyPath = path.join(sshDir, `${selectedKey}.pub`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(pubKeyPath)) {
|
||||
console.error(`❌ Public key not found for ${selectedKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pubKeyContent = fs.readFileSync(pubKeyPath, 'utf-8').trim();
|
||||
|
||||
// Detect OS and use appropriate clipboard command
|
||||
// Since the environment is Darwin, we prioritize pbcopy, but we can add others for completeness or use a simple check.
|
||||
// For this specific request on Darwin:
|
||||
const proc = execa('pbcopy');
|
||||
proc.stdin?.write(pubKeyContent);
|
||||
proc.stdin?.end();
|
||||
await proc;
|
||||
|
||||
console.log(`✅ Public key for ${selectedKey} copied to clipboard!`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to copy to clipboard: ${error}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
|
||||
export async function decryptKeys(sshDir: string, vaultDir: string, ageKey: string) {
|
||||
const keyDir = path.join(vaultDir, 'keys');
|
||||
const vaultKeys = fs.readdirSync(keyDir).filter((key) => {
|
||||
const keyfile = path.join(keyDir, key, `id_${key}.age`);
|
||||
console.log(keyfile);
|
||||
return fs.existsSync(keyfile);
|
||||
});
|
||||
|
||||
if (vaultKeys.length === 0) {
|
||||
console.log('⚠️ No encrypted keys found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedKeys, decryptMode } = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'selectedKeys',
|
||||
message: 'Select keys to decrypt:',
|
||||
choices: vaultKeys,
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
name: 'decryptMode',
|
||||
message: 'Choose decryption location:',
|
||||
choices: ['Local (vault/tmp)', 'SSH (~/.ssh)'],
|
||||
},
|
||||
]);
|
||||
|
||||
for (const key of selectedKeys) {
|
||||
const encryptedKey = path.join(keyDir, key, `id_${key}.age`);
|
||||
const publicKey = path.join(keyDir, key, `id_${key}.pub`);
|
||||
const privateKeyOut =
|
||||
decryptMode === 'Local (vault/tmp)'
|
||||
? path.join(vaultDir, 'tmp', `id_${key}`)
|
||||
: path.join(sshDir, `id_${key}`);
|
||||
const publicKeyOut =
|
||||
decryptMode === 'Local (vault/tmp)'
|
||||
? path.join(vaultDir, 'tmp', `id_${key}.pub`)
|
||||
: path.join(sshDir, `id_${key}.pub`);
|
||||
|
||||
// Decrypt key
|
||||
await execa('age', ['-d', '-i', ageKey, '-o', privateKeyOut, encryptedKey]);
|
||||
|
||||
await execa('cp', [publicKey, publicKeyOut]);
|
||||
await execa('chmod', ['600', privateKeyOut]);
|
||||
console.log(`✅ Decrypted: ${privateKeyOut}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
|
||||
export async function encryptKeys(
|
||||
sshDir: string,
|
||||
vaultDir: string,
|
||||
tmpDir: string,
|
||||
pubkey: string
|
||||
) {
|
||||
const sshKeys = fs
|
||||
.readdirSync(sshDir)
|
||||
.filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
||||
const tmpKeys = fs
|
||||
.readdirSync(tmpDir)
|
||||
.filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
|
||||
console.log(tmpKeys);
|
||||
console.log(sshKeys);
|
||||
const keys = [...new Set([...sshKeys, ...tmpKeys])];
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.log('⚠️ No private SSH keys found to encrypt.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { selectedKeys } = await inquirer.prompt<{ selectedKeys: string[] }>([
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'selectedKeys',
|
||||
message: 'Select SSH keys to encrypt:',
|
||||
choices: keys,
|
||||
},
|
||||
]);
|
||||
|
||||
for (const key of selectedKeys) {
|
||||
const keyPath = path.join(tmpKeys.includes(key) ? tmpDir : sshDir, key);
|
||||
const vaultPath = path.join(vaultDir, 'keys', key.replace('id_', ''));
|
||||
fs.mkdirSync(vaultPath, { recursive: true });
|
||||
|
||||
// Encrypt key using `age`
|
||||
await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${key}.age`), keyPath]);
|
||||
|
||||
// Copy public key and create README
|
||||
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${key}.pub`));
|
||||
|
||||
console.log(`🔒 Encrypted and stored: ${vaultPath}/${key}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
|
||||
export async function generateKey(tmpDir: string, keysDir: string, pubkey: string) {
|
||||
const { algorithm } = await inquirer.prompt<{ algorithm: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'algorithm',
|
||||
message: 'Select algorithm:',
|
||||
choices: ['ed25519', 'rsa'],
|
||||
default: 'ed25519',
|
||||
},
|
||||
]);
|
||||
|
||||
const { keyName } = await inquirer.prompt<{ keyName: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'keyName',
|
||||
message: 'Enter key name:',
|
||||
validate: (input) => (input.trim() !== '' ? true : 'Key name cannot be empty'),
|
||||
},
|
||||
]);
|
||||
|
||||
const { password } = await inquirer.prompt<{ password: string }>([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: 'Enter passphrase (leave empty for no passphrase):',
|
||||
mask: '*',
|
||||
},
|
||||
]);
|
||||
|
||||
const { identity } = await inquirer.prompt<{ identity: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'identity',
|
||||
message: 'Enter key identity (comment):',
|
||||
},
|
||||
]);
|
||||
|
||||
const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`;
|
||||
const keyPath = path.join(tmpDir, fileName);
|
||||
|
||||
if (fs.existsSync(keyPath)) {
|
||||
console.error(`❌ Error: Key file ${fileName} already exists in ${tmpDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Generating ${algorithm} key pair...`);
|
||||
const args = ['-t', algorithm, '-f', keyPath, '-N', password, '-C', identity];
|
||||
|
||||
if (algorithm === 'rsa') {
|
||||
args.push('-b', '4096');
|
||||
}
|
||||
|
||||
await execa('ssh-keygen', args);
|
||||
console.log(`✅ Key generated: ${keyPath}`);
|
||||
|
||||
// Encrypt the key
|
||||
const folderName = fileName.replace('id_', '');
|
||||
const vaultPath = path.join(keysDir, folderName);
|
||||
fs.mkdirSync(vaultPath, { recursive: true });
|
||||
|
||||
// Encrypt key using `age`
|
||||
await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${fileName}.age`), keyPath]);
|
||||
|
||||
// Copy public key
|
||||
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${fileName}.pub`));
|
||||
|
||||
console.log(`🔒 Encrypted and stored: ${vaultPath}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error generating/encrypting key: ${error}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
interface KeyInfo {
|
||||
name: string;
|
||||
inSsh: boolean;
|
||||
hasSshPub: boolean;
|
||||
inVault: boolean;
|
||||
inTmp: boolean;
|
||||
hasTmpPub: boolean;
|
||||
}
|
||||
|
||||
export async function listKeys(sshDir: string, vaultDir: string, tmpDir: string) {
|
||||
console.log('\n📂 Checking keys in:');
|
||||
console.log(` SSH: ${sshDir}`);
|
||||
console.log(` Vault: ${vaultDir}`);
|
||||
console.log(` Tmp: ${tmpDir}\n`);
|
||||
|
||||
const keyMap = new Map<string, KeyInfo>();
|
||||
|
||||
// Scan SSH directory
|
||||
if (fs.existsSync(sshDir)) {
|
||||
const sshFiles = fs.readdirSync(sshDir).filter((file) => file.startsWith('id_'));
|
||||
|
||||
for (const file of sshFiles) {
|
||||
const keyName = file.replace(/\.pub$/, '');
|
||||
const isPub = file.endsWith('.pub');
|
||||
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: !isPub,
|
||||
hasSshPub: isPub,
|
||||
inVault: false,
|
||||
inTmp: false,
|
||||
hasTmpPub: false,
|
||||
});
|
||||
} else {
|
||||
const key = keyMap.get(keyName)!;
|
||||
if (isPub) {
|
||||
key.hasSshPub = true;
|
||||
} else {
|
||||
key.inSsh = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan tmp directory
|
||||
if (fs.existsSync(tmpDir)) {
|
||||
const tmpFiles = fs.readdirSync(tmpDir).filter((file) => file.startsWith('id_'));
|
||||
|
||||
for (const file of tmpFiles) {
|
||||
const keyName = file.replace(/\.pub$/, '');
|
||||
const isPub = file.endsWith('.pub');
|
||||
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: false,
|
||||
hasSshPub: false,
|
||||
inVault: false,
|
||||
inTmp: !isPub,
|
||||
hasTmpPub: isPub,
|
||||
});
|
||||
} else {
|
||||
const key = keyMap.get(keyName)!;
|
||||
if (isPub) {
|
||||
key.hasTmpPub = true;
|
||||
} else {
|
||||
key.inTmp = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan vault directory
|
||||
if (fs.existsSync(vaultDir)) {
|
||||
const vaultDirs = fs.readdirSync(vaultDir).filter((dir) => {
|
||||
const stat = fs.statSync(path.join(vaultDir, dir));
|
||||
return stat.isDirectory();
|
||||
});
|
||||
|
||||
for (const dir of vaultDirs) {
|
||||
const keyName = `id_${dir}`;
|
||||
const encryptedPath = path.join(vaultDir, dir, `${keyName}.age`);
|
||||
|
||||
if (fs.existsSync(encryptedPath)) {
|
||||
if (!keyMap.has(keyName)) {
|
||||
keyMap.set(keyName, {
|
||||
name: keyName,
|
||||
inSsh: false,
|
||||
hasSshPub: false,
|
||||
inVault: true,
|
||||
inTmp: false,
|
||||
hasTmpPub: false,
|
||||
});
|
||||
} else {
|
||||
keyMap.get(keyName)!.inVault = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display results
|
||||
if (keyMap.size === 0) {
|
||||
console.log('⚠️ No SSH keys found.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔑 SSH Keys:\n');
|
||||
console.log(' Key Name [Vault] [Tmp] [.ssh]');
|
||||
console.log(` ${'─'.repeat(58)}`);
|
||||
|
||||
const sortedKeys = Array.from(keyMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const vaultMark = key.inVault ? '✓' : ' ';
|
||||
const tmpMark = key.inTmp ? '✓' : ' ';
|
||||
const sshMark = key.inSsh ? '✓' : ' ';
|
||||
|
||||
// Show (.pub) if present in any location
|
||||
const hasPub = key.hasSshPub || key.hasTmpPub;
|
||||
const pubIndicator = hasPub ? ' (.pub)' : '';
|
||||
|
||||
// Determine status
|
||||
const status =
|
||||
key.inVault && key.inSsh ? '✅' : key.inVault && key.inTmp ? '🔓' : key.inVault ? '🔒' : '⚠️ ';
|
||||
|
||||
const namePart = `${key.name}${pubIndicator}`.padEnd(32);
|
||||
console.log(` ${status} ${namePart} [${vaultMark}] [${tmpMark}] [${sshMark}]`);
|
||||
}
|
||||
|
||||
console.log('\n Legend:');
|
||||
console.log(' ✅ = Managed (encrypted in vault + active in .ssh)');
|
||||
console.log(' 🔓 = Decrypted (in vault + decrypted to tmp)');
|
||||
console.log(' 🔒 = Encrypted only (in vault, not decrypted)');
|
||||
console.log(' ⚠️ = Unmanaged (in .ssh or tmp, not encrypted in vault)\n');
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import inquirer from 'inquirer';
|
||||
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
import { copyKey } from './keyman.copy.js';
|
||||
import { decryptKeys } from './keyman.decrypt.js';
|
||||
import { encryptKeys } from './keyman.encrypt.js';
|
||||
import { generateKey } from './keyman.generate.js';
|
||||
import { listKeys } from './keyman.list.js';
|
||||
import { extractAgePublicKey } from './keyman.utils.js';
|
||||
|
||||
// 🔹 Main function to resolve paths and manage flow
|
||||
export async function keyman() {
|
||||
// Load configuration from .keymanrc.json or use defaults
|
||||
const config = loadConfig();
|
||||
const paths = resolveConfigPaths(config);
|
||||
|
||||
console.log(`\n📁 Vault Root: ${paths.vaultRoot}`);
|
||||
console.log(`🔑 Keys Directory: ${paths.keysDir}`);
|
||||
console.log(`📂 Temp Directory: ${paths.tmpDir}`);
|
||||
console.log(`🔐 Age Key: ${paths.keyPath}\n`);
|
||||
|
||||
// Get USER input
|
||||
const { user } = await inquirer.prompt<{ user: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'user',
|
||||
message: 'Specify USER (default: @current):',
|
||||
default: '@current',
|
||||
},
|
||||
]);
|
||||
|
||||
const homeDir = user === '@current' ? process.env.HOME || '' : `/home/${user}`;
|
||||
if (!homeDir) {
|
||||
console.error('Error: Unable to determine HOME directory.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sshDir = path.join(homeDir, '.ssh');
|
||||
fs.mkdirSync(paths.vaultRoot, { recursive: true });
|
||||
fs.mkdirSync(paths.tmpDir, { recursive: true });
|
||||
|
||||
// Main loop - keep showing menu until user quits
|
||||
let running = true;
|
||||
while (running) {
|
||||
console.log(`\n${'='.repeat(50)}`);
|
||||
|
||||
// 🔹 Show category selection
|
||||
const { category } = await inquirer.prompt<{ category: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'category',
|
||||
message: 'Select operation:',
|
||||
choices: [
|
||||
{ name: '📋 List keys', value: 'list' },
|
||||
{ name: '📝 Copy public key', value: 'copy' },
|
||||
{ name: '🆕 Generate key', value: 'generate' },
|
||||
{ name: '🔒 Encrypt keys', value: 'encrypt' },
|
||||
{ name: '🔓 Decrypt keys', value: 'decrypt' },
|
||||
{ name: '❌ Quit', value: 'quit' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
switch (category) {
|
||||
case 'list':
|
||||
await listKeys(sshDir, paths.keysDir, paths.tmpDir);
|
||||
break;
|
||||
case 'copy':
|
||||
await copyKey(sshDir, paths.tmpDir);
|
||||
break;
|
||||
case 'generate':
|
||||
await generateKey(paths.tmpDir, paths.keysDir, extractAgePublicKey(paths.keyPath)!);
|
||||
break;
|
||||
case 'encrypt':
|
||||
await encryptKeys(
|
||||
sshDir,
|
||||
paths.vaultRoot,
|
||||
paths.tmpDir,
|
||||
extractAgePublicKey(paths.keyPath)!
|
||||
);
|
||||
break;
|
||||
case 'decrypt':
|
||||
await decryptKeys(sshDir, paths.vaultRoot, paths.keyPath);
|
||||
break;
|
||||
case 'quit':
|
||||
console.log('\n👋 Goodbye!\n');
|
||||
running = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* Extracts the public key from an age key file.
|
||||
* @param keyFilePath Path to the age key file.
|
||||
* @returns The public key as a string, or null if not found.
|
||||
*/
|
||||
export function extractAgePublicKey(keyFilePath: string): string | null {
|
||||
if (!fs.existsSync(keyFilePath)) {
|
||||
console.error(`❌ ERROR: Age key file not found at ${keyFilePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileContents = fs.readFileSync(keyFilePath, 'utf-8');
|
||||
const publicKeyMatch = fileContents.match(/^# public key:\s*(age1[^\s]+)/m);
|
||||
|
||||
return publicKeyMatch ? publicKeyMatch[1] : null;
|
||||
} catch (error) {
|
||||
console.error(`❌ ERROR: Failed to read key file - ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2020"],
|
||||
"composite": true,
|
||||
"module": "NodeNext",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["coverage", "node_modules", "dist"],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"hosts": ["@docker/nopy-test-container", "tecpi.local"],
|
||||
"cubeDirs": ["./cubes"],
|
||||
"env": {
|
||||
"KEY_DIR": "../../vault/tmp"
|
||||
},
|
||||
"log": {
|
||||
"verbosity": "trace",
|
||||
"debug": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Nopy Refactoring Plan
|
||||
|
||||
This document tracks the major refactoring of the `nopy` package.
|
||||
|
||||
## Refactoring Items
|
||||
|
||||
### 1. Remove parallel execution
|
||||
- **Status**: ✅ Completed
|
||||
- **Goal**: Remove all logic supporting parallel execution of cubes to simplify the execution flow and improve reliability.
|
||||
- **Context**:
|
||||
- Parallelism removed from `NopyConfig`, `NopyOptions`, and `executeDeployCalls`.
|
||||
- `buildExecutionStages` deleted.
|
||||
- CLI flags `--parallel` and `--concurrency` removed.
|
||||
- **Proposed Solution**: (Done)
|
||||
|
||||
### 2. Rework cube building process & Dependency Resolution
|
||||
- **Status**: ✅ Completed
|
||||
- **Goal**: Allow dependencies to be defined as a function of the collected variables.
|
||||
- **New Signature**: `dependencies?: (variables: CubeVariables) => DependencySpec[]`
|
||||
- **Architectural Change**: Implement a clean, step-based resolution mechanism using a `BuildContext`.
|
||||
- **Context**:
|
||||
- Introduced `BuildContext` in `cubes/dependencies.ts` which handles recursive resolution, variable collection, and hook execution.
|
||||
- Resolution is now dynamic: variables are collected for a cube before its dependencies are resolved.
|
||||
- **Proposed Solution**: (Done)
|
||||
|
||||
### 3. Remove `env` property from cube Manifest
|
||||
- **Status**: ✅ Completed
|
||||
- **Goal**: Remove the `env` property from the cube manifest.
|
||||
- **Context**:
|
||||
- `env` removed from `Manifest` and `Env` types.
|
||||
- Responsibility for defaults shifted entirely to Zod schema defaults and `getDefaults()`.
|
||||
- **Proposed Solution**: (Done)
|
||||
|
||||
### 4. Redesign Manifest and Cube types
|
||||
- **Status**: ✅ Completed
|
||||
- **Goal**: Transition from `Env -> Manifest -> Cube` inheritance to a cleaner `Manifest` (specification) and `Cube` (runtime) separation.
|
||||
- **Context**:
|
||||
- `Manifest` is now a clean interface with a factory namespace.
|
||||
- `Cube` is a class encapsulating a `Manifest` and runtime info (`dir`, `deployScript`).
|
||||
- **Proposed Solution**: (Done)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Testing Nopy with Docker
|
||||
|
||||
This guide explains how to set up a local Docker container to test `nopy` deployments using the provided `Ubuntu-24LTS.Dockerfile` and `example.nopysession.json`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker installed and running on your machine.
|
||||
- `nopy` installed and linked (see [README.md](./README.md)).
|
||||
|
||||
## 1. Setup SSH Key (Important)
|
||||
|
||||
The provided `Ubuntu-24LTS.Dockerfile` contains a hardcoded public SSH key. Before building, you **must** replace it with your own public key to allow SSH access (if needed) or ensure `pyinfra` can connect if it uses SSH transport.
|
||||
|
||||
1. Open `Ubuntu-24LTS.Dockerfile`.
|
||||
2. Locate the line starting with `echo "ssh-ed25519 ...`.
|
||||
3. Replace the key string with the content of your own public key (usually `~/.ssh/id_ed25519.pub` or `~/.ssh/id_rsa.pub`).
|
||||
|
||||
```dockerfile
|
||||
# Example replacement
|
||||
RUN mkdir -p /home/testuser/.ssh && \
|
||||
echo "YOUR_PUBLIC_KEY_HERE" >> /home/testuser/.ssh/authorized_keys && \
|
||||
...
|
||||
```
|
||||
|
||||
## 2. Build the Docker Image
|
||||
|
||||
Run the following command from the `packages/nopy` directory:
|
||||
|
||||
```bash
|
||||
docker build -f Ubuntu-24LTS.Dockerfile -t nopy-test-ubuntu .
|
||||
```
|
||||
|
||||
## 3. Run the Container
|
||||
|
||||
Start the container in the background. We explicitly name it `nopy-test-container` because the `example.nopysession.json` is configured to target this specific container name.
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name nopy-test-container \
|
||||
--privileged \
|
||||
-p 2222:22 \
|
||||
nopy-test-ubuntu
|
||||
```
|
||||
|
||||
- `--name nopy-test-container`: Matches the host defined in `example.nopysession.json`.
|
||||
- `--privileged`: Required for some system-level operations (like `criu` or service management) if tested.
|
||||
- `-p 2222:22`: Maps the container's SSH port to local port 2222 (optional, allows manual SSH connection).
|
||||
|
||||
## 4. Deploy using Nopy
|
||||
|
||||
Now you can run the example session. Nopy uses `pyinfra`'s `@docker` connector to communicate directly with the container, so SSH keys are not strictly required for the *deployment* itself, but the session is configured to simulate a realistic environment.
|
||||
|
||||
```bash
|
||||
nopy install -l example.nopysession.json
|
||||
```
|
||||
|
||||
If successful, `nopy` will execute the `apt:essentials` cube against the container.
|
||||
|
||||
## 5. Manual Verification
|
||||
|
||||
You can connect to the container manually to verify changes:
|
||||
|
||||
**Via Docker Exec:**
|
||||
|
||||
```bash
|
||||
docker exec -it nopy-test-container bash
|
||||
```
|
||||
|
||||
**Via SSH (if configured):**
|
||||
|
||||
```bash
|
||||
ssh -p 2222 testuser@localhost
|
||||
# Password: password
|
||||
```
|
||||
|
||||
## 6. Cleanup
|
||||
|
||||
To stop and remove the container:
|
||||
|
||||
```bash
|
||||
docker rm -f nopy-test-container
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
# Vagrant
|
||||
|
||||
`vagrant ssh-config` to find the SSH port of the machine
|
||||
`vagrant status --machine-readable` will be executed by pyinfra to get information about available VMs
|
||||
|
||||
```ruby
|
||||
|
||||
Vagrant.configure("2") do |config|
|
||||
# DO NOT USE special characters in vm name
|
||||
config.vm.define "nopytestvm"
|
||||
config.vm.provider "vmware_desktop" do |vmware|
|
||||
vmware.gui = false
|
||||
vmware.allowlist_verified = true
|
||||
end
|
||||
config.vm.box = "bento/ubuntu-24.04" # Use Ubuntu 24.04 box
|
||||
config.ssh.insert_key = false
|
||||
config.vm.box_check_update = false
|
||||
config.vm.hostname = "nopytestvm"
|
||||
end
|
||||
|
||||
```
|
||||
@@ -0,0 +1,380 @@
|
||||
# Nopy
|
||||
|
||||
A CLI tool that simplifies **pyinfra** script management and execution, providing an interactive workflow for deploying infrastructure configurations ("cubes") to remote hosts.
|
||||
|
||||
## Overview
|
||||
|
||||
Nopy wraps pyinfra with structure, validation, and an interactive experience for managing complex infrastructure deployments. It organizes deployments into self-contained "cubes" with dependency management, schema validation, and lifecycle hooks.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Cubes
|
||||
|
||||
Self-contained deployment units consisting of:
|
||||
|
||||
- **Python deployment script**: `<cube-name>.deploy.py`
|
||||
- **JavaScript manifest**: `<cube-name>.manifest.mjs` defining schema, dependencies, defaults, and hooks
|
||||
- **Configuration variables**: Validated with Zod schemas
|
||||
|
||||
#### Cube Manifest
|
||||
|
||||
```javascript
|
||||
import { z } from 'zod'
|
||||
import { cubes } from '@bitstack/nopy'
|
||||
|
||||
export default cubes.Manifest({
|
||||
id: 'apt:install',
|
||||
name: 'Install packages with apt',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
UPDATE: z.boolean().describe('Update package cache').default(false),
|
||||
PACKAGES: z.string().describe('Space-separated list of packages').default('vim htop'),
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### Variable Defaults
|
||||
|
||||
Variable defaults are defined directly in the Zod schema using `.default()`. This ensures that every cube has a predictable starting state and provides type-safe default values.
|
||||
|
||||
**Priority order (lowest to highest):**
|
||||
|
||||
1. Zod schema `.default()` values
|
||||
2. Global `env` from `.nopyrc.json`
|
||||
3. Accumulated variables from dependencies
|
||||
4. User prompts / session replay
|
||||
|
||||
This allows cubes to ship with reasonable defaults while still allowing users to override them globally via `.nopyrc.json` or interactively during deployment.
|
||||
|
||||
### Configuration
|
||||
|
||||
Uses `.nopyrc.json` files (project-level or home directory) containing:
|
||||
|
||||
```json
|
||||
{
|
||||
"hosts": ["host1.example.com", "host2.example.com"],
|
||||
"cubeDirs": ["./cubes", "../shared-cubes"],
|
||||
"env": {
|
||||
"SHARED_VAR": "value"
|
||||
},
|
||||
"log": {
|
||||
"verbosity": "info",
|
||||
"debug": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Logging Configuration
|
||||
|
||||
Control pyinfra output verbosity and debug information using the `log` configuration object:
|
||||
|
||||
**`log.verbosity`** - Controls the level of information printed during execution:
|
||||
|
||||
| Verbosity | PyInfra Flag | Description | Use Case |
|
||||
|-----------|--------------|-------------|----------|
|
||||
| `"silent"` | (none) | Minimal output (default) | Production deployments, clean output |
|
||||
| `"info"` | `-v` | Print meta information | See what operations are running |
|
||||
| `"verbose"` | `-vv` | Include input data | Debug parameters and configuration |
|
||||
| `"trace"` | `-vvv` | Full command output | See all command outputs and details |
|
||||
|
||||
**`log.debug`** - Enables pyinfra's internal debug logging:
|
||||
|
||||
| Value | PyInfra Flag | Description | Use Case |
|
||||
|-------|--------------|-------------|----------|
|
||||
| `false` | (none) | No debug logs (default) | Normal operation |
|
||||
| `true` | `--debug` | Enable pyinfra debug logs | Deep debugging of pyinfra internals |
|
||||
|
||||
**Examples:**
|
||||
|
||||
Basic troubleshooting:
|
||||
|
||||
```json
|
||||
{
|
||||
"log": {
|
||||
"verbosity": "info"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Debug command failures:
|
||||
|
||||
```json
|
||||
{
|
||||
"log": {
|
||||
"verbosity": "trace"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Deep debugging with pyinfra internals:
|
||||
|
||||
```json
|
||||
{
|
||||
"log": {
|
||||
"verbosity": "trace",
|
||||
"debug": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Recommendation:** Start with `"info"` for typical troubleshooting, use `"trace"` when investigating command failures, and enable `debug: true` only when debugging pyinfra itself.
|
||||
|
||||
### Session Recording and Replay
|
||||
|
||||
Nopy supports recording deployment sessions to JSON files for later replay. This is useful for:
|
||||
|
||||
- Repeatable deployments
|
||||
- CI/CD pipelines
|
||||
- Documentation and auditing
|
||||
- Sharing configurations across teams
|
||||
|
||||
#### Session File Format
|
||||
|
||||
Sessions are stored in `.nopysession.json` files with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"name": "My Deployment Session",
|
||||
"timestamp": "2025-10-13T10:30:00Z",
|
||||
"cubes": [
|
||||
{
|
||||
"key": "apt:essentials",
|
||||
"variables": {
|
||||
"UPDATE": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "apt-more",
|
||||
"variables": {
|
||||
"SOME_VAR": "value"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hosts": [
|
||||
"@docker/nopy-test-container"
|
||||
],
|
||||
"env": {
|
||||
"KEY_DIR": "../../vault/tmp"
|
||||
},
|
||||
"auth": {
|
||||
"method": "ssh-key",
|
||||
"username": "root"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Structure Details:**
|
||||
|
||||
- **`cubes`**: Array of cubes with only cube-specific variables (not global env vars)
|
||||
- **`env`**: Global environment variables shared across cubes (like in `.nopyrc.json`)
|
||||
- **`hosts`**: Array of target hosts
|
||||
- **`auth`**: Authentication configuration (passwords are never stored)
|
||||
|
||||
**Security Note**: Passwords are never stored in session files. If a session uses password authentication, you'll be prompted for the password during replay.
|
||||
|
||||
#### Recording a Session
|
||||
|
||||
```bash
|
||||
# Run deployment interactively and save the session
|
||||
nopy install --save-session my-deployment.nopysession.json
|
||||
|
||||
# With defaults (no prompts for variables)
|
||||
nopy install -D --save-session automated-deployment.nopysession.json
|
||||
```
|
||||
|
||||
#### Replaying a Session
|
||||
|
||||
```bash
|
||||
# Load and execute a saved session
|
||||
nopy install --load-session my-deployment.nopysession.json
|
||||
|
||||
# Session replay uses the exact cubes, variables, and hosts from the file
|
||||
# Only password authentication will prompt for credentials
|
||||
```
|
||||
|
||||
### Cube Discovery
|
||||
|
||||
Nopy searches for cubes in:
|
||||
|
||||
1. Directories specified in `.nopyrc.json` `cubeDirs`
|
||||
2. Directories containing a `.npcubes` marker file (searching upwards from current directory)
|
||||
|
||||
## Command Line Usage
|
||||
|
||||
### Installation
|
||||
|
||||
This package is part of a yarn workspace monorepo. Install from the repository root:
|
||||
|
||||
```bash
|
||||
# From repository root (/ansiblings)
|
||||
yarn install
|
||||
yarn workspace @bitstack/nopy build
|
||||
```
|
||||
|
||||
To use the `nopy` command globally, you can:
|
||||
|
||||
1. **Use yarn workspace command**:
|
||||
|
||||
```bash
|
||||
yarn workspace @bitstack/nopy nopy
|
||||
```
|
||||
|
||||
2. **Link the package globally**:
|
||||
|
||||
```bash
|
||||
cd packages/nopy
|
||||
npm link
|
||||
# Now you can use 'nopy' from anywhere
|
||||
nopy install
|
||||
```
|
||||
|
||||
3. **Use via npm scripts** (from packages/nopy directory):
|
||||
|
||||
```bash
|
||||
yarn nopy
|
||||
```
|
||||
|
||||
### Basic Commands
|
||||
|
||||
**Install cubes (default command)**:
|
||||
|
||||
```bash
|
||||
nopy install
|
||||
# or simply
|
||||
nopy
|
||||
```
|
||||
|
||||
**Install with defaults (no prompts for customization)**:
|
||||
|
||||
```bash
|
||||
nopy install --use-defaults
|
||||
# or
|
||||
nopy install -D
|
||||
```
|
||||
|
||||
**Use SSH key authentication**:
|
||||
|
||||
```bash
|
||||
nopy install --auth-method-key
|
||||
# or
|
||||
nopy install -K
|
||||
```
|
||||
|
||||
**Repeat last run**:
|
||||
|
||||
```bash
|
||||
nopy install --repeat-last-run
|
||||
# or
|
||||
nopy install -R
|
||||
```
|
||||
|
||||
**Save session for replay**:
|
||||
|
||||
```bash
|
||||
nopy install --save-session my-deployment.nopysession.json
|
||||
# or
|
||||
nopy install -s my-deployment.nopysession.json
|
||||
```
|
||||
|
||||
**Load and replay session**:
|
||||
|
||||
```bash
|
||||
nopy install --load-session my-deployment.nopysession.json
|
||||
# or
|
||||
nopy install -l my-deployment.nopysession.json
|
||||
```
|
||||
|
||||
**Combined options**:
|
||||
|
||||
```bash
|
||||
nopy install -D -K # Use defaults + SSH key auth
|
||||
nopy install -D -s session.nopysession.json # Use defaults and save session
|
||||
```
|
||||
|
||||
### Advanced Options
|
||||
|
||||
**Dry run (preview without executing)**:
|
||||
|
||||
```bash
|
||||
nopy install --dry-run
|
||||
```
|
||||
|
||||
Shows the execution plan including commands, environment variables, and targets without running anything. Sensitive data is masked in output.
|
||||
|
||||
**Parallel execution**:
|
||||
|
||||
```bash
|
||||
nopy install --parallel
|
||||
```
|
||||
|
||||
Executes independent cubes in parallel using a dependency graph. Cubes are grouped into execution stages, with a default concurrency limit of 4.
|
||||
|
||||
**JSON output (for CI/CD)**:
|
||||
|
||||
```bash
|
||||
nopy install --json
|
||||
nopy history --json
|
||||
```
|
||||
|
||||
Machine-readable JSON output for scripting and CI/CD integration.
|
||||
|
||||
**Continue on error**:
|
||||
|
||||
```bash
|
||||
nopy install --continue-on-error
|
||||
```
|
||||
|
||||
Continue deploying remaining cubes even if one fails.
|
||||
|
||||
**View deployment history**:
|
||||
|
||||
```bash
|
||||
nopy history # List recent deployments
|
||||
nopy install -H <id> # Replay a specific deployment by ID
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
**Run without building**:
|
||||
|
||||
```bash
|
||||
npm run nopy
|
||||
```
|
||||
|
||||
**Debug**:
|
||||
|
||||
```bash
|
||||
npm run debug
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Load cubes** - Discovers and validates cubes from configured directories
|
||||
2. **Interactive prompts** - Select cubes, target host, and authentication method
|
||||
3. **Dependency resolution** - Topologically sorts cubes based on dependencies
|
||||
4. **Variable assignment** - Validates and collects configuration with schema validation
|
||||
5. **Execute hooks** - Runs before/after hooks for orchestration
|
||||
6. **Deploy** - Sequentially executes pyinfra commands
|
||||
|
||||
## Features
|
||||
|
||||
- **Dependency resolution** with topological sorting
|
||||
- **Parallel execution** of independent cubes in stages
|
||||
- **Before/after hooks** for multi-cube orchestration
|
||||
- **SSH key or password authentication**
|
||||
- **Default values** with optional customization via manifest `env`
|
||||
- **Schema validation** using Zod
|
||||
- **Recursive cube directory discovery**
|
||||
- **Dry-run mode** for previewing deployments
|
||||
- **JSON output** for CI/CD integration
|
||||
- **Session history** with replay capability
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Cube Hooks](docs/HOOKS.md) - Lifecycle hooks for dynamic orchestration
|
||||
- [Session Format](docs/SESSION_FORMAT.md) - Internal JSON/MJS session structure
|
||||
|
||||
## Resources
|
||||
|
||||
- [Pyinfra Documentation](https://docs.pyinfra.com/en/3.x/arguments.html)
|
||||
@@ -0,0 +1,33 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
# Install software-properties-common
|
||||
RUN apt-get update && \
|
||||
apt-get install -y software-properties-common && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add the CRIU repository
|
||||
RUN add-apt-repository ppa:criu/ppa -y
|
||||
|
||||
# Update apt cache and install SSH server, basic utilities, and CRIU fro snapshot management
|
||||
RUN apt-get update && \
|
||||
apt-get install -y openssh-server sudo curl htop criu && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a user (replace 'testuser' with your preferred username)
|
||||
RUN useradd -m -d /home/testuser -s /bin/bash testuser
|
||||
RUN echo 'testuser:password' | chpasswd
|
||||
RUN adduser testuser sudo
|
||||
|
||||
# Add your public key (replace with your actual public key)
|
||||
RUN mkdir -p /home/testuser/.ssh && \
|
||||
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICpnZ6IxwQKL1rBE4dy7w5Sd3s2tLFZUDfjH87C1QIlc bdiedrichsen@Benjamins-MBP.lan" >> /home/testuser/.ssh/authorized_keys && \
|
||||
chmod 600 /home/testuser/.ssh/authorized_keys && \
|
||||
chown testuser:testuser /home/testuser/.ssh/authorized_keys
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /run/sshd
|
||||
# Expose SSH port
|
||||
EXPOSE 22
|
||||
|
||||
# Set the default command to keep the container running
|
||||
CMD ["/usr/sbin/sshd", "-D"]
|
||||
@@ -0,0 +1,4 @@
|
||||
from pyinfra.operations import apt
|
||||
from pyinfra import host
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cubes } from '@bitstack/nopy';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default cubes.Manifest({
|
||||
name: '[apt-all] Test dependencies',
|
||||
dependencies: () => ['apt/more'],
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
from pyinfra.operations import apt
|
||||
from pyinfra import host
|
||||
|
||||
UPDATE = host.data.UPDATE
|
||||
|
||||
apt.packages(
|
||||
name='Install essentials',
|
||||
packages=[
|
||||
'fish',
|
||||
'ranger',
|
||||
'age'
|
||||
],
|
||||
update=UPDATE,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { cubes } from '@bitstack/nopy';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default cubes.Manifest({
|
||||
name: '[apt:essentials] Install essential packages',
|
||||
dependencies: () => [],
|
||||
schema: z.object({
|
||||
UPDATE: z.boolean().default(false),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
from pyinfra.operations import apt
|
||||
from pyinfra import host
|
||||
|
||||
UPDATE = host.data.UPDATE
|
||||
|
||||
apt.packages(
|
||||
name='Install essentials',
|
||||
packages=[
|
||||
'build-essential',
|
||||
'pkg-config',
|
||||
'age'
|
||||
],
|
||||
update=UPDATE,
|
||||
_sudo=True
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cubes } from '@bitstack/nopy';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default cubes.Manifest({
|
||||
name: '[apt-more] Test dependencies',
|
||||
dependencies: () => [['apt/essentials']],
|
||||
});
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Dynamic dependency resolution for cubes
|
||||
* @module cubes/dependencies
|
||||
*/
|
||||
import type { Variables } from '../nopy.common.js';
|
||||
import type { NopyConfig } from '../nopy.config.js';
|
||||
import type { DeployCall } from '../nopy.executor.js';
|
||||
import { type CubeSession, type NopySession } from '../nopy.session.js';
|
||||
import type { Cube, CubeVariables } from './types.js';
|
||||
/**
|
||||
* Context for the resolution process
|
||||
*/
|
||||
export declare class BuildContext {
|
||||
readonly allCubes: Record<string, Cube>;
|
||||
readonly variables: Variables;
|
||||
readonly session: NopySession;
|
||||
readonly config: NopyConfig;
|
||||
readonly auth: {
|
||||
method: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
readonly options: {
|
||||
useDefaults?: boolean;
|
||||
isSessionReplay?: boolean;
|
||||
};
|
||||
readonly deployCalls: DeployCall[];
|
||||
readonly cubeSessions: CubeSession[];
|
||||
private readonly resolvedCubes;
|
||||
constructor(allCubes: Record<string, Cube>, variables: Variables, session: NopySession, config: NopyConfig, auth: {
|
||||
method: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}, options?: {
|
||||
useDefaults?: boolean;
|
||||
isSessionReplay?: boolean;
|
||||
});
|
||||
/**
|
||||
* Resolves a cube, its dependencies, and hooks recursively
|
||||
*/
|
||||
resolveCube(cubeId: string, host: string, overrides?: CubeVariables): Promise<void>;
|
||||
/**
|
||||
* Builds and stores a deployment call for a resolved cube
|
||||
*/
|
||||
private buildDeployCall;
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Dynamic dependency resolution for cubes
|
||||
* @module cubes/dependencies
|
||||
*/
|
||||
import { getLogger } from '@logtape/logtape';
|
||||
import { VariableAssignment } from '../nopy.prompts.js';
|
||||
const log = getLogger(['nopy', 'resolution']);
|
||||
/**
|
||||
* Context for the resolution process
|
||||
*/
|
||||
export class BuildContext {
|
||||
allCubes;
|
||||
variables;
|
||||
session;
|
||||
config;
|
||||
auth;
|
||||
options;
|
||||
deployCalls = [];
|
||||
cubeSessions = [];
|
||||
resolvedCubes = new Set();
|
||||
constructor(allCubes, variables, session, config, auth, options = {}) {
|
||||
this.allCubes = allCubes;
|
||||
this.variables = variables;
|
||||
this.session = session;
|
||||
this.config = config;
|
||||
this.auth = auth;
|
||||
this.options = options;
|
||||
}
|
||||
/**
|
||||
* Resolves a cube, its dependencies, and hooks recursively
|
||||
*/
|
||||
async resolveCube(cubeId, host, overrides = {}) {
|
||||
const cube = this.allCubes[cubeId];
|
||||
if (!cube) {
|
||||
throw new Error(`Cube not found: ${cubeId}`);
|
||||
}
|
||||
log.debug('Resolving cube', { cubeId, host });
|
||||
// 1. Assign overrides and defaults
|
||||
if (Object.keys(overrides).length > 0) {
|
||||
this.variables.assign(cubeId, 'params', overrides);
|
||||
}
|
||||
this.variables.assign(cubeId, 'defaults', cube.getDefaults());
|
||||
// 2. Variable collection
|
||||
if (this.options.isSessionReplay) {
|
||||
const sessionCube = this.session.cubes.find(c => c.key === cubeId);
|
||||
if (sessionCube) {
|
||||
this.variables.assign(cubeId, 'defaults', sessionCube.variables);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await VariableAssignment(cube, this.variables);
|
||||
}
|
||||
const currentVars = this.variables.get(cubeId);
|
||||
const hookCtx = {
|
||||
exec: (id, vars) => this.resolveCube(id, host, vars),
|
||||
};
|
||||
// 3. Execute 'before' hooks
|
||||
if (cube.manifest.before) {
|
||||
for (const hook of cube.manifest.before) {
|
||||
await hook(hookCtx, currentVars);
|
||||
}
|
||||
}
|
||||
// 4. Resolve dynamic dependencies
|
||||
const depSpecs = cube.manifest.dependencies?.(currentVars) ?? [];
|
||||
for (const spec of depSpecs) {
|
||||
const depId = typeof spec === 'string' ? spec : spec[0];
|
||||
const depVars = typeof spec === 'string' ? {} : (spec[1] ?? {});
|
||||
await this.resolveCube(depId, host, depVars);
|
||||
}
|
||||
// 5. Build the deployment call
|
||||
this.buildDeployCall(cube, host);
|
||||
// 6. Execute 'after' hooks
|
||||
if (cube.manifest.after) {
|
||||
for (const hook of cube.manifest.after) {
|
||||
await hook(hookCtx, currentVars);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Builds and stores a deployment call for a resolved cube
|
||||
*/
|
||||
buildDeployCall(cube, host) {
|
||||
const cubeId = cube.id;
|
||||
const callKey = `${cubeId}:${host}`;
|
||||
if (this.resolvedCubes.has(callKey))
|
||||
return;
|
||||
const parts = [];
|
||||
if (this.auth.method === 'password' && this.auth.username && this.auth.password) {
|
||||
parts.push(`--user ${this.auth.username} --password ${this.auth.password}`);
|
||||
}
|
||||
const cubeVars = this.variables.get(cubeId);
|
||||
Object.entries(cubeVars).forEach(([key, value]) => {
|
||||
parts.push(`--data "${key}=${value}"`);
|
||||
});
|
||||
parts.push(`--chdir ${cube.dir}`);
|
||||
parts.push(`${cube.dir}/${cube.deployScript}`);
|
||||
const command = ['pyinfra', host, '-y', ...parts];
|
||||
this.deployCalls.push({
|
||||
cube: cubeId,
|
||||
host,
|
||||
cwd: cube.dir,
|
||||
command,
|
||||
env: cubeVars,
|
||||
dependencies: [],
|
||||
});
|
||||
if (!this.cubeSessions.some(s => s.key === cubeId)) {
|
||||
this.cubeSessions.push({
|
||||
key: cubeId,
|
||||
variables: this.variables.get(cubeId, 'prompts'),
|
||||
});
|
||||
}
|
||||
this.resolvedCubes.add(callKey);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Factory functions for creating cube configurations
|
||||
* @module cubes/factories
|
||||
*/
|
||||
import { Manifest } from './types.js';
|
||||
/**
|
||||
* Creates a manifest configuration for a cube
|
||||
*
|
||||
* @param opts - Manifest options including name, schema, dependencies, and hooks
|
||||
* @returns Manifest configuration object
|
||||
*/
|
||||
export declare function createManifest<Schema extends import('zod').z.AnyZodObject>(opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>): Manifest<Schema>;
|
||||
/**
|
||||
* Alias for createManifest - for backwards compatibility with existing manifests
|
||||
*/
|
||||
export declare const manifest: typeof createManifest;
|
||||
/**
|
||||
* @deprecated Use createManifest or manifest instead
|
||||
*/
|
||||
export declare const ManifestFactory: typeof createManifest;
|
||||
export { Manifest } from './types.js';
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Factory functions for creating cube configurations
|
||||
* @module cubes/factories
|
||||
*/
|
||||
import { Manifest } from './types.js';
|
||||
/**
|
||||
* Creates a manifest configuration for a cube
|
||||
*
|
||||
* @param opts - Manifest options including name, schema, dependencies, and hooks
|
||||
* @returns Manifest configuration object
|
||||
*/
|
||||
export function createManifest(opts) {
|
||||
return Manifest(opts);
|
||||
}
|
||||
/**
|
||||
* Alias for createManifest - for backwards compatibility with existing manifests
|
||||
*/
|
||||
export const manifest = createManifest;
|
||||
/**
|
||||
* @deprecated Use createManifest or manifest instead
|
||||
*/
|
||||
export const ManifestFactory = createManifest;
|
||||
export { Manifest } from './types.js';
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Nopy Cubes Module
|
||||
*
|
||||
* Self-contained deployment units for pyinfra automation.
|
||||
*
|
||||
* @module cubes
|
||||
*/
|
||||
export { Cube, Manifest, } from './types.js';
|
||||
export type { Hook, HookContext, LoadResult, CubeVariables, DependencySpec, } from './types.js';
|
||||
export { createManifest, manifest, } from './factories.js';
|
||||
export { loadCubes, findCubeDirectories, getCube, } from './loader.js';
|
||||
export { BuildContext, } from './dependencies.js';
|
||||
export { uniqid } from './utils.js';
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Nopy Cubes Module
|
||||
*
|
||||
* Self-contained deployment units for pyinfra automation.
|
||||
*
|
||||
* @module cubes
|
||||
*/
|
||||
// Types
|
||||
export { Cube, Manifest, } from './types.js';
|
||||
// Factory functions
|
||||
export { createManifest, manifest, } from './factories.js';
|
||||
// Loader
|
||||
export { loadCubes, findCubeDirectories, getCube, } from './loader.js';
|
||||
// Dependencies
|
||||
export { BuildContext, } from './dependencies.js';
|
||||
// Utilities
|
||||
export { uniqid } from './utils.js';
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Cube discovery and loading from the filesystem
|
||||
* @module cubes/loader
|
||||
*/
|
||||
import { Cube, type LoadResult } from './types.js';
|
||||
/**
|
||||
* Traverses upwards from the current working directory to the root
|
||||
* and collects all directories that contain a `.npcubes` marker file.
|
||||
*
|
||||
* Also includes directories specified in the `.nopyrc.json` configuration.
|
||||
*
|
||||
* @returns Array of absolute paths to directories containing cubes
|
||||
*/
|
||||
export declare function findCubeDirectories(): string[];
|
||||
/**
|
||||
* Loads all cubes from discovered cube directories.
|
||||
*/
|
||||
export declare function loadCubes(): Promise<LoadResult>;
|
||||
/**
|
||||
* Gets information about a single cube by name.
|
||||
*/
|
||||
export declare function getCube(cubeName: string): Promise<Cube | undefined>;
|
||||
Vendored
+102
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Cube discovery and loading from the filesystem
|
||||
* @module cubes/loader
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { fs } from 'zx';
|
||||
import { loadConfig } from '../nopy.config.js';
|
||||
import { Cube } from './types.js';
|
||||
/**
|
||||
* Traverses upwards from the current working directory to the root
|
||||
* and collects all directories that contain a `.npcubes` marker file.
|
||||
*
|
||||
* Also includes directories specified in the `.nopyrc.json` configuration.
|
||||
*
|
||||
* @returns Array of absolute paths to directories containing cubes
|
||||
*/
|
||||
export function findCubeDirectories() {
|
||||
let currentDir = process.cwd();
|
||||
const config = loadConfig();
|
||||
const dirSet = new Set(config.cubeDirs.map((dir) => path.resolve(process.cwd(), dir)));
|
||||
while (true) {
|
||||
const targetFile = path.join(currentDir, '.npcubes');
|
||||
if (fs.existsSync(targetFile) && fs.statSync(targetFile).isFile()) {
|
||||
dirSet.add(currentDir);
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
break; // Stop when reaching the root
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
return [...dirSet];
|
||||
}
|
||||
/**
|
||||
* Extracts cube ID from name pattern [id] or explicit id field
|
||||
*/
|
||||
function extractCubeId(manifest) {
|
||||
if (manifest.id)
|
||||
return manifest.id;
|
||||
const match = manifest.name.match(/^\[([^\]]+)\]/);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
/**
|
||||
* Loads all cubes from discovered cube directories.
|
||||
*/
|
||||
export async function loadCubes() {
|
||||
const cubesFolders = findCubeDirectories();
|
||||
const cubes = {};
|
||||
const errors = [];
|
||||
async function scanDirectory(currentDir, baseDir) {
|
||||
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
const files = entries.filter((e) => e.isFile());
|
||||
const manifestFile = files.find((f) => f.name === 'manifest.mjs' || f.name.endsWith('.manifest.mjs'));
|
||||
const deployFile = files.find((f) => f.name === 'deploy.py' || f.name.endsWith('.deploy.py'));
|
||||
if (manifestFile && deployFile) {
|
||||
const cubePath = currentDir;
|
||||
const manifestPath = path.join(cubePath, manifestFile.name);
|
||||
try {
|
||||
const manifest = (await import(manifestPath)).default;
|
||||
if (!manifest || typeof manifest !== 'object') {
|
||||
errors.push(`Invalid manifest export in ${manifestPath}`);
|
||||
}
|
||||
else if (!manifest.name) {
|
||||
errors.push(`Invalid manifest format in ${manifestPath}: missing 'name'`);
|
||||
}
|
||||
else {
|
||||
const cubeId = extractCubeId(manifest) || path.basename(cubePath);
|
||||
if (cubes[cubeId]) {
|
||||
errors.push(`Duplicate cube id '${cubeId}'`);
|
||||
return;
|
||||
}
|
||||
// Ensure basic properties
|
||||
manifest.id = cubeId;
|
||||
manifest.schema = manifest.schema ?? z.object({});
|
||||
cubes[cubeId] = new Cube(manifest, cubePath, deployFile.name);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
errors.push(`Failed to load manifest ${manifestPath}: ${err}`);
|
||||
}
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
||||
await scanDirectory(path.join(currentDir, entry.name), baseDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(cubesFolders.map(async (folder) => {
|
||||
if (fs.existsSync(folder)) {
|
||||
await scanDirectory(folder, folder);
|
||||
}
|
||||
}));
|
||||
return { cubes, errors };
|
||||
}
|
||||
/**
|
||||
* Gets information about a single cube by name.
|
||||
*/
|
||||
export async function getCube(cubeName) {
|
||||
const { cubes } = await loadCubes();
|
||||
return cubes[cubeName];
|
||||
}
|
||||
Vendored
+74
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Type definitions for Nopy cubes
|
||||
* @module cubes/types
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
/**
|
||||
* Variables that can be passed to a cube
|
||||
*/
|
||||
export type CubeVariables = Record<string, string | number | boolean>;
|
||||
/**
|
||||
* A dependency specification
|
||||
*/
|
||||
export type DependencySpec = string | [id: string, variables?: CubeVariables];
|
||||
/**
|
||||
* Context passed to cube hooks for executing other cubes
|
||||
*/
|
||||
export interface HookContext {
|
||||
exec: (key: string, variables: CubeVariables) => Promise<void> | void;
|
||||
}
|
||||
/**
|
||||
* Hook function type for before/after cube execution
|
||||
*/
|
||||
export type Hook<Schema extends z.AnyZodObject = z.AnyZodObject> = (ctx: HookContext, variables: z.infer<Schema>) => void | Promise<void>;
|
||||
/**
|
||||
* User-defined specification for a cube
|
||||
*/
|
||||
export interface Manifest<Schema extends z.AnyZodObject = z.AnyZodObject> {
|
||||
/** Unique identifier for the cube (used for dependency references) */
|
||||
id: string;
|
||||
/** Human-readable name of the cube */
|
||||
name: string;
|
||||
/** Zod schema for validating cube variables */
|
||||
schema: Schema;
|
||||
/** Dynamic dependency resolver based on collected variables */
|
||||
dependencies?: (variables: z.infer<Schema>) => DependencySpec[];
|
||||
/** Hooks to run before cube execution */
|
||||
before?: Hook<Schema>[];
|
||||
/** Hooks to run after cube execution */
|
||||
after?: Hook<Schema>[];
|
||||
}
|
||||
/**
|
||||
* Factory function and namespace for Manifest
|
||||
*/
|
||||
export declare function Manifest<Schema extends z.AnyZodObject>(opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>): Manifest<Schema>;
|
||||
export declare namespace Manifest {
|
||||
/**
|
||||
* Internal create helper
|
||||
*/
|
||||
function create<Schema extends z.AnyZodObject>(opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>): Manifest<Schema>;
|
||||
}
|
||||
/**
|
||||
* A fully loaded cube with its filesystem location and runtime state
|
||||
*/
|
||||
export declare class Cube<Schema extends z.AnyZodObject = z.AnyZodObject> {
|
||||
readonly manifest: Manifest<Schema>;
|
||||
readonly dir: string;
|
||||
readonly deployScript: string;
|
||||
constructor(manifest: Manifest<Schema>, dir: string, deployScript: string);
|
||||
get id(): string;
|
||||
get name(): string;
|
||||
/**
|
||||
* Returns default values for the cube's schema
|
||||
*/
|
||||
getDefaults(): z.infer<Schema>;
|
||||
}
|
||||
/**
|
||||
* Result of loading cubes from the filesystem
|
||||
*/
|
||||
export interface LoadResult {
|
||||
/** Map of cube key to Cube object */
|
||||
cubes: Record<string, Cube>;
|
||||
/** List of errors encountered during loading */
|
||||
errors: string[];
|
||||
}
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Type definitions for Nopy cubes
|
||||
* @module cubes/types
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
/**
|
||||
* Factory function and namespace for Manifest
|
||||
*/
|
||||
export function Manifest(opts) {
|
||||
return {
|
||||
id: opts.id ?? '',
|
||||
name: opts.name,
|
||||
schema: opts.schema ?? z.object({}),
|
||||
dependencies: opts.dependencies,
|
||||
before: opts.before ?? [],
|
||||
after: opts.after ?? [],
|
||||
};
|
||||
}
|
||||
(function (Manifest) {
|
||||
/**
|
||||
* Internal create helper
|
||||
*/
|
||||
function create(opts) {
|
||||
return Manifest(opts);
|
||||
}
|
||||
Manifest.create = create;
|
||||
})(Manifest || (Manifest = {}));
|
||||
/**
|
||||
* A fully loaded cube with its filesystem location and runtime state
|
||||
*/
|
||||
export class Cube {
|
||||
manifest;
|
||||
dir;
|
||||
deployScript;
|
||||
constructor(manifest, dir, deployScript) {
|
||||
this.manifest = manifest;
|
||||
this.dir = dir;
|
||||
this.deployScript = deployScript;
|
||||
}
|
||||
get id() {
|
||||
return this.manifest.id;
|
||||
}
|
||||
get name() {
|
||||
return this.manifest.name;
|
||||
}
|
||||
/**
|
||||
* Returns default values for the cube's schema
|
||||
*/
|
||||
getDefaults() {
|
||||
try {
|
||||
return this.manifest.schema.parse({});
|
||||
}
|
||||
catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Utility functions for cubes
|
||||
* @module cubes/utils
|
||||
*/
|
||||
/**
|
||||
* Generates a random string of the specified length using the current nanotime as a seed.
|
||||
*
|
||||
* Uses a simple Linear Congruential Generator (LCG) seeded with high-resolution time.
|
||||
* Suitable for generating unique identifiers, not for cryptographic purposes.
|
||||
*
|
||||
* @param length - The desired length of the random string (default: 5)
|
||||
* @returns A random alphanumeric string of the specified length
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const id = uniqid(); // e.g., "Kx7Pm"
|
||||
* const longId = uniqid(10); // e.g., "Kx7PmQr2Yw"
|
||||
* ```
|
||||
*/
|
||||
export declare function uniqid(length?: number): string;
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Utility functions for cubes
|
||||
* @module cubes/utils
|
||||
*/
|
||||
/**
|
||||
* Generates a random string of the specified length using the current nanotime as a seed.
|
||||
*
|
||||
* Uses a simple Linear Congruential Generator (LCG) seeded with high-resolution time.
|
||||
* Suitable for generating unique identifiers, not for cryptographic purposes.
|
||||
*
|
||||
* @param length - The desired length of the random string (default: 5)
|
||||
* @returns A random alphanumeric string of the specified length
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const id = uniqid(); // e.g., "Kx7Pm"
|
||||
* const longId = uniqid(10); // e.g., "Kx7PmQr2Yw"
|
||||
* ```
|
||||
*/
|
||||
export function uniqid(length = 5) {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const charsetLength = charset.length;
|
||||
// Use process.hrtime.bigint() for high-resolution time in nanoseconds
|
||||
let seed = Number(process.hrtime.bigint() % BigInt(Number.MAX_SAFE_INTEGER));
|
||||
const randomString = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
// Simple linear congruential generator (LCG) for pseudo-randomness
|
||||
seed = (seed * 48271) % 2147483647;
|
||||
const index = seed % charsetLength;
|
||||
randomString.push(charset[index]);
|
||||
}
|
||||
return randomString.join('');
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Nopy - A CLI tool for pyinfra script management and execution
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
export * from './cubes/index.js';
|
||||
export { cubes } from './nopy.cubes.js';
|
||||
export { nopy } from './nopy.main.js';
|
||||
export type { NopyOptions, NopyResult } from './nopy.main.js';
|
||||
export { executeDeployCalls, outputExecutionPlan, summarizeResults, } from './nopy.executor.js';
|
||||
export type { DeployCall, ExecutionResult, ExecutionOptions, } from './nopy.executor.js';
|
||||
export { runWorkflow, runInteractiveWorkflow, runReplayWorkflow, runSessionReplayWorkflow, } from './nopy.workflow.js';
|
||||
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
|
||||
export { CubeSelection, AuthSelection, HostSelection, VariableAssignment, PasswordSelection, } from './nopy.prompts.js';
|
||||
export { loadSession, saveSession, createSession, listSessions, filterInternalVariables, separateEnvAndCubeVariables, } from './nopy.session.js';
|
||||
export type { NopySession, CubeSession, AuthSession } from './nopy.session.js';
|
||||
export { loadHistory, saveHistory, addToHistory, getLastSession, getSessionById, listHistory, clearHistory, removeFromHistory, formatHistoryList, getHistoryPath, DEFAULT_HISTORY_SIZE, HISTORY_FILE, } from './nopy.history.js';
|
||||
export type { HistoryEntry, SessionHistory } from './nopy.history.js';
|
||||
export { loadConfig, saveConfig, logConfigToFlags, getConfigPaths } from './nopy.config.js';
|
||||
export type { NopyConfig, NopyConfigFile, LogConfig, LogVerbosity, HistoryConfig, ExecutionConfig, ResolutionStrategy, ResolutionConfig, } from './nopy.config.js';
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Nopy - A CLI tool for pyinfra script management and execution
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
// Cubes module
|
||||
export * from './cubes/index.js';
|
||||
// Backwards compatibility - cubes namespace
|
||||
export { cubes } from './nopy.cubes.js';
|
||||
// Main entry point
|
||||
export { nopy } from './nopy.main.js';
|
||||
// Executor
|
||||
export { executeDeployCalls, outputExecutionPlan, summarizeResults, } from './nopy.executor.js';
|
||||
// Workflow
|
||||
export { runWorkflow, runInteractiveWorkflow, runReplayWorkflow, runSessionReplayWorkflow, } from './nopy.workflow.js';
|
||||
// Prompts
|
||||
export { CubeSelection, AuthSelection, HostSelection, VariableAssignment, PasswordSelection, } from './nopy.prompts.js';
|
||||
// Session management
|
||||
export { loadSession, saveSession, createSession, listSessions, filterInternalVariables, separateEnvAndCubeVariables, } from './nopy.session.js';
|
||||
// History management
|
||||
export { loadHistory, saveHistory, addToHistory, getLastSession, getSessionById, listHistory, clearHistory, removeFromHistory, formatHistoryList, getHistoryPath, DEFAULT_HISTORY_SIZE, HISTORY_FILE, } from './nopy.history.js';
|
||||
// Configuration
|
||||
export { loadConfig, saveConfig, logConfigToFlags, getConfigPaths } from './nopy.config.js';
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Nopy CLI - pyinfra deployment management
|
||||
* @module nopy.cli
|
||||
*/
|
||||
export {};
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Nopy CLI - pyinfra deployment management
|
||||
* @module nopy.cli
|
||||
*/
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from './nopy.config.js';
|
||||
import { clearHistory, formatHistoryList, getLastSession, getSessionById, listHistory, } from './nopy.history.js';
|
||||
import { nopy } from './nopy.main.js';
|
||||
const program = new Command();
|
||||
const config = loadConfig();
|
||||
program
|
||||
.name('nopy')
|
||||
.version('1.0.0')
|
||||
.description('A CLI tool for pyinfra script management and execution.')
|
||||
.addHelpText('after', `
|
||||
Examples:
|
||||
$ nopy Interactive cube selection and deployment
|
||||
$ nopy -R Repeat the last deployment session
|
||||
$ nopy -H <id> Run a specific session from history
|
||||
$ nopy -l session.json Load and replay a saved session file
|
||||
$ nopy -s session.json Save session to file after deployment
|
||||
$ nopy -n Dry run (show plan without executing)
|
||||
$ nopy -P Print deploy commands only
|
||||
$ nopy history List all saved sessions
|
||||
$ nopy clear-history Clear session history
|
||||
|
||||
Session Replay:
|
||||
Sessions are automatically saved to history after each deployment.
|
||||
Use 'nopy history' to see available sessions and their IDs.
|
||||
Use 'nopy -R' to quickly repeat the last session.
|
||||
Use 'nopy -H <id>' to run any session from history.
|
||||
`);
|
||||
program
|
||||
.command('install', { isDefault: true })
|
||||
.description('Install cubes on a given host')
|
||||
.alias('i')
|
||||
.option('-D, --use-defaults', 'Run cubes with default values without prompts')
|
||||
.option('-K, --auth-method-key', 'Use SSH key authentication')
|
||||
.option('-R, --repeat-last', 'Repeat the last session from history')
|
||||
.option('-H, --history <id>', 'Run a specific session from history by ID')
|
||||
.option('-s, --save-session <path>', 'Save session to file for later replay')
|
||||
.option('-l, --load-session <path>', 'Load and replay session from file')
|
||||
.option('-n, --dry-run', 'Show execution plan without running')
|
||||
.option('-P, --print-only', 'Print deploy commands and exit (no execution)')
|
||||
.option('-c, --continue-on-error', 'Continue executing after failures')
|
||||
.option('-j, --json', 'Output results as JSON')
|
||||
.option('--no-history', 'Do not save this session to history')
|
||||
.action(async (options) => {
|
||||
// Apply config defaults
|
||||
const execConfig = config.execution ?? {};
|
||||
const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false;
|
||||
try {
|
||||
// Handle session replay
|
||||
const loadSessionPath = options.loadSession;
|
||||
let sessionToReplay;
|
||||
if (options.repeatLast) {
|
||||
const lastEntry = getLastSession();
|
||||
if (!lastEntry) {
|
||||
console.error('No sessions in history. Run a deployment first.');
|
||||
process.exit(1);
|
||||
}
|
||||
sessionToReplay = lastEntry;
|
||||
console.log(`Repeating: ${lastEntry.name}\n`);
|
||||
}
|
||||
else if (options.history) {
|
||||
const entry = getSessionById(options.history);
|
||||
if (!entry) {
|
||||
console.error(`Session not found: ${options.history}`);
|
||||
console.error('Use "nopy history" to list available sessions.');
|
||||
process.exit(1);
|
||||
}
|
||||
sessionToReplay = entry;
|
||||
console.log(`Running: ${entry.name}\n`);
|
||||
}
|
||||
const result = await nopy({
|
||||
useDefaults: options.useDefaults,
|
||||
useAuthKey: options.authMethodKey,
|
||||
saveSession: options.saveSession,
|
||||
loadSession: loadSessionPath,
|
||||
replaySession: sessionToReplay?.session,
|
||||
dryRun: options.dryRun,
|
||||
printOnly: options.printOnly,
|
||||
continueOnError,
|
||||
jsonOutput: options.json,
|
||||
saveToHistory: options.history !== false && !options.dryRun,
|
||||
});
|
||||
// Exit with error code if deployment failed
|
||||
if (result && !result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}, null, 2));
|
||||
}
|
||||
else {
|
||||
console.error('Error:', error instanceof Error ? error.message : error, error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
program
|
||||
.command('history')
|
||||
.description('List session history')
|
||||
.alias('h')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
.action((options) => {
|
||||
const entries = listHistory();
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
}
|
||||
else {
|
||||
console.log(formatHistoryList(entries));
|
||||
}
|
||||
});
|
||||
program
|
||||
.command('clear-history')
|
||||
.description('Clear all session history')
|
||||
.action(() => {
|
||||
clearHistory();
|
||||
console.log('Session history cleared.');
|
||||
});
|
||||
program.parse();
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Environment variable configuration
|
||||
*/
|
||||
export type TVariables = Record<string, string | number | boolean>;
|
||||
export declare namespace Variables {
|
||||
type ArtefactId = string;
|
||||
type Scope = 'defaults' | 'prompts' | 'params';
|
||||
}
|
||||
export declare class Variables {
|
||||
readonly global: TVariables;
|
||||
/** @summary env as configured in cube or session script */
|
||||
defaults: Record<Variables.ArtefactId, TVariables>;
|
||||
/** @summary env as configured via prompts */
|
||||
prompts: Record<Variables.ArtefactId, TVariables>;
|
||||
/** @summary env as handed via params (on hook calls) */
|
||||
params: Record<Variables.ArtefactId, TVariables>;
|
||||
constructor(global?: TVariables);
|
||||
assign(artefactId: Variables.ArtefactId, scope: Variables.Scope, values?: TVariables): void;
|
||||
get(artefactId: Variables.ArtefactId, scope?: Variables.Scope): TVariables;
|
||||
}
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
export class Variables {
|
||||
global;
|
||||
/** @summary env as configured in cube or session script */
|
||||
defaults = {};
|
||||
/** @summary env as configured via prompts */
|
||||
prompts = {};
|
||||
/** @summary env as handed via params (on hook calls) */
|
||||
params = {};
|
||||
constructor(global = {}) {
|
||||
this.global = global;
|
||||
}
|
||||
assign(artefactId, scope, values = {}) {
|
||||
console.log('Assigning', artefactId, scope, values);
|
||||
if (!this[scope][artefactId]) {
|
||||
this[scope][artefactId] = values;
|
||||
}
|
||||
else {
|
||||
Object.assign(this[scope][artefactId], values);
|
||||
}
|
||||
}
|
||||
get(artefactId, scope) {
|
||||
if (scope) {
|
||||
return this[scope][artefactId] || {};
|
||||
}
|
||||
return {
|
||||
...this.global,
|
||||
...this.defaults[artefactId],
|
||||
...this.prompts[artefactId],
|
||||
...this.params[artefactId],
|
||||
};
|
||||
}
|
||||
}
|
||||
Vendored
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Configuration loading and management
|
||||
* @module nopy.config
|
||||
*/
|
||||
import type { TVariables } from './nopy.common.js';
|
||||
/**
|
||||
* Log verbosity levels for pyinfra output
|
||||
*/
|
||||
export type LogVerbosity = 'silent' | 'info' | 'verbose' | 'trace';
|
||||
/**
|
||||
* Logging configuration
|
||||
*/
|
||||
export interface LogConfig {
|
||||
/** Output verbosity level */
|
||||
verbosity?: LogVerbosity;
|
||||
/** Enable pyinfra debug logging */
|
||||
debug?: boolean;
|
||||
}
|
||||
/**
|
||||
* History configuration
|
||||
*/
|
||||
export interface HistoryConfig {
|
||||
/** Maximum number of sessions to keep in history (default: 10) */
|
||||
maxSessions?: number;
|
||||
/** Whether to auto-save sessions to history (default: true) */
|
||||
autoSave?: boolean;
|
||||
}
|
||||
/**
|
||||
* Execution configuration
|
||||
*/
|
||||
export interface ExecutionConfig {
|
||||
/** Continue executing after a cube fails (default: false) */
|
||||
continueOnError?: boolean;
|
||||
}
|
||||
/**
|
||||
* Resolution strategy for merging config properties
|
||||
* - 'merge': Arrays are concatenated, objects are deep merged (default)
|
||||
* - 'override': Child value completely replaces parent value
|
||||
*/
|
||||
export type ResolutionStrategy = 'merge' | 'override';
|
||||
/**
|
||||
* Resolution configuration for customizing merge behavior
|
||||
*/
|
||||
export type ResolutionConfig = {
|
||||
[K in keyof NopyConfig]?: ResolutionStrategy;
|
||||
};
|
||||
/**
|
||||
* Raw config file structure (includes resolution)
|
||||
*/
|
||||
export interface NopyConfigFile extends Partial<NopyConfig> {
|
||||
/** Customize merge behavior for specific properties */
|
||||
resolution?: ResolutionConfig;
|
||||
}
|
||||
/**
|
||||
* Nopy configuration file structure
|
||||
*/
|
||||
export interface NopyConfig {
|
||||
/** Available host addresses */
|
||||
hosts: string[];
|
||||
/** Directories to search for cubes */
|
||||
cubeDirs: string[];
|
||||
/** Global environment variables */
|
||||
env: TVariables;
|
||||
/** Logging configuration */
|
||||
log?: LogConfig;
|
||||
/** Session history configuration */
|
||||
history?: HistoryConfig;
|
||||
/** Execution configuration */
|
||||
execution?: ExecutionConfig;
|
||||
}
|
||||
/**
|
||||
* Loads the nopy configuration
|
||||
*
|
||||
* Searches for `.nopyrc.json` by traversing upwards from cwd to root.
|
||||
* Multiple config files are merged, with child configs overriding parent configs.
|
||||
*
|
||||
* Use the `resolution` property to customize merge behavior:
|
||||
* ```json
|
||||
* {
|
||||
* "hosts": ["local-host"],
|
||||
* "resolution": {
|
||||
* "hosts": "override"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns The merged configuration
|
||||
* @throws Error if no config file is found
|
||||
*/
|
||||
export declare function loadConfig(): NopyConfig;
|
||||
/**
|
||||
* Gets the paths of all discovered config files (for debugging)
|
||||
*/
|
||||
export declare function getConfigPaths(): string[];
|
||||
/**
|
||||
* Saves configuration to a file
|
||||
*
|
||||
* @param data - Configuration data to save
|
||||
* @param configPath - Path to save to (defaults to cwd/.nopyrc.json)
|
||||
*/
|
||||
export declare function saveConfig(data: Partial<NopyConfig>, configPath?: string): void;
|
||||
/**
|
||||
* Converts log configuration to pyinfra command line flags
|
||||
*
|
||||
* @param logConfig - Log configuration with verbosity and debug settings
|
||||
* @returns Array of pyinfra flags
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const flags = logConfigToFlags({ verbosity: 'verbose', debug: true });
|
||||
* // Returns: ['-vv', '--debug']
|
||||
* ```
|
||||
*/
|
||||
export declare function logConfigToFlags(logConfig?: LogConfig): string[];
|
||||
Vendored
+263
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Configuration loading and management
|
||||
* @module nopy.config
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG = {
|
||||
hosts: [],
|
||||
cubeDirs: [],
|
||||
env: {},
|
||||
};
|
||||
const CONFIG_FILENAME = '.nopyrc.json';
|
||||
/**
|
||||
* Finds all config files by traversing upwards from cwd to root
|
||||
* Returns configs in order from root to cwd (parent first, child last)
|
||||
*/
|
||||
function findConfigFiles() {
|
||||
const configPaths = [];
|
||||
let currentDir = process.cwd();
|
||||
// Traverse upwards
|
||||
while (true) {
|
||||
const configPath = path.join(currentDir, CONFIG_FILENAME);
|
||||
if (fs.existsSync(configPath)) {
|
||||
configPaths.unshift(configPath); // Add to front (root first)
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
break; // Reached root
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
// Also check home directory (lowest priority)
|
||||
const homeConfig = path.join(process.env.HOME || '', CONFIG_FILENAME);
|
||||
if (homeConfig && fs.existsSync(homeConfig) && !configPaths.includes(homeConfig)) {
|
||||
configPaths.unshift(homeConfig);
|
||||
}
|
||||
return configPaths;
|
||||
}
|
||||
/**
|
||||
* Deep merges two values based on resolution strategy
|
||||
*/
|
||||
function mergeValue(parentValue, childValue, strategy) {
|
||||
// Override strategy: child replaces parent completely
|
||||
if (strategy === 'override') {
|
||||
return childValue;
|
||||
}
|
||||
// Merge strategy (default)
|
||||
if (Array.isArray(parentValue) && Array.isArray(childValue)) {
|
||||
// Concatenate arrays, remove duplicates for primitives
|
||||
const combined = [...parentValue, ...childValue];
|
||||
if (combined.every((v) => typeof v !== 'object')) {
|
||||
return [...new Set(combined)];
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
if (typeof parentValue === 'object' &&
|
||||
parentValue !== null &&
|
||||
typeof childValue === 'object' &&
|
||||
childValue !== null &&
|
||||
!Array.isArray(parentValue) &&
|
||||
!Array.isArray(childValue)) {
|
||||
// Deep merge objects
|
||||
const result = { ...parentValue };
|
||||
for (const [key, value] of Object.entries(childValue)) {
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, 'merge');
|
||||
}
|
||||
else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Primitives: child overrides parent
|
||||
return childValue;
|
||||
}
|
||||
/**
|
||||
* Checks if a string looks like a relative path
|
||||
*/
|
||||
function isRelativePath(value) {
|
||||
return (value.startsWith('./') ||
|
||||
value.startsWith('../') ||
|
||||
// Also match paths without ./ prefix that don't look like URLs or absolute paths
|
||||
(!value.startsWith('/') &&
|
||||
!value.startsWith('~') &&
|
||||
!value.includes('://') &&
|
||||
(value.includes('/') || value.endsWith('.json') || value.endsWith('.yml'))));
|
||||
}
|
||||
/**
|
||||
* Resolves relative paths in a value based on config file location
|
||||
*/
|
||||
function resolveRelativePaths(value, configDir) {
|
||||
if (typeof value === 'string') {
|
||||
if (isRelativePath(value)) {
|
||||
return path.resolve(configDir, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => resolveRelativePaths(item, configDir));
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const result = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
result[key] = resolveRelativePaths(val, configDir);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
/**
|
||||
* Properties that contain filesystem paths and should have relative paths resolved
|
||||
*/
|
||||
const PATH_PROPERTIES = ['cubeDirs'];
|
||||
/**
|
||||
* Resolves relative paths in a config file based on its location
|
||||
* Only resolves paths for properties that are known to contain filesystem paths
|
||||
*/
|
||||
function resolveConfigPaths(config, configPath) {
|
||||
const configDir = path.dirname(configPath);
|
||||
const resolved = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (key === 'resolution') {
|
||||
// Don't resolve the resolution config itself
|
||||
resolved[key] = value;
|
||||
}
|
||||
else if (PATH_PROPERTIES.includes(key)) {
|
||||
// Only resolve paths for known path properties
|
||||
resolved[key] = resolveRelativePaths(value, configDir);
|
||||
}
|
||||
else {
|
||||
// Copy other properties as-is (including hosts)
|
||||
resolved[key] = value;
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
/**
|
||||
* Merges a child config into a parent config
|
||||
*/
|
||||
function mergeConfigs(parent, childFile) {
|
||||
const resolution = childFile.resolution || {};
|
||||
const result = { ...parent };
|
||||
for (const [key, value] of Object.entries(childFile)) {
|
||||
if (key === 'resolution')
|
||||
continue; // Skip resolution property itself
|
||||
const strategy = resolution[key] || 'merge';
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, strategy);
|
||||
}
|
||||
else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Loads the nopy configuration
|
||||
*
|
||||
* Searches for `.nopyrc.json` by traversing upwards from cwd to root.
|
||||
* Multiple config files are merged, with child configs overriding parent configs.
|
||||
*
|
||||
* Use the `resolution` property to customize merge behavior:
|
||||
* ```json
|
||||
* {
|
||||
* "hosts": ["local-host"],
|
||||
* "resolution": {
|
||||
* "hosts": "override"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns The merged configuration
|
||||
* @throws Error if no config file is found
|
||||
*/
|
||||
export function loadConfig() {
|
||||
const configPaths = findConfigFiles();
|
||||
if (configPaths.length === 0) {
|
||||
throw new Error(`No ${CONFIG_FILENAME} found. Create one in your project directory or any parent directory.`);
|
||||
}
|
||||
// Start with defaults and merge each config file
|
||||
let config = { ...DEFAULT_CONFIG };
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const rawConfig = JSON.parse(content);
|
||||
// Resolve relative paths based on config file location
|
||||
const resolvedConfig = resolveConfigPaths(rawConfig, configPath);
|
||||
config = mergeConfigs(config, resolvedConfig);
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to load config ${configPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
/**
|
||||
* Gets the paths of all discovered config files (for debugging)
|
||||
*/
|
||||
export function getConfigPaths() {
|
||||
return findConfigFiles();
|
||||
}
|
||||
/**
|
||||
* Saves configuration to a file
|
||||
*
|
||||
* @param data - Configuration data to save
|
||||
* @param configPath - Path to save to (defaults to cwd/.nopyrc.json)
|
||||
*/
|
||||
export function saveConfig(data, configPath) {
|
||||
const savePath = configPath || path.resolve(process.cwd(), CONFIG_FILENAME);
|
||||
// Try to load existing config from this specific file
|
||||
let existing = {};
|
||||
if (fs.existsSync(savePath)) {
|
||||
try {
|
||||
existing = JSON.parse(fs.readFileSync(savePath, 'utf-8'));
|
||||
}
|
||||
catch {
|
||||
// Ignore parse errors, start fresh
|
||||
}
|
||||
}
|
||||
const merged = { ...existing, ...data };
|
||||
fs.writeFileSync(savePath, JSON.stringify(merged, null, 2));
|
||||
}
|
||||
/**
|
||||
* Converts log configuration to pyinfra command line flags
|
||||
*
|
||||
* @param logConfig - Log configuration with verbosity and debug settings
|
||||
* @returns Array of pyinfra flags
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const flags = logConfigToFlags({ verbosity: 'verbose', debug: true });
|
||||
* // Returns: ['-vv', '--debug']
|
||||
* ```
|
||||
*/
|
||||
export function logConfigToFlags(logConfig) {
|
||||
const flags = [];
|
||||
const verbosity = logConfig?.verbosity ?? 'silent';
|
||||
// Add verbosity flags
|
||||
switch (verbosity) {
|
||||
case 'silent':
|
||||
// No verbosity flags
|
||||
break;
|
||||
case 'info':
|
||||
flags.push('-v'); // Print meta information
|
||||
break;
|
||||
case 'verbose':
|
||||
flags.push('-vv'); // Print meta + input data
|
||||
break;
|
||||
case 'trace':
|
||||
flags.push('-vvv'); // Print meta + input + output
|
||||
break;
|
||||
}
|
||||
// Add debug flag if enabled
|
||||
if (logConfig?.debug) {
|
||||
flags.push('--debug');
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Backwards compatibility re-export
|
||||
*
|
||||
* This file maintains the `cubes` namespace for existing code.
|
||||
* New code should import directly from './cubes/index.js'
|
||||
*
|
||||
* @deprecated Import from './cubes/index.js' instead
|
||||
*/
|
||||
import * as cubesModule from './cubes/index.js';
|
||||
export declare const cubes: {
|
||||
Cube: typeof cubesModule.Cube;
|
||||
Manifest: typeof cubesModule.Manifest;
|
||||
createManifest: typeof cubesModule.createManifest;
|
||||
manifest: typeof cubesModule.createManifest;
|
||||
loadCubes: typeof cubesModule.loadCubes;
|
||||
getCube: typeof cubesModule.getCube;
|
||||
BuildContext: typeof cubesModule.BuildContext;
|
||||
uniqid: typeof cubesModule.uniqid;
|
||||
load: typeof cubesModule.loadCubes;
|
||||
findCubeDirectories: typeof cubesModule.findCubeDirectories;
|
||||
};
|
||||
export type { Hook, HookContext, Cube, Manifest, LoadResult, CubeVariables, } from './cubes/index.js';
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Backwards compatibility re-export
|
||||
*
|
||||
* This file maintains the `cubes` namespace for existing code.
|
||||
* New code should import directly from './cubes/index.js'
|
||||
*
|
||||
* @deprecated Import from './cubes/index.js' instead
|
||||
*/
|
||||
import * as cubesModule from './cubes/index.js';
|
||||
export const cubes = {
|
||||
// Runtime exports
|
||||
...cubesModule,
|
||||
// Aliases for backwards compatibility
|
||||
load: cubesModule.loadCubes,
|
||||
findCubeDirectories: cubesModule.findCubeDirectories,
|
||||
};
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Pyinfra command execution
|
||||
* @module nopy.executor
|
||||
*/
|
||||
import type { DependencySpec } from './cubes/types.js';
|
||||
/**
|
||||
* A deployment command ready for execution
|
||||
*/
|
||||
export interface DeployCall {
|
||||
/** Cube being deployed */
|
||||
cube: string;
|
||||
/** Target host */
|
||||
host: string;
|
||||
/** Working directory for execution */
|
||||
cwd: string;
|
||||
/** Full command array */
|
||||
command: string[];
|
||||
/** Environment variables for the cube */
|
||||
env: Record<string, unknown>;
|
||||
/** Cube dependencies */
|
||||
dependencies: DependencySpec[];
|
||||
}
|
||||
/**
|
||||
* Result of executing a deployment command
|
||||
*/
|
||||
export interface ExecutionResult {
|
||||
/** Cube that was deployed */
|
||||
cube: string;
|
||||
/** Target host */
|
||||
host: string;
|
||||
/** Whether execution succeeded */
|
||||
success: boolean;
|
||||
/** Execution duration in milliseconds */
|
||||
duration: number;
|
||||
/** Standard output (if captured) */
|
||||
stdout?: string;
|
||||
/** Standard error (if captured) */
|
||||
stderr?: string;
|
||||
/** Error if execution failed */
|
||||
error?: Error;
|
||||
}
|
||||
/**
|
||||
* Options for deployment execution
|
||||
*/
|
||||
export interface ExecutionOptions {
|
||||
/** Continue executing remaining cubes after failure */
|
||||
continueOnError?: boolean;
|
||||
/** Show what would be executed without running */
|
||||
dryRun?: boolean;
|
||||
/** Callback for progress updates */
|
||||
onProgress?: (result: ExecutionResult, completed: number, total: number) => void;
|
||||
/** Callback when execution starts */
|
||||
onStart?: (cube: string, host: string) => void;
|
||||
}
|
||||
/**
|
||||
* Outputs the execution plan without running (dry run)
|
||||
*
|
||||
* @param calls - Array of deployment calls
|
||||
* @param asJson - Output as JSON instead of text
|
||||
*/
|
||||
export declare function outputExecutionPlan(calls: DeployCall[], asJson?: boolean): void;
|
||||
/**
|
||||
* Executes an array of deployment calls
|
||||
*
|
||||
* @param calls - Array of deployment calls to execute
|
||||
* @param options - Execution options
|
||||
* @returns Array of execution results
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const results = await executeDeployCalls(calls, {
|
||||
* continueOnError: false,
|
||||
* onProgress: (result, completed, total) => {
|
||||
* console.log(`${completed}/${total} complete`);
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export declare function executeDeployCalls(calls: DeployCall[], options?: ExecutionOptions): Promise<ExecutionResult[]>;
|
||||
/**
|
||||
* Generates a summary of execution results
|
||||
*
|
||||
* @param results - Array of execution results
|
||||
* @returns Summary object
|
||||
*/
|
||||
export declare function summarizeResults(results: ExecutionResult[]): {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
totalDuration: number;
|
||||
failures: ExecutionResult[];
|
||||
};
|
||||
Vendored
+138
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Pyinfra command execution
|
||||
* @module nopy.executor
|
||||
*/
|
||||
import { getLogger } from '@logtape/logtape';
|
||||
import { execa } from 'execa';
|
||||
const log = getLogger(['nopy', 'executor']);
|
||||
/**
|
||||
* Executes a single deployment call
|
||||
*
|
||||
* @param call - The deployment call to execute
|
||||
* @returns Execution result
|
||||
*/
|
||||
async function executeCall(call) {
|
||||
const startTime = Date.now();
|
||||
const commandStr = call.command.join(' ');
|
||||
try {
|
||||
log.info(`Executing: ${call.cube} -> ${call.host}`);
|
||||
log.debug(`Command: ${commandStr}`);
|
||||
// Inherit stdio for live output
|
||||
await execa({ shell: true })(commandStr, {
|
||||
cwd: call.cwd,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
return {
|
||||
cube: call.cube,
|
||||
host: call.host,
|
||||
success: true,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
log.error(`Failed: ${call.cube} -> ${call.host}`, { error: err.message });
|
||||
return {
|
||||
cube: call.cube,
|
||||
host: call.host,
|
||||
success: false,
|
||||
duration: Date.now() - startTime,
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Outputs the execution plan without running (dry run)
|
||||
*
|
||||
* @param calls - Array of deployment calls
|
||||
* @param asJson - Output as JSON instead of text
|
||||
*/
|
||||
export function outputExecutionPlan(calls, asJson) {
|
||||
if (asJson) {
|
||||
const plan = calls.map((call) => ({
|
||||
cube: call.cube,
|
||||
host: call.host,
|
||||
command: call.command.join(' '),
|
||||
variables: call.env,
|
||||
}));
|
||||
console.log(JSON.stringify({ plan }, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log('\n=== Execution Plan (Dry Run) ===\n');
|
||||
for (let i = 0; i < calls.length; i++) {
|
||||
const call = calls[i];
|
||||
console.log(`Step ${i + 1}: ${call.cube} -> ${call.host}`);
|
||||
console.log(` Command: ${call.command.join(' ')}`);
|
||||
const envKeys = Object.keys(call.env);
|
||||
if (envKeys.length > 0) {
|
||||
console.log(' Variables:');
|
||||
for (const [key, value] of Object.entries(call.env)) {
|
||||
// Mask sensitive values
|
||||
const displayValue = key.toLowerCase().includes('password') ? '********' : String(value);
|
||||
console.log(` ${key}=${displayValue}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
console.log(`Total: ${calls.length} command(s)\n`);
|
||||
console.log('Run without --dry-run to execute.\n');
|
||||
}
|
||||
/**
|
||||
* Executes an array of deployment calls
|
||||
*
|
||||
* @param calls - Array of deployment calls to execute
|
||||
* @param options - Execution options
|
||||
* @returns Array of execution results
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const results = await executeDeployCalls(calls, {
|
||||
* continueOnError: false,
|
||||
* onProgress: (result, completed, total) => {
|
||||
* console.log(`${completed}/${total} complete`);
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function executeDeployCalls(calls, options = {}) {
|
||||
if (calls.length === 0) {
|
||||
log.info('No deployment calls to execute');
|
||||
return [];
|
||||
}
|
||||
if (options.dryRun) {
|
||||
outputExecutionPlan(calls);
|
||||
return [];
|
||||
}
|
||||
log.info(`Executing ${calls.length} deployment call(s)`);
|
||||
const results = [];
|
||||
for (let i = 0; i < calls.length; i++) {
|
||||
const call = calls[i];
|
||||
options.onStart?.(call.cube, call.host);
|
||||
const result = await executeCall(call);
|
||||
results.push(result);
|
||||
options.onProgress?.(result, i + 1, calls.length);
|
||||
if (!result.success && !options.continueOnError) {
|
||||
log.warn(`Stopping execution due to failure in ${call.cube}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
/**
|
||||
* Generates a summary of execution results
|
||||
*
|
||||
* @param results - Array of execution results
|
||||
* @returns Summary object
|
||||
*/
|
||||
export function summarizeResults(results) {
|
||||
const successful = results.filter((r) => r.success);
|
||||
const failed = results.filter((r) => !r.success);
|
||||
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
|
||||
return {
|
||||
total: results.length,
|
||||
successful: successful.length,
|
||||
failed: failed.length,
|
||||
totalDuration,
|
||||
failures: failed,
|
||||
};
|
||||
}
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Session history management
|
||||
* @module nopy.history
|
||||
*/
|
||||
import type { NopySession } from './nopy.session.js';
|
||||
/** Default number of sessions to keep in history */
|
||||
export declare const DEFAULT_HISTORY_SIZE = 10;
|
||||
/** History file name */
|
||||
export declare const HISTORY_FILE = ".nopy.history.json";
|
||||
/**
|
||||
* A session entry in history
|
||||
*/
|
||||
export interface HistoryEntry {
|
||||
/** Unique identifier (timestamp-based) */
|
||||
id: string;
|
||||
/** Human-readable name (timestamp + cube names) */
|
||||
name: string;
|
||||
/** ISO timestamp when session was executed */
|
||||
timestamp: string;
|
||||
/** The full session data */
|
||||
session: NopySession;
|
||||
}
|
||||
/**
|
||||
* History file structure
|
||||
*/
|
||||
export interface SessionHistory {
|
||||
/** Array of session entries, newest first */
|
||||
entries: HistoryEntry[];
|
||||
}
|
||||
/**
|
||||
* Gets the path to the history file
|
||||
*/
|
||||
export declare function getHistoryPath(): string;
|
||||
/**
|
||||
* Loads the session history from disk
|
||||
*
|
||||
* @returns The session history or empty history if file doesn't exist
|
||||
*/
|
||||
export declare function loadHistory(): SessionHistory;
|
||||
/**
|
||||
* Saves the session history to disk
|
||||
*
|
||||
* @param history - The history to save
|
||||
*/
|
||||
export declare function saveHistory(history: SessionHistory): void;
|
||||
/**
|
||||
* Adds a session to the history
|
||||
*
|
||||
* @param session - The session to add
|
||||
* @param maxEntries - Maximum number of entries to keep
|
||||
* @returns The created history entry
|
||||
*/
|
||||
export declare function addToHistory(session: NopySession, maxEntries?: number): HistoryEntry;
|
||||
/**
|
||||
* Gets the most recent session from history
|
||||
*
|
||||
* @returns The last session or undefined if history is empty
|
||||
*/
|
||||
export declare function getLastSession(): HistoryEntry | undefined;
|
||||
/**
|
||||
* Gets a session by ID
|
||||
*
|
||||
* @param id - The session ID
|
||||
* @returns The session entry or undefined
|
||||
*/
|
||||
export declare function getSessionById(id: string): HistoryEntry | undefined;
|
||||
/**
|
||||
* Lists all sessions in history
|
||||
*
|
||||
* @returns Array of history entries, newest first
|
||||
*/
|
||||
export declare function listHistory(): HistoryEntry[];
|
||||
/**
|
||||
* Clears all session history
|
||||
*/
|
||||
export declare function clearHistory(): void;
|
||||
/**
|
||||
* Removes a specific session from history
|
||||
*
|
||||
* @param id - The session ID to remove
|
||||
* @returns true if removed, false if not found
|
||||
*/
|
||||
export declare function removeFromHistory(id: string): boolean;
|
||||
/**
|
||||
* Formats history entries for display
|
||||
*
|
||||
* @param entries - History entries to format
|
||||
* @returns Formatted string for console output
|
||||
*/
|
||||
export declare function formatHistoryList(entries: HistoryEntry[]): string;
|
||||
Vendored
+170
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Session history management
|
||||
* @module nopy.history
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
/** Default number of sessions to keep in history */
|
||||
export const DEFAULT_HISTORY_SIZE = 10;
|
||||
/** History file name */
|
||||
export const HISTORY_FILE = '.nopy.history.json';
|
||||
/**
|
||||
* Gets the path to the history file
|
||||
*/
|
||||
export function getHistoryPath() {
|
||||
return path.resolve(process.cwd(), HISTORY_FILE);
|
||||
}
|
||||
/**
|
||||
* Loads the session history from disk
|
||||
*
|
||||
* @returns The session history or empty history if file doesn't exist
|
||||
*/
|
||||
export function loadHistory() {
|
||||
const historyPath = getHistoryPath();
|
||||
if (!fs.existsSync(historyPath)) {
|
||||
return { entries: [] };
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(historyPath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
catch {
|
||||
return { entries: [] };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Saves the session history to disk
|
||||
*
|
||||
* @param history - The history to save
|
||||
*/
|
||||
export function saveHistory(history) {
|
||||
const historyPath = getHistoryPath();
|
||||
fs.writeFileSync(historyPath, JSON.stringify(history, null, 2), 'utf-8');
|
||||
}
|
||||
/**
|
||||
* Generates a history entry name from session data
|
||||
*
|
||||
* Format: "YYYY-MM-DD HH:mm - cube1, cube2, ..."
|
||||
*
|
||||
* @param session - The session to name
|
||||
* @param timestamp - ISO timestamp
|
||||
* @returns Human-readable name
|
||||
*/
|
||||
function generateEntryName(session, timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const dateStr = date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const cubeNames = session.cubes.map((c) => c.key).join(', ');
|
||||
const truncatedCubes = cubeNames.length > 40 ? `${cubeNames.substring(0, 37)}...` : cubeNames;
|
||||
const hosts = session.hosts?.join(', ') || 'no host';
|
||||
const truncatedHosts = hosts.length > 20 ? `${hosts.substring(0, 17)}...` : hosts;
|
||||
return `${dateStr} - ${truncatedCubes} → ${truncatedHosts}`;
|
||||
}
|
||||
/**
|
||||
* Generates a unique ID for a history entry
|
||||
*/
|
||||
function generateEntryId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
||||
}
|
||||
/**
|
||||
* Adds a session to the history
|
||||
*
|
||||
* @param session - The session to add
|
||||
* @param maxEntries - Maximum number of entries to keep
|
||||
* @returns The created history entry
|
||||
*/
|
||||
export function addToHistory(session, maxEntries = DEFAULT_HISTORY_SIZE) {
|
||||
const history = loadHistory();
|
||||
const timestamp = new Date().toISOString();
|
||||
const entry = {
|
||||
id: generateEntryId(),
|
||||
name: generateEntryName(session, timestamp),
|
||||
timestamp,
|
||||
session,
|
||||
};
|
||||
// Add to beginning (newest first)
|
||||
history.entries.unshift(entry);
|
||||
// Trim to max size
|
||||
if (history.entries.length > maxEntries) {
|
||||
history.entries = history.entries.slice(0, maxEntries);
|
||||
}
|
||||
saveHistory(history);
|
||||
return entry;
|
||||
}
|
||||
/**
|
||||
* Gets the most recent session from history
|
||||
*
|
||||
* @returns The last session or undefined if history is empty
|
||||
*/
|
||||
export function getLastSession() {
|
||||
const history = loadHistory();
|
||||
return history.entries[0];
|
||||
}
|
||||
/**
|
||||
* Gets a session by ID
|
||||
*
|
||||
* @param id - The session ID
|
||||
* @returns The session entry or undefined
|
||||
*/
|
||||
export function getSessionById(id) {
|
||||
const history = loadHistory();
|
||||
return history.entries.find((e) => e.id === id);
|
||||
}
|
||||
/**
|
||||
* Lists all sessions in history
|
||||
*
|
||||
* @returns Array of history entries, newest first
|
||||
*/
|
||||
export function listHistory() {
|
||||
const history = loadHistory();
|
||||
return history.entries;
|
||||
}
|
||||
/**
|
||||
* Clears all session history
|
||||
*/
|
||||
export function clearHistory() {
|
||||
saveHistory({ entries: [] });
|
||||
}
|
||||
/**
|
||||
* Removes a specific session from history
|
||||
*
|
||||
* @param id - The session ID to remove
|
||||
* @returns true if removed, false if not found
|
||||
*/
|
||||
export function removeFromHistory(id) {
|
||||
const history = loadHistory();
|
||||
const initialLength = history.entries.length;
|
||||
history.entries = history.entries.filter((e) => e.id !== id);
|
||||
if (history.entries.length < initialLength) {
|
||||
saveHistory(history);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Formats history entries for display
|
||||
*
|
||||
* @param entries - History entries to format
|
||||
* @returns Formatted string for console output
|
||||
*/
|
||||
export function formatHistoryList(entries) {
|
||||
if (entries.length === 0) {
|
||||
return 'No sessions in history.';
|
||||
}
|
||||
const lines = ['', 'Session History:', ''];
|
||||
entries.forEach((entry, index) => {
|
||||
const marker = index === 0 ? '→' : ' ';
|
||||
lines.push(` ${marker} [${index + 1}] ${entry.name}`);
|
||||
lines.push(` ID: ${entry.id}`);
|
||||
});
|
||||
lines.push('');
|
||||
lines.push(`Total: ${entries.length} session(s)`);
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Main entry point for nopy
|
||||
* @module nopy.main
|
||||
*/
|
||||
import { type ExecutionResult } from './nopy.executor.js';
|
||||
import { type NopySession } from './nopy.session.js';
|
||||
/**
|
||||
* Options for the nopy main function
|
||||
*/
|
||||
export interface NopyOptions {
|
||||
useDefaults?: boolean;
|
||||
useAuthKey?: boolean;
|
||||
saveSession?: string;
|
||||
loadSession?: string;
|
||||
replaySession?: NopySession;
|
||||
dryRun?: boolean;
|
||||
printOnly?: boolean;
|
||||
continueOnError?: boolean;
|
||||
jsonOutput?: boolean;
|
||||
saveToHistory?: boolean;
|
||||
}
|
||||
/**
|
||||
* Result of a nopy execution
|
||||
*/
|
||||
export interface NopyResult {
|
||||
success: boolean;
|
||||
results: ExecutionResult[];
|
||||
summary: {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
totalDuration: number;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Main entry point for nopy deployments
|
||||
*/
|
||||
export declare function nopy(opts?: NopyOptions): Promise<NopyResult | undefined>;
|
||||
Vendored
+163
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Main entry point for nopy
|
||||
* @module nopy.main
|
||||
*/
|
||||
import { configure, getAnsiColorFormatter, getLogger } from '@logtape/logtape';
|
||||
import { loadCubes } from './cubes/index.js';
|
||||
import { BuildContext } from './cubes/dependencies.js';
|
||||
import { Variables } from './nopy.common.js';
|
||||
import { getConfigPaths, loadConfig } from './nopy.config.js';
|
||||
import { executeDeployCalls, summarizeResults } from './nopy.executor.js';
|
||||
import { DEFAULT_HISTORY_SIZE, addToHistory } from './nopy.history.js';
|
||||
import { saveSession } from './nopy.session.js';
|
||||
import { runWorkflow } from './nopy.workflow.js';
|
||||
/**
|
||||
* Configures the logtape logger for console output
|
||||
*/
|
||||
function configureLogtape() {
|
||||
configure({
|
||||
sinks: {
|
||||
console: (() => {
|
||||
const formatter = getAnsiColorFormatter();
|
||||
return (record) => {
|
||||
const formatted = formatter(record);
|
||||
if (typeof formatted === 'string') {
|
||||
const msg = formatted.replace(/\r?\n$/, '');
|
||||
const props = record.properties;
|
||||
console.log(msg, ...Object.values(props));
|
||||
}
|
||||
};
|
||||
})(),
|
||||
},
|
||||
loggers: [
|
||||
{
|
||||
category: ['logtape', 'meta'],
|
||||
level: 'error',
|
||||
sinks: ['console'],
|
||||
},
|
||||
{
|
||||
category: 'nopy',
|
||||
level: 'debug',
|
||||
sinks: ['console'],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
// Initialize logging
|
||||
configureLogtape();
|
||||
/**
|
||||
* Prints the active configuration summary
|
||||
*/
|
||||
function printActiveConfig(config, opts) {
|
||||
const configPaths = getConfigPaths();
|
||||
const cwd = process.cwd();
|
||||
const lines = [''];
|
||||
lines.push(' Configuration');
|
||||
lines.push(' ─────────────');
|
||||
const relativePaths = configPaths.map((p) => {
|
||||
if (p.startsWith(cwd))
|
||||
return `.${p.slice(cwd.length)}`;
|
||||
if (p.startsWith(process.env.HOME || ''))
|
||||
return `~${p.slice((process.env.HOME || '').length)}`;
|
||||
return p;
|
||||
});
|
||||
lines.push(` Config: ${relativePaths.join(' → ')}`);
|
||||
if (config.hosts.length > 0)
|
||||
lines.push(` Hosts: ${config.hosts.join(', ')}`);
|
||||
if (config.cubeDirs.length > 0)
|
||||
lines.push(` Cube dirs: ${config.cubeDirs.join(', ')}`);
|
||||
if (opts.continueOnError)
|
||||
lines.push(' Execution: continue-on-error');
|
||||
const envEntries = Object.entries(config.env);
|
||||
if (envEntries.length > 0) {
|
||||
lines.push(' Env vars:');
|
||||
for (const [key, value] of envEntries) {
|
||||
const isEmpty = value === null || value === undefined || value === '';
|
||||
lines.push(` ${key}: ${isEmpty ? '<EMPTY>' : '<VALUE>'}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
/**
|
||||
* Main entry point for nopy deployments
|
||||
*/
|
||||
export async function nopy(opts = {}) {
|
||||
const { useDefaults = false, useAuthKey, saveSession: saveSessionPath, loadSession: loadSessionPath, replaySession, dryRun = false, printOnly = false, continueOnError = false, jsonOutput = false, saveToHistory = true, } = opts;
|
||||
const log = getLogger(['nopy']);
|
||||
const config = loadConfig();
|
||||
if (!jsonOutput && !replaySession && !loadSessionPath) {
|
||||
printActiveConfig(config, { continueOnError });
|
||||
}
|
||||
const { cubes, errors } = await loadCubes();
|
||||
const variables = new Variables(config.env);
|
||||
if (errors.length > 0) {
|
||||
log.error('Errors found during cube loading:');
|
||||
errors.forEach((error) => log.error(error));
|
||||
if (jsonOutput)
|
||||
console.log(JSON.stringify({ success: false, errors }, null, 2));
|
||||
return undefined;
|
||||
}
|
||||
const workflow = await runWorkflow(loadSessionPath, cubes, config, { useDefaults, useAuthKey }, replaySession);
|
||||
// Step 3: Build deployment calls using BuildContext
|
||||
const context = new BuildContext(cubes, variables, workflow.session, config, {
|
||||
method: workflow.authMethod,
|
||||
username: workflow.username,
|
||||
password: workflow.password,
|
||||
}, {
|
||||
useDefaults,
|
||||
isSessionReplay: workflow.isReplay,
|
||||
});
|
||||
for (const host of workflow.session.hosts) {
|
||||
for (const cubeId of workflow.selectedCubes) {
|
||||
await context.resolveCube(cubeId, host);
|
||||
}
|
||||
}
|
||||
const sessionForSaving = {
|
||||
...workflow.session,
|
||||
cubes: context.cubeSessions,
|
||||
env: variables.get('global'),
|
||||
};
|
||||
if (saveSessionPath && !workflow.isReplay) {
|
||||
saveSession(sessionForSaving, saveSessionPath);
|
||||
}
|
||||
if (saveToHistory && !dryRun && !workflow.isReplay && context.deployCalls.length > 0) {
|
||||
const historySize = config.history?.maxSessions ?? DEFAULT_HISTORY_SIZE;
|
||||
if (config.history?.autoSave !== false) {
|
||||
addToHistory(sessionForSaving, historySize);
|
||||
}
|
||||
}
|
||||
if (printOnly) {
|
||||
console.log('\n Deploy Commands\n ───────────────\n');
|
||||
for (const call of context.deployCalls) {
|
||||
console.log(` # ${call.cube} -> ${call.host}`);
|
||||
console.log(` ${call.command.join(' ')}\n`);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
results: [],
|
||||
summary: { total: context.deployCalls.length, successful: 0, failed: 0, totalDuration: 0 },
|
||||
};
|
||||
}
|
||||
const results = await executeDeployCalls(context.deployCalls, {
|
||||
dryRun,
|
||||
continueOnError,
|
||||
onProgress: (result, completed, total) => {
|
||||
if (!jsonOutput) {
|
||||
const status = result.success ? '✓' : '✗';
|
||||
log.info(`[${completed}/${total}] ${status} ${result.cube} -> ${result.host}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
const summary = summarizeResults(results);
|
||||
return {
|
||||
success: summary.failed === 0,
|
||||
results,
|
||||
summary: {
|
||||
total: summary.total,
|
||||
successful: summary.successful,
|
||||
failed: summary.failed,
|
||||
totalDuration: summary.totalDuration,
|
||||
},
|
||||
};
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Interactive prompts for nopy CLI
|
||||
* @module nopy.prompts
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import type { Cube } from './cubes/index.js';
|
||||
import type { Variables } from './nopy.common.js';
|
||||
/**
|
||||
* Prompts the user to select cubes to execute with filtering support
|
||||
*/
|
||||
export declare function CubeSelection(cubes: Record<string, Cube>): Promise<{
|
||||
selectedCubes: string[];
|
||||
}>;
|
||||
export declare function AuthSelection(useAuthKey?: boolean): Promise<{
|
||||
authMethod: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}>;
|
||||
export declare function PasswordSelection(username: string): Promise<string>;
|
||||
export declare function HostSelection(hosts: string[]): Promise<string>;
|
||||
export declare function VariableAssignment<S extends z.AnyZodObject>(cube: Cube<S>, variables: Variables): Promise<void>;
|
||||
Vendored
+174
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Interactive prompts for nopy CLI
|
||||
* @module nopy.prompts
|
||||
*/
|
||||
// @ts-ignore - no types available
|
||||
import Enquirer from 'enquirer';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
// @ts-ignore - no types available
|
||||
import CheckboxPlus from 'inquirer-checkbox-plus-prompt';
|
||||
import { z } from 'zod';
|
||||
// Register the checkbox-plus prompt type for filterable multi-select
|
||||
inquirer.registerPrompt('checkbox-plus', CheckboxPlus);
|
||||
/**
|
||||
* Prompts the user to select cubes to execute with filtering support
|
||||
*/
|
||||
export async function CubeSelection(cubes) {
|
||||
const cubeChoices = Object.values(cubes)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map((cube) => ({
|
||||
name: `${cube.id} - ${cube.name}`,
|
||||
value: cube.id,
|
||||
short: cube.id,
|
||||
}));
|
||||
// Clear terminal and move cursor to top
|
||||
process.stdout.write('\x1B[2J\x1B[0f');
|
||||
const terminalHeight = process.stdout.rows || 24;
|
||||
const pageSize = Math.max(10, terminalHeight - 5);
|
||||
console.log('\n Cube Selection\n');
|
||||
console.log(' Type to filter • Space to select • Enter to confirm\n');
|
||||
const answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox-plus',
|
||||
name: 'selectedCubes',
|
||||
message: 'Select cubes:',
|
||||
pageSize,
|
||||
highlight: true,
|
||||
searchable: true,
|
||||
source: (_answersSoFar, input) => {
|
||||
const searchTerm = input || '';
|
||||
if (!searchTerm)
|
||||
return Promise.resolve(cubeChoices);
|
||||
const results = fuzzy.filter(searchTerm, cubeChoices, {
|
||||
extract: (choice) => choice.name,
|
||||
});
|
||||
return Promise.resolve(results.map((r) => r.original));
|
||||
},
|
||||
},
|
||||
]);
|
||||
return { selectedCubes: answers.selectedCubes };
|
||||
}
|
||||
export async function AuthSelection(useAuthKey) {
|
||||
if (useAuthKey)
|
||||
return { authMethod: 'ssh-key' };
|
||||
const answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'authMethod',
|
||||
message: 'Select authentication method:',
|
||||
choices: ['ssh-key', 'password'],
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'username',
|
||||
message: 'Enter username:',
|
||||
when: (answers) => answers.authMethod !== 'ssh-key',
|
||||
},
|
||||
{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: 'Enter password:',
|
||||
when: (answers) => answers.authMethod !== 'ssh-key',
|
||||
},
|
||||
]);
|
||||
return answers;
|
||||
}
|
||||
export async function PasswordSelection(username) {
|
||||
const { password } = await inquirer.prompt([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: `Enter password for ${username}:`,
|
||||
},
|
||||
]);
|
||||
return password;
|
||||
}
|
||||
export async function HostSelection(hosts) {
|
||||
const selectedHost = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'host',
|
||||
message: 'Select host from inventory',
|
||||
choices: ['docker', 'vagrant', ...hosts, 'custom'],
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'customHost',
|
||||
message: 'Specify custom host address:',
|
||||
when: (answers) => answers.host === 'custom',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'vagrantVM',
|
||||
message: 'Specify vagrant machine:',
|
||||
default: 'default',
|
||||
when: (answers) => answers.host === 'vagrant',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'dockerContainer',
|
||||
message: 'Specify docker container name:',
|
||||
when: (answers) => answers.host === 'runtime:docker',
|
||||
},
|
||||
]);
|
||||
if (selectedHost.host === 'vagrant')
|
||||
return `@vagrant/${selectedHost.vagrantVM}`;
|
||||
if (selectedHost.host === 'runtime:docker')
|
||||
return `@docker/${selectedHost.dockerContainer}`;
|
||||
return selectedHost.customHost ?? selectedHost.host;
|
||||
}
|
||||
function coerceValue(value, zodType) {
|
||||
if (typeof value !== 'string')
|
||||
return value;
|
||||
if (zodType instanceof z.ZodDefault)
|
||||
return coerceValue(value, zodType._def.innerType);
|
||||
if (zodType instanceof z.ZodOptional)
|
||||
return coerceValue(value, zodType._def.innerType);
|
||||
if (zodType instanceof z.ZodNullable) {
|
||||
if (value === 'null' || value === '')
|
||||
return null;
|
||||
return coerceValue(value, zodType._def.innerType);
|
||||
}
|
||||
if (zodType instanceof z.ZodBoolean)
|
||||
return value === 'true' || value === 'yes' || value === '1';
|
||||
if (zodType instanceof z.ZodNumber) {
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? value : num;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
export async function VariableAssignment(cube, variables) {
|
||||
const schema = cube.manifest.schema.shape;
|
||||
const defaults = cube.getDefaults();
|
||||
const variablesToConfigure = {};
|
||||
for (const [key, defaultValue] of Object.entries(defaults)) {
|
||||
if (variables.get(cube.id, 'params')[key] === undefined) {
|
||||
variablesToConfigure[key] = defaultValue;
|
||||
}
|
||||
}
|
||||
if (Object.keys(variablesToConfigure).length === 0)
|
||||
return;
|
||||
const choices = Object.entries(variablesToConfigure).map(([key, value]) => {
|
||||
const zodType = schema[key];
|
||||
const description = zodType?.description || key;
|
||||
return { name: key, message: description, initial: String(value ?? '') };
|
||||
});
|
||||
const form = new Enquirer.Form({
|
||||
name: 'variables',
|
||||
message: `[${cube.id}] ${cube.name}\n (↑↓ navigate, Enter to submit)`,
|
||||
choices,
|
||||
});
|
||||
try {
|
||||
const result = await form.run();
|
||||
const coercedResult = {};
|
||||
for (const [key, value] of Object.entries(result)) {
|
||||
const zodType = schema[key];
|
||||
coercedResult[key] = zodType ? coerceValue(value, zodType) : value;
|
||||
}
|
||||
variables.assign(cube.id, 'prompts', coercedResult);
|
||||
}
|
||||
catch {
|
||||
// User cancelled
|
||||
}
|
||||
}
|
||||
Vendored
+112
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Session management for saving and replaying deployments
|
||||
* @module nopy.session
|
||||
*/
|
||||
import type { TVariables } from './nopy.common.js';
|
||||
/**
|
||||
* Primitive value types that can be stored in session variables
|
||||
*/
|
||||
export type SessionValue = string | number | boolean | null | undefined;
|
||||
/**
|
||||
* Record of session variables
|
||||
*/
|
||||
export type SessionVariables = Record<string, unknown>;
|
||||
/**
|
||||
* Configuration for a single cube within a session
|
||||
*/
|
||||
export interface CubeSession {
|
||||
/** Cube identifier */
|
||||
key: string;
|
||||
/** Cube-specific variables */
|
||||
variables: TVariables;
|
||||
}
|
||||
/**
|
||||
* Authentication configuration for a session
|
||||
*/
|
||||
export interface AuthSession {
|
||||
/** Authentication method */
|
||||
method: 'ssh-key' | 'password' | 'ssh';
|
||||
/** Username for authentication (password auth only) */
|
||||
username?: string;
|
||||
}
|
||||
/**
|
||||
* Complete session configuration
|
||||
*/
|
||||
export interface NopySession {
|
||||
/** Optional session name */
|
||||
name?: string;
|
||||
/** Array of cube configurations */
|
||||
cubes: CubeSession[];
|
||||
/** Target hosts */
|
||||
hosts?: string[];
|
||||
/** Authentication configuration */
|
||||
auth: AuthSession;
|
||||
/** Global environment variables */
|
||||
env?: TVariables;
|
||||
}
|
||||
/**
|
||||
* Saves a session to a JSON file
|
||||
*
|
||||
* @param session - The session to save
|
||||
* @param filePath - Path to save the session file
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* saveSession(session, './my-deployment.nopysession.json');
|
||||
* ```
|
||||
*/
|
||||
export declare function saveSession(session: NopySession, filePath: string): void;
|
||||
/**
|
||||
* Loads a session from a JSON or MJS file
|
||||
*
|
||||
* @param filePath - Path to the session file (.json or .mjs)
|
||||
* @returns The loaded session
|
||||
* @throws Error if file not found or invalid format
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const session = await loadSession('./deployment.nopysession.json');
|
||||
* ```
|
||||
*/
|
||||
export declare function loadSession(filePath: string): Promise<NopySession>;
|
||||
/**
|
||||
* Lists all session files in a directory
|
||||
*
|
||||
* @param dirPath - Directory to search for session files
|
||||
* @returns Array of session file paths
|
||||
*/
|
||||
export declare function listSessions(dirPath?: string): string[];
|
||||
/**
|
||||
* Creates a session object from runtime data
|
||||
*
|
||||
* @param params - Session parameters
|
||||
* @returns A NopySession object
|
||||
*/
|
||||
export declare function createSession(params: {
|
||||
name?: string;
|
||||
cubes: CubeSession[];
|
||||
hosts: string[];
|
||||
auth: AuthSession;
|
||||
env?: TVariables;
|
||||
}): NopySession;
|
||||
/**
|
||||
* Filters out internal variables from cube variables
|
||||
*
|
||||
* Internal variables are those used by the prompts system
|
||||
* and should not be saved in session files.
|
||||
*
|
||||
* @param variables - Variables object
|
||||
* @returns Filtered variables without internal keys
|
||||
*/
|
||||
export declare function filterInternalVariables(variables: Record<string, unknown>): Record<string, unknown>;
|
||||
/**
|
||||
* Separates environment variables from cube-specific variables
|
||||
*
|
||||
* @param allVariables - All variables including env and cube-specific
|
||||
* @param envVariables - Known environment variables from config
|
||||
* @returns Object with separate env and cube variables
|
||||
*/
|
||||
export declare function separateEnvAndCubeVariables(allVariables: Record<string, unknown>, envVariables: Record<string, unknown>): {
|
||||
env: Record<string, unknown>;
|
||||
cubeVars: Record<string, unknown>;
|
||||
};
|
||||
Vendored
+166
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Session management for saving and replaying deployments
|
||||
* @module nopy.session
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
/**
|
||||
* Saves a session to a JSON file
|
||||
*
|
||||
* @param session - The session to save
|
||||
* @param filePath - Path to save the session file
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* saveSession(session, './my-deployment.nopysession.json');
|
||||
* ```
|
||||
*/
|
||||
export function saveSession(session, filePath) {
|
||||
const sessionToSave = {
|
||||
...session,
|
||||
};
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify(sessionToSave, null, 2), 'utf-8');
|
||||
}
|
||||
/**
|
||||
* Loads a session from an MJS file
|
||||
*
|
||||
* @param filePath - Path to the MJS session file
|
||||
* @returns The loaded session
|
||||
*/
|
||||
async function loadSessionFromMJS(filePath) {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const fileUrl = `file://${absolutePath}`;
|
||||
try {
|
||||
const module = (await import(fileUrl));
|
||||
const session = module.default;
|
||||
if (!session) {
|
||||
throw new Error('MJS file must export a default object');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Failed to load MJS session: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Loads a session from a JSON file
|
||||
*
|
||||
* @param filePath - Path to the JSON session file
|
||||
* @returns The loaded session
|
||||
*/
|
||||
function loadSessionFromJSON(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
/**
|
||||
* Loads a session from a JSON or MJS file
|
||||
*
|
||||
* @param filePath - Path to the session file (.json or .mjs)
|
||||
* @returns The loaded session
|
||||
* @throws Error if file not found or invalid format
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const session = await loadSession('./deployment.nopysession.json');
|
||||
* ```
|
||||
*/
|
||||
export async function loadSession(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Session file not found: ${filePath}`);
|
||||
}
|
||||
const ext = path.extname(filePath);
|
||||
let session;
|
||||
if (ext === '.mjs') {
|
||||
session = await loadSessionFromMJS(filePath);
|
||||
}
|
||||
else if (ext === '.json') {
|
||||
session = loadSessionFromJSON(filePath);
|
||||
}
|
||||
else {
|
||||
throw new Error(`Unsupported session file format: ${ext}. Use .json or .mjs`);
|
||||
}
|
||||
// Validate required fields
|
||||
if (!session.cubes || !Array.isArray(session.cubes)) {
|
||||
throw new Error('Invalid session format: missing or invalid "cubes" field');
|
||||
}
|
||||
if (session.hosts && !Array.isArray(session.hosts)) {
|
||||
throw new Error('Invalid session format: invalid "hosts" field');
|
||||
}
|
||||
if (!session.auth) {
|
||||
throw new Error('Invalid session format: missing "auth" field');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
/**
|
||||
* Lists all session files in a directory
|
||||
*
|
||||
* @param dirPath - Directory to search for session files
|
||||
* @returns Array of session file paths
|
||||
*/
|
||||
export function listSessions(dirPath = process.cwd()) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return [];
|
||||
}
|
||||
const files = fs.readdirSync(dirPath);
|
||||
return files
|
||||
.filter((file) => file.endsWith('.session.json') || file.endsWith('.session.mjs'))
|
||||
.map((file) => path.join(dirPath, file));
|
||||
}
|
||||
/**
|
||||
* Creates a session object from runtime data
|
||||
*
|
||||
* @param params - Session parameters
|
||||
* @returns A NopySession object
|
||||
*/
|
||||
export function createSession(params) {
|
||||
return {
|
||||
name: params.name,
|
||||
cubes: params.cubes,
|
||||
hosts: params.hosts,
|
||||
auth: params.auth,
|
||||
env: params.env,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Filters out internal variables from cube variables
|
||||
*
|
||||
* Internal variables are those used by the prompts system
|
||||
* and should not be saved in session files.
|
||||
*
|
||||
* @param variables - Variables object
|
||||
* @returns Filtered variables without internal keys
|
||||
*/
|
||||
export function filterInternalVariables(variables) {
|
||||
const internalKeys = ['customize'];
|
||||
const filtered = {};
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
if (!internalKeys.includes(key)) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
/**
|
||||
* Separates environment variables from cube-specific variables
|
||||
*
|
||||
* @param allVariables - All variables including env and cube-specific
|
||||
* @param envVariables - Known environment variables from config
|
||||
* @returns Object with separate env and cube variables
|
||||
*/
|
||||
export function separateEnvAndCubeVariables(allVariables, envVariables) {
|
||||
const env = {};
|
||||
const cubeVars = {};
|
||||
for (const [key, value] of Object.entries(allVariables)) {
|
||||
if (key in envVariables) {
|
||||
env[key] = value;
|
||||
}
|
||||
else {
|
||||
cubeVars[key] = value;
|
||||
}
|
||||
}
|
||||
return { env, cubeVars };
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Workflow logic for interactive and replay modes
|
||||
* @module nopy.workflow
|
||||
*/
|
||||
import type { Cube } from './cubes/index.js';
|
||||
import type { NopyConfig } from './nopy.config.js';
|
||||
import { type NopySession } from './nopy.session.js';
|
||||
/**
|
||||
* Options for workflow execution
|
||||
*/
|
||||
export interface WorkflowOptions {
|
||||
/** Use defaults without prompting */
|
||||
useDefaults?: boolean;
|
||||
/** Force SSH key authentication */
|
||||
useAuthKey?: boolean;
|
||||
}
|
||||
/**
|
||||
* Result of running a workflow
|
||||
*/
|
||||
export interface WorkflowResult {
|
||||
/** The session configuration */
|
||||
session: NopySession;
|
||||
/** Target cubes selected for execution */
|
||||
selectedCubes: string[];
|
||||
/** Authentication method used */
|
||||
authMethod: string;
|
||||
/** Username if applicable */
|
||||
username?: string;
|
||||
/** Password if applicable */
|
||||
password?: string;
|
||||
/** Whether this is a session replay */
|
||||
isReplay: boolean;
|
||||
}
|
||||
/**
|
||||
* Runs the interactive workflow for cube selection and configuration
|
||||
*/
|
||||
export declare function runInteractiveWorkflow(cubes: Record<string, Cube>, config: NopyConfig, options?: WorkflowOptions): Promise<WorkflowResult>;
|
||||
/**
|
||||
* Runs the replay workflow from a saved session file
|
||||
*/
|
||||
export declare function runReplayWorkflow(sessionPath: string, cubes: Record<string, Cube>, config: NopyConfig): Promise<WorkflowResult>;
|
||||
/**
|
||||
* Runs replay workflow from a session object (from history)
|
||||
*/
|
||||
export declare function runSessionReplayWorkflow(session: NopySession, cubes: Record<string, Cube>, config: NopyConfig): Promise<WorkflowResult>;
|
||||
/**
|
||||
* Determines the appropriate workflow based on options
|
||||
*/
|
||||
export declare function runWorkflow(sessionPath: string | undefined, cubes: Record<string, Cube>, config: NopyConfig, options?: WorkflowOptions, replaySession?: NopySession): Promise<WorkflowResult>;
|
||||
Vendored
+149
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Workflow logic for interactive and replay modes
|
||||
* @module nopy.workflow
|
||||
*/
|
||||
import { getLogger } from '@logtape/logtape';
|
||||
import { AuthSelection, CubeSelection, HostSelection, PasswordSelection } from './nopy.prompts.js';
|
||||
import { createSession, loadSession } from './nopy.session.js';
|
||||
const log = getLogger(['nopy', 'workflow']);
|
||||
/**
|
||||
* Runs the interactive workflow for cube selection and configuration
|
||||
*/
|
||||
export async function runInteractiveWorkflow(cubes, config, options = {}) {
|
||||
const { useAuthKey } = options;
|
||||
// Step 1: Select cubes
|
||||
const { selectedCubes } = await CubeSelection(cubes);
|
||||
log.info('Selected cubes', { selectedCubes });
|
||||
if (selectedCubes.length === 0) {
|
||||
log.warn('No cubes selected');
|
||||
}
|
||||
// Step 2: Select host
|
||||
const host = await HostSelection(config.hosts);
|
||||
// Step 3: Select authentication
|
||||
const isLocalHost = host.includes('@vagrant') || host.includes('@docker');
|
||||
const authResult = isLocalHost
|
||||
? { authMethod: 'ssh', username: undefined, password: undefined }
|
||||
: await AuthSelection(useAuthKey);
|
||||
// Create session
|
||||
const session = createSession({
|
||||
cubes: [], // Will be populated during build
|
||||
hosts: [host],
|
||||
auth: {
|
||||
method: authResult.authMethod,
|
||||
username: authResult.username,
|
||||
},
|
||||
env: config.env,
|
||||
});
|
||||
return {
|
||||
session,
|
||||
selectedCubes,
|
||||
authMethod: authResult.authMethod,
|
||||
username: authResult.username,
|
||||
password: authResult.password,
|
||||
isReplay: false,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Runs the replay workflow from a saved session file
|
||||
*/
|
||||
export async function runReplayWorkflow(sessionPath, cubes, config) {
|
||||
log.info('Loading session from', { path: sessionPath });
|
||||
const session = await loadSession(sessionPath);
|
||||
log.info('Session loaded', { name: session.name, cubeCount: session.cubes.length });
|
||||
// Validate cubes exist
|
||||
for (const cubeSession of session.cubes) {
|
||||
if (!cubes[cubeSession.key]) {
|
||||
log.warn(`Cube from session not found: ${cubeSession.key}`);
|
||||
}
|
||||
}
|
||||
// Handle missing hosts
|
||||
if (!session.hosts || session.hosts.length === 0) {
|
||||
log.info('No hosts in session, prompting for selection');
|
||||
const host = await HostSelection(config.hosts);
|
||||
session.hosts = [host];
|
||||
}
|
||||
// Extract auth details
|
||||
let authMethod = session.auth.method;
|
||||
let username = session.auth.username;
|
||||
let password;
|
||||
// Prompt for password if needed (passwords are never stored)
|
||||
if (authMethod === 'password') {
|
||||
if (username) {
|
||||
password = await PasswordSelection(username);
|
||||
}
|
||||
else {
|
||||
log.info('Password auth requires username, prompting');
|
||||
const authResult = await AuthSelection(false);
|
||||
authMethod = authResult.authMethod;
|
||||
username = authResult.username;
|
||||
password = authResult.password;
|
||||
}
|
||||
}
|
||||
// Target cubes are those in the session
|
||||
const selectedCubes = session.cubes.map((c) => c.key);
|
||||
return {
|
||||
session,
|
||||
selectedCubes,
|
||||
authMethod,
|
||||
username,
|
||||
password,
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Runs replay workflow from a session object (from history)
|
||||
*/
|
||||
export async function runSessionReplayWorkflow(session, cubes, config) {
|
||||
log.info('Replaying session from history', { cubeCount: session.cubes.length });
|
||||
// Validate cubes exist
|
||||
for (const cubeSession of session.cubes) {
|
||||
if (!cubes[cubeSession.key]) {
|
||||
log.warn(`Cube from session not found: ${cubeSession.key}`);
|
||||
}
|
||||
}
|
||||
// Handle missing hosts
|
||||
if (!session.hosts || session.hosts.length === 0) {
|
||||
log.info('No hosts in session, prompting for selection');
|
||||
const host = await HostSelection(config.hosts);
|
||||
session.hosts = [host];
|
||||
}
|
||||
// Extract auth details
|
||||
let authMethod = session.auth.method;
|
||||
let username = session.auth.username;
|
||||
let password;
|
||||
// Prompt for password if needed (passwords are never stored)
|
||||
if (authMethod === 'password') {
|
||||
if (username) {
|
||||
password = await PasswordSelection(username);
|
||||
}
|
||||
else {
|
||||
log.info('Password auth requires username, prompting');
|
||||
const authResult = await AuthSelection(false);
|
||||
authMethod = authResult.authMethod;
|
||||
username = authResult.username;
|
||||
password = authResult.password;
|
||||
}
|
||||
}
|
||||
// Target cubes are those in the session
|
||||
const selectedCubes = session.cubes.map((c) => c.key);
|
||||
return {
|
||||
session,
|
||||
selectedCubes,
|
||||
authMethod,
|
||||
username,
|
||||
password,
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Determines the appropriate workflow based on options
|
||||
*/
|
||||
export async function runWorkflow(sessionPath, cubes, config, options = {}, replaySession) {
|
||||
if (replaySession) {
|
||||
return runSessionReplayWorkflow(replaySession, cubes, config);
|
||||
}
|
||||
if (sessionPath) {
|
||||
return runReplayWorkflow(sessionPath, cubes, config);
|
||||
}
|
||||
return runInteractiveWorkflow(cubes, config, options);
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
# Nopy API Reference
|
||||
|
||||
This document describes the public API for the nopy package.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Main Module](#main-module)
|
||||
- [Cubes Module](#cubes-module)
|
||||
- [Executor Module](#executor-module)
|
||||
- [Builder Module](#builder-module)
|
||||
- [Workflow Module](#workflow-module)
|
||||
- [Session Module](#session-module)
|
||||
- [Config Module](#config-module)
|
||||
- [Prompts Module](#prompts-module)
|
||||
|
||||
---
|
||||
|
||||
## Main Module
|
||||
|
||||
### `nopy(options?)`
|
||||
|
||||
Main entry point for nopy deployments.
|
||||
|
||||
```typescript
|
||||
import { nopy } from '@bitstack/nopy';
|
||||
|
||||
const result = await nopy({
|
||||
useDefaults: false,
|
||||
dryRun: true,
|
||||
});
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `useDefaults` | `boolean` | `false` | Skip variable prompts, use defaults |
|
||||
| `useAuthKey` | `boolean` | `false` | Force SSH key authentication |
|
||||
| `saveSession` | `string` | - | Path to save session file |
|
||||
| `loadSession` | `string` | - | Path to load session for replay |
|
||||
| `dryRun` | `boolean` | `false` | Show execution plan without running |
|
||||
| `parallel` | `boolean` | `false` | Execute independent cubes in parallel |
|
||||
| `continueOnError` | `boolean` | `false` | Continue after failures |
|
||||
| `jsonOutput` | `boolean` | `false` | Output results as JSON |
|
||||
|
||||
**Returns:** `Promise<NopyResult | undefined>`
|
||||
|
||||
```typescript
|
||||
interface NopyResult {
|
||||
success: boolean;
|
||||
results: ExecutionResult[];
|
||||
summary: {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
totalDuration: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cubes Module
|
||||
|
||||
The cubes module provides types and functions for working with deployment units.
|
||||
|
||||
### Types
|
||||
|
||||
#### `Cube<Schema>`
|
||||
|
||||
A fully loaded cube with filesystem location.
|
||||
|
||||
```typescript
|
||||
interface Cube<Schema extends z.AnyZodObject = z.AnyZodObject> {
|
||||
key: string; // Unique identifier
|
||||
name: string; // Human-readable name
|
||||
dir: string; // Absolute path to cube directory
|
||||
dependencies: string[];
|
||||
schema: Schema;
|
||||
defaults: () => z.infer<Schema>;
|
||||
before: Hook<Schema>[];
|
||||
after: Hook<Schema>[];
|
||||
}
|
||||
```
|
||||
|
||||
#### `Manifest<Schema>`
|
||||
|
||||
Cube manifest (used in `*.manifest.mjs` files).
|
||||
|
||||
```typescript
|
||||
interface Manifest<Schema extends z.AnyZodObject = z.AnyZodObject> {
|
||||
name: string;
|
||||
key: string;
|
||||
dependencies: string[];
|
||||
schema: Schema;
|
||||
defaults: () => z.infer<Schema>;
|
||||
before: Hook<Schema>[];
|
||||
after: Hook<Schema>[];
|
||||
}
|
||||
```
|
||||
|
||||
#### `Hook<Schema>`
|
||||
|
||||
Hook function for before/after cube execution. See [Cube Hooks](HOOKS.md) for more details.
|
||||
|
||||
```typescript
|
||||
type Hook<Schema extends z.AnyZodObject> = (
|
||||
ctx: HookContext,
|
||||
params: z.infer<Schema>
|
||||
) => void | Promise<void>;
|
||||
|
||||
interface HookContext {
|
||||
/**
|
||||
* Schedules another cube for execution.
|
||||
* @param key - The unique identifier or path of the cube.
|
||||
* @param params - Variables to pass to the cube.
|
||||
*/
|
||||
exec: (key: string, params: CubeVariables) => Promise<void> | void;
|
||||
}
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
#### `loadCubes()`
|
||||
|
||||
Loads all cubes from discovered cube directories.
|
||||
|
||||
```typescript
|
||||
const { cubes, errors } = await loadCubes();
|
||||
```
|
||||
|
||||
**Returns:** `Promise<LoadResult>`
|
||||
|
||||
```typescript
|
||||
interface LoadResult {
|
||||
cubes: Record<string, Cube>;
|
||||
errors: string[];
|
||||
}
|
||||
```
|
||||
|
||||
#### `resolveDependencies(cubes, selectedCubeNames)`
|
||||
|
||||
Resolves all transitive dependencies for selected cubes.
|
||||
|
||||
```typescript
|
||||
const order = resolveDependencies(cubes, ['apt-all']);
|
||||
// Returns: ['apt:essentials', 'apt-more', 'apt-all']
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `cubes` | `Record<string, Cube>` | Map of all available cubes |
|
||||
| `selectedCubeNames` | `string[]` | Cubes to resolve |
|
||||
|
||||
**Returns:** `string[]` - Cube names in execution order
|
||||
|
||||
**Throws:** `Error` if cube not found or circular dependency detected
|
||||
|
||||
#### `buildExecutionStages(cubes, selectedCubeNames)`
|
||||
|
||||
Groups cubes into stages for parallel execution.
|
||||
|
||||
```typescript
|
||||
const stages = buildExecutionStages(cubes, ['apt-all', 'docker']);
|
||||
// Returns: [['apt:essentials'], ['apt-more', 'docker'], ['apt-all']]
|
||||
```
|
||||
|
||||
**Returns:** `string[][]` - Array of stages
|
||||
|
||||
#### `createManifest(options)`
|
||||
|
||||
Factory function for creating cube manifests.
|
||||
|
||||
```typescript
|
||||
export default createManifest({
|
||||
name: 'My Cube',
|
||||
dependencies: () => [['apt:essentials']],
|
||||
schema: z.object({
|
||||
VERSION: z.string().default('1.0'),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
#### `uniqid(length?)`
|
||||
|
||||
Generates a random alphanumeric string.
|
||||
|
||||
```typescript
|
||||
const id = uniqid(); // 'Kx7Pm'
|
||||
const long = uniqid(10); // 'Kx7PmQr2Yw'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Executor Module
|
||||
|
||||
Handles pyinfra command execution.
|
||||
|
||||
### Types
|
||||
|
||||
#### `DeployCall`
|
||||
|
||||
A deployment command ready for execution.
|
||||
|
||||
```typescript
|
||||
interface DeployCall {
|
||||
cube: string;
|
||||
host: string;
|
||||
cwd: string;
|
||||
command: string[];
|
||||
env: Record<string, unknown>;
|
||||
dependencies: string[];
|
||||
}
|
||||
```
|
||||
|
||||
#### `ExecutionResult`
|
||||
|
||||
Result of executing a deployment command.
|
||||
|
||||
```typescript
|
||||
interface ExecutionResult {
|
||||
cube: string;
|
||||
host: string;
|
||||
success: boolean;
|
||||
duration: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: Error;
|
||||
}
|
||||
```
|
||||
|
||||
#### `ExecutionOptions`
|
||||
|
||||
Options for deployment execution.
|
||||
|
||||
```typescript
|
||||
interface ExecutionOptions {
|
||||
parallel?: boolean;
|
||||
concurrency?: number;
|
||||
continueOnError?: boolean;
|
||||
dryRun?: boolean;
|
||||
onProgress?: (result: ExecutionResult, completed: number, total: number) => void;
|
||||
onStart?: (cube: string, host: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
#### `executeDeployCalls(calls, options?)`
|
||||
|
||||
Executes an array of deployment calls.
|
||||
|
||||
```typescript
|
||||
const results = await executeDeployCalls(calls, {
|
||||
parallel: true,
|
||||
concurrency: 4,
|
||||
onProgress: (result, completed, total) => {
|
||||
console.log(`${completed}/${total}`);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### `outputExecutionPlan(calls, asJson?)`
|
||||
|
||||
Outputs the execution plan without running.
|
||||
|
||||
```typescript
|
||||
outputExecutionPlan(deployCalls); // Text output
|
||||
outputExecutionPlan(deployCalls, true); // JSON output
|
||||
```
|
||||
|
||||
#### `summarizeResults(results)`
|
||||
|
||||
Generates a summary of execution results.
|
||||
|
||||
```typescript
|
||||
const summary = summarizeResults(results);
|
||||
// {
|
||||
// total: 5,
|
||||
// successful: 4,
|
||||
// failed: 1,
|
||||
// totalDuration: 12345,
|
||||
// failures: [{ cube: 'docker', ... }]
|
||||
// }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Builder Module
|
||||
|
||||
Constructs deployment commands.
|
||||
|
||||
### `buildDeployCalls(cubeNames, hosts, context)`
|
||||
|
||||
Builds deployment calls for all cubes and hosts.
|
||||
|
||||
```typescript
|
||||
const result = await buildDeployCalls(
|
||||
['apt:essentials', 'apt-more'],
|
||||
['@docker/test'],
|
||||
{
|
||||
cubes,
|
||||
session,
|
||||
config,
|
||||
authMethod: 'ssh-key',
|
||||
useDefaults: true,
|
||||
isSessionReplay: false,
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**Returns:** `Promise<BuildResult>`
|
||||
|
||||
```typescript
|
||||
interface BuildResult {
|
||||
deployCalls: DeployCall[];
|
||||
cubeSessions: CubeSession[];
|
||||
sessionEnv: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow Module
|
||||
|
||||
Manages interactive and replay workflows.
|
||||
|
||||
### `runWorkflow(sessionPath, cubes, config, options?)`
|
||||
|
||||
Runs the appropriate workflow based on options.
|
||||
|
||||
```typescript
|
||||
const result = await runWorkflow(
|
||||
undefined, // null for interactive, path for replay
|
||||
cubes,
|
||||
config,
|
||||
{ useDefaults: false }
|
||||
);
|
||||
```
|
||||
|
||||
**Returns:** `Promise<WorkflowResult>`
|
||||
|
||||
```typescript
|
||||
interface WorkflowResult {
|
||||
session: NopySession;
|
||||
cubesWithDependencies: string[];
|
||||
authMethod: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
isReplay: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### `runInteractiveWorkflow(cubes, config, options?)`
|
||||
|
||||
Runs the interactive cube selection workflow.
|
||||
|
||||
### `runReplayWorkflow(sessionPath, cubes, config)`
|
||||
|
||||
Runs a replay from a saved session file.
|
||||
|
||||
---
|
||||
|
||||
## Session Module
|
||||
|
||||
Manages session save/load operations.
|
||||
|
||||
### Types
|
||||
|
||||
#### `NopySession`
|
||||
|
||||
Complete session configuration.
|
||||
|
||||
```typescript
|
||||
interface NopySession {
|
||||
name?: string;
|
||||
cubes: CubeSession[];
|
||||
hosts?: string[];
|
||||
auth: AuthSession;
|
||||
env?: SessionVariables;
|
||||
}
|
||||
```
|
||||
|
||||
#### `CubeSession`
|
||||
|
||||
Configuration for a single cube.
|
||||
|
||||
```typescript
|
||||
interface CubeSession {
|
||||
key: string;
|
||||
variables: SessionVariables;
|
||||
}
|
||||
```
|
||||
|
||||
#### `AuthSession`
|
||||
|
||||
Authentication configuration.
|
||||
|
||||
```typescript
|
||||
interface AuthSession {
|
||||
method: 'ssh-key' | 'password' | 'ssh';
|
||||
username?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
#### `saveSession(session, filePath)`
|
||||
|
||||
Saves a session to a JSON file.
|
||||
|
||||
```typescript
|
||||
saveSession(session, './my-deployment.nopysession.json');
|
||||
```
|
||||
|
||||
#### `loadSession(filePath)`
|
||||
|
||||
Loads a session from a JSON or MJS file.
|
||||
|
||||
```typescript
|
||||
const session = await loadSession('./deployment.json');
|
||||
const session = await loadSession('./deployment.mjs');
|
||||
```
|
||||
|
||||
#### `createSession(params)`
|
||||
|
||||
Creates a session object from runtime data.
|
||||
|
||||
```typescript
|
||||
const session = createSession({
|
||||
cubes: [{ key: 'apt:essentials', variables: {} }],
|
||||
hosts: ['localhost'],
|
||||
auth: { method: 'ssh-key' },
|
||||
});
|
||||
```
|
||||
|
||||
#### `listSessions(dirPath?)`
|
||||
|
||||
Lists all session files in a directory.
|
||||
|
||||
```typescript
|
||||
const sessions = listSessions('./sessions');
|
||||
// ['./sessions/deploy.session.json', './sessions/test.session.mjs']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Config Module
|
||||
|
||||
Manages nopy configuration.
|
||||
|
||||
### Types
|
||||
|
||||
#### `NopyConfig`
|
||||
|
||||
Configuration file structure.
|
||||
|
||||
```typescript
|
||||
interface NopyConfig {
|
||||
hosts: string[];
|
||||
cubeDirs: string[];
|
||||
env: EnvConfig;
|
||||
log?: LogConfig;
|
||||
}
|
||||
```
|
||||
|
||||
#### `LogConfig`
|
||||
|
||||
Logging configuration.
|
||||
|
||||
```typescript
|
||||
interface LogConfig {
|
||||
verbosity?: 'silent' | 'info' | 'verbose' | 'trace';
|
||||
debug?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
#### `loadConfig()`
|
||||
|
||||
Loads configuration from `.nopyrc.json`.
|
||||
|
||||
```typescript
|
||||
const config = loadConfig();
|
||||
```
|
||||
|
||||
Search order:
|
||||
|
||||
1. `./nopyrc.json` (local)
|
||||
2. `~/.nopyrc.json` (home)
|
||||
|
||||
#### `saveConfig(data, local?)`
|
||||
|
||||
Saves configuration to a file.
|
||||
|
||||
```typescript
|
||||
saveConfig({ hosts: ['server.local'] }); // Local
|
||||
saveConfig({ hosts: ['server.local'] }, false); // Home
|
||||
```
|
||||
|
||||
#### `logConfigToFlags(logConfig?)`
|
||||
|
||||
Converts log config to pyinfra flags.
|
||||
|
||||
```typescript
|
||||
logConfigToFlags({ verbosity: 'verbose', debug: true });
|
||||
// ['-vv', '--debug']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prompts Module
|
||||
|
||||
Interactive prompts for user input.
|
||||
|
||||
### `CubeSelection(cubes)`
|
||||
|
||||
Prompts user to select cubes to execute.
|
||||
|
||||
```typescript
|
||||
const { selectedCubes } = await CubeSelection(cubes);
|
||||
```
|
||||
|
||||
### `HostSelection(hosts)`
|
||||
|
||||
Prompts user to select a target host.
|
||||
|
||||
```typescript
|
||||
const host = await HostSelection(['server1', 'server2']);
|
||||
```
|
||||
|
||||
### `AuthSelection(useAuthKey?)`
|
||||
|
||||
Prompts user to select authentication method.
|
||||
|
||||
```typescript
|
||||
const { authMethod, username, password } = await AuthSelection();
|
||||
```
|
||||
|
||||
### `VariableAssignment(cube, env)`
|
||||
|
||||
Prompts user to customize cube variables.
|
||||
|
||||
```typescript
|
||||
const vars = await VariableAssignment(cube, { existing: 'value' });
|
||||
```
|
||||
|
||||
### `PasswordSelection(username)`
|
||||
|
||||
Prompts for password input.
|
||||
|
||||
```typescript
|
||||
const password = await PasswordSelection('admin');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Usage
|
||||
|
||||
```bash
|
||||
# Interactive deployment
|
||||
nopy install
|
||||
|
||||
# With defaults (no prompts)
|
||||
nopy install -D
|
||||
|
||||
# SSH key auth
|
||||
nopy install -K
|
||||
|
||||
# Save session
|
||||
nopy install -s ./my-session.json
|
||||
|
||||
# Replay session
|
||||
nopy install -l ./my-session.json
|
||||
|
||||
# Dry run
|
||||
nopy install -n
|
||||
|
||||
# Parallel execution
|
||||
nopy install -p
|
||||
|
||||
# JSON output
|
||||
nopy install -j
|
||||
|
||||
# Continue on error
|
||||
nopy install -c
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating a Cube
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
cubes/
|
||||
└── my-cube/
|
||||
├── my-cube.manifest.mjs
|
||||
└── my-cube.deploy.py
|
||||
```
|
||||
|
||||
### Manifest Example
|
||||
|
||||
```javascript
|
||||
// my-cube.manifest.mjs
|
||||
import { createManifest } from '@bitstack/nopy';
|
||||
import { z } from 'zod';
|
||||
|
||||
export default createManifest({
|
||||
name: 'My Cube',
|
||||
dependencies: () => [['apt:essentials']],
|
||||
schema: z.object({
|
||||
VERSION: z.string().default('1.0').describe('Version to install'),
|
||||
ENABLE_FEATURE: z.boolean().default(false),
|
||||
}),
|
||||
before: [
|
||||
(ctx, params) => {
|
||||
console.log('Before my-cube');
|
||||
},
|
||||
],
|
||||
after: [
|
||||
(ctx, params) => {
|
||||
console.log('After my-cube');
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Deploy Script Example
|
||||
|
||||
```python
|
||||
# my-cube.deploy.py
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import apt, server
|
||||
|
||||
VERSION = host.data.get('VERSION', '1.0')
|
||||
ENABLE_FEATURE = host.data.get('ENABLE_FEATURE', False)
|
||||
|
||||
apt.packages(
|
||||
name='Install my-package',
|
||||
packages=[f'my-package={VERSION}'],
|
||||
update=True,
|
||||
)
|
||||
|
||||
if ENABLE_FEATURE:
|
||||
server.shell(
|
||||
name='Enable feature',
|
||||
commands=['my-package --enable-feature'],
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,90 @@
|
||||
# Nopy Cube Hooks
|
||||
|
||||
Hooks provide a way to orchestrate deployments dynamically during the build process. They allow a cube to trigger the execution of other cubes based on its configuration or the environment.
|
||||
|
||||
## Overview
|
||||
|
||||
A cube manifest can define `before` and `after` hooks. These hooks are executed when the deployment plan is being built.
|
||||
|
||||
- **`before` hooks**: Executed *before* the current cube is added to the deployment sequence.
|
||||
- **`after` hooks**: Executed *after* the current cube is added to the deployment sequence.
|
||||
|
||||
## Specification
|
||||
|
||||
Hooks are defined as an array of functions in the cube manifest.
|
||||
|
||||
```javascript
|
||||
import { z } from 'zod';
|
||||
import { cubes } from '@bitstack/nopy';
|
||||
|
||||
export default cubes.Manifest({
|
||||
name: 'my-cube',
|
||||
schema: z.object({
|
||||
SETUP_DB: z.boolean().default(false),
|
||||
}),
|
||||
before: [
|
||||
async ({ exec }, params) => {
|
||||
if (params.SETUP_DB) {
|
||||
// This will run BEFORE my-cube
|
||||
await exec('db:setup', { TYPE: 'postgres' });
|
||||
}
|
||||
}
|
||||
],
|
||||
after: [
|
||||
({ exec }, params) => {
|
||||
// This will run AFTER my-cube
|
||||
console.log('Finished setting up my-cube');
|
||||
}
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
### Hook Function Signature
|
||||
|
||||
Each hook function receives two arguments:
|
||||
|
||||
1. **`context`**: An object containing:
|
||||
- `exec(cubeKey: string, params: Record<string, any>)`: A function to schedule another cube for execution.
|
||||
2. **`params`**: The final, validated variables for the current cube (including defaults and user-provided values).
|
||||
|
||||
Hooks can be synchronous or asynchronous (returning a `Promise`).
|
||||
|
||||
## Mechanics
|
||||
|
||||
### Sequential Execution
|
||||
|
||||
In sequential execution mode (the default), cubes added via hooks will follow the order in which they were pushed to the deployment plan:
|
||||
|
||||
1. Cubes from `before` hooks.
|
||||
2. The current cube itself.
|
||||
3. Cubes from `after` hooks.
|
||||
|
||||
### Parallel Execution
|
||||
|
||||
In parallel execution mode, cubes added via hooks **do not automatically inherit dependencies**.
|
||||
|
||||
If a `before` hook calls `exec('setup-cube')`, it ensures that `setup-cube` is placed earlier in the deployment plan, but for parallel execution, you should still ensure that dependencies are correctly specified if one cube relies on another's completion.
|
||||
|
||||
### Variable Passing
|
||||
|
||||
When you call `exec(cubeKey, params)` within a hook:
|
||||
|
||||
1. The `params` provided are merged with the current environment variables.
|
||||
2. These variables are passed to the target cube, preventing it from prompting the user for those same variables.
|
||||
|
||||
### Use Cases
|
||||
|
||||
- **Conditional Setup**: Running a setup cube only if a specific variable is set.
|
||||
- **Environment Preparation**: Ensuring a user exists or a directory is created before the main cube runs.
|
||||
- **Cleanup/Notification**: Running a task after a cube deployment finishes.
|
||||
|
||||
## Comparison with Dependencies
|
||||
|
||||
| Feature | Dependencies | Hooks |
|
||||
| :--- | :--- | :--- |
|
||||
| **Declaration** | Static (`dependencies: () => [['id']]`) | Dynamic (`before: [...]`) |
|
||||
| **Execution Order** | Guaranteed before dependent | `before` (before) or `after` (after) |
|
||||
| **Variable Passing** | Inherited from env | Explicitly passed via `exec()` |
|
||||
| **Conditionality** | Always run | Can be conditional based on logic |
|
||||
|
||||
Use **dependencies** for static requirements and **hooks** for dynamic orchestration and explicit parameter passing.
|
||||
@@ -0,0 +1,323 @@
|
||||
# Nopy Session Format
|
||||
|
||||
Nopy supports two session file formats: **JSON** and **MJS** (ES Module JavaScript).
|
||||
|
||||
## Supported Formats
|
||||
|
||||
### JSON Format (`.session.json`)
|
||||
|
||||
Traditional JSON format for session files:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"timestamp": "2025-10-15T00:00:00.000Z",
|
||||
"cubes": [
|
||||
{
|
||||
"key": "runtime:nodevm",
|
||||
"variables": {
|
||||
"VERSION": "22",
|
||||
"USER": "myuser"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hosts": ["@ssh/myhost.local"],
|
||||
"auth": {
|
||||
"method": "password",
|
||||
"username": "admin"
|
||||
},
|
||||
"env": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Limitations:**
|
||||
- No comments allowed (pure JSON)
|
||||
- Cannot use dynamic values or computation
|
||||
- No code reuse or imports
|
||||
|
||||
### MJS Format (`.session.mjs`) - **Recommended**
|
||||
|
||||
JavaScript module format with full ES Module support:
|
||||
|
||||
```javascript
|
||||
// Nopy Session Configuration
|
||||
// Comments are fully supported!
|
||||
|
||||
// You can import values from other files
|
||||
import { commonHosts } from './common-config.mjs';
|
||||
|
||||
// You can use dynamic values
|
||||
const timestamp = new Date().toISOString();
|
||||
const nodeVersion = process.env.NODE_VERSION || "22";
|
||||
|
||||
export default {
|
||||
version: "1.0.0",
|
||||
timestamp,
|
||||
|
||||
cubes: [
|
||||
// Inline comments for each cube
|
||||
{
|
||||
key: "runtime:nodevm",
|
||||
variables: {
|
||||
VERSION: nodeVersion, // Dynamic value
|
||||
USER: "myuser",
|
||||
ALIAS: "nodelts",
|
||||
GLOBAL_PACKAGES: "pm2 yarn"
|
||||
}
|
||||
},
|
||||
|
||||
// Add more cubes...
|
||||
],
|
||||
|
||||
hosts: commonHosts, // Imported from another file
|
||||
|
||||
auth: {
|
||||
method: "password",
|
||||
username: "admin"
|
||||
},
|
||||
|
||||
env: {
|
||||
NODE_ENV: process.env.NODE_ENV || "production"
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Advantages:**
|
||||
- ✅ **Comments** - Document your configuration inline
|
||||
- ✅ **Dynamic values** - Use environment variables, compute values
|
||||
- ✅ **Code reuse** - Import common configurations from other files
|
||||
- ✅ **Parameterization** - Easily parameterize sessions from external tools
|
||||
- ✅ **Type safety** - Use JSDoc or TypeScript for validation
|
||||
- ✅ **Computation** - Calculate values, filter arrays, etc.
|
||||
|
||||
## Advanced MJS Examples
|
||||
|
||||
### Using Environment Variables
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
version: "1.0.0",
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
cubes: [
|
||||
{
|
||||
key: "typestack-install",
|
||||
variables: {
|
||||
REPO: process.env.GIT_REPO || "git@github.com:org/repo.git",
|
||||
USER: process.env.DEPLOY_USER || "admin",
|
||||
APP: process.env.APP_NAME || "myapp",
|
||||
ENV: process.env.NODE_ENV || "production"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
hosts: [process.env.TARGET_HOST || "@ssh/localhost"],
|
||||
|
||||
auth: {
|
||||
method: "password",
|
||||
username: process.env.SSH_USER || "admin"
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Conditional Cube Inclusion
|
||||
|
||||
```javascript
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
export default {
|
||||
version: "1.0.0",
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
cubes: [
|
||||
{
|
||||
key: "runtime:docker",
|
||||
variables: { DISTRO: "debian" }
|
||||
},
|
||||
|
||||
// Only include in development
|
||||
...(isDevelopment ? [{
|
||||
key: "debug-tools",
|
||||
variables: { INSTALL_GDB: true }
|
||||
}] : [])
|
||||
],
|
||||
|
||||
hosts: ["@ssh/myhost.local"],
|
||||
auth: { method: "ssh" }
|
||||
};
|
||||
```
|
||||
|
||||
### Importing Common Configuration
|
||||
|
||||
**common-config.mjs:**
|
||||
```javascript
|
||||
export const productionHosts = [
|
||||
"@ssh/prod-server-1.local",
|
||||
"@ssh/prod-server-2.local"
|
||||
];
|
||||
|
||||
export const stagingHosts = [
|
||||
"@ssh/staging.local"
|
||||
];
|
||||
|
||||
export const commonCubes = [
|
||||
{
|
||||
key: "apt:essentials",
|
||||
variables: { UPDATE: true }
|
||||
},
|
||||
{
|
||||
key: "runtime:docker",
|
||||
variables: { DISTRO: "debian" }
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
**my-session.session.mjs:**
|
||||
```javascript
|
||||
import { productionHosts, commonCubes } from './common-config.mjs';
|
||||
|
||||
export default {
|
||||
version: "1.0.0",
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
cubes: [
|
||||
...commonCubes, // Include common cubes
|
||||
{
|
||||
key: "typestack-install",
|
||||
variables: {
|
||||
REPO: "git@github.com:myorg/myapp.git",
|
||||
USER: "appuser",
|
||||
APP: "myapp"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
hosts: productionHosts, // Use imported hosts
|
||||
|
||||
auth: {
|
||||
method: "password",
|
||||
username: "admin"
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Programmatic Generation
|
||||
|
||||
You can even generate sessions programmatically from other tools:
|
||||
|
||||
**generate-session.mjs:**
|
||||
```javascript
|
||||
import fs from 'fs';
|
||||
|
||||
function generateSession(config) {
|
||||
const cubes = config.services.map(service => ({
|
||||
key: "typestack-install",
|
||||
variables: {
|
||||
REPO: service.repo,
|
||||
USER: config.user,
|
||||
APP: service.name,
|
||||
ENV: config.environment
|
||||
}
|
||||
}));
|
||||
|
||||
const session = {
|
||||
version: "1.0.0",
|
||||
timestamp: new Date().toISOString(),
|
||||
cubes,
|
||||
hosts: config.hosts,
|
||||
auth: {
|
||||
method: "password",
|
||||
username: config.user
|
||||
}
|
||||
};
|
||||
|
||||
const content = `export default ${JSON.stringify(session, null, 2)};`;
|
||||
fs.writeFileSync('generated.session.mjs', content);
|
||||
}
|
||||
|
||||
// Generate from external configuration
|
||||
generateSession({
|
||||
user: "deploy",
|
||||
environment: "production",
|
||||
services: [
|
||||
{ name: "api", repo: "git@github.com:org/api.git" },
|
||||
{ name: "web", repo: "git@github.com:org/web.git" }
|
||||
],
|
||||
hosts: ["@ssh/prod.local"]
|
||||
});
|
||||
```
|
||||
|
||||
## Loading Sessions
|
||||
|
||||
Both formats are loaded the same way:
|
||||
|
||||
```javascript
|
||||
import { loadSession } from '@bitstack/nopy';
|
||||
|
||||
// Load JSON
|
||||
const jsonSession = await loadSession('./my-session.session.json');
|
||||
|
||||
// Load MJS
|
||||
const mjsSession = await loadSession('./my-session.session.mjs');
|
||||
```
|
||||
|
||||
The file extension determines which loader to use.
|
||||
|
||||
## Migration from JSON to MJS
|
||||
|
||||
To convert an existing JSON session to MJS:
|
||||
|
||||
1. Rename the file from `.session.json` to `.session.mjs`
|
||||
2. Add `export default` before the configuration object
|
||||
3. Remove quotes from property keys (optional)
|
||||
4. Add comments and dynamic values as needed
|
||||
|
||||
**Before (JSON):**
|
||||
```json
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"cubes": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**After (MJS):**
|
||||
```javascript
|
||||
export default {
|
||||
version: "1.0.0",
|
||||
cubes: [...]
|
||||
};
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use MJS for new sessions** - Take advantage of comments and flexibility
|
||||
2. **Document your cubes** - Add comments explaining what each cube does
|
||||
3. **Use environment variables** - Make sessions reusable across environments
|
||||
4. **Extract common config** - Share configuration across multiple sessions
|
||||
5. **Version control** - Both formats work well with git
|
||||
6. **Validate at runtime** - The loader validates the structure regardless of format
|
||||
|
||||
## Session Schema
|
||||
|
||||
Both formats must export/contain an object with this structure:
|
||||
|
||||
```typescript
|
||||
interface NopySession {
|
||||
version: string; // Session format version
|
||||
timestamp: string; // ISO timestamp
|
||||
cubes: CubeSession[]; // Array of cube configurations
|
||||
hosts: string[]; // Target hosts
|
||||
auth: AuthSession; // Authentication configuration
|
||||
env?: Record<string, any>; // Global environment variables
|
||||
}
|
||||
|
||||
interface CubeSession {
|
||||
key: string; // Cube identifier
|
||||
variables: Record<string, any>; // Cube-specific variables
|
||||
}
|
||||
|
||||
interface AuthSession {
|
||||
method: 'ssh-key' | 'password' | 'ssh';
|
||||
username?: string;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Example Deployment Session",
|
||||
"cubes": [
|
||||
{
|
||||
"key": "apt:essentials",
|
||||
"variables": {
|
||||
"UPDATE": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"hosts": ["@docker/nopy-test-ubuntu"],
|
||||
"env": {
|
||||
"KEY_DIR": "../../vault/tmp"
|
||||
},
|
||||
"auth": {
|
||||
"method": "ssh-key"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@bitstack/nopy",
|
||||
"description": "A system to simplify pyinfra script management and execution.",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"author": "bitsquare",
|
||||
"bin": "./dist/nopy.cli.js",
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"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",
|
||||
"test": "vitest run",
|
||||
"test:integration": "vitest run --pool=forks",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"files": ["dist/"],
|
||||
"dependencies": {
|
||||
"@logtape/logtape": "^0.8.0",
|
||||
"commander": "^13.1.0",
|
||||
"enquirer": "^2.4.1",
|
||||
"execa": "9.5.2",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/inquirer": "^8.2.10",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/uniqid": "^5.3.4",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Dynamic dependency resolution for cubes
|
||||
* @module cubes/dependencies
|
||||
*/
|
||||
|
||||
import { getLogger } from '@logtape/logtape';
|
||||
import type { Variables } from '../nopy.common.js';
|
||||
import type { NopyConfig } from '../nopy.config.js';
|
||||
import type { DeployCall } from '../nopy.executor.js';
|
||||
import { VariableAssignment } from '../nopy.prompts.js';
|
||||
import { type CubeSession, type NopySession } from '../nopy.session.js';
|
||||
import type { Cube, CubeVariables, DependencySpec, HookContext } from './types.js';
|
||||
|
||||
const log = getLogger(['nopy', 'resolution']);
|
||||
|
||||
/**
|
||||
* Context for the resolution process
|
||||
*/
|
||||
export class BuildContext {
|
||||
public readonly deployCalls: DeployCall[] = [];
|
||||
public readonly cubeSessions: CubeSession[] = [];
|
||||
private readonly resolvedCubes = new Set<string>();
|
||||
|
||||
constructor(
|
||||
public readonly allCubes: Record<string, Cube>,
|
||||
public readonly variables: Variables,
|
||||
public readonly session: NopySession,
|
||||
public readonly config: NopyConfig,
|
||||
public readonly auth: {
|
||||
method: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
},
|
||||
public readonly options: {
|
||||
useDefaults?: boolean;
|
||||
isSessionReplay?: boolean;
|
||||
} = {}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolves a cube, its dependencies, and hooks recursively
|
||||
*/
|
||||
public async resolveCube(cubeId: string, host: string, overrides: CubeVariables = {}): Promise<void> {
|
||||
const cube = this.allCubes[cubeId];
|
||||
if (!cube) {
|
||||
throw new Error(`Cube not found: ${cubeId}`);
|
||||
}
|
||||
|
||||
log.debug('Resolving cube', { cubeId, host });
|
||||
|
||||
// 1. Assign overrides and defaults
|
||||
if (Object.keys(overrides).length > 0) {
|
||||
this.variables.assign(cubeId, 'params', overrides);
|
||||
}
|
||||
this.variables.assign(cubeId, 'defaults', cube.getDefaults());
|
||||
|
||||
// 2. Variable collection
|
||||
if (this.options.isSessionReplay) {
|
||||
const sessionCube = this.session.cubes.find(c => c.key === cubeId);
|
||||
if (sessionCube) {
|
||||
this.variables.assign(cubeId, 'defaults', sessionCube.variables);
|
||||
}
|
||||
} else {
|
||||
await VariableAssignment(cube, this.variables);
|
||||
}
|
||||
|
||||
const currentVars = this.variables.get(cubeId);
|
||||
const hookCtx: HookContext = {
|
||||
exec: (id, vars) => this.resolveCube(id, host, vars),
|
||||
};
|
||||
|
||||
// 3. Execute 'before' hooks
|
||||
if (cube.manifest.before) {
|
||||
for (const hook of cube.manifest.before) {
|
||||
await hook(hookCtx, currentVars);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve dynamic dependencies
|
||||
const depSpecs = cube.manifest.dependencies?.(currentVars) ?? [];
|
||||
for (const spec of depSpecs) {
|
||||
const depId = typeof spec === 'string' ? spec : spec[0];
|
||||
const depVars = typeof spec === 'string' ? {} : (spec[1] ?? {});
|
||||
await this.resolveCube(depId, host, depVars);
|
||||
}
|
||||
|
||||
// 5. Build the deployment call
|
||||
this.buildDeployCall(cube, host);
|
||||
|
||||
// 6. Execute 'after' hooks
|
||||
if (cube.manifest.after) {
|
||||
for (const hook of cube.manifest.after) {
|
||||
await hook(hookCtx, currentVars);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and stores a deployment call for a resolved cube
|
||||
*/
|
||||
private buildDeployCall(cube: Cube, host: string): void {
|
||||
const cubeId = cube.id;
|
||||
const callKey = `${cubeId}:${host}`;
|
||||
|
||||
if (this.resolvedCubes.has(callKey)) return;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (this.auth.method === 'password' && this.auth.username && this.auth.password) {
|
||||
parts.push(`--user ${this.auth.username} --password ${this.auth.password}`);
|
||||
}
|
||||
|
||||
const cubeVars = this.variables.get(cubeId);
|
||||
Object.entries(cubeVars).forEach(([key, value]) => {
|
||||
parts.push(`--data "${key}=${value}"`);
|
||||
});
|
||||
|
||||
parts.push(`--chdir ${cube.dir}`);
|
||||
parts.push(`${cube.dir}/${cube.deployScript}`);
|
||||
|
||||
const command = ['pyinfra', host, '-y', ...parts];
|
||||
|
||||
this.deployCalls.push({
|
||||
cube: cubeId,
|
||||
host,
|
||||
cwd: cube.dir,
|
||||
command,
|
||||
env: cubeVars,
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
if (!this.cubeSessions.some(s => s.key === cubeId)) {
|
||||
this.cubeSessions.push({
|
||||
key: cubeId,
|
||||
variables: this.variables.get(cubeId, 'prompts'),
|
||||
});
|
||||
}
|
||||
|
||||
this.resolvedCubes.add(callKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Factory functions for creating cube configurations
|
||||
* @module cubes/factories
|
||||
*/
|
||||
|
||||
import { Manifest } from './types.js';
|
||||
|
||||
/**
|
||||
* Creates a manifest configuration for a cube
|
||||
*
|
||||
* @param opts - Manifest options including name, schema, dependencies, and hooks
|
||||
* @returns Manifest configuration object
|
||||
*/
|
||||
export function createManifest<Schema extends import('zod').z.AnyZodObject>(
|
||||
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
|
||||
): Manifest<Schema> {
|
||||
return Manifest(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for createManifest - for backwards compatibility with existing manifests
|
||||
*/
|
||||
export const manifest = createManifest;
|
||||
|
||||
/**
|
||||
* @deprecated Use createManifest or manifest instead
|
||||
*/
|
||||
export const ManifestFactory = createManifest;
|
||||
|
||||
export { Manifest } from './types.js';
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Nopy Cubes Module
|
||||
*
|
||||
* Self-contained deployment units for pyinfra automation.
|
||||
*
|
||||
* @module cubes
|
||||
*/
|
||||
|
||||
// Types
|
||||
export {
|
||||
Cube,
|
||||
Manifest,
|
||||
} from './types.js';
|
||||
|
||||
export type {
|
||||
Hook,
|
||||
HookContext,
|
||||
LoadResult,
|
||||
CubeVariables,
|
||||
DependencySpec,
|
||||
} from './types.js';
|
||||
|
||||
// Factory functions
|
||||
export {
|
||||
createManifest,
|
||||
manifest,
|
||||
} from './factories.js';
|
||||
|
||||
// Loader
|
||||
export {
|
||||
loadCubes,
|
||||
findCubeDirectories,
|
||||
getCube,
|
||||
} from './loader.js';
|
||||
|
||||
// Dependencies
|
||||
export {
|
||||
BuildContext,
|
||||
} from './dependencies.js';
|
||||
|
||||
// Utilities
|
||||
export { uniqid } from './utils.js';
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Cube discovery and loading from the filesystem
|
||||
* @module cubes/loader
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { fs } from 'zx';
|
||||
import { loadConfig } from '../nopy.config.js';
|
||||
import { Cube, type LoadResult, type Manifest } from './types.js';
|
||||
|
||||
/**
|
||||
* Traverses upwards from the current working directory to the root
|
||||
* and collects all directories that contain a `.npcubes` marker file.
|
||||
*
|
||||
* Also includes directories specified in the `.nopyrc.json` configuration.
|
||||
*
|
||||
* @returns Array of absolute paths to directories containing cubes
|
||||
*/
|
||||
export function findCubeDirectories(): string[] {
|
||||
let currentDir = process.cwd();
|
||||
const config = loadConfig();
|
||||
const dirSet = new Set<string>(config.cubeDirs.map((dir) => path.resolve(process.cwd(), dir)));
|
||||
|
||||
while (true) {
|
||||
const targetFile = path.join(currentDir, '.npcubes');
|
||||
|
||||
if (fs.existsSync(targetFile) && fs.statSync(targetFile).isFile()) {
|
||||
dirSet.add(currentDir);
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(currentDir);
|
||||
|
||||
if (parentDir === currentDir) {
|
||||
break; // Stop when reaching the root
|
||||
}
|
||||
|
||||
currentDir = parentDir;
|
||||
}
|
||||
|
||||
return [...dirSet];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts cube ID from name pattern [id] or explicit id field
|
||||
*/
|
||||
function extractCubeId(manifest: Manifest): string | undefined {
|
||||
if (manifest.id) return manifest.id;
|
||||
const match = manifest.name.match(/^\[([^\]]+)\]/);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all cubes from discovered cube directories.
|
||||
*/
|
||||
export async function loadCubes(): Promise<LoadResult> {
|
||||
const cubesFolders = findCubeDirectories();
|
||||
const cubes: Record<string, Cube> = {};
|
||||
const errors: string[] = [];
|
||||
|
||||
async function scanDirectory(currentDir: string, baseDir: string): Promise<void> {
|
||||
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
|
||||
const files = entries.filter((e) => e.isFile());
|
||||
const manifestFile = files.find(
|
||||
(f) => f.name === 'manifest.mjs' || f.name.endsWith('.manifest.mjs')
|
||||
);
|
||||
const deployFile = files.find((f) => f.name === 'deploy.py' || f.name.endsWith('.deploy.py'));
|
||||
|
||||
if (manifestFile && deployFile) {
|
||||
const cubePath = currentDir;
|
||||
const manifestPath = path.join(cubePath, manifestFile.name);
|
||||
|
||||
try {
|
||||
const manifest = (await import(manifestPath)).default as Manifest;
|
||||
|
||||
if (!manifest || typeof manifest !== 'object') {
|
||||
errors.push(`Invalid manifest export in ${manifestPath}`);
|
||||
} else if (!manifest.name) {
|
||||
errors.push(`Invalid manifest format in ${manifestPath}: missing 'name'`);
|
||||
} else {
|
||||
const cubeId = extractCubeId(manifest) || path.basename(cubePath);
|
||||
|
||||
if (cubes[cubeId]) {
|
||||
errors.push(`Duplicate cube id '${cubeId}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure basic properties
|
||||
manifest.id = cubeId;
|
||||
manifest.schema = manifest.schema ?? z.object({});
|
||||
|
||||
cubes[cubeId] = new Cube(manifest, cubePath, deployFile.name);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`Failed to load manifest ${manifestPath}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
||||
await scanDirectory(path.join(currentDir, entry.name), baseDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
cubesFolders.map(async (folder) => {
|
||||
if (fs.existsSync(folder)) {
|
||||
await scanDirectory(folder, folder);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return { cubes, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets information about a single cube by name.
|
||||
*/
|
||||
export async function getCube(cubeName: string): Promise<Cube | undefined> {
|
||||
const { cubes } = await loadCubes();
|
||||
return cubes[cubeName];
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Type definitions for Nopy cubes
|
||||
* @module cubes/types
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Variables that can be passed to a cube
|
||||
*/
|
||||
export type CubeVariables = Record<string, string | number | boolean>;
|
||||
|
||||
/**
|
||||
* A dependency specification
|
||||
*/
|
||||
export type DependencySpec = string | [id: string, variables?: CubeVariables];
|
||||
|
||||
/**
|
||||
* Context passed to cube hooks for executing other cubes
|
||||
*/
|
||||
export interface HookContext {
|
||||
exec: (key: string, variables: CubeVariables) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook function type for before/after cube execution
|
||||
*/
|
||||
export type Hook<Schema extends z.AnyZodObject = z.AnyZodObject> = (
|
||||
ctx: HookContext,
|
||||
variables: z.infer<Schema>
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* User-defined specification for a cube
|
||||
*/
|
||||
export interface Manifest<Schema extends z.AnyZodObject = z.AnyZodObject> {
|
||||
/** Unique identifier for the cube (used for dependency references) */
|
||||
id: string;
|
||||
/** Human-readable name of the cube */
|
||||
name: string;
|
||||
/** Zod schema for validating cube variables */
|
||||
schema: Schema;
|
||||
/** Dynamic dependency resolver based on collected variables */
|
||||
dependencies?: (variables: z.infer<Schema>) => DependencySpec[];
|
||||
/** Hooks to run before cube execution */
|
||||
before?: Hook<Schema>[];
|
||||
/** Hooks to run after cube execution */
|
||||
after?: Hook<Schema>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function and namespace for Manifest
|
||||
*/
|
||||
export function Manifest<Schema extends z.AnyZodObject>(
|
||||
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
|
||||
): Manifest<Schema> {
|
||||
return {
|
||||
id: opts.id ?? '',
|
||||
name: opts.name,
|
||||
schema: opts.schema ?? (z.object({}) as unknown as Schema),
|
||||
dependencies: opts.dependencies,
|
||||
before: opts.before ?? [],
|
||||
after: opts.after ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export namespace Manifest {
|
||||
/**
|
||||
* Internal create helper
|
||||
*/
|
||||
export function create<Schema extends z.AnyZodObject>(
|
||||
opts: Pick<Manifest<Schema>, 'name'> & Partial<Omit<Manifest<Schema>, 'name'>>
|
||||
): Manifest<Schema> {
|
||||
return Manifest(opts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fully loaded cube with its filesystem location and runtime state
|
||||
*/
|
||||
export class Cube<Schema extends z.AnyZodObject = z.AnyZodObject> {
|
||||
constructor(
|
||||
public readonly manifest: Manifest<Schema>,
|
||||
public readonly dir: string,
|
||||
public readonly deployScript: string
|
||||
) {}
|
||||
|
||||
get id(): string {
|
||||
return this.manifest.id;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.manifest.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns default values for the cube's schema
|
||||
*/
|
||||
getDefaults(): z.infer<Schema> {
|
||||
try {
|
||||
return this.manifest.schema.parse({});
|
||||
} catch {
|
||||
return {} as z.infer<Schema>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of loading cubes from the filesystem
|
||||
*/
|
||||
export interface LoadResult {
|
||||
/** Map of cube key to Cube object */
|
||||
cubes: Record<string, Cube>;
|
||||
/** List of errors encountered during loading */
|
||||
errors: string[];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Utility functions for cubes
|
||||
* @module cubes/utils
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generates a random string of the specified length using the current nanotime as a seed.
|
||||
*
|
||||
* Uses a simple Linear Congruential Generator (LCG) seeded with high-resolution time.
|
||||
* Suitable for generating unique identifiers, not for cryptographic purposes.
|
||||
*
|
||||
* @param length - The desired length of the random string (default: 5)
|
||||
* @returns A random alphanumeric string of the specified length
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const id = uniqid(); // e.g., "Kx7Pm"
|
||||
* const longId = uniqid(10); // e.g., "Kx7PmQr2Yw"
|
||||
* ```
|
||||
*/
|
||||
export function uniqid(length = 5): string {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const charsetLength = charset.length;
|
||||
|
||||
// Use process.hrtime.bigint() for high-resolution time in nanoseconds
|
||||
let seed = Number(process.hrtime.bigint() % BigInt(Number.MAX_SAFE_INTEGER));
|
||||
|
||||
const randomString: string[] = [];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
// Simple linear congruential generator (LCG) for pseudo-randomness
|
||||
seed = (seed * 48271) % 2147483647;
|
||||
const index = seed % charsetLength;
|
||||
randomString.push(charset[index]);
|
||||
}
|
||||
|
||||
return randomString.join('');
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Nopy - A CLI tool for pyinfra script management and execution
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
// Cubes module
|
||||
export * from './cubes/index.js';
|
||||
|
||||
// Backwards compatibility - cubes namespace
|
||||
export { cubes } from './nopy.cubes.js';
|
||||
|
||||
// Main entry point
|
||||
export { nopy } from './nopy.main.js';
|
||||
export type { NopyOptions, NopyResult } from './nopy.main.js';
|
||||
|
||||
// Executor
|
||||
export {
|
||||
executeDeployCalls,
|
||||
outputExecutionPlan,
|
||||
summarizeResults,
|
||||
} from './nopy.executor.js';
|
||||
export type {
|
||||
DeployCall,
|
||||
ExecutionResult,
|
||||
ExecutionOptions,
|
||||
} from './nopy.executor.js';
|
||||
|
||||
// Workflow
|
||||
export {
|
||||
runWorkflow,
|
||||
runInteractiveWorkflow,
|
||||
runReplayWorkflow,
|
||||
runSessionReplayWorkflow,
|
||||
} from './nopy.workflow.js';
|
||||
export type { WorkflowOptions, WorkflowResult } from './nopy.workflow.js';
|
||||
|
||||
// Prompts
|
||||
export {
|
||||
CubeSelection,
|
||||
AuthSelection,
|
||||
HostSelection,
|
||||
VariableAssignment,
|
||||
PasswordSelection,
|
||||
} from './nopy.prompts.js';
|
||||
|
||||
// Session management
|
||||
export {
|
||||
loadSession,
|
||||
saveSession,
|
||||
createSession,
|
||||
listSessions,
|
||||
filterInternalVariables,
|
||||
separateEnvAndCubeVariables,
|
||||
} from './nopy.session.js';
|
||||
export type { NopySession, CubeSession, AuthSession } from './nopy.session.js';
|
||||
|
||||
// History management
|
||||
export {
|
||||
loadHistory,
|
||||
saveHistory,
|
||||
addToHistory,
|
||||
getLastSession,
|
||||
getSessionById,
|
||||
listHistory,
|
||||
clearHistory,
|
||||
removeFromHistory,
|
||||
formatHistoryList,
|
||||
getHistoryPath,
|
||||
DEFAULT_HISTORY_SIZE,
|
||||
HISTORY_FILE,
|
||||
} from './nopy.history.js';
|
||||
export type { HistoryEntry, SessionHistory } from './nopy.history.js';
|
||||
|
||||
// Configuration
|
||||
export { loadConfig, saveConfig, logConfigToFlags, getConfigPaths } from './nopy.config.js';
|
||||
export type {
|
||||
NopyConfig,
|
||||
NopyConfigFile,
|
||||
LogConfig,
|
||||
LogVerbosity,
|
||||
HistoryConfig,
|
||||
ExecutionConfig,
|
||||
ResolutionStrategy,
|
||||
ResolutionConfig,
|
||||
} from './nopy.config.js';
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Nopy CLI - pyinfra deployment management
|
||||
* @module nopy.cli
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from './nopy.config.js';
|
||||
import {
|
||||
clearHistory,
|
||||
formatHistoryList,
|
||||
getLastSession,
|
||||
getSessionById,
|
||||
listHistory,
|
||||
} from './nopy.history.js';
|
||||
import { nopy } from './nopy.main.js';
|
||||
|
||||
const program = new Command();
|
||||
const config = loadConfig();
|
||||
|
||||
program
|
||||
.name('nopy')
|
||||
.version('1.0.0')
|
||||
.description('A CLI tool for pyinfra script management and execution.')
|
||||
.addHelpText(
|
||||
'after',
|
||||
`
|
||||
Examples:
|
||||
$ nopy Interactive cube selection and deployment
|
||||
$ nopy -R Repeat the last deployment session
|
||||
$ nopy -H <id> Run a specific session from history
|
||||
$ nopy -l session.json Load and replay a saved session file
|
||||
$ nopy -s session.json Save session to file after deployment
|
||||
$ nopy -n Dry run (show plan without executing)
|
||||
$ nopy -P Print deploy commands only
|
||||
$ nopy history List all saved sessions
|
||||
$ nopy clear-history Clear session history
|
||||
|
||||
Session Replay:
|
||||
Sessions are automatically saved to history after each deployment.
|
||||
Use 'nopy history' to see available sessions and their IDs.
|
||||
Use 'nopy -R' to quickly repeat the last session.
|
||||
Use 'nopy -H <id>' to run any session from history.
|
||||
`
|
||||
);
|
||||
|
||||
program
|
||||
.command('install', { isDefault: true })
|
||||
.description('Install cubes on a given host')
|
||||
.alias('i')
|
||||
.option('-D, --use-defaults', 'Run cubes with default values without prompts')
|
||||
.option('-K, --auth-method-key', 'Use SSH key authentication')
|
||||
.option('-R, --repeat-last', 'Repeat the last session from history')
|
||||
.option('-H, --history <id>', 'Run a specific session from history by ID')
|
||||
.option('-s, --save-session <path>', 'Save session to file for later replay')
|
||||
.option('-l, --load-session <path>', 'Load and replay session from file')
|
||||
.option('-n, --dry-run', 'Show execution plan without running')
|
||||
.option('-P, --print-only', 'Print deploy commands and exit (no execution)')
|
||||
.option('-c, --continue-on-error', 'Continue executing after failures')
|
||||
.option('-j, --json', 'Output results as JSON')
|
||||
.option('--no-history', 'Do not save this session to history')
|
||||
.action(async (options) => {
|
||||
// Apply config defaults
|
||||
const execConfig = config.execution ?? {};
|
||||
const continueOnError = options.continueOnError ?? execConfig.continueOnError ?? false;
|
||||
|
||||
try {
|
||||
// Handle session replay
|
||||
const loadSessionPath = options.loadSession;
|
||||
let sessionToReplay: { session: import('./nopy.session.js').NopySession } | undefined;
|
||||
|
||||
if (options.repeatLast) {
|
||||
const lastEntry = getLastSession();
|
||||
if (!lastEntry) {
|
||||
console.error('No sessions in history. Run a deployment first.');
|
||||
process.exit(1);
|
||||
}
|
||||
sessionToReplay = lastEntry;
|
||||
console.log(`Repeating: ${lastEntry.name}\n`);
|
||||
} else if (options.history) {
|
||||
const entry = getSessionById(options.history);
|
||||
if (!entry) {
|
||||
console.error(`Session not found: ${options.history}`);
|
||||
console.error('Use "nopy history" to list available sessions.');
|
||||
process.exit(1);
|
||||
}
|
||||
sessionToReplay = entry;
|
||||
console.log(`Running: ${entry.name}\n`);
|
||||
}
|
||||
|
||||
const result = await nopy({
|
||||
useDefaults: options.useDefaults,
|
||||
useAuthKey: options.authMethodKey,
|
||||
saveSession: options.saveSession,
|
||||
loadSession: loadSessionPath,
|
||||
replaySession: sessionToReplay?.session,
|
||||
dryRun: options.dryRun,
|
||||
printOnly: options.printOnly,
|
||||
continueOnError,
|
||||
jsonOutput: options.json,
|
||||
saveToHistory: options.history !== false && !options.dryRun,
|
||||
});
|
||||
|
||||
// Exit with error code if deployment failed
|
||||
if (result && !result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
if (options.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.error('Error:', error instanceof Error ? error.message : error, error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('history')
|
||||
.description('List session history')
|
||||
.alias('h')
|
||||
.option('-j, --json', 'Output as JSON')
|
||||
.action((options) => {
|
||||
const entries = listHistory();
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
} else {
|
||||
console.log(formatHistoryList(entries));
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('clear-history')
|
||||
.description('Clear all session history')
|
||||
.action(() => {
|
||||
clearHistory();
|
||||
console.log('Session history cleared.');
|
||||
});
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Environment variable configuration
|
||||
*/
|
||||
export type TVariables = Record<string, string | number | boolean>;
|
||||
|
||||
export namespace Variables {
|
||||
export type ArtefactId = string;
|
||||
export type Scope = 'defaults' | 'prompts' | 'params';
|
||||
}
|
||||
|
||||
export class Variables {
|
||||
/** @summary env as configured in cube or session script */
|
||||
defaults: Record<Variables.ArtefactId, TVariables> = {};
|
||||
/** @summary env as configured via prompts */
|
||||
prompts: Record<Variables.ArtefactId, TVariables> = {};
|
||||
/** @summary env as handed via params (on hook calls) */
|
||||
params: Record<Variables.ArtefactId, TVariables> = {};
|
||||
|
||||
constructor(readonly global: TVariables = {}) {}
|
||||
|
||||
assign(artefactId: Variables.ArtefactId, scope: Variables.Scope, values: TVariables = {}) {
|
||||
console.log('Assigning', artefactId, scope, values);
|
||||
if (!this[scope][artefactId]) {
|
||||
this[scope][artefactId] = values;
|
||||
} else {
|
||||
Object.assign(this[scope][artefactId], values);
|
||||
}
|
||||
}
|
||||
|
||||
get(artefactId: Variables.ArtefactId, scope?: Variables.Scope): TVariables {
|
||||
if (scope) {
|
||||
return this[scope][artefactId] || {};
|
||||
}
|
||||
return {
|
||||
...this.global,
|
||||
...this.defaults[artefactId],
|
||||
...this.prompts[artefactId],
|
||||
...this.params[artefactId],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Configuration loading and management
|
||||
* @module nopy.config
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { TVariables } from './nopy.common.js';
|
||||
|
||||
/**
|
||||
* Log verbosity levels for pyinfra output
|
||||
*/
|
||||
export type LogVerbosity = 'silent' | 'info' | 'verbose' | 'trace';
|
||||
|
||||
/**
|
||||
* Logging configuration
|
||||
*/
|
||||
export interface LogConfig {
|
||||
/** Output verbosity level */
|
||||
verbosity?: LogVerbosity;
|
||||
/** Enable pyinfra debug logging */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* History configuration
|
||||
*/
|
||||
export interface HistoryConfig {
|
||||
/** Maximum number of sessions to keep in history (default: 10) */
|
||||
maxSessions?: number;
|
||||
/** Whether to auto-save sessions to history (default: true) */
|
||||
autoSave?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution configuration
|
||||
*/
|
||||
export interface ExecutionConfig {
|
||||
/** Continue executing after a cube fails (default: false) */
|
||||
continueOnError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolution strategy for merging config properties
|
||||
* - 'merge': Arrays are concatenated, objects are deep merged (default)
|
||||
* - 'override': Child value completely replaces parent value
|
||||
*/
|
||||
export type ResolutionStrategy = 'merge' | 'override';
|
||||
|
||||
/**
|
||||
* Resolution configuration for customizing merge behavior
|
||||
*/
|
||||
export type ResolutionConfig = {
|
||||
[K in keyof NopyConfig]?: ResolutionStrategy;
|
||||
};
|
||||
|
||||
/**
|
||||
* Raw config file structure (includes resolution)
|
||||
*/
|
||||
export interface NopyConfigFile extends Partial<NopyConfig> {
|
||||
/** Customize merge behavior for specific properties */
|
||||
resolution?: ResolutionConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nopy configuration file structure
|
||||
*/
|
||||
export interface NopyConfig {
|
||||
/** Available host addresses */
|
||||
hosts: string[];
|
||||
/** Directories to search for cubes */
|
||||
cubeDirs: string[];
|
||||
/** Global environment variables */
|
||||
env: TVariables;
|
||||
/** Logging configuration */
|
||||
log?: LogConfig;
|
||||
/** Session history configuration */
|
||||
history?: HistoryConfig;
|
||||
/** Execution configuration */
|
||||
execution?: ExecutionConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: NopyConfig = {
|
||||
hosts: [],
|
||||
cubeDirs: [],
|
||||
env: {},
|
||||
};
|
||||
|
||||
const CONFIG_FILENAME = '.nopyrc.json';
|
||||
|
||||
/**
|
||||
* Finds all config files by traversing upwards from cwd to root
|
||||
* Returns configs in order from root to cwd (parent first, child last)
|
||||
*/
|
||||
function findConfigFiles(): string[] {
|
||||
const configPaths: string[] = [];
|
||||
let currentDir = process.cwd();
|
||||
|
||||
// Traverse upwards
|
||||
while (true) {
|
||||
const configPath = path.join(currentDir, CONFIG_FILENAME);
|
||||
if (fs.existsSync(configPath)) {
|
||||
configPaths.unshift(configPath); // Add to front (root first)
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
break; // Reached root
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
|
||||
// Also check home directory (lowest priority)
|
||||
const homeConfig = path.join(process.env.HOME || '', CONFIG_FILENAME);
|
||||
if (homeConfig && fs.existsSync(homeConfig) && !configPaths.includes(homeConfig)) {
|
||||
configPaths.unshift(homeConfig);
|
||||
}
|
||||
|
||||
return configPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merges two values based on resolution strategy
|
||||
*/
|
||||
function mergeValue(
|
||||
parentValue: unknown,
|
||||
childValue: unknown,
|
||||
strategy: ResolutionStrategy
|
||||
): unknown {
|
||||
// Override strategy: child replaces parent completely
|
||||
if (strategy === 'override') {
|
||||
return childValue;
|
||||
}
|
||||
|
||||
// Merge strategy (default)
|
||||
if (Array.isArray(parentValue) && Array.isArray(childValue)) {
|
||||
// Concatenate arrays, remove duplicates for primitives
|
||||
const combined = [...parentValue, ...childValue];
|
||||
if (combined.every((v) => typeof v !== 'object')) {
|
||||
return [...new Set(combined)];
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parentValue === 'object' &&
|
||||
parentValue !== null &&
|
||||
typeof childValue === 'object' &&
|
||||
childValue !== null &&
|
||||
!Array.isArray(parentValue) &&
|
||||
!Array.isArray(childValue)
|
||||
) {
|
||||
// Deep merge objects
|
||||
const result: Record<string, unknown> = { ...parentValue };
|
||||
for (const [key, value] of Object.entries(childValue)) {
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, 'merge');
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Primitives: child overrides parent
|
||||
return childValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string looks like a relative path
|
||||
*/
|
||||
function isRelativePath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith('./') ||
|
||||
value.startsWith('../') ||
|
||||
// Also match paths without ./ prefix that don't look like URLs or absolute paths
|
||||
(!value.startsWith('/') &&
|
||||
!value.startsWith('~') &&
|
||||
!value.includes('://') &&
|
||||
(value.includes('/') || value.endsWith('.json') || value.endsWith('.yml')))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves relative paths in a value based on config file location
|
||||
*/
|
||||
function resolveRelativePaths(value: unknown, configDir: string): unknown {
|
||||
if (typeof value === 'string') {
|
||||
if (isRelativePath(value)) {
|
||||
return path.resolve(configDir, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => resolveRelativePaths(item, configDir));
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
result[key] = resolveRelativePaths(val, configDir);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties that contain filesystem paths and should have relative paths resolved
|
||||
*/
|
||||
const PATH_PROPERTIES: (keyof NopyConfig)[] = ['cubeDirs'];
|
||||
|
||||
/**
|
||||
* Resolves relative paths in a config file based on its location
|
||||
* Only resolves paths for properties that are known to contain filesystem paths
|
||||
*/
|
||||
function resolveConfigPaths(config: NopyConfigFile, configPath: string): NopyConfigFile {
|
||||
const configDir = path.dirname(configPath);
|
||||
const resolved: NopyConfigFile = {};
|
||||
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (key === 'resolution') {
|
||||
// Don't resolve the resolution config itself
|
||||
resolved[key] = value as ResolutionConfig;
|
||||
} else if (PATH_PROPERTIES.includes(key as keyof NopyConfig)) {
|
||||
// Only resolve paths for known path properties
|
||||
resolved[key as keyof NopyConfigFile] = resolveRelativePaths(value, configDir) as any;
|
||||
} else {
|
||||
// Copy other properties as-is (including hosts)
|
||||
resolved[key as keyof NopyConfigFile] = value as any;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a child config into a parent config
|
||||
*/
|
||||
function mergeConfigs(parent: NopyConfig, childFile: NopyConfigFile): NopyConfig {
|
||||
const resolution = childFile.resolution || {};
|
||||
const result: Record<string, unknown> = { ...parent };
|
||||
|
||||
for (const [key, value] of Object.entries(childFile)) {
|
||||
if (key === 'resolution') continue; // Skip resolution property itself
|
||||
|
||||
const strategy = resolution[key as keyof NopyConfig] || 'merge';
|
||||
if (key in result) {
|
||||
result[key] = mergeValue(result[key], value, strategy);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result as unknown as NopyConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the nopy configuration
|
||||
*
|
||||
* Searches for `.nopyrc.json` by traversing upwards from cwd to root.
|
||||
* Multiple config files are merged, with child configs overriding parent configs.
|
||||
*
|
||||
* Use the `resolution` property to customize merge behavior:
|
||||
* ```json
|
||||
* {
|
||||
* "hosts": ["local-host"],
|
||||
* "resolution": {
|
||||
* "hosts": "override"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @returns The merged configuration
|
||||
* @throws Error if no config file is found
|
||||
*/
|
||||
export function loadConfig(): NopyConfig {
|
||||
const configPaths = findConfigFiles();
|
||||
|
||||
if (configPaths.length === 0) {
|
||||
throw new Error(
|
||||
`No ${CONFIG_FILENAME} found. Create one in your project directory or any parent directory.`
|
||||
);
|
||||
}
|
||||
|
||||
// Start with defaults and merge each config file
|
||||
let config: NopyConfig = { ...DEFAULT_CONFIG };
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const rawConfig = JSON.parse(content) as NopyConfigFile;
|
||||
// Resolve relative paths based on config file location
|
||||
const resolvedConfig = resolveConfigPaths(rawConfig, configPath);
|
||||
config = mergeConfigs(config, resolvedConfig);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to load config ${configPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the paths of all discovered config files (for debugging)
|
||||
*/
|
||||
export function getConfigPaths(): string[] {
|
||||
return findConfigFiles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves configuration to a file
|
||||
*
|
||||
* @param data - Configuration data to save
|
||||
* @param configPath - Path to save to (defaults to cwd/.nopyrc.json)
|
||||
*/
|
||||
export function saveConfig(data: Partial<NopyConfig>, configPath?: string): void {
|
||||
const savePath = configPath || path.resolve(process.cwd(), CONFIG_FILENAME);
|
||||
|
||||
// Try to load existing config from this specific file
|
||||
let existing: Partial<NopyConfig> = {};
|
||||
if (fs.existsSync(savePath)) {
|
||||
try {
|
||||
existing = JSON.parse(fs.readFileSync(savePath, 'utf-8'));
|
||||
} catch {
|
||||
// Ignore parse errors, start fresh
|
||||
}
|
||||
}
|
||||
|
||||
const merged = { ...existing, ...data };
|
||||
fs.writeFileSync(savePath, JSON.stringify(merged, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts log configuration to pyinfra command line flags
|
||||
*
|
||||
* @param logConfig - Log configuration with verbosity and debug settings
|
||||
* @returns Array of pyinfra flags
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const flags = logConfigToFlags({ verbosity: 'verbose', debug: true });
|
||||
* // Returns: ['-vv', '--debug']
|
||||
* ```
|
||||
*/
|
||||
export function logConfigToFlags(logConfig?: LogConfig): string[] {
|
||||
const flags: string[] = [];
|
||||
const verbosity = logConfig?.verbosity ?? 'silent';
|
||||
|
||||
// Add verbosity flags
|
||||
switch (verbosity) {
|
||||
case 'silent':
|
||||
// No verbosity flags
|
||||
break;
|
||||
case 'info':
|
||||
flags.push('-v'); // Print meta information
|
||||
break;
|
||||
case 'verbose':
|
||||
flags.push('-vv'); // Print meta + input data
|
||||
break;
|
||||
case 'trace':
|
||||
flags.push('-vvv'); // Print meta + input + output
|
||||
break;
|
||||
}
|
||||
|
||||
// Add debug flag if enabled
|
||||
if (logConfig?.debug) {
|
||||
flags.push('--debug');
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Backwards compatibility re-export
|
||||
*
|
||||
* This file maintains the `cubes` namespace for existing code.
|
||||
* New code should import directly from './cubes/index.js'
|
||||
*
|
||||
* @deprecated Import from './cubes/index.js' instead
|
||||
*/
|
||||
|
||||
import * as cubesModule from './cubes/index.js';
|
||||
|
||||
export const cubes = {
|
||||
// Runtime exports
|
||||
...cubesModule,
|
||||
|
||||
// Aliases for backwards compatibility
|
||||
load: cubesModule.loadCubes,
|
||||
findCubeDirectories: cubesModule.findCubeDirectories,
|
||||
};
|
||||
|
||||
// Re-export types for direct access
|
||||
export type {
|
||||
Hook,
|
||||
HookContext,
|
||||
Cube,
|
||||
Manifest,
|
||||
LoadResult,
|
||||
CubeVariables,
|
||||
} from './cubes/index.js';
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Pyinfra command execution
|
||||
* @module nopy.executor
|
||||
*/
|
||||
|
||||
import { getLogger } from '@logtape/logtape';
|
||||
import { execa } from 'execa';
|
||||
import type { DependencySpec } from './cubes/types.js';
|
||||
|
||||
const log = getLogger(['nopy', 'executor']);
|
||||
|
||||
/**
|
||||
* A deployment command ready for execution
|
||||
*/
|
||||
export interface DeployCall {
|
||||
/** Cube being deployed */
|
||||
cube: string;
|
||||
/** Target host */
|
||||
host: string;
|
||||
/** Working directory for execution */
|
||||
cwd: string;
|
||||
/** Full command array */
|
||||
command: string[];
|
||||
/** Environment variables for the cube */
|
||||
env: Record<string, unknown>;
|
||||
/** Cube dependencies */
|
||||
dependencies: DependencySpec[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of executing a deployment command
|
||||
*/
|
||||
export interface ExecutionResult {
|
||||
/** Cube that was deployed */
|
||||
cube: string;
|
||||
/** Target host */
|
||||
host: string;
|
||||
/** Whether execution succeeded */
|
||||
success: boolean;
|
||||
/** Execution duration in milliseconds */
|
||||
duration: number;
|
||||
/** Standard output (if captured) */
|
||||
stdout?: string;
|
||||
/** Standard error (if captured) */
|
||||
stderr?: string;
|
||||
/** Error if execution failed */
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for deployment execution
|
||||
*/
|
||||
export interface ExecutionOptions {
|
||||
/** Continue executing remaining cubes after failure */
|
||||
continueOnError?: boolean;
|
||||
/** Show what would be executed without running */
|
||||
dryRun?: boolean;
|
||||
/** Callback for progress updates */
|
||||
onProgress?: (result: ExecutionResult, completed: number, total: number) => void;
|
||||
/** Callback when execution starts */
|
||||
onStart?: (cube: string, host: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a single deployment call
|
||||
*
|
||||
* @param call - The deployment call to execute
|
||||
* @returns Execution result
|
||||
*/
|
||||
async function executeCall(call: DeployCall): Promise<ExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
const commandStr = call.command.join(' ');
|
||||
|
||||
try {
|
||||
log.info(`Executing: ${call.cube} -> ${call.host}`);
|
||||
log.debug(`Command: ${commandStr}`);
|
||||
|
||||
// Inherit stdio for live output
|
||||
await execa({ shell: true })(commandStr, {
|
||||
cwd: call.cwd,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
return {
|
||||
cube: call.cube,
|
||||
host: call.host,
|
||||
success: true,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
log.error(`Failed: ${call.cube} -> ${call.host}`, { error: err.message });
|
||||
|
||||
return {
|
||||
cube: call.cube,
|
||||
host: call.host,
|
||||
success: false,
|
||||
duration: Date.now() - startTime,
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs the execution plan without running (dry run)
|
||||
*
|
||||
* @param calls - Array of deployment calls
|
||||
* @param asJson - Output as JSON instead of text
|
||||
*/
|
||||
export function outputExecutionPlan(calls: DeployCall[], asJson?: boolean): void {
|
||||
if (asJson) {
|
||||
const plan = calls.map((call) => ({
|
||||
cube: call.cube,
|
||||
host: call.host,
|
||||
command: call.command.join(' '),
|
||||
variables: call.env,
|
||||
}));
|
||||
console.log(JSON.stringify({ plan }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n=== Execution Plan (Dry Run) ===\n');
|
||||
|
||||
for (let i = 0; i < calls.length; i++) {
|
||||
const call = calls[i];
|
||||
console.log(`Step ${i + 1}: ${call.cube} -> ${call.host}`);
|
||||
console.log(` Command: ${call.command.join(' ')}`);
|
||||
|
||||
const envKeys = Object.keys(call.env);
|
||||
if (envKeys.length > 0) {
|
||||
console.log(' Variables:');
|
||||
for (const [key, value] of Object.entries(call.env)) {
|
||||
// Mask sensitive values
|
||||
const displayValue = key.toLowerCase().includes('password') ? '********' : String(value);
|
||||
console.log(` ${key}=${displayValue}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(`Total: ${calls.length} command(s)\n`);
|
||||
console.log('Run without --dry-run to execute.\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an array of deployment calls
|
||||
*
|
||||
* @param calls - Array of deployment calls to execute
|
||||
* @param options - Execution options
|
||||
* @returns Array of execution results
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const results = await executeDeployCalls(calls, {
|
||||
* continueOnError: false,
|
||||
* onProgress: (result, completed, total) => {
|
||||
* console.log(`${completed}/${total} complete`);
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function executeDeployCalls(
|
||||
calls: DeployCall[],
|
||||
options: ExecutionOptions = {}
|
||||
): Promise<ExecutionResult[]> {
|
||||
if (calls.length === 0) {
|
||||
log.info('No deployment calls to execute');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
outputExecutionPlan(calls);
|
||||
return [];
|
||||
}
|
||||
|
||||
log.info(`Executing ${calls.length} deployment call(s)`);
|
||||
|
||||
const results: ExecutionResult[] = [];
|
||||
|
||||
for (let i = 0; i < calls.length; i++) {
|
||||
const call = calls[i];
|
||||
options.onStart?.(call.cube, call.host);
|
||||
|
||||
const result = await executeCall(call);
|
||||
results.push(result);
|
||||
|
||||
options.onProgress?.(result, i + 1, calls.length);
|
||||
|
||||
if (!result.success && !options.continueOnError) {
|
||||
log.warn(`Stopping execution due to failure in ${call.cube}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a summary of execution results
|
||||
*
|
||||
* @param results - Array of execution results
|
||||
* @returns Summary object
|
||||
*/
|
||||
export function summarizeResults(results: ExecutionResult[]): {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
totalDuration: number;
|
||||
failures: ExecutionResult[];
|
||||
} {
|
||||
const successful = results.filter((r) => r.success);
|
||||
const failed = results.filter((r) => !r.success);
|
||||
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
|
||||
|
||||
return {
|
||||
total: results.length,
|
||||
successful: successful.length,
|
||||
failed: failed.length,
|
||||
totalDuration,
|
||||
failures: failed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Session history management
|
||||
* @module nopy.history
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { NopySession } from './nopy.session.js';
|
||||
|
||||
/** Default number of sessions to keep in history */
|
||||
export const DEFAULT_HISTORY_SIZE = 10;
|
||||
|
||||
/** History file name */
|
||||
export const HISTORY_FILE = '.nopy.history.json';
|
||||
|
||||
/**
|
||||
* A session entry in history
|
||||
*/
|
||||
export interface HistoryEntry {
|
||||
/** Unique identifier (timestamp-based) */
|
||||
id: string;
|
||||
/** Human-readable name (timestamp + cube names) */
|
||||
name: string;
|
||||
/** ISO timestamp when session was executed */
|
||||
timestamp: string;
|
||||
/** The full session data */
|
||||
session: NopySession;
|
||||
}
|
||||
|
||||
/**
|
||||
* History file structure
|
||||
*/
|
||||
export interface SessionHistory {
|
||||
/** Array of session entries, newest first */
|
||||
entries: HistoryEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the history file
|
||||
*/
|
||||
export function getHistoryPath(): string {
|
||||
return path.resolve(process.cwd(), HISTORY_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the session history from disk
|
||||
*
|
||||
* @returns The session history or empty history if file doesn't exist
|
||||
*/
|
||||
export function loadHistory(): SessionHistory {
|
||||
const historyPath = getHistoryPath();
|
||||
|
||||
if (!fs.existsSync(historyPath)) {
|
||||
return { entries: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(historyPath, 'utf-8');
|
||||
return JSON.parse(content) as SessionHistory;
|
||||
} catch {
|
||||
return { entries: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the session history to disk
|
||||
*
|
||||
* @param history - The history to save
|
||||
*/
|
||||
export function saveHistory(history: SessionHistory): void {
|
||||
const historyPath = getHistoryPath();
|
||||
fs.writeFileSync(historyPath, JSON.stringify(history, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a history entry name from session data
|
||||
*
|
||||
* Format: "YYYY-MM-DD HH:mm - cube1, cube2, ..."
|
||||
*
|
||||
* @param session - The session to name
|
||||
* @param timestamp - ISO timestamp
|
||||
* @returns Human-readable name
|
||||
*/
|
||||
function generateEntryName(session: NopySession, timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const dateStr = date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const cubeNames = session.cubes.map((c) => c.key).join(', ');
|
||||
const truncatedCubes = cubeNames.length > 40 ? `${cubeNames.substring(0, 37)}...` : cubeNames;
|
||||
|
||||
const hosts = session.hosts?.join(', ') || 'no host';
|
||||
const truncatedHosts = hosts.length > 20 ? `${hosts.substring(0, 17)}...` : hosts;
|
||||
|
||||
return `${dateStr} - ${truncatedCubes} → ${truncatedHosts}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique ID for a history entry
|
||||
*/
|
||||
function generateEntryId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a session to the history
|
||||
*
|
||||
* @param session - The session to add
|
||||
* @param maxEntries - Maximum number of entries to keep
|
||||
* @returns The created history entry
|
||||
*/
|
||||
export function addToHistory(
|
||||
session: NopySession,
|
||||
maxEntries: number = DEFAULT_HISTORY_SIZE
|
||||
): HistoryEntry {
|
||||
const history = loadHistory();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const entry: HistoryEntry = {
|
||||
id: generateEntryId(),
|
||||
name: generateEntryName(session, timestamp),
|
||||
timestamp,
|
||||
session,
|
||||
};
|
||||
|
||||
// Add to beginning (newest first)
|
||||
history.entries.unshift(entry);
|
||||
|
||||
// Trim to max size
|
||||
if (history.entries.length > maxEntries) {
|
||||
history.entries = history.entries.slice(0, maxEntries);
|
||||
}
|
||||
|
||||
saveHistory(history);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the most recent session from history
|
||||
*
|
||||
* @returns The last session or undefined if history is empty
|
||||
*/
|
||||
export function getLastSession(): HistoryEntry | undefined {
|
||||
const history = loadHistory();
|
||||
return history.entries[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a session by ID
|
||||
*
|
||||
* @param id - The session ID
|
||||
* @returns The session entry or undefined
|
||||
*/
|
||||
export function getSessionById(id: string): HistoryEntry | undefined {
|
||||
const history = loadHistory();
|
||||
return history.entries.find((e) => e.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all sessions in history
|
||||
*
|
||||
* @returns Array of history entries, newest first
|
||||
*/
|
||||
export function listHistory(): HistoryEntry[] {
|
||||
const history = loadHistory();
|
||||
return history.entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all session history
|
||||
*/
|
||||
export function clearHistory(): void {
|
||||
saveHistory({ entries: [] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a specific session from history
|
||||
*
|
||||
* @param id - The session ID to remove
|
||||
* @returns true if removed, false if not found
|
||||
*/
|
||||
export function removeFromHistory(id: string): boolean {
|
||||
const history = loadHistory();
|
||||
const initialLength = history.entries.length;
|
||||
history.entries = history.entries.filter((e) => e.id !== id);
|
||||
|
||||
if (history.entries.length < initialLength) {
|
||||
saveHistory(history);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats history entries for display
|
||||
*
|
||||
* @param entries - History entries to format
|
||||
* @returns Formatted string for console output
|
||||
*/
|
||||
export function formatHistoryList(entries: HistoryEntry[]): string {
|
||||
if (entries.length === 0) {
|
||||
return 'No sessions in history.';
|
||||
}
|
||||
|
||||
const lines = ['', 'Session History:', ''];
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const marker = index === 0 ? '→' : ' ';
|
||||
lines.push(` ${marker} [${index + 1}] ${entry.name}`);
|
||||
lines.push(` ID: ${entry.id}`);
|
||||
});
|
||||
|
||||
lines.push('');
|
||||
lines.push(`Total: ${entries.length} session(s)`);
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Main entry point for nopy
|
||||
* @module nopy.main
|
||||
*/
|
||||
|
||||
import { type LogRecord, configure, getAnsiColorFormatter, getLogger } from '@logtape/logtape';
|
||||
import { loadCubes } from './cubes/index.js';
|
||||
import { BuildContext } from './cubes/dependencies.js';
|
||||
import { Variables } from './nopy.common.js';
|
||||
import { getConfigPaths, loadConfig } from './nopy.config.js';
|
||||
import { type ExecutionResult, executeDeployCalls, summarizeResults } from './nopy.executor.js';
|
||||
import { DEFAULT_HISTORY_SIZE, addToHistory } from './nopy.history.js';
|
||||
import { type NopySession, saveSession } from './nopy.session.js';
|
||||
import { runWorkflow } from './nopy.workflow.js';
|
||||
|
||||
/**
|
||||
* Configures the logtape logger for console output
|
||||
*/
|
||||
function configureLogtape(): void {
|
||||
configure({
|
||||
sinks: {
|
||||
console: (() => {
|
||||
const formatter = getAnsiColorFormatter();
|
||||
return (record: LogRecord) => {
|
||||
const formatted = formatter(record);
|
||||
if (typeof formatted === 'string') {
|
||||
const msg = formatted.replace(/\r?\n$/, '');
|
||||
const props = record.properties as Record<string, unknown>;
|
||||
console.log(msg, ...Object.values(props));
|
||||
}
|
||||
};
|
||||
})(),
|
||||
},
|
||||
loggers: [
|
||||
{
|
||||
category: ['logtape', 'meta'],
|
||||
level: 'error',
|
||||
sinks: ['console'],
|
||||
},
|
||||
{
|
||||
category: 'nopy',
|
||||
level: 'debug',
|
||||
sinks: ['console'],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize logging
|
||||
configureLogtape();
|
||||
|
||||
/**
|
||||
* Prints the active configuration summary
|
||||
*/
|
||||
function printActiveConfig(config: import('./nopy.config.js').NopyConfig, opts: { continueOnError: boolean }): void {
|
||||
const configPaths = getConfigPaths();
|
||||
const cwd = process.cwd();
|
||||
|
||||
const lines: string[] = [''];
|
||||
lines.push(' Configuration');
|
||||
lines.push(' ─────────────');
|
||||
|
||||
const relativePaths = configPaths.map((p) => {
|
||||
if (p.startsWith(cwd)) return `.${p.slice(cwd.length)}`;
|
||||
if (p.startsWith(process.env.HOME || '')) return `~${p.slice((process.env.HOME || '').length)}`;
|
||||
return p;
|
||||
});
|
||||
lines.push(` Config: ${relativePaths.join(' → ')}`);
|
||||
|
||||
if (config.hosts.length > 0) lines.push(` Hosts: ${config.hosts.join(', ')}`);
|
||||
if (config.cubeDirs.length > 0) lines.push(` Cube dirs: ${config.cubeDirs.join(', ')}`);
|
||||
if (opts.continueOnError) lines.push(' Execution: continue-on-error');
|
||||
|
||||
const envEntries = Object.entries(config.env);
|
||||
if (envEntries.length > 0) {
|
||||
lines.push(' Env vars:');
|
||||
for (const [key, value] of envEntries) {
|
||||
const isEmpty = value === null || value === undefined || value === '';
|
||||
lines.push(` ${key}: ${isEmpty ? '<EMPTY>' : '<VALUE>'}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the nopy main function
|
||||
*/
|
||||
export interface NopyOptions {
|
||||
useDefaults?: boolean;
|
||||
useAuthKey?: boolean;
|
||||
saveSession?: string;
|
||||
loadSession?: string;
|
||||
replaySession?: NopySession;
|
||||
dryRun?: boolean;
|
||||
printOnly?: boolean;
|
||||
continueOnError?: boolean;
|
||||
jsonOutput?: boolean;
|
||||
saveToHistory?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a nopy execution
|
||||
*/
|
||||
export interface NopyResult {
|
||||
success: boolean;
|
||||
results: ExecutionResult[];
|
||||
summary: {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
totalDuration: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for nopy deployments
|
||||
*/
|
||||
export async function nopy(opts: NopyOptions = {}): Promise<NopyResult | undefined> {
|
||||
const {
|
||||
useDefaults = false,
|
||||
useAuthKey,
|
||||
saveSession: saveSessionPath,
|
||||
loadSession: loadSessionPath,
|
||||
replaySession,
|
||||
dryRun = false,
|
||||
printOnly = false,
|
||||
continueOnError = false,
|
||||
jsonOutput = false,
|
||||
saveToHistory = true,
|
||||
} = opts;
|
||||
|
||||
const log = getLogger(['nopy']);
|
||||
const config = loadConfig();
|
||||
|
||||
if (!jsonOutput && !replaySession && !loadSessionPath) {
|
||||
printActiveConfig(config, { continueOnError });
|
||||
}
|
||||
|
||||
const { cubes, errors } = await loadCubes();
|
||||
const variables = new Variables(config.env);
|
||||
|
||||
if (errors.length > 0) {
|
||||
log.error('Errors found during cube loading:');
|
||||
errors.forEach((error) => log.error(error));
|
||||
if (jsonOutput) console.log(JSON.stringify({ success: false, errors }, null, 2));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const workflow = await runWorkflow(loadSessionPath, cubes, config, { useDefaults, useAuthKey }, replaySession);
|
||||
|
||||
// Step 3: Build deployment calls using BuildContext
|
||||
const context = new BuildContext(
|
||||
cubes,
|
||||
variables,
|
||||
workflow.session,
|
||||
config,
|
||||
{
|
||||
method: workflow.authMethod,
|
||||
username: workflow.username,
|
||||
password: workflow.password,
|
||||
},
|
||||
{
|
||||
useDefaults,
|
||||
isSessionReplay: workflow.isReplay,
|
||||
}
|
||||
);
|
||||
|
||||
for (const host of workflow.session.hosts!) {
|
||||
for (const cubeId of workflow.selectedCubes) {
|
||||
await context.resolveCube(cubeId, host);
|
||||
}
|
||||
}
|
||||
|
||||
const sessionForSaving: NopySession = {
|
||||
...workflow.session,
|
||||
cubes: context.cubeSessions,
|
||||
env: variables.get('global'),
|
||||
};
|
||||
|
||||
if (saveSessionPath && !workflow.isReplay) {
|
||||
saveSession(sessionForSaving, saveSessionPath);
|
||||
}
|
||||
|
||||
if (saveToHistory && !dryRun && !workflow.isReplay && context.deployCalls.length > 0) {
|
||||
const historySize = config.history?.maxSessions ?? DEFAULT_HISTORY_SIZE;
|
||||
if (config.history?.autoSave !== false) {
|
||||
addToHistory(sessionForSaving, historySize);
|
||||
}
|
||||
}
|
||||
|
||||
if (printOnly) {
|
||||
console.log('\n Deploy Commands\n ───────────────\n');
|
||||
for (const call of context.deployCalls) {
|
||||
console.log(` # ${call.cube} -> ${call.host}`);
|
||||
console.log(` ${call.command.join(' ')}\n`);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
results: [],
|
||||
summary: { total: context.deployCalls.length, successful: 0, failed: 0, totalDuration: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const results = await executeDeployCalls(context.deployCalls, {
|
||||
dryRun,
|
||||
continueOnError,
|
||||
onProgress: (result, completed, total) => {
|
||||
if (!jsonOutput) {
|
||||
const status = result.success ? '✓' : '✗';
|
||||
log.info(`[${completed}/${total}] ${status} ${result.cube} -> ${result.host}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const summary = summarizeResults(results);
|
||||
return {
|
||||
success: summary.failed === 0,
|
||||
results,
|
||||
summary: {
|
||||
total: summary.total,
|
||||
successful: summary.successful,
|
||||
failed: summary.failed,
|
||||
totalDuration: summary.totalDuration,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Interactive prompts for nopy CLI
|
||||
* @module nopy.prompts
|
||||
*/
|
||||
|
||||
// @ts-ignore - no types available
|
||||
import Enquirer from 'enquirer';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
// @ts-ignore - no types available
|
||||
import CheckboxPlus from 'inquirer-checkbox-plus-prompt';
|
||||
import { z } from 'zod';
|
||||
import type { Cube } from './cubes/index.js';
|
||||
import type { Variables } from './nopy.common.js';
|
||||
|
||||
// Register the checkbox-plus prompt type for filterable multi-select
|
||||
inquirer.registerPrompt('checkbox-plus', CheckboxPlus);
|
||||
|
||||
interface CubeChoice {
|
||||
name: string;
|
||||
value: string;
|
||||
short: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to select cubes to execute with filtering support
|
||||
*/
|
||||
export async function CubeSelection(
|
||||
cubes: Record<string, Cube>
|
||||
): Promise<{ selectedCubes: string[] }> {
|
||||
const cubeChoices: CubeChoice[] = Object.values(cubes)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map((cube) => ({
|
||||
name: `${cube.id} - ${cube.name}`,
|
||||
value: cube.id,
|
||||
short: cube.id,
|
||||
}));
|
||||
|
||||
// Clear terminal and move cursor to top
|
||||
process.stdout.write('\x1B[2J\x1B[0f');
|
||||
|
||||
const terminalHeight = process.stdout.rows || 24;
|
||||
const pageSize = Math.max(10, terminalHeight - 5);
|
||||
|
||||
console.log('\n Cube Selection\n');
|
||||
console.log(' Type to filter • Space to select • Enter to confirm\n');
|
||||
|
||||
const answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox-plus',
|
||||
name: 'selectedCubes',
|
||||
message: 'Select cubes:',
|
||||
pageSize,
|
||||
highlight: true,
|
||||
searchable: true,
|
||||
source: (_answersSoFar: unknown, input: string | undefined) => {
|
||||
const searchTerm = input || '';
|
||||
if (!searchTerm) return Promise.resolve(cubeChoices);
|
||||
const results = fuzzy.filter(searchTerm, cubeChoices, {
|
||||
extract: (choice: CubeChoice) => choice.name,
|
||||
});
|
||||
return Promise.resolve(results.map((r) => r.original));
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return { selectedCubes: answers.selectedCubes };
|
||||
}
|
||||
|
||||
export async function AuthSelection(useAuthKey?: boolean): Promise<{
|
||||
authMethod: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}> {
|
||||
if (useAuthKey) return { authMethod: 'ssh-key' };
|
||||
const answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'authMethod',
|
||||
message: 'Select authentication method:',
|
||||
choices: ['ssh-key', 'password'],
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'username',
|
||||
message: 'Enter username:',
|
||||
when: (answers) => answers.authMethod !== 'ssh-key',
|
||||
},
|
||||
{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: 'Enter password:',
|
||||
when: (answers) => answers.authMethod !== 'ssh-key',
|
||||
},
|
||||
]);
|
||||
return answers as { authMethod: string; username?: string; password?: string };
|
||||
}
|
||||
|
||||
export async function PasswordSelection(username: string): Promise<string> {
|
||||
const { password } = await inquirer.prompt([
|
||||
{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: `Enter password for ${username}:`,
|
||||
},
|
||||
]);
|
||||
return password;
|
||||
}
|
||||
|
||||
export async function HostSelection(hosts: string[]): Promise<string> {
|
||||
const selectedHost = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'host',
|
||||
message: 'Select host from inventory',
|
||||
choices: ['docker', 'vagrant', ...hosts, 'custom'],
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'customHost',
|
||||
message: 'Specify custom host address:',
|
||||
when: (answers) => answers.host === 'custom',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'vagrantVM',
|
||||
message: 'Specify vagrant machine:',
|
||||
default: 'default',
|
||||
when: (answers) => answers.host === 'vagrant',
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'dockerContainer',
|
||||
message: 'Specify docker container name:',
|
||||
when: (answers) => answers.host === 'runtime:docker',
|
||||
},
|
||||
]);
|
||||
if (selectedHost.host === 'vagrant') return `@vagrant/${selectedHost.vagrantVM}`;
|
||||
if (selectedHost.host === 'runtime:docker') return `@docker/${selectedHost.dockerContainer}`;
|
||||
return selectedHost.customHost ?? selectedHost.host;
|
||||
}
|
||||
|
||||
function coerceValue(value: unknown, zodType: z.ZodTypeAny): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
if (zodType instanceof z.ZodDefault) return coerceValue(value, zodType._def.innerType);
|
||||
if (zodType instanceof z.ZodOptional) return coerceValue(value, zodType._def.innerType);
|
||||
if (zodType instanceof z.ZodNullable) {
|
||||
if (value === 'null' || value === '') return null;
|
||||
return coerceValue(value, zodType._def.innerType);
|
||||
}
|
||||
if (zodType instanceof z.ZodBoolean) return value === 'true' || value === 'yes' || value === '1';
|
||||
if (zodType instanceof z.ZodNumber) {
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? value : num;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
interface FormChoice {
|
||||
name: string;
|
||||
message: string;
|
||||
initial: string;
|
||||
}
|
||||
|
||||
export async function VariableAssignment<S extends z.AnyZodObject>(
|
||||
cube: Cube<S>,
|
||||
variables: Variables
|
||||
) {
|
||||
const schema = cube.manifest.schema.shape;
|
||||
const defaults = cube.getDefaults();
|
||||
const variablesToConfigure: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, defaultValue] of Object.entries(defaults)) {
|
||||
if (variables.get(cube.id, 'params')[key] === undefined) {
|
||||
variablesToConfigure[key] = defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(variablesToConfigure).length === 0) return;
|
||||
|
||||
const choices: FormChoice[] = Object.entries(variablesToConfigure).map(([key, value]) => {
|
||||
const zodType = schema[key];
|
||||
const description = zodType?.description || key;
|
||||
return { name: key, message: description, initial: String(value ?? '') };
|
||||
});
|
||||
|
||||
const form = new (Enquirer as any).Form({
|
||||
name: 'variables',
|
||||
message: `[${cube.id}] ${cube.name}\n (↑↓ navigate, Enter to submit)`,
|
||||
choices,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await form.run();
|
||||
const coercedResult: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(result)) {
|
||||
const zodType = schema[key];
|
||||
coercedResult[key] = zodType ? coerceValue(value, zodType) : value;
|
||||
}
|
||||
variables.assign(cube.id, 'prompts', coercedResult);
|
||||
} catch {
|
||||
// User cancelled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Session management for saving and replaying deployments
|
||||
* @module nopy.session
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { TVariables } from './nopy.common.js';
|
||||
|
||||
/**
|
||||
* Primitive value types that can be stored in session variables
|
||||
*/
|
||||
export type SessionValue = string | number | boolean | null | undefined;
|
||||
|
||||
/**
|
||||
* Record of session variables
|
||||
*/
|
||||
export type SessionVariables = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Configuration for a single cube within a session
|
||||
*/
|
||||
export interface CubeSession {
|
||||
/** Cube identifier */
|
||||
key: string;
|
||||
/** Cube-specific variables */
|
||||
variables: TVariables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication configuration for a session
|
||||
*/
|
||||
export interface AuthSession {
|
||||
/** Authentication method */
|
||||
method: 'ssh-key' | 'password' | 'ssh';
|
||||
/** Username for authentication (password auth only) */
|
||||
username?: string;
|
||||
// Note: password is intentionally excluded for security
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete session configuration
|
||||
*/
|
||||
export interface NopySession {
|
||||
/** Optional session name */
|
||||
name?: string;
|
||||
/** Array of cube configurations */
|
||||
cubes: CubeSession[];
|
||||
/** Target hosts */
|
||||
hosts?: string[];
|
||||
/** Authentication configuration */
|
||||
auth: AuthSession;
|
||||
/** Global environment variables */
|
||||
env?: TVariables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a session to a JSON file
|
||||
*
|
||||
* @param session - The session to save
|
||||
* @param filePath - Path to save the session file
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* saveSession(session, './my-deployment.nopysession.json');
|
||||
* ```
|
||||
*/
|
||||
export function saveSession(session: NopySession, filePath: string): void {
|
||||
const sessionToSave = {
|
||||
...session,
|
||||
};
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(sessionToSave, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a session from an MJS file
|
||||
*
|
||||
* @param filePath - Path to the MJS session file
|
||||
* @returns The loaded session
|
||||
*/
|
||||
async function loadSessionFromMJS(filePath: string): Promise<NopySession> {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const fileUrl = `file://${absolutePath}`;
|
||||
|
||||
try {
|
||||
const module = (await import(fileUrl)) as { default?: NopySession };
|
||||
const session = module.default;
|
||||
|
||||
if (!session) {
|
||||
throw new Error('MJS file must export a default object');
|
||||
}
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to load MJS session: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a session from a JSON file
|
||||
*
|
||||
* @param filePath - Path to the JSON session file
|
||||
* @returns The loaded session
|
||||
*/
|
||||
function loadSessionFromJSON(filePath: string): NopySession {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content) as NopySession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a session from a JSON or MJS file
|
||||
*
|
||||
* @param filePath - Path to the session file (.json or .mjs)
|
||||
* @returns The loaded session
|
||||
* @throws Error if file not found or invalid format
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const session = await loadSession('./deployment.nopysession.json');
|
||||
* ```
|
||||
*/
|
||||
export async function loadSession(filePath: string): Promise<NopySession> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Session file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath);
|
||||
let session: NopySession;
|
||||
|
||||
if (ext === '.mjs') {
|
||||
session = await loadSessionFromMJS(filePath);
|
||||
} else if (ext === '.json') {
|
||||
session = loadSessionFromJSON(filePath);
|
||||
} else {
|
||||
throw new Error(`Unsupported session file format: ${ext}. Use .json or .mjs`);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!session.cubes || !Array.isArray(session.cubes)) {
|
||||
throw new Error('Invalid session format: missing or invalid "cubes" field');
|
||||
}
|
||||
if (session.hosts && !Array.isArray(session.hosts)) {
|
||||
throw new Error('Invalid session format: invalid "hosts" field');
|
||||
}
|
||||
if (!session.auth) {
|
||||
throw new Error('Invalid session format: missing "auth" field');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all session files in a directory
|
||||
*
|
||||
* @param dirPath - Directory to search for session files
|
||||
* @returns Array of session file paths
|
||||
*/
|
||||
export function listSessions(dirPath: string = process.cwd()): string[] {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(dirPath);
|
||||
return files
|
||||
.filter((file) => file.endsWith('.session.json') || file.endsWith('.session.mjs'))
|
||||
.map((file) => path.join(dirPath, file));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a session object from runtime data
|
||||
*
|
||||
* @param params - Session parameters
|
||||
* @returns A NopySession object
|
||||
*/
|
||||
export function createSession(params: {
|
||||
name?: string;
|
||||
cubes: CubeSession[];
|
||||
hosts: string[];
|
||||
auth: AuthSession;
|
||||
env?: TVariables;
|
||||
}): NopySession {
|
||||
return {
|
||||
name: params.name,
|
||||
cubes: params.cubes,
|
||||
hosts: params.hosts,
|
||||
auth: params.auth,
|
||||
env: params.env,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out internal variables from cube variables
|
||||
*
|
||||
* Internal variables are those used by the prompts system
|
||||
* and should not be saved in session files.
|
||||
*
|
||||
* @param variables - Variables object
|
||||
* @returns Filtered variables without internal keys
|
||||
*/
|
||||
export function filterInternalVariables(
|
||||
variables: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const internalKeys = ['customize'];
|
||||
const filtered: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
if (!internalKeys.includes(key)) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separates environment variables from cube-specific variables
|
||||
*
|
||||
* @param allVariables - All variables including env and cube-specific
|
||||
* @param envVariables - Known environment variables from config
|
||||
* @returns Object with separate env and cube variables
|
||||
*/
|
||||
export function separateEnvAndCubeVariables(
|
||||
allVariables: Record<string, unknown>,
|
||||
envVariables: Record<string, unknown>
|
||||
): { env: Record<string, unknown>; cubeVars: Record<string, unknown> } {
|
||||
const env: Record<string, unknown> = {};
|
||||
const cubeVars: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(allVariables)) {
|
||||
if (key in envVariables) {
|
||||
env[key] = value;
|
||||
} else {
|
||||
cubeVars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { env, cubeVars };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user