dsh-session-curator 0.1.2 → 0.3.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/lib/index.js CHANGED
@@ -2,21 +2,18 @@
2
2
  * dsh-session-curator — host 半区入口。
3
3
  *
4
4
  * 提供三块能力:
5
- * 1. 会话软删除:先把会话**藏起来**(进 registry-global 的归档集合 —— DSH 里唯一能让它
6
- * 从侧边栏所有分组面消失的机制,否则它会以"未分组"的幽灵行留在那里),再把它从
7
- * 工作区记账里摘掉(`Workspace.detachSession`),最后把
8
- * `<DSH_HOME>/sessions/<slug>/<sessionId>/` 整个搬到
9
- * `<DSH_HOME>/storages/session-manager/trash/<sessionId>/`。可完整恢复。
10
- * ⚠️ 目录名 `session-manager` 是**故意保留的旧名**(见 host/paths.ts)。
11
- * 2. 回收站管理:列出 / 恢复 / 永久删除。
12
- * 3. 归档会话管理:列出全部归档会话(标题、目录、体积),支持批量取消归档、置顶,
13
- * 以及直接删除。归档/取消归档/置顶全部走官方 `ctx.workspaceRegistry`。
5
+ * 1. 会话删除:**物理删除**(不可恢复)—— 先清掉归档 / 置顶 / 工作区三处记账,再删掉
6
+ * `<DSH_HOME>/sessions/<slug>/<sessionId>/` 与它的投影缓存。想留退路的用户走归档:
7
+ * 归档同样能让会话从侧边栏消失,而且随时能找回来。
8
+ * 2. 归档会话管理:列出全部归档会话(标题、目录、体积),支持批量取消归档、置顶、删除。
9
+ * 3. 置顶会话管理:同一套列表与动作;归档 / 取消归档 / 置顶全部走官方 `ctx.workspaceRegistry`。
10
+ * 另有三个辅助端点:磁盘占用视图(只读)、复制路径 / 在资源管理器中显示、更新检查。
14
11
  *
15
12
  * 对客户端暴露一组挂在共享 `/api` 通道下的精确 Fetch 路由(
16
13
  * `ctx.connection.fetch.register`):信任检查与会话鉴权由 Connection 的 `/api`
17
14
  * 前缀路由先做完,所以插件自己不需要任何鉴权代码。
18
15
  *
19
- * 源码在 `src/`(TypeScript),`lib/` 是构建产物(`npm run build`),DSH 加载的是后者。
16
+ * 源码在 `src/`(TypeScript),`lib/` 是构建产物(`pnpm run build`),DSH 加载的是后者。
20
17
  */
21
18
  import { SessionManager } from "./host/session-manager.js";
22
19
  import { registerRoutes } from "./host/routes.js";
@@ -30,5 +27,8 @@ export function apply(ctx) {
30
27
  const manager = new SessionManager(ctx);
31
28
  registerRoutes(ctx, manager);
32
29
  }
33
- // 纯函数,给 `scripts/smoke.mjs` 直接单测(插件运行时用不到)。
30
+ // 纯函数 / 纯工具,给 `scripts/smoke.mjs` 直接单测(插件运行时用不到)。
34
31
  export { compareVersions } from "./host/update.js";
32
+ export { dirStats, forgetDirStats } from "./host/fs-utils.js";
33
+ // `open` 的路径校验单独导出:自检要直接断言那道判据,不必真的去调系统 shell。
34
+ export { insideRoot } from "./host/open.js";
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Session manager",
4
- "description": "Soft-delete sessions to a trash and manage archived or pinned ones, grouped by their original workspace folder."
4
+ "description": "Permanently delete sessions and manage archived or pinned ones, grouped by their original workspace folder."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "会话管理",
4
- "description": "删除会话(软删除到回收站)并管理已归档 / 已置顶的会话,按原来的工作区文件夹分组。"
4
+ "description": "永久删除会话,并管理已归档 / 已置顶的会话,按原来的工作区文件夹分组。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-session-curator",
3
- "version": "0.1.2",
4
- "description": "DeepSeek Harness plugin: soft-delete sessions to a recoverable trash and manage archived/pinned ones, grouped by their original workspace folder. | DSH 插件:会话软删除到可恢复的回收站,并按原来的工作区文件夹管理已归档 / 已置顶会话。",
3
+ "version": "0.3.0",
4
+ "description": "DeepSeek Harness plugin: permanently delete sessions and manage archived/pinned ones, grouped by their original workspace folder. | DSH 插件:永久删除会话,并按原来的工作区文件夹管理已归档 / 已置顶会话。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.svg",
@@ -43,8 +43,7 @@
43
43
  "session-management",
44
44
  "curator",
45
45
  "archive",
46
- "trash",
47
- "soft-delete",
46
+ "delete",
48
47
  "workspace",
49
48
  "i18n"
50
49
  ],
@@ -100,17 +99,9 @@
100
99
  "optional": true
101
100
  }
102
101
  },
103
- "scripts": {
104
- "build": "node scripts/build.mjs",
105
- "typecheck": "tsc -p tsconfig.host.json && tsc -p tsconfig.client.json",
106
- "test": "npm run build && node scripts/client.mjs && node scripts/smoke.mjs && node scripts/activation.mjs",
107
- "check": "npm run typecheck && node --check lib/index.js && node --check lib/client.js && node --check scripts/client.mjs",
108
- "prepublishOnly": "npm test"
109
- },
110
102
  "files": [
111
103
  "lib",
112
104
  "locale",
113
- "scripts",
114
105
  "cordis.patch.yml",
115
106
  "icon.svg",
116
107
  "LICENSE",
@@ -120,6 +111,18 @@
120
111
  "devDependencies": {
121
112
  "@types/node": "^26.6.5",
122
113
  "esbuild": "^0.28.2",
114
+ "prettier": "^3.9.10",
123
115
  "typescript": "^5.9.3"
116
+ },
117
+ "scripts": {
118
+ "build": "node scripts/build.mjs",
119
+ "typecheck": "tsc -p tsconfig.host.json && tsc -p tsconfig.client.json",
120
+ "test": "pnpm run build && node scripts/artifact.mjs && node scripts/client.mjs && node scripts/smoke.mjs && node scripts/activation.mjs",
121
+ "check": "pnpm run typecheck && pnpm run format:check && node --check lib/index.js && node --check lib/client.js && node --check scripts/artifact.mjs && node --check scripts/check-profile.mjs && node --check scripts/client.mjs && node --check scripts/lib-sync.mjs",
122
+ "check:lib": "node scripts/lib-sync.mjs",
123
+ "format": "prettier --write .",
124
+ "format:check": "prettier --check .",
125
+ "release": "node scripts/release.mjs",
126
+ "check:profile": "node scripts/check-profile.mjs"
124
127
  }
125
- }
128
+ }
package/lib/host/trash.js DELETED
@@ -1,190 +0,0 @@
1
- /**
2
- * 危险操作:软删除(移入回收站)/ 恢复 / 永久删除。
3
- *
4
- * 这个文件里每一条顺序都是踩过事故之后定下来的,动之前先读注释;回归测试在
5
- * `scripts/smoke.mjs` 的"软删除语义(临时 DSH_HOME)"那一段。
6
- */
7
- import { existsSync, promises as fsp } from "node:fs";
8
- import { join } from "node:path";
9
- import { messageOf, moveDir } from "./fs-utils.js";
10
- import { META_FILE, PROJ_FILE, projCacheFile, sessionsRoot, trashRoot } from "./paths.js";
11
- /**
12
- * 软删除:把会话**藏起来**(进归档集合)+ 移出工作区记账 + 搬到回收站(含投影缓存)。
13
- *
14
- * 为什么是"归档"而不是"取消归档":DSH 里**唯一**能让一个会话从所有分组面
15
- *(侧边栏的工作区分组、**未分组**、置顶)消失的机制,就是 `archivedSessionIds`
16
- * 这个 registry-global 集合 —— 它的注释原话是 "sessions hidden from every grouping
17
- * surface",而且归档**不动工作区记账**,槽位留着,所以恢复时位置不变。
18
- *
19
- * 反过来做(先 `unarchiveSession`)必然出幽灵行:取消归档会把会话重新暴露给
20
- * 客户端那份会话列表(`ctx.sessions.list`),而它的目录虽然已经被我们搬走,
21
- * 客户端 list 里那条还在 —— 侧边栏 `groupByWorkspace()` 的判定是
22
- * "在 list 里 && 不属于任何工作区 && 不在归档集合里" → 落进**未分组**。
23
- * 同时归档集合里已经没有它,等于谁也管不住这条幽灵行了。
24
- *
25
- * 原来是否归档记在 `__trash.json` 的 `wasArchived` 里,恢复时照原样还原。
26
- */
27
- export async function trashSessions(host, ids) {
28
- await fsp.mkdir(trashRoot(), { recursive: true });
29
- const dirs = await host.scanSessionDirs();
30
- return host.each(ids, async (sessionId) => {
31
- const steps = [];
32
- if (sessionId === process.env.DSH_SESSION_ID) {
33
- throw new Error("这是当前正在使用的会话,不能删除");
34
- }
35
- const activity = await host.sessionActivity(sessionId);
36
- if (activity.length > 0)
37
- throw new Error("会话正在运行,请先停止它");
38
- const info = dirs.get(sessionId);
39
- if (!info)
40
- throw new Error("找不到会话日志目录(可能已被删除)");
41
- // 必须**趁目录还在**归档:`archiveSession` 要先通过 `sessionKnown()`
42
- //(live 会话 / 头部索引 / 持久化清单),搬走之后三者都查不到了。
43
- const wasArchived = host.registry.archivedSessionIds.includes(sessionId);
44
- if (!wasArchived) {
45
- try {
46
- await host.registry.archiveSession(sessionId);
47
- steps.push("archive");
48
- }
49
- catch (error) {
50
- // 藏不住不该阻止删除:会话可能继续留在侧边栏,但数据照样进回收站。
51
- steps.push(`archive-failed:${messageOf(error)}`);
52
- }
53
- }
54
- // 置顶的会话也要先取消置顶:否则它的 id 还留在 pinnedSessionIds 里,
55
- // 搬进回收站之后会同时出现在"已置顶"和"回收站"两个页签。
56
- if (host.registry.pinnedSessionIds.includes(sessionId)) {
57
- await host.registry.unpinSession(sessionId);
58
- steps.push("unpin");
59
- }
60
- const workspace = host.findWorkspaceOf(sessionId);
61
- if (workspace) {
62
- try {
63
- if (typeof workspace.detachSession === "function") {
64
- await workspace.detachSession(sessionId);
65
- steps.push("detach");
66
- }
67
- }
68
- catch (error) {
69
- steps.push(`detach-failed:${messageOf(error)}`);
70
- }
71
- }
72
- const projection = await host.readProjection(sessionId);
73
- const target = join(trashRoot(), sessionId);
74
- await fsp.rm(target, { recursive: true, force: true });
75
- await moveDir(info.dir, target);
76
- steps.push("move");
77
- const cached = projCacheFile(sessionId);
78
- if (existsSync(cached)) {
79
- try {
80
- await fsp.rename(cached, join(target, PROJ_FILE));
81
- steps.push("projcache");
82
- }
83
- catch {
84
- // 缓存搬不动不影响会话本身
85
- }
86
- }
87
- await fsp.writeFile(join(target, META_FILE), JSON.stringify({
88
- version: 1,
89
- sessionId,
90
- slug: info.slug,
91
- title: projection.title,
92
- cwd: projection.cwd,
93
- workspaceId: workspace ? String(workspace.id) : null,
94
- workspaceTitle: workspace ? workspace.title : null,
95
- // 删除前是否已经归档 —— 恢复时要照原样还原(老条目没有这个字段,
96
- // 一律按"没归档过"处理,也就是恢复成普通会话)。
97
- wasArchived,
98
- deletedAt: Date.now()
99
- }, null, 2), "utf8");
100
- return steps;
101
- });
102
- }
103
- /**
104
- * 从回收站恢复;尽力把会话重新挂回原工作区,并把归档状态还原成删除前的样子。
105
- *
106
- * `trashSessions()` 会把会话塞进归档集合(藏起来),所以这里必须配对处理:
107
- * - 删除前**已经归档**(`wasArchived: true`)→ 保持归档,它就回到"已归档"页签;
108
- * - 其余(含老版本写下的、没有这个字段的条目)→ 取消归档,恢复成普通会话。
109
- * 不还原的话,恢复出来的会话会莫名其妙地"还在已归档里"。
110
- */
111
- export async function restoreSessions(host, ids) {
112
- const dirs = await host.scanSessionDirs();
113
- return host.each(ids, async (sessionId) => {
114
- const steps = [];
115
- const source = join(trashRoot(), sessionId);
116
- if (!existsSync(source))
117
- throw new Error("回收站里没有这个会话");
118
- let meta = null;
119
- try {
120
- meta = JSON.parse(await fsp.readFile(join(source, META_FILE), "utf8"));
121
- }
122
- catch {
123
- meta = null;
124
- }
125
- if (typeof meta?.slug !== "string" || meta.slug === "") {
126
- throw new Error("缺少回收站元数据,无法确定原目录");
127
- }
128
- if (dirs.has(sessionId))
129
- throw new Error("目标会话目录已存在,请先处理同名会话");
130
- const target = join(sessionsRoot(), meta.slug, sessionId);
131
- await moveDir(source, target);
132
- steps.push("move");
133
- const cached = join(target, PROJ_FILE);
134
- if (existsSync(cached)) {
135
- try {
136
- await fsp.rename(cached, projCacheFile(sessionId));
137
- steps.push("projcache");
138
- }
139
- catch {
140
- // 同上,非关键
141
- }
142
- }
143
- try {
144
- await fsp.rm(join(target, META_FILE), { force: true });
145
- }
146
- catch {
147
- // 元数据残留无害
148
- }
149
- // 归档状态先还原,再挂回工作区:attachSession 要读得到会话头(目录已经回来了)。
150
- if (meta.wasArchived !== true && host.registry.archivedSessionIds.includes(sessionId)) {
151
- await host.registry.unarchiveSession(sessionId);
152
- steps.push("unarchive");
153
- }
154
- if (typeof meta.cwd === "string" && meta.cwd !== "") {
155
- try {
156
- const workspace = await host.registry.resolveByPath(meta.cwd);
157
- if (workspace && typeof workspace.attachSession === "function") {
158
- await workspace.attachSession(sessionId);
159
- steps.push("attach");
160
- }
161
- }
162
- catch {
163
- // 索引要等下次启动才会收录这个会话,挂靠失败不影响恢复
164
- }
165
- }
166
- return steps;
167
- });
168
- }
169
- /**
170
- * 永久删除(不可恢复)。
171
- *
172
- * 先取消归档:`trashSessions()` 为了让会话从侧边栏消失而把它放进了归档集合,永久删除之后
173
- * 再没人会去动这条 id —— 留在集合里就是一条**永久幽灵**(DSH 自身会一直以为它归档着,
174
- * 界面上又找不到它)。
175
- */
176
- export async function purgeSessions(host, ids) {
177
- return host.each(ids, async (sessionId) => {
178
- const target = join(trashRoot(), sessionId);
179
- if (!existsSync(target))
180
- throw new Error("回收站里没有这个会话");
181
- const steps = [];
182
- if (host.registry.archivedSessionIds.includes(sessionId)) {
183
- await host.registry.unarchiveSession(sessionId);
184
- steps.push("unarchive");
185
- }
186
- await fsp.rm(target, { recursive: true, force: true });
187
- steps.push("purge");
188
- return steps;
189
- });
190
- }
@@ -1,358 +0,0 @@
1
- /**
2
- * 宿主半区**激活**自检 —— 对着真实 DSH 运行时的 cordis 跑一遍(不需要 DSH 在跑)。
3
- *
4
- * 为什么除了 `smoke.mjs` 还要这个:`smoke.mjs` 用的是**假 ctx**(服务直接挂在根上),
5
- * 它测得出"登记了什么路由",但复现不了这次的 405 事故 —— 那个事故要求服务**各自挂在
6
- * 自己的兄弟 fiber 上**(Loader 挂 profile 条目就是这样),此时 cordis 的服务调用带
7
- * shadow(`Context[symbols.shadow]` 指向提供者作用域),于是
8
- * `ctx.connection.rpc.handle()` 会拿 **Connection 自己的 ctx** 去
9
- * `owner.webServer.register(...)`,抛
10
- *
11
- * cannot get property "webServer" without inject
12
- * (@deepseek-ai/dsh-client-connection/lib/index.js:656)
13
- *
14
- * 插件 fiber 直接 `failed`、一条路由都没挂上,客户端每个 POST 落到 SPA 静态兜底,
15
- * 被回 **HTTP 405** —— 就是 `transport failure for /session-manager/trash: HTTP 405`。
16
- *
17
- * 本脚本从 `app.asar` 里抽出真实的 `@deepseek-ai/cordis` + `@deepseek-ai/cosmokit`,
18
- * 用**逐字照抄**的 Connection(`rpc` / `fetch` / `createSharedFetchHandler` /
19
- * `endpointFromPath` / `assertChannel` / `assertFetchRoute`)搭出同样的兄弟拓扑,然后:
20
- * 1. 载入本插件的宿主半区,断言 fiber 变 **ACTIVE**(FAILED 直接判失败);
21
- * 2. 断言 8 条 `/api/session-curator/*` 精确路由都登记上了;
22
- * 3. 用真 `Request` 走一遍共享处理器(等价于宿主 `/api` 栅栏之后的路径),要 200 + 合法信封;
23
- * 4. 登记一条"永远不该被碰到"的 webServer —— 谁把实现改回 `rpc.handle()` 就会撞上它;
24
- * 5. dispose 插件,断言 8 条路由全部释放。
25
- *
26
- * 用法:`node scripts/activation.mjs`
27
- * 找不到 `app.asar` 时打印 SKIP 并以 0 退出;可用 `$env:DSH_APP_ASAR` 指定路径。
28
- */
29
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
30
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
31
- import { createRequire } from "node:module";
32
- import { tmpdir } from "node:os";
33
- import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
34
- import { pathToFileURL } from "node:url";
35
-
36
- // ── 找 app.asar ──────────────────────────────────────────────────────────
37
- function findArchive() {
38
- const configured = process.env.DSH_APP_ASAR;
39
- if (typeof configured === "string" && configured.trim() !== "") {
40
- const path = resolve(configured.trim());
41
- if (!existsSync(path)) throw new Error(`$DSH_APP_ASAR 指向的文件不存在:${path}`);
42
- return path;
43
- }
44
- const candidates = [
45
- "C:/Soft/DeepSeek Harness/resources/app.asar",
46
- join(process.env.LOCALAPPDATA ?? "", "Programs/DeepSeek Harness/resources/app.asar"),
47
- join(process.env.PROGRAMFILES ?? "", "DeepSeek Harness/resources/app.asar")
48
- ];
49
- return candidates.find((path) => existsSync(path));
50
- }
51
-
52
- /** 最小 asar 读取器:Chromium pickle 头 + JSON 目录 + 顺序拼接的文件体。 */
53
- async function readArchive(path) {
54
- const buffer = await readFile(path);
55
- const headerSize = buffer.readUInt32LE(4);
56
- const jsonLength = buffer.readUInt32LE(12);
57
- const header = JSON.parse(buffer.subarray(16, 16 + jsonLength).toString("utf8"));
58
- const dataStart = 8 + headerSize;
59
- const files = [];
60
- (function walk(node, prefix) {
61
- for (const [name, entry] of Object.entries(node.files ?? {})) {
62
- const target = prefix === "" ? name : `${prefix}/${name}`;
63
- if (entry.files) walk(entry, target);
64
- else files.push({ path: target, offset: Number(entry.offset), size: entry.size });
65
- }
66
- })(header, "");
67
- return {
68
- files,
69
- read(file) {
70
- return buffer.subarray(dataStart + file.offset, dataStart + file.offset + file.size);
71
- }
72
- };
73
- }
74
-
75
- /** 把运行时的 cordis + cosmokit 抽到一个临时 node_modules 里(每次覆盖,避免陈旧)。 */
76
- async function extractRuntime(archivePath) {
77
- const archive = await readArchive(archivePath);
78
- const root = join(tmpdir(), "dsh-session-curator-activation");
79
- const modules = join(root, "node_modules");
80
- await rm(modules, { recursive: true, force: true });
81
- let extracted = 0;
82
- for (const file of archive.files) {
83
- const match = /^dsh\/node_modules\/(@deepseek-ai\/(?:cordis|cosmokit))(\/.*)?$/.exec(file.path);
84
- if (match === null) continue;
85
- const [, packageName, rest = ""] = match;
86
- const wanted = rest === "" || rest === "/package.json" || rest.startsWith("/lib/");
87
- if (!wanted) continue;
88
- const target = join(modules, packageName, rest.replace(/^\//, ""));
89
- await mkdir(dirname(target), { recursive: true });
90
- await writeFile(target, archive.read(file));
91
- extracted += 1;
92
- }
93
- if (extracted === 0) throw new Error("app.asar 里没有找到 @deepseek-ai/cordis —— 布局变了?");
94
- return join(modules, "@deepseek-ai/cordis/lib/index.js");
95
- }
96
-
97
- // ── 逐字照抄 @deepseek-ai/dsh-client-connection 的判定与注册代码 ─────────
98
- const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/;
99
- const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
100
-
101
- function endpointFromPath(channel, pathname) {
102
- if (!pathname.startsWith(`${channel}/`)) return undefined;
103
- const endpoint = pathname.slice(channel.length + 1);
104
- if (endpoint.split("/").some((segment) => segment === "" || segment === "." || segment === ".." || !ENDPOINT_SEGMENT_PATTERN.test(segment))) return undefined;
105
- return endpoint;
106
- }
107
-
108
- function assertChannel(channel) {
109
- if (!CHANNEL_PATTERN.test(channel) || channel === "/api") throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`);
110
- }
111
-
112
- function assertFetchRoute(route) {
113
- if (endpointFromPath("/api", route.path) === undefined) throw new Error(`connection: invalid exact Fetch route ${JSON.stringify(route.path)}`);
114
- if (route.methods.length === 0) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} declares no methods`);
115
- if (new Set(route.methods).size !== route.methods.length) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`);
116
- }
117
-
118
- const ENDPOINTS = ["snapshot", "archive", "unarchive", "pin", "unpin", "trash", "restore", "purge", "check-update"];
119
- const CHANNEL = "/api";
120
- const PREFIX = "session-curator";
121
- const ROUTE_PREFIX = `${CHANNEL}/${PREFIX}`;
122
-
123
- const checks = [];
124
- function check(label, condition, detail) {
125
- checks.push({ label, ok: Boolean(condition) });
126
- console.log(`${condition ? " ok " : " FAIL "} ${label}${detail === undefined ? "" : ` — ${detail}`}`);
127
- }
128
-
129
- // ── 跑 ───────────────────────────────────────────────────────────────────
130
- const archivePath = findArchive();
131
- if (archivePath === undefined) {
132
- console.log("SKIP: 找不到 app.asar(用 $env:DSH_APP_ASAR 指定 DSH 的 resources/app.asar 后重跑)");
133
- process.exit(0);
134
- }
135
-
136
- console.log(`runtime: ${archivePath}`);
137
- const cordisEntry = await extractRuntime(archivePath);
138
- const { Context, Service } = await import(pathToFileURL(cordisEntry).href);
139
-
140
- /** 故意抛错:这个插件不该再碰 webServer(老实现才会)。 */
141
- class WebServer extends Service {
142
- constructor(ctx) {
143
- super(ctx, "webServer");
144
- }
145
- register(route) {
146
- throw new Error(`webserver.register 不该被本插件调用:${route.path}`);
147
- }
148
- }
149
-
150
- /** verbatim: dsh-client-connection/lib/index.js `HostConnectionService`。 */
151
- class Connection extends Service {
152
- fetchRoutes = new Map();
153
- constructor(ctx) {
154
- super(ctx, "connection");
155
- }
156
- get rpc() {
157
- const owner = this.ctx;
158
- return {
159
- handle: (channel, handler) => this.register(owner, channel, handler),
160
- intercept: (channel, matches, handler) => this.registerInterceptor(owner, channel, matches, handler)
161
- };
162
- }
163
- get fetch() {
164
- const owner = this.ctx;
165
- return { register: (route) => this.registerFetchRoute(owner, route) };
166
- }
167
- register(owner, channel) {
168
- assertChannel(channel);
169
- const route = { kind: "prefix", path: channel, handler: async () => {} };
170
- return owner.effect(() => owner.webServer.register(route), `client-connection: ${channel} rpc channel`);
171
- }
172
- registerInterceptor() {
173
- throw new Error("intercept 未实现(本插件不用)");
174
- }
175
- registerFetchRoute(owner, route) {
176
- assertFetchRoute(route);
177
- const registered = { methods: new Set(route.methods), requestBody: route.requestBody, fetch: route.fetch };
178
- return owner.effect(() => {
179
- if (this.fetchRoutes.has(route.path)) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} is already registered`);
180
- this.fetchRoutes.set(route.path, registered);
181
- return () => {
182
- this.fetchRoutes.delete(route.path);
183
- };
184
- }, `client-connection: ${route.path} Fetch route`);
185
- }
186
- createSharedFetchHandler(channel) {
187
- return {
188
- requestBodyMode: ({ method, url }) => {
189
- const route = this.fetchRoutes.get(url.pathname);
190
- return route?.methods.has(method) === true ? route.requestBody : "buffered";
191
- },
192
- fetch: (request) => {
193
- const pathname = new URL(request.url).pathname;
194
- const route = this.fetchRoutes.get(pathname);
195
- if (route?.methods.has(request.method) === true) return route.fetch(request);
196
- return Promise.resolve(new Response("not found", { status: 404 }));
197
- }
198
- };
199
- }
200
- }
201
-
202
- class WorkspaceRegistry extends Service {
203
- constructor(ctx) {
204
- super(ctx, "workspaceRegistry");
205
- }
206
- list() {
207
- return [];
208
- }
209
- get archivedSessionIds() {
210
- return [];
211
- }
212
- get pinnedSessionIds() {
213
- return [];
214
- }
215
- }
216
-
217
- // 兄弟拓扑:Loader 挂 profile 条目就是每个服务一条自己的 fiber。
218
- const root = new Context();
219
- let connection;
220
- root.plugin({ name: "webserver", apply: (ctx) => void new WebServer(ctx) });
221
- root.plugin({
222
- name: "connection",
223
- apply: (ctx) => {
224
- connection = new Connection(ctx);
225
- }
226
- });
227
- root.plugin({ name: "workspace-registry", apply: (ctx) => void new WorkspaceRegistry(ctx) });
228
-
229
- const plugin = await import(pathToFileURL(process.argv[2] === undefined ? join(import.meta.dirname, "../lib/index.js") : resolve(process.argv[2])).href);
230
- const fiber = root.plugin(plugin);
231
- let failure;
232
- try {
233
- await fiber.await();
234
- } catch (error) {
235
- failure = error;
236
- }
237
-
238
- check("host half activates (fiber ACTIVE)", fiber.state === 2, failure === undefined ? `state=${fiber.state}` : `state=${fiber.state}: ${failure.message}`);
239
- check(
240
- "registers one exact /api route per endpoint",
241
- ENDPOINTS.every((endpoint) => connection.fetchRoutes.has(`${ROUTE_PREFIX}/${endpoint}`)),
242
- [...connection.fetchRoutes.keys()].join(", ")
243
- );
244
-
245
- const shared = connection.createSharedFetchHandler("/api");
246
- const rpcId = "activation-probe";
247
- const response = await shared.fetch(new Request(`http://127.0.0.1:19387${ROUTE_PREFIX}/snapshot`, {
248
- method: "POST",
249
- headers: { "content-type": "application/json" },
250
- body: JSON.stringify({ type: "client-request", rpcId, method: `${PREFIX}/snapshot`, payload: {} })
251
- }));
252
- const text = await response.text();
253
- let envelope;
254
- try {
255
- envelope = JSON.parse(text);
256
- } catch {
257
- // 路由没挂上时这里拿到的是 Connection 的纯文本 404,别让它盖掉上面的 FAIL 列表。
258
- envelope = { type: "unparsable", result: { ok: false, error: { message: text.slice(0, 80) } } };
259
- }
260
- check("POST /api/session-curator/snapshot answers 200 + Connection envelope", response.status === 200 && envelope.type === "server-response" && envelope.rpcId === rpcId, `status=${response.status} type=${envelope.type} ok=${envelope.result?.ok}${envelope.result?.ok === false ? ` error=${JSON.stringify(envelope.result.error)}` : ""}`);
261
- check("snapshot value carries the documented shape", envelope.result?.ok === true && ["dshHome", "trashDir", "sessionsDir", "archived", "pinned", "trashed", "totals"].every((key) => key in (envelope.result.value ?? {})));
262
-
263
- const unregistered = await shared.fetch(new Request("http://127.0.0.1:19387/api/not-ours", { method: "POST" }));
264
- check("an unregistered /api path is left to Connection (404)", unregistered.status === 404, `status=${unregistered.status}`);
265
-
266
- await fiber.dispose();
267
- await fiber.await().catch(() => {});
268
- check("disposing the host half releases every route", connection.fetchRoutes.size === 0, `${connection.fetchRoutes.size} left`);
269
-
270
- // ── manifest 元数据:插件管理页卡片上的图标与标题/描述 ───────────────────
271
- // 逐条复刻 @deepseek-ai/dsh-app-boot 的 `lib/types/package-meta.js`(readPluginMeta /
272
- // iconOf):locale 文件**必须能被 Node 解析器按 exports 找到**,图标是 manifest 相对路径、
273
- // 只能是 SVG/PNG/JPEG/WebP、realpath 之后仍在 manifest 目录内、且 ≤ 256 KiB。
274
- const ICON_MEDIA_TYPES = new Map([[".svg", "image/svg+xml"], [".png", "image/png"], [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"], [".webp", "image/webp"]]);
275
- const MAX_ICON_BYTES = 256 * 1024;
276
-
277
- /** 极简标签配对:够验证我们自己的手写 SVG(跳过注释、自闭合、XML 声明)。 */
278
- function tagsBalanced(source) {
279
- const text = source.replace(/<!--[\s\S]*?-->/gu, "");
280
- const stack = [];
281
- const pattern = /<\s*(\/?)\s*([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)\s*>/gu;
282
- let match;
283
- while ((match = pattern.exec(text)) !== null) {
284
- const [, closing, name] = match;
285
- // 自闭合看原始匹配的结尾,别信那个可选捕获组:属性组会把 `/` 一起吃掉。
286
- if (/\/\s*>$/u.test(match[0]) || name.startsWith("?")) continue;
287
- if (closing === "/") {
288
- if (stack.pop() !== name) return false;
289
- } else {
290
- stack.push(name);
291
- }
292
- }
293
- return stack.length === 0;
294
- }
295
- check("the SVG tag checker itself detects imbalance", tagsBalanced("<svg><path/></svg>") === true && tagsBalanced("<svg><path></svg>") === false && tagsBalanced("<svg><path>x</svg>") === false);
296
-
297
- const pluginDir = resolve(import.meta.dirname, "..");
298
- // 用 self-reference 解析:不需要插件已经装进 profile,走的是同一张 exports 表。
299
- const pluginRequire = createRequire(join(pluginDir, "package.json"));
300
- // 包名从自己的 manifest 读:将来改名(发布到 npm 前一定要改)自检不会跟着烂。
301
- const PACKAGE_NAME = JSON.parse(readFileSync(join(pluginDir, "package.json"), "utf8")).name;
302
- let manifest;
303
- let manifestPath;
304
- try {
305
- manifestPath = pluginRequire.resolve(`${PACKAGE_NAME}/package.json`);
306
- manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
307
- } catch (error) {
308
- manifestPath = undefined;
309
- check("package.json is reachable through its own exports map", false, String(error?.code ?? error));
310
- }
311
- if (manifestPath !== undefined) {
312
- check("package.json is reachable through its own exports map", true);
313
- check("the manifest exports locale JSON for the metadata reader", (() => {
314
- try {
315
- return pluginRequire.resolve(`${PACKAGE_NAME}/locale/en.json`).endsWith("en.json");
316
- } catch {
317
- return false;
318
- }
319
- })());
320
-
321
- // 本地化 title / description(app-boot 读取 locale/ 目录下每个 *.json 的 meta)。
322
- const englishPath = pluginRequire.resolve(`${PACKAGE_NAME}/locale/en.json`);
323
- const dictionaries = new Map();
324
- for (const name of readdirSync(dirname(englishPath))) {
325
- if (!name.endsWith(".json")) continue;
326
- const id = name.slice(0, -5).toLowerCase();
327
- const parsed = JSON.parse(readFileSync(pluginRequire.resolve(`${PACKAGE_NAME}/locale/${name}`), "utf8"));
328
- dictionaries.set(id, parsed.meta ?? {});
329
- }
330
- check("every locale file carries meta.title and meta.description", [...dictionaries].every(([, meta]) => typeof meta.title === "string" && meta.title !== "" && typeof meta.description === "string" && meta.description !== ""), [...dictionaries].map(([id, meta]) => `${id}:${meta.title}`).join(", "));
331
- const title = { en: manifest.name, ...Object.fromEntries([...dictionaries].filter(([, meta]) => meta.title !== undefined).map(([id, meta]) => [id, meta.title])) };
332
- const description = { en: manifest.description ?? "", ...Object.fromEntries([...dictionaries].filter(([, meta]) => meta.description !== undefined).map(([id, meta]) => [id, meta.description])) };
333
- check("the plugin card gets a real (non-package-name) title in both languages", title.en !== manifest.name && typeof title.zh === "string" && title.zh !== "", JSON.stringify(title));
334
- check("the plugin card gets a description in both languages", typeof description.en === "string" && description.en !== "" && typeof description.zh === "string" && description.zh !== "");
335
-
336
- // 图标:路径规则 + 体积 + 内容。
337
- const icon = manifest.icon;
338
- check("the manifest declares a manifest-relative SVG icon", typeof icon === "string" && !isAbsolute(icon) && ICON_MEDIA_TYPES.get(extname(icon).toLowerCase()) === "image/svg+xml", JSON.stringify(icon));
339
- if (typeof icon === "string") {
340
- const iconFile = realpathSync(resolve(dirname(manifestPath), icon));
341
- const local = relative(realpathSync(dirname(manifestPath)), iconFile);
342
- const size = statSync(iconFile).size;
343
- check("the icon stays inside the manifest directory, is a regular file and fits 256 KiB", !local.startsWith("..") && !isAbsolute(local) && statSync(iconFile).isFile() && size <= MAX_ICON_BYTES, `${size} B`);
344
- const svg = readFileSync(iconFile, "utf8");
345
- // 注释不算数:只有真正会被渲染的部分才需要自带着色。
346
- const shapes = svg.replace(/<!--[\s\S]*?-->/gu, "");
347
- check("the icon is well-formed SVG with its own colours (it renders through <img>)", svg.trimStart().startsWith("<svg") && svg.trimEnd().endsWith("</svg>") && tagsBalanced(svg) && !shapes.includes("currentColor"), `${svg.length} chars`);
348
- check("the icon uses the same square canvas as DSH's own plugin artwork", /viewBox="0 0 36 36"/u.test(svg) && /width="36"/u.test(svg) && /height="36"/u.test(svg));
349
- // 只量几何属性(别去扫 xmlns 里的 2000、也别把 #324DE2 读成 324)。
350
- const geometryAttributes = [...shapes.matchAll(/\b(?:d|viewBox|x|y|x1|y1|x2|y2|width|height|rx)="([^"]*)"/gu)];
351
- const coordinates = geometryAttributes.flatMap((match) => [...match[1].matchAll(/-?\d+(?:\.\d+)?/gu)].map((number) => Number(number[0])));
352
- check("every coordinate stays inside the 36×36 canvas", coordinates.length > 0 && coordinates.every((value) => value >= 0 && value <= 36), `${coordinates.length} numbers, max=${Math.max(...coordinates)}`);
353
- }
354
- }
355
-
356
- const failed = checks.filter((entry) => !entry.ok);
357
- console.log(`\n${checks.length - failed.length}/${checks.length} checks passed`);
358
- process.exit(failed.length === 0 ? 0 : 1);