#!/usr/bin/env node import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { platform } from 'node:os'; import { chmod } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { createLoggedFs } from "./createLoggedFs.js"; const execFileAsync = promisify(execFile); function getKeychainService(cliId) { return `com.${cliId}.cli`; } export function getConfigFilePath(cliId) { return join(homedir(), `.${cliId}.json`); } /** * Build a server key like "https://reggi@domain.com" from serverUrl and username */ export function serverKey(serverUrl, username) { const url = new URL(serverUrl); url.username = username; return url.href.replace(/\/$/, ''); } /** * Parse a server key like "https://reggi@domain.com" into serverUrl and username */ export function parseServerKey(key) { const url = new URL(key); const username = url.username; url.username = ''; return { serverUrl: url.origin, username }; } /** * Validate the shape of the config file JSON. * Returns a valid ServerConfig or throws with a descriptive message. */ export function validateConfigJson(data) { if (data === null || typeof data !== 'object' || Array.isArray(data)) { throw new Error('Config file must contain a JSON object'); } const obj = data; if (obj.active !== undefined && obj.active !== null && typeof obj.active !== 'string') { throw new Error('Config "active" field must be a string or null'); } if (obj.servers !== undefined && (typeof obj.servers !== 'object' || obj.servers === null || Array.isArray(obj.servers))) { throw new Error('Config "servers" field must be an object'); } const servers = (obj.servers || {}); for (const key of Object.keys(servers)) { if (typeof servers[key] !== 'object' || servers[key] === null || Array.isArray(servers[key])) { throw new Error(`Config "servers.${key}" must be an object`); } try { const url = new URL(key); if (!url.username) { throw new Error(`Config server key "${key}" must include a username (e.g. https://user@host.com)`); } } catch (err) { if (err instanceof TypeError) { throw new Error(`Config server key "${key}" is not a valid URL`); } throw err; } } if (obj.active != null) { const activeKey = obj.active; if (!servers[activeKey]) { throw new Error(`Config "active" value "${activeKey}" does not match any server key`); } } return { active: obj.active || null, servers: servers }; } /** * Detect which credential storage backend to use based on OS */ export function getOsCredentialBackend() { const os = platform(); if (os === 'darwin') { return 'keychain'; } else if (os === 'linux') { // TODO: Could check for secret-tool availability return 'keyring'; } else if (os === 'win32') { return 'wincred'; } return 'file'; } /** * macOS Keychain operations */ class KeychainCredentialStore { cliName; constructor(cliName, _config) { this.cliName = cliName; } async store(serverUrl, username, password) { // Delete existing credential if it exists (to update it) try { await execFileAsync('security', ['delete-internet-password', '-s', serverUrl, '-a', username]); } catch { // Ignore errors if credential doesn't exist } // Add new credential await execFileAsync('security', [ 'add-internet-password', '-s', serverUrl, '-a', username, '-w', password, '-l', `${this.cliName} (${serverUrl})`, '-U', ]); } async retrieve(serverUrl, username) { try { const { stdout } = await execFileAsync('security', [ 'find-internet-password', '-s', serverUrl, '-a', username, '-w', ]); return stdout.trim(); } catch { return null; } } async delete(serverUrl, username) { try { await execFileAsync('security', ['delete-internet-password', '-s', serverUrl, '-a', username]); } catch { // Ignore errors if credential doesn't exist } } } /** * Linux Secret Service / keyring operations */ class KeyringCredentialStore { keychainService; cliId; cliName; constructor(cliId, cliName, _config) { this.cliId = cliId; this.cliName = cliName; this.keychainService = getKeychainService(cliId); } async store(serverUrl, username, password) { try { // Use secret-tool with --label flag and attribute key-value pairs const child = execFile('secret-tool', [ 'store', '--label', `${this.cliName} (${serverUrl})`, 'service', this.keychainService, 'server', serverUrl, 'username', username, ]); // secret-tool reads the secret from stdin child.stdin?.write(password); child.stdin?.end(); await new Promise((resolve, reject) => { child.on('close', code => { if (code === 0) resolve(); else reject(new Error(`secret-tool exited with code ${code}`)); }); child.on('error', reject); }); } catch { throw new Error('Linux keyring not available. Please install libsecret-tools or use file-based storage.'); } } async retrieve(serverUrl, username) { try { const { stdout } = await execFileAsync('secret-tool', [ 'lookup', 'service', this.keychainService, 'server', serverUrl, 'username', username, ]); return stdout.trim(); } catch { return null; } } async delete(serverUrl, username) { try { await execFileAsync('secret-tool', [ 'clear', 'service', this.keychainService, 'server', serverUrl, 'username', username, ]); } catch { // Ignore errors } } } /** * Windows Credential Manager operations */ class WinCredentialStore { keychainService; constructor(cliId, _config) { this.keychainService = getKeychainService(cliId); } async store(serverUrl, username, password) { // Windows cmdkey command const target = `${this.keychainService}:${serverUrl}:${username}`; try { await execFileAsync('cmdkey', ['/delete', target]); } catch { // Ignore if doesn't exist } await execFileAsync('cmdkey', ['/generic', target, '/user', username, '/pass', password]); } async retrieve(serverUrl, username) { try { const target = `${this.keychainService}:${serverUrl}:${username}`; await execFileAsync('cmdkey', ['/list', target]); // This doesn't actually retrieve the password, just lists it // Windows doesn't expose credential retrieval easily from command line // Would need to use Node.js native bindings or fall back to file throw new Error('Windows credential retrieval not implemented - falling back to file storage'); } catch { return null; } } async delete(serverUrl, username) { try { const target = `${this.keychainService}:${serverUrl}:${username}`; await execFileAsync('cmdkey', ['/delete', target]); } catch { // Ignore errors } } } /** * File-based credential storage (fallback with encryption warning) * Stores passwords in the config file under servers..password */ class FileCredentialStore { cliId; config; fs; cmdLog; constructor(cliId, cmdLog, config) { this.cliId = cliId; this.config = config; this.fs = createLoggedFs(cmdLog); this.cmdLog = cmdLog; } async store(serverUrl, username, password) { const configPath = getConfigFilePath(this.cliId); const key = serverKey(serverUrl, username); await this.fs.updateJsonPropertyRedacted(configPath, ['servers', key, 'password'], password); await chmod(configPath, 0o600); this.cmdLog.warn('Credentials stored in plain text file. Consider using a secure credential store.'); } async retrieve(serverUrl, username) { const key = serverKey(serverUrl, username); const password = this.config.servers[key]?.password; return typeof password === 'string' ? password : null; } async delete(serverUrl, username) { const configPath = getConfigFilePath(this.cliId); const key = serverKey(serverUrl, username); try { await this.fs.removeJsonProperty(configPath, ['servers', key, 'password']); } catch { // Ignore errors if config file doesn't exist } } } /** * Create a credential store for the given backend */ export function createCredentialStore(backend, ctx, cmdLog, config) { switch (backend) { case 'keychain': return new KeychainCredentialStore(ctx.cliName, config); case 'keyring': return new KeyringCredentialStore(ctx.cliId, ctx.cliName, config); case 'wincred': return new WinCredentialStore(ctx.cliId, config); case 'file': return new FileCredentialStore(ctx.cliId, cmdLog, config); } } /** * Get credentials backend name for display */ export function getCredentialBackendName() { const backend = getOsCredentialBackend(); const names = { keychain: 'macOS Keychain', keyring: 'Linux Secret Service', wincred: 'Windows Credential Manager', file: 'File (plaintext - not recommended)', }; return names[backend]; } //# sourceMappingURL=credentials.js.map