@opentiny/tiny-robot-cli 0.4.1-alpha.0
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/LICENSE +22 -0
- package/README.md +21 -0
- package/bin/cli.js +26 -0
- package/bin/commands/add.js +436 -0
- package/bin/commands/create.js +144 -0
- package/bin/utils.js +337 -0
- package/package.json +28 -0
- package/templates/basic/.env.example +2 -0
- package/templates/basic/README.md +45 -0
- package/templates/basic/index.html +13 -0
- package/templates/basic/package.json +29 -0
- package/templates/basic/public/favicon.ico +0 -0
- package/templates/basic/public/modelcontextprotocol.png +0 -0
- package/templates/basic/src/App.vue +130 -0
- package/templates/basic/src/components/ChatList.vue +82 -0
- package/templates/basic/src/components/ChatSender.vue +125 -0
- package/templates/basic/src/components/ConversationHistory.vue +136 -0
- package/templates/basic/src/components/HistoryDrawerButton.vue +43 -0
- package/templates/basic/src/components/McpServerPickerButton.vue +278 -0
- package/templates/basic/src/components/ThemeToggleButton.vue +44 -0
- package/templates/basic/src/components/icons/IconDeepThink.vue +29 -0
- package/templates/basic/src/components/icons/IconModelAliyunBailian.vue +51 -0
- package/templates/basic/src/components/icons/IconModelDeepseek.vue +29 -0
- package/templates/basic/src/components/icons/IconMoon.vue +29 -0
- package/templates/basic/src/components/icons/IconPlugin.vue +29 -0
- package/templates/basic/src/components/icons/IconSun.vue +35 -0
- package/templates/basic/src/components/icons/IconWebSearch.vue +36 -0
- package/templates/basic/src/components/icons/index.ts +7 -0
- package/templates/basic/src/composables/useChat.ts +129 -0
- package/templates/basic/src/composables/useMcp.ts +170 -0
- package/templates/basic/src/composables/useModel.ts +82 -0
- package/templates/basic/src/main.ts +7 -0
- package/templates/basic/src/mcpServers.ts +40 -0
- package/templates/basic/src/models.ts +81 -0
- package/templates/basic/src/style.css +21 -0
- package/templates/basic/tsconfig.app.json +16 -0
- package/templates/basic/tsconfig.json +7 -0
- package/templates/basic/tsconfig.node.json +26 -0
- package/templates/basic/vite.config.ts +16 -0
- package/templates/chat/.env.example +1 -0
- package/templates/chat/src/TinyRobotChat.vue +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 - present OpenTiny Authors.
|
|
4
|
+
Copyright (c) 2025 - present Huawei Cloud Computing Technologies Co., Ltd.
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @opentiny/tiny-robot-cli
|
|
2
|
+
|
|
3
|
+
A lightweight CLI for scaffolding TinyRobot-based product projects.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @opentiny/tiny-robot-cli create my-app
|
|
9
|
+
pnpm dlx @opentiny/tiny-robot-cli create my-app
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Options
|
|
13
|
+
|
|
14
|
+
- `-t, --template <name>`: template name, currently supports `basic`
|
|
15
|
+
- `-h, --help`: show help
|
|
16
|
+
|
|
17
|
+
## Template Documentation
|
|
18
|
+
|
|
19
|
+
Template-specific features and environment variables are documented in each template directory, for example:
|
|
20
|
+
|
|
21
|
+
- `packages/cli/templates/basic/README.md`
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import { Command } from 'commander'
|
|
5
|
+
|
|
6
|
+
import { registerCreateCommand } from './commands/create.js'
|
|
7
|
+
import { registerAddCommand } from './commands/add.js'
|
|
8
|
+
|
|
9
|
+
function run() {
|
|
10
|
+
const program = new Command()
|
|
11
|
+
|
|
12
|
+
program.name('tiny-robot-cli').description('CLI to scaffold TinyRobot product projects').showHelpAfterError()
|
|
13
|
+
|
|
14
|
+
registerCreateCommand(program)
|
|
15
|
+
|
|
16
|
+
registerAddCommand(program)
|
|
17
|
+
|
|
18
|
+
if (process.argv.length <= 2) {
|
|
19
|
+
program.outputHelp()
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
program.parse(process.argv)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
run()
|
|
@@ -0,0 +1,436 @@
|
|
|
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.5'
|
|
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
|
+
if (!currentVersion) {
|
|
158
|
+
pkg.dependencies = insertDependencyOrdered(pkg.dependencies, name, targetVersion)
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
type: 'added',
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const validVersion = semver.minVersion(currentVersion)
|
|
166
|
+
|
|
167
|
+
if (validVersion && semver.lt(validVersion.version, targetVersion)) {
|
|
168
|
+
pkg.dependencies[name] = targetVersion
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
type: 'updated',
|
|
172
|
+
from: currentVersion,
|
|
173
|
+
to: targetVersion,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
type: 'skipped',
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function printDependencyResult(result, name) {
|
|
183
|
+
switch (result.type) {
|
|
184
|
+
case 'added':
|
|
185
|
+
logSuccess(`Added ${name}@${TARGET_VERSION}`)
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
case 'updated':
|
|
189
|
+
logSuccess(`Updated ${name} from ${result.from} to ${result.to}`)
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
case 'skipped':
|
|
193
|
+
logSkip(`${name} already satisfies required version`)
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function getChatFeatureFiles(targetDir) {
|
|
199
|
+
const mainEntry = findMainEntry(targetDir)
|
|
200
|
+
|
|
201
|
+
return [
|
|
202
|
+
{
|
|
203
|
+
label: 'TinyRobotChat.vue',
|
|
204
|
+
target: path.join(targetDir, 'src/TinyRobotChat.vue'),
|
|
205
|
+
template: getTemplateFile('src', 'TinyRobotChat.vue'),
|
|
206
|
+
action(fileExists) {
|
|
207
|
+
return fileExists ? 'overwrite' : 'create'
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
label: 'main entry style import',
|
|
212
|
+
target: mainEntry,
|
|
213
|
+
action() {
|
|
214
|
+
return 'modify'
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
label: '.env',
|
|
219
|
+
target: path.join(targetDir, '.env'),
|
|
220
|
+
template: getTemplateFile('.env.example'),
|
|
221
|
+
action(fileExists) {
|
|
222
|
+
return fileExists ? 'modify' : 'create'
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
{
|
|
227
|
+
label: 'package.json',
|
|
228
|
+
target: path.join(targetDir, 'package.json'),
|
|
229
|
+
action() {
|
|
230
|
+
return 'modify'
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
]
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function selectFileChanges(files) {
|
|
237
|
+
return checkbox({
|
|
238
|
+
message: 'Select which file changes to apply (all selected by default):',
|
|
239
|
+
choices: files.map((file) => {
|
|
240
|
+
const exists = file.target ? fs.existsSync(file.target) : false
|
|
241
|
+
|
|
242
|
+
const disabled = !file.target
|
|
243
|
+
|
|
244
|
+
const descriptions = {
|
|
245
|
+
'TinyRobotChat.vue': 'integrate TinyRobot chat component',
|
|
246
|
+
'main entry style import': 'import TinyRobot styles',
|
|
247
|
+
'.env': 'add environment variables',
|
|
248
|
+
'package.json': 'add TinyRobot dependencies',
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const description = descriptions[file.label]
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
name: disabled
|
|
255
|
+
? `${file.action(false)} ${file.label} — ${description} (main.ts/js not found)`
|
|
256
|
+
: `${file.action(exists)} ${file.label} — ${description}`,
|
|
257
|
+
|
|
258
|
+
value: file.label,
|
|
259
|
+
|
|
260
|
+
checked: !disabled,
|
|
261
|
+
|
|
262
|
+
disabled,
|
|
263
|
+
}
|
|
264
|
+
}),
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isSelected(selectedFiles, label) {
|
|
269
|
+
return selectedFiles.includes(label)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function addFeature(targetDir, type) {
|
|
273
|
+
invariant(type === 'chat', `unsupported feature: ${type}`)
|
|
274
|
+
|
|
275
|
+
const files = getChatFeatureFiles(targetDir)
|
|
276
|
+
|
|
277
|
+
const selectedFiles = await selectFileChanges(files)
|
|
278
|
+
|
|
279
|
+
if (selectedFiles.length === 0) {
|
|
280
|
+
logSkip('No changes selected.')
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const componentFile = files.find((f) => {
|
|
285
|
+
return f.label === 'TinyRobotChat.vue'
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
const envFile = files.find((f) => {
|
|
289
|
+
return f.label === '.env'
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
invariant(componentFile, 'TinyRobotChat.vue config missing.')
|
|
293
|
+
|
|
294
|
+
invariant(envFile, '.env config missing.')
|
|
295
|
+
|
|
296
|
+
console.log('\nChange Results\n')
|
|
297
|
+
|
|
298
|
+
let mainImportChanged = false
|
|
299
|
+
let envChanged = false
|
|
300
|
+
let dependencyChanged = false
|
|
301
|
+
|
|
302
|
+
if (isSelected(selectedFiles, 'TinyRobotChat.vue')) {
|
|
303
|
+
copyFile(componentFile.template, componentFile.target)
|
|
304
|
+
logSuccess(`Copied ${componentFile.label}`)
|
|
305
|
+
} else {
|
|
306
|
+
logSkippedSelection(componentFile.label)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const mainFile = files.find((f) => {
|
|
310
|
+
return f.label === 'main entry style import'
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
if (!mainFile?.target) {
|
|
314
|
+
logUnavailable('main entry style import (main.ts/js not found)')
|
|
315
|
+
} else {
|
|
316
|
+
if (isSelected(selectedFiles, mainFile.label)) {
|
|
317
|
+
const result = ensureStyleImport(mainFile.target)
|
|
318
|
+
|
|
319
|
+
switch (result.type) {
|
|
320
|
+
case 'inserted':
|
|
321
|
+
mainImportChanged = true
|
|
322
|
+
|
|
323
|
+
logSuccess('Inserted TinyRobot style import')
|
|
324
|
+
break
|
|
325
|
+
|
|
326
|
+
case 'skipped':
|
|
327
|
+
logSkip('TinyRobot style import already exists')
|
|
328
|
+
break
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
logSkippedSelection(mainFile.label)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (isSelected(selectedFiles, '.env')) {
|
|
336
|
+
const envResult = mergeEnvFile(envFile.template, envFile.target)
|
|
337
|
+
|
|
338
|
+
switch (envResult.type) {
|
|
339
|
+
case 'created':
|
|
340
|
+
envChanged = true
|
|
341
|
+
|
|
342
|
+
logSuccess('Created .env')
|
|
343
|
+
break
|
|
344
|
+
|
|
345
|
+
case 'merged':
|
|
346
|
+
envChanged = true
|
|
347
|
+
|
|
348
|
+
logSuccess(`Added ${envResult.added} env variables`)
|
|
349
|
+
break
|
|
350
|
+
|
|
351
|
+
case 'skipped':
|
|
352
|
+
logSkip('.env already contains required variables')
|
|
353
|
+
break
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
logSkippedSelection('.env')
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const pkgPath = path.join(targetDir, 'package.json')
|
|
360
|
+
|
|
361
|
+
invariant(fs.existsSync(pkgPath), 'package.json not found.')
|
|
362
|
+
|
|
363
|
+
const pkg = readPackageJson(pkgPath)
|
|
364
|
+
|
|
365
|
+
if (isSelected(selectedFiles, 'package.json')) {
|
|
366
|
+
const result = ensureDependency(pkg, DEP_NAME, TARGET_VERSION)
|
|
367
|
+
|
|
368
|
+
if (result.type !== 'skipped') {
|
|
369
|
+
dependencyChanged = true
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
writePackageJson(pkgPath, pkg)
|
|
373
|
+
|
|
374
|
+
printDependencyResult(result, DEP_NAME)
|
|
375
|
+
} else {
|
|
376
|
+
logSkippedSelection('package.json')
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
console.log(`\nSuccessfully added "${type}" feature to ${targetDir}`)
|
|
380
|
+
|
|
381
|
+
printNextSteps({
|
|
382
|
+
mainImportChanged,
|
|
383
|
+
envChanged,
|
|
384
|
+
dependencyChanged,
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function printNextSteps({ mainImportChanged, envChanged, dependencyChanged }) {
|
|
389
|
+
const steps = []
|
|
390
|
+
|
|
391
|
+
if (!mainImportChanged) {
|
|
392
|
+
steps.push(`Add "${STYLE_IMPORT}" to your application entry file.`)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
steps.push('Render <TinyRobotChat /> near your main application component.')
|
|
396
|
+
|
|
397
|
+
if (envChanged) {
|
|
398
|
+
steps.push('Fill in your API key in .env.')
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (dependencyChanged) {
|
|
402
|
+
steps.push('Run npm/pnpm install to update dependencies.')
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (steps.length === 0) {
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
console.log('\nNext Steps\n')
|
|
410
|
+
|
|
411
|
+
for (const [index, step] of steps.entries()) {
|
|
412
|
+
console.log(`${index + 1}. ${step}`)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function registerAddCommand(program) {
|
|
417
|
+
program
|
|
418
|
+
.command('add')
|
|
419
|
+
.description('Add a feature to the project')
|
|
420
|
+
.addArgument(new Argument('<type>', 'type of feature to add').choices(['chat']))
|
|
421
|
+
.action(async (type) => {
|
|
422
|
+
try {
|
|
423
|
+
const targetDir = await resolveTargetPackage(process.cwd())
|
|
424
|
+
|
|
425
|
+
await addFeature(targetDir, type)
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error instanceof Error && error.name === 'ExitPromptError') {
|
|
428
|
+
console.error('\nOperation cancelled.')
|
|
429
|
+
|
|
430
|
+
process.exit(1)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
throw error
|
|
434
|
+
}
|
|
435
|
+
})
|
|
436
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
const templateDir = getTemplateDir(templateName)
|
|
95
|
+
|
|
96
|
+
invariant(
|
|
97
|
+
exists(templateDir),
|
|
98
|
+
`template "${templateName}" does not exist. Available: ${availableTemplates.join(', ')}`,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const targetDir = path.resolve(process.cwd(), projectName)
|
|
102
|
+
|
|
103
|
+
invariant(!exists(targetDir), `target directory already exists: ${targetDir}`)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
templateDir,
|
|
107
|
+
targetDir,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function printCreateSuccess(projectName) {
|
|
112
|
+
console.log('\nProject created successfully!')
|
|
113
|
+
console.log('\nNext steps:')
|
|
114
|
+
console.log(` cd ${projectName}`)
|
|
115
|
+
console.log(' pnpm install')
|
|
116
|
+
console.log(' pnpm dev')
|
|
117
|
+
console.log()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function createProject(initialProjectName, initialTemplateName, skipPrompt) {
|
|
121
|
+
const options = await resolveCreateOptions(initialProjectName, initialTemplateName, skipPrompt)
|
|
122
|
+
|
|
123
|
+
const { templateDir, targetDir } = validateCreateOptions(options)
|
|
124
|
+
|
|
125
|
+
scaffoldProject(templateDir, targetDir, options.projectName)
|
|
126
|
+
|
|
127
|
+
printCreateSuccess(options.projectName)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function registerCreateCommand(program) {
|
|
131
|
+
program
|
|
132
|
+
.command('create [project-name]')
|
|
133
|
+
.description('Create a TinyRobot project from template')
|
|
134
|
+
.option('-t, --template <name>', 'template name')
|
|
135
|
+
.action((projectName, options) => {
|
|
136
|
+
const skipPrompt = !process.stdout.isTTY
|
|
137
|
+
|
|
138
|
+
createProject(projectName ?? '', options.template ?? '', skipPrompt).catch((error) => {
|
|
139
|
+
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
|
140
|
+
|
|
141
|
+
process.exit(1)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
}
|