From 582dbb8ac2dd54eff9a99650604f0bd81c0944d2 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 26 Jun 2025 22:39:20 +0000 Subject: [PATCH 1/3] feat: implement `alchemy bootstrap` command for automated DOStateStore setup - Add bootstrap command that generates secure tokens and deploys DOStateStore worker - Reuse existing `upsertStateStoreWorker()` function from DOStateStore implementation - Generate 32-byte secure tokens using Node.js crypto with base64url encoding - Automatically manage .env file with ALCHEMY_STATE_TOKEN - Interactive prompts with force and yes flags for automation - Comprehensive error handling and user feedback This addresses the user friction in getting started with Cloudflare by automating: 1. Token generation (no more manual ALCHEMY_STATE_TOKEN creation) 2. DOStateStore worker deployment 3. .env file configuration Usage: - `alchemy bootstrap` - Interactive setup - `alchemy bootstrap --force` - Force overwrite existing setup - `alchemy bootstrap --yes` - Skip prompts Fixes #497 Co-authored-by: sam --- alchemy/bin/alchemy.ts | 33 +++++++ alchemy/bin/commands/bootstrap.ts | 153 ++++++++++++++++++++++++++++++ alchemy/bin/types.ts | 5 + 3 files changed, 191 insertions(+) create mode 100644 alchemy/bin/commands/bootstrap.ts diff --git a/alchemy/bin/alchemy.ts b/alchemy/bin/alchemy.ts index 6f9180cad..5093af473 100644 --- a/alchemy/bin/alchemy.ts +++ b/alchemy/bin/alchemy.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { createCli, trpcServer, zod as z } from "trpc-cli"; +import { bootstrapAlchemy, type BootstrapInput } from "./commands/bootstrap.ts"; import { createAlchemy } from "./commands/create.ts"; import { getPackageVersion } from "./services/get-package-version.ts"; import { @@ -73,6 +74,38 @@ const router = t.router({ }; await createAlchemy(combinedInput); }), + bootstrap: t.procedure + .meta({ + description: "Bootstrap Cloudflare DOStateStore for Alchemy", + }) + .input( + z.tuple([ + z + .object({ + force: z + .boolean() + .optional() + .default(false) + .describe("Force overwrite existing token and redeploy worker"), + yes: z + .boolean() + .optional() + .default(false) + .describe("Skip prompts and use defaults"), + }) + .optional() + .default({}), + ]), + ) + .mutation(async ({ input }) => { + const [options] = input; + const isTest = process.env.NODE_ENV === "test"; + const combinedInput: BootstrapInput = { + ...options, + yes: isTest || options.yes, + }; + await bootstrapAlchemy(combinedInput); + }), }); export type AppRouter = typeof router; diff --git a/alchemy/bin/commands/bootstrap.ts b/alchemy/bin/commands/bootstrap.ts new file mode 100644 index 000000000..2c1c6be68 --- /dev/null +++ b/alchemy/bin/commands/bootstrap.ts @@ -0,0 +1,153 @@ +import { + cancel, + confirm, + intro, + isCancel, + log, + outro, + spinner, +} from "@clack/prompts"; +import { randomBytes } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import pc from "picocolors"; + +import { createCloudflareApi } from "../../src/cloudflare/api.ts"; +import { upsertStateStoreWorker } from "../../src/cloudflare/do-state-store/internal.ts"; +import { throwWithContext } from "../errors.ts"; +import type { BootstrapInput } from "../types.ts"; + +const isTest = process.env.NODE_ENV === "test"; + +function generateSecureToken(): string { + // Generate a 32-byte random token and encode as base64url + return randomBytes(32).toString("base64url"); +} + +async function updateEnvFile(token: string): Promise { + const envPath = join(process.cwd(), ".env"); + let envContent = ""; + + // Read existing .env file if it exists + if (existsSync(envPath)) { + envContent = readFileSync(envPath, "utf-8"); + } + + // Check if ALCHEMY_STATE_TOKEN already exists + const lines = envContent.split("\n"); + const tokenLineIndex = lines.findIndex((line) => + line.startsWith("ALCHEMY_STATE_TOKEN=") + ); + + if (tokenLineIndex !== -1) { + // Replace existing token + lines[tokenLineIndex] = `ALCHEMY_STATE_TOKEN=${token}`; + } else { + // Append new token (ensure there's a newline before if file isn't empty) + if (envContent && !envContent.endsWith("\n")) { + envContent += "\n"; + } + lines.push(`ALCHEMY_STATE_TOKEN=${token}`); + } + + // Write back to file + writeFileSync(envPath, lines.join("\n")); +} + +export async function bootstrapAlchemy( + cliOptions: BootstrapInput, +): Promise { + try { + intro(pc.cyan("๐Ÿงช Alchemy Bootstrap")); + log.info("Setting up Cloudflare DOStateStore..."); + + const options = { yes: isTest, ...cliOptions }; + + // Check if .env already has a token and ask for confirmation + const envPath = join(process.cwd(), ".env"); + const hasExistingToken = existsSync(envPath) && + readFileSync(envPath, "utf-8").includes("ALCHEMY_STATE_TOKEN="); + + if (hasExistingToken && !options.force && !options.yes) { + const shouldOverwrite = await confirm({ + message: "ALCHEMY_STATE_TOKEN already exists in .env. Overwrite?", + initialValue: false, + }); + + if (isCancel(shouldOverwrite)) { + cancel(pc.red("Operation cancelled.")); + process.exit(0); + } + + if (!shouldOverwrite) { + cancel(pc.yellow("Keeping existing token.")); + process.exit(0); + } + } + + // Generate secure token + const s = spinner(); + s.start("Generating secure token..."); + const token = generateSecureToken(); + s.stop("Token generated"); + + // Create Cloudflare API client + s.start("Connecting to Cloudflare API..."); + const api = await createCloudflareApi(); + s.stop(`Connected to Cloudflare account: ${pc.green(api.accountId)}`); + + // Deploy DOStateStore worker + const workerName = `alchemy-state-store-${api.accountId}`; + s.start(`Deploying DOStateStore worker (${workerName})...`); + + await upsertStateStoreWorker( + api, + workerName, + token, + options.force ?? false, + ); + + s.stop(`DOStateStore worker deployed: ${pc.green(workerName)}`); + + // Update .env file + s.start("Updating .env file..."); + await updateEnvFile(token); + s.stop(".env file updated"); + + log.info(`Worker URL: ${pc.cyan(`https://${workerName}.${api.accountId}.workers.dev`)}`); + + outro(pc.green("โœ… Bootstrap completed successfully!")); + + log.info(` +${pc.cyan("Next steps:")} +1. Create your ${pc.yellow("alchemy.run.ts")} file +2. Use ${pc.yellow("DOStateStore")} as your state store +3. Run ${pc.yellow("bun ./alchemy.run.ts")} to deploy your app + +${pc.cyan("Example alchemy.run.ts:")} +${pc.gray(`import { alchemy } from "alchemy"; +import { DOStateStore } from "alchemy/cloudflare"; + +const app = alchemy({ + name: "my-app", + state: DOStateStore(), +}); + +// Your resources here... + +export default app;`)} +`); + + } catch (error) { + log.error("Bootstrap failed:"); + 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))); + } + process.exit(1); + } +} \ No newline at end of file diff --git a/alchemy/bin/types.ts b/alchemy/bin/types.ts index 200f8fc62..6490f77f0 100644 --- a/alchemy/bin/types.ts +++ b/alchemy/bin/types.ts @@ -81,3 +81,8 @@ export type CreateInput = { export type CLIInput = CreateInput & { projectDirectory?: string; }; + +export type BootstrapInput = { + force?: boolean; + yes?: boolean; +}; From a56de424b1aa0f61392b339b87242776abd58fd7 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 26 Jun 2025 22:54:47 +0000 Subject: [PATCH 2/3] refactor: print token instead of auto-managing .env file in bootstrap command - Remove automatic .env file writing and replace with manual instructions - Display token and clear setup instructions to user - Remove confirmation prompts for .env overwrites - Update next steps to remind about .env setup - Clean up unused imports (cancel, confirm, isCancel, fs operations) Co-authored-by: sam --- alchemy/bin/commands/bootstrap.ts | 76 ++++++------------------------- 1 file changed, 15 insertions(+), 61 deletions(-) diff --git a/alchemy/bin/commands/bootstrap.ts b/alchemy/bin/commands/bootstrap.ts index 2c1c6be68..ddfbb00a1 100644 --- a/alchemy/bin/commands/bootstrap.ts +++ b/alchemy/bin/commands/bootstrap.ts @@ -1,15 +1,10 @@ import { - cancel, - confirm, intro, - isCancel, log, outro, spinner, } from "@clack/prompts"; import { randomBytes } from "node:crypto"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import pc from "picocolors"; import { createCloudflareApi } from "../../src/cloudflare/api.ts"; @@ -24,34 +19,15 @@ function generateSecureToken(): string { return randomBytes(32).toString("base64url"); } -async function updateEnvFile(token: string): Promise { - const envPath = join(process.cwd(), ".env"); - let envContent = ""; - - // Read existing .env file if it exists - if (existsSync(envPath)) { - envContent = readFileSync(envPath, "utf-8"); - } - - // Check if ALCHEMY_STATE_TOKEN already exists - const lines = envContent.split("\n"); - const tokenLineIndex = lines.findIndex((line) => - line.startsWith("ALCHEMY_STATE_TOKEN=") - ); - - if (tokenLineIndex !== -1) { - // Replace existing token - lines[tokenLineIndex] = `ALCHEMY_STATE_TOKEN=${token}`; - } else { - // Append new token (ensure there's a newline before if file isn't empty) - if (envContent && !envContent.endsWith("\n")) { - envContent += "\n"; - } - lines.push(`ALCHEMY_STATE_TOKEN=${token}`); - } - - // Write back to file - writeFileSync(envPath, lines.join("\n")); +function displayTokenInstructions(token: string): void { + log.info(` +${pc.cyan("๐Ÿ“‹ Setup Instructions:")} + +Add the following line to your ${pc.yellow(".env")} file: + +${pc.green(`ALCHEMY_STATE_TOKEN=${token}`)} + +${pc.gray("If you don't have a .env file, create one in your project root.")}`); } export async function bootstrapAlchemy( @@ -63,27 +39,6 @@ export async function bootstrapAlchemy( const options = { yes: isTest, ...cliOptions }; - // Check if .env already has a token and ask for confirmation - const envPath = join(process.cwd(), ".env"); - const hasExistingToken = existsSync(envPath) && - readFileSync(envPath, "utf-8").includes("ALCHEMY_STATE_TOKEN="); - - if (hasExistingToken && !options.force && !options.yes) { - const shouldOverwrite = await confirm({ - message: "ALCHEMY_STATE_TOKEN already exists in .env. Overwrite?", - initialValue: false, - }); - - if (isCancel(shouldOverwrite)) { - cancel(pc.red("Operation cancelled.")); - process.exit(0); - } - - if (!shouldOverwrite) { - cancel(pc.yellow("Keeping existing token.")); - process.exit(0); - } - } // Generate secure token const s = spinner(); @@ -109,10 +64,8 @@ export async function bootstrapAlchemy( s.stop(`DOStateStore worker deployed: ${pc.green(workerName)}`); - // Update .env file - s.start("Updating .env file..."); - await updateEnvFile(token); - s.stop(".env file updated"); + // Display token instructions + displayTokenInstructions(token); log.info(`Worker URL: ${pc.cyan(`https://${workerName}.${api.accountId}.workers.dev`)}`); @@ -120,9 +73,10 @@ export async function bootstrapAlchemy( log.info(` ${pc.cyan("Next steps:")} -1. Create your ${pc.yellow("alchemy.run.ts")} file -2. Use ${pc.yellow("DOStateStore")} as your state store -3. Run ${pc.yellow("bun ./alchemy.run.ts")} to deploy your app +1. Add the token to your ${pc.yellow(".env")} file (see instructions above) +2. Create your ${pc.yellow("alchemy.run.ts")} file +3. Use ${pc.yellow("DOStateStore")} as your state store +4. Run ${pc.yellow("bun ./alchemy.run.ts")} to deploy your app ${pc.cyan("Example alchemy.run.ts:")} ${pc.gray(`import { alchemy } from "alchemy"; From 7d4a46c90f0a34276785ce66ff09cee0daf91286 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 26 Jun 2025 22:57:47 +0000 Subject: [PATCH 3/3] fix: correct DOStateStore syntax in bootstrap example Co-authored-by: sam --- alchemy/bin/commands/bootstrap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alchemy/bin/commands/bootstrap.ts b/alchemy/bin/commands/bootstrap.ts index ddfbb00a1..bd75887fe 100644 --- a/alchemy/bin/commands/bootstrap.ts +++ b/alchemy/bin/commands/bootstrap.ts @@ -84,7 +84,7 @@ import { DOStateStore } from "alchemy/cloudflare"; const app = alchemy({ name: "my-app", - state: DOStateStore(), + stateStore: (scope) => new DOStateStore(scope), }); // Your resources here...