You've already forked hugo-extended
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:
+14
-13
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user