feat: prioritize clouds with detected credentials in interactive picker (#752)

When running `spawn` interactively, clouds where the user already has
auth env vars set (e.g. HCLOUD_TOKEN, DO_API_TOKEN) now appear first
in the cloud selection list with a "credentials detected" hint. This
reduces friction by surfacing the most likely-to-succeed options.

Fixes #685

Agent: ux-engineer

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
This commit is contained in:
A 2026-02-12 15:33:14 -08:00 committed by GitHub
parent eae6219f27
commit e36e087029
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 123 additions and 4 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.2.64",
"version": "0.2.65",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { hasCloudCredentials, parseAuthEnvVars } from "../commands";
describe("hasCloudCredentials", () => {
const savedEnv: Record<string, string | undefined> = {};
function setEnv(key: string, value: string): void {
savedEnv[key] = process.env[key];
process.env[key] = value;
}
afterEach(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
// Clear saved state
for (const key of Object.keys(savedEnv)) {
delete savedEnv[key];
}
});
it("should return true when single env var is set", () => {
setEnv("HCLOUD_TOKEN", "test-token");
expect(hasCloudCredentials("HCLOUD_TOKEN")).toBe(true);
});
it("should return false when single env var is not set", () => {
delete process.env["HCLOUD_TOKEN"];
expect(hasCloudCredentials("HCLOUD_TOKEN")).toBe(false);
});
it("should return true when all multiple env vars are set", () => {
setEnv("UPCLOUD_USERNAME", "user");
setEnv("UPCLOUD_PASSWORD", "pass");
expect(hasCloudCredentials("UPCLOUD_USERNAME + UPCLOUD_PASSWORD")).toBe(true);
});
it("should return false when only some env vars are set", () => {
setEnv("UPCLOUD_USERNAME", "user");
delete process.env["UPCLOUD_PASSWORD"];
expect(hasCloudCredentials("UPCLOUD_USERNAME + UPCLOUD_PASSWORD")).toBe(false);
});
it("should return false for non-env-var auth like 'none'", () => {
expect(hasCloudCredentials("none")).toBe(false);
});
it("should return false for CLI-based auth like 'gcloud auth login'", () => {
expect(hasCloudCredentials("gcloud auth login")).toBe(false);
});
it("should return false for auth like 'sprite login'", () => {
expect(hasCloudCredentials("sprite login")).toBe(false);
});
it("should return false for empty string", () => {
expect(hasCloudCredentials("")).toBe(false);
});
it("should return false when env var is set to empty string", () => {
setEnv("HCLOUD_TOKEN", "");
expect(hasCloudCredentials("HCLOUD_TOKEN")).toBe(false);
});
it("should handle complex multi-var auth like contabo", () => {
setEnv("CONTABO_CLIENT_ID", "id");
setEnv("CONTABO_CLIENT_SECRET", "secret");
setEnv("CONTABO_API_USER", "user");
setEnv("CONTABO_API_PASSWORD", "pass");
expect(hasCloudCredentials("CONTABO_CLIENT_ID + CONTABO_CLIENT_SECRET + CONTABO_API_USER + CONTABO_API_PASSWORD")).toBe(true);
});
it("should return false for complex auth with one var missing", () => {
setEnv("CONTABO_CLIENT_ID", "id");
setEnv("CONTABO_CLIENT_SECRET", "secret");
setEnv("CONTABO_API_USER", "user");
delete process.env["CONTABO_API_PASSWORD"];
expect(hasCloudCredentials("CONTABO_CLIENT_ID + CONTABO_CLIENT_SECRET + CONTABO_API_USER + CONTABO_API_PASSWORD")).toBe(false);
});
it("should handle auth with mixed text and env vars", () => {
// e.g. "aws configure (AWS credentials)" - no valid env var names
expect(hasCloudCredentials("aws configure (AWS credentials)")).toBe(false);
});
});

View file

@ -58,12 +58,13 @@ function validateNonEmptyString(value: string, fieldName: string, helpCommand: s
function mapToSelectOptions<T extends { name: string; description: string }>(
keys: string[],
items: Record<string, T>
items: Record<string, T>,
hintOverrides?: Record<string, string>
): Array<{ value: string; label: string; hint: string }> {
return keys.map((key) => ({
value: key,
label: items[key].name,
hint: items[key].description,
hint: hintOverrides?.[key] ?? items[key].description,
}));
}
@ -297,9 +298,31 @@ export async function cmdInteractive(): Promise<void> {
process.exit(1);
}
// Prioritize clouds where the user already has credentials set
const withCreds: string[] = [];
const withoutCreds: string[] = [];
for (const c of clouds) {
if (hasCloudCredentials(manifest.clouds[c].auth)) {
withCreds.push(c);
} else {
withoutCreds.push(c);
}
}
const sortedClouds = [...withCreds, ...withoutCreds];
// Add credential hints to the select options
const hintOverrides: Record<string, string> = {};
for (const c of withCreds) {
hintOverrides[c] = `credentials detected -- ${manifest.clouds[c].description}`;
}
if (withCreds.length > 0) {
p.log.info(`${withCreds.length} cloud${withCreds.length > 1 ? "s" : ""} with credentials detected`);
}
const cloudChoice = await p.select({
message: "Select a cloud provider",
options: mapToSelectOptions(clouds, manifest.clouds),
options: mapToSelectOptions(sortedClouds, manifest.clouds, hintOverrides),
});
if (p.isCancel(cloudChoice)) handleCancel();
@ -1071,6 +1094,13 @@ export function parseAuthEnvVars(auth: string): string[] {
.filter((s) => /^[A-Z][A-Z0-9_]{3,}$/.test(s));
}
/** Check if a cloud's required auth env vars are all set in the environment */
export function hasCloudCredentials(auth: string): boolean {
const vars = parseAuthEnvVars(auth);
if (vars.length === 0) return false;
return vars.every((v) => !!process.env[v]);
}
export async function cmdAgents(): Promise<void> {
const manifest = await loadManifestWithSpinner();