@dsh-workbench/core 1.0.0-beta.1 → 1.0.1

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,14 +2,14 @@
2
2
  #
3
3
  # 一次性引导:从 shipped 的 web 模板创建 workbench profile,
4
4
  # 继承 web 的 **bundle 清单**(这台机器上装了哪些 bundle),
5
- # 再加上会话 / 存储 / 附件的隔离层。
5
+ # 补上可写根那一行;新 profile 直接跟 web 共用数据根,旧 profile 保持原状,等用户跑 migrate。
6
6
  #
7
7
  # **设置不在这里继承**(这里曾经把 web 的 cordis.patch.yml 快照一份过来):
8
- # 工作台自己的 patch 是唯一真相,想搬 web 的哪几行由用户走「设置 → 配置同步」。
8
+ # 工作台自己的 patch 是唯一真相,想同步 web 的哪几行由用户走「设置 → 配置同步」。
9
9
  # 为什么这么分(安装 vs 设置)、为什么不再是自动的:内部设计记录
10
10
  #
11
- # 幂等 / 可续跑:profile 已存在就跳过创建,隔离层已存在就跳过追加
12
- # —— 重复运行不会覆盖任何东西。
11
+ # 幂等 / 可续跑:profile 已存在就跳过创建;数据根已经是共用的、或旧数据还没迁
12
+ # (只提示 migrate)就什么都不写 —— 重复运行不会覆盖任何东西。
13
13
  # 安全:全程只**读** web 的 profile 目录,只**写** workbench 自己的目录。
14
14
  #
15
15
  # 用法:
@@ -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,14 @@ 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 步:数据根配置 + 可写根那一行 ─────────────────────────────────
180
+ #
181
+ # 新 profile 跟 `dsh web` / Desktop 一样只分 profile,数据落在 `$DSH_HOME`。
182
+ # 旧 profile 如果还有没迁的数据,这里只打印 migrate 提示,保持隔离、不动数据;
183
+ # 用户自己跑 `dsh-workbench migrate` 才切共用根并并数据。
184
+ # 每一步都幂等:已经是共用数据根的 profile 一个字节都不写。
185
+ WORKBENCH_PROFILE="$PROFILE" DSH_HOME="$DSH_HOME" DSH_WORKBENCH_HOME="$WORKBENCH_HOME" \
186
+ node "$REPO_DIR/scripts/share-data-root.mjs" || die "整理数据根失败(见上一行)"
199
187
 
200
188
  # ── 第 4 步:建 DIY 工坊 ────────────────────────────────────────────────
201
189
  #
@@ -215,11 +203,12 @@ fi
215
203
 
216
204
  cat <<EOF
217
205
 
218
- profile '$DST_PROFILE' 的骨架好了(bundle 清单 + 会话/存储/附件隔离层)。
206
+ profile '$DST_PROFILE' 的骨架好了(bundle 清单 + 可写根)。
207
+ 旧实例如果还有数据没迁,启动时会提示;要切共用根并并旧数据,跑 dsh-workbench migrate。
219
208
 
220
- 设置与本地插件按创建时的勾选从 $SRC_PROFILE_NAME 继承;直接跑 setup 时只继承
221
- bundle 清单,想要 web 那边的模型供应商 / 搜索源 / MCP,起工作台后去
222
- 「设置 → 配置同步」按行勾(读的是那个 profile 的 cordis.patch.yml;搬完重启生效)。
209
+ 设置按创建时的勾选从 $SRC_PROFILE_NAME 继承(只同步改已有插件的行;挂新插件的行不同步);
210
+ 直接跑 setup 时只继承 bundle 清单,想要 web 那边的模型供应商 / 搜索源 / MCP,起工作台后去
211
+ 「设置 → 配置同步」按行勾(读的是那个 profile 的 cordis.patch.yml;同步完重启生效)。
223
212
 
224
213
  装 bundle —— 两条**等价**的路,任选一条(都走 dsh 的 runPluginCommand,装完会
225
214
  把 bundle 登记进 profile 清单):