diff --git a/cli/package.json b/cli/package.json index d64af566..8e5e66d0 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.2.64", + "version": "0.2.65", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/__tests__/cloud-credentials.test.ts b/cli/src/__tests__/cloud-credentials.test.ts new file mode 100644 index 00000000..e4959bca --- /dev/null +++ b/cli/src/__tests__/cloud-credentials.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { hasCloudCredentials, parseAuthEnvVars } from "../commands"; + +describe("hasCloudCredentials", () => { + const savedEnv: Record = {}; + + 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); + }); +}); diff --git a/cli/src/commands.ts b/cli/src/commands.ts index e0a20dec..60622d92 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -58,12 +58,13 @@ function validateNonEmptyString(value: string, fieldName: string, helpCommand: s function mapToSelectOptions( keys: string[], - items: Record + items: Record, + hintOverrides?: Record ): 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 { 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 = {}; + 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 { const manifest = await loadManifestWithSpinner();