@kikkimo/claude-launcher 2.0.0 → 2.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/CHANGELOG.md +112 -0
- package/README.md +9 -6
- package/claude-launcher +57 -3
- package/docs/README-zh.md +9 -6
- package/lib/auth/password-input.js +101 -87
- package/lib/i18n/locales/de.js +17 -2
- package/lib/i18n/locales/en.js +17 -2
- package/lib/i18n/locales/es.js +17 -2
- package/lib/i18n/locales/fr.js +18 -3
- package/lib/i18n/locales/it.js +16 -0
- package/lib/i18n/locales/ja.js +17 -2
- package/lib/i18n/locales/ko.js +17 -2
- package/lib/i18n/locales/pt.js +16 -0
- package/lib/i18n/locales/ru.js +16 -0
- package/lib/i18n/locales/zh-TW.js +17 -2
- package/lib/i18n/locales/zh.js +17 -2
- package/lib/launcher.js +153 -47
- package/lib/presets/providers.js +65 -3
- package/lib/ui/interactive-table.js +102 -24
- package/lib/ui/menu.js +213 -144
- package/lib/ui/prompts.js +116 -85
- package/lib/utils/stdin-manager.js +715 -0
- package/package.json +1 -1
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StdinManager - Unified stdin state management
|
|
3
|
+
*
|
|
4
|
+
* This module provides centralized management of stdin operations,
|
|
5
|
+
* preventing conflicts between different modules that need to interact
|
|
6
|
+
* with user input (raw mode, readline, password input, etc.)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const readline = require('readline');
|
|
10
|
+
const EventEmitter = require('events');
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
const colors = require('../ui/colors');
|
|
13
|
+
const i18n = require('../i18n');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generate a cryptographically unique ID
|
|
17
|
+
* Uses crypto.randomUUID() when available (Node 14.17+, 15.6+),
|
|
18
|
+
* falls back to crypto.randomBytes-based hex string for older versions
|
|
19
|
+
* @returns {string} A unique identifier
|
|
20
|
+
*/
|
|
21
|
+
function generateUniqueId() {
|
|
22
|
+
// Use randomUUID if available (Node.js 14.17+, 15.6+)
|
|
23
|
+
if (typeof crypto.randomUUID === 'function') {
|
|
24
|
+
return crypto.randomUUID();
|
|
25
|
+
}
|
|
26
|
+
// Fallback: generate 16 random bytes and convert to hex (32 characters)
|
|
27
|
+
return crypto.randomBytes(16).toString('hex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class StdinManager extends EventEmitter {
|
|
31
|
+
constructor() {
|
|
32
|
+
super();
|
|
33
|
+
this.currentOwner = null;
|
|
34
|
+
this.activeScope = null; // Track currently active scope object
|
|
35
|
+
this.ownerStack = [];
|
|
36
|
+
this.listeners = new Map();
|
|
37
|
+
this.debugMode = process.env.DEBUG_STDIN === 'true';
|
|
38
|
+
// Suspension flag: when true, parent process should not attach/read stdin at all
|
|
39
|
+
this.suspended = false;
|
|
40
|
+
|
|
41
|
+
// Save initial state
|
|
42
|
+
this.initialState = this._captureState();
|
|
43
|
+
|
|
44
|
+
// Track if we're in a cleanup phase to prevent recursion
|
|
45
|
+
this.isCleaningUp = false;
|
|
46
|
+
|
|
47
|
+
// Ctrl+C double-press management
|
|
48
|
+
this.ctrlCCount = 0;
|
|
49
|
+
this.ctrlCTimer = null;
|
|
50
|
+
this.ctrlCEnabled = true; // Flag to enable/disable Ctrl+C monitoring
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Capture current stdin state
|
|
55
|
+
*/
|
|
56
|
+
_captureState() {
|
|
57
|
+
if (!process.stdin.isTTY) {
|
|
58
|
+
return {
|
|
59
|
+
isTTY: false,
|
|
60
|
+
isPaused: true,
|
|
61
|
+
isRaw: false,
|
|
62
|
+
encoding: 'utf8'
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
isTTY: true,
|
|
68
|
+
isPaused: typeof process.stdin.isPaused === 'function' ? process.stdin.isPaused() : true,
|
|
69
|
+
isRaw: process.stdin.isRaw || false,
|
|
70
|
+
encoding: process.stdin.readableEncoding || 'utf8',
|
|
71
|
+
listeners: {
|
|
72
|
+
data: process.stdin.listenerCount('data'),
|
|
73
|
+
keypress: process.stdin.listenerCount('keypress'),
|
|
74
|
+
readable: process.stdin.listenerCount('readable')
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Apply a state to stdin
|
|
81
|
+
*/
|
|
82
|
+
_applyState(state) {
|
|
83
|
+
if (!process.stdin.isTTY || !state.isTTY) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// Set raw mode
|
|
89
|
+
if (state.isRaw !== undefined) {
|
|
90
|
+
process.stdin.setRawMode(state.isRaw);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Set encoding
|
|
94
|
+
if (state.encoding) {
|
|
95
|
+
process.stdin.setEncoding(state.encoding);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Pause/resume
|
|
99
|
+
if (state.isPaused) {
|
|
100
|
+
process.stdin.pause();
|
|
101
|
+
} else {
|
|
102
|
+
process.stdin.resume();
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
this._debug('Error applying state:', error.message);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Debug logging
|
|
111
|
+
*/
|
|
112
|
+
_debug(...args) {
|
|
113
|
+
if (this.debugMode) {
|
|
114
|
+
console.error('[StdinManager]', ...args);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Acquire control of stdin with a specific mode
|
|
120
|
+
* @param {string} mode - 'raw', 'line', 'password'
|
|
121
|
+
* @param {object} options - Additional options
|
|
122
|
+
* @returns {StdinScope} - A scope object to manage the acquired state
|
|
123
|
+
*/
|
|
124
|
+
acquire(mode, options = {}) {
|
|
125
|
+
// If suspended, return a no-op scope that does not touch stdin
|
|
126
|
+
if (this.suspended) {
|
|
127
|
+
this._debug(`Acquire ignored (suspended): ${options.id || 'anonymous'} requested ${mode}`);
|
|
128
|
+
return new NoopStdinScope();
|
|
129
|
+
}
|
|
130
|
+
const callerId = options.id || `anonymous_${generateUniqueId()}`;
|
|
131
|
+
|
|
132
|
+
if (this.currentOwner && this.currentOwner !== callerId && !options.force) {
|
|
133
|
+
this._debug(`Warning: ${callerId} trying to acquire while ${this.currentOwner} owns stdin`);
|
|
134
|
+
if (!options.allowNested) {
|
|
135
|
+
throw new Error(`Stdin is currently owned by ${this.currentOwner}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Save current state before acquiring
|
|
140
|
+
const previousState = this._captureState();
|
|
141
|
+
const previousOwner = this.currentOwner;
|
|
142
|
+
const previousScope = this.activeScope;
|
|
143
|
+
|
|
144
|
+
// Push to stack if nesting
|
|
145
|
+
if (this.currentOwner && options.allowNested) {
|
|
146
|
+
// Detach listeners from previous scope but do not clear global listeners
|
|
147
|
+
if (previousScope && typeof previousScope.detach === 'function') {
|
|
148
|
+
previousScope.detach();
|
|
149
|
+
}
|
|
150
|
+
this.ownerStack.push({
|
|
151
|
+
owner: this.currentOwner,
|
|
152
|
+
state: previousState,
|
|
153
|
+
scope: previousScope
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.currentOwner = callerId;
|
|
158
|
+
this._debug(`${callerId} acquired stdin in ${mode} mode`);
|
|
159
|
+
|
|
160
|
+
// Create scope object
|
|
161
|
+
const scope = new StdinScope(this, callerId, mode, previousState, previousOwner);
|
|
162
|
+
|
|
163
|
+
// Apply the requested mode
|
|
164
|
+
switch (mode) {
|
|
165
|
+
case 'raw':
|
|
166
|
+
this._setupRawMode(scope);
|
|
167
|
+
break;
|
|
168
|
+
case 'line':
|
|
169
|
+
this._setupLineMode(scope);
|
|
170
|
+
break;
|
|
171
|
+
case 'password':
|
|
172
|
+
this._setupPasswordMode(scope);
|
|
173
|
+
break;
|
|
174
|
+
default:
|
|
175
|
+
throw new Error(`Unknown mode: ${mode}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Mark this scope as active
|
|
179
|
+
this.activeScope = scope;
|
|
180
|
+
|
|
181
|
+
return scope;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Setup raw mode for navigation/menu
|
|
186
|
+
*/
|
|
187
|
+
_setupRawMode(scope) {
|
|
188
|
+
if (!process.stdin.isTTY) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
// Setup for raw mode
|
|
194
|
+
process.stdin.setRawMode(true);
|
|
195
|
+
process.stdin.setEncoding('utf8');
|
|
196
|
+
process.stdin.resume();
|
|
197
|
+
|
|
198
|
+
this._debug(`Raw mode activated for ${scope.ownerId}`);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
this._debug('Error setting up raw mode:', error.message);
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Setup line mode for readline interface
|
|
207
|
+
*/
|
|
208
|
+
_setupLineMode(scope) {
|
|
209
|
+
try {
|
|
210
|
+
// Ensure we're not in raw mode
|
|
211
|
+
if (process.stdin.isTTY) {
|
|
212
|
+
process.stdin.setRawMode(false);
|
|
213
|
+
process.stdin.setEncoding('utf8');
|
|
214
|
+
// Ensure stream is flowing for readline
|
|
215
|
+
process.stdin.resume();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this._debug(`Line mode activated for ${scope.ownerId}`);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
this._debug('Error setting up line mode:', error.message);
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Setup password mode (raw with echo off)
|
|
227
|
+
*/
|
|
228
|
+
_setupPasswordMode(scope) {
|
|
229
|
+
if (!process.stdin.isTTY) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
// Clear any existing listeners from other modules
|
|
235
|
+
this._saveAndClearListeners(scope);
|
|
236
|
+
|
|
237
|
+
// Setup for password input
|
|
238
|
+
process.stdin.setRawMode(true);
|
|
239
|
+
process.stdin.setEncoding('utf8');
|
|
240
|
+
process.stdin.resume();
|
|
241
|
+
|
|
242
|
+
this._debug(`Password mode activated for ${scope.ownerId}`);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
this._debug('Error setting up password mode:', error.message);
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Save and clear existing listeners
|
|
251
|
+
*/
|
|
252
|
+
_saveAndClearListeners(scope) {
|
|
253
|
+
// Deprecated: avoid global removeAllListeners which causes deadlocks.
|
|
254
|
+
// Kept for backward compatibility but no-op now.
|
|
255
|
+
scope.savedListeners = new Map();
|
|
256
|
+
this._debug('Skipped global removeAllListeners to preserve pending scopes');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Restore saved listeners
|
|
261
|
+
*/
|
|
262
|
+
_restoreListeners(scope) {
|
|
263
|
+
// No-op: we no longer clear global listeners.
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Release control of stdin
|
|
268
|
+
*/
|
|
269
|
+
release(callerId) {
|
|
270
|
+
if (this.currentOwner !== callerId) {
|
|
271
|
+
this._debug(`Warning: ${callerId} trying to release but current owner is ${this.currentOwner}`);
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this._debug(`${callerId} releasing stdin control`);
|
|
276
|
+
|
|
277
|
+
// Check if there's a stacked owner to restore
|
|
278
|
+
if (this.ownerStack.length > 0) {
|
|
279
|
+
const previous = this.ownerStack.pop();
|
|
280
|
+
this.currentOwner = previous.owner;
|
|
281
|
+
this.activeScope = previous.scope || null;
|
|
282
|
+
// Reattach previous scope listeners if possible
|
|
283
|
+
if (this.activeScope && typeof this.activeScope.reattach === 'function') {
|
|
284
|
+
this.activeScope.reattach();
|
|
285
|
+
}
|
|
286
|
+
this._applyState(previous.state);
|
|
287
|
+
this._debug(`Restored previous owner ${previous.owner}`);
|
|
288
|
+
} else {
|
|
289
|
+
this.currentOwner = null;
|
|
290
|
+
this.activeScope = null;
|
|
291
|
+
// Restore to safe default state
|
|
292
|
+
this._applyState({
|
|
293
|
+
isTTY: process.stdin.isTTY,
|
|
294
|
+
isPaused: true,
|
|
295
|
+
isRaw: false,
|
|
296
|
+
encoding: 'utf8'
|
|
297
|
+
});
|
|
298
|
+
this._debug('No previous owner, restored to default state');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Force reset to initial state (emergency use only)
|
|
306
|
+
*
|
|
307
|
+
* This method cleans up only the listeners managed by StdinManager,
|
|
308
|
+
* preserving any listeners registered by other modules.
|
|
309
|
+
*
|
|
310
|
+
* @param {boolean} clearAll - If true, also removes ALL stdin listeners (destructive).
|
|
311
|
+
* Use with extreme caution as it will break other modules.
|
|
312
|
+
* Defaults to false for safe cleanup.
|
|
313
|
+
*/
|
|
314
|
+
forceReset(clearAll = false) {
|
|
315
|
+
if (this.isCleaningUp) {
|
|
316
|
+
return; // Prevent recursion
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.isCleaningUp = true;
|
|
320
|
+
this._debug('Force resetting stdin to initial state');
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
if (clearAll) {
|
|
324
|
+
// DESTRUCTIVE: Clear ALL listeners including those from other modules
|
|
325
|
+
// Only use this when you're absolutely sure (e.g., before process.exit)
|
|
326
|
+
this._debug('WARNING: Removing ALL stdin listeners (clearAll=true)');
|
|
327
|
+
process.stdin.removeAllListeners();
|
|
328
|
+
} else {
|
|
329
|
+
// SAFE: Only remove listeners managed by StdinManager
|
|
330
|
+
// Clean up active scope listeners
|
|
331
|
+
if (this.activeScope && typeof this.activeScope.removeAllListeners === 'function') {
|
|
332
|
+
this._debug('Cleaning up active scope listeners');
|
|
333
|
+
this.activeScope.removeAllListeners();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Clean up stacked scope listeners
|
|
337
|
+
this.ownerStack.forEach(item => {
|
|
338
|
+
if (item.scope && typeof item.scope.removeAllListeners === 'function') {
|
|
339
|
+
this._debug(`Cleaning up stacked scope listeners for ${item.owner}`);
|
|
340
|
+
item.scope.removeAllListeners();
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Reset state
|
|
346
|
+
this._applyState(this.initialState);
|
|
347
|
+
|
|
348
|
+
// Clear ownership
|
|
349
|
+
this.currentOwner = null;
|
|
350
|
+
this.activeScope = null;
|
|
351
|
+
this.ownerStack = [];
|
|
352
|
+
this.listeners.clear();
|
|
353
|
+
} catch (error) {
|
|
354
|
+
this._debug('Error during force reset:', error.message);
|
|
355
|
+
} finally {
|
|
356
|
+
this.isCleaningUp = false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Get current status for debugging
|
|
362
|
+
*/
|
|
363
|
+
getStatus() {
|
|
364
|
+
return {
|
|
365
|
+
currentOwner: this.currentOwner,
|
|
366
|
+
stackDepth: this.ownerStack.length,
|
|
367
|
+
currentState: this._captureState(),
|
|
368
|
+
isCleaningUp: this.isCleaningUp
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Check if currently waiting for second Ctrl+C
|
|
374
|
+
*/
|
|
375
|
+
isCtrlCPending() {
|
|
376
|
+
return this.ctrlCCount > 0;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Cancel Ctrl+C exit intent (called when user presses any other key)
|
|
381
|
+
*/
|
|
382
|
+
cancelCtrlC() {
|
|
383
|
+
if (this.ctrlCCount > 0) {
|
|
384
|
+
this.ctrlCCount = 0;
|
|
385
|
+
if (this.ctrlCTimer) {
|
|
386
|
+
clearTimeout(this.ctrlCTimer);
|
|
387
|
+
this.ctrlCTimer = null;
|
|
388
|
+
}
|
|
389
|
+
// Clear the warning text only in TTY mode to avoid spamming ANSI codes
|
|
390
|
+
if (process.stdout && process.stdout.isTTY) {
|
|
391
|
+
// Clear the warning text (2 lines: empty line + warning line)
|
|
392
|
+
process.stdout.write('\x1b[1A\r\x1b[K'); // Up 1 line, clear warning
|
|
393
|
+
process.stdout.write('\x1b[1A\r\x1b[K'); // Up 1 line, clear empty line
|
|
394
|
+
}
|
|
395
|
+
this._debug('Ctrl+C cancelled by user input');
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Disable Ctrl+C monitoring
|
|
401
|
+
* Call this before launching Claude Code to prevent Ctrl+C interception
|
|
402
|
+
*/
|
|
403
|
+
disableCtrlC() {
|
|
404
|
+
this.ctrlCEnabled = false;
|
|
405
|
+
// Also cancel any pending Ctrl+C state
|
|
406
|
+
if (this.ctrlCCount > 0) {
|
|
407
|
+
this.ctrlCCount = 0;
|
|
408
|
+
if (this.ctrlCTimer) {
|
|
409
|
+
clearTimeout(this.ctrlCTimer);
|
|
410
|
+
this.ctrlCTimer = null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
this._debug('Ctrl+C monitoring disabled');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Enable Ctrl+C monitoring
|
|
418
|
+
* Call this to re-enable Ctrl+C monitoring after disabling
|
|
419
|
+
*/
|
|
420
|
+
enableCtrlC() {
|
|
421
|
+
this.ctrlCEnabled = true;
|
|
422
|
+
this._debug('Ctrl+C monitoring enabled');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Private: Reset Ctrl+C state on timeout
|
|
427
|
+
* Clears warning text after 3 seconds of no input
|
|
428
|
+
*/
|
|
429
|
+
_resetCtrlCOnTimeout() {
|
|
430
|
+
this.ctrlCCount = 0;
|
|
431
|
+
this.ctrlCTimer = null;
|
|
432
|
+
// Clear the warning text only in TTY mode to avoid spamming ANSI codes
|
|
433
|
+
if (process.stdout && process.stdout.isTTY) {
|
|
434
|
+
// Clear the warning text (2 lines: empty line + warning line)
|
|
435
|
+
process.stdout.write('\x1b[1A\r\x1b[K'); // Up 1 line, clear warning
|
|
436
|
+
process.stdout.write('\x1b[1A\r\x1b[K'); // Up 1 line, clear empty line
|
|
437
|
+
}
|
|
438
|
+
this._debug('Ctrl+C timer expired - warning cleared, returning to normal operation');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Handle Ctrl+C with double-press confirmation
|
|
443
|
+
* First press: show warning and start 3-second timer
|
|
444
|
+
* Second press (within 3 seconds): exit program
|
|
445
|
+
* Timer expires: reset count and return to normal operation
|
|
446
|
+
* Any other key: cancel and return to normal operation
|
|
447
|
+
*
|
|
448
|
+
* If Ctrl+C monitoring is disabled (e.g., when launching Claude Code),
|
|
449
|
+
* this function does nothing and returns false
|
|
450
|
+
*/
|
|
451
|
+
handleCtrlC() {
|
|
452
|
+
// If suspended, do nothing so child process can receive Ctrl+C
|
|
453
|
+
if (this.suspended) {
|
|
454
|
+
this._debug('Ctrl+C ignored (suspended)');
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
// If Ctrl+C monitoring is disabled, do nothing
|
|
458
|
+
if (!this.ctrlCEnabled) {
|
|
459
|
+
this._debug('Ctrl+C ignored (monitoring disabled)');
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
this.ctrlCCount++;
|
|
464
|
+
|
|
465
|
+
if (this.ctrlCCount === 1) {
|
|
466
|
+
// First Ctrl+C - show warning
|
|
467
|
+
console.log('');
|
|
468
|
+
console.log(colors.yellow + '⚠️ ' + i18n.tSync('messages.prompts.ctrl_c_again') + colors.reset);
|
|
469
|
+
|
|
470
|
+
// Clear any existing timer
|
|
471
|
+
if (this.ctrlCTimer) {
|
|
472
|
+
clearTimeout(this.ctrlCTimer);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Set 3-second timeout to reset count
|
|
476
|
+
this.ctrlCTimer = setTimeout(() => {
|
|
477
|
+
this._resetCtrlCOnTimeout();
|
|
478
|
+
}, 3000);
|
|
479
|
+
|
|
480
|
+
return false; // Don't exit yet, continue normal operation
|
|
481
|
+
|
|
482
|
+
} else if (this.ctrlCCount >= 2) {
|
|
483
|
+
// Second Ctrl+C within timeout - exit
|
|
484
|
+
if (this.ctrlCTimer) {
|
|
485
|
+
clearTimeout(this.ctrlCTimer);
|
|
486
|
+
this.ctrlCTimer = null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
console.log('');
|
|
490
|
+
console.log(colors.green + i18n.tSync('ui.general.goodbye') + colors.reset);
|
|
491
|
+
|
|
492
|
+
// Clean up stdin before exit
|
|
493
|
+
// Use clearAll=true since we're exiting anyway
|
|
494
|
+
try {
|
|
495
|
+
this.forceReset(true);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
// Ignore cleanup errors during exit
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
process.exit(0);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* StdinScope - Represents an acquired stdin context
|
|
507
|
+
*/
|
|
508
|
+
class StdinScope {
|
|
509
|
+
constructor(manager, ownerId, mode, previousState, previousOwner) {
|
|
510
|
+
this.manager = manager;
|
|
511
|
+
this.ownerId = ownerId;
|
|
512
|
+
this.mode = mode;
|
|
513
|
+
this.previousState = previousState;
|
|
514
|
+
this.previousOwner = previousOwner;
|
|
515
|
+
this.isReleased = false;
|
|
516
|
+
this.listeners = new Map();
|
|
517
|
+
this.savedListeners = null;
|
|
518
|
+
this._detached = false;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Add an event listener that will be automatically cleaned up
|
|
523
|
+
*/
|
|
524
|
+
on(event, handler) {
|
|
525
|
+
if (this.isReleased) {
|
|
526
|
+
throw new Error('Cannot add listener to released scope');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (!this.listeners.has(event)) {
|
|
530
|
+
this.listeners.set(event, []);
|
|
531
|
+
}
|
|
532
|
+
this.listeners.get(event).push(handler);
|
|
533
|
+
process.stdin.on(event, handler);
|
|
534
|
+
|
|
535
|
+
return this;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Add a one-time event listener
|
|
540
|
+
*/
|
|
541
|
+
once(event, handler) {
|
|
542
|
+
if (this.isReleased) {
|
|
543
|
+
throw new Error('Cannot add listener to released scope');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const wrappedHandler = (...args) => {
|
|
547
|
+
this.removeListener(event, wrappedHandler);
|
|
548
|
+
handler(...args);
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
this.on(event, wrappedHandler);
|
|
552
|
+
return this;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Remove an event listener
|
|
557
|
+
*/
|
|
558
|
+
removeListener(event, handler) {
|
|
559
|
+
if (this.listeners.has(event)) {
|
|
560
|
+
const handlers = this.listeners.get(event);
|
|
561
|
+
const index = handlers.indexOf(handler);
|
|
562
|
+
if (index !== -1) {
|
|
563
|
+
handlers.splice(index, 1);
|
|
564
|
+
process.stdin.removeListener(event, handler);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return this;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Remove all listeners for this scope
|
|
572
|
+
*/
|
|
573
|
+
removeAllListeners() {
|
|
574
|
+
this.listeners.forEach((handlers, event) => {
|
|
575
|
+
handlers.forEach(handler => {
|
|
576
|
+
process.stdin.removeListener(event, handler);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
this.listeners.clear();
|
|
580
|
+
return this;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Detach listeners from process.stdin but keep record for later reattach
|
|
585
|
+
*/
|
|
586
|
+
detach() {
|
|
587
|
+
if (this._detached) return;
|
|
588
|
+
this.listeners.forEach((handlers, event) => {
|
|
589
|
+
handlers.forEach(handler => {
|
|
590
|
+
process.stdin.removeListener(event, handler);
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
this._detached = true;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Reattach previously detached listeners
|
|
598
|
+
*/
|
|
599
|
+
reattach() {
|
|
600
|
+
if (!this._detached) return;
|
|
601
|
+
this.listeners.forEach((handlers, event) => {
|
|
602
|
+
handlers.forEach(handler => {
|
|
603
|
+
process.stdin.on(event, handler);
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
this._detached = false;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Release this scope and restore previous state
|
|
611
|
+
*/
|
|
612
|
+
release() {
|
|
613
|
+
if (this.isReleased) {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Remove all our listeners
|
|
618
|
+
this.removeAllListeners();
|
|
619
|
+
|
|
620
|
+
// Restore any saved listeners
|
|
621
|
+
this.manager._restoreListeners(this);
|
|
622
|
+
|
|
623
|
+
// Release from manager (which will restore previous state if needed)
|
|
624
|
+
// Note: manager.release() handles state restoration to avoid double-application
|
|
625
|
+
const released = this.manager.release(this.ownerId);
|
|
626
|
+
this.isReleased = true;
|
|
627
|
+
|
|
628
|
+
return released;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Create a readline interface with proper cleanup
|
|
633
|
+
*/
|
|
634
|
+
createReadline(options = {}) {
|
|
635
|
+
// Capture current stdin state before creating readline
|
|
636
|
+
const stateBeforeReadline = {
|
|
637
|
+
isPaused: typeof process.stdin.isPaused === 'function' ? process.stdin.isPaused() : true,
|
|
638
|
+
isRaw: process.stdin.isRaw || false,
|
|
639
|
+
encoding: process.stdin.readableEncoding || 'utf8'
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
const rl = readline.createInterface({
|
|
643
|
+
input: process.stdin,
|
|
644
|
+
output: process.stdout,
|
|
645
|
+
...options
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// Track for cleanup
|
|
649
|
+
const originalClose = rl.close.bind(rl);
|
|
650
|
+
rl.close = () => {
|
|
651
|
+
originalClose();
|
|
652
|
+
|
|
653
|
+
// Critical: restore stdin state after readline closes
|
|
654
|
+
// readline.close() removes its own listeners but may leave stdin in an active state
|
|
655
|
+
try {
|
|
656
|
+
// Restore raw mode state
|
|
657
|
+
if (process.stdin.setRawMode) {
|
|
658
|
+
process.stdin.setRawMode(stateBeforeReadline.isRaw);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Restore pause state
|
|
662
|
+
if (stateBeforeReadline.isPaused && typeof process.stdin.pause === 'function') {
|
|
663
|
+
process.stdin.pause();
|
|
664
|
+
} else if (!stateBeforeReadline.isPaused && typeof process.stdin.resume === 'function') {
|
|
665
|
+
process.stdin.resume();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Restore encoding
|
|
669
|
+
if (process.stdin.setEncoding && stateBeforeReadline.encoding) {
|
|
670
|
+
process.stdin.setEncoding(stateBeforeReadline.encoding);
|
|
671
|
+
}
|
|
672
|
+
} catch (error) {
|
|
673
|
+
this._debug('Error restoring stdin state after readline close:', error.message);
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
return rl;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Create singleton instance
|
|
682
|
+
const stdinManager = new StdinManager();
|
|
683
|
+
|
|
684
|
+
// Export both the class and singleton
|
|
685
|
+
module.exports = stdinManager;
|
|
686
|
+
module.exports.StdinManager = StdinManager;
|
|
687
|
+
module.exports.StdinScope = StdinScope;
|
|
688
|
+
// Suspension API
|
|
689
|
+
stdinManager.suspend = function() {
|
|
690
|
+
this._debug('StdinManager suspended');
|
|
691
|
+
this.suspended = true;
|
|
692
|
+
// Best-effort: clear ctrl-c pending state
|
|
693
|
+
this.cancelCtrlC();
|
|
694
|
+
};
|
|
695
|
+
stdinManager.resume = function() {
|
|
696
|
+
this._debug('StdinManager resumed');
|
|
697
|
+
this.suspended = false;
|
|
698
|
+
};
|
|
699
|
+
stdinManager.isSuspended = function() {
|
|
700
|
+
return !!this.suspended;
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Noop scope used when stdin is suspended to prevent attaching listeners
|
|
705
|
+
*/
|
|
706
|
+
class NoopStdinScope {
|
|
707
|
+
on() { return this; }
|
|
708
|
+
once() { return this; }
|
|
709
|
+
removeListener() { return this; }
|
|
710
|
+
removeAllListeners() { return this; }
|
|
711
|
+
detach() { return this; }
|
|
712
|
+
reattach() { return this; }
|
|
713
|
+
release() { return true; }
|
|
714
|
+
createReadline() { return { question: (_, cb)=>cb && cb(''), close: ()=>{} }; }
|
|
715
|
+
}
|