weaver-e9-assistant-cli 1.0.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/dist/client.js ADDED
@@ -0,0 +1,199 @@
1
+ import { endpointFor } from './config.js';
2
+ import { AuthTokenSession } from './auth-token.js';
3
+ import { sleep } from './util.js';
4
+ const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
5
+ /**
6
+ * 响应是否疑似鉴权失效(需重试续期)。
7
+ * 认:HTTP 401/403;顶层 msg 含「登录信息超时/登录超时/无效认证/认证失败/未授权/E9AIAgent」。
8
+ * **不扫整个 body**——微博正文可合法含这些关键词(如日报记录本次认证改造就含"E9AIAgent无效认证"),
9
+ * 整体正则会把 200 成功响应当成鉴权失效误触发重试/弹浏览器。
10
+ * **不认空 body**——E9 有不少接口正常返回 200+空 body(成功但无内容),误判会多弹浏览器并把成功报成失败。
11
+ * 不认:业务层 status:false(如「记录不存在」),避免误触发重试。
12
+ */
13
+ function looksAuthFailed(er) {
14
+ if (!er)
15
+ return false; // fetch 抛错(er 为 null)走外层 er-null 分支
16
+ if (er.status === 401 || er.status === 403)
17
+ return true;
18
+ const j = er.json;
19
+ if (j && typeof j === 'object') {
20
+ const msg = String(j.msg ?? '');
21
+ if (/登录信息超时|登录超时|无效认证|认证失败|未授权|E9AIAgent/i.test(msg))
22
+ return true;
23
+ }
24
+ return false;
25
+ }
26
+ /** 把 fetch 响应包成 E9Response。 */
27
+ async function toE9Response(res) {
28
+ const text = await res.text();
29
+ let json = null;
30
+ if (text) {
31
+ try {
32
+ json = JSON.parse(text);
33
+ }
34
+ catch {
35
+ json = null;
36
+ }
37
+ }
38
+ return { status: res.status, ok: res.ok, text, json };
39
+ }
40
+ /** 拼 baseUrl+path+query,query 非空时追加(path 已含 ? 则用 &)。 */
41
+ function buildUrl(baseUrl, path, query) {
42
+ let url = baseUrl + path;
43
+ if (query && Object.keys(query).length > 0) {
44
+ const qs = new URLSearchParams();
45
+ for (const [k, v] of Object.entries(query))
46
+ qs.append(k, v);
47
+ url += (path.includes('?') ? '&' : '?') + qs.toString();
48
+ }
49
+ return url;
50
+ }
51
+ /**
52
+ * 底层裸请求:按显式 opts 构造 header 集合发请求,无鉴权/重试逻辑。
53
+ * 保留供 dry-run 路径或外部直接调用;E9Api 内部自带鉴权与重试。
54
+ */
55
+ export async function e9Request(ep, opts) {
56
+ const url = buildUrl(ep.baseUrl, opts.path, opts.query);
57
+ const headers = {
58
+ Referer: ep.baseUrl + '/wui/index.html',
59
+ 'X-Requested-With': 'XMLHttpRequest',
60
+ 'User-Agent': UA,
61
+ Accept: 'application/json, text/plain, */*',
62
+ Cookie: ep.cookie,
63
+ };
64
+ let bodyStr;
65
+ if (opts.method === 'POST') {
66
+ headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=utf-8';
67
+ const form = new URLSearchParams();
68
+ for (const [k, v] of Object.entries(opts.body ?? {}))
69
+ form.append(k, v);
70
+ bodyStr = form.toString();
71
+ }
72
+ const timeoutMs = Number(process.env.E9_TIMEOUT_MS ?? '20000');
73
+ const controller = new AbortController();
74
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
75
+ let res;
76
+ try {
77
+ res = await fetch(url, { method: opts.method, headers, body: bodyStr, signal: controller.signal });
78
+ }
79
+ catch (e) {
80
+ if (controller.signal.aborted) {
81
+ throw new Error(`请求超时(${timeoutMs}ms):${opts.method} ${url}。服务器可能故障或网络不通。可用 E9_TIMEOUT_MS 调大。`);
82
+ }
83
+ throw new Error(`请求失败:${opts.method} ${url} — ${e.message}。服务器可能不可达或网络不通。`);
84
+ }
85
+ finally {
86
+ clearTimeout(timer);
87
+ }
88
+ return toE9Response(res);
89
+ }
90
+ /**
91
+ * E9Api:双头鉴权 + 失效重试(对标 e9-cli lib/api.js 的 E9Api)。
92
+ * 有 token(ensureValid true)→ aiAgentToken 头(不带 cookie/X-Requested-With/Accept:application/json)
93
+ * 无 token / noToken → cookie 头(http:// 才带 X-Requested-With)
94
+ */
95
+ export class E9Api {
96
+ cfg;
97
+ baseUrl;
98
+ tokenSession;
99
+ constructor(cfg, tokenSession) {
100
+ this.cfg = cfg;
101
+ this.baseUrl = endpointFor(cfg).baseUrl.replace(/\/+$/, '');
102
+ this.tokenSession = tokenSession;
103
+ }
104
+ /** 取当前 cookie(实时读 config,反映 saveCookieToProfile 的回写)。 */
105
+ get cookie() {
106
+ return endpointFor(this.cfg).cookie;
107
+ }
108
+ async getHeaders(contentType, { forceCookie = false } = {}) {
109
+ const h = {
110
+ Accept: '*/*',
111
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
112
+ 'Content-Type': contentType ?? 'application/x-www-form-urlencoded; charset=utf-8',
113
+ };
114
+ if (!forceCookie && this.tokenSession && (await this.tokenSession.ensureValid()) && this.tokenSession.token) {
115
+ // token 路径:仅 aiAgentToken + Content-Type(不带 cookie/X-Requested-With/Accept:application/json)
116
+ h['aiAgentToken'] = this.tokenSession.token;
117
+ }
118
+ else {
119
+ // cookie 回退路径:复刻原 e9Request 的头集合(Referer/UA/X-Requested-With),保证 E9 后端识别为 WUI 内部调用
120
+ const cookie = this.cookie;
121
+ if (cookie) {
122
+ h['Cookie'] = cookie;
123
+ h['Referer'] = this.baseUrl + '/wui/index.html';
124
+ h['User-Agent'] = UA;
125
+ h['Accept'] = 'application/json, text/plain, */*';
126
+ if (this.baseUrl.startsWith('http://'))
127
+ h['X-Requested-With'] = 'XMLHttpRequest';
128
+ }
129
+ }
130
+ return h;
131
+ }
132
+ /** 单次请求(带 getHeaders),返回 E9Response 或 null(fetch 抛错)。opts.forceCookie=true 强制走 cookie 头(不调 ensureValid)。 */
133
+ async fetchOnce(method, url, bodyStr, timeoutMs = 15000, opts = {}) {
134
+ const headers = await this.getHeaders(undefined, opts);
135
+ const init = { method, headers, signal: AbortSignal.timeout(timeoutMs) };
136
+ if (bodyStr !== undefined)
137
+ init.body = bodyStr;
138
+ const res = await fetch(url, init).catch(() => null);
139
+ return res ? await toE9Response(res) : null;
140
+ }
141
+ /**
142
+ * 带失效重试的单次请求(get/post 共享):
143
+ * 瞬时 401/登录超时 → 同 token 重试 3 次(500ms 间隔,应对服务器抖动,不弹浏览器);
144
+ * ★#14 仍失败 → 先 cookie-only 重发一次(token 可能被服务端吊销但 cookie 还能用,避免直接弹浏览器);
145
+ * 还失败且未设 E9_NO_REAUTH 且 token 可续期且未在退避窗口 → acquireToken(reactive 重登)再试一次;
146
+ * 最终仍失败 → 抛鉴权失效错。
147
+ */
148
+ async requestWithRetry(method, url, bodyStr, timeoutMs = 15000) {
149
+ let er = await this.fetchOnce(method, url, bodyStr, timeoutMs);
150
+ if (looksAuthFailed(er)) {
151
+ for (let i = 0; i < 3 && looksAuthFailed(er); i++) {
152
+ await sleep(500);
153
+ er = await this.fetchOnce(method, url, bodyStr, timeoutMs);
154
+ }
155
+ // ★ #14 先 cookie-only 重发一次(forceCookie 跳过 ensureValid,直接走 cookie 头)
156
+ if (looksAuthFailed(er) && this.cookie) {
157
+ await sleep(300);
158
+ er = await this.fetchOnce(method, url, bodyStr, timeoutMs, { forceCookie: true });
159
+ }
160
+ // ★ reactive 续期:尊重 #13 退避窗口(acquireFailedAt 10min 内不反复弹浏览器)
161
+ const backoff = this.tokenSession?.data?.acquireFailedAt && Date.now() - this.tokenSession.data.acquireFailedAt < 10 * 60 * 1000;
162
+ if (looksAuthFailed(er) && !process.env.E9_NO_REAUTH && this.tokenSession && this.tokenSession.data?.token && !this.tokenSession.data?.noToken && !backoff) {
163
+ try {
164
+ await this.tokenSession.acquireToken();
165
+ }
166
+ catch { /* browser/network 失败 → 下面回退 cookie */ }
167
+ er = await this.fetchOnce(method, url, bodyStr, timeoutMs);
168
+ }
169
+ if (!er || looksAuthFailed(er)) {
170
+ throw new Error('鉴权失效,请运行 e9 auth token-login 或确认 .e9/config.json 的 cookie');
171
+ }
172
+ }
173
+ if (!er)
174
+ throw new Error(`请求失败:${method} ${url}。服务器可能不可达或网络不通。`);
175
+ return er;
176
+ }
177
+ /** GET。URL+query 拼好后委托 requestWithRetry。 */
178
+ async get(path, query) {
179
+ return this.requestWithRetry('GET', buildUrl(this.baseUrl, path, query));
180
+ }
181
+ /** POST。body 用 URLSearchParams(与原 e9Request 编码一致),委托 requestWithRetry。 */
182
+ async post(path, body) {
183
+ const form = new URLSearchParams();
184
+ for (const [k, v] of Object.entries(body ?? {}))
185
+ form.append(k, v);
186
+ return this.requestWithRetry('POST', buildUrl(this.baseUrl, path), form.toString(), 30000);
187
+ }
188
+ }
189
+ /**
190
+ * 建当前环境的 E9Api(单一端点)。baseUrl 空或 http://localhost:8080 → tokenSession=null(纯 cookie)。
191
+ */
192
+ export async function createApiFromProfile(cfg) {
193
+ const endpoint = endpointFor(cfg);
194
+ let tokenSession = null;
195
+ if (endpoint.baseUrl && endpoint.baseUrl.replace(/\/+$/, '') !== 'http://localhost:8080') {
196
+ tokenSession = AuthTokenSession.fromProfile(cfg);
197
+ }
198
+ return new E9Api(cfg, tokenSession);
199
+ }
package/dist/config.js ADDED
@@ -0,0 +1,124 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ /** 取当前环境的唯一端点(不再按模块拆分——所有功能访问同一个 baseUrl)。 */
5
+ export function endpointFor(cfg) {
6
+ return { baseUrl: cfg.baseUrl.replace(/\/+$/, ''), cookie: cfg.cookie ?? '' };
7
+ }
8
+ export const DEFAULT_MENU_IDS = '1,13';
9
+ /** 待办三分类的标准默认 tab(取自 references/todo.md 实跑确认的 viewcondition)。config 未配 tabs 时兜底,避免 todo list 报"找不到 tab: (空)"。 */
10
+ export const DEFAULT_TABS = {
11
+ todo: { viewcondition: '5', name: '待处理' },
12
+ done: { viewcondition: '10', name: '已办' },
13
+ mine: { viewcondition: '16', name: '我发起的' },
14
+ };
15
+ export function resolveConfigPath(explicit) {
16
+ const candidates = [];
17
+ if (explicit)
18
+ candidates.push(explicit);
19
+ if (process.env.E9_CONFIG)
20
+ candidates.push(process.env.E9_CONFIG);
21
+ candidates.push(path.join(process.cwd(), 'e9config', 'config.json'));
22
+ candidates.push(path.join(os.homedir(), '.e9', 'config.json'));
23
+ for (const c of candidates) {
24
+ try {
25
+ if (fs.existsSync(c))
26
+ return c;
27
+ }
28
+ catch { /* ignore */ }
29
+ }
30
+ return null;
31
+ }
32
+ export function defaultConfigPath() {
33
+ const local = path.join(process.cwd(), 'e9config', 'config.json');
34
+ if (fs.existsSync(local))
35
+ return local;
36
+ return path.join(os.homedir(), '.e9', 'config.json');
37
+ }
38
+ export function loadConfig(explicit) {
39
+ const p = resolveConfigPath(explicit);
40
+ if (!p)
41
+ throw new Error('找不到 E9 配置文件');
42
+ let raw;
43
+ try {
44
+ raw = JSON.parse(fs.readFileSync(p, 'utf8'));
45
+ }
46
+ catch (e) {
47
+ throw new Error('配置文件 ' + p + ' 不是合法 JSON:' + e.message);
48
+ }
49
+ if (typeof raw.baseUrl !== 'string' || !raw.baseUrl)
50
+ throw new Error('配置文件 ' + p + ' 缺少 baseUrl');
51
+ return {
52
+ baseUrl: raw.baseUrl.replace(/\/+$/, ''),
53
+ cookie: typeof raw.cookie === 'string' ? raw.cookie : undefined,
54
+ username: raw.username,
55
+ encPassword: raw.encPassword,
56
+ menuIds: raw.menuIds ?? DEFAULT_MENU_IDS,
57
+ menuPathIds: raw.menuPathIds ?? raw.menuIds ?? DEFAULT_MENU_IDS,
58
+ emailMenuIds: raw.emailMenuIds,
59
+ defaultTab: raw.defaultTab ?? 'todo',
60
+ tabs: raw.tabs ?? DEFAULT_TABS,
61
+ llmConsent: raw.llmConsent === true || String(raw.llmConsent).toLowerCase() === 'true',
62
+ _configPath: p,
63
+ };
64
+ }
65
+ export function resolveViewcondition(cfg, tab) {
66
+ const tabs = cfg.tabs ?? {};
67
+ const key = tab ?? cfg.defaultTab ?? Object.keys(tabs)[0];
68
+ if (!key || !tabs[key])
69
+ throw new Error('找不到 tab: ' + (key ?? '(空)'));
70
+ const t = tabs[key];
71
+ return { key, viewcondition: String(t.viewcondition), name: t.name };
72
+ }
73
+ export function saveConfig(cfg) {
74
+ const p = cfg._configPath ?? defaultConfigPath();
75
+ const raw = {
76
+ baseUrl: cfg.baseUrl.replace(/\/+$/, ''),
77
+ cookie: cfg.cookie ?? '',
78
+ menuIds: cfg.menuIds,
79
+ menuPathIds: cfg.menuPathIds,
80
+ defaultTab: cfg.defaultTab ?? 'todo',
81
+ tabs: cfg.tabs ?? DEFAULT_TABS,
82
+ };
83
+ if (cfg.llmConsent !== undefined)
84
+ raw.llmConsent = cfg.llmConsent === true || String(cfg.llmConsent).toLowerCase() === 'true';
85
+ if (cfg.username !== undefined)
86
+ raw.username = cfg.username;
87
+ if (cfg.encPassword !== undefined)
88
+ raw.encPassword = cfg.encPassword;
89
+ try {
90
+ fs.mkdirSync(path.dirname(p), { recursive: true });
91
+ fs.writeFileSync(p, JSON.stringify(raw, null, 2) + '\n', 'utf8');
92
+ }
93
+ catch (e) {
94
+ if (e.code === 'EPERM' || e.code === 'EACCES') {
95
+ throw new Error('无法写入配置文件 ' + p + ',请检查文件权限或沙箱设置(可尝试 danger-full-access)');
96
+ }
97
+ console.error('[saveConfig] write failed:', e.message);
98
+ }
99
+ }
100
+ export function setConfigField(field, value, explicit) {
101
+ const p = explicit ?? resolveConfigPath(explicit) ?? defaultConfigPath();
102
+ let raw = {};
103
+ try {
104
+ raw = JSON.parse(fs.readFileSync(p, 'utf8'));
105
+ }
106
+ catch { /* new */ }
107
+ if (field === 'baseUrl')
108
+ raw.baseUrl = value.replace(/\/+$/, '');
109
+ else
110
+ raw[field] = value;
111
+ fs.mkdirSync(path.dirname(p), { recursive: true });
112
+ fs.writeFileSync(p, JSON.stringify(raw, null, 2) + '\n', 'utf8');
113
+ console.log('已设置 ' + field + ' (写入 ' + p + ')');
114
+ }
115
+ export function profileNameFromBaseUrl(baseUrl) {
116
+ try {
117
+ const u = new URL(baseUrl);
118
+ const host = (u.hostname || '').replace(/[^a-zA-Z0-9]/g, '_');
119
+ return host + (u.port ? '_' + u.port : '') || 'override';
120
+ }
121
+ catch {
122
+ return 'override';
123
+ }
124
+ }
@@ -0,0 +1,41 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ function contextDir() {
6
+ return path.resolve(process.cwd(), 'state', 'create_contexts');
7
+ }
8
+ function contextPath(id) {
9
+ if (!/^[a-zA-Z0-9-]+$/.test(String(id)))
10
+ throw new Error('context_id 格式无效。');
11
+ return path.join(contextDir(), `${id}.json`);
12
+ }
13
+ function write(ctx) {
14
+ fs.mkdirSync(contextDir(), { recursive: true });
15
+ fs.writeFileSync(contextPath(ctx.id), JSON.stringify(ctx, null, 2), 'utf8');
16
+ return ctx;
17
+ }
18
+
19
+ export function createContext({ workflowId, workflowName }) {
20
+ return write({
21
+ id: randomUUID(),
22
+ workflowId: String(workflowId),
23
+ workflowName: String(workflowName ?? ''),
24
+ values: {},
25
+ requestName: '',
26
+ details: [],
27
+ files: [],
28
+ createdAt: new Date().toISOString(),
29
+ updatedAt: new Date().toISOString(),
30
+ });
31
+ }
32
+ export function loadContext(id) {
33
+ const p = contextPath(id);
34
+ if (!fs.existsSync(p))
35
+ throw new Error(`未找到发起上下文“${id}”。请先运行 create form。`);
36
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
37
+ }
38
+ export function updateContext(id, patch) {
39
+ const old = loadContext(id);
40
+ return write({ ...old, ...patch, updatedAt: new Date().toISOString() });
41
+ }
package/dist/crypto.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * AES-256-GCM 加解密(对标 e9-cli lib/crypto.js)。
3
+ *
4
+ * 用途:加密存 aiAgentToken 文件、profile.encPassword 的解密。
5
+ *
6
+ * 密钥管理(v2,修复 v1 硬编码静态种子的安全缺陷):
7
+ * - 主密钥:首次运行生成 32 字节随机数,写入 ~/.e9/master.key(chmod 0o600,Windows 忽略)。
8
+ * 每台机器/每个安装一把独立钥匙,源码泄露不再能解密他人 token。
9
+ * - 旧密钥(LEGACY_KEY):v1 用静态 SEED SHA256,所有安装共享。decrypt 读不到主密钥时
10
+ * 回退旧密钥,保证存量 token/encPassword 仍可读;save() 用新密钥重写即自动迁移。
11
+ *
12
+ * 存储信封统一:JSON.stringify({ enc: true, v: 1, data: <base64> }) + "\n"。
13
+ */
14
+ import crypto from 'node:crypto';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import os from 'node:os';
18
+ const LEGACY_SEED = 'e9_cli_auth_v1_7b2e9d4f1a8c';
19
+ const LEGACY_KEY = crypto.createHash('sha256').update(LEGACY_SEED).digest(); // 32 字节
20
+ let masterKeyCache = null;
21
+ /** 主密钥文件路径(支持 E9_MASTER_KEY_PATH 覆盖,便于测试)。 */
22
+ function masterKeyPath() {
23
+ return process.env.E9_MASTER_KEY_PATH ?? path.join(os.homedir(), '.e9', 'master.key');
24
+ }
25
+ /**
26
+ * 取主密钥:有 master.key 用之;没有则生成并写盘(0o600)。
27
+ * 写盘失败(权限等)落回 LEGACY_KEY,保证存量文件仍可读,不阻断首次使用。
28
+ * 进程内缓存,避免反复读盘。
29
+ */
30
+ function getMasterKey() {
31
+ if (masterKeyCache)
32
+ return masterKeyCache;
33
+ try {
34
+ const p = masterKeyPath();
35
+ if (fs.existsSync(p)) {
36
+ const raw = fs.readFileSync(p, 'utf8').trim();
37
+ const buf = Buffer.from(raw, 'base64');
38
+ if (buf.length === 32) {
39
+ masterKeyCache = buf;
40
+ return buf;
41
+ }
42
+ }
43
+ }
44
+ catch {
45
+ /* 读失败下面尝试生成 */
46
+ }
47
+ // 生成新主密钥并写盘
48
+ const newKey = crypto.randomBytes(32);
49
+ try {
50
+ const p = masterKeyPath();
51
+ fs.mkdirSync(path.dirname(p), { recursive: true });
52
+ fs.writeFileSync(p, newKey.toString('base64') + '\n', {
53
+ encoding: 'utf8',
54
+ mode: 0o600,
55
+ });
56
+ try {
57
+ fs.chmodSync(p, 0o600);
58
+ }
59
+ catch { /* Windows 忽略 */ }
60
+ masterKeyCache = newKey;
61
+ return newKey;
62
+ }
63
+ catch {
64
+ /* 写盘失败:落回旧密钥(降级,存量仍可读,但新写的也用旧密钥) */
65
+ masterKeyCache = LEGACY_KEY;
66
+ return LEGACY_KEY;
67
+ }
68
+ }
69
+ /**
70
+ * 加密明文字符串(用主密钥)。
71
+ * 输出布局:IV(12) + 密文 + tag(16),整体 base64。IV 每次随机,故同明文密文不同。
72
+ */
73
+ export function encrypt(plaintext) {
74
+ const key = getMasterKey();
75
+ const iv = crypto.randomBytes(12);
76
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
77
+ const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
78
+ const tag = cipher.getAuthTag(); // 16 字节
79
+ return Buffer.concat([iv, enc, tag]).toString('base64');
80
+ }
81
+ /**
82
+ * 解密 encrypt() 输出的 base64。
83
+ * 先用主密钥;tag 不匹配(v1 旧密文)回退 LEGACY_KEY,保证存量文件透明迁移。
84
+ */
85
+ export function decrypt(b64) {
86
+ const buf = Buffer.from(b64, 'base64');
87
+ if (buf.length < 12 + 16)
88
+ throw new Error('密文过短');
89
+ const iv = buf.subarray(0, 12);
90
+ const tag = buf.subarray(buf.length - 16);
91
+ const enc = buf.subarray(12, buf.length - 16);
92
+ // 先主密钥
93
+ try {
94
+ const decipher = crypto.createDecipheriv('aes-256-gcm', getMasterKey(), iv);
95
+ decipher.setAuthTag(tag);
96
+ return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
97
+ }
98
+ catch {
99
+ /* 主密钥解不开,尝试旧密钥(v1 存量迁移) */
100
+ }
101
+ const decipher = crypto.createDecipheriv('aes-256-gcm', LEGACY_KEY, iv);
102
+ decipher.setAuthTag(tag);
103
+ const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
104
+ return dec.toString('utf8');
105
+ }
106
+ /** 把明文写成加密信封 JSON 字符串(带尾换行)。 */
107
+ export function wrapEncrypted(plaintext) {
108
+ return JSON.stringify({ enc: true, v: 1, data: encrypt(plaintext) }, null, 2) + '\n';
109
+ }
110
+ /**
111
+ * 解析加密信封;兼容明文 { token } / 裸字符串。
112
+ * 返回 null 表示文件不存在或不可解析。
113
+ */
114
+ export function unwrapEncrypted(raw) {
115
+ const trimmed = raw.trim();
116
+ if (!trimmed)
117
+ return null;
118
+ try {
119
+ const j = JSON.parse(trimmed);
120
+ if (j && j.enc === true && typeof j.data === 'string') {
121
+ try {
122
+ return decrypt(j.data);
123
+ }
124
+ catch {
125
+ /* 密文损坏,落回其他形态 */
126
+ }
127
+ }
128
+ if (j && typeof j.token === 'string')
129
+ return trimmed; // 明文信封,原样返回由上层再 JSON.parse
130
+ }
131
+ catch {
132
+ /* 非 JSON */
133
+ }
134
+ return trimmed; // 裸字符串
135
+ }
136
+ // 仅测试用:重置主密钥缓存(避免用例间互相污染)
137
+ export function __resetMasterKeyCacheForTests() {
138
+ masterKeyCache = null;
139
+ }