309 lines
7.8 KiB
JavaScript
309 lines
7.8 KiB
JavaScript
|
|
#!/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]
|
||
|
|
`)
|
||
|
|
}
|