feat(cli): add programmatic api (#494)

This commit is contained in:
Aman Varshney
2025-08-12 07:40:19 +05:30
committed by GitHub
parent 5b2827ef12
commit aecde5a54e
18 changed files with 1295 additions and 203 deletions

View File

@@ -9,9 +9,33 @@ import {
removeSync,
} from "fs-extra";
import * as JSONC from "jsonc-parser";
import { FailedToExitError } from "trpc-cli";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createBtsCli } from "../src/index";
const CLI_BIN = join(__dirname, "..", "dist", "index.js");
async function runCli(argv: string[], cwd: string) {
const previous = process.cwd();
process.chdir(cwd);
try {
const cli = createBtsCli();
await cli
.run({
argv,
logger: { info: () => {}, error: () => {} },
process: { exit: () => void 0 as never },
})
.catch((err) => {
let e: unknown = err;
while (e instanceof FailedToExitError) {
if (e.exitCode === 0) return e.cause;
e = e.cause;
}
throw e;
});
} finally {
process.chdir(previous);
}
}
function createTmpDir(_prefix: string) {
const dir = join(__dirname, "..", ".smoke");
@@ -22,39 +46,30 @@ function createTmpDir(_prefix: string) {
return dir;
}
async function runCli(args: string[], cwd: string, env?: NodeJS.ProcessEnv) {
const subprocess = execa("node", [CLI_BIN, ...args], {
cwd,
env: {
...process.env,
BTS_TELEMETRY_DISABLED: "1",
...env,
},
});
subprocess.stdout?.pipe(process.stdout);
subprocess.stderr?.pipe(process.stderr);
const { exitCode } = await subprocess;
expect(exitCode).toBe(0);
}
async function runCliExpectingError(
args: string[],
cwd: string,
env?: NodeJS.ProcessEnv,
) {
const subprocess = execa("node", [CLI_BIN, ...args], {
cwd,
env: {
...process.env,
BTS_TELEMETRY_DISABLED: "1",
...env,
},
reject: false,
});
subprocess.stdout?.pipe(process.stdout);
subprocess.stderr?.pipe(process.stderr);
const { exitCode } = await subprocess;
expect(exitCode).not.toBe(0);
async function runCliExpectingError(args: string[], cwd: string) {
const previous = process.cwd();
process.chdir(cwd);
try {
const cli = createBtsCli();
let threw = false;
await cli
.run({
argv: args,
logger: { info: () => {}, error: () => {} },
process: { exit: () => void 0 as never },
})
.catch((err) => {
threw = true;
let e: unknown = err;
while (e instanceof FailedToExitError) {
if (e.exitCode === 0) throw new Error("Expected failure");
e = e.cause;
}
});
expect(threw).toBe(true);
} finally {
process.chdir(previous);
}
}
function assertScaffoldedProject(dir: string) {
@@ -310,9 +325,8 @@ describe("create-better-t-stack smoke", () => {
expect(exitCode).toBe(0);
consola.success("CLI build completed");
const cliBinExists = existsSync(CLI_BIN);
expect(cliBinExists).toBe(true);
consola.info(`CLI binary: ${CLI_BIN}`);
process.env.BTS_TELEMETRY_DISABLED = "1";
consola.info("Programmatic CLI mode");
});
// Exhaustive matrix: all frontends x standard backends (no db, no orm, no api, no auth)

View File

@@ -0,0 +1,340 @@
import { join } from "node:path";
import { ensureDirSync, existsSync, readFileSync, removeSync } from "fs-extra";
import { parse as parseJsonc } from "jsonc-parser";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { init } from "../src/index";
import type { BetterTStackConfig } from "../src/types";
let testCounter = 0;
let tmpDir: string;
let originalCwd: string;
function createTmpDir() {
testCounter++;
const dir = join(__dirname, "..", `.prog-test-${testCounter}`);
if (existsSync(dir)) {
removeSync(dir);
}
ensureDirSync(dir);
return dir;
}
function assertProjectExists(dir: string) {
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "package.json"))).toBe(true);
expect(existsSync(join(dir, "bts.jsonc"))).toBe(true);
}
function assertBtsConfig(
dir: string,
expectedConfig: Partial<{
frontend: string[];
backend: string;
database: string;
orm: string;
api: string;
runtime: string;
addons: string[];
}>,
) {
const configPath = join(dir, "bts.jsonc");
expect(existsSync(configPath)).toBe(true);
const configContent = readFileSync(configPath, "utf-8");
const config: BetterTStackConfig = parseJsonc(configContent);
if (expectedConfig.frontend) {
expect(config.frontend).toEqual(expectedConfig.frontend);
}
if (expectedConfig.backend) {
expect(config.backend).toBe(expectedConfig.backend);
}
if (expectedConfig.database) {
expect(config.database).toBe(expectedConfig.database);
}
if (expectedConfig.orm) {
expect(config.orm).toBe(expectedConfig.orm);
}
if (expectedConfig.api) {
expect(config.api).toBe(expectedConfig.api);
}
if (expectedConfig.runtime) {
expect(config.runtime).toBe(expectedConfig.runtime);
}
if (expectedConfig.addons) {
expect(config.addons).toEqual(expectedConfig.addons);
}
}
describe("Programmatic API - Fast Tests", () => {
beforeEach(() => {
originalCwd = process.cwd();
tmpDir = createTmpDir();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(originalCwd);
if (existsSync(tmpDir)) {
removeSync(tmpDir);
}
});
describe("Core functionality", () => {
test("creates minimal project successfully", async () => {
const result = await init("test-app", {
yes: true,
install: false,
git: false,
});
expect(result.success).toBe(true);
expect(result.projectConfig.projectName).toBe("test-app");
expect(result.projectDirectory).toContain("test-app");
expect(result.reproducibleCommand).toContain("test-app");
expect(typeof result.elapsedTimeMs).toBe("number");
expect(result.elapsedTimeMs).toBeGreaterThan(0);
assertProjectExists(result.projectDirectory);
}, 15000);
test("returns complete result structure", async () => {
const result = await init("result-test", {
yes: true,
install: false,
git: false,
});
expect(result).toHaveProperty("success");
expect(result).toHaveProperty("projectConfig");
expect(result).toHaveProperty("reproducibleCommand");
expect(result).toHaveProperty("timeScaffolded");
expect(result).toHaveProperty("elapsedTimeMs");
expect(result).toHaveProperty("projectDirectory");
expect(result).toHaveProperty("relativePath");
}, 15000);
test("handles project with custom name", async () => {
const result = await init("custom-name", {
yes: true,
install: false,
git: false,
});
expect(result.success).toBe(true);
expect(result.projectConfig.projectName).toBe("custom-name");
expect(result.projectDirectory).toContain("custom-name");
}, 15000);
});
describe("Configuration options", () => {
test("creates project with Next.js frontend", async () => {
const result = await init("next-app", {
yes: true,
frontend: ["next"],
install: false,
git: false,
});
expect(result.success).toBe(true);
assertBtsConfig(result.projectDirectory, {
frontend: ["next"],
});
}, 15000);
test("creates project with Fastify backend", async () => {
const result = await init("fastify-app", {
yes: true,
backend: "fastify",
install: false,
git: false,
});
expect(result.success).toBe(true);
assertBtsConfig(result.projectDirectory, {
backend: "fastify",
});
}, 15000);
test("creates project with PostgreSQL + Prisma", async () => {
const result = await init("pg-app", {
yes: true,
database: "postgres",
orm: "prisma",
install: false,
git: false,
});
expect(result.success).toBe(true);
assertBtsConfig(result.projectDirectory, {
database: "postgres",
orm: "prisma",
});
}, 15000);
test("creates project with oRPC API", async () => {
const result = await init("orpc-app", {
yes: true,
api: "orpc",
install: false,
git: false,
});
expect(result.success).toBe(true);
assertBtsConfig(result.projectDirectory, {
api: "orpc",
});
}, 15000);
test("creates project with Node runtime", async () => {
const result = await init("node-app", {
yes: true,
runtime: "node",
install: false,
git: false,
});
expect(result.success).toBe(true);
assertBtsConfig(result.projectDirectory, {
runtime: "node",
});
}, 15000);
test("creates project with Biome addon", async () => {
const result = await init("biome-app", {
yes: true,
addons: ["biome"],
install: false,
git: false,
});
expect(result.success).toBe(true);
assertBtsConfig(result.projectDirectory, {
addons: ["biome"],
});
}, 15000);
});
describe("Error scenarios", () => {
test("handles invalid project name", async () => {
await expect(
init("", {
yes: true,
install: false,
git: false,
}),
).rejects.toThrow("Project name cannot be empty");
});
test("handles invalid characters in project name", async () => {
await expect(
init("invalid<name>", {
yes: true,
install: false,
git: false,
}),
).rejects.toThrow("invalid characters");
});
test("handles incompatible database + ORM combination", async () => {
await expect(
init("incompatible", {
yes: true,
database: "mongodb",
orm: "drizzle",
install: false,
git: false,
yolo: false,
}),
).rejects.toThrow(/requires Mongoose or Prisma/);
});
test("handles auth without database", async () => {
await expect(
init("auth-no-db", {
yes: true,
auth: true,
database: "none",
install: false,
git: false,
yolo: false,
}),
).rejects.toThrow(/Authentication requires/);
});
test("handles directory conflict with error strategy", async () => {
const result1 = await init("conflict-test", {
yes: true,
install: false,
git: false,
});
expect(result1.success).toBe(true);
const result2 = await init("conflict-test", {
yes: true,
install: false,
git: false,
directoryConflict: "error",
});
expect(result2.success).toBe(false);
expect(result2.error).toMatch(/already exists/);
}, 20000);
});
describe("Advanced features", () => {
test("creates project with multiple addons", async () => {
const result = await init("multi-addon", {
yes: true,
addons: ["biome", "turborepo"],
install: false,
git: false,
});
expect(result.success).toBe(true);
assertBtsConfig(result.projectDirectory, {
addons: ["biome", "turborepo"],
});
}, 15000);
test("creates project with authentication enabled", async () => {
const result = await init("auth-app", {
yes: true,
auth: true,
database: "sqlite",
orm: "drizzle",
install: false,
git: false,
});
expect(result.success).toBe(true);
assertBtsConfig(result.projectDirectory, {
database: "sqlite",
orm: "drizzle",
});
expect(result.projectConfig.auth).toBe(true);
}, 15000);
test("validates reproducible command format", async () => {
const result = await init("repro-test", {
yes: true,
frontend: ["next"],
backend: "fastify",
database: "postgres",
orm: "prisma",
install: false,
git: false,
});
expect(result.success).toBe(true);
expect(result.reproducibleCommand).toContain("repro-test");
expect(result.reproducibleCommand).toContain("--frontend next");
expect(result.reproducibleCommand).toContain("--backend fastify");
expect(result.reproducibleCommand).toContain("--database postgres");
expect(result.reproducibleCommand).toContain("--orm prisma");
expect(result.reproducibleCommand).toContain("--no-install");
expect(result.reproducibleCommand).toContain("--no-git");
}, 15000);
});
});