Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
1f4399d
fix(cli): report accurate scaffold status and roll back partial outpu…
Marve10s Jun 18, 2026
908f0af
fix(testing): gate CI smoke on any real step failure
Marve10s Jun 18, 2026
c65553a
feat(templates): add check-types to generated packages + coverage guard
Marve10s Jun 18, 2026
062acb1
docs: add next-updates roadmap from codebase + platform analysis
Marve10s Jun 18, 2026
0cdfbf6
fix(web): derive ecosystem count/names from schema (include .NET = 8)
Marve10s Jun 18, 2026
5339029
feat(ai-docs): generate AGENTS.md by default with the standard upperc…
Marve10s Jun 18, 2026
7883b6c
feat(web): add /showcase route for the community project gallery
Marve10s Jun 18, 2026
db608a9
feat(web): add /analytics popular-stacks dashboard + nav links
Marve10s Jun 18, 2026
826c6da
test(smoke): cover react-native in the per-PR smoke matrix (pr-core)
Marve10s Jun 18, 2026
6da0425
fix(types): update default-aiDocs test for the AGENTS.md default
Marve10s Jun 18, 2026
9f939b7
test: add compat property tests, schema↔template coverage, and API-li…
Marve10s Jun 18, 2026
2f862f6
feat(cli): add `bfs doctor` command
Marve10s Jun 18, 2026
9b3be20
feat(mcp): expose missing create fields + targetDir
Marve10s Jun 18, 2026
35c4514
test(smoke): add dotnet to the combo generator + supported ecosystems
Marve10s Jun 18, 2026
e4a70d3
fix(cli): make bfs doctor exit non-zero on failure
Marve10s Jun 18, 2026
18fcf6d
fix(cli): complete the AGENTS.md default (createVirtual + MCP compat …
Marve10s Jun 18, 2026
538062c
test: scope dotnet matching in the schema↔template coverage guard
Marve10s Jun 18, 2026
ebe4eb2
feat(addons): add github-actions CI addon
Marve10s Jun 18, 2026
75eda5f
feat(mcp): structured outputs + annotations + list_presets/recommend_…
Marve10s Jun 18, 2026
2333600
fix(mcp,ci): ecosystem-correct compatibility issues, recommend config…
Marve10s Jun 18, 2026
e1310e0
feat(types,cli,web): add vectorDb category (pgvector/qdrant/chroma/pi…
Marve10s Jun 19, 2026
d499799
fix(web,testing): vectorDb icons + smoke command emits --vector-db
Marve10s Jun 19, 2026
a4a5e4a
fix(types): tolerate omitted vectorDb in analyzeStackCompatibility
Marve10s Jun 19, 2026
82ab51d
fix(templates): stripe env/apiVersion + better-auth api dep + go hand…
Marve10s Jun 19, 2026
015231e
fix(templates): vinext base-ui trigger + better-auth no-db + python r…
Marve10s Jun 19, 2026
2ad6ca2
test(cli): update RQ pyproject assertion to redis>=5.0.1
Marve10s Jun 19, 2026
955e17e
fix(templates,testing): redis zscore type, garph ctx/context, better-…
Marve10s Jun 20, 2026
db393fa
feat(web): slim analytics into a stack leaderboard; remove /showcase
Marve10s Jun 21, 2026
124c847
feat(analytics): collect all-ecosystem telemetry + ecosystem leaderboard
Marve10s Jun 21, 2026
35d84bc
test(cli): refresh template snapshots after rebase
Marve10s Jun 22, 2026
22648ec
test(cli): align CI guards after rebase
Marve10s Jun 22, 2026
21ac00a
fix(generator): repair smoke failures after rebase
Marve10s Jun 22, 2026
437bc41
fix(generator): align smoke templates with dependency updates
Marve10s Jun 22, 2026
b4849bb
fix(generator): cover additional smoke edge cases
Marve10s Jun 22, 2026
ef76c7b
fix(generator): configure elixir swoosh finch client
Marve10s Jun 22, 2026
d79cb22
test(smoke): probe expected dev url during startup
Marve10s Jun 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
360 changes: 360 additions & 0 deletions apps/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,360 @@
import type { Dirent } from "node:fs";

import { intro, log, spinner } from "@clack/prompts";
import { $ } from "execa";
import fs from "fs-extra";
import path from "node:path";
import pc from "picocolors";

import type { BetterTStackConfig, ProjectConfig } from "../types";

import { readBtsConfig } from "../utils/bts-config";
import { handleError } from "../utils/errors";
import { runGeneratedChecks } from "../utils/generated-checks";
import { renderTitle } from "../utils/render-title";

export type DoctorCommandInput = {
projectDir?: string;
skipChecks?: boolean;
json?: boolean;
};

type CheckStatus = "pass" | "warn" | "fail";

type DoctorCheck = {
label: string;
status: CheckStatus;
detail?: string;
};

const NON_TS_BACKEND_ECOSYSTEMS = new Set(["go", "rust", "python", "elixir", "java", "dotnet"]);

const IGNORED_DIRECTORIES = new Set([
"node_modules",
".git",
"dist",
"build",
".next",
".expo",
".turbo",
"target",
".venv",
"deps",
"_build",
]);

const JS_LOCKFILES = ["bun.lock", "bun.lockb", "pnpm-lock.yaml", "package-lock.json", "yarn.lock"];

const NATIVE_LOCKFILES: Record<string, { file: string; hint: string }> = {
rust: { file: "Cargo.lock", hint: "cargo build" },
go: { file: "go.sum", hint: "go mod tidy" },
python: { file: "uv.lock", hint: "uv sync" },
elixir: { file: "mix.lock", hint: "mix deps.get" },
};

function statusIcon(status: CheckStatus): string {
switch (status) {
case "pass":
return pc.green("✓");
case "warn":
return pc.yellow("!");
case "fail":
return pc.red("✗");
}
}

function hasNativeChecks(config: Pick<ProjectConfig, "ecosystem" | "stackParts">): boolean {
if (NON_TS_BACKEND_ECOSYSTEMS.has(config.ecosystem)) {
return true;
}
return (config.stackParts ?? []).some(
(part) =>
part.source !== "provided" &&
part.role === "backend" &&
NON_TS_BACKEND_ECOSYSTEMS.has(part.ecosystem),
);
}

async function checkInstalledDependencies(
projectDir: string,
config: BetterTStackConfig,
): Promise<DoctorCheck[]> {
const checks: DoctorCheck[] = [];

if (await fs.pathExists(path.join(projectDir, "package.json"))) {
const lockfile = JS_LOCKFILES.find((name) => fs.existsSync(path.join(projectDir, name)));
checks.push(
lockfile
? { label: "Lockfile", status: "pass", detail: lockfile }
: {
label: "Lockfile",
status: "warn",
detail: "No JavaScript lockfile found at the project root",
},
);

const nodeModulesExists = await fs.pathExists(path.join(projectDir, "node_modules"));
checks.push(
nodeModulesExists
? { label: "node_modules", status: "pass" }
: {
label: "node_modules",
status: "fail",
detail: `Dependencies are not installed. Run \`${config.packageManager ?? "npm"} install\`.`,
},
);
}

const native = NATIVE_LOCKFILES[config.ecosystem];
if (native) {
const exists =
(await fs.pathExists(path.join(projectDir, native.file))) ||
(await fs.pathExists(path.join(projectDir, "apps/server", native.file)));
checks.push(
exists
? { label: native.file, status: "pass" }
: {
label: native.file,
status: "warn",
detail: `Not found. Run \`${native.hint}\` to fetch dependencies.`,
},
);
}

return checks;
}

async function findEnvExampleFiles(rootDir: string): Promise<string[]> {
const results: string[] = [];

async function walk(dir: string, depth: number): Promise<void> {
if (depth > 5) return;
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (IGNORED_DIRECTORIES.has(entry.name)) continue;
await walk(path.join(dir, entry.name), depth + 1);
} else if (entry.name === ".env.example") {
results.push(path.join(dir, entry.name));
}
}
}

await walk(rootDir, 0);
return results;
}

function parseEnvKeys(content: string): Map<string, string> {
const map = new Map<string, string>();
for (const rawLine of content.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
if (key) {
map.set(key, line.slice(eq + 1).trim());
}
}
return map;
}

async function checkEnvFiles(projectDir: string): Promise<DoctorCheck[]> {
const checks: DoctorCheck[] = [];
const exampleFiles = await findEnvExampleFiles(projectDir);

for (const examplePath of exampleFiles) {
const envPath = examplePath.replace(/\.example$/, "");
const relExample = path.relative(projectDir, examplePath) || ".env.example";
const exampleKeys = parseEnvKeys(await fs.readFile(examplePath, "utf-8"));
if (exampleKeys.size === 0) continue;

if (!(await fs.pathExists(envPath))) {
checks.push({
label: relExample,
status: "warn",
detail: `Missing ${path.relative(projectDir, envPath)} (copy from .env.example and fill in values)`,
});
continue;
}

const envKeys = parseEnvKeys(await fs.readFile(envPath, "utf-8"));
const missing: string[] = [];
for (const key of exampleKeys.keys()) {
const value = envKeys.get(key);
if (value === undefined || value === "") {
missing.push(key);
}
}

checks.push(
missing.length > 0
? {
label: path.relative(projectDir, envPath),
status: "warn",
detail: `Missing or empty: ${missing.join(", ")}`,
}
: { label: path.relative(projectDir, envPath), status: "pass" },
);
}

return checks;
}

async function runBuildChecks(config: ProjectConfig, json: boolean): Promise<DoctorCheck[]> {
const checks: DoctorCheck[] = [];
const projectDir = config.projectDir;

const rootPackageJsonPath = path.join(projectDir, "package.json");
if (await fs.pathExists(rootPackageJsonPath)) {
const pkg = (await fs.readJson(rootPackageJsonPath).catch(() => ({}))) as {
scripts?: Record<string, string>;
};
if (pkg.scripts?.["check-types"]) {
const pm = config.packageManager ?? "npm";
const s = json ? null : spinner();
s?.start("Running type checks (check-types)...");
const result = await $({
cwd: projectDir,
reject: false,
stdout: json ? "ignore" : "inherit",
stderr: json ? "ignore" : "inherit",
})`${pm} run check-types`;
if (result.exitCode === 0) {
s?.stop("Type checks passed");
checks.push({ label: "check-types", status: "pass" });
} else {
s?.stop(pc.red("Type checks failed"));
checks.push({
label: "check-types",
status: "fail",
detail: `\`${pm} run check-types\` exited with code ${result.exitCode ?? `signal ${result.signal}`}`,
});
}
}
}

if (hasNativeChecks(config)) {
if (json) {
checks.push({
label: "ecosystem build checks",
status: "warn",
detail: "Skipped in --json mode. Re-run without --json to execute native build checks.",
});
} else {
try {
await runGeneratedChecks(config);
checks.push({ label: "ecosystem build checks", status: "pass" });
} catch (error) {
checks.push({
label: "ecosystem build checks",
status: "fail",
detail: error instanceof Error ? error.message : String(error),
});
}
}
}

return checks;
}

export async function doctorCommand(input: DoctorCommandInput): Promise<void> {
const projectDir = path.resolve(input.projectDir || process.cwd());
const json = input.json ?? false;

const btsConfig = await readBtsConfig(projectDir);
if (!btsConfig) {
if (json) {
console.log(
JSON.stringify(
{
projectDir,
ok: false,
error: "No Better Fullstack project found (bts.jsonc missing or invalid).",
},
null,
2,
),
);
// Exit synchronously: trpc-cli calls process.exit(0) after the handler
// resolves, which would override process.exitCode and break doctor as a gate.
process.exit(1);
}
handleError(`No Better Fullstack project found in ${projectDir}. Make sure bts.jsonc exists.`);
}

const config = { ...btsConfig, projectDir } as unknown as ProjectConfig;

if (!json) {
renderTitle();
intro(pc.magenta(`Diagnosing ${pc.cyan(path.basename(projectDir))}`));
log.info(pc.dim(`Path: ${projectDir}`));
log.info(pc.dim(`Ecosystem: ${btsConfig.ecosystem}`));
if (btsConfig.graphSummary) {
log.info(pc.dim(`Stack: ${btsConfig.graphSummary}`));
}
}

const checks: DoctorCheck[] = [
{ label: "bts.jsonc", status: "pass", detail: `version ${btsConfig.version}` },
];
checks.push(...(await checkInstalledDependencies(projectDir, btsConfig)));
checks.push(...(await checkEnvFiles(projectDir)));

if (!input.skipChecks) {
checks.push(...(await runBuildChecks(config, json)));
}

const counts: Record<CheckStatus, number> = { pass: 0, warn: 0, fail: 0 };
for (const check of checks) {
counts[check.status] += 1;
}

if (json) {
console.log(
JSON.stringify(
{
projectDir,
ecosystem: btsConfig.ecosystem,
ok: counts.fail === 0,
summary: counts,
checks,
},
null,
2,
),
);
} else {
log.message("");
for (const check of checks) {
log.message(
`${statusIcon(check.status)} ${check.label}${
check.detail ? pc.dim(` — ${check.detail}`) : ""
}`,
);
}
log.message("");
const summaryLine = `${pc.green(`${counts.pass} passed`)}, ${pc.yellow(
`${counts.warn} warnings`,
)}, ${pc.red(`${counts.fail} failed`)}`;
if (counts.fail > 0) {
log.error(`Diagnosis complete: ${summaryLine}`);
} else if (counts.warn > 0) {
log.warn(`Diagnosis complete: ${summaryLine}`);
} else {
log.success(`Diagnosis complete: ${summaryLine}`);
}
}

// Exit synchronously on failure: trpc-cli calls process.exit(0) after the
// handler resolves, which would override process.exitCode and let CI pipelines
// (e.g. `bfs doctor && deploy`) proceed despite a failed diagnosis.
if (counts.fail > 0) {
process.exit(1);
}
}
1 change: 1 addition & 0 deletions apps/cli/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const ADDON_COMPATIBILITY = {
wxt: [],
devcontainer: [],
"docker-compose": [],
"github-actions": [],
msw: [],
storybook: ["tanstack-router", "react-router", "react-vite", "next", "vinext", "nuxt", "svelte", "solid"],
swr: ["tanstack-router", "react-router", "react-vite", "tanstack-start", "next", "vinext", "astro", "redwood"],
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/create-command-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ import {
RustOrmSchema,
RustWebFrameworkSchema,
SearchSchema,
VectorDbSchema,
ServerDeploySchema,
ShadcnBaseColorSchema,
ShadcnBaseSchema,
Expand Down Expand Up @@ -196,6 +197,9 @@ export const CreateCommandOptionsSchema = z.object({
rateLimit: RateLimitSchema.optional().describe("Rate limiting solution"),
i18n: I18nSchema.optional().describe("Internationalization (i18n) library"),
search: SearchSchema.optional().describe("Search engine solution"),
vectorDb: VectorDbSchema.optional().describe(
"Vector database for AI embeddings (pgvector, qdrant, chroma, pinecone)",
),
fileStorage: FileStorageSchema.optional().describe("File storage solution (S3, R2)"),
mobileNavigation: MobileNavigationSchema.optional().describe(
"Mobile navigation (expo-router, react-navigation)",
Expand Down
Loading
Loading