@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.
@@ -7,6 +7,7 @@ const { maskApiToken } = require('../validators');
7
7
  const { decrypt } = require('../crypto');
8
8
  const i18n = require('../i18n');
9
9
  const { padStringToWidth } = require('../utils/string-width');
10
+ const stdinManager = require('../utils/stdin-manager');
10
11
 
11
12
  /**
12
13
  * Display simple interactive table for API selection
@@ -127,6 +128,18 @@ async function showApiSelectionTable(apis, title, actionType = 'select', activeI
127
128
  }
128
129
 
129
130
  function handleKeyPress(key) {
131
+ // Handle Ctrl+C first
132
+ if (key === '\u0003') {
133
+ stdinManager.handleCtrlC();
134
+ return undefined;
135
+ }
136
+
137
+ // If waiting for second Ctrl+C, any other key cancels it
138
+ if (stdinManager.isCtrlCPending()) {
139
+ stdinManager.cancelCtrlC();
140
+ // Continue to process this key normally
141
+ }
142
+
130
143
  switch (key) {
131
144
  case '\u001b[A': // Up arrow
132
145
  selectedIndex = (selectedIndex - 1 + apis.length) % apis.length;
@@ -157,22 +170,17 @@ async function showApiSelectionTable(apis, title, actionType = 'select', activeI
157
170
  displaySimpleTable();
158
171
 
159
172
  if (process.stdin.isTTY) {
160
- process.stdin.removeAllListeners('data');
161
- process.stdin.removeAllListeners('keypress');
162
- process.stdin.setRawMode(true);
163
- process.stdin.resume();
164
- process.stdin.setEncoding('utf8');
173
+ // Use StdinManager for proper state management
174
+ const scope = stdinManager.acquire('raw', {
175
+ id: 'showApiSelectionTable',
176
+ allowNested: true
177
+ });
165
178
 
166
179
  const keyHandler = async (key) => {
167
180
  const result = handleKeyPress(key);
168
181
  if (result !== undefined) {
169
- // Force complete cleanup to prevent navigation issues
170
- if (process.stdin.isTTY) {
171
- process.stdin.setRawMode(false);
172
- }
173
- process.stdin.removeAllListeners('data');
174
- process.stdin.removeAllListeners('keypress');
175
- process.stdin.pause();
182
+ // Release the scope, which automatically restores previous state
183
+ scope.release();
176
184
 
177
185
  // Handle switch mode - activate the selected API
178
186
  if (result && actionType === 'switch' && apiManager) {
@@ -202,7 +210,7 @@ async function showApiSelectionTable(apis, title, actionType = 'select', activeI
202
210
  }
203
211
  };
204
212
 
205
- process.stdin.on('data', keyHandler);
213
+ scope.on('data', keyHandler);
206
214
  } else {
207
215
  resolve(null);
208
216
  }
@@ -211,12 +219,33 @@ async function showApiSelectionTable(apis, title, actionType = 'select', activeI
211
219
 
212
220
  function waitForKeyPress() {
213
221
  return new Promise((resolve) => {
214
- const keyHandler = () => {
215
- process.stdin.removeListener('data', keyHandler);
222
+ // Use StdinManager for proper state management
223
+ const scope = stdinManager.acquire('raw', {
224
+ id: 'waitForKeyPress',
225
+ allowNested: true
226
+ });
227
+
228
+ const handler = (key) => {
229
+ // Handle Ctrl+C first
230
+ if (key === '\u0003') {
231
+ stdinManager.handleCtrlC();
232
+ return; // Keep listener active for subsequent keys
233
+ }
234
+
235
+ // If waiting for second Ctrl+C, any other key cancels it
236
+ if (stdinManager.isCtrlCPending()) {
237
+ stdinManager.cancelCtrlC();
238
+ }
239
+
240
+ // Manually remove listener before resolving
241
+ scope.removeListener('data', handler);
242
+ // Release the scope, which automatically restores previous state
243
+ scope.release();
216
244
  resolve();
217
245
  };
218
- process.stdin.once('data', keyHandler);
219
- process.stdin.resume();
246
+
247
+ // Use on() instead of once() so Ctrl+C doesn't remove the listener
248
+ scope.on('data', handler);
220
249
  });
221
250
  }
222
251
 
@@ -238,17 +267,66 @@ async function confirmDeletion(api) {
238
267
  console.log(colors.red + i18n.tSync('ui.general.action_cannot_undone') + colors.reset);
239
268
  console.log('');
240
269
 
241
- const readline = require('readline');
242
- const rl = readline.createInterface({
243
- input: process.stdin,
244
- output: process.stdout
270
+ // Use StdinManager for proper state management
271
+ const scope = stdinManager.acquire('line', {
272
+ id: 'confirmDeletion',
273
+ allowNested: false
245
274
  });
246
275
 
276
+ let rl;
277
+ try {
278
+ rl = scope.createReadline();
279
+ } catch (error) {
280
+ console.error('[ERROR] Failed to create readline interface:', error.message);
281
+ scope.release();
282
+ return false; // Default to not deleting if we can't get user confirmation
283
+ }
284
+
247
285
  return new Promise((resolve) => {
286
+ let released = false;
287
+ let timeoutId = null;
288
+
289
+ // Centralized cleanup helper to prevent double-release
290
+ const cleanup = (result) => {
291
+ if (released) return; // Guard against multiple calls
292
+ released = true;
293
+
294
+ // Clear timeout if it exists
295
+ if (timeoutId !== null) {
296
+ clearTimeout(timeoutId);
297
+ timeoutId = null;
298
+ }
299
+
300
+ // Remove all readline listeners to prevent subsequent handlers
301
+ if (rl) {
302
+ rl.removeAllListeners('error');
303
+ rl.removeAllListeners('line');
304
+ // Don't remove 'close' listeners - readline needs them for cleanup
305
+ rl.close();
306
+ }
307
+
308
+ // Release the scope
309
+ scope.release();
310
+
311
+ // Resolve the promise
312
+ resolve(result);
313
+ };
314
+
315
+ // Set a timeout to prevent infinite waiting
316
+ timeoutId = setTimeout(() => {
317
+ console.log('\n' + colors.yellow + '[!] Confirmation timeout - operation cancelled' + colors.reset);
318
+ cleanup(false); // Timeout means no deletion
319
+ }, 60000); // 60 second timeout
320
+
248
321
  rl.question(colors.red + i18n.tSync('ui.general.confirm_deletion_prompt') + colors.reset, (answer) => {
249
- rl.close();
250
322
  const confirmed = answer.trim().toLowerCase() === 'y';
251
- resolve(confirmed);
323
+ cleanup(confirmed);
324
+ });
325
+
326
+ // Handle readline errors
327
+ rl.on('error', (error) => {
328
+ console.error('[ERROR] Readline error:', error.message);
329
+ cleanup(false); // Error means no deletion
252
330
  });
253
331
  });
254
332
  }
@@ -257,4 +335,4 @@ module.exports = {
257
335
  showApiSelectionTable,
258
336
  waitForKeyPress,
259
337
  confirmDeletion
260
- };
338
+ };
package/lib/ui/menu.js CHANGED
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * Menu Module - Handles menu display and navigation
3
3
  */
4
4
 
@@ -6,6 +6,7 @@ const readline = require('readline');
6
6
  const colors = require('./colors');
7
7
  const i18n = require('../i18n');
8
8
  const { padStringToWidth } = require('../utils/string-width');
9
+ const stdinManager = require('../utils/stdin-manager');
9
10
 
10
11
  /**
11
12
  * Force cleanup stdin state before displaying any menu
@@ -14,13 +15,20 @@ const { padStringToWidth } = require('../utils/string-width');
14
15
  function forceCleanupBeforeMenu() {
15
16
  try {
16
17
  if (process.stdin.isTTY) {
18
+ // Only reset mode, don't remove listeners that might be in use
17
19
  process.stdin.setRawMode(false);
18
- process.stdin.removeAllListeners('data');
19
- process.stdin.removeAllListeners('keypress');
20
- process.stdin.pause();
20
+ // NOTE: Removed removeAllListeners to prevent conflicts with other modules
21
+ // The menu will manage its own listeners
22
+ // Only pause if not already paused
23
+ if (typeof process.stdin.isPaused !== 'function' || !process.stdin.isPaused()) {
24
+ process.stdin.pause();
25
+ }
21
26
  }
22
27
  } catch (error) {
23
- // Ignore cleanup errors - we just want to ensure clean state
28
+ // Ignore cleanup errors but log for debugging
29
+ if (process.env.DEBUG_STDIN) {
30
+ console.error('[DEBUG] forceCleanupBeforeMenu error:', error.message);
31
+ }
24
32
  }
25
33
  }
26
34
 
@@ -40,9 +48,9 @@ class Menu {
40
48
  // Use console.clear and console.log for proper screen clearing
41
49
  console.clear();
42
50
  console.log('');
43
- console.log(colors.orange + ' ┌───────────────────────────────────────────────────────────────────────────┐' + colors.reset);
44
- console.log(colors.orange + ' │' + colors.white + colors.bright + ' Claude Code Launcher ' + colors.orange + '│' + colors.reset);
45
- console.log(colors.orange + ' └───────────────────────────────────────────────────────────────────────────┘' + colors.reset);
51
+ console.log(colors.orange + ' ┌──────────────────────────────────────────────────────┐' + colors.reset);
52
+ console.log(colors.orange + ' │' + colors.white + colors.bright + ' Claude Code Launcher ' + colors.orange + '│' + colors.reset);
53
+ console.log(colors.orange + ' └──────────────────────────────────────────────────────┘' + colors.reset);
46
54
  console.log('');
47
55
  console.log(colors.gray + ' ' + i18n.tSync('navigation.use_arrows') + colors.reset);
48
56
  console.log('');
@@ -59,9 +67,9 @@ class Menu {
59
67
  console.clear();
60
68
  }
61
69
  console.log('');
62
- console.log(colors.orange + ' ┌───────────────────────────────────────────────────────────────────────────┐' + colors.reset);
63
- console.log(colors.orange + ' │' + colors.white + colors.bright + ' Claude Code Launcher ' + colors.orange + '│' + colors.reset);
64
- console.log(colors.orange + ' └───────────────────────────────────────────────────────────────────────────┘' + colors.reset);
70
+ console.log(colors.orange + ' ┌──────────────────────────────────────────────────────┐' + colors.reset);
71
+ console.log(colors.orange + ' │' + colors.white + colors.bright + ' Claude Code Launcher ' + colors.orange + '│' + colors.reset);
72
+ console.log(colors.orange + ' └──────────────────────────────────────────────────────┘' + colors.reset);
65
73
  console.log('');
66
74
 
67
75
  // Display version info if provided (between banner and navigation tips, like Claude Code)
@@ -101,90 +109,115 @@ class Menu {
101
109
  * @param {string} versionInfo - Optional version info to display
102
110
  */
103
111
  async navigate(clearScreen = true, versionInfo = null) {
104
- let ctrlCCount = 0;
112
+ // Guard against empty menu to prevent NaN from modulo operations
113
+ if (!this.menuOptions || this.menuOptions.length === 0) {
114
+ console.log(colors.yellow + ' Warning: No menu options available' + colors.reset);
115
+ return -1; // Return cancel/exit code
116
+ }
117
+
105
118
  this.versionInfo = versionInfo; // Store for redrawing
106
119
 
107
- return new Promise((resolve) => {
120
+ return new Promise((resolve, reject) => {
108
121
  this.displayMenu(clearScreen, versionInfo);
109
122
 
110
123
  if (process.stdin.isTTY) {
111
- process.stdin.setRawMode(true);
112
- process.stdin.resume();
113
- process.stdin.setEncoding('utf8');
124
+ const scope = stdinManager.acquire('raw', {
125
+ id: 'menu_navigate',
126
+ allowNested: true
127
+ });
114
128
 
115
- const handleKeyPress = (key) => {
116
- switch (key) {
117
- case '\u0003': // Ctrl+C - 2-step exit
118
- ctrlCCount++;
119
- if (ctrlCCount === 1) {
120
- console.log('');
121
- console.log(colors.yellow + '⚠️ ' + i18n.tSync('messages.prompts.ctrl_c_again') + colors.reset);
122
- setTimeout(() => {
123
- ctrlCCount = 0; // Reset after 3 seconds
124
- }, 3000);
125
- } else if (ctrlCCount >= 2) {
126
- process.stdin.removeListener('data', handleKeyPress);
127
- if (process.stdin.isTTY) {
128
- process.stdin.setRawMode(false);
129
- }
130
- console.log('');
131
- console.log(colors.green + '👋 Goodbye!' + colors.reset);
132
- process.exit(0);
133
- }
134
- break;
135
-
136
- case '\u001b[A': // Up arrow
137
- ctrlCCount = 0; // Reset Ctrl+C count on other keys
138
- this.selectedIndex = (this.selectedIndex - 1 + this.menuOptions.length) % this.menuOptions.length;
139
- this.displayMenu(true, this.versionInfo);
140
- break;
141
-
142
- case '\u001b[B': // Down arrow
143
- ctrlCCount = 0; // Reset Ctrl+C count on other keys
144
- this.selectedIndex = (this.selectedIndex + 1) % this.menuOptions.length;
145
- this.displayMenu(true, this.versionInfo);
146
- break;
147
-
148
- case '\r': // Enter
149
- process.stdin.removeListener('data', handleKeyPress);
150
- if (process.stdin.isTTY) {
151
- process.stdin.setRawMode(false);
152
- }
153
- process.stdin.pause();
154
- resolve(this.selectedIndex);
155
- break;
156
-
157
- case '\u001b': // Escape
158
- case 'q':
159
- case 'Q':
160
- process.stdin.removeListener('data', handleKeyPress);
161
- if (process.stdin.isTTY) {
162
- process.stdin.setRawMode(false);
163
- }
164
- process.stdin.pause();
165
- resolve(-1);
166
- break;
129
+ let resolved = false;
130
+ let cleanedUp = false;
131
+
132
+ const cleanup = () => {
133
+ if (cleanedUp) return;
134
+ cleanedUp = true;
135
+ try {
136
+ scope.removeListener('data', handleKeyPress);
137
+ scope.release();
138
+ } catch (error) {
139
+ // Ignore cleanup errors to prevent masking original error
140
+ }
141
+ };
142
+
143
+ const safeResolve = (value) => {
144
+ if (resolved) return;
145
+ resolved = true;
146
+ cleanup();
147
+ resolve(value);
148
+ };
167
149
 
150
+ const safeReject = (error) => {
151
+ if (resolved) return;
152
+ resolved = true;
153
+ cleanup();
154
+ reject(error);
155
+ };
156
+
157
+ const handleKeyPress = (key) => {
158
+ try {
159
+ // Handle Ctrl+C first
160
+ if (key === '\u0003') {
161
+ stdinManager.handleCtrlC();
162
+ return; // Don't process further
163
+ }
164
+
165
+ // If waiting for second Ctrl+C, any other key cancels it
166
+ if (stdinManager.isCtrlCPending()) {
167
+ stdinManager.cancelCtrlC();
168
+ // Continue to process this key normally
169
+ }
170
+
171
+ switch (key) {
172
+ case '\u001b[A': // Up arrow
173
+ this.selectedIndex = (this.selectedIndex - 1 + this.menuOptions.length) % this.menuOptions.length;
174
+ this.displayMenu(true, this.versionInfo);
175
+ break;
176
+
177
+ case '\u001b[B': // Down arrow
178
+ this.selectedIndex = (this.selectedIndex + 1) % this.menuOptions.length;
179
+ this.displayMenu(true, this.versionInfo);
180
+ break;
181
+
182
+ case '\r': // Enter
183
+ safeResolve(this.selectedIndex);
184
+ break;
185
+
186
+ case '\u001b': // Escape
187
+ case 'q':
188
+ case 'Q':
189
+ safeResolve(-1);
190
+ break;
191
+
192
+ default:
193
+ break;
194
+ }
195
+ } catch (error) {
196
+ safeReject(error);
168
197
  }
169
198
  };
170
199
 
171
- process.stdin.on('data', handleKeyPress);
200
+ scope.on('data', handleKeyPress);
172
201
  } else {
173
- // Fallback for non-TTY environments
174
- const rl = readline.createInterface({
175
- input: process.stdin,
176
- output: process.stdout
202
+ // Non-TTY fallback: use StdinManager for line-based input
203
+ const lineScope = stdinManager.acquire('line', {
204
+ id: 'menu_navigate_nonTTY',
205
+ allowNested: false
177
206
  });
178
207
 
208
+ const rl = lineScope.createReadline();
209
+
179
210
  console.log(colors.yellow + ' ' + i18n.tSync('navigation.arrow_keys_not_available', this.menuOptions.length) + colors.reset);
180
211
 
181
212
  rl.on('line', (input) => {
182
213
  const choice = parseInt(input.trim());
183
214
  if (choice >= 1 && choice <= this.menuOptions.length) {
184
215
  rl.close();
216
+ lineScope.release();
185
217
  resolve(choice - 1);
186
218
  } else if (input.toLowerCase() === 'q' || input.toLowerCase() === 'exit') {
187
219
  rl.close();
220
+ lineScope.release();
188
221
  resolve(-1);
189
222
  } else {
190
223
  console.log(colors.red + ' Invalid selection. Please enter 1-' + this.menuOptions.length + '.' + colors.reset);
@@ -194,17 +227,18 @@ class Menu {
194
227
  });
195
228
  }
196
229
 
197
- /**
198
- * Display a list for selection
199
- */
200
230
  async selectFromList(title, items, activeIndex = -1) {
201
- // Force cleanup stdin state before any list selection
231
+ // Guard against empty items list to prevent NaN from modulo operations
232
+ if (!items || items.length === 0) {
233
+ console.log(colors.yellow + ' Warning: No items available to select' + colors.reset);
234
+ return null; // Return null to indicate no selection
235
+ }
236
+
202
237
  forceCleanupBeforeMenu();
203
238
 
204
239
  let selectedIndex = activeIndex >= 0 ? activeIndex : 0;
205
- let ctrlCCount = 0;
206
240
 
207
- function displayList() {
241
+ const displayList = () => {
208
242
  console.clear();
209
243
  console.log('');
210
244
  console.log(colors.bright + colors.orange + `[*] ${title}` + colors.reset);
@@ -218,7 +252,6 @@ class Menu {
218
252
  const activeIndicator = isActive ? ' (ACTIVE)' : '';
219
253
 
220
254
  if (index === selectedIndex) {
221
- // Pad the selected item to ensure complete background coverage
222
255
  const itemText = `${item.name}${activeIndicator}`;
223
256
  const paddedItem = padStringToWidth(itemText, Math.max(40, itemText.length + 2));
224
257
  console.log(colors.orange + prefix + colors.black + colors.bgAmber + paddedItem + colors.reset);
@@ -233,82 +266,118 @@ class Menu {
233
266
  });
234
267
 
235
268
  console.log('');
236
- }
269
+ };
237
270
 
238
- return new Promise((resolve) => {
271
+ return new Promise((resolve, reject) => {
239
272
  displayList();
240
273
 
241
274
  if (process.stdin.isTTY) {
242
- process.stdin.setRawMode(true);
243
- process.stdin.resume();
244
- process.stdin.setEncoding('utf8');
275
+ const scope = stdinManager.acquire('raw', {
276
+ id: 'menu_selectFromList',
277
+ allowNested: true
278
+ });
279
+
280
+ let resolved = false;
281
+ let cleanedUp = false;
282
+
283
+ const cleanup = () => {
284
+ if (cleanedUp) return;
285
+ cleanedUp = true;
286
+ try {
287
+ scope.removeListener('data', handleKeyPress);
288
+ scope.release();
289
+ } catch (error) {
290
+ // Ignore cleanup errors to prevent masking original error
291
+ }
292
+ };
293
+
294
+ const safeResolve = (value) => {
295
+ if (resolved) return;
296
+ resolved = true;
297
+ cleanup();
298
+ resolve(value);
299
+ };
300
+
301
+ const safeReject = (error) => {
302
+ if (resolved) return;
303
+ resolved = true;
304
+ cleanup();
305
+ reject(error);
306
+ };
245
307
 
246
308
  const handleKeyPress = (key) => {
247
- switch (key) {
248
- case '\u0003': // Ctrl+C - 2-step exit
249
- ctrlCCount++;
250
- if (ctrlCCount === 1) {
251
- console.log('');
252
- console.log(colors.yellow + '⚠️ ' + i18n.tSync('messages.prompts.ctrl_c_again') + colors.reset);
253
- setTimeout(() => {
254
- ctrlCCount = 0; // Reset after 3 seconds
255
- }, 3000);
256
- } else if (ctrlCCount >= 2) {
257
- process.stdin.removeListener('data', handleKeyPress);
258
- if (process.stdin.isTTY) {
259
- process.stdin.setRawMode(false);
260
- }
261
- console.log('');
262
- console.log(colors.green + '👋 Goodbye!' + colors.reset);
263
- process.exit(0);
264
- }
265
- break;
266
-
267
- case '\u001b[A': // Up arrow
268
- ctrlCCount = 0; // Reset Ctrl+C count on other keys
269
- selectedIndex = (selectedIndex - 1 + items.length) % items.length;
270
- displayList();
271
- break;
272
-
273
- case '\u001b[B': // Down arrow
274
- ctrlCCount = 0; // Reset Ctrl+C count on other keys
275
- selectedIndex = (selectedIndex + 1) % items.length;
276
- displayList();
277
- break;
278
-
279
- case '\r': // Enter
280
- process.stdin.removeListener('data', handleKeyPress);
281
- if (process.stdin.isTTY) {
282
- process.stdin.setRawMode(false);
283
- }
284
- process.stdin.pause();
285
- resolve(selectedIndex);
286
- break;
287
-
288
- case '\u001b': // Escape
289
- case 'q':
290
- case 'Q':
291
- process.stdin.removeListener('data', handleKeyPress);
292
- if (process.stdin.isTTY) {
293
- process.stdin.setRawMode(false);
294
- }
295
- process.stdin.pause();
296
- resolve(null);
297
- break;
298
-
299
- default:
300
- // Any other key resets Ctrl+C count
301
- ctrlCCount = 0;
302
- break;
309
+ try {
310
+ // Handle Ctrl+C first
311
+ if (key === '\u0003') {
312
+ stdinManager.handleCtrlC();
313
+ return; // Don't process further
314
+ }
315
+
316
+ // If waiting for second Ctrl+C, any other key cancels it
317
+ if (stdinManager.isCtrlCPending()) {
318
+ stdinManager.cancelCtrlC();
319
+ // Continue to process this key normally
320
+ }
321
+
322
+ switch (key) {
323
+ case '\u001b[A': // Up arrow
324
+ selectedIndex = (selectedIndex - 1 + items.length) % items.length;
325
+ displayList();
326
+ break;
327
+
328
+ case '\u001b[B': // Down arrow
329
+ selectedIndex = (selectedIndex + 1) % items.length;
330
+ displayList();
331
+ break;
332
+
333
+ case '\r': // Enter
334
+ safeResolve(selectedIndex);
335
+ break;
336
+
337
+ case '\u001b': // Escape
338
+ case 'q':
339
+ case 'Q':
340
+ safeResolve(null);
341
+ break;
342
+
343
+ default:
344
+ break;
345
+ }
346
+ } catch (error) {
347
+ safeReject(error);
303
348
  }
304
349
  };
305
350
 
306
- process.stdin.on('data', handleKeyPress);
351
+ scope.on('data', handleKeyPress);
307
352
  } else {
308
- resolve(null);
353
+ // Non-TTY fallback: use line-based input
354
+ const lineScope = stdinManager.acquire('line', {
355
+ id: 'menu_selectFromList_nonTTY',
356
+ allowNested: false
357
+ });
358
+
359
+ const rl = lineScope.createReadline();
360
+
361
+ console.log(colors.yellow + ' Arrow keys not available. Enter item number (1-' + items.length + ') or q to cancel:' + colors.reset);
362
+
363
+ rl.on('line', (input) => {
364
+ const choice = parseInt(input.trim());
365
+ if (choice >= 1 && choice <= items.length) {
366
+ rl.close();
367
+ lineScope.release();
368
+ resolve(choice - 1);
369
+ } else if (input.toLowerCase() === 'q') {
370
+ rl.close();
371
+ lineScope.release();
372
+ resolve(null);
373
+ } else {
374
+ console.log(colors.red + ' Invalid selection. Please enter 1-' + items.length + '.' + colors.reset);
375
+ }
376
+ });
309
377
  }
310
378
  });
311
379
  }
380
+
312
381
  }
313
382
 
314
- module.exports = Menu;
383
+ module.exports = Menu;