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
4 changes: 2 additions & 2 deletions src/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,8 +363,8 @@ export interface BrowserFlowOptions {
// (the console keeps it alive across the provider round-trip), so a brand-new
// user creates the account and lands on the consent screen in one browser trip.
signupEntry?: boolean;
// Which provider the user picked in the CLI. The signup page ignores it today;
// sent so the console can auto-start that provider without another click.
// Which provider the user picked in the CLI. The console signup page
// auto-starts that provider's OAuth without another click.
provider?: 'google' | 'github';
}

Expand Down
63 changes: 63 additions & 0 deletions src/commands/cloud/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ import { CLIError } from '../../errors/base';
import { ExitCode } from '../../errors/codes';
import { openBrowser } from '../../utils/browser';
import { isInteractive } from '../../utils/env';
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import {
BACK,
cancel,
note,
promptMultiselect,
promptSelectOrBack,
promptConfirmOrBack,
} from '../../utils/prompt';
Expand Down Expand Up @@ -54,6 +57,36 @@ const PROVIDER_OPTIONS: Array<{ value: Provider; label: string; hint: string }>
{ value: 'kubernetes', label: 'Kubernetes', hint: 'in-cluster agent, installed with Helm (console)' },
];

// Lightweight repo markers for --multi pre-selection: cwd plus one directory
// level, filenames only — no file contents are read.
const PROVIDER_MARKERS: Partial<Record<Provider, string[]>> = {
cloudflare: ['wrangler.toml', 'wrangler.json', 'wrangler.jsonc'],
vercel: ['vercel.json', '.vercel'],
aws: ['cdk.json', 'serverless.yml', 'serverless.yaml', 'samconfig.toml', '.aws-sam'],
fly: ['fly.toml'],
render: ['render.yaml'],
supabase: ['supabase/config.toml'],
kubernetes: ['Chart.yaml', 'kustomization.yaml'],
};

function detectProviders(root: string): Provider[] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: no tests reference detectProviders or promptMultiselect. The marker table and the one-level directory walk (dot-dir and node_modules exclusion, the readdir cap) are exactly the kind of pure logic that's cheap to unit-test — a small table-driven test over a temp directory tree would lock in the pre-selection behavior. Non-blocking.


Generated by Claude Code

const dirs = [root];
try {
for (const entry of readdirSync(root, { withFileTypes: true }).slice(0, 200)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: .slice(0, 200) caps the scan at the first 200 directory entries including files, so in a large repo root a marker-bearing subdirectory can silently fall outside the window and lose its pre-selection. Filtering to directories before slicing would make the cap apply to what you actually scan. Related edge: shared flags like --token pass unchanged into every selected wizard (line 523), which is the existing single-provider semantics but reads oddly when two token-taking providers are selected. Both are cosmetic — pre-selection is only a convenience default.


Generated by Claude Code

if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
dirs.push(join(root, entry.name));
}
}
} catch {
return [];
}
const detected: Provider[] = [];
for (const [provider, markers] of Object.entries(PROVIDER_MARKERS) as Array<[Provider, string[]]>) {
if (dirs.some((d) => markers.some((m) => existsSync(join(d, m))))) detected.push(provider);
}
return detected;
}

// Same region list the console offers; the flag accepts any region so accounts
// in regions not listed here are not locked out.
const AWS_REGIONS = [
Expand Down Expand Up @@ -448,9 +481,11 @@ export const cloudConnectCommand: Command = {
// Render
{ flag: '--api-key <key>', description: 'Render API key', type: 'string' },
{ flag: '--no-browser', description: 'AWS / Vercel / PlanetScale / Supabase: print the URL instead of opening it', type: 'boolean' },
{ flag: '--multi', description: 'Pick several providers at once (pre-checked from repo markers in the current directory), then connect them one at a time', type: 'boolean' },
],
examples: [
'polylane cloud connect',
'polylane cloud connect --multi',
'polylane cloud connect --provider vercel',
'polylane cloud connect --provider cloudflare --token <token>',
'polylane cloud connect --provider aws --account 123456789012 --region us-east-1 --subscribe-alarms',
Expand All @@ -466,6 +501,34 @@ export const cloudConnectCommand: Command = {
const api = new PolylaneAPI(config);
const providerFromFlag = getArgString(args, 'provider') !== undefined;

if (getArgBoolean(args, 'multi') === true && !providerFromFlag) {
if (!isInteractive(config.nonInteractive)) {
throw new CLIError(
'--multi needs a TTY',
ExitCode.USAGE,
'Run interactively, or pass --provider to connect one provider at a time'
);
}
const detected = detectProviders(process.cwd());
const selected = await promptMultiselect(
{ nonInteractive: config.nonInteractive },
'Which clouds do you use? (space toggles, enter continues)',
PROVIDER_OPTIONS.map((o) => (detected.includes(o.value) ? { ...o, hint: `detected · ${o.hint}` } : o)),
detected
);
let connected = 0;
for (const provider of selected) {
// BACK from a flow's first step skips that provider and moves on to
// the next selected one, rather than reopening a picker.
if ((await connectProvider(config, api, args, workspaceId, provider, noBrowser)) !== BACK) connected += 1;
Comment on lines +521 to +523

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: BACK covers Ctrl+C as well as the explicit "← Back" option (src/utils/prompt.ts:10-12), so a user who Ctrl+C's out of the first step of one provider's wizard gets advanced to the next selected provider instead of aborting the whole --multi run. That's consistent with how the existing wrappers work, but in a multi-provider loop "cancel everything" is a plausible intent for Ctrl+C — might be worth distinguishing cancel from back here at some point. Non-blocking.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the browser-flow confirmation path returns void even when the connection is never confirmed — after the poll times out it just prints "Not seeing the connection yet" (connect.ts:147-149). So an unconfirmed browser flow (vercel / supabase / planetscale-browser / kubernetes) still counts toward connected here, and the command exits 0 having possibly connected nothing. This matches the pre-existing single-provider path at connect.ts:553, so it's not a regression introduced by this PR — just noting that --multi's "Nothing connected. → exit non-zero" guarantee inherits the same blind spot. Non-blocking.


Generated by Claude Code

}
if (connected === 0) {
cancel('Nothing connected.');
process.exitCode = ExitCode.GENERAL;
}
return;
}

// Provider selection restarts whenever the user backs out of the first
// step of the chosen flow, so nothing is committed until a flow completes.
for (;;) {
Expand Down
18 changes: 18 additions & 0 deletions src/utils/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,24 @@ export async function promptSelectOrBack<T extends string>(
return result as T;
}

export async function promptMultiselect<T extends string>(
ctx: PromptContext,
message: string,
options: Array<{ value: T; label: string; hint?: string }>,
initialValues: T[] = []
): Promise<T[]> {
ensureInteractive(ctx, message);
type MultiOption = { value: string; label: string; hint?: string };
const result = await p.multiselect<MultiOption[], string>({
message,
options,
initialValues,
required: false,
});
if (p.isCancel(result)) return [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: returning [] on cancel conflates Ctrl+C with a deliberate empty submit — the sibling wrappers in this file either return BACK or throw on cancel. Callers can't tell "user picked nothing" from "user bailed". Fine for the current single caller (both end in "Nothing connected." + non-zero exit), but worth a BACK return or a doc comment if this helper grows more callers.


Generated by Claude Code

return result as T[];
}

export async function promptConfirm(
ctx: PromptContext,
message: string,
Expand Down
Loading