UNPKG

create-links-base

Version:

CLI tool to create a new Links Base project

472 lines (459 loc) 14.2 kB
#!/usr/bin/env node // src/index.ts import { Command } from "commander"; // src/commands/init.ts import boxen2 from "boxen"; import chalk2 from "chalk"; import fs3 from "fs/promises"; import ora from "ora"; import prompts from "prompts"; import { fileURLToPath as fileURLToPath2 } from "url"; // src/utils/banner.ts import boxen from "boxen"; import figlet from "figlet"; import gradient from "gradient-string"; async function showBanner() { const gradientColors = gradient([ { color: "#00ff00", pos: 0 }, { color: "#00ffff", pos: 0.5 }, { color: "#ff00ff", pos: 1 } ]); const text = await new Promise((resolve) => { figlet( "Links Base", { font: "Standard", horizontalLayout: "default", verticalLayout: "default" }, (err, data) => { if (err) { resolve("Links Base"); return; } resolve(data || "Links Base"); } ); }); const gradientText = gradientColors(text); const info = boxen( "Create a new Links Base project\nA modern, static link management application", { padding: 1, margin: 1, borderStyle: "round", borderColor: "cyan" } ); console.log(gradientText); console.log(info); } // src/utils/copy.ts import { exec } from "child_process"; import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; import { promisify } from "util"; // src/constants/packages.ts var CORE_PACKAGES = [ "@links-base/ui", "@links-base/tsconfig", "@links-base/tailwind-config" ]; var PACKAGE_NAME_MAP = { tsconfig: "tsconfig", ui: "ui", "tailwind-config": "tailwind" }; // src/utils/copy.ts var execAsync = promisify(exec); var __dirname = path.dirname(fileURLToPath(import.meta.url)); var isDevelopment = () => process.env.NODE_ENV === "development"; var getProjectRoot = () => { return path.join(__dirname, "../../.."); }; async function copyFiles(targetDir) { if (isDevelopment()) { await copyLocalFiles(targetDir); } else { await downloadRemoteFiles(targetDir); } } async function copyLocalFiles(targetDir) { const projectRoot = getProjectRoot(); const appDir = path.join(projectRoot, "apps/app"); const excludeDirs = [ "node_modules", ".next", "out", ".turbo", "coverage", ".git", "dist", ".cache", ".vercel", ".env*" ]; await copyDir(appDir, targetDir, excludeDirs); const packagesDir = path.join(projectRoot, "packages"); await copyPackages(packagesDir, targetDir); await copyRootFiles(projectRoot, targetDir); } async function copyRootFiles(projectRoot, targetDir) { const rootFiles = [ ".gitignore", ".editorconfig", ".npmrc", ".nvmrc", "LICENCE" ]; for (const file of rootFiles) { const srcPath = path.join(projectRoot, file); const destPath = path.join(targetDir, file); try { await fs.copyFile(srcPath, destPath); } catch (error) { if (error instanceof Error) { console.warn(`Warning: Could not copy ${file}: ${error.message}`); } else { console.warn(`Warning: Could not copy ${file}: Unknown error occurred`); } } } } async function downloadRemoteFiles(targetDir) { await execAsync(`npx degit thedaviddias/links-base/apps/app ${targetDir}`); const packagesDir = path.join(targetDir, "packages"); await fs.mkdir(packagesDir, { recursive: true }); for (const pkg of CORE_PACKAGES) { const pkgName = pkg.split("/")[1]; await execAsync( `npx degit thedaviddias/links-base/packages/${pkgName} ${packagesDir}/${pkgName}` ); } } async function copyPackages(packagesDir, targetDir) { for (const pkg of CORE_PACKAGES) { const pkgName = pkg.split("/")[1]; const folderName = PACKAGE_NAME_MAP[pkgName] || pkgName; const srcDir = path.join(packagesDir, folderName); const destDir = path.join(targetDir, "packages", pkgName); await fs.mkdir(destDir, { recursive: true }); await copyPackageJson(srcDir, destDir); await copyDir(srcDir, destDir, [ "node_modules", ".turbo", "dist", "coverage", ".git", ".cache", ".env*", ".vercel", "codecov.yml" ]); } } async function copyDir(src, dest, excludeDirs = []) { const entries = await fs.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (excludeDirs.includes(entry.name)) continue; if (entry.isDirectory()) { await fs.mkdir(destPath, { recursive: true }); await copyDir(srcPath, destPath, excludeDirs); } else { await fs.copyFile(srcPath, destPath); } } } async function copyPackageJson(srcDir, destDir) { const srcPackageJson = path.join(srcDir, "package.json"); const destPackageJson = path.join(destDir, "package.json"); const pkgContent = JSON.parse(await fs.readFile(srcPackageJson, "utf8")); updateWorkspaceDependencies(pkgContent); await fs.writeFile(destPackageJson, JSON.stringify(pkgContent, null, 2)); } function updateWorkspaceDependencies(pkgContent) { ; ["dependencies", "devDependencies"].forEach((depType) => { const deps = pkgContent[depType]; if (deps) { Object.keys(deps).forEach((dep) => { if (dep.startsWith("@links-base/")) { deps[dep] = "workspace:*"; } }); } }); } // src/utils/dependencies.ts import { exec as execCallback } from "child_process"; import { promisify as promisify2 } from "util"; var exec2 = promisify2(execCallback); async function installDependencies(targetDir) { await exec2("pnpm install", { cwd: targetDir }); } // src/utils/logger.ts import chalk from "chalk"; var logger = { info: (...args) => console.log(chalk.blue(...args)), success: (...args) => console.log(chalk.green(...args)), warning: (...args) => console.log(chalk.yellow(...args)), error: (...args) => console.log(chalk.red(...args)), subtle: (...args) => console.log(chalk.gray(...args)) }; // src/utils/package-json.ts import fs2 from "fs/promises"; import path2 from "path"; async function updatePackageJson(targetDir) { const pkgPath = path2.join(targetDir, "package.json"); const pkg = JSON.parse(await fs2.readFile(pkgPath, "utf8")); removeUnwantedDependencies(pkg); updateWorkspaceReferences(pkg); await writePackageFiles(targetDir, pkg); } function removeUnwantedDependencies(pkg) { delete pkg.dependencies["@links-base/web"]; delete pkg.dependencies["@links-base/e2e"]; delete pkg.dependencies["@links-base/keystatic"]; delete pkg.workspaces; } function updateWorkspaceReferences(pkg) { for (const pkgName of CORE_PACKAGES) { if (pkg.dependencies?.[pkgName]) { pkg.dependencies[pkgName] = "workspace:*"; } } } async function writePackageFiles(targetDir, pkg) { const pkgPath = path2.join(targetDir, "package.json"); await fs2.writeFile(pkgPath, JSON.stringify(pkg, null, 2)); const workspaceConfig = `packages: - 'packages/*' `; await fs2.writeFile( path2.join(targetDir, "pnpm-workspace.yaml"), workspaceConfig ); } // src/commands/init.ts async function init(projectName, options = {}) { await showBanner(); const spinner = ora({ spinner: "dots", color: "cyan" }); try { const targetDir = await getProjectName(projectName); if (options.dryRun) { await handleDryRun(targetDir, spinner); return; } const steps = [ { text: "Creating project directory", action: async () => { await fs3.mkdir(targetDir, { recursive: true }); } }, { text: "Copying project files", action: async () => { await copyFiles(targetDir); await updatePackageJson(targetDir); } }, { text: "Installing dependencies", action: async () => { await installDependencies(targetDir); } } ]; for (const step of steps) { spinner.start(step.text); await step.action(); spinner.succeed(); } logger.success( boxen2(`\u{1F389} Successfully created Links Base app at ${targetDir}!`, { padding: 1, margin: 1, borderStyle: "round", borderColor: "green" }) ); logger.info("\n\u{1F4E6} Available commands:\n"); const commands = [ ["pnpm dev", "Start the development server"], ["pnpm build", "Build for production"], ["pnpm start", "Run production build"] ]; commands.forEach(([command, description]) => { logger.info(` ${chalk2.cyan(command)}`); logger.subtle(` ${description} `); }); logger.info("\n\u{1F680} Get started with:\n"); logger.info(` ${chalk2.cyan(`cd ${targetDir}`)}`); logger.info(` ${chalk2.cyan("pnpm dev")} `); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error occurred"; spinner.fail(`Failed to create project: ${errorMessage}`); process.exit(1); } } async function getProjectName(projectName) { if (projectName) return projectName; const response = await prompts({ type: "text", name: "projectName", message: "What is your project named?", initial: "my-links-app" }); if (!response.projectName) { logger.error("Project name is required"); process.exit(1); } return response.projectName; } async function handleDryRun(targetDir, spinner) { await Promise.resolve(); try { spinner.start("Simulating project creation..."); spinner.info(`Would create directory: ${targetDir}`); spinner.info(`Would copy app files from apps/app to ${targetDir}`); spinner.info(`Would copy packages to ${targetDir}/packages`); spinner.info("Would update package.json"); spinner.info("Would install dependencies using pnpm"); spinner.succeed("Dry run completed - no changes made"); } catch (err) { const error = err; spinner.fail(`Dry run failed: ${error.message}`); throw error; } } if (import.meta.url === fileURLToPath2(import.meta.url)) { init().catch((error) => { console.error("Failed to initialize project:", error); process.exit(1); }); } // src/commands/update.ts import { exec as execCallback2 } from "child_process"; import fs4 from "fs/promises"; import ora2 from "ora"; import path3 from "path"; import { promisify as promisify3 } from "util"; var exec3 = promisify3(execCallback2); async function checkIsLinksBaseProject() { try { const pkg = JSON.parse( await fs4.readFile(path3.join(process.cwd(), "package.json"), "utf8") ); return pkg.dependencies?.["@links-base/ui"] !== void 0; } catch { return false; } } async function getCurrentVersion() { const pkg = JSON.parse( await fs4.readFile(path3.join(process.cwd(), "package.json"), "utf8") ); return pkg.version || "0.0.0"; } async function getLatestVersion() { const { stdout } = await exec3("npm view @links-base/ui version"); return stdout.trim(); } async function hasLocalChanges() { try { const { stdout } = await exec3("git status --porcelain"); return stdout.trim().length > 0; } catch { return false; } } async function performUpdate() { const targetDir = process.cwd(); await backupUserConfigs(); await downloadRemoteFiles(targetDir); await exec3("pnpm install"); await restoreUserConfigs(); } async function backupUserConfigs() { const filesToBackup = [ ".env", ".env.local", "next.config.js", "tailwind.config.js" ]; for (const file of filesToBackup) { try { await fs4.copyFile( path3.join(process.cwd(), file), path3.join(process.cwd(), `${file}.backup`) ); } catch { } } } async function restoreUserConfigs() { const backupFiles = await fs4.readdir(process.cwd()); for (const file of backupFiles) { if (file.endsWith(".backup")) { const originalFile = file.replace(".backup", ""); await fs4.rename( path3.join(process.cwd(), file), path3.join(process.cwd(), originalFile) ); } } } async function update(options = {}) { await showBanner(); const spinner = ora2({ spinner: "dots", color: "cyan" }); try { const isLinksBaseProject = await checkIsLinksBaseProject(); if (!isLinksBaseProject) { logger.error("Not in a Links Base project directory"); process.exit(1); } spinner.start("Checking for updates..."); const currentVersion = await getCurrentVersion(); const latestVersion = await getLatestVersion(); if (currentVersion === latestVersion) { spinner.succeed("You're already using the latest version!"); return; } if (options.dryRun) { spinner.info(`Would update from ${currentVersion} to ${latestVersion}`); return; } if (!options.force && await hasLocalChanges()) { spinner.fail("Local changes detected. Use --force to override"); process.exit(1); } spinner.start(`Updating to version ${latestVersion}...`); await performUpdate(); spinner.succeed(`Successfully updated to version ${latestVersion}`); logger.info("\n\u{1F389} Update complete!"); logger.info("\nPlease review any changes and test your application."); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error occurred"; spinner.fail(`Update failed: ${errorMessage}`); process.exit(1); } } // src/index.ts var program = new Command(); program.name("links-base").description("CLI tool to create and manage Links Base projects").version("1.0.0"); program.command("init").description("Create a new Links Base project").argument("[name]", "Project name").option("--dry-run", "Show what would be done without making changes", false).action(async (name, options) => { await init(name, options); }); program.command("update").description("Update Links Base to the latest version").option("--dry-run", "Show what would be done without making changes", false).option("--force", "Force update even if there are local changes", false).action(async (options) => { await update(options); }); program.parse();