import { readFile, stat } from "node:fs/promises"; import type { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { CliArgError } from "./lib/cli-args"; import { integerOption, isEnvironmentHelp, parseEnvironmentOptions, requireSecretName, } from "./lib/environment-cli-args"; import { EnvironmentOutputRedactor } from "./lib/environment-execution"; const MAX_SECRET_BYTES = 64 * 1024; const HELP = `dench secrets — workspace-shared encrypted credentials dench secrets set NAME --stdin [--kind env|file] [--expected-version N] [--json] dench secrets set NAME --file PATH [--kind env|file] [--expected-version N] [--json] dench secrets list [--json] dench secrets delete NAME [--expected-version N] [--json] Values are accepted only through stdin or a file. Values are never printed. Bind a stored name in dench-env.json secretEnv or secretFiles; env run injects only those bindings. Memory is not credential storage. --kind defaults to env. Example: dench secrets set OPENAI_API_KEY --stdin < /private/path/key.txt`; export function workspaceSecretsHelpText(): string { return HELP; } export function validateWorkspaceSecretsArgs(input: readonly string[]) { if (isEnvironmentHelp(input)) return { verb: "help" as const }; const verb = input[0]; if (!verb || !["set", "list", "delete"].includes(verb)) throw new CliArgError( "Use dench secrets set, list, or delete; see --help.", ); const opts = parseEnvironmentOptions( input.slice(1), verb === "set" ? ["--file", "--kind", "--expected-version"] : verb === "delete" ? ["--expected-version"] : [], verb === "set" ? ["--stdin", "--json"] : ["--json"], ); if (opts.child.length || opts.positional.length !== (verb === "list" ? 0 : 1)) throw new CliArgError( `Unexpected or missing arguments for dench secrets ${verb}.`, ); const name = verb === "list" ? undefined : requireSecretName(opts.positional[0]); const kind = opts.flags["--kind"] ?? "env"; if (kind !== "env" && kind !== "file") throw new CliArgError("--kind must be env or file."); if ( verb === "set" && Number(Boolean(opts.flags["--stdin"])) + Number(Boolean(opts.flags["--file"])) !== 1 ) throw new CliArgError( "Use exactly one of --stdin or --file; never put secret values in command arguments.", ); const expectedVersion = opts.flags["--expected-version"] === undefined ? undefined : integerOption( opts.flags["--expected-version"], "--expected-version", 0, 0, Number.MAX_SAFE_INTEGER, ); return { verb: verb as "set" | "list" | "delete", name, kind, expectedVersion, file: opts.flags["--file"] as string | undefined, }; } async function readSecretInput(): Promise { if (process.stdin.isTTY) throw new CliArgError( "Pipe the credential into stdin, or use --file. Interactive plaintext entry is not supported.", ); const chunks: Buffer[] = []; let bytes = 0; for await (const chunk of process.stdin) { const buffer = Buffer.from(chunk); bytes += buffer.length; if (bytes > MAX_SECRET_BYTES) throw new CliArgError("Secret exceeds the 64 KiB limit."); chunks.push(buffer); } return new TextDecoder("utf-8", { fatal: true }).decode( Buffer.concat(chunks), ); } export async function runWorkspaceSecretsCommand(opts: { convex: ConvexHttpClient; args: string[]; sessionToken?: string; readStdin?: () => Promise; }): Promise { const parsed = validateWorkspaceSecretsArgs(opts.args); if (parsed.verb === "help") { console.log(HELP); return; } const token = opts.sessionToken ? { sessionToken: opts.sessionToken } : {}; if (parsed.verb === "list") { console.log( JSON.stringify( await opts.convex.query( makeFunctionReference<"query">("functions/workspaceSecrets:list"), token, ), null, 2, ), ); return; } if (parsed.verb === "delete") { console.log( JSON.stringify( await opts.convex.mutation( makeFunctionReference<"mutation">( "functions/workspaceSecrets:remove", ), { ...token, name: parsed.name, ...(parsed.expectedVersion === undefined ? {} : { expectedVersion: parsed.expectedVersion }), }, ), null, 2, ), ); return; } if (parsed.file && (await stat(parsed.file)).size > MAX_SECRET_BYTES) throw new CliArgError("Secret exceeds the 64 KiB limit."); let value = parsed.file ? new TextDecoder("utf-8", { fatal: true }).decode( await readFile(parsed.file), ) : await (opts.readStdin ?? readSecretInput)(); // Text files commonly contain a final newline; preserve credential-file bytes. if (parsed.kind === "env") value = value.replace(/\r?\n$/, ""); if ( !value || Buffer.byteLength(value, "utf8") > MAX_SECRET_BYTES || value.includes("\0") ) throw new CliArgError( "Credential must be nonempty UTF-8 text without NUL, up to 64 KiB.", ); let result: unknown; try { result = await opts.convex.action( makeFunctionReference<"action">("functions/workspaceSecretsNode:set"), { ...token, name: parsed.name, kind: parsed.kind, value, ...(parsed.expectedVersion === undefined ? {} : { expectedVersion: parsed.expectedVersion }), }, ); } catch (error) { const redactor = new EnvironmentOutputRedactor([value]); const data = ( error as { data?: { code?: string; receiptId?: string; version?: number }; } ).data; const sanitized = new Error( redactor.push( error instanceof Error ? error.message : String(error), true, ), ); Object.assign(sanitized, { data: { message: sanitized.message, ...(data?.code ? { code: data.code } : {}), ...(data?.receiptId ? { receiptId: data.receiptId } : {}), ...(data?.version !== undefined ? { version: data.version } : {}), }, }); throw sanitized; } // Do not print an arbitrary response shape from a credential endpoint. const safe = result as { name?: string; version?: number; kind?: string; updatedAt?: number; }; if ( !safe || !Number.isSafeInteger(safe.version) || (safe.version ?? 0) < 1 || (safe.name && safe.name !== parsed.name) ) throw new Error( "secret_storage_unconfirmed: Storage did not return a valid version. Inspect dench secrets list before retrying.", ); console.log( JSON.stringify( { name: safe.name ?? parsed.name, version: safe.version, kind: safe.kind ?? parsed.kind, updatedAt: safe.updatedAt, stored: true, }, null, 2, ), ); }