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:
@@ -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
3
apps/cli/src/cli.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createBtsCli } from "./index";
|
||||
|
||||
createBtsCli().run();
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user