@pedrofariasx/qwenproxy 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +292 -0
  3. package/bin/qwenproxy.mjs +11 -0
  4. package/package.json +56 -0
  5. package/src/api/models.ts +183 -0
  6. package/src/api/server.ts +126 -0
  7. package/src/cache/memory-cache.ts +186 -0
  8. package/src/core/account-manager.ts +132 -0
  9. package/src/core/accounts.ts +78 -0
  10. package/src/core/config.ts +91 -0
  11. package/src/core/database.ts +92 -0
  12. package/src/core/logger.ts +96 -0
  13. package/src/core/metrics.ts +169 -0
  14. package/src/core/model-registry.ts +30 -0
  15. package/src/core/stream-registry.ts +40 -0
  16. package/src/core/watchdog.ts +130 -0
  17. package/src/index.ts +7 -0
  18. package/src/linter/extraction-engine.ts +165 -0
  19. package/src/linter/index.ts +258 -0
  20. package/src/linter/repair-normalize.ts +245 -0
  21. package/src/linter/safety-gate.ts +219 -0
  22. package/src/linter/streaming-state-machine.ts +252 -0
  23. package/src/linter/structural-parser.ts +352 -0
  24. package/src/linter/types.ts +74 -0
  25. package/src/login.ts +228 -0
  26. package/src/routes/chat.ts +801 -0
  27. package/src/routes/upload.ts +700 -0
  28. package/src/services/playwright.ts +778 -0
  29. package/src/services/qwen.ts +500 -0
  30. package/src/tests/advanced.test.ts +227 -0
  31. package/src/tests/agenticStress.test.ts +360 -0
  32. package/src/tests/concurrency.test.ts +103 -0
  33. package/src/tests/concurrentChat.test.ts +71 -0
  34. package/src/tests/delta.test.ts +63 -0
  35. package/src/tests/index.test.ts +356 -0
  36. package/src/tests/jsonFix.test.ts +98 -0
  37. package/src/tests/linter.test.ts +151 -0
  38. package/src/tests/parallel.test.ts +42 -0
  39. package/src/tests/parser.test.ts +89 -0
  40. package/src/tests/rotation.test.ts +45 -0
  41. package/src/tests/streamingOptimizations.test.ts +328 -0
  42. package/src/tests/structureVerification.test.ts +176 -0
  43. package/src/tools/ast.ts +15 -0
  44. package/src/tools/coercion.ts +67 -0
  45. package/src/tools/confidence.ts +48 -0
  46. package/src/tools/detector.ts +40 -0
  47. package/src/tools/executor.ts +236 -0
  48. package/src/tools/parser.ts +446 -0
  49. package/src/tools/pipeline.ts +122 -0
  50. package/src/tools/registry-runtime.ts +34 -0
  51. package/src/tools/registry.ts +142 -0
  52. package/src/tools/repair.ts +42 -0
  53. package/src/tools/schema.ts +285 -0
  54. package/src/tools/types.ts +104 -0
  55. package/src/tools/validator.ts +33 -0
  56. package/src/utils/context-truncation.ts +61 -0
  57. package/src/utils/json.ts +114 -0
  58. package/src/utils/qwen-stream-parser.ts +286 -0
  59. package/src/utils/types.ts +101 -0
@@ -0,0 +1,186 @@
1
+ import { config } from '../core/config.js'
2
+ import { metrics } from '../core/metrics.js'
3
+
4
+ export type CacheKey =
5
+ | `auth:${string}`
6
+ | `session:${string}`
7
+ | `prompt:${string}`
8
+ | `response:${string}`
9
+ | `rate:${string}`
10
+ | `models:${string}`
11
+
12
+ interface CacheEntry<T> {
13
+ value: T
14
+ expiresAt: number
15
+ }
16
+
17
+ export class MemoryCache {
18
+ private store: Map<string, CacheEntry<any>>
19
+ private defaultTTL: number
20
+ private prefix: string
21
+ private cleanupInterval: NodeJS.Timeout | null
22
+
23
+ constructor(options?: { prefix?: string; defaultTTL?: number }) {
24
+ this.prefix = options?.prefix || 'qwenproxy:'
25
+ this.defaultTTL = options?.defaultTTL || config.cache.defaultTTL
26
+ this.store = new Map()
27
+ this.cleanupInterval = null
28
+
29
+ this.startCleanup()
30
+ }
31
+
32
+ private startCleanup(): void {
33
+ this.cleanupInterval = setInterval(() => {
34
+ const now = Date.now()
35
+ for (const [key, entry] of this.store.entries()) {
36
+ if (entry.expiresAt <= now) {
37
+ this.store.delete(key)
38
+ }
39
+ }
40
+ }, 60000)
41
+ }
42
+
43
+ async connect(): Promise<void> {
44
+ // No-op for in-memory cache
45
+ }
46
+
47
+ async set<T>(key: CacheKey, value: T, ttl?: number): Promise<void> {
48
+ const serialized = JSON.stringify(value)
49
+ const effectiveTTL = ttl || this.defaultTTL
50
+ const fullKey = this.prefix + key
51
+
52
+ this.store.set(fullKey, {
53
+ value,
54
+ expiresAt: Date.now() + (effectiveTTL * 1000)
55
+ })
56
+
57
+ metrics.increment('cache.set')
58
+ metrics.histogram('cache.value.size', Buffer.byteLength(serialized))
59
+ }
60
+
61
+ async get<T>(key: CacheKey): Promise<T | null> {
62
+ const start = Date.now()
63
+ const fullKey = this.prefix + key
64
+ const entry = this.store.get(fullKey)
65
+
66
+ metrics.histogram('cache.get.latency', Date.now() - start)
67
+
68
+ if (!entry || entry.expiresAt <= Date.now()) {
69
+ if (entry) this.store.delete(fullKey)
70
+ metrics.increment('cache.miss')
71
+ return null
72
+ }
73
+
74
+ metrics.increment('cache.hit')
75
+ return entry.value as T
76
+ }
77
+
78
+ async delete(key: CacheKey): Promise<void> {
79
+ const fullKey = this.prefix + key
80
+ this.store.delete(fullKey)
81
+ metrics.increment('cache.deleted')
82
+ }
83
+
84
+ async exists(key: CacheKey): Promise<boolean> {
85
+ const fullKey = this.prefix + key
86
+ const entry = this.store.get(fullKey)
87
+ if (!entry || entry.expiresAt <= Date.now()) {
88
+ if (entry) this.store.delete(fullKey)
89
+ return false
90
+ }
91
+ return true
92
+ }
93
+
94
+ async setWithNX<T>(key: CacheKey, value: T, ttl?: number): Promise<boolean> {
95
+ const fullKey = this.prefix + key
96
+ if (this.store.has(fullKey)) {
97
+ const entry = this.store.get(fullKey)
98
+ if (entry && entry.expiresAt > Date.now()) {
99
+ return false
100
+ }
101
+ }
102
+ await this.set(key, value, ttl)
103
+ return true
104
+ }
105
+
106
+ async increment(key: CacheKey, by: number = 1, ttl?: number): Promise<number> {
107
+ const fullKey = this.prefix + key
108
+ const entry = this.store.get(fullKey)
109
+ let current = 0
110
+
111
+ if (entry && entry.expiresAt > Date.now()) {
112
+ current = typeof entry.value === 'number' ? entry.value : 0
113
+ }
114
+
115
+ const newValue = current + by
116
+ const effectiveTTL = ttl || this.defaultTTL
117
+
118
+ this.store.set(fullKey, {
119
+ value: newValue,
120
+ expiresAt: Date.now() + (effectiveTTL * 1000)
121
+ })
122
+
123
+ return newValue
124
+ }
125
+
126
+ async getMulti<T>(keys: CacheKey[]): Promise<(T | null)[]> {
127
+ return Promise.all(keys.map(key => this.get<T>(key)))
128
+ }
129
+
130
+ async scan(pattern: string, _count: number = 100): Promise<string[]> {
131
+ const regex = new RegExp(this.prefix + pattern.replace(/\*/g, '.*'))
132
+ const now = Date.now()
133
+ const keys: string[] = []
134
+
135
+ for (const [key, entry] of this.store.entries()) {
136
+ if (regex.test(key) && entry.expiresAt > now) {
137
+ keys.push(key)
138
+ }
139
+ }
140
+ return keys
141
+ }
142
+
143
+ async flush(pattern?: string): Promise<void> {
144
+ if (pattern) {
145
+ const keys = await this.scan(pattern)
146
+ for (const key of keys) {
147
+ this.store.delete(key)
148
+ }
149
+ } else {
150
+ this.store.clear()
151
+ }
152
+ metrics.increment('cache.flushed')
153
+ }
154
+
155
+ async getStats(): Promise<{
156
+ connected: boolean
157
+ keysCount?: number
158
+ memoryUsage?: string
159
+ }> {
160
+ const now = Date.now()
161
+ let validKeys = 0
162
+ let totalBytes = 0
163
+ for (const [key, entry] of this.store.entries()) {
164
+ if (entry.expiresAt > now) {
165
+ validKeys++
166
+ totalBytes += Buffer.byteLength(JSON.stringify(entry.value)) + Buffer.byteLength(key)
167
+ }
168
+ }
169
+
170
+ return {
171
+ connected: true,
172
+ keysCount: validKeys,
173
+ memoryUsage: `${(totalBytes / 1024).toFixed(2)}KB`
174
+ }
175
+ }
176
+
177
+ async close(): Promise<void> {
178
+ if (this.cleanupInterval) {
179
+ clearInterval(this.cleanupInterval)
180
+ this.cleanupInterval = null
181
+ }
182
+ this.store.clear()
183
+ }
184
+ }
185
+
186
+ export const cache = new MemoryCache()
@@ -0,0 +1,132 @@
1
+ import { QwenAccount, loadAccounts } from './accounts.ts'
2
+ import { config } from './config.js'
3
+
4
+ let currentIndex = 0
5
+
6
+ interface CooldownEntry {
7
+ until: number
8
+ reason: string
9
+ }
10
+
11
+ const cooldowns = new Map<string, CooldownEntry>()
12
+
13
+ const DEFAULT_COOLDOWN_MS = 3 * 60 * 1000 // 3 minutes
14
+
15
+ let accountsCache: QwenAccount[] | null = null
16
+ let accountsCacheTimestamp = 0
17
+ const ACCOUNTS_CACHE_TTL = config.cache.defaultTTL * 1000
18
+
19
+ function getCachedAccounts(): QwenAccount[] {
20
+ const now = Date.now()
21
+ if (!accountsCache || (now - accountsCacheTimestamp) > ACCOUNTS_CACHE_TTL) {
22
+ accountsCache = loadAccounts()
23
+ accountsCacheTimestamp = now
24
+ }
25
+ return accountsCache
26
+ }
27
+
28
+ export function invalidateAccountsCache(): void {
29
+ accountsCache = null
30
+ accountsCacheTimestamp = 0
31
+ }
32
+
33
+ export function markAccountRateLimited(accountId: string, cooldownMs?: number, reason?: string): void {
34
+ cooldowns.set(accountId, {
35
+ until: Date.now() + (cooldownMs ?? DEFAULT_COOLDOWN_MS),
36
+ reason: reason ?? 'RateLimited',
37
+ })
38
+ console.log(`[AccountManager] Account ${accountId} marked as rate-limited. Cooldown until ${new Date(Date.now() + (cooldownMs ?? DEFAULT_COOLDOWN_MS)).toISOString()}`)
39
+ }
40
+
41
+ export function clearAccountCooldown(accountId: string): void {
42
+ cooldowns.delete(accountId)
43
+ }
44
+
45
+ export function getAccountCooldownInfo(accountId: string): { onCooldown: boolean; remainingMs: number; reason: string } | null {
46
+ const entry = cooldowns.get(accountId)
47
+ if (!entry) return null
48
+ const remaining = entry.until - Date.now()
49
+ if (remaining <= 0) {
50
+ cooldowns.delete(accountId)
51
+ return null
52
+ }
53
+ return { onCooldown: true, remainingMs: remaining, reason: entry.reason }
54
+ }
55
+
56
+ function isAccountOnCooldown(accountId: string): boolean {
57
+ return getAccountCooldownInfo(accountId) !== null
58
+ }
59
+
60
+ export function getNextAccount(forceReset?: boolean): QwenAccount | null {
61
+ const accounts = getCachedAccounts()
62
+ if (accounts.length === 0) {
63
+ return null
64
+ }
65
+
66
+ if (forceReset) {
67
+ currentIndex = 0
68
+ }
69
+
70
+ for (let i = 0; i < accounts.length; i++) {
71
+ const account = accounts[currentIndex % accounts.length]
72
+ currentIndex = (currentIndex + 1) % accounts.length
73
+ if (!isAccountOnCooldown(account.id)) {
74
+ return account
75
+ }
76
+ }
77
+
78
+ // All accounts on cooldown — return the one with the shortest remaining cooldown
79
+ let best: QwenAccount | null = null
80
+ let bestRemaining = Infinity
81
+ for (const account of accounts) {
82
+ const info = getAccountCooldownInfo(account.id)
83
+ if (info && info.remainingMs < bestRemaining) {
84
+ bestRemaining = info.remainingMs
85
+ best = account
86
+ }
87
+ }
88
+ return best
89
+ }
90
+
91
+ export function getNextAvailableAccount(skipAccountId?: string): QwenAccount | null {
92
+ const accounts = getCachedAccounts()
93
+ if (accounts.length === 0) return null
94
+
95
+ for (let i = 0; i < accounts.length; i++) {
96
+ const idx = (currentIndex + i) % accounts.length
97
+ const account = accounts[idx]
98
+ if (skipAccountId && account.id === skipAccountId) continue
99
+ if (!isAccountOnCooldown(account.id)) {
100
+ currentIndex = (idx + 1) % accounts.length
101
+ return account
102
+ }
103
+ }
104
+
105
+ // All remaining accounts on cooldown — return the one with shortest cooldown
106
+ let best: QwenAccount | null = null
107
+ let bestRemaining = Infinity
108
+ for (const account of accounts) {
109
+ if (skipAccountId && account.id === skipAccountId) continue
110
+ const info = getAccountCooldownInfo(account.id)
111
+ if (info && info.remainingMs < bestRemaining) {
112
+ bestRemaining = info.remainingMs
113
+ best = account
114
+ }
115
+ }
116
+ return best
117
+ }
118
+
119
+ export function getAccountCount(): number {
120
+ return getCachedAccounts().length
121
+ }
122
+
123
+ export function getCooldownStatus(): Record<string, { remainingMs: number; reason: string }> {
124
+ const result: Record<string, { remainingMs: number; reason: string }> = {}
125
+ for (const [id, info] of cooldowns.entries()) {
126
+ const remaining = info.until - Date.now()
127
+ if (remaining > 0) {
128
+ result[id] = { remainingMs: remaining, reason: info.reason }
129
+ }
130
+ }
131
+ return result
132
+ }
@@ -0,0 +1,78 @@
1
+ import crypto from 'crypto'
2
+ import { getDatabase } from './database.ts'
3
+ import { config } from './config.js'
4
+
5
+ export interface QwenAccount {
6
+ id: string
7
+ email: string
8
+ password: string
9
+ }
10
+
11
+ let accountsCache: QwenAccount[] | null = null
12
+ let accountsCacheTimestamp = 0
13
+ const ACCOUNTS_CACHE_TTL = config.cache.defaultTTL * 1000
14
+
15
+ function getCachedAccounts(): QwenAccount[] {
16
+ const now = Date.now()
17
+ if (!accountsCache || (now - accountsCacheTimestamp) > ACCOUNTS_CACHE_TTL) {
18
+ const db = getDatabase()
19
+ accountsCache = db.prepare('SELECT id, email, password FROM accounts ORDER BY created_at ASC').all() as QwenAccount[]
20
+ accountsCacheTimestamp = now
21
+ }
22
+ return accountsCache
23
+ }
24
+
25
+ export function invalidateAccountsCache(): void {
26
+ accountsCache = null
27
+ accountsCacheTimestamp = 0
28
+ }
29
+
30
+ export function loadAccounts(): QwenAccount[] {
31
+ return getCachedAccounts()
32
+ }
33
+
34
+ export function addAccount(email: string, password: string, id?: string): QwenAccount {
35
+ if (!email || typeof email !== 'string' || email.trim().length === 0) {
36
+ throw new Error('Email is required')
37
+ }
38
+
39
+ const db = getDatabase()
40
+ const existing = db.prepare('SELECT id FROM accounts WHERE email = ?').get(email.trim())
41
+ if (existing) {
42
+ throw new Error(`Account with email ${email} already exists`)
43
+ }
44
+
45
+ const newAccount: QwenAccount = {
46
+ id: id || crypto.randomUUID(),
47
+ email: email.trim(),
48
+ password,
49
+ }
50
+
51
+ db.prepare('INSERT INTO accounts (id, email, password) VALUES (?, ?, ?)').run(
52
+ newAccount.id,
53
+ newAccount.email,
54
+ newAccount.password,
55
+ )
56
+
57
+ invalidateAccountsCache()
58
+ return newAccount
59
+ }
60
+
61
+ export function removeAccount(id: string): boolean {
62
+ const db = getDatabase()
63
+ const result = db.prepare('DELETE FROM accounts WHERE id = ?').run(id)
64
+ if (result.changes > 0) {
65
+ invalidateAccountsCache()
66
+ }
67
+ return result.changes > 0
68
+ }
69
+
70
+ export function listAccounts(): QwenAccount[] {
71
+ return getCachedAccounts().map(a => ({ id: a.id, email: a.email, password: '***' }))
72
+ }
73
+
74
+ export function getAccountCredentials(id: string): QwenAccount | undefined {
75
+ const db = getDatabase()
76
+ const row = db.prepare('SELECT id, email, password FROM accounts WHERE id = ?').get(id)
77
+ return row as QwenAccount | undefined
78
+ }
@@ -0,0 +1,91 @@
1
+ import { z } from 'zod'
2
+
3
+ const envSchema = z.object({
4
+ PORT: z.string().default('3000'),
5
+ HOST: z.string().default('0.0.0.0'),
6
+ HEADLESS: z.string().default('true'),
7
+ BROWSER: z.enum(['chromium', 'firefox', 'webkit', 'chrome', 'edge']).default('chromium'),
8
+ USER_DATA_DIR: z.string().default('./qwen_profiles'),
9
+ USER_AGENT: z.string().default('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'),
10
+ LOG_CONSOLE: z.string().default('false'),
11
+ NAVIGATION_TIMEOUT: z.string().default('30000'),
12
+ PAGE_TIMEOUT: z.string().default('15000'),
13
+ HTTP_TIMEOUT: z.string().default('10000'),
14
+ CHAT_TIMEOUT: z.string().default('120000'),
15
+ CACHE_TTL: z.string().default('3600'),
16
+ RESPONSE_TTL: z.string().default('1800'),
17
+ METRICS_INTERVAL: z.string().default('10000'),
18
+ WATCHDOG_INTERVAL: z.string().default('5000'),
19
+ WATCHDOG_FAILURES: z.string().default('3'),
20
+ RAM_WARNING: z.string().default('80'),
21
+ RAM_CRITICAL: z.string().default('95'),
22
+ WS_WARNING: z.string().default('50'),
23
+ WS_CRITICAL: z.string().default('100'),
24
+ QWEN_BASE_URL: z.string().default('https://chat.qwen.ai'),
25
+ QWEN_HTTP_ENDPOINT: z.string().default('https://api.qwen.ai/v1/chat'),
26
+ QWEN_API_KEY: z.string().default(''),
27
+ API_KEY: z.string().default(''),
28
+ })
29
+
30
+ const env = envSchema.parse(process.env)
31
+
32
+ export const config = {
33
+ server: {
34
+ port: parseInt(env.PORT),
35
+ host: env.HOST,
36
+ },
37
+ browser: {
38
+ headless: env.HEADLESS !== 'false',
39
+ type: env.BROWSER,
40
+ userDataDir: env.USER_DATA_DIR,
41
+ userAgent: env.USER_AGENT,
42
+ args: [
43
+ '--disable-gpu',
44
+ '--disable-dev-shm-usage',
45
+ '--disable-accelerated-2d-canvas',
46
+ '--no-first-run',
47
+ '--no-zygote',
48
+ '--disable-features=IsolateOrigins,site-per-process',
49
+ ],
50
+ launchTimeout: 30000,
51
+ healthCheckInterval: 30000,
52
+ headers: {
53
+ 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
54
+ 'accept-language': 'en-US,en;q=0.9',
55
+ },
56
+ logConsole: env.LOG_CONSOLE === 'true',
57
+ },
58
+ timeouts: {
59
+ navigation: parseInt(env.NAVIGATION_TIMEOUT),
60
+ page: parseInt(env.PAGE_TIMEOUT),
61
+ http: parseInt(env.HTTP_TIMEOUT),
62
+ chat: parseInt(env.CHAT_TIMEOUT),
63
+ },
64
+ cache: {
65
+ defaultTTL: parseInt(env.CACHE_TTL),
66
+ responseTTL: parseInt(env.RESPONSE_TTL),
67
+ },
68
+ metrics: {
69
+ interval: parseInt(env.METRICS_INTERVAL),
70
+ },
71
+ watchdog: {
72
+ checkInterval: parseInt(env.WATCHDOG_INTERVAL),
73
+ consecutiveFailuresThreshold: parseInt(env.WATCHDOG_FAILURES),
74
+ ram: {
75
+ warningThreshold: parseInt(env.RAM_WARNING),
76
+ criticalThreshold: parseInt(env.RAM_CRITICAL),
77
+ },
78
+ streams: {
79
+ warningThreshold: parseInt(env.WS_WARNING),
80
+ criticalThreshold: parseInt(env.WS_CRITICAL),
81
+ },
82
+ },
83
+ apiKey: env.API_KEY,
84
+ qwen: {
85
+ baseUrl: env.QWEN_BASE_URL,
86
+ httpEndpoint: env.QWEN_HTTP_ENDPOINT,
87
+ apiKey: env.QWEN_API_KEY,
88
+ },
89
+ }
90
+
91
+ export type Config = typeof config
@@ -0,0 +1,92 @@
1
+ import Database from 'better-sqlite3'
2
+ import path from 'path'
3
+ import fs from 'fs'
4
+
5
+ const DATA_DIR = path.resolve('data')
6
+ const DB_PATH = path.join(DATA_DIR, 'qwenproxy.db')
7
+
8
+ let db: Database.Database | null = null
9
+
10
+ export function getDatabase(): Database.Database {
11
+ if (db) return db
12
+
13
+ // Ensure data directory exists
14
+ if (!fs.existsSync(DATA_DIR)) {
15
+ fs.mkdirSync(DATA_DIR, { recursive: true })
16
+ }
17
+
18
+ db = new Database(DB_PATH)
19
+
20
+ // Enable WAL mode for better concurrent read performance (ideal for VPS)
21
+ db.pragma('journal_mode = WAL')
22
+ db.pragma('busy_timeout = 5000')
23
+ db.pragma('synchronous = NORMAL')
24
+ db.pragma('cache_size = -64000') // 64MB cache
25
+ db.pragma('foreign_keys = ON')
26
+
27
+ runMigrations(db)
28
+ migrateFromJson(db)
29
+
30
+ return db
31
+ }
32
+
33
+ function runMigrations(db: Database.Database): void {
34
+ db.exec(`
35
+ CREATE TABLE IF NOT EXISTS accounts (
36
+ id TEXT PRIMARY KEY,
37
+ email TEXT UNIQUE NOT NULL,
38
+ password TEXT NOT NULL DEFAULT '',
39
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
40
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
41
+ );
42
+
43
+ CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email);
44
+ `)
45
+ }
46
+
47
+ /**
48
+ * Auto-migrate existing accounts.json into SQLite on first run.
49
+ * The JSON file is renamed to accounts.json.bak after successful migration.
50
+ */
51
+ function migrateFromJson(db: Database.Database): void {
52
+ const jsonPath = path.resolve('accounts.json')
53
+ if (!fs.existsSync(jsonPath)) return
54
+
55
+ try {
56
+ const raw = fs.readFileSync(jsonPath, 'utf-8')
57
+ const accounts = JSON.parse(raw) as Array<{ id: string; email: string; password: string }>
58
+
59
+ if (!Array.isArray(accounts) || accounts.length === 0) {
60
+ // Empty or invalid file — just rename it
61
+ fs.renameSync(jsonPath, jsonPath + '.bak')
62
+ return
63
+ }
64
+
65
+ const insert = db.prepare(`
66
+ INSERT OR IGNORE INTO accounts (id, email, password) VALUES (?, ?, ?)
67
+ `)
68
+
69
+ const migrate = db.transaction(() => {
70
+ for (const account of accounts) {
71
+ if (account.id && typeof account.email === 'string' && account.email.trim().length > 0) {
72
+ insert.run(account.id, account.email.trim(), account.password || '')
73
+ }
74
+ }
75
+ })
76
+
77
+ migrate()
78
+
79
+ // Rename old file to .bak to avoid re-migration
80
+ fs.renameSync(jsonPath, jsonPath + '.bak')
81
+ console.log(`[Database] Migrated ${accounts.length} account(s) from accounts.json to SQLite`)
82
+ } catch (err: any) {
83
+ console.error('[Database] Failed to migrate accounts.json:', err.message)
84
+ }
85
+ }
86
+
87
+ export function closeDatabase(): void {
88
+ if (db) {
89
+ db.close()
90
+ db = null
91
+ }
92
+ }
@@ -0,0 +1,96 @@
1
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
+
3
+ export interface LogEntry {
4
+ timestamp: Date;
5
+ level: LogLevel;
6
+ message: string;
7
+ context?: string;
8
+ data?: Record<string, unknown>;
9
+ }
10
+
11
+ export class Logger {
12
+ private minLevel: LogLevel;
13
+ private context?: string;
14
+
15
+ constructor(level: LogLevel = 'info', context?: string) {
16
+ this.minLevel = level;
17
+ this.context = context;
18
+ }
19
+
20
+ private shouldLog(level: LogLevel): boolean {
21
+ const levels: LogLevel[] = ['debug', 'info', 'warn', 'error'];
22
+ return levels.indexOf(level) >= levels.indexOf(this.minLevel);
23
+ }
24
+
25
+ private formatEntry(entry: LogEntry): string {
26
+ const timestamp = entry.timestamp.toISOString();
27
+ const pad = (str: string): string => str.padStart(5, ' ');
28
+ const colorCode = (
29
+ entry.level === 'error' ? '\x1b[31m' :
30
+ entry.level === 'warn' ? '\x1b[33m' :
31
+ entry.level === 'debug' ? '\x1b[36m' : ''
32
+ );
33
+ const reset = '\x1b[0m';
34
+
35
+ const coloredLevel = colorCode + pad(entry.level.toUpperCase()) + reset;
36
+ const contextPart = entry.context ? ` [${entry.context}]` : '';
37
+
38
+ let output = `${timestamp} ${coloredLevel}${contextPart} ${entry.message}`;
39
+
40
+ if (entry.data) {
41
+ output += '\n' + JSON.stringify(entry.data, null, 2);
42
+ }
43
+
44
+ return output;
45
+ }
46
+
47
+ debug(message: string, data?: Record<string, unknown>): void {
48
+ if (this.shouldLog('debug')) {
49
+ console.log(this.formatEntry({
50
+ timestamp: new Date(),
51
+ level: 'debug',
52
+ message: this.context ? `[${this.context}] ${message}` : message,
53
+ data,
54
+ }));
55
+ }
56
+ }
57
+
58
+ info(message: string, data?: Record<string, unknown>): void {
59
+ if (this.shouldLog('info')) {
60
+ console.log(this.formatEntry({
61
+ timestamp: new Date(),
62
+ level: 'info',
63
+ message: this.context ? `[${this.context}] ${message}` : message,
64
+ data,
65
+ }));
66
+ }
67
+ }
68
+
69
+ warn(message: string, data?: Record<string, unknown>): void {
70
+ if (this.shouldLog('warn')) {
71
+ console.warn(this.formatEntry({
72
+ timestamp: new Date(),
73
+ level: 'warn',
74
+ message: this.context ? `[${this.context}] ${message}` : message,
75
+ data,
76
+ }));
77
+ }
78
+ }
79
+
80
+ error(message: string, data?: Record<string, unknown>): void {
81
+ if (this.shouldLog('error')) {
82
+ console.error(this.formatEntry({
83
+ timestamp: new Date(),
84
+ level: 'error',
85
+ message: this.context ? `[${this.context}] ${message}` : message,
86
+ data,
87
+ }));
88
+ }
89
+ }
90
+
91
+ child(context: string): Logger {
92
+ return new Logger(this.minLevel, this.context ? `${this.context}.${context}` : context);
93
+ }
94
+ }
95
+
96
+ export const logger = new Logger('info');