├── .nvmrc ├── test ├── template │ └── tailwind.config.js ├── constants │ ├── index.ts │ ├── path.ts │ └── template.ts ├── vitest.config.ts ├── src │ ├── components.spec.ts │ └── check │ │ ├── check-peer-dependencies.spec.ts │ │ └── check-tailwind.spec.ts ├── tsconfig.json ├── helpers │ └── test-exec.ts └── package.json ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── workflows │ ├── build.yml │ ├── lint.yml │ ├── release.yml │ ├── commitlint.yml │ └── sync.yml └── pull_request_template.md ├── packages └── codemod │ ├── src │ ├── types │ │ ├── index.ts │ │ └── codemod.ts │ ├── helpers │ │ ├── debug.ts │ │ ├── actions │ │ │ ├── lint-affected-files.ts │ │ │ └── migrate │ │ │ │ ├── migrate-npmrc.ts │ │ │ │ ├── migrate-left-files.ts │ │ │ │ ├── migrate-css-variables.ts │ │ │ │ ├── migrate-nextui-provider.ts │ │ │ │ ├── migrate-tailwindcss.ts │ │ │ │ ├── migrate-import.ts │ │ │ │ ├── migrate-json.ts │ │ │ │ └── migrate-common.ts │ │ ├── transform.ts │ │ ├── options.ts │ │ ├── find-files.ts │ │ ├── utils.ts │ │ ├── bar.ts │ │ ├── lint.ts │ │ ├── parse.ts │ │ ├── https.ts │ │ └── store.ts │ ├── constants │ │ ├── path.ts │ │ └── prefix.ts │ ├── actions │ │ └── codemod-action.ts │ └── index.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ ├── package.json │ └── README.md ├── pnpm-workspace.yaml ├── src ├── scripts │ ├── sync │ │ ├── sync.ts │ │ └── index.ts │ ├── cache │ │ ├── clean.ts │ │ └── cache.ts │ ├── update │ │ └── update-components.ts │ ├── path.ts │ └── helpers.ts ├── constants │ ├── path.ts │ ├── box.ts │ ├── templates.ts │ ├── store.ts │ ├── component.ts │ └── required.ts ├── helpers │ ├── actions │ │ ├── init │ │ │ └── change-npmrc.ts │ │ ├── add │ │ │ ├── heroui-chat │ │ │ │ ├── fetch-package.ts │ │ │ │ ├── write-files.ts │ │ │ │ ├── get-base-storage-url.ts │ │ │ │ ├── get-codebase-files.ts │ │ │ │ ├── get-related-imports.ts │ │ │ │ └── add-hero-chat-codebase.ts │ │ │ ├── template.ts │ │ │ └── get-peer-pakcage-version.ts │ │ └── upgrade │ │ │ ├── upgrade-types.ts │ │ │ ├── catch-pnpm-exec.ts │ │ │ └── get-libs-data.ts │ ├── condition-value.ts │ ├── init.ts │ ├── setup.ts │ ├── math-diff.ts │ ├── detect.ts │ ├── exec.ts │ ├── beta.ts │ ├── logger.ts │ ├── debug.ts │ ├── match.ts │ ├── fetch.ts │ ├── type.ts │ ├── remove.ts │ ├── package.ts │ ├── fix.ts │ └── utils.ts ├── actions │ ├── env-action.ts │ ├── list-action.ts │ ├── remove-action.ts │ ├── init-action.ts │ └── upgrade-action.ts └── prompts │ ├── get-beta-version-select.ts │ ├── index.ts │ └── clack.ts ├── screenshots ├── add-command.png ├── env-command.png ├── list-command.png ├── doctor-command.png ├── remove-command.png └── upgrade-command.png ├── .husky ├── commit-msg ├── post-merge ├── post-rebase ├── pre-commit └── scripts │ └── update-dep ├── .eslintignore ├── .npmrc ├── .prettierignore ├── .commitlintrc.cjs ├── .editorconfig ├── .vscode └── settings.json ├── .prettierrc ├── .gitignore ├── plugins └── plugin-copy.ts ├── tsup.config.ts ├── .lintstagedrc.mjs ├── license ├── tsconfig.json ├── .eslintrc ├── CODE_OF_CONDUCT.md ├── package.json └── CONTRIBUTING.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.17.0 2 | -------------------------------------------------------------------------------- /test/template/tailwind.config.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @tianenpang @jrgarciadev @winchesHe 2 | -------------------------------------------------------------------------------- /packages/codemod/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './codemod'; 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/**/*' 3 | - 'test/**' 4 | -------------------------------------------------------------------------------- /src/scripts/sync/sync.ts: -------------------------------------------------------------------------------- 1 | import {syncDocs} from '.'; 2 | 3 | syncDocs(); 4 | -------------------------------------------------------------------------------- /packages/codemod/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /test/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './template'; 2 | export * from './path'; 3 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/debug.ts: -------------------------------------------------------------------------------- 1 | export const DEBUG = { 2 | enabled: false 3 | }; 4 | -------------------------------------------------------------------------------- /src/scripts/cache/clean.ts: -------------------------------------------------------------------------------- 1 | import {removeCache} from './cache'; 2 | 3 | removeCache(); 4 | -------------------------------------------------------------------------------- /screenshots/add-command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heroui-inc/heroui-cli/HEAD/screenshots/add-command.png -------------------------------------------------------------------------------- /screenshots/env-command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heroui-inc/heroui-cli/HEAD/screenshots/env-command.png -------------------------------------------------------------------------------- /screenshots/list-command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heroui-inc/heroui-cli/HEAD/screenshots/list-command.png -------------------------------------------------------------------------------- /screenshots/doctor-command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heroui-inc/heroui-cli/HEAD/screenshots/doctor-command.png -------------------------------------------------------------------------------- /screenshots/remove-command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heroui-inc/heroui-cli/HEAD/screenshots/remove-command.png -------------------------------------------------------------------------------- /screenshots/upgrade-command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heroui-inc/heroui-cli/HEAD/screenshots/upgrade-command.png -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm commitlint --config .commitlintrc.cjs --edit ${1} 5 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | huskyDir=$(dirname -- "$0") 3 | . "$huskyDir/_/husky.sh" 4 | 5 | . "$huskyDir/scripts/update-dep" 6 | -------------------------------------------------------------------------------- /.husky/post-rebase: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | huskyDir=$(dirname -- "$0") 3 | . "$huskyDir/_/husky.sh" 4 | 5 | . "$huskyDir/scripts/update-dep" 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .husky 3 | dist 4 | node_modules 5 | pnpm-lock.yaml 6 | !.vscode 7 | !.*.js 8 | !.*.cjs 9 | !.*.mjs 10 | !.*.ts 11 | -------------------------------------------------------------------------------- /packages/codemod/src/constants/path.ts: -------------------------------------------------------------------------------- 1 | import {resolve} from 'pathe'; 2 | 3 | export const resolver = (path: string) => resolve(process.cwd(), path); 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | if [ -t 2 ]; then exec >/dev/tty 2>&1; fi 5 | 6 | pnpm lint-staged -c .lintstagedrc.mjs 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | lockfile=true 2 | save-exact=true 3 | engine-strict=true 4 | auto-install-peers=true 5 | enable-pre-post-scripts=true 6 | strict-peer-dependencies=false 7 | ignore-workspace-root-check=true -------------------------------------------------------------------------------- /test/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from 'vite-tsconfig-paths'; 2 | import {defineConfig} from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [tsconfigPaths()] 6 | }); 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .husky 3 | dist 4 | node_modules 5 | pnpm-lock.yaml 6 | !.vscode 7 | !.*.js 8 | !.*.cjs 9 | !.*.mjs 10 | !.*.ts 11 | 12 | .github 13 | .eslintrc 14 | .lintstagedrc.mjs 15 | -------------------------------------------------------------------------------- /.commitlintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Commitlint Config 3 | */ 4 | const commitlintConfig = { 5 | extends: ['@commitlint/config-conventional'], 6 | plugins: ['commitlint-plugin-function-rules'] 7 | }; 8 | 9 | module.exports = commitlintConfig; 10 | -------------------------------------------------------------------------------- /test/constants/path.ts: -------------------------------------------------------------------------------- 1 | import {join, resolve} from 'node:path'; 2 | import {fileURLToPath} from 'node:url'; 3 | 4 | export const TEST_ROOT = resolve(fileURLToPath(import.meta.url), '../..'); 5 | export const TEMP_ADD_DIR = join(TEST_ROOT, 'test-add-action'); 6 | -------------------------------------------------------------------------------- /test/src/components.spec.ts: -------------------------------------------------------------------------------- 1 | import {describe, expect, test} from 'vitest'; 2 | 3 | describe('components', () => { 4 | test('UpdateComponents function ', () => { 5 | // TODO: wait for the canary option merged to finish this test 6 | expect(1).toBe(1); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "paths": { 5 | "test/*": ["test/*"], 6 | "@helpers/*": ["src/helpers/*"], 7 | "src/*": ["src/*"], 8 | "build/*": ["plugins/*"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/codemod/src/types/codemod.ts: -------------------------------------------------------------------------------- 1 | export const codemods = [ 2 | 'import-heroui', 3 | 'package-json-package-name', 4 | 'heroui-provider', 5 | 'tailwindcss-heroui', 6 | 'css-variables', 7 | 'npmrc' 8 | ] as const; 9 | 10 | export type Codemods = (typeof codemods)[number]; 11 | -------------------------------------------------------------------------------- /test/helpers/test-exec.ts: -------------------------------------------------------------------------------- 1 | import {exec} from 'src/helpers/exec'; 2 | 3 | import {TEST_ROOT} from 'test/constants'; 4 | 5 | export const RUN_CLI_CMD = `tsx ../src/index.ts`; 6 | 7 | export const testExec = async (command: string) => { 8 | return await exec(command, {cwd: TEST_ROOT}); 9 | }; 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | 3 | [*] 4 | tab_width=2 5 | indent_size=2 6 | charset=utf-8 7 | end_of_line=unset 8 | indent_style=space 9 | max_line_length=100 10 | insert_final_newline=true 11 | trim_trailing_whitespace=true 12 | 13 | [*.md] 14 | max_line_length=off 15 | trim_trailing_whitespace=false 16 | -------------------------------------------------------------------------------- /src/constants/path.ts: -------------------------------------------------------------------------------- 1 | import {fileURLToPath} from 'node:url'; 2 | 3 | import {resolve} from 'pathe'; 4 | 5 | export const ROOT = process.cwd(); 6 | export const resolver = (path: string) => resolve(ROOT, path); 7 | 8 | export const COMPONENTS_PATH = resolve(fileURLToPath(import.meta.url), '../components.json'); 9 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/actions/lint-affected-files.ts: -------------------------------------------------------------------------------- 1 | import {tryLintFile} from '../lint'; 2 | import {affectedFiles} from '../store'; 3 | 4 | export async function lintAffectedFiles() { 5 | try { 6 | await tryLintFile(Array.from(affectedFiles)); 7 | } catch (error) { 8 | return; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.husky/scripts/update-dep: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | changed_files="$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)" 3 | 4 | check_run() { 5 | if (echo "$changed_files" | grep --quiet "$1"); then 6 | echo "Detected changes in pnpm-lock.yaml, starting dependency update" 7 | eval "$2" 8 | fi 9 | } 10 | 11 | check_run pnpm-lock.yaml "pnpm install --color" 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "npm.packageManager": "pnpm", 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | "typescript.enablePromptUseWorkspaceTsdk": true, 5 | "editor.formatOnSave": false, 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll.eslint": "explicit" 8 | }, 9 | "cSpell.words": ["codemod", "codemods", "heroui", "nextui", "tailwindcss"] 10 | } 11 | -------------------------------------------------------------------------------- /src/scripts/update/update-components.ts: -------------------------------------------------------------------------------- 1 | import {store} from 'src/constants/store'; 2 | 3 | import {initCache} from '../cache/cache'; 4 | import {isGithubAction, updateComponents} from '../helpers'; 5 | 6 | if (!isGithubAction) { 7 | // Won't run on GitHub Actions 8 | initCache(true); 9 | // Update beta components 10 | store.beta = true; 11 | updateComponents(); 12 | } 13 | -------------------------------------------------------------------------------- /src/helpers/actions/init/change-npmrc.ts: -------------------------------------------------------------------------------- 1 | import {writeFileSync} from 'node:fs'; 2 | 3 | const DEFAULT_NPMRC_CONTENT = `package-lock=true`; 4 | 5 | /** 6 | * Change the npmrc file to the default content 7 | * Currently it is using `package-lock=false` which won't generate the lockfile 8 | */ 9 | export function changeNpmrc(npmrcFile: string) { 10 | writeFileSync(npmrcFile, DEFAULT_NPMRC_CONTENT, 'utf-8'); 11 | } 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc.json", 3 | "tabWidth": 2, 4 | "printWidth": 100, 5 | "semi": true, 6 | "useTabs": false, 7 | "singleQuote": true, 8 | "bracketSpacing": false, 9 | "jsxSingleQuote": false, 10 | "bracketSameLine": false, 11 | "endOfLine": "auto", 12 | "arrowParens": "always", 13 | "trailingComma": "none", 14 | "plugins": [], 15 | "overrides": [] 16 | } 17 | -------------------------------------------------------------------------------- /src/helpers/condition-value.ts: -------------------------------------------------------------------------------- 1 | import {store} from 'src/constants/store'; 2 | import {getLatestVersion} from 'src/scripts/helpers'; 3 | 4 | import {getBetaVersion} from './beta'; 5 | 6 | export async function getConditionVersion(packageName: string) { 7 | const conditionVersion = store.beta 8 | ? await getBetaVersion(packageName) 9 | : await getLatestVersion(packageName); 10 | 11 | return conditionVersion; 12 | } 13 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/transform.ts: -------------------------------------------------------------------------------- 1 | import {resolver} from '../constants/path'; 2 | 3 | /** 4 | * Transforms the paths to the correct format 5 | * @param paths - The paths to transform 6 | * @example ['src'] --> ['absolute/path/to/src\/**\/*'] 7 | */ 8 | export function transformPaths(paths?: string[]) { 9 | paths ??= ['.']; 10 | paths = [paths].flat(); 11 | 12 | return paths.map((path) => resolver(path) + '/**/*'); 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers/actions/add/heroui-chat/fetch-package.ts: -------------------------------------------------------------------------------- 1 | import type {SAFE_ANY} from '@helpers/type'; 2 | 3 | import {fetchRequest} from '@helpers/fetch'; 4 | 5 | export async function fetchPackage(pkgFile: string) { 6 | let pkgContent: SAFE_ANY; 7 | 8 | const response = await fetchRequest(pkgFile); 9 | 10 | try { 11 | pkgContent = JSON.parse(await response.text()); 12 | // eslint-disable-next-line no-empty 13 | } catch {} 14 | 15 | return pkgContent; 16 | } 17 | -------------------------------------------------------------------------------- /src/helpers/actions/add/heroui-chat/write-files.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | export function writeFilesWithMkdir(directory: string, file: string, content: string) { 4 | if (file.includes('/')) { 5 | const path = file.split('/').slice(0, -1).join('/'); 6 | 7 | fs.mkdirSync(`${directory}/${path}`, {recursive: true}); 8 | fs.writeFileSync(`${directory}/${file}`, content, 'utf8'); 9 | } else { 10 | fs.writeFileSync(`${directory}/${file}`, content, 'utf8'); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/actions/migrate/migrate-npmrc.ts: -------------------------------------------------------------------------------- 1 | import {HEROUI_PREFIX, NEXTUI_PREFIX} from '../../../constants/prefix'; 2 | import {getStore, writeFileAndUpdateStore} from '../../store'; 3 | 4 | export function migrateNpmrc(files: string[]) { 5 | for (const file of files) { 6 | const rawContent = getStore(file, 'rawContent'); 7 | const content = rawContent.replaceAll(NEXTUI_PREFIX, HEROUI_PREFIX); 8 | 9 | writeFileAndUpdateStore(file, 'rawContent', content); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/codemod/src/constants/prefix.ts: -------------------------------------------------------------------------------- 1 | export const HEROUI_PREFIX = '@heroui'; 2 | export const NEXTUI_PREFIX = '@nextui-org'; 3 | 4 | export const NEXTUI_PROVIDER = 'NextUIProvider'; 5 | export const HEROUI_PROVIDER = 'HeroUIProvider'; 6 | 7 | export const NEXTUI_PLUGIN = 'nextui'; 8 | export const HEROUI_PLUGIN = 'heroui'; 9 | 10 | export const NEXTUI_CSS_VARIABLES_PREFIX = '--nextui-'; 11 | export const HEROUI_CSS_VARIABLES_PREFIX = '--heroui-'; 12 | 13 | export const EXTRA_FILES = ['.storybook']; 14 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/options.ts: -------------------------------------------------------------------------------- 1 | export const CODEMOD_OPTIONS = { 2 | format: false 3 | }; 4 | 5 | export function initOptions(options: {format: boolean}) { 6 | const {format} = options; 7 | 8 | setOptionsValue('format', format); 9 | } 10 | 11 | export function setOptionsValue(key: keyof typeof CODEMOD_OPTIONS, value: boolean) { 12 | CODEMOD_OPTIONS[key] = value; 13 | } 14 | 15 | export function getOptionsValue(key: T) { 16 | return CODEMOD_OPTIONS[key]; 17 | } 18 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "heroui-cli-test", 3 | "type": "module", 4 | "private": true, 5 | "version": "1.0.0", 6 | "description": "test for heroui-cli", 7 | "main": "index.js", 8 | "scripts": { 9 | "test": "vitest", 10 | "test:run": "vitest run" 11 | }, 12 | "devDependencies": { 13 | "vite": "latest", 14 | "vite-tsconfig-paths": "5.1.4", 15 | "vitest": "latest" 16 | }, 17 | "keywords": [ 18 | "heroui", 19 | "cli", 20 | "test" 21 | ], 22 | "author": "HeroUI", 23 | "license": "MIT" 24 | } 25 | -------------------------------------------------------------------------------- /src/helpers/actions/add/heroui-chat/get-base-storage-url.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the base storage url from the given url 3 | */ 4 | export async function getBaseStorageUrl(url: string) { 5 | const httpMatch = url.match(/^https?:\/\//)?.[0] ?? ''; 6 | const [baseUrl, userId, chatId, sandboxId, chatTitle] = url.replace(httpMatch, '').split('/'); 7 | 8 | const baseStorageUrl = `${httpMatch}${baseUrl}/sandbox/files/${chatId}?sandboxId=${sandboxId}`; 9 | 10 | return { 11 | baseStorageUrl, 12 | chatTitle, 13 | sandboxId, 14 | userId: userId ?? '' 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # output 2 | dist 3 | 4 | # ide 5 | .idea 6 | 7 | # dependencies 8 | node_modules 9 | .pnp 10 | .pnp.js 11 | 12 | # testing 13 | /.swc 14 | /coverage 15 | __snapshots__ 16 | test/test* 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # logs 23 | *.log 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | .pnpm-debug.log 30 | 31 | # local env files 32 | .env 33 | .env.local 34 | .env.development.local 35 | .env.test.local 36 | .env.production.local 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | 41 | # lock 42 | yarn.lock 43 | package-lock.json 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: 🤔 Long question or ideas? 4 | url: https://github.com/heroui-inc/heroui-cli/discussions 5 | about: Ask long-form questions and discuss ideas. 6 | - name: 💬 Discord Community Chat 7 | url: https://discord.gg/9b6yyZKmH4 8 | about: Ask quick questions or simply chat on the `HeroUI` community Discord server. 9 | - name: 💬 New Updates (Twitter) 10 | url: https://twitter.com/hero_ui 11 | about: Link to our twitter account if you want to follow us and stay up to date with HeroUI news 12 | -------------------------------------------------------------------------------- /plugins/plugin-copy.ts: -------------------------------------------------------------------------------- 1 | import type {Options} from 'tsup'; 2 | 3 | import {copyFileSync} from 'node:fs'; 4 | 5 | import {resolver} from 'src/constants/path'; 6 | 7 | export function copy(src: string, dest: string) { 8 | copyFileSync(src, dest); 9 | } 10 | 11 | function copyComponents() { 12 | copy(resolver('src/constants/components.json'), resolver('dist/components.json')); 13 | } 14 | 15 | export function pluginCopyComponents(): Required['plugins'][number] { 16 | return { 17 | buildEnd: () => { 18 | copyComponents(); 19 | }, 20 | name: 'copyComponents' 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /test/constants/template.ts: -------------------------------------------------------------------------------- 1 | interface PkgTemplate { 2 | dependencies?: Record; 3 | devDependencies?: Record; 4 | } 5 | 6 | export function getPkgTemplate(options: PkgTemplate = {}) { 7 | return JSON.stringify( 8 | { 9 | dependencies: { 10 | ...options.dependencies 11 | }, 12 | devDependencies: { 13 | ...options.devDependencies 14 | }, 15 | license: 'MIT', 16 | name: 'heroui-cli-pkg-template', 17 | private: false, 18 | type: 'module', 19 | version: '1.0.0' 20 | }, 21 | null, 22 | 2 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'tsup'; 2 | 3 | import {pluginCopyComponents} from 'build/plugin-copy'; 4 | 5 | export default defineConfig((options) => { 6 | return { 7 | banner: {js: '#!/usr/bin/env node'}, 8 | clean: true, 9 | dts: true, 10 | entry: ['src/index.ts'], 11 | format: ['esm'], 12 | minify: !options.watch, 13 | outDir: 'dist', 14 | plugins: [pluginCopyComponents()], 15 | skipNodeModulesBundle: true, 16 | sourcemap: true, 17 | splitting: false, 18 | target: 'esnext', 19 | treeshake: true, 20 | tsconfig: 'tsconfig.json' 21 | }; 22 | }); 23 | -------------------------------------------------------------------------------- /packages/codemod/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'tsup'; 2 | 3 | export default defineConfig((options) => { 4 | return { 5 | banner: {js: '#!/usr/bin/env node'}, 6 | clean: true, 7 | dts: false, 8 | entry: ['src/index.ts'], 9 | format: ['esm'], 10 | minify: !options.watch, 11 | noExternal: ['jscodeshift/parser/babylon', 'jscodeshift/parser/tsOptions', '@babel/parser'], 12 | outDir: 'dist', 13 | skipNodeModulesBundle: true, 14 | sourcemap: true, 15 | splitting: false, 16 | target: 'esnext', 17 | treeshake: true, 18 | tsconfig: 'tsconfig.json' 19 | }; 20 | }); 21 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/find-files.ts: -------------------------------------------------------------------------------- 1 | import fg from 'fast-glob'; 2 | 3 | interface FindFilesOptions extends fg.Options { 4 | ext?: string; 5 | } 6 | 7 | export const findFiles = async (paths: string[], options: FindFilesOptions = {}) => { 8 | const {ext, ...fgOptions} = options; 9 | 10 | if (ext) { 11 | paths = paths.map((path) => `${path}.${ext}`); 12 | } 13 | 14 | const files = await fg.glob(paths, { 15 | absolute: true, 16 | cwd: process.cwd(), 17 | ignore: ['**/node_modules', '**/dist', '**/*.d.ts', '**/build', '**/output'], 18 | onlyFiles: true, 19 | ...fgOptions 20 | }); 21 | 22 | return files; 23 | }; 24 | -------------------------------------------------------------------------------- /src/scripts/path.ts: -------------------------------------------------------------------------------- 1 | import {existsSync} from 'node:fs'; 2 | import {fileURLToPath} from 'node:url'; 3 | 4 | import {join, resolve} from 'pathe'; 5 | 6 | export const ROOT = resolve(fileURLToPath(import.meta.url), '../..'); 7 | 8 | export const resolver = (path: string) => resolve(ROOT, path); 9 | 10 | const PROD_DIR = resolve(fileURLToPath(import.meta.url), '..'); 11 | const PROD = existsSync(join(PROD_DIR, 'components.json')); 12 | 13 | export const CACHE_DIR = PROD 14 | ? resolve(`${PROD_DIR}/.heroui-cli-cache`) 15 | : resolve(join(ROOT, '..'), 'node_modules/.heroui-cli-cache'); 16 | export const CACHE_PATH = resolve(`${CACHE_DIR}/data.json`); 17 | -------------------------------------------------------------------------------- /src/helpers/init.ts: -------------------------------------------------------------------------------- 1 | import type {SAFE_ANY} from './type'; 2 | 3 | import {type InitActionOptions, templatesMap} from 'src/actions/init-action'; 4 | 5 | import {AGENTS, type Agent} from './detect'; 6 | import {printMostMatchText} from './math-diff'; 7 | 8 | export function checkInitOptions(template: InitActionOptions['template'], agent: Agent) { 9 | if (template) { 10 | if (!Object.keys(templatesMap).includes(template)) { 11 | printMostMatchText(Object.keys(templatesMap), template); 12 | } 13 | } 14 | if (agent) { 15 | if (!AGENTS.includes(agent)) { 16 | printMostMatchText(AGENTS as SAFE_ANY, agent); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/actions/migrate/migrate-left-files.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HEROUI_PLUGIN, 3 | HEROUI_PREFIX, 4 | NEXTUI_PLUGIN, 5 | NEXTUI_PREFIX 6 | } from '../../../constants/prefix'; 7 | import {getStore, updateAffectedFiles, writeFileAndUpdateStore} from '../../store'; 8 | 9 | export function migrateLeftFiles(files: string[]) { 10 | for (const file of files) { 11 | const rawContent = getStore(file, 'rawContent'); 12 | const replaceContent = rawContent 13 | .replaceAll(NEXTUI_PREFIX, HEROUI_PREFIX) 14 | .replaceAll(NEXTUI_PLUGIN, HEROUI_PLUGIN); 15 | 16 | writeFileAndUpdateStore(file, 'rawContent', replaceContent); 17 | updateAffectedFiles(file); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | import type {Codemods} from '../types'; 2 | 3 | import {detect} from '@helpers/detect'; 4 | 5 | import {NEXTUI_PREFIX} from '../constants/prefix'; 6 | 7 | import {getStore} from './store'; 8 | 9 | export function getCanRunCodemod(codemod: Codemods, targetName: Codemods) { 10 | return codemod === undefined || codemod === targetName; 11 | } 12 | 13 | export function filterNextuiFiles(files: string[]) { 14 | return files.filter((file) => new RegExp(NEXTUI_PREFIX, 'g').test(getStore(file, 'rawContent'))); 15 | } 16 | 17 | export async function getInstallCommand() { 18 | const packageManager = await detect(); 19 | 20 | return { 21 | cmd: `${packageManager} install`, 22 | packageManager 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/helpers/setup.ts: -------------------------------------------------------------------------------- 1 | import type {Agent} from './detect'; 2 | 3 | import {existsSync, writeFileSync} from 'node:fs'; 4 | 5 | import {resolver} from 'src/constants/path'; 6 | import {pnpmRequired} from 'src/constants/required'; 7 | 8 | import {checkPnpm} from './check'; 9 | import {fixPnpm} from './fix'; 10 | 11 | export async function setupPnpm(packageManager: Agent) { 12 | if (packageManager === 'pnpm') { 13 | const npmrcPath = resolver('.npmrc'); 14 | 15 | if (!existsSync(npmrcPath)) { 16 | writeFileSync(resolver('.npmrc'), pnpmRequired.content, 'utf-8'); 17 | } else { 18 | const [isCorrectPnpm] = checkPnpm(npmrcPath); 19 | 20 | if (!isCorrectPnpm) { 21 | fixPnpm(npmrcPath); 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/actions/env-action.ts: -------------------------------------------------------------------------------- 1 | import {outputComponents, outputInfo} from '@helpers/output-info'; 2 | import {getPackageInfo} from '@helpers/package'; 3 | import {resolver} from 'src/constants/path'; 4 | 5 | interface EnvActionOptions { 6 | packagePath?: string; 7 | } 8 | 9 | export async function envAction(options: EnvActionOptions) { 10 | const {packagePath = resolver('package.json')} = options; 11 | 12 | const {currentComponents} = getPackageInfo(packagePath); 13 | 14 | /** ======================== Output the current components ======================== */ 15 | outputComponents({components: currentComponents}); 16 | 17 | /** ======================== Output the system environment info ======================== */ 18 | outputInfo(); 19 | 20 | process.exit(0); 21 | } 22 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/actions/migrate/migrate-css-variables.ts: -------------------------------------------------------------------------------- 1 | import {HEROUI_CSS_VARIABLES_PREFIX, NEXTUI_CSS_VARIABLES_PREFIX} from '../../../constants/prefix'; 2 | import {getStore, updateAffectedFiles, writeFileAndUpdateStore} from '../../store'; 3 | 4 | export function migrateCssVariables(files: string[]) { 5 | for (const file of files) { 6 | const rawContent = getStore(file, 'rawContent'); 7 | const dirtyFlag = rawContent.includes(NEXTUI_CSS_VARIABLES_PREFIX); 8 | 9 | if (dirtyFlag) { 10 | const content = rawContent.replaceAll( 11 | NEXTUI_CSS_VARIABLES_PREFIX, 12 | HEROUI_CSS_VARIABLES_PREFIX 13 | ); 14 | 15 | writeFileAndUpdateStore(file, 'rawContent', content); 16 | updateAffectedFiles(file); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/prompts/get-beta-version-select.ts: -------------------------------------------------------------------------------- 1 | import {getBetaVersionData} from '@helpers/beta'; 2 | 3 | import {getSelect} from './index'; 4 | 5 | export async function getBetaVersionSelect(components: string[]) { 6 | const result: string[] = []; 7 | 8 | for (const component of components) { 9 | const betaVersionData = JSON.parse(await getBetaVersionData(component)); 10 | 11 | const selectedResult = await getSelect( 12 | `Select beta version of ${component}`, 13 | Object.values(betaVersionData).map((value) => { 14 | const betaVersion = `${component}@${value}`; 15 | 16 | return { 17 | title: betaVersion, 18 | value: betaVersion 19 | }; 20 | }) 21 | ); 22 | 23 | result.push(selectedResult); 24 | } 25 | 26 | return result; 27 | } 28 | -------------------------------------------------------------------------------- /packages/codemod/src/actions/codemod-action.ts: -------------------------------------------------------------------------------- 1 | import {Logger} from '@helpers/logger'; 2 | 3 | import {type Codemods, codemods} from '../types'; 4 | 5 | import {migrateAction} from './migrate-action'; 6 | 7 | function printUsage() { 8 | Logger.grey('Usage: '); 9 | Logger.log(`heroui [codemod]`); 10 | Logger.log(`heroui migrate [projectPath]`); 11 | Logger.newLine(); 12 | Logger.grey('Codemods:'); 13 | Logger.log(`- ${codemods.join('\n- ')}`); 14 | } 15 | 16 | export async function codemodAction(codemod: Codemods) { 17 | if (!codemod) { 18 | printUsage(); 19 | 20 | process.exit(0); 21 | } else if (!codemods.includes(codemod)) { 22 | Logger.error(`Codemod "${codemod}" is invalid`); 23 | Logger.newLine(); 24 | 25 | printUsage(); 26 | process.exit(0); 27 | } 28 | 29 | migrateAction(['.'], {codemod}); 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | next: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | script: [build] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - name: Checkout codebase 18 | uses: actions/checkout@v3 19 | - name: Setup pnpm 20 | uses: pnpm/action-setup@v2 21 | with: 22 | run_install: false 23 | - name: Setup node 24 | uses: actions/setup-node@v3 25 | with: 26 | cache: 'pnpm' 27 | check-latest: true 28 | node-version-file: '.nvmrc' 29 | - name: Install dependencies 30 | run: pnpm install --frozen-lockfile 31 | - name: Run build script 32 | run: pnpm ${{ matrix.script }} 33 | -------------------------------------------------------------------------------- /src/helpers/actions/upgrade/upgrade-types.ts: -------------------------------------------------------------------------------- 1 | import type {RequiredKey, SAFE_ANY} from '@helpers/type'; 2 | import type {Dependencies} from 'src/scripts/helpers'; 3 | 4 | export interface Upgrade { 5 | isHeroUIAll: boolean; 6 | allDependencies?: Record; 7 | upgradeOptionList?: UpgradeOption[]; 8 | all?: boolean; 9 | } 10 | 11 | export type ExtractUpgrade = T extends {isHeroUIAll: infer U} 12 | ? U extends true 13 | ? RequiredKey 14 | : RequiredKey 15 | : T; 16 | 17 | export type MissingDepSetType = { 18 | name: string; 19 | version: string; 20 | }; 21 | 22 | export interface UpgradeOption { 23 | package: string; 24 | version: string; 25 | latestVersion: string; 26 | isLatest: boolean; 27 | versionMode: string; 28 | peerDependencies?: Dependencies; 29 | } 30 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/bar.ts: -------------------------------------------------------------------------------- 1 | import c from 'chalk'; 2 | import {MultiBar, Presets, SingleBar} from 'cli-progress'; 3 | 4 | export function createSingleProgressBar() { 5 | return new SingleBar( 6 | { 7 | barsize: 40, 8 | clearOnComplete: true, 9 | format: `${c.green('{head}')} ${c.green('{bar}')} | {percentage}% || {value}/{total} || time: {duration}s | ${c.gray('{name}')}`, 10 | hideCursor: true, 11 | linewrap: false 12 | }, 13 | Presets.shades_classic 14 | ); 15 | } 16 | 17 | export function createMultiProgressBar() { 18 | return new MultiBar( 19 | { 20 | barsize: 40, 21 | clearOnComplete: true, 22 | format: `${c.green('{head}')} ${c.green('{bar}')} | {percentage}% || {value}/{total} || time: {duration}s | ${c.gray('{name}')}`, 23 | hideCursor: true, 24 | linewrap: false 25 | }, 26 | Presets.shades_classic 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/helpers/actions/add/heroui-chat/get-codebase-files.ts: -------------------------------------------------------------------------------- 1 | import type {SAFE_ANY} from '@helpers/type'; 2 | 3 | import {fetchRequest} from '@helpers/fetch'; 4 | 5 | export const CODEBASE_FILES = [ 6 | 'index.html', 7 | 'package.json', 8 | 'postcss.config.js', 9 | 'src/App.tsx', 10 | 'src/index.css', 11 | 'src/main.tsx', 12 | 'tailwind.config.js', 13 | 'tsconfig.json', 14 | 'vite.config.ts' 15 | ]; 16 | 17 | export interface CodeBaseFile { 18 | name: string; 19 | type: 'file' | 'directory'; 20 | isSymlink: boolean; 21 | content: string; 22 | } 23 | 24 | export async function getCodeBaseFiles(url: string, userId: string): Promise { 25 | const response = await fetchRequest(url, { 26 | fetchInfo: 'codebase files', 27 | headers: {userId} 28 | }); 29 | 30 | const data = await response.json(); 31 | 32 | return ((data as SAFE_ANY).entries ?? {}) as CodeBaseFile[]; 33 | } 34 | -------------------------------------------------------------------------------- /src/helpers/actions/upgrade/catch-pnpm-exec.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | import {exec} from '@helpers/exec'; 4 | import {selectClack} from 'src/prompts/clack'; 5 | 6 | export async function catchPnpmExec(execFn: () => Promise) { 7 | try { 8 | await execFn(); 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | } catch (error: any) { 11 | if (error) { 12 | const reRunPnpm = await selectClack({ 13 | message: `${chalk.red('Error: ')}a unexpected error occurred, run "pnpm install" maybe can fix it or report it as a bug`, 14 | options: [ 15 | {label: 'Re-run pnpm install', value: 're-run-pnpm-install'}, 16 | {label: 'Exit', value: 'exit'} 17 | ] 18 | }); 19 | 20 | if (reRunPnpm === 'exit') { 21 | process.exit(1); 22 | } 23 | 24 | await exec('pnpm install --force'); 25 | await execFn(); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | commitlint: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | runs-on: ${{ matrix.os }} 15 | steps: 16 | - name: Checkout codebase 17 | uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | - name: Setup pnpm 21 | uses: pnpm/action-setup@v2 22 | with: 23 | run_install: false 24 | - name: Setup node 25 | uses: actions/setup-node@v3 26 | with: 27 | cache: 'pnpm' 28 | check-latest: true 29 | node-version-file: '.nvmrc' 30 | - name: Install dependencies 31 | run: pnpm install --frozen-lockfile 32 | - name: Check eslint 33 | run: pnpm lint 34 | - name: Check Prettier 35 | run: pnpm check:prettier 36 | - name: Check type 37 | run: pnpm check:types 38 | -------------------------------------------------------------------------------- /.lintstagedrc.mjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { relative } from 'path'; 4 | 5 | import { ESLint } from 'eslint'; 6 | 7 | const removeIgnoredFiles = async (files) => { 8 | const cwd = process.cwd(); 9 | const eslint = new ESLint(); 10 | const relativePaths = files.map((file) => relative(cwd, file)); 11 | const isIgnored = await Promise.all(relativePaths.map((file) => eslint.isPathIgnored(file))); 12 | const filteredFiles = files.filter((_, i) => !isIgnored[i]); 13 | 14 | return filteredFiles.join(' '); 15 | }; 16 | 17 | /** 18 | * Lint Staged Config 19 | */ 20 | const lintStaged = { 21 | '**/*.{cjs,mjs,js,ts}': async (files) => { 22 | const filesToLint = await removeIgnoredFiles(files); 23 | 24 | return [`eslint -c .eslintrc --max-warnings=0 --fix ${filesToLint}`]; 25 | }, 26 | '**/*.{json,md}': async (files) => { 27 | const filesToLint = await removeIgnoredFiles(files); 28 | 29 | return [`prettier --config .prettierrc --ignore-path --write ${filesToLint}`]; 30 | } 31 | }; 32 | 33 | export default lintStaged; 34 | -------------------------------------------------------------------------------- /src/helpers/actions/add/template.ts: -------------------------------------------------------------------------------- 1 | export const templates = { 2 | '.gitignore': { 3 | content: 4 | '# output\n/dist\n\n# ide\n/.idea\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/.swc\n/coverage\n__snapshots__\n\n\n# misc\n.DS_Store\n*.pem\n\n# logs\n*.log\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.pnpm-debug.log\n\n# local env files\n.env\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\n# typescript\n*.tsbuildinfo\n\n# lock\nyarn.lock\npackage-lock.json' 5 | }, 6 | '.npmrc': { 7 | content: 'public-hoist-pattern[]=*@heroui/*' 8 | }, 9 | 'README.md': { 10 | content: 11 | '# React + Tailwind\n\nThis template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. One top of the standard Vite setup, [tailwindcss](https://tailwindcss.com/) is installed and ready to be used in React components.\n\nAdditional references:\n* [Getting started with Vite](https://vitejs.dev/guide/)\n* [Tailwind documentation](https://tailwindcss.com/docs/installation)\n\n' 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Next UI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | workflow_dispatch: # Allows manual triggering of the workflow 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set node 21 | uses: actions/setup-node@v4 22 | with: 23 | registry-url: https://registry.npmjs.org/ 24 | node-version: lts/* 25 | 26 | - run: npx changelogithub 27 | env: 28 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 29 | continue-on-error: true # Don't block the release, release notes can be added manually 30 | 31 | - name: Setup pnpm 32 | uses: pnpm/action-setup@v2 33 | 34 | - name: Install dependencies 35 | run: pnpm install --frozen-lockfile 36 | 37 | - name: Publish to npm 38 | run: | 39 | npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN 40 | npm publish 41 | env: 42 | NPM_TOKEN: ${{secrets.NPM_TOKEN}} -------------------------------------------------------------------------------- /src/helpers/math-diff.ts: -------------------------------------------------------------------------------- 1 | import {Logger} from './logger'; 2 | 3 | function matchTextScore(text: string, pattern: string) { 4 | let score = 0; 5 | const textLength = text.length; 6 | const patternLength = pattern.length; 7 | let i = 0; 8 | let j = 0; 9 | 10 | while (i < textLength && j < patternLength) { 11 | if (text[i] === pattern[j]) { 12 | score++; 13 | j++; 14 | } 15 | 16 | i++; 17 | } 18 | 19 | return score; 20 | } 21 | 22 | export function findMostMatchText(list: string[], pattern: string) { 23 | let maxScore = 0; 24 | let result = ''; 25 | 26 | for (const text of list) { 27 | const score = matchTextScore(text, pattern); 28 | 29 | if (score > maxScore) { 30 | maxScore = score; 31 | result = text; 32 | } 33 | } 34 | 35 | return result !== '' ? result : null; 36 | } 37 | 38 | export function printMostMatchText(list: string[], pattern: string) { 39 | const mathOption = findMostMatchText(list, pattern); 40 | 41 | if (mathOption) { 42 | Logger.error(`Unknown option '${pattern}', Did you mean '${mathOption}'?`); 43 | } else { 44 | Logger.error(`Unknown option '${pattern}'`); 45 | } 46 | process.exit(1); 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yml: -------------------------------------------------------------------------------- 1 | name: Commitlint 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | commitlint: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | runs-on: ${{ matrix.os }} 15 | steps: 16 | - name: Checkout codebase 17 | uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | - name: Setup pnpm 21 | uses: pnpm/action-setup@v2 22 | with: 23 | run_install: false 24 | - name: Setup node 25 | uses: actions/setup-node@v3 26 | with: 27 | cache: 'pnpm' 28 | check-latest: true 29 | node-version-file: '.nvmrc' 30 | - name: Install dependencies 31 | run: pnpm install --frozen-lockfile 32 | - name: Run commitlint 33 | id: run_commitlint 34 | uses: wagoid/commitlint-github-action@v5 35 | with: 36 | configFile: .commitlintrc.js 37 | env: 38 | NODE_PATH: ${{ github.workspace }}/node_modules 39 | - name: Show outputs 40 | if: ${{ always() }} 41 | run: echo ${{ toJSON(steps.run_commitlint.outputs.results) }} 42 | -------------------------------------------------------------------------------- /src/constants/box.ts: -------------------------------------------------------------------------------- 1 | export const boxRound = { 2 | bold: { 3 | bottomLeft: '┗', 4 | bottomRight: '┛', 5 | horizontal: '━', 6 | topLeft: '┏', 7 | topRight: '┓', 8 | vertical: '┃' 9 | }, 10 | classic: { 11 | bottomLeft: '+', 12 | bottomRight: '+', 13 | horizontal: '-', 14 | topLeft: '+', 15 | topRight: '+', 16 | vertical: '|' 17 | }, 18 | double: { 19 | bottomLeft: '╚', 20 | bottomRight: '╝', 21 | horizontal: '═', 22 | topLeft: '╔', 23 | topRight: '╗', 24 | vertical: '║' 25 | }, 26 | doubleSingle: { 27 | bottomLeft: '╘', 28 | bottomRight: '╛', 29 | horizontal: '═', 30 | topLeft: '╒', 31 | topRight: '╕', 32 | vertical: '│' 33 | }, 34 | round: { 35 | bottomLeft: '╰', 36 | bottomRight: '╯', 37 | horizontal: '─', 38 | topLeft: '╭', 39 | topRight: '╮', 40 | vertical: '│' 41 | }, 42 | single: { 43 | bottomLeft: '└', 44 | bottomRight: '┘', 45 | horizontal: '─', 46 | topLeft: '┌', 47 | topRight: '┐', 48 | vertical: '│' 49 | }, 50 | singleDouble: { 51 | bottomLeft: '╙', 52 | bottomRight: '╜', 53 | horizontal: '─', 54 | topLeft: '╓', 55 | topRight: '╖', 56 | vertical: '║' 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/helpers/detect.ts: -------------------------------------------------------------------------------- 1 | import type {SAFE_ANY} from './type'; 2 | 3 | import {findUp} from 'find-up'; 4 | import path from 'pathe'; 5 | 6 | import {ROOT} from 'src/constants/path'; 7 | import {getSelect} from 'src/prompts'; 8 | 9 | type TupleToUnion = T[number]; 10 | 11 | export const AGENTS = ['npm', 'bun', 'pnpm', 'yarn'] as const; 12 | 13 | export type Agent = TupleToUnion; 14 | 15 | export const agents = AGENTS; 16 | 17 | // the order here matters, more specific one comes first 18 | export const LOCKS: Record = { 19 | 'bun.lock': 'bun', 20 | 'bun.lockb': 'bun', 21 | 'npm-shrinkwrap.json': 'npm', 22 | 'package-lock.json': 'npm', 23 | 'pnpm-lock.yaml': 'pnpm', 24 | 'yarn.lock': 'yarn' 25 | }; 26 | 27 | export async function detect(cwd = ROOT) { 28 | let agent: Agent; 29 | const lockPath = await findUp(Object.keys(LOCKS), {cwd}); 30 | 31 | // detect based on lock 32 | if (lockPath) { 33 | agent = LOCKS[path.basename(lockPath)]!; 34 | } else { 35 | agent = await getSelect( 36 | 'No agent found, please choose one', 37 | AGENTS.map((agent) => ({ 38 | title: agent, 39 | value: agent 40 | })) 41 | ); 42 | } 43 | 44 | return agent; 45 | } 46 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/actions/migrate/migrate-nextui-provider.ts: -------------------------------------------------------------------------------- 1 | import {HEROUI_PROVIDER, NEXTUI_PROVIDER} from '../../../constants/prefix'; 2 | import {getStore, updateAffectedFiles, writeFileAndUpdateStore} from '../../store'; 3 | 4 | import {migrateByRegex} from './migrate-common'; 5 | 6 | /** 7 | * Migrate the NextUIProvider to HeroUIProvider will directly write the file 8 | * @example 9 | * migrateNextuiProvider(['xxx']); 10 | * -> 11 | */ 12 | export function migrateNextuiProvider(paths: string[]) { 13 | for (const path of paths) { 14 | try { 15 | let rawContent = getStore(path, 'rawContent'); 16 | let dirtyFlag = false; 17 | 18 | if (!rawContent) { 19 | continue; 20 | } 21 | 22 | // Replace JSX element NextUIProvider with HeroUIProvider 23 | // Replace NextUIProvider with HeroUIProvider in import statements 24 | ({dirtyFlag, rawContent} = migrateByRegex(rawContent, NEXTUI_PROVIDER, HEROUI_PROVIDER)); 25 | 26 | if (dirtyFlag) { 27 | // Write the modified content back to the file 28 | writeFileAndUpdateStore(path, 'rawContent', rawContent); 29 | updateAffectedFiles(path); 30 | } 31 | // eslint-disable-next-line no-empty 32 | } catch {} 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/helpers/exec.ts: -------------------------------------------------------------------------------- 1 | import type {AppendKeyValue} from './type'; 2 | 3 | import {type CommonExecOptions, execSync} from 'node:child_process'; 4 | 5 | import {Logger} from './logger'; 6 | import {omit} from './utils'; 7 | 8 | const execCache = new Map(); 9 | 10 | /** 11 | * Execute a command and return the output 12 | * 13 | * Recommend use `getCacheExecData` instead if you want to cache the output 14 | */ 15 | export async function exec( 16 | cmd: string, 17 | commonExecOptions?: AppendKeyValue & { 18 | cache?: boolean; 19 | } 20 | ) { 21 | return new Promise((resolve, reject) => { 22 | try { 23 | const {cache = true, logCmd = true} = commonExecOptions || {}; 24 | 25 | if (execCache.has(cmd) && cache) { 26 | resolve(execCache.get(cmd)); 27 | } 28 | 29 | if (logCmd) { 30 | Logger.newLine(); 31 | Logger.log(`${cmd}`); 32 | } 33 | 34 | const stdout = execSync(cmd, { 35 | stdio: 'inherit', 36 | ...(commonExecOptions ? omit(commonExecOptions, ['logCmd']) : {}) 37 | }); 38 | 39 | if (stdout) { 40 | const output = stdout.toString(); 41 | 42 | resolve(output); 43 | execCache.set(cmd, output); 44 | } 45 | resolve(''); 46 | } catch (error) { 47 | reject(error); 48 | } 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /src/scripts/sync/index.ts: -------------------------------------------------------------------------------- 1 | import {readFileSync, writeFileSync} from 'node:fs'; 2 | 3 | import {resolver} from 'src/constants/path'; 4 | 5 | export function syncDocs() { 6 | const docs = readFileSync(resolver('README.md'), 'utf-8'); 7 | const matchDocs = docs.match(/(?<=Usage: heroui \[command]\n\n)[\W\w]+(?=## Documentation)/)?.[0]; 8 | 9 | const targetPath = resolver('heroui/apps/docs/content/docs/api-references/cli-api.mdx'); 10 | const targetDocs = readFileSync(targetPath, 'utf-8'); 11 | const replaceTargetDocs = targetDocs.replace(/(?<=Usage: heroui \[command])[\W\w]+/, ''); 12 | let writeDocs = `${replaceTargetDocs}\n\n${matchDocs?.replace(/\n$/, '')}`; 13 | 14 | writeDocs = writeDocs.replaceAll(/```bash/g, '```codeBlock bash'); 15 | 16 | writeFileSync(targetPath, writeDocs, 'utf-8'); 17 | 18 | syncApiRoutes(); 19 | } 20 | 21 | function syncApiRoutes() { 22 | const targetPath = resolver('heroui/apps/docs/config/routes.json'); 23 | const targetDocs = JSON.parse(readFileSync(targetPath, 'utf-8')); 24 | 25 | targetDocs.routes.forEach((route) => { 26 | if (route.key === 'api-references') { 27 | route.routes.forEach((apiRoute) => { 28 | if (apiRoute.key === 'cli-api') { 29 | apiRoute.updated = true; 30 | } 31 | }); 32 | } 33 | }); 34 | 35 | writeFileSync(targetPath, `${JSON.stringify(targetDocs, null, 2)}\n`, 'utf-8'); 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "baseUrl": ".", 6 | "rootDir": ".", 7 | "lib": ["ESNext"], 8 | "target": "ES2019", 9 | "moduleDetection": "auto", 10 | "moduleResolution": "Node", 11 | "allowJs": true, 12 | "allowUnreachableCode": false, 13 | "allowUnusedLabels": false, 14 | "checkJs": true, 15 | "declaration": true, 16 | "declarationMap": true, 17 | "esModuleInterop": true, 18 | "exactOptionalPropertyTypes": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noImplicitOverride": true, 24 | "noImplicitReturns": true, 25 | "noPropertyAccessFromIndexSignature": true, 26 | "noUncheckedIndexedAccess": true, 27 | "noUnusedLocals": true, 28 | "noUnusedParameters": true, 29 | "resolveJsonModule": true, 30 | "skipLibCheck": true, 31 | "sourceMap": true, 32 | "strict": true, 33 | "verbatimModuleSyntax": true, 34 | "noImplicitAny": false, 35 | "paths": { 36 | "@helpers/*": ["src/helpers/*"], 37 | "src/*": ["src/*"], 38 | "build/*": ["plugins/*"] 39 | } 40 | }, 41 | "include": ["**/*.js", "**/*.cjs", "**/*.mjs", "**/*.ts", "**/*.tsx", "**/*.d.ts"], 42 | "exclude": [".github", ".husky", "dist", "node_modules"] 43 | } 44 | -------------------------------------------------------------------------------- /src/actions/list-action.ts: -------------------------------------------------------------------------------- 1 | import {Logger} from '@helpers/logger'; 2 | import {outputComponents} from '@helpers/output-info'; 3 | import {getPackageInfo} from '@helpers/package'; 4 | import {store} from 'src/constants/store'; 5 | 6 | import {type HeroUIComponents} from '../../src/constants/component'; 7 | import {resolver} from '../../src/constants/path'; 8 | 9 | interface ListActionOptions { 10 | remote?: boolean; 11 | packagePath?: string; 12 | } 13 | 14 | export async function listAction(options: ListActionOptions) { 15 | const {packagePath = resolver('package.json'), remote = false} = options; 16 | 17 | let components = store.heroUIComponents as HeroUIComponents; 18 | 19 | try { 20 | /** ======================== Get the installed components ======================== */ 21 | if (!remote) { 22 | const {currentComponents} = await getPackageInfo(packagePath); 23 | 24 | components = currentComponents; 25 | } 26 | 27 | if (!components.length) { 28 | Logger.warn(`No HeroUI components detected in the specified package.json at: ${packagePath}`); 29 | 30 | return; 31 | } 32 | 33 | /** ======================== Output the components ======================== */ 34 | remote ? outputComponents({commandName: 'list', components}) : outputComponents({components}); 35 | } catch (error) { 36 | Logger.prefix('error', `An error occurred while attempting to list the components: ${error}`); 37 | } 38 | 39 | process.exit(0); 40 | } 41 | -------------------------------------------------------------------------------- /src/helpers/beta.ts: -------------------------------------------------------------------------------- 1 | import {store} from 'src/constants/store'; 2 | import {getCacheExecData} from 'src/scripts/cache/cache'; 3 | 4 | import {Logger} from './logger'; 5 | 6 | export async function getBetaVersionData(component: string) { 7 | const data = await getCacheExecData( 8 | `npm view ${component} dist-tags --json`, 9 | `Fetching ${component} tags` 10 | ); 11 | 12 | return data; 13 | } 14 | 15 | export function getPrefixComponent(component: string) { 16 | return `@heroui/${component.replace('@heroui/', '')}`; 17 | } 18 | 19 | export async function getBetaVersion(componentName: string) { 20 | if (store.betaHeroUIComponentsPackageMap[componentName]) { 21 | return store.betaHeroUIComponentsPackageMap[componentName]!.version; 22 | } 23 | 24 | const data = await getBetaVersionData(componentName); 25 | 26 | try { 27 | return JSON.parse(data).beta; 28 | } catch (error) { 29 | Logger.error(`Get beta version error: ${error}`); 30 | process.exit(1); 31 | } 32 | } 33 | 34 | /** 35 | * @example Input: ["drawer"] 36 | * 37 | * Return: 38 | * ["@heroui/drawer@beta"] 39 | */ 40 | export async function getBetaComponents(components: string[]) { 41 | const componentsVersionList = await Promise.all( 42 | components.map(getPrefixComponent).map(async (c) => { 43 | const version = await getBetaVersion(c); 44 | 45 | return `${getPrefixComponent(c)}@${version}`; 46 | }) 47 | ); 48 | 49 | return componentsVersionList; 50 | } 51 | -------------------------------------------------------------------------------- /src/helpers/actions/add/get-peer-pakcage-version.ts: -------------------------------------------------------------------------------- 1 | import {transformPeerVersion} from '@helpers/utils'; 2 | import {getConditionComponentData} from 'src/constants/component'; 3 | import {compareVersions} from 'src/scripts/helpers'; 4 | 5 | /** 6 | * Find the peer package version 7 | * @param peerPackageName 8 | * @param isMinVersion default is true 9 | * @example 10 | * components: [ 11 | * { 12 | * peerDependencies: { 13 | * 'react': '18.0.0' 14 | * } 15 | * }, 16 | * { 17 | * peerDependencies: { 18 | * 'react': '18.2.0' 19 | * } 20 | * } 21 | * ] 22 | * 23 | * getPeerPackageVersion('react') --> 18.0.0 24 | * getPeerPackageVersion('react', false) --> 18.2.0 25 | */ 26 | export function getPeerPackageVersion(peerPackageName: string, isMinVersion = true) { 27 | const components = getConditionComponentData().components; 28 | const filerTargetPackages = components.filter( 29 | (component) => component.peerDependencies[peerPackageName] 30 | ); 31 | let version = ''; 32 | 33 | if (isMinVersion) { 34 | const versionList = filerTargetPackages.map( 35 | (component) => component.peerDependencies[peerPackageName] 36 | ); 37 | const minVersion = versionList.reduce((min, version) => { 38 | return compareVersions(min, version) > 0 ? version : min; 39 | }); 40 | 41 | version = minVersion || ''; 42 | } else { 43 | version = filerTargetPackages[0]?.version || ''; 44 | } 45 | 46 | return transformPeerVersion(version); 47 | } 48 | -------------------------------------------------------------------------------- /src/helpers/actions/upgrade/get-libs-data.ts: -------------------------------------------------------------------------------- 1 | import type {UpgradeOption} from './upgrade-types'; 2 | import type {SAFE_ANY} from '@helpers/type'; 3 | 4 | import {getConditionVersion} from '@helpers/condition-value'; 5 | import {getColorVersion, getVersionAndMode} from '@helpers/utils'; 6 | import {HEROUI_PREFIX} from 'src/constants/required'; 7 | import {store} from 'src/constants/store'; 8 | import {compareVersions} from 'src/scripts/helpers'; 9 | 10 | export async function getLibsData( 11 | allDependencies: Record 12 | ): Promise { 13 | const allDependenciesKeys = Object.keys(allDependencies); 14 | 15 | const allLibs = allDependenciesKeys.filter((dependency) => { 16 | return ( 17 | !store.heroUIcomponentsPackages.includes(dependency) && dependency.startsWith(HEROUI_PREFIX) 18 | ); 19 | }); 20 | 21 | if (!allLibs.length) { 22 | return []; 23 | } 24 | 25 | const libsData: UpgradeOption[] = await Promise.all( 26 | allLibs.map(async (lib) => { 27 | const {currentVersion, versionMode} = getVersionAndMode(allDependencies, lib); 28 | const conditionVersion = await getConditionVersion(lib); 29 | const isLatest = compareVersions(currentVersion, conditionVersion) >= 0; 30 | 31 | return { 32 | isLatest, 33 | latestVersion: getColorVersion( 34 | currentVersion, 35 | isLatest ? currentVersion : conditionVersion 36 | ), 37 | package: lib, 38 | version: currentVersion, 39 | versionMode 40 | }; 41 | }) 42 | ); 43 | 44 | return libsData; 45 | } 46 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 16 | 17 | ## 📝 Description 18 | 21 | 22 | ## ✅ Type of change 23 | - [ ] Bug fix (non-breaking change which fixes an issue) 24 | - [ ] New feature (non-breaking change which adds functionality) 25 | - [ ] Refactoring (improve a current implementation without adding a new feature or fixing a bug) 26 | - [ ] Improvement (non-breaking change which improves an existing feature) 27 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 28 | - [ ] Documentation update 29 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/lint.ts: -------------------------------------------------------------------------------- 1 | import {getOptionsValue} from './options'; 2 | import {getStore, writeFileAndUpdateStore} from './store'; 3 | 4 | async function tryImportPackage(packageName: string) { 5 | try { 6 | return await import(packageName); 7 | } catch { 8 | return null; 9 | } 10 | } 11 | 12 | export async function lintWithESLint(filePaths: string[]) { 13 | const eslintPkg = await tryImportPackage('eslint'); 14 | 15 | if (eslintPkg) { 16 | const ESLint = eslintPkg.ESLint; 17 | const eslint = new ESLint({ 18 | fix: true 19 | }); 20 | const result = await eslint.lintFiles(filePaths); 21 | 22 | await ESLint.outputFixes(result); 23 | 24 | return result; 25 | } 26 | } 27 | 28 | export async function lintWithPrettier(filePaths: string[]) { 29 | const prettier = await tryImportPackage('prettier'); 30 | const options = await prettier.resolveConfig(process.cwd()); 31 | 32 | if (prettier) { 33 | await Promise.all( 34 | filePaths.map(async (filePath) => { 35 | const rawContent = getStore(filePath, 'rawContent'); 36 | const formattedContent = await prettier.format(rawContent, { 37 | options, 38 | parser: 'typescript' 39 | }); 40 | 41 | writeFileAndUpdateStore(filePath, 'rawContent', formattedContent); 42 | }) 43 | ); 44 | } 45 | } 46 | 47 | /** 48 | * Try linting a file with ESLint or Prettier 49 | */ 50 | export async function tryLintFile(filePaths: string[]) { 51 | try { 52 | if (getOptionsValue('format')) { 53 | await lintWithPrettier(filePaths); 54 | } else { 55 | await lintWithESLint(filePaths); 56 | } 57 | } catch { 58 | return; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/parse.ts: -------------------------------------------------------------------------------- 1 | import type {SAFE_ANY} from '@helpers/type'; 2 | 3 | import {Logger} from '@helpers/logger'; 4 | import jscodeshift, {type Collection} from 'jscodeshift'; 5 | import babylonParse from 'jscodeshift/parser/babylon'; 6 | import tsOptions from 'jscodeshift/parser/tsOptions'; 7 | 8 | import {DEBUG} from './debug'; 9 | import {getStore} from './store'; 10 | 11 | const dtsOptions = { 12 | ...tsOptions, 13 | plugins: [ 14 | ...tsOptions.plugins.filter((plugin) => plugin !== 'typescript'), 15 | ['typescript', {dts: true}] 16 | ] 17 | }; 18 | 19 | function createParserFromPath(filePath: string): jscodeshift.JSCodeshift { 20 | const isDeclarationFile = /\.d\.(m|c)?ts$/.test(filePath); 21 | 22 | if (isDeclarationFile) { 23 | return jscodeshift.withParser(babylonParse(dtsOptions)); 24 | } 25 | 26 | // jsx is allowed in .js files, feed them into the tsx parser. 27 | // tsx parser :.js, .jsx, .tsx 28 | // ts parser: .ts, .mts, .cts 29 | const isTsFile = /\.(m|c)?.ts$/.test(filePath); 30 | 31 | return isTsFile ? jscodeshift.withParser('ts') : jscodeshift.withParser('tsx'); 32 | } 33 | 34 | export function parseContent(path: string): Collection | undefined { 35 | // skip json files 36 | if (path.endsWith('.json')) { 37 | return; 38 | } 39 | const content = getStore(path, 'rawContent'); 40 | 41 | try { 42 | const parser = createParserFromPath(path); 43 | const jscodeShift = parser(content); 44 | 45 | return jscodeShift; 46 | } catch (error) { 47 | DEBUG.enabled && Logger.warn(`Parse ${path} content failed, skip it: ${error}`); 48 | 49 | return; 50 | } 51 | } 52 | 53 | export function safeParseJson(content: string) { 54 | try { 55 | return JSON.parse(content); 56 | } catch { 57 | return {}; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/https.ts: -------------------------------------------------------------------------------- 1 | import retry from 'async-retry'; 2 | import chalk from 'chalk'; 3 | import ora from 'ora'; 4 | 5 | export async function fetchPackageLatestVersion(packageName: string): Promise { 6 | const text = `Fetching ${packageName} version`; 7 | const spinner = ora({ 8 | discardStdin: false, 9 | spinner: { 10 | frames: [ 11 | `⠋ ${chalk.gray(`${text}.`)}`, 12 | `⠙ ${chalk.gray(`${text}..`)}`, 13 | `⠹ ${chalk.gray(`${text}...`)}`, 14 | `⠸ ${chalk.gray(`${text}.`)}`, 15 | `⠼ ${chalk.gray(`${text}..`)}`, 16 | `⠴ ${chalk.gray(`${text}...`)}`, 17 | `⠦ ${chalk.gray(`${text}.`)}`, 18 | `⠧ ${chalk.gray(`${text}..`)}`, 19 | `⠇ ${chalk.gray(`${text}...`)}`, 20 | `⠏ ${chalk.gray(`${text}.`)}` 21 | ], 22 | interval: 150 23 | } 24 | }); 25 | 26 | spinner.start(); 27 | 28 | try { 29 | return await retry( 30 | async () => { 31 | const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`, { 32 | headers: { 33 | Accept: 'application/json' 34 | } 35 | }); 36 | 37 | if (!response.ok) { 38 | throw new Error(`Request failed with status ${response.status}`); 39 | } 40 | 41 | const data = await response.json(); 42 | 43 | return (data as {version: string}).version; 44 | }, 45 | { 46 | retries: 2 47 | } 48 | ); 49 | } catch (error) { 50 | if (error instanceof Error) { 51 | if (error.message.includes('fetch failed')) { 52 | throw new Error('Connection failed. Please check your network connection.'); 53 | } 54 | throw error; 55 | } 56 | throw new Error('Parse version info failed'); 57 | } finally { 58 | spinner.stop(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/helpers/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import type {SAFE_ANY} from './type'; 4 | 5 | import chalk from 'chalk'; 6 | import {default as _gradientString} from 'gradient-string'; 7 | 8 | export const defaultColors = ['#F54180', '#338EF7'] as const; 9 | 10 | export const gradientString = _gradientString(...defaultColors); 11 | 12 | const logPrefix = gradientString('HeroUI CLI:'); 13 | 14 | export type PrefixLogType = Extract< 15 | keyof typeof Logger, 16 | 'error' | 'gradient' | 'info' | 'log' | 'warn' | 'success' 17 | >; 18 | export class Logger { 19 | constructor() {} 20 | 21 | static log(...args: Parameters) { 22 | console.log(...args); 23 | } 24 | 25 | static info(...args: Parameters) { 26 | console.info(...args.map((item) => chalk.blue(item))); 27 | } 28 | 29 | static success(...args: Parameters) { 30 | console.info(...args.map((item) => chalk.green(item))); 31 | } 32 | 33 | static warn(...args: Parameters) { 34 | console.warn(...args.map((item) => chalk.yellow(item))); 35 | } 36 | 37 | static error(...args: Parameters) { 38 | console.error(...args.map((item) => chalk.red(item))); 39 | } 40 | 41 | static grey(...args: Parameters) { 42 | console.log(...args.map((item) => chalk.gray(item))); 43 | } 44 | 45 | static gradient(content: string | number | boolean, options?: {colors?: tinycolor.ColorInput[]}) { 46 | this.log(_gradientString(...(options?.colors ?? defaultColors))(String(content))); 47 | } 48 | 49 | static prefix(type: PrefixLogType, ...args: SAFE_ANY) { 50 | return this[type](logPrefix, ...args); 51 | } 52 | 53 | static newLine(lines?: number) { 54 | if (!lines) lines = 1; 55 | 56 | for (let i = 0; i < lines; i++) this.log(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/sync.yml: -------------------------------------------------------------------------------- 1 | name: sync docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - README.md 9 | 10 | jobs: 11 | sync-docs: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | 18 | - uses: actions/setup-node@v3 19 | with: 20 | check-latest: true 21 | node-version-file: '.nvmrc' 22 | 23 | - name: Setup pnpm 24 | uses: pnpm/action-setup@v2 25 | 26 | - name: Install dependencies 27 | run: pnpm install --frozen-lockfile 28 | 29 | - name: Set up Git 30 | run: | 31 | git config --global user.name 'GitHub Action' 32 | git config --global user.email 'action@github.com' 33 | 34 | - name: Clone heroui repository 35 | run: | 36 | git clone https://github.com/heroui-inc/heroui heroui --depth 1 37 | 38 | - name: Run docs sync script 39 | run: | 40 | pnpm sync:docs 41 | 42 | - name: Get version from package.json 43 | id: get_version 44 | run: | 45 | VERSION=$(jq -r '.version' package.json) 46 | echo "::set-output name=version::$VERSION" 47 | 48 | - name: Commit changes to heroui repository 49 | run: | 50 | cd heroui 51 | git add . 52 | git commit -m "docs: sync api from heroui-cli v${{ steps.get_version.outputs.version }}" 53 | 54 | - name: Create Pull Request 55 | uses: peter-evans/create-pull-request@v5 56 | with: 57 | token: ${{ secrets.PAT }} 58 | path: heroui 59 | branch: sync-docs-${{ steps.get_version.outputs.version }}-${{ github.run_id }} 60 | title: "docs: sync api from heroui-cli v${{ steps.get_version.outputs.version }}" 61 | body: Sync api from heroui-cli. -------------------------------------------------------------------------------- /packages/codemod/src/index.ts: -------------------------------------------------------------------------------- 1 | import type {SAFE_ANY} from '@helpers/type'; 2 | 3 | import {Logger} from '@helpers/logger'; 4 | import {getCommandDescAndLog} from '@helpers/utils'; 5 | import {Command} from 'commander'; 6 | 7 | import pkg from '../package.json'; 8 | 9 | import {codemodAction} from './actions/codemod-action'; 10 | import {migrateAction} from './actions/migrate-action'; 11 | import {DEBUG} from './helpers/debug'; 12 | import {initOptions} from './helpers/options'; 13 | import {codemods} from './types'; 14 | 15 | const heroui = new Command(); 16 | 17 | heroui 18 | .name(pkg.name) 19 | .usage('[command]') 20 | .description(getCommandDescAndLog(`\nHeroUI Codemod v${pkg.version}\n`, pkg.description)) 21 | .version(pkg.version, '-v, --version', 'Output the current version') 22 | .helpOption('-h, --help', 'Display help for command') 23 | .argument('[codemod]', `Specify which codemod to run\nCodemods: ${codemods.join(', ')}`) 24 | .allowUnknownOption() 25 | .option('-d, --debug', 'Enable debug mode') 26 | .option('-f, --format', 'Format the affected files with Prettier') 27 | .action(codemodAction); 28 | 29 | heroui 30 | .command('migrate') 31 | .description('Migrates your codebase to use the heroui') 32 | .argument('[projectPath]', 'Path to the project to migrate') 33 | .action(migrateAction); 34 | 35 | heroui.hook('preAction', async (command) => { 36 | const options = (command as SAFE_ANY).rawArgs.slice(2); 37 | const debug = options.includes('--debug') || options.includes('-d'); 38 | const format = options.includes('--format') || options.includes('-f'); 39 | 40 | initOptions({format}); 41 | 42 | DEBUG.enabled = debug; 43 | }); 44 | 45 | heroui.parseAsync(process.argv).catch(async (reason) => { 46 | Logger.newLine(); 47 | Logger.error('Unexpected error. Please report it as a bug:'); 48 | Logger.log(reason); 49 | Logger.newLine(); 50 | process.exit(1); 51 | }); 52 | -------------------------------------------------------------------------------- /src/helpers/debug.ts: -------------------------------------------------------------------------------- 1 | import {writeFileSync} from 'node:fs'; 2 | 3 | import {getStoreSync, store} from 'src/constants/store'; 4 | 5 | import {catchPnpmExec} from './actions/upgrade/catch-pnpm-exec'; 6 | import {exec} from './exec'; 7 | import {Logger} from './logger'; 8 | import {getPackageInfo} from './package'; 9 | 10 | export async function debugExecAddAction(cmd: string, components: string[] = []) { 11 | if (getStoreSync('debug')) { 12 | for (const component of components) { 13 | Logger.warn(`Debug: ${component}`); 14 | } 15 | } else { 16 | await catchPnpmExec(() => exec(cmd)); 17 | } 18 | } 19 | 20 | export function debugAddedPkg(components: string[], packagePath: string) { 21 | if (!components.length || !getStoreSync('debug')) return; 22 | 23 | const {dependencies, packageJson} = getPackageInfo(packagePath); 24 | 25 | for (const component of components) { 26 | const compData = store.heroUIComponentsMap[component]; 27 | 28 | if (!compData) continue; 29 | 30 | dependencies[compData.package] = `${compData.package}@${compData.version}`; 31 | } 32 | writeFileSync( 33 | packagePath, 34 | JSON.stringify( 35 | { 36 | ...packageJson, 37 | dependencies 38 | }, 39 | null, 40 | 2 41 | ), 42 | 'utf-8' 43 | ); 44 | } 45 | 46 | export function debugRemovedPkg(components: string[], packagePath: string) { 47 | if (!components.length || !getStoreSync('debug')) return; 48 | 49 | const {dependencies, packageJson} = getPackageInfo(packagePath); 50 | 51 | for (const component of components) { 52 | const compData = store.heroUIComponentsMap[component]; 53 | 54 | if (!compData) continue; 55 | delete dependencies[compData.package]; 56 | } 57 | writeFileSync( 58 | packagePath, 59 | JSON.stringify( 60 | { 61 | ...packageJson, 62 | dependencies 63 | }, 64 | null, 65 | 2 66 | ), 67 | 'utf-8' 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/constants/templates.ts: -------------------------------------------------------------------------------- 1 | import type {CheckType} from '@helpers/check'; 2 | 3 | export const APP_REPO = 'https://codeload.github.com/heroui-inc/next-app-template/tar.gz/main'; 4 | export const PAGES_REPO = 'https://codeload.github.com/heroui-inc/next-pages-template/tar.gz/main'; 5 | export const VITE_REPO = 'https://codeload.github.com/heroui-inc/vite-template/tar.gz/main'; 6 | export const REMIX_REPO = 'https://codeload.github.com/heroui-inc/remix-template/tar.gz/main'; 7 | export const LARAVEL_REPO = 8 | 'https://github.com/heroui-inc/laravel-template/archive/refs/heads/master.zip'; 9 | 10 | export const APP_DIR = 'next-app-template-main'; 11 | export const PAGES_DIR = 'next-pages-template-main'; 12 | export const VITE_DIR = 'vite-template-main'; 13 | export const REMIX_DIR = 'remix-template-main'; 14 | export const LARAVEL_DIR = 'laravel-template-main'; 15 | 16 | export const APP_NAME = 'next-app-template'; 17 | export const PAGES_NAME = 'next-pages-template'; 18 | export const VITE_NAME = 'vite-template'; 19 | export const REMIX_NAME = 'remix-template'; 20 | export const LARAVEL_NAME = 'laravel-template'; 21 | export const DEFAULT_PROJECT_NAME = 'heroui-app'; 22 | 23 | export function tailwindTemplate(type: 'all', content?: string): string; 24 | export function tailwindTemplate(type: 'partial', content: string): string; 25 | export function tailwindTemplate(type: CheckType, content?: string) { 26 | if (type === 'all') { 27 | return `// tailwind.config.js 28 | const {heroui} = require("@heroui/react"); 29 | 30 | /** @type {import('tailwindcss').Config} */ 31 | module.exports = { 32 | content: [ 33 | "./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}", 34 | ], 35 | theme: { 36 | extend: {}, 37 | }, 38 | darkMode: "class", 39 | plugins: [heroui()], 40 | };`; 41 | } else { 42 | return `// tailwind.config.js 43 | const {heroui} = require("@heroui/theme"); 44 | 45 | /** @type {import('tailwindcss').Config} */ 46 | module.exports = { 47 | content: [ 48 | ${JSON.stringify(content)}, 49 | ], 50 | theme: { 51 | extend: {}, 52 | }, 53 | darkMode: "class", 54 | plugins: [heroui()], 55 | };`; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/prompts/index.ts: -------------------------------------------------------------------------------- 1 | import prompts from '@winches/prompts'; 2 | import chalk from 'chalk'; 3 | 4 | import {Logger} from '@helpers/logger'; 5 | 6 | const defaultPromptOptions: prompts.Options = { 7 | onCancel: () => { 8 | Logger.log(`${chalk.red('✖')} Operation cancelled`); 9 | process.exit(0); 10 | } 11 | }; 12 | 13 | export async function getText(message: string, initial?: string) { 14 | const result = await prompts( 15 | { 16 | message, 17 | name: 'value', 18 | type: 'text', 19 | ...(initial ? {initial} : {}) 20 | }, 21 | defaultPromptOptions 22 | ); 23 | 24 | return result.value; 25 | } 26 | 27 | export async function getAutocomplete(message: string, choices?: prompts.Choice[]) { 28 | const result = await prompts( 29 | { 30 | message, 31 | name: 'value', 32 | type: 'autocomplete', 33 | ...(choices ? {choices} : {}) 34 | }, 35 | defaultPromptOptions 36 | ); 37 | 38 | return result.value; 39 | } 40 | 41 | export async function getAutocompleteMultiselect(message: string, choices?: prompts.Choice[]) { 42 | const result = await prompts( 43 | { 44 | hint: '- Space to select. Return to submit', 45 | message, 46 | min: 1, 47 | name: 'value', 48 | type: 'autocompleteMultiselect', 49 | ...(choices ? {choices} : {}) 50 | }, 51 | defaultPromptOptions 52 | ); 53 | 54 | return result.value; 55 | } 56 | 57 | export async function getSelect(message: string, choices: prompts.Choice[]) { 58 | const result = await prompts( 59 | { 60 | message, 61 | name: 'value', 62 | type: 'select', 63 | ...(choices ? {choices} : {}) 64 | }, 65 | defaultPromptOptions 66 | ); 67 | 68 | return result.value; 69 | } 70 | 71 | export async function getMultiselect(message: string, choices?: prompts.Choice[]) { 72 | const result = await prompts( 73 | { 74 | hint: '- Space to select. Return to submit', 75 | message, 76 | min: 1, 77 | name: 'value', 78 | type: 'multiselect', 79 | ...(choices ? {choices} : {}) 80 | }, 81 | defaultPromptOptions 82 | ); 83 | 84 | return result.value; 85 | } 86 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | title: "[Feature Request] YOUR_FEATURE_TITLE_HERE_REPLACE_ME" 3 | labels: [feature request] 4 | description: | 5 | 💡 Suggest an idea for the `HeroUI CLI` project 6 | Examples 7 | - propose a new command 8 | - improve an exiting features 9 | - ....etc 10 | body: 11 | - type: markdown 12 | attributes: 13 | value: | 14 | This issue form is for requesting features only! For example, requesting a new command, behavior ... etc 15 | If you want to report a bug, please use the [bug report form](https://github.com/heroui-inc/heroui-cli/issues/new?assignees=&labels=&template=bug_report.yml). 16 | - type: textarea 17 | validations: 18 | required: true 19 | attributes: 20 | label: Is your feature request related to a problem? Please describe. 21 | description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 22 | - type: textarea 23 | validations: 24 | required: true 25 | attributes: 26 | label: Describe the solution you'd like 27 | description: A clear and concise description of what you want to happen. 28 | placeholder: | 29 | As a user, I expected ___ behavior but ___ ... 30 | 31 | Ideal Steps I would like to see: 32 | 1. Run the command '...' 33 | 2. Select '....' 34 | 3. .... 35 | - type: textarea 36 | validations: 37 | required: true 38 | attributes: 39 | label: Describe alternatives you've considered 40 | description: A clear and concise description of any alternative solutions or features you've considered. 41 | - type: textarea 42 | attributes: 43 | label: Screenshots or Videos 44 | description: | 45 | If applicable, add screenshots or a video to help explain your problem. 46 | For more information on the supported file image/file types and the file size limits, please refer 47 | to the following link: https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files 48 | placeholder: | 49 | You can drag your video or image files inside of this editor ↓ -------------------------------------------------------------------------------- /src/helpers/actions/add/heroui-chat/get-related-imports.ts: -------------------------------------------------------------------------------- 1 | import type {CodeBaseFile} from './get-codebase-files'; 2 | 3 | import {basename} from 'node:path'; 4 | 5 | import {Logger} from '@helpers/logger'; 6 | import {getMatchImport} from '@helpers/match'; 7 | 8 | export function getRelatedImports(fileContent: string) { 9 | const matchImport = getMatchImport(fileContent); 10 | const result = matchImport 11 | .map((imports) => imports.find((target) => target.includes('./'))) 12 | .filter(Boolean); 13 | 14 | return result as string[]; 15 | } 16 | 17 | export interface FetchAllRelatedFilesParams { 18 | content: string; 19 | entries: CodeBaseFile[]; 20 | filePath: string; 21 | } 22 | 23 | export async function fetchAllRelatedFiles(params: FetchAllRelatedFilesParams) { 24 | const {content: fileContent, entries, filePath} = params; 25 | const result: {filePath: string; fileContent: string; fileName: string}[] = []; 26 | 27 | async function fetchRelatedImports(fileContent: string) { 28 | const relatedImports = getRelatedImports(fileContent); 29 | 30 | if (relatedImports.length === 0) return; 31 | 32 | // Add related imports 33 | await Promise.all( 34 | relatedImports.map(async (relatedPath) => { 35 | const targetFile = entries?.find((file) => { 36 | return basename(file.name).includes(basename(relatedPath)); 37 | }); 38 | const suffix = targetFile?.name.split('.').pop(); 39 | const fileName = `${relatedPath.split('/').pop()}`; 40 | const filePath = `src/${relatedPath.replace(/.*?\//, '')}${suffix ? `.${suffix}` : ''}`; 41 | 42 | if (result.some((file) => file.fileName === fileName)) return; 43 | 44 | const fileContent = targetFile?.content ?? ''; 45 | 46 | result.push({ 47 | fileContent, 48 | fileName, 49 | filePath 50 | }); 51 | 52 | await fetchRelatedImports(fileContent); 53 | }) 54 | ); 55 | } 56 | 57 | try { 58 | await fetchRelatedImports(fileContent); 59 | 60 | result.push({ 61 | fileContent, 62 | fileName: filePath.split('/').pop()!, 63 | filePath 64 | }); 65 | } catch (error) { 66 | Logger.error(error); 67 | process.exit(1); 68 | } 69 | 70 | return result; 71 | } 72 | -------------------------------------------------------------------------------- /packages/codemod/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@heroui/codemod", 3 | "private": false, 4 | "type": "module", 5 | "license": "MIT", 6 | "version": "1.3.0", 7 | "homepage": "https://github.com/heroui-inc/heroui-cli#readme", 8 | "description": "HeroUI Codemod provides transformations to help migrate your codebase from NextUI to HeroUI", 9 | "keywords": [ 10 | "UI", 11 | "CLI", 12 | "Tool", 13 | "HeroUI", 14 | "NextUI", 15 | "HeroUI", 16 | "Integration", 17 | "Modify Codebase" 18 | ], 19 | "author": { 20 | "name": "HeroUI", 21 | "email": "support@heroui.com", 22 | "url": "https://github.com/heroui-inc" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/heroui-inc/heroui-cli.git" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/heroui-inc/heroui-cli/issues" 30 | }, 31 | "publishConfig": { 32 | "access": "public", 33 | "registry": "https://registry.npmjs.org/" 34 | }, 35 | "files": [ 36 | "dist" 37 | ], 38 | "bin": { 39 | "heroui-codemod": "./dist/index.js" 40 | }, 41 | "main": "./dist/index.js", 42 | "module": "./dist/index.js", 43 | "types": "./dist/index.d.ts", 44 | "scripts": { 45 | "dev": "tsup --watch", 46 | "link:cli": "pnpm link --global", 47 | "link:remove": "pnpm uninstall --global heroui-codemod", 48 | "build": "tsup", 49 | "lint": "eslint . --max-warnings=0", 50 | "lint:fix": "eslint . --max-warnings=0 --fix", 51 | "check:prettier": "prettier --check .", 52 | "check:types": "tsc --noEmit", 53 | "changelog": "npx conventional-changelog -p angular -i CHANGELOG.md -s --commit-path .", 54 | "release": "bumpp --execute='pnpm run changelog' --all --tag 'heroui-codemodv%s'", 55 | "prepublishOnly": "pnpm run build" 56 | }, 57 | "dependencies": { 58 | "@clack/prompts": "0.7.0", 59 | "async-retry": "1.3.3", 60 | "chalk": "5.3.0", 61 | "@winches/prompts": "0.0.7", 62 | "cli-progress": "3.12.0", 63 | "commander": "11.0.0", 64 | "find-up": "7.0.0", 65 | "compare-versions": "6.1.1", 66 | "fast-glob": "3.3.2", 67 | "gradient-string": "2.0.2", 68 | "jscodeshift": "17.1.1", 69 | "ora": "8.0.1", 70 | "pathe": "1.1.2" 71 | }, 72 | "engines": { 73 | "pnpm": ">=9.x" 74 | }, 75 | "packageManager": "pnpm@9.6.0", 76 | "devDependencies": { 77 | "@types/jscodeshift": "0.12.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/actions/migrate/migrate-tailwindcss.ts: -------------------------------------------------------------------------------- 1 | import type {SAFE_ANY} from '@helpers/type'; 2 | 3 | import jscodeshift from 'jscodeshift'; 4 | 5 | import { 6 | HEROUI_PLUGIN, 7 | HEROUI_PREFIX, 8 | NEXTUI_PLUGIN, 9 | NEXTUI_PREFIX 10 | } from '../../../constants/prefix'; 11 | import {getStore, updateAffectedFiles, writeFileAndUpdateStore} from '../../store'; 12 | 13 | import {migrateCallExpressionName, migrateImportName} from './migrate-common'; 14 | import {migrateImportPackage} from './migrate-import'; 15 | 16 | export function migrateTailwindcss(paths: string[]) { 17 | for (const path of paths) { 18 | // Migrate nextui plugin import/require 19 | const parsedContent = getStore(path, 'parsedContent'); 20 | 21 | if (!parsedContent) { 22 | continue; 23 | } 24 | let dirtyFlag = false; 25 | 26 | // Migrate const {nextui} = require("xxx") --> const {heroui} = require("xxx") 27 | dirtyFlag = migrateImportName(parsedContent, NEXTUI_PLUGIN, HEROUI_PLUGIN); 28 | 29 | // Migrate const {xxx} = require("nextui") --> const {xxx} = require("heroui") -- (optional avoid user skip the "import-heroui" codemod) 30 | dirtyFlag = migrateImportPackage(parsedContent); 31 | 32 | // Migrate plugin call expression nextui() -> heroui() 33 | dirtyFlag = migrateCallExpressionName(parsedContent, NEXTUI_PLUGIN, HEROUI_PLUGIN); 34 | 35 | // Migrate the content path from `@nextui-org/theme` to `@heroui/theme` 36 | parsedContent.find(jscodeshift.ObjectExpression).forEach((path) => { 37 | path.node.properties.forEach((prop: SAFE_ANY) => { 38 | if ( 39 | jscodeshift.Identifier.check(prop.key) && 40 | prop.key.name === 'content' && 41 | jscodeshift.ArrayExpression.check(prop.value) 42 | ) { 43 | prop.value.elements.forEach((element) => { 44 | if ( 45 | jscodeshift.Literal.check(element) && 46 | typeof element.value === 'string' && 47 | element.value.includes(NEXTUI_PREFIX) 48 | ) { 49 | element.value = element.value.replace(NEXTUI_PREFIX, HEROUI_PREFIX); 50 | dirtyFlag = true; 51 | } 52 | }); 53 | } 54 | }); 55 | }); 56 | 57 | if (dirtyFlag) { 58 | writeFileAndUpdateStore(path, 'parsedContent', parsedContent); 59 | updateAffectedFiles(path); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/actions/migrate/migrate-import.ts: -------------------------------------------------------------------------------- 1 | import jscodeshift from 'jscodeshift'; 2 | 3 | import {HEROUI_PREFIX, NEXTUI_PREFIX} from '../../../constants/prefix'; 4 | import { 5 | type StoreObject, 6 | getStore, 7 | updateAffectedFiles, 8 | writeFileAndUpdateStore 9 | } from '../../store'; 10 | 11 | /** 12 | * Migrate the import package will directly write the file 13 | * @example 14 | * migrateImportPackage(['xxx']); 15 | * import {xxx} from '@nextui-org/theme'; -> import {xxx} from '@heroui/theme'; 16 | * const {xxx} = require('@nextui-org/theme'); -> const {xxx} = require('@heroui/theme'); 17 | */ 18 | export function migrateImportPackageWithPaths(paths: string[]) { 19 | for (const path of paths) { 20 | const parsedContent = getStore(path, 'parsedContent'); 21 | 22 | if (!parsedContent) { 23 | continue; 24 | } 25 | 26 | try { 27 | const dirtyFlag = migrateImportPackage(parsedContent); 28 | 29 | if (dirtyFlag) { 30 | // Write the modified content back to the file 31 | writeFileAndUpdateStore(path, 'parsedContent', parsedContent); 32 | updateAffectedFiles(path); 33 | } 34 | // eslint-disable-next-line no-empty 35 | } catch {} 36 | } 37 | } 38 | 39 | export function migrateImportPackage(parsedContent: NonNullable) { 40 | let dirtyFlag = false; 41 | 42 | // Find the import declaration for '@nextui-org/' start 43 | parsedContent.find(jscodeshift.ImportDeclaration).forEach((path) => { 44 | const importValue = path.node.source.value; 45 | 46 | if (importValue && importValue.toString().includes(NEXTUI_PREFIX)) { 47 | path.node.source.value = importValue.toString().replaceAll(NEXTUI_PREFIX, HEROUI_PREFIX); 48 | dirtyFlag = true; 49 | } 50 | }); 51 | // Find the require declaration for '@nextui-org/' start, when the import declaration is not found 52 | if (!dirtyFlag) { 53 | parsedContent 54 | .find(jscodeshift.CallExpression, { 55 | callee: { 56 | name: 'require', 57 | type: 'Identifier' 58 | } 59 | }) 60 | .forEach((path) => { 61 | const requireArg = path.node.arguments[0]; 62 | 63 | if ( 64 | requireArg && 65 | requireArg.type === 'StringLiteral' && 66 | requireArg.value.includes(NEXTUI_PREFIX) 67 | ) { 68 | requireArg.value = requireArg.value.replaceAll(NEXTUI_PREFIX, HEROUI_PREFIX); 69 | dirtyFlag = true; 70 | } 71 | }); 72 | } 73 | 74 | return dirtyFlag; 75 | } 76 | -------------------------------------------------------------------------------- /src/constants/store.ts: -------------------------------------------------------------------------------- 1 | import type {ExtractStoreData, SAFE_ANY} from '@helpers/type'; 2 | 3 | import {getBetaVersion} from '@helpers/beta'; 4 | import {type Components, getLatestVersion} from 'src/scripts/helpers'; 5 | 6 | import {HEROUI_CLI, HERO_UI} from './required'; 7 | 8 | export type HeroUIComponentsMap = Record; 9 | 10 | export type Store = { 11 | debug: boolean; 12 | beta: boolean; 13 | cliLatestVersion: string; 14 | latestVersion: string; 15 | betaVersion: string; 16 | 17 | // HeroUI 18 | heroUIComponents: Components; 19 | heroUIComponentsKeys: string[]; 20 | heroUIcomponentsPackages: string[]; 21 | heroUIComponentsKeysSet: Set; 22 | heroUIComponentsMap: HeroUIComponentsMap; 23 | heroUIComponentsPackageMap: HeroUIComponentsMap; 24 | 25 | // Beta HeroUI 26 | betaHeroUIComponents: Components; 27 | betaHeroUIIComponentsKeys: string[]; 28 | betaHeroUIcomponentsPackages: string[]; 29 | betaHeroUIComponentsKeysSet: Set; 30 | betaHeroUIComponentsMap: HeroUIComponentsMap; 31 | betaHeroUIComponentsPackageMap: HeroUIComponentsMap; 32 | }; 33 | 34 | /* eslint-disable sort-keys-fix/sort-keys-fix, sort-keys */ 35 | export const store = { 36 | debug: false, 37 | beta: false, 38 | cliLatestVersion: '', 39 | latestVersion: '', 40 | betaVersion: '', 41 | 42 | betaHeroUIComponents: [], 43 | betaHeroUIIComponentsKeys: [], 44 | betaHeroUIComponentsKeysSet: new Set(), 45 | betaHeroUIComponentsMap: {}, 46 | betaHeroUIComponentsPackageMap: {}, 47 | betaHeroUIcomponentsPackages: [], 48 | 49 | heroUIComponents: [], 50 | heroUIComponentsKeys: [], 51 | heroUIComponentsKeysSet: new Set(), 52 | heroUIComponentsMap: {}, 53 | heroUIComponentsPackageMap: {}, 54 | heroUIcomponentsPackages: [] 55 | } as Store; 56 | /* eslint-enable sort-keys-fix/sort-keys-fix, sort-keys */ 57 | 58 | export type StoreKeys = keyof Store; 59 | 60 | export async function getStore( 61 | key: T 62 | ): Promise> { 63 | let data = store[key]; 64 | 65 | if (!data) { 66 | if (key === 'latestVersion') { 67 | data = (await getLatestVersion(HERO_UI)) as SAFE_ANY; 68 | 69 | store[key] = data; 70 | } else if (key === 'cliLatestVersion') { 71 | data = (await getLatestVersion(HEROUI_CLI)) as SAFE_ANY; 72 | 73 | store[key] = data; 74 | } else if (key === 'betaVersion') { 75 | data = (await getBetaVersion(HERO_UI)) as SAFE_ANY; 76 | 77 | store[key] = data; 78 | } 79 | } 80 | 81 | return data as unknown as Promise>; 82 | } 83 | 84 | export function getStoreSync(key: T) { 85 | return store[key] as unknown as ExtractStoreData; 86 | } 87 | -------------------------------------------------------------------------------- /src/helpers/match.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the content of the key in the target string. 3 | * @example getMatchImport('import {test} from "source"') => [['test', 'source']] 4 | * @param str 5 | */ 6 | export function getMatchImport(str: string) { 7 | const importRegexAll = /import {?\s*([\W\w]+?)\s*}? from ["'](.+)["']/g; 8 | 9 | const matchAll = str.match(importRegexAll) ?? []; 10 | const result: string[][] = []; 11 | 12 | for (const item of matchAll) { 13 | result.push(matchImport(item)); 14 | } 15 | 16 | return result.length ? result : []; 17 | 18 | function matchImport(itemImport: string) { 19 | const importRegex = /import {?\s*([\W\w]+?)\s*}? from ["'](.+)["']/; 20 | const match = itemImport.match(importRegex) ?? []; 21 | 22 | return [match[1] ?? '', match[2] ?? '']; 23 | } 24 | } 25 | 26 | function removeQuote(str: string) { 27 | return str.replace(/^["'`](.*)["'`]$/, '$1'); 28 | } 29 | 30 | /** 31 | * Get the array content of the key in the target string. 32 | * @example getMatchArray('key', 'key: [a, b, c]') => ['a', 'b', 'c'] 33 | * @param key 34 | * @param target 35 | */ 36 | export function getMatchArray(key: string, target: string) { 37 | const mixinReg = new RegExp(`\\s*${key}:\\s\\[([\\w\\W]*?)\\]\\s*`); 38 | 39 | if (mixinReg.test(target)) 40 | return ( 41 | target 42 | .match(mixinReg)?.[1] 43 | ?.split(/,\s/g) 44 | .map((i) => removeQuote(i.trim())) 45 | .filter(Boolean) ?? [] 46 | ); 47 | 48 | return []; 49 | } 50 | 51 | /** 52 | * Replace the array content of the key in the target string. 53 | * @example replaceMatchArray('key', 'key: [a, b, c]', ['d', 'e', 'f']) => 'key: [d, e, f]' 54 | * @param key 55 | * @param target 56 | * @param value 57 | */ 58 | export function replaceMatchArray( 59 | key: string, 60 | target: string, 61 | value: string[], 62 | _replaceValue?: string 63 | ) { 64 | const mixinReg = new RegExp(`\\s*${key}:\\s\\[([\\w\\W]*?)\\]\\s*`); 65 | const replaceValue = _replaceValue ?? value.map((v) => JSON.stringify(v)).join(', '); 66 | 67 | if (mixinReg.test(target)) { 68 | const _value = key === 'content' ? `\n ${key}: [${replaceValue}]` : `\n ${key}: [${value}]`; 69 | 70 | return target.replace(mixinReg, _value); 71 | } 72 | 73 | // If the key does not exist, add the key and value to the end of the target 74 | const targetArray = target.split('\n'); 75 | const contentIndex = targetArray.findIndex((item) => item.includes('content:')); 76 | const moduleIndex = targetArray.findIndex((item) => item.includes('module.exports =')); 77 | const insertIndex = contentIndex !== -1 ? contentIndex - 1 : moduleIndex !== -1 ? moduleIndex : 0; 78 | 79 | key === 'content' 80 | ? targetArray.splice(insertIndex + 1, 0, ` ${key}: [${replaceValue}],`) 81 | : targetArray.splice(insertIndex + 1, 0, ` ${key}: [${value.map((v) => removeQuote(v))}],`); 82 | 83 | return targetArray.join('\n'); 84 | } 85 | -------------------------------------------------------------------------------- /src/prompts/clack.ts: -------------------------------------------------------------------------------- 1 | import type {SAFE_ANY} from '@helpers/type'; 2 | 3 | import {readdirSync, statSync} from 'node:fs'; 4 | 5 | import { 6 | type ConfirmOptions, 7 | spinner as _spinner, 8 | cancel, 9 | confirm, 10 | isCancel, 11 | multiselect, 12 | select, 13 | text 14 | } from '@clack/prompts'; 15 | import chalk from 'chalk'; 16 | import {join} from 'pathe'; 17 | 18 | export const cancelClack = (value: SAFE_ANY) => { 19 | if (isCancel(value)) { 20 | cancel(`${chalk.red('✖')} Operation cancelled`); 21 | process.exit(0); 22 | } 23 | }; 24 | 25 | export const textClack: typeof text = async (opts) => { 26 | const result = (await text(opts)) as string; 27 | 28 | cancelClack(result); 29 | 30 | return result; 31 | }; 32 | 33 | export const selectClack: typeof select = async (opts) => { 34 | const result = await select(opts); 35 | 36 | cancelClack(result); 37 | 38 | return result; 39 | }; 40 | 41 | export const multiselectClack: typeof multiselect = async (opts) => { 42 | const result = await multiselect(opts); 43 | 44 | cancelClack(result); 45 | 46 | return result; 47 | }; 48 | 49 | export const spinner = _spinner(); 50 | 51 | export interface TaskClackOptions { 52 | text: string; 53 | task: PromiseLike | T; 54 | successText?: string; 55 | failText?: string; 56 | } 57 | 58 | export const taskClack = async (opts: TaskClackOptions) => { 59 | const {failText, successText, task, text} = opts; 60 | 61 | let result: string | null = null; 62 | 63 | try { 64 | spinner.start(text); 65 | result = await (task instanceof Promise ? task : Promise.resolve(task)); 66 | spinner.stop(successText); 67 | } catch (error) { 68 | cancel(failText ?? result ?? 'Task failed'); 69 | process.exit(0); 70 | } 71 | 72 | return result; 73 | }; 74 | 75 | export const confirmClack = async (opts: ConfirmOptions) => { 76 | const result = await confirm(opts); 77 | 78 | cancelClack(result); 79 | 80 | return result; 81 | }; 82 | 83 | export const getDirectoryClack = async () => { 84 | const currentDirectories = readdirSync(process.cwd()).filter((dir) => 85 | statSync(join(process.cwd(), dir)).isDirectory() 86 | ); 87 | const options = currentDirectories 88 | .map((dir) => ({ 89 | label: dir, 90 | value: dir 91 | })) 92 | .filter( 93 | (dir) => 94 | !['node_modules', 'dist', 'build', 'output', /^\./].some((ignore) => { 95 | if (typeof ignore === 'string') { 96 | return dir.value.includes(ignore); 97 | } 98 | 99 | return ignore.test(dir.value); 100 | }) 101 | ); 102 | const result = options.length 103 | ? await selectClack({ 104 | message: 'Please select the directory to add the codebase', 105 | options 106 | }) 107 | : 'src'; 108 | 109 | return result as string; 110 | }; 111 | -------------------------------------------------------------------------------- /src/helpers/fetch.ts: -------------------------------------------------------------------------------- 1 | import {basename} from 'node:path'; 2 | import {Readable} from 'node:stream'; 3 | import {pipeline} from 'node:stream/promises'; 4 | 5 | import retry from 'async-retry'; 6 | import chalk from 'chalk'; 7 | import ora from 'ora'; 8 | import tar from 'tar'; 9 | 10 | /** 11 | * Fetch the tar stream from the specified URL. 12 | * @param url 13 | */ 14 | async function fetchTarStream(url: string) { 15 | const res = await fetch(url); 16 | 17 | if (!res.body) { 18 | throw new Error(`Failed to download: ${url}`); 19 | } 20 | 21 | return Readable.fromWeb(res.body); 22 | } 23 | 24 | /** 25 | * Download the template from the specified URL and extract it to the specified directory. 26 | * @param root 27 | * @param url 28 | */ 29 | export async function downloadTemplate(root: string, url: string) { 30 | await retry( 31 | async (bail) => { 32 | try { 33 | await pipeline( 34 | await fetchTarStream(url), 35 | tar.x({ 36 | cwd: root 37 | }) 38 | ); 39 | } catch (error) { 40 | bail(new Error(`Failed to download ${url} Error: ${error}`)); 41 | } 42 | }, 43 | { 44 | retries: 3 45 | } 46 | ); 47 | } 48 | 49 | export async function fetchRequest( 50 | url: string, 51 | options?: RequestInit & {fetchInfo?: string; throwError?: boolean} 52 | ): Promise { 53 | const {fetchInfo, throwError = true, ...rest} = options ?? {}; 54 | const text = `Fetching ${fetchInfo ?? basename(url)}`; 55 | const spinner = ora({ 56 | discardStdin: false, 57 | spinner: { 58 | frames: [ 59 | `⠋ ${chalk.gray(`${text}.`)}`, 60 | `⠙ ${chalk.gray(`${text}..`)}`, 61 | `⠹ ${chalk.gray(`${text}...`)}`, 62 | `⠸ ${chalk.gray(`${text}.`)}`, 63 | `⠼ ${chalk.gray(`${text}..`)}`, 64 | `⠴ ${chalk.gray(`${text}...`)}`, 65 | `⠦ ${chalk.gray(`${text}.`)}`, 66 | `⠧ ${chalk.gray(`${text}..`)}`, 67 | `⠇ ${chalk.gray(`${text}...`)}`, 68 | `⠏ ${chalk.gray(`${text}.`)}` 69 | ], 70 | interval: 150 71 | } 72 | }); 73 | 74 | spinner.start(); 75 | 76 | try { 77 | return await retry( 78 | async () => { 79 | const response = await fetch(url, { 80 | ...rest, 81 | headers: { 82 | Accept: 'application/json', 83 | ...rest?.headers 84 | } 85 | }); 86 | 87 | if (!response.ok && throwError) { 88 | throw new Error(`Request failed with status ${response.status}`); 89 | } 90 | 91 | return response; 92 | }, 93 | { 94 | retries: 2 95 | } 96 | ); 97 | } catch (error) { 98 | if (error instanceof Error && error.message.includes('fetch failed')) { 99 | throw new Error('Connection failed. Please check your network connection.'); 100 | } 101 | 102 | throw error; 103 | } finally { 104 | spinner.stop(); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/helpers/type.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import type {HeroUIComponentsMap, StoreKeys} from 'src/constants/store'; 4 | import type {Components} from 'src/scripts/helpers'; 5 | 6 | /** 7 | * @example 'test-test' => 'TestTest' 8 | */ 9 | export type PascalCase = T extends `${infer F}-${infer R}` 10 | ? `${Capitalize}${PascalCase}` 11 | : Capitalize; 12 | 13 | export type SAFE_ANY = any; 14 | 15 | export type AppendKeyValue = { 16 | [P in keyof T | K]?: P extends keyof T ? T[P] : P extends K ? V : never; 17 | }; 18 | 19 | export type CommandName = 20 | | 'init' 21 | | 'list' 22 | | 'env' 23 | | 'upgrade' 24 | | 'remove' 25 | | 'add' 26 | | 'doctor' 27 | | 'remove'; 28 | 29 | /** 30 | * @example RequiredKey<{a?: 1, b?: 2}, a> => {a: 1, b?: 2} 31 | */ 32 | export type RequiredKey = { 33 | [K in keyof T as K extends Key ? never : K]?: T[K]; 34 | } & { 35 | [K in Key]-?: T[K]; 36 | }; 37 | 38 | /** 39 | * @example PartialKey<{a: 1, b: 2}, a> => {a?: 1, b: 2} 40 | */ 41 | export type PartialKey = { 42 | [K in keyof T as K extends Key ? never : K]: T[K]; 43 | } & { 44 | [K in Key]?: T[K]; 45 | }; 46 | 47 | export type ChalkColor = 48 | | 'black' 49 | | 'red' 50 | | 'green' 51 | | 'yellow' 52 | | 'blue' 53 | | 'magenta' 54 | | 'cyan' 55 | | 'white' 56 | | 'gray' 57 | | 'grey' 58 | | 'blackBright' 59 | | 'redBright' 60 | | 'greenBright' 61 | | 'yellowBright' 62 | | 'blueBright' 63 | | 'magentaBright' 64 | | 'cyanBright' 65 | | 'whiteBright' 66 | | 'bgBlack' 67 | | 'bgRed' 68 | | 'bgGreen' 69 | | 'bgYellow' 70 | | 'bgBlue' 71 | | 'bgMagenta' 72 | | 'bgCyan' 73 | | 'bgWhite' 74 | | 'bgGray' 75 | | 'bgGrey' 76 | | 'bgBlackBright' 77 | | 'bgRedBright' 78 | | 'bgGreenBright' 79 | | 'bgYellowBright' 80 | | 'bgBlueBright' 81 | | 'bgMagentaBright' 82 | | 'bgCyanBright' 83 | | 'bgWhiteBright'; 84 | 85 | export type ExtractStoreData = T extends 'latestVersion' | 'cliLatestVersion' 86 | ? string 87 | : T extends 'heroUIComponents' 88 | ? Components 89 | : T extends 'heroUIComponentsKeys' | 'heroUIcomponentsPackages' 90 | ? string[] 91 | : T extends 'heroUIComponentsKeysSet' 92 | ? Set 93 | : T extends 'heroUIComponentsMap' 94 | ? HeroUIComponentsMap 95 | : T extends 'heroUIComponentsPackageMap' 96 | ? HeroUIComponentsMap 97 | : never; 98 | 99 | /** 100 | * @example UnionToIntersection<{ foo: string } | { bar: string }> --> { foo: string } & { bar: string } 101 | */ 102 | export type UnionToIntersection = (U extends any ? (arg: U) => any : never) extends ( 103 | arg: infer I 104 | ) => any 105 | ? I 106 | : never; 107 | 108 | /** 109 | * @example GetUnionLastValue<0 | 1 | 2> --> 2 110 | */ 111 | export type GetUnionLastValue = 112 | UnionToIntersection T : never> extends () => infer R ? R : never; 113 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc.json", 3 | "root": true, 4 | "env": { 5 | "node": true, 6 | "es2024": true 7 | }, 8 | "parser": "@typescript-eslint/parser", 9 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], 10 | "plugins": [ 11 | "@typescript-eslint", 12 | "import", 13 | "unused-imports", 14 | "sort-keys-fix", 15 | "sort-destructure-keys", 16 | "prettier", 17 | "unicorn" 18 | ], 19 | "parserOptions": { 20 | "sourceType": "module", 21 | "ecmaVersion": "latest" 22 | }, 23 | "settings": { 24 | "import/resolver": { 25 | "node": true, 26 | "typescript": true 27 | } 28 | }, 29 | "rules": { 30 | "no-console": "warn", 31 | "prettier/prettier": "error", 32 | "import/no-duplicates": "error", 33 | "sort-keys-fix/sort-keys-fix": "error", 34 | "unused-imports/no-unused-imports": "error", 35 | "@typescript-eslint/consistent-type-imports": "error", 36 | "import/newline-after-import": ["error", { "count": 1 }], 37 | "no-unused-vars": "off", 38 | "unused-imports/no-unused-vars": [ 39 | "error", 40 | { 41 | "args": "after-used", 42 | "argsIgnorePattern": "^_", 43 | "vars": "all", 44 | "varsIgnorePattern": "^_" 45 | } 46 | ], 47 | "padding-line-between-statements": [ 48 | "error", 49 | { "blankLine": "always", "prev": "directive", "next": "*" }, 50 | { "blankLine": "any", "prev": "directive", "next": "directive" }, 51 | { "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" }, 52 | { "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"] }, 53 | { "blankLine": "always", "prev": "*", "next": "return" } 54 | ], 55 | "sort-destructure-keys/sort-destructure-keys": [ 56 | "error", 57 | { 58 | "caseSensitive": true 59 | } 60 | ], 61 | "sort-keys": [ 62 | "error", 63 | "asc", 64 | { 65 | "minKeys": 2, 66 | "natural": false, 67 | "caseSensitive": true 68 | } 69 | ], 70 | "sort-imports": [ 71 | "error", 72 | { 73 | "ignoreCase": false, 74 | "ignoreMemberSort": false, 75 | "allowSeparatedGroups": true, 76 | "ignoreDeclarationSort": true, 77 | "memberSyntaxSortOrder": ["none", "all", "multiple", "single"] 78 | } 79 | ], 80 | "import/order": [ 81 | "error", 82 | { 83 | "groups": [ 84 | "type", 85 | "builtin", 86 | "external", 87 | "internal", 88 | "parent", 89 | "sibling", 90 | "index", 91 | "unknown", 92 | "object" 93 | ], 94 | "pathGroupsExcludedImportTypes": ["type"], 95 | "newlines-between": "always", 96 | "warnOnUnassignedImports": true, 97 | "alphabetize": { 98 | "order": "asc", 99 | "caseInsensitive": true 100 | } 101 | } 102 | ], 103 | "unicorn/better-regex": "error", 104 | "unicorn/prefer-node-protocol": "error" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at jrgarciadev@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/store.ts: -------------------------------------------------------------------------------- 1 | import type {SAFE_ANY} from '@helpers/type'; 2 | import type {Collection} from 'jscodeshift'; 3 | 4 | import {readFileSync, writeFileSync} from 'node:fs'; 5 | 6 | import {Logger} from '@helpers/logger'; 7 | import {basename} from 'pathe'; 8 | 9 | import {createSingleProgressBar} from './bar'; 10 | import {parseContent} from './parse'; 11 | 12 | export type StoreObject = { 13 | rawContent: string; 14 | filePath: string; 15 | parsedContent?: Collection | undefined; 16 | }; 17 | 18 | export type StoreKey = keyof StoreObject; 19 | 20 | type ExcludeStoreKey = Exclude; 21 | 22 | /** 23 | * Used to temporarily store the data that parsed from the file 24 | */ 25 | export const store: Record = {}; 26 | 27 | export function storePathsRawContent(paths: string[]) { 28 | try { 29 | paths.forEach((path) => { 30 | store[path] = { 31 | filePath: path, 32 | rawContent: readFileSync(path, 'utf-8') 33 | }; 34 | }); 35 | } catch (error) { 36 | Logger.error(`Store paths raw content failed: ${error}`); 37 | process.exit(1); 38 | } 39 | } 40 | 41 | export function storeParsedContent(paths: string[]) { 42 | const bar = createSingleProgressBar(); 43 | 44 | bar.start(paths.length, 0, {head: 'Parsing files...'}); 45 | 46 | for (const path of paths) { 47 | bar.increment(1, {name: `Parsing file: ${basename(path)}`}); 48 | store[path]!.parsedContent = parseContent(path); 49 | } 50 | 51 | bar.stop(); 52 | } 53 | 54 | /** 55 | * Get the store object, note that only store all the files find by `findFiles` 56 | * @param path - The path of the file 57 | * @param key - The key of the store object 58 | * @returns The value of the store object 59 | */ 60 | export function getStore(path: string, key: T): StoreObject[T] { 61 | if (key === 'rawContent') { 62 | return (store[path]?.rawContent ?? readFileSync(path, 'utf-8')) as StoreObject[T]; 63 | } 64 | if (key === 'parsedContent') { 65 | return (store[path]?.parsedContent ?? parseContent(path)) as StoreObject[T]; 66 | } 67 | 68 | return store[path]?.[key] as StoreObject[T]; 69 | } 70 | 71 | export function updateStore( 72 | path: string, 73 | key: K, 74 | value: StoreObject[K] 75 | ) { 76 | if (!store[path]) { 77 | store[path] = { 78 | filePath: path, 79 | [key]: value 80 | } as SAFE_ANY; 81 | 82 | return; 83 | } 84 | 85 | store[path]![key] = value as SAFE_ANY; 86 | } 87 | 88 | export function writeFileAndUpdateStore( 89 | path: string, 90 | key: K, 91 | parsedContent: StoreObject[K] 92 | ) { 93 | const data: Record = { 94 | parsedContent, 95 | rawContent: typeof parsedContent === 'string' ? parsedContent : parsedContent?.toSource() 96 | }; 97 | const value = data[key]; 98 | 99 | writeFileSync(path, data.rawContent, 'utf-8'); 100 | updateStore(path, key, value); 101 | key === 'parsedContent' && updateStore(path, 'rawContent', data.rawContent); 102 | } 103 | 104 | export const affectedFiles = new Set(); 105 | 106 | export function updateAffectedFiles(path: string) { 107 | affectedFiles.add(path); 108 | } 109 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/actions/migrate/migrate-json.ts: -------------------------------------------------------------------------------- 1 | import type {SAFE_ANY} from '@helpers/type'; 2 | 3 | import {Logger} from '@helpers/logger'; 4 | 5 | import {HEROUI_PREFIX, NEXTUI_PREFIX} from '../../../constants/prefix'; 6 | import {fetchPackageLatestVersion} from '../../https'; 7 | import {safeParseJson} from '../../parse'; 8 | import {getStore, updateAffectedFiles, writeFileAndUpdateStore} from '../../store'; 9 | 10 | const DEFAULT_INDENT = 2; 11 | const LATEST_VERSION = 'latest'; 12 | 13 | export function detectIndent(content: string): number { 14 | const match = content.match(/^(\s+)/m); 15 | 16 | return match ? match[1]?.length || DEFAULT_INDENT : DEFAULT_INDENT; 17 | } 18 | 19 | function filterHeroUiPkgs(pkgs: string[]) { 20 | return pkgs.filter((pkg) => pkg.includes(HEROUI_PREFIX) || pkg.includes(NEXTUI_PREFIX)); 21 | } 22 | 23 | export async function migrateJson(files: string[]) { 24 | try { 25 | await Promise.all( 26 | files.map(async (file) => { 27 | const content = getStore(file, 'rawContent'); 28 | const dirtyFlag = content.includes(NEXTUI_PREFIX); 29 | 30 | if (dirtyFlag) { 31 | const replacedContent = content.replaceAll(NEXTUI_PREFIX, HEROUI_PREFIX); 32 | const json = safeParseJson(replacedContent); 33 | 34 | try { 35 | await Promise.all([ 36 | ...filterHeroUiPkgs(Object.keys(json.dependencies)).map(async (key) => { 37 | try { 38 | const version = await fetchPackageLatestVersion(key); 39 | 40 | json.dependencies[key] = version; 41 | } catch (error) { 42 | json.dependencies[key] = LATEST_VERSION; 43 | } 44 | }), 45 | ...filterHeroUiPkgs(Object.keys(json.devDependencies)).map(async (key) => { 46 | try { 47 | const version = await fetchPackageLatestVersion(key); 48 | 49 | json.devDependencies[key] = version; 50 | } catch (error) { 51 | json.devDependencies[key] = LATEST_VERSION; 52 | } 53 | }) 54 | ]); 55 | } catch (error) { 56 | Logger.warn( 57 | `Migrate ${file} failed\n${error}\nYou need to manually migrate the rest of the packages.` 58 | ); 59 | } 60 | const indent = detectIndent(content); 61 | 62 | writeFileAndUpdateStore(file, 'rawContent', JSON.stringify(json, null, indent)); 63 | updateAffectedFiles(file); 64 | } 65 | }) 66 | ); 67 | } catch (error) { 68 | Logger.error(`Migrate package.json failed: ${error}`); 69 | process.exit(1); 70 | } 71 | } 72 | 73 | export function migrateNextuiToHeroui(json: Record) { 74 | const {dependencies, devDependencies} = json; 75 | 76 | if (dependencies) { 77 | Object.keys(dependencies).forEach((key) => { 78 | if (key.includes(NEXTUI_PREFIX)) { 79 | dependencies[key.replace(NEXTUI_PREFIX, HEROUI_PREFIX)] = dependencies[key]; 80 | delete dependencies[key]; 81 | } 82 | }); 83 | } 84 | 85 | if (devDependencies) { 86 | Object.keys(devDependencies).forEach((key) => { 87 | if (key.includes(NEXTUI_PREFIX)) { 88 | devDependencies[key.replace(NEXTUI_PREFIX, HEROUI_PREFIX)] = devDependencies[key]; 89 | delete devDependencies[key]; 90 | } 91 | }); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 'Bug report' 2 | title: "[BUG] - YOUR_ISSUE_TITLE_HERE_REPLACE_ME" 3 | description: Create a report to help us improve 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for reporting an issue :pray:. 10 | 11 | This issue tracker is for reporting bugs found in [HeroUI CLI github repository](https://github.com/heroui-inc/heroui-cli/) 12 | If you have a question about how to achieve something and are struggling, please post a question 13 | inside of either of the following places: 14 | - HeroUI CLI's [Discussion's tab](https://github.com/heroui-inc/heroui-cli/discussions) 15 | - HeroUI's [Discord channel](https://discord.gg/9b6yyZKmH4) 16 | 17 | 18 | Before submitting a new bug/issue, please check the links below to see if there is a solution or question posted there already: 19 | - HeroUI CLI's [Issue's tab](https://github.com/heroui-inc/heroui-cli/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc) 20 | - HeroUI CLI's [closed issues tab](https://github.com/heroui-inc/heroui-cli/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed) 21 | - HeroUI CLI's [Discussions tab](https://github.com/heroui-inc/heroui-cli/discussions) 22 | 23 | The more information you fill in, the better the community can help you. 24 | - type: input 25 | id: version 26 | attributes: 27 | label: HeroUI CLI Version 28 | description: | 29 | Please provide the version of HeroUI CLI you are using. 30 | You can find the version number in the package.json file. 31 | placeholder: ex. 0.1.4 32 | validations: 33 | required: true 34 | - type: textarea 35 | id: description 36 | attributes: 37 | label: Describe the bug 38 | description: Provide a clear and concise description of the challenge you are running into. 39 | validations: 40 | required: true 41 | - type: textarea 42 | id: steps 43 | attributes: 44 | label: Steps to Reproduce the Bug or Issue 45 | description: Describe the steps we have to take to reproduce the behavior. 46 | placeholder: | 47 | 1. Run '...' 48 | 2. View the output '....' 49 | 3. See error 50 | validations: 51 | required: true 52 | - type: textarea 53 | id: expected 54 | attributes: 55 | label: Expected behavior 56 | description: Provide a clear and concise description of what you expected to happen. 57 | placeholder: | 58 | As a user, I expected ___ behavior but i am seeing ___ 59 | validations: 60 | required: true 61 | - type: textarea 62 | id: screenshots_or_videos 63 | attributes: 64 | label: Screenshots or Videos 65 | description: | 66 | If applicable, add screenshots or a video to help explain your problem. 67 | For more information on the supported file image/file types and the file size limits, please refer 68 | to the following link: https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files 69 | placeholder: | 70 | You can drag your video or image files inside of this editor ↓ 71 | - type: input 72 | id: os 73 | attributes: 74 | label: Operating System Version 75 | description: What operating system are you using? 76 | placeholder: | 77 | - OS: [e.g. macOS, Windows, Linux] 78 | validations: 79 | required: true -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "heroui-cli", 3 | "private": false, 4 | "type": "module", 5 | "license": "MIT", 6 | "version": "1.2.3", 7 | "homepage": "https://github.com/heroui-inc/heroui-cli#readme", 8 | "description": "A CLI tool that unlocks seamless HeroUI integration (Previously NextUI CLI)", 9 | "keywords": [ 10 | "UI", 11 | "CLI", 12 | "Tool", 13 | "NextUI", 14 | "Template", 15 | "Integration", 16 | "Add Component", 17 | "HeroUI" 18 | ], 19 | "author": { 20 | "name": "HeroUI", 21 | "email": "support@heroui.com", 22 | "url": "https://github.com/heroui-inc" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/heroui-inc/heroui-cli.git" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/heroui-inc/heroui-cli/issues" 30 | }, 31 | "publishConfig": { 32 | "access": "public", 33 | "registry": "https://registry.npmjs.org/" 34 | }, 35 | "files": [ 36 | "dist" 37 | ], 38 | "bin": { 39 | "heroui": "./dist/index.js" 40 | }, 41 | "main": "./dist/index.js", 42 | "module": "./dist/index.js", 43 | "types": "./dist/index.d.ts", 44 | "scripts": { 45 | "dev": "tsup --watch", 46 | "link:cli": "pnpm link --global", 47 | "link:remove": "pnpm uninstall --global heroui-cli", 48 | "build": "tsup", 49 | "update:components": "tsx src/scripts/update/update-components.ts", 50 | "sync:docs": "tsx src/scripts/sync/sync.ts", 51 | "clean:cache": "tsx src/scripts/cache/clean.ts", 52 | "lint": "eslint . --max-warnings=0", 53 | "lint:fix": "eslint . --max-warnings=0 --fix", 54 | "check:prettier": "prettier --check .", 55 | "check:types": "tsc --noEmit", 56 | "changelog": "npx conventional-changelog -p angular -i CHANGELOG.md -s --commit-path .", 57 | "release": "bumpp --execute='pnpm run changelog' --all", 58 | "prepare": "husky install", 59 | "prebuild": "pnpm run update:components", 60 | "prepublishOnly": "pnpm run build" 61 | }, 62 | "dependencies": { 63 | "@clack/prompts": "0.7.0", 64 | "@winches/prompts": "0.0.7", 65 | "async-retry": "1.3.3", 66 | "chalk": "5.3.0", 67 | "commander": "11.0.0", 68 | "compare-versions": "6.1.1", 69 | "fast-glob": "3.3.2", 70 | "find-up": "7.0.0", 71 | "gradient-string": "2.0.2", 72 | "ora": "8.0.1", 73 | "pathe": "1.1.2", 74 | "tar": "6.2.1" 75 | }, 76 | "devDependencies": { 77 | "@commitlint/cli": "17.7.1", 78 | "@commitlint/config-conventional": "17.7.0", 79 | "@types/gradient-string": "1.1.3", 80 | "@types/node": "20.11.30", 81 | "@typescript-eslint/eslint-plugin": "6.7.2", 82 | "@typescript-eslint/parser": "6.7.2", 83 | "bumpp": "9.4.0", 84 | "clean-package": "2.2.0", 85 | "commitlint-plugin-function-rules": "2.0.2", 86 | "conventional-changelog-cli": "4.1.0", 87 | "eslint": "8.50.0", 88 | "eslint-config-prettier": "9.0.0", 89 | "eslint-import-resolver-typescript": "3.6.1", 90 | "eslint-plugin-import": "2.28.1", 91 | "eslint-plugin-prettier": "5.0.0", 92 | "eslint-plugin-sort-destructure-keys": "1.5.0", 93 | "eslint-plugin-sort-keys-fix": "1.1.2", 94 | "eslint-plugin-unicorn": "52.0.0", 95 | "eslint-plugin-unused-imports": "3.0.0", 96 | "husky": "8.0.3", 97 | "lint-staged": "14.0.1", 98 | "prettier": "3.3.2", 99 | "tsup": "7.2.0", 100 | "tsx": "4.7.1", 101 | "typescript": "5.2.2" 102 | }, 103 | "engines": { 104 | "pnpm": ">=9.x" 105 | }, 106 | "packageManager": "pnpm@9.6.0" 107 | } 108 | -------------------------------------------------------------------------------- /src/scripts/cache/cache.ts: -------------------------------------------------------------------------------- 1 | import type {SAFE_ANY} from '@helpers/type'; 2 | 3 | import {existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync} from 'node:fs'; 4 | 5 | import {oraExecCmd} from '../helpers'; 6 | import {CACHE_DIR, CACHE_PATH} from '../path'; 7 | 8 | const cacheTTL = 30 * 60_000; // 30min 9 | let noCache = false; 10 | 11 | export interface CacheData { 12 | [packageName: string]: { 13 | version: string; 14 | date: Date; 15 | formatDate: string; 16 | expiredDate: number; 17 | expiredFormatDate: string; 18 | execResult: SAFE_ANY; 19 | }; 20 | } 21 | 22 | export function initCache(_noCache = noCache) { 23 | noCache = !!_noCache; 24 | 25 | const isExistCache = existsSync(CACHE_DIR); 26 | 27 | if (isExistCache) return; 28 | 29 | mkdirSync(CACHE_DIR, {recursive: true}); 30 | writeFileSync(CACHE_PATH, JSON.stringify({}), 'utf8'); 31 | } 32 | 33 | export function getCacheData(): CacheData { 34 | if (!existsSync(CACHE_DIR)) { 35 | initCache(); 36 | } 37 | const data = readFileSync(CACHE_PATH, 'utf8'); 38 | 39 | return JSON.parse(data); 40 | } 41 | 42 | /** 43 | * Used to cache unknown npm package expired time 30m 44 | * @packageName string 45 | */ 46 | export function cacheData( 47 | packageName: string, 48 | packageData: { 49 | version?: string; 50 | execResult?: SAFE_ANY; 51 | }, 52 | cacheData?: CacheData 53 | ) { 54 | initCache(); 55 | 56 | const data = cacheData ?? getCacheData(); 57 | const now = new Date(); 58 | const expiredDate = +now + cacheTTL; 59 | 60 | data[packageName] = { 61 | ...(packageData as SAFE_ANY), 62 | date: now, 63 | expiredDate, 64 | expiredFormatDate: new Date(expiredDate).toString(), 65 | formatDate: now.toString() 66 | }; 67 | 68 | writeFileSync(CACHE_PATH, JSON.stringify(data, undefined, 2), 'utf-8'); 69 | } 70 | 71 | export function removeCache() { 72 | unlinkSync(CACHE_DIR); 73 | } 74 | 75 | function now() { 76 | return +new Date(); 77 | } 78 | 79 | function ttl(n: number) { 80 | return now() - n; 81 | } 82 | 83 | export function isExpired(packageName: string, cacheData?: CacheData) { 84 | // If noCache then always return true 85 | if (noCache) return true; 86 | 87 | const data = cacheData ?? getCacheData(); 88 | const pkgData = data[packageName]; 89 | 90 | if (!pkgData?.expiredDate) return true; 91 | 92 | return ttl(pkgData.expiredDate) > 0; 93 | } 94 | 95 | export async function getPackageVersion(packageName: string) { 96 | const data = getCacheData(); 97 | const isExpiredPkg = isExpired(packageName, data); 98 | 99 | // If expired or don't exist then init data 100 | if (isExpiredPkg) { 101 | const version = await oraExecCmd( 102 | `npm view ${packageName} version`, 103 | `Fetching ${packageName} latest version` 104 | ); 105 | 106 | const pkgVersion = {version}; 107 | 108 | cacheData(packageName, pkgVersion, data); 109 | 110 | return pkgVersion; 111 | } 112 | 113 | return data[packageName]!; 114 | } 115 | 116 | export async function getCacheExecData( 117 | key: string, 118 | execMessage?: string 119 | ): Promise { 120 | const data = getCacheData(); 121 | const isExpiredPkg = isExpired(key, data); 122 | 123 | // If expired or don't exist then init data 124 | if (isExpiredPkg) { 125 | const execResult = await oraExecCmd(key, execMessage); 126 | 127 | const result = {execResult}; 128 | 129 | cacheData(key, result, data); 130 | 131 | return result.execResult; 132 | } 133 | 134 | return data[key]!.execResult; 135 | } 136 | -------------------------------------------------------------------------------- /src/constants/component.ts: -------------------------------------------------------------------------------- 1 | import type {Components} from 'src/scripts/helpers'; 2 | 3 | import {store} from './store'; 4 | 5 | export function getHerouiComponentsData(heroUIComponents: Components) { 6 | const heroUIComponentsKeys = heroUIComponents.map((component) => component.name); 7 | const heroUIcomponentsPackages = heroUIComponents.map((component) => component.package); 8 | 9 | const heroUIComponentsKeysSet = new Set(heroUIComponentsKeys); 10 | 11 | const heroUIComponentsMap = heroUIComponents.reduce((acc, component) => { 12 | acc[component.name] = component; 13 | 14 | return acc; 15 | }, {} as HeroUIComponentsMap); 16 | const heroUIComponentsPackageMap = heroUIComponents.reduce((acc, component) => { 17 | acc[component.package] = component; 18 | 19 | return acc; 20 | }, {} as HeroUIComponentsMap); 21 | 22 | return { 23 | heroUIComponentsKeys, 24 | heroUIComponentsKeysSet, 25 | heroUIComponentsMap, 26 | heroUIComponentsPackageMap, 27 | heroUIcomponentsPackages 28 | }; 29 | } 30 | 31 | export function initStoreComponentsData({ 32 | beta, 33 | heroUIComponents 34 | }: { 35 | beta: boolean; 36 | heroUIComponents: Components; 37 | }) { 38 | const { 39 | heroUIComponentsKeys, 40 | heroUIComponentsKeysSet, 41 | heroUIComponentsMap, 42 | heroUIComponentsPackageMap, 43 | heroUIcomponentsPackages 44 | } = getHerouiComponentsData(heroUIComponents); 45 | 46 | if (beta) { 47 | store.betaHeroUIComponents = heroUIComponents; 48 | store.betaHeroUIIComponentsKeys = heroUIComponentsKeys; 49 | store.betaHeroUIComponentsKeysSet = heroUIComponentsKeysSet; 50 | store.betaHeroUIComponentsMap = heroUIComponentsMap; 51 | store.betaHeroUIComponentsPackageMap = heroUIComponentsPackageMap; 52 | store.betaHeroUIcomponentsPackages = heroUIcomponentsPackages; 53 | } else { 54 | store.heroUIComponents = heroUIComponents; 55 | store.heroUIComponentsKeys = heroUIComponentsKeys; 56 | store.heroUIComponentsKeysSet = heroUIComponentsKeysSet; 57 | store.heroUIComponentsMap = heroUIComponentsMap; 58 | store.heroUIComponentsPackageMap = heroUIComponentsPackageMap; 59 | store.heroUIcomponentsPackages = heroUIcomponentsPackages; 60 | } 61 | } 62 | 63 | export type HeroUIComponentsMap = Record; 64 | 65 | export const orderHeroUIComponentKeys = ['package', 'version', 'status', 'docs'] as const; 66 | 67 | export const colorHeroUIComponentKeys = ['package', 'version', 'status']; 68 | 69 | // eslint-disable-next-line @typescript-eslint/ban-types 70 | export type HeroUIComponentStatus = 'stable' | 'updated' | 'new' | (string & {}); 71 | 72 | export type HeroUIComponent = (typeof store.heroUIComponents)[0]; 73 | 74 | export type HeroUIComponents = (Omit & { 75 | status: HeroUIComponentStatus; 76 | versionMode: string; 77 | })[]; 78 | 79 | /** 80 | * Get the component data 81 | * isBeta --> betaHeroUIComponents 82 | * isStable --> heroUIComponents 83 | */ 84 | export function getConditionComponentData() { 85 | if (store.beta) { 86 | return { 87 | components: store.betaHeroUIComponents, 88 | componentsKeys: store.betaHeroUIIComponentsKeys, 89 | componentsKeysSet: store.betaHeroUIComponentsKeysSet, 90 | componentsMap: store.betaHeroUIComponentsMap, 91 | componentsPackageMap: store.betaHeroUIComponentsPackageMap, 92 | componentsPackages: store.betaHeroUIcomponentsPackages 93 | }; 94 | } 95 | 96 | return { 97 | components: store.heroUIComponents, 98 | componentsKeys: store.heroUIComponentsKeys, 99 | componentsKeysSet: store.heroUIComponentsKeysSet, 100 | componentsMap: store.heroUIComponentsMap, 101 | componentsPackageMap: store.heroUIComponentsPackageMap, 102 | componentsPackages: store.heroUIcomponentsPackages 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /src/helpers/remove.ts: -------------------------------------------------------------------------------- 1 | import type {Agent} from './detect'; 2 | import type {HeroUIComponents} from 'src/constants/component'; 3 | 4 | import {existsSync, readFileSync, writeFileSync} from 'node:fs'; 5 | 6 | import {tailwindRequired} from 'src/constants/required'; 7 | 8 | import {type CheckType, checkTailwind} from './check'; 9 | import {exec} from './exec'; 10 | import {fixTailwind} from './fix'; 11 | import {Logger} from './logger'; 12 | import {getMatchArray, replaceMatchArray} from './match'; 13 | import {getPackageManagerInfo} from './utils'; 14 | 15 | export async function removeDependencies(components: string[], packageManager: Agent) { 16 | const {remove} = getPackageManagerInfo(packageManager); 17 | 18 | await exec(`${packageManager} ${remove} ${components.join(' ')}`); 19 | 20 | return; 21 | } 22 | 23 | export async function removeTailwind( 24 | type: CheckType, 25 | options: { 26 | tailwindPath?: string; 27 | currentComponents: HeroUIComponents; 28 | isPnpm: boolean; 29 | prettier: boolean; 30 | isHeroUIAll: boolean; 31 | } 32 | ) { 33 | const {currentComponents, isHeroUIAll, isPnpm, prettier, tailwindPath} = options; 34 | 35 | if (tailwindPath && !existsSync(tailwindPath)) { 36 | Logger.prefix('warn', `No tailwind.config.(j|t)s found remove action skipped`); 37 | 38 | return; 39 | } 40 | 41 | let tailwindContent = readFileSync(tailwindPath!, 'utf-8'); 42 | const contentMatch = getMatchArray('content', tailwindContent); 43 | const pluginsMatch = getMatchArray('plugins', tailwindContent); 44 | 45 | const insIncludeAll = contentMatch.some((c) => c.includes(tailwindRequired.content)); 46 | 47 | // Not installed HeroUI components then remove the tailwind content about heroui 48 | if (!currentComponents.length && !isHeroUIAll) { 49 | const index = pluginsMatch.findIndex((c) => c.includes('heroui')); 50 | 51 | index !== -1 && pluginsMatch.splice(index, 1); 52 | tailwindContent = replaceMatchArray('plugins', tailwindContent, pluginsMatch); 53 | 54 | // Remove the import heroui content 55 | tailwindContent = tailwindContent.replace(/(const|var|let|import)[\W\w]+?heroui.*?;\n/, ''); 56 | } 57 | 58 | // If there are already have all heroui content include then don't need to remove the content 59 | if (!insIncludeAll) { 60 | // Remove the heroui content 61 | while (contentMatch.some((c) => c.includes('heroui'))) { 62 | contentMatch.splice( 63 | contentMatch.findIndex((c) => c.includes('heroui')), 64 | 1 65 | ); 66 | } 67 | tailwindContent = replaceMatchArray('content', tailwindContent, contentMatch); 68 | } 69 | // if (!currentComponents.length && isHeroUIAll) { 70 | // const index = contentMatch.findIndex(c => c.includes('heroui')); 71 | 72 | // // Remove the heroui content 73 | // index !== -1 && 74 | // contentMatch.splice( 75 | // contentMatch.indexOf('./node_modules/@heroui/theme/dist/components'), 76 | // 1 77 | // ); 78 | // tailwindContent = replaceMatchArray('content', tailwindContent, contentMatch); 79 | // } else if (!isHeroUIAll && currentComponents.length) { 80 | // const index = contentMatch.indexOf(tailwindRequired.content); 81 | 82 | // // Remove the heroui content 83 | // index !== -1 && contentMatch.splice(index, 1); 84 | // tailwindContent = replaceMatchArray('content', tailwindContent, contentMatch); 85 | // } 86 | // Write the tailwind content 87 | writeFileSync(tailwindPath!, tailwindContent, 'utf-8'); 88 | 89 | const [, ...errorInfoList] = checkTailwind( 90 | type as 'partial', 91 | tailwindPath!, 92 | currentComponents, 93 | isPnpm, 94 | undefined, 95 | true 96 | ); 97 | 98 | fixTailwind(type, {errorInfoList, format: prettier, tailwindPath: tailwindPath!}); 99 | 100 | Logger.newLine(); 101 | Logger.info(`Remove the removed components tailwind content in file: ${tailwindPath}`); 102 | } 103 | -------------------------------------------------------------------------------- /src/constants/required.ts: -------------------------------------------------------------------------------- 1 | import {existsSync} from 'node:fs'; 2 | 3 | import fg from 'fast-glob'; 4 | import {join} from 'pathe'; 5 | 6 | import {getPackageInfo} from '@helpers/package'; 7 | 8 | import {type HeroUIComponent, type HeroUIComponents} from './component'; 9 | import {resolver} from './path'; 10 | 11 | export const HEROUI_CLI = 'heroui-cli'; 12 | 13 | export const FRAMER_MOTION = 'framer-motion'; 14 | export const TAILWINDCSS = 'tailwindcss'; 15 | export const HERO_UI = '@heroui/react'; 16 | export const THEME_UI = '@heroui/theme'; 17 | export const SYSTEM_UI = '@heroui/system'; 18 | export const ALL_COMPONENTS_REQUIRED = [HERO_UI, FRAMER_MOTION] as const; 19 | export const HEROUI_PREFIX = '@heroui'; 20 | 21 | export const DOCS_INSTALLED = 'https://heroui.com/docs/guide/installation#global-installation'; 22 | export const DOCS_TAILWINDCSS_SETUP = 23 | 'https://heroui.com/docs/guide/installation#tailwind-css-setup'; 24 | export const DOCS_APP_SETUP = 'https://heroui.com/docs/guide/installation#provider-setup'; 25 | export const DOCS_PNPM_SETUP = 'https://heroui.com/docs/guide/installation#setup-pnpm-optional'; 26 | export const DOCS_PROVIDER_SETUP = 'https://heroui.com/docs/guide/installation#provider-setup'; 27 | 28 | // Record the required content of tailwind.config file 29 | export const tailwindRequired = { 30 | checkPluginsRegex: /heroui(([\W\w]+)?)/, 31 | content: './node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}', 32 | darkMode: 'darkMode: "class"', 33 | importContent: (isTypescript = false) => { 34 | if (isTypescript) { 35 | return `import {heroui} from '@heroui/theme';`; 36 | } 37 | 38 | return `const {heroui} = require('@heroui/theme');`; 39 | }, 40 | plugins: 'heroui()' 41 | } as const; 42 | 43 | export const individualTailwindRequired = { 44 | content: (currentComponents: HeroUIComponents, isPnpm: boolean) => { 45 | currentComponents.forEach((component) => { 46 | const walkDeps = walkDepComponents(component, isPnpm) as HeroUIComponents; 47 | 48 | currentComponents.push(...walkDeps); 49 | }); 50 | 51 | const outputComponents = [ 52 | ...new Set( 53 | currentComponents.map((component) => { 54 | return component.style || component.name; 55 | }) 56 | ) 57 | ]; 58 | 59 | if (outputComponents.length === 1) { 60 | return `./node_modules/@heroui/theme/dist/components/${outputComponents[0]}.js`; 61 | } 62 | const requiredContent = outputComponents 63 | .reduce((acc, component) => { 64 | return (acc += `${component}|`); 65 | }, '') 66 | .replace(/\|$/, ''); 67 | 68 | return `./node_modules/@heroui/theme/dist/components/(${requiredContent}).js`; 69 | }, 70 | plugins: 'heroui()' 71 | } as const; 72 | 73 | export const appRequired = { 74 | import: 'HeroUIProvider' 75 | } as const; 76 | 77 | export const pnpmRequired = { 78 | content: 'public-hoist-pattern[]=*@heroui/*' 79 | } as const; 80 | 81 | export function walkDepComponents(heroUIComponent: HeroUIComponent, isPnpm: boolean) { 82 | const component = heroUIComponent.name; 83 | let componentPath = resolver(`node_modules/@heroui/${component}`); 84 | const components = [heroUIComponent]; 85 | 86 | if (!existsSync(componentPath) && isPnpm) { 87 | const pnpmDir = resolver('node_modules/.pnpm'); 88 | 89 | const file = fg.sync(`**/@heroui/${component}`, { 90 | absolute: true, 91 | cwd: pnpmDir, 92 | onlyDirectories: true 93 | })[0]; 94 | 95 | if (file) { 96 | componentPath = file; 97 | } else { 98 | return components; 99 | } 100 | } 101 | 102 | const {currentComponents} = getPackageInfo(join(componentPath, 'package.json')); 103 | 104 | if (currentComponents.length) { 105 | for (const component of currentComponents) { 106 | const result = walkDepComponents(component, isPnpm); 107 | 108 | components.push(...result); 109 | } 110 | } 111 | 112 | return components; 113 | } 114 | -------------------------------------------------------------------------------- /src/helpers/package.ts: -------------------------------------------------------------------------------- 1 | import type {UpgradeOption} from './actions/upgrade/upgrade-types'; 2 | 3 | import {readFileSync} from 'node:fs'; 4 | 5 | import {type HeroUIComponents} from 'src/constants/component'; 6 | import {HERO_UI} from 'src/constants/required'; 7 | import {store} from 'src/constants/store'; 8 | import {getCacheExecData} from 'src/scripts/cache/cache'; 9 | import {getLatestVersion} from 'src/scripts/helpers'; 10 | 11 | import {Logger} from './logger'; 12 | import {colorMatchRegex} from './output-info'; 13 | import {getVersionAndMode} from './utils'; 14 | 15 | /** 16 | * Get the package information 17 | * @param packagePath string 18 | * @param transformVersion boolean 19 | */ 20 | export function getPackageInfo(packagePath: string, transformVersion = true) { 21 | let pkg; 22 | 23 | try { 24 | pkg = JSON.parse(readFileSync(packagePath, 'utf-8')); 25 | } catch (error) { 26 | Logger.prefix('error', `Error reading package.json file: ${packagePath} \nError: ${error}`); 27 | } 28 | 29 | const devDependencies = pkg.devDependencies || {}; 30 | const dependencies = pkg.dependencies || {}; 31 | const allDependencies = {...devDependencies, ...dependencies}; 32 | const allDependenciesKeys = new Set(Object.keys(allDependencies)); 33 | 34 | const currentComponents = (store.heroUIComponents as unknown as HeroUIComponents) 35 | .map((component) => { 36 | let version = component.version; 37 | let versionMode = component.versionMode; 38 | 39 | if (allDependenciesKeys.has(component.package)) { 40 | const data = getVersionAndMode(allDependencies, component.package); 41 | 42 | version = transformVersion ? `${data.currentVersion} new: ${version}` : data.currentVersion; 43 | versionMode = data.versionMode; 44 | } 45 | 46 | return { 47 | ...component, 48 | version, 49 | versionMode 50 | }; 51 | }) 52 | .filter((component) => allDependenciesKeys.has(component.package)) as HeroUIComponents; 53 | const isAllComponents = allDependenciesKeys.has(HERO_UI); 54 | 55 | return { 56 | allDependencies, 57 | allDependenciesKeys, 58 | currentComponents, 59 | dependencies, 60 | devDependencies, 61 | isAllComponents, 62 | packageJson: pkg 63 | }; 64 | } 65 | 66 | export function transformComponentsToPackage(components: string[]) { 67 | return components.map((component) => { 68 | const herouiComponent = store.heroUIComponentsMap[component]; 69 | const packageName = herouiComponent?.package; 70 | 71 | return packageName ? packageName : component; 72 | }); 73 | } 74 | 75 | /** 76 | * Get the package detail information 77 | * @param components need package name 78 | * @param allDependencies 79 | * @returns 80 | */ 81 | export async function transformPackageDetail( 82 | components: string[], 83 | allDependencies: Record, 84 | transformVersion = true 85 | ): Promise { 86 | const result: HeroUIComponents = []; 87 | 88 | for (const component of components) { 89 | let {currentVersion} = getVersionAndMode(allDependencies, component); 90 | const {versionMode} = getVersionAndMode(allDependencies, component); 91 | const docs = ( 92 | ((await getCacheExecData(`npm show ${component} homepage`)) || '') as string 93 | ).replace(/\n/, ''); 94 | const description = ( 95 | ((await getCacheExecData(`npm show ${component} description`)) || '') as string 96 | ).replace(/\n/, ''); 97 | const latestVersion = 98 | store.heroUIComponentsPackageMap[component]?.version || (await getLatestVersion(component)); 99 | 100 | currentVersion = transformVersion ? `${currentVersion} new: ${latestVersion}` : currentVersion; 101 | 102 | const detailPackageInfo: HeroUIComponents[0] = { 103 | description: description || '', 104 | docs: docs || '', 105 | name: component, 106 | package: component, 107 | peerDependencies: {}, 108 | status: 'stable', 109 | style: '', 110 | version: currentVersion, 111 | versionMode: versionMode 112 | }; 113 | 114 | result.push(detailPackageInfo); 115 | } 116 | 117 | return result; 118 | } 119 | 120 | /** 121 | * Get the complete version 122 | * @example getCompleteVersion({latestVersion: '1.0.0', versionMode: '^'}) --> '^1.0.0' 123 | */ 124 | export function getCompleteVersion(upgradeOption: UpgradeOption) { 125 | return `${upgradeOption.versionMode || ''}${upgradeOption.latestVersion.replace( 126 | colorMatchRegex, 127 | '' 128 | )}`; 129 | } 130 | -------------------------------------------------------------------------------- /packages/codemod/src/helpers/actions/migrate/migrate-common.ts: -------------------------------------------------------------------------------- 1 | import type {StoreObject} from '../../store'; 2 | 3 | import jscodeshift from 'jscodeshift'; 4 | 5 | /** 6 | * Migrate the name of the import 7 | * @example 8 | * migrateImportName(parsedContent, 'nextui', 'heroui'); 9 | * import {nextui} from 'xxx'; -> import {heroui} from 'xxx'; 10 | * import nextui from 'xxx'; -> import heroui from 'xxx'; 11 | * const {nextui} = require('xxx'); -> const {heroui} = require('xxx'); 12 | * const nextui = require('xxx'); -> const heroui = require('xxx'); 13 | */ 14 | export function migrateImportName( 15 | parsedContent: StoreObject['parsedContent'], 16 | match: string, 17 | replace: string 18 | ) { 19 | let dirtyFlag = false; 20 | 21 | parsedContent?.find(jscodeshift.ImportDeclaration).forEach((path) => { 22 | path.node.specifiers?.forEach((specifier) => { 23 | // ImportSpecifier 24 | if (jscodeshift.ImportSpecifier.check(specifier) && specifier.imported.name === match) { 25 | specifier.imported.name = replace; 26 | dirtyFlag = true; 27 | } 28 | if (jscodeshift.ImportDefaultSpecifier.check(specifier) && specifier.local?.name === match) { 29 | specifier.local.name = replace; 30 | dirtyFlag = true; 31 | } 32 | }); 33 | }); 34 | 35 | // Handle require statements 36 | if (!dirtyFlag) { 37 | parsedContent?.find(jscodeshift.VariableDeclaration).forEach((path) => { 38 | path.node.declarations.forEach((declaration) => { 39 | if ( 40 | jscodeshift.VariableDeclarator.check(declaration) && 41 | jscodeshift.CallExpression.check(declaration.init) && 42 | jscodeshift.Identifier.check(declaration.init.callee) && 43 | declaration.init.callee.name === 'require' 44 | ) { 45 | // Handle: const nextui = require('...') 46 | if (jscodeshift.Identifier.check(declaration.id) && declaration.id.name === match) { 47 | declaration.id.name = replace; 48 | dirtyFlag = true; 49 | } 50 | 51 | // Handle: const { nextui } = require('...') 52 | if (jscodeshift.ObjectPattern.check(declaration.id)) { 53 | declaration.id.properties.forEach((property) => { 54 | if ( 55 | jscodeshift.ObjectProperty.check(property) && 56 | jscodeshift.Identifier.check(property.key) && 57 | property.key.name === match 58 | ) { 59 | property.key.name = replace; 60 | if (jscodeshift.Identifier.check(property.value)) { 61 | property.value.name = replace; 62 | } 63 | dirtyFlag = true; 64 | } 65 | }); 66 | } 67 | } 68 | }); 69 | }); 70 | } 71 | 72 | return dirtyFlag; 73 | } 74 | 75 | /** 76 | * Migrate the name of the JSX element 77 | * @example 78 | * migrateJSXElementName(parsedContent, 'NextUIProvider', 'HeroUIProvider'); 79 | * -> 80 | */ 81 | export function migrateJSXElementName( 82 | parsedContent: StoreObject['parsedContent'], 83 | match: string, 84 | replace: string 85 | ) { 86 | let dirtyFlag = false; 87 | 88 | parsedContent 89 | ?.find(jscodeshift.JSXElement, { 90 | openingElement: {name: {name: match}} 91 | }) 92 | .forEach((path) => { 93 | (path.node.openingElement.name as jscodeshift.JSXIdentifier).name = replace; 94 | if (path.node.closingElement) { 95 | (path.node.closingElement.name as jscodeshift.JSXIdentifier).name = replace; 96 | } 97 | dirtyFlag = true; 98 | }); 99 | 100 | return dirtyFlag; 101 | } 102 | 103 | export function migrateByRegex(rawContent: string, match: string, replace: string) { 104 | const regex = new RegExp(match, 'g'); 105 | const dirtyFlag = regex.test(rawContent); 106 | 107 | if (dirtyFlag) { 108 | rawContent = rawContent.replace(regex, replace); 109 | } 110 | 111 | return { 112 | dirtyFlag, 113 | rawContent 114 | }; 115 | } 116 | 117 | /** 118 | * Migrate the name of the CallExpression 119 | * @example 120 | * migrateCallExpressionName(parsedContent, 'nextui', 'heroui'); 121 | * nextui() -> heroui() 122 | */ 123 | export function migrateCallExpressionName( 124 | parsedContent: StoreObject['parsedContent'], 125 | match: string, 126 | replace: string 127 | ) { 128 | let dirtyFlag = false; 129 | 130 | // Replace `nextui` with `heroui` in the plugins array 131 | parsedContent?.find(jscodeshift.CallExpression, {callee: {name: match}}).forEach((path) => { 132 | path.get('callee').replace(jscodeshift.identifier(replace)); 133 | dirtyFlag = true; 134 | }); 135 | 136 | return dirtyFlag; 137 | } 138 | -------------------------------------------------------------------------------- /test/src/check/check-peer-dependencies.spec.ts: -------------------------------------------------------------------------------- 1 | import {checkPeerDependencies} from '@helpers/check'; 2 | import {beforeEach, describe, expect, it, vi} from 'vitest'; 3 | 4 | import * as packageHelper from '../../../src/helpers/package'; 5 | import * as upgradeHelper from '../../../src/helpers/upgrade'; 6 | 7 | describe('checkPeerDependencies', () => { 8 | beforeEach(() => { 9 | vi.clearAllMocks(); 10 | }); 11 | 12 | it('should return empty array when no peer dependencies need updating', async () => { 13 | const mockAllDependencies = { 14 | '@heroui/react': '1.0.0', 15 | react: '18.0.0' 16 | }; 17 | const mockPackageNames = ['@heroui/react']; 18 | 19 | vi.spyOn(upgradeHelper, 'getPackagePeerDep').mockResolvedValue([ 20 | { 21 | isLatest: true, 22 | latestVersion: '18.0.0', 23 | package: 'react', 24 | version: '18.0.0', 25 | versionMode: 'exact' 26 | } 27 | ]); 28 | 29 | const result = await checkPeerDependencies({ 30 | allDependencies: mockAllDependencies, 31 | packageNames: mockPackageNames 32 | }); 33 | 34 | expect(result).toEqual([]); 35 | expect(upgradeHelper.getPackagePeerDep).toHaveBeenCalledWith( 36 | '@heroui/react', 37 | mockAllDependencies, 38 | expect.any(Set) 39 | ); 40 | }); 41 | 42 | it('should return array of outdated peer dependencies', async () => { 43 | const mockAllDependencies = { 44 | '@heroui/react': '1.0.0', 45 | react: '17.0.0' 46 | }; 47 | const mockPackageNames = ['@heroui/react']; 48 | 49 | vi.spyOn(upgradeHelper, 'getPackagePeerDep').mockResolvedValue([ 50 | { 51 | isLatest: false, 52 | latestVersion: '18.0.0', 53 | package: 'react', 54 | version: '17.0.0', 55 | versionMode: 'exact' 56 | } 57 | ]); 58 | 59 | const result = await checkPeerDependencies({ 60 | allDependencies: mockAllDependencies, 61 | packageNames: mockPackageNames 62 | }); 63 | 64 | expect(result).toEqual(['react@18.0.0']); 65 | }); 66 | 67 | it('should handle multiple packages with peer dependencies', async () => { 68 | const mockAllDependencies = { 69 | '@heroui/icons': '1.0.0', 70 | '@heroui/react': '1.0.0', 71 | react: '17.0.0', 72 | typescript: '4.0.0' 73 | }; 74 | const mockPackageNames = ['@heroui/react', '@heroui/icons']; 75 | 76 | vi.spyOn(packageHelper, 'getPackageInfo').mockReturnValue({ 77 | allDependencies: mockAllDependencies, 78 | allDependenciesKeys: new Set(Object.keys(mockAllDependencies)), 79 | currentComponents: [], 80 | dependencies: {}, 81 | devDependencies: {}, 82 | isAllComponents: false, 83 | packageJson: { 84 | dependencies: mockAllDependencies 85 | } 86 | }); 87 | 88 | vi.spyOn(upgradeHelper, 'getPackagePeerDep') 89 | .mockResolvedValueOnce([ 90 | { 91 | isLatest: false, 92 | latestVersion: '18.0.0', 93 | package: 'react', 94 | version: '17.0.0', 95 | versionMode: 'exact' 96 | } 97 | ]) 98 | .mockResolvedValueOnce([ 99 | { 100 | isLatest: false, 101 | latestVersion: '5.0.0', 102 | package: 'typescript', 103 | version: '4.0.0', 104 | versionMode: 'exact' 105 | } 106 | ]); 107 | 108 | const result = await checkPeerDependencies({ 109 | allDependencies: mockAllDependencies, 110 | packageNames: mockPackageNames 111 | }); 112 | 113 | expect(result).toEqual(['react@18.0.0', 'typescript@5.0.0']); 114 | expect(upgradeHelper.getPackagePeerDep).toHaveBeenCalledTimes(2); 115 | }); 116 | 117 | it('should keep only the latest version when same package appears multiple times', async () => { 118 | const mockAllDependencies = { 119 | '@heroui/icons': '1.0.0', 120 | '@heroui/react': '1.0.0', 121 | react: '17.0.0' 122 | }; 123 | const mockPackageNames = ['@heroui/react', '@heroui/icons']; 124 | 125 | vi.spyOn(packageHelper, 'getPackageInfo').mockReturnValue({ 126 | allDependencies: mockAllDependencies, 127 | allDependenciesKeys: new Set(Object.keys(mockAllDependencies)), 128 | currentComponents: [], 129 | dependencies: {}, 130 | devDependencies: {}, 131 | isAllComponents: false, 132 | packageJson: { 133 | dependencies: mockAllDependencies 134 | } 135 | }); 136 | 137 | vi.spyOn(upgradeHelper, 'getPackagePeerDep') 138 | .mockResolvedValueOnce([ 139 | { 140 | isLatest: false, 141 | latestVersion: '18.0.0', 142 | package: 'react', 143 | version: '17.0.0', 144 | versionMode: 'exact' 145 | } 146 | ]) 147 | .mockResolvedValueOnce([ 148 | { 149 | isLatest: false, 150 | latestVersion: '18.2.0', 151 | package: 'react', 152 | version: '17.0.0', 153 | versionMode: 'exact' 154 | } 155 | ]); 156 | 157 | const result = await checkPeerDependencies({ 158 | allDependencies: mockAllDependencies, 159 | packageNames: mockPackageNames 160 | }); 161 | 162 | expect(result).toEqual(['react@18.2.0']); 163 | expect(upgradeHelper.getPackagePeerDep).toHaveBeenCalledTimes(2); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /src/actions/remove-action.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | import type {SAFE_ANY} from '@helpers/type'; 3 | 4 | import {existsSync, readFileSync, writeFileSync} from 'node:fs'; 5 | 6 | import chalk from 'chalk'; 7 | 8 | import {checkIllegalComponents} from '@helpers/check'; 9 | import {detect} from '@helpers/detect'; 10 | import {Logger} from '@helpers/logger'; 11 | import {outputComponents} from '@helpers/output-info'; 12 | import { 13 | getPackageInfo, 14 | transformComponentsToPackage, 15 | transformPackageDetail 16 | } from '@helpers/package'; 17 | import {removeDependencies, removeTailwind} from '@helpers/remove'; 18 | import {findFiles} from '@helpers/utils'; 19 | import {resolver} from 'src/constants/path'; 20 | import { 21 | DOCS_PROVIDER_SETUP, 22 | HERO_UI, 23 | SYSTEM_UI, 24 | THEME_UI, 25 | pnpmRequired 26 | } from 'src/constants/required'; 27 | import {getAutocompleteMultiselect, getSelect} from 'src/prompts'; 28 | 29 | interface RemoveOptionsAction { 30 | packagePath: string; 31 | all?: boolean; 32 | tailwindPath?: string; 33 | prettier?: boolean; 34 | } 35 | 36 | export async function removeAction(components: string[], options: RemoveOptionsAction) { 37 | const { 38 | all = false, 39 | packagePath = resolver('package.json'), 40 | tailwindPath = findFiles('**/tailwind.config.(j|t)s')[0] 41 | } = options; 42 | 43 | var {allDependencies, allDependenciesKeys, currentComponents} = getPackageInfo(packagePath); 44 | const packageManager = await detect(); 45 | const prettier = options.prettier ?? allDependenciesKeys.has('prettier'); 46 | 47 | let isHeroUIAll = !!allDependencies[HERO_UI]; 48 | 49 | // If no Installed HeroUI components then exit 50 | if (!currentComponents.length && !isHeroUIAll) { 51 | Logger.prefix('error', `No HeroUI components detected in your package.json at: ${packagePath}`); 52 | 53 | return; 54 | } 55 | 56 | if (all || isHeroUIAll) { 57 | components = isHeroUIAll ? [HERO_UI] : currentComponents.map((component) => component.package); 58 | } else if (!components.length) { 59 | components = await getAutocompleteMultiselect( 60 | 'Select the components to remove', 61 | currentComponents.map((component) => { 62 | return { 63 | title: component.package, 64 | value: component.package 65 | }; 66 | }) 67 | ); 68 | } else { 69 | // Check if the custom input components are valid 70 | if (!checkIllegalComponents(components)) { 71 | return; 72 | } 73 | 74 | // Transform components to package 75 | components = transformComponentsToPackage(components); 76 | } 77 | 78 | // Ask user whether need to remove these components 79 | const filteredComponents = components.includes(HERO_UI) 80 | ? await transformPackageDetail(components, allDependencies) 81 | : currentComponents.filter((component) => 82 | components.some((c) => c.includes(component.package) || c.includes(component.name)) 83 | ); 84 | 85 | outputComponents({ 86 | components: filteredComponents, 87 | message: chalk.yellowBright('❗️ Components slated for removal:') 88 | }); 89 | 90 | const isRemove = await getSelect('Confirm removal of these components:', [ 91 | {title: 'Yes', value: true}, 92 | {title: 'No', value: false} 93 | ]); 94 | 95 | if (!isRemove) { 96 | // Exit the process 97 | process.exit(0); 98 | } 99 | 100 | /** ======================== Step 1 Remove dependencies ======================== */ 101 | const removeDepList: string[] = [...components]; 102 | const filterComponents = currentComponents.filter((c) => !components.includes(c.package)); 103 | 104 | if (!filterComponents.length) { 105 | // Remove the selected components if not components leave then remove the theme-ui and system-ui 106 | allDependencies[THEME_UI] && removeDepList.push(THEME_UI); 107 | allDependencies[SYSTEM_UI] && removeDepList.push(SYSTEM_UI); 108 | } 109 | 110 | await removeDependencies(removeDepList, packageManager); 111 | 112 | /** ======================== Step 2 Remove the content ======================== */ 113 | // Get the new package information 114 | var {allDependencies, currentComponents} = getPackageInfo(packagePath, false); 115 | 116 | isHeroUIAll = !!allDependencies[HERO_UI]; 117 | 118 | const type: SAFE_ANY = isHeroUIAll ? 'all' : 'partial'; 119 | 120 | removeTailwind(type, { 121 | currentComponents, 122 | isHeroUIAll, 123 | isPnpm: packageManager === 'pnpm', 124 | prettier, 125 | tailwindPath: tailwindPath! 126 | }); 127 | 128 | /** ======================== Step 3 Remove the pnpm ======================== */ 129 | if (!currentComponents.length && !isHeroUIAll) { 130 | if (packageManager === 'pnpm') { 131 | const npmrcPath = resolver('.npmrc'); 132 | 133 | if (existsSync(npmrcPath)) { 134 | let content = readFileSync(npmrcPath, 'utf-8'); 135 | 136 | content = content.replace(pnpmRequired.content, ''); 137 | 138 | Logger.newLine(); 139 | Logger.info('Removing specified .npmrc file content'); 140 | 141 | writeFileSync(npmrcPath, content, 'utf-8'); 142 | } 143 | } 144 | 145 | Logger.newLine(); 146 | Logger.warn( 147 | `No HeroUI components remain installed. Ensure the HeroUIProvider is also removed if necessary.\nFor more information, visit: ${DOCS_PROVIDER_SETUP}` 148 | ); 149 | } 150 | 151 | Logger.newLine(); 152 | 153 | Logger.success( 154 | `✅ Successfully removed the specified HeroUI components: ${components 155 | .map((c) => chalk.underline(c)) 156 | .join(', ')}` 157 | ); 158 | 159 | process.exit(0); 160 | } 161 | -------------------------------------------------------------------------------- /src/helpers/fix.ts: -------------------------------------------------------------------------------- 1 | import type {CheckType} from './check'; 2 | 3 | import {execSync} from 'node:child_process'; 4 | import {existsSync, readFileSync, writeFileSync} from 'node:fs'; 5 | 6 | import {pnpmRequired, tailwindRequired} from 'src/constants/required'; 7 | import {getStoreSync} from 'src/constants/store'; 8 | 9 | import {Logger} from './logger'; 10 | import {getMatchArray, replaceMatchArray} from './match'; 11 | 12 | interface FixTailwind { 13 | errorInfoList: string[]; 14 | tailwindPath: string; 15 | write?: boolean; 16 | format?: boolean; 17 | } 18 | 19 | interface FixProvider { 20 | write?: boolean; 21 | format?: boolean; 22 | } 23 | 24 | export function fixProvider(appPath: string, options: FixProvider) { 25 | const {format = false, write = true} = options; 26 | let appContent = readFileSync(appPath, 'utf-8'); 27 | 28 | appContent = `import {HeroUIProvider} from "@heroui/react";\n${appContent}`; 29 | 30 | appContent = wrapWithHeroUIProvider(appContent); 31 | 32 | write && writeFileSync(appPath, appContent, 'utf-8'); 33 | format && execSync(`npx prettier --write ${appPath}`, {stdio: 'ignore'}); 34 | } 35 | 36 | function wrapWithHeroUIProvider(content: string) { 37 | const returnRegex = /return\s*\(([\S\s]*?)\);/g; 38 | const wrappedCode = content.replace(returnRegex, (_, p1) => { 39 | return `return ( 40 | 41 | ${p1.trim()} 42 | 43 | );`; 44 | }); 45 | 46 | return wrappedCode; 47 | } 48 | 49 | export function fixTailwind(type: CheckType, options: FixTailwind) { 50 | const {errorInfoList, format = false, tailwindPath, write = true} = options; 51 | 52 | if (!errorInfoList.length) { 53 | return; 54 | } 55 | 56 | let tailwindContent = readFileSync(tailwindPath, 'utf-8'); 57 | let contentMatch = getMatchArray('content', tailwindContent); 58 | const pluginsMatch = getMatchArray('plugins', tailwindContent); 59 | 60 | for (const errorInfo of errorInfoList) { 61 | const [errorType, info] = transformErrorInfo(errorInfo); 62 | 63 | if (errorType === 'content') { 64 | // Check if all the required content is added then skip 65 | const allPublic = contentMatch.includes(tailwindRequired.content); 66 | 67 | if (allPublic) continue; 68 | 69 | contentMatch = contentMatch.filter((content) => !content.includes('@heroui/theme/dist/')); 70 | contentMatch.push(info); 71 | tailwindContent = replaceMatchArray( 72 | 'content', 73 | tailwindContent, 74 | contentMatch, 75 | contentMatch 76 | .map((v, index, arr) => { 77 | // Add 4 spaces before the content 78 | if (index === 0) { 79 | if (arr.length === 1) { 80 | return `\n ${JSON.stringify(v)}\n`; 81 | } 82 | 83 | return `\n ${JSON.stringify(v)}`; 84 | } 85 | if (arr.length - 1 === index) { 86 | return ` ${JSON.stringify(v)}\n `; 87 | } 88 | 89 | return ` ${JSON.stringify(v)}`; 90 | }) 91 | .join(',\n') 92 | ); 93 | } else if (errorType === 'plugins') { 94 | pluginsMatch.push(info); 95 | tailwindContent = replaceMatchArray('plugins', tailwindContent, pluginsMatch); 96 | 97 | // Add import content 98 | const importContent = tailwindRequired.importContent(tailwindPath.endsWith('.ts')); 99 | 100 | tailwindContent = `${importContent}\n${tailwindContent}`; 101 | } 102 | 103 | if (type === 'all' && errorType === 'darkMode') { 104 | // Add darkMode under the plugins content in tailwindContent 105 | const darkModeIndex = tailwindContent.indexOf('plugins') - 1; 106 | const darkModeContent = tailwindRequired.darkMode; 107 | 108 | tailwindContent = `${tailwindContent.slice( 109 | 0, 110 | darkModeIndex 111 | )} ${darkModeContent},\n${tailwindContent.slice(darkModeIndex)}`; 112 | } 113 | } 114 | 115 | write && writeFileSync(tailwindPath, tailwindContent, 'utf-8'); 116 | 117 | if (format) { 118 | try { 119 | execSync(`npx prettier --write ${tailwindPath}`, {stdio: 'ignore'}); 120 | } catch (error) { 121 | Logger.warn(`Prettier failed to format ${tailwindPath}`); 122 | } 123 | } 124 | } 125 | 126 | function transformErrorInfo(errorInfo: string): [keyof typeof tailwindRequired, string] { 127 | if (tailwindRequired.darkMode.includes(errorInfo)) { 128 | return ['darkMode', errorInfo]; 129 | } else if (tailwindRequired.plugins.includes(errorInfo)) { 130 | return ['plugins', errorInfo]; 131 | } else { 132 | return ['content', errorInfo]; 133 | } 134 | } 135 | 136 | export function fixPnpm( 137 | npmrcPath: string, 138 | write = true, 139 | runInstall = true, 140 | logger: (({message, runInstall}) => void) | undefined = undefined 141 | ) { 142 | if (!existsSync(npmrcPath)) { 143 | write && writeFileSync(npmrcPath, pnpmRequired.content, 'utf-8'); 144 | } else { 145 | let content = readFileSync(npmrcPath, 'utf-8'); 146 | 147 | content = `${pnpmRequired.content}\n${content}`; 148 | 149 | write && writeFileSync(npmrcPath, content, 'utf-8'); 150 | } 151 | 152 | if (!logger) { 153 | Logger.newLine(); 154 | Logger.log(`Added the required content in file: ${npmrcPath}`); 155 | 156 | if (runInstall && !getStoreSync('debug')) { 157 | Logger.newLine(); 158 | Logger.log('Pnpm restructure will be run now'); 159 | execSync('pnpm install', {stdio: 'inherit'}); 160 | } 161 | 162 | return; 163 | } 164 | 165 | // Custom logger 166 | logger({message: `Added the required content in file: ${npmrcPath}`, runInstall}); 167 | } 168 | -------------------------------------------------------------------------------- /src/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | import type {Agent} from './detect'; 2 | import type {PascalCase, SAFE_ANY} from './type'; 3 | 4 | import chalk from 'chalk'; 5 | import {compareVersions} from 'compare-versions'; 6 | import fg, {type Options} from 'fast-glob'; 7 | 8 | import {ROOT} from 'src/constants/path'; 9 | 10 | import {Logger} from './logger'; 11 | import {colorMatchRegex} from './output-info'; 12 | 13 | export const versionModeRegex = /([\^~])/; 14 | 15 | export function getCommandDescAndLog(log: string, desc: string) { 16 | Logger.gradient(log); 17 | 18 | return desc; 19 | } 20 | 21 | /** 22 | * Convert a string to PascalCase. 23 | * @example 'test-test' => 'TestTest' 24 | * @param str 25 | */ 26 | export function PasCalCase(str: T) { 27 | return str 28 | .split('-') 29 | .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) 30 | .join('') as PascalCase; 31 | } 32 | 33 | /** 34 | * Find files by glob pattern. 35 | * @param glob 36 | * @param options 37 | */ 38 | export const findFiles = (glob: string, options?: Options) => { 39 | const file = fg.sync(glob, { 40 | absolute: true, 41 | cwd: ROOT, 42 | deep: 5, 43 | ignore: ['node_modules/**', 'dist/**', 'build/**', 'coverage/**', 'public/**', 'out/**'], 44 | onlyFiles: true, 45 | ...options 46 | }); 47 | 48 | return file; 49 | }; 50 | 51 | export function transformOption(options: boolean | 'false') { 52 | if (options === 'false') return false; 53 | 54 | return !!options; 55 | } 56 | 57 | export function omit(obj: Record, keys: string[]) { 58 | return Object.fromEntries(Object.entries(obj).filter(([key]) => !keys.includes(key))); 59 | } 60 | 61 | export function getUpgradeType({ 62 | major, 63 | minor, 64 | patch 65 | }: { 66 | major: boolean; 67 | minor: boolean; 68 | patch: boolean; 69 | }) { 70 | if (major) return 'major'; 71 | if (minor) return 'minor'; 72 | if (patch) return 'patch'; 73 | 74 | return 'minor'; 75 | } 76 | 77 | export function getColorVersion(currentVersion: string, latestVersion: string) { 78 | currentVersion = transformPeerVersion(currentVersion); 79 | latestVersion = transformPeerVersion(latestVersion); 80 | 81 | if (isMajorUpdate(currentVersion, latestVersion)) { 82 | return isMajorUpdate(currentVersion, latestVersion); 83 | } else if (isMinorUpdate(currentVersion, latestVersion)) { 84 | return isMinorUpdate(currentVersion, latestVersion); 85 | } else if (isPatchUpdate(currentVersion, latestVersion)) { 86 | return isPatchUpdate(currentVersion, latestVersion); 87 | } 88 | 89 | return latestVersion; 90 | } 91 | 92 | export function isMajorUpdate(currentVersion: string, latestVersion: string) { 93 | const currentVersionArr = currentVersion.split('.'); 94 | const latestVersionArr = latestVersion.split('.'); 95 | 96 | if (currentVersionArr[0] !== latestVersionArr[0]) { 97 | return chalk.redBright(latestVersionArr.join('.')); 98 | } 99 | 100 | return ''; 101 | } 102 | 103 | export function isMinorUpdate(currentVersion: string, latestVersion: string) { 104 | const currentVersionArr = currentVersion.split('.'); 105 | const latestVersionArr = latestVersion.split('.'); 106 | 107 | if (currentVersionArr[1] !== latestVersionArr[1]) { 108 | return `${chalk.white(latestVersionArr[0])}${chalk.white('.')}${chalk.cyanBright( 109 | latestVersionArr.slice(1).join('.') 110 | )}`; 111 | } 112 | 113 | return ''; 114 | } 115 | 116 | export function isPatchUpdate(currentVersion: string, latestVersion: string) { 117 | const currentVersionArr = currentVersion.split('.'); 118 | const latestVersionArr = latestVersion.split('.'); 119 | 120 | if (currentVersionArr[2] !== latestVersionArr[2]) { 121 | return `${chalk.white(latestVersionArr.slice(0, 2).join('.'))}${chalk.white( 122 | '.' 123 | )}${chalk.greenBright(latestVersionArr.slice(2).join('.'))}`; 124 | } 125 | 126 | return ''; 127 | } 128 | 129 | export function getVersionAndMode(allDependencies: Record, packageName: string) { 130 | const currentVersion = allDependencies[packageName].replace(versionModeRegex, ''); 131 | const versionMode = allDependencies[packageName].match(versionModeRegex)?.[1] || ''; 132 | 133 | return { 134 | currentVersion, 135 | versionMode 136 | }; 137 | } 138 | 139 | export function getPackageManagerInfo(packageManager: T) { 140 | const packageManagerInfo = { 141 | bun: { 142 | install: 'add', 143 | remove: 'remove', 144 | run: 'run' 145 | }, 146 | npm: { 147 | install: 'install', 148 | remove: 'uninstall', 149 | run: 'run' 150 | }, 151 | pnpm: { 152 | install: 'add', 153 | remove: 'remove', 154 | run: 'run' 155 | }, 156 | yarn: { 157 | install: 'add', 158 | remove: 'remove', 159 | run: 'run' 160 | } 161 | } as const; 162 | 163 | return packageManagerInfo[packageManager] as (typeof packageManagerInfo)[T]; 164 | } 165 | 166 | /** 167 | * @example transformPeerVersion('>=1.0.0') // '1.0.0' 168 | * @example transformPeerVersion(">=11.5.6 || >=12.0.0-alpha.1") // 11.5.6 169 | * @param version 170 | */ 171 | export function transformPeerVersion(version: string, isLatest = false) { 172 | const ranges = version.split('||').map((r) => r.trim()); 173 | const result = ranges 174 | .map((range) => { 175 | return range.replace(/^[<=>^~]+\s*/, '').trim(); 176 | }) 177 | .sort((a, b) => { 178 | if (isLatest) { 179 | return compareVersions(b, a); 180 | } 181 | 182 | return compareVersions(a, b); 183 | }); 184 | 185 | return result[0]!; 186 | } 187 | 188 | export function fillAnsiLength(str: string, length: number) { 189 | const stripStr = str.replace(colorMatchRegex, ''); 190 | const fillSpace = length - stripStr.length > 0 ? ' '.repeat(length - stripStr.length) : ''; 191 | 192 | return `${str}${fillSpace}`; 193 | } 194 | 195 | export function strip(str: string) { 196 | return str.replace(colorMatchRegex, ''); 197 | } 198 | -------------------------------------------------------------------------------- /packages/codemod/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | nextui 4 |

@heroui/codemod

5 | 6 |

7 | 8 |
9 | 10 | The CLI provides a comprehensive suite of tools to migrate your codebase from NextUI to HeroUI. 11 | 12 | ## Quick Start 13 | 14 | > **Note**: The heroui CLI requires [Node.js](https://nodejs.org/en) _18.17.x_ or later 15 | > 16 | > **Note**: If running in monorepo, you need to run the command in the root of your monorepo 17 | 18 | You can start using @heroui/codemod in one of the following ways: 19 | 20 | ### Npx 21 | 22 | ```bash 23 | npx @heroui/codemod@latest 24 | ``` 25 | 26 | ### Global Installation 27 | 28 | ```bash 29 | npm install -g @heroui/codemod 30 | ``` 31 | 32 | ## Usage 33 | 34 | ```bash 35 | Usage: @heroui/codemod [command] 36 | 37 | A CLI tool for migrating your codebase to heroui 38 | 39 | Arguments: 40 | codemod Specify which codemod to run 41 | Codemods: import-heroui, package-json-package-name, heroui-provider, tailwindcss-heroui, css-variables, npmrc 42 | 43 | Options: 44 | -v, --version Output the current version 45 | -d, --debug Enable debug mode 46 | -h, --help Display help for command 47 | -f, --format Format the affected files with Prettier 48 | 49 | Commands: 50 | migrate [projectPath] Migrate your codebase to use heroui 51 | ``` 52 | 53 | ## Codemod Arguments 54 | 55 | ### import-heroui 56 | 57 | Updates all import statements from `@nextui-org/*` packages to their `@heroui/*` equivalents. 58 | 59 | ```bash 60 | heroui-codemod import-heroui 61 | ``` 62 | 63 | Example: 64 | 65 | 1. `import { Button } from "@nextui-org/button"` to `import { Button } from "@heroui/button"` 66 | 67 | ### package-json-package-name 68 | 69 | Updates all package names in `package.json` from `@nextui-org/*` to `@heroui/*`. 70 | 71 | ```bash 72 | heroui-codemod package-json-package-name 73 | ``` 74 | 75 | Example: 76 | 77 | 1. `@nextui-org/button: x.xx.xxx` to `@heroui/button: x.xx.xxx` 78 | 79 | ### heroui-provider 80 | 81 | Migrate `NextUIProvider` to `HeroProvider`. 82 | 83 | ```bash 84 | heroui-codemod heroui-provider 85 | ``` 86 | 87 | Example: 88 | 89 | 1. `import { NextUIProvider } from "@nextui-org/react"` to `import { HeroProvider } from "@heroui/react"` 90 | 91 | 2. `...` to `...` 92 | 93 | ### tailwindcss-heroui 94 | 95 | Migrate all the `tailwind.config.(j|t)s` file to use the `@heroui` package. 96 | 97 | ```bash 98 | heroui-codemod tailwindcss-heroui 99 | ``` 100 | 101 | Example: 102 | 103 | 1. `const {nextui} = require('@nextui-org/theme')` to `const {heroui} = require('@heroui/theme')` 104 | 105 | 2. `plugins: [nextui({...})]` to `plugins: [heroui({...})]` 106 | 107 | 3. `content: ['./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}']` to `content: ['./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}']` 108 | 109 | 4. `var(--nextui-primary-500)` to `var(--heroui-primary-500)` 110 | 111 | ### css-variables 112 | 113 | Migrate all the css variables in the file starting with `--nextui-` to `--heroui-`. 114 | 115 | ```bash 116 | heroui-codemod css-variables 117 | ``` 118 | 119 | Example: 120 | 121 | 1. `className="text-[var(--nextui-primary-500)]"` to `className="text-[var(--heroui-primary-500)]"` 122 | 123 | ### npmrc (Pnpm only) 124 | 125 | Migrate the `.npmrc` file to use the `@heroui` package. 126 | 127 | ```bash 128 | heroui-codemod npmrc 129 | ``` 130 | 131 | Example: 132 | 133 | 1. `public-hoist-pattern[]=*@nextui-org/theme*` to `public-hoist-pattern[]=*@heroui/theme*` 134 | 135 | ## Migrate Command 136 | 137 | Migrate your entire codebase from NextUI to heroui. You can choose which codemods to run during the migration process. 138 | 139 | ```bash 140 | heroui-codemod migrate [projectPath] [--format] 141 | ``` 142 | 143 | Example: 144 | 145 | ```bash 146 | heroui-codemod migrate ./my-nextui-app 147 | ``` 148 | 149 | Output: 150 | 151 | ```bash 152 | heroui Codemod v0.0.1 153 | 154 | ┌ Starting to migrate nextui to heroui 155 | │ 156 | ◇ 1. Migrating "package.json" 157 | │ 158 | ◇ Do you want to migrate package.json? 159 | │ Yes 160 | │ 161 | ◇ Migrated package.json 162 | │ 163 | ◇ 2. Migrating import "nextui" to "heroui" 164 | │ 165 | ◇ Do you want to migrate import nextui to heroui? 166 | │ Yes 167 | │ 168 | ◇ Migrated import nextui to heroui 169 | │ 170 | ◇ 3. Migrating "NextUIProvider" to "HeroUIProvider" 171 | │ 172 | ◇ Do you want to migrate NextUIProvider to HeroUIProvider? 173 | │ Yes 174 | │ 175 | ◇ Migrated NextUIProvider to HeroUIProvider 176 | │ 177 | ◇ 4. Migrating "tailwindcss" 178 | │ 179 | ◇ Do you want to migrate tailwindcss? 180 | │ Yes 181 | │ 182 | ◇ Migrated tailwindcss 183 | │ 184 | ◇ 5. Migrating "css variables" 185 | │ 186 | ◇ Do you want to migrate css variables? 187 | │ Yes 188 | │ 189 | ◇ Migrated css variables 190 | │ 191 | ◇ 6. Migrating "npmrc" (Pnpm only) 192 | │ 193 | ◇ Do you want to migrate npmrc (Pnpm only) ? 194 | │ Yes 195 | │ 196 | ◇ Migrated npmrc 197 | │ 198 | └ ✅ Migration completed! 199 | ``` 200 | 201 | ### Community 202 | 203 | We're excited to see the community adopt NextUI CLI, raise issues, and provide feedback. 204 | Whether it's a feature request, bug report, or a project to showcase, please get involved! 205 | 206 | - [Discord](https://discord.gg/9b6yyZKmH4) 207 | - [Twitter](https://twitter.com/getnextui) 208 | - [GitHub Discussions](https://github.com/nextui-org/nextui-cli/discussions) 209 | 210 | ## Contributing 211 | 212 | Contributions are always welcome! 213 | 214 | See [CONTRIBUTING.md](https://github.com/nextui-org/nextui-cli/blob/main/CONTRIBUTING.md) for ways to get started. 215 | 216 | Please adhere to this project's [CODE_OF_CONDUCT](https://github.com/nextui-org/nextui-cli/blob/main/CODE_OF_CONDUCT.md). 217 | 218 | ## License 219 | 220 | [MIT](https://choosealicense.com/licenses/mit/) 221 | -------------------------------------------------------------------------------- /src/helpers/actions/add/heroui-chat/add-hero-chat-codebase.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import {join} from 'node:path'; 3 | 4 | import * as p from '@clack/prompts'; 5 | import chalk from 'chalk'; 6 | import {resolve} from 'pathe'; 7 | 8 | import {detect} from '@helpers/detect'; 9 | import {exec} from '@helpers/exec'; 10 | import {Logger} from '@helpers/logger'; 11 | import {getPackageInfo} from '@helpers/package'; 12 | import {confirmClack, getDirectoryClack} from 'src/prompts/clack'; 13 | 14 | import {getBaseStorageUrl} from './get-base-storage-url'; 15 | import {getCodeBaseFiles} from './get-codebase-files'; 16 | import {fetchAllRelatedFiles} from './get-related-imports'; 17 | import {writeFilesWithMkdir} from './write-files'; 18 | 19 | export interface AddActionOptions { 20 | all?: boolean; 21 | prettier?: boolean; 22 | packagePath?: string; 23 | tailwindPath?: string; 24 | appPath?: string; 25 | addApp?: boolean; 26 | beta?: boolean; 27 | directory: string; 28 | } 29 | 30 | const httpRegex = /^https?:\/\//; 31 | const APP_FILE = 'App.tsx'; 32 | 33 | export function isAddingHeroChatCodebase(targets: string[]) { 34 | return targets.some((target) => httpRegex.test(target)); 35 | } 36 | 37 | export async function addHeroChatCodebase(targets: string[], options: AddActionOptions) { 38 | p.intro(chalk.cyan('Starting to add HeroUI Chat codebase')); 39 | 40 | const directory = resolve(process.cwd(), options.directory ?? (await getDirectoryClack())); 41 | const {baseStorageUrl, chatTitle, userId} = await getBaseStorageUrl(targets[0]!); 42 | const chatTitleFile = chatTitle ? `${chatTitle}.tsx` : undefined; 43 | 44 | const ifExists = fs.existsSync(directory); 45 | 46 | if (!ifExists) { 47 | Logger.error(`Directory ${directory} does not exist`); 48 | process.exit(1); 49 | } 50 | 51 | /** ======================== Add files ======================== */ 52 | 53 | const codeFiles = await getCodeBaseFiles(baseStorageUrl, userId); 54 | const appFile = codeFiles.find((file) => file.name.includes(APP_FILE)); 55 | const pkgContent = codeFiles.find((file) => file.name.includes('package.json'))?.content; 56 | 57 | if (appFile) { 58 | const relatedFiles = await fetchAllRelatedFiles({ 59 | content: appFile.content, 60 | entries: codeFiles, 61 | filePath: 'src/App.tsx' 62 | }); 63 | 64 | for (const relatedFile of relatedFiles) { 65 | if (relatedFile.fileName.includes(APP_FILE)) { 66 | writeFilesWithMkdir(directory, `${chatTitleFile || APP_FILE}`, relatedFile.fileContent); 67 | continue; 68 | } 69 | 70 | writeFilesWithMkdir( 71 | directory, 72 | `${relatedFile.filePath.replace('src/', '')}`, 73 | relatedFile.fileContent 74 | ); 75 | } 76 | } 77 | 78 | /** ======================== Check if the project missing dependencies ======================== */ 79 | if (pkgContent) { 80 | const pkgContentJson = JSON.parse(pkgContent); 81 | const {allDependenciesKeys} = getPackageInfo(join(process.cwd(), 'package.json')); 82 | const missingDependencies = Object.keys(pkgContentJson.dependencies).filter( 83 | (key) => !allDependenciesKeys.has(key) 84 | ); 85 | 86 | if (missingDependencies.length > 0) { 87 | p.log.warn( 88 | `The project is missing the following dependencies to run the codebase: ${missingDependencies.join(', ')}` 89 | ); 90 | 91 | const isAddMissingDependencies = await confirmClack({ 92 | message: 'Do you want to add the missing dependencies?' 93 | }); 94 | 95 | const currentPkgManager = await detect(); 96 | const runCmd = currentPkgManager === 'npm' ? 'install' : 'add'; 97 | const installCmd = `${currentPkgManager} ${runCmd} ${missingDependencies.map((target) => `${target}@${pkgContentJson.dependencies[target]}`).join(' ')}`; 98 | 99 | if (isAddMissingDependencies) { 100 | try { 101 | await exec(installCmd); 102 | } catch { 103 | p.log.error( 104 | `Failed to install dependencies. Please add ${missingDependencies.join(' ')} manually.` 105 | ); 106 | } 107 | } else { 108 | p.note(`Run \`${installCmd}\` to start!`, 'Next steps'); 109 | } 110 | } 111 | } 112 | 113 | p.outro(chalk.green('✅ HeroUI Chat codebase added successfully!')); 114 | } 115 | 116 | /** ======================== For the future init project ======================== */ 117 | // else { 118 | // for (let file of filesToAdd) { 119 | // const filePath = `${url}/${file}`; 120 | // const response = await fetchRequest(filePath); 121 | 122 | // let fileContent = await response.text(); 123 | 124 | // if (chatTitleFile) { 125 | // // Update App.tsx to chatTitle 126 | // if (file.includes(APP_FILE)) { 127 | // file = file.replace(APP_FILE, chatTitleFile); 128 | // } 129 | // // Update main.tsx import 130 | // if (file.includes('main.tsx')) { 131 | // fileContent = fileContent.replace(/from '.\/App\.tsx'/, `from './${chatTitleFile}'`); 132 | // fileContent = fileContent.replace(/from '.\/App'/, `from './${chatTitle}'`); 133 | // } 134 | // } 135 | 136 | // writeFilesWithMkdir(directory, file, fileContent); 137 | // } 138 | // } 139 | 140 | /** ======================== Add templates ======================== */ 141 | // for (const [file, value] of Object.entries(templates)) { 142 | // writeFilesWithMkdir(directory, file, value.content); 143 | // } 144 | 145 | // const isInstall = await confirmClack({ 146 | // message: 'Do you want to install the dependencies?' 147 | // }); 148 | 149 | // if (isInstall) { 150 | // const packageManager = await detect(); 151 | // const installCmd = 152 | // packageManager === 'pnpm' ? 'pnpm install --shamefully-hoist' : `${packageManager} install`; 153 | 154 | // try { 155 | // await exec(`cd ${directory} && ${installCmd} && npm run dev`); 156 | // } catch { 157 | // p.log.error(`Failed to install dependencies. Please run "${installCmd}" manually.`); 158 | // } 159 | // } else { 160 | // p.note(`Cd to ${directory}\nRun \`pnpm install\` to start!`, 'Next steps'); 161 | // } 162 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # HeroUI CLI Contributing Guide 2 | 3 | Hello!, I am very excited that you are interested in contributing with HeroUI CLI. However, before submitting your contribution, be sure to take a moment and read the following guidelines. 4 | 5 | - [Code of Conduct](https://github.com/heroui-inc/heroui-cli/blob/main/CODE_OF_CONDUCT.md) 6 | - [Pull Request Guidelines](#pull-request-guidelines) 7 | - [Development Setup](#development-setup) 8 | - [Documentation](#documentation) 9 | - [Breaking Changes](#breaking-changes) 10 | - [Becoming a maintainer](#becoming-a-maintainer) 11 | 12 | ### Tooling 13 | 14 | - [PNPM](https://pnpm.io/) to manage packages and dependencies 15 | - [Tsup](https://tsup.egoist.sh/) to bundle packages 16 | 17 | ### Commit Convention 18 | 19 | Before you create a Pull Request, please check whether your commits comply with 20 | the commit conventions used in this repository. 21 | 22 | When you create a commit we kindly ask you to follow the convention 23 | `category(scope or module): message` in your commit message while using one of 24 | the following categories: 25 | 26 | - `feat / feature`: all changes that introduce completely new code or new 27 | features 28 | - `fix`: changes that fix a bug (ideally you will additionally reference an 29 | issue if present) 30 | - `refactor`: any code related change that is not a fix nor a feature 31 | - `docs`: changing existing or creating new documentation (i.e. README, docs for 32 | usage of a lib or cli usage) 33 | - `build`: all changes regarding the build of the software, changes to 34 | dependencies or the addition of new dependencies 35 | - `test`: all changes regarding tests (adding new tests or changing existing 36 | ones) 37 | - `ci`: all changes regarding the configuration of continuous integration (i.e. 38 | github actions, ci system) 39 | - `chore`: all changes to the repository that do not fit into any of the above 40 | categories 41 | 42 | e.g. `feat(components): add new prop to the avatar component` 43 | 44 | If you are interested in the detailed specification you can visit 45 | https://www.conventionalcommits.org/ or check out the 46 | [Angular Commit Message Guidelines](https://github.com/angular/angular/blob/22b96b9/CONTRIBUTING.md#-commit-message-guidelines). 47 | 48 | ## Pull Request Guidelines 49 | 50 | - The `main` branch is basically a snapshot of the latest stable version. All development must be done in dedicated branches. 51 | - Make sure that Github Actions are green 52 | - It is good to have multiple small commits while working on the PR. We'll let GitHub squash it automatically before the merge. 53 | - If you add a new feature: 54 | - Add the test case that accompanies it. 55 | - Provide a compelling reason to add this feature. Ideally, I would first open a suggestion topic and green it before working on it. 56 | - If you correct an error: 57 | - If you are solving a special problem, add (fix #xxxx [, # xxx]) (# xxxx is the problem identification) in your PR title for a better launch record, for example update entities encoding / decoding (fix # 3899). 58 | - Provide a detailed description of the error in the PR. Favorite live demo. 59 | - Add the appropriate test coverage, if applicable. 60 | 61 | ### Steps to PR 62 | 63 | 1. Fork of the heroui cli repository and clone your fork 64 | 65 | 2. Create a new branch out of the `main` branch. We follow the convention 66 | `[type/scope]`. For example `fix/dropdown-hook` or `docs/menu-typo`. `type` 67 | can be either `docs`, `fix`, `feat`, `build`, or any other conventional 68 | commit type. `scope` is just a short id that describes the scope of work. 69 | 70 | 3. Make and commit your changes following the 71 | [commit convention](https://github.com/heroui-inc/heroui-cli/blob/main/CONTRIBUTING.md#commit-convention). 72 | As you develop, you can run `pnpm lint` and 73 | `pnpm build` e.g. `pnpm lint && pnpm build` to make sure everything works as expected. 74 | 75 | 4. Please note that you might have to run `git fetch origin main:master` (where 76 | origin will be your fork on GitHub). 77 | 78 | ## Development Setup 79 | 80 | After cloning the repository, execute the following commands in the root folder: 81 | 82 | 1. Install dependencies 83 | 84 | ```bash 85 | pnpm i 86 | 87 | #or 88 | 89 | pnpm install 90 | ``` 91 | 92 | 2. Run dev to start development 93 | 94 | ```bash 95 | ## Start the dev babel server of HeroUI CLI 96 | pnpm dev 97 | ``` 98 | 99 | Remember that these commands must be executed in the root folder of the project. 100 | 101 | 3. Create a branch for your feature or fix: 102 | 103 | ```bash 104 | ## Move into a new branch for your feature 105 | git checkout -b feat/thing 106 | ``` 107 | 108 | ```bash 109 | ## Move into a new branch for your fix 110 | git checkout -b fix/something 111 | ``` 112 | 113 | 4. Test locally 114 | 115 | ```bash 116 | ## make sure pnpm dev is running 117 | npm link 118 | ## then run heroui-cli locally and test 119 | ``` 120 | 121 | > Note: ensure your version of Node is 18.17.x or higher to run scripts 122 | 123 | 5. Build the CLI 124 | 125 | ```bash 126 | pnpm lint && pnpm build 127 | ``` 128 | 129 | 6. Send your pull request: 130 | 131 | - Send your pull request to the `main` branch 132 | - Your pull request will be reviewed by the maintainers and the maintainers will decide if it is accepted or not 133 | - Once the pull request is accepted, the maintainers will merge it to the `main` branch 134 | 135 | ## Documentation 136 | 137 | Please update the docs with any command changes, the code and docs should always be in sync. 138 | 139 | The main documentation lives in `https://heroui.com/docs/guide/cli`, please create a PR in `heroui-inc/heroui` instead. 140 | 141 | ## Breaking changes 142 | 143 | Breaking changes should be accompanied with deprecations of removed functionality. The deprecated changes themselves should not be removed until the minor release after that. 144 | 145 | ## Becoming a maintainer 146 | 147 | If you are interested in becoming a HeroUI maintainer, start by 148 | reviewing issues and pull requests. Answer questions for those in need of 149 | troubleshooting. Join us in the 150 | [Discord Community](https://discord.gg/9b6yyZKmH4) chat room. 151 | Once we see you helping, either we will reach out and ask you if you want to 152 | join or you can ask one of the current maintainers to add you. We will try our 153 | best to be proactive in reaching out to those that are already helping out. 154 | 155 | GitHub by default does not publicly state that you are a member of the 156 | organization. Please feel free to change that setting for yourself so others 157 | will know who's helping out. That can be configured on the [organization 158 | list](https://github.com/orgs/heroui-inc/people) page. 159 | 160 | Being a maintainer is not an obligation. You can help when you have time and be 161 | less active when you don't. If you get a new job and get busy, that's alright. 162 | -------------------------------------------------------------------------------- /src/scripts/helpers.ts: -------------------------------------------------------------------------------- 1 | import type {SAFE_ANY} from '@helpers/type'; 2 | 3 | import {exec} from 'node:child_process'; 4 | import {existsSync, readFileSync, writeFileSync} from 'node:fs'; 5 | 6 | import retry from 'async-retry'; 7 | import chalk from 'chalk'; 8 | import {compareVersions as InternalCompareVersions, validate} from 'compare-versions'; 9 | import ora, {oraPromise} from 'ora'; 10 | 11 | import {Logger} from '@helpers/logger'; 12 | import {COMPONENTS_PATH} from 'src/constants/path'; 13 | import {getStore, getStoreSync, store} from 'src/constants/store'; 14 | 15 | import {getPackageVersion} from './cache/cache'; 16 | 17 | export type Dependencies = Record; 18 | 19 | export type Components = { 20 | name: string; 21 | package: string; 22 | version: string; 23 | docs: string; 24 | description: string; 25 | status: string; 26 | style: string; 27 | peerDependencies: Dependencies; 28 | }[]; 29 | 30 | export type ComponentsJson = { 31 | version: string; 32 | components: Components; 33 | betaComponents: Components; 34 | betaVersion: string; 35 | }; 36 | 37 | /** 38 | * Compare two versions 39 | * @example compareVersions('1.0.0', '1.0.1') // -1 40 | * compareVersions('1.0.1', '1.0.0') // 1 41 | * compareVersions('1.0.0', '1.0.0') // 0 42 | * @param version1 43 | * @param version2 44 | */ 45 | export function compareVersions(version1 = '', version2 = '') { 46 | if (!validate(version1)) { 47 | // e.g. Current version is https://pkg.pr.new/@heroui/dropdown@4656 then just upgrade to latest version 48 | return -1; 49 | } 50 | try { 51 | return InternalCompareVersions(version1, version2); 52 | } catch { 53 | // Can't not support ('18 || 19.0.0-rc.0' received) temporary solution 54 | return 0; 55 | } 56 | } 57 | 58 | export async function updateComponents() { 59 | if (!existsSync(COMPONENTS_PATH)) { 60 | // First time download the latest date from net 61 | await autoUpdateComponents(); 62 | 63 | return; 64 | } 65 | 66 | const components = JSON.parse(readFileSync(COMPONENTS_PATH, 'utf-8')) as ComponentsJson; 67 | const currentVersion = components.version; 68 | const betaVersion = components.betaVersion; 69 | const latestVersion = await getStore('latestVersion'); 70 | const latestBetaVersion = await getStore('betaVersion'); 71 | 72 | if ( 73 | compareVersions(currentVersion, latestVersion) === -1 || 74 | (betaVersion && compareVersions(betaVersion, latestBetaVersion) === -1) 75 | ) { 76 | // After the first time, check the version and update 77 | await autoUpdateComponents(latestVersion, latestBetaVersion); 78 | } 79 | } 80 | 81 | export async function getComponents() { 82 | let components: ComponentsJson = {} as ComponentsJson; 83 | 84 | await updateComponents(); 85 | 86 | try { 87 | components = JSON.parse(readFileSync(COMPONENTS_PATH, 'utf-8')) as ComponentsJson; 88 | } catch (error) { 89 | new Error(`Get components.json error: ${error}`); 90 | } 91 | 92 | return components; 93 | } 94 | 95 | export async function oraExecCmd(cmd: string, text?: string): Promise { 96 | text = text ?? `Executing ${cmd}`; 97 | 98 | const spinner = ora({ 99 | // Open ctrl + c cancel 100 | discardStdin: false, 101 | spinner: { 102 | frames: [ 103 | `⠋ ${chalk.gray(`${text}.`)}`, 104 | `⠙ ${chalk.gray(`${text}..`)}`, 105 | `⠹ ${chalk.gray(`${text}...`)}`, 106 | `⠸ ${chalk.gray(`${text}.`)}`, 107 | `⠼ ${chalk.gray(`${text}..`)}`, 108 | `⠴ ${chalk.gray(`${text}...`)}`, 109 | `⠦ ${chalk.gray(`${text}.`)}`, 110 | `⠧ ${chalk.gray(`${text}..`)}`, 111 | `⠇ ${chalk.gray(`${text}...`)}`, 112 | `⠏ ${chalk.gray(`${text}.`)}` 113 | ], 114 | interval: 150 115 | } 116 | }); 117 | 118 | spinner.start(); 119 | 120 | const result = await new Promise((resolve) => { 121 | exec(cmd, (error, stdout) => { 122 | if (error) { 123 | Logger.error(`Exec cmd ${cmd} error`); 124 | process.exit(1); 125 | } 126 | resolve(stdout.trim()); 127 | }); 128 | }); 129 | 130 | spinner.stop(); 131 | 132 | return result; 133 | } 134 | 135 | export async function getLatestVersion(packageName: string): Promise { 136 | if (store.heroUIComponentsPackageMap[packageName]) { 137 | return store.heroUIComponentsPackageMap[packageName]!.version; 138 | } 139 | 140 | const result = await getPackageVersion(packageName); 141 | 142 | return result.version; 143 | } 144 | 145 | const getUnpkgUrl = (version: string) => 146 | `https://unpkg.com/@heroui/react@${version}/dist/components.json`; 147 | 148 | export async function autoUpdateComponents(latestVersion?: string, betaVersion?: string) { 149 | [latestVersion, betaVersion] = await Promise.all([ 150 | latestVersion || getStore('latestVersion'), 151 | betaVersion || getStore('betaVersion') 152 | ]); 153 | 154 | const url = getUnpkgUrl(latestVersion); 155 | 156 | const [components, betaComponents] = await Promise.all([ 157 | downloadFile(url), 158 | getStoreSync('beta') && betaVersion 159 | ? downloadFile(getUnpkgUrl(betaVersion), false) 160 | : Promise.resolve([]) 161 | ]); 162 | 163 | const filterMissingComponents = betaComponents.filter( 164 | (component) => !components.find((c) => c.name === component.name) 165 | ); 166 | 167 | // Add missing beta components to components 168 | components.push(...filterMissingComponents); 169 | 170 | const componentsJson: ComponentsJson = { 171 | betaComponents, 172 | betaVersion, 173 | components, 174 | version: latestVersion 175 | }; 176 | 177 | writeFileSync(COMPONENTS_PATH, JSON.stringify(componentsJson, null, 2), 'utf-8'); 178 | 179 | return componentsJson; 180 | } 181 | 182 | export async function downloadFile(url: string, log = true): Promise { 183 | let data; 184 | 185 | await oraPromise( 186 | retry( 187 | async (bail) => { 188 | try { 189 | const result = await fetch(url, { 190 | body: null, 191 | headers: { 192 | 'Content-Type': 'application/json', 193 | accept: 194 | 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' 195 | }, 196 | method: 'GET', 197 | mode: 'cors' 198 | }); 199 | 200 | data = JSON.parse(await result.text()); 201 | } catch (error) { 202 | bail(error); 203 | } 204 | }, 205 | { 206 | retries: 3 207 | } 208 | ), 209 | { 210 | failText(error) { 211 | log && Logger.prefix('error', `Update components data error: ${error}`); 212 | process.exit(1); 213 | }, 214 | ...(log ? {successText: chalk.greenBright('Components data updated successfully!\n')} : {}), 215 | text: 'Fetching components data...' 216 | } 217 | ); 218 | 219 | return data; 220 | } 221 | 222 | export const isGithubAction = process.env['CI'] === 'true'; 223 | -------------------------------------------------------------------------------- /src/actions/init-action.ts: -------------------------------------------------------------------------------- 1 | import type {Agent} from '@helpers/detect'; 2 | import type {GetUnionLastValue} from '@helpers/type'; 3 | 4 | import {existsSync, renameSync} from 'node:fs'; 5 | 6 | import * as p from '@clack/prompts'; 7 | import chalk from 'chalk'; 8 | import {join} from 'pathe'; 9 | 10 | import {changeNpmrc} from '@helpers/actions/init/change-npmrc'; 11 | import {downloadTemplate} from '@helpers/fetch'; 12 | import {fixPnpm} from '@helpers/fix'; 13 | import {checkInitOptions} from '@helpers/init'; 14 | import {getPackageManagerInfo} from '@helpers/utils'; 15 | import {selectClack, taskClack, textClack} from 'src/prompts/clack'; 16 | import {resolver} from 'src/scripts/path'; 17 | 18 | import {ROOT} from '../../src/constants/path'; 19 | import { 20 | APP_DIR, 21 | APP_NAME, 22 | APP_REPO, 23 | LARAVEL_DIR, 24 | LARAVEL_NAME, 25 | LARAVEL_REPO, 26 | PAGES_DIR, 27 | PAGES_NAME, 28 | PAGES_REPO, 29 | REMIX_DIR, 30 | REMIX_NAME, 31 | REMIX_REPO, 32 | VITE_DIR, 33 | VITE_NAME, 34 | VITE_REPO 35 | } from '../../src/constants/templates'; 36 | 37 | export interface InitActionOptions { 38 | template?: 'app' | 'pages' | 'vite' | 'remix' | 'laravel'; 39 | package?: Agent; 40 | } 41 | 42 | export const templatesMap: Record['template'], string> = { 43 | app: APP_NAME, 44 | laravel: LARAVEL_NAME, 45 | pages: PAGES_NAME, 46 | remix: REMIX_NAME, 47 | vite: VITE_NAME 48 | }; 49 | 50 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 51 | declare let _exhaustiveCheck: never; 52 | 53 | export async function initAction(_projectName: string, options: InitActionOptions) { 54 | const {package: _package = 'npm', template: _template} = options; 55 | 56 | /** ======================== Check invalid options ======================== */ 57 | checkInitOptions(_template, _package); 58 | 59 | /** ======================== Welcome title ======================== */ 60 | p.intro(chalk.cyanBright('Create a new project')); 61 | 62 | /** ======================== Get the init info ======================== */ 63 | const {packageName, projectName, template} = await getTableInfo( 64 | _package, 65 | _projectName, 66 | _template 67 | ); 68 | const {run} = getPackageManagerInfo(packageName); 69 | 70 | /** ======================== Generate template ======================== */ 71 | // Detect if the project name already exists 72 | if (existsSync(resolver(`${ROOT}/${projectName}`))) { 73 | p.cancel(`The project name ${chalk.redBright(projectName)} already exists`); 74 | process.exit(1); 75 | } 76 | 77 | if (template === 'app') { 78 | await generateTemplate(APP_REPO); 79 | renameTemplate(APP_DIR, projectName); 80 | } else if (template === 'pages') { 81 | await generateTemplate(PAGES_REPO); 82 | renameTemplate(PAGES_DIR, projectName); 83 | } else if (template === 'vite') { 84 | await generateTemplate(VITE_REPO); 85 | renameTemplate(VITE_DIR, projectName); 86 | } else if (template === 'remix') { 87 | await generateTemplate(REMIX_REPO); 88 | renameTemplate(REMIX_DIR, projectName); 89 | } else if (template === 'laravel') { 90 | await generateTemplate(LARAVEL_REPO); 91 | renameTemplate(LARAVEL_DIR, projectName); 92 | } else { 93 | // If add new template and not update this template, it will be exhaustive check error 94 | _exhaustiveCheck = template; 95 | } 96 | 97 | const npmrcFile = resolver(`${ROOT}/${projectName}/.npmrc`); 98 | 99 | /** ======================== Change default npmrc content ======================== */ 100 | changeNpmrc(npmrcFile); 101 | 102 | /** ======================== Pnpm setup (optional) ======================== */ 103 | if (packageName === 'pnpm') { 104 | fixPnpm(npmrcFile, true, false, ({message}) => { 105 | p.log.message(message); 106 | }); 107 | } 108 | 109 | /** ======================== Add guide ======================== */ 110 | p.note( 111 | `cd ${chalk.cyanBright(projectName)}\n${chalk.cyanBright(packageName)} install`, 112 | 'Next steps' 113 | ); 114 | 115 | p.outro(`🚀 Get started with ${chalk.cyanBright(`${packageName} ${run} dev`)}`); 116 | 117 | process.exit(0); 118 | } 119 | 120 | /** ======================== Helper function ======================== */ 121 | async function generateTemplate(url: string) { 122 | await taskClack({ 123 | failText: 'Template creation failed', 124 | successText: 'Template created successfully!', 125 | task: downloadTemplate(ROOT, url), 126 | text: 'Creating template...' 127 | }); 128 | } 129 | 130 | function renameTemplate(originName: string, projectName: string) { 131 | try { 132 | renameSync(join(ROOT, originName), join(ROOT, projectName)); 133 | } catch (error) { 134 | if (error) { 135 | p.cancel(`rename Error: ${error}`); 136 | process.exit(0); 137 | } 138 | } 139 | } 140 | 141 | export type GenerateOptions> = [T] extends [never] 142 | ? [] 143 | : [ 144 | ...GenerateOptions>, 145 | { 146 | label: string; 147 | value: Last; 148 | hint: string; 149 | } 150 | ]; 151 | 152 | async function getTableInfo(packageName?: string, projectName?: string, template?: string) { 153 | const options: GenerateOptions> = [ 154 | { 155 | hint: 'A Next.js 15 with app directory template pre-configured with HeroUI (v2) and Tailwind CSS.', 156 | label: 'App', 157 | value: 'app' 158 | }, 159 | { 160 | hint: 'A Next.js 15 with pages directory template pre-configured with HeroUI (v2) and Tailwind CSS.', 161 | label: 'Pages', 162 | value: 'pages' 163 | }, 164 | { 165 | hint: 'A Vite template pre-configured with HeroUI (v2) and Tailwind CSS.', 166 | label: 'Vite', 167 | value: 'vite' 168 | }, 169 | { 170 | hint: 'A Remix template pre-configured with HeroUI (v2) and Tailwind CSS.', 171 | label: 'Remix', 172 | value: 'remix' 173 | }, 174 | { 175 | hint: 'A Laravel template pre-configured with HeroUI (v2) and Tailwind CSS.', 176 | label: 'Laravel', 177 | value: 'laravel' 178 | } 179 | ]; 180 | 181 | template = (await selectClack({ 182 | initialValue: template, 183 | message: 'Select a template (Enter to select)', 184 | options 185 | })) as string; 186 | 187 | projectName = (await textClack({ 188 | initialValue: projectName ?? templatesMap[template], 189 | message: 'New project name (Enter to skip with default name)', 190 | placeholder: projectName ?? templatesMap[template] 191 | })) as string; 192 | 193 | packageName = (await selectClack({ 194 | initialValue: packageName, 195 | message: 'Select a package manager (Enter to select)', 196 | options: [ 197 | { 198 | label: chalk.gray('npm'), 199 | value: 'npm' 200 | }, 201 | { 202 | label: chalk.gray('yarn'), 203 | value: 'yarn' 204 | }, 205 | { 206 | label: chalk.gray('pnpm'), 207 | value: 'pnpm' 208 | }, 209 | { 210 | label: chalk.gray('bun'), 211 | value: 'bun' 212 | } 213 | ] 214 | })) as Agent; 215 | 216 | return { 217 | packageName: packageName as Agent, 218 | projectName, 219 | template: template as Exclude 220 | }; 221 | } 222 | -------------------------------------------------------------------------------- /test/src/check/check-tailwind.spec.ts: -------------------------------------------------------------------------------- 1 | import type {HeroUIComponents} from 'src/constants/component'; 2 | 3 | import {checkTailwind} from '@helpers/check'; 4 | import * as required from 'src/constants/required'; 5 | import {individualTailwindRequired} from 'src/constants/required'; 6 | import {beforeEach, describe, expect, it, vi} from 'vitest'; 7 | 8 | import * as packageHelper from '../../../src/helpers/package'; 9 | 10 | const mockAllDependencies = { 11 | '@heroui/icons': '1.0.0', 12 | '@heroui/react': '1.0.0', 13 | react: '17.0.0', 14 | typescript: '4.0.0' 15 | }; 16 | 17 | describe('checkTailwind', () => { 18 | beforeEach(() => { 19 | vi.clearAllMocks(); 20 | 21 | vi.spyOn(packageHelper, 'getPackageInfo').mockReturnValue({ 22 | allDependencies: mockAllDependencies, 23 | allDependenciesKeys: new Set(Object.keys(mockAllDependencies)), 24 | currentComponents: [], 25 | dependencies: {}, 26 | devDependencies: {}, 27 | isAllComponents: false, 28 | packageJson: { 29 | dependencies: mockAllDependencies 30 | } 31 | }); 32 | }); 33 | 34 | describe('all type configuration', () => { 35 | it('validates complete configuration successfully', () => { 36 | const mockTailwindContent = ` 37 | module.exports = { 38 | darkMode: ['class'], 39 | content: [ 40 | './node_modules/@heroui/theme/dist/**/*.{js,jsx,ts,tsx}', 41 | ], 42 | plugins: [require('@heroui/react/plugin')], 43 | } 44 | `; 45 | 46 | const result = checkTailwind( 47 | 'all', 48 | 'path/to/tailwind.config.js', 49 | undefined, 50 | false, 51 | mockTailwindContent 52 | ); 53 | 54 | expect(result).toEqual([true]); 55 | }); 56 | 57 | it('identifies missing requirements', () => { 58 | const mockTailwindContent = ` 59 | module.exports = { 60 | content: [], 61 | plugins: [], 62 | } 63 | `; 64 | 65 | const result = checkTailwind( 66 | 'all', 67 | 'path/to/tailwind.config.js', 68 | undefined, 69 | false, 70 | mockTailwindContent 71 | ); 72 | 73 | expect(result).toEqual([ 74 | false, 75 | required.tailwindRequired.darkMode, 76 | required.tailwindRequired.content, 77 | required.tailwindRequired.plugins 78 | ]); 79 | }); 80 | 81 | it('accepts darkMode as string', () => { 82 | const mockTailwindContent = ` 83 | module.exports = { 84 | darkMode: 'class', 85 | content: [ 86 | './node_modules/@heroui/theme/dist/**/*.{js,jsx,ts,tsx}', 87 | ], 88 | plugins: [require('@heroui/react/plugin')], 89 | } 90 | `; 91 | 92 | const result = checkTailwind( 93 | 'all', 94 | 'path/to/tailwind.config.js', 95 | undefined, 96 | false, 97 | mockTailwindContent 98 | ); 99 | 100 | expect(result).toEqual([true]); 101 | }); 102 | }); 103 | 104 | describe('partial type configuration', () => { 105 | it('validates complete configuration successfully', async () => { 106 | const components: HeroUIComponents = [ 107 | { 108 | description: '', 109 | docs: '', 110 | name: 'button', 111 | package: '@heroui/react', 112 | peerDependencies: {}, 113 | status: 'stable', 114 | style: '', 115 | version: '1.0.0', 116 | versionMode: '' 117 | } 118 | ]; 119 | const mockTailwindContent = ` 120 | module.exports = { 121 | content: [ 122 | ${individualTailwindRequired.content(components, false)}, 123 | ], 124 | plugins: [require('@heroui/react/plugin')], 125 | } 126 | `; 127 | 128 | const result = checkTailwind( 129 | 'partial', 130 | 'path/to/tailwind.config.js', 131 | components, 132 | false, 133 | mockTailwindContent 134 | ); 135 | 136 | expect(result).toEqual([true]); 137 | }); 138 | 139 | it('identifies missing requirements', () => { 140 | const mockTailwindContent = ` 141 | module.exports = { 142 | content: [], 143 | plugins: [], 144 | } 145 | `; 146 | const components: HeroUIComponents = [ 147 | { 148 | description: '', 149 | docs: '', 150 | name: 'button', 151 | package: '@heroui/react', 152 | peerDependencies: {}, 153 | status: 'stable', 154 | style: '', 155 | version: '1.0.0', 156 | versionMode: '' 157 | }, 158 | { 159 | description: '', 160 | docs: '', 161 | name: 'input', 162 | package: '@heroui/react', 163 | peerDependencies: {}, 164 | status: 'stable', 165 | style: '', 166 | version: '1.0.0', 167 | versionMode: '' 168 | } 169 | ]; 170 | 171 | const result = checkTailwind( 172 | 'partial', 173 | 'path/to/tailwind.config.js', 174 | components, 175 | false, 176 | mockTailwindContent 177 | ); 178 | 179 | expect(result).toEqual([ 180 | false, 181 | individualTailwindRequired.content(components, false), 182 | required.tailwindRequired.plugins 183 | ]); 184 | }); 185 | 186 | it('handles pnpm path correctly', () => { 187 | const mockTailwindContent = ` 188 | module.exports = { 189 | content: [], 190 | plugins: [], 191 | } 192 | `; 193 | const components: HeroUIComponents = [ 194 | { 195 | description: '', 196 | docs: '', 197 | name: 'button', 198 | package: '@heroui/react', 199 | peerDependencies: {}, 200 | status: 'stable', 201 | style: '', 202 | version: '1.0.0', 203 | versionMode: '' 204 | } 205 | ]; 206 | 207 | const result = checkTailwind( 208 | 'partial', 209 | 'path/to/tailwind.config.js', 210 | components, 211 | true, 212 | mockTailwindContent 213 | ); 214 | 215 | expect(result).toEqual([ 216 | false, 217 | individualTailwindRequired.content(components, true), 218 | required.tailwindRequired.plugins 219 | ]); 220 | }); 221 | 222 | it('should log warning when using global content in partial mode', () => { 223 | const mockTailwindContent = ` 224 | module.exports = { 225 | content: [ 226 | './node_modules/@heroui/theme/dist/**/*.{js,jsx,ts,tsx}', 227 | ], 228 | plugins: [require('@heroui/react/plugin')], 229 | } 230 | `; 231 | const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 232 | const components: HeroUIComponents = [ 233 | { 234 | description: '', 235 | docs: '', 236 | name: 'button', 237 | package: '@heroui/react', 238 | peerDependencies: {}, 239 | status: 'stable', 240 | style: '', 241 | version: '1.0.0', 242 | versionMode: '' 243 | } 244 | ]; 245 | 246 | const result = checkTailwind( 247 | 'partial', 248 | 'path/to/tailwind.config.js', 249 | components, 250 | false, 251 | mockTailwindContent, 252 | true 253 | ); 254 | 255 | expect(result).toEqual([true]); 256 | expect(logSpy).toHaveBeenCalled(); 257 | expect(logSpy.mock.calls[0]?.[0]).toContain('Attention'); 258 | }); 259 | 260 | it('should return [true] for empty components in "partial" type', () => { 261 | const result = checkTailwind('partial', 'path/to/tailwind.config.js', [], false, ''); 262 | 263 | expect(result).toEqual([true]); 264 | }); 265 | }); 266 | }); 267 | -------------------------------------------------------------------------------- /src/actions/upgrade-action.ts: -------------------------------------------------------------------------------- 1 | import type {AppendKeyValue} from '@helpers/type'; 2 | 3 | import fs from 'node:fs'; 4 | 5 | import {catchPnpmExec} from '@helpers/actions/upgrade/catch-pnpm-exec'; 6 | import {checkIllegalComponents} from '@helpers/check'; 7 | import {getConditionVersion} from '@helpers/condition-value'; 8 | import {detect} from '@helpers/detect'; 9 | import {exec} from '@helpers/exec'; 10 | import {Logger} from '@helpers/logger'; 11 | import {colorMatchRegex} from '@helpers/output-info'; 12 | import {getPackageInfo} from '@helpers/package'; 13 | import {setupPnpm} from '@helpers/setup'; 14 | import {upgrade, writeUpgradeVersion} from '@helpers/upgrade'; 15 | import {getColorVersion, getPackageManagerInfo, transformPeerVersion} from '@helpers/utils'; 16 | import {type HeroUIComponents} from 'src/constants/component'; 17 | import {resolver} from 'src/constants/path'; 18 | import {HERO_UI} from 'src/constants/required'; 19 | import {store} from 'src/constants/store'; 20 | import {getAutocompleteMultiselect, getMultiselect, getSelect} from 'src/prompts'; 21 | import {compareVersions} from 'src/scripts/helpers'; 22 | 23 | interface UpgradeActionOptions { 24 | packagePath?: string; 25 | all?: boolean; 26 | major?: boolean; 27 | minor?: boolean; 28 | patch?: boolean; 29 | write?: boolean; 30 | beta?: boolean; 31 | } 32 | 33 | type TransformComponent = Required< 34 | AppendKeyValue & {isLatest: boolean} 35 | >; 36 | 37 | function betaCompareVersions(version: string, latestVersion: string, beta: boolean) { 38 | // compareResult(beta, 2.1.0) = 0 39 | // So we need to check if it is autoChangeTag like `beta` or `canary` and latestVersion is not match `beta` or `canary` then return false 40 | // Example: `beta` Compare `2.1.0` (not latest), `beta` Compare `2.1.0-beta.0` (latest) 41 | const autoChangeTag = version.match(/(^\w+$)/)?.[1]; 42 | 43 | if (autoChangeTag) { 44 | return latestVersion.includes(autoChangeTag); 45 | } 46 | 47 | const compareResult = compareVersions(version, latestVersion); 48 | 49 | // Beta version is greater than latest version if beta is true 50 | // compareResult(2.1.0, 2.1.0-beta.0) = 1 51 | // Example: 2.1.0 < 2.1.0-beta.0 52 | return beta && compareResult === 1 && !version.includes('beta') ? false : compareResult >= 0; 53 | } 54 | 55 | export async function upgradeAction(components: string[], options: UpgradeActionOptions) { 56 | const { 57 | all = false, 58 | beta = false, 59 | packagePath = resolver('package.json'), 60 | write = false 61 | } = options; 62 | const {allDependencies, currentComponents, dependencies, devDependencies, packageJson} = 63 | getPackageInfo(packagePath, false); 64 | 65 | const isHeroUIAll = !!allDependencies[HERO_UI]; 66 | 67 | const transformComponents: TransformComponent[] = []; 68 | 69 | await Promise.all( 70 | currentComponents.map(async (component) => { 71 | const mergedVersion = await getConditionVersion(component.package); 72 | const compareResult = betaCompareVersions(component.version, mergedVersion, beta); 73 | 74 | transformComponents.push({ 75 | ...component, 76 | isLatest: compareResult, 77 | latestVersion: mergedVersion 78 | }); 79 | }) 80 | ); 81 | 82 | // If no Installed HeroUI components then exit 83 | if (!transformComponents.length && !isHeroUIAll) { 84 | Logger.prefix('error', `No HeroUI components detected in your package.json at: ${packagePath}`); 85 | 86 | return; 87 | } 88 | 89 | if (all) { 90 | components = currentComponents.map((component) => component.package); 91 | } else if (!components.length) { 92 | // If have the main heroui then add 93 | if (isHeroUIAll) { 94 | const latestVersion = await getConditionVersion(HERO_UI); 95 | const herouiData = { 96 | isLatest: 97 | compareVersions(latestVersion, transformPeerVersion(allDependencies[HERO_UI])) <= 0, 98 | latestVersion, 99 | package: HERO_UI, 100 | version: transformPeerVersion(allDependencies[HERO_UI]) 101 | } as TransformComponent; 102 | 103 | transformComponents.push(herouiData); 104 | } 105 | 106 | // If all package is latest then pass 107 | if (transformComponents.every((component) => component.isLatest)) { 108 | Logger.success('✅ All HeroUI packages are up to date'); 109 | process.exit(0); 110 | } 111 | 112 | components = await getAutocompleteMultiselect( 113 | 'Select the components to upgrade', 114 | transformComponents.map((component) => { 115 | const isUpToDate = betaCompareVersions(component.version, component.latestVersion, beta); 116 | 117 | return { 118 | disabled: isUpToDate, 119 | disabledMessage: 'Already up to date', 120 | title: `${component.package}${ 121 | isUpToDate 122 | ? '' 123 | : `@${component.version} -> ${getColorVersion( 124 | component.version, 125 | component.latestVersion 126 | )}` 127 | }`, 128 | value: component.package 129 | }; 130 | }) 131 | ); 132 | } else { 133 | // Check if the components are valid 134 | if (!checkIllegalComponents(components)) { 135 | return; 136 | } 137 | } 138 | 139 | components = components.map((c) => { 140 | if (store.heroUIComponentsMap[c]?.package) { 141 | return store.heroUIComponentsMap[c]!.package; 142 | } 143 | 144 | return c; 145 | }); 146 | 147 | /** ======================== Upgrade ======================== */ 148 | const upgradeOptionList = transformComponents.filter((c) => components.includes(c.package)); 149 | 150 | let result = await upgrade({ 151 | all, 152 | allDependencies, 153 | isHeroUIAll, 154 | upgradeOptionList 155 | }); 156 | let ignoreList: string[] = []; 157 | const packageManager = await detect(); 158 | 159 | if (result.length) { 160 | const isExecute = await getSelect('Would you like to proceed with the upgrade?', [ 161 | { 162 | title: 'Yes', 163 | value: true 164 | }, 165 | { 166 | description: 'Select this if you wish to exclude certain packages from the upgrade', 167 | title: 'No', 168 | value: false 169 | } 170 | ]); 171 | 172 | const {install} = getPackageManagerInfo(packageManager); 173 | 174 | if (!isExecute) { 175 | // Ask whether need to remove some package not to upgrade 176 | const isNeedRemove = await getSelect( 177 | 'Would you like to exclude any packages from the upgrade?', 178 | [ 179 | { 180 | description: 'Select this to choose packages to exclude', 181 | title: 'Yes', 182 | value: true 183 | }, 184 | { 185 | description: 'Select this to proceed without excluding any packages', 186 | title: 'No', 187 | value: false 188 | } 189 | ] 190 | ); 191 | 192 | if (isNeedRemove) { 193 | ignoreList = await getMultiselect( 194 | 'Select the packages you want to exclude from the upgrade:', 195 | result.map((c) => { 196 | return { 197 | description: `${c.version} -> ${getColorVersion(c.version, c.latestVersion)}`, 198 | title: c.package, 199 | value: c.package 200 | }; 201 | }) 202 | ); 203 | } 204 | } 205 | 206 | // Remove the components that need to be ignored 207 | result = result.filter((r) => { 208 | return !ignoreList.some((ignore) => r.package === ignore); 209 | }); 210 | 211 | if (write) { 212 | // Write the upgrade version to the package file 213 | writeUpgradeVersion({ 214 | dependencies, 215 | devDependencies, 216 | upgradePackageList: result 217 | }); 218 | 219 | fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2), 'utf-8'); 220 | 221 | Logger.newLine(); 222 | Logger.success('✅ Upgrade version written to package.json'); 223 | process.exit(0); 224 | } else { 225 | await catchPnpmExec(() => 226 | exec( 227 | `${packageManager} ${install} ${result.reduce((acc, component, index) => { 228 | return `${acc}${index === 0 ? '' : ' '}${ 229 | component.package 230 | }@${component.latestVersion.replace(colorMatchRegex, '')}`; 231 | }, '')}` 232 | ) 233 | ); 234 | } 235 | } 236 | 237 | /** ======================== Setup Pnpm ======================== */ 238 | setupPnpm(packageManager); 239 | 240 | Logger.newLine(); 241 | Logger.success('✅ Upgrade complete. All components are up to date.'); 242 | 243 | process.exit(0); 244 | } 245 | --------------------------------------------------------------------------------