refactor: replace postinstall script with platform-specific binary packages

This commit is contained in:
2026-06-21 13:30:33 -04:00
parent 970328933b
commit 46fb81fab4
36 changed files with 2532 additions and 2640 deletions
-10
View File
@@ -1,10 +0,0 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
-2
View File
@@ -1,2 +0,0 @@
# Set default behavior to automatically normalize line endings
* text=auto eol=lf
@@ -66,7 +66,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
LATEST="${{ steps.hugo.outputs.version }}"
BRANCH_NAME="autobump-hugo-v${LATEST}"
BRANCH_NAME="chore/hugo-v${LATEST}"
# Check if a PR for this version already exists
EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --json number --jq '.[0].number // empty')
@@ -89,7 +89,7 @@ jobs:
if: steps.check.outputs.needs_update == 'true' && steps.pr_check.outputs.pr_exists == 'false'
run: |
LATEST="${{ steps.hugo.outputs.version }}"
BRANCH_NAME="autobump-hugo-v${LATEST}"
BRANCH_NAME="chore/hugo-v${LATEST}"
# Create new branch
git checkout -b "$BRANCH_NAME"
@@ -97,6 +97,8 @@ jobs:
# Use npm version to properly bump (updates package.json and package-lock.json)
# --no-git-tag-version prevents creating a tag, we'll let the publish workflow handle that
npm version "$LATEST" --no-git-tag-version
npm run sync-binary-package-versions
npm install --package-lock-only --ignore-scripts
# Stage package files
git add package.json package-lock.json
@@ -105,7 +107,7 @@ jobs:
if: steps.check.outputs.needs_update == 'true' && steps.pr_check.outputs.pr_exists == 'false'
run: |
LATEST="${{ steps.hugo.outputs.version }}"
BRANCH_NAME="autobump-hugo-v${LATEST}"
BRANCH_NAME="chore/hugo-v${LATEST}"
git commit -m "chore: bump to Hugo v${LATEST}"
git push origin "$BRANCH_NAME"
@@ -116,7 +118,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
LATEST="${{ steps.hugo.outputs.version }}"
BRANCH_NAME="autobump-hugo-v${LATEST}"
BRANCH_NAME="chore/hugo-v${LATEST}"
gh pr create \
--title "chore: bump to Hugo v${LATEST}" \
+52 -6
View File
@@ -10,8 +10,54 @@ permissions:
contents: read
jobs:
npm:
name: Publish to NPM
binary-packages:
name: Publish ${{ matrix.package_name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- package_name: "@jakejarvis/hugo-extended-darwin-universal"
os: macos-latest
- package_name: "@jakejarvis/hugo-extended-linux-amd64"
os: ubuntu-latest
- package_name: "@jakejarvis/hugo-extended-linux-arm64"
os: ubuntu-latest
- package_name: "@jakejarvis/hugo-extended-windows-amd64"
os: ubuntu-latest
- package_name: "@jakejarvis/hugo-windows-arm64"
os: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: npm install -g npm@latest && npm ci
- name: Generate binary package
env:
PACKAGE_NAME: ${{ matrix.package_name }}
run: npm run generate-binary-packages -- --package "$PACKAGE_NAME" --pack-dry-run
- name: Publish binary package
env:
PACKAGE_NAME: ${{ matrix.package_name }}
shell: bash
run: |
package_dir="dist-platforms/${PACKAGE_NAME#@jakejarvis/}"
npm publish "$package_dir" --access public
main-package:
name: Publish hugo-extended
needs: binary-packages
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -25,12 +71,12 @@ jobs:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: npm install -g npm@latest && npm ci
- name: Generate current platform binary package
run: npm run generate-binary-packages -- --set-github-env
- name: Generate types
run: npm run generate-types
@@ -38,7 +84,7 @@ jobs:
run: npm run lint
- name: Type check
run: npm run typecheck
run: npm run check-types
- name: Build
run: npm run build
+43 -317
View File
@@ -10,7 +10,6 @@ permissions:
contents: read
jobs:
# Fast unit tests - run first to catch basic issues quickly
unit:
name: Unit Tests (Node ${{ matrix.node }})
runs-on: ubuntu-latest
@@ -28,12 +27,12 @@ jobs:
node-version: ${{ matrix.node }}
cache: "npm"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: npm ci
- name: Generate current platform binary package
run: npm run generate-binary-packages -- --set-github-env
- name: Generate types
run: npm run generate-types
@@ -41,7 +40,7 @@ jobs:
run: npm run lint
- name: Type check
run: npm run typecheck
run: npm run check-types
- name: Build
run: npm run build
@@ -49,7 +48,6 @@ jobs:
- name: Run unit tests
run: npm run test:unit
# Integration tests - run Hugo commands on each platform
integration:
name: Integration (${{ matrix.os }}, Node ${{ matrix.node }})
needs: unit
@@ -69,12 +67,12 @@ jobs:
node-version: ${{ matrix.node }}
cache: "npm"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: npm ci
- name: Generate current platform binary package
run: npm run generate-binary-packages -- --set-github-env
- name: Generate types
run: npm run generate-types
@@ -84,9 +82,8 @@ jobs:
- name: Run integration tests
run: npm run test:integration
# E2E installation tests - verify the full installation pipeline
e2e:
name: E2E Installation (${{ matrix.os }})
name: E2E Binary Package (${{ matrix.os }})
needs: unit
runs-on: ${{ matrix.os }}
strategy:
@@ -103,12 +100,12 @@ jobs:
node-version: "24"
cache: "npm"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: npm ci
- name: Generate current platform binary package
run: npm run generate-binary-packages -- --set-github-env
- name: Generate types
run: npm run generate-types
@@ -118,9 +115,8 @@ jobs:
- name: Run E2E tests
run: npm run test:e2e
# Fresh installation test - simulates a user installing the package
fresh-install:
name: Fresh Install (${{ matrix.os }})
package-resolution:
name: Package Resolution (${{ matrix.os }})
needs: unit
runs-on: ${{ matrix.os }}
strategy:
@@ -137,147 +133,40 @@ jobs:
node-version: "24"
cache: "npm"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: npm ci
- name: Generate current platform binary package
run: npm run generate-binary-packages
- name: Install generated binary package
shell: bash
run: |
package_dir=$(find dist-platforms -mindepth 1 -maxdepth 1 -type d ! -name .downloads | head -n 1)
npm install --no-save --package-lock=false "$package_dir"
- name: Generate types
run: npm run generate-types
- name: Build
run: npm run build
- name: Remove any pre-existing Hugo binary
shell: bash
run: rm -rf bin/
- name: Verify bin directory is empty
shell: bash
- name: Verify package-resolved Hugo
run: |
if [ -d "bin" ]; then
echo "bin/ directory still exists!"
ls -la bin/
exit 1
fi
echo "bin/ directory confirmed empty"
node -e "
import('./dist/hugo.mjs').then(async (m) => {
const bin = await m.default();
console.log(bin);
const { stdout } = await m.execWithOutput('version');
console.log(stdout.trim());
}).catch((error) => {
console.error(error);
process.exit(1);
});
"
- name: Run postinstall to trigger fresh install
run: node postinstall.js
- name: Verify Hugo binary exists (Unix)
if: runner.os != 'Windows'
run: |
if [ ! -f "bin/hugo" ]; then
echo "Hugo binary not found!"
exit 1
fi
echo "Hugo binary found at bin/hugo"
ls -la bin/
- name: Verify Hugo binary exists (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
if (-not (Test-Path "bin/hugo.exe")) {
Write-Error "Hugo binary not found!"
exit 1
}
Write-Host "Hugo binary found at bin/hugo.exe"
Get-ChildItem bin/
- name: Verify Hugo version matches package version
shell: bash
run: |
PKG_VERSION=$(node -p "require('./package.json').version")
if [[ "$RUNNER_OS" == "Windows" ]]; then
HUGO_VERSION=$(./bin/hugo.exe version)
else
HUGO_VERSION=$(./bin/hugo version)
fi
echo "Package version: $PKG_VERSION"
echo "Hugo version: $HUGO_VERSION"
if [[ "$HUGO_VERSION" != *"v$PKG_VERSION"* ]]; then
echo "Version mismatch!"
exit 1
fi
echo "Version verified successfully"
- name: Verify Extended version (where supported)
shell: bash
run: |
if [[ "$RUNNER_OS" == "Windows" ]]; then
HUGO_VERSION=$(./bin/hugo.exe version)
else
HUGO_VERSION=$(./bin/hugo version)
fi
# Extended is supported on all GitHub Actions runners (x64)
if [[ "$HUGO_VERSION" != *"+extended"* ]]; then
echo "Expected Extended version but got: $HUGO_VERSION"
exit 1
fi
echo "Extended version verified"
# macOS-specific tests (pkg extraction via pkgutil, no sudo required)
macos-quirks:
name: macOS quirks
needs: unit
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: npm ci
- name: Generate types
run: npm run generate-types
- name: Build
run: npm run build
- name: Remove any pre-existing Hugo binary
run: rm -rf bin/
- name: Run postinstall
run: node postinstall.js
- name: Verify binary is regular file (not symlink)
run: |
if [ -L "bin/hugo" ]; then
echo "bin/hugo should not be a symlink (we use pkgutil extraction now)!"
exit 1
fi
if [ ! -f "bin/hugo" ]; then
echo "bin/hugo is not a regular file!"
exit 1
fi
echo "Binary is a regular file (extracted from .pkg via pkgutil)"
- name: Verify executable permissions
run: |
if [ ! -x "bin/hugo" ]; then
echo "bin/hugo is not executable!"
exit 1
fi
echo "Permissions verified"
- name: Verify binary runs
run: ./bin/hugo version
# Linux- tests (tar.gz extraction, permissions)
linux-quirks:
name: Linux quirks
pack:
name: Pack Dry Run
needs: unit
runs-on: ubuntu-latest
steps:
@@ -290,180 +179,17 @@ jobs:
node-version: "24"
cache: "npm"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: npm ci
- name: Generate types
run: npm run generate-types
- name: Generate current platform binary package
run: npm run generate-binary-packages
- name: Build
run: npm run build
- name: Root package dry run
run: npm pack --dry-run
- name: Remove any pre-existing Hugo binary
run: rm -rf bin/
- name: Run postinstall
run: node postinstall.js
- name: Verify binary is regular file (not symlink)
run: |
if [ -L "bin/hugo" ]; then
echo "bin/hugo should not be a symlink on Linux!"
exit 1
fi
if [ ! -f "bin/hugo" ]; then
echo "bin/hugo is not a regular file!"
exit 1
fi
echo "Binary is a regular file"
- name: Verify executable permissions
run: |
if [ ! -x "bin/hugo" ]; then
echo "bin/hugo is not executable!"
exit 1
fi
PERMS=$(stat -c "%a" bin/hugo)
echo "Permissions: $PERMS"
# Should have at least 755 (owner rwx, group rx, others rx)
if [ "$PERMS" -lt "755" ]; then
echo "Insufficient permissions!"
exit 1
fi
echo "Permissions verified"
- name: Verify binary runs
run: ./bin/hugo version
# Windows- tests (zip extraction)
windows-quirks:
name: Windows quirks
needs: unit
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: npm ci
- name: Generate types
run: npm run generate-types
- name: Build
run: npm run build
- name: Remove any pre-existing Hugo binary
shell: pwsh
run: |
if (Test-Path "bin") {
Remove-Item -Recurse -Force "bin"
}
- name: Run postinstall
run: node postinstall.js
- name: Verify binary exists with correct extension
shell: pwsh
run: |
if (-not (Test-Path "bin/hugo.exe")) {
Write-Error "bin/hugo.exe not found!"
exit 1
}
Write-Host "Binary found: bin/hugo.exe"
Get-ChildItem bin/
- name: Verify binary runs
run: ./bin/hugo.exe version
# Re-installation test - simulates the "Hugo disappeared" recovery path
reinstall:
name: Re-installation Recovery (${{ matrix.os }})
needs: unit
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: npm ci
- name: Generate types
run: npm run generate-types
- name: Build
run: npm run build
- name: Initial install
run: node postinstall.js
- name: Verify initial install (Unix)
if: runner.os != 'Windows'
run: ./bin/hugo version
- name: Verify initial install (Windows)
if: runner.os == 'Windows'
run: ./bin/hugo.exe version
- name: Remove Hugo binary to simulate disappearance
- name: Binary package dry run
shell: bash
run: rm -rf bin/
- name: Run Node.js script that triggers auto-reinstall
run: |
node -e "
import('./dist/hugo.mjs').then(async (m) => {
const path = await m.default();
console.log('Hugo reinstalled at:', path);
const { execWithOutput } = m;
const { stdout } = await execWithOutput('version');
console.log('Version:', stdout.trim());
}).catch(e => {
console.error('Failed:', e);
process.exit(1);
});
"
- name: Verify Hugo was reinstalled (Unix)
if: runner.os != 'Windows'
run: |
if [ ! -f "bin/hugo" ]; then
echo "Hugo was not reinstalled!"
exit 1
fi
./bin/hugo version
- name: Verify Hugo was reinstalled (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
if (-not (Test-Path "bin/hugo.exe")) {
Write-Error "Hugo was not reinstalled!"
exit 1
}
./bin/hugo.exe version
package_dir=$(find dist-platforms -mindepth 1 -maxdepth 1 -type d ! -name .downloads | head -n 1)
npm pack --dry-run "$package_dir"
+1 -1
View File
@@ -2,7 +2,7 @@ node_modules/
npm-debug.log*
*.tsbuildinfo
dist/
bin/
dist-platforms/
src/generated/
mysite/
coverage/
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"sortImports": {},
"sortPackageJson": true,
"overrides": [
{
"files": ["**/*.json", "**/*.jsonc"],
"options": {
"trailingComma": "none"
}
}
],
"ignorePatterns": ["node_modules", "dist", "dist-platforms", "src/generated"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["oxc", "eslint", "typescript", "import", "unicorn"],
"env": {
"builtin": true
},
"categories": {
"correctness": "error",
"suspicious": "warn"
},
"rules": {
"import/no-named-as-default": "off",
"unicorn/consistent-function-scoping": "off",
"unicorn/no-array-sort": "off"
},
"ignorePatterns": ["node_modules", "dist", "dist-platforms", "src/generated"]
}
+27 -36
View File
@@ -17,28 +17,29 @@ Notes for LLM coding agents working on `hugo-extended`.
- **Public API**: `src/hugo.ts`
- `default export`: callable function that returns the Hugo binary path **and** has builder methods attached.
- Named exports:
- `getHugoBinary` (binary resolution + auto-install if missing)
- `getHugoBinary` (binary resolution via `HUGO_BIN_PATH` or platform package)
- `exec` / `execWithOutput` (spawn Hugo with argv built from options)
- `hugo` (builder object)
- **CLI entry**: `src/cli.ts`
- Resolves the binary path via the default export and forwards `process.argv.slice(2)` to Hugo.
- **Binary installation**: `src/lib/install.ts`
- Downloads Hugo release assets and verifies SHA-256 checksums.
- **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`.
- **Platform binary packages**: `src/lib/platform.ts`
- Maps supported platform/architecture pairs to exact optional npm packages under `@jakejarvis`.
- Extended packages use `hugo-extended-*` names only where upstream Hugo ships Extended.
- Windows ARM64 uses the vanilla `@jakejarvis/hugo-windows-arm64` package.
- **Binary package generation**: `scripts/generate-binary-packages.ts`
- Downloads Hugo release assets and verifies SHA-256 checksums during release packaging.
- **macOS v0.153.0+**: uses `pkgutil --expand-full` to extract the binary from the `.pkg` file, so the macOS package must be generated on macOS.
- `.tar.gz` and `.zip` assets can be generated on Linux/macOS.
- Emits publishable package directories in `dist-platforms/`.
- **Environment variables**: `src/lib/env.ts`
- Centralized handling of all `HUGO_*` environment variables.
- Exports `getEnvConfig()` for reading parsed config, `logger` for quiet-aware logging.
- Centralized handling of `HUGO_BIN_PATH`.
- Exports `getEnvConfig()` for reading parsed config.
- Exports `ENV_VAR_DOCS` for programmatic access to variable metadata.
- **Postinstall**: `postinstall.js`
- For published packages (where `dist/` exists), runs the compiled installer.
- For repo/dev/CI (where `dist/` may not exist), exits successfully and skips installation.
- **Argv builder**: `src/lib/args.ts`
- Builds argv using `src/generated/flags.json` to understand flag kinds and canonical long names.
- Important: **when a flag exists in the generated spec, its long name is used as-is** (e.g. `--baseURL`, `--buildDrafts`).
@@ -83,8 +84,8 @@ npm run test:coverage # coverage via v8
- Fast, pure TS/JS (no Hugo execution).
- Example: `tests/unit/args.test.ts` covers argv building behavior driven by `flags.json`.
- Example: `tests/unit/types.test.ts` uses `expectTypeOf` to validate type surfaces.
- Example: `tests/unit/utils.test.ts` covers platform detection, release filename resolution.
- Example: `tests/unit/install.test.ts` covers checksum parsing, archive type detection.
- Example: `tests/unit/utils.test.ts` covers platform package detection and binary path resolution.
- Example: `tests/unit/binary-packages.test.ts` covers generated package manifests, checksum parsing, and archive type detection.
- `tests/integration/*`
- Executes real Hugo commands and does real filesystem work in temp dirs.
@@ -92,8 +93,8 @@ npm run test:coverage # coverage via v8
- Prefer passing Hugo's global `--source` via `{ source: sitePath }`.
- `tests/e2e/*`
- End-to-end tests for the full installation pipeline.
- Verifies binary installation, permissions, symlinks (macOS), and version matching.
- End-to-end tests for the resolved Hugo binary.
- Verifies binary presence, permissions, and version matching.
- Platform-specific tests use `it.skipIf()` to skip on unsupported platforms.
### Integration test expectations to keep in mind
@@ -110,35 +111,25 @@ npm run test:coverage # coverage via v8
- Re-run `npm run generate-types` if the change depends on spec shape.
- Prefer making tests match **the committed generated spec**, not an assumed kebab-case transform.
- If you touch installation (`src/lib/install.ts` / `postinstall.js`):
- macOS install path uses `sudo installer` and will behave differently in CI/sandboxed environments.
- Tests are intentionally focused on the wrapper behavior, not on end-to-end installer reliability.
- If you touch binary package generation (`scripts/generate-binary-packages.ts`):
- macOS `.pkg` extraction requires macOS `pkgutil`.
- Keep generated package manifests script-free.
- Use exact package versions that match the root package/Hugo version.
- If you touch exports in `src/hugo.ts`:
- Remember: consumers rely on the **default export being callable** (binary path) and having builder methods attached.
- If you touch environment variables (`src/lib/env.ts`):
- All env vars are defined in `ENV_VARS` with name, aliases, parse function, and description.
- Boolean env vars accept: `1`, `true`, `yes`, `on` (case-insensitive).
- Use `getEnvConfig()` to read config; use `logger.info/warn/error` for quiet-aware output.
- `postinstall.js` has its own minimal env parsing (can't import TypeScript modules).
- `HUGO_BIN_PATH` is the only supported runtime override.
- Use `getEnvConfig()` to read config.
## Environment variables reference
| Variable | Type | Description |
|----------|------|-------------|
| `HUGO_OVERRIDE_VERSION` | string | Install a different Hugo version (ignores package.json) |
| `HUGO_NO_EXTENDED` | boolean | Force vanilla Hugo instead of Extended |
| `HUGO_SKIP_DOWNLOAD` | boolean | Skip postinstall binary download |
| Variable | Type | Description |
| --------------- | ------ | ------------------------------ |
| `HUGO_BIN_PATH` | string | Use a pre-existing Hugo binary |
| `HUGO_MIRROR_BASE_URL` | string | Custom download mirror URL |
| `HUGO_SKIP_CHECKSUM` | boolean | Skip SHA-256 verification |
| `HUGO_QUIET` | boolean | Suppress installation output |
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.
### Version-dependent behavior
- **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.
- **macOS v0.153.0+**: Hugo ships as a `.pkg`; release packaging extracts it using `pkgutil --expand-full`.
- Runtime code does not download or extract Hugo.
+34 -48
View File
@@ -8,7 +8,7 @@
## Features
- 🚀 **Zero configuration** — Hugo binary is automatically downloaded on install
- 🚀 **Zero configuration** — Hugo binary is provided by a platform-specific optional package
- 📦 **Version-locked** — Package version matches Hugo version (e.g., `hugo-extended@0.140.0` = Hugo v0.140.0)
- 🔒 **Type-safe API** — Full TypeScript support with autocomplete for all Hugo commands and flags
-**Multiple APIs** — Use CLI, function-based, or builder-style APIs
@@ -40,7 +40,7 @@ These integrate seamlessly with Hugo's [built-in PostCSS pipes](https://gohugo.i
The simplest way — just run `hugo` commands directly:
```jsonc
```json
// package.json
{
"scripts": {
@@ -184,77 +184,63 @@ await hugo.server({ port: 3000 });
All Hugo commands are fully typed with autocomplete:
| Command | Builder Method | Description |
|---------|---------------|-------------|
| `build` | `hugo.build()` | Build your site |
| `server` | `hugo.server()` | Start dev server |
| `new` | `hugo.new()` | Create new content |
| `mod get` | `hugo.mod.get()` | Download modules |
| `mod tidy` | `hugo.mod.tidy()` | Clean go.mod/go.sum |
| `mod clean` | `hugo.mod.clean()` | Clean module cache |
| `mod vendor` | `hugo.mod.vendor()` | Vendor dependencies |
| `list all` | `hugo.list.all()` | List all content |
| `list drafts` | `hugo.list.drafts()` | List draft content |
| `config` | `hugo.config()` | Print configuration |
| `version` | `hugo.version()` | Print version |
| `env` | `hugo.env()` | Print environment |
| ... | ... | [All Hugo commands supported](https://gohugo.io/commands/) |
| Command | Builder Method | Description |
| ------------- | -------------------- | ---------------------------------------------------------- |
| `build` | `hugo.build()` | Build your site |
| `server` | `hugo.server()` | Start dev server |
| `new` | `hugo.new()` | Create new content |
| `mod get` | `hugo.mod.get()` | Download modules |
| `mod tidy` | `hugo.mod.tidy()` | Clean go.mod/go.sum |
| `mod clean` | `hugo.mod.clean()` | Clean module cache |
| `mod vendor` | `hugo.mod.vendor()` | Vendor dependencies |
| `list all` | `hugo.list.all()` | List all content |
| `list drafts` | `hugo.list.drafts()` | List draft content |
| `config` | `hugo.config()` | Print configuration |
| `version` | `hugo.version()` | Print version |
| `env` | `hugo.env()` | Print environment |
| ... | ... | [All Hugo commands supported](https://gohugo.io/commands/) |
## Platform Support
Hugo Extended is automatically used on supported platforms:
| Platform | Architecture | Hugo Extended |
|----------|-------------|---------------|
| macOS | x64, ARM64 | ✅ |
| Linux | x64, ARM64 | ✅ |
| Windows | x64 | ✅ |
| Windows | ARM64 | ❌ (vanilla Hugo) |
| FreeBSD | x64 | ❌ (vanilla Hugo) |
| Platform | Architecture | Hugo Extended |
| -------- | ------------ | ----------------- |
| macOS | x64, ARM64 | ✅ |
| Linux | x64, ARM64 | ✅ |
| Windows | x64 | ✅ |
| Windows | ARM64 | ❌ (vanilla Hugo) |
## Environment Variables
Customize installation and runtime behavior with these environment variables:
Customize runtime binary resolution with this environment variable:
| Variable | Description |
|----------|-------------|
| `HUGO_OVERRIDE_VERSION` | Install a specific Hugo version instead of the package version. Example: `HUGO_OVERRIDE_VERSION=0.139.0 npm install` |
| `HUGO_NO_EXTENDED` | Force vanilla Hugo instead of Extended edition. Example: `HUGO_NO_EXTENDED=1 npm install` |
| `HUGO_SKIP_DOWNLOAD` | Skip the postinstall binary download entirely. Useful for CI caching or Docker layer optimization. |
| `HUGO_BIN_PATH` | Use a pre-existing Hugo binary instead of the bundled one. Example: `HUGO_BIN_PATH=/usr/local/bin/hugo` |
| `HUGO_MIRROR_BASE_URL` | Download from a custom mirror instead of GitHub releases. Example: `HUGO_MIRROR_BASE_URL=https://mirror.example.com/hugo` |
| `HUGO_SKIP_CHECKSUM` | Skip SHA-256 checksum verification. **Use with caution.** |
| `HUGO_QUIET` | Suppress installation progress output. |
| Variable | Description |
| --------------- | ------------------------------------------------------------------------------------------------------------------- |
| `HUGO_BIN_PATH` | Use a pre-existing Hugo binary instead of the platform package binary. Example: `HUGO_BIN_PATH=/usr/local/bin/hugo` |
### Examples
```sh
# Install a specific older version
HUGO_OVERRIDE_VERSION=0.139.0 npm install hugo-extended
# Skip download for CI caching (when binary is already cached)
HUGO_SKIP_DOWNLOAD=1 npm ci
# Use smaller vanilla Hugo (no SCSS support)
HUGO_NO_EXTENDED=1 npm install hugo-extended
# Use a corporate mirror
HUGO_MIRROR_BASE_URL=https://internal.example.com/hugo npm install hugo-extended
# Use a system-installed Hugo binary
HUGO_BIN_PATH=/usr/local/bin/hugo npm run build
```
## Troubleshooting
### Hugo binary not found
If Hugo seems to disappear (rare edge case), it will be automatically reinstalled on next use. You can also manually trigger reinstallation:
`hugo-extended` depends on platform-specific optional packages such as `@jakejarvis/hugo-extended-linux-amd64`. If you install with optional dependencies omitted, the wrapper cannot find its bundled Hugo binary.
```sh
npm rebuild hugo-extended
npm install
```
If your environment intentionally omits optional dependencies, set `HUGO_BIN_PATH` to a compatible Hugo binary.
### macOS installation
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.
As of [v0.153.0](https://github.com/gohugoio/hugo/releases/tag/v0.153.0), Hugo is distributed as a `.pkg` installer for macOS. The macOS binary package is built from that `.pkg` during release packaging, so **no sudo, global installation, or install-time script is required** for consumers.
## License
-36
View File
@@ -1,36 +0,0 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!src/generated", "!!**/dist"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+1439 -449
View File
File diff suppressed because it is too large Load Diff
+47 -51
View File
@@ -2,30 +2,36 @@
"name": "hugo-extended",
"version": "0.163.3",
"description": "✏️ Plug-and-play binary wrapper for Hugo Extended, the awesomest static-site generator.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/jakejarvis/hugo-extended.git"
},
"keywords": [
"cli",
"gohugoio",
"hugo",
"hugo-extended"
],
"bugs": {
"url": "https://github.com/jakejarvis/hugo-extended/issues"
},
"license": "MIT",
"author": {
"name": "Jake Jarvis",
"email": "jake@jarv.is",
"url": "https://github.com/jakejarvis"
},
"publishConfig": {
"access": "public"
"repository": {
"type": "git",
"url": "git+https://github.com/jakejarvis/hugo-extended.git"
},
"type": "module",
"main": "./dist/hugo.mjs",
"module": "./dist/hugo.mjs",
"types": "./dist/hugo.d.mts",
"bin": {
"hugo": "dist/cli.mjs",
"hugo-extended": "dist/cli.mjs"
},
"files": [
"dist"
],
"type": "module",
"main": "./dist/hugo.mjs",
"module": "./dist/hugo.mjs",
"types": "./dist/hugo.d.mts",
"exports": {
".": {
"types": "./dist/hugo.d.mts",
@@ -37,61 +43,51 @@
"default": "./dist/generated/types.mjs"
}
},
"files": [
"dist",
"postinstall.js"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"generate-types": "bun scripts/generate-types.ts",
"lint": "biome check",
"lint:fix": "biome check --write",
"typecheck": "tsc --noEmit",
"generate-types": "npx jiti scripts/generate-types.ts",
"generate-binary-packages": "npx jiti scripts/generate-binary-packages.ts",
"sync-binary-package-versions": "npx jiti scripts/sync-binary-package-versions.ts",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check",
"check-types": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration",
"test:e2e": "vitest run tests/e2e",
"test:coverage": "vitest run --coverage",
"postinstall": "node postinstall.js",
"pack:dry-run": "npm pack --dry-run && npm run generate-binary-packages -- --pack-dry-run",
"prepublishOnly": "npm run generate-types && npm run build"
},
"dependencies": {
"adm-zip": "^0.5.17",
"tar": "^7.5.15"
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"@types/adm-zip": "^0.5.8",
"@types/node": "^25.9.1",
"@types/node": "^26.0.0",
"@types/tar": "^6.1.13",
"@vitest/coverage-v8": "^4.1.7",
"tinyexec": "^1.2.2",
"tsdown": "^0.22.0",
"@vitest/coverage-v8": "^4.1.9",
"adm-zip": "^0.5.17",
"oxfmt": "^0.55.0",
"oxlint": "^1.70.0",
"tar": "^7.5.16",
"tinyexec": "^1.2.4",
"tsdown": "^0.22.3",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
"vitest": "^4.1.9"
},
"optionalDependencies": {
"@jakejarvis/hugo-extended-darwin-universal": "0.163.3",
"@jakejarvis/hugo-extended-linux-amd64": "0.163.3",
"@jakejarvis/hugo-extended-linux-arm64": "0.163.3",
"@jakejarvis/hugo-extended-windows-amd64": "0.163.3",
"@jakejarvis/hugo-windows-arm64": "0.163.3"
},
"engines": {
"node": ">=18.17"
},
"keywords": [
"hugo",
"hugo extended",
"gohugoio",
"cli",
"front-end",
"bin",
"binary",
"wrapper",
"static site generator",
"static-site",
"ssg",
"static",
"markdown",
"blog",
"frontmatter",
"go",
"golang"
]
"node": "^20.19.0 || >=22.12.0"
}
}
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
/**
* Postinstall wrapper script that properly handles errors during Hugo binary installation.
* This script imports and executes the install function, logging any errors with full stack traces
* and exiting with a non-zero code on failure.
*
* During development/CI (before build), the dist folder won't exist and this script will exit gracefully.
* For published packages, the dist folder is included and installation will proceed.
*
* Environment variables:
* - HUGO_SKIP_DOWNLOAD: Skip installation entirely (useful for CI caching, Docker layers)
*/
const __dirname = dirname(fileURLToPath(import.meta.url));
const installPath = join(__dirname, "dist", "lib", "install.mjs");
/**
* Check if an environment variable is set to a truthy value.
* @param {string} name - Environment variable name
* @returns {boolean}
*/
function isEnvTruthy(name) {
const value = process.env[name];
if (!value) return false;
return ["1", "true", "yes", "on"].includes(value.toLowerCase().trim());
}
async function run() {
// Skip installation if HUGO_SKIP_DOWNLOAD is set
if (isEnvTruthy("HUGO_SKIP_DOWNLOAD")) {
console.log("Skipping Hugo installation (HUGO_SKIP_DOWNLOAD is set)");
process.exit(0);
}
// Skip installation if dist folder doesn't exist (development/CI environment)
if (!existsSync(installPath)) {
console.log(
"Skipping Hugo installation (dist not found - likely in CI or development environment)",
);
process.exit(0);
}
try {
const m = await import("./dist/lib/install.mjs");
await m.default();
} catch (error) {
console.error("Hugo installation failed:");
console.error(error);
process.exit(1);
}
}
run();
+327
View File
@@ -0,0 +1,327 @@
import { execFileSync } 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";
import { fileURLToPath, pathToFileURL } from "node:url";
import AdmZip from "adm-zip";
import * as tar from "tar";
import {
getPlatformPackage,
getPlatformPackageByName,
HUGO_PLATFORM_PACKAGES,
type HugoPlatformPackage,
} from "../src/lib/platform";
import { getChecksumFilename, getReleaseUrl } from "../src/lib/utils";
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.join(currentDir, "..");
export const BINARY_PACKAGES_DIR = path.join(ROOT_DIR, "dist-platforms");
export type ArchiveType = "zip" | "tar.gz" | "pkg" | null;
interface GenerateOptions {
outputDir?: string;
version?: string;
packDryRun?: boolean;
}
interface PackageJson {
name: string;
version: string;
description: string;
license: string;
repository: {
type: "git";
url: string;
};
os: NodeJS.Platform[];
cpu: NodeJS.Architecture[];
preferUnplugged: true;
publishConfig: {
access: "public";
};
files: string[];
}
export function getArchiveType(filename: string): ArchiveType {
if (filename.endsWith(".zip")) return "zip";
if (filename.endsWith(".tar.gz")) return "tar.gz";
if (filename.endsWith(".pkg")) return "pkg";
return null;
}
export function parseChecksumFile(content: string): Map<string, string> {
const checksums = new Map<string, string>();
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] as string;
const filename = tokens[tokens.length - 1] as string;
checksums.set(filename, hash);
}
}
return checksums;
}
export function createBinaryPackageJson(pkg: HugoPlatformPackage, version: string): PackageJson {
return {
name: pkg.packageName,
version,
description: `${pkg.extended ? "Hugo Extended" : "Hugo"} binary for ${pkg.os.join(", ")}/${pkg.cpu.join(", ")}`,
license: "MIT",
repository: {
type: "git",
url: "git+https://github.com/jakejarvis/hugo-extended.git",
},
os: [...pkg.os],
cpu: [...pkg.cpu],
preferUnplugged: true,
publishConfig: {
access: "public",
},
files: ["bin", "README.md", "LICENSE"],
};
}
export function getBinaryPackageDir(
pkg: HugoPlatformPackage,
outputDir = BINARY_PACKAGES_DIR,
): string {
return path.join(outputDir, pkg.directoryName);
}
export function getBinaryPackageBinPath(
pkg: HugoPlatformPackage,
outputDir = BINARY_PACKAGES_DIR,
): string {
return path.join(getBinaryPackageDir(pkg, outputDir), "bin", pkg.binaryName);
}
function readRootPackageVersion(): string {
const packageJsonPath = path.join(ROOT_DIR, "package.json");
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
return (packageJson as { version: string }).version;
}
async function downloadFile(url: string, dest: string): Promise<void> {
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));
}
function sha256(filePath: string): string {
const hash = crypto.createHash("sha256");
hash.update(fs.readFileSync(filePath));
return hash.digest("hex");
}
async function verifyChecksum(filePath: string, version: string, filename: string): Promise<void> {
const checksumFilename = getChecksumFilename(version);
const checksumUrl = getReleaseUrl(version, checksumFilename);
const response = await fetch(checksumUrl);
if (!response.ok) {
throw new Error(`Failed to download checksums: ${response.statusText}`);
}
const checksums = parseChecksumFile(await response.text());
const expected = checksums.get(filename);
if (!expected) {
throw new Error(`Checksum for ${filename} not found in ${checksumFilename}`);
}
const actual = sha256(filePath);
if (actual !== expected) {
throw new Error(`Checksum mismatch for ${filename}: expected ${expected}, got ${actual}`);
}
}
function findFile(startDir: string, filename: string): string | null {
const entries = fs.readdirSync(startDir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(startDir, entry.name);
if (entry.isFile() && entry.name === filename) {
return entryPath;
}
if (entry.isDirectory()) {
const found = findFile(entryPath, filename);
if (found) return found;
}
}
return null;
}
async function extractBinary(
archivePath: string,
pkg: HugoPlatformPackage,
destDir: string,
): Promise<void> {
const archiveType = getArchiveType(archivePath);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hugo-binary-"));
try {
if (archiveType === "pkg") {
if (process.platform !== "darwin") {
throw new Error(`Extracting ${path.basename(archivePath)} requires macOS pkgutil`);
}
execFileSync("pkgutil", ["--expand-full", archivePath, path.join(tempDir, "expanded")], {
stdio: "pipe",
});
} else if (archiveType === "zip") {
const zip = new AdmZip(archivePath);
zip.extractAllTo(tempDir, true);
} else if (archiveType === "tar.gz") {
await tar.x({
file: archivePath,
cwd: tempDir,
});
} else {
throw new Error(`Unexpected archive type for ${path.basename(archivePath)}`);
}
const extractedBinary = findFile(tempDir, pkg.binaryName);
if (!extractedBinary) {
throw new Error(`Could not find ${pkg.binaryName} in ${path.basename(archivePath)}`);
}
fs.mkdirSync(destDir, { recursive: true });
const destPath = path.join(destDir, pkg.binaryName);
fs.copyFileSync(extractedBinary, destPath);
if (pkg.binaryName === "hugo") {
fs.chmodSync(destPath, 0o755);
}
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
function writePackageFiles(pkg: HugoPlatformPackage, version: string, packageDir: string): void {
const manifest = createBinaryPackageJson(pkg, version);
fs.writeFileSync(path.join(packageDir, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`);
fs.copyFileSync(path.join(ROOT_DIR, "LICENSE"), path.join(packageDir, "LICENSE"));
fs.writeFileSync(
path.join(packageDir, "README.md"),
`# ${pkg.packageName}\n\n` +
`Platform binary package for ${pkg.extended ? "Hugo Extended" : "Hugo"} v${version}.\n\n` +
"This package is installed as an optional dependency of `hugo-extended` and is not intended to be used directly.\n",
);
}
export async function generateBinaryPackage(
pkg: HugoPlatformPackage,
options: GenerateOptions = {},
): Promise<string> {
const version = options.version ?? readRootPackageVersion();
const outputDir = options.outputDir ?? BINARY_PACKAGES_DIR;
const packageDir = getBinaryPackageDir(pkg, outputDir);
const releaseFile = pkg.releaseFilename(version);
const releaseUrl = getReleaseUrl(version, releaseFile);
const downloadDir = path.join(outputDir, ".downloads");
const downloadPath = path.join(downloadDir, releaseFile);
fs.rmSync(packageDir, { recursive: true, force: true });
fs.mkdirSync(packageDir, { recursive: true });
fs.mkdirSync(downloadDir, { recursive: true });
console.info(`Downloading ${releaseFile}`);
await downloadFile(releaseUrl, downloadPath);
await verifyChecksum(downloadPath, version, releaseFile);
writePackageFiles(pkg, version, packageDir);
await extractBinary(downloadPath, pkg, path.join(packageDir, "bin"));
if (options.packDryRun) {
execFileSync("npm", ["pack", "--dry-run"], {
cwd: packageDir,
stdio: "inherit",
});
}
console.info(`Generated ${pkg.packageName} at ${packageDir}`);
return packageDir;
}
function parsePackageArgument(args: string[]): string | null {
const index = args.indexOf("--package");
if (index === -1) return null;
const value = args[index + 1];
if (!value) {
throw new Error("--package requires a package name");
}
return value;
}
function selectPackages(args: string[]): HugoPlatformPackage[] {
const packageName = parsePackageArgument(args);
if (packageName) {
const pkg = getPlatformPackageByName(packageName);
if (!pkg) {
throw new Error(`Unknown binary package: ${packageName}`);
}
return [pkg];
}
if (args.includes("--all")) {
return [...HUGO_PLATFORM_PACKAGES];
}
const current = getPlatformPackage();
if (!current) {
throw new Error(`No binary package is defined for ${process.platform}/${process.arch}`);
}
return [current];
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const selectedPackages = selectPackages(args);
const packDryRun = args.includes("--pack-dry-run");
const setGithubEnv = args.includes("--set-github-env");
const generatedDirs: string[] = [];
for (const pkg of selectedPackages) {
generatedDirs.push(await generateBinaryPackage(pkg, { packDryRun }));
}
if (setGithubEnv) {
if (generatedDirs.length !== 1) {
throw new Error("--set-github-env requires exactly one generated package");
}
const pkg = selectedPackages[0];
if (!pkg) {
throw new Error("No generated package selected");
}
const binPath = getBinaryPackageBinPath(pkg);
if (!process.env.GITHUB_ENV) {
throw new Error("GITHUB_ENV is not set");
}
fs.appendFileSync(process.env.GITHUB_ENV, `HUGO_BIN_PATH=${binPath}${os.EOL}`);
console.info(`Set HUGO_BIN_PATH=${binPath}`);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
}
+28 -20
View File
@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { x } from "tinyexec";
import hugo from "../src/hugo";
@@ -150,10 +151,12 @@ function extractDefault(desc: string): {
const re = /\s*\(default(?:\s+is)?\s+([^)]+)\)\s*$/i;
const m = re.exec(desc);
if (!m) return { cleaned: desc };
const defaultRaw = m[1];
if (defaultRaw === undefined) return { cleaned: desc };
return {
cleaned: desc.slice(0, m.index).trimEnd(),
defaultRaw: m[1].trim(),
defaultRaw: defaultRaw.trim(),
};
}
@@ -170,8 +173,10 @@ function extractEnum(desc: string): { cleaned: string; enum?: string[] } {
const re = /\(([^()]*\|[^()]*)\)/;
const m = re.exec(desc);
if (!m) return { cleaned: desc };
const enumRaw = m[1];
if (enumRaw === undefined) return { cleaned: desc };
const parts = m[1]
const parts = enumRaw
.split("|")
.map((s) => s.trim())
.filter(Boolean);
@@ -202,7 +207,10 @@ function parseFlagsFromSection(
let last: FlagSpec | null = null;
for (let i = startIdx; i < lines.length; i++) {
const raw = lines[i].replace(/\t/g, " ").trimEnd();
const line = lines[i];
if (line === undefined) continue;
const raw = line.replace(/\t/g, " ").trimEnd();
// Hugo ends with a standard "Use ..." hint; treat that as a hard stop.
if (raw.startsWith('Use "hugo ')) return { flags: out, endIdx: i };
@@ -238,7 +246,7 @@ function parseFlagsFromSection(
const en = extractEnum(desc);
desc = en.cleaned;
out.push({
const flag: FlagSpec = {
long,
short: m.groups.short,
typeToken,
@@ -246,9 +254,10 @@ function parseFlagsFromSection(
description: desc,
defaultRaw: def.defaultRaw,
enum: en.enum,
});
};
last = out[out.length - 1];
out.push(flag);
last = flag;
continue;
}
@@ -279,7 +288,10 @@ function parseAvailableCommands(helpText: string): string[] {
const out: string[] = [];
for (let i = idx + 1; i < lines.length; i++) {
const raw = lines[i].trimEnd();
const line = lines[i];
if (line === undefined) continue;
const raw = line.trimEnd();
if (raw.trim() === "") continue;
// Stop on the next section header.
@@ -342,10 +354,8 @@ function normalizeLong(long: string) {
*/
function camelizeIfKebab(name: string) {
if (!name.includes("-")) return name;
const [first, ...rest] = name.split("-");
return (
first + rest.map((p) => (p ? p[0].toUpperCase() + p.slice(1) : "")).join("")
);
const [first = "", ...rest] = name.split("-");
return first + rest.map((p) => (p ? p.charAt(0).toUpperCase() + p.slice(1) : "")).join("");
}
/**
@@ -355,7 +365,7 @@ function camelizeIfKebab(name: string) {
* @returns PascalCase string (e.g. `ModGet`).
*/
function pascal(tokens: string[]) {
return tokens.map((t) => (t ? t[0].toUpperCase() + t.slice(1) : "")).join("");
return tokens.map((t) => (t ? t.charAt(0).toUpperCase() + t.slice(1) : "")).join("");
}
/**
@@ -410,7 +420,7 @@ function emitInterfaces(globalFlags: FlagSpec[], commands: CommandSpec[]) {
lines.push("");
lines.push(`export interface HugoGlobalOptions {`);
for (const f of globalFlags.sort((a, b) => a.long.localeCompare(b.long))) {
for (const f of globalFlags.slice().sort((a, b) => a.long.localeCompare(b.long))) {
const prop = camelizeIfKebab(normalizeLong(f.long));
const tsType = kindToTs(f.kind, f.enum);
const def = f.defaultRaw ? ` (default ${f.defaultRaw})` : "";
@@ -420,12 +430,12 @@ function emitInterfaces(globalFlags: FlagSpec[], commands: CommandSpec[]) {
lines.push(`}`);
lines.push("");
for (const cmd of commands.sort((a, b) =>
a.pathTokens.join(" ").localeCompare(b.pathTokens.join(" ")),
)) {
for (const cmd of commands
.slice()
.sort((a, b) => a.pathTokens.join(" ").localeCompare(b.pathTokens.join(" ")))) {
const name = `Hugo${pascal(cmd.pathTokens)}Options`;
lines.push(`export interface ${name} extends HugoGlobalOptions {`);
for (const f of cmd.flags.sort((a, b) => a.long.localeCompare(b.long))) {
for (const f of cmd.flags.slice().sort((a, b) => a.long.localeCompare(b.long))) {
const prop = camelizeIfKebab(normalizeLong(f.long));
const tsType = kindToTs(f.kind, f.enum);
const def = f.defaultRaw ? ` (default ${f.defaultRaw})` : "";
@@ -437,9 +447,7 @@ function emitInterfaces(globalFlags: FlagSpec[], commands: CommandSpec[]) {
}
const cmdStrings = commands.map((c) => c.pathTokens.join(" "));
lines.push(
`export type HugoCommand = ${cmdStrings.map((s) => JSON.stringify(s)).join(" | ")};`,
);
lines.push(`export type HugoCommand = ${cmdStrings.map((s) => JSON.stringify(s)).join(" | ")};`);
lines.push("");
lines.push(`export type HugoOptionsFor<C extends HugoCommand> =`);
for (const cmd of commands) {
+18
View File
@@ -0,0 +1,18 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { HUGO_PLATFORM_PACKAGES } from "../src/lib/platform";
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const packageJsonPath = path.join(currentDir, "..", "package.json");
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
version: string;
optionalDependencies?: Record<string, string>;
};
packageJson.optionalDependencies = Object.fromEntries(
HUGO_PLATFORM_PACKAGES.map((pkg) => [pkg.packageName, packageJson.version]),
);
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
+2 -4
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import hugo from "./hugo";
// Handle unexpected promise rejections
@@ -26,10 +27,7 @@ process.on("unhandledRejection", (reason) => {
process.exitCode = code ?? undefined;
});
} catch (err) {
console.error(
"Failed to initialize Hugo:",
err instanceof Error ? err.message : err,
);
console.error("Failed to initialize Hugo:", err instanceof Error ? err.message : err);
process.exitCode = 1;
}
})();
+45 -71
View File
@@ -1,25 +1,23 @@
import { spawn } from "node:child_process";
import type { HugoCommand, HugoOptionsFor } from "./generated/types";
import { buildArgs } from "./lib/args";
import { getEnvConfig } from "./lib/env";
import install from "./lib/install";
import { doesBinExist, getBinPath, logger } from "./lib/utils";
import { getPlatformPackage } from "./lib/platform";
import { doesBinExist, getBinPath } from "./lib/utils";
/**
* Gets the path to the Hugo binary, automatically installing it if it's missing.
* Gets the path to the Hugo binary.
*
* This is the main entry point for the hugo-extended package. It checks if Hugo
* is already installed and available, and if not, triggers an automatic installation
* before returning the binary path.
*
* This handles the case where Hugo may mysteriously disappear (see issue #81),
* ensuring the binary is always available when this function is called.
* This is the main entry point for the hugo-extended package. It checks for a
* custom HUGO_BIN_PATH first, then resolves the platform-specific optional npm
* package that contains the Hugo binary.
*
* Environment variables that affect behavior:
* - HUGO_BIN_PATH: Use a custom binary path (skips auto-install if missing)
* - HUGO_BIN_PATH: Use a custom binary path
*
* @returns A promise that resolves with the absolute path to the Hugo binary
* @throws {Error} If installation fails, the platform is unsupported, or custom binary is missing
* @throws {Error} If the platform is unsupported or the binary is missing
*
* @example
* ```typescript
@@ -33,22 +31,27 @@ export const getHugoBinary = async (): Promise<string> => {
const envConfig = getEnvConfig();
const bin = getBinPath();
// If using a custom binary path, don't try to auto-install
if (envConfig.binPath) {
if (!doesBinExist(bin)) {
throw new Error(`Custom Hugo binary not found at HUGO_BIN_PATH: ${bin}`);
if (!bin || !doesBinExist(bin)) {
throw new Error(`Custom Hugo binary not found at HUGO_BIN_PATH: ${envConfig.binPath}`);
}
return bin;
}
// A fix for fleeting ENOENT errors, where Hugo seems to disappear. For now,
// just reinstall Hugo when it's missing and then continue normally like
// nothing happened.
// See: https://github.com/jakejarvis/hugo-extended/issues/81
if (!doesBinExist(bin)) {
// Hugo isn't there for some reason. Try re-installing.
logger.warn("Hugo is missing, reinstalling now...");
await install();
const platformPackage = getPlatformPackage();
if (!platformPackage) {
throw new Error(
`Unsupported platform for hugo-extended: ${process.platform}/${process.arch}. ` +
"Set HUGO_BIN_PATH to use a custom Hugo binary.",
);
}
if (!bin || !doesBinExist(bin)) {
throw new Error(
`Hugo binary package not found for ${process.platform}/${process.arch}. ` +
`Expected optional dependency ${platformPackage.packageName}. ` +
"Reinstall without omitting optional dependencies, or set HUGO_BIN_PATH to a custom Hugo binary.",
);
}
return bin;
@@ -107,11 +110,7 @@ export async function exec<C extends HugoCommand>(
opts = positionalArgsOrOptions;
}
const args = buildArgs(
command,
positionalArgs,
opts as Record<string, unknown>,
);
const args = buildArgs(command, positionalArgs, opts as Record<string, unknown>);
return new Promise((resolve, reject) => {
const child = spawn(bin, args, { stdio: "inherit" });
@@ -175,11 +174,7 @@ export async function execWithOutput<C extends HugoCommand>(
opts = positionalArgsOrOptions;
}
const args = buildArgs(
command,
positionalArgs,
opts as Record<string, unknown>,
);
const args = buildArgs(command, positionalArgs, opts as Record<string, unknown>);
return new Promise((resolve, reject) => {
const stdoutChunks: Buffer[] = [];
@@ -207,9 +202,7 @@ export async function execWithOutput<C extends HugoCommand>(
resolve({ stdout, stderr });
} else {
reject(
new Error(
`Hugo command failed with exit code ${code}${stderr ? `\n${stderr}` : ""}`,
),
new Error(`Hugo command failed with exit code ${code}${stderr ? `\n${stderr}` : ""}`),
);
}
});
@@ -247,14 +240,11 @@ export const hugo = {
/** Generate shell completion scripts */
completion: {
bash: (options?: HugoOptionsFor<"completion bash">) =>
exec("completion bash", options),
fish: (options?: HugoOptionsFor<"completion fish">) =>
exec("completion fish", options),
bash: (options?: HugoOptionsFor<"completion bash">) => exec("completion bash", options),
fish: (options?: HugoOptionsFor<"completion fish">) => exec("completion fish", options),
powershell: (options?: HugoOptionsFor<"completion powershell">) =>
exec("completion powershell", options),
zsh: (options?: HugoOptionsFor<"completion zsh">) =>
exec("completion zsh", options),
zsh: (options?: HugoOptionsFor<"completion zsh">) => exec("completion zsh", options),
},
/** Print Hugo configuration */
@@ -262,12 +252,9 @@ export const hugo = {
/** Convert content to different formats */
convert: {
toJSON: (options?: HugoOptionsFor<"convert toJSON">) =>
exec("convert toJSON", options),
toTOML: (options?: HugoOptionsFor<"convert toTOML">) =>
exec("convert toTOML", options),
toYAML: (options?: HugoOptionsFor<"convert toYAML">) =>
exec("convert toYAML", options),
toJSON: (options?: HugoOptionsFor<"convert toJSON">) => exec("convert toJSON", options),
toTOML: (options?: HugoOptionsFor<"convert toTOML">) => exec("convert toTOML", options),
toYAML: (options?: HugoOptionsFor<"convert toYAML">) => exec("convert toYAML", options),
},
/** Print Hugo environment info */
@@ -281,48 +268,35 @@ export const hugo = {
/** Import your site from others */
import: {
jekyll: (options?: HugoOptionsFor<"import jekyll">) =>
exec("import jekyll", options),
jekyll: (options?: HugoOptionsFor<"import jekyll">) => exec("import jekyll", options),
},
/** List various types of content */
list: {
all: (options?: HugoOptionsFor<"list all">) => exec("list all", options),
drafts: (options?: HugoOptionsFor<"list drafts">) =>
exec("list drafts", options),
expired: (options?: HugoOptionsFor<"list expired">) =>
exec("list expired", options),
future: (options?: HugoOptionsFor<"list future">) =>
exec("list future", options),
published: (options?: HugoOptionsFor<"list published">) =>
exec("list published", options),
drafts: (options?: HugoOptionsFor<"list drafts">) => exec("list drafts", options),
expired: (options?: HugoOptionsFor<"list expired">) => exec("list expired", options),
future: (options?: HugoOptionsFor<"list future">) => exec("list future", options),
published: (options?: HugoOptionsFor<"list published">) => exec("list published", options),
},
/** Module operations */
mod: {
clean: (options?: HugoOptionsFor<"mod clean">) =>
exec("mod clean", options),
clean: (options?: HugoOptionsFor<"mod clean">) => exec("mod clean", options),
get: (options?: HugoOptionsFor<"mod get">) => exec("mod get", options),
graph: (options?: HugoOptionsFor<"mod graph">) =>
exec("mod graph", options),
graph: (options?: HugoOptionsFor<"mod graph">) => exec("mod graph", options),
init: (options?: HugoOptionsFor<"mod init">) => exec("mod init", options),
npm: {
pack: (options?: HugoOptionsFor<"mod npm pack">) =>
exec("mod npm pack", options),
pack: (options?: HugoOptionsFor<"mod npm pack">) => exec("mod npm pack", options),
},
tidy: (options?: HugoOptionsFor<"mod tidy">) => exec("mod tidy", options),
vendor: (options?: HugoOptionsFor<"mod vendor">) =>
exec("mod vendor", options),
verify: (options?: HugoOptionsFor<"mod verify">) =>
exec("mod verify", options),
vendor: (options?: HugoOptionsFor<"mod vendor">) => exec("mod vendor", options),
verify: (options?: HugoOptionsFor<"mod verify">) => exec("mod verify", options),
},
/** Create new content */
new: Object.assign(
(
pathOrOptions?: string | HugoOptionsFor<"new">,
options?: HugoOptionsFor<"new">,
) => {
(pathOrOptions?: string | HugoOptionsFor<"new">, options?: HugoOptionsFor<"new">) => {
if (typeof pathOrOptions === "string") {
return exec("new", [pathOrOptions], options);
}
+3 -5
View File
@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const currentDir = path.dirname(fileURLToPath(import.meta.url));
/**
* Flag specification loaded from the generated spec.json file.
@@ -43,7 +43,7 @@ let cachedSpec: HugoSpec | null = null;
function loadSpec(): HugoSpec {
if (cachedSpec) return cachedSpec;
const specPath = path.join(__dirname, "..", "generated", "flags.json");
const specPath = path.join(currentDir, "..", "generated", "flags.json");
try {
const specText = fs.readFileSync(specPath, "utf8");
cachedSpec = JSON.parse(specText) as HugoSpec;
@@ -202,9 +202,7 @@ export function buildArgs(
* @param value - The value to inspect.
* @returns The inferred flag kind.
*/
function inferKind(
value: unknown,
): "boolean" | "string" | "number" | "string[]" | "number[]" {
function inferKind(value: unknown): "boolean" | "string" | "number" | "string[]" | "number[]" {
if (typeof value === "boolean") return "boolean";
if (typeof value === "number") return "number";
if (Array.isArray(value)) {
+10 -211
View File
@@ -1,40 +1,13 @@
/**
* Centralized environment variable handling for hugo-extended.
*
* All environment variables are prefixed with `HUGO_` and provide ways to
* customize the installation and runtime behavior of the package.
* The package intentionally keeps runtime configuration small. Platform
* binaries are provided by optional npm packages, and HUGO_BIN_PATH remains as
* the escape hatch for users who want to provide their own Hugo executable.
*
* @module
*/
/**
* Environment variable configuration schema.
* Each entry defines a variable's name, aliases, and parsing behavior.
*/
interface EnvVarConfig<T> {
/** Primary environment variable name */
name: string;
/** Alternative names that also work (for convenience) */
aliases?: string[];
/** Description for documentation */
description: string;
/** Parse the raw string value into the desired type */
parse: (value: string | undefined) => T;
/** Default value when not set */
defaultValue: T;
}
/**
* Parses a boolean environment variable.
* Truthy values: "1", "true", "yes", "on" (case-insensitive)
* Falsy values: "0", "false", "no", "off", undefined, empty string
*/
function parseBoolean(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.toLowerCase().trim();
return ["1", "true", "yes", "on"].includes(normalized);
}
/**
* Parses a string environment variable (returns undefined if empty).
*/
@@ -43,15 +16,6 @@ function parseString(value: string | undefined): string | undefined {
return value.trim();
}
/**
* Parses a version string, stripping any leading "v" prefix.
*/
function parseVersion(value: string | undefined): string | undefined {
const str = parseString(value);
if (!str) return undefined;
return str.startsWith("v") ? str.slice(1) : str;
}
/**
* Gets the first defined value from a list of environment variable names.
*/
@@ -65,196 +29,36 @@ function getFirstDefined(names: string[]): string | undefined {
return undefined;
}
/**
* All supported environment variable configurations.
*/
const ENV_VARS = {
/**
* Override the Hugo version to install.
* When set, ignores the version from package.json.
*
* Intentionally not aliased to HUGO_VERSION to avoid confusion and
* conflicts with Netlify, etc.
*
* @example HUGO_OVERRIDE_VERSION=0.139.0 npm install hugo-extended
*/
overrideVersion: {
name: "HUGO_OVERRIDE_VERSION",
aliases: [],
description: "Override the Hugo version to install",
parse: parseVersion,
defaultValue: undefined,
} satisfies EnvVarConfig<string | undefined>,
/**
* Force installation of vanilla Hugo instead of Extended.
* Useful when SCSS/SASS features aren't needed or to reduce binary size.
*
* @example HUGO_NO_EXTENDED=1 npm install hugo-extended
*/
forceStandard: {
name: "HUGO_NO_EXTENDED",
aliases: ["HUGO_FORCE_STANDARD"],
description: "Force vanilla Hugo instead of Extended edition",
parse: parseBoolean,
defaultValue: false,
} satisfies EnvVarConfig<boolean>,
/**
* Skip the postinstall Hugo binary download entirely.
* Useful for CI caching, Docker layer optimization, or when Hugo is
* already installed system-wide.
*
* @example HUGO_SKIP_DOWNLOAD=1 npm ci
*/
skipInstall: {
name: "HUGO_SKIP_DOWNLOAD",
aliases: [],
description: "Skip the postinstall binary download",
parse: parseBoolean,
defaultValue: false,
} satisfies EnvVarConfig<boolean>,
/**
* Use a pre-existing Hugo binary instead of the bundled one.
* When set, the package will use this path for all Hugo operations.
*
* @example HUGO_BIN_PATH=/usr/local/bin/hugo npm start
*/
binPath: {
name: "HUGO_BIN_PATH",
aliases: [],
description: "Path to a pre-existing Hugo binary",
parse: parseString,
type: "string",
defaultValue: undefined,
} satisfies EnvVarConfig<string | undefined>,
/**
* Override the base URL for downloading Hugo releases.
* Useful for air-gapped environments, corporate mirrors, or faster
* regional mirrors.
*
* The URL should be the base path where release files are hosted.
* The version and filename will be appended automatically.
*
* @example HUGO_MIRROR_BASE_URL=https://mirror.example.com/hugo npm install
*/
downloadBaseUrl: {
name: "HUGO_MIRROR_BASE_URL",
aliases: [],
description: "Custom base URL for Hugo release downloads",
parse: parseString,
defaultValue: undefined,
} satisfies EnvVarConfig<string | undefined>,
/**
* Skip SHA-256 checksum verification of downloaded files.
* Use with caution - only recommended for trusted mirrors or development.
*
* @example HUGO_SKIP_CHECKSUM=1 npm install hugo-extended
*/
skipChecksum: {
name: "HUGO_SKIP_CHECKSUM",
aliases: ["HUGO_SKIP_VERIFY"],
description: "Skip SHA-256 checksum verification",
parse: parseBoolean,
defaultValue: false,
} satisfies EnvVarConfig<boolean>,
/**
* Suppress installation progress output.
* Useful for cleaner CI logs or scripted automation.
*
* @example HUGO_QUIET=1 npm install hugo-extended
*/
quiet: {
name: "HUGO_QUIET",
aliases: ["HUGO_SILENT"],
description: "Suppress installation progress output",
parse: parseBoolean,
defaultValue: false,
} satisfies EnvVarConfig<boolean>,
},
} as const;
/**
* Typed environment configuration object.
* Provides a clean API for accessing all Hugo environment variables.
*/
export interface HugoEnvConfig {
/** Override the Hugo version to install (ignores package.json) */
overrideVersion: string | undefined;
/** Force vanilla Hugo instead of Extended edition */
forceStandard: boolean;
/** Skip the postinstall binary download */
skipInstall: boolean;
/** Path to a pre-existing Hugo binary */
binPath: string | undefined;
/** Custom base URL for Hugo release downloads */
downloadBaseUrl: string | undefined;
/** Skip SHA-256 checksum verification */
skipChecksum: boolean;
/** Suppress installation progress output */
quiet: boolean;
}
/**
* Reads and parses all Hugo environment variables.
*
* This function reads from `process.env` each time it's called,
* so it will pick up any runtime changes to environment variables.
* This function reads from `process.env` each time it's called, so it will pick
* up any runtime changes to environment variables.
*
* @returns Parsed environment configuration
*
* @example
* ```typescript
* import { getEnvConfig } from './lib/env';
*
* const config = getEnvConfig();
* if (config.skipInstall) {
* console.log('Skipping installation');
* }
* ```
*/
export function getEnvConfig(): HugoEnvConfig {
return {
overrideVersion: ENV_VARS.overrideVersion.parse(
getFirstDefined([
ENV_VARS.overrideVersion.name,
...(ENV_VARS.overrideVersion.aliases ?? []),
]),
),
forceStandard: ENV_VARS.forceStandard.parse(
getFirstDefined([
ENV_VARS.forceStandard.name,
...(ENV_VARS.forceStandard.aliases ?? []),
]),
),
skipInstall: ENV_VARS.skipInstall.parse(
getFirstDefined([
ENV_VARS.skipInstall.name,
...(ENV_VARS.skipInstall.aliases ?? []),
]),
),
binPath: ENV_VARS.binPath.parse(
getFirstDefined([
ENV_VARS.binPath.name,
...(ENV_VARS.binPath.aliases ?? []),
]),
),
downloadBaseUrl: ENV_VARS.downloadBaseUrl.parse(
getFirstDefined([
ENV_VARS.downloadBaseUrl.name,
...(ENV_VARS.downloadBaseUrl.aliases ?? []),
]),
),
skipChecksum: ENV_VARS.skipChecksum.parse(
getFirstDefined([
ENV_VARS.skipChecksum.name,
...(ENV_VARS.skipChecksum.aliases ?? []),
]),
),
quiet: ENV_VARS.quiet.parse(
getFirstDefined([ENV_VARS.quiet.name, ...(ENV_VARS.quiet.aliases ?? [])]),
binPath: parseString(
getFirstDefined([ENV_VARS.binPath.name, ...(ENV_VARS.binPath.aliases ?? [])]),
),
};
}
@@ -268,11 +72,6 @@ export const ENV_VAR_DOCS = Object.entries(ENV_VARS).map(([key, config]) => ({
name: config.name,
aliases: config.aliases ?? [],
description: config.description,
type:
config.defaultValue === undefined
? "string"
: typeof config.defaultValue === "boolean"
? "boolean"
: "string",
type: config.type,
default: config.defaultValue,
}));
-289
View File
@@ -1,289 +0,0 @@
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";
import { fileURLToPath } from "node:url";
import AdmZip from "adm-zip";
import * as tar from "tar";
import { getEnvConfig } from "./env";
import {
getBinFilename,
getBinVersion,
getChecksumFilename,
getPkgVersion,
getReleaseFilename,
getReleaseUrl,
isExtended,
logger,
} from "./utils";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Archive types supported by the installer.
*/
export type ArchiveType = "zip" | "tar.gz" | "pkg" | null;
/**
* 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
*/
export function getArchiveType(filename: string): ArchiveType {
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
*/
export function parseChecksumFile(content: string): Map<string, string> {
const checksums = new Map<string, string>();
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] as string;
const filename = tokens[tokens.length - 1] as string;
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: string, dest: string): Promise<void> {
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
*/
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.
*
* 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: string,
checksumUrl: string,
filename: string,
): Promise<void> {
const response = await fetch(checksumUrl);
if (!response.ok) {
throw new Error(`Failed to download checksums: ${response.statusText}`);
}
const checksumContent = await response.text();
const checksums = parseChecksumFile(checksumContent);
const expectedChecksum = checksums.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(): Promise<string> {
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.",
);
}
}
// Prepare bin directory
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);
}
// All other platforms and macOS pre-0.153.0 (tar.gz) use archive extraction
logger.info("📦 Extracting...");
const archiveType = getArchiveType(releaseFile);
// 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 {
// Defensive: should not happen since unsupported platforms are caught earlier
throw new Error(
`Unexpected archive type for ${releaseFile}. Expected .zip, .tar.gz, or .pkg.`,
);
}
// Cleanup downloaded package
fs.unlinkSync(downloadPath);
const binPath = path.join(binDir, binFile);
if (fs.existsSync(binPath)) {
fs.chmodSync(binPath, 0o755);
}
logger.info("🎉 Hugo installed successfully!");
// Check version and return path
logger.info(getBinVersion(binPath));
return binPath;
} catch (error) {
logger.error("Hugo installation failed. :(");
throw error;
}
}
export default install;
+92
View File
@@ -0,0 +1,92 @@
/**
* Platform-specific npm packages that provide Hugo binaries.
*
* Package names intentionally include "hugo-extended" only where the upstream
* Hugo release actually ships the Extended edition for that target.
*/
export interface HugoPlatformPackage {
/** npm package containing the binary */
packageName: string;
/** Directory name used when generating publishable package folders */
directoryName: string;
/** npm os field values */
os: NodeJS.Platform[];
/** npm cpu field values */
cpu: NodeJS.Architecture[];
/** Binary filename inside the package's bin directory */
binaryName: "hugo" | "hugo.exe";
/** Whether the upstream asset is Hugo Extended */
extended: boolean;
/** Hugo release asset to download for this package */
releaseFilename: (version: string) => string;
}
export const HUGO_PLATFORM_PACKAGES = [
{
packageName: "@jakejarvis/hugo-extended-darwin-universal",
directoryName: "hugo-extended-darwin-universal",
os: ["darwin"],
cpu: ["x64", "arm64"],
binaryName: "hugo",
extended: true,
releaseFilename: (version: string) => `hugo_extended_${version}_darwin-universal.pkg`,
},
{
packageName: "@jakejarvis/hugo-extended-linux-amd64",
directoryName: "hugo-extended-linux-amd64",
os: ["linux"],
cpu: ["x64"],
binaryName: "hugo",
extended: true,
releaseFilename: (version: string) => `hugo_extended_${version}_linux-amd64.tar.gz`,
},
{
packageName: "@jakejarvis/hugo-extended-linux-arm64",
directoryName: "hugo-extended-linux-arm64",
os: ["linux"],
cpu: ["arm64"],
binaryName: "hugo",
extended: true,
releaseFilename: (version: string) => `hugo_extended_${version}_linux-arm64.tar.gz`,
},
{
packageName: "@jakejarvis/hugo-extended-windows-amd64",
directoryName: "hugo-extended-windows-amd64",
os: ["win32"],
cpu: ["x64"],
binaryName: "hugo.exe",
extended: true,
releaseFilename: (version: string) => `hugo_extended_${version}_windows-amd64.zip`,
},
{
packageName: "@jakejarvis/hugo-windows-arm64",
directoryName: "hugo-windows-arm64",
os: ["win32"],
cpu: ["arm64"],
binaryName: "hugo.exe",
extended: false,
releaseFilename: (version: string) => `hugo_${version}_windows-arm64.zip`,
},
] as const satisfies readonly HugoPlatformPackage[];
export function getPlatformPackage(
platform: NodeJS.Platform = process.platform,
arch: NodeJS.Architecture = process.arch,
): HugoPlatformPackage | null {
return (
HUGO_PLATFORM_PACKAGES.find(
(pkg) =>
(pkg.os as readonly NodeJS.Platform[]).includes(platform) &&
(pkg.cpu as readonly NodeJS.Architecture[]).includes(arch),
) ?? null
);
}
export function getPlatformPackageSubpath(pkg: HugoPlatformPackage): `bin/${"hugo" | "hugo.exe"}` {
return `bin/${pkg.binaryName}`;
}
export function getPlatformPackageByName(packageName: string): HugoPlatformPackage | null {
return HUGO_PLATFORM_PACKAGES.find((pkg) => pkg.packageName === packageName) ?? null;
}
+64 -159
View File
@@ -1,18 +1,20 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getEnvConfig } from "./env";
import {
getPlatformPackage,
getPlatformPackageSubpath,
type HugoPlatformPackage,
} from "./platform";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
/**
* The first Hugo version that uses .pkg installers for macOS.
* Versions before this use .tar.gz archives.
*
* @see https://github.com/gohugoio/hugo/issues/14135
*/
const MACOS_PKG_MIN_VERSION = "0.153.0";
export type ResolvePackagePath = (specifier: string) => string;
/**
* Compares two semver version strings.
@@ -39,68 +41,32 @@ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
}
/**
* Checks if a version uses .pkg installers for macOS.
* Hugo v0.153.0+ uses .pkg, earlier versions use .tar.gz.
* Gets the Hugo version for this package.
*
* @param version - The Hugo version to check
* @returns true if the version uses .pkg installers on macOS
*/
export function usesMacOSPkg(version: string): boolean {
return compareVersions(version, MACOS_PKG_MIN_VERSION) >= 0;
}
/**
* Gets the Hugo version to install.
* Package versions are version-locked to Hugo releases.
*
* Resolution order:
* 1. HUGO_OVERRIDE_VERSION environment variable (if set)
* 2. `hugoVersion` field in package.json (for emergency overrides)
* 3. `version` field in package.json (should match Hugo release)
*
* @throws {Error} If package.json cannot be found and no override is set
* @returns The version string (e.g., "0.88.1")
* @throws {Error} If package.json cannot be found
* @returns The version string (e.g., "0.163.3")
*/
export function getPkgVersion(): string {
// Check for environment variable override first
const envConfig = getEnvConfig();
if (envConfig.overrideVersion) {
return envConfig.overrideVersion;
}
// Walk up from __dirname (dist/lib) to find package.json
const packageJsonPath = path.join(__dirname, "..", "..", "package.json");
const packageJsonPath = path.join(currentDir, "..", "..", "package.json");
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
return (
(packageJson as { hugoVersion?: string; version: string }).hugoVersion ||
packageJson.version
);
return (packageJson as { version: string }).version;
} catch {
throw new Error(
`Could not find or read package.json at ${packageJsonPath}`,
);
throw new Error(`Could not find or read package.json at ${packageJsonPath}`);
}
}
/**
* Generates the full URL to a Hugo release file.
* Generates the full GitHub URL to a Hugo release file.
*
* By default, downloads from GitHub releases. Can be overridden with
* HUGO_MIRROR_BASE_URL for mirrors or air-gapped environments.
*
* @param version - The Hugo version number (e.g., "0.88.1")
* @param filename - The release filename (e.g., "hugo_extended_0.88.1_darwin-universal.pkg")
* @param version - The Hugo version number (e.g., "0.163.3")
* @param filename - The release filename
* @returns The complete download URL for the release file
*/
export function getReleaseUrl(version: string, filename: string): string {
const envConfig = getEnvConfig();
if (envConfig.downloadBaseUrl) {
// Custom mirror: append filename to base URL
const baseUrl = envConfig.downloadBaseUrl.replace(/\/$/, "");
return `${baseUrl}/${filename}`;
}
// Default: GitHub releases
return `https://github.com/gohugoio/hugo/releases/download/v${version}/${filename}`;
}
@@ -113,28 +79,56 @@ export function getBinFilename(): string {
return process.platform === "win32" ? "hugo.exe" : "hugo";
}
export function getPlatformPackageBinaryPath(
pkg: HugoPlatformPackage,
resolvePackagePath: ResolvePackagePath = require.resolve,
): string | null {
try {
return resolvePackagePath(`${pkg.packageName}/${getPlatformPackageSubpath(pkg)}`);
} catch (error: unknown) {
if (
error &&
typeof error === "object" &&
"code" in error &&
error.code !== "MODULE_NOT_FOUND" &&
error.code !== "ERR_MODULE_NOT_FOUND" &&
error.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED"
) {
throw error;
}
return null;
}
}
/**
* Gets the absolute path to the Hugo binary.
* Gets the absolute path to the Hugo binary if it can be resolved.
*
* Resolution order:
* 1. HUGO_BIN_PATH environment variable (if set)
* 2. Local bin directory (./bin/hugo or ./bin/hugo.exe)
* 1. HUGO_BIN_PATH environment variable
* 2. The matching @jakejarvis platform binary package
*
* @returns The absolute path to hugo binary
* @returns The absolute path to the Hugo binary, or null when no platform
* package is available for the current platform/architecture.
*/
export function getBinPath(): string {
export function getBinPath(): string | null {
const envConfig = getEnvConfig();
if (envConfig.binPath) {
return envConfig.binPath;
}
return path.join(__dirname, "..", "..", "bin", getBinFilename());
const pkg = getPlatformPackage();
if (!pkg) {
return null;
}
return getPlatformPackageBinaryPath(pkg);
}
/**
* Executes the Hugo binary and returns its version string.
*
* @param bin - The absolute path to the Hugo binary
* @returns The version output string (e.g., "hugo v0.88.1-5BC54738+extended darwin/arm64 BuildDate=...")
* @returns The version output string
* @throws {Error} If the binary cannot be executed
*/
export function getBinVersion(bin: string): string {
@@ -155,13 +149,7 @@ export function doesBinExist(bin: string): boolean {
return true;
}
} catch (error: unknown) {
// something bad happened besides Hugo not existing
if (
error &&
typeof error === "object" &&
"code" in error &&
error.code !== "ENOENT"
) {
if (error && typeof error === "object" && "code" in error && error.code !== "ENOENT") {
throw error;
}
@@ -173,85 +161,18 @@ export function doesBinExist(bin: string): boolean {
/**
* Determines the correct Hugo release filename for the current platform and architecture.
*
* Hugo Extended is available for:
* - macOS: x64 and ARM64 (universal binaries as of v0.102.0)
* - Linux: x64 and ARM64
* - Windows: x64 only
*
* Other platform/architecture combinations fall back to vanilla Hugo where available.
* Set HUGO_NO_EXTENDED=1 to force vanilla Hugo even on platforms that support Extended.
*
* Note: macOS uses .pkg installers starting from v0.153.0. Earlier versions use .tar.gz.
*
* @param version - The Hugo version number (e.g., "0.88.1")
* @returns The release filename if supported (e.g., "hugo_extended_0.88.1_darwin-universal.pkg"),
* or `null` if the platform/architecture combination is not supported
* @param version - The Hugo version number (e.g., "0.163.3")
* @returns The release filename if supported, or `null` if unsupported
*/
export function getReleaseFilename(version: string): string | null {
const { platform, arch } = process;
const envConfig = getEnvConfig();
const forceStandard = envConfig.forceStandard;
// Helper to choose between extended and standard edition
const edition = (extended: string, standard: string): string =>
forceStandard ? standard : extended;
// macOS: as of 0.102.0, binaries are universal
// As of v0.153.0, macOS uses .pkg installers instead of .tar.gz
if (platform === "darwin" && (arch === "x64" || arch === "arm64")) {
if (usesMacOSPkg(version)) {
// v0.153.0+: .pkg installer
return edition(
`hugo_extended_${version}_darwin-universal.pkg`,
`hugo_${version}_darwin-universal.pkg`,
);
}
// Pre-v0.153.0: .tar.gz archive
return edition(
`hugo_extended_${version}_darwin-universal.tar.gz`,
`hugo_${version}_darwin-universal.tar.gz`,
);
}
const filename =
// Windows x64: Extended available
platform === "win32" && arch === "x64"
? edition(
`hugo_extended_${version}_windows-amd64.zip`,
`hugo_${version}_windows-amd64.zip`,
)
: // Windows ARM64: Extended not available
platform === "win32" && arch === "arm64"
? `hugo_${version}_windows-arm64.zip`
: // Linux x64: Extended available
platform === "linux" && arch === "x64"
? edition(
`hugo_extended_${version}_linux-amd64.tar.gz`,
`hugo_${version}_linux-amd64.tar.gz`,
)
: // Linux ARM64: Extended available
platform === "linux" && arch === "arm64"
? edition(
`hugo_extended_${version}_linux-arm64.tar.gz`,
`hugo_${version}_linux-arm64.tar.gz`,
)
: // FreeBSD: Extended not available
platform === "freebsd" && arch === "x64"
? `hugo_${version}_freebsd-amd64.tar.gz`
: // OpenBSD: Extended not available
platform === "openbsd" && arch === "x64"
? `hugo_${version}_openbsd-amd64.tar.gz`
: // not gonna work :(
null;
return filename;
return getPlatformPackage()?.releaseFilename(version) ?? null;
}
/**
* Generates the checksums filename for a given Hugo version.
*
* @param version - The Hugo version number (e.g., "0.88.1")
* @returns The checksums filename (e.g., "hugo_0.88.1_checksums.txt")
* @param version - The Hugo version number (e.g., "0.163.3")
* @returns The checksums filename (e.g., "hugo_0.163.3_checksums.txt")
*/
export function getChecksumFilename(version: string): string {
return `hugo_${version}_checksums.txt`;
@@ -260,38 +181,22 @@ export function getChecksumFilename(version: string): string {
/**
* Determines if a release filename corresponds to Hugo Extended or vanilla Hugo.
*
* @param releaseFile - The release filename to check (e.g., "hugo_extended_0.88.1_darwin-universal.pkg")
* @param releaseFile - The release filename to check
* @returns `true` if the release is Hugo Extended, `false` if it's vanilla Hugo
*/
export function isExtended(releaseFile: string): boolean {
return releaseFile.startsWith("hugo_extended_");
}
/**
* Logger utility that respects the HUGO_QUIET setting.
*/
export const logger = {
/**
* Log an info message (respects HUGO_QUIET).
*/
info: (message: string): void => {
if (!getEnvConfig().quiet) {
console.info(message);
}
console.info(message);
},
/**
* Log a warning message (respects HUGO_QUIET).
*/
warn: (message: string): void => {
if (!getEnvConfig().quiet) {
console.warn(`${message}`);
}
console.warn(`${message}`);
},
/**
* Log an error message (always shown, even in quiet mode).
*/
error: (message: string): void => {
console.error(`${message}`);
},
+13 -18
View File
@@ -1,6 +1,8 @@
import { execFileSync } from "node:child_process";
import { existsSync, lstatSync, statSync } from "node:fs";
import { beforeAll, describe, expect, it } from "vitest";
import hugo, { execWithOutput, getHugoBinary } from "../../src/hugo";
import {
getBinFilename,
@@ -11,16 +13,16 @@ import {
} from "../../src/lib/utils";
/**
* End-to-end tests for Hugo installation.
* End-to-end tests for Hugo binary resolution.
*
* These tests verify:
* - Binary is installed correctly for the current platform
* - Binary is resolved correctly for the current platform
* - Binary has correct permissions
* - Binary is executable and returns expected version
* - Extended version is installed where supported
*
* Note: These tests use the actual installed Hugo binary and require
* npm install/postinstall to have completed successfully.
* Note: These tests use the actual resolved Hugo binary. In repo CI,
* HUGO_BIN_PATH points at a generated platform binary package.
*/
describe("Hugo Installation E2E", () => {
let binaryPath: string;
@@ -48,15 +50,12 @@ describe("Hugo Installation E2E", () => {
});
describe("Binary Permissions (Unix)", () => {
it.skipIf(process.platform === "win32")(
"should have executable permissions",
() => {
const stats = statSync(binaryPath);
// Check that at least owner has execute permission (0o100)
const hasExecute = (stats.mode & 0o100) !== 0;
expect(hasExecute).toBe(true);
},
);
it.skipIf(process.platform === "win32")("should have executable permissions", () => {
const stats = statSync(binaryPath);
// Check that at least owner has execute permission (0o100)
const hasExecute = (stats.mode & 0o100) !== 0;
expect(hasExecute).toBe(true);
});
it.skipIf(process.platform !== "darwin")(
"should be a regular file on macOS (not a symlink)",
@@ -137,11 +136,7 @@ describe("Hugo Installation E2E", () => {
const { stdout } = await execWithOutput("env");
const expectedGoarch =
process.arch === "x64"
? "amd64"
: process.arch === "arm64"
? "arm64"
: process.arch;
process.arch === "x64" ? "amd64" : process.arch === "arm64" ? "arm64" : process.arch;
expect(stdout).toContain(`GOARCH="${expectedGoarch}"`);
});
+1
View File
@@ -1,4 +1,5 @@
import { beforeAll, describe, expect, it } from "vitest";
import hugo, { execWithOutput } from "../../src/hugo";
describe("Hugo Commands Integration", () => {
+8 -18
View File
@@ -1,7 +1,9 @@
import { access, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { hugo } from "../../src/hugo";
describe("New Commands Integration", () => {
@@ -23,9 +25,7 @@ describe("New Commands Integration", () => {
await hugo.new.project(sitePath);
// Check that essential directories exist
await expect(
access(join(sitePath, "hugo.toml")),
).resolves.toBeUndefined();
await expect(access(join(sitePath, "hugo.toml"))).resolves.toBeUndefined();
await expect(access(join(sitePath, "content"))).resolves.toBeUndefined();
await expect(access(join(sitePath, "themes"))).resolves.toBeUndefined();
});
@@ -34,18 +34,14 @@ describe("New Commands Integration", () => {
const sitePath = join(tempDir, "test-site-yaml");
await hugo.new.project(sitePath, { format: "yaml" });
await expect(
access(join(sitePath, "hugo.yaml")),
).resolves.toBeUndefined();
await expect(access(join(sitePath, "hugo.yaml"))).resolves.toBeUndefined();
});
it("should create a new project with json format", async () => {
const sitePath = join(tempDir, "test-site-json");
await hugo.new.project(sitePath, { format: "json" });
await expect(
access(join(sitePath, "hugo.json")),
).resolves.toBeUndefined();
await expect(access(join(sitePath, "hugo.json"))).resolves.toBeUndefined();
});
it("should respect force flag", async () => {
@@ -59,9 +55,7 @@ describe("New Commands Integration", () => {
try {
await hugo.new.project(sitePath, { force: true });
// If it succeeds, verify the site still exists
await expect(
access(join(sitePath, "hugo.toml")),
).resolves.toBeUndefined();
await expect(access(join(sitePath, "hugo.toml"))).resolves.toBeUndefined();
} catch (error) {
// If it fails, that's the current 0.154.x behavior
expect(error).toBeDefined();
@@ -79,9 +73,7 @@ describe("New Commands Integration", () => {
const themePath = join(sitePath, "themes", "test-theme");
await expect(access(themePath)).resolves.toBeUndefined();
await expect(
access(join(themePath, "hugo.toml")),
).resolves.toBeUndefined();
await expect(access(join(themePath, "hugo.toml"))).resolves.toBeUndefined();
});
it("should create theme with yaml format", async () => {
@@ -94,9 +86,7 @@ describe("New Commands Integration", () => {
});
const themePath = join(sitePath, "themes", "test-theme-yaml");
await expect(
access(join(themePath, "hugo.yaml")),
).resolves.toBeUndefined();
await expect(access(join(themePath, "hugo.yaml"))).resolves.toBeUndefined();
});
});
+2 -3
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildArgs } from "../../src/lib/args";
describe("buildArgs", () => {
@@ -88,9 +89,7 @@ describe("buildArgs", () => {
}, []);
expect(themeIndices).toHaveLength(3);
expect(args[themeIndices[0] + 1]).toBe("a");
expect(args[themeIndices[1] + 1]).toBe("b");
expect(args[themeIndices[2] + 1]).toBe("c");
expect(themeIndices.map((index) => args[index + 1])).toEqual(["a", "b", "c"]);
});
});
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
createBinaryPackageJson,
getArchiveType,
getBinaryPackageBinPath,
getBinaryPackageDir,
parseChecksumFile,
} from "../../scripts/generate-binary-packages";
import { getPlatformPackageByName, HUGO_PLATFORM_PACKAGES } from "../../src/lib/platform";
describe("binary package generation", () => {
describe("package set", () => {
it("contains the supported platform packages", () => {
expect(HUGO_PLATFORM_PACKAGES.map((pkg) => pkg.packageName)).toEqual([
"@jakejarvis/hugo-extended-darwin-universal",
"@jakejarvis/hugo-extended-linux-amd64",
"@jakejarvis/hugo-extended-linux-arm64",
"@jakejarvis/hugo-extended-windows-amd64",
"@jakejarvis/hugo-windows-arm64",
]);
});
});
describe("createBinaryPackageJson", () => {
it("creates a script-free manifest with platform filters", () => {
const pkg = getPlatformPackageByName("@jakejarvis/hugo-extended-linux-amd64");
if (!pkg) throw new Error("Expected Linux x64 package");
const manifest = createBinaryPackageJson(pkg, "0.163.3");
expect(manifest).toMatchObject({
name: "@jakejarvis/hugo-extended-linux-amd64",
version: "0.163.3",
os: ["linux"],
cpu: ["x64"],
preferUnplugged: true,
publishConfig: { access: "public" },
files: ["bin", "README.md", "LICENSE"],
});
expect(manifest).not.toHaveProperty("scripts");
expect(manifest).not.toHaveProperty("bin");
});
it("uses vanilla Hugo naming for Windows ARM64", () => {
const pkg = getPlatformPackageByName("@jakejarvis/hugo-windows-arm64");
if (!pkg) throw new Error("Expected Windows ARM64 package");
const manifest = createBinaryPackageJson(pkg, "0.163.3");
expect(manifest.name).toBe("@jakejarvis/hugo-windows-arm64");
expect(manifest.description).toContain("Hugo binary");
expect(pkg?.releaseFilename("0.163.3")).toBe("hugo_0.163.3_windows-arm64.zip");
});
});
describe("paths", () => {
it("uses package directory names and binary filenames", () => {
const pkg = getPlatformPackageByName("@jakejarvis/hugo-extended-windows-amd64");
if (!pkg) throw new Error("Expected Windows x64 package");
expect(getBinaryPackageDir(pkg, "/tmp/out")).toBe("/tmp/out/hugo-extended-windows-amd64");
expect(getBinaryPackageBinPath(pkg, "/tmp/out")).toBe(
"/tmp/out/hugo-extended-windows-amd64/bin/hugo.exe",
);
});
});
describe("parseChecksumFile", () => {
it("parses checksums file format correctly", () => {
const checksumContent = `
abc123def456 hugo_0.163.3_linux-amd64.tar.gz
def789abc012 hugo_extended_0.163.3_linux-amd64.tar.gz
ghi345jkl678 hugo_0.163.3_windows-amd64.zip
`.trim();
const checksums = parseChecksumFile(checksumContent);
expect(checksums.size).toBe(3);
expect(checksums.get("hugo_0.163.3_linux-amd64.tar.gz")).toBe("abc123def456");
expect(checksums.get("hugo_extended_0.163.3_linux-amd64.tar.gz")).toBe("def789abc012");
expect(checksums.get("hugo_0.163.3_windows-amd64.zip")).toBe("ghi345jkl678");
});
it("handles empty content", () => {
expect(parseChecksumFile("").size).toBe(0);
expect(parseChecksumFile(" \n\n \n").size).toBe(0);
});
});
describe("getArchiveType", () => {
it("detects supported archive types", () => {
expect(getArchiveType("hugo_extended_0.163.3_windows-amd64.zip")).toBe("zip");
expect(getArchiveType("hugo_extended_0.163.3_linux-amd64.tar.gz")).toBe("tar.gz");
expect(getArchiveType("hugo_extended_0.163.3_darwin-universal.pkg")).toBe("pkg");
});
it("returns null for unknown extensions", () => {
expect(getArchiveType("hugo_0.163.3_checksums.txt")).toBeNull();
expect(getArchiveType("hugo")).toBeNull();
});
});
});
+21 -202
View File
@@ -1,26 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ENV_VAR_DOCS, getEnvConfig } from "../../src/lib/env";
describe("env", () => {
// Store original env vars to restore after each test
const envVars = ["HUGO_BIN_PATH"];
const originalEnv: Record<string, string | undefined> = {};
// List of all env vars we might set during tests
const envVars = [
"HUGO_OVERRIDE_VERSION",
"HUGO_NO_EXTENDED",
"HUGO_FORCE_STANDARD",
"HUGO_SKIP_DOWNLOAD",
"HUGO_BIN_PATH",
"HUGO_MIRROR_BASE_URL",
"HUGO_SKIP_CHECKSUM",
"HUGO_SKIP_VERIFY",
"HUGO_QUIET",
"HUGO_SILENT",
];
beforeEach(() => {
// Save original values
for (const key of envVars) {
originalEnv[key] = process.env[key];
delete process.env[key];
@@ -28,7 +14,6 @@ describe("env", () => {
});
afterEach(() => {
// Restore original values
for (const key of envVars) {
if (originalEnv[key] === undefined) {
delete process.env[key];
@@ -39,201 +24,35 @@ describe("env", () => {
});
describe("getEnvConfig", () => {
describe("overrideVersion", () => {
it("should return undefined when not set", () => {
const config = getEnvConfig();
expect(config.overrideVersion).toBeUndefined();
});
it("should return the value from HUGO_OVERRIDE_VERSION", () => {
process.env.HUGO_OVERRIDE_VERSION = "0.139.0";
const config = getEnvConfig();
expect(config.overrideVersion).toBe("0.139.0");
});
it("should strip v prefix from version", () => {
process.env.HUGO_OVERRIDE_VERSION = "v0.139.0";
const config = getEnvConfig();
expect(config.overrideVersion).toBe("0.139.0");
});
it("should return undefined for empty string", () => {
process.env.HUGO_OVERRIDE_VERSION = "";
const config = getEnvConfig();
expect(config.overrideVersion).toBeUndefined();
});
it("should return undefined for whitespace-only string", () => {
process.env.HUGO_OVERRIDE_VERSION = " ";
const config = getEnvConfig();
expect(config.overrideVersion).toBeUndefined();
});
it("returns undefined when HUGO_BIN_PATH is not set", () => {
expect(getEnvConfig()).toEqual({ binPath: undefined });
});
describe("forceStandard", () => {
it("should return false when not set", () => {
const config = getEnvConfig();
expect(config.forceStandard).toBe(false);
});
it("returns the trimmed value from HUGO_BIN_PATH", () => {
process.env.HUGO_BIN_PATH = " /usr/local/bin/hugo ";
it.each([
"1",
"true",
"yes",
"on",
"TRUE",
"True",
"YES",
"ON",
])('should return true for "%s"', (value) => {
process.env.HUGO_NO_EXTENDED = value;
const config = getEnvConfig();
expect(config.forceStandard).toBe(true);
});
it.each([
"0",
"false",
"no",
"off",
"",
"invalid",
])('should return false for "%s"', (value) => {
process.env.HUGO_NO_EXTENDED = value;
const config = getEnvConfig();
expect(config.forceStandard).toBe(false);
});
it("should work with HUGO_FORCE_STANDARD alias", () => {
process.env.HUGO_FORCE_STANDARD = "1";
const config = getEnvConfig();
expect(config.forceStandard).toBe(true);
});
expect(getEnvConfig()).toEqual({ binPath: "/usr/local/bin/hugo" });
});
describe("skipInstall", () => {
it("should return false when not set", () => {
const config = getEnvConfig();
expect(config.skipInstall).toBe(false);
});
it("ignores empty values", () => {
process.env.HUGO_BIN_PATH = " ";
it("should return true when HUGO_SKIP_DOWNLOAD is truthy", () => {
process.env.HUGO_SKIP_DOWNLOAD = "1";
const config = getEnvConfig();
expect(config.skipInstall).toBe(true);
});
it("should work with HUGO_SKIP_DOWNLOAD alias", () => {
process.env.HUGO_SKIP_DOWNLOAD = "true";
const config = getEnvConfig();
expect(config.skipInstall).toBe(true);
});
});
describe("binPath", () => {
it("should return undefined when not set", () => {
const config = getEnvConfig();
expect(config.binPath).toBeUndefined();
});
it("should return the value from HUGO_BIN_PATH", () => {
process.env.HUGO_BIN_PATH = "/usr/local/bin/hugo";
const config = getEnvConfig();
expect(config.binPath).toBe("/usr/local/bin/hugo");
});
it("should trim whitespace", () => {
process.env.HUGO_BIN_PATH = " /usr/local/bin/hugo ";
const config = getEnvConfig();
expect(config.binPath).toBe("/usr/local/bin/hugo");
});
});
describe("downloadBaseUrl", () => {
it("should return undefined when not set", () => {
const config = getEnvConfig();
expect(config.downloadBaseUrl).toBeUndefined();
});
it("should return the value from HUGO_MIRROR_BASE_URL", () => {
process.env.HUGO_MIRROR_BASE_URL = "https://mirror.example.com/hugo";
const config = getEnvConfig();
expect(config.downloadBaseUrl).toBe("https://mirror.example.com/hugo");
});
});
describe("skipChecksum", () => {
it("should return false when not set", () => {
const config = getEnvConfig();
expect(config.skipChecksum).toBe(false);
});
it("should return true when HUGO_SKIP_CHECKSUM is truthy", () => {
process.env.HUGO_SKIP_CHECKSUM = "1";
const config = getEnvConfig();
expect(config.skipChecksum).toBe(true);
});
it("should work with HUGO_SKIP_VERIFY alias", () => {
process.env.HUGO_SKIP_VERIFY = "true";
const config = getEnvConfig();
expect(config.skipChecksum).toBe(true);
});
});
describe("quiet", () => {
it("should return false when not set", () => {
const config = getEnvConfig();
expect(config.quiet).toBe(false);
});
it("should return true when HUGO_QUIET is truthy", () => {
process.env.HUGO_QUIET = "1";
const config = getEnvConfig();
expect(config.quiet).toBe(true);
});
it("should work with HUGO_SILENT alias", () => {
process.env.HUGO_SILENT = "true";
const config = getEnvConfig();
expect(config.quiet).toBe(true);
});
expect(getEnvConfig()).toEqual({ binPath: undefined });
});
});
describe("ENV_VAR_DOCS", () => {
it("should export documentation for all env vars", () => {
expect(ENV_VAR_DOCS).toBeDefined();
expect(Array.isArray(ENV_VAR_DOCS)).toBe(true);
expect(ENV_VAR_DOCS.length).toBeGreaterThan(0);
});
it("should have required fields for each entry", () => {
for (const doc of ENV_VAR_DOCS) {
expect(doc.key).toBeDefined();
expect(doc.name).toBeDefined();
expect(doc.name).toMatch(/^HUGO_/);
expect(doc.description).toBeDefined();
expect(Array.isArray(doc.aliases)).toBe(true);
expect(["string", "boolean"]).toContain(doc.type);
}
});
it("should document all expected env vars", () => {
const expectedKeys = [
"overrideVersion",
"forceStandard",
"skipInstall",
"binPath",
"downloadBaseUrl",
"skipChecksum",
"quiet",
];
const actualKeys = ENV_VAR_DOCS.map((doc) => doc.key);
for (const key of expectedKeys) {
expect(actualKeys).toContain(key);
}
it("documents only HUGO_BIN_PATH", () => {
expect(ENV_VAR_DOCS).toEqual([
{
key: "binPath",
name: "HUGO_BIN_PATH",
aliases: [],
description: "Path to a pre-existing Hugo binary",
type: "string",
default: undefined,
},
]);
});
});
});
-263
View File
@@ -1,263 +0,0 @@
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 {
extractPkg,
getArchiveType,
parseChecksumFile,
} from "../../src/lib/install";
import { getReleaseFilename } from "../../src/lib/utils";
/**
* Unit tests for installation logic that can be tested without network calls.
* These tests verify:
* - Checksum file parsing
* - Archive type detection
* - SHA-256 computation
*/
describe("Installation Logic", () => {
describe("SHA-256 Computation", () => {
it("should correctly compute SHA-256 hash", () => {
const testData = "Hello, Hugo!";
const hash = crypto.createHash("sha256");
hash.update(Buffer.from(testData));
const digest = hash.digest("hex");
// Expected SHA-256 hash for "Hello, Hugo!"
// Computed via: echo -n "Hello, Hugo!" | sha256sum
// Cross-verified by computing with a second method below
const expectedHash =
"766a2e18bc3e2f7e217b4566b7988ca3a28e1de8cd70d995219088497a0830e5";
expect(digest).toBe(expectedHash);
expect(digest).toHaveLength(64);
// Cross-verify by computing with a fresh hash instance
const verifyHash = crypto.createHash("sha256");
verifyHash.update(testData, "utf8");
expect(verifyHash.digest("hex")).toBe(expectedHash);
});
});
describe("parseChecksumFile", () => {
it("should parse checksums file format correctly", () => {
const checksumContent = `
abc123def456 hugo_0.154.3_linux-amd64.tar.gz
def789abc012 hugo_extended_0.154.3_linux-amd64.tar.gz
ghi345jkl678 hugo_0.154.3_windows-amd64.zip
`.trim();
const checksums = parseChecksumFile(checksumContent);
expect(checksums.size).toBe(3);
expect(checksums.get("hugo_0.154.3_linux-amd64.tar.gz")).toBe(
"abc123def456",
);
expect(checksums.get("hugo_extended_0.154.3_linux-amd64.tar.gz")).toBe(
"def789abc012",
);
expect(checksums.get("hugo_0.154.3_windows-amd64.zip")).toBe(
"ghi345jkl678",
);
});
it("should find correct checksum for a given filename", () => {
const checksumContent = `
abc123def456 hugo_0.154.3_linux-amd64.tar.gz
def789abc012 hugo_extended_0.154.3_linux-amd64.tar.gz
ghi345jkl678 hugo_0.154.3_windows-amd64.zip
`;
const checksums = parseChecksumFile(checksumContent);
expect(checksums.get("hugo_extended_0.154.3_linux-amd64.tar.gz")).toBe(
"def789abc012",
);
});
it("should return undefined when filename not in checksums", () => {
const checksumContent = `
abc123def456 hugo_0.154.3_linux-amd64.tar.gz
`;
const checksums = parseChecksumFile(checksumContent);
expect(checksums.get("hugo_0.154.3_windows-amd64.zip")).toBeUndefined();
});
it("should handle empty content", () => {
const checksums = parseChecksumFile("");
expect(checksums.size).toBe(0);
});
it("should handle content with only whitespace lines", () => {
const checksums = parseChecksumFile(" \n\n \n");
expect(checksums.size).toBe(0);
});
it("should handle real-world Hugo checksums format", () => {
// Real format from Hugo releases uses two spaces between hash and filename
const realChecksumContent = `
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 hugo_0.154.3_checksums.txt
a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890 hugo_extended_0.154.3_darwin-universal.pkg
f0e9d8c7b6a5432109876543210fedcba0987654321fedcba0987654321fedc hugo_extended_0.154.3_linux-amd64.tar.gz
`;
const checksums = parseChecksumFile(realChecksumContent);
expect(checksums.size).toBe(3);
expect(checksums.get("hugo_extended_0.154.3_darwin-universal.pkg")).toBe(
"a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890",
);
});
});
describe("getArchiveType", () => {
it("should identify zip files", () => {
expect(getArchiveType("hugo_extended_0.154.3_windows-amd64.zip")).toBe(
"zip",
);
});
it("should identify tar.gz files", () => {
expect(getArchiveType("hugo_extended_0.154.3_linux-amd64.tar.gz")).toBe(
"tar.gz",
);
});
it("should identify pkg files", () => {
expect(getArchiveType("hugo_extended_0.154.3_darwin-universal.pkg")).toBe(
"pkg",
);
});
it("should return null for unknown extensions", () => {
expect(getArchiveType("hugo_0.154.3_readme.txt")).toBeNull();
expect(getArchiveType("hugo.exe")).toBeNull();
expect(getArchiveType("checksums.txt")).toBeNull();
});
it("should correctly detect archive type for all platform release filenames", () => {
// Windows x64 -> zip
expect(getArchiveType("hugo_extended_0.154.3_windows-amd64.zip")).toBe(
"zip",
);
// Windows arm64 -> zip
expect(getArchiveType("hugo_0.154.3_windows-arm64.zip")).toBe("zip");
// Linux x64 -> tar.gz
expect(getArchiveType("hugo_extended_0.154.3_linux-amd64.tar.gz")).toBe(
"tar.gz",
);
// Linux arm64 -> tar.gz
expect(getArchiveType("hugo_extended_0.154.3_linux-arm64.tar.gz")).toBe(
"tar.gz",
);
// macOS -> pkg
expect(getArchiveType("hugo_extended_0.154.3_darwin-universal.pkg")).toBe(
"pkg",
);
// FreeBSD -> tar.gz
expect(getArchiveType("hugo_0.154.3_freebsd-amd64.tar.gz")).toBe(
"tar.gz",
);
// OpenBSD -> tar.gz
expect(getArchiveType("hugo_0.154.3_openbsd-amd64.tar.gz")).toBe(
"tar.gz",
);
});
});
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;
beforeEach(() => {
originalPlatform = process.platform;
originalArch = process.arch;
});
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform });
Object.defineProperty(process, "arch", { value: originalArch });
});
it("should return zip for Windows release filenames", () => {
Object.defineProperty(process, "platform", { value: "win32" });
Object.defineProperty(process, "arch", { value: "x64" });
const filename = getReleaseFilename("0.154.3");
assert(filename !== null, "Expected Windows x64 to have a release file");
expect(getArchiveType(filename)).toBe("zip");
});
it("should return tar.gz for Linux release filenames", () => {
Object.defineProperty(process, "platform", { value: "linux" });
Object.defineProperty(process, "arch", { value: "x64" });
const filename = getReleaseFilename("0.154.3");
assert(filename !== null, "Expected Linux x64 to have a release file");
expect(getArchiveType(filename)).toBe("tar.gz");
});
it("should return pkg for macOS release filenames", () => {
Object.defineProperty(process, "platform", { value: "darwin" });
Object.defineProperty(process, "arch", { value: "arm64" });
const filename = getReleaseFilename("0.154.3");
assert(filename !== null, "Expected macOS arm64 to have a release file");
expect(getArchiveType(filename)).toBe("pkg");
});
});
});
+1
View File
@@ -1,4 +1,5 @@
import { describe, expectTypeOf, it } from "vitest";
import type { HugoCommand, HugoOptionsFor } from "../../src/generated/types";
describe("Type Safety", () => {
+106 -341
View File
@@ -1,55 +1,45 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getPlatformPackage } from "../../src/lib/platform";
import {
compareVersions,
getBinFilename,
getBinPath,
getChecksumFilename,
getPkgVersion,
getPlatformPackageBinaryPath,
getReleaseFilename,
getReleaseUrl,
isExtended,
logger,
usesMacOSPkg,
} from "../../src/lib/utils";
describe("utils", () => {
describe("compareVersions", () => {
it("should return 0 for equal versions", () => {
it("returns 0 for equal versions", () => {
expect(compareVersions("0.153.0", "0.153.0")).toBe(0);
expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
});
it("should return -1 when first version is less", () => {
it("returns -1 when the first version is less", () => {
expect(compareVersions("0.152.0", "0.153.0")).toBe(-1);
expect(compareVersions("0.152.9", "0.153.0")).toBe(-1);
expect(compareVersions("0.100.0", "0.153.0")).toBe(-1);
expect(compareVersions("0.153.0", "1.0.0")).toBe(-1);
});
it("should return 1 when first version is greater", () => {
it("returns 1 when the first version is greater", () => {
expect(compareVersions("0.154.0", "0.153.0")).toBe(1);
expect(compareVersions("0.153.1", "0.153.0")).toBe(1);
expect(compareVersions("1.0.0", "0.153.0")).toBe(1);
});
it("should handle versions with different segment counts", () => {
it("handles versions with different segment counts", () => {
expect(compareVersions("0.153", "0.153.0")).toBe(0);
expect(compareVersions("0.153.0.1", "0.153.0")).toBe(1);
});
});
describe("usesMacOSPkg", () => {
it("should return true for v0.153.0 and later", () => {
expect(usesMacOSPkg("0.153.0")).toBe(true);
expect(usesMacOSPkg("0.154.0")).toBe(true);
expect(usesMacOSPkg("0.154.3")).toBe(true);
expect(usesMacOSPkg("1.0.0")).toBe(true);
});
it("should return false for versions before v0.153.0", () => {
expect(usesMacOSPkg("0.152.0")).toBe(false);
expect(usesMacOSPkg("0.152.9")).toBe(false);
expect(usesMacOSPkg("0.100.0")).toBe(false);
expect(usesMacOSPkg("0.102.3")).toBe(false);
describe("getPkgVersion", () => {
it("returns the package version", () => {
expect(getPkgVersion()).toMatch(/^\d+\.\d+\.\d+/);
});
});
@@ -64,24 +54,51 @@ describe("utils", () => {
Object.defineProperty(process, "platform", { value: originalPlatform });
});
it("should return hugo.exe on Windows", () => {
it("returns hugo.exe on Windows", () => {
Object.defineProperty(process, "platform", { value: "win32" });
expect(getBinFilename()).toBe("hugo.exe");
});
it("should return hugo on Linux", () => {
it("returns hugo on Unix-like platforms", () => {
Object.defineProperty(process, "platform", { value: "linux" });
expect(getBinFilename()).toBe("hugo");
});
});
it("should return hugo on macOS", () => {
Object.defineProperty(process, "platform", { value: "darwin" });
expect(getBinFilename()).toBe("hugo");
describe("platform packages", () => {
it("maps macOS x64 and ARM64 to the universal Extended package", () => {
expect(getPlatformPackage("darwin", "x64")).toMatchObject({
packageName: "@jakejarvis/hugo-extended-darwin-universal",
binaryName: "hugo",
extended: true,
});
expect(getPlatformPackage("darwin", "arm64")).toMatchObject({
packageName: "@jakejarvis/hugo-extended-darwin-universal",
});
});
it("should return hugo on other platforms", () => {
Object.defineProperty(process, "platform", { value: "freebsd" });
expect(getBinFilename()).toBe("hugo");
it("maps Linux x64 and ARM64 to Extended packages", () => {
expect(getPlatformPackage("linux", "x64")).toMatchObject({
packageName: "@jakejarvis/hugo-extended-linux-amd64",
binaryName: "hugo",
extended: true,
});
expect(getPlatformPackage("linux", "arm64")).toMatchObject({
packageName: "@jakejarvis/hugo-extended-linux-arm64",
});
});
it("maps Windows x64 to Extended and Windows ARM64 to vanilla", () => {
expect(getPlatformPackage("win32", "x64")).toMatchObject({
packageName: "@jakejarvis/hugo-extended-windows-amd64",
binaryName: "hugo.exe",
extended: true,
});
expect(getPlatformPackage("win32", "arm64")).toMatchObject({
packageName: "@jakejarvis/hugo-windows-arm64",
binaryName: "hugo.exe",
extended: false,
});
});
});
@@ -99,276 +116,70 @@ describe("utils", () => {
Object.defineProperty(process, "arch", { value: originalArch });
});
describe("macOS", () => {
it("should return universal pkg for darwin x64 (v0.153.0+)", () => {
Object.defineProperty(process, "platform", { value: "darwin" });
Object.defineProperty(process, "arch", { value: "x64" });
expect(getReleaseFilename("0.154.3")).toBe(
"hugo_extended_0.154.3_darwin-universal.pkg",
);
});
it("returns the release filename for the current supported platform", () => {
Object.defineProperty(process, "platform", { value: "linux" });
Object.defineProperty(process, "arch", { value: "x64" });
it("should return universal pkg for darwin arm64 (v0.153.0+)", () => {
Object.defineProperty(process, "platform", { value: "darwin" });
Object.defineProperty(process, "arch", { value: "arm64" });
expect(getReleaseFilename("0.154.3")).toBe(
"hugo_extended_0.154.3_darwin-universal.pkg",
);
});
it("should return universal tar.gz for darwin x64 (pre-v0.153.0)", () => {
Object.defineProperty(process, "platform", { value: "darwin" });
Object.defineProperty(process, "arch", { value: "x64" });
expect(getReleaseFilename("0.152.0")).toBe(
"hugo_extended_0.152.0_darwin-universal.tar.gz",
);
});
it("should return universal tar.gz for darwin arm64 (pre-v0.153.0)", () => {
Object.defineProperty(process, "platform", { value: "darwin" });
Object.defineProperty(process, "arch", { value: "arm64" });
expect(getReleaseFilename("0.139.0")).toBe(
"hugo_extended_0.139.0_darwin-universal.tar.gz",
);
});
it("should return pkg at version boundary (v0.153.0)", () => {
Object.defineProperty(process, "platform", { value: "darwin" });
Object.defineProperty(process, "arch", { value: "arm64" });
expect(getReleaseFilename("0.153.0")).toBe(
"hugo_extended_0.153.0_darwin-universal.pkg",
);
});
expect(getReleaseFilename("0.163.3")).toBe("hugo_extended_0.163.3_linux-amd64.tar.gz");
});
describe("Windows", () => {
it("should return extended zip for win32 x64", () => {
Object.defineProperty(process, "platform", { value: "win32" });
Object.defineProperty(process, "arch", { value: "x64" });
expect(getReleaseFilename("0.154.3")).toBe(
"hugo_extended_0.154.3_windows-amd64.zip",
);
});
it("returns null for unsupported platforms", () => {
Object.defineProperty(process, "platform", { value: "freebsd" });
Object.defineProperty(process, "arch", { value: "x64" });
it("should return vanilla zip for win32 arm64 (no extended support)", () => {
Object.defineProperty(process, "platform", { value: "win32" });
Object.defineProperty(process, "arch", { value: "arm64" });
expect(getReleaseFilename("0.154.3")).toBe(
"hugo_0.154.3_windows-arm64.zip",
);
});
});
describe("Linux", () => {
it("should return extended tar.gz for linux x64", () => {
Object.defineProperty(process, "platform", { value: "linux" });
Object.defineProperty(process, "arch", { value: "x64" });
expect(getReleaseFilename("0.154.3")).toBe(
"hugo_extended_0.154.3_linux-amd64.tar.gz",
);
});
it("should return extended tar.gz for linux arm64", () => {
Object.defineProperty(process, "platform", { value: "linux" });
Object.defineProperty(process, "arch", { value: "arm64" });
expect(getReleaseFilename("0.154.3")).toBe(
"hugo_extended_0.154.3_linux-arm64.tar.gz",
);
});
});
describe("BSD", () => {
it("should return vanilla tar.gz for freebsd x64", () => {
Object.defineProperty(process, "platform", { value: "freebsd" });
Object.defineProperty(process, "arch", { value: "x64" });
expect(getReleaseFilename("0.154.3")).toBe(
"hugo_0.154.3_freebsd-amd64.tar.gz",
);
});
it("should return vanilla tar.gz for openbsd x64", () => {
Object.defineProperty(process, "platform", { value: "openbsd" });
Object.defineProperty(process, "arch", { value: "x64" });
expect(getReleaseFilename("0.154.3")).toBe(
"hugo_0.154.3_openbsd-amd64.tar.gz",
);
});
});
describe("unsupported platforms", () => {
it("should return null for unsupported platform", () => {
Object.defineProperty(process, "platform", { value: "sunos" });
Object.defineProperty(process, "arch", { value: "x64" });
expect(getReleaseFilename("0.154.3")).toBeNull();
});
it("should return null for unsupported arch on linux", () => {
Object.defineProperty(process, "platform", { value: "linux" });
Object.defineProperty(process, "arch", { value: "ia32" });
expect(getReleaseFilename("0.154.3")).toBeNull();
});
it("should return null for unsupported arch on freebsd", () => {
Object.defineProperty(process, "platform", { value: "freebsd" });
Object.defineProperty(process, "arch", { value: "arm64" });
expect(getReleaseFilename("0.154.3")).toBeNull();
});
});
});
describe("getChecksumFilename", () => {
it("should return correct checksum filename", () => {
expect(getChecksumFilename("0.154.3")).toBe("hugo_0.154.3_checksums.txt");
});
it("should handle different version formats", () => {
expect(getChecksumFilename("1.0.0")).toBe("hugo_1.0.0_checksums.txt");
expect(getChecksumFilename("0.100.0")).toBe("hugo_0.100.0_checksums.txt");
});
});
describe("isExtended", () => {
it("should return true for extended releases", () => {
expect(isExtended("hugo_extended_0.154.3_linux-amd64.tar.gz")).toBe(true);
expect(isExtended("hugo_extended_0.154.3_darwin-universal.pkg")).toBe(
true,
);
expect(isExtended("hugo_extended_0.154.3_windows-amd64.zip")).toBe(true);
});
it("should return false for vanilla releases", () => {
expect(isExtended("hugo_0.154.3_windows-arm64.zip")).toBe(false);
expect(isExtended("hugo_0.154.3_freebsd-amd64.tar.gz")).toBe(false);
expect(isExtended("hugo_0.154.3_openbsd-amd64.tar.gz")).toBe(false);
expect(getReleaseFilename("0.163.3")).toBeNull();
});
});
describe("getReleaseUrl", () => {
let originalMirrorBaseUrl: string | undefined;
beforeEach(() => {
originalMirrorBaseUrl = process.env.HUGO_MIRROR_BASE_URL;
delete process.env.HUGO_MIRROR_BASE_URL;
});
afterEach(() => {
if (originalMirrorBaseUrl === undefined) {
delete process.env.HUGO_MIRROR_BASE_URL;
} else {
process.env.HUGO_MIRROR_BASE_URL = originalMirrorBaseUrl;
}
});
it("should return correct GitHub release URL", () => {
expect(
getReleaseUrl("0.154.3", "hugo_extended_0.154.3_linux-amd64.tar.gz"),
).toBe(
"https://github.com/gohugoio/hugo/releases/download/v0.154.3/hugo_extended_0.154.3_linux-amd64.tar.gz",
);
});
it("should work with checksum files", () => {
expect(getReleaseUrl("0.154.3", "hugo_0.154.3_checksums.txt")).toBe(
"https://github.com/gohugoio/hugo/releases/download/v0.154.3/hugo_0.154.3_checksums.txt",
);
});
it("should use custom base URL when HUGO_MIRROR_BASE_URL is set", () => {
process.env.HUGO_MIRROR_BASE_URL = "https://mirror.example.com/hugo";
expect(
getReleaseUrl("0.154.3", "hugo_extended_0.154.3_linux-amd64.tar.gz"),
).toBe(
"https://mirror.example.com/hugo/hugo_extended_0.154.3_linux-amd64.tar.gz",
);
});
it("should strip trailing slash from custom base URL", () => {
process.env.HUGO_MIRROR_BASE_URL = "https://mirror.example.com/hugo/";
expect(
getReleaseUrl("0.154.3", "hugo_extended_0.154.3_linux-amd64.tar.gz"),
).toBe(
"https://mirror.example.com/hugo/hugo_extended_0.154.3_linux-amd64.tar.gz",
it("returns the GitHub release URL", () => {
expect(getReleaseUrl("0.163.3", "hugo_extended_0.163.3_linux-amd64.tar.gz")).toBe(
"https://github.com/gohugoio/hugo/releases/download/v0.163.3/hugo_extended_0.163.3_linux-amd64.tar.gz",
);
});
});
describe("getReleaseFilename with HUGO_NO_EXTENDED", () => {
let originalPlatform: NodeJS.Platform;
let originalArch: NodeJS.Architecture;
let originalNoExtended: string | undefined;
let originalForceStandard: string | undefined;
beforeEach(() => {
originalPlatform = process.platform;
originalArch = process.arch;
originalNoExtended = process.env.HUGO_NO_EXTENDED;
originalForceStandard = process.env.HUGO_FORCE_STANDARD;
delete process.env.HUGO_NO_EXTENDED;
delete process.env.HUGO_FORCE_STANDARD;
});
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform });
Object.defineProperty(process, "arch", { value: originalArch });
if (originalNoExtended === undefined) {
delete process.env.HUGO_NO_EXTENDED;
} else {
process.env.HUGO_NO_EXTENDED = originalNoExtended;
}
if (originalForceStandard === undefined) {
delete process.env.HUGO_FORCE_STANDARD;
} else {
process.env.HUGO_FORCE_STANDARD = originalForceStandard;
}
});
it("should return vanilla Hugo pkg when HUGO_NO_EXTENDED is set on macOS (v0.153.0+)", () => {
Object.defineProperty(process, "platform", { value: "darwin" });
Object.defineProperty(process, "arch", { value: "arm64" });
process.env.HUGO_NO_EXTENDED = "1";
expect(getReleaseFilename("0.154.3")).toBe(
"hugo_0.154.3_darwin-universal.pkg",
);
});
it("should return vanilla Hugo tar.gz when HUGO_NO_EXTENDED is set on macOS (pre-v0.153.0)", () => {
Object.defineProperty(process, "platform", { value: "darwin" });
Object.defineProperty(process, "arch", { value: "arm64" });
process.env.HUGO_NO_EXTENDED = "1";
expect(getReleaseFilename("0.152.0")).toBe(
"hugo_0.152.0_darwin-universal.tar.gz",
);
});
it("should return vanilla Hugo when HUGO_NO_EXTENDED is set on Linux", () => {
Object.defineProperty(process, "platform", { value: "linux" });
Object.defineProperty(process, "arch", { value: "x64" });
process.env.HUGO_NO_EXTENDED = "1";
expect(getReleaseFilename("0.154.3")).toBe(
"hugo_0.154.3_linux-amd64.tar.gz",
);
});
it("should return vanilla Hugo when HUGO_NO_EXTENDED is set on Windows", () => {
Object.defineProperty(process, "platform", { value: "win32" });
Object.defineProperty(process, "arch", { value: "x64" });
process.env.HUGO_NO_EXTENDED = "1";
expect(getReleaseFilename("0.154.3")).toBe(
"hugo_0.154.3_windows-amd64.zip",
);
});
it("should work with HUGO_FORCE_STANDARD alias", () => {
Object.defineProperty(process, "platform", { value: "linux" });
Object.defineProperty(process, "arch", { value: "arm64" });
process.env.HUGO_FORCE_STANDARD = "true";
expect(getReleaseFilename("0.154.3")).toBe(
"hugo_0.154.3_linux-arm64.tar.gz",
);
describe("getChecksumFilename", () => {
it("returns the checksums filename", () => {
expect(getChecksumFilename("0.163.3")).toBe("hugo_0.163.3_checksums.txt");
});
});
describe("getBinPath with HUGO_BIN_PATH", () => {
describe("isExtended", () => {
it("identifies Extended release filenames", () => {
expect(isExtended("hugo_extended_0.163.3_linux-amd64.tar.gz")).toBe(true);
expect(isExtended("hugo_0.163.3_windows-arm64.zip")).toBe(false);
});
});
describe("getPlatformPackageBinaryPath", () => {
it("resolves a package binary subpath", () => {
const pkg = getPlatformPackage("linux", "x64");
if (!pkg) throw new Error("Expected Linux x64 package");
const resolved = getPlatformPackageBinaryPath(pkg, (specifier) => {
expect(specifier).toBe("@jakejarvis/hugo-extended-linux-amd64/bin/hugo");
return "/node_modules/@jakejarvis/hugo-extended-linux-amd64/bin/hugo";
});
expect(resolved).toBe("/node_modules/@jakejarvis/hugo-extended-linux-amd64/bin/hugo");
});
it("returns null when the optional package is missing", () => {
const pkg = getPlatformPackage("linux", "x64");
if (!pkg) throw new Error("Expected Linux x64 package");
const resolved = getPlatformPackageBinaryPath(pkg, () => {
const error = new Error("missing") as NodeJS.ErrnoException;
error.code = "MODULE_NOT_FOUND";
throw error;
});
expect(resolved).toBeNull();
});
});
describe("getBinPath", () => {
let originalBinPath: string | undefined;
beforeEach(() => {
@@ -384,83 +195,37 @@ describe("utils", () => {
}
});
it("should return custom path when HUGO_BIN_PATH is set", () => {
it("returns HUGO_BIN_PATH when set", () => {
process.env.HUGO_BIN_PATH = "/custom/path/to/hugo";
expect(getBinPath()).toBe("/custom/path/to/hugo");
});
it("should return default path when HUGO_BIN_PATH is not set", () => {
const binPath = getBinPath();
expect(binPath).toContain("bin");
expect(binPath).toMatch(/hugo(\.exe)?$/);
expect(getBinPath()).toBe("/custom/path/to/hugo");
});
});
describe("logger", () => {
let originalQuiet: string | undefined;
let originalSilent: string | undefined;
beforeEach(() => {
originalQuiet = process.env.HUGO_QUIET;
originalSilent = process.env.HUGO_SILENT;
delete process.env.HUGO_QUIET;
delete process.env.HUGO_SILENT;
vi.spyOn(console, "info").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
if (originalQuiet === undefined) {
delete process.env.HUGO_QUIET;
} else {
process.env.HUGO_QUIET = originalQuiet;
}
if (originalSilent === undefined) {
delete process.env.HUGO_SILENT;
} else {
process.env.HUGO_SILENT = originalSilent;
}
vi.restoreAllMocks();
});
describe("info", () => {
it("should log when not quiet", () => {
logger.info("test message");
expect(console.info).toHaveBeenCalledWith("test message");
});
it("should not log when HUGO_QUIET is set", () => {
process.env.HUGO_QUIET = "1";
logger.info("test message");
expect(console.info).not.toHaveBeenCalled();
});
it("logs info messages", () => {
logger.info("test message");
expect(console.info).toHaveBeenCalledWith("test message");
});
describe("warn", () => {
it("should log when not quiet", () => {
logger.warn("warning message");
expect(console.warn).toHaveBeenCalledWith("⚠ warning message");
});
it("should not log when HUGO_SILENT is set", () => {
process.env.HUGO_SILENT = "1";
logger.warn("warning message");
expect(console.warn).not.toHaveBeenCalled();
});
it("logs warning messages", () => {
logger.warn("warning message");
expect(console.warn).toHaveBeenCalledWith("warning message");
});
describe("error", () => {
it("should always log errors", () => {
logger.error("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");
});
it("logs error messages", () => {
logger.error("error message");
expect(console.error).toHaveBeenCalledWith("error message");
});
});
});
+6 -9
View File
@@ -3,25 +3,22 @@
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"],
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": false,
"sourceMap": false,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"include": ["**/*.ts"],
"exclude": ["dist", "dist-platforms", "node_modules"]
}
+1 -3
View File
@@ -4,9 +4,7 @@ export default defineConfig({
entry: ["src/**/*.ts"],
outDir: "dist",
clean: true,
dts: {
resolve: true,
},
dts: true,
unbundle: true,
copy: ["src/generated"],
});
+1 -6
View File
@@ -16,12 +16,7 @@ export default defineConfig({
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.ts"],
exclude: [
"src/generated/**",
"src/**/*.d.ts",
"**/*.test.ts",
"scripts/**",
],
exclude: ["src/generated/**", "src/**/*.d.ts", "**/*.test.ts", "scripts/**"],
},
// Timeout for integration tests (some may be slow)