Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add CLI for in-project components #11

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/cli/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
components
dist
.turbo
1 change: 1 addition & 0 deletions packages/cli/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test
39 changes: 39 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# md-cli

A CLI for adding Material components to your React apps.

## Usage

Use the `init` command to initialize dependencies for a new project.

The `init` command installs dependencies, configures `tailwind.config.js`, and CSS variables for the project.

```bash
npx md-cli init
```

## add

Use the `add` command to add components to your project.

The `add` command adds a component to your project and installs all required dependencies.

```bash
npx md-ui add [component]
```

### Example

```bash
npx md-ui add button
```

You can also run the command without any arguments to view a list of all available components:

```bash
npx md-ui add
```

## License

Licensed under the [MIT license](https://github.com/grayhatdevelopers/material-web-components-react/blob/main/LICENSE.md).
81 changes: 81 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
{
"name": "md-cli",
"version": "0.0.1",
"description": "Add Material components to your React apps.",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"author": {
"name": "Grayhat Team",
"url": "https://grayhat.studio/"
},
"repository": {
"type": "git",
"url": "https://github.com/grayhatdevelopers/material-web-components-react.git",
"directory": "packages/cli"
},
"files": [
"dist"
],
"keywords": [
"components",
"ui",
"tailwind",
"material",
"material-web-components"
],
"type": "module",
"exports": "./dist/index.js",
"bin": "./dist/index.js",
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"typecheck": "tsc --noEmit",
"clean": "rimraf dist && rimraf components",
"start:dev": "cross-env COMPONENTS_REGISTRY_URL=http://localhost:3003 node dist/index.js",
"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 && pnpm publish --no-git-checks --access public --tag beta",
"pub:next": "pnpm build && pnpm publish --no-git-checks --access public --tag next",
"pub:release": "pnpm build && pnpm publish --access public",
"test": "vitest run"
},
"dependencies": {
"@antfu/ni": "^0.21.4",
"@babel/core": "^7.22.1",
"@babel/parser": "^7.22.6",
"@babel/plugin-transform-typescript": "^7.22.5",
"chalk": "5.2.0",
"commander": "^10.0.0",
"cosmiconfig": "^8.1.3",
"diff": "^5.1.0",
"execa": "^7.0.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.1.0",
"https-proxy-agent": "^6.2.0",
"lodash.template": "^4.5.0",
"node-fetch": "^3.3.0",
"ora": "^6.1.2",
"prompts": "^2.4.2",
"recast": "^0.23.2",
"ts-morph": "^18.0.0",
"tsconfig-paths": "^4.2.0",
"zod": "^3.20.2"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/babel__core": "^7.20.1",
"@types/diff": "^5.0.3",
"@types/fs-extra": "^11.0.1",
"@types/lodash.template": "^4.5.1",
"@types/prompts": "^2.4.2",
"rimraf": "^4.1.3",
"tsup": "^6.6.3",
"type-fest": "^3.8.0",
"typescript": "^4.9.3"
}
}
218 changes: 218 additions & 0 deletions packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { existsSync, promises as fs } from "fs"
import path from "path"
import { getConfig } from "@/src/utils/get-config"
import { getPackageManager } from "@/src/utils/get-package-manager"
import { handleError } from "@/src/utils/handle-error"
import { logger } from "@/src/utils/logger"
import {
fetchTree,
getItemTargetPath,
getRegistryBaseColor,
getRegistryIndex,
resolveTree,
} from "@/src/utils/registry"
import { transform } from "@/src/utils/transformers"
import chalk from "chalk"
import { Command } from "commander"
import { execa } from "execa"
import ora from "ora"
import prompts from "prompts"
import { z } from "zod"

const addOptionsSchema = z.object({
components: z.array(z.string()).optional(),
yes: z.boolean(),
overwrite: z.boolean(),
cwd: z.string(),
all: z.boolean(),
path: z.string().optional(),
})

export const add = new Command()
.name("add")
.description("add a component to your project")
.argument("[components...]", "the components to add")
.option("-y, --yes", "skip confirmation prompt.", true)
.option("-o, --overwrite", "overwrite existing files.", false)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-a, --all", "add all available components", false)
.option("-p, --path <path>", "the path to add the component to.")
.action(async (components, opts) => {
try {
const options = addOptionsSchema.parse({
components,
...opts,
})

const cwd = path.resolve(options.cwd)

if (!existsSync(cwd)) {
logger.error(`The path ${cwd} does not exist. Please try again.`)
process.exit(1)
}

const config = await getConfig(cwd)
if (!config) {
logger.warn(
`Configuration is missing. Please run ${chalk.green(
`init`
)} to create a components.json file.`
)
process.exit(1)
}

const registryIndex = await getRegistryIndex()

let selectedComponents = options.all
? registryIndex.map((entry) => entry.name)
: options.components
if (!options.components?.length && !options.all) {
const { components } = await prompts({
type: "multiselect",
name: "components",
message: "Which components would you like to add?",
hint: "Space to select. A to toggle all. Enter to submit.",
instructions: false,
choices: registryIndex.map((entry) => ({
title: entry.name,
value: entry.name,
selected: options.all
? true
: options.components?.includes(entry.name),
})),
})
selectedComponents = components
}

if (!selectedComponents?.length) {
logger.warn("No components selected. Exiting.")
process.exit(0)
}

const tree = await resolveTree(registryIndex, selectedComponents)
const payload = await fetchTree(config.style, tree)
const baseColor = await getRegistryBaseColor(config.tailwind.baseColor)

if (!payload.length) {
logger.warn("Selected components not found. Exiting.")
process.exit(0)
}

if (!options.yes) {
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: `Ready to install components and dependencies. Proceed?`,
initial: true,
})

if (!proceed) {
process.exit(0)
}
}

const spinner = ora(`Installing components...`).start()
for (const item of payload) {
spinner.text = `Installing ${item.name}...`
const targetDir = await getItemTargetPath(
config,
item,
options.path ? path.resolve(cwd, options.path) : undefined
)

if (!targetDir) {
continue
}

if (!existsSync(targetDir)) {
await fs.mkdir(targetDir, { recursive: true })
}

const existingComponent = item.files.filter((file) =>
existsSync(path.resolve(targetDir, file.name))
)

if (existingComponent.length && !options.overwrite) {
if (selectedComponents.includes(item.name)) {
spinner.stop()
const { overwrite } = await prompts({
type: "confirm",
name: "overwrite",
message: `Component ${item.name} already exists. Would you like to overwrite?`,
initial: false,
})

if (!overwrite) {
logger.info(
`Skipped ${item.name}. To overwrite, run with the ${chalk.green(
"--overwrite"
)} flag.`
)
continue
}

spinner.start(`Installing ${item.name}...`)
} else {
continue
}
}

for (const file of item.files) {
let filePath = path.resolve(targetDir, file.name)

// Run transformers.
const content = await transform({
filename: file.name,
raw: file.content,
config,
baseColor,
})

if (!config.tsx) {
filePath = filePath.replace(/\.tsx$/, ".jsx")
filePath = filePath.replace(/\.ts$/, ".js")
}

await fs.writeFile(filePath, content)
}

const packageManager = await getPackageManager(cwd)

// Install dependencies.
if (item.dependencies?.length) {
await execa(
packageManager,
[
packageManager === "npm" ? "install" : "add",
...item.dependencies,
],
{
cwd,
}
)
}

// Install devDependencies.
if (item.devDependencies?.length) {
await execa(
packageManager,
[
packageManager === "npm" ? "install" : "add",
"-D",
...item.devDependencies,
],
{
cwd,
}
)
}
}
spinner.succeed(`Done.`)
} catch (error) {
handleError(error)
}
})
Loading