dsh-session-curator 0.1.2 → 0.2.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/host/trash.js CHANGED
@@ -3,11 +3,41 @@
3
3
  *
4
4
  * 这个文件里每一条顺序都是踩过事故之后定下来的,动之前先读注释;回归测试在
5
5
  * `scripts/smoke.mjs` 的"软删除语义(临时 DSH_HOME)"那一段。
6
+ *
7
+ * ── `__trash.json`(回收站条目的元数据)──────────────────────────────────
8
+ * 位置:`<DSH_HOME>/storages/session-manager/trash/<sessionId>/__trash.json`,
9
+ * 与会话自己的文件(`session.jsonl` 等)**同级同搬** —— 所以"恢复"就是把整个目录搬回
10
+ * `<DSH_HOME>/sessions/<slug>/<sessionId>/`,元数据跟着回去,搬完再删掉它。
11
+ *
12
+ * **v1**(当前,`TRASH_META_VERSION = 1`)的字段:
13
+ *
14
+ * | 字段 | 类型 | 说明 |
15
+ * | --- | --- | --- |
16
+ * | `version` | `number` | 写这份文件的 schema 版本。**最早那一版没有这个字段**(插件还叫 `dsh-session-manager` 的时代),读的时候按 v1 处理 |
17
+ * | `sessionId` | `string` | 会话 id;与目录名一致,冗余存一份便于人眼排错 |
18
+ * | `slug` | `string` | 原目录 `<DSH_HOME>/sessions/<slug>/`。**恢复的关键字段**:缺失或不是非空字符串就恢复不了,界面会按 `restorable: false` 显示"无法恢复(缺元数据)" |
19
+ * | `title` | `string \| null` | 删除时的标题(只用于展示) |
20
+ * | `cwd` | `string \| null` | 原工作区路径;恢复时拿它去 `resolveByPath()` 把会话挂回去 |
21
+ * | `workspaceId` | `string \| null` | 原工作区 id(展示 / 分组用,恢复**不**依赖它) |
22
+ * | `workspaceTitle` | `string \| null` | 原工作区标题(同上) |
23
+ * | `wasArchived` | `boolean` | 删除前是否已经在归档集合里;恢复时照原样还原。老条目没有这个字段,按 `false` 处理 |
24
+ * | `deletedAt` | `number` | 删除时间(`Date.now()`)。`0` 或缺失 = 认不出删除时间的老条目,**永远不算过期**,只能逐条永久删除(见 `retention.ts`) |
25
+ *
26
+ * **版本约定:加字段不升版**。
27
+ * 1. 读方必须容忍**缺字段**(老条目、以及将来加了字段之后的新条目),逐字段给默认值 ——
28
+ * 正因为如此,"新增一个可选字段"**不需要**动 `version`。
29
+ * 2. 只有**不兼容**的改动(改字段含义、挪位置、不重写就读不对)才 `+1`,并且必须同时
30
+ * `readTrashMeta()` 里补一段迁移:把老版本升成新版本,再往下走。
31
+ * 3. 读到**比自己新**的 `version` 一律**拒绝**(`readTrashMeta()` 抛错,逐项失败里带原因),
32
+ * 绝不按旧语义硬猜 —— 猜错的代价是"把一个会话恢复进错误的目录"。
6
33
  */
7
34
  import { existsSync, promises as fsp } from "node:fs";
8
35
  import { join } from "node:path";
9
36
  import { messageOf, moveDir } from "./fs-utils.js";
10
37
  import { META_FILE, PROJ_FILE, projCacheFile, sessionsRoot, trashRoot } from "./paths.js";
38
+ import { isExpired, retentionCutoff } from "./retention.js";
39
+ /** `__trash.json` 当前版本(字段表与版本约定见文件头)。 */
40
+ export const TRASH_META_VERSION = 1;
11
41
  /**
12
42
  * 软删除:把会话**藏起来**(进归档集合)+ 移出工作区记账 + 搬到回收站(含投影缓存)。
13
43
  *
@@ -84,8 +114,9 @@ export async function trashSessions(host, ids) {
84
114
  // 缓存搬不动不影响会话本身
85
115
  }
86
116
  }
87
- await fsp.writeFile(join(target, META_FILE), JSON.stringify({
88
- version: 1,
117
+ // 写入侧就用 `TrashMeta` 这个类型:schema 改了却忘了改字段表,tsc 当场就红。
118
+ const meta = {
119
+ version: TRASH_META_VERSION,
89
120
  sessionId,
90
121
  slug: info.slug,
91
122
  title: projection.title,
@@ -96,10 +127,50 @@ export async function trashSessions(host, ids) {
96
127
  // 一律按"没归档过"处理,也就是恢复成普通会话)。
97
128
  wasArchived,
98
129
  deletedAt: Date.now()
99
- }, null, 2), "utf8");
130
+ };
131
+ await fsp.writeFile(join(target, META_FILE), JSON.stringify(meta, null, 2), "utf8");
100
132
  return steps;
101
133
  });
102
134
  }
135
+ /**
136
+ * 读一条回收站元数据(字段表与版本约定见文件头)。
137
+ *
138
+ * 三种结果:
139
+ * - 文件不在 / 不是 JSON 对象 / JSON 坏了 → `null`。与"没有元数据"同义:恢复不了,
140
+ * 但列表仍然要能显示它(`listTrash()` 会给它 `deletedAt: 0` / `restorable: false`)。
141
+ * - `version` 缺失(最早那一版写下的)或 ≤ 当前 → 原样返回,字段由调用方逐个校验。
142
+ * - `version` **比当前新** → 抛错。语义不可猜,宁可不恢复,也不能把一个会话按旧规则
143
+ * 塞进错误的目录(错误信息会出现在批量操作的逐项失败里)。
144
+ *
145
+ * 迁移放哪:将来真的升到 v2 时,在这里加一段"把 v1 读成 v2 形状"的转换,
146
+ * 然后再 `return`;`version` 的**当前**值只认 `TRASH_META_VERSION`。
147
+ */
148
+ export async function readTrashMeta(dir) {
149
+ let line;
150
+ try {
151
+ line = await fsp.readFile(join(dir, META_FILE), "utf8");
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ let parsed;
157
+ try {
158
+ parsed = JSON.parse(line);
159
+ }
160
+ catch {
161
+ return null;
162
+ }
163
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
164
+ return null;
165
+ // 跨 JSON 边界的一次收窄(AGENTS.md §5 允许的那种):上面已经证明它是个对象。
166
+ const meta = parsed;
167
+ // `version` 缺失 = 最早那版(插件还叫 `dsh-session-manager` 的时代)写下的,按 v1 处理。
168
+ const written = typeof meta.version === "number" ? meta.version : TRASH_META_VERSION;
169
+ if (written > TRASH_META_VERSION) {
170
+ throw new Error(`这条回收站元数据是更新版本的插件写的(version ${written},本插件只认到 ${TRASH_META_VERSION}):先升级插件,再恢复`);
171
+ }
172
+ return meta;
173
+ }
103
174
  /**
104
175
  * 从回收站恢复;尽力把会话重新挂回原工作区,并把归档状态还原成删除前的样子。
105
176
  *
@@ -115,13 +186,8 @@ export async function restoreSessions(host, ids) {
115
186
  const source = join(trashRoot(), sessionId);
116
187
  if (!existsSync(source))
117
188
  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
- }
189
+ // 版本比插件新时这里会抛(原因进逐项失败),别的坏情况一律回 null。
190
+ const meta = await readTrashMeta(source);
125
191
  if (typeof meta?.slug !== "string" || meta.slug === "") {
126
192
  throw new Error("缺少回收站元数据,无法确定原目录");
127
193
  }
@@ -174,6 +240,29 @@ export async function restoreSessions(host, ids) {
174
240
  * 界面上又找不到它)。
175
241
  */
176
242
  export async function purgeSessions(host, ids) {
243
+ return purgeEach(host, ids);
244
+ }
245
+ /**
246
+ * 「清理 N 天前」:**按时间**批量永久删除。
247
+ *
248
+ * 只认回收站目录里的条目(`purgeEach()` 会再查一次 `existsSync`),并且必须先把
249
+ * `deletedAt` 早于截止时间的条目挑出来 —— 挑的规则与预览共用
250
+ * `retention.ts` 的 `isExpired()`,所以"预览到的那批"就是"真删的那批"。
251
+ *
252
+ * 为什么这里不做静默后台清理:这是**永久删除**,本插件唯一不可撤销的动作。它只能由用户
253
+ * 在界面上点一次、看过预览、再过一道确认弹窗之后发生(客户端侧见配置页的保留策略一行)。
254
+ */
255
+ export async function purgeOlderThan(host, days) {
256
+ const cutoff = retentionCutoff(days);
257
+ const rows = await host.listTrash();
258
+ return purgeEach(host, rows.filter((row) => isExpired(row, cutoff)).map((row) => row.sessionId));
259
+ }
260
+ /**
261
+ * 逐项永久删除的**唯一**实现(`purge` 与 `purge-older-than` 都走它)。
262
+ *
263
+ * 顺序不能改:先 `unarchiveSession` 再 `rm`(理由见上面的注释)。
264
+ */
265
+ async function purgeEach(host, ids) {
177
266
  return host.each(ids, async (sessionId) => {
178
267
  const target = join(trashRoot(), sessionId);
179
268
  if (!existsSync(target))
package/lib/index.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * `ctx.connection.fetch.register`):信任检查与会话鉴权由 Connection 的 `/api`
17
17
  * 前缀路由先做完,所以插件自己不需要任何鉴权代码。
18
18
  *
19
- * 源码在 `src/`(TypeScript),`lib/` 是构建产物(`npm run build`),DSH 加载的是后者。
19
+ * 源码在 `src/`(TypeScript),`lib/` 是构建产物(`pnpm run build`),DSH 加载的是后者。
20
20
  */
21
21
  import { SessionManager } from "./host/session-manager.js";
22
22
  import { registerRoutes } from "./host/routes.js";
@@ -30,5 +30,9 @@ export function apply(ctx) {
30
30
  const manager = new SessionManager(ctx);
31
31
  registerRoutes(ctx, manager);
32
32
  }
33
- // 纯函数,给 `scripts/smoke.mjs` 直接单测(插件运行时用不到)。
33
+ // 纯函数 / 纯工具,给 `scripts/smoke.mjs` 直接单测(插件运行时用不到)。
34
34
  export { compareVersions } from "./host/update.js";
35
+ export { dirStats, forgetDirStats } from "./host/fs-utils.js";
36
+ export { isExpired, retentionCutoff } from "./host/retention.js";
37
+ // `open` 的路径校验单独导出:自检要直接断言那道判据,不必真的去调系统 shell。
38
+ export { insideRoot } from "./host/open.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-curator",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "DeepSeek Harness plugin: soft-delete sessions to a recoverable trash and manage archived/pinned ones, grouped by their original workspace folder. | DSH 插件:会话软删除到可恢复的回收站,并按原来的工作区文件夹管理已归档 / 已置顶会话。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -100,17 +100,9 @@
100
100
  "optional": true
101
101
  }
102
102
  },
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
103
  "files": [
111
104
  "lib",
112
105
  "locale",
113
- "scripts",
114
106
  "cordis.patch.yml",
115
107
  "icon.svg",
116
108
  "LICENSE",
@@ -120,6 +112,17 @@
120
112
  "devDependencies": {
121
113
  "@types/node": "^26.6.5",
122
114
  "esbuild": "^0.28.2",
115
+ "prettier": "^3.9.10",
123
116
  "typescript": "^5.9.3"
117
+ },
118
+ "scripts": {
119
+ "build": "node scripts/build.mjs",
120
+ "typecheck": "tsc -p tsconfig.host.json && tsc -p tsconfig.client.json",
121
+ "test": "pnpm run build && node scripts/artifact.mjs && node scripts/client.mjs && node scripts/smoke.mjs && node scripts/activation.mjs",
122
+ "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",
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
+ }
@@ -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);