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

Resolve releases from `hunk.dev/api/release/latest` so macOS no longer blocks pasting the curl install command as malware.
6 changes: 5 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@
set -eu

REPO="modem-dev/hunk"
RELEASE_PROXY="https://updates.hunk.dev/v1/curl/latest"
# Release discovery stays on the apex domain. macOS 26.4+ executes browser-pasted Terminal commands
# in an XProtect sandbox and blocks the paste when the script contacts a host Apple's website
# reputation service distrusts; the Worker's own hostname tripped that, so Vercel proxies this path
# to it instead (see vercel.json).
RELEASE_PROXY="https://hunk.dev/api/release/latest"
RELEASES_API="https://api.github.com/repos/${REPO}/releases/latest"
DOWNLOAD_BASE="https://github.com/${REPO}/releases/download"

Expand Down
6 changes: 3 additions & 3 deletions packages/hunk/src/core/install/latestRelease.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe("release channel lookups", () => {
},
}),
).resolves.toEqual({ latest: "1.4.0" });
expect(requested).toEqual(["https://updates.hunk.dev/v1/curl/latest"]);
expect(requested).toEqual(["https://hunk.dev/api/release/latest"]);
expect(headers[0]?.get("x-hunk-request-source")).toBe("startup");
expect(headers[0]?.get("x-hunk-current-version")).toBe("1.3.0");
});
Expand All @@ -76,7 +76,7 @@ describe("release channel lookups", () => {
}),
).resolves.toEqual({ latest: "1.4.0" });
expect(requested).toEqual([
"https://updates.hunk.dev/v1/curl/latest",
"https://hunk.dev/api/release/latest",
"https://api.github.com/repos/modem-dev/hunk/releases/latest",
]);
expect(accepts).toEqual([null, "application/vnd.github+json"]);
Expand Down Expand Up @@ -104,7 +104,7 @@ describe("release channel lookups", () => {
fetchChannelVersions("curl", {
env: {},
fetchImpl: async (input) =>
String(input).includes("updates.hunk.dev")
String(input).includes("hunk.dev/api/release")
? jsonResponse({ version: "1.4.0-beta.1" })
: jsonResponse({ tag_name: "v1.4.0-beta.1" }),
}),
Expand Down
4 changes: 3 additions & 1 deletion packages/hunk/src/core/install/latestRelease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import { isPrereleaseVersion, isStableVersion } from "../run/version";

const NPM_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/hunkdiff/dist-tags";
const HOMEBREW_FORMULA_URL = "https://formulae.brew.sh/api/formula/hunk.json";
const HUNK_CURL_RELEASE_URL = "https://updates.hunk.dev/v1/curl/latest";
// Served by Vercel as a rewrite to the release-proxy Worker so clients only contact the apex
// domain; the Worker's own hostname is distrusted by macOS's paste-time reputation check.
const HUNK_CURL_RELEASE_URL = "https://hunk.dev/api/release/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 DISABLE_ANALYTICS_ENV = "HUNK_DISABLE_ANALYTICS";
Expand Down
2 changes: 1 addition & 1 deletion packages/hunk/src/core/install/selfUpdate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ describe("hunk update", () => {
expect(result.commands).toEqual([]);
expect(result.stdout).toContain("hunk 1.0.0 (installed with the install script)");
expect(result.stdout).toContain("latest 1.1.0");
expect(result.releaseRequests[0]?.url).toBe("https://updates.hunk.dev/v1/curl/latest");
expect(result.releaseRequests[0]?.url).toBe("https://hunk.dev/api/release/latest");
expect(result.releaseRequests[0]?.headers.get("x-hunk-request-source")).toBe("update-check");
expect(result.releaseRequests[0]?.headers.get("x-hunk-current-version")).toBe("1.0.0");
});
Expand Down
2 changes: 1 addition & 1 deletion packages/hunk/src/core/process/updateNotice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ describe("startup update notice", () => {
key: "latest:0.7.1",
message: "Update available: 0.7.1 (latest) • run `hunk update`",
});
expect(requested).toEqual(["https://updates.hunk.dev/v1/curl/latest"]);
expect(requested).toEqual(["https://hunk.dev/api/release/latest"]);
expect(headers[0]?.get("x-hunk-request-source")).toBe("startup");
expect(headers[0]?.get("x-hunk-current-version")).toBe("0.7.0");
});
Expand Down
33 changes: 30 additions & 3 deletions scripts/packaging/install-sh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ function runReleaseResolution(
'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://hunk.dev/api/release/*) [ "${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",
Expand Down Expand Up @@ -253,13 +253,13 @@ describe("hunk.dev install script", () => {
() => {
const proxied = runReleaseResolution();
expect(proxied.exitCode).toBe(0);
expect(proxied.requests).toEqual(["https://updates.hunk.dev/v1/curl/latest"]);
expect(proxied.requests).toEqual(["https://hunk.dev/api/release/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://hunk.dev/api/release/latest",
"https://api.github.com/repos/modem-dev/hunk/releases/latest",
]);
},
Expand All @@ -283,6 +283,33 @@ describe("hunk.dev install script", () => {
expect(INSTALL_SCRIPT).toContain('current_header="X-Hunk-Current-Version: $1"');
});

test("resolves releases through the apex-domain rewrite that reaches the Worker route", () => {
// The installer, the in-app updater, the Vercel rewrite, and the Worker route must agree, or
// release discovery silently falls back to GitHub. The client side must stay on hunk.dev: macOS
// blocks browser-pasted commands whose sandbox run contacts the Worker's own hostname.
const vercelConfig = JSON.parse(readFileSync(join(REPO_ROOT, "vercel.json"), "utf8")) as {
rewrites: Array<{ source: string; destination: string }>;
};
const workerSource = readFileSync(
join(REPO_ROOT, "workers/release-proxy/src/index.ts"),
"utf8",
);
const workerRoute = workerSource.match(/const RELEASE_ROUTE = "([^"]+)"/)?.[1];
const updaterSource = readFileSync(
join(REPO_ROOT, "packages/hunk/src/core/install/latestRelease.ts"),
"utf8",
);

const releaseProxy = INSTALL_SCRIPT.match(/^RELEASE_PROXY="([^"]+)"$/m)?.[1];
expect(releaseProxy).toBe("https://hunk.dev/api/release/latest");
expect(updaterSource).toContain(`const HUNK_CURL_RELEASE_URL = "${releaseProxy}";`);

const rewrite = vercelConfig.rewrites.find(
(candidate) => candidate.source === new URL(releaseProxy ?? "").pathname,
);
expect(rewrite?.destination).toBe(`https://updates.hunk.dev${workerRoute}`);
});

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
2 changes: 1 addition & 1 deletion test/cli/install-vm/prepare-fixtures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ describe("install VM package fixtures", () => {
test("keeps curl release fallback inside the isolated VM server", () => {
const rewritten = rewriteCurlInstallerForVmServer(
[
'RELEASE_PROXY="https://updates.hunk.dev/v1/curl/latest"',
'RELEASE_PROXY="https://hunk.dev/api/release/latest"',
'RELEASES_API="https://api.github.com/repos/${REPO}/releases/latest"',
'DOWNLOAD_BASE="https://github.com/${REPO}/releases/download"',
].join("\n"),
Expand Down
2 changes: 1 addition & 1 deletion test/cli/install-vm/prepare-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,7 @@ async function stageSyntheticCurlArchive(
export function rewriteCurlInstallerForVmServer(installer: string) {
const replacements = [
[
'RELEASE_PROXY="https://updates.hunk.dev/v1/curl/latest"',
'RELEASE_PROXY="https://hunk.dev/api/release/latest"',
'RELEASE_PROXY="http://172.16.0.1:18080/unavailable-release-proxy"',
],
[
Expand Down
5 changes: 4 additions & 1 deletion vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
"installCommand": "SKIP_INSTALL_SIMPLE_GIT_HOOKS=1 bun install --frozen-lockfile && bun install --cwd website --frozen-lockfile",
"buildCommand": "bun run website:build",
"outputDirectory": "website/dist",
"rewrites": [{ "source": "/install", "destination": "/install.sh" }],
"rewrites": [
{ "source": "/install", "destination": "/install.sh" },
{ "source": "/api/release/latest", "destination": "https://updates.hunk.dev/v1/curl/latest" }
Comment thread
elucid marked this conversation as resolved.
],
"headers": [
{
"source": "/install(\\.sh)?",
Expand Down
5 changes: 4 additions & 1 deletion website/src/content/docs/docs/help/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Archive `website/dist/` as one deployable artifact. `bun run website:build` chec

## Deploy with Vercel

The repository-level `vercel.json` defines the install command, build command, Astro framework, and `website/dist` output directory. Configure one Vercel project with:
The repository-level `vercel.json` defines the install command, build command, Astro framework, and `website/dist` output directory. It also rewrites `/api/release/latest` to the release-proxy Worker (`workers/release-proxy/`) so the installer and `hunk update` resolve releases from the apex domain rather than the Worker's own hostname. Configure one Vercel project with:

- **Git repository:** `modem-dev/hunk`
- **Root directory:** repository root
Expand Down Expand Up @@ -56,8 +56,11 @@ curl --fail https://hunk.dev/sitemap.xml
curl --fail https://hunk.dev/pagefind/pagefind.js
curl --fail https://hunk.dev/docs/hunk-review-skill.md
curl --fail https://hunk.dev/og.png
curl --fail --location --silent --show-error --dump-header - https://hunk.dev/api/release/latest | grep --extended-regexp '^cache-control: no-store|"version"'
```

The last check must print both the `no-store` header and a `"version"` line; an HTML page or a cached response means the release rewrite is misrouted.

In a browser, confirm the landing page links to the docs, theme previews switch, the install command copies, documentation search returns results, theme selection persists across docs navigation, and a GitHub edit link opens the matching source file.

## Roll back
Expand Down
12 changes: 10 additions & 2 deletions workers/release-proxy/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# Hunk release proxy

This Cloudflare Worker serves `GET /v1/curl/latest`. It normalizes GitHub's latest stable Hunk
release to:
This Cloudflare Worker serves `GET /v1/curl/latest`. Clients reach it as
`https://hunk.dev/api/release/latest`: the repository-level `vercel.json` rewrites that path to the
Worker's `updates.hunk.dev` custom domain so the installer and `hunk update` only ever contact the
apex domain. macOS 26.4+ runs browser-pasted Terminal commands in an XProtect sandbox and blocks
the paste when the command reaches a host Apple's website reputation service distrusts, which the
Worker hostname tripped. It normalizes GitHub's latest stable Hunk release to:

```json
{ "version": "0.20.1" }
Expand Down Expand Up @@ -48,8 +52,12 @@ npm test
npm run typecheck
npm run deploy
curl -fsS https://updates.hunk.dev/v1/curl/latest
curl -fsSL https://hunk.dev/api/release/latest
```

The second check covers the Vercel rewrite. Keep the `updates.hunk.dev` custom domain while
releases that call it directly remain installed; they fall back to GitHub when it is unavailable.

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.
Keep the GitHub credential in the Worker secret, never in Hunk or Wrangler configuration.
Loading