qwenproxy-cli 1.0.1 → 1.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qwenproxy-cli",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "High-performance OpenAI & Anthropic compatible API gateway for Qwen with multi-account rotation, interactive TUI, and resilient tool calling.",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -16,7 +16,6 @@
16
16
  "LICENSE"
17
17
  ],
18
18
  "scripts": {
19
- "postinstall": "patchright install chromium",
20
19
  "start": "tsx src/index.ts",
21
20
  "tui": "npx tsx src/tui/index.ts",
22
21
  "login": "npx tsx src/login.ts",
package/src/api/server.ts CHANGED
@@ -269,6 +269,7 @@ app.get("/health", async (c) => {
269
269
  : undefined,
270
270
  timestamp: Date.now(),
271
271
  readyAccounts: (await import("../core/account-manager.js")).getHeadersReadyAccountIds(),
272
+ activeAccounts: (await import("../services/playwright.js")).getActivePlaywrightAccountIds(),
272
273
  metrics: {
273
274
  cache: await cache?.getStats(),
274
275
  },
@@ -52,7 +52,7 @@ const envSchema = z
52
52
  // own context while serving. Accounts in cooldown (rate-limited) sit idle,
53
53
  // drop out of the warm set and get evicted.
54
54
  PLAYWRIGHT_MAX_ACTIVE_CONTEXTS: z.string().default("2"),
55
- PLAYWRIGHT_PREPARE_ALL_ON_STARTUP: z.string().default("true"),
55
+ PLAYWRIGHT_PREPARE_ALL_ON_STARTUP: z.string().default("false"),
56
56
  CAPTCHA_SOLVER_ENABLED: z.string().default("true"),
57
57
  CAPTCHA_SOLVER_MAX_ATTEMPTS: z.string().default("3"),
58
58
  CAPTCHA_SOLVER_TIMEOUT_MS: z.string().default("15000"),
@@ -166,7 +166,7 @@ const envSchema = z
166
166
  // trust score and gets TMD-challenged on the next request. On by default;
167
167
  // the keeper skips accounts that are mid-stream or mutex-busy.
168
168
  SESSION_KEEP_ALIVE_ENABLED: z.string().default("true"),
169
- SESSION_KEEP_ALIVE_INTERVAL_MS: z.string().default("30000"),
169
+ SESSION_KEEP_ALIVE_INTERVAL_MS: z.string().default("180000"),
170
170
  SESSION_KEEP_ALIVE_IDLE_MS: z.string().default("120000"),
171
171
  SESSION_KEEP_ALIVE_NAVIGATION_INTERVAL_MS: z.string().default("480000"),
172
172
  API_KEY: z.string().default(""),
@@ -3,7 +3,7 @@
3
3
  * Captures real browser headers (bx-ua, bx-umidtoken) per account.
4
4
  */
5
5
 
6
- import { chromium, type BrowserContext, type Page } from "patchright";
6
+ import { chromium, type Browser, type BrowserContext, type Page } from "patchright";
7
7
  import path from "path";
8
8
  import fs from "fs";
9
9
  import crypto from "crypto";
@@ -183,6 +183,112 @@ const accountContexts = new Map<string, BrowserContext>();
183
183
  const accountPages = new Map<string, Page>();
184
184
  const cachedUserAgents = new Map<string, string>();
185
185
 
186
+ let sharedBrowser: Browser | null = null;
187
+ let sharedBrowserPromise: Promise<Browser> | null = null;
188
+
189
+ export function getSharedBrowser(): Browser | null {
190
+ return sharedBrowser;
191
+ }
192
+
193
+ export function getStorageStatePath(accountId: string): string {
194
+ const profileDir = getAccountProfilePath(accountId);
195
+ return path.join(profileDir, "storage_state.json");
196
+ }
197
+
198
+ export function loadStorageState(accountId: string): string | undefined {
199
+ const p1 = getStorageStatePath(accountId);
200
+ const p2 = path.join(path.dirname(p1), `${accountId}_state.json`);
201
+ const chosenPath = fs.existsSync(p1) ? p1 : fs.existsSync(p2) ? p2 : undefined;
202
+ if (!chosenPath) return undefined;
203
+ try {
204
+ const raw = fs.readFileSync(chosenPath, "utf8");
205
+ const state = JSON.parse(raw);
206
+ if (!state || typeof state !== "object" || !Array.isArray(state.cookies)) {
207
+ return undefined;
208
+ }
209
+ return chosenPath;
210
+ } catch {
211
+ return undefined;
212
+ }
213
+ }
214
+
215
+ export async function saveStorageState(
216
+ context: BrowserContext,
217
+ accountId: string,
218
+ ): Promise<void> {
219
+ try {
220
+ const stateFile = getStorageStatePath(accountId);
221
+ const dir = path.dirname(stateFile);
222
+ if (!fs.existsSync(dir)) {
223
+ fs.mkdirSync(dir, { recursive: true });
224
+ }
225
+ await context.storageState({ path: stateFile });
226
+ } catch (error) {
227
+ console.warn(
228
+ `[Playwright] Failed to save storage state for ${accountId}: ${getErrorMessage(error)}`,
229
+ );
230
+ }
231
+ }
232
+
233
+ async function hasValidAuthCookie(context: BrowserContext): Promise<boolean> {
234
+ try {
235
+ const cookies = await context.cookies();
236
+ return cookies.some(
237
+ (c) =>
238
+ (c.name.toLowerCase().includes("token") || c.name.toLowerCase().includes("session")) &&
239
+ (c.expires === undefined || c.expires === -1 || c.expires * 1000 > Date.now()),
240
+ );
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+
246
+ export async function getOrLaunchSharedBrowser(
247
+ browserType: BrowserType = "chromium",
248
+ headless = true,
249
+ ): Promise<Browser> {
250
+ if (sharedBrowser && sharedBrowser.isConnected()) {
251
+ return sharedBrowser;
252
+ }
253
+ if (sharedBrowserPromise) {
254
+ return sharedBrowserPromise;
255
+ }
256
+
257
+ sharedBrowserPromise = (async () => {
258
+ const { engine, channel } = resolveBrowserEngine(browserType);
259
+ const defaultViewport = { width: 1280, height: 800 };
260
+ const launchArgs = buildChromiumLaunchArgs(defaultViewport);
261
+
262
+ console.log(
263
+ `[Playwright] Launching single shared ${browserType} browser...`,
264
+ );
265
+
266
+ const browser = await engine.launch({
267
+ headless,
268
+ channel,
269
+ ignoreDefaultArgs: ["--enable-automation", "--enable-blink-features"],
270
+ args: launchArgs,
271
+ });
272
+
273
+ browser.on("disconnected", () => {
274
+ console.warn("[Playwright] Shared browser disconnected");
275
+ sharedBrowser = null;
276
+ for (const accountId of Array.from(accountPages.keys())) {
277
+ cleanupPlaywrightAccountState(accountId);
278
+ }
279
+ });
280
+
281
+ sharedBrowser = browser;
282
+ return browser;
283
+ })();
284
+
285
+ try {
286
+ return await sharedBrowserPromise;
287
+ } finally {
288
+ sharedBrowserPromise = null;
289
+ }
290
+ }
291
+
186
292
  // Header cache per account
187
293
  interface AccountHeaderCache {
188
294
  headers: Record<string, string>;
@@ -1041,13 +1147,12 @@ export async function initPlaywrightForAccount(
1041
1147
  // If a context limit is configured, make room by closing idle contexts.
1042
1148
  await evictIdlePlaywrightContextsToLimit().catch(() => {});
1043
1149
 
1044
- const profilePath = getAccountProfilePath(account.id);
1045
1150
  const fingerprint = getFingerprintProfile(account.id);
1046
- const { engine, channel } = resolveBrowserEngine(browserType);
1151
+ const sharedBrowser = await getOrLaunchSharedBrowser(browserType, headless);
1152
+ const storageState = loadStorageState(account.id);
1047
1153
 
1048
- const acctContext = await engine.launchPersistentContext(profilePath, {
1049
- headless,
1050
- channel,
1154
+ const acctContext = await sharedBrowser.newContext({
1155
+ ...(storageState ? { storageState } : {}),
1051
1156
  userAgent: fingerprint.userAgent,
1052
1157
  locale: fingerprint.locale,
1053
1158
  timezoneId: fingerprint.timezoneId,
@@ -1062,8 +1167,6 @@ export async function initPlaywrightForAccount(
1062
1167
  "sec-ch-ua-mobile": "?0",
1063
1168
  "sec-ch-ua-platform": '"Windows"',
1064
1169
  },
1065
- ignoreDefaultArgs: ["--enable-automation", "--enable-blink-features"],
1066
- args: buildChromiumLaunchArgs(fingerprint.viewport),
1067
1170
  });
1068
1171
 
1069
1172
  try {
@@ -1073,23 +1176,7 @@ export async function initPlaywrightForAccount(
1073
1176
  await hook(acctContext);
1074
1177
  }
1075
1178
 
1076
- // Persistent contexts may already contain an initial about:blank tab.
1077
- // Reuse it instead of creating a second tab. Prefer a tab already on the
1078
- // Qwen origin when one exists.
1079
- const existingPages = acctContext.pages().filter((p) => !p.isClosed());
1080
- const acctPage =
1081
- existingPages.find((p) => p.url().startsWith(qwenOrigin())) ??
1082
- existingPages[0] ??
1083
- (await acctContext.newPage());
1084
-
1085
- // Close any extra blank tabs that may have been created by the browser
1086
- // profile/startup, but keep the primary page selected above.
1087
- for (const extraPage of existingPages.slice(1)) {
1088
- if (extraPage !== acctPage && extraPage.url() === "about:blank") {
1089
- await extraPage.close({ runBeforeUnload: false }).catch(() => {});
1090
- }
1091
- }
1092
-
1179
+ const acctPage = await acctContext.newPage();
1093
1180
  acctPage.setDefaultTimeout(config.timeouts.page);
1094
1181
  acctPage.setDefaultNavigationTimeout(config.timeouts.navigation);
1095
1182
  accountContexts.set(account.id, acctContext);
@@ -1205,12 +1292,12 @@ export async function validateAccountLogin(
1205
1292
  try {
1206
1293
  if (accountPages.has(account.id)) return true;
1207
1294
 
1208
- const profilePath = getAccountProfilePath(account.id);
1209
1295
  const fingerprint = getFingerprintProfile(account.id);
1210
- const { engine, channel } = resolveBrowserEngine(browserType);
1211
- const acctContext = await engine.launchPersistentContext(profilePath, {
1212
- headless,
1213
- channel,
1296
+ const sharedBrowser = await getOrLaunchSharedBrowser(browserType, headless);
1297
+ const storageState = loadStorageState(account.id);
1298
+
1299
+ const acctContext = await sharedBrowser.newContext({
1300
+ ...(storageState ? { storageState } : {}),
1214
1301
  userAgent: fingerprint.userAgent,
1215
1302
  locale: fingerprint.locale,
1216
1303
  timezoneId: fingerprint.timezoneId,
@@ -1225,18 +1312,11 @@ export async function validateAccountLogin(
1225
1312
  "sec-ch-ua-mobile": "?0",
1226
1313
  "sec-ch-ua-platform": '"Windows"',
1227
1314
  },
1228
- ignoreDefaultArgs: ["--enable-automation", "--enable-blink-features"],
1229
- args: buildChromiumLaunchArgs(fingerprint.viewport),
1230
1315
  });
1231
1316
 
1232
1317
  try {
1233
1318
  await acctContext.addInitScript(getStealthScript(fingerprint));
1234
-
1235
- const existingPages = acctContext.pages().filter((p) => !p.isClosed());
1236
- const acctPage =
1237
- existingPages.find((p) => p.url().startsWith(qwenOrigin())) ??
1238
- existingPages[0] ??
1239
- (await acctContext.newPage());
1319
+ const acctPage = await acctContext.newPage();
1240
1320
 
1241
1321
  // Check if already logged in via cookies
1242
1322
  const cookies = await acctContext.cookies();
@@ -1306,12 +1386,14 @@ async function loginToQwen(
1306
1386
  // Try API login first
1307
1387
  const apiResult = await loginViaApi(page, email, password);
1308
1388
  if (apiResult) {
1389
+ await saveStorageState(page.context(), accountId);
1309
1390
  return true;
1310
1391
  }
1311
1392
 
1312
1393
  // Fallback to UI login
1313
1394
  const uiResult = await loginViaUi(page, email, password);
1314
1395
  if (uiResult) {
1396
+ await saveStorageState(page.context(), accountId);
1315
1397
  return true;
1316
1398
  }
1317
1399
 
@@ -2107,6 +2189,8 @@ async function resetPlaywrightProfileLocked(accountId: string): Promise<void> {
2107
2189
  await closePlaywrightForAccountLocked(accountId);
2108
2190
  const profilePath = getAccountProfilePath(accountId);
2109
2191
  removePlaywrightProfile(profilePath);
2192
+ const stateFile2 = path.join(path.dirname(profilePath), `${accountId}_state.json`);
2193
+ try { fs.rmSync(stateFile2, { force: true }); } catch {}
2110
2194
  }
2111
2195
 
2112
2196
  /**
@@ -2533,7 +2617,11 @@ async function closePlaywrightContextBestEffort(
2533
2617
  accountId: string,
2534
2618
  context: BrowserContext,
2535
2619
  ): Promise<void> {
2536
- const browserProcess = getBrowserProcess(context);
2620
+ try {
2621
+ if (await hasValidAuthCookie(context)) {
2622
+ await saveStorageState(context, accountId);
2623
+ }
2624
+ } catch {}
2537
2625
 
2538
2626
  try {
2539
2627
  const pages = context.pages();
@@ -2558,19 +2646,6 @@ async function closePlaywrightContextBestEffort(
2558
2646
  `[Playwright] Failed to close context for ${accountId}: ${getErrorMessage(error)}`,
2559
2647
  );
2560
2648
  }
2561
-
2562
- if (browserProcess && !browserProcess.killed) {
2563
- try {
2564
- browserProcess.kill("SIGKILL");
2565
- console.warn(
2566
- `[Playwright] Killed lingering browser process for ${accountId}`,
2567
- );
2568
- } catch (killError) {
2569
- console.warn(
2570
- `[Playwright] Failed to kill browser process for ${accountId}: ${getErrorMessage(killError)}`,
2571
- );
2572
- }
2573
- }
2574
2649
  }
2575
2650
  }
2576
2651
 
@@ -2651,6 +2726,10 @@ export async function closeAllPlaywright(): Promise<void> {
2651
2726
  for (const accountId of accountIds) {
2652
2727
  await closePlaywrightForAccount(accountId);
2653
2728
  }
2729
+ if (sharedBrowser && sharedBrowser.isConnected()) {
2730
+ await sharedBrowser.close().catch(() => {});
2731
+ sharedBrowser = null;
2732
+ }
2654
2733
  } finally {
2655
2734
  closingAllPlaywright = false;
2656
2735
  }
@@ -2726,7 +2805,6 @@ export async function getTokenDiagnostics(
2726
2805
  const targetAccounts = accountId
2727
2806
  ? [accountId]
2728
2807
  : Array.from(accountContexts.keys());
2729
-
2730
2808
  const allCookies: CookieDiagnostic[] = [];
2731
2809
  const headerDiags: HeaderDiagnostic[] = [];
2732
2810
 
package/src/tui/app.ts CHANGED
@@ -7,7 +7,19 @@ import type { TuiView, ProxyStatusSnapshot } from "./types.ts";
7
7
  import { theme, glyphs, drawBox, stringWidth } from "./theme.ts";
8
8
  import { fetchProxyStatus } from "./proxy-client.ts";
9
9
  import { ServerManager } from "./server-manager.ts";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
10
13
 
14
+ let cachedAppVersion = "v1.0.2";
15
+ try {
16
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
17
+ const pkgPath = path.resolve(currentDir, "../../package.json");
18
+ if (fs.existsSync(pkgPath)) {
19
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
20
+ if (pkg.version) cachedAppVersion = `v${pkg.version}`;
21
+ }
22
+ } catch {}
11
23
  import { StatusView } from "./views/status-view.ts";
12
24
  import { ChatView } from "./views/chat-view.ts";
13
25
  import { SyncView } from "./views/sync-view.ts";
@@ -91,11 +103,6 @@ export class TuiApp {
91
103
  if (!this.isRunning) return;
92
104
  try {
93
105
  this.statusSnapshot = await fetchProxyStatus();
94
- // If the active view has a model refresher, keep it synchronized
95
- const activeView = this.views[this.activeViewIndex];
96
- if ("refreshModels" in activeView && typeof (activeView as any).refreshModels === "function") {
97
- void (activeView as any).refreshModels();
98
- }
99
106
  this.requestRender();
100
107
  } catch {}
101
108
  }, 1000);
@@ -245,7 +252,7 @@ export class TuiApp {
245
252
 
246
253
  const headerContent = [` ${tabsBar}`];
247
254
  const headerBox = drawBox({
248
- title: `QwenProxy v1.0.0 ${statusChip}`,
255
+ title: `QwenProxy ${cachedAppVersion} ${statusChip}`,
249
256
  width: cols,
250
257
  height: 3,
251
258
  borderColor: theme.borderActive,
@@ -10,6 +10,7 @@ import {
10
10
  clearAccountCooldown,
11
11
  isAccountHeadersReady,
12
12
  } from "../core/account-manager.ts";
13
+ import { isPlaywrightInitialized } from "../services/playwright.ts";
13
14
  import { getAccountConcurrencySnapshot } from "../core/account-concurrency.ts";
14
15
  import { getRssUsageSnapshot } from "../core/memory-usage.ts";
15
16
  import type { ProxyStatusSnapshot } from "./types.ts";
@@ -46,12 +47,14 @@ let cachedAccounts: Array<{
46
47
  onCooldown: boolean;
47
48
  remainingCooldownMs: number;
48
49
  headersReady: boolean;
50
+ isInitialized: boolean;
49
51
  }> = [];
50
52
  let lastAccountsFetch = 0;
51
53
  let isHealthCheckPending = false;
52
54
  let lastOnlineState = false;
53
55
  let lastOverallStatus = "offline";
54
56
  let lastServerReadyAccounts: Set<string> | null = null;
57
+ let lastServerActiveAccounts: Set<string> | null = null;
55
58
  export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
56
59
  const port = config.server?.port || 7936;
57
60
  const configuredHost = config.server?.host;
@@ -73,9 +76,13 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
73
76
  if (Array.isArray(data.readyAccounts)) {
74
77
  lastServerReadyAccounts = new Set(data.readyAccounts);
75
78
  }
79
+ if (Array.isArray(data.activeAccounts)) {
80
+ lastServerActiveAccounts = new Set(data.activeAccounts);
81
+ }
76
82
  } else {
77
83
  lastOnlineState = false;
78
84
  lastServerReadyAccounts = null;
85
+ lastServerActiveAccounts = null;
79
86
  }
80
87
  })
81
88
  .catch(() => {
@@ -104,6 +111,9 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
104
111
  const headersReady = lastServerReadyAccounts !== null
105
112
  ? lastServerReadyAccounts.has(acc.id)
106
113
  : isAccountHeadersReady(acc.id);
114
+ const isInitialized = lastServerActiveAccounts !== null
115
+ ? lastServerActiveAccounts.has(acc.id)
116
+ : isPlaywrightInitialized(acc.id);
107
117
  return {
108
118
  id: acc.id,
109
119
  emailOrName: maskAccountIdentifier(acc.email || acc.id),
@@ -112,10 +122,10 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
112
122
  onCooldown,
113
123
  remainingCooldownMs,
114
124
  headersReady,
125
+ isInitialized,
115
126
  };
116
127
  });
117
128
  }
118
-
119
129
  const accounts = cachedAccounts;
120
130
  const online = lastOnlineState;
121
131
  const overallStatus = lastOverallStatus;
package/src/tui/theme.ts CHANGED
@@ -16,7 +16,7 @@ export const ANSI = {
16
16
  showCursor: "\x1b[?25h",
17
17
  enterAltScreen: "\x1b[?1049h",
18
18
  exitAltScreen: "\x1b[?1049l",
19
- enableMouse: "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h",
19
+ enableMouse: "\x1b[?1000h\x1b[?1002h\x1b[?1006h",
20
20
  disableMouse: "\x1b[?1006l\x1b[?1005l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1015l",
21
21
  };
22
22
 
package/src/tui/types.ts CHANGED
@@ -29,5 +29,6 @@ export interface ProxyStatusSnapshot {
29
29
  onCooldown: boolean;
30
30
  remainingCooldownMs: number;
31
31
  headersReady: boolean;
32
+ isInitialized?: boolean;
32
33
  }>;
33
34
  }
@@ -497,7 +497,9 @@ export class AccountsView implements TuiView {
497
497
  const mins = Math.max(1, Math.round(acc.remainingCooldownMs / 60000));
498
498
  status = theme.yellow(`⚠ ${mins}m cd `);
499
499
  } else if (!acc.headersReady) {
500
- status = theme.yellow(`◐ Aquecendo...`);
500
+ status = acc.isInitialized
501
+ ? theme.yellow(`◐ Aquecendo...`)
502
+ : theme.muted(`○ Standby `);
501
503
  }
502
504
 
503
505
  const line = `${pointer}${num}${name}${status}`;
@@ -550,7 +552,9 @@ export class AccountsView implements TuiView {
550
552
 
551
553
  const hStatus = selected.headersReady
552
554
  ? theme.green(`${glyphs.check} Capturados`)
553
- : theme.muted(`${glyphs.circle} Pendente`);
555
+ : selected.isInitialized
556
+ ? theme.yellow(`◐ Aquecendo...`)
557
+ : theme.muted(`${glyphs.circle} Standby (Sob Demanda)`);
554
558
  rightContent.push(` ${theme.bold("Headers:")} ${hStatus}`);
555
559
  rightContent.push("");
556
560
  rightContent.push(` ${theme.dim("─────────────────────────────────")}`);
@@ -175,7 +175,9 @@ export class StatusView implements TuiView {
175
175
  const mins = Math.max(1, Math.round(acc.remainingCooldownMs / 60000));
176
176
  status = theme.yellow(`⚠ Cooldown ${mins}m`);
177
177
  } else if (!acc.headersReady) {
178
- status = theme.yellow(`◐ Aquecendo...`);
178
+ status = acc.isInitialized
179
+ ? theme.yellow(`◐ Aquecendo...`)
180
+ : theme.muted(`○ Standby`);
179
181
  }
180
182
  rightContent.push(` ${num} ${name} ${status}`);
181
183
  });
@@ -0,0 +1,121 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const packageRoot = path.resolve(__dirname, "..");
8
+ const packageJsonPath = path.join(packageRoot, "package.json");
9
+
10
+ export type PackageManager = "bun" | "pnpm" | "yarn" | "npm";
11
+
12
+ export function detectPackageManager(): PackageManager {
13
+ const userAgent = process.env.npm_config_user_agent || "";
14
+ if (userAgent.startsWith("bun")) return "bun";
15
+ if (userAgent.startsWith("pnpm")) return "pnpm";
16
+ if (userAgent.startsWith("yarn")) return "yarn";
17
+
18
+ // Check which executable is actually running the script or installed in PATH
19
+ const execPath = process.execPath.toLowerCase();
20
+ if (execPath.includes("bun")) return "bun";
21
+
22
+ // Check if installation path contains pnpm / bun / yarn markers
23
+ const currentPath = packageRoot.toLowerCase();
24
+ if (currentPath.includes(".pnpm") || currentPath.includes("pnpm")) return "pnpm";
25
+ if (currentPath.includes(".bun") || currentPath.includes("bun")) return "bun";
26
+ if (currentPath.includes("yarn")) return "yarn";
27
+
28
+ return "npm";
29
+ }
30
+ export function getUpdateArgs(pm: PackageManager, packageName: string): { cmd: string; args: string[] } {
31
+ switch (pm) {
32
+ case "bun":
33
+ return { cmd: "bun", args: ["add", "-g", `${packageName}@latest`] };
34
+ case "pnpm":
35
+ return { cmd: "pnpm", args: ["update", "-g", packageName] };
36
+ case "yarn":
37
+ return { cmd: "yarn", args: ["global", "upgrade", packageName] };
38
+ case "npm":
39
+ default:
40
+ return { cmd: "npm", args: ["install", "-g", `${packageName}@latest`] };
41
+ }
42
+ }
43
+
44
+ export function isNewerVersion(current: string, latest: string): boolean {
45
+ const c = current.replace(/^v/i, "").split(".").map((n) => parseInt(n, 10));
46
+ const l = latest.replace(/^v/i, "").split(".").map((n) => parseInt(n, 10));
47
+ for (let i = 0; i < 3; i++) {
48
+ const cv = c[i] || 0;
49
+ const lv = l[i] || 0;
50
+ if (lv > cv) return true;
51
+ if (lv < cv) return false;
52
+ }
53
+ return false;
54
+ }
55
+
56
+ export async function runUpdateCommand(): Promise<void> {
57
+ let pkg: any = { name: "qwenproxy-cli", version: "1.0.0" };
58
+ try {
59
+ pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
60
+ } catch {}
61
+
62
+ const currentVersion = pkg.version || "1.0.0";
63
+ const packageName = pkg.name || "qwenproxy-cli";
64
+ const pm = detectPackageManager();
65
+
66
+ console.log(`\n📦 [QwenProxy] Versão local instalada: v${currentVersion}`);
67
+ console.log(`⚙️ [QwenProxy] Gerenciador de pacotes detectado: ${pm}`);
68
+ console.log(`🔍 [QwenProxy] Verificando se há novas versões de ${packageName} no npm registry...`);
69
+
70
+ let latestVersion = "";
71
+ try {
72
+ const fullCmd = `npm view ${packageName} version`;
73
+ const res = spawnSync(fullCmd, {
74
+ encoding: "utf-8",
75
+ shell: true,
76
+ timeout: 10000,
77
+ });
78
+ latestVersion = res.stdout ? res.stdout.trim() : "";
79
+ } catch {}
80
+
81
+ if (!latestVersion) {
82
+ console.warn("⚠️ [QwenProxy] Não foi possível consultar o registro online.");
83
+ const manual = getUpdateArgs(pm, packageName);
84
+ console.log(`👉 Você pode forçar a atualização manualmente com:\n ${manual.cmd} ${manual.args.join(" ")}\n`);
85
+ return;
86
+ }
87
+
88
+ console.log(`🌐 [QwenProxy] Versão mais recente disponível: v${latestVersion}`);
89
+
90
+ if (!isNewerVersion(currentVersion, latestVersion)) {
91
+ console.log(`\n✨ Você já está utilizando a versão mais recente (v${currentVersion})!\n`);
92
+ return;
93
+ }
94
+
95
+ console.log(`\n🚀 Nova versão disponível: v${currentVersion} ➔ v${latestVersion}`);
96
+ const { cmd, args } = getUpdateArgs(pm, packageName);
97
+ console.log(`⏳ Atualizando globalmente via ${pm} (${cmd} ${args.join(" ")})...`);
98
+ const fullUpdateCmd = `${cmd} ${args.join(" ")}`;
99
+ const updateProc = spawnSync(fullUpdateCmd, {
100
+ stdio: "inherit",
101
+ shell: true,
102
+ });
103
+ if (updateProc.status === 0) {
104
+ console.log(`\n✅ [QwenProxy] Atualizado com sucesso para a versão v${latestVersion}!`);
105
+ console.log("👉 Digite 'qpx' para iniciar a nova versão.\n");
106
+ } else {
107
+ console.error(`\n❌ [QwenProxy] Falha ao atualizar automaticamente com ${cmd}.`);
108
+ console.log(`👉 Tente executar manualmente:\n ${cmd} ${args.join(" ")}\n`);
109
+ }
110
+ }
111
+
112
+ // Execute if run directly via CLI runner, not when imported in unit tests
113
+ const isDirectRun =
114
+ Boolean(process.argv[1]) &&
115
+ (fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) ||
116
+ process.argv[1].endsWith("update-cli.ts") ||
117
+ process.argv[1].endsWith("update-cli.js"));
118
+
119
+ if (isDirectRun) {
120
+ void runUpdateCommand();
121
+ }