Source

cli/commands/credentials.ts

import { execSync } from "node:child_process";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { LoggingConfig } from "@decaf-ts/logging";
import { Command } from "../command";
import { DefaultCommandValues } from "../constants";
import { UserInput } from "../../input/input";
import { printCommandHelp } from "./help";

/**
 * @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: Record<
  string,
  { env: string; service: string; account: string; legacyFile: string }
> = {
  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",
  },
};

type SecretSpec = {
  env: string;
  service: string;
  account: string;
  legacyFile: string;
};

type KeychainBackend = "keychain" | "libsecret" | "keyring" | null;

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: string): boolean {
  try {
    execSync(`command -v ${bin}`, { stdio: "ignore" });
    return true;
  } catch {
    return false;
  }
}

function pythonKeyringAvailable(): boolean {
  try {
    execSync('python3 -c "import keyring; import keyring.backends"', {
      stdio: "ignore",
    });
    return true;
  } catch {
    return false;
  }
}

function detectBackend(): KeychainBackend {
  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: KeychainBackend,
  spec: SecretSpec,
  value: string
): boolean {
  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: KeychainBackend,
  spec: SecretSpec
): string | null {
  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: string): string | null {
  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: string,
  overrides?: Partial<SecretSpec>
): string {
  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: SecretSpec = {
    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] as string;
  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: string,
  overrides?: Partial<SecretSpec>
): boolean {
  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<typeof options, void> {
  constructor() {
    super("CredentialsCommand", options);
  }

  private resolveSpec(name: string, overrides: Partial<SecretSpec>): SecretSpec {
    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,
    };
  }

  protected override help(): void {
    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",
      ]
    );
  }

  private doGet(spec: SecretSpec): void {
    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] as string);
      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}.`
    );
  }

  private doStore(spec: SecretSpec, value: string): void {
    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}.`);
  }

  private async doSetup(rm: boolean): Promise<void> {
    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).'
      );
    }
  }

  private deleteLegacyFiles(): string[] {
    const log = this.log.for(this.deleteLegacyFiles);
    const deleted: string[] = [];
    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 as Error).message}`);
        }
      }
    }
    return deleted;
  }

  private configureGitHelper(): void {
    const log = this.log.for(this.configureGitHelper);
    const platform = os.platform();
    let helper: string;
    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.'
      );
    }
  }

  protected async run(
    answers: LoggingConfig &
      typeof DefaultCommandValues & { [k in keyof typeof options]: unknown }
  ): Promise<void> {
    const action = `${answers.action || "get"}`.trim();
    const name = `${answers.name || "npm"}`.trim();
    const str = (v: unknown): string | undefined => {
      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`
        );
    }
  }
}