@dreanzy/dsh-skill-pack 0.1.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.
@@ -0,0 +1,135 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { normalizeCwd } from './policy.js';
3
+ import { effective } from './effective.js';
4
+ import { groupsPath, packsPath, workspacesPath, } from './store.js';
5
+ function cwdKeyOf(cwd) {
6
+ return normalizeCwd(cwd) ?? '(global)';
7
+ }
8
+ /**
9
+ * scope 身份:对象用 WeakMap 发号,不做结构比较。
10
+ *
11
+ * 宿主按**对象身份**索引作用域链,两个 preset 的 agent 可能是形状相同但身份不同的
12
+ * 两个对象。按形状比较会把它们当成同一个,代价是清单串台。
13
+ */
14
+ const scopeIds = new WeakMap();
15
+ let nextScopeId = 1;
16
+ function scopeKeyOf(scope) {
17
+ if (scope === undefined || scope === null)
18
+ return '-';
19
+ if (typeof scope === 'object' || typeof scope === 'function') {
20
+ const existing = scopeIds.get(scope);
21
+ if (existing !== undefined)
22
+ return `s${String(existing)}`;
23
+ const assigned = nextScopeId;
24
+ nextScopeId += 1;
25
+ scopeIds.set(scope, assigned);
26
+ return `s${String(assigned)}`;
27
+ }
28
+ return `v:${String(scope)}`;
29
+ }
30
+ /** 单个文件的指纹。不存在记 `absent`,读不到记 `error`——两者都不该被当成「没变」。 */
31
+ async function fileStamp(path) {
32
+ try {
33
+ const info = await stat(path);
34
+ return `${String(info.size)}:${String(Math.round(info.mtimeMs))}`;
35
+ }
36
+ catch (error) {
37
+ if (error.code === 'ENOENT')
38
+ return 'absent';
39
+ return 'error';
40
+ }
41
+ }
42
+ /**
43
+ * 三份状态文件的指纹。
44
+ *
45
+ * **只做兜底**:同进程写入用 `invalidate()` 显式打掉,不依赖时间戳。这里覆盖的是
46
+ * 「用户在编辑器里手改了文件」——那种改动没有事件可订阅。
47
+ *
48
+ * 已知盲区:同毫秒内改完且字节数不变时指纹一样。显式失效路径能覆盖同进程写入,
49
+ * 所以这个盲区只在「外部手改 + 恰好同毫秒同字节数」时成立,代价是旧值多活一轮。
50
+ */
51
+ export async function fingerprintOf(cwd) {
52
+ // cwd 已不进指纹(旧工作区层不再读);参数保留以匹配 `SnapshotDeps.fingerprint` 的形状。
53
+ const paths = [packsPath(), groupsPath(), workspacesPath()];
54
+ const stamps = await Promise.all(paths.map((path) => fileStamp(path)));
55
+ return stamps.join('|');
56
+ }
57
+ /**
58
+ * 建一个按 cwd + scope 索引的求值缓存。
59
+ *
60
+ * 三条性质:
61
+ * 1. **`read()` 零 IO**:只用内存,供同步装配路径使用。
62
+ * 2. **`refresh()` 单飞**:同键并发请求共用一次计算,不会 N 份重复读盘。
63
+ * 3. **失效分两种**:显式 `invalidate()` 不依赖时间戳;指纹只兜外部手改。
64
+ */
65
+ export function createScopeSnapshot(deps) {
66
+ const fingerprint = deps.fingerprint ?? fingerprintOf;
67
+ const entries = new Map();
68
+ /** cwd 键 → 该目录下所有 scope 的键。用于按目录失效。 */
69
+ const keysByCwd = new Map();
70
+ const inflight = new Map();
71
+ const remember = (cwdKey, key) => {
72
+ let keys = keysByCwd.get(cwdKey);
73
+ if (keys === undefined) {
74
+ keys = new Set();
75
+ keysByCwd.set(cwdKey, keys);
76
+ }
77
+ keys.add(key);
78
+ };
79
+ const compute = async (cwd, cwdKey, scope, key, force) => {
80
+ const cached = entries.get(key);
81
+ const stamp = await fingerprint(cwd);
82
+ if (!force && cached !== undefined && cached.fingerprint === stamp)
83
+ return cached.value;
84
+ // 两件事互不依赖:并行读,串行等会白付一次往返。
85
+ const [policy, inventory] = await Promise.all([deps.loadPolicy(cwd), deps.readInventory(cwd, scope)]);
86
+ const answer = effective({
87
+ policy,
88
+ names: inventory.rows.map((row) => row.name),
89
+ scope: 'workspace',
90
+ complete: inventory.complete,
91
+ });
92
+ const value = {
93
+ rows: inventory.rows,
94
+ complete: inventory.complete,
95
+ hidden: answer.hidden,
96
+ policy,
97
+ warnings: policy.warnings,
98
+ };
99
+ entries.set(key, { value, fingerprint: stamp });
100
+ remember(cwdKey, key);
101
+ return value;
102
+ };
103
+ return {
104
+ async refresh(cwd, scope, options = {}) {
105
+ const cwdKey = cwdKeyOf(cwd);
106
+ const key = `${cwdKey}|${scopeKeyOf(scope)}`;
107
+ // 单飞:并发刷新同一格时共用一次计算,避免把一次保存放大成 N 次读盘。
108
+ const running = inflight.get(key);
109
+ if (running !== undefined)
110
+ return await running;
111
+ const task = compute(cwd, cwdKey, scope, key, options.force === true);
112
+ inflight.set(key, task);
113
+ try {
114
+ return await task;
115
+ }
116
+ finally {
117
+ inflight.delete(key);
118
+ }
119
+ },
120
+ invalidate(cwd) {
121
+ if (cwd === undefined) {
122
+ entries.clear();
123
+ keysByCwd.clear();
124
+ return;
125
+ }
126
+ const cwdKey = cwdKeyOf(cwd);
127
+ const keys = keysByCwd.get(cwdKey);
128
+ if (keys === undefined)
129
+ return;
130
+ for (const key of keys)
131
+ entries.delete(key);
132
+ keysByCwd.delete(cwdKey);
133
+ },
134
+ };
135
+ }
package/lib/store.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { type Bindings, type Command, type GroupRegistry, type Pack, type PackBook, type UiState } from './types.js';
2
+ /**
3
+ * 三个状态文件,各自独立事务。
4
+ *
5
+ * 路径一律走 `dshHomePath`,不自己拼 `$DSH_HOME` 前缀:宿主允许用环境变量或
6
+ * 显式配置改根目录,自己拼会绕开它。
7
+ */
8
+ export declare function packsPath(): string;
9
+ export declare function groupsPath(): string;
10
+ export declare function workspacesPath(): string;
11
+ /**
12
+ * 当前完整状态,读侧不加锁(原子 rename 保证读到的是完整版本)。
13
+ *
14
+ * `binding` 是原始绑定 id,可能悬空;`packId` / `pack` 是解析后的生效值,
15
+ * 悬空或未绑定时都退回 `default`。读侧必须容忍悬空:删除 pack 后
16
+ * `workspaces.json` 里会留下指向它的键。
17
+ */
18
+ export interface PolicyState {
19
+ readonly packs: PackBook;
20
+ readonly ui: UiState;
21
+ /** 该 cwd 的原始绑定 id;未绑定为 `null`。 */
22
+ readonly binding: string | null;
23
+ /** 生效的 pack id;未绑定或悬空时为 `default`。 */
24
+ readonly packId: string;
25
+ /** 生效的 pack;`packId === 'default'` 时为 `null`(按 default 求值)。 */
26
+ readonly pack: Pack | null;
27
+ readonly groups: GroupRegistry;
28
+ /** 工作区绑定表(含全部 cwd 的绑定与 `extra`)。 */
29
+ readonly bindings: Bindings;
30
+ readonly warnings: readonly string[];
31
+ }
32
+ export declare function loadState(cwd: string | null): Promise<PolicyState>;
33
+ /**
34
+ * 应用一批意图并返回写入后的完整状态。
35
+ *
36
+ * 不比对任何版本:命令是绝对状态,幂等、可交换,写同一键即后点者赢。
37
+ * 三个文件各自一把锁,固定顺序(pack → 组定义 → 绑定)避免交叉等待;
38
+ * 一个命令命中两个文件时顺序写,不引入跨文件事务。
39
+ *
40
+ * **永不抛**:写失败、文件损坏都一样,说明只落进返回值的 `warnings`。
41
+ * 写盘失败是一次「没写成」,不该让路由把它当异常回滚成错误信封——那会连带
42
+ * 丢掉这次已经算出来的最新 state。
43
+ */
44
+ export declare function applyCommands(cwd: string | null, commands: readonly Command[]): Promise<PolicyState>;
45
+ /** 未落盘时的空状态,供读取失败的降级路径使用。 */
46
+ export declare function emptyState(): PolicyState;
package/lib/store.js ADDED
@@ -0,0 +1,434 @@
1
+ import { mkdir, readFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
4
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
5
+ import { normalizeCwd, parseBindings, parseGroups, parsePacks, serializeBindings, serializeGroups, serializePacks, } from './policy.js';
6
+ import { DEFAULT_PACK_ID, EMPTY_BINDINGS, EMPTY_GROUPS, EMPTY_PACK, EMPTY_UI, } from './types.js';
7
+ /**
8
+ * 三个状态文件,各自独立事务。
9
+ *
10
+ * 路径一律走 `dshHomePath`,不自己拼 `$DSH_HOME` 前缀:宿主允许用环境变量或
11
+ * 显式配置改根目录,自己拼会绕开它。
12
+ */
13
+ export function packsPath() {
14
+ return dshHomePath('packs', 'skill', 'packs.json');
15
+ }
16
+ export function groupsPath() {
17
+ return dshHomePath('packs', 'skill', 'groups.json');
18
+ }
19
+ export function workspacesPath() {
20
+ return dshHomePath('packs', 'skill', 'workspaces.json');
21
+ }
22
+ async function readText(path) {
23
+ try {
24
+ return await readFile(path, 'utf8');
25
+ }
26
+ catch (error) {
27
+ if (error.code === 'ENOENT')
28
+ return undefined;
29
+ throw error;
30
+ }
31
+ }
32
+ /** 同一批命令可能读写同一份文件两次(bind 要先读 pack 名册),失败说明只留一条。 */
33
+ function note(warnings, message) {
34
+ if (!warnings.includes(message))
35
+ warnings.push(message);
36
+ }
37
+ /**
38
+ * 写前读取。`null` 表示这份文件这次不能写:读失败或原文已损坏时,把解析出来的
39
+ * 空名册覆盖回去等于删掉用户的全部决定(坏 JSON 正是被解析成空 book 的)。
40
+ *
41
+ * 空文件仍然可写:它不是损坏,只是还没写过。
42
+ */
43
+ async function readForWrite(path, label, warnings) {
44
+ let text;
45
+ try {
46
+ text = await readText(path);
47
+ }
48
+ catch (error) {
49
+ note(warnings, `读取 ${label} 失败,本次未写入:${String(error)}`);
50
+ return null;
51
+ }
52
+ if (text === undefined || text.trim() === '')
53
+ return text;
54
+ let raw;
55
+ try {
56
+ raw = JSON.parse(text);
57
+ }
58
+ catch {
59
+ note(warnings, `${label} 不是合法 JSON,已拒绝写入`);
60
+ return null;
61
+ }
62
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
63
+ note(warnings, `${label} 顶层不是对象,已拒绝写入`);
64
+ return null;
65
+ }
66
+ return text;
67
+ }
68
+ /**
69
+ * 一次写事务:建父目录 → 拿跨进程锁 → 读 → 改 → 原子覆盖写。
70
+ *
71
+ * 父目录必须在拿锁之前建好:`withFileLock` 用 `wx` 直接建 `<file>.lock`,
72
+ * 它不会建父目录。`writeFileAtomic` 自己会 mkdir,但那是拿锁之后的事。
73
+ */
74
+ async function transact(path, label, warnings, mutate) {
75
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
76
+ await withFileLock(path, async () => {
77
+ const current = await readForWrite(path, label, warnings);
78
+ // 没有可写的基线就跳过本次写入:不覆盖、也不凭空建一份。
79
+ if (current === null)
80
+ return;
81
+ const next = mutate(current);
82
+ // `null` 表示这一批命令没有实际改变:不落盘,不留下无意义的空文件。
83
+ if (next === null)
84
+ return;
85
+ await writeFileAtomic(path, next, { mode: 0o600, dirMode: 0o700 });
86
+ });
87
+ }
88
+ /** 单个文件一次写事务。失败只记 warning:三个文件各自独立,一个坏了不该拖住另外两个。 */
89
+ async function writeFileSafely(path, label, warnings, mutate) {
90
+ try {
91
+ await transact(path, label, warnings, mutate);
92
+ }
93
+ catch (error) {
94
+ note(warnings, `${label} 更新失败:${String(error)}`);
95
+ }
96
+ }
97
+ function setKey(map, key, value) {
98
+ const next = { ...map };
99
+ if (value === null)
100
+ delete next[key];
101
+ else
102
+ next[key] = value;
103
+ return next;
104
+ }
105
+ export async function loadState(cwd) {
106
+ const warnings = [];
107
+ // 三份文件互不依赖:并行读。串行会白付两次往返,而这条路径每次新会话都走。
108
+ const [packsText, groupsText, workspacesText] = await Promise.all([
109
+ readText(packsPath()),
110
+ readText(groupsPath()),
111
+ readText(workspacesPath()),
112
+ ]);
113
+ const parsedPacks = parsePacks(packsText);
114
+ warnings.push(...parsedPacks.warnings);
115
+ const { packs } = parsedPacks.value.book;
116
+ const groupsParsed = parseGroups(groupsText);
117
+ warnings.push(...groupsParsed.warnings);
118
+ const cwdKey = normalizeCwd(cwd);
119
+ const parsedBindings = parseBindings(workspacesText);
120
+ warnings.push(...parsedBindings.warnings);
121
+ const bindings = parsedBindings.value;
122
+ const binding = cwdKey === null ? null : (bindings.bindings[cwdKey] ?? null);
123
+ const resolvedId = binding === null || binding === DEFAULT_PACK_ID || packs[binding] === undefined ? DEFAULT_PACK_ID : binding;
124
+ return {
125
+ packs: { packs },
126
+ ui: parsedPacks.value.ui,
127
+ binding,
128
+ packId: resolvedId,
129
+ pack: resolvedId === DEFAULT_PACK_ID ? null : (packs[resolvedId] ?? null),
130
+ groups: groupsParsed.value,
131
+ bindings,
132
+ warnings,
133
+ };
134
+ }
135
+ function requireName(raw) {
136
+ const name = raw.trim();
137
+ if (name === '')
138
+ throw new Error('pack 名不能为空');
139
+ return name;
140
+ }
141
+ function findPack(packs, packId) {
142
+ const pack = packs[packId];
143
+ if (pack === undefined)
144
+ throw new Error(`未知 pack:${packId}`);
145
+ return pack;
146
+ }
147
+ function assertNameFree(packs, name, exceptId) {
148
+ for (const [id, pack] of Object.entries(packs)) {
149
+ if (id !== exceptId && pack.name === name)
150
+ throw new Error(`pack 名已存在:${name}`);
151
+ }
152
+ }
153
+ /** 造一个短且稳定的 pack id,与分组 id 一样与名字脱钩。 */
154
+ function nextPackId(packs) {
155
+ for (let index = 1;; index += 1) {
156
+ const candidate = `p${String(index)}`;
157
+ if (packs[candidate] === undefined)
158
+ return candidate;
159
+ }
160
+ }
161
+ /** 写 packs.json。 */
162
+ function applyToPacks(current, commands) {
163
+ const parsed = parsePacks(current).value;
164
+ const ui = { ...parsed.ui };
165
+ const packs = { ...parsed.book.packs };
166
+ for (const command of commands) {
167
+ switch (command.op) {
168
+ case 'createPack': {
169
+ const name = requireName(command.name);
170
+ assertNameFree(packs, name);
171
+ const id = nextPackId(packs);
172
+ packs[id] = { name, items: {}, groups: {} };
173
+ break;
174
+ }
175
+ case 'renamePack': {
176
+ if (command.packId === DEFAULT_PACK_ID)
177
+ throw new Error('默认 pack 不可改名');
178
+ const pack = findPack(packs, command.packId);
179
+ const name = requireName(command.name);
180
+ assertNameFree(packs, name, command.packId);
181
+ packs[command.packId] = { ...pack, name };
182
+ break;
183
+ }
184
+ case 'deletePack': {
185
+ if (command.packId === DEFAULT_PACK_ID)
186
+ throw new Error('默认 pack 不可删除');
187
+ findPack(packs, command.packId);
188
+ delete packs[command.packId];
189
+ break;
190
+ }
191
+ case 'deleteGroup': {
192
+ // 组没了,它的开关就没有意义:一并从每张 pack 里删掉。
193
+ // 留成 orphan 会在同一个 id 被复用时突然生效,而 id 是按现有数量生成的。
194
+ for (const [id, pack] of Object.entries(packs)) {
195
+ if (pack.groups[command.groupId] === undefined)
196
+ continue;
197
+ packs[id] = { ...pack, groups: setKey(pack.groups, command.groupId, null) };
198
+ }
199
+ break;
200
+ }
201
+ case 'packItem': {
202
+ const pack = findPack(packs, command.packId);
203
+ packs[command.packId] = { ...pack, items: setKey(pack.items, command.name, command.value) };
204
+ break;
205
+ }
206
+ case 'packGroup': {
207
+ const pack = findPack(packs, command.packId);
208
+ packs[command.packId] = { ...pack, groups: setKey(pack.groups, command.groupId, command.value) };
209
+ break;
210
+ }
211
+ case 'showOfficialSkills':
212
+ ui.showOfficialSkills = command.value;
213
+ break;
214
+ case 'showChip':
215
+ ui.showChip = command.value;
216
+ break;
217
+ case 'defaultTab':
218
+ ui.defaultTab = command.value;
219
+ break;
220
+ default:
221
+ break;
222
+ }
223
+ }
224
+ return serializePacks(packs, ui);
225
+ }
226
+ /** 写 groups.json。 */
227
+ function applyToGroups(current, commands) {
228
+ const parsed = parseGroups(current).value;
229
+ const assignments = { ...parsed.assignments };
230
+ let groups = [...parsed.groups];
231
+ const known = () => new Set(groups.map((group) => group.id));
232
+ for (const command of commands) {
233
+ if (command.op === 'createGroup') {
234
+ const name = command.name.trim();
235
+ if (name === '')
236
+ throw new Error('分组名不能为空');
237
+ // id 稳定且与名字脱钩:改名不需要扫任何工程文件。
238
+ const id = nextGroupId(groups);
239
+ groups = [...groups, { id, name }];
240
+ continue;
241
+ }
242
+ if (command.op === 'renameGroup') {
243
+ const name = command.name.trim();
244
+ if (name === '')
245
+ throw new Error('分组名不能为空');
246
+ if (!known().has(command.groupId))
247
+ throw new Error(`未知分组:${command.groupId}`);
248
+ groups = groups.map((group) => group.id === command.groupId ? { id: group.id, name } : group);
249
+ continue;
250
+ }
251
+ if (command.op === 'deleteGroup') {
252
+ if (!known().has(command.groupId))
253
+ throw new Error(`未知分组:${command.groupId}`);
254
+ groups = groups.filter((group) => group.id !== command.groupId);
255
+ // 成员回到未分组。各 pack 里该组的开关由 applyToPacks 同批清除:
256
+ // id 按现有数量生成,删除后可能被复用,留着 orphan 会让新组继承旧决定。
257
+ for (const [name, groupId] of Object.entries(assignments)) {
258
+ if (groupId === command.groupId)
259
+ delete assignments[name];
260
+ }
261
+ continue;
262
+ }
263
+ if (command.op !== 'assign')
264
+ continue;
265
+ if (command.groupId === null) {
266
+ delete assignments[command.name];
267
+ continue;
268
+ }
269
+ if (!known().has(command.groupId)) {
270
+ throw new Error(`未知分组:${command.groupId}`);
271
+ }
272
+ assignments[command.name] = command.groupId;
273
+ }
274
+ return serializeGroups({ groups, assignments });
275
+ }
276
+ /** 造一个短且稳定的分组 id。 */
277
+ function nextGroupId(groups) {
278
+ const taken = new Set(groups.map((group) => group.id));
279
+ for (let index = groups.length + 1;; index += 1) {
280
+ const candidate = `g${String(index)}`;
281
+ if (!taken.has(candidate))
282
+ return candidate;
283
+ }
284
+ }
285
+ function applyToBindings(current, commands, knownPacks) {
286
+ const parsed = parseBindings(current);
287
+ const bindings = { ...parsed.value.bindings };
288
+ const extra = [...parsed.value.extra];
289
+ let changed = false;
290
+ for (const command of commands) {
291
+ if (command.op === 'deletePack') {
292
+ // 删除 pack 后清理指向它的绑定;残留的悬空键也能读(按 default 求值),
293
+ // 这里主动清是为了不让文件无限积累垃圾。
294
+ for (const [key, packId] of Object.entries(bindings)) {
295
+ if (packId === command.packId) {
296
+ delete bindings[key];
297
+ changed = true;
298
+ }
299
+ }
300
+ continue;
301
+ }
302
+ if (command.op === 'addExtra') {
303
+ const key = normalizeCwd(command.cwd);
304
+ if (key === null)
305
+ throw new Error('extra 的工作目录不能为空');
306
+ if (extra.indexOf(key) < 0) {
307
+ extra.push(key);
308
+ changed = true;
309
+ }
310
+ continue;
311
+ }
312
+ if (command.op === 'removeExtra') {
313
+ const key = normalizeCwd(command.cwd);
314
+ if (key === null)
315
+ throw new Error('extra 的工作目录不能为空');
316
+ const at = extra.indexOf(key);
317
+ if (at >= 0) {
318
+ extra.splice(at, 1);
319
+ changed = true;
320
+ }
321
+ continue;
322
+ }
323
+ if (command.op !== 'bind')
324
+ continue;
325
+ const key = normalizeCwd(command.cwd);
326
+ if (key === null)
327
+ throw new Error('绑定的工作目录不能为空');
328
+ if (command.packId === null || command.packId === DEFAULT_PACK_ID) {
329
+ // 显式绑到 default 与解除绑定等价,落盘时不留冗余键。
330
+ if (bindings[key] !== undefined) {
331
+ delete bindings[key];
332
+ changed = true;
333
+ }
334
+ continue;
335
+ }
336
+ if (!knownPacks.has(command.packId))
337
+ throw new Error(`未知 pack:${command.packId}`);
338
+ if (bindings[key] !== command.packId) {
339
+ bindings[key] = command.packId;
340
+ changed = true;
341
+ }
342
+ }
343
+ // 没有实际变化就不创建文件:只改 pack 的批量命令不该顺手留下一个空绑定文件。
344
+ return changed ? serializeBindings({ bindings, extra }) : null;
345
+ }
346
+ function targets(commands) {
347
+ let packs = false;
348
+ let groups = false;
349
+ let bindings = false;
350
+ for (const command of commands) {
351
+ switch (command.op) {
352
+ case 'createPack':
353
+ case 'renamePack':
354
+ case 'deletePack':
355
+ case 'packItem':
356
+ case 'packGroup':
357
+ case 'showOfficialSkills':
358
+ case 'showChip':
359
+ case 'defaultTab':
360
+ packs = true;
361
+ break;
362
+ case 'assign':
363
+ case 'createGroup':
364
+ case 'renameGroup':
365
+ case 'deleteGroup':
366
+ groups = true;
367
+ break;
368
+ case 'bind':
369
+ case 'addExtra':
370
+ case 'removeExtra':
371
+ bindings = true;
372
+ break;
373
+ default:
374
+ break;
375
+ }
376
+ // deletePack 还要清绑定,deleteGroup 还要清各 pack 里的组开关。
377
+ if (command.op === 'deletePack')
378
+ bindings = true;
379
+ if (command.op === 'deleteGroup')
380
+ packs = true;
381
+ }
382
+ return { packs, groups, bindings };
383
+ }
384
+ /**
385
+ * 应用一批意图并返回写入后的完整状态。
386
+ *
387
+ * 不比对任何版本:命令是绝对状态,幂等、可交换,写同一键即后点者赢。
388
+ * 三个文件各自一把锁,固定顺序(pack → 组定义 → 绑定)避免交叉等待;
389
+ * 一个命令命中两个文件时顺序写,不引入跨文件事务。
390
+ *
391
+ * **永不抛**:写失败、文件损坏都一样,说明只落进返回值的 `warnings`。
392
+ * 写盘失败是一次「没写成」,不该让路由把它当异常回滚成错误信封——那会连带
393
+ * 丢掉这次已经算出来的最新 state。
394
+ */
395
+ export async function applyCommands(cwd, commands) {
396
+ const plan = targets(commands);
397
+ const warnings = [];
398
+ if (plan.packs) {
399
+ await writeFileSafely(packsPath(), 'packs.json', warnings, (current) => applyToPacks(current, commands));
400
+ }
401
+ if (plan.groups) {
402
+ await writeFileSafely(groupsPath(), 'groups.json', warnings, (current) => applyToGroups(current, commands));
403
+ }
404
+ if (plan.bindings) {
405
+ // bind 的合法目标要在 pack 写完之后再读:同一批命令里可能有 createPack / deletePack。
406
+ const packsText = await readForWrite(packsPath(), 'packs.json', warnings);
407
+ if (packsText !== null) {
408
+ const known = new Set(Object.keys(parsePacks(packsText).value.book.packs));
409
+ await writeFileSafely(workspacesPath(), 'workspaces.json', warnings, (current) => applyToBindings(current, commands, known));
410
+ }
411
+ }
412
+ try {
413
+ const state = await loadState(cwd);
414
+ return { ...state, warnings: [...warnings, ...state.warnings] };
415
+ }
416
+ catch (error) {
417
+ // 写失败后连回读也失败时,仍要给出一个能渲染的状态:宁可面板空一轮,也不抛。
418
+ note(warnings, `读取最新状态失败:${String(error)}`);
419
+ return { ...emptyState(), warnings };
420
+ }
421
+ }
422
+ /** 未落盘时的空状态,供读取失败的降级路径使用。 */
423
+ export function emptyState() {
424
+ return {
425
+ packs: { packs: { [DEFAULT_PACK_ID]: EMPTY_PACK } },
426
+ ui: { ...EMPTY_UI },
427
+ binding: null,
428
+ packId: DEFAULT_PACK_ID,
429
+ pack: null,
430
+ groups: EMPTY_GROUPS,
431
+ bindings: EMPTY_BINDINGS,
432
+ warnings: [],
433
+ };
434
+ }