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
11 changes: 10 additions & 1 deletion ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Exit codes are part of the CLI contract — they change rarely. Branch on `$?` a
| 4 | QUOTA | 429 (rate limit) or 426 (plan upgrade required) |
| 5 | TIMEOUT | Request timed out |
| 6 | NETWORK | DNS failure, connection refused, TLS error |
| 7 | PENDING | Command finished but the requested state is not reached yet (e.g. `cloud connect --provider aws` while the CloudFormation stack is still creating); re-check with the matching `list` command |
| 130 | — | Ctrl-C (SIGINT) |

## Error envelope
Expand Down Expand Up @@ -136,7 +137,15 @@ Some connect-style operations may return `{ accounts: [...], failures: [...] }`

### Browser-opening operations

Commands that generate an install / consent URL (`auth login`, `integration connect --type <browser-flow>`, `cloud connect --provider <browser-flow>`, …) always print the URL to stdout and, in interactive mode, also try to open the browser. They succeed whether or not the browser actually opens — **the exit code reflects URL generation, not the install completing upstream.** After a browser flow, re-query state with the relevant `list` / `show` command to confirm.
Commands that generate an install / consent URL (`auth login`, `integration connect --type <browser-flow>`, `cloud connect --provider <browser-flow>`, …) always print the URL to stdout and, in interactive mode, also try to open the browser. They succeed whether or not the browser actually opens. When the command can wait for the upstream side to finish (`integration connect`, `cloud connect`), the exit code reflects that wait: `0` once the connection shows up, `1` when the wait timed out, `7` when `cloud connect --provider aws` ends while the CloudFormation stack is still creating (the account arrives later; `polylane cloud list` shows it). After a browser flow, re-query state with the relevant `list` / `show` command to confirm.

### Pending (exit `7`)

| Scenario | Exit | Typical message |
|---|---|---|
| `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` |

`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.

### Streaming commands (`thread ask --stream`, `thread continue --stream`)

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ Full details: [PRIVACY.md](PRIVACY.md).
| 4 | Rate limit or plan upgrade required |
| 5 | Timeout |
| 6 | Network error |
| 7 | Pending (finished, but not yet complete upstream: re-check with `list`) |
| 130 | Interrupted (Ctrl-C / SIGINT) |

See [ERRORS.md](ERRORS.md) for the per-scenario reference.
Expand Down
1 change: 1 addition & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ Anonymous usage telemetry is on by default. `polylane telemetry status` prints e
| 4 | Rate limit or plan upgrade required |
| 5 | Timeout |
| 6 | Network error |
| 7 | Pending (finished, but not yet complete upstream: re-check with `list`) |
| 130 | Interrupted (Ctrl-C) |

See [ERRORS.md](../ERRORS.md) for categories, envelope shape, and the patterns agents should branch on.
73 changes: 53 additions & 20 deletions src/commands/cloud/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,28 @@ const PROVIDER_OPTIONS: Array<{ value: Provider; label: string; hint: string }>
];

// Same region list the console offers; the flag accepts any region so accounts
// in regions not listed here are not locked out.
// in regions not listed here are not locked out. The API takes a list, or null
// for every region enabled on the account (including regions enabled later).
const AWS_ALL_REGIONS = 'all';

export function parseAwsRegions(value: string): string[] | null {
const regions = value
.split(',')
.map((r) => r.trim())
.filter((r) => r.length > 0);
const hint = 'Pass one or more AWS regions (e.g. --region us-east-1,eu-west-1) or --region all';
if (regions.length === 0) {
throw new CLIError(`Invalid value for --region: "${value}"`, ExitCode.USAGE, hint);
}
const all = regions.some((r) => r.toLowerCase() === AWS_ALL_REGIONS);
if (all && regions.length > 1) {
throw new CLIError(`Invalid value for --region: "${value}" mixes "all" with specific regions`, ExitCode.USAGE, hint);
}
return all ? null : regions;
}

const AWS_REGIONS = [
{ value: AWS_ALL_REGIONS, label: 'All regions (every region enabled on the account, now and later)' },
{ value: 'us-east-1', label: 'us-east-1 (N. Virginia)' },
{ value: 'us-east-2', label: 'us-east-2 (Ohio)' },
{ value: 'us-west-1', label: 'us-west-1 (N. California)' },
Expand All @@ -86,11 +106,22 @@ const AWS_REGIONS = [
];

// A completed handoff is only counted as connected when the account actually
// showed up — a timed-out wait must not look like a success to the caller's
// exit code. 'pending' is the background AWS path only: the stack launch went
// through and polling continues invisibly, so the command exits clean after
// showed up — neither a timed-out wait nor a stack that is still creating may
// look like a success to the caller's exit code. 'pending' is the background
// AWS path only: the stack launch went through but the account has not
// arrived by the time the session ends, so the command exits PENDING after
// telling the user how to check.
export type ConnectOutcome = 'connected' | 'timeout' | 'pending';
export type HandoffOutcome = 'connected' | 'timeout';
export type ConnectOutcome = HandoffOutcome | 'pending';

export function connectExitCode(
outcome: HandoffOutcome | null,
awsOutcome: Extract<ConnectOutcome, 'connected' | 'pending'> | null
): ExitCode {
if (outcome === 'timeout') return ExitCode.GENERAL;
if (awsOutcome === 'pending') return ExitCode.PENDING;
return ExitCode.SUCCESS;
}

export interface AccountBaseline {
existing: CloudAccount[];
Expand Down Expand Up @@ -131,7 +162,7 @@ async function confirmBrowserConnect(
check: (() => Promise<CloudAccount[] | null>) | null,
label: string,
opts: { startHint?: string; timeoutMs?: number; intervalMs?: number } = {}
): Promise<ConnectOutcome> {
): Promise<HandoffOutcome> {
if (!check) {
if (!config.quiet && config.output !== 'json') {
process.stderr.write('\nAfter finishing in the browser, check with `polylane cloud list`.\n');
Expand Down Expand Up @@ -163,9 +194,10 @@ const AWS_SETTLE_TIMEOUT_MS = 2 * 60_000;
const AWS_CHECK_HINT = 'Check with `polylane cloud list`.';
const AWS_STILL_CONNECTING =
'AWS is still connecting — the CloudFormation stack has not shown up yet.\n' +
'Check later with `polylane cloud list`. If the stack failed or rolled back,\n' +
'your AWS CloudFormation console shows the reason; fix it and re-run\n' +
'`polylane cloud connect --provider aws`.';
'Check later with `polylane cloud list`; the account appears there once the\n' +
'stack finishes creating. If the stack failed or rolled back, your AWS\n' +
'CloudFormation console shows the reason; fix it and re-run\n' +
'`polylane cloud connect --provider aws`. Exiting 7 (pending) until then.';

export interface AwsStackWait {
pending: () => boolean;
Expand Down Expand Up @@ -313,7 +345,7 @@ async function browserConnect(
label: string,
noBrowser: boolean,
reconnect: boolean
): Promise<ConnectOutcome> {
): Promise<HandoffOutcome> {
const baseline = config.dryRun ? null : await accountBaseline(api, workspaceId, provider);
if (baseline && !reconnect && baseline.existing.length > 0) {
printAlreadyConnected(config, name, baseline.existing);
Expand Down Expand Up @@ -404,7 +436,7 @@ async function connectProvider(
provider: Provider,
noBrowser: boolean,
background: boolean
): Promise<typeof BACK | ConnectOutcome | AwsStackWait> {
): Promise<typeof BACK | HandoffOutcome | AwsStackWait> {
const ctx = { nonInteractive: config.nonInteractive };
const reconnect = getArgBoolean(args, 'reconnect') === true;

Expand Down Expand Up @@ -516,14 +548,14 @@ async function connectProvider(
return 'connected';
}
let account = '';
let region = '';
let regions: string[] | null = null;
let subscribeToAlarms = getArgBoolean(args, 'subscribeAlarms') === true;
const ok = await runSteps([
textStep(config, args, 'account', 'AWS account ID (12 digits)', '--account', (v) => {
account = v;
}),
choiceStep(config, args, 'region', '--region', 'AWS region', AWS_REGIONS, (v) => {
region = v;
choiceStep(config, args, 'region', '--region', 'AWS regions to scan', AWS_REGIONS, (v) => {
regions = parseAwsRegions(v);
}),
async () => {
if (
Expand All @@ -549,7 +581,7 @@ async function connectProvider(
workspaceId,
provider: 'aws',
account,
region,
regions,
...(createMonitoringAlarms ? { createMonitoringAlarms } : {}),
...(subscribeToAlarms ? { subscribeToAlarms } : {}),
};
Expand Down Expand Up @@ -755,7 +787,7 @@ export const cloudConnectCommand: Command = {
},
// AWS
{ flag: '--account <id>', description: 'AWS 12-digit account ID', type: 'string' },
{ flag: '--region <region>', description: 'AWS region (e.g. us-east-1)', type: 'string' },
{ flag: '--region <regions>', description: 'AWS regions to scan, comma-separated (e.g. us-east-1,eu-west-1), or "all" for every enabled region', type: 'string' },
{ flag: '--create-alarms', description: 'AWS: create monitoring alarms', type: 'boolean' },
{ flag: '--subscribe-alarms', description: 'AWS: subscribe to existing CloudWatch alarms', type: 'boolean' },
// Cloudflare / Fly / PlanetScale / Convex / Turso
Expand All @@ -782,7 +814,8 @@ export const cloudConnectCommand: Command = {
'polylane cloud connect --provider vercel',
'polylane cloud connect --provider vercel --reconnect',
'polylane cloud connect --provider cloudflare --token <token>',
'polylane cloud connect --provider aws --account 123456789012 --region us-east-1 --subscribe-alarms',
'polylane cloud connect --provider aws --account 123456789012 --region us-east-1,eu-west-1 --subscribe-alarms',
'polylane cloud connect --provider aws --account 123456789012 --region all',
'polylane cloud connect --provider render --api-key <key>',
'polylane cloud connect --provider supabase',
'polylane cloud connect --provider planetscale --token-id <id> --token <token> --organization <org>',
Expand Down Expand Up @@ -840,12 +873,12 @@ export const cloudConnectCommand: Command = {
awsWait = outcome;
continue;
}
if (awsWait) await awsWait.settle();
if (outcome === 'timeout') process.exitCode = ExitCode.GENERAL;
const awsOutcome = awsWait ? await awsWait.settle() : null;
process.exitCode = connectExitCode(outcome, awsOutcome);
return;
}
if (awsWait) {
await awsWait.settle();
process.exitCode = connectExitCode(null, await awsWait.settle());
return;
}
cancel('Nothing connected.');
Expand Down
1 change: 1 addition & 0 deletions src/errors/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export enum ExitCode {
QUOTA = 4,
TIMEOUT = 5,
NETWORK = 6,
PENDING = 7,
}
1 change: 1 addition & 0 deletions test/cloud-connect-aws-background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,5 +117,6 @@ describe('startAwsStackWait', () => {
assert.match(output, /AWS is still connecting/);
assert.match(output, /polylane cloud list/);
assert.match(output, /failed or rolled back/);
assert.match(output, /Exiting 7 \(pending\)/);
});
});
27 changes: 27 additions & 0 deletions test/cloud-connect-aws-regions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { parseAwsRegions } from '../src/commands/cloud/connect';

describe('parseAwsRegions', () => {
it('maps a single region to a one-element list', () => {
assert.deepEqual(parseAwsRegions('us-east-1'), ['us-east-1']);
});

it('splits a comma-separated list and drops blanks', () => {
assert.deepEqual(parseAwsRegions('us-east-1, eu-west-1,,ap-south-1 '), ['us-east-1', 'eu-west-1', 'ap-south-1']);
});

it('maps "all" to null, which the API reads as every enabled region', () => {
assert.equal(parseAwsRegions('all'), null);
assert.equal(parseAwsRegions('ALL'), null);
});

it('rejects "all" mixed with specific regions instead of silently scanning everything', () => {
assert.throws(() => parseAwsRegions('us-east-1,all'), /mixes "all" with specific regions/);
});

it('rejects an empty value', () => {
assert.throws(() => parseAwsRegions(''), /Invalid value for --region/);
assert.throws(() => parseAwsRegions(' , '), /Invalid value for --region/);
});
});
25 changes: 25 additions & 0 deletions test/exit-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { settledExitCode } from '../src/exit-code';
import { ExitCode } from '../src/errors/codes';
import { connectExitCode } from '../src/commands/cloud/connect';

describe('settledExitCode', () => {
afterEach(() => {
Expand Down Expand Up @@ -50,4 +52,27 @@ describe('main honors process.exitCode after a command completes', () => {
it('exits with the flagged code instead of 0', () => {
assert.equal(runConfigShow('1'), 1);
});

it('exits with the pending code when a connect left the CloudFormation stack still creating', () => {
assert.equal(runConfigShow(String(ExitCode.PENDING)), 7);
});
});

describe('cloud connect exit code', () => {
it('is SUCCESS when every leg connected', () => {
assert.equal(connectExitCode('connected', null), ExitCode.SUCCESS);
assert.equal(connectExitCode('connected', 'connected'), ExitCode.SUCCESS);
assert.equal(connectExitCode(null, 'connected'), ExitCode.SUCCESS);
});

it('is PENDING when the CloudFormation stack is still creating', () => {
assert.equal(connectExitCode(null, 'pending'), ExitCode.PENDING);
assert.equal(connectExitCode('connected', 'pending'), ExitCode.PENDING);
});

it('is GENERAL when a browser wait timed out, even if AWS is merely pending', () => {
assert.equal(connectExitCode('timeout', null), ExitCode.GENERAL);
assert.equal(connectExitCode('timeout', 'pending'), ExitCode.GENERAL);
assert.equal(connectExitCode('timeout', 'connected'), ExitCode.GENERAL);
});
});
Loading