@dsh-workbench/adapter-profile 1.1.14-beta.2 → 1.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config-sync-assets.d.ts +32 -0
- package/dist/config-sync-assets.js +290 -0
- package/dist/config-sync.d.ts +48 -0
- package/dist/config-sync.js +342 -0
- package/dist/host-actions.d.ts +3 -3
- package/dist/host-actions.js +21 -19
- package/dist/profile-create.d.ts +18 -0
- package/dist/profile-create.js +143 -0
- package/dist/profile-registries.d.ts +32 -0
- package/dist/profile-registries.js +172 -0
- package/dist/release.d.ts +4 -5
- package/dist/release.js +3 -3
- package/package.json +2 -5
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ConfigSyncAsset } from '@dsh-workbench/shared/contract/protocol/config-sync';
|
|
2
|
+
export interface CopyPlan {
|
|
3
|
+
readonly source: string;
|
|
4
|
+
readonly target: string;
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly overwrite: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface AssetBlock {
|
|
9
|
+
readonly insert: boolean;
|
|
10
|
+
readonly body: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function assetsOf(block: AssetBlock, sourceDir: string, targetDir: string): readonly ConfigSyncAsset[];
|
|
13
|
+
export declare function assetMapOf(blocks: readonly AssetBlock[], sourceDir: string, targetDir: string): Map<string, ConfigSyncAsset>;
|
|
14
|
+
export declare function planCopies(assets: readonly ConfigSyncAsset[], sourceDir: string, targetDir: string, overwriteAssets: ReadonlySet<string>): {
|
|
15
|
+
ok: true;
|
|
16
|
+
plans: readonly CopyPlan[];
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
text: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function cleanupFiles(created: readonly string[], backups: ReadonlyMap<string, Buffer>): void;
|
|
22
|
+
export declare function copyPlans(plans: readonly CopyPlan[]): {
|
|
23
|
+
ok: true;
|
|
24
|
+
files: string[];
|
|
25
|
+
created: string[];
|
|
26
|
+
backups: Map<string, Buffer>;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
text: string;
|
|
30
|
+
created: string[];
|
|
31
|
+
backups: Map<string, Buffer>;
|
|
32
|
+
};
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
const NAME_LINE = /^[ \t]*-?[ \t]*name:[ \t]*['"]?([^'"\s#]+)/;
|
|
6
|
+
const INSERT_LINE = /^- insert:[ \t]*$/;
|
|
7
|
+
const ASSET_MAX_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
const ASSET_MAX_TOTAL = 32 * 1024 * 1024;
|
|
9
|
+
const ASSET_MAX_FILES = 500;
|
|
10
|
+
const ASSET_SKIP = new Set(['node_modules', '.git', '.DS_Store']);
|
|
11
|
+
function matched(text, pattern) {
|
|
12
|
+
const found = pattern.exec(text);
|
|
13
|
+
return found === null ? undefined : found[1];
|
|
14
|
+
}
|
|
15
|
+
function looksLikeAssetName(value) {
|
|
16
|
+
return value.startsWith('./') || value.startsWith('../') || value.startsWith('file://') || isAbsolute(value);
|
|
17
|
+
}
|
|
18
|
+
function insertNamesOf(body) {
|
|
19
|
+
if (!INSERT_LINE.test(body.split('\n')[0] ?? ''))
|
|
20
|
+
return [];
|
|
21
|
+
return body
|
|
22
|
+
.split('\n')
|
|
23
|
+
.flatMap((line) => {
|
|
24
|
+
const name = matched(line, NAME_LINE);
|
|
25
|
+
return name !== undefined && looksLikeAssetName(name) ? [name] : [];
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function assetLocationOf(name, profileDir) {
|
|
29
|
+
let abs = null;
|
|
30
|
+
if (name.startsWith('file://')) {
|
|
31
|
+
try {
|
|
32
|
+
abs = fileURLToPath(name);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else if (name.startsWith('./') || name.startsWith('../')) {
|
|
39
|
+
abs = resolve(profileDir, name);
|
|
40
|
+
}
|
|
41
|
+
else if (isAbsolute(name)) {
|
|
42
|
+
abs = resolve(name);
|
|
43
|
+
}
|
|
44
|
+
if (abs === null)
|
|
45
|
+
return null;
|
|
46
|
+
const rel = relative(profileDir, abs);
|
|
47
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
48
|
+
return { kind: 'outside', path: name };
|
|
49
|
+
}
|
|
50
|
+
return { kind: 'inside', rel: rel.split(sep).join('/'), abs };
|
|
51
|
+
}
|
|
52
|
+
function hashOf(path) {
|
|
53
|
+
return createHash('sha1').update(readFileSync(path)).digest('hex');
|
|
54
|
+
}
|
|
55
|
+
function fileStateOf(source, target) {
|
|
56
|
+
try {
|
|
57
|
+
const sourceStat = statSync(source);
|
|
58
|
+
if (sourceStat.size > ASSET_MAX_BYTES)
|
|
59
|
+
return 'external';
|
|
60
|
+
let targetStat;
|
|
61
|
+
try {
|
|
62
|
+
targetStat = statSync(target);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return 'missing';
|
|
66
|
+
}
|
|
67
|
+
if (!targetStat.isFile() || targetStat.size !== sourceStat.size)
|
|
68
|
+
return 'differs';
|
|
69
|
+
return hashOf(source) === hashOf(target) ? 'same' : 'differs';
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return 'external';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function collectDirFiles(dir, profileDir, found, budget) {
|
|
76
|
+
let entries;
|
|
77
|
+
try {
|
|
78
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
84
|
+
if (ASSET_SKIP.has(entry.name))
|
|
85
|
+
continue;
|
|
86
|
+
const abs = join(dir, entry.name);
|
|
87
|
+
const rel = relative(profileDir, abs).split(sep).join('/');
|
|
88
|
+
if (entry.isSymbolicLink())
|
|
89
|
+
return false;
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
if (!collectDirFiles(abs, profileDir, found, budget))
|
|
92
|
+
return false;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!entry.isFile())
|
|
96
|
+
continue;
|
|
97
|
+
let size;
|
|
98
|
+
try {
|
|
99
|
+
size = statSync(abs).size;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
if (size > ASSET_MAX_BYTES)
|
|
105
|
+
return false;
|
|
106
|
+
budget.bytes += size;
|
|
107
|
+
if (budget.bytes > ASSET_MAX_TOTAL || found.length >= ASSET_MAX_FILES)
|
|
108
|
+
return false;
|
|
109
|
+
found.push(rel);
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
function aggregateAssetState(files) {
|
|
114
|
+
if (files.some((file) => file.state === 'external'))
|
|
115
|
+
return 'external';
|
|
116
|
+
if (files.some((file) => file.state === 'differs'))
|
|
117
|
+
return 'differs';
|
|
118
|
+
if (files.some((file) => file.state === 'missing'))
|
|
119
|
+
return 'missing';
|
|
120
|
+
return 'same';
|
|
121
|
+
}
|
|
122
|
+
function externalAsset(path, reason) {
|
|
123
|
+
return {
|
|
124
|
+
key: `asset:${path}`,
|
|
125
|
+
path,
|
|
126
|
+
kind: 'file',
|
|
127
|
+
state: 'external',
|
|
128
|
+
files: [],
|
|
129
|
+
bytes: 0,
|
|
130
|
+
reason,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function assetOf(name, sourceDir, targetDir) {
|
|
134
|
+
const location = assetLocationOf(name, sourceDir);
|
|
135
|
+
if (location === null)
|
|
136
|
+
return null;
|
|
137
|
+
if (location.kind === 'outside')
|
|
138
|
+
return externalAsset(location.path, '这个文件在 profile 外面');
|
|
139
|
+
let sourceStat;
|
|
140
|
+
try {
|
|
141
|
+
sourceStat = lstatSync(location.abs);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return externalAsset(location.rel, '来源里没有这个文件');
|
|
145
|
+
}
|
|
146
|
+
if (sourceStat.isSymbolicLink())
|
|
147
|
+
return externalAsset(location.rel, '符号链接不会跟着搬');
|
|
148
|
+
if (sourceStat.isDirectory()) {
|
|
149
|
+
const found = [];
|
|
150
|
+
if (!collectDirFiles(location.abs, sourceDir, found, { bytes: 0 })) {
|
|
151
|
+
return externalAsset(location.rel, '目录太大或含符号链接,先手动复制');
|
|
152
|
+
}
|
|
153
|
+
const files = found.map((path) => {
|
|
154
|
+
const source = join(sourceDir, path);
|
|
155
|
+
const bytes = statSync(source).size;
|
|
156
|
+
return { path, state: fileStateOf(source, join(targetDir, path)), bytes };
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
key: `asset:${location.rel}`,
|
|
160
|
+
path: location.rel,
|
|
161
|
+
kind: 'dir',
|
|
162
|
+
state: aggregateAssetState(files),
|
|
163
|
+
files,
|
|
164
|
+
bytes: files.reduce((sum, file) => sum + (file.bytes ?? 0), 0),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (!sourceStat.isFile())
|
|
168
|
+
return externalAsset(location.rel, '不是普通文件');
|
|
169
|
+
const state = fileStateOf(location.abs, join(targetDir, location.rel));
|
|
170
|
+
return {
|
|
171
|
+
key: `asset:${location.rel}`,
|
|
172
|
+
path: location.rel,
|
|
173
|
+
kind: 'file',
|
|
174
|
+
state,
|
|
175
|
+
files: [{ path: location.rel, state, bytes: sourceStat.size }],
|
|
176
|
+
bytes: sourceStat.size,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export function assetsOf(block, sourceDir, targetDir) {
|
|
180
|
+
if (!block.insert)
|
|
181
|
+
return [];
|
|
182
|
+
const seen = new Set();
|
|
183
|
+
const assets = [];
|
|
184
|
+
for (const name of insertNamesOf(block.body)) {
|
|
185
|
+
const asset = assetOf(name, sourceDir, targetDir);
|
|
186
|
+
if (asset === null || seen.has(asset.key))
|
|
187
|
+
continue;
|
|
188
|
+
seen.add(asset.key);
|
|
189
|
+
assets.push(asset);
|
|
190
|
+
}
|
|
191
|
+
return assets;
|
|
192
|
+
}
|
|
193
|
+
export function assetMapOf(blocks, sourceDir, targetDir) {
|
|
194
|
+
const map = new Map();
|
|
195
|
+
for (const block of blocks) {
|
|
196
|
+
for (const asset of assetsOf(block, sourceDir, targetDir))
|
|
197
|
+
map.set(asset.key, asset);
|
|
198
|
+
}
|
|
199
|
+
return map;
|
|
200
|
+
}
|
|
201
|
+
function insideTarget(targetDir, path) {
|
|
202
|
+
const target = resolve(targetDir, path);
|
|
203
|
+
const rel = relative(targetDir, target);
|
|
204
|
+
return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
205
|
+
}
|
|
206
|
+
export function planCopies(assets, sourceDir, targetDir, overwriteAssets) {
|
|
207
|
+
const plans = [];
|
|
208
|
+
let total = 0;
|
|
209
|
+
for (const asset of assets) {
|
|
210
|
+
if (asset.state === 'external') {
|
|
211
|
+
return { ok: false, text: `这个文件搬不过来:${asset.path}${asset.reason === undefined ? '' : `(${asset.reason})`}` };
|
|
212
|
+
}
|
|
213
|
+
for (const file of asset.files) {
|
|
214
|
+
if (file.state === 'same')
|
|
215
|
+
continue;
|
|
216
|
+
if (file.state === 'external') {
|
|
217
|
+
return { ok: false, text: `这个文件搬不过来:${file.path}${asset.reason === undefined ? '' : `(${asset.reason})`}` };
|
|
218
|
+
}
|
|
219
|
+
const overwrite = file.state === 'differs';
|
|
220
|
+
if (overwrite && !overwriteAssets.has(asset.key)) {
|
|
221
|
+
return { ok: false, text: `文件不一样:${file.path}。勾上覆盖再试` };
|
|
222
|
+
}
|
|
223
|
+
if (!insideTarget(targetDir, file.path)) {
|
|
224
|
+
return { ok: false, text: `这个文件会写到 profile 外面:${file.path}` };
|
|
225
|
+
}
|
|
226
|
+
total += file.bytes ?? 0;
|
|
227
|
+
if (total > ASSET_MAX_TOTAL) {
|
|
228
|
+
return { ok: false, text: `这次要复制的文件太大(超过 ${String(ASSET_MAX_TOTAL / 1024 / 1024)} MB),先手动复制` };
|
|
229
|
+
}
|
|
230
|
+
plans.push({
|
|
231
|
+
source: join(sourceDir, file.path),
|
|
232
|
+
target: join(targetDir, file.path),
|
|
233
|
+
path: file.path,
|
|
234
|
+
overwrite,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { ok: true, plans };
|
|
239
|
+
}
|
|
240
|
+
export function cleanupFiles(created, backups) {
|
|
241
|
+
for (const file of [...created].reverse()) {
|
|
242
|
+
try {
|
|
243
|
+
unlinkSync(file);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const [file, body] of backups) {
|
|
250
|
+
try {
|
|
251
|
+
writeFileSync(file, body);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
export function copyPlans(plans) {
|
|
259
|
+
const created = [];
|
|
260
|
+
const backups = new Map();
|
|
261
|
+
const files = [];
|
|
262
|
+
for (const plan of plans) {
|
|
263
|
+
try {
|
|
264
|
+
mkdirSync(dirname(plan.target), { recursive: true });
|
|
265
|
+
if (existsSync(plan.target)) {
|
|
266
|
+
if (!plan.overwrite)
|
|
267
|
+
continue;
|
|
268
|
+
backups.set(plan.target, readFileSync(plan.target));
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
created.push(plan.target);
|
|
272
|
+
}
|
|
273
|
+
const mode = statSync(plan.source).mode & 0o777;
|
|
274
|
+
const tmp = `${plan.target}.wb-sync-${String(process.pid)}-${String(files.length)}`;
|
|
275
|
+
copyFileSync(plan.source, tmp);
|
|
276
|
+
chmodSync(tmp, mode);
|
|
277
|
+
renameSync(tmp, plan.target);
|
|
278
|
+
files.push(plan.path);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
text: `写不进去:${error instanceof Error ? error.message : String(error)}`,
|
|
284
|
+
created,
|
|
285
|
+
backups,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { ok: true, files, created, backups };
|
|
290
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { PostResource, RouteHandler } from '@dsh-workbench/shared/host';
|
|
2
|
+
import type { ConfigSyncRow, ConfigSyncSnapshot } from '@dsh-workbench/shared/contract/protocol/config-sync';
|
|
3
|
+
export interface PatchBlock {
|
|
4
|
+
readonly key: string;
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly insert: boolean;
|
|
7
|
+
readonly body: string;
|
|
8
|
+
readonly names: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function patchBlocks(text: string): PatchBlock[];
|
|
11
|
+
export declare function normalizeBlock(body: string): string;
|
|
12
|
+
export declare function syncRows(source: readonly PatchBlock[], own: readonly PatchBlock[], missing: (block: PatchBlock) => readonly string[]): ConfigSyncRow[];
|
|
13
|
+
export declare function mergeBlocks(ownText: string, incoming: readonly PatchBlock[], overwrite: ReadonlySet<string>): {
|
|
14
|
+
ok: true;
|
|
15
|
+
text: string;
|
|
16
|
+
written: string[];
|
|
17
|
+
skipped: string[];
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
reason: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function readPatchBlocks(path: string): {
|
|
23
|
+
ok: true;
|
|
24
|
+
text: string;
|
|
25
|
+
blocks: PatchBlock[];
|
|
26
|
+
} | {
|
|
27
|
+
ok: false;
|
|
28
|
+
reason: string;
|
|
29
|
+
};
|
|
30
|
+
export declare function writePatchFile(path: string, text: string): void;
|
|
31
|
+
export interface ConfigSyncDeps {
|
|
32
|
+
readonly home: string;
|
|
33
|
+
readonly self: string;
|
|
34
|
+
readonly profileDir: string | null;
|
|
35
|
+
readonly reconcile?: (() => Promise<boolean | void>) | undefined;
|
|
36
|
+
}
|
|
37
|
+
export declare function configSyncHandler(deps: ConfigSyncDeps): RouteHandler;
|
|
38
|
+
export declare function configSyncSnapshot(deps: ConfigSyncDeps, asked?: string): ConfigSyncSnapshot;
|
|
39
|
+
export declare function configSyncApply(deps: ConfigSyncDeps, payload: unknown): Promise<{
|
|
40
|
+
ok: true;
|
|
41
|
+
written: string[];
|
|
42
|
+
skipped: string[];
|
|
43
|
+
verified: boolean;
|
|
44
|
+
} | {
|
|
45
|
+
ok: false;
|
|
46
|
+
text: string;
|
|
47
|
+
}>;
|
|
48
|
+
export declare function configSyncAction(deps: ConfigSyncDeps): PostResource;
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { CONFIG_SYNC_RESOURCE } from '@dsh-workbench/shared/contract/protocol/config-sync';
|
|
4
|
+
const ID_LINE = /^- id:[ \t]*"?([^"\s#]+)/;
|
|
5
|
+
const INSERT_LINE = /^- insert:[ \t]*$/;
|
|
6
|
+
const NESTED_ID = /^[ \t]+-[ \t]*id:[ \t]*"?([^"\s#]+)/;
|
|
7
|
+
const NAME_LINE = /^[ \t]*-?[ \t]*name:[ \t]*['"]?([^'"\s#]+)/;
|
|
8
|
+
const COMMENT_LINE = /^[ \t]*#/;
|
|
9
|
+
const ISOLATION_IDS = new Set([
|
|
10
|
+
'session-persistence-jsonl',
|
|
11
|
+
'storage-json',
|
|
12
|
+
'attachment-local',
|
|
13
|
+
'sandbox-policy',
|
|
14
|
+
]);
|
|
15
|
+
function trimmedTail(lines) {
|
|
16
|
+
let end = lines.length;
|
|
17
|
+
while (end > 0 && (lines[end - 1] ?? '').trim() === '')
|
|
18
|
+
end -= 1;
|
|
19
|
+
return lines.slice(0, end);
|
|
20
|
+
}
|
|
21
|
+
function matched(text, pattern) {
|
|
22
|
+
const found = pattern.exec(text);
|
|
23
|
+
return found === null ? undefined : found[1];
|
|
24
|
+
}
|
|
25
|
+
function blockOf(body, index) {
|
|
26
|
+
const lines = body.split('\n');
|
|
27
|
+
const first = lines[0] ?? '';
|
|
28
|
+
const id = matched(first, ID_LINE);
|
|
29
|
+
const nested = lines.flatMap((line) => {
|
|
30
|
+
const one = matched(line, NESTED_ID);
|
|
31
|
+
return one === undefined ? [] : [one];
|
|
32
|
+
});
|
|
33
|
+
const insert = id === undefined && INSERT_LINE.test(first);
|
|
34
|
+
const label = id ?? nested.join(', ');
|
|
35
|
+
const key = id !== undefined ? `id:${id}` : insert ? `insert:${nested.join(', ')}` : `block:${String(index)}`;
|
|
36
|
+
const names = lines.flatMap((line) => {
|
|
37
|
+
const one = matched(line, NAME_LINE);
|
|
38
|
+
return one === undefined ? [] : [one];
|
|
39
|
+
});
|
|
40
|
+
return { key, id: label === '' ? key : label, insert, body, names };
|
|
41
|
+
}
|
|
42
|
+
function spannedBlocks(text) {
|
|
43
|
+
const lines = text.split('\n');
|
|
44
|
+
const starts = [];
|
|
45
|
+
lines.forEach((line, index) => {
|
|
46
|
+
if (/^- /.test(line) || line === '-')
|
|
47
|
+
starts.push(index);
|
|
48
|
+
});
|
|
49
|
+
const blocks = [];
|
|
50
|
+
for (let i = 0; i < starts.length; i += 1) {
|
|
51
|
+
const from = starts[i] ?? 0;
|
|
52
|
+
const to = i + 1 < starts.length ? (starts[i + 1] ?? lines.length) : lines.length;
|
|
53
|
+
const body = trimmedTail(lines.slice(from, to)).join('\n');
|
|
54
|
+
if (body.trim() === '')
|
|
55
|
+
continue;
|
|
56
|
+
blocks.push({ ...blockOf(body, blocks.length), from, to });
|
|
57
|
+
}
|
|
58
|
+
return blocks;
|
|
59
|
+
}
|
|
60
|
+
export function patchBlocks(text) {
|
|
61
|
+
return spannedBlocks(text).map(({ from: _from, to: _to, ...block }) => block);
|
|
62
|
+
}
|
|
63
|
+
export function normalizeBlock(body) {
|
|
64
|
+
return body
|
|
65
|
+
.split('\n')
|
|
66
|
+
.map((line) => line.replace(/\s+$/, ''))
|
|
67
|
+
.filter((line) => line.trim() !== '' && !COMMENT_LINE.test(line))
|
|
68
|
+
.join('\n');
|
|
69
|
+
}
|
|
70
|
+
function rewriteForTarget(body, self) {
|
|
71
|
+
if (self === '' || self === 'workbench')
|
|
72
|
+
return body;
|
|
73
|
+
return body.replace(/(dshHomePath\(\s*['"])workbench(?=[/'"])/g, `$1${self}`);
|
|
74
|
+
}
|
|
75
|
+
function syncable(block) {
|
|
76
|
+
return !block.insert && !ISOLATION_IDS.has(block.id);
|
|
77
|
+
}
|
|
78
|
+
export function syncRows(source, own, missing) {
|
|
79
|
+
const mine = new Map(own.map((block) => [block.key, block]));
|
|
80
|
+
const rows = [];
|
|
81
|
+
for (const block of source) {
|
|
82
|
+
if (!syncable(block))
|
|
83
|
+
continue;
|
|
84
|
+
const here = mine.get(block.key);
|
|
85
|
+
if (here !== undefined && normalizeBlock(here.body) === normalizeBlock(block.body))
|
|
86
|
+
continue;
|
|
87
|
+
rows.push({
|
|
88
|
+
key: block.key,
|
|
89
|
+
id: block.id,
|
|
90
|
+
state: here === undefined ? 'new' : 'differs',
|
|
91
|
+
body: block.body,
|
|
92
|
+
ownBody: here === undefined ? null : here.body,
|
|
93
|
+
missing: missing(block),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
function replaceBlocks(text, blocks) {
|
|
99
|
+
const wanted = new Map(blocks.map((block) => [block.key, block]));
|
|
100
|
+
const spans = spannedBlocks(text).filter((span) => wanted.has(span.key));
|
|
101
|
+
const lines = text.split('\n');
|
|
102
|
+
for (const span of [...spans].reverse()) {
|
|
103
|
+
const replacement = (wanted.get(span.key)?.body ?? '').split('\n');
|
|
104
|
+
lines.splice(span.from, span.to - span.from, ...replacement);
|
|
105
|
+
}
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
function appendBlocks(text, blocks) {
|
|
109
|
+
const kept = text
|
|
110
|
+
.split('\n')
|
|
111
|
+
.filter((line) => line.trim() !== '[]')
|
|
112
|
+
.join('\n')
|
|
113
|
+
.replace(/\s+$/, '');
|
|
114
|
+
let out = kept === '' ? '' : `${kept}\n`;
|
|
115
|
+
for (const block of blocks)
|
|
116
|
+
out += `\n${block.body}\n`;
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
export function mergeBlocks(ownText, incoming, overwrite) {
|
|
120
|
+
const own = patchBlocks(ownText);
|
|
121
|
+
const mine = new Set(own.map((block) => block.key));
|
|
122
|
+
const written = [];
|
|
123
|
+
const skipped = [];
|
|
124
|
+
const take = [];
|
|
125
|
+
for (const block of incoming) {
|
|
126
|
+
if (!mine.has(block.key) || overwrite.has(block.key)) {
|
|
127
|
+
take.push(block);
|
|
128
|
+
written.push(block.id);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
skipped.push(block.id);
|
|
132
|
+
}
|
|
133
|
+
if (take.length === 0)
|
|
134
|
+
return { ok: true, text: ownText, written, skipped };
|
|
135
|
+
const replaced = replaceBlocks(ownText, take.filter((block) => mine.has(block.key)));
|
|
136
|
+
const text = appendBlocks(replaced, take.filter((block) => !mine.has(block.key)));
|
|
137
|
+
const check = patchBlocks(text);
|
|
138
|
+
const expected = new Set([...mine, ...take.map((block) => block.key)]);
|
|
139
|
+
if (check.length !== expected.size || new Set(check.map((block) => block.key)).size !== expected.size) {
|
|
140
|
+
return { ok: false, reason: '写入前的自检没过:行的 id 会重复或丢行' };
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, text, written, skipped };
|
|
143
|
+
}
|
|
144
|
+
export function readPatchBlocks(path) {
|
|
145
|
+
try {
|
|
146
|
+
const text = readFileSync(path, 'utf8');
|
|
147
|
+
return { ok: true, text, blocks: patchBlocks(text) };
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function patchMode(path) {
|
|
154
|
+
try {
|
|
155
|
+
return statSync(path).mode & 0o777;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return 0o644;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export function writePatchFile(path, text) {
|
|
162
|
+
const mode = patchMode(path);
|
|
163
|
+
const tmp = `${path}.wb-sync-${String(process.pid)}`;
|
|
164
|
+
writeFileSync(tmp, text, { mode });
|
|
165
|
+
try {
|
|
166
|
+
renameSync(tmp, path);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
try {
|
|
170
|
+
unlinkSync(tmp);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
export function configSyncHandler(deps) {
|
|
179
|
+
return (search) => ({ ok: true, ...configSyncSnapshot(deps, search.get('source') ?? undefined) });
|
|
180
|
+
}
|
|
181
|
+
function textOf(value) {
|
|
182
|
+
return typeof value === 'string' ? value : '';
|
|
183
|
+
}
|
|
184
|
+
function profileDependencies(dir) {
|
|
185
|
+
try {
|
|
186
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
187
|
+
return new Set(Object.keys(manifest.dependencies ?? {}));
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return new Set();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function profileNames(profilesDir) {
|
|
194
|
+
try {
|
|
195
|
+
return readdirSync(profilesDir, { withFileTypes: true })
|
|
196
|
+
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
|
197
|
+
.map((entry) => entry.name)
|
|
198
|
+
.filter((name) => existsSync(join(profilesDir, name, 'package.json')))
|
|
199
|
+
.sort((a, b) => a.localeCompare(b));
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
export function configSyncSnapshot(deps, asked) {
|
|
206
|
+
const profilesDir = join(deps.home, 'profiles');
|
|
207
|
+
const names = profileNames(profilesDir);
|
|
208
|
+
const others = names.filter((name) => name !== deps.self);
|
|
209
|
+
const base = {
|
|
210
|
+
self: deps.self,
|
|
211
|
+
home: deps.home,
|
|
212
|
+
};
|
|
213
|
+
if (deps.profileDir === null) {
|
|
214
|
+
return {
|
|
215
|
+
...base,
|
|
216
|
+
source: '',
|
|
217
|
+
profiles: [],
|
|
218
|
+
rows: [],
|
|
219
|
+
emptyProviders: false,
|
|
220
|
+
reason: '拿不到当前 profile 目录',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const own = readPatchBlocks(join(deps.profileDir, 'cordis.patch.yml'));
|
|
224
|
+
const emptyProviders = own.ok && !own.blocks.some((block) => block.key.startsWith('id:llm-'));
|
|
225
|
+
const ownBlocks = own.ok ? own.blocks : [];
|
|
226
|
+
const profiles = others.map((name) => {
|
|
227
|
+
const read = readPatchBlocks(join(profilesDir, name, 'cordis.patch.yml'));
|
|
228
|
+
const blocks = read.ok
|
|
229
|
+
? read.blocks
|
|
230
|
+
.filter(syncable)
|
|
231
|
+
.map((block) => ({ ...block, body: rewriteForTarget(block.body, deps.self) }))
|
|
232
|
+
: [];
|
|
233
|
+
return {
|
|
234
|
+
name,
|
|
235
|
+
readable: read.ok,
|
|
236
|
+
candidates: read.ok ? syncRows(blocks, ownBlocks, () => []).length : 0,
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
if (others.length === 0) {
|
|
240
|
+
return {
|
|
241
|
+
...base,
|
|
242
|
+
source: '',
|
|
243
|
+
profiles,
|
|
244
|
+
rows: [],
|
|
245
|
+
emptyProviders,
|
|
246
|
+
reason: '这台机器上只有当前这个 profile,没有可同步的来源',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
const source = others.includes(asked ?? '') ? (asked ?? '') : others.includes('web') ? 'web' : (others[0] ?? '');
|
|
250
|
+
const sourceDir = join(profilesDir, source);
|
|
251
|
+
const read = readPatchBlocks(join(sourceDir, 'cordis.patch.yml'));
|
|
252
|
+
if (!read.ok) {
|
|
253
|
+
return { ...base, source, profiles, rows: [], emptyProviders, reason: read.reason };
|
|
254
|
+
}
|
|
255
|
+
if (!own.ok) {
|
|
256
|
+
return { ...base, source, profiles, rows: [], emptyProviders, reason: own.reason };
|
|
257
|
+
}
|
|
258
|
+
const declared = profileDependencies(sourceDir);
|
|
259
|
+
const modulesDir = join(deps.profileDir, 'node_modules');
|
|
260
|
+
const sourceBlocks = read.blocks
|
|
261
|
+
.filter(syncable)
|
|
262
|
+
.map((block) => ({ ...block, body: rewriteForTarget(block.body, deps.self) }));
|
|
263
|
+
const rows = syncRows(sourceBlocks, own.blocks, (block) => block.names.filter((name) => declared.has(name) && !existsSync(join(modulesDir, name))));
|
|
264
|
+
return { ...base, source, profiles, rows, emptyProviders };
|
|
265
|
+
}
|
|
266
|
+
export async function configSyncApply(deps, payload) {
|
|
267
|
+
if (deps.profileDir === null)
|
|
268
|
+
return { ok: false, text: '拿不到当前 profile 目录,这次没写任何东西' };
|
|
269
|
+
const body = typeof payload === 'object' && payload !== null ? payload : {};
|
|
270
|
+
const source = textOf(body['source']);
|
|
271
|
+
const keys = new Set((Array.isArray(body['keys']) ? body['keys'] : []).map((item) => textOf(item)).filter((item) => item !== ''));
|
|
272
|
+
const overwrite = new Set((Array.isArray(body['overwrite']) ? body['overwrite'] : []).map((item) => textOf(item)));
|
|
273
|
+
if (source === '' || keys.size === 0) {
|
|
274
|
+
return { ok: false, text: '没说清从哪个 profile 同步哪几行' };
|
|
275
|
+
}
|
|
276
|
+
const profilesDir = join(deps.home, 'profiles');
|
|
277
|
+
if (!profileNames(profilesDir).includes(source))
|
|
278
|
+
return { ok: false, text: `没有这个 profile:${source}` };
|
|
279
|
+
if (source === deps.self)
|
|
280
|
+
return { ok: false, text: '来源不能是工作台自己' };
|
|
281
|
+
const sourceDir = join(profilesDir, source);
|
|
282
|
+
const read = readPatchBlocks(join(sourceDir, 'cordis.patch.yml'));
|
|
283
|
+
if (!read.ok)
|
|
284
|
+
return { ok: false, text: `读不到 ${source} 的配置:${read.reason}` };
|
|
285
|
+
const own = readPatchBlocks(join(deps.profileDir, 'cordis.patch.yml'));
|
|
286
|
+
if (!own.ok)
|
|
287
|
+
return { ok: false, text: `读不到工作台自己的配置:${own.reason}` };
|
|
288
|
+
if (read.blocks.some((block) => block.insert && keys.has(block.key))) {
|
|
289
|
+
return { ok: false, text: '插件行不同步:先把插件装到工作台这边,再同步它的设置' };
|
|
290
|
+
}
|
|
291
|
+
const picked = read.blocks
|
|
292
|
+
.filter((block) => keys.has(block.key) && syncable(block))
|
|
293
|
+
.map((block) => ({ ...block, body: rewriteForTarget(block.body, deps.self) }));
|
|
294
|
+
if (picked.length === 0) {
|
|
295
|
+
return { ok: false, text: '选中的行在来源里已经找不到了,重新读一遍再试' };
|
|
296
|
+
}
|
|
297
|
+
const merged = mergeBlocks(own.text, picked, overwrite);
|
|
298
|
+
if (!merged.ok)
|
|
299
|
+
return { ok: false, text: merged.reason };
|
|
300
|
+
const patchPath = join(deps.profileDir, 'cordis.patch.yml');
|
|
301
|
+
const patchChanged = merged.text !== own.text;
|
|
302
|
+
try {
|
|
303
|
+
if (patchChanged)
|
|
304
|
+
writePatchFile(patchPath, merged.text);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
return { ok: false, text: `写不进去:${error instanceof Error ? error.message : String(error)}` };
|
|
308
|
+
}
|
|
309
|
+
if (deps.reconcile === undefined) {
|
|
310
|
+
return { ok: true, written: merged.written, skipped: merged.skipped, verified: false };
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const verified = await deps.reconcile();
|
|
314
|
+
if (verified === false) {
|
|
315
|
+
return { ok: true, written: merged.written, skipped: merged.skipped, verified: false };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (patchChanged) {
|
|
320
|
+
try {
|
|
321
|
+
writePatchFile(patchPath, own.text);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return { ok: false, text: `写进去了,但启动检查没通过:${error instanceof Error ? error.message : String(error)}。原来的设置没能恢复,手动检查一下` };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
await deps.reconcile();
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return { ok: false, text: `写进去了,但启动检查没通过:${error instanceof Error ? error.message : String(error)}。已恢复原来的设置` };
|
|
332
|
+
}
|
|
333
|
+
return { ok: false, text: `写进去了,但启动检查没通过:${error instanceof Error ? error.message : String(error)}。已恢复原来的设置` };
|
|
334
|
+
}
|
|
335
|
+
return { ok: true, written: merged.written, skipped: merged.skipped, verified: true };
|
|
336
|
+
}
|
|
337
|
+
export function configSyncAction(deps) {
|
|
338
|
+
return {
|
|
339
|
+
resource: CONFIG_SYNC_RESOURCE,
|
|
340
|
+
handle: (payload) => configSyncApply(deps, payload),
|
|
341
|
+
};
|
|
342
|
+
}
|
package/dist/host-actions.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { HostActions, ProfileCreateDeps } from '@dsh-workbench/shared/host';
|
|
2
|
-
export declare function runSetupAt(packageRoot: string, env: Readonly<Record<string, string>>): Promise<{
|
|
1
|
+
import type { HostActions, ProfileCreateDeps, Seam } from '@dsh-workbench/shared/host';
|
|
2
|
+
export declare function runSetupAt(seam: Seam, packageRoot: string, env: Readonly<Record<string, string>>): Promise<{
|
|
3
3
|
ok: boolean;
|
|
4
4
|
log: string;
|
|
5
5
|
}>;
|
|
6
6
|
export declare function createHostActions(): HostActions;
|
|
7
|
-
export declare function profileCreateDepsOf(deps: Omit<ProfileCreateDeps, 'runSetup'>): ProfileCreateDeps;
|
|
7
|
+
export declare function profileCreateDepsOf(seam: Seam, deps: Omit<ProfileCreateDeps, 'runSetup'>): ProfileCreateDeps;
|
package/dist/host-actions.js
CHANGED
|
@@ -1,28 +1,30 @@
|
|
|
1
|
-
import { execa } from 'execa';
|
|
2
1
|
import { join } from 'node:path';
|
|
3
2
|
import { configSyncAction, configSyncHandler, profileCreateAction, profileCreateHandler, } from '@dsh-workbench/shared/host';
|
|
4
3
|
const SETUP_TIMEOUT_MS = 10 * 60 * 1000;
|
|
5
|
-
|
|
4
|
+
const LOG_TAIL_CHARS = 20_000;
|
|
5
|
+
function tailOf(stdout, stderr) {
|
|
6
|
+
return `${stdout}${stderr}`.slice(-LOG_TAIL_CHARS);
|
|
7
|
+
}
|
|
8
|
+
export async function runSetupAt(seam, packageRoot, env) {
|
|
6
9
|
const bin = join(packageRoot, 'bin', 'dsh-workbench');
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return child.then((result) => (result.timedOut === true ? { ok: false, log: `${log}\n创建超时` } : { ok: result.exitCode === 0, log }), (error) => ({ ok: false, log: `${log}\n${error instanceof Error ? error.message : String(error)}` }));
|
|
10
|
+
try {
|
|
11
|
+
const outcome = await seam.run({
|
|
12
|
+
argv: ['bash', bin, 'setup'],
|
|
13
|
+
intent: { kind: 'host' },
|
|
14
|
+
workdir: packageRoot,
|
|
15
|
+
env,
|
|
16
|
+
timeoutMs: SETUP_TIMEOUT_MS,
|
|
17
|
+
});
|
|
18
|
+
const log = tailOf(outcome.stdout, outcome.stderr);
|
|
19
|
+
return outcome.timedOut ? { ok: false, log: `${log}\n创建超时` } : { ok: outcome.exitCode === 0, log };
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
return { ok: false, log: `\n${error instanceof Error ? error.message : String(error)}` };
|
|
23
|
+
}
|
|
22
24
|
}
|
|
23
25
|
export function createHostActions() {
|
|
24
26
|
return { configSyncAction, configSyncHandler, profileCreateAction, profileCreateHandler };
|
|
25
27
|
}
|
|
26
|
-
export function profileCreateDepsOf(deps) {
|
|
27
|
-
return { ...deps, runSetup: (env) => runSetupAt(deps.packageRoot, env) };
|
|
28
|
+
export function profileCreateDepsOf(seam, deps) {
|
|
29
|
+
return { ...deps, runSetup: (env) => runSetupAt(seam, deps.packageRoot, env) };
|
|
28
30
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PostResource, RouteHandler } from '@dsh-workbench/shared/host';
|
|
2
|
+
import type { ProfileCreateApplied, ProfileCreateSnapshot } from '@dsh-workbench/shared/contract/protocol/profile-create';
|
|
3
|
+
export interface ProfileCreateDeps {
|
|
4
|
+
readonly home: string;
|
|
5
|
+
readonly self: string;
|
|
6
|
+
readonly packageRoot: string;
|
|
7
|
+
readonly runSetup?: ((env: Readonly<Record<string, string>>) => Promise<{
|
|
8
|
+
ok: boolean;
|
|
9
|
+
log: string;
|
|
10
|
+
}>) | undefined;
|
|
11
|
+
}
|
|
12
|
+
export declare function profileCreateSnapshot(deps: ProfileCreateDeps): ProfileCreateSnapshot;
|
|
13
|
+
export declare function profileCreateHandler(deps: ProfileCreateDeps): RouteHandler;
|
|
14
|
+
export declare function profileCreateApply(deps: ProfileCreateDeps, payload: unknown): Promise<ProfileCreateApplied | {
|
|
15
|
+
ok: false;
|
|
16
|
+
text: string;
|
|
17
|
+
}>;
|
|
18
|
+
export declare function profileCreateAction(deps: ProfileCreateDeps): PostResource;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, sep } from 'node:path';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import { quoteShellArg } from '@dsh-workbench/shared/host';
|
|
5
|
+
import { PROFILE_CREATE_RESOURCE } from '@dsh-workbench/shared/contract/protocol/profile-create';
|
|
6
|
+
const PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
7
|
+
const BUNDLE = '@dsh-workbench/core';
|
|
8
|
+
const SETUP_TIMEOUT_MS = 10 * 60 * 1000;
|
|
9
|
+
function textOf(value) {
|
|
10
|
+
return typeof value === 'string' ? value : '';
|
|
11
|
+
}
|
|
12
|
+
function profileNames(profilesDir) {
|
|
13
|
+
try {
|
|
14
|
+
return readdirSync(profilesDir, { withFileTypes: true })
|
|
15
|
+
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
|
16
|
+
.map((entry) => entry.name)
|
|
17
|
+
.filter((name) => existsSync(join(profilesDir, name, 'package.json')))
|
|
18
|
+
.sort((a, b) => a.localeCompare(b));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function profileCreateSnapshot(deps) {
|
|
25
|
+
const profiles = profileNames(join(deps.home, 'profiles')).map((name) => ({
|
|
26
|
+
name,
|
|
27
|
+
current: name === deps.self,
|
|
28
|
+
readable: true,
|
|
29
|
+
}));
|
|
30
|
+
return { current: deps.self, profiles };
|
|
31
|
+
}
|
|
32
|
+
export function profileCreateHandler(deps) {
|
|
33
|
+
return () => ({ ok: true, ...profileCreateSnapshot(deps) });
|
|
34
|
+
}
|
|
35
|
+
function inside(base, path) {
|
|
36
|
+
const root = resolve(base);
|
|
37
|
+
const target = resolve(path);
|
|
38
|
+
return target === root ? false : target.startsWith(`${root}${sep}`);
|
|
39
|
+
}
|
|
40
|
+
async function defaultRunSetup(packageRoot, env) {
|
|
41
|
+
const bin = join(packageRoot, 'bin', 'dsh-workbench');
|
|
42
|
+
let log = '';
|
|
43
|
+
const collect = (chunk) => {
|
|
44
|
+
log = `${log}${chunk.toString('utf8')}`.slice(-20_000);
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
const child = execa('bash', [bin, 'setup'], {
|
|
48
|
+
cwd: packageRoot,
|
|
49
|
+
env: { ...process.env, ...env },
|
|
50
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
51
|
+
timeout: SETUP_TIMEOUT_MS,
|
|
52
|
+
reject: false,
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
});
|
|
55
|
+
child.stdout?.on('data', collect);
|
|
56
|
+
child.stderr?.on('data', collect);
|
|
57
|
+
const result = await child;
|
|
58
|
+
if (result.timedOut)
|
|
59
|
+
return { ok: false, log: `${log}\n创建超时` };
|
|
60
|
+
return { ok: result.exitCode === 0, log };
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
return { ok: false, log: `${log}\n${error instanceof Error ? error.message : String(error)}` };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export async function profileCreateApply(deps, payload) {
|
|
67
|
+
const body = typeof payload === 'object' && payload !== null ? payload : {};
|
|
68
|
+
const profile = textOf(body['profile']).trim();
|
|
69
|
+
const source = textOf(body['source']).trim();
|
|
70
|
+
const copy = typeof body['copy'] === 'object' && body['copy'] !== null ? body['copy'] : {};
|
|
71
|
+
const flags = {
|
|
72
|
+
settings: copy['settings'] === true,
|
|
73
|
+
};
|
|
74
|
+
if (!PROFILE_NAME.test(profile))
|
|
75
|
+
return { ok: false, text: '新工作台的名字不合法:只用字母、数字、点、下划线或短横线' };
|
|
76
|
+
if (source === '')
|
|
77
|
+
return { ok: false, text: '先选一个来源 profile' };
|
|
78
|
+
if (source === profile)
|
|
79
|
+
return { ok: false, text: '来源不能是新工作台自己' };
|
|
80
|
+
const profilesDir = join(deps.home, 'profiles');
|
|
81
|
+
if (!profileNames(profilesDir).includes(source))
|
|
82
|
+
return { ok: false, text: `没有这个 profile:${source}` };
|
|
83
|
+
if (existsSync(join(profilesDir, profile)))
|
|
84
|
+
return { ok: false, text: `已经有一个叫 ${profile} 的 profile` };
|
|
85
|
+
const targetHome = join(deps.home, profile);
|
|
86
|
+
if (existsSync(targetHome))
|
|
87
|
+
return { ok: false, text: `运行时目录已经存在:${targetHome}` };
|
|
88
|
+
const env = {
|
|
89
|
+
DSH_HOME: deps.home,
|
|
90
|
+
DSH_WORKBENCH_HOME: targetHome,
|
|
91
|
+
WORKBENCH_PROFILE: profile,
|
|
92
|
+
WORKBENCH_SOURCE_PROFILE: source,
|
|
93
|
+
WB_CLONE: '1',
|
|
94
|
+
WB_COPY_SETTINGS: flags.settings ? '1' : '0',
|
|
95
|
+
};
|
|
96
|
+
const setup = deps.runSetup ?? ((next) => defaultRunSetup(deps.packageRoot, next));
|
|
97
|
+
const cleanup = () => {
|
|
98
|
+
try {
|
|
99
|
+
if (inside(profilesDir, join(profilesDir, profile)))
|
|
100
|
+
rmSync(join(profilesDir, profile), { recursive: true, force: true });
|
|
101
|
+
if (inside(deps.home, targetHome))
|
|
102
|
+
rmSync(targetHome, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const result = await setup(env);
|
|
109
|
+
if (!result.ok) {
|
|
110
|
+
cleanup();
|
|
111
|
+
return { ok: false, text: `创建失败:${result.log.trim().slice(-1200) || 'setup 没给出原因'}` };
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const manifest = JSON.parse(readFileSync(join(profilesDir, profile, 'package.json'), 'utf8'));
|
|
115
|
+
const bundles = manifest.dsh?.profile?.bundles;
|
|
116
|
+
if (!Array.isArray(bundles) || !bundles.includes(BUNDLE)) {
|
|
117
|
+
cleanup();
|
|
118
|
+
return { ok: false, text: 'profile 建好了,但工作台 bundle 没被选中;先跑一次 setup 修好它' };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
cleanup();
|
|
123
|
+
return { ok: false, text: `profile 建好了,但读不了清单:${error instanceof Error ? error.message : String(error)}` };
|
|
124
|
+
}
|
|
125
|
+
const copied = Object.entries(flags)
|
|
126
|
+
.filter(([, on]) => on)
|
|
127
|
+
.map(([name]) => name);
|
|
128
|
+
const bin = join(deps.packageRoot, 'bin', 'dsh-workbench');
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
profile,
|
|
132
|
+
source,
|
|
133
|
+
home: targetHome,
|
|
134
|
+
command: `WORKBENCH_PROFILE=${quoteShellArg(profile)} ${quoteShellArg(bin)}`,
|
|
135
|
+
copied,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export function profileCreateAction(deps) {
|
|
139
|
+
return {
|
|
140
|
+
resource: PROFILE_CREATE_RESOURCE,
|
|
141
|
+
handle: (payload) => profileCreateApply(deps, payload),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface BundleChange {
|
|
2
|
+
readonly pkg?: string;
|
|
3
|
+
readonly registry?: string;
|
|
4
|
+
readonly application?: string;
|
|
5
|
+
readonly packageResult?: {
|
|
6
|
+
readonly kind?: string;
|
|
7
|
+
readonly output?: string;
|
|
8
|
+
};
|
|
9
|
+
readonly errorDetail?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface RegistryEntry {
|
|
12
|
+
readonly scope: string;
|
|
13
|
+
readonly registry: string;
|
|
14
|
+
}
|
|
15
|
+
export interface RegistryNotebook {
|
|
16
|
+
sources(): readonly RegistryEntry[];
|
|
17
|
+
remember(entries: readonly RegistryEntry[]): readonly string[];
|
|
18
|
+
swapScope?(scope: string, registry: string): () => void;
|
|
19
|
+
}
|
|
20
|
+
export declare const REGISTRY_MARKER = "# dsh-workbench \u5199\u7684\uFF1A\u8FD9\u4E00\u884C\u7684\u5305\u53EA\u5728\u8FD9\u4E2A\u6E90\u4E0A\uFF08dsh \u5378\u8F7D\u6CA1\u6709\u6E90\u53C2\u6570\uFF0Cpnpm \u5F97\u95EE\u5F97\u5230\uFF09";
|
|
21
|
+
export declare function scopeOf(pkg: string): string | null;
|
|
22
|
+
export declare function normalizeRegistry(url: string): string;
|
|
23
|
+
export declare function sameRegistry(a: string | null | undefined, b: string | null | undefined): boolean;
|
|
24
|
+
export declare function sourcesFromLockfile(text: string): readonly RegistryEntry[];
|
|
25
|
+
export interface RegistryConfigMerge {
|
|
26
|
+
readonly text: string;
|
|
27
|
+
readonly added: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
export declare function mergeRegistryConfig(text: string, entries: readonly RegistryEntry[]): RegistryConfigMerge;
|
|
30
|
+
export declare function replaceScopeRegistry(text: string, scope: string, registry: string): string;
|
|
31
|
+
export declare function registryFailure(change: BundleChange): boolean;
|
|
32
|
+
export declare function createRegistryNotebook(dir: string | null): RegistryNotebook;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const REGISTRY_MARKER = '# dsh-workbench 写的:这一行的包只在这个源上(dsh 卸载没有源参数,pnpm 得问得到)';
|
|
4
|
+
export function scopeOf(pkg) {
|
|
5
|
+
const slash = pkg.indexOf('/');
|
|
6
|
+
return pkg.startsWith('@') && slash > 1 ? pkg.slice(0, slash) : null;
|
|
7
|
+
}
|
|
8
|
+
export function normalizeRegistry(url) {
|
|
9
|
+
const trimmed = url.trim();
|
|
10
|
+
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
|
11
|
+
}
|
|
12
|
+
export function sameRegistry(a, b) {
|
|
13
|
+
if (a === null || a === undefined || b === null || b === undefined)
|
|
14
|
+
return false;
|
|
15
|
+
if (a.trim() === '' || b.trim() === '')
|
|
16
|
+
return false;
|
|
17
|
+
return normalizeRegistry(a) === normalizeRegistry(b);
|
|
18
|
+
}
|
|
19
|
+
function registryRootOf(name, tarball) {
|
|
20
|
+
const at = tarball.indexOf(`/${name}/-/`);
|
|
21
|
+
if (at > 0)
|
|
22
|
+
return normalizeRegistry(tarball.slice(0, at));
|
|
23
|
+
try {
|
|
24
|
+
return normalizeRegistry(new URL(tarball).origin);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const PACKAGE_KEY = /^ {2}'?(\S+)'?:\s*$/;
|
|
31
|
+
const PACKAGE_NAME = /^(?:@[^/\s]+\/)?[^/\s@]+$/;
|
|
32
|
+
const TARBALL = /tarball:\s*(\S+?)\}?\s*$/;
|
|
33
|
+
export function sourcesFromLockfile(text) {
|
|
34
|
+
const found = new Map();
|
|
35
|
+
let name = null;
|
|
36
|
+
for (const line of text.split('\n')) {
|
|
37
|
+
const key = PACKAGE_KEY.exec(line);
|
|
38
|
+
if (key !== null) {
|
|
39
|
+
const raw = key[1] ?? '';
|
|
40
|
+
const at = raw.lastIndexOf('@');
|
|
41
|
+
const candidate = at > 0 ? raw.slice(0, at) : '';
|
|
42
|
+
name = PACKAGE_NAME.test(candidate) ? candidate : null;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (name === null)
|
|
46
|
+
continue;
|
|
47
|
+
const tarball = TARBALL.exec(line);
|
|
48
|
+
if (tarball === null)
|
|
49
|
+
continue;
|
|
50
|
+
const scope = scopeOf(name);
|
|
51
|
+
const root = registryRootOf(name, tarball[1] ?? '');
|
|
52
|
+
name = null;
|
|
53
|
+
if (scope === null || root === null)
|
|
54
|
+
continue;
|
|
55
|
+
const seen = found.get(scope);
|
|
56
|
+
if (seen === undefined)
|
|
57
|
+
found.set(scope, root);
|
|
58
|
+
else if (!sameRegistry(seen, root))
|
|
59
|
+
found.set(scope, null);
|
|
60
|
+
}
|
|
61
|
+
return [...found.entries()]
|
|
62
|
+
.filter((entry) => entry[1] !== null)
|
|
63
|
+
.map(([scope, registry]) => ({ scope, registry }));
|
|
64
|
+
}
|
|
65
|
+
export function mergeRegistryConfig(text, entries) {
|
|
66
|
+
const lines = text.split('\n');
|
|
67
|
+
const present = (key) => lines.some((line) => line.trim().startsWith(`${key}=`));
|
|
68
|
+
const appended = [];
|
|
69
|
+
const added = [];
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
const key = `${entry.scope}:registry`;
|
|
72
|
+
if (entry.registry.trim() === '' || present(key))
|
|
73
|
+
continue;
|
|
74
|
+
lines.push(`${key}=${normalizeRegistry(entry.registry)}`);
|
|
75
|
+
appended.push(`${key}=${normalizeRegistry(entry.registry)}`);
|
|
76
|
+
added.push(key);
|
|
77
|
+
}
|
|
78
|
+
if (appended.length === 0)
|
|
79
|
+
return { text, added: [] };
|
|
80
|
+
const base = text === '' ? '' : text.endsWith('\n') ? text : `${text}\n`;
|
|
81
|
+
const marker = base.includes(REGISTRY_MARKER) ? '' : `${REGISTRY_MARKER}\n`;
|
|
82
|
+
return { text: `${base}${marker}${appended.join('\n')}\n`, added };
|
|
83
|
+
}
|
|
84
|
+
export function replaceScopeRegistry(text, scope, registry) {
|
|
85
|
+
const key = `${scope}:registry`;
|
|
86
|
+
const value = normalizeRegistry(registry);
|
|
87
|
+
let found = false;
|
|
88
|
+
const lines = text.split('\n').map((line) => {
|
|
89
|
+
if (!line.trim().startsWith(`${key}=`))
|
|
90
|
+
return line;
|
|
91
|
+
found = true;
|
|
92
|
+
return `${key}=${value}`;
|
|
93
|
+
});
|
|
94
|
+
if (found)
|
|
95
|
+
return lines.join('\n');
|
|
96
|
+
const base = text === '' ? '' : text.endsWith('\n') ? text : `${text}\n`;
|
|
97
|
+
return `${base}${key}=${value}\n`;
|
|
98
|
+
}
|
|
99
|
+
function readNpmrc(file) {
|
|
100
|
+
try {
|
|
101
|
+
return fs.readFileSync(file, 'utf8');
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function writeNpmrc(file, text) {
|
|
108
|
+
const temp = `${file}.dsh-workbench.tmp`;
|
|
109
|
+
fs.writeFileSync(temp, text, { mode: 0o600 });
|
|
110
|
+
fs.renameSync(temp, file);
|
|
111
|
+
}
|
|
112
|
+
export function registryFailure(change) {
|
|
113
|
+
if (change.application !== 'failed')
|
|
114
|
+
return false;
|
|
115
|
+
if (change.packageResult?.kind === 'not-found')
|
|
116
|
+
return true;
|
|
117
|
+
const output = change.packageResult?.output ?? change.errorDetail ?? '';
|
|
118
|
+
return /ERR_PNPM_[A-Z_]*FETCH/.test(output);
|
|
119
|
+
}
|
|
120
|
+
export function createRegistryNotebook(dir) {
|
|
121
|
+
return {
|
|
122
|
+
sources() {
|
|
123
|
+
if (dir === null)
|
|
124
|
+
return [];
|
|
125
|
+
try {
|
|
126
|
+
return sourcesFromLockfile(fs.readFileSync(path.join(dir, 'pnpm-lock.yaml'), 'utf8'));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
remember(entries) {
|
|
133
|
+
if (dir === null || entries.length === 0)
|
|
134
|
+
return [];
|
|
135
|
+
const file = path.join(dir, '.npmrc');
|
|
136
|
+
const merged = mergeRegistryConfig(readNpmrc(file), entries);
|
|
137
|
+
if (merged.added.length === 0)
|
|
138
|
+
return [];
|
|
139
|
+
writeNpmrc(file, merged.text);
|
|
140
|
+
return merged.added;
|
|
141
|
+
},
|
|
142
|
+
swapScope(scope, registry) {
|
|
143
|
+
if (dir === null || scope.trim() === '' || registry.trim() === '')
|
|
144
|
+
return () => { };
|
|
145
|
+
const file = path.join(dir, '.npmrc');
|
|
146
|
+
const key = `${scope.trim()}:registry`;
|
|
147
|
+
const value = normalizeRegistry(registry);
|
|
148
|
+
const before = readNpmrc(file);
|
|
149
|
+
const previous = before.split('\n').find((line) => line.trim().startsWith(`${key}=`));
|
|
150
|
+
const after = replaceScopeRegistry(before, scope.trim(), registry);
|
|
151
|
+
if (after === before)
|
|
152
|
+
return () => { };
|
|
153
|
+
writeNpmrc(file, after);
|
|
154
|
+
return () => {
|
|
155
|
+
const now = readNpmrc(file);
|
|
156
|
+
if (now === after) {
|
|
157
|
+
if (before === '')
|
|
158
|
+
fs.rmSync(file, { force: true });
|
|
159
|
+
else
|
|
160
|
+
writeNpmrc(file, before);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (!now.includes(`${key}=${value}`))
|
|
164
|
+
return;
|
|
165
|
+
const restored = previous === undefined
|
|
166
|
+
? now.split('\n').filter((line) => !line.trim().startsWith(`${key}=`)).join('\n')
|
|
167
|
+
: now.replace(`${key}=${value}`, previous);
|
|
168
|
+
writeNpmrc(file, restored);
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
package/dist/release.d.ts
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import type { DshShell } from '@dsh-workbench/shared';
|
|
2
1
|
import type { PluginRelease } from '@dsh-workbench/shared';
|
|
2
|
+
import type { Seam } from '@dsh-workbench/shared/host';
|
|
3
3
|
import { parseLedgerVersion } from '@dsh-workbench/shared/host';
|
|
4
4
|
export { parseLedgerVersion };
|
|
5
5
|
export { compareVersions, isNewerVersion } from '@dsh-workbench/shared/host';
|
|
6
6
|
export interface ReleasePort {
|
|
7
7
|
latestVersion(options: {
|
|
8
|
-
readonly
|
|
8
|
+
readonly seam: Seam;
|
|
9
9
|
readonly name: string;
|
|
10
10
|
readonly registry: string;
|
|
11
11
|
readonly force?: boolean;
|
|
12
12
|
}): Promise<string | undefined>;
|
|
13
13
|
missingReleaseParts(options: {
|
|
14
|
-
readonly
|
|
14
|
+
readonly seam: Seam;
|
|
15
15
|
readonly name: string;
|
|
16
16
|
readonly registry: string;
|
|
17
17
|
readonly version: string;
|
|
18
18
|
readonly force?: boolean;
|
|
19
19
|
}): Promise<readonly string[]>;
|
|
20
20
|
probeRelease(options: {
|
|
21
|
-
readonly
|
|
21
|
+
readonly seam: Seam;
|
|
22
22
|
readonly package: string;
|
|
23
23
|
readonly registry?: string | undefined;
|
|
24
24
|
readonly force?: boolean;
|
|
@@ -26,6 +26,5 @@ export interface ReleasePort {
|
|
|
26
26
|
reset(): void;
|
|
27
27
|
}
|
|
28
28
|
export declare function createReleasePort(options: {
|
|
29
|
-
readonly cacheDir: string;
|
|
30
29
|
readonly prefix: string;
|
|
31
30
|
}): ReleasePort;
|
package/dist/release.js
CHANGED
|
@@ -4,9 +4,9 @@ export { compareVersions, isNewerVersion } from '@dsh-workbench/shared/host';
|
|
|
4
4
|
export function createReleasePort(options) {
|
|
5
5
|
const probe = createReleaseProbe();
|
|
6
6
|
return {
|
|
7
|
-
latestVersion: (call) => probe.latestVersion(
|
|
8
|
-
missingReleaseParts: (call) => probe.missingReleaseParts(
|
|
9
|
-
probeRelease: (call) => probe.probeRelease({ ...call,
|
|
7
|
+
latestVersion: (call) => probe.latestVersion(call),
|
|
8
|
+
missingReleaseParts: (call) => probe.missingReleaseParts(call),
|
|
9
|
+
probeRelease: (call) => probe.probeRelease({ ...call, prefix: options.prefix }),
|
|
10
10
|
reset: () => {
|
|
11
11
|
probe.reset();
|
|
12
12
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-workbench/adapter-profile",
|
|
3
|
-
"version": "1.1.14
|
|
3
|
+
"version": "1.1.14",
|
|
4
4
|
"description": "Workbench profile adapter package.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -24,15 +24,12 @@
|
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/node": "^24.19.0",
|
|
26
26
|
"@types/proper-lockfile": "^4.1.4",
|
|
27
|
-
"@types/write-file-atomic": "^4.0.3",
|
|
28
27
|
"vitest": "^5.0.2",
|
|
29
28
|
"@vitest/coverage-v8": "^5.0.2"
|
|
30
29
|
},
|
|
31
30
|
"dependencies": {
|
|
32
|
-
"execa": "^10.2.0",
|
|
33
31
|
"proper-lockfile": "^4.1.2",
|
|
34
|
-
"
|
|
35
|
-
"@dsh-workbench/shared": "1.1.14-beta.2"
|
|
32
|
+
"@dsh-workbench/shared": "1.1.14"
|
|
36
33
|
},
|
|
37
34
|
"scripts": {
|
|
38
35
|
"build": "tsc -p tsconfig.json",
|