-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprompts.ts
More file actions
385 lines (356 loc) · 11.8 KB
/
prompts.ts
File metadata and controls
385 lines (356 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
/**
* @fileoverview User prompt utilities for interactive scripts.
* Provides inquirer.js integration with spinner support, context handling, and theming.
*/
import { getAbortSignal } from '../constants/process'
// @ts-expect-error - external vendored module
import checkboxRaw from '../external/@inquirer/checkbox'
// @ts-expect-error - external vendored module
import confirmRaw from '../external/@inquirer/confirm'
// @ts-expect-error - external vendored module
import inputRaw from '../external/@inquirer/input'
// @ts-expect-error - external vendored module
import passwordRaw from '../external/@inquirer/password'
// @ts-expect-error - external vendored module
import * as searchModule from '../external/@inquirer/search'
// @ts-expect-error - external vendored module
import * as selectModuleImport from '../external/@inquirer/select'
import yoctocolorsCjs from '../external/yoctocolors-cjs'
import type { ColorValue } from '../colors'
import { getDefaultSpinner } from '../spinner'
import { getTheme } from '../themes/context'
import { THEMES, type ThemeName } from '../themes/themes'
import type { Theme } from '../themes/types'
import { resolveColor } from '../themes/utils'
const abortSignal = getAbortSignal()
const spinner = getDefaultSpinner()
// Modules imported at the top - extract default and Separator.
// The @inquirer/select shim exposes the namespaced CJS module, so we
// narrow instead of `as any` to stay within CLAUDE.md's no-any rule.
const searchRaw = searchModule.default
const selectModule = selectModuleImport as unknown as {
default: typeof selectModuleImport.default
Separator: typeof selectModuleImport.Separator
}
const selectRaw = selectModule.default
const ActualSeparator = selectModule.Separator
// Type definitions
/**
* Choice option for select and search prompts.
*
* @template Value - Type of the choice value
*/
export interface Choice<Value = unknown> {
/** The value returned when this choice is selected */
value: Value
/** Display name for the choice (defaults to value.toString()) */
name?: string | undefined
/** Additional description text shown below the choice */
description?: string | undefined
/** Short text shown after selection (defaults to name) */
short?: string | undefined
/** Whether this choice is disabled, or a reason string */
disabled?: boolean | string | undefined
}
/**
* Context for inquirer prompts.
* Minimal context interface used by Inquirer prompts.
* Duplicated from `@inquirer/type` - InquirerContext.
*/
interface InquirerContext {
/** Abort signal for cancelling the prompt */
signal?: AbortSignal | undefined
/** Input stream (defaults to process.stdin) */
input?: NodeJS.ReadableStream | undefined
/** Output stream (defaults to process.stdout) */
output?: NodeJS.WritableStream | undefined
/** Clear the prompt from terminal when done */
clearPromptOnDone?: boolean | undefined
}
/**
* Extended context with spinner support.
* Allows passing a spinner instance to be managed during prompts.
*/
export type Context = import('../objects').Remap<
InquirerContext & {
/** Optional spinner to stop/start during prompt display */
spinner?: import('../spinner').Spinner | undefined
}
>
/**
* Separator for visual grouping in select/checkbox prompts.
* Creates a non-selectable visual separator line.
* Duplicated from `@inquirer/select` - Separator.
* This type definition ensures the Separator type is available in published packages.
*
* @example
* import { Separator } from './prompts'
*
* const choices = [
* { name: 'Option 1', value: 1 },
* new Separator(),
* { name: 'Option 2', value: 2 }
* ]
*/
declare class SeparatorType {
readonly separator: string
readonly type: 'separator'
constructor(separator?: string)
}
export type Separator = SeparatorType
/**
* Apply a color to text using yoctocolors.
* Handles both named colors and RGB tuples.
* @private
*/
function applyColor(text: string, color: ColorValue): string {
if (typeof color === 'string') {
// Named color like 'green', 'red', etc.
return (yoctocolorsCjs as any)[color](text)
}
// RGB tuple [r, g, b] - manually construct ANSI escape codes.
// yoctocolors-cjs doesn't have an rgb() method, so we build it ourselves.
const { 0: r, 1: g, 2: b } = color
return `\u001B[38;2;${r};${g};${b}m${text}\u001B[39m`
}
/**
* Check if value is a Socket Theme object.
* @param value - Value to check
* @returns True if value is a Socket Theme
*/
function isSocketTheme(value: unknown): value is Theme {
return (
typeof value === 'object' &&
value !== null &&
'name' in value &&
'colors' in value
)
}
/**
* Resolve theme name or object to Theme.
* @param theme - Theme name or object
* @returns Resolved Theme
*/
function resolveTheme(theme: Theme | ThemeName): Theme {
return typeof theme === 'string' ? (THEMES[theme] ?? THEMES.socket) : theme
}
/**
* Convert Socket theme to @inquirer theme format.
* Maps our theme colors to inquirer's style functions.
* Handles theme names, Theme objects, and passes through @inquirer themes.
*
* @param theme - Socket theme name, Theme object, or @inquirer theme
* @returns @inquirer theme object
*
* @example
* ```ts
* // Socket theme name
* createInquirerTheme('sunset')
*
* // Socket Theme object
* createInquirerTheme(SUNSET_THEME)
*
* // @inquirer theme (passes through)
* createInquirerTheme({ style: {...}, icon: {...} })
* ```
*/
export function createInquirerTheme(
theme: Theme | ThemeName | unknown,
): Record<string, unknown> {
// If it's a string (theme name) or Socket Theme object, convert it
if (typeof theme === 'string' || isSocketTheme(theme)) {
const socketTheme = resolveTheme(theme as Theme | ThemeName)
const promptColor = resolveColor(
socketTheme.colors.prompt,
socketTheme.colors,
) as ColorValue
const textDimColor = resolveColor(
socketTheme.colors.textDim,
socketTheme.colors,
) as ColorValue
const errorColor = socketTheme.colors.error
const successColor = socketTheme.colors.success
const primaryColor = socketTheme.colors.primary
return {
style: {
// Message text (uses colors.prompt)
message: (text: string) => applyColor(text, promptColor),
// Answer text (uses primary color)
answer: (text: string) => applyColor(text, primaryColor),
// Help text / descriptions (uses textDim)
help: (text: string) => applyColor(text, textDimColor),
description: (text: string) => applyColor(text, textDimColor),
// Disabled items (uses textDim)
disabled: (text: string) => applyColor(text, textDimColor),
// Error messages (uses error color)
error: (text: string) => applyColor(text, errorColor),
// Highlight/active (uses primary color)
highlight: (text: string) => applyColor(text, primaryColor),
},
icon: {
// Use success color for confirmed items
checked: applyColor('✓', successColor),
unchecked: ' ',
// Cursor uses primary color
cursor: applyColor('❯', primaryColor),
},
}
}
// Otherwise it's already an @inquirer theme, return as-is
return theme as Record<string, unknown>
}
/**
* Create a separator for select prompts.
* Creates a visual separator line in choice lists.
*
* @param text - Optional separator text (defaults to '───────')
* @returns Separator instance
*
* @example
* import { select, createSeparator } from '@socketsecurity/lib/stdio/prompts'
*
* const choice = await select({
* message: 'Choose an option:',
* choices: [
* { name: 'Option 1', value: 1 },
* createSeparator(),
* { name: 'Option 2', value: 2 }
* ]
* })
*/
export function createSeparator(
text?: string,
): InstanceType<typeof ActualSeparator> {
return new ActualSeparator(text)
}
/**
* Wrap an inquirer prompt with spinner handling, theme injection, and signal injection.
* Automatically stops/starts spinners during prompt display, injects the current theme,
* and injects abort signals. Trims string results and handles cancellation gracefully.
*
* @template T - Type of the prompt result
* @param inquirerPrompt - The inquirer prompt function to wrap
* @returns Wrapped prompt function with spinner, theme, and signal handling
*
* @example
* const myPrompt = wrapPrompt(rawInquirerPrompt)
* const result = await myPrompt({ message: 'Enter name:' })
*/
/*@__NO_SIDE_EFFECTS__*/
export function wrapPrompt<T = unknown>(
inquirerPrompt: (...args: unknown[]) => Promise<T>,
): (...args: unknown[]) => Promise<T | undefined> {
return async (...args) => {
const origContext = (args.length > 1 ? args[1] : undefined) as
| Context
| undefined
const { spinner: contextSpinner, ...contextWithoutSpinner } =
origContext ?? ({} as Context)
const spinnerInstance =
contextSpinner !== undefined ? contextSpinner : spinner
const signal = abortSignal
// Inject theme into config (args[0])
const config = args[0] as Record<string, unknown>
if (config && typeof config === 'object') {
if (!config['theme']) {
// No theme provided, use current theme
config['theme'] = createInquirerTheme(getTheme())
} else {
// Theme provided - let createInquirerTheme handle detection
config['theme'] = createInquirerTheme(config['theme'])
}
}
// Inject signal into context (args[1])
if (origContext) {
args[1] = {
signal,
...contextWithoutSpinner,
}
} else {
args[1] = { signal }
}
const wasSpinning = !!spinnerInstance?.isSpinning
spinnerInstance?.stop()
let result: unknown
try {
result = await inquirerPrompt(...args)
} catch (e) {
if (e instanceof TypeError) {
throw e
}
}
if (wasSpinning) {
spinnerInstance.start()
}
return (typeof result === 'string' ? result.trim() : result) as
| T
| undefined
}
}
/**
* Prompt to select multiple items from a list of choices.
* Wrapped with spinner handling and abort signal support.
*
* @example
* const choices = await checkbox({
* message: 'Select options:',
* choices: [
* { name: 'Option 1', value: 'opt1' },
* { name: 'Option 2', value: 'opt2' },
* { name: 'Option 3', value: 'opt3' }
* ]
* })
*/
export const checkbox: typeof checkboxRaw = wrapPrompt(checkboxRaw)
/**
* Prompt for a yes/no confirmation.
* Wrapped with spinner handling and abort signal support.
*
* @example
* const answer = await confirm({ message: 'Continue?' })
* if (answer) { // user confirmed }
*/
export const confirm: typeof confirmRaw = wrapPrompt(confirmRaw)
/**
* Prompt for text input.
* Wrapped with spinner handling and abort signal support.
* Result is automatically trimmed.
*
* @example
* const name = await input({ message: 'Enter your name:' })
*/
export const input: typeof inputRaw = wrapPrompt(inputRaw)
/**
* Prompt for password input (hidden characters).
* Wrapped with spinner handling and abort signal support.
*
* @example
* const token = await password({ message: 'Enter API token:' })
*/
export const password: typeof passwordRaw = wrapPrompt(passwordRaw)
/**
* Prompt with searchable/filterable choices.
* Wrapped with spinner handling and abort signal support.
*
* @example
* const result = await search({
* message: 'Select a package:',
* source: async (input) => fetchPackages(input)
* })
*/
export const search: typeof searchRaw = wrapPrompt(searchRaw)
/**
* Prompt to select from a list of choices.
* Wrapped with spinner handling and abort signal support.
*
* @example
* const choice = await select({
* message: 'Choose an option:',
* choices: [
* { name: 'Option 1', value: 'opt1' },
* { name: 'Option 2', value: 'opt2' }
* ]
* })
*/
export const select: typeof selectRaw = wrapPrompt(selectRaw)
export { ActualSeparator as Separator }