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/calm-updates-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Route curl-install release checks through globally refreshed metadata by default while retaining direct GitHub fallback and analytics opt-outs.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built

## Install

The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise. Release discovery stays direct to GitHub while Hunk's anonymous aggregate endpoint is evaluated; set `HUNK_ENABLE_RELEASE_PROXY=1` to opt into testing it:
The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise. Release discovery uses Hunk's anonymous aggregate endpoint with direct GitHub fallback:

```bash
curl -fsSL https://hunk.dev/install.sh | sh
Expand Down
6 changes: 1 addition & 5 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@
# HUNK_NO_MODIFY_PATH set to 1 to leave shell startup files alone
# HUNK_ALLOW_CONFLICTING_INSTALLS
# set to 1 to install alongside another Hunk
# HUNK_ENABLE_RELEASE_PROXY
# set to 1 to test Hunk's aggregate release endpoint
# HUNK_DISABLE_ANALYTICS
# set to 1 to resolve releases directly from GitHub
# DO_NOT_TRACK set to 1 to resolve releases directly from GitHub
Expand Down Expand Up @@ -77,8 +75,6 @@ Environment:
HUNK_NO_MODIFY_PATH set to 1 for --no-modify-path
HUNK_ALLOW_CONFLICTING_INSTALLS
set to 1 for --force
HUNK_ENABLE_RELEASE_PROXY
set to 1 to test Hunk's aggregate release endpoint
HUNK_DISABLE_ANALYTICS
set to 1 to bypass Hunk's aggregate release endpoint
DO_NOT_TRACK set to 1 to bypass Hunk's aggregate release endpoint
Expand Down Expand Up @@ -438,7 +434,7 @@ main() {
release_current="$(installed_version "${HOME}/.hunk/bin/hunk")"
fi
# Parsed with sed rather than jq so the installer needs nothing but a shell and a downloader.
if [ "${HUNK_ENABLE_RELEASE_PROXY:-0}" = "1" ] && [ "${HUNK_DISABLE_ANALYTICS:-0}" != "1" ] && [ "${DO_NOT_TRACK:-0}" != "1" ]; then
if [ "${HUNK_DISABLE_ANALYTICS:-0}" != "1" ] && [ "${DO_NOT_TRACK:-0}" != "1" ]; then
proxy_payload="$(fetch_release_proxy "$release_current" 2>/dev/null)" || proxy_payload=""
version="$(printf '%s\n' "$proxy_payload" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)"
printf '%s\n' "$version" | grep -q '^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$' || version=""
Expand Down
2 changes: 1 addition & 1 deletion packages/hunk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built

## Install

The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise. Release discovery stays direct to GitHub while Hunk's anonymous aggregate endpoint is evaluated; set `HUNK_ENABLE_RELEASE_PROXY=1` to opt into testing it:
The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise. Release discovery uses Hunk's anonymous aggregate endpoint with direct GitHub fallback:

```bash
curl -fsSL https://hunk.dev/install.sh | sh
Expand Down
27 changes: 5 additions & 22 deletions packages/hunk/src/core/install/latestRelease.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ describe("release channel lookups", () => {
expect(requested).toEqual(["https://formulae.brew.sh/api/formula/hunk.json"]);
});

test("reads curl release metadata through the first-party endpoint", async () => {
test("reads curl release metadata through the first-party endpoint by default", async () => {
const requested: string[] = [];
const headers: Headers[] = [];

await expect(
fetchChannelVersions("curl", {
env: { HUNK_ENABLE_RELEASE_PROXY: "1" },
env: {},
requestSource: "startup",
currentVersion: "1.3.0",
fetchImpl: async (input, init) => {
Expand All @@ -65,7 +65,7 @@ describe("release channel lookups", () => {
const accepts: Array<string | null> = [];
await expect(
fetchChannelVersions("curl", {
env: { HUNK_ENABLE_RELEASE_PROXY: "1" },
env: {},
fetchImpl: async (input, init) => {
requested.push(String(input));
accepts.push(new Headers(init?.headers).get("accept"));
Expand All @@ -83,25 +83,8 @@ describe("release channel lookups", () => {
}
});

test("uses GitHub directly unless first-party release testing is enabled", async () => {
const requested: string[] = [];
await expect(
fetchChannelVersions("curl", {
env: {},
fetchImpl: async (input) => {
requested.push(String(input));
return jsonResponse({ tag_name: "v1.4.0" });
},
}),
).resolves.toEqual({ latest: "1.4.0" });
expect(requested).toEqual(["https://api.github.com/repos/modem-dev/hunk/releases/latest"]);
});

test("bypasses first-party analytics when either opt-out is set", async () => {
for (const env of [
{ HUNK_ENABLE_RELEASE_PROXY: "1", HUNK_DISABLE_ANALYTICS: "1" },
{ HUNK_ENABLE_RELEASE_PROXY: "1", DO_NOT_TRACK: "1" },
]) {
for (const env of [{ HUNK_DISABLE_ANALYTICS: "1" }, { DO_NOT_TRACK: "1" }]) {
const requested: string[] = [];
await expect(
fetchChannelVersions("curl", {
Expand All @@ -119,7 +102,7 @@ describe("release channel lookups", () => {
test("drops curl release metadata that is not a stable version", async () => {
await expect(
fetchChannelVersions("curl", {
env: { HUNK_ENABLE_RELEASE_PROXY: "1" },
env: {},
fetchImpl: async (input) =>
String(input).includes("updates.hunk.dev")
? jsonResponse({ version: "1.4.0-beta.1" })
Expand Down
15 changes: 5 additions & 10 deletions packages/hunk/src/core/install/latestRelease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ const HOMEBREW_FORMULA_URL = "https://formulae.brew.sh/api/formula/hunk.json";
const HUNK_CURL_RELEASE_URL = "https://updates.hunk.dev/v1/curl/latest";
const GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/modem-dev/hunk/releases/latest";
const DEFAULT_RELEASE_FETCH_TIMEOUT_MS = 5_000;
const ENABLE_RELEASE_PROXY_ENV = "HUNK_ENABLE_RELEASE_PROXY";
const DISABLE_ANALYTICS_ENV = "HUNK_DISABLE_ANALYTICS";
const DO_NOT_TRACK_ENV = "DO_NOT_TRACK";

Expand Down Expand Up @@ -125,13 +124,9 @@ export async function fetchHomebrewChannelVersions(
return { latest: stable && isStableVersion(stable) ? stable : undefined };
}

/** Return whether this process explicitly opts into the first-party release proxy. */
/** Return whether this process permits the first-party release proxy. */
function releaseProxyEnabled(env: NodeJS.ProcessEnv | undefined) {
return (
env?.[ENABLE_RELEASE_PROXY_ENV] === "1" &&
env[DISABLE_ANALYTICS_ENV] !== "1" &&
env[DO_NOT_TRACK_ENV] !== "1"
);
return env?.[DISABLE_ANALYTICS_ENV] !== "1" && env?.[DO_NOT_TRACK_ENV] !== "1";
}

/** Build bounded headers for the first-party curl release endpoint. */
Expand All @@ -149,9 +144,9 @@ function curlReleaseHeaders(deps: ReleaseLookupDeps) {
/**
* Fetch the stable release published for curl installs.
*
* The opt-in first-party endpoint supplies aggregate release-check observability and normalized
* metadata while it is evaluated before general rollout. Every other client and every endpoint
* failure uses GitHub directly so the proxy can never make update discovery less reliable.
* The first-party endpoint supplies aggregate release-check observability and normalized metadata.
* Analytics opt-outs and every endpoint failure use GitHub directly so the proxy cannot make update
* discovery less reliable.
*/
export async function fetchCurlChannelVersions(
deps: ReleaseLookupDeps = {},
Expand Down
4 changes: 1 addition & 3 deletions packages/hunk/src/core/install/selfUpdate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,6 @@ describe("hunk update", () => {
env: {
PATH: "/usr/bin",
HOME: "/home/reviewer",
HUNK_ENABLE_RELEASE_PROXY: "1",
},
});

Expand All @@ -219,7 +218,6 @@ describe("hunk update", () => {
{
PATH: "/usr/bin",
HOME: "/home/reviewer",
HUNK_ENABLE_RELEASE_PROXY: "1",
HUNK_VERSION: "1.1.0",
},
]);
Expand All @@ -244,7 +242,7 @@ describe("hunk update", () => {
const result = await runUpdate({
installSource: "curl",
input: { check: true },
env: { HUNK_ENABLE_RELEASE_PROXY: "1" },
env: {},
});

expect(result.exitCode).toBe(0);
Expand Down
4 changes: 2 additions & 2 deletions packages/hunk/src/core/process/updateNotice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,14 @@ describe("startup update notice", () => {
});
});

test("reads the GitHub releases API for curl installer installs", async () => {
test("reads the first-party release endpoint for curl installer installs", async () => {
await withTempStatePath(async (statePath) => {
const requested: string[] = [];
const headers: Headers[] = [];

await expect(
resolveStartupUpdateNotice({
env: { HUNK_ENABLE_RELEASE_PROXY: "1" },
env: {},
fetchImpl: async (input, init) => {
requested.push(String(input));
headers.push(new Headers(init?.headers));
Expand Down
31 changes: 11 additions & 20 deletions scripts/packaging/install-sh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ function runConflictCheck(

/** Run default-version resolution against a stub downloader and an already-current target. */
function runReleaseResolution(
options: { enableProxy?: boolean; proxyFails?: boolean; disableAnalytics?: boolean } = {},
options: { proxyFails?: boolean; disableAnalytics?: boolean; doNotTrack?: boolean } = {},
) {
const root = mkdtempSync(join(tmpdir(), "hunk-install-release-"));
const home = join(root, "home");
Expand Down Expand Up @@ -165,8 +165,8 @@ function runReleaseResolution(
PATH: [toolsDir, targetDir, "/usr/bin", "/bin"].join(":"),
CURL_LOG: curlLog,
PROXY_FAILS: options.proxyFails ? "1" : "0",
HUNK_ENABLE_RELEASE_PROXY: options.enableProxy ? "1" : undefined,
HUNK_DISABLE_ANALYTICS: options.disableAnalytics ? "1" : undefined,
DO_NOT_TRACK: options.doNotTrack ? "1" : undefined,
},
stdin: "ignore",
stdout: "pipe",
Expand Down Expand Up @@ -251,12 +251,12 @@ describe("hunk.dev install script", () => {
test.skipIf(process.platform === "win32")(
"resolves through Hunk and falls back directly to GitHub",
() => {
const proxied = runReleaseResolution({ enableProxy: true });
const proxied = runReleaseResolution();
expect(proxied.exitCode).toBe(0);
expect(proxied.requests).toEqual(["https://updates.hunk.dev/v1/curl/latest"]);
expect(proxied.stdout).toContain("hunk 1.2.3 is already installed.");

const fallback = runReleaseResolution({ enableProxy: true, proxyFails: true });
const fallback = runReleaseResolution({ proxyFails: true });
expect(fallback.exitCode).toBe(0);
expect(fallback.requests).toEqual([
"https://updates.hunk.dev/v1/curl/latest",
Expand All @@ -265,25 +265,16 @@ describe("hunk.dev install script", () => {
},
);

test.skipIf(process.platform === "win32")(
"uses GitHub directly unless proxy testing is enabled",
() => {
const result = runReleaseResolution();
expect(result.exitCode).toBe(0);
expect(result.requests).toEqual([
"https://api.github.com/repos/modem-dev/hunk/releases/latest",
]);
},
);

test.skipIf(process.platform === "win32")(
"bypasses Hunk release analytics when opted out",
() => {
const result = runReleaseResolution({ enableProxy: true, disableAnalytics: true });
expect(result.exitCode).toBe(0);
expect(result.requests).toEqual([
"https://api.github.com/repos/modem-dev/hunk/releases/latest",
]);
for (const options of [{ disableAnalytics: true }, { doNotTrack: true }]) {
const result = runReleaseResolution(options);
expect(result.exitCode).toBe(0);
expect(result.requests).toEqual([
"https://api.github.com/repos/modem-dev/hunk/releases/latest",
]);
}
},
);

Expand Down
5 changes: 2 additions & 3 deletions website/src/content/docs/docs/start/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ The script accepts these settings:
| `HUNK_INSTALL_DIR` | Install the binary into this directory instead of `~/.hunk/bin`. |
| `--no-modify-path` (or `HUNK_NO_MODIFY_PATH=1`) | Leave shell startup files alone. |
| `--force` (or `HUNK_ALLOW_CONFLICTING_INSTALLS=1`) | Install despite another Hunk on PATH or in a known version-manager directory. |
| `HUNK_ENABLE_RELEASE_PROXY=1` | Test Hunk's anonymous aggregate release endpoint instead of resolving directly from GitHub. |
| `HUNK_DISABLE_ANALYTICS=1` or `DO_NOT_TRACK=1` | Keep release discovery direct to GitHub even when proxy testing is enabled. |
| `HUNK_DISABLE_ANALYTICS=1` or `DO_NOT_TRACK=1` | Keep release discovery direct to GitHub instead of using Hunk's aggregate release endpoint. |

By default, the installer refuses to create a second Hunk installation. It lists every competing
path it finds, its version and PATH precedence, and the command that removes it. Remove those
Expand All @@ -40,7 +39,7 @@ curl -fsSL https://hunk.dev/install.sh | HUNK_VERSION=0.19.0 sh

On Hunk 0.20 and newer, `hunk update` refreshes a default install in place. An install redirected with `HUNK_INSTALL_DIR` cannot be auto-detected later (the variable is gone once your shell exits), so update one of those by re-running the script with the same `HUNK_INSTALL_DIR`; the installer prints a reminder at the end of a custom-directory install.

Release discovery remains direct to GitHub by default while Hunk's cached endpoint is evaluated. Set `HUNK_ENABLE_RELEASE_PROXY=1` to route default install-script release resolution, automatic startup update checks, `hunk update --check`, and `hunk update` through it for curl-managed installs. Automatic checks normally run at most once every four hours for each user profile; simultaneous processes can produce an occasional duplicate, while explicit update commands and installer runs remain immediate. The endpoint records aggregate request source and current-version fields, but Hunk sends no installation ID, repository, hostname, cookie, or request body. `HUNK_DISABLE_ANALYTICS=1` and `DO_NOT_TRACK=1` override the testing flag, and every endpoint failure falls back directly to GitHub.
Release discovery for curl-managed installs routes default install-script resolution, automatic startup update checks, `hunk update --check`, and `hunk update` through Hunk's release endpoint. Automatic checks normally run at most once every four hours for each user profile; simultaneous processes can produce an occasional duplicate, while explicit update commands and installer runs remain immediate. The endpoint records aggregate request source and current-version fields, but Hunk sends no installation ID, repository, hostname, cookie, or request body. `HUNK_DISABLE_ANALYTICS=1` and `DO_NOT_TRACK=1` keep release discovery direct to GitHub, and every endpoint failure falls back directly to GitHub.

Windows is not covered by the script; use npm or mise there.

Expand Down
41 changes: 27 additions & 14 deletions workers/release-proxy/README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
# Hunk release proxy

This stateless Cloudflare Worker serves `GET /v1/curl/latest`. It caches and normalizes GitHub's
latest stable Hunk release to:
This Cloudflare Worker serves `GET /v1/curl/latest`. It normalizes GitHub's latest stable Hunk
release to:

```json
{ "version": "0.20.1" }
```

A cron trigger refreshes that version in Workers KV every minute. Requests read KV instead of
GitHub, so release-check traffic cannot consume GitHub's API quota. Failed refreshes preserve the
last valid version, while metadata older than six hours returns an endpoint error so Hunk can use
its direct-GitHub fallback. Unchanged metadata writes a fresh heartbeat at most once per hour.

The Worker writes one structured `release_check` log containing only allowlisted `source` and
`currentVersion` values. Client responses use `Cache-Control: no-store` so Cloudflare's outer cache
cannot bypass the Worker and its per-request log; the Worker Cache API still keeps the normalized
GitHub response for five minutes. It does not use D1, cookies, request bodies, or installation
identifiers. Cloudflare's infrastructure may provide its own request metadata subject to the
account's log and retention configuration.
`currentVersion` values. Scheduled attempts write a bounded `release_refresh` result. Client
responses use `Cache-Control: no-store` so Cloudflare's outer cache cannot bypass the Worker and its
per-request log. It does not use cookies, request bodies, or installation identifiers. Cloudflare's
infrastructure may provide its own request metadata subject to the account's log and retention
configuration.

## Development

Expand All @@ -23,12 +28,21 @@ npm run typecheck
npm run dev
```

`wrangler deploy` publishes the Worker to the configured `updates.hunk.dev` custom domain. The
`release-proxy.yml` workflow checks pull requests and `main` without receiving production
credentials; deployment stays a manual operation from a trusted maintainer machine:
## Deployment

The committed `RELEASE_METADATA` binding points at the production KV namespace. Before the first
deployment, add a read-only GitHub credential as a Worker-scoped secret:

```sh
npx wrangler secret put GITHUB_TOKEN
```

`wrangler deploy` publishes the Worker and its every-minute cron trigger to the configured
`updates.hunk.dev` custom domain. The `release-proxy.yml` workflow checks pull requests and `main`
without receiving production credentials; deployment stays a manual operation from a trusted
maintainer machine:

```sh
npx wrangler login
npm ci
npm test
npm run typecheck
Expand All @@ -37,6 +51,5 @@ curl -fsS https://updates.hunk.dev/v1/curl/latest
```

The client and installer fall back directly to GitHub, so their rollout does not depend on
deployment ordering. Verify the endpoint and its bounded structured logs after each deployment. No
GitHub token is required for the initial anonymous upstream request; if one is added later, store it
as a Worker secret and never in Hunk.
deployment ordering. Verify the endpoint and its bounded structured logs after each deployment.
Keep the GitHub credential in the Worker secret, never in Hunk or Wrangler configuration.
Loading
Loading