hostinger-api-mcp 0.1.42 → 0.2.1

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.
package/.env.example CHANGED
@@ -4,6 +4,10 @@ TRANSPORT=stdio # Fixed to stdio
4
4
  # Debug
5
5
  DEBUG=false
6
6
 
7
+ # OAuth Configuration (used when API_TOKEN is not set — stdio mode only)
8
+ # Uncomment to override the default OAuth issuer:
9
+ # OAUTH_ISSUER=https://auth.hostinger.com
10
+
7
11
  # --- Authorization Configuration ---
8
12
  # Example for HTTP Bearer Token "apiToken"
9
13
  APITOKEN=YOUR_TOKEN_VALUE
package/README.md CHANGED
@@ -63,7 +63,43 @@ Pick the binary that matches your agent's scope. `hostinger-api-mcp` remains the
63
63
 
64
64
  The following environment variables can be configured when running the server:
65
65
  - `DEBUG`: Enable debug logging (true/false) (default: false)
66
- - `API_TOKEN`: Your API token, which will be sent in the `Authorization` header.
66
+ - `API_TOKEN`: Your API token, which will be sent in the `Authorization` header. When set, OAuth is bypassed entirely.
67
+ - `OAUTH_ISSUER`: OAuth server base URL (default: `https://auth.hostinger.com`). Only used when `API_TOKEN` is not set.
68
+
69
+ ## Authentication
70
+
71
+ The server supports two authentication methods:
72
+
73
+ ### API Token (recommended for CI/scripts)
74
+
75
+ Set `API_TOKEN` in the environment or `.env` file. When present it always takes precedence — no OAuth code runs.
76
+
77
+ ### OAuth 2.0 with PKCE (interactive sign-in)
78
+
79
+ When `API_TOKEN` is not set and the server runs in stdio mode, OAuth 2.0 with PKCE is used automatically on the first authenticated tool call:
80
+
81
+ 1. A dynamic OAuth client is registered with the issuer (RFC 7591) — once per machine.
82
+ 2. A browser window opens to the authorization page.
83
+ 3. After sign-in, the server captures the redirect on a local ephemeral port, exchanges the code for tokens, and stores them.
84
+ 4. Subsequent calls reuse the stored access token; expired tokens are refreshed automatically. If a refresh token is revoked, the browser flow is re-launched.
85
+
86
+ Credentials are stored at:
87
+ - macOS / Linux: `~/.config/hostinger-mcp/credentials.json` (mode 0600)
88
+ - Windows: `%APPDATA%\hostinger-mcp\credentials.json`
89
+
90
+ Credentials are shared across all Hostinger MCP binaries (`hostinger-api-mcp`, `hostinger-vps-mcp`, etc.).
91
+
92
+ **Manual commands:**
93
+
94
+ ```bash
95
+ # Run the OAuth sign-in flow immediately (don't wait for the first tool call)
96
+ hostinger-api-mcp --login
97
+
98
+ # Revoke stored credentials
99
+ hostinger-api-mcp --logout
100
+ ```
101
+
102
+ **HTTP transport note:** OAuth sign-in is not supported in `--http` mode. Set `API_TOKEN` before using `--http`.
67
103
 
68
104
  ## Usage
69
105
 
@@ -107,10 +143,12 @@ hostinger-api-mcp --http --host 0.0.0.0 --port 8150
107
143
 
108
144
  ```
109
145
  Options:
110
- --http Use HTTP streaming transport
146
+ --http Use HTTP streaming transport (requires API_TOKEN env var)
111
147
  --stdio Use Server-Sent Events transport (default)
112
148
  --host {host} Hostname or IP address to listen on (default: 127.0.0.1)
113
149
  --port {port} Port to bind to (default: 8100)
150
+ --login Run OAuth sign-in flow and exit
151
+ --logout Revoke stored OAuth credentials and exit
114
152
  --help Show help message
115
153
  ```
116
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hostinger-api-mcp",
3
- "version": "0.1.42",
3
+ "version": "0.2.1",
4
4
  "description": "MCP server for Hostinger API",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,90 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Authentication failed</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --primary: #673DE6;
13
+ --primary-hover: #5025D1;
14
+ --deep: #2F1C6A;
15
+ --bg: #F4F5FF;
16
+ --lavender: #b6b2ff;
17
+ }
18
+ *, *::before, *::after { box-sizing: border-box; }
19
+ html, body { height: 100%; margin: 0; }
20
+ body {
21
+ font-family: "DM Sans", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
22
+ background: var(--bg);
23
+ color: var(--deep);
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: center;
27
+ padding: 24px;
28
+ }
29
+ .card {
30
+ background: #ffffff;
31
+ border-radius: 16px;
32
+ box-shadow: 0 10px 40px rgba(31, 19, 70, 0.08);
33
+ padding: 48px 56px;
34
+ max-width: 480px;
35
+ width: 100%;
36
+ text-align: center;
37
+ }
38
+ .icon {
39
+ width: 72px;
40
+ height: 72px;
41
+ border-radius: 50%;
42
+ background: #ffffff;
43
+ color: var(--primary);
44
+ display: inline-flex;
45
+ align-items: center;
46
+ justify-content: center;
47
+ margin-bottom: 24px;
48
+ border: 2px solid var(--primary);
49
+ }
50
+ .icon svg { width: 36px; height: 36px; }
51
+ h1 {
52
+ font-size: 28px;
53
+ font-weight: 700;
54
+ letter-spacing: -0.01em;
55
+ margin: 0 0 12px;
56
+ color: var(--deep);
57
+ }
58
+ p {
59
+ font-size: 16px;
60
+ line-height: 1.5;
61
+ margin: 0 0 16px;
62
+ color: var(--deep);
63
+ opacity: 0.72;
64
+ }
65
+ .error-detail {
66
+ display: inline-block;
67
+ margin-top: 8px;
68
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Cascadia Code", monospace;
69
+ font-size: 14px;
70
+ color: var(--deep);
71
+ background: var(--bg);
72
+ padding: 8px 14px;
73
+ border-radius: 8px;
74
+ border: 1px solid var(--lavender);
75
+ }
76
+ </style>
77
+ </head>
78
+ <body>
79
+ <main class="card" role="alert" aria-live="assertive">
80
+ <div class="icon" aria-hidden="true">
81
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
82
+ <path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
83
+ </svg>
84
+ </div>
85
+ <h1>Authentication failed</h1>
86
+ <p>You can close this tab.</p>
87
+ <code class="error-detail">{{error}}</code>
88
+ </main>
89
+ </body>
90
+ </html>
@@ -0,0 +1,77 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Authentication successful</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --primary: #673DE6;
13
+ --primary-hover: #5025D1;
14
+ --deep: #2F1C6A;
15
+ --bg: #F4F5FF;
16
+ --lavender: #b6b2ff;
17
+ }
18
+ *, *::before, *::after { box-sizing: border-box; }
19
+ html, body { height: 100%; margin: 0; }
20
+ body {
21
+ font-family: "DM Sans", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
22
+ background: var(--bg);
23
+ color: var(--deep);
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: center;
27
+ padding: 24px;
28
+ }
29
+ .card {
30
+ background: #ffffff;
31
+ border-radius: 16px;
32
+ box-shadow: 0 10px 40px rgba(31, 19, 70, 0.08);
33
+ padding: 48px 56px;
34
+ max-width: 480px;
35
+ width: 100%;
36
+ text-align: center;
37
+ }
38
+ .icon {
39
+ width: 72px;
40
+ height: 72px;
41
+ border-radius: 50%;
42
+ background: var(--primary);
43
+ color: #ffffff;
44
+ display: inline-flex;
45
+ align-items: center;
46
+ justify-content: center;
47
+ margin-bottom: 24px;
48
+ }
49
+ .icon svg { width: 36px; height: 36px; }
50
+ h1 {
51
+ font-size: 28px;
52
+ font-weight: 700;
53
+ letter-spacing: -0.01em;
54
+ margin: 0 0 12px;
55
+ color: var(--deep);
56
+ }
57
+ p {
58
+ font-size: 16px;
59
+ line-height: 1.5;
60
+ margin: 0;
61
+ color: var(--deep);
62
+ opacity: 0.72;
63
+ }
64
+ </style>
65
+ </head>
66
+ <body>
67
+ <main class="card" role="status" aria-live="polite">
68
+ <div class="icon" aria-hidden="true">
69
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
70
+ <path d="M20 6L9 17l-5-5" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
71
+ </svg>
72
+ </div>
73
+ <h1>Authentication successful</h1>
74
+ <p>You can close this tab and return to your application.</p>
75
+ </main>
76
+ </body>
77
+ </html>
@@ -0,0 +1,392 @@
1
+ import { createHash, randomBytes } from "crypto";
2
+ import { createServer } from "http";
3
+ import { readFile, writeFile, mkdir } from "fs/promises";
4
+ import { existsSync, readFileSync } from "fs";
5
+ import { exec } from "child_process";
6
+ import { fileURLToPath } from "url";
7
+ import path from "path";
8
+ import os from "os";
9
+ import axios from "axios";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const SUCCESS_HTML = readFileSync(path.join(__dirname, "oauth-success.html"), "utf8");
13
+ const ERROR_HTML_TEMPLATE = readFileSync(path.join(__dirname, "oauth-error.html"), "utf8");
14
+
15
+ function escapeHtml(s) {
16
+ return String(s).replace(/[&<>"']/g, (ch) => ({
17
+ "&": "&amp;",
18
+ "<": "&lt;",
19
+ ">": "&gt;",
20
+ '"': "&quot;",
21
+ "'": "&#39;",
22
+ })[ch]);
23
+ }
24
+
25
+ const DEFAULT_ISSUER = "https://auth.hostinger.com";
26
+ const REGISTER_PATH = "/api/external/v1/oauth-server/register";
27
+ const AUTHORIZE_PATH = "/api/external/v1/oauth-server/authorize";
28
+ const TOKEN_PATH = "/api/external/v1/oauth-server/token";
29
+ const REVOKE_PATH = "/api/external/v1/oauth-server/token/revoke";
30
+ const CLIENT_NAME = "hostinger-mcp";
31
+ const CALLBACK_PATH = "/oauth/callback";
32
+ const CREDENTIALS_DIR_NAME = "hostinger-mcp";
33
+ const CREDENTIALS_FILE_NAME = "credentials.json";
34
+ const EXPIRY_BUFFER_SECONDS = 60;
35
+
36
+ export class OAuthRefreshError extends Error {
37
+ constructor(message) {
38
+ super(message);
39
+ this.name = "OAuthRefreshError";
40
+ this.code = "OAUTH_INVALID_GRANT";
41
+ }
42
+ }
43
+
44
+ export class OAuthProvider {
45
+ constructor(issuerBaseUrl) {
46
+ this.issuer = (
47
+ issuerBaseUrl ||
48
+ process.env["OAUTH_ISSUER"] ||
49
+ DEFAULT_ISSUER
50
+ ).replace(/\/$/, "");
51
+ this._loginInProgress = null;
52
+ }
53
+
54
+ async getAccessToken() {
55
+ const envToken = process.env["API_TOKEN"] || process.env["APITOKEN"];
56
+ if (envToken) {
57
+ return envToken;
58
+ }
59
+
60
+ if (!this._loginInProgress) {
61
+ this._loginInProgress = this._resolveToken().finally(() => {
62
+ this._loginInProgress = null;
63
+ });
64
+ }
65
+ return await this._loginInProgress;
66
+ }
67
+
68
+ async _resolveToken() {
69
+ const creds = await this._load();
70
+
71
+ if (
72
+ creds &&
73
+ creds.access_token &&
74
+ creds.expires_at &&
75
+ Date.now() < creds.expires_at
76
+ ) {
77
+ return creds.access_token;
78
+ }
79
+
80
+ if (creds && creds.refresh_token && creds.client_id) {
81
+ try {
82
+ return await this._refresh(creds);
83
+ } catch (err) {
84
+ if (err.code !== "OAUTH_INVALID_GRANT") {
85
+ throw err;
86
+ }
87
+ }
88
+ }
89
+
90
+ return await this._login();
91
+ }
92
+
93
+ async login() {
94
+ if (this._loginInProgress) {
95
+ return await this._loginInProgress;
96
+ }
97
+ this._loginInProgress = this._login().finally(() => {
98
+ this._loginInProgress = null;
99
+ });
100
+ return await this._loginInProgress;
101
+ }
102
+
103
+ /**
104
+ * Force a fresh token, bypassing the cached-token fast path. Called by the
105
+ * runtime when the API rejects an apparently-valid token with 401. Tries the
106
+ * refresh grant first; if the refresh token is also dead (4xx), falls through
107
+ * to the full browser login flow.
108
+ */
109
+ async reauthenticate() {
110
+ if (!this._loginInProgress) {
111
+ this._loginInProgress = this._reauthenticate().finally(() => {
112
+ this._loginInProgress = null;
113
+ });
114
+ }
115
+ return await this._loginInProgress;
116
+ }
117
+
118
+ async _reauthenticate() {
119
+ const creds = await this._load();
120
+ if (creds && creds.refresh_token && creds.client_id) {
121
+ try {
122
+ return await this._refresh(creds);
123
+ } catch (err) {
124
+ if (err.code !== "OAUTH_INVALID_GRANT") {
125
+ throw err;
126
+ }
127
+ }
128
+ }
129
+ return await this._login();
130
+ }
131
+
132
+ async logout() {
133
+ const creds = await this._load();
134
+ if (!creds) {
135
+ return;
136
+ }
137
+ if (creds.access_token && creds.client_id) {
138
+ try {
139
+ const params = new URLSearchParams();
140
+ params.set("token", creds.access_token);
141
+ params.set("client_id", creds.client_id);
142
+ await axios.post(`${this.issuer}${REVOKE_PATH}`, params.toString(), {
143
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
144
+ validateStatus: () => true,
145
+ });
146
+ } catch (_) {}
147
+ }
148
+ await this._save({ client_id: creds.client_id });
149
+ }
150
+
151
+ async _login() {
152
+ let creds = (await this._load()) || {};
153
+
154
+ const port = await this._getFreePort();
155
+ const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`;
156
+
157
+ if (!creds.client_id) {
158
+ creds.client_id = await this._register(redirectUri);
159
+ }
160
+
161
+ const { verifier, challenge } = this._generatePKCE();
162
+ const state = this._generateState();
163
+
164
+ const callbackPromise = this._listenForCallback(state, port);
165
+
166
+ const authorizeUrl = new URL(`${this.issuer}${AUTHORIZE_PATH}`);
167
+ authorizeUrl.searchParams.set("client_id", creds.client_id);
168
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
169
+ authorizeUrl.searchParams.set("state", state);
170
+ authorizeUrl.searchParams.set("code_challenge", challenge);
171
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
172
+ authorizeUrl.searchParams.set("response_type", "code");
173
+
174
+ this._openBrowser(authorizeUrl.toString());
175
+
176
+ const { code } = await callbackPromise;
177
+
178
+ const params = new URLSearchParams();
179
+ params.set("grant_type", "authorization_code");
180
+ params.set("code", code);
181
+ params.set("code_verifier", verifier);
182
+ params.set("redirect_uri", redirectUri);
183
+ params.set("client_id", creds.client_id);
184
+
185
+ const resp = await axios.post(
186
+ `${this.issuer}${TOKEN_PATH}`,
187
+ params.toString(),
188
+ {
189
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
190
+ validateStatus: () => true,
191
+ },
192
+ );
193
+
194
+ if (resp.status >= 400) {
195
+ throw new Error(
196
+ `Token exchange failed (${resp.status}): ${JSON.stringify(resp.data)}`,
197
+ );
198
+ }
199
+
200
+ const tokens = resp.data;
201
+ const newCreds = {
202
+ client_id: creds.client_id,
203
+ access_token: tokens.access_token,
204
+ refresh_token: tokens.refresh_token,
205
+ expires_at:
206
+ Date.now() + (tokens.expires_in - EXPIRY_BUFFER_SECONDS) * 1000,
207
+ };
208
+ await this._save(newCreds);
209
+ return tokens.access_token;
210
+ }
211
+
212
+ async _refresh(creds) {
213
+ const params = new URLSearchParams();
214
+ params.set("grant_type", "refresh_token");
215
+ params.set("refresh_token", creds.refresh_token);
216
+ params.set("client_id", creds.client_id);
217
+
218
+ const resp = await axios.post(
219
+ `${this.issuer}${TOKEN_PATH}`,
220
+ params.toString(),
221
+ {
222
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
223
+ validateStatus: () => true,
224
+ },
225
+ );
226
+
227
+ if (resp.status >= 400 && resp.status < 500) {
228
+ throw new OAuthRefreshError(
229
+ `Refresh token rejected (${resp.status}): ${JSON.stringify(resp.data)}`,
230
+ );
231
+ }
232
+
233
+ if (resp.status >= 500) {
234
+ throw new Error(
235
+ `Token refresh failed (${resp.status}): ${JSON.stringify(resp.data)}`,
236
+ );
237
+ }
238
+
239
+ const tokens = resp.data;
240
+ const newCreds = {
241
+ client_id: creds.client_id,
242
+ access_token: tokens.access_token,
243
+ refresh_token: tokens.refresh_token || creds.refresh_token,
244
+ expires_at:
245
+ Date.now() + (tokens.expires_in - EXPIRY_BUFFER_SECONDS) * 1000,
246
+ };
247
+ await this._save(newCreds);
248
+ return tokens.access_token;
249
+ }
250
+
251
+ async _register(redirectUri) {
252
+ const resp = await axios.post(
253
+ `${this.issuer}${REGISTER_PATH}`,
254
+ {
255
+ client_name: CLIENT_NAME,
256
+ redirect_uris: [redirectUri],
257
+ },
258
+ {
259
+ headers: { "Content-Type": "application/json" },
260
+ validateStatus: () => true,
261
+ },
262
+ );
263
+
264
+ if (resp.status >= 400 || !resp.data || !resp.data.client_id) {
265
+ throw new Error(
266
+ `Client registration failed (${resp.status}): ${JSON.stringify(resp.data)}`,
267
+ );
268
+ }
269
+ return resp.data.client_id;
270
+ }
271
+
272
+ _generatePKCE() {
273
+ const verifier = randomBytes(32).toString("base64url");
274
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
275
+ return { verifier, challenge };
276
+ }
277
+
278
+ _generateState() {
279
+ return randomBytes(16).toString("hex");
280
+ }
281
+
282
+ _listenForCallback(expectedState, port) {
283
+ return new Promise((resolve, reject) => {
284
+ const server = createServer((req, res) => {
285
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
286
+ if (url.pathname !== CALLBACK_PATH) {
287
+ res.writeHead(404);
288
+ res.end();
289
+ return;
290
+ }
291
+
292
+ const code = url.searchParams.get("code");
293
+ const state = url.searchParams.get("state");
294
+ const error = url.searchParams.get("error");
295
+
296
+ const body = error
297
+ ? ERROR_HTML_TEMPLATE.replace("{{error}}", escapeHtml(error))
298
+ : SUCCESS_HTML;
299
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
300
+ res.end(body);
301
+
302
+ setImmediate(() => server.close());
303
+
304
+ if (error) {
305
+ return reject(
306
+ new Error(`OAuth error from authorization server: ${error}`),
307
+ );
308
+ }
309
+ if (!state || state !== expectedState) {
310
+ return reject(new Error("OAuth state mismatch"));
311
+ }
312
+ if (!code) {
313
+ return reject(new Error("No authorization code received"));
314
+ }
315
+ resolve({ code, port });
316
+ });
317
+ server.on("error", reject);
318
+ server.listen(port, "127.0.0.1");
319
+ });
320
+ }
321
+
322
+ _getFreePort() {
323
+ return new Promise((resolve, reject) => {
324
+ const srv = createServer();
325
+ srv.unref();
326
+ srv.on("error", reject);
327
+ srv.listen(0, "127.0.0.1", () => {
328
+ const addr = srv.address();
329
+ const port = typeof addr === "object" && addr ? addr.port : 0;
330
+ srv.close(() => resolve(port));
331
+ });
332
+ });
333
+ }
334
+
335
+ _credentialsPath() {
336
+ if (process.platform === "win32") {
337
+ const base = process.env["APPDATA"] || os.homedir();
338
+ return path.join(base, CREDENTIALS_DIR_NAME, CREDENTIALS_FILE_NAME);
339
+ }
340
+ return path.join(
341
+ os.homedir(),
342
+ ".config",
343
+ CREDENTIALS_DIR_NAME,
344
+ CREDENTIALS_FILE_NAME,
345
+ );
346
+ }
347
+
348
+ async _load() {
349
+ const p = this._credentialsPath();
350
+ if (!existsSync(p)) {
351
+ return null;
352
+ }
353
+ try {
354
+ const raw = await readFile(p, "utf8");
355
+ return JSON.parse(raw);
356
+ } catch (_) {
357
+ return null;
358
+ }
359
+ }
360
+
361
+ async _save(creds) {
362
+ const p = this._credentialsPath();
363
+ await mkdir(path.dirname(p), { recursive: true });
364
+ const writeOpts =
365
+ process.platform === "win32"
366
+ ? { encoding: "utf8" }
367
+ : { encoding: "utf8", mode: 0o600 };
368
+ await writeFile(p, JSON.stringify(creds, null, 2), writeOpts);
369
+ }
370
+
371
+ _openBrowser(url) {
372
+ process.stderr.write(`\n[OAuth] Opening browser for sign-in:\n ${url}\n`);
373
+ process.stderr.write(
374
+ "[OAuth] If the browser does not open, copy the URL above into one manually.\n\n",
375
+ );
376
+ let cmd;
377
+ if (process.platform === "darwin") {
378
+ cmd = `open "${url}"`;
379
+ } else if (process.platform === "win32") {
380
+ cmd = `start "" "${url}"`;
381
+ } else {
382
+ cmd = `xdg-open "${url}"`;
383
+ }
384
+ exec(cmd, (err) => {
385
+ if (err) {
386
+ process.stderr.write(
387
+ `[OAuth] Could not auto-launch browser: ${err.message}\n`,
388
+ );
389
+ }
390
+ });
391
+ }
392
+ }