Files

309 lines
7.8 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
import { accessSync } from "node:fs"
import { access, copyFile, mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import process from "node:process"
import { fileURLToPath } from "node:url"
const command = process.argv[2]
const args = process.argv.slice(3)
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const bundledManifestPath = path.join(packageRoot, "manifest.json")
const configFileName = "base-ui-tailwind.json"
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
async function main() {
if (!command || command === "-h" || command === "--help") {
printHelp()
return
}
if (command === "init") {
await initProject(parseOptions(args))
return
}
if (command === "add") {
await addItems(parseOptions(args))
return
}
throw new Error(`Unknown command: ${command}`)
}
async function initProject(options) {
const cwd = path.resolve(options.cwd ?? process.cwd())
const configPath = path.join(cwd, configFileName)
const localManifestPath = path.join(cwd, "manifest.json")
const config = {
manifest: options.manifest ?? ((await exists(localManifestPath)) ? "manifest.json" : bundledManifestPath),
aliases: {
ui: "components/ui",
lib: "lib",
},
}
if (!options.force && (await exists(configPath))) {
console.log(`${configFileName} already exists. Use --force to overwrite it.`)
return
}
await mkdir(path.join(cwd, config.aliases.ui), { recursive: true })
await mkdir(path.join(cwd, config.aliases.lib), { recursive: true })
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`)
console.log(`Created ${path.relative(process.cwd(), configPath) || configFileName}.`)
}
async function addItems(options) {
const names = options._.filter((item) => !item.startsWith("-"))
if (names.length === 0) {
throw new Error("Usage: base-ui-tailwind add <name...>")
}
const cwd = path.resolve(options.cwd ?? process.cwd())
const config = await loadConfig(cwd)
const manifestRef = options.manifest ?? config.manifest ?? bundledManifestPath
const manifest = await loadManifest(manifestRef, cwd)
const selected = resolveItems(manifest, names)
const files = collectFiles(manifest, selected)
const written = []
const skipped = []
const packageDependencies = collectPackageDependencies(manifest, selected)
for (const file of files) {
const targetPath = path.join(cwd, resolveTargetPath(file.target, config.aliases))
if (!options.overwrite && (await exists(targetPath))) {
skipped.push(path.relative(cwd, targetPath))
continue
}
if (!options.dryRun) {
await mkdir(path.dirname(targetPath), { recursive: true })
const source = await readSourceFile(file.path, manifestRef, cwd)
if (source.kind === "local") {
await copyFile(source.path, targetPath)
} else {
await writeFile(targetPath, source.contents)
}
}
written.push(path.relative(cwd, targetPath))
}
if (written.length > 0) {
console.log(`${options.dryRun ? "Would add" : "Added"}:`)
for (const file of written) {
console.log(` ${file}`)
}
}
if (skipped.length > 0) {
console.log("Skipped existing files:")
for (const file of skipped) {
console.log(` ${file}`)
}
console.log("Use --overwrite to replace existing files.")
}
if (packageDependencies.length > 0) {
console.log("Required packages:")
console.log(` ${packageDependencies.join(" ")}`)
}
}
function resolveItems(manifest, names) {
const resolved = new Set()
const queue = [...names]
while (queue.length > 0) {
const name = queue.shift()
const item = manifest.items?.[name]
if (!item) {
throw new Error(`Manifest item not found: ${name}`)
}
if (resolved.has(name)) {
continue
}
resolved.add(name)
for (const dependency of item.localDependencies ?? []) {
queue.push(dependency)
}
}
return [...resolved]
}
function collectFiles(manifest, names) {
const files = []
const seen = new Set()
for (const name of names) {
for (const file of manifest.items[name].files ?? []) {
const key = file.target
if (!seen.has(key)) {
seen.add(key)
files.push(file)
}
}
}
return files
}
function collectPackageDependencies(manifest, names) {
const dependencies = new Set()
for (const name of names) {
for (const dependency of manifest.items[name].dependencies ?? []) {
dependencies.add(dependency)
}
}
return [...dependencies].sort((a, b) => a.localeCompare(b))
}
async function loadConfig(cwd) {
const configPath = path.join(cwd, configFileName)
if (!(await exists(configPath))) {
return {
manifest: undefined,
aliases: {
ui: "components/ui",
lib: "lib",
},
}
}
const config = JSON.parse(await readFile(configPath, "utf8"))
return {
manifest: config.manifest,
aliases: {
ui: config.aliases?.ui ?? "components/ui",
lib: config.aliases?.lib ?? "lib",
},
}
}
async function loadManifest(manifestRef, cwd) {
const contents = isUrl(manifestRef)
? await fetchText(manifestRef)
: await readFile(resolveLocalPath(manifestRef, cwd), "utf8")
const manifest = JSON.parse(contents)
if (!manifest.items || typeof manifest.items !== "object") {
throw new Error("Invalid manifest: missing items.")
}
return manifest
}
async function readSourceFile(filePath, manifestRef, cwd) {
if (isUrl(manifestRef)) {
return {
kind: "remote",
contents: await fetchText(new URL(filePath, manifestRef).toString()),
}
}
return {
kind: "local",
path: path.join(path.dirname(resolveLocalPath(manifestRef, cwd)), filePath),
}
}
function resolveTargetPath(target, aliases) {
if (target.startsWith("src/components/ui/")) {
return path.join(aliases.ui, target.slice("src/components/ui/".length))
}
if (target.startsWith("src/lib/")) {
return path.join(aliases.lib, target.slice("src/lib/".length))
}
if (target.startsWith("components/ui/")) {
return path.join(aliases.ui, target.slice("components/ui/".length))
}
if (target.startsWith("lib/")) {
return path.join(aliases.lib, target.slice("lib/".length))
}
return target
}
function resolveLocalPath(value, cwd) {
if (path.isAbsolute(value)) {
return value
}
const projectPath = path.resolve(cwd, value)
return value === "manifest.json" && !existsSync(projectPath) ? bundledManifestPath : projectPath
}
function parseOptions(values) {
const options = { _: [] }
for (let index = 0; index < values.length; index += 1) {
const value = values[index]
if (value === "--cwd") {
options.cwd = values[++index]
} else if (value === "--manifest") {
options.manifest = values[++index]
} else if (value === "--force") {
options.force = true
} else if (value === "--overwrite") {
options.overwrite = true
} else if (value === "--dry-run") {
options.dryRun = true
} else {
options._.push(value)
}
}
return options
}
async function fetchText(url) {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
}
return response.text()
}
function existsSync(filePath) {
try {
accessSync(filePath)
return true
} catch {
return false
}
}
async function exists(filePath) {
try {
await access(filePath)
return true
} catch {
return false
}
}
function isUrl(value) {
return /^https?:\/\//.test(value)
}
function printHelp() {
console.log(`base-ui-tailwind
Usage:
base-ui-tailwind init [--manifest <path-or-url>] [--cwd <dir>] [--force]
base-ui-tailwind add <name...> [--manifest <path-or-url>] [--cwd <dir>] [--overwrite] [--dry-run]
`)
}