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
JavaScript
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