Skip to content

Commit 3cb4de9

Browse files
authored
Merge pull request #46 from alchemyplatform/chris/auth-improvements
Auth improvements
2 parents 26b0961 + cd6bb86 commit 3cb4de9

6 files changed

Lines changed: 121 additions & 326 deletions

File tree

src/commands/auth.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,24 @@ import { AdminClient } from "../lib/admin-client.js";
55
import type { App } from "../lib/admin-client.js";
66
import { CLIError, ErrorCode, exitWithError } from "../lib/errors.js";
77
import { printHuman, isJSONMode, debug } from "../lib/output.js";
8-
import { promptSelect } from "../lib/terminal-ui.js";
8+
import { promptAutocomplete, promptText } from "../lib/terminal-ui.js";
99
import { isInteractiveAllowed } from "../lib/interaction.js";
1010
import { resolveAuthToken } from "../lib/resolve.js";
1111
import { green, dim, bold, brand, maskIf, withSpinner } from "../lib/ui.js";
1212

1313
export function registerAuth(program: Command) {
1414
const cmd = program
1515
.command("auth")
16-
.description("Authenticate with your Alchemy account");
16+
.description("Authenticate with your Alchemy account")
17+
.option("-y, --yes", "Skip confirmation prompt and open browser immediately");
1718

1819
cmd
1920
.command("login", { isDefault: true })
2021
.description("Log in via browser")
2122
.option("--force", "Force re-authentication even if a valid token exists")
22-
.action(async (opts: { force?: boolean }) => {
23+
.option("-y, --yes", "Skip confirmation prompt and open browser immediately")
24+
.action(async (opts: { force?: boolean; yes?: boolean }) => {
25+
const yes = opts.yes || cmd.opts().yes;
2326
try {
2427
// Skip browser flow if we already have a valid token
2528
if (!opts.force) {
@@ -49,9 +52,20 @@ export function registerAuth(program: Command) {
4952
console.log(` ${brand("◆")} ${bold("Alchemy Authentication")}`);
5053
console.log(` ${dim("────────────────────────────────────")}`);
5154
console.log("");
52-
console.log(` Opening browser to log in...`);
5355
console.log(` ${dim(getLoginUrl(AUTH_PORT))}`);
5456
console.log("");
57+
}
58+
59+
if (!yes && !isJSONMode() && isInteractiveAllowed(program)) {
60+
const answer = await promptText({
61+
message: "Press Enter to open browser and link your Alchemy account",
62+
cancelMessage: "Login cancelled.",
63+
});
64+
if (answer === null) return;
65+
}
66+
67+
if (!isJSONMode()) {
68+
console.log(` Opening browser to log in...`);
5569
console.log(` ${dim("Waiting for authentication...")}`);
5670
}
5771

@@ -200,8 +214,9 @@ export async function selectAppAfterAuth(authToken: string): Promise<void> {
200214
console.log(` ${green("✓")} Auto-selected app: ${bold(selectedApp.name)}`);
201215
} else {
202216
console.log("");
203-
const appId = await promptSelect({
217+
const appId = await promptAutocomplete({
204218
message: "Select an app",
219+
placeholder: "Type to search by name",
205220
options: apps.map((app) => ({
206221
value: app.id,
207222
label: app.name,

src/commands/config.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ export function registerConfig(program: Command) {
392392
cmd
393393
.command("list")
394394
.description("List all config values")
395-
.action(() => {
395+
.action(async () => {
396396
const cfg = config.load();
397397
const hasApiKeyMismatch = Boolean(
398398
cfg.api_key &&
@@ -405,19 +405,29 @@ export function registerConfig(program: Command) {
405405
return;
406406
}
407407

408+
const { resolveAuthToken } = await import("../lib/resolve.js");
409+
const validToken = resolveAuthToken(cfg);
410+
const authStatus = cfg.auth_token
411+
? validToken
412+
? `${green("✓")} authenticated${cfg.auth_token_expires_at ? ` ${dim(`(expires ${cfg.auth_token_expires_at})`)}` : ""}`
413+
: `${yellow("◆")} expired${cfg.auth_token_expires_at ? ` ${dim(`(${cfg.auth_token_expires_at})`)}` : ""}`
414+
: dim("(not set) — run 'alchemy auth' to log in");
415+
408416
const pairs: Array<[string, string]> = [
417+
["auth", authStatus],
409418
[
410419
"api-key",
411420
cfg.api_key
412421
? `${hasApiKeyMismatch ? `${yellow("◆")} ` : ""}${maskIf(cfg.api_key)}`
413422
: dim("(not set)"),
414423
],
415424
["access-key", cfg.access_key ? maskIf(cfg.access_key) : dim("(not set)")],
425+
["webhook-api-key", cfg.webhook_api_key ? maskIf(cfg.webhook_api_key) : dim("(not set)")],
416426
[
417427
"app",
418428
cfg.app
419429
? `${cfg.app.name} ${dim(`(${cfg.app.id})`)}`
420-
: dim("(not set) — set automatically via 'config set access-key' or 'config set app'"),
430+
: dim("(not set) — set automatically via 'alchemy auth' or 'config set app'"),
421431
],
422432
["network", cfg.network || dim("(not set, defaults to eth-mainnet)")],
423433
[

src/commands/onboarding.ts

Lines changed: 23 additions & 202 deletions
Original file line numberDiff line numberDiff line change
@@ -1,135 +1,14 @@
11
import type { Command } from "commander";
2-
import { AdminClient } from "../lib/admin-client.js";
32
import { load as loadConfig, save as saveConfig } from "../lib/config.js";
4-
import { promptSelect, promptText } from "../lib/terminal-ui.js";
3+
import { promptText } from "../lib/terminal-ui.js";
54
import {
65
brand,
76
bold,
87
brandedHelp,
98
dim,
109
green,
11-
maskIf,
12-
printKeyValueBox,
1310
} from "../lib/ui.js";
1411
import { getUpdateNoticeLines } from "../lib/update-check.js";
15-
import { selectOrCreateApp } from "./config.js";
16-
import { generateAndPersistWallet, importAndPersistWallet } from "./wallet.js";
17-
18-
type OnboardingMethod = "browser-login" | "api-key" | "access-key" | "siwx" | "exit";
19-
20-
function printNextSteps(method: Exclude<OnboardingMethod, "exit">): void {
21-
const commandsByMethod: Record<Exclude<OnboardingMethod, "exit">, string[]> = {
22-
"browser-login": ["alchemy auth"],
23-
"api-key": ["alchemy config set api-key <key>"],
24-
"access-key": [
25-
"alchemy config set access-key <key>",
26-
"alchemy config set app <app-id>",
27-
],
28-
siwx: [
29-
"alchemy wallet generate",
30-
"alchemy config set wallet-key-file <path>",
31-
"alchemy config set x402 true",
32-
],
33-
};
34-
35-
console.log("");
36-
console.log(` ${dim("Next steps:")}`);
37-
for (const command of commandsByMethod[method]) {
38-
console.log(` ${dim(`- ${command}`)}`);
39-
}
40-
}
41-
42-
function printAPIKeyPostSetupGuidance(): void {
43-
const cfg = loadConfig() ?? {};
44-
const network = cfg.network ?? "eth-mainnet";
45-
46-
console.log("");
47-
console.log(` ${brand("◆")} ${bold("Your configuration")}`);
48-
printKeyValueBox([
49-
["api-key", cfg.api_key ? maskIf(cfg.api_key) : dim("(not set)")],
50-
["network", cfg.network ? network : `${network} ${dim("(default)")}`],
51-
]);
52-
53-
console.log("");
54-
console.log(` ${brand("◆")} ${bold("Next steps")}`);
55-
printKeyValueBox([
56-
["Verify setup", "rpc eth_chainId"],
57-
["Need a different chain?", "config set network <network>"],
58-
["List available chains", "network list"],
59-
["View set API key", "config get api-key"],
60-
["Need help?", "help"],
61-
]);
62-
}
63-
64-
async function runAPIKeyOnboarding(): Promise<void> {
65-
const key = await promptText({
66-
message: "Enter API Key",
67-
cancelMessage: "Skipped API key setup.",
68-
clearAfterSubmit: true,
69-
});
70-
if (!key || !key.trim()) return;
71-
const cfg = loadConfig();
72-
saveConfig({ ...cfg, api_key: key.trim() });
73-
console.log(` ${green("✓")} Saved API key`);
74-
}
75-
76-
async function runAccessKeyOnboarding(): Promise<void> {
77-
const key = await promptText({
78-
message: "Alchemy access key",
79-
placeholder: "Used for Admin API operations",
80-
cancelMessage: "Skipped access key setup.",
81-
});
82-
if (!key || !key.trim()) return;
83-
84-
const cfg = loadConfig();
85-
saveConfig({ ...cfg, access_key: key.trim() });
86-
console.log(` ${green("✓")} Saved access key`);
87-
await selectOrCreateApp(new AdminClient(key.trim()));
88-
}
89-
90-
async function runSiwxOnboarding(): Promise<void> {
91-
const action = await promptSelect({
92-
message: "SIWx wallet setup",
93-
options: [
94-
{ label: "Generate a new wallet", value: "generate" },
95-
{ label: "Import wallet from key file", value: "import" },
96-
],
97-
initialValue: "generate",
98-
cancelMessage: "Skipped SIWx setup.",
99-
});
100-
if (!action) return;
101-
102-
const wallet =
103-
action === "generate"
104-
? generateAndPersistWallet()
105-
: await (async () => {
106-
const path = await promptText({
107-
message: "Wallet private key file path",
108-
cancelMessage: "Skipped wallet import.",
109-
});
110-
if (!path || !path.trim()) return null;
111-
return importAndPersistWallet(path.trim());
112-
})();
113-
if (!wallet) return;
114-
115-
const cfg = loadConfig();
116-
saveConfig({ ...cfg, x402: true });
117-
console.log(` ${green("✓")} SIWx enabled with wallet ${wallet.address}`);
118-
119-
// Sign SIWE token immediately so it's cached for subsequent commands
120-
try {
121-
const { signSiwe } = await import("@alchemy/x402");
122-
const { readFileSync } = await import("node:fs");
123-
const keyPath = wallet.keyFile;
124-
const privateKey = readFileSync(keyPath, "utf-8").trim();
125-
const siweToken = await signSiwe({ privateKey, expiresAfter: "1h" });
126-
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
127-
saveConfig({ ...loadConfig(), siwe_token: siweToken, siwe_token_expires_at: expiresAt });
128-
console.log(` ${green("✓")} Signed SIWE token (cached for 1h)`);
129-
} catch {
130-
// Non-fatal — token will be signed on first API call
131-
}
132-
}
13312

13413
export async function runOnboarding(
13514
_program: Command,
@@ -140,98 +19,40 @@ export async function runOnboarding(
14019
console.log(` ${brand("◆")} ${bold("Welcome to Alchemy CLI")}`);
14120
console.log(` ${dim(" ────────────────────────────────────")}`);
14221
console.log(` ${dim(" Let's get you set up with authentication.")}`);
143-
console.log(` ${dim(" Choose one auth path to continue.")}`);
144-
console.log(` ${dim(" Tip: select 'exit' to skip setup for now.")}`);
14522
console.log("");
14623
if (latestUpdate) {
14724
for (const line of getUpdateNoticeLines(latestUpdate)) {
14825
console.log(line);
14926
}
15027
console.log("");
15128
}
152-
const method = await promptSelect<OnboardingMethod>({
153-
message: "Choose an auth setup path",
154-
options: [
155-
{
156-
label: "Browser login",
157-
hint: "Log in via browser (recommended)",
158-
value: "browser-login",
159-
},
160-
{
161-
label: "API key",
162-
hint: "Query Alchemy RPC nodes",
163-
value: "api-key",
164-
},
165-
{
166-
label: "Access Key",
167-
hint: "Admin API plus RPC nodes",
168-
value: "access-key",
169-
},
170-
{
171-
label: "SIWx",
172-
hint: "Sign-In with Ethereum/Solana wallet",
173-
value: "siwx",
174-
},
175-
{
176-
label: "exit",
177-
value: "exit",
178-
},
179-
],
180-
initialValue: "browser-login",
29+
30+
const answer = await promptText({
31+
message: "Press Enter to open browser and link your Alchemy account",
18132
cancelMessage: "Skipped onboarding.",
18233
});
183-
if (!method) return false;
184-
if (method === "exit") {
185-
console.log(` ${dim("Exited onboarding.")}`);
34+
if (answer === null) {
18635
return false;
18736
}
18837

189-
if (method === "browser-login") {
190-
const { performBrowserLogin, AUTH_PORT, getLoginUrl } = await import("../lib/auth.js");
191-
console.log(` Opening browser to log in...`);
192-
console.log(` ${dim(getLoginUrl(AUTH_PORT))}`);
193-
console.log(` ${dim("Waiting for authentication...")}`);
194-
try {
195-
const result = await performBrowserLogin();
196-
const cfg = loadConfig();
197-
saveConfig({
198-
...cfg,
199-
auth_token: result.token,
200-
auth_token_expires_at: result.expiresAt,
201-
});
202-
console.log(` ${green("✓")} Logged in successfully`);
203-
const { selectAppAfterAuth } = await import("./auth.js");
204-
await selectAppAfterAuth(result.token);
205-
return true;
206-
} catch (err) {
207-
console.log(` ${dim(`Login failed: ${err instanceof Error ? err.message : String(err)}`)}`);
208-
return false;
209-
}
210-
}
211-
if (method === "api-key") {
212-
await runAPIKeyOnboarding();
213-
const complete = Boolean(loadConfig().api_key?.trim());
214-
if (!complete) {
215-
printNextSteps("api-key");
216-
} else {
217-
printAPIKeyPostSetupGuidance();
218-
}
219-
return complete;
220-
}
221-
if (method === "access-key") {
222-
await runAccessKeyOnboarding();
38+
const { performBrowserLogin, AUTH_PORT, getLoginUrl } = await import("../lib/auth.js");
39+
console.log(` Opening browser to log in...`);
40+
console.log(` ${dim(getLoginUrl(AUTH_PORT))}`);
41+
console.log(` ${dim("Waiting for authentication...")}`);
42+
try {
43+
const result = await performBrowserLogin();
22344
const cfg = loadConfig();
224-
const complete = Boolean(cfg.access_key?.trim() && cfg.app?.id && cfg.app.apiKey);
225-
if (!complete) {
226-
printNextSteps("access-key");
227-
}
228-
return complete;
229-
}
230-
await runSiwxOnboarding();
231-
const cfg = loadConfig();
232-
const complete = cfg.x402 === true && Boolean(cfg.wallet_key_file?.trim());
233-
if (!complete) {
234-
printNextSteps("siwx");
45+
saveConfig({
46+
...cfg,
47+
auth_token: result.token,
48+
auth_token_expires_at: result.expiresAt,
49+
});
50+
console.log(` ${green("✓")} Logged in successfully`);
51+
const { selectAppAfterAuth } = await import("./auth.js");
52+
await selectAppAfterAuth(result.token);
53+
return true;
54+
} catch (err) {
55+
console.log(` ${dim(`Login failed: ${err instanceof Error ? err.message : String(err)}`)}`);
56+
return false;
23557
}
236-
return complete;
23758
}

src/index.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ program
326326
` ${hDim("Docs:")} ${hBrand("https://www.alchemy.com/docs")}`,
327327
].join("\n");
328328
})
329-
.hook("preAction", () => {
329+
.hook("preAction", async (thisCommand, actionCommand) => {
330330
const opts = program.opts();
331331
if (opts.color === false) setNoColor(true);
332332
const cfg = loadConfig();
@@ -338,6 +338,30 @@ program
338338
reveal: Boolean(opts.reveal),
339339
timeout: opts.timeout,
340340
});
341+
342+
// If we have an auth token but no API key, prompt for app selection
343+
// before running commands that need one (skip for auth/config/setup/help/etc.)
344+
const cmdName = actionCommand.name();
345+
const skipAppPrompt = [
346+
"auth", "config", "setup", "help", "version",
347+
"completions", "agent-prompt", "update-check", "wallet",
348+
];
349+
if (
350+
!skipAppPrompt.includes(cmdName) &&
351+
isInteractiveAllowed(program) &&
352+
!opts.apiKey &&
353+
!process.env.ALCHEMY_API_KEY
354+
) {
355+
const { resolveAuthToken } = await import("./lib/resolve.js");
356+
const authToken = resolveAuthToken(cfg);
357+
const hasApiKey = Boolean(cfg.api_key?.trim() || cfg.app?.apiKey);
358+
if (authToken && !hasApiKey) {
359+
const { selectAppAfterAuth } = await import("./commands/auth.js");
360+
console.log("");
361+
console.log(` No app selected. Please select an app to continue.`);
362+
await selectAppAfterAuth(authToken);
363+
}
364+
}
341365
})
342366
.hook("postAction", () => {
343367
if (!isJSONMode() && !quiet) {

0 commit comments

Comments
 (0)