Repository navigation
feat(cloud): multi-select provider connect with repo-marker pre-selection (--multi) #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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[] { | ||
| const dirs = [root]; | ||
| try { | ||
| for (const entry of readdirSync(root, { withFileTypes: true }).slice(0, 200)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: 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 = [ | ||
|
|
@@ -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', | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: Generated by Claude Code
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: the browser-flow confirmation path returns 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 (;;) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 []; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: returning Generated by Claude Code |
||
| return result as T[]; | ||
| } | ||
|
|
||
| export async function promptConfirm( | ||
| ctx: PromptContext, | ||
| message: string, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: no tests reference
detectProvidersorpromptMultiselect. The marker table and the one-level directory walk (dot-dir andnode_modulesexclusion, 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