@dsh-workbench/shared 1.1.5 → 1.1.7

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 (38) 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 +3 -1
  4. package/dist/contract/protocol/market-index.js +2 -0
  5. package/dist/host/lib-runners.d.ts +20 -0
  6. package/dist/host/lib-runners.js +46 -0
  7. package/dist/host/market-index-claim.d.ts +2 -0
  8. package/dist/host/market-index-claim.js +7 -0
  9. package/dist/host/market-index-doc.d.ts +56 -0
  10. package/dist/host/market-index-doc.js +1 -0
  11. package/dist/host/market-index-parse.d.ts +23 -0
  12. package/dist/host/market-index-parse.js +485 -0
  13. package/dist/host/market-index.d.ts +13 -21
  14. package/dist/host/market-index.js +120 -368
  15. package/dist/host/opencli-runner.d.ts +25 -0
  16. package/dist/host/opencli-runner.js +50 -0
  17. package/dist/host.d.ts +2 -0
  18. package/dist/host.js +2 -0
  19. package/dist/i18n/en.js +10 -4
  20. package/dist/i18n/index.d.ts +11 -5
  21. package/dist/i18n/zh.d.ts +10 -4
  22. package/dist/i18n/zh.js +10 -4
  23. package/package.json +1 -1
  24. package/src/contract/__tests__/needs.test.ts +8 -0
  25. package/src/contract/needs.ts +15 -0
  26. package/src/contract/protocol/market-index.ts +4 -0
  27. package/src/host/__tests__/lib-runners.test.ts +82 -0
  28. package/src/host/__tests__/market-index.test.ts +114 -0
  29. package/src/host/__tests__/opencli-runner.test.ts +120 -0
  30. package/src/host/lib-runners.ts +70 -0
  31. package/src/host/market-index-claim.ts +9 -0
  32. package/src/host/market-index-doc.ts +62 -0
  33. package/src/host/market-index-parse.ts +482 -0
  34. package/src/host/market-index.ts +162 -357
  35. package/src/host/opencli-runner.ts +76 -0
  36. package/src/host.ts +2 -0
  37. package/src/i18n/en.ts +10 -4
  38. package/src/i18n/zh.ts +10 -4
@@ -2,14 +2,23 @@ import { describe, expect, it } from 'vitest';
2
2
  import type { MarketIndex, MarketIndexEntry } from '../../contract/protocol/market-index.js';
3
3
  import { claimOf, claimOfEntry, entryOwnerOf, entryUnitOf, marketArtifactPath, samePublisher } from '../market-index-claim.js';
4
4
  import {
5
+ assembleMarketIndex,
6
+ collectMarketEntryFiles,
5
7
  filterMarketIndex,
8
+ marketEntryFilesOf,
9
+ marketEntryPath,
6
10
  marketInstallTarget,
11
+ marketMetaPath,
7
12
  marketSourceKey,
13
+ parseMarketEntryDoc,
8
14
  parseMarketIndex,
9
15
  parseMarketIndexJson,
10
16
  parseSourceConfig,
11
17
  upsertMarketEntry,
12
18
  } from '../market-index.js';
19
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
13
22
 
14
23
  function entry(change: Partial<MarketIndexEntry> = {}): MarketIndexEntry {
15
24
  return {
@@ -343,3 +352,108 @@ describe('索引源与安装目标', () => {
343
352
  ).toBe(false);
344
353
  });
345
354
  });
355
+
356
+ describe('条目文件:一个版本一个文件', () => {
357
+ const versionDoc = (version: string, timestamp: number, title: string) => ({
358
+ name: '@scope/module-x',
359
+ version,
360
+ source: { type: 'npm' },
361
+ publishTimestamp: timestamp,
362
+ title: { zh: title },
363
+ });
364
+
365
+ it('路径按包名分层,meta 文件带 epoch 与随机后缀', () => {
366
+ expect(marketEntryPath('market', '@scope/module-x', '0.1.0')).toBe('market/@scope/module-x/0.1.0.json');
367
+ expect(marketMetaPath('market', '@scope/module-x', 3, 'a1b2c3')).toBe(
368
+ 'market/@scope/module-x/meta-3-a1b2c3.json',
369
+ );
370
+ expect(marketEntryPath('market', 'plain', '1.0.0')).toBe('market/plain/1.0.0.json');
371
+ });
372
+
373
+ it('包名/版本号不合法就不给路径(防止写出目录外)', () => {
374
+ expect(() => marketEntryPath('market', '../escape', '1.0.0')).toThrow();
375
+ expect(() => marketEntryPath('market', 'ok', '../1.0.0')).toThrow();
376
+ });
377
+
378
+ it('单条文档能解析,坏文档整条拒', () => {
379
+ const good = parseMarketEntryDoc(versionDoc('0.1.0', 1_760_000_000, '探针'));
380
+ expect(good.ok).toBe(true);
381
+ if (!good.ok) return;
382
+ expect(good.doc.name).toBe('@scope/module-x');
383
+ const bad = parseMarketEntryDoc({ name: 'Bad Name', version: '0.1.0', source: { type: 'npm' } });
384
+ expect(bad.ok).toBe(false);
385
+ const noSource = parseMarketEntryDoc({ name: '@scope/x', version: '0.1.0' });
386
+ expect(noSource.ok).toBe(false);
387
+ });
388
+
389
+ it('组装:版本全收并按 SemVer 降序,元数据取最新那份', () => {
390
+ const files = [
391
+ { path: 'market/@scope/module-x/0.1.0.json', text: JSON.stringify(versionDoc('0.1.0', 1_760_000_000, '旧标题')) },
392
+ { path: 'market/@scope/module-x/0.2.0.json', text: JSON.stringify(versionDoc('0.2.0', 1_760_000_100, '新标题')) },
393
+ { path: 'market/@scope/module-x/meta-1760000200000.json', text: JSON.stringify({
394
+ name: '@scope/module-x',
395
+ publishTimestamp: 1_760_000_200,
396
+ title: { zh: '最新标题' },
397
+ }) },
398
+ ];
399
+ const assembled = assembleMarketIndex(files);
400
+ expect(assembled.ok).toBe(true);
401
+ if (!assembled.ok) return;
402
+ const entry = assembled.index.plugins[0];
403
+ expect(entry?.versions.map((one) => one.version)).toEqual(['0.2.0', '0.1.0']);
404
+ expect(entry?.title?.zh).toBe('最新标题');
405
+ });
406
+
407
+ it('组装:同一个版本出现两次就整份拒(那是真重复发布)', () => {
408
+ const assembled = assembleMarketIndex([
409
+ { path: 'a/0.1.0.json', text: JSON.stringify(versionDoc('0.1.0', 1_760_000_000, '甲')) },
410
+ { path: 'b/0.1.0.json', text: JSON.stringify(versionDoc('0.1.0', 1_760_000_100, '乙')) },
411
+ ]);
412
+ expect(assembled.ok).toBe(false);
413
+ if (assembled.ok) return;
414
+ expect(assembled.problems.some((one) => one.kind === 'duplicate-version')).toBe(true);
415
+ });
416
+
417
+ it('从目录读只收 .json,README 不当条目', () => {
418
+ const dir = mkdtempSync(join(tmpdir(), 'wb-entry-'));
419
+ try {
420
+ mkdirSync(join(dir, '@scope/module-x'), { recursive: true });
421
+ writeFileSync(join(dir, 'README.md'), 'index');
422
+ writeFileSync(join(dir, '@scope/module-x', '0.1.0.json'), JSON.stringify(versionDoc('0.1.0', 1_760_000_000, '探针')));
423
+ const files = collectMarketEntryFiles(dir);
424
+ expect(files.map((one) => one.path)).toEqual(['@scope/module-x/0.1.0.json']);
425
+ const assembled = assembleMarketIndex(files);
426
+ expect(assembled.ok).toBe(true);
427
+ if (!assembled.ok) return;
428
+ expect(assembled.index.plugins[0]?.name).toBe('@scope/module-x');
429
+ } finally {
430
+ rmSync(dir, { recursive: true, force: true });
431
+ }
432
+ });
433
+
434
+ it('迁移:一份 format 1 能拆成条目文件,再组装回来一模一样', () => {
435
+ const legacy: MarketIndex = {
436
+ format: 1,
437
+ plugins: [
438
+ {
439
+ name: '@scope/module-x',
440
+ owner: '甲',
441
+ metadata: { workbench: true, unit: 'x' },
442
+ versions: [
443
+ { version: '0.2.0', source: { type: 'npm' }, publishTimestamp: 1_760_000_100 },
444
+ { version: '0.1.0', source: { type: 'npm' }, publishTimestamp: 1_760_000_000 },
445
+ ],
446
+ },
447
+ ],
448
+ };
449
+ const files = marketEntryFilesOf(legacy, 'market');
450
+ expect(files.map((one) => one.path)).toEqual([
451
+ 'market/@scope/module-x/0.2.0.json',
452
+ 'market/@scope/module-x/0.1.0.json',
453
+ ]);
454
+ const assembled = assembleMarketIndex(files);
455
+ expect(assembled.ok).toBe(true);
456
+ if (!assembled.ok) return;
457
+ expect(assembled.index.plugins).toEqual(legacy.plugins);
458
+ });
459
+ });
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import type { MarketClaim, MarketIndex, MarketIndexEntry } from '../contract/protocol/market-index.js';
2
+ import { MARKET_ARTIFACTS_DIR } from '../contract/protocol/market-index.js';
2
3
 
3
4
  export interface PublisherIdentity {
4
5
  readonly name: string;
@@ -53,6 +54,14 @@ export function marketArtifactPath(name: string): string {
53
54
  return `packages/${name}`;
54
55
  }
55
56
 
57
+ export function marketArtifactRoot(name: string): string {
58
+ return `${MARKET_ARTIFACTS_DIR}/${name}`;
59
+ }
60
+
61
+ export function marketArtifactPathFor(name: string, version: string): string {
62
+ return `${MARKET_ARTIFACTS_DIR}/${name}/${version}`;
63
+ }
64
+
56
65
  export function entryUnitOf(entry: MarketIndexEntry): string | undefined {
57
66
  const unit = entry.metadata?.unit?.trim();
58
67
  if (unit !== undefined && unit !== '') return unit;
@@ -0,0 +1,62 @@
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[] };
43
+
44
+ export interface MarketEntryFile {
45
+ readonly path: string;
46
+ readonly text: string;
47
+ }
48
+
49
+ export type MarketIndexAssemble =
50
+ | { readonly ok: true; readonly index: MarketIndex }
51
+ | { 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
+ }