@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.
- package/README.md +18 -15
- package/bin/dsh-workbench +27 -26
- package/client.js +213 -322
- package/dist/client/client.body.js +213 -322
- package/dist/client/index.js +213 -322
- package/dist/host/index.js +108 -74
- package/dist/types/app/layouts/rightbar/market-shared-rows.d.ts +0 -1
- package/dist/types/app/layouts/workbench-settings/ConfigSyncRows.d.ts +1 -3
- package/dist/types/kernel/contract/config-sync.d.ts +1 -1
- package/dist/types/modules/panels/model/diy-context.d.ts +14 -4
- package/dist/types/modules/panels/model/diy-report.d.ts +2 -0
- package/dist/types/modules/panels/model/diy-tools.d.ts +2 -1
- package/package.json +5 -3
- package/profile/sandbox.patch.yml +7 -5
- package/scripts/clone-profile.mjs +11 -199
- package/scripts/create-workbench.mjs +7 -20
- package/scripts/patch-blocks.mjs +60 -0
- package/scripts/seed-profile.sh +14 -27
- package/scripts/share-data-root.mjs +211 -0
- package/skills/workbench-module-diy/FAMILY.md +4 -6
- package/skills/workbench-module-diy/SKILL.md +4 -5
- package/skills/workbench-module-diy/WIRING.md +4 -13
- package/dist/types/shared/api/host/git.test.d.ts +0 -1
- package/profile/isolation.patch.yml +0 -46
|
@@ -1,41 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import {
|
|
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
|
|
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 (
|
|
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: '
|
|
128
|
-
name: '
|
|
129
|
-
message: `从 ${source}
|
|
130
|
-
|
|
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}
|
|
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 (
|
|
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
|
+
}
|
package/scripts/seed-profile.sh
CHANGED
|
@@ -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
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
需要用户接管时返回 `needsUser: { step, text, command? }` 而不抛异常:把 `text` 说给用户、给命令,
|
|
52
|
-
等「好了」再跑;不向用户要 token,不展示 `package.json`。
|
|
48
|
+
只在用户明确说「帮我发布」后调;先 `_changelog` 把这一版写进 `CHANGELOG.md`。
|
|
49
|
+
出口、署名、标题、安装命令由工具与审批屏处理,不用问、不用补。
|
|
50
|
+
需要用户接管时返回 `needsUser: { step, text, command? }`:把 `text` 说给用户,等「好了」再跑;不向用户要 token。
|