mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
feat(cli): add programmatic api (#494)
This commit is contained in:
@@ -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)
|
||||
|
||||
340
apps/cli/test/programmatic-api.test.ts
Normal file
340
apps/cli/test/programmatic-api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user