Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/billing/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<CheckoutOutcome> {
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;
Expand Down
6 changes: 6 additions & 0 deletions src/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
23 changes: 18 additions & 5 deletions test/billing-checkout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading