@decaf-ts/utils
Version:
module management utils for decaf-ts
525 lines • 21.8 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CredentialsCommand = exports.DEFAULT_SECRETS = void 0;
exports.resolveSecret = resolveSecret;
exports.hasSecret = hasSecret;
const node_child_process_1 = require("node:child_process");
const node_fs_1 = require("node:fs");
const node_os_1 = __importDefault(require("node:os"));
const node_path_1 = __importDefault(require("node:path"));
const command_js_1 = require("./../command.cjs");
const input_js_1 = require("./../../input/input.cjs");
const help_command_js_1 = require("./help.command.cjs");
/**
* @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
*/
exports.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 {
(0, node_child_process_1.execSync)(`command -v ${bin}`, { stdio: "ignore" });
return true;
}
catch {
return false;
}
}
function pythonKeyringAvailable() {
try {
(0, node_child_process_1.execSync)('python3 -c "import keyring; import keyring.backends"', {
stdio: "ignore",
});
return true;
}
catch {
return false;
}
}
function detectBackend() {
const platform = node_os_1.default.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 (node_os_1.default.platform() === "darwin") {
(0, node_child_process_1.execSync)(`security add-generic-password -a "${spec.account}" -s "${spec.service}" -w "${value}" -U`, { stdio: "ignore" });
return true;
}
return false;
}
if (backend === "libsecret") {
(0, node_child_process_1.execSync)(`secret-tool store --label='${spec.service}' service '${spec.service}' account '${spec.account}' <<< '${value}'`, { stdio: "ignore" });
return true;
}
if (backend === "keyring") {
(0, node_child_process_1.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" && node_os_1.default.platform() === "darwin") {
return (0, node_child_process_1.execSync)(`security find-generic-password -a "${spec.account}" -s "${spec.service}" -w`, { encoding: "utf8" }).trim();
}
if (backend === "libsecret") {
return (0, node_child_process_1.execSync)(`secret-tool lookup service '${spec.service}' account '${spec.account}'`, { encoding: "utf8" }).trim();
}
if (backend === "keyring") {
return (0, node_child_process_1.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 = node_path_1.default.resolve(process.cwd(), filePath);
if (!(0, node_fs_1.existsSync)(resolved))
return null;
return (0, node_fs_1.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
*/
function resolveSecret(name, overrides) {
const base = exports.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
*/
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
* ```
*/
class CredentialsCommand extends command_js_1.Command {
constructor() {
super("CredentialsCommand", options);
}
resolveSpec(name, overrides) {
const base = exports.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() {
(0, help_command_js_1.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(exports.DEFAULT_SECRETS);
for (const name of names) {
const spec = this.resolveSpec(name, {});
const existing = readFromBackend(backend, spec);
const skip = existing
? await input_js_1.UserInput.askConfirmation(`update-${name}`, `Update ${name} token?`, false)
: true;
if (!skip)
continue;
const value = await input_js_1.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(exports.DEFAULT_SECRETS)) {
const spec = this.resolveSpec(name, {});
const resolved = node_path_1.default.resolve(process.cwd(), spec.legacyFile);
if ((0, node_fs_1.existsSync)(resolved)) {
try {
(0, node_fs_1.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 = node_os_1.default.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";
}
(0, node_child_process_1.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`);
}
}
}
exports.CredentialsCommand = CredentialsCommand;
//# sourceMappingURL=credentials.command.js.map
//# sourceMappingURL=credentials.command.cjs.map