mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
feat: add command (#337)
This commit is contained in:
@@ -60,15 +60,16 @@
|
||||
"globby": "^14.1.0",
|
||||
"gradient-string": "^3.0.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"posthog-node": "^4.18.0",
|
||||
"trpc-cli": "^0.8.0",
|
||||
"zod": "^3.25.57"
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^24.0.0",
|
||||
"tsdown": "^0.12.7",
|
||||
"@types/node": "^24.0.3",
|
||||
"tsdown": "^0.12.8",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,3 +110,13 @@ export const dependencyVersionMap = {
|
||||
} as const;
|
||||
|
||||
export type AvailableDependencies = keyof typeof dependencyVersionMap;
|
||||
|
||||
export const ADDON_COMPATIBILITY = {
|
||||
pwa: ["tanstack-router", "react-router", "solid"],
|
||||
tauri: ["tanstack-router", "react-router", "nuxt", "svelte", "solid"],
|
||||
biome: [],
|
||||
husky: [],
|
||||
turborepo: [],
|
||||
starlight: [],
|
||||
none: [],
|
||||
} as const;
|
||||
|
||||
104
apps/cli/src/helpers/project-generation/add-addons.ts
Normal file
104
apps/cli/src/helpers/project-generation/add-addons.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import path from "node:path";
|
||||
import { cancel, log } from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import type { AddInput, Addons, ProjectConfig } from "../../types";
|
||||
import { validateAddonCompatibility } from "../../utils/addon-compatibility";
|
||||
import { updateBtsConfig } from "../../utils/bts-config";
|
||||
import { setupAddons } from "../setup/addons-setup";
|
||||
import {
|
||||
detectProjectConfig,
|
||||
isBetterTStackProject,
|
||||
} from "./detect-project-config";
|
||||
import { installDependencies } from "./install-dependencies";
|
||||
import { setupAddonsTemplate } from "./template-manager";
|
||||
|
||||
function exitWithError(message: string): never {
|
||||
cancel(pc.red(message));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export async function addAddonsToProject(
|
||||
input: AddInput & { addons: Addons[] },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const projectDir = input.projectDir || process.cwd();
|
||||
|
||||
const isBetterTStack = await isBetterTStackProject(projectDir);
|
||||
if (!isBetterTStack) {
|
||||
exitWithError(
|
||||
"This doesn't appear to be a Better-T Stack project. Please run this command from the root of a Better-T Stack project.",
|
||||
);
|
||||
}
|
||||
|
||||
const detectedConfig = await detectProjectConfig(projectDir);
|
||||
if (!detectedConfig) {
|
||||
exitWithError(
|
||||
"Could not detect the project configuration. Please ensure this is a valid Better-T Stack project.",
|
||||
);
|
||||
}
|
||||
|
||||
const config: ProjectConfig = {
|
||||
projectName: detectedConfig.projectName || path.basename(projectDir),
|
||||
projectDir,
|
||||
relativePath: ".",
|
||||
database: detectedConfig.database || "none",
|
||||
orm: detectedConfig.orm || "none",
|
||||
backend: detectedConfig.backend || "none",
|
||||
runtime: detectedConfig.runtime || "none",
|
||||
frontend: detectedConfig.frontend || [],
|
||||
addons: input.addons,
|
||||
examples: detectedConfig.examples || [],
|
||||
auth: detectedConfig.auth || false,
|
||||
git: false,
|
||||
packageManager:
|
||||
input.packageManager || detectedConfig.packageManager || "npm",
|
||||
install: input.install || false,
|
||||
dbSetup: detectedConfig.dbSetup || "none",
|
||||
api: detectedConfig.api || "none",
|
||||
};
|
||||
|
||||
for (const addon of input.addons) {
|
||||
const { isCompatible, reason } = validateAddonCompatibility(
|
||||
addon,
|
||||
config.frontend,
|
||||
);
|
||||
if (!isCompatible) {
|
||||
exitWithError(
|
||||
reason ||
|
||||
`${addon} addon is not compatible with current frontend configuration`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
pc.green(
|
||||
`Adding ${input.addons.join(", ")} to ${config.frontend.join("/")}`,
|
||||
),
|
||||
);
|
||||
|
||||
await setupAddonsTemplate(projectDir, config);
|
||||
await setupAddons(config, true);
|
||||
|
||||
const currentAddons = detectedConfig.addons || [];
|
||||
const mergedAddons = [...new Set([...currentAddons, ...input.addons])];
|
||||
await updateBtsConfig(projectDir, { addons: mergedAddons });
|
||||
|
||||
if (config.install) {
|
||||
await installDependencies({
|
||||
projectDir,
|
||||
packageManager: config.packageManager,
|
||||
});
|
||||
} else {
|
||||
log.info(
|
||||
pc.yellow(
|
||||
`Run ${pc.bold(
|
||||
`${config.packageManager} install`,
|
||||
)} to install dependencies`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
exitWithError(`Error adding addons: ${message}`);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { cancel, log } from "@clack/prompts";
|
||||
import fs from "fs-extra";
|
||||
import pc from "picocolors";
|
||||
import type { ProjectConfig } from "../../types";
|
||||
import { writeBtsConfig } from "../../utils/bts-config";
|
||||
import { setupAddons } from "../setup/addons-setup";
|
||||
import { setupApi } from "../setup/api-setup";
|
||||
import { setupAuth } from "../setup/auth-setup";
|
||||
@@ -71,6 +72,9 @@ export async function createProject(options: ProjectConfig) {
|
||||
await setupEnvironmentVariables(options);
|
||||
await updatePackageConfigurations(projectDir, options);
|
||||
await createReadme(projectDir, options);
|
||||
|
||||
await writeBtsConfig(options);
|
||||
|
||||
await initializeGit(projectDir, options.git);
|
||||
|
||||
log.success("Project template successfully scaffolded!");
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import path from "node:path";
|
||||
import fs from "fs-extra";
|
||||
import type { ProjectConfig } from "../../types";
|
||||
import { readBtsConfig } from "../../utils/bts-config";
|
||||
|
||||
export async function detectProjectConfig(
|
||||
projectDir: string,
|
||||
): Promise<Partial<ProjectConfig> | null> {
|
||||
try {
|
||||
const btsConfig = await readBtsConfig(projectDir);
|
||||
if (btsConfig) {
|
||||
return {
|
||||
projectDir,
|
||||
projectName: path.basename(projectDir),
|
||||
database: btsConfig.database,
|
||||
orm: btsConfig.orm,
|
||||
backend: btsConfig.backend,
|
||||
runtime: btsConfig.runtime,
|
||||
frontend: btsConfig.frontend,
|
||||
addons: btsConfig.addons,
|
||||
examples: btsConfig.examples,
|
||||
auth: btsConfig.auth,
|
||||
packageManager: btsConfig.packageManager,
|
||||
dbSetup: btsConfig.dbSetup,
|
||||
api: btsConfig.api,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isBetterTStackProject(
|
||||
projectDir: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await fs.pathExists(path.join(projectDir, "bts.jsonc"));
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import path from "node:path";
|
||||
import { log } from "@clack/prompts";
|
||||
import fs from "fs-extra";
|
||||
import pc from "picocolors";
|
||||
import type { Frontend, ProjectConfig } from "../../types";
|
||||
import { addPackageDependency } from "../../utils/add-package-deps";
|
||||
import { setupStarlight } from "./starlight-setup";
|
||||
import { setupTauri } from "./tauri-setup";
|
||||
|
||||
export async function setupAddons(config: ProjectConfig) {
|
||||
export async function setupAddons(config: ProjectConfig, isAddCommand = false) {
|
||||
const { addons, frontend, projectDir } = config;
|
||||
const hasReactWebFrontend =
|
||||
frontend.includes("react-router") ||
|
||||
@@ -21,6 +23,20 @@ export async function setupAddons(config: ProjectConfig) {
|
||||
devDependencies: ["turbo"],
|
||||
projectDir,
|
||||
});
|
||||
|
||||
if (isAddCommand) {
|
||||
log.info(`${pc.yellow("Update your package.json scripts:")}
|
||||
|
||||
${pc.dim("Replace:")} ${pc.yellow('"pnpm -r dev"')} ${pc.dim("→")} ${pc.green(
|
||||
'"turbo dev"',
|
||||
)}
|
||||
${pc.dim("Replace:")} ${pc.yellow('"pnpm --filter web dev"')} ${pc.dim(
|
||||
"→",
|
||||
)} ${pc.green('"turbo -F web dev"')}
|
||||
|
||||
${pc.cyan("Docs:")} ${pc.underline("https://turborepo.com/docs")}
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
if (addons.includes("pwa") && (hasReactWebFrontend || hasSolidFrontend)) {
|
||||
|
||||
@@ -13,10 +13,13 @@ import fs from "fs-extra";
|
||||
import pc from "picocolors";
|
||||
import { createCli, trpcServer, zod as z } from "trpc-cli";
|
||||
import { DEFAULT_CONFIG } from "./constants";
|
||||
import { addAddonsToProject } from "./helpers/project-generation/add-addons";
|
||||
import { createProject } from "./helpers/project-generation/create-project";
|
||||
import { detectProjectConfig } from "./helpers/project-generation/detect-project-config";
|
||||
import { getAddonsToAdd } from "./prompts/addons";
|
||||
import { gatherConfig } from "./prompts/config-prompts";
|
||||
import { getProjectName } from "./prompts/project-name";
|
||||
import type { CreateInput, ProjectConfig } from "./types";
|
||||
import type { AddInput, CreateInput, ProjectConfig } from "./types";
|
||||
import {
|
||||
AddonsSchema,
|
||||
APISchema,
|
||||
@@ -259,6 +262,53 @@ async function createProjectHandler(
|
||||
}
|
||||
}
|
||||
|
||||
async function addAddonsHandler(input: AddInput): Promise<void> {
|
||||
try {
|
||||
if (!input.addons || input.addons.length === 0) {
|
||||
const projectDir = input.projectDir || process.cwd();
|
||||
const detectedConfig = await detectProjectConfig(projectDir);
|
||||
|
||||
if (!detectedConfig) {
|
||||
cancel(
|
||||
pc.red(
|
||||
"Could not detect project configuration. Please ensure this is a valid Better-T Stack project.",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const addonsPrompt = await getAddonsToAdd(
|
||||
detectedConfig.frontend || [],
|
||||
detectedConfig.addons || [],
|
||||
);
|
||||
|
||||
if (addonsPrompt.length === 0) {
|
||||
outro(
|
||||
pc.yellow(
|
||||
"No addons to add or all compatible addons are already present.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
input.addons = addonsPrompt;
|
||||
}
|
||||
|
||||
if (!input.addons || input.addons.length === 0) {
|
||||
outro(pc.yellow("No addons specified to add."));
|
||||
return;
|
||||
}
|
||||
|
||||
await addAddonsToProject({
|
||||
...input,
|
||||
addons: input.addons,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const router = t.router({
|
||||
init: t.procedure
|
||||
.meta({
|
||||
@@ -301,6 +351,31 @@ const router = t.router({
|
||||
};
|
||||
await createProjectHandler(combinedInput);
|
||||
}),
|
||||
add: t.procedure
|
||||
.meta({
|
||||
description: "Add addons to an existing Better-T Stack project",
|
||||
})
|
||||
.input(
|
||||
z.tuple([
|
||||
z
|
||||
.object({
|
||||
addons: z.array(AddonsSchema).optional().default([]),
|
||||
projectDir: z.string().optional(),
|
||||
install: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe("Install dependencies after adding addons"),
|
||||
packageManager: PackageManagerSchema.optional(),
|
||||
})
|
||||
.optional()
|
||||
.default({}),
|
||||
]),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const [options] = input;
|
||||
await addAddonsHandler(options);
|
||||
}),
|
||||
sponsors: t.procedure
|
||||
.meta({ description: "Show Better-T Stack sponsors" })
|
||||
.mutation(async () => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { cancel, isCancel, multiselect } from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { DEFAULT_CONFIG } from "../constants";
|
||||
import type { Addons, Frontend } from "../types";
|
||||
import { type Addons, AddonsSchema, type Frontend } from "../types";
|
||||
import {
|
||||
getCompatibleAddons,
|
||||
validateAddonCompatibility,
|
||||
} from "../utils/addon-compatibility";
|
||||
|
||||
type AddonOption = {
|
||||
value: Addons;
|
||||
@@ -9,63 +13,73 @@ type AddonOption = {
|
||||
hint: string;
|
||||
};
|
||||
|
||||
function getAddonDisplay(
|
||||
addon: Addons,
|
||||
isRecommended = false,
|
||||
): { label: string; hint: string } {
|
||||
let label: string;
|
||||
let hint: string;
|
||||
|
||||
if (addon === "turborepo") {
|
||||
label = isRecommended ? "Turborepo (Recommended)" : "Turborepo";
|
||||
hint = "High-performance build system for JavaScript and TypeScript";
|
||||
} else if (addon === "pwa") {
|
||||
label = "PWA (Progressive Web App)";
|
||||
hint = "Make your app installable and work offline";
|
||||
} else if (addon === "tauri") {
|
||||
label = isRecommended ? "Tauri Desktop App" : "Tauri";
|
||||
hint = "Build native desktop apps from your web frontend";
|
||||
} else if (addon === "biome") {
|
||||
label = "Biome";
|
||||
hint = isRecommended
|
||||
? "Add Biome for linting and formatting"
|
||||
: "Fast formatter and linter for JavaScript, TypeScript, JSX";
|
||||
} else if (addon === "husky") {
|
||||
label = "Husky";
|
||||
hint = isRecommended
|
||||
? "Add Git hooks with Husky, lint-staged (requires Biome)"
|
||||
: "Git hooks made easy";
|
||||
} else if (addon === "starlight") {
|
||||
label = "Starlight";
|
||||
hint = isRecommended
|
||||
? "Add Astro Starlight documentation site"
|
||||
: "Documentation site with Astro";
|
||||
} else {
|
||||
label = addon;
|
||||
hint = `Add ${addon}`;
|
||||
}
|
||||
|
||||
return { label, hint };
|
||||
}
|
||||
|
||||
export async function getAddonsChoice(
|
||||
addons?: Addons[],
|
||||
frontends?: Frontend[],
|
||||
): Promise<Addons[]> {
|
||||
if (addons !== undefined) return addons;
|
||||
|
||||
const hasCompatiblePwaFrontend =
|
||||
frontends?.includes("react-router") ||
|
||||
frontends?.includes("tanstack-router") ||
|
||||
frontends?.includes("solid") ||
|
||||
frontends?.includes("next");
|
||||
const allAddons = AddonsSchema.options.filter((addon) => addon !== "none");
|
||||
|
||||
const hasCompatibleTauriFrontend =
|
||||
frontends?.includes("react-router") ||
|
||||
frontends?.includes("tanstack-router") ||
|
||||
frontends?.includes("nuxt") ||
|
||||
frontends?.includes("svelte") ||
|
||||
frontends?.includes("solid") ||
|
||||
frontends?.includes("next");
|
||||
const allPossibleOptions: AddonOption[] = [];
|
||||
|
||||
const allPossibleOptions: AddonOption[] = [
|
||||
{
|
||||
value: "turborepo",
|
||||
label: "Turborepo (Recommended)",
|
||||
hint: "Optimize builds for monorepos",
|
||||
},
|
||||
{
|
||||
value: "starlight",
|
||||
label: "Starlight",
|
||||
hint: "Add Astro Starlight documentation site",
|
||||
},
|
||||
{
|
||||
value: "biome",
|
||||
label: "Biome",
|
||||
hint: "Add Biome for linting and formatting",
|
||||
},
|
||||
{
|
||||
value: "husky",
|
||||
label: "Husky",
|
||||
hint: "Add Git hooks with Husky, lint-staged (requires Biome)",
|
||||
},
|
||||
{
|
||||
value: "pwa",
|
||||
label: "PWA (Progressive Web App)",
|
||||
hint: "Make your app installable and work offline",
|
||||
},
|
||||
{
|
||||
value: "tauri",
|
||||
label: "Tauri Desktop App",
|
||||
hint: "Build native desktop apps from your web frontend",
|
||||
},
|
||||
];
|
||||
for (const addon of allAddons) {
|
||||
const { isCompatible } = validateAddonCompatibility(addon, frontends || []);
|
||||
|
||||
const options = allPossibleOptions.filter((option) => {
|
||||
if (option.value === "pwa") return hasCompatiblePwaFrontend;
|
||||
if (option.value === "tauri") return hasCompatibleTauriFrontend;
|
||||
return true;
|
||||
if (isCompatible) {
|
||||
const { label, hint } = getAddonDisplay(addon, true);
|
||||
|
||||
allPossibleOptions.push({
|
||||
value: addon,
|
||||
label,
|
||||
hint,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const options = allPossibleOptions.sort((a, b) => {
|
||||
if (a.value === "turborepo") return -1;
|
||||
if (b.value === "turborepo") return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const initialValues = DEFAULT_CONFIG.addons.filter((addonValue) =>
|
||||
@@ -90,3 +104,45 @@ export async function getAddonsChoice(
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function getAddonsToAdd(
|
||||
frontend: Frontend[],
|
||||
existingAddons: Addons[] = [],
|
||||
): Promise<Addons[]> {
|
||||
const options: AddonOption[] = [];
|
||||
|
||||
const allAddons = AddonsSchema.options.filter((addon) => addon !== "none");
|
||||
|
||||
const compatibleAddons = getCompatibleAddons(
|
||||
allAddons,
|
||||
frontend,
|
||||
existingAddons,
|
||||
);
|
||||
|
||||
for (const addon of compatibleAddons) {
|
||||
const { label, hint } = getAddonDisplay(addon, false);
|
||||
|
||||
options.push({
|
||||
value: addon,
|
||||
label,
|
||||
hint,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await multiselect<Addons>({
|
||||
message: "Select addons",
|
||||
options: options,
|
||||
required: true,
|
||||
});
|
||||
|
||||
if (isCancel(response)) {
|
||||
cancel(pc.red("Operation cancelled"));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,13 @@ export type CreateInput = {
|
||||
api?: API;
|
||||
};
|
||||
|
||||
export type AddInput = {
|
||||
addons?: Addons[];
|
||||
projectDir?: string;
|
||||
install?: boolean;
|
||||
packageManager?: PackageManager;
|
||||
};
|
||||
|
||||
export type CLIInput = CreateInput & {
|
||||
projectDirectory?: string;
|
||||
};
|
||||
@@ -133,4 +140,20 @@ export interface ProjectConfig {
|
||||
api: API;
|
||||
}
|
||||
|
||||
export interface BetterTStackConfig {
|
||||
version: string;
|
||||
createdAt: string;
|
||||
database: Database;
|
||||
orm: ORM;
|
||||
backend: Backend;
|
||||
runtime: Runtime;
|
||||
frontend: Frontend[];
|
||||
addons: Addons[];
|
||||
examples: Examples[];
|
||||
auth: boolean;
|
||||
packageManager: PackageManager;
|
||||
dbSetup: DatabaseSetup;
|
||||
api: API;
|
||||
}
|
||||
|
||||
export type AvailablePackageManagers = "npm" | "pnpm" | "bun";
|
||||
|
||||
42
apps/cli/src/utils/addon-compatibility.ts
Normal file
42
apps/cli/src/utils/addon-compatibility.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ADDON_COMPATIBILITY } from "../constants";
|
||||
import type { Addons, Frontend } from "../types";
|
||||
|
||||
export function validateAddonCompatibility(
|
||||
addon: Addons,
|
||||
frontend: Frontend[],
|
||||
): { isCompatible: boolean; reason?: string } {
|
||||
const compatibleFrontends = ADDON_COMPATIBILITY[addon];
|
||||
|
||||
if (compatibleFrontends.length === 0) {
|
||||
return { isCompatible: true };
|
||||
}
|
||||
|
||||
const hasCompatibleFrontend = frontend.some((f) =>
|
||||
(compatibleFrontends as readonly string[]).includes(f),
|
||||
);
|
||||
|
||||
if (!hasCompatibleFrontend) {
|
||||
const frontendList = compatibleFrontends.join(", ");
|
||||
return {
|
||||
isCompatible: false,
|
||||
reason: `${addon} addon requires one of these frontends: ${frontendList}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isCompatible: true };
|
||||
}
|
||||
|
||||
export function getCompatibleAddons(
|
||||
allAddons: Addons[],
|
||||
frontend: Frontend[],
|
||||
existingAddons: Addons[] = [],
|
||||
): Addons[] {
|
||||
return allAddons.filter((addon) => {
|
||||
if (existingAddons.includes(addon)) return false;
|
||||
|
||||
if (addon === "none") return false;
|
||||
|
||||
const { isCompatible } = validateAddonCompatibility(addon, frontend);
|
||||
return isCompatible;
|
||||
});
|
||||
}
|
||||
120
apps/cli/src/utils/bts-config.ts
Normal file
120
apps/cli/src/utils/bts-config.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import path from "node:path";
|
||||
import fs from "fs-extra";
|
||||
import * as JSONC from "jsonc-parser";
|
||||
import type { BetterTStackConfig, ProjectConfig } from "../types";
|
||||
import { getLatestCLIVersion } from "./get-latest-cli-version";
|
||||
|
||||
const BTS_CONFIG_FILE = "bts.jsonc";
|
||||
|
||||
export async function writeBtsConfig(
|
||||
projectConfig: ProjectConfig,
|
||||
): Promise<void> {
|
||||
const btsConfig: BetterTStackConfig = {
|
||||
version: getLatestCLIVersion(),
|
||||
createdAt: new Date().toISOString(),
|
||||
database: projectConfig.database,
|
||||
orm: projectConfig.orm,
|
||||
backend: projectConfig.backend,
|
||||
runtime: projectConfig.runtime,
|
||||
frontend: projectConfig.frontend,
|
||||
addons: projectConfig.addons,
|
||||
examples: projectConfig.examples,
|
||||
auth: projectConfig.auth,
|
||||
packageManager: projectConfig.packageManager,
|
||||
dbSetup: projectConfig.dbSetup,
|
||||
api: projectConfig.api,
|
||||
};
|
||||
|
||||
const baseContent = {
|
||||
$schema: "https://better-t-stack.dev/schema.json",
|
||||
version: btsConfig.version,
|
||||
createdAt: btsConfig.createdAt,
|
||||
database: btsConfig.database,
|
||||
orm: btsConfig.orm,
|
||||
backend: btsConfig.backend,
|
||||
runtime: btsConfig.runtime,
|
||||
frontend: btsConfig.frontend,
|
||||
addons: btsConfig.addons,
|
||||
examples: btsConfig.examples,
|
||||
auth: btsConfig.auth,
|
||||
packageManager: btsConfig.packageManager,
|
||||
dbSetup: btsConfig.dbSetup,
|
||||
api: btsConfig.api,
|
||||
};
|
||||
|
||||
let configContent = JSON.stringify(baseContent);
|
||||
|
||||
const formatResult = JSONC.format(configContent, undefined, {
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
eol: "\n",
|
||||
});
|
||||
|
||||
configContent = JSONC.applyEdits(configContent, formatResult);
|
||||
|
||||
const finalContent = `// Better-T-Stack configuration file
|
||||
// safe to delete
|
||||
|
||||
${configContent}`;
|
||||
const configPath = path.join(projectConfig.projectDir, BTS_CONFIG_FILE);
|
||||
await fs.writeFile(configPath, finalContent, "utf-8");
|
||||
}
|
||||
|
||||
export async function readBtsConfig(
|
||||
projectDir: string,
|
||||
): Promise<BetterTStackConfig | null> {
|
||||
try {
|
||||
const configPath = path.join(projectDir, BTS_CONFIG_FILE);
|
||||
|
||||
if (!(await fs.pathExists(configPath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configContent = await fs.readFile(configPath, "utf-8");
|
||||
|
||||
const errors: JSONC.ParseError[] = [];
|
||||
const config = JSONC.parse(configContent, errors, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
}) as BetterTStackConfig;
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn("Warning: Found errors parsing bts.jsonc:", errors);
|
||||
return null;
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBtsConfig(
|
||||
projectDir: string,
|
||||
updates: Partial<Pick<BetterTStackConfig, "addons">>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const configPath = path.join(projectDir, BTS_CONFIG_FILE);
|
||||
|
||||
if (!(await fs.pathExists(configPath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const configContent = await fs.readFile(configPath, "utf-8");
|
||||
|
||||
let modifiedContent = configContent;
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const editResult = JSONC.modify(modifiedContent, [key], value, {
|
||||
formattingOptions: {
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
eol: "\n",
|
||||
},
|
||||
});
|
||||
modifiedContent = JSONC.applyEdits(modifiedContent, editResult);
|
||||
}
|
||||
|
||||
await fs.writeFile(configPath, modifiedContent, "utf-8");
|
||||
} catch (_error) {}
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
"start": "next start",
|
||||
"check": "biome check --write .",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"generate-analytics": "bun scripts/generate-analytics.ts"
|
||||
"generate-analytics": "bun scripts/generate-analytics.ts",
|
||||
"generate-schema": "bun scripts/generate-schema.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"babel-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
@@ -19,7 +20,7 @@
|
||||
"fumadocs-mdx": "11.6.7",
|
||||
"fumadocs-ui": "15.5.1",
|
||||
"lucide-react": "^0.513.0",
|
||||
"motion": "^12.16.0",
|
||||
"motion": "^12.18.1",
|
||||
"next": "15.3.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.4.3",
|
||||
@@ -28,21 +29,21 @@
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-tweet": "^3.2.2",
|
||||
"recharts": "^2.15.3",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.3.0"
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "24.0.0",
|
||||
"@types/react": "^19.1.7",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-config-next": "15.3.3",
|
||||
"postcss": "^8.5.4",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
20
apps/web/public/example-bts.jsonc
Normal file
20
apps/web/public/example-bts.jsonc
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://better-t-stack.dev/schema.json",
|
||||
// Better-T-Stack configuration file
|
||||
// This file was automatically generated when the project was created
|
||||
// It contains the stack configuration used to generate this project
|
||||
|
||||
"version": "2.21.0",
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"database": "sqlite",
|
||||
"orm": "drizzle",
|
||||
"backend": "hono",
|
||||
"runtime": "bun",
|
||||
"frontend": ["tanstack-router"],
|
||||
"addons": ["turborepo", "biome"],
|
||||
"examples": ["todo"],
|
||||
"auth": true,
|
||||
"packageManager": "bun",
|
||||
"dbSetup": "turso",
|
||||
"api": "trpc"
|
||||
}
|
||||
137
apps/web/public/schema.json
Normal file
137
apps/web/public/schema.json
Normal file
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://better-t-stack.dev/schema.json",
|
||||
"title": "Better-T-Stack Configuration",
|
||||
"description": "Configuration file for Better-T-Stack projects",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON Schema reference for validation"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "CLI version used to create this project",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Timestamp when the project was created"
|
||||
},
|
||||
"database": {
|
||||
"type": "string",
|
||||
"enum": ["none", "sqlite", "postgres", "mysql", "mongodb"],
|
||||
"description": "Database type"
|
||||
},
|
||||
"orm": {
|
||||
"type": "string",
|
||||
"enum": ["drizzle", "prisma", "mongoose", "none"],
|
||||
"description": "ORM type"
|
||||
},
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"hono",
|
||||
"express",
|
||||
"fastify",
|
||||
"next",
|
||||
"elysia",
|
||||
"convex",
|
||||
"none"
|
||||
],
|
||||
"description": "Backend framework"
|
||||
},
|
||||
"runtime": {
|
||||
"type": "string",
|
||||
"enum": ["bun", "node", "workers", "none"],
|
||||
"description": "Runtime environment (workers only available with hono backend and drizzle orm)"
|
||||
},
|
||||
"frontend": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"tanstack-router",
|
||||
"react-router",
|
||||
"tanstack-start",
|
||||
"next",
|
||||
"nuxt",
|
||||
"native-nativewind",
|
||||
"native-unistyles",
|
||||
"svelte",
|
||||
"solid",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"description": "Frontend framework"
|
||||
},
|
||||
"addons": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pwa",
|
||||
"tauri",
|
||||
"starlight",
|
||||
"biome",
|
||||
"husky",
|
||||
"turborepo",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"description": "Additional addons"
|
||||
},
|
||||
"examples": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["todo", "ai", "none"]
|
||||
},
|
||||
"description": "Example templates to include"
|
||||
},
|
||||
"auth": {
|
||||
"type": "boolean",
|
||||
"description": "Whether authentication is enabled"
|
||||
},
|
||||
"packageManager": {
|
||||
"type": "string",
|
||||
"enum": ["npm", "pnpm", "bun"],
|
||||
"description": "Package manager"
|
||||
},
|
||||
"dbSetup": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"turso",
|
||||
"neon",
|
||||
"prisma-postgres",
|
||||
"mongodb-atlas",
|
||||
"supabase",
|
||||
"d1",
|
||||
"none"
|
||||
],
|
||||
"description": "Database hosting setup"
|
||||
},
|
||||
"api": {
|
||||
"type": "string",
|
||||
"enum": ["trpc", "orpc", "none"],
|
||||
"description": "API type"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"version",
|
||||
"createdAt",
|
||||
"database",
|
||||
"orm",
|
||||
"backend",
|
||||
"runtime",
|
||||
"frontend",
|
||||
"addons",
|
||||
"examples",
|
||||
"auth",
|
||||
"packageManager",
|
||||
"dbSetup",
|
||||
"api"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
137
apps/web/scripts/generate-schema.ts
Normal file
137
apps/web/scripts/generate-schema.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import path from "node:path";
|
||||
import fs from "fs-extra";
|
||||
import {
|
||||
AddonsSchema,
|
||||
APISchema,
|
||||
BackendSchema,
|
||||
DatabaseSchema,
|
||||
DatabaseSetupSchema,
|
||||
ExamplesSchema,
|
||||
FrontendSchema,
|
||||
ORMSchema,
|
||||
PackageManagerSchema,
|
||||
RuntimeSchema,
|
||||
} from "../../cli/src/types";
|
||||
|
||||
const DATABASE_VALUES = DatabaseSchema.options;
|
||||
const ORM_VALUES = ORMSchema.options;
|
||||
const BACKEND_VALUES = BackendSchema.options;
|
||||
const RUNTIME_VALUES = RuntimeSchema.options;
|
||||
const FRONTEND_VALUES = FrontendSchema.options;
|
||||
const ADDONS_VALUES = AddonsSchema.options;
|
||||
const EXAMPLES_VALUES = ExamplesSchema.options;
|
||||
const PACKAGE_MANAGER_VALUES = PackageManagerSchema.options;
|
||||
const DATABASE_SETUP_VALUES = DatabaseSetupSchema.options;
|
||||
const API_VALUES = APISchema.options;
|
||||
|
||||
const schema = {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
$id: "https://better-t-stack.dev/schema.json",
|
||||
title: "Better-T-Stack Configuration",
|
||||
description: "Configuration file for Better-T-Stack projects",
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
$schema: {
|
||||
type: "string" as const,
|
||||
description: "JSON Schema reference for validation",
|
||||
},
|
||||
version: {
|
||||
type: "string" as const,
|
||||
description: "CLI version used to create this project",
|
||||
pattern: "^\\d+\\.\\d+\\.\\d+$",
|
||||
},
|
||||
createdAt: {
|
||||
type: "string" as const,
|
||||
format: "date-time" as const,
|
||||
description: "Timestamp when the project was created",
|
||||
},
|
||||
database: {
|
||||
type: "string" as const,
|
||||
enum: DATABASE_VALUES,
|
||||
description: DatabaseSchema.description,
|
||||
},
|
||||
orm: {
|
||||
type: "string" as const,
|
||||
enum: ORM_VALUES,
|
||||
description: ORMSchema.description,
|
||||
},
|
||||
backend: {
|
||||
type: "string" as const,
|
||||
enum: BACKEND_VALUES,
|
||||
description: BackendSchema.description,
|
||||
},
|
||||
runtime: {
|
||||
type: "string" as const,
|
||||
enum: RUNTIME_VALUES,
|
||||
description: RuntimeSchema.description,
|
||||
},
|
||||
frontend: {
|
||||
type: "array" as const,
|
||||
items: {
|
||||
type: "string" as const,
|
||||
enum: FRONTEND_VALUES,
|
||||
},
|
||||
description: FrontendSchema.description,
|
||||
},
|
||||
addons: {
|
||||
type: "array" as const,
|
||||
items: {
|
||||
type: "string" as const,
|
||||
enum: ADDONS_VALUES,
|
||||
},
|
||||
description: AddonsSchema.description,
|
||||
},
|
||||
examples: {
|
||||
type: "array" as const,
|
||||
items: {
|
||||
type: "string" as const,
|
||||
enum: EXAMPLES_VALUES,
|
||||
},
|
||||
description: ExamplesSchema.description,
|
||||
},
|
||||
auth: {
|
||||
type: "boolean" as const,
|
||||
description: "Whether authentication is enabled",
|
||||
},
|
||||
packageManager: {
|
||||
type: "string" as const,
|
||||
enum: PACKAGE_MANAGER_VALUES,
|
||||
description: PackageManagerSchema.description,
|
||||
},
|
||||
dbSetup: {
|
||||
type: "string" as const,
|
||||
enum: DATABASE_SETUP_VALUES,
|
||||
description: DatabaseSetupSchema.description,
|
||||
},
|
||||
api: {
|
||||
type: "string" as const,
|
||||
enum: API_VALUES,
|
||||
description: APISchema.description,
|
||||
},
|
||||
},
|
||||
required: [
|
||||
"version",
|
||||
"createdAt",
|
||||
"database",
|
||||
"orm",
|
||||
"backend",
|
||||
"runtime",
|
||||
"frontend",
|
||||
"addons",
|
||||
"examples",
|
||||
"auth",
|
||||
"packageManager",
|
||||
"dbSetup",
|
||||
"api",
|
||||
],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
async function generateSchema() {
|
||||
const schemaPath = path.join(process.cwd(), "public", "schema.json");
|
||||
await fs.ensureDir(path.dirname(schemaPath));
|
||||
await fs.writeFile(schemaPath, JSON.stringify(schema, null, 2), "utf-8");
|
||||
console.log("✅ Generated schema.json from shared types package");
|
||||
}
|
||||
|
||||
generateSchema().catch(console.error);
|
||||
@@ -1465,7 +1465,6 @@ const StackBuilder = () => {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
id="save-stack-button"
|
||||
type="button"
|
||||
onClick={saveCurrentStack}
|
||||
className="flex items-center gap-1 rounded border border-border bg-background px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, File, Github, Monitor } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -24,26 +23,10 @@ export default function ShowcaseItem({
|
||||
tags,
|
||||
index = 0,
|
||||
}: ShowcaseItemProps) {
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: "easeOut",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const projectId = `PROJECT_${String(index + 1).padStart(3, "0")}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="terminal-block-hover flex h-full flex-col overflow-hidden rounded border border-border bg-background"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
<div className="terminal-block-hover flex h-full flex-col overflow-hidden rounded border border-border bg-background">
|
||||
<div className="border-border border-b bg-muted/20 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="h-3 w-3 text-primary" />
|
||||
@@ -137,6 +120,6 @@ export default function ShowcaseItem({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FolderOpen, Terminal } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Navbar from "../_components/navbar";
|
||||
import ShowcaseItem from "./_components/ShowcaseItem";
|
||||
|
||||
@@ -17,40 +16,12 @@ const showcaseProjects = [
|
||||
];
|
||||
|
||||
export default function ShowcasePage() {
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: "easeOut",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="flex min-h-svh flex-col items-center bg-background px-4 pt-24 pb-10 sm:px-6 md:px-8 md:pt-28 lg:pt-32">
|
||||
<motion.div
|
||||
className="mx-auto w-full max-w-6xl"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div className="mb-8" variants={itemVariants}>
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-primary" />
|
||||
<span className="font-bold font-mono text-lg">
|
||||
@@ -92,18 +63,15 @@ export default function ShowcasePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{showcaseProjects.map((project, index) => (
|
||||
<ShowcaseItem key={project.title} {...project} index={index} />
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div className="mt-8" variants={itemVariants}>
|
||||
<div className="mt-8">
|
||||
<div className="terminal-block-hover rounded border border-border bg-muted/20 p-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-primary">$</span>
|
||||
@@ -119,8 +87,8 @@ export default function ShowcasePage() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user