axusage 2.2.0 → 3.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/README.md +61 -15
- package/dist/adapters/github-copilot.js +2 -1
- package/dist/cli.js +57 -41
- package/dist/commands/auth-clear-command.d.ts +2 -0
- package/dist/commands/auth-clear-command.js +62 -3
- package/dist/commands/auth-setup-command.d.ts +1 -0
- package/dist/commands/auth-setup-command.js +36 -5
- package/dist/commands/auth-status-command.js +36 -4
- package/dist/commands/fetch-service-usage-with-reauth.js +2 -2
- package/dist/commands/fetch-service-usage.js +4 -2
- package/dist/commands/run-auth-setup.js +26 -5
- package/dist/commands/usage-command.js +5 -3
- package/dist/config/credential-sources.js +22 -0
- package/dist/services/create-auth-context.js +2 -1
- package/dist/services/do-setup-auth.js +2 -8
- package/dist/services/persist-storage-state.d.ts +1 -1
- package/dist/services/persist-storage-state.js +8 -8
- package/dist/services/setup-auth-flow.js +38 -11
- package/dist/services/supported-service.js +4 -2
- package/dist/services/wait-for-login.d.ts +3 -2
- package/dist/services/wait-for-login.js +89 -18
- package/dist/utils/check-cli-dependency.d.ts +25 -0
- package/dist/utils/check-cli-dependency.js +81 -0
- package/dist/utils/color.d.ts +5 -0
- package/dist/utils/color.js +27 -0
- package/dist/utils/format-service-usage.js +1 -1
- package/dist/utils/resolve-prompt-capability.d.ts +1 -0
- package/dist/utils/resolve-prompt-capability.js +3 -0
- package/dist/utils/validate-root-options.d.ts +11 -0
- package/dist/utils/validate-root-options.js +18 -0
- package/dist/utils/write-atomic-json.d.ts +1 -0
- package/dist/utils/write-atomic-json.js +56 -0
- package/package.json +2 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import chalk from "chalk";
|
|
2
1
|
import { formatServiceUsageData, formatServiceUsageDataAsJson, formatServiceUsageAsTsv, toJsonObject, } from "../utils/format-service-usage.js";
|
|
3
2
|
import { formatPrometheusMetrics } from "../utils/format-prometheus-metrics.js";
|
|
4
3
|
import { fetchServiceUsage, selectServicesToQuery, } from "./fetch-service-usage.js";
|
|
5
4
|
import { fetchServiceUsageWithAutoReauth } from "./fetch-service-usage-with-reauth.js";
|
|
6
5
|
import { isAuthFailure } from "./run-auth-setup.js";
|
|
6
|
+
import { chalk } from "../utils/color.js";
|
|
7
7
|
/**
|
|
8
8
|
* Fetches usage for services using hybrid strategy:
|
|
9
9
|
* 1. Try all services in parallel first (fast path for valid credentials)
|
|
@@ -79,7 +79,9 @@ export async function usageCommand(options) {
|
|
|
79
79
|
}
|
|
80
80
|
if (!interactive && authFailureServices.size > 0) {
|
|
81
81
|
const list = [...authFailureServices].join(", ");
|
|
82
|
-
console.error(chalk.gray(`Authentication required for: ${list}.
|
|
82
|
+
console.error(chalk.gray(`Authentication required for: ${list}. ` +
|
|
83
|
+
"For GitHub Copilot, run 'axusage --auth-setup github-copilot --interactive'. " +
|
|
84
|
+
"For CLI-auth services, run the provider CLI (claude/codex/gemini), or re-run with '--interactive' to re-authenticate during fetch."));
|
|
83
85
|
if (successes.length > 0) {
|
|
84
86
|
console.error();
|
|
85
87
|
}
|
|
@@ -141,6 +143,6 @@ export async function usageCommand(options) {
|
|
|
141
143
|
}
|
|
142
144
|
}
|
|
143
145
|
if (hasPartialFailures) {
|
|
144
|
-
process.exitCode =
|
|
146
|
+
process.exitCode = 1;
|
|
145
147
|
}
|
|
146
148
|
}
|
|
@@ -26,6 +26,7 @@ function getConfig() {
|
|
|
26
26
|
if (!configInstance) {
|
|
27
27
|
configInstance = new Conf({
|
|
28
28
|
projectName: "axusage",
|
|
29
|
+
projectSuffix: "",
|
|
29
30
|
schema: {
|
|
30
31
|
sources: {
|
|
31
32
|
type: "object",
|
|
@@ -33,9 +34,30 @@ function getConfig() {
|
|
|
33
34
|
},
|
|
34
35
|
},
|
|
35
36
|
});
|
|
37
|
+
// Migration runs once per process when the config is first initialized.
|
|
38
|
+
migrateLegacySources(configInstance);
|
|
36
39
|
}
|
|
37
40
|
return configInstance;
|
|
38
41
|
}
|
|
42
|
+
function migrateLegacySources(config) {
|
|
43
|
+
// Respect explicit new config values; never overwrite them with legacy data.
|
|
44
|
+
if (config.get("sources") !== undefined)
|
|
45
|
+
return;
|
|
46
|
+
// Conf defaults to the legacy "-nodejs" suffix, which matches older configs.
|
|
47
|
+
const legacyConfig = new Conf({
|
|
48
|
+
projectName: "axusage",
|
|
49
|
+
});
|
|
50
|
+
const legacySources = legacyConfig.get("sources");
|
|
51
|
+
if (!legacySources)
|
|
52
|
+
return;
|
|
53
|
+
const parsed = SourcesConfig.safeParse(legacySources);
|
|
54
|
+
if (!parsed.success) {
|
|
55
|
+
console.error("Warning: Legacy axusage config contains invalid sources; skipping migration. Check your legacy config and migrate manually if needed.");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
config.set("sources", parsed.data);
|
|
59
|
+
console.error("Migrated credential source configuration from legacy axusage-nodejs config path.");
|
|
60
|
+
}
|
|
39
61
|
/**
|
|
40
62
|
* Get the full credential source configuration.
|
|
41
63
|
*
|
|
@@ -27,7 +27,8 @@ export async function loadStoredUserAgent(dataDirectory, service) {
|
|
|
27
27
|
export async function createAuthContext(browser, dataDirectory, service) {
|
|
28
28
|
const storageStatePath = getStorageStatePathFor(dataDirectory, service);
|
|
29
29
|
if (!existsSync(storageStatePath)) {
|
|
30
|
-
throw new Error(`No saved authentication for ${service}.
|
|
30
|
+
throw new Error(`No saved authentication for ${service}. ` +
|
|
31
|
+
`Run 'axusage --auth-setup ${service} --interactive' first.`);
|
|
31
32
|
}
|
|
32
33
|
const userAgent = await loadStoredUserAgent(dataDirectory, service);
|
|
33
34
|
return browser.newContext({ storageState: storageStatePath, userAgent });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { setupAuthInContext } from "./setup-auth-flow.js";
|
|
2
|
-
import { writeFile, chmod } from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import { getAuthMetaPathFor } from "./auth-storage-path.js";
|
|
4
|
+
import { writeAtomicJson } from "../utils/write-atomic-json.js";
|
|
5
5
|
export async function doSetupAuth(service, context, storagePath, instructions) {
|
|
6
6
|
console.error(`\n${instructions}`);
|
|
7
7
|
console.error("Waiting for login to complete (or press Enter to continue)\n");
|
|
@@ -9,13 +9,7 @@ export async function doSetupAuth(service, context, storagePath, instructions) {
|
|
|
9
9
|
try {
|
|
10
10
|
if (userAgent) {
|
|
11
11
|
const metaPath = getAuthMetaPathFor(path.dirname(storagePath), service);
|
|
12
|
-
await
|
|
13
|
-
try {
|
|
14
|
-
await chmod(metaPath, 0o600);
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
// best effort
|
|
18
|
-
}
|
|
12
|
+
await writeAtomicJson(metaPath, { userAgent }, 0o600);
|
|
19
13
|
}
|
|
20
14
|
}
|
|
21
15
|
catch {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BrowserContext } from "playwright";
|
|
2
2
|
/**
|
|
3
3
|
* Persist context storage state to disk with secure permissions (0o600).
|
|
4
|
-
* Errors are
|
|
4
|
+
* Errors are logged as warnings to avoid blocking the main operation.
|
|
5
5
|
*/
|
|
6
6
|
export declare function persistStorageState(context: BrowserContext, storagePath: string): Promise<void>;
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { writeAtomicJson } from "../utils/write-atomic-json.js";
|
|
2
|
+
import { chalk } from "../utils/color.js";
|
|
2
3
|
/**
|
|
3
4
|
* Persist context storage state to disk with secure permissions (0o600).
|
|
4
|
-
* Errors are
|
|
5
|
+
* Errors are logged as warnings to avoid blocking the main operation.
|
|
5
6
|
*/
|
|
6
7
|
export async function persistStorageState(context, storagePath) {
|
|
7
8
|
try {
|
|
8
|
-
await context.storageState(
|
|
9
|
-
await
|
|
10
|
-
// best effort: permissions may already be correct or OS may ignore
|
|
11
|
-
});
|
|
9
|
+
const state = await context.storageState();
|
|
10
|
+
await writeAtomicJson(storagePath, state, 0o600);
|
|
12
11
|
}
|
|
13
|
-
catch {
|
|
14
|
-
|
|
12
|
+
catch (error) {
|
|
13
|
+
const details = error instanceof Error ? error.message : String(error);
|
|
14
|
+
console.error(chalk.yellow(`Warning: Failed to persist auth state to ${storagePath} (${details}).`));
|
|
15
15
|
}
|
|
16
16
|
}
|
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
import { getServiceAuthConfig } from "./service-auth-configs.js";
|
|
2
2
|
import { waitForLogin } from "./wait-for-login.js";
|
|
3
3
|
import { verifySessionByFetching } from "./verify-session.js";
|
|
4
|
-
import {
|
|
4
|
+
import { writeAtomicJson } from "../utils/write-atomic-json.js";
|
|
5
|
+
function describeLoginOutcome(outcome) {
|
|
6
|
+
switch (outcome) {
|
|
7
|
+
case "manual": {
|
|
8
|
+
return "after manual continuation";
|
|
9
|
+
}
|
|
10
|
+
case "timeout": {
|
|
11
|
+
return "after login timeout";
|
|
12
|
+
}
|
|
13
|
+
case "closed": {
|
|
14
|
+
return "after the browser window closed";
|
|
15
|
+
}
|
|
16
|
+
case "aborted": {
|
|
17
|
+
return "after prompt cancellation";
|
|
18
|
+
}
|
|
19
|
+
case "selector": {
|
|
20
|
+
return "after detecting a login signal";
|
|
21
|
+
}
|
|
22
|
+
case "skipped": {
|
|
23
|
+
return "without waiting for a login signal";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
5
27
|
export async function setupAuthInContext(service, context, storagePath) {
|
|
6
28
|
const page = await context.newPage();
|
|
7
29
|
try {
|
|
@@ -9,24 +31,27 @@ export async function setupAuthInContext(service, context, storagePath) {
|
|
|
9
31
|
await page.goto(config.url);
|
|
10
32
|
const selectors = config.waitForSelectors ??
|
|
11
33
|
(config.waitForSelector ? [config.waitForSelector] : []);
|
|
12
|
-
await waitForLoginForService(page, selectors);
|
|
34
|
+
const loginOutcome = await waitForLoginForService(page, selectors);
|
|
35
|
+
const outcomeLabel = describeLoginOutcome(loginOutcome);
|
|
36
|
+
if (loginOutcome === "aborted") {
|
|
37
|
+
throw new Error("Authentication was canceled. Authentication was not saved.");
|
|
38
|
+
}
|
|
13
39
|
if (config.verifyUrl) {
|
|
14
40
|
const ok = config.verifyFunction
|
|
15
41
|
? await config.verifyFunction(context, config.verifyUrl)
|
|
16
42
|
: await verifySessionByFetching(context, config.verifyUrl);
|
|
17
43
|
if (!ok) {
|
|
18
|
-
|
|
44
|
+
throw new Error(`Unable to verify session via ${config.verifyUrl} ${outcomeLabel}. Authentication was not saved. Ensure login completed successfully and retry.`);
|
|
19
45
|
}
|
|
20
46
|
}
|
|
47
|
+
else if (selectors.length > 0 && loginOutcome !== "selector") {
|
|
48
|
+
// Without a verification URL, we only persist when a login selector confirms success.
|
|
49
|
+
throw new Error(`Login was not confirmed ${outcomeLabel}. Authentication was not saved.`);
|
|
50
|
+
}
|
|
21
51
|
// Capture user agent for future headless contexts
|
|
22
52
|
const userAgent = await page.evaluate(() => navigator.userAgent);
|
|
23
|
-
await context.storageState(
|
|
24
|
-
|
|
25
|
-
await chmod(storagePath, 0o600);
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
// best effort to restrict sensitive storage state
|
|
29
|
-
}
|
|
53
|
+
const state = await context.storageState();
|
|
54
|
+
await writeAtomicJson(storagePath, state, 0o600);
|
|
30
55
|
return userAgent;
|
|
31
56
|
}
|
|
32
57
|
finally {
|
|
@@ -35,6 +60,8 @@ export async function setupAuthInContext(service, context, storagePath) {
|
|
|
35
60
|
}
|
|
36
61
|
async function waitForLoginForService(page, selectors) {
|
|
37
62
|
if (selectors.length > 0) {
|
|
38
|
-
|
|
63
|
+
return waitForLogin(page, selectors);
|
|
39
64
|
}
|
|
65
|
+
// When no selectors are configured, skip waiting and rely on verification if available.
|
|
66
|
+
return "skipped";
|
|
40
67
|
}
|
|
@@ -6,11 +6,13 @@ export const SUPPORTED_SERVICES = [
|
|
|
6
6
|
];
|
|
7
7
|
export function validateService(service) {
|
|
8
8
|
if (!service) {
|
|
9
|
-
throw new Error(`Service is required. Supported services: ${SUPPORTED_SERVICES.join(", ")}`
|
|
9
|
+
throw new Error(`Service is required. Supported services: ${SUPPORTED_SERVICES.join(", ")}. ` +
|
|
10
|
+
"Run 'axusage --help' for usage.");
|
|
10
11
|
}
|
|
11
12
|
const normalizedService = service.toLowerCase();
|
|
12
13
|
if (!SUPPORTED_SERVICES.includes(normalizedService)) {
|
|
13
|
-
throw new Error(`Unsupported service: ${service}. Supported services: ${SUPPORTED_SERVICES.join(", ")}`
|
|
14
|
+
throw new Error(`Unsupported service: ${service}. Supported services: ${SUPPORTED_SERVICES.join(", ")}. ` +
|
|
15
|
+
"Run 'axusage --help' for usage.");
|
|
14
16
|
}
|
|
15
17
|
return normalizedService;
|
|
16
18
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Page } from "playwright";
|
|
2
2
|
/**
|
|
3
3
|
* Waits until one of the selectors appears on the page, or the user presses Enter to continue.
|
|
4
4
|
*/
|
|
5
|
-
export
|
|
5
|
+
export type LoginWaitOutcome = "selector" | "manual" | "timeout" | "closed" | "aborted" | "skipped";
|
|
6
|
+
export declare function waitForLogin(page: Page, selectors: readonly string[]): Promise<LoginWaitOutcome>;
|
|
@@ -1,24 +1,73 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { stdin as input, stdout as output } from "node:process";
|
|
1
|
+
import { errors } from "playwright";
|
|
3
2
|
import { LOGIN_TIMEOUT_MS } from "./auth-timeouts.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import { input } from "@inquirer/prompts";
|
|
4
|
+
function isTimeoutError(error) {
|
|
5
|
+
return error instanceof errors.TimeoutError;
|
|
6
|
+
}
|
|
7
|
+
const SELECTOR_CLOSED_MESSAGES = [
|
|
8
|
+
"target closed",
|
|
9
|
+
"page closed",
|
|
10
|
+
"context closed",
|
|
11
|
+
"execution context was destroyed",
|
|
12
|
+
];
|
|
13
|
+
function isSelectorClosedError(error) {
|
|
14
|
+
if (!(error instanceof Error))
|
|
15
|
+
return false;
|
|
16
|
+
const message = error.message.toLowerCase();
|
|
17
|
+
return SELECTOR_CLOSED_MESSAGES.some((snippet) => message.includes(snippet));
|
|
18
|
+
}
|
|
19
|
+
function classifySelectorFailure(error) {
|
|
20
|
+
if (isTimeoutError(error))
|
|
21
|
+
return "timeout";
|
|
22
|
+
if (isSelectorClosedError(error))
|
|
23
|
+
return "closed";
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
function classifySelectorAggregate(error) {
|
|
27
|
+
if (error instanceof AggregateError) {
|
|
28
|
+
const outcomes = error.errors.map((item) => classifySelectorFailure(item));
|
|
29
|
+
if (outcomes.every((item) => item === "timeout"))
|
|
30
|
+
return "timeout";
|
|
31
|
+
if (outcomes.every((item) => item === "timeout" || item === "closed") &&
|
|
32
|
+
outcomes.includes("closed")) {
|
|
33
|
+
return "closed";
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
return classifySelectorFailure(error);
|
|
38
|
+
}
|
|
7
39
|
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
40
|
const timeoutMs = LOGIN_TIMEOUT_MS;
|
|
14
|
-
const
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
const
|
|
41
|
+
const canPrompt = process.stdin.isTTY && process.stdout.isTTY;
|
|
42
|
+
// Non-TTY sessions rely solely on selector waits (no manual continuation).
|
|
43
|
+
if (!canPrompt && selectors.length === 0) {
|
|
44
|
+
return "skipped";
|
|
45
|
+
}
|
|
46
|
+
const waiters = selectors.map((sel) => page.waitForSelector(sel, { timeout: timeoutMs }));
|
|
47
|
+
const shouldShowCountdown = process.stderr.isTTY && waiters.length > 0;
|
|
20
48
|
let interval;
|
|
49
|
+
const manualController = canPrompt ? new AbortController() : undefined;
|
|
50
|
+
const manualPromise = manualController
|
|
51
|
+
? input({
|
|
52
|
+
message: "Press Enter after completing login in the browser...",
|
|
53
|
+
default: "",
|
|
54
|
+
}, { signal: manualController.signal })
|
|
55
|
+
.then(() => "manual")
|
|
56
|
+
.catch((error) => {
|
|
57
|
+
if (error instanceof Error &&
|
|
58
|
+
(error.name === "AbortPromptError" || error.name === "AbortError")) {
|
|
59
|
+
// Expected when we cancel the prompt after a selector wins.
|
|
60
|
+
// Returning "manual" keeps the promise resolved for the race.
|
|
61
|
+
return "manual";
|
|
62
|
+
}
|
|
63
|
+
if (error instanceof Error && error.name === "ExitPromptError") {
|
|
64
|
+
return "aborted";
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
})
|
|
68
|
+
: undefined;
|
|
21
69
|
if (shouldShowCountdown) {
|
|
70
|
+
const deadline = Date.now() + timeoutMs;
|
|
22
71
|
interval = setInterval(() => {
|
|
23
72
|
const remaining = deadline - Date.now();
|
|
24
73
|
if (remaining <= 0) {
|
|
@@ -34,11 +83,33 @@ export async function waitForLogin(page, selectors) {
|
|
|
34
83
|
}, 60_000);
|
|
35
84
|
}
|
|
36
85
|
try {
|
|
37
|
-
|
|
86
|
+
const selectorPromise = waiters.length > 0
|
|
87
|
+
? Promise.any(waiters)
|
|
88
|
+
.then(() => "selector")
|
|
89
|
+
.catch((error) => {
|
|
90
|
+
// Promise.any only rejects once all selectors have settled.
|
|
91
|
+
const outcome = classifySelectorAggregate(error);
|
|
92
|
+
if (outcome)
|
|
93
|
+
return outcome;
|
|
94
|
+
throw error;
|
|
95
|
+
})
|
|
96
|
+
: undefined;
|
|
97
|
+
if (selectorPromise) {
|
|
98
|
+
// Avoid unhandled rejections if the manual prompt wins the race.
|
|
99
|
+
void selectorPromise.catch(() => { });
|
|
100
|
+
}
|
|
101
|
+
const raceTargets = [];
|
|
102
|
+
if (manualPromise)
|
|
103
|
+
raceTargets.push(manualPromise);
|
|
104
|
+
if (selectorPromise)
|
|
105
|
+
raceTargets.push(selectorPromise);
|
|
106
|
+
if (raceTargets.length === 0)
|
|
107
|
+
return "skipped";
|
|
108
|
+
return await Promise.race(raceTargets);
|
|
38
109
|
}
|
|
39
110
|
finally {
|
|
40
111
|
if (interval)
|
|
41
112
|
clearInterval(interval);
|
|
42
|
-
|
|
113
|
+
manualController?.abort();
|
|
43
114
|
}
|
|
44
115
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
type CliDependency = {
|
|
2
|
+
readonly command: string;
|
|
3
|
+
readonly envVar: string;
|
|
4
|
+
readonly installHint: string;
|
|
5
|
+
};
|
|
6
|
+
declare const AUTH_CLI_SERVICES: readonly ["claude", "chatgpt", "gemini"];
|
|
7
|
+
type AuthCliService = (typeof AUTH_CLI_SERVICES)[number];
|
|
8
|
+
export declare function getAuthCliDependency(service: AuthCliService): CliDependency;
|
|
9
|
+
export declare function checkCliDependency(dep: CliDependency): {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
path: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function ensureAuthCliDependency(service: AuthCliService): {
|
|
14
|
+
ok: true;
|
|
15
|
+
path: string;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
dependency: CliDependency;
|
|
19
|
+
path: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function resolveAuthCliDependencyOrReport(service: AuthCliService, options?: {
|
|
22
|
+
readonly setExitCode?: boolean;
|
|
23
|
+
}): string | undefined;
|
|
24
|
+
export { AUTH_CLI_SERVICES };
|
|
25
|
+
export type { AuthCliService };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { chalk } from "./color.js";
|
|
3
|
+
const CLI_DEPENDENCIES = {
|
|
4
|
+
claude: {
|
|
5
|
+
command: "claude",
|
|
6
|
+
envVar: "AXUSAGE_CLAUDE_PATH",
|
|
7
|
+
installHint: "npm install -g @anthropic-ai/claude-code",
|
|
8
|
+
},
|
|
9
|
+
codex: {
|
|
10
|
+
command: "codex",
|
|
11
|
+
envVar: "AXUSAGE_CODEX_PATH",
|
|
12
|
+
installHint: "npm install -g @openai/codex",
|
|
13
|
+
},
|
|
14
|
+
gemini: {
|
|
15
|
+
command: "gemini",
|
|
16
|
+
envVar: "AXUSAGE_GEMINI_PATH",
|
|
17
|
+
installHint: "npm install -g @google/gemini-cli",
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const AUTH_CLI_SERVICES = ["claude", "chatgpt", "gemini"];
|
|
21
|
+
function resolveCliDependencyTimeout() {
|
|
22
|
+
const raw = process.env.AXUSAGE_CLI_TIMEOUT_MS;
|
|
23
|
+
if (!raw)
|
|
24
|
+
return 5000;
|
|
25
|
+
const parsed = Number(raw);
|
|
26
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
27
|
+
return 5000;
|
|
28
|
+
return Math.round(parsed);
|
|
29
|
+
}
|
|
30
|
+
export function getAuthCliDependency(service) {
|
|
31
|
+
if (service === "chatgpt")
|
|
32
|
+
return CLI_DEPENDENCIES.codex;
|
|
33
|
+
return CLI_DEPENDENCIES[service];
|
|
34
|
+
}
|
|
35
|
+
function resolveCliDependencyPath(dep) {
|
|
36
|
+
const environmentValue = process.env[dep.envVar]?.trim();
|
|
37
|
+
// Treat empty env vars as unset to fall back to the default command.
|
|
38
|
+
if (environmentValue)
|
|
39
|
+
return environmentValue;
|
|
40
|
+
return dep.command;
|
|
41
|
+
}
|
|
42
|
+
export function checkCliDependency(dep) {
|
|
43
|
+
const path = resolveCliDependencyPath(dep);
|
|
44
|
+
try {
|
|
45
|
+
const timeout = resolveCliDependencyTimeout();
|
|
46
|
+
execFileSync(path, ["--version"], {
|
|
47
|
+
stdio: "ignore",
|
|
48
|
+
timeout,
|
|
49
|
+
});
|
|
50
|
+
return { ok: true, path };
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return { ok: false, path };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function ensureAuthCliDependency(service) {
|
|
57
|
+
const dependency = getAuthCliDependency(service);
|
|
58
|
+
const result = checkCliDependency(dependency);
|
|
59
|
+
if (result.ok)
|
|
60
|
+
return { ok: true, path: result.path };
|
|
61
|
+
return { ok: false, dependency, path: result.path };
|
|
62
|
+
}
|
|
63
|
+
export function resolveAuthCliDependencyOrReport(service, options = {}) {
|
|
64
|
+
const result = ensureAuthCliDependency(service);
|
|
65
|
+
if (!result.ok) {
|
|
66
|
+
reportMissingCliDependency(result.dependency, result.path);
|
|
67
|
+
if (options.setExitCode)
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return result.path;
|
|
72
|
+
}
|
|
73
|
+
function reportMissingCliDependency(dependency, path) {
|
|
74
|
+
console.error(chalk.red(`Error: Required dependency '${dependency.command}' not found.`));
|
|
75
|
+
console.error(chalk.gray(`Looked for: ${path}`));
|
|
76
|
+
console.error(chalk.gray("\nTo fix, either:"));
|
|
77
|
+
console.error(chalk.gray(` 1. Install it: ${dependency.installHint}`));
|
|
78
|
+
console.error(chalk.gray(` 2. Set ${dependency.envVar}=/path/to/${dependency.command}`));
|
|
79
|
+
console.error(chalk.gray("Try 'axusage --help' for requirements and overrides."));
|
|
80
|
+
}
|
|
81
|
+
export { AUTH_CLI_SERVICES };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import chalkBase from "chalk";
|
|
2
|
+
export { default as chalk } from "chalk";
|
|
3
|
+
function resolveColorOverride(enabled) {
|
|
4
|
+
if (enabled === false)
|
|
5
|
+
return "disable";
|
|
6
|
+
if (enabled === true)
|
|
7
|
+
return "force";
|
|
8
|
+
if (process.env.FORCE_COLOR === "0")
|
|
9
|
+
return "disable";
|
|
10
|
+
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") {
|
|
11
|
+
return "disable";
|
|
12
|
+
}
|
|
13
|
+
return "auto";
|
|
14
|
+
}
|
|
15
|
+
const autoLevel = chalkBase.level;
|
|
16
|
+
export function configureColor(config = {}) {
|
|
17
|
+
const mode = resolveColorOverride(config.enabled);
|
|
18
|
+
if (mode === "disable") {
|
|
19
|
+
chalkBase.level = 0;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (mode === "force") {
|
|
23
|
+
chalkBase.level = 3;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
chalkBase.level = autoLevel;
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function resolvePromptCapability(): boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { UsageCommandOptions } from "../commands/fetch-service-usage.js";
|
|
2
|
+
export type RootOptions = {
|
|
3
|
+
readonly authSetup?: string;
|
|
4
|
+
readonly authStatus?: string | boolean;
|
|
5
|
+
readonly authClear?: string;
|
|
6
|
+
readonly force?: boolean;
|
|
7
|
+
readonly service?: UsageCommandOptions["service"];
|
|
8
|
+
readonly format?: UsageCommandOptions["format"];
|
|
9
|
+
readonly interactive?: UsageCommandOptions["interactive"];
|
|
10
|
+
};
|
|
11
|
+
export declare function getRootOptionsError(options: RootOptions, formatSource?: string): string | undefined;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function getRootOptionsError(options, formatSource) {
|
|
2
|
+
// Commander sets optional args to `true` when provided without a value.
|
|
3
|
+
const authSelectionCount = Number(Boolean(options.authSetup)) +
|
|
4
|
+
Number(options.authStatus !== undefined) +
|
|
5
|
+
Number(Boolean(options.authClear));
|
|
6
|
+
if (authSelectionCount > 1) {
|
|
7
|
+
return "Use only one of --auth-setup, --auth-status, or --auth-clear.";
|
|
8
|
+
}
|
|
9
|
+
if (options.force && !options.authClear) {
|
|
10
|
+
return "--force is only supported with --auth-clear.";
|
|
11
|
+
}
|
|
12
|
+
const hasExplicitFormat = formatSource === "cli";
|
|
13
|
+
const hasUsageOptions = Boolean(options.service) || hasExplicitFormat;
|
|
14
|
+
if (authSelectionCount > 0 && hasUsageOptions) {
|
|
15
|
+
return "Usage options cannot be combined with auth operations.";
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function writeAtomicJson(filePath: string, data: unknown, mode?: number): Promise<void>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { chmod, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
function getErrorCode(error) {
|
|
4
|
+
if (error instanceof Error && "code" in error) {
|
|
5
|
+
return error.code;
|
|
6
|
+
}
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
export async function writeAtomicJson(filePath, data, mode) {
|
|
10
|
+
const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
|
|
11
|
+
const writeOptions = mode === undefined ? "utf8" : { encoding: "utf8", mode };
|
|
12
|
+
await writeFile(temporaryPath, JSON.stringify(data), writeOptions);
|
|
13
|
+
if (mode !== undefined) {
|
|
14
|
+
await chmod(temporaryPath, mode).catch(() => {
|
|
15
|
+
// Best-effort: some filesystems ignore chmod, but the mode was set at write.
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
await rename(temporaryPath, filePath);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
const code = getErrorCode(error);
|
|
23
|
+
if (code === "EPERM" || code === "EACCES" || code === "EEXIST") {
|
|
24
|
+
// Windows can reject rename over an existing file; fall back to a backup swap.
|
|
25
|
+
// Best-effort: not fully atomic and assumes a single writer. Backups are
|
|
26
|
+
// cleaned up by auth-clear when possible.
|
|
27
|
+
const backupPath = `${filePath}.${randomUUID()}.bak`;
|
|
28
|
+
let hasBackup = false;
|
|
29
|
+
try {
|
|
30
|
+
await rename(filePath, backupPath);
|
|
31
|
+
hasBackup = true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Best-effort: source file may not exist or be locked.
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
await rename(temporaryPath, filePath);
|
|
38
|
+
}
|
|
39
|
+
catch (fallbackError) {
|
|
40
|
+
if (hasBackup) {
|
|
41
|
+
await rename(backupPath, filePath).catch(() => {
|
|
42
|
+
console.warn(`Warning: Failed to restore backup from ${backupPath}`);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
await unlink(temporaryPath).catch(() => { });
|
|
46
|
+
throw fallbackError;
|
|
47
|
+
}
|
|
48
|
+
if (hasBackup) {
|
|
49
|
+
await unlink(backupPath).catch(() => { });
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
await unlink(temporaryPath).catch(() => { });
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "axusage",
|
|
3
3
|
"author": "Łukasz Jerciński",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "3.0.0",
|
|
6
6
|
"description": "Monitor API usage across Claude, ChatGPT, GitHub Copilot, and Gemini from a single CLI",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@commander-js/extra-typings": "^14.0.0",
|
|
57
|
+
"@inquirer/prompts": "^8.2.0",
|
|
57
58
|
"axauth": "^1.11.2",
|
|
58
59
|
"chalk": "^5.6.2",
|
|
59
60
|
"commander": "^14.0.2",
|