boxdown 1.0.0 → 1.2.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/README.md +135 -12
- package/assets/devcontainer/README.md +49 -17
- package/assets/devcontainer/devcontainer.json +24 -7
- package/assets/devcontainer/hooks/initialize.sh +32 -0
- package/assets/devcontainer/hooks/post-create.sh +59 -12
- package/assets/devcontainer/hooks/post-start.sh +21 -4
- package/assets/devcontainer/ssh-config-install.sh +12 -3
- package/assets/devcontainer/start.sh +721 -44
- package/assets/devcontainer/utils/coding-agent-cli-update.sh +267 -3
- package/assets/devcontainer/utils/deps-install.sh +68 -0
- package/assets/devcontainer/utils/git-config-bootstrap.sh +128 -0
- package/assets/devcontainer/utils/git-signing-bootstrap.sh +56 -0
- package/assets/devcontainer/utils/python-bootstrap.sh +69 -0
- package/assets/devcontainer/utils/ssh-bootstrap.sh +9 -0
- package/dist/bin/cli.cjs +1 -1
- package/dist/bin/cli.mjs +1 -1
- package/dist/main-Df4E8ARj.cjs +5498 -0
- package/dist/main-XMBsKjIK.mjs +5458 -0
- package/dist/main-XMBsKjIK.mjs.map +1 -0
- package/dist/main.cjs +5 -2
- package/dist/main.d.cts +495 -4
- package/dist/main.d.cts.map +1 -1
- package/dist/main.d.mts +495 -4
- package/dist/main.d.mts.map +1 -1
- package/dist/main.mjs +2 -2
- package/docs/README.md +1 -0
- package/docs/architecture.md +32 -0
- package/docs/development.md +13 -6
- package/docs/features/README.md +2 -0
- package/docs/features/commit-signing.md +23 -0
- package/docs/features/generated-config-and-state.md +57 -4
- package/docs/features/github-auth-refresh.md +12 -0
- package/docs/features/lifecycle.md +97 -11
- package/docs/features/setup.md +66 -0
- package/docs/features/ssh-config-and-proxy.md +228 -7
- package/docs/features/start-and-shell.md +40 -5
- package/docs/superpowers/plans/2026-07-11-default-commit-signing.md +110 -0
- package/docs/superpowers/plans/2026-07-11-progress-ci-regression.md +125 -0
- package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +396 -0
- package/docs/superpowers/specs/2026-07-11-progress-ci-regression-design.md +43 -0
- package/docs/testing.md +35 -2
- package/docs/todo.md +2 -2
- package/package.json +1 -1
- package/src/claude-app-config.ts +304 -0
- package/src/cli-style.ts +43 -0
- package/src/codex-app-config.ts +656 -0
- package/src/config.ts +68 -10
- package/src/constants.ts +5 -0
- package/src/devcontainer.ts +500 -64
- package/src/doctor.ts +195 -30
- package/src/git-signing.ts +87 -0
- package/src/interactive-prompts.ts +692 -0
- package/src/list.ts +16 -2
- package/src/logging.ts +154 -0
- package/src/main.ts +1213 -63
- package/src/metadata.ts +52 -3
- package/src/package-info.ts +18 -0
- package/src/paths.ts +50 -10
- package/src/process.ts +80 -2
- package/src/progress.ts +593 -0
- package/src/purge.ts +208 -0
- package/src/shell.ts +19 -0
- package/src/ssh-config.ts +134 -16
- package/src/ssh-install-targets.ts +111 -0
- package/src/ssh-key.ts +53 -13
- package/src/status.ts +5 -0
- package/dist/main-BuEptwlL.cjs +0 -1707
- package/dist/main-ZFTrSVgt.mjs +0 -1685
- package/dist/main-ZFTrSVgt.mjs.map +0 -1
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline'
|
|
2
|
+
|
|
3
|
+
import { color, emptyMark, formatPromptDetailLine, formatPromptEnd, formatPromptLabel, formatPromptTitle, promptRail, selectedMark, type CliColor } from './cli-style.ts'
|
|
4
|
+
|
|
5
|
+
export interface MultiSelectDescriptionSegment {
|
|
6
|
+
text: string
|
|
7
|
+
color: CliColor
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface MultiSelectChoice<T extends string> {
|
|
11
|
+
value: T
|
|
12
|
+
label: string
|
|
13
|
+
description: string
|
|
14
|
+
focusedDescription?: readonly MultiSelectDescriptionSegment[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type MultiSelectPromptResult<T extends string> =
|
|
18
|
+
| { status: 'selected', values: T[] }
|
|
19
|
+
| { status: 'skipped', values: [] }
|
|
20
|
+
| { status: 'cancelled', values: [] }
|
|
21
|
+
| { status: 'non-interactive', values: [] }
|
|
22
|
+
|
|
23
|
+
export type PromptInput = NodeJS.ReadableStream & {
|
|
24
|
+
isTTY?: boolean
|
|
25
|
+
setRawMode?: (mode: boolean) => void
|
|
26
|
+
resume: () => PromptInput
|
|
27
|
+
pause: () => PromptInput
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type PromptOutput = NodeJS.WritableStream & {
|
|
31
|
+
isTTY?: boolean
|
|
32
|
+
columns?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MultiSelectPromptOptions<T extends string> {
|
|
36
|
+
title: string
|
|
37
|
+
choices: readonly MultiSelectChoice<T>[]
|
|
38
|
+
skipLabel: string
|
|
39
|
+
summaryLabel?: string
|
|
40
|
+
input?: PromptInput
|
|
41
|
+
output?: PromptOutput
|
|
42
|
+
env?: NodeJS.ProcessEnv
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type TextPromptResult =
|
|
46
|
+
| { status: 'submitted', value: string }
|
|
47
|
+
| { status: 'cancelled', value?: undefined }
|
|
48
|
+
| { status: 'non-interactive', value?: undefined }
|
|
49
|
+
|
|
50
|
+
export interface TextPromptOptions {
|
|
51
|
+
title: string
|
|
52
|
+
details?: readonly string[]
|
|
53
|
+
defaultValue?: string
|
|
54
|
+
summaryLabel: string
|
|
55
|
+
validate?: (value: string) => string | undefined
|
|
56
|
+
input?: PromptInput
|
|
57
|
+
output?: PromptOutput
|
|
58
|
+
env?: NodeJS.ProcessEnv
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type ConfirmPromptResult =
|
|
62
|
+
| { status: 'confirmed' }
|
|
63
|
+
| { status: 'denied' }
|
|
64
|
+
| { status: 'cancelled' }
|
|
65
|
+
| { status: 'non-interactive' }
|
|
66
|
+
|
|
67
|
+
export interface ConfirmPromptOptions {
|
|
68
|
+
title: string
|
|
69
|
+
details?: readonly string[]
|
|
70
|
+
confirmLabel: string
|
|
71
|
+
cancelLabel: string
|
|
72
|
+
summaryLabel: string
|
|
73
|
+
input?: PromptInput
|
|
74
|
+
output?: PromptOutput
|
|
75
|
+
env?: NodeJS.ProcessEnv
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isCiEnvironment (env: NodeJS.ProcessEnv): boolean {
|
|
79
|
+
const ci = env.CI
|
|
80
|
+
return ci !== undefined && ci !== '' && ci !== '0' && ci !== 'false'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function canPromptInteractively (input: PromptInput, output: PromptOutput, env: NodeJS.ProcessEnv): boolean {
|
|
84
|
+
return !isCiEnvironment(env) && input.isTTY === true && output.isTTY === true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/gu
|
|
88
|
+
|
|
89
|
+
function visibleLength (value: string): number {
|
|
90
|
+
return value.replace(ansiPattern, '').length
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function terminalColumns (output: PromptOutput): number {
|
|
94
|
+
return Number.isInteger(output.columns) && output.columns !== undefined && output.columns > 0
|
|
95
|
+
? output.columns
|
|
96
|
+
: 80
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderedRowCount (lines: readonly string[], output: PromptOutput): number {
|
|
100
|
+
const columns = terminalColumns(output)
|
|
101
|
+
return lines.reduce((rows, line) => rows + Math.max(1, Math.ceil(visibleLength(line) / columns)), 0)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderPromptLines (
|
|
105
|
+
output: PromptOutput,
|
|
106
|
+
lines: readonly string[],
|
|
107
|
+
previousRows: number
|
|
108
|
+
): number {
|
|
109
|
+
if (previousRows > 0) {
|
|
110
|
+
output.write(`\u001B[${previousRows}A\r\u001B[J`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
output.write(`\u001B[2K\r${line}\n`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return renderedRowCount(lines, output)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatChoiceLine <T extends string> (
|
|
121
|
+
choice: MultiSelectChoice<T>,
|
|
122
|
+
isFocused: boolean,
|
|
123
|
+
isSelected: boolean
|
|
124
|
+
): string {
|
|
125
|
+
const mark = isSelected ? selectedMark() : emptyMark(isFocused)
|
|
126
|
+
const description = isFocused && choice.focusedDescription !== undefined
|
|
127
|
+
? `${color(' - ', 'dim')}${choice.focusedDescription.map((segment) => color(segment.text, segment.color)).join('')}`
|
|
128
|
+
: color(` - ${choice.description}`, 'dim')
|
|
129
|
+
return `${promptRail()} ${mark} ${formatPromptLabel(choice.label, isFocused)}${description}`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function formatSkipLine (skipLabel: string, isFocused: boolean, selectedCount: number): string {
|
|
133
|
+
const mark = selectedCount === 0 ? selectedMark() : emptyMark(isFocused)
|
|
134
|
+
return `${promptRail()} ${mark} ${formatPromptLabel(skipLabel, isFocused)}`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function formatConfirmLine (
|
|
138
|
+
label: string,
|
|
139
|
+
isFocused: boolean
|
|
140
|
+
): string {
|
|
141
|
+
const mark = isFocused ? selectedMark() : emptyMark(false)
|
|
142
|
+
return `${promptRail()} ${mark} ${formatPromptLabel(label, isFocused)}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatMultiSelectFinalLine <T extends string> (
|
|
146
|
+
result: MultiSelectPromptResult<T>,
|
|
147
|
+
choices: readonly MultiSelectChoice<T>[],
|
|
148
|
+
summaryLabel: string
|
|
149
|
+
): string {
|
|
150
|
+
if (result.status === 'cancelled') {
|
|
151
|
+
return `${summaryLabel}: canceled`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (result.status !== 'selected') {
|
|
155
|
+
return `${summaryLabel}: skipped`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const selectedLabels = choices
|
|
159
|
+
.filter((choice) => result.values.includes(choice.value))
|
|
160
|
+
.map((choice) => choice.label)
|
|
161
|
+
.join(', ')
|
|
162
|
+
|
|
163
|
+
return `${summaryLabel}: ${selectedLabels}`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function formatTextFinalLine (result: TextPromptResult, summaryLabel: string): string {
|
|
167
|
+
if (result.status === 'cancelled') {
|
|
168
|
+
return `${summaryLabel}: canceled`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (result.status === 'non-interactive') {
|
|
172
|
+
return `${summaryLabel}: skipped`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return `${summaryLabel}: ${result.value}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function formatConfirmFinalLine (result: ConfirmPromptResult, summaryLabel: string): string {
|
|
179
|
+
if (result.status === 'confirmed') {
|
|
180
|
+
return `${summaryLabel}: confirmed`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (result.status === 'cancelled') {
|
|
184
|
+
return `${summaryLabel}: canceled`
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return `${summaryLabel}: canceled`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function resultFromValues <T extends string> (values: T[]): MultiSelectPromptResult<T> {
|
|
191
|
+
return values.length === 0
|
|
192
|
+
? { status: 'skipped', values: [] }
|
|
193
|
+
: { status: 'selected', values }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseLineSelection <T extends string> (
|
|
197
|
+
answer: string,
|
|
198
|
+
choices: readonly MultiSelectChoice<T>[]
|
|
199
|
+
): { values: T[] } | { error: string } {
|
|
200
|
+
const trimmed = answer.trim()
|
|
201
|
+
|
|
202
|
+
if (trimmed === '' || trimmed === '0' || /^skip$/iu.test(trimmed)) {
|
|
203
|
+
return { values: [] }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const selected = new Set<T>()
|
|
207
|
+
const tokens = trimmed.split(/[,\s]+/u).filter((token) => token.length > 0)
|
|
208
|
+
|
|
209
|
+
for (const token of tokens) {
|
|
210
|
+
const byNumber = /^[0-9]+$/u.test(token) ? Number(token) : undefined
|
|
211
|
+
const choice = byNumber === undefined
|
|
212
|
+
? choices.find((candidate) => candidate.value === token)
|
|
213
|
+
: choices[byNumber - 1]
|
|
214
|
+
|
|
215
|
+
if (choice === undefined) {
|
|
216
|
+
return { error: `Unknown selection: ${token}` }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
selected.add(choice.value)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { values: [...selected] }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function askLine (input: PromptInput, output: PromptOutput, question: string): Promise<string | undefined> {
|
|
226
|
+
const rl = createInterface({
|
|
227
|
+
input,
|
|
228
|
+
output,
|
|
229
|
+
terminal: false
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
let settled = false
|
|
234
|
+
|
|
235
|
+
function settle (answer: string | undefined): void {
|
|
236
|
+
if (settled) {
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
settled = true
|
|
241
|
+
rl.close()
|
|
242
|
+
resolve(answer)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
rl.once('close', () => {
|
|
246
|
+
settle(undefined)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
rl.question(question, (answer) => {
|
|
250
|
+
settle(answer)
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function promptLineMultiSelect <T extends string> (
|
|
256
|
+
options: Required<Pick<MultiSelectPromptOptions<T>, 'title' | 'choices' | 'skipLabel' | 'summaryLabel' | 'input' | 'output'>>
|
|
257
|
+
): Promise<MultiSelectPromptResult<T>> {
|
|
258
|
+
options.output.write(`${formatPromptTitle(options.title)}\n`)
|
|
259
|
+
|
|
260
|
+
options.choices.forEach((choice, index) => {
|
|
261
|
+
options.output.write(`${promptRail()} ${index + 1}) ${choice.label} - ${choice.description}\n`)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
options.output.write(`${promptRail()} 0) ${options.skipLabel}\n`)
|
|
265
|
+
|
|
266
|
+
while (true) {
|
|
267
|
+
const answer = await askLine(options.input, options.output, `${promptRail()} `)
|
|
268
|
+
|
|
269
|
+
if (answer === undefined) {
|
|
270
|
+
return { status: 'cancelled', values: [] }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const parsed = parseLineSelection(answer, options.choices)
|
|
274
|
+
|
|
275
|
+
if ('values' in parsed) {
|
|
276
|
+
const result = resultFromValues(parsed.values)
|
|
277
|
+
options.output.write(`${formatPromptEnd()}\n${formatMultiSelectFinalLine(result, options.choices, options.summaryLabel)}\n`)
|
|
278
|
+
return result
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
options.output.write(`${promptRail()} ${parsed.error}\n`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function promptRawMultiSelect <T extends string> (
|
|
286
|
+
options: Required<Pick<MultiSelectPromptOptions<T>, 'title' | 'choices' | 'skipLabel' | 'summaryLabel' | 'input' | 'output'>>
|
|
287
|
+
): Promise<MultiSelectPromptResult<T>> {
|
|
288
|
+
return new Promise((resolve) => {
|
|
289
|
+
const selected = new Set<T>()
|
|
290
|
+
let focusedIndex = options.choices.length
|
|
291
|
+
let settled = false
|
|
292
|
+
let renderedRows = 0
|
|
293
|
+
|
|
294
|
+
function lines (): string[] {
|
|
295
|
+
return [
|
|
296
|
+
formatPromptTitle(options.title),
|
|
297
|
+
promptRail(),
|
|
298
|
+
...options.choices.map((choice, index) => formatChoiceLine(
|
|
299
|
+
choice,
|
|
300
|
+
focusedIndex === index,
|
|
301
|
+
selected.has(choice.value)
|
|
302
|
+
)),
|
|
303
|
+
formatSkipLine(options.skipLabel, focusedIndex === options.choices.length, selected.size),
|
|
304
|
+
formatPromptEnd()
|
|
305
|
+
]
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function render (): void {
|
|
309
|
+
const nextLines = lines()
|
|
310
|
+
renderedRows = renderPromptLines(options.output, nextLines, renderedRows)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function cleanup (): void {
|
|
314
|
+
options.input.removeListener('data', onData)
|
|
315
|
+
options.input.setRawMode?.(false)
|
|
316
|
+
options.input.pause()
|
|
317
|
+
options.output.write('\u001B[?25h')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function finish (result: MultiSelectPromptResult<T>): void {
|
|
321
|
+
if (settled) {
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
settled = true
|
|
326
|
+
cleanup()
|
|
327
|
+
options.output.write(`${formatMultiSelectFinalLine(result, options.choices, options.summaryLabel)}\n`)
|
|
328
|
+
resolve(result)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function submit (): void {
|
|
332
|
+
if (focusedIndex === options.choices.length) {
|
|
333
|
+
finish({ status: 'skipped', values: [] })
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
finish(resultFromValues([...selected]))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function toggleFocused (): void {
|
|
341
|
+
const focusedChoice = options.choices[focusedIndex]
|
|
342
|
+
|
|
343
|
+
if (focusedChoice === undefined) {
|
|
344
|
+
selected.clear()
|
|
345
|
+
render()
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (selected.has(focusedChoice.value)) {
|
|
350
|
+
selected.delete(focusedChoice.value)
|
|
351
|
+
} else {
|
|
352
|
+
selected.add(focusedChoice.value)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
render()
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function moveFocus (direction: 1 | -1): void {
|
|
359
|
+
const rowCount = options.choices.length + 1
|
|
360
|
+
focusedIndex = (focusedIndex + direction + rowCount) % rowCount
|
|
361
|
+
render()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function handleKey (key: string): void {
|
|
365
|
+
if (key === '\u0003' || key === '\u0004' || key === '\u001B') {
|
|
366
|
+
finish({ status: 'cancelled', values: [] })
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (key === '\r' || key === '\n') {
|
|
371
|
+
submit()
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (key === ' ') {
|
|
376
|
+
toggleFocused()
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (key === 'k') {
|
|
381
|
+
moveFocus(-1)
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (key === 'j') {
|
|
386
|
+
moveFocus(1)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function handleText (text: string): void {
|
|
391
|
+
for (let index = 0; index < text.length;) {
|
|
392
|
+
if (text.startsWith('\u001B[A', index)) {
|
|
393
|
+
moveFocus(-1)
|
|
394
|
+
index += 3
|
|
395
|
+
continue
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (text.startsWith('\u001B[B', index)) {
|
|
399
|
+
moveFocus(1)
|
|
400
|
+
index += 3
|
|
401
|
+
continue
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
handleKey(text[index] ?? '')
|
|
405
|
+
index += 1
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function onData (chunk: string | Buffer): void {
|
|
410
|
+
handleText(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
options.output.write('\u001B[?25l')
|
|
414
|
+
options.input.setRawMode?.(true)
|
|
415
|
+
options.input.resume()
|
|
416
|
+
options.input.on('data', onData)
|
|
417
|
+
render()
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export async function promptMultiSelect <T extends string> (
|
|
422
|
+
options: MultiSelectPromptOptions<T>
|
|
423
|
+
): Promise<MultiSelectPromptResult<T>> {
|
|
424
|
+
const input = options.input ?? process.stdin
|
|
425
|
+
const output = options.output ?? process.stdout
|
|
426
|
+
const env = options.env ?? process.env
|
|
427
|
+
const summaryLabel = options.summaryLabel ?? 'Selection'
|
|
428
|
+
|
|
429
|
+
if (!canPromptInteractively(input, output, env)) {
|
|
430
|
+
return { status: 'non-interactive', values: [] }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (typeof input.setRawMode !== 'function') {
|
|
434
|
+
return promptLineMultiSelect({
|
|
435
|
+
title: options.title,
|
|
436
|
+
choices: options.choices,
|
|
437
|
+
skipLabel: options.skipLabel,
|
|
438
|
+
summaryLabel,
|
|
439
|
+
input,
|
|
440
|
+
output
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
return await promptRawMultiSelect({
|
|
446
|
+
title: options.title,
|
|
447
|
+
choices: options.choices,
|
|
448
|
+
skipLabel: options.skipLabel,
|
|
449
|
+
summaryLabel,
|
|
450
|
+
input,
|
|
451
|
+
output
|
|
452
|
+
})
|
|
453
|
+
} catch {
|
|
454
|
+
return promptLineMultiSelect({
|
|
455
|
+
title: options.title,
|
|
456
|
+
choices: options.choices,
|
|
457
|
+
skipLabel: options.skipLabel,
|
|
458
|
+
summaryLabel,
|
|
459
|
+
input,
|
|
460
|
+
output
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export async function promptText (options: TextPromptOptions): Promise<TextPromptResult> {
|
|
466
|
+
const input = options.input ?? process.stdin
|
|
467
|
+
const output = options.output ?? process.stdout
|
|
468
|
+
const env = options.env ?? process.env
|
|
469
|
+
|
|
470
|
+
if (!canPromptInteractively(input, output, env)) {
|
|
471
|
+
return { status: 'non-interactive' }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
output.write(`${formatPromptTitle(options.title)}\n`)
|
|
475
|
+
|
|
476
|
+
for (const detail of options.details ?? []) {
|
|
477
|
+
output.write(`${formatPromptDetailLine(detail)}\n`)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
while (true) {
|
|
481
|
+
const defaultText = options.defaultValue === undefined ? '' : color(` (${options.defaultValue})`, 'dim')
|
|
482
|
+
const answer = await askLine(input, output, `${promptRail()} ${defaultText} `)
|
|
483
|
+
|
|
484
|
+
if (answer === undefined || answer === '\u0003' || answer === '\u0004' || answer === '\u001B') {
|
|
485
|
+
const result: TextPromptResult = { status: 'cancelled' }
|
|
486
|
+
output.write(`${formatPromptEnd()}\n${formatTextFinalLine(result, options.summaryLabel)}\n`)
|
|
487
|
+
return result
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const value = answer.trim() === '' && options.defaultValue !== undefined
|
|
491
|
+
? options.defaultValue
|
|
492
|
+
: answer.trim()
|
|
493
|
+
const error = options.validate?.(value)
|
|
494
|
+
|
|
495
|
+
if (error === undefined) {
|
|
496
|
+
const result: TextPromptResult = { status: 'submitted', value }
|
|
497
|
+
output.write(`${formatPromptEnd()}\n${formatTextFinalLine(result, options.summaryLabel)}\n`)
|
|
498
|
+
return result
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
output.write(`${promptRail()} ${error}\n`)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function promptLineConfirm (
|
|
506
|
+
options: Required<Pick<ConfirmPromptOptions, 'title' | 'details' | 'confirmLabel' | 'cancelLabel' | 'summaryLabel' | 'input' | 'output'>>
|
|
507
|
+
): Promise<ConfirmPromptResult> {
|
|
508
|
+
options.output.write(`${formatPromptTitle(options.title)}\n`)
|
|
509
|
+
|
|
510
|
+
for (const detail of options.details) {
|
|
511
|
+
options.output.write(`${formatPromptDetailLine(detail)}\n`)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return new Promise((resolve) => {
|
|
515
|
+
async function ask (): Promise<void> {
|
|
516
|
+
const answer = await askLine(options.input, options.output, `${promptRail()} ${options.confirmLabel}? [y/N] `)
|
|
517
|
+
|
|
518
|
+
if (answer === undefined || answer === '\u0003' || answer === '\u0004' || answer === '\u001B') {
|
|
519
|
+
const result: ConfirmPromptResult = { status: 'cancelled' }
|
|
520
|
+
options.output.write(`${formatPromptEnd()}\n${formatConfirmFinalLine(result, options.summaryLabel)}\n`)
|
|
521
|
+
resolve(result)
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (answer.trim() === '' || /^n(?:o)?$/iu.test(answer.trim())) {
|
|
526
|
+
const result: ConfirmPromptResult = { status: 'denied' }
|
|
527
|
+
options.output.write(`${formatPromptEnd()}\n${formatConfirmFinalLine(result, options.summaryLabel)}\n`)
|
|
528
|
+
resolve(result)
|
|
529
|
+
return
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (/^y(?:es)?$/iu.test(answer.trim())) {
|
|
533
|
+
const result: ConfirmPromptResult = { status: 'confirmed' }
|
|
534
|
+
options.output.write(`${formatPromptEnd()}\n${formatConfirmFinalLine(result, options.summaryLabel)}\n`)
|
|
535
|
+
resolve(result)
|
|
536
|
+
return
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
options.output.write(`${promptRail()} Enter y or n.\n`)
|
|
540
|
+
await ask()
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
void ask()
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function promptRawConfirm (
|
|
548
|
+
options: Required<Pick<ConfirmPromptOptions, 'title' | 'details' | 'confirmLabel' | 'cancelLabel' | 'summaryLabel' | 'input' | 'output'>>
|
|
549
|
+
): Promise<ConfirmPromptResult> {
|
|
550
|
+
return new Promise((resolve) => {
|
|
551
|
+
let focusedIndex = 0
|
|
552
|
+
let settled = false
|
|
553
|
+
let renderedRows = 0
|
|
554
|
+
|
|
555
|
+
function lines (): string[] {
|
|
556
|
+
return [
|
|
557
|
+
formatPromptTitle(options.title),
|
|
558
|
+
promptRail(),
|
|
559
|
+
...options.details.map((detail) => formatPromptDetailLine(detail)),
|
|
560
|
+
formatConfirmLine(options.cancelLabel, focusedIndex === 0),
|
|
561
|
+
formatConfirmLine(options.confirmLabel, focusedIndex === 1),
|
|
562
|
+
formatPromptEnd()
|
|
563
|
+
]
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function render (): void {
|
|
567
|
+
const nextLines = lines()
|
|
568
|
+
renderedRows = renderPromptLines(options.output, nextLines, renderedRows)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function cleanup (): void {
|
|
572
|
+
options.input.removeListener('data', onData)
|
|
573
|
+
options.input.setRawMode?.(false)
|
|
574
|
+
options.input.pause()
|
|
575
|
+
options.output.write('\u001B[?25h')
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function finish (result: ConfirmPromptResult): void {
|
|
579
|
+
if (settled) {
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
settled = true
|
|
584
|
+
cleanup()
|
|
585
|
+
options.output.write(`${formatConfirmFinalLine(result, options.summaryLabel)}\n`)
|
|
586
|
+
resolve(result)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function moveFocus (): void {
|
|
590
|
+
focusedIndex = focusedIndex === 0 ? 1 : 0
|
|
591
|
+
render()
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function submit (): void {
|
|
595
|
+
finish(focusedIndex === 1 ? { status: 'confirmed' } : { status: 'denied' })
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function handleKey (key: string): void {
|
|
599
|
+
if (key === '\u0003' || key === '\u0004' || key === '\u001B') {
|
|
600
|
+
finish({ status: 'cancelled' })
|
|
601
|
+
return
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (key === '\r' || key === '\n') {
|
|
605
|
+
submit()
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (key === ' ' || key === 'j' || key === 'k' || key === 'h' || key === 'l') {
|
|
610
|
+
moveFocus()
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (key === 'y' || key === 'Y') {
|
|
615
|
+
finish({ status: 'confirmed' })
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (key === 'n' || key === 'N') {
|
|
620
|
+
finish({ status: 'denied' })
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function handleText (text: string): void {
|
|
625
|
+
for (let index = 0; index < text.length;) {
|
|
626
|
+
if (text.startsWith('\u001B[A', index) || text.startsWith('\u001B[B', index) || text.startsWith('\u001B[C', index) || text.startsWith('\u001B[D', index)) {
|
|
627
|
+
moveFocus()
|
|
628
|
+
index += 3
|
|
629
|
+
continue
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
handleKey(text[index] ?? '')
|
|
633
|
+
index += 1
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function onData (chunk: string | Buffer): void {
|
|
638
|
+
handleText(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
options.output.write('\u001B[?25l')
|
|
642
|
+
options.input.setRawMode?.(true)
|
|
643
|
+
options.input.resume()
|
|
644
|
+
options.input.on('data', onData)
|
|
645
|
+
render()
|
|
646
|
+
})
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export async function promptConfirm (options: ConfirmPromptOptions): Promise<ConfirmPromptResult> {
|
|
650
|
+
const input = options.input ?? process.stdin
|
|
651
|
+
const output = options.output ?? process.stdout
|
|
652
|
+
const env = options.env ?? process.env
|
|
653
|
+
const details = options.details ?? []
|
|
654
|
+
|
|
655
|
+
if (!canPromptInteractively(input, output, env)) {
|
|
656
|
+
return { status: 'non-interactive' }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (typeof input.setRawMode !== 'function') {
|
|
660
|
+
return promptLineConfirm({
|
|
661
|
+
title: options.title,
|
|
662
|
+
details,
|
|
663
|
+
confirmLabel: options.confirmLabel,
|
|
664
|
+
cancelLabel: options.cancelLabel,
|
|
665
|
+
summaryLabel: options.summaryLabel,
|
|
666
|
+
input,
|
|
667
|
+
output
|
|
668
|
+
})
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
return await promptRawConfirm({
|
|
673
|
+
title: options.title,
|
|
674
|
+
details,
|
|
675
|
+
confirmLabel: options.confirmLabel,
|
|
676
|
+
cancelLabel: options.cancelLabel,
|
|
677
|
+
summaryLabel: options.summaryLabel,
|
|
678
|
+
input,
|
|
679
|
+
output
|
|
680
|
+
})
|
|
681
|
+
} catch {
|
|
682
|
+
return promptLineConfirm({
|
|
683
|
+
title: options.title,
|
|
684
|
+
details,
|
|
685
|
+
confirmLabel: options.confirmLabel,
|
|
686
|
+
cancelLabel: options.cancelLabel,
|
|
687
|
+
summaryLabel: options.summaryLabel,
|
|
688
|
+
input,
|
|
689
|
+
output
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
}
|
package/src/list.ts
CHANGED
|
@@ -86,10 +86,9 @@ export function formatWorkspaceListText (entries: WorkspaceListEntry[]): string
|
|
|
86
86
|
entry.state,
|
|
87
87
|
entry.workspaceBasename,
|
|
88
88
|
entry.workspaceFolder,
|
|
89
|
-
entry.sshAlias,
|
|
90
89
|
containerLabel(entry)
|
|
91
90
|
])
|
|
92
|
-
const headers = ['STATE', 'REPO', 'PATH', '
|
|
91
|
+
const headers = ['STATE', 'REPO', 'PATH', 'CONTAINER']
|
|
93
92
|
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)))
|
|
94
93
|
const lines = [
|
|
95
94
|
'Boxdown list',
|
|
@@ -100,3 +99,18 @@ export function formatWorkspaceListText (entries: WorkspaceListEntry[]): string
|
|
|
100
99
|
|
|
101
100
|
return `${lines.join('\n')}\n`
|
|
102
101
|
}
|
|
102
|
+
|
|
103
|
+
export function formatWorkspaceListDetailsText (entries: WorkspaceListEntry[]): string {
|
|
104
|
+
if (entries.length === 0) {
|
|
105
|
+
return 'Boxdown list\n\nNo Boxdown workspaces found.\n'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const details = entries.map((entry) => [
|
|
109
|
+
`${entry.state} ${entry.workspaceBasename}`,
|
|
110
|
+
` ${pad('path', 9)}: ${entry.workspaceFolder}`,
|
|
111
|
+
` ${pad('ssh alias', 9)}: ${entry.sshAlias}`,
|
|
112
|
+
` ${pad('container', 9)}: ${containerLabel(entry)}`
|
|
113
|
+
].join('\n')).join('\n\n')
|
|
114
|
+
|
|
115
|
+
return `Boxdown list\n\n${details}\n`
|
|
116
|
+
}
|