diff --git a/ERRORS.md b/ERRORS.md index bdbb555..b44df7d 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -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 @@ -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 `, `cloud connect --provider `, …) 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 `, `cloud connect --provider `, …) 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`) diff --git a/README.md b/README.md index 895d6d1..e3309b3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/skill/SKILL.md b/skill/SKILL.md index 7b432d5..26a68ad 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -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. diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index 382486f..f66a6ef 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -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)' }, @@ -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 | null +): ExitCode { + if (outcome === 'timeout') return ExitCode.GENERAL; + if (awsOutcome === 'pending') return ExitCode.PENDING; + return ExitCode.SUCCESS; +} export interface AccountBaseline { existing: CloudAccount[]; @@ -131,7 +162,7 @@ async function confirmBrowserConnect( check: (() => Promise) | null, label: string, opts: { startHint?: string; timeoutMs?: number; intervalMs?: number } = {} -): Promise { +): Promise { if (!check) { if (!config.quiet && config.output !== 'json') { process.stderr.write('\nAfter finishing in the browser, check with `polylane cloud list`.\n'); @@ -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; @@ -313,7 +345,7 @@ async function browserConnect( label: string, noBrowser: boolean, reconnect: boolean -): Promise { +): Promise { const baseline = config.dryRun ? null : await accountBaseline(api, workspaceId, provider); if (baseline && !reconnect && baseline.existing.length > 0) { printAlreadyConnected(config, name, baseline.existing); @@ -404,7 +436,7 @@ async function connectProvider( provider: Provider, noBrowser: boolean, background: boolean -): Promise { +): Promise { const ctx = { nonInteractive: config.nonInteractive }; const reconnect = getArgBoolean(args, 'reconnect') === true; @@ -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 ( @@ -549,7 +581,7 @@ async function connectProvider( workspaceId, provider: 'aws', account, - region, + regions, ...(createMonitoringAlarms ? { createMonitoringAlarms } : {}), ...(subscribeToAlarms ? { subscribeToAlarms } : {}), }; @@ -755,7 +787,7 @@ export const cloudConnectCommand: Command = { }, // AWS { flag: '--account ', description: 'AWS 12-digit account ID', type: 'string' }, - { flag: '--region ', description: 'AWS region (e.g. us-east-1)', type: 'string' }, + { flag: '--region ', 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 @@ -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 ', - '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 ', 'polylane cloud connect --provider supabase', 'polylane cloud connect --provider planetscale --token-id --token --organization ', @@ -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.'); diff --git a/src/errors/codes.ts b/src/errors/codes.ts index 1ad70c3..7b8b97e 100644 --- a/src/errors/codes.ts +++ b/src/errors/codes.ts @@ -6,4 +6,5 @@ export enum ExitCode { QUOTA = 4, TIMEOUT = 5, NETWORK = 6, + PENDING = 7, } diff --git a/test/cloud-connect-aws-background.test.ts b/test/cloud-connect-aws-background.test.ts index e722457..df2dc4d 100644 --- a/test/cloud-connect-aws-background.test.ts +++ b/test/cloud-connect-aws-background.test.ts @@ -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\)/); }); }); diff --git a/test/cloud-connect-aws-regions.test.ts b/test/cloud-connect-aws-regions.test.ts new file mode 100644 index 0000000..fbbe046 --- /dev/null +++ b/test/cloud-connect-aws-regions.test.ts @@ -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/); + }); +}); diff --git a/test/exit-code.test.ts b/test/exit-code.test.ts index 47265bf..d947863 100644 --- a/test/exit-code.test.ts +++ b/test/exit-code.test.ts @@ -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(() => { @@ -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); + }); });