@alchemy/cli 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ if(process.argv.includes("--no-color"))process.env.NO_COLOR="1";
3
+ import {
4
+ isRevealMode
5
+ } from "./chunk-56ZVYB4G.js";
6
+
7
+ // src/lib/secrets.ts
8
+ function maskSecret(value) {
9
+ if (value.length <= 8) return "\u2022".repeat(value.length);
10
+ return value.slice(0, 4) + "\u2022".repeat(value.length - 8) + value.slice(-4);
11
+ }
12
+ function maskIf(value) {
13
+ return isRevealMode() ? value : maskSecret(value);
14
+ }
15
+
16
+ // src/lib/config.ts
17
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
18
+ import { homedir } from "os";
19
+ import { join, dirname } from "path";
20
+ import { z } from "zod";
21
+ var KEY_MAP = {
22
+ "api-key": "api_key",
23
+ api_key: "api_key",
24
+ "access-key": "access_key",
25
+ access_key: "access_key",
26
+ "webhook-api-key": "webhook_api_key",
27
+ webhook_api_key: "webhook_api_key",
28
+ network: "network",
29
+ verbose: "verbose",
30
+ "wallet-key-file": "wallet_key_file",
31
+ wallet_key_file: "wallet_key_file",
32
+ "wallet-address": "wallet_address",
33
+ wallet_address: "wallet_address",
34
+ x402: "x402",
35
+ "auth-token": "auth_token",
36
+ auth_token: "auth_token",
37
+ "auth-token-expires-at": "auth_token_expires_at",
38
+ auth_token_expires_at: "auth_token_expires_at"
39
+ };
40
+ var SAFE_ID_RE = /^[A-Za-z0-9:_-]{1,128}$/;
41
+ var SAFE_NETWORK_RE = /^[A-Za-z0-9:_-]{1,128}$/;
42
+ var MAX_SECRET_LEN = 512;
43
+ var MAX_APP_NAME_LEN = 128;
44
+ var CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/;
45
+ var safeTextSchema = (maxLen) => z.string().min(1).max(maxLen).refine((value) => !CONTROL_CHAR_RE.test(value));
46
+ var appConfigSchema = z.object({
47
+ id: z.string().regex(SAFE_ID_RE),
48
+ name: safeTextSchema(MAX_APP_NAME_LEN),
49
+ apiKey: safeTextSchema(MAX_SECRET_LEN),
50
+ webhookApiKey: safeTextSchema(MAX_SECRET_LEN).optional().catch(void 0)
51
+ }).strip();
52
+ var MAX_PATH_LEN = 4096;
53
+ var configSchema = z.object({
54
+ api_key: safeTextSchema(MAX_SECRET_LEN).optional().catch(void 0),
55
+ access_key: safeTextSchema(MAX_SECRET_LEN).optional().catch(void 0),
56
+ webhook_api_key: safeTextSchema(MAX_SECRET_LEN).optional().catch(void 0),
57
+ app: appConfigSchema.optional().catch(void 0),
58
+ network: z.string().regex(SAFE_NETWORK_RE).optional().catch(void 0),
59
+ verbose: z.boolean().optional().catch(void 0),
60
+ wallet_key_file: safeTextSchema(MAX_PATH_LEN).optional().catch(void 0),
61
+ wallet_address: safeTextSchema(MAX_SECRET_LEN).optional().catch(void 0),
62
+ x402: z.boolean().optional().catch(void 0),
63
+ auth_token: safeTextSchema(MAX_SECRET_LEN).optional().catch(void 0),
64
+ auth_token_expires_at: safeTextSchema(MAX_SECRET_LEN).optional().catch(void 0),
65
+ siwe_token: safeTextSchema(MAX_PATH_LEN).optional().catch(void 0),
66
+ siwe_token_expires_at: safeTextSchema(MAX_SECRET_LEN).optional().catch(void 0)
67
+ }).strip();
68
+ function sanitizeConfig(input) {
69
+ const parsed = configSchema.safeParse(input);
70
+ if (!parsed.success) {
71
+ return {};
72
+ }
73
+ return parsed.data;
74
+ }
75
+ function getHome() {
76
+ return process.env.HOME || homedir();
77
+ }
78
+ function configPath() {
79
+ if (process.env.ALCHEMY_CONFIG) return process.env.ALCHEMY_CONFIG;
80
+ const configHome = process.env.XDG_CONFIG_HOME || join(getHome(), ".config");
81
+ return join(configHome, "alchemy", "config.json");
82
+ }
83
+ function configDir() {
84
+ return dirname(configPath());
85
+ }
86
+ function load() {
87
+ const p = configPath();
88
+ if (!existsSync(p)) return {};
89
+ try {
90
+ const data = readFileSync(p, "utf-8");
91
+ return sanitizeConfig(JSON.parse(data));
92
+ } catch {
93
+ console.error(`warning: could not parse config file at ${p} \u2014 using defaults`);
94
+ return {};
95
+ }
96
+ }
97
+ function save(cfg) {
98
+ const p = configPath();
99
+ const sanitized = sanitizeConfig(cfg);
100
+ mkdirSync(dirname(p), { recursive: true, mode: 493 });
101
+ writeFileSync(p, JSON.stringify(sanitized, null, 2) + "\n", {
102
+ mode: 384
103
+ });
104
+ }
105
+ function get(cfg, key) {
106
+ if (key === "app") {
107
+ if (!cfg.app) return void 0;
108
+ return `${cfg.app.name} (${cfg.app.id})`;
109
+ }
110
+ const mapped = KEY_MAP[key];
111
+ if (!mapped) return void 0;
112
+ const value = cfg[mapped];
113
+ if (value === void 0) return void 0;
114
+ if (typeof value === "boolean") return String(value);
115
+ if (typeof value === "string") return value;
116
+ return void 0;
117
+ }
118
+ function toMap(cfg) {
119
+ const m = {};
120
+ if (cfg.api_key) m["api-key"] = maskIf(cfg.api_key);
121
+ if (cfg.access_key) m["access-key"] = maskIf(cfg.access_key);
122
+ if (cfg.webhook_api_key) m["webhook-api-key"] = maskIf(cfg.webhook_api_key);
123
+ if (cfg.app) m["app"] = `${cfg.app.name} (${cfg.app.id})`;
124
+ if (cfg.network) m["network"] = cfg.network;
125
+ if (cfg.verbose !== void 0) m["verbose"] = String(cfg.verbose);
126
+ if (cfg.wallet_key_file) m["wallet-key-file"] = cfg.wallet_key_file;
127
+ if (cfg.wallet_address) m["wallet-address"] = cfg.wallet_address;
128
+ if (cfg.x402 !== void 0) m["x402"] = String(cfg.x402);
129
+ if (cfg.auth_token) m["auth-token"] = maskIf(cfg.auth_token);
130
+ if (cfg.auth_token_expires_at) m["auth-token-expires-at"] = cfg.auth_token_expires_at;
131
+ return m;
132
+ }
133
+
134
+ export {
135
+ maskIf,
136
+ KEY_MAP,
137
+ configPath,
138
+ configDir,
139
+ load,
140
+ save,
141
+ get,
142
+ toMap
143
+ };
@@ -18,7 +18,7 @@ var SHARED_STYLE = `
18
18
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
19
19
  display: flex; justify-content: center; align-items: center;
20
20
  min-height: 100vh;
21
- background: #000;
21
+ background: linear-gradient(180deg, #4F46E5 0%, #06B6D4 100%);
22
22
  color: #fff;
23
23
  overflow: hidden;
24
24
  }
@@ -35,7 +35,7 @@ var SHARED_STYLE = `
35
35
  letter-spacing: -0.01em;
36
36
  }
37
37
  p {
38
- color: #6b6b6b;
38
+ color: #fff;
39
39
  font-size: 0.875rem;
40
40
  }
41
41
  `;
@@ -130,7 +130,7 @@ ${SHARED_STYLE}
130
130
  // src/lib/auth.ts
131
131
  var AUTH_PORT = 16424;
132
132
  var AUTH_CALLBACK_PATH = "/callback";
133
- var DEFAULT_EXPIRES_IN_SECONDS = 90 * 24 * 60 * 60;
133
+ var OAUTH_CLIENT_ID = "alchemy-cli";
134
134
  function getAuthBaseUrl() {
135
135
  return process.env.ALCHEMY_AUTH_URL || `https://auth.${getBaseDomain()}`;
136
136
  }
@@ -140,14 +140,29 @@ function generateCodeVerifier() {
140
140
  function deriveCodeChallenge(verifier) {
141
141
  return createHash("sha256").update(verifier).digest("base64url");
142
142
  }
143
- function getLoginUrl(port, codeChallenge) {
143
+ function generateState() {
144
+ return randomBytes(32).toString("base64url");
145
+ }
146
+ function getAuthorizeUrl(port, codeChallenge, state) {
144
147
  const base = getAuthBaseUrl();
145
- const redirect = encodeURIComponent(`http://localhost:${port}${AUTH_CALLBACK_PATH}`);
146
- let url = `${base}/login?redirectUrl=${redirect}&_t=${Date.now()}`;
147
- if (codeChallenge) {
148
- url += `&code_challenge=${encodeURIComponent(codeChallenge)}`;
149
- }
150
- return url;
148
+ const url = new URL(`${base}/oauth/authorize`);
149
+ url.searchParams.set("response_type", "code");
150
+ url.searchParams.set("client_id", OAUTH_CLIENT_ID);
151
+ url.searchParams.set("redirect_uri", `http://localhost:${port}${AUTH_CALLBACK_PATH}`);
152
+ url.searchParams.set("code_challenge", codeChallenge);
153
+ url.searchParams.set("code_challenge_method", "S256");
154
+ url.searchParams.set("state", state);
155
+ return url.toString();
156
+ }
157
+ function prepareBrowserLogin(port = AUTH_PORT) {
158
+ const codeVerifier = generateCodeVerifier();
159
+ const codeChallenge = deriveCodeChallenge(codeVerifier);
160
+ const state = generateState();
161
+ return {
162
+ authorizeUrl: getAuthorizeUrl(port, codeChallenge, state),
163
+ codeVerifier,
164
+ state
165
+ };
151
166
  }
152
167
  function openBrowser(url) {
153
168
  const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
@@ -188,6 +203,7 @@ function waitForCallback(port, timeoutMs = 12e4) {
188
203
  clearTimeout(timer);
189
204
  resolve({
190
205
  code,
206
+ state: url.searchParams.get("state"),
191
207
  sendSuccess: () => {
192
208
  res.writeHead(200, { "Content-Type": "text/html" });
193
209
  res.end(AUTH_SUCCESS_HTML);
@@ -220,43 +236,44 @@ function waitForCallback(port, timeoutMs = 12e4) {
220
236
  async function exchangeCodeForToken(code, port, options) {
221
237
  const baseUrl = getAuthBaseUrl();
222
238
  const redirectUri = `http://localhost:${port}${AUTH_CALLBACK_PATH}`;
223
- const body = {
239
+ const body = new URLSearchParams({
240
+ grant_type: "authorization_code",
224
241
  code,
225
- redirect_uri: redirectUri
226
- };
227
- if (options?.expiresInSeconds) {
228
- body.expires_in_seconds = options.expiresInSeconds;
229
- }
230
- if (options?.codeVerifier) {
231
- body.code_verifier = options.codeVerifier;
232
- }
233
- const response = await fetch(`${baseUrl}/api/cli/token`, {
242
+ redirect_uri: redirectUri,
243
+ client_id: OAUTH_CLIENT_ID,
244
+ code_verifier: options.codeVerifier
245
+ });
246
+ const response = await fetch(`${baseUrl}/oauth/token`, {
234
247
  method: "POST",
235
- headers: { "Content-Type": "application/json" },
236
- body: JSON.stringify(body)
248
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
249
+ body: body.toString()
237
250
  });
238
251
  if (!response.ok) {
239
252
  const errBody = await response.json().catch(() => ({}));
240
- throw new Error(
241
- errBody.error || `Token exchange failed (HTTP ${response.status})`
242
- );
253
+ const errMsg = errBody.error_description || errBody.error || `Token exchange failed (HTTP ${response.status})`;
254
+ throw new Error(errMsg);
243
255
  }
244
256
  const data = await response.json();
245
- if (!data.authToken) {
246
- throw new Error("Token exchange response missing authToken");
257
+ if (!data.access_token) {
258
+ throw new Error("Token exchange response missing access_token");
247
259
  }
248
- return { token: data.authToken, expiresAt: data.expiresAt };
260
+ const expiresAt = new Date(Date.now() + data.expires_in * 1e3).toISOString();
261
+ return { token: data.access_token, expiresAt };
249
262
  }
250
- async function performBrowserLogin(port = AUTH_PORT, options) {
251
- const codeVerifier = generateCodeVerifier();
252
- const codeChallenge = deriveCodeChallenge(codeVerifier);
253
- const loginUrl = getLoginUrl(port, codeChallenge);
263
+ async function performBrowserLogin(prepared, options) {
264
+ const port = options?.port ?? AUTH_PORT;
265
+ const { authorizeUrl, codeVerifier, state } = prepared ?? prepareBrowserLogin(port);
254
266
  const callbackPromise = waitForCallback(port);
255
- openBrowser(loginUrl);
267
+ if (!options?.skipBrowserOpen) {
268
+ openBrowser(authorizeUrl);
269
+ }
256
270
  const callback = await callbackPromise;
271
+ if (callback.state !== state) {
272
+ callback.sendError("State mismatch \u2014 possible CSRF attack.");
273
+ throw new Error("OAuth state mismatch. Authentication aborted.");
274
+ }
257
275
  try {
258
276
  const result = await exchangeCodeForToken(callback.code, port, {
259
- expiresInSeconds: options?.expiresInSeconds ?? DEFAULT_EXPIRES_IN_SECONDS,
260
277
  codeVerifier
261
278
  });
262
279
  callback.sendSuccess();
@@ -290,8 +307,9 @@ async function revokeToken(token) {
290
307
 
291
308
  export {
292
309
  AUTH_PORT,
293
- DEFAULT_EXPIRES_IN_SECONDS,
294
- getLoginUrl,
310
+ OAUTH_CLIENT_ID,
311
+ getAuthorizeUrl,
312
+ prepareBrowserLogin,
295
313
  openBrowser,
296
314
  waitForCallback,
297
315
  exchangeCodeForToken,
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ if(process.argv.includes("--no-color"))process.env.NO_COLOR="1";
3
+
4
+ // src/lib/credential-storage.ts
5
+ import {
6
+ getPassword,
7
+ setPassword,
8
+ deletePassword,
9
+ getKeyring,
10
+ PasswordDeleteError
11
+ } from "cross-keychain";
12
+ var SERVICE = "alchemy-cli";
13
+ var ACCOUNT = "oauth-credentials";
14
+ async function getCredentials() {
15
+ try {
16
+ const raw = await getPassword(SERVICE, ACCOUNT);
17
+ if (!raw) return null;
18
+ const parsed = JSON.parse(raw);
19
+ if (parsed && typeof parsed.auth_token === "string" && typeof parsed.auth_token_expires_at === "string") {
20
+ return parsed;
21
+ }
22
+ return null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+ async function saveCredentials(creds) {
28
+ await setPassword(SERVICE, ACCOUNT, JSON.stringify(creds));
29
+ }
30
+ async function deleteCredentials() {
31
+ try {
32
+ await deletePassword(SERVICE, ACCOUNT);
33
+ } catch (err) {
34
+ if (!(err instanceof PasswordDeleteError)) {
35
+ throw err;
36
+ }
37
+ }
38
+ }
39
+ async function getStorageBackend() {
40
+ try {
41
+ const keyring = await getKeyring();
42
+ return keyring.name;
43
+ } catch {
44
+ return "unknown";
45
+ }
46
+ }
47
+
48
+ export {
49
+ getCredentials,
50
+ saveCredentials,
51
+ deleteCredentials,
52
+ getStorageBackend
53
+ };
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ if(process.argv.includes("--no-color"))process.env.NO_COLOR="1";
3
+ import {
4
+ isJSONMode
5
+ } from "./chunk-56ZVYB4G.js";
6
+
7
+ // src/lib/interaction.ts
8
+ import { stdin, stdout } from "process";
9
+ function isTruthy(value) {
10
+ if (!value) return false;
11
+ const normalized = value.trim().toLowerCase();
12
+ return normalized === "1" || normalized === "true" || normalized === "yes";
13
+ }
14
+ function isNonInteractiveEnv() {
15
+ return isTruthy(process.env.ALCHEMY_NON_INTERACTIVE);
16
+ }
17
+ function isInteractiveAllowed(program) {
18
+ if (!stdin.isTTY || !stdout.isTTY) return false;
19
+ if (isJSONMode()) return false;
20
+ if (isNonInteractiveEnv()) return false;
21
+ if (program && program.opts().interactive === false) return false;
22
+ return true;
23
+ }
24
+
25
+ export {
26
+ isInteractiveAllowed
27
+ };