From 132cfdef4616601c6e5502a68a31cb977cc2d29a Mon Sep 17 00:00:00 2001 From: Hooray <304327508@qq.com> Date: Wed, 1 Apr 2026 19:53:46 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E4=BB=A5=E7=BB=9F=E4=B8=80=20CLI=20=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 6 +- scripts/build.ts | 151 -------------------- scripts/cli.ts | 355 +++++++++++++++++++++++++++++++++++++++++++++++ scripts/dev.ts | 119 ---------------- scripts/serve.ts | 143 ------------------- 5 files changed, 358 insertions(+), 416 deletions(-) delete mode 100755 scripts/build.ts create mode 100644 scripts/cli.ts delete mode 100755 scripts/dev.ts delete mode 100755 scripts/serve.ts diff --git a/package.json b/package.json index b89a3bc8..2b5f0b38 100755 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ "node": "^20.19.0 || ^22.13.0 || >=24" }, "scripts": { - "dev": "tsx scripts/dev.ts", - "build": "tsx scripts/build.ts", - "serve": "tsx scripts/serve.ts", + "dev": "tsx scripts/cli.ts --mode=dev", + "build": "tsx scripts/cli.ts --mode=build", + "serve": "tsx scripts/cli.ts --mode=serve", "lint": "run-s lint:tsc lint:eslint lint:stylelint", "lint:tsc": "pnpm --filter './apps/*' -r run lint", "lint:eslint": "eslint . --cache --fix", diff --git a/scripts/build.ts b/scripts/build.ts deleted file mode 100755 index ec91901e..00000000 --- a/scripts/build.ts +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from 'node:child_process' -import fs from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' -import * as p from '@clack/prompts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootDir = path.resolve(__dirname, '..') -const isWindows = process.platform === 'win32' - -let childProcess: ReturnType | null = null - -process.on('SIGINT', () => { - if (childProcess) { - childProcess.kill('SIGINT') - } -}) - -interface AppInfo { - name: string - packageName: string - description: string - buildScripts: string[] -} - -function getApps(): AppInfo[] { - const apps: AppInfo[] = [] - - // Scan apps directory (subdirectories) - const appsDir = path.resolve(rootDir, 'apps') - if (fs.existsSync(appsDir)) { - const entries = fs.readdirSync(appsDir, { withFileTypes: true }) - for (const entry of entries) { - if (entry.isDirectory()) { - const packageJsonPath = path.join(appsDir, entry.name, 'package.json') - if (fs.existsSync(packageJsonPath)) { - const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) - const buildScripts = Object.keys(pkg.scripts || {}).filter(s => s === 'build' || s.startsWith('build:')) - if (buildScripts.length > 0) { - apps.push({ - name: entry.name, - packageName: pkg.name, - description: pkg.description || '', - buildScripts, - }) - } - } - } - } - } - - return apps -} - -async function runBuild(packageName: string, script: string): Promise { - return new Promise((resolve, reject) => { - childProcess = spawn('pnpm', ['--filter', packageName, 'run', script], { - stdio: 'inherit', - cwd: rootDir, - shell: isWindows, - }) - - childProcess.on('close', (code) => { - childProcess = null - if (code === 0 || code === null) { - resolve() - } - else { - reject(new Error(`Process exited with code ${code}`)) - } - }) - - childProcess.on('error', (err) => { - childProcess = null - reject(err) - }) - }) -} - -async function selectBuildScript(app: AppInfo): Promise { - if (app.buildScripts.length === 1) { - return app.buildScripts[0] - } - - const script = await p.select({ - message: `Select build script for "${app.name}":`, - options: app.buildScripts.map(s => ({ value: s, label: s })), - }) - - if (p.isCancel(script)) { - p.cancel('Operation cancelled') - process.exit(0) - } - - return script as string -} - -async function main() { - p.intro('Select apps to build') - - const apps = getApps() - - if (apps.length === 0) { - p.log.error('No apps found with build script') - process.exit(1) - } - - if (apps.length === 1) { - p.log.info(`Only one app found: ${apps[0].name}`) - const script = await selectBuildScript(apps[0]) - await runBuild(apps[0].packageName, script) - p.outro('Build complete!') - return - } - - const choices = apps.map(app => ({ - value: app.packageName, - label: app.name, - hint: app.description, - })) - - const selected = await p.multiselect({ - message: 'Which apps do you want to build?', - options: choices, - required: true, - }) - - if (p.isCancel(selected)) { - p.cancel('Operation cancelled') - process.exit(0) - } - - const selectedPackageNames = selected as string[] - const selectedApps = selectedPackageNames.map(pkg => apps.find(a => a.packageName === pkg)!) - - for (const app of selectedApps) { - const script = await selectBuildScript(app) - p.log.info(`Building "${app.name}" with script "${script}"...`) - await runBuild(app.packageName, script) - } - - p.outro('Build complete!') -} - -main().catch((err) => { - p.log.error(err.message) - process.exit(1) -}) diff --git a/scripts/cli.ts b/scripts/cli.ts new file mode 100644 index 00000000..71f30072 --- /dev/null +++ b/scripts/cli.ts @@ -0,0 +1,355 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import * as p from '@clack/prompts' + +interface PackageJson { + name: string + description?: string + scripts?: Record +} + +interface WorkspaceEntry { + name: string + packageJsonPath: string +} + +interface AppInfo { + name: string + packageName: string + description: string + scripts: string[] +} + +interface RunMessageContext { + app: AppInfo + script: string +} + +interface ModeConfig { + action: Mode + intro: string + noAppsMessage: string + appSelectMessage: string + scriptMatcher: (scriptName: string) => boolean + selectionMode: 'single' | 'multiple' + completionMessage?: string + getRunMessage?: (context: RunMessageContext) => string + showRunMessageWhenSingleApp?: boolean +} + +type Mode = 'build' | 'dev' | 'serve' + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(currentDir, '..') +const isWindows = process.platform === 'win32' +const validModes: Mode[] = ['build', 'dev', 'serve'] +const modeConfigs: Record = { + build: { + action: 'build', + intro: 'Select apps to build', + noAppsMessage: 'No apps found with build script', + appSelectMessage: 'Which apps do you want to build?', + scriptMatcher: isBuildScript, + selectionMode: 'multiple', + completionMessage: 'Build complete!', + getRunMessage: ({ app, script }) => `Building "${app.name}" with script "${script}"...`, + }, + dev: { + action: 'dev', + intro: 'Select an app to run', + noAppsMessage: 'No apps found with dev script', + appSelectMessage: 'Which app do you want to run?', + scriptMatcher: isDevScript, + selectionMode: 'single', + getRunMessage: ({ app }) => `Starting ${app.packageName}...`, + }, + serve: { + action: 'serve', + intro: 'Select an app to serve', + noAppsMessage: 'No apps found with serve script', + appSelectMessage: 'Which app do you want to serve?', + scriptMatcher: isServeScript, + selectionMode: 'single', + getRunMessage: ({ app, script }) => `Serving "${app.name}" with script "${script}"...`, + }, +} + +let childProcess: ReturnType | null = null + +process.on('SIGINT', () => { + childProcess?.kill('SIGINT') +}) + +function isBuildScript(scriptName: string): boolean { + return scriptName === 'build' || scriptName.startsWith('build:') +} + +function isDevScript(scriptName: string): boolean { + return scriptName === 'dev' +} + +function isServeScript(scriptName: string): boolean { + return scriptName === 'serve' || scriptName.startsWith('serve:') +} + +function getWorkspaceEntries(): WorkspaceEntry[] { + const entries: WorkspaceEntry[] = [] + const appsDir = path.resolve(rootDir, 'apps') + + if (fs.existsSync(appsDir)) { + const apps = fs.readdirSync(appsDir, { withFileTypes: true }) + + for (const app of apps) { + if (!app.isDirectory()) { + continue + } + + const packageJsonPath = path.join(appsDir, app.name, 'package.json') + if (!fs.existsSync(packageJsonPath)) { + continue + } + + entries.push({ + name: app.name, + packageJsonPath, + }) + } + } + + const docsPackageJsonPath = path.resolve(rootDir, 'docs', 'package.json') + if (fs.existsSync(docsPackageJsonPath)) { + entries.push({ + name: 'docs', + packageJsonPath: docsPackageJsonPath, + }) + } + + return entries +} + +function readPackageJson(packageJsonPath: string): PackageJson { + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as PackageJson +} + +function getApps(scriptMatcher: (scriptName: string) => boolean): AppInfo[] { + const apps: AppInfo[] = [] + + for (const entry of getWorkspaceEntries()) { + const packageJson = readPackageJson(entry.packageJsonPath) + const scripts = Object.keys(packageJson.scripts ?? {}).filter(scriptMatcher) + + if (scripts.length === 0) { + continue + } + + apps.push({ + name: entry.name, + packageName: packageJson.name, + description: packageJson.description || '', + scripts, + }) + } + + return apps +} + +function getAppChoices(apps: AppInfo[]): Array<{ value: string, label: string, hint: string }> { + return apps.map(app => ({ + value: app.packageName, + label: app.name, + hint: app.description, + })) +} + +function getAppByPackageName(apps: AppInfo[], packageName: string): AppInfo { + const app = apps.find(item => item.packageName === packageName) + + if (!app) { + throw new Error(`App not found: ${packageName}`) + } + + return app +} + +function resolvePromptValue(value: T | symbol): T { + if (p.isCancel(value)) { + p.cancel('Operation cancelled') + process.exit(0) + } + + return value +} + +async function selectScript(app: AppInfo, action: Mode): Promise { + if (app.scripts.length === 1) { + return app.scripts[0] + } + + const script = await p.select({ + message: `Select ${action} script for "${app.name}":`, + options: app.scripts.map(name => ({ + value: name, + label: name, + })), + }) + + return resolvePromptValue(script) +} + +async function runPackageScript(packageName: string, script: string): Promise { + return new Promise((resolve, reject) => { + childProcess = spawn('pnpm', ['--filter', packageName, 'run', script], { + stdio: 'inherit', + cwd: rootDir, + shell: isWindows, + }) + + childProcess.on('close', (code) => { + childProcess = null + + if (code === 0 || code === null) { + resolve() + return + } + + reject(new Error(`Process exited with code ${code}`)) + }) + + childProcess.on('error', (error) => { + childProcess = null + reject(error) + }) + }) +} + +async function selectApps(apps: AppInfo[], message: string, selectionMode: ModeConfig['selectionMode']): Promise { + if (selectionMode === 'multiple') { + const selectedPackageNames = resolvePromptValue(await p.multiselect({ + message, + options: getAppChoices(apps), + required: true, + })) + + return selectedPackageNames.map(packageName => getAppByPackageName(apps, packageName)) + } + + const selectedPackageName = resolvePromptValue(await p.select({ + message, + options: getAppChoices(apps), + })) + + return [getAppByPackageName(apps, selectedPackageName)] +} + +async function runSelectedApps(apps: AppInfo[], config: ModeConfig, showRunMessage: boolean): Promise { + for (const app of apps) { + const script = await selectScript(app, config.action) + + if (showRunMessage) { + const runMessage = config.getRunMessage?.({ + app, + script, + }) + + if (runMessage) { + p.log.info(runMessage) + } + } + + await runPackageScript(app.packageName, script) + } +} + +async function runMode(config: ModeConfig): Promise { + p.intro(config.intro) + + const apps = getApps(config.scriptMatcher) + + if (apps.length === 0) { + p.log.error(config.noAppsMessage) + process.exit(1) + } + + if (apps.length === 1) { + p.log.info(`Only one app found: ${apps[0].name}`) + await runSelectedApps([apps[0]], config, config.showRunMessageWhenSingleApp ?? false) + + if (config.completionMessage) { + p.outro(config.completionMessage) + } + + return + } + + const selectedApps = await selectApps(apps, config.appSelectMessage, config.selectionMode) + await runSelectedApps(selectedApps, config, true) + + if (config.completionMessage) { + p.outro(config.completionMessage) + } +} + +function parseModeArg(argv: string[]): string | undefined { + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + + if (arg === '--mode' || arg === '-m') { + return argv[index + 1] + } + + if (arg.startsWith('--mode=')) { + return arg.slice('--mode='.length) + } + + if (arg.startsWith('-m=')) { + return arg.slice('-m='.length) + } + } + + return undefined +} + +function hasHelpArg(argv: string[]): boolean { + return argv.includes('--help') || argv.includes('-h') +} + +function isMode(value: string | undefined): value is Mode { + return value !== undefined && validModes.includes(value as Mode) +} + +function printHelp(): void { + console.log(`Usage: tsx scripts/cli.ts --mode=<${validModes.join('|')}>`) + console.log('Examples:') + console.log(' pnpm dev') + console.log(' pnpm build') + console.log(' pnpm serve') + console.log(' pnpm cli -- --mode=dev') +} + +async function main(): Promise { + const argv = process.argv.slice(2) + const modeArg = parseModeArg(argv) + + if (hasHelpArg(argv)) { + printHelp() + return + } + + if (!isMode(modeArg)) { + p.log.error(`Invalid or missing mode. Expected one of: ${validModes.join(', ')}`) + printHelp() + process.exit(1) + } + + await runMode(modeConfigs[modeArg]) +} + +main().catch((error: Error) => { + p.log.error(error.message) + process.exit(1) +}) diff --git a/scripts/dev.ts b/scripts/dev.ts deleted file mode 100755 index 6955e8a6..00000000 --- a/scripts/dev.ts +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from 'node:child_process' -import fs from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' -import * as p from '@clack/prompts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootDir = path.resolve(__dirname, '..') -const isWindows = process.platform === 'win32' - -let childProcess: ReturnType | null = null - -process.on('SIGINT', () => { - if (childProcess) { - childProcess.kill('SIGINT') - } -}) - -interface AppInfo { - name: string - packageName: string - description: string -} - -function getApps(): AppInfo[] { - const apps: AppInfo[] = [] - - // Scan apps directory (subdirectories) - const appsDir = path.resolve(rootDir, 'apps') - if (fs.existsSync(appsDir)) { - const entries = fs.readdirSync(appsDir, { withFileTypes: true }) - for (const entry of entries) { - if (entry.isDirectory()) { - const packageJsonPath = path.join(appsDir, entry.name, 'package.json') - if (fs.existsSync(packageJsonPath)) { - const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) - if (pkg.scripts?.dev) { - apps.push({ - name: entry.name, - packageName: pkg.name, - description: pkg.description || '', - }) - } - } - } - } - } - - return apps -} - -async function runDev(packageName: string): Promise { - return new Promise((resolve, reject) => { - childProcess = spawn('pnpm', ['--filter', packageName, 'run', 'dev'], { - stdio: 'inherit', - cwd: rootDir, - shell: isWindows, - }) - - childProcess.on('close', (code) => { - childProcess = null - if (code === 0 || code === null) { - resolve() - } - else { - reject(new Error(`Process exited with code ${code}`)) - } - }) - - childProcess.on('error', (err) => { - childProcess = null - reject(err) - }) - }) -} - -async function main() { - p.intro('Select an app to run') - - const apps = getApps() - - if (apps.length === 0) { - p.log.error('No apps found with dev script') - process.exit(1) - } - - if (apps.length === 1) { - p.log.info(`Only one app found: ${apps[0].name}`) - await runDev(apps[0].packageName) - return - } - - const choices = apps.map(app => ({ - value: app.packageName, - label: app.name, - hint: app.description, - })) - - const selected = await p.select({ - message: 'Which app do you want to run?', - options: choices, - }) - - if (p.isCancel(selected)) { - p.cancel('Operation cancelled') - process.exit(0) - } - - p.log.info(`Starting ${selected}...`) - await runDev(selected) -} - -main().catch((err) => { - p.log.error(err.message) - process.exit(1) -}) diff --git a/scripts/serve.ts b/scripts/serve.ts deleted file mode 100755 index b07e3fd6..00000000 --- a/scripts/serve.ts +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from 'node:child_process' -import fs from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' -import * as p from '@clack/prompts' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const rootDir = path.resolve(__dirname, '..') -const isWindows = process.platform === 'win32' - -let childProcess: ReturnType | null = null - -process.on('SIGINT', () => { - if (childProcess) { - childProcess.kill('SIGINT') - } -}) - -interface AppInfo { - name: string - packageName: string - description: string - serveScripts: string[] -} - -function getApps(): AppInfo[] { - const apps: AppInfo[] = [] - - // Scan apps directory (subdirectories) - const appsDir = path.resolve(rootDir, 'apps') - if (fs.existsSync(appsDir)) { - const entries = fs.readdirSync(appsDir, { withFileTypes: true }) - for (const entry of entries) { - if (entry.isDirectory()) { - const packageJsonPath = path.join(appsDir, entry.name, 'package.json') - if (fs.existsSync(packageJsonPath)) { - const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) - const serveScripts = Object.keys(pkg.scripts || {}).filter(s => s === 'serve' || s.startsWith('serve:')) - if (serveScripts.length > 0) { - apps.push({ - name: entry.name, - packageName: pkg.name, - description: pkg.description || '', - serveScripts, - }) - } - } - } - } - } - - return apps -} - -async function runServe(packageName: string, script: string): Promise { - return new Promise((resolve, reject) => { - childProcess = spawn('pnpm', ['--filter', packageName, 'run', script], { - stdio: 'inherit', - cwd: rootDir, - shell: isWindows, - }) - - childProcess.on('close', (code) => { - childProcess = null - if (code === 0 || code === null) { - resolve() - } - else { - reject(new Error(`Process exited with code ${code}`)) - } - }) - - childProcess.on('error', (err) => { - childProcess = null - reject(err) - }) - }) -} - -async function selectServeScript(app: AppInfo): Promise { - if (app.serveScripts.length === 1) { - return app.serveScripts[0] - } - - const script = await p.select({ - message: `Select serve script for "${app.name}":`, - options: app.serveScripts.map(s => ({ value: s, label: s })), - }) - - if (p.isCancel(script)) { - p.cancel('Operation cancelled') - process.exit(0) - } - - return script as string -} - -async function main() { - p.intro('Select an app to serve') - - const apps = getApps() - - if (apps.length === 0) { - p.log.error('No apps found with serve script') - process.exit(1) - } - - if (apps.length === 1) { - p.log.info(`Only one app found: ${apps[0].name}`) - const script = await selectServeScript(apps[0]) - await runServe(apps[0].packageName, script) - return - } - - const choices = apps.map(app => ({ - value: app.packageName, - label: app.name, - hint: app.description, - })) - - const selected = await p.select({ - message: 'Which app do you want to serve?', - options: choices, - }) - - if (p.isCancel(selected)) { - p.cancel('Operation cancelled') - process.exit(0) - } - - const selectedApp = apps.find(a => a.packageName === selected)! - const script = await selectServeScript(selectedApp) - p.log.info(`Serving "${selectedApp.name}" with script "${script}"...`) - await runServe(selectedApp.packageName, script) -} - -main().catch((err) => { - p.log.error(err.message) - process.exit(1) -})