Skip to content
Open
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/fresh-updates-observe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Route curl-install release checks through a privacy-preserving, cached Hunk endpoint with direct GitHub fallback and analytics opt-out controls.
84 changes: 84 additions & 0 deletions .github/workflows/release-proxy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: Release proxy

on:
pull_request:
paths:
- .github/workflows/release-proxy.yml
- workers/release-proxy/**
push:
branches:
- main
paths:
- .github/workflows/release-proxy.yml
- workers/release-proxy/**
workflow_dispatch:

concurrency:
group: release-proxy-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
check:
name: Check Worker
runs-on: ubuntu-latest
defaults:
run:
working-directory: workers/release-proxy
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14

- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm
cache-dependency-path: workers/release-proxy/package-lock.json

- name: Install dependencies
run: npm ci

- name: Test
run: npm test

- name: Typecheck
run: npm run typecheck

- name: Verify deployment bundle
run: npx wrangler deploy --dry-run

deploy:
name: Deploy Worker
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
needs: check
runs-on: ubuntu-latest
environment: release-proxy
defaults:
run:
working-directory: workers/release-proxy
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm
cache-dependency-path: workers/release-proxy/package-lock.json

- name: Install dependencies
run: npm ci

- name: Deploy
run: npm run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
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:
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; set `HUNK_DISABLE_ANALYTICS=1` or `DO_NOT_TRACK=1` to bypass it:

```bash
curl -fsSL https://hunk.dev/install.sh | sh
Expand Down
50 changes: 45 additions & 5 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
# 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_DISABLE_ANALYTICS
# set to 1 to resolve releases directly from GitHub
# DO_NOT_TRACK set to 1 to resolve releases directly from GitHub
#
# macOS and Linux only. On Windows, install with `npm install -g hunkdiff`.
#
Expand All @@ -30,6 +33,7 @@
set -eu

REPO="modem-dev/hunk"
RELEASE_PROXY="https://updates.hunk.dev/v1/curl/latest"
RELEASES_API="https://api.github.com/repos/${REPO}/releases/latest"
DOWNLOAD_BASE="https://github.com/${REPO}/releases/download"

Expand Down Expand Up @@ -71,6 +75,9 @@ Environment:
HUNK_NO_MODIFY_PATH set to 1 for --no-modify-path
HUNK_ALLOW_CONFLICTING_INSTALLS
set to 1 for --force
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

macOS and Linux only. On Windows, install with `npm install -g hunkdiff`.
EOF
Expand Down Expand Up @@ -128,12 +135,32 @@ download() {
fi
}

# Print one URL's body, returning non-zero when the server refuses it.
# Print one metadata URL's body with one bounded attempt.
fetch() {
if [ "$downloader" = "curl" ]; then
curl -fsSL "$1"
curl -fsSL --max-time 5 "$1"
else
wget -q -O - "$1"
wget -q -t 1 -T 5 -O - "$1"
fi
}

# Resolve through Hunk's observable release endpoint without sending an installation identifier.
# This attempt is bounded so a stalled proxy yields promptly to the direct GitHub fallback.
fetch_release_proxy() {
current_header=""
[ -n "${1:-}" ] && current_header="X-Hunk-Current-Version: $1"
if [ "$downloader" = "curl" ]; then
if [ -n "$current_header" ]; then
curl -fsSL --max-time 5 -H "X-Hunk-Request-Source: install" -H "$current_header" "$RELEASE_PROXY"
else
curl -fsSL --max-time 5 -H "X-Hunk-Request-Source: install" "$RELEASE_PROXY"
fi
else
if [ -n "$current_header" ]; then
wget -q -t 1 -T 5 --header="X-Hunk-Request-Source: install" --header="$current_header" -O - "$RELEASE_PROXY"
else
wget -q -t 1 -T 5 --header="X-Hunk-Request-Source: install" -O - "$RELEASE_PROXY"
fi
fi
}

Expand Down Expand Up @@ -400,9 +427,22 @@ main() {

if [ -z "$version" ]; then
info "Resolving the newest Hunk release..."
release_current=""
if [ -n "${HUNK_INSTALL_DIR:-}" ]; then
release_current="$(installed_version "${HUNK_INSTALL_DIR%/}/hunk")"
elif [ -n "${HOME:-}" ]; then
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.
version="$(fetch "$RELEASES_API" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' | head -n 1)"
[ -n "$version" ] || fail "Could not resolve the newest Hunk release from ${RELEASES_API}."
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=""
fi
if [ -z "$version" ]; then
version="$(fetch "$RELEASES_API" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' | head -n 1)"
fi
[ -n "$version" ] || fail "Could not resolve the newest Hunk release from Hunk or ${RELEASES_API}."
fi

home_dir="${HOME:-}"
Expand Down
85 changes: 85 additions & 0 deletions scripts/install-sh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,58 @@ function runConflictCheck(
}
}

/** Run default-version resolution against a stub downloader and an already-current target. */
function runReleaseResolution(options: { proxyFails?: boolean; disableAnalytics?: boolean } = {}) {
const root = mkdtempSync(join(tmpdir(), "hunk-install-release-"));
const home = join(root, "home");
const targetDir = join(home, ".hunk", "bin");
const toolsDir = join(root, "tools");
const curlLog = join(root, "curl.log");
mkdirSync(targetDir, { recursive: true });
mkdirSync(toolsDir, { recursive: true });
writeFakeHunk(join(targetDir, "hunk"), "1.2.3");
const curlPath = join(toolsDir, "curl");
writeFileSync(
curlPath,
[
"#!/bin/sh",
'for argument in "$@"; do url="$argument"; done',
'printf "%s\\n" "$url" >>"$CURL_LOG"',
'case "$url" in',
' https://updates.hunk.dev/*) [ "${PROXY_FAILS:-0}" = "1" ] && exit 22; printf \'%s\\n\' \'{"version":"1.2.3"}\' ;;',
" https://api.github.com/*) printf '%s\\n' '{\"tag_name\":\"v1.2.3\"}' ;;",
" *) exit 22 ;;",
"esac",
"",
].join("\n"),
);
chmodSync(curlPath, 0o755);

try {
const result = Bun.spawnSync(["sh", INSTALL_SCRIPT_PATH, "--no-modify-path"], {
env: {
...process.env,
HOME: home,
PATH: [toolsDir, targetDir, "/usr/bin", "/bin"].join(":"),
CURL_LOG: curlLog,
PROXY_FAILS: options.proxyFails ? "1" : "0",
HUNK_DISABLE_ANALYTICS: options.disableAnalytics ? "1" : undefined,
},
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
return {
exitCode: result.exitCode,
stdout: Buffer.from(result.stdout).toString("utf8"),
stderr: Buffer.from(result.stderr).toString("utf8"),
requests: readFileSync(curlLog, "utf8").trim().split("\n"),
};
} finally {
rmSync(root, { recursive: true, force: true });
}
}

/**
* Run the installer's platform detection with a stubbed `uname` and print `<os> <arch>`.
*
Expand Down Expand Up @@ -193,6 +245,39 @@ describe("hunk.dev install script", () => {
expect(INSTALL_SCRIPT).toContain("https://github.com/${REPO}/releases/download");
});

test.skipIf(process.platform === "win32")(
"resolves through Hunk and falls back directly to GitHub",
() => {
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({ proxyFails: true });
expect(fallback.exitCode).toBe(0);
expect(fallback.requests).toEqual([
"https://updates.hunk.dev/v1/curl/latest",
"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({ disableAnalytics: true });
expect(result.exitCode).toBe(0);
expect(result.requests).toEqual([
"https://api.github.com/repos/modem-dev/hunk/releases/latest",
]);
},
);

test("sends only bounded release-check headers to Hunk's endpoint", () => {
expect(INSTALL_SCRIPT).toContain('"X-Hunk-Request-Source: install"');
expect(INSTALL_SCRIPT).toContain('current_header="X-Hunk-Current-Version: $1"');
});

test("installs beside the bundled skills so skill resolution still finds them", () => {
// `resolveBundledSkillPath` walks up from the binary looking for `skills/<name>/SKILL.md`,
// so the payload directory must be the binary's directory or one of its ancestors.
Expand Down
65 changes: 57 additions & 8 deletions src/core/install/latestRelease.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,32 +38,81 @@ describe("release channel lookups", () => {
expect(requested).toEqual(["https://formulae.brew.sh/api/formula/hunk.json"]);
});

test("reads the newest GitHub release tag for curl installer installs", async () => {
test("reads curl release metadata through the first-party endpoint", async () => {
const requested: string[] = [];
const accepts: unknown[] = [];
const headers: Headers[] = [];

await expect(
fetchChannelVersions("curl", {
env: {},
requestSource: "startup",
currentVersion: "1.3.0",
fetchImpl: async (input, init) => {
requested.push(String(input));
accepts.push(new Headers(init?.headers).get("accept"));
return jsonResponse({ tag_name: "v1.4.0" });
headers.push(new Headers(init?.headers));
return jsonResponse({ version: "1.4.0" });
},
}),
).resolves.toEqual({ latest: "1.4.0" });
expect(requested).toEqual(["https://api.github.com/repos/modem-dev/hunk/releases/latest"]);
expect(accepts).toEqual(["application/vnd.github+json"]);
expect(requested).toEqual(["https://updates.hunk.dev/v1/curl/latest"]);
expect(headers[0]?.get("x-hunk-request-source")).toBe("startup");
expect(headers[0]?.get("x-hunk-current-version")).toBe("1.3.0");
});

test("drops a GitHub release tag that is not a stable version", async () => {
test("falls back to GitHub when the first-party endpoint fails or is invalid", async () => {
for (const proxyResponse of [jsonResponse({}, 503), jsonResponse({ version: "invalid" })]) {
const requested: string[] = [];
const accepts: Array<string | null> = [];
await expect(
fetchChannelVersions("curl", {
env: {},
fetchImpl: async (input, init) => {
requested.push(String(input));
accepts.push(new Headers(init?.headers).get("accept"));
return requested.length === 1
? proxyResponse.clone()
: jsonResponse({ tag_name: "v1.4.0" });
},
}),
).resolves.toEqual({ latest: "1.4.0" });
expect(requested).toEqual([
"https://updates.hunk.dev/v1/curl/latest",
"https://api.github.com/repos/modem-dev/hunk/releases/latest",
]);
expect(accepts).toEqual([null, "application/vnd.github+json"]);
}
});

test("bypasses first-party analytics when either opt-out is set", async () => {
for (const env of [{ HUNK_DISABLE_ANALYTICS: "1" }, { DO_NOT_TRACK: "1" }]) {
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("drops curl release metadata that is not a stable version", async () => {
await expect(
fetchChannelVersions("curl", {
fetchImpl: async () => jsonResponse({ tag_name: "v1.4.0-beta.1" }),
env: {},
fetchImpl: async (input) =>
String(input).includes("updates.hunk.dev")
? jsonResponse({ version: "1.4.0-beta.1" })
: jsonResponse({ tag_name: "v1.4.0-beta.1" }),
}),
).resolves.toEqual({ latest: undefined });

await expect(
fetchChannelVersions("curl", {
env: {},
fetchImpl: async () => jsonResponse({ name: "1.4.0" }),
}),
).resolves.toEqual({ latest: undefined });
Expand Down
Loading
Loading