UNPKG

dotcn

Version:

Simple CLI tool to install shadcn compatible components from various ui-libraries such as magic-ui, aceternity-ui or hexta-ui etc..

412 lines (398 loc) 14.4 kB
import { Command } from "commander"; import fs from "node:fs"; import path from "node:path"; import { confirm } from "@inquirer/prompts"; import { execa } from "execa"; import { z } from "zod/v4"; import { blueBright, bold, greenBright, italic, redBright, underline, yellowBright } from "yoctocolors"; //#region package.json var name = "dotcn"; var version = "0.0.12-alpha"; var description = "Simple CLI tool to install shadcn compatible components from various ui-libraries such as magic-ui, aceternity-ui or hexta-ui etc.."; var type = "module"; var publishConfig = { "access": "public" }; var files = ["dist"]; var main = "./dist/index.js"; var module = "./dist/index.js"; var types = "./dist/index.d.ts"; var exports = { ".": "./dist/index.js" }; var bin = { "dotcn": "dist/index.js" }; var homepage = "https://github.com/atybdot/dotcn"; var author = { "name": "@atybdot", "url": "https://x.com/atybdot" }; var repository = { "type": "git", "url": "https://github.com/atybdot/dotcn.git" }; var keywords = [ "shadcn", "ui", "manager", "nodejs", "cli", "components", "radix-ui", "magic-ui", "aceternity-ui", "libraries", "shadcn-ui", "react" ]; var license = "MIT"; var bugs = { "url": "https://github.com/atybdot/dotcn/issues/new/choose" }; var scripts = { "build": "tsdown", "dev": "tsdown --watch", "lint": "biome lint --write", "format": "biome format --write", "check": "biome check --write" }; var devDependencies = { "@biomejs/biome": "2.1.1", "@types/node": "^24.0.13", "husky": "^9.1.7", "tsdown": "latest", "ultracite": "5.0.36" }; var dependencies = { "@inquirer/prompts": "^7.6.0", "commander": "^14.0.0", "execa": "^9.6.0", "motion": "^12.23.5", "yoctocolors": "^2.1.1", "zod": "^4.0.5" }; var package_default = { name, version, description, type, publishConfig, files, main, module, types, exports, bin, homepage, author, repository, keywords, license, bugs, scripts, devDependencies, dependencies }; //#endregion //#region src/constants.ts const FILE_NAME = "registries.json"; //#endregion //#region src/schema/index.ts const baseSchema = z.object({ name: z.string(), url: z.url() }); const registriesSchema = z.object({ default: baseSchema, registries: z.array(baseSchema) }); //#endregion //#region src/utils/highlighter.ts const highlighter = { success: (...s) => greenBright(s.join(" ")), error: (...s) => redBright(s.join(" ")), warn: (...s) => yellowBright(s.join(" ")), info: (...s) => blueBright(s.join(" ")), bold: (...s) => bold(s.join(" ")), italic: (...s) => italic(s.join(" ")), underline: (...s) => underline(s.join(" ")) }; const logger = { error: (...s) => console.log(redBright(highlighter.bold(s.join(" ")))), warn: (...s) => console.log(yellowBright(highlighter.bold(s.join(" ")))), info: (...s) => console.log(blueBright(highlighter.bold(s.join(" ")))), success: (...s) => console.log(greenBright(highlighter.bold(s.join(" ")))), log: (...s) => console.log(highlighter.bold(s.join(" "))) }; //#endregion //#region src/utils/index.ts function checkFileExists(absoluteFilePath, silent = false) { const absPath = path.join(absoluteFilePath); const fileExists = fs.existsSync(absPath); if (!fileExists && silent) logger.error(`unable to find ${highlighter.bold(absoluteFilePath)}`); return fileExists; } function readRegistry(cwd) { const absPath = path.resolve(cwd ? cwd : process.cwd(), FILE_NAME); try { const registryFile = fs.readFileSync(absPath, "utf-8"); const jsonValues = JSON.parse(registryFile); const safeValues = registriesSchema.parse(jsonValues); return safeValues; } catch (error) { if (error instanceof z.ZodError) logger.error("❌", FILE_NAME, "is not in correct format, please make sure that it has correct schema"); else if (error instanceof Error) { logger.error(`❌ unable to read registry file\nEnsure that ${highlighter.underline(highlighter.warn(FILE_NAME))} is present along with components.json`); logger.info(`Run ${highlighter.underline(highlighter.warn("npx dotcn@latest init"))}\nBefore running this command`); logger.error(error?.message); } process.exit(1); } } async function returnUrl(key, useDefault) { const values = readRegistry(); const res = values.registries?.find((item) => item.name === key); if (res) return res?.url; logger.warn(`${key} not found in ${FILE_NAME}\n`); if (useDefault) return values.default.url; const confirmDefault = await confirm({ message: "Do you want to use default registry?: " }); if (!confirmDefault) { logger.error(`❌ ${highlighter.underline(key)} does not exsits in ${highlighter.underline(FILE_NAME)}`); process.exit(1); } return values.default.url; } function runCommand(command, args, option) { try { const res = execa(command, args, { stderr: option?.stderr ?? "inherit", stdin: option?.stdin ?? "inherit", stdout: option?.stdout ?? "inherit", reject: true, ...option }); return res; } catch (error) { logger.error("unable to run command\n", error?.message); process.exit(1); } } async function runShadcn(components) { const command = ["pnpm", [ "dlx", "shadcn@latest", "add", ...components ].join(" ")].join(" "); try { logger.info(command); await runCommand("pnpm", [ "dlx", "shadcn@latest", "add", ...components ]); } catch (_error) { logger.error("unable to run\n", highlighter.bold(command)); } } function writeToFile(absoluteFilePath, data, isJson = false, silent = true) { try { if (isJson) fs.writeFileSync(absoluteFilePath, JSON.stringify(data, null, 2)); else fs.writeFileSync(absoluteFilePath, data); !silent && logger.success(`successfully created ${highlighter.bold(absoluteFilePath)}`); return true; } catch (error) { logger.error(`failed to create ${highlighter.bold(absoluteFilePath)}`); logger.info(error?.message); return false; } } //#endregion //#region src/commands/add.ts const add = new Command(); add.name("add").description("add a component to your project, equivalent to shadcn@latest add component").argument("[components...]", "names, url or local path to component").option("-d, --default", "use default registry if the").action(async (args, opts) => { const baseUrl = await returnUrl(args[0], opts.default); const componentsArray = args.slice(1); const components = componentsArray.map((item) => `${baseUrl + item}.json`); await runShadcn(components); }); //#endregion //#region src/utils/preflight.ts async function runPreflight({ basePath }) { const checkComponentsFile = checkFileExists(`${basePath}/components.json`); if (!checkComponentsFile) { logger.error(`please run ${highlighter.warn("npx shadcn@latest init")} to create it.`); const createComponents = await confirm({ message: `Do you want to run ${highlighter.warn("npx shadcn@latest init")} ?: ` }); if (!createComponents) { logger.info(highlighter.italic("cannot continue without components.json\ncreate it before running this command")); return false; } try { await runCommand("pnpm", [ "dlx", "shadcn@latest", "init" ]); return true; } catch (error) { logger.error("unable to create components.json"); logger.info(highlighter.italic("cannot continue without components.json\ncreate it before running this command")); logger.error(error?.message); return false; } } return true; } //#endregion //#region src/commands/init.ts const initCmd = new Command("init"); initCmd.description("initialize a dotcn project").option("-c, --cwd <cwd>", "the working directory. defaults to the current directory.", process.cwd()).action(async (opts) => { const basePath = path.resolve(opts.cwd); const registryPath = path.resolve(basePath, FILE_NAME); const passPreflight = await runPreflight({ basePath }); if (!passPreflight) { logger.error("program aborted!!!"); process.exit(1); } await runInit({ cwd: registryPath, assure: true, changeDefault: false }); }); async function runInit({ assure = false, changeDefault = false,...props }) { const checkRegistry = checkFileExists(props.cwd); const defaultValues = { name: "shadcn", url: "https://ui.shadcn.com/r/styles/new-york-v4/" }; if (checkRegistry) { logger.success(`${highlighter.underline(highlighter.info(FILE_NAME))} already present.`); logger.log(`run ${highlighter.warn(highlighter.underline("npx dotcn add button"))}`); return; } if (assure) { const reassure = await confirm({ message: `Do you want to create ${highlighter.info(FILE_NAME)}?` }); if (!reassure) { logger.error(`Cannot continue without creating ${highlighter.underline(highlighter.warn(FILE_NAME))}`, "\n❌ Program aborted"); process.exit(1); } } const defaultReg = changeDefault && props?.data ? props?.data : defaultValues; const dataToWrite = { default: defaultReg, registries: props?.data ? [defaultValues, props.data] : [defaultValues] }; const writeSuccess = writeToFile(props.cwd, dataToWrite, true); if (!writeSuccess) return; logger.success(`successfully added ${props?.data ? props.data.name : defaultValues.name} registry.`); logger.log(props?.customLog ? props?.customLog : `run ${highlighter.warn(highlighter.underline("npx dotcn add button"))}`); } //#endregion //#region src/commands/registries/add.ts const addCmd = new Command("add"); addCmd.description("add a registry").argument("<name>", "name of the registry, will be used as ID").argument("<url>", "url, where components are located such, as https://magicui.design/r/").option("-c, --cwd <cwd>", "the working directory. defaults to the current directory.", process.cwd()).option("-d, --default", "mark this registry as default", false).action(async (name$1, url, opts) => { const basePath = path.resolve(opts.cwd); const newRegistry = { name: name$1, url }; const registryPath = `${basePath}/${FILE_NAME}`; try { new URL(url); } catch (_e) { logger.error("Unable to validate URL\n provide correct URL for", highlighter.bold(name$1)); process.exit(1); } const registriesCheck = checkFileExists(registryPath); if (registriesCheck) { const values = readRegistry(); const defaultValue = opts.default ? newRegistry : values.default; const alreadyRegistered = values.registries?.find((reg) => reg.name === name$1 || reg.url === url); if (alreadyRegistered) { logger.warn("Registry is already present."); logger.info(`If you are trying to mark ${name$1} as default\nUse ${highlighter.warn(highlighter.bold("mark-default"))} command`); } else { const newData = { default: defaultValue, registries: [...values.registries, newRegistry] }; const writeSuccess = writeToFile(registryPath, newData, true); if (!writeSuccess) logger.error("Failed to write data to ", highlighter.underline(registryPath)); logger.success(`"Successfully added ${highlighter.bold(name$1)} to ${FILE_NAME}"`); } } else await runInit({ cwd: registryPath, changeDefault: opts?.default ?? false, assure: true, data: newRegistry }); }); //#endregion //#region src/commands/registries/mark-default.ts const markDefaultCmd = new Command("mark-default"); markDefaultCmd.description("mark registry as default ").argument("<name>", "name of the registry").option("-c,--cwd <cwd>", "specify folder for registries.json", process.cwd()).action((name$1, opts) => { const values = readRegistry(opts.cwd); const basePath = path.resolve(opts.cwd); const registryPath = `${basePath}/${FILE_NAME}`; const valuePresent = values.registries?.find((reg) => reg.name === name$1); if (!valuePresent) { logger.error(`❌ ${highlighter.warn(highlighter.underline(name$1))} is not present in ${highlighter.underline(FILE_NAME)}.\nIf you are trying to add a registery then run \n${highlighter.warn(`npx dotcn add ${name$1} <url>`)}`); process.exit(0); } if (values.default.name === name$1) { logger.success(`${name$1} is already marked as ${highlighter.underline("Default")} ✅`); logger.info("If you are trying to remove it run"); logger.warn(`npx dotcn remove ${name$1}`); process.exit(0); } const newData = { default: values.registries.find((reg) => reg.name === name$1), registries: values?.registries }; const writeSuccess = writeToFile(registryPath, newData, true, true); if (!writeSuccess) { logger.error(`Unable to mark ${highlighter.underline(name$1)} as Default ❌`); return; } logger.success(`Successfully marked ${highlighter.underline(name$1)} as Default ✅`); }); //#endregion //#region src/commands/registries/remove.ts const removeCmd = new Command("remove"); removeCmd.description("remove a registry").argument("<name>", "name of the registry").option("-c, --cwd <cwd>", "the working directory. defaults to the current directory.", process.cwd()).action((name$1, opts) => { const values = readRegistry(opts.cwd); const registryPath = path.resolve(opts.cwd); const valuePresent = values.registries?.find((reg) => reg.name === name$1); if (!valuePresent) { logger.error(`${name$1} is not registered`); logger.info("first add it using", highlighter.bold(highlighter.warn(`npx doctcn add ${name$1}`))); process.exit(0); } if (values.registries?.length <= 1) { logger.error(`unable to remove ${name$1}\n`); logger.warn(highlighter.bold("registries.json must contain at-least 1 value.")); process.exit(0); } const newData = { default: values.default, registries: values.registries?.filter((reg) => reg.name !== name$1) }; const writeSuccess = writeToFile(registryPath, newData, true); if (!writeSuccess) return; logger.success(`removed ${name$1} from registry.`); }); //#endregion //#region src/commands/registries/index.ts const registry = new Command(); registry.name("registries").description("manage registries"); registry.addCommand(addCmd); registry.addCommand(removeCmd); registry.addCommand(markDefaultCmd); //#endregion //#region src/index.ts const program = new Command(); program.name("dotcn").description(package_default.description || "Simple CLI tool to install shadcn compatible components from various ui-libraries such as magic-ui, aceternity-ui or hexta-ui").version(package_default.version || "0.0.0", "-v, --version", "display the version number"); program.addCommand(registry); program.addCommand(initCmd); program.addCommand(add); program.parse(); process.on("uncaughtException", (error) => { if (error instanceof Error) logger.error("Operation Cancelled ❌"); else throw error; }); //#endregion