dsh-session-manager 0.4.11 → 0.5.2
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/CHANGELOG.md +60 -2
- package/README.md +73 -44
- package/README.zh.md +137 -112
- package/lib/annotation-store.js +199 -0
- package/lib/client.js +2772 -1196
- package/lib/clipboard-parser.js +208 -0
- package/lib/compat/dsh-adapter.js +17 -0
- package/lib/compat/zstd-frames.js +2 -2
- package/lib/index.js +1243 -1247
- package/lib/session-files.js +193 -0
- package/package.json +10 -2
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { decompressAllZstdFrames, decompressZstdFrame, scanZstdFrames } from "./compat/zstd-frames.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Filenames tried in this order to locate a session's durable artifact.
|
|
8
|
+
* V4 first: DSH 0.1.7+ writes V4 by default; existing V3/V2/V1 files remain
|
|
9
|
+
* readable. The list-snapshot reader is version-agnostic (it parses the
|
|
10
|
+
* header JSON regardless of declared version), so we only need the filename
|
|
11
|
+
* in the candidate list — no per-version code paths.
|
|
12
|
+
*/
|
|
13
|
+
export const ARTIFACT_NAMES = [
|
|
14
|
+
"session.v4.jsonl.zstd",
|
|
15
|
+
"session.v3.jsonl.zstd",
|
|
16
|
+
"session.v2.jsonl.zstd",
|
|
17
|
+
"session.jsonl.zstd",
|
|
18
|
+
"session.jsonl"
|
|
19
|
+
];
|
|
20
|
+
const MAX_HEADER_BYTES = 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
export function assertSessionId(id) {
|
|
23
|
+
if (typeof id !== "string" || id.trim() === "" || id === "." || id === ".."
|
|
24
|
+
|| /[\\/\x00-\x1f<>:"|?*]/.test(id) || /[. ]$/.test(id)) {
|
|
25
|
+
throw Object.assign(new Error("sessionId 必须是合法的会话标识,不能包含路径或特殊目录名"), { code: "bad-request" });
|
|
26
|
+
}
|
|
27
|
+
return id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function encodeSessionSegment(id) {
|
|
31
|
+
let readable = "";
|
|
32
|
+
let separatorRun = false;
|
|
33
|
+
for (let i = 0; i < id.length; i++) {
|
|
34
|
+
const ch = id[i];
|
|
35
|
+
const code = id.charCodeAt(i);
|
|
36
|
+
if (ch === "/" || ch === "\\" || ch === ":") {
|
|
37
|
+
if (!separatorRun) readable += "-";
|
|
38
|
+
separatorRun = true;
|
|
39
|
+
} else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
40
|
+
readable += ch;
|
|
41
|
+
separatorRun = false;
|
|
42
|
+
} else {
|
|
43
|
+
readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
|
|
44
|
+
separatorRun = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return "--" + (readable.replace(/^-+/, "") || "root").slice(0, 251) + "--";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Only an actual <sessions>/<project>/<session> directory may be mutated.
|
|
51
|
+
* The root itself may be configured through a symlink, but neither child may
|
|
52
|
+
* be a symlink/junction. Check again immediately before destructive cleanup.
|
|
53
|
+
*/
|
|
54
|
+
export async function assertSessionDirectory(root, directory, id, { allowMissing = false } = {}) {
|
|
55
|
+
assertSessionId(id);
|
|
56
|
+
if (typeof root !== "string" || typeof directory !== "string") {
|
|
57
|
+
throw new Error("无法验证会话目录,已停止文件操作");
|
|
58
|
+
}
|
|
59
|
+
const base = resolve(root);
|
|
60
|
+
const target = resolve(directory);
|
|
61
|
+
const rel = relative(base, target);
|
|
62
|
+
const parts = rel.split(sep);
|
|
63
|
+
if (isAbsolute(rel) || parts.length !== 2 || parts.some(p => p === ".." || p === "." || p === "")
|
|
64
|
+
|| ![id, encodeSessionSegment(id)].includes(parts[1])) {
|
|
65
|
+
throw new Error("会话目录超出允许范围,已停止文件操作");
|
|
66
|
+
}
|
|
67
|
+
const canonicalRoot = await fs.realpath(base);
|
|
68
|
+
let current = base;
|
|
69
|
+
for (const part of parts) {
|
|
70
|
+
current = join(current, part);
|
|
71
|
+
let info;
|
|
72
|
+
try { info = await fs.lstat(current); }
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (allowMissing && error.code === "ENOENT") return target;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
78
|
+
throw new Error("会话目录不能是符号链接、junction 或普通文件");
|
|
79
|
+
}
|
|
80
|
+
const canonical = await fs.realpath(current);
|
|
81
|
+
const child = relative(canonicalRoot, canonical);
|
|
82
|
+
if (isAbsolute(child) || child === "" || child.split(sep).includes("..")) {
|
|
83
|
+
throw new Error("会话目录解析到了允许范围之外");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return target;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseHeader(content) {
|
|
90
|
+
const newline = content.indexOf("\n");
|
|
91
|
+
if (newline < 0) throw new Error("会话工件缺少完整头行");
|
|
92
|
+
const header = JSON.parse(content.slice(0, newline));
|
|
93
|
+
assertSessionId(header?.id);
|
|
94
|
+
return header;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function assertRegularFile(path) {
|
|
98
|
+
const info = await fs.lstat(path);
|
|
99
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error("会话工件必须是普通文件,不能是符号链接");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Header-only discovery: never inflate the history just to list an orphan. */
|
|
103
|
+
export async function readSessionHeader(path) {
|
|
104
|
+
await assertRegularFile(path);
|
|
105
|
+
const handle = await fs.open(path, "r");
|
|
106
|
+
try {
|
|
107
|
+
let buffer = Buffer.alloc(0);
|
|
108
|
+
while (buffer.length < MAX_HEADER_BYTES) {
|
|
109
|
+
const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_HEADER_BYTES - buffer.length));
|
|
110
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, buffer.length);
|
|
111
|
+
if (bytesRead === 0) break;
|
|
112
|
+
buffer = Buffer.concat([buffer, chunk.subarray(0, bytesRead)]);
|
|
113
|
+
if (path.endsWith(".zstd")) {
|
|
114
|
+
const { frames } = scanZstdFrames(buffer, 1);
|
|
115
|
+
if (frames.length) {
|
|
116
|
+
const frame = frames[0];
|
|
117
|
+
const decoded = await decompressZstdFrame(buffer.subarray(frame.start, frame.end), { maxOutputLength: MAX_HEADER_BYTES });
|
|
118
|
+
return parseHeader(decoded.toString("utf8"));
|
|
119
|
+
}
|
|
120
|
+
} else if (buffer.includes(10)) {
|
|
121
|
+
return parseHeader(buffer.toString("utf8"));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
throw new Error("会话头行不完整或超过大小限制");
|
|
125
|
+
} finally {
|
|
126
|
+
await handle.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Mutators must never publish a successfully decoded prefix as a full log. */
|
|
131
|
+
export async function readSessionFile(path) {
|
|
132
|
+
await assertRegularFile(path);
|
|
133
|
+
const bytes = await fs.readFile(path);
|
|
134
|
+
let content;
|
|
135
|
+
if (path.endsWith(".zstd")) {
|
|
136
|
+
const decoded = await decompressAllZstdFrames(bytes);
|
|
137
|
+
if (decoded.torn) throw new Error("会话日志存在截断的 Zstd 帧,拒绝重写;请先备份并修复");
|
|
138
|
+
content = decoded.content.toString("utf8");
|
|
139
|
+
} else {
|
|
140
|
+
content = bytes.toString("utf8");
|
|
141
|
+
}
|
|
142
|
+
if (!content.endsWith("\n")) throw new Error("会话日志存在不完整的尾行,拒绝重写");
|
|
143
|
+
return { meta: parseHeader(content), content, path, filename: basename(path) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Inject only filesystem operations for deterministic fault-injection tests. */
|
|
147
|
+
export async function writeTempFile(finalPath, data, io = fs) {
|
|
148
|
+
const temp = `${finalPath}.${randomBytes(6).toString("hex")}.tmp`;
|
|
149
|
+
const handle = await io.open(temp, "wx", 0o600);
|
|
150
|
+
try {
|
|
151
|
+
await handle.writeFile(data);
|
|
152
|
+
await handle.sync();
|
|
153
|
+
} catch (error) {
|
|
154
|
+
try { await handle.close(); } catch { /* retain the original write error */ }
|
|
155
|
+
try { await io.rm(temp, { force: true }); } catch { /* uncommitted temp only */ }
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
try { await handle.close(); }
|
|
159
|
+
catch (error) {
|
|
160
|
+
try { await io.rm(temp, { force: true }); } catch { /* uncommitted temp only */ }
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
return temp;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Backup, publish, and rollback are distinct phases. Never remove the source
|
|
167
|
+
* when creating its backup failed. A failed rollback leaves the backup intact
|
|
168
|
+
* and reports its exact recovery path rather than silently swallowing failure.
|
|
169
|
+
*/
|
|
170
|
+
export async function replaceSessionFile(path, bytes, io = fs) {
|
|
171
|
+
const temp = await writeTempFile(path, bytes, io);
|
|
172
|
+
const backup = `${path}.${randomBytes(6).toString("hex")}.bak`;
|
|
173
|
+
try {
|
|
174
|
+
await io.rename(path, backup);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
try { await io.rm(temp, { force: true }); } catch { /* uncommitted temp only */ }
|
|
177
|
+
throw new Error(`无法备份会话工件,未删除原文件: ${error.message}`, { cause: error });
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await io.rename(temp, path);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
try {
|
|
183
|
+
await io.rename(backup, path);
|
|
184
|
+
} catch (rollbackError) {
|
|
185
|
+
throw new AggregateError([error, rollbackError], `发布失败且回滚失败;原始会话保留在 ${backup},新文件保留在 ${temp}`);
|
|
186
|
+
}
|
|
187
|
+
try { await io.rm(temp, { force: true }); } catch { /* original already restored */ }
|
|
188
|
+
throw new Error(`发布失败,原始会话已恢复: ${error.message}`, { cause: error });
|
|
189
|
+
}
|
|
190
|
+
try { await io.rm(backup, { force: true }); }
|
|
191
|
+
catch { return { backupPath: backup }; }
|
|
192
|
+
return {};
|
|
193
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-manager",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.2",
|
|
4
|
+
"description": "DSH Web 会话管理:删除、归档、跨工作区移动、迁移预设;收藏、待看、搜索、排序、设置优先级,添加标签和备注;批量处理。欢迎至 GitHub 提意见。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|
|
@@ -51,5 +51,13 @@
|
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"cordis": ">=4.0.0-rc <5"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": "^22.15.0 || >=24.0.0"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/session-files.js && node --check lib/annotation-store.js && node --check lib/clipboard-parser.js && node -e \"import('./lib/index.js')\"",
|
|
60
|
+
"test": "node scripts/run-tests.mjs",
|
|
61
|
+
"check:package": "node scripts/check-package.mjs"
|
|
54
62
|
}
|
|
55
63
|
}
|