mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-05-14 06:58:26 +00:00
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ccstatusline",
|
||||
"version": "2.2.2",
|
||||
"version": "2.2.3",
|
||||
"description": "A customizable status line formatter for Claude Code CLI",
|
||||
"module": "src/ccstatusline.ts",
|
||||
"type": "module",
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface RenderUsageData {
|
||||
extraUsageLimit?: number;
|
||||
extraUsageUsed?: number;
|
||||
extraUsageUtilization?: number;
|
||||
error?: 'no-credentials' | 'timeout' | 'api-error' | 'parse-error';
|
||||
error?: 'no-credentials' | 'timeout' | 'rate-limited' | 'api-error' | 'parse-error';
|
||||
}
|
||||
|
||||
export interface RenderContext {
|
||||
|
||||
@@ -17,54 +17,34 @@ interface UsageProbeResult {
|
||||
requestCount: number;
|
||||
proxyAgentConfigured: boolean;
|
||||
requestHost: string | null;
|
||||
lockContents: string | null;
|
||||
}
|
||||
|
||||
describe('fetchUsageData error handling', () => {
|
||||
it('preserves root errors within lock window and avoids locking on no-credentials', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-usage-test-'));
|
||||
const probeScriptPath = path.join(tempRoot, 'probe-usage.mjs');
|
||||
const usageModulePath = fileURLToPath(new URL('../usage.ts', import.meta.url));
|
||||
interface TokenHome {
|
||||
bin: string;
|
||||
claudeConfig: string;
|
||||
home: string;
|
||||
}
|
||||
|
||||
const noCredentialsHome = path.join(tempRoot, 'home-no-credentials');
|
||||
const apiErrorHome = path.join(tempRoot, 'home-api-error');
|
||||
const apiErrorBin = path.join(tempRoot, 'bin-api-error');
|
||||
const apiErrorClaudeConfig = path.join(tempRoot, 'claude-api-error');
|
||||
const invalidProxyHome = path.join(tempRoot, 'home-invalid-proxy');
|
||||
const successHome = path.join(tempRoot, 'home-success');
|
||||
const successBin = path.join(tempRoot, 'bin-success');
|
||||
const successClaudeConfig = path.join(tempRoot, 'claude-success');
|
||||
const securityScript = path.join(apiErrorBin, 'security');
|
||||
const successSecurityScript = path.join(successBin, 'security');
|
||||
const credentialsFile = path.join(apiErrorClaudeConfig, '.credentials.json');
|
||||
const successCredentialsFile = path.join(successClaudeConfig, '.credentials.json');
|
||||
const successResponseBody = JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 42,
|
||||
resets_at: '2030-01-01T00:00:00.000Z'
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 17,
|
||||
resets_at: '2030-01-07T00:00:00.000Z'
|
||||
}
|
||||
});
|
||||
interface ProbeOptions {
|
||||
claudeConfigDir?: string;
|
||||
home: string;
|
||||
httpsProxy?: string;
|
||||
lowercaseHttpsProxy?: string;
|
||||
mode?: 'error' | 'status' | 'success' | 'unexpected';
|
||||
nowMs: number;
|
||||
pathDir?: string;
|
||||
responseBody?: string;
|
||||
responseHeaders?: Record<string, string>;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
fs.mkdirSync(noCredentialsHome, { recursive: true });
|
||||
fs.mkdirSync(apiErrorHome, { recursive: true });
|
||||
fs.mkdirSync(apiErrorBin, { recursive: true });
|
||||
fs.mkdirSync(apiErrorClaudeConfig, { recursive: true });
|
||||
fs.mkdirSync(invalidProxyHome, { recursive: true });
|
||||
fs.mkdirSync(successHome, { recursive: true });
|
||||
fs.mkdirSync(successBin, { recursive: true });
|
||||
fs.mkdirSync(successClaudeConfig, { recursive: true });
|
||||
function createProbeHarness() {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-usage-test-'));
|
||||
const probeScriptPath = path.join(tempRoot, 'probe-usage.mjs');
|
||||
const usageModulePath = fileURLToPath(new URL('../usage.ts', import.meta.url));
|
||||
|
||||
fs.writeFileSync(securityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n');
|
||||
fs.chmodSync(securityScript, 0o755);
|
||||
fs.writeFileSync(credentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } }));
|
||||
fs.writeFileSync(successSecurityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n');
|
||||
fs.chmodSync(successSecurityScript, 0o755);
|
||||
fs.writeFileSync(successCredentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } }));
|
||||
|
||||
const probeScript = `
|
||||
const probeScript = `
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -74,6 +54,8 @@ const require = createRequire(import.meta.url);
|
||||
const https = require('https');
|
||||
const mode = process.env.TEST_REQUEST_MODE || 'success';
|
||||
const responseBody = process.env.TEST_RESPONSE_BODY || '';
|
||||
const responseHeaders = JSON.parse(process.env.TEST_RESPONSE_HEADERS_JSON || '{}');
|
||||
const statusCode = Number(process.env.TEST_STATUS_CODE || (mode === 'success' ? '200' : '500'));
|
||||
let requestCount = 0;
|
||||
let proxyAgentConfigured = false;
|
||||
let requestHost = null;
|
||||
@@ -88,7 +70,8 @@ https.request = (...args) => {
|
||||
const responseHandlers = new Map();
|
||||
|
||||
const response = {
|
||||
statusCode: mode === 'success' ? 200 : 500,
|
||||
headers: responseHeaders,
|
||||
statusCode,
|
||||
setEncoding() {},
|
||||
on(event, handler) {
|
||||
const existing = responseHandlers.get(event) || [];
|
||||
@@ -115,24 +98,28 @@ https.request = (...args) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'success') {
|
||||
if (callback) {
|
||||
callback(response);
|
||||
}
|
||||
const dataHandlers = responseHandlers.get('data') || [];
|
||||
for (const handler of dataHandlers) {
|
||||
handler(Buffer.from(responseBody));
|
||||
}
|
||||
const endHandlers = responseHandlers.get('end') || [];
|
||||
for (const handler of endHandlers) {
|
||||
handler();
|
||||
if (mode === 'unexpected') {
|
||||
const handlers = requestHandlers.get('error') || [];
|
||||
for (const handler of handlers) {
|
||||
handler(new Error('unexpected request'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const handlers = requestHandlers.get('error') || [];
|
||||
for (const handler of handlers) {
|
||||
handler(new Error('unexpected request'));
|
||||
if (callback) {
|
||||
callback(response);
|
||||
}
|
||||
|
||||
if (responseBody !== '') {
|
||||
const dataHandlers = responseHandlers.get('data') || [];
|
||||
for (const handler of dataHandlers) {
|
||||
handler(responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
const endHandlers = responseHandlers.get('end') || [];
|
||||
for (const handler of endHandlers) {
|
||||
handler();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -156,25 +143,125 @@ process.stdout.write(JSON.stringify({
|
||||
cacheExists: fs.existsSync(cacheFile),
|
||||
requestCount,
|
||||
proxyAgentConfigured,
|
||||
requestHost
|
||||
requestHost,
|
||||
lockContents: fs.existsSync(lockFile) ? fs.readFileSync(lockFile, 'utf8') : null
|
||||
}));
|
||||
`;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(probeScriptPath, probeScript);
|
||||
fs.writeFileSync(probeScriptPath, probeScript);
|
||||
|
||||
const noCredentialsOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: noCredentialsHome,
|
||||
PATH: '/nonexistent',
|
||||
TEST_REQUEST_MODE: 'unexpected',
|
||||
TEST_NOW_MS: '2200000000000'
|
||||
}
|
||||
function createEmptyHome(name: string): { home: string } {
|
||||
const home = path.join(tempRoot, `home-${name}`);
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
return { home };
|
||||
}
|
||||
|
||||
function createTokenHome(name: string): TokenHome {
|
||||
const home = path.join(tempRoot, `home-${name}`);
|
||||
const bin = path.join(tempRoot, `bin-${name}`);
|
||||
const claudeConfig = path.join(tempRoot, `claude-${name}`);
|
||||
const securityScript = path.join(bin, 'security');
|
||||
const credentialsFile = path.join(claudeConfig, '.credentials.json');
|
||||
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
fs.mkdirSync(claudeConfig, { recursive: true });
|
||||
|
||||
fs.writeFileSync(securityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n');
|
||||
fs.chmodSync(securityScript, 0o755);
|
||||
fs.writeFileSync(credentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } }));
|
||||
|
||||
return {
|
||||
bin,
|
||||
claudeConfig,
|
||||
home
|
||||
};
|
||||
}
|
||||
|
||||
function runProbe(options: ProbeOptions): UsageProbeResult {
|
||||
const output = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: options.home,
|
||||
PATH: options.pathDir ?? '/nonexistent',
|
||||
TEST_NOW_MS: String(options.nowMs),
|
||||
TEST_REQUEST_MODE: options.mode ?? 'success',
|
||||
TEST_RESPONSE_BODY: options.responseBody ?? '',
|
||||
TEST_RESPONSE_HEADERS_JSON: JSON.stringify(options.responseHeaders ?? {}),
|
||||
TEST_STATUS_CODE: String(options.statusCode ?? (options.mode === 'success' ? 200 : 500)),
|
||||
...(options.claudeConfigDir ? { CLAUDE_CONFIG_DIR: options.claudeConfigDir } : {}),
|
||||
...(options.httpsProxy !== undefined ? { HTTPS_PROXY: options.httpsProxy } : {}),
|
||||
...(options.lowercaseHttpsProxy !== undefined ? { https_proxy: options.lowercaseHttpsProxy } : {})
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.parse(output) as UsageProbeResult;
|
||||
}
|
||||
|
||||
function cleanup(): void {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
createEmptyHome,
|
||||
createTokenHome,
|
||||
runProbe
|
||||
};
|
||||
}
|
||||
|
||||
function parseLockContents(lockContents: string | null): { blockedUntil: number; error?: string } | null {
|
||||
return lockContents ? JSON.parse(lockContents) as { blockedUntil: number; error?: string } : null;
|
||||
}
|
||||
|
||||
describe('fetchUsageData error handling', () => {
|
||||
const nowMs = 2200000000000;
|
||||
const successResponseBody = JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 42,
|
||||
resets_at: '2030-01-01T00:00:00.000Z'
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 17,
|
||||
resets_at: '2030-01-07T00:00:00.000Z'
|
||||
}
|
||||
});
|
||||
const updatedSuccessResponseBody = JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 55,
|
||||
resets_at: '2030-01-02T00:00:00.000Z'
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 21,
|
||||
resets_at: '2030-01-08T00:00:00.000Z'
|
||||
}
|
||||
});
|
||||
const rateLimitedResponseBody = JSON.stringify({
|
||||
error: {
|
||||
message: 'Rate limited. Please try again later.',
|
||||
type: 'rate_limit_error'
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves root errors within a process and keeps existing proxy and cache behavior', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const noCredentialsHome = harness.createEmptyHome('no-credentials');
|
||||
const apiErrorHome = harness.createTokenHome('api-error');
|
||||
const successHome = harness.createTokenHome('success');
|
||||
const invalidProxyHome = harness.createTokenHome('invalid-proxy');
|
||||
const proxyHome = harness.createTokenHome('proxy');
|
||||
const blankProxyHome = harness.createTokenHome('blank-proxy');
|
||||
const lowercaseProxyHome = harness.createTokenHome('lowercase-proxy');
|
||||
|
||||
const noCredentialsResult = harness.runProbe({
|
||||
home: noCredentialsHome.home,
|
||||
mode: 'unexpected',
|
||||
nowMs
|
||||
});
|
||||
|
||||
const noCredentialsResult = JSON.parse(noCredentialsOutput) as UsageProbeResult;
|
||||
expect(noCredentialsResult.first).toEqual({ error: 'no-credentials' });
|
||||
expect(noCredentialsResult.second).toEqual({ error: 'no-credentials' });
|
||||
expect(noCredentialsResult.lockExists).toBe(false);
|
||||
@@ -182,40 +269,46 @@ process.stdout.write(JSON.stringify({
|
||||
expect(noCredentialsResult.requestCount).toBe(0);
|
||||
expect(noCredentialsResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const apiErrorOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: apiErrorHome,
|
||||
PATH: apiErrorBin,
|
||||
CLAUDE_CONFIG_DIR: apiErrorClaudeConfig,
|
||||
TEST_REQUEST_MODE: 'error',
|
||||
TEST_NOW_MS: '2200000000000'
|
||||
}
|
||||
const apiErrorResult = harness.runProbe({
|
||||
claudeConfigDir: apiErrorHome.claudeConfig,
|
||||
home: apiErrorHome.home,
|
||||
mode: 'error',
|
||||
nowMs,
|
||||
pathDir: apiErrorHome.bin
|
||||
});
|
||||
|
||||
const apiErrorResult = JSON.parse(apiErrorOutput) as UsageProbeResult;
|
||||
expect(apiErrorResult.first).toEqual({ error: 'api-error' });
|
||||
expect(apiErrorResult.second).toEqual({ error: 'api-error' });
|
||||
expect(apiErrorResult.cacheExists).toBe(false);
|
||||
expect(apiErrorResult.requestCount).toBe(1);
|
||||
expect(apiErrorResult.proxyAgentConfigured).toBe(false);
|
||||
expect(apiErrorResult.requestHost).toBe('api.anthropic.com');
|
||||
|
||||
const successOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: successHome,
|
||||
PATH: successBin,
|
||||
CLAUDE_CONFIG_DIR: successClaudeConfig,
|
||||
TEST_REQUEST_MODE: 'success',
|
||||
TEST_RESPONSE_BODY: successResponseBody,
|
||||
TEST_NOW_MS: '2200000000000'
|
||||
}
|
||||
expect(parseLockContents(apiErrorResult.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor(nowMs / 1000) + 30,
|
||||
error: 'timeout'
|
||||
});
|
||||
|
||||
const genericLockResult = harness.runProbe({
|
||||
claudeConfigDir: apiErrorHome.claudeConfig,
|
||||
home: apiErrorHome.home,
|
||||
mode: 'unexpected',
|
||||
nowMs,
|
||||
pathDir: apiErrorHome.bin
|
||||
});
|
||||
|
||||
expect(genericLockResult.first).toEqual({ error: 'timeout' });
|
||||
expect(genericLockResult.second).toEqual({ error: 'timeout' });
|
||||
expect(genericLockResult.requestCount).toBe(0);
|
||||
|
||||
const successResult = harness.runProbe({
|
||||
claudeConfigDir: successHome.claudeConfig,
|
||||
home: successHome.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: successHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
const successResult = JSON.parse(successOutput) as UsageProbeResult;
|
||||
expect(successResult.first).toEqual({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
@@ -228,126 +321,278 @@ process.stdout.write(JSON.stringify({
|
||||
expect(successResult.proxyAgentConfigured).toBe(false);
|
||||
expect(successResult.requestHost).toBe('api.anthropic.com');
|
||||
|
||||
const httpsProxyOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: apiErrorHome,
|
||||
PATH: apiErrorBin,
|
||||
CLAUDE_CONFIG_DIR: apiErrorClaudeConfig,
|
||||
HTTPS_PROXY: 'http://proxy.local:8080',
|
||||
TEST_REQUEST_MODE: 'success',
|
||||
TEST_RESPONSE_BODY: successResponseBody,
|
||||
TEST_NOW_MS: '2200000000000'
|
||||
}
|
||||
const httpsProxyResult = harness.runProbe({
|
||||
claudeConfigDir: proxyHome.claudeConfig,
|
||||
home: proxyHome.home,
|
||||
httpsProxy: 'http://proxy.local:8080',
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: proxyHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
const httpsProxyResult = JSON.parse(httpsProxyOutput) as UsageProbeResult;
|
||||
expect(httpsProxyResult.first).toEqual(successResult.first);
|
||||
expect(httpsProxyResult.second).toEqual(successResult.first);
|
||||
expect(httpsProxyResult.requestCount).toBe(1);
|
||||
expect(httpsProxyResult.proxyAgentConfigured).toBe(true);
|
||||
expect(httpsProxyResult.requestHost).toBe('api.anthropic.com');
|
||||
|
||||
const lowercaseProxyOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: apiErrorHome,
|
||||
PATH: apiErrorBin,
|
||||
CLAUDE_CONFIG_DIR: apiErrorClaudeConfig,
|
||||
https_proxy: 'http://proxy.local:8080',
|
||||
TEST_REQUEST_MODE: 'success',
|
||||
TEST_RESPONSE_BODY: successResponseBody,
|
||||
TEST_NOW_MS: '2200000000000'
|
||||
}
|
||||
const lowercaseProxyResult = harness.runProbe({
|
||||
claudeConfigDir: lowercaseProxyHome.claudeConfig,
|
||||
home: lowercaseProxyHome.home,
|
||||
lowercaseHttpsProxy: 'http://proxy.local:8080',
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: lowercaseProxyHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
const lowercaseProxyResult = JSON.parse(lowercaseProxyOutput) as UsageProbeResult;
|
||||
expect(lowercaseProxyResult.first).toEqual(successResult.first);
|
||||
expect(lowercaseProxyResult.second).toEqual(successResult.first);
|
||||
expect(lowercaseProxyResult.requestCount).toBe(1);
|
||||
expect(lowercaseProxyResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const blankProxyOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: apiErrorHome,
|
||||
PATH: apiErrorBin,
|
||||
CLAUDE_CONFIG_DIR: apiErrorClaudeConfig,
|
||||
HTTPS_PROXY: ' ',
|
||||
TEST_REQUEST_MODE: 'success',
|
||||
TEST_RESPONSE_BODY: successResponseBody,
|
||||
TEST_NOW_MS: '2200000000000'
|
||||
}
|
||||
const blankProxyResult = harness.runProbe({
|
||||
claudeConfigDir: blankProxyHome.claudeConfig,
|
||||
home: blankProxyHome.home,
|
||||
httpsProxy: ' ',
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: blankProxyHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
const blankProxyResult = JSON.parse(blankProxyOutput) as UsageProbeResult;
|
||||
expect(blankProxyResult.first).toEqual(successResult.first);
|
||||
expect(blankProxyResult.second).toEqual(successResult.first);
|
||||
expect(blankProxyResult.requestCount).toBe(1);
|
||||
expect(blankProxyResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const invalidProxyOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: invalidProxyHome,
|
||||
PATH: apiErrorBin,
|
||||
CLAUDE_CONFIG_DIR: apiErrorClaudeConfig,
|
||||
HTTPS_PROXY: '://bad-proxy',
|
||||
TEST_REQUEST_MODE: 'success',
|
||||
TEST_RESPONSE_BODY: successResponseBody,
|
||||
TEST_NOW_MS: '2200000000000'
|
||||
}
|
||||
const invalidProxyResult = harness.runProbe({
|
||||
claudeConfigDir: invalidProxyHome.claudeConfig,
|
||||
home: invalidProxyHome.home,
|
||||
httpsProxy: '://bad-proxy',
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: invalidProxyHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
const invalidProxyResult = JSON.parse(invalidProxyOutput) as UsageProbeResult;
|
||||
expect(invalidProxyResult.first).toEqual({ error: 'api-error' });
|
||||
expect(invalidProxyResult.second).toEqual({ error: 'api-error' });
|
||||
expect(invalidProxyResult.requestCount).toBe(0);
|
||||
expect(invalidProxyResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const staleProxyOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: successHome,
|
||||
PATH: successBin,
|
||||
CLAUDE_CONFIG_DIR: successClaudeConfig,
|
||||
HTTPS_PROXY: '://bad-proxy',
|
||||
TEST_REQUEST_MODE: 'success',
|
||||
TEST_RESPONSE_BODY: successResponseBody,
|
||||
TEST_NOW_MS: '2200000181000'
|
||||
}
|
||||
const staleProxyResult = harness.runProbe({
|
||||
claudeConfigDir: successHome.claudeConfig,
|
||||
home: successHome.home,
|
||||
httpsProxy: '://bad-proxy',
|
||||
mode: 'success',
|
||||
nowMs: nowMs + 181000,
|
||||
pathDir: successHome.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
const staleProxyResult = JSON.parse(staleProxyOutput) as UsageProbeResult;
|
||||
expect(staleProxyResult.first).toEqual(successResult.first);
|
||||
expect(staleProxyResult.second).toEqual(successResult.first);
|
||||
expect(staleProxyResult.requestCount).toBe(0);
|
||||
expect(staleProxyResult.proxyAgentConfigured).toBe(false);
|
||||
|
||||
const cachedSuccessOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: successHome,
|
||||
PATH: successBin,
|
||||
CLAUDE_CONFIG_DIR: successClaudeConfig,
|
||||
TEST_REQUEST_MODE: 'unexpected',
|
||||
TEST_NOW_MS: '2200000000000'
|
||||
}
|
||||
const cachedSuccessResult = harness.runProbe({
|
||||
claudeConfigDir: successHome.claudeConfig,
|
||||
home: successHome.home,
|
||||
mode: 'unexpected',
|
||||
nowMs,
|
||||
pathDir: successHome.bin
|
||||
});
|
||||
|
||||
const cachedSuccessResult = JSON.parse(cachedSuccessOutput) as UsageProbeResult;
|
||||
expect(cachedSuccessResult.first).toEqual(successResult.first);
|
||||
expect(cachedSuccessResult.second).toEqual(successResult.first);
|
||||
expect(cachedSuccessResult.cacheExists).toBe(true);
|
||||
expect(cachedSuccessResult.requestCount).toBe(1);
|
||||
expect(cachedSuccessResult.requestCount).toBe(0);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses stale cached data during a numeric Retry-After backoff and retries after expiry', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('rate-limited-with-cache');
|
||||
const rateLimitNowMs = nowMs + 31000;
|
||||
const successResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
const rateLimitedResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'status',
|
||||
nowMs: rateLimitNowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: rateLimitedResponseBody,
|
||||
responseHeaders: { 'retry-after': '3600' },
|
||||
statusCode: 429
|
||||
});
|
||||
|
||||
expect(rateLimitedResult.first).toEqual(successResult.first);
|
||||
expect(rateLimitedResult.second).toEqual(successResult.first);
|
||||
expect(rateLimitedResult.requestCount).toBe(1);
|
||||
expect(parseLockContents(rateLimitedResult.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor(rateLimitNowMs / 1000) + 3600,
|
||||
error: 'rate-limited'
|
||||
});
|
||||
|
||||
const activeBackoffResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs: rateLimitNowMs + 600000,
|
||||
pathDir: home.bin
|
||||
});
|
||||
|
||||
expect(activeBackoffResult.first).toEqual(successResult.first);
|
||||
expect(activeBackoffResult.second).toEqual(successResult.first);
|
||||
expect(activeBackoffResult.requestCount).toBe(0);
|
||||
|
||||
const postBackoffResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs: rateLimitNowMs + 3601000,
|
||||
pathDir: home.bin,
|
||||
responseBody: updatedSuccessResponseBody
|
||||
});
|
||||
|
||||
expect(postBackoffResult.first).toEqual({
|
||||
sessionUsage: 55,
|
||||
sessionResetAt: '2030-01-02T00:00:00.000Z',
|
||||
weeklyUsage: 21,
|
||||
weeklyResetAt: '2030-01-08T00:00:00.000Z'
|
||||
});
|
||||
expect(postBackoffResult.second).toEqual(postBackoffResult.first);
|
||||
expect(postBackoffResult.requestCount).toBe(1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns rate-limited without stale cache and falls back to the default backoff when Retry-After is invalid', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('rate-limited-no-cache');
|
||||
const firstRateLimitedResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'status',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: rateLimitedResponseBody,
|
||||
responseHeaders: { 'retry-after': 'not-a-number' },
|
||||
statusCode: 429
|
||||
});
|
||||
|
||||
expect(firstRateLimitedResult.first).toEqual({ error: 'rate-limited' });
|
||||
expect(firstRateLimitedResult.second).toEqual({ error: 'rate-limited' });
|
||||
expect(firstRateLimitedResult.requestCount).toBe(1);
|
||||
expect(parseLockContents(firstRateLimitedResult.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor(nowMs / 1000) + 300,
|
||||
error: 'rate-limited'
|
||||
});
|
||||
|
||||
const activeBackoffResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs: nowMs + 299000,
|
||||
pathDir: home.bin
|
||||
});
|
||||
|
||||
expect(activeBackoffResult.first).toEqual({ error: 'rate-limited' });
|
||||
expect(activeBackoffResult.second).toEqual({ error: 'rate-limited' });
|
||||
expect(activeBackoffResult.requestCount).toBe(0);
|
||||
|
||||
const postBackoffResult = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'success',
|
||||
nowMs: nowMs + 301000,
|
||||
pathDir: home.bin,
|
||||
responseBody: successResponseBody
|
||||
});
|
||||
|
||||
expect(postBackoffResult.first).toEqual({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
weeklyUsage: 17,
|
||||
weeklyResetAt: '2030-01-07T00:00:00.000Z'
|
||||
});
|
||||
expect(postBackoffResult.second).toEqual(postBackoffResult.first);
|
||||
expect(postBackoffResult.requestCount).toBe(1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('parses HTTP-date Retry-After headers', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('rate-limited-http-date');
|
||||
const retryAt = new Date(nowMs + 900000).toUTCString();
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'status',
|
||||
nowMs,
|
||||
pathDir: home.bin,
|
||||
responseBody: rateLimitedResponseBody,
|
||||
responseHeaders: { 'retry-after': retryAt },
|
||||
statusCode: 429
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({ error: 'rate-limited' });
|
||||
expect(result.second).toEqual({ error: 'rate-limited' });
|
||||
expect(parseLockContents(result.lockContents)).toEqual({
|
||||
blockedUntil: Math.floor((nowMs + 900000) / 1000),
|
||||
error: 'rate-limited'
|
||||
});
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('supports the legacy empty lock file fallback', () => {
|
||||
const harness = createProbeHarness();
|
||||
|
||||
try {
|
||||
const home = harness.createTokenHome('legacy-lock');
|
||||
const lockDir = path.join(home.home, '.cache', 'ccstatusline');
|
||||
const lockFile = path.join(lockDir, 'usage.lock');
|
||||
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
fs.writeFileSync(lockFile, '');
|
||||
fs.utimesSync(lockFile, new Date(nowMs), new Date(nowMs));
|
||||
|
||||
const result = harness.runProbe({
|
||||
claudeConfigDir: home.claudeConfig,
|
||||
home: home.home,
|
||||
mode: 'unexpected',
|
||||
nowMs,
|
||||
pathDir: home.bin
|
||||
});
|
||||
|
||||
expect(result.first).toEqual({ error: 'timeout' });
|
||||
expect(result.second).toEqual({ error: 'timeout' });
|
||||
expect(result.requestCount).toBe(0);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { getUsageErrorMessage } from '../usage-windows';
|
||||
|
||||
describe('getUsageErrorMessage', () => {
|
||||
it('returns the rate-limited label', () => {
|
||||
expect(getUsageErrorMessage('rate-limited')).toBe('[Rate limited]');
|
||||
});
|
||||
});
|
||||
+134
-48
@@ -19,9 +19,15 @@ const CACHE_FILE = path.join(CACHE_DIR, 'usage.json');
|
||||
const LOCK_FILE = path.join(CACHE_DIR, 'usage.lock');
|
||||
const CACHE_MAX_AGE = 180; // seconds
|
||||
const LOCK_MAX_AGE = 30; // rate limit: only try API once per 30 seconds
|
||||
const DEFAULT_RATE_LIMIT_BACKOFF = 300; // seconds
|
||||
const TOKEN_CACHE_MAX_AGE = 3600; // 1 hour
|
||||
|
||||
const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() });
|
||||
const UsageLockErrorSchema = z.enum(['timeout', 'rate-limited']);
|
||||
const UsageLockSchema = z.object({
|
||||
blockedUntil: z.number(),
|
||||
error: UsageLockErrorSchema.optional()
|
||||
});
|
||||
|
||||
const CachedUsageDataSchema = z.object({
|
||||
sessionUsage: z.number().nullable().optional(),
|
||||
@@ -110,22 +116,39 @@ let cachedUsageData: UsageData | null = null;
|
||||
let usageCacheTime = 0;
|
||||
let cachedUsageToken: string | null = null;
|
||||
let usageTokenCacheTime = 0;
|
||||
let usageErrorCacheMaxAge = LOCK_MAX_AGE;
|
||||
|
||||
function setCachedUsageError(error: UsageError, now: number): UsageData {
|
||||
type UsageLockError = z.infer<typeof UsageLockErrorSchema>;
|
||||
|
||||
type UsageApiFetchResult = { kind: 'success'; body: string } | { kind: 'rate-limited'; retryAfterSeconds: number } | { kind: 'error' };
|
||||
|
||||
function ensureCacheDirExists(): void {
|
||||
if (!fs.existsSync(CACHE_DIR)) {
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function setCachedUsageError(error: UsageError, now: number, maxAge = LOCK_MAX_AGE): UsageData {
|
||||
const errorData: UsageData = { error };
|
||||
cachedUsageData = errorData;
|
||||
usageCacheTime = now;
|
||||
usageErrorCacheMaxAge = maxAge;
|
||||
return errorData;
|
||||
}
|
||||
|
||||
function getStaleUsageOrError(error: UsageError, now: number): UsageData {
|
||||
function cacheUsageData(data: UsageData, now: number): UsageData {
|
||||
cachedUsageData = data;
|
||||
usageCacheTime = now;
|
||||
usageErrorCacheMaxAge = LOCK_MAX_AGE;
|
||||
return data;
|
||||
}
|
||||
|
||||
function getStaleUsageOrError(error: UsageError, now: number, errorCacheMaxAge = LOCK_MAX_AGE): UsageData {
|
||||
const stale = readStaleUsageCache();
|
||||
if (stale && !stale.error) {
|
||||
cachedUsageData = stale;
|
||||
usageCacheTime = now;
|
||||
return stale;
|
||||
return cacheUsageData(stale, now);
|
||||
}
|
||||
return setCachedUsageError(error, now);
|
||||
return setCachedUsageError(error, now, errorCacheMaxAge);
|
||||
}
|
||||
|
||||
function getUsageToken(): string | null {
|
||||
@@ -173,6 +196,76 @@ function readStaleUsageCache(): UsageData | null {
|
||||
}
|
||||
}
|
||||
|
||||
function writeUsageLock(blockedUntil: number, error: UsageLockError): void {
|
||||
try {
|
||||
ensureCacheDirExists();
|
||||
fs.writeFileSync(LOCK_FILE, JSON.stringify({ blockedUntil, error }));
|
||||
} catch {
|
||||
// Ignore lock file errors
|
||||
}
|
||||
}
|
||||
|
||||
function readActiveUsageLock(now: number): { blockedUntil: number; error: UsageLockError } | null {
|
||||
let hasValidJsonLock = false;
|
||||
|
||||
try {
|
||||
const parsed = parseJsonWithSchema(fs.readFileSync(LOCK_FILE, 'utf8'), UsageLockSchema);
|
||||
if (parsed) {
|
||||
hasValidJsonLock = true;
|
||||
if (parsed.blockedUntil > now) {
|
||||
return {
|
||||
blockedUntil: parsed.blockedUntil,
|
||||
error: parsed.error ?? 'timeout'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the legacy mtime-based lock behavior below.
|
||||
}
|
||||
|
||||
if (hasValidJsonLock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const lockStat = fs.statSync(LOCK_FILE);
|
||||
const lockMtime = Math.floor(lockStat.mtimeMs / 1000);
|
||||
const blockedUntil = lockMtime + LOCK_MAX_AGE;
|
||||
if (blockedUntil > now) {
|
||||
return {
|
||||
blockedUntil,
|
||||
error: 'timeout'
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Lock file doesn't exist - OK to proceed
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRetryAfterSeconds(headerValue: string | string[] | undefined, nowMs = Date.now()): number | null {
|
||||
const rawValue = Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
||||
const trimmedValue = rawValue?.trim();
|
||||
if (!trimmedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(trimmedValue)) {
|
||||
const seconds = Number.parseInt(trimmedValue, 10);
|
||||
return seconds > 0 ? seconds : null;
|
||||
}
|
||||
|
||||
const retryAtMs = Date.parse(trimmedValue);
|
||||
if (Number.isNaN(retryAtMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const retryAfterSeconds = Math.ceil((retryAtMs - nowMs) / 1000);
|
||||
return retryAfterSeconds > 0 ? retryAfterSeconds : null;
|
||||
}
|
||||
|
||||
const USAGE_API_HOST = 'api.anthropic.com';
|
||||
const USAGE_API_PATH = '/api/oauth/usage';
|
||||
const USAGE_API_TIMEOUT_MS = 5000;
|
||||
@@ -206,11 +299,11 @@ function getUsageApiRequestOptions(token: string): https.RequestOptions | null {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFromUsageApi(token: string): Promise<string | null> {
|
||||
async function fetchFromUsageApi(token: string): Promise<UsageApiFetchResult> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = (value: string | null) => {
|
||||
const finish = (value: UsageApiFetchResult) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
@@ -220,7 +313,7 @@ async function fetchFromUsageApi(token: string): Promise<string | null> {
|
||||
|
||||
const requestOptions = getUsageApiRequestOptions(token);
|
||||
if (!requestOptions) {
|
||||
finish(null);
|
||||
finish({ kind: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -234,17 +327,26 @@ async function fetchFromUsageApi(token: string): Promise<string | null> {
|
||||
|
||||
response.on('end', () => {
|
||||
if (response.statusCode === 200 && data) {
|
||||
finish(data);
|
||||
finish({ kind: 'success', body: data });
|
||||
return;
|
||||
}
|
||||
finish(null);
|
||||
|
||||
if (response.statusCode === 429) {
|
||||
finish({
|
||||
kind: 'rate-limited',
|
||||
retryAfterSeconds: parseRetryAfterSeconds(response.headers['retry-after']) ?? DEFAULT_RATE_LIMIT_BACKOFF
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
finish({ kind: 'error' });
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', () => { finish(null); });
|
||||
request.on('error', () => { finish({ kind: 'error' }); });
|
||||
request.on('timeout', () => {
|
||||
request.destroy();
|
||||
finish(null);
|
||||
finish({ kind: 'error' });
|
||||
});
|
||||
request.end();
|
||||
});
|
||||
@@ -259,7 +361,7 @@ export async function fetchUsageData(): Promise<UsageData> {
|
||||
if (!cachedUsageData.error && cacheAge < CACHE_MAX_AGE) {
|
||||
return cachedUsageData;
|
||||
}
|
||||
if (cachedUsageData.error && cacheAge < LOCK_MAX_AGE) {
|
||||
if (cachedUsageData.error && cacheAge < usageErrorCacheMaxAge) {
|
||||
return cachedUsageData;
|
||||
}
|
||||
}
|
||||
@@ -271,9 +373,7 @@ export async function fetchUsageData(): Promise<UsageData> {
|
||||
if (fileAge < CACHE_MAX_AGE) {
|
||||
const fileData = parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8'));
|
||||
if (fileData && !fileData.error) {
|
||||
cachedUsageData = fileData;
|
||||
usageCacheTime = now;
|
||||
return fileData;
|
||||
return cacheUsageData(fileData, now);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -286,41 +386,31 @@ export async function fetchUsageData(): Promise<UsageData> {
|
||||
return getStaleUsageOrError('no-credentials', now);
|
||||
}
|
||||
|
||||
// Rate limit: only try API once per 30 seconds
|
||||
try {
|
||||
const lockStat = fs.statSync(LOCK_FILE);
|
||||
const lockAge = now - Math.floor(lockStat.mtimeMs / 1000);
|
||||
if (lockAge < LOCK_MAX_AGE) {
|
||||
// Rate limited - return stale cache or timeout error
|
||||
const stale = readStaleUsageCache();
|
||||
if (stale && !stale.error)
|
||||
return stale;
|
||||
return { error: 'timeout' };
|
||||
}
|
||||
} catch {
|
||||
// Lock file doesn't exist - OK to proceed
|
||||
const activeLock = readActiveUsageLock(now);
|
||||
if (activeLock) {
|
||||
return getStaleUsageOrError(
|
||||
activeLock.error,
|
||||
now,
|
||||
Math.max(1, activeLock.blockedUntil - now)
|
||||
);
|
||||
}
|
||||
|
||||
// Touch lock file
|
||||
try {
|
||||
const lockDir = path.dirname(LOCK_FILE);
|
||||
if (!fs.existsSync(lockDir)) {
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(LOCK_FILE, '');
|
||||
} catch {
|
||||
// Ignore lock file errors
|
||||
}
|
||||
writeUsageLock(now + LOCK_MAX_AGE, 'timeout');
|
||||
|
||||
// Fetch from API using Node's https module
|
||||
try {
|
||||
const response = await fetchFromUsageApi(token);
|
||||
|
||||
if (!response) {
|
||||
if (response.kind === 'rate-limited') {
|
||||
writeUsageLock(now + response.retryAfterSeconds, 'rate-limited');
|
||||
return getStaleUsageOrError('rate-limited', now, response.retryAfterSeconds);
|
||||
}
|
||||
|
||||
if (response.kind === 'error') {
|
||||
return getStaleUsageOrError('api-error', now);
|
||||
}
|
||||
|
||||
const usageData = parseUsageApiResponse(response);
|
||||
const usageData = parseUsageApiResponse(response.body);
|
||||
if (!usageData) {
|
||||
return getStaleUsageOrError('parse-error', now);
|
||||
}
|
||||
@@ -332,17 +422,13 @@ export async function fetchUsageData(): Promise<UsageData> {
|
||||
|
||||
// Save to cache
|
||||
try {
|
||||
if (!fs.existsSync(CACHE_DIR)) {
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
}
|
||||
ensureCacheDirExists();
|
||||
fs.writeFileSync(CACHE_FILE, JSON.stringify(usageData));
|
||||
} catch {
|
||||
// Ignore cache write errors
|
||||
}
|
||||
|
||||
cachedUsageData = usageData;
|
||||
usageCacheTime = now;
|
||||
return usageData;
|
||||
return cacheUsageData(usageData, now);
|
||||
} catch {
|
||||
return getStaleUsageOrError('parse-error', now);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
export const FIVE_HOUR_BLOCK_MS = 5 * 60 * 60 * 1000;
|
||||
export const SEVEN_DAY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const UsageErrorSchema = z.enum(['no-credentials', 'timeout', 'api-error', 'parse-error']);
|
||||
export const UsageErrorSchema = z.enum(['no-credentials', 'timeout', 'rate-limited', 'api-error', 'parse-error']);
|
||||
export type UsageError = z.infer<typeof UsageErrorSchema>;
|
||||
|
||||
export interface UsageData {
|
||||
|
||||
@@ -109,6 +109,7 @@ export function getUsageErrorMessage(error: UsageError): string {
|
||||
switch (error) {
|
||||
case 'no-credentials': return '[No credentials]';
|
||||
case 'timeout': return '[Timeout]';
|
||||
case 'rate-limited': return '[Rate limited]';
|
||||
case 'api-error': return '[API Error]';
|
||||
case 'parse-error': return '[Parse Error]';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user