Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/attested-release-archives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Publish GitHub build provenance attestations for the release archives so installs can be cryptographically verified.
5 changes: 5 additions & 0 deletions .changeset/document-mise-install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Document installing Hunk with mise, and note that Hunk ships as a default Omarchy tool.
5 changes: 5 additions & 0 deletions .changeset/mise-update-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Recognize mise-managed installs and skip the startup update notice for them, since mise already keeps Hunk up to date.
13 changes: 13 additions & 0 deletions .github/workflows/release-prebuilt-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,10 @@ jobs:
if: github.event_name == 'push'
permissions:
contents: write
# Required by actions/attest-build-provenance: `id-token` mints the Sigstore
# OIDC token and `attestations` writes the bundle to GitHub's attestation store.
id-token: write
attestations: write
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -275,6 +279,15 @@ jobs:
done < <(find dist/release/artifacts -mindepth 1 -maxdepth 1 -type d -name 'hunkdiff-*' -print0 | sort -z)
find dist/release/github -maxdepth 1 -type f | sort

# Attest the archives before they are uploaded so the provenance covers
# exactly the bytes published as release assets. The subject glob is kept
# identical to the upload glob below so nothing can ship unattested.
# mise/aqua verifies these through GitHub Artifact Attestations on install.
- name: Attest release archives
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: dist/release/github/*

- name: Create or update GitHub release
env:
GH_TOKEN: ${{ github.token }}
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ CLI input
- Do not rely blindly on autogenerated GitHub release notes. After publishing, verify the release body and edit it if needed.
- Prefer `gh release create/edit --notes-file` for multi-line release notes so the exact body is reviewed before posting.
- After publishing, verify npm packages and GitHub release assets point at the new version. For Homebrew, Hunk is distributed through `Homebrew/homebrew-core`; do not open manual simple version-bump PRs yourself. Let Homebrew Autobump create the `hunk <version>` PR, then verify it merges and `brew install hunk` resolves to the new version. Only use `brew bump-formula-pr hunk --version <version>` if Homebrew maintainers request a manual bump or Autobump stalls unexpectedly.
- For mise, verify with `MISE_AQUA_BAKED_REGISTRY=false mise latest hunk`. mise resolves Hunk through the community `aqua:modem-dev/hunk` entry, bakes an aqua registry snapshot into each of its own releases (`aqua.baked_registry`, default true), and caches downloaded registry sources for a week (`aqua.registry_cache_ttl`), so a default check can report stale registry data unrelated to our release; `mise cache clear` forces a refresh.
- For patch releases and backports, list only changes actually present between the previous tag and the new tag on that release branch.
- Prefer concise, user-visible entries over internal refactors unless the refactor changes user-visible behavior.
- Keep each changeset summary to one concise user-facing sentence; put implementation detail in the PR or supporting docs.
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,22 @@ brew install hunk
> [!NOTE]
> If you previously installed hunk via `modem-dev/tap`, be sure to uninstall it first with `brew uninstall modem-dev/tap/hunk`.

Or with [mise](https://mise.jdx.dev) (macOS and Linux):

```bash
mise use -g hunk
```

Requirements:

- Node.js 18+
- macOS, Linux, or Windows
- Node.js 18+ for the npm install; Homebrew, mise, and Nix ship a standalone binary
- Git recommended for most workflows

> Nix users can use the `default` package exported in `flake.nix` instead. See [nix/README.md](./nix/README.md) for details.

> Hunk also ships as a default tool in [Omarchy](https://omarchy.org), installed through mise.

## Quick start

```bash
Expand Down
79 changes: 79 additions & 0 deletions src/core/updateNotice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,85 @@ describe("startup update notice", () => {
});
});

test("suppresses update notices for mise-managed installs", async () => {
await withTempStatePath(async (statePath) => {
await expect(
resolveStartupUpdateNotice({
env: { HUNK_INSTALL_SOURCE: "mise" },
fetchImpl: async () => {
throw new Error("should not fetch for mise installs");
},
resolveInstalledVersion: () => "0.7.0",
statePath,
}),
).resolves.toBeNull();
});
});

test("detects unmarked mise installs from their mise install path", async () => {
await withTempStatePath(async (statePath) => {
await expect(
resolveStartupUpdateNotice({
env: {},
fetchImpl: async () => {
throw new Error("should not fetch for mise installs");
},
resolveExecutablePath: () =>
"/home/user/.local/share/mise/installs/aqua-modem-dev-hunk/0.7.0/hunk",
resolveInstalledVersion: () => "0.7.0",
statePath,
}),
).resolves.toBeNull();
});
});

test("detects unmarked mise installs from Windows-style install paths", async () => {
await withTempStatePath(async (statePath) => {
await expect(
resolveStartupUpdateNotice({
env: {},
fetchImpl: async () => {
throw new Error("should not fetch for mise installs");
},
resolveExecutablePath: () =>
"C:\\Users\\user\\AppData\\Local\\mise\\installs\\aqua-modem-dev-hunk\\0.7.0\\hunk.exe",
resolveInstalledVersion: () => "0.7.0",
statePath,
}),
).resolves.toBeNull();
});
});

test("never surfaces beta or latest notices for a resolved mise install source", async () => {
await withTempStatePath(async (statePath) => {
await expect(
resolveStartupUpdateNotice({
fetchImpl: async () => createDistTagsResponse({ latest: "0.7.0", beta: "0.8.0-beta.1" }),
resolveInstalledVersion: () => "0.7.0",
resolveInstallSource: () => "mise",
statePath,
}),
).resolves.toBeNull();
});
});

test("keeps npm notices for paths that only mention mise outside an install directory", async () => {
await withTempStatePath(async (statePath) => {
await expect(
resolveStartupUpdateNotice({
env: {},
fetchImpl: async () => createDistTagsResponse({ latest: "0.7.1" }),
resolveExecutablePath: () => "/home/mise/projects/hunk/node_modules/.bin/hunk",
resolveInstalledVersion: () => "0.7.0",
statePath,
}),
).resolves.toEqual({
key: "latest:0.7.1",
message: "Update available: 0.7.1 (latest) • npm i -g hunkdiff",
});
});
});

test("returns null when already up to date", async () => {
await withTempStatePath(async (statePath) => {
await expect(
Expand Down
63 changes: 58 additions & 5 deletions src/core/updateNotice.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { posix, win32 } from "node:path";
import { readHunkStateRecord, updateHunkStateRecord } from "./hunkState";
import { resolveHunkStatePath } from "./paths";
import type { StartupNotice } from "./startupNotice";
Expand All @@ -17,7 +18,17 @@ interface PersistedStartupState {
}

export type UpdateChannel = "latest" | "beta";
export type InstallSource = "npm" | "homebrew" | "nix";
export type InstallSource = "npm" | "homebrew" | "nix" | "mise";

/**
* Install sources that upgrade Hunk on their own, so Hunk never surfaces an update notice for them.
*
* mise owns its tool versions: omarchy's `hunk` wrapper runs `mise use -g aqua:modem-dev/hunk`
* before exec'ing the binary, so the newest release is already installed by the time this session
* starts. A notice there would ask the user to fix something mise just fixed, so suppress rather
* than swap in a mise-flavored update command.
*/
const SELF_UPDATING_INSTALL_SOURCES: readonly InstallSource[] = ["mise"];

type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;

Expand Down Expand Up @@ -68,20 +79,56 @@ function isNewerVersion(current: string, candidate: string) {
}
}

/** Split one filesystem path into segments, tolerating either platform's separator. */
function splitPathSegments(candidatePath: string) {
return candidatePath
.split(win32.sep)
.flatMap((segment) => segment.split(posix.sep))
.filter((segment) => segment.length > 0);
}

/**
* Return whether this executable lives inside a mise-managed install directory.
*
* mise lays every backend out as `<data dir>/mise/installs/<tool>/<version>/<bin>` on all
* platforms, so the adjacent `mise/installs` segments are the one signal that survives `mise x`
* (the omarchy wrapper's launch path, which sets none of mise's shell env vars) as well as shims
* and activated shells.
*/
function isMiseManagedExecutablePath(executablePath: string) {
const segments = splitPathSegments(executablePath);
return segments.some(
(segment, index) => segment === "mise" && segments[index + 1] === "installs",
);
}

/** Resolve which package manager installed this binary, defaulting to the npm package path. */
function resolveInstallSourceFromRuntime(
env: NodeJS.ProcessEnv = process.env,
executablePath = process.execPath,
): InstallSource {
const installSource = env[INSTALL_SOURCE_ENV];
if (installSource === "homebrew" || installSource === "nix") {
if (installSource === "homebrew" || installSource === "nix" || installSource === "mise") {
return installSource;
}

return executablePath.startsWith("/nix/store/") ? "nix" : "npm";
if (executablePath.startsWith("/nix/store/")) {
return "nix";
}

return isMiseManagedExecutablePath(executablePath) ? "mise" : "npm";
}

/** Return whether the install source manages its own upgrades and needs no update notice. */
function managesOwnUpdates(installSource: InstallSource) {
return SELF_UPDATING_INSTALL_SOURCES.includes(installSource);
}

/** Build the install-aware update instruction shown for one release channel. */
/**
* Build the install-aware update instruction shown for one release channel.
*
* Self-updating sources never reach here; they are filtered out before the dist-tag lookup.
*/
function updateInstructionForChannel(channel: UpdateChannel, installSource: InstallSource) {
if (installSource === "homebrew") {
return "brew update && brew upgrade hunk";
Expand Down Expand Up @@ -259,6 +306,12 @@ export async function resolveStartupUpdateNotice(
const resolveInstallSource =
deps.resolveInstallSource ??
(() => resolveInstallSourceFromRuntime(env, deps.resolveExecutablePath?.()));
const installSource = resolveInstallSource();
// Resolved before fetching so self-updating installs skip the dist-tag request entirely.
if (managesOwnUpdates(installSource)) {
return null;
}

const { signal, dispose } = createFetchTimeoutSignal(fetchTimeoutMs);

try {
Expand All @@ -268,7 +321,7 @@ export async function resolveStartupUpdateNotice(
}

const parsedPayload = parseDistTags(await response.json());
return selectUpdateNotice(resolveInstalledVersion(), parsedPayload, resolveInstallSource());
return selectUpdateNotice(resolveInstalledVersion(), parsedPayload, installSource);
} catch {
return null;
} finally {
Expand Down
17 changes: 14 additions & 3 deletions website/src/content/docs/docs/start/install.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
title: Install
description: Install Hunk with npm, Homebrew, or Nix and verify the CLI.
description: Install Hunk with npm, Homebrew, mise, or Nix and verify the CLI.
---

Hunk runs on macOS, Linux, and Windows. npm installs require Node.js 18 or newer; Homebrew and Nix installs are self-contained binaries. Git is recommended for the most common review workflows.
Hunk runs on macOS, Linux, and Windows. npm installs require Node.js 18 or newer; Homebrew, mise, and Nix installs are self-contained binaries. Git is recommended for the most common review workflows.

## npm

Expand All @@ -30,6 +30,17 @@ brew uninstall modem-dev/tap/hunk
brew install hunk
```

## mise

[mise](https://mise.jdx.dev) knows Hunk by the short name `hunk` (alias `hunkdiff`) and installs the prebuilt binary on macOS and Linux:

```bash
mise use -g hunk
hunk --version
```

Hunk also ships as a default tool in [Omarchy](https://omarchy.org), which installs it through mise.

## Nix

The repository exports a `default` package from `flake.nix`. From a clone of Hunk:
Expand All @@ -47,6 +58,6 @@ See the repository's `nix/README.md` for Home Manager and development-shell deta
hunk --help
```

You should see `Usage: hunk <command> [options]`. If the shell cannot find Hunk, ensure your global npm or Homebrew binary directory is on `PATH`, then open a new shell.
You should see `Usage: hunk <command> [options]`. If the shell cannot find Hunk, ensure your global npm, Homebrew, or mise binary directory is on `PATH`, then open a new shell.

Next, [review your first working tree](/docs/start/quick-start/).
1 change: 1 addition & 0 deletions website/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ const formattedStars =
<ul>
<li><span class="t">npm</span><code>npm i -g hunkdiff</code></li>
<li><span class="t">Homebrew</span><code>brew install hunk</code></li>
<li><span class="t">mise</span><code>mise use -g hunk</code></li>
<li><span class="t">Nix</span><code>nix run github:modem-dev/hunk</code></li>
</ul>
<p class="req">
Expand Down
Loading