pi-antigravity-rotator 2.2.2 → 2.3.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.
package/src/onboarding.ts CHANGED
@@ -1,20 +1,20 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { addAccountToConfig } from "./account-store.js";
3
3
  import {
4
- buildAuthUrl,
5
- discoverProject,
6
- exchangeAuthorizationCode,
7
- generatePkce,
8
- generateState,
9
- getOAuthClientConfig,
10
- getUserEmail,
11
- isHostedOAuthConfigured,
4
+ buildAuthUrl,
5
+ discoverProject,
6
+ exchangeAuthorizationCode,
7
+ generatePkce,
8
+ generateState,
9
+ getOAuthClientConfig,
10
+ getUserEmail,
11
+ isHostedOAuthConfigured,
12
12
  } from "./oauth.js";
13
13
  import type { AccountRotator } from "./rotator.js";
14
14
 
15
15
  interface PendingSession {
16
- verifier: string;
17
- createdAt: number;
16
+ verifier: string;
17
+ createdAt: number;
18
18
  }
19
19
 
20
20
  const pendingSessions = new Map<string, PendingSession>();
@@ -22,12 +22,12 @@ const SESSION_TTL_MS = 15 * 60 * 1000;
22
22
  const SESSION_PRUNE_INTERVAL_MS = 5 * 60 * 1000;
23
23
 
24
24
  function prunePendingSessions(): void {
25
- const cutoff = Date.now() - SESSION_TTL_MS;
26
- for (const [state, session] of pendingSessions.entries()) {
27
- if (session.createdAt < cutoff) {
28
- pendingSessions.delete(state);
29
- }
30
- }
25
+ const cutoff = Date.now() - SESSION_TTL_MS;
26
+ for (const [state, session] of pendingSessions.entries()) {
27
+ if (session.createdAt < cutoff) {
28
+ pendingSessions.delete(state);
29
+ }
30
+ }
31
31
  }
32
32
 
33
33
  // Background reaper. Without this, a long-lived proxy that never sees
@@ -36,20 +36,23 @@ function prunePendingSessions(): void {
36
36
  // so it does not block process exit.
37
37
  let pruneTimer: ReturnType<typeof setInterval> | null = null;
38
38
  function startPendingSessionReaper(): void {
39
- if (pruneTimer) return;
40
- pruneTimer = setInterval(() => prunePendingSessions(), SESSION_PRUNE_INTERVAL_MS);
41
- if (pruneTimer.unref) pruneTimer.unref();
39
+ if (pruneTimer) return;
40
+ pruneTimer = setInterval(
41
+ () => prunePendingSessions(),
42
+ SESSION_PRUNE_INTERVAL_MS,
43
+ );
44
+ if (pruneTimer.unref) pruneTimer.unref();
42
45
  }
43
- function stopPendingSessionReaper(): void {
44
- if (pruneTimer) {
45
- clearInterval(pruneTimer);
46
- pruneTimer = null;
47
- }
46
+ export function stopPendingSessionReaper(): void {
47
+ if (pruneTimer) {
48
+ clearInterval(pruneTimer);
49
+ pruneTimer = null;
50
+ }
48
51
  }
49
52
  startPendingSessionReaper();
50
53
 
51
54
  function renderPage(title: string, body: string): string {
52
- return `<!DOCTYPE html>
55
+ return `<!DOCTYPE html>
53
56
  <html lang="en">
54
57
  <head>
55
58
  <meta charset="utf-8">
@@ -151,108 +154,344 @@ function renderPage(title: string, body: string): string {
151
154
  }
152
155
 
153
156
  export function serveLoginLanding(res: ServerResponse): void {
154
- const oauth = getOAuthClientConfig();
155
- const hostedReady = isHostedOAuthConfigured();
156
- const message = hostedReady
157
- ? `<p>This page starts the Antigravity sign-in flow and returns here automatically so the account can be added to this rotator.</p>
157
+ const oauth = getOAuthClientConfig();
158
+ const hostedReady = isHostedOAuthConfigured();
159
+ const message = hostedReady
160
+ ? `<p>This page starts the Antigravity sign-in flow and returns here automatically so the account can be added to this rotator.</p>
158
161
  <p class="mono">Configured callback: ${oauth.redirectUri}</p>
159
162
  <div class="note">Signing in here grants this server a refresh token for the selected Google account. That allows the rotator to keep using that account until access is revoked.</div>
160
163
  <a class="cta" href="/auth/antigravity/start">Continue With Google</a>`
161
- : `<p>This server is not yet configured for hosted OAuth.</p>
164
+ : `<p>This server is not yet configured for hosted OAuth.</p>
162
165
  <p class="mono">Set ANTIGRAVITY_REDIRECT_URI, and usually ANTIGRAVITY_CLIENT_ID plus ANTIGRAVITY_CLIENT_SECRET, to a public callback URL registered with the OAuth client.</p>
163
166
  <div class="note error">The current redirect is still loopback-only, so the transparent public callback cannot complete yet.</div>`;
164
167
 
165
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
166
- res.end(renderPage("Antigravity Login", `<h1>Connect Your Account</h1>${message}`));
168
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
169
+ res.end(
170
+ renderPage("Antigravity Login", `<h1>Connect Your Account</h1>${message}`),
171
+ );
172
+ }
173
+
174
+ export function startHostedLogin(
175
+ req: IncomingMessage,
176
+ res: ServerResponse,
177
+ ): void {
178
+ if (!isHostedOAuthConfigured()) {
179
+ res.writeHead(409, { "Content-Type": "text/html; charset=utf-8" });
180
+ res.end(
181
+ renderPage(
182
+ "Hosted OAuth Not Configured",
183
+ "<h1>Hosted Login Isn’t Ready</h1><p>This server still uses a loopback redirect URI. Configure a public redirect before sharing this page.</p>",
184
+ ),
185
+ );
186
+ return;
187
+ }
188
+
189
+ prunePendingSessions();
190
+ const { verifier, challenge } = generatePkce();
191
+ const state = generateState();
192
+ pendingSessions.set(state, { verifier, createdAt: Date.now() });
193
+
194
+ const authUrl = buildAuthUrl(state, challenge);
195
+ res.writeHead(302, { Location: authUrl });
196
+ res.end();
197
+ }
198
+
199
+ // ── Web-based CLI login (/login-cli) ──────────────────────────────────────────
200
+ // Replicates the CLI login flow in the browser: shows the Google OAuth URL,
201
+ // user signs in and pastes the redirect URL back, server exchanges the code.
202
+
203
+ interface CliLoginSession {
204
+ verifier: string;
205
+ challenge: string;
206
+ authUrl: string;
207
+ createdAt: number;
167
208
  }
168
209
 
169
- export function startHostedLogin(req: IncomingMessage, res: ServerResponse): void {
170
- if (!isHostedOAuthConfigured()) {
171
- res.writeHead(409, { "Content-Type": "text/html; charset=utf-8" });
172
- res.end(
173
- renderPage(
174
- "Hosted OAuth Not Configured",
175
- "<h1>Hosted Login Isn’t Ready</h1><p>This server still uses a loopback redirect URI. Configure a public redirect before sharing this page.</p>",
176
- ),
177
- );
178
- return;
179
- }
180
-
181
- prunePendingSessions();
182
- const { verifier, challenge } = generatePkce();
183
- const state = generateState();
184
- pendingSessions.set(state, { verifier, createdAt: Date.now() });
185
-
186
- const authUrl = buildAuthUrl(state, challenge);
187
- res.writeHead(302, { Location: authUrl });
188
- res.end();
210
+ const cliLoginSessions = new Map<string, CliLoginSession>();
211
+
212
+ function pruneCliSessions(): void {
213
+ const cutoff = Date.now() - SESSION_TTL_MS;
214
+ for (const [id, session] of cliLoginSessions.entries()) {
215
+ if (session.createdAt < cutoff) {
216
+ cliLoginSessions.delete(id);
217
+ }
218
+ }
219
+ }
220
+
221
+ export function serveCliLogin(res: ServerResponse): void {
222
+ pruneCliSessions();
223
+ const { verifier, challenge } = generatePkce();
224
+ const authUrl = buildAuthUrl(verifier, challenge);
225
+ const sessionId = generateState();
226
+ cliLoginSessions.set(sessionId, {
227
+ verifier,
228
+ challenge,
229
+ authUrl,
230
+ createdAt: Date.now(),
231
+ });
232
+
233
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
234
+ res.end(
235
+ renderPage(
236
+ "Add Account",
237
+ `<h1>Add Account</h1>
238
+ <p>This page works like the CLI login. Follow the steps below to add a Google account to the rotator.</p>
239
+
240
+ <h3 style="margin:24px 0 8px;font-size:18px;">Step 1 &mdash; Sign in with Google</h3>
241
+ <p>Click the button below to open the Google sign-in page in a new tab:</p>
242
+ <a class="cta" href="${authUrl}" target="_blank" rel="noopener" style="font-size:16px;">
243
+ Sign in with Google &nearr;
244
+ </a>
245
+
246
+ <h3 style="margin:24px 0 8px;font-size:18px;">Step 2 &mdash; Paste the redirect URL</h3>
247
+ <p>After signing in, Google will redirect to <code>localhost</code> (which will fail — that's expected). Copy the <strong>full URL</strong> from your browser's address bar and paste it here:</p>
248
+ <form id="pasteForm" style="margin-top:12px;">
249
+ <input type="hidden" name="session" value="${sessionId}" />
250
+ <textarea name="redirectUrl" rows="4" placeholder="Paste the redirect URL here (starts with http://localhost:51121/oauth-callback?...)" style="
251
+ width:100%;font-family:'IBM Plex Mono',monospace;font-size:13px;
252
+ padding:12px 14px;border-radius:12px;border:1px solid rgba(31,42,31,0.15);
253
+ background:rgba(31,42,31,0.03);resize:vertical;
254
+ "></textarea>
255
+ <button type="submit" class="cta" style="cursor:pointer;border:none;font-family:inherit;font-size:16px;margin-top:12px;">
256
+ Connect Account
257
+ </button>
258
+ </form>
259
+ <div id="result" style="margin-top:18px;"></div>
260
+
261
+ <script>
262
+ document.getElementById('pasteForm').addEventListener('submit', async (e) => {
263
+ e.preventDefault();
264
+ const form = e.target;
265
+ const btn = form.querySelector('button[type=submit]');
266
+ const resultDiv = document.getElementById('result');
267
+ const redirectUrl = form.redirectUrl.value.trim();
268
+ const session = form.session.value;
269
+ if (!redirectUrl) { resultDiv.innerHTML = '<div class="note error">Please paste the redirect URL.</div>'; return; }
270
+ btn.disabled = true;
271
+ btn.textContent = 'Connecting...';
272
+ resultDiv.innerHTML = '<div class="note">Exchanging code for tokens...</div>';
273
+ try {
274
+ const params = new URLSearchParams(window.location.search);
275
+ const token = params.get('token') || '';
276
+ const res = await fetch('/api/cli-login', {
277
+ method: 'POST',
278
+ headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Rotator-Admin-Token': token } : {}) },
279
+ body: JSON.stringify({ session, redirectUrl }),
280
+ });
281
+ const data = await res.json();
282
+ if (data.ok) {
283
+ resultDiv.innerHTML = '<div class="note" style="border-left-color:var(--accent);background:rgba(30,107,82,0.12);">' +
284
+ '<strong>' + data.email + '</strong> ' + (data.isNew ? 'added' : 'updated') + ' successfully.<br>' +
285
+ 'Project: <span class="mono" style="padding:2px 6px;">' + data.projectId + '</span>' +
286
+ '</div>';
287
+ } else {
288
+ var errorDiv = document.createElement('div');
289
+ errorDiv.className = 'note error';
290
+ errorDiv.textContent = data.error || 'Unknown error';
291
+ resultDiv.innerHTML = '';
292
+ resultDiv.appendChild(errorDiv);
293
+ }
294
+ } catch (err) {
295
+ var errDiv = document.createElement('div');
296
+ errDiv.className = 'note error';
297
+ errDiv.textContent = 'Request failed: ' + err.message;
298
+ resultDiv.innerHTML = '';
299
+ resultDiv.appendChild(errDiv);
300
+ } finally {
301
+ btn.disabled = false;
302
+ btn.textContent = 'Connect Account';
303
+ }
304
+ });
305
+ </script>
306
+ `,
307
+ ),
308
+ );
309
+ }
310
+
311
+ export async function handleCliLoginApi(
312
+ req: IncomingMessage,
313
+ res: ServerResponse,
314
+ rotator: AccountRotator,
315
+ ): Promise<void> {
316
+ let body: { session?: string; redirectUrl?: string };
317
+ try {
318
+ const chunks: Buffer[] = [];
319
+ for await (const chunk of req) chunks.push(chunk as Buffer);
320
+ body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
321
+ } catch {
322
+ res.writeHead(400, { "Content-Type": "application/json" });
323
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
324
+ return;
325
+ }
326
+
327
+ const { session: sessionId, redirectUrl } = body;
328
+ if (!sessionId || !redirectUrl) {
329
+ res.writeHead(400, { "Content-Type": "application/json" });
330
+ res.end(
331
+ JSON.stringify({ ok: false, error: "Missing session or redirectUrl" }),
332
+ );
333
+ return;
334
+ }
335
+
336
+ pruneCliSessions();
337
+ const session = cliLoginSessions.get(sessionId);
338
+ if (!session) {
339
+ res.writeHead(400, { "Content-Type": "application/json" });
340
+ res.end(
341
+ JSON.stringify({
342
+ ok: false,
343
+ error: "Session expired or invalid. Reload the page and try again.",
344
+ }),
345
+ );
346
+ return;
347
+ }
348
+
349
+ // Parse the redirect URL to extract code
350
+ let code: string | undefined;
351
+ try {
352
+ const url = new URL(redirectUrl.trim());
353
+ code = url.searchParams.get("code") ?? undefined;
354
+ } catch {
355
+ res.writeHead(400, { "Content-Type": "application/json" });
356
+ res.end(
357
+ JSON.stringify({
358
+ ok: false,
359
+ error:
360
+ "Could not parse the URL. Make sure you pasted the full redirect URL.",
361
+ }),
362
+ );
363
+ return;
364
+ }
365
+
366
+ if (!code) {
367
+ res.writeHead(400, { "Content-Type": "application/json" });
368
+ res.end(
369
+ JSON.stringify({
370
+ ok: false,
371
+ error: "No authorization code found in the URL.",
372
+ }),
373
+ );
374
+ return;
375
+ }
376
+
377
+ cliLoginSessions.delete(sessionId);
378
+
379
+ try {
380
+ const tokenData = await exchangeAuthorizationCode(code, session.verifier);
381
+ const email = await getUserEmail(tokenData.accessToken);
382
+ const project = await discoverProject(tokenData.accessToken);
383
+ const label = email ? email.split("@")[0] : "Account";
384
+ const entry = {
385
+ email: email || "unknown@gmail.com",
386
+ refreshToken: tokenData.refreshToken,
387
+ projectId: project.projectId,
388
+ projectSource: project.source,
389
+ label,
390
+ };
391
+
392
+ const { isNew } = addAccountToConfig(entry);
393
+ rotator.addOrUpdateAccount(entry);
394
+
395
+ res.writeHead(200, { "Content-Type": "application/json" });
396
+ res.end(
397
+ JSON.stringify({
398
+ ok: true,
399
+ email: entry.email,
400
+ isNew,
401
+ projectId: project.projectId,
402
+ }),
403
+ );
404
+ } catch (err) {
405
+ res.writeHead(500, { "Content-Type": "application/json" });
406
+ res.end(
407
+ JSON.stringify({
408
+ ok: false,
409
+ error: err instanceof Error ? err.message : String(err),
410
+ }),
411
+ );
412
+ }
189
413
  }
190
414
 
191
415
  export async function handleHostedCallback(
192
- req: IncomingMessage,
193
- res: ServerResponse,
194
- rotator: AccountRotator,
416
+ req: IncomingMessage,
417
+ res: ServerResponse,
418
+ rotator: AccountRotator,
195
419
  ): Promise<void> {
196
- const requestUrl = new URL(req.url || "/", "http://localhost");
197
- const code = requestUrl.searchParams.get("code");
198
- const state = requestUrl.searchParams.get("state");
199
- const error = requestUrl.searchParams.get("error");
200
-
201
- if (error) {
202
- res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
203
- res.end(renderPage("Sign-In Cancelled", `<h1>Sign-In Cancelled</h1><p>Google returned: ${error}</p>`));
204
- return;
205
- }
206
-
207
- if (!code || !state) {
208
- res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
209
- res.end(renderPage("Missing Parameters", "<h1>Missing Parameters</h1><p>The callback did not include a valid code and state.</p>"));
210
- return;
211
- }
212
-
213
- prunePendingSessions();
214
- const session = pendingSessions.get(state);
215
- if (!session) {
216
- res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
217
- res.end(renderPage("Session Expired", "<h1>Session Expired</h1><p>This sign-in session is no longer valid. Start again from the login page.</p>"));
218
- return;
219
- }
220
- pendingSessions.delete(state);
221
-
222
- try {
223
- const tokenData = await exchangeAuthorizationCode(code, session.verifier);
224
- const email = await getUserEmail(tokenData.accessToken);
225
- const project = await discoverProject(tokenData.accessToken);
226
- const label = email ? email.split("@")[0] : "Account";
227
- const entry = {
228
- email: email || "unknown@gmail.com",
229
- refreshToken: tokenData.refreshToken,
230
- projectId: project.projectId,
231
- projectSource: project.source,
232
- label,
233
- };
234
-
235
- const { isNew } = addAccountToConfig(entry);
236
- rotator.addOrUpdateAccount(entry);
237
-
238
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
239
- res.end(
240
- renderPage(
241
- "Account Connected",
242
- `<h1>Account Connected</h1>
420
+ const requestUrl = new URL(req.url || "/", "http://localhost");
421
+ const code = requestUrl.searchParams.get("code");
422
+ const state = requestUrl.searchParams.get("state");
423
+ const error = requestUrl.searchParams.get("error");
424
+
425
+ if (error) {
426
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
427
+ res.end(
428
+ renderPage(
429
+ "Sign-In Cancelled",
430
+ `<h1>Sign-In Cancelled</h1><p>Google returned: ${error}</p>`,
431
+ ),
432
+ );
433
+ return;
434
+ }
435
+
436
+ if (!code || !state) {
437
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
438
+ res.end(
439
+ renderPage(
440
+ "Missing Parameters",
441
+ "<h1>Missing Parameters</h1><p>The callback did not include a valid code and state.</p>",
442
+ ),
443
+ );
444
+ return;
445
+ }
446
+
447
+ prunePendingSessions();
448
+ const session = pendingSessions.get(state);
449
+ if (!session) {
450
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
451
+ res.end(
452
+ renderPage(
453
+ "Session Expired",
454
+ "<h1>Session Expired</h1><p>This sign-in session is no longer valid. Start again from the login page.</p>",
455
+ ),
456
+ );
457
+ return;
458
+ }
459
+ pendingSessions.delete(state);
460
+
461
+ try {
462
+ const tokenData = await exchangeAuthorizationCode(code, session.verifier);
463
+ const email = await getUserEmail(tokenData.accessToken);
464
+ const project = await discoverProject(tokenData.accessToken);
465
+ const label = email ? email.split("@")[0] : "Account";
466
+ const entry = {
467
+ email: email || "unknown@gmail.com",
468
+ refreshToken: tokenData.refreshToken,
469
+ projectId: project.projectId,
470
+ projectSource: project.source,
471
+ label,
472
+ };
473
+
474
+ const { isNew } = addAccountToConfig(entry);
475
+ rotator.addOrUpdateAccount(entry);
476
+
477
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
478
+ res.end(
479
+ renderPage(
480
+ "Account Connected",
481
+ `<h1>Account Connected</h1>
243
482
  <p><strong>${entry.email}</strong> was ${isNew ? "added" : "updated"} successfully.</p>
244
483
  <p>Project: <span class="mono">${project.projectId}</span> via ${project.source}.</p>
245
484
  <p>The rotator can start using this account immediately.</p>
246
485
  <div class="note">If you ever want to stop sharing access, revoke this app's access from the Google account security settings.</div>`,
247
- ),
248
- );
249
- } catch (err) {
250
- res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
251
- res.end(
252
- renderPage(
253
- "Sign-In Failed",
254
- `<h1>Sign-In Failed</h1><p>${err instanceof Error ? err.message : String(err)}</p>`,
255
- ),
256
- );
257
- }
486
+ ),
487
+ );
488
+ } catch (err) {
489
+ res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
490
+ res.end(
491
+ renderPage(
492
+ "Sign-In Failed",
493
+ `<h1>Sign-In Failed</h1><p>${err instanceof Error ? err.message : String(err)}</p>`,
494
+ ),
495
+ );
496
+ }
258
497
  }
package/src/paths.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // Default: ~/.pi-antigravity-rotator/
3
3
  // Override: --config-dir <path> or PI_ROTATOR_DIR env var
4
4
 
5
- import { join, resolve, sep } from "node:path";
5
+ import { join, resolve } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { mkdirSync } from "node:fs";
8
8
 
@@ -15,46 +15,49 @@ let configDir: string | null = null;
15
15
  * traversal. Refuses to mkdir and returns the default if the input contains
16
16
  * `..` segments that escape the intended root. Throws otherwise.
17
17
  */
18
- export function resolveSafeConfigDir(input: string, source: "argv" | "env" = "argv"): string {
19
- // Check the ORIGINAL input (before resolve() collapses ".."), since
20
- // resolve("/a/../../b") silently becomes "/b" and would mask the attack.
21
- const segments = input.split(/[\\/]+/);
22
- for (const seg of segments) {
23
- if (seg === "..") {
24
- throw new Error(
25
- `Refusing --config-dir="${input}" from ${source}: contains '..' segment which could escape the config root. ` +
26
- `Use an absolute path or PI_ROTATOR_DIR env var set by the container orchestrator.`,
27
- );
28
- }
29
- }
30
- return resolve(input);
18
+ export function resolveSafeConfigDir(
19
+ input: string,
20
+ source: "argv" | "env" = "argv",
21
+ ): string {
22
+ // Check the ORIGINAL input (before resolve() collapses ".."), since
23
+ // resolve("/a/../../b") silently becomes "/b" and would mask the attack.
24
+ const segments = input.split(/[\\/]+/);
25
+ for (const seg of segments) {
26
+ if (seg === "..") {
27
+ throw new Error(
28
+ `Refusing --config-dir="${input}" from ${source}: contains '..' segment which could escape the config root. ` +
29
+ `Use an absolute path or PI_ROTATOR_DIR env var set by the container orchestrator.`,
30
+ );
31
+ }
32
+ }
33
+ return resolve(input);
31
34
  }
32
35
 
33
36
  export function getConfigDir(): string {
34
- if (configDir) return configDir;
35
-
36
- // Check CLI arg
37
- const idx = process.argv.indexOf("--config-dir");
38
- if (idx !== -1 && process.argv[idx + 1]) {
39
- configDir = resolveSafeConfigDir(process.argv[idx + 1], "argv");
40
- } else if (process.env.PI_ROTATOR_DIR) {
41
- configDir = resolveSafeConfigDir(process.env.PI_ROTATOR_DIR, "env");
42
- } else {
43
- configDir = DEFAULT_DIR;
44
- }
45
-
46
- mkdirSync(configDir, { recursive: true });
47
- return configDir;
37
+ if (configDir) return configDir;
38
+
39
+ // Check CLI arg
40
+ const idx = process.argv.indexOf("--config-dir");
41
+ if (idx !== -1 && process.argv[idx + 1]) {
42
+ configDir = resolveSafeConfigDir(process.argv[idx + 1], "argv");
43
+ } else if (process.env.PI_ROTATOR_DIR) {
44
+ configDir = resolveSafeConfigDir(process.env.PI_ROTATOR_DIR, "env");
45
+ } else {
46
+ configDir = DEFAULT_DIR;
47
+ }
48
+
49
+ mkdirSync(configDir, { recursive: true });
50
+ return configDir;
48
51
  }
49
52
 
50
53
  export function getAccountsPath(): string {
51
- return join(getConfigDir(), "accounts.json");
54
+ return join(getConfigDir(), "accounts.json");
52
55
  }
53
56
 
54
57
  export function getStatePath(): string {
55
- return join(getConfigDir(), "state.json");
58
+ return join(getConfigDir(), "state.json");
56
59
  }
57
60
 
58
61
  export function getTokenUsagePath(): string {
59
- return join(getConfigDir(), "token-usage.json");
62
+ return join(getConfigDir(), "token-usage.json");
60
63
  }