@max-null/dsh-plugin-center 0.2.13 → 0.2.14

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/dist/engine.d.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  import { Service, type Context } from '@deepseek-ai/cordis';
9
9
  import { type InstalledPlugin } from './meta.ts';
10
10
  import { type MarketPlugin } from './market.ts';
11
- import { type PnpmResult, type UpdateDigest } from './update.ts';
11
+ import { type LlmUpdatePackage, type PnpmResult, type UpdateDigest } from './update.ts';
12
+ import { type LlmLogRecord } from './llm-log.ts';
12
13
  declare module '@deepseek-ai/cordis' {
13
14
  interface Context {
14
15
  /** The plugin-center engine (provided by this package's host half). */
@@ -75,6 +76,11 @@ export declare class PluginCenterEngine extends Service {
75
76
  readVersions(): Promise<Record<string, string>>;
76
77
  /** Persist the read-mark (best-effort; a quota/IO failure just loses the mark). */
77
78
  markRead(versions: Record<string, string>): Promise<void>;
79
+ /** 弹窗当天已展示标记(2026-08-27):host 文件(DSH web 端口随机,
80
+ * localStorage 按 origin 隔离会丢;文件侧稳定)。 */
81
+ private get whatsNewDailyPath();
82
+ whatsNewDaily(): Promise<string>;
83
+ markWhatsNewDaily(day: string): Promise<void>;
78
84
  /** Current DSH version, read from the installed @deepseek-ai/dsh package. */
79
85
  dshVersion(): Promise<string>;
80
86
  /** Non-group Loader entries, cross-matched with market categories. */
@@ -105,6 +111,20 @@ export declare class PluginCenterEngine extends Service {
105
111
  * - 官方 dsh web(无消费方)→ 仿社区市场返回可复制 CLI 指令;
106
112
  * 3. 非锁失败(网络/版本)→ 原样报错。 */
107
113
  update(name: string, version: string): Promise<PnpmResult>;
114
+ /** LLM 驱动更新准备:采集信息包供确认面板/会话 prompt 使用(2026-08-28)。
115
+ * 只读采集(npm/GitHub/本地 package.json),不执行任何安装——执行由 LLM
116
+ * Agent 在「插件更新」会话中按 skill 决策后完成。 */
117
+ llmUpdatePrepare(name: string): Promise<LlmUpdatePackage | null>;
118
+ /** 追加一条 LLM 更新动作日志(JSONL,供 client 轮询结果展示)。 */
119
+ appendLlmUpdateLog(entry: {
120
+ name: string;
121
+ action: string;
122
+ detail: string;
123
+ status: 'pending' | 'running' | 'success' | 'failed';
124
+ }): Promise<void>;
125
+ /** 读取某个插件最近一条 LLM 更新动作(JSONL 逆序找 name 匹配);
126
+ * 无记录返回 null。client 轮询据此做三态(进行中/成功/失败)。 */
127
+ readLlmUpdateResult(name: string): Promise<LlmLogRecord | null>;
108
128
  /** 串行执行一次 pnpm 操作并失效缓存(无论成败都放行链条后续任务)。 */
109
129
  private enqueuePnpm;
110
130
  /** Temporary diagnostics for the empty-update bug; removed once root-caused. */
package/dist/engine.js CHANGED
@@ -12,9 +12,10 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
12
12
  import { existsSync, readFileSync } from 'node:fs';
13
13
  import { buildInstalledPlugin, clearPackageCache, resolvePackage } from "./meta.js";
14
14
  import { fetchAwesomePluginsJson, fetchDshMarketPlugins, fetchOhMyDshOverrides, fetchOhMyDshPlugins, mapConcurrent, mergePlugins, } from "./market.js";
15
- import { detectUpdate, installPlugin, preparePluginUpdate, updatePlugin } from "./update.js";
15
+ import { detectUpdate, installPlugin, preparePluginUpdate, updatePlugin, buildLlmPackage, dependencySpecifierOf, isSameUpstream, sourceOf, } from "./update.js";
16
16
  import { reconcileInstalled, readDependencyKeys } from "./reconcile.js";
17
17
  import { readDisabledState, setDisabled, escapeRegExp } from "./toggle.js";
18
+ import { appendLlmLog, readLlmLogLatest } from "./llm-log.js";
18
19
  /** Runtime mirror of cordis FiberState (a cross-package const enum). */
19
20
  const FIBER_PHASE = {
20
21
  0: 'pending',
@@ -135,6 +136,30 @@ export class PluginCenterEngine extends Service {
135
136
  }
136
137
  catch { /* best-effort */ }
137
138
  }
139
+ /** 弹窗当天已展示标记(2026-08-27):host 文件(DSH web 端口随机,
140
+ * localStorage 按 origin 隔离会丢;文件侧稳定)。 */
141
+ get whatsNewDailyPath() {
142
+ return join(this.dshHome, 'plugin-center-whatsnew-daily.json');
143
+ }
144
+ async whatsNewDaily() {
145
+ try {
146
+ const parsed = JSON.parse(await readFile(this.whatsNewDailyPath, 'utf8'));
147
+ return typeof parsed === 'object' && parsed !== null && typeof parsed.day === 'string'
148
+ ? parsed.day
149
+ : '';
150
+ }
151
+ catch {
152
+ return '';
153
+ }
154
+ }
155
+ async markWhatsNewDaily(day) {
156
+ try {
157
+ const path = this.whatsNewDailyPath;
158
+ await mkdir(dirname(path), { recursive: true });
159
+ await writeFile(path, JSON.stringify({ day, at: new Date().toISOString() }), 'utf8');
160
+ }
161
+ catch { /* best-effort */ }
162
+ }
138
163
  /** Current DSH version, read from the installed @deepseek-ai/dsh package. */
139
164
  async dshVersion() {
140
165
  const resolved = await resolvePackage(this.baseUrl, '@deepseek-ai/dsh');
@@ -310,7 +335,22 @@ export class PluginCenterEngine extends Service {
310
335
  return hit.digests;
311
336
  const [installed, localDsh] = await Promise.all([this.listInstalled(), this.dshVersion()]);
312
337
  const candidates = installed.filter(p => UPDATABLE.has(p.source) && p.version !== null);
313
- const digests = await Promise.all(candidates.map(p => detectUpdate(p.name, p.version, p.repoUrl, p.compatRange, localDsh, sinceIso)));
338
+ const digests = await Promise.all(candidates.map(async (p) => {
339
+ // 同名异源保护(2026-08-29):本地自定义来源(依赖声明 vendor/tarball/
340
+ // local-file)时,校验 npm 同名包是否同一上游——不一致(如 dream12347
341
+ // 定制 vs hkkz9522 独立同名项目)排除出更新列表,防误报与覆盖定制。
342
+ // 无法判定(任一侧缺 repository,如 dsh-session-manager 0.2.2 作者未
343
+ // 声明)同样排除:宁少报不漏报——漏报只少一个提示,误报是 LLM 白跑 +
344
+ // 用户被误导;人工核对入口见 InstalledView/LLM 会话。
345
+ const spec = dependencySpecifierOf(this.baseUrl, p.name);
346
+ const src = spec === null ? 'npm' : sourceOf(spec, this.baseUrl);
347
+ if (src === 'vendor' || src === 'tarball' || src === 'local-file') {
348
+ const same = await isSameUpstream(p.repoUrl, p.name);
349
+ if (same !== true)
350
+ return null;
351
+ }
352
+ return detectUpdate(p.name, p.version, p.repoUrl, p.compatRange, localDsh, sinceIso);
353
+ }));
314
354
  this.updatesCache = { since: sinceIso, at: now, digests: digests.filter((d) => d !== null) };
315
355
  return this.updatesCache.digests;
316
356
  }
@@ -357,6 +397,29 @@ export class PluginCenterEngine extends Service {
357
397
  const profileName = basename(this.baseUrl) || 'web';
358
398
  return { ok: true, detail: '', durationMs: direct.durationMs, command: `dsh plugin --profile ${profileName} add ${name}@${version}` };
359
399
  }
400
+ /** LLM 驱动更新准备:采集信息包供确认面板/会话 prompt 使用(2026-08-28)。
401
+ * 只读采集(npm/GitHub/本地 package.json),不执行任何安装——执行由 LLM
402
+ * Agent 在「插件更新」会话中按 skill 决策后完成。 */
403
+ async llmUpdatePrepare(name) {
404
+ // 从已安装列表找该插件的元数据(版本/repo/兼容范围)
405
+ const installed = await this.listInstalled();
406
+ const p = installed.find(i => i.name === name);
407
+ if (p === undefined || p.version === null)
408
+ return null;
409
+ const localDsh = await this.dshVersion();
410
+ const since = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString();
411
+ // detectUpdate 同款采集参数,复用 buildLlmPackage
412
+ return buildLlmPackage(p.name, p.version, p.repoUrl, p.compatRange, localDsh, since, this.baseUrl);
413
+ }
414
+ /** 追加一条 LLM 更新动作日志(JSONL,供 client 轮询结果展示)。 */
415
+ async appendLlmUpdateLog(entry) {
416
+ await appendLlmLog(entry);
417
+ }
418
+ /** 读取某个插件最近一条 LLM 更新动作(JSONL 逆序找 name 匹配);
419
+ * 无记录返回 null。client 轮询据此做三态(进行中/成功/失败)。 */
420
+ async readLlmUpdateResult(name) {
421
+ return readLlmLogLatest(name);
422
+ }
360
423
  /** 串行执行一次 pnpm 操作并失效缓存(无论成败都放行链条后续任务)。 */
361
424
  enqueuePnpm(op) {
362
425
  const run = this.pnpmChain.then(async () => {
@@ -0,0 +1,25 @@
1
+ export type LlmLogStatus = 'pending' | 'running' | 'success' | 'failed';
2
+ export interface LlmLogEntry {
3
+ name: string;
4
+ action: string;
5
+ detail: string;
6
+ status: LlmLogStatus;
7
+ }
8
+ /** 最近一条记录(JSONL 逆序找 name 匹配;坏行跳过;无记录/文件缺失 → null)。 */
9
+ export interface LlmLogRecord {
10
+ at: number;
11
+ name: string;
12
+ action: string;
13
+ detail: string;
14
+ status: LlmLogStatus;
15
+ }
16
+ /** 日志文件路径(DSH_HOME 未设时回退 ~/.dsh)。 */
17
+ export declare function llmLogPath(dshHome?: string | undefined): string;
18
+ /** 追加一条记录(目录自动创建;失败静默——日志绝不阻断主流程)。 */
19
+ export declare function appendLlmLog(entry: LlmLogEntry, dshHome?: string | undefined): Promise<void>;
20
+ /** 解析一行 JSON:合法且 name 匹配 → 记录;其他 → null。 */
21
+ export declare function parseLlmLogLine(line: string, name: string): LlmLogRecord | null;
22
+ /** 读取某插件最近一条记录(逆序;文件缺失/全坏行 → null)。 */
23
+ export declare function readLlmLogLatest(name: string, dshHome?: string | undefined): Promise<LlmLogRecord | null>;
24
+ /** 按文本扫最后一行含 name 的 JSON(提取解析出的 attr——测试断言用)。 */
25
+ export declare function extractLlmLogField(line: string): Record<string, unknown> | null;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * LLM 更新动作日志(JSONL)——纯文件读写模块(无 engine 依赖,可单测)。
3
+ * 文件位置:$DSH_HOME/plugin-center/llm-update-log.jsonl
4
+ * 行格式:{"at":number,"name":string,"action":string,"detail":string,"status":...}
5
+ * client 侧轮询 llm-update.result 依赖此文件做三态(执行中/成功/失败)。
6
+ */
7
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
8
+ import { dirname, join } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ /** 日志文件路径(DSH_HOME 未设时回退 ~/.dsh)。 */
11
+ export function llmLogPath(dshHome = process.env.DSH_HOME) {
12
+ return join(dshHome ?? join(homedir(), '.dsh'), 'plugin-center', 'llm-update-log.jsonl');
13
+ }
14
+ /** 追加一条记录(目录自动创建;失败静默——日志绝不阻断主流程)。 */
15
+ export async function appendLlmLog(entry, dshHome) {
16
+ try {
17
+ const file = llmLogPath(dshHome);
18
+ await mkdir(dirname(file), { recursive: true });
19
+ const line = JSON.stringify({ at: Date.now(), ...entry }) + '\n';
20
+ await appendFile(file, line, 'utf8');
21
+ }
22
+ catch { /* best-effort */ }
23
+ }
24
+ /** 解析一行 JSON:合法且 name 匹配 → 记录;其他 → null。 */
25
+ export function parseLlmLogLine(line, name) {
26
+ try {
27
+ const rec = JSON.parse(line);
28
+ if (rec.name !== name)
29
+ return null;
30
+ const status = rec.status === 'pending' || rec.status === 'running' || rec.status === 'success' || rec.status === 'failed' ? rec.status : 'running';
31
+ return {
32
+ at: typeof rec.at === 'number' ? rec.at : 0,
33
+ name,
34
+ action: typeof rec.action === 'string' ? rec.action : '',
35
+ detail: typeof rec.detail === 'string' ? rec.detail : '',
36
+ status,
37
+ };
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ /** 读取某插件最近一条记录(逆序;文件缺失/全坏行 → null)。 */
44
+ export async function readLlmLogLatest(name, dshHome) {
45
+ try {
46
+ const text = await readFile(llmLogPath(dshHome), 'utf8');
47
+ const lines = text.split('\n');
48
+ for (let i = lines.length - 1; i >= 0; i--) {
49
+ const line = lines[i]?.trim();
50
+ if (line === undefined || line === '')
51
+ continue;
52
+ const rec = parseLlmLogLine(line, name);
53
+ if (rec !== null)
54
+ return rec;
55
+ }
56
+ return null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /** 按文本扫最后一行含 name 的 JSON(提取解析出的 attr——测试断言用)。 */
63
+ export function extractLlmLogField(line) {
64
+ try {
65
+ return JSON.parse(line);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
package/dist/rpc.js CHANGED
@@ -54,6 +54,37 @@ export class PluginCenterRpc extends Service {
54
54
  },
55
55
  };
56
56
  }
57
+ case 'llm-update.prepare': {
58
+ // LLM 驱动更新信息包(只读采集):来源/版本/兼容/变更,供确认面板与
59
+ // 会话 prompt。执行由 LLM Agent 在插件更新会话中按 skill 决策完成。
60
+ const name = payload?.name;
61
+ if (typeof name !== 'string' || name === '')
62
+ return internal('llm-update.prepare: name is required');
63
+ const pkg = await ctx.pluginCenter.llmUpdatePrepare(name);
64
+ if (pkg === null)
65
+ return internal('llm-update.prepare: 插件不存在或版本未知');
66
+ return { ok: true, value: pkg };
67
+ }
68
+ case 'llm-update.log': {
69
+ // 追加一条 LLM 更新动作日志(host JSONL,client 轮询结果展示)。
70
+ const p = payload;
71
+ if (typeof p?.name !== 'string' || typeof p.status !== 'string')
72
+ return internal('llm-update.log: bad payload');
73
+ await ctx.pluginCenter.appendLlmUpdateLog({
74
+ name: p.name,
75
+ action: typeof p.action === 'string' ? p.action : '',
76
+ detail: typeof p.detail === 'string' ? p.detail : '',
77
+ status: p.status,
78
+ });
79
+ return { ok: true, value: null };
80
+ }
81
+ case 'llm-update.result': {
82
+ // 读某插件最近一条 LLM 更新动作(轮询三态:running/success/failed)。
83
+ const name = payload?.name;
84
+ if (typeof name !== 'string' || name === '')
85
+ return internal('llm-update.result: name is required');
86
+ return { ok: true, value: await ctx.pluginCenter.readLlmUpdateResult(name) };
87
+ }
57
88
  case 'toggle': {
58
89
  const payload2 = payload;
59
90
  const id = payload2?.id;
@@ -89,6 +120,14 @@ export class PluginCenterRpc extends Service {
89
120
  const versions = payload?.versions ?? {};
90
121
  return { ok: true, value: await ctx.pluginCenter.markRead(versions) };
91
122
  }
123
+ case 'whatsNewDaily':
124
+ return { ok: true, value: await ctx.pluginCenter.whatsNewDaily() };
125
+ case 'markWhatsNewDaily': {
126
+ const day = payload?.day;
127
+ if (typeof day !== 'string' || day === '')
128
+ return internal('markWhatsNewDaily: day is required');
129
+ return { ok: true, value: await ctx.pluginCenter.markWhatsNewDaily(day) };
130
+ }
92
131
  default:
93
132
  return internal(`unknown endpoint "${endpoint}"`);
94
133
  }
package/dist/update.d.ts CHANGED
@@ -7,8 +7,24 @@ export interface UpdateDigest {
7
7
  compat: 'compatible' | 'incompatible' | 'unknown';
8
8
  compatRange: string | null;
9
9
  }
10
+ /** 服务面判定:目标客户端 bundle 是否深度依赖 Remote BFF(ctx.remote.*)。
11
+ * SSiD 内核(0.1.x)无 remote BFF 服务(走 /plugin-center RPC channel),
12
+ * 这类版本在 SSiD 上必然「pending waiting for service: remote.session」。
13
+ * 案例: dsh-sidebar-qa 0.4.1/0.4.2(2026-08-29 两次实崩)。 */
14
+ export declare function clientBundleUsesRemote(content: string): boolean;
15
+ /** 下载目标 tgz 并抽取 client bundle,判定 remote 服务依赖。
16
+ * 仅返回 boolean 不用 pnpm(直接 registry 下载 tgz + bsdtar 抽文件)。 */
17
+ export declare function targetClientUsesRemote(name: string, version: string): Promise<boolean>;
10
18
  /** Latest published version on the npm registry; null when unreachable/unpublished. */
11
19
  export declare function npmLatest(packageName: string): Promise<string | null>;
20
+ /** 测试用:清空 repository 缓存(生产无调用)。 */
21
+ export declare function clearNpmRepoCache(): void;
22
+ /** 读 npm 包根级 repository.url(带 24h 缓存;失败/缺失 null)。 */
23
+ export declare function npmRepository(packageName: string): Promise<string | null>;
24
+ /** 仓库 URL 归一化(去 scheme/git+ 前缀/尾 .git/尾斜杠/大小写)用于同源比较。 */
25
+ export declare function normalizeRepoUrl(url: string): string;
26
+ /** 同源判定:true=同一上游;false=同名异源;null=无法判定(任一侧缺 repo)。 */
27
+ export declare function isSameUpstream(localRepoUrl: string | null, packageName: string): Promise<boolean | null>;
12
28
  /** Commit-message changelog: the reliable source for repos without release notes. */
13
29
  export declare function fetchCommitChangelog(repoUrl: string | null, sinceIso: string): Promise<string[]>;
14
30
  /**
@@ -122,3 +138,44 @@ export declare function hostFilesChanged(before: FileIdentity[], after: FileIden
122
138
  * `minimumReleaseAgeExclude` itself (2026-08-18, reproduced in-process).
123
139
  */
124
140
  export declare function updatePlugin(packageName: string, version: string, profileDir: string): Promise<PnpmResult>;
141
+ /** 插件来源判定:依据依赖声明形态 + profile 目录(复用前置设计 §4.2 算法)。
142
+ * vendor 定制是 SSiD 生态核心(open-sea-skin/genui/panels 均本地魔改),
143
+ * 机械更新会把 file: 覆盖回 npm —— 来源标记给 LLM 决策「保持 vendor」。 */
144
+ export type PluginSource = 'official' | 'npm' | 'vendor' | 'tarball' | 'local-file';
145
+ export declare function sourceOf(specifier: string, profileDir: string): PluginSource;
146
+ /** 读 profile dependencies 里该插件的声明形态(npm 纯净 / file: vendor / link: 等)。 */
147
+ export declare function dependencySpecifierOf(profileDir: string, name: string): string | null;
148
+ /** LLM 更新信息包:在 UpdateDigest 基础上补充来源/定制标记,驱动 Agent 决策。 */
149
+ export interface LlmUpdatePackage {
150
+ name: string;
151
+ /** 当前本地版本(实体 package.json)。 */
152
+ fromVersion: string;
153
+ /** npm latest(可 null=未发布/不可达,LLM 走 GitHub commit 路径)。 */
154
+ toVersion: string | null;
155
+ /** GitHub commit changelog(更新前后差异,截前 10 条)。 */
156
+ changelog: string[];
157
+ /** DSH 兼容性(peer 检查)。 */
158
+ compat: 'compatible' | 'incompatible' | 'unknown';
159
+ /** peer 声明的 DSH 版本范围。 */
160
+ compatRange: string | null;
161
+ /** 来源判定。 */
162
+ source: PluginSource;
163
+ /** 依赖声明形态(file:/github:等等),机械更新可能覆盖定制的线索。 */
164
+ specifier: string | null;
165
+ /** 是否本地定制(vendor/tarball/local-file)。 */
166
+ isVendorModified: boolean;
167
+ /** 插件所在 profile 目录(host 运行时锚点;LLM 只允许在此目录内操作)。
168
+ * 内部技术字段——不直接展示给用户,由 runtimeLabel 承担语义化表达。 */
169
+ profileDir: string;
170
+ /** 同名异源警告:npm 同名包与本地上游不是同一项目(repository 不一致)。
171
+ * 真机案例: dsh-session-manager(dream12347 定制)vs npm 0.4.1(独立项目)。 */
172
+ upstreamMismatch: boolean;
173
+ /** 用户可读的环境标签(小白视角):'SSID'(思灵应用内)/'DSH-WEB'。 */
174
+ runtimeLabel: string;
175
+ /** 已组装的 Agent prompt(host 单一来源,client 直接注入会话)。 */
176
+ prompt: string;
177
+ }
178
+ /** 采集一个插件用于 LLM 更新的完整信息包。 */
179
+ export declare function buildLlmPackage(name: string, localVersion: string, repoUrl: string | null, compatRange: string | null, localDshVersion: string, sinceIso: string, profileDir: string): Promise<LlmUpdatePackage>;
180
+ /** 组装发给 LLM 会话的 prompt(角色设定 + 信息包 + 规则引用)。 */
181
+ export declare function buildLlmPrompt(pkg: LlmUpdatePackage): string;
package/dist/update.js CHANGED
@@ -3,12 +3,63 @@
3
3
  * npm registry is the primary version source; changelog is commit-history
4
4
  * first (many community repos ship no release/tag/CHANGELOG — verified §7.2).
5
5
  */
6
- import { spawn } from 'node:child_process';
7
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
6
+ import { execFileSync, spawn } from 'node:child_process';
7
+ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
8
  import { createHash } from 'node:crypto';
9
9
  import { homedir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { compareVersions, satisfies } from "./semver.js";
12
+ /** 服务面判定:目标客户端 bundle 是否深度依赖 Remote BFF(ctx.remote.*)。
13
+ * SSiD 内核(0.1.x)无 remote BFF 服务(走 /plugin-center RPC channel),
14
+ * 这类版本在 SSiD 上必然「pending waiting for service: remote.session」。
15
+ * 案例: dsh-sidebar-qa 0.4.1/0.4.2(2026-08-29 两次实崩)。 */
16
+ export function clientBundleUsesRemote(content) {
17
+ return /ctx\.remote\.[a-zA-Z_$]+\./.test(content);
18
+ }
19
+ /** 目标版本客户端 bundle 缓存:name@version → true/false。 */
20
+ const remoteUseCache = new Map();
21
+ /** 下载目标 tgz 并抽取 client bundle,判定 remote 服务依赖。
22
+ * 仅返回 boolean 不用 pnpm(直接 registry 下载 tgz + bsdtar 抽文件)。 */
23
+ export async function targetClientUsesRemote(name, version) {
24
+ const key = `${name}@${version}`;
25
+ const hit = remoteUseCache.get(key);
26
+ if (hit !== undefined)
27
+ return hit;
28
+ let result = false;
29
+ const tmp = mkdtempSync(join(homedir(), '.dsh', 'tmp-remoteprobe-'));
30
+ try {
31
+ const tarballName = `${name.replace(/^@.*\//, '')}-${version}.tgz`;
32
+ for (const registry of ['https://registry.npmjs.org', 'https://registry.npmmirror.com']) {
33
+ try {
34
+ const res = await fetch(`${registry}/${name}/-/${tarballName}`, { signal: AbortSignal.timeout(15000) });
35
+ if (!res.ok)
36
+ continue;
37
+ const tgz = join(tmp, tarballName);
38
+ writeFileSync(tgz, Buffer.from(await res.arrayBuffer()));
39
+ // 找 client bundle 文件(约定 client.js / lib/client.js / client/*.js)
40
+ const listing = execFileSync('tar', ['-tzf', tgz], { encoding: 'utf8', timeout: 30000 });
41
+ const lines = (listing ?? '').split('\n').map(l => l.trim()).filter(l => l.endsWith('.js'));
42
+ const candidates = lines.filter(l => /(^|\/)client(\.js|\/)|\/client\//.test(l) || l.includes('/client.js'));
43
+ const file = candidates.find(l => l.endsWith('client.js'));
44
+ if (file !== undefined) {
45
+ const bundle = execFileSync('tar', ['-xzOf', tgz, file], { encoding: 'utf8', timeout: 30000 });
46
+ result = clientBundleUsesRemote(String(bundle));
47
+ }
48
+ break;
49
+ }
50
+ catch { /* next registry */ }
51
+ }
52
+ }
53
+ catch { /* 任何失败保持 false(不误伤) */ }
54
+ finally {
55
+ try {
56
+ rmSync(tmp, { recursive: true, force: true });
57
+ }
58
+ catch { /* best-effort */ }
59
+ }
60
+ remoteUseCache.set(key, result);
61
+ return result;
62
+ }
12
63
  const UA = { 'User-Agent': 'dsh-plugin-center' };
13
64
  /** Latest published version on the npm registry; null when unreachable/unpublished. */
14
65
  export async function npmLatest(packageName) {
@@ -24,6 +75,62 @@ export async function npmLatest(packageName) {
24
75
  }
25
76
  return null;
26
77
  }
78
+ // ---- 上游同源判定(2026-08-29):包名相同 ≠ 同一项目 ---------------
79
+ // 案例: dsh-session-manager 本地 0.2.2(dream12347 定制)vs npm 0.4.1
80
+ // (hkkz9522 独立同名项目)——机械升级按包名匹配会误报并覆盖定制。
81
+ // 本地为 vendor/tarball/local-file 来源时,校验两边 repository 是否一致。
82
+ const npmRepoCache = new Map();
83
+ const NPM_REPO_TTL = 24 * 3600_000;
84
+ /** 测试用:清空 repository 缓存(生产无调用)。 */
85
+ export function clearNpmRepoCache() { npmRepoCache.clear(); }
86
+ /** 读 npm 包根级 repository.url(带 24h 缓存;失败/缺失 null)。 */
87
+ export async function npmRepository(packageName) {
88
+ const hit = npmRepoCache.get(packageName);
89
+ if (hit !== undefined && Date.now() - hit.at < NPM_REPO_TTL)
90
+ return hit.repo;
91
+ let repo = null;
92
+ for (const registry of ['https://registry.npmjs.org', 'https://registry.npmmirror.com']) {
93
+ try {
94
+ const res = await fetch(`${registry}/${packageName}`, { signal: AbortSignal.timeout(8000) });
95
+ if (res.ok) {
96
+ const doc = await res.json();
97
+ repo = typeof doc.repository === 'object' && doc.repository !== null ? doc.repository.url ?? null : typeof doc.repository === 'string' ? doc.repository : null;
98
+ break;
99
+ }
100
+ }
101
+ catch { /* next registry */ }
102
+ }
103
+ const entry = { at: Date.now(), repo };
104
+ npmRepoCache.set(packageName, entry);
105
+ return repo;
106
+ }
107
+ /** 仓库 URL 归一化(去 scheme/git+ 前缀/尾 .git/尾斜杠/大小写)用于同源比较。 */
108
+ export function normalizeRepoUrl(url) {
109
+ return url
110
+ .trim()
111
+ .replace(/^git\+/, '')
112
+ .replace(/^https?:\/\//, '')
113
+ .replace(/^git:\/\//, '')
114
+ .replace(/^ssh:\/\/git@/, '')
115
+ .replace(/\.git$/, '')
116
+ .replace(/\/$/, '')
117
+ .replace(/^github\.com\//, '')
118
+ .replace(/@/g, '')
119
+ .toLowerCase();
120
+ }
121
+ /** 同源判定:true=同一上游;false=同名异源;null=无法判定(任一侧缺 repo)。 */
122
+ export async function isSameUpstream(localRepoUrl, packageName) {
123
+ if (localRepoUrl === null)
124
+ return null;
125
+ const npmRepo = await npmRepository(packageName);
126
+ if (npmRepo === null)
127
+ return null;
128
+ const a = normalizeRepoUrl(localRepoUrl);
129
+ const b = normalizeRepoUrl(npmRepo);
130
+ if (a === '' || b === '')
131
+ return null;
132
+ return a === b;
133
+ }
27
134
  /** Extract owner/repo from a package.json repository field. */
28
135
  function repoOf(repoUrl) {
29
136
  if (repoUrl === null)
@@ -62,6 +169,12 @@ export async function detectUpdate(name, localVersion, repoUrl, compatRange, loc
62
169
  if (compatRange !== null) {
63
170
  compat = satisfies(localDshVersion, compatRange) ? 'compatible' : 'incompatible';
64
171
  }
172
+ // 服务面校验(SSiD 专用):目标版本客户端依赖 Remote BFF(ctx.remote.*)而
173
+ // SSiD 内核无该服务 → 标不兼容(否则升级后内核启动即 failed)。
174
+ if (compat !== 'incompatible' && process.env.SSID_PENDING_CONSUMER === '1') {
175
+ if (await targetClientUsesRemote(name, latest))
176
+ compat = 'incompatible';
177
+ }
65
178
  return {
66
179
  name,
67
180
  fromVersion: localVersion,
@@ -475,3 +588,93 @@ export async function updatePlugin(packageName, version, profileDir) {
475
588
  }
476
589
  return result;
477
590
  }
591
+ export function sourceOf(specifier, profileDir) {
592
+ if (specifier.startsWith('@deepseek-ai/dsh-'))
593
+ return 'official';
594
+ // tarball 判定必须在 vendor 之前(否则 'file:./vendor/x.tgz' 被 vendor 分支截胡)。
595
+ if (specifier.startsWith('file:./vendor/') && specifier.endsWith('.tgz'))
596
+ return 'tarball';
597
+ if (specifier.startsWith('file:./vendor/'))
598
+ return 'vendor';
599
+ if (specifier.startsWith('file:') || specifier.startsWith('link:'))
600
+ return 'local-file';
601
+ if (specifier.startsWith('github:') || specifier.startsWith('git+'))
602
+ return 'tarball';
603
+ // ^x.y.z / x.y.z / ~x.y.z → npm 源
604
+ return 'npm';
605
+ }
606
+ /** 读 profile dependencies 里该插件的声明形态(npm 纯净 / file: vendor / link: 等)。 */
607
+ export function dependencySpecifierOf(profileDir, name) {
608
+ try {
609
+ const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'));
610
+ return pkg.dependencies?.[name] ?? null;
611
+ }
612
+ catch {
613
+ return null;
614
+ }
615
+ }
616
+ /** 采集一个插件用于 LLM 更新的完整信息包。 */
617
+ export async function buildLlmPackage(name, localVersion, repoUrl, compatRange, localDshVersion, sinceIso, profileDir) {
618
+ const latest = await npmLatest(name);
619
+ const specifier = dependencySpecifierOf(profileDir, name);
620
+ const source = specifier === null ? 'npm' : sourceOf(specifier, profileDir);
621
+ const isVendorModified = source === 'vendor' || source === 'tarball' || source === 'local-file';
622
+ // 同名异源:本地非 npm 来源时校验 npm 同名包上游是否一致(不一致 → 警告)。
623
+ const upstreamMismatch = (source === 'vendor' || source === 'tarball' || source === 'local-file')
624
+ ? (await isSameUpstream(repoUrl, name)) === false
625
+ : false;
626
+ let compat = 'unknown';
627
+ if (compatRange !== null) {
628
+ compat = satisfies(localDshVersion, compatRange) ? 'compatible' : 'incompatible';
629
+ }
630
+ // 服务面校验(SSiD 专用):目标版本依赖 Remote BFF 服务 → 不兼容(LLM 直接 keep)。
631
+ if (compat !== 'incompatible' && latest !== null && process.env.SSID_PENDING_CONSUMER === '1') {
632
+ if (await targetClientUsesRemote(name, latest))
633
+ compat = 'incompatible';
634
+ }
635
+ const pkg = {
636
+ name,
637
+ fromVersion: localVersion,
638
+ toVersion: latest,
639
+ // 变更取 commit changelog(与 detectUpdate 一致,社区 repo 常无 release notes)
640
+ changelog: (await fetchCommitChangelog(repoUrl, sinceIso)).slice(0, 10),
641
+ compat,
642
+ compatRange,
643
+ source,
644
+ specifier,
645
+ isVendorModified,
646
+ profileDir,
647
+ upstreamMismatch,
648
+ // 小白视角环境标签:SSiD 内核(kernel.ts)在 boot 时设置该变量;官方 DSH web 无。
649
+ runtimeLabel: process.env.SSID_PENDING_CONSUMER === '1' ? 'SSID' : 'DSH-WEB',
650
+ prompt: '',
651
+ };
652
+ return { ...pkg, prompt: buildLlmPrompt(pkg) };
653
+ }
654
+ /** 组装发给 LLM 会话的 prompt(角色设定 + 信息包 + 规则引用)。 */
655
+ export function buildLlmPrompt(pkg) {
656
+ const srcBadge = pkg.source.toUpperCase();
657
+ return [
658
+ '你是 dsh 插件更新决策 Agent。请严格按「dsh-plugin-upgrade」skill 的规则决策并执行本插件更新。',
659
+ '',
660
+ `插件: ${pkg.name}`,
661
+ `当前版本: ${pkg.fromVersion}`,
662
+ `npm 最新: ${pkg.toVersion ?? '(未发布或不可达)'}`,
663
+ `来源: ${srcBadge}${pkg.isVendorModified ? '(本地定制!机械更新会覆盖,需核对作者是否已采纳)' : ''}`,
664
+ ...(pkg.upstreamMismatch ? [`同名异源警告: npm 上的 ${pkg.name} 与本地上游不是同一项目(repository 不一致,如独立同名项目),升级将丢失本地定制——执行前务必核实来源。`] : []),
665
+ `依赖声明: ${pkg.specifier ?? '(非 npm 依赖)'}`,
666
+ `安装位置: ${pkg.profileDir}(唯一允许操作目录!本会话工作区与之不同,严禁按会话 cwd 操作)`,
667
+ `DSH 兼容: ${pkg.compat} (要求 ${pkg.compatRange ?? '未知'})`,
668
+ `变更: ${pkg.changelog.join('; ') || '(无 changelog,查 GitHub release/tag)'}`,
669
+ '',
670
+ '规则要点(详见 skill): ',
671
+ '1. 本地超前于 npm → 保持本地,不升级(vendor 魔改第一优先)。',
672
+ '2. vendor/定制 → 下载 npm 版对比是否已被作者采纳;采纳后切 npm 版,未采纳保持 vendor。',
673
+ '3. peer 缺失/不兼容 → 检查依赖树,先修复或回退,禁止让 DSH 启动失败。',
674
+ '4. Windows EPERM 锁 → 走两段式(pending 预下载)或 CLI 指令。',
675
+ '5. pnpm exit 0 假执行 → 校验实体版本,不符则重试或给手动命令。',
676
+ '6. SSiD 预置插件升级 → 注意同步归档(profile-template/vendor)。',
677
+ '',
678
+ '完成后回传: 决策(action) + 执行摘要(detail) + 状态(upgrade/keep/switch-npm/failed)。',
679
+ ].join('\n');
680
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max-null/dsh-plugin-center",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "Plugin center for DeepSeek Harness 閳?installed metadata, community market, update detection, and What's New",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -22,7 +22,8 @@
22
22
  "dist",
23
23
  "client.js",
24
24
  "cordis.patch.yml",
25
- "assets"
25
+ "assets",
26
+ "skills"
26
27
  ],
27
28
  "dsh": {
28
29
  "bundle": {
@@ -59,7 +60,8 @@
59
60
  },
60
61
  "scripts": {
61
62
  "build": "tsc -p tsconfig.json && node build-client.mjs",
62
- "typecheck": "tsc --noEmit -p tsconfig.json"
63
+ "typecheck": "tsc --noEmit -p tsconfig.json",
64
+ "test": "vitest run"
63
65
  },
64
66
  "dependencies": {
65
67
  "js-yaml": "^4.1.0"
@@ -80,6 +82,7 @@
80
82
  "@types/react": "~18.3.1",
81
83
  "esbuild": "^0.24.0",
82
84
  "react": "^18.2.0",
83
- "typescript": "^5.5.0"
85
+ "typescript": "^5.5.0",
86
+ "vitest": "^4.1.11"
84
87
  }
85
88
  }