@dsh-workbench/adapter-profile 0.0.0-stage → 1.0.0-beta.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-workbench contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,29 @@
1
- # Temporary Holding Version
1
+ # @dsh-workbench/adapter-profile
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 工作台的 profile 适配器。
4
+
5
+ ## 职责
6
+
7
+ - profile 创建与配置同步
8
+ - registry notebook 与 release 信息
9
+ - 本地 bundle 清单解析
10
+ - 依赖 `adapter-runtime` 的隔离缓存与 vendor 路径
11
+
12
+ `self-update` 属于 probe(它需要运行时种子与工具链),不在本包。
13
+
14
+ ## 入口
15
+
16
+ ```ts
17
+ import { createRegistryNotebook, profileCreateAction, configSyncAction } from '@dsh-workbench/adapter-profile';
18
+ ```
19
+
20
+ ## 边界
21
+
22
+ 只处理 profile 配置、registry 与发布元数据;不复制 probe 的 runtime-seed / toolchain / platform。
23
+
24
+ ## 验证
25
+
26
+ ```bash
27
+ pnpm --filter @dsh-workbench/adapter-profile run build
28
+ pnpm --filter @dsh-workbench/adapter-profile run test
29
+ ```
@@ -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,49 @@
1
+ import type { PostResource, RouteHandler } from '@dsh-workbench/shared/host';
2
+ import type { ConfigSyncAsset, 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[], assets?: (block: PatchBlock) => readonly ConfigSyncAsset[]): 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
+ files: string[];
44
+ verified: boolean;
45
+ } | {
46
+ ok: false;
47
+ text: string;
48
+ }>;
49
+ export declare function configSyncAction(deps: ConfigSyncDeps): PostResource;
@@ -0,0 +1,380 @@
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
+ import { assetMapOf, assetsOf, cleanupFiles, copyPlans, planCopies } from './config-sync-assets.js';
5
+ const ID_LINE = /^- id:[ \t]*"?([^"\s#]+)/;
6
+ const INSERT_LINE = /^- insert:[ \t]*$/;
7
+ const NESTED_ID = /^[ \t]+-[ \t]*id:[ \t]*"?([^"\s#]+)/;
8
+ const NAME_LINE = /^[ \t]*-?[ \t]*name:[ \t]*['"]?([^'"\s#]+)/;
9
+ const COMMENT_LINE = /^[ \t]*#/;
10
+ const ISOLATION_IDS = new Set([
11
+ 'session-persistence-jsonl',
12
+ 'storage-json',
13
+ 'attachment-local',
14
+ 'sandbox-policy',
15
+ ]);
16
+ function trimmedTail(lines) {
17
+ let end = lines.length;
18
+ while (end > 0 && (lines[end - 1] ?? '').trim() === '')
19
+ end -= 1;
20
+ return lines.slice(0, end);
21
+ }
22
+ function matched(text, pattern) {
23
+ const found = pattern.exec(text);
24
+ return found === null ? undefined : found[1];
25
+ }
26
+ function blockOf(body, index) {
27
+ const lines = body.split('\n');
28
+ const first = lines[0] ?? '';
29
+ const id = matched(first, ID_LINE);
30
+ const nested = lines.flatMap((line) => {
31
+ const one = matched(line, NESTED_ID);
32
+ return one === undefined ? [] : [one];
33
+ });
34
+ const insert = id === undefined && INSERT_LINE.test(first);
35
+ const label = id ?? nested.join(', ');
36
+ const key = id !== undefined ? `id:${id}` : insert ? `insert:${nested.join(', ')}` : `block:${String(index)}`;
37
+ const names = lines.flatMap((line) => {
38
+ const one = matched(line, NAME_LINE);
39
+ return one === undefined ? [] : [one];
40
+ });
41
+ return { key, id: label === '' ? key : label, insert, body, names };
42
+ }
43
+ function spannedBlocks(text) {
44
+ const lines = text.split('\n');
45
+ const starts = [];
46
+ lines.forEach((line, index) => {
47
+ if (/^- /.test(line) || line === '-')
48
+ starts.push(index);
49
+ });
50
+ const blocks = [];
51
+ for (let i = 0; i < starts.length; i += 1) {
52
+ const from = starts[i] ?? 0;
53
+ const to = i + 1 < starts.length ? (starts[i + 1] ?? lines.length) : lines.length;
54
+ const body = trimmedTail(lines.slice(from, to)).join('\n');
55
+ if (body.trim() === '')
56
+ continue;
57
+ blocks.push({ ...blockOf(body, blocks.length), from, to });
58
+ }
59
+ return blocks;
60
+ }
61
+ export function patchBlocks(text) {
62
+ return spannedBlocks(text).map(({ from: _from, to: _to, ...block }) => block);
63
+ }
64
+ export function normalizeBlock(body) {
65
+ return body
66
+ .split('\n')
67
+ .map((line) => line.replace(/\s+$/, ''))
68
+ .filter((line) => line.trim() !== '' && !COMMENT_LINE.test(line))
69
+ .join('\n');
70
+ }
71
+ function rewriteForTarget(body, self) {
72
+ if (self === '' || self === 'workbench')
73
+ return body;
74
+ return body.replace(/(dshHomePath\(\s*['"])workbench(?=[/'"])/g, `$1${self}`);
75
+ }
76
+ export function syncRows(source, own, missing, assets = () => []) {
77
+ const mine = new Map(own.map((block) => [block.key, block]));
78
+ const rows = [];
79
+ for (const block of source) {
80
+ const here = mine.get(block.key);
81
+ const same = here !== undefined && normalizeBlock(here.body) === normalizeBlock(block.body);
82
+ const found = assets(block);
83
+ if (same && found.every((asset) => asset.state === 'same'))
84
+ continue;
85
+ rows.push({
86
+ key: block.key,
87
+ id: block.id,
88
+ insert: block.insert,
89
+ state: here === undefined ? 'new' : same ? 'same' : 'differs',
90
+ body: block.body,
91
+ ownBody: here === undefined ? null : here.body,
92
+ missing: missing(block),
93
+ assets: found,
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 { ...base, source: '', profiles: [], rows: [], emptyProviders: false, reason: '拿不到当前 profile 目录' };
215
+ }
216
+ const own = readPatchBlocks(join(deps.profileDir, 'cordis.patch.yml'));
217
+ const emptyProviders = own.ok && !own.blocks.some((block) => block.key.startsWith('id:llm-'));
218
+ const ownBlocks = own.ok ? own.blocks : [];
219
+ const profiles = others.map((name) => {
220
+ const read = readPatchBlocks(join(profilesDir, name, 'cordis.patch.yml'));
221
+ const assetsFor = (block) => assetsOf(block, join(profilesDir, name), deps.profileDir ?? '');
222
+ const blocks = read.ok
223
+ ? read.blocks
224
+ .filter((block) => !ISOLATION_IDS.has(block.id))
225
+ .map((block) => ({ ...block, body: rewriteForTarget(block.body, deps.self) }))
226
+ : [];
227
+ return {
228
+ name,
229
+ readable: read.ok,
230
+ candidates: read.ok ? syncRows(blocks, ownBlocks, () => [], assetsFor).length : 0,
231
+ };
232
+ });
233
+ if (others.length === 0) {
234
+ return {
235
+ ...base,
236
+ source: '',
237
+ profiles,
238
+ rows: [],
239
+ emptyProviders,
240
+ reason: '这台机器上只有当前这个 profile,没有可同步的来源',
241
+ };
242
+ }
243
+ const source = others.includes(asked ?? '') ? (asked ?? '') : others.includes('web') ? 'web' : (others[0] ?? '');
244
+ const sourceDir = join(profilesDir, source);
245
+ const read = readPatchBlocks(join(sourceDir, 'cordis.patch.yml'));
246
+ if (!read.ok) {
247
+ return { ...base, source, profiles, rows: [], emptyProviders, reason: read.reason };
248
+ }
249
+ if (!own.ok) {
250
+ return { ...base, source, profiles, rows: [], emptyProviders, reason: own.reason };
251
+ }
252
+ const declared = profileDependencies(sourceDir);
253
+ const modulesDir = join(deps.profileDir, 'node_modules');
254
+ const assetsFor = (block) => assetsOf(block, sourceDir, deps.profileDir ?? '');
255
+ const sourceBlocks = read.blocks
256
+ .filter((block) => !ISOLATION_IDS.has(block.id))
257
+ .map((block) => ({ ...block, body: rewriteForTarget(block.body, deps.self) }));
258
+ const rows = syncRows(sourceBlocks, own.blocks, (block) => block.names.filter((name) => declared.has(name) && !existsSync(join(modulesDir, name))), assetsFor);
259
+ return { ...base, source, profiles, rows, emptyProviders };
260
+ }
261
+ export async function configSyncApply(deps, payload) {
262
+ if (deps.profileDir === null)
263
+ return { ok: false, text: '拿不到当前 profile 目录,这次没写任何东西' };
264
+ const body = typeof payload === 'object' && payload !== null ? payload : {};
265
+ const source = textOf(body['source']);
266
+ const keys = new Set((Array.isArray(body['keys']) ? body['keys'] : []).map((item) => textOf(item)).filter((item) => item !== ''));
267
+ const overwrite = new Set((Array.isArray(body['overwrite']) ? body['overwrite'] : []).map((item) => textOf(item)));
268
+ const assetKeys = new Set((Array.isArray(body['assets']) ? body['assets'] : []).map((item) => textOf(item)).filter((item) => item !== ''));
269
+ const overwriteAssets = new Set((Array.isArray(body['overwriteAssets']) ? body['overwriteAssets'] : []).map((item) => textOf(item)));
270
+ if (source === '' || (keys.size === 0 && assetKeys.size === 0)) {
271
+ return { ok: false, text: '没说清从哪个 profile 搬哪几行' };
272
+ }
273
+ const profilesDir = join(deps.home, 'profiles');
274
+ if (!profileNames(profilesDir).includes(source))
275
+ return { ok: false, text: `没有这个 profile:${source}` };
276
+ if (source === deps.self)
277
+ return { ok: false, text: '来源不能是工作台自己' };
278
+ const sourceDir = join(profilesDir, source);
279
+ const read = readPatchBlocks(join(sourceDir, 'cordis.patch.yml'));
280
+ if (!read.ok)
281
+ return { ok: false, text: `读不到 ${source} 的配置:${read.reason}` };
282
+ const own = readPatchBlocks(join(deps.profileDir, 'cordis.patch.yml'));
283
+ if (!own.ok)
284
+ return { ok: false, text: `读不到工作台自己的配置:${own.reason}` };
285
+ const picked = read.blocks
286
+ .filter((block) => keys.has(block.key) && !ISOLATION_IDS.has(block.id))
287
+ .map((block) => ({ ...block, body: rewriteForTarget(block.body, deps.self) }));
288
+ if (keys.size > 0 && picked.length === 0) {
289
+ return { ok: false, text: '选中的行在来源里已经找不到了,重新读一遍再试' };
290
+ }
291
+ const available = assetMapOf(read.blocks, sourceDir, deps.profileDir);
292
+ const pickedAssets = [];
293
+ for (const key of assetKeys) {
294
+ const asset = available.get(key);
295
+ if (asset === undefined)
296
+ return { ok: false, text: '选中的文件在来源里已经找不到了,重新读一遍再试' };
297
+ pickedAssets.push(asset);
298
+ }
299
+ const merged = mergeBlocks(own.text, picked, overwrite);
300
+ if (!merged.ok)
301
+ return { ok: false, text: merged.reason };
302
+ const planned = planCopies(pickedAssets, sourceDir, deps.profileDir, overwriteAssets);
303
+ if (!planned.ok)
304
+ return { ok: false, text: planned.text };
305
+ const patchPath = join(deps.profileDir, 'cordis.patch.yml');
306
+ let copied = null;
307
+ if (planned.plans.length > 0) {
308
+ const outcome = copyPlans(planned.plans);
309
+ if (!outcome.ok) {
310
+ cleanupFiles(outcome.created, outcome.backups);
311
+ return { ok: false, text: outcome.text };
312
+ }
313
+ copied = { files: outcome.files, created: outcome.created, backups: outcome.backups };
314
+ }
315
+ const patchChanged = merged.text !== own.text;
316
+ try {
317
+ if (patchChanged)
318
+ writePatchFile(patchPath, merged.text);
319
+ }
320
+ catch (error) {
321
+ if (copied !== null)
322
+ cleanupFiles(copied.created, copied.backups);
323
+ return { ok: false, text: `写不进去:${error instanceof Error ? error.message : String(error)}` };
324
+ }
325
+ if (deps.reconcile === undefined) {
326
+ return {
327
+ ok: true,
328
+ written: merged.written,
329
+ skipped: merged.skipped,
330
+ files: copied?.files ?? [],
331
+ verified: false,
332
+ };
333
+ }
334
+ try {
335
+ const verified = await deps.reconcile();
336
+ if (verified === false) {
337
+ return {
338
+ ok: true,
339
+ written: merged.written,
340
+ skipped: merged.skipped,
341
+ files: copied?.files ?? [],
342
+ verified: false,
343
+ };
344
+ }
345
+ }
346
+ catch (error) {
347
+ if (patchChanged) {
348
+ try {
349
+ writePatchFile(patchPath, own.text);
350
+ }
351
+ catch {
352
+ if (copied !== null)
353
+ cleanupFiles(copied.created, copied.backups);
354
+ return { ok: false, text: `写进去了,但启动检查没通过:${error instanceof Error ? error.message : String(error)}。原来的设置没能恢复,手动检查一下` };
355
+ }
356
+ }
357
+ if (copied !== null)
358
+ cleanupFiles(copied.created, copied.backups);
359
+ try {
360
+ await deps.reconcile();
361
+ }
362
+ catch {
363
+ return { ok: false, text: `写进去了,但启动检查没通过:${error instanceof Error ? error.message : String(error)}。已恢复原来的设置` };
364
+ }
365
+ return { ok: false, text: `写进去了,但启动检查没通过:${error instanceof Error ? error.message : String(error)}。已恢复原来的设置` };
366
+ }
367
+ return {
368
+ ok: true,
369
+ written: merged.written,
370
+ skipped: merged.skipped,
371
+ files: copied?.files ?? [],
372
+ verified: true,
373
+ };
374
+ }
375
+ export function configSyncAction(deps) {
376
+ return {
377
+ resource: CONFIG_SYNC_RESOURCE,
378
+ handle: (payload) => configSyncApply(deps, payload),
379
+ };
380
+ }
@@ -0,0 +1,5 @@
1
+ export * from './config-sync.js';
2
+ export * from './profile-registries.js';
3
+ export * from './profile-create.js';
4
+ export * from './config-sync-assets.js';
5
+ export * from './release.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './config-sync.js';
2
+ export * from './profile-registries.js';
3
+ export * from './profile-create.js';
4
+ export * from './config-sync-assets.js';
5
+ export * from './release.js';
@@ -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,165 @@
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
+ plugins: copy['plugins'] === true,
74
+ projects: copy['projects'] === true,
75
+ sessions: copy['sessions'] === true,
76
+ };
77
+ if (!PROFILE_NAME.test(profile))
78
+ return { ok: false, text: '新工作台的名字不合法:只用字母、数字、点、下划线或短横线' };
79
+ if (source === '')
80
+ return { ok: false, text: '先选一个来源 profile' };
81
+ if (source === profile)
82
+ return { ok: false, text: '来源不能是新工作台自己' };
83
+ const profilesDir = join(deps.home, 'profiles');
84
+ if (!profileNames(profilesDir).includes(source))
85
+ return { ok: false, text: `没有这个 profile:${source}` };
86
+ if (existsSync(join(profilesDir, profile)))
87
+ return { ok: false, text: `已经有一个叫 ${profile} 的 profile` };
88
+ const targetHome = join(deps.home, profile);
89
+ if (existsSync(targetHome))
90
+ return { ok: false, text: `数据目录已经存在:${targetHome}` };
91
+ const sourceCandidate = join(deps.home, source);
92
+ const sourcePatch = (() => {
93
+ try {
94
+ return readFileSync(join(profilesDir, source, 'cordis.patch.yml'), 'utf8');
95
+ }
96
+ catch {
97
+ return '';
98
+ }
99
+ })();
100
+ const sourceHasIsolation = /id:\s*session-persistence-jsonl/.test(sourcePatch);
101
+ const sourceHome = !sourceHasIsolation
102
+ ? deps.home
103
+ : sourcePatch.includes('DSH_WORKBENCH_HOME') && source !== 'workbench'
104
+ ? sourceCandidate
105
+ : join(deps.home, 'workbench');
106
+ const env = {
107
+ DSH_HOME: deps.home,
108
+ DSH_WORKBENCH_HOME: targetHome,
109
+ WORKBENCH_PROFILE: profile,
110
+ WORKBENCH_SOURCE_PROFILE: source,
111
+ WB_CLONE: '1',
112
+ WB_SOURCE_HOME: sourceHome,
113
+ WB_COPY_SETTINGS: flags.settings ? '1' : '0',
114
+ WB_COPY_PLUGINS: flags.plugins ? '1' : '0',
115
+ WB_COPY_PROJECTS: flags.projects ? '1' : '0',
116
+ WB_COPY_SESSIONS: flags.sessions ? '1' : '0',
117
+ };
118
+ const setup = deps.runSetup ?? ((next) => defaultRunSetup(deps.packageRoot, next));
119
+ const cleanup = () => {
120
+ try {
121
+ if (inside(profilesDir, join(profilesDir, profile)))
122
+ rmSync(join(profilesDir, profile), { recursive: true, force: true });
123
+ if (inside(deps.home, targetHome))
124
+ rmSync(targetHome, { recursive: true, force: true });
125
+ }
126
+ catch {
127
+ return;
128
+ }
129
+ };
130
+ const result = await setup(env);
131
+ if (!result.ok) {
132
+ cleanup();
133
+ return { ok: false, text: `创建失败:${result.log.trim().slice(-1200) || 'setup 没给出原因'}` };
134
+ }
135
+ try {
136
+ const manifest = JSON.parse(readFileSync(join(profilesDir, profile, 'package.json'), 'utf8'));
137
+ const bundles = manifest.dsh?.profile?.bundles;
138
+ if (!Array.isArray(bundles) || !bundles.includes(BUNDLE)) {
139
+ cleanup();
140
+ return { ok: false, text: 'profile 建好了,但工作台 bundle 没被选中;先跑一次 setup 修好它' };
141
+ }
142
+ }
143
+ catch (error) {
144
+ cleanup();
145
+ return { ok: false, text: `profile 建好了,但读不了清单:${error instanceof Error ? error.message : String(error)}` };
146
+ }
147
+ const copied = Object.entries(flags)
148
+ .filter(([, on]) => on)
149
+ .map(([name]) => name);
150
+ const bin = join(deps.packageRoot, 'bin', 'dsh-workbench');
151
+ return {
152
+ ok: true,
153
+ profile,
154
+ source,
155
+ home: targetHome,
156
+ command: `WORKBENCH_PROFILE=${quoteShellArg(profile)} ${quoteShellArg(bin)}`,
157
+ copied,
158
+ };
159
+ }
160
+ export function profileCreateAction(deps) {
161
+ return {
162
+ resource: PROFILE_CREATE_RESOURCE,
163
+ handle: (payload) => profileCreateApply(deps, payload),
164
+ };
165
+ }
@@ -0,0 +1,30 @@
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
+ }
19
+ 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";
20
+ export declare function scopeOf(pkg: string): string | null;
21
+ export declare function normalizeRegistry(url: string): string;
22
+ export declare function sameRegistry(a: string | null | undefined, b: string | null | undefined): boolean;
23
+ export declare function sourcesFromLockfile(text: string): readonly RegistryEntry[];
24
+ export interface RegistryConfigMerge {
25
+ readonly text: string;
26
+ readonly added: readonly string[];
27
+ }
28
+ export declare function mergeRegistryConfig(text: string, entries: readonly RegistryEntry[]): RegistryConfigMerge;
29
+ export declare function registryFailure(change: BundleChange): boolean;
30
+ export declare function createRegistryNotebook(dir: string | null): RegistryNotebook;
@@ -0,0 +1,124 @@
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 registryFailure(change) {
85
+ if (change.application !== 'failed')
86
+ return false;
87
+ if (change.packageResult?.kind === 'not-found')
88
+ return true;
89
+ const output = change.packageResult?.output ?? change.errorDetail ?? '';
90
+ return /ERR_PNPM_[A-Z_]*FETCH/.test(output);
91
+ }
92
+ export function createRegistryNotebook(dir) {
93
+ return {
94
+ sources() {
95
+ if (dir === null)
96
+ return [];
97
+ try {
98
+ return sourcesFromLockfile(fs.readFileSync(path.join(dir, 'pnpm-lock.yaml'), 'utf8'));
99
+ }
100
+ catch {
101
+ return [];
102
+ }
103
+ },
104
+ remember(entries) {
105
+ if (dir === null || entries.length === 0)
106
+ return [];
107
+ const file = path.join(dir, '.npmrc');
108
+ let before;
109
+ try {
110
+ before = fs.readFileSync(file, 'utf8');
111
+ }
112
+ catch {
113
+ before = '';
114
+ }
115
+ const merged = mergeRegistryConfig(before, entries);
116
+ if (merged.added.length === 0)
117
+ return [];
118
+ const temp = `${file}.dsh-workbench.tmp`;
119
+ fs.writeFileSync(temp, merged.text, { mode: 0o600 });
120
+ fs.renameSync(temp, file);
121
+ return merged.added;
122
+ },
123
+ };
124
+ }
@@ -0,0 +1,24 @@
1
+ import type { DshShell } from '@dsh-workbench/shared';
2
+ import type { PluginRelease } from '@dsh-workbench/shared';
3
+ export declare function parseLedgerVersion(stdout: string, name: string): string | undefined;
4
+ export declare function compareVersions(a: string, b: string): number | undefined;
5
+ export { isNewerVersion } from '@dsh-workbench/shared/host';
6
+ export declare function latestVersion(options: {
7
+ shell: DshShell;
8
+ name: string;
9
+ registry: string;
10
+ force?: boolean;
11
+ }): Promise<string | undefined>;
12
+ export declare function missingReleaseParts(options: {
13
+ shell: DshShell;
14
+ registry: string;
15
+ version: string;
16
+ force?: boolean;
17
+ }): Promise<readonly string[]>;
18
+ export declare function probeRelease(options: {
19
+ shell: DshShell;
20
+ package: string;
21
+ registry?: string | undefined;
22
+ force?: boolean;
23
+ }): Promise<PluginRelease | undefined>;
24
+ export declare function resetReleaseCache(): void;
@@ -0,0 +1,91 @@
1
+ import { RELEASE_SIBLINGS, VERSION_CHECK_TIMEOUT_MS, VERSION_CHECK_TTL_MS, } from '@dsh-workbench/shared/host';
2
+ import { parseJsonLoose, quoteShellArg } from '@dsh-workbench/shared/host';
3
+ import { npmCacheDir, vendorPrefix } from '@dsh-workbench/adapter-runtime';
4
+ import { runShell } from '@dsh-workbench/shared/host';
5
+ export function parseLedgerVersion(stdout, name) {
6
+ const parsed = parseJsonLoose(stdout);
7
+ if (typeof parsed !== "object" || parsed === null)
8
+ return undefined;
9
+ const entry = parsed
10
+ .dependencies?.[name];
11
+ if (typeof entry !== "object" || entry === null)
12
+ return undefined;
13
+ const version = entry.version;
14
+ return typeof version === "string" && /^\d+\.\d+\.\d+/.test(version.trim())
15
+ ? version.trim()
16
+ : undefined;
17
+ }
18
+ export function compareVersions(a, b) {
19
+ const na = a.split(".").map((part) => Number.parseInt(part, 10));
20
+ const nb = b.split(".").map((part) => Number.parseInt(part, 10));
21
+ if (na.some(Number.isNaN) || nb.some(Number.isNaN))
22
+ return undefined;
23
+ for (let index = 0; index < Math.max(na.length, nb.length); index += 1) {
24
+ const left = na[index] ?? 0;
25
+ const right = nb[index] ?? 0;
26
+ if (left !== right)
27
+ return left > right ? 1 : -1;
28
+ }
29
+ return 0;
30
+ }
31
+ export { isNewerVersion } from '@dsh-workbench/shared/host';
32
+ function parseVersion(stdout) {
33
+ const value = stdout.trim();
34
+ return /^\d+\.\d+\.\d+[^\s]*$/.test(value) ? value : undefined;
35
+ }
36
+ const latestCache = new Map();
37
+ export async function latestVersion(options) {
38
+ const { shell, name, registry, force = false } = options;
39
+ const key = `${registry}|${name}`;
40
+ const hit = latestCache.get(key);
41
+ if (!force && hit !== undefined && Date.now() - hit.at < VERSION_CHECK_TTL_MS)
42
+ return hit.value;
43
+ const result = await runShell(shell, `npm view ${quoteShellArg(name)} version --registry ${quoteShellArg(registry)} --cache ${quoteShellArg(npmCacheDir())}`, { timeoutMs: VERSION_CHECK_TIMEOUT_MS }).catch(() => undefined);
44
+ const value = result?.exitCode === 0 ? parseVersion(result.stdout) : undefined;
45
+ latestCache.set(key, { at: Date.now(), value });
46
+ return value;
47
+ }
48
+ const existsCache = new Map();
49
+ async function versionExists(options) {
50
+ const { shell, name, version, registry, force = false } = options;
51
+ const key = `${registry}|${name}@${version}`;
52
+ const hit = existsCache.get(key);
53
+ if (!force && hit !== undefined && (hit.value || Date.now() - hit.at < VERSION_CHECK_TTL_MS))
54
+ return hit.value;
55
+ const result = await runShell(shell, `npm view ${quoteShellArg(`${name}@${version}`)} version --registry ${quoteShellArg(registry)} --cache ${quoteShellArg(npmCacheDir())}`, { timeoutMs: VERSION_CHECK_TIMEOUT_MS }).catch(() => undefined);
56
+ const value = result?.exitCode === 0 && parseVersion(result.stdout) !== undefined;
57
+ existsCache.set(key, { at: Date.now(), value });
58
+ return value;
59
+ }
60
+ export async function missingReleaseParts(options) {
61
+ const { shell, registry, version, force = false } = options;
62
+ const missing = [];
63
+ for (const name of RELEASE_SIBLINGS) {
64
+ if (!(await versionExists({ shell, name, version, registry, force })))
65
+ missing.push(name);
66
+ }
67
+ return missing;
68
+ }
69
+ export async function probeRelease(options) {
70
+ const { shell, package: pkg, registry, force = false } = options;
71
+ if (pkg === "")
72
+ return undefined;
73
+ const ledger = await runShell(shell, `npm ls --prefix ${quoteShellArg(vendorPrefix())} ${quoteShellArg(pkg)} --json --depth=0 --cache ${quoteShellArg(npmCacheDir())}`, { timeoutMs: VERSION_CHECK_TIMEOUT_MS }).catch(() => undefined);
74
+ const installed = ledger === undefined ? undefined : parseLedgerVersion(ledger.stdout, pkg);
75
+ if (installed === undefined)
76
+ return undefined;
77
+ const source = registry === undefined || registry.trim() === ""
78
+ ? undefined
79
+ : registry.trim();
80
+ const latest = source === undefined
81
+ ? undefined
82
+ : await latestVersion({ shell, name: pkg, registry: source, force });
83
+ return {
84
+ installed,
85
+ ...(latest === undefined ? {} : { latest }),
86
+ };
87
+ }
88
+ export function resetReleaseCache() {
89
+ latestCache.clear();
90
+ existsCache.clear();
91
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@dsh-workbench/adapter-profile",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0-beta.0",
4
+ "description": "Workbench profile adapter package.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/novaeth/dsh-workbench.git",
22
+ "directory": "packages/adapter-profile"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^24.19.0",
26
+ "@types/proper-lockfile": "^4.1.4",
27
+ "@types/write-file-atomic": "^4.0.3",
28
+ "vitest": "^5.0.2",
29
+ "@vitest/coverage-v8": "^5.0.2"
30
+ },
31
+ "dependencies": {
32
+ "execa": "^10.2.0",
33
+ "proper-lockfile": "^4.1.2",
34
+ "write-file-atomic": "^8.0.0",
35
+ "@dsh-workbench/adapter-runtime": "1.0.0-beta.0",
36
+ "@dsh-workbench/shared": "1.0.0-beta.0"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.json",
40
+ "test": "vitest run --testTimeout=20000",
41
+ "arch": "arch-guard",
42
+ "check:runtimes": "node ../../scripts/check-adapter-package.mjs --runtime",
43
+ "coverage": "vitest run --coverage --coverage.reporter=text-summary --testTimeout=20000"
44
+ }
6
45
  }