Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,24 @@ 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";

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) {
Expand Down Expand Up @@ -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...")}`);
}

Expand Down Expand Up @@ -200,8 +214,9 @@ export async function selectAppAfterAuth(authToken: string): Promise<void> {
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,
Expand Down
14 changes: 12 additions & 2 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand All @@ -405,19 +405,29 @@ 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
? `${hasApiKeyMismatch ? `${yellow("◆")} ` : ""}${maskIf(cfg.api_key)}`
: 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)")],
[
Expand Down
225 changes: 23 additions & 202 deletions src/commands/onboarding.ts
Original file line number Diff line number Diff line change
@@ -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<OnboardingMethod, "exit">): void {
const commandsByMethod: Record<Exclude<OnboardingMethod, "exit">, string[]> = {
"browser-login": ["alchemy auth"],
"api-key": ["alchemy config set api-key <key>"],
"access-key": [
"alchemy config set access-key <key>",
"alchemy config set app <app-id>",
],
siwx: [
"alchemy wallet generate",
"alchemy config set wallet-key-file <path>",
"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 <network>"],
["List available chains", "network list"],
["View set API key", "config get api-key"],
["Need help?", "help"],
]);
}

async function runAPIKeyOnboarding(): Promise<void> {
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<void> {
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<void> {
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,
Expand All @@ -140,98 +19,40 @@ 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)) {
console.log(line);
}
console.log("");
}
const method = await promptSelect<OnboardingMethod>({
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;
}
26 changes: 25 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) {
Expand Down
Loading
Loading