mioku-service-webui 2.0.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.
package/system.ts ADDED
@@ -0,0 +1,1894 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import process from "node:process";
5
+ import { connectedBots, systemInfo } from "mioki";
6
+ import type {
7
+ InstallRequest,
8
+ ManagedTarget,
9
+ PackageManager,
10
+ RemoveRequest,
11
+ UpdateRequest,
12
+ WebUISettings,
13
+ } from "./types";
14
+ import {
15
+ CHAT_CONFIG_DIR,
16
+ defaultWebUISettings,
17
+ ensureDir,
18
+ getInstallCommand,
19
+ isValidRepoUrl,
20
+ LOCAL_CONFIG_PATH,
21
+ normalizeManagedPackageName,
22
+ normalizePackageManager,
23
+ PLUGINS_DIR,
24
+ readJsonFile,
25
+ ROOT_PACKAGE_PATH,
26
+ runCommand,
27
+ SERVICES_DIR,
28
+ SETTINGS_PATH,
29
+ WEBUI_DIST,
30
+ writeJsonFile,
31
+ } from "./utils";
32
+
33
+ interface NapcatNodeConfig {
34
+ name?: string;
35
+ host?: string;
36
+ port?: number;
37
+ token?: string;
38
+ protocol?: string;
39
+ }
40
+
41
+ interface MiokiRuntimeConfig {
42
+ mioki?: {
43
+ napcat?: NapcatNodeConfig[];
44
+ [key: string]: any;
45
+ };
46
+ [key: string]: any;
47
+ }
48
+
49
+ const SYSTEM_PLUGIN_NAMES = new Set(["boot", "chat", "help"]);
50
+ const SYSTEM_SERVICE_NAMES = new Set(["ai", "config", "help", "screenshot"]);
51
+ const BOOT_CONFIG_PATH = path.join(process.cwd(), "config", "boot", "base.json");
52
+
53
+ interface AccessRuleConfig {
54
+ whitelist: Array<string | number>;
55
+ blacklist: Array<string | number>;
56
+ }
57
+
58
+ export interface BootSystemConfig {
59
+ likeCommand: {
60
+ enabled: boolean;
61
+ keyword: string;
62
+ likeTimes: number;
63
+ reactionEmojiId: number;
64
+ };
65
+ friend: {
66
+ autoApprove: boolean;
67
+ };
68
+ group: {
69
+ minMemberCount: number;
70
+ welcome: {
71
+ enabled: boolean;
72
+ mode: "ai" | "text";
73
+ text: string;
74
+ aiPrompt: string;
75
+ };
76
+ };
77
+ messageFilter: {
78
+ user: AccessRuleConfig;
79
+ group: AccessRuleConfig;
80
+ };
81
+ }
82
+
83
+ const DEFAULT_BOOT_SYSTEM_CONFIG: BootSystemConfig = {
84
+ likeCommand: {
85
+ enabled: true,
86
+ keyword: "赞我",
87
+ likeTimes: 10,
88
+ reactionEmojiId: 66,
89
+ },
90
+ friend: {
91
+ autoApprove: false,
92
+ },
93
+ group: {
94
+ minMemberCount: 0,
95
+ welcome: {
96
+ enabled: true,
97
+ mode: "ai",
98
+ text: "欢迎 {user} 加入 {group}",
99
+ aiPrompt: "",
100
+ },
101
+ },
102
+ messageFilter: {
103
+ user: {
104
+ whitelist: [],
105
+ blacklist: [],
106
+ },
107
+ group: {
108
+ whitelist: [],
109
+ blacklist: [],
110
+ },
111
+ },
112
+ };
113
+
114
+ function isContainerRuntime(): boolean {
115
+ return fs.existsSync("/.dockerenv");
116
+ }
117
+
118
+ function isSystemPluginName(name: string): boolean {
119
+ return SYSTEM_PLUGIN_NAMES.has(
120
+ String(name || "")
121
+ .trim()
122
+ .toLowerCase(),
123
+ );
124
+ }
125
+
126
+ function isSystemServiceName(name: string): boolean {
127
+ return SYSTEM_SERVICE_NAMES.has(
128
+ String(name || "")
129
+ .trim()
130
+ .toLowerCase(),
131
+ );
132
+ }
133
+
134
+ function getTargetRoot(target: ManagedTarget): string {
135
+ return target === "plugin" ? PLUGINS_DIR : SERVICES_DIR;
136
+ }
137
+
138
+ async function getCurrentBranchName(dir: string): Promise<string> {
139
+ const branchRes = await runCommand("git", ["branch", "--show-current"], dir);
140
+ if (branchRes.code !== 0) {
141
+ return "unknown";
142
+ }
143
+ return String(branchRes.stdout || "").trim() || "unknown";
144
+ }
145
+
146
+ async function getDefaultRemoteBranch(dir: string): Promise<string> {
147
+ const headRes = await runCommand(
148
+ "git",
149
+ ["symbolic-ref", "refs/remotes/origin/HEAD"],
150
+ dir,
151
+ );
152
+ if (headRes.code !== 0) {
153
+ return "main";
154
+ }
155
+ const ref = String(headRes.stdout || "").trim();
156
+ return ref.split("/").pop() || "main";
157
+ }
158
+
159
+ async function resolveMiokuTargetRef(
160
+ dir: string,
161
+ ): Promise<{ currentBranch: string; targetRef: string; targetBranch: string }> {
162
+ const currentBranch = await getCurrentBranchName(dir);
163
+ if (currentBranch !== "unknown") {
164
+ return {
165
+ currentBranch,
166
+ targetRef: `origin/${currentBranch}`,
167
+ targetBranch: currentBranch,
168
+ };
169
+ }
170
+
171
+ const targetBranch = await getDefaultRemoteBranch(dir);
172
+ return {
173
+ currentBranch,
174
+ targetRef: `origin/${targetBranch}`,
175
+ targetBranch,
176
+ };
177
+ }
178
+
179
+ function deepMerge<T extends Record<string, any>>(
180
+ target: T,
181
+ source: Partial<T>,
182
+ ): T {
183
+ const result = { ...target };
184
+ for (const key in source) {
185
+ const sourceValue = source[key];
186
+ const targetValue = result[key];
187
+ if (
188
+ sourceValue &&
189
+ typeof sourceValue === "object" &&
190
+ !Array.isArray(sourceValue) &&
191
+ targetValue &&
192
+ typeof targetValue === "object" &&
193
+ !Array.isArray(targetValue)
194
+ ) {
195
+ result[key] = deepMerge(targetValue, sourceValue);
196
+ } else if (sourceValue !== undefined) {
197
+ result[key] = sourceValue as any;
198
+ }
199
+ }
200
+ return result;
201
+ }
202
+
203
+ function cloneJson<T>(value: T): T {
204
+ return JSON.parse(JSON.stringify(value)) as T;
205
+ }
206
+
207
+ function normalizeAccessRuleConfig(input: any): AccessRuleConfig {
208
+ return {
209
+ whitelist: Array.isArray(input?.whitelist) ? input.whitelist : [],
210
+ blacklist: Array.isArray(input?.blacklist) ? input.blacklist : [],
211
+ };
212
+ }
213
+
214
+ function normalizeBootSystemConfig(input: any): BootSystemConfig {
215
+ const merged = deepMerge(cloneJson(DEFAULT_BOOT_SYSTEM_CONFIG), input || {});
216
+ const legacyPrivateFilter = input?.messageFilter?.private;
217
+ const userFilterSource = input?.messageFilter?.user ?? legacyPrivateFilter;
218
+
219
+ return {
220
+ likeCommand: {
221
+ enabled: Boolean(merged?.likeCommand?.enabled),
222
+ keyword:
223
+ typeof merged?.likeCommand?.keyword === "string" &&
224
+ merged.likeCommand.keyword.trim()
225
+ ? merged.likeCommand.keyword.trim()
226
+ : DEFAULT_BOOT_SYSTEM_CONFIG.likeCommand.keyword,
227
+ likeTimes:
228
+ typeof merged?.likeCommand?.likeTimes === "number" &&
229
+ Number.isFinite(merged.likeCommand.likeTimes)
230
+ ? merged.likeCommand.likeTimes
231
+ : DEFAULT_BOOT_SYSTEM_CONFIG.likeCommand.likeTimes,
232
+ reactionEmojiId:
233
+ typeof merged?.likeCommand?.reactionEmojiId === "number" &&
234
+ Number.isFinite(merged.likeCommand.reactionEmojiId)
235
+ ? merged.likeCommand.reactionEmojiId
236
+ : DEFAULT_BOOT_SYSTEM_CONFIG.likeCommand.reactionEmojiId,
237
+ },
238
+ friend: {
239
+ autoApprove: Boolean(merged?.friend?.autoApprove),
240
+ },
241
+ group: {
242
+ minMemberCount:
243
+ typeof merged?.group?.minMemberCount === "number" &&
244
+ Number.isFinite(merged.group.minMemberCount)
245
+ ? merged.group.minMemberCount
246
+ : DEFAULT_BOOT_SYSTEM_CONFIG.group.minMemberCount,
247
+ welcome: {
248
+ enabled: Boolean(merged?.group?.welcome?.enabled),
249
+ mode:
250
+ merged?.group?.welcome?.mode === "text" ? "text" : "ai",
251
+ text:
252
+ typeof merged?.group?.welcome?.text === "string"
253
+ ? merged.group.welcome.text
254
+ : DEFAULT_BOOT_SYSTEM_CONFIG.group.welcome.text,
255
+ aiPrompt:
256
+ typeof merged?.group?.welcome?.aiPrompt === "string"
257
+ ? merged.group.welcome.aiPrompt
258
+ : DEFAULT_BOOT_SYSTEM_CONFIG.group.welcome.aiPrompt,
259
+ },
260
+ },
261
+ messageFilter: {
262
+ user: normalizeAccessRuleConfig(userFilterSource),
263
+ group: normalizeAccessRuleConfig(merged?.messageFilter?.group),
264
+ },
265
+ };
266
+ }
267
+
268
+ function readPackageJson(dir: string): any {
269
+ const packagePath = path.join(dir, "package.json");
270
+ if (!fs.existsSync(packagePath)) {
271
+ return null;
272
+ }
273
+ try {
274
+ return JSON.parse(fs.readFileSync(packagePath, "utf-8"));
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+
280
+ function readRootPackageJson(): any {
281
+ return JSON.parse(fs.readFileSync(ROOT_PACKAGE_PATH, "utf-8"));
282
+ }
283
+
284
+ function writeRootPackageJson(data: any): void {
285
+ fs.writeFileSync(ROOT_PACKAGE_PATH, JSON.stringify(data, null, 2), "utf-8");
286
+ }
287
+
288
+ function packageManagerFromSettings(input?: PackageManager): PackageManager {
289
+ if (input) {
290
+ return normalizePackageManager(input);
291
+ }
292
+ const settings = getWebUISettings();
293
+ return normalizePackageManager(settings.packageManager);
294
+ }
295
+
296
+ export function getWebUISettings(): WebUISettings {
297
+ ensureDir(path.dirname(SETTINGS_PATH));
298
+ const settings = readJsonFile<Record<string, unknown>>(
299
+ SETTINGS_PATH,
300
+ defaultWebUISettings as unknown as Record<string, unknown>,
301
+ );
302
+ const merged: WebUISettings = {
303
+ port:
304
+ typeof settings.port === "number" && Number.isFinite(settings.port)
305
+ ? settings.port
306
+ : defaultWebUISettings.port,
307
+ host:
308
+ typeof settings.host === "string" && settings.host.trim()
309
+ ? settings.host
310
+ : defaultWebUISettings.host,
311
+ packageManager: normalizePackageManager(
312
+ typeof settings.packageManager === "string"
313
+ ? settings.packageManager
314
+ : undefined,
315
+ ),
316
+ };
317
+ writeJsonFile(SETTINGS_PATH, merged);
318
+ return merged;
319
+ }
320
+
321
+ export function updateWebUISettings(
322
+ input: Partial<WebUISettings>,
323
+ ): WebUISettings {
324
+ const current = getWebUISettings();
325
+ const next: WebUISettings = {
326
+ port:
327
+ typeof input.port === "number" && Number.isFinite(input.port)
328
+ ? input.port
329
+ : current.port,
330
+ host:
331
+ typeof input.host === "string" && input.host.trim()
332
+ ? input.host
333
+ : current.host,
334
+ packageManager: normalizePackageManager(
335
+ input.packageManager ?? current.packageManager,
336
+ ),
337
+ };
338
+ writeJsonFile(SETTINGS_PATH, next);
339
+ return next;
340
+ }
341
+
342
+ export function getBootSystemConfig(): BootSystemConfig {
343
+ ensureDir(path.dirname(BOOT_CONFIG_PATH));
344
+ const raw = readJsonFile<any>(BOOT_CONFIG_PATH, DEFAULT_BOOT_SYSTEM_CONFIG);
345
+ const normalized = normalizeBootSystemConfig(raw);
346
+ writeJsonFile(BOOT_CONFIG_PATH, normalized);
347
+ return normalized;
348
+ }
349
+
350
+ export function updateBootSystemConfig(
351
+ input: Partial<BootSystemConfig>,
352
+ ): BootSystemConfig {
353
+ const current = getBootSystemConfig();
354
+ const next = normalizeBootSystemConfig(deepMerge(current, input || {}));
355
+ writeJsonFile(BOOT_CONFIG_PATH, next);
356
+ return next;
357
+ }
358
+
359
+ function checkDependentServices(packageJson: any): string[] {
360
+ const services = packageJson?.mioku?.services;
361
+ if (!Array.isArray(services)) {
362
+ return [];
363
+ }
364
+ return services.filter((serviceName: string) => {
365
+ const servicePath = path.join(SERVICES_DIR, serviceName);
366
+ return !fs.existsSync(servicePath);
367
+ });
368
+ }
369
+
370
+ function assertSafePackageName(name: string): string {
371
+ const trimmed = String(name || "").trim();
372
+ if (!trimmed) {
373
+ throw new Error("名称不能为空");
374
+ }
375
+ if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) {
376
+ throw new Error("名称格式非法");
377
+ }
378
+ return trimmed;
379
+ }
380
+
381
+ function resolveManagedDir(target: ManagedTarget, name: string): string {
382
+ const safeName = assertSafePackageName(name);
383
+ const root = path.resolve(getTargetRoot(target));
384
+ const dir = path.resolve(root, safeName);
385
+ if (!dir.startsWith(`${root}${path.sep}`)) {
386
+ throw new Error("非法路径");
387
+ }
388
+ if (!fs.existsSync(dir)) {
389
+ throw new Error("目录不存在");
390
+ }
391
+ return dir;
392
+ }
393
+
394
+ function getRepositoryFromPackage(pkg: any): string {
395
+ const repository = pkg?.repository;
396
+ if (!repository) return "";
397
+ if (typeof repository === "string") return repository;
398
+ if (typeof repository?.url === "string") return repository.url;
399
+ return "";
400
+ }
401
+
402
+ async function getGitOriginUrl(dir: string): Promise<string> {
403
+ const res = await runCommand("git", ["remote", "get-url", "origin"], dir);
404
+ if (res.code !== 0) return "";
405
+ return res.stdout.trim();
406
+ }
407
+
408
+ function resolveWebUIProjectDir(): string {
409
+ const candidates = [
410
+ path.join(process.cwd(), "mioku-webui"),
411
+ path.join(process.cwd(), "webui"),
412
+ ];
413
+
414
+ for (const dir of candidates) {
415
+ if (fs.existsSync(path.join(dir, "package.json"))) {
416
+ return dir;
417
+ }
418
+ }
419
+ return candidates[0];
420
+ }
421
+
422
+ function readInstalledWebUIVersion(): string {
423
+ const versionFileCandidates = [
424
+ path.join(WEBUI_DIST, "webui-version.json"),
425
+ path.join(WEBUI_DIST, ".webui-version"),
426
+ ];
427
+
428
+ for (const versionFile of versionFileCandidates) {
429
+ if (!fs.existsSync(versionFile)) continue;
430
+ try {
431
+ const raw = fs.readFileSync(versionFile, "utf-8").trim();
432
+ if (!raw) continue;
433
+ if (versionFile.endsWith(".json")) {
434
+ const parsed = JSON.parse(raw);
435
+ const version = String(parsed?.version || "").trim();
436
+ if (version) return normalizeVersionSpec(version);
437
+ } else {
438
+ return normalizeVersionSpec(raw);
439
+ }
440
+ } catch {
441
+ // ignore invalid version marker
442
+ }
443
+ }
444
+
445
+ return readPackageVersion(
446
+ path.join(resolveWebUIProjectDir(), "package.json"),
447
+ );
448
+ }
449
+
450
+ function parseGitHubRepo(
451
+ input: string,
452
+ ): { owner: string; repo: string; fullName: string } | null {
453
+ const url = String(input || "").trim();
454
+ if (!url) return null;
455
+
456
+ const patterns = [
457
+ /^https?:\/\/github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?(?:[/?#].*)?$/i,
458
+ /^git@github\.com:([^/]+)\/([^/#?]+?)(?:\.git)?$/i,
459
+ /^ssh:\/\/git@github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?$/i,
460
+ ];
461
+
462
+ for (const pattern of patterns) {
463
+ const matched = url.match(pattern);
464
+ if (!matched) continue;
465
+ const owner = matched[1];
466
+ const repo = matched[2];
467
+ if (!owner || !repo) continue;
468
+ return { owner, repo, fullName: `${owner}/${repo}` };
469
+ }
470
+
471
+ return null;
472
+ }
473
+
474
+ function parseVersionParts(input: string): number[] {
475
+ const normalized = normalizeVersionSpec(input).replace(/^v/i, "");
476
+ const core = normalized.split("-")[0].split("+")[0];
477
+ if (!core) return [];
478
+ return core
479
+ .split(".")
480
+ .map((part) => Number(part))
481
+ .filter((part) => Number.isFinite(part) && part >= 0);
482
+ }
483
+
484
+ function isVersionNewer(latest: string, current: string): boolean {
485
+ const latestParts = parseVersionParts(latest);
486
+ const currentParts = parseVersionParts(current);
487
+ const maxLen = Math.max(latestParts.length, currentParts.length, 3);
488
+
489
+ for (let i = 0; i < maxLen; i += 1) {
490
+ const a = latestParts[i] ?? 0;
491
+ const b = currentParts[i] ?? 0;
492
+ if (a > b) return true;
493
+ if (a < b) return false;
494
+ }
495
+
496
+ const latestNormalized = normalizeVersionSpec(latest).replace(/^v/i, "");
497
+ const currentNormalized = normalizeVersionSpec(current).replace(/^v/i, "");
498
+ return latestNormalized !== currentNormalized;
499
+ }
500
+
501
+ function hasUsableDistFiles(dir: string): boolean {
502
+ return fs.existsSync(path.join(dir, "index.html"));
503
+ }
504
+
505
+ function resolveDistSourceDir(unpackDir: string): string | null {
506
+ const directCandidates = [path.join(unpackDir, "dist"), unpackDir];
507
+ for (const candidate of directCandidates) {
508
+ if (hasUsableDistFiles(candidate)) {
509
+ return candidate;
510
+ }
511
+ }
512
+
513
+ const children = fs.existsSync(unpackDir)
514
+ ? fs.readdirSync(unpackDir, { withFileTypes: true })
515
+ : [];
516
+ for (const child of children) {
517
+ if (!child.isDirectory()) continue;
518
+ const childDir = path.join(unpackDir, child.name);
519
+ const nestedDist = path.join(childDir, "dist");
520
+ if (hasUsableDistFiles(nestedDist)) {
521
+ return nestedDist;
522
+ }
523
+ if (hasUsableDistFiles(childDir)) {
524
+ return childDir;
525
+ }
526
+ }
527
+
528
+ return null;
529
+ }
530
+
531
+ function readReadmeFile(
532
+ dir: string,
533
+ ): { fileName: string; content: string } | null {
534
+ const candidates = [
535
+ "README.md",
536
+ "README.MD",
537
+ "readme.md",
538
+ "README.txt",
539
+ "README",
540
+ "readme",
541
+ ];
542
+
543
+ for (const fileName of candidates) {
544
+ const filePath = path.join(dir, fileName);
545
+ if (!fs.existsSync(filePath)) continue;
546
+ try {
547
+ const content = fs.readFileSync(filePath, "utf-8");
548
+ return { fileName, content };
549
+ } catch {
550
+ return null;
551
+ }
552
+ }
553
+ return null;
554
+ }
555
+
556
+ interface ManagedPackageUpdateInfo {
557
+ state: "up-to-date" | "has-updates" | "unknown" | "no-git";
558
+ hasUpdates: boolean;
559
+ behind: number;
560
+ changelog: string[];
561
+ error?: string;
562
+ }
563
+
564
+ interface ManagedPackageUpdateCacheEntry {
565
+ checkedAt: number;
566
+ info: ManagedPackageUpdateInfo;
567
+ }
568
+
569
+ interface WebUIReleaseAsset {
570
+ name: string;
571
+ browser_download_url: string;
572
+ }
573
+
574
+ interface WebUIUpdateCheckResult {
575
+ currentVersion: string;
576
+ latestVersion: string;
577
+ releaseTag: string;
578
+ releaseUrl: string;
579
+ sourceRepo: string;
580
+ hasUpdates: boolean;
581
+ canUpdate: boolean;
582
+ assetName: string;
583
+ assetUrl: string;
584
+ checkedAt: number;
585
+ error?: string;
586
+ }
587
+
588
+ interface MiokuUpdateCheckResult {
589
+ currentVersion: string;
590
+ latestVersion: string;
591
+ sourceRepo: string;
592
+ currentBranch: string;
593
+ targetRef: string;
594
+ hasUpdates: boolean;
595
+ behind: number;
596
+ changelog: string[];
597
+ checkedAt: number;
598
+ error?: string;
599
+ }
600
+
601
+ const MANAGED_UPDATE_CACHE_TTL_MS = 120_000;
602
+ const managedPackageUpdateCache = new Map<
603
+ string,
604
+ ManagedPackageUpdateCacheEntry
605
+ >();
606
+ const managedOverviewRefreshInFlight = new Map<ManagedTarget, Promise<void>>();
607
+ const WEBUI_UPDATE_CACHE_TTL_MS = 60_000;
608
+ let webuiUpdateCache: WebUIUpdateCheckResult | null = null;
609
+ let webuiUpdateCheckInFlight: Promise<WebUIUpdateCheckResult> | null = null;
610
+ let webuiUpdatingInFlight: Promise<Record<string, any>> | null = null;
611
+ const MIOKU_UPDATE_CACHE_TTL_MS = 60_000;
612
+ let miokuUpdateCache: MiokuUpdateCheckResult | null = null;
613
+ let miokuUpdateCheckInFlight: Promise<MiokuUpdateCheckResult> | null = null;
614
+
615
+ function makeManagedUpdateCacheKey(
616
+ target: ManagedTarget,
617
+ name: string,
618
+ ): string {
619
+ return `${target}:${name}`;
620
+ }
621
+
622
+ function getCachedManagedUpdateInfo(
623
+ target: ManagedTarget,
624
+ name: string,
625
+ ): ManagedPackageUpdateCacheEntry | null {
626
+ return (
627
+ managedPackageUpdateCache.get(makeManagedUpdateCacheKey(target, name)) ||
628
+ null
629
+ );
630
+ }
631
+
632
+ function setCachedManagedUpdateInfo(
633
+ target: ManagedTarget,
634
+ name: string,
635
+ info: ManagedPackageUpdateInfo,
636
+ ): void {
637
+ managedPackageUpdateCache.set(makeManagedUpdateCacheKey(target, name), {
638
+ checkedAt: Date.now(),
639
+ info,
640
+ });
641
+ }
642
+
643
+ function isManagedUpdateCacheFresh(
644
+ entry: ManagedPackageUpdateCacheEntry | null,
645
+ ): boolean {
646
+ if (!entry) return false;
647
+ return Date.now() - entry.checkedAt < MANAGED_UPDATE_CACHE_TTL_MS;
648
+ }
649
+
650
+ async function refreshManagedUpdatesInBackground(
651
+ target: ManagedTarget,
652
+ packages: Array<Record<string, any>>,
653
+ ): Promise<void> {
654
+ for (const item of packages) {
655
+ const name = String(item.name || "");
656
+ if (!name) continue;
657
+
658
+ if (!item.hasGit) {
659
+ setCachedManagedUpdateInfo(target, name, {
660
+ state: "no-git",
661
+ hasUpdates: false,
662
+ behind: 0,
663
+ changelog: [],
664
+ error: "NOT_GIT_REPO",
665
+ });
666
+ continue;
667
+ }
668
+
669
+ try {
670
+ const updateInfo = await getManagedPackageUpdateInfo(
671
+ String(item.path || ""),
672
+ );
673
+ setCachedManagedUpdateInfo(target, name, updateInfo);
674
+ } catch (error: any) {
675
+ setCachedManagedUpdateInfo(target, name, {
676
+ state: "unknown",
677
+ hasUpdates: false,
678
+ behind: 0,
679
+ changelog: [],
680
+ error: error?.message || "UPDATE_CHECK_FAILED",
681
+ });
682
+ }
683
+ }
684
+ }
685
+
686
+ function scheduleManagedUpdatesRefresh(
687
+ target: ManagedTarget,
688
+ packages: Array<Record<string, any>>,
689
+ ): void {
690
+ if (managedOverviewRefreshInFlight.has(target)) return;
691
+
692
+ const shouldRefresh = packages.some((item) => {
693
+ if (!item.hasGit) return false;
694
+ const cached = getCachedManagedUpdateInfo(target, String(item.name || ""));
695
+ return !isManagedUpdateCacheFresh(cached);
696
+ });
697
+
698
+ if (!shouldRefresh) return;
699
+
700
+ const job = refreshManagedUpdatesInBackground(target, packages).finally(
701
+ () => {
702
+ managedOverviewRefreshInFlight.delete(target);
703
+ },
704
+ );
705
+ managedOverviewRefreshInFlight.set(target, job);
706
+ }
707
+
708
+ async function getManagedPackageUpdateInfo(
709
+ dir: string,
710
+ ): Promise<ManagedPackageUpdateInfo> {
711
+ if (!fs.existsSync(path.join(dir, ".git"))) {
712
+ return {
713
+ state: "no-git",
714
+ hasUpdates: false,
715
+ behind: 0,
716
+ changelog: [],
717
+ error: "NOT_GIT_REPO",
718
+ };
719
+ }
720
+
721
+ const fetchRes = await runCommand("git", ["fetch", "--all"], dir);
722
+ if (fetchRes.code !== 0) {
723
+ return {
724
+ state: "unknown",
725
+ hasUpdates: false,
726
+ behind: 0,
727
+ changelog: [],
728
+ error: `git fetch 失败: ${fetchRes.stderr || fetchRes.stdout}`.trim(),
729
+ };
730
+ }
731
+
732
+ const compare = await runCommand(
733
+ "git",
734
+ ["rev-list", "--left-right", "--count", "HEAD...@{u}"],
735
+ dir,
736
+ );
737
+ if (compare.code !== 0) {
738
+ return {
739
+ state: "unknown",
740
+ hasUpdates: false,
741
+ behind: 0,
742
+ changelog: [],
743
+ error: `无法比较更新: ${compare.stderr || compare.stdout}`.trim(),
744
+ };
745
+ }
746
+
747
+ const parts = compare.stdout
748
+ .trim()
749
+ .split(/\s+/)
750
+ .map((item) => Number(item));
751
+ const behind = Number.isFinite(parts[1]) ? parts[1] : 0;
752
+
753
+ const changelog = await runCommand(
754
+ "git",
755
+ ["log", "--oneline", "HEAD..@{u}", "-n", "30"],
756
+ dir,
757
+ );
758
+
759
+ return {
760
+ state: behind > 0 ? "has-updates" : "up-to-date",
761
+ hasUpdates: behind > 0,
762
+ behind,
763
+ changelog: changelog.stdout.trim().split("\n").filter(Boolean),
764
+ };
765
+ }
766
+
767
+ export function listManagedPackages(
768
+ target: ManagedTarget,
769
+ ): Array<Record<string, any>> {
770
+ const root = getTargetRoot(target);
771
+ ensureDir(root);
772
+ const names = fs
773
+ .readdirSync(root, { withFileTypes: true })
774
+ .filter((e) => e.isDirectory())
775
+ .map((e) => e.name);
776
+
777
+ return names.map((name) => {
778
+ const fullPath = path.join(root, name);
779
+ const pkg = readPackageJson(fullPath);
780
+ return {
781
+ name,
782
+ path: fullPath,
783
+ version: pkg?.version ?? "0.0.0",
784
+ description: pkg?.description ?? "",
785
+ hasGit: fs.existsSync(path.join(fullPath, ".git")),
786
+ isSystemPlugin: target === "plugin" ? isSystemPluginName(name) : false,
787
+ isSystemService: target === "service" ? isSystemServiceName(name) : false,
788
+ repository: getRepositoryFromPackage(pkg),
789
+ requiredServices: pkg?.mioku?.services ?? [],
790
+ };
791
+ });
792
+ }
793
+
794
+ export async function installManagedPackage(
795
+ input: InstallRequest,
796
+ ): Promise<Record<string, any>> {
797
+ if (!isValidRepoUrl(input.repoUrl)) {
798
+ throw new Error("仓库地址无效");
799
+ }
800
+
801
+ const targetRoot = getTargetRoot(input.target);
802
+ ensureDir(targetRoot);
803
+
804
+ const packageName = normalizeManagedPackageName(input.repoUrl, input.target);
805
+ const destination = path.join(targetRoot, packageName);
806
+
807
+ if (fs.existsSync(destination)) {
808
+ throw new Error(`${packageName} 已存在`);
809
+ }
810
+
811
+ const clone = await runCommand(
812
+ "git",
813
+ ["clone", input.repoUrl, destination],
814
+ process.cwd(),
815
+ );
816
+ if (clone.code !== 0) {
817
+ throw new Error(`git clone 失败: ${clone.stderr || clone.stdout}`);
818
+ }
819
+
820
+ const packageJson = readPackageJson(destination);
821
+ const missingServices = checkDependentServices(packageJson);
822
+
823
+ const packageManager = packageManagerFromSettings(input.packageManager);
824
+ const installCmd = getInstallCommand(packageManager);
825
+ const install = await runCommand(
826
+ installCmd.cmd,
827
+ installCmd.args,
828
+ destination,
829
+ );
830
+
831
+ if (install.code !== 0) {
832
+ throw new Error(`依赖安装失败: ${install.stderr || install.stdout}`);
833
+ }
834
+
835
+ managedPackageUpdateCache.delete(
836
+ makeManagedUpdateCacheKey(input.target, packageName),
837
+ );
838
+
839
+ return {
840
+ ok: true,
841
+ name: packageName,
842
+ missingServices,
843
+ packageManager,
844
+ restartRequired: true,
845
+ installOutput: install.stdout || install.stderr,
846
+ };
847
+ }
848
+
849
+ export async function checkUpdate(
850
+ name: string,
851
+ target: ManagedTarget,
852
+ ): Promise<Record<string, any>> {
853
+ const dir = resolveManagedDir(target, name);
854
+ const result = await getManagedPackageUpdateInfo(dir);
855
+ setCachedManagedUpdateInfo(target, name, result);
856
+ return {
857
+ ok: true,
858
+ state: result.state,
859
+ hasUpdates: result.hasUpdates,
860
+ behind: result.behind,
861
+ changelog: result.changelog,
862
+ hasGit: result.state !== "no-git",
863
+ error: result.error,
864
+ };
865
+ }
866
+
867
+ function packageJsonChanged(before: string, after: string): boolean {
868
+ return before !== after;
869
+ }
870
+
871
+ export async function updateManagedPackage(
872
+ input: UpdateRequest,
873
+ ): Promise<Record<string, any>> {
874
+ const dir = resolveManagedDir(input.target, input.name);
875
+
876
+ const before = await runCommand("git", ["show", "HEAD:package.json"], dir);
877
+
878
+ const pull = await runCommand("git", ["pull"], dir);
879
+ if (pull.code !== 0) {
880
+ throw new Error(`git pull 失败: ${pull.stderr || pull.stdout}`);
881
+ }
882
+
883
+ const after = await runCommand("git", ["show", "HEAD:package.json"], dir);
884
+ const changed = packageJsonChanged(before.stdout, after.stdout);
885
+
886
+ let reinstallOutput = "";
887
+ if (changed) {
888
+ const packageManager = packageManagerFromSettings(input.packageManager);
889
+ const installCmd = getInstallCommand(packageManager);
890
+ const install = await runCommand(installCmd.cmd, installCmd.args, dir);
891
+ if (install.code !== 0) {
892
+ throw new Error(`依赖安装失败: ${install.stderr || install.stdout}`);
893
+ }
894
+ reinstallOutput = install.stdout || install.stderr;
895
+ }
896
+
897
+ managedPackageUpdateCache.delete(
898
+ makeManagedUpdateCacheKey(input.target, input.name),
899
+ );
900
+
901
+ return {
902
+ ok: true,
903
+ restartRequired: true,
904
+ packageJsonChanged: changed,
905
+ reinstallOutput,
906
+ };
907
+ }
908
+
909
+ export async function removeManagedPackage(
910
+ input: RemoveRequest,
911
+ ): Promise<Record<string, any>> {
912
+ if (input.target === "plugin" && isSystemPluginName(input.name)) {
913
+ throw new Error("系统插件不可卸载");
914
+ }
915
+ if (input.target === "service" && isSystemServiceName(input.name)) {
916
+ throw new Error("系统服务不可卸载");
917
+ }
918
+
919
+ const dir = resolveManagedDir(input.target, input.name);
920
+
921
+ fs.rmSync(dir, { recursive: true, force: true });
922
+
923
+ managedPackageUpdateCache.delete(
924
+ makeManagedUpdateCacheKey(input.target, input.name),
925
+ );
926
+
927
+ return {
928
+ ok: true,
929
+ restartRequired: true,
930
+ };
931
+ }
932
+
933
+ export async function listManagedPackagesWithUpdates(
934
+ target: ManagedTarget,
935
+ ): Promise<Array<Record<string, any>>> {
936
+ const packages = listManagedPackages(target);
937
+ scheduleManagedUpdatesRefresh(target, packages);
938
+ const refreshRunning = managedOverviewRefreshInFlight.has(target);
939
+
940
+ return packages.map((item) => {
941
+ const name = String(item.name || "");
942
+
943
+ if (!item.hasGit) {
944
+ return {
945
+ ...item,
946
+ updateState: "no-git",
947
+ hasUpdates: false,
948
+ behind: 0,
949
+ updateError: "NOT_GIT_REPO",
950
+ updateChecking: false,
951
+ };
952
+ }
953
+
954
+ const cached = getCachedManagedUpdateInfo(target, name);
955
+ if (cached) {
956
+ return {
957
+ ...item,
958
+ updateState: cached.info.state,
959
+ hasUpdates: cached.info.hasUpdates,
960
+ behind: cached.info.behind,
961
+ updateError: cached.info.error || "",
962
+ updateChecking: refreshRunning && !isManagedUpdateCacheFresh(cached),
963
+ updateCheckedAt: cached.checkedAt,
964
+ };
965
+ }
966
+
967
+ return {
968
+ ...item,
969
+ updateState: "unknown",
970
+ hasUpdates: false,
971
+ behind: 0,
972
+ updateError: "",
973
+ updateChecking: true,
974
+ updateCheckedAt: 0,
975
+ };
976
+ });
977
+ }
978
+
979
+ export async function getManagedPackageDetail(
980
+ name: string,
981
+ target: ManagedTarget,
982
+ ): Promise<Record<string, any>> {
983
+ const dir = resolveManagedDir(target, name);
984
+ const pkg = readPackageJson(dir) || {};
985
+ const readme = readReadmeFile(dir);
986
+ const originUrl = await getGitOriginUrl(dir);
987
+ const repositoryFromPkg = getRepositoryFromPackage(pkg);
988
+ const updateInfo = await getManagedPackageUpdateInfo(dir);
989
+ setCachedManagedUpdateInfo(target, name, updateInfo);
990
+ const requiredServices = Array.isArray(pkg?.mioku?.services)
991
+ ? pkg.mioku.services
992
+ : [];
993
+ const missingServices = checkDependentServices(pkg);
994
+
995
+ return {
996
+ ok: true,
997
+ data: {
998
+ name,
999
+ target,
1000
+ path: dir,
1001
+ version: pkg?.version || "0.0.0",
1002
+ description: pkg?.description || "",
1003
+ hasGit: fs.existsSync(path.join(dir, ".git")),
1004
+ isSystemPlugin: target === "plugin" ? isSystemPluginName(name) : false,
1005
+ isSystemService: target === "service" ? isSystemServiceName(name) : false,
1006
+ repository: repositoryFromPkg,
1007
+ originUrl,
1008
+ homepage: pkg?.homepage || "",
1009
+ requiredServices,
1010
+ missingServices,
1011
+ help: pkg?.mioku?.help || null,
1012
+ readme: readme?.content || "",
1013
+ readmeFile: readme?.fileName || "",
1014
+ updateState: updateInfo.state,
1015
+ hasUpdates: updateInfo.hasUpdates,
1016
+ behind: updateInfo.behind,
1017
+ changelog: updateInfo.changelog,
1018
+ updateError: updateInfo.error || "",
1019
+ },
1020
+ };
1021
+ }
1022
+
1023
+ export async function changeManagedPackageRepo(
1024
+ name: string,
1025
+ target: ManagedTarget,
1026
+ repoUrl: string,
1027
+ ): Promise<Record<string, any>> {
1028
+ if (!isValidRepoUrl(repoUrl)) {
1029
+ throw new Error("仓库地址无效");
1030
+ }
1031
+
1032
+ const dir = resolveManagedDir(target, name);
1033
+ const nextUrl = repoUrl.trim();
1034
+ const oldUrl = await getGitOriginUrl(dir);
1035
+
1036
+ const setRemote = await runCommand(
1037
+ "git",
1038
+ ["remote", "set-url", "origin", nextUrl],
1039
+ dir,
1040
+ );
1041
+ if (setRemote.code !== 0) {
1042
+ throw new Error(
1043
+ `更新仓库地址失败: ${setRemote.stderr || setRemote.stdout}`,
1044
+ );
1045
+ }
1046
+
1047
+ const packagePath = path.join(dir, "package.json");
1048
+ if (fs.existsSync(packagePath)) {
1049
+ try {
1050
+ const pkg = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
1051
+ if (typeof pkg.repository === "string") {
1052
+ pkg.repository = nextUrl;
1053
+ } else if (pkg.repository && typeof pkg.repository === "object") {
1054
+ pkg.repository = { ...pkg.repository, url: nextUrl };
1055
+ } else {
1056
+ pkg.repository = nextUrl;
1057
+ }
1058
+ fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2), "utf-8");
1059
+ } catch {
1060
+ // ignore package.json update failure, git remote is the source of truth
1061
+ }
1062
+ }
1063
+
1064
+ managedPackageUpdateCache.delete(makeManagedUpdateCacheKey(target, name));
1065
+
1066
+ return {
1067
+ ok: true,
1068
+ oldUrl,
1069
+ newUrl: nextUrl,
1070
+ };
1071
+ }
1072
+
1073
+ export async function updateAllManagedPackages(input: {
1074
+ target: ManagedTarget;
1075
+ packageManager?: PackageManager;
1076
+ }): Promise<Record<string, any>> {
1077
+ const packages = listManagedPackages(input.target);
1078
+ const results: Array<Record<string, any>> = [];
1079
+
1080
+ for (const item of packages) {
1081
+ const updateInfo = await getManagedPackageUpdateInfo(item.path);
1082
+ if (updateInfo.state === "no-git") {
1083
+ results.push({
1084
+ name: item.name,
1085
+ ok: false,
1086
+ skipped: true,
1087
+ reason: "NOT_GIT_REPO",
1088
+ });
1089
+ continue;
1090
+ }
1091
+ if (!updateInfo.hasUpdates) {
1092
+ results.push({
1093
+ name: item.name,
1094
+ ok: true,
1095
+ skipped: true,
1096
+ reason: updateInfo.state === "unknown" ? "CHECK_FAILED" : "UP_TO_DATE",
1097
+ error: updateInfo.error || "",
1098
+ });
1099
+ continue;
1100
+ }
1101
+
1102
+ try {
1103
+ const updated = await updateManagedPackage({
1104
+ name: item.name,
1105
+ target: input.target,
1106
+ packageManager: input.packageManager,
1107
+ });
1108
+ results.push({
1109
+ name: item.name,
1110
+ ok: true,
1111
+ skipped: false,
1112
+ ...updated,
1113
+ });
1114
+ } catch (error: any) {
1115
+ results.push({
1116
+ name: item.name,
1117
+ ok: false,
1118
+ skipped: false,
1119
+ error: error?.message || "UPDATE_FAILED",
1120
+ });
1121
+ }
1122
+ }
1123
+
1124
+ const updatedCount = results.filter(
1125
+ (item) => item.ok && !item.skipped,
1126
+ ).length;
1127
+ const failedCount = results.filter(
1128
+ (item) => !item.ok && !item.skipped,
1129
+ ).length;
1130
+ const skippedCount = results.filter((item) => item.skipped).length;
1131
+
1132
+ return {
1133
+ ok: failedCount === 0,
1134
+ restartRequired: updatedCount > 0,
1135
+ updatedCount,
1136
+ failedCount,
1137
+ skippedCount,
1138
+ results,
1139
+ };
1140
+ }
1141
+
1142
+ function pickWebUIDistAsset(
1143
+ assets: WebUIReleaseAsset[],
1144
+ ): WebUIReleaseAsset | null {
1145
+ const zipAssets = assets.filter((asset) =>
1146
+ /\.zip$/i.test(String(asset?.name || "")),
1147
+ );
1148
+ if (zipAssets.length === 0) return null;
1149
+ const distAsset = zipAssets.find((asset) =>
1150
+ /dist/i.test(String(asset?.name || "")),
1151
+ );
1152
+ return distAsset || zipAssets[0] || null;
1153
+ }
1154
+
1155
+ async function fetchLatestWebUIUpdate(
1156
+ force = false,
1157
+ ): Promise<WebUIUpdateCheckResult> {
1158
+ const now = Date.now();
1159
+ if (
1160
+ !force &&
1161
+ webuiUpdateCache &&
1162
+ now - webuiUpdateCache.checkedAt < WEBUI_UPDATE_CACHE_TTL_MS
1163
+ ) {
1164
+ return webuiUpdateCache;
1165
+ }
1166
+
1167
+ if (!force && webuiUpdateCheckInFlight) {
1168
+ return webuiUpdateCheckInFlight;
1169
+ }
1170
+
1171
+ const job = (async () => {
1172
+ const projectDir = resolveWebUIProjectDir();
1173
+ const currentVersion = readInstalledWebUIVersion();
1174
+ const pkg = readPackageJson(projectDir) || {};
1175
+ const originUrl = (await getGitOriginUrl(projectDir)) || "";
1176
+ const repoUrl = originUrl || getRepositoryFromPackage(pkg);
1177
+ const repo = parseGitHubRepo(repoUrl);
1178
+
1179
+ const fallback: WebUIUpdateCheckResult = {
1180
+ currentVersion,
1181
+ latestVersion: currentVersion,
1182
+ releaseTag: "",
1183
+ releaseUrl: "",
1184
+ sourceRepo: repo?.fullName || "",
1185
+ hasUpdates: false,
1186
+ canUpdate: false,
1187
+ assetName: "",
1188
+ assetUrl: "",
1189
+ checkedAt: Date.now(),
1190
+ };
1191
+
1192
+ if (!repo) {
1193
+ return {
1194
+ ...fallback,
1195
+ error: "WebUI 仓库不是 GitHub,暂不支持自动检查更新",
1196
+ };
1197
+ }
1198
+
1199
+ const res = await fetch(
1200
+ `https://api.github.com/repos/${repo.owner}/${repo.repo}/releases/latest`,
1201
+ {
1202
+ headers: {
1203
+ Accept: "application/vnd.github+json",
1204
+ "User-Agent": "mioku-webui-updater",
1205
+ },
1206
+ },
1207
+ );
1208
+
1209
+ if (!res.ok) {
1210
+ return {
1211
+ ...fallback,
1212
+ error: `查询 GitHub Release 失败: HTTP_${res.status}`,
1213
+ };
1214
+ }
1215
+
1216
+ const release = (await res.json().catch(() => ({}))) as any;
1217
+ const releaseTag = String(release?.tag_name || release?.name || "").trim();
1218
+ const latestVersion = normalizeVersionSpec(releaseTag || "unknown");
1219
+ const assets = Array.isArray(release?.assets)
1220
+ ? (release.assets as WebUIReleaseAsset[])
1221
+ : [];
1222
+ const distAsset = pickWebUIDistAsset(assets);
1223
+ const hasUpdates =
1224
+ latestVersion !== "unknown" &&
1225
+ isVersionNewer(latestVersion, currentVersion);
1226
+
1227
+ return {
1228
+ currentVersion,
1229
+ latestVersion,
1230
+ releaseTag,
1231
+ releaseUrl: String(release?.html_url || ""),
1232
+ sourceRepo: repo.fullName,
1233
+ hasUpdates,
1234
+ canUpdate: hasUpdates && Boolean(distAsset?.browser_download_url),
1235
+ assetName: String(distAsset?.name || ""),
1236
+ assetUrl: String(distAsset?.browser_download_url || ""),
1237
+ checkedAt: Date.now(),
1238
+ error:
1239
+ hasUpdates && !distAsset
1240
+ ? "已检测到新版本,但 Release 没有可用 dist 压缩包"
1241
+ : "",
1242
+ };
1243
+ })();
1244
+
1245
+ webuiUpdateCheckInFlight = job;
1246
+ try {
1247
+ const result = await job;
1248
+ webuiUpdateCache = result;
1249
+ return result;
1250
+ } finally {
1251
+ webuiUpdateCheckInFlight = null;
1252
+ }
1253
+ }
1254
+
1255
+ export async function checkWebUIReleaseUpdate(
1256
+ force = false,
1257
+ ): Promise<Record<string, any>> {
1258
+ return fetchLatestWebUIUpdate(force);
1259
+ }
1260
+
1261
+ async function fetchLatestMiokuUpdate(
1262
+ force = false,
1263
+ ): Promise<MiokuUpdateCheckResult> {
1264
+ const now = Date.now();
1265
+ if (
1266
+ !force &&
1267
+ miokuUpdateCache &&
1268
+ now - miokuUpdateCache.checkedAt < MIOKU_UPDATE_CACHE_TTL_MS
1269
+ ) {
1270
+ return miokuUpdateCache;
1271
+ }
1272
+
1273
+ if (!force && miokuUpdateCheckInFlight) {
1274
+ return miokuUpdateCheckInFlight;
1275
+ }
1276
+
1277
+ const job = (async () => {
1278
+ const cwd = process.cwd();
1279
+ const rootPkg = readRootPackageJson() || {};
1280
+ const currentVersion = normalizeVersionSpec(rootPkg?.version || "unknown");
1281
+ const originUrl = (await getGitOriginUrl(cwd)) || "";
1282
+ const repoUrl = originUrl || getRepositoryFromPackage(rootPkg);
1283
+ const repo = parseGitHubRepo(repoUrl);
1284
+ const { currentBranch, targetRef, targetBranch } =
1285
+ await resolveMiokuTargetRef(cwd);
1286
+
1287
+ const fallback: MiokuUpdateCheckResult = {
1288
+ currentVersion,
1289
+ latestVersion: currentVersion,
1290
+ sourceRepo: repo?.fullName || "",
1291
+ currentBranch,
1292
+ targetRef,
1293
+ hasUpdates: false,
1294
+ behind: 0,
1295
+ changelog: [],
1296
+ checkedAt: Date.now(),
1297
+ };
1298
+
1299
+ if (!fs.existsSync(path.join(cwd, ".git"))) {
1300
+ return {
1301
+ ...fallback,
1302
+ error: isContainerRuntime()
1303
+ ? "当前 Docker 容器未挂载 .git 到 /app/.git,无法检查 Mioku 更新"
1304
+ : "当前目录不是 Git 仓库,无法检查 Mioku 更新",
1305
+ };
1306
+ }
1307
+
1308
+ if (!repo) {
1309
+ return {
1310
+ ...fallback,
1311
+ error: "Mioku 仓库不是 GitHub,暂不支持自动检查更新",
1312
+ };
1313
+ }
1314
+
1315
+ const fetchRes = await runCommand(
1316
+ "git",
1317
+ ["fetch", "origin", targetBranch],
1318
+ cwd,
1319
+ );
1320
+
1321
+ if (fetchRes.code !== 0) {
1322
+ return {
1323
+ ...fallback,
1324
+ error: `git fetch 失败: ${fetchRes.stderr || fetchRes.stdout}`.trim(),
1325
+ };
1326
+ }
1327
+
1328
+ const compare = await runCommand(
1329
+ "git",
1330
+ ["rev-list", "--left-right", "--count", `HEAD...${targetRef}`],
1331
+ cwd,
1332
+ );
1333
+
1334
+ if (compare.code !== 0) {
1335
+ return {
1336
+ ...fallback,
1337
+ error: `无法比较更新: ${compare.stderr || compare.stdout}`.trim(),
1338
+ };
1339
+ }
1340
+
1341
+ const parts = compare.stdout
1342
+ .trim()
1343
+ .split(/\s+/)
1344
+ .map((item) => Number(item));
1345
+ const behind = Number.isFinite(parts[1]) ? parts[1] : 0;
1346
+ const changelogRes = await runCommand(
1347
+ "git",
1348
+ ["log", "--oneline", `HEAD..${targetRef}`, "-n", "30"],
1349
+ cwd,
1350
+ );
1351
+
1352
+ let latestVersion = currentVersion;
1353
+ const remotePkg = await runCommand(
1354
+ "git",
1355
+ ["show", `${targetRef}:package.json`],
1356
+ cwd,
1357
+ );
1358
+ if (remotePkg.code === 0) {
1359
+ try {
1360
+ const parsed = JSON.parse(remotePkg.stdout);
1361
+ latestVersion = normalizeVersionSpec(parsed?.version || currentVersion);
1362
+ } catch {
1363
+ latestVersion = currentVersion;
1364
+ }
1365
+ }
1366
+
1367
+ return {
1368
+ currentVersion,
1369
+ latestVersion,
1370
+ sourceRepo: repo.fullName,
1371
+ currentBranch,
1372
+ targetRef,
1373
+ hasUpdates: behind > 0,
1374
+ behind,
1375
+ changelog: changelogRes.stdout.trim().split("\n").filter(Boolean),
1376
+ checkedAt: Date.now(),
1377
+ error: "",
1378
+ };
1379
+ })();
1380
+
1381
+ miokuUpdateCheckInFlight = job;
1382
+ try {
1383
+ const result = await job;
1384
+ miokuUpdateCache = result;
1385
+ return result;
1386
+ } finally {
1387
+ miokuUpdateCheckInFlight = null;
1388
+ }
1389
+ }
1390
+
1391
+ export async function checkMiokuReleaseUpdate(
1392
+ force = false,
1393
+ ): Promise<Record<string, any>> {
1394
+ return fetchLatestMiokuUpdate(force);
1395
+ }
1396
+
1397
+ export async function updateMiokuFromMain(): Promise<Record<string, any>> {
1398
+ const cwd = process.cwd();
1399
+ if (!fs.existsSync(path.join(cwd, ".git"))) {
1400
+ throw new Error(
1401
+ isContainerRuntime()
1402
+ ? "当前 Docker 容器未挂载 .git 到 /app/.git,无法更新 Mioku"
1403
+ : "当前目录不是 Git 仓库,无法更新 Mioku",
1404
+ );
1405
+ }
1406
+
1407
+ const { targetBranch } = await resolveMiokuTargetRef(cwd);
1408
+ const before = await runCommand("git", ["show", "HEAD:package.json"], cwd);
1409
+ const pull = await runCommand("git", ["pull", "origin", targetBranch], cwd);
1410
+
1411
+ if (pull.code !== 0) {
1412
+ throw new Error(`git pull 失败: ${pull.stderr || pull.stdout}`);
1413
+ }
1414
+
1415
+ const after = await runCommand("git", ["show", "HEAD:package.json"], cwd);
1416
+ const changed = packageJsonChanged(before.stdout, after.stdout);
1417
+
1418
+ let reinstallOutput = "";
1419
+ if (changed) {
1420
+ const packageManager = packageManagerFromSettings();
1421
+ const installCmd = getInstallCommand(packageManager);
1422
+ const install = await runCommand(installCmd.cmd, installCmd.args, cwd);
1423
+ if (install.code !== 0) {
1424
+ throw new Error(`依赖安装失败: ${install.stderr || install.stdout}`);
1425
+ }
1426
+ reinstallOutput = install.stdout || install.stderr;
1427
+ }
1428
+
1429
+ miokuUpdateCache = null;
1430
+
1431
+ const next = await fetchLatestMiokuUpdate(true);
1432
+ return {
1433
+ ok: true,
1434
+ restartRequired: true,
1435
+ packageJsonChanged: changed,
1436
+ reinstallOutput,
1437
+ currentVersion: next.currentVersion,
1438
+ latestVersion: next.latestVersion,
1439
+ hasUpdates: next.hasUpdates,
1440
+ };
1441
+ }
1442
+
1443
+ export async function updateWebUIDistFromRelease(): Promise<
1444
+ Record<string, any>
1445
+ > {
1446
+ if (webuiUpdatingInFlight) {
1447
+ return webuiUpdatingInFlight;
1448
+ }
1449
+
1450
+ const task = (async () => {
1451
+ const check = await fetchLatestWebUIUpdate(true);
1452
+ if (!check.hasUpdates) {
1453
+ return {
1454
+ ok: true,
1455
+ updated: false,
1456
+ message: "当前已是最新版本",
1457
+ currentVersion: check.currentVersion,
1458
+ latestVersion: check.latestVersion,
1459
+ };
1460
+ }
1461
+
1462
+ if (!check.assetUrl) {
1463
+ throw new Error(
1464
+ check.error || "没有找到可下载的 dist 压缩包,请检查 Release 资产",
1465
+ );
1466
+ }
1467
+
1468
+ const tempDir = path.join(
1469
+ os.tmpdir(),
1470
+ `mioku-webui-update-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1471
+ );
1472
+ const zipPath = path.join(tempDir, check.assetName || "webui-dist.zip");
1473
+ const unpackDir = path.join(tempDir, "unpack");
1474
+
1475
+ ensureDir(tempDir);
1476
+ ensureDir(unpackDir);
1477
+
1478
+ let backupPath = "";
1479
+ try {
1480
+ const downloadRes = await fetch(check.assetUrl, {
1481
+ headers: {
1482
+ Accept: "application/octet-stream",
1483
+ "User-Agent": "mioku-webui-updater",
1484
+ },
1485
+ });
1486
+
1487
+ if (!downloadRes.ok) {
1488
+ throw new Error(`下载 dist 压缩包失败: HTTP_${downloadRes.status}`);
1489
+ }
1490
+
1491
+ const buffer = Buffer.from(await downloadRes.arrayBuffer());
1492
+ fs.writeFileSync(zipPath, buffer);
1493
+
1494
+ const unzip = await runCommand(
1495
+ "unzip",
1496
+ ["-oq", zipPath, "-d", unpackDir],
1497
+ process.cwd(),
1498
+ );
1499
+ if (unzip.code !== 0) {
1500
+ throw new Error(`解压失败: ${unzip.stderr || unzip.stdout}`);
1501
+ }
1502
+
1503
+ const sourceDir = resolveDistSourceDir(unpackDir);
1504
+ if (!sourceDir) {
1505
+ throw new Error("压缩包内未找到可用的 WebUI dist 文件");
1506
+ }
1507
+
1508
+ ensureDir(path.dirname(WEBUI_DIST));
1509
+ const targetExisted = fs.existsSync(WEBUI_DIST);
1510
+ if (targetExisted) {
1511
+ backupPath = `${WEBUI_DIST}.backup.${Date.now()}`;
1512
+ fs.renameSync(WEBUI_DIST, backupPath);
1513
+ }
1514
+
1515
+ fs.mkdirSync(WEBUI_DIST, { recursive: true });
1516
+ fs.cpSync(sourceDir, WEBUI_DIST, { recursive: true, force: true });
1517
+
1518
+ if (!hasUsableDistFiles(WEBUI_DIST)) {
1519
+ throw new Error("更新后的 dist 无效,缺少 index.html");
1520
+ }
1521
+
1522
+ fs.writeFileSync(
1523
+ path.join(WEBUI_DIST, ".webui-version"),
1524
+ `${check.latestVersion}\n`,
1525
+ "utf-8",
1526
+ );
1527
+
1528
+ if (backupPath && fs.existsSync(backupPath)) {
1529
+ fs.rmSync(backupPath, { recursive: true, force: true });
1530
+ }
1531
+ } catch (error) {
1532
+ if (backupPath && fs.existsSync(backupPath)) {
1533
+ fs.rmSync(WEBUI_DIST, { recursive: true, force: true });
1534
+ fs.renameSync(backupPath, WEBUI_DIST);
1535
+ }
1536
+ throw error;
1537
+ } finally {
1538
+ fs.rmSync(tempDir, { recursive: true, force: true });
1539
+ }
1540
+
1541
+ webuiUpdateCache = {
1542
+ ...check,
1543
+ checkedAt: Date.now(),
1544
+ currentVersion: check.latestVersion,
1545
+ hasUpdates: false,
1546
+ canUpdate: false,
1547
+ error: "",
1548
+ };
1549
+
1550
+ return {
1551
+ ok: true,
1552
+ updated: true,
1553
+ version: check.latestVersion,
1554
+ assetName: check.assetName,
1555
+ releaseUrl: check.releaseUrl,
1556
+ restartRequired: false,
1557
+ };
1558
+ })().finally(() => {
1559
+ webuiUpdatingInFlight = null;
1560
+ });
1561
+
1562
+ webuiUpdatingInFlight = task;
1563
+ return task;
1564
+ }
1565
+
1566
+ function readPackageVersion(filePath: string): string {
1567
+ try {
1568
+ if (!fs.existsSync(filePath)) return "unknown";
1569
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1570
+ return parsed?.version || "unknown";
1571
+ } catch {
1572
+ return "unknown";
1573
+ }
1574
+ }
1575
+
1576
+ async function getSystemInformationSnapshot(): Promise<{
1577
+ diskUsagePercent: number;
1578
+ diskTotal: number;
1579
+ diskUsed: number;
1580
+ netRxPerSec: number;
1581
+ netTxPerSec: number;
1582
+ }> {
1583
+ try {
1584
+ const [fsSize, netStats] = await Promise.all([
1585
+ systemInfo.fsSize().catch(() => [] as any[]),
1586
+ systemInfo.networkStats().catch(() => [] as any[]),
1587
+ ]);
1588
+
1589
+ const disk = Array.isArray(fsSize) && fsSize.length > 0 ? fsSize[0] : null;
1590
+ const diskTotal = Number(disk?.size || 0);
1591
+ const diskUsed = Number(disk?.used || 0);
1592
+ const diskUsagePercent =
1593
+ diskTotal > 0
1594
+ ? Number(((diskUsed / diskTotal) * 100).toFixed(1))
1595
+ : Number(disk?.use || 0);
1596
+
1597
+ const networkList = Array.isArray(netStats) ? netStats : [];
1598
+ const netRxPerSec = networkList.reduce(
1599
+ (acc, item) => acc + Number(item?.rx_sec || 0),
1600
+ 0,
1601
+ );
1602
+ const netTxPerSec = networkList.reduce(
1603
+ (acc, item) => acc + Number(item?.tx_sec || 0),
1604
+ 0,
1605
+ );
1606
+
1607
+ return {
1608
+ diskUsagePercent: Number.isFinite(diskUsagePercent)
1609
+ ? diskUsagePercent
1610
+ : 0,
1611
+ diskTotal,
1612
+ diskUsed,
1613
+ netRxPerSec: Number.isFinite(netRxPerSec) ? netRxPerSec : 0,
1614
+ netTxPerSec: Number.isFinite(netTxPerSec) ? netTxPerSec : 0,
1615
+ };
1616
+ } catch {
1617
+ return {
1618
+ diskUsagePercent: 0,
1619
+ diskTotal: 0,
1620
+ diskUsed: 0,
1621
+ netRxPerSec: 0,
1622
+ netTxPerSec: 0,
1623
+ };
1624
+ }
1625
+ }
1626
+
1627
+ function sleep(ms: number): Promise<void> {
1628
+ return new Promise((resolve) => setTimeout(resolve, ms));
1629
+ }
1630
+
1631
+ function sampleCpuTimes() {
1632
+ const cpus = os.cpus();
1633
+ let idle = 0;
1634
+ let total = 0;
1635
+
1636
+ for (const cpu of cpus) {
1637
+ idle += cpu.times.idle;
1638
+ total +=
1639
+ cpu.times.user +
1640
+ cpu.times.nice +
1641
+ cpu.times.sys +
1642
+ cpu.times.irq +
1643
+ cpu.times.idle;
1644
+ }
1645
+
1646
+ return { idle, total };
1647
+ }
1648
+
1649
+ async function getCpuUsagePercent(): Promise<number> {
1650
+ const start = sampleCpuTimes();
1651
+ await sleep(180);
1652
+ const end = sampleCpuTimes();
1653
+ const idleDelta = end.idle - start.idle;
1654
+ const totalDelta = end.total - start.total;
1655
+ if (totalDelta <= 0) return 0;
1656
+ return Number((((totalDelta - idleDelta) / totalDelta) * 100).toFixed(1));
1657
+ }
1658
+
1659
+ function toHttpBaseUrl(bot: any): string {
1660
+ const protocol = String(bot.options?.protocol || "ws");
1661
+ const httpProtocol = protocol === "wss" ? "https" : "http";
1662
+ const host = bot.options?.host || "127.0.0.1";
1663
+ const port = bot.options?.port || 3001;
1664
+ return `${httpProtocol}://${host}:${port}`;
1665
+ }
1666
+
1667
+ async function fetchYiyan(): Promise<{ text: string }> {
1668
+ const controller = new AbortController();
1669
+ const timer = setTimeout(() => controller.abort(), 4500);
1670
+ try {
1671
+ const res = await fetch("https://uapis.cn/api/v1/saying", {
1672
+ method: "GET",
1673
+ signal: controller.signal,
1674
+ });
1675
+ if (!res.ok) {
1676
+ throw new Error(`HTTP_${res.status}`);
1677
+ }
1678
+ const data = (await res.json()) as { text?: string };
1679
+ return { text: data?.text || "愿每一次启动都带来新的灵感。" };
1680
+ } catch {
1681
+ return { text: "愿每一次启动都带来新的灵感。" };
1682
+ } finally {
1683
+ clearTimeout(timer);
1684
+ }
1685
+ }
1686
+
1687
+ function normalizeVersionSpec(input: string): string {
1688
+ if (!input || input === "unknown") return "unknown";
1689
+ const cleaned = input.trim().replace(/^[~^<>=\s]+/, "");
1690
+ const matched = cleaned.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
1691
+ return matched?.[0] || cleaned;
1692
+ }
1693
+
1694
+ async function getBotDetails(bot: any): Promise<Record<string, any>> {
1695
+ const base = {
1696
+ botId: bot?.bot_id || bot?.uin || 0,
1697
+ qq: bot?.uin || bot?.user_id || 0,
1698
+ nickname: bot?.nickname || bot?.name || "Unknown Bot",
1699
+ avatar: `https://q1.qlogo.cn/g?b=qq&nk=${bot?.uin || bot?.user_id || 0}&s=160`,
1700
+ online: true,
1701
+ napcatVersion: bot?.app_version || "unknown",
1702
+ napcatApiBase: toHttpBaseUrl(bot),
1703
+ groupCount: 0,
1704
+ friendCount: 0,
1705
+ onlineDurationMs: 0,
1706
+ statusText: "online",
1707
+ };
1708
+
1709
+ try {
1710
+ const [status, versionInfo, groups, friends] = await Promise.all([
1711
+ bot.api("get_status").catch(() => null),
1712
+ bot.api("get_version_info").catch(() => null),
1713
+ bot.getGroupList().catch(() => []),
1714
+ bot.getFriendList().catch(() => []),
1715
+ ]);
1716
+
1717
+ const stat = status?.stat || null;
1718
+ const startTs = Number(stat?.start_time || 0);
1719
+ const onlineDurationMs =
1720
+ startTs > 0 ? Math.max(0, Date.now() - startTs) : 0;
1721
+ const onlineFromStatus =
1722
+ typeof status?.online === "boolean" ? status.online : true;
1723
+
1724
+ return {
1725
+ ...base,
1726
+ online: onlineFromStatus,
1727
+ napcatVersion: versionInfo?.app_version || base.napcatVersion,
1728
+ groupCount: Array.isArray(groups) ? groups.length : 0,
1729
+ friendCount: Array.isArray(friends) ? friends.length : 0,
1730
+ onlineDurationMs,
1731
+ statusText: onlineFromStatus ? "online" : "offline",
1732
+ };
1733
+ } catch (error: any) {
1734
+ return {
1735
+ ...base,
1736
+ online: false,
1737
+ statusText: "error",
1738
+ error: error?.message || "NAPCAT_API_ERROR",
1739
+ };
1740
+ }
1741
+ }
1742
+
1743
+ export async function getSystemOverview(): Promise<Record<string, any>> {
1744
+ const rootPkg = readRootPackageJson();
1745
+ const cpus = os.cpus();
1746
+ const cpuModel = cpus[0]?.model || "unknown";
1747
+ const cpuSpeedMHz = cpus[0]?.speed || 0;
1748
+ const cpuCores = cpus.length;
1749
+ const cpuUsagePercent = await getCpuUsagePercent();
1750
+
1751
+ const totalMemory = os.totalmem();
1752
+ const freeMemory = os.freemem();
1753
+ const usedMemory = Math.max(0, totalMemory - freeMemory);
1754
+ const memoryUsagePercent =
1755
+ totalMemory > 0 ? Number(((usedMemory / totalMemory) * 100).toFixed(1)) : 0;
1756
+
1757
+ const processMemory = process.memoryUsage();
1758
+ const processMemoryPercent =
1759
+ totalMemory > 0
1760
+ ? Number(((processMemory.rss / totalMemory) * 100).toFixed(1))
1761
+ : 0;
1762
+ const siSnapshot = await getSystemInformationSnapshot();
1763
+
1764
+ const botInstances = Array.from(connectedBots.values());
1765
+ const bots = await Promise.all(botInstances.map((bot) => getBotDetails(bot)));
1766
+ const selectedBot = bots[0] || null;
1767
+
1768
+ return {
1769
+ uptimeSeconds: process.uptime(),
1770
+ bots,
1771
+ selectedBot,
1772
+ system: {
1773
+ cpuModel,
1774
+ cpuSpeedMHz,
1775
+ cpuCores,
1776
+ cpuUsagePercent,
1777
+ memoryTotal: totalMemory,
1778
+ memoryUsed: usedMemory,
1779
+ memoryFree: freeMemory,
1780
+ memoryUsagePercent,
1781
+ processMemoryRss: processMemory.rss,
1782
+ processMemoryHeapUsed: processMemory.heapUsed,
1783
+ processMemoryPercent,
1784
+ diskUsagePercent: siSnapshot.diskUsagePercent,
1785
+ diskTotal: siSnapshot.diskTotal,
1786
+ diskUsed: siSnapshot.diskUsed,
1787
+ networkRxPerSec: siSnapshot.netRxPerSec,
1788
+ networkTxPerSec: siSnapshot.netTxPerSec,
1789
+ osType: os.type(),
1790
+ osPlatform: os.platform(),
1791
+ osRelease: os.release(),
1792
+ osVersion: typeof os.version === "function" ? os.version() : "unknown",
1793
+ nodeVersion: process.version,
1794
+ },
1795
+ versions: {
1796
+ mioki: normalizeVersionSpec(
1797
+ rootPkg?.dependencies?.mioki ||
1798
+ rootPkg?.devDependencies?.mioki ||
1799
+ "unknown",
1800
+ ),
1801
+ mioku: rootPkg?.version || "unknown",
1802
+ webui: readInstalledWebUIVersion(),
1803
+ webuiService: readPackageVersion(
1804
+ path.join(process.cwd(), "src", "services", "webui", "package.json"),
1805
+ ),
1806
+ },
1807
+ plugins: listManagedPackages("plugin"),
1808
+ services: listManagedPackages("service"),
1809
+ };
1810
+ }
1811
+
1812
+ export async function getSaying(): Promise<{ text: string }> {
1813
+ return fetchYiyan();
1814
+ }
1815
+
1816
+ export function getChatConfig(fileName: string): any {
1817
+ const filePath = path.join(CHAT_CONFIG_DIR, fileName);
1818
+ return readJsonFile(filePath, {});
1819
+ }
1820
+
1821
+ export function updateChatConfig(fileName: string, data: any): any {
1822
+ const filePath = path.join(CHAT_CONFIG_DIR, fileName);
1823
+ writeJsonFile(filePath, data);
1824
+ return data;
1825
+ }
1826
+
1827
+ export interface MiokuConfig {
1828
+ owners: number[];
1829
+ admins: number[];
1830
+ napcat: Array<{
1831
+ name: string;
1832
+ protocol: string;
1833
+ port: number;
1834
+ host: string;
1835
+ token: string;
1836
+ }>;
1837
+ plugins: string[];
1838
+ boot: BootSystemConfig;
1839
+ }
1840
+
1841
+ export function getMiokuConfig(): MiokuConfig {
1842
+ const localConfig = readJsonFile<any>(LOCAL_CONFIG_PATH, { mioki: {} });
1843
+ const rootPkg = readRootPackageJson();
1844
+
1845
+ const miokiConfig = localConfig?.mioki || rootPkg?.mioki || {};
1846
+
1847
+ return {
1848
+ owners: Array.isArray(miokiConfig.owners) ? miokiConfig.owners : [],
1849
+ admins: Array.isArray(miokiConfig.admins) ? miokiConfig.admins : [],
1850
+ napcat: Array.isArray(miokiConfig.napcat) ? miokiConfig.napcat : [],
1851
+ plugins: Array.isArray(miokiConfig.plugins) ? miokiConfig.plugins : [],
1852
+ boot: getBootSystemConfig(),
1853
+ };
1854
+ }
1855
+
1856
+ export function updateMiokuConfig(config: Partial<MiokuConfig>): MiokuConfig {
1857
+ const current = getMiokuConfig();
1858
+ const updated: MiokuConfig = {
1859
+ owners: Array.isArray(config.owners) ? config.owners : current.owners,
1860
+ admins: Array.isArray(config.admins) ? config.admins : current.admins,
1861
+ napcat: Array.isArray(config.napcat) ? config.napcat : current.napcat,
1862
+ plugins: Array.isArray(config.plugins) ? config.plugins : current.plugins,
1863
+ boot: config.boot ? updateBootSystemConfig(config.boot) : current.boot,
1864
+ };
1865
+
1866
+ const localConfig = readJsonFile<any>(LOCAL_CONFIG_PATH, { mioki: {} });
1867
+ localConfig.mioki = {
1868
+ ...localConfig.mioki,
1869
+ ...updated,
1870
+ };
1871
+ writeJsonFile(LOCAL_CONFIG_PATH, localConfig);
1872
+
1873
+ const rootPkg = readRootPackageJson();
1874
+ if (rootPkg.mioki) {
1875
+ rootPkg.mioki.owners = updated.owners;
1876
+ rootPkg.mioki.admins = updated.admins;
1877
+ rootPkg.mioki.napcat = updated.napcat;
1878
+ rootPkg.mioki.plugins = updated.plugins;
1879
+ writeRootPackageJson(rootPkg);
1880
+ }
1881
+
1882
+ return updated;
1883
+ }
1884
+
1885
+ export function getAvailablePlugins(): string[] {
1886
+ ensureDir(PLUGINS_DIR);
1887
+ return fs
1888
+ .readdirSync(PLUGINS_DIR, { withFileTypes: true })
1889
+ .filter(
1890
+ (e) =>
1891
+ e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith("."),
1892
+ )
1893
+ .map((e) => e.name);
1894
+ }