diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 6568790..1e4cdbd 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -5,7 +5,7 @@ import { AdminClient } from "../lib/admin-client.js"; import type { App } from "../lib/admin-client.js"; import { CLIError, ErrorCode, exitWithError } from "../lib/errors.js"; import { printHuman, isJSONMode, debug } from "../lib/output.js"; -import { promptSelect } from "../lib/terminal-ui.js"; +import { promptAutocomplete, promptText } from "../lib/terminal-ui.js"; import { isInteractiveAllowed } from "../lib/interaction.js"; import { resolveAuthToken } from "../lib/resolve.js"; import { green, dim, bold, brand, maskIf, withSpinner } from "../lib/ui.js"; @@ -13,13 +13,16 @@ import { green, dim, bold, brand, maskIf, withSpinner } from "../lib/ui.js"; export function registerAuth(program: Command) { const cmd = program .command("auth") - .description("Authenticate with your Alchemy account"); + .description("Authenticate with your Alchemy account") + .option("-y, --yes", "Skip confirmation prompt and open browser immediately"); cmd .command("login", { isDefault: true }) .description("Log in via browser") .option("--force", "Force re-authentication even if a valid token exists") - .action(async (opts: { force?: boolean }) => { + .option("-y, --yes", "Skip confirmation prompt and open browser immediately") + .action(async (opts: { force?: boolean; yes?: boolean }) => { + const yes = opts.yes || cmd.opts().yes; try { // Skip browser flow if we already have a valid token if (!opts.force) { @@ -49,9 +52,20 @@ export function registerAuth(program: Command) { console.log(` ${brand("◆")} ${bold("Alchemy Authentication")}`); console.log(` ${dim("────────────────────────────────────")}`); console.log(""); - console.log(` Opening browser to log in...`); console.log(` ${dim(getLoginUrl(AUTH_PORT))}`); console.log(""); + } + + if (!yes && !isJSONMode() && isInteractiveAllowed(program)) { + const answer = await promptText({ + message: "Press Enter to open browser and link your Alchemy account", + cancelMessage: "Login cancelled.", + }); + if (answer === null) return; + } + + if (!isJSONMode()) { + console.log(` Opening browser to log in...`); console.log(` ${dim("Waiting for authentication...")}`); } @@ -200,8 +214,9 @@ export async function selectAppAfterAuth(authToken: string): Promise { console.log(` ${green("✓")} Auto-selected app: ${bold(selectedApp.name)}`); } else { console.log(""); - const appId = await promptSelect({ + const appId = await promptAutocomplete({ message: "Select an app", + placeholder: "Type to search by name", options: apps.map((app) => ({ value: app.id, label: app.name, diff --git a/src/commands/config.ts b/src/commands/config.ts index 75ad176..cd8f8bd 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -392,7 +392,7 @@ export function registerConfig(program: Command) { cmd .command("list") .description("List all config values") - .action(() => { + .action(async () => { const cfg = config.load(); const hasApiKeyMismatch = Boolean( cfg.api_key && @@ -405,7 +405,16 @@ export function registerConfig(program: Command) { return; } + const { resolveAuthToken } = await import("../lib/resolve.js"); + const validToken = resolveAuthToken(cfg); + const authStatus = cfg.auth_token + ? validToken + ? `${green("✓")} authenticated${cfg.auth_token_expires_at ? ` ${dim(`(expires ${cfg.auth_token_expires_at})`)}` : ""}` + : `${yellow("◆")} expired${cfg.auth_token_expires_at ? ` ${dim(`(${cfg.auth_token_expires_at})`)}` : ""}` + : dim("(not set) — run 'alchemy auth' to log in"); + const pairs: Array<[string, string]> = [ + ["auth", authStatus], [ "api-key", cfg.api_key @@ -413,11 +422,12 @@ export function registerConfig(program: Command) { : dim("(not set)"), ], ["access-key", cfg.access_key ? maskIf(cfg.access_key) : dim("(not set)")], + ["webhook-api-key", cfg.webhook_api_key ? maskIf(cfg.webhook_api_key) : dim("(not set)")], [ "app", cfg.app ? `${cfg.app.name} ${dim(`(${cfg.app.id})`)}` - : dim("(not set) — set automatically via 'config set access-key' or 'config set app'"), + : dim("(not set) — set automatically via 'alchemy auth' or 'config set app'"), ], ["network", cfg.network || dim("(not set, defaults to eth-mainnet)")], [ diff --git a/src/commands/onboarding.ts b/src/commands/onboarding.ts index 4b7ff62..cb6e874 100644 --- a/src/commands/onboarding.ts +++ b/src/commands/onboarding.ts @@ -1,135 +1,14 @@ import type { Command } from "commander"; -import { AdminClient } from "../lib/admin-client.js"; import { load as loadConfig, save as saveConfig } from "../lib/config.js"; -import { promptSelect, promptText } from "../lib/terminal-ui.js"; +import { promptText } from "../lib/terminal-ui.js"; import { brand, bold, brandedHelp, dim, green, - maskIf, - printKeyValueBox, } from "../lib/ui.js"; import { getUpdateNoticeLines } from "../lib/update-check.js"; -import { selectOrCreateApp } from "./config.js"; -import { generateAndPersistWallet, importAndPersistWallet } from "./wallet.js"; - -type OnboardingMethod = "browser-login" | "api-key" | "access-key" | "siwx" | "exit"; - -function printNextSteps(method: Exclude): void { - const commandsByMethod: Record, string[]> = { - "browser-login": ["alchemy auth"], - "api-key": ["alchemy config set api-key "], - "access-key": [ - "alchemy config set access-key ", - "alchemy config set app ", - ], - siwx: [ - "alchemy wallet generate", - "alchemy config set wallet-key-file ", - "alchemy config set x402 true", - ], - }; - - console.log(""); - console.log(` ${dim("Next steps:")}`); - for (const command of commandsByMethod[method]) { - console.log(` ${dim(`- ${command}`)}`); - } -} - -function printAPIKeyPostSetupGuidance(): void { - const cfg = loadConfig() ?? {}; - const network = cfg.network ?? "eth-mainnet"; - - console.log(""); - console.log(` ${brand("◆")} ${bold("Your configuration")}`); - printKeyValueBox([ - ["api-key", cfg.api_key ? maskIf(cfg.api_key) : dim("(not set)")], - ["network", cfg.network ? network : `${network} ${dim("(default)")}`], - ]); - - console.log(""); - console.log(` ${brand("◆")} ${bold("Next steps")}`); - printKeyValueBox([ - ["Verify setup", "rpc eth_chainId"], - ["Need a different chain?", "config set network "], - ["List available chains", "network list"], - ["View set API key", "config get api-key"], - ["Need help?", "help"], - ]); -} - -async function runAPIKeyOnboarding(): Promise { - const key = await promptText({ - message: "Enter API Key", - cancelMessage: "Skipped API key setup.", - clearAfterSubmit: true, - }); - if (!key || !key.trim()) return; - const cfg = loadConfig(); - saveConfig({ ...cfg, api_key: key.trim() }); - console.log(` ${green("✓")} Saved API key`); -} - -async function runAccessKeyOnboarding(): Promise { - const key = await promptText({ - message: "Alchemy access key", - placeholder: "Used for Admin API operations", - cancelMessage: "Skipped access key setup.", - }); - if (!key || !key.trim()) return; - - const cfg = loadConfig(); - saveConfig({ ...cfg, access_key: key.trim() }); - console.log(` ${green("✓")} Saved access key`); - await selectOrCreateApp(new AdminClient(key.trim())); -} - -async function runSiwxOnboarding(): Promise { - const action = await promptSelect({ - message: "SIWx wallet setup", - options: [ - { label: "Generate a new wallet", value: "generate" }, - { label: "Import wallet from key file", value: "import" }, - ], - initialValue: "generate", - cancelMessage: "Skipped SIWx setup.", - }); - if (!action) return; - - const wallet = - action === "generate" - ? generateAndPersistWallet() - : await (async () => { - const path = await promptText({ - message: "Wallet private key file path", - cancelMessage: "Skipped wallet import.", - }); - if (!path || !path.trim()) return null; - return importAndPersistWallet(path.trim()); - })(); - if (!wallet) return; - - const cfg = loadConfig(); - saveConfig({ ...cfg, x402: true }); - console.log(` ${green("✓")} SIWx enabled with wallet ${wallet.address}`); - - // Sign SIWE token immediately so it's cached for subsequent commands - try { - const { signSiwe } = await import("@alchemy/x402"); - const { readFileSync } = await import("node:fs"); - const keyPath = wallet.keyFile; - const privateKey = readFileSync(keyPath, "utf-8").trim(); - const siweToken = await signSiwe({ privateKey, expiresAfter: "1h" }); - const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); - saveConfig({ ...loadConfig(), siwe_token: siweToken, siwe_token_expires_at: expiresAt }); - console.log(` ${green("✓")} Signed SIWE token (cached for 1h)`); - } catch { - // Non-fatal — token will be signed on first API call - } -} export async function runOnboarding( _program: Command, @@ -140,8 +19,6 @@ export async function runOnboarding( console.log(` ${brand("◆")} ${bold("Welcome to Alchemy CLI")}`); console.log(` ${dim(" ────────────────────────────────────")}`); console.log(` ${dim(" Let's get you set up with authentication.")}`); - console.log(` ${dim(" Choose one auth path to continue.")}`); - console.log(` ${dim(" Tip: select 'exit' to skip setup for now.")}`); console.log(""); if (latestUpdate) { for (const line of getUpdateNoticeLines(latestUpdate)) { @@ -149,89 +26,33 @@ export async function runOnboarding( } console.log(""); } - const method = await promptSelect({ - message: "Choose an auth setup path", - options: [ - { - label: "Browser login", - hint: "Log in via browser (recommended)", - value: "browser-login", - }, - { - label: "API key", - hint: "Query Alchemy RPC nodes", - value: "api-key", - }, - { - label: "Access Key", - hint: "Admin API plus RPC nodes", - value: "access-key", - }, - { - label: "SIWx", - hint: "Sign-In with Ethereum/Solana wallet", - value: "siwx", - }, - { - label: "exit", - value: "exit", - }, - ], - initialValue: "browser-login", + + const answer = await promptText({ + message: "Press Enter to open browser and link your Alchemy account", cancelMessage: "Skipped onboarding.", }); - if (!method) return false; - if (method === "exit") { - console.log(` ${dim("Exited onboarding.")}`); + if (answer === null) { return false; } - if (method === "browser-login") { - const { performBrowserLogin, AUTH_PORT, getLoginUrl } = await import("../lib/auth.js"); - console.log(` Opening browser to log in...`); - console.log(` ${dim(getLoginUrl(AUTH_PORT))}`); - console.log(` ${dim("Waiting for authentication...")}`); - try { - const result = await performBrowserLogin(); - const cfg = loadConfig(); - saveConfig({ - ...cfg, - auth_token: result.token, - auth_token_expires_at: result.expiresAt, - }); - console.log(` ${green("✓")} Logged in successfully`); - const { selectAppAfterAuth } = await import("./auth.js"); - await selectAppAfterAuth(result.token); - return true; - } catch (err) { - console.log(` ${dim(`Login failed: ${err instanceof Error ? err.message : String(err)}`)}`); - return false; - } - } - if (method === "api-key") { - await runAPIKeyOnboarding(); - const complete = Boolean(loadConfig().api_key?.trim()); - if (!complete) { - printNextSteps("api-key"); - } else { - printAPIKeyPostSetupGuidance(); - } - return complete; - } - if (method === "access-key") { - await runAccessKeyOnboarding(); + const { performBrowserLogin, AUTH_PORT, getLoginUrl } = await import("../lib/auth.js"); + console.log(` Opening browser to log in...`); + console.log(` ${dim(getLoginUrl(AUTH_PORT))}`); + console.log(` ${dim("Waiting for authentication...")}`); + try { + const result = await performBrowserLogin(); const cfg = loadConfig(); - const complete = Boolean(cfg.access_key?.trim() && cfg.app?.id && cfg.app.apiKey); - if (!complete) { - printNextSteps("access-key"); - } - return complete; - } - await runSiwxOnboarding(); - const cfg = loadConfig(); - const complete = cfg.x402 === true && Boolean(cfg.wallet_key_file?.trim()); - if (!complete) { - printNextSteps("siwx"); + saveConfig({ + ...cfg, + auth_token: result.token, + auth_token_expires_at: result.expiresAt, + }); + console.log(` ${green("✓")} Logged in successfully`); + const { selectAppAfterAuth } = await import("./auth.js"); + await selectAppAfterAuth(result.token); + return true; + } catch (err) { + console.log(` ${dim(`Login failed: ${err instanceof Error ? err.message : String(err)}`)}`); + return false; } - return complete; } diff --git a/src/index.ts b/src/index.ts index a80875f..e5d5107 100644 --- a/src/index.ts +++ b/src/index.ts @@ -326,7 +326,7 @@ program ` ${hDim("Docs:")} ${hBrand("https://www.alchemy.com/docs")}`, ].join("\n"); }) - .hook("preAction", () => { + .hook("preAction", async (thisCommand, actionCommand) => { const opts = program.opts(); if (opts.color === false) setNoColor(true); const cfg = loadConfig(); @@ -338,6 +338,30 @@ program reveal: Boolean(opts.reveal), timeout: opts.timeout, }); + + // If we have an auth token but no API key, prompt for app selection + // before running commands that need one (skip for auth/config/setup/help/etc.) + const cmdName = actionCommand.name(); + const skipAppPrompt = [ + "auth", "config", "setup", "help", "version", + "completions", "agent-prompt", "update-check", "wallet", + ]; + if ( + !skipAppPrompt.includes(cmdName) && + isInteractiveAllowed(program) && + !opts.apiKey && + !process.env.ALCHEMY_API_KEY + ) { + const { resolveAuthToken } = await import("./lib/resolve.js"); + const authToken = resolveAuthToken(cfg); + const hasApiKey = Boolean(cfg.api_key?.trim() || cfg.app?.apiKey); + if (authToken && !hasApiKey) { + const { selectAppAfterAuth } = await import("./commands/auth.js"); + console.log(""); + console.log(` No app selected. Please select an app to continue.`); + await selectAppAfterAuth(authToken); + } + } }) .hook("postAction", () => { if (!isJSONMode() && !quiet) { diff --git a/src/lib/auth-html.ts b/src/lib/auth-html.ts index 73c2f67..a9b3dec 100644 --- a/src/lib/auth-html.ts +++ b/src/lib/auth-html.ts @@ -4,7 +4,7 @@ const SHARED_STYLE = ` font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; - background: #000; + background: linear-gradient(180deg, #4F46E5 0%, #06B6D4 100%); color: #fff; overflow: hidden; } @@ -21,7 +21,7 @@ const SHARED_STYLE = ` letter-spacing: -0.01em; } p { - color: #6b6b6b; + color: #fff; font-size: 0.875rem; } `; diff --git a/tests/commands/onboarding.test.ts b/tests/commands/onboarding.test.ts index fd9af58..911bc7e 100644 --- a/tests/commands/onboarding.test.ts +++ b/tests/commands/onboarding.test.ts @@ -6,16 +6,17 @@ describe("onboarding flow", () => { vi.resetModules(); }); - it("returns false when onboarding is cancelled", async () => { + it("returns false when browser login fails", async () => { vi.doMock("../../src/lib/terminal-ui.js", () => ({ - promptSelect: vi.fn().mockResolvedValue(null), - promptText: vi.fn(), + promptText: vi.fn().mockResolvedValue(""), + })); + vi.doMock("../../src/lib/auth.js", () => ({ + performBrowserLogin: vi.fn().mockRejectedValue(new Error("login failed")), + AUTH_PORT: 16424, + getLoginUrl: vi.fn().mockReturnValue("https://auth.alchemy.com/login"), })); vi.doMock("../../src/lib/update-check.js", () => ({ - getUpdateNoticeLines: vi.fn().mockReturnValue([ - " Update available 0.2.0 -> 9.9.9", - " Run npm i -g @alchemy/cli@latest to update", - ]), + getUpdateNoticeLines: vi.fn().mockReturnValue([]), })); vi.doMock("../../src/lib/config.js", () => ({ load: vi.fn().mockReturnValue({}), @@ -30,83 +31,35 @@ describe("onboarding flow", () => { maskIf: (s: string) => s, printKeyValueBox: vi.fn(), })); - vi.doMock("../../src/lib/admin-client.js", () => ({ - AdminClient: vi.fn(), - })); - vi.doMock("../../src/commands/config.js", () => ({ - selectOrCreateApp: vi.fn(), - })); - vi.doMock("../../src/commands/wallet.js", () => ({ - generateAndPersistWallet: vi.fn(), - importAndPersistWallet: vi.fn(), - })); + vi.spyOn(console, "log").mockImplementation(() => {}); const { runOnboarding } = await import("../../src/commands/onboarding.js"); const completed = await runOnboarding({} as never); expect(completed).toBe(false); }); - it("returns true when api key setup completes", async () => { - const load = vi - .fn() - .mockReturnValueOnce({}) - .mockReturnValueOnce({ api_key: "api_test" }) - .mockReturnValue({ api_key: "api_test" }); + it("returns true when browser login succeeds", async () => { const save = vi.fn(); vi.doMock("../../src/lib/terminal-ui.js", () => ({ - promptSelect: vi.fn().mockResolvedValue("api-key"), - promptText: vi.fn().mockResolvedValue("api_test"), - })); - vi.doMock("../../src/lib/update-check.js", () => ({ - getUpdateNoticeLines: vi.fn().mockReturnValue([ - " Update available 0.2.0 -> 9.9.9", - " Run npm i -g @alchemy/cli@latest to update", - ]), - })); - vi.doMock("../../src/lib/config.js", () => ({ - load, - save, - })); - vi.doMock("../../src/lib/ui.js", () => ({ - brand: (s: string) => s, - bold: (s: string) => s, - brandedHelp: () => "", - dim: (s: string) => s, - green: (s: string) => s, - maskIf: (s: string) => s, - printKeyValueBox: vi.fn(), - })); - vi.doMock("../../src/lib/admin-client.js", () => ({ - AdminClient: vi.fn(), + promptText: vi.fn().mockResolvedValue(""), })); - vi.doMock("../../src/commands/config.js", () => ({ - selectOrCreateApp: vi.fn(), + vi.doMock("../../src/lib/auth.js", () => ({ + performBrowserLogin: vi.fn().mockResolvedValue({ + token: "test_token", + expiresAt: "2099-01-01T00:00:00Z", + }), + AUTH_PORT: 16424, + getLoginUrl: vi.fn().mockReturnValue("https://auth.alchemy.com/login"), })); - vi.doMock("../../src/commands/wallet.js", () => ({ - generateAndPersistWallet: vi.fn(), - importAndPersistWallet: vi.fn(), - })); - - const { runOnboarding } = await import("../../src/commands/onboarding.js"); - const completed = await runOnboarding({} as never); - expect(completed).toBe(true); - expect(save).toHaveBeenCalledWith({ api_key: "api_test" }); - }); - - it("returns false when Exit onboarding is selected", async () => { - vi.doMock("../../src/lib/terminal-ui.js", () => ({ - promptSelect: vi.fn().mockResolvedValue("exit"), - promptText: vi.fn(), + vi.doMock("../../src/commands/auth.js", () => ({ + selectAppAfterAuth: vi.fn(), })); vi.doMock("../../src/lib/update-check.js", () => ({ - getUpdateNoticeLines: vi.fn().mockReturnValue([ - " Update available 0.2.0 -> 9.9.9", - " Run npm i -g @alchemy/cli@latest to update", - ]), + getUpdateNoticeLines: vi.fn().mockReturnValue([]), })); vi.doMock("../../src/lib/config.js", () => ({ load: vi.fn().mockReturnValue({}), - save: vi.fn(), + save, })); vi.doMock("../../src/lib/ui.js", () => ({ brand: (s: string) => s, @@ -117,33 +70,23 @@ describe("onboarding flow", () => { maskIf: (s: string) => s, printKeyValueBox: vi.fn(), })); - vi.doMock("../../src/lib/admin-client.js", () => ({ - AdminClient: vi.fn(), - })); - vi.doMock("../../src/commands/config.js", () => ({ - selectOrCreateApp: vi.fn(), - })); - vi.doMock("../../src/commands/wallet.js", () => ({ - generateAndPersistWallet: vi.fn(), - importAndPersistWallet: vi.fn(), - })); + vi.spyOn(console, "log").mockImplementation(() => {}); const { runOnboarding } = await import("../../src/commands/onboarding.js"); const completed = await runOnboarding({} as never); - expect(completed).toBe(false); + expect(completed).toBe(true); + expect(save).toHaveBeenCalledWith({ + auth_token: "test_token", + auth_token_expires_at: "2099-01-01T00:00:00Z", + }); }); - it("prints next steps when selected path remains incomplete", async () => { - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + it("returns false when user cancels the prompt", async () => { vi.doMock("../../src/lib/terminal-ui.js", () => ({ - promptSelect: vi.fn().mockResolvedValue("api-key"), - promptText: vi.fn().mockResolvedValue(""), + promptText: vi.fn().mockResolvedValue(null), })); vi.doMock("../../src/lib/update-check.js", () => ({ - getUpdateNoticeLines: vi.fn().mockReturnValue([ - " Update available 0.2.0 -> 9.9.9", - " Run npm i -g @alchemy/cli@latest to update", - ]), + getUpdateNoticeLines: vi.fn().mockReturnValue([]), })); vi.doMock("../../src/lib/config.js", () => ({ load: vi.fn().mockReturnValue({}), @@ -158,29 +101,22 @@ describe("onboarding flow", () => { maskIf: (s: string) => s, printKeyValueBox: vi.fn(), })); - vi.doMock("../../src/lib/admin-client.js", () => ({ - AdminClient: vi.fn(), - })); - vi.doMock("../../src/commands/config.js", () => ({ - selectOrCreateApp: vi.fn(), - })); - vi.doMock("../../src/commands/wallet.js", () => ({ - generateAndPersistWallet: vi.fn(), - importAndPersistWallet: vi.fn(), - })); + vi.spyOn(console, "log").mockImplementation(() => {}); const { runOnboarding } = await import("../../src/commands/onboarding.js"); const completed = await runOnboarding({} as never); expect(completed).toBe(false); - expect(logSpy).toHaveBeenCalledWith(" Next steps:"); - expect(logSpy).toHaveBeenCalledWith(" - alchemy config set api-key "); }); it("prints the update notice on the onboarding screen when provided", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); vi.doMock("../../src/lib/terminal-ui.js", () => ({ - promptSelect: vi.fn().mockResolvedValue("exit"), - promptText: vi.fn(), + promptText: vi.fn().mockResolvedValue(""), + })); + vi.doMock("../../src/lib/auth.js", () => ({ + performBrowserLogin: vi.fn().mockRejectedValue(new Error("login failed")), + AUTH_PORT: 16424, + getLoginUrl: vi.fn().mockReturnValue("https://auth.alchemy.com/login"), })); vi.doMock("../../src/lib/update-check.js", () => ({ getUpdateNoticeLines: vi.fn().mockReturnValue([ @@ -201,20 +137,9 @@ describe("onboarding flow", () => { maskIf: (s: string) => s, printKeyValueBox: vi.fn(), })); - vi.doMock("../../src/lib/admin-client.js", () => ({ - AdminClient: vi.fn(), - })); - vi.doMock("../../src/commands/config.js", () => ({ - selectOrCreateApp: vi.fn(), - })); - vi.doMock("../../src/commands/wallet.js", () => ({ - generateAndPersistWallet: vi.fn(), - importAndPersistWallet: vi.fn(), - })); const { runOnboarding } = await import("../../src/commands/onboarding.js"); - const completed = await runOnboarding({} as never, "9.9.9"); - expect(completed).toBe(false); + await runOnboarding({} as never, "9.9.9"); expect(logSpy).toHaveBeenCalledWith(" Update available 0.2.0 -> 9.9.9"); expect(logSpy).toHaveBeenCalledWith(" Run npm i -g @alchemy/cli@latest to update"); });