Add release pipeline and upgrade toolchain to TypeScript 7
Publish snapshot / snapshot (push) Failing after 1m58s
Publish snapshot / snapshot (push) Failing after 1m58s
Publishing infrastructure - Three Gitea workflows: ci.yml (PRs, non-main pushes), publish-snapshot.yml (main -> Gitea under dist-tag @main) and release.yml (tags -> Gitea + npmjs) - Tag-driven releases as <package-dir>-v<version>; the manifest stays the source of truth and release.yml refuses to run if tag and manifest disagree - Every publish is idempotent: each step checks the registry first, so a run that fails on the second registry can simply be re-run - Hard coverage gate (85% branches) shared by CI, the pre-push hook and local runs, since the thresholds live in vitest.config.ts rather than a CI flag - README.PUBLISH.md documents the whole mechanism Toolchain - TypeScript 7 native compiler; drop tsgo and ts-node, use tsx for dev runs - Biome 1.9 -> 2.x, Vitest 1 -> 4, zod 3 -> 4, inquirer 8 -> 14, pnpm 11.17.0 - Replace inquirer-checkbox-plus-prompt, which is peer-capped at inquirer <9, with enquirer's AutoComplete; the CubeSelection contract is unchanged - Stand in for zod 4's removed z.AnyZodObject with a local AnyObjectSchema Repo hygiene - Stop tracking dist/; ignore coverage/, *.tsbuildinfo, .npmrc* and release.json - Drop package-lock.json in favour of pnpm-lock.yaml Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 bitsquare
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
export * from './keyman.main.js';
|
||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
export * from './keyman.main.js';
|
||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
Vendored
-2
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
export {};
|
||||
Vendored
-11
@@ -1,11 +0,0 @@
|
||||
#!/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
@@ -1,76 +0,0 @@
|
||||
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
@@ -1,212 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
export declare function copyKey(sshDir: string, tmpDir: string): Promise<void>;
|
||||
Vendored
-50
@@ -1,50 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
export declare function decryptKeys(sshDir: string, vaultDir: string, ageKey: string): Promise<void>;
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
export declare function encryptKeys(sshDir: string, vaultDir: string, tmpDir: string, pubkey: string): Promise<void>;
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
export declare function generateKey(tmpDir: string, keysDir: string, pubkey: string): Promise<void>;
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
export declare function listKeys(sshDir: string, vaultDir: string, tmpDir: string): Promise<void>;
|
||||
Vendored
-115
@@ -1,115 +0,0 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
export declare function keyman(): Promise<void>;
|
||||
Vendored
-79
@@ -1,79 +0,0 @@
|
||||
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
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* 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
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,67 @@
|
||||
{
|
||||
"name": "@bitstack/keyman",
|
||||
"description": "A system to simplify ssh key management",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "A system to simplify ssh key management",
|
||||
"keywords": [
|
||||
"ssh",
|
||||
"keys",
|
||||
"age",
|
||||
"encryption",
|
||||
"cli"
|
||||
],
|
||||
"license": "MIT",
|
||||
"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"
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://gitea.bitsquare.dev/BitSquare/ansiblings.git",
|
||||
"directory": "packages/keyman"
|
||||
},
|
||||
"homepage": "https://gitea.bitsquare.dev/BitSquare/ansiblings/src/branch/main/packages/keyman",
|
||||
"bugs": {
|
||||
"url": "https://gitea.bitsquare.dev/BitSquare/ansiblings/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=21.0.0"
|
||||
"node": ">=22"
|
||||
},
|
||||
"bin": {
|
||||
"keyman": "./dist/keyman.cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist .tsbuildinfo",
|
||||
"build": "tsc",
|
||||
"prepack": "pnpm run build",
|
||||
"link:local": "pnpm run build && npm link",
|
||||
"keyman": "tsx src/keyman.cli.ts",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"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"
|
||||
"execa": "^10.0.0",
|
||||
"inquirer": "^14.0.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
export * from './keyman.main.js';
|
||||
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||
export * from './keyman.main.js';
|
||||
|
||||
@@ -232,7 +232,7 @@ export function loadConfig(): KeymanConfig {
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error('❌ ERROR: Invalid merged configuration:');
|
||||
error.errors.forEach((err) => {
|
||||
error.issues.forEach((err) => {
|
||||
console.error(` - ${err.path.join('.')}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Tests for keyman config discovery, merging and path resolution.
|
||||
*
|
||||
* Real .keymanrc.json files are written into temp directories and cwd is moved
|
||||
* there, because discovery is defined in terms of the real filesystem walk.
|
||||
* os.homedir() is stubbed so the developer's own home config cannot leak in.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getConfigPaths,
|
||||
type KeymanConfigFile,
|
||||
loadConfig,
|
||||
resolveConfigPaths,
|
||||
} from '../src/keyman.config.js';
|
||||
|
||||
const DEFAULTS = {
|
||||
vaultRoot: 'vault',
|
||||
keysDir: 'keys',
|
||||
tmpDir: 'tmp',
|
||||
ageKeyFile: 'age.key',
|
||||
};
|
||||
|
||||
describe('keyman config', () => {
|
||||
let originalCwd: string;
|
||||
let originalVaultRoot: string | undefined;
|
||||
let rootDir: string;
|
||||
let emptyHome: string;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const write = (dir: string, config: KeymanConfigFile | string) => {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.keymanrc.json'),
|
||||
typeof config === 'string' ? config : JSON.stringify(config, null, 2)
|
||||
);
|
||||
};
|
||||
|
||||
const messages = (spy: ReturnType<typeof vi.spyOn>) =>
|
||||
spy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
originalVaultRoot = process.env.VAULT_ROOT;
|
||||
delete process.env.VAULT_ROOT;
|
||||
|
||||
rootDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-config-')));
|
||||
emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-home-')));
|
||||
vi.spyOn(os, 'homedir').mockReturnValue(emptyHome);
|
||||
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
process.chdir(rootDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
vi.restoreAllMocks();
|
||||
if (originalVaultRoot === undefined) {
|
||||
delete process.env.VAULT_ROOT;
|
||||
} else {
|
||||
process.env.VAULT_ROOT = originalVaultRoot;
|
||||
}
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
fs.rmSync(emptyHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('discovery', () => {
|
||||
it('falls back to defaults when no config file exists', () => {
|
||||
expect(loadConfig()).toEqual(DEFAULTS);
|
||||
expect(messages(errorSpy)).toContain('No .keymanrc.json found');
|
||||
});
|
||||
|
||||
it('loads the config file in the current directory', () => {
|
||||
write(rootDir, { keysDir: 'my-keys' });
|
||||
|
||||
expect(loadConfig().keysDir).toBe('my-keys');
|
||||
expect(messages(errorSpy)).toContain('Loaded configuration from');
|
||||
});
|
||||
|
||||
it('fills unspecified properties from the defaults', () => {
|
||||
write(rootDir, { keysDir: 'my-keys' });
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.tmpDir).toBe(DEFAULTS.tmpDir);
|
||||
expect(config.ageKeyFile).toBe(DEFAULTS.ageKeyFile);
|
||||
});
|
||||
|
||||
it('lets a child config override its parent', () => {
|
||||
write(rootDir, { keysDir: 'parent-keys', tmpDir: 'parent-tmp' });
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, { keysDir: 'child-keys' });
|
||||
process.chdir(child);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.keysDir).toBe('child-keys');
|
||||
expect(config.tmpDir).toBe('parent-tmp');
|
||||
});
|
||||
|
||||
it('gives the home config the lowest priority', () => {
|
||||
write(emptyHome, { keysDir: 'home-keys', tmpDir: 'home-tmp' });
|
||||
write(rootDir, { keysDir: 'local-keys' });
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.keysDir).toBe('local-keys');
|
||||
expect(config.tmpDir).toBe('home-tmp');
|
||||
});
|
||||
|
||||
it('does not load the home config twice when cwd is the home directory', () => {
|
||||
write(emptyHome, { keysDir: 'home-keys' });
|
||||
process.chdir(emptyHome);
|
||||
|
||||
const homeConfig = path.join(emptyHome, '.keymanrc.json');
|
||||
expect(getConfigPaths().filter((p) => p === homeConfig)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('orders discovered config files parent first', () => {
|
||||
write(rootDir, {});
|
||||
const child = path.join(rootDir, 'a', 'b');
|
||||
write(child, {});
|
||||
process.chdir(child);
|
||||
|
||||
expect(getConfigPaths()).toEqual([
|
||||
path.join(rootDir, '.keymanrc.json'),
|
||||
path.join(child, '.keymanrc.json'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed configs', () => {
|
||||
it('skips a file with invalid JSON and keeps the rest', () => {
|
||||
write(rootDir, { keysDir: 'parent-keys' });
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, '{ not json');
|
||||
process.chdir(child);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.keysDir).toBe('parent-keys');
|
||||
expect(messages(warnSpy)).toContain('Skipping invalid JSON in');
|
||||
});
|
||||
|
||||
it('skips a config it cannot read at all', () => {
|
||||
// A directory where a file is expected: readFileSync fails with EISDIR,
|
||||
// which is not a SyntaxError.
|
||||
fs.mkdirSync(path.join(rootDir, '.keymanrc.json'));
|
||||
|
||||
expect(loadConfig()).toEqual(DEFAULTS);
|
||||
expect(messages(warnSpy)).toContain('Skipping config');
|
||||
expect(messages(warnSpy)).not.toContain('invalid JSON');
|
||||
});
|
||||
|
||||
it('falls back to defaults when the merged config fails validation', () => {
|
||||
write(rootDir, { keysDir: 123 } as unknown as KeymanConfigFile);
|
||||
|
||||
expect(loadConfig()).toEqual(DEFAULTS);
|
||||
expect(messages(errorSpy)).toContain('Invalid merged configuration');
|
||||
expect(messages(errorSpy)).toContain('keysDir');
|
||||
expect(messages(errorSpy)).toContain('Falling back to default configuration');
|
||||
});
|
||||
});
|
||||
|
||||
describe('path resolution', () => {
|
||||
it('resolves a relative vaultRoot against the config file directory', () => {
|
||||
write(rootDir, { vaultRoot: './secrets' });
|
||||
|
||||
expect(loadConfig().vaultRoot).toBe(path.join(rootDir, 'secrets'));
|
||||
});
|
||||
|
||||
it('resolves a vaultRoot that points above the config file', () => {
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, { vaultRoot: '../secrets' });
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig().vaultRoot).toBe(path.join(rootDir, 'secrets'));
|
||||
});
|
||||
|
||||
it('leaves an absolute vaultRoot untouched', () => {
|
||||
write(rootDir, { vaultRoot: '/srv/vault' });
|
||||
|
||||
expect(loadConfig().vaultRoot).toBe('/srv/vault');
|
||||
});
|
||||
|
||||
it('leaves non-path properties alone', () => {
|
||||
write(rootDir, { keysDir: './keys', tmpDir: './tmp' });
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.keysDir).toBe('./keys');
|
||||
expect(config.tmpDir).toBe('./tmp');
|
||||
});
|
||||
|
||||
it('resolves each config file against its own directory', () => {
|
||||
write(rootDir, { vaultRoot: './parent-vault' });
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, {});
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig().vaultRoot).toBe(path.join(rootDir, 'parent-vault'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge strategy', () => {
|
||||
it('honours an explicit override strategy', () => {
|
||||
write(rootDir, { vaultRoot: '/parent-vault' });
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, { vaultRoot: '/child-vault', resolution: { vaultRoot: 'override' } });
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig().vaultRoot).toBe('/child-vault');
|
||||
});
|
||||
|
||||
it('never surfaces the resolution key in the loaded config', () => {
|
||||
write(rootDir, { keysDir: 'my-keys', resolution: { keysDir: 'override' } });
|
||||
|
||||
expect(loadConfig()).not.toHaveProperty('resolution');
|
||||
});
|
||||
|
||||
it('tolerates and drops array-valued keys the schema does not define', () => {
|
||||
write(rootDir, { extra: ['a', 'b'] } as unknown as KeymanConfigFile);
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, { extra: ['b', 'c'], keysDir: 'my-keys' } as unknown as KeymanConfigFile);
|
||||
process.chdir(child);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config).toEqual({ ...DEFAULTS, keysDir: 'my-keys' });
|
||||
});
|
||||
|
||||
it('tolerates and drops object-valued keys the schema does not define', () => {
|
||||
write(rootDir, { extra: { a: 1 } } as unknown as KeymanConfigFile);
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, { extra: { a: 2, b: 3 } } as unknown as KeymanConfigFile);
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig()).toEqual(DEFAULTS);
|
||||
});
|
||||
|
||||
it('tolerates arrays of objects, which cannot be de-duplicated', () => {
|
||||
write(rootDir, { extra: [{ a: 1 }] } as unknown as KeymanConfigFile);
|
||||
const child = path.join(rootDir, 'nested');
|
||||
write(child, { extra: [{ a: 2 }] } as unknown as KeymanConfigFile);
|
||||
process.chdir(child);
|
||||
|
||||
expect(loadConfig()).toEqual(DEFAULTS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveConfigPaths', () => {
|
||||
it('places every directory under the vault root', () => {
|
||||
const paths = resolveConfigPaths({ ...DEFAULTS, vaultRoot: '/srv/vault' });
|
||||
|
||||
expect(paths).toEqual({
|
||||
vaultRoot: '/srv/vault',
|
||||
keysDir: '/srv/vault/keys',
|
||||
tmpDir: '/srv/vault/tmp',
|
||||
keyPath: '/srv/vault/age.key',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a relative vault root against the current directory', () => {
|
||||
const paths = resolveConfigPaths({ ...DEFAULTS, vaultRoot: 'vault' });
|
||||
|
||||
expect(paths.vaultRoot).toBe(path.join(rootDir, 'vault'));
|
||||
});
|
||||
|
||||
it('lets VAULT_ROOT take precedence over the config', () => {
|
||||
process.env.VAULT_ROOT = '/env/vault';
|
||||
|
||||
const paths = resolveConfigPaths({ ...DEFAULTS, vaultRoot: '/srv/vault' });
|
||||
|
||||
expect(paths.vaultRoot).toBe('/env/vault');
|
||||
expect(paths.keyPath).toBe('/env/vault/age.key');
|
||||
});
|
||||
|
||||
it('honours absolute sub-directory overrides', () => {
|
||||
const paths = resolveConfigPaths({
|
||||
...DEFAULTS,
|
||||
vaultRoot: '/srv/vault',
|
||||
keysDir: '/elsewhere/keys',
|
||||
});
|
||||
|
||||
expect(paths.keysDir).toBe('/elsewhere/keys');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Tests for copyKey.
|
||||
*
|
||||
* inquirer and execa are mocked so nothing touches a TTY or the real
|
||||
* clipboard; the key directories are real temp directories.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { execa, prompt, stdin } = vi.hoisted(() => ({
|
||||
execa: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
stdin: { write: vi.fn(), end: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('execa', () => ({ execa }));
|
||||
vi.mock('inquirer', () => ({ default: { prompt } }));
|
||||
|
||||
import { copyKey } from '../src/keyman.copy.js';
|
||||
|
||||
describe('copyKey', () => {
|
||||
let root: string;
|
||||
let sshDir: string;
|
||||
let tmpDir: string;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const touch = (dir: string, file: string, contents = '') => {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), contents);
|
||||
};
|
||||
|
||||
const messages = (spy: ReturnType<typeof vi.spyOn>) =>
|
||||
spy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
||||
/** The choices offered by the last inquirer.prompt call. */
|
||||
const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-copy-')));
|
||||
sshDir = path.join(root, '.ssh');
|
||||
tmpDir = path.join(root, 'tmp');
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const proc = Object.assign(Promise.resolve({ exitCode: 0 }), { stdin });
|
||||
execa.mockReturnValue(proc);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('warns when neither directory exists', async () => {
|
||||
await copyKey(sshDir, tmpDir);
|
||||
|
||||
expect(messages(logSpy)).toContain('No SSH keys found.');
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns when the directories hold no private keys', async () => {
|
||||
touch(sshDir, 'known_hosts');
|
||||
touch(tmpDir, 'id_prod.pub');
|
||||
|
||||
await copyKey(sshDir, tmpDir);
|
||||
|
||||
expect(messages(logSpy)).toContain('No SSH keys found.');
|
||||
});
|
||||
|
||||
it('offers the keys from both directories without duplicates', async () => {
|
||||
touch(sshDir, 'id_prod');
|
||||
touch(sshDir, 'id_prod.pub');
|
||||
touch(tmpDir, 'id_prod');
|
||||
touch(tmpDir, 'id_stage');
|
||||
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
|
||||
touch(tmpDir, 'id_prod.pub');
|
||||
|
||||
await copyKey(sshDir, tmpDir);
|
||||
|
||||
expect(choices()).toEqual(['id_prod', 'id_stage']);
|
||||
});
|
||||
|
||||
it('copies the trimmed public key from tmp to the clipboard', async () => {
|
||||
touch(tmpDir, 'id_prod');
|
||||
touch(tmpDir, 'id_prod.pub', 'ssh-ed25519 AAAA tmp\n');
|
||||
touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh\n');
|
||||
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
|
||||
|
||||
await copyKey(sshDir, tmpDir);
|
||||
|
||||
expect(execa).toHaveBeenCalledWith('pbcopy');
|
||||
expect(stdin.write).toHaveBeenCalledWith('ssh-ed25519 AAAA tmp');
|
||||
expect(stdin.end).toHaveBeenCalled();
|
||||
expect(messages(logSpy)).toContain('copied to clipboard');
|
||||
});
|
||||
|
||||
it('falls back to the public key in .ssh', async () => {
|
||||
touch(sshDir, 'id_prod');
|
||||
touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh\n');
|
||||
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
|
||||
|
||||
await copyKey(sshDir, tmpDir);
|
||||
|
||||
expect(stdin.write).toHaveBeenCalledWith('ssh-ed25519 AAAA ssh');
|
||||
});
|
||||
|
||||
it('reports a missing public key without invoking the clipboard', async () => {
|
||||
touch(sshDir, 'id_prod');
|
||||
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
|
||||
|
||||
await copyKey(sshDir, tmpDir);
|
||||
|
||||
expect(messages(errorSpy)).toContain('Public key not found for id_prod');
|
||||
expect(execa).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a clipboard failure instead of throwing', async () => {
|
||||
touch(sshDir, 'id_prod');
|
||||
touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh');
|
||||
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
|
||||
execa.mockImplementation(() => {
|
||||
throw new Error('pbcopy missing');
|
||||
});
|
||||
|
||||
await expect(copyKey(sshDir, tmpDir)).resolves.toBeUndefined();
|
||||
expect(messages(errorSpy)).toContain('Failed to copy to clipboard');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Tests for decryptKeys.
|
||||
*
|
||||
* age, cp and chmod are all mocked; the assertions cover which keys are
|
||||
* offered and exactly where each decrypted key is written.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { execa, prompt } = vi.hoisted(() => ({ execa: vi.fn(), prompt: vi.fn() }));
|
||||
|
||||
vi.mock('execa', () => ({ execa }));
|
||||
vi.mock('inquirer', () => ({ default: { prompt } }));
|
||||
|
||||
import { decryptKeys } from '../src/keyman.decrypt.js';
|
||||
|
||||
const LOCAL = 'Local (vault/tmp)';
|
||||
const SSH = 'SSH (~/.ssh)';
|
||||
|
||||
describe('decryptKeys', () => {
|
||||
let root: string;
|
||||
let sshDir: string;
|
||||
let vaultDir: string;
|
||||
let keyDir: string;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const AGE_KEY = '/vault/age.key';
|
||||
|
||||
/** Creates <vault>/keys/<name>/id_<name>.{age,pub}. */
|
||||
const vaultKey = (name: string) => {
|
||||
const dir = path.join(keyDir, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, `id_${name}.age`), 'ENCRYPTED');
|
||||
fs.writeFileSync(path.join(dir, `id_${name}.pub`), 'PUBLIC');
|
||||
};
|
||||
|
||||
const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[];
|
||||
|
||||
const argsOf = (binary: string) =>
|
||||
execa.mock.calls.find((c) => c[0] === binary)?.[1] as string[] | undefined;
|
||||
|
||||
const messages = (spy: ReturnType<typeof vi.spyOn>) =>
|
||||
spy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-decrypt-')));
|
||||
sshDir = path.join(root, '.ssh');
|
||||
vaultDir = path.join(root, 'vault');
|
||||
keyDir = path.join(vaultDir, 'keys');
|
||||
fs.mkdirSync(keyDir, { recursive: true });
|
||||
fs.mkdirSync(sshDir, { recursive: true });
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
execa.mockResolvedValue({ exitCode: 0 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('warns when the vault holds no encrypted keys', async () => {
|
||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
||||
|
||||
expect(messages(logSpy)).toContain('No encrypted keys found.');
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers only directories that actually contain an encrypted key', async () => {
|
||||
vaultKey('prod');
|
||||
fs.mkdirSync(path.join(keyDir, 'empty'), { recursive: true });
|
||||
fs.writeFileSync(path.join(keyDir, 'README.md'), '');
|
||||
prompt.mockResolvedValue({ selectedKeys: [], decryptMode: LOCAL });
|
||||
|
||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
||||
|
||||
expect(choices()).toEqual(['prod']);
|
||||
});
|
||||
|
||||
it('decrypts into the vault tmp directory', async () => {
|
||||
vaultKey('prod');
|
||||
prompt.mockResolvedValue({ selectedKeys: ['prod'], decryptMode: LOCAL });
|
||||
|
||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
||||
|
||||
const out = path.join(vaultDir, 'tmp', 'id_prod');
|
||||
expect(argsOf('age')).toEqual([
|
||||
'-d',
|
||||
'-i',
|
||||
AGE_KEY,
|
||||
'-o',
|
||||
out,
|
||||
path.join(keyDir, 'prod', 'id_prod.age'),
|
||||
]);
|
||||
expect(argsOf('cp')).toEqual([path.join(keyDir, 'prod', 'id_prod.pub'), `${out}.pub`]);
|
||||
expect(argsOf('chmod')).toEqual(['600', out]);
|
||||
expect(messages(logSpy)).toContain(`Decrypted: ${out}`);
|
||||
});
|
||||
|
||||
it('decrypts into the .ssh directory when asked', async () => {
|
||||
vaultKey('prod');
|
||||
prompt.mockResolvedValue({ selectedKeys: ['prod'], decryptMode: SSH });
|
||||
|
||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
||||
|
||||
const out = path.join(sshDir, 'id_prod');
|
||||
expect(argsOf('age')?.[4]).toBe(out);
|
||||
expect(argsOf('cp')?.[1]).toBe(`${out}.pub`);
|
||||
expect(argsOf('chmod')).toEqual(['600', out]);
|
||||
});
|
||||
|
||||
it('decrypts every selected key', async () => {
|
||||
vaultKey('prod');
|
||||
vaultKey('stage');
|
||||
prompt.mockResolvedValue({ selectedKeys: ['prod', 'stage'], decryptMode: LOCAL });
|
||||
|
||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
||||
|
||||
// age, cp and chmod for each of the two keys.
|
||||
expect(execa).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it('does nothing when the selection is empty', async () => {
|
||||
vaultKey('prod');
|
||||
prompt.mockResolvedValue({ selectedKeys: [], decryptMode: LOCAL });
|
||||
|
||||
await decryptKeys(sshDir, vaultDir, AGE_KEY);
|
||||
|
||||
expect(execa).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Tests for encryptKeys.
|
||||
*
|
||||
* `age` is mocked out; everything the function does to the filesystem itself
|
||||
* (creating the vault layout, copying public keys) is asserted for real.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { execa, prompt } = vi.hoisted(() => ({ execa: vi.fn(), prompt: vi.fn() }));
|
||||
|
||||
vi.mock('execa', () => ({ execa }));
|
||||
vi.mock('inquirer', () => ({ default: { prompt } }));
|
||||
|
||||
import { encryptKeys } from '../src/keyman.encrypt.js';
|
||||
|
||||
describe('encryptKeys', () => {
|
||||
let root: string;
|
||||
let sshDir: string;
|
||||
let vaultDir: string;
|
||||
let tmpDir: string;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const PUBKEY = 'age1recipient';
|
||||
|
||||
const key = (dir: string, name: string, marker: string) => {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, name), `PRIVATE ${marker}`);
|
||||
fs.writeFileSync(path.join(dir, `${name}.pub`), `PUBLIC ${marker}`);
|
||||
};
|
||||
|
||||
const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[];
|
||||
|
||||
const messages = (spy: ReturnType<typeof vi.spyOn>) =>
|
||||
spy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-encrypt-')));
|
||||
sshDir = path.join(root, '.ssh');
|
||||
vaultDir = path.join(root, 'vault');
|
||||
tmpDir = path.join(root, 'vault', 'tmp');
|
||||
fs.mkdirSync(sshDir, { recursive: true });
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// Stand in for `age`: record the call and write the output file.
|
||||
execa.mockImplementation(async (_binary: string, args: string[]) => {
|
||||
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
|
||||
return { exitCode: 0 };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('warns when there is nothing to encrypt', async () => {
|
||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
||||
|
||||
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores public keys and unrelated files when building the list', async () => {
|
||||
fs.writeFileSync(path.join(sshDir, 'known_hosts'), '');
|
||||
fs.writeFileSync(path.join(sshDir, 'id_orphan.pub'), 'PUBLIC');
|
||||
|
||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
||||
|
||||
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
|
||||
});
|
||||
|
||||
it('offers the keys from .ssh and tmp without duplicates', async () => {
|
||||
key(sshDir, 'id_prod', 'ssh');
|
||||
key(tmpDir, 'id_prod', 'tmp');
|
||||
key(tmpDir, 'id_stage', 'tmp');
|
||||
prompt.mockResolvedValue({ selectedKeys: [] });
|
||||
|
||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
||||
|
||||
expect(choices()).toEqual(['id_prod', 'id_stage']);
|
||||
});
|
||||
|
||||
it('encrypts a key from .ssh into the vault', async () => {
|
||||
key(sshDir, 'id_prod', 'ssh');
|
||||
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
|
||||
|
||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
||||
|
||||
const vaultPath = path.join(vaultDir, 'keys', 'prod');
|
||||
expect(execa).toHaveBeenCalledWith('age', [
|
||||
'-r',
|
||||
PUBKEY,
|
||||
'-o',
|
||||
path.join(vaultPath, 'id_prod.age'),
|
||||
path.join(sshDir, 'id_prod'),
|
||||
]);
|
||||
expect(fs.readFileSync(path.join(vaultPath, 'id_prod.pub'), 'utf-8')).toBe('PUBLIC ssh');
|
||||
expect(messages(logSpy)).toContain('Encrypted and stored');
|
||||
});
|
||||
|
||||
it('prefers the tmp copy when a key exists in both directories', async () => {
|
||||
key(sshDir, 'id_prod', 'ssh');
|
||||
key(tmpDir, 'id_prod', 'tmp');
|
||||
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
|
||||
|
||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
||||
|
||||
expect(execa.mock.calls[0][1]).toContain(path.join(tmpDir, 'id_prod'));
|
||||
expect(fs.readFileSync(path.join(vaultDir, 'keys', 'prod', 'id_prod.pub'), 'utf-8')).toBe(
|
||||
'PUBLIC tmp'
|
||||
);
|
||||
});
|
||||
|
||||
it('encrypts every selected key', async () => {
|
||||
key(sshDir, 'id_prod', 'ssh');
|
||||
key(sshDir, 'id_stage', 'ssh');
|
||||
prompt.mockResolvedValue({ selectedKeys: ['id_prod', 'id_stage'] });
|
||||
|
||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
||||
|
||||
expect(execa).toHaveBeenCalledTimes(2);
|
||||
expect(fs.existsSync(path.join(vaultDir, 'keys', 'prod', 'id_prod.age'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(vaultDir, 'keys', 'stage', 'id_stage.age'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing when the selection is empty', async () => {
|
||||
key(sshDir, 'id_prod', 'ssh');
|
||||
prompt.mockResolvedValue({ selectedKeys: [] });
|
||||
|
||||
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY);
|
||||
|
||||
expect(execa).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(path.join(vaultDir, 'keys'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Tests for generateKey.
|
||||
*
|
||||
* ssh-keygen and age are mocked; the ssh-keygen mock writes the files the real
|
||||
* binary would produce so the copy-into-vault step has something to work with.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { execa, prompt } = vi.hoisted(() => ({ execa: vi.fn(), prompt: vi.fn() }));
|
||||
|
||||
vi.mock('execa', () => ({ execa }));
|
||||
vi.mock('inquirer', () => ({ default: { prompt } }));
|
||||
|
||||
import { generateKey } from '../src/keyman.generate.js';
|
||||
|
||||
describe('generateKey', () => {
|
||||
let root: string;
|
||||
let tmpDir: string;
|
||||
let keysDir: string;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const PUBKEY = 'age1recipient';
|
||||
|
||||
/** Answers each prompt by the name of the question it asks. */
|
||||
const answer = (answers: Record<string, string>) => {
|
||||
prompt.mockImplementation(async (questions: { name: string }[]) => {
|
||||
const { name } = questions[0];
|
||||
return { [name]: answers[name] ?? '' };
|
||||
});
|
||||
};
|
||||
|
||||
/** The question object from the prompt call for `name`. */
|
||||
const question = (name: string) =>
|
||||
prompt.mock.calls.map((c) => c[0][0]).find((q) => q.name === name);
|
||||
|
||||
/** The argv of the mocked call to `binary`. */
|
||||
const argsOf = (binary: string) =>
|
||||
execa.mock.calls.find((c) => c[0] === binary)?.[1] as string[] | undefined;
|
||||
|
||||
const messages = (spy: ReturnType<typeof vi.spyOn>) =>
|
||||
spy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-generate-')));
|
||||
tmpDir = path.join(root, 'tmp');
|
||||
keysDir = path.join(root, 'keys');
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Stand in for the real binaries: ssh-keygen writes a key pair, age is a no-op.
|
||||
execa.mockImplementation(async (binary: string, args: string[]) => {
|
||||
if (binary === 'ssh-keygen') {
|
||||
const keyPath = args[args.indexOf('-f') + 1];
|
||||
fs.writeFileSync(keyPath, 'PRIVATE');
|
||||
fs.writeFileSync(`${keyPath}.pub`, 'ssh-ed25519 AAAA generated');
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
});
|
||||
|
||||
answer({ algorithm: 'ed25519', keyName: 'prod', password: 'pw', identity: 'me@host' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('generates the key pair with the answers it collected', async () => {
|
||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||
|
||||
expect(argsOf('ssh-keygen')).toEqual([
|
||||
'-t',
|
||||
'ed25519',
|
||||
'-f',
|
||||
path.join(tmpDir, 'id_prod'),
|
||||
'-N',
|
||||
'pw',
|
||||
'-C',
|
||||
'me@host',
|
||||
]);
|
||||
expect(messages(logSpy)).toContain('Key generated');
|
||||
});
|
||||
|
||||
it('does not prefix a key name that already starts with id_', async () => {
|
||||
answer({ algorithm: 'ed25519', keyName: 'id_prod', password: '', identity: '' });
|
||||
|
||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||
|
||||
expect(argsOf('ssh-keygen')).toContain(path.join(tmpDir, 'id_prod'));
|
||||
});
|
||||
|
||||
it('requests a 4096 bit key for rsa', async () => {
|
||||
answer({ algorithm: 'rsa', keyName: 'prod', password: '', identity: '' });
|
||||
|
||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||
|
||||
expect(argsOf('ssh-keygen')?.slice(-2)).toEqual(['-b', '4096']);
|
||||
});
|
||||
|
||||
it('rejects an empty key name', async () => {
|
||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||
|
||||
const { validate } = question('keyName');
|
||||
expect(validate(' ')).toBe('Key name cannot be empty');
|
||||
expect(validate('prod')).toBe(true);
|
||||
});
|
||||
|
||||
it('encrypts the new key into the vault and copies the public key', async () => {
|
||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||
|
||||
const vaultPath = path.join(keysDir, 'prod');
|
||||
expect(argsOf('age')).toEqual([
|
||||
'-r',
|
||||
PUBKEY,
|
||||
'-o',
|
||||
path.join(vaultPath, 'id_prod.age'),
|
||||
path.join(tmpDir, 'id_prod'),
|
||||
]);
|
||||
expect(fs.readFileSync(path.join(vaultPath, 'id_prod.pub'), 'utf-8')).toBe(
|
||||
'ssh-ed25519 AAAA generated'
|
||||
);
|
||||
expect(messages(logSpy)).toContain('Encrypted and stored');
|
||||
});
|
||||
|
||||
it('refuses to overwrite an existing key file', async () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'id_prod'), 'EXISTING');
|
||||
|
||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||
|
||||
expect(messages(errorSpy)).toContain('Key file id_prod already exists');
|
||||
expect(execa).not.toHaveBeenCalled();
|
||||
expect(fs.readFileSync(path.join(tmpDir, 'id_prod'), 'utf-8')).toBe('EXISTING');
|
||||
});
|
||||
|
||||
it('reports a failure from ssh-keygen without leaving a vault entry', async () => {
|
||||
execa.mockRejectedValue(new Error('ssh-keygen exploded'));
|
||||
|
||||
await expect(generateKey(tmpDir, keysDir, PUBKEY)).resolves.toBeUndefined();
|
||||
expect(messages(errorSpy)).toContain('Error generating/encrypting key');
|
||||
expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a failure from age', async () => {
|
||||
execa.mockImplementation(async (binary: string, args: string[]) => {
|
||||
if (binary === 'age') throw new Error('age exploded');
|
||||
const keyPath = args[args.indexOf('-f') + 1];
|
||||
fs.writeFileSync(keyPath, 'PRIVATE');
|
||||
fs.writeFileSync(`${keyPath}.pub`, 'ssh-ed25519 AAAA generated');
|
||||
return { exitCode: 0 };
|
||||
});
|
||||
|
||||
await generateKey(tmpDir, keysDir, PUBKEY);
|
||||
|
||||
expect(messages(errorSpy)).toContain('Error generating/encrypting key');
|
||||
expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.pub'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Tests for listKeys.
|
||||
*
|
||||
* listKeys is pure filesystem inspection plus console output, so it runs
|
||||
* against real temp directories and the assertions are made on what it prints.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { listKeys } from '../src/keyman.list.js';
|
||||
|
||||
describe('listKeys', () => {
|
||||
let root: string;
|
||||
let sshDir: string;
|
||||
let vaultDir: string;
|
||||
let tmpDir: string;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
/** The single output line describing `name`, without padding noise. */
|
||||
const row = (name: string) =>
|
||||
logSpy.mock.calls
|
||||
.map((c) => c.join(' '))
|
||||
.find((line) => line.includes(`${name} `) || line.includes(`${name}(`))
|
||||
?.replace(/ +/g, ' ');
|
||||
|
||||
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
||||
const touch = (dir: string, file: string) => {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), '');
|
||||
};
|
||||
|
||||
/** Creates a vault entry: <vault>/<name>/id_<name>.age */
|
||||
const vaultKey = (name: string) => touch(path.join(vaultDir, name), `id_${name}.age`);
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-list-')));
|
||||
sshDir = path.join(root, '.ssh');
|
||||
vaultDir = path.join(root, 'keys');
|
||||
tmpDir = path.join(root, 'tmp');
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports the directories it inspected', async () => {
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(output()).toContain(sshDir);
|
||||
expect(output()).toContain(vaultDir);
|
||||
expect(output()).toContain(tmpDir);
|
||||
});
|
||||
|
||||
it('warns when none of the directories exist', async () => {
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(output()).toContain('No SSH keys found.');
|
||||
expect(output()).not.toContain('SSH Keys:');
|
||||
});
|
||||
|
||||
it('warns when the directories exist but hold no id_ files', async () => {
|
||||
fs.mkdirSync(sshDir, { recursive: true });
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
fs.mkdirSync(vaultDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(sshDir, 'known_hosts'), '');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(output()).toContain('No SSH keys found.');
|
||||
});
|
||||
|
||||
it('marks a key present in the vault and in .ssh as managed', async () => {
|
||||
touch(sshDir, 'id_prod');
|
||||
vaultKey('prod');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_prod')).toContain('✅');
|
||||
expect(row('id_prod')).toContain('[✓] [ ] [✓]');
|
||||
});
|
||||
|
||||
it('marks a key decrypted into tmp as decrypted', async () => {
|
||||
touch(tmpDir, 'id_stage');
|
||||
vaultKey('stage');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_stage')).toContain('🔓');
|
||||
expect(row('id_stage')).toContain('[✓] [✓] [ ]');
|
||||
});
|
||||
|
||||
it('marks a key only present in the vault as encrypted', async () => {
|
||||
vaultKey('cold');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_cold')).toContain('🔒');
|
||||
expect(row('id_cold')).toContain('[✓] [ ] [ ]');
|
||||
});
|
||||
|
||||
it('marks a key missing from the vault as unmanaged', async () => {
|
||||
touch(sshDir, 'id_loose');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_loose')).toContain('⚠️');
|
||||
expect(row('id_loose')).toContain('[ ] [ ] [✓]');
|
||||
});
|
||||
|
||||
it('shows a .pub indicator for a public key found in .ssh', async () => {
|
||||
touch(sshDir, 'id_prod');
|
||||
touch(sshDir, 'id_prod.pub');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_prod')).toContain('id_prod (.pub)');
|
||||
});
|
||||
|
||||
it('shows a .pub indicator for a public key found in tmp', async () => {
|
||||
touch(tmpDir, 'id_prod');
|
||||
touch(tmpDir, 'id_prod.pub');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_prod')).toContain('id_prod (.pub)');
|
||||
});
|
||||
|
||||
it('lists a public key with no matching private key', async () => {
|
||||
touch(sshDir, 'id_orphan.pub');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_orphan')).toContain('id_orphan (.pub)');
|
||||
// No private key anywhere, so every column stays blank.
|
||||
expect(row('id_orphan')).toContain('[ ] [ ] [ ]');
|
||||
});
|
||||
|
||||
it('lists a tmp public key with no matching private key', async () => {
|
||||
touch(tmpDir, 'id_orphan.pub');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_orphan')).toContain('id_orphan (.pub)');
|
||||
});
|
||||
|
||||
it('merges the same key seen in .ssh, tmp and the vault', async () => {
|
||||
touch(sshDir, 'id_shared');
|
||||
touch(tmpDir, 'id_shared');
|
||||
touch(tmpDir, 'id_shared.pub');
|
||||
vaultKey('shared');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_shared')).toContain('[✓] [✓] [✓]');
|
||||
// Vault plus .ssh wins over the decrypted-to-tmp status.
|
||||
expect(row('id_shared')).toContain('✅');
|
||||
});
|
||||
|
||||
it('ignores files in the .ssh directory that are not keys', async () => {
|
||||
touch(sshDir, 'config');
|
||||
touch(sshDir, 'known_hosts');
|
||||
touch(sshDir, 'id_real');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(output()).not.toContain('known_hosts');
|
||||
expect(row('id_real')).toBeDefined();
|
||||
});
|
||||
|
||||
it('ignores vault directories with no encrypted key inside', async () => {
|
||||
fs.mkdirSync(path.join(vaultDir, 'empty'), { recursive: true });
|
||||
vaultKey('real');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(row('id_empty')).toBeUndefined();
|
||||
expect(row('id_real')).toBeDefined();
|
||||
});
|
||||
|
||||
it('ignores loose files sitting next to the vault directories', async () => {
|
||||
vaultKey('real');
|
||||
fs.writeFileSync(path.join(vaultDir, 'README.md'), '');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(output()).not.toContain('id_README');
|
||||
});
|
||||
|
||||
it('sorts keys by name', async () => {
|
||||
touch(sshDir, 'id_charlie');
|
||||
touch(sshDir, 'id_alpha');
|
||||
touch(sshDir, 'id_bravo');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
const names = output()
|
||||
.split('\n')
|
||||
.filter((line) => line.includes('id_'))
|
||||
.map((line) => line.match(/id_\w+/)?.[0]);
|
||||
expect(names).toEqual(['id_alpha', 'id_bravo', 'id_charlie']);
|
||||
});
|
||||
|
||||
it('prints the legend once keys are listed', async () => {
|
||||
touch(sshDir, 'id_prod');
|
||||
|
||||
await listKeys(sshDir, vaultDir, tmpDir);
|
||||
|
||||
expect(output()).toContain('Legend:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Tests for the keyman() menu loop.
|
||||
*
|
||||
* Every operation it dispatches to has its own suite, so they are all mocked
|
||||
* here: what is under test is path resolution, dispatch and the loop itself.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
prompt,
|
||||
loadConfig,
|
||||
resolveConfigPaths,
|
||||
listKeys,
|
||||
copyKey,
|
||||
generateKey,
|
||||
encryptKeys,
|
||||
decryptKeys,
|
||||
extractAgePublicKey,
|
||||
} = vi.hoisted(() => ({
|
||||
prompt: vi.fn(),
|
||||
loadConfig: vi.fn(),
|
||||
resolveConfigPaths: vi.fn(),
|
||||
listKeys: vi.fn(),
|
||||
copyKey: vi.fn(),
|
||||
generateKey: vi.fn(),
|
||||
encryptKeys: vi.fn(),
|
||||
decryptKeys: vi.fn(),
|
||||
extractAgePublicKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('inquirer', () => ({ default: { prompt } }));
|
||||
vi.mock('../src/keyman.config.js', () => ({ loadConfig, resolveConfigPaths }));
|
||||
vi.mock('../src/keyman.list.js', () => ({ listKeys }));
|
||||
vi.mock('../src/keyman.copy.js', () => ({ copyKey }));
|
||||
vi.mock('../src/keyman.generate.js', () => ({ generateKey }));
|
||||
vi.mock('../src/keyman.encrypt.js', () => ({ encryptKeys }));
|
||||
vi.mock('../src/keyman.decrypt.js', () => ({ decryptKeys }));
|
||||
vi.mock('../src/keyman.utils.js', () => ({ extractAgePublicKey }));
|
||||
|
||||
import { keyman } from '../src/keyman.main.js';
|
||||
|
||||
describe('keyman', () => {
|
||||
let root: string;
|
||||
let paths: { vaultRoot: string; keysDir: string; tmpDir: string; keyPath: string };
|
||||
let originalHome: string | undefined;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
/** Answers the leading `user` prompt, then walks the given menu choices. */
|
||||
const menu = (categories: string[], user = '@current') => {
|
||||
const queue = [...categories, 'quit'];
|
||||
prompt.mockImplementation(async (questions: { name: string }[]) => {
|
||||
const { name } = questions[0];
|
||||
if (name === 'user') return { user };
|
||||
return { category: queue.shift() };
|
||||
});
|
||||
};
|
||||
|
||||
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
originalHome = process.env.HOME;
|
||||
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-main-')));
|
||||
process.env.HOME = path.join(root, 'home');
|
||||
|
||||
paths = {
|
||||
vaultRoot: path.join(root, 'vault'),
|
||||
keysDir: path.join(root, 'vault', 'keys'),
|
||||
tmpDir: path.join(root, 'vault', 'tmp'),
|
||||
keyPath: path.join(root, 'vault', 'age.key'),
|
||||
};
|
||||
loadConfig.mockReturnValue({
|
||||
vaultRoot: 'vault',
|
||||
keysDir: 'keys',
|
||||
tmpDir: 'tmp',
|
||||
ageKeyFile: 'age.key',
|
||||
});
|
||||
resolveConfigPaths.mockReturnValue(paths);
|
||||
extractAgePublicKey.mockReturnValue('age1recipient');
|
||||
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
menu([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('prints the resolved paths and creates the vault directories', async () => {
|
||||
await keyman();
|
||||
|
||||
expect(output()).toContain(paths.vaultRoot);
|
||||
expect(output()).toContain(paths.keysDir);
|
||||
expect(output()).toContain(paths.keyPath);
|
||||
expect(fs.existsSync(paths.vaultRoot)).toBe(true);
|
||||
expect(fs.existsSync(paths.tmpDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('quits without running any operation', async () => {
|
||||
await keyman();
|
||||
|
||||
expect(output()).toContain('Goodbye!');
|
||||
expect(listKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers every operation in the menu', async () => {
|
||||
await keyman();
|
||||
|
||||
const menuQuestion = prompt.mock.calls.at(-1)?.[0][0] as { choices: { value: string }[] };
|
||||
expect(menuQuestion.choices.map((c) => c.value)).toEqual([
|
||||
'list',
|
||||
'copy',
|
||||
'generate',
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
'quit',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists keys against the .ssh directory of the current user', async () => {
|
||||
menu(['list']);
|
||||
|
||||
await keyman();
|
||||
|
||||
expect(listKeys).toHaveBeenCalledWith(
|
||||
path.join(process.env.HOME as string, '.ssh'),
|
||||
paths.keysDir,
|
||||
paths.tmpDir
|
||||
);
|
||||
});
|
||||
|
||||
it('copies a public key', async () => {
|
||||
menu(['copy']);
|
||||
|
||||
await keyman();
|
||||
|
||||
expect(copyKey).toHaveBeenCalledWith(
|
||||
path.join(process.env.HOME as string, '.ssh'),
|
||||
paths.tmpDir
|
||||
);
|
||||
});
|
||||
|
||||
it('generates a key with the age recipient from the key file', async () => {
|
||||
menu(['generate']);
|
||||
|
||||
await keyman();
|
||||
|
||||
expect(extractAgePublicKey).toHaveBeenCalledWith(paths.keyPath);
|
||||
expect(generateKey).toHaveBeenCalledWith(paths.tmpDir, paths.keysDir, 'age1recipient');
|
||||
});
|
||||
|
||||
it('encrypts keys into the vault root', async () => {
|
||||
menu(['encrypt']);
|
||||
|
||||
await keyman();
|
||||
|
||||
expect(encryptKeys).toHaveBeenCalledWith(
|
||||
path.join(process.env.HOME as string, '.ssh'),
|
||||
paths.vaultRoot,
|
||||
paths.tmpDir,
|
||||
'age1recipient'
|
||||
);
|
||||
});
|
||||
|
||||
it('decrypts keys using the age identity file', async () => {
|
||||
menu(['decrypt']);
|
||||
|
||||
await keyman();
|
||||
|
||||
expect(decryptKeys).toHaveBeenCalledWith(
|
||||
path.join(process.env.HOME as string, '.ssh'),
|
||||
paths.vaultRoot,
|
||||
paths.keyPath
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps showing the menu until the user quits', async () => {
|
||||
menu(['list', 'copy', 'list']);
|
||||
|
||||
await keyman();
|
||||
|
||||
expect(listKeys).toHaveBeenCalledTimes(2);
|
||||
expect(copyKey).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('targets another user home directory when a user is named', async () => {
|
||||
menu(['list'], 'deploy');
|
||||
|
||||
await keyman();
|
||||
|
||||
expect(listKeys).toHaveBeenCalledWith('/home/deploy/.ssh', paths.keysDir, paths.tmpDir);
|
||||
});
|
||||
|
||||
it('aborts when the home directory cannot be determined', async () => {
|
||||
delete process.env.HOME;
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit');
|
||||
});
|
||||
|
||||
await expect(keyman()).rejects.toThrow('process.exit');
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
expect(errorSpy.mock.calls[0][0]).toContain('Unable to determine HOME directory');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Tests for extractAgePublicKey.
|
||||
*
|
||||
* Runs against real files in a temp directory: the function is a thin wrapper
|
||||
* around fs plus a regex, and faking fs would only test the fake.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { extractAgePublicKey } from '../src/keyman.utils.js';
|
||||
|
||||
describe('extractAgePublicKey', () => {
|
||||
let tmpDir: string;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const keyFile = (contents: string) => {
|
||||
const file = path.join(tmpDir, 'age.key');
|
||||
fs.writeFileSync(file, contents);
|
||||
return file;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-utils-')));
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns the public key from a standard age key file', () => {
|
||||
const file = keyFile(
|
||||
[
|
||||
'# created: 2026-01-01T00:00:00Z',
|
||||
'# public key: age1abc123xyz',
|
||||
'AGE-SECRET-KEY-1QQQ',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
expect(extractAgePublicKey(file)).toBe('age1abc123xyz');
|
||||
});
|
||||
|
||||
it('tolerates extra whitespace after the label', () => {
|
||||
const file = keyFile('# public key: age1spaced\n');
|
||||
|
||||
expect(extractAgePublicKey(file)).toBe('age1spaced');
|
||||
});
|
||||
|
||||
it('returns null and reports when the file does not exist', () => {
|
||||
const missing = path.join(tmpDir, 'nope.key');
|
||||
|
||||
expect(extractAgePublicKey(missing)).toBeNull();
|
||||
expect(errorSpy.mock.calls[0][0]).toContain('Age key file not found');
|
||||
});
|
||||
|
||||
it('returns null when the file has no public key line', () => {
|
||||
const file = keyFile('AGE-SECRET-KEY-1QQQ\n');
|
||||
|
||||
expect(extractAgePublicKey(file)).toBeNull();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a key that is not on its own line', () => {
|
||||
const file = keyFile('prefix # public key: age1inline\n');
|
||||
|
||||
expect(extractAgePublicKey(file)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null and reports when the file cannot be read', () => {
|
||||
const asDirectory = path.join(tmpDir, 'age.key');
|
||||
fs.mkdirSync(asDirectory);
|
||||
|
||||
expect(extractAgePublicKey(asDirectory)).toBeNull();
|
||||
expect(errorSpy.mock.calls[0][0]).toContain('Failed to read key file');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo",
|
||||
"tsBuildInfoFile": ".tsbuildinfo",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2020"],
|
||||
"composite": true,
|
||||
"module": "NodeNext",
|
||||
"types": ["jest", "node"]
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["coverage", "node_modules", "dist"],
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
pool: 'forks', // Use forks instead of threads to support process.chdir()
|
||||
include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json-summary', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.test.ts',
|
||||
// Pure re-export barrel: no logic to cover.
|
||||
'src/index.ts',
|
||||
// Argv wiring only; behaviour lives in the modules it calls.
|
||||
'src/keyman.cli.ts',
|
||||
],
|
||||
thresholds: {
|
||||
branches: 85,
|
||||
functions: 85,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user