inup 1.5.2 → 1.5.5

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.
@@ -45,8 +45,8 @@ class PackageDetector {
45
45
  constructor(options) {
46
46
  this.packageJsonPath = null;
47
47
  this.packageJson = null;
48
- this.batchSize = 25;
49
- this.batchConcurrency = 25;
48
+ this.batchSize = 10;
49
+ this.maxConcurrency = 10;
50
50
  this.cwd = options?.cwd || process.cwd();
51
51
  this.excludePatterns = options?.excludePatterns || [];
52
52
  this.ignorePackages = options?.ignorePackages || [];
@@ -95,45 +95,47 @@ class PackageDetector {
95
95
  let lastBatchEndAt = Date.now();
96
96
  const tFetch = Date.now();
97
97
  utils_3.debugLog.info('PackageDetector', 'fetching version data via npm registry in batches');
98
- await (0, services_1.getAllPackageDataBatched)(prepared.uniquePackages, (batch) => {
99
- const batchStart = lastBatchEndAt;
100
- let batchFailedCount = 0;
101
- const batchItems = batch.map((batchItem) => {
102
- const packageInfo = this.resolvePackageGroup(batchItem.packageName, prepared.allDependencies, batchItem.data);
103
- packageLookup.set(batchItem.packageName, packageInfo);
104
- resolved++;
105
- const isFailed = batchItem.data.latestVersion === 'unknown';
106
- if (isFailed) {
107
- failed++;
108
- batchFailedCount++;
109
- performanceTracker.recordFailedPackage(batchItem.packageName);
110
- }
111
- return {
112
- packageName: batchItem.packageName,
113
- packageInfo,
114
- failed: isFailed,
115
- };
116
- });
117
- const batchEnd = Date.now();
118
- performanceTracker.recordBatch({
119
- index: batchIndex++,
120
- size: batch.length,
121
- durationMs: batchEnd - batchStart,
122
- failedCount: batchFailedCount,
123
- });
124
- lastBatchEndAt = batchEnd;
125
- performanceTracker.recordCounts({ resolved, failed });
126
- const progress = this.createProgressSnapshot(prepared.uniquePackages.length, resolved, failed, resolved < prepared.uniquePackages.length);
127
- onEvent({
128
- type: 'batch',
129
- payload: {
130
- batch: batchItems,
131
- progress,
132
- },
133
- });
134
- }, prepared.currentVersions, {
98
+ await (0, services_1.fetchPackageVersions)(prepared.uniquePackages, {
99
+ currentVersions: prepared.currentVersions,
135
100
  batchSize: this.batchSize,
136
- concurrency: this.batchConcurrency,
101
+ maxConcurrency: this.maxConcurrency,
102
+ onBatchReady: (batch) => {
103
+ const batchStart = lastBatchEndAt;
104
+ let batchFailedCount = 0;
105
+ const batchItems = batch.map((batchItem) => {
106
+ const packageInfo = this.resolvePackageGroup(batchItem.packageName, prepared.allDependencies, batchItem.data);
107
+ packageLookup.set(batchItem.packageName, packageInfo);
108
+ resolved++;
109
+ const isFailed = batchItem.data.latestVersion === 'unknown';
110
+ if (isFailed) {
111
+ failed++;
112
+ batchFailedCount++;
113
+ performanceTracker.recordFailedPackage(batchItem.packageName);
114
+ }
115
+ return {
116
+ packageName: batchItem.packageName,
117
+ packageInfo,
118
+ failed: isFailed,
119
+ };
120
+ });
121
+ const batchEnd = Date.now();
122
+ performanceTracker.recordBatch({
123
+ index: batchIndex++,
124
+ size: batch.length,
125
+ durationMs: batchEnd - batchStart,
126
+ failedCount: batchFailedCount,
127
+ });
128
+ lastBatchEndAt = batchEnd;
129
+ performanceTracker.recordCounts({ resolved, failed });
130
+ const progress = this.createProgressSnapshot(prepared.uniquePackages.length, resolved, failed, resolved < prepared.uniquePackages.length);
131
+ onEvent({
132
+ type: 'batch',
133
+ payload: {
134
+ batch: batchItems,
135
+ progress,
136
+ },
137
+ });
138
+ },
137
139
  });
138
140
  utils_3.debugLog.perf('PackageDetector', `registry fetch (${resolved}/${prepared.uniquePackages.length} resolved)`, tFetch);
139
141
  performanceTracker.recordPhaseDuration('registryFetch', Date.now() - tFetch);
@@ -348,6 +348,11 @@ class InteractiveUI {
348
348
  return;
349
349
  }
350
350
  break;
351
+ case 'switch_info_modal_tab': {
352
+ const nextTab = stateManager.getInfoModalTab() === 'info' ? 'usedBy' : 'info';
353
+ stateManager.setInfoModalTab(nextTab);
354
+ break;
355
+ }
351
356
  case 'navigate_info_modal_version':
352
357
  {
353
358
  if (uiState.infoModalRow >= 0 && uiState.infoModalRow < filteredStates.length) {
@@ -465,8 +470,18 @@ class InteractiveUI {
465
470
  }
466
471
  process.stdout.write(lines.map((line) => applyBackgroundToLine(line, bgCode)).join('\n'));
467
472
  };
473
+ const key = (text) => chalk_1.default.bold.white(text);
474
+ const hint = (text) => chalk_1.default.gray(text);
475
+ const sep = hint(' · ');
476
+ const SHORTCUTS = {
477
+ scroll: key('↑/↓ ') + hint('Scroll'),
478
+ version: key('←/→ ') + hint('Version'),
479
+ switchTab: key('Tab ') + hint('Switch tab'),
480
+ closeInfo: key('I / Esc ') + hint('Close'),
481
+ closeTheme: key('T / Esc ') + hint('Close'),
482
+ };
468
483
  const buildModalHeaderLines = (shortcutLabel) => [
469
- ' ' + chalk_1.default.bold.magenta('🚀 inup'),
484
+ ' ' + chalk_1.default.bold('🚀 ') + (0, themes_colors_1.coloredInupLogo)(),
470
485
  '',
471
486
  ' ' + shortcutLabel,
472
487
  '',
@@ -498,11 +513,24 @@ class InteractiveUI {
498
513
  process.off('SIGWINCH', handleResize);
499
514
  this.refreshView = undefined;
500
515
  };
516
+ // Safety net: restore terminal if the process exits without going through finalizeSelection.
517
+ // Only synchronous writes work in an 'exit' handler, but that's all we need here.
518
+ const emergencyCleanup = () => {
519
+ if (ownsAlternateScreen) {
520
+ process.stdout.write('\x1b[?1049l');
521
+ }
522
+ process.stdout.write('\x1b[?25h');
523
+ if (process.stdin.setRawMode) {
524
+ process.stdin.setRawMode(false);
525
+ }
526
+ };
527
+ process.on('exit', emergencyCleanup);
501
528
  const finalizeSelection = (selectedStates) => {
502
529
  isResolved = true;
503
530
  this.packageInfoModalController.cancel();
504
531
  releaseInteractiveScreen();
505
532
  cleanupInteractiveSession();
533
+ process.off('exit', emergencyCleanup);
506
534
  resolve(selectedStates);
507
535
  };
508
536
  const renderInterface = () => {
@@ -525,7 +553,7 @@ class InteractiveUI {
525
553
  const terminalHeight = this.getTerminalHeight();
526
554
  const themeManager = stateManager.getThemeManager();
527
555
  const modalLines = this.renderer.renderThemeSelectorModal(themeManager.getCurrentTheme(), themeManager.getPreviewTheme(), terminalWidth, terminalHeight);
528
- renderModalViewport('theme-modal', chalk_1.default.bold.white('T ') + chalk_1.default.gray('/ Esc Exit theme selector'), modalLines, terminalWidth, terminalHeight, bgCode);
556
+ renderModalViewport('theme-modal', SHORTCUTS.closeTheme, modalLines, terminalWidth, terminalHeight, bgCode);
529
557
  }
530
558
  else if (uiState.showDebugModal) {
531
559
  const terminalWidth = process.stdout.columns || 80;
@@ -534,10 +562,11 @@ class InteractiveUI {
534
562
  const result = (0, debug_1.renderPerformanceModal)(snapshot, terminalWidth, Math.max(8, terminalHeight - 4), uiState.debugModalScrollOffset);
535
563
  debugModalMaxScrollOffset = result.maxScrollOffset;
536
564
  stateManager.clampDebugModalScrollOffset(debugModalMaxScrollOffset);
537
- const scrollHint = result.usesInternalScroll && result.maxScrollOffset > 0
538
- ? chalk_1.default.bold.white('↑/↓ ') + chalk_1.default.gray('Scroll · ')
539
- : '';
540
- renderModalViewport('info-modal', scrollHint + chalk_1.default.bold.white('! / Esc ') + chalk_1.default.gray('Close'), result.lines, terminalWidth, terminalHeight, bgCode);
565
+ const debugHints = [
566
+ result.usesInternalScroll && result.maxScrollOffset > 0 ? SHORTCUTS.scroll : '',
567
+ SHORTCUTS.closeInfo,
568
+ ].filter(Boolean).join(sep);
569
+ renderModalViewport('info-modal', debugHints, result.lines, terminalWidth, terminalHeight, bgCode);
541
570
  }
542
571
  else if (uiState.showInfoModal &&
543
572
  uiState.infoModalRow >= 0 &&
@@ -549,21 +578,21 @@ class InteractiveUI {
549
578
  // Show loading state
550
579
  const result = this.renderer.renderPackageInfoLoading(selectedState, terminalWidth, Math.max(8, terminalHeight - 4));
551
580
  infoModalMaxScrollOffset = result.maxScrollOffset;
552
- renderModalViewport('info-modal', chalk_1.default.bold.white('I / Esc ') + chalk_1.default.gray('Exit this view'), result.lines, terminalWidth, terminalHeight, bgCode);
581
+ renderModalViewport('info-modal', SHORTCUTS.closeInfo, result.lines, terminalWidth, terminalHeight, bgCode);
553
582
  }
554
583
  else {
555
584
  // Show full info with scroll support
556
- const result = this.renderer.renderPackageInfoModal(selectedState, terminalWidth, Math.max(8, terminalHeight - 4), uiState.infoModalScrollOffset);
585
+ const activeTab = uiState.infoModalTab;
586
+ const result = this.renderer.renderPackageInfoModal(selectedState, terminalWidth, Math.max(8, terminalHeight - 4), uiState.infoModalScrollOffset, activeTab);
557
587
  infoModalMaxScrollOffset = result.maxScrollOffset;
558
588
  stateManager.clampInfoModalScrollOffset(infoModalMaxScrollOffset);
559
- const scrollHint = result.usesInternalScroll && result.maxScrollOffset > 0
560
- ? chalk_1.default.bold.white('↑/↓ ') + chalk_1.default.gray('Scroll · ')
561
- : '';
562
- renderModalViewport('info-modal', scrollHint +
563
- chalk_1.default.bold.white('←/→ ') +
564
- chalk_1.default.gray('Version · ') +
565
- chalk_1.default.bold.white('I / Esc ') +
566
- chalk_1.default.gray('Exit this view'), result.lines, terminalWidth, terminalHeight, bgCode);
589
+ const hints = [
590
+ result.usesInternalScroll && result.maxScrollOffset > 0 ? SHORTCUTS.scroll : '',
591
+ activeTab === 'info' ? SHORTCUTS.version : '',
592
+ SHORTCUTS.switchTab,
593
+ SHORTCUTS.closeInfo,
594
+ ].filter(Boolean).join(sep);
595
+ renderModalViewport('info-modal', hints, result.lines, terminalWidth, terminalHeight, bgCode);
567
596
  }
568
597
  }
569
598
  else {
@@ -621,6 +650,7 @@ class InteractiveUI {
621
650
  this.enqueueSecurityAudit(states);
622
651
  }
623
652
  catch (error) {
653
+ process.off('exit', emergencyCleanup);
624
654
  releaseInteractiveScreen();
625
655
  // Reset terminal colors
626
656
  process.stdout.write((0, themes_colors_1.getTerminalResetCode)());
@@ -641,7 +671,22 @@ class InteractiveUI {
641
671
  cleanupConfirmationSession();
642
672
  resolve(confirmed);
643
673
  };
644
- const inputHandler = new ui_1.ConfirmationInputHandler(handleConfirm);
674
+ // Safety net for the same reason as selectPackages — synchronous restore on exit.
675
+ // The confirmation screen does not enter the alternate screen, so alt-screen
676
+ // restoration is intentionally omitted here. If that ever changes, mirror the
677
+ // ownsAlternateScreen-gated pattern from selectPackages.
678
+ const confirmEmergencyCleanup = () => {
679
+ process.stdout.write('\x1b[?25h');
680
+ if (process.stdin.setRawMode) {
681
+ process.stdin.setRawMode(false);
682
+ }
683
+ };
684
+ process.on('exit', confirmEmergencyCleanup);
685
+ const handleConfirmWithCleanup = (confirmed) => {
686
+ handleConfirm(confirmed);
687
+ process.off('exit', confirmEmergencyCleanup);
688
+ };
689
+ const inputHandler = new ui_1.ConfirmationInputHandler(handleConfirmWithCleanup);
645
690
  const keypressHandler = (str, key) => inputHandler.handleKeypress(str, key);
646
691
  // Setup keypress handling
647
692
  try {
@@ -653,6 +698,7 @@ class InteractiveUI {
653
698
  ui_1.CursorUtils.hide();
654
699
  }
655
700
  catch (error) {
701
+ process.off('exit', confirmEmergencyCleanup);
656
702
  ui_1.TerminalInput.promptForConfirmation('Proceed with upgrade? [Y/n] ')
657
703
  .then(resolve)
658
704
  .catch(() => resolve(false));
@@ -33,8 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.getAllPackageData = getAllPackageData;
37
- exports.getAllPackageDataBatched = getAllPackageDataBatched;
36
+ exports.fetchPackageVersions = fetchPackageVersions;
38
37
  exports.clearPackageCache = clearPackageCache;
39
38
  exports.closeRegistryPool = closeRegistryPool;
40
39
  const semver = __importStar(require("semver"));
@@ -46,19 +45,25 @@ const inflateAsync = (0, node_util_1.promisify)(node_zlib_1.inflate);
46
45
  const brotliDecompressAsync = (0, node_util_1.promisify)(node_zlib_1.brotliDecompress);
47
46
  const config_1 = require("../config");
48
47
  const jsdelivr_registry_1 = require("./jsdelivr-registry");
49
- const utils_1 = require("../ui/utils");
50
48
  const inFlightLookups = new Map();
51
49
  const registryOrigin = new URL(config_1.NPM_REGISTRY_URL).origin;
52
50
  const registryPathPrefix = new URL(config_1.NPM_REGISTRY_URL).pathname.replace(/\/$/, '');
51
+ // Few connections + many requests per connection = maximum keep-alive reuse.
52
+ // No per-request timeouts: correctness matters more than speed for a CLI that
53
+ // runs on demand. Slow responses are tolerated; only true errors cause retry.
53
54
  const registryPool = new undici_1.Pool(registryOrigin, {
54
- connections: 64,
55
- pipelining: 10,
55
+ connections: 6,
56
+ pipelining: 1,
56
57
  keepAliveTimeout: 30000,
57
58
  keepAliveMaxTimeout: 600000,
58
- headersTimeout: config_1.REQUEST_TIMEOUT,
59
- bodyTimeout: config_1.REQUEST_TIMEOUT,
59
+ headersTimeout: 0,
60
+ bodyTimeout: 0,
61
+ connectTimeout: 15000,
60
62
  allowH2: false,
61
63
  });
64
+ const MAX_REGISTRY_ATTEMPTS = 3;
65
+ const RETRY_BACKOFF_MS = [500, 1500, 3000];
66
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
62
67
  const isRetryableStatus = (statusCode) => statusCode === 408 || statusCode === 429 || statusCode >= 500;
63
68
  const isTransientNetworkError = (error) => {
64
69
  if (!(error instanceof Error)) {
@@ -104,12 +109,14 @@ function parseVersions(raw) {
104
109
  const latestVersion = sortedVersions.length > 0 ? sortedVersions[0] : 'unknown';
105
110
  return { latestVersion, allVersions };
106
111
  }
107
- async function fetchPackageFromRegistryWithFallback(packageName, currentVersion) {
112
+ const encodeRegistryPath = (packageName) => {
113
+ const encodedName = packageName.startsWith('@')
114
+ ? `@${encodeURIComponent(packageName.slice(1).split('/')[0])}/${encodeURIComponent(packageName.slice(packageName.indexOf('/') + 1))}`
115
+ : encodeURIComponent(packageName);
116
+ return `${registryPathPrefix}/${encodedName}`;
117
+ };
118
+ async function attemptRegistryFetch(path) {
108
119
  try {
109
- const encodedName = packageName.startsWith('@')
110
- ? `@${encodeURIComponent(packageName.slice(1).split('/')[0])}/${encodeURIComponent(packageName.slice(packageName.indexOf('/') + 1))}`
111
- : encodeURIComponent(packageName);
112
- const path = `${registryPathPrefix}/${encodedName}`;
113
120
  const { statusCode, headers, body } = await registryPool.request({
114
121
  path,
115
122
  method: 'GET',
@@ -117,16 +124,16 @@ async function fetchPackageFromRegistryWithFallback(packageName, currentVersion)
117
124
  accept: 'application/vnd.npm.install-v1+json',
118
125
  'accept-encoding': 'gzip, deflate, br',
119
126
  },
120
- headersTimeout: config_1.REQUEST_TIMEOUT,
121
- bodyTimeout: config_1.REQUEST_TIMEOUT,
127
+ headersTimeout: 0,
128
+ bodyTimeout: 0,
122
129
  blocking: false,
123
130
  });
124
131
  if (statusCode < 200 || statusCode >= 300) {
125
- await body.dump();
132
+ await body.dump().catch(() => undefined);
126
133
  if (isRetryableStatus(statusCode)) {
127
- return await fetchFromJsdelivrFallback(packageName, currentVersion);
134
+ return { kind: 'retryable' };
128
135
  }
129
- return { latestVersion: 'unknown', allVersions: [] };
136
+ return { kind: 'not-found' };
130
137
  }
131
138
  const raw = Buffer.from(await body.arrayBuffer());
132
139
  const encodingHeader = headers['content-encoding'];
@@ -146,97 +153,128 @@ async function fetchPackageFromRegistryWithFallback(packageName, currentVersion)
146
153
  else {
147
154
  decoded = raw;
148
155
  }
149
- return parseVersions(decoded.toString('utf8'));
156
+ return { kind: 'success', data: parseVersions(decoded.toString('utf8')) };
150
157
  }
151
158
  catch (error) {
152
159
  if (isTransientNetworkError(error)) {
153
- return await fetchFromJsdelivrFallback(packageName, currentVersion);
160
+ return { kind: 'transient' };
154
161
  }
155
- return { latestVersion: 'unknown', allVersions: [] };
162
+ // Unknown error: treat as transient so we try the fallback rather than
163
+ // silently returning 'unknown'.
164
+ return { kind: 'transient' };
156
165
  }
157
166
  }
158
- /**
159
- * Fetches package version data from npm registry for multiple packages.
160
- * Uses an undici Pool with HTTP/1.1 pipelining for high throughput.
161
- * Only returns valid semantic versions (X.Y.Z format, excluding pre-releases).
162
- */
163
- async function getAllPackageData(packageNames, onProgress, currentVersions) {
164
- const packageData = new Map();
165
- if (packageNames.length === 0) {
166
- return packageData;
167
- }
168
- const total = packageNames.length;
169
- let completedCount = 0;
170
- const allPromises = packageNames.map(async (packageName) => {
171
- const data = await getFreshPackageData(packageName, currentVersions?.get(packageName));
172
- packageData.set(packageName, data);
173
- completedCount++;
174
- if (onProgress) {
175
- onProgress(packageName, completedCount, total);
167
+ async function fetchFromRegistryWithRetries(path) {
168
+ let lastOutcome = { kind: 'transient' };
169
+ for (let attempt = 0; attempt < MAX_REGISTRY_ATTEMPTS; attempt++) {
170
+ const outcome = await attemptRegistryFetch(path);
171
+ if (outcome.kind === 'success' || outcome.kind === 'not-found') {
172
+ return outcome;
173
+ }
174
+ lastOutcome = outcome;
175
+ if (attempt < MAX_REGISTRY_ATTEMPTS - 1) {
176
+ const backoff = RETRY_BACKOFF_MS[Math.min(attempt, RETRY_BACKOFF_MS.length - 1)];
177
+ await sleep(backoff);
176
178
  }
177
- });
178
- await Promise.all(allPromises);
179
- if (!onProgress) {
180
- utils_1.ConsoleUtils.clearProgress();
181
179
  }
182
- return packageData;
180
+ return lastOutcome;
183
181
  }
184
- async function runWithConcurrencyLimit(items, concurrency, worker) {
185
- if (items.length === 0) {
186
- return;
182
+ async function fetchPackageFromRegistryWithFallback(packageName, currentVersion) {
183
+ const path = encodeRegistryPath(packageName);
184
+ const outcome = await fetchFromRegistryWithRetries(path);
185
+ if (outcome.kind === 'success') {
186
+ return outcome.data;
187
187
  }
188
- const limit = Math.max(1, Math.min(concurrency, items.length));
189
- let nextIndex = 0;
190
- const runWorker = async () => {
191
- while (nextIndex < items.length) {
192
- const currentIndex = nextIndex++;
193
- await worker(items[currentIndex], currentIndex);
194
- }
195
- };
196
- await Promise.all(Array.from({ length: limit }, () => runWorker()));
188
+ if (outcome.kind === 'not-found') {
189
+ return { latestVersion: 'unknown', allVersions: [] };
190
+ }
191
+ // Only reach here after exhausted retries against real errors — try jsdelivr
192
+ // as last-resort safety net so we don't silently return 'unknown'.
193
+ const fallback = await fetchFromJsdelivrFallback(packageName, currentVersion).catch(() => null);
194
+ return fallback ?? { latestVersion: 'unknown', allVersions: [] };
197
195
  }
198
- async function getAllPackageDataBatched(packageNames, onBatchReady, currentVersions, options = {}) {
196
+ /**
197
+ * Fetches version data for a list of packages from the npm registry.
198
+ *
199
+ * Concurrency model:
200
+ * - `maxConcurrency` is a global cap on in-flight fetches at any moment.
201
+ * It doesn't interact with batch size — batches exist only to group
202
+ * emissions for the UI.
203
+ * - No per-request timeouts: slow responses are allowed to finish. Real
204
+ * network errors are retried with exponential backoff; after that, we
205
+ * fall back to jsdelivr as a last resort. A result is never silently
206
+ * dropped due to slowness.
207
+ *
208
+ * Callbacks:
209
+ * - `onBatchReady` fires once a whole emission batch has resolved, in
210
+ * original batch order.
211
+ */
212
+ async function fetchPackageVersions(packageNames, options = {}) {
199
213
  const packageData = new Map();
200
214
  if (packageNames.length === 0) {
201
215
  return packageData;
202
216
  }
203
217
  const batchSizes = options.batchSizes && options.batchSizes.length > 0
204
218
  ? options.batchSizes.map((size) => Math.max(1, size))
205
- : [Math.max(1, options.batchSize ?? 20)];
206
- const concurrency = Math.max(1, options.concurrency ?? 5);
219
+ : [Math.max(1, options.batchSize ?? 25)];
220
+ const maxConcurrency = Math.max(1, options.maxConcurrency ?? 10);
207
221
  const total = packageNames.length;
208
222
  let completedCount = 0;
209
- let batchStart = 0;
210
- let batchIndex = 0;
211
- const batchPromises = [];
212
223
  const pendingEmissions = new Map();
213
224
  let nextEmitIndex = 0;
214
225
  const flushPending = () => {
215
226
  while (pendingEmissions.has(nextEmitIndex)) {
216
227
  const ready = pendingEmissions.get(nextEmitIndex);
217
228
  pendingEmissions.delete(nextEmitIndex);
218
- onBatchReady?.(ready);
229
+ options.onBatchReady?.(ready);
219
230
  nextEmitIndex++;
220
231
  }
221
232
  };
233
+ // Global semaphore: `maxConcurrency` is the total in-flight cap across
234
+ // all batches. Batches don't gate concurrency — only emission order.
235
+ let inFlight = 0;
236
+ const waiters = [];
237
+ const acquire = async () => {
238
+ if (inFlight < maxConcurrency) {
239
+ inFlight++;
240
+ return;
241
+ }
242
+ await new Promise((resolve) => waiters.push(resolve));
243
+ inFlight++;
244
+ };
245
+ const release = () => {
246
+ inFlight--;
247
+ const next = waiters.shift();
248
+ if (next)
249
+ next();
250
+ };
251
+ const batchPromises = [];
252
+ let batchStart = 0;
253
+ let batchIndex = 0;
222
254
  while (batchStart < packageNames.length) {
223
255
  const batchSize = batchSizes[Math.min(batchIndex, batchSizes.length - 1)];
224
256
  const batchNames = packageNames.slice(batchStart, batchStart + batchSize);
225
257
  const capturedBatchIndex = batchIndex;
226
258
  const batchResults = new Array(batchNames.length);
227
- const batchPromise = runWithConcurrencyLimit(batchNames, concurrency, async (packageName, itemIndex) => {
228
- const data = await getFreshPackageData(packageName, currentVersions?.get(packageName));
229
- packageData.set(packageName, data);
230
- completedCount++;
231
- batchResults[itemIndex] = {
232
- packageName,
233
- data,
234
- completed: completedCount,
235
- total,
236
- batchIndex: capturedBatchIndex,
237
- itemIndex,
238
- };
239
- }).then(() => {
259
+ const batchPromise = Promise.all(batchNames.map(async (packageName, itemIndex) => {
260
+ await acquire();
261
+ try {
262
+ const data = await getFreshPackageData(packageName, options.currentVersions?.get(packageName));
263
+ packageData.set(packageName, data);
264
+ completedCount++;
265
+ batchResults[itemIndex] = {
266
+ packageName,
267
+ data,
268
+ completed: completedCount,
269
+ total,
270
+ batchIndex: capturedBatchIndex,
271
+ itemIndex,
272
+ };
273
+ }
274
+ finally {
275
+ release();
276
+ }
277
+ })).then(() => {
240
278
  pendingEmissions.set(capturedBatchIndex, batchResults.filter(Boolean));
241
279
  flushPending();
242
280
  });
@@ -15,7 +15,7 @@ class InputHandler {
15
15
  return;
16
16
  }
17
17
  if (key && key.ctrl && key.name === 'c') {
18
- utils_1.CursorUtils.show();
18
+ this.onCancel();
19
19
  process.exit(0);
20
20
  }
21
21
  const uiState = this.stateManager.getUIState();
@@ -81,6 +81,9 @@ class InputHandler {
81
81
  case 'I':
82
82
  this.onAction({ type: 'toggle_info_modal' });
83
83
  return;
84
+ case 'tab':
85
+ this.onAction({ type: 'switch_info_modal_tab' });
86
+ return;
84
87
  case 'up':
85
88
  this.onAction({ type: 'scroll_info_modal_up' });
86
89
  return;
@@ -88,10 +91,14 @@ class InputHandler {
88
91
  this.onAction({ type: 'scroll_info_modal_down' });
89
92
  return;
90
93
  case 'left':
91
- this.onAction({ type: 'navigate_info_modal_version', direction: 'newer' });
94
+ if (uiState.infoModalTab === 'info') {
95
+ this.onAction({ type: 'navigate_info_modal_version', direction: 'newer' });
96
+ }
92
97
  return;
93
98
  case 'right':
94
- this.onAction({ type: 'navigate_info_modal_version', direction: 'older' });
99
+ if (uiState.infoModalTab === 'info') {
100
+ this.onAction({ type: 'navigate_info_modal_version', direction: 'older' });
101
+ }
95
102
  return;
96
103
  default:
97
104
  return; // Consume all other keys while modal is open
@@ -260,7 +267,9 @@ class ConfirmationInputHandler {
260
267
  return;
261
268
  }
262
269
  if (key && key.ctrl && key.name === 'c') {
263
- utils_1.CursorUtils.show();
270
+ // onConfirm runs the normal cleanup path (cursor show, raw-mode off, listener
271
+ // removal). The 'exit' listener registered by confirmUpgrade is a final backstop.
272
+ this.onConfirm(false);
264
273
  process.exit(0);
265
274
  }
266
275
  if (!key) {
@@ -4,7 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.buildReleaseNotesSections = buildReleaseNotesSections;
7
+ exports.buildUsedBySections = buildUsedBySections;
7
8
  exports.buildPackageInfoSections = buildPackageInfoSections;
9
+ const node_path_1 = __importDefault(require("node:path"));
8
10
  const chalk_1 = __importDefault(require("chalk"));
9
11
  const themes_colors_1 = require("../themes-colors");
10
12
  const vulnerability_1 = require("../presenters/vulnerability");
@@ -240,8 +242,48 @@ function formatNumber(num) {
240
242
  return (num / 1000).toFixed(1) + 'K';
241
243
  return num.toString();
242
244
  }
243
- function buildPackageInfoSections(state, modalWidth) {
244
- const title = chalk_1.default.cyan.bold(`Package: ${state.name}`);
245
+ function getUsedByPaths(state) {
246
+ return state.packageJsonPaths ?? [state.packageJsonPath];
247
+ }
248
+ function buildTabBarSuffix(activeTab, usedByCount) {
249
+ const styleFor = (tab) => tab === activeTab ? chalk_1.default.bold.underline : chalk_1.default.gray;
250
+ const usedByLabel = `Used by${usedByCount > 0 ? ` (${usedByCount})` : ''}`;
251
+ return (' ' +
252
+ chalk_1.default.gray('[ ') +
253
+ styleFor('info')('Info') +
254
+ chalk_1.default.gray(' │ ') +
255
+ styleFor('usedBy')(usedByLabel) +
256
+ chalk_1.default.gray(' ]'));
257
+ }
258
+ function buildUsedBySections(state, modalWidth) {
259
+ const paths = getUsedByPaths(state);
260
+ const cwd = process.cwd();
261
+ const contentWidth = Math.max(10, modalWidth - 4);
262
+ const formatRelative = (absolutePath) => {
263
+ const display = node_path_1.default.relative(cwd, absolutePath) || absolutePath;
264
+ return (0, utils_1.truncatePlainText)(display, contentWidth);
265
+ };
266
+ return [
267
+ {
268
+ key: 'used-by-summary',
269
+ rows: [
270
+ chalk_1.default.bold(`${paths.length} package.json file${paths.length === 1 ? '' : 's'} depend on ${state.name}`),
271
+ chalk_1.default.gray(`Type: ${state.type}`),
272
+ ],
273
+ required: true,
274
+ behavior: 'pinned',
275
+ },
276
+ {
277
+ key: 'used-by-list',
278
+ rows: paths.map((p) => `${chalk_1.default.gray('•')} ${formatRelative(p)}`),
279
+ behavior: 'body',
280
+ },
281
+ ];
282
+ }
283
+ function buildPackageInfoSections(state, modalWidth, activeTab) {
284
+ const title = chalk_1.default.bold('Package: ') +
285
+ (0, themes_colors_1.getThemeColor)('packageName')(state.name) +
286
+ buildTabBarSuffix(activeTab, getUsedByPaths(state).length);
245
287
  const authorLicense = chalk_1.default.gray(`${state.author || 'Unknown'} • ${state.license || 'MIT'}`);
246
288
  const currentVersion = chalk_1.default.yellow(state.currentVersionSpecifier);
247
289
  const targetVersion = chalk_1.default.green(state.selectedOption === 'range' ? state.rangeVersion : state.latestVersion);
@@ -252,18 +294,22 @@ function buildPackageInfoSections(state, modalWidth) {
252
294
  required: true,
253
295
  behavior: 'pinned',
254
296
  },
255
- {
256
- key: 'meta',
257
- rows: [
258
- `Current: ${currentVersion} Target: ${targetVersion}`,
259
- ...(state.weeklyDownloads !== undefined
260
- ? [(0, themes_colors_1.getThemeColor)('primary')(`Downloads/week: ${formatNumber(state.weeklyDownloads)}`)]
261
- : []),
262
- ],
263
- required: true,
264
- behavior: 'pinned',
265
- },
266
297
  ];
298
+ if (activeTab === 'usedBy') {
299
+ sections.push(...buildUsedBySections(state, modalWidth));
300
+ return sections;
301
+ }
302
+ sections.push({
303
+ key: 'meta',
304
+ rows: [
305
+ `Current: ${currentVersion} Target: ${targetVersion}`,
306
+ ...(state.weeklyDownloads !== undefined
307
+ ? [(0, themes_colors_1.getThemeColor)('primary')(`Downloads/week: ${formatNumber(state.weeklyDownloads)}`)]
308
+ : []),
309
+ ],
310
+ required: true,
311
+ behavior: 'pinned',
312
+ });
267
313
  if (state.homepage) {
268
314
  sections.push({
269
315
  key: 'homepage',
@@ -29,9 +29,9 @@ function renderPackageInfoLoading(state, terminalWidth = 80, terminalHeight = 24
29
29
  usesInternalScroll: false,
30
30
  };
31
31
  }
32
- function renderPackageInfoModal(state, terminalWidth = 80, terminalHeight = 24, scrollOffset = 0) {
32
+ function renderPackageInfoModal(state, terminalWidth = 80, terminalHeight = 24, scrollOffset = 0, activeTab = 'info') {
33
33
  const modalWidth = (0, layout_1.getModalWidth)(terminalWidth, 60, 120);
34
- const allSections = (0, package_info_sections_1.buildPackageInfoSections)(state, modalWidth);
34
+ const allSections = (0, package_info_sections_1.buildPackageInfoSections)(state, modalWidth, activeTab);
35
35
  const maxHeight = Math.max(10, terminalHeight - 2);
36
36
  const trimOrder = ['homepage', 'changelog', 'description'];
37
37
  const compactSections = (0, layout_1.fitModalSections)(allSections, maxHeight, trimOrder);
@@ -65,8 +65,8 @@ class UIRenderer {
65
65
  renderPackageInfoLoading(state, terminalWidth = 80, terminalHeight = 24) {
66
66
  return Modal.renderPackageInfoLoading(state, terminalWidth, terminalHeight);
67
67
  }
68
- renderPackageInfoModal(state, terminalWidth = 80, terminalHeight = 24, scrollOffset = 0) {
69
- return Modal.renderPackageInfoModal(state, terminalWidth, terminalHeight, scrollOffset);
68
+ renderPackageInfoModal(state, terminalWidth = 80, terminalHeight = 24, scrollOffset = 0, activeTab = 'info') {
69
+ return Modal.renderPackageInfoModal(state, terminalWidth, terminalHeight, scrollOffset, activeTab);
70
70
  }
71
71
  renderThemeSelectorModal(currentTheme, previewTheme, terminalWidth = 80, terminalHeight = 24) {
72
72
  return Modal.renderThemeSelectorModal(currentTheme, previewTheme, terminalWidth, terminalHeight);
@@ -223,12 +223,10 @@ function renderInterface(states, currentRow, scrollOffset, maxVisibleItems, forc
223
223
  };
224
224
  const pmColor = colorMap[packageManager.name] || packageManager.color;
225
225
  // Each character in "inup" gets a different color
226
- const inupColors = [chalk_1.default.red, chalk_1.default.yellow, chalk_1.default.blue, chalk_1.default.magenta];
227
- const coloredInup = inupColors.map((color, i) => color.bold('inup'[i])).join('');
228
226
  const headerLine = ' ' +
229
227
  chalk_1.default.bold(pmColor('🚀')) +
230
228
  ' ' +
231
- coloredInup +
229
+ (0, themes_colors_1.coloredInupLogo)() +
232
230
  (0, themes_colors_1.getThemeColor)('textSecondary')(` (${packageManager.displayName})`);
233
231
  // Show filter state (always show, including "All")
234
232
  const fullHeaderLine = activeFilterLabel
@@ -241,12 +239,7 @@ function renderInterface(states, currentRow, scrollOffset, maxVisibleItems, forc
241
239
  output.push(fullHeaderLine + ' '.repeat(headerPadding));
242
240
  }
243
241
  else {
244
- const headerLine = ' ' +
245
- chalk_1.default.bold.blue('🚀 ') +
246
- chalk_1.default.bold.red('i') +
247
- chalk_1.default.bold.yellow('n') +
248
- chalk_1.default.bold.blue('u') +
249
- chalk_1.default.bold.magenta('p');
242
+ const headerLine = ' ' + chalk_1.default.bold.blue('🚀 ') + (0, themes_colors_1.coloredInupLogo)();
250
243
  // Show filter state (always show, including "All")
251
244
  const fullHeaderLine = activeFilterLabel
252
245
  ? headerLine +
@@ -9,10 +9,21 @@ class ModalManager {
9
9
  isLoadingModalInfo: false,
10
10
  infoModalScrollOffset: 0,
11
11
  infoModalSessionId: 0,
12
+ infoModalTab: 'info',
12
13
  showDebugModal: false,
13
14
  debugModalScrollOffset: 0,
14
15
  };
15
16
  }
17
+ getInfoModalTab() {
18
+ return this.state.infoModalTab;
19
+ }
20
+ setInfoModalTab(tab) {
21
+ if (this.state.infoModalTab === tab)
22
+ return false;
23
+ this.state.infoModalTab = tab;
24
+ this.state.infoModalScrollOffset = 0;
25
+ return true;
26
+ }
16
27
  isDebugModalOpen() {
17
28
  return this.state.showDebugModal;
18
29
  }
@@ -99,6 +110,7 @@ class ModalManager {
99
110
  this.state.infoModalRow = currentRow;
100
111
  this.state.infoModalScrollOffset = 0;
101
112
  this.state.isLoadingModalInfo = false;
113
+ this.state.infoModalTab = 'info';
102
114
  this.state.infoModalSessionId += 1;
103
115
  return this.state.infoModalSessionId;
104
116
  }
@@ -107,6 +119,7 @@ class ModalManager {
107
119
  this.state.infoModalRow = -1;
108
120
  this.state.isLoadingModalInfo = false;
109
121
  this.state.infoModalScrollOffset = 0;
122
+ this.state.infoModalTab = 'info';
110
123
  this.state.infoModalSessionId += 1;
111
124
  }
112
125
  setModalLoading(isLoading, sessionId) {
@@ -43,6 +43,7 @@ class StateManager {
43
43
  infoModalRow: modalState.infoModalRow,
44
44
  isLoadingModalInfo: modalState.isLoadingModalInfo,
45
45
  infoModalScrollOffset: modalState.infoModalScrollOffset,
46
+ infoModalTab: modalState.infoModalTab,
46
47
  showDebugModal: modalState.showDebugModal,
47
48
  debugModalScrollOffset: modalState.debugModalScrollOffset,
48
49
  filterMode: filterState.filterMode,
@@ -189,6 +190,16 @@ class StateManager {
189
190
  clampInfoModalScrollOffset(maxOffset) {
190
191
  return this.modalManager.clampScrollOffset(maxOffset);
191
192
  }
193
+ setInfoModalTab(tab) {
194
+ const changed = this.modalManager.setInfoModalTab(tab);
195
+ if (changed) {
196
+ this.renderState.forceFullRender = true;
197
+ }
198
+ return changed;
199
+ }
200
+ getInfoModalTab() {
201
+ return this.modalManager.getInfoModalTab();
202
+ }
192
203
  toggleDebugModal() {
193
204
  this.modalManager.toggleDebugModal();
194
205
  }
@@ -8,6 +8,7 @@ exports.getThemeColor = getThemeColor;
8
8
  exports.getThemeBgColor = getThemeBgColor;
9
9
  exports.getTerminalBgColorCode = getTerminalBgColorCode;
10
10
  exports.getTerminalResetCode = getTerminalResetCode;
11
+ exports.coloredInupLogo = coloredInupLogo;
11
12
  const chalk_1 = __importDefault(require("chalk"));
12
13
  const theme_manager_1 = require("./state/theme-manager");
13
14
  // Centralized theme color definitions - single source of truth
@@ -193,6 +194,10 @@ function getTerminalBgColorCode() {
193
194
  function getTerminalResetCode() {
194
195
  return '\x1b[0m';
195
196
  }
197
+ const BRAND_COLORS = [chalk_1.default.red, chalk_1.default.yellow, chalk_1.default.blue, chalk_1.default.magenta];
198
+ function coloredInupLogo() {
199
+ return BRAND_COLORS.map((color, i) => color.bold('inup'[i])).join('');
200
+ }
196
201
  exports.themeColors = {
197
202
  primary: () => getThemeColor('primary'),
198
203
  secondary: () => getThemeColor('secondary'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inup",
3
- "version": "1.5.2",
3
+ "version": "1.5.5",
4
4
  "description": "Interactive dependency upgrader for npm, yarn, pnpm & bun. Zero-config, monorepo-ready. Upgrade-interactive for every package manager.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -43,20 +43,20 @@
43
43
  "README.md"
44
44
  ],
45
45
  "devDependencies": {
46
- "@types/node": "^24.12.2",
46
+ "@types/node": "^24.12.4",
47
47
  "@types/semver": "^7.7.1",
48
- "@vitest/coverage-v8": "^4.1.4",
49
- "prettier": "^3.8.2",
50
- "typescript": "^6.0.2",
51
- "vitest": "^4.1.4"
48
+ "@vitest/coverage-v8": "^4.1.6",
49
+ "prettier": "^3.8.3",
50
+ "typescript": "^6.0.3",
51
+ "vitest": "^4.1.6"
52
52
  },
53
53
  "dependencies": {
54
54
  "chalk": "^5.6.2",
55
55
  "commander": "^14.0.3",
56
56
  "env-paths": "^4.0.0",
57
57
  "nanospinner": "^1.2.2",
58
- "semver": "^7.7.4",
59
- "undici": "^8.1.0"
58
+ "semver": "^7.8.0",
59
+ "undici": "^8.3.0"
60
60
  },
61
61
  "engines": {
62
62
  "node": ">=20.0.0"