diff --git a/README.md b/README.md index 0781edb..815ab65 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,15 @@ CLI for [Zerion Wallet](https://zerion.io). Analyze wallets, sign, swap, and bri ## Installation -Set up everything in one command (install CLI globally, configure your API key, and add skills across all detected coding agents): +Set up everything in one command (install CLI globally, authenticate via browser, and add skills across all detected coding agents): ```bash -npx -y zerion-cli init -y --browser +npx -y zerion-cli init ``` -- `-y` runs setup non-interactively -- `--browser` opens [dashboard.zerion.io](https://dashboard.zerion.io) so you can grab an API key and paste it back +- opens your browser to [dashboard.zerion.io](https://dashboard.zerion.io), waits for you to click **Authorize**, then saves the API key automatically (PKCE flow — no manual paste) - skills install globally to every detected AI coding agent by default +- pass `-y` to run non-interactively in CI; auth is skipped and you can finish later with `zerion login` Or just install the CLI without setup: @@ -144,21 +144,24 @@ Three options. The CLI auto-detects which is active. ### A) API key (recommended) -Get a key at **[dashboard.zerion.io](https://dashboard.zerion.io)** — it's free and takes a minute. Keys begin with `zk_`. +Run the browser-based login flow — it opens [dashboard.zerion.io](https://dashboard.zerion.io), waits for you to click **Authorize**, and saves the key for you (PKCE; no manual paste): ```bash -export ZERION_API_KEY="zk_..." +zerion login # opens browser, completes via PKCE, saves the key +zerion logout # clear the saved API key (and any agent tokens) ``` -- 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 only do this once — the key persists in `~/.zerion/config.json` (mode 0o600). -You can also persist it via config: +For non-interactive setups (CI, scripts, containers) you can supply the key directly: ```bash -zerion config set apiKey zk_... +zerion login --api-key zk_... # save a key non-interactively +export ZERION_API_KEY="zk_..." # or just export it; CLI auto-detects ``` +Keys begin with `zk_` (e.g. `zk_dev_…`). 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. @@ -305,8 +308,11 @@ 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 init -y --browser` | Non-interactive init that opens dashboard.zerion.io for the API key | `npx -y zerion-cli init -y --browser` | +| `zerion init` | One-shot onboarding — install CLI globally, browser-auth via PKCE, install agent skills | `zerion init` | +| `zerion init -y` | Non-interactive init for CI; skips auth (run `zerion login` later) | `npx -y zerion-cli init -y` | +| `zerion login` | Browser-based login (PKCE) — opens dashboard.zerion.io and saves the key | `zerion login` | +| `zerion login --api-key zk_...` | Non-interactive login with a key you already have | `zerion login --api-key zk_dev_...` | +| `zerion logout` | Clear the saved API key and any agent tokens | `zerion logout` | | `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 68473cb..e29a397 100644 --- a/cli/commands/init.js +++ b/cli/commands/init.js @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process"; import { print, printError } from "../utils/common/output.js"; -import { readSecret } from "../utils/common/prompt.js"; +import { browserLogin } from "../utils/auth/browser-flow.js"; import { getApiKey, setConfigValue } from "../utils/config.js"; const ZERION_AGENT_REPO = "zeriontech/zerion-ai"; @@ -9,18 +9,20 @@ 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 via browser (PKCE), and install Zerion agent skills into detected coding agents. The skills step is interactive by default — pick which skills you want.", 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", + "--yes, -y": + "Non-interactive — skip browser auth (run `zerion login` later) and install ALL skills", "--no-install": "Skip the global `npm install -g zerion-cli` step", - "--no-auth": "Skip the API key configuration step", + "--no-auth": "Skip the authentication step", "--no-skills": "Skip the agent skills install step", "--agent ": "Scope skills install to one agent (e.g. claude-code, cursor)", }, examples: { - "npx -y zerion-cli init -y --browser": - "Bootstrap end-to-end non-interactively, opening the dashboard for the API key", + "npx -y zerion-cli init": + "Bootstrap end-to-end interactively — opens the browser for PKCE login and saves the key", + "npx -y zerion-cli init -y": + "Non-interactive bootstrap; skips auth so it works in CI. Run `zerion login` later", "zerion init --no-install --agent claude-code": "Skip self-install and only set up Claude Code", }, @@ -35,17 +37,6 @@ function isNpxTempInvocation() { return path.includes("/_npx/") || path.includes("\\_npx\\"); } -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() { if (!isNpxTempInvocation()) { log(" ✓ CLI already installed globally"); @@ -60,42 +51,35 @@ function ensureGlobalInstall() { return { ok: true, skipped: false }; } -async function ensureApiKey({ yes, browser }) { +async function ensureApiKey({ yes }) { const existing = getApiKey(); if (existing) { log(" ✓ Already authenticated"); return { ok: true, skipped: true }; } + // Non-interactive (CI, scripts) — PKCE needs a human click in the browser. + // Skip cleanly and tell the user how to finish later. if (yes) { - log(` ! No API key configured. Get one at ${DASHBOARD_URL} and run:`); - log(` zerion config set apiKey `); + log(` ! Skipped — run "zerion login" interactively to authenticate via browser.`); return { ok: true, skipped: true, reason: "non_interactive" }; } - if (!process.stdin.isTTY) { - log(` ! No API key configured and stdin is not interactive.`); - log(` Set ZERION_API_KEY or run: zerion config set apiKey `); + log(` ! No TTY — run "zerion login" interactively or set ZERION_API_KEY.`); return { ok: true, skipped: true, reason: "non_tty" }; } - log(` Get an API key at ${DASHBOARD_URL}`); - if (browser) { - log(` Opening browser...`); - openBrowser(DASHBOARD_URL); + try { + const result = await browserLogin(); + setConfigValue("apiKey", result.apiKey); + const who = result.teamName || result.email || "Zerion user"; + log(` ✓ Authenticated as ${who}`); + return { ok: true, skipped: false }; + } catch (err) { + log(` ! Login failed: ${err.message || err}`); + log(` Run "zerion login" later to retry, or set ZERION_API_KEY manually.`); + return { ok: true, skipped: true, reason: "login_failed" }; } - - 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 }; } function installSkills({ agent, yes }) { @@ -140,7 +124,6 @@ export default async function init(args, flags) { } const yes = Boolean(flags.yes || flags.y); - const browser = Boolean(flags.browser); // parseFlags maps `--no-install` to `flags.install = false` const skipInstall = flags.install === false; const skipAuth = flags.auth === false; @@ -167,7 +150,7 @@ export default async function init(args, flags) { log("[2/3] Authenticate"); const authRes = skipAuth ? { ok: true, skipped: true, reason: "flag" } - : await ensureApiKey({ yes, browser }); + : await ensureApiKey({ yes }); steps.push({ step: "auth", ...authRes }); log(""); diff --git a/cli/commands/login.js b/cli/commands/login.js new file mode 100644 index 0000000..136383c --- /dev/null +++ b/cli/commands/login.js @@ -0,0 +1,150 @@ +import readline from "node:readline"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { setConfigValue, getApiKey } from "../utils/config.js"; +import { print, printError } from "../utils/common/output.js"; +import { browserLogin } from "../utils/auth/browser-flow.js"; +import { readSecret } from "../utils/common/prompt.js"; +import { API_BASE, CONFIG_PATH } from "../utils/common/constants.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8")); + +function prompt(question) { + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + return new Promise((resolve) => + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }) + ); +} + +function banner() { + const w = (s) => process.stderr.write(s + "\n"); + w(""); + w(` zerion cli v${pkg.version}`); + w(` Wallet analysis & autonomous trading for AI agents`); + w(""); +} + +function maskKey(key) { + if (!key || typeof key !== "string") return "(unknown)"; + if (key.length <= 10) return key; + return `${key.slice(0, 6)}…${key.slice(-4)}`; +} + +// Dashboard issues keys prefixed `zk_dev_` or `zk_prod_`. Older guidance +// mentioned `zk-` so we accept either to avoid rejecting valid keys. +function isValidKeyFormat(key) { + return typeof key === "string" && /^zk[_-]/.test(key); +} + +function successBlock({ team, method, key }) { + const w = (s) => process.stderr.write(s + "\n"); + w(""); + w(`✓ Login successful!`); + if (team) w(` Team: ${team}`); + w(` API: ${API_BASE}`); + w(` Key: ${maskKey(key)}`); + w(` Config: ${CONFIG_PATH}`); + w(` Mode: ${method}`); + w(""); +} + +export default async function loginCmd(args, flags) { + const existingKey = getApiKey(); + if (existingKey && !flags.force) { + process.stderr.write("Already logged in. Use --force to replace the current API key.\n"); + print({ loggedIn: true, api: API_BASE, config: CONFIG_PATH, keyPrefix: maskKey(existingKey) }); + return; + } + + if (flags["api-key"]) { + if (flags.browser) { + process.stderr.write("Note: --api-key takes precedence over --browser.\n"); + } + const key = flags["api-key"]; + if (!isValidKeyFormat(key)) { + printError("invalid_key_format", "API keys start with 'zk_' (e.g. zk_dev_…)"); + process.exit(1); + } + setConfigValue("apiKey", key); + print({ loggedIn: true, method: "api-key", api: API_BASE, config: CONFIG_PATH, keyPrefix: maskKey(key) }); + return; + } + + if (flags.browser) { + if (!flags.quiet) banner(); + return runBrowser({ quiet: flags.quiet }); + } + + // Interactive menu needs a TTY. In non-TTY contexts (CI, pipes, containers), + // an interactive prompt blocks forever — fail loudly with a fix. + if (!process.stdin.isTTY) { + printError( + "no_tty", + "Interactive login requires a terminal. Use --browser, --api-key , or set ZERION_API_KEY." + ); + process.exit(1); + } + + if (!flags.quiet) banner(); + const w = (s) => process.stderr.write(s + "\n"); + w(`Welcome! To get started, authenticate with your Zerion account.`); + w(""); + w(` 1. Login with browser (recommended)`); + w(` 2. Enter API key manually`); + w(""); + w(`Tip: You can also set ZERION_API_KEY environment variable`); + w(` API endpoint: ${API_BASE}`); + w(""); + + const choice = await prompt("Enter choice [1/2]: "); + if (choice === "" || choice === "1") { + return runBrowser({ quiet: flags.quiet }); + } + if (choice !== "2") { + printError("invalid_choice", "Enter 1 or 2"); + process.exit(1); + } + + const key = await readSecret("Enter your Zerion API key: ", { mask: true }); + if (!isValidKeyFormat(key)) { + printError("invalid_key_format", "API keys start with 'zk_' (e.g. zk_dev_…)"); + process.exit(1); + } + setConfigValue("apiKey", key); + successBlock({ method: "api-key", key }); + print({ loggedIn: true, method: "api-key", api: API_BASE }); +} + +async function runBrowser({ quiet = false } = {}) { + try { + const result = await browserLogin(); + setConfigValue("apiKey", result.apiKey); + successBlock({ team: result.teamName || "(unknown)", method: "browser", key: result.apiKey }); + print({ + loggedIn: true, + method: "browser", + email: result.email, + team: result.teamName, + api: API_BASE, + config: CONFIG_PATH, + keyPrefix: maskKey(result.apiKey), + }); + } catch (err) { + // Top-level `zerion login` is a standalone command — exit non-zero on + // failure. When called inline from another flow (wallet create/import + // pass quiet: true), rethrow so the caller can recover instead of + // killing the outer process mid-setup. + if (quiet) { + const e = err instanceof Error ? err : new Error(String(err)); + e.code = e.code || "login_failed"; + throw e; + } + printError("login_failed", err.message || "Login failed"); + process.exit(1); + } +} diff --git a/cli/commands/logout.js b/cli/commands/logout.js new file mode 100644 index 0000000..c3a8dec --- /dev/null +++ b/cli/commands/logout.js @@ -0,0 +1,22 @@ +import { unsetConfigValue } from "../utils/config.js"; +import { print } from "../utils/common/output.js"; +import { CONFIG_PATH } from "../utils/common/constants.js"; + +export default async function logoutCmd() { + unsetConfigValue("apiKey"); + // Clear agent tokens too — they're tied to this account and won't work after logout. + unsetConfigValue("agentTokens"); + process.stderr.write(`✓ Logged out successfully\n Config: ${CONFIG_PATH}\n`); + + // ZERION_API_KEY overrides the saved config, so logout alone doesn't end + // the session if the user exported it in their shell. Surface it. + const envKeySet = !!process.env.ZERION_API_KEY; + if (envKeySet) { + process.stderr.write( + " Note: ZERION_API_KEY is still set in this shell. " + + "Run `unset ZERION_API_KEY` to fully log out.\n" + ); + } + + print({ loggedOut: true, config: CONFIG_PATH, envKeySet }); +} diff --git a/cli/commands/wallet/create.js b/cli/commands/wallet/create.js index 2cbdaa5..d71b46a 100644 --- a/cli/commands/wallet/create.js +++ b/cli/commands/wallet/create.js @@ -4,6 +4,7 @@ import { setConfigValue, getConfigValue } from "../../utils/config.js"; import { readPassphrase, readSecret } from "../../utils/common/prompt.js"; import { PASSPHRASE_WARNING } from "../../utils/common/constants.js"; import { offerAgentToken } from "../../utils/wallet/offer-agent-token.js"; +import { offerLogin } from "../../utils/wallet/offer-login.js"; export default async function walletCreate(args, flags) { const name = flags.name || args[0] || generateName(); @@ -40,6 +41,9 @@ export default async function walletCreate(args, flags) { isDefault: getConfigValue("defaultWallet") === name, }); + // Offer API key login first — agent tokens / trading / analysis all need one. + await offerLogin(); + // Offer agent token creation as part of wallet setup await offerAgentToken(name, passphrase); } catch (err) { diff --git a/cli/commands/wallet/import.js b/cli/commands/wallet/import.js index 72426ce..ca7a0fd 100644 --- a/cli/commands/wallet/import.js +++ b/cli/commands/wallet/import.js @@ -3,6 +3,7 @@ import { print, printError } from "../../utils/common/output.js"; import { setConfigValue, getConfigValue, setWalletOrigin, getWalletAddresses } from "../../utils/config.js"; import { readSecret, readPassphrase } from "../../utils/common/prompt.js"; import { offerAgentToken } from "../../utils/wallet/offer-agent-token.js"; +import { offerLogin } from "../../utils/wallet/offer-login.js"; import { WALLET_ORIGIN, PASSPHRASE_WARNING } from "../../utils/common/constants.js"; export default async function walletImport(args, flags) { @@ -60,6 +61,7 @@ export default async function walletImport(args, flags) { imported: true, }); + await offerLogin(); await offerAgentToken(name, passphrase); } catch (err) { printError("ows_error", `Failed to import wallet: ${err.message}`); diff --git a/cli/router.js b/cli/router.js index 18a143e..8f4befe 100644 --- a/cli/router.js +++ b/cli/router.js @@ -75,6 +75,13 @@ function printUsage() { "watch remove ": "Remove from watchlist", "analyze ": "Analyze wallet trading activity", }, + auth: { + "login": "Interactive login (prompts for browser or manual paste)", + "login --browser": "Open dashboard, auto-save API key after Authorize", + "login --api-key ": "Save API key non-interactively", + "login --force": "Replace the currently saved API key", + "logout": "Remove saved API key and agent tokens", + }, other: { "chains": "List supported chains", "config set ": "Set config (apiKey, defaultWallet, defaultChain, slippage)", diff --git a/cli/utils/auth/browser-flow.js b/cli/utils/auth/browser-flow.js new file mode 100644 index 0000000..c30d8bf --- /dev/null +++ b/cli/utils/auth/browser-flow.js @@ -0,0 +1,86 @@ +/** + * Browser-based login flow. + * + * Opens a dashboard URL that contains a PKCE code_challenge in the query + * string and a session_id in the URL fragment. Once the user clicks + * "Authorize" in the browser, the backend associates their API key with + * the session_id. This CLI polls the backend with {session_id, code_verifier} + * and receives the API key when the authorization completes. + */ + +import open from "open"; +import { generateSessionId, generateVerifier, generateChallenge } from "./pkce.js"; +import { WEB_URL, CLI_STATUS_URL } from "../common/constants.js"; + +const POLL_INTERVAL_MS = 2000; +const TIMEOUT_MS = 5 * 60 * 1000; + +export async function browserLogin({ + webUrl = WEB_URL, + statusUrl = CLI_STATUS_URL, + opener = open, +} = {}) { + const sessionId = generateSessionId(); + const verifier = generateVerifier(); + const challenge = generateChallenge(verifier); + + const loginUrl = `${webUrl}/cli-auth?code_challenge=${challenge}#session_id=${sessionId}`; + + process.stderr.write(`\nOpening browser for authentication...\n`); + process.stderr.write(`If the browser doesn't open, visit:\n ${loginUrl}\n\n`); + + try { + await opener(loginUrl); + } catch { + // fallback instructions already printed above + } + + return await pollForAuth({ sessionId, verifier, statusUrl }); +} + +async function pollForAuth({ sessionId, verifier, statusUrl }) { + const start = Date.now(); + let dots = 0; + const isTTY = process.stderr.isTTY; + + while (Date.now() - start < TIMEOUT_MS) { + if (isTTY) { + process.stderr.write(`\rWaiting for browser authentication${".".repeat(dots % 4).padEnd(3)} `); + dots++; + } + + const result = await pollOnce({ statusUrl, sessionId, verifier }); + if (result) { + if (isTTY) process.stderr.write("\r" + " ".repeat(50) + "\r"); + return result; + } + + await sleep(POLL_INTERVAL_MS); + } + + throw new Error("Authentication timed out. Please try again."); +} + +async function pollOnce({ statusUrl, sessionId, verifier }) { + try { + const res = await fetch(statusUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, code_verifier: verifier }), + }); + if (res.status === 202) return null; // pending + if (res.status === 401) throw new Error("Authentication rejected. Start login over."); + if (!res.ok) return null; // transient, keep polling + const data = await res.json(); + if (data && data.apiKey) return data; + return null; + } catch (err) { + // Network errors: transient, keep polling. Explicit 401 rethrows above. + if (err.message?.startsWith("Authentication rejected")) throw err; + return null; + } +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/cli/utils/auth/pkce.js b/cli/utils/auth/pkce.js new file mode 100644 index 0000000..5a2315e --- /dev/null +++ b/cli/utils/auth/pkce.js @@ -0,0 +1,25 @@ +/** + * PKCE primitives for the CLI browser login flow. + * + * - sessionId: opaque correlation ID placed in the URL fragment so the + * backend can route the approval to the right polling CLI. + * - verifier: random secret held only by the CLI; sent to the backend + * alongside its hash to prove the polling client is the same one that + * initiated the flow. + * - challenge: sha256(verifier) in base64url, sent to the browser so the + * backend can compare against the verifier later. + */ + +import crypto from "node:crypto"; + +export function generateSessionId() { + return crypto.randomBytes(32).toString("hex"); // 64 hex chars +} + +export function generateVerifier() { + return crypto.randomBytes(32).toString("base64url"); // 43 chars +} + +export function generateChallenge(verifier) { + return crypto.createHash("sha256").update(verifier).digest("base64url"); // 43 chars +} diff --git a/cli/utils/common/constants.js b/cli/utils/common/constants.js index 823ef76..a8a8ea5 100644 --- a/cli/utils/common/constants.js +++ b/cli/utils/common/constants.js @@ -20,3 +20,7 @@ export const PASSPHRASE_WARNING = "\nWARNING: This passphrase is the ONLY way to recover your wallet or\n" + "create new agent tokens. There is no reset or recovery mechanism.\n" + "If you lose it, your funds are permanently inaccessible.\n\n"; + +// Browser-login destination and polling endpoint (overridable for staging/tests). +export const WEB_URL = process.env.ZERION_WEB_URL || "https://developers.zerion.io"; +export const CLI_STATUS_URL = process.env.ZERION_CLI_STATUS_URL || `${WEB_URL}/api/cli/status`; diff --git a/cli/utils/wallet/offer-login.js b/cli/utils/wallet/offer-login.js new file mode 100644 index 0000000..fe0b72b --- /dev/null +++ b/cli/utils/wallet/offer-login.js @@ -0,0 +1,60 @@ +/** + * Post-wallet-creation prompt: if the user has no API key configured, + * offer to run `zerion login` inline so they can actually use the CLI. + * + * Skipped silently when an API key is already available (saved in config + * or via ZERION_API_KEY env var). + */ + +import readline from "node:readline"; +import { getApiKey } from "../config.js"; +import loginCmd from "../../commands/login.js"; + +function promptYesDefault(question) { + return new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + rl.question(question, (answer) => { + rl.close(); + const a = answer.trim().toLowerCase(); + // Empty (just Enter) accepts the default (Yes). + resolve(a === "" || a.startsWith("y")); + }); + }); +} + +export async function offerLogin() { + if (getApiKey()) return; + + // Non-TTY environments (CI, pipes) shouldn't hit an interactive prompt — + // leave a hint on stderr and move on. + if (!process.stdin.isTTY) { + process.stderr.write( + "\nNote: no ZERION_API_KEY configured. Run `zerion login` to get one.\n" + ); + return; + } + + process.stderr.write( + "\nTo use the Zerion CLI (portfolio, swap, analyze, …) you need an API key.\n" + ); + + const yes = await promptYesDefault("Run `zerion login` now to get one? (Y/n) "); + if (!yes) { + process.stderr.write( + "Skipped. Run `zerion login` any time, or set ZERION_API_KEY in your shell.\n\n" + ); + return; + } + + try { + await loginCmd([], { browser: true, quiet: true }); + } catch (err) { + // Don't abort the surrounding wallet-setup flow — the wallet is already + // created and the user still needs the agent-token offer. Surface the + // failure and point them at a clean retry. + process.stderr.write( + `\nLogin skipped: ${err.message || "failed"}. ` + + "Run `zerion login` any time to finish setup.\n\n" + ); + } +} diff --git a/cli/zerion.js b/cli/zerion.js index 51aacbd..07b6bd3 100755 --- a/cli/zerion.js +++ b/cli/zerion.js @@ -92,6 +92,13 @@ register("agent", "delete-policy", agentDeletePolicy); import configCmd from "./commands/config.js"; registerSingle("config", configCmd); +// --- Auth (login / logout) --- + +import loginCmd from "./commands/login.js"; +import logoutCmd from "./commands/logout.js"; +registerSingle("login", loginCmd); +registerSingle("logout", logoutCmd); + // --- Setup (skills installer wrapper) --- import setupCmd from "./commands/setup.js"; diff --git a/package-lock.json b/package-lock.json index 23aff8b..e05d4e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@x402/fetch": "^2.6.0", "@x402/svm": "^2.8.0", "mppx": "^0.6.2", + "open": "^10.1.0", "qrcode-terminal": "^0.12.0", "viem": "^2.0.0" }, @@ -1457,19 +1458,19 @@ "ieee754": "^1.2.1" } }, - "node_modules/bufferutil": { + "node_modules/bundle-name": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", - "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "hasInstallScript": true, + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "node-gyp-build": "^4.3.0" + "run-applescript": "^7.0.0" }, "engines": { - "node": ">=6.14.2" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/chalk": { @@ -1493,6 +1494,46 @@ "node": ">=20" } }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/delay": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", @@ -1599,6 +1640,54 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isomorphic-ws": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", @@ -1655,21 +1744,6 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/jayson/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/jayson/node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", @@ -1780,6 +1854,24 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ox": { "version": "0.14.18", "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.18.tgz", @@ -1865,6 +1957,18 @@ "@types/node": "*" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2135,6 +2239,21 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yaml": { "version": "2.8.4", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", diff --git a/package.json b/package.json index a1da136..1beec43 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "author": "Zerion", "type": "module", "bin": { - "zerion": "./cli/zerion.js" + "zerion": "cli/zerion.js" }, "files": [ "cli/zerion.js", @@ -60,6 +60,7 @@ "@x402/fetch": "^2.6.0", "@x402/svm": "^2.8.0", "mppx": "^0.6.2", + "open": "^10.1.0", "qrcode-terminal": "^0.12.0", "viem": "^2.0.0" }, diff --git a/skills/zerion/SKILL.md b/skills/zerion/SKILL.md index b1286b5..9e61ae4 100644 --- a/skills/zerion/SKILL.md +++ b/skills/zerion/SKILL.md @@ -25,11 +25,21 @@ Three modes. Pick one for analytics; trading always uses an API key. ### A) API key (recommended) +Run the browser-based login flow — opens [dashboard.zerion.io](https://dashboard.zerion.io), waits for the user to click **Authorize**, and saves the key automatically (PKCE; no manual paste): + +```bash +zerion login # browser-based PKCE flow, saves to ~/.zerion/config.json +zerion logout # clear saved key + agent tokens +``` + +For non-interactive contexts (CI, scripts, headless agents) supply the key directly: + ```bash -export ZERION_API_KEY="zk_dev_..." +zerion login --api-key zk_dev_... # save a key non-interactively +export ZERION_API_KEY="zk_dev_..." # or just export it; CLI auto-detects ``` -Get yours at [dashboard.zerion.io](https://dashboard.zerion.io). Dev keys begin with `zk_dev_`. Limits: 120 req/min, 5K req/day. +Get a key at [dashboard.zerion.io](https://dashboard.zerion.io). Dev keys begin with `zk_dev_`. Limits: 120 req/min, 5K req/day. ### B) x402 pay-per-call (no signup, analytics only) diff --git a/tests/auth/browser-flow.test.mjs b/tests/auth/browser-flow.test.mjs new file mode 100644 index 0000000..e330832 --- /dev/null +++ b/tests/auth/browser-flow.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { describe, it, beforeEach, afterEach } from "node:test"; + +// We import dynamically so we can set env vars (ZERION_CLI_STATUS_URL) before +// constants.js is evaluated. That lets browserLogin() hit a mocked URL. +let browserLogin; +const ORIG_FETCH = globalThis.fetch; +const ORIG_OPEN_DEFAULT = process.env.ZERION_CLI_STATUS_URL; + +describe("browser-flow.browserLogin", () => { + beforeEach(() => { + process.env.ZERION_CLI_STATUS_URL = "https://mock.invalid/api/cli/status"; + }); + + afterEach(() => { + globalThis.fetch = ORIG_FETCH; + if (ORIG_OPEN_DEFAULT === undefined) { + delete process.env.ZERION_CLI_STATUS_URL; + } else { + process.env.ZERION_CLI_STATUS_URL = ORIG_OPEN_DEFAULT; + } + }); + + it("polls until fetch returns 200 and resolves with the payload", async () => { + // Mock fetch: 202 (pending) → 200 (apiKey returned) + let call = 0; + globalThis.fetch = async () => { + call++; + if (call < 2) { + return new Response(null, { status: 202 }); + } + return new Response( + JSON.stringify({ apiKey: "zk-test123", email: "a@b.com", teamName: "Acme" }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }; + + // Fresh module import after env is set so CLI_STATUS_URL picks it up. + ({ browserLogin } = await import( + `../../cli/lib/auth/browser-flow.js?cachebust=${Date.now()}` + )); + + // Override the open() call path by passing a custom statusUrl. The real + // `open` call is fire-and-forget with try/catch, so if it fails we still + // continue. We just let the catch handle it. + const result = await browserLogin({ + webUrl: "https://mock.invalid", + statusUrl: "https://mock.invalid/api/cli/status", + opener: async () => {}, + }); + + assert.equal(result.apiKey, "zk-test123"); + assert.equal(result.email, "a@b.com"); + assert.equal(result.teamName, "Acme"); + assert.ok(call >= 2, `expected at least 2 fetch calls, got ${call}`); + }); + + it("rejects when fetch returns 401", async () => { + globalThis.fetch = async () => new Response(null, { status: 401 }); + + ({ browserLogin } = await import( + `../../cli/lib/auth/browser-flow.js?cachebust=${Date.now()}` + )); + + await assert.rejects( + () => + browserLogin({ + webUrl: "https://mock.invalid", + statusUrl: "https://mock.invalid/api/cli/status", + opener: async () => {}, + }), + /rejected/i + ); + }); +}); diff --git a/tests/auth/pkce.test.mjs b/tests/auth/pkce.test.mjs new file mode 100644 index 0000000..b08f3a5 --- /dev/null +++ b/tests/auth/pkce.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + generateSessionId, + generateVerifier, + generateChallenge, +} from "../../cli/lib/auth/pkce.js"; + +describe("pkce.generateSessionId", () => { + it("returns 64 hex chars", () => { + const id = generateSessionId(); + assert.equal(typeof id, "string"); + assert.equal(id.length, 64); + assert.match(id, /^[0-9a-f]{64}$/); + }); + + it("returns different values on successive calls", () => { + const a = generateSessionId(); + const b = generateSessionId(); + assert.notEqual(a, b); + }); +}); + +describe("pkce.generateVerifier", () => { + it("returns 43 base64url chars", () => { + const v = generateVerifier(); + assert.equal(typeof v, "string"); + assert.equal(v.length, 43); + // base64url alphabet: A-Z a-z 0-9 - _ + assert.match(v, /^[A-Za-z0-9_-]{43}$/); + }); + + it("returns different values on successive calls", () => { + const a = generateVerifier(); + const b = generateVerifier(); + assert.notEqual(a, b); + }); +}); + +describe("pkce.generateChallenge", () => { + it("matches the known sha256 fixture for a fixed verifier", () => { + const verifier = "test-verifier-zerion-cli-12345678901234"; + const challenge = generateChallenge(verifier); + // Pre-computed: sha256(verifier) -> base64url + assert.equal(challenge, "E0PhRBl4zAcNF3hl3T9j1XHzDXocYVN__AMSv6ZG-X0"); + }); + + it("returns 43 base64url chars", () => { + const challenge = generateChallenge(generateVerifier()); + assert.equal(challenge.length, 43); + assert.match(challenge, /^[A-Za-z0-9_-]{43}$/); + }); + + it("produces different challenges for different verifiers", () => { + const a = generateChallenge("verifier-a"); + const b = generateChallenge("verifier-b"); + assert.notEqual(a, b); + }); + + it("is deterministic for the same verifier", () => { + const v = generateVerifier(); + assert.equal(generateChallenge(v), generateChallenge(v)); + }); +});