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

@@ -6,7 +6,7 @@
"license": "MIT",
"author": "Aman Varshney",
"bin": {
"create-better-t-stack": "dist/index.js"
"create-better-t-stack": "dist/cli.js"
},
"files": [
"templates",
@@ -53,6 +53,12 @@
"test:with-build": "bun run build && WITH_BUILD=1 vitest run",
"prepublishOnly": "npm run build"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"dependencies": {
"@clack/prompts": "^0.11.0",
"consola": "^3.4.2",
@@ -65,12 +71,12 @@
"picocolors": "^1.1.1",
"trpc-cli": "^0.10.2",
"ts-morph": "^26.0.0",
"zod": "^4.0.15"
"zod": "^4.0.17"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/node": "^24.2.0",
"tsdown": "^0.13.3",
"@types/node": "^24.2.1",
"tsdown": "^0.14.1",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}

3
apps/cli/src/cli.ts Normal file
View File

@@ -0,0 +1,3 @@
import { createBtsCli } from "./index";
createBtsCli().run();

View File

@@ -1,5 +1,6 @@
import path from "node:path";
import { intro, log, outro } from "@clack/prompts";
import consola from "consola";
import fs from "fs-extra";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../../constants";
@@ -7,7 +8,13 @@ import { getAddonsToAdd } from "../../prompts/addons";
import { gatherConfig } from "../../prompts/config-prompts";
import { getProjectName } from "../../prompts/project-name";
import { getDeploymentToAdd } from "../../prompts/web-deploy";
import type { AddInput, CreateInput, ProjectConfig } from "../../types";
import type {
AddInput,
CreateInput,
DirectoryConflict,
InitResult,
ProjectConfig,
} from "../../types";
import { trackProjectCreation } from "../../utils/analytics";
import { displayConfig } from "../../utils/display-config";
import { exitWithError, handleError } from "../../utils/errors";
@@ -26,113 +33,221 @@ import { installDependencies } from "./install-dependencies";
export async function createProjectHandler(
input: CreateInput & { projectName?: string },
) {
): Promise<InitResult> {
const startTime = Date.now();
const timeScaffolded = new Date().toISOString();
if (input.renderTitle !== false) {
renderTitle();
}
intro(pc.magenta("Creating a new Better-T Stack project"));
if (input.yolo) {
consola.fatal("YOLO mode enabled - skipping checks. Things may break!");
}
let currentPathInput: string;
if (input.yes && input.projectName) {
currentPathInput = input.projectName;
} else if (input.yes) {
let defaultName = DEFAULT_CONFIG.relativePath;
let counter = 1;
while (
fs.pathExistsSync(path.resolve(process.cwd(), defaultName)) &&
fs.readdirSync(path.resolve(process.cwd(), defaultName)).length > 0
) {
defaultName = `${DEFAULT_CONFIG.projectName}-${counter}`;
counter++;
}
currentPathInput = defaultName;
} else {
currentPathInput = await getProjectName(input.projectName);
}
let finalPathInput: string;
let shouldClearDirectory: boolean;
try {
renderTitle();
intro(pc.magenta("Creating a new Better-T Stack project"));
let currentPathInput: string;
if (input.yes && input.projectName) {
currentPathInput = input.projectName;
} else if (input.yes) {
let defaultName = DEFAULT_CONFIG.relativePath;
let counter = 1;
while (
fs.pathExistsSync(path.resolve(process.cwd(), defaultName)) &&
fs.readdirSync(path.resolve(process.cwd(), defaultName)).length > 0
) {
defaultName = `${DEFAULT_CONFIG.projectName}-${counter}`;
counter++;
}
currentPathInput = defaultName;
if (input.directoryConflict) {
const result = await handleDirectoryConflictProgrammatically(
currentPathInput,
input.directoryConflict,
);
finalPathInput = result.finalPathInput;
shouldClearDirectory = result.shouldClearDirectory;
} else {
currentPathInput = await getProjectName(input.projectName);
const result = await handleDirectoryConflict(currentPathInput);
finalPathInput = result.finalPathInput;
shouldClearDirectory = result.shouldClearDirectory;
}
} catch (error) {
const elapsedTimeMs = Date.now() - startTime;
return {
success: false,
projectConfig: {
projectName: "",
projectDir: "",
relativePath: "",
database: "none",
orm: "none",
backend: "none",
runtime: "none",
frontend: [],
addons: [],
examples: [],
auth: false,
git: false,
packageManager: "npm",
install: false,
dbSetup: "none",
api: "none",
webDeploy: "none",
} satisfies ProjectConfig,
reproducibleCommand: "",
timeScaffolded,
elapsedTimeMs,
projectDirectory: "",
relativePath: "",
error: error instanceof Error ? error.message : String(error),
};
}
const { finalPathInput, shouldClearDirectory } =
await handleDirectoryConflict(currentPathInput);
const { finalResolvedPath, finalBaseName } = await setupProjectDirectory(
finalPathInput,
shouldClearDirectory,
);
const { finalResolvedPath, finalBaseName } = await setupProjectDirectory(
finalPathInput,
shouldClearDirectory,
);
const cliInput = {
...input,
projectDirectory: input.projectName,
};
const cliInput = {
...input,
projectDirectory: input.projectName,
const providedFlags = getProvidedFlags(cliInput);
const flagConfig = processAndValidateFlags(
cliInput,
providedFlags,
finalBaseName,
);
const { projectName: _projectNameFromFlags, ...otherFlags } = flagConfig;
if (!input.yes && Object.keys(otherFlags).length > 0) {
log.info(pc.yellow("Using these pre-selected options:"));
log.message(displayConfig(otherFlags));
log.message("");
}
let config: ProjectConfig;
if (input.yes) {
config = {
...DEFAULT_CONFIG,
...flagConfig,
projectName: finalBaseName,
projectDir: finalResolvedPath,
relativePath: finalPathInput,
};
const providedFlags = getProvidedFlags(cliInput);
const flagConfig = processAndValidateFlags(
cliInput,
providedFlags,
finalBaseName,
);
const { projectName: _projectNameFromFlags, ...otherFlags } = flagConfig;
if (!input.yes && Object.keys(otherFlags).length > 0) {
log.info(pc.yellow("Using these pre-selected options:"));
log.message(displayConfig(otherFlags));
log.message("");
if (config.backend === "convex") {
log.info(
"Due to '--backend convex' flag, the following options have been automatically set: auth=false, database=none, orm=none, api=none, runtime=none, dbSetup=none, examples=todo",
);
} else if (config.backend === "none") {
log.info(
"Due to '--backend none', the following options have been automatically set: --auth=false, --database=none, --orm=none, --api=none, --runtime=none, --db-setup=none, --examples=none",
);
}
let config: ProjectConfig;
if (input.yes) {
config = {
...DEFAULT_CONFIG,
...flagConfig,
projectName: finalBaseName,
projectDir: finalResolvedPath,
relativePath: finalPathInput,
};
log.info(pc.yellow("Using default/flag options (config prompts skipped):"));
log.message(displayConfig(config));
log.message("");
} else {
config = await gatherConfig(
flagConfig,
finalBaseName,
finalResolvedPath,
finalPathInput,
);
}
if (config.backend === "convex") {
log.info(
"Due to '--backend convex' flag, the following options have been automatically set: auth=false, database=none, orm=none, api=none, runtime=none, dbSetup=none, examples=todo",
);
} else if (config.backend === "none") {
log.info(
"Due to '--backend none', the following options have been automatically set: --auth=false, --database=none, --orm=none, --api=none, --runtime=none, --db-setup=none, --examples=none",
);
await createProject(config);
const reproducibleCommand = generateReproducibleCommand(config);
log.success(
pc.blue(
`You can reproduce this setup with the following command:\n${reproducibleCommand}`,
),
);
await trackProjectCreation(config);
const elapsedTimeMs = Date.now() - startTime;
const elapsedTimeInSeconds = (elapsedTimeMs / 1000).toFixed(2);
outro(
pc.magenta(
`Project created successfully in ${pc.bold(
elapsedTimeInSeconds,
)} seconds!`,
),
);
return {
success: true,
projectConfig: config,
reproducibleCommand,
timeScaffolded,
elapsedTimeMs,
projectDirectory: config.projectDir,
relativePath: config.relativePath,
};
}
async function handleDirectoryConflictProgrammatically(
currentPathInput: string,
strategy: DirectoryConflict,
): Promise<{ finalPathInput: string; shouldClearDirectory: boolean }> {
const currentPath = path.resolve(process.cwd(), currentPathInput);
if (!fs.pathExistsSync(currentPath)) {
return { finalPathInput: currentPathInput, shouldClearDirectory: false };
}
const dirContents = fs.readdirSync(currentPath);
const isNotEmpty = dirContents.length > 0;
if (!isNotEmpty) {
return { finalPathInput: currentPathInput, shouldClearDirectory: false };
}
switch (strategy) {
case "overwrite":
return { finalPathInput: currentPathInput, shouldClearDirectory: true };
case "merge":
return { finalPathInput: currentPathInput, shouldClearDirectory: false };
case "increment": {
let counter = 1;
const baseName = currentPathInput;
let finalPathInput = `${baseName}-${counter}`;
while (
fs.pathExistsSync(path.resolve(process.cwd(), finalPathInput)) &&
fs.readdirSync(path.resolve(process.cwd(), finalPathInput)).length > 0
) {
counter++;
finalPathInput = `${baseName}-${counter}`;
}
log.info(
pc.yellow("Using default/flag options (config prompts skipped):"),
);
log.message(displayConfig(config));
log.message("");
} else {
config = await gatherConfig(
flagConfig,
finalBaseName,
finalResolvedPath,
finalPathInput,
);
return { finalPathInput, shouldClearDirectory: false };
}
await createProject(config);
case "error":
throw new Error(
`Directory "${currentPathInput}" already exists and is not empty. Use directoryConflict: "overwrite", "merge", or "increment" to handle this.`,
);
const reproducibleCommand = generateReproducibleCommand(config);
log.success(
pc.blue(
`You can reproduce this setup with the following command:\n${reproducibleCommand}`,
),
);
await trackProjectCreation(config);
const elapsedTimeInSeconds = ((Date.now() - startTime) / 1000).toFixed(2);
outro(
pc.magenta(
`Project created successfully in ${pc.bold(
elapsedTimeInSeconds,
)} seconds!`,
),
);
} catch (error) {
handleError(error, "Failed to create project");
default:
throw new Error(`Unknown directory conflict strategy: ${strategy}`);
}
}

View File

@@ -7,17 +7,35 @@ import {
createProjectHandler,
} from "./helpers/project-generation/command-handlers";
import {
type AddInput,
type Addons,
AddonsSchema,
type API,
APISchema,
type Backend,
BackendSchema,
type BetterTStackConfig,
type CreateInput,
type Database,
DatabaseSchema,
type DatabaseSetup,
DatabaseSetupSchema,
type DirectoryConflict,
DirectoryConflictSchema,
type Examples,
ExamplesSchema,
type Frontend,
FrontendSchema,
type InitResult,
type ORM,
ORMSchema,
type PackageManager,
PackageManagerSchema,
type ProjectConfig,
ProjectNameSchema,
type Runtime,
RuntimeSchema,
type WebDeploy,
WebDeploySchema,
} from "./types";
import { handleError } from "./utils/errors";
@@ -28,7 +46,7 @@ import { displaySponsors, fetchSponsors } from "./utils/sponsors";
const t = trpcServer.initTRPC.create();
const router = t.router({
export const router = t.router({
init: t.procedure
.meta({
description: "Create a new Better-T Stack project",
@@ -44,6 +62,18 @@ const router = t.router({
.optional()
.default(false)
.describe("Use default configuration"),
yolo: z
.boolean()
.optional()
.default(false)
.describe(
"(WARNING - NOT RECOMMENDED) Bypass validations and compatibility checks",
),
verbose: z
.boolean()
.optional()
.default(false)
.describe("Show detailed result information"),
database: DatabaseSchema.optional(),
orm: ORMSchema.optional(),
auth: z.boolean().optional(),
@@ -58,6 +88,8 @@ const router = t.router({
runtime: RuntimeSchema.optional(),
api: APISchema.optional(),
webDeploy: WebDeploySchema.optional(),
directoryConflict: DirectoryConflictSchema.optional(),
renderTitle: z.boolean().optional(),
}),
]),
)
@@ -67,7 +99,11 @@ const router = t.router({
projectName,
...options,
};
await createProjectHandler(combinedInput);
const result = await createProjectHandler(combinedInput);
if (options.verbose) {
return result;
}
}),
add: t.procedure
.meta({
@@ -129,8 +165,90 @@ const router = t.router({
}),
});
createCli({
router,
name: "create-better-t-stack",
version: getLatestCLIVersion(),
}).run();
const caller = t.createCallerFactory(router)({});
export function createBtsCli() {
return createCli({
router,
name: "create-better-t-stack",
version: getLatestCLIVersion(),
});
}
/**
* Initialize a new Better-T Stack project
*
* @example CLI usage:
* ```bash
* npx create-better-t-stack my-app --yes
* ```
*
* @example Programmatic usage (always returns structured data):
* ```typescript
* import { init } from "create-better-t-stack";
*
* const result = await init("my-app", {
* yes: true,
* frontend: ["tanstack-router"],
* backend: "hono",
* database: "sqlite",
* orm: "drizzle",
* auth: true,
* addons: ["biome", "turborepo"],
* packageManager: "bun",
* install: false,
* directoryConflict: "increment", // auto-handle conflicts
* });
*
* if (result.success) {
* console.log(`Project created at: ${result.projectDirectory}`);
* console.log(`Reproducible command: ${result.reproducibleCommand}`);
* console.log(`Time taken: ${result.elapsedTimeMs}ms`);
* }
* ```
*/
export async function init(
projectName?: string,
options?: CreateInput,
): Promise<InitResult> {
const opts = (options ?? {}) as CreateInput;
const programmaticOpts = { ...opts, verbose: true };
const prev = process.env.BTS_PROGRAMMATIC;
process.env.BTS_PROGRAMMATIC = "1";
const result = await caller.init([projectName, programmaticOpts]);
if (prev === undefined) delete process.env.BTS_PROGRAMMATIC;
else process.env.BTS_PROGRAMMATIC = prev;
return result as InitResult;
}
export async function sponsors() {
return caller.sponsors();
}
export async function docs() {
return caller.docs();
}
export async function builder() {
return caller.builder();
}
export type {
Database,
ORM,
Backend,
Runtime,
Frontend,
Addons,
Examples,
PackageManager,
DatabaseSetup,
API,
WebDeploy,
DirectoryConflict,
CreateInput,
AddInput,
ProjectConfig,
BetterTStackConfig,
InitResult,
};

View File

@@ -108,9 +108,16 @@ export const WebDeploySchema = z
.describe("Web deployment");
export type WebDeploy = z.infer<typeof WebDeploySchema>;
export const DirectoryConflictSchema = z
.enum(["merge", "overwrite", "increment", "error"])
.describe("How to handle existing directory conflicts");
export type DirectoryConflict = z.infer<typeof DirectoryConflictSchema>;
export type CreateInput = {
projectName?: string;
yes?: boolean;
yolo?: boolean;
verbose?: boolean;
database?: Database;
orm?: ORM;
auth?: boolean;
@@ -125,6 +132,8 @@ export type CreateInput = {
runtime?: Runtime;
api?: API;
webDeploy?: WebDeploy;
directoryConflict?: DirectoryConflict;
renderTitle?: boolean;
};
export type AddInput = {
@@ -175,3 +184,14 @@ export interface BetterTStackConfig {
api: API;
webDeploy: WebDeploy;
}
export interface InitResult {
success: boolean;
projectConfig: ProjectConfig;
reproducibleCommand: string;
timeScaffolded: string;
elapsedTimeMs: number;
projectDirectory: string;
relativePath: string;
error?: string;
}

View File

@@ -2,13 +2,23 @@ import { cancel } from "@clack/prompts";
import consola from "consola";
import pc from "picocolors";
function isProgrammatic(): boolean {
return process.env.BTS_PROGRAMMATIC === "1";
}
export function exitWithError(message: string): never {
consola.error(pc.red(message));
if (isProgrammatic()) {
throw new Error(message);
}
process.exit(1);
}
export function exitCancelled(message = "Operation cancelled"): never {
cancel(pc.red(message));
if (isProgrammatic()) {
throw new Error(message);
}
process.exit(0);
}
@@ -16,5 +26,8 @@ export function handleError(error: unknown, fallbackMessage?: string): never {
const message =
error instanceof Error ? error.message : fallbackMessage || String(error);
consola.error(pc.red(message));
if (isProgrammatic()) {
throw new Error(message);
}
process.exit(1);
}

View File

@@ -7,6 +7,7 @@ import { exitCancelled, handleError } from "./errors";
export async function handleDirectoryConflict(
currentPathInput: string,
silent = false,
): Promise<{
finalPathInput: string;
shouldClearDirectory: boolean;
@@ -20,6 +21,12 @@ export async function handleDirectoryConflict(
return { finalPathInput: currentPathInput, shouldClearDirectory: false };
}
if (silent) {
throw new Error(
`Directory "${currentPathInput}" already exists and is not empty. In silent mode, please provide a different project name or clear the directory manually.`,
);
}
log.warn(
`Directory "${pc.yellow(
currentPathInput,

View File

@@ -1,13 +1,10 @@
import path from "node:path";
import {
type Addons,
type API,
type Backend,
type CLIInput,
type Database,
type DatabaseSetup,
type Examples,
type Frontend,
type ORM,
type PackageManager,
type ProjectConfig,
@@ -28,6 +25,36 @@ import {
} from "./utils/compatibility-rules";
import { exitWithError } from "./utils/errors";
function processArrayOption<T>(options: (T | "none")[] | undefined): T[] {
if (!options || options.length === 0) return [];
if (options.includes("none" as T | "none")) return [];
return options.filter((item): item is T => item !== "none");
}
function deriveProjectName(
projectName?: string,
projectDirectory?: string,
): string {
if (projectName) {
return projectName;
}
if (projectDirectory) {
return path.basename(path.resolve(process.cwd(), projectDirectory));
}
return "";
}
function validateProjectName(name: string): void {
const result = ProjectNameSchema.safeParse(name);
if (!result.success) {
exitWithError(
`Invalid project name: ${
result.error.issues[0]?.message || "Invalid project name"
}`,
);
}
}
export function processAndValidateFlags(
options: CLIInput,
providedFlags: Set<string>,
@@ -96,29 +123,13 @@ export function processAndValidateFlags(
config.webDeploy = options.webDeploy as WebDeploy;
}
if (projectName) {
const result = ProjectNameSchema.safeParse(path.basename(projectName));
if (!result.success) {
exitWithError(
`Invalid project name: ${
result.error.issues[0]?.message || "Invalid project name"
}`,
);
}
config.projectName = projectName;
} else if (options.projectDirectory) {
const baseName = path.basename(
path.resolve(process.cwd(), options.projectDirectory),
);
const result = ProjectNameSchema.safeParse(baseName);
if (!result.success) {
exitWithError(
`Invalid project name: ${
result.error.issues[0]?.message || "Invalid project name"
}`,
);
}
config.projectName = baseName;
const derivedName = deriveProjectName(projectName, options.projectDirectory);
if (derivedName) {
const nameToValidate = projectName
? path.basename(projectName)
: derivedName;
validateProjectName(nameToValidate);
config.projectName = projectName || derivedName;
}
if (options.frontend && options.frontend.length > 0) {
@@ -128,9 +139,7 @@ export function processAndValidateFlags(
}
config.frontend = [];
} else {
const validOptions = options.frontend.filter(
(f): f is Frontend => f !== "none",
);
const validOptions = processArrayOption(options.frontend);
ensureSingleWebAndNative(validOptions);
config.frontend = validOptions;
}
@@ -152,9 +161,7 @@ export function processAndValidateFlags(
}
config.addons = [];
} else {
config.addons = options.addons.filter(
(addon): addon is Addons => addon !== "none",
);
config.addons = processArrayOption(options.addons);
}
}
if (options.examples && options.examples.length > 0) {
@@ -164,9 +171,7 @@ export function processAndValidateFlags(
}
config.examples = [];
} else {
config.examples = options.examples.filter(
(ex): ex is Examples => ex !== "none",
);
config.examples = processArrayOption(options.examples);
if (options.examples.includes("none") && config.backend !== "convex") {
config.examples = [];
}
@@ -421,6 +426,85 @@ export function validateConfigCompatibility(config: Partial<ProjectConfig>) {
);
}
export function processProvidedFlagsWithoutValidation(
options: CLIInput,
projectName?: string,
): Partial<ProjectConfig> {
const config: Partial<ProjectConfig> = {};
if (options.api) {
config.api = options.api as API;
}
if (options.backend) {
config.backend = options.backend as Backend;
}
if (options.database) {
config.database = options.database as Database;
}
if (options.orm) {
config.orm = options.orm as ORM;
}
if (options.auth !== undefined) {
config.auth = options.auth;
}
if (options.git !== undefined) {
config.git = options.git;
}
if (options.install !== undefined) {
config.install = options.install;
}
if (options.runtime) {
config.runtime = options.runtime as Runtime;
}
if (options.dbSetup) {
config.dbSetup = options.dbSetup as DatabaseSetup;
}
if (options.packageManager) {
config.packageManager = options.packageManager as PackageManager;
}
if (options.webDeploy) {
config.webDeploy = options.webDeploy as WebDeploy;
}
const derivedName = deriveProjectName(projectName, options.projectDirectory);
if (derivedName) {
const nameToValidate = projectName
? path.basename(projectName)
: derivedName;
const result = ProjectNameSchema.safeParse(nameToValidate);
if (!result.success) {
throw new Error(
`Invalid project name: ${result.error.issues[0]?.message}`,
);
}
config.projectName = projectName || derivedName;
}
if (options.frontend && options.frontend.length > 0) {
config.frontend = processArrayOption(options.frontend);
}
if (options.addons && options.addons.length > 0) {
config.addons = processArrayOption(options.addons);
}
if (options.examples && options.examples.length > 0) {
config.examples = processArrayOption(options.examples);
}
return config;
}
export function getProvidedFlags(options: CLIInput): Set<string> {
return new Set(
Object.keys(options).filter(

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);
});
});

View File

@@ -1,11 +1,12 @@
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts"],
entry: ["src/index.ts", "src/cli.ts"],
format: ["esm"],
clean: true,
shims: true,
outDir: "dist",
dts: true,
outputOptions: {
banner: "#!/usr/bin/env node",
},