Add release pipeline and upgrade toolchain to TypeScript 7
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:
Benjamin Diedrichsen
2026-07-27 15:17:14 +02:00
parent 736c01216a
commit 587ff2cf47
126 changed files with 6065 additions and 7544 deletions
+21
View File
@@ -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.
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env node
export * from './keyman.main.js';
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env node
export * from './keyman.main.js';
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env node
export {};
-11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1 +0,0 @@
export declare function copyKey(sshDir: string, tmpDir: string): Promise<void>;
-50
View File
@@ -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
View File
@@ -1 +0,0 @@
export declare function decryptKeys(sshDir: string, vaultDir: string, ageKey: string): Promise<void>;
-45
View File
@@ -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
View File
@@ -1 +0,0 @@
export declare function encryptKeys(sshDir: string, vaultDir: string, tmpDir: string, pubkey: string): Promise<void>;
-37
View File
@@ -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
View File
@@ -1 +0,0 @@
export declare function generateKey(tmpDir: string, keysDir: string, pubkey: string): Promise<void>;
-65
View File
@@ -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
View File
@@ -1 +0,0 @@
export declare function listKeys(sshDir: string, vaultDir: string, tmpDir: string): Promise<void>;
-115
View File
@@ -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
View File
@@ -1 +0,0 @@
export declare function keyman(): Promise<void>;
-79
View File
@@ -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
View File
@@ -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;
-21
View File
@@ -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;
}
}
+57 -19
View File
@@ -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 -1
View File
@@ -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';
+1 -1
View File
@@ -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
View File
@@ -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';
+294
View File
@@ -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');
});
});
});
+133
View File
@@ -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');
});
});
+134
View File
@@ -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();
});
});
+141
View File
@@ -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);
});
});
+164
View File
@@ -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);
});
});
+215
View File
@@ -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:');
});
});
+216
View File
@@ -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');
});
});
+79
View File
@@ -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');
});
});
+2 -2
View 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"],
+28
View File
@@ -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,
},
},
},
});