import path from "node:path"; import * as p from "@clack/prompts"; import { execa } from "execa"; import fse from "fs-extra"; import c from "picocolors"; import { exitPrompt, getModulePaths, isPathname, normalizePath, wait, } from "../utils/index.js"; //const runnerName = "basic"; /** @param {Context} ctx */ export async function createProject(ctx) { let { args, dryRun, initGitRepo, installDeps, template } = ctx; const s = p.spinner(); let cwd = process.cwd(); // 1. Set up project directory const project = await getProjectDetails(args[0], { cwd }); if (dryRun) { await wait(1); } else { await fse.ensureDir(project.pathname); } // 2. Create the damned thing cwd = project.pathname; const relativePath = path.relative(process.cwd(), project.pathname); s.start( `${c.yellow(`Creating a new Astro-GhostCMS project in ${relativePath}`)}`, ); if (dryRun) { await wait(2000); } else { await createApp(project.name, project.pathname, template, { onError(error) { s.stop(`${c.red("Failed to create new project")}`); p.cancel(); console.error(error); process.exit(1); }, }); } s.stop( `${c.green("New Astro-GhostCMS project")} '${project.name}' ${c.green( "created", )} 🚀`, ); const fCheck = await p.group( { installDeps: () => p.confirm({ message: `${c.cyan("Install dependencies? (Recommended)")}`, initialValue: true, }), //GitRepo: () => p.confirm({ // message: `${c.cyan('Initialize a Git repository?')} ${c.italic(c.gray("( Tip: This Option gets Stuck Press Enter Twice if you get no reponse! )"))}`, // initialValue: false, //}), readyCheck: () => p.confirm({ message: `${c.bgYellow( c.black( c.bold( " CONFIRM: Press Enter Twice to continue or `Ctrl+C` to Cancel. ", ), ), )}`, initialValue: true, }), }, { onCancel: () => { exitPrompt(); }, }, ); if (fCheck.readyCheck) { // 3. Initialize git repo if (initGitRepo) { if (dryRun) { await wait(1); } else { await exec("git", ["init"], { cwd }); } p.log.success(c.green("Initialized Git repository")); } else { p.log.info(`${c.gray("Skipped Git initialization")}`); } const nextSteps = `If you didnt opt to install Dependencies dont forget to run: \n ${c.yellow( "npm install", )} / ${c.yellow("pnpm install")} / ${c.yellow( "yarn install", )} inside your project directory! \n \n ${c.bgYellow( c.black( c.bold( " Dont forget to modify your .env file for YOUR ghost install! ", ), ), )} `; // 4. Install dependencies installDeps = installDeps ?? fCheck.installDeps; const pm = ctx.pkgManager ?? "pnpm"; if (installDeps) { s.start(`${c.cyan(`Installing dependencies with ${pm}`)} `); if (dryRun) { await wait(1); } else { await installDependencies(pm, { cwd }); } s.stop(`${c.green(`Dependencies installed with ${pm}`)}`); success(); } else { p.log.info(`${c.gray("Skipped dependency installation")}`); success(); } async function success() { p.note(nextSteps); p.outro(c.green("Deployment Complete!")); } } else { exitPrompt(); } } /** * * @param {string} projectName * @param {string} projectPathname * @param {string} template * @param {{ onError: (err: unknown) => any }} opts */ async function createApp(projectName, projectPathname, template, { onError }) { const { pathname } = getModulePaths(import.meta.url); const templatesDir = path.resolve(pathname, "..", "..", "templates"); const sharedTemplateDir = path.join(templatesDir, "_shared"); const runnerTemplateDir = path.join(templatesDir, template); await fse.ensureDir(projectPathname); // TODO: Detect if project directory is empty, otherwise we // can't create a new project here. await fse.copy(runnerTemplateDir, projectPathname); // Copy misc files from shared const filesToCopy = [ { src: path.join(sharedTemplateDir, ".env"), dest: path.join(projectPathname, ".env"), }, ]; await Promise.all( filesToCopy.map(async ({ src, dest }) => await fse.copy(src, dest)), ); /** @type {Array<{ pathname: string; getUpdates: (contents: string) => string }>} */ const filesToUpdate = [ { pathname: path.join(projectPathname, "package.json"), getUpdates: updateProjectName, }, ]; await Promise.all( filesToUpdate.map(async ({ pathname, getUpdates }) => { const contents = await fse.readFile(pathname, "utf-8"); const updatedContents = getUpdates(contents); await fse.writeFile(pathname, updatedContents, "utf-8"); }), ); /** @param {string} contents */ function updateProjectName(contents) { return contents.replace(/{{PROJECT_NAME}}/g, projectName); } } /** * @param {string|undefined} projectNameInput * @param {{ cwd: string }} opts */ async function getProjectDetails(projectNameInput, opts) { let projectName = projectNameInput; if (!projectName) { const defaultProjectName = "my-astro-ghost"; let answer = await p.text({ message: `${c.cyan("Where would you like to create your project?")}`, placeholder: `.${path.sep}${defaultProjectName}`, }); if (p.isCancel(answer)) exitPrompt(); answer = answer?.trim(); projectName = answer || defaultProjectName; } /** @type {string} */ let pathname; if (isPathname(projectName)) { const dir = path.resolve( opts.cwd, path.dirname(normalizePath(projectName)), ); projectName = toValidProjectName(path.basename(projectName)); pathname = path.join(dir, projectName); } else { projectName = toValidProjectName(projectName); pathname = path.resolve(opts.cwd, projectName); } return { name: projectName, pathname, }; } /** * @param {string} command * @param {readonly string[]} args * @param {{ cwd: string }} options * @returns {Promise<{ code: number | null; signal: NodeJS.Signals | null }>} */ async function exec(command, args, options) { const installExec = execa(command, ["init"], { ...options, stdio: "ignore" }); return new Promise((resolve, reject) => { installExec.on("error", (error) => reject(error)); installExec.on("close", (code, signal) => resolve({ code, signal })); }); } /** * @param {"npm" | "yarn" | "pnpm"} packageManager * @param {{ cwd: string }} opts * @returns */ async function installDependencies(packageManager, { cwd }) { const installExec = execa(packageManager, ["install"], { cwd }); return new Promise((resolve, reject) => { installExec.on("error", (error) => reject(error)); installExec.on("close", () => resolve()); }); } /** * @param {string} projectName */ function toValidProjectName(projectName) { if (isValidProjectName(projectName)) { return projectName; } return projectName .trim() .toLowerCase() .replace(/\s+/g, "-") .replace(/^[._]/, "") .replace(/[^a-z\d\-~]+/g, "-") .replace(/^-+/, "") .replace(/-+$/, ""); } /** * @param {string} projectName */ function isValidProjectName(projectName) { return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test( projectName, ); } /** * @typedef {import("../../types.js").Template} Template * @typedef {import("../../types.js").PackageManager} PackageManager * @typedef {import("../../types.js").Context} Context * @typedef {import("../../types.js").Serializable} Serializable */