feat: add command (#337)

This commit is contained in:
Aman Varshney
2025-06-22 03:20:05 +05:30
committed by GitHub
parent 198d0e7434
commit 9c7a0f0110
29 changed files with 1015 additions and 255 deletions

View File

@@ -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;

View 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}`);
}
}

View File

@@ -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!");

View File

@@ -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;
}
}

View File

@@ -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)) {

View File

@@ -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 () => {

View File

@@ -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;
}

View File

@@ -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";

View 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;
});
}

View 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) {}
}