From 036018666d82f669eedf0284b4a629bb4fa49a1d Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Mon, 6 Apr 2026 21:45:15 -0700 Subject: [PATCH 01/12] feat(listen): support headless mode for Docker/CI Add --access-token, --env, and --org flags (with env var fallbacks) to `polar listen` so it can run without interactive prompts. This enables usage in Docker containers, CI pipelines, and scripts where no TTY or browser is available for OAuth login. --- src/commands/listen.ts | 437 +++++++++++++++++++++++++++++++---------- 1 file changed, 331 insertions(+), 106 deletions(-) diff --git a/src/commands/listen.ts b/src/commands/listen.ts index 97072d8..3cd8801 100644 --- a/src/commands/listen.ts +++ b/src/commands/listen.ts @@ -1,135 +1,360 @@ -import { Args, Command } from "@effect/cli"; -import { Effect, Either, Redacted, Schema } from "effect"; +import { Args, Command, Options } from "@effect/cli"; +import { createHmac, randomUUID } from "node:crypto"; +import { Effect, Either, Option, Redacted, Schema } from "effect"; import { EventSource } from "eventsource"; import { environmentPrompt } from "../prompts/environment"; import { organizationLoginPrompt } from "../prompts/organizations"; import { ListenAck, ListenWebhookEvent } from "../schemas/Events"; import type { Token } from "../schemas/Tokens"; import * as OAuth from "../services/oauth"; +import * as Polar from "../services/polar"; const LISTEN_BASE_URLS = { production: "https://api.polar.sh/v1/cli/listen", sandbox: "https://sandbox-api.polar.sh/v1/cli/listen", } as const; +const API_BASE_URLS = { + production: "https://api.polar.sh", + sandbox: "https://sandbox-api.polar.sh", +} as const; + const url = Args.text({ name: "url" }); -export const listen = Command.make("listen", { url }, ({ url }) => - Effect.gen(function* () { - const environment = yield* environmentPrompt; +const accessTokenOption = Options.text("access-token").pipe( + Options.optional, + Options.withDescription( + "Personal access token (skips OAuth login). Can also be set via POLAR_ACCESS_TOKEN env var.", + ), +); + +const envOption = Options.choice("env", ["sandbox", "production"]).pipe( + Options.optional, + Options.withDescription( + "Environment to use (skips interactive prompt). Can also be set via POLAR_ENVIRONMENT env var.", + ), +); + +const orgOption = Options.text("org").pipe( + Options.optional, + Options.withDescription( + "Organization slug or ID (skips interactive prompt). Auto-selects if only one org exists.", + ), +); + +const webhookSecretOption = Options.text("webhook-secret").pipe( + Options.optional, + Options.withDescription( + "Webhook secret to re-sign forwarded payloads (standardwebhooks format). " + + "Can also be set via POLAR_WEBHOOK_SECRET env var. " + + "When provided, the relay re-signs each payload so the receiving app's " + + "signature verification passes.", + ), +); + +/** + * Re-sign a webhook payload using the standardwebhooks format. + * This matches how @polar-sh/sdk's `validateEvent` verifies signatures: + * HMAC-SHA256 with base64(secret) as the key, over "msgId.timestamp.body". + */ +function signPayload( + body: string, + secret: string, +): Record { + const msgId = `msg_${randomUUID()}`; + const timestamp = Math.floor(Date.now() / 1000).toString(); + const toSign = `${msgId}.${timestamp}.${body}`; + const key = Buffer.from(secret, "utf-8").toString("base64"); + const sig = createHmac("sha256", Buffer.from(key, "base64")) + .update(toSign) + .digest("base64"); + return { + "webhook-id": msgId, + "webhook-timestamp": timestamp, + "webhook-signature": `v1,${sig}`, + "content-type": "application/json", + }; +} + +/** + * Resolve the environment from (in order of priority): + * 1. --env flag + * 2. POLAR_ENVIRONMENT env var + * 3. Interactive prompt + */ +const resolveEnvironment = ( + envFlag: Option.Option, +): Effect.Effect => { + if (Option.isSome(envFlag)) { + return Effect.succeed(envFlag.value as OAuth.PolarEnvironment); + } + const envVar = process.env.POLAR_ENVIRONMENT; + if (envVar === "sandbox" || envVar === "production") { + return Effect.succeed(envVar); + } + return environmentPrompt; +}; + +/** + * Resolve the access token from (in order of priority): + * 1. --access-token flag + * 2. POLAR_ACCESS_TOKEN env var + * 3. OAuth login flow + */ +const resolveAccessToken = ( + tokenFlag: Option.Option, + environment: OAuth.PolarEnvironment, +): Effect.Effect => { + if (Option.isSome(tokenFlag)) { + return Effect.succeed(tokenFlag.value); + } + const envVar = process.env.POLAR_ACCESS_TOKEN; + if (envVar) { + return Effect.succeed(envVar); + } + return Effect.gen(function* () { const oauth = yield* OAuth.OAuth; - const organization = yield* organizationLoginPrompt(environment); - const listenUrl = `${LISTEN_BASE_URLS[environment]}/${organization.id}`; - - const startListening = (accessToken: string) => - Effect.async((resume) => { - const eventSource = new EventSource(listenUrl, { - fetch: (input, init) => - fetch(input, { - ...init, - headers: { - ...init.headers, - Authorization: `Bearer ${accessToken}`, - }, + const token = yield* oauth.resolveAccessToken(environment); + return Redacted.value(token.token); + }); +}; + +/** + * Resolve the organization. When using a personal access token (non-OAuth), + * we fetch orgs directly from the API. When using OAuth, we use the existing + * interactive prompt. + * + * Resolution order: + * 1. --org flag (slug or ID) + * 2. Auto-select if only one org + * 3. Interactive prompt (OAuth flow only) + */ +const resolveOrganization = ( + orgFlag: Option.Option, + environment: OAuth.PolarEnvironment, + accessToken: string, + isPersonalToken: boolean, +): Effect.Effect< + { id: string; slug: string; name: string }, + OAuth.OAuthError | Polar.PolarError +> => { + if (isPersonalToken) { + // When using a personal access token, fetch orgs directly via API + return Effect.gen(function* () { + const baseUrl = API_BASE_URLS[environment]; + const res = yield* Effect.tryPromise({ + try: () => + fetch(`${baseUrl}/v1/organizations?page=1&limit=100`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }).then(async (r) => { + if (!r.ok) + throw new Error(`${r.status} ${await r.text()}`); + return r.json() as Promise<{ + items: Array<{ id: string; slug: string; name: string }>; + }>; + }), + catch: (error) => + new OAuth.OAuthError({ + message: `Failed to fetch organizations: ${error}`, + cause: error, + }), + }); + + const orgs = res.items; + if (orgs.length === 0) { + return yield* Effect.fail( + new OAuth.OAuthError({ + message: "No organizations found for this access token", + }), + ); + } + + // If --org flag provided, match by slug or ID + if (Option.isSome(orgFlag)) { + const match = orgs.find( + (o) => o.slug === orgFlag.value || o.id === orgFlag.value, + ); + if (!match) { + return yield* Effect.fail( + new OAuth.OAuthError({ + message: `Organization "${orgFlag.value}" not found. Available: ${orgs.map((o) => o.slug).join(", ")}`, }), - }); + ); + } + return match; + } - eventSource.onmessage = (event) => { - const json = JSON.parse(event.data); - const ack = Schema.decodeUnknownEither(ListenAck)(json); + // Auto-select if only one org + if (orgs.length === 1) { + return orgs[0]; + } - if (Either.isRight(ack)) { - const { secret } = ack.right; - const dim = "\x1b[2m"; - const bold = "\x1b[1m"; - const cyan = "\x1b[36m"; - const reset = "\x1b[0m"; + // Multiple orgs, no flag — fail with helpful message in headless mode + return yield* Effect.fail( + new OAuth.OAuthError({ + message: `Multiple organizations found. Use --org to select one: ${orgs.map((o) => o.slug).join(", ")}`, + }), + ); + }); + } - console.log(""); - console.log( - ` ${bold}${cyan}Connected${reset} ${bold}${organization.name}${reset}`, - ); - console.log(` ${dim}Secret${reset} ${secret}`); - console.log(` ${dim}Forwarding${reset} ${url}`); - console.log(""); - console.log(` ${dim}Waiting for events...${reset}`); - console.log(""); - - return; - } - - const webhookEvent = - Schema.decodeUnknownEither(ListenWebhookEvent)(json); - - if (Either.isLeft(webhookEvent)) { - console.error(">> Failed to decode event"); - return; - } - - fetch(url, { - method: "POST", - headers: webhookEvent.right.headers, - body: JSON.stringify(webhookEvent.right.payload.payload), - }) - .then((res) => { + // OAuth flow — use the interactive prompt + return organizationLoginPrompt(environment); +}; + +export const listen = Command.make( + "listen", + { url, accessToken: accessTokenOption, env: envOption, org: orgOption, webhookSecret: webhookSecretOption }, + ({ url, accessToken: accessTokenFlag, env: envFlag, org: orgFlag, webhookSecret: webhookSecretFlag }) => + Effect.gen(function* () { + const webhookSecret = Option.isSome(webhookSecretFlag) + ? webhookSecretFlag.value + : process.env.POLAR_WEBHOOK_SECRET ?? null; + const environment = yield* resolveEnvironment(envFlag); + const isPersonalToken = + Option.isSome(accessTokenFlag) || !!process.env.POLAR_ACCESS_TOKEN; + const accessToken = yield* resolveAccessToken( + accessTokenFlag, + environment, + ); + const organization = yield* resolveOrganization( + orgFlag, + environment, + accessToken, + isPersonalToken, + ); + const listenUrl = `${LISTEN_BASE_URLS[environment]}/${organization.id}`; + + const startListening = (token: string) => + Effect.async((resume) => { + const eventSource = new EventSource(listenUrl, { + fetch: (input, init) => + fetch(input, { + ...init, + headers: { + ...init.headers, + Authorization: `Bearer ${token}`, + }, + }), + }); + + eventSource.onmessage = (event) => { + const json = JSON.parse(event.data); + const ack = Schema.decodeUnknownEither(ListenAck)(json); + + if (Either.isRight(ack)) { + const { secret } = ack.right; + const dim = "\x1b[2m"; + const bold = "\x1b[1m"; const cyan = "\x1b[36m"; const reset = "\x1b[0m"; + + console.log(""); console.log( - `>> '${cyan}${webhookEvent.right.payload.payload.type}${reset}' >> ${res.status} ${res.statusText}`, + ` ${bold}${cyan}Connected${reset} ${bold}${organization.name}${reset}`, ); + console.log(` ${dim}Secret${reset} ${secret}`); + console.log(` ${dim}Forwarding${reset} ${url}`); + if (webhookSecret) { + console.log(` ${dim}Signing${reset} enabled (--webhook-secret)`); + } + console.log(""); + console.log(` ${dim}Waiting for events...${reset}`); + console.log(""); + + return; + } + + const webhookEvent = + Schema.decodeUnknownEither(ListenWebhookEvent)(json); + + if (Either.isLeft(webhookEvent)) { + console.error(">> Failed to decode event"); + return; + } + + const forwardBody = JSON.stringify(webhookEvent.right.payload.payload); + const forwardHeaders = webhookSecret + ? signPayload(forwardBody, webhookSecret) + : webhookEvent.right.headers; + + fetch(url, { + method: "POST", + headers: forwardHeaders, + body: forwardBody, }) - .catch((err) => { - console.error(`>> Failed to forward event: ${err}`); - }); - }; - - eventSource.onerror = (error) => { - eventSource.close(); - resume( - Effect.fail( - new OAuth.OAuthError({ - message: - error.message ?? - (error.code - ? `Event stream error (${error.code})` - : "Event stream error"), - cause: error, - }), - ), - ); - }; + .then((res) => { + const cyan = "\x1b[36m"; + const reset = "\x1b[0m"; + console.log( + `>> '${cyan}${webhookEvent.right.payload.payload.type}${reset}' >> ${res.status} ${res.statusText}`, + ); + }) + .catch((err) => { + console.error(`>> Failed to forward event: ${err}`); + }); + }; - return Effect.sync(() => { - eventSource.close(); + eventSource.onerror = (error) => { + eventSource.close(); + resume( + Effect.fail( + new OAuth.OAuthError({ + message: + error.message ?? + (error.code + ? `Event stream error (${error.code})` + : "Event stream error"), + cause: error, + }), + ), + ); + }; + + return Effect.sync(() => { + eventSource.close(); + }); }); - }); - const isUnauthorized = (error: OAuth.OAuthError) => - (typeof error.cause === "object" && - error.cause !== null && - "code" in error.cause && - (error.cause as { code?: number }).code === 401) || - error.message.includes("401"); - - const listenWithToken = ( - token: Token, - retried = false, - ): Effect.Effect => - startListening(Redacted.value(token.token)).pipe( - Effect.catchTag("OAuthError", (error) => { - if (retried || !isUnauthorized(error)) { - return Effect.fail(error); - } - - return oauth - .login(environment) - .pipe( - Effect.flatMap((newToken) => listenWithToken(newToken, true)), - ); - }), - ); + const isUnauthorized = (error: OAuth.OAuthError) => + (typeof error.cause === "object" && + error.cause !== null && + "code" in error.cause && + (error.cause as { code?: number }).code === 401) || + error.message.includes("401"); - const token = yield* oauth.resolveAccessToken(environment); - yield* listenWithToken(token); - }), + const listenWithRetry = ( + token: string, + retried = false, + ): Effect.Effect => + startListening(token).pipe( + Effect.catchTag("OAuthError", (error) => { + if (retried || !isUnauthorized(error)) { + return Effect.fail(error); + } + + if (isPersonalToken) { + // Can't refresh a personal access token — just fail + return Effect.fail( + new OAuth.OAuthError({ + message: + "Access token was rejected (401). Check that your token is valid and has the required scopes.", + }), + ); + } + + // OAuth flow: try re-login + return Effect.gen(function* () { + const oauth = yield* OAuth.OAuth; + const newToken = yield* oauth.login(environment); + return yield* listenWithRetry( + Redacted.value(newToken.token), + true, + ); + }); + }), + ); + + yield* listenWithRetry(accessToken); + }), ); From 081341ff05157fa8fcc1a318ed4db625d459fd3a Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Mon, 6 Apr 2026 23:47:15 -0700 Subject: [PATCH 02/12] feat(listen): auto-reconnect on SSE disconnect with exponential backoff --- src/commands/listen.ts | 67 +++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/src/commands/listen.ts b/src/commands/listen.ts index 3cd8801..b4e876f 100644 --- a/src/commands/listen.ts +++ b/src/commands/listen.ts @@ -323,35 +323,60 @@ export const listen = Command.make( (error.cause as { code?: number }).code === 401) || error.message.includes("401"); + const RECONNECT_DELAY_MS = 3_000; + const MAX_RECONNECT_DELAY_MS = 30_000; + const listenWithRetry = ( token: string, - retried = false, + authRetried = false, + reconnectDelay = RECONNECT_DELAY_MS, ): Effect.Effect => startListening(token).pipe( Effect.catchTag("OAuthError", (error) => { - if (retried || !isUnauthorized(error)) { - return Effect.fail(error); - } + // 401 — try refreshing the token once + if (isUnauthorized(error)) { + if (authRetried) return Effect.fail(error); + + if (isPersonalToken) { + return Effect.fail( + new OAuth.OAuthError({ + message: + "Access token was rejected (401). Check that your token is valid and has the required scopes.", + }), + ); + } - if (isPersonalToken) { - // Can't refresh a personal access token — just fail - return Effect.fail( - new OAuth.OAuthError({ - message: - "Access token was rejected (401). Check that your token is valid and has the required scopes.", - }), - ); + return Effect.gen(function* () { + const oauth = yield* OAuth.OAuth; + const newToken = yield* oauth.login(environment); + return yield* listenWithRetry( + Redacted.value(newToken.token), + true, + ); + }); } - // OAuth flow: try re-login - return Effect.gen(function* () { - const oauth = yield* OAuth.OAuth; - const newToken = yield* oauth.login(environment); - return yield* listenWithRetry( - Redacted.value(newToken.token), - true, - ); - }); + // Non-auth error (SSE disconnect, network hiccup) — reconnect + const yellow = "\x1b[33m"; + const dim = "\x1b[2m"; + const reset = "\x1b[0m"; + const delaySec = (reconnectDelay / 1000).toFixed(0); + console.error( + `\n ${yellow}Connection lost${reset} ${dim}(${error.message})${reset}`, + ); + console.error( + ` ${dim}Reconnecting in ${delaySec}s...${reset}\n`, + ); + + const nextDelay = Math.min( + reconnectDelay * 2, + MAX_RECONNECT_DELAY_MS, + ); + return Effect.sleep(reconnectDelay).pipe( + Effect.flatMap(() => + listenWithRetry(token, false, nextDelay), + ), + ); }), ); From cf74616c4f641e1054ea9fde640a3cbc95c42baf Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Mon, 6 Apr 2026 23:57:16 -0700 Subject: [PATCH 03/12] fix(listen): log event type and payload on decode failure --- src/commands/listen.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands/listen.ts b/src/commands/listen.ts index b4e876f..fe75f60 100644 --- a/src/commands/listen.ts +++ b/src/commands/listen.ts @@ -269,7 +269,11 @@ export const listen = Command.make( Schema.decodeUnknownEither(ListenWebhookEvent)(json); if (Either.isLeft(webhookEvent)) { - console.error(">> Failed to decode event"); + const dim = "\x1b[2m"; + const reset = "\x1b[0m"; + const type = json?.type ?? json?.payload?.type ?? "unknown"; + console.error(`>> Failed to decode event: ${dim}${type}${reset}`); + console.error(` ${dim}${JSON.stringify(json).slice(0, 200)}${reset}`); return; } From 703cdd80fd76fa6a0a0fe101aaf8ecae216679d9 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Mon, 6 Apr 2026 23:59:14 -0700 Subject: [PATCH 04/12] add Dockerfile for local dev relay --- Dockerfile | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7d45a2c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM node:22-slim + +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN corepack enable && pnpm install --frozen-lockfile +COPY . . +RUN pnpm run build + +ENTRYPOINT ["node", "bin/cli.js"] From 8c77f603959b1eef96f5eaf2004eadd11f47bcb4 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Mon, 6 Apr 2026 23:59:46 -0700 Subject: [PATCH 05/12] add .dockerignore --- .dockerignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d19086a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +bin +.git From dfd813ab1b4e3565fcdca406cd6383dd2c6e8846 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 7 Apr 2026 00:04:23 -0700 Subject: [PATCH 06/12] ci: publish Docker image to Docker Hub on push --- .github/workflows/docker.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..df435cf --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,23 @@ +name: Docker + +on: + push: + branches: [feat/headless-listen, main] + +jobs: + push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + push: true + tags: | + jamierpond/polar-cli:latest + jamierpond/polar-cli:${{ github.sha }} From 35337f839f560365b72f93c6b0789aa2ccd2c6e5 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 7 Apr 2026 00:18:07 -0700 Subject: [PATCH 07/12] fix: correct Docker Hub username (jamiepond not jamierpond) --- .github/workflows/docker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index df435cf..1350f3e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,5 +19,5 @@ jobs: with: push: true tags: | - jamierpond/polar-cli:latest - jamierpond/polar-cli:${{ github.sha }} + jamiepond/polar-cli:latest + jamiepond/polar-cli:${{ github.sha }} From d78db4b09e2a3e9ddeea487d4c754b615c558afe Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 7 Apr 2026 00:22:04 -0700 Subject: [PATCH 08/12] user vars --- .github/workflows/docker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1350f3e..6ff7ec5 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,5 +19,5 @@ jobs: with: push: true tags: | - jamiepond/polar-cli:latest - jamiepond/polar-cli:${{ github.sha }} + ${{ vars.DOCKERHUB_USERNAME }}/polar-cli:latest + ${{ vars.DOCKERHUB_USERNAME }}/polar-cli:${{ github.sha }} From bead48c0e6a618eaf64bf563440455d7448c6f2e Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 7 Apr 2026 00:28:26 -0700 Subject: [PATCH 09/12] fix: handle whsec_ and base64 secret formats, reset backoff on reconnect --- src/commands/listen.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/commands/listen.ts b/src/commands/listen.ts index fe75f60..ee6f922 100644 --- a/src/commands/listen.ts +++ b/src/commands/listen.ts @@ -54,9 +54,26 @@ const webhookSecretOption = Options.text("webhook-secret").pipe( /** * Re-sign a webhook payload using the standardwebhooks format. - * This matches how @polar-sh/sdk's `validateEvent` verifies signatures: - * HMAC-SHA256 with base64(secret) as the key, over "msgId.timestamp.body". + * HMAC-SHA256 over "msgId.timestamp.body". + * + * The secret can be: + * - "whsec_" (Polar dashboard format) — strip prefix, base64-decode + * - raw base64 string — base64-decode + * - plain string (e.g. hex from `polar listen`) — use UTF-8 bytes directly */ +function decodeSecret(secret: string): Buffer { + if (secret.startsWith("whsec_")) { + return Buffer.from(secret.slice(6), "base64"); + } + // Try base64 decode — if it round-trips cleanly, it's base64-encoded + const decoded = Buffer.from(secret, "base64"); + if (decoded.length > 0 && decoded.toString("base64") === secret) { + return decoded; + } + // Plain string (hex, etc.) — use raw bytes + return Buffer.from(secret, "utf-8"); +} + function signPayload( body: string, secret: string, @@ -64,8 +81,8 @@ function signPayload( const msgId = `msg_${randomUUID()}`; const timestamp = Math.floor(Date.now() / 1000).toString(); const toSign = `${msgId}.${timestamp}.${body}`; - const key = Buffer.from(secret, "utf-8").toString("base64"); - const sig = createHmac("sha256", Buffer.from(key, "base64")) + const key = decodeSecret(secret); + const sig = createHmac("sha256", key) .update(toSign) .digest("base64"); return { @@ -376,9 +393,11 @@ export const listen = Command.make( reconnectDelay * 2, MAX_RECONNECT_DELAY_MS, ); + // Reset backoff to initial delay on reconnect — if the new + // connection stays healthy, the next disconnect starts fresh. return Effect.sleep(reconnectDelay).pipe( Effect.flatMap(() => - listenWithRetry(token, false, nextDelay), + listenWithRetry(token, false, RECONNECT_DELAY_MS), ), ); }), From 2fbd99aa9d52da8d672c505fee0fde82e6a7c9ac Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 7 Apr 2026 00:35:16 -0700 Subject: [PATCH 10/12] more platforms --- .github/workflows/docker.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 6ff7ec5..62c2ce8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -10,6 +10,8 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -18,6 +20,7 @@ jobs: - uses: docker/build-push-action@v6 with: push: true + platforms: linux/amd64,linux/arm64 tags: | ${{ vars.DOCKERHUB_USERNAME }}/polar-cli:latest ${{ vars.DOCKERHUB_USERNAME }}/polar-cli:${{ github.sha }} From da4998011a9f713faf512594b8a921f18e84bb84 Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 7 Apr 2026 16:54:38 -0700 Subject: [PATCH 11/12] fmt + tests --- package.json | 2 +- src/commands/listen.test.ts | 104 ++++++ src/commands/listen.ts | 665 ++++++++++++++++++------------------ 3 files changed, 434 insertions(+), 337 deletions(-) create mode 100644 src/commands/listen.test.ts diff --git a/package.json b/package.json index d6785f4..661346d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "tsup ./src/cli.ts --format esm --outDir bin", "dev": "tsc --watch", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "bun test", "check": "biome check --write ./src", "build:binary": "bun build ./src/cli.ts --compile --outfile polar", "build:binary:darwin-arm64": "bun build ./src/cli.ts --compile --target=bun-darwin-arm64 --outfile polar", diff --git a/src/commands/listen.test.ts b/src/commands/listen.test.ts new file mode 100644 index 0000000..d8ccbc0 --- /dev/null +++ b/src/commands/listen.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "bun:test"; +import { Webhook } from "standardwebhooks"; +import { signPayload } from "./listen"; + +/** + * These tests verify that signPayload produces signatures compatible with + * both the @polar-sh/sdk's validateEvent and raw standardwebhooks verification. + * + * The key derivation chain in @polar-sh/sdk's validateEvent is: + * 1. base64Encode(secret) → pass to new Webhook() + * 2. Webhook constructor base64Decodes → raw UTF-8 bytes of secret + * 3. HMAC-SHA256 with those bytes + * + * signPayload must produce the same HMAC, so it uses Buffer.from(secret, "utf-8") + * directly as the key. + */ + +function verifyWithStandardWebhooks( + body: string, + headers: Record, + secret: string, +): unknown { + // Replicate what @polar-sh/sdk's validateEvent does: + // base64-encode the raw secret, pass to Webhook constructor + const base64Secret = Buffer.from(secret, "utf-8").toString("base64"); + const wh = new Webhook(base64Secret); + return wh.verify(body, headers); +} + +describe("signPayload", () => { + it("produces headers that pass standardwebhooks verification", () => { + const secret = "test-webhook-secret"; + const body = JSON.stringify({ + type: "checkout.created", + data: { id: "123" }, + }); + + const headers = signPayload(body, secret); + + expect(headers["webhook-id"]).toStartWith("msg_"); + expect(headers["webhook-timestamp"]).toBeDefined(); + expect(headers["webhook-signature"]).toStartWith("v1,"); + expect(headers["content-type"]).toBe("application/json"); + + // The signature must be verifiable using the same key derivation as validateEvent + const parsed = verifyWithStandardWebhooks(body, headers, secret); + expect(parsed).toEqual(JSON.parse(body)); + }); + + it("works with secrets containing special characters", () => { + const secret = "s3cr3t!@#$%^&*()_+-="; + const body = JSON.stringify({ type: "order.created", data: {} }); + + const headers = signPayload(body, secret); + const parsed = verifyWithStandardWebhooks(body, headers, secret); + expect(parsed).toEqual(JSON.parse(body)); + }); + + it("works with long secrets", () => { + const secret = "a".repeat(256); + const body = JSON.stringify({ + type: "subscription.active", + data: { id: "sub_1" }, + }); + + const headers = signPayload(body, secret); + const parsed = verifyWithStandardWebhooks(body, headers, secret); + expect(parsed).toEqual(JSON.parse(body)); + }); + + it("fails verification with a different secret", () => { + const body = JSON.stringify({ type: "checkout.created", data: {} }); + const headers = signPayload(body, "correct-secret"); + + expect(() => + verifyWithStandardWebhooks(body, headers, "wrong-secret"), + ).toThrow(); + }); + + it("fails verification if body is tampered", () => { + const secret = "test-secret"; + const body = JSON.stringify({ + type: "checkout.created", + data: { id: "123" }, + }); + const headers = signPayload(body, secret); + + const tampered = JSON.stringify({ + type: "checkout.created", + data: { id: "456" }, + }); + expect(() => + verifyWithStandardWebhooks(tampered, headers, secret), + ).toThrow(); + }); + + it("produces unique message IDs per call", () => { + const body = JSON.stringify({ type: "test", data: {} }); + const h1 = signPayload(body, "secret"); + const h2 = signPayload(body, "secret"); + + expect(h1["webhook-id"]).not.toBe(h2["webhook-id"]); + }); +}); diff --git a/src/commands/listen.ts b/src/commands/listen.ts index ee6f922..b668757 100644 --- a/src/commands/listen.ts +++ b/src/commands/listen.ts @@ -1,96 +1,81 @@ -import { Args, Command, Options } from "@effect/cli"; import { createHmac, randomUUID } from "node:crypto"; +import { Args, Command, Options } from "@effect/cli"; import { Effect, Either, Option, Redacted, Schema } from "effect"; import { EventSource } from "eventsource"; import { environmentPrompt } from "../prompts/environment"; import { organizationLoginPrompt } from "../prompts/organizations"; import { ListenAck, ListenWebhookEvent } from "../schemas/Events"; -import type { Token } from "../schemas/Tokens"; import * as OAuth from "../services/oauth"; -import * as Polar from "../services/polar"; +import type * as Polar from "../services/polar"; const LISTEN_BASE_URLS = { - production: "https://api.polar.sh/v1/cli/listen", - sandbox: "https://sandbox-api.polar.sh/v1/cli/listen", + production: "https://api.polar.sh/v1/cli/listen", + sandbox: "https://sandbox-api.polar.sh/v1/cli/listen", } as const; const API_BASE_URLS = { - production: "https://api.polar.sh", - sandbox: "https://sandbox-api.polar.sh", + production: "https://api.polar.sh", + sandbox: "https://sandbox-api.polar.sh", } as const; const url = Args.text({ name: "url" }); const accessTokenOption = Options.text("access-token").pipe( - Options.optional, - Options.withDescription( - "Personal access token (skips OAuth login). Can also be set via POLAR_ACCESS_TOKEN env var.", - ), + Options.optional, + Options.withDescription( + "Personal access token (skips OAuth login). Can also be set via POLAR_ACCESS_TOKEN env var.", + ), ); const envOption = Options.choice("env", ["sandbox", "production"]).pipe( - Options.optional, - Options.withDescription( - "Environment to use (skips interactive prompt). Can also be set via POLAR_ENVIRONMENT env var.", - ), + Options.optional, + Options.withDescription( + "Environment to use (skips interactive prompt). Can also be set via POLAR_ENVIRONMENT env var.", + ), ); const orgOption = Options.text("org").pipe( - Options.optional, - Options.withDescription( - "Organization slug or ID (skips interactive prompt). Auto-selects if only one org exists.", - ), + Options.optional, + Options.withDescription( + "Organization slug or ID (skips interactive prompt). Auto-selects if only one org exists.", + ), ); const webhookSecretOption = Options.text("webhook-secret").pipe( - Options.optional, - Options.withDescription( - "Webhook secret to re-sign forwarded payloads (standardwebhooks format). " + - "Can also be set via POLAR_WEBHOOK_SECRET env var. " + - "When provided, the relay re-signs each payload so the receiving app's " + - "signature verification passes.", - ), + Options.optional, + Options.withDescription( + "Webhook secret to re-sign forwarded payloads (standardwebhooks format). " + + "Can also be set via POLAR_WEBHOOK_SECRET env var. " + + "When provided, the relay re-signs each payload so the receiving app's " + + "signature verification passes.", + ), ); /** * Re-sign a webhook payload using the standardwebhooks format. - * HMAC-SHA256 over "msgId.timestamp.body". * - * The secret can be: - * - "whsec_" (Polar dashboard format) — strip prefix, base64-decode - * - raw base64 string — base64-decode - * - plain string (e.g. hex from `polar listen`) — use UTF-8 bytes directly + * Key derivation must match @polar-sh/sdk's `validateEvent`, which does: + * base64Secret = Buffer.from(secret, "utf-8").toString("base64") + * new Webhook(base64Secret) // internally: base64.decode(base64Secret) + * + * The round-trip means the effective HMAC key is always the raw UTF-8 bytes + * of the secret string. We replicate that directly here. */ -function decodeSecret(secret: string): Buffer { - if (secret.startsWith("whsec_")) { - return Buffer.from(secret.slice(6), "base64"); - } - // Try base64 decode — if it round-trips cleanly, it's base64-encoded - const decoded = Buffer.from(secret, "base64"); - if (decoded.length > 0 && decoded.toString("base64") === secret) { - return decoded; - } - // Plain string (hex, etc.) — use raw bytes - return Buffer.from(secret, "utf-8"); -} - -function signPayload( - body: string, - secret: string, +export function signPayload( + body: string, + secret: string, ): Record { - const msgId = `msg_${randomUUID()}`; - const timestamp = Math.floor(Date.now() / 1000).toString(); - const toSign = `${msgId}.${timestamp}.${body}`; - const key = decodeSecret(secret); - const sig = createHmac("sha256", key) - .update(toSign) - .digest("base64"); - return { - "webhook-id": msgId, - "webhook-timestamp": timestamp, - "webhook-signature": `v1,${sig}`, - "content-type": "application/json", - }; + const msgId = `msg_${randomUUID()}`; + const timestamp = Math.floor(Date.now() / 1000).toString(); + const toSign = `${msgId}.${timestamp}.${body}`; + const key = Buffer.from(secret, "utf-8"); + const sig = createHmac("sha256", key).update(toSign).digest("base64"); + return { + "webhook-id": msgId, + "webhook-timestamp": timestamp, + "webhook-signature": `v1,${sig}`, + "content-type": "application/json", + }; } /** @@ -100,16 +85,16 @@ function signPayload( * 3. Interactive prompt */ const resolveEnvironment = ( - envFlag: Option.Option, + envFlag: Option.Option, ): Effect.Effect => { - if (Option.isSome(envFlag)) { - return Effect.succeed(envFlag.value as OAuth.PolarEnvironment); - } - const envVar = process.env.POLAR_ENVIRONMENT; - if (envVar === "sandbox" || envVar === "production") { - return Effect.succeed(envVar); - } - return environmentPrompt; + if (Option.isSome(envFlag)) { + return Effect.succeed(envFlag.value as OAuth.PolarEnvironment); + } + const envVar = process.env.POLAR_ENVIRONMENT; + if (envVar === "sandbox" || envVar === "production") { + return Effect.succeed(envVar); + } + return environmentPrompt; }; /** @@ -119,21 +104,21 @@ const resolveEnvironment = ( * 3. OAuth login flow */ const resolveAccessToken = ( - tokenFlag: Option.Option, - environment: OAuth.PolarEnvironment, + tokenFlag: Option.Option, + environment: OAuth.PolarEnvironment, ): Effect.Effect => { - if (Option.isSome(tokenFlag)) { - return Effect.succeed(tokenFlag.value); - } - const envVar = process.env.POLAR_ACCESS_TOKEN; - if (envVar) { - return Effect.succeed(envVar); - } - return Effect.gen(function* () { - const oauth = yield* OAuth.OAuth; - const token = yield* oauth.resolveAccessToken(environment); - return Redacted.value(token.token); - }); + if (Option.isSome(tokenFlag)) { + return Effect.succeed(tokenFlag.value); + } + const envVar = process.env.POLAR_ACCESS_TOKEN; + if (envVar) { + return Effect.succeed(envVar); + } + return Effect.gen(function* () { + const oauth = yield* OAuth.OAuth; + const token = yield* oauth.resolveAccessToken(environment); + return Redacted.value(token.token); + }); }; /** @@ -147,262 +132,270 @@ const resolveAccessToken = ( * 3. Interactive prompt (OAuth flow only) */ const resolveOrganization = ( - orgFlag: Option.Option, - environment: OAuth.PolarEnvironment, - accessToken: string, - isPersonalToken: boolean, + orgFlag: Option.Option, + environment: OAuth.PolarEnvironment, + accessToken: string, + isPersonalToken: boolean, ): Effect.Effect< - { id: string; slug: string; name: string }, - OAuth.OAuthError | Polar.PolarError + { id: string; slug: string; name: string }, + OAuth.OAuthError | Polar.PolarError > => { - if (isPersonalToken) { - // When using a personal access token, fetch orgs directly via API - return Effect.gen(function* () { - const baseUrl = API_BASE_URLS[environment]; - const res = yield* Effect.tryPromise({ - try: () => - fetch(`${baseUrl}/v1/organizations?page=1&limit=100`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }).then(async (r) => { - if (!r.ok) - throw new Error(`${r.status} ${await r.text()}`); - return r.json() as Promise<{ - items: Array<{ id: string; slug: string; name: string }>; - }>; - }), - catch: (error) => - new OAuth.OAuthError({ - message: `Failed to fetch organizations: ${error}`, - cause: error, - }), - }); - - const orgs = res.items; - if (orgs.length === 0) { - return yield* Effect.fail( - new OAuth.OAuthError({ - message: "No organizations found for this access token", - }), - ); - } - - // If --org flag provided, match by slug or ID - if (Option.isSome(orgFlag)) { - const match = orgs.find( - (o) => o.slug === orgFlag.value || o.id === orgFlag.value, - ); - if (!match) { - return yield* Effect.fail( - new OAuth.OAuthError({ - message: `Organization "${orgFlag.value}" not found. Available: ${orgs.map((o) => o.slug).join(", ")}`, - }), - ); - } - return match; - } - - // Auto-select if only one org - if (orgs.length === 1) { - return orgs[0]; - } - - // Multiple orgs, no flag — fail with helpful message in headless mode - return yield* Effect.fail( - new OAuth.OAuthError({ - message: `Multiple organizations found. Use --org to select one: ${orgs.map((o) => o.slug).join(", ")}`, - }), - ); - }); - } - - // OAuth flow — use the interactive prompt - return organizationLoginPrompt(environment); + if (isPersonalToken) { + // When using a personal access token, fetch orgs directly via API + return Effect.gen(function* () { + const baseUrl = API_BASE_URLS[environment]; + const res = yield* Effect.tryPromise({ + try: () => + fetch(`${baseUrl}/v1/organizations?page=1&limit=100`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }).then(async (r) => { + if (!r.ok) throw new Error(`${r.status} ${await r.text()}`); + return r.json() as Promise<{ + items: Array<{ id: string; slug: string; name: string }>; + }>; + }), + catch: (error) => + new OAuth.OAuthError({ + message: `Failed to fetch organizations: ${error}`, + cause: error, + }), + }); + + const orgs = res.items; + if (orgs.length === 0) { + return yield* Effect.fail( + new OAuth.OAuthError({ + message: "No organizations found for this access token", + }), + ); + } + + // If --org flag provided, match by slug or ID + if (Option.isSome(orgFlag)) { + const match = orgs.find( + (o) => o.slug === orgFlag.value || o.id === orgFlag.value, + ); + if (!match) { + return yield* Effect.fail( + new OAuth.OAuthError({ + message: `Organization "${orgFlag.value}" not found. Available: ${orgs.map((o) => o.slug).join(", ")}`, + }), + ); + } + return match; + } + + // Auto-select if only one org + if (orgs.length === 1) { + return orgs[0]; + } + + // Multiple orgs, no flag — fail with helpful message in headless mode + return yield* Effect.fail( + new OAuth.OAuthError({ + message: `Multiple organizations found. Use --org to select one: ${orgs.map((o) => o.slug).join(", ")}`, + }), + ); + }); + } + + // OAuth flow — use the interactive prompt + return organizationLoginPrompt(environment); }; export const listen = Command.make( - "listen", - { url, accessToken: accessTokenOption, env: envOption, org: orgOption, webhookSecret: webhookSecretOption }, - ({ url, accessToken: accessTokenFlag, env: envFlag, org: orgFlag, webhookSecret: webhookSecretFlag }) => - Effect.gen(function* () { - const webhookSecret = Option.isSome(webhookSecretFlag) - ? webhookSecretFlag.value - : process.env.POLAR_WEBHOOK_SECRET ?? null; - const environment = yield* resolveEnvironment(envFlag); - const isPersonalToken = - Option.isSome(accessTokenFlag) || !!process.env.POLAR_ACCESS_TOKEN; - const accessToken = yield* resolveAccessToken( - accessTokenFlag, - environment, - ); - const organization = yield* resolveOrganization( - orgFlag, - environment, - accessToken, - isPersonalToken, - ); - const listenUrl = `${LISTEN_BASE_URLS[environment]}/${organization.id}`; - - const startListening = (token: string) => - Effect.async((resume) => { - const eventSource = new EventSource(listenUrl, { - fetch: (input, init) => - fetch(input, { - ...init, - headers: { - ...init.headers, - Authorization: `Bearer ${token}`, - }, - }), - }); - - eventSource.onmessage = (event) => { - const json = JSON.parse(event.data); - const ack = Schema.decodeUnknownEither(ListenAck)(json); - - if (Either.isRight(ack)) { - const { secret } = ack.right; - const dim = "\x1b[2m"; - const bold = "\x1b[1m"; - const cyan = "\x1b[36m"; - const reset = "\x1b[0m"; - - console.log(""); - console.log( - ` ${bold}${cyan}Connected${reset} ${bold}${organization.name}${reset}`, - ); - console.log(` ${dim}Secret${reset} ${secret}`); - console.log(` ${dim}Forwarding${reset} ${url}`); - if (webhookSecret) { - console.log(` ${dim}Signing${reset} enabled (--webhook-secret)`); - } - console.log(""); - console.log(` ${dim}Waiting for events...${reset}`); - console.log(""); - - return; - } - - const webhookEvent = - Schema.decodeUnknownEither(ListenWebhookEvent)(json); - - if (Either.isLeft(webhookEvent)) { - const dim = "\x1b[2m"; - const reset = "\x1b[0m"; - const type = json?.type ?? json?.payload?.type ?? "unknown"; - console.error(`>> Failed to decode event: ${dim}${type}${reset}`); - console.error(` ${dim}${JSON.stringify(json).slice(0, 200)}${reset}`); - return; - } - - const forwardBody = JSON.stringify(webhookEvent.right.payload.payload); - const forwardHeaders = webhookSecret - ? signPayload(forwardBody, webhookSecret) - : webhookEvent.right.headers; - - fetch(url, { - method: "POST", - headers: forwardHeaders, - body: forwardBody, - }) - .then((res) => { - const cyan = "\x1b[36m"; - const reset = "\x1b[0m"; - console.log( - `>> '${cyan}${webhookEvent.right.payload.payload.type}${reset}' >> ${res.status} ${res.statusText}`, - ); - }) - .catch((err) => { - console.error(`>> Failed to forward event: ${err}`); - }); - }; - - eventSource.onerror = (error) => { - eventSource.close(); - resume( - Effect.fail( - new OAuth.OAuthError({ - message: - error.message ?? - (error.code - ? `Event stream error (${error.code})` - : "Event stream error"), - cause: error, - }), - ), - ); - }; - - return Effect.sync(() => { - eventSource.close(); - }); - }); - - const isUnauthorized = (error: OAuth.OAuthError) => - (typeof error.cause === "object" && - error.cause !== null && - "code" in error.cause && - (error.cause as { code?: number }).code === 401) || - error.message.includes("401"); - - const RECONNECT_DELAY_MS = 3_000; - const MAX_RECONNECT_DELAY_MS = 30_000; - - const listenWithRetry = ( - token: string, - authRetried = false, - reconnectDelay = RECONNECT_DELAY_MS, - ): Effect.Effect => - startListening(token).pipe( - Effect.catchTag("OAuthError", (error) => { - // 401 — try refreshing the token once - if (isUnauthorized(error)) { - if (authRetried) return Effect.fail(error); - - if (isPersonalToken) { - return Effect.fail( - new OAuth.OAuthError({ - message: - "Access token was rejected (401). Check that your token is valid and has the required scopes.", - }), - ); - } - - return Effect.gen(function* () { - const oauth = yield* OAuth.OAuth; - const newToken = yield* oauth.login(environment); - return yield* listenWithRetry( - Redacted.value(newToken.token), - true, - ); - }); - } - - // Non-auth error (SSE disconnect, network hiccup) — reconnect - const yellow = "\x1b[33m"; - const dim = "\x1b[2m"; - const reset = "\x1b[0m"; - const delaySec = (reconnectDelay / 1000).toFixed(0); - console.error( - `\n ${yellow}Connection lost${reset} ${dim}(${error.message})${reset}`, - ); - console.error( - ` ${dim}Reconnecting in ${delaySec}s...${reset}\n`, - ); - - const nextDelay = Math.min( - reconnectDelay * 2, - MAX_RECONNECT_DELAY_MS, - ); - // Reset backoff to initial delay on reconnect — if the new - // connection stays healthy, the next disconnect starts fresh. - return Effect.sleep(reconnectDelay).pipe( - Effect.flatMap(() => - listenWithRetry(token, false, RECONNECT_DELAY_MS), - ), - ); - }), - ); - - yield* listenWithRetry(accessToken); - }), + "listen", + { + url, + accessToken: accessTokenOption, + env: envOption, + org: orgOption, + webhookSecret: webhookSecretOption, + }, + ({ + url, + accessToken: accessTokenFlag, + env: envFlag, + org: orgFlag, + webhookSecret: webhookSecretFlag, + }) => + Effect.gen(function* () { + const webhookSecret = Option.isSome(webhookSecretFlag) + ? webhookSecretFlag.value + : (process.env.POLAR_WEBHOOK_SECRET ?? null); + const environment = yield* resolveEnvironment(envFlag); + const isPersonalToken = + Option.isSome(accessTokenFlag) || !!process.env.POLAR_ACCESS_TOKEN; + const accessToken = yield* resolveAccessToken( + accessTokenFlag, + environment, + ); + const organization = yield* resolveOrganization( + orgFlag, + environment, + accessToken, + isPersonalToken, + ); + const listenUrl = `${LISTEN_BASE_URLS[environment]}/${organization.id}`; + + const startListening = (token: string) => + Effect.async((resume) => { + const eventSource = new EventSource(listenUrl, { + fetch: (input, init) => + fetch(input, { + ...init, + headers: { + ...init.headers, + Authorization: `Bearer ${token}`, + }, + }), + }); + + eventSource.onmessage = (event) => { + const json = JSON.parse(event.data); + const ack = Schema.decodeUnknownEither(ListenAck)(json); + + if (Either.isRight(ack)) { + const { secret } = ack.right; + const dim = "\x1b[2m"; + const bold = "\x1b[1m"; + const cyan = "\x1b[36m"; + const reset = "\x1b[0m"; + + console.log(""); + console.log( + ` ${bold}${cyan}Connected${reset} ${bold}${organization.name}${reset}`, + ); + console.log(` ${dim}Secret${reset} ${secret}`); + console.log(` ${dim}Forwarding${reset} ${url}`); + if (webhookSecret) { + console.log( + ` ${dim}Signing${reset} enabled (--webhook-secret)`, + ); + } + console.log(""); + console.log(` ${dim}Waiting for events...${reset}`); + console.log(""); + + return; + } + + const webhookEvent = + Schema.decodeUnknownEither(ListenWebhookEvent)(json); + + if (Either.isLeft(webhookEvent)) { + const dim = "\x1b[2m"; + const reset = "\x1b[0m"; + const type = json?.type ?? json?.payload?.type ?? "unknown"; + console.error(`>> Failed to decode event: ${dim}${type}${reset}`); + console.error( + ` ${dim}${JSON.stringify(json).slice(0, 200)}${reset}`, + ); + return; + } + + const forwardBody = JSON.stringify( + webhookEvent.right.payload.payload, + ); + const forwardHeaders = webhookSecret + ? signPayload(forwardBody, webhookSecret) + : webhookEvent.right.headers; + + fetch(url, { + method: "POST", + headers: forwardHeaders, + body: forwardBody, + }) + .then((res) => { + const cyan = "\x1b[36m"; + const reset = "\x1b[0m"; + console.log( + `>> '${cyan}${webhookEvent.right.payload.payload.type}${reset}' >> ${res.status} ${res.statusText}`, + ); + }) + .catch((err) => { + console.error(`>> Failed to forward event: ${err}`); + }); + }; + + eventSource.onerror = (error) => { + eventSource.close(); + resume( + Effect.fail( + new OAuth.OAuthError({ + message: + error.message ?? + (error.code + ? `Event stream error (${error.code})` + : "Event stream error"), + cause: error, + }), + ), + ); + }; + + return Effect.sync(() => { + eventSource.close(); + }); + }); + + const isUnauthorized = (error: OAuth.OAuthError) => + (typeof error.cause === "object" && + error.cause !== null && + "code" in error.cause && + (error.cause as { code?: number }).code === 401) || + error.message.includes("401"); + + const RECONNECT_DELAY_MS = 3_000; + + const listenWithRetry = ( + token: string, + authRetried = false, + reconnectDelay = RECONNECT_DELAY_MS, + ): Effect.Effect => + startListening(token).pipe( + Effect.catchTag("OAuthError", (error) => { + // 401 — try refreshing the token once + if (isUnauthorized(error)) { + if (authRetried) return Effect.fail(error); + + if (isPersonalToken) { + return Effect.fail( + new OAuth.OAuthError({ + message: + "Access token was rejected (401). Check that your token is valid and has the required scopes.", + }), + ); + } + + return Effect.gen(function* () { + const oauth = yield* OAuth.OAuth; + const newToken = yield* oauth.login(environment); + return yield* listenWithRetry( + Redacted.value(newToken.token), + true, + ); + }); + } + + // Non-auth error (SSE disconnect, network hiccup) — reconnect + const yellow = "\x1b[33m"; + const dim = "\x1b[2m"; + const reset = "\x1b[0m"; + const delaySec = (reconnectDelay / 1000).toFixed(0); + console.error( + `\n ${yellow}Connection lost${reset} ${dim}(${error.message})${reset}`, + ); + console.error(` ${dim}Reconnecting in ${delaySec}s...${reset}\n`); + + return Effect.sleep(reconnectDelay).pipe( + Effect.flatMap(() => + listenWithRetry(token, false, RECONNECT_DELAY_MS), + ), + ); + }), + ); + + yield* listenWithRetry(accessToken); + }), ); From 6ed0ca1300a221842037c056f814e05ae94462bf Mon Sep 17 00:00:00 2001 From: Jamie Pond Date: Tue, 7 Apr 2026 17:01:28 -0700 Subject: [PATCH 12/12] sensible docker container --- Dockerfile | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7d45a2c..344221f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,13 @@ -FROM node:22-slim +FROM oven/bun AS builder WORKDIR /app -COPY package.json pnpm-lock.yaml ./ -RUN corepack enable && pnpm install --frozen-lockfile +COPY package.json bun.lock* pnpm-lock.yaml ./ +RUN bun install --frozen-lockfile COPY . . -RUN pnpm run build +RUN bun build ./src/cli.ts --compile --outfile polar -ENTRYPOINT ["node", "bin/cli.js"] +FROM debian:bookworm-slim + +COPY --from=builder /app/polar /usr/local/bin/polar + +ENTRYPOINT ["polar"]