opencode-account-manager 0.6.4 → 0.6.6
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 +235 -216
- package/README_VI.md +235 -216
- package/dist/cli.js +83 -0
- package/dist/cli.js.map +1 -1
- package/dist/core/config-store.d.ts +12 -0
- package/dist/core/config-store.d.ts.map +1 -1
- package/dist/core/config-store.js +98 -0
- package/dist/core/config-store.js.map +1 -1
- package/dist/core/health-log.d.ts +9 -0
- package/dist/core/health-log.d.ts.map +1 -0
- package/dist/core/health-log.js +154 -0
- package/dist/core/health-log.js.map +1 -0
- package/dist/core/health-oauth.d.ts +5 -0
- package/dist/core/health-oauth.d.ts.map +1 -0
- package/dist/core/health-oauth.js +147 -0
- package/dist/core/health-oauth.js.map +1 -0
- package/dist/core/health-orchestrator.d.ts +32 -0
- package/dist/core/health-orchestrator.d.ts.map +1 -0
- package/dist/core/health-orchestrator.js +148 -0
- package/dist/core/health-orchestrator.js.map +1 -0
- package/dist/core/health-utils.d.ts +15 -0
- package/dist/core/health-utils.d.ts.map +1 -0
- package/dist/core/health-utils.js +60 -0
- package/dist/core/health-utils.js.map +1 -0
- package/dist/core/paths.d.ts +1 -0
- package/dist/core/paths.d.ts.map +1 -1
- package/dist/core/paths.js +4 -0
- package/dist/core/paths.js.map +1 -1
- package/dist/core/types.d.ts +26 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/tui/Dashboard.d.ts.map +1 -1
- package/dist/tui/Dashboard.js +69 -2
- package/dist/tui/Dashboard.js.map +1 -1
- package/dist/tui/components/AccountList.d.ts +5 -3
- package/dist/tui/components/AccountList.d.ts.map +1 -1
- package/dist/tui/components/AccountList.js +9 -3
- package/dist/tui/components/AccountList.js.map +1 -1
- package/dist/tui/components/DashboardView.d.ts +3 -2
- package/dist/tui/components/DashboardView.d.ts.map +1 -1
- package/dist/tui/components/DashboardView.js +102 -17
- package/dist/tui/components/DashboardView.js.map +1 -1
- package/dist/tui/components/HealthBadge.d.ts +9 -0
- package/dist/tui/components/HealthBadge.d.ts.map +1 -0
- package/dist/tui/components/HealthBadge.js +56 -0
- package/dist/tui/components/HealthBadge.js.map +1 -0
- package/dist/tui/components/StatusBadge.d.ts +2 -1
- package/dist/tui/components/StatusBadge.d.ts.map +1 -1
- package/dist/tui/components/StatusBadge.js +30 -2
- package/dist/tui/components/StatusBadge.js.map +1 -1
- package/dist/tui/components/index.d.ts +1 -0
- package/dist/tui/components/index.d.ts.map +1 -1
- package/dist/tui/components/index.js +3 -1
- package/dist/tui/components/index.js.map +1 -1
- package/docs/BLUEPRINT.md +476 -476
- package/docs/ROADMAP.md +125 -107
- package/package.json +38 -38
- package/src/cli.ts +139 -38
- package/src/core/config-store.ts +278 -171
- package/src/core/crypto.ts +162 -162
- package/src/core/health-log.ts +173 -0
- package/src/core/health-oauth.ts +190 -0
- package/src/core/health-orchestrator.ts +224 -0
- package/src/core/importers/amExport.ts +177 -177
- package/src/core/opencode-config.ts +217 -217
- package/src/core/paths.ts +10 -6
- package/src/core/types.ts +193 -147
- package/src/tui/Dashboard.tsx +557 -478
- package/src/tui/components/AccountList.tsx +122 -104
- package/src/tui/components/ActionPalette.tsx +117 -117
- package/src/tui/components/Box.tsx +7 -7
- package/src/tui/components/DashboardView.tsx +324 -220
- package/src/tui/components/ExportModal.tsx +255 -255
- package/src/tui/components/FileBrowser.tsx +393 -393
- package/src/tui/components/Header.tsx +26 -26
- package/src/tui/components/HealthBadge.tsx +64 -0
- package/src/tui/components/ImportModal.tsx +334 -334
- package/src/tui/components/McpServerList.tsx +67 -67
- package/src/tui/components/Menu.tsx +61 -61
- package/src/tui/components/PasswordInput.tsx +159 -159
- package/src/tui/components/ProviderList.tsx +59 -59
- package/src/tui/components/SectionBox.tsx +35 -35
- package/src/tui/components/StatsRow.tsx +33 -33
- package/src/tui/components/StatusBadge.tsx +36 -3
- package/src/tui/components/index.ts +15 -14
- package/test-minimal.js +26 -26
- package/test-with-accounts.js +58 -58
package/src/core/crypto.ts
CHANGED
|
@@ -1,162 +1,162 @@
|
|
|
1
|
-
import * as crypto from "crypto";
|
|
2
|
-
|
|
3
|
-
// ============================================================================
|
|
4
|
-
// Constants
|
|
5
|
-
// ============================================================================
|
|
6
|
-
|
|
7
|
-
const ALGORITHM = "aes-256-gcm";
|
|
8
|
-
const KEY_LENGTH = 32; // 256 bits
|
|
9
|
-
const SALT_LENGTH = 32; // 256 bits
|
|
10
|
-
const IV_LENGTH = 12; // 96 bits (recommended for GCM)
|
|
11
|
-
const AUTH_TAG_LENGTH = 16; // 128 bits
|
|
12
|
-
|
|
13
|
-
// scrypt parameters (N=16384, r=8, p=1)
|
|
14
|
-
const SCRYPT_N = 16384;
|
|
15
|
-
const SCRYPT_R = 8;
|
|
16
|
-
const SCRYPT_P = 1;
|
|
17
|
-
|
|
18
|
-
// ============================================================================
|
|
19
|
-
// Types
|
|
20
|
-
// ============================================================================
|
|
21
|
-
|
|
22
|
-
export interface EncryptedData {
|
|
23
|
-
salt: string; // hex
|
|
24
|
-
iv: string; // hex
|
|
25
|
-
authTag: string; // hex
|
|
26
|
-
data: string; // hex (encrypted content)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// ============================================================================
|
|
30
|
-
// Helper Functions
|
|
31
|
-
// ============================================================================
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Generate random bytes and return as hex string
|
|
35
|
-
*/
|
|
36
|
-
function randomHex(length: number): string {
|
|
37
|
-
return crypto.randomBytes(length).toString("hex");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Derive encryption key from password using scrypt
|
|
42
|
-
*/
|
|
43
|
-
function deriveKey(password: string, salt: Buffer): Buffer {
|
|
44
|
-
return crypto.scryptSync(password, salt, KEY_LENGTH, {
|
|
45
|
-
N: SCRYPT_N,
|
|
46
|
-
r: SCRYPT_R,
|
|
47
|
-
p: SCRYPT_P,
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ============================================================================
|
|
52
|
-
// Public Functions
|
|
53
|
-
// ============================================================================
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Generate a random salt for encryption
|
|
57
|
-
*/
|
|
58
|
-
export function generateSalt(): string {
|
|
59
|
-
return randomHex(SALT_LENGTH);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Generate a random IV for encryption
|
|
64
|
-
*/
|
|
65
|
-
export function generateIV(): string {
|
|
66
|
-
return randomHex(IV_LENGTH);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Encrypt data object with password using AES-256-GCM
|
|
71
|
-
*
|
|
72
|
-
* @param data - Object to encrypt (will be JSON stringified)
|
|
73
|
-
* @param password - Password for encryption
|
|
74
|
-
* @returns Encrypted data with salt, iv, authTag, and encrypted content
|
|
75
|
-
*/
|
|
76
|
-
export function encrypt(data: object, password: string): EncryptedData {
|
|
77
|
-
// Generate random salt and IV
|
|
78
|
-
const salt = Buffer.from(generateSalt(), "hex");
|
|
79
|
-
const iv = Buffer.from(generateIV(), "hex");
|
|
80
|
-
|
|
81
|
-
// Derive key from password
|
|
82
|
-
const key = deriveKey(password, salt);
|
|
83
|
-
|
|
84
|
-
// Create cipher
|
|
85
|
-
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, {
|
|
86
|
-
authTagLength: AUTH_TAG_LENGTH,
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
// Encrypt data
|
|
90
|
-
const plaintext = JSON.stringify(data);
|
|
91
|
-
const encrypted = Buffer.concat([
|
|
92
|
-
cipher.update(plaintext, "utf8"),
|
|
93
|
-
cipher.final(),
|
|
94
|
-
]);
|
|
95
|
-
|
|
96
|
-
// Get auth tag
|
|
97
|
-
const authTag = cipher.getAuthTag();
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
salt: salt.toString("hex"),
|
|
101
|
-
iv: iv.toString("hex"),
|
|
102
|
-
authTag: authTag.toString("hex"),
|
|
103
|
-
data: encrypted.toString("hex"),
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Decrypt data with password using AES-256-GCM
|
|
109
|
-
*
|
|
110
|
-
* @param encrypted - Encrypted data object
|
|
111
|
-
* @param password - Password for decryption
|
|
112
|
-
* @returns Decrypted object
|
|
113
|
-
* @throws Error if password is wrong or data is corrupted
|
|
114
|
-
*/
|
|
115
|
-
export function decrypt<T = unknown>(encrypted: EncryptedData, password: string): T {
|
|
116
|
-
// Convert hex strings to buffers
|
|
117
|
-
const salt = Buffer.from(encrypted.salt, "hex");
|
|
118
|
-
const iv = Buffer.from(encrypted.iv, "hex");
|
|
119
|
-
const authTag = Buffer.from(encrypted.authTag, "hex");
|
|
120
|
-
const data = Buffer.from(encrypted.data, "hex");
|
|
121
|
-
|
|
122
|
-
// Derive key from password
|
|
123
|
-
const key = deriveKey(password, salt);
|
|
124
|
-
|
|
125
|
-
// Create decipher
|
|
126
|
-
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, {
|
|
127
|
-
authTagLength: AUTH_TAG_LENGTH,
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
// Set auth tag for verification
|
|
131
|
-
decipher.setAuthTag(authTag);
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
// Decrypt data
|
|
135
|
-
const decrypted = Buffer.concat([
|
|
136
|
-
decipher.update(data),
|
|
137
|
-
decipher.final(),
|
|
138
|
-
]);
|
|
139
|
-
|
|
140
|
-
// Parse JSON
|
|
141
|
-
return JSON.parse(decrypted.toString("utf8")) as T;
|
|
142
|
-
} catch (error) {
|
|
143
|
-
// Auth tag verification failed or JSON parse failed
|
|
144
|
-
throw new Error("Invalid password or corrupted file");
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Verify if a password can decrypt the data
|
|
150
|
-
*
|
|
151
|
-
* @param encrypted - Encrypted data object
|
|
152
|
-
* @param password - Password to verify
|
|
153
|
-
* @returns true if password is correct
|
|
154
|
-
*/
|
|
155
|
-
export function verifyPassword(encrypted: EncryptedData, password: string): boolean {
|
|
156
|
-
try {
|
|
157
|
-
decrypt(encrypted, password);
|
|
158
|
-
return true;
|
|
159
|
-
} catch {
|
|
160
|
-
return false;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
1
|
+
import * as crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Constants
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
const ALGORITHM = "aes-256-gcm";
|
|
8
|
+
const KEY_LENGTH = 32; // 256 bits
|
|
9
|
+
const SALT_LENGTH = 32; // 256 bits
|
|
10
|
+
const IV_LENGTH = 12; // 96 bits (recommended for GCM)
|
|
11
|
+
const AUTH_TAG_LENGTH = 16; // 128 bits
|
|
12
|
+
|
|
13
|
+
// scrypt parameters (N=16384, r=8, p=1)
|
|
14
|
+
const SCRYPT_N = 16384;
|
|
15
|
+
const SCRYPT_R = 8;
|
|
16
|
+
const SCRYPT_P = 1;
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Types
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
export interface EncryptedData {
|
|
23
|
+
salt: string; // hex
|
|
24
|
+
iv: string; // hex
|
|
25
|
+
authTag: string; // hex
|
|
26
|
+
data: string; // hex (encrypted content)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Helper Functions
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generate random bytes and return as hex string
|
|
35
|
+
*/
|
|
36
|
+
function randomHex(length: number): string {
|
|
37
|
+
return crypto.randomBytes(length).toString("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Derive encryption key from password using scrypt
|
|
42
|
+
*/
|
|
43
|
+
function deriveKey(password: string, salt: Buffer): Buffer {
|
|
44
|
+
return crypto.scryptSync(password, salt, KEY_LENGTH, {
|
|
45
|
+
N: SCRYPT_N,
|
|
46
|
+
r: SCRYPT_R,
|
|
47
|
+
p: SCRYPT_P,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Public Functions
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generate a random salt for encryption
|
|
57
|
+
*/
|
|
58
|
+
export function generateSalt(): string {
|
|
59
|
+
return randomHex(SALT_LENGTH);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generate a random IV for encryption
|
|
64
|
+
*/
|
|
65
|
+
export function generateIV(): string {
|
|
66
|
+
return randomHex(IV_LENGTH);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Encrypt data object with password using AES-256-GCM
|
|
71
|
+
*
|
|
72
|
+
* @param data - Object to encrypt (will be JSON stringified)
|
|
73
|
+
* @param password - Password for encryption
|
|
74
|
+
* @returns Encrypted data with salt, iv, authTag, and encrypted content
|
|
75
|
+
*/
|
|
76
|
+
export function encrypt(data: object, password: string): EncryptedData {
|
|
77
|
+
// Generate random salt and IV
|
|
78
|
+
const salt = Buffer.from(generateSalt(), "hex");
|
|
79
|
+
const iv = Buffer.from(generateIV(), "hex");
|
|
80
|
+
|
|
81
|
+
// Derive key from password
|
|
82
|
+
const key = deriveKey(password, salt);
|
|
83
|
+
|
|
84
|
+
// Create cipher
|
|
85
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, {
|
|
86
|
+
authTagLength: AUTH_TAG_LENGTH,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Encrypt data
|
|
90
|
+
const plaintext = JSON.stringify(data);
|
|
91
|
+
const encrypted = Buffer.concat([
|
|
92
|
+
cipher.update(plaintext, "utf8"),
|
|
93
|
+
cipher.final(),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
// Get auth tag
|
|
97
|
+
const authTag = cipher.getAuthTag();
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
salt: salt.toString("hex"),
|
|
101
|
+
iv: iv.toString("hex"),
|
|
102
|
+
authTag: authTag.toString("hex"),
|
|
103
|
+
data: encrypted.toString("hex"),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Decrypt data with password using AES-256-GCM
|
|
109
|
+
*
|
|
110
|
+
* @param encrypted - Encrypted data object
|
|
111
|
+
* @param password - Password for decryption
|
|
112
|
+
* @returns Decrypted object
|
|
113
|
+
* @throws Error if password is wrong or data is corrupted
|
|
114
|
+
*/
|
|
115
|
+
export function decrypt<T = unknown>(encrypted: EncryptedData, password: string): T {
|
|
116
|
+
// Convert hex strings to buffers
|
|
117
|
+
const salt = Buffer.from(encrypted.salt, "hex");
|
|
118
|
+
const iv = Buffer.from(encrypted.iv, "hex");
|
|
119
|
+
const authTag = Buffer.from(encrypted.authTag, "hex");
|
|
120
|
+
const data = Buffer.from(encrypted.data, "hex");
|
|
121
|
+
|
|
122
|
+
// Derive key from password
|
|
123
|
+
const key = deriveKey(password, salt);
|
|
124
|
+
|
|
125
|
+
// Create decipher
|
|
126
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, {
|
|
127
|
+
authTagLength: AUTH_TAG_LENGTH,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Set auth tag for verification
|
|
131
|
+
decipher.setAuthTag(authTag);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
// Decrypt data
|
|
135
|
+
const decrypted = Buffer.concat([
|
|
136
|
+
decipher.update(data),
|
|
137
|
+
decipher.final(),
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
// Parse JSON
|
|
141
|
+
return JSON.parse(decrypted.toString("utf8")) as T;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
// Auth tag verification failed or JSON parse failed
|
|
144
|
+
throw new Error("Invalid password or corrupted file");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Verify if a password can decrypt the data
|
|
150
|
+
*
|
|
151
|
+
* @param encrypted - Encrypted data object
|
|
152
|
+
* @param password - Password to verify
|
|
153
|
+
* @returns true if password is correct
|
|
154
|
+
*/
|
|
155
|
+
export function verifyPassword(encrypted: EncryptedData, password: string): boolean {
|
|
156
|
+
try {
|
|
157
|
+
decrypt(encrypted, password);
|
|
158
|
+
return true;
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { AccountHealthResult, AccountHealthStatus } from "./types";
|
|
4
|
+
import { getAntigravityLogsPath } from "./paths";
|
|
5
|
+
import { normalizeHealthKey } from "./config-store";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MAX_FILES = 10;
|
|
8
|
+
const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
const EMAIL_REGEX = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
|
10
|
+
|
|
11
|
+
const STATUS_PRIORITY: Record<AccountHealthStatus, number> = {
|
|
12
|
+
verification_required: 9,
|
|
13
|
+
disabled: 8,
|
|
14
|
+
deleted: 7,
|
|
15
|
+
password_changed: 6,
|
|
16
|
+
revoked: 5,
|
|
17
|
+
network_error: 4,
|
|
18
|
+
unknown_error: 3,
|
|
19
|
+
not_configured: 2,
|
|
20
|
+
not_checked: 1,
|
|
21
|
+
ok: 0,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function mapLineToStatus(line: string): AccountHealthStatus | undefined {
|
|
25
|
+
const text = line.toLowerCase();
|
|
26
|
+
|
|
27
|
+
if (
|
|
28
|
+
text.includes("verification required") ||
|
|
29
|
+
text.includes("complete verification") ||
|
|
30
|
+
text.includes("verify your account") ||
|
|
31
|
+
text.includes("login_required") ||
|
|
32
|
+
text.includes("challenge")
|
|
33
|
+
) {
|
|
34
|
+
return "verification_required";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (text.includes("invalid_grant")) {
|
|
38
|
+
if (text.includes("disabled")) return "disabled";
|
|
39
|
+
if (text.includes("deleted")) return "deleted";
|
|
40
|
+
if (text.includes("password")) return "password_changed";
|
|
41
|
+
if (text.includes("revoked") || text.includes("expired")) return "revoked";
|
|
42
|
+
return "revoked";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (text.includes("account has been disabled")) return "disabled";
|
|
46
|
+
if (text.includes("account has been deleted")) return "deleted";
|
|
47
|
+
if (text.includes("password changed")) return "password_changed";
|
|
48
|
+
if (text.includes("revoked")) return "revoked";
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
text.includes("timeout") ||
|
|
52
|
+
text.includes("econnreset") ||
|
|
53
|
+
text.includes("enetunreach") ||
|
|
54
|
+
text.includes("eai_again") ||
|
|
55
|
+
text.includes("rate limit")
|
|
56
|
+
) {
|
|
57
|
+
return "network_error";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mergeHealthResults(
|
|
64
|
+
left: AccountHealthResult | undefined,
|
|
65
|
+
right: AccountHealthResult | undefined
|
|
66
|
+
): AccountHealthResult | undefined {
|
|
67
|
+
if (!left) return right;
|
|
68
|
+
if (!right) return left;
|
|
69
|
+
|
|
70
|
+
const leftPriority = STATUS_PRIORITY[left.status] ?? 0;
|
|
71
|
+
const rightPriority = STATUS_PRIORITY[right.status] ?? 0;
|
|
72
|
+
if (leftPriority !== rightPriority) {
|
|
73
|
+
return leftPriority > rightPriority ? left : right;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (left.checkedAt !== right.checkedAt) {
|
|
77
|
+
return left.checkedAt > right.checkedAt ? left : right;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sourcePriority = { oauth: 3, log: 2, cache: 1, manual: 0 } as const;
|
|
81
|
+
const leftSource = sourcePriority[left.source] ?? 0;
|
|
82
|
+
const rightSource = sourcePriority[right.source] ?? 0;
|
|
83
|
+
return leftSource >= rightSource ? left : right;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readFileTail(filePath: string, maxBytes: number): string {
|
|
87
|
+
const stat = fs.statSync(filePath);
|
|
88
|
+
const size = stat.size;
|
|
89
|
+
if (size <= maxBytes) {
|
|
90
|
+
return fs.readFileSync(filePath, "utf8");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fd = fs.openSync(filePath, "r");
|
|
94
|
+
try {
|
|
95
|
+
const buffer = Buffer.allocUnsafe(maxBytes);
|
|
96
|
+
const start = Math.max(0, size - maxBytes);
|
|
97
|
+
fs.readSync(fd, buffer, 0, maxBytes, start);
|
|
98
|
+
return buffer.toString("utf8");
|
|
99
|
+
} finally {
|
|
100
|
+
fs.closeSync(fd);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface LogHealthOptions {
|
|
105
|
+
logDir?: string;
|
|
106
|
+
maxFiles?: number;
|
|
107
|
+
maxBytes?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function collectLogHealthResults(
|
|
111
|
+
options: LogHealthOptions = {}
|
|
112
|
+
): Record<string, AccountHealthResult> {
|
|
113
|
+
const logDir = options.logDir || getAntigravityLogsPath();
|
|
114
|
+
if (!fs.existsSync(logDir)) return {};
|
|
115
|
+
|
|
116
|
+
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
|
|
117
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
118
|
+
|
|
119
|
+
const entries = fs.readdirSync(logDir)
|
|
120
|
+
.map((name) => {
|
|
121
|
+
const fullPath = path.join(logDir, name);
|
|
122
|
+
try {
|
|
123
|
+
const stat = fs.statSync(fullPath);
|
|
124
|
+
return { name, fullPath, stat };
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
.filter((entry): entry is { name: string; fullPath: string; stat: fs.Stats } => !!entry)
|
|
130
|
+
.filter((entry) => entry.stat.isFile())
|
|
131
|
+
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
|
|
132
|
+
.slice(0, maxFiles);
|
|
133
|
+
|
|
134
|
+
const results: Record<string, AccountHealthResult> = {};
|
|
135
|
+
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
let content = "";
|
|
138
|
+
try {
|
|
139
|
+
content = readFileTail(entry.fullPath, maxBytes);
|
|
140
|
+
} catch {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const lines = content.split(/\r?\n/);
|
|
145
|
+
for (const line of lines) {
|
|
146
|
+
const status = mapLineToStatus(line);
|
|
147
|
+
if (!status) continue;
|
|
148
|
+
|
|
149
|
+
const emails = line.match(EMAIL_REGEX) || [];
|
|
150
|
+
if (emails.length === 0) continue;
|
|
151
|
+
|
|
152
|
+
for (const email of emails) {
|
|
153
|
+
const key = normalizeHealthKey(email);
|
|
154
|
+
const candidate: AccountHealthResult = {
|
|
155
|
+
status,
|
|
156
|
+
source: "log",
|
|
157
|
+
checkedAt: entry.stat.mtimeMs,
|
|
158
|
+
message: line.trim().slice(0, 200),
|
|
159
|
+
};
|
|
160
|
+
results[key] = mergeHealthResults(results[key], candidate) || candidate;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return results;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function mergeAccountHealth(
|
|
169
|
+
primary: AccountHealthResult | undefined,
|
|
170
|
+
secondary: AccountHealthResult | undefined
|
|
171
|
+
): AccountHealthResult | undefined {
|
|
172
|
+
return mergeHealthResults(primary, secondary);
|
|
173
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import https from "https";
|
|
2
|
+
import {
|
|
3
|
+
AccountHealthResult,
|
|
4
|
+
AccountHealthStatus,
|
|
5
|
+
HealthOAuthConfig,
|
|
6
|
+
} from "./types";
|
|
7
|
+
import { getHealthOAuthConfig } from "./config-store";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
10
|
+
const REQUEST_TIMEOUT_MS = 10000;
|
|
11
|
+
|
|
12
|
+
interface OAuthErrorResponse {
|
|
13
|
+
error?: string;
|
|
14
|
+
error_description?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface OAuthSuccessResponse {
|
|
18
|
+
access_token: string;
|
|
19
|
+
expires_in?: number;
|
|
20
|
+
scope?: string;
|
|
21
|
+
token_type?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveOAuthConfig(): HealthOAuthConfig | undefined {
|
|
25
|
+
const config = getHealthOAuthConfig() || {};
|
|
26
|
+
const clientId = process.env.OCAM_OAUTH_CLIENT_ID || config.clientId;
|
|
27
|
+
const clientSecret = process.env.OCAM_OAUTH_CLIENT_SECRET || config.clientSecret;
|
|
28
|
+
const tokenEndpoint = process.env.OCAM_OAUTH_TOKEN_ENDPOINT || config.tokenEndpoint;
|
|
29
|
+
if (!clientId || !clientSecret) return undefined;
|
|
30
|
+
return {
|
|
31
|
+
clientId,
|
|
32
|
+
clientSecret,
|
|
33
|
+
tokenEndpoint: tokenEndpoint || DEFAULT_TOKEN_ENDPOINT,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isOAuthHealthCheckConfigured(): boolean {
|
|
38
|
+
return !!resolveOAuthConfig();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mapOAuthError(
|
|
42
|
+
error?: string,
|
|
43
|
+
description?: string,
|
|
44
|
+
httpStatus?: number
|
|
45
|
+
): AccountHealthStatus {
|
|
46
|
+
const desc = (description || "").toLowerCase();
|
|
47
|
+
|
|
48
|
+
if (error === "invalid_client") return "not_configured";
|
|
49
|
+
|
|
50
|
+
if (error === "invalid_grant") {
|
|
51
|
+
if (desc.includes("disabled")) return "disabled";
|
|
52
|
+
if (desc.includes("deleted")) return "deleted";
|
|
53
|
+
if (desc.includes("password")) return "password_changed";
|
|
54
|
+
if (
|
|
55
|
+
desc.includes("verify") ||
|
|
56
|
+
desc.includes("verification") ||
|
|
57
|
+
desc.includes("challenge") ||
|
|
58
|
+
desc.includes("login_required") ||
|
|
59
|
+
desc.includes("login required")
|
|
60
|
+
) {
|
|
61
|
+
return "verification_required";
|
|
62
|
+
}
|
|
63
|
+
if (desc.includes("revoked") || desc.includes("expired")) return "revoked";
|
|
64
|
+
return "revoked";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (error === "consent_required") return "verification_required";
|
|
68
|
+
if (error === "access_denied") return "disabled";
|
|
69
|
+
if (error === "rate_limit_exceeded") return "network_error";
|
|
70
|
+
if (error === "server_error") return "network_error";
|
|
71
|
+
if (error === "temporarily_unavailable") return "network_error";
|
|
72
|
+
|
|
73
|
+
if (httpStatus && httpStatus >= 500) return "network_error";
|
|
74
|
+
|
|
75
|
+
return "unknown_error";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildResult(
|
|
79
|
+
status: AccountHealthStatus,
|
|
80
|
+
source: "oauth",
|
|
81
|
+
detail?: {
|
|
82
|
+
error?: string;
|
|
83
|
+
errorDescription?: string;
|
|
84
|
+
httpStatus?: number;
|
|
85
|
+
message?: string;
|
|
86
|
+
}
|
|
87
|
+
): AccountHealthResult {
|
|
88
|
+
return {
|
|
89
|
+
status,
|
|
90
|
+
source,
|
|
91
|
+
checkedAt: Date.now(),
|
|
92
|
+
message: detail?.message,
|
|
93
|
+
errorCode: detail?.error,
|
|
94
|
+
errorDescription: detail?.errorDescription,
|
|
95
|
+
httpStatus: detail?.httpStatus,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildOAuthRequestBody(refreshToken: string, config: HealthOAuthConfig): string {
|
|
100
|
+
const params = new URLSearchParams({
|
|
101
|
+
client_id: config.clientId || "",
|
|
102
|
+
client_secret: config.clientSecret || "",
|
|
103
|
+
refresh_token: refreshToken,
|
|
104
|
+
grant_type: "refresh_token",
|
|
105
|
+
});
|
|
106
|
+
return params.toString();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function checkAccountHealthOAuth(
|
|
110
|
+
refreshToken: string
|
|
111
|
+
): Promise<AccountHealthResult> {
|
|
112
|
+
const config = resolveOAuthConfig();
|
|
113
|
+
if (!config) {
|
|
114
|
+
return buildResult("not_configured", "oauth", {
|
|
115
|
+
message: "Missing OAuth client_id/client_secret",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!refreshToken) {
|
|
120
|
+
return buildResult("unknown_error", "oauth", {
|
|
121
|
+
message: "Missing refresh token",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const tokenEndpoint = config.tokenEndpoint || DEFAULT_TOKEN_ENDPOINT;
|
|
126
|
+
const body = buildOAuthRequestBody(refreshToken, config);
|
|
127
|
+
|
|
128
|
+
return new Promise<AccountHealthResult>((resolve) => {
|
|
129
|
+
const request = https.request(
|
|
130
|
+
tokenEndpoint,
|
|
131
|
+
{
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: {
|
|
134
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
135
|
+
"Content-Length": Buffer.byteLength(body),
|
|
136
|
+
},
|
|
137
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
138
|
+
},
|
|
139
|
+
(response) => {
|
|
140
|
+
const chunks: Buffer[] = [];
|
|
141
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
142
|
+
response.on("end", () => {
|
|
143
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
144
|
+
const httpStatus = response.statusCode || 0;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(raw) as OAuthSuccessResponse & OAuthErrorResponse;
|
|
148
|
+
|
|
149
|
+
if (httpStatus >= 200 && httpStatus < 300 && parsed.access_token) {
|
|
150
|
+
resolve(buildResult("ok", "oauth", { httpStatus }));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const status = mapOAuthError(parsed.error, parsed.error_description, httpStatus);
|
|
155
|
+
resolve(
|
|
156
|
+
buildResult(status, "oauth", {
|
|
157
|
+
error: parsed.error,
|
|
158
|
+
errorDescription: parsed.error_description,
|
|
159
|
+
httpStatus,
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const status = httpStatus >= 500 ? "network_error" : "unknown_error";
|
|
164
|
+
resolve(
|
|
165
|
+
buildResult(status, "oauth", {
|
|
166
|
+
httpStatus,
|
|
167
|
+
message: `Invalid response: ${error instanceof Error ? error.message : "unknown"}`,
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
request.on("timeout", () => {
|
|
176
|
+
request.destroy(new Error("Request timeout"));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
request.on("error", (err) => {
|
|
180
|
+
resolve(
|
|
181
|
+
buildResult("network_error", "oauth", {
|
|
182
|
+
message: err.message,
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
request.write(body);
|
|
188
|
+
request.end();
|
|
189
|
+
});
|
|
190
|
+
}
|