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.
- package/README.md +1 -1
- package/dist/cli.js +7 -0
- package/dist/config/constants.js +6 -3
- package/dist/core/package-detector.js +20 -0
- package/dist/core/upgrade-runner.js +8 -0
- package/dist/features/debug/index.js +1 -0
- package/dist/features/debug/renderer/performance-modal.js +17 -1
- package/dist/features/debug/services/perf-logger.js +129 -0
- package/dist/features/debug/services/performance-tracker.js +15 -3
- package/dist/features/headless/headless-runner.js +16 -0
- package/dist/services/http/adaptive-controller.js +153 -0
- package/dist/services/http/etag-store.js +91 -0
- package/dist/services/http/resizable-semaphore.js +71 -0
- package/dist/services/http/retry.js +23 -1
- package/dist/services/npm-registry.js +195 -86
- package/dist/utils/debug-logger.js +8 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/local-env.js +81 -0
- package/package.json +7 -7
- package/dist/services/cache-manager.js +0 -95
- 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;
|
|
@@ -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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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:
|
|
32
|
+
connections: config_1.POOL_CONNECTIONS,
|
|
24
33
|
pipelining: 1,
|
|
25
34
|
keepAliveTimeout: 30_000,
|
|
26
35
|
keepAliveMaxTimeout: 600_000,
|
|
27
|
-
headersTimeout:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* -
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* -
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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
|
|
133
|
-
*
|
|
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
|
-
|
|
193
|
+
const total = packageNames.length;
|
|
194
|
+
if (total === 0) {
|
|
138
195
|
return packageData;
|
|
139
196
|
}
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
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
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
let
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
299
|
+
if (controller) {
|
|
300
|
+
const next = controller.maybeTick();
|
|
301
|
+
if (next !== null)
|
|
302
|
+
semaphore.setLimit(next);
|
|
199
303
|
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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(
|
|
318
|
+
await Promise.all(workers);
|
|
209
319
|
return packageData;
|
|
210
320
|
}
|
|
211
321
|
/**
|
|
212
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/dist/utils/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
46
|
+
"@types/node": "^25.9.4",
|
|
47
47
|
"@types/semver": "^7.7.1",
|
|
48
|
-
"@vitest/coverage-v8": "^4.1.
|
|
49
|
-
"prettier": "^3.
|
|
48
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
49
|
+
"prettier": "^3.9.4",
|
|
50
50
|
"typescript": "^6.0.3",
|
|
51
|
-
"vitest": "^4.1.
|
|
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.
|
|
59
|
-
"undici": "^8.
|
|
58
|
+
"semver": "^7.8.5",
|
|
59
|
+
"undici": "^8.5.0"
|
|
60
60
|
},
|
|
61
61
|
"engines": {
|
|
62
62
|
"node": ">=20.0.0"
|