diff --git a/alchemy/bin/alchemy.ts b/alchemy/bin/alchemy.ts index 6d93acc55..04311185a 100644 --- a/alchemy/bin/alchemy.ts +++ b/alchemy/bin/alchemy.ts @@ -6,6 +6,7 @@ import { dev } from "./commands/dev.ts"; import { login } from "./commands/login.ts"; import { run } from "./commands/run.ts"; import { init } from "./commands/init.ts"; +import { rotatePassword } from "./commands/rotate-password.ts"; import { getPackageVersion } from "./services/get-package-version.ts"; import { t } from "./trpc.ts"; @@ -17,6 +18,7 @@ const router = t.router({ destroy, dev, run, + "rotate-password": rotatePassword, }); export type AppRouter = typeof router; diff --git a/alchemy/bin/commands/rotate-password.ts b/alchemy/bin/commands/rotate-password.ts new file mode 100644 index 000000000..43e8074d4 --- /dev/null +++ b/alchemy/bin/commands/rotate-password.ts @@ -0,0 +1,157 @@ +import { log } from "@clack/prompts"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { promises as fs, unlinkSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import pc from "picocolors"; +import z from "zod"; +import { exists } from "../../src/util/exists.ts"; +import { getRunPrefix } from "../get-run-prefix.ts"; +import { entrypoint, execArgs } from "../services/execute-alchemy.ts"; +import { ExitSignal, loggedProcedure } from "../trpc.ts"; + +export const rotatePassword = loggedProcedure + .meta({ + description: "rotate the password for an alchemy project", + }) + .input( + z.tuple([ + entrypoint, + z.object({ + ...execArgs, + oldPassword: z.string().describe("the current password"), + newPassword: z.string().describe("the new password to set"), + scope: z + .string() + .optional() + .describe("the scope/FQN to rotate password for (optional)"), + }), + ]), + ) + .mutation(async ({ input: [main, options] }) => { + try { + const cwd = options.cwd || process.cwd(); + let oldPassword = options.oldPassword; + let newPassword = options.newPassword; + + // Validate passwords + if (oldPassword === newPassword) { + log.error(pc.red("New password must be different from old password")); + throw new ExitSignal(1); + } + + // Check for alchemy.run.ts or alchemy.run.js (if not provided) + let alchemyFile = main; + if (!alchemyFile) { + const candidates = ["alchemy.run.ts", "alchemy.run.js"]; + for (const file of candidates) { + const resolved = resolve(cwd, file); + if (await exists(resolved)) { + alchemyFile = resolved; + break; + } + } + } + + if (!alchemyFile) { + log.error( + pc.red( + "No alchemy.run.ts or alchemy.run.js file found in the current directory.", + ), + ); + throw new ExitSignal(1); + } + + // Create a wrapper script that will load the alchemy file and call rotatePassword + const wrapperScript = ` + ${await fs.readFile(alchemyFile, "utf8")} + + // ================= + + const { rotatePassword: __ALCHEMY_ROTATE_PASSWORD } = await import("alchemy"); + const __ALCHEMY_oldPassword = "${oldPassword.replace(/"/g, '\\"')}"; + const __ALCHEMY_newPassword = "${newPassword.replace(/"/g, '\\"')}"; + const __ALCHEMY_scope = ${options.scope ? `"${options.scope.replace(/"/g, '\\"')}"` : "undefined"}; + + try { + await __ALCHEMY_ROTATE_PASSWORD(__ALCHEMY_oldPassword, __ALCHEMY_newPassword, __ALCHEMY_scope); + console.log("\\n✅ Password rotation completed successfully"); + process.exit(0); + } catch (error) { + console.error("\\n❌ Password rotation failed:", error.message); + process.exit(1); + } +`; + + // Write the wrapper script to a temporary file + const tempScriptPath = resolve( + cwd, + `.alchemy-rotate-${Date.now()}.${alchemyFile.endsWith(".ts") ? "ts" : "mjs"}`, + ); + writeFileSync(tempScriptPath, wrapperScript); + + try { + const runPrefix = await getRunPrefix({ + isTypeScript: tempScriptPath.endsWith(".ts"), + cwd, + }); + let command = `${runPrefix} ${tempScriptPath}`; + + // Set the old password in environment for the alchemy scope to use + const env = { + ...process.env, + ALCHEMY_PASSWORD: oldPassword, + FORCE_COLOR: "1", + } as Record; + + // Handle stage if provided + if (options.stage) { + env.STAGE = options.stage; + } + + // Load env file if specified + if (options.envFile && (await exists(resolve(cwd, options.envFile)))) { + // The subprocess will handle loading the env file + command = `${command} --env-file ${options.envFile}`; + } + + const child = spawn(command, { + cwd, + shell: true, + stdio: "inherit", + env, + }); + + const exitPromise = once(child, "exit"); + await exitPromise; + + const exitCode = child.exitCode === 1 ? 1 : 0; + + // Clean up temp file + unlinkSync(tempScriptPath); + + if (exitCode !== 0) { + throw new ExitSignal(exitCode); + } + } catch (error) { + // Clean up temp file on error + try { + unlinkSync(tempScriptPath); + } catch {} + throw error; + } + } catch (error) { + if (error instanceof ExitSignal) { + throw error; + } + if (error instanceof Error) { + log.error(`${pc.red("Error:")} ${error.message}`); + if (error.stack && process.env.DEBUG) { + log.error(`${pc.gray("Stack trace:")}\n${error.stack}`); + } + } else { + log.error(pc.red(String(error))); + } + throw new ExitSignal(1); + } + }); diff --git a/alchemy/bin/get-run-prefix.ts b/alchemy/bin/get-run-prefix.ts new file mode 100644 index 000000000..1705af408 --- /dev/null +++ b/alchemy/bin/get-run-prefix.ts @@ -0,0 +1,44 @@ +import { detectRuntime } from "../src/util/detect-node-runtime.ts"; +import { detectPackageManager } from "../src/util/detect-package-manager.ts"; + +export async function getRunPrefix(options?: { + isTypeScript?: boolean; + cwd?: string; +}) { + const packageManager = await detectPackageManager( + options?.cwd ?? process.cwd(), + ); + const runtime = detectRuntime(); + + // Determine the command to run based on package manager and runtime + let command: string; + + switch (packageManager) { + case "bun": + command = "bun"; + break; + case "deno": + command = "deno run -A"; + break; + case "pnpm": + command = options?.isTypeScript ? "pnpm dlx tsx" : "pnpm node"; + break; + case "yarn": + command = options?.isTypeScript ? "yarn tsx" : "yarn node"; + break; + default: + switch (runtime) { + case "bun": + command = "bun"; + break; + case "deno": + command = "deno run -A"; + break; + default: + command = options?.isTypeScript ? "npx tsx" : "npx node"; + break; + } + } + + return command; +} diff --git a/alchemy/bin/services/execute-alchemy.ts b/alchemy/bin/services/execute-alchemy.ts index acb1a9735..d8f259931 100644 --- a/alchemy/bin/services/execute-alchemy.ts +++ b/alchemy/bin/services/execute-alchemy.ts @@ -4,9 +4,8 @@ import { once } from "node:events"; import { resolve } from "node:path"; import pc from "picocolors"; import z from "zod"; -import { detectRuntime } from "../../src/util/detect-node-runtime.ts"; -import { detectPackageManager } from "../../src/util/detect-package-manager.ts"; import { exists } from "../../src/util/exists.ts"; +import { getRunPrefix } from "../get-run-prefix.ts"; import { ExitSignal } from "../trpc.ts"; export const entrypoint = z @@ -123,48 +122,13 @@ export async function execAlchemy( throw new ExitSignal(1); } - // Detect package manager - const packageManager = await detectPackageManager(cwd); - const runtime = detectRuntime(); - const argsString = args.join(" "); const execArgsString = execArgs.join(" "); - // Determine the command to run based on package manager and file extension - let command: string; const isTypeScript = main.endsWith(".ts"); + const runPrefix = await getRunPrefix({ isTypeScript, cwd }); + + const command = `${runPrefix} ${execArgsString} ${main} ${argsString}`; - switch (packageManager) { - case "bun": - command = `bun ${execArgsString} ${main} ${argsString}`; - break; - case "deno": - command = `deno run -A ${execArgsString} ${main} ${argsString}`; - break; - case "pnpm": - command = isTypeScript - ? `pnpm dlx tsx ${execArgsString} ${main} ${argsString}` - : `pnpm node ${execArgsString} ${main} ${argsString}`; - break; - case "yarn": - command = isTypeScript - ? `yarn tsx ${execArgsString} ${main} ${argsString}` - : `yarn node ${execArgsString} ${main} ${argsString}`; - break; - default: - switch (runtime) { - case "bun": - command = `bun ${execArgsString} ${main} ${argsString}`; - break; - case "deno": - command = `deno run -A ${execArgsString} ${main} ${argsString}`; - break; - case "node": - command = isTypeScript - ? `npx tsx ${execArgsString} ${main} ${argsString}` - : `node ${execArgsString} ${main} ${argsString}`; - break; - } - } process.on("SIGINT", async () => { await exitPromise; process.exit(sanitizeExitCode(child.exitCode)); diff --git a/alchemy/src/index.ts b/alchemy/src/index.ts index 8f39fcc71..d9bcd4c19 100644 --- a/alchemy/src/index.ts +++ b/alchemy/src/index.ts @@ -2,6 +2,7 @@ export type { AlchemyOptions, Phase } from "./alchemy.ts"; export type * from "./context.ts"; export * from "./resource.ts"; +export * from "./rotate-password.ts"; export * from "./scope.ts"; export * from "./secret.ts"; export * from "./serde.ts"; diff --git a/alchemy/src/rotate-password.ts b/alchemy/src/rotate-password.ts new file mode 100644 index 000000000..0eca48248 --- /dev/null +++ b/alchemy/src/rotate-password.ts @@ -0,0 +1,167 @@ +import { Scope } from "./scope.ts"; +import { deserialize, serialize } from "./serde.ts"; +import { logger } from "./util/logger.ts"; + +/** + * Rotates the encryption password for all secrets in the state store. + * Reads all resources from the state store, decrypts secrets with the old password, + * re-encrypts them with the new password, and writes them back. + * + * @param oldPassword The current password used to decrypt existing secrets + * @param newPassword The new password to use for encrypting secrets + * @param fqn Optional fully qualified name to scope the rotation (defaults to rotating all scopes) + * @returns Promise that resolves when rotation is complete + * + * @example + * // Rotate password for all resources + * await rotatePassword( + * process.env.OLD_PASSWORD, + * process.env.NEW_PASSWORD + * ); + * + * @example + * // Rotate password for a specific scope + * await rotatePassword( + * process.env.OLD_PASSWORD, + * process.env.NEW_PASSWORD, + * "my-app/my-stage" + * ); + */ +export async function rotatePassword( + oldPassword: string, + newPassword: string, + fqn?: string, +): Promise { + if (!oldPassword) { + throw new Error("Old password is required"); + } + if (!newPassword) { + throw new Error("New password is required"); + } + if (oldPassword === newPassword) { + throw new Error("New password must be different from old password"); + } + + const currentScope = Scope.getScope(); + if (!currentScope) { + throw new Error( + "Password rotation must be called within an alchemy scope. Use: const app = await alchemy('my-app')", + ); + } + + const startScope = fqn + ? findScopeByFqn(currentScope.root, fqn) + : currentScope.root; + if (!startScope) { + throw new Error(`Could not find scope for FQN: ${fqn}`); + } + + let totalErrorCount = 0; + + async function rotateInScope(scope: Scope): Promise { + const oldPasswordScope = new Scope({ + parent: scope.parent, + scopeName: scope.scopeName, + password: oldPassword, + stateStore: scope.stateStore, + quiet: true, + phase: "read", + telemetryClient: scope.telemetryClient, + }); + + const newPasswordScope = new Scope({ + parent: scope.parent, + scopeName: scope.scopeName, + password: newPassword, + stateStore: scope.stateStore, + quiet: true, + phase: "up", + telemetryClient: scope.telemetryClient, + }); + + const allStates = await scope.state.all(); + + for (const [key, state] of Object.entries(allStates)) { + try { + const stateJson = JSON.stringify(state); + + const hasEncryptedSecrets = stateJson.includes('"@secret"'); + const hasUnencryptedSecrets = stateJson.includes('"type":"secret"'); + + if (!hasEncryptedSecrets && !hasUnencryptedSecrets) { + continue; + } + + logger.task(key, { + message: `Found secrets in ${key}, rotating...`, + status: "pending", + resource: key, + prefix: "Secret rotation", + prefixColor: "cyanBright", + }); + + let stateToRotate = state; + + if (hasUnencryptedSecrets && !hasEncryptedSecrets) { + stateToRotate = await serialize(oldPasswordScope, state, { + encrypt: true, + }); + } + + const decrypted = await deserialize(oldPasswordScope, stateToRotate); + + const reencrypted = await serialize(newPasswordScope, decrypted, { + encrypt: true, + }); + + await scope.state.set(key, reencrypted); + + logger.task(key, { + message: `Rotated secrets in ${key}`, + status: "success", + resource: key, + prefix: "Secret rotation", + prefixColor: "cyanBright", + }); + } catch (error) { + totalErrorCount++; + logger.task(key, { + message: `Failed to rotate secrets for ${key}: ${error}`, + status: "failure", + resource: key, + prefix: "Secret rotation", + prefixColor: "cyanBright", + }); + } + } + + for (const child of scope.children.values()) { + await rotateInScope(child); + } + } + + await rotateInScope(startScope); + + if (totalErrorCount > 0) { + throw new Error( + `Password rotation completed with ${totalErrorCount} errors`, + ); + } +} + +function findScopeByFqn(scope: Scope, targetFqn: string): Scope | undefined { + const currentFqn = scope.chain.join("/"); + + if (currentFqn === targetFqn) { + return scope; + } + + for (const child of scope.children.values()) { + const found = findScopeByFqn(child, targetFqn); + if (found) { + return found; + } + } + + return undefined; +}