inup 1.6.0 → 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.
@@ -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;
@@ -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");
@@ -13,27 +12,41 @@ const config_1 = require("../config");
13
12
  const version_1 = require("../utils/version");
14
13
  const retry_1 = require("./http/retry");
15
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");
16
18
  const inFlightLookups = new inflight_1.InflightMap();
17
19
  const registryOrigin = new URL(config_1.NPM_REGISTRY_URL).origin;
18
20
  const registryPathPrefix = new URL(config_1.NPM_REGISTRY_URL).pathname.replace(/\/$/, '');
19
- // Few connections + many requests per connection = maximum keep-alive reuse.
20
- // No per-request timeouts: correctness matters more than speed for a CLI that
21
- // 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.
22
31
  const registryPool = new undici_1.Pool(registryOrigin, {
23
- connections: 6,
32
+ connections: config_1.POOL_CONNECTIONS,
24
33
  pipelining: 1,
25
34
  keepAliveTimeout: 30_000,
26
35
  keepAliveMaxTimeout: 600_000,
27
- headersTimeout: 0,
36
+ headersTimeout: 30_000,
28
37
  bodyTimeout: 0,
29
38
  connectTimeout: 15_000,
30
39
  allowH2: false,
31
40
  });
32
41
  const MAX_REGISTRY_ATTEMPTS = 3;
33
42
  const RETRY_BACKOFF_MS = [500, 1500, 3000];
34
- 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) {
35
48
  const cacheKey = `${packageName}@${currentVersion ?? ''}`;
36
- return inFlightLookups.dedupe(cacheKey, () => fetchPackageFromRegistry(packageName));
49
+ return inFlightLookups.dedupe(cacheKey, () => fetchPackageFromRegistry(packageName, onAttempt));
37
50
  }
38
51
  const encodeRegistryPath = (packageName) => {
39
52
  const encodedName = packageName.startsWith('@')
@@ -42,20 +55,40 @@ const encodeRegistryPath = (packageName) => {
42
55
  return `${registryPathPrefix}/${encodedName}`;
43
56
  };
44
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);
45
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
+ }
46
71
  const { statusCode, headers, body } = await registryPool.request({
47
72
  path,
48
73
  method: 'GET',
49
- headers: {
50
- accept: 'application/vnd.npm.install-v1+json',
51
- 'accept-encoding': 'gzip, deflate, br',
52
- },
53
- headersTimeout: 0,
74
+ headers: requestHeaders,
75
+ headersTimeout: 30_000,
54
76
  bodyTimeout: 0,
55
77
  blocking: false,
56
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
+ }
57
84
  if (statusCode < 200 || statusCode >= 300) {
58
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
+ }
59
92
  if ((0, retry_1.isRetryableStatus)(statusCode)) {
60
93
  return { kind: 'retryable' };
61
94
  }
@@ -79,7 +112,18 @@ async function attemptRegistryFetch(path) {
79
112
  else {
80
113
  decoded = raw;
81
114
  }
82
- 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
+ };
83
127
  }
84
128
  catch (error) {
85
129
  if ((0, retry_1.isTransientNetworkError)(error)) {
@@ -90,24 +134,28 @@ async function attemptRegistryFetch(path) {
90
134
  return { kind: 'transient' };
91
135
  }
92
136
  }
93
- async function fetchFromRegistryWithRetries(path) {
137
+ async function fetchFromRegistryWithRetries(path, onAttempt) {
94
138
  let lastOutcome = { kind: 'transient' };
95
139
  for (let attempt = 0; attempt < MAX_REGISTRY_ATTEMPTS; attempt++) {
96
140
  const outcome = await attemptRegistryFetch(path);
141
+ onAttempt?.(outcome);
97
142
  if (outcome.kind === 'success' || outcome.kind === 'not-found') {
98
143
  return outcome;
99
144
  }
100
145
  lastOutcome = outcome;
101
146
  if (attempt < MAX_REGISTRY_ATTEMPTS - 1) {
102
- 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)];
103
151
  await (0, retry_1.sleep)(backoff);
104
152
  }
105
153
  }
106
154
  return lastOutcome;
107
155
  }
108
- async function fetchPackageFromRegistry(packageName) {
156
+ async function fetchPackageFromRegistry(packageName, onAttempt) {
109
157
  const path = encodeRegistryPath(packageName);
110
- const outcome = await fetchFromRegistryWithRetries(path);
158
+ const outcome = await fetchFromRegistryWithRetries(path, onAttempt);
111
159
  if (outcome.kind === 'success') {
112
160
  return outcome.data;
113
161
  }
@@ -119,29 +167,48 @@ async function fetchPackageFromRegistry(packageName) {
119
167
  * Fetches version data for a list of packages from the npm registry.
120
168
  *
121
169
  * Concurrency model:
122
- * - `maxConcurrency` is a global cap on in-flight fetches at any moment.
123
- * It doesn't interact with batch size batches exist only to group
124
- * emissions for the UI.
125
- * - No per-request timeouts: slow responses are allowed to finish. Real
126
- * network errors are retried with exponential backoff; after the retry
127
- * budget is exhausted the package is reported as unavailable
128
- * (`latestVersion: 'unknown'`). A result is never silently dropped due to
129
- * 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.
130
183
  *
131
184
  * Callbacks:
132
- * - `onBatchReady` fires once a whole emission batch has resolved, in
133
- * 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.
134
190
  */
135
191
  async function fetchPackageVersions(packageNames, options = {}) {
136
192
  const packageData = new Map();
137
- if (packageNames.length === 0) {
193
+ const total = packageNames.length;
194
+ if (total === 0) {
138
195
  return packageData;
139
196
  }
140
- const batchSizes = options.batchSizes && options.batchSizes.length > 0
141
- ? options.batchSizes.map((size) => Math.max(1, size))
142
- : [Math.max(1, options.batchSize ?? 25)];
143
- const maxConcurrency = Math.max(1, options.maxConcurrency ?? 10);
144
- 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) ---------------------------------
145
212
  let completedCount = 0;
146
213
  const pendingEmissions = new Map();
147
214
  let nextEmitIndex = 0;
@@ -153,68 +220,110 @@ async function fetchPackageVersions(packageNames, options = {}) {
153
220
  nextEmitIndex++;
154
221
  }
155
222
  };
156
- // Global semaphore: `maxConcurrency` is the total in-flight cap across
157
- // all batches. Batches don't gate concurrency only emission order.
158
- let inFlight = 0;
159
- const waiters = [];
160
- const acquire = async () => {
161
- if (inFlight < maxConcurrency) {
162
- inFlight++;
163
- 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++;
164
247
  }
165
- await new Promise((resolve) => waiters.push(resolve));
166
- inFlight++;
167
- };
168
- const release = () => {
169
- inFlight--;
170
- const next = waiters.shift();
171
- if (next)
172
- 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
+ };
173
273
  };
174
- const batchPromises = [];
175
- let batchStart = 0;
176
- let batchIndex = 0;
177
- while (batchStart < packageNames.length) {
178
- const batchSize = batchSizes[Math.min(batchIndex, batchSizes.length - 1)];
179
- const batchNames = packageNames.slice(batchStart, batchStart + batchSize);
180
- const capturedBatchIndex = batchIndex;
181
- const batchResults = new Array(batchNames.length);
182
- const batchPromise = Promise.all(batchNames.map(async (packageName, itemIndex) => {
183
- await acquire();
184
- try {
185
- const data = await getFreshPackageData(packageName, options.currentVersions?.get(packageName));
186
- packageData.set(packageName, data);
187
- completedCount++;
188
- batchResults[itemIndex] = {
189
- packageName,
190
- data,
191
- completed: completedCount,
192
- total,
193
- batchIndex: capturedBatchIndex,
194
- itemIndex,
195
- };
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();
196
298
  }
197
- finally {
198
- release();
299
+ if (controller) {
300
+ const next = controller.maybeTick();
301
+ if (next !== null)
302
+ semaphore.setLimit(next);
199
303
  }
200
- })).then(() => {
201
- pendingEmissions.set(capturedBatchIndex, batchResults.filter(Boolean));
202
- flushPending();
203
- });
204
- batchPromises.push(batchPromise);
205
- batchStart += batchSize;
206
- 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++;
207
317
  }
208
- await Promise.all(batchPromises);
318
+ await Promise.all(workers);
209
319
  return packageData;
210
320
  }
211
321
  /**
212
- * 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.
213
325
  */
214
326
  function clearPackageCache() {
215
327
  inFlightLookups.clear();
216
328
  }
217
- async function closeRegistryPool() {
218
- await registryPool.close();
219
- }
220
329
  //# sourceMappingURL=npm-registry.js.map
@@ -5,6 +5,7 @@ exports.enableDebugLogging = enableDebugLogging;
5
5
  exports.isDebugEnabled = isDebugEnabled;
6
6
  exports.getDebugLogPath = getDebugLogPath;
7
7
  const fs_1 = require("fs");
8
+ const os_1 = require("os");
8
9
  const path_1 = require("path");
9
10
  let _enabled = false;
10
11
  let _logFile = null;
@@ -18,7 +19,13 @@ function getLogFile() {
18
19
  if (!_logFile) {
19
20
  const d = new Date();
20
21
  const dateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
21
- _logFile = (0, path_1.join)(`inup-debug-${dateStr}.log`);
22
+ // Write to the OS temp dir (matches the path advertised in --help), not the
23
+ // current working directory — a debug log must never litter the user's repo.
24
+ const dir = (0, path_1.join)((0, os_1.tmpdir)(), 'inup');
25
+ if (!(0, fs_1.existsSync)(dir)) {
26
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
27
+ }
28
+ _logFile = (0, path_1.join)(dir, `inup-debug-${dateStr}.log`);
22
29
  // Write a header so the file is easy to identify
23
30
  (0, fs_1.writeFileSync)(_logFile, `=== inup debug log started at ${timestamp()} ===\n`, { flag: 'a' });
24
31
  }
@@ -23,6 +23,7 @@ __exportStar(require("./exec"), exports);
23
23
  __exportStar(require("./git"), exports);
24
24
  __exportStar(require("./version"), exports);
25
25
  __exportStar(require("./debug-logger"), exports);
26
+ __exportStar(require("./local-env"), exports);
26
27
  __exportStar(require("./color"), exports);
27
28
  __exportStar(require("./engines"), exports);
28
29
  __exportStar(require("./manifest"), exports);
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadInupLocalEnv = loadInupLocalEnv;
4
+ const fs_1 = require("fs");
5
+ const path_1 = require("path");
6
+ /**
7
+ * Loads a gitignored `.env.local` from the inup repo itself (NOT the cwd), so
8
+ * developer-only toggles can be "set once" and apply to every `inup` run in any
9
+ * project — without committing anything or touching the shell profile.
10
+ *
11
+ * The repo is located by walking UP from this file's own directory until a
12
+ * `.env.local` is found, so it works regardless of nesting depth (src/utils when
13
+ * type-stripped, dist/utils when compiled) and resolves the inup repo even when
14
+ * the binary is linked and invoked from a different project directory.
15
+ *
16
+ * Fully optional and best-effort: if the file is absent or unreadable, nothing
17
+ * happens and the run proceeds exactly as before. Existing process env always
18
+ * wins, so a one-off `INUP_PERF=0 inup` still overrides the file.
19
+ */
20
+ const ENV_FILE_NAME = '.env.local';
21
+ /** Walk upward from this file's dir to find the .env.local; null if none. */
22
+ function findEnvFile() {
23
+ let dir = __dirname;
24
+ const root = (0, path_1.parse)(dir).root;
25
+ // Cap the walk so a missing file can never loop unbounded.
26
+ for (let i = 0; i < 12; i++) {
27
+ const candidate = (0, path_1.join)(dir, ENV_FILE_NAME);
28
+ if ((0, fs_1.existsSync)(candidate))
29
+ return candidate;
30
+ if (dir === root)
31
+ break;
32
+ dir = (0, path_1.dirname)(dir);
33
+ }
34
+ return null;
35
+ }
36
+ /** Parse a minimal KEY=VALUE env file. Ignores blanks and `#` comments. */
37
+ function parseEnv(contents) {
38
+ const out = {};
39
+ for (const rawLine of contents.split(/\r?\n/)) {
40
+ const line = rawLine.trim();
41
+ if (!line || line.startsWith('#'))
42
+ continue;
43
+ const eq = line.indexOf('=');
44
+ if (eq === -1)
45
+ continue;
46
+ const key = line.slice(0, eq).trim();
47
+ if (!key)
48
+ continue;
49
+ let value = line.slice(eq + 1).trim();
50
+ // Strip a single layer of matching quotes.
51
+ if ((value.startsWith('"') && value.endsWith('"')) ||
52
+ (value.startsWith("'") && value.endsWith("'"))) {
53
+ value = value.slice(1, -1);
54
+ }
55
+ out[key] = value;
56
+ }
57
+ return out;
58
+ }
59
+ /**
60
+ * Apply `<inup-repo>/.env.local` into process.env. Existing values are never
61
+ * overwritten (real env / one-off overrides win). Returns the path if loaded.
62
+ */
63
+ function loadInupLocalEnv() {
64
+ try {
65
+ const file = findEnvFile();
66
+ if (!file)
67
+ return null;
68
+ const parsed = parseEnv((0, fs_1.readFileSync)(file, 'utf8'));
69
+ for (const [key, value] of Object.entries(parsed)) {
70
+ if (process.env[key] === undefined) {
71
+ process.env[key] = value;
72
+ }
73
+ }
74
+ return file;
75
+ }
76
+ catch {
77
+ // Best-effort: never let a dev convenience break a real run.
78
+ return null;
79
+ }
80
+ }
81
+ //# sourceMappingURL=local-env.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inup",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
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": "^25.9.1",
46
+ "@types/node": "^25.9.4",
47
47
  "@types/semver": "^7.7.1",
48
- "@vitest/coverage-v8": "^4.1.7",
49
- "prettier": "^3.8.3",
48
+ "@vitest/coverage-v8": "^4.1.9",
49
+ "prettier": "^3.9.4",
50
50
  "typescript": "^6.0.3",
51
- "vitest": "^4.1.7"
51
+ "vitest": "^4.1.9"
52
52
  },
53
53
  "dependencies": {
54
54
  "chalk": "^5.6.2",
55
55
  "commander": "^15.0.0",
56
56
  "env-paths": "^4.0.0",
57
57
  "nanospinner": "^1.2.2",
58
- "semver": "^7.8.1",
59
- "undici": "^8.3.0"
58
+ "semver": "^7.8.5",
59
+ "undici": "^8.5.0"
60
60
  },
61
61
  "engines": {
62
62
  "node": ">=20.0.0"