Skip to content

Commit 9c7a0f0

Browse files
feat: add command (#337)
1 parent 198d0e7 commit 9c7a0f0

File tree

29 files changed

+1018
-258
lines changed

29 files changed

+1018
-258
lines changed

.changeset/all-doodles-hear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"create-better-t-stack": minor
3+
---
4+
5+
Add 'add' command for adding addons to existing projects

apps/cli/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,16 @@
6060
"globby": "^14.1.0",
6161
"gradient-string": "^3.0.0",
6262
"handlebars": "^4.7.8",
63+
"jsonc-parser": "^3.3.1",
6364
"picocolors": "^1.1.1",
6465
"posthog-node": "^4.18.0",
6566
"trpc-cli": "^0.8.0",
66-
"zod": "^3.25.57"
67+
"zod": "^3.25.67"
6768
},
6869
"devDependencies": {
6970
"@types/fs-extra": "^11.0.4",
70-
"@types/node": "^24.0.0",
71-
"tsdown": "^0.12.7",
71+
"@types/node": "^24.0.3",
72+
"tsdown": "^0.12.8",
7273
"typescript": "^5.8.3"
7374
}
7475
}

apps/cli/src/constants.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,13 @@ export const dependencyVersionMap = {
110110
} as const;
111111

112112
export type AvailableDependencies = keyof typeof dependencyVersionMap;
113+
114+
export const ADDON_COMPATIBILITY = {
115+
pwa: ["tanstack-router", "react-router", "solid"],
116+
tauri: ["tanstack-router", "react-router", "nuxt", "svelte", "solid"],
117+
biome: [],
118+
husky: [],
119+
turborepo: [],
120+
starlight: [],
121+
none: [],
122+
} as const;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import path from "node:path";
2+
import { cancel, log } from "@clack/prompts";
3+
import pc from "picocolors";
4+
import type { AddInput, Addons, ProjectConfig } from "../../types";
5+
import { validateAddonCompatibility } from "../../utils/addon-compatibility";
6+
import { updateBtsConfig } from "../../utils/bts-config";
7+
import { setupAddons } from "../setup/addons-setup";
8+
import {
9+
detectProjectConfig,
10+
isBetterTStackProject,
11+
} from "./detect-project-config";
12+
import { installDependencies } from "./install-dependencies";
13+
import { setupAddonsTemplate } from "./template-manager";
14+
15+
function exitWithError(message: string): never {
16+
cancel(pc.red(message));
17+
process.exit(1);
18+
}
19+
20+
export async function addAddonsToProject(
21+
input: AddInput & { addons: Addons[] },
22+
): Promise<void> {
23+
try {
24+
const projectDir = input.projectDir || process.cwd();
25+
26+
const isBetterTStack = await isBetterTStackProject(projectDir);
27+
if (!isBetterTStack) {
28+
exitWithError(
29+
"This doesn't appear to be a Better-T Stack project. Please run this command from the root of a Better-T Stack project.",
30+
);
31+
}
32+
33+
const detectedConfig = await detectProjectConfig(projectDir);
34+
if (!detectedConfig) {
35+
exitWithError(
36+
"Could not detect the project configuration. Please ensure this is a valid Better-T Stack project.",
37+
);
38+
}
39+
40+
const config: ProjectConfig = {
41+
projectName: detectedConfig.projectName || path.basename(projectDir),
42+
projectDir,
43+
relativePath: ".",
44+
database: detectedConfig.database || "none",
45+
orm: detectedConfig.orm || "none",
46+
backend: detectedConfig.backend || "none",
47+
runtime: detectedConfig.runtime || "none",
48+
frontend: detectedConfig.frontend || [],
49+
addons: input.addons,
50+
examples: detectedConfig.examples || [],
51+
auth: detectedConfig.auth || false,
52+
git: false,
53+
packageManager:
54+
input.packageManager || detectedConfig.packageManager || "npm",
55+
install: input.install || false,
56+
dbSetup: detectedConfig.dbSetup || "none",
57+
api: detectedConfig.api || "none",
58+
};
59+
60+
for (const addon of input.addons) {
61+
const { isCompatible, reason } = validateAddonCompatibility(
62+
addon,
63+
config.frontend,
64+
);
65+
if (!isCompatible) {
66+
exitWithError(
67+
reason ||
68+
`${addon} addon is not compatible with current frontend configuration`,
69+
);
70+
}
71+
}
72+
73+
log.info(
74+
pc.green(
75+
`Adding ${input.addons.join(", ")} to ${config.frontend.join("/")}`,
76+
),
77+
);
78+
79+
await setupAddonsTemplate(projectDir, config);
80+
await setupAddons(config, true);
81+
82+
const currentAddons = detectedConfig.addons || [];
83+
const mergedAddons = [...new Set([...currentAddons, ...input.addons])];
84+
await updateBtsConfig(projectDir, { addons: mergedAddons });
85+
86+
if (config.install) {
87+
await installDependencies({
88+
projectDir,
89+
packageManager: config.packageManager,
90+
});
91+
} else {
92+
log.info(
93+
pc.yellow(
94+
`Run ${pc.bold(
95+
`${config.packageManager} install`,
96+
)} to install dependencies`,
97+
),
98+
);
99+
}
100+
} catch (error) {
101+
const message = error instanceof Error ? error.message : String(error);
102+
exitWithError(`Error adding addons: ${message}`);
103+
}
104+
}

apps/cli/src/helpers/project-generation/create-project.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { cancel, log } from "@clack/prompts";
22
import fs from "fs-extra";
33
import pc from "picocolors";
44
import type { ProjectConfig } from "../../types";
5+
import { writeBtsConfig } from "../../utils/bts-config";
56
import { setupAddons } from "../setup/addons-setup";
67
import { setupApi } from "../setup/api-setup";
78
import { setupAuth } from "../setup/auth-setup";
@@ -71,6 +72,9 @@ export async function createProject(options: ProjectConfig) {
7172
await setupEnvironmentVariables(options);
7273
await updatePackageConfigurations(projectDir, options);
7374
await createReadme(projectDir, options);
75+
76+
await writeBtsConfig(options);
77+
7478
await initializeGit(projectDir, options.git);
7579

7680
log.success("Project template successfully scaffolded!");
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import path from "node:path";
2+
import fs from "fs-extra";
3+
import type { ProjectConfig } from "../../types";
4+
import { readBtsConfig } from "../../utils/bts-config";
5+
6+
export async function detectProjectConfig(
7+
projectDir: string,
8+
): Promise<Partial<ProjectConfig> | null> {
9+
try {
10+
const btsConfig = await readBtsConfig(projectDir);
11+
if (btsConfig) {
12+
return {
13+
projectDir,
14+
projectName: path.basename(projectDir),
15+
database: btsConfig.database,
16+
orm: btsConfig.orm,
17+
backend: btsConfig.backend,
18+
runtime: btsConfig.runtime,
19+
frontend: btsConfig.frontend,
20+
addons: btsConfig.addons,
21+
examples: btsConfig.examples,
22+
auth: btsConfig.auth,
23+
packageManager: btsConfig.packageManager,
24+
dbSetup: btsConfig.dbSetup,
25+
api: btsConfig.api,
26+
};
27+
}
28+
29+
return null;
30+
} catch (_error) {
31+
return null;
32+
}
33+
}
34+
35+
export async function isBetterTStackProject(
36+
projectDir: string,
37+
): Promise<boolean> {
38+
try {
39+
return await fs.pathExists(path.join(projectDir, "bts.jsonc"));
40+
} catch (_error) {
41+
return false;
42+
}
43+
}

apps/cli/src/helpers/setup/addons-setup.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import path from "node:path";
2+
import { log } from "@clack/prompts";
23
import fs from "fs-extra";
4+
import pc from "picocolors";
35
import type { Frontend, ProjectConfig } from "../../types";
46
import { addPackageDependency } from "../../utils/add-package-deps";
57
import { setupStarlight } from "./starlight-setup";
68
import { setupTauri } from "./tauri-setup";
79

8-
export async function setupAddons(config: ProjectConfig) {
10+
export async function setupAddons(config: ProjectConfig, isAddCommand = false) {
911
const { addons, frontend, projectDir } = config;
1012
const hasReactWebFrontend =
1113
frontend.includes("react-router") ||
@@ -21,6 +23,20 @@ export async function setupAddons(config: ProjectConfig) {
2123
devDependencies: ["turbo"],
2224
projectDir,
2325
});
26+
27+
if (isAddCommand) {
28+
log.info(`${pc.yellow("Update your package.json scripts:")}
29+
30+
${pc.dim("Replace:")} ${pc.yellow('"pnpm -r dev"')} ${pc.dim("→")} ${pc.green(
31+
'"turbo dev"',
32+
)}
33+
${pc.dim("Replace:")} ${pc.yellow('"pnpm --filter web dev"')} ${pc.dim(
34+
"→",
35+
)} ${pc.green('"turbo -F web dev"')}
36+
37+
${pc.cyan("Docs:")} ${pc.underline("https://turborepo.com/docs")}
38+
`);
39+
}
2440
}
2541

2642
if (addons.includes("pwa") && (hasReactWebFrontend || hasSolidFrontend)) {

apps/cli/src/index.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ import fs from "fs-extra";
1313
import pc from "picocolors";
1414
import { createCli, trpcServer, zod as z } from "trpc-cli";
1515
import { DEFAULT_CONFIG } from "./constants";
16+
import { addAddonsToProject } from "./helpers/project-generation/add-addons";
1617
import { createProject } from "./helpers/project-generation/create-project";
18+
import { detectProjectConfig } from "./helpers/project-generation/detect-project-config";
19+
import { getAddonsToAdd } from "./prompts/addons";
1720
import { gatherConfig } from "./prompts/config-prompts";
1821
import { getProjectName } from "./prompts/project-name";
19-
import type { CreateInput, ProjectConfig } from "./types";
22+
import type { AddInput, CreateInput, ProjectConfig } from "./types";
2023
import {
2124
AddonsSchema,
2225
APISchema,
@@ -259,6 +262,53 @@ async function createProjectHandler(
259262
}
260263
}
261264

265+
async function addAddonsHandler(input: AddInput): Promise<void> {
266+
try {
267+
if (!input.addons || input.addons.length === 0) {
268+
const projectDir = input.projectDir || process.cwd();
269+
const detectedConfig = await detectProjectConfig(projectDir);
270+
271+
if (!detectedConfig) {
272+
cancel(
273+
pc.red(
274+
"Could not detect project configuration. Please ensure this is a valid Better-T Stack project.",
275+
),
276+
);
277+
process.exit(1);
278+
}
279+
280+
const addonsPrompt = await getAddonsToAdd(
281+
detectedConfig.frontend || [],
282+
detectedConfig.addons || [],
283+
);
284+
285+
if (addonsPrompt.length === 0) {
286+
outro(
287+
pc.yellow(
288+
"No addons to add or all compatible addons are already present.",
289+
),
290+
);
291+
return;
292+
}
293+
294+
input.addons = addonsPrompt;
295+
}
296+
297+
if (!input.addons || input.addons.length === 0) {
298+
outro(pc.yellow("No addons specified to add."));
299+
return;
300+
}
301+
302+
await addAddonsToProject({
303+
...input,
304+
addons: input.addons,
305+
});
306+
} catch (error) {
307+
console.error(error);
308+
process.exit(1);
309+
}
310+
}
311+
262312
const router = t.router({
263313
init: t.procedure
264314
.meta({
@@ -301,6 +351,31 @@ const router = t.router({
301351
};
302352
await createProjectHandler(combinedInput);
303353
}),
354+
add: t.procedure
355+
.meta({
356+
description: "Add addons to an existing Better-T Stack project",
357+
})
358+
.input(
359+
z.tuple([
360+
z
361+
.object({
362+
addons: z.array(AddonsSchema).optional().default([]),
363+
projectDir: z.string().optional(),
364+
install: z
365+
.boolean()
366+
.optional()
367+
.default(false)
368+
.describe("Install dependencies after adding addons"),
369+
packageManager: PackageManagerSchema.optional(),
370+
})
371+
.optional()
372+
.default({}),
373+
]),
374+
)
375+
.mutation(async ({ input }) => {
376+
const [options] = input;
377+
await addAddonsHandler(options);
378+
}),
304379
sponsors: t.procedure
305380
.meta({ description: "Show Better-T Stack sponsors" })
306381
.mutation(async () => {

0 commit comments

Comments
 (0)