You've already forked hugo-extended
mirror of
https://github.com/jakejarvis/hugo-extended.git
synced 2026-06-12 08:45:27 -04:00
feat: add HTTP proxy support for binary downloads
Use undici's EnvHttpProxyAgent to automatically respect standard proxy environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) when downloading Hugo binaries during installation. Closes #178
This commit is contained in:
@@ -137,6 +137,18 @@ npm run test:coverage # coverage via v8
|
||||
|
||||
Some variables have aliases (e.g., `HUGO_FORCE_STANDARD` → `HUGO_NO_EXTENDED`, `HUGO_SILENT` → `HUGO_QUIET`). Check `ENV_VARS` in `src/lib/env.ts` for the full list.
|
||||
|
||||
### Proxy support
|
||||
|
||||
The installer automatically respects standard proxy environment variables via `undici`'s `EnvHttpProxyAgent`:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `HTTP_PROXY` / `http_proxy` | Proxy server for HTTP requests |
|
||||
| `HTTPS_PROXY` / `https_proxy` | Proxy server for HTTPS requests |
|
||||
| `NO_PROXY` / `no_proxy` | Comma-separated list of hosts to bypass proxy |
|
||||
|
||||
Lowercase variants take precedence over uppercase (matching standard convention). The proxy URL is logged once during installation (respects `HUGO_QUIET`).
|
||||
|
||||
### Version-dependent behavior
|
||||
|
||||
- **macOS v0.153.0+**: Hugo ships as `.pkg` installer, extracted locally using `pkgutil --expand-full` (no sudo required).
|
||||
|
||||
Generated
+11
-5
@@ -11,7 +11,8 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"tar": "^7.5.2"
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^6.23.0"
|
||||
},
|
||||
"bin": {
|
||||
"hugo": "dist/cli.mjs",
|
||||
@@ -2217,7 +2218,6 @@
|
||||
"integrity": "sha512-Slm000Gd8/AO9z4Kxl4r8mp/iakrbAuJ1L+7ddpkNxgQ+Vf37WPvY63l3oeyZcfuPD1DRrUYBsRPIXSOhvOsmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.107.0",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.59"
|
||||
@@ -2545,7 +2545,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -2568,6 +2567,15 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.23.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
@@ -2608,7 +2616,6 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -2684,7 +2691,6 @@
|
||||
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.16",
|
||||
"@vitest/mocker": "4.0.16",
|
||||
|
||||
+2
-1
@@ -59,7 +59,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"tar": "^7.5.2"
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^6.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.11",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Proxy-aware fetch utility for Hugo binary downloads.
|
||||
*
|
||||
* Automatically uses HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment
|
||||
* variables when making network requests during installation.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { RequestInit, Response } from "undici";
|
||||
import { EnvHttpProxyAgent, fetch as undiciFetch } from "undici";
|
||||
import { logger } from "./utils";
|
||||
|
||||
/**
|
||||
* Shared proxy agent that reads HTTP_PROXY, HTTPS_PROXY, NO_PROXY from env.
|
||||
* The agent handles both lowercase and uppercase variants, with lowercase
|
||||
* taking precedence.
|
||||
*/
|
||||
const proxyAgent = new EnvHttpProxyAgent();
|
||||
|
||||
/**
|
||||
* Track whether we've already logged the proxy message to avoid spam.
|
||||
*/
|
||||
let hasLoggedProxy = false;
|
||||
|
||||
/**
|
||||
* Gets the proxy URL from environment variables, if configured.
|
||||
*
|
||||
* Checks both uppercase and lowercase variants, with lowercase taking
|
||||
* precedence (matching undici's behavior).
|
||||
*
|
||||
* @returns The proxy URL if configured, undefined otherwise
|
||||
*/
|
||||
export function getProxyUrl(): string | undefined {
|
||||
return (
|
||||
process.env.https_proxy ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.http_proxy ||
|
||||
process.env.HTTP_PROXY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy-aware fetch that automatically uses HTTP_PROXY/HTTPS_PROXY env vars.
|
||||
*
|
||||
* This is a drop-in replacement for native fetch() that routes requests
|
||||
* through a proxy server when the standard proxy environment variables
|
||||
* are set:
|
||||
*
|
||||
* - `HTTP_PROXY` / `http_proxy` - Proxy for HTTP requests
|
||||
* - `HTTPS_PROXY` / `https_proxy` - Proxy for HTTPS requests
|
||||
* - `NO_PROXY` / `no_proxy` - Comma-separated list of hosts to bypass
|
||||
*
|
||||
* @param url - The URL to fetch
|
||||
* @param init - Optional fetch init options (same as native fetch)
|
||||
* @returns A Promise that resolves to the Response
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With HTTPS_PROXY=http://proxy.example.com:8080
|
||||
* const response = await proxyFetch('https://github.com/...');
|
||||
* // Request is routed through the proxy
|
||||
* ```
|
||||
*/
|
||||
export async function proxyFetch(
|
||||
url: string | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
// Log proxy usage once per installation (respects HUGO_QUIET)
|
||||
if (!hasLoggedProxy) {
|
||||
const proxyUrl = getProxyUrl();
|
||||
if (proxyUrl) {
|
||||
logger.info(`Using proxy: ${proxyUrl}`);
|
||||
}
|
||||
hasLoggedProxy = true;
|
||||
}
|
||||
|
||||
return undiciFetch(url, {
|
||||
...init,
|
||||
dispatcher: proxyAgent,
|
||||
});
|
||||
}
|
||||
+7
-3
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
||||
import AdmZip from "adm-zip";
|
||||
import * as tar from "tar";
|
||||
import { getEnvConfig } from "./env";
|
||||
import { proxyFetch } from "./fetch";
|
||||
import {
|
||||
getBinFilename,
|
||||
getBinVersion,
|
||||
@@ -76,14 +77,17 @@ export function parseChecksumFile(content: string): Map<string, string> {
|
||||
* @returns A promise that resolves when the download is complete
|
||||
*/
|
||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const response = await fetch(url);
|
||||
const response = await proxyFetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${url}: ${response.statusText}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error(`No response body from ${url}`);
|
||||
}
|
||||
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(dest));
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]),
|
||||
fs.createWriteStream(dest),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,7 +153,7 @@ async function verifyChecksum(
|
||||
checksumUrl: string,
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch(checksumUrl);
|
||||
const response = await proxyFetch(checksumUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download checksums: ${response.statusText}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock undici before importing the module under test
|
||||
vi.mock("undici", () => {
|
||||
// Create a mock class for EnvHttpProxyAgent
|
||||
class MockEnvHttpProxyAgent {}
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
});
|
||||
|
||||
return {
|
||||
EnvHttpProxyAgent: MockEnvHttpProxyAgent,
|
||||
fetch: mockFetch,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the logger to avoid console output during tests
|
||||
vi.mock("../../src/lib/utils", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { getProxyUrl, proxyFetch } from "../../src/lib/fetch";
|
||||
|
||||
describe("fetch", () => {
|
||||
// Store original env vars to restore after each test
|
||||
const originalEnv: Record<string, string | undefined> = {};
|
||||
|
||||
// List of all proxy env vars we might set during tests
|
||||
const proxyEnvVars = [
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"NO_PROXY",
|
||||
"no_proxy",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original values and clear
|
||||
for (const key of proxyEnvVars) {
|
||||
originalEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original values
|
||||
for (const key of proxyEnvVars) {
|
||||
if (originalEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = originalEnv[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("getProxyUrl", () => {
|
||||
it("should return undefined when no proxy env vars are set", () => {
|
||||
expect(getProxyUrl()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return HTTPS_PROXY value", () => {
|
||||
process.env.HTTPS_PROXY = "http://proxy.example.com:8080";
|
||||
expect(getProxyUrl()).toBe("http://proxy.example.com:8080");
|
||||
});
|
||||
|
||||
it("should return https_proxy value (lowercase)", () => {
|
||||
process.env.https_proxy = "http://proxy.example.com:8080";
|
||||
expect(getProxyUrl()).toBe("http://proxy.example.com:8080");
|
||||
});
|
||||
|
||||
it("should return HTTP_PROXY value when HTTPS_PROXY is not set", () => {
|
||||
process.env.HTTP_PROXY = "http://proxy.example.com:8080";
|
||||
expect(getProxyUrl()).toBe("http://proxy.example.com:8080");
|
||||
});
|
||||
|
||||
it("should return http_proxy value (lowercase)", () => {
|
||||
process.env.http_proxy = "http://proxy.example.com:8080";
|
||||
expect(getProxyUrl()).toBe("http://proxy.example.com:8080");
|
||||
});
|
||||
|
||||
it("should prefer lowercase https_proxy over uppercase", () => {
|
||||
process.env.https_proxy = "http://lowercase.example.com:8080";
|
||||
process.env.HTTPS_PROXY = "http://uppercase.example.com:8080";
|
||||
expect(getProxyUrl()).toBe("http://lowercase.example.com:8080");
|
||||
});
|
||||
|
||||
it("should prefer HTTPS_PROXY over HTTP_PROXY", () => {
|
||||
process.env.HTTPS_PROXY = "http://https-proxy.example.com:8080";
|
||||
process.env.HTTP_PROXY = "http://http-proxy.example.com:8080";
|
||||
expect(getProxyUrl()).toBe("http://https-proxy.example.com:8080");
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxyFetch", () => {
|
||||
it("should call undici fetch with the provided URL", async () => {
|
||||
const { fetch: mockFetch } = await import("undici");
|
||||
|
||||
await proxyFetch("https://example.com/file.tar.gz");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://example.com/file.tar.gz",
|
||||
expect.objectContaining({
|
||||
dispatcher: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass through init options", async () => {
|
||||
const { fetch: mockFetch } = await import("undici");
|
||||
|
||||
await proxyFetch("https://example.com/file.tar.gz", {
|
||||
method: "GET",
|
||||
headers: { "X-Custom": "header" },
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://example.com/file.tar.gz",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: { "X-Custom": "header" },
|
||||
dispatcher: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Note: Testing that logging happens exactly once requires module reset
|
||||
// which is complex with Vitest's module caching. The logging behavior
|
||||
// is covered by the implementation and manual testing. The key behaviors
|
||||
// (proxy detection and fetch dispatcher usage) are tested above.
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user