import fs from "fs"; import path from "path"; import { exec } from "child_process"; import { promisify } from "util"; import { fileURLToPath } from "url"; const APP_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", ); const COMPONENTS_JSON_FILE = "components.json"; const PACKAGE_JSON_FILE = "package.json"; const TAILWIND_CONFIG_FILE = "tailwind.config.js"; const FILES_UPDATED_BY_SHADCN_INIT = [ PACKAGE_JSON_FILE, COMPONENTS_JSON_FILE, TAILWIND_CONFIG_FILE, ]; const JSX_EXTENSION = "jsx"; const JS_EXTENSION = "js"; const COMPONENT_NAME_TO_INSTALLABLE_COMPONENT_NAME_MAP = { toaster: "toast", "use-toast": "toast", }; async function run() { let stdout = ""; let error = null; const updatedFiles = []; try { process.chdir(APP_DIR); // Get a list of components that are imported but not installed const uniqueComponentNames = await findImportedComponentNames(); const missingMask = await Promise.all( uniqueComponentNames.map((name) => componentExists(name).then((exists) => !exists), ), ); const missingComponentNames = uniqueComponentNames.filter( (_, i) => missingMask[i], ); const installableComponentNames = [ ...new Set( missingComponentNames.map( (name) => COMPONENT_NAME_TO_INSTALLABLE_COMPONENT_NAME_MAP[name] ?? name, ), ), ]; // If the components.json file doesn't exist, initialize shadcn const componentsJsonPath = path.join(APP_DIR, COMPONENTS_JSON_FILE); const componentsJsonExists = await pathExists(componentsJsonPath); if (!componentsJsonExists) { const shadcnInitResult = await runCommand( "npx -y shadcn@3 init -f --src-dir --no-base-style", ); const shadcnInitOutput = [ shadcnInitResult.stdout, shadcnInitResult.stderr, ] .filter(Boolean) .join("\n"); stdout = shadcnInitOutput; const isErrorStatus = !!shadcnInitResult.status; if (isErrorStatus) { error = `Shadcn failed to init: ${shadcnInitOutput}`; } else { updatedFiles.push(...FILES_UPDATED_BY_SHADCN_INIT); } } // If there are installable components, install them if (installableComponentNames.length && !error) { const shadcnAddResult = await runCommand( `npx -y shadcn@3 add ${installableComponentNames.join(" ")} -y -o --src-dir`, ); const shadcnAddOutput = [shadcnAddResult.stdout, shadcnAddResult.stderr] .filter(Boolean) .join("\n"); stdout = stdout ? `${stdout}\n${shadcnAddOutput}` : shadcnAddOutput; const isErrorStatus = !!shadcnAddResult.status; if (isErrorStatus) { error = `Shadcn failed to add: ${shadcnAddOutput}`; } else { // Example output of shadcnAddOutput: // - src/components/ui/button.tsx // - src/components/ui/input.tsx // The code below extracts these file paths like this: src/components/ui/button.tsx, src/components/ui/input.tsx const filesUpdatedByShadcnAdd = shadcnAddOutput .split("\n") .flatMap((line) => { const match = line.match(/^\s*-\s*(src\/\S+)/); return match ? [match[1].trim()] : []; }); updatedFiles.push(...filesUpdatedByShadcnAdd); } } } catch (err) { error = error ?? err?.message ?? "unknown error"; } const uniqueUpdatedFiles = [...new Set(updatedFiles)]; const result = { stdout: stdout, error: error, updatedFiles: uniqueUpdatedFiles, }; console.log(JSON.stringify(result)); } async function runCommand(command) { try { const { stdout, stderr } = await promisify(exec)(command); return { stdout: stdout ?? "", stderr: stderr ?? "", status: 0, }; } catch (error) { return { stdout: error.stdout ?? "", stderr: error.stderr ?? "", status: error.code ?? 1, }; } } async function pathExists(filePath) { try { await fs.promises.access(filePath); return true; } catch { return false; } } async function scanDirectoryForUiImports(directoryPath) { const componentNames = new Set(); const entries = await fs.promises.readdir(directoryPath, { withFileTypes: true, }); for (const entry of entries) { const fullEntryPath = path.join(directoryPath, entry.name); const isDirectory = entry.isDirectory(); if (isDirectory) { const innerDirectoryComponentNames = await scanDirectoryForUiImports(fullEntryPath); for (const name of innerDirectoryComponentNames) { componentNames.add(name); } } const isJsxFile = entry.name.endsWith(`.${JSX_EXTENSION}`); if (isJsxFile) { try { const fileContent = await fs.promises.readFile(fullEntryPath, "utf8"); for (const [, componentName] of fileContent.matchAll( /@\/components\/ui\/([a-z0-9-]+)/g, )) { componentNames.add(componentName); } } catch { // Skip unreadable files } } } return componentNames; } async function findImportedComponentNames() { const srcPath = path.join(APP_DIR, "src"); const srcExists = await pathExists(srcPath); if (!srcExists) { return []; } const uniqueComponentNames = await scanDirectoryForUiImports(srcPath); return [...uniqueComponentNames]; } async function componentExists(componentName) { const componentsUiPath = path.join(APP_DIR, "src", "components", "ui"); const hooksPath = path.join(APP_DIR, "src", "hooks"); const existenceChecks = await Promise.all( [JSX_EXTENSION, JS_EXTENSION].map(async (extension) => { const fileName = `${componentName}.${extension}`; return ( (await pathExists(path.join(componentsUiPath, fileName))) || (await pathExists(path.join(hooksPath, fileName))) ); }), ); return existenceChecks.some(Boolean); } run();