diff --git a/cli/src/__tests__/aws.test.ts b/cli/src/__tests__/aws.test.ts index 3c6c6859..d54075a1 100644 --- a/cli/src/__tests__/aws.test.ts +++ b/cli/src/__tests__/aws.test.ts @@ -1,10 +1,118 @@ -import { describe, it, expect } from "bun:test"; +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { unlinkSync, existsSync, readFileSync } from "node:fs"; -import { BUNDLES, DEFAULT_BUNDLE } from "../aws/aws"; +import { BUNDLES, DEFAULT_BUNDLE, loadCredsFromConfig, saveCredsToConfig, AWS_CONFIG_PATH } from "../aws/aws"; import { resolveAgent, agents } from "../aws/agents"; import { generateEnvConfig } from "../shared/agents"; +// ─── Credential caching tests ──────────────────────────────────────────────── + +describe("aws/credential-cache", () => { + let originalConfig: string | null = null; + + beforeEach(() => { + if (existsSync(AWS_CONFIG_PATH)) { + originalConfig = readFileSync(AWS_CONFIG_PATH, "utf-8"); + } else { + originalConfig = null; + } + }); + + afterEach(() => { + if (originalConfig !== null) { + Bun.write(AWS_CONFIG_PATH, originalConfig); + } else if (existsSync(AWS_CONFIG_PATH)) { + unlinkSync(AWS_CONFIG_PATH); + } + }); + + describe("loadCredsFromConfig", () => { + it("returns null when config file does not exist", () => { + if (existsSync(AWS_CONFIG_PATH)) { unlinkSync(AWS_CONFIG_PATH); } + expect(loadCredsFromConfig()).toBeNull(); + }); + + it("returns null for malformed JSON", async () => { + await Bun.write(AWS_CONFIG_PATH, "not-json", { mode: 0o600 }); + expect(loadCredsFromConfig()).toBeNull(); + }); + + it("returns null when accessKeyId is missing", async () => { + await Bun.write(AWS_CONFIG_PATH, JSON.stringify({ secretAccessKey: "secretsecretkey1234" }), { mode: 0o600 }); + expect(loadCredsFromConfig()).toBeNull(); + }); + + it("returns null when secretAccessKey is too short", async () => { + await Bun.write( + AWS_CONFIG_PATH, + JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "tooshort" }), + { mode: 0o600 }, + ); + expect(loadCredsFromConfig()).toBeNull(); + }); + + it("returns null for invalid accessKeyId format", async () => { + await Bun.write( + AWS_CONFIG_PATH, + JSON.stringify({ accessKeyId: "invalid key!", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY" }), + { mode: 0o600 }, + ); + expect(loadCredsFromConfig()).toBeNull(); + }); + + it("returns credentials for valid data", async () => { + await Bun.write( + AWS_CONFIG_PATH, + JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", region: "eu-west-1" }), + { mode: 0o600 }, + ); + const result = loadCredsFromConfig(); + expect(result).not.toBeNull(); + expect(result?.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE"); + expect(result?.secretAccessKey).toBe("wJalrXUtnFEMI/K7MDENG/bPxRfiCY"); + expect(result?.region).toBe("eu-west-1"); + }); + + it("defaults region to us-east-1 when not stored", async () => { + await Bun.write( + AWS_CONFIG_PATH, + JSON.stringify({ accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY" }), + { mode: 0o600 }, + ); + const result = loadCredsFromConfig(); + expect(result?.region).toBe("us-east-1"); + }); + }); + + describe("saveCredsToConfig", () => { + it("writes credentials to config file", async () => { + if (existsSync(AWS_CONFIG_PATH)) { unlinkSync(AWS_CONFIG_PATH); } + await saveCredsToConfig("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", "us-west-2"); + const result = loadCredsFromConfig(); + expect(result?.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE"); + expect(result?.secretAccessKey).toBe("wJalrXUtnFEMI/K7MDENG/bPxRfiCY"); + expect(result?.region).toBe("us-west-2"); + }); + + it("round-trips credentials with special characters in secret key", async () => { + if (existsSync(AWS_CONFIG_PATH)) { unlinkSync(AWS_CONFIG_PATH); } + const secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCY=="; + await saveCredsToConfig("AKIAIOSFODNN7EXAMPLE", secret, "ap-northeast-1"); + const result = loadCredsFromConfig(); + expect(result?.secretAccessKey).toBe(secret); + }); + + it("overwrites existing config file", async () => { + await saveCredsToConfig("AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCY", "us-east-1"); + await saveCredsToConfig("AKIAIOSFODNN7EXAMPLE2", "newSecretKeyNewSecretKey1234567", "eu-central-1"); + const result = loadCredsFromConfig(); + expect(result?.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE2"); + expect(result?.region).toBe("eu-central-1"); + }); + }); +}); + // ─── aws.ts tests ──────────────────────────────────────────────────────────── describe("aws/aws", () => { diff --git a/cli/src/__tests__/unknown-flags.test.ts b/cli/src/__tests__/unknown-flags.test.ts index 36ed7933..b791f7e1 100644 --- a/cli/src/__tests__/unknown-flags.test.ts +++ b/cli/src/__tests__/unknown-flags.test.ts @@ -222,6 +222,16 @@ describe("Unknown Flag Detection", () => { ]), ).toBeNull(); }); + + it("should allow --reauth", () => { + expect( + findUnknownFlag([ + "claude", + "aws", + "--reauth", + ]), + ).toBeNull(); + }); }); describe("ignores positional arguments", () => { @@ -339,6 +349,7 @@ describe("KNOWN_FLAGS completeness", () => { "--cloud", "--clear", "--custom", + "--reauth", ]; for (const flag of expected) { expect(KNOWN_FLAGS.has(flag)).toBe(true); diff --git a/cli/src/aws/aws.ts b/cli/src/aws/aws.ts index 342364e3..0f567348 100644 --- a/cli/src/aws/aws.ts +++ b/cli/src/aws/aws.ts @@ -15,6 +15,7 @@ import { toKebabCase, defaultSpawnName, sanitizeTermValue, + jsonEscape, } from "../shared/ui"; import type { CloudInitTier } from "../shared/agents"; import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; @@ -26,6 +27,40 @@ import { saveVmConnection } from "../history.js"; const DASHBOARD_URL = "https://lightsail.aws.amazon.com/"; +// ─── Credential Cache ──────────────────────────────────────────────────────── + +export const AWS_CONFIG_PATH = `${process.env.HOME}/.config/spawn/aws.json`; + +const AwsCredsSchema = v.object({ + accessKeyId: v.string(), + secretAccessKey: v.string(), + region: v.optional(v.string()), +}); + +export async function saveCredsToConfig(accessKeyId: string, secretAccessKey: string, region: string): Promise { + const dir = AWS_CONFIG_PATH.replace(/\/[^/]+$/, ""); + await Bun.spawn(["mkdir", "-p", dir]).exited; + const payload = `{\n "accessKeyId": ${jsonEscape(accessKeyId)},\n "secretAccessKey": ${jsonEscape(secretAccessKey)},\n "region": ${jsonEscape(region)}\n}\n`; + await Bun.write(AWS_CONFIG_PATH, payload, { mode: 0o600 }); +} + +export function loadCredsFromConfig(): { accessKeyId: string; secretAccessKey: string; region: string } | null { + try { + const raw = readFileSync(AWS_CONFIG_PATH, "utf-8"); + const data = parseJsonWith(raw, AwsCredsSchema); + if (!data?.accessKeyId || !data?.secretAccessKey) { return null; } + if (!/^[A-Za-z0-9/+]{16,128}$/.test(data.accessKeyId)) { return null; } + if (data.secretAccessKey.length < 16) { return null; } + return { + accessKeyId: data.accessKeyId, + secretAccessKey: data.secretAccessKey, + region: data.region || "us-east-1", + }; + } catch { + return null; + } +} + // ─── Lightsail Bundles ──────────────────────────────────────────────────────── export interface Bundle { @@ -435,6 +470,7 @@ export async function ensureAwsCli(): Promise { export async function authenticate(): Promise { const region = process.env.AWS_DEFAULT_REGION || process.env.LIGHTSAIL_REGION || "us-east-1"; awsRegion = region; + const skipCache = process.env.SPAWN_REAUTH === "1"; // 1. Try existing CLI with valid credentials if (hasAwsCli()) { @@ -460,23 +496,62 @@ export async function authenticate(): Promise { if (hasAwsCli()) { lightsailMode = "cli"; process.env.AWS_DEFAULT_REGION = region; + await saveCredsToConfig(awsAccessKeyId, awsSecretAccessKey, region); logInfo(`AWS CLI ready with env credentials, using region: ${region}`); return; } lightsailMode = "rest"; + await saveCredsToConfig(awsAccessKeyId, awsSecretAccessKey, region); logInfo("AWS CLI not available \u2014 using Lightsail REST API directly"); logInfo(`Using region: ${region}`); return; } - // 3. Interactive credential entry + // 3. Try cached credentials from ~/.config/spawn/aws.json + if (!skipCache) { + const cached = loadCredsFromConfig(); + if (cached) { + const cachedRegion = process.env.AWS_DEFAULT_REGION || process.env.LIGHTSAIL_REGION || cached.region; + process.env.AWS_ACCESS_KEY_ID = cached.accessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = cached.secretAccessKey; + process.env.AWS_DEFAULT_REGION = cachedRegion; + awsRegion = cachedRegion; + awsAccessKeyId = cached.accessKeyId; + awsSecretAccessKey = cached.secretAccessKey; + + if (hasAwsCli()) { + const result = awsCliSync(["sts", "get-caller-identity"]); + if (result.exitCode === 0) { + lightsailMode = "cli"; + logInfo(`AWS CLI ready with cached credentials, using region: ${cachedRegion}`); + return; + } + logWarn("Cached AWS credentials invalid or expired"); + awsAccessKeyId = ""; + awsSecretAccessKey = ""; + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + } else { + lightsailMode = "rest"; + logInfo("Using cached AWS credentials with Lightsail REST API"); + logInfo(`Using region: ${cachedRegion}`); + return; + } + } + } + + // 4. Interactive credential entry if (process.env.SPAWN_NON_INTERACTIVE === "1") { logError("AWS credentials not found. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY."); throw new Error("No AWS credentials"); } - logStep("Enter your AWS credentials:"); + if (skipCache) { + logStep("Re-entering AWS credentials (--reauth):"); + } else { + logStep("Enter your AWS credentials:"); + } const accessKey = await prompt("AWS Access Key ID: "); if (!accessKey) { throw new Error("No access key provided"); @@ -499,12 +574,14 @@ export async function authenticate(): Promise { ]); if (result.exitCode === 0) { lightsailMode = "cli"; + await saveCredsToConfig(accessKey, secretKey, region); logInfo(`AWS CLI configured, using region: ${region}`); return; } } lightsailMode = "rest"; + await saveCredsToConfig(accessKey, secretKey, region); logInfo("Using Lightsail REST API directly"); logInfo(`Using region: ${region}`); } diff --git a/cli/src/flags.ts b/cli/src/flags.ts index 4a41b520..e619e573 100644 --- a/cli/src/flags.ts +++ b/cli/src/flags.ts @@ -23,6 +23,7 @@ export const KNOWN_FLAGS = new Set([ "--cloud", "--clear", "--custom", + "--reauth", ]); /** Return the first unknown flag in args, or null if all are known/positional */ diff --git a/cli/src/index.ts b/cli/src/index.ts index b565c5d5..da4f52e6 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -94,6 +94,7 @@ function checkUnknownFlags(args: string[]): void { console.error(` ${pc.cyan("--output json")} Output structured JSON to stdout`); console.error(` ${pc.cyan("--custom")} Show interactive size/region pickers`); console.error(` ${pc.cyan("--name")} Set the spawn/resource name`); + console.error(` ${pc.cyan("--reauth")} Force re-prompting for cloud credentials`); console.error(` ${pc.cyan("--help, -h")} Show help information`); console.error(` ${pc.cyan("--version, -v")} Show version`); console.error(); @@ -726,6 +727,13 @@ async function main(): Promise { process.env.SPAWN_CUSTOM = "1"; } + // Extract --reauth boolean flag + const reauthIdx = filteredArgs.indexOf("--reauth"); + if (reauthIdx !== -1) { + filteredArgs.splice(reauthIdx, 1); + process.env.SPAWN_REAUTH = "1"; + } + // Extract --output flag const [outputFormat, outputFilteredArgs] = extractFlagValue( filteredArgs,