mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 08:01:17 +00:00
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:
parent
eae6219f27
commit
e36e087029
3 changed files with 123 additions and 4 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.2.64",
|
||||
"version": "0.2.65",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
89
cli/src/__tests__/cloud-credentials.test.ts
Normal file
89
cli/src/__tests__/cloud-credentials.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue