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

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