Skip to content

Commit f861338

Browse files
justinhelmerJustin Helmerclaude
authored
feat: subscription commands and a plan-limit upgrade offer in cloud connect (#94)
Plans cap how many cloud accounts a workspace can connect, and until now the CLI only learned that from a 402 after the fact. Every number here comes from the billing API at run time (the public catalog and the workspace plan endpoint); the CLI never assumes what a plan allows, so a changed limit needs no release. `polylane subscription`: - `plans` the catalog polylane.com/pricing renders, no sign-in needed - `show` this workspace's plan and how much of each limit is used - `upgrade` Stripe Checkout for a free workspace, or a prorated in-place change for one that already pays (the same split the console makes, read from the subscription record so a paying workspace never gets a second subscription); waits for the plan to land - `manage` opens the Stripe billing portal `cloud connect` checks capacity first. At the limit it names the plan and the count, offers the cheapest plan that raises it, runs the upgrade, and carries on with the connect once the plan lands. No, a Stripe cancel, or no answer in time exits 4 with "Upgrade any time: polylane subscription upgrade" and leaves connected accounts alone; a payment whose webhook has not landed exits 7. Non-interactive runs at the limit exit 4 with the same line as an error. The gate fails open when the plan cannot be read: the API still enforces the limit, and 402 now maps to exit 4 with the upgrade hint. Checkout waits on two signals racing: the browser returning to an ephemeral loopback listener (the only way to know about a cancel; its URLs ride the checkout request and the API accepts them from the release that validates them as loopback-only) and the plan endpoint flipping (the webhook landed; also the fallback when no port binds or the API ignores the URLs). The OAuth callback's HTML page moved to utils/browser-page so both flows share it. Co-authored-by: Justin Helmer <justin@coreplane.ai> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent ea66af6 commit f861338

27 files changed

Lines changed: 1945 additions & 119 deletions

‎ERRORS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ Every command inherits these. Specific messages are subject to change, but the *
7474
|---|---|
7575
| HTTP 429 | `Rate limited` + any `Retry-After` |
7676
| HTTP 426 | `Plan upgrade required` |
77+
| HTTP 402 (a plan limit, e.g. one more cloud account than the plan allows) | `Plan limit exceeded: maxCloudAccounts. The free plan allows 2. …` with the hint `Upgrade your plan: polylane subscription upgrade` |
78+
| `cloud connect` at the plan's cloud-account limit, and the upgrade offer was declined, canceled in Stripe, or not completed in time | `Upgrade any time: polylane subscription upgrade` on stderr, exit `4`, no `Error:` line: nothing failed, and the accounts already connected are untouched. Non-interactive runs get the same as an error (`Your Free plan includes 2 cloud accounts; 2 connected.`) |
7779

7880
### Usage (exit `2`)
7981

@@ -144,6 +146,7 @@ Commands that generate an install / consent URL (`auth login`, `integration conn
144146
| Scenario | Exit | Typical message |
145147
|---|---|---|
146148
| `cloud connect --provider aws` ends before the CloudFormation stack finishes creating | 7 | `AWS is still connecting — the CloudFormation stack has not shown up yet.` with a hint to check `polylane cloud list` |
149+
| `subscription upgrade` (or the upgrade offer inside `cloud connect`): Stripe reported the payment but the workspace plan had not changed after 90 s | 7 | `Payment received; your plan is updating.` with a hint to re-check (`polylane subscription show`, or re-run `polylane cloud connect`) |
147150

148151
`7` is not an error: the launch went through and nothing needs to be re-run unless the stack fails. Treat it as "not connected yet" and re-check with `polylane cloud list` before depending on the account.
149152

‎skill/SKILL.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,17 @@ polylane service list # cloud infra discovered from
144144

145145
`integration connect` and `cloud connect` dispatch on `--type` / `--provider`. Some options open a browser for an install URL; others take API credentials directly. Use `--help` on each to see the required flags and optional `--no-browser`. Browser flows wait in the terminal until the connection appears (interactive TTY only); with `--output json` or in non-interactive runs they print the URL and exit, so poll `integration list` / `cloud list` to confirm.
146146

147+
Plans cap how many cloud accounts a workspace can connect; the numbers come from the API, never from the CLI. `cloud connect` checks first: with room it connects; at the limit it names the plan and the count (`Your Free plan includes 2 cloud accounts; 2 connected.`), offers the cheapest plan that raises it, and on yes opens Stripe Checkout and waits, then carries on with the connect once the plan lands. A no, a Stripe cancel, or no answer in time exits `4` with `Upgrade any time: polylane subscription upgrade` and leaves the connected accounts as they are. Non-interactive runs at the limit exit `4` with the same line as an error. The API answers a connect over the limit with `402` (exit `4`) either way.
148+
149+
```bash
150+
polylane subscription plans # catalog: prices and limits, no sign-in needed
151+
polylane subscription show # this workspace: plan, cloud accounts used of allowed, credits
152+
polylane subscription upgrade --plan starter # Stripe Checkout in the browser; waits for the plan to land
153+
polylane subscription manage # billing portal: invoices, payment method, change or cancel
154+
```
155+
156+
`subscription upgrade` prints the checkout URL, opens it, and waits (TTY only) for either the browser to return to the CLI's local listener or the plan to change. Exit `0` once upgraded, `1` on a Stripe cancel or a timeout, `7` when the payment went through but the plan has not flipped yet (re-check with `subscription show`). With `--output json` or without a TTY it prints `{ "url": … }` and exits.
157+
147158
`integration connect --type github` asks one question before opening GitHub: review pull requests for production impact on the repositories this connection brings in (default yes). Pass `--no-pr-reviews` to opt out, or `--pr-reviews` to answer yes, without the prompt; non-interactive runs without either flag keep the default. Each repository can be changed later in the console.
148159

149160
### Investigating an issue

‎src/auth/oauth.ts‎

Lines changed: 3 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ONBOARDING_RUN_QUERY_PARAM, resolveOnboardingRunId, withOnboardingRun }
77
import type { OAuthTokenResponse, OIDCConfig } from './types';
88
import { CLIError } from '../errors/base';
99
import { ExitCode } from '../errors/codes';
10+
import { ALERT_ICON, CHECK_ICON, renderBrowserPage } from '../utils/browser-page';
1011

1112
const CALLBACK_PORT = 18991;
1213
const CALLBACK_PATH = '/callback';
@@ -129,125 +130,8 @@ async function fetchOIDCConfig(domain: string): Promise<OIDCConfig> {
129130
}
130131

131132
// Brand styling: DM Sans, emerald primary, neutral-950 dark bg.
132-
function renderShell(title: string, accent: string, headline: string, body: string, icon: string): string {
133-
return `<!DOCTYPE html>
134-
<html lang="en">
135-
<head>
136-
<meta charset="utf-8">
137-
<meta name="viewport" content="width=device-width, initial-scale=1">
138-
<title>${title} | Polylane</title>
139-
<link rel="preconnect" href="https://fonts.googleapis.com">
140-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
141-
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
142-
<style>
143-
:root {
144-
color-scheme: light dark;
145-
--bg: #ffffff;
146-
--surface: #ffffff;
147-
--border: rgba(0, 0, 0, 0.08);
148-
--text: #171717;
149-
--text-muted: rgba(0, 0, 0, 0.55);
150-
--accent: ${accent};
151-
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 12px 24px -12px rgba(0, 0, 0, 0.08);
152-
}
153-
@media (prefers-color-scheme: dark) {
154-
:root {
155-
--bg: #0a0a0a;
156-
--surface: #121212;
157-
--border: rgba(255, 255, 255, 0.08);
158-
--text: #fafafa;
159-
--text-muted: rgba(255, 255, 255, 0.55);
160-
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 24px -12px rgba(0, 0, 0, 0.6);
161-
}
162-
}
163-
* { box-sizing: border-box; }
164-
html, body { margin: 0; padding: 0; height: 100%; }
165-
body {
166-
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
167-
background: var(--bg);
168-
color: var(--text);
169-
display: flex;
170-
align-items: center;
171-
justify-content: center;
172-
min-height: 100vh;
173-
-webkit-font-smoothing: antialiased;
174-
}
175-
.wordmark {
176-
position: fixed;
177-
top: 24px;
178-
left: 32px;
179-
font-weight: 600;
180-
font-size: 16px;
181-
letter-spacing: -0.01em;
182-
}
183-
.wordmark::after {
184-
content: "";
185-
display: inline-block;
186-
width: 6px;
187-
height: 6px;
188-
margin-left: 6px;
189-
border-radius: 50%;
190-
background: var(--accent);
191-
vertical-align: middle;
192-
transform: translateY(-1px);
193-
}
194-
.card {
195-
width: min(420px, calc(100% - 48px));
196-
padding: 40px 32px 32px;
197-
background: var(--surface);
198-
border: 1px solid var(--border);
199-
border-radius: 14px;
200-
box-shadow: var(--shadow);
201-
text-align: center;
202-
}
203-
.icon {
204-
width: 48px;
205-
height: 48px;
206-
margin: 0 auto 20px;
207-
border-radius: 50%;
208-
display: flex;
209-
align-items: center;
210-
justify-content: center;
211-
background: color-mix(in srgb, var(--accent) 14%, transparent);
212-
color: var(--accent);
213-
}
214-
.icon svg { width: 24px; height: 24px; stroke: currentColor; fill: none; stroke-width: 2.25; stroke-linecap: round; stroke-linejoin: round; }
215-
h1 {
216-
margin: 0 0 8px;
217-
font-weight: 600;
218-
font-size: 20px;
219-
letter-spacing: -0.01em;
220-
}
221-
p {
222-
margin: 0;
223-
color: var(--text-muted);
224-
font-size: 14px;
225-
line-height: 1.5;
226-
}
227-
.hint {
228-
margin-top: 20px;
229-
font-size: 12px;
230-
color: var(--text-muted);
231-
font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
232-
}
233-
</style>
234-
</head>
235-
<body>
236-
<div class="wordmark">Polylane</div>
237-
<main class="card">
238-
<div class="icon">${icon}</div>
239-
<h1>${headline}</h1>
240-
<p>${body}</p>
241-
</main>
242-
</body>
243-
</html>`;
244-
}
245-
246-
const CHECK_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="5 12.5 10 17.5 19 7.5"/></svg>';
247-
const ALERT_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><line x1="12" y1="7" x2="12" y2="13"/><line x1="12" y1="17" x2="12" y2="17.01"/><circle cx="12" cy="12" r="9"/></svg>';
248-
249133
function renderSuccessHtml(): string {
250-
return renderShell(
134+
return renderBrowserPage(
251135
'Authenticated',
252136
// Emerald-500 — primary brand accent
253137
'#10b981',
@@ -262,7 +146,7 @@ function renderErrorHtml(error: string): string {
262146
const map: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
263147
return map[c] ?? c;
264148
});
265-
return renderShell(
149+
return renderBrowserPage(
266150
'Sign-in did not complete',
267151
// Red-500 from Tailwind default — high-contrast destructive accent
268152
'#ef4444',

‎src/billing/checkout.ts‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import type { Config } from '../config/schema';
2+
import { createCheckoutSession, fetchWorkspacePlan, type CheckoutRequest } from '../client/billing';
3+
import { canWaitForBrowser, scopedSigint } from '../commands/helpers';
4+
import { isApiError } from '../errors/api';
5+
import { CLIError } from '../errors/base';
6+
import { Spinner } from '../output/progress';
7+
import { openBrowser } from '../utils/browser';
8+
import { isInteractive } from '../utils/env';
9+
import type { BillingCycle, CatalogPlan, WorkspacePlan } from './plans';
10+
import { startReturnListener, type ReturnListener, type ReturnOutcome } from './return-listener';
11+
12+
// The one line every non-upgrade exit ends with, so nobody is left thinking
13+
// the door closed.
14+
export const UPGRADE_LATER = 'Upgrade any time: polylane subscription upgrade';
15+
16+
// upgraded: the workspace is on the new plan.
17+
// canceled: the customer backed out of Stripe (explicit, via the cancel URL).
18+
// timeout: nothing came back in time; treated as "not now".
19+
// pending: Stripe reported success but the plan has not flipped yet (webhook lag).
20+
// handoff: the URL was printed and nothing was waited for (json output or no TTY).
21+
export type CheckoutOutcome = 'upgraded' | 'canceled' | 'timeout' | 'pending' | 'handoff';
22+
23+
export interface CheckoutOptions {
24+
workspaceId: string;
25+
plan: CatalogPlan;
26+
cycle: BillingCycle;
27+
noBrowser: boolean;
28+
// The plan id before checkout; a different id on the plan endpoint is the
29+
// upgrade landing, whichever route the news arrives by.
30+
previousPlanId: string;
31+
timeoutMs?: number;
32+
pollIntervalMs?: number;
33+
// After the browser reports success, how long to wait for the plan flip.
34+
settleMs?: number;
35+
}
36+
37+
export interface CheckoutDeps {
38+
startListener: () => Promise<ReturnListener | null>;
39+
openBrowser: (url: string) => void;
40+
fetchPlan: (config: Config, workspaceId: string) => Promise<WorkspacePlan>;
41+
createCheckout: (config: Config, body: CheckoutRequest) => Promise<{ url: string }>;
42+
sleep: (ms: number) => Promise<void>;
43+
// Whether this terminal can wait for the browser at all (a TTY, not json).
44+
canWait: (config: Config) => boolean;
45+
// stdout is data (the URL); injectable so tests never stub the process stream
46+
// the test runner itself reports through.
47+
writeOut: (line: string) => void;
48+
}
49+
50+
const defaultDeps: CheckoutDeps = {
51+
startListener: () => startReturnListener(),
52+
openBrowser,
53+
fetchPlan: fetchWorkspacePlan,
54+
createCheckout: createCheckoutSession,
55+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
56+
canWait: canWaitForBrowser,
57+
writeOut: (line) => {
58+
process.stdout.write(line);
59+
},
60+
};
61+
62+
const DEFAULT_TIMEOUT_MS = 10 * 60_000;
63+
const DEFAULT_POLL_MS = 3_000;
64+
const DEFAULT_SETTLE_MS = 90_000;
65+
66+
export const TERMS_LINE = 'Paying accepts the terms: https://polylane.com/terms';
67+
68+
// Only an https URL is ever handed to the OS opener. The API is trusted, but
69+
// --domain lets a user point the CLI anywhere, and `open file:///…` or a
70+
// custom scheme must never be one bad response away.
71+
export function isOpenableUrl(url: string): boolean {
72+
try {
73+
return new URL(url).protocol === 'https:';
74+
} catch {
75+
return false;
76+
}
77+
}
78+
79+
function printCheckoutUrl(config: Config, url: string, noBrowser: boolean, deps: CheckoutDeps): void {
80+
const shouldOpen = !noBrowser && isInteractive(config.nonInteractive) && isOpenableUrl(url);
81+
if (!config.quiet) {
82+
process.stderr.write(`${TERMS_LINE}\n`);
83+
process.stderr.write(
84+
shouldOpen ? "Opening checkout in your browser…\nIf it doesn't open, use this URL:\n" : 'Open this URL to upgrade:\n'
85+
);
86+
}
87+
deps.writeOut(url + '\n');
88+
if (shouldOpen) deps.openBrowser(url);
89+
}
90+
91+
// Opens Stripe Checkout for `plan` and waits for the outcome. Two signals
92+
// race: the browser returning to the local listener (immediate, and the only
93+
// way to know about a cancel) and the plan endpoint flipping (the webhook
94+
// landed; also the fallback when no listener could bind or the API ignored
95+
// the return URLs). Ctrl+C stops waiting and counts as "not now".
96+
export async function runCheckout(config: Config, opts: CheckoutOptions, deps: CheckoutDeps = defaultDeps): Promise<CheckoutOutcome> {
97+
const waits = deps.canWait(config);
98+
const listener = waits ? await deps.startListener() : null;
99+
// Registered before the checkout request so a return that lands during it
100+
// is already visible when the wait starts.
101+
let returned: ReturnOutcome | null = null;
102+
void listener?.outcome.then((o) => {
103+
returned = o;
104+
});
105+
const body: CheckoutRequest = {
106+
workspaceId: opts.workspaceId,
107+
plan: opts.plan.id,
108+
billingCycle: opts.cycle,
109+
...(listener ? { successUrl: listener.successUrl, cancelUrl: listener.cancelUrl } : {}),
110+
};
111+
let url: string;
112+
try {
113+
({ url } = await deps.createCheckout(config, body));
114+
} catch (err) {
115+
listener?.close();
116+
if (isApiError(err)) {
117+
throw new CLIError(`Couldn't start checkout: ${err.message}`, err.exitCode, [err.hint, UPGRADE_LATER].filter(Boolean).join('\n'));
118+
}
119+
throw err;
120+
}
121+
if (config.output === 'json') {
122+
listener?.close();
123+
deps.writeOut(JSON.stringify({ url }, null, 2) + '\n');
124+
return 'handoff';
125+
}
126+
printCheckoutUrl(config, url, opts.noBrowser, deps);
127+
if (!waits) return 'handoff';
128+
129+
const planFlipped = async (): Promise<boolean> => {
130+
try {
131+
const plan = await deps.fetchPlan(config, opts.workspaceId);
132+
return plan.plan.id !== opts.previousPlanId;
133+
} catch {
134+
return false;
135+
}
136+
};
137+
138+
const spinner = new Spinner('Waiting for checkout… (Ctrl+C to stop waiting)');
139+
let interrupted = false;
140+
const restoreSigint = scopedSigint(() => {
141+
interrupted = true;
142+
});
143+
if (!config.quiet) process.stderr.write('\nFinish in the browser, then come back to this terminal.\n');
144+
spinner.start();
145+
try {
146+
const poll = opts.pollIntervalMs ?? DEFAULT_POLL_MS;
147+
const deadline = Date.now() + (opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
148+
// Check before the first sleep so an instant cancel is not held for a poll interval.
149+
for (let first = true; Date.now() < deadline && !interrupted; first = false) {
150+
if (!first) await deps.sleep(poll);
151+
if (interrupted) break;
152+
if (returned === 'cancel') return 'canceled';
153+
if (await planFlipped()) return 'upgraded';
154+
if (returned === 'success') {
155+
spinner.update('Payment received, updating your plan…');
156+
const settleBy = Date.now() + (opts.settleMs ?? DEFAULT_SETTLE_MS);
157+
while (Date.now() < settleBy && !interrupted) {
158+
await deps.sleep(poll);
159+
if (await planFlipped()) return 'upgraded';
160+
}
161+
return 'pending';
162+
}
163+
}
164+
return 'timeout';
165+
} finally {
166+
spinner.stop(interrupted ? 'Stopped waiting. Finish in the browser any time; your plan updates on its own.' : undefined);
167+
restoreSigint();
168+
listener?.close();
169+
}
170+
}

0 commit comments

Comments
 (0)