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

refactor: full typescript migration (#174)

This commit is contained in:
2026-01-06 21:08:36 -05:00
committed by GitHub
parent 4ce0fbb869
commit 0f9bca8bf5
32 changed files with 4803 additions and 2903 deletions
-8
View File
@@ -1,8 +0,0 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
versioning-strategy: increase
schedule:
interval: daily
+19 -10
View File
@@ -1,9 +1,9 @@
name: Publish to NPM
name: Publish
on:
push:
tags:
- 'v*'
- "v*"
permissions:
id-token: write
@@ -14,11 +14,20 @@ jobs:
name: Publish to NPM
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- run: npm install -g npm@latest
- run: npm ci
- run: npm publish
- uses: actions/checkout@v6
- 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 types
run: npm run generate-types
- name: Lint
run: npm run lint
- name: Type check
run: npm run typecheck
- name: Build
run: npm run build
- name: Publish
run: npm publish
+51 -24
View File
@@ -1,35 +1,62 @@
name: Run tests
name: Tests
on:
push:
branches:
- main
- main
pull_request:
permissions:
contents: read
jobs:
test:
if: "!contains(github.event.head_commit.message, '[skip ci]')"
name: Test on ${{ matrix.os }} with Node ${{ matrix.node }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os:
- ubuntu-latest
- macos-latest
- windows-latest
node:
- 24
- 22
- 20
fail-fast: false
runs-on: ${{ matrix.os }}
name: Node ${{ matrix.node }} on ${{ matrix.os }}
os: [ubuntu-latest, macos-latest, windows-latest]
node: ["20", "22", "24"]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm audit --omit=dev
continue-on-error: true
- run: npm run test
- run: node lib/cli.js new site mysite
- run: node lib/cli.js --source mysite/ --minify --enableGitInfo --logLevel info
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Generate types
run: npm run generate-types
- name: Lint
run: npm run lint
- name: Type check
run: npm run typecheck
- name: Build
run: npm run build
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
# coverage:
# name: Coverage
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v6
# - uses: actions/setup-node@v6
# with:
# node-version: "24"
# cache: "npm"
# - name: Install dependencies
# run: npm ci
# - name: Generate types
# run: npm run generate-types
# - name: Build
# run: npm run build
# - name: Run tests with coverage
# run: npm run test:coverage
# - name: Upload coverage reports
# uses: codecov/codecov-action@v5
# with:
# files: ./coverage/coverage-final.json
# fail_ci_if_error: false
+9 -2
View File
@@ -1,3 +1,10 @@
node_modules/
vendor/
npm-debug.log
npm-debug.log*
*.tsbuildinfo
dist/
bin/
src/generated/
mysite/
coverage/
.vitest-cache/
*.test.mjs
+105
View File
@@ -0,0 +1,105 @@
# AGENTS.md
Notes for LLM coding agents working on `hugo-extended`.
## What this repo is
`hugo-extended` is a **version-locked** Node package that wraps the Hugo CLI:
- **Package version == Hugo version** (e.g. `0.154.3`).
- Provides:
- **CLI passthrough** (`hugo` / `hugo-extended` binaries -> `dist/cli.mjs`)
- **Programmatic API** (type-safe `exec`, `execWithOutput`, and builder-style `hugo.*`)
- **Direct binary path access** (default export is callable and resolves to the Hugo binary path)
## Key files / mental model
- **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)
- `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**: uses `sudo installer -pkg ... -target /` (and then symlinks `bin/hugo` -> `/usr/local/bin/hugo`).
- **non-macOS**: extracts archive into `bin/` and `chmod +x`.
- **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`).
- **Generated inputs** (committed):
- `src/generated/types.ts`: command/options types.
- `src/generated/flags.json`: runtime flag spec used by argv building.
## Code generation (types + flag spec)
`scripts/generate-types.ts`:
- Runs Hugo help output traversal (BFS across the command tree).
- Emits:
- `src/generated/types.ts`
- `src/generated/flags.json`
When bumping Hugo versions, **regenerate these files** and expect downstream changes in:
- Flag names/casing (Hugo sometimes prefers mixed case like `baseURL`)
- Which commands support which flags
- Integration-test filesystem outputs (Hugo occasionally changes scaffolding)
## Testing (concise)
This repo uses **Vitest**.
### Commands
```bash
npm test # all tests (vitest run)
npm run test:watch # watch mode
npm run test:unit # unit tests only
npm run test:integration # integration tests only (runs real Hugo)
npm run test:coverage # coverage via v8
```
### Test layout
- `tests/unit/*`
- 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.
- `tests/integration/*`
- Executes real Hugo commands and does real filesystem work in temp dirs.
- **Avoid `process.chdir()`** in tests: Vitest worker contexts may not support it.
- Prefer passing Hugos global `--source` via `{ source: sitePath }`.
### Integration test expectations to keep in mind
- Hugo output is noisy (e.g. “Congratulations! Your new Hugo site…”). Tests should assert on filesystem results instead of brittle stdout text.
- Hugo scaffolding changes over time:
- Example: `hugo new theme` in 0.154.x generates a theme skeleton with `hugo.toml` / `hugo.yaml` rather than `theme.toml`.
- Some flags may exist but not behave as youd intuit for a given command:
- Example: `hugo new site --force` does **not** overwrite an existing `hugo.toml` in 0.154.x.
## Practical tips for agents making changes
- If you touch argv generation (`src/lib/args.ts`):
- 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 exports in `src/hugo.ts`:
- Remember: consumers rely on the **default export being callable** (binary path) and having builder methods attached.
View File
+189 -64
View File
@@ -1,106 +1,231 @@
# <img src="https://raw.githubusercontent.com/gohugoio/gohugoioTheme/master/static/images/hugo-logo-wide.svg?sanitize=true" alt="Hugo" width="115"> via NPM [![npm](https://img.shields.io/npm/v/hugo-extended?color=blue&logo=npm)](https://www.npmjs.com/package/hugo-extended) [![CI status](https://github.com/jakejarvis/hugo-extended/workflows/Run%20tests/badge.svg)](https://github.com/jakejarvis/hugo-extended/actions)
# <img src="https://raw.githubusercontent.com/gohugoio/gohugoioTheme/master/static/images/hugo-logo-wide.svg?sanitize=true" alt="Hugo" width="115"> via NPM
> Plug-and-play binary wrapper for [Hugo Extended](https://gohugo.io/), the awesomest static-site generator.
[![NPM Version](https://img.shields.io/npm/v/hugo-extended?color=blue)](https://www.npmjs.com/package/hugo-extended)
[![NPM Downloads](https://img.shields.io/npm/dw/hugo-extended?color=rebeccapurple)](https://www.npmjs.com/package/hugo-extended)
[![CI status](https://github.com/jakejarvis/hugo-extended/workflows/Run%20tests/badge.svg)](https://github.com/jakejarvis/hugo-extended/actions)
> Plug-and-play binary wrapper for [Hugo Extended](https://gohugo.io/), the awesomest static-site generator. Now with full TypeScript support and type-safe APIs!
## Features
- 🚀 **Zero configuration** — Hugo binary is automatically downloaded on install
- 📦 **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
- 🎯 **Extended by default** — Automatically uses Hugo Extended on supported platforms
## Installation
```sh
npm install hugo-extended --save-dev
# or...
# or
yarn add hugo-extended --dev
# or
pnpm add hugo-extended --save-dev
```
`hugo-extended` defaults to the [extended version](https://gohugo.io/troubleshooting/faq/#i-get--this-feature-is-not-available-in-your-current-hugo-version) of Hugo on [supported platforms](https://github.com/gohugoio/hugo/releases), and automatically falls back to vanilla Hugo if unsupported (mainly on 32-bit systems).
### SCSS/PostCSS Support
This package's version numbers align with Hugo's — `hugo-extended@0.64.1` installs Hugo v0.64.1, for example.
_Note:_ If you'll be using the SCSS features of Hugo Extended, it's probably smart to install [`postcss`](https://www.npmjs.com/package/postcss), [`postcss-cli`](https://www.npmjs.com/package/postcss-cli), and [`autoprefixer`](https://www.npmjs.com/package/autoprefixer) as devDependencies too, since they can be conveniently called via [built-in Hugo pipes](https://gohugo.io/hugo-pipes/postcss/):
If you're using Hugo's SCSS features, you'll also want:
```sh
npm install postcss postcss-cli autoprefixer --save-dev
# or...
yarn add postcss postcss-cli autoprefixer --dev
```
These integrate seamlessly with Hugo's [built-in PostCSS pipes](https://gohugo.io/functions/css/postcss/).
## Usage
The following examples simply refer to downloading and executing Hugo as a Node dependency. See the [official Hugo docs](https://gohugo.io/documentation/) for guidance on actual Hugo usage.
### CLI Usage
### via CLI / `package.json`:
The `build:preview` script below is designed for [Netlify deploy previews](https://www.netlify.com/blog/2016/07/20/introducing-deploy-previews-in-netlify/), where [`$DEPLOY_PRIME_URL`](https://docs.netlify.com/configure-builds/environment-variables/#deploy-urls-and-metadata) is substituted for the base URL (usually ending in .netlify.app) of each pull request, branch, or commit preview.
The simplest way — just run `hugo` commands directly:
```jsonc
// package.json:
// package.json
{
// ...
"scripts": {
"build": "hugo",
"build:preview": "hugo --baseURL \"${DEPLOY_PRIME_URL:-/}\" --buildDrafts --buildFuture",
"start": "hugo server"
},
"devDependencies": {
"autoprefixer": "^10.3.4",
"hugo-extended": "^0.88.1",
"postcss": "^8.3.6",
"postcss-cli": "^8.3.1"
"dev": "hugo server --buildDrafts",
"build": "hugo --minify",
"build:preview": "hugo --baseURL \"${DEPLOY_PRIME_URL:-/}\" --buildDrafts --buildFuture"
}
}
```
```sh
npm run dev
```
### Programmatic API
#### Builder-style API
A fluent interface where each Hugo command is a method:
```typescript
import hugo from "hugo-extended";
// Start server
await hugo.server({
port: 1313,
buildDrafts: true,
});
// Build site
await hugo.build({
minify: true,
environment: "production",
});
// Module commands
await hugo.mod.get();
await hugo.mod.tidy();
await hugo.mod.clean({ all: true });
// Generate shell completions
await hugo.completion.zsh();
```
#### Function-based API
Use `exec()` for commands that output to the console, or `execWithOutput()` to capture the output:
```typescript
import { exec, execWithOutput } from "hugo-extended";
// Start development server with full type safety
await exec("server", {
port: 1313,
buildDrafts: true,
navigateToChanged: true,
});
// Build for production
await exec("build", {
minify: true,
cleanDestinationDir: true,
baseURL: "https://example.com",
});
// Capture command output
const { stdout } = await execWithOutput("version");
console.log(stdout); // "hugo v0.140.0+extended darwin/arm64 ..."
// List all content pages
const { stdout: pages } = await execWithOutput("list all");
```
#### Direct Binary Access
For advanced use cases, get the Hugo binary path directly:
```typescript
import hugo from "hugo-extended";
import { spawn } from "child_process";
const binPath = await hugo();
console.log(binPath); // "/usr/local/bin/hugo" or similar
// Use with spawn, exec, or any process library
spawn(binPath, ["version"], { stdio: "inherit" });
```
### Type Imports
Import Hugo types for use in your own code:
```typescript
import type { HugoCommand, HugoOptionsFor, HugoServerOptions } from "hugo-extended";
// Type-safe option objects
const serverOpts: HugoServerOptions = {
port: 1313,
buildDrafts: true,
disableLiveReload: false,
};
// Generic helper
function runHugo<C extends HugoCommand>(cmd: C, opts: HugoOptionsFor<C>) {
// ...
}
```
```bash
$ npm run start
## API Reference
Start building sites …
hugo v0.88.1-5BC54738+extended darwin/amd64 BuildDate=2021-09-04T09:39:19Z VendorInfo=gohugoio
### `exec(command, options?)`
| EN
-------------------+------
Pages | 50
Paginator pages | 0
Non-page files | 138
Static files | 39
Processed images | 63
Aliases | 0
Sitemaps | 1
Cleaned | 0
Execute a Hugo command with inherited stdio (output goes to console).
Built in 2361 ms
Watching for changes in {archetypes,assets,content,data,layouts,package.json,static}
Watching for config changes in config.toml
Environment: "development"
Serving pages from memory
Web Server is available at http://localhost:1313/ (bind address 127.0.0.1)
- **command** — Hugo command string (e.g., `"server"`, `"build"`, `"mod clean"`)
- **options** — Type-safe options object (optional)
- **Returns** — `Promise<void>`
### `execWithOutput(command, options?)`
Execute a Hugo command and capture output.
- **command** — Hugo command string
- **options** — Type-safe options object (optional)
- **Returns** — `Promise<{ stdout: string; stderr: string }>`
### `hugo` (default export)
The default export is both callable (returns binary path) and has builder methods:
```typescript
// Get binary path (backward compatible)
const binPath = await hugo();
// Builder methods
await hugo.build({ minify: true });
await hugo.server({ port: 3000 });
```
### via API:
### Available Commands
```js
// version.js:
All Hugo commands are fully typed with autocomplete:
import hugo from "hugo-extended";
import { execFile } from "child_process";
| 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/) |
(async () => {
const binPath = await hugo();
## Platform Support
execFile(binPath, ["version"], (error, stdout) => {
console.log(stdout);
});
})();
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) |
## 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:
```sh
npm rebuild hugo-extended
```
```bash
$ node version.js
hugo v0.88.1-5BC54738+extended darwin/amd64 BuildDate=2021-09-04T09:39:19Z VendorInfo=gohugoio
```
### Permission issues on macOS
## Examples
- [jakejarvis/jarv.is](https://github.com/jakejarvis/jarv.is)
As of [v0.153.0](https://github.com/gohugoio/hugo/releases/tag/v0.153.0), Hugo is distributed as a full installer for macOS, rather than a simple binary/executable file. This package will make its best effort to run the installer for you (which includes prompting you for `sudo` access) but this method introduces infinitely more opportunities for things to go wrong. Please [open an issue](https://github.com/jakejarvis/hugo-extended/issues/new) if you encounter any issues.
## License
This project is distributed under the [MIT License](LICENSE.md). Hugo is distributed under the [Apache License 2.0](https://github.com/gohugoio/hugo/blob/master/LICENSE).
This project is distributed under the [MIT License](LICENSE). Hugo is distributed under the [Apache License 2.0](https://github.com/gohugoio/hugo/blob/master/LICENSE).
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/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"
}
}
}
}
-130
View File
@@ -1,130 +0,0 @@
import globals from "globals";
export default [
{
ignores: [
"vendor/**",
"*.d.ts",
],
},
{
languageOptions: {
ecmaVersion: 2020,
sourceType: "module",
globals: {
...globals.node,
...globals.es6,
},
},
rules: {
"brace-style": "error",
camelcase: ["error", {
properties: "never",
ignoreDestructuring: true,
}],
"comma-dangle": ["error", {
arrays: "always-multiline",
objects: "always-multiline",
imports: "always-multiline",
exports: "always-multiline",
functions: "never",
}],
"comma-spacing": "error",
"comma-style": "error",
curly: ["error", "multi-line"],
"func-call-spacing": "error",
"no-multiple-empty-lines": ["error", {
max: 1,
}],
"no-tabs": "error",
"no-trailing-spaces": "error",
"object-curly-spacing": ["error", "always"],
"one-var": ["error", {
var: "never",
let: "never",
const: "never",
}],
"operator-linebreak": ["error", "after", { overrides: { "?": "before", ":": "before" } }],
"padded-blocks": ["error", "never"],
"quote-props": ["error", "as-needed"],
quotes: ["error", "double", {
avoidEscape: true,
allowTemplateLiterals: true,
}],
semi: "error",
"semi-spacing": "error",
"space-before-blocks": "error",
"space-before-function-paren": ["error", {
named: "never",
anonymous: "always",
asyncArrow: "always",
}],
"spaced-comment": ["error", "always", {
line: {
markers: ["/"],
exceptions: ["-", "+"],
},
block: {
markers: ["!"],
exceptions: ["*"],
balanced: true,
},
}],
"template-tag-spacing": ["error", "never"],
"arrow-parens": ["error", "always"],
"arrow-spacing": ["error", {
before: true,
after: true,
}],
"no-confusing-arrow": ["error", {
allowParens: true,
}],
"no-var": "error",
"prefer-const": ["error", {
destructuring: "any",
ignoreReadBeforeAssign: true,
}],
"prefer-destructuring": ["error", {
VariableDeclarator: {
array: false,
object: true,
},
AssignmentExpression: {
array: true,
object: false,
},
}],
"prefer-rest-params": "error",
"prefer-spread": "error",
"template-curly-spacing": "error",
},
},
{
files: ["test/**/*.js"],
languageOptions: {
globals: {
...globals.mocha,
},
},
},
];
Vendored
-7
View File
@@ -1,7 +0,0 @@
/// <reference types="node" />
/**
* @returns A promise of the absolute path to the Hugo executable (`hugo.exe` on
* Windows, simply `hugo` otherwise) once it's installed.
*/
export default function hugo(): Promise<string>;
-22
View File
@@ -1,22 +0,0 @@
import logSymbols from "log-symbols";
import install from "./lib/install.js";
import { getBinPath, doesBinExist } from "./lib/utils.js";
const hugo = async () => {
const bin = getBinPath();
// 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.
console.info(`${logSymbols.info} Hugo is missing, reinstalling now...`);
await install();
}
return bin;
};
// The only thing this module really exports is the absolute path to Hugo:
export default hugo;
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env node
import { spawn } from "child_process";
import hugo from "../index.js";
(async () => {
const args = process.argv.slice(2);
const bin = await hugo();
spawn(bin, args, { stdio: "inherit" })
.on("exit", (code) => {
// forward Hugo's exit code so this module itself reports success/failure
process.exitCode = code;
});
})();
-144
View File
@@ -1,144 +0,0 @@
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
import { spawnSync } from "child_process";
import { pipeline } from "stream/promises";
import crypto from "crypto";
import * as tar from "tar";
import AdmZip from "adm-zip";
import logSymbols from "log-symbols";
import {
getPkgVersion,
getReleaseUrl,
getReleaseFilename,
getBinFilename,
getBinVersion,
getChecksumFilename,
isExtended,
} from "./utils.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function downloadFile(url, dest) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${url}: ${response.statusText}`);
}
await pipeline(response.body, fs.createWriteStream(dest));
}
async function verifyChecksum(filePath, checksumUrl, filename) {
const response = await fetch(checksumUrl);
if (!response.ok) {
throw new Error(`Failed to download checksums: ${response.statusText}`);
}
const checksums = await response.text();
// checksums file format: "sha256 filename"
const expectedChecksum = checksums
.split("\n")
.find((line) => line.endsWith(filename))
?.split(/\s+/)[0];
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}`);
}
}
async function install() {
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)) {
console.warn(`${logSymbols.info} Hugo Extended isn't supported on this platform, downloading vanilla Hugo instead.`);
}
// Prepare vendor directory
const vendorDir = path.join(__dirname, "..", "vendor");
if (!fs.existsSync(vendorDir)) {
fs.mkdirSync(vendorDir, { recursive: true });
}
const releaseUrl = getReleaseUrl(version, releaseFile);
const checksumUrl = getReleaseUrl(version, checksumFile);
const downloadPath = path.join(vendorDir, releaseFile);
console.info(`${logSymbols.info} Downloading ${releaseFile}...`);
await downloadFile(releaseUrl, downloadPath);
console.info(`${logSymbols.info} Verifying checksum...`);
await verifyChecksum(downloadPath, checksumUrl, releaseFile);
if (process.platform === "darwin") {
console.info(`${logSymbols.info} Installing ${releaseFile} (requires sudo)...`);
// Run MacOS installer
const result = spawnSync("sudo", ["installer", "-pkg", downloadPath, "-target", "/"], {
stdio: "inherit",
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`Installer failed with exit code ${result.status}`);
}
// Cleanup downloaded pkg
fs.unlinkSync(downloadPath);
} else {
console.info(`${logSymbols.info} Extracting...`);
if (releaseFile.endsWith(".zip")) {
const zip = new AdmZip(downloadPath);
zip.extractAllTo(vendorDir, true);
// Cleanup zip
fs.unlinkSync(downloadPath);
} else if (releaseFile.endsWith(".tar.gz")) {
await tar.x({
file: downloadPath,
cwd: vendorDir,
});
// Cleanup tar.gz
fs.unlinkSync(downloadPath);
}
const binPath = path.join(vendorDir, binFile);
if (fs.existsSync(binPath)) {
fs.chmodSync(binPath, 0o755);
}
}
console.info(`${logSymbols.success} Hugo installed successfully!`);
// Check version
if (process.platform === "darwin") {
console.info(getBinVersion("/usr/local/bin/hugo"));
return "/usr/local/bin/hugo";
} else {
const binPath = path.join(vendorDir, binFile);
console.info(getBinVersion(binPath));
return binPath;
}
} catch (error) {
console.error(`${logSymbols.error} Hugo installation failed. :(`);
throw error;
}
}
export default install;
-109
View File
@@ -1,109 +0,0 @@
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
import { execFileSync } from "child_process";
import { readPackageUpSync } from "read-package-up";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// This package's version number (should) always match the Hugo release we want.
// We check for a `hugoVersion` field in package.json just in case it doesn't
// match in the future (from pushing an emergency package update, etc.).
export function getPkgVersion() {
const { packageJson } = readPackageUpSync({ cwd: __dirname });
return packageJson.hugoVersion || packageJson.version;
}
// Generate the full GitHub URL to a given release file.
export function getReleaseUrl(version, filename) {
return `https://github.com/gohugoio/hugo/releases/download/v${version}/${filename}`;
}
// Binary is named `hugo.exe` on Windows, and simply `hugo` otherwise.
export function getBinFilename() {
return process.platform === "win32" ? "hugo.exe" : "hugo";
}
// Simple shortcut to ./vendor/hugo[.exe] from package root.
export function getBinPath() {
if (process.platform === "darwin") return "/usr/local/bin/hugo";
return path.join(
__dirname,
"..",
"vendor",
getBinFilename()
);
}
// Returns the output of the `hugo version` command, i.e.:
// "hugo v0.88.1-5BC54738+extended darwin/arm64 BuildDate=..."
export function getBinVersion(bin) {
const stdout = execFileSync(bin, ["version"]);
return stdout.toString().trim();
}
// Simply detect if the given file exists.
export function doesBinExist(bin) {
try {
if (fs.existsSync(bin)) {
return true;
}
} catch (error) {
// something bad happened besides Hugo not existing
if (error.code !== "ENOENT") {
throw error;
}
return false;
}
}
// Hugo Extended supports: macOS x64 / ARM64, Linux x64 / ARM64, Windows x64.
// All other combos fall back to vanilla Hugo. There are surely much better ways
// to do this but this is easy to read/update. :)
export function getReleaseFilename(version) {
const { platform, arch } = process;
const filename =
// macOS: as of 0.102.0, binaries are universal
platform === "darwin" && arch === "x64"
? `hugo_extended_${version}_darwin-universal.pkg`
: platform === "darwin" && arch === "arm64"
? `hugo_extended_${version}_darwin-universal.pkg`
// Windows
: platform === "win32" && arch === "x64"
? `hugo_extended_${version}_windows-amd64.zip`
: platform === "win32" && arch === "arm64"
? `hugo_${version}_windows-arm64.zip`
// Linux
: platform === "linux" && arch === "x64"
? `hugo_extended_${version}_linux-amd64.tar.gz`
: platform === "linux" && arch === "arm64"
? `hugo_extended_${version}_linux-arm64.tar.gz`
// FreeBSD
: platform === "freebsd" && arch === "x64"
? `hugo_${version}_freebsd-amd64.tar.gz`
// OpenBSD
: platform === "openbsd" && arch === "x64"
? `hugo_${version}_openbsd-amd64.tar.gz`
// not gonna work :(
: null;
return filename;
}
// Simple formula for the checksums.txt file.
export function getChecksumFilename(version) {
return `hugo_${version}_checksums.txt`;
}
// Check if Hugo extended is being downloaded (as opposed to plain Hugo) based on the release filename.
export function isExtended(releaseFile) {
return releaseFile.startsWith("hugo_extended_");
}
+2326 -2273
View File
File diff suppressed because it is too large Load Diff
+53 -27
View File
@@ -3,48 +3,74 @@
"version": "0.154.3",
"description": "✏️ Plug-and-play binary wrapper for Hugo Extended, the awesomest static-site generator.",
"license": "MIT",
"homepage": "https://github.com/jakejarvis/hugo-extended",
"repository": {
"type": "git",
"url": "git+https://github.com/jakejarvis/hugo-extended.git"
},
"bugs": {
"url": "https://github.com/jakejarvis/hugo-extended/issues"
},
"author": {
"name": "Jake Jarvis",
"email": "jake@jarv.is",
"url": "https://github.com/jakejarvis"
},
"repository": {
"type": "git",
"url": "git+https://github.com/jakejarvis/hugo-extended.git"
},
"publishConfig": {
"access": "public"
},
"files": [
"index.js",
"index.d.ts",
"postinstall.js",
"lib"
],
"bin": {
"hugo": "lib/cli.js",
"hugo-extended": "lib/cli.js"
},
"type": "module",
"exports": "./index.js",
"types": "./index.d.ts",
"main": "./dist/hugo.mjs",
"module": "./dist/hugo.mjs",
"types": "./dist/hugo.d.mts",
"bin": {
"hugo": "dist/cli.mjs",
"hugo-extended": "dist/cli.mjs"
},
"exports": {
".": {
"types": "./dist/hugo.d.mts",
"import": "./dist/hugo.mjs",
"default": "./dist/hugo.mjs"
},
"./types": {
"types": "./dist/generated/types.d.mts",
"default": "./dist/generated/types.mjs"
}
},
"files": [
"dist",
"postinstall.js"
],
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"generate-types": "tsx scripts/generate-types.ts",
"lint": "biome check",
"lint:fix": "biome check --write",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration",
"test:coverage": "vitest run --coverage",
"postinstall": "node postinstall.js",
"prepublishOnly": "npm run generate-types && npm run build"
},
"dependencies": {
"adm-zip": "^0.5.16",
"log-symbols": "^7.0.1",
"read-package-up": "^12.0.0",
"tar": "^7.5.2"
},
"devDependencies": {
"@biomejs/biome": "^2.3.11",
"@types/adm-zip": "^0.5.7",
"del": "^8.0.1",
"eslint": "^9.39.2",
"globals": "^16.5.0",
"mocha": "^11.7.5"
},
"scripts": {
"postinstall": "node postinstall.js",
"test": "eslint . && mocha"
"@types/node": "^25.0.3",
"@types/tar": "^6.1.13",
"@vitest/coverage-v8": "^4.0.16",
"tinyexec": "^1.0.2",
"tsdown": "^0.19.0-beta.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.16"
},
"engines": {
"node": ">=18.17"
Regular → Executable
+37 -3
View File
@@ -1,4 +1,38 @@
import install from "./lib/install.js";
#!/usr/bin/env node
// Install Hugo right off the bat.
(async () => await install())();
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.
*/
const __dirname = dirname(fileURLToPath(import.meta.url));
const installPath = join(__dirname, "dist", "lib", "install.mjs");
async function run() {
// 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();
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"]
}
+579
View File
@@ -0,0 +1,579 @@
import fs from "node:fs/promises";
import path from "node:path";
import { x } from "tinyexec";
import hugo from "../src/hugo";
const OUT_DIR = "src/generated";
const HUGO_TYPES_FILE = "types.ts";
const HUGO_FLAGS_JSON_FILE = "flags.json";
/**
* Normalized flag "kinds" that we map Hugo/Cobra type tokens into.
* These directly correspond to TypeScript types we emit.
*/
type FlagKind = "boolean" | "string" | "number" | "string[]" | "number[]";
/**
* Represents a single CLI flag as parsed from Hugo help output.
*/
type FlagSpec = {
/** Long flag name, including the leading `--` (e.g. `--baseURL`). */
long: string;
/** Optional short flag name, including the leading `-` (e.g. `-b`). */
short?: string;
/**
* Type token printed by Cobra/pflag (e.g. `string`, `int`, `strings`, `file`).
* Omitted for many boolean flags.
*/
typeToken?: string;
/** Derived TS-friendly kind for code generation + argv building. */
kind: FlagKind;
/** Human description (wrapped lines merged, defaults/enums stripped out). */
description: string;
/**
* Enum values inferred from patterns like `(debug|info|warn|error)` in the description.
* When present, we emit a string-literal union instead of a plain `string`.
*/
enum?: string[];
/**
* Default value parsed from a trailing `(default ...)` or `(default is ...)` suffix.
* Stored as raw text, since Hugo prints defaults in multiple formats.
*/
defaultRaw?: string;
};
/**
* Represents a Hugo commands parsed help metadata.
*/
type CommandSpec = {
/**
* Command tokens representing the "path" to the command.
* Examples: `["server"]`, `["mod","get"]`.
*/
pathTokens: string[];
/** Flags listed under the `Flags:` section (command-local). */
flags: FlagSpec[];
/** Flags listed under the `Global Flags:` section (persistent/global). */
globalFlags: FlagSpec[];
/** Subcommand names listed under the `Available Commands:` section. */
subcommands: string[];
};
/**
* Matches a single Hugo flag row in the `Flags:`/`Global Flags:` sections.
* Examples:
* - `-b, --baseURL string ...`
* - ` --cacheDir string ...`
* - `-D, --buildDrafts ...`
*/
const FLAG_LINE =
/^\s*(?:(?<short>-[A-Za-z]),\s*)?(?<long>--[A-Za-z0-9][A-Za-z0-9-]*)\s*(?:(?<type>[A-Za-z][A-Za-z0-9]*)\s+)?(?<desc>.+?)\s*$/;
/**
* Matches a wrapped continuation line for a flag description (indented; not starting with `-`/`--`).
*/
const CONTINUATION_LINE = /^\s{2,}(?<more>[^-\s].+?)\s*$/;
/**
* Matches section headers like `Flags:` / `Global Flags:` / `Available Commands:`.
*/
const SECTION_HEADER = /^(?<name>[A-Z][A-Za-z ]+):\s*$/;
/**
* Canonical type tokens emitted by Cobra/pflag in help output.
* If a captured "type" isn't in this list, it's actually part of the description (common for booleans).
*/
const KNOWN_TYPE_TOKENS = new Set([
"string",
"strings",
"int",
"int64",
"uint",
"uint64",
"float64",
"bool",
"boolean",
"file",
"duration",
"ints",
]);
/**
* Map Cobra/pflag type tokens to a small set of TS-friendly kinds.
*
* @param typeToken - Token printed in help output (e.g. `string`, `int`, `strings`, `file`).
* @returns A normalized {@link FlagKind} used in code generation.
*/
function mapTypeTokenToKind(typeToken?: string): FlagKind {
if (!typeToken) return "boolean";
switch (typeToken.toLowerCase()) {
case "bool":
case "boolean":
return "boolean";
case "string":
case "file":
case "duration":
return "string";
case "strings":
return "string[]";
case "int":
case "int64":
case "uint":
case "uint64":
case "float64":
return "number";
case "ints":
return "number[]";
default:
// Be conservative: Hugo occasionally prints tokens beyond the common set.
return "string";
}
}
/**
* Extract a trailing default from a help description.
*
* Supports:
* - `(default true)`
* - `(default "127.0.0.1")`
* - `(default is hugo.yaml|json|toml)`
* - `(default -1)`
*
* @param desc - Full description string from help output.
* @returns Cleaned description + raw default (if present).
*/
function extractDefault(desc: string): {
cleaned: string;
defaultRaw?: string;
} {
const re = /\s*\(default(?:\s+is)?\s+([^)]+)\)\s*$/i;
const m = re.exec(desc);
if (!m) return { cleaned: desc };
return {
cleaned: desc.slice(0, m.index).trimEnd(),
defaultRaw: m[1].trim(),
};
}
/**
* Extract a simple enum from a help description.
*
* Looks for a parenthesized `a|b|c` list, e.g.:
* - `log level (debug|info|warn|error)`
*
* @param desc - Full description string from help output.
* @returns Cleaned description + enum values (if confidently detected).
*/
function extractEnum(desc: string): { cleaned: string; enum?: string[] } {
const re = /\(([^()]*\|[^()]*)\)/;
const m = re.exec(desc);
if (!m) return { cleaned: desc };
const parts = m[1]
.split("|")
.map((s) => s.trim())
.filter(Boolean);
// Guard against false positives: only accept "simple" enum tokens.
if (parts.length < 2 || parts.some((p) => !/^[A-Za-z0-9._-]+$/.test(p))) {
return { cleaned: desc };
}
const cleaned = (desc.slice(0, m.index) + desc.slice(m.index + m[0].length))
.replace(/\s{2,}/g, " ")
.trim();
return { cleaned, enum: parts };
}
/**
* Parse a contiguous flag section (either `Flags:` or `Global Flags:`) from help output.
*
* @param lines - Entire help output split into lines.
* @param startIdx - Line index immediately after the section header.
* @returns Parsed flags, plus the index where parsing stopped.
*/
function parseFlagsFromSection(
lines: string[],
startIdx: number,
): { flags: FlagSpec[]; endIdx: number } {
const out: FlagSpec[] = [];
let last: FlagSpec | null = null;
for (let i = startIdx; i < lines.length; i++) {
const raw = lines[i].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 };
// If we hit another section header (e.g. `Global Flags:` after `Flags:`), stop.
const header = raw.trim().match(SECTION_HEADER);
if (header) return { flags: out, endIdx: i };
const m = raw.match(FLAG_LINE);
if (m?.groups?.long && m.groups.desc) {
const long = m.groups.long;
// Drop `--help` so it doesn't appear in generated option types.
if (long === "--help") {
last = null;
continue;
}
// Cobra's help formatting makes the type column optional. For boolean flags, the first
// word of the description can get mis-captured as a "type". Guard with a whitelist.
let typeToken: string | undefined = m.groups.type;
let desc = m.groups.desc.trim();
if (typeToken && !KNOWN_TYPE_TOKENS.has(typeToken.toLowerCase())) {
desc = `${typeToken} ${desc}`.trim();
typeToken = undefined;
}
// Pull defaults/enums out of the description while preserving raw values.
const def = extractDefault(desc);
desc = def.cleaned;
const en = extractEnum(desc);
desc = en.cleaned;
out.push({
long,
short: m.groups.short,
typeToken,
kind: mapTypeTokenToKind(typeToken),
description: desc,
defaultRaw: def.defaultRaw,
enum: en.enum,
});
last = out[out.length - 1];
continue;
}
// Merge wrapped description lines into the previous flag.
const c = raw.match(CONTINUATION_LINE);
if (c?.groups?.more && last) {
last.description = `${last.description} ${c.groups.more.trim()}`
.replace(/\s{2,}/g, " ")
.trim();
} else {
last = null;
}
}
return { flags: out, endIdx: lines.length };
}
/**
* Parse `Available Commands:` names from a help output.
*
* @param helpText - Full help output.
* @returns List of subcommand names (single token each).
*/
function parseAvailableCommands(helpText: string): string[] {
const lines = helpText.split(/\r?\n/);
const idx = lines.findIndex((l) => l.trim() === "Available Commands:");
if (idx === -1) return [];
const out: string[] = [];
for (let i = idx + 1; i < lines.length; i++) {
const raw = lines[i].trimEnd();
if (raw.trim() === "") continue;
// Stop on the next section header.
if (/^[A-Z][A-Za-z ]+:$/.test(raw.trim())) break;
// Typical format: " server Start the embedded web server"
const mm = raw.match(/^\s{2,}(?<name>[a-z0-9][a-z0-9-]*)\s{2,}.+$/i);
if (mm?.groups?.name) out.push(mm.groups.name);
}
return out;
}
/**
* Parse the relevant parts of a Hugo commands help output:
* - `Flags:`
* - `Global Flags:`
* - `Available Commands:`
*
* @param helpText - Full help output for a command.
* @param pathTokens - Command path tokens (e.g. `["server"]`, `["mod","get"]`, `["root"]`).
* @returns Parsed command metadata.
*/
function parseCommandHelp(helpText: string, pathTokens: string[]): CommandSpec {
const lines = helpText.split(/\r?\n/);
let flags: FlagSpec[] = [];
let globalFlags: FlagSpec[] = [];
const flagsHeaderIdx = lines.findIndex((l) => l.trim() === "Flags:");
if (flagsHeaderIdx !== -1) {
flags = parseFlagsFromSection(lines, flagsHeaderIdx + 1).flags;
}
const globalHeaderIdx = lines.findIndex((l) => l.trim() === "Global Flags:");
if (globalHeaderIdx !== -1) {
globalFlags = parseFlagsFromSection(lines, globalHeaderIdx + 1).flags;
}
const subcommands = parseAvailableCommands(helpText);
return { pathTokens, flags, globalFlags, subcommands };
}
/**
* Strip the leading `--` from a long flag name.
*
* @param long - Long flag name, e.g. `--baseURL`.
* @returns Name without the `--` prefix, e.g. `baseURL`.
*/
function normalizeLong(long: string) {
return long.startsWith("--") ? long.slice(2) : long;
}
/**
* Convert kebab-case to camelCase. If the name is already mixedCase (e.g. `baseURL`),
* it is returned as-is.
*
* @param name - Flag name without the `--` prefix.
* @returns JS/TS-friendly property name.
*/
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("")
);
}
/**
* Convert tokens to PascalCase (used to generate interface names).
*
* @param tokens - Command path tokens (e.g. `["mod","get"]`).
* @returns PascalCase string (e.g. `ModGet`).
*/
function pascal(tokens: string[]) {
return tokens.map((t) => (t ? t[0].toUpperCase() + t.slice(1) : "")).join("");
}
/**
* Convert a normalized {@link FlagKind} + optional enum into a TypeScript type string.
*
* @param kind - Normalized kind.
* @param en - Optional enum values inferred from description.
* @returns TypeScript type representation for emitted code.
*/
function kindToTs(kind: FlagKind, en?: string[]) {
if (en?.length) return en.map((v) => JSON.stringify(v)).join(" | ");
switch (kind) {
case "boolean":
return "boolean";
case "string":
return "string";
case "number":
return "number";
case "string[]":
return "string[]";
case "number[]":
return "number[]";
}
}
/**
* Deduplicate flags by their long name. First occurrence wins.
*
* @param flags - Flags to dedupe.
* @returns Deduped list.
*/
function dedupeByLong(flags: FlagSpec[]): FlagSpec[] {
const seen = new Map<string, FlagSpec>();
for (const f of flags) if (!seen.has(f.long)) seen.set(f.long, f);
return [...seen.values()];
}
/**
* Emit TypeScript interfaces and helper types for Hugo commands:
* - `HugoGlobalOptions`
* - `Hugo<CommandPath>Options` for each command
* - `HugoCommand` union and `HugoOptionsFor<>` conditional mapping
*
* @param globalFlags - Persistent/global flags (from `Global Flags:` sections).
* @param commands - Command metadata to emit (command-local flags).
* @returns TypeScript source as a single string.
*/
function emitInterfaces(globalFlags: FlagSpec[], commands: CommandSpec[]) {
const lines: string[] = [];
lines.push(`/* eslint-disable */`);
lines.push(`// AUTO-GENERATED. DO NOT EDIT.`);
lines.push("");
lines.push(`export interface HugoGlobalOptions {`);
for (const f of globalFlags.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})` : "";
lines.push(` /** ${f.description}${def} */`);
lines.push(` ${prop}?: ${tsType};`);
}
lines.push(`}`);
lines.push("");
for (const cmd of commands.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))) {
const prop = camelizeIfKebab(normalizeLong(f.long));
const tsType = kindToTs(f.kind, f.enum);
const def = f.defaultRaw ? ` (default ${f.defaultRaw})` : "";
lines.push(` /** ${f.description}${def} */`);
lines.push(` ${prop}?: ${tsType};`);
}
lines.push(`}`);
lines.push("");
}
const cmdStrings = commands.map((c) => c.pathTokens.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) {
const s = cmd.pathTokens.join(" ");
const name = `Hugo${pascal(cmd.pathTokens)}Options`;
lines.push(` C extends ${JSON.stringify(s)} ? ${name} :`);
}
lines.push(` never;`);
lines.push("");
return lines.join("\n");
}
/**
* Execute the Hugo binary with the provided args and return the help text.
*
* Hugo prints help to stdout in the cases we rely on.
*
* @param bin - Absolute path to the Hugo executable resolved by this package.
* @param args - CLI args to pass (e.g. `["server","--help"]`).
* @returns Help output (stdout).
*/
async function getHelp(bin: string, args: string[]) {
const out = await x(bin, args, { throwOnError: true });
return out.stdout;
}
/**
* Get help text for a command, using the appropriate method.
* Some commands (like `new`) redirect `--help` to a default subcommand,
* so we use `help <command>` instead to see the parent command structure.
*
* @param bin - Absolute path to the Hugo executable.
* @param tokens - Command path tokens (e.g. `["new"]`).
* @returns Help output showing subcommands if they exist.
*/
async function getCommandHelp(bin: string, tokens: string[]) {
if (tokens.length === 0) {
return getHelp(bin, ["--help"]);
}
// First try using `help <command>` to see if subcommands are listed
const helpArgs = ["help", ...tokens];
const helpOutput = await getHelp(bin, helpArgs);
// If we see "Available Commands:" in the output, use this version
if (helpOutput.includes("Available Commands:")) {
return helpOutput;
}
// Otherwise fall back to the standard `<command> --help`
const stdArgs = [...tokens, "--help"];
return getHelp(bin, stdArgs);
}
/**
* Main entry point: discovers the Hugo command tree, parses flags, and emits:
* - `src/types.ts` (types/interfaces)
* - `src/hugo.spec.json` (runtime spec for argv building)
*/
async function run() {
const bin = await hugo();
// BFS over command tree; `[]` means root.
const queue: string[][] = [[]];
const visited = new Set<string>();
const commandSpecs: CommandSpec[] = [];
const globalFlagsAll: FlagSpec[] = [];
while (queue.length) {
const tokens = queue.shift() ?? [];
const key = tokens.join(" ");
if (visited.has(key)) continue;
visited.add(key);
const helpText = await getCommandHelp(bin, tokens);
// Root is used only for discovery (naming convenience).
const pathTokens = tokens.length ? tokens : ["root"];
const spec = parseCommandHelp(helpText, pathTokens);
commandSpecs.push(spec);
globalFlagsAll.push(...spec.globalFlags);
for (const sub of spec.subcommands) {
queue.push(tokens.concat([sub]));
}
}
// Global options = union of flags found in `Global Flags:` sections.
const globalFlags = dedupeByLong(globalFlagsAll);
const globalLongSet = new Set(globalFlags.map((f) => f.long));
// Drop root and strip global flags from each commands local flags to avoid duplicates.
const cleanedCommands = commandSpecs
.filter((c) => c.pathTokens[0] !== "root")
.map((c) => ({
...c,
flags: c.flags.filter((f) => !globalLongSet.has(f.long)),
}));
const outDir = path.join(process.cwd(), OUT_DIR);
await fs.mkdir(outDir, { recursive: true });
if (HUGO_TYPES_FILE) {
await fs.writeFile(
path.join(outDir, HUGO_TYPES_FILE),
emitInterfaces(globalFlags, cleanedCommands),
"utf8",
);
console.log(`Wrote ${HUGO_TYPES_FILE}`);
}
if (HUGO_FLAGS_JSON_FILE) {
await fs.writeFile(
path.join(outDir, HUGO_FLAGS_JSON_FILE),
JSON.stringify(
{
globalFlags,
commands: cleanedCommands.map((c) => ({
command: c.pathTokens.join(" "),
flags: c.flags,
})),
},
null,
2,
),
"utf8",
);
console.log(`Wrote ${HUGO_FLAGS_JSON_FILE}`);
}
}
run().catch((err) => {
console.error(err);
process.exitCode = 1;
});
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import hugo from "./hugo";
// Handle unexpected promise rejections
process.on("unhandledRejection", (reason) => {
console.error("Unhandled promise rejection:", reason);
process.exitCode = 1;
});
(async () => {
try {
const args = process.argv.slice(2);
const bin = await hugo();
const child = spawn(bin, args, { stdio: "inherit" });
// Handle spawn errors (e.g., binary not found)
child.on("error", (err) => {
console.error("Failed to spawn Hugo binary:", err.message);
process.exitCode = 1;
});
// Forward Hugo's exit code so this module itself reports success/failure
child.on("exit", (code) => {
process.exitCode = code ?? undefined;
});
} catch (err) {
console.error(
"Failed to initialize Hugo:",
err instanceof Error ? err.message : err,
);
process.exitCode = 1;
}
})();
+363
View File
@@ -0,0 +1,363 @@
import { spawn } from "node:child_process";
import type { HugoCommand, HugoOptionsFor } from "./generated/types";
import { buildArgs } from "./lib/args";
import install from "./lib/install";
import { doesBinExist, getBinPath } from "./lib/utils";
/**
* Gets the path to the Hugo binary, automatically installing it if it's missing.
*
* 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.
*
* @returns A promise that resolves with the absolute path to the Hugo binary
* @throws {Error} If installation fails or the platform is unsupported
*
* @example
* ```typescript
* import hugo from 'hugo-extended';
*
* const hugoPath = await hugo();
* console.log(hugoPath); // "/usr/local/bin/hugo" or "./bin/hugo"
* ```
*/
export const getHugoBinary = async (): Promise<string> => {
const bin = getBinPath();
// 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.
console.info("⚠️ Hugo is missing, reinstalling now...");
await install();
}
return bin;
};
/**
* Execute a Hugo command with type-safe options.
*
* This function runs Hugo with the specified command and options, inheriting stdio
* so output goes directly to the console. It's perfect for interactive commands
* like `hugo server` or build commands where you want to see live output.
*
* @param command - Hugo command to execute (e.g., "server", "build", "mod clean")
* @param positionalArgsOrOptions - Either positional arguments array or options object
* @param options - Type-safe options object (if first param is positional args)
* @returns A promise that resolves when the command completes successfully
* @throws {Error} If the command fails or Hugo is not available
*
* @example
* ```typescript
* import { exec } from 'hugo-extended';
*
* // Start development server
* await exec("server", {
* port: 1313,
* buildDrafts: true,
* baseURL: "http://localhost:1313"
* });
*
* // Create a new site
* await exec("new site", ["my-site"], { format: "yaml" });
*
* // Build site for production
* await exec("build", {
* minify: true,
* cleanDestinationDir: true
* });
* ```
*/
export async function exec<C extends HugoCommand>(
command: C,
positionalArgsOrOptions?: string[] | HugoOptionsFor<C>,
options?: HugoOptionsFor<C>,
): Promise<void> {
const bin = await getHugoBinary();
// Handle overloaded parameters
let positionalArgs: string[] | undefined;
let opts: HugoOptionsFor<C> | undefined;
if (Array.isArray(positionalArgsOrOptions)) {
positionalArgs = positionalArgsOrOptions;
opts = options;
} else {
positionalArgs = undefined;
opts = positionalArgsOrOptions;
}
const args = buildArgs(
command,
positionalArgs,
opts as Record<string, unknown>,
);
return new Promise((resolve, reject) => {
const child = spawn(bin, args, { stdio: "inherit" });
child.on("exit", (code) => {
if (code === 0 || code === null) {
resolve();
} else {
reject(new Error(`Hugo command failed with exit code ${code}`));
}
});
child.on("error", (err) => {
reject(err);
});
});
}
/**
* Execute a Hugo command and capture its output.
*
* This function runs Hugo with the specified command and options, capturing
* stdout and stderr. It's useful for commands where you need to process the
* output programmatically, like `hugo version` or `hugo list all`.
*
* @param command - Hugo command to execute (e.g., "version", "list all")
* @param positionalArgsOrOptions - Either positional arguments array or options object
* @param options - Type-safe options object (if first param is positional args)
* @returns A promise that resolves with stdout and stderr strings
* @throws {Error} If the command fails or Hugo is not available
*
* @example
* ```typescript
* import { execWithOutput } from 'hugo-extended';
*
* // Get Hugo version
* const { stdout } = await execWithOutput("version");
* console.log(stdout); // "hugo v0.154.3+extended ..."
*
* // List all content
* const { stdout: content } = await execWithOutput("list all");
* const pages = content.split('\n');
* ```
*/
export async function execWithOutput<C extends HugoCommand>(
command: C,
positionalArgsOrOptions?: string[] | HugoOptionsFor<C>,
options?: HugoOptionsFor<C>,
): Promise<{ stdout: string; stderr: string }> {
const bin = await getHugoBinary();
// Handle overloaded parameters
let positionalArgs: string[] | undefined;
let opts: HugoOptionsFor<C> | undefined;
if (Array.isArray(positionalArgsOrOptions)) {
positionalArgs = positionalArgsOrOptions;
opts = options;
} else {
positionalArgs = undefined;
opts = positionalArgsOrOptions;
}
const args = buildArgs(
command,
positionalArgs,
opts as Record<string, unknown>,
);
return new Promise((resolve, reject) => {
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const child = spawn(bin, args);
if (child.stdout) {
child.stdout.on("data", (chunk: Buffer) => {
stdoutChunks.push(chunk);
});
}
if (child.stderr) {
child.stderr.on("data", (chunk: Buffer) => {
stderrChunks.push(chunk);
});
}
child.on("exit", (code) => {
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
const stderr = Buffer.concat(stderrChunks).toString("utf8");
if (code === 0 || code === null) {
resolve({ stdout, stderr });
} else {
reject(
new Error(
`Hugo command failed with exit code ${code}${stderr ? `\n${stderr}` : ""}`,
),
);
}
});
child.on("error", (err) => {
reject(err);
});
});
}
/**
* Builder-style API for executing Hugo commands.
*
* Provides a fluent interface where each Hugo command is a method on the
* builder object. All methods are type-safe with autocomplete for options.
*
* @example
* ```typescript
* import { hugo } from 'hugo-extended';
*
* // Start server
* await hugo.server({ port: 1313, buildDrafts: true });
*
* // Build site
* await hugo.build({ minify: true });
*
* // Module operations
* await hugo.mod.clean({ all: true });
* await hugo.mod.get();
* ```
*/
export const hugo = {
/** Build your site */
build: (options?: HugoOptionsFor<"build">) => exec("build", options),
/** Generate shell completion scripts */
completion: {
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),
},
/** Print Hugo configuration */
config: (options?: HugoOptionsFor<"config">) => exec("config", options),
/** 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),
},
/** Print Hugo environment info */
env: (options?: HugoOptionsFor<"env">) => exec("env", options),
/** Generate documentation */
gen: {
doc: (options?: HugoOptionsFor<"gen doc">) => exec("gen doc", options),
man: (options?: HugoOptionsFor<"gen man">) => exec("gen man", options),
},
/** Import your site from others */
import: {
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),
},
/** Module operations */
mod: {
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),
init: (options?: HugoOptionsFor<"mod init">) => exec("mod init", options),
npm: {
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),
},
/** Create new content */
new: Object.assign(
(
pathOrOptions?: string | HugoOptionsFor<"new">,
options?: HugoOptionsFor<"new">,
) => {
if (typeof pathOrOptions === "string") {
return exec("new", [pathOrOptions], options);
}
return exec("new", pathOrOptions);
},
{
content: (
pathOrOptions?: string | HugoOptionsFor<"new content">,
options?: HugoOptionsFor<"new content">,
) => {
if (typeof pathOrOptions === "string") {
return exec("new content", [pathOrOptions], options);
}
return exec("new content", pathOrOptions);
},
site: (
pathOrOptions?: string | HugoOptionsFor<"new site">,
options?: HugoOptionsFor<"new site">,
) => {
if (typeof pathOrOptions === "string") {
return exec("new site", [pathOrOptions], options);
}
return exec("new site", pathOrOptions);
},
theme: (
nameOrOptions?: string | HugoOptionsFor<"new theme">,
options?: HugoOptionsFor<"new theme">,
) => {
if (typeof nameOrOptions === "string") {
return exec("new theme", [nameOrOptions], options);
}
return exec("new theme", nameOrOptions);
},
},
),
/** Start the Hugo development server */
server: (options?: HugoOptionsFor<"server">) => exec("server", options),
/** Print the Hugo version */
version: (options?: HugoOptionsFor<"version">) => exec("version", options),
};
// Backward compatibility: default export still returns the binary path
const hugoCompat = getHugoBinary;
// Make the default export callable AND have builder properties
export default Object.assign(hugoCompat, hugo);
// Re-export types for convenience
export type { HugoCommand, HugoOptionsFor } from "./generated/types";
+215
View File
@@ -0,0 +1,215 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Flag specification loaded from the generated spec.json file.
*/
type FlagSpec = {
long: string;
short?: string;
typeToken?: string;
kind: "boolean" | "string" | "number" | "string[]" | "number[]";
description: string;
enum?: string[];
defaultRaw?: string;
};
/**
* Command specification with its local flags.
*/
type CommandSpec = {
command: string;
flags: FlagSpec[];
};
/**
* The complete spec loaded from spec.json.
*/
type HugoSpec = {
globalFlags: FlagSpec[];
commands: CommandSpec[];
};
let cachedSpec: HugoSpec | null = null;
/**
* Load the Hugo spec from the generated json file (cached after first load).
*
* @returns The parsed Hugo spec containing global flags and command-specific flags.
*/
function loadSpec(): HugoSpec {
if (cachedSpec) return cachedSpec;
const specPath = path.join(__dirname, "..", "generated", "flags.json");
try {
const specText = fs.readFileSync(specPath, "utf8");
cachedSpec = JSON.parse(specText) as HugoSpec;
} catch (error) {
throw new Error(
`Failed to load Hugo spec from ${specPath}. ` +
`Ensure the project is built (npm run build) before use.`,
{ cause: error },
);
}
return cachedSpec;
}
/**
* Convert a camelCase property name to kebab-case flag name.
*
* @param name - Property name in camelCase (e.g., "buildDrafts").
* @returns Kebab-case flag name (e.g., "build-drafts").
*
* @example
* camelToKebab("baseURL") // "base-u-r-l"
* camelToKebab("buildDrafts") // "build-drafts"
*/
function camelToKebab(name: string): string {
return name.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
}
/**
* Find a flag spec by its camelCase property name.
*
* @param flags - Array of flag specs to search.
* @param propName - Property name in camelCase.
* @returns The matching flag spec, or undefined if not found.
*/
function findFlag(flags: FlagSpec[], propName: string): FlagSpec | undefined {
const kebab = camelToKebab(propName);
return flags.find((f) => {
const flagName = f.long.startsWith("--") ? f.long.slice(2) : f.long;
return flagName === kebab || flagName === propName;
});
}
/**
* Build command-line arguments from a command and options object.
*
* This function:
* 1. Loads the Hugo spec to understand flag types
* 2. Converts camelCase property names to kebab-case flags
* 3. Formats values according to their types (boolean, string, number, arrays)
* 4. Returns an argv array ready to pass to child_process
*
* @param command - Hugo command string (e.g., "server", "build", "mod clean").
* @param positionalArgs - Optional array of positional arguments (e.g., paths, names).
* @param options - Options object with camelCase property names.
* @returns Array of command-line arguments.
*
* @example
* buildArgs("server", undefined, { port: 1313, buildDrafts: true })
* // Returns: ["server", "--port", "1313", "--build-drafts"]
*
* @example
* buildArgs("new site", ["my-site"], { format: "yaml" })
* // Returns: ["new", "site", "my-site", "--format", "yaml"]
*
* @example
* buildArgs("build", undefined, { theme: ["a", "b"], minify: true })
* // Returns: ["build", "--theme", "a", "--theme", "b", "--minify"]
*/
export function buildArgs(
command: string,
positionalArgs?: string[],
options?: Record<string, unknown>,
): string[] {
const spec = loadSpec();
const args: string[] = [];
// Add the command tokens (e.g., "mod clean" becomes ["mod", "clean"])
args.push(...command.split(" "));
// Add positional arguments after the command
if (positionalArgs && positionalArgs.length > 0) {
args.push(...positionalArgs);
}
// If no options, return command + positional args
if (!options || Object.keys(options).length === 0) {
return args;
}
// Find the command spec
const cmdSpec = spec.commands.find((c) => c.command === command);
// Combine global flags and command-specific flags
const allFlags = [...spec.globalFlags, ...(cmdSpec?.flags ?? [])];
// Process each option
for (const [key, value] of Object.entries(options)) {
// Skip undefined/null values
if (value === undefined || value === null) continue;
// Find the flag spec for this property
const flagSpec = findFlag(allFlags, key);
// If we don't have a spec, try to infer the format
const flagName = flagSpec
? flagSpec.long.startsWith("--")
? flagSpec.long
: `--${flagSpec.long}`
: `--${camelToKebab(key)}`;
const kind = flagSpec?.kind ?? inferKind(value);
// Format based on kind
switch (kind) {
case "boolean":
// Only add the flag if true
if (value === true) {
args.push(flagName);
}
break;
case "string":
args.push(flagName, String(value));
break;
case "number":
args.push(flagName, String(value));
break;
case "string[]":
// Repeat the flag for each array element
if (Array.isArray(value)) {
for (const item of value) {
args.push(flagName, String(item));
}
}
break;
case "number[]":
// Repeat the flag for each array element
if (Array.isArray(value)) {
for (const item of value) {
args.push(flagName, String(item));
}
}
break;
}
}
return args;
}
/**
* Infer the kind of a value when we don't have spec information.
*
* @param value - The value to inspect.
* @returns The inferred flag kind.
*/
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)) {
if (value.length > 0 && typeof value[0] === "number") return "number[]";
return "string[]";
}
return "string";
}
+202
View File
@@ -0,0 +1,202 @@
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
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 {
getBinFilename,
getBinVersion,
getChecksumFilename,
getPkgVersion,
getReleaseFilename,
getReleaseUrl,
isExtended,
} from "./utils";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* 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));
}
/**
* 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 checksums = await response.text();
// checksums file format: "sha256 filename"
const expectedChecksum = checksums
.split("\n")
.map((line) => line.trim().split(/\s+/))
.find((tokens) => tokens[tokens.length - 1] === filename)?.[0];
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
* - Verifies the integrity of the downloaded file using SHA-256 checksums
* - Extracts or installs the binary (platform-specific):
* - macOS: Uses `sudo installer` to install the .pkg file to /usr/local/bin
* - Windows/Linux: Extracts the .zip or .tar.gz archive to the local bin directory
* - Sets appropriate file permissions on Unix-like systems
* - Displays the installed Hugo version
*
* @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> {
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)) {
console.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);
console.info(`☁️ Downloading ${releaseFile}...`);
await downloadFile(releaseUrl, downloadPath);
console.info("🕵️ Verifying checksum...");
await verifyChecksum(downloadPath, checksumUrl, releaseFile);
if (process.platform === "darwin") {
console.info(`💾 Installing ${releaseFile} (requires sudo)...`);
// Run MacOS installer
const result = spawnSync(
"sudo",
["installer", "-pkg", downloadPath, "-target", "/"],
{
stdio: "inherit",
},
);
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`Installer failed with exit code ${result.status}`);
}
// Cleanup downloaded pkg
fs.unlinkSync(downloadPath);
// Create symlink in local bin dir for consistency with other platforms
const symlinkPath = path.join(binDir, binFile);
if (
fs.existsSync(symlinkPath) ||
fs.lstatSync(symlinkPath, { throwIfNoEntry: false })
) {
fs.unlinkSync(symlinkPath);
}
fs.symlinkSync("/usr/local/bin/hugo", symlinkPath);
} else {
console.info("📦 Extracting...");
if (releaseFile.endsWith(".zip")) {
const zip = new AdmZip(downloadPath);
zip.extractAllTo(binDir, true);
// Cleanup zip
fs.unlinkSync(downloadPath);
} else if (releaseFile.endsWith(".tar.gz")) {
await tar.x({
file: downloadPath,
cwd: binDir,
});
// Cleanup tar.gz
fs.unlinkSync(downloadPath);
}
const binPath = path.join(binDir, binFile);
if (fs.existsSync(binPath)) {
fs.chmodSync(binPath, 0o755);
}
}
console.info("🎉 Hugo installed successfully!");
// Check version and return path
const binPath = path.join(binDir, binFile);
console.info(getBinVersion(binPath));
return binPath;
} catch (error) {
console.error("⛔ Hugo installation failed. :(");
throw error;
}
}
export default install;
+168
View File
@@ -0,0 +1,168 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Gets the Hugo version to install from package.json.
*
* This package's version number (should) always match the Hugo release we want.
* We check for a `hugoVersion` field in package.json just in case it doesn't
* match in the future (from pushing an emergency package update, etc.).
*
* @throws {Error} If package.json cannot be found
* @returns The version string (e.g., "0.88.1")
*/
export function getPkgVersion(): string {
// Walk up from __dirname (dist/lib) to find package.json
const packageJsonPath = path.join(__dirname, "..", "..", "package.json");
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
return (
(packageJson as { hugoVersion?: string; version: string }).hugoVersion ||
packageJson.version
);
} catch {
throw new Error(
`Could not find or read package.json at ${packageJsonPath}`,
);
}
}
/**
* Generates the full GitHub URL to a Hugo release file.
*
* @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")
* @returns The complete download URL for the release file
*/
export function getReleaseUrl(version: string, filename: string): string {
return `https://github.com/gohugoio/hugo/releases/download/v${version}/${filename}`;
}
/**
* Gets the Hugo binary filename for the current platform.
*
* @returns "hugo.exe" on Windows, "hugo" on all other platforms
*/
export function getBinFilename(): string {
return process.platform === "win32" ? "hugo.exe" : "hugo";
}
/**
* Gets the absolute path to the installed Hugo binary.
*
* @returns The absolute path to hugo binary in the local bin directory.
* On macOS, this is a symlink to "/usr/local/bin/hugo".
*/
export function getBinPath(): string {
return path.join(__dirname, "..", "..", "bin", getBinFilename());
}
/**
* 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=...")
* @throws {Error} If the binary cannot be executed
*/
export function getBinVersion(bin: string): string {
const stdout = execFileSync(bin, ["version"]);
return stdout.toString().trim();
}
/**
* Checks if the Hugo binary exists at the specified path.
*
* @param bin - The absolute path to check for the Hugo binary
* @returns `true` if the file exists, `false` if it doesn't
* @throws {Error} If an unexpected error occurs (other than ENOENT)
*/
export function doesBinExist(bin: string): boolean {
try {
if (fs.existsSync(bin)) {
return true;
}
} catch (error: unknown) {
// something bad happened besides Hugo not existing
if (
error &&
typeof error === "object" &&
"code" in error &&
error.code !== "ENOENT"
) {
throw error;
}
return false;
}
return false;
}
/**
* 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.
*
* @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
*/
export function getReleaseFilename(version: string): string | null {
const { platform, arch } = process;
const filename =
// macOS: as of 0.102.0, binaries are universal
platform === "darwin" && arch === "x64"
? `hugo_extended_${version}_darwin-universal.pkg`
: platform === "darwin" && arch === "arm64"
? `hugo_extended_${version}_darwin-universal.pkg`
: // Windows
platform === "win32" && arch === "x64"
? `hugo_extended_${version}_windows-amd64.zip`
: platform === "win32" && arch === "arm64"
? `hugo_${version}_windows-arm64.zip`
: // Linux
platform === "linux" && arch === "x64"
? `hugo_extended_${version}_linux-amd64.tar.gz`
: platform === "linux" && arch === "arm64"
? `hugo_extended_${version}_linux-arm64.tar.gz`
: // FreeBSD
platform === "freebsd" && arch === "x64"
? `hugo_${version}_freebsd-amd64.tar.gz`
: // OpenBSD
platform === "openbsd" && arch === "x64"
? `hugo_${version}_openbsd-amd64.tar.gz`
: // not gonna work :(
null;
return filename;
}
/**
* 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")
*/
export function getChecksumFilename(version: string): string {
return `hugo_${version}_checksums.txt`;
}
/**
* 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")
* @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_");
}
-65
View File
@@ -1,65 +0,0 @@
/* global it, assert -- Globals defined by Mocha */
import path from "path";
import { execFile } from "child_process";
import assert from "assert";
import { deleteAsync } from "del";
import hugo from "../index.js";
import { getBinPath } from "../lib/utils.js";
it("Hugo exists and runs?", async function () {
this.timeout(30000); // increase timeout to an excessive 30 seconds for CI
const hugoPath = await hugo();
// Wrap execFile in a Promise to ensure it completes before the test finishes
await new Promise((resolve, reject) => {
execFile(hugoPath, ["env"], function (error, stdout) {
if (error) {
reject(error);
return;
}
console.log(stdout);
resolve();
});
});
});
it("Hugo doesn't exist, install it instead of throwing an error", async function () {
this.timeout(30000); // increase timeout to an excessive 30 seconds for CI
// On macOS, the binary is installed to /usr/local/bin, which we can't/shouldn't delete.
// We also can't easily test the reinstall flow without sudo.
if (process.platform === "darwin") {
console.log("Skipping reinstall test on macOS (requires sudo/system modification)");
this.skip();
return;
}
// delete binary to ensure it's auto-reinstalled
// Note: On Windows, we delete the specific file because deleting the directory often causes EPERM/locking issues
if (process.platform === "win32") {
// Retry logic for Windows EPERM issues
const maxRetries = 5;
for (let i = 0; i < maxRetries; i++) {
try {
await deleteAsync(getBinPath(), { force: true });
break;
} catch (err) {
if (i === maxRetries - 1) throw err;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
} else {
await deleteAsync(path.dirname(getBinPath()), { force: true });
}
const hugoPath = await hugo();
assert(execFile(hugoPath, ["version"], function (error, stdout) {
if (error) {
throw error;
}
console.log(stdout);
}));
});
+36
View File
@@ -0,0 +1,36 @@
import { beforeAll, describe, expect, it } from "vitest";
import hugo, { execWithOutput } from "../../src/hugo";
describe("Hugo Commands Integration", () => {
beforeAll(async () => {
// Ensure Hugo is installed
const bin = await hugo();
expect(bin).toBeTruthy();
});
describe("version command", () => {
it("should return Hugo version", async () => {
const { stdout } = await execWithOutput("version");
expect(stdout).toContain("hugo v");
});
});
describe("env command", () => {
it("should return environment info", async () => {
const { stdout } = await execWithOutput("env");
expect(stdout).toContain("GOOS");
expect(stdout).toContain("GOARCH");
});
});
describe("config command", () => {
it("should return default config when no config file exists", async () => {
// As of Hugo v0.154.x, the config command returns default configuration
// even when no config file exists, rather than throwing an error
const { stdout } = await execWithOutput("config");
expect(stdout).toContain("contentdir");
expect(stdout).toContain("publishdir");
expect(stdout).toContain("defaultcontentlanguage");
});
});
});
+135
View File
@@ -0,0 +1,135 @@
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", () => {
let tempDir: string;
beforeEach(async () => {
// Create temp directory for each test
tempDir = await mkdtemp(join(tmpdir(), "hugo-test-"));
});
afterEach(async () => {
// Cleanup
await rm(tempDir, { recursive: true, force: true });
});
describe("new.site", () => {
it("should create a new site with default format", async () => {
const sitePath = join(tempDir, "test-site");
await hugo.new.site(sitePath);
// Check that essential directories exist
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();
});
it("should create a new site with yaml format", async () => {
const sitePath = join(tempDir, "test-site-yaml");
await hugo.new.site(sitePath, { format: "yaml" });
await expect(
access(join(sitePath, "hugo.yaml")),
).resolves.toBeUndefined();
});
it("should create a new site with json format", async () => {
const sitePath = join(tempDir, "test-site-json");
await hugo.new.site(sitePath, { format: "json" });
await expect(
access(join(sitePath, "hugo.json")),
).resolves.toBeUndefined();
});
it("should respect force flag", async () => {
const sitePath = join(tempDir, "test-site-force");
// Create site first time
await hugo.new.site(sitePath);
// Hugo 0.154.x does not overwrite an existing site config file, even with --force.
// Future versions may change this behavior, so test accepts either outcome.
try {
await hugo.new.site(sitePath, { force: true });
// If it succeeds, verify the site still exists
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();
}
});
});
describe("new.theme", () => {
it("should create a new theme", async () => {
const sitePath = join(tempDir, "test-site");
await hugo.new.site(sitePath);
// Use --source instead of process.chdir (not supported in worker threads)
await hugo.new.theme("test-theme", { source: sitePath });
const themePath = join(sitePath, "themes", "test-theme");
await expect(access(themePath)).resolves.toBeUndefined();
await expect(
access(join(themePath, "hugo.toml")),
).resolves.toBeUndefined();
});
it("should create theme with yaml format", async () => {
const sitePath = join(tempDir, "test-site");
await hugo.new.site(sitePath);
await hugo.new.theme("test-theme-yaml", {
format: "yaml",
source: sitePath,
});
const themePath = join(sitePath, "themes", "test-theme-yaml");
await expect(
access(join(themePath, "hugo.yaml")),
).resolves.toBeUndefined();
});
});
describe("new.content", () => {
it("should create new content file", async () => {
const sitePath = join(tempDir, "test-site");
await hugo.new.site(sitePath);
await hugo.new.content("posts/my-post.md", { source: sitePath });
const postPath = join(sitePath, "content", "posts", "my-post.md");
await expect(access(postPath)).resolves.toBeUndefined();
});
it("should create content with custom kind", async () => {
const sitePath = join(tempDir, "test-site");
await hugo.new.site(sitePath);
await hugo.new.content("pages/about.md", {
kind: "page",
source: sitePath,
});
const pagePath = join(sitePath, "content", "pages", "about.md");
await expect(access(pagePath)).resolves.toBeUndefined();
});
});
describe("backwards compatibility", () => {
it("should work without positional arguments", async () => {
// Commands that don't require positional args should still work
await expect(hugo.version()).resolves.not.toThrow();
await expect(hugo.env()).resolves.not.toThrow();
});
});
});
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import { buildArgs } from "../../src/lib/args";
describe("buildArgs", () => {
describe("basic command", () => {
it("should handle command without options", () => {
const args = buildArgs("build");
expect(args).toEqual(["build"]);
});
it("should handle multi-word command", () => {
const args = buildArgs("mod clean");
expect(args).toEqual(["mod", "clean"]);
});
});
describe("positional arguments", () => {
it("should add positional arguments after command", () => {
const args = buildArgs("new site", ["my-site"]);
expect(args).toEqual(["new", "site", "my-site"]);
});
it("should handle multiple positional arguments", () => {
const args = buildArgs("command", ["arg1", "arg2", "arg3"]);
expect(args).toEqual(["command", "arg1", "arg2", "arg3"]);
});
it("should handle positional args with options", () => {
const args = buildArgs("new site", ["my-site"], { format: "yaml" });
expect(args).toEqual(["new", "site", "my-site", "--format", "yaml"]);
});
});
describe("boolean flags", () => {
it("should add flag when true", () => {
const args = buildArgs("build", undefined, { minify: true });
expect(args).toContain("--minify");
});
it("should not add flag when false", () => {
const args = buildArgs("build", undefined, { minify: false });
expect(args).not.toContain("--minify");
});
it("should handle multiple boolean flags", () => {
const args = buildArgs("build", undefined, {
minify: true,
buildDrafts: true,
cleanDestinationDir: false,
});
expect(args).toContain("--minify");
// Flags with an explicit spec keep Hugo's canonical casing.
expect(args).toContain("--buildDrafts");
expect(args).not.toContain("--cleanDestinationDir");
});
});
describe("string flags", () => {
it("should add flag with value", () => {
const args = buildArgs("server", undefined, { port: 1313 });
expect(args).toContain("--port");
expect(args).toContain("1313");
});
it("should keep spec flag casing (e.g. baseURL)", () => {
const args = buildArgs("server", undefined, {
baseURL: "http://localhost",
});
expect(args).toContain("--baseURL");
expect(args).toContain("http://localhost");
});
it("should convert unknown camelCase to kebab-case", () => {
const args = buildArgs("build", undefined, {
someUnknownFlag: "value",
});
expect(args).toContain("--some-unknown-flag");
expect(args).toContain("value");
});
});
describe("array flags", () => {
it("should repeat flag for each array element", () => {
const args = buildArgs("build", undefined, { theme: ["a", "b", "c"] });
const themeIndices = args.reduce<number[]>((acc, arg, i) => {
if (arg === "--theme") acc.push(i);
return acc;
}, []);
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");
});
});
describe("complex scenarios", () => {
it("should handle mixed flags and positional args", () => {
const args = buildArgs("new content", ["posts/my-post.md"], {
kind: "post",
force: true,
editor: "vim",
});
expect(args).toEqual([
"new",
"content",
"posts/my-post.md",
"--kind",
"post",
"--force",
"--editor",
"vim",
]);
});
it("should skip undefined and null values", () => {
const args = buildArgs("build", undefined, {
minify: true,
baseURL: undefined,
destination: null,
});
expect(args).toContain("--minify");
expect(args).not.toContain("--base-u-r-l");
expect(args).not.toContain("--destination");
});
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expectTypeOf, it } from "vitest";
import type { HugoCommand, HugoOptionsFor } from "../../src/generated/types";
describe("Type Safety", () => {
it("should have correct command types", () => {
// Check that specific commands are valid HugoCommand values
expectTypeOf<"build">().toExtend<HugoCommand>();
expectTypeOf<"server">().toExtend<HugoCommand>();
expectTypeOf<"new site">().toExtend<HugoCommand>();
expectTypeOf<"new theme">().toExtend<HugoCommand>();
expectTypeOf<"new content">().toExtend<HugoCommand>();
});
it("should map commands to correct option types", () => {
type BuildOpts = HugoOptionsFor<"build">;
type ServerOpts = HugoOptionsFor<"server">;
type NewSiteOpts = HugoOptionsFor<"new site">;
type NewThemeOpts = HugoOptionsFor<"new theme">;
// Build options should have minify
expectTypeOf<BuildOpts>().toHaveProperty("minify");
// Server options should have port
expectTypeOf<ServerOpts>().toHaveProperty("port");
// New site options should have format
expectTypeOf<NewSiteOpts>().toHaveProperty("format");
// New theme options should have format
expectTypeOf<NewThemeOpts>().toHaveProperty("format");
});
it("should inherit global options", () => {
type BuildOpts = HugoOptionsFor<"build">;
// All commands should have global options
expectTypeOf<BuildOpts>().toHaveProperty("source");
expectTypeOf<BuildOpts>().toHaveProperty("destination");
expectTypeOf<BuildOpts>().toHaveProperty("environment");
});
});
+27
View File
@@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"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,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/**/*.ts"],
outDir: "dist",
clean: true,
dts: {
resolve: true,
},
unbundle: true,
copy: ["src/generated"],
});
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// Run tests in Node environment
environment: "node",
// Test file patterns
include: ["tests/**/*.test.ts"],
// Coverage configuration
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.ts"],
exclude: [
"src/generated/**",
"src/**/*.d.ts",
"**/*.test.ts",
"scripts/**",
],
},
// Timeout for integration tests (some may be slow)
testTimeout: 30000,
// Vitest 4: avoid forks on macOS (can produce kill EPERM on teardown) and
// keep files sequential to reduce contention around Hugo + temp dirs.
pool: "threads",
fileParallelism: false,
},
});