UNPKG

trigger.dev

Version:

A Command-Line Interface for Trigger.dev projects

189 lines • 9.91 kB
import { intro, isCancel, log, outro, password, select } from "@clack/prompts"; import { z } from "zod"; import { commonOptions, handleTelemetry, wrapCommandAction } from "../../cli/common.js"; import { printInitialBanner } from "../../utilities/initialBanner.js"; import { resolveRunsClient, RunsCommonOptions, runsOptions } from "../runs/common.js"; import { DOTENV_FILES, prepareEnvFile, readEnvVariable, saveToEnvFile, saveToPrivateTempFile, trimSecret, } from "./envFile.js"; import { DEFAULT_GRACE_PERIOD_SECONDS, GRACE_PERIOD_CHOICES, parseGracePeriod, } from "./gracePeriod.js"; const DEFAULT_WRITE_FILE = ".env.local"; const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; const PREVIOUS_SECRET_TTL_HELP = "When replacing a secret, how long the old one keeps working: 0, or a duration up to 7d such as 1h or 24h. Asked for in a terminal; required in scripts when the endpoint already has a secret."; const SetSecretOptions = RunsCommonOptions.extend({ endpointId: z.string(), fromEnv: z.string().regex(ENV_NAME, "--from-env must be an environment variable name").optional(), fromFile: z.string().optional(), previousSecretTtl: z.string().optional(), }); const GenerateSecretOptions = RunsCommonOptions.extend({ endpointId: z.string(), toEnv: z.string().regex(ENV_NAME, "--to-env must be an environment variable name").optional(), toFile: z.string().optional(), print: z.boolean().default(false), previousSecretTtl: z.string().optional(), }); export function configureWebhooksSetSecretCommand(webhooks) { return commonOptions(runsOptions(webhooks .command("set-secret") .description("Store the signing secret (or public key) a provider issued for a webhook endpoint, without printing it") .argument("<endpoint>", "The endpoint's declared id or its wh_ id") .option("--from-env <name>", "Read the secret from this environment variable, or from the project's .env files") .option("--from-file <path>", "With --from-env, read the variable from this .env file only") .option("--previous-secret-ttl <duration>", PREVIOUS_SECRET_TTL_HELP))).action(async (endpointId, options) => { await handleTelemetry(async () => { await printInitialBanner(false, options.profile); await wrapCommandAction("webhooksSetSecretCommand", SetSecretOptions, { ...options, endpointId }, setSecret); }); }); } export function configureWebhooksGenerateSecretCommand(webhooks) { return commonOptions(runsOptions(webhooks .command("generate-secret") .description("Generate a new signing secret for a webhook endpoint and save it to an env file") .argument("<endpoint>", "The endpoint's declared id or its wh_ id") .option("--to-env <name>", `Save the secret as this variable in ${DEFAULT_WRITE_FILE}`) .option("--to-file <path>", `With --to-env, the .env file to save to (default ${DEFAULT_WRITE_FILE})`) .option("--print", "Print only the secret, for piping it into the provider's CLI") .option("--previous-secret-ttl <duration>", PREVIOUS_SECRET_TTL_HELP))).action(async (endpointId, options) => { await handleTelemetry(async () => { if (!options.print) await printInitialBanner(false, options.profile); await wrapCommandAction("webhooksGenerateSecretCommand", GenerateSecretOptions, { ...options, endpointId }, generateSecret); }); }); } async function setSecret(options) { intro(`Setting the signing secret for ${options.endpointId}`); const flagGracePeriod = parseGracePeriodFlag(options.previousSecretTtl); const secret = await readSecret(options); const { apiClient, env } = await resolveRunsClient(options); const previousSecretTtlSeconds = await resolveGracePeriod(apiClient, options.endpointId, flagGracePeriod); const result = await apiClient.setWebhookEndpointSecret(options.endpointId, { secret, previousSecretTtlSeconds, }); outro(`Stored the signing secret for ${result.id} in ${env}.${previousSecretNote(result.previousSecretExpiresAt)}`); } async function generateSecret(options) { if (options.toEnv && options.print) { throw new Error("Pass either --to-env or --print, not both."); } if (options.toFile && !options.toEnv) { throw new Error("--to-file needs --to-env <name>."); } if (!options.toEnv && !options.print && !process.stdout.isTTY) { throw new Error(`Pass --to-env <name> to save the secret to ${DEFAULT_WRITE_FILE}, or --print to print it for piping. The secret isn't printed to a non-interactive output by default, so it stays out of logs and AI agent transcripts.`); } const flagGracePeriod = parseGracePeriodFlag(options.previousSecretTtl); const file = options.toFile ?? DEFAULT_WRITE_FILE; const destination = options.toEnv ? await prepareEnvFile(file) : undefined; const stdoutWrite = process.stdout.write.bind(process.stdout); if (options.print) { process.stdout.write = process.stderr.write.bind(process.stderr); } try { if (!options.print) intro(`Generating a signing secret for ${options.endpointId}`); const { apiClient, env } = await resolveRunsClient(options); const previousSecretTtlSeconds = await resolveGracePeriod(apiClient, options.endpointId, flagGracePeriod); const result = await apiClient.generateWebhookEndpointSecret(options.endpointId, { previousSecretTtlSeconds, }); if (options.print) { stdoutWrite(`${result.secret}\n`); return; } const note = previousSecretNote(result.previousSecretExpiresAt); if (options.toEnv && destination) { let saved; try { saved = await saveToEnvFile(destination, options.toEnv, result.secret); } catch (error) { const reason = error instanceof Error ? error.message : String(error); const rescue = await saveToPrivateTempFile(options.toEnv, result.secret).catch(() => undefined); throw new Error(rescue ? `Generated a new signing secret for ${result.id}, but couldn't save it to ${file} (${reason}). It's saved as ${options.toEnv} in ${rescue}, readable only by you. Move it into place, then delete that file.${note}` : `Generated a new signing secret for ${result.id}, but couldn't save it to ${file} or a temporary file (${reason}). Run the command again to generate another.${note}`); } if (saved.readableByOthers) { log.warn(`${file} is readable by other users on this machine. Run: chmod 600 ${file}`); } outro(`Generated a signing secret for ${result.id} in ${env} and saved it as ${options.toEnv} in ${file}.${note}`); return; } log.info(result.secret); outro(`Generated a signing secret for ${result.id} in ${env}. It's shown once; give it to the provider.${note}`); } finally { process.stdout.write = stdoutWrite; } } function parseGracePeriodFlag(value) { return value === undefined ? undefined : parseGracePeriod(value); } /** * How long the endpoint's current secret keeps working after it's replaced. An explicit flag wins; * otherwise a terminal asks, with the dashboard's choices, and a script must say, so replacing a * live secret never cuts the provider off by accident. */ async function resolveGracePeriod(apiClient, endpointId, flag) { if (flag !== undefined) return flag; const endpoint = await apiClient.retrieveWebhookEndpoint(endpointId); if (!endpoint.secretSet) return undefined; if (!process.stdin.isTTY || !process.stdout.isTTY) { throw new Error(`${endpointId} already has a signing secret. Pass --previous-secret-ttl to say how long it keeps working: for example 24h while the provider switches to the new one, or 0 to stop it now if it leaked.`); } const choice = await select({ message: `${endpointId} already has a signing secret. How long should it keep working?`, options: GRACE_PERIOD_CHOICES.map((c) => ({ ...c })), initialValue: DEFAULT_GRACE_PERIOD_SECONDS, }); if (isCancel(choice)) { throw new Error("Cancelled."); } return choice; } async function readSecret(options) { if (options.fromFile && !options.fromEnv) { throw new Error("--from-file needs --from-env <name>."); } if (options.fromEnv) { const value = await readEnvVariable(options.fromEnv, options.fromFile); if (!value) { throw new Error(options.fromFile ? `${options.fromEnv} isn't set in ${options.fromFile}.` : `${options.fromEnv} isn't set in the environment or in ${DOTENV_FILES.join(", ")}.`); } return value; } if (!process.stdin.isTTY) { const value = trimSecret(await readStdin()); if (!value) { throw new Error("No secret on stdin. Pipe it in, or use --from-env <name>."); } return value; } const value = await password({ message: "Paste the signing secret", validate: (input) => (input?.trim() ? undefined : "The secret can't be empty"), }); if (isCancel(value)) { throw new Error("Cancelled."); } return value.trim(); } async function readStdin() { const chunks = []; for await (const chunk of process.stdin) { chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); } return Buffer.concat(chunks).toString("utf8"); } function previousSecretNote(expiresAt) { if (!expiresAt) return ""; return ` The previous secret keeps working until ${new Date(expiresAt).toISOString()}.`; } //# sourceMappingURL=secret.js.map