import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { configPath, DEFAULT_URL, type Env, nonEmpty, readConfig, type StoredConfig } from "@taleseal/sdk"; /** * The CLI owns the WRITE side of the stored config; the READ side — configPath, readConfig, * DEFAULT_URL and the empty-is-unset rule (`nonEmpty`) — lives in @taleseal/sdk so every * surface resolves credentials identically. The order the whole tool obeys: * * API key TALESEAL_API_KEY env var → config "apiKey" * Base URL TALESEAL_URL env var → config "url" → DEFAULT_URL * * The config lives at $XDG_CONFIG_HOME/taleseal/config.json (falling back to ~/.config), * written by `taleseal login` with mode 0600. */ /** The read side is the SDK's — re-exported so the CLI's surface is unchanged. */ export { configPath, DEFAULT_URL, type Env, readConfig }; /** The shape `taleseal login` writes — the SDK's stored-config shape, under the CLI's name. */ export type TalesealConfig = StoredConfig; /** Writes the config file (mode 0600; directory created as needed) and returns its path. */ export function writeConfig(config: TalesealConfig, env: Env = process.env): string { const path = configPath(env); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); chmodSync(path, 0o600); // the mode above only applies on creation — enforce it on overwrite too return path; } /** Removes the stored key (keeping a stored "url"); returns whether one was stored. */ export function deleteStoredKey(env: Env = process.env): boolean { const config = readConfig(env); if (config.apiKey === undefined) return false; if (config.url !== undefined) writeConfig({ url: config.url }, env); else rmSync(configPath(env), { force: true }); return true; } /** TALESEAL_API_KEY env var → config "apiKey" → undefined. */ export function resolveApiKey(env: Env = process.env, config: TalesealConfig = readConfig(env)): string | undefined { return nonEmpty(env.TALESEAL_API_KEY) ?? config.apiKey; } /** * The three-way key state behind auto-anonymous drafting. `resolveApiKey` collapses a * present-but-blank key into "no key", which is right for refusing but wrong for going * anonymous: a CI secret that fails to load expands to "", and silently publishing an * ownerless page that is hard-deleted in 24 hours — while the pipeline stays green — is * exactly the footgun. Only a key that was never set ANYWHERE ("absent") makes `draft` * eligible for the anonymous path; "empty" keeps refusing loudly. */ export type ApiKeyState = | { state: "key"; key: string } | { state: "absent" } | { state: "empty"; source: "env" | "config" }; export function resolveApiKeyState(env: Env = process.env): ApiKeyState { const key = resolveApiKey(env); if (key !== undefined) return { state: "key", key }; // No usable key. Distinguish "never set" from "set but blank": the env var first (a // blank one falls through resolveApiKey, so reaching here means no stored key rescued // it), then the raw config file, whose blank apiKey readConfig silently drops. if (env.TALESEAL_API_KEY !== undefined) return { state: "empty", source: "env" }; if (storedApiKeyIsBlank(env)) return { state: "empty", source: "config" }; return { state: "absent" }; } /** Whether the config file itself carries a blank `apiKey` — readConfig drops it, this must not. */ function storedApiKeyIsBlank(env: Env): boolean { let parsed: unknown; try { parsed = JSON.parse(readFileSync(configPath(env), "utf8")); } catch { return false; } if (typeof parsed !== "object" || parsed === null) return false; const apiKey = (parsed as Record).apiKey; return typeof apiKey === "string" && apiKey.trim() === ""; } /** TALESEAL_URL env var → config "url" → DEFAULT_URL; any trailing slash is stripped. */ export function resolveUrl(env: Env = process.env, config: TalesealConfig = readConfig(env)): string { return (nonEmpty(env.TALESEAL_URL) ?? config.url ?? DEFAULT_URL).replace(/\/$/, ""); }