From 0c2d4af3ef43c838914d3ebe37b6bc6ac740cb35 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 1 Sep 2026 16:16:50 -0400 Subject: [PATCH 1/2] feat(install): add observable release proxy --- .changeset/fresh-updates-observe.md | 5 + .github/workflows/release-proxy.yml | 84 + README.md | 2 +- install.sh | 50 +- scripts/install-sh.test.ts | 85 + src/core/install/latestRelease.test.ts | 65 +- src/core/install/latestRelease.ts | 40 +- src/core/install/selfUpdate.test.ts | 18 +- src/core/install/selfUpdate.ts | 3 + src/core/process/updateNotice.test.ts | 13 +- src/core/process/updateNotice.ts | 3 + test/cli/install-vm/prepare-fixtures.test.ts | 18 + test/cli/install-vm/prepare-fixtures.ts | 39 +- .../src/content/docs/docs/start/install.md | 3 + workers/release-proxy/README.md | 31 + workers/release-proxy/package-lock.json | 1590 +++++++++++++++++ workers/release-proxy/package.json | 16 + workers/release-proxy/src/index.test.ts | 196 ++ workers/release-proxy/src/index.ts | 145 ++ workers/release-proxy/tsconfig.json | 13 + workers/release-proxy/wrangler.jsonc | 8 + 21 files changed, 2392 insertions(+), 35 deletions(-) create mode 100644 .changeset/fresh-updates-observe.md create mode 100644 .github/workflows/release-proxy.yml create mode 100644 workers/release-proxy/README.md create mode 100644 workers/release-proxy/package-lock.json create mode 100644 workers/release-proxy/package.json create mode 100644 workers/release-proxy/src/index.test.ts create mode 100644 workers/release-proxy/src/index.ts create mode 100644 workers/release-proxy/tsconfig.json create mode 100644 workers/release-proxy/wrangler.jsonc diff --git a/.changeset/fresh-updates-observe.md b/.changeset/fresh-updates-observe.md new file mode 100644 index 000000000..f2030c222 --- /dev/null +++ b/.changeset/fresh-updates-observe.md @@ -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. diff --git a/.github/workflows/release-proxy.yml b/.github/workflows/release-proxy.yml new file mode 100644 index 000000000..54ca25c32 --- /dev/null +++ b/.github/workflows/release-proxy.yml @@ -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 }} diff --git a/README.md b/README.md index d327d7a3f..4b9c00169 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/install.sh b/install.sh index c6725cdd9..804825e2e 100755 --- a/install.sh +++ b/install.sh @@ -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`. # @@ -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" @@ -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 @@ -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 } @@ -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:-}" diff --git a/scripts/install-sh.test.ts b/scripts/install-sh.test.ts index 3c870b9bd..31d603f9f 100644 --- a/scripts/install-sh.test.ts +++ b/scripts/install-sh.test.ts @@ -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 ` `. * @@ -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//SKILL.md`, // so the payload directory must be the binary's directory or one of its ancestors. diff --git a/src/core/install/latestRelease.test.ts b/src/core/install/latestRelease.test.ts index d87124352..ac1513be9 100644 --- a/src/core/install/latestRelease.test.ts +++ b/src/core/install/latestRelease.test.ts @@ -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 = []; + 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 }); diff --git a/src/core/install/latestRelease.ts b/src/core/install/latestRelease.ts index cd809b45a..e39706367 100644 --- a/src/core/install/latestRelease.ts +++ b/src/core/install/latestRelease.ts @@ -13,12 +13,16 @@ 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"; 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"; +const DO_NOT_TRACK_ENV = "DO_NOT_TRACK"; export type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise; export type UpdateChannel = "latest" | "beta"; +export type ReleaseRequestSource = "startup" | "update-check" | "update"; /** Versions one install source currently publishes, after validation. */ export interface ChannelVersions { @@ -29,6 +33,9 @@ export interface ChannelVersions { export interface ReleaseLookupDeps { fetchImpl?: FetchImpl; fetchTimeoutMs?: number; + env?: NodeJS.ProcessEnv; + requestSource?: ReleaseRequestSource; + currentVersion?: string; } /** Build one fetch timeout signal for a release lookup, if supported by the runtime. */ @@ -117,16 +124,41 @@ export async function fetchHomebrewChannelVersions( return { latest: stable && isStableVersion(stable) ? stable : undefined }; } +/** Return whether release analytics are disabled by either supported environment convention. */ +function releaseAnalyticsDisabled(env: NodeJS.ProcessEnv = process.env) { + return env[DISABLE_ANALYTICS_ENV] === "1" || env[DO_NOT_TRACK_ENV] === "1"; +} + +/** Build bounded headers for the first-party curl release endpoint. */ +function curlReleaseHeaders(deps: ReleaseLookupDeps) { + const headers: Record = {}; + if (deps.requestSource) { + headers["X-Hunk-Request-Source"] = deps.requestSource; + } + if (deps.currentVersion) { + headers["X-Hunk-Current-Version"] = deps.currentVersion; + } + return headers; +} + /** - * Fetch the version of the newest GitHub release the curl installer downloads from. + * Fetch the stable release published for curl installs. * - * `releases/latest` never points at a prerelease, so a curl install only ever hears about - * `latest`. Release tags are spelled `v1.2.3` and versions are not, so the prefix is stripped - * before validation. + * The first-party endpoint supplies aggregate release-check observability and normalized metadata. + * Opted-out clients bypass it, and every endpoint failure falls back to GitHub so analytics can + * never make update discovery less reliable. */ export async function fetchCurlChannelVersions( deps: ReleaseLookupDeps = {}, ): Promise { + if (!releaseAnalyticsDisabled(deps.env)) { + const proxyPayload = await fetchJson(HUNK_CURL_RELEASE_URL, deps, curlReleaseHeaders(deps)); + const proxyVersion = readStringField(proxyPayload, "version"); + if (proxyVersion && isStableVersion(proxyVersion)) { + return { latest: proxyVersion }; + } + } + const payload = await fetchJson(GITHUB_LATEST_RELEASE_URL, deps, { Accept: "application/vnd.github+json", }); diff --git a/src/core/install/selfUpdate.test.ts b/src/core/install/selfUpdate.test.ts index c0ac160e2..915c03fba 100644 --- a/src/core/install/selfUpdate.test.ts +++ b/src/core/install/selfUpdate.test.ts @@ -35,6 +35,7 @@ async function runUpdate(options: UpdateRunOptions) { const stderr: string[] = []; const commands: string[][] = []; const commandEnvs: Array = []; + const releaseRequests: Array<{ url: string; headers: Headers }> = []; const latestVersion = options.latestVersion ?? "1.1.0"; const exitCode = await runSelfUpdateCommand( @@ -48,12 +49,15 @@ async function runUpdate(options: UpdateRunOptions) { resolveInstalledVersion: () => options.installedVersion ?? "1.0.0", resolveInstallSource: () => options.installSource, // One payload carrying every registry shape, so a `--method` override still resolves. - fetchImpl: async () => - jsonResponse({ + fetchImpl: async (input, init) => { + releaseRequests.push({ url: String(input), headers: new Headers(init?.headers) }); + return jsonResponse({ latest: latestVersion, + version: latestVersion, versions: { stable: latestVersion }, tag_name: `v${latestVersion}`, - }), + }); + }, runCommand: async (command, commandOptions) => { commands.push([...command]); commandEnvs.push(commandOptions?.env); @@ -68,6 +72,7 @@ async function runUpdate(options: UpdateRunOptions) { stderr: stderr.join(""), commands, commandEnvs, + releaseRequests, }; } @@ -209,6 +214,8 @@ describe("hunk update", () => { expect(result.commandEnvs).toEqual([ { PATH: "/usr/bin", HOME: "/home/reviewer", HUNK_VERSION: "1.1.0" }, ]); + expect(result.releaseRequests[0]?.headers.get("x-hunk-request-source")).toBe("update"); + expect(result.releaseRequests[0]?.headers.get("x-hunk-current-version")).toBe("1.0.0"); expect(result.stdout).toContain("Updated hunk to 1.1.0."); }); @@ -224,13 +231,16 @@ describe("hunk update", () => { expect(result.commandEnvs[0]?.HUNK_VERSION).toBe("0.9.0"); }); - test("reports the GitHub release version for a curl --check", async () => { + test("classifies curl --check release requests without installing", async () => { const result = await runUpdate({ installSource: "curl", input: { check: true } }); expect(result.exitCode).toBe(0); 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]?.headers.get("x-hunk-request-source")).toBe("update-check"); + expect(result.releaseRequests[0]?.headers.get("x-hunk-current-version")).toBe("1.0.0"); }); test("refuses to pin a version on Homebrew", async () => { diff --git a/src/core/install/selfUpdate.ts b/src/core/install/selfUpdate.ts index 429a14b67..a9f90ce80 100644 --- a/src/core/install/selfUpdate.ts +++ b/src/core/install/selfUpdate.ts @@ -305,6 +305,9 @@ export async function runSelfUpdateCommand( const channelVersions = await fetchChannelVersions(installSource, { fetchImpl: io.fetchImpl, fetchTimeoutMs: io.fetchTimeoutMs, + env, + requestSource: input.check ? "update-check" : "update", + currentVersion: installedVersion, }); const latestVersion = channelVersions.latest; const targetVersion = input.version ?? latestVersion; diff --git a/src/core/process/updateNotice.test.ts b/src/core/process/updateNotice.test.ts index 153fec81f..9384a0d92 100644 --- a/src/core/process/updateNotice.test.ts +++ b/src/core/process/updateNotice.test.ts @@ -20,9 +20,9 @@ function createFormulaResponse(stable: string) { }); } -/** Build one JSON response that mimics the GitHub latest-release payload. */ +/** Build one JSON response accepted by both the release proxy and direct-GitHub fallback. */ function createGitHubReleaseResponse(tagName: string) { - return new Response(JSON.stringify({ tag_name: tagName }), { + return new Response(JSON.stringify({ version: tagName.replace(/^v/, ""), tag_name: tagName }), { status: 200, headers: { "content-type": "application/json" }, }); @@ -129,11 +129,14 @@ describe("startup update notice", () => { test("reads the GitHub releases API for curl installer installs", async () => { await withTempStatePath(async (statePath) => { const requested: string[] = []; + const headers: Headers[] = []; await expect( resolveStartupUpdateNotice({ - fetchImpl: async (input) => { + env: {}, + fetchImpl: async (input, init) => { requested.push(String(input)); + headers.push(new Headers(init?.headers)); return createGitHubReleaseResponse("v0.7.1"); }, resolveExecutablePath: () => join("/", "home", "reviewer", ".hunk", "bin", "hunk"), @@ -144,7 +147,9 @@ describe("startup update notice", () => { key: "latest:0.7.1", message: "Update available: 0.7.1 (latest) • run `hunk update`", }); - expect(requested).toEqual(["https://api.github.com/repos/modem-dev/hunk/releases/latest"]); + 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("0.7.0"); }); }); diff --git a/src/core/process/updateNotice.ts b/src/core/process/updateNotice.ts index 1287b8aec..96fe8155f 100644 --- a/src/core/process/updateNotice.ts +++ b/src/core/process/updateNotice.ts @@ -223,6 +223,9 @@ export async function resolveStartupUpdateNotice( const channelVersions = await fetchChannelVersions(lookupSource, { fetchImpl: deps.fetchImpl, fetchTimeoutMs: deps.fetchTimeoutMs, + env, + requestSource: "startup", + currentVersion: resolveInstalledVersion(), }); return selectUpdateNotice(resolveInstalledVersion(), channelVersions, installSource); diff --git a/test/cli/install-vm/prepare-fixtures.test.ts b/test/cli/install-vm/prepare-fixtures.test.ts index d0d77f84a..5816f8c11 100644 --- a/test/cli/install-vm/prepare-fixtures.test.ts +++ b/test/cli/install-vm/prepare-fixtures.test.ts @@ -24,6 +24,7 @@ import { deriveVerifiedDaemonUpgradeBinaryDigests, FIXTURE_VERSION_A, FIXTURE_VERSION_B, + rewriteCurlInstallerForVmServer, verifyInstallVmFixtures, type InstallVmFixtureManifest, } from "./prepare-fixtures"; @@ -145,6 +146,23 @@ function writeTestFixtures(repo: string, fixtures: string) { } 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"', + 'RELEASES_API="https://api.github.com/repos/${REPO}/releases/latest"', + 'DOWNLOAD_BASE="https://github.com/${REPO}/releases/download"', + ].join("\n"), + ); + + expect(rewritten).toContain( + 'RELEASE_PROXY="http://172.16.0.1:18080/unavailable-release-proxy"', + ); + expect(rewritten).toContain('RELEASES_API="http://172.16.0.1:18080/latest"'); + expect(rewritten).toContain('DOWNLOAD_BASE="http://172.16.0.1:18080/download"'); + expect(rewritten).not.toContain("https://"); + }); + test("derives trusted daemon binary digests from the actual platform tarballs", async () => { if (process.platform !== "linux") return; const root = mkdtempSync(path.join(tmpdir(), "hunk-daemon-tarball-digests-")); diff --git a/test/cli/install-vm/prepare-fixtures.ts b/test/cli/install-vm/prepare-fixtures.ts index 5f08d99f9..ea785ce2c 100644 --- a/test/cli/install-vm/prepare-fixtures.ts +++ b/test/cli/install-vm/prepare-fixtures.ts @@ -569,6 +569,33 @@ async function stageSyntheticCurlArchive( } /** Prepare local registry and curl fixtures from the explicitly built checkout. */ +/** Redirect the production installer through the VM's isolated release and download server. */ +export function rewriteCurlInstallerForVmServer(installer: string) { + const replacements = [ + [ + 'RELEASE_PROXY="https://updates.hunk.dev/v1/curl/latest"', + 'RELEASE_PROXY="http://172.16.0.1:18080/unavailable-release-proxy"', + ], + [ + 'RELEASES_API="https://api.github.com/repos/${REPO}/releases/latest"', + 'RELEASES_API="http://172.16.0.1:18080/latest"', + ], + [ + 'DOWNLOAD_BASE="https://github.com/${REPO}/releases/download"', + 'DOWNLOAD_BASE="http://172.16.0.1:18080/download"', + ], + ] as const; + + let rewritten = installer; + for (const [production, fixture] of replacements) { + if (!rewritten.includes(production)) { + throw new Error(`Curl installer fixture could not replace ${production}.`); + } + rewritten = rewritten.replace(production, fixture); + } + return rewritten; +} + export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: string) { const releaseRoot = releaseNpmDir(repoRoot); const currentManifest = JSON.parse( @@ -694,15 +721,9 @@ export async function prepareInstallVmFixtures(repoRoot: string, outputRoot: str `${sha256(truncatedArchive)} ${archiveName}\n`, ); - const installer = readFileSync(path.join(repoRoot, "install.sh"), "utf8") - .replace( - 'RELEASES_API="https://api.github.com/repos/${REPO}/releases/latest"', - 'RELEASES_API="http://172.16.0.1:18080/latest"', - ) - .replace( - 'DOWNLOAD_BASE="https://github.com/${REPO}/releases/download"', - 'DOWNLOAD_BASE="http://172.16.0.1:18080/download"', - ); + const installer = rewriteCurlInstallerForVmServer( + readFileSync(path.join(repoRoot, "install.sh"), "utf8"), + ); writeFileSync(path.join(httpRoot, "install.sh"), installer); const fixtureManifest: InstallVmFixtureManifest = { diff --git a/website/src/content/docs/docs/start/install.md b/website/src/content/docs/docs/start/install.md index 5bea69b87..560733a83 100644 --- a/website/src/content/docs/docs/start/install.md +++ b/website/src/content/docs/docs/start/install.md @@ -24,6 +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_DISABLE_ANALYTICS=1` or `DO_NOT_TRACK=1` | Resolve the newest release directly from GitHub instead of Hunk's aggregate 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 @@ -38,6 +39,8 @@ 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. +Default install-script release resolution, automatic startup update checks, `hunk update --check`, and `hunk update` use Hunk's cached endpoint for curl-managed installs before downloading release assets directly from GitHub. The endpoint records aggregate request source and current-version fields, but Hunk sends no installation ID, repository, hostname, cookie, or request body. Set `HUNK_DISABLE_ANALYTICS=1` or `DO_NOT_TRACK=1` to bypass that endpoint and resolve the release directly from GitHub; the fallback also happens automatically if the endpoint is unavailable. + Windows is not covered by the script; use npm or mise there. ## npm diff --git a/workers/release-proxy/README.md b/workers/release-proxy/README.md new file mode 100644 index 000000000..009bbe507 --- /dev/null +++ b/workers/release-proxy/README.md @@ -0,0 +1,31 @@ +# Hunk release proxy + +This stateless Cloudflare Worker serves `GET /v1/curl/latest`. It caches and normalizes GitHub's +latest stable Hunk release to: + +```json +{ "version": "0.20.1" } +``` + +The Worker writes one structured `release_check` log containing only allowlisted `source` and +`currentVersion` values. 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. + +## Development + +```sh +npm install +bun test +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 deploys changes from `main` after its tests, +typecheck, and deployment dry run pass. Manual dispatches deploy only from `main`. Configure the +`CLOUDFLARE_API_TOKEN` secret in the protected `release-proxy` GitHub environment. The client and +installer fall back directly to GitHub, so their rollout does not depend on deployment ordering; +verify the Worker after its first deployment so release checks produce the intended aggregate logs. +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. diff --git a/workers/release-proxy/package-lock.json b/workers/release-proxy/package-lock.json new file mode 100644 index 000000000..5406d57db --- /dev/null +++ b/workers/release-proxy/package-lock.json @@ -0,0 +1,1590 @@ +{ + "name": "hunk-release-proxy", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hunk-release-proxy", + "devDependencies": { + "@types/bun": "1.3.14", + "typescript": "^5.9.3", + "wrangler": "^4.103.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260831.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260831.1.tgz", + "integrity": "sha512-oyZ8xhu+gYTvoxV/sn6NRmTHK95RhEO1Dk54/6oPb0Uu70w7ZeRoCjkJ5aNmfS8Vrkdu6+oL0HNg6EcC61uQ2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260831.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260831.1.tgz", + "integrity": "sha512-s6Go53KPnoXZ1sTGBZ3en3otfHDuMPJhiwXMYWU21JkJQkpoeRt6HFUwM0GPhK3YhXWm+8baGMvCGZYS/KA9eA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260831.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260831.1.tgz", + "integrity": "sha512-WxNKBgjKgeYTolW3yl1Lt3Lu67UlxdeyzWYi9MIqrKBdyQcz+UNG36RevSBf8rv1sTWapRW234VX2keZ+wXapA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260831.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260831.1.tgz", + "integrity": "sha512-JTF9+9clUT3gaCq7Xnmd+Q/wEMaitpngSTOec/Ffb/r3xexA9XwNJVFSOKfk6q61flHGjAYJ4H9B7Mu5Qur49w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260831.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260831.1.tgz", + "integrity": "sha512-do+KDYw0PABwsrKUQIccWBZB70kqKcADoSnvzJ8pvMaWUVB4qaCspEZYfm97WNdtY1wt8mlKYqIJyYUNOkTvQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@types/bun": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz", + "integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.14" + } + }, + "node_modules/@types/node": { + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", + "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/bun-types": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz", + "integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "5.20260831.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260831.0-alpha.tgz", + "integrity": "sha512-Hwgh1VDUiPCPGQKODQfUmy7hRAje1D55icB+9png3ueiM64rlSM87nSrtqpxAD+DlLWI4ehnYBuECaXV43zGmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260831.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260831.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260831.1.tgz", + "integrity": "sha512-A2LwrkBel/FnKABPfeBAMiL6v70+rugnunqQRfWsWZjlhsTZoBScWUVunMy/xLCGLjWCQL2zp39AVR6aO0jurQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260831.1", + "@cloudflare/workerd-darwin-arm64": "1.20260831.1", + "@cloudflare/workerd-linux-64": "1.20260831.1", + "@cloudflare/workerd-linux-arm64": "1.20260831.1", + "@cloudflare/workerd-windows-64": "1.20260831.1" + } + }, + "node_modules/wrangler": { + "version": "4.128.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.128.0.tgz", + "integrity": "sha512-jNXy9e8/pbx8iqTzXPiuflnitKJZoAfEUSUUDLW87bwyeMvJ7kb3yQMSbxEcfNdfHqJW38KRcKaLljOYV4N/4w==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260831.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260831.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260831.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/workers/release-proxy/package.json b/workers/release-proxy/package.json new file mode 100644 index 000000000..cd4ed4b8e --- /dev/null +++ b/workers/release-proxy/package.json @@ -0,0 +1,16 @@ +{ + "name": "hunk-release-proxy", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "typescript": "^5.9.3", + "wrangler": "^4.103.0" + } +} diff --git a/workers/release-proxy/src/index.test.ts b/workers/release-proxy/src/index.test.ts new file mode 100644 index 000000000..cc5864c56 --- /dev/null +++ b/workers/release-proxy/src/index.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from "bun:test"; +import { createReleaseProxyHandler } from "./index"; + +/** Build a direct invocation context and retain deferred cache work for assertions. */ +function createTestContext() { + const pending: Promise[] = []; + return { + context: { waitUntil: (promise: Promise) => pending.push(promise) }, + settle: () => Promise.all(pending), + }; +} + +/** Build a tiny in-memory implementation of the Worker cache seam. */ +function createTestCache() { + const entries = new Map(); + return { + entries, + cache: { + match: async (request: Request) => entries.get(request.url)?.clone(), + put: async (request: Request, response: Response) => { + entries.set(request.url, response.clone()); + }, + }, + }; +} + +describe("release proxy Worker", () => { + test("normalizes and caches GitHub's latest stable release", async () => { + const upstreamRequests: Array<{ url: string; headers: Headers }> = []; + const { cache, entries } = createTestCache(); + const { context, settle } = createTestContext(); + const handler = createReleaseProxyHandler({ + cache, + fetchImpl: async (input, init) => { + upstreamRequests.push({ url: String(input), headers: new Headers(init?.headers) }); + return Response.json({ tag_name: "v1.2.3" }); + }, + log: () => {}, + }); + + const response = await handler( + new Request("https://updates.hunk.dev/v1/curl/latest"), + {}, + context, + ); + await settle(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ version: "1.2.3" }); + expect(response.headers.get("cache-control")).toBe("public, max-age=300"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(upstreamRequests).toHaveLength(1); + expect(upstreamRequests[0]?.url).toBe( + "https://api.github.com/repos/modem-dev/hunk/releases/latest", + ); + expect(upstreamRequests[0]?.headers.get("accept")).toBe("application/vnd.github+json"); + expect(upstreamRequests[0]?.headers.get("user-agent")).toBe("hunk-release-proxy"); + expect(entries.has("https://updates.hunk.dev/v1/curl/latest")).toBe(true); + + const second = await handler( + new Request("https://updates.hunk.dev/v1/curl/latest"), + {}, + context, + ); + expect(await second.json()).toEqual({ version: "1.2.3" }); + expect(upstreamRequests).toHaveLength(1); + }); + + test("rejects prereleases and malformed GitHub payloads", async () => { + for (const payload of [{ tag_name: "v1.2.3-beta.1" }, { name: "v1.2.3" }, null]) { + const { context } = createTestContext(); + const handler = createReleaseProxyHandler({ + fetchImpl: async () => Response.json(payload), + log: () => {}, + }); + const response = await handler( + new Request("https://updates.hunk.dev/v1/curl/latest"), + {}, + context, + ); + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ error: "invalid_upstream_response" }); + } + }); + + test("contains upstream failures and unknown routes", async () => { + const { context } = createTestContext(); + const handler = createReleaseProxyHandler({ + fetchImpl: async () => { + throw new Error("offline"); + }, + log: () => {}, + }); + + const failed = await handler( + new Request("https://updates.hunk.dev/v1/curl/latest"), + {}, + context, + ); + expect(failed.status).toBe(502); + expect(failed.headers.get("cache-control")).toBe("no-store"); + expect(await failed.json()).toEqual({ error: "upstream_unavailable" }); + + const missing = await handler(new Request("https://updates.hunk.dev/other"), {}, context); + expect(missing.status).toBe(404); + expect(missing.headers.get("cache-control")).toBe("no-store"); + }); + + test("bounds a stalled GitHub lookup", async () => { + const { context } = createTestContext(); + const handler = createReleaseProxyHandler({ + upstreamTimeoutMs: 1, + fetchImpl: async (_input, init) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted"))); + }), + log: () => {}, + }); + + const response = await handler( + new Request("https://updates.hunk.dev/v1/curl/latest"), + {}, + context, + ); + expect(response.status).toBe(502); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + test("bounds a stalled GitHub response body", async () => { + const { context } = createTestContext(); + const handler = createReleaseProxyHandler({ + upstreamTimeoutMs: 1, + fetchImpl: async (_input, init) => { + const body = new ReadableStream({ + start(controller) { + init?.signal?.addEventListener("abort", () => controller.error(new Error("aborted"))); + }, + }); + return new Response(body); + }, + log: () => {}, + }); + + const response = await handler( + new Request("https://updates.hunk.dev/v1/curl/latest"), + {}, + context, + ); + expect(response.status).toBe(502); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + test("logs only allowlisted request dimensions", async () => { + const logs: string[] = []; + const { context } = createTestContext(); + const handler = createReleaseProxyHandler({ + fetchImpl: async () => Response.json({ tag_name: "v1.2.3" }), + log: (entry) => logs.push(entry), + }); + + await handler( + new Request("https://updates.hunk.dev/v1/curl/latest?ignored=secret", { + headers: { + cookie: "session=secret", + "x-hunk-current-version": "1.0.0-beta.1", + "x-hunk-request-source": "startup", + "x-other": "secret", + }, + }), + {}, + context, + ); + for (const currentVersion of [ + "not a version with private text", + "1.2.3-private-repository-name", + ]) { + await handler( + new Request("https://updates.hunk.dev/v1/curl/latest", { + headers: { + "x-hunk-current-version": currentVersion, + "x-hunk-request-source": "private-source", + }, + }), + {}, + context, + ); + } + + expect(logs.map((entry) => JSON.parse(entry))).toEqual([ + { event: "release_check", source: "startup", currentVersion: "1.0.0-beta.1" }, + { event: "release_check", source: "unknown", currentVersion: "unknown" }, + { event: "release_check", source: "unknown", currentVersion: "unknown" }, + ]); + expect(logs.join(" ")).not.toContain("secret"); + }); +}); diff --git a/workers/release-proxy/src/index.ts b/workers/release-proxy/src/index.ts new file mode 100644 index 000000000..cf90ad503 --- /dev/null +++ b/workers/release-proxy/src/index.ts @@ -0,0 +1,145 @@ +const GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/modem-dev/hunk/releases/latest"; +const RELEASE_ROUTE = "/v1/curl/latest"; +const CACHE_CONTROL = "public, max-age=300"; +const ERROR_CACHE_CONTROL = "no-store"; +const UPSTREAM_TIMEOUT_MS = 5_000; + +const REQUEST_SOURCES = ["install", "startup", "update-check", "update"] as const; +type RequestSource = (typeof REQUEST_SOURCES)[number] | "unknown"; + +interface WorkerExecutionContext { + waitUntil(promise: Promise): void; +} + +interface WorkerCache { + match(request: Request): Promise; + put(request: Request, response: Response): Promise; +} + +type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +interface ReleaseProxyDeps { + fetchImpl?: FetchImpl; + cache?: WorkerCache; + log?: (entry: string) => void; + upstreamTimeoutMs?: number; +} + +/** Return a bounded request source suitable for aggregate release-check logs. */ +function requestSource(request: Request): RequestSource { + const candidate = request.headers.get("x-hunk-request-source"); + return REQUEST_SOURCES.find((source) => source === candidate) ?? "unknown"; +} + +/** Return a normalized Hunk version without admitting arbitrary values into structured logs. */ +function currentVersion(request: Request) { + const candidate = request.headers.get("x-hunk-current-version"); + return candidate && /^\d+\.\d+\.\d+(?:-beta\.\d+)?$/.test(candidate) ? candidate : "unknown"; +} + +/** Read the stable version from GitHub's latest-release payload. */ +function stableVersion(payload: unknown) { + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + return undefined; + } + + const tagName = (payload as Record).tag_name; + if (typeof tagName !== "string") { + return undefined; + } + + const version = tagName.startsWith("v") ? tagName.slice(1) : tagName; + return /^\d+\.\d+\.\d+$/.test(version) ? version : undefined; +} + +/** Build one JSON response with explicit edge and client caching policy. */ +function jsonResponse(payload: unknown, status = 200) { + return new Response(JSON.stringify(payload), { + status, + headers: { + "cache-control": status === 200 ? CACHE_CONTROL : ERROR_CACHE_CONTROL, + "content-type": "application/json; charset=utf-8", + "x-content-type-options": "nosniff", + }, + }); +} + +/** Resolve Cloudflare's default cache without requiring it in direct unit tests. */ +function defaultWorkerCache() { + return ( + globalThis as typeof globalThis & { + caches?: { default?: WorkerCache }; + } + ).caches?.default; +} + +/** Serve normalized curl release metadata while logging only bounded aggregate dimensions. */ +export function createReleaseProxyHandler(deps: ReleaseProxyDeps = {}) { + const fetchImpl = deps.fetchImpl ?? fetch; + const cache = deps.cache ?? defaultWorkerCache(); + const log = deps.log ?? console.log; + const upstreamTimeoutMs = deps.upstreamTimeoutMs ?? UPSTREAM_TIMEOUT_MS; + + return async (request: Request, _env: unknown, ctx: WorkerExecutionContext) => { + const url = new URL(request.url); + if (request.method !== "GET" || url.pathname !== RELEASE_ROUTE) { + return jsonResponse({ error: "not_found" }, 404); + } + + log( + JSON.stringify({ + event: "release_check", + source: requestSource(request), + currentVersion: currentVersion(request), + }), + ); + + const cacheKey = new Request(`${url.origin}${RELEASE_ROUTE}`); + const cached = await cache?.match(cacheKey); + if (cached) { + return cached; + } + + let upstream: Response; + let payload: unknown; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), upstreamTimeoutMs); + try { + upstream = await fetchImpl(GITHUB_LATEST_RELEASE_URL, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "hunk-release-proxy", + }, + signal: controller.signal, + }); + if (!upstream.ok) { + return jsonResponse({ error: "upstream_unavailable" }, 502); + } + + try { + payload = await upstream.json(); + } catch { + return jsonResponse({ error: "invalid_upstream_response" }, 502); + } + } catch { + return jsonResponse({ error: "upstream_unavailable" }, 502); + } finally { + clearTimeout(timeout); + } + + const version = stableVersion(payload); + if (!version) { + return jsonResponse({ error: "invalid_upstream_response" }, 502); + } + + const response = jsonResponse({ version }); + if (cache) { + ctx.waitUntil(cache.put(cacheKey, response.clone())); + } + return response; + }; +} + +export default { + fetch: createReleaseProxyHandler(), +}; diff --git a/workers/release-proxy/tsconfig.json b/workers/release-proxy/tsconfig.json new file mode 100644 index 000000000..52cb007c9 --- /dev/null +++ b/workers/release-proxy/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "types": ["bun"], + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/workers/release-proxy/wrangler.jsonc b/workers/release-proxy/wrangler.jsonc new file mode 100644 index 000000000..6ccb6853f --- /dev/null +++ b/workers/release-proxy/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/wrangler.json", + "name": "hunk-release-proxy", + "main": "src/index.ts", + "compatibility_date": "2026-08-01", + "routes": [{ "pattern": "updates.hunk.dev", "custom_domain": true }], + "observability": { "enabled": true }, +} From e8b709d3ad62cd1cc7ec7a970b9b8f5d9f7bbe91 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 1 Sep 2026 17:04:31 -0400 Subject: [PATCH 2/2] fix(install): inject analytics environment --- src/core/install/latestRelease.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/install/latestRelease.ts b/src/core/install/latestRelease.ts index e39706367..5bbee29bc 100644 --- a/src/core/install/latestRelease.ts +++ b/src/core/install/latestRelease.ts @@ -125,8 +125,8 @@ export async function fetchHomebrewChannelVersions( } /** Return whether release analytics are disabled by either supported environment convention. */ -function releaseAnalyticsDisabled(env: NodeJS.ProcessEnv = process.env) { - return env[DISABLE_ANALYTICS_ENV] === "1" || env[DO_NOT_TRACK_ENV] === "1"; +function releaseAnalyticsDisabled(env: NodeJS.ProcessEnv | undefined) { + return env?.[DISABLE_ANALYTICS_ENV] === "1" || env?.[DO_NOT_TRACK_ENV] === "1"; } /** Build bounded headers for the first-party curl release endpoint. */