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:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user