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

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(