nanos-lint 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,6 +41,7 @@ function parseLogLevel(raw) {
41
41
  }
42
42
  var Logger = class {
43
43
  level = DEFAULT_LOG_LEVEL;
44
+ diagnosticStream = "stdout";
44
45
  constructor(initialLevel) {
45
46
  if (initialLevel && isValidLogLevel(initialLevel)) this.level = initialLevel;
46
47
  else {
@@ -56,6 +57,17 @@ var Logger = class {
56
57
  getLevel() {
57
58
  return this.level;
58
59
  }
60
+ /**
61
+ * Selects the stream used by `info`/`debug`. Machine-readable formats switch this to
62
+ * `stderr`, so a report on stdout is never interleaved with diagnostic lines.
63
+ */
64
+ setDiagnosticStream(stream) {
65
+ this.diagnosticStream = stream;
66
+ }
67
+ /** Returns the stream used by `info`/`debug`. */
68
+ getDiagnosticStream() {
69
+ return this.diagnosticStream;
70
+ }
59
71
  /** Checks if a message at the specified level should be emitted. */
60
72
  isEnabledFor(level) {
61
73
  return LOG_LEVEL_PRIORITY[this.level] >= LOG_LEVEL_PRIORITY[level];
@@ -72,13 +84,22 @@ var Logger = class {
72
84
  warn(...args) {
73
85
  if (this.isEnabledFor("warn")) console.warn(...args);
74
86
  }
75
- /** Logs an informational message to stdout. */
87
+ /** Logs an informational message to stdout, or to stderr when stdout carries a report. */
76
88
  info(...args) {
77
- if (this.isEnabledFor("info")) console.log(...args);
89
+ if (this.isEnabledFor("info")) this.emitDiagnostic(args, "log");
78
90
  }
79
- /** Logs a debug-level diagnostic message to stdout. */
91
+ /** Logs a debug-level diagnostic message to stdout, or to stderr when stdout carries a report. */
80
92
  debug(...args) {
81
- if (this.isEnabledFor("debug")) console.debug(...args);
93
+ if (this.isEnabledFor("debug")) this.emitDiagnostic(args, "debug");
94
+ }
95
+ /** Writes an `info`/`debug` diagnostic on the configured stream. */
96
+ emitDiagnostic(args, stdoutMethod) {
97
+ if (this.diagnosticStream === "stderr") {
98
+ console.error(...args);
99
+ return;
100
+ }
101
+ if (stdoutMethod === "debug") console.debug(...args);
102
+ else console.log(...args);
82
103
  }
83
104
  };
84
105
  const logger = new Logger();
@@ -5056,6 +5077,294 @@ async function writeAtomicFile(targetPath, data, options = {}) {
5056
5077
  }
5057
5078
  }
5058
5079
  //#endregion
5080
+ //#region src/terminal-progress.ts
5081
+ /** Minimum delay between two terminal redraws (about ten frames per second). */
5082
+ const PROGRESS_THROTTLE_MS = 100;
5083
+ /** Braille frames cycled while an indeterminate operation runs. */
5084
+ const SPINNER_FRAMES = [
5085
+ "⠋",
5086
+ "⠙",
5087
+ "⠹",
5088
+ "⠸",
5089
+ "⠼",
5090
+ "⠴",
5091
+ "⠦",
5092
+ "⠧",
5093
+ "⠇",
5094
+ "⠏"
5095
+ ];
5096
+ /** Fractions of a transfer logged as milestones when no terminal can be redrawn. */
5097
+ const PROGRESS_MILESTONES = [
5098
+ .25,
5099
+ .5,
5100
+ .75
5101
+ ];
5102
+ const CURSOR_HIDE = "\x1B[?25l";
5103
+ const CURSOR_SHOW = "\x1B[?25h";
5104
+ const CLEAR_LINE = "\r\x1B[2K";
5105
+ const FRAME_TAIL = " ";
5106
+ const DEFAULT_COLUMNS = 80;
5107
+ const MIN_BAR_WIDTH = 8;
5108
+ const MAX_BAR_WIDTH = 28;
5109
+ const TRUNCATION = "...";
5110
+ let progressMode = "auto";
5111
+ let cursorHidden = false;
5112
+ let guardsInstalled = false;
5113
+ let activeDynamicRenders = 0;
5114
+ /** Sets the redraw policy used by every progress display created afterwards. */
5115
+ function setProgressMode(mode) {
5116
+ progressMode = mode;
5117
+ }
5118
+ /** Returns the current redraw policy. */
5119
+ function getProgressMode() {
5120
+ return progressMode;
5121
+ }
5122
+ /** Returns whether an environment value is set to anything but an explicit false. */
5123
+ function isEnvFlagSet(value) {
5124
+ if (value === void 0) return false;
5125
+ const normalized = value.trim().toLowerCase();
5126
+ return normalized !== "" && normalized !== "0" && normalized !== "false" && normalized !== "no" && normalized !== "off";
5127
+ }
5128
+ /**
5129
+ * Returns whether dynamic redrawing is allowed for the given environment and terminal.
5130
+ * Non-interactive environments get single-line milestones instead of carriage returns.
5131
+ */
5132
+ function isProgressInteractive(env = process.env, isTTY = process.stderr.isTTY) {
5133
+ if (progressMode === "off" || !logger.isOutputEnabled() || !isTTY) return false;
5134
+ if ((env.TERM ?? "").trim().toLowerCase() === "dumb") return false;
5135
+ return !(isEnvFlagSet(env.CI) || isEnvFlagSet(env.NO_COLOR) || isEnvFlagSet(env.NANOS_NO_PROGRESS));
5136
+ }
5137
+ /** Writes a control sequence straight to file descriptor 2, tolerating a closed stderr. */
5138
+ function writeToStderr(sequence) {
5139
+ try {
5140
+ fs.writeSync(2, sequence);
5141
+ } catch (err) {}
5142
+ }
5143
+ /**
5144
+ * Writes a progress line to stderr, so stdout keeps carrying only the command report.
5145
+ *
5146
+ * Without a terminal the displays fall back to these discrete milestones, which are just as
5147
+ * much part of the progress display as an in-place redraw and therefore share its stream.
5148
+ */
5149
+ function logProgressLine(message) {
5150
+ if (logger.isEnabledFor("info")) console.error(message);
5151
+ }
5152
+ /** Writes a settle message on the stream matching its severity. */
5153
+ function logSettle(message, level) {
5154
+ if (level === "warn") {
5155
+ logger.warn(message);
5156
+ return;
5157
+ }
5158
+ logProgressLine(message);
5159
+ }
5160
+ /** Restores the cursor and abandons the active progress line; safe to call at any time. */
5161
+ function restoreTerminal() {
5162
+ if (!cursorHidden) return;
5163
+ cursorHidden = false;
5164
+ writeToStderr(`${CLEAR_LINE}${CURSOR_SHOW}`);
5165
+ }
5166
+ /** Restores the cursor, then re-raises the signal with the default disposition. */
5167
+ function handleTerminationSignal(signal) {
5168
+ restoreTerminal();
5169
+ removeTerminalGuards();
5170
+ process.kill(process.pid, signal);
5171
+ }
5172
+ /** Installs exit, crash and signal hooks so a hidden cursor is always restored. */
5173
+ function installTerminalGuards() {
5174
+ if (guardsInstalled) return;
5175
+ guardsInstalled = true;
5176
+ process.on("exit", restoreTerminal);
5177
+ process.on("uncaughtExceptionMonitor", restoreTerminal);
5178
+ process.on("SIGINT", handleTerminationSignal);
5179
+ process.on("SIGTERM", handleTerminationSignal);
5180
+ }
5181
+ /** Removes the cursor guards once no dynamic render is active. */
5182
+ function removeTerminalGuards() {
5183
+ if (!guardsInstalled) return;
5184
+ guardsInstalled = false;
5185
+ process.off("exit", restoreTerminal);
5186
+ process.off("uncaughtExceptionMonitor", restoreTerminal);
5187
+ process.off("SIGINT", handleTerminationSignal);
5188
+ process.off("SIGTERM", handleTerminationSignal);
5189
+ }
5190
+ /** Builds the mutable rendering state shared by bars and spinners. */
5191
+ function createRenderState(options) {
5192
+ const stream = options.stream ?? process.stderr;
5193
+ const now = options.now ?? Date.now;
5194
+ return {
5195
+ stream,
5196
+ interactive: options.interactive ?? isProgressInteractive(process.env, stream.isTTY),
5197
+ ownsProcessStderr: options.stream === void 0,
5198
+ label: options.label,
5199
+ now,
5200
+ throttleMs: Math.max(1, options.throttleMs ?? 100),
5201
+ startedAt: now(),
5202
+ lastFrameAt: Number.NEGATIVE_INFINITY,
5203
+ active: false
5204
+ };
5205
+ }
5206
+ /** Truncates a frame so it never wraps onto a second terminal line. */
5207
+ function fitToWidth(state, line) {
5208
+ const limit = Math.max(1, (state.stream.columns ?? DEFAULT_COLUMNS) - 1);
5209
+ if (line.length <= limit) return line;
5210
+ if (limit <= 3) return TRUNCATION.slice(0, limit);
5211
+ return `${line.slice(0, limit - 3)}${TRUNCATION}`;
5212
+ }
5213
+ /** Hides the cursor and arms the exit guards when a dynamic render starts. */
5214
+ function beginFrame(state) {
5215
+ if (state.active) return;
5216
+ state.active = true;
5217
+ if (!state.ownsProcessStderr) {
5218
+ state.stream.write(CURSOR_HIDE);
5219
+ return;
5220
+ }
5221
+ activeDynamicRenders += 1;
5222
+ if (activeDynamicRenders > 1) return;
5223
+ cursorHidden = true;
5224
+ installTerminalGuards();
5225
+ state.stream.write(CURSOR_HIDE);
5226
+ }
5227
+ /** Clears the dynamic line and reveals the cursor once the render stops. */
5228
+ function endFrame(state) {
5229
+ if (!state.interactive || !state.active) return;
5230
+ state.active = false;
5231
+ state.stream.write(CLEAR_LINE);
5232
+ if (!state.ownsProcessStderr) {
5233
+ state.stream.write(CURSOR_SHOW);
5234
+ return;
5235
+ }
5236
+ activeDynamicRenders = Math.max(0, activeDynamicRenders - 1);
5237
+ if (activeDynamicRenders === 0) {
5238
+ cursorHidden = false;
5239
+ removeTerminalGuards();
5240
+ state.stream.write(CURSOR_SHOW);
5241
+ }
5242
+ }
5243
+ /** Draws one frame, throttled unless `force`d, replacing whatever the line held. */
5244
+ function drawFrame(state, line, force = false) {
5245
+ if (!state.interactive) return;
5246
+ const now = state.now();
5247
+ if (!force && now - state.lastFrameAt < state.throttleMs) return;
5248
+ state.lastFrameAt = now;
5249
+ beginFrame(state);
5250
+ state.stream.write(`${CLEAR_LINE}${fitToWidth(state, line)}${FRAME_TAIL}`);
5251
+ }
5252
+ /** Formats a duration in milliseconds as seconds. */
5253
+ function formatDuration(ms) {
5254
+ const seconds = Math.max(0, ms) / 1e3;
5255
+ return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`;
5256
+ }
5257
+ /** Formats a completed transfer as size, duration and average speed. */
5258
+ function formatTransferSummary(bytes, elapsedMs) {
5259
+ const elapsed = Math.max(elapsedMs, 1);
5260
+ return `${formatBytes(bytes)} in ${formatDuration(elapsed)} (${formatBytes(bytes / (elapsed / 1e3))}/s)`;
5261
+ }
5262
+ /** Builds the `[====> ]` fill for a completion ratio. */
5263
+ function buildBar(ratio, width) {
5264
+ const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
5265
+ if (filled >= width) return "=".repeat(width);
5266
+ return `${"=".repeat(filled)}>${" ".repeat(width - filled - 1)}`;
5267
+ }
5268
+ /** Picks a bar width that keeps the whole frame inside the terminal. */
5269
+ function barWidthFor(state, reserved) {
5270
+ const columns = state.stream.columns ?? DEFAULT_COLUMNS;
5271
+ return Math.max(MIN_BAR_WIDTH, Math.min(MAX_BAR_WIDTH, columns - reserved));
5272
+ }
5273
+ /** Renders one deterministic transfer frame. */
5274
+ function renderBarFrame(state, transferred, total) {
5275
+ const elapsedMs = state.now() - state.startedAt;
5276
+ const speed = transferred / (Math.max(elapsedMs, 1) / 1e3);
5277
+ const speedText = `${formatBytes(speed)}/s`;
5278
+ if (!total || total <= 0) return `${state.label} ${formatBytes(transferred)} (${speedText}, ${formatDuration(elapsedMs)})`;
5279
+ const ratio = Math.max(0, Math.min(1, transferred / total));
5280
+ const percent = Math.floor(ratio * 100);
5281
+ const eta = speed > 0 && transferred < total ? `, ETA ${formatDuration((total - transferred) / speed * 1e3)}` : "";
5282
+ const tail = ` ${percent}% (${formatBytes(transferred)} / ${formatBytes(total)}, ${speedText}${eta})`;
5283
+ const width = barWidthFor(state, state.label.length + tail.length + 2 + 4);
5284
+ return `${state.label} [${buildBar(ratio, width)}]${tail}`;
5285
+ }
5286
+ /** Renders one indeterminate spinner frame. */
5287
+ function renderSpinnerFrame(state, frame) {
5288
+ const glyph = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? "|";
5289
+ return `${state.label} ${glyph} (${formatDuration(state.now() - state.startedAt)})`;
5290
+ }
5291
+ /** Creates a byte progress bar; without a terminal it logs start, milestones and completion. */
5292
+ function createProgressBar(options) {
5293
+ const state = createRenderState(options);
5294
+ let transferred = 0;
5295
+ let total = options.total !== void 0 && options.total > 0 ? options.total : void 0;
5296
+ let milestone = 0;
5297
+ if (!state.interactive && options.announce !== false) logProgressLine(`${state.label}${total ? ` (${formatBytes(total)})` : ""}...`);
5298
+ if (state.interactive) drawFrame(state, renderBarFrame(state, 0, total), true);
5299
+ const settle = (message, level) => {
5300
+ endFrame(state);
5301
+ if (message) logSettle(message, level);
5302
+ };
5303
+ return {
5304
+ update: (next, nextTotal) => {
5305
+ transferred = Math.max(transferred, next);
5306
+ if (nextTotal !== void 0 && nextTotal > 0) total = nextTotal;
5307
+ if (total !== void 0 && transferred > total) total = void 0;
5308
+ if (state.interactive) {
5309
+ drawFrame(state, renderBarFrame(state, transferred, total));
5310
+ return;
5311
+ }
5312
+ if (!total) return;
5313
+ const ratio = transferred / total;
5314
+ while (milestone < PROGRESS_MILESTONES.length && ratio >= (PROGRESS_MILESTONES[milestone] ?? 1)) {
5315
+ const reached = PROGRESS_MILESTONES[milestone] ?? 1;
5316
+ milestone += 1;
5317
+ logProgressLine(`${state.label}: ${Math.round(reached * 100)}% (${formatBytes(transferred)} / ${formatBytes(total)})`);
5318
+ }
5319
+ },
5320
+ finish: (message) => settle(message, "info"),
5321
+ fail: (message) => settle(message, "warn")
5322
+ };
5323
+ }
5324
+ /** Creates an indeterminate spinner driven by a timer and by manual ticks from sync loops. */
5325
+ function createSpinner(options) {
5326
+ const state = createRenderState(options);
5327
+ let frame = 0;
5328
+ let timer;
5329
+ const clearTimer = () => {
5330
+ if (timer !== void 0) {
5331
+ clearInterval(timer);
5332
+ timer = void 0;
5333
+ }
5334
+ };
5335
+ const settle = (message, level) => {
5336
+ clearTimer();
5337
+ endFrame(state);
5338
+ if (message) logSettle(message, level);
5339
+ };
5340
+ return {
5341
+ start: () => {
5342
+ if (!state.interactive) {
5343
+ if (options.announce !== false) logProgressLine(`${state.label}...`);
5344
+ return;
5345
+ }
5346
+ drawFrame(state, renderSpinnerFrame(state, frame), true);
5347
+ if (timer === void 0) {
5348
+ timer = setInterval(() => {
5349
+ frame += 1;
5350
+ drawFrame(state, renderSpinnerFrame(state, frame), true);
5351
+ }, state.throttleMs);
5352
+ timer.unref();
5353
+ }
5354
+ },
5355
+ tick: () => {
5356
+ frame += 1;
5357
+ drawFrame(state, renderSpinnerFrame(state, frame));
5358
+ },
5359
+ updateText: (label) => {
5360
+ state.label = label;
5361
+ drawFrame(state, renderSpinnerFrame(state, frame), true);
5362
+ },
5363
+ stop: (message) => settle(message, "info"),
5364
+ fail: (message) => settle(message, "warn")
5365
+ };
5366
+ }
5367
+ //#endregion
5059
5368
  //#region src/annotations-realms.ts
5060
5369
  /** Side markers emitted by the upstream docgen, mapped to the realm that owns them. */
5061
5370
  const SIDE_MARKERS = [
@@ -5065,6 +5374,8 @@ const SIDE_MARKERS = [
5065
5374
  ["authority-only", "shared"],
5066
5375
  ["network-authority", "shared"]
5067
5376
  ];
5377
+ /** Number of source lines parsed between two spinner ticks while splitting realms. */
5378
+ const PARSE_PROGRESS_STEP_LINES = 2e3;
5068
5379
  /** Finds the realm declared by the side marker of a documentation block. */
5069
5380
  function realmOfDocBlock(doc) {
5070
5381
  for (const line of doc) for (const [image, realm] of SIDE_MARKERS) if (line.includes(`assets/${image}.png`)) return realm;
@@ -5095,10 +5406,17 @@ function kindOf(declaration) {
5095
5406
  return "other";
5096
5407
  }
5097
5408
  /** Segments annotations into comment runs plus their optional declaration statement. */
5098
- function parseAnnotationUnits(text) {
5409
+ function parseAnnotationUnits(text, onProgress) {
5099
5410
  const lines = text.split(/\r?\n/);
5100
5411
  const units = [];
5101
5412
  let index = 0;
5413
+ let nextTick = PARSE_PROGRESS_STEP_LINES;
5414
+ const tick = () => {
5415
+ if (onProgress !== void 0 && index >= nextTick) {
5416
+ nextTick = index + PARSE_PROGRESS_STEP_LINES;
5417
+ onProgress();
5418
+ }
5419
+ };
5102
5420
  const push = (doc, decl) => {
5103
5421
  const first = decl?.[0] ?? null;
5104
5422
  units.push({
@@ -5112,6 +5430,7 @@ function parseAnnotationUnits(text) {
5112
5430
  });
5113
5431
  };
5114
5432
  while (index < lines.length) {
5433
+ tick();
5115
5434
  const doc = [];
5116
5435
  while (index < lines.length && (lines[index].startsWith("---") || lines[index].trim() === "")) {
5117
5436
  if (lines[index].startsWith("---")) doc.push(lines[index]);
@@ -5210,11 +5529,12 @@ function renderRealm(units, realm) {
5210
5529
  * and a realm-specific class table is dropped from the other realm unless it still has
5211
5530
  * instance members there.
5212
5531
  */
5213
- function splitAnnotationsByRealm(text) {
5214
- const units = parseAnnotationUnits(text);
5532
+ function splitAnnotationsByRealm(text, onProgress) {
5533
+ const units = parseAnnotationUnits(text, onProgress);
5215
5534
  const classRealms = classRealmsOf(units);
5216
5535
  const crossRealm = crossRealmClasses(units);
5217
5536
  const memberRealms = markedMemberRealms(units);
5537
+ onProgress?.();
5218
5538
  for (const unit of units) {
5219
5539
  if (unit.owner) {
5220
5540
  unit.realm = unit.owner;
@@ -5228,8 +5548,11 @@ function splitAnnotationsByRealm(text) {
5228
5548
  const classRealm = unit.receiver ? classRealms.get(unit.receiver) : void 0;
5229
5549
  unit.realm = classRealm && classRealm !== "shared" && !crossRealm.has(unit.receiver) ? classRealm : "shared";
5230
5550
  }
5551
+ onProgress?.();
5552
+ const client = renderRealm(units, "client");
5553
+ onProgress?.();
5231
5554
  return {
5232
- client: renderRealm(units, "client"),
5555
+ client,
5233
5556
  server: renderRealm(units, "server")
5234
5557
  };
5235
5558
  }
@@ -5259,17 +5582,204 @@ function deriveRealmAnnotationFiles(annotationsPath, cacheDir) {
5259
5582
  client: clientPath,
5260
5583
  server: serverPath
5261
5584
  };
5262
- const split = splitAnnotationsByRealm(fs.readFileSync(annotationsPath, "utf-8"));
5263
- fs.mkdirSync(targetDir, { recursive: true });
5264
- writeAtomicFileSync(clientPath, split.client);
5265
- writeAtomicFileSync(serverPath, split.server);
5266
- logger.debug(`[realms] Derived realm annotations in ${targetDir}.`);
5585
+ const spinner = createSpinner({ label: "[realms] Deriving client/server annotations" });
5586
+ spinner.start();
5587
+ try {
5588
+ const split = splitAnnotationsByRealm(fs.readFileSync(annotationsPath, "utf-8"), () => spinner.tick());
5589
+ spinner.updateText("[realms] Writing derived annotations");
5590
+ fs.mkdirSync(targetDir, { recursive: true });
5591
+ writeAtomicFileSync(clientPath, split.client);
5592
+ writeAtomicFileSync(serverPath, split.server);
5593
+ logger.debug(`[realms] Derived realm annotations in ${targetDir}.`);
5594
+ spinner.stop(`[realms] Derived client/server annotations in ${targetDir}.`);
5595
+ } catch (err) {
5596
+ spinner.fail();
5597
+ throw err;
5598
+ }
5267
5599
  return {
5268
5600
  client: clientPath,
5269
5601
  server: serverPath
5270
5602
  };
5271
5603
  }
5272
5604
  //#endregion
5605
+ //#region src/download-guard.ts
5606
+ /**
5607
+ * Shared transport policy for every outbound download performed by nanos-lint.
5608
+ *
5609
+ * `SECURITY.md` requires download requests *and* HTTP redirects to use HTTPS and to stay on
5610
+ * GitHub infrastructure. Redirects are therefore resolved hop by hop in `"manual"` mode: the
5611
+ * default `"follow"` mode contacts the redirect target before any check can run, which would
5612
+ * let a redirect downgrade to plaintext `http://` or reach an internal address.
5613
+ */
5614
+ const ALLOWED_DOWNLOAD_DOMAINS = ["github.com", "githubusercontent.com"];
5615
+ /** Headers that must never follow a redirect to another origin. */
5616
+ const CREDENTIAL_HEADERS = [
5617
+ "authorization",
5618
+ "cookie",
5619
+ "proxy-authorization"
5620
+ ];
5621
+ /** Returns the origin of an already validated URL, or null when it cannot be parsed. */
5622
+ function originOf(urlString) {
5623
+ try {
5624
+ return new URL(urlString).origin;
5625
+ } catch {
5626
+ return null;
5627
+ }
5628
+ }
5629
+ /**
5630
+ * Returns the caller's headers with credentials removed.
5631
+ *
5632
+ * The runtime strips `Authorization` when it follows a redirect by itself, so dropping it here
5633
+ * keeps that guarantee once the guard re-issues each hop manually instead of following it.
5634
+ */
5635
+ function withoutCredentialHeaders(headers) {
5636
+ if (headers === void 0) return;
5637
+ const stripped = new Headers(headers);
5638
+ for (const name of CREDENTIAL_HEADERS) stripped.delete(name);
5639
+ return stripped;
5640
+ }
5641
+ /** Validates that a URL uses HTTPS and targets an allowlisted GitHub host. */
5642
+ function isAllowedDownloadUrl(urlString) {
5643
+ try {
5644
+ const parsed = new URL(urlString);
5645
+ if (parsed.protocol !== "https:") return false;
5646
+ const hostname = parsed.hostname.toLowerCase();
5647
+ return ALLOWED_DOWNLOAD_DOMAINS.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`));
5648
+ } catch {
5649
+ return false;
5650
+ }
5651
+ }
5652
+ /**
5653
+ * Releases a response body without letting cleanup failures mask the block reason.
5654
+ *
5655
+ * Every failure mode is swallowed on purpose: a missing body, a double that exposes no
5656
+ * `cancel()`, and a `cancel()` that rejects or returns a non-promise must all leave the
5657
+ * caller free to report why the response was refused.
5658
+ */
5659
+ async function cancelResponseBody(res) {
5660
+ try {
5661
+ if (typeof res.body?.cancel === "function") await res.body.cancel();
5662
+ } catch (err) {}
5663
+ }
5664
+ /** Reads the `Location` header of a redirect response, tolerating minimal test doubles. */
5665
+ function getRedirectLocation(res) {
5666
+ if (typeof res.headers?.get !== "function") return null;
5667
+ return res.headers.get("location");
5668
+ }
5669
+ /**
5670
+ * Reads the `content-length` of a response whose bytes are streamed as received.
5671
+ *
5672
+ * A content-encoded response (`content-encoding: gzip`, `br`, ...) is decoded by the runtime
5673
+ * while its `content-length` still counts the compressed payload, so that value describes
5674
+ * neither the byte stream a progress bar counts nor the text that reaches the cache. Such a
5675
+ * response reports no usable length and progress falls back to transferred bytes and speed.
5676
+ */
5677
+ function parseDeclaredContentLength(res) {
5678
+ const encoding = (res.headers?.get?.("content-encoding") ?? "").trim().toLowerCase();
5679
+ if (encoding !== "" && encoding !== "identity") return;
5680
+ const header = res.headers?.get?.("content-length");
5681
+ if (!header) return;
5682
+ const declared = parseInt(header, 10);
5683
+ return Number.isFinite(declared) && declared > 0 ? declared : void 0;
5684
+ }
5685
+ /** Classifies a response the runtime followed by itself despite `redirect: "manual"`. */
5686
+ function isOpaqueRedirect(res) {
5687
+ return res.type === "opaqueredirect";
5688
+ }
5689
+ /**
5690
+ * Performs a fetch with hop-by-hop redirect validation against the allowlist.
5691
+ *
5692
+ * Each hop must use HTTPS and stay on an allowlisted GitHub host; a hop that does not is never
5693
+ * contacted, because `redirect: "manual"` surfaces the 3xx response instead of following it.
5694
+ * The final response URL is validated as well, which keeps the policy enforced even on a
5695
+ * runtime that ignores `"manual"` and follows redirects on its own.
5696
+ */
5697
+ async function guardedFetch(url, init = {}) {
5698
+ if (!isAllowedDownloadUrl(url)) return {
5699
+ ok: false,
5700
+ reason: "disallowed-url",
5701
+ url
5702
+ };
5703
+ let currentUrl = url;
5704
+ let currentInit = init;
5705
+ let followed = 0;
5706
+ const requestOrigin = originOf(url);
5707
+ for (;;) {
5708
+ let response;
5709
+ try {
5710
+ response = await fetch(currentUrl, {
5711
+ ...currentInit,
5712
+ redirect: "manual"
5713
+ });
5714
+ } catch (err) {
5715
+ return {
5716
+ ok: false,
5717
+ reason: "network-error",
5718
+ url: currentUrl,
5719
+ error: err
5720
+ };
5721
+ }
5722
+ if (isOpaqueRedirect(response)) {
5723
+ await cancelResponseBody(response);
5724
+ return {
5725
+ ok: false,
5726
+ reason: "disallowed-redirect",
5727
+ url: currentUrl
5728
+ };
5729
+ }
5730
+ if (!(response.status >= 300 && response.status < 400)) {
5731
+ if (response.url && !isAllowedDownloadUrl(response.url)) {
5732
+ await cancelResponseBody(response);
5733
+ return {
5734
+ ok: false,
5735
+ reason: "disallowed-redirect",
5736
+ url: response.url
5737
+ };
5738
+ }
5739
+ return {
5740
+ ok: true,
5741
+ response
5742
+ };
5743
+ }
5744
+ const location = getRedirectLocation(response);
5745
+ if (location === null || location.trim() === "") {
5746
+ await cancelResponseBody(response);
5747
+ return {
5748
+ ok: false,
5749
+ reason: "missing-location",
5750
+ url: currentUrl
5751
+ };
5752
+ }
5753
+ let nextUrl;
5754
+ try {
5755
+ nextUrl = new URL(location, currentUrl).href;
5756
+ } catch (err) {
5757
+ nextUrl = "";
5758
+ }
5759
+ if (!isAllowedDownloadUrl(nextUrl)) {
5760
+ await cancelResponseBody(response);
5761
+ return {
5762
+ ok: false,
5763
+ reason: "disallowed-redirect",
5764
+ url: nextUrl || location
5765
+ };
5766
+ }
5767
+ await cancelResponseBody(response);
5768
+ followed++;
5769
+ if (followed > 5) return {
5770
+ ok: false,
5771
+ reason: "too-many-redirects",
5772
+ url: nextUrl
5773
+ };
5774
+ const nextOrigin = originOf(nextUrl);
5775
+ if (requestOrigin !== null && nextOrigin !== null && nextOrigin !== requestOrigin) currentInit = {
5776
+ ...init,
5777
+ headers: withoutCredentialHeaders(init.headers)
5778
+ };
5779
+ currentUrl = nextUrl;
5780
+ }
5781
+ }
5782
+ //#endregion
5273
5783
  //#region src/luals/version.ts
5274
5784
  const FALLBACK_LUALS_VERSION = "3.19.1";
5275
5785
  const DEFAULT_LUALS_VERSION = "latest";
@@ -5301,10 +5811,15 @@ async function fetchLatestLuaLSVersionFromGitHub() {
5301
5811
  try {
5302
5812
  const headers = { "User-Agent": "nanos-lint" };
5303
5813
  if (process.env.GITHUB_TOKEN) headers["Authorization"] = `token ${process.env.GITHUB_TOKEN}`;
5304
- const res = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest", {
5814
+ const result = await guardedFetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest", {
5305
5815
  headers,
5306
5816
  signal: AbortSignal.timeout(5e3)
5307
5817
  });
5818
+ if (!result.ok || !result.response) {
5819
+ if (result.reason && result.reason !== "network-error") logger.warn(`[luals] Refused LuaLS release lookup: requests must stay on allowlisted GitHub infrastructure over HTTPS (${result.url ?? "unknown URL"}).`);
5820
+ return null;
5821
+ }
5822
+ const res = result.response;
5308
5823
  if (res.ok) {
5309
5824
  const data = await res.json();
5310
5825
  const version = typeof data.tag_name === "string" ? sanitizeLuaLSVersion(data.tag_name) : null;
@@ -6100,20 +6615,6 @@ function findExistingLuaLSDir(version, baseCacheDir = getBaseLuaLSCacheDir()) {
6100
6615
  //#region src/luals/download.ts
6101
6616
  const DOWNLOAD_TIMEOUT_MS = 12e4;
6102
6617
  const MAX_ARCHIVE_SIZE_BYTES = 157286400;
6103
- const ALLOWED_DOWNLOAD_DOMAINS = ["github.com", "githubusercontent.com"];
6104
- /**
6105
- * Validates that a download URL uses HTTPS and targets an allowlisted host.
6106
- */
6107
- function isAllowedDownloadUrl(urlString) {
6108
- try {
6109
- const parsed = new URL(urlString);
6110
- if (parsed.protocol !== "https:") return false;
6111
- const hostname = parsed.hostname.toLowerCase();
6112
- return ALLOWED_DOWNLOAD_DOMAINS.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`));
6113
- } catch {
6114
- return false;
6115
- }
6116
- }
6117
6618
  /** Chunk size used when hashing an archive, so a 150 MB asset is never buffered whole. */
6118
6619
  const HASH_CHUNK_SIZE_BYTES = 1048576;
6119
6620
  /**
@@ -6148,6 +6649,15 @@ async function* limitDownloadStream(source) {
6148
6649
  yield chunk;
6149
6650
  }
6150
6651
  }
6652
+ /** Reports cumulative transferred bytes while passing a download stream through unchanged. */
6653
+ async function* trackDownloadProgress(source, onProgress) {
6654
+ let transferred = 0;
6655
+ for await (const chunk of source) {
6656
+ transferred += chunk.length;
6657
+ onProgress(transferred);
6658
+ yield chunk;
6659
+ }
6660
+ }
6151
6661
  /** Returns whether a cache directory holds a complete, executable LuaLS installation. */
6152
6662
  function isCompleteLuaLSInstall(binaryPath, completeMarker, version) {
6153
6663
  if (!fs.existsSync(binaryPath) || !fs.existsSync(completeMarker)) return false;
@@ -6208,28 +6718,27 @@ async function downloadAndPromoteLuaLS(resolvedVersion, info, destDir, binaryPat
6208
6718
  logger.info(`[luals] Reusing existing LuaLS ${resolvedVersion} installation from ${existingSourceDir}...`);
6209
6719
  fs.cpSync(existingSourceDir, tempDir, { recursive: true });
6210
6720
  } else {
6211
- if (!isAllowedDownloadUrl(url)) throw new LuaLSError(`Refusing to download LuaLS from untrusted URL: ${url}`, "ERR_LUALS_DOWNLOAD", "Download URLs must use HTTPS and target an allowlisted GitHub host.");
6212
6721
  logger.info(`[luals] Downloading LuaLS ${resolvedVersion} from ${url}...`);
6213
6722
  let response = null;
6214
6723
  let lastErr = null;
6215
6724
  for (let attempt = 1; attempt <= 3; attempt++) {
6216
6725
  try {
6217
- const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
6218
- if (res.url && !isAllowedDownloadUrl(res.url)) {
6219
- if (typeof res.body?.cancel === "function") try {
6220
- await res.body.cancel();
6221
- } catch (cancelErr) {}
6222
- logger.warn(`[luals] Redirect to untrusted URL blocked: ${res.url}`);
6223
- throw new LuaLSError(`Redirect to untrusted URL blocked: ${res.url}`, "ERR_LUALS_DOWNLOAD", "Download redirects must stay on allowlisted HTTPS GitHub hosts.");
6224
- }
6225
- if (res.ok && res.body) {
6226
- response = res;
6726
+ const outcome = await guardedFetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
6727
+ if (!outcome.ok || !outcome.response) {
6728
+ if (outcome.reason !== "network-error") {
6729
+ const blockedMessage = `Redirect to untrusted URL blocked: ${outcome.url ?? url}`;
6730
+ logger.warn(`[luals] ${blockedMessage}`);
6731
+ throw new LuaLSError(blockedMessage, "ERR_LUALS_DOWNLOAD", "Download redirects must stay on allowlisted HTTPS GitHub hosts.");
6732
+ }
6733
+ lastErr = outcome.error ?? /* @__PURE__ */ new Error(`Failed to download ${url}`);
6734
+ } else if (outcome.response.ok && outcome.response.body) {
6735
+ response = outcome.response;
6227
6736
  break;
6737
+ } else {
6738
+ const res = outcome.response;
6739
+ await cancelResponseBody(res);
6740
+ lastErr = /* @__PURE__ */ new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`);
6228
6741
  }
6229
- if (typeof res.body?.cancel === "function") try {
6230
- await res.body.cancel();
6231
- } catch (cancelErr) {}
6232
- lastErr = /* @__PURE__ */ new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`);
6233
6742
  } catch (err) {
6234
6743
  if (err instanceof LuaLSError) throw err;
6235
6744
  lastErr = err;
@@ -6245,50 +6754,76 @@ async function downloadAndPromoteLuaLS(resolvedVersion, info, destDir, binaryPat
6245
6754
  throw new LuaLSError(`Archive size (${contentLength} bytes) exceeds maximum limit (${MAX_ARCHIVE_SIZE_BYTES} bytes)`, "ERR_LUALS_DOWNLOAD", "Verify the LuaLS release asset size or specify a local binary with LUALS_BIN.");
6246
6755
  }
6247
6756
  }
6757
+ const declaredTotal = parseDeclaredContentLength(response);
6758
+ const downloadStartedAt = Date.now();
6759
+ let transferred = 0;
6760
+ const progress = createProgressBar({
6761
+ label: `[luals] Downloading ${info.assetName}`,
6762
+ total: declaredTotal,
6763
+ announce: false
6764
+ });
6248
6765
  const fileStream = fs.createWriteStream(archivePath);
6249
6766
  try {
6250
6767
  const streamSource = typeof Readable.fromWeb === "function" && !("pipe" in response.body) ? Readable.fromWeb(response.body) : response.body;
6251
- await pipeline(streamSource, limitDownloadStream, fileStream);
6768
+ await pipeline(streamSource, limitDownloadStream, (source) => trackDownloadProgress(source, (bytes) => {
6769
+ transferred = bytes;
6770
+ progress.update(bytes, declaredTotal);
6771
+ }), fileStream);
6252
6772
  } catch (streamErr) {
6773
+ progress.fail();
6253
6774
  try {
6254
6775
  if (fs.existsSync(archivePath)) fs.unlinkSync(archivePath);
6255
6776
  } catch (unlinkErr) {}
6256
6777
  if (streamErr instanceof LuaLSError) throw streamErr;
6257
6778
  throw new LuaLSError(`Failed to download LuaLS from ${url}: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}`, "ERR_LUALS_DOWNLOAD", "Check your network connection or specify a custom binary with LUALS_BIN.", { cause: streamErr });
6258
6779
  }
6780
+ progress.finish(`[luals] Downloaded ${info.assetName} (${formatTransferSummary(transferred, Date.now() - downloadStartedAt)})`);
6259
6781
  const archiveSha256 = computeFileSha256(archivePath);
6260
6782
  logger.info(`[luals] Downloaded ${info.assetName} (SHA-256 ${archiveSha256})`);
6261
6783
  logger.info(`[luals] Extracting to ${destDir}...`);
6262
- await validateArchiveMembers(archivePath);
6263
- try {
6264
- const tarArgs = process.platform === "win32" ? [
6265
- "-xf",
6266
- path.basename(archivePath),
6267
- "-C",
6268
- tempDir
6269
- ] : [
6270
- "-xf",
6271
- path.basename(archivePath),
6272
- "--no-same-owner",
6273
- "--no-same-permissions",
6274
- "-C",
6275
- tempDir
6276
- ];
6277
- await execFileAsync$1(getTarBinary(), tarArgs, { cwd: path.dirname(archivePath) });
6278
- } catch (tarErr) {
6279
- if (process.platform === "win32" && info.assetName.endsWith(".zip")) await execFileAsync$1("powershell.exe", [
6280
- "-NoProfile",
6281
- "-Command",
6282
- `Expand-Archive -Path '${escapePowerShellSingleQuote(archivePath)}' -DestinationPath '${escapePowerShellSingleQuote(tempDir)}' -Force`
6283
- ]);
6284
- else throw tarErr;
6285
- }
6286
- const extractedSize = getDirectorySize(tempDir);
6287
- if (extractedSize > 524288e3) throw new LuaLSError(`Extracted archive size (${extractedSize} bytes) exceeds maximum limit (${MAX_DECOMPRESSED_SIZE_BYTES} bytes)`, "ERR_LUALS_EXTRACT", "Run 'nanos-lint clean-cache' and ensure there is sufficient disk space.");
6784
+ const spinner = createSpinner({
6785
+ label: `[luals] Extracting ${info.assetName}`,
6786
+ announce: false
6787
+ });
6788
+ spinner.start();
6789
+ let extractedSize;
6288
6790
  try {
6289
- fs.unlinkSync(archivePath);
6290
- } catch (err) {
6291
- logger.debug(`[luals] Failed to delete temporary archive ${archivePath}: ${err instanceof Error ? err.message : String(err)}`);
6791
+ await validateArchiveMembers(archivePath);
6792
+ try {
6793
+ const tarArgs = process.platform === "win32" ? [
6794
+ "-xf",
6795
+ path.basename(archivePath),
6796
+ "-C",
6797
+ tempDir
6798
+ ] : [
6799
+ "-xf",
6800
+ path.basename(archivePath),
6801
+ "--no-same-owner",
6802
+ "--no-same-permissions",
6803
+ "-C",
6804
+ tempDir
6805
+ ];
6806
+ await execFileAsync$1(getTarBinary(), tarArgs, { cwd: path.dirname(archivePath) });
6807
+ } catch (tarErr) {
6808
+ if (process.platform === "win32" && info.assetName.endsWith(".zip")) await execFileAsync$1("powershell.exe", [
6809
+ "-NoProfile",
6810
+ "-Command",
6811
+ `Expand-Archive -Path '${escapePowerShellSingleQuote(archivePath)}' -DestinationPath '${escapePowerShellSingleQuote(tempDir)}' -Force`
6812
+ ]);
6813
+ else throw tarErr;
6814
+ }
6815
+ spinner.updateText("[luals] Verifying extracted files");
6816
+ extractedSize = getDirectorySize(tempDir);
6817
+ if (extractedSize > 524288e3) throw new LuaLSError(`Extracted archive size (${extractedSize} bytes) exceeds maximum limit (${MAX_DECOMPRESSED_SIZE_BYTES} bytes)`, "ERR_LUALS_EXTRACT", "Run 'nanos-lint clean-cache' and ensure there is sufficient disk space.");
6818
+ try {
6819
+ fs.unlinkSync(archivePath);
6820
+ } catch (err) {
6821
+ logger.debug(`[luals] Failed to delete temporary archive ${archivePath}: ${err instanceof Error ? err.message : String(err)}`);
6822
+ }
6823
+ spinner.stop(`[luals] Extracted ${formatBytes(extractedSize)} to ${destDir}`);
6824
+ } catch (extractErr) {
6825
+ spinner.fail();
6826
+ throw extractErr;
6292
6827
  }
6293
6828
  }
6294
6829
  const tempBinaryPath = path.join(tempDir, info.binaryRelativePath);
@@ -10555,7 +11090,7 @@ const DOCGEN_BRANCH = "docgen-output";
10555
11090
  const RAW_ANNOTATIONS_URL = "https://raw.githubusercontent.com/nanos-world/vscode-extension/refs/heads/docgen-output/annotations.lua";
10556
11091
  const GITHUB_COMMITS_API = "https://api.github.com/repos/nanos-world/vscode-extension/commits/docgen-output";
10557
11092
  /** Reads an HTTP response body with an upper byte limit to guard against memory exhaustion. */
10558
- async function readBoundedResponseBody(res, maxBytes, onExceeded) {
11093
+ async function readBoundedResponseBody(res, maxBytes, onExceeded, onProgress) {
10559
11094
  const lengthHeader = res.headers?.get?.("content-length");
10560
11095
  if (lengthHeader) {
10561
11096
  const declared = parseInt(lengthHeader, 10);
@@ -10576,6 +11111,7 @@ async function readBoundedResponseBody(res, maxBytes, onExceeded) {
10576
11111
  onExceeded(total, "stream");
10577
11112
  return null;
10578
11113
  }
11114
+ onProgress?.(total);
10579
11115
  chunks.push(buf);
10580
11116
  }
10581
11117
  return Buffer.concat(chunks).toString("utf-8");
@@ -10591,15 +11127,35 @@ async function readBoundedResponseBody(res, maxBytes, onExceeded) {
10591
11127
  }
10592
11128
  return "";
10593
11129
  }
11130
+ /**
11131
+ * Resolves a request through the download guard, which refuses an off-allowlist request URL,
11132
+ * an off-allowlist redirect hop and an off-allowlist final response URL, releasing the body of
11133
+ * every refused response instead of downloading it.
11134
+ */
11135
+ async function resolveGuardedResponse(url, init) {
11136
+ const result = await guardedFetch(url, init);
11137
+ if (result.ok && result.response) return { res: result.response };
11138
+ return { block: result };
11139
+ }
11140
+ /** Builds the user-facing error for a request refused by the download transport policy. */
11141
+ function downloadBlockError(result) {
11142
+ if (result.reason === "network-error") return new AnnotationsError(`Failed to download annotations.lua: ${result.error instanceof Error ? result.error.message : String(result.error)}`, "ERR_ANNOTATIONS_DOWNLOAD", "Verify your internet connection and that GitHub raw endpoints are accessible.", { cause: result.error });
11143
+ return new AnnotationsError(`${result.reason === "too-many-redirects" ? "Redirect chain too long" : result.reason === "missing-location" ? "Redirect without a usable target" : "Redirect blocked"}: requests must stay on allowlisted GitHub infrastructure over HTTPS (${result.url ?? "unknown URL"})`, "ERR_ANNOTATIONS_DOWNLOAD", "Verify that no proxy or DNS override redirects GitHub traffic to another host or to plaintext http://.");
11144
+ }
10594
11145
  /** Fetches the latest commit SHA for the annotations branch from the GitHub API. */
10595
11146
  async function fetchLatestCommitId() {
10596
11147
  try {
10597
11148
  const headers = { "User-Agent": "nanos-lint" };
10598
11149
  if (process.env.GITHUB_TOKEN) headers["Authorization"] = `token ${process.env.GITHUB_TOKEN}`;
10599
- const res = await fetch(GITHUB_COMMITS_API, {
11150
+ const outcome = await resolveGuardedResponse(GITHUB_COMMITS_API, {
10600
11151
  headers,
10601
11152
  signal: AbortSignal.timeout(5e3)
10602
11153
  });
11154
+ if ("block" in outcome) {
11155
+ if (outcome.block.reason !== "network-error") logger.warn(`[annotations] ${downloadBlockError(outcome.block).message}`);
11156
+ return null;
11157
+ }
11158
+ const { res } = outcome;
10603
11159
  if (res.ok) {
10604
11160
  const rawJson = await readBoundedResponseBody(res, MAX_COMMIT_JSON_SIZE_BYTES, (bytes) => {
10605
11161
  logger.warn(`GitHub commits response exceeded size limit of ${MAX_COMMIT_JSON_SIZE_BYTES} bytes (${bytes} bytes)`);
@@ -10626,16 +11182,40 @@ function getRawAnnotationsUrl(commitSha) {
10626
11182
  }
10627
11183
  /** Downloads raw annotations.lua content from GitHub raw content endpoints. */
10628
11184
  async function fetchRawAnnotationsContent(commitSha) {
10629
- const url = getRawAnnotationsUrl(commitSha);
10630
- const res = await fetch(url, {
11185
+ const outcome = await resolveGuardedResponse(getRawAnnotationsUrl(commitSha), {
10631
11186
  headers: { "User-Agent": "nanos-lint" },
10632
11187
  signal: AbortSignal.timeout(15e3)
10633
11188
  });
11189
+ if ("block" in outcome) {
11190
+ const blockError = downloadBlockError(outcome.block);
11191
+ logger.warn(`[annotations] ${blockError.message}`);
11192
+ throw blockError;
11193
+ }
11194
+ const { res } = outcome;
10634
11195
  if (!res.ok) throw new AnnotationsError(`Failed to download annotations.lua: ${res.status} ${res.statusText}`, "ERR_ANNOTATIONS_DOWNLOAD", "Verify your internet connection and that GitHub raw endpoints are accessible.");
10635
- const text = await readBoundedResponseBody(res, MAX_ANNOTATIONS_SIZE_BYTES, (bytes, reason) => {
10636
- throw new AnnotationsError(reason === "header" ? `Annotations file size (${bytes} bytes) exceeds maximum limit (${MAX_ANNOTATIONS_SIZE_BYTES} bytes)` : `Annotations file download exceeded maximum allowed size of ${MAX_ANNOTATIONS_SIZE_BYTES} bytes`, "ERR_ANNOTATIONS_TOO_LARGE", "Specify a local annotations file via --annotations <path>.");
11196
+ const declaredTotal = parseDeclaredContentLength(res);
11197
+ const startedAt = Date.now();
11198
+ let transferred = 0;
11199
+ const commitLabel = commitSha ? ` (${commitSha.slice(0, 7)})` : "";
11200
+ const progress = createProgressBar({
11201
+ label: `[annotations] Downloading ${ANNOTATIONS_FILENAME}${commitLabel}`,
11202
+ total: declaredTotal,
11203
+ announce: false
10637
11204
  });
10638
- if (!text || text.length < 1e3) throw new AnnotationsError("Downloaded annotations.lua appears truncated or invalid", "ERR_ANNOTATIONS_INVALID", "Retry downloading or pass a local annotations file via --annotations <path>.");
11205
+ let text;
11206
+ try {
11207
+ text = await readBoundedResponseBody(res, MAX_ANNOTATIONS_SIZE_BYTES, (bytes, reason) => {
11208
+ throw new AnnotationsError(reason === "header" ? `Annotations file size (${bytes} bytes) exceeds maximum limit (${MAX_ANNOTATIONS_SIZE_BYTES} bytes)` : `Annotations file download exceeded maximum allowed size of ${MAX_ANNOTATIONS_SIZE_BYTES} bytes`, "ERR_ANNOTATIONS_TOO_LARGE", "Specify a local annotations file via --annotations <path>.");
11209
+ }, (bytes) => {
11210
+ transferred = bytes;
11211
+ progress.update(bytes, declaredTotal);
11212
+ });
11213
+ if (!text || text.length < 1e3) throw new AnnotationsError("Downloaded annotations.lua appears truncated or invalid", "ERR_ANNOTATIONS_INVALID", "Retry downloading or pass a local annotations file via --annotations <path>.");
11214
+ } catch (err) {
11215
+ progress.fail();
11216
+ throw err;
11217
+ }
11218
+ progress.finish(`[annotations] Downloaded ${ANNOTATIONS_FILENAME} (${formatTransferSummary(transferred, Date.now() - startedAt)})`);
10639
11219
  return text;
10640
11220
  }
10641
11221
  /**
@@ -10882,11 +11462,12 @@ function createProgram(options) {
10882
11462
  "info",
10883
11463
  "debug",
10884
11464
  "silent"
10885
- ]).default(DEFAULT_LOG_LEVEL)).hook("preAction", (thisCommand, actionCommand) => {
11465
+ ]).default(DEFAULT_LOG_LEVEL)).option("--no-progress", "Disable the interactive download, extraction and realm derivation progress display").hook("preAction", (thisCommand, actionCommand) => {
10886
11466
  const target = actionCommand || thisCommand;
10887
11467
  const opts = target.optsWithGlobals ? target.optsWithGlobals() : target.opts();
10888
11468
  if (opts.logLevel && isValidLogLevel(opts.logLevel)) logger.setLevel(opts.logLevel);
10889
- }).exitOverride().configureOutput({
11469
+ if (opts.progress === false) setProgressMode("off");
11470
+ }).exitOverride().configureHelp({ showGlobalOptions: true }).configureOutput({
10890
11471
  writeOut: (str) => console.log(str.trimEnd()),
10891
11472
  writeErr: (str) => logger.error(str.trimEnd())
10892
11473
  });
@@ -10914,6 +11495,10 @@ function createProgram(options) {
10914
11495
  if (opts.logLevel && isValidLogLevel(opts.logLevel)) logger.setLevel(opts.logLevel);
10915
11496
  const { rootPath, targetPaths: canonicalTargets } = resolveCheckTargets(targetPaths && targetPaths.length > 0 ? targetPaths : ["."]);
10916
11497
  const format = opts.github ? "github" : opts.format || (process.env.GITHUB_ACTIONS ? "github" : "pretty");
11498
+ if (format === "json") {
11499
+ setProgressMode("off");
11500
+ logger.setDiagnosticStream("stderr");
11501
+ }
10917
11502
  const checkOptions = {
10918
11503
  path: rootPath,
10919
11504
  paths: canonicalTargets,
@@ -11005,6 +11590,7 @@ function createProgram(options) {
11005
11590
  }
11006
11591
  };
11007
11592
  const handleCacheStatus = (opts) => {
11593
+ if (opts?.json) logger.setDiagnosticStream("stderr");
11008
11594
  const report = getCacheStatus();
11009
11595
  if (opts?.json) writeOutput(JSON.stringify(report, null, 2));
11010
11596
  else writeOutput(formatCacheStatusPretty(report));
@@ -11046,6 +11632,8 @@ Examples:
11046
11632
  }
11047
11633
  /** Parses command-line arguments and executes the requested CLI action. */
11048
11634
  async function runCLI(args = process.argv.slice(2)) {
11635
+ setProgressMode("auto");
11636
+ logger.setDiagnosticStream("stdout");
11049
11637
  for (let i = 0; i < args.length; i++) {
11050
11638
  const arg = args[i];
11051
11639
  if (!arg) continue;
@@ -11118,6 +11706,6 @@ if (isDirectExecution()) runCLI().then((code) => {
11118
11706
  process.exit(1);
11119
11707
  });
11120
11708
  //#endregion
11121
- export { ALLOWED_DOWNLOAD_DOMAINS as $, stripJsonComments as $t, formatProblemSummary as A, getPlatformInfo as At, resolvePackageDependencies as B, VALID_LUALS_DIAGNOSTIC_CODES as Bt, getCachedAnnotationsFilePath as C, inspectZipMembers as Ct, updateLastCheckedDate as D, isZipArchive as Dt, readAnnotationsMetadata as E, isGzipArchive as Et, shouldEnableColor as F, resolveLuaLSVersion as Ft, computeUnrequestedExclusions as G, getPackageRoot as Gt, runLuaLSCheck as H, getDefaultAnnotationsPath as Ht, collectRealmFiles as I, sanitizeLuaLSVersion as It, findProjectRoot as J, loadUserConfig as Jt, filterReportByTargetPaths as K, initWorkspace as Kt, planRealmCheck as L, deriveRealmAnnotationFiles as Lt, formatSeverityBadge as M, FALLBACK_LUALS_VERSION as Mt, getColors as N, fetchLatestLuaLSVersionFromGitHub as Nt, formatGitHubAnnotations as O, parseTarTvSize as Ot, pluralize as P, resolveLatestLuaLSVersion as Pt, resolveCheckTargets as Q, resolveWorkspaceConfig as Qt, runRealmAwareCheck as R, splitAnnotationsByRealm as Rt, getAnnotationsMetadataFilePath as S, getTarBinary as St, isAnnotationsValid as T, isBinaryValid as Tt, countCheckedFiles as U, getDefaultTemplatePath as Ut, resolveLuaLSBinary as V, buildWorkspaceConfig as Vt, listCheckedFiles as W, getDefinitionsDir as Wt, isFilesystemRoot as X, parseJsonc as Xt, getCanonicalPath as Y, mergeConfigs as Yt, matchesTargetPaths as Z, resolveRealmMappings as Zt, MAX_ANNOTATIONS_SIZE_BYTES as _, logger as _n, MAX_ARCHIVE_MEMBER_COUNT as _t, formatCacheStatusPretty as a, LuaLSError as an, limitDownloadStream as at, MIN_ANNOTATIONS_SIZE_BYTES as b, assertValidLuaLSBinary as bt, DOCGEN_BRANCH as c, formatBytes as cn, findExistingLuaLSDir as ct, RAW_ANNOTATIONS_URL as d, fileUriToPath as dn, getIsoWeek as dt, stripTrailingSlashes as en, DOWNLOAD_TIMEOUT_MS as et, downloadAndCacheAnnotations as f, DEFAULT_LOG_LEVEL as fn, getLuaLSMetadataPath as ft, ANNOTATIONS_FILENAME as g, isValidLogLevel as gn, writeLuaLSMetadata as gt, getRawAnnotationsUrl as h, Logger as hn, readLuaLSMetadata as ht, runCLI as i, ConfigError as in, isAllowedDownloadUrl as it, formatReport as j, DEFAULT_LUALS_VERSION as jt, formatPretty as k, validateArchiveMembers as kt, DOCGEN_REPO as l, getDirectorySize as ln, getBaseLuaLSCacheDir as lt, fetchRawAnnotationsContent as m, LOG_LEVEL_PRIORITY as mn, listCachedLuaLSVersions as mt, createProgram as n, AnnotationsError as nn, computeFileSha256 as nt, getCacheStatus as o, NanosLintError as on, LUALS_METADATA_FILENAME as ot, fetchLatestCommitId as p, LOG_LEVELS as pn, listCachedLuaLSVersionDirs as pt, findCommonAncestorDirectory as q, loadConfigFile as qt, isDirectExecution as r, CacheError as rn, downloadAndExtractLuaLS as rt, resolveAnnotations as s, cleanCache as sn, cleanupOldCachedLuaLSVersions as st, collectIgnorePatterns as t, writeTempConfig as tn, MAX_ARCHIVE_SIZE_BYTES as tt, GITHUB_COMMITS_API as u, systemPaths as un, getCacheDir as ut, MAX_COMMIT_JSON_SIZE_BYTES as v, parseLogLevel as vn, MAX_DECOMPRESSED_SIZE_BYTES as vt, getTodayDateString as w, isBinaryRunnable as wt, getAnnotationsCacheDir as x, escapePowerShellSingleQuote as xt, METADATA_FILENAME as y, MIN_DOWNLOADED_BINARY_SIZE_BYTES as yt, collectDeps as z, DEFAULT_REALM_MAPPINGS as zt };
11709
+ export { DOWNLOAD_TIMEOUT_MS as $, getDefaultAnnotationsPath as $t, formatProblemSummary as A, DEFAULT_LUALS_VERSION as At, resolvePackageDependencies as B, PROGRESS_MILESTONES as Bt, getCachedAnnotationsFilePath as C, DEFAULT_LOG_LEVEL as Cn, isBinaryRunnable as Ct, updateLastCheckedDate as D, isValidLogLevel as Dn, parseTarTvSize as Dt, readAnnotationsMetadata as E, Logger as En, isZipArchive as Et, shouldEnableColor as F, sanitizeLuaLSVersion as Ft, computeUnrequestedExclusions as G, formatTransferSummary as Gt, runLuaLSCheck as H, SPINNER_FRAMES as Ht, collectRealmFiles as I, ALLOWED_DOWNLOAD_DOMAINS as It, findProjectRoot as J, restoreTerminal as Jt, filterReportByTargetPaths as K, getProgressMode as Kt, planRealmCheck as L, isAllowedDownloadUrl as Lt, formatSeverityBadge as M, fetchLatestLuaLSVersionFromGitHub as Mt, getColors as N, resolveLatestLuaLSVersion as Nt, formatGitHubAnnotations as O, logger as On, validateArchiveMembers as Ot, pluralize as P, resolveLuaLSVersion as Pt, resolveCheckTargets as Q, buildWorkspaceConfig as Qt, runRealmAwareCheck as R, deriveRealmAnnotationFiles as Rt, getAnnotationsMetadataFilePath as S, fileUriToPath as Sn, inspectZipMembers as St, isAnnotationsValid as T, LOG_LEVEL_PRIORITY as Tn, isGzipArchive as Tt, countCheckedFiles as U, createProgressBar as Ut, resolveLuaLSBinary as V, PROGRESS_THROTTLE_MS as Vt, listCheckedFiles as W, createSpinner as Wt, isFilesystemRoot as X, DEFAULT_REALM_MAPPINGS as Xt, getCanonicalPath as Y, setProgressMode as Yt, matchesTargetPaths as Z, VALID_LUALS_DIAGNOSTIC_CODES as Zt, MAX_ANNOTATIONS_SIZE_BYTES as _, NanosLintError as _n, MAX_DECOMPRESSED_SIZE_BYTES as _t, formatCacheStatusPretty as a, loadUserConfig as an, LUALS_METADATA_FILENAME as at, MIN_ANNOTATIONS_SIZE_BYTES as b, getDirectorySize as bn, escapePowerShellSingleQuote as bt, DOCGEN_BRANCH as c, resolveRealmMappings as cn, getBaseLuaLSCacheDir as ct, RAW_ANNOTATIONS_URL as d, stripTrailingSlashes as dn, getLuaLSMetadataPath as dt, getDefaultTemplatePath as en, MAX_ARCHIVE_SIZE_BYTES as et, downloadAndCacheAnnotations as f, writeTempConfig as fn, listCachedLuaLSVersionDirs as ft, ANNOTATIONS_FILENAME as g, LuaLSError as gn, MAX_ARCHIVE_MEMBER_COUNT as gt, getRawAnnotationsUrl as h, ConfigError as hn, writeLuaLSMetadata as ht, runCLI as i, loadConfigFile as in, trackDownloadProgress as it, formatReport as j, FALLBACK_LUALS_VERSION as jt, formatPretty as k, parseLogLevel as kn, getPlatformInfo as kt, DOCGEN_REPO as l, resolveWorkspaceConfig as ln, getCacheDir as lt, fetchRawAnnotationsContent as m, CacheError as mn, readLuaLSMetadata as mt, createProgram as n, getPackageRoot as nn, downloadAndExtractLuaLS as nt, getCacheStatus as o, mergeConfigs as on, cleanupOldCachedLuaLSVersions as ot, fetchLatestCommitId as p, AnnotationsError as pn, listCachedLuaLSVersions as pt, findCommonAncestorDirectory as q, isProgressInteractive as qt, isDirectExecution as r, initWorkspace as rn, limitDownloadStream as rt, resolveAnnotations as s, parseJsonc as sn, findExistingLuaLSDir as st, collectIgnorePatterns as t, getDefinitionsDir as tn, computeFileSha256 as tt, GITHUB_COMMITS_API as u, stripJsonComments as un, getIsoWeek as ut, MAX_COMMIT_JSON_SIZE_BYTES as v, cleanCache as vn, MIN_DOWNLOADED_BINARY_SIZE_BYTES as vt, getTodayDateString as w, LOG_LEVELS as wn, isBinaryValid as wt, getAnnotationsCacheDir as x, systemPaths as xn, getTarBinary as xt, METADATA_FILENAME as y, formatBytes as yn, assertValidLuaLSBinary as yt, collectDeps as z, splitAnnotationsByRealm as zt };
11122
11710
 
11123
- //# sourceMappingURL=cli-Cb3wLBCr.js.map
11711
+ //# sourceMappingURL=cli-CeNxOiST.js.map