@dsh-workbench/shared 1.1.4 → 1.1.6

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.
Files changed (81) hide show
  1. package/dist/api/host-write.d.ts +4 -1
  2. package/dist/api/workbench-client.d.ts +2 -0
  3. package/dist/components/ApprovalPlan.css +7 -8
  4. package/dist/config/constants.d.ts +4 -0
  5. package/dist/config/constants.js +4 -0
  6. package/dist/contract/approval.d.ts +4 -0
  7. package/dist/contract/approval.js +4 -0
  8. package/dist/contract/command.d.ts +33 -0
  9. package/dist/contract/command.js +1 -1
  10. package/dist/contract/needs.d.ts +17 -0
  11. package/dist/contract/needs.js +41 -1
  12. package/dist/contract/protocol/bundle-install.d.ts +2 -0
  13. package/dist/contract/protocol/market-index.d.ts +3 -1
  14. package/dist/contract/protocol/market-index.js +2 -0
  15. package/dist/contract/unit.d.ts +10 -0
  16. package/dist/host/adapter-types.d.ts +2 -0
  17. package/dist/host/data-error.d.ts +2 -0
  18. package/dist/host/data-error.js +3 -0
  19. package/dist/host/http.js +1 -0
  20. package/dist/host/install-failure.d.ts +49 -0
  21. package/dist/host/install-failure.js +167 -0
  22. package/dist/host/installed-sources.d.ts +10 -0
  23. package/dist/host/installed-sources.js +64 -0
  24. package/dist/host/lib-install.d.ts +12 -1
  25. package/dist/host/lib-install.js +33 -1
  26. package/dist/host/market-index-claim.d.ts +2 -0
  27. package/dist/host/market-index-claim.js +7 -0
  28. package/dist/host/market-index-doc.d.ts +56 -0
  29. package/dist/host/market-index-doc.js +1 -0
  30. package/dist/host/market-index-parse.d.ts +23 -0
  31. package/dist/host/market-index-parse.js +485 -0
  32. package/dist/host/market-index.d.ts +13 -21
  33. package/dist/host/market-index.js +120 -368
  34. package/dist/host/paths.d.ts +7 -0
  35. package/dist/host/paths.js +17 -0
  36. package/dist/host/runtime-links.d.ts +7 -0
  37. package/dist/host/runtime-links.js +25 -0
  38. package/dist/host/tool-approval.d.ts +4 -2
  39. package/dist/host/tool-approval.js +4 -2
  40. package/dist/host.d.ts +3 -0
  41. package/dist/host.js +3 -0
  42. package/dist/i18n/en.js +50 -4
  43. package/dist/i18n/index.d.ts +51 -5
  44. package/dist/i18n/zh.d.ts +50 -4
  45. package/dist/i18n/zh.js +50 -4
  46. package/dist/index.d.ts +1 -0
  47. package/dist/index.js +1 -0
  48. package/package.json +1 -1
  49. package/src/api/host-write.ts +5 -1
  50. package/src/api/workbench-client.ts +2 -0
  51. package/src/components/ApprovalPlan.css +7 -8
  52. package/src/config/constants.ts +6 -0
  53. package/src/contract/__tests__/needs.test.ts +53 -0
  54. package/src/contract/approval.ts +4 -0
  55. package/src/contract/command.ts +22 -0
  56. package/src/contract/needs.ts +56 -1
  57. package/src/contract/protocol/bundle-install.ts +3 -0
  58. package/src/contract/protocol/market-index.ts +4 -0
  59. package/src/contract/unit.ts +11 -0
  60. package/src/host/__tests__/market-index.test.ts +114 -0
  61. package/src/host/adapter-types.ts +2 -0
  62. package/src/host/data-error.ts +3 -1
  63. package/src/host/http.ts +1 -0
  64. package/src/host/install-failure.test.ts +121 -0
  65. package/src/host/install-failure.ts +198 -0
  66. package/src/host/installed-sources.test.ts +80 -0
  67. package/src/host/installed-sources.ts +73 -0
  68. package/src/host/lib-install.ts +38 -2
  69. package/src/host/market-index-claim.ts +9 -0
  70. package/src/host/market-index-doc.ts +62 -0
  71. package/src/host/market-index-parse.ts +482 -0
  72. package/src/host/market-index.ts +162 -357
  73. package/src/host/paths.test.ts +26 -0
  74. package/src/host/paths.ts +18 -0
  75. package/src/host/runtime-links.test.ts +31 -0
  76. package/src/host/runtime-links.ts +23 -0
  77. package/src/host/tool-approval.ts +11 -4
  78. package/src/host.ts +3 -0
  79. package/src/i18n/en.ts +51 -4
  80. package/src/i18n/zh.ts +51 -4
  81. package/src/index.ts +1 -0
@@ -1,28 +1,40 @@
1
- import { createHash } from 'node:crypto';
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join, relative, sep } from 'node:path';
2
3
  import type {
3
- DiscoverFilter,
4
- IndexSourceSpec,
5
4
  MarketIndex,
6
5
  MarketIndexEntry,
7
- MarketIndexParse,
8
6
  MarketIndexProblem,
9
- MarketIndexSource,
10
7
  MarketIndexVersion,
11
- MarketLocalizedText,
12
- MarketSourceConfig,
13
8
  } from '../contract/protocol/market-index.js';
14
- import { MARKET_INDEX_FORMAT } from '../contract/protocol/market-index.js';
15
- import { marketText } from '../lib/market-index.js';
16
- import { compareVersions, isValidVersion } from './version.js';
9
+ import { MARKET_INDEX_DIR, MARKET_INDEX_FORMAT } from '../contract/protocol/market-index.js';
17
10
 
18
- export { marketBranchOk, marketInstallTarget, marketText } from '../lib/market-index.js';
19
-
20
- export const MARKET_INDEX_SEED = '{\n "format": 1,\n "plugins": []\n}\n';
11
+ import type {
12
+ MarketDocFields,
13
+ MarketEntryDoc,
14
+ MarketEntryFile,
15
+ MarketIndexAssemble,
16
+ MarketMetaDoc,
17
+ MarketVersionDoc,
18
+ } from './market-index-doc.js';
19
+ import {
20
+ marketEntryPath,
21
+ parseMarketEntryDoc,
22
+ parseMarketIndexJson,
23
+ sortMarketVersions,
24
+ } from './market-index-parse.js';
25
+
26
+ export const MARKET_ENTRIES_README_LINES: readonly string[] = [
27
+ '# 索引目录',
28
+ '',
29
+ '一个包一个目录,一个版本一个文件:`market/<包名>/<版本>.json`;产物在 `artifacts/<包名>/<版本>/`。',
30
+ '',
31
+ '`meta-<epoch>-<随机后缀>.json` 是只改标题 / 署名 / 说明时追加的元数据快照,读取时按 epoch 取最新的一份。',
32
+ ];
21
33
 
22
34
  export const MARKET_INDEX_README_LINES: readonly string[] = [
23
35
  '# 模块市场索引',
24
36
  '',
25
- '这个仓库给 DeepSeek Harness 工作台当模块市场索引:`market.json`(format 1)列出可安装的模块与版本。',
37
+ '这个仓库给 DeepSeek Harness 工作台当模块市场索引:条目在 `market/<包名>/<版本>.json`,产物在 `artifacts/<包名>/<版本>/`。',
26
38
  '',
27
39
  '## 发布模块',
28
40
  '',
@@ -32,9 +44,10 @@ export const MARKET_INDEX_README_LINES: readonly string[] = [
32
44
  'dsh-wb-module release --index <本仓库地址> --index-policy artifacts-in-repo',
33
45
  '```',
34
46
  '',
35
- '产物会提交到 `packages/<包名>/`(带 scope 时是 `packages/@scope/名字/`),并更新 `market.json`。',
47
+ '产物会提交到 `artifacts/<包名>/<版本>/`,条目提交到 `market/<包名>/<版本>.json`。',
48
+ '发布只新增文件、不改已有文件,两台机器同时发也不会冲突;老条目的 `packages/<包名>/` 路径继续可用。',
36
49
  '',
37
- '一个包名归第一次发布的人(`market.json` 里的 `owner`)。换一个署名发同名会被拒;要换人,先把这一条的 `owner` 改成新的人。',
50
+ '一个包名归第一次发布的人(条目文件里的 `owner`)。换一个署名发同名会被拒;要换人,先把这一条的 `owner` 改成新的人。',
38
51
  '同一个市场里,一个模块 id(`metadata.unit`)只能被一个条目使用;另一个包名想用同一个 id / 产物目录也会被拒。',
39
52
  '',
40
53
  '## 使用这个索引',
@@ -42,373 +55,165 @@ export const MARKET_INDEX_README_LINES: readonly string[] = [
42
55
  '在工作台的「模块市场 → 发现」里添加本仓库地址,即可浏览与安装。',
43
56
  '',
44
57
  ];
58
+ export { marketBranchOk, marketInstallTarget, marketText } from '../lib/market-index.js';
59
+ export type {
60
+ MarketEntryDoc,
61
+ MarketEntryDocParse,
62
+ MarketEntryFile,
63
+ MarketIndexAssemble,
64
+ MarketMetaDoc,
65
+ MarketVersionDoc,
66
+ } from './market-index-doc.js';
67
+ export {
68
+ compareSemver,
69
+ filterMarketIndex,
70
+ gitUrlOk,
71
+ httpUrlProblem,
72
+ marketEntryLabels,
73
+ marketEntryPath,
74
+ marketMetaPath,
75
+ marketSourceKey,
76
+ parseMarketEntryDoc,
77
+ parseMarketIndex,
78
+ parseMarketIndexJson,
79
+ parseSourceConfig,
80
+ sortMarketVersions,
81
+ upsertMarketEntry,
82
+ } from './market-index-parse.js';
83
+ export type { SourceConfigParse } from './market-index-parse.js';
45
84
 
46
- const NPM_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
47
- const GIT_COMMIT = /^[0-9a-f]{40}$/;
48
- const MAX_ICON_CHARS = 32 * 1024;
49
-
50
- function asRecord(value: unknown): Readonly<Record<string, unknown>> | undefined {
51
- return typeof value === 'object' && value !== null && !Array.isArray(value)
52
- ? (value as Readonly<Record<string, unknown>>)
53
- : undefined;
54
- }
55
-
56
- function asText(value: unknown): string {
57
- return typeof value === 'string' ? value.trim() : '';
58
- }
59
-
60
- function asTextArray(value: unknown): string[] {
61
- return Array.isArray(value)
62
- ? value.flatMap((item) => (typeof item === 'string' && item.trim() !== '' ? [item.trim()] : []))
63
- : [];
64
- }
65
-
66
- function asLocalized(value: unknown): MarketLocalizedText | undefined {
67
- const record = asRecord(value);
68
- if (record === undefined) return undefined;
69
- const zh = asText(record['zh']);
70
- const en = asText(record['en']);
71
- return zh === '' && en === '' ? undefined : { ...(zh === '' ? {} : { zh }), ...(en === '' ? {} : { en }) };
72
- }
73
85
 
74
86
  function problem(kind: MarketIndexProblem['kind'], detail: string, at?: string): MarketIndexProblem {
75
87
  return { kind, detail, ...(at === undefined ? {} : { at }) };
76
88
  }
77
89
 
78
- export function compareSemver(left: string, right: string): number | undefined {
79
- return compareVersions(left, right);
80
- }
81
-
82
- function sourceOf(raw: unknown, at: string): MarketIndexSource | MarketIndexProblem {
83
- const record = asRecord(raw);
84
- if (record === undefined) return problem('source', 'source 必须是一个对象', at);
85
- if (record['type'] === 'npm') {
86
- const registry = record['registry'];
87
- if (registry !== undefined && asText(registry) === '') {
88
- return problem('source', 'npm source 的 registry 必须是非空字符串', at);
89
- }
90
- return { type: 'npm', ...(registry === undefined ? {} : { registry: asText(registry) }) };
91
- }
92
- if (record['type'] === 'git') {
93
- const url = asText(record['url']);
94
- const commit = asText(record['commit']);
95
- if (url === '') return problem('source', 'git source 缺 url', at);
96
- if (!GIT_COMMIT.test(commit)) {
97
- return problem('git-commit', `git source 的 commit 必须是 40 位小写 SHA(现在是 ${JSON.stringify(commit)})`, at);
98
- }
99
- const path = record['path'];
100
- return {
101
- type: 'git',
102
- url,
103
- commit,
104
- ...(path === undefined || asText(path) === '' ? {} : { path: asText(path) }),
105
- };
106
- }
107
- return problem('source', 'source.type 只能是 npm 或 git', at);
108
- }
109
-
110
- function versionOf(raw: unknown, at: string): MarketIndexVersion | MarketIndexProblem {
111
- const record = asRecord(raw);
112
- if (record === undefined) return problem('version', '版本必须是一个对象', at);
113
- const version = asText(record['version']);
114
- if (!isValidVersion(version)) return problem('version', `版本号不是合法 SemVer:${JSON.stringify(version)}`, at);
115
- const source = sourceOf(record['source'], `${at}.source`);
116
- if ('kind' in source) return source;
117
- const timestamp = record['publishTimestamp'];
118
- if (timestamp !== undefined && (!Number.isInteger(timestamp) || (timestamp as number) < 0)) {
119
- return problem('timestamp', 'publishTimestamp 必须是 Unix 秒整数', at);
120
- }
121
- const engines = asRecord(record['engines']);
122
- const dsh = engines === undefined ? '' : asText(engines['dsh']);
90
+ function docFieldsFromEntry(entry: MarketIndexEntry): MarketDocFields {
123
91
  return {
124
- version,
125
- source,
126
- ...(timestamp === undefined ? {} : { publishTimestamp: timestamp as number }),
127
- ...(dsh === '' ? {} : { engines: { dsh } }),
92
+ name: entry.name,
93
+ ...(entry.title === undefined ? {} : { title: entry.title }),
94
+ ...(entry.description === undefined ? {} : { description: entry.description }),
95
+ ...(entry.author === undefined ? {} : { author: entry.author }),
96
+ ...(entry.owner === undefined ? {} : { owner: entry.owner }),
97
+ ...(entry.homepage === undefined ? {} : { homepage: entry.homepage }),
98
+ ...(entry.icon === undefined ? {} : { icon: entry.icon }),
99
+ ...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
128
100
  };
129
101
  }
130
102
 
131
- function versionsOf(raw: unknown, at: string): readonly MarketIndexVersion[] | MarketIndexProblem {
132
- if (!Array.isArray(raw) || raw.length === 0) return problem('versions', 'versions 必须是非空数组', at);
133
- const versions: MarketIndexVersion[] = [];
134
- const seen = new Set<string>();
135
- for (const [index, item] of raw.entries()) {
136
- const parsed = versionOf(item, `${at}[${String(index)}]`);
137
- if ('kind' in parsed) return parsed;
138
- if (seen.has(parsed.version)) return problem('versions', `版本重复:${parsed.version}`, at);
139
- seen.add(parsed.version);
140
- versions.push(parsed);
141
- }
142
- for (let index = 1; index < versions.length; index += 1) {
143
- const order = compareSemver(versions[index - 1]?.version ?? '', versions[index]?.version ?? '');
144
- if (order === undefined || order < 0) {
145
- return problem('version-order', 'versions 必须按 SemVer 降序', at);
146
- }
147
- }
148
- return versions;
149
- }
150
-
151
- function iconProblem(icon: string): MarketIndexProblem | undefined {
152
- if (icon.length > MAX_ICON_CHARS) return problem('icon', 'icon 超过 32 KiB');
153
- if (icon.startsWith('data:image/')) return icon.includes(';base64,') ? undefined : problem('icon', 'data URL 必须是 base64');
154
- if (/^https?:\/\//i.test(icon)) return undefined;
155
- return problem('icon', 'icon 只能是 base64 data URL 或 http(s) URL');
156
- }
157
-
158
- const MAX_OWNER_CHARS = 200;
159
- const UNIT_ID = /^[a-z][a-z0-9-]*$/;
160
-
161
- function ownerProblem(owner: string): MarketIndexProblem | undefined {
162
- if (/[\r\n]/u.test(owner)) return problem('owner', 'owner 只能是一行署名');
163
- if (owner.length > MAX_OWNER_CHARS) return problem('owner', 'owner 超过 200 字');
164
- return undefined;
165
- }
166
-
167
- function metadataOf(raw: unknown): MarketIndexEntry['metadata'] {
168
- const record = asRecord(raw);
169
- if (record === undefined) return undefined;
170
- const workbench = record['workbench'] === true;
171
- const unit = asText(record['unit']);
172
- const category = asText(record['category']);
173
- const labels = asTextArray(record['labels']);
174
- const keywords = asTextArray(record['keywords']);
175
- const sortKeysRecord = asRecord(record['sortKeys']);
176
- const sortKeys =
177
- sortKeysRecord === undefined
178
- ? undefined
179
- : Object.fromEntries(
180
- Object.entries(sortKeysRecord).flatMap(([key, value]) =>
181
- typeof value === 'string' || typeof value === 'number' ? [[key, value] as const] : [],
182
- ),
183
- );
184
- return {
185
- ...(workbench ? { workbench: true } : {}),
186
- ...(unit === '' ? {} : { unit }),
187
- ...(category === '' ? {} : { category }),
188
- ...(labels.length === 0 ? {} : { labels }),
189
- ...(keywords.length === 0 ? {} : { keywords }),
190
- ...(sortKeys === undefined || Object.keys(sortKeys).length === 0 ? {} : { sortKeys }),
191
- };
103
+ export function marketVersionDocOf(entry: MarketIndexEntry, version: MarketIndexVersion): MarketVersionDoc {
104
+ return { ...docFieldsFromEntry(entry), ...version };
192
105
  }
193
106
 
194
- function entryOf(raw: unknown, at: string): MarketIndexEntry | MarketIndexProblem {
195
- const record = asRecord(raw);
196
- if (record === undefined) return problem('plugin', '插件条目必须是对象', at);
197
- const name = asText(record['name']);
198
- if (name === '' || name.length > 214 || !NPM_NAME.test(name)) {
199
- return problem('name', `不是合法 npm 包名:${JSON.stringify(name)}`, at);
200
- }
201
- const versions = versionsOf(record['versions'], `${at}.versions`);
202
- if ('kind' in versions) return versions;
203
- const icon = asText(record['icon']);
204
- if (icon !== '') {
205
- const badIcon = iconProblem(icon);
206
- if (badIcon !== undefined) return badIcon;
207
- }
208
- const title = asLocalized(record['title']);
209
- const description = asLocalized(record['description']);
210
- const author = asText(record['author']);
211
- const owner = asText(record['owner']);
212
- if (owner !== '') {
213
- const badOwner = ownerProblem(owner);
214
- if (badOwner !== undefined) return badOwner;
215
- }
216
- const homepage = asText(record['homepage']);
217
- const metadata = metadataOf(record['metadata']);
218
- if (metadata?.unit !== undefined && !UNIT_ID.test(metadata.unit)) {
219
- return problem('unit', `metadata.unit 只能是小写字母开头的 a-z0-9-(现在是 ${JSON.stringify(metadata.unit)})`, at);
220
- }
221
- return {
222
- name,
223
- versions,
224
- ...(title === undefined ? {} : { title }),
225
- ...(description === undefined ? {} : { description }),
226
- ...(author === '' ? {} : { author }),
227
- ...(owner === '' ? {} : { owner }),
228
- ...(homepage === '' ? {} : { homepage }),
229
- ...(icon === '' ? {} : { icon }),
230
- ...(metadata === undefined ? {} : { metadata }),
231
- };
107
+ export function marketMetaDocOf(entry: MarketIndexEntry, epoch: number, at: number): MarketMetaDoc {
108
+ return { ...docFieldsFromEntry(entry), epoch, publishTimestamp: at };
232
109
  }
233
110
 
234
- export function parseMarketIndex(raw: unknown): MarketIndexParse {
235
- const record = asRecord(raw);
236
- if (record === undefined) return { ok: false, problems: [problem('not-object', '索引必须是一个 JSON 对象')] };
237
- if (record['format'] !== MARKET_INDEX_FORMAT) {
238
- return {
239
- ok: false,
240
- problems: [problem('format', `format 必须是 ${String(MARKET_INDEX_FORMAT)}(现在是 ${JSON.stringify(record['format'])})`)],
241
- };
242
- }
243
- const plugins = record['plugins'];
244
- if (!Array.isArray(plugins)) return { ok: false, problems: [problem('plugins', 'plugins 必须是数组')] };
245
- const entries: MarketIndexEntry[] = [];
111
+ export function assembleMarketIndex(files: readonly MarketEntryFile[]): MarketIndexAssemble {
246
112
  const problems: MarketIndexProblem[] = [];
247
- const seen = new Set<string>();
248
- for (const [index, item] of plugins.entries()) {
249
- const parsed = entryOf(item, `plugins[${String(index)}]`);
250
- if ('kind' in parsed) {
251
- problems.push(parsed);
113
+ interface Group {
114
+ versions: Map<string, MarketIndexVersion>;
115
+ meta: { epoch: number; at: number; path: string; doc: MarketEntryDoc } | undefined;
116
+ fallback: { at: number; path: string; doc: MarketEntryDoc } | undefined;
117
+ }
118
+ const groups = new Map<string, Group>();
119
+ const ordered = [...files].sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
120
+ for (const file of ordered) {
121
+ let raw: unknown;
122
+ try {
123
+ raw = JSON.parse(file.text) as unknown;
124
+ } catch (error) {
125
+ problems.push(
126
+ problem('not-json', `JSON 解析失败:${error instanceof Error ? error.message : String(error)}`, file.path),
127
+ );
252
128
  continue;
253
129
  }
254
- if (seen.has(parsed.name)) {
255
- problems.push(problem('duplicate-name', `插件名重复:${parsed.name}`, `plugins[${String(index)}]`));
130
+ const parsed = parseMarketEntryDoc(raw);
131
+ if (!parsed.ok) {
132
+ for (const one of parsed.problems) problems.push({ ...one, at: file.path });
256
133
  continue;
257
134
  }
258
- seen.add(parsed.name);
259
- entries.push(parsed);
135
+ const doc = parsed.doc;
136
+ const group =
137
+ groups.get(doc.name) ?? { versions: new Map<string, MarketIndexVersion>(), meta: undefined, fallback: undefined };
138
+ const at = doc.publishTimestamp ?? 0;
139
+ if ('version' in doc) {
140
+ if (group.versions.has(doc.version)) {
141
+ problems.push(problem('duplicate-version', `版本重复:${doc.version}`, file.path));
142
+ }
143
+ group.versions.set(doc.version, {
144
+ version: doc.version,
145
+ source: doc.source,
146
+ ...(doc.publishTimestamp === undefined ? {} : { publishTimestamp: doc.publishTimestamp }),
147
+ ...(doc.engines === undefined ? {} : { engines: doc.engines }),
148
+ });
149
+ const newerFallback =
150
+ group.fallback === undefined ||
151
+ at > group.fallback.at ||
152
+ (at === group.fallback.at && file.path > group.fallback.path);
153
+ if (newerFallback) group.fallback = { at, path: file.path, doc };
154
+ } else {
155
+ const epoch = doc.epoch ?? 0;
156
+ const newer =
157
+ group.meta === undefined ||
158
+ epoch > group.meta.epoch ||
159
+ (epoch === group.meta.epoch && at > group.meta.at) ||
160
+ (epoch === group.meta.epoch && at === group.meta.at && file.path > group.meta.path);
161
+ if (newer) group.meta = { epoch, at, path: file.path, doc };
162
+ }
163
+ groups.set(doc.name, group);
260
164
  }
261
165
  if (problems.length > 0) return { ok: false, problems };
262
- const title = asLocalized(record['title']);
263
- const description = asLocalized(record['description']);
264
- const metadata = asRecord(record['metadata']);
265
- return {
266
- ok: true,
267
- index: {
268
- format: MARKET_INDEX_FORMAT,
269
- plugins: entries,
270
- ...(title === undefined ? {} : { title }),
271
- ...(description === undefined ? {} : { description }),
272
- ...(metadata === undefined ? {} : { metadata: metadata as MarketIndex['metadata'] }),
273
- },
274
- };
275
- }
276
-
277
- export function parseMarketIndexJson(text: string): MarketIndexParse {
278
- try {
279
- return parseMarketIndex(JSON.parse(text) as unknown);
280
- } catch (error) {
166
+ const plugins: MarketIndexEntry[] = [...groups.entries()].map(([name, group]) => {
167
+ const meta = group.meta?.doc ?? group.fallback?.doc;
281
168
  return {
282
- ok: false,
283
- problems: [problem('not-json', `JSON 解析失败:${error instanceof Error ? error.message : String(error)}`)],
169
+ name,
170
+ versions: sortMarketVersions([...group.versions.values()]),
171
+ ...(meta?.title === undefined ? {} : { title: meta.title }),
172
+ ...(meta?.description === undefined ? {} : { description: meta.description }),
173
+ ...(meta?.author === undefined ? {} : { author: meta.author }),
174
+ ...(meta?.owner === undefined ? {} : { owner: meta.owner }),
175
+ ...(meta?.homepage === undefined ? {} : { homepage: meta.homepage }),
176
+ ...(meta?.icon === undefined ? {} : { icon: meta.icon }),
177
+ ...(meta?.metadata === undefined ? {} : { metadata: meta.metadata }),
284
178
  };
285
- }
286
- }
287
-
288
- export function sortMarketVersions(versions: readonly MarketIndexVersion[]): MarketIndexVersion[] {
289
- return [...versions].sort((left, right) => (compareSemver(right.version, left.version) ?? 0));
290
- }
291
-
292
- export function upsertMarketEntry(index: MarketIndex, entry: MarketIndexEntry): MarketIndex {
293
- const found = index.plugins.findIndex((item) => item.name === entry.name);
294
- if (found === -1) return { ...index, plugins: [...index.plugins, entry] };
295
- const current = index.plugins[found];
296
- if (current === undefined) return { ...index, plugins: [...index.plugins, entry] };
297
- const merged = new Map<string, MarketIndexVersion>();
298
- for (const one of current.versions) merged.set(one.version, one);
299
- for (const one of entry.versions) merged.set(one.version, one);
300
- const owner = current.owner ?? entry.owner;
301
- const plugins = [...index.plugins];
302
- plugins[found] = {
303
- ...current,
304
- ...entry,
305
- ...(owner === undefined ? {} : { owner }),
306
- versions: sortMarketVersions([...merged.values()]),
307
- };
308
- return { ...index, plugins };
309
- }
310
-
311
- export function marketEntryLabels(entry: MarketIndexEntry): readonly string[] {
312
- return entry.metadata?.labels ?? [];
313
- }
314
-
315
- export function filterMarketIndex(index: MarketIndex, filter: DiscoverFilter): readonly MarketIndexEntry[] {
316
- const query = (filter.query ?? '').trim().toLowerCase();
317
- const category = (filter.category ?? '').trim();
318
- const label = (filter.label ?? '').trim();
319
- return index.plugins.filter((entry) => {
320
- if (category !== '' && entry.metadata?.category !== category) return false;
321
- if (label !== '' && !marketEntryLabels(entry).includes(label)) return false;
322
- if (query === '') return true;
323
- const haystack = [
324
- entry.name,
325
- marketText(entry.title, 'zh'),
326
- marketText(entry.title, 'en'),
327
- marketText(entry.description, 'zh'),
328
- marketText(entry.description, 'en'),
329
- entry.author ?? '',
330
- ...(entry.metadata?.keywords ?? []),
331
- ]
332
- .join(' ')
333
- .toLowerCase();
334
- return haystack.includes(query);
335
179
  });
336
- }
337
-
338
- export function marketSourceKey(source: IndexSourceSpec): string {
339
- const canonical = JSON.stringify([source.kind, source.url.trim(), source.ref ?? '', source.path ?? '']);
340
- return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
341
- }
342
-
343
- export type SourceConfigParse =
344
- | { readonly ok: true; readonly source: MarketSourceConfig }
345
- | { readonly ok: false; readonly problem: MarketIndexProblem };
346
-
347
- export function parseSourceConfig(raw: unknown): SourceConfigParse {
348
- const record = asRecord(raw);
349
- if (record === undefined) return { ok: false, problem: problem('source', '索引源必须是对象') };
350
- const id = asText(record['id']);
351
- const label = asText(record['label']);
352
- const kind = record['kind'];
353
- const url = asText(record['url']);
354
- if (id === '') return { ok: false, problem: problem('source', '索引源缺 id') };
355
- if (label === '') return { ok: false, problem: problem('source', '索引源缺 label') };
356
- if (kind !== 'http' && kind !== 'git') return { ok: false, problem: problem('source', 'kind 只能是 http 或 git') };
357
- if (kind === 'http') {
358
- const bad = httpUrlProblem(url);
359
- if (bad !== undefined) return { ok: false, problem: problem('source', bad) };
360
- return { ok: true, source: { id, label, kind, url } };
361
- }
362
- if (!gitUrlOk(url)) {
363
- return { ok: false, problem: problem('source', 'git 地址不合格(只收 https / ssh / scp 形式,且不能带凭据)') };
364
- }
365
- const ref = asText(record['ref']);
366
- const path = asText(record['path']);
367
- const localPath = asText(record['localPath']);
368
- const policy = record['policy'];
369
- if (policy !== undefined && policy !== 'artifacts-in-repo' && policy !== 'catalog-only') {
370
- return { ok: false, problem: problem('source', 'policy 只能是 artifacts-in-repo 或 catalog-only') };
180
+ plugins.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
181
+ return { ok: true, index: { format: MARKET_INDEX_FORMAT, plugins } };
182
+ }
183
+
184
+ export function marketEntryFilesOf(index: MarketIndex, dir: string = MARKET_INDEX_DIR): readonly MarketEntryFile[] {
185
+ const files: MarketEntryFile[] = [];
186
+ for (const entry of index.plugins) {
187
+ for (const version of entry.versions) {
188
+ files.push({
189
+ path: marketEntryPath(dir, entry.name, version.version),
190
+ text: `${JSON.stringify(marketVersionDocOf(entry, version), null, 2)}\n`,
191
+ });
192
+ }
371
193
  }
372
- return {
373
- ok: true,
374
- source: {
375
- id,
376
- label,
377
- kind,
378
- url,
379
- ...(ref === '' ? {} : { ref }),
380
- ...(path === '' ? {} : { path }),
381
- ...(localPath === '' ? {} : { localPath }),
382
- ...(policy === undefined ? {} : { policy: policy as MarketSourceConfig['policy'] }),
383
- },
194
+ return files;
195
+ }
196
+
197
+ export function collectMarketEntryFiles(dir: string): readonly MarketEntryFile[] {
198
+ if (!existsSync(dir)) return [];
199
+ const files: MarketEntryFile[] = [];
200
+ const walk = (current: string): void => {
201
+ for (const item of readdirSync(current, { withFileTypes: true })) {
202
+ const full = join(current, item.name);
203
+ if (item.isDirectory()) {
204
+ walk(full);
205
+ continue;
206
+ }
207
+ if (!item.name.endsWith('.json')) continue;
208
+ files.push({ path: relative(dir, full).split(sep).join('/'), text: readFileSync(full, 'utf8') });
209
+ }
384
210
  };
211
+ walk(dir);
212
+ return files;
385
213
  }
386
214
 
387
- export function httpUrlProblem(url: string): string | undefined {
388
- let parsed: URL;
389
- try {
390
- parsed = new URL(url);
391
- } catch {
392
- return '不是合法 URL';
393
- }
394
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '只收 http / https';
395
- if (parsed.username !== '' || parsed.password !== '') return 'URL 不能带凭据';
396
- if (parsed.search !== '' || parsed.hash !== '') return 'URL 不能带 query / fragment';
397
- return undefined;
398
- }
399
-
400
- export function gitUrlOk(url: string): boolean {
401
- if (url === '') return false;
402
- if (/^[^@\s]+@[^@\s:]+:[^@\s]+$/.test(url)) return true;
403
- let parsed: URL;
404
- try {
405
- parsed = new URL(url);
406
- } catch {
407
- return false;
408
- }
409
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'ssh:' && parsed.protocol !== 'http:') return false;
410
- if (parsed.password !== '') return false;
411
- if (parsed.username !== '' && parsed.protocol !== 'ssh:') return false;
412
- if (parsed.search !== '' || parsed.hash !== '') return false;
413
- return true;
215
+ export function marketEntryFilesOfLegacy(text: string, dir: string = MARKET_INDEX_DIR): readonly MarketEntryFile[] | undefined {
216
+ const parsed = parseMarketIndexJson(text);
217
+ if (!parsed.ok) return undefined;
218
+ return marketEntryFilesOf(parsed.index, dir);
414
219
  }
@@ -0,0 +1,26 @@
1
+ import { delimiter } from 'node:path';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { ensureLibsBinOnPath, libsBinDir, npmCacheDir } from './paths.js';
4
+
5
+ describe('隔离运行时的路径', () => {
6
+ it('共用依赖的 bin 目录就在 libs 下面', () => {
7
+ expect(libsBinDir().endsWith('/opencli/libs/node_modules/.bin')).toBe(true);
8
+ expect(npmCacheDir().endsWith('/opencli/npm-cache')).toBe(true);
9
+ });
10
+
11
+ it('★ 把 bin 目录前插一次;再插不会重复(PATH 不许越跑越长)', () => {
12
+ const env = { PATH: `/usr/bin${delimiter}/bin` };
13
+ const dir = libsBinDir();
14
+
15
+ expect(ensureLibsBinOnPath(env)).toBe(true);
16
+ expect(env.PATH).toBe(`${dir}${delimiter}/usr/bin${delimiter}/bin`);
17
+ expect(ensureLibsBinOnPath(env)).toBe(false);
18
+ expect(env.PATH.split(delimiter).filter((one) => one === dir)).toHaveLength(1);
19
+ });
20
+
21
+ it('PATH 是空的也能插', () => {
22
+ const env: NodeJS.ProcessEnv = {};
23
+ expect(ensureLibsBinOnPath(env)).toBe(true);
24
+ expect(env.PATH).toBe(libsBinDir());
25
+ });
26
+ });
package/src/host/paths.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { delimiter } from 'node:path';
1
2
  import { workbenchRoot } from './data-root.js';
2
3
 
3
4
  export function isolatedRoot(): string {
@@ -35,3 +36,20 @@ export function vendorPeerLink(): { readonly dir: string; readonly parent: strin
35
36
  export function npmCacheDir(): string {
36
37
  return `${isolatedRoot()}/npm-cache`;
37
38
  }
39
+
40
+ export function libsBinDir(): string {
41
+ return `${libsPrefix()}/node_modules/.bin`;
42
+ }
43
+
44
+ /**
45
+ * 把隔离运行时的共用依赖 bin 目录放到 PATH 最前面(可执行文件都从这儿找)。
46
+ * @param env 改哪份环境(默认当前进程)。
47
+ * @returns 真的插进去了就 `true`;已经在里面就 `false`。
48
+ */
49
+ export function ensureLibsBinOnPath(env: NodeJS.ProcessEnv = process.env): boolean {
50
+ const dir = libsBinDir();
51
+ const parts = (env.PATH ?? '').split(delimiter).filter((one) => one !== '');
52
+ if (parts.includes(dir)) return false;
53
+ env.PATH = [dir, ...parts].join(delimiter);
54
+ return true;
55
+ }
@@ -0,0 +1,31 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { ensureLibsOpencliPeer } from './runtime-links.js';
6
+
7
+ afterEach(() => {
8
+ vi.unstubAllEnvs();
9
+ });
10
+
11
+ describe('ensureLibsOpencliPeer', () => {
12
+ it('★ 运行时那份 opencli 在 → 链到 libs,libs 里的命令才 import 得到它', () => {
13
+ const root = mkdtempSync(join(tmpdir(), 'wb-peer-'));
14
+ vi.stubEnv('DSH_WORKBENCH_HOME', root);
15
+ mkdirSync(join(root, 'opencli/toolchain/node_modules/@jackwener/opencli'), { recursive: true });
16
+
17
+ expect(ensureLibsOpencliPeer()).toBe(true);
18
+ const link = join(root, 'opencli/libs/node_modules/@jackwener/opencli');
19
+ expect(existsSync(link)).toBe(true);
20
+ expect(realpathSync(link)).toContain('toolchain/node_modules/@jackwener/opencli');
21
+ expect(ensureLibsOpencliPeer()).toBe(false);
22
+ });
23
+
24
+ it('运行时还没装 opencli → 不建空链接(等它装好再说)', () => {
25
+ const root = mkdtempSync(join(tmpdir(), 'wb-peer-'));
26
+ vi.stubEnv('DSH_WORKBENCH_HOME', root);
27
+
28
+ expect(ensureLibsOpencliPeer()).toBe(false);
29
+ expect(existsSync(join(root, 'opencli/libs/node_modules/@jackwener/opencli'))).toBe(false);
30
+ });
31
+ });