inup 1.5.6 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +30 -1
  2. package/dist/cli.js +42 -11
  3. package/dist/config/constants.js +6 -7
  4. package/dist/config/project-config.js +5 -0
  5. package/dist/core/package-detector.js +49 -1
  6. package/dist/core/upgrade-runner.js +9 -0
  7. package/dist/core/upgrader.js +9 -3
  8. package/dist/features/changelog/services/package-metadata-service.js +2 -13
  9. package/dist/features/changelog/services/release-notes-service.js +0 -27
  10. package/dist/features/debug/index.js +1 -0
  11. package/dist/features/debug/renderer/performance-modal.js +17 -1
  12. package/dist/features/debug/services/perf-logger.js +129 -0
  13. package/dist/features/debug/services/performance-tracker.js +15 -3
  14. package/dist/features/headless/headless-runner.js +69 -0
  15. package/dist/features/headless/index.js +27 -0
  16. package/dist/features/headless/report.js +64 -0
  17. package/dist/features/headless/types.js +6 -0
  18. package/dist/features/headless/vulnerability-audit.js +106 -0
  19. package/dist/interactive-ui.js +4 -2
  20. package/dist/services/http/adaptive-controller.js +153 -0
  21. package/dist/services/http/etag-store.js +91 -0
  22. package/dist/services/http/resizable-semaphore.js +71 -0
  23. package/dist/services/http/retry.js +23 -1
  24. package/dist/services/index.js +0 -1
  25. package/dist/services/npm-registry.js +198 -97
  26. package/dist/services/package-manager-detector.js +6 -3
  27. package/dist/ui/modal/package-info-sections/sections.js +24 -0
  28. package/dist/ui/presenters/health.js +24 -0
  29. package/dist/ui/renderer/package-list/rows.js +8 -3
  30. package/dist/ui/session/selection-state-builder.js +7 -2
  31. package/dist/ui/themes-colors.js +12 -3
  32. package/dist/ui/utils/cursor.js +9 -3
  33. package/dist/utils/color.js +38 -0
  34. package/dist/utils/debug-logger.js +8 -1
  35. package/dist/utils/engines.js +63 -0
  36. package/dist/utils/filesystem/io.js +16 -0
  37. package/dist/utils/filesystem/scan.js +82 -7
  38. package/dist/utils/index.js +4 -0
  39. package/dist/utils/local-env.js +81 -0
  40. package/dist/utils/manifest.js +35 -0
  41. package/dist/utils/version.js +9 -2
  42. package/package.json +8 -8
  43. package/dist/services/cache-manager.js +0 -95
  44. package/dist/services/jsdelivr/client.js +0 -191
  45. package/dist/services/jsdelivr/manifest.js +0 -136
  46. package/dist/services/jsdelivr-registry.js +0 -9
  47. package/dist/services/persistent-cache.js +0 -237
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ResizableSemaphore = void 0;
4
+ /**
5
+ * A counting semaphore whose limit can change at runtime.
6
+ *
7
+ * - `acquire()` resolves immediately if there is headroom, otherwise queues a
8
+ * waiter (FIFO) until a `release()` opens a slot.
9
+ * - `setLimit()` may grow or shrink the limit at any time. Growing wakes as many
10
+ * queued waiters as the new headroom allows. Shrinking never aborts in-flight
11
+ * holders — it simply stops admitting new ones until natural releases drain
12
+ * `inFlight` back below the new limit.
13
+ * - `release()` is guarded: it only wakes a waiter when `inFlight < limit`. This
14
+ * is what makes a shrink actually take effect (an unconditional wake would
15
+ * re-admit immediately and ignore the smaller limit).
16
+ *
17
+ * Invariant: `inFlight` never exceeds `limit` at the moment of admission. A prior
18
+ * shrink can leave `inFlight > limit` transiently; no new holder is admitted
19
+ * until it drains.
20
+ */
21
+ class ResizableSemaphore {
22
+ limit;
23
+ inFlight = 0;
24
+ waiters = [];
25
+ constructor(initialLimit) {
26
+ this.limit = Math.max(1, Math.floor(initialLimit));
27
+ }
28
+ getLimit() {
29
+ return this.limit;
30
+ }
31
+ getInFlight() {
32
+ return this.inFlight;
33
+ }
34
+ getWaiterCount() {
35
+ return this.waiters.length;
36
+ }
37
+ setLimit(next) {
38
+ this.limit = Math.max(1, Math.floor(next));
39
+ // Growing may have opened headroom for queued waiters.
40
+ this.drainWaiters();
41
+ }
42
+ async acquire() {
43
+ // `inFlight` is the single authoritative counter and is incremented exactly
44
+ // once per admission, synchronously at the moment admission is decided —
45
+ // either here (fast path) or in `drainWaiters` (when a slot frees up). A
46
+ // woken waiter must NOT increment again, or a concurrent fast-path acquire
47
+ // reading a stale count could over-admit past the limit.
48
+ if (this.inFlight < this.limit) {
49
+ this.inFlight++;
50
+ return;
51
+ }
52
+ await new Promise((resolve) => this.waiters.push(resolve));
53
+ }
54
+ release() {
55
+ this.inFlight--;
56
+ this.drainWaiters();
57
+ }
58
+ /**
59
+ * Wakes queued waiters while there is headroom, incrementing `inFlight` for
60
+ * each as it is admitted so the count stays authoritative and synchronous.
61
+ */
62
+ drainWaiters() {
63
+ while (this.inFlight < this.limit && this.waiters.length > 0) {
64
+ const next = this.waiters.shift();
65
+ this.inFlight++;
66
+ next();
67
+ }
68
+ }
69
+ }
70
+ exports.ResizableSemaphore = ResizableSemaphore;
71
+ //# sourceMappingURL=resizable-semaphore.js.map
@@ -1,10 +1,32 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isTransientNetworkError = exports.isRetryableStatus = exports.sleep = void 0;
3
+ exports.isTransientNetworkError = exports.parseRetryAfterMs = exports.isCongestionStatus = exports.isRetryableStatus = exports.sleep = void 0;
4
4
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
5
  exports.sleep = sleep;
6
6
  const isRetryableStatus = (statusCode) => statusCode === 408 || statusCode === 429 || statusCode >= 500;
7
7
  exports.isRetryableStatus = isRetryableStatus;
8
+ // Registry explicitly signaling congestion: the highest-quality back-off signal.
9
+ const isCongestionStatus = (statusCode) => statusCode === 429 || statusCode === 503;
10
+ exports.isCongestionStatus = isCongestionStatus;
11
+ // Parse a Retry-After header (delta-seconds or HTTP-date) into milliseconds.
12
+ // Returns null when absent or unparseable.
13
+ const parseRetryAfterMs = (header, now = Date.now()) => {
14
+ if (header === undefined)
15
+ return null;
16
+ const value = (Array.isArray(header) ? header[0] : header)?.toString().trim();
17
+ if (!value)
18
+ return null;
19
+ const asSeconds = Number(value);
20
+ if (Number.isFinite(asSeconds)) {
21
+ return asSeconds >= 0 ? Math.round(asSeconds * 1000) : null;
22
+ }
23
+ const asDate = Date.parse(value);
24
+ if (Number.isFinite(asDate)) {
25
+ return Math.max(0, asDate - now);
26
+ }
27
+ return null;
28
+ };
29
+ exports.parseRetryAfterMs = parseRetryAfterMs;
8
30
  const isTransientNetworkError = (error) => {
9
31
  if (!(error instanceof Error)) {
10
32
  return false;
@@ -18,7 +18,6 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
18
18
  };
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
20
  __exportStar(require("./npm-registry"), exports);
21
- __exportStar(require("./jsdelivr-registry"), exports);
22
21
  __exportStar(require("../features/changelog"), exports);
23
22
  __exportStar(require("./version-checker"), exports);
24
23
  __exportStar(require("./vulnerability-checker"), exports);
@@ -2,7 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.fetchPackageVersions = fetchPackageVersions;
4
4
  exports.clearPackageCache = clearPackageCache;
5
- exports.closeRegistryPool = closeRegistryPool;
6
5
  const undici_1 = require("undici");
7
6
  const node_zlib_1 = require("node:zlib");
8
7
  const node_util_1 = require("node:util");
@@ -10,35 +9,44 @@ const gunzipAsync = (0, node_util_1.promisify)(node_zlib_1.gunzip);
10
9
  const inflateAsync = (0, node_util_1.promisify)(node_zlib_1.inflate);
11
10
  const brotliDecompressAsync = (0, node_util_1.promisify)(node_zlib_1.brotliDecompress);
12
11
  const config_1 = require("../config");
13
- const jsdelivr_registry_1 = require("./jsdelivr-registry");
14
12
  const version_1 = require("../utils/version");
15
13
  const retry_1 = require("./http/retry");
16
14
  const inflight_1 = require("./http/inflight");
15
+ const resizable_semaphore_1 = require("./http/resizable-semaphore");
16
+ const adaptive_controller_1 = require("./http/adaptive-controller");
17
+ const etag_store_1 = require("./http/etag-store");
17
18
  const inFlightLookups = new inflight_1.InflightMap();
18
19
  const registryOrigin = new URL(config_1.NPM_REGISTRY_URL).origin;
19
20
  const registryPathPrefix = new URL(config_1.NPM_REGISTRY_URL).pathname.replace(/\/$/, '');
20
- // Few connections + many requests per connection = maximum keep-alive reuse.
21
- // No per-request timeouts: correctness matters more than speed for a CLI that
22
- // runs on demand. Slow responses are tolerated; only true errors cause retry.
21
+ // Connection count is kept == the adaptive controller's ceiling (POOL_CONNECTIONS)
22
+ // so the controller is never silently throttled below its chosen limit. Idle
23
+ // keep-alive connections are cheap.
24
+ //
25
+ // `headersTimeout` is intentionally non-zero (unlike the rest, where we tolerate
26
+ // slow bodies): a stalled connection that never sends headers would otherwise be
27
+ // consumed forever and stay invisible to the completion-based adaptive
28
+ // controller. With a headers timeout, a stall surfaces as a transient error the
29
+ // controller can react to (and retry handles). `bodyTimeout` stays 0 — large
30
+ // packuments legitimately stream slowly.
23
31
  const registryPool = new undici_1.Pool(registryOrigin, {
24
- connections: 6,
32
+ connections: config_1.POOL_CONNECTIONS,
25
33
  pipelining: 1,
26
34
  keepAliveTimeout: 30_000,
27
35
  keepAliveMaxTimeout: 600_000,
28
- headersTimeout: 0,
36
+ headersTimeout: 30_000,
29
37
  bodyTimeout: 0,
30
38
  connectTimeout: 15_000,
31
39
  allowH2: false,
32
40
  });
33
41
  const MAX_REGISTRY_ATTEMPTS = 3;
34
42
  const RETRY_BACKOFF_MS = [500, 1500, 3000];
35
- const fetchFromJsdelivrFallback = async (packageName, currentVersion) => {
36
- const jsdelivrData = await (0, jsdelivr_registry_1.getAllPackageDataFromJsdelivr)([packageName], currentVersion ? new Map([[packageName, currentVersion]]) : undefined);
37
- return jsdelivrData.get(packageName) ?? { latestVersion: 'unknown', allVersions: [] };
38
- };
39
- async function getFreshPackageData(packageName, currentVersion) {
43
+ // Fixed concurrency used when adaptive is disabled (INUP_ADAPTIVE=0, the A/B
44
+ // control arm). Matches the production caller (PackageDetector) so the fixed path
45
+ // reproduces the legacy behavior exactly.
46
+ const DEFAULT_FIXED_CONCURRENCY = 10;
47
+ async function getFreshPackageData(packageName, currentVersion, onAttempt) {
40
48
  const cacheKey = `${packageName}@${currentVersion ?? ''}`;
41
- return inFlightLookups.dedupe(cacheKey, () => fetchPackageFromRegistryWithFallback(packageName, currentVersion));
49
+ return inFlightLookups.dedupe(cacheKey, () => fetchPackageFromRegistry(packageName, onAttempt));
42
50
  }
43
51
  const encodeRegistryPath = (packageName) => {
44
52
  const encodedName = packageName.startsWith('@')
@@ -47,20 +55,40 @@ const encodeRegistryPath = (packageName) => {
47
55
  return `${registryPathPrefix}/${encodedName}`;
48
56
  };
49
57
  async function attemptRegistryFetch(path) {
58
+ const startedAt = Date.now();
59
+ // Conditional request: if we have a stored ETag for this packument, ask the
60
+ // registry to validate it. Unchanged → 304 (no body) and we reuse stored data.
61
+ // This still hits the registry every run, so data is never served stale.
62
+ const cached = (0, etag_store_1.readEtag)(path);
50
63
  try {
64
+ const requestHeaders = {
65
+ accept: 'application/vnd.npm.install-v1+json',
66
+ 'accept-encoding': 'gzip, deflate, br',
67
+ };
68
+ if (cached) {
69
+ requestHeaders['if-none-match'] = cached.etag;
70
+ }
51
71
  const { statusCode, headers, body } = await registryPool.request({
52
72
  path,
53
73
  method: 'GET',
54
- headers: {
55
- accept: 'application/vnd.npm.install-v1+json',
56
- 'accept-encoding': 'gzip, deflate, br',
57
- },
58
- headersTimeout: 0,
74
+ headers: requestHeaders,
75
+ headersTimeout: 30_000,
59
76
  bodyTimeout: 0,
60
77
  blocking: false,
61
78
  });
79
+ // Registry confirmed our cached copy is current — reuse it, skip the download.
80
+ if (statusCode === 304 && cached) {
81
+ await body.dump().catch(() => undefined);
82
+ return { kind: 'success', data: cached.data, latencyMs: Date.now() - startedAt };
83
+ }
62
84
  if (statusCode < 200 || statusCode >= 300) {
63
85
  await body.dump().catch(() => undefined);
86
+ if ((0, retry_1.isCongestionStatus)(statusCode)) {
87
+ return {
88
+ kind: 'congested',
89
+ retryAfterMs: (0, retry_1.parseRetryAfterMs)(headers['retry-after']),
90
+ };
91
+ }
64
92
  if ((0, retry_1.isRetryableStatus)(statusCode)) {
65
93
  return { kind: 'retryable' };
66
94
  }
@@ -84,7 +112,18 @@ async function attemptRegistryFetch(path) {
84
112
  else {
85
113
  decoded = raw;
86
114
  }
87
- return { kind: 'success', data: (0, version_1.parseVersions)(decoded.toString('utf8')) };
115
+ const data = (0, version_1.parseVersions)(decoded.toString('utf8'));
116
+ // Persist the ETag for next run's conditional request.
117
+ const etagHeader = headers['etag'];
118
+ const etag = Array.isArray(etagHeader) ? etagHeader[0] : etagHeader;
119
+ if (etag) {
120
+ (0, etag_store_1.writeEtag)(path, etag.toString(), data);
121
+ }
122
+ return {
123
+ kind: 'success',
124
+ data,
125
+ latencyMs: Date.now() - startedAt,
126
+ };
88
127
  }
89
128
  catch (error) {
90
129
  if ((0, retry_1.isTransientNetworkError)(error)) {
@@ -95,61 +134,81 @@ async function attemptRegistryFetch(path) {
95
134
  return { kind: 'transient' };
96
135
  }
97
136
  }
98
- async function fetchFromRegistryWithRetries(path) {
137
+ async function fetchFromRegistryWithRetries(path, onAttempt) {
99
138
  let lastOutcome = { kind: 'transient' };
100
139
  for (let attempt = 0; attempt < MAX_REGISTRY_ATTEMPTS; attempt++) {
101
140
  const outcome = await attemptRegistryFetch(path);
141
+ onAttempt?.(outcome);
102
142
  if (outcome.kind === 'success' || outcome.kind === 'not-found') {
103
143
  return outcome;
104
144
  }
105
145
  lastOutcome = outcome;
106
146
  if (attempt < MAX_REGISTRY_ATTEMPTS - 1) {
107
- const backoff = RETRY_BACKOFF_MS[Math.min(attempt, RETRY_BACKOFF_MS.length - 1)];
147
+ // Honor Retry-After on congestion; otherwise exponential backoff. These
148
+ // sleeps are deliberately NOT timed into the controller's latency EWMA.
149
+ const congestedWait = outcome.kind === 'congested' && outcome.retryAfterMs !== null ? outcome.retryAfterMs : null;
150
+ const backoff = congestedWait ?? RETRY_BACKOFF_MS[Math.min(attempt, RETRY_BACKOFF_MS.length - 1)];
108
151
  await (0, retry_1.sleep)(backoff);
109
152
  }
110
153
  }
111
154
  return lastOutcome;
112
155
  }
113
- async function fetchPackageFromRegistryWithFallback(packageName, currentVersion) {
156
+ async function fetchPackageFromRegistry(packageName, onAttempt) {
114
157
  const path = encodeRegistryPath(packageName);
115
- const outcome = await fetchFromRegistryWithRetries(path);
158
+ const outcome = await fetchFromRegistryWithRetries(path, onAttempt);
116
159
  if (outcome.kind === 'success') {
117
160
  return outcome.data;
118
161
  }
119
- if (outcome.kind === 'not-found') {
120
- return { latestVersion: 'unknown', allVersions: [] };
121
- }
122
- // Only reach here after exhausted retries against real errors — try jsdelivr
123
- // as last-resort safety net so we don't silently return 'unknown'.
124
- const fallback = await fetchFromJsdelivrFallback(packageName, currentVersion).catch(() => null);
125
- return fallback ?? { latestVersion: 'unknown', allVersions: [] };
162
+ // Not found, or exhausted retries against real errors: report unavailable.
163
+ // The registry is the single source of truth — there is no secondary fetch.
164
+ return { latestVersion: 'unknown', allVersions: [] };
126
165
  }
127
166
  /**
128
167
  * Fetches version data for a list of packages from the npm registry.
129
168
  *
130
169
  * Concurrency model:
131
- * - `maxConcurrency` is a global cap on in-flight fetches at any moment.
132
- * It doesn't interact with batch size batches exist only to group
133
- * emissions for the UI.
134
- * - No per-request timeouts: slow responses are allowed to finish. Real
135
- * network errors are retried with exponential backoff; after that, we
136
- * fall back to jsdelivr as a last resort. A result is never silently
137
- * dropped due to slowness.
170
+ * - A single resizable semaphore caps in-flight fetches. Package names are
171
+ * pulled from a work queue and dispatched as slots free up (a lazy pump),
172
+ * rather than pre-sliced into fixed batches.
173
+ * - `adaptive` (default true) enables an AIMD controller that ramps the limit to
174
+ * the ceiling on a healthy link and backs off on congestion (429/503) or
175
+ * errors. With `adaptive:false` the limit is fixed at `maxConcurrency` (the A/B
176
+ * control arm), reproducing the legacy fixed path.
177
+ * - Tiny runs (<= ceil packages) skip the controller and run at a fixed
178
+ * `min(ceil, count)` so they never crawl up from the floor and lose to fixed.
179
+ * - No body timeout: slow responses finish. Real network errors and header
180
+ * stalls are retried with backoff; after the retry budget is exhausted the
181
+ * package is reported unavailable (`latestVersion: 'unknown'`).
182
+ * - Unchanged packuments are revalidated via ETag (304), skipping re-download.
138
183
  *
139
184
  * Callbacks:
140
- * - `onBatchReady` fires once a whole emission batch has resolved, in
141
- * original batch order.
185
+ * - `onBatchReady` fires once an emission window has resolved, in original order.
186
+ * Emission windows are fixed-size groupings for UI progress only; they do not
187
+ * gate concurrency.
188
+ * - `onControlTick` (optional) reports each adaptive control decision for
189
+ * instrumentation.
142
190
  */
143
191
  async function fetchPackageVersions(packageNames, options = {}) {
144
192
  const packageData = new Map();
145
- if (packageNames.length === 0) {
193
+ const total = packageNames.length;
194
+ if (total === 0) {
146
195
  return packageData;
147
196
  }
148
- const batchSizes = options.batchSizes && options.batchSizes.length > 0
149
- ? options.batchSizes.map((size) => Math.max(1, size))
150
- : [Math.max(1, options.batchSize ?? 25)];
151
- const maxConcurrency = Math.max(1, options.maxConcurrency ?? 10);
152
- const total = packageNames.length;
197
+ const adaptive = options.adaptive ?? true;
198
+ // `maxConcurrency` is the fixed cap used only when adaptive is off; it never
199
+ // caps the adaptive start (the controller smart-starts near the work size and
200
+ // ramps to the ceiling, which beats a low fixed start on large runs).
201
+ const fixedConcurrency = Math.max(1, options.maxConcurrency ?? DEFAULT_FIXED_CONCURRENCY);
202
+ const controller = adaptive && adaptive_controller_1.AdaptiveController.shouldControl(total)
203
+ ? new adaptive_controller_1.AdaptiveController(total, options.onControlTick)
204
+ : null;
205
+ const initialLimit = controller
206
+ ? controller.getLimit()
207
+ : adaptive
208
+ ? Math.min(config_1.POOL_CONNECTIONS, total) // too small to control: smart fixed start
209
+ : fixedConcurrency;
210
+ const semaphore = new resizable_semaphore_1.ResizableSemaphore(initialLimit);
211
+ // --- emission ordering (unchanged contract) ---------------------------------
153
212
  let completedCount = 0;
154
213
  const pendingEmissions = new Map();
155
214
  let nextEmitIndex = 0;
@@ -161,68 +220,110 @@ async function fetchPackageVersions(packageNames, options = {}) {
161
220
  nextEmitIndex++;
162
221
  }
163
222
  };
164
- // Global semaphore: `maxConcurrency` is the total in-flight cap across
165
- // all batches. Batches don't gate concurrency only emission order.
166
- let inFlight = 0;
167
- const waiters = [];
168
- const acquire = async () => {
169
- if (inFlight < maxConcurrency) {
170
- inFlight++;
171
- return;
223
+ // Emission windows group results for UI progress only (decoupled from
224
+ // concurrency). Sizes come from `batchSizes` (a sequence, last value repeats)
225
+ // or a uniform `batchSize`. We precompute, per package index, which window it
226
+ // belongs to and its position within that window, so a window can flush as soon
227
+ // as all its items resolve — preserving original order via `flushPending`.
228
+ const windowSizes = options.batchSizes && options.batchSizes.length > 0
229
+ ? options.batchSizes.map((size) => Math.max(1, size))
230
+ : [Math.max(1, options.batchSize ?? 25)];
231
+ const windowIdByIndex = new Array(total);
232
+ const itemIndexByIndex = new Array(total);
233
+ const windowRemaining = [];
234
+ {
235
+ let cursorIndex = 0;
236
+ let windowId = 0;
237
+ while (cursorIndex < total) {
238
+ const size = windowSizes[Math.min(windowId, windowSizes.length - 1)];
239
+ const end = Math.min(cursorIndex + size, total);
240
+ windowRemaining[windowId] = end - cursorIndex;
241
+ for (let i = cursorIndex; i < end; i++) {
242
+ windowIdByIndex[i] = windowId;
243
+ itemIndexByIndex[i] = i - cursorIndex;
244
+ }
245
+ cursorIndex = end;
246
+ windowId++;
172
247
  }
173
- await new Promise((resolve) => waiters.push(resolve));
174
- inFlight++;
175
- };
176
- const release = () => {
177
- inFlight--;
178
- const next = waiters.shift();
179
- if (next)
180
- next();
248
+ }
249
+ const windowResults = windowRemaining.map(() => []);
250
+ // --- per-attempt observer ---------------------------------------------------
251
+ // Feeds the adaptive controller AND (optionally) reports per-package latency
252
+ // for diagnostics. Built per package so the timing callback knows the name.
253
+ const observerFor = (packageName) => {
254
+ if (!controller && !options.onPackageTiming)
255
+ return undefined;
256
+ return (outcome) => {
257
+ if (outcome.kind === 'success') {
258
+ controller?.record('success', outcome.latencyMs);
259
+ options.onPackageTiming?.(packageName, outcome.latencyMs);
260
+ }
261
+ else if (outcome.kind === 'congested') {
262
+ const next = controller?.record('congested');
263
+ if (next != null)
264
+ semaphore.setLimit(next);
265
+ }
266
+ else if (outcome.kind === 'retryable') {
267
+ controller?.record('retryable');
268
+ }
269
+ else if (outcome.kind === 'transient') {
270
+ controller?.record('transient');
271
+ }
272
+ };
181
273
  };
182
- const batchPromises = [];
183
- let batchStart = 0;
184
- let batchIndex = 0;
185
- while (batchStart < packageNames.length) {
186
- const batchSize = batchSizes[Math.min(batchIndex, batchSizes.length - 1)];
187
- const batchNames = packageNames.slice(batchStart, batchStart + batchSize);
188
- const capturedBatchIndex = batchIndex;
189
- const batchResults = new Array(batchNames.length);
190
- const batchPromise = Promise.all(batchNames.map(async (packageName, itemIndex) => {
191
- await acquire();
192
- try {
193
- const data = await getFreshPackageData(packageName, options.currentVersions?.get(packageName));
194
- packageData.set(packageName, data);
195
- completedCount++;
196
- batchResults[itemIndex] = {
197
- packageName,
198
- data,
199
- completed: completedCount,
200
- total,
201
- batchIndex: capturedBatchIndex,
202
- itemIndex,
203
- };
274
+ // --- worker: pull from the queue until exhausted ----------------------------
275
+ let cursor = 0;
276
+ const runOne = async (index) => {
277
+ const packageName = packageNames[index];
278
+ await semaphore.acquire();
279
+ try {
280
+ const data = await getFreshPackageData(packageName, options.currentVersions?.get(packageName), observerFor(packageName));
281
+ packageData.set(packageName, data);
282
+ completedCount++;
283
+ const w = windowIdByIndex[index];
284
+ const itemIndex = itemIndexByIndex[index];
285
+ // Index by position (not push) so items keep their original in-window order
286
+ // even when they resolve out of order.
287
+ windowResults[w][itemIndex] = {
288
+ packageName,
289
+ data,
290
+ completed: completedCount,
291
+ total,
292
+ batchIndex: w,
293
+ itemIndex,
294
+ };
295
+ if (--windowRemaining[w] === 0) {
296
+ pendingEmissions.set(w, windowResults[w]);
297
+ flushPending();
204
298
  }
205
- finally {
206
- release();
299
+ if (controller) {
300
+ const next = controller.maybeTick();
301
+ if (next !== null)
302
+ semaphore.setLimit(next);
207
303
  }
208
- })).then(() => {
209
- pendingEmissions.set(capturedBatchIndex, batchResults.filter(Boolean));
210
- flushPending();
211
- });
212
- batchPromises.push(batchPromise);
213
- batchStart += batchSize;
214
- batchIndex++;
304
+ }
305
+ finally {
306
+ semaphore.release();
307
+ }
308
+ };
309
+ // The pump: keep enough workers running to saturate the (possibly growing)
310
+ // limit. We dispatch all indices as promises but each one waits on the
311
+ // semaphore before doing work, so the semaphore — not the dispatch loop —
312
+ // enforces the limit. Growing the limit lets queued acquirers through.
313
+ const workers = [];
314
+ while (cursor < total) {
315
+ workers.push(runOne(cursor));
316
+ cursor++;
215
317
  }
216
- await Promise.all(batchPromises);
318
+ await Promise.all(workers);
217
319
  return packageData;
218
320
  }
219
321
  /**
220
- * Retained for backward compatibility. Registry responses are fresh-by-default.
322
+ * Clears the in-run in-flight dedupe map. Only meaningful within a single run
323
+ * (the map collapses duplicate concurrent lookups); registry data itself is
324
+ * never cached in memory across runs. Used by tests to isolate fetches.
221
325
  */
222
326
  function clearPackageCache() {
223
327
  inFlightLookups.clear();
224
328
  }
225
- async function closeRegistryPool() {
226
- await registryPool.close();
227
- }
228
329
  //# sourceMappingURL=npm-registry.js.map
@@ -56,8 +56,8 @@ class PackageManagerDetector {
56
56
  if (fromLockFile) {
57
57
  return fromLockFile;
58
58
  }
59
- // 3. Fallback to npm
60
- console.log(chalk_1.default.yellow('⚠️ No package manager detected. Defaulting to npm. Consider adding a "packageManager" field to your package.json.'));
59
+ // 3. Fallback to npm. Warn on stderr so it never corrupts --json output on stdout.
60
+ console.error(chalk_1.default.yellow('⚠️ No package manager detected. Defaulting to npm. Consider adding a "packageManager" field to your package.json.'));
61
61
  return PACKAGE_MANAGERS.npm;
62
62
  }
63
63
  /**
@@ -92,6 +92,8 @@ class PackageManagerDetector {
92
92
  const lockFileChecks = [
93
93
  { pm: PACKAGE_MANAGERS.pnpm, path: (0, path_1.join)(cwd, 'pnpm-lock.yaml') },
94
94
  { pm: PACKAGE_MANAGERS.bun, path: (0, path_1.join)(cwd, 'bun.lockb') },
95
+ // Bun >= 1.2 writes a text `bun.lock` instead of the binary `bun.lockb`.
96
+ { pm: PACKAGE_MANAGERS.bun, path: (0, path_1.join)(cwd, 'bun.lock') },
95
97
  { pm: PACKAGE_MANAGERS.yarn, path: (0, path_1.join)(cwd, 'yarn.lock') },
96
98
  { pm: PACKAGE_MANAGERS.npm, path: (0, path_1.join)(cwd, 'package-lock.json') },
97
99
  ];
@@ -107,7 +109,8 @@ class PackageManagerDetector {
107
109
  }
108
110
  // If multiple lock files, use most recently modified
109
111
  if (existingLocks.length > 1) {
110
- console.log(chalk_1.default.yellow('⚠️ Multiple lock files detected. Using most recently modified. Consider cleaning up unused lock files.'));
112
+ // stderr so it never corrupts --json output on stdout.
113
+ console.error(chalk_1.default.yellow('⚠️ Multiple lock files detected. Using most recently modified. Consider cleaning up unused lock files.'));
111
114
  existingLocks.sort((a, b) => b.mtime - a.mtime);
112
115
  }
113
116
  return existingLocks[0].pm;
@@ -10,6 +10,7 @@ const chalk_1 = __importDefault(require("chalk"));
10
10
  const themes_colors_1 = require("../../themes-colors");
11
11
  const vulnerability_1 = require("../../presenters/vulnerability");
12
12
  const utils_1 = require("../../utils");
13
+ const utils_2 = require("../../../utils");
13
14
  const release_notes_1 = require("./release-notes");
14
15
  function formatNumber(num) {
15
16
  if (!num)
@@ -88,6 +89,29 @@ function buildPackageInfoSections(state, modalWidth, activeTab) {
88
89
  required: true,
89
90
  behavior: 'pinned',
90
91
  });
92
+ const warningRows = [];
93
+ const warningContentWidth = Math.max(10, modalWidth - 4);
94
+ if (state.deprecated) {
95
+ // Wrap (don't truncate) so a deprecation URL stays whole and clickable —
96
+ // truncation with "..." produces a dead link. `wrapPlainText` breaks on
97
+ // spaces only, so the URL keeps its own intact line. No emoji marker:
98
+ // `getVisualLength` scores glyphs like ⚠ as width 2 while many terminals
99
+ // render them as width 1, which throws off the modal's border alignment.
100
+ for (const line of (0, utils_1.wrapPlainText)(`Deprecated: ${state.deprecated}`, warningContentWidth)) {
101
+ warningRows.push((0, themes_colors_1.getThemeColor)('warning')(line));
102
+ }
103
+ }
104
+ const engineWarning = (0, utils_2.checkNodeEngineCompatibility)(state.enginesNode);
105
+ if (engineWarning) {
106
+ warningRows.push((0, themes_colors_1.getThemeColor)('warning')(`Hold: ${engineWarning}`));
107
+ }
108
+ if (warningRows.length > 0) {
109
+ sections.push({
110
+ key: 'warnings',
111
+ rows: warningRows,
112
+ behavior: 'pinned',
113
+ });
114
+ }
91
115
  if (state.homepage) {
92
116
  sections.push({
93
117
  key: 'homepage',
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getHealthBadge = getHealthBadge;
4
+ const utils_1 = require("../../utils");
5
+ const themes_colors_1 = require("../themes-colors");
6
+ /**
7
+ * A compact badge flagging a package's health in the list:
8
+ * - `[DEPR]` when the latest version is deprecated (highest priority), or
9
+ * - `[ENG]` when its `engines.node` is incompatible with the running Node.
10
+ *
11
+ * Returns an empty string when neither applies. Deprecation wins because an
12
+ * engines mismatch on an abandoned package is moot. Both render in the theme's
13
+ * (amber) warning color — a caution signal, not an alarm.
14
+ */
15
+ function getHealthBadge(state) {
16
+ if (state.deprecated) {
17
+ return (0, themes_colors_1.getThemeColor)('warning')('[DEPR]');
18
+ }
19
+ if ((0, utils_1.checkNodeEngineCompatibility)(state.enginesNode)) {
20
+ return (0, themes_colors_1.getThemeColor)('warning')('[ENG]');
21
+ }
22
+ return '';
23
+ }
24
+ //# sourceMappingURL=health.js.map
@@ -11,6 +11,7 @@ const chalk_1 = __importDefault(require("chalk"));
11
11
  const utils_1 = require("../../utils");
12
12
  const themes_colors_1 = require("../../themes-colors");
13
13
  const vulnerability_1 = require("../../presenters/vulnerability");
14
+ const health_1 = require("../../presenters/health");
14
15
  function padLineToWidth(line, terminalWidth) {
15
16
  const padding = Math.max(0, terminalWidth - utils_1.VersionUtils.getVisualLength(line));
16
17
  return line + ' '.repeat(padding);
@@ -118,15 +119,19 @@ function renderPackageLine(state, _index, isCurrentRow, terminalWidth = 80, opti
118
119
  const shouldShowVulnerability = (0, vulnerability_1.shouldDisplayVulnerabilityForDependency)(state.type, options);
119
120
  const vulnBadge = shouldShowVulnerability ? (0, vulnerability_1.getVulnerabilityBadge)(state.vulnerability) : '';
120
121
  const vulnBadgeWidth = vulnBadge ? utils_1.VersionUtils.getVisualLength(vulnBadge) + 1 : 0;
122
+ // Deprecation / engines-incompatibility marker (independent of dep type).
123
+ const healthBadge = (0, health_1.getHealthBadge)(state);
124
+ const healthBadgeWidth = healthBadge ? utils_1.VersionUtils.getVisualLength(healthBadge) + 1 : 0;
121
125
  const nameLength = utils_1.VersionUtils.getVisualLength(truncatedName);
122
- const namePadding = Math.max(0, packageNameWidth - nameLength - 1 - badgeWidth - vulnBadgeWidth);
126
+ const namePadding = Math.max(0, packageNameWidth - nameLength - 1 - badgeWidth - vulnBadgeWidth - healthBadgeWidth);
123
127
  const nameDashes = shouldShowDashes(namePadding)
124
128
  ? dashColor('-').repeat(namePadding)
125
129
  : ' '.repeat(namePadding);
126
130
  const vulnSuffix = vulnBadge ? ` ${vulnBadge}` : '';
131
+ const healthSuffix = healthBadge ? ` ${healthBadge}` : '';
127
132
  const packageNameSection = typeBadge
128
- ? `${displayName} ${nameDashes}${vulnSuffix}${typeBadge}`
129
- : `${displayName} ${nameDashes}${vulnSuffix}`;
133
+ ? `${displayName} ${nameDashes}${vulnSuffix}${healthSuffix}${typeBadge}`
134
+ : `${displayName} ${nameDashes}${vulnSuffix}${healthSuffix}`;
130
135
  const currentSection = `${currentDot} ${currentVersion}`;
131
136
  const currentSectionLength = utils_1.VersionUtils.getVisualLength(currentSection) + 1;
132
137
  const currentPadding = Math.max(0, currentColumnWidth - currentSectionLength);