@dengzhibo/create-vue-template 0.0.1 → 0.0.2

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/lib/prompts.js CHANGED
@@ -1,318 +1,317 @@
1
- /* eslint-disable no-console */
2
-
3
- import path from 'node:path'
4
- /**
5
- * 命令行交互问答模块
6
- *
7
- * 作用:
8
- * 为脚手架提供终端交互能力,并把问答结果整理成创建计划所需的选项。
9
- * bin 入口只负责调用,所有输入框、单选、多选的渲染和按键处理都在本文件。
10
- *
11
- * 导出:
12
- * Cancelled 用户主动取消(Ctrl+C / Esc / 输入流结束)时抛出的异常
13
- * prompt 通用交互组件,支持三种形式:
14
- * - 文本输入(不传 choices)
15
- * - 单选(传 choices)
16
- * - 多选(传 choices 且 multiple: true)
17
- * collectOptions 配置向导,按顺序提问并处理选项之间的联动
18
- * summary 把创建计划整理成确认页的文字摘要
19
- *
20
- * 实现要点:
21
- * - 基于 Node 原生 readline 的按键事件,开启 Raw 模式后自行绘制界面,不依赖第三方库。
22
- * - 当前问题限制在终端可视高度内,长列表随当前选项滚动;完成后收起为一行摘要。
23
- * - 选中状态用符号和颜色双重表示:即使终端不支持颜色,符号也能区分选中与否。
24
- * - 颜色在 NO_COLOR 环境变量存在时自动关闭。
25
- *
26
- * 终端兼容:
27
- * 多选符号曾使用 ☑ / ☐,在部分 Windows 终端字体里 ☑ 缺失,显示成空方块,
28
- * 导致已选和未选无法区分,因此改用 ◉ / ◯。若仍有显示问题,
29
- * 将下方 MARK_ON / MARK_OFF 改为 '[x]' / '[ ]' 即可。
30
- */
31
- import readline from 'node:readline'
32
- import { featureLabels, normalizeOptions } from './options.js'
33
-
34
- /** 用户主动中断(Ctrl+C 或 Esc)时抛出,由调用方统一捕获 */
35
- export class Cancelled extends Error {}
36
-
37
- // ---------------------------------------------------------------------------
38
- // 显示样式
39
- // ---------------------------------------------------------------------------
40
-
41
- // 多选框符号(已选 / 未选)
42
- const MARK_ON = '◉'
43
- const MARK_OFF = '◯'
44
- // 单选框符号(当前项 / 其他项)
45
- const RADIO_ON = '●'
46
- const RADIO_OFF = '○'
47
-
48
- const useColor = !process.env.NO_COLOR
49
- const paint = code => text => (useColor ? `\x1B[${code}m${text}\x1B[0m` : text)
50
- const green = paint(32)
51
- const cyan = paint(36)
52
- const red = paint(31)
53
- const gray = paint(90)
54
-
55
- // 去掉颜色转义序列,用于计算真实显示宽度
56
- // eslint-disable-next-line no-control-regex
57
- const stripAnsi = text => text.replace(/\x1B\[[0-9;]*m/g, '')
58
-
59
- // 单个字符的显示宽度:中日韩、全角字符和表情占 2 格,其余占 1 格
60
- function charWidth(char) {
61
- const code = char.codePointAt(0)
62
- if (/\p{Mark}/u.test(char) || code === 0x200D)
63
- return 0
64
- const wide
65
- = (code >= 0x1100 && code <= 0x115F)
66
- || (code >= 0x2E80 && code <= 0xA4CF)
67
- || (code >= 0xAC00 && code <= 0xD7A3)
68
- || (code >= 0xF900 && code <= 0xFAFF)
69
- || (code >= 0xFE30 && code <= 0xFE6F)
70
- || (code >= 0xFF00 && code <= 0xFF60)
71
- || (code >= 0xFFE0 && code <= 0xFFE6)
72
- || (code >= 0x1F300 && code <= 0x1F64F)
73
- || (code >= 0x20000 && code <= 0x3FFFD)
74
- return wide ? 2 : 1
75
- }
76
-
77
- // 每行保留一格,避免行尾自动换行使光标回退行数失真;不截断颜色转义序列。
78
- function fitLine(line, columns) {
79
- const limit = Math.max(1, columns - 1)
80
- if ([...stripAnsi(line)].reduce((width, char) => width + charWidth(char), 0) <= limit)
81
- return line
82
- let result = ''
83
- let width = 0
84
- // eslint-disable-next-line no-control-regex
85
- for (const token of line.match(/\x1B\[[0-9;]*m|[\s\S]/gu) || []) {
86
- if (token.startsWith('\x1B')) {
87
- result += token
88
- continue
89
- }
90
- width += charWidth(token)
91
- if (width > limit - 1)
92
- break
93
- result += token
94
- }
95
- return `${result}…\x1B[0m`
96
- }
97
-
98
- // ---------------------------------------------------------------------------
99
- // 通用交互组件
100
- // ---------------------------------------------------------------------------
101
-
102
- /**
103
- * 显示一个交互问题并返回用户的回答。
104
- *
105
- * 参数:
106
- * title 问题标题
107
- * choices 选项数组 [{ value, label, group?, required? }];不传则为文本输入
108
- * multiple 是否多选(需要 choices)
109
- * initial 初始值:单选为某个 value,多选为 value 数组,文本为字符串
110
- * defaultValue 文本输入留空时使用的默认值
111
- * hint 标题下方的多行提示
112
- * validate 校验函数,返回错误文字则不允许提交
113
- *
114
- * 返回:文本为字符串,单选为选中的 value,多选为选中的 value 数组。
115
- * 非终端环境会直接抛错,提示改用 --yes 和显式选项。
116
- */
117
- export function prompt({ title, choices, multiple = false, initial, defaultValue = '', hint = '', validate = () => undefined }) {
118
- if (!process.stdin.isTTY || !process.stdout.isTTY)
119
- throw new Error('交互问答需要终端;自动化调用请使用 --yes 和显式选项,详见 --help')
120
-
121
- const input = process.stdin
122
- const output = process.stdout
123
- readline.emitKeypressEvents(input)
124
- const wasRaw = input.isRaw
125
-
126
- // Raw 模式:接管按键事件,阻止终端默认回显
127
- input.setRawMode(true)
128
- input.resume()
129
-
130
- let index = Math.max(0, choices?.findIndex(choice => choice.value === initial) ?? 0)
131
- let value = typeof initial === 'string' && !choices ? initial : ''
132
- const checked = new Set(Array.isArray(initial) ? initial : [])
133
-
134
- // 必选项始终处于选中状态
135
- for (const choice of choices || []) {
136
- if (choice.required)
137
- checked.add(choice.value)
138
- }
139
- let rows = 0
140
- let message = ''
141
- let offset = 0
142
- let columns = output.columns || 80
143
-
144
- const clearFrame = () => {
145
- if (rows) {
146
- output.write(`\x1B[${rows}A\r\x1B[0J`)
147
- rows = 0
148
- }
149
- }
150
-
151
- // 最后一行留给光标,整个活动问题始终处于可见屏幕中,不回退到历史缓冲区。
152
- const draw = (resized = false) => {
153
- const height = Math.max(1, (output.rows || 24) - 1)
154
- const nextColumns = Math.max(2, output.columns || 80)
155
- if (resized && (nextColumns !== columns || rows > height)) {
156
- // 改变宽度会使旧输出重新折行,缩小高度也可能丢失旧起点,不能再按旧行数回退。
157
- output.write('\x1B[H\x1B[2J')
158
- rows = 0
159
- }
160
- else {
161
- clearFrame()
162
- }
163
- columns = nextColumns
164
-
165
- const entries = []
166
- let activeRow = 0
167
- if (choices) {
168
- for (const [i, choice] of choices.entries()) {
169
- if (choice.group && choice.group !== choices[i - 1]?.group)
170
- entries.push(` ${gray(choice.group)}`)
171
-
172
- const active = index === i
173
- if (active)
174
- activeRow = entries.length
175
- const selected = multiple ? checked.has(choice.value) : active
176
- const on = multiple ? MARK_ON : RADIO_ON
177
- const off = multiple ? MARK_OFF : RADIO_OFF
178
- const mark = selected ? green(on) : gray(off)
179
- const pointer = active ? cyan('›') : gray('│')
180
- const label = active ? cyan(choice.label) : choice.label
181
- const required = choice.required ? gray('(必需)') : ''
182
- entries.push(`${pointer} ${mark} ${label}${required}`)
183
- }
184
- }
185
- else {
186
- entries.push(`${gray('│')} ${value || gray(`${defaultValue}(默认)`)}`)
187
- }
188
-
189
- const hints = hint ? hint.split('\n').slice(0, Math.max(0, height - 5)).map(line => `${gray('│')} ${gray(line)}`) : []
190
- const tip = choices ? (multiple ? '↑↓ 移动,Space 勾选,Enter 确认' : '↑↓ 选择,Enter 确认') : 'Enter 确认'
191
- let lines
192
- if (height < 3) {
193
- // 极小窗口优先保留当前选项;两行可用时同时显示校验或操作提示。
194
- lines = [entries[activeRow]]
195
- if (height === 2)
196
- lines.push(`${gray('└')} ${message ? red(message) : gray(tip)}`)
197
- }
198
- else {
199
- const budget = height - hints.length - 2
200
- const overflow = entries.length > budget
201
- const indicators = overflow && budget >= 3
202
- const capacity = indicators ? budget - 2 : budget
203
- offset = Math.max(0, Math.min(offset, activeRow, entries.length - capacity))
204
- if (activeRow >= offset + capacity)
205
- offset = activeRow - capacity + 1
206
- const visible = entries.slice(offset, offset + capacity)
207
- if (indicators) {
208
- visible.unshift(gray(offset ? '│ ↑ 上方还有选项' : '│ 已到列表顶部'))
209
- visible.push(gray(offset + capacity < entries.length ? '│ ↓ 下方还有选项' : '│ 已到列表底部'))
210
- }
211
- const position = overflow && choices ? ` [${index + 1}/${choices.length}]` : ''
212
- lines = [`${cyan('◆')} ${title}`, ...visible, ...hints, `${gray('└')} ${message ? red(message) : gray(`${position} ${tip}`.trim())}`]
213
- }
214
-
215
- rows = lines.length
216
- output.write(`${lines.map(line => fitLine(line, columns)).join('\n')}\n`)
217
- }
218
-
219
- return new Promise((resolve, reject) => {
220
- // 还原终端状态并移除监听,任何结束路径都必须调用
221
- function cleanup() {
222
- input.removeListener('keypress', onKey)
223
- input.removeListener('end', onEnd)
224
- input.removeListener('error', onError)
225
- output.removeListener('resize', onResize)
226
- input.setRawMode(Boolean(wasRaw))
227
- input.pause()
228
- output.write('\x1B[?25h')
229
- }
230
- function onEnd() {
231
- clearFrame()
232
- cleanup()
233
- reject(new Cancelled('已取消'))
234
- }
235
- function onError(error) {
236
- cleanup()
237
- reject(error)
238
- }
239
- function onResize() {
240
- draw(true)
241
- }
242
-
243
- // 按键处理:取消、确认、上下移动、空格勾选、文本输入
244
- function onKey(str, key = {}) {
245
- if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
246
- onEnd()
247
- return
248
- }
249
- message = ''
250
- if (key.name === 'return') {
251
- const result = choices
252
- ? multiple
253
- ? choices.filter(choice => checked.has(choice.value)).map(choice => choice.value)
254
- : choices[index].value
255
- : value.trim() || defaultValue
256
- message = validate(result) || ''
257
- if (message) {
258
- draw()
259
- return
260
- }
261
- const answer = choices
262
- ? choices.filter(choice => multiple ? checked.has(choice.value) : choice.value === result).map(choice => choice.label).join('、') || '未选择'
263
- : result
264
- clearFrame()
265
- output.write(`${fitLine(`${green('✓')} ${title}:${answer}`, columns)}\n`)
266
- cleanup()
267
- resolve(result)
268
- return
269
- }
270
- if (choices) {
271
- if (key.name === 'up')
272
- index = (index - 1 + choices.length) % choices.length
273
- if (key.name === 'down')
274
- index = (index + 1) % choices.length
275
- if (multiple && key.name === 'space' && !choices[index].required) {
276
- const selected = choices[index].value
277
- if (checked.has(selected))
278
- checked.delete(selected)
279
- else checked.add(selected)
280
- }
281
- }
282
- else if (key.name === 'backspace') {
283
- value = [...value].slice(0, -1).join('')
284
- }
285
- else if (key.ctrl && key.name === 'u') {
286
- value = ''
287
- }
288
- else if (str && !key.ctrl && !key.meta && [...str].every(char => char.codePointAt(0) >= 32 && char.codePointAt(0) !== 127)) {
289
- value += str
290
- }
291
- draw()
292
- }
293
-
294
- input.on('keypress', onKey)
295
- input.once('end', onEnd)
296
- input.once('error', onError)
297
- output.on('resize', onResize)
298
- output.write('\x1B[?25l')
299
- draw()
300
- })
301
- }
302
-
303
- // ---------------------------------------------------------------------------
304
-
305
- export async function collectOptions(previous = {}, ask = prompt) {
306
- const options = normalizeOptions(previous)
307
- options.location = await ask({ title: '项目创建位置', initial: options.location === '.' ? '' : options.location, defaultValue: '.', hint: '输入 my-vue-app:新建项目文件夹;输入 .:在当前目录创建;支持相对或绝对路径' }) || '.'
308
- options.features = await ask({ title: '选择可选功能', multiple: true, choices: Object.entries(featureLabels).map(([value, label]) => ({ value, label })), initial: options.features, hint: 'Space 勾选,Enter 确认;可全部不选或全部选择' })
309
- options.install = await ask({ title: '是否立即安装依赖?', choices: [{ value: false, label: '暂不安装' }, { value: true, label: '立即安装' }], initial: options.install })
310
- console.log(summary(options))
311
- const action = await ask({ title: '确认配置', choices: [{ value: 'create', label: '开始创建' }, { value: 'edit', label: '返回修改' }, { value: 'cancel', label: '取消' }] })
312
- if (action === 'edit')
313
- return collectOptions(options, ask)
314
- return action === 'create' ? normalizeOptions(options) : null
315
- }
316
- export function summary(options) {
317
- return [`创建位置:${path.resolve(options.location)}`, `可选功能:${options.features.map(f => featureLabels[f]).join('、') || '无'}`, `安装依赖:${options.install ? '立即安装' : '暂不安装'}`].join('\n')
318
- }
1
+ /* eslint-disable no-console */
2
+
3
+ import path from 'node:path'
4
+ /**
5
+ * 命令行交互问答模块
6
+ *
7
+ * 作用:
8
+ * 为脚手架提供终端交互能力,并把问答结果整理成创建计划所需的选项。
9
+ * bin 入口只负责调用,所有输入框、单选、多选的渲染和按键处理都在本文件。
10
+ *
11
+ * 导出:
12
+ * Cancelled 用户主动取消(Ctrl+C / Esc / 输入流结束)时抛出的异常
13
+ * prompt 通用交互组件,支持三种形式:
14
+ * - 文本输入(不传 choices)
15
+ * - 单选(传 choices)
16
+ * - 多选(传 choices 且 multiple: true)
17
+ * collectOptions 配置向导,按顺序提问并处理选项之间的联动
18
+ * summary 把创建计划整理成确认页的文字摘要
19
+ *
20
+ * 实现要点:
21
+ * - 基于 Node 原生 readline 的按键事件,开启 Raw 模式后自行绘制界面,不依赖第三方库。
22
+ * - 当前问题限制在终端可视高度内,长列表随当前选项滚动;完成后收起为一行摘要。
23
+ * - 选中状态用符号和颜色双重表示:即使终端不支持颜色,符号也能区分选中与否。
24
+ * - 颜色在 NO_COLOR 环境变量存在时自动关闭。
25
+ *
26
+ * 终端兼容:
27
+ * 多选符号曾使用 ☑ / ☐,在部分 Windows 终端字体里 ☑ 缺失,显示成空方块,
28
+ * 多选统一使用 ASCII 字符 [+] / [ ],单选保留圆形符号。
29
+ */
30
+ import readline from 'node:readline'
31
+ import { featureLabels, normalizeOptions } from './options.js'
32
+
33
+ /** 用户主动中断(Ctrl+C 或 Esc)时抛出,由调用方统一捕获 */
34
+ export class Cancelled extends Error {}
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // 显示样式
38
+ // ---------------------------------------------------------------------------
39
+
40
+ // 多选框符号(已选 / 未选)
41
+ const MARK_ON = '[+]'
42
+ const MARK_OFF = '[ ]'
43
+ // 单选框符号(当前项 / 其他项)
44
+ const RADIO_ON = '●'
45
+ const RADIO_OFF = '○'
46
+
47
+ const useColor = !process.env.NO_COLOR
48
+ const paint = code => text => (useColor ? `\x1B[${code}m${text}\x1B[0m` : text)
49
+ const green = paint(32)
50
+ const cyan = paint(36)
51
+ const red = paint(31)
52
+ const gray = paint(90)
53
+
54
+ // 去掉颜色转义序列,用于计算真实显示宽度
55
+ // eslint-disable-next-line no-control-regex
56
+ const stripAnsi = text => text.replace(/\x1B\[[0-9;]*m/g, '')
57
+
58
+ // 单个字符的显示宽度:中日韩、全角字符和表情占 2 格,其余占 1 格
59
+ function charWidth(char) {
60
+ const code = char.codePointAt(0)
61
+ if (/\p{Mark}/u.test(char) || code === 0x200D)
62
+ return 0
63
+ const wide
64
+ = (code >= 0x1100 && code <= 0x115F)
65
+ || (code >= 0x2E80 && code <= 0xA4CF)
66
+ || (code >= 0xAC00 && code <= 0xD7A3)
67
+ || (code >= 0xF900 && code <= 0xFAFF)
68
+ || (code >= 0xFE30 && code <= 0xFE6F)
69
+ || (code >= 0xFF00 && code <= 0xFF60)
70
+ || (code >= 0xFFE0 && code <= 0xFFE6)
71
+ || (code >= 0x1F300 && code <= 0x1F64F)
72
+ || (code >= 0x20000 && code <= 0x3FFFD)
73
+ return wide ? 2 : 1
74
+ }
75
+
76
+ // 每行保留一格,避免行尾自动换行使光标回退行数失真;不截断颜色转义序列。
77
+ function fitLine(line, columns) {
78
+ const limit = Math.max(1, columns - 1)
79
+ if ([...stripAnsi(line)].reduce((width, char) => width + charWidth(char), 0) <= limit)
80
+ return line
81
+ let result = ''
82
+ let width = 0
83
+ // eslint-disable-next-line no-control-regex
84
+ for (const token of line.match(/\x1B\[[0-9;]*m|[\s\S]/gu) || []) {
85
+ if (token.startsWith('\x1B')) {
86
+ result += token
87
+ continue
88
+ }
89
+ width += charWidth(token)
90
+ if (width > limit - 1)
91
+ break
92
+ result += token
93
+ }
94
+ return `${result}…\x1B[0m`
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // 通用交互组件
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * 显示一个交互问题并返回用户的回答。
103
+ *
104
+ * 参数:
105
+ * title 问题标题
106
+ * choices 选项数组 [{ value, label, group?, required? }];不传则为文本输入
107
+ * multiple 是否多选(需要 choices)
108
+ * initial 初始值:单选为某个 value,多选为 value 数组,文本为字符串
109
+ * defaultValue 文本输入留空时使用的默认值
110
+ * hint 标题下方的多行提示
111
+ * validate 校验函数,返回错误文字则不允许提交
112
+ *
113
+ * 返回:文本为字符串,单选为选中的 value,多选为选中的 value 数组。
114
+ * 非终端环境会直接抛错,提示改用 --yes 和显式选项。
115
+ */
116
+ export function prompt({ title, choices, multiple = false, initial, defaultValue = '', hint = '', validate = () => undefined }) {
117
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
118
+ throw new Error('交互问答需要终端;自动化调用请使用 --yes 和显式选项,详见 --help')
119
+
120
+ const input = process.stdin
121
+ const output = process.stdout
122
+ readline.emitKeypressEvents(input)
123
+ const wasRaw = input.isRaw
124
+
125
+ // Raw 模式:接管按键事件,阻止终端默认回显
126
+ input.setRawMode(true)
127
+ input.resume()
128
+
129
+ let index = Math.max(0, choices?.findIndex(choice => choice.value === initial) ?? 0)
130
+ let value = typeof initial === 'string' && !choices ? initial : ''
131
+ const checked = new Set(Array.isArray(initial) ? initial : [])
132
+
133
+ // 必选项始终处于选中状态
134
+ for (const choice of choices || []) {
135
+ if (choice.required)
136
+ checked.add(choice.value)
137
+ }
138
+ let rows = 0
139
+ let message = ''
140
+ let offset = 0
141
+ let columns = output.columns || 80
142
+
143
+ const clearFrame = () => {
144
+ if (rows) {
145
+ output.write(`\x1B[${rows}A\r\x1B[0J`)
146
+ rows = 0
147
+ }
148
+ }
149
+
150
+ // 最后一行留给光标,整个活动问题始终处于可见屏幕中,不回退到历史缓冲区。
151
+ const draw = (resized = false) => {
152
+ const height = Math.max(1, (output.rows || 24) - 1)
153
+ const nextColumns = Math.max(2, output.columns || 80)
154
+ if (resized && (nextColumns !== columns || rows > height)) {
155
+ // 改变宽度会使旧输出重新折行,缩小高度也可能丢失旧起点,不能再按旧行数回退。
156
+ output.write('\x1B[H\x1B[2J')
157
+ rows = 0
158
+ }
159
+ else {
160
+ clearFrame()
161
+ }
162
+ columns = nextColumns
163
+
164
+ const entries = []
165
+ let activeRow = 0
166
+ if (choices) {
167
+ for (const [i, choice] of choices.entries()) {
168
+ if (choice.group && choice.group !== choices[i - 1]?.group)
169
+ entries.push(` ${gray(choice.group)}`)
170
+
171
+ const active = index === i
172
+ if (active)
173
+ activeRow = entries.length
174
+ const selected = multiple ? checked.has(choice.value) : active
175
+ const on = multiple ? MARK_ON : RADIO_ON
176
+ const off = multiple ? MARK_OFF : RADIO_OFF
177
+ const mark = selected ? green(on) : gray(off)
178
+ const pointer = active ? cyan('›') : gray('│')
179
+ const label = active ? cyan(choice.label) : choice.label
180
+ const required = choice.required ? gray('(必需)') : ''
181
+ entries.push(`${pointer} ${mark} ${label}${required}`)
182
+ }
183
+ }
184
+ else {
185
+ entries.push(`${gray('│')} ${value || gray(`${defaultValue}(默认)`)}`)
186
+ }
187
+
188
+ const hints = hint ? hint.split('\n').slice(0, Math.max(0, height - 5)).map(line => `${gray('│')} ${gray(line)}`) : []
189
+ const tip = choices ? (multiple ? '↑↓ 移动,Space 勾选,Enter 确认' : '↑↓ 选择,Enter 确认') : 'Enter 确认'
190
+ let lines
191
+ if (height < 3) {
192
+ // 极小窗口优先保留当前选项;两行可用时同时显示校验或操作提示。
193
+ lines = [entries[activeRow]]
194
+ if (height === 2)
195
+ lines.push(`${gray('└')} ${message ? red(message) : gray(tip)}`)
196
+ }
197
+ else {
198
+ const budget = height - hints.length - 2
199
+ const overflow = entries.length > budget
200
+ const indicators = overflow && budget >= 3
201
+ const capacity = indicators ? budget - 2 : budget
202
+ offset = Math.max(0, Math.min(offset, activeRow, entries.length - capacity))
203
+ if (activeRow >= offset + capacity)
204
+ offset = activeRow - capacity + 1
205
+ const visible = entries.slice(offset, offset + capacity)
206
+ if (indicators) {
207
+ visible.unshift(gray(offset ? '│ ↑ 上方还有选项' : '│ 已到列表顶部'))
208
+ visible.push(gray(offset + capacity < entries.length ? '│ ↓ 下方还有选项' : '│ 已到列表底部'))
209
+ }
210
+ const position = overflow && choices ? ` [${index + 1}/${choices.length}]` : ''
211
+ lines = [`${cyan('◆')} ${title}`, ...visible, ...hints, `${gray('└')} ${message ? red(message) : gray(`${position} ${tip}`.trim())}`]
212
+ }
213
+
214
+ rows = lines.length
215
+ output.write(`${lines.map(line => fitLine(line, columns)).join('\n')}\n`)
216
+ }
217
+
218
+ return new Promise((resolve, reject) => {
219
+ // 还原终端状态并移除监听,任何结束路径都必须调用
220
+ function cleanup() {
221
+ input.removeListener('keypress', onKey)
222
+ input.removeListener('end', onEnd)
223
+ input.removeListener('error', onError)
224
+ output.removeListener('resize', onResize)
225
+ input.setRawMode(Boolean(wasRaw))
226
+ input.pause()
227
+ output.write('\x1B[?25h')
228
+ }
229
+ function onEnd() {
230
+ clearFrame()
231
+ cleanup()
232
+ reject(new Cancelled('已取消'))
233
+ }
234
+ function onError(error) {
235
+ cleanup()
236
+ reject(error)
237
+ }
238
+ function onResize() {
239
+ draw(true)
240
+ }
241
+
242
+ // 按键处理:取消、确认、上下移动、空格勾选、文本输入
243
+ function onKey(str, key = {}) {
244
+ if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
245
+ onEnd()
246
+ return
247
+ }
248
+ message = ''
249
+ if (key.name === 'return') {
250
+ const result = choices
251
+ ? multiple
252
+ ? choices.filter(choice => checked.has(choice.value)).map(choice => choice.value)
253
+ : choices[index].value
254
+ : value.trim() || defaultValue
255
+ message = validate(result) || ''
256
+ if (message) {
257
+ draw()
258
+ return
259
+ }
260
+ const answer = choices
261
+ ? choices.filter(choice => multiple ? checked.has(choice.value) : choice.value === result).map(choice => choice.label).join('、') || '未选择'
262
+ : result
263
+ clearFrame()
264
+ output.write(`${fitLine(`${green('✓')} ${title}:${answer}`, columns)}\n`)
265
+ cleanup()
266
+ resolve(result)
267
+ return
268
+ }
269
+ if (choices) {
270
+ if (key.name === 'up')
271
+ index = (index - 1 + choices.length) % choices.length
272
+ if (key.name === 'down')
273
+ index = (index + 1) % choices.length
274
+ if (multiple && key.name === 'space' && !choices[index].required) {
275
+ const selected = choices[index].value
276
+ if (checked.has(selected))
277
+ checked.delete(selected)
278
+ else checked.add(selected)
279
+ }
280
+ }
281
+ else if (key.name === 'backspace') {
282
+ value = [...value].slice(0, -1).join('')
283
+ }
284
+ else if (key.ctrl && key.name === 'u') {
285
+ value = ''
286
+ }
287
+ else if (str && !key.ctrl && !key.meta && [...str].every(char => char.codePointAt(0) >= 32 && char.codePointAt(0) !== 127)) {
288
+ value += str
289
+ }
290
+ draw()
291
+ }
292
+
293
+ input.on('keypress', onKey)
294
+ input.once('end', onEnd)
295
+ input.once('error', onError)
296
+ output.on('resize', onResize)
297
+ output.write('\x1B[?25l')
298
+ draw()
299
+ })
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+
304
+ export async function collectOptions(previous = {}, ask = prompt) {
305
+ const options = normalizeOptions(previous)
306
+ options.location = await ask({ title: '项目创建位置', initial: options.location === '.' ? '' : options.location, defaultValue: '.', hint: '输入 my-vue-app:新建项目文件夹;输入 .:在当前目录创建;支持相对或绝对路径' }) || '.'
307
+ options.features = await ask({ title: '选择可选功能', multiple: true, choices: Object.entries(featureLabels).map(([value, label]) => ({ value, label })), initial: options.features, hint: 'Space 勾选,Enter 确认;可全部不选或全部选择' })
308
+ options.install = await ask({ title: '是否立即安装依赖?', choices: [{ value: false, label: '暂不安装' }, { value: true, label: '立即安装' }], initial: options.install })
309
+ console.log(summary(options))
310
+ const action = await ask({ title: '确认配置', choices: [{ value: 'create', label: '开始创建' }, { value: 'edit', label: '返回修改' }, { value: 'cancel', label: '取消' }] })
311
+ if (action === 'edit')
312
+ return collectOptions(options, ask)
313
+ return action === 'create' ? normalizeOptions(options) : null
314
+ }
315
+ export function summary(options) {
316
+ return [`创建位置:${path.resolve(options.location)}`, `可选功能:${options.features.map(f => featureLabels[f]).join('、') || '无'}`, `安装依赖:${options.install ? '立即安装' : '暂不安装'}`].join('\n')
317
+ }