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.
- package/README.md +61 -15
- package/dist/adapters/chatgpt.js +2 -2
- package/dist/adapters/claude.js +2 -2
- package/dist/adapters/gemini.js +2 -2
- 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.d.ts +38 -0
- package/dist/config/credential-sources.js +117 -0
- package/dist/services/create-auth-context.js +2 -1
- package/dist/services/do-setup-auth.js +2 -8
- package/dist/services/get-service-access-token.d.ts +28 -0
- package/dist/services/get-service-access-token.js +146 -0
- 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 +11 -15
|
@@ -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,33 +54,29 @@
|
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@commander-js/extra-typings": "^14.0.0",
|
|
57
|
-
"
|
|
57
|
+
"@inquirer/prompts": "^8.2.0",
|
|
58
|
+
"axauth": "^1.11.2",
|
|
58
59
|
"chalk": "^5.6.2",
|
|
59
60
|
"commander": "^14.0.2",
|
|
61
|
+
"conf": "^15.0.2",
|
|
60
62
|
"env-paths": "^3.0.0",
|
|
61
63
|
"playwright": "^1.57.0",
|
|
62
64
|
"prom-client": "^15.1.3",
|
|
63
65
|
"trash": "^10.0.1",
|
|
64
|
-
"zod": "^4.
|
|
66
|
+
"zod": "^4.3.5"
|
|
65
67
|
},
|
|
66
68
|
"devDependencies": {
|
|
67
|
-
"@eslint/compat": "^2.0.0",
|
|
68
|
-
"@eslint/js": "^9.39.2",
|
|
69
69
|
"@total-typescript/ts-reset": "^0.6.1",
|
|
70
|
-
"@types/node": "^25.0.
|
|
71
|
-
"@vitest/coverage-v8": "^4.0.
|
|
72
|
-
"@vitest/eslint-plugin": "^1.5.2",
|
|
70
|
+
"@types/node": "^25.0.8",
|
|
71
|
+
"@vitest/coverage-v8": "^4.0.17",
|
|
73
72
|
"eslint": "^9.39.2",
|
|
74
|
-
"eslint-config-
|
|
75
|
-
"
|
|
76
|
-
"fta-check": "^1.5.0",
|
|
73
|
+
"eslint-config-axkit": "^1.0.0",
|
|
74
|
+
"fta-check": "^1.5.1",
|
|
77
75
|
"fta-cli": "^3.0.0",
|
|
78
|
-
"
|
|
79
|
-
"knip": "^5.73.4",
|
|
76
|
+
"knip": "^5.81.0",
|
|
80
77
|
"prettier": "3.7.4",
|
|
81
78
|
"semantic-release": "^25.0.2",
|
|
82
79
|
"typescript": "^5.9.3",
|
|
83
|
-
"
|
|
84
|
-
"vitest": "^4.0.15"
|
|
80
|
+
"vitest": "^4.0.17"
|
|
85
81
|
}
|
|
86
82
|
}
|