axusage 2.1.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.
Files changed (39) hide show
  1. package/README.md +61 -15
  2. package/dist/adapters/chatgpt.js +2 -2
  3. package/dist/adapters/claude.js +2 -2
  4. package/dist/adapters/gemini.js +2 -2
  5. package/dist/adapters/github-copilot.js +2 -1
  6. package/dist/cli.js +57 -41
  7. package/dist/commands/auth-clear-command.d.ts +2 -0
  8. package/dist/commands/auth-clear-command.js +62 -3
  9. package/dist/commands/auth-setup-command.d.ts +1 -0
  10. package/dist/commands/auth-setup-command.js +36 -5
  11. package/dist/commands/auth-status-command.js +36 -4
  12. package/dist/commands/fetch-service-usage-with-reauth.js +2 -2
  13. package/dist/commands/fetch-service-usage.js +4 -2
  14. package/dist/commands/run-auth-setup.js +26 -5
  15. package/dist/commands/usage-command.js +5 -3
  16. package/dist/config/credential-sources.d.ts +38 -0
  17. package/dist/config/credential-sources.js +117 -0
  18. package/dist/services/create-auth-context.js +2 -1
  19. package/dist/services/do-setup-auth.js +2 -8
  20. package/dist/services/get-service-access-token.d.ts +28 -0
  21. package/dist/services/get-service-access-token.js +146 -0
  22. package/dist/services/persist-storage-state.d.ts +1 -1
  23. package/dist/services/persist-storage-state.js +8 -8
  24. package/dist/services/setup-auth-flow.js +38 -11
  25. package/dist/services/supported-service.js +4 -2
  26. package/dist/services/wait-for-login.d.ts +3 -2
  27. package/dist/services/wait-for-login.js +89 -18
  28. package/dist/utils/check-cli-dependency.d.ts +25 -0
  29. package/dist/utils/check-cli-dependency.js +81 -0
  30. package/dist/utils/color.d.ts +5 -0
  31. package/dist/utils/color.js +27 -0
  32. package/dist/utils/format-service-usage.js +1 -1
  33. package/dist/utils/resolve-prompt-capability.d.ts +1 -0
  34. package/dist/utils/resolve-prompt-capability.js +3 -0
  35. package/dist/utils/validate-root-options.d.ts +11 -0
  36. package/dist/utils/validate-root-options.js +18 -0
  37. package/dist/utils/write-atomic-json.d.ts +1 -0
  38. package/dist/utils/write-atomic-json.js +56 -0
  39. package/package.json +11 -15
@@ -1,5 +1,7 @@
1
- import chalk from "chalk";
2
1
  import { BrowserAuthManager } from "../services/browser-auth-manager.js";
2
+ import { resolveAuthCliDependencyOrReport } from "../utils/check-cli-dependency.js";
3
+ import { chalk } from "../utils/color.js";
4
+ import { resolvePromptCapability } from "../utils/resolve-prompt-capability.js";
3
5
  /** Timeout for authentication setup (5 minutes) */
4
6
  const AUTH_SETUP_TIMEOUT_MS = 300_000;
5
7
  /**
@@ -46,31 +48,46 @@ export function isAuthFailure(result) {
46
48
  export async function runAuthSetup(service) {
47
49
  // CLI-based auth cannot use browser auth flow
48
50
  if (service === "gemini") {
51
+ const cliPath = resolveAuthCliDependencyOrReport("gemini");
52
+ if (!cliPath)
53
+ return false;
49
54
  console.error(chalk.yellow("\nGemini uses CLI-based authentication managed by the Gemini CLI."));
50
55
  console.error(chalk.gray("\nTo re-authenticate, run:"));
51
- console.error(chalk.cyan(" gemini"));
56
+ console.error(chalk.cyan(` ${cliPath}`));
52
57
  console.error(chalk.gray("\nThe Gemini CLI will guide you through the OAuth login process.\n"));
53
58
  return false;
54
59
  }
55
60
  if (service === "claude") {
61
+ const cliPath = resolveAuthCliDependencyOrReport("claude");
62
+ if (!cliPath)
63
+ return false;
56
64
  console.error(chalk.yellow("\nClaude uses CLI-based authentication managed by Claude Code."));
57
65
  console.error(chalk.gray("\nTo re-authenticate, run:"));
58
- console.error(chalk.cyan(" claude"));
66
+ console.error(chalk.cyan(` ${cliPath}`));
59
67
  console.error(chalk.gray("\nClaude Code will guide you through authentication.\n"));
60
68
  return false;
61
69
  }
62
70
  if (service === "chatgpt") {
71
+ const cliPath = resolveAuthCliDependencyOrReport("chatgpt");
72
+ if (!cliPath)
73
+ return false;
63
74
  console.error(chalk.yellow("\nChatGPT uses CLI-based authentication managed by Codex."));
64
75
  console.error(chalk.gray("\nTo re-authenticate, run:"));
65
- console.error(chalk.cyan(" codex"));
76
+ console.error(chalk.cyan(` ${cliPath}`));
66
77
  console.error(chalk.gray("\nCodex will guide you through authentication.\n"));
67
78
  return false;
68
79
  }
80
+ if (!resolvePromptCapability()) {
81
+ console.error(chalk.red("Error: Interactive authentication requires a TTY terminal."));
82
+ console.error(chalk.gray("Re-run in a TTY terminal (avoid piping stdin/stdout) with --interactive to complete authentication."));
83
+ return false;
84
+ }
69
85
  const manager = new BrowserAuthManager({ headless: false });
86
+ let setupPromise;
70
87
  let timeoutId;
71
88
  try {
72
89
  console.error(chalk.blue(`\nOpening browser for ${service} authentication...\n`));
73
- const setupPromise = manager.setupAuth(service);
90
+ setupPromise = manager.setupAuth(service);
74
91
  const timeoutPromise = new Promise((_, reject) => {
75
92
  timeoutId = setTimeout(() => {
76
93
  reject(new Error("Authentication setup timed out after 5 minutes"));
@@ -86,6 +103,10 @@ export async function runAuthSetup(service) {
86
103
  }
87
104
  finally {
88
105
  clearTimeout(timeoutId);
106
+ if (setupPromise) {
107
+ // Avoid unhandled rejections if the timeout wins the race.
108
+ void setupPromise.catch(() => { });
109
+ }
89
110
  await manager.close();
90
111
  }
91
112
  }
@@ -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}. Run 'axusage auth setup <service>' or re-run with '--interactive' to re-authenticate during fetch.`));
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 = 2;
146
+ process.exitCode = 1;
145
147
  }
146
148
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Configuration for credential sources per service.
3
+ *
4
+ * Supports three modes:
5
+ * - "local": Use local credentials from axauth (default behavior)
6
+ * - "vault": Fetch credentials from axvault server
7
+ * - "auto": Try vault first if configured and credential name provided, fallback to local
8
+ */
9
+ import { z } from "zod";
10
+ /** Credential source type */
11
+ declare const CredentialSourceType: z.ZodEnum<{
12
+ auto: "auto";
13
+ local: "local";
14
+ vault: "vault";
15
+ }>;
16
+ type CredentialSourceType = z.infer<typeof CredentialSourceType>;
17
+ /** Resolved source config with normalized fields */
18
+ interface ResolvedSourceConfig {
19
+ source: CredentialSourceType;
20
+ name: string | undefined;
21
+ }
22
+ /** Service IDs that support vault credentials (API-based services) */
23
+ type VaultSupportedServiceId = "claude" | "chatgpt" | "gemini";
24
+ /**
25
+ * All service IDs.
26
+ * Note: github-copilot uses GitHub token auth, not vault credentials,
27
+ * so it's excluded from VaultSupportedServiceId.
28
+ */
29
+ type ServiceId = VaultSupportedServiceId | "github-copilot";
30
+ /**
31
+ * Get the resolved source config for a specific service.
32
+ *
33
+ * @param service - Service ID (e.g., "claude", "chatgpt", "gemini")
34
+ * @returns Resolved config with source type and optional credential name
35
+ */
36
+ declare function getServiceSourceConfig(service: ServiceId): ResolvedSourceConfig;
37
+ export type { ServiceId, VaultSupportedServiceId };
38
+ export { getServiceSourceConfig };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Configuration for credential sources per service.
3
+ *
4
+ * Supports three modes:
5
+ * - "local": Use local credentials from axauth (default behavior)
6
+ * - "vault": Fetch credentials from axvault server
7
+ * - "auto": Try vault first if configured and credential name provided, fallback to local
8
+ */
9
+ import Conf from "conf";
10
+ import { z } from "zod";
11
+ /** Credential source type */
12
+ const CredentialSourceType = z.enum(["auto", "local", "vault"]);
13
+ /** Service source config - either a string shorthand or object with name */
14
+ const ServiceSourceConfig = z.union([
15
+ CredentialSourceType,
16
+ z.object({
17
+ source: CredentialSourceType,
18
+ name: z.string().optional(),
19
+ }),
20
+ ]);
21
+ /** Full sources config - map of service ID to source config */
22
+ const SourcesConfig = z.record(z.string(), ServiceSourceConfig);
23
+ // Lazy-initialized config instance
24
+ let configInstance;
25
+ function getConfig() {
26
+ if (!configInstance) {
27
+ configInstance = new Conf({
28
+ projectName: "axusage",
29
+ projectSuffix: "",
30
+ schema: {
31
+ sources: {
32
+ type: "object",
33
+ additionalProperties: true,
34
+ },
35
+ },
36
+ });
37
+ // Migration runs once per process when the config is first initialized.
38
+ migrateLegacySources(configInstance);
39
+ }
40
+ return configInstance;
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
+ }
61
+ /**
62
+ * Get the full credential source configuration.
63
+ *
64
+ * Priority:
65
+ * 1. AXUSAGE_SOURCES environment variable (flat JSON)
66
+ * 2. Config file sources key
67
+ * 3. Empty object (defaults apply per-service)
68
+ */
69
+ function getCredentialSourceConfig() {
70
+ // Priority 1: Environment variable
71
+ const environmentVariable = process.env.AXUSAGE_SOURCES;
72
+ if (environmentVariable) {
73
+ try {
74
+ const parsed = SourcesConfig.parse(JSON.parse(environmentVariable));
75
+ return parsed;
76
+ }
77
+ catch (error) {
78
+ const reason = error instanceof SyntaxError
79
+ ? "invalid JSON syntax"
80
+ : "schema validation failed";
81
+ console.error(`Warning: AXUSAGE_SOURCES ${reason}, falling back to config file`);
82
+ }
83
+ }
84
+ // Priority 2: Config file
85
+ const config = getConfig();
86
+ const fileConfig = config.get("sources");
87
+ if (fileConfig) {
88
+ const parsed = SourcesConfig.safeParse(fileConfig);
89
+ if (parsed.success) {
90
+ return parsed.data;
91
+ }
92
+ console.error("Warning: Config file contains invalid sources, using defaults");
93
+ }
94
+ // Priority 3: Empty (defaults apply)
95
+ return {};
96
+ }
97
+ /**
98
+ * Get the resolved source config for a specific service.
99
+ *
100
+ * @param service - Service ID (e.g., "claude", "chatgpt", "gemini")
101
+ * @returns Resolved config with source type and optional credential name
102
+ */
103
+ function getServiceSourceConfig(service) {
104
+ const config = getCredentialSourceConfig();
105
+ const serviceConfig = config[service];
106
+ // Default: auto mode with no credential name
107
+ if (serviceConfig === undefined) {
108
+ return { source: "auto", name: undefined };
109
+ }
110
+ // String shorthand: just the source type
111
+ if (typeof serviceConfig === "string") {
112
+ return { source: serviceConfig, name: undefined };
113
+ }
114
+ // Object: source and name
115
+ return { source: serviceConfig.source, name: serviceConfig.name };
116
+ }
117
+ export { getServiceSourceConfig };
@@ -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}. Run 'axusage auth setup ${service}' first.`);
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 writeFile(metaPath, JSON.stringify({ userAgent }), "utf8");
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 {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Unified credential fetcher for services.
3
+ *
4
+ * Fetches access tokens based on per-service configuration:
5
+ * - "local": From local axauth credential store
6
+ * - "vault": From axvault server
7
+ * - "auto": Try vault first if configured, fallback to local
8
+ */
9
+ import { type VaultSupportedServiceId } from "../config/credential-sources.js";
10
+ /**
11
+ * Get access token for a service.
12
+ *
13
+ * Uses the configured credential source for the service:
14
+ * - "local": Fetch from local axauth credential store
15
+ * - "vault": Fetch from axvault server (requires credential name)
16
+ * - "auto": Try vault if configured and name provided, fallback to local
17
+ *
18
+ * @param service - Service ID (e.g., "claude", "chatgpt", "gemini")
19
+ * @returns Access token string or undefined if not available
20
+ *
21
+ * @example
22
+ * const token = await getServiceAccessToken("claude");
23
+ * if (!token) {
24
+ * console.error("No credentials found for Claude");
25
+ * }
26
+ */
27
+ declare function getServiceAccessToken(service: VaultSupportedServiceId): Promise<string | undefined>;
28
+ export { getServiceAccessToken };
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Unified credential fetcher for services.
3
+ *
4
+ * Fetches access tokens based on per-service configuration:
5
+ * - "local": From local axauth credential store
6
+ * - "vault": From axvault server
7
+ * - "auto": Try vault first if configured, fallback to local
8
+ */
9
+ import { fetchVaultCredentials, getAgentAccessToken, isVaultConfigured, } from "axauth";
10
+ import { getServiceSourceConfig, } from "../config/credential-sources.js";
11
+ /** Map service IDs to agent IDs for axauth/vault */
12
+ const SERVICE_TO_AGENT = {
13
+ claude: "claude",
14
+ chatgpt: "codex", // ChatGPT and Codex both use OpenAI API credentials
15
+ gemini: "gemini",
16
+ };
17
+ /**
18
+ * Extract access token from vault credentials.
19
+ *
20
+ * Different credential types store tokens differently:
21
+ * - oauth-credentials: access_token (Gemini style) or tokens.access_token (Codex/OpenAI style)
22
+ * - oauth-token: accessToken field (Claude style)
23
+ * - api-key: apiKey field
24
+ */
25
+ function extractAccessToken(credentials) {
26
+ if (!credentials)
27
+ return undefined;
28
+ const { data } = credentials;
29
+ // Try accessToken first (Claude oauth-token style, camelCase)
30
+ if (typeof data.accessToken === "string") {
31
+ return data.accessToken;
32
+ }
33
+ // Try access_token at top level (Gemini oauth-credentials style, snake_case)
34
+ if (typeof data.access_token === "string") {
35
+ return data.access_token;
36
+ }
37
+ // Try tokens.access_token (Codex/OpenAI oauth-credentials style)
38
+ if (data.tokens &&
39
+ typeof data.tokens === "object" &&
40
+ "access_token" in data.tokens &&
41
+ typeof data.tokens.access_token === "string") {
42
+ return data.tokens.access_token;
43
+ }
44
+ // Try apiKey as fallback (api-key type)
45
+ if (typeof data.apiKey === "string") {
46
+ return data.apiKey;
47
+ }
48
+ return undefined;
49
+ }
50
+ /**
51
+ * Fetch access token from vault.
52
+ *
53
+ * @returns Access token string or undefined if not available
54
+ */
55
+ async function fetchFromVault(agentId, credentialName) {
56
+ try {
57
+ const result = await fetchVaultCredentials({
58
+ agentId,
59
+ name: credentialName,
60
+ });
61
+ if (!result.ok) {
62
+ // Log warning for debugging, but don't fail hard
63
+ if (result.reason !== "not-configured" && result.reason !== "not-found") {
64
+ console.error(`[axusage] Vault fetch failed for ${agentId}/${credentialName}: ${result.reason}`);
65
+ }
66
+ return undefined;
67
+ }
68
+ const token = extractAccessToken(result.credentials);
69
+ if (!token) {
70
+ console.error(`[axusage] Vault credentials for ${agentId}/${credentialName} missing access token. ` +
71
+ `Credential type: ${result.credentials.type}`);
72
+ }
73
+ return token;
74
+ }
75
+ catch (error) {
76
+ console.error(`[axusage] Vault fetch error for ${agentId}/${credentialName}: ${error instanceof Error ? error.message : String(error)}`);
77
+ return undefined;
78
+ }
79
+ }
80
+ /**
81
+ * Fetch access token from local credential store.
82
+ *
83
+ * @returns Access token string or undefined if not available
84
+ */
85
+ async function fetchFromLocal(agentId) {
86
+ try {
87
+ return await getAgentAccessToken(agentId);
88
+ }
89
+ catch (error) {
90
+ console.error(`[axusage] Local credential fetch error for ${agentId}: ${error instanceof Error ? error.message : String(error)}`);
91
+ return undefined;
92
+ }
93
+ }
94
+ /**
95
+ * Get access token for a service.
96
+ *
97
+ * Uses the configured credential source for the service:
98
+ * - "local": Fetch from local axauth credential store
99
+ * - "vault": Fetch from axvault server (requires credential name)
100
+ * - "auto": Try vault if configured and name provided, fallback to local
101
+ *
102
+ * @param service - Service ID (e.g., "claude", "chatgpt", "gemini")
103
+ * @returns Access token string or undefined if not available
104
+ *
105
+ * @example
106
+ * const token = await getServiceAccessToken("claude");
107
+ * if (!token) {
108
+ * console.error("No credentials found for Claude");
109
+ * }
110
+ */
111
+ async function getServiceAccessToken(service) {
112
+ const config = getServiceSourceConfig(service);
113
+ const agentId = SERVICE_TO_AGENT[service];
114
+ switch (config.source) {
115
+ case "local": {
116
+ return fetchFromLocal(agentId);
117
+ }
118
+ case "vault": {
119
+ if (!config.name) {
120
+ console.error(`[axusage] Vault source requires credential name for ${service}. ` +
121
+ `Set {"${service}": {"source": "vault", "name": "your-name"}} in config.`);
122
+ return undefined;
123
+ }
124
+ const token = await fetchFromVault(agentId, config.name);
125
+ if (!token) {
126
+ // User explicitly selected vault but it failed - provide clear feedback
127
+ console.error(`[axusage] Vault credential fetch failed for ${service}. ` +
128
+ `Check that vault is configured (AXVAULT env) and credential "${config.name}" exists.`);
129
+ }
130
+ return token;
131
+ }
132
+ case "auto": {
133
+ // Auto mode: try vault first if configured and name provided
134
+ if (config.name && isVaultConfigured()) {
135
+ const vaultToken = await fetchFromVault(agentId, config.name);
136
+ if (vaultToken) {
137
+ return vaultToken;
138
+ }
139
+ // Fallback to local if vault failed
140
+ }
141
+ // No credential name or vault not configured: use local only
142
+ return fetchFromLocal(agentId);
143
+ }
144
+ }
145
+ }
146
+ export { getServiceAccessToken };
@@ -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 silently ignored to avoid blocking the main operation.
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 { chmod } from "node:fs/promises";
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 silently ignored to avoid blocking the main operation.
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({ path: storagePath });
9
- await chmod(storagePath, 0o600).catch(() => {
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
- // ignore persistence errors; do not block request completion
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 { chmod } from "node:fs/promises";
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
- console.warn(`\n⚠ Unable to verify session via ${config.verifyUrl}. Saving state anyway...`);
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({ path: storagePath });
24
- try {
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
- await waitForLogin(page, selectors);
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 { Page } from "playwright";
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 declare function waitForLogin(page: Page, selectors: readonly string[]): Promise<void>;
5
+ export type LoginWaitOutcome = "selector" | "manual" | "timeout" | "closed" | "aborted" | "skipped";
6
+ export declare function waitForLogin(page: Page, selectors: readonly string[]): Promise<LoginWaitOutcome>;