feat(cli): add alchemy and improve cli tooling and structure (#520)

This commit is contained in:
Aman Varshney
2025-08-20 23:43:58 +05:30
committed by GitHub
parent c5430ae4fd
commit 5788876c47
152 changed files with 5804 additions and 2264 deletions

View File

@@ -66,7 +66,8 @@ Options:
--install Install dependencies
--no-install Skip installing dependencies
--db-setup <setup> Database setup (turso, d1, neon, supabase, prisma-postgres, mongodb-atlas, docker, none)
--web-deploy <setup> Web deployment (workers, none)
--web-deploy <setup> Web deployment (workers, alchemy, none)
--server-deploy <setup> Server deployment (workers, alchemy, none)
--backend <framework> Backend framework (hono, express, elysia, next, convex, fastify, none)
--runtime <runtime> Runtime (bun, node, workers, none)
--api <type> API type (trpc, orpc, none)

View File

@@ -52,7 +52,8 @@
"dev": "tsdown --watch",
"check-types": "tsc --noEmit",
"check": "biome check --write .",
"test": "bun run build && vitest --ui",
"test": "bun run build && vitest run",
"test:ui": "bun run build && vitest --ui",
"test:with-build": "bun run build && WITH_BUILD=1 vitest --ui",
"prepublishOnly": "npm run build"
},
@@ -63,22 +64,24 @@
}
},
"dependencies": {
"@biomejs/js-api": "^3.0.0",
"@biomejs/wasm-nodejs": "^2.2.0",
"@clack/prompts": "^0.11.0",
"consola": "^3.4.2",
"execa": "^9.6.0",
"fs-extra": "^11.3.1",
"globby": "^14.1.0",
"gradient-string": "^3.0.0",
"handlebars": "^4.7.8",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"tinyglobby": "^0.2.14",
"trpc-cli": "^0.10.2",
"ts-morph": "^26.0.0",
"zod": "^4.0.17"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/node": "^24.2.1",
"@types/node": "^24.3.0",
"@vitest/ui": "^3.2.4",
"tsdown": "^0.14.1",
"typescript": "^5.9.2",

View File

@@ -7,25 +7,37 @@ const __filename = fileURLToPath(import.meta.url);
const distPath = path.dirname(__filename);
export const PKG_ROOT = path.join(distPath, "../");
export const DEFAULT_CONFIG: ProjectConfig = {
export const DEFAULT_CONFIG_BASE = {
projectName: "my-better-t-app",
projectDir: path.resolve(process.cwd(), "my-better-t-app"),
relativePath: "my-better-t-app",
frontend: ["tanstack-router"],
database: "sqlite",
orm: "drizzle",
frontend: ["tanstack-router"] as const,
database: "sqlite" as const,
orm: "drizzle" as const,
auth: true,
addons: ["turborepo"],
examples: [],
addons: ["turborepo"] as const,
examples: [] as const,
git: true,
packageManager: getUserPkgManager(),
install: true,
dbSetup: "none",
backend: "hono",
runtime: "bun",
api: "trpc",
webDeploy: "none",
};
dbSetup: "none" as const,
backend: "hono" as const,
runtime: "bun" as const,
api: "trpc" as const,
webDeploy: "none" as const,
serverDeploy: "none" as const,
} as const;
export function getDefaultConfig(): ProjectConfig {
return {
...DEFAULT_CONFIG_BASE,
projectDir: path.resolve(process.cwd(), DEFAULT_CONFIG_BASE.projectName),
packageManager: getUserPkgManager(),
frontend: [...DEFAULT_CONFIG_BASE.frontend],
addons: [...DEFAULT_CONFIG_BASE.addons],
examples: [...DEFAULT_CONFIG_BASE.examples],
};
}
export const DEFAULT_CONFIG = getDefaultConfig();
export const dependencyVersionMap = {
"better-auth": "^1.3.4",
@@ -106,7 +118,8 @@ export const dependencyVersionMap = {
"convex-nuxt": "0.1.5",
"convex-vue": "^0.1.5",
"@tanstack/svelte-query": "^5.74.4",
"@tanstack/svelte-query": "^5.85.3",
"@tanstack/svelte-query-devtools": "^5.85.3",
"@tanstack/vue-query-devtools": "^5.83.0",
"@tanstack/vue-query": "^5.83.0",
@@ -116,19 +129,27 @@ export const dependencyVersionMap = {
"@tanstack/solid-query": "^5.75.0",
"@tanstack/solid-query-devtools": "^5.75.0",
"@tanstack/solid-router-devtools": "^1.131.25",
wrangler: "^4.23.0",
"@cloudflare/vite-plugin": "^1.9.0",
"@opennextjs/cloudflare": "^1.3.0",
"nitro-cloudflare-dev": "^0.2.2",
"@sveltejs/adapter-cloudflare": "^7.0.4",
"@sveltejs/adapter-cloudflare": "^7.2.1",
"@cloudflare/workers-types": "^4.20250813.0",
alchemy: "^0.62.1",
// temporary workaround for alchemy + tanstack start
nitropack: "^2.12.4",
dotenv: "^17.2.1",
} as const;
export type AvailableDependencies = keyof typeof dependencyVersionMap;
export const ADDON_COMPATIBILITY: Record<Addons, readonly Frontend[]> = {
pwa: ["tanstack-router", "react-router", "solid", "next"],
tauri: ["tanstack-router", "react-router", "nuxt", "svelte", "solid"],
tauri: ["tanstack-router", "react-router", "nuxt", "svelte", "solid", "next"],
biome: [],
husky: [],
turborepo: [],

View File

@@ -7,7 +7,7 @@ import { PKG_ROOT } from "../../constants";
import type { ProjectConfig } from "../../types";
import { exitCancelled } from "../../utils/errors";
import { getPackageExecutionCommand } from "../../utils/package-runner";
import { processAndCopyFiles } from "../project-generation/template-manager";
import { processAndCopyFiles } from "../core/template-manager";
export async function setupVibeRules(config: ProjectConfig) {
const { packageManager, projectDir } = config;

View File

@@ -69,8 +69,8 @@ export async function setupTauri(config: ProjectConfig) {
`--window-title=${path.basename(projectDir)}`,
`--frontend-dist=${frontendDist}`,
`--dev-url=${devUrl}`,
`--before-dev-command=\"${packageManager} run dev\"`,
`--before-build-command=\"${packageManager} run build\"`,
`--before-dev-command="${packageManager} run dev"`,
`--before-build-command="${packageManager} run build"`,
];
const tauriArgsString = tauriArgs.join(" ");

View File

@@ -5,7 +5,7 @@ import type { AddInput, Addons, ProjectConfig } from "../../types";
import { validateAddonCompatibility } from "../../utils/addon-compatibility";
import { updateBtsConfig } from "../../utils/bts-config";
import { exitWithError } from "../../utils/errors";
import { setupAddons } from "../setup/addons-setup";
import { setupAddons } from "../addons/addons-setup";
import {
detectProjectConfig,
isBetterTStackProject,
@@ -52,6 +52,7 @@ export async function addAddonsToProject(
dbSetup: detectedConfig.dbSetup || "none",
api: detectedConfig.api || "none",
webDeploy: detectedConfig.webDeploy || "none",
serverDeploy: detectedConfig.serverDeploy || "none",
};
for (const addon of input.addons) {

View File

@@ -1,10 +1,16 @@
import path from "node:path";
import { log } from "@clack/prompts";
import pc from "picocolors";
import type { AddInput, ProjectConfig, WebDeploy } from "../../types";
import type {
AddInput,
ProjectConfig,
ServerDeploy,
WebDeploy,
} from "../../types";
import { updateBtsConfig } from "../../utils/bts-config";
import { exitWithError } from "../../utils/errors";
import { setupWebDeploy } from "../setup/web-deploy-setup";
import { setupServerDeploy } from "../deployment/server-deploy-setup";
import { setupWebDeploy } from "../deployment/web-deploy-setup";
import {
detectProjectConfig,
isBetterTStackProject,
@@ -13,7 +19,11 @@ import { installDependencies } from "./install-dependencies";
import { setupDeploymentTemplates } from "./template-manager";
export async function addDeploymentToProject(
input: AddInput & { webDeploy: WebDeploy; suppressInstallMessage?: boolean },
input: AddInput & {
webDeploy?: WebDeploy;
serverDeploy?: ServerDeploy;
suppressInstallMessage?: boolean;
},
) {
try {
const projectDir = input.projectDir || process.cwd();
@@ -32,9 +42,18 @@ export async function addDeploymentToProject(
);
}
if (detectedConfig.webDeploy === input.webDeploy) {
if (input.webDeploy && detectedConfig.webDeploy === input.webDeploy) {
exitWithError(
`${input.webDeploy} deployment is already configured for this project.`,
`${input.webDeploy} web deployment is already configured for this project.`,
);
}
if (
input.serverDeploy &&
detectedConfig.serverDeploy === input.serverDeploy
) {
exitWithError(
`${input.serverDeploy} server deployment is already configured for this project.`,
);
}
@@ -56,19 +75,30 @@ export async function addDeploymentToProject(
install: input.install || false,
dbSetup: detectedConfig.dbSetup || "none",
api: detectedConfig.api || "none",
webDeploy: input.webDeploy,
webDeploy: input.webDeploy || detectedConfig.webDeploy || "none",
serverDeploy: input.serverDeploy || detectedConfig.serverDeploy || "none",
};
log.info(
pc.green(
`Adding ${input.webDeploy} deployment to ${config.frontend.join("/")}`,
),
);
if (input.webDeploy && input.webDeploy !== "none") {
log.info(
pc.green(
`Adding ${input.webDeploy} web deployment to ${config.frontend.join("/")}`,
),
);
}
if (input.serverDeploy && input.serverDeploy !== "none") {
log.info(pc.green(`Adding ${input.serverDeploy} server deployment`));
}
await setupDeploymentTemplates(projectDir, config);
await setupWebDeploy(config);
await setupServerDeploy(config);
await updateBtsConfig(projectDir, { webDeploy: input.webDeploy });
await updateBtsConfig(projectDir, {
webDeploy: input.webDeploy || config.webDeploy,
serverDeploy: input.serverDeploy || config.serverDeploy,
});
if (config.install) {
await installDependencies({

View File

@@ -0,0 +1,303 @@
import path from "node:path";
import fs from "fs-extra";
import type { AvailableDependencies } from "../../constants";
import type { Frontend, ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
async function addBackendWorkspaceDependency(
projectDir: string,
backendPackageName: string,
workspaceVersion: string,
) {
const pkgJsonPath = path.join(projectDir, "package.json");
try {
const pkgJson = await fs.readJson(pkgJsonPath);
if (!pkgJson.dependencies) {
pkgJson.dependencies = {};
}
pkgJson.dependencies[backendPackageName] = workspaceVersion;
await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
} catch (_error) {}
}
function getFrontendType(frontend: Frontend[]): {
hasReactWeb: boolean;
hasNuxtWeb: boolean;
hasSvelteWeb: boolean;
hasSolidWeb: boolean;
hasNative: boolean;
} {
const reactBasedFrontends = [
"tanstack-router",
"react-router",
"tanstack-start",
"next",
];
const nativeFrontends = ["native-nativewind", "native-unistyles"];
return {
hasReactWeb: frontend.some((f) => reactBasedFrontends.includes(f)),
hasNuxtWeb: frontend.includes("nuxt"),
hasSvelteWeb: frontend.includes("svelte"),
hasSolidWeb: frontend.includes("solid"),
hasNative: frontend.some((f) => nativeFrontends.includes(f)),
};
}
function getApiDependencies(
api: string,
frontendType: ReturnType<typeof getFrontendType>,
) {
const deps: Record<
string,
{ dependencies: string[]; devDependencies?: string[] }
> = {};
if (api === "orpc") {
deps.server = { dependencies: ["@orpc/server", "@orpc/client"] };
} else if (api === "trpc") {
deps.server = { dependencies: ["@trpc/server", "@trpc/client"] };
}
if (frontendType.hasReactWeb) {
if (api === "orpc") {
deps.web = { dependencies: ["@orpc/tanstack-query", "@orpc/client"] };
} else if (api === "trpc") {
deps.web = {
dependencies: [
"@trpc/tanstack-react-query",
"@trpc/client",
"@trpc/server",
],
};
}
} else if (frontendType.hasNuxtWeb && api === "orpc") {
deps.web = {
dependencies: [
"@tanstack/vue-query",
"@orpc/tanstack-query",
"@orpc/client",
],
devDependencies: ["@tanstack/vue-query-devtools"],
};
} else if (frontendType.hasSvelteWeb && api === "orpc") {
deps.web = {
dependencies: [
"@orpc/tanstack-query",
"@orpc/client",
"@tanstack/svelte-query",
],
devDependencies: ["@tanstack/svelte-query-devtools"],
};
} else if (frontendType.hasSolidWeb && api === "orpc") {
deps.web = {
dependencies: [
"@orpc/tanstack-query",
"@orpc/client",
"@tanstack/solid-query",
],
devDependencies: [
"@tanstack/solid-query-devtools",
"@tanstack/solid-router-devtools",
],
};
}
if (api === "trpc") {
deps.native = {
dependencies: [
"@trpc/tanstack-react-query",
"@trpc/client",
"@trpc/server",
],
};
} else if (api === "orpc") {
deps.native = { dependencies: ["@orpc/tanstack-query", "@orpc/client"] };
}
return deps;
}
function getQueryDependencies(frontend: Frontend[]) {
const reactBasedFrontends: Frontend[] = [
"react-router",
"tanstack-router",
"tanstack-start",
"next",
"native-nativewind",
"native-unistyles",
];
const deps: Record<
string,
{ dependencies: string[]; devDependencies?: string[] }
> = {};
const needsReactQuery = frontend.some((f) => reactBasedFrontends.includes(f));
if (needsReactQuery) {
const hasReactWeb = frontend.some(
(f) =>
f !== "native-nativewind" &&
f !== "native-unistyles" &&
reactBasedFrontends.includes(f),
);
const hasNative =
frontend.includes("native-nativewind") ||
frontend.includes("native-unistyles");
if (hasReactWeb) {
deps.web = {
dependencies: ["@tanstack/react-query"],
devDependencies: ["@tanstack/react-query-devtools"],
};
}
if (hasNative) {
deps.native = { dependencies: ["@tanstack/react-query"] };
}
}
if (frontend.includes("solid")) {
deps.web = {
dependencies: ["@tanstack/solid-query"],
devDependencies: [
"@tanstack/solid-query-devtools",
"@tanstack/solid-router-devtools",
],
};
}
return deps;
}
function getConvexDependencies(frontend: Frontend[]) {
const deps: Record<string, { dependencies: string[] }> = {
web: { dependencies: ["convex"] },
native: { dependencies: ["convex"] },
};
if (frontend.includes("tanstack-start")) {
deps.web.dependencies.push("@convex-dev/react-query");
}
if (frontend.includes("svelte")) {
deps.web.dependencies.push("convex-svelte");
}
if (frontend.includes("nuxt")) {
deps.web.dependencies.push("convex-nuxt", "convex-vue");
}
return deps;
}
export async function setupApi(config: ProjectConfig) {
const { api, projectName, frontend, backend, packageManager, projectDir } =
config;
const isConvex = backend === "convex";
const webDir = path.join(projectDir, "apps/web");
const nativeDir = path.join(projectDir, "apps/native");
const serverDir = path.join(projectDir, "apps/server");
const webDirExists = await fs.pathExists(webDir);
const nativeDirExists = await fs.pathExists(nativeDir);
const serverDirExists = await fs.pathExists(serverDir);
const frontendType = getFrontendType(frontend);
if (!isConvex && api !== "none") {
const apiDeps = getApiDependencies(api, frontendType);
if (serverDirExists && apiDeps.server) {
await addPackageDependency({
dependencies: apiDeps.server.dependencies as AvailableDependencies[],
projectDir: serverDir,
});
if (api === "trpc") {
if (backend === "hono") {
await addPackageDependency({
dependencies: ["@hono/trpc-server"],
projectDir: serverDir,
});
} else if (backend === "elysia") {
await addPackageDependency({
dependencies: ["@elysiajs/trpc"],
projectDir: serverDir,
});
}
}
}
if (webDirExists && apiDeps.web) {
await addPackageDependency({
dependencies: apiDeps.web.dependencies as AvailableDependencies[],
devDependencies: apiDeps.web.devDependencies as AvailableDependencies[],
projectDir: webDir,
});
}
if (nativeDirExists && apiDeps.native) {
await addPackageDependency({
dependencies: apiDeps.native.dependencies as AvailableDependencies[],
projectDir: nativeDir,
});
}
}
if (!isConvex) {
const queryDeps = getQueryDependencies(frontend);
if (webDirExists && queryDeps.web) {
await addPackageDependency({
dependencies: queryDeps.web.dependencies as AvailableDependencies[],
devDependencies: queryDeps.web
.devDependencies as AvailableDependencies[],
projectDir: webDir,
});
}
if (nativeDirExists && queryDeps.native) {
await addPackageDependency({
dependencies: queryDeps.native.dependencies as AvailableDependencies[],
projectDir: nativeDir,
});
}
}
if (isConvex) {
const convexDeps = getConvexDependencies(frontend);
if (webDirExists) {
await addPackageDependency({
dependencies: convexDeps.web.dependencies as AvailableDependencies[],
projectDir: webDir,
});
}
if (nativeDirExists) {
await addPackageDependency({
dependencies: convexDeps.native.dependencies as AvailableDependencies[],
projectDir: nativeDir,
});
}
const backendPackageName = `@${projectName}/backend`;
const backendWorkspaceVersion =
packageManager === "npm" ? "*" : "workspace:*";
if (webDirExists) {
await addBackendWorkspaceDependency(
webDir,
backendPackageName,
backendWorkspaceVersion,
);
}
if (nativeDirExists) {
await addBackendWorkspaceDependency(
nativeDir,
backendPackageName,
backendWorkspaceVersion,
);
}
}
}

View File

@@ -3,10 +3,11 @@ import { intro, log, outro } from "@clack/prompts";
import consola from "consola";
import fs from "fs-extra";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../../constants";
import { getDefaultConfig } from "../../constants";
import { getAddonsToAdd } from "../../prompts/addons";
import { gatherConfig } from "../../prompts/config-prompts";
import { getProjectName } from "../../prompts/project-name";
import { getServerDeploymentToAdd } from "../../prompts/server-deploy";
import { getDeploymentToAdd } from "../../prompts/web-deploy";
import type {
AddInput,
@@ -16,6 +17,7 @@ import type {
ProjectConfig,
} from "../../types";
import { trackProjectCreation } from "../../utils/analytics";
import { coerceBackendPresets } from "../../utils/compatibility-rules";
import { displayConfig } from "../../utils/display-config";
import { exitWithError, handleError } from "../../utils/errors";
import { generateReproducibleCommand } from "../../utils/generate-reproducible-command";
@@ -24,7 +26,12 @@ import {
setupProjectDirectory,
} from "../../utils/project-directory";
import { renderTitle } from "../../utils/render-title";
import { getProvidedFlags, processAndValidateFlags } from "../../validation";
import {
getProvidedFlags,
processAndValidateFlags,
processProvidedFlagsWithoutValidation,
validateConfigCompatibility,
} from "../../validation";
import { addAddonsToProject } from "./add-addons";
import { addDeploymentToProject } from "./add-deployment";
import { createProject } from "./create-project";
@@ -50,13 +57,14 @@ export async function createProjectHandler(
if (input.yes && input.projectName) {
currentPathInput = input.projectName;
} else if (input.yes) {
let defaultName = DEFAULT_CONFIG.relativePath;
const defaultConfig = getDefaultConfig();
let defaultName = defaultConfig.relativePath;
let counter = 1;
while (
fs.pathExistsSync(path.resolve(process.cwd(), defaultName)) &&
fs.readdirSync(path.resolve(process.cwd(), defaultName)).length > 0
(await fs.pathExists(path.resolve(process.cwd(), defaultName))) &&
(await fs.readdir(path.resolve(process.cwd(), defaultName))).length > 0
) {
defaultName = `${DEFAULT_CONFIG.projectName}-${counter}`;
defaultName = `${defaultConfig.projectName}-${counter}`;
counter++;
}
currentPathInput = defaultName;
@@ -102,6 +110,7 @@ export async function createProjectHandler(
dbSetup: "none",
api: "none",
webDeploy: "none",
serverDeploy: "none",
} satisfies ProjectConfig,
reproducibleCommand: "",
timeScaffolded,
@@ -124,29 +133,25 @@ export async function createProjectHandler(
const providedFlags = getProvidedFlags(cliInput);
const flagConfig = processAndValidateFlags(
cliInput,
providedFlags,
finalBaseName,
);
const { projectName: _projectNameFromFlags, ...otherFlags } = flagConfig;
if (!input.yes && Object.keys(otherFlags).length > 0) {
log.info(pc.yellow("Using these pre-selected options:"));
log.message(displayConfig(otherFlags));
log.message("");
}
let config: ProjectConfig;
if (input.yes) {
const flagConfig = processProvidedFlagsWithoutValidation(
cliInput,
finalBaseName,
);
config = {
...DEFAULT_CONFIG,
...getDefaultConfig(),
...flagConfig,
projectName: finalBaseName,
projectDir: finalResolvedPath,
relativePath: finalPathInput,
};
coerceBackendPresets(config);
validateConfigCompatibility(config, providedFlags, cliInput);
if (config.backend === "convex") {
log.info(
"Due to '--backend convex' flag, the following options have been automatically set: auth=false, database=none, orm=none, api=none, runtime=none, dbSetup=none, examples=todo",
@@ -161,6 +166,19 @@ export async function createProjectHandler(
log.message(displayConfig(config));
log.message("");
} else {
const flagConfig = processAndValidateFlags(
cliInput,
providedFlags,
finalBaseName,
);
const { projectName: _projectNameFromFlags, ...otherFlags } = flagConfig;
if (Object.keys(otherFlags).length > 0) {
log.info(pc.yellow("Using these pre-selected options:"));
log.message(displayConfig(otherFlags));
log.message("");
}
config = await gatherConfig(
flagConfig,
finalBaseName,
@@ -207,11 +225,11 @@ async function handleDirectoryConflictProgrammatically(
): Promise<{ finalPathInput: string; shouldClearDirectory: boolean }> {
const currentPath = path.resolve(process.cwd(), currentPathInput);
if (!fs.pathExistsSync(currentPath)) {
if (!(await fs.pathExists(currentPath))) {
return { finalPathInput: currentPathInput, shouldClearDirectory: false };
}
const dirContents = fs.readdirSync(currentPath);
const dirContents = await fs.readdir(currentPath);
const isNotEmpty = dirContents.length > 0;
if (!isNotEmpty) {
@@ -231,8 +249,9 @@ async function handleDirectoryConflictProgrammatically(
let finalPathInput = `${baseName}-${counter}`;
while (
fs.pathExistsSync(path.resolve(process.cwd(), finalPathInput)) &&
fs.readdirSync(path.resolve(process.cwd(), finalPathInput)).length > 0
(await fs.pathExists(path.resolve(process.cwd(), finalPathInput))) &&
(await fs.readdir(path.resolve(process.cwd(), finalPathInput))).length >
0
) {
counter++;
finalPathInput = `${baseName}-${counter}`;
@@ -284,6 +303,17 @@ export async function addAddonsHandler(input: AddInput) {
}
}
if (!input.serverDeploy) {
const serverDeploymentPrompt = await getServerDeploymentToAdd(
detectedConfig.runtime,
detectedConfig.serverDeploy,
);
if (serverDeploymentPrompt !== "none") {
input.serverDeploy = serverDeploymentPrompt;
}
}
const packageManager =
input.packageManager || detectedConfig.packageManager || "npm";
@@ -309,6 +339,16 @@ export async function addAddonsHandler(input: AddInput) {
somethingAdded = true;
}
if (input.serverDeploy && input.serverDeploy !== "none") {
await addDeploymentToProject({
...input,
install: false,
suppressInstallMessage: true,
serverDeploy: input.serverDeploy,
});
somethingAdded = true;
}
if (!somethingAdded) {
outro(pc.yellow("No addons or deployment configurations to add."));
return;

View File

@@ -0,0 +1,13 @@
import path from "node:path";
import { execa } from "execa";
import type { PackageManager } from "../../types";
import { getPackageExecutionCommand } from "../../utils/package-runner";
export async function runConvexCodegen(
projectDir: string,
packageManager: PackageManager | null | undefined,
) {
const backendDir = path.join(projectDir, "packages/backend");
const cmd = getPackageExecutionCommand(packageManager, "convex codegen");
await execa(cmd, { cwd: backendDir, shell: true });
}

View File

@@ -3,17 +3,17 @@ import fs from "fs-extra";
import type { ProjectConfig } from "../../types";
import { writeBtsConfig } from "../../utils/bts-config";
import { exitWithError } from "../../utils/errors";
import { setupAddons } from "../setup/addons-setup";
import { setupApi } from "../setup/api-setup";
import { setupAuth } from "../setup/auth-setup";
import { setupBackendDependencies } from "../setup/backend-setup";
import { setupDatabase } from "../setup/db-setup";
import { setupExamples } from "../setup/examples-setup";
import {
generateCloudflareWorkerTypes,
setupRuntime,
} from "../setup/runtime-setup";
import { setupWebDeploy } from "../setup/web-deploy-setup";
import { formatProjectWithBiome } from "../../utils/format-with-biome";
import { setupAddons } from "../addons/addons-setup";
import { setupAuth } from "../addons/auth-setup";
import { setupExamples } from "../addons/examples-setup";
import { setupApi } from "../core/api-setup";
import { setupBackendDependencies } from "../core/backend-setup";
import { setupDatabase } from "../core/db-setup";
import { setupRuntime } from "../core/runtime-setup";
import { setupServerDeploy } from "../deployment/server-deploy-setup";
import { setupWebDeploy } from "../deployment/web-deploy-setup";
import { runConvexCodegen } from "./convex-codegen";
import { createReadme } from "./create-readme";
import { setupEnvironmentVariables } from "./env-setup";
import { initializeGit } from "./git";
@@ -77,6 +77,7 @@ export async function createProject(options: ProjectConfig) {
await handleExtras(projectDir, options);
await setupWebDeploy(options);
await setupServerDeploy(options);
await setupEnvironmentVariables(options);
await updatePackageConfigurations(projectDir, options);
@@ -84,6 +85,12 @@ export async function createProject(options: ProjectConfig) {
await writeBtsConfig(options);
await formatProjectWithBiome(projectDir);
if (isConvex) {
await runConvexCodegen(projectDir, options.packageManager);
}
log.success("Project template successfully scaffolded!");
if (options.install) {
@@ -91,7 +98,6 @@ export async function createProject(options: ProjectConfig) {
projectDir,
packageManager: options.packageManager,
});
await generateCloudflareWorkerTypes(options);
}
await initializeGit(projectDir, options.git);

View File

@@ -24,6 +24,7 @@ export async function detectProjectConfig(
dbSetup: btsConfig.dbSetup,
api: btsConfig.api,
webDeploy: btsConfig.webDeploy,
serverDeploy: btsConfig.serverDeploy,
};
}

View File

@@ -1,7 +1,7 @@
import path from "node:path";
import fs from "fs-extra";
import type { ProjectConfig } from "../../types";
import { generateAuthSecret } from "../setup/auth-setup";
import { generateAuthSecret } from "../addons/auth-setup";
export interface EnvVariable {
key: string;
@@ -85,8 +85,17 @@ export async function addEnvVariablesToFile(
}
export async function setupEnvironmentVariables(config: ProjectConfig) {
const { backend, frontend, database, auth, examples, dbSetup, projectDir } =
config;
const {
backend,
frontend,
database,
auth,
examples,
dbSetup,
projectDir,
webDeploy,
serverDeploy,
} = config;
const hasReactRouter = frontend.includes("react-router");
const hasTanStackRouter = frontend.includes("tanstack-router");
@@ -239,10 +248,51 @@ export async function setupEnvironmentVariables(config: ProjectConfig) {
await addEnvVariablesToFile(envPath, serverVars);
if (config.runtime === "workers") {
const devVarsPath = path.join(serverDir, ".dev.vars");
try {
await fs.copy(envPath, devVarsPath);
} catch (_err) {}
const isUnifiedAlchemy =
webDeploy === "alchemy" && serverDeploy === "alchemy";
const isIndividualAlchemy =
webDeploy === "alchemy" || serverDeploy === "alchemy";
if (isUnifiedAlchemy) {
const rootEnvPath = path.join(projectDir, ".env");
const rootAlchemyVars: EnvVariable[] = [
{
key: "ALCHEMY_PASSWORD",
value: "please-change-this",
condition: true,
},
];
await addEnvVariablesToFile(rootEnvPath, rootAlchemyVars);
} else if (isIndividualAlchemy) {
if (webDeploy === "alchemy") {
const webDir = path.join(projectDir, "apps/web");
if (await fs.pathExists(webDir)) {
const webAlchemyVars: EnvVariable[] = [
{
key: "ALCHEMY_PASSWORD",
value: "please-change-this",
condition: true,
},
];
await addEnvVariablesToFile(path.join(webDir, ".env"), webAlchemyVars);
}
}
if (serverDeploy === "alchemy") {
const serverDir = path.join(projectDir, "apps/server");
if (await fs.pathExists(serverDir)) {
const serverAlchemyVars: EnvVariable[] = [
{
key: "ALCHEMY_PASSWORD",
value: "please-change-this",
condition: true,
},
];
await addEnvVariablesToFile(
path.join(serverDir, ".env"),
serverAlchemyVars,
);
}
}
}
}

View File

@@ -25,6 +25,7 @@ export async function displayPostInstallInstructions(
backend,
dbSetup,
webDeploy,
serverDeploy,
} = config;
const isConvex = backend === "convex";
@@ -35,7 +36,14 @@ export async function displayPostInstallInstructions(
const databaseInstructions =
!isConvex && database !== "none"
? await getDatabaseInstructions(database, orm, runCmd, runtime, dbSetup)
? await getDatabaseInstructions(
database,
orm,
runCmd,
runtime,
dbSetup,
serverDeploy,
)
: "";
const tauriInstructions = addons?.includes("tauri")
@@ -56,8 +64,16 @@ export async function displayPostInstallInstructions(
const starlightInstructions = addons?.includes("starlight")
? getStarlightInstructions(runCmd)
: "";
const workersDeployInstructions =
webDeploy === "workers" ? getWorkersDeployInstructions(runCmd) : "";
const wranglerDeployInstructions = getWranglerDeployInstructions(
runCmd,
webDeploy,
serverDeploy,
);
const alchemyDeployInstructions = getAlchemyDeployInstructions(
runCmd,
webDeploy,
serverDeploy,
);
const hasWeb = frontend?.some((f) =>
[
@@ -116,11 +132,9 @@ export async function displayPostInstallInstructions(
)} Complete D1 database setup first\n (see Database commands below)\n`;
}
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n`;
output += `${pc.cyan(
`${stepCounter++}.`,
)} cd apps/server && ${runCmd} run cf-typegen\n\n`;
} else {
output += "\n";
if (serverDeploy === "wrangler") {
output += `${pc.cyan(`${stepCounter++}.`)} cd apps/server && ${runCmd} cf-typegen\n`;
}
}
}
@@ -151,8 +165,10 @@ export async function displayPostInstallInstructions(
if (tauriInstructions) output += `\n${tauriInstructions.trim()}\n`;
if (lintingInstructions) output += `\n${lintingInstructions.trim()}\n`;
if (pwaInstructions) output += `\n${pwaInstructions.trim()}\n`;
if (workersDeployInstructions)
output += `\n${workersDeployInstructions.trim()}\n`;
if (wranglerDeployInstructions)
output += `\n${wranglerDeployInstructions.trim()}\n`;
if (alchemyDeployInstructions)
output += `\n${alchemyDeployInstructions.trim()}\n`;
if (starlightInstructions) output += `\n${starlightInstructions.trim()}\n`;
if (noOrmWarning) output += `\n${noOrmWarning.trim()}\n`;
@@ -202,10 +218,11 @@ async function getDatabaseInstructions(
database: Database,
orm?: ORM,
runCmd?: string,
runtime?: Runtime,
_runtime?: Runtime,
dbSetup?: DatabaseSetup,
serverDeploy?: string,
): Promise<string> {
const instructions = [];
const instructions: string[] = [];
if (dbSetup === "docker") {
const dockerStatus = await getDockerStatus(database);
@@ -216,7 +233,7 @@ async function getDatabaseInstructions(
}
}
if (runtime === "workers" && dbSetup === "d1") {
if (serverDeploy === "wrangler" && dbSetup === "d1") {
const packageManager = runCmd === "npm run" ? "npm" : runCmd || "npm";
instructions.push(
@@ -249,7 +266,9 @@ async function getDatabaseInstructions(
`${packageManager} wrangler d1 migrations apply YOUR_DB_NAME`,
)}`,
);
instructions.push("");
}
if (dbSetup === "d1" && serverDeploy === "alchemy") {
}
if (orm === "prisma") {
@@ -281,7 +300,9 @@ async function getDatabaseInstructions(
`${pc.cyan("•")} Start docker container: ${`${runCmd} db:start`}`,
);
}
instructions.push(`${pc.cyan("•")} Apply schema: ${`${runCmd} db:push`}`);
if (dbSetup !== "d1") {
instructions.push(`${pc.cyan("•")} Apply schema: ${`${runCmd} db:push`}`);
}
instructions.push(`${pc.cyan("•")} Database UI: ${`${runCmd} db:studio`}`);
if (database === "sqlite" && dbSetup !== "d1") {
instructions.push(
@@ -343,6 +364,47 @@ function getBunWebNativeWarning(): string {
)} 'bun' might cause issues with web + native apps in a monorepo.\n Use 'pnpm' if problems arise.`;
}
function getWorkersDeployInstructions(runCmd?: string): string {
return `\n${pc.bold("Deploy frontend to Cloudflare Workers:")}\n${pc.cyan("•")} Deploy: ${`cd apps/web && ${runCmd} run deploy`}`;
function getWranglerDeployInstructions(
runCmd?: string,
webDeploy?: string,
serverDeploy?: string,
): string {
const instructions: string[] = [];
if (webDeploy === "wrangler") {
instructions.push(
`${pc.bold("Deploy web to Cloudflare Workers:")}\n${pc.cyan("•")} Deploy: ${`cd apps/web && ${runCmd} run deploy`}`,
);
}
if (serverDeploy === "wrangler") {
instructions.push(
`${pc.bold("Deploy server to Cloudflare Workers:")}\n${pc.cyan("•")} Deploy: ${`cd apps/server && ${runCmd} run deploy`}`,
);
}
return instructions.length ? `\n${instructions.join("\n")}` : "";
}
function getAlchemyDeployInstructions(
runCmd?: string,
webDeploy?: string,
serverDeploy?: string,
): string {
const instructions: string[] = [];
if (webDeploy === "alchemy" && serverDeploy !== "alchemy") {
instructions.push(
`${pc.bold("Deploy web to Alchemy:")}\n${pc.cyan("•")} Deploy: ${`cd apps/web && ${runCmd} deploy`}`,
);
} else if (serverDeploy === "alchemy" && webDeploy !== "alchemy") {
instructions.push(
`${pc.bold("Deploy server to Alchemy:")}\n${pc.cyan("•")} Deploy: ${`cd apps/server && ${runCmd} deploy`}`,
);
} else if (webDeploy === "alchemy" && serverDeploy === "alchemy") {
instructions.push(
`${pc.bold("Deploy to Alchemy:")}\n${pc.cyan("•")} Deploy: ${`${runCmd} deploy`}`,
);
}
return instructions.length ? `\n${instructions.join("\n")}` : "";
}

View File

@@ -1,8 +1,5 @@
import path from "node:path";
import { spinner } from "@clack/prompts";
import { execa } from "execa";
import fs from "fs-extra";
import pc from "picocolors";
import type { Backend, ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
@@ -23,43 +20,6 @@ export async function setupRuntime(config: ProjectConfig) {
await setupBunRuntime(serverDir, backend);
} else if (runtime === "node") {
await setupNodeRuntime(serverDir, backend);
} else if (runtime === "workers") {
await setupWorkersRuntime(serverDir);
}
}
export async function generateCloudflareWorkerTypes(config: ProjectConfig) {
if (config.runtime !== "workers") {
return;
}
const serverDir = path.join(config.projectDir, "apps/server");
if (!(await fs.pathExists(serverDir))) {
return;
}
const s = spinner();
try {
s.start("Generating Cloudflare Workers types...");
const runCmd =
config.packageManager === "npm" ? "npm" : config.packageManager;
await execa(runCmd, ["run", "cf-typegen"], {
cwd: serverDir,
});
s.stop("Cloudflare Workers types generated successfully!");
} catch {
s.stop(pc.yellow("Failed to generate Cloudflare Workers types"));
const managerCmd =
config.packageManager === "npm"
? "npm run"
: `${config.packageManager} run`;
console.warn(
`Note: You can manually run 'cd apps/server && ${managerCmd} cf-typegen' in the project directory later`,
);
}
}
@@ -114,26 +74,3 @@ async function setupNodeRuntime(serverDir: string, backend: Backend) {
});
}
}
async function setupWorkersRuntime(serverDir: string) {
const packageJsonPath = path.join(serverDir, "package.json");
if (!(await fs.pathExists(packageJsonPath))) return;
const packageJson = await fs.readJson(packageJsonPath);
packageJson.scripts = {
...packageJson.scripts,
dev: "wrangler dev --port=3000",
start: "wrangler dev",
deploy: "wrangler deploy",
build: "wrangler deploy --dry-run",
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
};
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
await addPackageDependency({
devDependencies: ["wrangler", "@types/node"],
projectDir: serverDir,
});
}

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import fs from "fs-extra";
import { globby } from "globby";
import { glob } from "tinyglobby";
import { PKG_ROOT } from "../../constants";
import type { ProjectConfig } from "../../types";
import { processTemplate } from "../../utils/template-processor";
@@ -13,7 +13,7 @@ export async function processAndCopyFiles(
overwrite = true,
ignorePatterns?: string[],
) {
const sourceFiles = await globby(sourcePattern, {
const sourceFiles = await glob(sourcePattern, {
cwd: baseSourceDir,
dot: true,
onlyFiles: true,
@@ -788,19 +788,6 @@ export async function handleExtras(projectDir: string, context: ProjectConfig) {
await processAndCopyFiles("_npmrc.hbs", extrasDir, projectDir, context);
}
}
if (context.runtime === "workers") {
const runtimeWorkersDir = path.join(PKG_ROOT, "templates/runtime/workers");
if (await fs.pathExists(runtimeWorkersDir)) {
await processAndCopyFiles(
"**/*",
runtimeWorkersDir,
projectDir,
context,
false,
);
}
}
}
export async function setupDockerComposeTemplates(
@@ -827,43 +814,137 @@ export async function setupDeploymentTemplates(
projectDir: string,
context: ProjectConfig,
) {
if (context.webDeploy === "none") {
return;
}
if (context.webDeploy === "workers") {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) {
return;
}
const frontends = context.frontend;
const templateMap: Record<string, string> = {
"tanstack-router": "react/tanstack-router",
"tanstack-start": "react/tanstack-start",
"react-router": "react/react-router",
solid: "solid",
next: "react/next",
nuxt: "nuxt",
svelte: "svelte",
};
for (const f of frontends) {
if (templateMap[f]) {
const deployTemplateSrc = path.join(
PKG_ROOT,
`templates/deploy/web/${templateMap[f]}`,
if (context.webDeploy === "alchemy" || context.serverDeploy === "alchemy") {
if (context.webDeploy === "alchemy" && context.serverDeploy === "alchemy") {
const alchemyTemplateSrc = path.join(
PKG_ROOT,
"templates/deploy/alchemy",
);
if (await fs.pathExists(alchemyTemplateSrc)) {
await processAndCopyFiles(
"alchemy.run.ts.hbs",
alchemyTemplateSrc,
projectDir,
context,
);
if (await fs.pathExists(deployTemplateSrc)) {
const serverAppDir = path.join(projectDir, "apps/server");
if (await fs.pathExists(serverAppDir)) {
await processAndCopyFiles(
"**/*",
deployTemplateSrc,
"env.d.ts.hbs",
alchemyTemplateSrc,
serverAppDir,
context,
);
await processAndCopyFiles(
"wrangler.jsonc.hbs",
alchemyTemplateSrc,
serverAppDir,
context,
);
}
}
} else {
if (context.webDeploy === "alchemy") {
const alchemyTemplateSrc = path.join(
PKG_ROOT,
"templates/deploy/alchemy",
);
const webAppDir = path.join(projectDir, "apps/web");
if (
(await fs.pathExists(alchemyTemplateSrc)) &&
(await fs.pathExists(webAppDir))
) {
await processAndCopyFiles(
"alchemy.run.ts.hbs",
alchemyTemplateSrc,
webAppDir,
context,
);
}
}
if (context.serverDeploy === "alchemy") {
const alchemyTemplateSrc = path.join(
PKG_ROOT,
"templates/deploy/alchemy",
);
const serverAppDir = path.join(projectDir, "apps/server");
if (
(await fs.pathExists(alchemyTemplateSrc)) &&
(await fs.pathExists(serverAppDir))
) {
await processAndCopyFiles(
"alchemy.run.ts.hbs",
alchemyTemplateSrc,
serverAppDir,
context,
);
await processAndCopyFiles(
"env.d.ts.hbs",
alchemyTemplateSrc,
serverAppDir,
context,
);
await processAndCopyFiles(
"wrangler.jsonc.hbs",
alchemyTemplateSrc,
serverAppDir,
context,
);
}
}
}
}
if (context.webDeploy !== "none" && context.webDeploy !== "alchemy") {
const webAppDir = path.join(projectDir, "apps/web");
if (await fs.pathExists(webAppDir)) {
const frontends = context.frontend;
const templateMap: Record<string, string> = {
"tanstack-router": "react/tanstack-router",
"tanstack-start": "react/tanstack-start",
"react-router": "react/react-router",
solid: "solid",
next: "react/next",
nuxt: "nuxt",
svelte: "svelte",
};
for (const f of frontends) {
if (templateMap[f]) {
const deployTemplateSrc = path.join(
PKG_ROOT,
`templates/deploy/${context.webDeploy}/web/${templateMap[f]}`,
);
if (await fs.pathExists(deployTemplateSrc)) {
await processAndCopyFiles(
"**/*",
deployTemplateSrc,
webAppDir,
context,
);
}
}
}
}
}
if (context.serverDeploy !== "none" && context.serverDeploy !== "alchemy") {
const serverAppDir = path.join(projectDir, "apps/server");
if (await fs.pathExists(serverAppDir)) {
const deployTemplateSrc = path.join(
PKG_ROOT,
`templates/deploy/${context.serverDeploy}/server`,
);
if (await fs.pathExists(deployTemplateSrc)) {
await processAndCopyFiles(
"**/*",
deployTemplateSrc,
serverAppDir,
context,
);
}
}
}
}

View File

@@ -1,34 +1,33 @@
import path from "node:path";
import type { ProjectConfig } from "../../types";
import {
addEnvVariablesToFile,
type EnvVariable,
} from "../project-generation/env-setup";
import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup";
export async function setupCloudflareD1(config: ProjectConfig) {
const { projectDir } = config;
const { projectDir, serverDeploy } = config;
const envPath = path.join(projectDir, "apps/server", ".env");
if (serverDeploy === "wrangler") {
const envPath = path.join(projectDir, "apps/server", ".env");
const variables: EnvVariable[] = [
{
key: "CLOUDFLARE_ACCOUNT_ID",
value: "",
condition: true,
},
{
key: "CLOUDFLARE_DATABASE_ID",
value: "",
condition: true,
},
{
key: "CLOUDFLARE_D1_TOKEN",
value: "",
condition: true,
},
];
const variables: EnvVariable[] = [
{
key: "CLOUDFLARE_ACCOUNT_ID",
value: "",
condition: true,
},
{
key: "CLOUDFLARE_DATABASE_ID",
value: "",
condition: true,
},
{
key: "CLOUDFLARE_D1_TOKEN",
value: "",
condition: true,
},
];
try {
await addEnvVariablesToFile(envPath, variables);
} catch (_err) {}
try {
await addEnvVariablesToFile(envPath, variables);
} catch (_err) {}
}
}

View File

@@ -1,9 +1,6 @@
import path from "node:path";
import type { Database, ProjectConfig } from "../../types";
import {
addEnvVariablesToFile,
type EnvVariable,
} from "../project-generation/env-setup";
import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup";
export async function setupDockerCompose(config: ProjectConfig) {
const { database, projectDir, projectName } = config;

View File

@@ -6,10 +6,7 @@ import fs from "fs-extra";
import pc from "picocolors";
import type { ProjectConfig } from "../../types";
import { commandExists } from "../../utils/command-exists";
import {
addEnvVariablesToFile,
type EnvVariable,
} from "../project-generation/env-setup";
import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup";
type MongoDBConfig = {
connectionString: string;

View File

@@ -7,10 +7,7 @@ import pc from "picocolors";
import type { PackageManager, ProjectConfig } from "../../types";
import { exitCancelled } from "../../utils/errors";
import { getPackageExecutionCommand } from "../../utils/package-runner";
import {
addEnvVariablesToFile,
type EnvVariable,
} from "../project-generation/env-setup";
import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup";
type NeonConfig = {
connectionString: string;

View File

@@ -8,10 +8,7 @@ import type { ORM, PackageManager, ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
import { exitCancelled } from "../../utils/errors";
import { getPackageExecutionCommand } from "../../utils/package-runner";
import {
addEnvVariablesToFile,
type EnvVariable,
} from "../project-generation/env-setup";
import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup";
type PrismaConfig = {
databaseUrl: string;
@@ -253,9 +250,8 @@ export async function setupPrismaPostgres(config: ProjectConfig) {
if (prismaConfig) {
await writeEnvFile(projectDir, prismaConfig);
await addDotenvImportToPrismaConfig(projectDir);
if (orm === "prisma") {
await addDotenvImportToPrismaConfig(projectDir);
await addPrismaAccelerateExtension(serverDir);
}
log.success(

View File

@@ -6,10 +6,7 @@ import fs from "fs-extra";
import pc from "picocolors";
import type { PackageManager, ProjectConfig } from "../../types";
import { getPackageExecutionCommand } from "../../utils/package-runner";
import {
addEnvVariablesToFile,
type EnvVariable,
} from "../project-generation/env-setup";
import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup";
async function writeSupabaseEnvFile(projectDir: string, databaseUrl: string) {
try {

View File

@@ -7,10 +7,7 @@ import pc from "picocolors";
import type { ProjectConfig } from "../../types";
import { commandExists } from "../../utils/command-exists";
import { exitCancelled } from "../../utils/errors";
import {
addEnvVariablesToFile,
type EnvVariable,
} from "../project-generation/env-setup";
import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup";
type TursoConfig = {
dbUrl: string;

View File

@@ -0,0 +1,66 @@
import path from "node:path";
import fs from "fs-extra";
import type { PackageManager, ProjectConfig } from "../../../types";
import { addPackageDependency } from "../../../utils/add-package-deps";
import { setupAlchemyServerDeploy } from "../server-deploy-setup";
import { setupNextAlchemyDeploy } from "./alchemy-next-setup";
import { setupNuxtAlchemyDeploy } from "./alchemy-nuxt-setup";
import { setupReactRouterAlchemyDeploy } from "./alchemy-react-router-setup";
import { setupSolidAlchemyDeploy } from "./alchemy-solid-setup";
import { setupSvelteAlchemyDeploy } from "./alchemy-svelte-setup";
import { setupTanStackRouterAlchemyDeploy } from "./alchemy-tanstack-router-setup";
import { setupTanStackStartAlchemyDeploy } from "./alchemy-tanstack-start-setup";
export async function setupCombinedAlchemyDeploy(
projectDir: string,
packageManager: PackageManager,
config: ProjectConfig,
) {
await addPackageDependency({
devDependencies: ["alchemy", "dotenv"],
projectDir,
});
const rootPkgPath = path.join(projectDir, "package.json");
if (await fs.pathExists(rootPkgPath)) {
const pkg = await fs.readJson(rootPkgPath);
pkg.scripts = {
...pkg.scripts,
deploy: "alchemy deploy",
destroy: "alchemy destroy",
"alchemy:dev": "alchemy dev",
};
await fs.writeJson(rootPkgPath, pkg, { spaces: 2 });
}
const serverDir = path.join(projectDir, "apps/server");
if (await fs.pathExists(serverDir)) {
await setupAlchemyServerDeploy(serverDir, packageManager);
}
const frontend = config.frontend;
const isNext = frontend.includes("next");
const isNuxt = frontend.includes("nuxt");
const isSvelte = frontend.includes("svelte");
const isTanstackRouter = frontend.includes("tanstack-router");
const isTanstackStart = frontend.includes("tanstack-start");
const isReactRouter = frontend.includes("react-router");
const isSolid = frontend.includes("solid");
if (isNext) {
await setupNextAlchemyDeploy(projectDir, packageManager);
} else if (isNuxt) {
await setupNuxtAlchemyDeploy(projectDir, packageManager);
} else if (isSvelte) {
await setupSvelteAlchemyDeploy(projectDir, packageManager);
} else if (isTanstackStart) {
await setupTanStackStartAlchemyDeploy(projectDir, packageManager);
} else if (isTanstackRouter) {
await setupTanStackRouterAlchemyDeploy(projectDir, packageManager);
} else if (isReactRouter) {
await setupReactRouterAlchemyDeploy(projectDir, packageManager);
} else if (isSolid) {
await setupSolidAlchemyDeploy(projectDir, packageManager);
}
}

View File

@@ -0,0 +1,30 @@
import path from "node:path";
import fs from "fs-extra";
import type { PackageManager } from "../../../types";
import { addPackageDependency } from "../../../utils/add-package-deps";
export async function setupNextAlchemyDeploy(
projectDir: string,
_packageManager: PackageManager,
) {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return;
await addPackageDependency({
devDependencies: ["alchemy", "dotenv"],
projectDir: webAppDir,
});
const pkgPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(pkgPath)) {
const pkg = await fs.readJson(pkgPath);
pkg.scripts = {
...pkg.scripts,
deploy: "alchemy deploy",
destroy: "alchemy destroy",
"alchemy:dev": "alchemy dev",
};
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
}
}

View File

@@ -0,0 +1,104 @@
import path from "node:path";
import fs from "fs-extra";
import { IndentationText, Node, Project, QuoteKind } from "ts-morph";
import type { PackageManager } from "../../../types";
import { addPackageDependency } from "../../../utils/add-package-deps";
export async function setupNuxtAlchemyDeploy(
projectDir: string,
_packageManager: PackageManager,
) {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return;
await addPackageDependency({
devDependencies: ["alchemy", "nitro-cloudflare-dev", "dotenv"],
projectDir: webAppDir,
});
const pkgPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(pkgPath)) {
const pkg = await fs.readJson(pkgPath);
pkg.scripts = {
...pkg.scripts,
deploy: "alchemy deploy",
destroy: "alchemy destroy",
"alchemy:dev": "alchemy dev",
};
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
}
const nuxtConfigPath = path.join(webAppDir, "nuxt.config.ts");
if (!(await fs.pathExists(nuxtConfigPath))) return;
try {
const project = new Project({
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
quoteKind: QuoteKind.Double,
},
});
project.addSourceFileAtPath(nuxtConfigPath);
const sourceFile = project.getSourceFileOrThrow(nuxtConfigPath);
const exportAssignment = sourceFile.getExportAssignment(
(d) => !d.isExportEquals(),
);
if (!exportAssignment) return;
const defineConfigCall = exportAssignment.getExpression();
if (
!Node.isCallExpression(defineConfigCall) ||
defineConfigCall.getExpression().getText() !== "defineNuxtConfig"
)
return;
let configObject = defineConfigCall.getArguments()[0];
if (!configObject) {
configObject = defineConfigCall.addArgument("{}");
}
if (Node.isObjectLiteralExpression(configObject)) {
if (!configObject.getProperty("nitro")) {
configObject.addPropertyAssignment({
name: "nitro",
initializer: `{
preset: "cloudflare_module",
cloudflare: {
deployConfig: true,
nodeCompat: true
}
}`,
});
}
const modulesProperty = configObject.getProperty("modules");
if (modulesProperty && Node.isPropertyAssignment(modulesProperty)) {
const initializer = modulesProperty.getInitializer();
if (Node.isArrayLiteralExpression(initializer)) {
const hasModule = initializer
.getElements()
.some(
(el) =>
el.getText() === '"nitro-cloudflare-dev"' ||
el.getText() === "'nitro-cloudflare-dev'",
);
if (!hasModule) {
initializer.addElement('"nitro-cloudflare-dev"');
}
}
} else if (!modulesProperty) {
configObject.addPropertyAssignment({
name: "modules",
initializer: '["nitro-cloudflare-dev"]',
});
}
}
await project.save();
} catch (error) {
console.warn("Failed to update nuxt.config.ts:", error);
}
}

View File

@@ -0,0 +1,168 @@
import path from "node:path";
import fs from "fs-extra";
import { IndentationText, Node, Project, QuoteKind } from "ts-morph";
import type { PackageManager } from "../../../types";
import { addPackageDependency } from "../../../utils/add-package-deps";
export async function setupReactRouterAlchemyDeploy(
projectDir: string,
_packageManager: PackageManager,
) {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return;
await addPackageDependency({
devDependencies: ["alchemy", "@cloudflare/vite-plugin", "dotenv"],
projectDir: webAppDir,
});
const pkgPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(pkgPath)) {
const pkg = await fs.readJson(pkgPath);
pkg.scripts = {
...pkg.scripts,
deploy: "alchemy deploy",
destroy: "alchemy destroy",
"alchemy:dev": "alchemy dev",
};
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
}
const viteConfigPath = path.join(webAppDir, "vite.config.ts");
if (await fs.pathExists(viteConfigPath)) {
try {
const project = new Project({
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
quoteKind: QuoteKind.Double,
},
});
project.addSourceFileAtPath(viteConfigPath);
const sourceFile = project.getSourceFileOrThrow(viteConfigPath);
const alchemyImport = sourceFile.getImportDeclaration(
"alchemy/cloudflare/react-router",
);
if (!alchemyImport) {
sourceFile.addImportDeclaration({
moduleSpecifier: "alchemy/cloudflare/react-router",
defaultImport: "alchemy",
});
}
const exportAssignment = sourceFile.getExportAssignment(
(d) => !d.isExportEquals(),
);
if (!exportAssignment) return;
const defineConfigCall = exportAssignment.getExpression();
if (
!Node.isCallExpression(defineConfigCall) ||
defineConfigCall.getExpression().getText() !== "defineConfig"
)
return;
let configObject = defineConfigCall.getArguments()[0];
if (!configObject) {
configObject = defineConfigCall.addArgument("{}");
}
if (Node.isObjectLiteralExpression(configObject)) {
const pluginsProperty = configObject.getProperty("plugins");
if (pluginsProperty && Node.isPropertyAssignment(pluginsProperty)) {
const initializer = pluginsProperty.getInitializer();
if (Node.isArrayLiteralExpression(initializer)) {
const hasCloudflarePlugin = initializer
.getElements()
.some((el) => el.getText().includes("cloudflare("));
if (!hasCloudflarePlugin) {
initializer.addElement("alchemy()");
}
}
} else if (!pluginsProperty) {
configObject.addPropertyAssignment({
name: "plugins",
initializer: "[alchemy()]",
});
}
}
await project.save();
} catch (error) {
console.warn("Failed to update vite.config.ts:", error);
}
}
const reactRouterConfigPath = path.join(webAppDir, "react-router.config.ts");
if (await fs.pathExists(reactRouterConfigPath)) {
try {
const project = new Project({
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
quoteKind: QuoteKind.Double,
},
});
project.addSourceFileAtPath(reactRouterConfigPath);
const sourceFile = project.getSourceFileOrThrow(reactRouterConfigPath);
const exportAssignment = sourceFile.getExportAssignment(
(d) => !d.isExportEquals(),
);
if (!exportAssignment) return;
const configExpression = exportAssignment.getExpression();
let configObject: Node | undefined;
if (Node.isObjectLiteralExpression(configExpression)) {
configObject = configExpression;
} else if (Node.isSatisfiesExpression(configExpression)) {
const expression = configExpression.getExpression();
if (Node.isObjectLiteralExpression(expression)) {
configObject = expression;
}
}
if (!configObject || !Node.isObjectLiteralExpression(configObject))
return;
const futureProperty = configObject.getProperty("future");
if (!futureProperty) {
configObject.addPropertyAssignment({
name: "future",
initializer: `{
unstable_viteEnvironmentApi: true,
}`,
});
} else if (Node.isPropertyAssignment(futureProperty)) {
const futureInitializer = futureProperty.getInitializer();
if (Node.isObjectLiteralExpression(futureInitializer)) {
const viteEnvApiProp = futureInitializer.getProperty(
"unstable_viteEnvironmentApi",
);
if (!viteEnvApiProp) {
futureInitializer.addPropertyAssignment({
name: "unstable_viteEnvironmentApi",
initializer: "true",
});
} else if (Node.isPropertyAssignment(viteEnvApiProp)) {
const value = viteEnvApiProp.getInitializer()?.getText();
if (value === "false") {
viteEnvApiProp.setInitializer("true");
}
}
}
}
await project.save();
} catch (error) {
console.warn("Failed to update react-router.config.ts:", error);
}
}
}

View File

@@ -0,0 +1,30 @@
import path from "node:path";
import fs from "fs-extra";
import type { PackageManager } from "../../../types";
import { addPackageDependency } from "../../../utils/add-package-deps";
export async function setupSolidAlchemyDeploy(
projectDir: string,
_packageManager: PackageManager,
) {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return;
await addPackageDependency({
devDependencies: ["alchemy", "dotenv"],
projectDir: webAppDir,
});
const pkgPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(pkgPath)) {
const pkg = await fs.readJson(pkgPath);
pkg.scripts = {
...pkg.scripts,
deploy: "alchemy deploy",
destroy: "alchemy destroy",
"alchemy:dev": "alchemy dev",
};
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
}
}

View File

@@ -0,0 +1,98 @@
import path from "node:path";
import fs from "fs-extra";
import { IndentationText, Node, Project, QuoteKind } from "ts-morph";
import type { PackageManager } from "../../../types";
import { addPackageDependency } from "../../../utils/add-package-deps";
export async function setupSvelteAlchemyDeploy(
projectDir: string,
_packageManager: PackageManager,
) {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return;
await addPackageDependency({
devDependencies: ["alchemy", "@sveltejs/adapter-cloudflare", "dotenv"],
projectDir: webAppDir,
});
const pkgPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(pkgPath)) {
const pkg = await fs.readJson(pkgPath);
pkg.scripts = {
...pkg.scripts,
deploy: "alchemy deploy",
destroy: "alchemy destroy",
"alchemy:dev": "alchemy dev",
};
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
}
const svelteConfigPath = path.join(webAppDir, "svelte.config.js");
if (!(await fs.pathExists(svelteConfigPath))) return;
try {
const project = new Project({
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
quoteKind: QuoteKind.Single,
},
});
project.addSourceFileAtPath(svelteConfigPath);
const sourceFile = project.getSourceFileOrThrow(svelteConfigPath);
const importDeclarations = sourceFile.getImportDeclarations();
const adapterImport = importDeclarations.find((imp) =>
imp.getModuleSpecifierValue().includes("@sveltejs/adapter"),
);
if (adapterImport) {
adapterImport.setModuleSpecifier("alchemy/cloudflare/sveltekit");
adapterImport.removeDefaultImport();
adapterImport.setDefaultImport("alchemy");
} else {
sourceFile.insertImportDeclaration(0, {
moduleSpecifier: "alchemy/cloudflare/sveltekit",
defaultImport: "alchemy",
});
}
const configVariable = sourceFile.getVariableDeclaration("config");
if (configVariable) {
const initializer = configVariable.getInitializer();
if (Node.isObjectLiteralExpression(initializer)) {
updateAdapterInConfig(initializer);
}
}
await project.save();
} catch (error) {
console.warn("Failed to update svelte.config.js:", error);
}
}
function updateAdapterInConfig(configObject: Node): void {
if (!Node.isObjectLiteralExpression(configObject)) return;
const kitProperty = configObject.getProperty("kit");
if (kitProperty && Node.isPropertyAssignment(kitProperty)) {
const kitInitializer = kitProperty.getInitializer();
if (Node.isObjectLiteralExpression(kitInitializer)) {
const adapterProperty = kitInitializer.getProperty("adapter");
if (adapterProperty && Node.isPropertyAssignment(adapterProperty)) {
const initializer = adapterProperty.getInitializer();
if (Node.isCallExpression(initializer)) {
const expression = initializer.getExpression();
if (
Node.isIdentifier(expression) &&
expression.getText() === "adapter"
) {
expression.replaceWithText("alchemy");
}
}
}
}
}
}

View File

@@ -0,0 +1,30 @@
import path from "node:path";
import fs from "fs-extra";
import type { PackageManager } from "../../../types";
import { addPackageDependency } from "../../../utils/add-package-deps";
export async function setupTanStackRouterAlchemyDeploy(
projectDir: string,
_packageManager: PackageManager,
) {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return;
await addPackageDependency({
devDependencies: ["alchemy", "dotenv"],
projectDir: webAppDir,
});
const pkgPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(pkgPath)) {
const pkg = await fs.readJson(pkgPath);
pkg.scripts = {
...pkg.scripts,
deploy: "alchemy deploy",
destroy: "alchemy destroy",
"alchemy:dev": "alchemy dev",
};
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
}
}

View File

@@ -0,0 +1,158 @@
import path from "node:path";
import fs from "fs-extra";
import { IndentationText, Node, Project, QuoteKind } from "ts-morph";
import type { PackageManager } from "../../../types";
import { addPackageDependency } from "../../../utils/add-package-deps";
export async function setupTanStackStartAlchemyDeploy(
projectDir: string,
_packageManager: PackageManager,
) {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return;
await addPackageDependency({
devDependencies: ["alchemy", "nitropack", "dotenv"],
projectDir: webAppDir,
});
const pkgPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(pkgPath)) {
const pkg = await fs.readJson(pkgPath);
pkg.scripts = {
...pkg.scripts,
deploy: "alchemy deploy",
destroy: "alchemy destroy",
"alchemy:dev": "alchemy dev",
};
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
}
const viteConfigPath = path.join(webAppDir, "vite.config.ts");
if (await fs.pathExists(viteConfigPath)) {
try {
const project = new Project({
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
quoteKind: QuoteKind.Double,
},
});
project.addSourceFileAtPath(viteConfigPath);
const sourceFile = project.getSourceFileOrThrow(viteConfigPath);
const alchemyImport = sourceFile.getImportDeclaration(
"alchemy/cloudflare/tanstack-start",
);
if (!alchemyImport) {
sourceFile.addImportDeclaration({
moduleSpecifier: "alchemy/cloudflare/tanstack-start",
defaultImport: "alchemy",
});
} else {
alchemyImport.setModuleSpecifier("alchemy/cloudflare/tanstack-start");
}
const exportAssignment = sourceFile.getExportAssignment(
(d) => !d.isExportEquals(),
);
if (!exportAssignment) return;
const defineConfigCall = exportAssignment.getExpression();
if (
!Node.isCallExpression(defineConfigCall) ||
defineConfigCall.getExpression().getText() !== "defineConfig"
)
return;
let configObject = defineConfigCall.getArguments()[0];
if (!configObject) {
configObject = defineConfigCall.addArgument("{}");
}
if (Node.isObjectLiteralExpression(configObject)) {
if (!configObject.getProperty("build")) {
configObject.addPropertyAssignment({
name: "build",
initializer: `{
target: "esnext",
rollupOptions: {
external: ["node:async_hooks", "cloudflare:workers"],
},
}`,
});
}
const pluginsProperty = configObject.getProperty("plugins");
if (pluginsProperty && Node.isPropertyAssignment(pluginsProperty)) {
const initializer = pluginsProperty.getInitializer();
if (Node.isArrayLiteralExpression(initializer)) {
const hasShim = initializer
.getElements()
.some((el) => el.getText().includes("alchemy"));
if (!hasShim) {
initializer.addElement("alchemy()");
}
const tanstackElements = initializer
.getElements()
.filter((el) => el.getText().includes("tanstackStart"));
tanstackElements.forEach((element) => {
if (Node.isCallExpression(element)) {
const args = element.getArguments();
if (args.length === 0) {
element.addArgument(`{
target: "cloudflare-module",
customViteReactPlugin: true,
}`);
} else if (
args.length === 1 &&
Node.isObjectLiteralExpression(args[0])
) {
const configObj = args[0];
if (!configObj.getProperty("target")) {
configObj.addPropertyAssignment({
name: "target",
initializer: '"cloudflare-module"',
});
}
if (!configObj.getProperty("customViteReactPlugin")) {
configObj.addPropertyAssignment({
name: "customViteReactPlugin",
initializer: "true",
});
}
}
}
});
}
} else {
configObject.addPropertyAssignment({
name: "plugins",
initializer: "[alchemy()]",
});
}
}
await project.save();
} catch (error) {
console.warn("Failed to update vite.config.ts:", error);
}
}
// workaround for tanstack start + workers
const nitroConfigPath = path.join(webAppDir, "nitro.config.ts");
const nitroConfigContent = `import { defineNitroConfig } from "nitropack/config";
export default defineNitroConfig({
preset: "cloudflare-module",
cloudflare: {
nodeCompat: true,
},
});
`;
await fs.writeFile(nitroConfigPath, nitroConfigContent, "utf-8");
}

View File

@@ -0,0 +1,7 @@
export { setupNextAlchemyDeploy } from "./alchemy-next-setup";
export { setupNuxtAlchemyDeploy } from "./alchemy-nuxt-setup";
export { setupReactRouterAlchemyDeploy } from "./alchemy-react-router-setup";
export { setupSolidAlchemyDeploy } from "./alchemy-solid-setup";
export { setupSvelteAlchemyDeploy } from "./alchemy-svelte-setup";
export { setupTanStackRouterAlchemyDeploy } from "./alchemy-tanstack-router-setup";
export { setupTanStackStartAlchemyDeploy } from "./alchemy-tanstack-start-setup";

View File

@@ -0,0 +1,111 @@
import path from "node:path";
import { log, spinner } from "@clack/prompts";
import { execa } from "execa";
import fs from "fs-extra";
import pc from "picocolors";
import type { PackageManager, ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
export async function setupServerDeploy(config: ProjectConfig) {
const { serverDeploy, webDeploy, projectDir } = config;
const { packageManager } = config;
if (serverDeploy === "none") return;
if (serverDeploy === "alchemy" && webDeploy === "alchemy") {
return;
}
const serverDir = path.join(projectDir, "apps/server");
if (!(await fs.pathExists(serverDir))) return;
if (serverDeploy === "wrangler") {
await setupWorkersServerDeploy(serverDir, packageManager);
await generateCloudflareWorkerTypes({ serverDir, packageManager });
} else if (serverDeploy === "alchemy") {
await setupAlchemyServerDeploy(serverDir, packageManager);
}
}
async function setupWorkersServerDeploy(
serverDir: string,
_packageManager: PackageManager,
) {
const packageJsonPath = path.join(serverDir, "package.json");
if (!(await fs.pathExists(packageJsonPath))) return;
const packageJson = await fs.readJson(packageJsonPath);
packageJson.scripts = {
...packageJson.scripts,
dev: "wrangler dev --port=3000",
start: "wrangler dev",
deploy: "wrangler deploy",
build: "wrangler deploy --dry-run",
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
};
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
await addPackageDependency({
devDependencies: ["wrangler", "@types/node", "@cloudflare/workers-types"],
projectDir: serverDir,
});
}
async function generateCloudflareWorkerTypes({
serverDir,
packageManager,
}: {
serverDir: string;
packageManager: ProjectConfig["packageManager"];
}) {
if (!(await fs.pathExists(serverDir))) return;
const s = spinner();
try {
s.start("Generating Cloudflare Workers types...");
const runCmd = packageManager === "npm" ? "npm" : packageManager;
await execa(runCmd, ["run", "cf-typegen"], { cwd: serverDir });
s.stop("Cloudflare Workers types generated successfully!");
} catch {
s.stop(pc.yellow("Failed to generate Cloudflare Workers types"));
const managerCmd = `${packageManager} run`;
log.warn(
`Note: You can manually run 'cd apps/server && ${managerCmd} cf-typegen' in the project directory later`,
);
}
}
export async function setupAlchemyServerDeploy(
serverDir: string,
_packageManager: PackageManager,
) {
if (!(await fs.pathExists(serverDir))) return;
await addPackageDependency({
devDependencies: [
"alchemy",
"wrangler",
"@types/node",
"@cloudflare/workers-types",
"dotenv",
],
projectDir: serverDir,
});
const packageJsonPath = path.join(serverDir, "package.json");
if (await fs.pathExists(packageJsonPath)) {
const packageJson = await fs.readJson(packageJsonPath);
packageJson.scripts = {
...packageJson.scripts,
dev: "wrangler dev --port=3000",
build: "wrangler deploy --dry-run",
deploy: "alchemy deploy",
destroy: "alchemy destroy",
"alchemy:dev": "alchemy dev",
};
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
}
}

View File

@@ -0,0 +1,94 @@
import path from "node:path";
import fs from "fs-extra";
import type { PackageManager, ProjectConfig } from "../../types";
import { setupCombinedAlchemyDeploy } from "./alchemy/alchemy-combined-setup";
import { setupNextAlchemyDeploy } from "./alchemy/alchemy-next-setup";
import { setupNuxtAlchemyDeploy } from "./alchemy/alchemy-nuxt-setup";
import { setupReactRouterAlchemyDeploy } from "./alchemy/alchemy-react-router-setup";
import { setupSolidAlchemyDeploy } from "./alchemy/alchemy-solid-setup";
import { setupSvelteAlchemyDeploy } from "./alchemy/alchemy-svelte-setup";
import { setupTanStackRouterAlchemyDeploy } from "./alchemy/alchemy-tanstack-router-setup";
import { setupTanStackStartAlchemyDeploy } from "./alchemy/alchemy-tanstack-start-setup";
import { setupNextWorkersDeploy } from "./workers/workers-next-setup";
import { setupNuxtWorkersDeploy } from "./workers/workers-nuxt-setup";
import { setupSvelteWorkersDeploy } from "./workers/workers-svelte-setup";
import { setupTanstackStartWorkersDeploy } from "./workers/workers-tanstack-start-setup";
import { setupWorkersVitePlugin } from "./workers/workers-vite-setup";
export async function setupWebDeploy(config: ProjectConfig) {
const { webDeploy, serverDeploy, frontend, projectDir } = config;
const { packageManager } = config;
if (webDeploy === "none") return;
if (webDeploy !== "wrangler" && webDeploy !== "alchemy") return;
if (webDeploy === "alchemy" && serverDeploy === "alchemy") {
await setupCombinedAlchemyDeploy(projectDir, packageManager, config);
return;
}
const isNext = frontend.includes("next");
const isNuxt = frontend.includes("nuxt");
const isSvelte = frontend.includes("svelte");
const isTanstackRouter = frontend.includes("tanstack-router");
const isTanstackStart = frontend.includes("tanstack-start");
const isReactRouter = frontend.includes("react-router");
const isSolid = frontend.includes("solid");
if (webDeploy === "wrangler") {
if (isNext) {
await setupNextWorkersDeploy(projectDir, packageManager);
} else if (isNuxt) {
await setupNuxtWorkersDeploy(projectDir, packageManager);
} else if (isSvelte) {
await setupSvelteWorkersDeploy(projectDir, packageManager);
} else if (isTanstackStart) {
await setupTanstackStartWorkersDeploy(projectDir, packageManager);
} else if (isTanstackRouter || isReactRouter || isSolid) {
await setupWorkersWebDeploy(projectDir, packageManager);
}
} else if (webDeploy === "alchemy") {
if (isNext) {
await setupNextAlchemyDeploy(projectDir, packageManager);
} else if (isNuxt) {
await setupNuxtAlchemyDeploy(projectDir, packageManager);
} else if (isSvelte) {
await setupSvelteAlchemyDeploy(projectDir, packageManager);
} else if (isTanstackStart) {
await setupTanStackStartAlchemyDeploy(projectDir, packageManager);
} else if (isTanstackRouter) {
await setupTanStackRouterAlchemyDeploy(projectDir, packageManager);
} else if (isReactRouter) {
await setupReactRouterAlchemyDeploy(projectDir, packageManager);
} else if (isSolid) {
await setupSolidAlchemyDeploy(projectDir, packageManager);
}
}
}
async function setupWorkersWebDeploy(
projectDir: string,
pkgManager: PackageManager,
) {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) {
return;
}
const packageJsonPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(packageJsonPath)) {
const packageJson = await fs.readJson(packageJsonPath);
packageJson.scripts = {
...packageJson.scripts,
"wrangler:dev": "wrangler dev --port=3001",
deploy: `${pkgManager} run build && wrangler deploy`,
};
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
}
await setupWorkersVitePlugin(projectDir);
}

View File

@@ -0,0 +1,34 @@
import path from "node:path";
import fs from "fs-extra";
import type { PackageManager } from "../../../types";
import { addPackageDependency } from "../../../utils/add-package-deps";
export async function setupNextWorkersDeploy(
projectDir: string,
_packageManager: PackageManager,
) {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return;
await addPackageDependency({
dependencies: ["@opennextjs/cloudflare"],
devDependencies: ["wrangler"],
projectDir: webAppDir,
});
const packageJsonPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(packageJsonPath)) {
const pkg = await fs.readJson(packageJsonPath);
pkg.scripts = {
...pkg.scripts,
preview: "opennextjs-cloudflare build && opennextjs-cloudflare preview",
deploy: "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
upload: "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen":
"wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
};
await fs.writeJson(packageJsonPath, pkg, { spaces: 2 });
}
}

View File

@@ -8,9 +8,9 @@ import {
type PropertyAssignment,
SyntaxKind,
} from "ts-morph";
import type { PackageManager } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
import { tsProject } from "../../utils/ts-morph";
import type { PackageManager } from "../../../types";
import { addPackageDependency } from "../../../utils/add-package-deps";
import { tsProject } from "../../../utils/ts-morph";
export async function setupNuxtWorkersDeploy(
projectDir: string,

View File

@@ -1,9 +1,9 @@
import path from "node:path";
import fs from "fs-extra";
import type { ImportDeclaration } from "ts-morph";
import type { PackageManager } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
import { tsProject } from "../../utils/ts-morph";
import type { PackageManager } from "../../../types";
import { addPackageDependency } from "../../../utils/add-package-deps";
import { tsProject } from "../../../utils/ts-morph";
export async function setupSvelteWorkersDeploy(
projectDir: string,

View File

@@ -6,9 +6,9 @@ import {
type ObjectLiteralExpression,
SyntaxKind,
} from "ts-morph";
import type { PackageManager } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
import { ensureArrayProperty, tsProject } from "../../utils/ts-morph";
import type { PackageManager } from "../../../types";
import { addPackageDependency } from "../../../utils/add-package-deps";
import { ensureArrayProperty, tsProject } from "../../../utils/ts-morph";
export async function setupTanstackStartWorkersDeploy(
projectDir: string,

View File

@@ -6,8 +6,8 @@ import {
type ObjectLiteralExpression,
SyntaxKind,
} from "ts-morph";
import { addPackageDependency } from "../../utils/add-package-deps";
import { ensureArrayProperty, tsProject } from "../../utils/ts-morph";
import { addPackageDependency } from "../../../utils/add-package-deps";
import { ensureArrayProperty, tsProject } from "../../../utils/ts-morph";
export async function setupWorkersVitePlugin(projectDir: string) {
const webAppDir = path.join(projectDir, "apps/web");

View File

@@ -1,284 +0,0 @@
import path from "node:path";
import fs from "fs-extra";
import type { AvailableDependencies } from "../../constants";
import type { Frontend, ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
export async function setupApi(config: ProjectConfig) {
const { api, projectName, frontend, backend, packageManager, projectDir } =
config;
const isConvex = backend === "convex";
const webDir = path.join(projectDir, "apps/web");
const nativeDir = path.join(projectDir, "apps/native");
const webDirExists = await fs.pathExists(webDir);
const nativeDirExists = await fs.pathExists(nativeDir);
const hasReactWeb = frontend.some((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(f),
);
const hasNuxtWeb = frontend.includes("nuxt");
const hasSvelteWeb = frontend.includes("svelte");
const hasSolidWeb = frontend.includes("solid");
if (!isConvex && api !== "none") {
const serverDir = path.join(projectDir, "apps/server");
const serverDirExists = await fs.pathExists(serverDir);
if (serverDirExists) {
if (api === "orpc") {
await addPackageDependency({
dependencies: ["@orpc/server", "@orpc/client"],
projectDir: serverDir,
});
} else if (api === "trpc") {
await addPackageDependency({
dependencies: ["@trpc/server", "@trpc/client"],
projectDir: serverDir,
});
if (config.backend === "hono") {
await addPackageDependency({
dependencies: ["@hono/trpc-server"],
projectDir: serverDir,
});
} else if (config.backend === "elysia") {
await addPackageDependency({
dependencies: ["@elysiajs/trpc"],
projectDir: serverDir,
});
}
}
} else {
}
if (webDirExists) {
if (hasReactWeb) {
if (api === "orpc") {
await addPackageDependency({
dependencies: ["@orpc/tanstack-query", "@orpc/client"],
projectDir: webDir,
});
} else if (api === "trpc") {
await addPackageDependency({
dependencies: [
"@trpc/tanstack-react-query",
"@trpc/client",
"@trpc/server",
],
projectDir: webDir,
});
}
} else if (hasNuxtWeb) {
if (api === "orpc") {
await addPackageDependency({
dependencies: [
"@tanstack/vue-query",
"@tanstack/vue-query-devtools",
"@orpc/tanstack-query",
"@orpc/client",
],
projectDir: webDir,
});
}
} else if (hasSvelteWeb) {
if (api === "orpc") {
await addPackageDependency({
dependencies: [
"@orpc/tanstack-query",
"@orpc/client",
"@tanstack/svelte-query",
],
projectDir: webDir,
});
}
} else if (hasSolidWeb) {
if (api === "orpc") {
await addPackageDependency({
dependencies: [
"@orpc/tanstack-query",
"@orpc/client",
"@tanstack/solid-query",
],
projectDir: webDir,
});
}
}
}
if (nativeDirExists) {
if (api === "trpc") {
await addPackageDependency({
dependencies: [
"@trpc/tanstack-react-query",
"@trpc/client",
"@trpc/server",
],
projectDir: nativeDir,
});
} else if (api === "orpc") {
await addPackageDependency({
dependencies: ["@orpc/tanstack-query", "@orpc/client"],
projectDir: nativeDir,
});
}
}
}
const reactBasedFrontends: Frontend[] = [
"react-router",
"tanstack-router",
"tanstack-start",
"next",
"native-nativewind",
"native-unistyles",
];
const needsSolidQuery = frontend.includes("solid");
const needsReactQuery = frontend.some((f) => reactBasedFrontends.includes(f));
if (needsReactQuery && !isConvex) {
const reactQueryDeps: AvailableDependencies[] = ["@tanstack/react-query"];
const reactQueryDevDeps: AvailableDependencies[] = [
"@tanstack/react-query-devtools",
];
const hasReactWeb = frontend.some(
(f) =>
f !== "native-nativewind" &&
f !== "native-unistyles" &&
reactBasedFrontends.includes(f),
);
const hasNative =
frontend.includes("native-nativewind") ||
frontend.includes("native-unistyles");
if (hasReactWeb && webDirExists) {
const webPkgJsonPath = path.join(webDir, "package.json");
if (await fs.pathExists(webPkgJsonPath)) {
try {
await addPackageDependency({
dependencies: reactQueryDeps,
devDependencies: reactQueryDevDeps,
projectDir: webDir,
});
} catch (_error) {}
} else {
}
}
if (hasNative && nativeDirExists) {
const nativePkgJsonPath = path.join(nativeDir, "package.json");
if (await fs.pathExists(nativePkgJsonPath)) {
try {
await addPackageDependency({
dependencies: reactQueryDeps,
projectDir: nativeDir,
});
} catch (_error) {}
} else {
}
}
}
if (needsSolidQuery && !isConvex) {
const solidQueryDeps: AvailableDependencies[] = ["@tanstack/solid-query"];
const solidQueryDevDeps: AvailableDependencies[] = [
"@tanstack/solid-query-devtools",
];
if (webDirExists) {
const webPkgJsonPath = path.join(webDir, "package.json");
if (await fs.pathExists(webPkgJsonPath)) {
try {
await addPackageDependency({
dependencies: solidQueryDeps,
devDependencies: solidQueryDevDeps,
projectDir: webDir,
});
} catch (_error) {}
}
}
}
if (isConvex) {
if (webDirExists) {
const webPkgJsonPath = path.join(webDir, "package.json");
if (await fs.pathExists(webPkgJsonPath)) {
try {
const webDepsToAdd: AvailableDependencies[] = ["convex"];
if (frontend.includes("tanstack-start")) {
webDepsToAdd.push("@convex-dev/react-query");
}
if (hasSvelteWeb) {
webDepsToAdd.push("convex-svelte");
}
if (hasNuxtWeb) {
webDepsToAdd.push("convex-nuxt");
webDepsToAdd.push("convex-vue");
}
await addPackageDependency({
dependencies: webDepsToAdd,
projectDir: webDir,
});
} catch (_error) {}
} else {
}
}
if (nativeDirExists) {
const nativePkgJsonPath = path.join(nativeDir, "package.json");
if (await fs.pathExists(nativePkgJsonPath)) {
try {
await addPackageDependency({
dependencies: ["convex"],
projectDir: nativeDir,
});
} catch (_error) {}
} else {
}
}
const backendPackageName = `@${projectName}/backend`;
const backendWorkspaceVersion =
packageManager === "npm" ? "*" : "workspace:*";
const addWorkspaceDepManually = async (
pkgJsonPath: string,
depName: string,
depVersion: string,
) => {
try {
const pkgJson = await fs.readJson(pkgJsonPath);
if (!pkgJson.dependencies) {
pkgJson.dependencies = {};
}
if (pkgJson.dependencies[depName] !== depVersion) {
pkgJson.dependencies[depName] = depVersion;
await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
} else {
}
} catch (_error) {}
};
if (webDirExists) {
const webPkgJsonPath = path.join(webDir, "package.json");
if (await fs.pathExists(webPkgJsonPath)) {
await addWorkspaceDepManually(
webPkgJsonPath,
backendPackageName,
backendWorkspaceVersion,
);
} else {
}
}
if (nativeDirExists) {
const nativePkgJsonPath = path.join(nativeDir, "package.json");
if (await fs.pathExists(nativePkgJsonPath)) {
await addWorkspaceDepManually(
nativePkgJsonPath,
backendPackageName,
backendWorkspaceVersion,
);
} else {
}
}
}
}

View File

@@ -1,93 +0,0 @@
import path from "node:path";
import fs from "fs-extra";
import type { PackageManager, ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
import { setupNuxtWorkersDeploy } from "./workers-nuxt-setup";
import { setupSvelteWorkersDeploy } from "./workers-svelte-setup";
import { setupTanstackStartWorkersDeploy } from "./workers-tanstack-start-setup";
import { setupWorkersVitePlugin } from "./workers-vite-setup";
export async function setupWebDeploy(config: ProjectConfig) {
const { webDeploy, frontend, projectDir } = config;
const { packageManager } = config;
if (webDeploy === "none") return;
if (webDeploy !== "workers") return;
const isNext = frontend.includes("next");
const isNuxt = frontend.includes("nuxt");
const isSvelte = frontend.includes("svelte");
const isTanstackRouter = frontend.includes("tanstack-router");
const isTanstackStart = frontend.includes("tanstack-start");
const isReactRouter = frontend.includes("react-router");
const isSolid = frontend.includes("solid");
if (isNext) {
await setupNextWorkersDeploy(projectDir, packageManager);
} else if (isNuxt) {
await setupNuxtWorkersDeploy(projectDir, packageManager);
} else if (isSvelte) {
await setupSvelteWorkersDeploy(projectDir, packageManager);
} else if (isTanstackStart) {
await setupTanstackStartWorkersDeploy(projectDir, packageManager);
} else if (isTanstackRouter || isReactRouter || isSolid) {
await setupWorkersWebDeploy(projectDir, packageManager);
}
}
async function setupWorkersWebDeploy(
projectDir: string,
pkgManager: PackageManager,
) {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) {
return;
}
const packageJsonPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(packageJsonPath)) {
const packageJson = await fs.readJson(packageJsonPath);
packageJson.scripts = {
...packageJson.scripts,
"wrangler:dev": "wrangler dev --port=3001",
deploy: `${pkgManager} run build && wrangler deploy`,
};
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
}
await setupWorkersVitePlugin(projectDir);
}
async function setupNextWorkersDeploy(
projectDir: string,
_packageManager: PackageManager,
) {
const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return;
await addPackageDependency({
dependencies: ["@opennextjs/cloudflare"],
devDependencies: ["wrangler"],
projectDir: webAppDir,
});
const packageJsonPath = path.join(webAppDir, "package.json");
if (await fs.pathExists(packageJsonPath)) {
const pkg = await fs.readJson(packageJsonPath);
pkg.scripts = {
...pkg.scripts,
preview: "opennextjs-cloudflare build && opennextjs-cloudflare preview",
deploy: "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
upload: "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen":
"wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
};
await fs.writeJson(packageJsonPath, pkg, { spaces: 2 });
}
}

View File

@@ -5,7 +5,7 @@ import z from "zod";
import {
addAddonsHandler,
createProjectHandler,
} from "./helpers/project-generation/command-handlers";
} from "./helpers/core/command-handlers";
import {
type AddInput,
type Addons,
@@ -35,6 +35,8 @@ import {
ProjectNameSchema,
type Runtime,
RuntimeSchema,
type ServerDeploy,
ServerDeploySchema,
type WebDeploy,
WebDeploySchema,
} from "./types";
@@ -88,6 +90,7 @@ export const router = t.router({
runtime: RuntimeSchema.optional(),
api: APISchema.optional(),
webDeploy: WebDeploySchema.optional(),
serverDeploy: ServerDeploySchema.optional(),
directoryConflict: DirectoryConflictSchema.optional(),
renderTitle: z.boolean().optional(),
disableAnalytics: z
@@ -120,6 +123,7 @@ export const router = t.router({
z.object({
addons: z.array(AddonsSchema).optional().default([]),
webDeploy: WebDeploySchema.optional(),
serverDeploy: ServerDeploySchema.optional(),
projectDir: z.string().optional(),
install: z
.boolean()
@@ -251,6 +255,7 @@ export type {
DatabaseSetup,
API,
WebDeploy,
ServerDeploy,
DirectoryConflict,
CreateInput,
AddInput,

View File

@@ -11,6 +11,7 @@ import type {
PackageManager,
ProjectConfig,
Runtime,
ServerDeploy,
WebDeploy,
} from "../types";
import { exitCancelled } from "../utils/errors";
@@ -27,6 +28,7 @@ import { getinstallChoice } from "./install";
import { getORMChoice } from "./orm";
import { getPackageManagerChoice } from "./package-manager";
import { getRuntimeChoice } from "./runtime";
import { getServerDeploymentChoice } from "./server-deploy";
import { getDeploymentChoice } from "./web-deploy";
type PromptGroupResults = {
@@ -44,6 +46,7 @@ type PromptGroupResults = {
packageManager: PackageManager;
install: boolean;
webDeploy: WebDeploy;
serverDeploy: ServerDeploy;
};
export async function gatherConfig(
@@ -97,6 +100,13 @@ export async function gatherConfig(
results.backend,
results.frontend,
),
serverDeploy: ({ results }) =>
getServerDeploymentChoice(
flags.serverDeploy,
results.runtime,
results.backend,
results.webDeploy,
),
git: () => getGitChoice(flags.git),
packageManager: () => getPackageManagerChoice(flags.packageManager),
install: () => getinstallChoice(flags.install),
@@ -144,5 +154,6 @@ export async function gatherConfig(
dbSetup: result.dbSetup,
api: result.api,
webDeploy: result.webDeploy,
serverDeploy: result.serverDeploy,
};
}

View File

@@ -45,8 +45,8 @@ export async function getProjectName(initialName?: string): Promise<string> {
let counter = 1;
while (
fs.pathExistsSync(path.resolve(process.cwd(), defaultName)) &&
fs.readdirSync(path.resolve(process.cwd(), defaultName)).length > 0
(await fs.pathExists(path.resolve(process.cwd(), defaultName))) &&
(await fs.readdir(path.resolve(process.cwd(), defaultName))).length > 0
) {
defaultName = `${DEFAULT_CONFIG.projectName}-${counter}`;
counter++;

View File

@@ -0,0 +1,129 @@
import { isCancel, select } from "@clack/prompts";
import { DEFAULT_CONFIG } from "../constants";
import type { Backend, Runtime, ServerDeploy, WebDeploy } from "../types";
import { exitCancelled } from "../utils/errors";
type DeploymentOption = {
value: ServerDeploy;
label: string;
hint: string;
};
function getDeploymentDisplay(deployment: ServerDeploy): {
label: string;
hint: string;
} {
if (deployment === "wrangler") {
return {
label: "Wrangler",
hint: "Deploy to Cloudflare Workers using Wrangler",
};
}
if (deployment === "alchemy") {
return {
label: "Alchemy",
hint: "Deploy to Cloudflare Workers using Alchemy",
};
}
return {
label: deployment,
hint: `Add ${deployment} deployment`,
};
}
export async function getServerDeploymentChoice(
deployment?: ServerDeploy,
runtime?: Runtime,
backend?: Backend,
webDeploy?: WebDeploy,
): Promise<ServerDeploy> {
if (deployment !== undefined) return deployment;
if (backend === "none" || backend === "convex") {
return "none";
}
const options: DeploymentOption[] = [];
if (runtime === "workers") {
["alchemy", "wrangler"].forEach((deploy) => {
const { label, hint } = getDeploymentDisplay(deploy as ServerDeploy);
options.unshift({
value: deploy as ServerDeploy,
label,
hint,
});
});
} else {
options.push({ value: "none", label: "None", hint: "Manual setup" });
}
const response = await select<ServerDeploy>({
message: "Select server deployment",
options,
initialValue:
webDeploy === "alchemy"
? "alchemy"
: runtime === "workers"
? "wrangler"
: DEFAULT_CONFIG.serverDeploy,
});
if (isCancel(response)) return exitCancelled("Operation cancelled");
return response;
}
export async function getServerDeploymentToAdd(
runtime?: Runtime,
existingDeployment?: ServerDeploy,
): Promise<ServerDeploy> {
const options: DeploymentOption[] = [];
if (runtime === "workers") {
if (existingDeployment !== "wrangler") {
const { label, hint } = getDeploymentDisplay("wrangler");
options.push({
value: "wrangler",
label,
hint,
});
}
if (existingDeployment !== "alchemy") {
const { label, hint } = getDeploymentDisplay("alchemy");
options.push({
value: "alchemy",
label,
hint,
});
}
}
if (existingDeployment && existingDeployment !== "none") {
return "none";
}
if (options.length > 0) {
options.push({
value: "none",
label: "None",
hint: "Skip deployment setup",
});
}
if (options.length === 0) {
return "none";
}
const response = await select<ServerDeploy>({
message: "Select server deployment",
options,
initialValue:
runtime === "workers" ? "wrangler" : DEFAULT_CONFIG.serverDeploy,
});
if (isCancel(response)) return exitCancelled("Operation cancelled");
return response;
}

View File

@@ -18,12 +18,18 @@ function getDeploymentDisplay(deployment: WebDeploy): {
label: string;
hint: string;
} {
if (deployment === "workers") {
if (deployment === "wrangler") {
return {
label: "Cloudflare Workers",
label: "Wrangler",
hint: "Deploy to Cloudflare Workers using Wrangler",
};
}
if (deployment === "alchemy") {
return {
label: "Alchemy",
hint: "Deploy to Cloudflare Workers using Alchemy",
};
}
return {
label: deployment,
hint: `Add ${deployment} deployment`,
@@ -41,14 +47,16 @@ export async function getDeploymentChoice(
return "none";
}
const options: DeploymentOption[] = [
{
value: "workers",
label: "Cloudflare Workers",
hint: "Deploy to Cloudflare Workers using Wrangler",
const options: DeploymentOption[] = ["wrangler", "alchemy", "none"].map(
(deploy) => {
const { label, hint } = getDeploymentDisplay(deploy as WebDeploy);
return {
value: deploy as WebDeploy,
label,
hint,
};
},
{ value: "none", label: "None", hint: "Manual setup" },
];
);
const response = await select<WebDeploy>({
message: "Select web deployment",
@@ -71,10 +79,19 @@ export async function getDeploymentToAdd(
const options: DeploymentOption[] = [];
if (existingDeployment !== "workers") {
const { label, hint } = getDeploymentDisplay("workers");
if (existingDeployment !== "wrangler") {
const { label, hint } = getDeploymentDisplay("wrangler");
options.push({
value: "workers",
value: "wrangler",
label,
hint,
});
}
if (existingDeployment !== "alchemy") {
const { label, hint } = getDeploymentDisplay("alchemy");
options.push({
value: "alchemy",
label,
hint,
});

View File

@@ -104,10 +104,15 @@ export const ProjectNameSchema = z
export type ProjectName = z.infer<typeof ProjectNameSchema>;
export const WebDeploySchema = z
.enum(["workers", "none"])
.enum(["wrangler", "alchemy", "none"])
.describe("Web deployment");
export type WebDeploy = z.infer<typeof WebDeploySchema>;
export const ServerDeploySchema = z
.enum(["wrangler", "alchemy", "none"])
.describe("Server deployment");
export type ServerDeploy = z.infer<typeof ServerDeploySchema>;
export const DirectoryConflictSchema = z
.enum(["merge", "overwrite", "increment", "error"])
.describe("How to handle existing directory conflicts");
@@ -132,6 +137,7 @@ export type CreateInput = {
runtime?: Runtime;
api?: API;
webDeploy?: WebDeploy;
serverDeploy?: ServerDeploy;
directoryConflict?: DirectoryConflict;
renderTitle?: boolean;
disableAnalytics?: boolean;
@@ -140,6 +146,7 @@ export type CreateInput = {
export type AddInput = {
addons?: Addons[];
webDeploy?: WebDeploy;
serverDeploy?: ServerDeploy;
projectDir?: string;
install?: boolean;
packageManager?: PackageManager;
@@ -167,6 +174,7 @@ export interface ProjectConfig {
dbSetup: DatabaseSetup;
api: API;
webDeploy: WebDeploy;
serverDeploy: ServerDeploy;
}
export interface BetterTStackConfig {
@@ -184,6 +192,7 @@ export interface BetterTStackConfig {
dbSetup: DatabaseSetup;
api: API;
webDeploy: WebDeploy;
serverDeploy: ServerDeploy;
}
export interface InitResult {

View File

@@ -5,13 +5,19 @@ import { isTelemetryEnabled } from "./telemetry";
const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY || "";
const POSTHOG_HOST = process.env.POSTHOG_HOST;
function generateSessionId() {
const rand = Math.random().toString(36).slice(2);
const now = Date.now().toString(36);
return `cli_${now}${rand}`;
}
export async function trackProjectCreation(
config: ProjectConfig,
disableAnalytics = false,
) {
if (!isTelemetryEnabled() || disableAnalytics) return;
const sessionId = `cli_${crypto.randomUUID().replace(/-/g, "")}`;
const sessionId = generateSessionId();
// biome-ignore lint/correctness/noUnusedVariables: `projectName`, `projectDir`, and `relativePath` are not used in the event properties
const { projectName, projectDir, relativePath, ...safeConfig } = config;
@@ -21,8 +27,8 @@ export async function trackProjectCreation(
properties: {
...safeConfig,
cli_version: getLatestCLIVersion(),
node_version: process.version,
platform: process.platform,
node_version: typeof process !== "undefined" ? process.version : "",
platform: typeof process !== "undefined" ? process.platform : "",
$ip: null,
},
distinct_id: sessionId,

View File

@@ -22,6 +22,7 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) {
dbSetup: projectConfig.dbSetup,
api: projectConfig.api,
webDeploy: projectConfig.webDeploy,
serverDeploy: projectConfig.serverDeploy,
};
const baseContent = {
@@ -40,6 +41,7 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) {
dbSetup: btsConfig.dbSetup,
api: btsConfig.api,
webDeploy: btsConfig.webDeploy,
serverDeploy: btsConfig.serverDeploy,
};
let configContent = JSON.stringify(baseContent);
@@ -91,7 +93,9 @@ export async function readBtsConfig(
export async function updateBtsConfig(
projectDir: string,
updates: Partial<Pick<BetterTStackConfig, "addons" | "webDeploy">>,
updates: Partial<
Pick<BetterTStackConfig, "addons" | "webDeploy" | "serverDeploy">
>,
) {
try {
const configPath = path.join(projectDir, BTS_CONFIG_FILE);

View File

@@ -1,9 +1,11 @@
import type {
Addons,
API,
Backend,
CLIInput,
Frontend,
ProjectConfig,
ServerDeploy,
WebDeploy,
} from "../types";
import { validateAddonCompatibility } from "./addon-compatibility";
@@ -252,6 +254,21 @@ export function validateWebDeployRequiresWebFrontend(
}
}
export function validateServerDeployRequiresBackend(
serverDeploy: ServerDeploy | undefined,
backend: Backend | undefined,
) {
if (
serverDeploy &&
serverDeploy !== "none" &&
(!backend || backend === "none")
) {
exitWithError(
"'--server-deploy' requires a backend. Please select a backend or set '--server-deploy none'.",
);
}
}
export function validateAddonsAgainstFrontends(
addons: Addons[] = [],
frontends: Frontend[] = [],
@@ -297,3 +314,31 @@ export function validateExamplesCompatibility(
);
}
}
export function validateAlchemyCompatibility(
webDeploy: WebDeploy | undefined,
serverDeploy: ServerDeploy | undefined,
frontends: Frontend[] = [],
) {
const isAlchemyWebDeploy = webDeploy === "alchemy";
const isAlchemyServerDeploy = serverDeploy === "alchemy";
if (isAlchemyWebDeploy || isAlchemyServerDeploy) {
const incompatibleFrontends = frontends.filter(
(f) => f === "next" || f === "react-router",
);
if (incompatibleFrontends.length > 0) {
const deployType =
isAlchemyWebDeploy && isAlchemyServerDeploy
? "web and server deployment"
: isAlchemyWebDeploy
? "web deployment"
: "server deployment";
exitWithError(
`Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")} frontend(s). Please choose a different frontend or deployment option.`,
);
}
}
}

View File

@@ -0,0 +1,134 @@
import path from "node:path";
import type {
API,
Backend,
CLIInput,
Database,
DatabaseSetup,
ORM,
PackageManager,
ProjectConfig,
Runtime,
ServerDeploy,
WebDeploy,
} from "../types";
export function processArrayOption<T>(
options: (T | "none")[] | undefined,
): T[] {
if (!options || options.length === 0) return [];
if (options.includes("none" as T | "none")) return [];
return options.filter((item): item is T => item !== "none");
}
export function deriveProjectName(
projectName?: string,
projectDirectory?: string,
): string {
if (projectName) {
return projectName;
}
if (projectDirectory) {
return path.basename(path.resolve(process.cwd(), projectDirectory));
}
return "";
}
export function processFlags(
options: CLIInput,
projectName?: string,
): Partial<ProjectConfig> {
const config: Partial<ProjectConfig> = {};
if (options.api) {
config.api = options.api as API;
}
if (options.backend) {
config.backend = options.backend as Backend;
}
if (options.database) {
config.database = options.database as Database;
}
if (options.orm) {
config.orm = options.orm as ORM;
}
if (options.auth !== undefined) {
config.auth = options.auth;
}
if (options.git !== undefined) {
config.git = options.git;
}
if (options.install !== undefined) {
config.install = options.install;
}
if (options.runtime) {
config.runtime = options.runtime as Runtime;
}
if (options.dbSetup) {
config.dbSetup = options.dbSetup as DatabaseSetup;
}
if (options.packageManager) {
config.packageManager = options.packageManager as PackageManager;
}
if (options.webDeploy) {
config.webDeploy = options.webDeploy as WebDeploy;
}
if (options.serverDeploy) {
config.serverDeploy = options.serverDeploy as ServerDeploy;
}
const derivedName = deriveProjectName(projectName, options.projectDirectory);
if (derivedName) {
config.projectName = projectName || derivedName;
}
if (options.frontend && options.frontend.length > 0) {
config.frontend = processArrayOption(options.frontend);
}
if (options.addons && options.addons.length > 0) {
config.addons = processArrayOption(options.addons);
}
if (options.examples && options.examples.length > 0) {
config.examples = processArrayOption(options.examples);
}
return config;
}
export function getProvidedFlags(options: CLIInput): Set<string> {
return new Set(
Object.keys(options).filter(
(key) => options[key as keyof CLIInput] !== undefined,
),
);
}
export function validateNoneExclusivity<T>(
options: (T | "none")[] | undefined,
optionName: string,
): void {
if (!options || options.length === 0) return;
if (options.includes("none" as T | "none") && options.length > 1) {
throw new Error(`Cannot combine 'none' with other ${optionName}.`);
}
}
export function validateArrayOptions(options: CLIInput): void {
validateNoneExclusivity(options.frontend, "frontend options");
validateNoneExclusivity(options.addons, "addons");
validateNoneExclusivity(options.examples, "examples");
}

View File

@@ -0,0 +1,333 @@
import type {
CLIInput,
Database,
DatabaseSetup,
ProjectConfig,
Runtime,
} from "../types";
import {
coerceBackendPresets,
ensureSingleWebAndNative,
incompatibleFlagsForBackend,
isWebFrontend,
validateAddonsAgainstFrontends,
validateApiFrontendCompatibility,
validateExamplesCompatibility,
validateServerDeployRequiresBackend,
validateWebDeployRequiresWebFrontend,
validateWorkersCompatibility,
validateAlchemyCompatibility,
} from "./compatibility-rules";
import { exitWithError } from "./errors";
export function validateDatabaseOrmAuth(
cfg: Partial<ProjectConfig>,
flags?: Set<string>,
): void {
const db = cfg.database;
const orm = cfg.orm;
const has = (k: string) => (flags ? flags.has(k) : true);
if (has("orm") && has("database") && orm === "mongoose" && db !== "mongodb") {
exitWithError(
"Mongoose ORM requires MongoDB database. Please use '--database mongodb' or choose a different ORM.",
);
}
if (has("orm") && has("database") && orm === "drizzle" && db === "mongodb") {
exitWithError(
"Drizzle ORM does not support MongoDB. Please use '--orm mongoose' or '--orm prisma' or choose a different database.",
);
}
if (
has("database") &&
has("orm") &&
db === "mongodb" &&
orm &&
orm !== "mongoose" &&
orm !== "prisma" &&
orm !== "none"
) {
exitWithError(
"MongoDB database requires Mongoose or Prisma ORM. Please use '--orm mongoose' or '--orm prisma' or choose a different database.",
);
}
if (has("database") && has("orm") && db && db !== "none" && orm === "none") {
exitWithError(
"Database selection requires an ORM. Please choose '--orm drizzle', '--orm prisma', or '--orm mongoose'.",
);
}
if (has("orm") && has("database") && orm && orm !== "none" && db === "none") {
exitWithError(
"ORM selection requires a database. Please choose a database or set '--orm none'.",
);
}
if (has("auth") && has("database") && cfg.auth && db === "none") {
exitWithError(
"Authentication requires a database. Please choose a database or set '--no-auth'.",
);
}
if (cfg.auth && db === "none") {
exitWithError(
"Authentication requires a database. Please choose a database or set '--no-auth'.",
);
}
if (orm && orm !== "none" && db === "none") {
exitWithError(
"ORM selection requires a database. Please choose a database or set '--orm none'.",
);
}
}
export function validateDatabaseSetup(
config: Partial<ProjectConfig>,
providedFlags: Set<string>,
): void {
const { dbSetup, database, runtime } = config;
if (
providedFlags.has("dbSetup") &&
providedFlags.has("database") &&
dbSetup &&
dbSetup !== "none" &&
database === "none"
) {
exitWithError(
"Database setup requires a database. Please choose a database or set '--db-setup none'.",
);
}
const setupValidations: Record<
DatabaseSetup,
{ database?: Database; runtime?: Runtime; errorMessage: string }
> = {
turso: {
database: "sqlite",
errorMessage:
"Turso setup requires SQLite database. Please use '--database sqlite' or choose a different setup.",
},
neon: {
database: "postgres",
errorMessage:
"Neon setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.",
},
"prisma-postgres": {
database: "postgres",
errorMessage:
"Prisma PostgreSQL setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.",
},
"mongodb-atlas": {
database: "mongodb",
errorMessage:
"MongoDB Atlas setup requires MongoDB database. Please use '--database mongodb' or choose a different setup.",
},
supabase: {
database: "postgres",
errorMessage:
"Supabase setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.",
},
d1: {
database: "sqlite",
runtime: "workers",
errorMessage:
"Cloudflare D1 setup requires SQLite database and Cloudflare Workers runtime.",
},
docker: {
errorMessage:
"Docker setup is not compatible with SQLite database or Cloudflare Workers runtime.",
},
none: { errorMessage: "" },
};
if (dbSetup && dbSetup !== "none") {
const validation = setupValidations[dbSetup];
if (validation.database && database !== validation.database) {
exitWithError(validation.errorMessage);
}
if (validation.runtime && runtime !== validation.runtime) {
exitWithError(validation.errorMessage);
}
if (dbSetup === "docker") {
if (database === "sqlite") {
exitWithError(
"Docker setup is not compatible with SQLite database. SQLite is file-based and doesn't require Docker. Please use '--database postgres', '--database mysql', '--database mongodb', or choose a different setup.",
);
}
if (runtime === "workers") {
exitWithError(
"Docker setup is not compatible with Cloudflare Workers runtime. Workers runtime uses serverless databases (D1) and doesn't support local Docker containers. Please use '--db-setup d1' for SQLite or choose a different runtime.",
);
}
}
}
}
export function validateBackendConstraints(
config: Partial<ProjectConfig>,
providedFlags: Set<string>,
options: CLIInput,
): void {
const { backend } = config;
if (
providedFlags.has("backend") &&
backend &&
backend !== "convex" &&
backend !== "none"
) {
if (providedFlags.has("runtime") && options.runtime === "none") {
exitWithError(
"'--runtime none' is only supported with '--backend convex' or '--backend none'. Please choose 'bun', 'node', or remove the --runtime flag.",
);
}
}
if (backend === "convex" || backend === "none") {
const incompatibleFlags = incompatibleFlagsForBackend(
backend,
providedFlags,
options,
);
if (incompatibleFlags.length > 0) {
exitWithError(
`The following flags are incompatible with '--backend ${backend}': ${incompatibleFlags.join(
", ",
)}. Please remove them.`,
);
}
if (
backend === "convex" &&
providedFlags.has("frontend") &&
options.frontend
) {
const incompatibleFrontends = options.frontend.filter(
(f) => f === "solid",
);
if (incompatibleFrontends.length > 0) {
exitWithError(
`The following frontends are not compatible with '--backend convex': ${incompatibleFrontends.join(
", ",
)}. Please choose a different frontend or backend.`,
);
}
}
coerceBackendPresets(config);
}
}
export function validateFrontendConstraints(
config: Partial<ProjectConfig>,
providedFlags: Set<string>,
): void {
const { frontend } = config;
if (frontend && frontend.length > 0) {
ensureSingleWebAndNative(frontend);
if (
providedFlags.has("api") &&
providedFlags.has("frontend") &&
config.api
) {
validateApiFrontendCompatibility(config.api, frontend);
}
}
const hasWebFrontendFlag = (frontend ?? []).some((f) => isWebFrontend(f));
validateWebDeployRequiresWebFrontend(config.webDeploy, hasWebFrontendFlag);
}
export function validateApiConstraints(
config: Partial<ProjectConfig>,
options: CLIInput,
): void {
if (config.api === "none") {
if (
options.examples &&
!(options.examples.length === 1 && options.examples[0] === "none") &&
options.backend !== "convex"
) {
exitWithError(
"Cannot use '--examples' when '--api' is set to 'none'. Please remove the --examples flag or choose an API type.",
);
}
}
}
export function validateFullConfig(
config: Partial<ProjectConfig>,
providedFlags: Set<string>,
options: CLIInput,
): void {
validateDatabaseOrmAuth(config, providedFlags);
validateDatabaseSetup(config, providedFlags);
validateBackendConstraints(config, providedFlags, options);
validateFrontendConstraints(config, providedFlags);
validateApiConstraints(config, options);
validateServerDeployRequiresBackend(config.serverDeploy, config.backend);
validateWorkersCompatibility(providedFlags, options, config);
if (config.addons && config.addons.length > 0) {
validateAddonsAgainstFrontends(config.addons, config.frontend);
config.addons = [...new Set(config.addons)];
}
validateExamplesCompatibility(
config.examples ?? [],
config.backend,
config.database,
config.frontend ?? [],
);
validateAlchemyCompatibility(
config.webDeploy,
config.serverDeploy,
config.frontend ?? [],
);
}
export function validateConfigForProgrammaticUse(
config: Partial<ProjectConfig>,
): void {
try {
validateDatabaseOrmAuth(config);
if (config.frontend && config.frontend.length > 0) {
ensureSingleWebAndNative(config.frontend);
}
validateApiFrontendCompatibility(config.api, config.frontend);
if (config.addons && config.addons.length > 0) {
validateAddonsAgainstFrontends(config.addons, config.frontend);
}
validateExamplesCompatibility(
config.examples ?? [],
config.backend,
config.database,
config.frontend ?? [],
);
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(String(error));
}
}

View File

@@ -107,6 +107,12 @@ export function displayConfig(config: Partial<ProjectConfig>) {
);
}
if (config.serverDeploy !== undefined) {
configDisplay.push(
`${pc.blue("Server Deployment:")} ${String(config.serverDeploy)}`,
);
}
if (configDisplay.length === 0) {
return pc.yellow("No configuration selected.");
}

View File

@@ -0,0 +1,61 @@
import path from "node:path";
import { Biome } from "@biomejs/js-api/nodejs";
import fs from "fs-extra";
import { glob } from "tinyglobby";
export async function formatProjectWithBiome(projectDir: string) {
const biome = new Biome();
const { projectKey } = biome.openProject(projectDir);
biome.applyConfiguration(projectKey, {
formatter: {
enabled: true,
indentStyle: "tab",
},
javascript: {
formatter: {
quoteStyle: "double",
},
},
});
const files = await glob("**/*", {
cwd: projectDir,
dot: true,
absolute: true,
onlyFiles: true,
});
for (const filePath of files) {
try {
const ext = path.extname(filePath).toLowerCase();
const supported = new Set([
".ts",
".tsx",
".js",
".jsx",
".cjs",
".mjs",
".cts",
".mts",
".json",
".jsonc",
".md",
".mdx",
".css",
".scss",
".html",
]);
if (!supported.has(ext)) continue;
const original = await fs.readFile(filePath, "utf8");
const result = biome.formatContent(projectKey, original, { filePath });
const content = result?.content;
if (typeof content !== "string") continue;
if (content.length === 0 && original.length > 0) continue;
if (content !== original) {
await fs.writeFile(filePath, content);
}
} catch {}
}
}

View File

@@ -30,6 +30,7 @@ export function generateReproducibleCommand(config: ProjectConfig): string {
flags.push(`--db-setup ${config.dbSetup}`);
flags.push(`--web-deploy ${config.webDeploy}`);
flags.push(`--server-deploy ${config.serverDeploy}`);
flags.push(config.git ? "--git" : "--no-git");
flags.push(`--package-manager ${config.packageManager}`);
flags.push(config.install ? "--install" : "--no-install");

View File

@@ -14,8 +14,9 @@ export async function handleDirectoryConflict(
}> {
while (true) {
const resolvedPath = path.resolve(process.cwd(), currentPathInput);
const dirExists = fs.pathExistsSync(resolvedPath);
const dirIsNotEmpty = dirExists && fs.readdirSync(resolvedPath).length > 0;
const dirExists = await fs.pathExists(resolvedPath);
const dirIsNotEmpty =
dirExists && (await fs.readdir(resolvedPath)).length > 0;
if (!dirIsNotEmpty) {
return { finalPathInput: currentPathInput, shouldClearDirectory: false };

View File

@@ -0,0 +1,47 @@
import path from "node:path";
import { ProjectNameSchema } from "../types";
import { exitWithError } from "./errors";
export function validateProjectName(name: string): void {
const result = ProjectNameSchema.safeParse(name);
if (!result.success) {
exitWithError(
`Invalid project name: ${
result.error.issues[0]?.message || "Invalid project name"
}`,
);
}
}
export function validateProjectNameThrow(name: string): void {
const result = ProjectNameSchema.safeParse(name);
if (!result.success) {
throw new Error(`Invalid project name: ${result.error.issues[0]?.message}`);
}
}
export function extractAndValidateProjectName(
projectName?: string,
projectDirectory?: string,
throwOnError = false,
): string {
const derivedName =
projectName ||
(projectDirectory
? path.basename(path.resolve(process.cwd(), projectDirectory))
: "");
if (!derivedName) {
return "";
}
const nameToValidate = projectName ? path.basename(projectName) : derivedName;
if (throwOnError) {
validateProjectNameThrow(nameToValidate);
} else {
validateProjectName(nameToValidate);
}
return projectName || derivedName;
}

View File

@@ -1,514 +1,85 @@
import path from "node:path";
import type { CLIInput, ProjectConfig } from "./types";
import {
type API,
type Backend,
type CLIInput,
type Database,
type DatabaseSetup,
type ORM,
type PackageManager,
type ProjectConfig,
ProjectNameSchema,
type Runtime,
type WebDeploy,
} from "./types";
getProvidedFlags,
processFlags,
validateArrayOptions,
} from "./utils/config-processing";
import {
coerceBackendPresets,
ensureSingleWebAndNative,
incompatibleFlagsForBackend,
isWebFrontend,
validateAddonsAgainstFrontends,
validateApiFrontendCompatibility,
validateExamplesCompatibility,
validateWebDeployRequiresWebFrontend,
validateWorkersCompatibility,
} from "./utils/compatibility-rules";
validateConfigForProgrammaticUse,
validateFullConfig,
} from "./utils/config-validation";
import { exitWithError } from "./utils/errors";
function processArrayOption<T>(options: (T | "none")[] | undefined): T[] {
if (!options || options.length === 0) return [];
if (options.includes("none" as T | "none")) return [];
return options.filter((item): item is T => item !== "none");
}
function deriveProjectName(
projectName?: string,
projectDirectory?: string,
): string {
if (projectName) {
return projectName;
}
if (projectDirectory) {
return path.basename(path.resolve(process.cwd(), projectDirectory));
}
return "";
}
function validateProjectName(name: string): void {
const result = ProjectNameSchema.safeParse(name);
if (!result.success) {
exitWithError(
`Invalid project name: ${
result.error.issues[0]?.message || "Invalid project name"
}`,
);
}
}
import { extractAndValidateProjectName } from "./utils/project-name-validation";
export function processAndValidateFlags(
options: CLIInput,
providedFlags: Set<string>,
projectName?: string,
): Partial<ProjectConfig> {
const config: Partial<ProjectConfig> = {};
if (options.api) {
config.api = options.api as API;
if (options.api === "none") {
if (
options.examples &&
!(options.examples.length === 1 && options.examples[0] === "none") &&
options.backend !== "convex"
) {
exitWithError(
"Cannot use '--examples' when '--api' is set to 'none'. Please remove the --examples flag or choose an API type.",
);
}
if (options.yolo) {
const cfg = processFlags(options, projectName);
const validatedProjectName = extractAndValidateProjectName(
projectName,
options.projectDirectory,
true,
);
if (validatedProjectName) {
cfg.projectName = validatedProjectName;
}
return cfg;
}
if (options.backend) {
config.backend = options.backend as Backend;
try {
validateArrayOptions(options);
} catch (error) {
exitWithError(error instanceof Error ? error.message : String(error));
}
if (
providedFlags.has("backend") &&
config.backend &&
config.backend !== "convex" &&
config.backend !== "none"
) {
if (providedFlags.has("runtime") && options.runtime === "none") {
exitWithError(
`'--runtime none' is only supported with '--backend convex' or '--backend none'. Please choose 'bun', 'node', or remove the --runtime flag.`,
);
}
}
const config = processFlags(options, projectName);
if (options.database) {
config.database = options.database as Database;
}
if (options.orm) {
config.orm = options.orm as ORM;
}
if (options.auth !== undefined) {
config.auth = options.auth;
}
if (options.git !== undefined) {
config.git = options.git;
}
if (options.install !== undefined) {
config.install = options.install;
}
if (options.runtime) {
config.runtime = options.runtime as Runtime;
}
if (options.dbSetup) {
config.dbSetup = options.dbSetup as DatabaseSetup;
}
if (options.packageManager) {
config.packageManager = options.packageManager as PackageManager;
}
if (options.webDeploy) {
config.webDeploy = options.webDeploy as WebDeploy;
}
const derivedName = deriveProjectName(projectName, options.projectDirectory);
if (derivedName) {
const nameToValidate = projectName
? path.basename(projectName)
: derivedName;
validateProjectName(nameToValidate);
config.projectName = projectName || derivedName;
}
if (options.frontend && options.frontend.length > 0) {
if (options.frontend.includes("none")) {
if (options.frontend.length > 1) {
exitWithError(`Cannot combine 'none' with other frontend options.`);
}
config.frontend = [];
} else {
const validOptions = processArrayOption(options.frontend);
ensureSingleWebAndNative(validOptions);
config.frontend = validOptions;
}
}
if (
providedFlags.has("api") &&
providedFlags.has("frontend") &&
config.api &&
config.frontend &&
config.frontend.length > 0
) {
validateApiFrontendCompatibility(config.api, config.frontend);
}
if (options.addons && options.addons.length > 0) {
if (options.addons.includes("none")) {
if (options.addons.length > 1) {
exitWithError(`Cannot combine 'none' with other addons.`);
}
config.addons = [];
} else {
config.addons = processArrayOption(options.addons);
}
}
if (options.examples && options.examples.length > 0) {
if (options.examples.includes("none")) {
if (options.examples.length > 1) {
exitWithError("Cannot combine 'none' with other examples.");
}
config.examples = [];
} else {
config.examples = processArrayOption(options.examples);
if (options.examples.includes("none") && config.backend !== "convex") {
config.examples = [];
}
}
}
if (config.backend === "convex" || config.backend === "none") {
const incompatibleFlags = incompatibleFlagsForBackend(
config.backend,
providedFlags,
options,
);
if (incompatibleFlags.length > 0) {
exitWithError(
`The following flags are incompatible with '--backend ${config.backend}': ${incompatibleFlags.join(
", ",
)}. Please remove them.`,
);
}
if (
config.backend === "convex" &&
providedFlags.has("frontend") &&
options.frontend
) {
const incompatibleFrontends = options.frontend.filter(
(f) => f === "solid",
);
if (incompatibleFrontends.length > 0) {
exitWithError(
`The following frontends are not compatible with '--backend convex': ${incompatibleFrontends.join(
", ",
)}. Please choose a different frontend or backend.`,
);
}
}
coerceBackendPresets(config);
}
if (
providedFlags.has("orm") &&
providedFlags.has("database") &&
config.orm === "mongoose" &&
config.database !== "mongodb"
) {
exitWithError(
"Mongoose ORM requires MongoDB database. Please use '--database mongodb' or choose a different ORM.",
);
}
if (
providedFlags.has("database") &&
providedFlags.has("orm") &&
config.database === "mongodb" &&
config.orm &&
config.orm !== "mongoose" &&
config.orm !== "prisma"
) {
exitWithError(
"MongoDB database requires Mongoose or Prisma ORM. Please use '--orm mongoose' or '--orm prisma' or choose a different database.",
);
}
if (
providedFlags.has("orm") &&
providedFlags.has("database") &&
config.orm === "drizzle" &&
config.database === "mongodb"
) {
exitWithError(
"Drizzle ORM does not support MongoDB. Please use '--orm mongoose' or '--orm prisma' or choose a different database.",
);
}
if (
providedFlags.has("database") &&
providedFlags.has("orm") &&
config.database &&
config.database !== "none" &&
config.orm === "none"
) {
exitWithError(
"Database selection requires an ORM. Please choose '--orm drizzle', '--orm prisma', or '--orm mongoose'.",
);
}
if (
providedFlags.has("orm") &&
providedFlags.has("database") &&
config.orm &&
config.orm !== "none" &&
config.database === "none"
) {
exitWithError(
"ORM selection requires a database. Please choose a database or set '--orm none'.",
);
}
if (
providedFlags.has("auth") &&
providedFlags.has("database") &&
config.auth &&
config.database === "none"
) {
exitWithError(
"Authentication requires a database. Please choose a database or set '--no-auth'.",
);
}
if (
providedFlags.has("dbSetup") &&
providedFlags.has("database") &&
config.dbSetup &&
config.dbSetup !== "none" &&
config.database === "none"
) {
exitWithError(
"Database setup requires a database. Please choose a database or set '--db-setup none'.",
);
}
if (
providedFlags.has("dbSetup") &&
(config.database ? providedFlags.has("database") : true) &&
config.dbSetup === "turso" &&
config.database !== "sqlite"
) {
exitWithError(
"Turso setup requires SQLite database. Please use '--database sqlite' or choose a different setup.",
);
}
if (
providedFlags.has("dbSetup") &&
(config.database ? providedFlags.has("database") : true) &&
config.dbSetup === "neon" &&
config.database !== "postgres"
) {
exitWithError(
"Neon setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.",
);
}
if (
providedFlags.has("dbSetup") &&
(config.database ? providedFlags.has("database") : true) &&
config.dbSetup === "prisma-postgres" &&
config.database !== "postgres"
) {
exitWithError(
"Prisma PostgreSQL setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.",
);
}
if (
providedFlags.has("dbSetup") &&
(config.database ? providedFlags.has("database") : true) &&
config.dbSetup === "mongodb-atlas" &&
config.database !== "mongodb"
) {
exitWithError(
"MongoDB Atlas setup requires MongoDB database. Please use '--database mongodb' or choose a different setup.",
);
}
if (
providedFlags.has("dbSetup") &&
(config.database ? providedFlags.has("database") : true) &&
config.dbSetup === "supabase" &&
config.database !== "postgres"
) {
exitWithError(
"Supabase setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.",
);
}
if (config.dbSetup === "d1") {
if (
(providedFlags.has("dbSetup") && providedFlags.has("database")) ||
(providedFlags.has("dbSetup") && !config.database)
) {
if (config.database !== "sqlite") {
exitWithError(
"Cloudflare D1 setup requires SQLite database. Please use '--database sqlite' or choose a different setup.",
);
}
}
if (
(providedFlags.has("dbSetup") && providedFlags.has("runtime")) ||
(providedFlags.has("dbSetup") && !config.runtime)
) {
if (config.runtime !== "workers") {
exitWithError(
"Cloudflare D1 setup requires the Cloudflare Workers runtime. Please use '--runtime workers' or choose a different setup.",
);
}
}
}
if (
providedFlags.has("dbSetup") &&
providedFlags.has("database") &&
config.dbSetup === "docker" &&
config.database === "sqlite"
) {
exitWithError(
"Docker setup is not compatible with SQLite database. SQLite is file-based and doesn't require Docker. Please use '--database postgres', '--database mysql', '--database mongodb', or choose a different setup.",
);
}
if (
providedFlags.has("dbSetup") &&
providedFlags.has("runtime") &&
config.dbSetup === "docker" &&
config.runtime === "workers"
) {
exitWithError(
"Docker setup is not compatible with Cloudflare Workers runtime. Workers runtime uses serverless databases (D1) and doesn't support local Docker containers. Please use '--db-setup d1' for SQLite or choose a different runtime.",
);
}
validateWorkersCompatibility(providedFlags, options, config);
const hasWebFrontendFlag = (config.frontend ?? []).some((f) =>
isWebFrontend(f),
const validatedProjectName = extractAndValidateProjectName(
projectName,
options.projectDirectory,
false,
);
validateWebDeployRequiresWebFrontend(config.webDeploy, hasWebFrontendFlag);
if (validatedProjectName) {
config.projectName = validatedProjectName;
}
validateFullConfig(config, providedFlags, options);
return config;
}
export function validateConfigCompatibility(config: Partial<ProjectConfig>) {
const effectiveDatabase = config.database;
const effectiveBackend = config.backend;
const effectiveFrontend = config.frontend;
const effectiveApi = config.api;
validateApiFrontendCompatibility(effectiveApi, effectiveFrontend);
if (config.addons && config.addons.length > 0) {
validateAddonsAgainstFrontends(config.addons, effectiveFrontend);
config.addons = [...new Set(config.addons)];
}
validateExamplesCompatibility(
config.examples ?? [],
effectiveBackend,
effectiveDatabase,
effectiveFrontend ?? [],
);
}
export function processProvidedFlagsWithoutValidation(
options: CLIInput,
projectName?: string,
): Partial<ProjectConfig> {
const config: Partial<ProjectConfig> = {};
const config = processFlags(options, projectName);
if (options.api) {
config.api = options.api as API;
}
if (options.backend) {
config.backend = options.backend as Backend;
}
if (options.database) {
config.database = options.database as Database;
}
if (options.orm) {
config.orm = options.orm as ORM;
}
if (options.auth !== undefined) {
config.auth = options.auth;
}
if (options.git !== undefined) {
config.git = options.git;
}
if (options.install !== undefined) {
config.install = options.install;
}
if (options.runtime) {
config.runtime = options.runtime as Runtime;
}
if (options.dbSetup) {
config.dbSetup = options.dbSetup as DatabaseSetup;
}
if (options.packageManager) {
config.packageManager = options.packageManager as PackageManager;
}
if (options.webDeploy) {
config.webDeploy = options.webDeploy as WebDeploy;
}
const derivedName = deriveProjectName(projectName, options.projectDirectory);
if (derivedName) {
const nameToValidate = projectName
? path.basename(projectName)
: derivedName;
const result = ProjectNameSchema.safeParse(nameToValidate);
if (!result.success) {
throw new Error(
`Invalid project name: ${result.error.issues[0]?.message}`,
);
}
config.projectName = projectName || derivedName;
}
if (options.frontend && options.frontend.length > 0) {
config.frontend = processArrayOption(options.frontend);
}
if (options.addons && options.addons.length > 0) {
config.addons = processArrayOption(options.addons);
}
if (options.examples && options.examples.length > 0) {
config.examples = processArrayOption(options.examples);
const validatedProjectName = extractAndValidateProjectName(
projectName,
options.projectDirectory,
true,
);
if (validatedProjectName) {
config.projectName = validatedProjectName;
}
return config;
}
export function getProvidedFlags(options: CLIInput): Set<string> {
return new Set(
Object.keys(options).filter(
(key) => options[key as keyof CLIInput] !== undefined,
),
);
export function validateConfigCompatibility(
config: Partial<ProjectConfig>,
providedFlags?: Set<string>,
options?: CLIInput,
) {
if (options?.yolo) return;
if (options && providedFlags) {
validateFullConfig(config, providedFlags, options);
} else {
validateConfigForProgrammaticUse(config);
}
}
export { getProvidedFlags };

View File

@@ -21,6 +21,7 @@
"!bts.jsonc",
"!**/.expo",
"!**/.wrangler",
"!**/.alchemy",
"!**/wrangler.jsonc",
"!**/.source"
]

View File

@@ -4,7 +4,7 @@
"type": "stdio",
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"]
}{{#if (or (eq runtime "workers") (eq webDeploy "workers"))}},
}{{#if (or (eq runtime "workers") (eq webDeploy "wrangler"))}},
"cloudflare": {
"command": "npx",
"args": ["mcp-remote", "https://docs.mcp.cloudflare.com/sse"]

View File

@@ -16,6 +16,7 @@
"!bts.jsonc",
"!**/.expo",
"!**/.wrangler",
"!**/.alchemy",
"!**/wrangler.jsonc",
"!**/.source"
]

View File

@@ -21,6 +21,13 @@ export const auth = betterAuth({
],
emailAndPassword: {
enabled: true,
},
advanced: {
defaultCookieAttributes: {
sameSite: "none",
secure: true,
httpOnly: true,
},
}
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
, plugins: [expo()]
@@ -54,8 +61,13 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
secret: process.env.BETTER_AUTH_SECRET,
baseURL: process.env.BETTER_AUTH_URL,
advanced: {
defaultCookieAttributes: {
sameSite: "none",
secure: true,
httpOnly: true,
},
},
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
plugins: [expo()],
{{/if}}
@@ -73,7 +85,7 @@ import * as schema from "../db/schema/auth";
import { env } from "cloudflare:workers";
export const auth = betterAuth({
database: drizzleAdapter(db, {
database: drizzleAdapter(db, {
{{#if (eq database "postgres")}}provider: "pg",{{/if}}
{{#if (eq database "sqlite")}}provider: "sqlite",{{/if}}
{{#if (eq database "mysql")}}provider: "mysql",{{/if}}
@@ -85,6 +97,13 @@ export const auth = betterAuth({
},
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
advanced: {
defaultCookieAttributes: {
sameSite: "none",
secure: true,
httpOnly: true,
},
},
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
plugins: [expo()],
{{/if}}
@@ -110,6 +129,13 @@ export const auth = betterAuth({
],
emailAndPassword: {
enabled: true,
},
advanced: {
defaultCookieAttributes: {
sameSite: "none",
secure: true,
httpOnly: true,
},
}
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
, plugins: [expo()]
@@ -133,9 +159,16 @@ export const auth = betterAuth({
],
emailAndPassword: {
enabled: true,
},
advanced: {
defaultCookieAttributes: {
sameSite: "none",
secure: true,
httpOnly: true,
},
}
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
, plugins: [expo()]
{{/if}}
});
{{/if}}
{{/if}}

View File

@@ -16,6 +16,7 @@ dist/
.idea/usage.statistics.xml
.idea/shelf
.wrangler
.alchemy
/.next/
.vercel

View File

@@ -20,7 +20,7 @@
{{else if (eq runtime "bun")}}
"bun"
{{else if (eq runtime "workers")}}
"./worker-configuration",
"@cloudflare/workers-types",
"node"
{{else}}
"node",

View File

@@ -1,2 +1,4 @@
node_modules
.turbo
.alchemy
.env

View File

@@ -7,11 +7,13 @@ export default defineConfig({
// DOCS: https://orm.drizzle.team/docs/guides/d1-http-with-drizzle-kit
dialect: "sqlite",
driver: "d1-http",
{{#if (eq serverDeploy "wrangler")}}
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
token: process.env.CLOUDFLARE_D1_TOKEN!,
},
{{/if}}
{{else}}
dialect: "turso",
dbCredentials: {

View File

@@ -0,0 +1,208 @@
import alchemy from "alchemy";
{{#if (eq webDeploy "alchemy")}}
{{#if (includes frontend "next")}}
import { Next } from "alchemy/cloudflare";
{{else if (includes frontend "nuxt")}}
import { Nuxt } from "alchemy/cloudflare";
{{else if (includes frontend "svelte")}}
import { SvelteKit } from "alchemy/cloudflare";
{{else if (includes frontend "tanstack-start")}}
import { TanStackStart } from "alchemy/cloudflare";
{{else if (includes frontend "tanstack-router")}}
import { Vite } from "alchemy/cloudflare";
{{else if (includes frontend "react-router")}}
import { ReactRouter } from "alchemy/cloudflare";
{{else if (includes frontend "solid")}}
import { Vite } from "alchemy/cloudflare";
{{/if}}
{{/if}}
{{#if (eq serverDeploy "alchemy")}}
import { Worker, WranglerJson } from "alchemy/cloudflare";
{{#if (eq dbSetup "d1")}}
import { D1Database } from "alchemy/cloudflare";
{{/if}}
{{/if}}
{{#if (and (eq serverDeploy "alchemy") (eq dbSetup "d1"))}}
import { Exec } from "alchemy/os";
{{/if}}
import { config } from "dotenv";
{{#if (and (eq webDeploy "alchemy") (eq serverDeploy "alchemy"))}}
config({ path: "./.env" });
config({ path: "./apps/web/.env" });
config({ path: "./apps/server/.env" });
{{else if (or (eq webDeploy "alchemy") (eq serverDeploy "alchemy"))}}
config({ path: "./.env" });
{{/if}}
const app = await alchemy("{{projectName}}");
{{#if (and (eq serverDeploy "alchemy") (eq dbSetup "d1"))}}
await Exec("db-generate", {
{{#if (and (eq webDeploy "alchemy") (eq serverDeploy "alchemy"))}}cwd: "apps/server",{{/if}}
command: "{{packageManager}} run db:generate",
});
const db = await D1Database("database", {
name: `${app.name}-${app.stage}-db`,
migrationsDir: "apps/server/src/db/migrations",
});
{{/if}}
{{#if (eq webDeploy "alchemy")}}
{{#if (includes frontend "next")}}
export const web = await Next("web", {
{{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}}
name: `${app.name}-${app.stage}-web`,
bindings: {
{{#if (eq backend "convex")}}
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL || "",
{{else}}
NEXT_PUBLIC_SERVER_URL: process.env.NEXT_PUBLIC_SERVER_URL || "",
{{/if}}
},
dev: {
command: "{{packageManager}} run dev"
}
});
{{else if (includes frontend "nuxt")}}
export const web = await Nuxt("web", {
{{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}}
name: `${app.name}-${app.stage}-web`,
bindings: {
{{#if (eq backend "convex")}}
NUXT_PUBLIC_CONVEX_URL: process.env.NUXT_PUBLIC_CONVEX_URL || "",
{{else}}
NUXT_PUBLIC_SERVER_URL: process.env.NUXT_PUBLIC_SERVER_URL || "",
{{/if}}
},
dev: {
command: "{{packageManager}} run dev"
}
});
{{else if (includes frontend "svelte")}}
export const web = await SvelteKit("web", {
{{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}}
name: `${app.name}-${app.stage}-web`,
bindings: {
{{#if (eq backend "convex")}}
PUBLIC_CONVEX_URL: process.env.PUBLIC_CONVEX_URL || "",
{{else}}
PUBLIC_SERVER_URL: process.env.PUBLIC_SERVER_URL || "",
{{/if}}
},
dev: {
command: "{{packageManager}} run dev"
}
});
{{else if (includes frontend "tanstack-start")}}
export const web = await TanStackStart("web", {
{{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}}
name: `${app.name}-${app.stage}-web`,
bindings: {
{{#if (eq backend "convex")}}
VITE_CONVEX_URL: process.env.VITE_CONVEX_URL || "",
{{else}}
VITE_SERVER_URL: process.env.VITE_SERVER_URL || "",
{{/if}}
},
dev: {
command: "{{packageManager}} run dev"
}
});
{{else if (includes frontend "tanstack-router")}}
export const web = await Vite("web", {
{{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}}
name: `${app.name}-${app.stage}-web`,
assets: "dist",
bindings: {
{{#if (eq backend "convex")}}
VITE_CONVEX_URL: process.env.VITE_CONVEX_URL || "",
{{else}}
VITE_SERVER_URL: process.env.VITE_SERVER_URL || "",
{{/if}}
},
dev: {
command: "{{packageManager}} run dev"
}
});
{{else if (includes frontend "react-router")}}
export const web = await ReactRouter("web", {
{{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}}
name: `${app.name}-${app.stage}-web`,
bindings: {
{{#if (eq backend "convex")}}
VITE_CONVEX_URL: process.env.VITE_CONVEX_URL || "",
{{else}}
VITE_SERVER_URL: process.env.VITE_SERVER_URL || "",
{{/if}}
},
dev: {
command: "{{packageManager}} run dev"
}
});
{{else if (includes frontend "solid")}}
export const web = await Vite("web", {
{{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}}
name: `${app.name}-${app.stage}-web`,
assets: "dist",
bindings: {
{{#if (eq backend "convex")}}
VITE_CONVEX_URL: process.env.VITE_CONVEX_URL || "",
{{else}}
VITE_SERVER_URL: process.env.VITE_SERVER_URL || "",
{{/if}}
},
dev: {
command: "{{packageManager}} run dev"
}
});
{{/if}}
{{/if}}
{{#if (eq serverDeploy "alchemy")}}
export const server = await Worker("server", {
{{#if (eq webDeploy "alchemy")}}cwd: "apps/server",{{/if}}
name: `${app.name}-${app.stage}`,
entrypoint: "src/index.ts",
compatibility: "node",
bindings: {
{{#if (eq dbSetup "d1")}}
DB: db,
{{else if (and (ne database "none") (ne dbSetup "none"))}}
DATABASE_URL: alchemy.secret(process.env.DATABASE_URL),
{{/if}}
CORS_ORIGIN: process.env.CORS_ORIGIN || "",
{{#if auth}}
BETTER_AUTH_SECRET: alchemy.secret(process.env.BETTER_AUTH_SECRET),
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL || "",
{{/if}}
{{#if (includes examples "ai")}}
GOOGLE_GENERATIVE_AI_API_KEY: alchemy.secret(process.env.GOOGLE_GENERATIVE_AI_API_KEY),
{{/if}}
{{#if (eq dbSetup "turso")}}
DATABASE_AUTH_TOKEN: alchemy.secret(process.env.DATABASE_AUTH_TOKEN),
{{/if}}
},
dev: {
port: 3000,
},
});
await WranglerJson("wrangler", {
worker: server,
});
{{/if}}
{{#if (and (eq webDeploy "alchemy") (eq serverDeploy "alchemy"))}}
console.log(`Web -> ${web.url}`);
console.log(`Server -> ${server.url}`);
{{else if (eq webDeploy "alchemy")}}
console.log(`Web -> ${web.url}`);
{{else if (eq serverDeploy "alchemy")}}
console.log(`Server -> ${server.url}`);
{{/if}}
await app.finalize();

View File

@@ -0,0 +1,20 @@
// This file infers types for the cloudflare:workers environment from your Alchemy Worker.
// @see https://alchemy.run/concepts/bindings/#type-safe-bindings
{{#if (eq webDeploy "alchemy")}}
import type { server } from "../../alchemy.run";
{{else}}
import type { server } from "./alchemy.run";
{{/if}}
export type CloudflareEnv = typeof server.Env;
declare global {
type Env = CloudflareEnv;
}
declare module "cloudflare:workers" {
namespace Cloudflare {
export interface Env extends CloudflareEnv {}
}
}

View File

@@ -0,0 +1,11 @@
// This is a temporary wrangler.jsonc file that will be overwritten by alchemy
// It's only here so that `wrangler dev` can work or use alchemy dev instead
{
"name": "{{projectName}}",
"main": "src/index.ts",
"compatibility_date": "2025-08-16",
"compatibility_flags": [
"nodejs_compat",
"nodejs_compat_populate_process_env"
]
}

View File

@@ -3,7 +3,7 @@
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "{{projectName}}",
"main": "./.output/server/index.mjs",
"compatibility_date": "2025-07-01",

View File

@@ -1,5 +1,5 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"$schema": "./node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "{{projectName}}",
"compatibility_date": "2025-07-05",

View File

@@ -1,5 +1,5 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "{{projectName}}",
"compatibility_date": "2025-04-03",
"assets": {

View File

@@ -1,5 +1,5 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "{{projectName}}",
"compatibility_date": "2025-04-03",
"assets": {

View File

@@ -1,5 +1,5 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "{{projectName}}",
"main": ".output/server/index.mjs",
"compatibility_date": "2025-07-05",

View File

@@ -1,5 +1,5 @@
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "{{projectName}}",
"compatibility_date": "2025-04-03",
"assets": {

View File

@@ -3,7 +3,7 @@
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "../../node_modules/wrangler/config-schema.json",
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "{{projectName}}",
"main": ".svelte-kit/cloudflare/_worker.js",
"compatibility_date": "2025-07-05",

View File

@@ -5,6 +5,8 @@
.nitro
.cache
dist
.wrangler
.alchemy
# Node dependencies
node_modules
@@ -22,3 +24,4 @@ logs
.env
.env.*
!.env.example

View File

@@ -12,9 +12,7 @@
},
{
"path": "./.nuxt/tsconfig.node.json"
}
{{#unless (or (eq backend "convex") (eq backend "none"))}}
,
}{{#unless (or (eq backend "convex") (eq backend "none"))}},
{
"path": "../server"
}

View File

@@ -26,6 +26,7 @@
.vercel
.netlify
.wrangler
.alchemy
# Environment & local files
.env*

View File

@@ -6,7 +6,6 @@ import { NavLink } from "react-router";
{{else if (or (includes frontend "tanstack-router") (includes frontend "tanstack-start"))}}
import { Link } from "@tanstack/react-router";
{{/if}}
{{#unless (includes frontend "tanstack-start")}}
import { ModeToggle } from "./mode-toggle";
{{/unless}}

View File

@@ -7,4 +7,5 @@ dist-ssr
.env.*
.wrangler
.alchemy
.dev.vars*

View File

@@ -13,7 +13,6 @@
"@tanstack/router-plugin": "^1.109.2",
"@tanstack/solid-form": "^1.9.0",
"@tanstack/solid-router": "^1.110.0",
"@tanstack/solid-router-devtools": "^1.109.2",
"lucide-solid": "^0.507.0",
"solid-js": "^1.9.4",
"tailwindcss": "^4.0.6",

View File

@@ -5,6 +5,7 @@ node_modules
.vercel
.netlify
.wrangler
.alchemy
/.svelte-kit
/build

View File

@@ -12,20 +12,18 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.20.7",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.4",
"svelte": "^5.28.2",
"svelte-check": "^4.1.6",
"tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"@tanstack/svelte-query-devtools": "^5.74.6",
"vite": "^7.0.2"
"@sveltejs/adapter-auto": "^6.1.0",
"@sveltejs/kit": "^2.31.1",
"@sveltejs/vite-plugin-svelte": "^6.1.2",
"@tailwindcss/vite": "^4.1.12",
"svelte": "^5.38.1",
"svelte-check": "^4.3.1",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2",
"vite": "^7.1.2"
},
"dependencies": {
"@tanstack/svelte-form": "^1.7.0",
"@tanstack/svelte-query": "^5.74.4",
"zod": "^4.0.2"
"@tanstack/svelte-form": "^1.19.2",
"zod": "^4.0.17"
}
}

View File

@@ -1,13 +1,7 @@
import { join } from "node:path";
import consola from "consola";
import { execa } from "execa";
import {
ensureDirSync,
existsSync,
readFileSync,
readJsonSync,
removeSync,
} from "fs-extra";
import { ensureDir, existsSync, readFile, readJson, remove } from "fs-extra";
import * as JSONC from "jsonc-parser";
import { FailedToExitError } from "trpc-cli";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
@@ -17,6 +11,8 @@ async function runCli(argv: string[], cwd: string) {
const previous = process.cwd();
process.chdir(cwd);
try {
consola.info(`Running CLI command: bts ${argv.join(" ")}`);
const cli = createBtsCli();
await cli
.run({
@@ -37,12 +33,12 @@ async function runCli(argv: string[], cwd: string) {
}
}
function createTmpDir(_prefix: string) {
async function createTmpDir(_prefix: string) {
const dir = join(__dirname, "..", ".smoke");
if (existsSync(dir)) {
removeSync(dir);
await remove(dir);
}
ensureDirSync(dir);
await ensureDir(dir);
return dir;
}
@@ -50,6 +46,10 @@ async function runCliExpectingError(args: string[], cwd: string) {
const previous = process.cwd();
process.chdir(cwd);
try {
consola.info(
`Running CLI command (expecting error): bts ${args.join(" ")}`,
);
const cli = createBtsCli();
let threw = false;
await cli
@@ -72,15 +72,15 @@ async function runCliExpectingError(args: string[], cwd: string) {
}
}
function assertScaffoldedProject(dir: string) {
async function assertScaffoldedProject(dir: string) {
const pkgJsonPath = join(dir, "package.json");
expect(existsSync(pkgJsonPath)).toBe(true);
const pkg = readJsonSync(pkgJsonPath);
const pkg = await readJson(pkgJsonPath);
expect(typeof pkg.name).toBe("string");
expect(Array.isArray(pkg.workspaces)).toBe(true);
}
function assertProjectStructure(
async function assertProjectStructure(
dir: string,
options: {
hasWeb?: boolean;
@@ -107,6 +107,13 @@ function assertProjectStructure(
expect(existsSync(join(dir, "package.json"))).toBe(true);
expect(existsSync(join(dir, ".gitignore"))).toBe(true);
try {
const pmConfig = (await readBtsConfig(dir)) as { packageManager?: string };
if (pmConfig && pmConfig.packageManager === "bun") {
expect(existsSync(join(dir, "bunfig.toml"))).toBe(true);
}
} catch {}
if (hasWeb) {
expect(existsSync(join(dir, "apps", "web", "package.json"))).toBe(true);
const webDir = join(dir, "apps", "web");
@@ -132,6 +139,26 @@ function assertProjectStructure(
hasAppDir ||
hasPublicDir,
).toBe(true);
const bts = (await readBtsConfig(dir)) as {
webDeploy?: string;
serverDeploy?: string;
frontend?: string[];
};
if (bts.webDeploy === "wrangler") {
expect(existsSync(join(dir, "apps", "web", "wrangler.jsonc"))).toBe(true);
}
if (
bts.webDeploy === "alchemy" &&
bts.serverDeploy !== "alchemy" &&
bts.frontend &&
bts.frontend.length > 0
) {
const webRunner = join(dir, "apps", "web", "alchemy.run.ts");
consola.info(`Checking Alchemy web runner at: ${webRunner}`);
expect(existsSync(webRunner)).toBe(true);
}
}
if (hasNative) {
@@ -154,8 +181,42 @@ function assertProjectStructure(
expect(existsSync(join(dir, "apps", "server", "src", "index.ts"))).toBe(
true,
);
expect(existsSync(join(dir, "apps", "server", "tsconfig.json"))).toBe(true);
const bts = (await readBtsConfig(dir)) as {
serverDeploy?: string;
webDeploy?: string;
};
if (bts.serverDeploy === "wrangler") {
expect(existsSync(join(dir, "apps", "server", "wrangler.jsonc"))).toBe(
true,
);
}
if (bts.serverDeploy === "alchemy") {
const serverRunner = join(dir, "apps", "server", "alchemy.run.ts");
const serverEnv = join(dir, "apps", "server", "env.d.ts");
consola.info(`Checking Alchemy server runner at: ${serverRunner}`);
consola.info(`Checking Alchemy env types at: ${serverEnv}`);
expect(existsSync(serverRunner)).toBe(true);
expect(existsSync(serverEnv)).toBe(true);
}
}
try {
const btsAll = (await readBtsConfig(dir)) as {
serverDeploy?: string;
webDeploy?: string;
};
if (btsAll.serverDeploy === "alchemy" && btsAll.webDeploy === "alchemy") {
const rootRunner = join(dir, "alchemy.run.ts");
const serverEnv = join(dir, "apps", "server", "env.d.ts");
consola.info(`Checking Alchemy root runner at: ${rootRunner}`);
consola.info(`Checking Alchemy env types at: ${serverEnv}`);
expect(existsSync(rootRunner)).toBe(true);
expect(existsSync(serverEnv)).toBe(true);
}
} catch {}
if (hasConvexBackend) {
const hasPackagesDir = existsSync(join(dir, "packages"));
const hasConvexRelated =
@@ -208,11 +269,11 @@ function assertProjectStructure(
}
expect(existsSync(join(dir, "bts.jsonc"))).toBe(true);
const btsConfig = readFileSync(join(dir, "bts.jsonc"), "utf8");
const btsConfig = await readFile(join(dir, "bts.jsonc"), "utf8");
expect(btsConfig).toContain("Better-T-Stack configuration");
}
function assertBtsConfig(
async function assertBtsConfig(
dir: string,
expectedConfig: Partial<{
frontend: string[];
@@ -225,11 +286,13 @@ function assertBtsConfig(
api: string;
runtime: string;
packageManager: string;
webDeploy: string;
serverDeploy: string;
}>,
) {
const btsConfigPath = join(dir, "bts.jsonc");
expect(existsSync(btsConfigPath)).toBe(true);
const content = readFileSync(btsConfigPath, "utf8");
const content = await readFile(btsConfigPath, "utf8");
type BtsConfig = {
frontend?: string[];
@@ -242,6 +305,8 @@ function assertBtsConfig(
api?: string;
runtime?: string;
packageManager?: string;
webDeploy?: string;
serverDeploy?: string;
};
const errors: JSONC.ParseError[] = [];
@@ -286,13 +351,19 @@ function assertBtsConfig(
if (expectedConfig.packageManager) {
expect(config.packageManager).toBe(expectedConfig.packageManager);
}
if (expectedConfig.webDeploy) {
expect(config.webDeploy).toBe(expectedConfig.webDeploy);
}
if (expectedConfig.serverDeploy) {
expect(config.serverDeploy).toBe(expectedConfig.serverDeploy);
}
}
function readBtsConfig(dir: string) {
async function readBtsConfig(dir: string) {
const btsConfigPath = join(dir, "bts.jsonc");
if (!existsSync(btsConfigPath)) return {} as Record<string, unknown>;
const content = readFileSync(btsConfigPath, "utf8");
const content = await readFile(btsConfigPath, "utf8");
const errors: JSONC.ParseError[] = [];
const parsed = JSONC.parse(content, errors, {
allowTrailingComma: true,
@@ -309,7 +380,7 @@ describe("create-better-t-stack smoke", () => {
let workdir: string;
beforeAll(async () => {
workdir = createTmpDir("cli");
workdir = await createTmpDir("cli");
consola.start("Building CLI...");
const buildProc = execa("bun", ["run", "build"], {
cwd: join(__dirname, ".."),
@@ -329,7 +400,6 @@ describe("create-better-t-stack smoke", () => {
consola.info("Programmatic CLI mode");
});
// Exhaustive matrix: all frontends x standard backends (no db, no orm, no api, no auth)
describe("frontend x backend matrix (no db, no api)", () => {
const FRONTENDS = [
"tanstack-router",
@@ -391,15 +461,15 @@ describe("create-better-t-stack smoke", () => {
);
const projectDir = join(workdir, projectName);
assertScaffoldedProject(projectDir);
assertProjectStructure(projectDir, {
await assertScaffoldedProject(projectDir);
await assertProjectStructure(projectDir, {
hasWeb: WEB_FRONTENDS.has(frontend),
hasNative:
frontend === "native-nativewind" ||
frontend === "native-unistyles",
hasServer: true,
});
assertBtsConfig(projectDir, {
await assertBtsConfig(projectDir, {
frontend: [frontend],
backend,
database: "none",
@@ -474,9 +544,9 @@ describe("create-better-t-stack smoke", () => {
});
}
});
afterAll(() => {
afterAll(async () => {
try {
removeSync(workdir);
await remove(workdir);
} catch {}
});
@@ -1101,6 +1171,52 @@ describe("create-better-t-stack smoke", () => {
});
});
it("scaffolds with PostgreSQL + Drizzle", async () => {
const projectName = "app-postgres-drizzle";
await runCli(
[
projectName,
"--yes",
"--frontend",
"tanstack-router",
"--backend",
"hono",
"--runtime",
"bun",
"--database",
"postgres",
"--orm",
"drizzle",
"--api",
"trpc",
"--no-auth",
"--addons",
"none",
"--db-setup",
"none",
"--examples",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
],
workdir,
);
const projectDir = join(workdir, projectName);
assertScaffoldedProject(projectDir);
assertProjectStructure(projectDir, {
hasWeb: true,
hasServer: true,
hasDatabase: true,
});
assertBtsConfig(projectDir, {
database: "postgres",
orm: "drizzle",
});
});
it("scaffolds with MongoDB + Mongoose", async () => {
const projectName = "app-mongo-mongoose";
await runCli(
@@ -1457,6 +1573,116 @@ describe("create-better-t-stack smoke", () => {
workdir,
);
});
it("rejects Turso db-setup with non-SQLite database", async () => {
await runCliExpectingError(
[
"invalid-combo",
"--yes",
"--frontend",
"tanstack-router",
"--backend",
"hono",
"--runtime",
"bun",
"--database",
"postgres",
"--orm",
"prisma",
"--api",
"none",
"--no-auth",
"--addons",
"none",
"--db-setup",
"turso",
"--examples",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
],
workdir,
);
});
});
describe("YOLO mode", () => {
it("bypasses db-setup/database validation (Turso + Postgres + Prisma)", async () => {
const projectName = "app-yolo-turso-postgres";
await runCli(
[
projectName,
"--yes",
"--frontend",
"tanstack-router",
"--backend",
"hono",
"--runtime",
"bun",
"--database",
"postgres",
"--orm",
"prisma",
"--api",
"none",
"--no-auth",
"--addons",
"none",
"--db-setup",
"turso",
"--examples",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
"--yolo",
],
workdir,
);
const projectDir = join(workdir, projectName);
await assertScaffoldedProject(projectDir);
await assertBtsConfig(projectDir, {
database: "postgres",
orm: "prisma",
});
});
it("bypasses web-deploy requires web frontend (none + wrangler)", async () => {
const projectName = "app-yolo-webdeploy-no-frontend";
await runCli(
[
projectName,
"--yes",
"--frontend",
"none",
"--backend",
"none",
"--web-deploy",
"wrangler",
"--addons",
"none",
"--examples",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
"--yolo",
],
workdir,
);
const projectDir = join(workdir, projectName);
await assertScaffoldedProject(projectDir);
await assertBtsConfig(projectDir, {
backend: "none",
webDeploy: "wrangler",
});
});
});
describe("runtime compatibility", () => {
@@ -1500,10 +1726,6 @@ describe("create-better-t-stack smoke", () => {
runtime: "workers",
orm: "drizzle",
});
expect(
existsSync(join(projectDir, "apps", "server", "wrangler.jsonc")),
).toBe(true);
});
it("rejects incompatible runtime and backend combinations", async () => {
@@ -1810,7 +2032,6 @@ describe("create-better-t-stack smoke", () => {
});
});
// Git and install flag variations
it("scaffolds with git enabled", async () => {
const projectName = "app-with-git";
await runCli(
@@ -1855,6 +2076,8 @@ describe("create-better-t-stack smoke", () => {
[
projectName,
"--yes",
"--directory-conflict",
"overwrite",
"--frontend",
"tanstack-router",
"--backend",
@@ -1887,7 +2110,6 @@ describe("create-better-t-stack smoke", () => {
expect(existsSync(join(projectDir, "node_modules"))).toBe(true);
});
// Additional addons beyond turborepo and biome
it("scaffolds with PWA addon", async () => {
const projectName = "app-addon-pwa";
await runCli(
@@ -2008,7 +2230,6 @@ describe("create-better-t-stack smoke", () => {
});
});
// Authentication combinations
it("scaffolds with authentication enabled", async () => {
const projectName = "app-with-auth";
await runCli(
@@ -2055,7 +2276,6 @@ describe("create-better-t-stack smoke", () => {
});
});
// MySQL database
it("scaffolds with MySQL + Prisma", async () => {
const projectName = "app-mysql-prisma";
await runCli(
@@ -2138,7 +2358,6 @@ describe("create-better-t-stack smoke", () => {
});
});
// oRPC API with more frontends
it("scaffolds oRPC with Next.js", async () => {
const projectName = "app-orpc-next";
await runCli(
@@ -2303,7 +2522,6 @@ describe("create-better-t-stack smoke", () => {
});
});
// Backend next combinations
it("scaffolds with Next.js backend", async () => {
const projectName = "app-backend-next";
await runCli(
@@ -2345,7 +2563,6 @@ describe("create-better-t-stack smoke", () => {
});
});
// Node runtime combinations
it("scaffolds with Node runtime", async () => {
const projectName = "app-node-runtime";
await runCli(
@@ -2459,14 +2676,16 @@ describe("create-better-t-stack smoke", () => {
"app-orpc-solid",
"app-backend-next",
"app-node-runtime",
].forEach((n) => projectNames.add(n));
].forEach((n) => {
projectNames.add(n);
});
const detectPackageManager = (
const detectPackageManager = async (
projectDir: string,
): "bun" | "pnpm" | "npm" => {
): Promise<"bun" | "pnpm" | "npm"> => {
const bts = readBtsConfig(projectDir) as { packageManager?: string };
const pkgJsonPath = join(projectDir, "package.json");
const pkg = existsSync(pkgJsonPath) ? readJsonSync(pkgJsonPath) : {};
const pkg = existsSync(pkgJsonPath) ? await readJson(pkgJsonPath) : {};
const pkgMgrField =
(pkg.packageManager as string | undefined) || bts.packageManager;
@@ -2531,7 +2750,7 @@ describe("create-better-t-stack smoke", () => {
consola.info(`${dirName} not found, skipping`);
return;
}
const pm = detectPackageManager(projectDir);
const pm = await detectPackageManager(projectDir);
consola.info(`Processing ${dirName} (pm=${pm})`);
try {
@@ -2552,11 +2771,11 @@ describe("create-better-t-stack smoke", () => {
}
const pkgJsonPath = join(projectDir, "package.json");
const pkg = readJsonSync(pkgJsonPath);
const pkg = await readJson(pkgJsonPath);
const scripts = pkg.scripts || {};
consola.info(`Scripts: ${Object.keys(scripts).join(", ")}`);
const bts = readBtsConfig(projectDir) as {
const bts = (await readBtsConfig(projectDir)) as {
backend?: string;
frontend?: string[];
};
@@ -2603,33 +2822,25 @@ describe("create-better-t-stack smoke", () => {
if (scripts["check-types"]) {
consola.start(`Type checking ${dirName}...`);
try {
const typeRes = await runScript(
pm,
projectDir,
"check-types",
[],
120_000,
);
if (typeRes.exitCode === 0) {
consola.success(`${dirName} type check passed`);
} else {
consola.warn(
`${dirName} type check failed (exit code ${typeRes.exitCode}) - likely due to missing generated files`,
);
}
} catch (error) {
consola.warn(
`${dirName} type check failed - likely due to missing generated files:`,
error.message,
);
}
const typeRes = await runScript(
pm,
projectDir,
"check-types",
[],
120_000,
);
expect(typeRes.exitCode).toBe(0);
consola.success(`${dirName} type check passed`);
}
if (!scripts.build && !scripts["check-types"]) {
consola.info(
`No build or check-types script for ${dirName}, skipping`,
);
} else if (!scripts.build && scripts["check-types"]) {
consola.info(
`Only check-types script available for ${dirName}, type checking will be performed`,
);
}
} catch (error) {
consola.error(`${dirName} failed`, error);
@@ -2639,4 +2850,279 @@ describe("create-better-t-stack smoke", () => {
}
},
);
describe("deploy combinations", () => {
it("scaffolds workers runtime + web deploy wrangler", async () => {
const projectName = "app-web-wrangler";
await runCli(
[
projectName,
"--yes",
"--frontend",
"tanstack-router",
"--backend",
"hono",
"--runtime",
"workers",
"--web-deploy",
"wrangler",
"--database",
"none",
"--orm",
"none",
"--api",
"none",
"--no-auth",
"--addons",
"none",
"--db-setup",
"none",
"--examples",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
],
workdir,
);
const projectDir = join(workdir, projectName);
await assertScaffoldedProject(projectDir);
await assertBtsConfig(projectDir, {
frontend: ["tanstack-router"],
backend: "hono",
runtime: "workers",
});
});
it("scaffolds workers runtime + web deploy alchemy", async () => {
const projectName = "app-web-alchemy";
await runCli(
[
projectName,
"--yes",
"--frontend",
"tanstack-router",
"--backend",
"hono",
"--runtime",
"workers",
"--web-deploy",
"alchemy",
"--database",
"none",
"--orm",
"none",
"--api",
"none",
"--no-auth",
"--addons",
"none",
"--db-setup",
"none",
"--examples",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
],
workdir,
);
const projectDir = join(workdir, projectName);
await assertScaffoldedProject(projectDir);
await assertBtsConfig(projectDir, {
frontend: ["tanstack-router"],
backend: "hono",
runtime: "workers",
});
});
it("scaffolds workers runtime + server deploy alchemy (server-only)", async () => {
const projectName = "app-server-only-alchemy";
await runCli(
[
projectName,
"--yes",
"--directory-conflict",
"overwrite",
"--frontend",
"tanstack-router",
"--backend",
"hono",
"--runtime",
"workers",
"--server-deploy",
"alchemy",
"--database",
"none",
"--orm",
"none",
"--api",
"none",
"--no-auth",
"--addons",
"none",
"--db-setup",
"none",
"--examples",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
],
workdir,
);
const projectDir = join(workdir, projectName);
await assertScaffoldedProject(projectDir);
await assertBtsConfig(projectDir, {
frontend: ["tanstack-router"],
backend: "hono",
runtime: "workers",
serverDeploy: "alchemy",
});
consola.info("Verifying server-only Alchemy artifacts");
expect(
existsSync(join(projectDir, "apps", "server", "alchemy.run.ts")),
).toBe(true);
expect(existsSync(join(projectDir, "apps", "server", "env.d.ts"))).toBe(
true,
);
expect(existsSync(join(projectDir, "alchemy.run.ts"))).toBe(false);
});
it("scaffolds workers runtime + server deploy wrangler", async () => {
const projectName = "app-server-wrangler";
await runCli(
[
projectName,
"--yes",
"--frontend",
"tanstack-router",
"--backend",
"hono",
"--runtime",
"workers",
"--server-deploy",
"wrangler",
"--database",
"none",
"--orm",
"none",
"--api",
"none",
"--no-auth",
"--addons",
"none",
"--db-setup",
"none",
"--examples",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
],
workdir,
);
const projectDir = join(workdir, projectName);
await assertScaffoldedProject(projectDir);
await assertBtsConfig(projectDir, {
frontend: ["tanstack-router"],
backend: "hono",
runtime: "workers",
});
});
it("scaffolds workers runtime + server deploy alchemy", async () => {
const projectName = "app-server-alchemy";
await runCli(
[
projectName,
"--yes",
"--frontend",
"tanstack-router",
"--backend",
"hono",
"--runtime",
"workers",
"--server-deploy",
"alchemy",
"--database",
"none",
"--orm",
"none",
"--api",
"none",
"--no-auth",
"--addons",
"none",
"--db-setup",
"none",
"--examples",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
],
workdir,
);
const projectDir = join(workdir, projectName);
await assertScaffoldedProject(projectDir);
await assertBtsConfig(projectDir, {
frontend: ["tanstack-router"],
backend: "hono",
runtime: "workers",
});
});
it("scaffolds web deploy wrangler with backend none (no server deploy)", async () => {
const projectName = "app-web-wrangler-only";
await runCli(
[
projectName,
"--yes",
"--frontend",
"tanstack-router",
"--backend",
"none",
"--web-deploy",
"wrangler",
"--database",
"none",
"--orm",
"none",
"--api",
"none",
"--no-auth",
"--addons",
"none",
"--db-setup",
"none",
"--examples",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
],
workdir,
);
const projectDir = join(workdir, projectName);
await assertScaffoldedProject(projectDir);
await assertBtsConfig(projectDir, {
frontend: ["tanstack-router"],
backend: "none",
webDeploy: "wrangler",
});
});
});
});

Some files were not shown because too many files have changed in this diff Show More