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.
Files changed (69) hide show
  1. package/README.md +135 -12
  2. package/assets/devcontainer/README.md +49 -17
  3. package/assets/devcontainer/devcontainer.json +24 -7
  4. package/assets/devcontainer/hooks/initialize.sh +32 -0
  5. package/assets/devcontainer/hooks/post-create.sh +59 -12
  6. package/assets/devcontainer/hooks/post-start.sh +21 -4
  7. package/assets/devcontainer/ssh-config-install.sh +12 -3
  8. package/assets/devcontainer/start.sh +721 -44
  9. package/assets/devcontainer/utils/coding-agent-cli-update.sh +267 -3
  10. package/assets/devcontainer/utils/deps-install.sh +68 -0
  11. package/assets/devcontainer/utils/git-config-bootstrap.sh +128 -0
  12. package/assets/devcontainer/utils/git-signing-bootstrap.sh +56 -0
  13. package/assets/devcontainer/utils/python-bootstrap.sh +69 -0
  14. package/assets/devcontainer/utils/ssh-bootstrap.sh +9 -0
  15. package/dist/bin/cli.cjs +1 -1
  16. package/dist/bin/cli.mjs +1 -1
  17. package/dist/main-Df4E8ARj.cjs +5498 -0
  18. package/dist/main-XMBsKjIK.mjs +5458 -0
  19. package/dist/main-XMBsKjIK.mjs.map +1 -0
  20. package/dist/main.cjs +5 -2
  21. package/dist/main.d.cts +495 -4
  22. package/dist/main.d.cts.map +1 -1
  23. package/dist/main.d.mts +495 -4
  24. package/dist/main.d.mts.map +1 -1
  25. package/dist/main.mjs +2 -2
  26. package/docs/README.md +1 -0
  27. package/docs/architecture.md +32 -0
  28. package/docs/development.md +13 -6
  29. package/docs/features/README.md +2 -0
  30. package/docs/features/commit-signing.md +23 -0
  31. package/docs/features/generated-config-and-state.md +57 -4
  32. package/docs/features/github-auth-refresh.md +12 -0
  33. package/docs/features/lifecycle.md +97 -11
  34. package/docs/features/setup.md +66 -0
  35. package/docs/features/ssh-config-and-proxy.md +228 -7
  36. package/docs/features/start-and-shell.md +40 -5
  37. package/docs/superpowers/plans/2026-07-11-default-commit-signing.md +110 -0
  38. package/docs/superpowers/plans/2026-07-11-progress-ci-regression.md +125 -0
  39. package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +396 -0
  40. package/docs/superpowers/specs/2026-07-11-progress-ci-regression-design.md +43 -0
  41. package/docs/testing.md +35 -2
  42. package/docs/todo.md +2 -2
  43. package/package.json +1 -1
  44. package/src/claude-app-config.ts +304 -0
  45. package/src/cli-style.ts +43 -0
  46. package/src/codex-app-config.ts +656 -0
  47. package/src/config.ts +68 -10
  48. package/src/constants.ts +5 -0
  49. package/src/devcontainer.ts +500 -64
  50. package/src/doctor.ts +195 -30
  51. package/src/git-signing.ts +87 -0
  52. package/src/interactive-prompts.ts +692 -0
  53. package/src/list.ts +16 -2
  54. package/src/logging.ts +154 -0
  55. package/src/main.ts +1213 -63
  56. package/src/metadata.ts +52 -3
  57. package/src/package-info.ts +18 -0
  58. package/src/paths.ts +50 -10
  59. package/src/process.ts +80 -2
  60. package/src/progress.ts +593 -0
  61. package/src/purge.ts +208 -0
  62. package/src/shell.ts +19 -0
  63. package/src/ssh-config.ts +134 -16
  64. package/src/ssh-install-targets.ts +111 -0
  65. package/src/ssh-key.ts +53 -13
  66. package/src/status.ts +5 -0
  67. package/dist/main-BuEptwlL.cjs +0 -1707
  68. package/dist/main-ZFTrSVgt.mjs +0 -1685
  69. 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', 'SSH ALIAS', 'CONTAINER']
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
+ }