1
mirror of https://github.com/jakejarvis/hugo-extended.git synced 2026-06-12 08:45:27 -04:00

fix: update macOS installation process to extract Hugo binary without sudo (#184)

This commit is contained in:
2026-01-09 10:24:39 -05:00
committed by GitHub
parent b409823e55
commit db078597f6
10 changed files with 171 additions and 98 deletions
+14 -13
View File
@@ -219,7 +219,7 @@ jobs:
fi
echo "Extended version verified"
# macOS-specific tests (symlink behavior, pkg installation)
# macOS-specific tests (pkg extraction via pkgutil, no sudo required)
macos-quirks:
name: macOS quirks
needs: unit
@@ -252,27 +252,28 @@ jobs:
- name: Run postinstall
run: node postinstall.js
- name: Verify symlink structure
- name: Verify binary is regular file (not symlink)
run: |
if [ ! -L "bin/hugo" ]; then
echo "bin/hugo is not a symlink!"
if [ -L "bin/hugo" ]; then
echo "bin/hugo should not be a symlink (we use pkgutil extraction now)!"
exit 1
fi
TARGET=$(readlink bin/hugo)
echo "Symlink target: $TARGET"
if [ "$TARGET" != "/usr/local/bin/hugo" ]; then
echo "Unexpected symlink target!"
if [ ! -f "bin/hugo" ]; then
echo "bin/hugo is not a regular file!"
exit 1
fi
echo "Symlink structure verified"
echo "Binary is a regular file (extracted from .pkg via pkgutil)"
- name: Verify system Hugo binary
- name: Verify executable permissions
run: |
if [ ! -f "/usr/local/bin/hugo" ]; then
echo "System Hugo binary not found!"
if [ ! -x "bin/hugo" ]; then
echo "bin/hugo is not executable!"
exit 1
fi
/usr/local/bin/hugo version
echo "Permissions verified"
- name: Verify binary runs
run: ./bin/hugo version
# Linux- tests (tar.gz extraction, permissions)
linux-quirks:
+3 -3
View File
@@ -26,8 +26,8 @@ Notes for LLM coding agents working on `hugo-extended`.
- **Binary installation**: `src/lib/install.ts`
- Downloads Hugo release assets and verifies SHA-256 checksums.
- **macOS v0.153.0+**: uses `sudo installer -pkg ... -target /` (and then symlinks `bin/hugo` -> `/usr/local/bin/hugo`).
- **macOS pre-v0.153.0**: extracts `.tar.gz` archive into `bin/` (no sudo required).
- **macOS v0.153.0+**: uses `pkgutil --expand-full` to extract the binary from the `.pkg` file (no sudo required).
- **macOS pre-v0.153.0**: extracts `.tar.gz` archive into `bin/`.
- **non-macOS**: extracts archive into `bin/` and `chmod +x`.
- **Environment variables**: `src/lib/env.ts`
@@ -139,6 +139,6 @@ Some variables have aliases (e.g., `HUGO_FORCE_STANDARD` → `HUGO_NO_EXTENDED`,
### Version-dependent behavior
- **macOS v0.153.0+**: Hugo ships as `.pkg` installer, requires `sudo`.
- **macOS v0.153.0+**: Hugo ships as `.pkg` installer, extracted locally using `pkgutil --expand-full` (no sudo required).
- **macOS pre-v0.153.0**: Hugo ships as `.tar.gz`, extracted to `bin/` directly.
- The `usesMacOSPkg(version)` and `compareVersions(a, b)` utilities in `src/lib/utils.ts` handle this.
+2 -2
View File
@@ -252,9 +252,9 @@ If Hugo seems to disappear (rare edge case), it will be automatically reinstalle
npm rebuild hugo-extended
```
### Permission issues on macOS
### macOS installation
As of [v0.153.0](https://github.com/gohugoio/hugo/releases/tag/v0.153.0), Hugo is distributed as a full installer for macOS, rather than a simple binary/executable file. This package will make its best effort to run the installer for you (which includes prompting you for `sudo` access) but this method introduces infinitely more opportunities for things to go wrong. Please [open an issue](https://github.com/jakejarvis/hugo-extended/issues/new) if you encounter any issues.
As of [v0.153.0](https://github.com/gohugoio/hugo/releases/tag/v0.153.0), Hugo is distributed as a `.pkg` installer for macOS. This package extracts the binary locally using `pkgutil --expand-full`, so **no sudo or global installation is required**. The Hugo binary stays in `node_modules` just like on other platforms.
## License
+1 -1
View File
@@ -47,7 +47,7 @@ export const getHugoBinary = async (): Promise<string> => {
// See: https://github.com/jakejarvis/hugo-extended/issues/81
if (!doesBinExist(bin)) {
// Hugo isn't there for some reason. Try re-installing.
logger.info("⚠️ Hugo is missing, reinstalling now...");
logger.warn("Hugo is missing, reinstalling now...");
await install();
}
+80 -64
View File
@@ -1,6 +1,7 @@
import { spawnSync } from "node:child_process";
import { execSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
@@ -85,6 +86,52 @@ async function downloadFile(url: string, dest: string): Promise<void> {
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(dest));
}
/**
* Extracts a Hugo binary from a macOS .pkg file without requiring sudo.
*
* Uses `pkgutil --expand-full` to expand the package, then locates and copies
* the Hugo binary from the payload to the destination directory.
*
* The Hugo .pkg structure after expansion contains:
* - A "Payload" directory containing the hugo binary directly
* - Or a component package directory with Payload inside
*
* @param pkgPath - The path to the .pkg file to extract
* @param destDir - The directory where the hugo binary should be placed
* @throws {Error} If extraction fails, Payload is not found, or hugo binary is missing
* @see https://github.com/jmooring/hvm/commit/16eb55ae4965b5d2e414061085490a90fe7ea73e
*/
export function extractPkg(pkgPath: string, destDir: string): void {
// Create a temporary directory for expansion
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hugo-pkg-"));
try {
const expansionDir = path.join(tempDir, "expanded");
// Use pkgutil to expand the package without installing
execSync(`pkgutil --expand-full "${pkgPath}" "${expansionDir}"`, {
stdio: "pipe",
});
// Find the hugo binary in the expanded package
const hugoPayload = path.join(expansionDir, "Payload", "hugo");
if (!fs.existsSync(hugoPayload)) {
throw new Error(
"Could not find hugo binary in expanded .pkg. Expected path: */Payload/hugo",
);
}
// Copy the binary to the destination
const destPath = path.join(destDir, getBinFilename());
fs.copyFileSync(hugoPayload, destPath);
fs.chmodSync(destPath, 0o755);
} finally {
// Clean up the temp directory
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
/**
* Verifies that a downloaded file matches its expected SHA-256 checksum.
*
@@ -133,9 +180,11 @@ async function verifyChecksum(
* - Determines the correct Hugo release file for the current platform and architecture
* - Downloads the release file and checksums from GitHub (or custom mirror)
* - Verifies the integrity of the downloaded file using SHA-256 checksums (unless HUGO_SKIP_CHECKSUM is set)
* - Extracts or installs the binary (platform-specific):
* - macOS: Uses `sudo installer` to install the .pkg file to /usr/local/bin
* - Windows/Linux: Extracts the .zip or .tar.gz archive to the local bin directory
* - Extracts the binary (platform-specific):
* - macOS v0.153.0+: Extracts from .pkg using pkgutil (no sudo required)
* - macOS pre-v0.153.0: Extracts from .tar.gz archive
* - Windows: Extracts from .zip archive
* - Linux/BSD: Extracts from .tar.gz archive
* - Sets appropriate file permissions on Unix-like systems
* - Displays the installed Hugo version
*
@@ -166,10 +215,10 @@ async function install(): Promise<string> {
if (!isExtended(releaseFile)) {
if (envConfig.forceStandard) {
logger.info("Installing vanilla Hugo (HUGO_NO_EXTENDED is set).");
logger.info("Installing vanilla Hugo (HUGO_NO_EXTENDED is set).");
} else {
logger.warn(
"Hugo Extended isn't supported on this platform, downloading vanilla Hugo instead.",
"Hugo Extended isn't supported on this platform, downloading vanilla Hugo instead.",
);
}
}
@@ -189,83 +238,50 @@ async function install(): Promise<string> {
if (envConfig.skipChecksum) {
logger.warn(
"⚠️ Skipping checksum verification (HUGO_SKIP_CHECKSUM is set).",
"Skipping checksum verification (HUGO_SKIP_CHECKSUM is set).",
);
} else {
logger.info("🕵️ Verifying checksum...");
await verifyChecksum(downloadPath, checksumUrl, releaseFile);
}
// All other platforms and macOS pre-0.153.0 (tar.gz) use archive extraction
logger.info("📦 Extracting...");
const archiveType = getArchiveType(releaseFile);
// macOS .pkg files require special handling with sudo installer
if (process.platform === "darwin" && archiveType === "pkg") {
logger.info(`💾 Installing ${releaseFile} (requires sudo)...`);
// Run MacOS installer
const result = spawnSync(
"sudo",
["installer", "-pkg", downloadPath, "-target", "/"],
{
stdio: envConfig.quiet ? "pipe" : "inherit",
},
);
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`Installer failed with exit code ${result.status}`);
}
// Cleanup downloaded pkg
fs.unlinkSync(downloadPath);
// Create symlink in local bin dir for consistency with other platforms
const symlinkPath = path.join(binDir, binFile);
if (
fs.existsSync(symlinkPath) ||
fs.lstatSync(symlinkPath, { throwIfNoEntry: false })
) {
fs.unlinkSync(symlinkPath);
}
fs.symlinkSync("/usr/local/bin/hugo", symlinkPath);
// macOS .pkg files: extract using pkgutil (no sudo required)
if (archiveType === "pkg") {
extractPkg(downloadPath, binDir);
} else if (archiveType === "zip") {
const zip = new AdmZip(downloadPath);
zip.extractAllTo(binDir, true);
} else if (archiveType === "tar.gz") {
await tar.x({
file: downloadPath,
cwd: binDir,
});
} else {
// All other platforms and macOS pre-0.153.0 (tar.gz) use archive extraction
logger.info("📦 Extracting...");
// Defensive: should not happen since unsupported platforms are caught earlier
throw new Error(
`Unexpected archive type for ${releaseFile}. Expected .zip, .tar.gz, or .pkg.`,
);
}
if (archiveType === "zip") {
const zip = new AdmZip(downloadPath);
zip.extractAllTo(binDir, true);
// Cleanup downloaded package
fs.unlinkSync(downloadPath);
// Cleanup zip
fs.unlinkSync(downloadPath);
} else if (archiveType === "tar.gz") {
await tar.x({
file: downloadPath,
cwd: binDir,
});
// Cleanup tar.gz
fs.unlinkSync(downloadPath);
} else {
// Defensive: should not happen since unsupported platforms are caught earlier
throw new Error(
`Unexpected archive type for ${releaseFile}. Expected .zip, .tar.gz, or .pkg.`,
);
}
const binPath = path.join(binDir, binFile);
if (fs.existsSync(binPath)) {
fs.chmodSync(binPath, 0o755);
}
const binPath = path.join(binDir, binFile);
if (fs.existsSync(binPath)) {
fs.chmodSync(binPath, 0o755);
}
logger.info("🎉 Hugo installed successfully!");
// Check version and return path
const binPath = path.join(binDir, binFile);
logger.info(getBinVersion(binPath));
return binPath;
} catch (error) {
logger.error("Hugo installation failed. :(");
logger.error("Hugo installation failed. :(");
throw error;
}
}
+3 -4
View File
@@ -120,8 +120,7 @@ export function getBinFilename(): string {
* 1. HUGO_BIN_PATH environment variable (if set)
* 2. Local bin directory (./bin/hugo or ./bin/hugo.exe)
*
* @returns The absolute path to hugo binary.
* On macOS (when using local bin), this is a symlink to "/usr/local/bin/hugo".
* @returns The absolute path to hugo binary
*/
export function getBinPath(): string {
const envConfig = getEnvConfig();
@@ -286,7 +285,7 @@ export const logger = {
*/
warn: (message: string): void => {
if (!getEnvConfig().quiet) {
console.warn(message);
console.warn(`${message}`);
}
},
@@ -294,6 +293,6 @@ export const logger = {
* Log an error message (always shown, even in quiet mode).
*/
error: (message: string): void => {
console.error(message);
console.error(`${message}`);
},
};
+7 -7
View File
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { existsSync, lstatSync, readlinkSync, statSync } from "node:fs";
import { existsSync, lstatSync, statSync } from "node:fs";
import { beforeAll, describe, expect, it } from "vitest";
import hugo, { execWithOutput, getHugoBinary } from "../../src/hugo";
import {
@@ -59,13 +59,13 @@ describe("Hugo Installation E2E", () => {
);
it.skipIf(process.platform !== "darwin")(
"should be a symlink to /usr/local/bin/hugo on macOS",
"should be a regular file on macOS (not a symlink)",
() => {
const isSymlink = lstatSync(binaryPath).isSymbolicLink();
expect(isSymlink).toBe(true);
const target = readlinkSync(binaryPath);
expect(target).toBe("/usr/local/bin/hugo");
// Since v0.153.0, we extract the .pkg locally using pkgutil
// instead of running `sudo installer`, so the binary is a regular file
const stats = lstatSync(binaryPath);
expect(stats.isSymbolicLink()).toBe(false);
expect(stats.isFile()).toBe(true);
},
);
});
+55 -1
View File
@@ -1,6 +1,13 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, assert, beforeEach, describe, expect, it } from "vitest";
import { getArchiveType, parseChecksumFile } from "../../src/lib/install";
import {
extractPkg,
getArchiveType,
parseChecksumFile,
} from "../../src/lib/install";
import { getReleaseFilename } from "../../src/lib/utils";
/**
@@ -165,6 +172,53 @@ f0e9d8c7b6a5432109876543210fedcba0987654321fedcba0987654321fedc hugo_extended_0
});
});
describe("extractPkg", () => {
it.skipIf(process.platform !== "darwin")(
"should throw error for non-existent .pkg file",
() => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hugo-test-"));
try {
const nonExistentPkg = path.join(tempDir, "nonexistent.pkg");
expect(() => extractPkg(nonExistentPkg, tempDir)).toThrow();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform !== "darwin")(
"should throw descriptive error when pkgutil fails on invalid pkg",
() => {
// Create a temporary directory with a fake .pkg file
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hugo-test-"));
try {
// Create a fake .pkg file (just an empty file - pkgutil will fail to expand it)
const fakePkg = path.join(tempDir, "fake.pkg");
fs.writeFileSync(fakePkg, "not a real pkg");
expect(() => extractPkg(fakePkg, tempDir)).toThrow();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === "darwin")(
"should not be available on non-macOS platforms (pkgutil is macOS-only)",
() => {
// On non-macOS platforms, pkgutil doesn't exist, so the function will fail
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hugo-test-"));
try {
const fakePkg = path.join(tempDir, "fake.pkg");
fs.writeFileSync(fakePkg, "");
expect(() => extractPkg(fakePkg, tempDir)).toThrow();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
},
);
});
describe("getReleaseFilename + getArchiveType integration", () => {
let originalPlatform: NodeJS.Platform;
let originalArch: NodeJS.Architecture;
+3 -3
View File
@@ -440,7 +440,7 @@ describe("utils", () => {
describe("warn", () => {
it("should log when not quiet", () => {
logger.warn("warning message");
expect(console.warn).toHaveBeenCalledWith("warning message");
expect(console.warn).toHaveBeenCalledWith("warning message");
});
it("should not log when HUGO_SILENT is set", () => {
@@ -453,13 +453,13 @@ describe("utils", () => {
describe("error", () => {
it("should always log errors", () => {
logger.error("error message");
expect(console.error).toHaveBeenCalledWith("error message");
expect(console.error).toHaveBeenCalledWith("error message");
});
it("should log errors even when quiet", () => {
process.env.HUGO_QUIET = "1";
logger.error("error message");
expect(console.error).toHaveBeenCalledWith("error message");
expect(console.error).toHaveBeenCalledWith("error message");
});
});
});
+3
View File
@@ -8,6 +8,9 @@ export default defineConfig({
// Test file patterns
include: ["tests/**/*.test.ts"],
// Don't show noisy Hugo output unless the test fails
silent: "passed-only",
// Coverage configuration
coverage: {
provider: "v8",