From 66484caecf307bb822c4f30c088fe0edb4d0755b Mon Sep 17 00:00:00 2001 From: Pieter Beulque Date: Tue, 13 Oct 2026 13:09:25 +0200 Subject: [PATCH 1/8] feat(cli): create organization access tokens through the dashboard `polar auth token` opens /dashboard/:slug/cli/token for the active organization. The page creates the token with the web session, so the fresh-session requirement on token creation stays and an OAuth session still can't mint a token that outlives it. The CLI sends a one-time RSA public key and a state with the page link. The page encrypts the token in the browser and redirects to the CLI's loopback listener on 127.0.0.1:3333, which only ever receives ciphertext. The CLI decrypts it and writes it to .env (or --env-file) without printing it, so the token stays out of terminal and agent history. Opened without a key, the page shows the token once to copy. Along the way: - OAuth login and the token handoff share the loopback callback server (utils/loopback.ts) and the dashboard origin (dashboardOrigin in services/api.ts), so --local and POLAR_DASHBOARD_URL apply to both. - The create-token logic, including the fresh-session retry, moves out of CreateAccessTokenModal into useSubmitOrganizationAccessToken. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../[organization]/cli/token/CLITokenPage.tsx | 184 ++++++++++++++++++ .../[organization]/cli/token/handoff.test.ts | 66 +++++++ .../[organization]/cli/token/handoff.ts | 64 ++++++ .../[organization]/cli/token/page.tsx | 10 + .../Settings/CreateAccessTokenModal.tsx | 49 +---- .../useSubmitOrganizationAccessToken.ts | 59 ++++++ clients/packages/cli/README.md | 9 + clients/packages/cli/scripts/docs.ts | 2 + clients/packages/cli/src/cli.ts | 2 + .../packages/cli/src/commands/auth.test.ts | 5 + clients/packages/cli/src/commands/auth.ts | 3 +- .../packages/cli/src/commands/token.test.ts | 105 ++++++++++ clients/packages/cli/src/commands/token.ts | 153 +++++++++++++++ clients/packages/cli/src/services/api.ts | 25 ++- clients/packages/cli/src/services/oauth.ts | 143 +++----------- .../cli/src/services/token-handoff.test.ts | 106 ++++++++++ .../cli/src/services/token-handoff.ts | 115 +++++++++++ .../packages/cli/src/utils/callback-page.ts | 29 ++- .../packages/cli/src/utils/env-file.test.ts | 38 ++++ clients/packages/cli/src/utils/env-file.ts | 15 ++ clients/packages/cli/src/utils/loopback.ts | 93 +++++++++ docs/integrate/cli/reference/auth.mdx | 30 +++ 22 files changed, 1145 insertions(+), 160 deletions(-) create mode 100644 clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/CLITokenPage.tsx create mode 100644 clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/handoff.test.ts create mode 100644 clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/handoff.ts create mode 100644 clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/page.tsx create mode 100644 clients/apps/web/src/components/Settings/useSubmitOrganizationAccessToken.ts create mode 100644 clients/packages/cli/src/commands/token.test.ts create mode 100644 clients/packages/cli/src/commands/token.ts create mode 100644 clients/packages/cli/src/services/token-handoff.test.ts create mode 100644 clients/packages/cli/src/services/token-handoff.ts create mode 100644 clients/packages/cli/src/utils/env-file.test.ts create mode 100644 clients/packages/cli/src/utils/env-file.ts create mode 100644 clients/packages/cli/src/utils/loopback.ts diff --git a/clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/CLITokenPage.tsx b/clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/CLITokenPage.tsx new file mode 100644 index 00000000000..7f8c13b8ea4 --- /dev/null +++ b/clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/CLITokenPage.tsx @@ -0,0 +1,184 @@ +'use client' + +import { OnboardingLayout } from '@/components/Layout/OnboardingLayout' +import { PolarLogotype } from '@/components/Layout/Public/PolarLogotype' +import { + AccessTokenForm, + type AccessTokenCreate, +} from '@/components/Settings/OrganizationAccessTokensSettings' +import { useResumeOrganizationAccessTokenCreation } from '@/components/Settings/useResumeOrganizationAccessTokenCreation' +import { useSubmitOrganizationAccessToken } from '@/components/Settings/useSubmitOrganizationAccessToken' +import { toast } from '@/components/Toast/use-toast' +import { useAuth } from '@/hooks/auth' +import { OrganizationContext } from '@/providers/maintainerOrganization' +import { schemas } from '@polar-sh/client' +import { Alert, Avatar, Button, Text } from '@polar-sh/orbit' +import { Box } from '@polar-sh/orbit/Box' +import CopyToClipboardInput from '@polar-sh/ui/components/atoms/CopyToClipboardInput' +import { Form } from '@polar-sh/ui/components/ui/form' +import { useSearchParams } from 'next/navigation' +import { useCallback, useContext, useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' +import { + getCLICancelURL, + getCLIHandoff, + getCLITokenURL, + getRequestedScopes, +} from './handoff' + +const FORM_ID = 'cli-access-token' + +type Step = + | { name: 'form' } + | { name: 'sending' } + | { name: 'created'; token: string } + +export default function CLITokenPage() { + const { organization } = useContext(OrganizationContext) + const { currentUser } = useAuth() + const searchParams = useSearchParams() + const handoff = useMemo(() => getCLIHandoff(searchParams), [searchParams]) + const [step, setStep] = useState({ name: 'form' }) + const { submit, isPending } = useSubmitOrganizationAccessToken( + organization.id, + ) + + const form = useForm({ + defaultValues: { + comment: searchParams.get('name') ?? '', + expires_in: 'P30D', + scopes: getRequestedScopes(searchParams), + }, + }) + + const deliver = useCallback( + async (created: schemas['OrganizationAccessTokenCreateResponse']) => { + if (!handoff) { + setStep({ name: 'created', token: created.token }) + return + } + setStep({ name: 'sending' }) + try { + window.location.assign(await getCLITokenURL(handoff, created)) + } catch { + toast({ + title: 'Could not send the token to the CLI', + description: + 'The CLI link is invalid. Revoke the token in your settings and run polar auth token again.', + }) + setStep({ name: 'created', token: created.token }) + } + }, + [handoff], + ) + + useResumeOrganizationAccessTokenCreation({ + organizationId: organization.id, + onSuccess: deliver, + }) + + const onSubmit = useCallback( + async (data: AccessTokenCreate) => { + const created = await submit(data) + if (created) await deliver(created) + }, + [deliver, submit], + ) + + const actions = + step.name === 'form' ? ( + + {handoff && ( + + )} + + + ) : undefined + + return ( + + + + } + introduction={ + + Create an organization access token for{' '} + + {organization.name} + {' '} + to use in your app. + + } + footer={ + currentUser && ( + + + + + Signed in as + + {currentUser.email} + + + ) + } + title={ + step.name === 'created' + ? 'Copy your access token' + : 'Create access token' + } + subtitle={ + step.name === 'sending' + ? 'Sending the token to the Polar CLI…' + : step.name === 'created' + ? 'Store it somewhere safe, like your .env file. You won’t be able to see it again.' + : handoff + ? 'Pick the permissions your app needs. The token is encrypted and sent straight to the Polar CLI on this computer.' + : 'Pick the permissions your app needs.' + } + actions={actions} + > + {step.name === 'form' && ( +
+ + + +
+ )} + {step.name === 'created' && ( + + + + + )} +
+ ) +} diff --git a/clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/handoff.test.ts b/clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/handoff.test.ts new file mode 100644 index 00000000000..53dafcf9a4d --- /dev/null +++ b/clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/handoff.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + encryptForCLI, + getCLICancelURL, + getCLIHandoff, + getRequestedScopes, +} from './handoff' + +const generateCLIKeys = async () => { + const { publicKey, privateKey } = await crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['encrypt', 'decrypt'], + ) + const spki = await crypto.subtle.exportKey('spki', publicKey) + return { privateKey, publicKey: Buffer.from(spki).toString('base64url') } +} + +describe('getCLIHandoff', () => { + it('requires both the state and the public key', () => { + expect(getCLIHandoff(new URLSearchParams('state=s'))).toBeNull() + expect(getCLIHandoff(new URLSearchParams('public_key=k'))).toBeNull() + expect(getCLIHandoff(new URLSearchParams('state=s&public_key=k'))).toEqual({ + state: 's', + publicKey: 'k', + }) + }) +}) + +describe('getRequestedScopes', () => { + it('keeps only scopes an organization token can have', () => { + expect( + getRequestedScopes( + new URLSearchParams('scopes=products:read,web:write,nope'), + ), + ).toEqual(['products:read']) + }) +}) + +describe('encryptForCLI', () => { + it('encrypts the token so only the CLI private key can read it', async () => { + const { privateKey, publicKey } = await generateCLIKeys() + const encrypted = await encryptForCLI(publicKey, 'polar_oat_secret') + + expect(encrypted).not.toContain('polar_oat_secret') + const decrypted = await crypto.subtle.decrypt( + { name: 'RSA-OAEP' }, + privateKey, + Buffer.from(encrypted, 'base64url'), + ) + expect(new TextDecoder().decode(decrypted)).toBe('polar_oat_secret') + }) +}) + +describe('getCLICancelURL', () => { + it('only ever targets the local CLI', () => { + expect(getCLICancelURL({ state: 's', publicKey: 'k' })).toBe( + 'http://127.0.0.1:3333/token/callback?state=s&error=access_denied', + ) + }) +}) diff --git a/clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/handoff.ts b/clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/handoff.ts new file mode 100644 index 00000000000..fd16f34a389 --- /dev/null +++ b/clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/handoff.ts @@ -0,0 +1,64 @@ +import { enums, type schemas } from '@polar-sh/client' + +const CLI_CALLBACK_URL = 'http://127.0.0.1:3333/token/callback' + +export interface CLIHandoff { + state: string + publicKey: string +} + +export const getCLIHandoff = ( + searchParams: URLSearchParams, +): CLIHandoff | null => { + const state = searchParams.get('state') + const publicKey = searchParams.get('public_key') + return state && publicKey ? { state, publicKey } : null +} + +export const getRequestedScopes = (searchParams: URLSearchParams) => { + const requested = new Set(searchParams.get('scopes')?.split(',') ?? []) + return enums.availableScopeValues.filter((scope) => requested.has(scope)) +} + +const fromBase64Url = (value: string) => + Uint8Array.from( + atob(value.replace(/-/g, '+').replace(/_/g, '/')), + (character) => character.charCodeAt(0), + ) + +const toBase64Url = (bytes: ArrayBuffer) => + btoa(String.fromCharCode(...new Uint8Array(bytes))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + +export const encryptForCLI = async (publicKey: string, token: string) => { + const key = await crypto.subtle.importKey( + 'spki', + fromBase64Url(publicKey), + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['encrypt'], + ) + const encrypted = await crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + key, + new TextEncoder().encode(token), + ) + return toBase64Url(encrypted) +} + +const callbackURL = (params: Record) => + `${CLI_CALLBACK_URL}?${new URLSearchParams(params).toString()}` + +export const getCLITokenURL = async ( + { state, publicKey }: CLIHandoff, + created: schemas['OrganizationAccessTokenCreateResponse'], +) => + callbackURL({ + state, + encrypted_token: await encryptForCLI(publicKey, created.token), + }) + +export const getCLICancelURL = ({ state }: CLIHandoff) => + callbackURL({ state, error: 'access_denied' }) diff --git a/clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/page.tsx b/clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/page.tsx new file mode 100644 index 00000000000..3c23fc8dd3f --- /dev/null +++ b/clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/page.tsx @@ -0,0 +1,10 @@ +import { Metadata } from 'next' +import CLITokenPage from './CLITokenPage' + +export const metadata: Metadata = { + title: 'Create access token', +} + +export default function Page() { + return +} diff --git a/clients/apps/web/src/components/Settings/CreateAccessTokenModal.tsx b/clients/apps/web/src/components/Settings/CreateAccessTokenModal.tsx index bfd525c90d6..aa700effa69 100644 --- a/clients/apps/web/src/components/Settings/CreateAccessTokenModal.tsx +++ b/clients/apps/web/src/components/Settings/CreateAccessTokenModal.tsx @@ -1,27 +1,16 @@ 'use client' -import { promptSessionRefresh } from '@/components/SessionRefresh/store' -import { useAuth } from '@/hooks/auth' -import { useCreateOrganizationAccessToken } from '@/hooks/queries' -import { - extractApiErrorMessage, - isSessionNotFreshError, -} from '@/utils/api/errors' import { InlineModalHeader } from '@polar-sh/orbit' import { schemas } from '@polar-sh/client' import { Button } from '@polar-sh/orbit' import { Form } from '@polar-sh/ui/components/ui/form' import { useCallback } from 'react' import { useForm } from 'react-hook-form' -import { toast } from '../Toast/use-toast' -import { - getOrganizationAccessTokenResumePath, - savePendingOrganizationAccessTokenCreation, -} from './organizationAccessTokenContinuation' import { AccessTokenForm, type AccessTokenCreate, } from './OrganizationAccessTokensSettings' +import { useSubmitOrganizationAccessToken } from './useSubmitOrganizationAccessToken' interface CreateAccessTokenModalProps { organization: schemas['Organization'] @@ -36,8 +25,9 @@ export const CreateAccessTokenModal = ({ onHide, title = 'Create Organization Token', }: CreateAccessTokenModalProps) => { - const { currentUser } = useAuth() - const createToken = useCreateOrganizationAccessToken(organization.id) + const { submit, isPending } = useSubmitOrganizationAccessToken( + organization.id, + ) const form = useForm({ defaultValues: { comment: '', @@ -49,38 +39,13 @@ export const CreateAccessTokenModal = ({ const onCreate = useCallback( async (data: AccessTokenCreate) => { - const body = { - comment: data.comment ? data.comment : '', - expires_in: - data.expires_in === 'no-expiration' ? null : data.expires_in, - scopes: data.scopes, - } - const { data: created, error } = await createToken.mutateAsync(body) + const created = await submit(data) if (created) { onSuccess(created) reset({ scopes: [] }) - createToken.reset() - } else if (error) { - if (isSessionNotFreshError(error) && currentUser) { - const actionId = savePendingOrganizationAccessTokenCreation( - organization.id, - currentUser.id, - body, - ) - if (actionId) { - promptSessionRefresh({ - returnTo: getOrganizationAccessTokenResumePath(actionId), - }) - return - } - } - toast({ - title: 'Could not create access token', - description: extractApiErrorMessage(error), - }) } }, - [createToken, currentUser, onSuccess, organization.id, reset], + [onSuccess, reset, submit], ) return ( @@ -97,7 +62,7 @@ export const CreateAccessTokenModal = ({ className="max-w-[700px] space-y-8" > - diff --git a/clients/apps/web/src/components/Settings/useSubmitOrganizationAccessToken.ts b/clients/apps/web/src/components/Settings/useSubmitOrganizationAccessToken.ts new file mode 100644 index 00000000000..e6bc2362d18 --- /dev/null +++ b/clients/apps/web/src/components/Settings/useSubmitOrganizationAccessToken.ts @@ -0,0 +1,59 @@ +'use client' + +import { promptSessionRefresh } from '@/components/SessionRefresh/store' +import { useAuth } from '@/hooks/auth' +import { useCreateOrganizationAccessToken } from '@/hooks/queries' +import { + extractApiErrorMessage, + isSessionNotFreshError, +} from '@/utils/api/errors' +import { useCallback } from 'react' +import { toast } from '../Toast/use-toast' +import { + getOrganizationAccessTokenResumePath, + savePendingOrganizationAccessTokenCreation, +} from './organizationAccessTokenContinuation' +import type { AccessTokenCreate } from './OrganizationAccessTokensSettings' + +export const useSubmitOrganizationAccessToken = (organizationId: string) => { + const { currentUser } = useAuth() + const createToken = useCreateOrganizationAccessToken(organizationId) + + const submit = useCallback( + async (data: AccessTokenCreate) => { + const body = { + comment: data.comment ? data.comment : '', + expires_in: + data.expires_in === 'no-expiration' ? null : data.expires_in, + scopes: data.scopes, + } + const { data: created, error } = await createToken.mutateAsync(body) + if (created) { + createToken.reset() + return created + } + if (!error) return undefined + if (isSessionNotFreshError(error) && currentUser) { + const actionId = savePendingOrganizationAccessTokenCreation( + organizationId, + currentUser.id, + body, + ) + if (actionId) { + promptSessionRefresh({ + returnTo: getOrganizationAccessTokenResumePath(actionId), + }) + return undefined + } + } + toast({ + title: 'Could not create access token', + description: extractApiErrorMessage(error), + }) + return undefined + }, + [createToken, currentUser, organizationId], + ) + + return { submit, isPending: createToken.isPending } +} diff --git a/clients/packages/cli/README.md b/clients/packages/cli/README.md index b3c81b4d84f..19a693a9c88 100644 --- a/clients/packages/cli/README.md +++ b/clients/packages/cli/README.md @@ -57,6 +57,15 @@ export POLAR_ACCESS_TOKEN=polar_oat_... bun src/cli.ts ``` +To create a token for an app against the local stack, sign in with +`auth login --local` (`dev seed` registers its OAuth client). `auth token` then +opens the local dashboard at `127.0.0.1:3000`: + +```bash +bun src/cli.ts auth login --local +bun src/cli.ts auth token --env-file /tmp/polar-test.env +``` + ### Generated API commands The CLI imports its API command tree from the private `@polar-sh/cli-commands` diff --git a/clients/packages/cli/scripts/docs.ts b/clients/packages/cli/scripts/docs.ts index 874d6471e04..52efb03e80f 100644 --- a/clients/packages/cli/scripts/docs.ts +++ b/clients/packages/cli/scripts/docs.ts @@ -9,6 +9,7 @@ import { Auth } from '@/services/auth' import { BillingConfig } from '@/services/billing-config/service' import { Deliveries } from '@/services/deliveries' import { Organizations } from '@/services/organizations' +import { TokenHandoff } from '@/services/token-handoff' import { Trigger } from '@/services/trigger' import { Updater } from '@/services/updater' import { captureConsole } from '@/utils/test-utils/cli' @@ -29,6 +30,7 @@ const helpOnlyServices = Layer.mergeAll( ), Layer.succeed(Organizations, fakeOrganizations().organizations), Layer.succeed(Trigger, Trigger.of({ listEvents: unused, send: unused })), + Layer.succeed(TokenHandoff, TokenHandoff.of({ receive: unused })), Layer.succeed(ApiRuntime, ApiRuntime.of({ execute: unused })), Layer.succeed(Deliveries, Deliveries.of({ record: unused, await: unused })), Layer.succeed( diff --git a/clients/packages/cli/src/cli.ts b/clients/packages/cli/src/cli.ts index 8bb01e7952d..6c929f55532 100644 --- a/clients/packages/cli/src/cli.ts +++ b/clients/packages/cli/src/cli.ts @@ -17,6 +17,7 @@ import * as Organizations from '@/services/organizations' import * as OAuth from '@/services/oauth' import * as Polar from '@/services/polar' import * as Telemetry from '@/services/telemetry' +import * as TokenHandoff from '@/services/token-handoff' import * as Trigger from '@/services/trigger' import { removeRetiredBinary } from '@/services/update' import { availableUpdate, checkForUpdate } from '@/services/update-check' @@ -63,6 +64,7 @@ const services = Layer.mergeAll( polarLayer, organizationsLayer, triggerLayer, + TokenHandoff.layer, updaterLayer, telemetryLayer, BunServices.layer, diff --git a/clients/packages/cli/src/commands/auth.test.ts b/clients/packages/cli/src/commands/auth.test.ts index 7f903013e54..04f9f7847e2 100644 --- a/clients/packages/cli/src/commands/auth.test.ts +++ b/clients/packages/cli/src/commands/auth.test.ts @@ -4,6 +4,7 @@ import { auth as authCommand } from '@/commands/auth' import type { ActiveOrganization } from '@/schemas/Auth' import { Auth } from '@/services/auth' import { Organizations } from '@/services/organizations' +import { TokenHandoff } from '@/services/token-handoff' import { keys, runCli, type RunCliOptions } from '@/utils/test-utils/cli' import { fakeAuth, @@ -33,6 +34,10 @@ const run = (args: string[], options?: RunCliOptions) => { cli.effect.pipe( Effect.provideService(Auth, auth.auth), Effect.provideService(Organizations, organizations.organizations), + Effect.provideService( + TokenHandoff, + TokenHandoff.of({ receive: () => Effect.die('unused') }), + ), ), ) return { promise, output: cli.output, terminal: cli.terminal } diff --git a/clients/packages/cli/src/commands/auth.ts b/clients/packages/cli/src/commands/auth.ts index 28f1a0629df..68b37b78b26 100644 --- a/clients/packages/cli/src/commands/auth.ts +++ b/clients/packages/cli/src/commands/auth.ts @@ -11,6 +11,7 @@ import { } from '@/schemas/Auth' import { Auth } from '@/services/auth' import { Organizations } from '@/services/organizations' +import { token } from '@/commands/token' import { output } from '@/utils/command' import { local, production, sandbox } from '@/utils/flags' import { note } from '@/utils/progress' @@ -466,5 +467,5 @@ const logout = Command.make('logout', { export const auth = Command.make('auth').pipe( Command.withDescription('Manage your Polar sessions and active organization'), - Command.withSubcommands([login, whoami, list, org, logout]), + Command.withSubcommands([login, whoami, list, org, token, logout]), ) diff --git a/clients/packages/cli/src/commands/token.test.ts b/clients/packages/cli/src/commands/token.test.ts new file mode 100644 index 00000000000..ca0cfe32f40 --- /dev/null +++ b/clients/packages/cli/src/commands/token.test.ts @@ -0,0 +1,105 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Effect, Redacted } from 'effect' +import { afterEach, beforeEach, expect, test } from 'vitest' +import { token as tokenCommand } from '@/commands/token' +import type { ActiveOrganization } from '@/schemas/Auth' +import { Organizations } from '@/services/organizations' +import { type TokenRequest, TokenHandoff } from '@/services/token-handoff' +import { runCli } from '@/utils/test-utils/cli' +import { fakeOrganizations } from '@/utils/test-utils/services' + +const acme: ActiveOrganization = { + id: 'org-1', + name: 'Acme', + slug: 'acme', + environment: 'sandbox', +} + +let dir: string +let requests: TokenRequest[] + +let organizations: ReturnType + +const run = (args: string[]) => { + const cli = runCli(tokenCommand, args) + const promise = Effect.runPromise( + cli.effect.pipe( + Effect.provideService(Organizations, organizations.organizations), + Effect.provideService( + TokenHandoff, + TokenHandoff.of({ + receive: (request) => + Effect.sync(() => { + requests.push(request) + return Redacted.make('polar_oat_secret') + }), + }), + ), + ), + ) + return { promise, output: cli.output } +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'polar-test-')) + requests = [] + organizations = fakeOrganizations({ + items: [acme], + selected: { id: acme.id, environment: acme.environment }, + }) +}) + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +test('writes the token to the env file without printing it', async () => { + const file = join(dir, '.env') + await writeFile(file, 'OTHER=1\nPOLAR_ACCESS_TOKEN=old\n') + const { promise, output } = run([ + '--env-file', + file, + '--scope', + 'products:read', + '--scope', + 'checkouts:write', + '--name', + 'my-app', + ]) + await promise + + expect(await readFile(file, 'utf8')).toBe( + 'OTHER=1\nPOLAR_ACCESS_TOKEN=polar_oat_secret\n', + ) + expect(requests).toEqual([ + { + organization: { slug: 'acme', environment: 'sandbox' }, + name: 'my-app', + scopes: ['products:read', 'checkouts:write'], + }, + ]) + expect(output()).toContain('Updated POLAR_ACCESS_TOKEN') + expect(output()).not.toContain('polar_oat_secret') +}) + +test('creates the env file under a custom variable name', async () => { + const file = join(dir, '.env.local') + const { promise, output } = run([ + '--env-file', + file, + '--env-var', + 'POLAR_OAT', + ]) + await promise + + expect(await readFile(file, 'utf8')).toBe('POLAR_OAT=polar_oat_secret\n') + expect(output()).toContain('Added POLAR_OAT') +}) + +test('rejects an invalid variable name before creating a token', async () => { + const { promise } = run(['--env-file', join(dir, '.env'), '--env-var', 'A=B']) + await expect(promise).rejects.toThrow('not a valid environment variable name') + expect(requests).toEqual([]) +}) diff --git a/clients/packages/cli/src/commands/token.ts b/clients/packages/cli/src/commands/token.ts new file mode 100644 index 00000000000..62c296f77a5 --- /dev/null +++ b/clients/packages/cli/src/commands/token.ts @@ -0,0 +1,153 @@ +import { Effect, FileSystem, Option, Path, Redacted, Schema } from 'effect' +import { Command, Flag } from 'effect/cli' +import { AuthError, PolarEnvironment } from '@/schemas/Auth' +import { Organizations } from '@/services/organizations' +import { TokenHandoff } from '@/services/token-handoff' +import { output } from '@/utils/command' +import { upsertEnv } from '@/utils/env-file' +import { note } from '@/utils/progress' +import * as ui from '@/utils/ui' + +const scope = Flag.String('scope').pipe( + Flag.withDescription( + 'Scope to preselect on the token page, e.g. products:read. Repeatable.', + ), + Flag.atLeast(0), +) + +const name = Flag.String('name').pipe( + Flag.optional, + Flag.withDescription('Token name. Defaults to the current directory name'), +) + +const envFile = Flag.String('env-file').pipe( + Flag.withDefault('.env'), + Flag.withDescription('File to write the token to'), +) + +const envVar = Flag.String('env-var').pipe( + Flag.withDefault('POLAR_ACCESS_TOKEN'), + Flag.withDescription('Variable name to store the token under'), +) + +const Target = Schema.Struct({ + slug: Schema.String, + environment: PolarEnvironment, +}) + +const Created = Schema.Struct({ + organization: Target, + envFile: Schema.String, + envVar: Schema.String, + replaced: Schema.Boolean, +}) + +const announceTokenPage = (page: URL) => + note( + [ + ui.blank, + ui.step('Opening your browser to create an organization access token...'), + ui.step('If it does not open, visit:'), + ` ${ui.cyan(page.toString())}`, + ui.blank, + ui.step('Waiting for the token...'), + ui.blank, + ].join('\n'), + ) + +const writeToken = ( + file: string, + variable: string, + token: Redacted.Redacted, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const current = yield* fs + .readFileString(file) + .pipe( + Effect.catch((error) => + error.reason._tag === 'NotFound' + ? Effect.succeed('') + : Effect.fail(error), + ), + ) + const { content, replaced } = upsertEnv( + current, + variable, + Redacted.value(token), + ) + yield* fs.writeFileString(file, content) + return replaced + }).pipe( + Effect.mapError( + () => + new AuthError({ + message: `Unable to write the token to ${file}. Check its permissions. The token was created; revoke it from the dashboard if you can't recover it.`, + }), + ), + ) + +export const token = Command.make('token', { + scope, + name, + envFile, + envVar, +}).pipe( + Command.withDescription( + 'Create an organization access token for your app and save it to an env file', + ), + Command.withExamples([ + { + command: 'polar auth token', + description: + 'Create a token for the active organization and add it to .env', + }, + { + command: + 'polar auth token --scope products:read --scope checkouts:write --env-file .env.local', + description: 'Preselect scopes and write to .env.local', + }, + ]), + output({ + result: Created, + run: (flags) => + Effect.gen(function* () { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(flags.envVar)) { + return yield* new AuthError({ + message: `${flags.envVar} is not a valid environment variable name.`, + }) + } + const path = yield* Path.Path + const { slug, environment } = yield* (yield* Organizations).resolve() + const organization = { slug, environment } + const file = path.resolve(flags.envFile) + const created = yield* (yield* TokenHandoff).receive( + { + organization, + name: Option.getOrElse(flags.name, () => + path.basename(process.cwd()), + ), + scopes: flags.scope, + }, + announceTokenPage, + ) + const replaced = yield* writeToken(file, flags.envVar, created) + return { organization, envFile: file, envVar: flags.envVar, replaced } + }), + render: ({ + organization, + envFile, + envVar, + replaced, + }: typeof Created.Type) => [ + ui.success( + `Created an access token for ${ui.bold(organization.slug)} ${ui.dim(organization.environment)}`, + ), + ui.step(`${replaced ? 'Updated' : 'Added'} ${envVar} in ${envFile}`), + ...(organization.environment === 'sandbox' + ? [ui.step("Point your Polar SDK at the sandbox: server: 'sandbox'")] + : []), + ui.blank, + ], + }), +) diff --git a/clients/packages/cli/src/services/api.ts b/clients/packages/cli/src/services/api.ts index 39afa350906..3725cc754fc 100644 --- a/clients/packages/cli/src/services/api.ts +++ b/clients/packages/cli/src/services/api.ts @@ -1,6 +1,11 @@ import { Context, Effect } from 'effect' import { HttpClientRequest } from 'effect/http' -import { loginCommand, orgCommand, type PolarEnvironment } from '@/schemas/Auth' +import { + AuthError, + loginCommand, + orgCommand, + type PolarEnvironment, +} from '@/schemas/Auth' type Env = Record @@ -19,6 +24,12 @@ const API_ORIGINS = { local: 'http://127.0.0.1:8000', } as const +const DASHBOARD_ORIGINS = { + production: 'https://polar.sh', + sandbox: 'https://sandbox.polar.sh', + local: 'http://127.0.0.1:3000', +} as const + const isLoopback = (host: string) => host === 'localhost' || host.endsWith('.localhost') || @@ -64,6 +75,18 @@ export const apiOrigin = (environment: PolarEnvironment) => return (yield* apiOverride) ?? API_ORIGINS[environment] }) +export const dashboardOrigin = (environment: PolarEnvironment) => + Effect.gen(function* () { + const override = yield* dashboardOverride + if (override) return override + if (yield* apiOverride) + return yield* new AuthError({ + message: + 'POLAR_API_URL is set, so POLAR_DASHBOARD_URL is needed too. Point it at the matching dashboard, e.g. http://127.0.0.1:3000.', + }) + return DASHBOARD_ORIGINS[environment] + }) + export const apiUrl = (environment: PolarEnvironment, path: string) => Effect.map(apiOrigin(environment), (origin) => `${origin}/v1${path}`) diff --git a/clients/packages/cli/src/services/oauth.ts b/clients/packages/cli/src/services/oauth.ts index 4083a34ae70..44bd4d32d3c 100644 --- a/clients/packages/cli/src/services/oauth.ts +++ b/clients/packages/cli/src/services/oauth.ts @@ -1,16 +1,20 @@ import { createHash, randomBytes } from 'node:crypto' -import { createServer } from 'node:http' import { Context, DateTime, Effect, Layer, Redacted, Schema } from 'effect' import { HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/http' -import open from 'open' -import { apiOverride, apiUrl, dashboardOverride } from '@/services/api' +import { apiUrl, dashboardOrigin } from '@/services/api' import { AuthError, loginCommand, type PolarEnvironment, type Session, } from '@/schemas/Auth' -import { callbackPage, type CallbackOutcome } from '@/utils/callback-page' +import { loginCopy } from '@/utils/callback-page' +import { + awaitCallback, + type CallbackResult, + LOOPBACK_ORIGIN, + validateResult, +} from '@/utils/loopback' const CLIENT_IDS = { production: 'polar_ci_gBnJ_Yv_uSGm5mtoPa2cCA', @@ -18,23 +22,13 @@ const CLIENT_IDS = { local: 'polar_ci_local_cli', } as const -const DASHBOARD_ORIGINS = { - production: 'https://polar.sh', - sandbox: 'https://sandbox.polar.sh', - local: 'http://127.0.0.1:3000', -} as const - export const authorizationUrl = (environment: PolarEnvironment) => - Effect.gen(function* () { - const dashboard = yield* dashboardOverride - if (dashboard) return new URL('/oauth2/authorize', dashboard) - if (yield* apiOverride) - return yield* new AuthError({ - message: - 'POLAR_API_URL is set, so login needs POLAR_DASHBOARD_URL too. Point it at the matching dashboard, e.g. http://127.0.0.1:3000.', - }) - return new URL('/oauth2/authorize', DASHBOARD_ORIGINS[environment]) - }) + Effect.map( + dashboardOrigin(environment), + (origin) => new URL('/oauth2/authorize', origin), + ) + +const CALLBACK_PATH = '/oauth/callback' const config = { scopes: [ @@ -101,7 +95,7 @@ const config = { 'webhooks:read', 'webhooks:write', ], - redirectUrl: 'http://127.0.0.1:3333/oauth/callback', + redirectUrl: `${LOOPBACK_ORIGIN}${CALLBACK_PATH}`, } export type AnnounceAuthorization = (authorization: URL) => Effect.Effect @@ -173,11 +167,10 @@ export const exchange = ( } }).pipe(Effect.scoped) -type CallbackResult = - | { outcome: 'success'; code: string } - | { outcome: Exclude; message: string } - -const parseCallback = (url: URL, expectedState: string): CallbackResult => { +const parseCallback = ( + url: URL, + expectedState: string, +): CallbackResult => { if (url.searchParams.get('state') !== expectedState) { return { outcome: 'invalid', message: 'Invalid OAuth callback state.' } } @@ -189,19 +182,15 @@ const parseCallback = (url: URL, expectedState: string): CallbackResult => { } const code = url.searchParams.get('code') return code - ? { outcome: 'success', code } + ? { outcome: 'success', value: code } : { outcome: 'invalid', message: 'OAuth callback is missing its authorization code.', } } -export const validateCallback = (url: URL, expectedState: string) => { - const result = parseCallback(url, expectedState) - return result.outcome === 'success' - ? Effect.succeed(result.code) - : Effect.fail(new AuthError({ message: result.message })) -} +export const validateCallback = (url: URL, expectedState: string) => + validateResult(parseCallback(url, expectedState)) const login = ( environment: PolarEnvironment, @@ -222,86 +211,14 @@ const login = ( sub_type: 'user', }).toString() yield* announce(authorization) - const server = createServer() - const code = yield* Effect.callback((resume) => { - let completed = false - const finish = (result: Effect.Effect) => { - if (!completed) { - completed = true - resume(result) - } - } - server.on('request', (request, response) => { - const url = new URL(request.url ?? '/', config.redirectUrl) - if (url.pathname !== '/oauth/callback' || request.method !== 'GET') { - response.writeHead(404).end() - return - } - const result = parseCallback(url, state) - response - .writeHead(result.outcome === 'invalid' ? 400 : 200, { - 'Content-Type': 'text/html; charset=utf-8', - }) - .end(callbackPage(result.outcome)) - finish(validateCallback(url, state)) - }) - server.on('error', () => - finish( - Effect.fail( - new AuthError({ - message: - 'Cannot start OAuth callback server on 127.0.0.1:3333. Check whether the port is occupied.', - }), - ), - ), - ) - server.listen(3333, '127.0.0.1', () => { - void open(authorization.toString()) - .then((child) => { - child.on('error', () => - finish( - Effect.fail( - new AuthError({ - message: 'Could not open the browser for login.', - }), - ), - ), - ) - child.on('exit', (code) => { - if (code) - finish( - Effect.fail( - new AuthError({ - message: 'Could not open the browser for login.', - }), - ), - ) - }) - }) - .catch(() => - finish( - Effect.fail( - new AuthError({ - message: 'Could not open the browser for login.', - }), - ), - ), - ) - }) - }).pipe( - Effect.ensuring( - Effect.sync(() => { - server.close() - server.closeAllConnections() - }), - ), - Effect.timeout('5 minutes'), - Effect.mapError((error) => - error instanceof AuthError - ? error - : new AuthError({ message: 'OAuth login timed out. Try again.' }), - ), - ) + const code = yield* awaitCallback({ + path: CALLBACK_PATH, + target: authorization, + parse: (url) => parseCallback(url, state), + copy: loginCopy, + browserError: 'Could not open the browser for login.', + timeoutError: 'OAuth login timed out. Try again.', + }) return yield* exchange( environment, new URLSearchParams({ diff --git a/clients/packages/cli/src/services/token-handoff.test.ts b/clients/packages/cli/src/services/token-handoff.test.ts new file mode 100644 index 00000000000..87692f2f4cc --- /dev/null +++ b/clients/packages/cli/src/services/token-handoff.test.ts @@ -0,0 +1,106 @@ +import { generateKeyPairSync } from 'node:crypto' +import { Redacted } from 'effect' +import { expect, test } from 'vitest' +import type { ActiveOrganization } from '@/schemas/Auth' +import { parseHandoff, tokenPage } from '@/services/token-handoff' + +const acme: ActiveOrganization = { + id: 'org-1', + name: 'Acme', + slug: 'acme', + environment: 'sandbox', +} + +const keys = () => { + const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + }) + return { + privateKey, + spki: publicKey.export({ type: 'spki', format: 'der' }), + } +} + +const encryptLikeTheBrowser = async (spki: Buffer, token: string) => { + const key = await crypto.subtle.importKey( + 'spki', + new Uint8Array(spki), + { name: 'RSA-OAEP', hash: 'SHA-256' }, + false, + ['encrypt'], + ) + const encrypted = await crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + key, + new TextEncoder().encode(token), + ) + return Buffer.from(encrypted).toString('base64url') +} + +const callback = (params: Record) => + new URL(`http://127.0.0.1:3333/token/callback?${new URLSearchParams(params)}`) + +test('points at the organization token page', () => { + const page = tokenPage( + 'https://sandbox.polar.sh', + { organization: acme, name: 'my-app', scopes: ['products:read'] }, + 'state', + 'key', + ) + expect(page.origin).toBe('https://sandbox.polar.sh') + expect(page.pathname).toBe('/dashboard/acme/cli/token') + expect(Object.fromEntries(page.searchParams)).toEqual({ + state: 'state', + public_key: 'key', + name: 'my-app', + scopes: 'products:read', + }) +}) + +test('omits scopes when none are requested', () => { + const page = tokenPage( + 'https://polar.sh', + { + organization: acme, + name: 'x', + scopes: [], + }, + 'state', + 'key', + ) + expect(page.searchParams.has('scopes')).toBe(false) +}) + +test('decrypts a token encrypted by the browser', async () => { + const { privateKey, spki } = keys() + const encrypted = await encryptLikeTheBrowser(spki, 'polar_oat_secret') + const result = parseHandoff( + callback({ state: 'expected', encrypted_token: encrypted }), + 'expected', + privateKey, + ) + expect(result.outcome).toBe('success') + if (result.outcome === 'success') + expect(Redacted.value(result.value)).toBe('polar_oat_secret') +}) + +test('rejects a token encrypted for another key', async () => { + const { spki } = keys() + const encrypted = await encryptLikeTheBrowser(spki, 'polar_oat_secret') + const result = parseHandoff( + callback({ state: 'expected', encrypted_token: encrypted }), + 'expected', + keys().privateKey, + ) + expect(result.outcome).toBe('invalid') +}) + +test.each([ + [{ state: 'wrong', encrypted_token: 'x' }, 'invalid'], + [{ state: 'expected', error: 'access_denied' }, 'denied'], + [{ state: 'expected' }, 'invalid'], +] as const)('handles callback %j as %s', (params, outcome) => { + expect( + parseHandoff(callback(params), 'expected', keys().privateKey).outcome, + ).toBe(outcome) +}) diff --git a/clients/packages/cli/src/services/token-handoff.ts b/clients/packages/cli/src/services/token-handoff.ts new file mode 100644 index 00000000000..a0d2be402d0 --- /dev/null +++ b/clients/packages/cli/src/services/token-handoff.ts @@ -0,0 +1,115 @@ +import { + constants, + generateKeyPairSync, + type KeyObject, + privateDecrypt, + randomBytes, +} from 'node:crypto' +import { Context, Effect, Layer, Redacted } from 'effect' +import type { ActiveOrganization, AuthError } from '@/schemas/Auth' +import { dashboardOrigin } from '@/services/api' +import { tokenCopy } from '@/utils/callback-page' +import { awaitCallback, type CallbackResult } from '@/utils/loopback' + +const CALLBACK_PATH = '/token/callback' + +export interface TokenRequest { + organization: Pick + name: string + scopes: ReadonlyArray +} + +export type AnnounceTokenPage = (page: URL) => Effect.Effect + +export class TokenHandoff extends Context.Service< + TokenHandoff, + { + receive: ( + request: TokenRequest, + announce: AnnounceTokenPage, + ) => Effect.Effect, AuthError> + } +>()('TokenHandoff') {} + +export const tokenPage = ( + origin: string, + { organization, name, scopes }: TokenRequest, + state: string, + publicKey: string, +) => { + const page = new URL( + `/dashboard/${encodeURIComponent(organization.slug)}/cli/token`, + origin, + ) + page.search = new URLSearchParams({ + state, + public_key: publicKey, + name, + ...(scopes.length > 0 ? { scopes: scopes.join(',') } : {}), + }).toString() + return page +} + +export const parseHandoff = ( + url: URL, + expectedState: string, + privateKey: KeyObject, +): CallbackResult> => { + if (url.searchParams.get('state') !== expectedState) { + return { outcome: 'invalid', message: 'Invalid token callback state.' } + } + if (url.searchParams.has('error')) { + return { + outcome: 'denied', + message: 'Token creation was canceled. No token was created.', + } + } + const encrypted = url.searchParams.get('encrypted_token') + if (!encrypted) { + return { + outcome: 'invalid', + message: 'Token callback is missing its token.', + } + } + try { + const token = privateDecrypt( + { + key: privateKey, + padding: constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256', + }, + Buffer.from(encrypted, 'base64url'), + ).toString('utf8') + return { outcome: 'success', value: Redacted.make(token) } + } catch { + return { + outcome: 'invalid', + message: 'Could not decrypt the token sent by the browser.', + } + } +} + +const receive = (request: TokenRequest, announce: AnnounceTokenPage) => + Effect.gen(function* () { + const state = randomBytes(32).toString('hex') + const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + }) + const page = tokenPage( + yield* dashboardOrigin(request.organization.environment), + request, + state, + publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), + ) + yield* announce(page) + return yield* awaitCallback({ + path: CALLBACK_PATH, + target: page, + parse: (url) => parseHandoff(url, state, privateKey), + copy: tokenCopy, + browserError: 'Could not open the browser to create the token.', + timeoutError: 'Token creation timed out. Try again.', + }) + }) + +export const layer = Layer.succeed(TokenHandoff, TokenHandoff.of({ receive })) diff --git a/clients/packages/cli/src/utils/callback-page.ts b/clients/packages/cli/src/utils/callback-page.ts index d6e89fc265d..21eaae41e80 100644 --- a/clients/packages/cli/src/utils/callback-page.ts +++ b/clients/packages/cli/src/utils/callback-page.ts @@ -1,9 +1,11 @@ export type CallbackOutcome = 'success' | 'denied' | 'invalid' -const copy: Record< +export type CallbackCopy = Record< CallbackOutcome, { title: string; message: string; note?: string } -> = { +> + +export const loginCopy: CallbackCopy = { success: { title: 'You are signed in', message: 'Return to your terminal to continue.', @@ -48,7 +50,28 @@ ${autoCloseScript} const logo = `` -export const callbackPage = (outcome: CallbackOutcome) => { +export const tokenCopy: CallbackCopy = { + success: { + title: 'Token received', + message: 'Return to your terminal to continue.', + note: 'You can close this tab.', + }, + denied: { + title: 'Token creation canceled', + message: + 'No token was created. Return to your terminal and run polar auth token to try again.', + }, + invalid: { + title: 'Something went wrong', + message: + 'This link is invalid or has expired. Return to your terminal and run polar auth token again.', + }, +} + +export const callbackPage = ( + outcome: CallbackOutcome, + copy: CallbackCopy = loginCopy, +) => { const { title, message, note } = copy[outcome] return ` diff --git a/clients/packages/cli/src/utils/env-file.test.ts b/clients/packages/cli/src/utils/env-file.test.ts new file mode 100644 index 00000000000..5e83dccab26 --- /dev/null +++ b/clients/packages/cli/src/utils/env-file.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from 'vitest' +import { upsertEnv } from '@/utils/env-file' + +test('creates the variable in an empty file', () => { + expect(upsertEnv('', 'POLAR_ACCESS_TOKEN', 'polar_oat_1')).toEqual({ + content: 'POLAR_ACCESS_TOKEN=polar_oat_1\n', + replaced: false, + }) +}) + +test('appends the variable on a new line', () => { + expect(upsertEnv('A=1', 'POLAR_ACCESS_TOKEN', 'polar_oat_1')).toEqual({ + content: 'A=1\nPOLAR_ACCESS_TOKEN=polar_oat_1\n', + replaced: false, + }) +}) + +test.each([ + 'POLAR_ACCESS_TOKEN=old', + 'export POLAR_ACCESS_TOKEN=old', + ' POLAR_ACCESS_TOKEN = "old"', +])('replaces an existing assignment: %s', (existing) => { + expect( + upsertEnv(`A=1\n${existing}\nB=2\n`, 'POLAR_ACCESS_TOKEN', 'polar_oat_1'), + ).toEqual({ + content: 'A=1\nPOLAR_ACCESS_TOKEN=polar_oat_1\nB=2\n', + replaced: true, + }) +}) + +test('leaves variables that share a prefix alone', () => { + expect( + upsertEnv('POLAR_ACCESS_TOKEN_OLD=x\n', 'POLAR_ACCESS_TOKEN', 'new'), + ).toEqual({ + content: 'POLAR_ACCESS_TOKEN_OLD=x\nPOLAR_ACCESS_TOKEN=new\n', + replaced: false, + }) +}) diff --git a/clients/packages/cli/src/utils/env-file.ts b/clients/packages/cli/src/utils/env-file.ts new file mode 100644 index 00000000000..6214275bb2d --- /dev/null +++ b/clients/packages/cli/src/utils/env-file.ts @@ -0,0 +1,15 @@ +const assignment = (key: string) => + new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`) + +export const upsertEnv = (content: string, key: string, value: string) => { + const line = `${key}=${value}` + const lines = content.split('\n') + const pattern = assignment(key) + const index = lines.findIndex((existing) => pattern.test(existing)) + if (index !== -1) { + lines[index] = line + return { content: lines.join('\n'), replaced: true } + } + const separator = content === '' || content.endsWith('\n') ? '' : '\n' + return { content: `${content}${separator}${line}\n`, replaced: false } +} diff --git a/clients/packages/cli/src/utils/loopback.ts b/clients/packages/cli/src/utils/loopback.ts new file mode 100644 index 00000000000..0586e55fa73 --- /dev/null +++ b/clients/packages/cli/src/utils/loopback.ts @@ -0,0 +1,93 @@ +import { createServer } from 'node:http' +import { Effect } from 'effect' +import open from 'open' +import { AuthError } from '@/schemas/Auth' +import { + callbackPage, + type CallbackCopy, + type CallbackOutcome, +} from '@/utils/callback-page' + +export const LOOPBACK_ORIGIN = 'http://127.0.0.1:3333' + +export type CallbackResult = + | { outcome: 'success'; value: A } + | { outcome: Exclude; message: string } + +export const validateResult = (result: CallbackResult) => + result.outcome === 'success' + ? Effect.succeed(result.value) + : Effect.fail(new AuthError({ message: result.message })) + +interface CallbackOptions { + path: string + target: URL + parse: (url: URL) => CallbackResult + copy: CallbackCopy + browserError: string + timeoutError: string +} + +export const awaitCallback = ({ + path, + target, + parse, + copy, + browserError, + timeoutError, +}: CallbackOptions) => { + const server = createServer() + return Effect.callback((resume) => { + let completed = false + const finish = (result: Effect.Effect) => { + if (!completed) { + completed = true + resume(result) + } + } + const fail = (message: string) => + finish(Effect.fail(new AuthError({ message }))) + server.on('request', (request, response) => { + const url = new URL(request.url ?? '/', LOOPBACK_ORIGIN) + if (url.pathname !== path || request.method !== 'GET') { + response.writeHead(404).end() + return + } + const result = parse(url) + response + .writeHead(result.outcome === 'invalid' ? 400 : 200, { + 'Content-Type': 'text/html; charset=utf-8', + }) + .end(callbackPage(result.outcome, copy)) + finish(validateResult(result)) + }) + server.on('error', () => + fail( + 'Cannot start the callback server on 127.0.0.1:3333. Check whether the port is occupied.', + ), + ) + server.listen(3333, '127.0.0.1', () => { + void open(target.toString()) + .then((child) => { + child.on('error', () => fail(browserError)) + child.on('exit', (code) => { + if (code) fail(browserError) + }) + }) + .catch(() => fail(browserError)) + }) + }).pipe( + Effect.ensuring( + Effect.sync(() => { + server.close() + server.closeAllConnections() + }), + ), + Effect.timeout('5 minutes'), + Effect.mapError((error) => + error instanceof AuthError + ? error + : new AuthError({ message: timeoutError }), + ), + ) +} diff --git a/docs/integrate/cli/reference/auth.mdx b/docs/integrate/cli/reference/auth.mdx index 04b448a995a..b78c897de95 100644 --- a/docs/integrate/cli/reference/auth.mdx +++ b/docs/integrate/cli/reference/auth.mdx @@ -16,6 +16,7 @@ polar auth [flags] - [`polar auth whoami`](#polar-auth-whoami) Show the active organization - [`polar auth list`](#polar-auth-list) List the organizations you have access to - [`polar auth org`](#polar-auth-org) Choose the active organization +- [`polar auth token`](#polar-auth-token) Create an organization access token for your app and save it to an env file - [`polar auth logout`](#polar-auth-logout) Sign out and remove saved sessions ## polar auth login @@ -75,6 +76,35 @@ Choose the active organization polar auth org [flags] ``` +## polar auth token + +Create an organization access token for your app and save it to an env file + +```bash +polar auth token [flags] +``` + +**Flags** + +| Flag | Type | Description | +| --- | --- | --- | +| `--scope` | `string` | Scope to preselect on the token page, e.g. products:read. Repeatable. | +| `--name` | `string` | Token name. Defaults to the current directory name | +| `--env-file` | `string` | File to write the token to | +| `--env-var` | `string` | Variable name to store the token under | + +**Examples** + +Create a token for the active organization and add it to .env +```bash +polar auth token +``` + +Preselect scopes and write to .env.local +```bash +polar auth token --scope products:read --scope checkouts:write --env-file .env.local +``` + ## polar auth logout Sign out and remove saved sessions From d5c47cfa2901d9c97e79d3a06261f2bfa60657f1 Mon Sep 17 00:00:00 2001 From: Pieter Beulque Date: Tue, 13 Oct 2026 15:36:25 +0200 Subject: [PATCH 2/8] feat(cli): write the token's environment and add --stdout to auth token Sandbox tokens also write POLAR_ENVIRONMENT=sandbox and local tokens POLAR_API_URL, and a variable left from another environment is flagged rather than removed. --stdout prints KEY=value lines to pipe into a hosting provider, with status messages on stderr. Drop --env-var and --name: the token is always POLAR_ACCESS_TOKEN and is named after the current directory, editable on the dashboard page. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../packages/cli/src/commands/token.test.ts | 98 +++++++--- clients/packages/cli/src/commands/token.ts | 184 +++++++++++------- .../cli/src/services/token-handoff.ts | 22 ++- .../packages/cli/src/utils/env-file.test.ts | 9 +- clients/packages/cli/src/utils/env-file.ts | 5 + clients/packages/cli/src/utils/progress.ts | 5 + docs/integrate/cli/reference/auth.mdx | 8 +- 7 files changed, 234 insertions(+), 97 deletions(-) diff --git a/clients/packages/cli/src/commands/token.test.ts b/clients/packages/cli/src/commands/token.test.ts index ca0cfe32f40..de9b829245e 100644 --- a/clients/packages/cli/src/commands/token.test.ts +++ b/clients/packages/cli/src/commands/token.test.ts @@ -1,10 +1,10 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { Effect, Redacted } from 'effect' import { afterEach, beforeEach, expect, test } from 'vitest' import { token as tokenCommand } from '@/commands/token' -import type { ActiveOrganization } from '@/schemas/Auth' +import type { ActiveOrganization, PolarEnvironment } from '@/schemas/Auth' import { Organizations } from '@/services/organizations' import { type TokenRequest, TokenHandoff } from '@/services/token-handoff' import { runCli } from '@/utils/test-utils/cli' @@ -30,11 +30,11 @@ const run = (args: string[]) => { Effect.provideService( TokenHandoff, TokenHandoff.of({ - receive: (request) => - Effect.sync(() => { - requests.push(request) - return Redacted.make('polar_oat_secret') - }), + receive: (request, announce) => + announce(new URL('https://polar.sh/dashboard/acme/cli/token')).pipe( + Effect.as(Redacted.make('polar_oat_secret')), + Effect.tap(() => Effect.sync(() => requests.push(request))), + ), }), ), ), @@ -65,18 +65,16 @@ test('writes the token to the env file without printing it', async () => { 'products:read', '--scope', 'checkouts:write', - '--name', - 'my-app', ]) await promise expect(await readFile(file, 'utf8')).toBe( - 'OTHER=1\nPOLAR_ACCESS_TOKEN=polar_oat_secret\n', + 'OTHER=1\nPOLAR_ACCESS_TOKEN=polar_oat_secret\nPOLAR_ENVIRONMENT=sandbox\n', ) expect(requests).toEqual([ { organization: { slug: 'acme', environment: 'sandbox' }, - name: 'my-app', + name: basename(process.cwd()), scopes: ['products:read', 'checkouts:write'], }, ]) @@ -84,22 +82,70 @@ test('writes the token to the env file without printing it', async () => { expect(output()).not.toContain('polar_oat_secret') }) -test('creates the env file under a custom variable name', async () => { - const file = join(dir, '.env.local') - const { promise, output } = run([ - '--env-file', - file, - '--env-var', - 'POLAR_OAT', - ]) +const selectEnvironment = (environment: PolarEnvironment) => { + organizations = fakeOrganizations({ + items: [{ ...acme, environment }], + selected: { id: acme.id, environment }, + }) +} + +test('writes only the token for production', async () => { + selectEnvironment('production') + const file = join(dir, '.env') + const { promise } = run(['--env-file', file]) + await promise + + expect(await readFile(file, 'utf8')).toBe( + 'POLAR_ACCESS_TOKEN=polar_oat_secret\n', + ) +}) + +test('writes the local API URL for a local session', async () => { + selectEnvironment('local') + const file = join(dir, '.env') + const { promise, output } = run(['--env-file', file]) + await promise + + expect(await readFile(file, 'utf8')).toBe( + 'POLAR_ACCESS_TOKEN=polar_oat_secret\nPOLAR_API_URL=http://127.0.0.1:8000\n', + ) + expect(output()).toContain('serverURL: process.env.POLAR_API_URL') +}) + +test('warns about environment variables left from another environment', async () => { + selectEnvironment('production') + const file = join(dir, '.env') + await writeFile(file, 'POLAR_ENVIRONMENT=sandbox\n') + const { promise, output } = run(['--env-file', file]) await promise - expect(await readFile(file, 'utf8')).toBe('POLAR_OAT=polar_oat_secret\n') - expect(output()).toContain('Added POLAR_OAT') + expect(await readFile(file, 'utf8')).toBe( + 'POLAR_ENVIRONMENT=sandbox\nPOLAR_ACCESS_TOKEN=polar_oat_secret\n', + ) + expect(output()).toContain('POLAR_ENVIRONMENT in') + expect(output()).toContain("doesn't match this production token") }) -test('rejects an invalid variable name before creating a token', async () => { - const { promise } = run(['--env-file', join(dir, '.env'), '--env-var', 'A=B']) - await expect(promise).rejects.toThrow('not a valid environment variable name') - expect(requests).toEqual([]) +test('prints only the variables with --stdout and writes no file', async () => { + const file = join(dir, '.env') + const { promise, output } = run(['--stdout', '--env-file', file]) + await promise + + expect(output().trim()).toBe( + 'POLAR_ACCESS_TOKEN=polar_oat_secret\nPOLAR_ENVIRONMENT=sandbox', + ) + await expect(access(file)).rejects.toThrow() +}) + +test('includes the variables in JSON output with --stdout', async () => { + const { promise, output } = run(['--stdout', '--json']) + await promise + + expect(JSON.parse(output())).toMatchObject({ + destination: 'stdout', + variables: [ + { name: 'POLAR_ACCESS_TOKEN', value: 'polar_oat_secret' }, + { name: 'POLAR_ENVIRONMENT', value: 'sandbox' }, + ], + }) }) diff --git a/clients/packages/cli/src/commands/token.ts b/clients/packages/cli/src/commands/token.ts index 62c296f77a5..b16c4617fa6 100644 --- a/clients/packages/cli/src/commands/token.ts +++ b/clients/packages/cli/src/commands/token.ts @@ -1,11 +1,15 @@ -import { Effect, FileSystem, Option, Path, Redacted, Schema } from 'effect' +import { Effect, FileSystem, Path, Redacted, Schema } from 'effect' import { Command, Flag } from 'effect/cli' import { AuthError, PolarEnvironment } from '@/schemas/Auth' import { Organizations } from '@/services/organizations' -import { TokenHandoff } from '@/services/token-handoff' +import { + ENVIRONMENT_VARS, + environmentSettings, + TokenHandoff, +} from '@/services/token-handoff' import { output } from '@/utils/command' -import { upsertEnv } from '@/utils/env-file' -import { note } from '@/utils/progress' +import { hasEnv, upsertEnv } from '@/utils/env-file' +import { note, noteToStderr } from '@/utils/progress' import * as ui from '@/utils/ui' const scope = Flag.String('scope').pipe( @@ -15,19 +19,16 @@ const scope = Flag.String('scope').pipe( Flag.atLeast(0), ) -const name = Flag.String('name').pipe( - Flag.optional, - Flag.withDescription('Token name. Defaults to the current directory name'), -) - const envFile = Flag.String('env-file').pipe( Flag.withDefault('.env'), Flag.withDescription('File to write the token to'), ) -const envVar = Flag.String('env-var').pipe( - Flag.withDefault('POLAR_ACCESS_TOKEN'), - Flag.withDescription('Variable name to store the token under'), +const stdout = Flag.Boolean('stdout').pipe( + Flag.withDefault(false), + Flag.withDescription( + 'Print the variables as KEY=value lines instead of writing --env-file, to pipe into another command', + ), ) const Target = Schema.Struct({ @@ -35,34 +36,59 @@ const Target = Schema.Struct({ environment: PolarEnvironment, }) -const Created = Schema.Struct({ +const Written = Schema.Struct({ + name: Schema.String, + replaced: Schema.Boolean, +}) + +const Saved = Schema.Struct({ + destination: Schema.Literal('file'), organization: Target, envFile: Schema.String, - envVar: Schema.String, - replaced: Schema.Boolean, + written: Schema.Array(Written), + stale: Schema.Array(Schema.String), }) -const announceTokenPage = (page: URL) => - note( - [ - ui.blank, - ui.step('Opening your browser to create an organization access token...'), - ui.step('If it does not open, visit:'), - ` ${ui.cyan(page.toString())}`, - ui.blank, - ui.step('Waiting for the token...'), - ui.blank, - ].join('\n'), - ) +const Printed = Schema.Struct({ + destination: Schema.Literal('stdout'), + organization: Target, + variables: Schema.Array( + Schema.Struct({ name: Schema.String, value: Schema.String }), + ), +}) -const writeToken = ( +const Created = Schema.Union([Saved, Printed]) +type Created = typeof Created.Type + +const sdkHints: Record = { + production: undefined, + sandbox: 'Pass server: process.env.POLAR_ENVIRONMENT to your Polar SDK', + local: 'Pass serverURL: process.env.POLAR_API_URL to your Polar SDK', +} + +const announceTokenPage = + (print: (text: string) => Effect.Effect) => (page: URL) => + print( + [ + ui.blank, + ui.step( + 'Opening your browser to create an organization access token...', + ), + ui.step('If it does not open, visit:'), + ` ${ui.cyan(page.toString())}`, + ui.blank, + ui.step('Waiting for the token...'), + ui.blank, + ].join('\n'), + ) + +const writeEnv = ( file: string, - variable: string, - token: Redacted.Redacted, + settings: ReadonlyArray, ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem - const current = yield* fs + let content = yield* fs .readFileString(file) .pipe( Effect.catch((error) => @@ -71,13 +97,18 @@ const writeToken = ( : Effect.fail(error), ), ) - const { content, replaced } = upsertEnv( - current, - variable, - Redacted.value(token), - ) + const written: Array = [] + for (const [name, value] of settings) { + const result = upsertEnv(content, name, value) + content = result.content + written.push({ name, replaced: result.replaced }) + } yield* fs.writeFileString(file, content) - return replaced + const stale = ENVIRONMENT_VARS.filter( + (name) => + hasEnv(content, name) && !settings.some(([key]) => key === name), + ) + return { written, stale } }).pipe( Effect.mapError( () => @@ -89,9 +120,8 @@ const writeToken = ( export const token = Command.make('token', { scope, - name, envFile, - envVar, + stdout, }).pipe( Command.withDescription( 'Create an organization access token for your app and save it to an env file', @@ -107,47 +137,69 @@ export const token = Command.make('token', { 'polar auth token --scope products:read --scope checkouts:write --env-file .env.local', description: 'Preselect scopes and write to .env.local', }, + { + command: 'polar auth token --stdout | fly secrets import', + description: + 'Pipe the variables into a hosting provider without writing them to disk', + }, ]), output({ result: Created, run: (flags) => Effect.gen(function* () { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(flags.envVar)) { - return yield* new AuthError({ - message: `${flags.envVar} is not a valid environment variable name.`, - }) - } const path = yield* Path.Path const { slug, environment } = yield* (yield* Organizations).resolve() const organization = { slug, environment } - const file = path.resolve(flags.envFile) const created = yield* (yield* TokenHandoff).receive( { organization, - name: Option.getOrElse(flags.name, () => - path.basename(process.cwd()), - ), + name: path.basename(process.cwd()), scopes: flags.scope, }, - announceTokenPage, + announceTokenPage(flags.stdout ? noteToStderr : note), ) - const replaced = yield* writeToken(file, flags.envVar, created) - return { organization, envFile: file, envVar: flags.envVar, replaced } + const settings: ReadonlyArray = [ + ['POLAR_ACCESS_TOKEN', Redacted.value(created)], + ...(yield* environmentSettings(environment)), + ] + if (flags.stdout) { + return { + destination: 'stdout', + organization, + variables: settings.map(([name, value]) => ({ name, value })), + } satisfies Created + } + const file = path.resolve(flags.envFile) + const { written, stale } = yield* writeEnv(file, settings) + return { + destination: 'file', + organization, + envFile: file, + written, + stale, + } satisfies Created }), - render: ({ - organization, - envFile, - envVar, - replaced, - }: typeof Created.Type) => [ - ui.success( - `Created an access token for ${ui.bold(organization.slug)} ${ui.dim(organization.environment)}`, - ), - ui.step(`${replaced ? 'Updated' : 'Added'} ${envVar} in ${envFile}`), - ...(organization.environment === 'sandbox' - ? [ui.step("Point your Polar SDK at the sandbox: server: 'sandbox'")] - : []), - ui.blank, - ], + render: (created: Created) => { + if (created.destination === 'stdout') { + return created.variables.map(({ name, value }) => `${name}=${value}`) + } + const { organization, envFile, written, stale } = created + const hint = sdkHints[organization.environment] + return [ + ui.success( + `Created an access token for ${ui.bold(organization.slug)} ${ui.dim(organization.environment)}`, + ), + ...written.map(({ name, replaced }) => + ui.step(`${replaced ? 'Updated' : 'Added'} ${name} in ${envFile}`), + ), + ...stale.map((name) => + ui.warning( + `${name} in ${envFile} doesn't match this ${organization.environment} token. Remove it.`, + ), + ), + ...(hint ? [ui.step(hint)] : []), + ui.blank, + ] + }, }), ) diff --git a/clients/packages/cli/src/services/token-handoff.ts b/clients/packages/cli/src/services/token-handoff.ts index a0d2be402d0..a3ebc6731b2 100644 --- a/clients/packages/cli/src/services/token-handoff.ts +++ b/clients/packages/cli/src/services/token-handoff.ts @@ -6,13 +6,31 @@ import { randomBytes, } from 'node:crypto' import { Context, Effect, Layer, Redacted } from 'effect' -import type { ActiveOrganization, AuthError } from '@/schemas/Auth' -import { dashboardOrigin } from '@/services/api' +import type { + ActiveOrganization, + AuthError, + PolarEnvironment, +} from '@/schemas/Auth' +import { apiOrigin, dashboardOrigin } from '@/services/api' import { tokenCopy } from '@/utils/callback-page' import { awaitCallback, type CallbackResult } from '@/utils/loopback' const CALLBACK_PATH = '/token/callback' +export const ENVIRONMENT_VARS = ['POLAR_ENVIRONMENT', 'POLAR_API_URL'] as const + +export const environmentSettings = (environment: PolarEnvironment) => + Effect.gen(function* () { + switch (environment) { + case 'production': + return [] + case 'sandbox': + return [['POLAR_ENVIRONMENT', 'sandbox'] as const] + case 'local': + return [['POLAR_API_URL', yield* apiOrigin('local')] as const] + } + }) + export interface TokenRequest { organization: Pick name: string diff --git a/clients/packages/cli/src/utils/env-file.test.ts b/clients/packages/cli/src/utils/env-file.test.ts index 5e83dccab26..c00c9b4da97 100644 --- a/clients/packages/cli/src/utils/env-file.test.ts +++ b/clients/packages/cli/src/utils/env-file.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest' -import { upsertEnv } from '@/utils/env-file' +import { hasEnv, upsertEnv } from '@/utils/env-file' test('creates the variable in an empty file', () => { expect(upsertEnv('', 'POLAR_ACCESS_TOKEN', 'polar_oat_1')).toEqual({ @@ -36,3 +36,10 @@ test('leaves variables that share a prefix alone', () => { replaced: false, }) }) + +test('finds an existing assignment', () => { + expect( + hasEnv('A=1\nexport POLAR_ENVIRONMENT=sandbox\n', 'POLAR_ENVIRONMENT'), + ).toBe(true) + expect(hasEnv('POLAR_ENVIRONMENT_X=1\n', 'POLAR_ENVIRONMENT')).toBe(false) +}) diff --git a/clients/packages/cli/src/utils/env-file.ts b/clients/packages/cli/src/utils/env-file.ts index 6214275bb2d..f73a3f9416d 100644 --- a/clients/packages/cli/src/utils/env-file.ts +++ b/clients/packages/cli/src/utils/env-file.ts @@ -13,3 +13,8 @@ export const upsertEnv = (content: string, key: string, value: string) => { const separator = content === '' || content.endsWith('\n') ? '' : '\n' return { content: `${content}${separator}${line}\n`, replaced: false } } + +export const hasEnv = (content: string, key: string) => { + const pattern = assignment(key) + return content.split('\n').some((line) => pattern.test(line)) +} diff --git a/clients/packages/cli/src/utils/progress.ts b/clients/packages/cli/src/utils/progress.ts index 19da4075ced..4022a75b817 100644 --- a/clients/packages/cli/src/utils/progress.ts +++ b/clients/packages/cli/src/utils/progress.ts @@ -116,3 +116,8 @@ export const note = (line: string) => if (yield* isJson) return yield* Console.log(line) }) + +export const noteToStderr = (line: string) => + Effect.sync(() => { + process.stderr.write(`${line}\n`) + }) diff --git a/docs/integrate/cli/reference/auth.mdx b/docs/integrate/cli/reference/auth.mdx index b78c897de95..5b48db63488 100644 --- a/docs/integrate/cli/reference/auth.mdx +++ b/docs/integrate/cli/reference/auth.mdx @@ -89,9 +89,8 @@ polar auth token [flags] | Flag | Type | Description | | --- | --- | --- | | `--scope` | `string` | Scope to preselect on the token page, e.g. products:read. Repeatable. | -| `--name` | `string` | Token name. Defaults to the current directory name | | `--env-file` | `string` | File to write the token to | -| `--env-var` | `string` | Variable name to store the token under | +| `--stdout` | `boolean` | Print the variables as KEY=value lines instead of writing --env-file, to pipe into another command | **Examples** @@ -105,6 +104,11 @@ Preselect scopes and write to .env.local polar auth token --scope products:read --scope checkouts:write --env-file .env.local ``` +Pipe the variables into a hosting provider without writing them to disk +```bash +polar auth token --stdout | fly secrets import +``` + ## polar auth logout Sign out and remove saved sessions From 77b200cb4e56d08e5f5827e7101b7f8e3907705b Mon Sep 17 00:00:00 2001 From: Pieter Beulque Date: Tue, 13 Oct 2026 15:43:27 +0200 Subject: [PATCH 3/8] fix(cli): harden the loopback callback and env file writes - Ignore loopback callbacks whose state doesn't match instead of ending the wait, so another page can't abort a login or token handoff. - Create new env files with mode 600 and remove later duplicate assignments, so the new token is the one that takes effect. - Unset the token-override variables before the local login steps in the README. Co-Authored-By: Claude Opus 5.5 (1M context) --- clients/packages/cli/README.md | 5 +- .../packages/cli/src/commands/token.test.ts | 28 +++++++- clients/packages/cli/src/commands/token.ts | 2 +- clients/packages/cli/src/services/oauth.ts | 1 + .../cli/src/services/token-handoff.ts | 1 + .../packages/cli/src/utils/env-file.test.ts | 10 +++ clients/packages/cli/src/utils/env-file.ts | 12 +++- .../packages/cli/src/utils/loopback.test.ts | 69 +++++++++++++++++++ clients/packages/cli/src/utils/loopback.ts | 8 +++ 9 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 clients/packages/cli/src/utils/loopback.test.ts diff --git a/clients/packages/cli/README.md b/clients/packages/cli/README.md index 19a693a9c88..b708827e77e 100644 --- a/clients/packages/cli/README.md +++ b/clients/packages/cli/README.md @@ -59,9 +59,12 @@ bun src/cli.ts To create a token for an app against the local stack, sign in with `auth login --local` (`dev seed` registers its OAuth client). `auth token` then -opens the local dashboard at `127.0.0.1:3000`: +opens the local dashboard at `127.0.0.1:3000`. Unset the variables from the token +setup above first: `auth login` refuses to run with `POLAR_ACCESS_TOKEN` set, and +`--local` already points at the local API: ```bash +unset POLAR_API_URL POLAR_ACCESS_TOKEN bun src/cli.ts auth login --local bun src/cli.ts auth token --env-file /tmp/polar-test.env ``` diff --git a/clients/packages/cli/src/commands/token.test.ts b/clients/packages/cli/src/commands/token.test.ts index de9b829245e..2816051928a 100644 --- a/clients/packages/cli/src/commands/token.test.ts +++ b/clients/packages/cli/src/commands/token.test.ts @@ -1,4 +1,12 @@ -import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { + access, + chmod, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { Effect, Redacted } from 'effect' @@ -149,3 +157,21 @@ test('includes the variables in JSON output with --stdout', async () => { ], }) }) + +test('creates a new env file readable only by the owner', async () => { + const file = join(dir, '.env') + const { promise } = run(['--env-file', file]) + await promise + + expect((await stat(file)).mode & 0o777).toBe(0o600) +}) + +test('keeps the permissions of an existing env file', async () => { + const file = join(dir, '.env') + await writeFile(file, 'A=1\n') + await chmod(file, 0o644) + const { promise } = run(['--env-file', file]) + await promise + + expect((await stat(file)).mode & 0o777).toBe(0o644) +}) diff --git a/clients/packages/cli/src/commands/token.ts b/clients/packages/cli/src/commands/token.ts index b16c4617fa6..7ca64741d5f 100644 --- a/clients/packages/cli/src/commands/token.ts +++ b/clients/packages/cli/src/commands/token.ts @@ -103,7 +103,7 @@ const writeEnv = ( content = result.content written.push({ name, replaced: result.replaced }) } - yield* fs.writeFileString(file, content) + yield* fs.writeFileString(file, content, { mode: 0o600 }) const stale = ENVIRONMENT_VARS.filter( (name) => hasEnv(content, name) && !settings.some(([key]) => key === name), diff --git a/clients/packages/cli/src/services/oauth.ts b/clients/packages/cli/src/services/oauth.ts index 44bd4d32d3c..7a4dbd61b5d 100644 --- a/clients/packages/cli/src/services/oauth.ts +++ b/clients/packages/cli/src/services/oauth.ts @@ -213,6 +213,7 @@ const login = ( yield* announce(authorization) const code = yield* awaitCallback({ path: CALLBACK_PATH, + state, target: authorization, parse: (url) => parseCallback(url, state), copy: loginCopy, diff --git a/clients/packages/cli/src/services/token-handoff.ts b/clients/packages/cli/src/services/token-handoff.ts index a3ebc6731b2..3e81dc8dd19 100644 --- a/clients/packages/cli/src/services/token-handoff.ts +++ b/clients/packages/cli/src/services/token-handoff.ts @@ -122,6 +122,7 @@ const receive = (request: TokenRequest, announce: AnnounceTokenPage) => yield* announce(page) return yield* awaitCallback({ path: CALLBACK_PATH, + state, target: page, parse: (url) => parseHandoff(url, state, privateKey), copy: tokenCopy, diff --git a/clients/packages/cli/src/utils/env-file.test.ts b/clients/packages/cli/src/utils/env-file.test.ts index c00c9b4da97..af7ddedf02b 100644 --- a/clients/packages/cli/src/utils/env-file.test.ts +++ b/clients/packages/cli/src/utils/env-file.test.ts @@ -28,6 +28,16 @@ test.each([ }) }) +test('removes later duplicates so the new value wins', () => { + expect( + upsertEnv( + 'POLAR_ACCESS_TOKEN=a\nB=2\nexport POLAR_ACCESS_TOKEN=b\n', + 'POLAR_ACCESS_TOKEN', + 'new', + ), + ).toEqual({ content: 'POLAR_ACCESS_TOKEN=new\nB=2\n', replaced: true }) +}) + test('leaves variables that share a prefix alone', () => { expect( upsertEnv('POLAR_ACCESS_TOKEN_OLD=x\n', 'POLAR_ACCESS_TOKEN', 'new'), diff --git a/clients/packages/cli/src/utils/env-file.ts b/clients/packages/cli/src/utils/env-file.ts index f73a3f9416d..2e9399b4c9b 100644 --- a/clients/packages/cli/src/utils/env-file.ts +++ b/clients/packages/cli/src/utils/env-file.ts @@ -3,12 +3,18 @@ const assignment = (key: string) => export const upsertEnv = (content: string, key: string, value: string) => { const line = `${key}=${value}` - const lines = content.split('\n') const pattern = assignment(key) + const lines = content.split('\n') const index = lines.findIndex((existing) => pattern.test(existing)) if (index !== -1) { - lines[index] = line - return { content: lines.join('\n'), replaced: true } + const updated = lines.flatMap((existing, position) => + position === index + ? [line] + : position > index && pattern.test(existing) + ? [] + : [existing], + ) + return { content: updated.join('\n'), replaced: true } } const separator = content === '' || content.endsWith('\n') ? '' : '\n' return { content: `${content}${separator}${line}\n`, replaced: false } diff --git a/clients/packages/cli/src/utils/loopback.test.ts b/clients/packages/cli/src/utils/loopback.test.ts new file mode 100644 index 00000000000..c5608b5f46e --- /dev/null +++ b/clients/packages/cli/src/utils/loopback.test.ts @@ -0,0 +1,69 @@ +import { ChildProcess } from 'node:child_process' +import * as http from 'node:http' +import { Effect } from 'effect' +import * as browser from 'open' +import { expect, test, vi } from 'vitest' +import { loginCopy } from '@/utils/callback-page' +import { awaitCallback } from '@/utils/loopback' + +vi.mock('node:http', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, createServer: vi.fn(actual.createServer) } +}) +vi.mock('open', () => ({ default: vi.fn() })) + +const get = (url: URL) => + new Promise((resolve, reject) => { + http + .get(url, (response) => { + response.resume() + response.on('end', () => resolve(response.statusCode)) + }) + .on('error', reject) + }) + +test('ignores callbacks with the wrong state and keeps waiting', async () => { + const server = http.createServer() + const nativeListen = server.listen.bind(server) + vi.spyOn(server, 'listen').mockImplementation((...args: unknown[]) => + nativeListen(0, '127.0.0.1', args[2] as () => void), + ) + vi.spyOn(http, 'createServer').mockReturnValue(server) + const statuses: Array = [] + let browserDone: Promise | undefined + vi.spyOn(browser, 'default').mockImplementation(() => { + browserDone = visit() + return browserDone + }) + const visit = async () => { + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('Expected a TCP listener') + const callback = (params: Record) => + new URL( + `http://127.0.0.1:${address.port}/callback?${new URLSearchParams(params)}`, + ) + statuses.push(await get(callback({ state: 'forged', error: 'denied' }))) + statuses.push(await get(callback({ state: 'expected', code: 'code' }))) + return new ChildProcess() + } + + const result = await Effect.runPromise( + awaitCallback({ + path: '/callback', + state: 'expected', + target: new URL('https://polar.sh/authorize'), + parse: (url) => ({ + outcome: 'success', + value: url.searchParams.get('code')!, + }), + copy: loginCopy, + browserError: 'browser', + timeoutError: 'timeout', + }), + ) + + await browserDone + expect(result).toBe('code') + expect(statuses).toEqual([400, 200]) +}) diff --git a/clients/packages/cli/src/utils/loopback.ts b/clients/packages/cli/src/utils/loopback.ts index 0586e55fa73..bf6fa4ddc54 100644 --- a/clients/packages/cli/src/utils/loopback.ts +++ b/clients/packages/cli/src/utils/loopback.ts @@ -21,6 +21,7 @@ export const validateResult = (result: CallbackResult) => interface CallbackOptions { path: string + state: string target: URL parse: (url: URL) => CallbackResult copy: CallbackCopy @@ -30,6 +31,7 @@ interface CallbackOptions { export const awaitCallback = ({ path, + state, target, parse, copy, @@ -53,6 +55,12 @@ export const awaitCallback = ({ response.writeHead(404).end() return } + if (url.searchParams.get('state') !== state) { + response + .writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }) + .end(callbackPage('invalid', copy)) + return + } const result = parse(url) response .writeHead(result.outcome === 'invalid' ? 400 : 200, { From b97685e828424b28f123033180b4cffe78f0f558 Mon Sep 17 00:00:00 2001 From: Pieter Beulque Date: Tue, 13 Oct 2026 15:54:39 +0200 Subject: [PATCH 4/8] feat(cli): default auth token to .env.local and warn when git tracks it Without --env-file, write to .env.local when it exists and .env otherwise, so projects that commit .env keep secrets in .env.local. After writing, warn when git doesn't ignore the file. Writing env files moves into services/env-file.ts, which runs git check-ignore. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../packages/cli/src/commands/token.test.ts | 45 ++++++++++++ clients/packages/cli/src/commands/token.ts | 71 ++++++------------- clients/packages/cli/src/services/env-file.ts | 64 +++++++++++++++++ docs/integrate/cli/reference/auth.mdx | 4 +- 4 files changed, 133 insertions(+), 51 deletions(-) create mode 100644 clients/packages/cli/src/services/env-file.ts diff --git a/clients/packages/cli/src/commands/token.test.ts b/clients/packages/cli/src/commands/token.test.ts index 2816051928a..c67b4f8a767 100644 --- a/clients/packages/cli/src/commands/token.test.ts +++ b/clients/packages/cli/src/commands/token.test.ts @@ -7,6 +7,7 @@ import { stat, writeFile, } from 'node:fs/promises' +import { execFileSync } from 'node:child_process' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { Effect, Redacted } from 'effect' @@ -175,3 +176,47 @@ test('keeps the permissions of an existing env file', async () => { expect((await stat(file)).mode & 0o777).toBe(0o644) }) + +test.each([ + [['.env.local'], '.env.local'], + [[], '.env'], +])( + 'writes to the default env file when %j exists', + async (existing, expected) => { + for (const file of existing) await writeFile(join(dir, file), '') + const cwd = process.cwd() + process.chdir(dir) + try { + const { promise } = run([]) + await promise + } finally { + process.chdir(cwd) + } + + expect(await readFile(join(dir, expected), 'utf8')).toContain( + 'POLAR_ACCESS_TOKEN=polar_oat_secret', + ) + }, +) + +test.each([ + ['', true], + ['.env\n', false], +])( + 'warns when git does not ignore the env file (.gitignore %j)', + async (gitignore, warned) => { + execFileSync('git', ['init', '--quiet', dir]) + await writeFile(join(dir, '.gitignore'), gitignore) + const { promise, output } = run(['--env-file', join(dir, '.env')]) + await promise + + expect(output().includes('is not ignored by git')).toBe(warned) + }, +) + +test('does not warn outside a git repository', async () => { + const { promise, output } = run(['--env-file', join(dir, '.env')]) + await promise + + expect(output()).not.toContain('is not ignored by git') +}) diff --git a/clients/packages/cli/src/commands/token.ts b/clients/packages/cli/src/commands/token.ts index 7ca64741d5f..684a5ee4c6e 100644 --- a/clients/packages/cli/src/commands/token.ts +++ b/clients/packages/cli/src/commands/token.ts @@ -1,14 +1,10 @@ -import { Effect, FileSystem, Path, Redacted, Schema } from 'effect' +import { Effect, Option, Path, Redacted, Schema } from 'effect' import { Command, Flag } from 'effect/cli' -import { AuthError, PolarEnvironment } from '@/schemas/Auth' +import { PolarEnvironment } from '@/schemas/Auth' import { Organizations } from '@/services/organizations' -import { - ENVIRONMENT_VARS, - environmentSettings, - TokenHandoff, -} from '@/services/token-handoff' +import { defaultEnvFile, isExposedToGit, writeEnv } from '@/services/env-file' +import { environmentSettings, TokenHandoff } from '@/services/token-handoff' import { output } from '@/utils/command' -import { hasEnv, upsertEnv } from '@/utils/env-file' import { note, noteToStderr } from '@/utils/progress' import * as ui from '@/utils/ui' @@ -20,8 +16,10 @@ const scope = Flag.String('scope').pipe( ) const envFile = Flag.String('env-file').pipe( - Flag.withDefault('.env'), - Flag.withDescription('File to write the token to'), + Flag.optional, + Flag.withDescription( + 'File to write the token to. Defaults to .env.local if it exists, otherwise .env', + ), ) const stdout = Flag.Boolean('stdout').pipe( @@ -47,6 +45,7 @@ const Saved = Schema.Struct({ envFile: Schema.String, written: Schema.Array(Written), stale: Schema.Array(Schema.String), + exposedToGit: Schema.Boolean, }) const Printed = Schema.Struct({ @@ -82,42 +81,6 @@ const announceTokenPage = ].join('\n'), ) -const writeEnv = ( - file: string, - settings: ReadonlyArray, -) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - let content = yield* fs - .readFileString(file) - .pipe( - Effect.catch((error) => - error.reason._tag === 'NotFound' - ? Effect.succeed('') - : Effect.fail(error), - ), - ) - const written: Array = [] - for (const [name, value] of settings) { - const result = upsertEnv(content, name, value) - content = result.content - written.push({ name, replaced: result.replaced }) - } - yield* fs.writeFileString(file, content, { mode: 0o600 }) - const stale = ENVIRONMENT_VARS.filter( - (name) => - hasEnv(content, name) && !settings.some(([key]) => key === name), - ) - return { written, stale } - }).pipe( - Effect.mapError( - () => - new AuthError({ - message: `Unable to write the token to ${file}. Check its permissions. The token was created; revoke it from the dashboard if you can't recover it.`, - }), - ), - ) - export const token = Command.make('token', { scope, envFile, @@ -130,7 +93,7 @@ export const token = Command.make('token', { { command: 'polar auth token', description: - 'Create a token for the active organization and add it to .env', + 'Create a token for the active organization and add it to .env.local or .env', }, { command: @@ -169,7 +132,9 @@ export const token = Command.make('token', { variables: settings.map(([name, value]) => ({ name, value })), } satisfies Created } - const file = path.resolve(flags.envFile) + const file = Option.isSome(flags.envFile) + ? path.resolve(flags.envFile.value) + : yield* defaultEnvFile const { written, stale } = yield* writeEnv(file, settings) return { destination: 'file', @@ -177,13 +142,14 @@ export const token = Command.make('token', { envFile: file, written, stale, + exposedToGit: yield* isExposedToGit(file), } satisfies Created }), render: (created: Created) => { if (created.destination === 'stdout') { return created.variables.map(({ name, value }) => `${name}=${value}`) } - const { organization, envFile, written, stale } = created + const { organization, envFile, written, stale, exposedToGit } = created const hint = sdkHints[organization.environment] return [ ui.success( @@ -197,6 +163,13 @@ export const token = Command.make('token', { `${name} in ${envFile} doesn't match this ${organization.environment} token. Remove it.`, ), ), + ...(exposedToGit + ? [ + ui.warning( + `${envFile} is not ignored by git. Add it to .gitignore before you commit.`, + ), + ] + : []), ...(hint ? [ui.step(hint)] : []), ui.blank, ] diff --git a/clients/packages/cli/src/services/env-file.ts b/clients/packages/cli/src/services/env-file.ts new file mode 100644 index 00000000000..9d6a3031ae7 --- /dev/null +++ b/clients/packages/cli/src/services/env-file.ts @@ -0,0 +1,64 @@ +import { Effect, FileSystem, Path } from 'effect' +import { AuthError } from '@/schemas/Auth' +import { ENVIRONMENT_VARS } from '@/services/token-handoff' +import { exec } from '@/services/updater' +import { hasEnv, upsertEnv } from '@/utils/env-file' + +export const defaultEnvFile = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const local = path.resolve('.env.local') + const exists = yield* fs.exists(local).pipe(Effect.orElseSucceed(() => false)) + return exists ? local : path.resolve('.env') +}) + +export const writeEnv = ( + file: string, + settings: ReadonlyArray, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + let content = yield* fs + .readFileString(file) + .pipe( + Effect.catch((error) => + error.reason._tag === 'NotFound' + ? Effect.succeed('') + : Effect.fail(error), + ), + ) + const written: Array<{ name: string; replaced: boolean }> = [] + for (const [name, value] of settings) { + const result = upsertEnv(content, name, value) + content = result.content + written.push({ name, replaced: result.replaced }) + } + yield* fs.writeFileString(file, content, { mode: 0o600 }) + const stale = ENVIRONMENT_VARS.filter( + (name) => + hasEnv(content, name) && !settings.some(([key]) => key === name), + ) + return { written, stale } + }).pipe( + Effect.mapError( + () => + new AuthError({ + message: `Unable to write the token to ${file}. Check its permissions. The token was created; revoke it from the dashboard if you can't recover it.`, + }), + ), + ) + +export const isExposedToGit = (file: string) => + Effect.gen(function* () { + const path = yield* Path.Path + const result = yield* exec([ + 'git', + '-C', + path.dirname(file), + 'check-ignore', + '--quiet', + '--', + path.basename(file), + ]) + return result.code === 1 + }).pipe(Effect.orElseSucceed(() => false)) diff --git a/docs/integrate/cli/reference/auth.mdx b/docs/integrate/cli/reference/auth.mdx index 5b48db63488..f7244d6cdb6 100644 --- a/docs/integrate/cli/reference/auth.mdx +++ b/docs/integrate/cli/reference/auth.mdx @@ -89,12 +89,12 @@ polar auth token [flags] | Flag | Type | Description | | --- | --- | --- | | `--scope` | `string` | Scope to preselect on the token page, e.g. products:read. Repeatable. | -| `--env-file` | `string` | File to write the token to | +| `--env-file` | `string` | File to write the token to. Defaults to .env.local if it exists, otherwise .env | | `--stdout` | `boolean` | Print the variables as KEY=value lines instead of writing --env-file, to pipe into another command | **Examples** -Create a token for the active organization and add it to .env +Create a token for the active organization and add it to .env.local or .env ```bash polar auth token ``` From 1c84132ac9c473c2e3f73afac6ebb215f25aea4f Mon Sep 17 00:00:00 2001 From: Pieter Beulque Date: Wed, 14 Oct 2026 09:14:46 +0200 Subject: [PATCH 5/8] refactor(cli): make auth token text-only instead of supporting --json Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/lint/rules/commands-output-json.js | 2 +- .../packages/cli/src/commands/token.test.ts | 13 ---- clients/packages/cli/src/commands/token.ts | 64 ++++++++----------- 3 files changed, 29 insertions(+), 50 deletions(-) diff --git a/clients/packages/cli/lint/rules/commands-output-json.js b/clients/packages/cli/lint/rules/commands-output-json.js index ab078119ee6..cafe5c58ab2 100644 --- a/clients/packages/cli/lint/rules/commands-output-json.js +++ b/clients/packages/cli/lint/rules/commands-output-json.js @@ -8,7 +8,7 @@ import { } from '../ast.js' // Paths under src/, e.g. 'commands/auth/token.ts' -const TEXT_ONLY = [] +const TEXT_ONLY = ['commands/token.ts'] const printers = new Set(['Console', 'printJson']) diff --git a/clients/packages/cli/src/commands/token.test.ts b/clients/packages/cli/src/commands/token.test.ts index c67b4f8a767..a41ce9fa223 100644 --- a/clients/packages/cli/src/commands/token.test.ts +++ b/clients/packages/cli/src/commands/token.test.ts @@ -146,19 +146,6 @@ test('prints only the variables with --stdout and writes no file', async () => { await expect(access(file)).rejects.toThrow() }) -test('includes the variables in JSON output with --stdout', async () => { - const { promise, output } = run(['--stdout', '--json']) - await promise - - expect(JSON.parse(output())).toMatchObject({ - destination: 'stdout', - variables: [ - { name: 'POLAR_ACCESS_TOKEN', value: 'polar_oat_secret' }, - { name: 'POLAR_ENVIRONMENT', value: 'sandbox' }, - ], - }) -}) - test('creates a new env file readable only by the owner', async () => { const file = join(dir, '.env') const { promise } = run(['--env-file', file]) diff --git a/clients/packages/cli/src/commands/token.ts b/clients/packages/cli/src/commands/token.ts index 684a5ee4c6e..7dc9df54419 100644 --- a/clients/packages/cli/src/commands/token.ts +++ b/clients/packages/cli/src/commands/token.ts @@ -1,11 +1,11 @@ -import { Effect, Option, Path, Redacted, Schema } from 'effect' +import { Effect, Option, Path, Redacted } from 'effect' import { Command, Flag } from 'effect/cli' -import { PolarEnvironment } from '@/schemas/Auth' +import type { PolarEnvironment } from '@/schemas/Auth' import { Organizations } from '@/services/organizations' import { defaultEnvFile, isExposedToGit, writeEnv } from '@/services/env-file' import { environmentSettings, TokenHandoff } from '@/services/token-handoff' -import { output } from '@/utils/command' -import { note, noteToStderr } from '@/utils/progress' +import { textOnly } from '@/utils/command' +import { noteToStderr } from '@/utils/progress' import * as ui from '@/utils/ui' const scope = Flag.String('scope').pipe( @@ -29,35 +29,25 @@ const stdout = Flag.Boolean('stdout').pipe( ), ) -const Target = Schema.Struct({ - slug: Schema.String, - environment: PolarEnvironment, -}) - -const Written = Schema.Struct({ - name: Schema.String, - replaced: Schema.Boolean, -}) - -const Saved = Schema.Struct({ - destination: Schema.Literal('file'), - organization: Target, - envFile: Schema.String, - written: Schema.Array(Written), - stale: Schema.Array(Schema.String), - exposedToGit: Schema.Boolean, -}) - -const Printed = Schema.Struct({ - destination: Schema.Literal('stdout'), - organization: Target, - variables: Schema.Array( - Schema.Struct({ name: Schema.String, value: Schema.String }), - ), -}) +interface Target { + readonly slug: string + readonly environment: PolarEnvironment +} -const Created = Schema.Union([Saved, Printed]) -type Created = typeof Created.Type +type Created = + | { + readonly destination: 'file' + readonly organization: Target + readonly envFile: string + readonly written: ReadonlyArray<{ name: string; replaced: boolean }> + readonly stale: ReadonlyArray + readonly exposedToGit: boolean + } + | { + readonly destination: 'stdout' + readonly organization: Target + readonly variables: ReadonlyArray<{ name: string; value: string }> + } const sdkHints: Record = { production: undefined, @@ -106,9 +96,9 @@ export const token = Command.make('token', { 'Pipe the variables into a hosting provider without writing them to disk', }, ]), - output({ - result: Created, - run: (flags) => + textOnly({ + reason: 'the token goes into your env file, or with --stdout into a pipe', + run: (flags, { say }) => Effect.gen(function* () { const path = yield* Path.Path const { slug, environment } = yield* (yield* Organizations).resolve() @@ -119,7 +109,9 @@ export const token = Command.make('token', { name: path.basename(process.cwd()), scopes: flags.scope, }, - announceTokenPage(flags.stdout ? noteToStderr : note), + announceTokenPage( + flags.stdout ? noteToStderr : (text) => say([text]), + ), ) const settings: ReadonlyArray = [ ['POLAR_ACCESS_TOKEN', Redacted.value(created)], From 9d8c49469f38f63d85301d44929c6e5a061e9851 Mon Sep 17 00:00:00 2001 From: Pieter Beulque Date: Wed, 14 Oct 2026 10:05:27 +0200 Subject: [PATCH 6/8] refactor(cli): print the auth token to stdout instead of writing an env file Drop --stdout and --env-file. auth token now prints KEY=value lines on stdout and leaves storing them to the caller; prompts and hints go to stderr so the output can be piped or redirected. Co-Authored-By: Claude Opus 5.5 (1M context) --- clients/packages/cli/README.md | 2 +- .../packages/cli/src/commands/token.test.ts | 139 ++-------------- clients/packages/cli/src/commands/token.ts | 152 ++++-------------- clients/packages/cli/src/services/env-file.ts | 64 -------- .../cli/src/services/token-handoff.ts | 2 - .../packages/cli/src/utils/env-file.test.ts | 55 ------- clients/packages/cli/src/utils/env-file.ts | 26 --- docs/integrate/cli/reference/auth.mdx | 19 +-- 8 files changed, 48 insertions(+), 411 deletions(-) delete mode 100644 clients/packages/cli/src/services/env-file.ts delete mode 100644 clients/packages/cli/src/utils/env-file.test.ts delete mode 100644 clients/packages/cli/src/utils/env-file.ts diff --git a/clients/packages/cli/README.md b/clients/packages/cli/README.md index b708827e77e..5e421f51489 100644 --- a/clients/packages/cli/README.md +++ b/clients/packages/cli/README.md @@ -66,7 +66,7 @@ setup above first: `auth login` refuses to run with `POLAR_ACCESS_TOKEN` set, an ```bash unset POLAR_API_URL POLAR_ACCESS_TOKEN bun src/cli.ts auth login --local -bun src/cli.ts auth token --env-file /tmp/polar-test.env +bun src/cli.ts auth token ``` ### Generated API commands diff --git a/clients/packages/cli/src/commands/token.test.ts b/clients/packages/cli/src/commands/token.test.ts index a41ce9fa223..b8648d50114 100644 --- a/clients/packages/cli/src/commands/token.test.ts +++ b/clients/packages/cli/src/commands/token.test.ts @@ -1,17 +1,6 @@ -import { - access, - chmod, - mkdtemp, - readFile, - rm, - stat, - writeFile, -} from 'node:fs/promises' -import { execFileSync } from 'node:child_process' -import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' +import { basename } from 'node:path' import { Effect, Redacted } from 'effect' -import { afterEach, beforeEach, expect, test } from 'vitest' +import { beforeEach, expect, test } from 'vitest' import { token as tokenCommand } from '@/commands/token' import type { ActiveOrganization, PolarEnvironment } from '@/schemas/Auth' import { Organizations } from '@/services/organizations' @@ -26,7 +15,6 @@ const acme: ActiveOrganization = { environment: 'sandbox', } -let dir: string let requests: TokenRequest[] let organizations: ReturnType @@ -51,8 +39,7 @@ const run = (args: string[]) => { return { promise, output: cli.output } } -beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'polar-test-')) +beforeEach(() => { requests = [] organizations = fakeOrganizations({ items: [acme], @@ -60,16 +47,8 @@ beforeEach(async () => { }) }) -afterEach(async () => { - await rm(dir, { recursive: true, force: true }) -}) - -test('writes the token to the env file without printing it', async () => { - const file = join(dir, '.env') - await writeFile(file, 'OTHER=1\nPOLAR_ACCESS_TOKEN=old\n') +test('prints the token and environment as KEY=value lines', async () => { const { promise, output } = run([ - '--env-file', - file, '--scope', 'products:read', '--scope', @@ -77,8 +56,8 @@ test('writes the token to the env file without printing it', async () => { ]) await promise - expect(await readFile(file, 'utf8')).toBe( - 'OTHER=1\nPOLAR_ACCESS_TOKEN=polar_oat_secret\nPOLAR_ENVIRONMENT=sandbox\n', + expect(output().trim()).toBe( + 'POLAR_ACCESS_TOKEN=polar_oat_secret\nPOLAR_ENVIRONMENT=sandbox', ) expect(requests).toEqual([ { @@ -87,8 +66,6 @@ test('writes the token to the env file without printing it', async () => { scopes: ['products:read', 'checkouts:write'], }, ]) - expect(output()).toContain('Updated POLAR_ACCESS_TOKEN') - expect(output()).not.toContain('polar_oat_secret') }) const selectEnvironment = (environment: PolarEnvironment) => { @@ -98,112 +75,20 @@ const selectEnvironment = (environment: PolarEnvironment) => { }) } -test('writes only the token for production', async () => { +test('prints only the token for production', async () => { selectEnvironment('production') - const file = join(dir, '.env') - const { promise } = run(['--env-file', file]) + const { promise, output } = run([]) await promise - expect(await readFile(file, 'utf8')).toBe( - 'POLAR_ACCESS_TOKEN=polar_oat_secret\n', - ) + expect(output().trim()).toBe('POLAR_ACCESS_TOKEN=polar_oat_secret') }) -test('writes the local API URL for a local session', async () => { +test('prints the local API URL for a local session', async () => { selectEnvironment('local') - const file = join(dir, '.env') - const { promise, output } = run(['--env-file', file]) - await promise - - expect(await readFile(file, 'utf8')).toBe( - 'POLAR_ACCESS_TOKEN=polar_oat_secret\nPOLAR_API_URL=http://127.0.0.1:8000\n', - ) - expect(output()).toContain('serverURL: process.env.POLAR_API_URL') -}) - -test('warns about environment variables left from another environment', async () => { - selectEnvironment('production') - const file = join(dir, '.env') - await writeFile(file, 'POLAR_ENVIRONMENT=sandbox\n') - const { promise, output } = run(['--env-file', file]) - await promise - - expect(await readFile(file, 'utf8')).toBe( - 'POLAR_ENVIRONMENT=sandbox\nPOLAR_ACCESS_TOKEN=polar_oat_secret\n', - ) - expect(output()).toContain('POLAR_ENVIRONMENT in') - expect(output()).toContain("doesn't match this production token") -}) - -test('prints only the variables with --stdout and writes no file', async () => { - const file = join(dir, '.env') - const { promise, output } = run(['--stdout', '--env-file', file]) + const { promise, output } = run([]) await promise expect(output().trim()).toBe( - 'POLAR_ACCESS_TOKEN=polar_oat_secret\nPOLAR_ENVIRONMENT=sandbox', + 'POLAR_ACCESS_TOKEN=polar_oat_secret\nPOLAR_API_URL=http://127.0.0.1:8000', ) - await expect(access(file)).rejects.toThrow() -}) - -test('creates a new env file readable only by the owner', async () => { - const file = join(dir, '.env') - const { promise } = run(['--env-file', file]) - await promise - - expect((await stat(file)).mode & 0o777).toBe(0o600) -}) - -test('keeps the permissions of an existing env file', async () => { - const file = join(dir, '.env') - await writeFile(file, 'A=1\n') - await chmod(file, 0o644) - const { promise } = run(['--env-file', file]) - await promise - - expect((await stat(file)).mode & 0o777).toBe(0o644) -}) - -test.each([ - [['.env.local'], '.env.local'], - [[], '.env'], -])( - 'writes to the default env file when %j exists', - async (existing, expected) => { - for (const file of existing) await writeFile(join(dir, file), '') - const cwd = process.cwd() - process.chdir(dir) - try { - const { promise } = run([]) - await promise - } finally { - process.chdir(cwd) - } - - expect(await readFile(join(dir, expected), 'utf8')).toContain( - 'POLAR_ACCESS_TOKEN=polar_oat_secret', - ) - }, -) - -test.each([ - ['', true], - ['.env\n', false], -])( - 'warns when git does not ignore the env file (.gitignore %j)', - async (gitignore, warned) => { - execFileSync('git', ['init', '--quiet', dir]) - await writeFile(join(dir, '.gitignore'), gitignore) - const { promise, output } = run(['--env-file', join(dir, '.env')]) - await promise - - expect(output().includes('is not ignored by git')).toBe(warned) - }, -) - -test('does not warn outside a git repository', async () => { - const { promise, output } = run(['--env-file', join(dir, '.env')]) - await promise - - expect(output()).not.toContain('is not ignored by git') }) diff --git a/clients/packages/cli/src/commands/token.ts b/clients/packages/cli/src/commands/token.ts index 7dc9df54419..b30cb523386 100644 --- a/clients/packages/cli/src/commands/token.ts +++ b/clients/packages/cli/src/commands/token.ts @@ -1,8 +1,7 @@ -import { Effect, Option, Path, Redacted } from 'effect' +import { Effect, Path, Redacted } from 'effect' import { Command, Flag } from 'effect/cli' import type { PolarEnvironment } from '@/schemas/Auth' import { Organizations } from '@/services/organizations' -import { defaultEnvFile, isExposedToGit, writeEnv } from '@/services/env-file' import { environmentSettings, TokenHandoff } from '@/services/token-handoff' import { textOnly } from '@/utils/command' import { noteToStderr } from '@/utils/progress' @@ -15,156 +14,63 @@ const scope = Flag.String('scope').pipe( Flag.atLeast(0), ) -const envFile = Flag.String('env-file').pipe( - Flag.optional, - Flag.withDescription( - 'File to write the token to. Defaults to .env.local if it exists, otherwise .env', - ), -) - -const stdout = Flag.Boolean('stdout').pipe( - Flag.withDefault(false), - Flag.withDescription( - 'Print the variables as KEY=value lines instead of writing --env-file, to pipe into another command', - ), -) - -interface Target { - readonly slug: string - readonly environment: PolarEnvironment -} - -type Created = - | { - readonly destination: 'file' - readonly organization: Target - readonly envFile: string - readonly written: ReadonlyArray<{ name: string; replaced: boolean }> - readonly stale: ReadonlyArray - readonly exposedToGit: boolean - } - | { - readonly destination: 'stdout' - readonly organization: Target - readonly variables: ReadonlyArray<{ name: string; value: string }> - } - const sdkHints: Record = { production: undefined, sandbox: 'Pass server: process.env.POLAR_ENVIRONMENT to your Polar SDK', local: 'Pass serverURL: process.env.POLAR_API_URL to your Polar SDK', } -const announceTokenPage = - (print: (text: string) => Effect.Effect) => (page: URL) => - print( - [ - ui.blank, - ui.step( - 'Opening your browser to create an organization access token...', - ), - ui.step('If it does not open, visit:'), - ` ${ui.cyan(page.toString())}`, - ui.blank, - ui.step('Waiting for the token...'), - ui.blank, - ].join('\n'), - ) +const announceTokenPage = (page: URL) => + noteToStderr( + [ + ui.blank, + ui.step('Opening your browser to create an organization access token...'), + ui.step('If it does not open, visit:'), + ` ${ui.cyan(page.toString())}`, + ui.blank, + ui.step('Waiting for the token...'), + ui.blank, + ].join('\n'), + ) -export const token = Command.make('token', { - scope, - envFile, - stdout, -}).pipe( +export const token = Command.make('token', { scope }).pipe( Command.withDescription( - 'Create an organization access token for your app and save it to an env file', + 'Create an organization access token for your app and print it as KEY=value lines', ), Command.withExamples([ { - command: 'polar auth token', - description: - 'Create a token for the active organization and add it to .env.local or .env', + command: 'polar auth token >> .env.local', + description: 'Add the token for the active organization to .env.local', }, { command: - 'polar auth token --scope products:read --scope checkouts:write --env-file .env.local', - description: 'Preselect scopes and write to .env.local', - }, - { - command: 'polar auth token --stdout | fly secrets import', + 'polar auth token --scope products:read --scope checkouts:write | fly secrets import', description: - 'Pipe the variables into a hosting provider without writing them to disk', + 'Preselect scopes and pipe the variables into a hosting provider without writing them to disk', }, ]), textOnly({ - reason: 'the token goes into your env file, or with --stdout into a pipe', - run: (flags, { say }) => + reason: 'it prints the token as KEY=value lines', + run: (flags) => Effect.gen(function* () { const path = yield* Path.Path const { slug, environment } = yield* (yield* Organizations).resolve() - const organization = { slug, environment } const created = yield* (yield* TokenHandoff).receive( { - organization, + organization: { slug, environment }, name: path.basename(process.cwd()), scopes: flags.scope, }, - announceTokenPage( - flags.stdout ? noteToStderr : (text) => say([text]), - ), + announceTokenPage, ) - const settings: ReadonlyArray = [ - ['POLAR_ACCESS_TOKEN', Redacted.value(created)], + const hint = sdkHints[environment] + if (hint) yield* noteToStderr(ui.step(hint)) + return [ + ['POLAR_ACCESS_TOKEN', Redacted.value(created)] as const, ...(yield* environmentSettings(environment)), ] - if (flags.stdout) { - return { - destination: 'stdout', - organization, - variables: settings.map(([name, value]) => ({ name, value })), - } satisfies Created - } - const file = Option.isSome(flags.envFile) - ? path.resolve(flags.envFile.value) - : yield* defaultEnvFile - const { written, stale } = yield* writeEnv(file, settings) - return { - destination: 'file', - organization, - envFile: file, - written, - stale, - exposedToGit: yield* isExposedToGit(file), - } satisfies Created }), - render: (created: Created) => { - if (created.destination === 'stdout') { - return created.variables.map(({ name, value }) => `${name}=${value}`) - } - const { organization, envFile, written, stale, exposedToGit } = created - const hint = sdkHints[organization.environment] - return [ - ui.success( - `Created an access token for ${ui.bold(organization.slug)} ${ui.dim(organization.environment)}`, - ), - ...written.map(({ name, replaced }) => - ui.step(`${replaced ? 'Updated' : 'Added'} ${name} in ${envFile}`), - ), - ...stale.map((name) => - ui.warning( - `${name} in ${envFile} doesn't match this ${organization.environment} token. Remove it.`, - ), - ), - ...(exposedToGit - ? [ - ui.warning( - `${envFile} is not ignored by git. Add it to .gitignore before you commit.`, - ), - ] - : []), - ...(hint ? [ui.step(hint)] : []), - ui.blank, - ] - }, + render: (variables: ReadonlyArray) => + variables.map(([name, value]) => `${name}=${value}`), }), ) diff --git a/clients/packages/cli/src/services/env-file.ts b/clients/packages/cli/src/services/env-file.ts deleted file mode 100644 index 9d6a3031ae7..00000000000 --- a/clients/packages/cli/src/services/env-file.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Effect, FileSystem, Path } from 'effect' -import { AuthError } from '@/schemas/Auth' -import { ENVIRONMENT_VARS } from '@/services/token-handoff' -import { exec } from '@/services/updater' -import { hasEnv, upsertEnv } from '@/utils/env-file' - -export const defaultEnvFile = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const local = path.resolve('.env.local') - const exists = yield* fs.exists(local).pipe(Effect.orElseSucceed(() => false)) - return exists ? local : path.resolve('.env') -}) - -export const writeEnv = ( - file: string, - settings: ReadonlyArray, -) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - let content = yield* fs - .readFileString(file) - .pipe( - Effect.catch((error) => - error.reason._tag === 'NotFound' - ? Effect.succeed('') - : Effect.fail(error), - ), - ) - const written: Array<{ name: string; replaced: boolean }> = [] - for (const [name, value] of settings) { - const result = upsertEnv(content, name, value) - content = result.content - written.push({ name, replaced: result.replaced }) - } - yield* fs.writeFileString(file, content, { mode: 0o600 }) - const stale = ENVIRONMENT_VARS.filter( - (name) => - hasEnv(content, name) && !settings.some(([key]) => key === name), - ) - return { written, stale } - }).pipe( - Effect.mapError( - () => - new AuthError({ - message: `Unable to write the token to ${file}. Check its permissions. The token was created; revoke it from the dashboard if you can't recover it.`, - }), - ), - ) - -export const isExposedToGit = (file: string) => - Effect.gen(function* () { - const path = yield* Path.Path - const result = yield* exec([ - 'git', - '-C', - path.dirname(file), - 'check-ignore', - '--quiet', - '--', - path.basename(file), - ]) - return result.code === 1 - }).pipe(Effect.orElseSucceed(() => false)) diff --git a/clients/packages/cli/src/services/token-handoff.ts b/clients/packages/cli/src/services/token-handoff.ts index 3e81dc8dd19..4b93ae6bf4c 100644 --- a/clients/packages/cli/src/services/token-handoff.ts +++ b/clients/packages/cli/src/services/token-handoff.ts @@ -17,8 +17,6 @@ import { awaitCallback, type CallbackResult } from '@/utils/loopback' const CALLBACK_PATH = '/token/callback' -export const ENVIRONMENT_VARS = ['POLAR_ENVIRONMENT', 'POLAR_API_URL'] as const - export const environmentSettings = (environment: PolarEnvironment) => Effect.gen(function* () { switch (environment) { diff --git a/clients/packages/cli/src/utils/env-file.test.ts b/clients/packages/cli/src/utils/env-file.test.ts deleted file mode 100644 index af7ddedf02b..00000000000 --- a/clients/packages/cli/src/utils/env-file.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { expect, test } from 'vitest' -import { hasEnv, upsertEnv } from '@/utils/env-file' - -test('creates the variable in an empty file', () => { - expect(upsertEnv('', 'POLAR_ACCESS_TOKEN', 'polar_oat_1')).toEqual({ - content: 'POLAR_ACCESS_TOKEN=polar_oat_1\n', - replaced: false, - }) -}) - -test('appends the variable on a new line', () => { - expect(upsertEnv('A=1', 'POLAR_ACCESS_TOKEN', 'polar_oat_1')).toEqual({ - content: 'A=1\nPOLAR_ACCESS_TOKEN=polar_oat_1\n', - replaced: false, - }) -}) - -test.each([ - 'POLAR_ACCESS_TOKEN=old', - 'export POLAR_ACCESS_TOKEN=old', - ' POLAR_ACCESS_TOKEN = "old"', -])('replaces an existing assignment: %s', (existing) => { - expect( - upsertEnv(`A=1\n${existing}\nB=2\n`, 'POLAR_ACCESS_TOKEN', 'polar_oat_1'), - ).toEqual({ - content: 'A=1\nPOLAR_ACCESS_TOKEN=polar_oat_1\nB=2\n', - replaced: true, - }) -}) - -test('removes later duplicates so the new value wins', () => { - expect( - upsertEnv( - 'POLAR_ACCESS_TOKEN=a\nB=2\nexport POLAR_ACCESS_TOKEN=b\n', - 'POLAR_ACCESS_TOKEN', - 'new', - ), - ).toEqual({ content: 'POLAR_ACCESS_TOKEN=new\nB=2\n', replaced: true }) -}) - -test('leaves variables that share a prefix alone', () => { - expect( - upsertEnv('POLAR_ACCESS_TOKEN_OLD=x\n', 'POLAR_ACCESS_TOKEN', 'new'), - ).toEqual({ - content: 'POLAR_ACCESS_TOKEN_OLD=x\nPOLAR_ACCESS_TOKEN=new\n', - replaced: false, - }) -}) - -test('finds an existing assignment', () => { - expect( - hasEnv('A=1\nexport POLAR_ENVIRONMENT=sandbox\n', 'POLAR_ENVIRONMENT'), - ).toBe(true) - expect(hasEnv('POLAR_ENVIRONMENT_X=1\n', 'POLAR_ENVIRONMENT')).toBe(false) -}) diff --git a/clients/packages/cli/src/utils/env-file.ts b/clients/packages/cli/src/utils/env-file.ts deleted file mode 100644 index 2e9399b4c9b..00000000000 --- a/clients/packages/cli/src/utils/env-file.ts +++ /dev/null @@ -1,26 +0,0 @@ -const assignment = (key: string) => - new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`) - -export const upsertEnv = (content: string, key: string, value: string) => { - const line = `${key}=${value}` - const pattern = assignment(key) - const lines = content.split('\n') - const index = lines.findIndex((existing) => pattern.test(existing)) - if (index !== -1) { - const updated = lines.flatMap((existing, position) => - position === index - ? [line] - : position > index && pattern.test(existing) - ? [] - : [existing], - ) - return { content: updated.join('\n'), replaced: true } - } - const separator = content === '' || content.endsWith('\n') ? '' : '\n' - return { content: `${content}${separator}${line}\n`, replaced: false } -} - -export const hasEnv = (content: string, key: string) => { - const pattern = assignment(key) - return content.split('\n').some((line) => pattern.test(line)) -} diff --git a/docs/integrate/cli/reference/auth.mdx b/docs/integrate/cli/reference/auth.mdx index f7244d6cdb6..653ce1f9236 100644 --- a/docs/integrate/cli/reference/auth.mdx +++ b/docs/integrate/cli/reference/auth.mdx @@ -16,7 +16,7 @@ polar auth [flags] - [`polar auth whoami`](#polar-auth-whoami) Show the active organization - [`polar auth list`](#polar-auth-list) List the organizations you have access to - [`polar auth org`](#polar-auth-org) Choose the active organization -- [`polar auth token`](#polar-auth-token) Create an organization access token for your app and save it to an env file +- [`polar auth token`](#polar-auth-token) Create an organization access token for your app and print it as KEY=value lines - [`polar auth logout`](#polar-auth-logout) Sign out and remove saved sessions ## polar auth login @@ -78,7 +78,7 @@ polar auth org [flags] ## polar auth token -Create an organization access token for your app and save it to an env file +Create an organization access token for your app and print it as KEY=value lines ```bash polar auth token [flags] @@ -89,24 +89,17 @@ polar auth token [flags] | Flag | Type | Description | | --- | --- | --- | | `--scope` | `string` | Scope to preselect on the token page, e.g. products:read. Repeatable. | -| `--env-file` | `string` | File to write the token to. Defaults to .env.local if it exists, otherwise .env | -| `--stdout` | `boolean` | Print the variables as KEY=value lines instead of writing --env-file, to pipe into another command | **Examples** -Create a token for the active organization and add it to .env.local or .env +Add the token for the active organization to .env.local ```bash -polar auth token +polar auth token >> .env.local ``` -Preselect scopes and write to .env.local +Preselect scopes and pipe the variables into a hosting provider without writing them to disk ```bash -polar auth token --scope products:read --scope checkouts:write --env-file .env.local -``` - -Pipe the variables into a hosting provider without writing them to disk -```bash -polar auth token --stdout | fly secrets import +polar auth token --scope products:read --scope checkouts:write | fly secrets import ``` ## polar auth logout From bc6deba4073fd9d15d17d1a3e3568cc36d8bc277 Mon Sep 17 00:00:00 2001 From: Pieter Beulque Date: Wed, 14 Oct 2026 10:10:47 +0200 Subject: [PATCH 7/8] refactor(cli): print only the token from auth token Drop the KEY=value lines so the output can go straight into a secret store or a variable. The environment becomes an SDK hint on stderr. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../packages/cli/src/commands/token.test.ts | 33 ++----------------- clients/packages/cli/src/commands/token.ts | 31 ++++++----------- .../cli/src/services/token-handoff.ts | 8 ++--- docs/integrate/cli/reference/auth.mdx | 12 +++---- 4 files changed, 23 insertions(+), 61 deletions(-) diff --git a/clients/packages/cli/src/commands/token.test.ts b/clients/packages/cli/src/commands/token.test.ts index b8648d50114..62d04c136f3 100644 --- a/clients/packages/cli/src/commands/token.test.ts +++ b/clients/packages/cli/src/commands/token.test.ts @@ -2,7 +2,7 @@ import { basename } from 'node:path' import { Effect, Redacted } from 'effect' import { beforeEach, expect, test } from 'vitest' import { token as tokenCommand } from '@/commands/token' -import type { ActiveOrganization, PolarEnvironment } from '@/schemas/Auth' +import type { ActiveOrganization } from '@/schemas/Auth' import { Organizations } from '@/services/organizations' import { type TokenRequest, TokenHandoff } from '@/services/token-handoff' import { runCli } from '@/utils/test-utils/cli' @@ -47,7 +47,7 @@ beforeEach(() => { }) }) -test('prints the token and environment as KEY=value lines', async () => { +test('prints only the token', async () => { const { promise, output } = run([ '--scope', 'products:read', @@ -56,9 +56,7 @@ test('prints the token and environment as KEY=value lines', async () => { ]) await promise - expect(output().trim()).toBe( - 'POLAR_ACCESS_TOKEN=polar_oat_secret\nPOLAR_ENVIRONMENT=sandbox', - ) + expect(output().trim()).toBe('polar_oat_secret') expect(requests).toEqual([ { organization: { slug: 'acme', environment: 'sandbox' }, @@ -67,28 +65,3 @@ test('prints the token and environment as KEY=value lines', async () => { }, ]) }) - -const selectEnvironment = (environment: PolarEnvironment) => { - organizations = fakeOrganizations({ - items: [{ ...acme, environment }], - selected: { id: acme.id, environment }, - }) -} - -test('prints only the token for production', async () => { - selectEnvironment('production') - const { promise, output } = run([]) - await promise - - expect(output().trim()).toBe('POLAR_ACCESS_TOKEN=polar_oat_secret') -}) - -test('prints the local API URL for a local session', async () => { - selectEnvironment('local') - const { promise, output } = run([]) - await promise - - expect(output().trim()).toBe( - 'POLAR_ACCESS_TOKEN=polar_oat_secret\nPOLAR_API_URL=http://127.0.0.1:8000', - ) -}) diff --git a/clients/packages/cli/src/commands/token.ts b/clients/packages/cli/src/commands/token.ts index b30cb523386..8c309931637 100644 --- a/clients/packages/cli/src/commands/token.ts +++ b/clients/packages/cli/src/commands/token.ts @@ -1,8 +1,7 @@ import { Effect, Path, Redacted } from 'effect' import { Command, Flag } from 'effect/cli' -import type { PolarEnvironment } from '@/schemas/Auth' import { Organizations } from '@/services/organizations' -import { environmentSettings, TokenHandoff } from '@/services/token-handoff' +import { sdkHint, TokenHandoff } from '@/services/token-handoff' import { textOnly } from '@/utils/command' import { noteToStderr } from '@/utils/progress' import * as ui from '@/utils/ui' @@ -14,12 +13,6 @@ const scope = Flag.String('scope').pipe( Flag.atLeast(0), ) -const sdkHints: Record = { - production: undefined, - sandbox: 'Pass server: process.env.POLAR_ENVIRONMENT to your Polar SDK', - local: 'Pass serverURL: process.env.POLAR_API_URL to your Polar SDK', -} - const announceTokenPage = (page: URL) => noteToStderr( [ @@ -35,22 +28,22 @@ const announceTokenPage = (page: URL) => export const token = Command.make('token', { scope }).pipe( Command.withDescription( - 'Create an organization access token for your app and print it as KEY=value lines', + 'Create an organization access token for your app and print it', ), Command.withExamples([ { - command: 'polar auth token >> .env.local', - description: 'Add the token for the active organization to .env.local', + command: 'echo "POLAR_ACCESS_TOKEN=$(polar auth token)" >> .env.local', + description: 'Add a token for the active organization to .env.local', }, { command: - 'polar auth token --scope products:read --scope checkouts:write | fly secrets import', + 'polar auth token --scope products:read --scope checkouts:write | gh secret set POLAR_ACCESS_TOKEN', description: - 'Preselect scopes and pipe the variables into a hosting provider without writing them to disk', + 'Preselect scopes and store the token as a GitHub Actions secret without writing it to disk', }, ]), textOnly({ - reason: 'it prints the token as KEY=value lines', + reason: 'it prints only the token', run: (flags) => Effect.gen(function* () { const path = yield* Path.Path @@ -63,14 +56,10 @@ export const token = Command.make('token', { scope }).pipe( }, announceTokenPage, ) - const hint = sdkHints[environment] + const hint = yield* sdkHint(environment) if (hint) yield* noteToStderr(ui.step(hint)) - return [ - ['POLAR_ACCESS_TOKEN', Redacted.value(created)] as const, - ...(yield* environmentSettings(environment)), - ] + return Redacted.value(created) }), - render: (variables: ReadonlyArray) => - variables.map(([name, value]) => `${name}=${value}`), + render: (created: string) => [created], }), ) diff --git a/clients/packages/cli/src/services/token-handoff.ts b/clients/packages/cli/src/services/token-handoff.ts index 4b93ae6bf4c..56323c324a5 100644 --- a/clients/packages/cli/src/services/token-handoff.ts +++ b/clients/packages/cli/src/services/token-handoff.ts @@ -17,15 +17,15 @@ import { awaitCallback, type CallbackResult } from '@/utils/loopback' const CALLBACK_PATH = '/token/callback' -export const environmentSettings = (environment: PolarEnvironment) => +export const sdkHint = (environment: PolarEnvironment) => Effect.gen(function* () { switch (environment) { case 'production': - return [] + return undefined case 'sandbox': - return [['POLAR_ENVIRONMENT', 'sandbox'] as const] + return "This is a sandbox token. Pass server: 'sandbox' to your Polar SDK." case 'local': - return [['POLAR_API_URL', yield* apiOrigin('local')] as const] + return `This is a local token. Pass serverURL: '${yield* apiOrigin('local')}' to your Polar SDK.` } }) diff --git a/docs/integrate/cli/reference/auth.mdx b/docs/integrate/cli/reference/auth.mdx index 653ce1f9236..749c4be6133 100644 --- a/docs/integrate/cli/reference/auth.mdx +++ b/docs/integrate/cli/reference/auth.mdx @@ -16,7 +16,7 @@ polar auth [flags] - [`polar auth whoami`](#polar-auth-whoami) Show the active organization - [`polar auth list`](#polar-auth-list) List the organizations you have access to - [`polar auth org`](#polar-auth-org) Choose the active organization -- [`polar auth token`](#polar-auth-token) Create an organization access token for your app and print it as KEY=value lines +- [`polar auth token`](#polar-auth-token) Create an organization access token for your app and print it - [`polar auth logout`](#polar-auth-logout) Sign out and remove saved sessions ## polar auth login @@ -78,7 +78,7 @@ polar auth org [flags] ## polar auth token -Create an organization access token for your app and print it as KEY=value lines +Create an organization access token for your app and print it ```bash polar auth token [flags] @@ -92,14 +92,14 @@ polar auth token [flags] **Examples** -Add the token for the active organization to .env.local +Add a token for the active organization to .env.local ```bash -polar auth token >> .env.local +echo "POLAR_ACCESS_TOKEN=$(polar auth token)" >> .env.local ``` -Preselect scopes and pipe the variables into a hosting provider without writing them to disk +Preselect scopes and store the token as a GitHub Actions secret without writing it to disk ```bash -polar auth token --scope products:read --scope checkouts:write | fly secrets import +polar auth token --scope products:read --scope checkouts:write | gh secret set POLAR_ACCESS_TOKEN ``` ## polar auth logout From a039fdc1ed855b832aa375f356ca671a097df26f Mon Sep 17 00:00:00 2001 From: Pieter Beulque Date: Wed, 14 Oct 2026 11:21:58 +0200 Subject: [PATCH 8/8] refactor(cli): drop the redundant state check from parseHandoff awaitCallback already rejects callbacks with the wrong state before parsing them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/src/services/token-handoff.test.ts | 17 +++++++---------- .../packages/cli/src/services/token-handoff.ts | 6 +----- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/clients/packages/cli/src/services/token-handoff.test.ts b/clients/packages/cli/src/services/token-handoff.test.ts index 87692f2f4cc..881f4bedadc 100644 --- a/clients/packages/cli/src/services/token-handoff.test.ts +++ b/clients/packages/cli/src/services/token-handoff.test.ts @@ -75,8 +75,7 @@ test('decrypts a token encrypted by the browser', async () => { const { privateKey, spki } = keys() const encrypted = await encryptLikeTheBrowser(spki, 'polar_oat_secret') const result = parseHandoff( - callback({ state: 'expected', encrypted_token: encrypted }), - 'expected', + callback({ encrypted_token: encrypted }), privateKey, ) expect(result.outcome).toBe('success') @@ -88,19 +87,17 @@ test('rejects a token encrypted for another key', async () => { const { spki } = keys() const encrypted = await encryptLikeTheBrowser(spki, 'polar_oat_secret') const result = parseHandoff( - callback({ state: 'expected', encrypted_token: encrypted }), - 'expected', + callback({ encrypted_token: encrypted }), keys().privateKey, ) expect(result.outcome).toBe('invalid') }) test.each([ - [{ state: 'wrong', encrypted_token: 'x' }, 'invalid'], - [{ state: 'expected', error: 'access_denied' }, 'denied'], - [{ state: 'expected' }, 'invalid'], + [{ error: 'access_denied' }, 'denied'], + [{}, 'invalid'], ] as const)('handles callback %j as %s', (params, outcome) => { - expect( - parseHandoff(callback(params), 'expected', keys().privateKey).outcome, - ).toBe(outcome) + expect(parseHandoff(callback(params), keys().privateKey).outcome).toBe( + outcome, + ) }) diff --git a/clients/packages/cli/src/services/token-handoff.ts b/clients/packages/cli/src/services/token-handoff.ts index 56323c324a5..ba5bee09cff 100644 --- a/clients/packages/cli/src/services/token-handoff.ts +++ b/clients/packages/cli/src/services/token-handoff.ts @@ -68,12 +68,8 @@ export const tokenPage = ( export const parseHandoff = ( url: URL, - expectedState: string, privateKey: KeyObject, ): CallbackResult> => { - if (url.searchParams.get('state') !== expectedState) { - return { outcome: 'invalid', message: 'Invalid token callback state.' } - } if (url.searchParams.has('error')) { return { outcome: 'denied', @@ -122,7 +118,7 @@ const receive = (request: TokenRequest, announce: AnnounceTokenPage) => path: CALLBACK_PATH, state, target: page, - parse: (url) => parseHandoff(url, state, privateKey), + parse: (url) => parseHandoff(url, privateKey), copy: tokenCopy, browserError: 'Could not open the browser to create the token.', timeoutError: 'Token creation timed out. Try again.',