Skip to content

Commit ae783b0

Browse files
authored
Merge pull request #234 from Marve10s/roadmap-phase-1
Roadmap phases 1–2: correctness/trust fixes + Tier 1 quick wins + Tier 2/3 quality foundation
2 parents 67caceb + d79cb22 commit ae783b0

125 files changed

Lines changed: 4813 additions & 1729 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cli/src/commands/doctor.ts

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
import type { Dirent } from "node:fs";
2+
3+
import { intro, log, spinner } from "@clack/prompts";
4+
import { $ } from "execa";
5+
import fs from "fs-extra";
6+
import path from "node:path";
7+
import pc from "picocolors";
8+
9+
import type { BetterTStackConfig, ProjectConfig } from "../types";
10+
11+
import { readBtsConfig } from "../utils/bts-config";
12+
import { handleError } from "../utils/errors";
13+
import { runGeneratedChecks } from "../utils/generated-checks";
14+
import { renderTitle } from "../utils/render-title";
15+
16+
export type DoctorCommandInput = {
17+
projectDir?: string;
18+
skipChecks?: boolean;
19+
json?: boolean;
20+
};
21+
22+
type CheckStatus = "pass" | "warn" | "fail";
23+
24+
type DoctorCheck = {
25+
label: string;
26+
status: CheckStatus;
27+
detail?: string;
28+
};
29+
30+
const NON_TS_BACKEND_ECOSYSTEMS = new Set(["go", "rust", "python", "elixir", "java", "dotnet"]);
31+
32+
const IGNORED_DIRECTORIES = new Set([
33+
"node_modules",
34+
".git",
35+
"dist",
36+
"build",
37+
".next",
38+
".expo",
39+
".turbo",
40+
"target",
41+
".venv",
42+
"deps",
43+
"_build",
44+
]);
45+
46+
const JS_LOCKFILES = ["bun.lock", "bun.lockb", "pnpm-lock.yaml", "package-lock.json", "yarn.lock"];
47+
48+
const NATIVE_LOCKFILES: Record<string, { file: string; hint: string }> = {
49+
rust: { file: "Cargo.lock", hint: "cargo build" },
50+
go: { file: "go.sum", hint: "go mod tidy" },
51+
python: { file: "uv.lock", hint: "uv sync" },
52+
elixir: { file: "mix.lock", hint: "mix deps.get" },
53+
};
54+
55+
function statusIcon(status: CheckStatus): string {
56+
switch (status) {
57+
case "pass":
58+
return pc.green("✓");
59+
case "warn":
60+
return pc.yellow("!");
61+
case "fail":
62+
return pc.red("✗");
63+
}
64+
}
65+
66+
function hasNativeChecks(config: Pick<ProjectConfig, "ecosystem" | "stackParts">): boolean {
67+
if (NON_TS_BACKEND_ECOSYSTEMS.has(config.ecosystem)) {
68+
return true;
69+
}
70+
return (config.stackParts ?? []).some(
71+
(part) =>
72+
part.source !== "provided" &&
73+
part.role === "backend" &&
74+
NON_TS_BACKEND_ECOSYSTEMS.has(part.ecosystem),
75+
);
76+
}
77+
78+
async function checkInstalledDependencies(
79+
projectDir: string,
80+
config: BetterTStackConfig,
81+
): Promise<DoctorCheck[]> {
82+
const checks: DoctorCheck[] = [];
83+
84+
if (await fs.pathExists(path.join(projectDir, "package.json"))) {
85+
const lockfile = JS_LOCKFILES.find((name) => fs.existsSync(path.join(projectDir, name)));
86+
checks.push(
87+
lockfile
88+
? { label: "Lockfile", status: "pass", detail: lockfile }
89+
: {
90+
label: "Lockfile",
91+
status: "warn",
92+
detail: "No JavaScript lockfile found at the project root",
93+
},
94+
);
95+
96+
const nodeModulesExists = await fs.pathExists(path.join(projectDir, "node_modules"));
97+
checks.push(
98+
nodeModulesExists
99+
? { label: "node_modules", status: "pass" }
100+
: {
101+
label: "node_modules",
102+
status: "fail",
103+
detail: `Dependencies are not installed. Run \`${config.packageManager ?? "npm"} install\`.`,
104+
},
105+
);
106+
}
107+
108+
const native = NATIVE_LOCKFILES[config.ecosystem];
109+
if (native) {
110+
const exists =
111+
(await fs.pathExists(path.join(projectDir, native.file))) ||
112+
(await fs.pathExists(path.join(projectDir, "apps/server", native.file)));
113+
checks.push(
114+
exists
115+
? { label: native.file, status: "pass" }
116+
: {
117+
label: native.file,
118+
status: "warn",
119+
detail: `Not found. Run \`${native.hint}\` to fetch dependencies.`,
120+
},
121+
);
122+
}
123+
124+
return checks;
125+
}
126+
127+
async function findEnvExampleFiles(rootDir: string): Promise<string[]> {
128+
const results: string[] = [];
129+
130+
async function walk(dir: string, depth: number): Promise<void> {
131+
if (depth > 5) return;
132+
let entries: Dirent[];
133+
try {
134+
entries = await fs.readdir(dir, { withFileTypes: true });
135+
} catch {
136+
return;
137+
}
138+
for (const entry of entries) {
139+
if (entry.isDirectory()) {
140+
if (IGNORED_DIRECTORIES.has(entry.name)) continue;
141+
await walk(path.join(dir, entry.name), depth + 1);
142+
} else if (entry.name === ".env.example") {
143+
results.push(path.join(dir, entry.name));
144+
}
145+
}
146+
}
147+
148+
await walk(rootDir, 0);
149+
return results;
150+
}
151+
152+
function parseEnvKeys(content: string): Map<string, string> {
153+
const map = new Map<string, string>();
154+
for (const rawLine of content.split("\n")) {
155+
const line = rawLine.trim();
156+
if (!line || line.startsWith("#")) continue;
157+
const eq = line.indexOf("=");
158+
if (eq === -1) continue;
159+
const key = line.slice(0, eq).trim();
160+
if (key) {
161+
map.set(key, line.slice(eq + 1).trim());
162+
}
163+
}
164+
return map;
165+
}
166+
167+
async function checkEnvFiles(projectDir: string): Promise<DoctorCheck[]> {
168+
const checks: DoctorCheck[] = [];
169+
const exampleFiles = await findEnvExampleFiles(projectDir);
170+
171+
for (const examplePath of exampleFiles) {
172+
const envPath = examplePath.replace(/\.example$/, "");
173+
const relExample = path.relative(projectDir, examplePath) || ".env.example";
174+
const exampleKeys = parseEnvKeys(await fs.readFile(examplePath, "utf-8"));
175+
if (exampleKeys.size === 0) continue;
176+
177+
if (!(await fs.pathExists(envPath))) {
178+
checks.push({
179+
label: relExample,
180+
status: "warn",
181+
detail: `Missing ${path.relative(projectDir, envPath)} (copy from .env.example and fill in values)`,
182+
});
183+
continue;
184+
}
185+
186+
const envKeys = parseEnvKeys(await fs.readFile(envPath, "utf-8"));
187+
const missing: string[] = [];
188+
for (const key of exampleKeys.keys()) {
189+
const value = envKeys.get(key);
190+
if (value === undefined || value === "") {
191+
missing.push(key);
192+
}
193+
}
194+
195+
checks.push(
196+
missing.length > 0
197+
? {
198+
label: path.relative(projectDir, envPath),
199+
status: "warn",
200+
detail: `Missing or empty: ${missing.join(", ")}`,
201+
}
202+
: { label: path.relative(projectDir, envPath), status: "pass" },
203+
);
204+
}
205+
206+
return checks;
207+
}
208+
209+
async function runBuildChecks(config: ProjectConfig, json: boolean): Promise<DoctorCheck[]> {
210+
const checks: DoctorCheck[] = [];
211+
const projectDir = config.projectDir;
212+
213+
const rootPackageJsonPath = path.join(projectDir, "package.json");
214+
if (await fs.pathExists(rootPackageJsonPath)) {
215+
const pkg = (await fs.readJson(rootPackageJsonPath).catch(() => ({}))) as {
216+
scripts?: Record<string, string>;
217+
};
218+
if (pkg.scripts?.["check-types"]) {
219+
const pm = config.packageManager ?? "npm";
220+
const s = json ? null : spinner();
221+
s?.start("Running type checks (check-types)...");
222+
const result = await $({
223+
cwd: projectDir,
224+
reject: false,
225+
stdout: json ? "ignore" : "inherit",
226+
stderr: json ? "ignore" : "inherit",
227+
})`${pm} run check-types`;
228+
if (result.exitCode === 0) {
229+
s?.stop("Type checks passed");
230+
checks.push({ label: "check-types", status: "pass" });
231+
} else {
232+
s?.stop(pc.red("Type checks failed"));
233+
checks.push({
234+
label: "check-types",
235+
status: "fail",
236+
detail: `\`${pm} run check-types\` exited with code ${result.exitCode ?? `signal ${result.signal}`}`,
237+
});
238+
}
239+
}
240+
}
241+
242+
if (hasNativeChecks(config)) {
243+
if (json) {
244+
checks.push({
245+
label: "ecosystem build checks",
246+
status: "warn",
247+
detail: "Skipped in --json mode. Re-run without --json to execute native build checks.",
248+
});
249+
} else {
250+
try {
251+
await runGeneratedChecks(config);
252+
checks.push({ label: "ecosystem build checks", status: "pass" });
253+
} catch (error) {
254+
checks.push({
255+
label: "ecosystem build checks",
256+
status: "fail",
257+
detail: error instanceof Error ? error.message : String(error),
258+
});
259+
}
260+
}
261+
}
262+
263+
return checks;
264+
}
265+
266+
export async function doctorCommand(input: DoctorCommandInput): Promise<void> {
267+
const projectDir = path.resolve(input.projectDir || process.cwd());
268+
const json = input.json ?? false;
269+
270+
const btsConfig = await readBtsConfig(projectDir);
271+
if (!btsConfig) {
272+
if (json) {
273+
console.log(
274+
JSON.stringify(
275+
{
276+
projectDir,
277+
ok: false,
278+
error: "No Better Fullstack project found (bts.jsonc missing or invalid).",
279+
},
280+
null,
281+
2,
282+
),
283+
);
284+
// Exit synchronously: trpc-cli calls process.exit(0) after the handler
285+
// resolves, which would override process.exitCode and break doctor as a gate.
286+
process.exit(1);
287+
}
288+
handleError(`No Better Fullstack project found in ${projectDir}. Make sure bts.jsonc exists.`);
289+
}
290+
291+
const config = { ...btsConfig, projectDir } as unknown as ProjectConfig;
292+
293+
if (!json) {
294+
renderTitle();
295+
intro(pc.magenta(`Diagnosing ${pc.cyan(path.basename(projectDir))}`));
296+
log.info(pc.dim(`Path: ${projectDir}`));
297+
log.info(pc.dim(`Ecosystem: ${btsConfig.ecosystem}`));
298+
if (btsConfig.graphSummary) {
299+
log.info(pc.dim(`Stack: ${btsConfig.graphSummary}`));
300+
}
301+
}
302+
303+
const checks: DoctorCheck[] = [
304+
{ label: "bts.jsonc", status: "pass", detail: `version ${btsConfig.version}` },
305+
];
306+
checks.push(...(await checkInstalledDependencies(projectDir, btsConfig)));
307+
checks.push(...(await checkEnvFiles(projectDir)));
308+
309+
if (!input.skipChecks) {
310+
checks.push(...(await runBuildChecks(config, json)));
311+
}
312+
313+
const counts: Record<CheckStatus, number> = { pass: 0, warn: 0, fail: 0 };
314+
for (const check of checks) {
315+
counts[check.status] += 1;
316+
}
317+
318+
if (json) {
319+
console.log(
320+
JSON.stringify(
321+
{
322+
projectDir,
323+
ecosystem: btsConfig.ecosystem,
324+
ok: counts.fail === 0,
325+
summary: counts,
326+
checks,
327+
},
328+
null,
329+
2,
330+
),
331+
);
332+
} else {
333+
log.message("");
334+
for (const check of checks) {
335+
log.message(
336+
`${statusIcon(check.status)} ${check.label}${
337+
check.detail ? pc.dim(` — ${check.detail}`) : ""
338+
}`,
339+
);
340+
}
341+
log.message("");
342+
const summaryLine = `${pc.green(`${counts.pass} passed`)}, ${pc.yellow(
343+
`${counts.warn} warnings`,
344+
)}, ${pc.red(`${counts.fail} failed`)}`;
345+
if (counts.fail > 0) {
346+
log.error(`Diagnosis complete: ${summaryLine}`);
347+
} else if (counts.warn > 0) {
348+
log.warn(`Diagnosis complete: ${summaryLine}`);
349+
} else {
350+
log.success(`Diagnosis complete: ${summaryLine}`);
351+
}
352+
}
353+
354+
// Exit synchronously on failure: trpc-cli calls process.exit(0) after the
355+
// handler resolves, which would override process.exitCode and let CI pipelines
356+
// (e.g. `bfs doctor && deploy`) proceed despite a failed diagnosis.
357+
if (counts.fail > 0) {
358+
process.exit(1);
359+
}
360+
}

apps/cli/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export const ADDON_COMPATIBILITY = {
7979
wxt: [],
8080
devcontainer: [],
8181
"docker-compose": [],
82+
"github-actions": [],
8283
msw: [],
8384
storybook: ["tanstack-router", "react-router", "react-vite", "next", "vinext", "nuxt", "svelte", "solid"],
8485
swr: ["tanstack-router", "react-router", "react-vite", "tanstack-start", "next", "vinext", "astro", "redwood"],

apps/cli/src/create-command-input.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ import {
119119
RustOrmSchema,
120120
RustWebFrameworkSchema,
121121
SearchSchema,
122+
VectorDbSchema,
122123
ServerDeploySchema,
123124
ShadcnBaseColorSchema,
124125
ShadcnBaseSchema,
@@ -196,6 +197,9 @@ export const CreateCommandOptionsSchema = z.object({
196197
rateLimit: RateLimitSchema.optional().describe("Rate limiting solution"),
197198
i18n: I18nSchema.optional().describe("Internationalization (i18n) library"),
198199
search: SearchSchema.optional().describe("Search engine solution"),
200+
vectorDb: VectorDbSchema.optional().describe(
201+
"Vector database for AI embeddings (pgvector, qdrant, chroma, pinecone)",
202+
),
199203
fileStorage: FileStorageSchema.optional().describe("File storage solution (S3, R2)"),
200204
mobileNavigation: MobileNavigationSchema.optional().describe(
201205
"Mobile navigation (expo-router, react-navigation)",

0 commit comments

Comments
 (0)