From 96ab4d475f3c17efe8a711bfe278e8074451a714 Mon Sep 17 00:00:00 2001 From: gene9831 Date: Fri, 29 May 2026 17:19:34 +0800 Subject: [PATCH 1/5] feat(cli): add chat feature with template and commands --- packages/cli/bin/cli.js | 175 +------ packages/cli/bin/commands/add.js | 436 ++++++++++++++++++ packages/cli/bin/commands/create.js | 144 ++++++ packages/cli/bin/utils.js | 337 ++++++++++++++ packages/cli/package.json | 4 +- packages/cli/templates/chat/.env.example | 1 + .../cli/templates/chat/src/TinyRobotChat.vue | 35 ++ 7 files changed, 963 insertions(+), 169 deletions(-) create mode 100644 packages/cli/bin/commands/add.js create mode 100644 packages/cli/bin/commands/create.js create mode 100644 packages/cli/bin/utils.js create mode 100644 packages/cli/templates/chat/.env.example create mode 100644 packages/cli/templates/chat/src/TinyRobotChat.vue diff --git a/packages/cli/bin/cli.js b/packages/cli/bin/cli.js index 4311794f8..35d11b950 100755 --- a/packages/cli/bin/cli.js +++ b/packages/cli/bin/cli.js @@ -1,180 +1,19 @@ #!/usr/bin/env node -import fs from 'node:fs' -import path from 'node:path' import process from 'node:process' -import { fileURLToPath } from 'node:url' -import { input, select } from '@inquirer/prompts' import { Command } from 'commander' -const TEMPLATE_PLACEHOLDER = '__PROJECT_NAME__' -const DEFAULT_TEMPLATE = 'basic' -const DEFAULT_PROJECT_NAME = 'tiny-robot-app' -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const templatesRoot = path.resolve(__dirname, '../templates') - -function getAvailableTemplates() { - if (!fs.existsSync(templatesRoot)) { - return [] - } - - return fs - .readdirSync(templatesRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) -} - -function validateProjectName(name) { - // Keep project naming rules strict for npm package compatibility. - const npmSafePattern = /^[a-z0-9-]+$/ - return npmSafePattern.test(name) -} - -function getTemplateDir(templateName) { - return path.join(templatesRoot, templateName) -} - -async function resolveProjectName(initialProjectName, skipPrompts) { - if (initialProjectName) { - return initialProjectName - } - - if (skipPrompts) { - return DEFAULT_PROJECT_NAME - } - - return input({ - message: 'Project name:', - default: DEFAULT_PROJECT_NAME, - validate: (value) => { - if (!value) { - return 'Project name is required.' - } - if (!validateProjectName(value)) { - return 'Project name can only contain lowercase letters, numbers, and dashes.' - } - const targetDir = path.resolve(process.cwd(), value) - if (fs.existsSync(targetDir)) { - return `Target directory already exists: ${targetDir}` - } - return true - }, - }) -} - -async function resolveTemplateName(initialTemplateName, availableTemplates, skipPrompts) { - if (initialTemplateName) { - return initialTemplateName - } - - if (skipPrompts) { - return availableTemplates.includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : availableTemplates[0] - } - - return select({ - message: 'Template:', - default: availableTemplates.includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : availableTemplates[0], - choices: availableTemplates.map((templateName) => ({ - name: templateName, - value: templateName, - })), - }) -} - -function copyTemplate(sourceDir, targetDir) { - fs.cpSync(sourceDir, targetDir, { - recursive: true, - filter: (source) => { - const name = path.basename(source) - // Ignore local build artifacts to keep generated projects clean. - return !['node_modules', '.git', 'dist', '.DS_Store', '.vite'].includes(name) - }, - }) -} - -function renameSpecialFiles(targetDir) { - const from = path.join(targetDir, '_gitignore') - const to = path.join(targetDir, '.gitignore') - - if (fs.existsSync(from)) { - fs.renameSync(from, to) - } -} - -function replaceProjectName(targetDir, projectName) { - const filesToReplace = ['package.json', 'README.md'] - - for (const relativeFile of filesToReplace) { - const absoluteFile = path.join(targetDir, relativeFile) - - if (!fs.existsSync(absoluteFile)) { - continue - } - - const content = fs.readFileSync(absoluteFile, 'utf-8') - const nextContent = content.replaceAll(TEMPLATE_PLACEHOLDER, projectName) - fs.writeFileSync(absoluteFile, nextContent, 'utf-8') - } -} - -async function createProject(initialProjectName, initialTemplateName, skipPrompts) { - const availableTemplates = getAvailableTemplates() - if (availableTemplates.length === 0) { - console.error('Error: no templates found.') - process.exit(1) - } - - const projectName = await resolveProjectName(initialProjectName, skipPrompts) - const templateName = await resolveTemplateName(initialTemplateName, availableTemplates, skipPrompts) - - if (!validateProjectName(projectName)) { - console.error('Error: project name can only contain lowercase letters, numbers, and dashes.') - process.exit(1) - } - - const templateDir = getTemplateDir(templateName) - const targetDir = path.resolve(process.cwd(), projectName) - - if (!fs.existsSync(templateDir)) { - console.error(`Error: template "${templateName}" does not exist. Available: ${availableTemplates.join(', ')}`) - process.exit(1) - } - - if (fs.existsSync(targetDir)) { - console.error(`Error: target directory already exists: ${targetDir}`) - process.exit(1) - } - - copyTemplate(templateDir, targetDir) - renameSpecialFiles(targetDir) - replaceProjectName(targetDir, projectName) - - console.log('\nProject created successfully!') - console.log(`\nNext steps:`) - console.log(` cd ${projectName}`) - console.log(' pnpm install') - console.log(' pnpm dev\n') -} +import { registerCreateCommand } from './commands/create.js' +import { registerAddCommand } from './commands/add.js' function run() { const program = new Command() - program - .name('tiny-robot-cli') - .description('CLI to scaffold TinyRobot product projects') - .showHelpAfterError() - program - .command('create [project-name]') - .description('Create a TinyRobot project from template') - .option('-t, --template ', 'template name') - .action((projectName, options) => { - const skipPrompts = !process.stdout.isTTY - createProject(projectName ?? '', options.template ?? '', skipPrompts).catch((error) => { - console.error(`Error: ${error instanceof Error ? error.message : String(error)}`) - process.exit(1) - }) - }) + program.name('tiny-robot-cli').description('CLI to scaffold TinyRobot product projects').showHelpAfterError() + + registerCreateCommand(program) + + registerAddCommand(program) if (process.argv.length <= 2) { program.outputHelp() diff --git a/packages/cli/bin/commands/add.js b/packages/cli/bin/commands/add.js new file mode 100644 index 000000000..cbb3ab8a3 --- /dev/null +++ b/packages/cli/bin/commands/add.js @@ -0,0 +1,436 @@ +import { checkbox, select } from '@inquirer/prompts' +import { Argument } from 'commander' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import semver from 'semver' + +import { + copyFile, + findProjectRoot, + findSubPackageRoot, + findWorkspacePackages, + findWorkspaceRoot, + getTemplateDir, + invariant, + listPackages, + logSkip, + logSuccess, + mergeEnvFile, +} from '../utils.js' + +const DEP_NAME = '@opentiny/tiny-robot' +const TARGET_VERSION = '0.4.2-alpha.5' +const STYLE_IMPORT = "import '@opentiny/tiny-robot/dist/style.css'" + +function logUnavailable(label) { + logSkip(`${label} could not be applied`) +} + +function logSkippedSelection(label) { + logSkip(`${label} change was not selected`) +} + +async function resolveTargetPackage(cwd) { + const workspaceRoot = findWorkspaceRoot(cwd) + + if (workspaceRoot) { + const subPackageRoot = findSubPackageRoot(cwd, workspaceRoot) + + if (subPackageRoot) { + return subPackageRoot + } + + const workspacePatterns = findWorkspacePackages(workspaceRoot) + + const packageDirs = listPackages(workspaceRoot, workspacePatterns) + + invariant(packageDirs.length > 0, 'no packages found in workspace.') + + return select({ + message: 'Multi-package workspace detected, select a target package:', + choices: packageDirs.map((dir) => ({ + name: path.basename(dir), + value: dir, + })), + }) + } + + const projectRoot = findProjectRoot(cwd) + + if (projectRoot) { + return projectRoot + } + + console.error('Error: no package.json found.') + + process.exit(1) +} + +function getTemplateFile(...segments) { + return path.join(getTemplateDir('chat'), ...segments) +} + +function readPackageJson(pkgPath) { + return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) +} + +function writePackageJson(pkgPath, pkg) { + fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`) +} + +function findMainEntry(targetDir) { + for (const file of ['src/main.ts', 'src/main.js']) { + const fullPath = path.join(targetDir, file) + + if (fs.existsSync(fullPath)) { + return fullPath + } + } + + return null +} + +function ensureStyleImport(mainFile) { + const content = fs.readFileSync(mainFile, 'utf-8') + + if (content.includes(STYLE_IMPORT)) { + return { + type: 'skipped', + } + } + + const lines = content.split('\n') + + let lastImportIndex = -1 + + for (let i = 0; i < lines.length; i++) { + if (/^\s*import\s/.test(lines[i])) { + lastImportIndex = i + continue + } + + if (lastImportIndex !== -1) { + break + } + } + + if (lastImportIndex === -1) { + lines.unshift(STYLE_IMPORT) + } else { + lines.splice(lastImportIndex + 1, 0, STYLE_IMPORT) + } + + fs.writeFileSync(mainFile, lines.join('\n')) + + return { + type: 'inserted', + } +} + +function insertDependencyOrdered(dependencies, name, version) { + const next = {} + + let inserted = false + + for (const [key, value] of Object.entries(dependencies)) { + if (!inserted && name < key) { + next[name] = version + inserted = true + } + + next[key] = value + } + + if (!inserted) { + next[name] = version + } + + return next +} + +function ensureDependency(pkg, name, targetVersion) { + pkg.dependencies ??= {} + + const currentVersion = pkg.dependencies[name] + + if (!currentVersion) { + pkg.dependencies = insertDependencyOrdered(pkg.dependencies, name, targetVersion) + + return { + type: 'added', + } + } + + const validVersion = semver.minVersion(currentVersion) + + if (validVersion && semver.lt(validVersion.version, targetVersion)) { + pkg.dependencies[name] = targetVersion + + return { + type: 'updated', + from: currentVersion, + to: targetVersion, + } + } + + return { + type: 'skipped', + } +} + +function printDependencyResult(result, name) { + switch (result.type) { + case 'added': + logSuccess(`Added ${name}@${TARGET_VERSION}`) + break + + case 'updated': + logSuccess(`Updated ${name} from ${result.from} to ${result.to}`) + break + + case 'skipped': + logSkip(`${name} already satisfies required version`) + break + } +} + +function getChatFeatureFiles(targetDir) { + const mainEntry = findMainEntry(targetDir) + + return [ + { + label: 'TinyRobotChat.vue', + target: path.join(targetDir, 'src/TinyRobotChat.vue'), + template: getTemplateFile('src', 'TinyRobotChat.vue'), + action(fileExists) { + return fileExists ? 'overwrite' : 'create' + }, + }, + { + label: 'main entry style import', + target: mainEntry, + action() { + return 'modify' + }, + }, + { + label: '.env', + target: path.join(targetDir, '.env'), + template: getTemplateFile('.env.example'), + action(fileExists) { + return fileExists ? 'modify' : 'create' + }, + }, + + { + label: 'package.json', + target: path.join(targetDir, 'package.json'), + action() { + return 'modify' + }, + }, + ] +} + +async function selectFileChanges(files) { + return checkbox({ + message: 'Select which file changes to apply (all selected by default):', + choices: files.map((file) => { + const exists = file.target ? fs.existsSync(file.target) : false + + const disabled = !file.target + + const descriptions = { + 'TinyRobotChat.vue': 'integrate TinyRobot chat component', + 'main entry style import': 'import TinyRobot styles', + '.env': 'add environment variables', + 'package.json': 'add TinyRobot dependencies', + } + + const description = descriptions[file.label] + + return { + name: disabled + ? `${file.action(false)} ${file.label} — ${description} (main.ts/js not found)` + : `${file.action(exists)} ${file.label} — ${description}`, + + value: file.label, + + checked: !disabled, + + disabled, + } + }), + }) +} + +function isSelected(selectedFiles, label) { + return selectedFiles.includes(label) +} + +async function addFeature(targetDir, type) { + invariant(type === 'chat', `unsupported feature: ${type}`) + + const files = getChatFeatureFiles(targetDir) + + const selectedFiles = await selectFileChanges(files) + + if (selectedFiles.length === 0) { + logSkip('No changes selected.') + return + } + + const componentFile = files.find((f) => { + return f.label === 'TinyRobotChat.vue' + }) + + const envFile = files.find((f) => { + return f.label === '.env' + }) + + invariant(componentFile, 'TinyRobotChat.vue config missing.') + + invariant(envFile, '.env config missing.') + + console.log('\nChange Results\n') + + let mainImportChanged = false + let envChanged = false + let dependencyChanged = false + + if (isSelected(selectedFiles, 'TinyRobotChat.vue')) { + copyFile(componentFile.template, componentFile.target) + logSuccess(`Copied ${componentFile.label}`) + } else { + logSkippedSelection(componentFile.label) + } + + const mainFile = files.find((f) => { + return f.label === 'main entry style import' + }) + + if (!mainFile?.target) { + logUnavailable('main entry style import (main.ts/js not found)') + } else { + if (isSelected(selectedFiles, mainFile.label)) { + const result = ensureStyleImport(mainFile.target) + + switch (result.type) { + case 'inserted': + mainImportChanged = true + + logSuccess('Inserted TinyRobot style import') + break + + case 'skipped': + logSkip('TinyRobot style import already exists') + break + } + } else { + logSkippedSelection(mainFile.label) + } + } + + if (isSelected(selectedFiles, '.env')) { + const envResult = mergeEnvFile(envFile.template, envFile.target) + + switch (envResult.type) { + case 'created': + envChanged = true + + logSuccess('Created .env') + break + + case 'merged': + envChanged = true + + logSuccess(`Added ${envResult.added} env variables`) + break + + case 'skipped': + logSkip('.env already contains required variables') + break + } + } else { + logSkippedSelection('.env') + } + + const pkgPath = path.join(targetDir, 'package.json') + + invariant(fs.existsSync(pkgPath), 'package.json not found.') + + const pkg = readPackageJson(pkgPath) + + if (isSelected(selectedFiles, 'package.json')) { + const result = ensureDependency(pkg, DEP_NAME, TARGET_VERSION) + + if (result.type !== 'skipped') { + dependencyChanged = true + } + + writePackageJson(pkgPath, pkg) + + printDependencyResult(result, DEP_NAME) + } else { + logSkippedSelection('package.json') + } + + console.log(`\nSuccessfully added "${type}" feature to ${targetDir}`) + + printNextSteps({ + mainImportChanged, + envChanged, + dependencyChanged, + }) +} + +function printNextSteps({ mainImportChanged, envChanged, dependencyChanged }) { + const steps = [] + + if (!mainImportChanged) { + steps.push(`Add "${STYLE_IMPORT}" to your application entry file.`) + } + + steps.push('Render near your main application component.') + + if (envChanged) { + steps.push('Fill in your API key in .env.') + } + + if (dependencyChanged) { + steps.push('Run npm/pnpm install to update dependencies.') + } + + if (steps.length === 0) { + return + } + + console.log('\nNext Steps\n') + + for (const [index, step] of steps.entries()) { + console.log(`${index + 1}. ${step}`) + } +} + +export function registerAddCommand(program) { + program + .command('add') + .description('Add a feature to the project') + .addArgument(new Argument('', 'type of feature to add').choices(['chat'])) + .action(async (type) => { + try { + const targetDir = await resolveTargetPackage(process.cwd()) + + await addFeature(targetDir, type) + } catch (error) { + if (error instanceof Error && error.name === 'ExitPromptError') { + console.error('\nOperation cancelled.') + + process.exit(1) + } + + throw error + } + }) +} diff --git a/packages/cli/bin/commands/create.js b/packages/cli/bin/commands/create.js new file mode 100644 index 000000000..c8b6ffc98 --- /dev/null +++ b/packages/cli/bin/commands/create.js @@ -0,0 +1,144 @@ +import { input, select } from '@inquirer/prompts' +import path from 'node:path' +import process from 'node:process' + +import { + DEFAULT_PROJECT_NAME, + DEFAULT_TEMPLATE, + exists, + getAvailableTemplates, + getTemplateDir, + invariant, + scaffoldProject, + validateProjectName, +} from '../utils.js' + +async function promptOrFallback(skipPrompt, fallbackValue, promptFactory) { + if (skipPrompt) { + return fallbackValue + } + + return promptFactory() +} + +async function resolveProjectName(initialValue, skipPrompt) { + if (initialValue) { + return initialValue + } + + return promptOrFallback(skipPrompt, DEFAULT_PROJECT_NAME, () => { + return input({ + message: 'Project name:', + default: DEFAULT_PROJECT_NAME, + validate(value) { + if (!value) { + return 'Project name is required.' + } + + if (!validateProjectName(value)) { + return 'Project name can only contain lowercase letters, numbers, and dashes.' + } + + const targetDir = path.resolve(process.cwd(), value) + + if (exists(targetDir)) { + return `Target directory already exists: ${targetDir}` + } + + return true + }, + }) + }) +} + +async function resolveTemplateName(initialValue, templates, skipPrompt) { + if (initialValue) { + return initialValue + } + + const fallback = templates.includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : templates[0] + + return promptOrFallback(skipPrompt, fallback, () => { + return select({ + message: 'Template:', + default: fallback, + choices: templates.map((templateName) => ({ + name: templateName, + value: templateName, + })), + }) + }) +} + +async function resolveCreateOptions(initialProjectName, initialTemplateName, skipPrompt) { + const availableTemplates = getAvailableTemplates() + + invariant(availableTemplates.length > 0, 'no templates found.') + + const projectName = await resolveProjectName(initialProjectName, skipPrompt) + + const templateName = await resolveTemplateName(initialTemplateName, availableTemplates, skipPrompt) + + return { + projectName, + templateName, + availableTemplates, + } +} + +function validateCreateOptions(options) { + const { projectName, templateName, availableTemplates } = options + + invariant(validateProjectName(projectName), 'project name can only contain lowercase letters, numbers, and dashes.') + + const templateDir = getTemplateDir(templateName) + + invariant( + exists(templateDir), + `template "${templateName}" does not exist. Available: ${availableTemplates.join(', ')}`, + ) + + const targetDir = path.resolve(process.cwd(), projectName) + + invariant(!exists(targetDir), `target directory already exists: ${targetDir}`) + + return { + templateDir, + targetDir, + } +} + +function printCreateSuccess(projectName) { + console.log('\nProject created successfully!') + console.log('\nNext steps:') + console.log(` cd ${projectName}`) + console.log(' pnpm install') + console.log(' pnpm dev') + console.log() +} + +async function createProject(initialProjectName, initialTemplateName, skipPrompt) { + const options = await resolveCreateOptions(initialProjectName, initialTemplateName, skipPrompt) + + const { templateDir, targetDir } = validateCreateOptions(options) + + scaffoldProject(templateDir, targetDir, options.projectName) + + printCreateSuccess(options.projectName) +} + +export function registerCreateCommand(program) { + program + .command('create [project-name]') + .description('Create a TinyRobot project from template') + .option('-t, --template ', 'template name') + .action((projectName, options) => { + const skipPrompt = !process.stdout.isTTY + + createProject(projectName ?? '', options.template ?? '', skipPrompt).catch((error) => { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + + process.exit(1) + }) + }) +} diff --git a/packages/cli/bin/utils.js b/packages/cli/bin/utils.js new file mode 100644 index 000000000..b4e26165a --- /dev/null +++ b/packages/cli/bin/utils.js @@ -0,0 +1,337 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import pc from 'picocolors' + +const TEMPLATE_PLACEHOLDER = '__PROJECT_NAME__' + +export const BUILTIN_TEMPLATES = ['basic'] +export const DEFAULT_TEMPLATE = 'basic' +export const DEFAULT_PROJECT_NAME = 'tiny-robot-app' + +const WORKSPACE_FILES = ['pnpm-workspace.yaml', 'pnpm-workspace.yml'] + +const IGNORE_COPY_FILES = ['node_modules', '.git', 'dist', '.DS_Store', '.vite'] + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const templatesRoot = path.resolve(__dirname, '../templates') + +function createStatusLabel(icon, label, color) { + return `${color(icon)} ${color(label)}` +} + +export function logSuccess(message) { + console.log(`${createStatusLabel('✔', 'SUCCESS', pc.green)} ${message}`) +} + +export function logSkip(message) { + console.log(`${createStatusLabel('○', 'SKIPPED', pc.dim)} ${message}`) +} + +export function logError(message) { + console.log(`${createStatusLabel('✖', 'FAILED', pc.red)} ${message}`) +} + +export function invariant(condition, message) { + if (!condition) { + console.error(`Error: ${message}`) + process.exit(1) + } +} + +export function exists(file) { + return fs.existsSync(file) +} + +function isPackageDir(dir) { + return exists(path.join(dir, 'package.json')) +} + +function findUp(startDir, matcher) { + let dir = path.resolve(startDir) + + for (;;) { + if (matcher(dir)) { + return dir + } + + const parent = path.dirname(dir) + + if (parent === dir) { + return null + } + + dir = parent + } +} + +export function getAvailableTemplates() { + if (!exists(templatesRoot)) { + return [] + } + + return fs + .readdirSync(templatesRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && BUILTIN_TEMPLATES.includes(entry.name)) + .map((entry) => entry.name) +} + +export function getTemplateDir(templateName) { + return path.join(templatesRoot, templateName) +} + +export function validateProjectName(name) { + return /^[a-z0-9-]+$/.test(name) +} + +export function copyTemplate(sourceDir, targetDir) { + fs.cpSync(sourceDir, targetDir, { + recursive: true, + filter: (source) => { + return !IGNORE_COPY_FILES.includes(path.basename(source)) + }, + }) +} + +function renameSpecialFiles(targetDir) { + const files = [['_gitignore', '.gitignore']] + + for (const [fromName, toName] of files) { + const from = path.join(targetDir, fromName) + const to = path.join(targetDir, toName) + + if (exists(from)) { + fs.renameSync(from, to) + } + } +} + +function replaceTemplateVariables(targetDir, variables) { + const replaceFiles = ['package.json', 'README.md'] + + for (const relativePath of replaceFiles) { + const file = path.join(targetDir, relativePath) + + if (!exists(file)) { + continue + } + + let content = fs.readFileSync(file, 'utf-8') + + for (const [key, value] of Object.entries(variables)) { + content = content.replaceAll(key, value) + } + + fs.writeFileSync(file, content, 'utf-8') + } +} + +export function scaffoldProject(templateDir, targetDir, projectName) { + copyTemplate(templateDir, targetDir) + + renameSpecialFiles(targetDir) + + replaceTemplateVariables(targetDir, { + [TEMPLATE_PLACEHOLDER]: projectName, + }) +} + +function resolveWorkspaceFile(workspaceRoot) { + for (const name of WORKSPACE_FILES) { + const file = path.join(workspaceRoot, name) + + if (exists(file)) { + return file + } + } + + return null +} + +export function findWorkspaceRoot(cwd) { + return findUp(cwd, (dir) => { + return WORKSPACE_FILES.some((name) => { + return exists(path.join(dir, name)) + }) + }) +} + +export function findProjectRoot(cwd) { + return findUp(cwd, (dir) => { + return isPackageDir(dir) + }) +} + +export function findSubPackageRoot(cwd, workspaceRoot) { + return findUp(cwd, (dir) => { + if (dir === workspaceRoot) { + return false + } + + return isPackageDir(dir) + }) +} + +export function findWorkspacePackages(workspaceRoot) { + const workspaceFile = resolveWorkspaceFile(workspaceRoot) + + if (!workspaceFile) { + return [] + } + + const content = fs.readFileSync(workspaceFile, 'utf-8') + + const packages = [] + + let inPackages = false + + for (const line of content.split('\n')) { + const trimmed = line.trim() + + if (trimmed === 'packages:') { + inPackages = true + continue + } + + if (!inPackages) { + continue + } + + const match = trimmed.match(/^-\s+(.+)$/) + + if (match) { + const pattern = match[1] + + if (!pattern.startsWith('!')) { + packages.push(pattern) + } + + continue + } + + if (trimmed && !trimmed.startsWith('#')) { + break + } + } + + return packages +} + +function normalizeWorkspacePattern(pattern) { + return pattern.replace(/\*\*?/g, '').replace(/\/$/, '') +} + +export function listPackages(workspaceRoot, patterns) { + const packageDirs = [] + + const addPackage = (dir) => { + if (isPackageDir(dir)) { + packageDirs.push(dir) + } + } + + for (const pattern of patterns) { + const base = normalizeWorkspacePattern(pattern) + + const fullPath = path.join(workspaceRoot, base) + + if (!exists(fullPath)) { + continue + } + + if (pattern.includes('*')) { + const entries = fs.readdirSync(fullPath, { + withFileTypes: true, + }) + + for (const entry of entries) { + if (entry.isDirectory()) { + addPackage(path.join(fullPath, entry.name)) + } + } + } else { + addPackage(fullPath) + } + } + + return packageDirs +} + +function ensureDir(file) { + fs.mkdirSync(path.dirname(file), { + recursive: true, + }) +} + +export function copyFile(from, to) { + ensureDir(to) + + fs.copyFileSync(from, to) +} + +function parseEnv(content) { + const map = new Map() + + for (const line of content.split('\n')) { + const trimmed = line.trim() + + if (!trimmed || trimmed.startsWith('#')) { + continue + } + + const index = trimmed.indexOf('=') + + if (index === -1) { + continue + } + + const key = trimmed.slice(0, index).trim() + + map.set(key, trimmed) + } + + return map +} + +export function mergeEnvFile(templateFile, targetFile) { + if (!fs.existsSync(targetFile)) { + copyFile(templateFile, targetFile) + + return { + type: 'created', + } + } + + const templateContent = fs.readFileSync(templateFile, 'utf-8') + + const targetContent = fs.readFileSync(targetFile, 'utf-8') + + const templateEnv = parseEnv(templateContent) + + const targetEnv = parseEnv(targetContent) + + const appendLines = [] + + for (const [key, line] of templateEnv) { + if (!targetEnv.has(key)) { + appendLines.push(line) + } + } + + if (appendLines.length === 0) { + return { + type: 'skipped', + } + } + + const nextContent = [targetContent.trimEnd(), '', ...appendLines, ''].join('\n') + + fs.writeFileSync(targetFile, nextContent) + + return { + type: 'merged', + added: appendLines.length, + } +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 1ac97e841..96d5cb0f6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,6 +20,8 @@ }, "dependencies": { "@inquirer/prompts": "^8.3.2", - "commander": "^14.0.3" + "commander": "^14.0.3", + "picocolors": "^1.1.1", + "semver": "^7.8.1" } } diff --git a/packages/cli/templates/chat/.env.example b/packages/cli/templates/chat/.env.example new file mode 100644 index 000000000..2a890a7ff --- /dev/null +++ b/packages/cli/templates/chat/.env.example @@ -0,0 +1 @@ +VITE_DEEPSEEK_API_KEY= diff --git a/packages/cli/templates/chat/src/TinyRobotChat.vue b/packages/cli/templates/chat/src/TinyRobotChat.vue new file mode 100644 index 000000000..8e1fdbdb7 --- /dev/null +++ b/packages/cli/templates/chat/src/TinyRobotChat.vue @@ -0,0 +1,35 @@ + + + From 4e904fa8c03c3a24591e1128d48746d208a27ece Mon Sep 17 00:00:00 2001 From: gene9831 Date: Fri, 29 May 2026 18:50:02 +0800 Subject: [PATCH 2/5] fix(cli): ensure proper formatting of appended lines in environment file --- packages/cli/bin/commands/add.js | 49 +++++++++++++++++++++++++------- packages/cli/bin/utils.js | 4 ++- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/packages/cli/bin/commands/add.js b/packages/cli/bin/commands/add.js index cbb3ab8a3..046ea00e7 100644 --- a/packages/cli/bin/commands/add.js +++ b/packages/cli/bin/commands/add.js @@ -295,7 +295,7 @@ async function addFeature(targetDir, type) { console.log('\nChange Results\n') - let mainImportChanged = false + let needsManualStyleImport = false let envChanged = false let dependencyChanged = false @@ -311,6 +311,8 @@ async function addFeature(targetDir, type) { }) if (!mainFile?.target) { + needsManualStyleImport = true + logUnavailable('main entry style import (main.ts/js not found)') } else { if (isSelected(selectedFiles, mainFile.label)) { @@ -318,8 +320,6 @@ async function addFeature(targetDir, type) { switch (result.type) { case 'inserted': - mainImportChanged = true - logSuccess('Inserted TinyRobot style import') break @@ -328,6 +328,8 @@ async function addFeature(targetDir, type) { break } } else { + needsManualStyleImport = true + logSkippedSelection(mainFile.label) } } @@ -379,27 +381,52 @@ async function addFeature(targetDir, type) { console.log(`\nSuccessfully added "${type}" feature to ${targetDir}`) printNextSteps({ - mainImportChanged, + needsManualStyleImport, envChanged, dependencyChanged, }) } -function printNextSteps({ mainImportChanged, envChanged, dependencyChanged }) { +function printNextSteps({ needsManualStyleImport, envChanged, dependencyChanged }) { const steps = [] - if (!mainImportChanged) { - steps.push(`Add "${STYLE_IMPORT}" to your application entry file.`) + if (needsManualStyleImport) { + steps.push( + ['Import TinyRobot styles in your application entry file.', '', 'Example:', '', ` ${STYLE_IMPORT}`].join('\n'), + ) } - steps.push('Render near your main application component.') + steps.push( + [ + 'Render near your main application component.', + '', + "Example ('src/App.vue'):", + '', + ' ', + '', + ' ', + ].join('\n'), + ) if (envChanged) { - steps.push('Fill in your API key in .env.') + steps.push( + [ + 'Configure your AI provider API key in the .env file.', + '', + 'Example:', + '', + ' VITE_DEEPSEEK_API_KEY=your_api_key', + ].join('\n'), + ) } if (dependencyChanged) { - steps.push('Run npm/pnpm install to update dependencies.') + steps.push(['Install or update project dependencies.', '', 'Example:', '', ' pnpm install'].join('\n')) } if (steps.length === 0) { @@ -409,7 +436,7 @@ function printNextSteps({ mainImportChanged, envChanged, dependencyChanged }) { console.log('\nNext Steps\n') for (const [index, step] of steps.entries()) { - console.log(`${index + 1}. ${step}`) + console.log(`${index + 1}. ${step}\n`) } } diff --git a/packages/cli/bin/utils.js b/packages/cli/bin/utils.js index b4e26165a..cd3b2a265 100644 --- a/packages/cli/bin/utils.js +++ b/packages/cli/bin/utils.js @@ -326,7 +326,9 @@ export function mergeEnvFile(templateFile, targetFile) { } } - const nextContent = [targetContent.trimEnd(), '', ...appendLines, ''].join('\n') + const targetTrimmed = targetContent.replace(/\s*$/, '') + + const nextContent = targetTrimmed ? `${targetTrimmed}\n${appendLines.join('\n')}\n` : `${appendLines.join('\n')}\n` fs.writeFileSync(targetFile, nextContent) From 8bd46c77cb2b34f2ba57c5e504924ccdc716e0f9 Mon Sep 17 00:00:00 2001 From: gene9831 Date: Fri, 29 May 2026 19:09:55 +0800 Subject: [PATCH 3/5] fix(cli): update target version to 0.4.2-alpha.6 and enhance dependency handling logic --- packages/cli/bin/commands/add.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli/bin/commands/add.js b/packages/cli/bin/commands/add.js index 046ea00e7..020c01400 100644 --- a/packages/cli/bin/commands/add.js +++ b/packages/cli/bin/commands/add.js @@ -20,7 +20,7 @@ import { } from '../utils.js' const DEP_NAME = '@opentiny/tiny-robot' -const TARGET_VERSION = '0.4.2-alpha.5' +const TARGET_VERSION = '0.4.2-alpha.6' const STYLE_IMPORT = "import '@opentiny/tiny-robot/dist/style.css'" function logUnavailable(label) { @@ -154,17 +154,20 @@ function ensureDependency(pkg, name, targetVersion) { const currentVersion = pkg.dependencies[name] + // 1. 不存在 => 新增(使用 ordered insert) if (!currentVersion) { pkg.dependencies = insertDependencyOrdered(pkg.dependencies, name, targetVersion) return { type: 'added', + to: targetVersion, } } - const validVersion = semver.minVersion(currentVersion) + // 2. 已存在 => 只更新版本,不调整顺序 + const current = semver.valid(semver.coerce(currentVersion)?.version) - if (validVersion && semver.lt(validVersion.version, targetVersion)) { + if (current && current !== targetVersion) { pkg.dependencies[name] = targetVersion return { @@ -174,6 +177,7 @@ function ensureDependency(pkg, name, targetVersion) { } } + // 3. 完全一致 => 跳过 return { type: 'skipped', } From cb4aff27ff0739648da5ceb14797e9ce942d97c7 Mon Sep 17 00:00:00 2001 From: gene9831 Date: Sat, 30 May 2026 15:24:40 +0800 Subject: [PATCH 4/5] fix(cli): improve dependency version handling and enhance template validation --- packages/cli/bin/commands/add.js | 4 +-- packages/cli/bin/commands/create.js | 15 ++++++---- packages/cli/bin/utils.js | 44 ++++++++--------------------- packages/cli/package.json | 3 +- 4 files changed, 25 insertions(+), 41 deletions(-) diff --git a/packages/cli/bin/commands/add.js b/packages/cli/bin/commands/add.js index 020c01400..67e25b150 100644 --- a/packages/cli/bin/commands/add.js +++ b/packages/cli/bin/commands/add.js @@ -165,9 +165,9 @@ function ensureDependency(pkg, name, targetVersion) { } // 2. 已存在 => 只更新版本,不调整顺序 - const current = semver.valid(semver.coerce(currentVersion)?.version) + const current = semver.valid(currentVersion) - if (current && current !== targetVersion) { + if (current !== targetVersion) { pkg.dependencies[name] = targetVersion return { diff --git a/packages/cli/bin/commands/create.js b/packages/cli/bin/commands/create.js index c8b6ffc98..1bccd9ba6 100644 --- a/packages/cli/bin/commands/create.js +++ b/packages/cli/bin/commands/create.js @@ -91,13 +91,15 @@ function validateCreateOptions(options) { invariant(validateProjectName(projectName), 'project name can only contain lowercase letters, numbers, and dashes.') - const templateDir = getTemplateDir(templateName) - invariant( - exists(templateDir), + availableTemplates.includes(templateName), `template "${templateName}" does not exist. Available: ${availableTemplates.join(', ')}`, ) + const templateDir = getTemplateDir(templateName) + + invariant(exists(templateDir), `template directory missing: ${templateDir}`) + const targetDir = path.resolve(process.cwd(), projectName) invariant(!exists(targetDir), `target directory already exists: ${targetDir}`) @@ -136,9 +138,12 @@ export function registerCreateCommand(program) { const skipPrompt = !process.stdout.isTTY createProject(projectName ?? '', options.template ?? '', skipPrompt).catch((error) => { - console.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + if (error instanceof Error && error.name === 'ExitPromptError') { + console.error('\nOperation cancelled.') + process.exit(1) + } - process.exit(1) + throw error }) }) } diff --git a/packages/cli/bin/utils.js b/packages/cli/bin/utils.js index cd3b2a265..824b7ad63 100644 --- a/packages/cli/bin/utils.js +++ b/packages/cli/bin/utils.js @@ -2,6 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import pc from 'picocolors' +import yaml from 'yaml' const TEMPLATE_PLACEHOLDER = '__PROJECT_NAME__' @@ -181,42 +182,19 @@ export function findWorkspacePackages(workspaceRoot) { return [] } - const content = fs.readFileSync(workspaceFile, 'utf-8') + try { + const content = fs.readFileSync(workspaceFile, 'utf8') - const packages = [] + const config = yaml.parse(content) - let inPackages = false - - for (const line of content.split('\n')) { - const trimmed = line.trim() - - if (trimmed === 'packages:') { - inPackages = true - continue - } - - if (!inPackages) { - continue - } - - const match = trimmed.match(/^-\s+(.+)$/) - - if (match) { - const pattern = match[1] - - if (!pattern.startsWith('!')) { - packages.push(pattern) - } - - continue - } - - if (trimmed && !trimmed.startsWith('#')) { - break - } + return Array.isArray(config?.packages) + ? config.packages.filter( + (pattern) => typeof pattern === 'string' && pattern.length > 0 && !pattern.startsWith('!'), + ) + : [] + } catch { + return [] } - - return packages } function normalizeWorkspacePattern(pattern) { diff --git a/packages/cli/package.json b/packages/cli/package.json index 96d5cb0f6..15916fb75 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,6 +22,7 @@ "@inquirer/prompts": "^8.3.2", "commander": "^14.0.3", "picocolors": "^1.1.1", - "semver": "^7.8.1" + "semver": "^7.8.1", + "yaml": "^2.9.0" } } From 75e19e7780f4476ae2e6b33a32ce23a1437a8e45 Mon Sep 17 00:00:00 2001 From: gene9831 Date: Sat, 30 May 2026 15:32:55 +0800 Subject: [PATCH 5/5] fix(cli): improve error handling in add and create commands --- packages/cli/bin/commands/add.js | 3 ++- packages/cli/bin/commands/create.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/bin/commands/add.js b/packages/cli/bin/commands/add.js index 67e25b150..e679bd675 100644 --- a/packages/cli/bin/commands/add.js +++ b/packages/cli/bin/commands/add.js @@ -461,7 +461,8 @@ export function registerAddCommand(program) { process.exit(1) } - throw error + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) } }) } diff --git a/packages/cli/bin/commands/create.js b/packages/cli/bin/commands/create.js index 1bccd9ba6..003e98a21 100644 --- a/packages/cli/bin/commands/create.js +++ b/packages/cli/bin/commands/create.js @@ -143,7 +143,8 @@ export function registerCreateCommand(program) { process.exit(1) } - throw error + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) }) }) }