UNPKG

@decaf-ts/utils

Version:

module management utils for decaf-ts

515 lines 20.8 kB
import { execSync } from "node:child_process"; import { existsSync, readFileSync, unlinkSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { Command } from "./../command.js"; import { UserInput } from "./../../input/input.js"; import { printCommandHelp } from "./help.command.js"; /** * @description Default secret definitions used by the credentials command. * @summary Maps the well-known secret names to their env-var, keychain * service/account, and legacy plaintext-file defaults. Every field can be * overridden at runtime via flags so custom secrets are fully supported. * @const DEFAULT_SECRETS * @memberOf module:utils */ export const DEFAULT_SECRETS = { npm: { env: "NPM_TOKEN", service: "decaf-ts:npm", account: "publish", legacyFile: ".npmtoken", }, github: { env: "GH_TOKEN", service: "decaf-ts:github", account: "git", legacyFile: ".token", }, confluence: { env: "ATLASSIAN_API_TOKEN", service: "decaf-ts:confluence", account: "api", legacyFile: ".confluence-token", }, }; const options = { action: { type: "string", short: "a", default: "get", }, name: { type: "string", short: "n", default: "npm", }, value: { type: "string", short: "v", default: undefined, }, envVar: { type: "string", default: undefined, }, service: { type: "string", default: undefined, }, account: { type: "string", default: undefined, }, legacyFile: { type: "string", default: undefined, }, rm: { type: "boolean", short: "r", default: false, }, }; function which(bin) { try { execSync(`command -v ${bin}`, { stdio: "ignore" }); return true; } catch { return false; } } function pythonKeyringAvailable() { try { execSync('python3 -c "import keyring; import keyring.backends"', { stdio: "ignore", }); return true; } catch { return false; } } function detectBackend() { const platform = os.platform(); if (platform === "darwin") return "keychain"; if (platform === "linux") { if (which("secret-tool")) return "libsecret"; if (which("python3") && pythonKeyringAvailable()) return "keyring"; } if (platform === "win32") return "keychain"; return null; } function storeInBackend(backend, spec, value) { if (backend === "keychain") { if (os.platform() === "darwin") { execSync(`security add-generic-password -a "${spec.account}" -s "${spec.service}" -w "${value}" -U`, { stdio: "ignore" }); return true; } return false; } if (backend === "libsecret") { execSync(`secret-tool store --label='${spec.service}' service '${spec.service}' account '${spec.account}' <<< '${value}'`, { stdio: "ignore" }); return true; } if (backend === "keyring") { execSync(`python3 -c "import keyring; keyring.set_password('${spec.service}', '${spec.account}', '${value}')"`); return true; } return false; } function readFromBackend(backend, spec) { try { if (backend === "keychain" && os.platform() === "darwin") { return execSync(`security find-generic-password -a "${spec.account}" -s "${spec.service}" -w`, { encoding: "utf8" }).trim(); } if (backend === "libsecret") { return execSync(`secret-tool lookup service '${spec.service}' account '${spec.account}'`, { encoding: "utf8" }).trim(); } if (backend === "keyring") { return execSync(`python3 -c "import keyring; print(keyring.get_password('${spec.service}', '${spec.account}'), end='')"`, { encoding: "utf8" }).trim(); } } catch { return null; } return null; } function readLegacyFile(filePath) { const resolved = path.resolve(process.cwd(), filePath); if (!existsSync(resolved)) return null; return readFileSync(resolved, "utf8").trim(); } /** * @description Resolves a secret using the same resolution order as CredentialsCommand. * @summary Tries environment variable → OS keychain → legacy plaintext file, returning * the first hit. Throws if no source provides a value. * @param {string} name - Secret name (npm, github, confluence, or custom). * @param {Partial<SecretSpec>} [overrides] - Optional overrides for env-var, service, account, or legacyFile. * @returns {string} The resolved secret value. * @function resolveSecret * @memberOf module:utils */ export function resolveSecret(name, overrides) { const base = DEFAULT_SECRETS[name] ?? { env: name.toUpperCase().replace(/[^A-Z0-9]/g, "_") + "_TOKEN", service: `decaf-ts:${name}`, account: "default", legacyFile: `.${name}-token`, }; const spec = { env: overrides?.env ?? base.env, service: overrides?.service ?? base.service, account: overrides?.account ?? base.account, legacyFile: overrides?.legacyFile ?? base.legacyFile, }; if (process.env[spec.env]) return process.env[spec.env]; const backend = detectBackend(); if (backend) { const val = readFromBackend(backend, spec); if (val) return val; } const legacy = readLegacyFile(spec.legacyFile); if (legacy) return legacy; throw new Error(`No token found for "${name}". Set ${spec.env}, run "credentials --action setup", or (legacy) create ${spec.legacyFile}.`); } /** * @description Checks whether a secret can be resolved without throwing. * @summary Returns true if any source (env var, keychain, legacy file) provides a value. * @param {string} name - Secret name. * @param {Partial<SecretSpec>} [overrides] - Optional overrides. * @returns {boolean} Whether the secret is available. * @function hasSecret * @memberOf module:utils */ export function hasSecret(name, overrides) { try { resolveSecret(name, overrides); return true; } catch { return false; } } /** * @description Command-line tool for managing secrets via the OS keychain. * @summary Provides a secure alternative to plaintext token files by resolving * secrets from environment variables, the OS keychain (macOS Keychain, Linux * libsecret/`keyring`), or — as a deprecated fallback — legacy plaintext files. * Supports `get`, `store`, `setup`, and `git-helper` actions. * * @class CredentialsCommand * @extends {Command} * * @example * ```sh * # Resolve the npm token (prints to stdout) * credentials --action get --name npm * * # Store a custom secret in the keychain * credentials --action store --name my-api --service "my-app:api" --account "default" --value "secret123" * * # Interactive one-time enrollment * credentials --action setup * * # Configure the OS-native git credential helper * credentials --action git-helper * ``` */ export class CredentialsCommand extends Command { constructor() { super("CredentialsCommand", options); } resolveSpec(name, overrides) { const base = DEFAULT_SECRETS[name] ?? { env: name.toUpperCase().replace(/[^A-Z0-9]/g, "_") + "_TOKEN", service: `decaf-ts:${name}`, account: "default", legacyFile: `.${name}-token`, }; return { env: overrides.env ?? base.env, service: overrides.service ?? base.service, account: overrides.account ?? base.account, legacyFile: overrides.legacyFile ?? base.legacyFile, }; } help() { printCommandHelp(this.log, "credentials", "Manage secrets via the OS keychain with env-var and legacy-file fallbacks.\n" + "Used by release scripts (tag-release, npm publish, git push) and consumable\n" + "programmatically via resolveSecret() / hasSecret().", "credentials [options]", [ { flag: "--action <get|store|setup|git-helper>", description: "get - Resolve a secret and print it to stdout (no trailing newline).\n" + " Resolution order: env var -> OS keychain -> legacy plaintext file.\n" + " In CI, set NPM_TOKEN / GH_TOKEN / ATLASSIAN_API_TOKEN as env vars\n" + " and they will be picked up immediately (keychain is never consulted).\n" + "store - Save a secret value in the OS keychain. Requires --value.\n" + "setup - Interactive one-time enrollment: prompts for each default secret,\n" + " stores them in the keychain, and configures the git credential helper.\n" + " Use --rm to auto-delete legacy plaintext token files afterwards.\n" + "git-helper- Configure the OS-native git credential helper only\n" + " (osxkeychain on macOS, libsecret/store on Linux, manager on Windows).", defaultValue: "get", }, { flag: "--name <name>", description: "Secret name. Built-in names (npm, github, confluence) use sensible defaults.\n" + " Any other name is treated as custom: env var defaults to <NAME>_TOKEN,\n" + " service to decaf-ts:<name>, account to 'default', legacyFile to .<name>-token.", defaultValue: "npm", }, { flag: "--value <value>", description: "Secret value to store. Required for --action store. Ignored for other actions.", }, { flag: "--envVar <name>", description: "Override the environment variable name checked first during resolution.\n" + " Example: --name github --envVar GH_PAT to read GH_PAT instead of GH_TOKEN.", }, { flag: "--service <name>", description: "Override the keychain service label (default: decaf-ts:<name>).\n" + " Used as the primary key when storing/looking up secrets in the OS keychain.", }, { flag: "--account <name>", description: "Override the keychain account label (default: 'publish' for npm, 'git' for github,\n" + " 'api' for confluence, 'default' for custom names).", }, { flag: "--legacyFile <path>", description: "Override the legacy plaintext file path checked last during resolution.\n" + " Example: --legacyFile ~/.npmrc to read from a different file.\n" + " Emits a deprecation warning when used; will be removed in a future release.", }, { flag: "--rm", description: "Used with --action setup. Auto-deletes legacy plaintext token files\n" + " (.npmtoken, .token, .confluence-token) after successful keychain enrollment.\n" + " Only deletes files that exist; logs each file removed.", defaultValue: "false", }, { flag: "-h, --help", description: "Show this help text and exit", }, ], [ "Resolution order for --action get: environment variable -> OS keychain -> legacy plaintext file (with deprecation warning).", "Supported keychain backends: macOS Keychain (security), Linux libsecret (secret-tool), Linux Python keyring, Windows Credential Manager.", "Built-in secrets: npm (NPM_TOKEN, .npmtoken), github (GH_TOKEN, .token), confluence (ATLASSIAN_API_TOKEN, .confluence-token).", "CI usage: set the env var as a GitHub/GitLab secret. resolveSecret() picks it up without any keychain access.", "Local usage: run 'credentials --action setup' once to enroll secrets in the keychain, then delete plaintext token files.", "Programmatic API: import { resolveSecret, hasSecret } from '@decaf-ts/utils' — same resolution order, throws if not found.", ], [ "# Resolve the npm publish token (prints to stdout, no trailing newline)", "credentials --action get --name npm", "", "# Resolve the github token using a non-default env var name", "credentials --action get --name github --envVar GH_PAT", "", "# Store the github token in the OS keychain", "credentials --action store --name github --value ghp_xxxxxxxxxxxx", "", "# Store a custom secret with fully overridden metadata", "credentials --action store --name my-api --envVar MY_API_TOKEN --service 'my-app:api' --account 'ci' --value 'sk_live_xxx'", "", "# Interactive one-time setup (prompts for npm, github, confluence; configures git helper)", "credentials --action setup", "", "# Setup and auto-delete legacy plaintext token files (.npmtoken, .token, .confluence-token)", "credentials --action setup --rm", "", "# Configure only the git credential helper (no secret enrollment)", "credentials --action git-helper", "", "# Resolve a custom secret that has no built-in defaults", "credentials --action get --name stripe --envVar STRIPE_SECRET_KEY --legacyFile .stripe-token", "", "# Use in a shell pipeline (e.g., set npm auth for publish)", "npm config set //registry.npmjs.org/:_authToken \"$(credentials --action get --name npm)\"", "", "# Programmatic usage in TypeScript", "import { resolveSecret } from '@decaf-ts/utils';", "const token = resolveSecret('npm'); // env var -> keychain -> legacy file", ]); } doGet(spec) { const log = this.log.for(this.doGet); if (process.env[spec.env]) { log.verbose(`Resolved from env var ${spec.env}`); process.stdout.write(process.env[spec.env]); return; } const backend = detectBackend(); if (backend) { const val = readFromBackend(backend, spec); if (val) { log.verbose(`Resolved from ${backend}`); process.stdout.write(val); return; } } const legacy = readLegacyFile(spec.legacyFile); if (legacy) { log.warn(`Using plaintext ${spec.legacyFile}. Run "credentials --action setup" to migrate to the OS keychain. This fallback will be removed in a future release.`); process.stdout.write(legacy); return; } throw new Error(`No token found for "${spec.service}". Set ${spec.env}, run "credentials --action setup", or (legacy) create ${spec.legacyFile}.`); } doStore(spec, value) { const log = this.log.for(this.doStore); const backend = detectBackend(); if (!backend) { throw new Error("No supported keychain backend detected. Install libsecret (apt: libsecret-tools) on Linux."); } storeInBackend(backend, spec, value); log.info(`Stored secret in ${backend}.`); } async doSetup(rm) { const log = this.log.for(this.doSetup); const backend = detectBackend(); if (!backend) { throw new Error("No supported keychain backend detected. Install libsecret (apt: libsecret-tools) on Linux, or use env vars in CI."); } const names = Object.keys(DEFAULT_SECRETS); for (const name of names) { const spec = this.resolveSpec(name, {}); const existing = readFromBackend(backend, spec); const skip = existing ? await UserInput.askConfirmation(`update-${name}`, `Update ${name} token?`, false) : true; if (!skip) continue; const value = await UserInput.askText(name, `Paste ${name} token (${spec.env}):`, "*"); const trimmed = `${value}`.trim(); if (!trimmed) { log.warn(`Empty value for ${name}, skipping.`); continue; } storeInBackend(backend, spec, trimmed); log.info(`Stored ${name} token in ${backend}.`); } this.configureGitHelper(); if (rm) { const deleted = this.deleteLegacyFiles(); if (deleted.length > 0) { log.info(`Deleted legacy token files: ${deleted.join(", ")}`); } else { log.info("No legacy token files found to delete."); } log.info("Setup complete with --rm. All secrets are now in the OS keychain."); } else { log.info('Setup complete. Plaintext .token/.npmtoken/.confluence-token can now be deleted (run with --rm to auto-delete).'); } } deleteLegacyFiles() { const log = this.log.for(this.deleteLegacyFiles); const deleted = []; for (const name of Object.keys(DEFAULT_SECRETS)) { const spec = this.resolveSpec(name, {}); const resolved = path.resolve(process.cwd(), spec.legacyFile); if (existsSync(resolved)) { try { unlinkSync(resolved); deleted.push(spec.legacyFile); } catch (err) { log.warn(`Could not delete ${spec.legacyFile}: ${err.message}`); } } } return deleted; } configureGitHelper() { const log = this.log.for(this.configureGitHelper); const platform = os.platform(); let helper; if (platform === "darwin") { helper = "osxkeychain"; } else if (platform === "linux") { const candidates = [ "/usr/share/doc/git/contrib/credential/libsecret/git-credential-libsecret", "libsecret", "store", ]; helper = "store"; for (const c of candidates) { try { if (which(`git-credential-${c}`)) { helper = c; break; } } catch { // continue } } } else if (platform === "win32") { helper = "manager"; } else { helper = "store"; } execSync(`git config --global credential.helper '${helper}'`, { stdio: "inherit", }); log.info(`Git credential helper set to "${helper}".`); if (helper === "store") { log.warn('Falling back to plaintext "store" helper. Install libsecret for keychain storage.'); } } async run(answers) { const action = `${answers.action || "get"}`.trim(); const name = `${answers.name || "npm"}`.trim(); const str = (v) => { if (typeof v !== "string") return undefined; const s = v.trim(); return s.length > 0 ? s : undefined; }; const spec = this.resolveSpec(name, { env: str(answers.envVar), service: str(answers.service), account: str(answers.account), legacyFile: str(answers.legacyFile), }); switch (action) { case "get": this.doGet(spec); return; case "store": { const value = str(answers.value); if (!value) { throw new Error("--value is required for --action store"); } this.doStore(spec, value); return; } case "setup": await this.doSetup(Boolean(answers.rm)); return; case "git-helper": this.configureGitHelper(); return; default: throw new Error(`Unknown action "${action}". Valid: get, store, setup, git-helper`); } } } //# sourceMappingURL=credentials.command.js.map