feat: add @shadcn/ui cli (#112)

This commit is contained in:
shadcn
2023-03-08 11:37:15 +04:00
committed by GitHub
parent b043cf7f8c
commit be701cf139
28 changed files with 2833 additions and 70 deletions

2
packages/cli/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
components
dist

58
packages/cli/package.json Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "@shadcn/ui",
"version": "0.0.1",
"description": "Add @shadcn/ui components to your app.",
"license": "MIT",
"author": {
"name": "shadcn",
"url": "https://twitter.com/shadcn"
},
"repository": {
"type": "git",
"url": "https://github.com/shadcn/ui.git",
"directory": "packages/cli"
},
"keywords": [
"components",
"ui",
"tailwind",
"radix-ui",
"shadcn"
],
"type": "module",
"exports": "./dist/index.js",
"bin": {
"@shadcn/cli": "./dist/index.js"
},
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"typecheck": "tsc",
"clean": "rimraf dist && rimraf components",
"start": "node dist/index.js",
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
"release": "changeset version",
"pub:beta": "pnpm build && npm publish --tag beta",
"pub:next": "pnpm build && npm publish --tag next",
"pub:release": "pnpm build && npm publish"
},
"dependencies": {
"chalk": "5.2.0",
"commander": "^10.0.0",
"execa": "^7.0.0",
"fs-extra": "^11.1.0",
"node-fetch": "^3.3.0",
"ora": "^6.1.2",
"prompts": "^2.4.2",
"zod": "^3.20.2"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/prompts": "^2.4.2",
"rimraf": "^4.1.3",
"tsup": "^6.6.3",
"type-fest": "^3.6.1",
"typescript": "^4.9.5"
}
}

113
packages/cli/src/index.ts Normal file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env node
import { existsSync, promises as fs } from "fs"
import path from "path"
import { Command } from "commander"
import { execa } from "execa"
import ora from "ora"
import prompts from "prompts"
import { Component, getAvailableComponents } from "./utils/get-components"
import { getPackageInfo } from "./utils/get-package-info"
import { getPackageManager } from "./utils/get-package-manager"
import { logger } from "./utils/logger"
process.on("SIGINT", () => process.exit(0))
process.on("SIGTERM", () => process.exit(0))
async function main() {
const packageInfo = await getPackageInfo()
const program = new Command()
.name("@shadcn/ui")
.description("Add @shadcn/ui components to your project")
.version(
packageInfo.version || "1.0.0",
"-v, --version",
"display the version number"
)
program
.command("add")
.description("add components to your project")
.action(async () => {
const { components, dir } = await promptForAddOptions()
if (!components?.length) {
logger.warn("No components selected. Nothing to install.")
process.exit(0)
}
// Create componentPath directory if it doesn't exist.
const destinationDir = path.resolve(dir)
if (!existsSync(destinationDir)) {
const spinner = ora(`Creating ${dir}...`).start()
await fs.mkdir(destinationDir, { recursive: true })
spinner.succeed()
}
const packageManager = getPackageManager()
logger.success(`Installing components...`)
for (const component of components) {
const componentSpinner = ora(`${component.name}...`).start()
// Write the files.
for (const file of component.files) {
const filePath = path.resolve(dir, file.name)
await fs.writeFile(filePath, file.content)
}
// Install dependencies.
if (component.dependencies?.length) {
const dependencies = component.dependencies.join(" ")
await execa(packageManager, [
packageManager === "npm" ? "install" : "add",
dependencies,
])
}
componentSpinner.succeed(component.name)
}
})
program.parse()
}
type AddOptions = {
components: Component[]
dir: string
}
async function promptForAddOptions() {
const availableComponents = await getAvailableComponents()
if (!availableComponents?.length) {
logger.error(
"An error occurred while fetching components. Please try again."
)
process.exit(0)
}
const options = await prompts([
{
type: "multiselect",
name: "components",
message: "Which component(s) would you like to add?",
hint: "Space to select. A to select all. I to invert selection.",
instructions: false,
choices: availableComponents.map((component) => ({
title: component.name,
value: component,
})),
},
{
type: "text",
name: "dir",
message: "Where would you like to install the component(s)?",
initial: "./components/ui",
},
])
return options as AddOptions
}
main()

View File

@@ -0,0 +1,34 @@
import fetch from "node-fetch"
import * as z from "zod"
const baseUrl =
process.env.NODE_ENV === "production"
? "https://ui.shadcn.com"
: "http://localhost:3000"
const componentSchema = z.object({
name: z.string(),
dependencies: z.array(z.string()).optional(),
files: z.array(
z.object({
name: z.string(),
dir: z.string(),
content: z.string(),
})
),
})
export type Component = z.infer<typeof componentSchema>
const componentsSchema = z.array(componentSchema)
export async function getAvailableComponents() {
try {
const response = await fetch(`${baseUrl}/api/components`)
const components = await response.json()
return componentsSchema.parse(components)
} catch (error) {
throw new Error("Failed to fetch components")
}
}

View File

@@ -0,0 +1,9 @@
import path from "path"
import fs from "fs-extra"
import { type PackageJson } from "type-fest"
export function getPackageInfo() {
const packageJsonPath = path.join("package.json")
return fs.readJSONSync(packageJsonPath) as PackageJson
}

View File

@@ -0,0 +1,17 @@
export function getPackageManager() {
const userAgent = process.env.npm_config_user_agent
if (!userAgent) {
return "npm"
}
if (userAgent.startsWith("yarn")) {
return "yarn"
}
if (userAgent.startsWith("pnpm")) {
return "pnpm"
}
return "npm"
}

View File

@@ -0,0 +1,16 @@
import chalk from "chalk"
export const logger = {
error(...args: unknown[]) {
console.log(chalk.red(...args))
},
warn(...args: unknown[]) {
console.log(chalk.yellow(...args))
},
info(...args: unknown[]) {
console.log(chalk.cyan(...args))
},
success(...args: unknown[]) {
console.log(chalk.green(...args))
},
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.json",
"compilerOptions": {
"isolatedModules": false,
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from "tsup"
export default defineConfig({
clean: true,
dts: true,
entry: ["src/index.ts"],
format: ["esm"],
sourcemap: true,
target: "esnext",
outDir: "dist",
})