cc-viewer 1.9.9 → 1.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +1 -1
  2. package/cli.js +4 -3
  3. package/dist/assets/App-Ci9GYs24.js +2 -0
  4. package/dist/assets/{MdxEditorPanel-BJVD_bcU.js → MdxEditorPanel-8fwlNHMz.js} +1 -1
  5. package/dist/assets/Mobile-DngSOqYr.js +1 -0
  6. package/dist/assets/ProxyStatsModal-CWSIELLj.js +1 -0
  7. package/dist/assets/{_baseUniq-CwQykUuc.js → _baseUniq-6ZLcdMmL.js} +1 -1
  8. package/dist/assets/{arc-EdUdBxtG.js → arc-Dirf6EZP.js} +1 -1
  9. package/dist/assets/{architectureDiagram-Q4EWVU46-DtBIdElY.js → architectureDiagram-Q4EWVU46-BfIhmu_I.js} +1 -1
  10. package/dist/assets/{blockDiagram-DXYQGD6D-guNZIKkZ.js → blockDiagram-DXYQGD6D-D0y4ALi1.js} +1 -1
  11. package/dist/assets/{c4Diagram-AHTNJAMY-xV5vB-Z9.js → c4Diagram-AHTNJAMY-Dlfp4SUK.js} +1 -1
  12. package/dist/assets/{channel-DYEktNap.js → channel-CaNcU8SI.js} +1 -1
  13. package/dist/assets/{chunk-4BX2VUAB-ZTkItpvj.js → chunk-4BX2VUAB-ki0vHNS-.js} +1 -1
  14. package/dist/assets/{chunk-4TB4RGXK-dYIewyyR.js → chunk-4TB4RGXK-D8yO8Iuv.js} +1 -1
  15. package/dist/assets/{chunk-55IACEB6-CSsPNgPX.js → chunk-55IACEB6-BvIgaNPO.js} +1 -1
  16. package/dist/assets/{chunk-EDXVE4YY-CnMiuqzQ.js → chunk-EDXVE4YY-DF2Of_VO.js} +1 -1
  17. package/dist/assets/{chunk-FMBD7UC4-CVpATPZ3.js → chunk-FMBD7UC4-BIw7SXs-.js} +1 -1
  18. package/dist/assets/{chunk-OYMX7WX6-Dpq4s8dW.js → chunk-OYMX7WX6-AB76s0A0.js} +1 -1
  19. package/dist/assets/{chunk-QZHKN3VN-Bnojh6X6.js → chunk-QZHKN3VN-CuN2LCFc.js} +1 -1
  20. package/dist/assets/{chunk-YZCP3GAM-BYqK4Dsa.js → chunk-YZCP3GAM-D4DLBzBN.js} +1 -1
  21. package/dist/assets/classDiagram-6PBFFD2Q-DLJzWgYm.js +1 -0
  22. package/dist/assets/classDiagram-v2-HSJHXN6E-DLJzWgYm.js +1 -0
  23. package/dist/assets/clone-H_VGsHn9.js +1 -0
  24. package/dist/assets/{cose-bilkent-S5V4N54A-OJ7mmWyV.js → cose-bilkent-S5V4N54A-Bn485PX9.js} +1 -1
  25. package/dist/assets/{dagre-KV5264BT-B1Kwd_Rk.js → dagre-KV5264BT-e4QVCf17.js} +1 -1
  26. package/dist/assets/{diagram-5BDNPKRD-D8qrw8sp.js → diagram-5BDNPKRD-_SBFGgnf.js} +1 -1
  27. package/dist/assets/{diagram-G4DWMVQ6-ip-N4ho8.js → diagram-G4DWMVQ6-Bz1ovxLs.js} +1 -1
  28. package/dist/assets/{diagram-MMDJMWI5-2PZaskUF.js → diagram-MMDJMWI5-B-oojW8K.js} +1 -1
  29. package/dist/assets/{diagram-TYMM5635-CeAOQUZP.js → diagram-TYMM5635-CTAyNaOj.js} +1 -1
  30. package/dist/assets/{erDiagram-SMLLAGMA-BCBaEYEb.js → erDiagram-SMLLAGMA-BfvKAqq2.js} +1 -1
  31. package/dist/assets/{flowDiagram-DWJPFMVM-CsGyIqcX.js → flowDiagram-DWJPFMVM-BpTEZEAe.js} +1 -1
  32. package/dist/assets/{ganttDiagram-T4ZO3ILL-COsj3FZ-.js → ganttDiagram-T4ZO3ILL-DUD_vkyE.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DvQVQGJG.js → gitGraphDiagram-UUTBAWPF-DnlXcTud.js} +1 -1
  34. package/dist/assets/{graph-BSB9fDSP.js → graph-D4xsUKSn.js} +1 -1
  35. package/dist/assets/{index-DKufsAzx.js → index-84-BjpGl.js} +1 -1
  36. package/dist/assets/index-BDKwOnE3.js +2 -0
  37. package/dist/assets/{index-Bo-12bug.js → index-BniBLRtZ.js} +1 -1
  38. package/dist/assets/{index-BRw4Iz2O.js → index-C2Zo3KOg.js} +1 -1
  39. package/dist/assets/{index-BuVComcY.js → index-CWZoKT7e.js} +1 -1
  40. package/dist/assets/{index-eM2ih5n7.js → index-D-4EsFub.js} +1 -1
  41. package/dist/assets/{index-CNNAYjkF.js → index-D3KzRmkP.js} +1 -1
  42. package/dist/assets/{index-DXBfwPYD.js → index-NGgdum2i.js} +1 -1
  43. package/dist/assets/{infoDiagram-42DDH7IO-eNucS4iW.js → infoDiagram-42DDH7IO-BbdvmCDt.js} +1 -1
  44. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js → ishikawaDiagram-UXIWVN3A-oZBOPoTO.js} +1 -1
  45. package/dist/assets/{journeyDiagram-VCZTEJTY-yJJzVEBb.js → journeyDiagram-VCZTEJTY-DWoH0hs9.js} +1 -1
  46. package/dist/assets/{jszip.min-ByI0ds5u.js → jszip.min-I5y9RZhq.js} +1 -1
  47. package/dist/assets/{kanban-definition-6JOO6SKY-2rfTi2zw.js → kanban-definition-6JOO6SKY-BLHtjKsS.js} +1 -1
  48. package/dist/assets/{layout-DTmHj0Wm.js → layout-C116SNcQ.js} +1 -1
  49. package/dist/assets/{linear-BOA3a9FE.js → linear-Br08WCkF.js} +1 -1
  50. package/dist/assets/{mermaid.core-5H-NR79H.js → mermaid.core-D-4dTrvI.js} +2 -2
  51. package/dist/assets/{min-CpjFTEec.js → min-khs74HbH.js} +1 -1
  52. package/dist/assets/{mindmap-definition-QFDTVHPH-B8KCjFgB.js → mindmap-definition-QFDTVHPH-C3_yItFF.js} +1 -1
  53. package/dist/assets/{pieDiagram-DEJITSTG-BARy96g5.js → pieDiagram-DEJITSTG-ByCwM2_P.js} +1 -1
  54. package/dist/assets/{quadrantDiagram-34T5L4WZ-DYoTapuI.js → quadrantDiagram-34T5L4WZ-CjV65X1r.js} +1 -1
  55. package/dist/assets/{requirementDiagram-MS252O5E-EeANLLK2.js → requirementDiagram-MS252O5E-DFJ_dbtV.js} +1 -1
  56. package/dist/assets/{sankeyDiagram-XADWPNL6-DmQ-YVPj.js → sankeyDiagram-XADWPNL6-BBvXoBfj.js} +1 -1
  57. package/dist/assets/{seqResourceLoaders-C0cU2e1o.css → seqResourceLoaders-C34z9uOa.css} +2 -2
  58. package/dist/assets/seqResourceLoaders-JJzgZZzU.js +2 -0
  59. package/dist/assets/{sequenceDiagram-FGHM5R23-SZtW0rYC.js → sequenceDiagram-FGHM5R23-BJM04Y3N.js} +1 -1
  60. package/dist/assets/{stateDiagram-FHFEXIEX-CEygsIld.js → stateDiagram-FHFEXIEX-DI2q_Kkn.js} +1 -1
  61. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js → stateDiagram-v2-QKLJ7IA2-CrGC6kEY.js} +1 -1
  62. package/dist/assets/{timeline-definition-GMOUNBTQ-DhPBaQBt.js → timeline-definition-GMOUNBTQ-JcfRUsjD.js} +1 -1
  63. package/dist/assets/vendor-antd-DxEVqVhD.js +17 -0
  64. package/dist/assets/vendor-codemirror-CgNlkZva.js +1 -0
  65. package/dist/assets/{vendor-mdxeditor-Cz09a1_I.js → vendor-mdxeditor-DoreQf4A.js} +2 -2
  66. package/dist/assets/{vendor-qrcode-qbbntD6-.js → vendor-qrcode-q1CKtOyX.js} +1 -1
  67. package/dist/assets/{vendor-virtuoso-Sk8fQ4Hx.js → vendor-virtuoso-BXGe9q7E.js} +1 -1
  68. package/dist/assets/{vennDiagram-DHZGUBPP-NniTHxDw.js → vennDiagram-DHZGUBPP-mAHR1Ugj.js} +1 -1
  69. package/dist/assets/{wardley-RL74JXVD-D9SNOrDw.js → wardley-RL74JXVD-mlTq_P-4.js} +1 -1
  70. package/dist/assets/{wardleyDiagram-NUSXRM2D-DvHXKNza.js → wardleyDiagram-NUSXRM2D-BDGfajRb.js} +1 -1
  71. package/dist/assets/{xychartDiagram-5P7HB3ND-CaiwPTQB.js → xychartDiagram-5P7HB3ND-Cf2pR1mV.js} +1 -1
  72. package/dist/index.html +4 -4
  73. package/package.json +1 -1
  74. package/plugins/README.md +9 -1
  75. package/server/interceptor.js +1 -1
  76. package/server/lib/builtin-model-prompts.js +1 -1
  77. package/server/lib/chat-queue.js +1 -1
  78. package/server/lib/credential-access.js +1 -1
  79. package/server/lib/credential-migrate.js +1 -1
  80. package/server/lib/error-report.js +26 -0
  81. package/server/lib/json-store.js +1 -1
  82. package/server/lib/launch-config.js +1 -1
  83. package/server/lib/log-watcher.js +1 -1
  84. package/server/lib/plugin-host.mjs +198 -0
  85. package/server/lib/plugin-loader.js +346 -90
  86. package/server/lib/plugin-manager.js +46 -2
  87. package/server/lib/plugin-manifest.js +329 -0
  88. package/server/lib/plugin-panels.js +183 -0
  89. package/server/lib/plugin-proxy.js +408 -0
  90. package/server/lib/plugin-runtime.js +775 -0
  91. package/server/lib/plugin-service-manager.js +463 -0
  92. package/server/lib/proxy/proxy-retry.js +1 -1
  93. package/server/lib/proxy/proxy-stats.js +1 -1
  94. package/server/lib/resume-watchdog.js +1 -1
  95. package/server/lib/same-origin.js +13 -3
  96. package/server/lib/sdk-manager.js +16 -9
  97. package/server/lib/sdk-session-index.js +1 -1
  98. package/server/lib/stats-worker.js +1 -1
  99. package/server/lib/system-prompt-files.js +1 -1
  100. package/server/lib/system-prompt-live.js +1 -1
  101. package/server/lib/system-prompt-snapshots.js +1 -1
  102. package/server/lib/v2/adapter.js +1 -1
  103. package/server/lib/v2/convert.js +1 -1
  104. package/server/lib/v2/jsonl-read.js +1 -1
  105. package/server/lib/v2/live-feed.js +1 -1
  106. package/server/lib/v2/meta-rows.js +1 -1
  107. package/server/lib/v2/resume-list.js +1 -1
  108. package/server/lib/v2/session-list.js +1 -1
  109. package/server/lib/v2/session-owner.js +1 -1
  110. package/server/lib/v2/v2-writer.js +1 -1
  111. package/server/lib/wire-compress.js +1 -1
  112. package/server/lib/ws-mux.js +1 -1
  113. package/server/proxy.js +1 -1
  114. package/server/pty-manager.js +1 -1
  115. package/server/routes/ask-perm.js +1 -1
  116. package/server/routes/events.js +1 -1
  117. package/server/routes/expert.js +1 -1
  118. package/server/routes/files-content.js +1 -1
  119. package/server/routes/files-fs.js +4 -2
  120. package/server/routes/misc.js +11 -0
  121. package/server/routes/plugin-services.js +104 -0
  122. package/server/routes/plugins.js +145 -17
  123. package/server/routes/preferences.js +1 -1
  124. package/server/routes/resume.js +1 -1
  125. package/server/routes/sdk-context.js +1 -1
  126. package/server/routes/sdk-mcp-status.js +1 -1
  127. package/server/routes/workspaces.js +1 -1
  128. package/server/server.js +328 -171
  129. package/dist/assets/App-CLteNB83.js +0 -2
  130. package/dist/assets/Mobile-CJCQf3MQ.js +0 -1
  131. package/dist/assets/ProxyStatsModal-B5ccoOPy.js +0 -1
  132. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +0 -1
  133. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +0 -1
  134. package/dist/assets/clone-CFj55h49.js +0 -1
  135. package/dist/assets/index-CqX5TkEL.js +0 -2
  136. package/dist/assets/seqResourceLoaders-7lsgbvGa.js +0 -2
  137. package/dist/assets/vendor-antd-CS_S2mBN.js +0 -17
  138. package/dist/assets/vendor-codemirror-B5l-VbBO.js +0 -1
@@ -1,8 +1,18 @@
1
1
  import { readdirSync, existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
+ import { randomBytes } from 'node:crypto';
4
5
  import { LOG_DIR } from '../../findcc.js';
5
6
  import { PLUGINS_DIR } from '../_paths.js';
7
+ import {
8
+ parsePluginManifest,
9
+ computeMainFingerprint,
10
+ computePanelFingerprint,
11
+ listPluginDirs,
12
+ validatePluginName,
13
+ validateServicePrefix,
14
+ } from './plugin-manifest.js';
15
+ import { reportSwallowed } from './error-report.js';
6
16
 
7
17
  // 动态获取(LOG_DIR 可能在运行时被 setLogDir 修改)
8
18
  export function getPluginsDir() { return join(LOG_DIR, 'plugins'); }
@@ -24,81 +34,280 @@ const HOOK_TYPES = {
24
34
  };
25
35
 
26
36
  let _plugins = [];
37
+ // Full result of the last loadPlugins (directory service/panel plugins and failures included)
38
+ let _entries = [];
39
+ let _generation = 0;
40
+ // Directory plugins with an unchanged fingerprint reuse the old module instance and import URL to avoid repeated reloads
41
+ // accumulate module namespaces in the ESM registry that are never released
42
+ const _moduleCache = new Map(); // mainPath -> { fingerprint, url, module }
27
43
 
28
- /**
29
- * 扫描 LOG_DIR/plugins/ 目录,动态 import 每个 .js/.mjs 文件
30
- */
31
- export async function loadPlugins() {
32
- _plugins = [];
44
+ // Runtime event dispatcher (bridge for ctx.events.on): loadPlugins rebuilds _plugins every time,
45
+ // and the loader re-attaches this after each scan so it stays online across hot reloads.
46
+ let _eventDispatcherPlugin = null;
47
+ export function registerEventDispatcherPlugin(plugin) {
48
+ _eventDispatcherPlugin = plugin;
49
+ if (plugin && !_plugins.some(p => p.name === plugin.name)) {
50
+ _plugins.push(plugin);
51
+ }
52
+ }
33
53
 
34
- // 读取 disabledPlugins 列表
35
- let disabledPlugins = [];
54
+ function readDisabledPlugins() {
36
55
  try {
37
56
  if (existsSync(getPrefsFilePath())) {
38
57
  const prefs = JSON.parse(readFileSync(getPrefsFilePath(), 'utf-8'));
39
58
  if (Array.isArray(prefs.disabledPlugins)) {
40
- disabledPlugins = prefs.disabledPlugins;
59
+ return prefs.disabledPlugins;
41
60
  }
42
61
  }
43
- } catch { }
62
+ } catch (err) {
63
+ reportSwallowed('plugin-loader.prefs', err);
64
+ }
65
+ return [];
66
+ }
44
67
 
45
- // Load user plugins from LOG_DIR/plugins/
46
- if (existsSync(getPluginsDir())) {
47
- let files;
68
+ async function importMainModule(mainPath, fingerprint, force) {
69
+ const cacheKey = mainPath;
70
+ const cached = _moduleCache.get(cacheKey);
71
+ if (!force && cached && cached.fingerprint === fingerprint) {
72
+ return { module: cached.module, url: cached.url, reused: true };
73
+ }
74
+ const tag = force ? randomBytes(8).toString('hex') : fingerprint.slice(0, 16);
75
+ // Fingerprint cache-bust: pathToFileURL wraps the absolute path (Windows-safe); a new query yields a new module instance
76
+ const mod = await import(pathToFileURL(mainPath).href + '?ccv=' + tag);
77
+ const url = `${pathToFileURL(mainPath).href}?ccv=${tag}`;
78
+ const module = mod.default || mod;
79
+ _moduleCache.set(cacheKey, { fingerprint, url, module });
80
+ return { module, url, reused: false };
81
+ }
82
+
83
+ /**
84
+ * Scan plugin directories (user LOG_DIR/plugins/ and the bundled plugins/) and load every plugin.
85
+ * - Top-level .js/.mjs: single-file hook plugins (legacy behavior unchanged)
86
+ * - Top-level directories with plugin.json: directory plugins (hooks optional; service/panel/lifecycle allowed)
87
+ * Never rejects: a single failure is recorded in that entry's loadError.
88
+ */
89
+ export async function loadPlugins(opts = {}) {
90
+ const force = opts.force === true;
91
+ _plugins = [];
92
+ _entries = [];
93
+ if (force) _moduleCache.clear();
94
+
95
+ const disabledPlugins = readDisabledPlugins();
96
+ const seenNames = new Set();
97
+ const usedPrefixes = new Set();
98
+
99
+ const loadFilePlugin = async (dir, file, source) => {
100
+ const filePath = join(dir, file);
101
+ const entry = {
102
+ name: file,
103
+ source,
104
+ shape: 'file',
105
+ file,
106
+ dir: null,
107
+ pluginDir: null,
108
+ manifest: null,
109
+ plugin: null,
110
+ service: null,
111
+ panel: null,
112
+ fingerprints: null,
113
+ enabled: true,
114
+ loadError: null,
115
+ };
48
116
  try {
49
- files = readdirSync(getPluginsDir())
50
- .filter(f => f.endsWith('.js') || f.endsWith('.mjs'))
51
- .sort();
52
- } catch {
53
- files = [];
54
- }
117
+ // On Windows `file://${C:\...}` yields `file://C:\...` (no third slash, unescaped backslashes);
118
+ // pathToFileURL guarantees the correct cross-platform form. On POSIX it matches the old string concatenation.
119
+ const mod = await import(pathToFileURL(filePath).href);
120
+ const plugin = mod.default || mod;
121
+ const name = plugin.name || (source === 'bundled' ? file.replace(/\.[cm]?js$/, '') : file);
122
+ entry.name = name;
123
+ entry.plugin = plugin;
55
124
 
56
- for (const file of files) {
57
- const filePath = join(getPluginsDir(), file);
58
- try {
59
- // Windows 下 `file://${C:\...}` 会产生 `file://C:\...`(无第三个 /、反斜杠未转),
60
- // pathToFileURL 保证跨平台正确形式。POSIX 下与原先字符串拼接 ESM 行为等价。
61
- const mod = await import(pathToFileURL(filePath).href);
62
- const plugin = mod.default || mod;
63
- const name = plugin.name || file;
64
-
65
- if (disabledPlugins.includes(name)) {
66
- if (SHOULD_LOG) console.error(`[CC Viewer] Plugin "${name}" is disabled, skipping.`);
67
- continue;
125
+ if (plugin.service !== undefined) {
126
+ entry.loadError = 'single-file plugins cannot declare "service"; use a directory plugin with plugin.json';
127
+ if (SHOULD_LOG) console.error(`[CC Viewer] Plugin "${name}": ${entry.loadError}`);
128
+ _entries.push(entry);
129
+ return;
130
+ }
131
+ if (disabledPlugins.includes(name)) {
132
+ entry.enabled = false;
133
+ if (SHOULD_LOG) console.error(`[CC Viewer] Plugin "${name}" is disabled, skipping.`);
134
+ _entries.push(entry);
135
+ return;
136
+ }
137
+ if (source === 'user') seenNames.add(name);
138
+ if (plugin.hooks && typeof plugin.hooks === 'object') {
139
+ const alreadyLoaded = source === 'bundled' && _plugins.some(p => p.name === name);
140
+ if (!alreadyLoaded) {
141
+ _plugins.push({ name, hooks: plugin.hooks, file, ...(source === 'bundled' ? { source } : {}) });
142
+ if (SHOULD_LOG) console.error(`[CC Viewer] Plugin loaded: ${name} (${file})`);
68
143
  }
144
+ }
145
+ _entries.push(entry);
146
+ } catch (err) {
147
+ entry.loadError = err.message;
148
+ if (SHOULD_LOG) console.error(`[CC Viewer] Failed to load plugin "${file}":`, err.message);
149
+ _entries.push(entry);
150
+ }
151
+ };
69
152
 
70
- if (plugin.hooks && typeof plugin.hooks === 'object') {
71
- _plugins.push({ name, hooks: plugin.hooks, file });
72
- if (SHOULD_LOG) console.error(`[CC Viewer] Plugin loaded: ${name} (${file})`);
153
+ const loadDirPlugin = async (parentDir, dirName, source) => {
154
+ const pluginDir = join(parentDir, dirName);
155
+ const entry = {
156
+ name: dirName,
157
+ source,
158
+ shape: 'dir',
159
+ file: null,
160
+ dir: dirName,
161
+ pluginDir,
162
+ manifest: null,
163
+ plugin: null,
164
+ service: null,
165
+ panel: null,
166
+ fingerprints: null,
167
+ enabled: true,
168
+ loadError: null,
169
+ };
170
+ const { manifest, error } = parsePluginManifest(pluginDir);
171
+ if (error) {
172
+ // Directories without plugin.json: ignored (may be a user's asset folder)
173
+ if (error === 'missing plugin.json') return;
174
+ entry.loadError = error;
175
+ _entries.push(entry);
176
+ return;
177
+ }
178
+ entry.manifest = manifest;
179
+ entry.name = manifest.name;
180
+
181
+ if (manifest.panel) {
182
+ entry.panel = {
183
+ entryRel: manifest.panel.entryRel,
184
+ entryPath: manifest.panel.entryPath,
185
+ rootDir: manifest.panel.rootDir,
186
+ cssRel: manifest.panel.cssRel,
187
+ cssPath: manifest.panel.cssPath,
188
+ title: manifest.panel.title,
189
+ titleI18n: manifest.panel.titleI18n,
190
+ icon: manifest.panel.icon,
191
+ width: manifest.panel.width,
192
+ desktopOnly: manifest.panel.desktopOnly,
193
+ fingerprint: computePanelFingerprint(manifest.panel),
194
+ };
195
+ }
196
+ if (manifest.service) {
197
+ entry.service = {
198
+ entryRel: manifest.service.entryRel,
199
+ entryPath: manifest.service.entryPath,
200
+ prefix: manifest.service.prefix,
201
+ env: manifest.service.env,
202
+ };
203
+ }
204
+
205
+ if (disabledPlugins.includes(manifest.name)) {
206
+ entry.enabled = false;
207
+ _entries.push(entry);
208
+ return;
209
+ }
210
+ if (!validatePluginName(manifest.name)) {
211
+ entry.loadError = `invalid plugin name: ${manifest.name}`;
212
+ _entries.push(entry);
213
+ return;
214
+ }
215
+ if (seenNames.has(manifest.name)) {
216
+ // User source overrides bundled source: a same-named bundled directory plugin silently yields (same as single-file plugins)
217
+ if (source === 'bundled') return;
218
+ entry.enabled = false;
219
+ entry.loadError = `duplicate plugin name: ${manifest.name}`;
220
+ _entries.push(entry);
221
+ return;
222
+ }
223
+ if (manifest.service) {
224
+ if (!validateServicePrefix(manifest.service.prefix) || usedPrefixes.has(manifest.service.prefix)) {
225
+ entry.loadError = `invalid or duplicate service.prefix: ${manifest.service.prefix}`;
226
+ _entries.push(entry);
227
+ return;
228
+ }
229
+ }
230
+
231
+ // main module (optional): hooks + activate/deactivate/onError
232
+ let plugin = null;
233
+ if (manifest.mainPath) {
234
+ const fingerprint = computeMainFingerprint(manifest.raw, manifest.mainPath, pluginDir);
235
+ entry.fingerprints = { main: fingerprint };
236
+ try {
237
+ const { module } = await importMainModule(manifest.mainPath, fingerprint, force);
238
+ plugin = module;
239
+ entry.plugin = plugin;
240
+ if (plugin && plugin.name && plugin.name !== manifest.name && SHOULD_LOG) {
241
+ console.error(`[CC Viewer] Plugin "${manifest.name}": module name "${plugin.name}" differs from manifest name, manifest wins.`);
73
242
  }
74
243
  } catch (err) {
75
- if (SHOULD_LOG) console.error(`[CC Viewer] Failed to load plugin "${file}":`, err.message);
244
+ entry.loadError = `failed to import ${manifest.mainRel}: ${err.message}`;
245
+ if (SHOULD_LOG) console.error(`[CC Viewer] ${entry.loadError}`);
246
+ _entries.push(entry);
247
+ return;
76
248
  }
77
249
  }
250
+
251
+ seenNames.add(manifest.name);
252
+ if (manifest.service) usedPrefixes.add(manifest.service.prefix);
253
+
254
+ // hooks still use the same pipeline; service/panel/activate info lives in entries (filled above)
255
+ if (plugin && plugin.hooks && typeof plugin.hooks === 'object') {
256
+ _plugins.push({ name: manifest.name, hooks: plugin.hooks, file: dirName, ...(source === 'bundled' ? { source } : {}) });
257
+ }
258
+ if (SHOULD_LOG) console.error(`[CC Viewer] Directory plugin loaded: ${manifest.name} (${dirName})`);
259
+ _entries.push(entry);
260
+ };
261
+
262
+ // ---- User plugin directory ----
263
+ const userDir = getPluginsDir();
264
+ if (existsSync(userDir)) {
265
+ let dirents;
266
+ try {
267
+ dirents = readdirSync(userDir, { withFileTypes: true });
268
+ } catch {
269
+ dirents = [];
270
+ }
271
+ const files = dirents
272
+ .filter(d => d.isFile() && (d.name.endsWith('.js') || d.name.endsWith('.mjs')))
273
+ .map(d => d.name)
274
+ .sort();
275
+ for (const file of files) {
276
+ await loadFilePlugin(userDir, file, 'user');
277
+ }
278
+ for (const dirName of listPluginDirs(userDir)) {
279
+ await loadDirPlugin(userDir, dirName, 'user');
280
+ }
78
281
  }
79
282
 
80
- // Load bundled plugins from package's plugins/ directory (user plugins take priority)
283
+ // ---- Bundled plugin directory (user source wins) ----
81
284
  const bundledDir = PLUGINS_DIR;
82
285
  if (existsSync(bundledDir)) {
83
- let bundledFiles;
286
+ let dirents;
84
287
  try {
85
- bundledFiles = readdirSync(bundledDir).filter(f => f.endsWith('.js') || f.endsWith('.mjs')).sort();
86
- } catch { bundledFiles = []; }
87
- for (const file of bundledFiles) {
88
- try {
89
- const mod = await import(pathToFileURL(join(bundledDir, file)).href);
90
- const plugin = mod.default || mod;
91
- const name = plugin.name || file.replace(/\.[cm]?js$/, '');
92
- if (disabledPlugins.includes(name)) continue;
93
- if (_plugins.some(p => p.name === name)) continue;
94
- if (!plugin.hooks || typeof plugin.hooks !== 'object') continue;
95
- _plugins.push({ name, hooks: plugin.hooks, file, source: 'bundled' });
96
- if (SHOULD_LOG) console.error(`[CC Viewer] Bundled plugin loaded: ${name} (${file})`);
97
- } catch (err) {
98
- if (SHOULD_LOG) console.error(`[CC Viewer] Failed to load bundled plugin "${file}":`, err.message);
99
- }
288
+ dirents = readdirSync(bundledDir, { withFileTypes: true });
289
+ } catch {
290
+ dirents = [];
291
+ }
292
+ const files = dirents
293
+ .filter(d => d.isFile() && (d.name.endsWith('.js') || d.name.endsWith('.mjs')))
294
+ .map(d => d.name)
295
+ .sort();
296
+ for (const file of files) {
297
+ await loadFilePlugin(bundledDir, file, 'bundled');
298
+ }
299
+ for (const dirName of listPluginDirs(bundledDir)) {
300
+ await loadDirPlugin(bundledDir, dirName, 'bundled');
100
301
  }
101
302
  }
303
+
304
+ // Re-attach the runtime's event dispatcher (_plugins was cleared at the top)
305
+ if (_eventDispatcherPlugin && !_plugins.some(p => p.name === _eventDispatcherPlugin.name)) {
306
+ _plugins.push(_eventDispatcherPlugin);
307
+ }
308
+
309
+ _generation += 1;
310
+ return { generation: _generation, entries: _entries };
102
311
  }
103
312
 
104
313
  /**
@@ -140,55 +349,102 @@ export async function runParallelHook(name, context = {}) {
140
349
  await Promise.all(tasks);
141
350
  }
142
351
 
352
+ // ---- Structured access for plugin-runtime ----
353
+
354
+ export function getPluginEntries() {
355
+ return _entries;
356
+ }
357
+
358
+ export function getServiceEntries() {
359
+ return _entries.filter(e => e.enabled && !e.loadError && e.service);
360
+ }
361
+
362
+ export function getPluginsGeneration() {
363
+ return _generation;
364
+ }
365
+
366
+ // Test-only: reset the module cache (does not affect already imported modules; only later loadPlugins)
367
+ export function _resetModuleCacheForTest() {
368
+ _moduleCache.clear();
369
+ }
370
+
143
371
  /**
144
372
  * 返回所有插件文件信息(含已禁用的),供 /api/plugins 使用
145
373
  */
146
374
  export function getPluginsInfo() {
147
- if (!existsSync(getPluginsDir())) return [];
375
+ const info = [];
376
+ const disabledPlugins = readDisabledPlugins();
148
377
 
149
- let disabledPlugins = [];
150
- try {
151
- if (existsSync(getPrefsFilePath())) {
152
- const prefs = JSON.parse(readFileSync(getPrefsFilePath(), 'utf-8'));
153
- if (Array.isArray(prefs.disabledPlugins)) {
154
- disabledPlugins = prefs.disabledPlugins;
155
- }
378
+ // Single-file plugins: keep legacy behavior (regex-extract name from source when not loaded)
379
+ if (existsSync(getPluginsDir())) {
380
+ let files;
381
+ try {
382
+ files = readdirSync(getPluginsDir())
383
+ .filter(f => f.endsWith('.js') || f.endsWith('.mjs'))
384
+ .sort();
385
+ } catch {
386
+ files = [];
156
387
  }
157
- } catch { }
158
388
 
159
- let files;
160
- try {
161
- files = readdirSync(getPluginsDir())
162
- .filter(f => f.endsWith('.js') || f.endsWith('.mjs'))
163
- .sort();
164
- } catch {
165
- return [];
166
- }
167
-
168
- return files.map(file => {
169
- const loaded = _plugins.find(p => p.file === file);
170
- let name = file;
389
+ for (const file of files) {
390
+ const loaded = _plugins.find(p => p.file === file);
391
+ const fileEntry = _entries.find(e => e.shape === 'file' && e.file === file && e.source === 'user');
392
+ let name = file;
393
+ let resolvedFromSource = false;
171
394
 
172
- // 如果插件已加载,使用加载时的 name
173
- if (loaded) {
174
- name = loaded.name;
175
- } else {
176
- // 如果插件未加载(可能被禁用),尝试读取文件获取真实的 name
177
- try {
178
- const filePath = join(getPluginsDir(), file);
179
- const content = readFileSync(filePath, 'utf-8');
180
- // 简单匹配 name: 'xxx' 或 name: "xxx"
181
- const match = content.match(/name\s*:\s*['"]([^'"]+)['"]/);
182
- if (match) {
183
- name = match[1];
395
+ if (loaded) {
396
+ name = loaded.name;
397
+ } else {
398
+ try {
399
+ const filePath = join(getPluginsDir(), file);
400
+ const content = readFileSync(filePath, 'utf-8');
401
+ // Simple match for name: 'xxx' or name: "xxx"
402
+ const match = content.match(/name\s*:\s*['"]([^'"]+)['"]/);
403
+ if (match) {
404
+ name = match[1];
405
+ resolvedFromSource = true;
406
+ }
407
+ } catch (err) {
408
+ reportSwallowed('plugin-loader.info-read', err);
409
+ }
410
+ if (!resolvedFromSource && fileEntry) {
411
+ name = fileEntry.name;
184
412
  }
185
- } catch {
186
- // 读取失败,使用文件名
187
413
  }
414
+
415
+ const hooks = loaded ? Object.keys(loaded.hooks) : [];
416
+ const enabled = !disabledPlugins.includes(name);
417
+ const item = { name, file, hooks, enabled, type: 'hook' };
418
+ if (fileEntry && fileEntry.loadError) item.loadError = fileEntry.loadError;
419
+ info.push(item);
420
+ }
421
+ }
422
+
423
+ // Directory plugins: merge the last loadPlugins result (manifest reads execute no plugin code,
424
+ // but the list only reflects known state; reload refreshes it)
425
+ for (const entry of _entries) {
426
+ if (entry.shape !== 'dir') continue;
427
+ const item = {
428
+ name: entry.name,
429
+ dir: entry.dir,
430
+ hooks: entry.plugin && entry.plugin.hooks ? Object.keys(entry.plugin.hooks) : [],
431
+ enabled: entry.enabled && !disabledPlugins.includes(entry.name),
432
+ type: entry.service ? 'service' : (entry.panel ? 'panel' : (entry.manifest && entry.manifest.mainPath ? 'hook' : 'dir')),
433
+ source: entry.source,
434
+ };
435
+ if (entry.service) item.service = { prefix: entry.service.prefix };
436
+ if (entry.panel) {
437
+ item.panel = {
438
+ title: entry.panel.title,
439
+ titleI18n: entry.panel.titleI18n,
440
+ icon: entry.panel.icon,
441
+ width: entry.panel.width,
442
+ desktopOnly: entry.panel.desktopOnly,
443
+ };
188
444
  }
445
+ if (entry.loadError) item.loadError = entry.loadError;
446
+ info.push(item);
447
+ }
189
448
 
190
- const hooks = loaded ? Object.keys(loaded.hooks) : [];
191
- const enabled = !disabledPlugins.includes(name);
192
- return { name, file, hooks, enabled };
193
- });
449
+ return info;
194
450
  }
@@ -1,7 +1,8 @@
1
- import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync, rmSync, realpathSync, readFileSync } from 'node:fs';
2
+ import { join, relative, isAbsolute } from 'node:path';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { tmpdir } from 'node:os';
5
+ import { PLUGIN_NAME_RE } from './plugin-manifest.js';
5
6
 
6
7
  /**
7
8
  * Upload plugin files from a file list.
@@ -116,3 +117,46 @@ export async function installPluginFromUrl(pluginsDir, fileUrl, extractNameScrip
116
117
  writeFileSync(join(pluginsDir, filename), content, 'utf-8');
117
118
  return { filename };
118
119
  }
120
+
121
+ /**
122
+ * Delete a directory plugin (service/panel plugin with plugin.json).
123
+ * Validates: slug shape, realpath containment inside pluginsDir (symlink-safe),
124
+ * presence of plugin.json, manifest name matching the directory name.
125
+ * Caller must stop/unload the plugin's service BEFORE calling this (Windows: the
126
+ * child's cwd is the plugin dir, deletion would EBUSY while it runs).
127
+ * @param {string} pluginsDir
128
+ * @param {string} dirName
129
+ * @throws {Error} with statusCode on validation failure
130
+ */
131
+ export function deletePluginDir(pluginsDir, dirName) {
132
+ if (!dirName || !PLUGIN_NAME_RE.test(dirName)) {
133
+ throw Object.assign(new Error('Invalid directory name'), { statusCode: 400 });
134
+ }
135
+ const target = join(pluginsDir, dirName);
136
+ if (!existsSync(target)) {
137
+ throw Object.assign(new Error('Directory not found'), { statusCode: 404 });
138
+ }
139
+ const realRoot = realpathSync(pluginsDir);
140
+ const realTarget = realpathSync(target);
141
+ const rootCmp = process.platform === 'win32' ? realRoot.toLowerCase() : realRoot;
142
+ const targetCmp = process.platform === 'win32' ? realTarget.toLowerCase() : realTarget;
143
+ const rel = relative(rootCmp, targetCmp);
144
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
145
+ throw Object.assign(new Error('Directory escapes plugins root'), { statusCode: 400 });
146
+ }
147
+ const manifestPath = join(realTarget, 'plugin.json');
148
+ if (!existsSync(manifestPath)) {
149
+ throw Object.assign(new Error('Not a directory plugin (missing plugin.json)'), { statusCode: 400 });
150
+ }
151
+ try {
152
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
153
+ if (manifest.name && manifest.name !== dirName) {
154
+ throw Object.assign(new Error(`Manifest name "${manifest.name}" does not match directory "${dirName}"`), { statusCode: 400 });
155
+ }
156
+ } catch (err) {
157
+ if (err.statusCode) throw err;
158
+ throw Object.assign(new Error('plugin.json is not valid JSON'), { statusCode: 400 });
159
+ }
160
+ rmSync(realTarget, { recursive: true, force: true });
161
+ return { deleted: dirName };
162
+ }