hugo-extended
Version:
✏️ Plug-and-play binary wrapper for Hugo Extended, the awesomest static-site generator.
190 lines (188 loc) • 8.53 kB
JavaScript
import { getEnvConfig } from "./env.mjs";
import { getBinFilename, getBinVersion, getChecksumFilename, getPkgVersion, getReleaseFilename, getReleaseUrl, isExtended, logger } from "./utils.mjs";
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import crypto from "node:crypto";
import os from "node:os";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import AdmZip from "adm-zip";
import * as tar from "tar";
//#region src/lib/install.ts
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Detects the archive type from a filename based on its extension.
*
* @param filename - The filename to check
* @returns The detected archive type, or null if unknown
*/
function getArchiveType(filename) {
if (filename.endsWith(".zip")) return "zip";
if (filename.endsWith(".tar.gz")) return "tar.gz";
if (filename.endsWith(".pkg")) return "pkg";
return null;
}
/**
* Parses a checksums file content into a lookup map.
*
* The checksums file format is: "sha256hash filename" (hash followed by whitespace and filename).
* This is the standard format used by Hugo releases.
*
* @param content - The raw content of the checksums file
* @returns A Map of filename to SHA-256 hash
*/
function parseChecksumFile(content) {
const checksums = /* @__PURE__ */ new Map();
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const tokens = trimmed.split(/\s+/);
if (tokens.length >= 2) {
const hash = tokens[0];
const filename = tokens[tokens.length - 1];
checksums.set(filename, hash);
}
}
return checksums;
}
/**
* Downloads a file from a URL to a local destination path.
*
* @param url - The URL to download the file from
* @param dest - The local file path where the downloaded file will be saved
* @throws {Error} If the download fails or the response is invalid
* @returns A promise that resolves when the download is complete
*/
async function downloadFile(url, dest) {
const response = await fetch(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));
}
/**
* 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
*/
function extractPkg(pkgPath, destDir) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hugo-pkg-"));
try {
const expansionDir = path.join(tempDir, "expanded");
execSync(`pkgutil --expand-full "${pkgPath}" "${expansionDir}"`, { stdio: "pipe" });
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");
const destPath = path.join(destDir, getBinFilename());
fs.copyFileSync(hugoPayload, destPath);
fs.chmodSync(destPath, 493);
} finally {
fs.rmSync(tempDir, {
recursive: true,
force: true
});
}
}
/**
* Verifies that a downloaded file matches its expected SHA-256 checksum.
*
* Downloads the checksums file from GitHub, extracts the expected checksum for the
* specified filename, computes the actual checksum of the local file, and compares them.
*
* @param filePath - The local path to the file to verify
* @param checksumUrl - The URL to the checksums file (usually checksums.txt from the release)
* @param filename - The name of the file to find in the checksums file
* @throws {Error} If checksums don't match, the checksums file can't be downloaded, or the filename isn't found
* @returns A promise that resolves when verification is successful
*/
async function verifyChecksum(filePath, checksumUrl, filename) {
const response = await fetch(checksumUrl);
if (!response.ok) throw new Error(`Failed to download checksums: ${response.statusText}`);
const expectedChecksum = parseChecksumFile(await response.text()).get(filename);
if (!expectedChecksum) throw new Error(`Checksum for ${filename} not found in checksums file.`);
const fileBuffer = fs.readFileSync(filePath);
const hash = crypto.createHash("sha256");
hash.update(fileBuffer);
const actualChecksum = hash.digest("hex");
if (actualChecksum !== expectedChecksum) throw new Error(`Checksum mismatch! Expected ${expectedChecksum}, got ${actualChecksum}`);
}
/**
* Downloads, verifies, and installs Hugo (Extended when available) for the current platform.
*
* This function handles the complete installation process:
* - 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 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
*
* Environment variables that affect installation:
* - HUGO_OVERRIDE_VERSION: Install a different Hugo version
* - HUGO_NO_EXTENDED: Force vanilla Hugo instead of Extended
* - HUGO_MIRROR_BASE_URL: Custom download mirror
* - HUGO_SKIP_CHECKSUM: Skip SHA-256 verification
* - HUGO_QUIET: Suppress progress output
*
* @throws {Error} If the platform is unsupported, download fails, checksum doesn't match, or installation fails
* @returns A promise that resolves with the absolute path to the installed Hugo binary
*/
async function install() {
const envConfig = getEnvConfig();
try {
const version = getPkgVersion();
const releaseFile = getReleaseFilename(version);
const checksumFile = getChecksumFilename(version);
const binFile = getBinFilename();
if (!releaseFile) throw new Error(`Are you sure this platform is supported? See: https://github.com/gohugoio/hugo/releases/tag/v${version}`);
if (!isExtended(releaseFile)) if (envConfig.forceStandard) 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.");
const binDir = path.join(__dirname, "..", "..", "bin");
if (!fs.existsSync(binDir)) fs.mkdirSync(binDir, { recursive: true });
const releaseUrl = getReleaseUrl(version, releaseFile);
const checksumUrl = getReleaseUrl(version, checksumFile);
const downloadPath = path.join(binDir, releaseFile);
logger.info(`☁️ Downloading ${releaseFile}...`);
await downloadFile(releaseUrl, downloadPath);
if (envConfig.skipChecksum) logger.warn("Skipping checksum verification (HUGO_SKIP_CHECKSUM is set).");
else {
logger.info("🕵️ Verifying checksum...");
await verifyChecksum(downloadPath, checksumUrl, releaseFile);
}
logger.info("📦 Extracting...");
const archiveType = getArchiveType(releaseFile);
if (archiveType === "pkg") extractPkg(downloadPath, binDir);
else if (archiveType === "zip") new AdmZip(downloadPath).extractAllTo(binDir, true);
else if (archiveType === "tar.gz") await tar.x({
file: downloadPath,
cwd: binDir
});
else throw new Error(`Unexpected archive type for ${releaseFile}. Expected .zip, .tar.gz, or .pkg.`);
fs.unlinkSync(downloadPath);
const binPath = path.join(binDir, binFile);
if (fs.existsSync(binPath)) fs.chmodSync(binPath, 493);
logger.info("🎉 Hugo installed successfully!");
logger.info(getBinVersion(binPath));
return binPath;
} catch (error) {
logger.error("Hugo installation failed. :(");
throw error;
}
}
var install_default = install;
//#endregion
export { install_default as default, extractPkg, getArchiveType, parseChecksumFile };