magpie-gemini-web 0.4.1 → 0.5.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.
@@ -0,0 +1,1137 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import crypto from 'node:crypto';
5
+ import net from 'node:net';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { spawn, execFile, execFileSync } from 'node:child_process';
8
+ import release from './studio-release.json' with { type: 'json' };
9
+
10
+ export const STUDIO_VERSION = `v${release.version}`;
11
+ export const PINNED_ARCHIVES = release.binaries;
12
+ // The port a new setup tries first: AIStudio2API's own default.
13
+ export const DEFAULT_STUDIO_PORT = 2048;
14
+ // backend.log is set aside as backend.log.1 when a start finds it past this size.
15
+ const LOG_ROTATE_BYTES = 10 * 1024 * 1024;
16
+
17
+ const adminSessions = new Map();
18
+ const inFlightStarts = new Map();
19
+ const inFlightReady = new Map();
20
+
21
+ export function studioDataDir(env = process.env) {
22
+ return env.AI_STUDIO_DATA_DIR || path.join(os.homedir(), '.local', 'share', 'magpie-ai-studio');
23
+ }
24
+
25
+ export function computeFileHash(filePath) {
26
+ const content = fs.readFileSync(filePath);
27
+ return crypto.createHash('sha256').update(content).digest('hex');
28
+ }
29
+
30
+ // studioCommand is a command line for this plugin's own management CLI, to quote in messages.
31
+ // Magpie installs the plugin where its user doesn't look, so `npm run studio:*` from a checkout
32
+ // is no help to them; the CLI's full path also keeps its pinned version the plugin's own.
33
+ export function studioCommand(action) {
34
+ const script = fileURLToPath(new URL('../scripts/studio.mjs', import.meta.url));
35
+ return `node "${script}" ${action}`;
36
+ }
37
+
38
+ // pickStudioPort is the port for a new setup: preferred when nothing uses it, else one the system
39
+ // has free. A user who already runs AIStudio2API keeps it on its default port, which a new setup
40
+ // would otherwise find taken on every start.
41
+ export async function pickStudioPort(preferred = DEFAULT_STUDIO_PORT) {
42
+ if (!(await portAnswers(preferred)) && await canListen(preferred)) return preferred;
43
+ return new Promise((resolve, reject) => {
44
+ const server = net.createServer();
45
+ server.once('error', reject);
46
+ server.listen(0, '127.0.0.1', () => {
47
+ const { port } = server.address();
48
+ server.close(() => resolve(port));
49
+ });
50
+ });
51
+ }
52
+
53
+ // portAnswers says whether something accepts connections on the port. Listening alone doesn't
54
+ // tell: on macOS a listener on 0.0.0.0 lets another bind 127.0.0.1 to the same port.
55
+ function portAnswers(port) {
56
+ return new Promise(resolve => {
57
+ const socket = net.connect({ host: '127.0.0.1', port });
58
+ const done = answered => {
59
+ socket.destroy();
60
+ resolve(answered);
61
+ };
62
+ socket.setTimeout(1000, () => done(true));
63
+ socket.once('connect', () => done(true));
64
+ socket.once('error', err => done(err.code !== 'ECONNREFUSED'));
65
+ });
66
+ }
67
+
68
+ function canListen(port) {
69
+ return new Promise(resolve => {
70
+ const server = net.createServer();
71
+ server.once('error', () => resolve(false));
72
+ server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)));
73
+ });
74
+ }
75
+
76
+ // newConfigPort is the port to write when dataDir has no config.json yet (options.preferredPort
77
+ // tried first), else undefined.
78
+ async function newConfigPort(dataDir, options = {}) {
79
+ if (fs.existsSync(path.join(dataDir, 'config.json'))) return undefined;
80
+ return pickStudioPort(options.preferredPort ?? DEFAULT_STUDIO_PORT);
81
+ }
82
+
83
+ export function binaryPath(dataDir, platform = process.platform) {
84
+ return path.join(dataDir, 'package', platform === 'win32' ? 'aistudio2api.exe' : 'aistudio2api');
85
+ }
86
+
87
+ export function validateProxy(proxy) {
88
+ if (proxy === undefined || proxy === null || proxy === '') return '';
89
+ if (typeof proxy !== 'string') {
90
+ throw new Error('Proxy URL must be a string');
91
+ }
92
+ const trimmed = proxy.trim();
93
+ if (!trimmed) return '';
94
+ if (/[\r\n]/.test(trimmed)) throw new Error('Proxy URL must not contain newlines');
95
+ let parsed;
96
+ try {
97
+ parsed = new URL(trimmed);
98
+ } catch {
99
+ throw new Error('Invalid proxy URL');
100
+ }
101
+ if (!['http:', 'https:', 'socks5:', 'socks5h:'].includes(parsed.protocol)) {
102
+ throw new Error('Proxy URL must use http, https, or socks5 protocol');
103
+ }
104
+ return trimmed;
105
+ }
106
+
107
+ // effectiveProxy is the proxy the service and its downloads go through: the one given, else
108
+ // AI_STUDIO_PROXY, else the one saved in config.json.
109
+ function effectiveProxy(explicit, saved) {
110
+ if (explicit !== undefined) return validateProxy(explicit);
111
+ if (process.env.AI_STUDIO_PROXY !== undefined) return validateProxy(process.env.AI_STUDIO_PROXY);
112
+ return validateProxy(saved);
113
+ }
114
+
115
+ export function validateConfig(config) {
116
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
117
+ throw new Error('Configuration must be an object');
118
+ }
119
+ if (config.version !== 1) {
120
+ throw new Error(`Unsupported configuration version: ${config.version}`);
121
+ }
122
+ const port = config.port;
123
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
124
+ throw new Error(`Invalid port number in configuration: ${port}`);
125
+ }
126
+ if (typeof config.apiKey !== 'string' || !config.apiKey || /[\r\n]/.test(config.apiKey)) {
127
+ throw new Error('Invalid apiKey in configuration: must be non-empty string without newlines');
128
+ }
129
+ if (typeof config.adminUsername !== 'string' || !config.adminUsername || /[\r\n]/.test(config.adminUsername)) {
130
+ throw new Error('Invalid adminUsername in configuration: must be non-empty string without newlines');
131
+ }
132
+ if (typeof config.adminPassword !== 'string' || !config.adminPassword || /[\r\n]/.test(config.adminPassword)) {
133
+ throw new Error('Invalid adminPassword in configuration: must be non-empty string without newlines');
134
+ }
135
+ const proxy = validateProxy(config.proxy);
136
+ return {
137
+ version: 1,
138
+ port,
139
+ apiKey: config.apiKey,
140
+ adminUsername: config.adminUsername,
141
+ adminPassword: config.adminPassword,
142
+ proxy
143
+ };
144
+ }
145
+
146
+ // serviceEnv is the fixed settings the service runs with. The service is started with them and
147
+ // they are written to its .env, which it reads again when its generation service restarts.
148
+ function serviceEnv(dataDir, config, options = {}) {
149
+ const authDir = path.resolve(dataDir, 'auth');
150
+ if (/[\r\n]/.test(authDir) || /[\r\n]/.test(config.apiKey) || /[\r\n]/.test(config.adminUsername) || /[\r\n]/.test(config.adminPassword)) {
151
+ throw new Error('Configuration paths or credentials must not contain newlines');
152
+ }
153
+ return {
154
+ LISTEN_ADDR: `127.0.0.1:${config.port}`,
155
+ AISTUDIO_AUTH_STATES: authDir,
156
+ PROXY_API_KEY: config.apiKey,
157
+ ADMIN_AUTH_ENABLED: 'true',
158
+ ADMIN_USERNAME: config.adminUsername,
159
+ ADMIN_PASSWORD: config.adminPassword,
160
+ AUTO_START: 'true',
161
+ REQUEST_BODY_LOG: 'false',
162
+ LOG_FILE: 'false',
163
+ WAA_BACKEND: 'go',
164
+ UPSTREAM_CHANNELS: 'playground',
165
+ BUILD_NATIVE_NONSTREAM: 'false',
166
+ TEMPORARY_CHAT: 'true',
167
+ PER_ACCOUNT_CONCURRENCY: '1',
168
+ WARM_WORKER_LIMIT: '1',
169
+ MAX_ACTIVE_WORKERS: '1',
170
+ WARM_STARTUP_CONCURRENCY: '1',
171
+ ROUTING_STRATEGY: 'fill-first',
172
+ REQUEST_TIMEOUT: '5m',
173
+ INIT_TIMEOUT: '2m',
174
+ PROXY: effectiveProxy(options.proxy, config.proxy)
175
+ };
176
+ }
177
+
178
+ export function generateEnv(dataDir, config, options = {}) {
179
+ return Object.entries(serviceEnv(dataDir, config, options)).map(([k, v]) => `${k}=${v}\n`).join('');
180
+ }
181
+
182
+ // ensureStudioConfig reads config.json, writing a new one if there is none: on options.port when
183
+ // given (see pickStudioPort), else on DEFAULT_STUDIO_PORT.
184
+ export function ensureStudioConfig(dataDir, explicitProxy = undefined, options = {}) {
185
+ fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
186
+ fs.chmodSync(dataDir, 0o700);
187
+
188
+ const configPath = path.join(dataDir, 'config.json');
189
+ let config;
190
+ try {
191
+ const raw = fs.readFileSync(configPath, 'utf8');
192
+ let parsed;
193
+ try {
194
+ parsed = JSON.parse(raw);
195
+ } catch (parseErr) {
196
+ throw new Error(`Invalid JSON in configuration file ${configPath}: ${parseErr.message}`);
197
+ }
198
+ config = validateConfig(parsed);
199
+ fs.chmodSync(configPath, 0o600);
200
+ } catch (err) {
201
+ if (err.code === 'ENOENT') {
202
+ config = {
203
+ version: 1,
204
+ port: options.port ?? DEFAULT_STUDIO_PORT,
205
+ apiKey: 'as_' + crypto.randomBytes(32).toString('hex'),
206
+ adminUsername: 'magpie',
207
+ adminPassword: crypto.randomBytes(32).toString('hex'),
208
+ proxy: validateProxy(explicitProxy ?? '')
209
+ };
210
+ const tmp = `${configPath}.${crypto.randomBytes(4).toString('hex')}.tmp`;
211
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
212
+ fs.renameSync(tmp, configPath);
213
+ fs.chmodSync(configPath, 0o600);
214
+ } else {
215
+ throw err;
216
+ }
217
+ }
218
+
219
+ const envPath = path.join(dataDir, '.env');
220
+ const envContent = generateEnv(dataDir, config, { proxy: explicitProxy });
221
+ const tmpEnv = `${envPath}.${crypto.randomBytes(4).toString('hex')}.tmp`;
222
+ fs.writeFileSync(tmpEnv, envContent, { mode: 0o600 });
223
+ fs.renameSync(tmpEnv, envPath);
224
+ fs.chmodSync(envPath, 0o600);
225
+
226
+ const authDir = path.join(dataDir, 'auth');
227
+ fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
228
+ fs.chmodSync(authDir, 0o700);
229
+
230
+ return config;
231
+ }
232
+
233
+ export function localStudio(options = {}) {
234
+ const dataDir = options.dataDir || studioDataDir();
235
+ const platform = options.platform || process.platform;
236
+ const binPath = binaryPath(dataDir, platform);
237
+ if (!fs.existsSync(binPath)) return null;
238
+
239
+ const configPath = path.join(dataDir, 'config.json');
240
+ if (!fs.existsSync(configPath)) return null;
241
+ let config;
242
+ try {
243
+ config = validateConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
244
+ } catch {
245
+ return null;
246
+ }
247
+
248
+ const origin = `http://127.0.0.1:${config.port}`;
249
+ return {
250
+ origin,
251
+ baseURL: `${origin}/v1`,
252
+ apiKey: config.apiKey,
253
+ adminUsername: config.adminUsername,
254
+ adminPassword: config.adminPassword,
255
+ port: config.port,
256
+ dataDir
257
+ };
258
+ }
259
+
260
+ export async function studioAdmin(local, method, pathname, body = null, options = {}) {
261
+ if (!local || typeof local !== 'object') {
262
+ throw new Error('localStudio configuration required for studioAdmin');
263
+ }
264
+ const fetchFn = options.fetchFn || globalThis.fetch;
265
+ const origin = local.origin;
266
+ const sessionKey = `${origin}\n${local.adminUsername}\n${local.adminPassword}`;
267
+
268
+ let timeoutMs = options.timeoutMs;
269
+ if (!timeoutMs) {
270
+ if (pathname === '/api/accounts' && method.toUpperCase() === 'POST') {
271
+ timeoutMs = 15 * 60 * 1000;
272
+ } else if (pathname.includes('/control/start')) {
273
+ timeoutMs = 180 * 1000;
274
+ } else {
275
+ timeoutMs = 5 * 1000;
276
+ }
277
+ }
278
+
279
+ async function login() {
280
+ const loginUrl = `${origin}/api/auth/login`;
281
+ let res;
282
+ try {
283
+ res = await fetchFn(loginUrl, {
284
+ method: 'POST',
285
+ headers: { 'Content-Type': 'application/json' },
286
+ body: JSON.stringify({ username: local.adminUsername, password: local.adminPassword }),
287
+ redirect: 'error',
288
+ signal: AbortSignal.timeout(Math.min(timeoutMs, 10000))
289
+ });
290
+ } catch (err) {
291
+ adminSessions.delete(sessionKey);
292
+ if (err.foreign) throw err;
293
+ if (err.cause?.message?.includes('redirect') || err.message?.includes('redirect')) {
294
+ const e = new Error('Admin login returned redirect; foreign listener detected');
295
+ e.foreign = true;
296
+ throw e;
297
+ }
298
+ throw err;
299
+ }
300
+ if (res.status >= 300 && res.status < 400) {
301
+ adminSessions.delete(sessionKey);
302
+ const err = new Error('Admin login returned redirect; foreign listener detected');
303
+ err.foreign = true;
304
+ throw err;
305
+ }
306
+ if (!res.ok) {
307
+ adminSessions.delete(sessionKey);
308
+ const err = new Error(`Admin login failed: HTTP ${res.status}`);
309
+ err.status = res.status;
310
+ // A server error or a rate limit is the service having trouble; any other refusal of this
311
+ // setup's credentials means the service isn't this setup's.
312
+ err.foreign = res.status < 500 && res.status !== 429;
313
+ throw err;
314
+ }
315
+ // Each Set-Cookie on its own: joined, a cookie set after another one isn't found by name.
316
+ const cookie = res.headers.getSetCookie()
317
+ .map(line => /^\s*(aistudio_admin=[^;]+)/.exec(line)?.[1])
318
+ .find(Boolean);
319
+ if (!cookie) {
320
+ const err = new Error('Admin login did not provide aistudio_admin session cookie');
321
+ err.foreign = true;
322
+ throw err;
323
+ }
324
+ adminSessions.set(sessionKey, cookie);
325
+ return cookie;
326
+ }
327
+
328
+ let cookie = adminSessions.get(sessionKey);
329
+ if (!cookie) {
330
+ cookie = await login();
331
+ }
332
+
333
+ async function executeRequest(activeCookie) {
334
+ const url = `${origin}${pathname}`;
335
+ const headers = new Headers();
336
+ if (activeCookie) headers.set('Cookie', activeCookie);
337
+ let payload = null;
338
+ if (body !== null && body !== undefined) {
339
+ if (typeof body === 'object') {
340
+ payload = JSON.stringify(body);
341
+ headers.set('Content-Type', 'application/json');
342
+ } else {
343
+ payload = String(body);
344
+ }
345
+ }
346
+ return fetchFn(url, {
347
+ method,
348
+ headers,
349
+ body: payload,
350
+ redirect: 'error',
351
+ signal: AbortSignal.timeout(timeoutMs)
352
+ });
353
+ }
354
+
355
+ let res;
356
+ try {
357
+ res = await executeRequest(cookie);
358
+ } catch (err) {
359
+ if (err.foreign) throw err;
360
+ if (err.cause?.message?.includes('redirect') || err.message?.includes('redirect')) {
361
+ const e = new Error(`Admin request ${method} ${pathname} returned redirect; foreign listener detected`);
362
+ e.foreign = true;
363
+ throw e;
364
+ }
365
+ const e = new Error(`Admin request ${method} ${pathname} failed: ${err.message}`, { cause: err });
366
+ if (err.code) e.code = err.code;
367
+ throw e;
368
+ }
369
+
370
+ if (res.status >= 300 && res.status < 400) {
371
+ const err = new Error(`Admin request ${method} ${pathname} returned redirect; foreign listener detected`);
372
+ err.foreign = true;
373
+ throw err;
374
+ }
375
+
376
+ if (res.status === 401) {
377
+ cookie = await login();
378
+ try {
379
+ res = await executeRequest(cookie);
380
+ } catch (err) {
381
+ if (err.foreign) throw err;
382
+ if (err.cause?.message?.includes('redirect') || err.message?.includes('redirect')) {
383
+ const e = new Error(`Admin request ${method} ${pathname} returned redirect on retry`);
384
+ e.foreign = true;
385
+ throw e;
386
+ }
387
+ const e = new Error(`Admin request ${method} ${pathname} retry failed: ${err.message}`, { cause: err });
388
+ if (err.code) e.code = err.code;
389
+ throw e;
390
+ }
391
+ if (res.status >= 300 && res.status < 400) {
392
+ const err = new Error(`Admin request ${method} ${pathname} returned redirect on retry`);
393
+ err.foreign = true;
394
+ throw err;
395
+ }
396
+ }
397
+
398
+ if (res.status === 204) return null;
399
+ const contentType = res.headers.get('content-type') || '';
400
+ if (contentType.includes('application/json')) {
401
+ const json = await res.json();
402
+ if (!res.ok) {
403
+ throw new Error(json?.error?.message || json?.message || `Admin request failed: HTTP ${res.status}`);
404
+ }
405
+ return json;
406
+ }
407
+
408
+ const text = await res.text();
409
+ if (!res.ok) {
410
+ throw new Error(`Admin request failed: HTTP ${res.status}: ${text.slice(0, 500)}`);
411
+ }
412
+ return text;
413
+ }
414
+
415
+ export async function studioAccounts(options = {}) {
416
+ const local = localStudio(options);
417
+ if (!local) return [];
418
+ const res = await studioAdmin(local, 'GET', '/api/accounts', null, options);
419
+ const accounts = Array.isArray(res?.accounts) ? res.accounts : [];
420
+ return accounts.filter(a => a.enabled !== false);
421
+ }
422
+
423
+ export function isConnectRefused(err) {
424
+ if (!err) return false;
425
+ const visited = new Set();
426
+ let current = err;
427
+ while (current && !visited.has(current)) {
428
+ visited.add(current);
429
+ if (current.code === 'ECONNREFUSED' || current.code === 'ENOENT') return true;
430
+ if (Array.isArray(current.errors)) {
431
+ for (const sub of current.errors) {
432
+ if (isConnectRefused(sub)) return true;
433
+ }
434
+ }
435
+ current = current.cause;
436
+ }
437
+ return false;
438
+ }
439
+
440
+ // checkStudioHealth says what answers on the setup's port: 'down' (nothing), 'up' (this setup's
441
+ // service, at the pinned version), 'outdated' (this setup's service, at another version),
442
+ // 'foreign' (something else) or 'unresponsive' (something that times out or fails, as the
443
+ // service can while it starts or when it hangs).
444
+ export async function checkStudioHealth(local, fetchFn) {
445
+ let status;
446
+ try {
447
+ status = await studioAdmin(local, 'GET', '/api/status', null, { fetchFn, timeoutMs: 2000 });
448
+ } catch (err) {
449
+ if (isConnectRefused(err)) {
450
+ return { state: 'down', reason: 'ECONNREFUSED' };
451
+ }
452
+ return { state: err.foreign ? 'foreign' : 'unresponsive', reason: err.message };
453
+ }
454
+
455
+ if (!status || typeof status !== 'object') {
456
+ return { state: 'foreign', reason: 'Status response is not an object' };
457
+ }
458
+ if (typeof status.ready !== 'boolean' || typeof status.running !== 'boolean') {
459
+ return { state: 'foreign', reason: 'Missing boolean ready or running in status' };
460
+ }
461
+ if (!status.accounts || typeof status.accounts !== 'object') {
462
+ return { state: 'foreign', reason: 'Missing accounts object in status' };
463
+ }
464
+
465
+ if (status.ready) {
466
+ try {
467
+ const modelsRes = await fetchFn(`${local.baseURL}/models`, {
468
+ headers: { Authorization: `Bearer ${local.apiKey}` },
469
+ redirect: 'error',
470
+ signal: AbortSignal.timeout(2000)
471
+ });
472
+ await modelsRes.body?.cancel();
473
+ if (modelsRes.status === 401 || (modelsRes.status >= 300 && modelsRes.status < 400)) {
474
+ return { state: 'foreign', reason: 'Models auth key mismatch' };
475
+ }
476
+ } catch (err) {
477
+ if (isConnectRefused(err)) {
478
+ return { state: 'down', reason: 'ECONNREFUSED' };
479
+ }
480
+ return { state: 'unresponsive', reason: err.message };
481
+ }
482
+ }
483
+
484
+ // The service took this setup's own admin credentials, so this plugin installed it. Another
485
+ // version is an earlier install still running, as after a plugin update pinned a new one.
486
+ if (String(status.version).replace(/^v/, '') !== release.version) {
487
+ return { state: 'outdated', status, reason: `Running version ${status.version}, expected ${STUDIO_VERSION}` };
488
+ }
489
+
490
+ return { state: 'up', status };
491
+ }
492
+
493
+ export async function studioStatus(options = {}) {
494
+ const dataDir = options.dataDir || studioDataDir();
495
+ const platform = options.platform || process.platform;
496
+ const fetchFn = options.fetchFn || globalThis.fetch;
497
+ const local = localStudio({ dataDir, platform });
498
+ if (!local) return { status: 'not_installed', dataDir };
499
+
500
+ const health = await checkStudioHealth(local, fetchFn);
501
+ if (health.state === 'up') {
502
+ return { status: 'running', ...health.status, port: local.port, dataDir };
503
+ }
504
+ if (health.state === 'down') {
505
+ return { status: 'stopped', port: local.port, dataDir };
506
+ }
507
+ if (health.state === 'outdated') {
508
+ return { ...health.status, status: 'outdated', expected: STUDIO_VERSION, port: local.port, dataDir };
509
+ }
510
+ return { status: health.state, reason: health.reason, port: local.port, dataDir };
511
+ }
512
+
513
+ export function verifyProcessIdentity(pid, expectedBinaryPath, platform = process.platform) {
514
+ if (!Number.isInteger(pid) || pid <= 0) return false;
515
+ if (!fs.existsSync(expectedBinaryPath)) return false;
516
+
517
+ const expectedReal = fs.realpathSync(expectedBinaryPath);
518
+
519
+ if (platform === 'win32') {
520
+ try {
521
+ const out = execFileSync('powershell', [
522
+ '-NoProfile',
523
+ '-NonInteractive',
524
+ '-Command',
525
+ `Get-CimInstance Win32_Process -Filter "ProcessId = $env:TARGET_PID" | Select-Object -ExpandProperty ExecutablePath`
526
+ ], {
527
+ env: { ...process.env, TARGET_PID: String(pid) },
528
+ encoding: 'utf8',
529
+ windowsHide: true
530
+ }).trim();
531
+ if (!out) return false;
532
+ return fs.realpathSync(out).toLowerCase() === expectedReal.toLowerCase();
533
+ } catch {
534
+ return false;
535
+ }
536
+ }
537
+
538
+ // Linux /proc/<pid>/exe
539
+ const linuxProcExe = `/proc/${pid}/exe`;
540
+ if (fs.existsSync(linuxProcExe)) {
541
+ try {
542
+ const targetReal = fs.realpathSync(linuxProcExe);
543
+ return targetReal === expectedReal;
544
+ } catch {}
545
+ }
546
+
547
+ // macOS / fallback: ps -p <pid> -o comm=
548
+ try {
549
+ const comm = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], { encoding: 'utf8' }).trim();
550
+ if (!comm) return false;
551
+ if (fs.existsSync(comm)) {
552
+ return fs.realpathSync(comm) === expectedReal;
553
+ }
554
+ if (path.basename(comm) === path.basename(expectedReal)) {
555
+ const command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' }).trim();
556
+ const firstArg = command.split(' ')[0];
557
+ if (fs.existsSync(firstArg) && fs.realpathSync(firstArg) === expectedReal) {
558
+ return true;
559
+ }
560
+ }
561
+ } catch {}
562
+
563
+ return false;
564
+ }
565
+
566
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
567
+
568
+ async function withLock(dataDir, fn, timeoutMs = 600000) {
569
+ const lockPath = path.join(dataDir, '.setup.lock');
570
+ const token = crypto.randomBytes(16).toString('hex');
571
+ const deadline = Date.now() + timeoutMs;
572
+
573
+ for (;;) {
574
+ try {
575
+ const fd = fs.openSync(lockPath, 'wx', 0o600);
576
+ fs.writeFileSync(fd, `${process.pid}\n${Date.now()}\n${token}\n`);
577
+ fs.closeSync(fd);
578
+ break;
579
+ } catch (err) {
580
+ if (err.code !== 'EEXIST') throw err;
581
+
582
+ let ownerAlive = false;
583
+ let ownerPid = NaN;
584
+ let stat = null;
585
+ try {
586
+ stat = fs.statSync(lockPath);
587
+ const content = fs.readFileSync(lockPath, 'utf8');
588
+ const lines = content.split('\n');
589
+ ownerPid = parseInt(lines[0], 10);
590
+ if (Number.isInteger(ownerPid) && ownerPid > 0) {
591
+ process.kill(ownerPid, 0);
592
+ ownerAlive = true;
593
+ }
594
+ } catch {
595
+ ownerAlive = false;
596
+ }
597
+
598
+ if (Number.isInteger(ownerPid) && ownerPid > 0) {
599
+ if (!ownerAlive) {
600
+ try {
601
+ fs.rmSync(lockPath, { force: true });
602
+ continue;
603
+ } catch {}
604
+ }
605
+ } else if (stat && Date.now() - stat.mtimeMs > 30000) {
606
+ try {
607
+ fs.rmSync(lockPath, { force: true });
608
+ continue;
609
+ } catch {}
610
+ }
611
+
612
+ if (Date.now() > deadline) {
613
+ throw new Error(`Timed out waiting for another AI Studio setup lock (${lockPath})`);
614
+ }
615
+ await sleep(250);
616
+ }
617
+ }
618
+
619
+ try {
620
+ return await fn();
621
+ } finally {
622
+ try {
623
+ const current = fs.readFileSync(lockPath, 'utf8');
624
+ if (current.includes(token)) {
625
+ fs.rmSync(lockPath, { force: true });
626
+ }
627
+ } catch {}
628
+ }
629
+ }
630
+
631
+ export async function installStudio(options = {}) {
632
+ const platform = options.platform || process.platform;
633
+ const arch = options.arch || process.arch;
634
+ const pinKey = `${platform}-${arch}`;
635
+ const pin = PINNED_ARCHIVES[pinKey];
636
+ if (!pin) {
637
+ throw new Error(`Unsupported platform/arch: ${platform}/${arch}. Only darwin-x64, darwin-arm64, linux-x64, linux-arm64, win32-x64 are supported.`);
638
+ }
639
+
640
+ const dataDir = options.dataDir || studioDataDir();
641
+ fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
642
+ fs.chmodSync(dataDir, 0o700);
643
+
644
+ // Validate existing config first, throw if corrupted
645
+ const configPath = path.join(dataDir, 'config.json');
646
+ const savedConfig = fs.existsSync(configPath)
647
+ ? validateConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')))
648
+ : null;
649
+
650
+ // Refuse install/update while running or foreign
651
+ const localExisting = localStudio({ dataDir, platform });
652
+ if (localExisting) {
653
+ const health = await checkStudioHealth(localExisting, options.fetchFn || globalThis.fetch);
654
+ if (health.state === 'up') {
655
+ throw new Error('Cannot install or update AIStudio2API while service is running. Stop the service first.');
656
+ }
657
+ if (health.state === 'outdated') {
658
+ throw new Error(`Cannot install AIStudio2API ${STUDIO_VERSION} while ${health.status.version} from an earlier install is running. Stop the service first.`);
659
+ }
660
+ if (health.state === 'foreign') {
661
+ throw new Error(`Cannot install AIStudio2API: Port ${localExisting.port} is occupied by an unknown/foreign listener. Change "port" in config.json.`);
662
+ }
663
+ if (health.state === 'unresponsive') {
664
+ throw new Error(`Cannot install AIStudio2API while something on port ${localExisting.port} is not responding (${health.reason}). Stop the service first.`);
665
+ }
666
+ }
667
+
668
+ const expectedHash = pin.sha256;
669
+ let archivePath = options.archivePath;
670
+ // The archive kept from an earlier install sets a damaged binary right again without a download.
671
+ const retainedArchive = path.join(dataDir, pin.asset);
672
+ if (!archivePath && fs.existsSync(retainedArchive) && computeFileHash(retainedArchive) === expectedHash) {
673
+ archivePath = retainedArchive;
674
+ }
675
+ const port = await newConfigPort(dataDir, options);
676
+ const stageDir = path.join(dataDir, `.install.tmp.${crypto.randomBytes(4).toString('hex')}`);
677
+ fs.mkdirSync(stageDir, { recursive: true, mode: 0o700 });
678
+
679
+ try {
680
+ if (archivePath) {
681
+ if (!fs.existsSync(archivePath)) {
682
+ throw new Error(`Provided archive does not exist: ${archivePath}`);
683
+ }
684
+ const actualHash = computeFileHash(archivePath);
685
+ if (actualHash !== expectedHash) {
686
+ throw new Error(`SHA256 checksum mismatch for archive: expected ${expectedHash}, got ${actualHash}`);
687
+ }
688
+ } else {
689
+ const downloadUrl = `https://github.com/${release.repository}/releases/download/${release.tag}/${pin.asset}`;
690
+ archivePath = path.join(stageDir, pin.asset);
691
+ const proxy = effectiveProxy(options.proxy, savedConfig?.proxy);
692
+
693
+ if (options.fetchFn && options.fetchFn !== globalThis.fetch) {
694
+ const res = await options.fetchFn(downloadUrl, { signal: AbortSignal.timeout(300000) });
695
+ if (!res.ok) {
696
+ throw new Error(`Failed to download AIStudio2API archive from ${downloadUrl}: HTTP ${res.status}`);
697
+ }
698
+ const buffer = Buffer.from(await res.arrayBuffer());
699
+ const downloadedHash = crypto.createHash('sha256').update(buffer).digest('hex');
700
+ if (downloadedHash !== expectedHash) {
701
+ throw new Error(`SHA256 checksum mismatch for downloaded archive: expected ${expectedHash}, got ${downloadedHash}`);
702
+ }
703
+ fs.writeFileSync(archivePath, buffer, { mode: 0o600 });
704
+ } else {
705
+ // Without a progress meter curl's stderr holds just the reason a download failed.
706
+ const curlArgs = ['--fail', '--location', '--silent', '--show-error', '--connect-timeout', '15', '--max-time', '300'];
707
+ if (proxy) {
708
+ curlArgs.push('--proxy', proxy);
709
+ }
710
+ curlArgs.push('-o', archivePath, downloadUrl);
711
+ await new Promise((resolve, reject) => {
712
+ execFile('curl', curlArgs, (err, _stdout, stderr) => {
713
+ if (err) {
714
+ const status = err.code || err.status || 'failed';
715
+ const reason = String(stderr || '').trim().slice(0, 500);
716
+ reject(new Error(`Failed to download AIStudio2API archive via curl (status: ${status})${reason ? `: ${reason}` : ''}`));
717
+ } else {
718
+ resolve();
719
+ }
720
+ });
721
+ });
722
+ const downloadedHash = computeFileHash(archivePath);
723
+ if (downloadedHash !== expectedHash) {
724
+ throw new Error(`SHA256 checksum mismatch for downloaded archive: expected ${expectedHash}, got ${downloadedHash}`);
725
+ }
726
+ }
727
+ }
728
+
729
+ if (archivePath !== retainedArchive) {
730
+ fs.copyFileSync(archivePath, retainedArchive);
731
+ fs.chmodSync(retainedArchive, 0o600);
732
+ }
733
+
734
+ const extractDir = path.join(stageDir, 'extracted');
735
+ fs.mkdirSync(extractDir, { recursive: true, mode: 0o700 });
736
+
737
+ if (platform === 'win32') {
738
+ execFileSync('powershell', [
739
+ '-NoProfile',
740
+ '-NonInteractive',
741
+ '-Command',
742
+ 'Expand-Archive -LiteralPath $env:ARCHIVE_PATH -DestinationPath $env:DEST_PATH -Force'
743
+ ], {
744
+ env: { ...process.env, ARCHIVE_PATH: archivePath, DEST_PATH: extractDir },
745
+ windowsHide: true
746
+ });
747
+ } else {
748
+ const listOutput = execFileSync('tar', ['-tvf', archivePath], { encoding: 'utf8' }).split('\n').filter(Boolean);
749
+ for (const line of listOutput) {
750
+ const parts = line.split(/\s+/);
751
+ const mode = parts[0];
752
+ if (mode.startsWith('l') || mode.startsWith('s')) {
753
+ throw new Error(`Symlinks or special files in archive rejected: ${line}`);
754
+ }
755
+ }
756
+ const listing = execFileSync('tar', ['-tf', archivePath], { encoding: 'utf8' }).split('\n').filter(Boolean);
757
+ for (const entry of listing) {
758
+ const entryParts = entry.replace(/\\/g, '/').split('/');
759
+ if (entryParts.includes('..') || entry.startsWith('/') || entry.startsWith('\\') || /^[a-zA-Z]:/.test(entry)) {
760
+ throw new Error(`Dangerous member in archive rejected: ${entry}`);
761
+ }
762
+ }
763
+ execFileSync('tar', ['-xzf', archivePath, '-C', extractDir]);
764
+ }
765
+
766
+ let topDir = extractDir;
767
+ const entries = fs.readdirSync(extractDir);
768
+ if (entries.length === 1 && fs.statSync(path.join(extractDir, entries[0])).isDirectory()) {
769
+ topDir = path.join(extractDir, entries[0]);
770
+ }
771
+
772
+ const binName = platform === 'win32' ? 'aistudio2api.exe' : 'aistudio2api';
773
+ const foundBin = path.join(topDir, binName);
774
+ if (!fs.existsSync(foundBin)) {
775
+ throw new Error(`Could not find ${binName} in extracted package`);
776
+ }
777
+ fs.chmodSync(foundBin, 0o755);
778
+
779
+ const binarySha256 = computeFileHash(foundBin);
780
+ const manifestPath = path.join(dataDir, 'install-manifest.json');
781
+ const oldManifestExists = fs.existsSync(manifestPath);
782
+ const oldManifestBytes = oldManifestExists ? fs.readFileSync(manifestPath) : null;
783
+
784
+ const finalPackageDir = path.join(dataDir, 'package');
785
+ const stagePackageDir = path.join(dataDir, `.package.stage.${crypto.randomBytes(4).toString('hex')}`);
786
+ const backupPackageDir = path.join(dataDir, `.package.backup.${crypto.randomBytes(4).toString('hex')}`);
787
+
788
+ fs.renameSync(topDir, stagePackageDir);
789
+
790
+ let hasBackup = false;
791
+ let installedNewFinal = false;
792
+ if (fs.existsSync(finalPackageDir)) {
793
+ fs.renameSync(finalPackageDir, backupPackageDir);
794
+ hasBackup = true;
795
+ }
796
+
797
+ try {
798
+ fs.renameSync(stagePackageDir, finalPackageDir);
799
+ installedNewFinal = true;
800
+
801
+ const manifest = {
802
+ version: release.version,
803
+ platform,
804
+ arch,
805
+ archiveSha256: expectedHash,
806
+ binarySha256
807
+ };
808
+ const tmpManifest = `${manifestPath}.${crypto.randomBytes(4).toString('hex')}.tmp`;
809
+ fs.writeFileSync(tmpManifest, JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
810
+ fs.renameSync(tmpManifest, manifestPath);
811
+ fs.chmodSync(manifestPath, 0o600);
812
+
813
+ ensureStudioConfig(dataDir, options.proxy, { port });
814
+
815
+ if (hasBackup) {
816
+ fs.rmSync(backupPackageDir, { recursive: true, force: true });
817
+ }
818
+ removeOtherArchives(dataDir, pin.asset);
819
+ } catch (swapErr) {
820
+ if (installedNewFinal) {
821
+ try { fs.rmSync(finalPackageDir, { recursive: true, force: true }); } catch {}
822
+ }
823
+ if (hasBackup) {
824
+ try { fs.renameSync(backupPackageDir, finalPackageDir); } catch {}
825
+ }
826
+ if (oldManifestExists && oldManifestBytes) {
827
+ try { fs.writeFileSync(manifestPath, oldManifestBytes, { mode: 0o600 }); } catch {}
828
+ } else {
829
+ try { fs.rmSync(manifestPath, { force: true }); } catch {}
830
+ }
831
+ throw swapErr;
832
+ }
833
+ } finally {
834
+ fs.rmSync(stageDir, { recursive: true, force: true });
835
+ }
836
+
837
+ return localStudio({ dataDir, platform });
838
+ }
839
+
840
+ // removeOtherArchives deletes the release archives earlier installs kept, all but keep.
841
+ function removeOtherArchives(dataDir, keep) {
842
+ for (const name of fs.readdirSync(dataDir)) {
843
+ if (name !== keep && /^aistudio2api-v[^/\\]+\.(?:tar\.gz|zip)$/.test(name)) {
844
+ try { fs.rmSync(path.join(dataDir, name), { force: true }); } catch {}
845
+ }
846
+ }
847
+ }
848
+
849
+ // readyStudio waits until the service takes generation requests, starting it when stopped.
850
+ // Once it is RUNNING it takes them: with no account ready, the backend answers each request
851
+ // saying why, so there is nothing to wait for.
852
+ async function readyStudio(local, initial, options = {}) {
853
+ const fetchFn = options.fetchFn || globalThis.fetch;
854
+ let status = initial;
855
+ if (status.running || status.accounts.total === 0) return local;
856
+ if (status.state === 'STOPPED' && !needsSignIn(status.accounts)) {
857
+ await studioAdmin(local, 'POST', '/api/control/start', null, { fetchFn, timeoutMs: 180000 });
858
+ }
859
+ const deadline = Date.now() + (options.generationTimeoutMs ?? 120000);
860
+ for (;;) {
861
+ if (status.running) return local;
862
+ if (needsSignIn(status.accounts)) {
863
+ throw new Error(`AI Studio cannot start: its Google accounts need to sign in again (${status.accounts.auth_required} of ${status.accounts.total}). In Magpie, sign in to AI Studio with "重新登录失效的 AI Studio 账号", or run: ${studioCommand('relogin')}`);
864
+ }
865
+ if (Date.now() >= deadline) {
866
+ throw new Error(`AI Studio generation did not become ready (state ${status.state}, ${status.accounts.ready ?? 0}/${status.accounts.total} accounts ready); see ${path.join(local.dataDir, 'backend.log')}`);
867
+ }
868
+ await sleep(Math.min(250, Math.max(1, deadline - Date.now())));
869
+ const h = await checkStudioHealth(local, fetchFn);
870
+ if (h.state === 'unresponsive') continue;
871
+ if (h.state !== 'up') throw new Error('AI Studio became unavailable while starting');
872
+ status = h.status;
873
+ }
874
+ }
875
+
876
+ // needsSignIn says whether no account can serve until one signs in again: none is ready, busy
877
+ // or cooling down, and some need to sign in.
878
+ function needsSignIn(accounts) {
879
+ const usable = (accounts.ready ?? 0) + (accounts.busy ?? 0) + (accounts.cooldown ?? 0);
880
+ return usable === 0 && (accounts.auth_required ?? 0) > 0;
881
+ }
882
+
883
+ // settle waits out a service that answers but not properly, as one does while it starts, for up
884
+ // to ms.
885
+ async function settle(local, health, fetchFn, ms) {
886
+ const deadline = Date.now() + ms;
887
+ while (health.state === 'unresponsive' && Date.now() < deadline) {
888
+ await sleep(250);
889
+ health = await checkStudioHealth(local, fetchFn);
890
+ }
891
+ return health;
892
+ }
893
+
894
+ function portTaken(local) {
895
+ return new Error(`Port ${local.port} is already used by another service; change "port" in ${path.join(local.dataDir, 'config.json')}`);
896
+ }
897
+
898
+ function notResponding(local, health) {
899
+ return new Error(`The AI Studio service on port ${local.port} is not responding (${health.reason}). If it is stuck, run: ${studioCommand('stop')}; if another program uses the port, change "port" in ${path.join(local.dataDir, 'config.json')}`);
900
+ }
901
+
902
+ // rotateLog sets a log past LOG_ROTATE_BYTES aside as <log>.1, replacing the one before.
903
+ function rotateLog(logPath) {
904
+ try {
905
+ if (fs.statSync(logPath).size > LOG_ROTATE_BYTES) fs.renameSync(logPath, `${logPath}.1`);
906
+ } catch {}
907
+ }
908
+
909
+ // shared runs fn once for key while it is in flight: later callers get the same promise.
910
+ function shared(map, key, fn) {
911
+ if (!map.has(key)) map.set(key, fn().finally(() => map.delete(key)));
912
+ return map.get(key);
913
+ }
914
+
915
+ // ensureStudio makes sure the service is installed and running, and, unless managementOnly,
916
+ // that it takes generation requests. Callers share one start of the service and one wait for
917
+ // it to take requests; a managementOnly caller doesn't wait for the latter.
918
+ export async function ensureStudio(options = {}) {
919
+ const dataDir = options.dataDir || studioDataDir();
920
+ const { local, status } = await shared(inFlightStarts, dataDir, () => launchStudio({ ...options, dataDir }));
921
+ if (options.managementOnly) return local;
922
+ return shared(inFlightReady, dataDir, () => readyStudio(local, status, options));
923
+ }
924
+
925
+ // launchStudio gets the service up, installing it when missing, outdated or untrusted, and
926
+ // returns how to reach it with the status it last reported.
927
+ async function launchStudio(options) {
928
+ const { dataDir } = options;
929
+ const platform = options.platform || process.platform;
930
+ const arch = options.arch || process.arch;
931
+ const fetchFn = options.fetchFn || globalThis.fetch;
932
+ const spawnFn = options.spawnFn || spawn;
933
+ const startTimeoutMs = options.startTimeoutMs ?? 60000;
934
+
935
+ let local = localStudio({ dataDir, platform });
936
+ if (local) {
937
+ const health = await checkStudioHealth(local, fetchFn);
938
+ if (health.state === 'up') return { local, status: health.status };
939
+ if (health.state === 'foreign') throw portTaken(local);
940
+ }
941
+
942
+ fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
943
+ return withLock(dataDir, async () => {
944
+ local = localStudio({ dataDir, platform });
945
+ if (local) {
946
+ const health = await settle(local, await checkStudioHealth(local, fetchFn), fetchFn, options.unresponsiveTimeoutMs ?? 10000);
947
+ if (health.state === 'up') return { local, status: health.status };
948
+ if (health.state === 'unresponsive') throw notResponding(local, health);
949
+ if (health.state === 'outdated') await stopOutdated(local, health, { dataDir, platform, fetchFn });
950
+ }
951
+
952
+ // Check config validity before touching binaries
953
+ const configPath = path.join(dataDir, 'config.json');
954
+ if (fs.existsSync(configPath)) {
955
+ validateConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
956
+ }
957
+
958
+ const binPath = binaryPath(dataDir, platform);
959
+ const manifestPath = path.join(dataDir, 'install-manifest.json');
960
+ const pin = PINNED_ARCHIVES[`${platform}-${arch}`];
961
+ let manifestValid = false;
962
+
963
+ if (fs.existsSync(binPath) && fs.existsSync(manifestPath) && pin) {
964
+ try {
965
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
966
+ const currentHash = computeFileHash(binPath);
967
+ if (
968
+ manifest.version === release.version &&
969
+ manifest.platform === platform &&
970
+ manifest.arch === arch &&
971
+ manifest.archiveSha256 === pin.sha256
972
+ ) {
973
+ if (currentHash === manifest.binarySha256) {
974
+ manifestValid = true;
975
+ } else {
976
+ throw new Error('Binary checksum verification failed (mutated binary refused on launch)');
977
+ }
978
+ }
979
+ } catch (err) {
980
+ if (err.message.includes('mutated binary')) throw err;
981
+ }
982
+ }
983
+
984
+ if (!manifestValid) {
985
+ await installStudio({ dataDir, platform, arch, fetchFn, archivePath: options.archivePath, proxy: options.proxy, preferredPort: options.preferredPort });
986
+ }
987
+
988
+ const config = ensureStudioConfig(dataDir, options.proxy, { port: await newConfigPort(dataDir, options) });
989
+ local = localStudio({ dataDir, platform });
990
+ if (!local) throw new Error(`Invalid AI Studio configuration in ${dataDir}`);
991
+
992
+ const health = await checkStudioHealth(local, fetchFn);
993
+ if (health.state === 'up') return { local, status: health.status };
994
+ if (health.state === 'foreign') throw portTaken(local);
995
+ if (health.state !== 'down') throw notResponding(local, health);
996
+
997
+ // Clean environment: no arbitrary host AISTUDIO_* or WAA_* vars
998
+ const cleanEnv = {};
999
+ for (const [k, v] of Object.entries(process.env)) {
1000
+ if (!k.startsWith('AISTUDIO_') && !k.startsWith('WAA_')) {
1001
+ cleanEnv[k] = v;
1002
+ }
1003
+ }
1004
+ Object.assign(cleanEnv, serviceEnv(dataDir, config, { proxy: options.proxy }));
1005
+
1006
+ const logPath = path.join(dataDir, 'backend.log');
1007
+ rotateLog(logPath);
1008
+ const logFd = fs.openSync(logPath, 'a', 0o600);
1009
+ try {
1010
+ const child = spawnFn(binPath, ['-open-ui=false'], {
1011
+ cwd: dataDir,
1012
+ env: cleanEnv,
1013
+ detached: true,
1014
+ stdio: ['ignore', logFd, logFd],
1015
+ windowsHide: true
1016
+ });
1017
+ child.on?.('error', () => {});
1018
+ if (child.pid) {
1019
+ fs.writeFileSync(path.join(dataDir, 'backend.pid'), `${child.pid}\n`, { mode: 0o600 });
1020
+ }
1021
+ child.unref?.();
1022
+ } finally {
1023
+ fs.closeSync(logFd);
1024
+ }
1025
+
1026
+ // The service can answer with errors while it starts: only 'up' ends the wait.
1027
+ const deadline = Date.now() + startTimeoutMs;
1028
+ while (Date.now() < deadline) {
1029
+ const h = await checkStudioHealth(local, fetchFn);
1030
+ if (h.state === 'up') return { local, status: h.status };
1031
+ if (h.state === 'foreign') throw portTaken(local);
1032
+ if (h.state === 'outdated') {
1033
+ throw new Error(`AIStudio2API on port ${local.port} reports version ${h.status.version}, expected ${STUDIO_VERSION}`);
1034
+ }
1035
+ await sleep(250);
1036
+ }
1037
+ throw new Error(`AI Studio service did not start within ${Math.round(startTimeoutMs / 1000)}s; see ${logPath}`);
1038
+ });
1039
+ }
1040
+
1041
+ // stopOutdated stops an earlier install of the service that is still running, so the pinned
1042
+ // version can be installed and started in its place.
1043
+ async function stopOutdated(local, health, options) {
1044
+ const result = await stopStudio(options).catch(err => ({ stopped: false, reported: err.message }));
1045
+ const after = await checkStudioHealth(local, options.fetchFn);
1046
+ if (result.stopped && after.state === 'down') return;
1047
+ const why = result.stopped ? `it still answers on port ${local.port}` : result.reported;
1048
+ throw new Error(`AIStudio2API ${health.status.version} from an earlier install is still running and could not be stopped (${why}). End that process, then try again.`);
1049
+ }
1050
+
1051
+ export async function stopStudio(options = {}) {
1052
+ const dataDir = options.dataDir || studioDataDir();
1053
+ const platform = options.platform || process.platform;
1054
+ const fetchFn = options.fetchFn || globalThis.fetch;
1055
+ const pidFile = path.join(dataDir, 'backend.pid');
1056
+
1057
+ const configPath = path.join(dataDir, 'config.json');
1058
+ if (!fs.existsSync(configPath)) {
1059
+ throw new Error('Cannot stop: missing configuration in data directory');
1060
+ }
1061
+ let local;
1062
+ try {
1063
+ local = localStudio({ dataDir, platform });
1064
+ if (!local) throw new Error('invalid config');
1065
+ } catch {
1066
+ throw new Error('Cannot stop: invalid configuration in data directory');
1067
+ }
1068
+
1069
+ let health = await checkStudioHealth(local, fetchFn);
1070
+ if (health.state === 'foreign') {
1071
+ throw new Error(`Cannot stop unknown or foreign process listening on port ${local.port}`);
1072
+ }
1073
+
1074
+ let pid = NaN;
1075
+ try {
1076
+ pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
1077
+ } catch {}
1078
+
1079
+ let alive = Number.isInteger(pid) && pid > 0;
1080
+ if (alive) {
1081
+ try {
1082
+ process.kill(pid, 0);
1083
+ } catch {
1084
+ alive = false;
1085
+ }
1086
+ }
1087
+
1088
+ // Without a live process in backend.pid there is nothing this setup can signal. That only
1089
+ // means stopped when nothing answers on the port either.
1090
+ if (!alive) {
1091
+ fs.rmSync(pidFile, { force: true });
1092
+ if (health.state === 'down') return { stopped: true };
1093
+ return { stopped: false, reported: `AIStudio2API still answers on port ${local.port}, but ${pidFile} names no running process; end it yourself` };
1094
+ }
1095
+
1096
+ const bin = binaryPath(dataDir, platform);
1097
+ if (!verifyProcessIdentity(pid, bin, platform)) {
1098
+ throw new Error(`Process ${pid} does not match expected binary path ${bin}; refusal to signal unverified process`);
1099
+ }
1100
+
1101
+ health = await checkStudioHealth(local, fetchFn);
1102
+ if (health.state === 'foreign') {
1103
+ throw new Error(`Cannot stop unknown or foreign process listening on port ${local.port}`);
1104
+ }
1105
+
1106
+ if (health.state === 'up' || health.state === 'outdated') {
1107
+ try {
1108
+ await studioAdmin(local, 'POST', '/api/control/stop', null, { fetchFn, timeoutMs: 5000 });
1109
+ } catch {}
1110
+ }
1111
+
1112
+ if (!verifyProcessIdentity(pid, bin, platform)) {
1113
+ throw new Error(`Process identity changed before signal for PID ${pid}`);
1114
+ }
1115
+
1116
+ try {
1117
+ process.kill(pid, 'SIGTERM');
1118
+ } catch {}
1119
+
1120
+ const deadline = Date.now() + 5000;
1121
+ while (Date.now() < deadline) {
1122
+ try {
1123
+ process.kill(pid, 0);
1124
+ await sleep(150);
1125
+ } catch {
1126
+ alive = false;
1127
+ break;
1128
+ }
1129
+ }
1130
+
1131
+ if (alive) {
1132
+ return { stopped: false, reported: `Process ${pid} did not exit after SIGTERM` };
1133
+ }
1134
+
1135
+ fs.rmSync(pidFile, { force: true });
1136
+ return { stopped: true };
1137
+ }