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..bd75887fe --- /dev/null +++ b/alchemy/bin/commands/bootstrap.ts @@ -0,0 +1,107 @@ +import { + intro, + log, + outro, + spinner, +} from "@clack/prompts"; +import { randomBytes } from "node:crypto"; +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"); +} + +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( + cliOptions: BootstrapInput, +): Promise { + try { + intro(pc.cyan("๐Ÿงช Alchemy Bootstrap")); + log.info("Setting up Cloudflare DOStateStore..."); + + const options = { yes: isTest, ...cliOptions }; + + + // 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)}`); + + // Display token instructions + displayTokenInstructions(token); + + 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. 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"; +import { DOStateStore } from "alchemy/cloudflare"; + +const app = alchemy({ + name: "my-app", + stateStore: (scope) => new DOStateStore(scope), +}); + +// 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; +};