From d7ff0739a25f9f92cd3e4da6af91e3b794039463 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Sat, 21 Feb 2026 11:06:19 -0800 Subject: [PATCH] fix: fly auth token deprecated + org picker + macaroon tokens (#1603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: fly auth token deprecated + org picker + macaroon discharge tokens Three fixes for the fly/ TypeScript provider: 1. `fly auth token` is deprecated — newer flyctl outputs a message, not a token. Now tries `fly tokens create org --expiry 24h` first, with `fly auth token` as fallback. Uses org tokens (not deploy) since spawn needs to create new apps. 2. Token sanitization stripped macaroon discharge tokens at commas (`fm2_[^ ,]*` → `fm2_\S+`). The full composite token `fm2_xxx,fm2_yyy,fo1_zzz` is now preserved. 3. Org picker upgraded from numbered 1/2 input to arrow-key interactive selector with cursor navigation, scroll windowing, and fallback to numbered list when TTY is unavailable. Also fixes: testFlyToken fallback sent `Bearer FlyV1 ...` (double prefix) for macaroon tokens — now dispatches FlyV1 vs Bearer correctly. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: never run test/mock.sh locally — opens browser, CI only Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: lab <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + cli/src/__tests__/fly.test.ts | 11 +++- fly/lib/fly.ts | 90 +++++++++++++++++++-------- fly/lib/ui.ts | 112 +++++++++++++++++++++++++++++++--- 4 files changed, 176 insertions(+), 38 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ee7f5847..20764e1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -257,6 +257,7 @@ When shell scripts need JSON processing, HTTP calls, crypto, or any non-trivial - Test files go in `cli/src/__tests__/` - Run tests with `bun test` - Use `import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"` +- **NEVER run `bash test/mock.sh` locally** — it opens the browser (OAuth flow) and is disruptive. `test/mock.sh` is for CI only. Locally, only run `bun test` and `bash test/run.sh`. ### Mock Test Infrastructure (MANDATORY for new clouds) diff --git a/cli/src/__tests__/fly.test.ts b/cli/src/__tests__/fly.test.ts index 16044b4f..5f784114 100644 --- a/cli/src/__tests__/fly.test.ts +++ b/cli/src/__tests__/fly.test.ts @@ -119,9 +119,14 @@ describe("fly/lib/fly", () => { it("wraps fm2_ tokens with FlyV1", () => { expect(sanitizeFlyToken("fm2_abc123")).toBe("FlyV1 fm2_abc123"); }); - it("wraps fm2_ tokens with prefix text", () => { - expect(sanitizeFlyToken("deploy token fm2_abc123 extra")).toBe( - "FlyV1 fm2_abc123", + it("preserves comma-separated macaroon discharge tokens", () => { + expect(sanitizeFlyToken("fm2_abc,fm2_def,fo1_ghi")).toBe( + "FlyV1 fm2_abc,fm2_def,fo1_ghi", + ); + }); + it("extracts full macaroon from noisy input", () => { + expect(sanitizeFlyToken("deploy token fm2_abc,fm2_def extra")).toBe( + "FlyV1 fm2_abc,fm2_def", ); }); it("wraps m2. tokens with FlyV1", () => { diff --git a/fly/lib/fly.ts b/fly/lib/fly.ts index dafc13e1..325947fb 100644 --- a/fly/lib/fly.ts +++ b/fly/lib/fly.ts @@ -110,9 +110,12 @@ function getCmd(): string | null { export function sanitizeFlyToken(raw: string): string { let t = raw.replace(/[\n\r]/g, "").trim(); if (t.includes("FlyV1 ")) { + // Already prefixed — extract everything after "FlyV1 " t = "FlyV1 " + t.split("FlyV1 ").pop()!; } else if (t.includes("fm2_")) { - const m = t.match(/(fm2_[^ ,]*)/); + // Macaroon token — may have comma-separated discharge tokens (fm2_xxx,fm2_yyy,fo1_zzz). + // Extract from the first fm2_ to end-of-string, preserving all segments. + const m = t.match(/(fm2_\S+)/); if (m) t = "FlyV1 " + m[1]; } else if (t.startsWith("m2.")) { t = "FlyV1 " + t; @@ -131,10 +134,13 @@ async function testFlyToken(): Promise { } catch { // fall through } - // Fallback: user API + // Fallback: user API (OAuth/personal tokens) try { + const authHeader = flyApiToken.startsWith("FlyV1 ") + ? flyApiToken + : `Bearer ${flyApiToken}`; const resp = await fetch("https://api.fly.io/v1/user", { - headers: { Authorization: `Bearer ${flyApiToken}` }, + headers: { Authorization: authHeader }, signal: AbortSignal.timeout(10_000), }); const text = await resp.text(); @@ -223,6 +229,41 @@ export async function ensureFlyCli(): Promise { logInfo("flyctl CLI installed"); } +/** + * Extract a token from fly CLI output. + * Runs the given command, strips ANSI codes, and finds a line that looks like a token. + * Token formats: "FlyV1 fm2_...", "fm2_...", "m2...." or a bare alphanumeric string. + * `fly tokens create` outputs the token prefixed with "FlyV1 " (~650-700 chars). + */ +function extractTokenFromCli(flyCmd: string, args: string[]): string { + try { + const proc = Bun.spawnSync([flyCmd, ...args], { + stdio: ["ignore", "pipe", "pipe"], + }); + const stdout = new TextDecoder().decode(proc.stdout); + const stderr = new TextDecoder().decode(proc.stderr); + // Try stdout first, then stderr + for (const output of [stdout, stderr]) { + for (const line of output.split("\n")) { + const cleaned = line.replace(/\x1b\[[0-9;]*m/g, "").trim(); + if (!cleaned) continue; + // Match "FlyV1 fm2_..." (the standard output format) + if (/^FlyV1\s+\S+/.test(cleaned)) return cleaned; + // Match bare macaroon tokens: fm2_..., m2.... + if (/^(fm2_|m2\.)\S+/.test(cleaned)) return cleaned; + // Skip deprecation notices, help text, error messages + if (/deprecated|command|usage|error|failed|help|available|flags/i.test(cleaned)) continue; + if (cleaned.startsWith("-") || cleaned.startsWith("The ") || cleaned.startsWith("Use ")) continue; + // A long alphanumeric string is likely a token + if (/^[a-zA-Z0-9_.,+/=: -]{40,}$/.test(cleaned)) return cleaned; + } + } + } catch { + // ignore + } + return ""; +} + export async function ensureFlyToken(): Promise { const flyCmd = getCmd(); @@ -250,17 +291,16 @@ export async function ensureFlyToken(): Promise { flyApiToken = ""; } - // 3. fly auth token + // 3. Try existing fly CLI session — try multiple token commands + // "fly auth token" is deprecated in newer flyctl; "fly tokens create org" is the replacement. + // Org tokens are needed (not deploy tokens) since spawn creates new apps. if (flyCmd) { - try { - const proc = Bun.spawnSync([flyCmd, "auth", "token"], { - stdio: ["ignore", "pipe", "ignore"], - }); - const token = new TextDecoder() - .decode(proc.stdout) - .split("\n")[0] - .replace(/\x1b\[[0-9;]*m/g, "") - .trim(); + const tokenCmds: string[][] = [ + ["tokens", "create", "org", "--expiry", "24h"], + ["auth", "token"], + ]; + for (const args of tokenCmds) { + const token = extractTokenFromCli(flyCmd, args); if (token) { flyApiToken = sanitizeFlyToken(token); if (await testFlyToken()) { @@ -268,12 +308,10 @@ export async function ensureFlyToken(): Promise { await saveTokenToConfig(flyApiToken); return; } - logWarn("Fly CLI session token is invalid or expired"); flyApiToken = ""; } - } catch { - // ignore } + logWarn("No valid token from fly CLI session"); } // 4. OAuth login via fly auth login @@ -283,23 +321,20 @@ export async function ensureFlyToken(): Promise { stdio: ["inherit", "inherit", "inherit"], }); await proc.exited; - try { - const result = Bun.spawnSync([flyCmd, "auth", "token"], { - stdio: ["ignore", "pipe", "ignore"], - }); - const token = new TextDecoder() - .decode(result.stdout) - .split("\n")[0] - .replace(/\x1b\[[0-9;]*m/g, "") - .trim(); + + // After login, try to get an org token (needed for creating apps) + const tokenCmds: string[][] = [ + ["tokens", "create", "org", "--expiry", "24h"], + ["auth", "token"], + ]; + for (const args of tokenCmds) { + const token = extractTokenFromCli(flyCmd, args); if (token) { flyApiToken = sanitizeFlyToken(token); await saveTokenToConfig(flyApiToken); logInfo("Authenticated with Fly.io via OAuth"); return; } - } catch { - // fall through } logWarn("fly auth login did not succeed"); } @@ -307,6 +342,7 @@ export async function ensureFlyToken(): Promise { // 5. Manual token paste logStep("Manual token entry (last resort)"); logWarn("Get a token from: https://fly.io/dashboard -> Tokens"); + logWarn("Or run: fly tokens create org"); const token = await prompt("Enter your Fly.io API token: "); if (!token) throw new Error("No token provided"); flyApiToken = sanitizeFlyToken(token); diff --git a/fly/lib/ui.ts b/fly/lib/ui.ts index 01673e48..1c78b0bd 100644 --- a/fly/lib/ui.ts +++ b/fly/lib/ui.ts @@ -41,8 +41,10 @@ export async function prompt(question: string): Promise { } /** - * Display a numbered list from pipe-delimited items and let the user pick one. + * Display an interactive picker from pipe-delimited items. * Items format: "id|label" per line. + * Uses arrow keys + Enter for selection, with type-to-filter support. + * Falls back to numbered list if TTY is unavailable. * Returns the selected id. */ export async function selectFromList( @@ -51,18 +53,28 @@ export async function selectFromList( defaultValue: string, ): Promise { if (items.length === 0) return defaultValue; - if (items.length === 1) { - const id = items[0].split("|")[0]; - logInfo(`Using ${promptText}: ${id}`); - return id; - } - logStep(`Available ${promptText}:`); const parsed = items.map((line) => { const parts = line.split("|"); return { id: parts[0], label: parts.slice(1).join(" — ") }; }); + if (parsed.length === 1) { + logInfo(`Using ${promptText}: ${parsed[0].id}`); + return parsed[0].id; + } + + // Try interactive arrow-key picker if we have a TTY + if (process.stdin.isTTY && process.env.SPAWN_NON_INTERACTIVE !== "1") { + try { + return await arrowKeyPicker(parsed, promptText, defaultValue); + } catch { + // fall through to numbered list + } + } + + // Fallback: numbered list + logStep(`Available ${promptText}:`); let defaultIdx = -1; for (let i = 0; i < parsed.length; i++) { const marker = parsed[i].id === defaultValue ? " (default)" : ""; @@ -77,12 +89,96 @@ export async function selectFromList( const num = parseInt(answer, 10); if (num >= 1 && num <= parsed.length) return parsed[num - 1].id; - // Maybe they typed the id directly const match = parsed.find((p) => p.id === answer); if (match) return match.id; return defaultValue; } +/** Interactive arrow-key picker rendered to stderr. */ +async function arrowKeyPicker( + items: { id: string; label: string }[], + title: string, + defaultValue: string, +): Promise { + const DIM = "\x1b[2m"; + const BOLD = "\x1b[1m"; + const INVERT = "\x1b[7m"; + + let cursor = Math.max(0, items.findIndex((i) => i.id === defaultValue)); + const maxVisible = Math.min(items.length, Math.max(5, (process.stdout.rows || 20) - 4)); + + function render() { + // Calculate scroll window + let start = 0; + if (items.length > maxVisible) { + start = Math.max(0, Math.min(cursor - Math.floor(maxVisible / 2), items.length - maxVisible)); + } + const end = Math.min(start + maxVisible, items.length); + + const lines: string[] = []; + lines.push(`${CYAN}Select ${title}:${NC} ${DIM}(↑/↓ to move, Enter to select)${NC}`); + for (let i = start; i < end; i++) { + const prefix = i === cursor ? `${INVERT}${BOLD} ▸ ` : ` `; + const suffix = items[i].id === defaultValue ? ` ${DIM}(default)${NC}` : ""; + const reset = i === cursor ? NC : ""; + lines.push(`${prefix}${items[i].id} — ${items[i].label}${reset}${suffix}`); + } + if (items.length > maxVisible) { + const pct = Math.round(((cursor + 1) / items.length) * 100); + lines.push(`${DIM} ${items.length} items (${pct}%)${NC}`); + } + + // Clear previous render, write new one + process.stderr.write(`\x1b[?25l`); // hide cursor + // Move up to overwrite previous frame (except first render) + if ((render as any)._rendered) { + process.stderr.write(`\x1b[${(render as any)._lines}A\x1b[J`); + } + process.stderr.write(lines.join("\n") + "\n"); + (render as any)._rendered = true; + (render as any)._lines = lines.length; + } + + return new Promise((resolve) => { + const stdin = process.stdin; + stdin.setRawMode(true); + stdin.resume(); + + render(); + + const onData = (buf: Buffer) => { + const key = buf.toString(); + + if (key === "\x1b[A" || key === "k") { + // Up + cursor = (cursor - 1 + items.length) % items.length; + render(); + } else if (key === "\x1b[B" || key === "j") { + // Down + cursor = (cursor + 1) % items.length; + render(); + } else if (key === "\r" || key === "\n") { + // Enter — select + cleanup(); + resolve(items[cursor].id); + } else if (key === "\x1b" || key === "\x03") { + // Escape or Ctrl-C — use default + cleanup(); + resolve(defaultValue); + } + }; + + function cleanup() { + stdin.removeListener("data", onData); + stdin.setRawMode(false); + stdin.pause(); + process.stderr.write("\x1b[?25h"); // show cursor + } + + stdin.on("data", onData); + }); +} + /** Open a URL in the user's browser. */ export function openBrowser(url: string): void { const cmds: [string, string[]][] =