Repository Public MIT
1import { execFileSync } from "node:child_process";2import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";3import { dirname, join } from "node:path";4import type { IO } from "./io";56/**7 * Where the CLI keeps the API token. Lookup order: SKILLS_TOKEN (process-scoped injection) →8 * OS credential store → opt-in 0600 file. The token is never written to argv, logs or the repo.9 */10export const SERVICE = "skills-registry";1112export interface CredentialStore {13 kind: "keychain" | "secret-tool" | "file";14 describe: string;15 get(account: string): string | null;16 set(account: string, token: string): void;17 delete(account: string): boolean;18}1920export class FileStore implements CredentialStore {21 kind = "file" as const;22 describe: string;23 constructor(private path: string) {24 this.describe = `file ${path} (mode 600)`;25 }26 private read(): Record<string, string> {27 if (!existsSync(this.path)) return {};28 try {29 const v = JSON.parse(readFileSync(this.path, "utf8"));30 return v && typeof v === "object" ? (v as Record<string, string>) : {};31 } catch {32 return {};33 }34 }35 private write(v: Record<string, string>) {36 mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });37 writeFileSync(this.path, JSON.stringify(v, null, 2) + "\n", { mode: 0o600 });38 chmodSync(this.path, 0o600);39 }40 get(account: string) {41 return this.read()[account] ?? null;42 }43 set(account: string, token: string) {44 this.write({ ...this.read(), [account]: token });45 }46 delete(account: string) {47 const v = this.read();48 if (!(account in v)) return false;49 delete v[account];50 this.write(v);51 return true;52 }53}5455const SECURITY = "/usr/bin/security";5657export class KeychainStore implements CredentialStore {58 kind = "keychain" as const;59 describe = `macOS Keychain (service "${SERVICE}")`;60 get(account: string) {61 try {62 const out = execFileSync(SECURITY, ["find-generic-password", "-s", SERVICE, "-a", account, "-w"], { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });63 return out.trim() || null;64 } catch {65 return null;66 }67 }68 set(account: string, token: string) {69 if (!/^[A-Za-z0-9_-]+$/.test(token) || !/^[A-Za-z0-9.:_-]+$/.test(account)) throw new Error("refusing to store a credential with unexpected characters");70 // `security -i` reads the command from stdin, so the secret never appears in argv / `ps`.71 execFileSync(SECURITY, ["-i"], { input: `add-generic-password -U -s ${SERVICE} -a ${account} -l "Skills registry (${account})" -w ${token}\n`, stdio: ["pipe", "ignore", "pipe"] });72 if (this.get(account) !== token) throw new Error("the Keychain did not accept the credential");73 }74 delete(account: string) {75 try {76 execFileSync(SECURITY, ["delete-generic-password", "-s", SERVICE, "-a", account], { stdio: "ignore" });77 return true;78 } catch {79 return false;80 }81 }82}8384export class SecretToolStore implements CredentialStore {85 kind = "secret-tool" as const;86 describe = `libsecret keyring (service "${SERVICE}")`;87 constructor(private bin: string) {}88 get(account: string) {89 try {90 const out = execFileSync(this.bin, ["lookup", "service", SERVICE, "account", account], { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });91 return out.trim() || null;92 } catch {93 return null;94 }95 }96 set(account: string, token: string) {97 execFileSync(this.bin, ["store", `--label=Skills registry (${account})`, "service", SERVICE, "account", account], { input: token, stdio: ["pipe", "ignore", "pipe"] });98 }99 delete(account: string) {100 try {101 execFileSync(this.bin, ["clear", "service", SERVICE, "account", account], { stdio: "ignore" });102 return true;103 } catch {104 return false;105 }106 }107}108109function which(bin: string, io: IO): string | null {110 for (const dir of (io.env.PATH ?? "").split(":")) {111 if (dir && existsSync(join(dir, bin))) return join(dir, bin);112 }113 return null;114}115116export function optInFilePath(io: IO): string {117 return join(io.home, ".config", "skills", "credentials.json");118}119120/** The OS credential manager, or null when none is available (then only the opt-in file remains). */121export function osStore(io: IO): CredentialStore | null {122 const forced = io.env.SKILLS_CREDENTIAL_STORE;123 if (forced) {124 if (!forced.startsWith("file:") || forced.length < 6) throw new Error("SKILLS_CREDENTIAL_STORE must look like file:/absolute/path.json");125 return new FileStore(forced.slice(5));126 }127 if (io.platform === "darwin" && existsSync(SECURITY)) return new KeychainStore();128 if (io.platform === "linux") {129 const bin = which("secret-tool", io);130 if (bin) return new SecretToolStore(bin);131 }132 return null;133}134135export interface Resolved {136 token: string;137 source: "env" | CredentialStore["kind"];138 describe: string;139 store: CredentialStore | null;140}141142export function resolveCredential(io: IO, account: string): Resolved | null {143 const envTok = io.env.SKILLS_TOKEN?.trim();144 if (envTok) return { token: envTok, source: "env", describe: "SKILLS_TOKEN environment variable (process-scoped)", store: null };145 const primary = osStore(io);146 const t = primary?.get(account);147 if (primary && t) return { token: t, source: primary.kind, describe: primary.describe, store: primary };148 const file = new FileStore(optInFilePath(io));149 const ft = file.get(account);150 if (ft) return { token: ft, source: "file", describe: file.describe, store: file };151 return null;152}153154/** Store to write to: the OS store, or the plaintext file only with explicit opt-in. */155export function writableStore(io: IO, insecureFile: boolean): CredentialStore {156 if (insecureFile) return new FileStore(optInFilePath(io));157 const s = osStore(io);158 if (s) return s;159 throw new Error("No OS credential manager found (macOS Keychain or secret-tool). Re-run with --insecure-file to store the token in ~/.config/skills/credentials.json (mode 600), or inject it per process with SKILLS_TOKEN.");160}161