@dsh-workbench/core 1.0.0-beta.1 → 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.
@@ -1,41 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { join } from 'node:path';
6
+ import { parseBlocks } from './patch-blocks.mjs';
6
7
 
7
8
  const home = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
8
9
  const sourceName = process.env.WB_SOURCE_PROFILE?.trim() || 'web';
9
10
  const targetName = process.env.WB_TARGET_PROFILE?.trim() || '';
10
11
  const sourceDir = join(home, 'profiles', sourceName);
11
12
  const targetDir = join(home, 'profiles', targetName);
12
- const sourceCandidate = join(home, sourceName);
13
- const sourcePatchForHome = join(sourceDir, 'cordis.patch.yml');
14
- const sourcePatchText = existsSync(sourcePatchForHome) ? readText(sourcePatchForHome) : '';
15
- const sourceHasIsolation = /id:\s*session-persistence-jsonl/.test(sourcePatchText);
16
- const sourceHome =
17
- process.env.WB_SOURCE_HOME?.trim() ||
18
- (!sourceHasIsolation
19
- ? home
20
- : sourcePatchText.includes('DSH_WORKBENCH_HOME') && sourceName !== 'workbench'
21
- ? sourceCandidate
22
- : join(home, 'workbench'));
23
- const targetHome = process.env.DSH_WORKBENCH_HOME?.trim() || join(home, targetName);
24
13
  const copySettings = process.env.WB_COPY_SETTINGS === '1';
25
- const copyPlugins = process.env.WB_COPY_PLUGINS === '1';
26
- const copyProjects = process.env.WB_COPY_PROJECTS === '1';
27
- const copySessions = process.env.WB_COPY_SESSIONS === '1';
28
14
 
29
15
  const PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
30
- const ISOLATION_IDS = new Set([
16
+ const SKIPPED_IDS = new Set([
31
17
  'session-persistence-jsonl',
32
18
  'storage-json',
33
19
  'attachment-local',
34
20
  'sandbox-policy',
35
21
  ]);
36
- const ASSET_MAX_BYTES = 8 * 1024 * 1024;
37
- const ASSET_MAX_FILES = 500;
38
- const ASSET_SKIP = new Set(['node_modules', '.git', '.DS_Store']);
39
22
 
40
23
  function say(message) {
41
24
  process.stdout.write(`→ ${message}\n`);
@@ -56,105 +39,6 @@ function readText(file) {
56
39
  return readFileSync(file, 'utf8');
57
40
  }
58
41
 
59
- if (copySettings && !copyPlugins) {
60
- const refs = localPluginRefs();
61
- if (refs > 0) {
62
- fail(`设置里有 ${refs} 个本地插件文件引用;不能只复制设置不复制插件(会指向不存在的文件)。要么一起复制插件,要么取消设置。`);
63
- }
64
- }
65
-
66
- function parseBlocks(text) {
67
- const lines = text.split('\n');
68
- const starts = [];
69
- lines.forEach((line, index) => {
70
- if (/^- /.test(line) || line === '-') starts.push(index);
71
- });
72
- const blocks = [];
73
- for (let i = 0; i < starts.length; i += 1) {
74
- const from = starts[i];
75
- const to = i + 1 < starts.length ? starts[i + 1] : lines.length;
76
- let end = to;
77
- while (end > from && (lines[end - 1] ?? '').trim() === '') end -= 1;
78
- const body = lines.slice(from, end).join('\n');
79
- if (body.trim() === '') continue;
80
- const first = body.split('\n')[0] ?? '';
81
- const id = /^- id:[ \t]*"?([^"\s#]+)/.exec(first)?.[1];
82
- const insert = id === undefined && /^- insert:[ \t]*$/.test(first);
83
- const nested = body
84
- .split('\n')
85
- .map((line) => /^[ \t]+-[ \t]*id:[ \t]*"?([^"\s#]+)/.exec(line)?.[1])
86
- .filter(Boolean);
87
- const key = id !== undefined ? `id:${id}` : insert ? `insert:${nested.join(', ')}` : `block:${String(i)}`;
88
- blocks.push({ key, id: id ?? nested.join(', ') ?? key, insert, body });
89
- }
90
- return blocks;
91
- }
92
-
93
- function namesOf(body) {
94
- return body
95
- .split('\n')
96
- .map((line) => /^[ \t]*-?[ \t]*name:[ \t]*['"]?([^'"\s#]+)/.exec(line)?.[1])
97
- .filter((name) => name !== undefined && (name.startsWith('./') || name.startsWith('../') || name.startsWith('file://') || isAbsolute(name)));
98
- }
99
-
100
- function insideProfile(dir, ref) {
101
- if (ref.startsWith('file://')) return null;
102
- if (!ref.startsWith('./') && !ref.startsWith('../') && !isAbsolute(ref)) return null;
103
- const abs = resolve(dir, ref);
104
- const rel = relative(dir, abs);
105
- if (rel === '' || rel === '..' || rel.startsWith(`..${requireSep()}`) || isAbsolute(rel)) return null;
106
- return { rel: rel.split(requireSep()).join('/'), abs };
107
- }
108
-
109
- function requireSep() {
110
- return process.platform === 'win32' ? '\\' : '/';
111
- }
112
-
113
- function copyFileAsset(source, target, budget) {
114
- const stat = statSync(source);
115
- if (stat.isSymbolicLink() || !stat.isFile()) return false;
116
- if (stat.size > ASSET_MAX_BYTES) return false;
117
- budget.bytes += stat.size;
118
- if (budget.bytes > 32 * 1024 * 1024 || budget.files >= ASSET_MAX_FILES) return false;
119
- mkdirSync(dirname(target), { recursive: true });
120
- cpSync(source, target);
121
- budget.files += 1;
122
- return true;
123
- }
124
-
125
- function copyDirAsset(source, target, budget) {
126
- const walk = (dir) => {
127
- for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
128
- if (ASSET_SKIP.has(entry.name)) continue;
129
- const abs = join(dir, entry.name);
130
- if (entry.isSymbolicLink()) return false;
131
- if (entry.isDirectory()) {
132
- if (!walk(abs)) return false;
133
- continue;
134
- }
135
- if (!entry.isFile()) continue;
136
- const rel = relative(source, abs);
137
- if (!copyFileAsset(abs, join(target, rel), budget)) return false;
138
- }
139
- return true;
140
- };
141
- return walk(source);
142
- }
143
-
144
- function localPluginRefs() {
145
- const sourcePatch = join(sourceDir, 'cordis.patch.yml');
146
- if (!existsSync(sourcePatch)) return 0;
147
- const seen = new Set();
148
- for (const block of parseBlocks(readText(sourcePatch))) {
149
- if (!block.insert) continue;
150
- for (const ref of namesOf(block.body)) {
151
- const located = insideProfile(sourceDir, ref);
152
- if (located !== null) seen.add(located.rel);
153
- }
154
- }
155
- return seen.size;
156
- }
157
-
158
42
  function rewritePaths(body) {
159
43
  return body.replace(/(dshHomePath\(\s*['"])workbench(?=[/'"])/g, `$1${targetName}`);
160
44
  }
@@ -172,8 +56,13 @@ function copySettingRows() {
172
56
  const targetKeys = new Set(parseBlocks(current).map((block) => block.key));
173
57
  const copied = [];
174
58
  const skipped = [];
59
+ let plugins = 0;
175
60
  for (const block of sourceBlocks) {
176
- if (ISOLATION_IDS.has(block.id) || targetKeys.has(block.key)) {
61
+ if (block.insert) {
62
+ plugins += 1;
63
+ continue;
64
+ }
65
+ if (SKIPPED_IDS.has(block.id) || targetKeys.has(block.key)) {
177
66
  skipped.push(block.id);
178
67
  continue;
179
68
  }
@@ -183,86 +72,9 @@ function copySettingRows() {
183
72
  targetKeys.add(block.key);
184
73
  copied.push(block.id);
185
74
  }
186
- say(`设置行:复制 ${copied.length},跳过 ${skipped.length}`);
187
- }
188
-
189
- function copyPluginAssets() {
190
- const sourcePatch = join(sourceDir, 'cordis.patch.yml');
191
- if (!existsSync(sourcePatch)) return;
192
- const budget = { bytes: 0, files: 0 };
193
- const seen = new Set();
194
- let copied = 0;
195
- for (const block of parseBlocks(readText(sourcePatch))) {
196
- if (!block.insert || ISOLATION_IDS.has(block.id)) continue;
197
- for (const ref of namesOf(block.body)) {
198
- const located = insideProfile(sourceDir, ref);
199
- if (located === null || seen.has(located.rel)) continue;
200
- seen.add(located.rel);
201
- const source = join(sourceDir, located.rel);
202
- const target = join(targetDir, located.rel);
203
- if (!existsSync(source)) {
204
- say(`缺文件,跳过:${located.rel}`);
205
- continue;
206
- }
207
- const stat = statSync(source);
208
- const ok = stat.isDirectory()
209
- ? copyDirAsset(source, target, budget)
210
- : copyFileAsset(source, target, budget);
211
- if (ok) copied += 1;
212
- else say(`文件太大或含符号链接,跳过:${located.rel}`);
213
- }
214
- }
215
- say(`本地插件文件:复制 ${copied} 个`);
216
- }
217
-
218
- function copyProjectList() {
219
- const source = join(sourceHome, 'storages', 'workspace.json');
220
- if (!existsSync(source)) {
221
- say('来源没有项目列表,跳过');
222
- return;
223
- }
224
- const target = join(targetHome, 'storages', 'workspace.json');
225
- mkdirSync(dirname(target), { recursive: true });
226
- cpSync(source, target);
227
- say('项目列表:已复制');
228
- }
229
-
230
- function copySessionData() {
231
- const source = join(sourceHome, 'sessions');
232
- if (!existsSync(source)) {
233
- say('来源没有会话记录,跳过');
234
- return;
235
- }
236
- const target = join(targetHome, 'sessions');
237
- mkdirSync(dirname(target), { recursive: true });
238
- cpSync(source, target, { recursive: true });
239
- say('会话记录:已复制');
240
- const attachments = join(sourceHome, 'attachments');
241
- if (existsSync(attachments)) cpSync(attachments, join(targetHome, 'attachments'), { recursive: true });
242
- }
243
-
244
- function mergeDependencies() {
245
- const sourceManifest = JSON.parse(readText(join(sourceDir, 'package.json')));
246
- const targetManifest = JSON.parse(readText(join(targetDir, 'package.json')));
247
- const sourceDeps = sourceManifest.dependencies ?? {};
248
- const targetDeps = targetManifest.dependencies ?? {};
249
- const merged = { ...targetDeps };
250
- for (const [name, spec] of Object.entries(sourceDeps)) {
251
- if (name === '@dsh-workbench/core') continue;
252
- if (!Object.hasOwn(merged, name)) merged[name] = spec;
253
- }
254
- targetManifest.dependencies = merged;
255
- const tmp = `${join(targetDir, 'package.json')}.wb-clone`;
256
- writeFileSync(tmp, `${JSON.stringify(targetManifest, null, 2)}\n`, 'utf8');
257
- renameSync(tmp, join(targetDir, 'package.json'));
258
- say(`依赖:并入 ${Object.keys(merged).length - Object.keys(targetDeps).length} 项`);
75
+ say(`设置行:复制 ${copied.length},跳过 ${skipped.length};挂新插件的 ${plugins} 行不同步`);
259
76
  }
260
77
 
261
78
  mkdirSync(targetDir, { recursive: true });
262
- mkdirSync(targetHome, { recursive: true });
263
79
  if (copySettings) copySettingRows();
264
- if (copyPlugins) copyPluginAssets();
265
- mergeDependencies();
266
- if (copyProjects) copyProjectList();
267
- if (copySessions) copySessionData();
268
80
  say('完成');
@@ -122,32 +122,22 @@ const nameAnswer = await prompts(
122
122
  );
123
123
  const name = String(nameAnswer.name).trim();
124
124
 
125
+ say('会话、项目、附件跟 web / Desktop 共用一份,不用复制;这里只问设置。');
125
126
  const copyAnswer = await prompts(
126
127
  {
127
- type: 'multiselect',
128
- name: 'copy',
129
- message: `从 ${source} 复制哪些内容?`,
130
- instructions: '空格选择 · 回车确认',
131
- choices: [
132
- { title: '设置(模型、主题等)', value: 'settings', selected: true },
133
- { title: '本地插件文件', value: 'plugins', selected: true },
134
- { title: '项目列表', value: 'projects', selected: true },
135
- { title: '会话记录(可选,可能很多)', value: 'sessions', selected: false },
136
- ],
128
+ type: 'confirm',
129
+ name: 'settings',
130
+ message: `从 ${source} 复制已配好的设置(模型、主题等)?`,
131
+ initial: true,
137
132
  },
138
133
  { onCancel },
139
134
  );
140
- const copy = new Set(copyAnswer.copy ?? []);
141
- if (copy.has('settings') && !copy.has('plugins')) {
142
- copy.add('plugins');
143
- say('设置行引用的本地插件会一起复制(否则配置会指向不存在的文件)。');
144
- }
145
135
 
146
136
  const confirmAnswer = await prompts(
147
137
  {
148
138
  type: 'confirm',
149
139
  name: 'ok',
150
- message: `创建 ${name}(来源 ${source},复制:${[...copy].join('、') || '无'})?`,
140
+ message: `创建 ${name}(来源 ${source},${copyAnswer.settings ? '复制设置' : '不复制设置'})?`,
151
141
  initial: true,
152
142
  },
153
143
  { onCancel },
@@ -158,9 +148,6 @@ if (!confirmAnswer.ok) {
158
148
  }
159
149
 
160
150
  const args = ['setup', '--from', source, '--profile', name];
161
- if (copy.has('sessions')) args.push('--with-sessions');
162
- if (!copy.has('settings')) args.push('--no-settings');
163
- if (!copy.has('plugins')) args.push('--no-plugins');
164
- if (!copy.has('projects')) args.push('--no-projects');
151
+ if (!copyAnswer.settings) args.push('--no-settings');
165
152
 
166
153
  runSetup(args);
@@ -0,0 +1,60 @@
1
+ export function parseBlocks(text) {
2
+ const lines = text.split('\n');
3
+ const starts = [];
4
+ lines.forEach((line, index) => {
5
+ if (/^- /.test(line) || line === '-') starts.push(index);
6
+ });
7
+ const blocks = [];
8
+ for (let i = 0; i < starts.length; i += 1) {
9
+ const from = starts[i];
10
+ const to = i + 1 < starts.length ? starts[i + 1] : lines.length;
11
+ let end = to;
12
+ while (end > from && (lines[end - 1] ?? '').trim() === '') end -= 1;
13
+ const body = lines.slice(from, end).join('\n');
14
+ if (body.trim() === '') continue;
15
+ const first = body.split('\n')[0] ?? '';
16
+ const id = /^- id:[ \t]*"?([^"\s#]+)/.exec(first)?.[1];
17
+ const insert = id === undefined && /^- insert:[ \t]*$/.test(first);
18
+ const nested = body
19
+ .split('\n')
20
+ .map((line) => /^[ \t]+-[ \t]*id:[ \t]*"?([^"\s#]+)/.exec(line)?.[1])
21
+ .filter(Boolean);
22
+ const key = id !== undefined ? `id:${id}` : insert ? `insert:${nested.join(', ')}` : `block:${String(i)}`;
23
+ blocks.push({
24
+ key,
25
+ id: id ?? nested.join(', ') ?? key,
26
+ insert,
27
+ body,
28
+ lineStart: from,
29
+ lineEnd: end,
30
+ });
31
+ }
32
+ return blocks;
33
+ }
34
+
35
+ export function removeBlocks(text, predicate) {
36
+ const blocks = parseBlocks(text).filter(predicate);
37
+ if (blocks.length === 0) return text;
38
+ const lines = text.split('\n');
39
+ const drop = new Set();
40
+ for (const block of blocks) {
41
+ let end = block.lineEnd;
42
+ for (let i = block.lineEnd; i < lines.length; i += 1) {
43
+ if ((lines[i] ?? '').trim() !== '') break;
44
+ drop.add(i);
45
+ end = i + 1;
46
+ }
47
+ for (let i = block.lineStart; i < end; i += 1) drop.add(i);
48
+ }
49
+ const kept = lines.filter((_, index) => !drop.has(index));
50
+ return `${kept.join('\n').replace(/\s+$/, '')}\n`;
51
+ }
52
+
53
+ export function appendBlocks(text, body) {
54
+ const stripped = text
55
+ .split('\n')
56
+ .filter((line) => line.trim() !== '[]')
57
+ .join('\n')
58
+ .replace(/\s+$/, '');
59
+ return `${stripped}\n\n${body.replace(/\s+$/, '')}\n`;
60
+ }
@@ -2,13 +2,13 @@
2
2
  #
3
3
  # 一次性引导:从 shipped 的 web 模板创建 workbench profile,
4
4
  # 继承 web 的 **bundle 清单**(这台机器上装了哪些 bundle),
5
- # 再加上会话 / 存储 / 附件的隔离层。
5
+ # 补上可写根那一行,并把会话 / 项目 / 附件指回跟 web 共用的数据根。
6
6
  #
7
7
  # **设置不在这里继承**(这里曾经把 web 的 cordis.patch.yml 快照一份过来):
8
- # 工作台自己的 patch 是唯一真相,想搬 web 的哪几行由用户走「设置 → 配置同步」。
8
+ # 工作台自己的 patch 是唯一真相,想同步 web 的哪几行由用户走「设置 → 配置同步」。
9
9
  # 为什么这么分(安装 vs 设置)、为什么不再是自动的:内部设计记录
10
10
  #
11
- # 幂等 / 可续跑:profile 已存在就跳过创建,隔离层已存在就跳过追加
11
+ # 幂等 / 可续跑:profile 已存在就跳过创建,数据根已经是共用的就什么都不写
12
12
  # —— 重复运行不会覆盖任何东西。
13
13
  # 安全:全程只**读** web 的 profile 目录,只**写** workbench 自己的目录。
14
14
  #
@@ -37,7 +37,6 @@ GIT_TOP="$(git -C "$REPO_DIR" rev-parse --show-toplevel 2>/dev/null || true)"
37
37
  if [ -n "$GIT_TOP" ] && ! git -C "$REPO_DIR" ls-files --error-unmatch package.json >/dev/null 2>&1; then
38
38
  GIT_TOP=""
39
39
  fi
40
- ISOLATION="$REPO_DIR/profile/isolation.patch.yml"
41
40
 
42
41
  # `verify.sh` 留在**仓库根**(它证明的是"dsh web 未被动过 / 组合树差异 / 数据分家",
43
42
  # 与包边界无关),所以不能按 $REPO_DIR 拼。装成安装包(没有 git 工作树)时它不存在,
@@ -100,7 +99,6 @@ HAS_SOURCE=0
100
99
  if [ -f "$SRC_PROFILE/package.json" ]; then
101
100
  HAS_SOURCE=1
102
101
  fi
103
- [ -f "$ISOLATION" ] || die "找不到 $ISOLATION"
104
102
 
105
103
  DST_PATCH="$DST_PROFILE/cordis.patch.yml"
106
104
 
@@ -178,24 +176,13 @@ console.log("→ profile 里那条 link: 指的是我这份装出来的副本(
178
176
  '
179
177
  fi
180
178
 
181
- # ── 第 3 步:追加会话 / 存储 / 附件隔离层 ───────────────────────────────
182
- if grep -q 'id: session-persistence-jsonl' "$DST_PATCH"; then
183
- echo "→ 隔离层已存在,跳过"
184
- else
185
- # dsh 生成的 profile patch 是「三行横幅注释 + 一个空数组 []」。空数组本身是一个**完整的
186
- # YAML 文档**,往后直接接一个序列,解析就会在最外层报
187
- # "end of the stream or a document separator is expected",profile 直接起不来
188
- # (实测 dsh 0.2.1-alpha.3:setup 出来的 profile 报这条,去掉那行 [] 才起)。
189
- # 所以追加之前先把那行单独的 [] 拿掉;本来就没有(或已经是序列)时这一步什么都不做。
190
- if grep -qx '\[\]' "$DST_PATCH"; then
191
- grep -vx '\[\]' "$DST_PATCH" >"$DST_PATCH.tmp" && mv "$DST_PATCH.tmp" "$DST_PATCH"
192
- echo "→ 模板 patch 里的空数组 [] 已让位(否则接不上隔离层)"
193
- fi
194
- printf '\n' >>"$DST_PATCH"
195
- cat "$ISOLATION" >>"$DST_PATCH"
196
- echo "→ 已追加隔离层(sessions / storages / attachments 分家)"
197
- rm -f "$DST_PATCH.tmp"
198
- fi
179
+ # ── 第 3 步:数据根跟 web 共用 + 可写根那一行 ───────────────────────────
180
+ #
181
+ # 会话 / 项目 / 附件不再按 profile 分家:跟 `dsh web` 和 Desktop 一样只分 profile,
182
+ # 数据都在 `$DSH_HOME` 下(旧实例的那三行隔离补丁与旧数据由 share-data-root.mjs 收回来)。
183
+ # 每一步都幂等:已经是共用数据根的 profile 一个字节都不写。
184
+ WORKBENCH_PROFILE="$PROFILE" DSH_HOME="$DSH_HOME" DSH_WORKBENCH_HOME="$WORKBENCH_HOME" \
185
+ node "$REPO_DIR/scripts/share-data-root.mjs" || die "整理数据根失败(见上一行)"
199
186
 
200
187
  # ── 第 4 步:建 DIY 工坊 ────────────────────────────────────────────────
201
188
  #
@@ -215,11 +202,11 @@ fi
215
202
 
216
203
  cat <<EOF
217
204
 
218
- profile '$DST_PROFILE' 的骨架好了(bundle 清单 + 会话/存储/附件隔离层)。
205
+ profile '$DST_PROFILE' 的骨架好了(bundle 清单 + 可写根;会话 / 项目 / 附件跟 web 共用)。
219
206
 
220
- 设置与本地插件按创建时的勾选从 $SRC_PROFILE_NAME 继承;直接跑 setup 时只继承
221
- bundle 清单,想要 web 那边的模型供应商 / 搜索源 / MCP,起工作台后去
222
- 「设置 → 配置同步」按行勾(读的是那个 profile 的 cordis.patch.yml;搬完重启生效)。
207
+ 设置按创建时的勾选从 $SRC_PROFILE_NAME 继承(只同步改已有插件的行;挂新插件的行不同步);
208
+ 直接跑 setup 时只继承 bundle 清单,想要 web 那边的模型供应商 / 搜索源 / MCP,起工作台后去
209
+ 「设置 → 配置同步」按行勾(读的是那个 profile 的 cordis.patch.yml;同步完重启生效)。
223
210
 
224
211
  装 bundle —— 两条**等价**的路,任选一条(都走 dsh 的 runPluginCommand,装完会
225
212
  把 bundle 登记进 profile 清单):
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {
4
+ copyFileSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ renameSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { dirname, join, resolve } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { appendBlocks, parseBlocks, removeBlocks } from './patch-blocks.mjs';
17
+
18
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
19
+ const PKG_ROOT = dirname(SCRIPT_DIR);
20
+ const HOME = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
21
+ const PROFILE = process.env.WORKBENCH_PROFILE?.trim() || 'workbench';
22
+ const PROFILE_DIR = join(HOME, 'profiles', PROFILE);
23
+ const RUNTIME_ROOT = process.env.DSH_WORKBENCH_HOME?.trim() || join(HOME, PROFILE);
24
+ const PATCH_PATH = join(PROFILE_DIR, 'cordis.patch.yml');
25
+ const SANDBOX_PATCH = join(PKG_ROOT, 'profile', 'sandbox.patch.yml');
26
+ const MARKER = join(RUNTIME_ROOT, '.data-root-shared');
27
+
28
+ const DATA_ROOT_IDS = new Set(['session-persistence-jsonl', 'storage-json', 'attachment-local']);
29
+ const OWN_ROOT = /DSH_WORKBENCH_HOME|dshHomePath\(\s*['"]workbench/;
30
+
31
+ function say(message) {
32
+ process.stdout.write(`→ ${message}\n`);
33
+ }
34
+
35
+ function warn(message) {
36
+ process.stderr.write(`dsh-workbench: ${message}\n`);
37
+ }
38
+
39
+ function readText(file) {
40
+ try {
41
+ return readFileSync(file, 'utf8');
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function writeAtomic(file, text) {
48
+ const tmp = `${file}.tmp`;
49
+ writeFileSync(tmp, text, 'utf8');
50
+ renameSync(tmp, file);
51
+ }
52
+
53
+ function mergeTree(from, to, skip = new Set()) {
54
+ let stat;
55
+ try {
56
+ stat = statSync(from);
57
+ } catch {
58
+ return 0;
59
+ }
60
+ if (!stat.isDirectory()) return 0;
61
+ mkdirSync(to, { recursive: true });
62
+ let count = 0;
63
+ for (const entry of readdirSync(from, { withFileTypes: true })) {
64
+ if (entry.name === '.DS_Store' || skip.has(entry.name)) continue;
65
+ const source = join(from, entry.name);
66
+ const target = join(to, entry.name);
67
+ if (entry.isDirectory()) {
68
+ count += mergeTree(source, target);
69
+ } else if (entry.isFile() && !existsSync(target)) {
70
+ copyFileSync(source, target);
71
+ count += 1;
72
+ }
73
+ }
74
+ return count;
75
+ }
76
+
77
+ function unionList(left, right) {
78
+ return [...new Set([...(Array.isArray(left) ? left : []), ...(Array.isArray(right) ? right : [])])];
79
+ }
80
+
81
+ function mergeWorkspaceList(sourceFile, targetFile) {
82
+ let source;
83
+ try {
84
+ source = JSON.parse(readFileSync(sourceFile, 'utf8'));
85
+ } catch {
86
+ return 0;
87
+ }
88
+ if (source?.unit?.name !== 'workspace') return 0;
89
+ if (!existsSync(targetFile)) {
90
+ mkdirSync(dirname(targetFile), { recursive: true });
91
+ copyFileSync(sourceFile, targetFile);
92
+ return 1;
93
+ }
94
+ let target;
95
+ try {
96
+ target = JSON.parse(readFileSync(targetFile, 'utf8'));
97
+ } catch {
98
+ warn(`${targetFile} 读不出来,这次没并项目列表。`);
99
+ return 0;
100
+ }
101
+ if (target?.unit?.name !== 'workspace') return 0;
102
+ const sourceTable = source.tables?.workspaces ?? {};
103
+ const targetTable = target.tables?.workspaces ?? {};
104
+ const byPath = new Map();
105
+ for (const entry of Object.values(targetTable)) {
106
+ if (typeof entry?.path === 'string') byPath.set(entry.path, entry);
107
+ }
108
+ let added = 0;
109
+ for (const [id, entry] of Object.entries(sourceTable)) {
110
+ if (typeof entry?.path !== 'string') continue;
111
+ const hit = byPath.get(entry.path);
112
+ if (hit === undefined) {
113
+ targetTable[id] = entry;
114
+ byPath.set(entry.path, entry);
115
+ added += 1;
116
+ continue;
117
+ }
118
+ hit.sessionIds = unionList(hit.sessionIds, entry.sessionIds);
119
+ if (typeof entry.updatedAt === 'string' && (typeof hit.updatedAt !== 'string' || entry.updatedAt > hit.updatedAt)) {
120
+ hit.updatedAt = entry.updatedAt;
121
+ }
122
+ }
123
+ target.tables = { ...(target.tables ?? {}), workspaces: targetTable };
124
+ const global = target.global ?? {};
125
+ const sourceGlobal = source.global ?? {};
126
+ const known = new Set(Array.isArray(global.workspaceIds) ? global.workspaceIds : []);
127
+ for (const id of Array.isArray(sourceGlobal.workspaceIds) ? sourceGlobal.workspaceIds : []) known.add(id);
128
+ target.global = {
129
+ ...global,
130
+ workspaceIds: [...known],
131
+ archivedSessionIds: unionList(global.archivedSessionIds, sourceGlobal.archivedSessionIds),
132
+ pinnedSessionIds: unionList(global.pinnedSessionIds, sourceGlobal.pinnedSessionIds),
133
+ };
134
+ writeAtomic(targetFile, `${JSON.stringify(target, null, 2)}\n`);
135
+ return added;
136
+ }
137
+
138
+ function mergeDataRoot() {
139
+ if (resolve(RUNTIME_ROOT) === resolve(HOME)) return;
140
+ if (existsSync(MARKER)) return;
141
+ const sourceStorages = join(RUNTIME_ROOT, 'storages');
142
+ let count = 0;
143
+ try {
144
+ count += mergeWorkspaceList(join(sourceStorages, 'workspace.json'), join(HOME, 'storages', 'workspace.json'));
145
+ count += mergeTree(join(RUNTIME_ROOT, 'sessions'), join(HOME, 'sessions'));
146
+ count += mergeTree(join(RUNTIME_ROOT, 'attachments'), join(HOME, 'attachments'));
147
+ count += mergeTree(sourceStorages, join(HOME, 'storages'), new Set(['workspace.json']));
148
+ } catch (error) {
149
+ warn(`并回 ${HOME} 时出错,下次启动再试:${error instanceof Error ? error.message : String(error)}`);
150
+ return;
151
+ }
152
+ mkdirSync(dirname(MARKER), { recursive: true });
153
+ writeFileSync(
154
+ MARKER,
155
+ `${JSON.stringify({ at: new Date().toISOString(), from: RUNTIME_ROOT, to: HOME }, null, 2)}\n`,
156
+ 'utf8',
157
+ );
158
+ if (count > 0) say(`已把 ${RUNTIME_ROOT} 里的会话 / 项目 / 附件并回 ${HOME}(${count} 个文件,旧目录保留)。`);
159
+ }
160
+
161
+ function stripIsolationHeader(text, blockStart) {
162
+ const lines = text.split('\n');
163
+ let marker = -1;
164
+ for (let i = blockStart - 1; i >= 0; i -= 1) {
165
+ const line = lines[i] ?? '';
166
+ if (/^#.*会话\s*\/\s*存储隔离层/.test(line)) {
167
+ marker = i;
168
+ break;
169
+ }
170
+ if (line.trim() !== '' && !line.startsWith('#')) break;
171
+ }
172
+ if (marker === -1) return text;
173
+ lines.splice(marker, blockStart - marker);
174
+ return lines.join('\n');
175
+ }
176
+
177
+ const profileManifest = join(PROFILE_DIR, 'package.json');
178
+ if (!existsSync(profileManifest)) process.exit(0);
179
+
180
+ let patch = readText(PATCH_PATH);
181
+ if (patch === null) process.exit(0);
182
+
183
+ const legacy = parseBlocks(patch).filter((block) => DATA_ROOT_IDS.has(block.id));
184
+ const ours = legacy.filter((block) => OWN_ROOT.test(block.body));
185
+ const custom = legacy.filter((block) => !OWN_ROOT.test(block.body));
186
+ if (ours.length > 0) {
187
+ const first = Math.min(...ours.map((block) => block.lineStart));
188
+ patch = stripIsolationHeader(patch, first);
189
+ patch = removeBlocks(patch, (block) => DATA_ROOT_IDS.has(block.id) && OWN_ROOT.test(block.body));
190
+ writeAtomic(PATCH_PATH, patch);
191
+ say(`数据根改回共用:从 ${PROFILE} 的 profile 里去掉 ${ours.map((block) => block.id).join(' / ')}。`);
192
+ }
193
+
194
+ const hasSandbox = parseBlocks(patch).some((block) => block.id === 'sandbox-policy');
195
+ if (!hasSandbox) {
196
+ const sandbox = readText(SANDBOX_PATCH);
197
+ const row = sandbox === null ? undefined : parseBlocks(sandbox).find((block) => block.id === 'sandbox-policy');
198
+ if (row !== undefined) {
199
+ patch = appendBlocks(patch, row.body);
200
+ writeAtomic(PATCH_PATH, patch);
201
+ }
202
+ }
203
+
204
+ if (custom.length > 0) {
205
+ warn(
206
+ `${PROFILE} 的会话 / 存储根是你自己改过的(${custom.map((block) => block.id).join(' / ')})—— 这次不动它,也不并数据。`,
207
+ );
208
+ process.exit(0);
209
+ }
210
+
211
+ mergeDataRoot();
@@ -7,7 +7,7 @@
7
7
 
8
8
  | 要做什么 | 用 |
9
9
  |---|---|
10
- | 认路、装没装、起不起得来 | `_status`(`unit` 可省);它的「下一步」就是下一件事 |
10
+ | 认路、装没装、起不起得来 | `_status`(`unit` 可省);它的「下一步」就是下一件事;带 `unit` 时 text 末尾多一条「发布出口:…」 |
11
11
  | 卡片空白 / 崩了 / 读不到 | `_panel` |
12
12
  | 找 opencli 命令、看命令形状 | `_recon` → `_spec`(`--help -f json` 原样) |
13
13
  | kit 有什么 / 令牌叫什么 | `_components`(`bucket` / `name` 取声明;`scope: tokens` 现读 `--dsw-*` / `--wb-*`) |
@@ -45,8 +45,6 @@
45
45
 
46
46
  ## 4 发布
47
47
 
48
- 只在用户明确说「帮我发布」后调。它一次做完预检 → 全门禁 → 按出口发布 → 发后核包,审批一次。
49
- 发布前先 `_changelog` 把这一版写进 `CHANGELOG.md`:没有这一版的记录,`_publish` 会拦下。
50
- 审批屏会把这一版的更新日志按 markdown 展示给用户。
51
- 需要用户接管时返回 `needsUser: { step, text, command? }` 而不抛异常:把 `text` 说给用户、给命令,
52
- 等「好了」再跑;不向用户要 token,不展示 `package.json`。
48
+ 只在用户明确说「帮我发布」后调;先 `_changelog` 把这一版写进 `CHANGELOG.md`。
49
+ 出口、署名、标题、安装命令由工具与审批屏处理,不用问、不用补。
50
+ 需要用户接管时返回 `needsUser: { step, text, command? }`:把 `text` 说给用户,等「好了」再跑;不向用户要 token。