@opentiny/tiny-robot-cli 0.4.2-alpha.5 → 0.4.2-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -1,180 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import fs from 'node:fs'
4
- import path from 'node:path'
5
3
  import process from 'node:process'
6
- import { fileURLToPath } from 'node:url'
7
- import { input, select } from '@inquirer/prompts'
8
4
  import { Command } from 'commander'
9
5
 
10
- const TEMPLATE_PLACEHOLDER = '__PROJECT_NAME__'
11
- const DEFAULT_TEMPLATE = 'basic'
12
- const DEFAULT_PROJECT_NAME = 'tiny-robot-app'
13
- const __filename = fileURLToPath(import.meta.url)
14
- const __dirname = path.dirname(__filename)
15
- const templatesRoot = path.resolve(__dirname, '../templates')
16
-
17
- function getAvailableTemplates() {
18
- if (!fs.existsSync(templatesRoot)) {
19
- return []
20
- }
21
-
22
- return fs
23
- .readdirSync(templatesRoot, { withFileTypes: true })
24
- .filter((entry) => entry.isDirectory())
25
- .map((entry) => entry.name)
26
- }
27
-
28
- function validateProjectName(name) {
29
- // Keep project naming rules strict for npm package compatibility.
30
- const npmSafePattern = /^[a-z0-9-]+$/
31
- return npmSafePattern.test(name)
32
- }
33
-
34
- function getTemplateDir(templateName) {
35
- return path.join(templatesRoot, templateName)
36
- }
37
-
38
- async function resolveProjectName(initialProjectName, skipPrompts) {
39
- if (initialProjectName) {
40
- return initialProjectName
41
- }
42
-
43
- if (skipPrompts) {
44
- return DEFAULT_PROJECT_NAME
45
- }
46
-
47
- return input({
48
- message: 'Project name:',
49
- default: DEFAULT_PROJECT_NAME,
50
- validate: (value) => {
51
- if (!value) {
52
- return 'Project name is required.'
53
- }
54
- if (!validateProjectName(value)) {
55
- return 'Project name can only contain lowercase letters, numbers, and dashes.'
56
- }
57
- const targetDir = path.resolve(process.cwd(), value)
58
- if (fs.existsSync(targetDir)) {
59
- return `Target directory already exists: ${targetDir}`
60
- }
61
- return true
62
- },
63
- })
64
- }
65
-
66
- async function resolveTemplateName(initialTemplateName, availableTemplates, skipPrompts) {
67
- if (initialTemplateName) {
68
- return initialTemplateName
69
- }
70
-
71
- if (skipPrompts) {
72
- return availableTemplates.includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : availableTemplates[0]
73
- }
74
-
75
- return select({
76
- message: 'Template:',
77
- default: availableTemplates.includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : availableTemplates[0],
78
- choices: availableTemplates.map((templateName) => ({
79
- name: templateName,
80
- value: templateName,
81
- })),
82
- })
83
- }
84
-
85
- function copyTemplate(sourceDir, targetDir) {
86
- fs.cpSync(sourceDir, targetDir, {
87
- recursive: true,
88
- filter: (source) => {
89
- const name = path.basename(source)
90
- // Ignore local build artifacts to keep generated projects clean.
91
- return !['node_modules', '.git', 'dist', '.DS_Store', '.vite'].includes(name)
92
- },
93
- })
94
- }
95
-
96
- function renameSpecialFiles(targetDir) {
97
- const from = path.join(targetDir, '_gitignore')
98
- const to = path.join(targetDir, '.gitignore')
99
-
100
- if (fs.existsSync(from)) {
101
- fs.renameSync(from, to)
102
- }
103
- }
104
-
105
- function replaceProjectName(targetDir, projectName) {
106
- const filesToReplace = ['package.json', 'README.md']
107
-
108
- for (const relativeFile of filesToReplace) {
109
- const absoluteFile = path.join(targetDir, relativeFile)
110
-
111
- if (!fs.existsSync(absoluteFile)) {
112
- continue
113
- }
114
-
115
- const content = fs.readFileSync(absoluteFile, 'utf-8')
116
- const nextContent = content.replaceAll(TEMPLATE_PLACEHOLDER, projectName)
117
- fs.writeFileSync(absoluteFile, nextContent, 'utf-8')
118
- }
119
- }
120
-
121
- async function createProject(initialProjectName, initialTemplateName, skipPrompts) {
122
- const availableTemplates = getAvailableTemplates()
123
- if (availableTemplates.length === 0) {
124
- console.error('Error: no templates found.')
125
- process.exit(1)
126
- }
127
-
128
- const projectName = await resolveProjectName(initialProjectName, skipPrompts)
129
- const templateName = await resolveTemplateName(initialTemplateName, availableTemplates, skipPrompts)
130
-
131
- if (!validateProjectName(projectName)) {
132
- console.error('Error: project name can only contain lowercase letters, numbers, and dashes.')
133
- process.exit(1)
134
- }
135
-
136
- const templateDir = getTemplateDir(templateName)
137
- const targetDir = path.resolve(process.cwd(), projectName)
138
-
139
- if (!fs.existsSync(templateDir)) {
140
- console.error(`Error: template "${templateName}" does not exist. Available: ${availableTemplates.join(', ')}`)
141
- process.exit(1)
142
- }
143
-
144
- if (fs.existsSync(targetDir)) {
145
- console.error(`Error: target directory already exists: ${targetDir}`)
146
- process.exit(1)
147
- }
148
-
149
- copyTemplate(templateDir, targetDir)
150
- renameSpecialFiles(targetDir)
151
- replaceProjectName(targetDir, projectName)
152
-
153
- console.log('\nProject created successfully!')
154
- console.log(`\nNext steps:`)
155
- console.log(` cd ${projectName}`)
156
- console.log(' pnpm install')
157
- console.log(' pnpm dev\n')
158
- }
6
+ import { registerCreateCommand } from './commands/create.js'
7
+ import { registerAddCommand } from './commands/add.js'
159
8
 
160
9
  function run() {
161
10
  const program = new Command()
162
- program
163
- .name('tiny-robot-cli')
164
- .description('CLI to scaffold TinyRobot product projects')
165
- .showHelpAfterError()
166
11
 
167
- program
168
- .command('create [project-name]')
169
- .description('Create a TinyRobot project from template')
170
- .option('-t, --template <name>', 'template name')
171
- .action((projectName, options) => {
172
- const skipPrompts = !process.stdout.isTTY
173
- createProject(projectName ?? '', options.template ?? '', skipPrompts).catch((error) => {
174
- console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
175
- process.exit(1)
176
- })
177
- })
12
+ program.name('tiny-robot-cli').description('CLI to scaffold TinyRobot product projects').showHelpAfterError()
13
+
14
+ registerCreateCommand(program)
15
+
16
+ registerAddCommand(program)
178
17
 
179
18
  if (process.argv.length <= 2) {
180
19
  program.outputHelp()
@@ -0,0 +1,468 @@
1
+ import { checkbox, select } from '@inquirer/prompts'
2
+ import { Argument } from 'commander'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import process from 'node:process'
6
+ import semver from 'semver'
7
+
8
+ import {
9
+ copyFile,
10
+ findProjectRoot,
11
+ findSubPackageRoot,
12
+ findWorkspacePackages,
13
+ findWorkspaceRoot,
14
+ getTemplateDir,
15
+ invariant,
16
+ listPackages,
17
+ logSkip,
18
+ logSuccess,
19
+ mergeEnvFile,
20
+ } from '../utils.js'
21
+
22
+ const DEP_NAME = '@opentiny/tiny-robot'
23
+ const TARGET_VERSION = '0.4.2-alpha.6'
24
+ const STYLE_IMPORT = "import '@opentiny/tiny-robot/dist/style.css'"
25
+
26
+ function logUnavailable(label) {
27
+ logSkip(`${label} could not be applied`)
28
+ }
29
+
30
+ function logSkippedSelection(label) {
31
+ logSkip(`${label} change was not selected`)
32
+ }
33
+
34
+ async function resolveTargetPackage(cwd) {
35
+ const workspaceRoot = findWorkspaceRoot(cwd)
36
+
37
+ if (workspaceRoot) {
38
+ const subPackageRoot = findSubPackageRoot(cwd, workspaceRoot)
39
+
40
+ if (subPackageRoot) {
41
+ return subPackageRoot
42
+ }
43
+
44
+ const workspacePatterns = findWorkspacePackages(workspaceRoot)
45
+
46
+ const packageDirs = listPackages(workspaceRoot, workspacePatterns)
47
+
48
+ invariant(packageDirs.length > 0, 'no packages found in workspace.')
49
+
50
+ return select({
51
+ message: 'Multi-package workspace detected, select a target package:',
52
+ choices: packageDirs.map((dir) => ({
53
+ name: path.basename(dir),
54
+ value: dir,
55
+ })),
56
+ })
57
+ }
58
+
59
+ const projectRoot = findProjectRoot(cwd)
60
+
61
+ if (projectRoot) {
62
+ return projectRoot
63
+ }
64
+
65
+ console.error('Error: no package.json found.')
66
+
67
+ process.exit(1)
68
+ }
69
+
70
+ function getTemplateFile(...segments) {
71
+ return path.join(getTemplateDir('chat'), ...segments)
72
+ }
73
+
74
+ function readPackageJson(pkgPath) {
75
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
76
+ }
77
+
78
+ function writePackageJson(pkgPath, pkg) {
79
+ fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
80
+ }
81
+
82
+ function findMainEntry(targetDir) {
83
+ for (const file of ['src/main.ts', 'src/main.js']) {
84
+ const fullPath = path.join(targetDir, file)
85
+
86
+ if (fs.existsSync(fullPath)) {
87
+ return fullPath
88
+ }
89
+ }
90
+
91
+ return null
92
+ }
93
+
94
+ function ensureStyleImport(mainFile) {
95
+ const content = fs.readFileSync(mainFile, 'utf-8')
96
+
97
+ if (content.includes(STYLE_IMPORT)) {
98
+ return {
99
+ type: 'skipped',
100
+ }
101
+ }
102
+
103
+ const lines = content.split('\n')
104
+
105
+ let lastImportIndex = -1
106
+
107
+ for (let i = 0; i < lines.length; i++) {
108
+ if (/^\s*import\s/.test(lines[i])) {
109
+ lastImportIndex = i
110
+ continue
111
+ }
112
+
113
+ if (lastImportIndex !== -1) {
114
+ break
115
+ }
116
+ }
117
+
118
+ if (lastImportIndex === -1) {
119
+ lines.unshift(STYLE_IMPORT)
120
+ } else {
121
+ lines.splice(lastImportIndex + 1, 0, STYLE_IMPORT)
122
+ }
123
+
124
+ fs.writeFileSync(mainFile, lines.join('\n'))
125
+
126
+ return {
127
+ type: 'inserted',
128
+ }
129
+ }
130
+
131
+ function insertDependencyOrdered(dependencies, name, version) {
132
+ const next = {}
133
+
134
+ let inserted = false
135
+
136
+ for (const [key, value] of Object.entries(dependencies)) {
137
+ if (!inserted && name < key) {
138
+ next[name] = version
139
+ inserted = true
140
+ }
141
+
142
+ next[key] = value
143
+ }
144
+
145
+ if (!inserted) {
146
+ next[name] = version
147
+ }
148
+
149
+ return next
150
+ }
151
+
152
+ function ensureDependency(pkg, name, targetVersion) {
153
+ pkg.dependencies ??= {}
154
+
155
+ const currentVersion = pkg.dependencies[name]
156
+
157
+ // 1. 不存在 => 新增(使用 ordered insert)
158
+ if (!currentVersion) {
159
+ pkg.dependencies = insertDependencyOrdered(pkg.dependencies, name, targetVersion)
160
+
161
+ return {
162
+ type: 'added',
163
+ to: targetVersion,
164
+ }
165
+ }
166
+
167
+ // 2. 已存在 => 只更新版本,不调整顺序
168
+ const current = semver.valid(currentVersion)
169
+
170
+ if (current !== targetVersion) {
171
+ pkg.dependencies[name] = targetVersion
172
+
173
+ return {
174
+ type: 'updated',
175
+ from: currentVersion,
176
+ to: targetVersion,
177
+ }
178
+ }
179
+
180
+ // 3. 完全一致 => 跳过
181
+ return {
182
+ type: 'skipped',
183
+ }
184
+ }
185
+
186
+ function printDependencyResult(result, name) {
187
+ switch (result.type) {
188
+ case 'added':
189
+ logSuccess(`Added ${name}@${TARGET_VERSION}`)
190
+ break
191
+
192
+ case 'updated':
193
+ logSuccess(`Updated ${name} from ${result.from} to ${result.to}`)
194
+ break
195
+
196
+ case 'skipped':
197
+ logSkip(`${name} already satisfies required version`)
198
+ break
199
+ }
200
+ }
201
+
202
+ function getChatFeatureFiles(targetDir) {
203
+ const mainEntry = findMainEntry(targetDir)
204
+
205
+ return [
206
+ {
207
+ label: 'TinyRobotChat.vue',
208
+ target: path.join(targetDir, 'src/TinyRobotChat.vue'),
209
+ template: getTemplateFile('src', 'TinyRobotChat.vue'),
210
+ action(fileExists) {
211
+ return fileExists ? 'overwrite' : 'create'
212
+ },
213
+ },
214
+ {
215
+ label: 'main entry style import',
216
+ target: mainEntry,
217
+ action() {
218
+ return 'modify'
219
+ },
220
+ },
221
+ {
222
+ label: '.env',
223
+ target: path.join(targetDir, '.env'),
224
+ template: getTemplateFile('.env.example'),
225
+ action(fileExists) {
226
+ return fileExists ? 'modify' : 'create'
227
+ },
228
+ },
229
+
230
+ {
231
+ label: 'package.json',
232
+ target: path.join(targetDir, 'package.json'),
233
+ action() {
234
+ return 'modify'
235
+ },
236
+ },
237
+ ]
238
+ }
239
+
240
+ async function selectFileChanges(files) {
241
+ return checkbox({
242
+ message: 'Select which file changes to apply (all selected by default):',
243
+ choices: files.map((file) => {
244
+ const exists = file.target ? fs.existsSync(file.target) : false
245
+
246
+ const disabled = !file.target
247
+
248
+ const descriptions = {
249
+ 'TinyRobotChat.vue': 'integrate TinyRobot chat component',
250
+ 'main entry style import': 'import TinyRobot styles',
251
+ '.env': 'add environment variables',
252
+ 'package.json': 'add TinyRobot dependencies',
253
+ }
254
+
255
+ const description = descriptions[file.label]
256
+
257
+ return {
258
+ name: disabled
259
+ ? `${file.action(false)} ${file.label} — ${description} (main.ts/js not found)`
260
+ : `${file.action(exists)} ${file.label} — ${description}`,
261
+
262
+ value: file.label,
263
+
264
+ checked: !disabled,
265
+
266
+ disabled,
267
+ }
268
+ }),
269
+ })
270
+ }
271
+
272
+ function isSelected(selectedFiles, label) {
273
+ return selectedFiles.includes(label)
274
+ }
275
+
276
+ async function addFeature(targetDir, type) {
277
+ invariant(type === 'chat', `unsupported feature: ${type}`)
278
+
279
+ const files = getChatFeatureFiles(targetDir)
280
+
281
+ const selectedFiles = await selectFileChanges(files)
282
+
283
+ if (selectedFiles.length === 0) {
284
+ logSkip('No changes selected.')
285
+ return
286
+ }
287
+
288
+ const componentFile = files.find((f) => {
289
+ return f.label === 'TinyRobotChat.vue'
290
+ })
291
+
292
+ const envFile = files.find((f) => {
293
+ return f.label === '.env'
294
+ })
295
+
296
+ invariant(componentFile, 'TinyRobotChat.vue config missing.')
297
+
298
+ invariant(envFile, '.env config missing.')
299
+
300
+ console.log('\nChange Results\n')
301
+
302
+ let needsManualStyleImport = false
303
+ let envChanged = false
304
+ let dependencyChanged = false
305
+
306
+ if (isSelected(selectedFiles, 'TinyRobotChat.vue')) {
307
+ copyFile(componentFile.template, componentFile.target)
308
+ logSuccess(`Copied ${componentFile.label}`)
309
+ } else {
310
+ logSkippedSelection(componentFile.label)
311
+ }
312
+
313
+ const mainFile = files.find((f) => {
314
+ return f.label === 'main entry style import'
315
+ })
316
+
317
+ if (!mainFile?.target) {
318
+ needsManualStyleImport = true
319
+
320
+ logUnavailable('main entry style import (main.ts/js not found)')
321
+ } else {
322
+ if (isSelected(selectedFiles, mainFile.label)) {
323
+ const result = ensureStyleImport(mainFile.target)
324
+
325
+ switch (result.type) {
326
+ case 'inserted':
327
+ logSuccess('Inserted TinyRobot style import')
328
+ break
329
+
330
+ case 'skipped':
331
+ logSkip('TinyRobot style import already exists')
332
+ break
333
+ }
334
+ } else {
335
+ needsManualStyleImport = true
336
+
337
+ logSkippedSelection(mainFile.label)
338
+ }
339
+ }
340
+
341
+ if (isSelected(selectedFiles, '.env')) {
342
+ const envResult = mergeEnvFile(envFile.template, envFile.target)
343
+
344
+ switch (envResult.type) {
345
+ case 'created':
346
+ envChanged = true
347
+
348
+ logSuccess('Created .env')
349
+ break
350
+
351
+ case 'merged':
352
+ envChanged = true
353
+
354
+ logSuccess(`Added ${envResult.added} env variables`)
355
+ break
356
+
357
+ case 'skipped':
358
+ logSkip('.env already contains required variables')
359
+ break
360
+ }
361
+ } else {
362
+ logSkippedSelection('.env')
363
+ }
364
+
365
+ const pkgPath = path.join(targetDir, 'package.json')
366
+
367
+ invariant(fs.existsSync(pkgPath), 'package.json not found.')
368
+
369
+ const pkg = readPackageJson(pkgPath)
370
+
371
+ if (isSelected(selectedFiles, 'package.json')) {
372
+ const result = ensureDependency(pkg, DEP_NAME, TARGET_VERSION)
373
+
374
+ if (result.type !== 'skipped') {
375
+ dependencyChanged = true
376
+ }
377
+
378
+ writePackageJson(pkgPath, pkg)
379
+
380
+ printDependencyResult(result, DEP_NAME)
381
+ } else {
382
+ logSkippedSelection('package.json')
383
+ }
384
+
385
+ console.log(`\nSuccessfully added "${type}" feature to ${targetDir}`)
386
+
387
+ printNextSteps({
388
+ needsManualStyleImport,
389
+ envChanged,
390
+ dependencyChanged,
391
+ })
392
+ }
393
+
394
+ function printNextSteps({ needsManualStyleImport, envChanged, dependencyChanged }) {
395
+ const steps = []
396
+
397
+ if (needsManualStyleImport) {
398
+ steps.push(
399
+ ['Import TinyRobot styles in your application entry file.', '', 'Example:', '', ` ${STYLE_IMPORT}`].join('\n'),
400
+ )
401
+ }
402
+
403
+ steps.push(
404
+ [
405
+ 'Render <TinyRobotChat /> near your main application component.',
406
+ '',
407
+ "Example ('src/App.vue'):",
408
+ '',
409
+ ' <script setup>',
410
+ " import TinyRobotChat from './TinyRobotChat.vue'",
411
+ ' </script>',
412
+ '',
413
+ ' <template>',
414
+ ' <YourAppComponent />',
415
+ ' <TinyRobotChat />',
416
+ ' </template>',
417
+ ].join('\n'),
418
+ )
419
+
420
+ if (envChanged) {
421
+ steps.push(
422
+ [
423
+ 'Configure your AI provider API key in the .env file.',
424
+ '',
425
+ 'Example:',
426
+ '',
427
+ ' VITE_DEEPSEEK_API_KEY=your_api_key',
428
+ ].join('\n'),
429
+ )
430
+ }
431
+
432
+ if (dependencyChanged) {
433
+ steps.push(['Install or update project dependencies.', '', 'Example:', '', ' pnpm install'].join('\n'))
434
+ }
435
+
436
+ if (steps.length === 0) {
437
+ return
438
+ }
439
+
440
+ console.log('\nNext Steps\n')
441
+
442
+ for (const [index, step] of steps.entries()) {
443
+ console.log(`${index + 1}. ${step}\n`)
444
+ }
445
+ }
446
+
447
+ export function registerAddCommand(program) {
448
+ program
449
+ .command('add')
450
+ .description('Add a feature to the project')
451
+ .addArgument(new Argument('<type>', 'type of feature to add').choices(['chat']))
452
+ .action(async (type) => {
453
+ try {
454
+ const targetDir = await resolveTargetPackage(process.cwd())
455
+
456
+ await addFeature(targetDir, type)
457
+ } catch (error) {
458
+ if (error instanceof Error && error.name === 'ExitPromptError') {
459
+ console.error('\nOperation cancelled.')
460
+
461
+ process.exit(1)
462
+ }
463
+
464
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
465
+ process.exit(1)
466
+ }
467
+ })
468
+ }
@@ -0,0 +1,150 @@
1
+ import { input, select } from '@inquirer/prompts'
2
+ import path from 'node:path'
3
+ import process from 'node:process'
4
+
5
+ import {
6
+ DEFAULT_PROJECT_NAME,
7
+ DEFAULT_TEMPLATE,
8
+ exists,
9
+ getAvailableTemplates,
10
+ getTemplateDir,
11
+ invariant,
12
+ scaffoldProject,
13
+ validateProjectName,
14
+ } from '../utils.js'
15
+
16
+ async function promptOrFallback(skipPrompt, fallbackValue, promptFactory) {
17
+ if (skipPrompt) {
18
+ return fallbackValue
19
+ }
20
+
21
+ return promptFactory()
22
+ }
23
+
24
+ async function resolveProjectName(initialValue, skipPrompt) {
25
+ if (initialValue) {
26
+ return initialValue
27
+ }
28
+
29
+ return promptOrFallback(skipPrompt, DEFAULT_PROJECT_NAME, () => {
30
+ return input({
31
+ message: 'Project name:',
32
+ default: DEFAULT_PROJECT_NAME,
33
+ validate(value) {
34
+ if (!value) {
35
+ return 'Project name is required.'
36
+ }
37
+
38
+ if (!validateProjectName(value)) {
39
+ return 'Project name can only contain lowercase letters, numbers, and dashes.'
40
+ }
41
+
42
+ const targetDir = path.resolve(process.cwd(), value)
43
+
44
+ if (exists(targetDir)) {
45
+ return `Target directory already exists: ${targetDir}`
46
+ }
47
+
48
+ return true
49
+ },
50
+ })
51
+ })
52
+ }
53
+
54
+ async function resolveTemplateName(initialValue, templates, skipPrompt) {
55
+ if (initialValue) {
56
+ return initialValue
57
+ }
58
+
59
+ const fallback = templates.includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : templates[0]
60
+
61
+ return promptOrFallback(skipPrompt, fallback, () => {
62
+ return select({
63
+ message: 'Template:',
64
+ default: fallback,
65
+ choices: templates.map((templateName) => ({
66
+ name: templateName,
67
+ value: templateName,
68
+ })),
69
+ })
70
+ })
71
+ }
72
+
73
+ async function resolveCreateOptions(initialProjectName, initialTemplateName, skipPrompt) {
74
+ const availableTemplates = getAvailableTemplates()
75
+
76
+ invariant(availableTemplates.length > 0, 'no templates found.')
77
+
78
+ const projectName = await resolveProjectName(initialProjectName, skipPrompt)
79
+
80
+ const templateName = await resolveTemplateName(initialTemplateName, availableTemplates, skipPrompt)
81
+
82
+ return {
83
+ projectName,
84
+ templateName,
85
+ availableTemplates,
86
+ }
87
+ }
88
+
89
+ function validateCreateOptions(options) {
90
+ const { projectName, templateName, availableTemplates } = options
91
+
92
+ invariant(validateProjectName(projectName), 'project name can only contain lowercase letters, numbers, and dashes.')
93
+
94
+ invariant(
95
+ availableTemplates.includes(templateName),
96
+ `template "${templateName}" does not exist. Available: ${availableTemplates.join(', ')}`,
97
+ )
98
+
99
+ const templateDir = getTemplateDir(templateName)
100
+
101
+ invariant(exists(templateDir), `template directory missing: ${templateDir}`)
102
+
103
+ const targetDir = path.resolve(process.cwd(), projectName)
104
+
105
+ invariant(!exists(targetDir), `target directory already exists: ${targetDir}`)
106
+
107
+ return {
108
+ templateDir,
109
+ targetDir,
110
+ }
111
+ }
112
+
113
+ function printCreateSuccess(projectName) {
114
+ console.log('\nProject created successfully!')
115
+ console.log('\nNext steps:')
116
+ console.log(` cd ${projectName}`)
117
+ console.log(' pnpm install')
118
+ console.log(' pnpm dev')
119
+ console.log()
120
+ }
121
+
122
+ async function createProject(initialProjectName, initialTemplateName, skipPrompt) {
123
+ const options = await resolveCreateOptions(initialProjectName, initialTemplateName, skipPrompt)
124
+
125
+ const { templateDir, targetDir } = validateCreateOptions(options)
126
+
127
+ scaffoldProject(templateDir, targetDir, options.projectName)
128
+
129
+ printCreateSuccess(options.projectName)
130
+ }
131
+
132
+ export function registerCreateCommand(program) {
133
+ program
134
+ .command('create [project-name]')
135
+ .description('Create a TinyRobot project from template')
136
+ .option('-t, --template <name>', 'template name')
137
+ .action((projectName, options) => {
138
+ const skipPrompt = !process.stdout.isTTY
139
+
140
+ createProject(projectName ?? '', options.template ?? '', skipPrompt).catch((error) => {
141
+ if (error instanceof Error && error.name === 'ExitPromptError') {
142
+ console.error('\nOperation cancelled.')
143
+ process.exit(1)
144
+ }
145
+
146
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
147
+ process.exit(1)
148
+ })
149
+ })
150
+ }
package/bin/utils.js ADDED
@@ -0,0 +1,317 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import pc from 'picocolors'
5
+ import yaml from 'yaml'
6
+
7
+ const TEMPLATE_PLACEHOLDER = '__PROJECT_NAME__'
8
+
9
+ export const BUILTIN_TEMPLATES = ['basic']
10
+ export const DEFAULT_TEMPLATE = 'basic'
11
+ export const DEFAULT_PROJECT_NAME = 'tiny-robot-app'
12
+
13
+ const WORKSPACE_FILES = ['pnpm-workspace.yaml', 'pnpm-workspace.yml']
14
+
15
+ const IGNORE_COPY_FILES = ['node_modules', '.git', 'dist', '.DS_Store', '.vite']
16
+
17
+ const __filename = fileURLToPath(import.meta.url)
18
+ const __dirname = path.dirname(__filename)
19
+
20
+ const templatesRoot = path.resolve(__dirname, '../templates')
21
+
22
+ function createStatusLabel(icon, label, color) {
23
+ return `${color(icon)} ${color(label)}`
24
+ }
25
+
26
+ export function logSuccess(message) {
27
+ console.log(`${createStatusLabel('✔', 'SUCCESS', pc.green)} ${message}`)
28
+ }
29
+
30
+ export function logSkip(message) {
31
+ console.log(`${createStatusLabel('○', 'SKIPPED', pc.dim)} ${message}`)
32
+ }
33
+
34
+ export function logError(message) {
35
+ console.log(`${createStatusLabel('✖', 'FAILED', pc.red)} ${message}`)
36
+ }
37
+
38
+ export function invariant(condition, message) {
39
+ if (!condition) {
40
+ console.error(`Error: ${message}`)
41
+ process.exit(1)
42
+ }
43
+ }
44
+
45
+ export function exists(file) {
46
+ return fs.existsSync(file)
47
+ }
48
+
49
+ function isPackageDir(dir) {
50
+ return exists(path.join(dir, 'package.json'))
51
+ }
52
+
53
+ function findUp(startDir, matcher) {
54
+ let dir = path.resolve(startDir)
55
+
56
+ for (;;) {
57
+ if (matcher(dir)) {
58
+ return dir
59
+ }
60
+
61
+ const parent = path.dirname(dir)
62
+
63
+ if (parent === dir) {
64
+ return null
65
+ }
66
+
67
+ dir = parent
68
+ }
69
+ }
70
+
71
+ export function getAvailableTemplates() {
72
+ if (!exists(templatesRoot)) {
73
+ return []
74
+ }
75
+
76
+ return fs
77
+ .readdirSync(templatesRoot, { withFileTypes: true })
78
+ .filter((entry) => entry.isDirectory() && BUILTIN_TEMPLATES.includes(entry.name))
79
+ .map((entry) => entry.name)
80
+ }
81
+
82
+ export function getTemplateDir(templateName) {
83
+ return path.join(templatesRoot, templateName)
84
+ }
85
+
86
+ export function validateProjectName(name) {
87
+ return /^[a-z0-9-]+$/.test(name)
88
+ }
89
+
90
+ export function copyTemplate(sourceDir, targetDir) {
91
+ fs.cpSync(sourceDir, targetDir, {
92
+ recursive: true,
93
+ filter: (source) => {
94
+ return !IGNORE_COPY_FILES.includes(path.basename(source))
95
+ },
96
+ })
97
+ }
98
+
99
+ function renameSpecialFiles(targetDir) {
100
+ const files = [['_gitignore', '.gitignore']]
101
+
102
+ for (const [fromName, toName] of files) {
103
+ const from = path.join(targetDir, fromName)
104
+ const to = path.join(targetDir, toName)
105
+
106
+ if (exists(from)) {
107
+ fs.renameSync(from, to)
108
+ }
109
+ }
110
+ }
111
+
112
+ function replaceTemplateVariables(targetDir, variables) {
113
+ const replaceFiles = ['package.json', 'README.md']
114
+
115
+ for (const relativePath of replaceFiles) {
116
+ const file = path.join(targetDir, relativePath)
117
+
118
+ if (!exists(file)) {
119
+ continue
120
+ }
121
+
122
+ let content = fs.readFileSync(file, 'utf-8')
123
+
124
+ for (const [key, value] of Object.entries(variables)) {
125
+ content = content.replaceAll(key, value)
126
+ }
127
+
128
+ fs.writeFileSync(file, content, 'utf-8')
129
+ }
130
+ }
131
+
132
+ export function scaffoldProject(templateDir, targetDir, projectName) {
133
+ copyTemplate(templateDir, targetDir)
134
+
135
+ renameSpecialFiles(targetDir)
136
+
137
+ replaceTemplateVariables(targetDir, {
138
+ [TEMPLATE_PLACEHOLDER]: projectName,
139
+ })
140
+ }
141
+
142
+ function resolveWorkspaceFile(workspaceRoot) {
143
+ for (const name of WORKSPACE_FILES) {
144
+ const file = path.join(workspaceRoot, name)
145
+
146
+ if (exists(file)) {
147
+ return file
148
+ }
149
+ }
150
+
151
+ return null
152
+ }
153
+
154
+ export function findWorkspaceRoot(cwd) {
155
+ return findUp(cwd, (dir) => {
156
+ return WORKSPACE_FILES.some((name) => {
157
+ return exists(path.join(dir, name))
158
+ })
159
+ })
160
+ }
161
+
162
+ export function findProjectRoot(cwd) {
163
+ return findUp(cwd, (dir) => {
164
+ return isPackageDir(dir)
165
+ })
166
+ }
167
+
168
+ export function findSubPackageRoot(cwd, workspaceRoot) {
169
+ return findUp(cwd, (dir) => {
170
+ if (dir === workspaceRoot) {
171
+ return false
172
+ }
173
+
174
+ return isPackageDir(dir)
175
+ })
176
+ }
177
+
178
+ export function findWorkspacePackages(workspaceRoot) {
179
+ const workspaceFile = resolveWorkspaceFile(workspaceRoot)
180
+
181
+ if (!workspaceFile) {
182
+ return []
183
+ }
184
+
185
+ try {
186
+ const content = fs.readFileSync(workspaceFile, 'utf8')
187
+
188
+ const config = yaml.parse(content)
189
+
190
+ return Array.isArray(config?.packages)
191
+ ? config.packages.filter(
192
+ (pattern) => typeof pattern === 'string' && pattern.length > 0 && !pattern.startsWith('!'),
193
+ )
194
+ : []
195
+ } catch {
196
+ return []
197
+ }
198
+ }
199
+
200
+ function normalizeWorkspacePattern(pattern) {
201
+ return pattern.replace(/\*\*?/g, '').replace(/\/$/, '')
202
+ }
203
+
204
+ export function listPackages(workspaceRoot, patterns) {
205
+ const packageDirs = []
206
+
207
+ const addPackage = (dir) => {
208
+ if (isPackageDir(dir)) {
209
+ packageDirs.push(dir)
210
+ }
211
+ }
212
+
213
+ for (const pattern of patterns) {
214
+ const base = normalizeWorkspacePattern(pattern)
215
+
216
+ const fullPath = path.join(workspaceRoot, base)
217
+
218
+ if (!exists(fullPath)) {
219
+ continue
220
+ }
221
+
222
+ if (pattern.includes('*')) {
223
+ const entries = fs.readdirSync(fullPath, {
224
+ withFileTypes: true,
225
+ })
226
+
227
+ for (const entry of entries) {
228
+ if (entry.isDirectory()) {
229
+ addPackage(path.join(fullPath, entry.name))
230
+ }
231
+ }
232
+ } else {
233
+ addPackage(fullPath)
234
+ }
235
+ }
236
+
237
+ return packageDirs
238
+ }
239
+
240
+ function ensureDir(file) {
241
+ fs.mkdirSync(path.dirname(file), {
242
+ recursive: true,
243
+ })
244
+ }
245
+
246
+ export function copyFile(from, to) {
247
+ ensureDir(to)
248
+
249
+ fs.copyFileSync(from, to)
250
+ }
251
+
252
+ function parseEnv(content) {
253
+ const map = new Map()
254
+
255
+ for (const line of content.split('\n')) {
256
+ const trimmed = line.trim()
257
+
258
+ if (!trimmed || trimmed.startsWith('#')) {
259
+ continue
260
+ }
261
+
262
+ const index = trimmed.indexOf('=')
263
+
264
+ if (index === -1) {
265
+ continue
266
+ }
267
+
268
+ const key = trimmed.slice(0, index).trim()
269
+
270
+ map.set(key, trimmed)
271
+ }
272
+
273
+ return map
274
+ }
275
+
276
+ export function mergeEnvFile(templateFile, targetFile) {
277
+ if (!fs.existsSync(targetFile)) {
278
+ copyFile(templateFile, targetFile)
279
+
280
+ return {
281
+ type: 'created',
282
+ }
283
+ }
284
+
285
+ const templateContent = fs.readFileSync(templateFile, 'utf-8')
286
+
287
+ const targetContent = fs.readFileSync(targetFile, 'utf-8')
288
+
289
+ const templateEnv = parseEnv(templateContent)
290
+
291
+ const targetEnv = parseEnv(targetContent)
292
+
293
+ const appendLines = []
294
+
295
+ for (const [key, line] of templateEnv) {
296
+ if (!targetEnv.has(key)) {
297
+ appendLines.push(line)
298
+ }
299
+ }
300
+
301
+ if (appendLines.length === 0) {
302
+ return {
303
+ type: 'skipped',
304
+ }
305
+ }
306
+
307
+ const targetTrimmed = targetContent.replace(/\s*$/, '')
308
+
309
+ const nextContent = targetTrimmed ? `${targetTrimmed}\n${appendLines.join('\n')}\n` : `${appendLines.join('\n')}\n`
310
+
311
+ fs.writeFileSync(targetFile, nextContent)
312
+
313
+ return {
314
+ type: 'merged',
315
+ added: appendLines.length,
316
+ }
317
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentiny/tiny-robot-cli",
3
- "version": "0.4.2-alpha.5",
3
+ "version": "0.4.2-alpha.7",
4
4
  "description": "CLI to scaffold TinyRobot product projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,10 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@inquirer/prompts": "^8.3.2",
23
- "commander": "^14.0.3"
23
+ "commander": "^14.0.3",
24
+ "picocolors": "^1.1.1",
25
+ "semver": "^7.8.1",
26
+ "yaml": "^2.9.0"
24
27
  },
25
- "gitHead": "1f85d103786f862188181c40c0702ab447b1851b"
28
+ "gitHead": "6713e6fd0f9a7239d940e5402fec6b8d81bce365"
26
29
  }
@@ -0,0 +1 @@
1
+ VITE_DEEPSEEK_API_KEY=
@@ -0,0 +1,35 @@
1
+ <script setup lang="ts">
2
+ import type { ChatMcpServerConfig, ChatModelOption } from '@opentiny/tiny-robot/experimental'
3
+ import { TrThemeProvider } from '@opentiny/tiny-robot'
4
+ import { TrChat } from '@opentiny/tiny-robot/experimental'
5
+ import { ref } from 'vue'
6
+
7
+ const show = ref(false)
8
+ const fullscreen = ref(false)
9
+
10
+ const chatModelOptions: ChatModelOption[] = [
11
+ {
12
+ id: 'deepseek-chat',
13
+ provider: 'deepseek',
14
+ name: 'DeepSeek Chat',
15
+ model: 'deepseek-chat',
16
+ apiUrl: 'https://api.deepseek.com/chat/completions',
17
+ apiKey: import.meta.env.VITE_DEEPSEEK_API_KEY || '',
18
+ },
19
+ ]
20
+
21
+ const chatMcpServers: Record<string, ChatMcpServerConfig> = {}
22
+ </script>
23
+
24
+ <template>
25
+ <TrThemeProvider>
26
+ <TrChat
27
+ v-model:show="show"
28
+ v-model:fullscreen="fullscreen"
29
+ title="TrChat Preview"
30
+ system-prompt="You are a helpful assistant."
31
+ :model-options="chatModelOptions"
32
+ :mcp-servers="chatMcpServers"
33
+ />
34
+ </TrThemeProvider>
35
+ </template>