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
+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');
});
});