commands/secret.ts
Rama main · réplica de lectura del repositorio original.
import * as p from "@clack/prompts";
import arg from "arg";
import { z } from "zod";
import {
configurationApi,
confirmChange,
readValueFile,
resolveTarget,
scopeArgs,
targetName,
} from "./_configuration.ts";
import { SUBSCRIPTION_SECRET_NAMES, shadowRefusal } from "./_shared.ts";
export const secretNamesSchema = z.object({
target: z.string(),
secrets: z.array(z.string()),
inherited: z.array(z.string()),
// `.default([])` so a CLI newer than its server — a preview deployment, a self-hosted
// install — keeps working: every command parses this shape, not just `auth`.
overrides: z.array(z.object({ name: z.string(), repo: z.string() })).default([]),
writable: z.boolean(),
});
export async function runCli(input: { args: string[]; prog: string; showHelp?: boolean }) {
const args = arg({ ...scopeArgs, "--file": String, "--yes": Boolean }, { argv: input.args });
if (input.showHelp || args["--help"] || !args._.length) {
console.log(`usage: ${input.prog} secret <list|set|delete> [NAME...]
list list Pullfrog-stored secret names, never values
set NAME [NAME...] prompt for masked values; save the group together
delete NAME remove a secret from the selected scope
--repo, -R OWNER/REPO select a repository (default: GitHub origin)
--org OWNER select shared organization or personal-account secrets
--file PATH value for one secret; - reads stdin
--yes acknowledge deleting a secret
values are never accepted as command arguments or printed.
use auth codex, auth claude or auth grok for subscription credentials.`);
return;
}
const command = args._[0];
const names = args._.slice(1);
if (!["list", "set", "delete"].includes(command))
throw new Error("expected secret list, set or delete");
if (names.some((name) => !/^[A-Z][A-Z0-9_]{0,63}$/.test(name)))
throw new Error(
"secret names must be uppercase alphanumeric with underscores, max 64 characters"
);
if (new Set(names).size !== names.length) throw new Error("duplicate secret name");
const target = resolveTarget({ org: args["--org"], repo: args["--repo"] });
const data = secretNamesSchema.parse(await configurationApi({ target, path: "credentials" }));
if (command === "list") {
if (names.length || args["--file"] !== undefined) throw new Error("usage: secret list");
console.log(data.target);
for (const name of data.secrets) console.log(`${name} [${target.repo ? "repo" : "org"}]`);
for (const name of data.inherited)
console.log(`${name} [org${data.secrets.includes(name) ? ", overridden" : ", inherited"}]`);
if (!data.secrets.length && !data.inherited.length) console.log("no Pullfrog-stored secrets");
return;
}
if (!data.writable)
throw new Error(
target.repo ? "repo admin required to change secrets" : "org owner required to change secrets"
);
if (command === "delete") {
if (names.length !== 1 || args["--file"] !== undefined)
throw new Error("usage: secret delete NAME");
if (!data.secrets.includes(names[0])) {
throw new Error(
`secret is not stored on ${data.target}${target.repo && data.inherited.includes(names[0]) ? "; use --org to manage the inherited secret" : ""}`
);
}
await confirmChange({
message: `${targetName(target)}: delete ${names[0]}${target.repo && data.inherited.includes(names[0]) ? " (the org credential will apply instead)" : ""}`,
yes: args["--yes"] === true,
});
await configurationApi({
target,
path: "credentials",
method: "DELETE",
body: { name: names[0], confirmed: true },
});
} else {
if (!names.length || names.length > 64) throw new Error("usage: secret set NAME [NAME...]");
const file = args["--file"];
if (file !== undefined && names.length !== 1)
throw new Error("--file requires exactly one secret name");
if (file === undefined && !process.stdin.isTTY)
throw new Error("use --file PATH or --file - without a terminal");
// the other door to the same account-level write `auth` guards. checked before any value
// is prompted for, so nobody types a credential that would land in a shadow.
for (const name of names.filter((secret) => SUBSCRIPTION_SECRET_NAMES.includes(secret))) {
const refusal = shadowRefusal({ overrides: data.overrides, owner: target.owner, name });
if (refusal) throw new Error(refusal);
}
const secrets: { name: string; value: string }[] = [];
for (const name of names) {
const value =
file === undefined
? await p.password({
message: `${data.target}: ${name}${data.secrets.includes(name) ? " (replace)" : ""}`,
})
: await readValueFile(file);
if (p.isCancel(value)) throw new Error("canceled; nothing changed");
if (!value.trim() || Buffer.byteLength(value.trim(), "utf8") > 49152)
throw new Error("secret must contain 1..49152 bytes");
secrets.push({ name, value });
}
await configurationApi({ target, path: "credentials", method: "PUT", body: { secrets } });
}
console.log(`${data.target}: ${names.join(", ")} ${command === "delete" ? "deleted" : "saved"}`);
}