From 7107f1fbd6d3338e353e2f3d0d6a7a51a24dbb96 Mon Sep 17 00:00:00 2001 From: Andrey Date: Mon, 13 Jul 2026 15:16:10 +0100 Subject: [PATCH] WLT-1728: add browser (OAuth) login to CLI Add a modern browser login flow (like `claude` / `gh auth login`): the CLI starts a loopback server, opens dashboard.zerion.io/oauth/authorize, and captures the API key from the redirect. Exposed as a standalone `zerion login` command and as an interactive picker in `zerion init` (browser login first, then paste-a-key, then pay-per-call info). - cli/utils/api/oauth.js: loopback authorize flow (client_id=zerion-cli, 127.0.0.1 bind, state CSRF guard, 302 to /oauth/success, 5m timeout) - cli/utils/api/interactive-auth.js: shared auth-method picker - cli/utils/common/select.js: arrow-key single-select extracted from policy-picker.js (now reused there) - cli/utils/common/browser.js: shared openBrowser helper - ZERION_DASHBOARD_URL override for staging/local QA - README + --help + router usage updated Co-Authored-By: Claude Opus 4.8 --- README.md | 20 ++-- cli/commands/init.js | 43 ++------ cli/commands/login.js | 77 ++++++++++++++ cli/router.js | 7 +- cli/utils/api/interactive-auth.js | 104 +++++++++++++++++++ cli/utils/api/oauth.js | 164 ++++++++++++++++++++++++++++++ cli/utils/common/browser.js | 18 ++++ cli/utils/common/constants.js | 4 + cli/utils/common/select.js | 83 +++++++++++++++ cli/utils/wallet/policy-picker.js | 80 ++------------- cli/zerion.js | 5 + 11 files changed, 490 insertions(+), 115 deletions(-) create mode 100644 cli/commands/login.js create mode 100644 cli/utils/api/interactive-auth.js create mode 100644 cli/utils/api/oauth.js create mode 100644 cli/utils/common/browser.js create mode 100644 cli/utils/common/select.js diff --git a/README.md b/README.md index ef317b57..35e363ef 100644 --- a/README.md +++ b/README.md @@ -153,19 +153,23 @@ Three options. The CLI auto-detects which is active. Get a key at **[dashboard.zerion.io](https://dashboard.zerion.io)** — it's free and takes a minute. Keys begin with `zk_`. +The fastest way is **browser login** — like `claude` or `gh auth login`, it opens the dashboard, you approve, and the key is captured over a local loopback redirect and saved to config. The key never leaves your machine. + ```bash -export ZERION_API_KEY="zk_..." +zerion login # pick browser login, paste a key, or pay-per-call +zerion login --browser # go straight to browser authentication ``` -- HTTP Basic Auth -- Required for analysis and trading commands (analysis can also use x402 / MPP pay-per-call instead — see options B and C) - -You can also persist it via config: +Or set / persist a key manually: ```bash -zerion config set apiKey zk_... +export ZERION_API_KEY="zk_..." # per-session +zerion config set apiKey zk_... # persisted to ~/.zerion/config.json ``` +- HTTP Basic Auth +- Required for analysis and trading commands (analysis can also use x402 / MPP pay-per-call instead — see options B and C) + ### B) x402 pay-per-call **No API key needed.** Pay $0.01 USDC per request via the [x402 protocol](https://www.x402.org/). Supports EVM (Base) and Solana. @@ -314,7 +318,9 @@ Track wallets by name without exposing addresses in commands. | Command | Description | Example | |---------|-------------|---------| -| `zerion init` | One-shot onboarding — install CLI globally, configure API key, install agent skills | `zerion init` | +| `zerion login` | Authenticate — browser (dashboard) login, paste an API key, or pay-per-call | `zerion login` | +| `zerion login --browser` | Browser auth: opens dashboard.zerion.io, captures the key via loopback | `zerion login --browser` | +| `zerion init` | One-shot onboarding — install CLI globally, authenticate, install agent skills | `zerion init` | | `zerion init -y --browser` | Non-interactive init that opens dashboard.zerion.io for the API key | `npx -y zerion-cli init -y --browser` | | `zerion setup skills` | Install Zerion agent skills into detected coding agents | `zerion setup skills` | | `zerion setup skills --agent claude-code` | Install into a specific agent | `zerion setup skills --agent claude-code` | diff --git a/cli/commands/init.js b/cli/commands/init.js index 46bc3c47..dc69005b 100644 --- a/cli/commands/init.js +++ b/cli/commands/init.js @@ -3,19 +3,20 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { print, printError } from "../utils/common/output.js"; -import { readSecret } from "../utils/common/prompt.js"; -import { getApiKey, setConfigValue } from "../utils/config.js"; +import { openBrowser } from "../utils/common/browser.js"; +import { DASHBOARD_URL } from "../utils/common/constants.js"; +import { getApiKey } from "../utils/config.js"; +import { runInteractiveAuth } from "../utils/api/interactive-auth.js"; const ZERION_AGENT_REPO = "zeriontech/zerion-ai"; -const DASHBOARD_URL = "https://dashboard.zerion.io"; const HELP = { usage: "zerion init [options]", description: - "One-shot onboarding: install the CLI globally, configure an API key, and install Zerion agent skills into detected coding agents. By default the skills step is interactive — pick which skills you want.", + "One-shot onboarding: install the CLI globally, authenticate (browser login, paste an API key, or pay-per-call), and install Zerion agent skills into detected coding agents. By default the auth and skills steps are interactive.", flags: { "--yes, -y": "Non-interactive — skip prompts and install ALL skills (otherwise user picks)", - "--browser": "Open dashboard.zerion.io in the default browser during auth", + "--browser": "With --yes: open dashboard.zerion.io to grab an API key (interactive runs offer browser login directly)", "--no-install": "Skip the global `npm install -g zerion-cli` step", "--no-auth": "Skip the API key configuration step", "--no-skills": "Skip the agent skills install step", @@ -60,17 +61,6 @@ function detectAgent() { return null; } -function openBrowser(url) { - const cmd = - process.platform === "darwin" - ? "open" - : process.platform === "win32" - ? "start" - : "xdg-open"; - const args = process.platform === "win32" ? ["", url] : [url]; - spawnSync(cmd, args, { stdio: "ignore", shell: process.platform === "win32" }); -} - function ensureGlobalInstall() { // Two skip conditions: // 1. Running from a global install (not npx temp dir). @@ -98,6 +88,8 @@ async function ensureApiKey({ yes, browser }) { if (yes) { log(` ! No API key configured. Get one at ${DASHBOARD_URL} and run:`); log(` zerion config set apiKey `); + log(` (or run 'zerion login' for browser authentication)`); + if (browser) openBrowser(DASHBOARD_URL); return { ok: true, skipped: true, reason: "non_interactive" }; } @@ -107,23 +99,8 @@ async function ensureApiKey({ yes, browser }) { return { ok: true, skipped: true, reason: "non_tty" }; } - log(` Get an API key at ${DASHBOARD_URL}`); - if (browser) { - log(` Opening browser...`); - openBrowser(DASHBOARD_URL); - } - - const key = await readSecret(" Paste your API key (or press Enter to skip): ", { mask: true }); - if (!key) { - log(" ! Skipped — set later with: zerion config set apiKey "); - return { ok: true, skipped: true, reason: "user_skipped" }; - } - if (!key.startsWith("zk_")) { - log(` ! Warning: keys typically start with "zk_". Saving anyway.`); - } - setConfigValue("apiKey", key); - log(" ✓ API key saved to config"); - return { ok: true, skipped: false }; + // Interactive: browser login (default), paste a key, or pay-per-call. + return runInteractiveAuth({ log, open: true }); } function installSkills({ agent, yes }) { diff --git a/cli/commands/login.js b/cli/commands/login.js new file mode 100644 index 00000000..3c777aa1 --- /dev/null +++ b/cli/commands/login.js @@ -0,0 +1,77 @@ +import { print, printError } from "../utils/common/output.js"; +import { getApiKey, setConfigValue } from "../utils/config.js"; +import { DASHBOARD_URL } from "../utils/common/constants.js"; +import { authenticateWithBrowser } from "../utils/api/oauth.js"; +import { runInteractiveAuth } from "../utils/api/interactive-auth.js"; + +const HELP = { + usage: "zerion login [options]", + description: + "Authenticate the CLI with your Zerion API key. Opens the Zerion dashboard in your browser and captures the key via a local loopback redirect (like `claude` / `gh auth login`), then saves it to config. Interactive runs also offer pasting an existing key or pay-per-call.", + flags: { + "--browser": "Skip the picker and go straight to browser authentication", + "--no-open": "Print the authorize URL but don't auto-open the browser", + }, + examples: { + "zerion login": "Interactive — pick browser auth, paste a key, or pay-per-call", + "zerion login --browser": "Go straight to browser authentication", + "zerion login --browser --no-open": "Browser auth on a remote/headless host — copy the printed URL", + }, +}; + +function log(line = "") { + process.stderr.write(line + "\n"); +} + +export default async function login(args, flags) { + if (flags.help || flags.h) { + print(HELP); + return; + } + + // parseFlags maps `--no-open` to `flags.open = false`. + const open = flags.open !== false; + const dashboardUrl = DASHBOARD_URL; + + if (getApiKey()) { + log(" ! An API key is already configured — continuing will replace it."); + } + + // --browser: skip the picker. Works without a TTY (approval happens in the + // browser, out-of-band), so it's the headless-friendly path. + if (flags.browser) { + try { + const { apiKey } = await authenticateWithBrowser({ dashboardUrl, open, log }); + setConfigValue("apiKey", apiKey); + log(" ✓ Authenticated — API key saved to config"); + print({ ok: true, action: "login", method: "oauth" }); + } catch (err) { + printError(err.code || "login_failed", err.message); + process.exit(1); + } + return; + } + + if (!process.stdin.isTTY) { + printError( + "not_interactive", + "zerion login needs an interactive terminal. Use --browser for headless browser auth, " + + "or set ZERION_API_KEY / run: zerion config set apiKey " + ); + process.exit(1); + } + + const res = await runInteractiveAuth({ log, open, dashboardUrl }); + if (!res.ok) { + printError(res.reason || "login_failed", res.message || "Login failed"); + process.exit(1); + } + + print({ + ok: true, + action: "login", + method: res.method ?? null, + skipped: Boolean(res.skipped), + ...(res.reason ? { reason: res.reason } : {}), + }); +} diff --git a/cli/router.js b/cli/router.js index 866d0ae2..77b5aa9f 100644 --- a/cli/router.js +++ b/cli/router.js @@ -111,7 +111,8 @@ function printUsage() { "--allowlist ": "Only allow interaction with listed addresses", }, env: { - "ZERION_API_KEY": "API key (get at dashboard.zerion.io)", + "ZERION_API_KEY": "API key (get at dashboard.zerion.io, or run `zerion login`)", + "ZERION_DASHBOARD_URL": "Override the dashboard base URL for `zerion login` / `init` (default: https://dashboard.zerion.io)", "WALLET_PRIVATE_KEY": "Private key for pay-per-call: 0x-hex → x402 on Base; base58 → x402 on Solana; 0x-hex → also works for MPP", "EVM_PRIVATE_KEY": "EVM private key for x402 on Base (overrides WALLET_PRIVATE_KEY for EVM)", "SOLANA_PRIVATE_KEY": "Solana private key for x402 on Solana (overrides WALLET_PRIVATE_KEY for Solana)", @@ -129,7 +130,9 @@ function printUsage() { "slippage": "Default slippage % for swaps (default: 2)", }, setup: { - "init": "One-shot onboarding: install CLI globally, configure API key, install agent skills", + "login": "Authenticate — browser (dashboard) login, paste an API key, or pay-per-call", + "login --browser": "Browser authentication: opens dashboard.zerion.io, captures the key via loopback", + "init": "One-shot onboarding: install CLI globally, authenticate, install agent skills", "init -y --browser": "Non-interactive init that opens dashboard.zerion.io for the API key", "setup skills": "Install Zerion agent skills via `npx skills add zeriontech/zerion-ai` (45+ hosts)", }, diff --git a/cli/utils/api/interactive-auth.js b/cli/utils/api/interactive-auth.js new file mode 100644 index 00000000..bbde43da --- /dev/null +++ b/cli/utils/api/interactive-auth.js @@ -0,0 +1,104 @@ +/** + * Interactive auth-method picker shared by `zerion init` and `zerion login`. + * + * Presents the currently available ways to authenticate — browser login first + * (the modern default, like Claude Code), then pasting an existing API key, + * then a pointer to pay-per-call (x402 / MPP) which needs no API key. Requires + * a raw-mode TTY; callers guard on `process.stdin.isTTY` before invoking. + */ + +import { selectOne, BACK } from "../common/select.js"; +import { readSecret } from "../common/prompt.js"; +import { setConfigValue } from "../config.js"; +import { DASHBOARD_URL } from "../common/constants.js"; +import { authenticateWithBrowser } from "./oauth.js"; + +const METHODS = [ + { id: "oauth", label: "Authenticate with the Zerion dashboard (opens browser)" }, + { id: "paste", label: "Paste an existing API key" }, + { id: "payg", label: "Use pay-per-call instead (x402 / MPP — no API key)" }, +]; + +/** + * Show the auth-method menu. Returns the chosen method id, or null if the user + * backed out (Esc). + * @param {{ includePayg?: boolean }} [opts] + * @returns {Promise<"oauth" | "paste" | "payg" | null>} + */ +export async function selectAuthMethod({ includePayg = true } = {}) { + const methods = includePayg ? METHODS : METHODS.filter((m) => m.id !== "payg"); + const idx = await selectOne( + "How would you like to authenticate?", + methods.map((m) => m.label), + { defaultIndex: 0 } + ); + if (idx === BACK) return null; + return methods[idx].id; +} + +function printPaygGuidance(log) { + log(""); + log(" Pay-per-call needs no API key — set a private key and pass --x402 or --mpp:"); + log(" export WALLET_PRIVATE_KEY=0x... # x402 on Base (EVM) or MPP on Tempo"); + log(" export WALLET_PRIVATE_KEY= # x402 on Solana"); + log(" zerion portfolio
--x402 # or --mpp"); + log(" Note: pay-per-call covers analytics only; trading needs an API key."); +} + +/** + * Run the interactive auth setup: pick a method and execute it, persisting the + * API key to config on success. Never throws for expected outcomes (denied / + * timeout / cancel / skip) — returns a structured result so callers decide how + * loud to be. + * + * @param {{ + * log?: (line?: string) => void, + * open?: boolean, + * dashboardUrl?: string, + * includePayg?: boolean, + * }} [opts] + * @returns {Promise<{ ok: boolean, method?: string, skipped?: boolean, reason?: string, message?: string }>} + */ +export async function runInteractiveAuth({ + log = (line = "") => process.stderr.write(line + "\n"), + open = true, + dashboardUrl = DASHBOARD_URL, + includePayg = true, +} = {}) { + const method = await selectAuthMethod({ includePayg }); + + if (method === null) { + log(" ! Cancelled — no changes made."); + return { ok: true, skipped: true, reason: "user_cancelled" }; + } + + if (method === "oauth") { + try { + const { apiKey } = await authenticateWithBrowser({ dashboardUrl, open, log }); + setConfigValue("apiKey", apiKey); + log(" ✓ Authenticated — API key saved to config"); + return { ok: true, method: "oauth" }; + } catch (err) { + log(` ! Browser authorization failed: ${err.message}`); + return { ok: false, method: "oauth", reason: err.code || "oauth_failed", message: err.message }; + } + } + + if (method === "paste") { + const key = await readSecret(" Paste your API key (or press Enter to skip): ", { mask: true }); + if (!key) { + log(" ! Skipped — set later with: zerion config set apiKey "); + return { ok: true, skipped: true, method: "paste", reason: "user_skipped" }; + } + if (!key.startsWith("zk_")) { + log(` ! Warning: keys typically start with "zk_". Saving anyway.`); + } + setConfigValue("apiKey", key); + log(" ✓ API key saved to config"); + return { ok: true, method: "paste" }; + } + + // payg — informational only; nothing is persisted. + printPaygGuidance(log); + return { ok: true, skipped: true, method: "payg", reason: "pay_per_call" }; +} diff --git a/cli/utils/api/oauth.js b/cli/utils/api/oauth.js new file mode 100644 index 00000000..b60301da --- /dev/null +++ b/cli/utils/api/oauth.js @@ -0,0 +1,164 @@ +/** + * Browser (OAuth-style) authentication for the Zerion CLI. + * + * Mirrors the modern CLI login pattern (Claude Code, `gh auth login`): the CLI + * starts a throwaway loopback HTTP server, opens dashboard.zerion.io/oauth/authorize + * in the browser, and the dashboard redirects back to the loopback server with + * the credential once the user approves — the key never leaves the machine. + * + * Protocol (dashboard side — api-developer-dashboard, ADR 0001): + * - client_id = "zerion-cli" + * - redirect_uri must be loopback http (127.0.0.1 / localhost / [::1]), + * any port, any path + * - `state` is required and echoed back verbatim (CSRF guard) + * - approve → GET redirect_uri?code=&state= + * (v1: the `code` value IS the raw API key — no token exchange) + * - deny → GET redirect_uri?error=access_denied&state= + * On success the loopback server replies to the browser with a 302 to the + * dashboard's /oauth/success page so the user sees a friendly confirmation and + * can close the tab. + */ + +import { createServer } from "node:http"; +import { randomBytes } from "node:crypto"; +import { DASHBOARD_URL } from "../common/constants.js"; +import { openBrowser } from "../common/browser.js"; + +const CLIENT_ID = "zerion-cli"; +const CALLBACK_PATH = "/callback"; +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +function authError(code, message) { + const err = new Error(message); + err.code = code; + return err; +} + +// Bind to 127.0.0.1 only — the callback carries the raw API key and must never +// be reachable off-host. Port 0 lets the OS assign an ephemeral port. +function listenLoopback(server) { + return new Promise((resolve, reject) => { + const onError = (err) => reject(err); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.removeListener("error", onError); + resolve(server.address().port); + }); + }); +} + +export function buildAuthorizeUrl({ dashboardUrl, redirectUri, state }) { + const url = new URL("/oauth/authorize", dashboardUrl); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("state", state); + return url.toString(); +} + +/** + * Run the browser login flow. Resolves with `{ ok: true, apiKey }` once the + * dashboard redirects the key back; rejects with a coded Error on + * timeout / denial / state-mismatch / no-code. + * + * @param {{ + * dashboardUrl?: string, + * open?: boolean, + * timeoutMs?: number, + * log?: (line?: string) => void, + * }} [opts] + */ +export async function authenticateWithBrowser({ + dashboardUrl = DASHBOARD_URL, + open = true, + timeoutMs = DEFAULT_TIMEOUT_MS, + log = (line = "") => process.stderr.write(line + "\n"), +} = {}) { + const state = randomBytes(32).toString("base64url"); + const server = createServer(); + const port = await listenLoopback(server); + + const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`; + const authorizeUrl = buildAuthorizeUrl({ dashboardUrl, redirectUri, state }); + const successUrl = new URL("/oauth/success", dashboardUrl).toString(); + + const waiter = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + authError( + "oauth_timeout", + `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for browser authorization.` + ) + ); + }, timeoutMs); + + const settle = (fn, arg) => { + clearTimeout(timer); + fn(arg); + }; + + server.on("request", (req, res) => { + let reqUrl; + try { + reqUrl = new URL(req.url, `http://127.0.0.1:${port}`); + } catch { + res.writeHead(400).end(); + return; + } + + // Ignore stray requests (favicon, health checks) — only the callback + // path settles the flow. + if (reqUrl.pathname !== CALLBACK_PATH) { + res.writeHead(404, { "content-type": "text/plain" }).end("Not found"); + return; + } + + const params = reqUrl.searchParams; + const returnedState = params.get("state"); + const error = params.get("error"); + const code = params.get("code"); + + // CSRF guard — verify state before trusting `code`/`error`. + if (returnedState !== state) { + res.writeHead(400, { "content-type": "text/plain" }); + res.end("Authorization failed: state mismatch. You can close this tab."); + settle( + reject, + authError("oauth_state_mismatch", "State mismatch — authorization rejected (possible CSRF).") + ); + return; + } + if (error) { + res.writeHead(200, { "content-type": "text/plain" }); + res.end(`Authorization ${error}. You can close this tab and return to the CLI.`); + settle(reject, authError("oauth_denied", `Authorization was denied (${error}).`)); + return; + } + if (!code) { + res.writeHead(400, { "content-type": "text/plain" }); + res.end("Authorization failed: no credential returned. You can close this tab."); + settle(reject, authError("oauth_no_code", "No credential returned by the dashboard.")); + return; + } + + // Success — bounce the browser to the dashboard's confirmation page. + res.writeHead(302, { location: successUrl }); + res.end(); + settle(resolve, code); + }); + + server.on("error", (err) => settle(reject, err)); + }); + + log(" Opening your browser to authorize with the Zerion dashboard:"); + log(` ${authorizeUrl}`); + log(""); + if (open) openBrowser(authorizeUrl); + log(" Waiting for you to approve in the browser… (Ctrl-C to cancel)"); + + try { + const apiKey = await waiter; + return { ok: true, apiKey }; + } finally { + server.close(); + } +} diff --git a/cli/utils/common/browser.js b/cli/utils/common/browser.js new file mode 100644 index 00000000..8fcc48aa --- /dev/null +++ b/cli/utils/common/browser.js @@ -0,0 +1,18 @@ +/** + * Open a URL in the user's default browser. Best-effort and fire-and-forget — + * callers always print the URL too so a headless / no-GUI environment can copy + * it manually. + */ + +import { spawnSync } from "node:child_process"; + +export function openBrowser(url) { + const cmd = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "start" + : "xdg-open"; + const args = process.platform === "win32" ? ["", url] : [url]; + spawnSync(cmd, args, { stdio: "ignore", shell: process.platform === "win32" }); +} diff --git a/cli/utils/common/constants.js b/cli/utils/common/constants.js index 823ef764..57d4fd92 100644 --- a/cli/utils/common/constants.js +++ b/cli/utils/common/constants.js @@ -1,6 +1,10 @@ import { homedir } from "node:os"; export const API_BASE = process.env.ZERION_API_BASE || "https://api.zerion.io/v1"; +// Developer dashboard — hosts the OAuth-style authorize flow (`zerion login`, +// `zerion init`). Overridable so the browser flow can be pointed at a staging +// or local dashboard during QA. +export const DASHBOARD_URL = process.env.ZERION_DASHBOARD_URL || "https://dashboard.zerion.io"; export const HOME = process.env.HOME || process.env.USERPROFILE || homedir(); export const CONFIG_DIR = `${HOME}/.zerion`; export const CONFIG_PATH = `${CONFIG_DIR}/config.json`; diff --git a/cli/utils/common/select.js b/cli/utils/common/select.js new file mode 100644 index 00000000..9e373807 --- /dev/null +++ b/cli/utils/common/select.js @@ -0,0 +1,83 @@ +/** + * Interactive single-select menu — ↑/↓ (or j/k) navigate, Enter confirm, + * optional Esc to go back. Shared by the security-policy picker and the + * auth-method picker. + * + * Renders to stderr so stdout stays a clean JSON channel, and requires a + * raw-mode TTY (callers must guard on `process.stdin.isTTY`). + */ + +// ANSI — bright variants for dark terminal contrast. +export const BOLD = "\x1b[1m"; +export const RESET = "\x1b[0m"; +export const WHITE = "\x1b[97m"; +export const GREEN = "\x1b[92m"; +export const GRAY = "\x1b[90m"; + +export const BACK = Symbol("back"); + +/** + * @param {string} title + * @param {string[]} items + * @param {{ defaultIndex?: number, allowBack?: boolean }} [opts] + * @returns {Promise} the selected index, or BACK if Esc + * was pressed while `allowBack` is enabled. + */ +export function selectOne(title, items, { defaultIndex = 0, allowBack = true } = {}) { + let cursor = defaultIndex; + // title + items + hint = exact line count for re-draw + const menuLines = items.length + 2; + + function render(clear) { + if (clear) process.stderr.write(`\x1b[${menuLines}A\x1b[J`); + process.stderr.write(`${WHITE}${BOLD}${title}${RESET}\n`); + for (let i = 0; i < items.length; i++) { + if (i === cursor) { + process.stderr.write(` ${GREEN}>${RESET} ${WHITE}${BOLD}${items[i]}${RESET}\n`); + } else { + process.stderr.write(` ${GRAY}${items[i]}${RESET}\n`); + } + } + const hint = allowBack + ? " ↑/↓ navigate · Enter confirm · Esc back" + : " ↑/↓ navigate · Enter confirm"; + process.stderr.write(`${GRAY}${hint}${RESET}\n`); + } + + process.stderr.write("\n"); // spacing before first render only + render(false); + + return new Promise((resolve) => { + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + + const onData = (key) => { + const done = (val) => { + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdin.removeListener("data", onData); + process.stderr.write(`\x1b[${menuLines}A\x1b[J`); + resolve(val); + }; + if (key === "\r" || key === "\n") { + done(cursor); + } else if (allowBack && key === "\x1b" && key.length === 1) { + done(BACK); + } else if (key === "\x1b[A" || key === "k") { + cursor = (cursor - 1 + items.length) % items.length; + render(true); + } else if (key === "\x1b[B" || key === "j") { + cursor = (cursor + 1) % items.length; + render(true); + } else if (key === "\x03") { + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stderr.write("\n"); + process.exit(130); + } + }; + + process.stdin.on("data", onData); + }); +} diff --git a/cli/utils/wallet/policy-picker.js b/cli/utils/wallet/policy-picker.js index ed9fa92e..f37b4c5d 100644 --- a/cli/utils/wallet/policy-picker.js +++ b/cli/utils/wallet/policy-picker.js @@ -15,19 +15,11 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import * as ows from "./keystore.js"; import { allChainNames, toCaip2, fromCaip2 } from "./keystore.js"; +import { selectOne, BACK, BOLD, RESET, WHITE, GREEN, GRAY } from "../common/select.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const POLICIES_DIR = join(__dirname, "..", "..", "policies"); -// ANSI — bright variants for dark terminal contrast -const BOLD = "\x1b[1m"; -const RESET = "\x1b[0m"; -const WHITE = "\x1b[97m"; -const GREEN = "\x1b[92m"; -const GRAY = "\x1b[90m"; - -const BACK = Symbol("back"); - const EXPIRY_OPTIONS = [ { label: "7 days", days: 7 }, { label: "30 days", days: 30 }, @@ -50,11 +42,11 @@ export async function pickPolicyInteractive(walletName) { while (true) { // Step 1: Pick tier - const tier = await pickOne("Select policy tier:", [ + const tier = await selectOne("Select policy tier:", [ "Standard — deny transfers + expiry (recommended)", "Strict — deny transfers + expiry + restrict chains", "Custom — use an existing policy", - ], 0); + ], { defaultIndex: 0 }); if (tier === BACK) continue; @@ -68,20 +60,20 @@ export async function pickPolicyInteractive(walletName) { ); continue; } - const choice = await pickOne( + const choice = await selectOne( "Select policy:", policies.map((p) => `${p.name || p.id} ${formatPolicyDetails(p)}`), - 0 + { defaultIndex: 0 } ); if (choice === BACK) continue; return policies[choice].id; } // Step 2: Pick expiry - const expiryIdx = await pickOne("Select token expiry:", EXPIRY_OPTIONS.map((o, i) => { + const expiryIdx = await selectOne("Select token expiry:", EXPIRY_OPTIONS.map((o, i) => { const tag = i === 0 ? " (recommended)" : ""; return `${o.label}${tag}`; - }), 0); + }), { defaultIndex: 0 }); if (expiryIdx === BACK) continue; @@ -208,64 +200,6 @@ function findMatchingPolicy(expiryDays, chainNames) { return null; } -// --- Interactive single-select (↑/↓ navigate, Enter confirm, Esc back) --- - -function pickOne(title, items, defaultIndex) { - let cursor = defaultIndex; - // title + items + hint = exact line count for re-draw - const menuLines = items.length + 2; - - function render(clear) { - if (clear) process.stderr.write(`\x1b[${menuLines}A\x1b[J`); - process.stderr.write(`${WHITE}${BOLD}${title}${RESET}\n`); - for (let i = 0; i < items.length; i++) { - if (i === cursor) { - process.stderr.write(` ${GREEN}>${RESET} ${WHITE}${BOLD}${items[i]}${RESET}\n`); - } else { - process.stderr.write(` ${GRAY}${items[i]}${RESET}\n`); - } - } - process.stderr.write(`${GRAY} ↑/↓ navigate · Enter confirm · Esc back${RESET}\n`); - } - - process.stderr.write("\n"); // spacing before first render only - render(false); - - return new Promise((resolve) => { - process.stdin.setRawMode(true); - process.stdin.resume(); - process.stdin.setEncoding("utf8"); - - const onData = (key) => { - const done = (val) => { - process.stdin.setRawMode(false); - process.stdin.pause(); - process.stdin.removeListener("data", onData); - process.stderr.write(`\x1b[${menuLines}A\x1b[J`); - resolve(val); - }; - if (key === "\r" || key === "\n") { - done(cursor); - } else if (key === "\x1b" && key.length === 1) { - done(BACK); - } else if (key === "\x1b[A" || key === "k") { - cursor = (cursor - 1 + items.length) % items.length; - render(true); - } else if (key === "\x1b[B" || key === "j") { - cursor = (cursor + 1) % items.length; - render(true); - } else if (key === "\x03") { - process.stdin.setRawMode(false); - process.stdin.pause(); - process.stderr.write("\n"); - process.exit(130); - } - }; - - process.stdin.on("data", onData); - }); -} - // --- Interactive chain checklist (Space toggle, Enter confirm, Esc back) --- function pickChains() { diff --git a/cli/zerion.js b/cli/zerion.js index 4da8726c..4273e03a 100755 --- a/cli/zerion.js +++ b/cli/zerion.js @@ -106,6 +106,11 @@ registerSingle("setup", setupCmd); import initCmd from "./commands/init.js"; registerSingle("init", initCmd); +// --- Login (browser / API-key authentication) --- + +import loginCmd from "./commands/login.js"; +registerSingle("login", loginCmd); + // --- Dispatch --- try {