diff --git a/src/billing/checkout.ts b/src/billing/checkout.ts index 61cef4e..1087ff0 100644 --- a/src/billing/checkout.ts +++ b/src/billing/checkout.ts @@ -5,7 +5,7 @@ import { isApiError } from '../errors/api'; import { CLIError } from '../errors/base'; import { Spinner } from '../output/progress'; import { openBrowser } from '../utils/browser'; -import { isInteractive } from '../utils/env'; +import { isInteractive, isRemoteTerminal } from '../utils/env'; import type { BillingCycle, CatalogPlan, WorkspacePlan } from './plans'; import { startReturnListener, type ReturnListener, type ReturnOutcome } from './return-listener'; @@ -95,7 +95,12 @@ function printCheckoutUrl(config: Config, url: string, noBrowser: boolean, deps: // the return URLs). Ctrl+C stops waiting and counts as "not now". export async function runCheckout(config: Config, opts: CheckoutOptions, deps: CheckoutDeps = defaultDeps): Promise { const waits = deps.canWait(config); - const listener = waits ? await deps.startListener() : null; + // The loopback listener only helps when the browser runs on this machine. + // --no-browser says it does not, and an SSH session means it cannot; in + // both cases Stripe returns to the console page and the plan poll below + // is the only signal. + const browserIsHere = !opts.noBrowser && !isRemoteTerminal(); + const listener = waits && browserIsHere ? await deps.startListener() : null; // Registered before the checkout request so a return that lands during it // is already visible when the wait starts. let returned: ReturnOutcome | null = null; diff --git a/src/utils/env.ts b/src/utils/env.ts index e6ffd9c..c941496 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -52,3 +52,9 @@ export function shouldUseColor(noColor: boolean): boolean { if (process.env.FORCE_COLOR) return true; return isStdoutTTY(); } + +// A terminal reached over SSH cannot be the machine the browser runs on, so a +// loopback listener there would never be reached by a redirect. +export function isRemoteTerminal(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT); +} diff --git a/test/billing-checkout.test.ts b/test/billing-checkout.test.ts index 3fc0dac..8156b48 100644 --- a/test/billing-checkout.test.ts +++ b/test/billing-checkout.test.ts @@ -231,12 +231,25 @@ describe('runCheckout', () => { assert.equal(h.out.join(''), 'file:///etc/passwd\n'); }); - it('honours --no-browser', async () => { - const h = harness(); - const run = runCheckout(mockConfig({ output: 'text', nonInteractive: false }), { ...base, noBrowser: true }, h.deps); - h.listener!.settle('cancel'); - await run; + it('honours --no-browser: nothing opened, no loopback listener, Stripe returns to the console', async () => { + const h = harness({ planIds: ['free', 'starter'] }); + const outcome = await runCheckout(mockConfig({ output: 'text', nonInteractive: false }), { ...base, noBrowser: true }, h.deps); + assert.equal(outcome, 'upgraded'); assert.deepEqual(h.opened, []); + assert.deepEqual(h.bodies, [{ workspaceId: 'ws_1', plan: 'starter', billingCycle: 'monthly' }]); + }); + + it('skips the loopback listener over SSH, where the browser is on another machine', async () => { + const prior = process.env.SSH_CONNECTION; + process.env.SSH_CONNECTION = '10.0.0.2 51000 10.0.0.1 22'; + try { + const h = harness({ planIds: ['free', 'starter'] }); + assert.equal(await runCheckout(mockConfig({ output: 'text', nonInteractive: false }), base, h.deps), 'upgraded'); + assert.deepEqual(h.bodies, [{ workspaceId: 'ws_1', plan: 'starter', billingCycle: 'monthly' }]); + } finally { + if (prior === undefined) delete process.env.SSH_CONNECTION; + else process.env.SSH_CONNECTION = prior; + } }); it('wraps a checkout API error, keeps its exit code, and always says how to upgrade later', async () => {