@dsh-workbench/shared 1.1.6 → 1.1.8

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 (37) hide show
  1. package/dist/contract/needs.d.ts +3 -0
  2. package/dist/contract/needs.js +17 -0
  3. package/dist/contract/protocol/market-index.d.ts +1 -0
  4. package/dist/host/lib-runners.d.ts +20 -0
  5. package/dist/host/lib-runners.js +46 -0
  6. package/dist/host/market-index-claim.d.ts +0 -1
  7. package/dist/host/market-index-claim.js +0 -3
  8. package/dist/host/market-index-doc.d.ts +1 -45
  9. package/dist/host/market-index-parse.d.ts +9 -4
  10. package/dist/host/market-index-parse.js +17 -92
  11. package/dist/host/market-index.d.ts +5 -6
  12. package/dist/host/market-index.js +29 -87
  13. package/dist/host/opencli-runner.d.ts +25 -0
  14. package/dist/host/opencli-runner.js +50 -0
  15. package/dist/host.d.ts +2 -0
  16. package/dist/host.js +2 -0
  17. package/dist/i18n/en.js +3 -1
  18. package/dist/i18n/index.d.ts +4 -2
  19. package/dist/i18n/zh.d.ts +3 -1
  20. package/dist/i18n/zh.js +3 -1
  21. package/package.json +1 -1
  22. package/src/contract/__tests__/needs.test.ts +8 -0
  23. package/src/contract/needs.ts +15 -0
  24. package/src/contract/protocol/market-index.ts +1 -0
  25. package/src/host/__tests__/lib-runners.test.ts +82 -0
  26. package/src/host/__tests__/market-index-assemble.test.ts +26 -0
  27. package/src/host/__tests__/market-index.test.ts +41 -51
  28. package/src/host/__tests__/opencli-runner.test.ts +120 -0
  29. package/src/host/lib-runners.ts +70 -0
  30. package/src/host/market-index-claim.ts +0 -4
  31. package/src/host/market-index-doc.ts +1 -53
  32. package/src/host/market-index-parse.ts +21 -85
  33. package/src/host/market-index.ts +33 -127
  34. package/src/host/opencli-runner.ts +76 -0
  35. package/src/host.ts +2 -0
  36. package/src/i18n/en.ts +3 -1
  37. package/src/i18n/zh.ts +3 -1
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { DataRequest, DataResult } from '../../contract/data.js';
3
+ import { opencliRunnerOf } from '../opencli-runner.js';
4
+
5
+ type Read = (request: DataRequest) => Promise<DataResult<unknown>>;
6
+
7
+ function readerOf(result: DataResult<unknown>): {
8
+ readonly read: Read;
9
+ readonly spy: ReturnType<typeof vi.fn>;
10
+ } {
11
+ const spy = vi.fn(async (_request: DataRequest) => result);
12
+ return { read: spy as unknown as Read, spy };
13
+ }
14
+
15
+ describe('opencliRunnerOf', () => {
16
+ it('把 argv 原样交给读缝,解析后的值序列化回 stdout', async () => {
17
+ const { read, spy } = readerOf({ ok: true, data: [{ name: 'lsznh' }] });
18
+ const runner = opencliRunnerOf(read);
19
+
20
+ await expect(
21
+ runner(['argocd', 'application-resource-tree', '--name', 'lsznh']),
22
+ ).resolves.toEqual({ stdout: JSON.stringify([{ name: 'lsznh' }]) });
23
+ expect(spy).toHaveBeenCalledWith({
24
+ kind: 'command',
25
+ adapter: 'opencli',
26
+ argv: ['argocd', 'application-resource-tree', '--name', 'lsznh'],
27
+ });
28
+ });
29
+
30
+ it('timeoutMs 透传给读缝;不传就不出现这一格', async () => {
31
+ const withTimeout = readerOf({ ok: true, data: [] });
32
+ await opencliRunnerOf(withTimeout.read, { timeoutMs: 1_800_000 })([
33
+ 'coding',
34
+ 'ci-status',
35
+ ]);
36
+ expect(withTimeout.spy).toHaveBeenCalledWith(
37
+ expect.objectContaining({ timeoutMs: 1_800_000 }),
38
+ );
39
+
40
+ const withoutTimeout = readerOf({ ok: true, data: [] });
41
+ await opencliRunnerOf(withoutTimeout.read)(['coding', 'ci-status']);
42
+ expect(withoutTimeout.spy).toHaveBeenCalledWith(
43
+ expect.not.objectContaining({ timeoutMs: expect.anything() }),
44
+ );
45
+ });
46
+
47
+ it('未登录还原成 opencli 的约定:退出码 77 + AUTH_REQUIRED + 登录地址', async () => {
48
+ const { read } = readerOf({
49
+ ok: false,
50
+ error: {
51
+ code: 'auth',
52
+ pageCode: 'auth-required',
53
+ message: 'Coding 未登录',
54
+ helpUrl: 'https://baiguokeji.coding.net/login',
55
+ },
56
+ });
57
+
58
+ const failure = await opencliRunnerOf(read)(['coding', 'ci-status']).then(
59
+ () => null,
60
+ (error: unknown) => error as Error & { code?: number },
61
+ );
62
+
63
+ expect(failure).toBeInstanceOf(Error);
64
+ expect(failure?.code).toBe(77);
65
+ expect(failure?.message).toBe(
66
+ [
67
+ 'Command failed: opencli coding ci-status',
68
+ 'code: AUTH_REQUIRED',
69
+ 'message: Coding 未登录',
70
+ 'help: Please open Chrome or Chromium and log in to https://baiguokeji.coding.net/login',
71
+ ].join('\n'),
72
+ );
73
+ });
74
+
75
+ it('其它失败保留 Command failed 外壳与原因 / 细节 / 下一步', async () => {
76
+ const { read } = readerOf({
77
+ ok: false,
78
+ error: {
79
+ code: 'command',
80
+ pageCode: 'cli-command-failed',
81
+ message: 'UNKNOWN:插件里没有这条命令',
82
+ detail: 'error: unknown command',
83
+ hint: '升级插件后重试',
84
+ },
85
+ });
86
+
87
+ const failure = await opencliRunnerOf(read)([
88
+ 'argocd',
89
+ 'application-resource-tree',
90
+ ]).then(
91
+ () => null,
92
+ (error: unknown) => error as Error,
93
+ );
94
+
95
+ expect(failure?.message).toBe(
96
+ [
97
+ 'Command failed: opencli argocd application-resource-tree',
98
+ 'UNKNOWN:插件里没有这条命令',
99
+ 'error: unknown command',
100
+ '升级插件后重试',
101
+ ].join('\n'),
102
+ );
103
+ });
104
+
105
+ it('没有原因时也有一句能看懂的兜底', async () => {
106
+ const { read } = readerOf({
107
+ ok: false,
108
+ error: { code: 'command', message: '' },
109
+ });
110
+
111
+ const failure = await opencliRunnerOf(read)(['cmdb', 'publish']).then(
112
+ () => null,
113
+ (error: unknown) => error as Error,
114
+ );
115
+
116
+ expect(failure?.message).toBe(
117
+ 'Command failed: opencli cmdb publish\nopencli 这条命令没跑通',
118
+ );
119
+ });
120
+ });
@@ -0,0 +1,70 @@
1
+ import type { LibNeed } from '../contract/needs.js';
2
+ import type { LibReader } from '../contract/lib.js';
3
+ import type { OpencliRunner } from './opencli-runner.js';
4
+
5
+ export interface LibRuntimes {
6
+ readonly opencli?: OpencliRunner | undefined;
7
+ }
8
+
9
+ interface Logger {
10
+ warn?(message: string): void;
11
+ }
12
+
13
+ function runnerOf(runtimes: LibRuntimes, runtime: string): OpencliRunner | undefined {
14
+ return runtime === 'opencli' ? runtimes.opencli : undefined;
15
+ }
16
+
17
+ function setterNameOf(runtime: string): string {
18
+ return `set${runtime.charAt(0).toUpperCase()}${runtime.slice(1)}Runner`;
19
+ }
20
+
21
+ /**
22
+ * 给声明了 `runs` 的 lib 注入「跑这个运行时」的执行器:库侧按约定暴露 `setOpencliRunner`
23
+ * (运行时名字首字母大写 + `Runner`),外壳在每次 `load` 之后把执行器交给它。
24
+ *
25
+ * 没暴露的(版本旧)就保持它自己的行为(按 PATH 找),只记一条 warn;不熟悉的运行时同样只 warn。
26
+ * @param libs 原来的读库口。
27
+ * @param runtimes 外壳手上有的执行器,按运行时名字给。
28
+ * @param logger 记 warn 的地方(默认不记)。
29
+ * @returns 包好的读库口;`load` 的签名与返回不变。
30
+ */
31
+ export function withLibRunners(
32
+ libs: LibReader,
33
+ runtimes: LibRuntimes,
34
+ logger?: Logger,
35
+ ): LibReader {
36
+ const warned = new Set<string>();
37
+ const warnOnce = (key: string, message: string): void => {
38
+ if (warned.has(key)) return;
39
+ warned.add(key);
40
+ logger?.warn?.(message);
41
+ };
42
+ return {
43
+ async load<T = unknown>(need: LibNeed, entry: string): Promise<T> {
44
+ const module = await libs.load<T>(need, entry);
45
+ for (const runtime of need.runs ?? []) {
46
+ const runner = runnerOf(runtimes, runtime);
47
+ if (runner === undefined) {
48
+ warnOnce(
49
+ `${need.name}:${runtime}:unsupported`,
50
+ `[dsh-workbench] ${need.name} 声明了自己会跑 ${runtime},但工作台没有这个执行器 —— 它会按自己的方式找`,
51
+ );
52
+ continue;
53
+ }
54
+ const setter =
55
+ typeof module === 'object' && module !== null
56
+ ? (module as Record<string, unknown>)[setterNameOf(runtime)]
57
+ : undefined;
58
+ if (typeof setter !== 'function') {
59
+ warnOnce(
60
+ `${need.name}:${entry}:${runtime}:missing`,
61
+ `[dsh-workbench] ${need.name} 声明了自己会跑 ${runtime},但这一版没有 ${setterNameOf(runtime)} —— 它会按 PATH 找`,
62
+ );
63
+ continue;
64
+ }
65
+ (setter as (runner: OpencliRunner) => void).call(module, runner);
66
+ }
67
+ return module;
68
+ },
69
+ };
70
+ }
@@ -58,10 +58,6 @@ export function marketArtifactRoot(name: string): string {
58
58
  return `${MARKET_ARTIFACTS_DIR}/${name}`;
59
59
  }
60
60
 
61
- export function marketArtifactPathFor(name: string, version: string): string {
62
- return `${MARKET_ARTIFACTS_DIR}/${name}/${version}`;
63
- }
64
-
65
61
  export function entryUnitOf(entry: MarketIndexEntry): string | undefined {
66
62
  const unit = entry.metadata?.unit?.trim();
67
63
  if (unit !== undefined && unit !== '') return unit;
@@ -1,45 +1,4 @@
1
- import type {
2
- MarketIndex,
3
- MarketIndexEngines,
4
- MarketIndexEntryMetadata,
5
- MarketIndexProblem,
6
- MarketIndexSource,
7
- MarketLocalizedText,
8
- } from '../contract/protocol/market-index.js';
9
-
10
- export interface MarketVersionDoc {
11
- readonly name: string;
12
- readonly version: string;
13
- readonly source: MarketIndexSource;
14
- readonly publishTimestamp?: number;
15
- readonly engines?: MarketIndexEngines;
16
- readonly title?: MarketLocalizedText;
17
- readonly description?: MarketLocalizedText;
18
- readonly author?: string;
19
- readonly owner?: string;
20
- readonly homepage?: string;
21
- readonly icon?: string;
22
- readonly metadata?: MarketIndexEntryMetadata;
23
- }
24
-
25
- export interface MarketMetaDoc {
26
- readonly name: string;
27
- readonly epoch?: number;
28
- readonly publishTimestamp: number;
29
- readonly title?: MarketLocalizedText;
30
- readonly description?: MarketLocalizedText;
31
- readonly author?: string;
32
- readonly owner?: string;
33
- readonly homepage?: string;
34
- readonly icon?: string;
35
- readonly metadata?: MarketIndexEntryMetadata;
36
- }
37
-
38
- export type MarketEntryDoc = MarketVersionDoc | MarketMetaDoc;
39
-
40
- export type MarketEntryDocParse =
41
- | { readonly ok: true; readonly doc: MarketEntryDoc }
42
- | { readonly ok: false; readonly problems: readonly MarketIndexProblem[] };
1
+ import type { MarketIndex, MarketIndexProblem } from '../contract/protocol/market-index.js';
43
2
 
44
3
  export interface MarketEntryFile {
45
4
  readonly path: string;
@@ -49,14 +8,3 @@ export interface MarketEntryFile {
49
8
  export type MarketIndexAssemble =
50
9
  | { readonly ok: true; readonly index: MarketIndex }
51
10
  | { readonly ok: false; readonly problems: readonly MarketIndexProblem[] };
52
-
53
- export interface MarketDocFields {
54
- readonly name: string;
55
- readonly title?: MarketLocalizedText;
56
- readonly description?: MarketLocalizedText;
57
- readonly author?: string;
58
- readonly owner?: string;
59
- readonly homepage?: string;
60
- readonly icon?: string;
61
- readonly metadata?: MarketIndexEntryMetadata;
62
- }
@@ -13,7 +13,6 @@ import type {
13
13
  } from '../contract/protocol/market-index.js';
14
14
  import { MARKET_INDEX_DIR, MARKET_INDEX_FORMAT } from '../contract/protocol/market-index.js';
15
15
  import { marketText } from '../lib/market-index.js';
16
- import type { MarketDocFields, MarketEntryDocParse } from './market-index-doc.js';
17
16
  import { compareVersions, isValidVersion } from './version.js';
18
17
 
19
18
  const NPM_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
@@ -388,95 +387,32 @@ export function parseSourceConfig(raw: unknown): SourceConfigParse {
388
387
  }
389
388
 
390
389
 
391
- function docFieldsOf(record: Readonly<Record<string, unknown>>, at: string): MarketDocFields | MarketIndexProblem {
392
- const name = asText(record['name']);
393
- if (name === '' || name.length > 214 || !NPM_NAME.test(name)) {
394
- return problem('name', `不是合法 npm 包名:${JSON.stringify(name)}`, at);
395
- }
396
- const icon = asText(record['icon']);
397
- if (icon !== '') {
398
- const badIcon = iconProblem(icon);
399
- if (badIcon !== undefined) return badIcon;
400
- }
401
- const owner = asText(record['owner']);
402
- if (owner !== '') {
403
- const badOwner = ownerProblem(owner);
404
- if (badOwner !== undefined) return badOwner;
405
- }
406
- const metadata = metadataOf(record['metadata']);
407
- if (metadata?.unit !== undefined && !UNIT_ID.test(metadata.unit)) {
408
- return problem('unit', `metadata.unit 只能是小写字母开头的 a-z0-9-(现在是 ${JSON.stringify(metadata.unit)})`, at);
409
- }
410
- const title = asLocalized(record['title']);
411
- const description = asLocalized(record['description']);
412
- const author = asText(record['author']);
413
- const homepage = asText(record['homepage']);
414
- return {
415
- name,
416
- ...(title === undefined ? {} : { title }),
417
- ...(description === undefined ? {} : { description }),
418
- ...(author === '' ? {} : { author }),
419
- ...(owner === '' ? {} : { owner }),
420
- ...(homepage === '' ? {} : { homepage }),
421
- ...(icon === '' ? {} : { icon }),
422
- ...(metadata === undefined ? {} : { metadata }),
423
- };
424
- }
425
-
426
- export function parseMarketEntryDoc(raw: unknown): MarketEntryDocParse {
427
- const record = asRecord(raw);
428
- if (record === undefined) return { ok: false, problems: [problem('entry', '条目文件必须是一个 JSON 对象')] };
429
- const fields = docFieldsOf(record, 'entry');
430
- if ('kind' in fields) return { ok: false, problems: [fields] };
431
- const version = asText(record['version']);
432
- const timestamp = record['publishTimestamp'];
433
- if (timestamp !== undefined && (!Number.isInteger(timestamp) || (timestamp as number) < 0)) {
434
- return { ok: false, problems: [problem('timestamp', 'publishTimestamp 必须是 Unix 秒整数', 'entry')] };
435
- }
436
- if (version === '') {
437
- if (timestamp === undefined) {
438
- return { ok: false, problems: [problem('timestamp', '元数据文件要有 publishTimestamp', 'entry')] };
439
- }
440
- const epoch = record['epoch'];
441
- if (epoch !== undefined && (!Number.isInteger(epoch) || (epoch as number) < 0)) {
442
- return { ok: false, problems: [problem('timestamp', 'epoch 必须是非负整数', 'entry')] };
443
- }
444
- return {
445
- ok: true,
446
- doc: { ...fields, publishTimestamp: timestamp as number, ...(epoch === undefined ? {} : { epoch: epoch as number }) },
447
- };
448
- }
449
- if (!isValidVersion(version)) {
450
- return { ok: false, problems: [problem('version', `版本号不是合法 SemVer:${JSON.stringify(version)}`, 'entry')] };
451
- }
452
- const source = sourceOf(record['source'], 'entry.source');
453
- if ('kind' in source) return { ok: false, problems: [source] };
454
- const engines = asRecord(record['engines']);
455
- const dsh = engines === undefined ? '' : asText(engines['dsh']);
456
- return {
457
- ok: true,
458
- doc: {
459
- ...fields,
460
- version,
461
- source,
462
- ...(timestamp === undefined ? {} : { publishTimestamp: timestamp as number }),
463
- ...(dsh === '' ? {} : { engines: { dsh } }),
464
- },
465
- };
466
- }
467
390
  function indexPathBase(dir: string): string {
468
391
  return dir.replace(/^\/+|\/+$/g, '') || MARKET_INDEX_DIR;
469
392
  }
470
393
 
471
- export function marketEntryPath(dir: string, name: string, version: string): string {
394
+ export function marketEntryPath(dir: string, name: string): string {
472
395
  if (!NPM_NAME.test(name)) throw new Error(`不是合法包名:${JSON.stringify(name)}`);
473
- if (!isValidVersion(version)) throw new Error(`不是合法版本号:${JSON.stringify(version)}`);
474
- return `${indexPathBase(dir)}/${name}/${version}.json`;
396
+ return `${indexPathBase(dir)}/${name}.json`;
475
397
  }
476
398
 
477
- export function marketMetaPath(dir: string, name: string, epoch: number, suffix: string): string {
478
- if (!NPM_NAME.test(name)) throw new Error(`不是合法包名:${JSON.stringify(name)}`);
479
- if (!Number.isInteger(epoch) || epoch < 0) throw new Error(`epoch 不合法:${String(epoch)}`);
480
- if (!/^[a-z0-9]{4,16}$/u.test(suffix)) throw new Error(`后缀不合法:${suffix}`);
481
- return `${indexPathBase(dir)}/${name}/meta-${String(epoch)}-${suffix}.json`;
399
+ export type MarketEntryParse =
400
+ | { readonly ok: true; readonly entry: MarketIndexEntry }
401
+ | { readonly ok: false; readonly problems: readonly MarketIndexProblem[] };
402
+
403
+ export function parseMarketEntryJson(text: string): MarketEntryParse {
404
+ let raw: unknown;
405
+ try {
406
+ raw = JSON.parse(text);
407
+ } catch (error) {
408
+ return {
409
+ ok: false,
410
+ problems: [problem('not-json', `JSON 解析失败:${error instanceof Error ? error.message : String(error)}`, 'entry')],
411
+ };
412
+ }
413
+ const parsed = entryOf(raw, 'entry');
414
+ if ('kind' in parsed) return { ok: false, problems: [parsed] };
415
+ return { ok: true, entry: parsed };
482
416
  }
417
+
418
+
@@ -1,40 +1,26 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { join, relative, sep } from 'node:path';
3
- import type {
4
- MarketIndex,
5
- MarketIndexEntry,
6
- MarketIndexProblem,
7
- MarketIndexVersion,
8
- } from '../contract/protocol/market-index.js';
3
+ import type { MarketIndex, MarketIndexEntry, MarketIndexProblem } from '../contract/protocol/market-index.js';
9
4
  import { MARKET_INDEX_DIR, MARKET_INDEX_FORMAT } from '../contract/protocol/market-index.js';
10
-
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';
5
+ import type { MarketEntryFile, MarketIndexAssemble } from './market-index-doc.js';
6
+ import { entryUnitOf } from './market-index-claim.js';
7
+ import { marketEntryPath, parseMarketEntryJson, parseMarketIndexJson, sortMarketVersions } from './market-index-parse.js';
25
8
 
26
9
  export const MARKET_ENTRIES_README_LINES: readonly string[] = [
27
10
  '# 索引目录',
28
11
  '',
29
- '一个包一个目录,一个版本一个文件:`market/<包名>/<版本>.json`;产物在 `artifacts/<包名>/<版本>/`。',
12
+ '一个包一个文件:`market/<包名>.json`,里面是这一个包的元数据与所有版本;',
13
+ '',
14
+ '产物在 `artifacts/<包名>/`(只放最新一版,老版本按条目里的 commit 取历史)。',
30
15
  '',
31
- '`meta-<epoch>-<随机后缀>.json` 是只改标题 / 署名 / 说明时追加的元数据快照,读取时按 epoch 取最新的一份。',
16
+ '发布时只改自己包的文件;包与包之间不共享文件。',
32
17
  ];
33
18
 
34
19
  export const MARKET_INDEX_README_LINES: readonly string[] = [
35
20
  '# 模块市场索引',
36
21
  '',
37
- '这个仓库给 DeepSeek Harness 工作台当模块市场索引:条目在 `market/<包名>/<版本>.json`,产物在 `artifacts/<包名>/<版本>/`。',
22
+ '这个仓库给 DeepSeek Harness 工作台当模块市场索引:一个包一个文件 `market/<包名>.json`,',
23
+ '产物在 `artifacts/<包名>/`;老仓库的 `market.json` 会在第一次发布时自动拆开。',
38
24
  '',
39
25
  '## 发布模块',
40
26
  '',
@@ -44,8 +30,7 @@ export const MARKET_INDEX_README_LINES: readonly string[] = [
44
30
  'dsh-wb-module release --index <本仓库地址> --index-policy artifacts-in-repo',
45
31
  '```',
46
32
  '',
47
- '产物会提交到 `artifacts/<包名>/<版本>/`,条目提交到 `market/<包名>/<版本>.json`。',
48
- '发布只新增文件、不改已有文件,两台机器同时发也不会冲突;老条目的 `packages/<包名>/` 路径继续可用。',
33
+ '发布只会改自己包的那一个文件;包与包之间不会碰同一个文件。',
49
34
  '',
50
35
  '一个包名归第一次发布的人(条目文件里的 `owner`)。换一个署名发同名会被拒;要换人,先把这一条的 `owner` 改成新的人。',
51
36
  '同一个市场里,一个模块 id(`metadata.unit`)只能被一个条目使用;另一个包名想用同一个 id / 产物目录也会被拒。',
@@ -56,14 +41,7 @@ export const MARKET_INDEX_README_LINES: readonly string[] = [
56
41
  '',
57
42
  ];
58
43
  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';
44
+ export type { MarketEntryFile, MarketIndexAssemble } from './market-index-doc.js';
67
45
  export {
68
46
  compareSemver,
69
47
  filterMarketIndex,
@@ -71,9 +49,8 @@ export {
71
49
  httpUrlProblem,
72
50
  marketEntryLabels,
73
51
  marketEntryPath,
74
- marketMetaPath,
75
52
  marketSourceKey,
76
- parseMarketEntryDoc,
53
+ parseMarketEntryJson,
77
54
  parseMarketIndex,
78
55
  parseMarketIndexJson,
79
56
  parseSourceConfig,
@@ -82,116 +59,45 @@ export {
82
59
  } from './market-index-parse.js';
83
60
  export type { SourceConfigParse } from './market-index-parse.js';
84
61
 
85
-
86
62
  function problem(kind: MarketIndexProblem['kind'], detail: string, at?: string): MarketIndexProblem {
87
63
  return { kind, detail, ...(at === undefined ? {} : { at }) };
88
64
  }
89
65
 
90
- function docFieldsFromEntry(entry: MarketIndexEntry): MarketDocFields {
91
- return {
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 }),
100
- };
101
- }
102
-
103
- export function marketVersionDocOf(entry: MarketIndexEntry, version: MarketIndexVersion): MarketVersionDoc {
104
- return { ...docFieldsFromEntry(entry), ...version };
105
- }
106
-
107
- export function marketMetaDocOf(entry: MarketIndexEntry, epoch: number, at: number): MarketMetaDoc {
108
- return { ...docFieldsFromEntry(entry), epoch, publishTimestamp: at };
109
- }
110
-
111
66
  export function assembleMarketIndex(files: readonly MarketEntryFile[]): MarketIndexAssemble {
112
67
  const problems: MarketIndexProblem[] = [];
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>();
68
+ const entries = new Map<string, MarketIndexEntry>();
119
69
  const ordered = [...files].sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
120
70
  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
- );
128
- continue;
129
- }
130
- const parsed = parseMarketEntryDoc(raw);
71
+ if (!file.path.endsWith('.json')) continue;
72
+ const parsed = parseMarketEntryJson(file.text);
131
73
  if (!parsed.ok) {
132
74
  for (const one of parsed.problems) problems.push({ ...one, at: file.path });
133
75
  continue;
134
76
  }
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 };
77
+ const entry = parsed.entry;
78
+ if (entries.has(entry.name)) {
79
+ problems.push(problem('duplicate-name', `插件名重复:${entry.name}`, file.path));
80
+ continue;
162
81
  }
163
- groups.set(doc.name, group);
82
+ entries.set(entry.name, { ...entry, versions: sortMarketVersions(entry.versions) });
164
83
  }
165
84
  if (problems.length > 0) return { ok: false, problems };
166
- const plugins: MarketIndexEntry[] = [...groups.entries()].map(([name, group]) => {
167
- const meta = group.meta?.doc ?? group.fallback?.doc;
168
- return {
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 }),
178
- };
179
- });
180
- plugins.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
85
+ const plugins = [...entries.values()].sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
181
86
  return { ok: true, index: { format: MARKET_INDEX_FORMAT, plugins } };
182
87
  }
183
88
 
89
+ export function marketEntryFileOf(entry: MarketIndexEntry, dir: string = MARKET_INDEX_DIR): MarketEntryFile {
90
+ return { path: marketEntryPath(dir, entry.name), text: `${JSON.stringify(entry, null, 2)}\n` };
91
+ }
92
+
184
93
  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
- }
193
- }
194
- return files;
94
+ return index.plugins.map((entry) => marketEntryFileOf(healedUnit(entry), dir));
95
+ }
96
+
97
+ function healedUnit(entry: MarketIndexEntry): MarketIndexEntry {
98
+ if (entry.metadata?.unit !== undefined) return entry;
99
+ const unit = entryUnitOf(entry);
100
+ return unit === undefined ? entry : { ...entry, metadata: { ...entry.metadata, unit } };
195
101
  }
196
102
 
197
103
  export function collectMarketEntryFiles(dir: string): readonly MarketEntryFile[] {