ui-thing
Version:
CLI used to add Nuxt components to a project
291 lines (262 loc) • 9.16 kB
text/typescript
import path from "node:path";
import { updateConfig } from "c12/update";
import { Command } from "commander";
import { consola } from "consola";
import kleur from "kleur";
import _ from "lodash";
import prompts from "prompts";
import { AddCommand, Component, UIConfig } from "../types";
import { compareUIConfig } from "../utils/compareUIConfig";
import { addModuleToConfig, getUIConfig } from "../utils/config";
import { DEFAULT_CONFIG, DEFAULT_CONFIG_NUXT4, PACKAGE_MANAGER_CHOICES } from "../utils/constants";
import { detectNuxtVersion } from "../utils/detectNuxtVersion";
import { fetchComponents } from "../utils/fetchComponents";
import { fileExists } from "../utils/fileExists";
import { installPackages } from "../utils/installPackages";
import { installValidator } from "../utils/installValidator";
import { logger } from "../utils/logger";
import { printFancyBoxMessage } from "../utils/printFancyBoxMessage";
import { promptUserForComponents } from "../utils/promptForComponents";
import { writeFile } from "../utils/writeFile";
let allComponents: Component[] = [];
const currentDirectory = process.cwd();
/**
* Finds a component definition by its name (case-insensitive).
*/
const findComponent = (name: string) =>
allComponents.find((c) => c.value.toLowerCase() === name.toLowerCase());
/**
* Handles writing a file with overwrite checks.
*/
async function safeWriteFile(
targetPath: string,
content: string,
forceOverwrite: boolean,
promptMessage: string
) {
const exists = await fileExists(targetPath);
if (exists && !forceOverwrite) {
const { value: overwrite } = await prompts({
type: "confirm",
name: "value",
message: promptMessage,
initial: false,
});
if (!overwrite) {
consola.info(`Skipped: ${kleur.cyan(path.basename(targetPath))}`);
return false;
}
}
await writeFile(targetPath, content);
return true;
}
/**
* Writes all files in a given category (utils, composables, plugins).
*/
async function writeCategoryFiles(
category: string,
items: Array<{ fileName: string; fileContent: string; dirPath?: string }>,
baseDir: string,
forceOverwrite: boolean
) {
for (const item of items) {
const targetPath = path.join(currentDirectory, baseDir, item.fileName);
await safeWriteFile(
targetPath,
item.fileContent,
forceOverwrite,
`The ${category} file ${kleur.bold(item.fileName)} already exists. Overwrite?`
);
}
}
/**
* Main command logic for adding components.
*/
export const runAddCommand = async (components: string[], options: AddCommand) => {
// Step 1 — Load and verify UI config
let uiConfig: UIConfig;
if (options.skipConfig) {
const nuxtVersion = detectNuxtVersion();
const base = nuxtVersion === 4 ? DEFAULT_CONFIG_NUXT4 : DEFAULT_CONFIG;
let pm = options.packageManager;
if (!pm) {
const { packageManager } = await prompts({
type: "select",
name: "packageManager",
message: "Which package manager are you using?",
choices: PACKAGE_MANAGER_CHOICES,
});
pm = packageManager ?? "npm";
}
uiConfig = { ...base, packageManager: pm };
} else {
uiConfig = await getUIConfig();
if (!(await compareUIConfig())) {
uiConfig = await getUIConfig({ force: true });
}
if (_.isEmpty(uiConfig)) {
consola.error("Config file not set. Exiting...");
process.exit(1);
}
}
// Step 2 — Fetch all available components
allComponents = await fetchComponents();
// Step 3 — If no components were passed, prompt user to select them
let componentNames = components;
if (componentNames.length === 0) {
const response = await promptUserForComponents(options.all, allComponents);
if (!response || response.length === 0) {
consola.info("No components selected. Exiting...");
process.exit(0);
}
componentNames = response;
}
// Step 4 — Validate component names
const notFound = componentNames.filter((name) => !findComponent(name));
if (notFound.length > 0) {
consola.error(`Not found: ${kleur.bgRed(notFound.join(", "))}`);
}
// Step 5 — Collect found components and their dependencies
const found: Component[] = componentNames
.map((name) => findComponent(name))
.filter(Boolean) as Component[];
for (const comp of [...found]) {
if (comp.components) {
comp.components.forEach((dep) => {
if (!found.find((c) => c.value === dep)) {
found.push(findComponent(dep)!);
}
});
}
}
// Step 6 — Write files for each component
for (const component of found) {
for (const file of component.files) {
let dirPath = uiConfig.componentsLocation;
let filePath = path.join(currentDirectory, dirPath, file.fileName);
// Ask for custom location if not using default
if (!uiConfig.useDefaultFilename) {
const { value: newDir } = await prompts({
type: "text",
name: "value",
message: `Where should we add the file ${kleur.cyan(file.fileName)}?`,
initial: dirPath,
});
if (newDir) {
dirPath = newDir;
filePath = path.join(currentDirectory, dirPath, file.fileName);
}
}
const fileWritten = await safeWriteFile(
filePath,
file.fileContent,
uiConfig.force,
`The file ${kleur.bold(file.fileName)} already exists. Overwrite?`
);
if (!fileWritten) continue;
// Component-specific logic hooks
if (component.value === "vue-sonner" || component.value === "sonner") await addSonner();
if (component.value === "datatable") await addDataTable();
// Write related files
await writeCategoryFiles("utils", component.utils, uiConfig.utilsLocation, uiConfig.force);
await writeCategoryFiles(
"composables",
component.composables,
uiConfig.composablesLocation,
uiConfig.force
);
await writeCategoryFiles(
"plugins",
component.plugins,
uiConfig.pluginsLocation ?? "",
uiConfig.force
);
}
}
// Step 7 — Add Nuxt modules
await addModuleToConfig(_.uniq(found.flatMap((c) => c.nuxtModules || [])));
// Step 8 — Install dependencies if necessary
const deps = _.uniq(found.flatMap((c) => c.deps || []));
const devDeps = _.uniq(found.flatMap((c) => c.devDeps || []));
if (deps.length > 0 || devDeps.length > 0) {
if (options.all) {
await installPackages(uiConfig.packageManager, deps, devDeps);
} else {
const { confirmInstall } = await prompts({
type: "confirm",
name: "confirmInstall",
message: `Install packages: ${kleur.cyan([...deps, ...devDeps].join(", "))}?`,
initial: true,
});
if (confirmInstall) {
await installPackages(uiConfig.packageManager, deps, devDeps);
}
}
}
// Step 9 — Install validator if required
if (found.some((c) => c.askValidator)) {
await installValidator(uiConfig.packageManager);
}
// Step 10 — Success message & instructions
printFancyBoxMessage(
"All Done!",
`Run the ${kleur.cyan("ui-thing@latest --help")} command to learn more.\n`,
{ box: { title: "Components Added" } }
);
found
.filter((c) => c.docsPath)
.forEach((c) => consola.info(`Docs: https://uithing.com${c.docsPath}`));
const instructions = _.compact(found.flatMap((c) => c.instructions));
if (instructions.length > 0) {
logger.info("Instructions:");
instructions.forEach((i) => logger.info(`- ${i}`));
}
};
/**
* CLI Command Registration
*/
export const add = new Command()
.name("add")
.command("add")
.description("Add a list of components to your project.")
.option("-a --all", "Add all components to your project.", false)
.option(
"--skip-config",
"Add components without a ui-thing config file. Uses auto-detected Nuxt defaults."
)
.option("--package-manager <pm>", "Package manager to use when --skip-config is set.")
.argument("[componentNames...]", "Components that you want to add.")
.action(runAddCommand);
/**
* Component-specific setup helpers
*/
async function addSonner() {
await updateConfig({
configFile: "nuxt.config",
cwd: currentDirectory,
onUpdate(config: any) {
config.imports ||= { imports: [] };
if (!config.imports.imports.find((i: any) => i.from === "vue-sonner" && i.name === "toast")) {
config.imports.imports.push({ from: "vue-sonner", name: "toast", as: "useSonner" });
}
},
});
}
async function addDataTable() {
await updateConfig({
configFile: "nuxt.config",
cwd: currentDirectory,
onUpdate(cfg: any) {
cfg.app ||= { head: { script: [] } };
const scripts = [
"https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.12/pdfmake.min.js",
"https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.12/vfs_fonts.min.js",
];
scripts.forEach((src) => {
if (!cfg.app.head.script.find((i: any) => i.src === src)) {
cfg.app.head.script.push({ src, defer: true });
}
});
},
});
}