@dsh-workbench/shared 1.0.1 → 1.1.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.
@@ -1,10 +1,11 @@
1
- import type { UnitHostNeeds, UnitManifest } from '../contract/unit.js';
1
+ import type { UnitHostNeeds, UnitManifest, UnitTitle } from '../contract/unit.js';
2
+ import { unitTitleKey } from '../contract/unit.js';
2
3
  import { needsProblemsOf, type Need } from './needs.js';
3
4
 
4
5
  /**
5
6
  * 声明协议当前版本;模块清单的 dsh.workbench.apiVersion 要写这个整数。
6
7
  */
7
- export const WORKBENCH_API_VERSION = 2;
8
+ export const WORKBENCH_API_VERSION = 3;
8
9
 
9
10
  /**
10
11
  * 外壳愿意认的协议版本;不在表里的模块当场被拒,不猜语义。
@@ -12,10 +13,9 @@ export const WORKBENCH_API_VERSION = 2;
12
13
  export const SUPPORTED_API_VERSIONS: readonly number[] = [WORKBENCH_API_VERSION];
13
14
 
14
15
  /**
15
- * 清单里那半张卡片声明:文案键加排序位;卡片默认显不显示不在这里(那是运行期面板配置的事)。
16
+ * 清单里那半张卡片声明:只剩排序位;卡片默认显不显示不在这里(那是运行期面板配置的事)。
16
17
  */
17
18
  export interface UnitCardDeclaration {
18
- readonly titleKey: string;
19
19
  readonly order: number;
20
20
  }
21
21
 
@@ -29,11 +29,12 @@ export interface UnitToolDeclaration {
29
29
  }
30
30
 
31
31
  /**
32
- * 模块清单里 dsh.workbench 那一段的完整形状(协议版本、单元 id、卡片、工具、宿主与环境需要)。
32
+ * 模块清单里 dsh.workbench 那一段的完整形状(协议版本、单元 id、名字、卡片、工具、宿主与环境需要)。
33
33
  */
34
34
  export interface UnitDeclaration {
35
35
  readonly apiVersion: number;
36
36
  readonly unit: string;
37
+ readonly title: UnitTitle;
37
38
  readonly card: UnitCardDeclaration | null;
38
39
  readonly tools: readonly UnitToolDeclaration[];
39
40
  readonly host: UnitHostNeeds;
@@ -46,6 +47,7 @@ export interface UnitDeclaration {
46
47
  export const DECLARATION_KEYS: readonly string[] = [
47
48
  'apiVersion',
48
49
  'unit',
50
+ 'title',
49
51
  'card',
50
52
  'tools',
51
53
  'host',
@@ -58,12 +60,14 @@ export const DECLARATION_KEYS: readonly string[] = [
58
60
  export const DECLARATION_REQUIRED_KEYS: readonly string[] = [
59
61
  'apiVersion',
60
62
  'unit',
63
+ 'title',
61
64
  'card',
62
65
  'tools',
63
66
  'host',
64
67
  ];
65
68
 
66
- const CARD_KEYS = ['titleKey', 'order'];
69
+ const CARD_KEYS = ['order'];
70
+ const TITLE_KEYS = ['zh', 'en'];
67
71
  const TOOL_KEYS = ['name', 'writes', 'key'];
68
72
 
69
73
  const TOOL_KEY_SHAPE = /^[a-z][a-zA-Z0-9]*$/;
@@ -77,6 +81,17 @@ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
77
81
  return typeof value === 'object' && value !== null && !Array.isArray(value);
78
82
  }
79
83
 
84
+ /**
85
+ * 读一份声明里的协议版本;不是对象或 apiVersion 不是整数时给 undefined。
86
+ * @param raw 清单里 dsh.workbench 那段原始值。
87
+ * @returns 声明声明的协议版本,读不出来时 undefined。
88
+ */
89
+ export function declarationApiVersionOf(raw: unknown): number | undefined {
90
+ if (!isRecord(raw)) return undefined;
91
+ const version = raw.apiVersion;
92
+ return Number.isInteger(version) ? (version as number) : undefined;
93
+ }
94
+
80
95
  function outsideKeys(value: Readonly<Record<string, unknown>>, allowed: readonly string[]): string[] {
81
96
  return Object.keys(value).filter((key) => !allowed.includes(key));
82
97
  }
@@ -110,10 +125,9 @@ export function declarationProblemsOf(subject: string, raw: unknown): readonly s
110
125
  }
111
126
  if (!Number.isInteger(version) || !SUPPORTED_API_VERSIONS.includes(version as number)) {
112
127
  const hint =
113
- version === 1
114
- ? ` —— v2 把 \`env\` 的三张表(command / plugins / libs)换成了 \`needs\`:` +
115
- '把外部需要逐条写成 `{ kind, name, purpose, ... }`(kind 是 bin / extension / lib / env),' +
116
- '再把 apiVersion 改成 2'
128
+ version === 2
129
+ ? ' —— v3 把卡片标题挪成了顶层 `title`(`{ "zh": "…", "en": "…" }` 字面量):' +
130
+ '把 `card.titleKey` 删掉、在顶层写 title,再把 apiVersion 改成 3'
117
131
  : '';
118
132
  return [
119
133
  `${subject}: dsh.workbench.apiVersion 是 ${JSON.stringify(version)},这里只认 ` +
@@ -140,6 +154,29 @@ export function declarationProblemsOf(subject: string, raw: unknown): readonly s
140
154
  }
141
155
  }
142
156
 
157
+ const title = raw.title;
158
+ if (title !== undefined) {
159
+ if (!isRecord(title)) {
160
+ problems.push(
161
+ `${subject}: dsh.workbench.title 要是对象(中英各一份),现在是 ${JSON.stringify(title)}`,
162
+ );
163
+ } else {
164
+ const extra = outsideKeys(title, TITLE_KEYS);
165
+ if (extra.length > 0) {
166
+ problems.push(
167
+ `${subject}: dsh.workbench.title 里有不认的键 ${listOf(extra)} —— title 只有 ${listOf(TITLE_KEYS)}`,
168
+ );
169
+ }
170
+ for (const language of TITLE_KEYS) {
171
+ if (text(title[language]) === '') {
172
+ problems.push(
173
+ `${subject}: dsh.workbench.title.${language} 要是非空字符串(市场和面板里显示的名字)`,
174
+ );
175
+ }
176
+ }
177
+ }
178
+ }
179
+
143
180
  const card = raw.card;
144
181
  if (card !== undefined && card !== null) {
145
182
  if (!isRecord(card)) {
@@ -149,12 +186,9 @@ export function declarationProblemsOf(subject: string, raw: unknown): readonly s
149
186
  if (extra.length > 0) {
150
187
  problems.push(
151
188
  `${subject}: dsh.workbench.card 里有不认的键 ${listOf(extra)} —— card 只有 ${listOf(CARD_KEYS)}` +
152
- `(卡片默认显不显示由运行期的面板配置决定,清单里没有这一位)`,
189
+ `(名字写在顶层的 title 里;卡片默认显不显示由运行期的面板配置决定,清单里没有这一位)`,
153
190
  );
154
191
  }
155
- if (text(card.titleKey) === '') {
156
- problems.push(`${subject}: dsh.workbench.card.titleKey 要是非空字符串(面板那一格的文案键)`);
157
- }
158
192
  if (typeof card.order !== 'number' || !Number.isFinite(card.order)) {
159
193
  problems.push(`${subject}: dsh.workbench.card.order 要是数字,现在是 ${JSON.stringify(card.order)}`);
160
194
  }
@@ -257,16 +291,18 @@ export function readDeclaration(subject: string, raw: unknown): DeclarationRead
257
291
  const needsRead = needsProblemsOf(subject, declaration.needs);
258
292
  if (needsRead.errors.length > 0) return { problems: needsRead.errors, warnings: needsRead.warnings };
259
293
  const card = declaration.card;
294
+ const id = text(declaration.unit);
260
295
  return {
261
296
  declaration,
262
297
  problems: [],
263
298
  manifest: {
264
- id: text(declaration.unit),
299
+ id,
300
+ title: { zh: text(declaration.title.zh), en: text(declaration.title.en) },
265
301
  card:
266
302
  card === null
267
303
  ? null
268
304
  : {
269
- titleKey: text(card.titleKey),
305
+ titleKey: unitTitleKey(id),
270
306
  order: card.order,
271
307
  },
272
308
  tools: declaration.tools.map((tool) => ({
@@ -1,5 +1,22 @@
1
1
  import type { ExtensionNeed, LibNeed, Need } from './needs.js';
2
2
 
3
+ /**
4
+ * 模块的人类可读名字:中英各一份,写在 package.json 的 dsh.workbench.title 里。
5
+ */
6
+ export interface UnitTitle {
7
+ readonly zh: string;
8
+ readonly en: string;
9
+ }
10
+
11
+ /**
12
+ * 外壳替模块名合成的词表键:模块自己不必写名字键,名字直接从清单里的 title 注册进来。
13
+ * @param unit 单元 id。
14
+ * @returns 该单元名字在词表里的键。
15
+ */
16
+ export function unitTitleKey(unit: string): string {
17
+ return `dsh-workbench.unit.${unit}.title`;
18
+ }
19
+
3
20
  /**
4
21
  * 一张卡片在清单里的静态声明(文案键加排序位);默认显不显示由运行期面板配置定。
5
22
  */
@@ -33,10 +50,11 @@ export interface PluginRelease {
33
50
  }
34
51
 
35
52
  /**
36
- * 装配期用的模块清单:单元 id、卡片、工具、宿主服务与环境需要,比声明少一层协议包装。
53
+ * 装配期用的模块清单:单元 id、名字、卡片、工具、宿主服务与环境需要,比声明少一层协议包装。
37
54
  */
38
55
  export interface UnitManifest {
39
56
  readonly id: string;
57
+ readonly title?: UnitTitle;
40
58
  readonly card: UnitCard | null;
41
59
  readonly tools: readonly UnitTool[];
42
60
  readonly host: UnitHostNeeds;
@@ -15,7 +15,9 @@ export type ProvisionAction =
15
15
  | 'cancel-install'
16
16
  | 'update-plugin'
17
17
  | 'uninstall-plugin'
18
- | 'update-workbench';
18
+ | 'update-workbench'
19
+ | 'migrate-workbench'
20
+ | 'dismiss-followup';
19
21
 
20
22
  /**
21
23
  * 宿主对「这个单元现在能不能用」的那份读数:状态汇总加逐条 need 的细节。
@@ -16,6 +16,8 @@ describe('provision 请求体:每类动作各有各的目标字段', () => {
16
16
  ['update-plugin', 'name'],
17
17
  ['uninstall-plugin', 'name'],
18
18
  ['update-workbench', 'name'],
19
+ ['migrate-workbench', 'name'],
20
+ ['dismiss-followup', 'id'],
19
21
  ]);
20
22
  });
21
23
 
@@ -2,12 +2,16 @@ import { describe, expect, it } from 'vitest';
2
2
  import type { ExtensionNeed, LibNeed, SupplySource } from '../../contract/needs.js';
3
3
  import { buildSupplyIndex, type SupplyDeclaration } from '../supply-index.js';
4
4
 
5
- function npm(pkg?: string, registryEnv = 'DSH_WB_REGISTRY'): SupplySource {
5
+ function npm(
6
+ pkg?: string,
7
+ registryEnv = 'DSH_WB_REGISTRY',
8
+ registryDefault = 'https://registry.invalid/',
9
+ ): SupplySource {
6
10
  return {
7
11
  kind: 'npm',
8
12
  ...(pkg === undefined ? {} : { package: pkg }),
9
13
  registryEnv,
10
- registryDefault: 'https://registry.invalid/',
14
+ registryDefault,
11
15
  };
12
16
  }
13
17
 
@@ -77,23 +81,55 @@ describe('供给索引:一个包只有一个来源', () => {
77
81
  expect(index.sourcesOf('extension', 'coding')).toEqual([npm('opencli-plugin-coding')]);
78
82
  });
79
83
 
80
- it('★★ 共用库两家用不同的 registryEnv:判冲突,即使地址相同', () => {
84
+ it('★★ 共用库两家用不同的 registryEnv、解析到同一个地址:收成一家,不算冲突', () => {
81
85
  const index = buildSupplyIndex([
82
86
  manifest('argocd', [lib('opencli-workflow', [npm(undefined, 'DSH_WORKBENCH_ARGOCD_REGISTRY')])]),
83
87
  manifest('cmdb', [lib('opencli-workflow', [npm(undefined, 'DSH_WORKBENCH_CMDB_REGISTRY')])]),
84
88
  ]);
85
89
 
90
+ expect(index.conflicts).toEqual([]);
91
+ expect(index.entryOf('lib', 'opencli-workflow')?.conflicted).toBe(false);
92
+ expect(index.entryOf('lib', 'opencli-workflow')?.declarers).toEqual(['argocd', 'cmdb']);
93
+ expect(index.sourcesOf('lib', 'opencli-workflow')).toHaveLength(1);
94
+ });
95
+
96
+ it('★★ registryEnv 名不同、其中一家被环境变量指到别处:判冲突', () => {
97
+ const index = buildSupplyIndex(
98
+ [
99
+ manifest('jenkins', [lib('opencli-workflow', [npm(undefined, 'DSH_WB_JENKINS_REGISTRY')])]),
100
+ manifest('argocd', [lib('opencli-workflow', [npm(undefined, 'DSH_WB_ARGOCD_REGISTRY')])]),
101
+ ],
102
+ { env: { DSH_WB_JENKINS_REGISTRY: 'https://mirror.invalid/' } },
103
+ );
104
+
105
+ expect(index.conflicts.map((item) => item.kind)).toEqual(['source-mismatch']);
106
+ expect(index.sourcesOf('lib', 'opencli-workflow')).toEqual([]);
107
+ });
108
+
109
+ it('★★ 两家默认源就不同:判冲突,哪怕 registryEnv 同名', () => {
110
+ const index = buildSupplyIndex([
111
+ manifest('a', [lib('opencli-workflow', [npm(undefined, 'DSH_WB_REGISTRY', 'https://r-a.invalid/')])]),
112
+ manifest('b', [lib('opencli-workflow', [npm(undefined, 'DSH_WB_REGISTRY', 'https://r-b.invalid/')])]),
113
+ ]);
114
+
86
115
  expect(index.conflicts).toHaveLength(1);
87
- expect(index.conflicts[0]?.kind).toBe('source-mismatch');
88
116
  expect(index.entryOf('lib', 'opencli-workflow')?.conflicted).toBe(true);
89
- expect(index.sourcesOf('lib', 'opencli-workflow')).toEqual([]);
117
+ });
118
+
119
+ it('★ 两家都没写默认源、registryEnv 又不同:解析不出地址,按名字判冲突', () => {
120
+ const index = buildSupplyIndex([
121
+ manifest('a', [lib('opencli-workflow', [{ kind: 'npm', registryEnv: 'DSH_WB_A_REGISTRY' }])]),
122
+ manifest('b', [lib('opencli-workflow', [{ kind: 'npm', registryEnv: 'DSH_WB_B_REGISTRY' }])]),
123
+ ]);
124
+
125
+ expect(index.conflicts).toHaveLength(1);
90
126
  });
91
127
 
92
128
  it('★★ 三家三个源:同一个包只报一条冲突,declarers 合并齐', () => {
93
129
  const index = buildSupplyIndex([
94
130
  manifest('a', [lib('opencli-workflow', [npm(undefined, 'DSH_WB_A_REGISTRY')])]),
95
131
  manifest('b', [lib('opencli-workflow', [npm(undefined, 'DSH_WB_B_REGISTRY')])]),
96
- manifest('c', [lib('opencli-workflow', [npm(undefined, 'DSH_WB_C_REGISTRY')])]),
132
+ manifest('c', [lib('opencli-workflow', [npm(undefined, 'DSH_WB_C_REGISTRY', 'https://mirror.invalid/')])]),
97
133
  ]);
98
134
 
99
135
  expect(index.conflicts).toHaveLength(1);
@@ -15,6 +15,8 @@ export const PROVISION_ACTIONS = [
15
15
  'update-plugin',
16
16
  'uninstall-plugin',
17
17
  'update-workbench',
18
+ 'migrate-workbench',
19
+ 'dismiss-followup',
18
20
  ] as const satisfies readonly ProvisionAction[];
19
21
 
20
22
  export const DEPENDENCY_ACTIONS = ['update-dependency'] as const satisfies readonly ProvisionAction[];
@@ -37,7 +39,8 @@ export function provisionTargetOf(action: ProvisionAction): ProvisionTarget {
37
39
  if (action === 'install-bundle' || action === 'install-version') return 'spec';
38
40
  if (action === 'remove-bundle') return 'name';
39
41
  if (action === 'update-plugin' || action === 'uninstall-plugin') return 'name';
40
- if (action === 'update-workbench') return 'name';
42
+ if (action === 'update-workbench' || action === 'migrate-workbench') return 'name';
43
+ if (action === 'dismiss-followup') return 'id';
41
44
  if (action === 'cancel-install') return 'requestId';
42
45
  return 'unit';
43
46
  }
@@ -81,7 +84,8 @@ function requestOf(action: ProvisionAction, value: string): ProvisionRequest {
81
84
  if (action === 'install-bundle' || action === 'install-version') return { action, spec: value };
82
85
  if (action === 'remove-bundle') return { action, name: value };
83
86
  if (action === 'update-plugin' || action === 'uninstall-plugin') return { action, name: value };
84
- if (action === 'update-workbench') return { action, name: value };
87
+ if (action === 'update-workbench' || action === 'migrate-workbench') return { action, name: value };
88
+ if (action === 'dismiss-followup') return { action, id: value };
85
89
  if (action === 'cancel-install') return { action, requestId: value };
86
90
  return { action, unit: value };
87
91
  }
@@ -1,10 +1,15 @@
1
1
  import type { ExtensionNeed, LibNeed, Need, SupplySource } from '../contract/needs.js';
2
+ import { npmRegistryOf } from './plugin-source.js';
2
3
 
3
4
  export interface SupplyDeclaration {
4
5
  readonly id: string;
5
6
  readonly needs?: readonly Need[];
6
7
  }
7
8
 
9
+ export interface SupplyIndexOptions {
10
+ readonly env?: Readonly<Record<string, string | undefined>>;
11
+ }
12
+
8
13
  export interface SupplyEntry {
9
14
  readonly key: string;
10
15
  readonly kind: 'extension' | 'lib';
@@ -81,16 +86,28 @@ function keyOf(kind: 'extension' | 'lib', pkg: string): string {
81
86
  return `${kind}:${pkg}`;
82
87
  }
83
88
 
84
- function sourceCompatible(left: SupplySource, right: SupplySource): boolean {
89
+ function registryAddressOf(
90
+ source: Extract<SupplySource, { kind: 'npm' }>,
91
+ env: Readonly<Record<string, string | undefined>>,
92
+ ): string | undefined {
93
+ const resolved = npmRegistryOf(source, env);
94
+ return resolved.ok ? resolved.value : undefined;
95
+ }
96
+
97
+ function sourceCompatible(
98
+ left: SupplySource,
99
+ right: SupplySource,
100
+ env: Readonly<Record<string, string | undefined>>,
101
+ ): boolean {
85
102
  if (left.kind !== right.kind) return false;
86
103
  if (left.kind === 'npm' && right.kind === 'npm') {
87
104
  if (text(left.package) !== '' && text(right.package) !== '' && text(left.package) !== text(right.package)) {
88
105
  return false;
89
106
  }
90
- if (text(left.registryEnv) !== text(right.registryEnv)) return false;
91
- const l = text(left.registryDefault);
92
- const r = text(right.registryDefault);
93
- return l === '' || r === '' || l === r;
107
+ const l = registryAddressOf(left, env);
108
+ const r = registryAddressOf(right, env);
109
+ if (l !== undefined && r !== undefined) return l === r;
110
+ return text(left.registryEnv) === text(right.registryEnv);
94
111
  }
95
112
  if (left.kind === 'git' && right.kind === 'git') return text(left.url) === text(right.url);
96
113
  if (left.kind === 'local' && right.kind === 'local') {
@@ -99,11 +116,15 @@ function sourceCompatible(left: SupplySource, right: SupplySource): boolean {
99
116
  return false;
100
117
  }
101
118
 
102
- function sourcesCompatible(left: readonly SupplySource[], right: readonly SupplySource[]): boolean {
119
+ function sourcesCompatible(
120
+ left: readonly SupplySource[],
121
+ right: readonly SupplySource[],
122
+ env: Readonly<Record<string, string | undefined>>,
123
+ ): boolean {
103
124
  if (left.length !== right.length) return false;
104
125
  return left.every((source, index) => {
105
126
  const other = right[index];
106
- return other !== undefined && sourceCompatible(source, other);
127
+ return other !== undefined && sourceCompatible(source, other, env);
107
128
  });
108
129
  }
109
130
 
@@ -120,13 +141,17 @@ function mergedSource(left: SupplySource, right: SupplySource): SupplySource {
120
141
  return left;
121
142
  }
122
143
 
123
- function mergeSources(entry: MutableEntry, sources: readonly SupplySource[]): boolean {
144
+ function mergeSources(
145
+ entry: MutableEntry,
146
+ sources: readonly SupplySource[],
147
+ env: Readonly<Record<string, string | undefined>>,
148
+ ): boolean {
124
149
  if (!entry.declared) {
125
150
  entry.declared = true;
126
151
  entry.sources = [...sources];
127
152
  return true;
128
153
  }
129
- if (!sourcesCompatible(entry.sources, sources)) {
154
+ if (!sourcesCompatible(entry.sources, sources, env)) {
130
155
  entry.conflicted = true;
131
156
  return false;
132
157
  }
@@ -150,7 +175,11 @@ function addDeclarer(entry: MutableEntry, unit: string, commands: readonly strin
150
175
  for (const command of commands) if (!entry.commands.includes(command)) entry.commands.push(command);
151
176
  }
152
177
 
153
- export function buildSupplyIndex(declarations: readonly SupplyDeclaration[]): SupplyIndex {
178
+ export function buildSupplyIndex(
179
+ declarations: readonly SupplyDeclaration[],
180
+ options: SupplyIndexOptions = {},
181
+ ): SupplyIndex {
182
+ const env = options.env ?? {};
154
183
  const entries = new Map<string, MutableEntry>();
155
184
  const byName = new Map<string, MutableEntry>();
156
185
  const conflicts: SupplyConflict[] = [];
@@ -186,7 +215,7 @@ export function buildSupplyIndex(declarations: readonly SupplyDeclaration[]): Su
186
215
  } else {
187
216
  addDeclarer(entry, declaration.id, []);
188
217
  }
189
- const same = mergeSources(entry, need.sources ?? []);
218
+ const same = mergeSources(entry, need.sources ?? [], env);
190
219
  if (!same) {
191
220
  conflicts.push({
192
221
  kind: 'source-mismatch',