hostinger-api-mcp 0.1.43 → 0.2.2

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 HOSTINGER_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,44 @@ 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
+ - `HOSTINGER_API_TOKEN`: Your API token, which will be sent in the `Authorization` header. When set, OAuth is bypassed entirely.
67
+ - `API_TOKEN`: Deprecated alias for `HOSTINGER_API_TOKEN`. Will be removed in a future version — prefer `HOSTINGER_API_TOKEN`.
68
+ - `OAUTH_ISSUER`: OAuth server base URL (default: `https://auth.hostinger.com`). Only used when `HOSTINGER_API_TOKEN` is not set.
69
+
70
+ ## Authentication
71
+
72
+ The server supports two authentication methods:
73
+
74
+ ### API Token (recommended for CI/scripts)
75
+
76
+ Set `HOSTINGER_API_TOKEN` in the environment or `.env` file. When present it always takes precedence — no OAuth code runs.
77
+
78
+ ### OAuth 2.0 with PKCE (interactive sign-in)
79
+
80
+ When `HOSTINGER_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:
81
+
82
+ 1. A dynamic OAuth client is registered with the issuer (RFC 7591) — once per machine.
83
+ 2. A browser window opens to the authorization page.
84
+ 3. After sign-in, the server captures the redirect on a local ephemeral port, exchanges the code for tokens, and stores them.
85
+ 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.
86
+
87
+ Credentials are stored at:
88
+ - macOS / Linux: `~/.config/hostinger-mcp/credentials.json` (mode 0600)
89
+ - Windows: `%APPDATA%\hostinger-mcp\credentials.json`
90
+
91
+ Credentials are shared across all Hostinger MCP binaries (`hostinger-api-mcp`, `hostinger-vps-mcp`, etc.).
92
+
93
+ **Manual commands:**
94
+
95
+ ```bash
96
+ # Run the OAuth sign-in flow immediately (don't wait for the first tool call)
97
+ hostinger-api-mcp --login
98
+
99
+ # Revoke stored credentials
100
+ hostinger-api-mcp --logout
101
+ ```
102
+
103
+ **HTTP transport note:** OAuth sign-in is not supported in `--http` mode. Set `HOSTINGER_API_TOKEN` before using `--http`.
67
104
 
68
105
  ## Usage
69
106
 
@@ -76,7 +113,7 @@ The following environment variables can be configured when running the server:
76
113
  "command": "hostinger-api-mcp",
77
114
  "env": {
78
115
  "DEBUG": "false",
79
- "API_TOKEN": "YOUR API TOKEN"
116
+ "HOSTINGER_API_TOKEN": "YOUR API TOKEN"
80
117
  }
81
118
  }
82
119
  }
@@ -107,10 +144,12 @@ hostinger-api-mcp --http --host 0.0.0.0 --port 8150
107
144
 
108
145
  ```
109
146
  Options:
110
- --http Use HTTP streaming transport
147
+ --http Use HTTP streaming transport (requires HOSTINGER_API_TOKEN env var)
111
148
  --stdio Use Server-Sent Events transport (default)
112
149
  --host {host} Hostname or IP address to listen on (default: 127.0.0.1)
113
150
  --port {port} Port to bind to (default: 8100)
151
+ --login Run OAuth sign-in flow and exit
152
+ --logout Revoke stored OAuth credentials and exit
114
153
  --help Show help message
115
154
  ```
116
155
 
@@ -128,7 +167,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
128
167
  const transport = new StreamableHTTPClientTransport({
129
168
  url: "http://localhost:8100/",
130
169
  headers: {
131
- "Authorization": `Bearer ${process.env.API_TOKEN}`
170
+ "Authorization": `Bearer ${process.env.HOSTINGER_API_TOKEN}`
132
171
  }
133
172
  });
134
173
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hostinger-api-mcp",
3
- "version": "0.1.43",
3
+ "version": "0.2.2",
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,406 @@
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
+ /**
45
+ * Resolve the bearer token from the environment. HOSTINGER_API_TOKEN is the
46
+ * preferred name; API_TOKEN and APITOKEN are kept as backwards-compatible
47
+ * aliases (API_TOKEN is deprecated and will be removed in a future version).
48
+ * Empty values fall through, matching the previous `||` behavior.
49
+ */
50
+ export function getEnvToken() {
51
+ return (
52
+ process.env["HOSTINGER_API_TOKEN"] ||
53
+ process.env["API_TOKEN"] ||
54
+ process.env["APITOKEN"]
55
+ );
56
+ }
57
+
58
+ export class OAuthProvider {
59
+ constructor(issuerBaseUrl) {
60
+ this.issuer = (
61
+ issuerBaseUrl ||
62
+ process.env["OAUTH_ISSUER"] ||
63
+ DEFAULT_ISSUER
64
+ ).replace(/\/$/, "");
65
+ this._loginInProgress = null;
66
+ }
67
+
68
+ async getAccessToken() {
69
+ const envToken = getEnvToken();
70
+ if (envToken) {
71
+ return envToken;
72
+ }
73
+
74
+ if (!this._loginInProgress) {
75
+ this._loginInProgress = this._resolveToken().finally(() => {
76
+ this._loginInProgress = null;
77
+ });
78
+ }
79
+ return await this._loginInProgress;
80
+ }
81
+
82
+ async _resolveToken() {
83
+ const creds = await this._load();
84
+
85
+ if (
86
+ creds &&
87
+ creds.access_token &&
88
+ creds.expires_at &&
89
+ Date.now() < creds.expires_at
90
+ ) {
91
+ return creds.access_token;
92
+ }
93
+
94
+ if (creds && creds.refresh_token && creds.client_id) {
95
+ try {
96
+ return await this._refresh(creds);
97
+ } catch (err) {
98
+ if (err.code !== "OAUTH_INVALID_GRANT") {
99
+ throw err;
100
+ }
101
+ }
102
+ }
103
+
104
+ return await this._login();
105
+ }
106
+
107
+ async login() {
108
+ if (this._loginInProgress) {
109
+ return await this._loginInProgress;
110
+ }
111
+ this._loginInProgress = this._login().finally(() => {
112
+ this._loginInProgress = null;
113
+ });
114
+ return await this._loginInProgress;
115
+ }
116
+
117
+ /**
118
+ * Force a fresh token, bypassing the cached-token fast path. Called by the
119
+ * runtime when the API rejects an apparently-valid token with 401. Tries the
120
+ * refresh grant first; if the refresh token is also dead (4xx), falls through
121
+ * to the full browser login flow.
122
+ */
123
+ async reauthenticate() {
124
+ if (!this._loginInProgress) {
125
+ this._loginInProgress = this._reauthenticate().finally(() => {
126
+ this._loginInProgress = null;
127
+ });
128
+ }
129
+ return await this._loginInProgress;
130
+ }
131
+
132
+ async _reauthenticate() {
133
+ const creds = await this._load();
134
+ if (creds && creds.refresh_token && creds.client_id) {
135
+ try {
136
+ return await this._refresh(creds);
137
+ } catch (err) {
138
+ if (err.code !== "OAUTH_INVALID_GRANT") {
139
+ throw err;
140
+ }
141
+ }
142
+ }
143
+ return await this._login();
144
+ }
145
+
146
+ async logout() {
147
+ const creds = await this._load();
148
+ if (!creds) {
149
+ return;
150
+ }
151
+ if (creds.access_token && creds.client_id) {
152
+ try {
153
+ const params = new URLSearchParams();
154
+ params.set("token", creds.access_token);
155
+ params.set("client_id", creds.client_id);
156
+ await axios.post(`${this.issuer}${REVOKE_PATH}`, params.toString(), {
157
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
158
+ validateStatus: () => true,
159
+ });
160
+ } catch (_) {}
161
+ }
162
+ await this._save({ client_id: creds.client_id });
163
+ }
164
+
165
+ async _login() {
166
+ let creds = (await this._load()) || {};
167
+
168
+ const port = await this._getFreePort();
169
+ const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`;
170
+
171
+ if (!creds.client_id) {
172
+ creds.client_id = await this._register(redirectUri);
173
+ }
174
+
175
+ const { verifier, challenge } = this._generatePKCE();
176
+ const state = this._generateState();
177
+
178
+ const callbackPromise = this._listenForCallback(state, port);
179
+
180
+ const authorizeUrl = new URL(`${this.issuer}${AUTHORIZE_PATH}`);
181
+ authorizeUrl.searchParams.set("client_id", creds.client_id);
182
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
183
+ authorizeUrl.searchParams.set("state", state);
184
+ authorizeUrl.searchParams.set("code_challenge", challenge);
185
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
186
+ authorizeUrl.searchParams.set("response_type", "code");
187
+
188
+ this._openBrowser(authorizeUrl.toString());
189
+
190
+ const { code } = await callbackPromise;
191
+
192
+ const params = new URLSearchParams();
193
+ params.set("grant_type", "authorization_code");
194
+ params.set("code", code);
195
+ params.set("code_verifier", verifier);
196
+ params.set("redirect_uri", redirectUri);
197
+ params.set("client_id", creds.client_id);
198
+
199
+ const resp = await axios.post(
200
+ `${this.issuer}${TOKEN_PATH}`,
201
+ params.toString(),
202
+ {
203
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
204
+ validateStatus: () => true,
205
+ },
206
+ );
207
+
208
+ if (resp.status >= 400) {
209
+ throw new Error(
210
+ `Token exchange failed (${resp.status}): ${JSON.stringify(resp.data)}`,
211
+ );
212
+ }
213
+
214
+ const tokens = resp.data;
215
+ const newCreds = {
216
+ client_id: creds.client_id,
217
+ access_token: tokens.access_token,
218
+ refresh_token: tokens.refresh_token,
219
+ expires_at:
220
+ Date.now() + (tokens.expires_in - EXPIRY_BUFFER_SECONDS) * 1000,
221
+ };
222
+ await this._save(newCreds);
223
+ return tokens.access_token;
224
+ }
225
+
226
+ async _refresh(creds) {
227
+ const params = new URLSearchParams();
228
+ params.set("grant_type", "refresh_token");
229
+ params.set("refresh_token", creds.refresh_token);
230
+ params.set("client_id", creds.client_id);
231
+
232
+ const resp = await axios.post(
233
+ `${this.issuer}${TOKEN_PATH}`,
234
+ params.toString(),
235
+ {
236
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
237
+ validateStatus: () => true,
238
+ },
239
+ );
240
+
241
+ if (resp.status >= 400 && resp.status < 500) {
242
+ throw new OAuthRefreshError(
243
+ `Refresh token rejected (${resp.status}): ${JSON.stringify(resp.data)}`,
244
+ );
245
+ }
246
+
247
+ if (resp.status >= 500) {
248
+ throw new Error(
249
+ `Token refresh failed (${resp.status}): ${JSON.stringify(resp.data)}`,
250
+ );
251
+ }
252
+
253
+ const tokens = resp.data;
254
+ const newCreds = {
255
+ client_id: creds.client_id,
256
+ access_token: tokens.access_token,
257
+ refresh_token: tokens.refresh_token || creds.refresh_token,
258
+ expires_at:
259
+ Date.now() + (tokens.expires_in - EXPIRY_BUFFER_SECONDS) * 1000,
260
+ };
261
+ await this._save(newCreds);
262
+ return tokens.access_token;
263
+ }
264
+
265
+ async _register(redirectUri) {
266
+ const resp = await axios.post(
267
+ `${this.issuer}${REGISTER_PATH}`,
268
+ {
269
+ client_name: CLIENT_NAME,
270
+ redirect_uris: [redirectUri],
271
+ },
272
+ {
273
+ headers: { "Content-Type": "application/json" },
274
+ validateStatus: () => true,
275
+ },
276
+ );
277
+
278
+ if (resp.status >= 400 || !resp.data || !resp.data.client_id) {
279
+ throw new Error(
280
+ `Client registration failed (${resp.status}): ${JSON.stringify(resp.data)}`,
281
+ );
282
+ }
283
+ return resp.data.client_id;
284
+ }
285
+
286
+ _generatePKCE() {
287
+ const verifier = randomBytes(32).toString("base64url");
288
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
289
+ return { verifier, challenge };
290
+ }
291
+
292
+ _generateState() {
293
+ return randomBytes(16).toString("hex");
294
+ }
295
+
296
+ _listenForCallback(expectedState, port) {
297
+ return new Promise((resolve, reject) => {
298
+ const server = createServer((req, res) => {
299
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
300
+ if (url.pathname !== CALLBACK_PATH) {
301
+ res.writeHead(404);
302
+ res.end();
303
+ return;
304
+ }
305
+
306
+ const code = url.searchParams.get("code");
307
+ const state = url.searchParams.get("state");
308
+ const error = url.searchParams.get("error");
309
+
310
+ const body = error
311
+ ? ERROR_HTML_TEMPLATE.replace("{{error}}", escapeHtml(error))
312
+ : SUCCESS_HTML;
313
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
314
+ res.end(body);
315
+
316
+ setImmediate(() => server.close());
317
+
318
+ if (error) {
319
+ return reject(
320
+ new Error(`OAuth error from authorization server: ${error}`),
321
+ );
322
+ }
323
+ if (!state || state !== expectedState) {
324
+ return reject(new Error("OAuth state mismatch"));
325
+ }
326
+ if (!code) {
327
+ return reject(new Error("No authorization code received"));
328
+ }
329
+ resolve({ code, port });
330
+ });
331
+ server.on("error", reject);
332
+ server.listen(port, "127.0.0.1");
333
+ });
334
+ }
335
+
336
+ _getFreePort() {
337
+ return new Promise((resolve, reject) => {
338
+ const srv = createServer();
339
+ srv.unref();
340
+ srv.on("error", reject);
341
+ srv.listen(0, "127.0.0.1", () => {
342
+ const addr = srv.address();
343
+ const port = typeof addr === "object" && addr ? addr.port : 0;
344
+ srv.close(() => resolve(port));
345
+ });
346
+ });
347
+ }
348
+
349
+ _credentialsPath() {
350
+ if (process.platform === "win32") {
351
+ const base = process.env["APPDATA"] || os.homedir();
352
+ return path.join(base, CREDENTIALS_DIR_NAME, CREDENTIALS_FILE_NAME);
353
+ }
354
+ return path.join(
355
+ os.homedir(),
356
+ ".config",
357
+ CREDENTIALS_DIR_NAME,
358
+ CREDENTIALS_FILE_NAME,
359
+ );
360
+ }
361
+
362
+ async _load() {
363
+ const p = this._credentialsPath();
364
+ if (!existsSync(p)) {
365
+ return null;
366
+ }
367
+ try {
368
+ const raw = await readFile(p, "utf8");
369
+ return JSON.parse(raw);
370
+ } catch (_) {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ async _save(creds) {
376
+ const p = this._credentialsPath();
377
+ await mkdir(path.dirname(p), { recursive: true });
378
+ const writeOpts =
379
+ process.platform === "win32"
380
+ ? { encoding: "utf8" }
381
+ : { encoding: "utf8", mode: 0o600 };
382
+ await writeFile(p, JSON.stringify(creds, null, 2), writeOpts);
383
+ }
384
+
385
+ _openBrowser(url) {
386
+ process.stderr.write(`\n[OAuth] Opening browser for sign-in:\n ${url}\n`);
387
+ process.stderr.write(
388
+ "[OAuth] If the browser does not open, copy the URL above into one manually.\n\n",
389
+ );
390
+ let cmd;
391
+ if (process.platform === "darwin") {
392
+ cmd = `open "${url}"`;
393
+ } else if (process.platform === "win32") {
394
+ cmd = `start "" "${url}"`;
395
+ } else {
396
+ cmd = `xdg-open "${url}"`;
397
+ }
398
+ exec(cmd, (err) => {
399
+ if (err) {
400
+ process.stderr.write(
401
+ `[OAuth] Could not auto-launch browser: ${err.message}\n`,
402
+ );
403
+ }
404
+ });
405
+ }
406
+ }