axusage 2.0.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/LICENSE +21 -0
- package/README.md +254 -0
- package/bin/axusage +2 -0
- package/dist/adapters/chatgpt.d.ts +8 -0
- package/dist/adapters/chatgpt.js +68 -0
- package/dist/adapters/claude.d.ts +9 -0
- package/dist/adapters/claude.js +108 -0
- package/dist/adapters/coalesce-claude-usage-response.d.ts +12 -0
- package/dist/adapters/coalesce-claude-usage-response.js +119 -0
- package/dist/adapters/gemini.d.ts +8 -0
- package/dist/adapters/gemini.js +43 -0
- package/dist/adapters/github-copilot.d.ts +6 -0
- package/dist/adapters/github-copilot.js +56 -0
- package/dist/adapters/parse-chatgpt-usage.d.ts +15 -0
- package/dist/adapters/parse-chatgpt-usage.js +28 -0
- package/dist/adapters/parse-claude-usage.d.ts +16 -0
- package/dist/adapters/parse-claude-usage.js +75 -0
- package/dist/adapters/parse-gemini-usage.d.ts +55 -0
- package/dist/adapters/parse-gemini-usage.js +151 -0
- package/dist/adapters/parse-github-copilot-usage.d.ts +23 -0
- package/dist/adapters/parse-github-copilot-usage.js +78 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +69 -0
- package/dist/commands/auth-clear-command.d.ts +5 -0
- package/dist/commands/auth-clear-command.js +25 -0
- package/dist/commands/auth-setup-command.d.ts +11 -0
- package/dist/commands/auth-setup-command.js +45 -0
- package/dist/commands/auth-status-command.d.ts +5 -0
- package/dist/commands/auth-status-command.js +25 -0
- package/dist/commands/fetch-service-usage-with-reauth.d.ts +7 -0
- package/dist/commands/fetch-service-usage-with-reauth.js +45 -0
- package/dist/commands/fetch-service-usage.d.ts +8 -0
- package/dist/commands/fetch-service-usage.js +19 -0
- package/dist/commands/run-auth-setup.d.ts +29 -0
- package/dist/commands/run-auth-setup.js +91 -0
- package/dist/commands/usage-command.d.ts +15 -0
- package/dist/commands/usage-command.js +146 -0
- package/dist/services/app-paths.d.ts +9 -0
- package/dist/services/app-paths.js +39 -0
- package/dist/services/auth-storage-path.d.ts +3 -0
- package/dist/services/auth-storage-path.js +7 -0
- package/dist/services/auth-timeouts.d.ts +4 -0
- package/dist/services/auth-timeouts.js +4 -0
- package/dist/services/browser-auth-manager.d.ts +49 -0
- package/dist/services/browser-auth-manager.js +113 -0
- package/dist/services/create-auth-context.d.ts +8 -0
- package/dist/services/create-auth-context.js +34 -0
- package/dist/services/do-setup-auth.d.ts +3 -0
- package/dist/services/do-setup-auth.js +25 -0
- package/dist/services/fetch-json-with-context.d.ts +5 -0
- package/dist/services/fetch-json-with-context.js +37 -0
- package/dist/services/gemini-api.d.ts +11 -0
- package/dist/services/gemini-api.js +109 -0
- package/dist/services/launch-chromium.d.ts +6 -0
- package/dist/services/launch-chromium.js +20 -0
- package/dist/services/persist-storage-state.d.ts +6 -0
- package/dist/services/persist-storage-state.js +16 -0
- package/dist/services/request-service.d.ts +3 -0
- package/dist/services/request-service.js +4 -0
- package/dist/services/service-adapter-registry.d.ts +18 -0
- package/dist/services/service-adapter-registry.js +26 -0
- package/dist/services/service-auth-configs.d.ts +15 -0
- package/dist/services/service-auth-configs.js +26 -0
- package/dist/services/setup-auth-flow.d.ts +3 -0
- package/dist/services/setup-auth-flow.js +40 -0
- package/dist/services/shared-browser-auth-manager.d.ts +4 -0
- package/dist/services/shared-browser-auth-manager.js +80 -0
- package/dist/services/supported-service.d.ts +6 -0
- package/dist/services/supported-service.js +16 -0
- package/dist/services/verify-session.d.ts +2 -0
- package/dist/services/verify-session.js +25 -0
- package/dist/services/wait-for-login.d.ts +5 -0
- package/dist/services/wait-for-login.js +44 -0
- package/dist/types/chatgpt.d.ts +32 -0
- package/dist/types/chatgpt.js +21 -0
- package/dist/types/domain.d.ts +57 -0
- package/dist/types/domain.js +16 -0
- package/dist/types/gemini.d.ts +31 -0
- package/dist/types/gemini.js +27 -0
- package/dist/types/github-copilot.d.ts +21 -0
- package/dist/types/github-copilot.js +27 -0
- package/dist/types/usage.d.ts +31 -0
- package/dist/types/usage.js +25 -0
- package/dist/utils/calculate-usage-rate.d.ts +9 -0
- package/dist/utils/calculate-usage-rate.js +31 -0
- package/dist/utils/classify-usage-rate.d.ts +6 -0
- package/dist/utils/classify-usage-rate.js +10 -0
- package/dist/utils/format-prometheus-metrics.d.ts +6 -0
- package/dist/utils/format-prometheus-metrics.js +20 -0
- package/dist/utils/format-service-usage.d.ts +18 -0
- package/dist/utils/format-service-usage.js +120 -0
- package/package.json +83 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { getAuthMetaPathFor, getStorageStatePathFor, } from "./auth-storage-path.js";
|
|
4
|
+
/**
|
|
5
|
+
* Load the stored userAgent from the auth meta file for a service.
|
|
6
|
+
* Returns undefined if meta file doesn't exist or is invalid.
|
|
7
|
+
*/
|
|
8
|
+
export async function loadStoredUserAgent(dataDirectory, service) {
|
|
9
|
+
try {
|
|
10
|
+
const metaPath = getAuthMetaPathFor(dataDirectory, service);
|
|
11
|
+
const metaRaw = await readFile(metaPath, "utf8");
|
|
12
|
+
const meta = JSON.parse(metaRaw);
|
|
13
|
+
// Validate the parsed structure at runtime
|
|
14
|
+
if (meta &&
|
|
15
|
+
typeof meta === "object" &&
|
|
16
|
+
"userAgent" in meta &&
|
|
17
|
+
typeof meta.userAgent === "string") {
|
|
18
|
+
return meta.userAgent;
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Meta file missing, unreadable, or contains invalid JSON
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function createAuthContext(browser, dataDirectory, service) {
|
|
28
|
+
const storageStatePath = getStorageStatePathFor(dataDirectory, service);
|
|
29
|
+
if (!existsSync(storageStatePath)) {
|
|
30
|
+
throw new Error(`No saved authentication for ${service}. Run 'axusage auth setup ${service}' first.`);
|
|
31
|
+
}
|
|
32
|
+
const userAgent = await loadStoredUserAgent(dataDirectory, service);
|
|
33
|
+
return browser.newContext({ storageState: storageStatePath, userAgent });
|
|
34
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { setupAuthInContext } from "./setup-auth-flow.js";
|
|
2
|
+
import { writeFile, chmod } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getAuthMetaPathFor } from "./auth-storage-path.js";
|
|
5
|
+
export async function doSetupAuth(service, context, storagePath, instructions) {
|
|
6
|
+
console.error(`\n${instructions}`);
|
|
7
|
+
console.error("Waiting for login to complete (or press Enter to continue)\n");
|
|
8
|
+
const userAgent = await setupAuthInContext(service, context, storagePath);
|
|
9
|
+
try {
|
|
10
|
+
if (userAgent) {
|
|
11
|
+
const metaPath = getAuthMetaPathFor(path.dirname(storagePath), service);
|
|
12
|
+
await writeFile(metaPath, JSON.stringify({ userAgent }), "utf8");
|
|
13
|
+
try {
|
|
14
|
+
await chmod(metaPath, 0o600);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// best effort
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// ignore errors when writing meta; not critical
|
|
23
|
+
}
|
|
24
|
+
console.error(`\n✓ Authentication saved for ${service}. You can now close the browser.`);
|
|
25
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch JSON using the page's fetch API so cookies/session are included without navigation.
|
|
3
|
+
*/
|
|
4
|
+
export async function fetchJsonWithContext(context, url) {
|
|
5
|
+
const page = await context.newPage();
|
|
6
|
+
try {
|
|
7
|
+
const origin = new URL(url).origin;
|
|
8
|
+
await page.goto(origin + "/", { waitUntil: "domcontentloaded" });
|
|
9
|
+
const result = await page.evaluate(async (targetUrl) => {
|
|
10
|
+
const response = await fetch(targetUrl, {
|
|
11
|
+
credentials: "include",
|
|
12
|
+
headers: {
|
|
13
|
+
Accept: "application/json, text/plain, */*",
|
|
14
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
const text = await response.text();
|
|
18
|
+
return {
|
|
19
|
+
ok: response.ok,
|
|
20
|
+
status: response.status,
|
|
21
|
+
statusText: response.statusText,
|
|
22
|
+
contentType: response.headers.get("content-type") || "",
|
|
23
|
+
text,
|
|
24
|
+
};
|
|
25
|
+
}, url);
|
|
26
|
+
if (!result.ok) {
|
|
27
|
+
throw new Error(`Request failed: ${String(result.status)} ${result.statusText}`);
|
|
28
|
+
}
|
|
29
|
+
if (!result.contentType.toLowerCase().startsWith("application/json")) {
|
|
30
|
+
throw new Error(`Expected JSON response, got ${result.contentType}`);
|
|
31
|
+
}
|
|
32
|
+
return result.text;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await page.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Result } from "../types/domain.js";
|
|
2
|
+
import { ApiError } from "../types/domain.js";
|
|
3
|
+
import type { GeminiQuotaResponse } from "../types/gemini.js";
|
|
4
|
+
/**
|
|
5
|
+
* Discover the Gemini project ID for more accurate quota retrieval
|
|
6
|
+
*/
|
|
7
|
+
export declare function fetchGeminiProject(accessToken: string): Promise<string | undefined>;
|
|
8
|
+
/**
|
|
9
|
+
* Fetch quota data from Gemini API
|
|
10
|
+
*/
|
|
11
|
+
export declare function fetchGeminiQuota(accessToken: string, projectId?: string): Promise<Result<GeminiQuotaResponse, ApiError>>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { ApiError } from "../types/domain.js";
|
|
2
|
+
import { GeminiQuotaResponse as GeminiQuotaResponseSchema, GeminiProjectsResponse as GeminiProjectsResponseSchema, } from "../types/gemini.js";
|
|
3
|
+
// NOTE: This is an undocumented internal Google API used by Gemini CLI.
|
|
4
|
+
// It may change without notice. Last verified: December 2024.
|
|
5
|
+
const QUOTA_API_URL = "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota";
|
|
6
|
+
const PROJECTS_API_URL = "https://cloudresourcemanager.googleapis.com/v1/projects";
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
8
|
+
/**
|
|
9
|
+
* Discover the Gemini project ID for more accurate quota retrieval
|
|
10
|
+
*/
|
|
11
|
+
export async function fetchGeminiProject(accessToken) {
|
|
12
|
+
try {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timeoutId = setTimeout(() => {
|
|
15
|
+
controller.abort();
|
|
16
|
+
}, REQUEST_TIMEOUT_MS);
|
|
17
|
+
const response = await fetch(PROJECTS_API_URL, {
|
|
18
|
+
method: "GET",
|
|
19
|
+
headers: {
|
|
20
|
+
Authorization: `Bearer ${accessToken}`,
|
|
21
|
+
},
|
|
22
|
+
signal: controller.signal,
|
|
23
|
+
});
|
|
24
|
+
clearTimeout(timeoutId);
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const data = await response.json();
|
|
29
|
+
const parseResult = GeminiProjectsResponseSchema.safeParse(data);
|
|
30
|
+
if (!parseResult.success || !parseResult.data.projects) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
// Find Gemini CLI project (starts with gen-lang-client or has generative-language label)
|
|
34
|
+
for (const project of parseResult.data.projects) {
|
|
35
|
+
if (project.projectId.startsWith("gen-lang-client")) {
|
|
36
|
+
return project.projectId;
|
|
37
|
+
}
|
|
38
|
+
if (project.labels?.["generative-language"] === "true") {
|
|
39
|
+
return project.projectId;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Fetch quota data from Gemini API
|
|
50
|
+
*/
|
|
51
|
+
export async function fetchGeminiQuota(accessToken, projectId) {
|
|
52
|
+
try {
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const timeoutId = setTimeout(() => {
|
|
55
|
+
controller.abort();
|
|
56
|
+
}, REQUEST_TIMEOUT_MS);
|
|
57
|
+
const body = projectId ? JSON.stringify({ project: projectId }) : "{}";
|
|
58
|
+
const response = await fetch(QUOTA_API_URL, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
Authorization: `Bearer ${accessToken}`,
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
},
|
|
64
|
+
body,
|
|
65
|
+
signal: controller.signal,
|
|
66
|
+
});
|
|
67
|
+
clearTimeout(timeoutId);
|
|
68
|
+
if (response.status === 401) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: new ApiError("Gemini authentication expired. Run 'gemini' to re-authenticate.", 401),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const errorText = await response.text();
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: new ApiError(`Gemini API error: ${String(response.status)} ${errorText}`, response.status),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const data = await response.json();
|
|
82
|
+
const parseResult = GeminiQuotaResponseSchema.safeParse(data);
|
|
83
|
+
if (!parseResult.success) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
error: new ApiError(`Invalid Gemini quota response: ${parseResult.error.message}`, undefined, data),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (parseResult.data.buckets.length === 0) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
error: new ApiError("No quota data available. Token may be invalid or expired."),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, value: parseResult.data };
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: new ApiError("Gemini quota API request timed out."),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error: new ApiError(`Failed to fetch Gemini quota: ${error instanceof Error ? error.message : String(error)}`),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
/**
|
|
3
|
+
* Launch Chromium with automation indicators disabled to reduce Cloudflare bot detection
|
|
4
|
+
* during the authentication flow.
|
|
5
|
+
*/
|
|
6
|
+
export async function launchChromium(headless) {
|
|
7
|
+
try {
|
|
8
|
+
return await chromium.launch({
|
|
9
|
+
headless,
|
|
10
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15
|
+
if (/Executable doesn't exist|playwright\s+install/iu.test(message)) {
|
|
16
|
+
throw new Error("Playwright browsers are not installed. This is usually handled automatically by the postinstall script in package.json. Please try reinstalling the package or check if the postinstall script ran successfully. If the problem persists, you can manually run `pnpm exec playwright install chromium` or `npx playwright install chromium`, then retry.");
|
|
17
|
+
}
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BrowserContext } from "playwright";
|
|
2
|
+
/**
|
|
3
|
+
* Persist context storage state to disk with secure permissions (0o600).
|
|
4
|
+
* Errors are silently ignored to avoid blocking the main operation.
|
|
5
|
+
*/
|
|
6
|
+
export declare function persistStorageState(context: BrowserContext, storagePath: string): Promise<void>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { chmod } from "node:fs/promises";
|
|
2
|
+
/**
|
|
3
|
+
* Persist context storage state to disk with secure permissions (0o600).
|
|
4
|
+
* Errors are silently ignored to avoid blocking the main operation.
|
|
5
|
+
*/
|
|
6
|
+
export async function persistStorageState(context, storagePath) {
|
|
7
|
+
try {
|
|
8
|
+
await context.storageState({ path: storagePath });
|
|
9
|
+
await chmod(storagePath, 0o600).catch(() => {
|
|
10
|
+
// best effort: permissions may already be correct or OS may ignore
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// ignore persistence errors; do not block request completion
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ServiceAdapter } from "../types/domain.js";
|
|
2
|
+
/**
|
|
3
|
+
* Registry of available service adapters
|
|
4
|
+
*/
|
|
5
|
+
export declare const SERVICE_ADAPTERS: {
|
|
6
|
+
readonly claude: ServiceAdapter;
|
|
7
|
+
readonly chatgpt: ServiceAdapter;
|
|
8
|
+
readonly "github-copilot": ServiceAdapter;
|
|
9
|
+
readonly gemini: ServiceAdapter;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Get a service adapter by name
|
|
13
|
+
*/
|
|
14
|
+
export declare function getServiceAdapter(name: string): ServiceAdapter | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Get all available service names
|
|
17
|
+
*/
|
|
18
|
+
export declare function getAvailableServices(): string[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { chatGPTAdapter } from "../adapters/chatgpt.js";
|
|
2
|
+
import { claudeAdapter } from "../adapters/claude.js";
|
|
3
|
+
import { geminiAdapter } from "../adapters/gemini.js";
|
|
4
|
+
import { githubCopilotAdapter } from "../adapters/github-copilot.js";
|
|
5
|
+
/**
|
|
6
|
+
* Registry of available service adapters
|
|
7
|
+
*/
|
|
8
|
+
export const SERVICE_ADAPTERS = {
|
|
9
|
+
claude: claudeAdapter,
|
|
10
|
+
chatgpt: chatGPTAdapter,
|
|
11
|
+
"github-copilot": githubCopilotAdapter,
|
|
12
|
+
gemini: geminiAdapter,
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Get a service adapter by name
|
|
16
|
+
*/
|
|
17
|
+
export function getServiceAdapter(name) {
|
|
18
|
+
const key = name.toLowerCase();
|
|
19
|
+
return SERVICE_ADAPTERS[key];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Get all available service names
|
|
23
|
+
*/
|
|
24
|
+
export function getAvailableServices() {
|
|
25
|
+
return Object.keys(SERVICE_ADAPTERS);
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SupportedService } from "./supported-service.js";
|
|
2
|
+
import type { BrowserContext } from "playwright";
|
|
3
|
+
/**
|
|
4
|
+
* Service-specific configuration for authentication
|
|
5
|
+
*/
|
|
6
|
+
type ServiceAuthConfig = {
|
|
7
|
+
readonly url: string;
|
|
8
|
+
readonly waitForSelector?: string;
|
|
9
|
+
readonly waitForSelectors?: readonly string[];
|
|
10
|
+
readonly verifyUrl?: string;
|
|
11
|
+
readonly verifyFunction?: (context: BrowserContext, url: string) => Promise<boolean>;
|
|
12
|
+
readonly instructions: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function getServiceAuthConfig(service: SupportedService): ServiceAuthConfig;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const SERVICE_AUTH_CONFIGS = {
|
|
2
|
+
// Claude uses CLI-based auth via Claude Code credentials
|
|
3
|
+
claude: {
|
|
4
|
+
url: "",
|
|
5
|
+
instructions: "Claude uses Claude Code authentication. Run 'claude' in your terminal to authenticate.",
|
|
6
|
+
},
|
|
7
|
+
// ChatGPT uses CLI-based auth via Codex credentials
|
|
8
|
+
chatgpt: {
|
|
9
|
+
url: "",
|
|
10
|
+
instructions: "ChatGPT uses Codex CLI authentication. Run 'codex' in your terminal to authenticate.",
|
|
11
|
+
},
|
|
12
|
+
"github-copilot": {
|
|
13
|
+
url: "https://github.com/login",
|
|
14
|
+
waitForSelector: 'img[alt*="@"]',
|
|
15
|
+
verifyUrl: "https://github.com/github-copilot/chat/entitlement",
|
|
16
|
+
instructions: "Please log in to your GitHub account in the browser window.",
|
|
17
|
+
},
|
|
18
|
+
// Gemini uses CLI-based auth, not browser auth. This entry exists only to satisfy the Record type.
|
|
19
|
+
gemini: {
|
|
20
|
+
url: "",
|
|
21
|
+
instructions: "Gemini uses CLI-based authentication. Run 'gemini' in your terminal.",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
export function getServiceAuthConfig(service) {
|
|
25
|
+
return SERVICE_AUTH_CONFIGS[service];
|
|
26
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getServiceAuthConfig } from "./service-auth-configs.js";
|
|
2
|
+
import { waitForLogin } from "./wait-for-login.js";
|
|
3
|
+
import { verifySessionByFetching } from "./verify-session.js";
|
|
4
|
+
import { chmod } from "node:fs/promises";
|
|
5
|
+
export async function setupAuthInContext(service, context, storagePath) {
|
|
6
|
+
const page = await context.newPage();
|
|
7
|
+
try {
|
|
8
|
+
const config = getServiceAuthConfig(service);
|
|
9
|
+
await page.goto(config.url);
|
|
10
|
+
const selectors = config.waitForSelectors ??
|
|
11
|
+
(config.waitForSelector ? [config.waitForSelector] : []);
|
|
12
|
+
await waitForLoginForService(page, selectors);
|
|
13
|
+
if (config.verifyUrl) {
|
|
14
|
+
const ok = config.verifyFunction
|
|
15
|
+
? await config.verifyFunction(context, config.verifyUrl)
|
|
16
|
+
: await verifySessionByFetching(context, config.verifyUrl);
|
|
17
|
+
if (!ok) {
|
|
18
|
+
console.warn(`\n⚠ Unable to verify session via ${config.verifyUrl}. Saving state anyway...`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Capture user agent for future headless contexts
|
|
22
|
+
const userAgent = await page.evaluate(() => navigator.userAgent);
|
|
23
|
+
await context.storageState({ path: storagePath });
|
|
24
|
+
try {
|
|
25
|
+
await chmod(storagePath, 0o600);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// best effort to restrict sensitive storage state
|
|
29
|
+
}
|
|
30
|
+
return userAgent;
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await page.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function waitForLoginForService(page, selectors) {
|
|
37
|
+
if (selectors.length > 0) {
|
|
38
|
+
await waitForLogin(page, selectors);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { BrowserAuthManager } from "./browser-auth-manager.js";
|
|
2
|
+
let manager;
|
|
3
|
+
let references = 0;
|
|
4
|
+
let cleanupInstalled = false;
|
|
5
|
+
let closing = false;
|
|
6
|
+
let exitInitiated = false;
|
|
7
|
+
export function acquireAuthManager() {
|
|
8
|
+
if (!manager)
|
|
9
|
+
manager = new BrowserAuthManager();
|
|
10
|
+
references++;
|
|
11
|
+
return manager;
|
|
12
|
+
}
|
|
13
|
+
export async function releaseAuthManager() {
|
|
14
|
+
if (references <= 0) {
|
|
15
|
+
// Over-release guard: ignore unmatched release
|
|
16
|
+
if (references === 0) {
|
|
17
|
+
console.warn("releaseAuthManager() called without a matching acquire; ignoring");
|
|
18
|
+
}
|
|
19
|
+
// Avoid closing the manager in an over-release state
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
references -= 1;
|
|
23
|
+
if (references === 0 && manager) {
|
|
24
|
+
await manager.close();
|
|
25
|
+
manager = undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function forceClose() {
|
|
29
|
+
if (closing)
|
|
30
|
+
return;
|
|
31
|
+
closing = true;
|
|
32
|
+
references = 0;
|
|
33
|
+
if (manager) {
|
|
34
|
+
try {
|
|
35
|
+
await manager.close();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// ignore
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
manager = undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function installAuthManagerCleanup() {
|
|
46
|
+
if (cleanupInstalled)
|
|
47
|
+
return;
|
|
48
|
+
cleanupInstalled = true;
|
|
49
|
+
/* eslint-disable unicorn/no-process-exit */
|
|
50
|
+
const safeExit = (code) => {
|
|
51
|
+
if (exitInitiated)
|
|
52
|
+
return;
|
|
53
|
+
exitInitiated = true;
|
|
54
|
+
process.exit(code);
|
|
55
|
+
};
|
|
56
|
+
/* eslint-enable unicorn/no-process-exit */
|
|
57
|
+
process.on("SIGINT", () => {
|
|
58
|
+
// Ensure browser is actually closed before exiting
|
|
59
|
+
void forceClose().finally(() => {
|
|
60
|
+
safeExit(130);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
process.on("SIGTERM", () => {
|
|
64
|
+
// Ensure browser is actually closed before exiting
|
|
65
|
+
void forceClose().finally(() => {
|
|
66
|
+
safeExit(143);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
process.on("beforeExit", () => {
|
|
70
|
+
// Best-effort cleanup on natural process exit; ensure we schedule a macrotask
|
|
71
|
+
// so the event loop stays alive until the async close finishes.
|
|
72
|
+
if (process.exitCode === undefined)
|
|
73
|
+
process.exitCode = 0;
|
|
74
|
+
setImmediate(() => {
|
|
75
|
+
void forceClose().finally(() => {
|
|
76
|
+
safeExit(Number(process.exitCode ?? 0));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported service names for usage tracking
|
|
3
|
+
*/
|
|
4
|
+
export type SupportedService = "claude" | "chatgpt" | "github-copilot" | "gemini";
|
|
5
|
+
export declare const SUPPORTED_SERVICES: SupportedService[];
|
|
6
|
+
export declare function validateService(service: string | undefined): SupportedService;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const SUPPORTED_SERVICES = [
|
|
2
|
+
"claude",
|
|
3
|
+
"chatgpt",
|
|
4
|
+
"github-copilot",
|
|
5
|
+
"gemini",
|
|
6
|
+
];
|
|
7
|
+
export function validateService(service) {
|
|
8
|
+
if (!service) {
|
|
9
|
+
throw new Error(`Service is required. Supported services: ${SUPPORTED_SERVICES.join(", ")}`);
|
|
10
|
+
}
|
|
11
|
+
const normalizedService = service.toLowerCase();
|
|
12
|
+
if (!SUPPORTED_SERVICES.includes(normalizedService)) {
|
|
13
|
+
throw new Error(`Unsupported service: ${service}. Supported services: ${SUPPORTED_SERVICES.join(", ")}`);
|
|
14
|
+
}
|
|
15
|
+
return normalizedService;
|
|
16
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { fetchJsonWithContext } from "./fetch-json-with-context.js";
|
|
2
|
+
/**
|
|
3
|
+
* Tries to fetch the given URL within the authenticated context.
|
|
4
|
+
* Returns true if the request succeeds, false if it keeps failing.
|
|
5
|
+
*/
|
|
6
|
+
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
7
|
+
const DEFAULT_RETRY_DELAY_MS = 1500;
|
|
8
|
+
export async function verifySessionByFetching(context, url, maxAttempts = DEFAULT_MAX_ATTEMPTS, delayMs = DEFAULT_RETRY_DELAY_MS) {
|
|
9
|
+
// Try up to `maxAttempts` times; some providers need a brief
|
|
10
|
+
// delay for session cookies/tokens to settle after login.
|
|
11
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
12
|
+
try {
|
|
13
|
+
await fetchJsonWithContext(context, url);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Wait a bit and try again; tokens/cookies may not be settled yet
|
|
18
|
+
// Skip the delay after the final attempt to avoid unnecessary wait
|
|
19
|
+
if (attempt < maxAttempts - 1) {
|
|
20
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { LOGIN_TIMEOUT_MS } from "./auth-timeouts.js";
|
|
4
|
+
/**
|
|
5
|
+
* Waits until one of the selectors appears on the page, or the user presses Enter to continue.
|
|
6
|
+
*/
|
|
7
|
+
export async function waitForLogin(page, selectors) {
|
|
8
|
+
const reader = createInterface({ input, output });
|
|
9
|
+
const manual = reader.question("Press Enter to continue without waiting for login... ");
|
|
10
|
+
// Absorb rejection when the interface is closed to prevent
|
|
11
|
+
// unhandled promise rejection (AbortError) after a selector wins.
|
|
12
|
+
const manualSilenced = manual.catch(() => { });
|
|
13
|
+
const timeoutMs = LOGIN_TIMEOUT_MS;
|
|
14
|
+
const deadline = Date.now() + timeoutMs;
|
|
15
|
+
// Prevent unhandled rejections if the page closes before all waiters finish
|
|
16
|
+
const waiters = selectors.map((sel) => page.waitForSelector(sel, { timeout: timeoutMs }).catch(() => {
|
|
17
|
+
// Intentionally ignored: the page may navigate/close before selector resolves
|
|
18
|
+
}));
|
|
19
|
+
const shouldShowCountdown = process.stderr.isTTY;
|
|
20
|
+
let interval;
|
|
21
|
+
if (shouldShowCountdown) {
|
|
22
|
+
interval = setInterval(() => {
|
|
23
|
+
const remaining = deadline - Date.now();
|
|
24
|
+
if (remaining <= 0) {
|
|
25
|
+
// Stop logging once timeout elapses to avoid confusing "0 minute(s)" spam
|
|
26
|
+
if (interval)
|
|
27
|
+
clearInterval(interval);
|
|
28
|
+
console.error("Login wait timed out; finishing up...");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// Round up to the next minute for clearer UX, ensure at least 1
|
|
32
|
+
const minutes = Math.max(1, Math.ceil(remaining / 60_000));
|
|
33
|
+
console.error(`Still waiting for login... ${String(minutes)} minute(s) remaining`);
|
|
34
|
+
}, 60_000);
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
await Promise.race([manualSilenced, ...waiters]);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
if (interval)
|
|
41
|
+
clearInterval(interval);
|
|
42
|
+
reader.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* ChatGPT API response schemas
|
|
4
|
+
*/
|
|
5
|
+
export declare const ChatGPTRateLimitWindow: z.ZodObject<{
|
|
6
|
+
used_percent: z.ZodNumber;
|
|
7
|
+
limit_window_seconds: z.ZodNumber;
|
|
8
|
+
reset_after_seconds: z.ZodNumber;
|
|
9
|
+
reset_at: z.ZodNumber;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
export type ChatGPTRateLimitWindow = z.infer<typeof ChatGPTRateLimitWindow>;
|
|
12
|
+
export declare const ChatGPTUsageResponse: z.ZodObject<{
|
|
13
|
+
plan_type: z.ZodString;
|
|
14
|
+
rate_limit: z.ZodObject<{
|
|
15
|
+
allowed: z.ZodBoolean;
|
|
16
|
+
limit_reached: z.ZodBoolean;
|
|
17
|
+
primary_window: z.ZodObject<{
|
|
18
|
+
used_percent: z.ZodNumber;
|
|
19
|
+
limit_window_seconds: z.ZodNumber;
|
|
20
|
+
reset_after_seconds: z.ZodNumber;
|
|
21
|
+
reset_at: z.ZodNumber;
|
|
22
|
+
}, z.core.$strip>;
|
|
23
|
+
secondary_window: z.ZodObject<{
|
|
24
|
+
used_percent: z.ZodNumber;
|
|
25
|
+
limit_window_seconds: z.ZodNumber;
|
|
26
|
+
reset_after_seconds: z.ZodNumber;
|
|
27
|
+
reset_at: z.ZodNumber;
|
|
28
|
+
}, z.core.$strip>;
|
|
29
|
+
}, z.core.$strip>;
|
|
30
|
+
credits: z.ZodNullable<z.ZodUnknown>;
|
|
31
|
+
}, z.core.$strip>;
|
|
32
|
+
export type ChatGPTUsageResponse = z.infer<typeof ChatGPTUsageResponse>;
|