mirror of
https://github.com/jakejarvis/hugo-extended.git
synced 2026-09-13 09:15:33 -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.
|
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
|
### Version-dependent behavior
|
||||||
|
|
||||||
- **macOS v0.153.0+**: Hugo ships as `.pkg` installer, extracted locally using `pkgutil --expand-full` (no sudo required).
|
- **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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"tar": "^7.5.2"
|
"tar": "^7.5.2",
|
||||||
|
"undici": "^6.23.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"hugo": "dist/cli.mjs",
|
"hugo": "dist/cli.mjs",
|
||||||
@@ -2217,7 +2218,6 @@
|
|||||||
"integrity": "sha512-Slm000Gd8/AO9z4Kxl4r8mp/iakrbAuJ1L+7ddpkNxgQ+Vf37WPvY63l3oeyZcfuPD1DRrUYBsRPIXSOhvOsmw==",
|
"integrity": "sha512-Slm000Gd8/AO9z4Kxl4r8mp/iakrbAuJ1L+7ddpkNxgQ+Vf37WPvY63l3oeyZcfuPD1DRrUYBsRPIXSOhvOsmw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.107.0",
|
"@oxc-project/types": "=0.107.0",
|
||||||
"@rolldown/pluginutils": "1.0.0-beta.59"
|
"@rolldown/pluginutils": "1.0.0-beta.59"
|
||||||
@@ -2545,7 +2545,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -2568,6 +2567,15 @@
|
|||||||
"url": "https://github.com/sponsors/antfu"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
@@ -2608,7 +2616,6 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -2684,7 +2691,6 @@
|
|||||||
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
|
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.16",
|
"@vitest/expect": "4.0.16",
|
||||||
"@vitest/mocker": "4.0.16",
|
"@vitest/mocker": "4.0.16",
|
||||||
|
|||||||
+2
-1
@@ -59,7 +59,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"tar": "^7.5.2"
|
"tar": "^7.5.2",
|
||||||
|
"undici": "^6.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.11",
|
"@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 AdmZip from "adm-zip";
|
||||||
import * as tar from "tar";
|
import * as tar from "tar";
|
||||||
import { getEnvConfig } from "./env";
|
import { getEnvConfig } from "./env";
|
||||||
|
import { proxyFetch } from "./fetch";
|
||||||
import {
|
import {
|
||||||
getBinFilename,
|
getBinFilename,
|
||||||
getBinVersion,
|
getBinVersion,
|
||||||
@@ -76,14 +77,17 @@ export function parseChecksumFile(content: string): Map<string, string> {
|
|||||||
* @returns A promise that resolves when the download is complete
|
* @returns A promise that resolves when the download is complete
|
||||||
*/
|
*/
|
||||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||||
const response = await fetch(url);
|
const response = await proxyFetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to download ${url}: ${response.statusText}`);
|
throw new Error(`Failed to download ${url}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
throw new Error(`No response body from ${url}`);
|
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,
|
checksumUrl: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await fetch(checksumUrl);
|
const response = await proxyFetch(checksumUrl);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to download checksums: ${response.statusText}`);
|
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