@scorehub/creator-outreach 1.8.1 → 1.10.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/install.js CHANGED
@@ -14,6 +14,24 @@ const WORKBUDDY_SHARE_URL = 'https://www.workbuddy.cn/work/launch/?sharecode=lNM
14
14
  const CREATOR_OUTREACH_SELF_BOOTSTRAP_COMMAND = 'npx -y @scorehub/creator-outreach@latest';
15
15
  const MCP_SERVER_SELF_CHECK_COMMAND = 'npx -y @scorehub/mcp-server@latest --self-check --json';
16
16
 
17
+ // 平台通道(对用户发布):上架到开放平台后,WorkBuddy 自行从 CDN 下载到 experts 市场,
18
+ // 与本包安装器无关。用户侧不需要本地副本。
19
+ const PLATFORM_PLUGIN_NAME = 'tiktok-creator-outreach';
20
+ const PLATFORM_MARKETPLACE = 'experts';
21
+ const PLATFORM_PLUGIN_ID = `${PLATFORM_PLUGIN_NAME}@${PLATFORM_MARKETPLACE}`;
22
+
23
+ // 本地自测通道(仅 `bootstrap --dev`):写进 my-experts 时换一套独立身份,
24
+ // 避免与平台版同名 —— 同名插件在会话里会撞 agent,同名技能会被先加载的一方静默占用。
25
+ const CUSTOM_EXPERT_MARKETPLACE = 'my-experts';
26
+ const DEV_PLUGIN_SUFFIX = '-dev';
27
+ const PLUGIN_NAME = `${PLATFORM_PLUGIN_NAME}${DEV_PLUGIN_SUFFIX}`;
28
+ const PLUGIN_ID = `${PLUGIN_NAME}@${CUSTOM_EXPERT_MARKETPLACE}`;
29
+ const DEV_DISPLAY_NAME = { en: 'ScoreHub Tiky (local dev)', zh: 'ScoreHub Tiky(本地自测)' };
30
+ const DEV_DESCRIPTION_SUFFIX = ' |本地自测副本';
31
+ const PLUGIN_METADATA_DIR = '.codebuddy-plugin';
32
+ const MARKETPLACE_MANIFEST_FILE = 'marketplace.json';
33
+ const DEFAULT_PLUGIN_SCOPE = 'user';
34
+
17
35
  const MCP_CONFIG = {
18
36
  type: 'stdio',
19
37
  command: 'npx',
@@ -36,12 +54,31 @@ function isSupportedNode(nodeVersion = process.versions.node) {
36
54
  function getPaths(homeDir) {
37
55
  const workBuddyDir = path.join(homeDir, '.workbuddy');
38
56
  const scorehubDir = path.join(workBuddyDir, 'scorehub');
57
+ const pluginsDir = path.join(workBuddyDir, 'plugins');
58
+ const customMarketplaceDir = path.join(
59
+ pluginsDir, 'marketplaces', CUSTOM_EXPERT_MARKETPLACE,
60
+ );
39
61
 
40
62
  return {
41
63
  workBuddyDir,
42
- workBuddyPluginDir: path.join(
43
- workBuddyDir, 'plugins', 'marketplaces', 'my-experts', 'plugins', 'tiktok-creator-outreach',
64
+ pluginsDir,
65
+ // 本地自测通道的注册清单:插件管理器把它当「输入」而不是「产物」,
66
+ // 目录写进去但清单没登记等于不存在(且不会自愈)。
67
+ marketplaceManifest: path.join(
68
+ customMarketplaceDir, PLUGIN_METADATA_DIR, MARKETPLACE_MANIFEST_FILE,
69
+ ),
70
+ // 本地自测通道的源码位目录(插件管理器物化时读取)。
71
+ workBuddyPluginDir: path.join(customMarketplaceDir, 'plugins', PLUGIN_NAME),
72
+ // 本地自测通道的版本化缓存根目录(加载位):实际被加载的代码只来自 <root>/<版本>。
73
+ pluginCacheRoot: path.join(pluginsDir, 'cache', CUSTOM_EXPERT_MARKETPLACE, PLUGIN_NAME),
74
+ // 平台通道的加载位:由 WorkBuddy 从开放平台下载并物化,本安装器只读取不写入。
75
+ platformPluginCacheRoot: path.join(
76
+ pluginsDir, 'cache', PLATFORM_MARKETPLACE, PLATFORM_PLUGIN_NAME,
44
77
  ),
78
+ // 安装登记表:本地自测通道的 installPath 与 version 是判定「实际生效版本」的来源。
79
+ installedPluginsRegistry: path.join(pluginsDir, 'installed_plugins.json'),
80
+ // 插件启用开关;本地自测通道是否被加载由此决定。
81
+ workBuddySettings: path.join(workBuddyDir, 'settings.json'),
45
82
  workBuddyMcpJson: path.join(workBuddyDir, 'mcp.json'),
46
83
  bootstrapState: path.join(scorehubDir, 'bootstrap-state.json'),
47
84
  bootstrapDir: scorehubDir,
@@ -136,14 +173,84 @@ function loadState(statePath) {
136
173
  }
137
174
  }
138
175
 
139
- function readInstalledPluginVersion(pluginDir) {
140
- const packagePath = path.join(pluginDir, 'package.json');
176
+ /**
177
+ * 源码位版本:本安装器写入的官方专家目录里的 plugin.json。
178
+ * 它只代表「本地已准备好哪个版本」,不代表 WorkBuddy 已经加载它。
179
+ */
180
+ function readSourcePluginVersion(paths) {
181
+ const manifestPath = path.join(paths.workBuddyPluginDir, PLUGIN_METADATA_DIR, 'plugin.json');
182
+ try {
183
+ const manifest = readJson(manifestPath);
184
+ return typeof manifest.version === 'string' ? manifest.version : null;
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * 实际生效版本:WorkBuddy 安装登记表里指向、且缓存目录真实存在的版本。
192
+ * 这是判断「用户手上跑的是哪一版」的唯一权威来源;插件代码只从加载位读取。
193
+ */
194
+ function readEffectivePluginVersion(paths, scope = DEFAULT_PLUGIN_SCOPE) {
195
+ let registry;
141
196
  try {
142
- const installed = readJson(packagePath);
143
- return typeof installed.version === 'string' ? installed.version : null;
197
+ registry = readJson(paths.installedPluginsRegistry);
144
198
  } catch {
145
199
  return null;
146
200
  }
201
+ const records = registry && registry.plugins ? registry.plugins[PLUGIN_ID] : null;
202
+ if (!Array.isArray(records)) return null;
203
+ const candidates = records.filter((record) => (
204
+ record
205
+ && record.scope === scope
206
+ && typeof record.installPath === 'string'
207
+ && fs.existsSync(record.installPath)
208
+ ));
209
+ if (candidates.length === 0) return null;
210
+ const chosen = candidates[candidates.length - 1];
211
+ const raw = typeof chosen.resolvedVersion === 'string' && chosen.resolvedVersion
212
+ ? chosen.resolvedVersion
213
+ : chosen.version;
214
+ return {
215
+ version: typeof raw === 'string' ? raw : null,
216
+ installPath: chosen.installPath,
217
+ };
218
+ }
219
+
220
+ function parseSemver(value) {
221
+ if (typeof value !== 'string') return null;
222
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(value.trim());
223
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
224
+ }
225
+
226
+ /** 仅在两侧都能解析为语义化版本时判定落后;无法比较时保守返回 false。 */
227
+ function isVersionBehind(installed, required) {
228
+ const left = parseSemver(installed);
229
+ const right = parseSemver(required);
230
+ if (!left || !right) return false;
231
+ for (let index = 0; index < left.length; index += 1) {
232
+ if (left[index] !== right[index]) return left[index] < right[index];
233
+ }
234
+ return false;
235
+ }
236
+
237
+ /**
238
+ * 本地自测通道的「源码位版本」与「加载位版本」不一致时的落差。
239
+ *
240
+ * 只在自测通道已安装(源码位有 plugin.json)时才有意义 —— 平台通道由 WorkBuddy
241
+ * 自己下载并物化,本安装器不参与,也无权替它判定生效状态。
242
+ */
243
+ function detectActivationGap(paths) {
244
+ const sourceVersion = readSourcePluginVersion(paths);
245
+ if (!sourceVersion) return null;
246
+ const effective = readEffectivePluginVersion(paths);
247
+ if (!effective || !effective.version) return null;
248
+ if (!isVersionBehind(effective.version, sourceVersion)) return null;
249
+ return {
250
+ effectiveVersion: effective.version,
251
+ requiredVersion: sourceVersion,
252
+ reason: `本地自测通道的加载位停在 ${effective.version},落后于源码位的 ${sourceVersion}:重跑 bootstrap --dev 会把缓存推进到新版本,重启 WorkBuddy 无效。`,
253
+ };
147
254
  }
148
255
 
149
256
  function buildMcpEnvironment(version, binDir) {
@@ -198,29 +305,474 @@ function mergeMcpConfig(configPath, serverConfig) {
198
305
  if (current.config === null) throw new Error(current.error);
199
306
  const config = current.config || {};
200
307
  config.mcpServers = config.mcpServers || {};
308
+ const changed = JSON.stringify(config.mcpServers.scorehub) !== JSON.stringify(serverConfig);
201
309
  config.mcpServers.scorehub = serverConfig;
202
310
  writeJsonAtomic(configPath, config);
311
+ return changed;
203
312
  }
204
313
 
205
- function copyPluginToStage(stageDir) {
314
+ /**
315
+ * 把插件源码复制进 stage 目录,并改写成本地自测身份。
316
+ *
317
+ * `sourceDir` 默认是**当前正在执行的 install.js 所在包根**。自测通道必须显式传入
318
+ * 「开发者仓库」那个根,不能靠默认值:agent 会话里的静默更新走的是
319
+ * `npx @scorehub/creator-outreach@latest`,此时 `__dirname` 指向 npm 缓存里的**发布版**,
320
+ * 用它刷新自测副本会把未发布的新功能直接覆盖回旧代码(见 `readLocalChannelSourceDir`)。
321
+ */
322
+ function copyPluginToStage(stageDir, sourceDir = __dirname) {
206
323
  const pluginDirs = ['.codebuddy-plugin', 'agents', 'skills', 'avatars'];
207
324
  const pluginFiles = ['package.json', 'README.md', 'README.en.md', 'install.js'];
208
325
  fs.mkdirSync(stageDir, { recursive: true });
209
326
  for (const dir of pluginDirs) {
210
- const source = path.join(__dirname, dir);
327
+ const source = path.join(sourceDir, dir);
211
328
  if (fs.existsSync(source)) fs.cpSync(source, path.join(stageDir, dir), { recursive: true });
212
329
  }
213
330
  for (const file of pluginFiles) {
214
- const source = path.join(__dirname, file);
331
+ const source = path.join(sourceDir, file);
215
332
  if (fs.existsSync(source)) fs.copyFileSync(source, path.join(stageDir, file));
216
333
  }
334
+ applyDevIdentity(stageDir);
335
+ }
336
+
337
+ function escapeRegExp(value) {
338
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
339
+ }
340
+
341
+ /** 取 markdown 的 YAML frontmatter 正文;没有则返回 null。 */
342
+ function readFrontmatter(filePath) {
343
+ const text = fs.readFileSync(filePath, 'utf8');
344
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
345
+ return match ? match[1] : null;
346
+ }
347
+
348
+ /** 递归列出目录下匹配后缀的文件(跳过 `.` 开头的目录)。 */
349
+ function listTextFiles(dir, pattern) {
350
+ if (!fs.existsSync(dir)) return [];
351
+ const files = [];
352
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
353
+ if (entry.name.startsWith('.')) continue;
354
+ const full = path.join(dir, entry.name);
355
+ if (entry.isDirectory()) files.push(...listTextFiles(full, pattern));
356
+ else if (entry.isFile() && pattern.test(entry.name)) files.push(full);
357
+ }
358
+ return files;
359
+ }
360
+
361
+ /** stage 内的技能 id:各 skills/<目录>/SKILL.md frontmatter 的 name。 */
362
+ function readSkillIds(stageDir) {
363
+ const skillsDir = path.join(stageDir, 'skills');
364
+ if (!fs.existsSync(skillsDir)) return [];
365
+ const ids = [];
366
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
367
+ if (!entry.isDirectory()) continue;
368
+ const skillFile = path.join(skillsDir, entry.name, 'SKILL.md');
369
+ if (!fs.existsSync(skillFile)) continue;
370
+ const frontmatter = readFrontmatter(skillFile);
371
+ const match = frontmatter && /^name:[ \t]*(\S+)[ \t]*$/m.exec(frontmatter);
372
+ if (match) ids.push(match[1]);
373
+ }
374
+ return ids;
375
+ }
376
+
377
+ /**
378
+ * 给自测副本的技能 id 加 dev 后缀。
379
+ *
380
+ * 技能 id 在 WorkBuddy 里是全局按 `name` 索引的,且**同名不会报错**:`SkillExtensionLoader`
381
+ * 是「先加载者占用、后者静默跳过」,实测同一台机器上平台版与本地副本会同时加载各自的
382
+ * 同名技能(日志里两侧都报 Loaded)。更要命的是 settings 里的 `enabledPlugins` **不**
383
+ * 阻止加载 —— 实测该值为 false 的插件照样加载技能。所以自测副本必须持有自己的技能 id,
384
+ * 否则两个 Tiky 在同一台机器上互相遮蔽,自测结果不可信。
385
+ *
386
+ * 只改 frontmatter 的 `name` 与 agent 文件里的 id 引用,**不改目录名**:plugin.json 的
387
+ * `skills` 数组用的是路径(`./skills/<目录>`),目录名保持不变就不用同步它。带
388
+ * `-dev` 后缀的 id 会被跳过,保证重复执行幂等。
389
+ */
390
+ function applyDevSkillIds(stageDir) {
391
+ const ids = readSkillIds(stageDir);
392
+ if (ids.length === 0) return [];
393
+ const targets = listTextFiles(path.join(stageDir, 'skills'), /\.md$/i)
394
+ .concat(listTextFiles(path.join(stageDir, 'agents'), /\.md$/i));
395
+ const renamed = [];
396
+ for (const id of ids) {
397
+ if (id.endsWith(DEV_PLUGIN_SUFFIX)) continue;
398
+ const pattern = new RegExp(`\\b${escapeRegExp(id)}\\b(?!${escapeRegExp(DEV_PLUGIN_SUFFIX)})`, 'g');
399
+ let hit = false;
400
+ for (const file of targets) {
401
+ const before = fs.readFileSync(file, 'utf8');
402
+ const after = before.replace(pattern, `${id}${DEV_PLUGIN_SUFFIX}`);
403
+ if (after !== before) {
404
+ fs.writeFileSync(file, after);
405
+ hit = true;
406
+ }
407
+ }
408
+ if (hit) renamed.push(`${id}${DEV_PLUGIN_SUFFIX}`);
409
+ }
410
+ return renamed;
411
+ }
412
+
413
+ /**
414
+ * 把拷贝出来的插件包改写成本地自测身份。
415
+ *
416
+ * 改 name / plugin / displayName / description 四个身份字段,并给技能 id 加 `-dev` 后缀
417
+ * (见 `applyDevSkillIds`)。**不动 agentName 和 agents/ 下的文件名**:专家运行时会按
418
+ * agentName 在自己的 plugin 目录里定位 agent markdown,改名会连带要求重命名文件与
419
+ * frontmatter,而 agentName 重名不会造成遮蔽(两侧各自的 agent 目录是独立的)。
420
+ *
421
+ * 返回被改名的技能 id 列表,供调用方与 `--check` 对照。
422
+ */
423
+ function applyDevIdentity(stageDir) {
424
+ const manifestPath = path.join(stageDir, PLUGIN_METADATA_DIR, 'plugin.json');
425
+ if (!fs.existsSync(manifestPath)) return [];
426
+ const skillIds = applyDevSkillIds(stageDir);
427
+ const manifest = readJson(manifestPath);
428
+ manifest.name = PLUGIN_NAME;
429
+ manifest.plugin = PLUGIN_NAME;
430
+ manifest.displayName = DEV_DISPLAY_NAME;
431
+ if (typeof manifest.description === 'string' && !manifest.description.includes(DEV_DESCRIPTION_SUFFIX)) {
432
+ manifest.description = `${manifest.description}${DEV_DESCRIPTION_SUFFIX}`;
433
+ }
434
+ writeJsonAtomic(manifestPath, manifest);
435
+ return skillIds;
436
+ }
437
+
438
+ /** 自测通道在注册清单里的条目;字段与平台 updateCustomExpertMarketplaceManifest 一致。 */
439
+ function buildMarketplaceEntry() {
440
+ let description = `${PLATFORM_PLUGIN_NAME} 本地自测副本`;
441
+ try {
442
+ const manifest = readJson(path.join(__dirname, PLUGIN_METADATA_DIR, 'plugin.json'));
443
+ if (typeof manifest.description === 'string' && manifest.description) {
444
+ description = `${manifest.description}${DEV_DESCRIPTION_SUFFIX}`;
445
+ }
446
+ } catch { /* 回退到默认描述 */ }
447
+ return { name: PLUGIN_NAME, source: `./plugins/${PLUGIN_NAME}`, description };
448
+ }
449
+
450
+ /**
451
+ * 在 my-experts 注册清单里 upsert 自测通道条目。
452
+ *
453
+ * WorkBuddy 从 5.5.x 起把该清单当扫描的**输入**:`doScanCustomExperts` 只返回清单里
454
+ * 已登记的条目,`reconcileCustomMarketplaceManifest` 也只做清理、**不主动注册磁盘裸
455
+ * 目录**。所以只写目录不写清单 = 插件在任何列表里都不存在,且不会自愈。
456
+ * 其余条目一律原样保留(同目录下可能还有别的自定义专家)。
457
+ */
458
+ function registerMarketplaceEntry(paths) {
459
+ let manifest = {
460
+ name: CUSTOM_EXPERT_MARKETPLACE,
461
+ description: `${CUSTOM_EXPERT_MARKETPLACE} marketplace (auto-generated)`,
462
+ plugins: [],
463
+ };
464
+ if (fs.existsSync(paths.marketplaceManifest)) {
465
+ try {
466
+ const parsed = readJson(paths.marketplaceManifest);
467
+ if (parsed && typeof parsed === 'object') {
468
+ manifest = {
469
+ name: typeof parsed.name === 'string' ? parsed.name : manifest.name,
470
+ description: typeof parsed.description === 'string' ? parsed.description : manifest.description,
471
+ plugins: Array.isArray(parsed.plugins) ? parsed.plugins.slice() : [],
472
+ };
473
+ }
474
+ } catch { /* 清单损坏时重建骨架,无法保留其余条目 */ }
475
+ }
476
+ const entry = buildMarketplaceEntry();
477
+ const index = manifest.plugins.findIndex((plugin) => (
478
+ plugin && (plugin.source === entry.source || plugin.name === entry.name)
479
+ ));
480
+ if (index >= 0 && JSON.stringify(manifest.plugins[index]) === JSON.stringify(entry)) return false;
481
+ if (index >= 0) manifest.plugins[index] = entry;
482
+ else manifest.plugins.push(entry);
483
+ writeJsonAtomic(paths.marketplaceManifest, manifest);
484
+ return true;
485
+ }
486
+
487
+ /** 旧版安装器写入的本地副本 id:与平台版同名,已废弃。 */
488
+ const LEGACY_LOCAL_PLUGIN_ID = `${PLATFORM_PLUGIN_NAME}@${CUSTOM_EXPERT_MARKETPLACE}`;
489
+
490
+ /** JSON 文件里是否存在某个键路径(任意一层缺失即 false;文件损坏也返回 false)。 */
491
+ function jsonHasKey(filePath, keyPath) {
492
+ if (!fs.existsSync(filePath)) return false;
493
+ try {
494
+ let cursor = readJson(filePath);
495
+ for (const key of keyPath) {
496
+ if (!cursor || typeof cursor !== 'object' || !(key in cursor)) return false;
497
+ cursor = cursor[key];
498
+ }
499
+ return true;
500
+ } catch {
501
+ return false;
502
+ }
503
+ }
504
+
505
+ /** 删掉 JSON 文件里的一个键;键不存在或文件不可读时返回 false,不改动文件。 */
506
+ function dropJsonKey(filePath, keyPath) {
507
+ if (!jsonHasKey(filePath, keyPath)) return false;
508
+ const root = readJson(filePath);
509
+ let cursor = root;
510
+ for (const key of keyPath.slice(0, -1)) cursor = cursor[key];
511
+ delete cursor[keyPath[keyPath.length - 1]];
512
+ writeJsonAtomic(filePath, root);
513
+ return true;
514
+ }
515
+
516
+ /** 目录是否是旧版安装器写的同名副本(按 plugin.json 身份判定,不误删别人的专家)。 */
517
+ function isLegacyLocalPluginDir(dir) {
518
+ const manifestPath = path.join(dir, PLUGIN_METADATA_DIR, 'plugin.json');
519
+ if (!fs.existsSync(manifestPath)) return false;
520
+ try {
521
+ const manifest = readJson(manifestPath);
522
+ return !!manifest && manifest.name === PLATFORM_PLUGIN_NAME;
523
+ } catch {
524
+ return false;
525
+ }
526
+ }
527
+
528
+ /** my-experts 注册清单里是否还有指定 name 的条目。 */
529
+ function marketplaceHasEntry(paths, name) {
530
+ if (!fs.existsSync(paths.marketplaceManifest)) return false;
531
+ try {
532
+ const manifest = readJson(paths.marketplaceManifest);
533
+ if (!manifest || !Array.isArray(manifest.plugins)) return false;
534
+ return manifest.plugins.some((plugin) => (
535
+ plugin && (plugin.name === name || plugin.source === `./plugins/${name}`)
536
+ ));
537
+ } catch {
538
+ return false;
539
+ }
540
+ }
541
+
542
+ /**
543
+ * 只读探测旧版安装器留下的同名本地副本残留。
544
+ *
545
+ * 旧实现只把插件目录拷进 `my-experts` 就结束了:不在市场清单里注册(列表里看不见、
546
+ * 也不会自愈),却照样参与扩展加载。实测同一台机器上 `@experts` 与 `@my-experts`
547
+ * 会**同时**加载同名技能,先加载者占用 —— 这正是「装了新版本却仍在跑旧代码」的成因之一。
548
+ * 自测副本改用独立 `-dev` 身份后,带同名残留只会污染加载,必须清掉。
549
+ */
550
+ function findLegacyLocalCopy(paths) {
551
+ // 身份重合时无法判断目录属于谁,宁可不动。
552
+ if (PLUGIN_NAME === PLATFORM_PLUGIN_NAME) return [];
553
+ const legacyMarketplacePlugins = path.dirname(paths.workBuddyPluginDir);
554
+ const legacyCacheRoot = path.dirname(paths.pluginCacheRoot);
555
+ const found = [];
556
+ if (isLegacyLocalPluginDir(path.join(legacyMarketplacePlugins, PLATFORM_PLUGIN_NAME))) found.push('plugin');
557
+ if (fs.existsSync(path.join(legacyCacheRoot, PLATFORM_PLUGIN_NAME))) found.push('cache');
558
+ if (jsonHasKey(paths.installedPluginsRegistry, ['plugins', LEGACY_LOCAL_PLUGIN_ID])) found.push('registry');
559
+ if (jsonHasKey(paths.workBuddySettings, ['enabledPlugins', LEGACY_LOCAL_PLUGIN_ID])) found.push('settings');
560
+ if (marketplaceHasEntry(paths, PLATFORM_PLUGIN_NAME)) found.push('manifest');
561
+ return found;
217
562
  }
218
563
 
219
- function replacePluginAtomic(pluginDir) {
564
+ /** 清掉 `findLegacyLocalCopy` 找到的残留,返回实际清掉的项。 */
565
+ function cleanupLegacyLocalCopy(paths) {
566
+ const removed = [];
567
+ for (const item of findLegacyLocalCopy(paths)) {
568
+ const target = item === 'plugin'
569
+ ? path.join(path.dirname(paths.workBuddyPluginDir), PLATFORM_PLUGIN_NAME)
570
+ : path.join(path.dirname(paths.pluginCacheRoot), PLATFORM_PLUGIN_NAME);
571
+ if (item === 'plugin' || item === 'cache') {
572
+ fs.rmSync(target, { recursive: true, force: true });
573
+ } else if (item === 'registry') {
574
+ dropJsonKey(paths.installedPluginsRegistry, ['plugins', LEGACY_LOCAL_PLUGIN_ID]);
575
+ } else if (item === 'settings') {
576
+ dropJsonKey(paths.workBuddySettings, ['enabledPlugins', LEGACY_LOCAL_PLUGIN_ID]);
577
+ } else if (item === 'manifest') {
578
+ const manifest = readJson(paths.marketplaceManifest);
579
+ manifest.plugins = manifest.plugins.filter((plugin) => !(
580
+ plugin && (plugin.name === PLATFORM_PLUGIN_NAME || plugin.source === `./plugins/${PLATFORM_PLUGIN_NAME}`)
581
+ ));
582
+ writeJsonAtomic(paths.marketplaceManifest, manifest);
583
+ }
584
+ removed.push(item);
585
+ }
586
+ return removed;
587
+ }
588
+
589
+ function copyDirSync(sourceDir, targetDir) {
590
+ fs.mkdirSync(targetDir, { recursive: true });
591
+ for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
592
+ if (entry.name === '.git') continue;
593
+ const source = path.join(sourceDir, entry.name);
594
+ const target = path.join(targetDir, entry.name);
595
+ if (entry.isDirectory()) copyDirSync(source, target);
596
+ else if (entry.isSymbolicLink()) fs.symlinkSync(fs.readlinkSync(source), target);
597
+ else if (entry.isFile()) fs.copyFileSync(source, target);
598
+ }
599
+ }
600
+
601
+ /**
602
+ * 把源码位物化成 <pluginCacheRoot>/<version>,让新代码真的进入加载位。
603
+ *
604
+ * 对齐插件管理器的 `materializePluginCache`:缓存目录名取 plugin.json 的 `version`
605
+ * (`resolvePluginCacheIdentity`),用 .staging-* → rename 原子发布。临时目录必须以
606
+ * `.` 开头,否则会被 `findCachedPluginByDirectory` 当成一个额外版本参与挑选。
607
+ */
608
+ function materializePluginCache(paths, version) {
609
+ const stageDir = path.join(paths.pluginCacheRoot, `.staging-${process.pid}-${Date.now()}`);
610
+ const backupDir = path.join(paths.pluginCacheRoot, `.backup-${process.pid}-${Date.now()}`);
611
+ const targetDir = path.join(paths.pluginCacheRoot, version);
612
+ fs.mkdirSync(paths.pluginCacheRoot, { recursive: true });
613
+ copyDirSync(paths.workBuddyPluginDir, stageDir);
614
+ try {
615
+ if (fs.existsSync(targetDir)) fs.renameSync(targetDir, backupDir);
616
+ fs.renameSync(stageDir, targetDir);
617
+ } catch (error) {
618
+ if (fs.existsSync(backupDir) && !fs.existsSync(targetDir)) fs.renameSync(backupDir, targetDir);
619
+ if (fs.existsSync(stageDir)) fs.rmSync(stageDir, { recursive: true, force: true });
620
+ throw error;
621
+ }
622
+ // WorkBuddy 可能正持有旧缓存目录,删不掉也不该让安装失败(`.backup-*` 不会被当成版本)。
623
+ try {
624
+ if (fs.existsSync(backupDir)) fs.rmSync(backupDir, { recursive: true, force: true });
625
+ } catch { /* 留待下次重跑时清理 */ }
626
+ return targetDir;
627
+ }
628
+
629
+ /** 在 installed_plugins.json 里 upsert 自测通道的 user 作用域记录(字段对齐平台实现)。 */
630
+ function recordInstalledPlugin(paths, version, installPath, sourceDir = null) {
631
+ let registry = {};
632
+ try {
633
+ const parsed = readJson(paths.installedPluginsRegistry);
634
+ if (parsed && typeof parsed === 'object') registry = parsed;
635
+ } catch { /* 登记表缺失或损坏时重建,避免坏文件把自测通道卡死 */ }
636
+ if (typeof registry.version !== 'number') registry.version = 2;
637
+ if (!registry.plugins || typeof registry.plugins !== 'object') registry.plugins = {};
638
+ const records = Array.isArray(registry.plugins[PLUGIN_ID]) ? registry.plugins[PLUGIN_ID] : [];
639
+ const isUserScope = (record) => (
640
+ !!record && record.scope === DEFAULT_PLUGIN_SCOPE && record.projectPath === undefined
641
+ );
642
+ const previous = records.find(isUserScope);
643
+ if (
644
+ previous
645
+ && previous.version === version
646
+ && previous.installPath === installPath
647
+ && (previous.sourceDir || null) === sourceDir
648
+ ) return false;
649
+ const now = new Date().toISOString();
650
+ const next = records.filter((record) => !isUserScope(record));
651
+ next.push({
652
+ scope: DEFAULT_PLUGIN_SCOPE,
653
+ installPath,
654
+ version,
655
+ // 记录自测副本的代码来源(开发者仓库根)。平台实现没有这个字段,白名单外的字段
656
+ // WorkBuddy 会原样忽略,所以加它不影响插件管理器读取本条记录。
657
+ ...(sourceDir ? { sourceDir } : {}),
658
+ installedAt: (previous && previous.installedAt) || now,
659
+ lastUpdated: now,
660
+ });
661
+ registry.plugins[PLUGIN_ID] = next;
662
+ writeJsonAtomic(paths.installedPluginsRegistry, registry);
663
+ return true;
664
+ }
665
+
666
+ /**
667
+ * 读回自测副本当初的代码来源(开发者仓库根)。目录已不存在时返回 null。
668
+ *
669
+ * 这是「本地安装用来测新功能」能成立的关键:静默更新(来自 npm 发布包)刷新自测通道时,
670
+ * 必须从**这里记录的仓库**取代码,而不是从它自己的 npm 缓存里取,否则一次 `--update`
671
+ * 就会把未发布的改动覆盖掉,且版本号看起来还正常。
672
+ */
673
+ function readLocalChannelSourceDir(paths) {
674
+ try {
675
+ const registry = readJson(paths.installedPluginsRegistry);
676
+ const records = registry && registry.plugins ? registry.plugins[PLUGIN_ID] : null;
677
+ if (!Array.isArray(records)) return null;
678
+ for (let i = records.length - 1; i >= 0; i -= 1) {
679
+ const record = records[i];
680
+ if (!record || record.scope !== DEFAULT_PLUGIN_SCOPE) continue;
681
+ const dir = record.sourceDir;
682
+ if (typeof dir === 'string' && dir && fs.existsSync(dir)) return dir;
683
+ }
684
+ return null;
685
+ } catch {
686
+ return null;
687
+ }
688
+ }
689
+
690
+ /** settings.json 里某个插件 id 的启用态;文件或键缺失时返回 null(未知)。 */
691
+ function isPluginEnabled(paths, pluginId) {
692
+ try {
693
+ const settings = readJson(paths.workBuddySettings);
694
+ const enabled = settings && settings.enabledPlugins;
695
+ if (!enabled || typeof enabled !== 'object') return null;
696
+ const value = enabled[pluginId];
697
+ return typeof value === 'boolean' ? value : null;
698
+ } catch {
699
+ return null;
700
+ }
701
+ }
702
+
703
+ /**
704
+ * 确保自测通道在 settings.json 里是启用态。
705
+ *
706
+ * 实测 `enabledPlugins` **不是**加载开关:该值为 false 的插件照样加载技能(本机日志里
707
+ * `@experts` 与 `@my-experts` 在 false 状态下都报过 Loaded)。它决定插件管理页显示的
708
+ * 启用态,所以自测副本仍要显式写 true —— 否则它在 UI 里看着是关的,容易被误判成没装好。
709
+ * 真正决定“加载哪一份代码”的是市场清单 + 缓存物化 + 技能 id 是否撞名。
710
+ */
711
+ function ensureLocalChannelEnabled(paths) {
712
+ let settings = {};
713
+ try {
714
+ const parsed = readJson(paths.workBuddySettings);
715
+ if (parsed && typeof parsed === 'object') settings = parsed;
716
+ } catch { /* settings.json 缺失或损坏时只建最小骨架,其余字段无法保留 */ }
717
+ if (!settings.enabledPlugins || typeof settings.enabledPlugins !== 'object') {
718
+ settings.enabledPlugins = {};
719
+ }
720
+ if (settings.enabledPlugins[PLUGIN_ID] === true) return false;
721
+ settings.enabledPlugins[PLUGIN_ID] = true;
722
+ writeJsonAtomic(paths.workBuddySettings, settings);
723
+ return true;
724
+ }
725
+
726
+ /** 缓存目录是否已经是本安装器要的那个身份+版本(对齐 isValidMaterializedPluginCache)。 */
727
+ function isMaterializedCacheValid(paths, version) {
728
+ try {
729
+ const manifest = readJson(
730
+ path.join(paths.pluginCacheRoot, version, PLUGIN_METADATA_DIR, 'plugin.json'),
731
+ );
732
+ return !!manifest && manifest.name === PLUGIN_NAME && manifest.version === version;
733
+ } catch {
734
+ return false;
735
+ }
736
+ }
737
+
738
+ /**
739
+ * 把自测通道整体推到「可见 + 可加载」:
740
+ * 写源码位之后 → 注册清单(列表可见)→ settings 启用(不被 disable 挡住)→ 物化缓存(新代码进加载位)→ 写登记表。
741
+ *
742
+ * **每次都重新物化**,不按版本跳过:自测循环里 `plugin.json` 的 version 常常不变,
743
+ * 按版本判定「缓存已是最新」会让加载位继续跑旧代码 —— 正是这套机制要消灭的失灵模式。
744
+ * 缓存目录名仍是 version,所以旧目录换新内容,登记表记录的 installPath 不变。
745
+ */
746
+ function activateLocalChannel(paths, { sourceDir = __dirname } = {}) {
747
+ const version = readSourcePluginVersion(paths);
748
+ if (!version) {
749
+ throw new Error(`${PLUGIN_NAME} 源码位缺少 ${PLUGIN_METADATA_DIR}/plugin.json,无法激活本地自测通道。`);
750
+ }
751
+ const registered = registerMarketplaceEntry(paths);
752
+ const enabledChanged = ensureLocalChannelEnabled(paths);
753
+ const installPath = materializePluginCache(paths, version);
754
+ if (!isMaterializedCacheValid(paths, version)) {
755
+ throw new Error(`${PLUGIN_NAME} 缓存物化后自检失败:${installPath} 的身份或版本不匹配。`);
756
+ }
757
+ const recorded = recordInstalledPlugin(paths, version, installPath, sourceDir);
758
+ return {
759
+ version,
760
+ installPath,
761
+ // 从加载位读回技能 id,既能对外汇报,也顺带验证 dev 后缀真的落到了缓存里。
762
+ skillIds: readSkillIds(installPath),
763
+ registered,
764
+ enabledChanged,
765
+ materialized: true,
766
+ recorded,
767
+ changed: true,
768
+ };
769
+ }
770
+
771
+ function replacePluginAtomic(pluginDir, sourceDir = __dirname) {
220
772
  const parentDir = path.dirname(pluginDir);
221
- const stageDir = path.join(parentDir, `.tiktok-creator-outreach.stage-${process.pid}-${Date.now()}`);
222
- const backupDir = path.join(parentDir, `.tiktok-creator-outreach.backup-${process.pid}-${Date.now()}`);
223
- copyPluginToStage(stageDir);
773
+ const stageDir = path.join(parentDir, `.${PLUGIN_NAME}.stage-${process.pid}-${Date.now()}`);
774
+ const backupDir = path.join(parentDir, `.${PLUGIN_NAME}.backup-${process.pid}-${Date.now()}`);
775
+ copyPluginToStage(stageDir, sourceDir);
224
776
  try {
225
777
  if (fs.existsSync(pluginDir)) fs.renameSync(pluginDir, backupDir);
226
778
  fs.renameSync(stageDir, pluginDir);
@@ -289,10 +841,43 @@ function detectStatus({ paths, state, nodeVersion }) {
289
841
  if (!state.consent_granted || !isManagedMcpConfig(mcp.config)) {
290
842
  return { status: 'uninitialized', reason: 'WorkBuddy MCP bootstrap has not been confirmed.' };
291
843
  }
292
- if (state.restart_required) return { status: 'restart_required', reason: 'WorkBuddy must be restarted to load the updated plugin and MCP configuration.' };
844
+ if (state.restart_required) return { status: 'restart_required', reason: 'WorkBuddy must be restarted to load the updated MCP configuration and plugin.' };
845
+ const gap = detectActivationGap(paths);
846
+ if (gap) return { status: 'activation_required', reason: gap.reason };
293
847
  return { status: 'ready', reason: null };
294
848
  }
295
849
 
850
+ /** 平台通道的有效版本:WorkBuddy 从开放平台下载并物化出的缓存目录名。本安装器只读。 */
851
+ function readPlatformChannelVersion(paths) {
852
+ try {
853
+ const versions = fs.readdirSync(paths.platformPluginCacheRoot, { withFileTypes: true })
854
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
855
+ .map((entry) => entry.name);
856
+ if (versions.length === 0) return null;
857
+ versions.sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
858
+ return versions[versions.length - 1];
859
+ } catch {
860
+ return null;
861
+ }
862
+ }
863
+
864
+ /** 本地自测通道是否在 settings.json 里被启用;文件或键缺失时返回 null(未知)。 */
865
+ function isLocalChannelEnabled(paths) {
866
+ return isPluginEnabled(paths, PLUGIN_ID);
867
+ }
868
+
869
+ /** 本地自测通道加载位里的技能 id;空数组表示自测通道尚未安装。 */
870
+ function readLocalChannelSkillIds(paths) {
871
+ const version = readSourcePluginVersion(paths);
872
+ if (!version) return [];
873
+ return readSkillIds(path.join(paths.pluginCacheRoot, version));
874
+ }
875
+
876
+ /** 平台通道(开放平台版本)是否被启用;本安装器只读,不写。 */
877
+ function isPlatformChannelEnabled(paths) {
878
+ return isPluginEnabled(paths, PLATFORM_PLUGIN_ID);
879
+ }
880
+
296
881
  function check({
297
882
  homeDir = os.homedir(),
298
883
  nodeVersion = process.versions.node,
@@ -302,6 +887,7 @@ function check({
302
887
  const paths = getPaths(homeDir);
303
888
  const state = loadState(paths.bootstrapState);
304
889
  const detected = detectStatus({ paths, state, nodeVersion });
890
+ const effective = readEffectivePluginVersion(paths);
305
891
  const lastCheckAt = state.last_check_at ? Date.parse(state.last_check_at) : Number.NaN;
306
892
  const mcp = readMcpConfig(paths.workBuddyMcpJson);
307
893
  const expectedLauncher = platform === 'win32'
@@ -316,9 +902,24 @@ function check({
316
902
  status: detected.status,
317
903
  reason: detected.reason,
318
904
  creator_outreach_version: pkg.version,
905
+ source_creator_outreach_version: readSourcePluginVersion(paths),
906
+ effective_creator_outreach_version: effective ? effective.version : null,
319
907
  installed_creator_outreach_version: state.creator_outreach_version,
908
+ // 平台通道(对用户发布):由 WorkBuddy 从开放平台下载并物化,本安装器只汇报。
909
+ platform_creator_outreach_version: readPlatformChannelVersion(paths),
910
+ platform_channel_enabled: isPlatformChannelEnabled(paths),
911
+ // 本地自测通道:只有 bootstrap --dev 会创建,用它自测未发布的新功能。
912
+ local_channel_version: readSourcePluginVersion(paths),
913
+ local_channel_enabled: isLocalChannelEnabled(paths),
914
+ local_channel_skill_ids: readLocalChannelSkillIds(paths),
915
+ // 自测副本的代码来源(开发者仓库根);静默更新会从它刷新,而不是从 npm 发布包。
916
+ local_channel_source_dir: readLocalChannelSourceDir(paths),
917
+ // 旧版安装器留下的同名副本;非空即说明该机器上曾有第二个 Tiky 参与加载。
918
+ legacy_local_copy: findLegacyLocalCopy(paths),
320
919
  consent_granted: state.consent_granted,
321
- mcp_configured: detected.status === 'ready' || detected.status === 'restart_required',
920
+ mcp_configured: detected.status === 'ready'
921
+ || detected.status === 'restart_required'
922
+ || detected.status === 'activation_required',
322
923
  restart_required: state.restart_required,
323
924
  update_due: updateDue,
324
925
  };
@@ -335,6 +936,18 @@ function assertInstallable({ homeDir, nodeVersion, platform }) {
335
936
  return { paths, launcher };
336
937
  }
337
938
 
939
+ /**
940
+ * 安装 / 初始化。
941
+ *
942
+ * `dev: false`(默认,用户路径):只写 MCP 配置。插件的发布版由 WorkBuddy 从开放平台
943
+ * 下载,本安装器不碰,也不会在 my-experts 里留一个跟平台版打架的同名副本。
944
+ *
945
+ * `dev: true`(`bootstrap --dev`,自测路径):额外把本地源码写进 my-experts 的自测身份
946
+ * 目录(插件名与技能 id 都带 `-dev` 后缀),并整体激活(注册清单 + 启用态 + 物化缓存 +
947
+ * 写登记表),让未发布的新功能立刻可加载。
948
+ *
949
+ * 两条路径都会顺手清掉旧版安装器留下的同名 `my-experts` 残留(见 `findLegacyLocalCopy`)。
950
+ */
338
951
  function install({
339
952
  homeDir = os.homedir(),
340
953
  nodeVersion = process.versions.node,
@@ -342,55 +955,111 @@ function install({
342
955
  markConsent = true,
343
956
  replacePlugin = replacePluginAtomic,
344
957
  platform = process.platform,
958
+ dev = false,
959
+ devSourceDir = null,
345
960
  } = {}) {
346
961
  const { paths, launcher } = assertInstallable({ homeDir, nodeVersion, platform });
962
+ // 自测副本的代码来源:显式参数(CLI 本地运行 = 仓库根)→ 登记表记录 → 当前包根。
963
+ // 中间的「登记表记录」保证来自 npm 发布包的静默更新也是从开发者仓库刷新,而不是自我覆盖。
964
+ const devSource = dev ? (devSourceDir || readLocalChannelSourceDir(paths) || __dirname) : __dirname;
347
965
  const env = buildMcpEnvironment(pkg.version, launcher.binDir);
348
966
  const mcpCheck = runMcpSelfCheck({ launcher, env, selfCheck });
349
967
  const mcpSnapshot = snapshotFile(paths.workBuddyMcpJson);
350
968
  const stateSnapshot = snapshotFile(paths.bootstrapState);
969
+ // 自测通道会额外写这三处,失败时一并回滚,避免留下半激活状态。
970
+ const marketplaceSnapshot = snapshotFile(paths.marketplaceManifest);
971
+ const registrySnapshot = snapshotFile(paths.installedPluginsRegistry);
972
+ const settingsSnapshot = snapshotFile(paths.workBuddySettings);
351
973
  let pluginBackup = null;
352
974
  let pluginReplaced = false;
975
+ let status = 'restart_required';
976
+ let reason = null;
977
+ let localChannel = null;
978
+ let legacyRemoved = [];
353
979
  try {
354
- mergeMcpConfig(paths.workBuddyMcpJson, buildMcpConfig(pkg.version, launcher));
355
- pluginBackup = replacePlugin(paths.workBuddyPluginDir);
356
- pluginReplaced = true;
980
+ const mcpChanged = mergeMcpConfig(paths.workBuddyMcpJson, buildMcpConfig(pkg.version, launcher));
981
+ // 先清旧版同名残留,再写自测通道:残留不清掉就会和自测副本一起被加载、互相遮蔽。
982
+ legacyRemoved = cleanupLegacyLocalCopy(paths);
983
+ if (dev) {
984
+ pluginBackup = replacePlugin(paths.workBuddyPluginDir, devSource);
985
+ pluginReplaced = true;
986
+ localChannel = activateLocalChannel(paths, { sourceDir: devSource });
987
+ }
988
+
989
+ // 源码位写入后才可能出现激活落差:加载位仍指向缓存里的旧版本。
990
+ // 自测通道刚刚自己把缓存推进过,不存在落差;只有用户路径(dev=false)才可能残留。
991
+ const gap = dev ? null : detectActivationGap(paths);
992
+ if (mcpChanged || (localChannel && localChannel.changed)) status = 'restart_required';
993
+ else if (gap) status = 'activation_required';
994
+ else status = 'ready';
995
+ reason = status === 'activation_required' ? gap.reason : null;
357
996
 
358
997
  const state = loadState(paths.bootstrapState);
359
998
  state.consent_granted = markConsent || state.consent_granted;
360
- state.status = 'restart_required';
999
+ state.status = status;
361
1000
  state.creator_outreach_version = pkg.version;
362
1001
  state.observed_mcp_server_version = mcpCheck.server_version || null;
363
1002
  state.last_check_at = new Date().toISOString();
364
1003
  state.last_error = null;
365
- state.restart_required = true;
1004
+ state.restart_required = status === 'restart_required';
366
1005
  writeJsonAtomic(paths.bootstrapState, state);
367
1006
  } catch (error) {
368
1007
  if (pluginReplaced) restorePlugin(paths.workBuddyPluginDir, pluginBackup);
369
1008
  restoreFile(paths.workBuddyMcpJson, mcpSnapshot);
370
1009
  restoreFile(paths.bootstrapState, stateSnapshot);
1010
+ restoreFile(paths.marketplaceManifest, marketplaceSnapshot);
1011
+ restoreFile(paths.installedPluginsRegistry, registrySnapshot);
1012
+ restoreFile(paths.workBuddySettings, settingsSnapshot);
371
1013
  throw error;
372
1014
  }
373
1015
  if (pluginBackup && fs.existsSync(pluginBackup)) fs.rmSync(pluginBackup, { recursive: true, force: true });
374
- return { status: 'restart_required', mcp_server_version: mcpCheck.server_version || null };
1016
+ return {
1017
+ status,
1018
+ reason,
1019
+ mcp_server_version: mcpCheck.server_version || null,
1020
+ ...(legacyRemoved.length ? { legacy_local_copy_removed: legacyRemoved } : {}),
1021
+ ...(dev ? {
1022
+ local_channel_version: localChannel ? localChannel.version : null,
1023
+ local_channel_install_path: localChannel ? localChannel.installPath : null,
1024
+ local_channel_skill_ids: localChannel ? localChannel.skillIds : [],
1025
+ local_channel_source_dir: devSource,
1026
+ local_channel_registered: localChannel ? localChannel.registered : false,
1027
+ local_channel_enabled: isLocalChannelEnabled(paths),
1028
+ platform_channel_enabled: isPlatformChannelEnabled(paths),
1029
+ } : {}),
1030
+ };
375
1031
  }
376
1032
 
377
1033
  function markReady({ homeDir = os.homedir(), nodeVersion = process.versions.node } = {}) {
378
1034
  const paths = getPaths(homeDir);
379
1035
  const state = loadState(paths.bootstrapState);
380
1036
  const detected = detectStatus({ paths, state: Object.assign({}, state, { restart_required: false }), nodeVersion });
381
- if (detected.status !== 'ready') throw new Error(detected.reason || `Cannot mark bootstrap as ready: ${detected.status}`);
382
- state.status = 'ready';
1037
+ const acceptable = detected.status === 'ready' || detected.status === 'activation_required';
1038
+ if (!acceptable) throw new Error(detected.reason || `Cannot mark bootstrap as ready: ${detected.status}`);
1039
+ const activationGap = detected.status === 'activation_required';
1040
+ state.status = detected.status;
383
1041
  state.restart_required = false;
384
1042
  state.last_error = null;
385
1043
  writeJsonAtomic(paths.bootstrapState, state);
386
- return { status: 'ready' };
1044
+ return activationGap
1045
+ ? { status: 'activation_required', reason: detected.reason }
1046
+ : { status: 'ready' };
387
1047
  }
388
1048
 
1049
+ /**
1050
+ * 静默更新。
1051
+ *
1052
+ * 自测通道一旦装过就一直跟着刷新,并且**必须放在 `restart_required` 早退之前**:
1053
+ * 否则「上一轮装了东西、还在等重启」会把代码刷新整个挡掉,用户重启后仍跑旧代码,
1054
+ * 而且因为源码位与加载位版本相同,也不会再出现 `activation_required` 提示。
1055
+ */
389
1056
  function update({
390
1057
  homeDir = os.homedir(),
391
1058
  nodeVersion = process.versions.node,
392
1059
  selfCheck = defaultMcpSelfCheck,
393
1060
  platform = process.platform,
1061
+ dev = false,
1062
+ devSourceDir = null,
394
1063
  } = {}) {
395
1064
  const paths = getPaths(homeDir);
396
1065
  const state = loadState(paths.bootstrapState);
@@ -399,22 +1068,42 @@ function update({
399
1068
  if (detected.status === 'repair_required' || detected.status === 'uninitialized') {
400
1069
  return { status: detected.status, reason: detected.reason };
401
1070
  }
402
- if (state.restart_required) return { status: 'restart_required', reason: 'WorkBuddy must be restarted to load the updated plugin and MCP configuration.' };
1071
+ // 自测通道:源码位存在就说明装过;`activation_required`(加载位落后)也由这一次安装自愈。
1072
+ // 注意这里**不传** devSourceDir:静默更新可能来自 npm 发布包,刷新时必须回到登记表记录的
1073
+ // 开发者仓库,否则会把未发布的改动覆盖成发布版代码。
1074
+ const devChannelInstalled = !!readSourcePluginVersion(paths);
1075
+ if (dev || devChannelInstalled) {
1076
+ return install({
1077
+ homeDir, nodeVersion, selfCheck, markConsent: false, platform, dev: true, devSourceDir,
1078
+ });
1079
+ }
1080
+ if (state.restart_required) return { status: 'restart_required', reason: 'WorkBuddy must be restarted to load the updated MCP configuration and plugin.' };
403
1081
  const mcp = readMcpConfig(paths.workBuddyMcpJson);
404
1082
  const expectedLauncher = platform === 'win32'
405
1083
  ? resolveWorkBuddyLauncher(paths.workBuddyDir, platform)
406
1084
  : undefined;
407
1085
  const launcherMigrationDue = mcp.config !== null
408
1086
  && needsMcpLauncherMigration(mcp.config, platform, expectedLauncher);
409
- if (readInstalledPluginVersion(paths.workBuddyPluginDir) === pkg.version && !launcherMigrationDue) {
1087
+ // 用户路径没有任何本地插件可刷新:只有 Windows 启动器需要迁移、或旧版同名副本还留着
1088
+ // 需要清理时,才值得走一次完整安装。
1089
+ const legacyPending = findLegacyLocalCopy(paths).length > 0;
1090
+ if (!launcherMigrationDue && !legacyPending) {
410
1091
  state.status = 'ready';
411
1092
  state.creator_outreach_version = pkg.version;
412
1093
  state.last_check_at = new Date().toISOString();
413
1094
  state.last_error = null;
414
1095
  writeJsonAtomic(paths.bootstrapState, state);
415
- return { status: 'ready', creator_outreach_version: pkg.version };
1096
+ return {
1097
+ status: 'ready',
1098
+ creator_outreach_version: pkg.version,
1099
+ local_channel_version: readSourcePluginVersion(paths),
1100
+ local_channel_enabled: isLocalChannelEnabled(paths),
1101
+ platform_channel_enabled: isPlatformChannelEnabled(paths),
1102
+ };
416
1103
  }
417
- return install({ homeDir, nodeVersion, selfCheck, markConsent: false, platform });
1104
+ return install({
1105
+ homeDir, nodeVersion, selfCheck, markConsent: false, platform, dev: false,
1106
+ });
418
1107
  }
419
1108
 
420
1109
  function printJson(value) {
@@ -427,13 +1116,17 @@ function runCli(argv = process.argv.slice(2)) {
427
1116
  homeDir: process.env.SCOREHUB_BOOTSTRAP_HOME || os.homedir(),
428
1117
  nodeVersion: process.versions.node,
429
1118
  };
1119
+ const dev = argv.includes('--dev');
1120
+ // 只有显式 `--dev`(本地仓库运行)才把「当前包根」当作自测通道的代码来源。发布包里的
1121
+ // install.js 被静默更新调用时不得覆盖它,否则会从 npm 缓存反向刷新自测副本。
1122
+ const devSourceDir = dev ? __dirname : null;
430
1123
  try {
431
1124
  let result;
432
1125
  if (action === 'bootstrap' && argv.includes('--check')) result = check(options);
433
- else if (action === 'bootstrap' && argv.includes('--install')) result = install(options);
434
- else if (action === 'bootstrap' && argv.includes('--update')) result = update(options);
1126
+ else if (action === 'bootstrap' && argv.includes('--install')) result = install({ ...options, dev, devSourceDir });
1127
+ else if (action === 'bootstrap' && argv.includes('--update')) result = update({ ...options, dev, devSourceDir });
435
1128
  else if (action === 'bootstrap' && argv.includes('--mark-ready')) result = markReady(options);
436
- else result = install(options);
1129
+ else result = install({ ...options, dev, devSourceDir });
437
1130
  printJson(result);
438
1131
  return result.status === 'repair_required' ? 1 : 0;
439
1132
  } catch (error) {
@@ -447,20 +1140,49 @@ if (require.main === module) process.exitCode = runCli();
447
1140
 
448
1141
  module.exports = {
449
1142
  CREATOR_OUTREACH_SELF_BOOTSTRAP_COMMAND,
1143
+ CUSTOM_EXPERT_MARKETPLACE,
1144
+ DEV_DISPLAY_NAME,
450
1145
  MCP_SERVER_SELF_CHECK_COMMAND,
451
1146
  MIN_NODE_MAJOR,
1147
+ PLATFORM_MARKETPLACE,
1148
+ PLATFORM_PLUGIN_ID,
1149
+ PLATFORM_PLUGIN_NAME,
1150
+ PLUGIN_ID,
1151
+ PLUGIN_NAME,
452
1152
  UPDATE_INTERVAL_MS,
453
1153
  WORKBUDDY_SHARE_URL,
1154
+ activateLocalChannel,
1155
+ applyDevIdentity,
1156
+ applyDevSkillIds,
1157
+ buildMarketplaceEntry,
454
1158
  check,
1159
+ cleanupLegacyLocalCopy,
455
1160
  defaultMcpSelfCheck,
1161
+ detectActivationGap,
456
1162
  detectStatus,
1163
+ ensureLocalChannelEnabled,
1164
+ findLegacyLocalCopy,
457
1165
  getNodeMajorVersion,
458
1166
  getPaths,
459
1167
  install,
1168
+ isLocalChannelEnabled,
460
1169
  isManagedMcpConfig,
1170
+ isMaterializedCacheValid,
1171
+ isPlatformChannelEnabled,
1172
+ isPluginEnabled,
461
1173
  isSupportedNode,
1174
+ isVersionBehind,
462
1175
  markReady,
1176
+ materializePluginCache,
463
1177
  needsMcpLauncherMigration,
1178
+ readEffectivePluginVersion,
1179
+ readLocalChannelSkillIds,
1180
+ readLocalChannelSourceDir,
1181
+ readPlatformChannelVersion,
1182
+ readSkillIds,
1183
+ readSourcePluginVersion,
1184
+ recordInstalledPlugin,
1185
+ registerMarketplaceEntry,
464
1186
  resolveWorkBuddyLauncher,
465
1187
  runCli,
466
1188
  update,