refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
This commit is contained in:
67
apps/server/agent/accounts/extractor.ts
Normal file
67
apps/server/agent/accounts/extractor.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir, platform } from 'node:os';
|
||||
import { execa } from 'execa';
|
||||
|
||||
export interface ExtractedAccount {
|
||||
email: string;
|
||||
accountUuid: string;
|
||||
configJson: object;
|
||||
credentials: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Claude Code config path with fallback logic.
|
||||
* Primary: ~/.claude/.claude.json (if it exists and has oauthAccount)
|
||||
* Fallback: ~/.claude.json
|
||||
*/
|
||||
function getClaudeConfigPath(): string {
|
||||
const home = homedir();
|
||||
const primary = join(home, '.claude', '.claude.json');
|
||||
const fallback = join(home, '.claude.json');
|
||||
|
||||
if (existsSync(primary)) {
|
||||
try {
|
||||
const json = JSON.parse(readFileSync(primary, 'utf-8'));
|
||||
if (json.oauthAccount) return primary;
|
||||
} catch {
|
||||
// invalid JSON, fall through
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function extractCurrentClaudeAccount(): Promise<ExtractedAccount> {
|
||||
const home = homedir();
|
||||
|
||||
// 1. Read Claude config (with fallback logic matching ccswitch)
|
||||
const configPath = getClaudeConfigPath();
|
||||
const configRaw = readFileSync(configPath, 'utf-8');
|
||||
const configJson = JSON.parse(configRaw);
|
||||
|
||||
const email = configJson.oauthAccount?.emailAddress;
|
||||
const accountUuid = configJson.oauthAccount?.accountUuid;
|
||||
|
||||
if (!email || !accountUuid) {
|
||||
throw new Error('No Claude account found. Please log in with `claude` first.');
|
||||
}
|
||||
|
||||
// 2. Read credentials (platform-specific)
|
||||
let credentials: string;
|
||||
if (platform() === 'darwin') {
|
||||
// macOS: read from Keychain
|
||||
const { stdout } = await execa('security', [
|
||||
'find-generic-password',
|
||||
'-s', 'Claude Code-credentials',
|
||||
'-w',
|
||||
]);
|
||||
credentials = stdout;
|
||||
} else {
|
||||
// Linux: read from file
|
||||
const credPath = join(home, '.claude', '.credentials.json');
|
||||
credentials = readFileSync(credPath, 'utf-8');
|
||||
}
|
||||
|
||||
return { email, accountUuid, configJson, credentials };
|
||||
}
|
||||
5
apps/server/agent/accounts/index.ts
Normal file
5
apps/server/agent/accounts/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { extractCurrentClaudeAccount, type ExtractedAccount } from './extractor.js';
|
||||
export { setupAccountConfigDir } from './setup.js';
|
||||
export { getAccountConfigDir } from './paths.js';
|
||||
export { checkAccountHealth, ensureAccountCredentials } from './usage.js';
|
||||
export type { AccountHealthResult, AccountUsage, UsageTier } from './usage.js';
|
||||
5
apps/server/agent/accounts/paths.ts
Normal file
5
apps/server/agent/accounts/paths.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
export function getAccountConfigDir(workspaceRoot: string, accountId: string): string {
|
||||
return join(workspaceRoot, '.cw', 'accounts', accountId);
|
||||
}
|
||||
15
apps/server/agent/accounts/setup.ts
Normal file
15
apps/server/agent/accounts/setup.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export function setupAccountConfigDir(
|
||||
configDir: string,
|
||||
extracted: { configJson: object; credentials: string },
|
||||
): void {
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
|
||||
// Write .claude.json
|
||||
writeFileSync(join(configDir, '.claude.json'), JSON.stringify(extracted.configJson, null, 2));
|
||||
|
||||
// Write .credentials.json
|
||||
writeFileSync(join(configDir, '.credentials.json'), extracted.credentials);
|
||||
}
|
||||
374
apps/server/agent/accounts/usage.ts
Normal file
374
apps/server/agent/accounts/usage.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { Account } from '../../db/schema.js';
|
||||
import type { AgentInfo } from '../types.js';
|
||||
import type { AccountCredentialManager } from '../credentials/types.js';
|
||||
import { createModuleLogger } from '../../logger/index.js';
|
||||
import { getAccountConfigDir } from './paths.js';
|
||||
import { setupAccountConfigDir } from './setup.js';
|
||||
|
||||
const log = createModuleLogger('account-usage');
|
||||
|
||||
const USAGE_API_URL = 'https://api.anthropic.com/api/oauth/usage';
|
||||
const TOKEN_REFRESH_URL = 'https://console.anthropic.com/v1/oauth/token';
|
||||
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
||||
const TOKEN_REFRESH_BUFFER_MS = 300_000; // 5 minutes
|
||||
|
||||
export interface OAuthCredentials {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
expiresAt: number | null; // ms epoch, null for setup tokens
|
||||
subscriptionType: string | null;
|
||||
rateLimitTier: string | null;
|
||||
}
|
||||
|
||||
export interface UsageTier {
|
||||
utilization: number;
|
||||
resets_at: string | null;
|
||||
}
|
||||
|
||||
export interface AccountUsage {
|
||||
five_hour: UsageTier | null;
|
||||
seven_day: UsageTier | null;
|
||||
seven_day_sonnet: UsageTier | null;
|
||||
seven_day_opus: UsageTier | null;
|
||||
extra_usage: {
|
||||
is_enabled: boolean;
|
||||
monthly_limit: number | null;
|
||||
used_credits: number | null;
|
||||
utilization: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AccountHealthResult {
|
||||
id: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
credentialsValid: boolean;
|
||||
tokenValid: boolean;
|
||||
tokenExpiresAt: string | null;
|
||||
subscriptionType: string | null;
|
||||
error: string | null;
|
||||
usage: AccountUsage | null;
|
||||
isExhausted: boolean;
|
||||
exhaustedUntil: string | null;
|
||||
lastUsedAt: string | null;
|
||||
agentCount: number;
|
||||
activeAgentCount: number;
|
||||
}
|
||||
|
||||
function readCredentials(configDir: string): OAuthCredentials | null {
|
||||
try {
|
||||
const credPath = join(configDir, '.credentials.json');
|
||||
if (!existsSync(credPath)) return null;
|
||||
const raw = readFileSync(credPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const oauth = parsed.claudeAiOauth;
|
||||
if (!oauth || !oauth.accessToken) return null;
|
||||
return {
|
||||
accessToken: oauth.accessToken,
|
||||
refreshToken: oauth.refreshToken ?? null,
|
||||
expiresAt: oauth.expiresAt ?? null,
|
||||
subscriptionType: oauth.subscriptionType ?? null,
|
||||
rateLimitTier: oauth.rateLimitTier ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isTokenExpired(credentials: OAuthCredentials): boolean {
|
||||
if (!credentials.expiresAt) return false; // Setup tokens without expiry are treated as non-expired
|
||||
return credentials.expiresAt < Date.now() + TOKEN_REFRESH_BUFFER_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write credentials back to the config directory.
|
||||
* Matches ccswitch's update_credentials_with_token() behavior.
|
||||
*/
|
||||
function writeCredentials(
|
||||
configDir: string,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
expiresIn: number,
|
||||
): void {
|
||||
const credPath = join(configDir, '.credentials.json');
|
||||
|
||||
// Read existing credentials to preserve other fields
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
if (existsSync(credPath)) {
|
||||
existing = JSON.parse(readFileSync(credPath, 'utf-8'));
|
||||
}
|
||||
} catch {
|
||||
// Start fresh if can't read
|
||||
}
|
||||
|
||||
// Calculate expiry in milliseconds (matching ccswitch behavior)
|
||||
const nowMs = Date.now();
|
||||
const expiresAt = nowMs + (expiresIn * 1000);
|
||||
|
||||
// Update claudeAiOauth section
|
||||
const claudeAiOauth = (existing.claudeAiOauth as Record<string, unknown>) ?? {};
|
||||
claudeAiOauth.accessToken = accessToken;
|
||||
claudeAiOauth.refreshToken = refreshToken;
|
||||
claudeAiOauth.expiresAt = expiresAt;
|
||||
existing.claudeAiOauth = claudeAiOauth;
|
||||
|
||||
// Ensure directory exists
|
||||
mkdirSync(dirname(credPath), { recursive: true });
|
||||
|
||||
// Write back (compact JSON for consistency with ccswitch)
|
||||
writeFileSync(credPath, JSON.stringify(existing));
|
||||
log.debug({ configDir }, 'credentials written after token refresh');
|
||||
}
|
||||
|
||||
async function refreshToken(
|
||||
refreshTokenStr: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number } | null> {
|
||||
try {
|
||||
const response = await fetch(TOKEN_REFRESH_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshTokenStr,
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
scope: 'user:inference user:profile',
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresIn: data.expires_in,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type FetchUsageResult =
|
||||
| { ok: true; usage: AccountUsage }
|
||||
| { ok: false; status: number; statusText: string }
|
||||
| { ok: false; status: 0; statusText: string };
|
||||
|
||||
async function fetchUsage(accessToken: string): Promise<FetchUsageResult> {
|
||||
try {
|
||||
const response = await fetch(USAGE_API_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'anthropic-beta': 'oauth-2025-04-20',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { ok: false, status: response.status, statusText: response.statusText };
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
ok: true,
|
||||
usage: {
|
||||
five_hour: data.five_hour ?? null,
|
||||
seven_day: data.seven_day ?? null,
|
||||
seven_day_sonnet: data.seven_day_sonnet ?? null,
|
||||
seven_day_opus: data.seven_day_opus ?? null,
|
||||
extra_usage: data.extra_usage ?? null,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, status: 0, statusText: err instanceof Error ? err.message : 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAccountHealth(
|
||||
account: Account,
|
||||
agents: AgentInfo[],
|
||||
credentialManager?: AccountCredentialManager,
|
||||
workspaceRoot?: string,
|
||||
): Promise<AccountHealthResult> {
|
||||
const configDir = workspaceRoot ? getAccountConfigDir(workspaceRoot, account.id) : null;
|
||||
|
||||
const accountAgents = agents.filter((a) => a.accountId === account.id);
|
||||
const activeAgents = accountAgents.filter(
|
||||
(a) => a.status === 'running' || a.status === 'waiting_for_input',
|
||||
);
|
||||
|
||||
const base: AccountHealthResult = {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
provider: account.provider,
|
||||
credentialsValid: false,
|
||||
tokenValid: false,
|
||||
tokenExpiresAt: null,
|
||||
subscriptionType: null,
|
||||
error: null,
|
||||
usage: null,
|
||||
isExhausted: account.isExhausted,
|
||||
exhaustedUntil: account.exhaustedUntil?.toISOString() ?? null,
|
||||
lastUsedAt: account.lastUsedAt?.toISOString() ?? null,
|
||||
agentCount: accountAgents.length,
|
||||
activeAgentCount: activeAgents.length,
|
||||
};
|
||||
|
||||
if (!configDir) {
|
||||
return { ...base, error: 'Cannot derive config dir: workspaceRoot not provided' };
|
||||
}
|
||||
|
||||
// Ensure DB credentials are written to disk so file-based checks can find them
|
||||
if (account.configJson && account.credentials) {
|
||||
try {
|
||||
setupAccountConfigDir(configDir, {
|
||||
configJson: JSON.parse(account.configJson),
|
||||
credentials: account.credentials,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ accountId: account.id, err: err instanceof Error ? err.message : String(err) }, 'failed to sync DB credentials to disk');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Use credential manager if provided, otherwise fall back to direct functions
|
||||
let accessToken: string;
|
||||
let currentExpiresAt: number | null;
|
||||
let subscriptionType: string | null = null;
|
||||
|
||||
if (credentialManager) {
|
||||
const result = await credentialManager.ensureValid(configDir, account.id);
|
||||
if (!result.valid || !result.credentials) {
|
||||
return {
|
||||
...base,
|
||||
credentialsValid: result.credentials !== null,
|
||||
error: result.error ?? 'Credentials validation failed',
|
||||
};
|
||||
}
|
||||
accessToken = result.credentials.accessToken;
|
||||
currentExpiresAt = result.credentials.expiresAt;
|
||||
subscriptionType = result.credentials.subscriptionType;
|
||||
} else {
|
||||
// Legacy path: direct function calls
|
||||
const credentials = readCredentials(configDir);
|
||||
if (!credentials) {
|
||||
return {
|
||||
...base,
|
||||
error: 'Credentials file not found or unreadable',
|
||||
};
|
||||
}
|
||||
|
||||
accessToken = credentials.accessToken;
|
||||
currentExpiresAt = credentials.expiresAt;
|
||||
subscriptionType = credentials.subscriptionType;
|
||||
|
||||
if (isTokenExpired(credentials)) {
|
||||
if (!credentials.refreshToken) {
|
||||
log.warn({ accountId: account.id }, 'setup token expired, no refresh token');
|
||||
return {
|
||||
...base,
|
||||
credentialsValid: true,
|
||||
error: 'Setup token expired, no refresh token available',
|
||||
};
|
||||
}
|
||||
log.info({ accountId: account.id, email: account.email }, 'token expired, refreshing');
|
||||
const refreshed = await refreshToken(credentials.refreshToken);
|
||||
if (!refreshed) {
|
||||
log.warn({ accountId: account.id }, 'token refresh failed');
|
||||
return {
|
||||
...base,
|
||||
credentialsValid: true,
|
||||
error: 'Token expired and refresh failed',
|
||||
};
|
||||
}
|
||||
accessToken = refreshed.accessToken;
|
||||
|
||||
// Persist the refreshed credentials back to disk
|
||||
const newRefreshToken = refreshed.refreshToken || credentials.refreshToken;
|
||||
writeCredentials(configDir, accessToken, newRefreshToken, refreshed.expiresIn);
|
||||
currentExpiresAt = Date.now() + (refreshed.expiresIn * 1000);
|
||||
log.info({ accountId: account.id, expiresIn: refreshed.expiresIn }, 'token refreshed and persisted');
|
||||
}
|
||||
}
|
||||
|
||||
const isSetupToken = !currentExpiresAt;
|
||||
const usageResult = await fetchUsage(accessToken);
|
||||
|
||||
if (!usageResult.ok) {
|
||||
const statusDetail = usageResult.status > 0
|
||||
? `HTTP ${usageResult.status} ${usageResult.statusText}`
|
||||
: usageResult.statusText;
|
||||
|
||||
if (isSetupToken) {
|
||||
// Setup tokens often can't query the usage API — not a hard error
|
||||
return {
|
||||
...base,
|
||||
credentialsValid: true,
|
||||
tokenValid: true,
|
||||
tokenExpiresAt: null,
|
||||
subscriptionType,
|
||||
error: `Usage API unavailable for setup token (${statusDetail}). Run \`claude\` with this account to complete OAuth setup.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
credentialsValid: true,
|
||||
error: `Usage API request failed: ${statusDetail}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
credentialsValid: true,
|
||||
tokenValid: true,
|
||||
tokenExpiresAt: currentExpiresAt ? new Date(currentExpiresAt).toISOString() : null,
|
||||
subscriptionType,
|
||||
usage: usageResult.usage,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
...base,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure account credentials are valid and refreshed if needed.
|
||||
* Call this before spawning an agent to ensure the credentials file
|
||||
* has fresh tokens that the agent subprocess can use.
|
||||
*
|
||||
* Returns true if credentials are valid (or were successfully refreshed).
|
||||
* Returns false if credentials are missing or refresh failed.
|
||||
*
|
||||
* @deprecated Use AccountCredentialManager.ensureValid() instead for event emission support.
|
||||
*/
|
||||
export async function ensureAccountCredentials(configDir: string): Promise<boolean> {
|
||||
const credentials = readCredentials(configDir);
|
||||
if (!credentials) {
|
||||
log.warn({ configDir }, 'no credentials found');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isTokenExpired(credentials)) {
|
||||
log.debug({ configDir }, 'credentials valid, no refresh needed');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!credentials.refreshToken) {
|
||||
log.error({ configDir }, 'setup token expired, no refresh token available');
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info({ configDir }, 'credentials expired, refreshing before spawn');
|
||||
const refreshed = await refreshToken(credentials.refreshToken);
|
||||
if (!refreshed) {
|
||||
log.error({ configDir }, 'failed to refresh credentials');
|
||||
return false;
|
||||
}
|
||||
|
||||
const newRefreshToken = refreshed.refreshToken || credentials.refreshToken;
|
||||
writeCredentials(configDir, refreshed.accessToken, newRefreshToken, refreshed.expiresIn);
|
||||
log.info({ configDir, expiresIn: refreshed.expiresIn }, 'credentials refreshed before spawn');
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user