@dsh-workbench/shared 1.1.6 → 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.
- package/dist/contract/needs.d.ts +3 -0
- package/dist/contract/needs.js +17 -0
- package/dist/host/lib-runners.d.ts +20 -0
- package/dist/host/lib-runners.js +46 -0
- package/dist/host/opencli-runner.d.ts +25 -0
- package/dist/host/opencli-runner.js +50 -0
- package/dist/host.d.ts +2 -0
- package/dist/host.js +2 -0
- package/package.json +1 -1
- package/src/contract/__tests__/needs.test.ts +8 -0
- package/src/contract/needs.ts +15 -0
- package/src/host/__tests__/lib-runners.test.ts +82 -0
- package/src/host/__tests__/opencli-runner.test.ts +120 -0
- package/src/host/lib-runners.ts +70 -0
- package/src/host/opencli-runner.ts +76 -0
- package/src/host.ts +2 -0
package/dist/contract/needs.d.ts
CHANGED
|
@@ -76,12 +76,15 @@ export interface ExtensionNeed extends NeedCommon {
|
|
|
76
76
|
}
|
|
77
77
|
/**
|
|
78
78
|
* 要在隔离运行时里有一个共用依赖包。
|
|
79
|
+
* `runs` 写它自己会起哪些运行时的命令(如 `["opencli"]`)—— 写了外壳就把那份执行器注入进去
|
|
80
|
+
* (库侧约定 `setOpencliRunner`);不写就按它自己在 PATH 上找。
|
|
79
81
|
*/
|
|
80
82
|
export interface LibNeed extends NeedCommon {
|
|
81
83
|
readonly kind: 'lib';
|
|
82
84
|
readonly name: string;
|
|
83
85
|
readonly version?: string;
|
|
84
86
|
readonly sources?: readonly SupplySource[];
|
|
87
|
+
readonly runs?: readonly string[] | undefined;
|
|
85
88
|
}
|
|
86
89
|
/**
|
|
87
90
|
* 跑起来时要有这个环境变量;`secret` 为真时任何地方都不回显它的值。
|
package/dist/contract/needs.js
CHANGED
|
@@ -22,6 +22,7 @@ export const NEED_KEYS = [
|
|
|
22
22
|
'secret',
|
|
23
23
|
'install',
|
|
24
24
|
'docs',
|
|
25
|
+
'runs',
|
|
25
26
|
];
|
|
26
27
|
const SOURCE_KEYS = ['kind', 'package', 'registryEnv', 'registryDefault', 'url', 'ref', 'path', 'env'];
|
|
27
28
|
const REGISTRY_PATTERN = /^(?:https?|file):\/\//;
|
|
@@ -149,6 +150,20 @@ export function binInstallProblemsOf(raw, at) {
|
|
|
149
150
|
}
|
|
150
151
|
return errors;
|
|
151
152
|
}
|
|
153
|
+
function libRunsProblemsOf(raw, at) {
|
|
154
|
+
if (raw === undefined)
|
|
155
|
+
return [];
|
|
156
|
+
if (!Array.isArray(raw))
|
|
157
|
+
return [`${at}.runs 要是数组`];
|
|
158
|
+
if (raw.length === 0)
|
|
159
|
+
return [`${at}.runs 是空数组 —— 不自己跑命令就整条删掉`];
|
|
160
|
+
const names = raw.map((one) => text(one));
|
|
161
|
+
if (names.some((one) => one === ''))
|
|
162
|
+
return [`${at}.runs 里有空字符串`];
|
|
163
|
+
if (new Set(names).size !== names.length)
|
|
164
|
+
return [`${at}.runs 里有重复的运行时`];
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
152
167
|
function perKindProblemsOf(kind, raw, at) {
|
|
153
168
|
const errors = [];
|
|
154
169
|
if (kind === 'bin' || kind === 'lib' || kind === 'env') {
|
|
@@ -159,6 +174,8 @@ function perKindProblemsOf(kind, raw, at) {
|
|
|
159
174
|
errors.push(...binInstallProblemsOf(raw['install'], at));
|
|
160
175
|
if (raw['docs'] !== undefined && text(raw['docs']) === '')
|
|
161
176
|
errors.push(`${at}.docs 是空串`);
|
|
177
|
+
if (kind === 'lib')
|
|
178
|
+
errors.push(...libRunsProblemsOf(raw['runs'], at));
|
|
162
179
|
if (kind === 'extension') {
|
|
163
180
|
if (text(raw['runtime']) === '')
|
|
164
181
|
errors.push(`${at}.runtime 没写 —— 扩展属于哪个运行时(如 opencli)`);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LibReader } from '../contract/lib.js';
|
|
2
|
+
import type { OpencliRunner } from './opencli-runner.js';
|
|
3
|
+
export interface LibRuntimes {
|
|
4
|
+
readonly opencli?: OpencliRunner | undefined;
|
|
5
|
+
}
|
|
6
|
+
interface Logger {
|
|
7
|
+
warn?(message: string): void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* 给声明了 `runs` 的 lib 注入「跑这个运行时」的执行器:库侧按约定暴露 `setOpencliRunner`
|
|
11
|
+
* (运行时名字首字母大写 + `Runner`),外壳在每次 `load` 之后把执行器交给它。
|
|
12
|
+
*
|
|
13
|
+
* 没暴露的(版本旧)就保持它自己的行为(按 PATH 找),只记一条 warn;不熟悉的运行时同样只 warn。
|
|
14
|
+
* @param libs 原来的读库口。
|
|
15
|
+
* @param runtimes 外壳手上有的执行器,按运行时名字给。
|
|
16
|
+
* @param logger 记 warn 的地方(默认不记)。
|
|
17
|
+
* @returns 包好的读库口;`load` 的签名与返回不变。
|
|
18
|
+
*/
|
|
19
|
+
export declare function withLibRunners(libs: LibReader, runtimes: LibRuntimes, logger?: Logger): LibReader;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
function runnerOf(runtimes, runtime) {
|
|
2
|
+
return runtime === 'opencli' ? runtimes.opencli : undefined;
|
|
3
|
+
}
|
|
4
|
+
function setterNameOf(runtime) {
|
|
5
|
+
return `set${runtime.charAt(0).toUpperCase()}${runtime.slice(1)}Runner`;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* 给声明了 `runs` 的 lib 注入「跑这个运行时」的执行器:库侧按约定暴露 `setOpencliRunner`
|
|
9
|
+
* (运行时名字首字母大写 + `Runner`),外壳在每次 `load` 之后把执行器交给它。
|
|
10
|
+
*
|
|
11
|
+
* 没暴露的(版本旧)就保持它自己的行为(按 PATH 找),只记一条 warn;不熟悉的运行时同样只 warn。
|
|
12
|
+
* @param libs 原来的读库口。
|
|
13
|
+
* @param runtimes 外壳手上有的执行器,按运行时名字给。
|
|
14
|
+
* @param logger 记 warn 的地方(默认不记)。
|
|
15
|
+
* @returns 包好的读库口;`load` 的签名与返回不变。
|
|
16
|
+
*/
|
|
17
|
+
export function withLibRunners(libs, runtimes, logger) {
|
|
18
|
+
const warned = new Set();
|
|
19
|
+
const warnOnce = (key, message) => {
|
|
20
|
+
if (warned.has(key))
|
|
21
|
+
return;
|
|
22
|
+
warned.add(key);
|
|
23
|
+
logger?.warn?.(message);
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
async load(need, entry) {
|
|
27
|
+
const module = await libs.load(need, entry);
|
|
28
|
+
for (const runtime of need.runs ?? []) {
|
|
29
|
+
const runner = runnerOf(runtimes, runtime);
|
|
30
|
+
if (runner === undefined) {
|
|
31
|
+
warnOnce(`${need.name}:${runtime}:unsupported`, `[dsh-workbench] ${need.name} 声明了自己会跑 ${runtime},但工作台没有这个执行器 —— 它会按自己的方式找`);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const setter = typeof module === 'object' && module !== null
|
|
35
|
+
? module[setterNameOf(runtime)]
|
|
36
|
+
: undefined;
|
|
37
|
+
if (typeof setter !== 'function') {
|
|
38
|
+
warnOnce(`${need.name}:${entry}:${runtime}:missing`, `[dsh-workbench] ${need.name} 声明了自己会跑 ${runtime},但这一版没有 ${setterNameOf(runtime)} —— 它会按 PATH 找`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
setter.call(module, runner);
|
|
42
|
+
}
|
|
43
|
+
return module;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { DataRequest, DataResult } from '../contract/data.js';
|
|
2
|
+
/**
|
|
3
|
+
* 一条 opencli 命令的执行器:同一形状的 `execFile('opencli', args)`,结果只留 stdout。
|
|
4
|
+
*/
|
|
5
|
+
export type OpencliRunner = (args: readonly string[]) => Promise<{
|
|
6
|
+
readonly stdout: string;
|
|
7
|
+
}>;
|
|
8
|
+
/**
|
|
9
|
+
* 读缝(`DataReader.read`)的形状;模块直接把自己的 `data.read` 传进来即可。
|
|
10
|
+
*/
|
|
11
|
+
export type OpencliRead = (request: DataRequest) => Promise<DataResult<unknown>>;
|
|
12
|
+
export interface OpencliRunnerOptions {
|
|
13
|
+
readonly timeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 把读缝包成一条 opencli 命令的执行器:给还按 `execFile('opencli', args)` 收结果的编排库用,
|
|
17
|
+
* 让它们跑在面板用的那份 opencli 上(同一套插件、同一份登录态)。
|
|
18
|
+
*
|
|
19
|
+
* 失败按 opencli 自己的约定还原:未登录是 `code: AUTH_REQUIRED` + 退出码 77,编排库的
|
|
20
|
+
* 「等登录再重试」才接得上;其它失败保留 `Command failed: opencli …` 那句外壳。
|
|
21
|
+
* @param read 模块手里的读缝(`data.read`)。
|
|
22
|
+
* @param options `timeoutMs` 透传给读缝;不传就用读缝自己的默认值。
|
|
23
|
+
* @returns 执行器;直接交给编排库的 `setOpencliRunner`。
|
|
24
|
+
*/
|
|
25
|
+
export declare function opencliRunnerOf(read: OpencliRead, options?: OpencliRunnerOptions): OpencliRunner;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const AUTH_REQUIRED_EXIT_CODE = 77;
|
|
2
|
+
function textOf(value) {
|
|
3
|
+
return typeof value === 'string' ? value : '';
|
|
4
|
+
}
|
|
5
|
+
function failedOf(args, error) {
|
|
6
|
+
const head = `Command failed: opencli ${args.join(' ')}`;
|
|
7
|
+
const auth = error?.pageCode === 'auth-required';
|
|
8
|
+
const helpUrl = textOf(error?.helpUrl);
|
|
9
|
+
const detail = textOf(error?.detail);
|
|
10
|
+
const hint = textOf(error?.hint);
|
|
11
|
+
const failed = new Error([
|
|
12
|
+
head,
|
|
13
|
+
...(auth
|
|
14
|
+
? ['code: AUTH_REQUIRED', `message: ${textOf(error?.message)}`]
|
|
15
|
+
: [textOf(error?.message) || 'opencli 这条命令没跑通']),
|
|
16
|
+
...(detail === '' ? [] : [detail]),
|
|
17
|
+
...(hint === '' ? [] : [hint]),
|
|
18
|
+
...(auth && helpUrl !== ''
|
|
19
|
+
? [`help: Please open Chrome or Chromium and log in to ${helpUrl}`]
|
|
20
|
+
: []),
|
|
21
|
+
].join('\n'));
|
|
22
|
+
if (auth)
|
|
23
|
+
failed.code = AUTH_REQUIRED_EXIT_CODE;
|
|
24
|
+
return failed;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 把读缝包成一条 opencli 命令的执行器:给还按 `execFile('opencli', args)` 收结果的编排库用,
|
|
28
|
+
* 让它们跑在面板用的那份 opencli 上(同一套插件、同一份登录态)。
|
|
29
|
+
*
|
|
30
|
+
* 失败按 opencli 自己的约定还原:未登录是 `code: AUTH_REQUIRED` + 退出码 77,编排库的
|
|
31
|
+
* 「等登录再重试」才接得上;其它失败保留 `Command failed: opencli …` 那句外壳。
|
|
32
|
+
* @param read 模块手里的读缝(`data.read`)。
|
|
33
|
+
* @param options `timeoutMs` 透传给读缝;不传就用读缝自己的默认值。
|
|
34
|
+
* @returns 执行器;直接交给编排库的 `setOpencliRunner`。
|
|
35
|
+
*/
|
|
36
|
+
export function opencliRunnerOf(read, options = {}) {
|
|
37
|
+
return async (args) => {
|
|
38
|
+
const result = await read({
|
|
39
|
+
kind: 'command',
|
|
40
|
+
adapter: 'opencli',
|
|
41
|
+
argv: [...args],
|
|
42
|
+
...(options.timeoutMs === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: { timeoutMs: options.timeoutMs }),
|
|
45
|
+
});
|
|
46
|
+
if (result.ok)
|
|
47
|
+
return { stdout: JSON.stringify(result.data) };
|
|
48
|
+
throw failedOf(args, result.error);
|
|
49
|
+
};
|
|
50
|
+
}
|
package/dist/host.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export * from './host/tokens.js';
|
|
|
34
34
|
export * from './host/tool-approval.js';
|
|
35
35
|
export * from './host/tool-kit.js';
|
|
36
36
|
export * from './host/unit-face.js';
|
|
37
|
+
export * from './host/opencli-runner.js';
|
|
38
|
+
export * from './host/lib-runners.js';
|
|
37
39
|
export * from './host/value-handoff.js';
|
|
38
40
|
export * from './host/workspace-store.js';
|
|
39
41
|
export * from './host/write-result.js';
|
package/dist/host.js
CHANGED
|
@@ -34,6 +34,8 @@ export * from './host/tokens.js';
|
|
|
34
34
|
export * from './host/tool-approval.js';
|
|
35
35
|
export * from './host/tool-kit.js';
|
|
36
36
|
export * from './host/unit-face.js';
|
|
37
|
+
export * from './host/opencli-runner.js';
|
|
38
|
+
export * from './host/lib-runners.js';
|
|
37
39
|
export * from './host/value-handoff.js';
|
|
38
40
|
export * from './host/workspace-store.js';
|
|
39
41
|
export * from './host/write-result.js';
|
package/package.json
CHANGED
|
@@ -114,6 +114,14 @@ describe('needs 校验矩阵', () => {
|
|
|
114
114
|
expect(errorsOf({ ...base, id: '' }).join(' ')).toContain('id 是空串');
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
it('lib 声明 runs(会自己起运行时的命令)→ 过;写歪了判红并点名', () => {
|
|
118
|
+
expect(errorsOf({ ...base, runs: ['opencli'] })).toEqual([]);
|
|
119
|
+
expect(errorsOf({ ...base, runs: 'opencli' }).join(' ')).toContain('.runs 要是数组');
|
|
120
|
+
expect(errorsOf({ ...base, runs: [] }).join(' ')).toContain('空数组');
|
|
121
|
+
expect(errorsOf({ ...base, runs: [''] }).join(' ')).toContain('空字符串');
|
|
122
|
+
expect(errorsOf({ ...base, runs: ['opencli', 'opencli'] }).join(' ')).toContain('重复');
|
|
123
|
+
});
|
|
124
|
+
|
|
117
125
|
it('★ need 内未知字段只是警告;x- 前缀直接忽略(不为可选字段升协议版本)', () => {
|
|
118
126
|
const read = needProblemsOf('demo', { ...base, future: 1, 'x-vendor': true }, 0);
|
|
119
127
|
expect(read.errors).toEqual([]);
|
package/src/contract/needs.ts
CHANGED
|
@@ -82,12 +82,15 @@ export interface ExtensionNeed extends NeedCommon {
|
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
84
|
* 要在隔离运行时里有一个共用依赖包。
|
|
85
|
+
* `runs` 写它自己会起哪些运行时的命令(如 `["opencli"]`)—— 写了外壳就把那份执行器注入进去
|
|
86
|
+
* (库侧约定 `setOpencliRunner`);不写就按它自己在 PATH 上找。
|
|
85
87
|
*/
|
|
86
88
|
export interface LibNeed extends NeedCommon {
|
|
87
89
|
readonly kind: 'lib';
|
|
88
90
|
readonly name: string;
|
|
89
91
|
readonly version?: string;
|
|
90
92
|
readonly sources?: readonly SupplySource[];
|
|
93
|
+
readonly runs?: readonly string[] | undefined;
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
/**
|
|
@@ -128,6 +131,7 @@ export const NEED_KEYS: readonly string[] = [
|
|
|
128
131
|
'secret',
|
|
129
132
|
'install',
|
|
130
133
|
'docs',
|
|
134
|
+
'runs',
|
|
131
135
|
];
|
|
132
136
|
|
|
133
137
|
/**
|
|
@@ -267,6 +271,16 @@ export function binInstallProblemsOf(raw: unknown, at: string): readonly string[
|
|
|
267
271
|
return errors;
|
|
268
272
|
}
|
|
269
273
|
|
|
274
|
+
function libRunsProblemsOf(raw: unknown, at: string): readonly string[] {
|
|
275
|
+
if (raw === undefined) return [];
|
|
276
|
+
if (!Array.isArray(raw)) return [`${at}.runs 要是数组`];
|
|
277
|
+
if (raw.length === 0) return [`${at}.runs 是空数组 —— 不自己跑命令就整条删掉`];
|
|
278
|
+
const names = raw.map((one) => text(one));
|
|
279
|
+
if (names.some((one) => one === '')) return [`${at}.runs 里有空字符串`];
|
|
280
|
+
if (new Set(names).size !== names.length) return [`${at}.runs 里有重复的运行时`];
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
|
|
270
284
|
function perKindProblemsOf(
|
|
271
285
|
kind: NeedKind,
|
|
272
286
|
raw: Readonly<Record<string, unknown>>,
|
|
@@ -278,6 +292,7 @@ function perKindProblemsOf(
|
|
|
278
292
|
}
|
|
279
293
|
if (kind === 'bin') errors.push(...binInstallProblemsOf(raw['install'], at));
|
|
280
294
|
if (raw['docs'] !== undefined && text(raw['docs']) === '') errors.push(`${at}.docs 是空串`);
|
|
295
|
+
if (kind === 'lib') errors.push(...libRunsProblemsOf(raw['runs'], at));
|
|
281
296
|
if (kind === 'extension') {
|
|
282
297
|
if (text(raw['runtime']) === '') errors.push(`${at}.runtime 没写 —— 扩展属于哪个运行时(如 opencli)`);
|
|
283
298
|
if (text(raw['name']) === '') errors.push(`${at}.name 没写`);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { LibNeed } from '../../contract/needs.js';
|
|
3
|
+
import type { LibReader } from '../../contract/lib.js';
|
|
4
|
+
import type { OpencliRunner } from '../opencli-runner.js';
|
|
5
|
+
import { withLibRunners } from '../lib-runners.js';
|
|
6
|
+
|
|
7
|
+
const runner: OpencliRunner = async () => ({ stdout: '[]' });
|
|
8
|
+
|
|
9
|
+
function needOf(runs?: readonly string[]): LibNeed {
|
|
10
|
+
return {
|
|
11
|
+
kind: 'lib',
|
|
12
|
+
name: 'opencli-workflow',
|
|
13
|
+
purpose: '发布编排',
|
|
14
|
+
...(runs === undefined ? {} : { runs }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function libsOf(module: unknown): LibReader {
|
|
19
|
+
return {
|
|
20
|
+
async load<T = unknown>(): Promise<T> {
|
|
21
|
+
return module as T;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function logger(): { readonly warn: ReturnType<typeof vi.fn<(message: string) => void>> } {
|
|
27
|
+
return { warn: vi.fn<(message: string) => void>() };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('withLibRunners', () => {
|
|
31
|
+
it('没声明 runs → 原样返回,不注入、不记 warn', async () => {
|
|
32
|
+
const module = { setOpencliRunner: vi.fn() };
|
|
33
|
+
const warn = logger();
|
|
34
|
+
const libs = withLibRunners(libsOf(module), { opencli: runner }, warn);
|
|
35
|
+
|
|
36
|
+
await expect(libs.load(needOf(), 'src/utils.js')).resolves.toBe(module);
|
|
37
|
+
expect(module.setOpencliRunner).not.toHaveBeenCalled();
|
|
38
|
+
expect(warn.warn).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('声明了 opencli 且库有 setOpencliRunner → 把执行器交进去', async () => {
|
|
42
|
+
const module = { setOpencliRunner: vi.fn() };
|
|
43
|
+
const warn = logger();
|
|
44
|
+
const libs = withLibRunners(libsOf(module), { opencli: runner }, warn);
|
|
45
|
+
|
|
46
|
+
await expect(libs.load(needOf(['opencli']), 'src/utils.js')).resolves.toBe(module);
|
|
47
|
+
expect(module.setOpencliRunner).toHaveBeenCalledWith(runner);
|
|
48
|
+
expect(warn.warn).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('库版本旧、没有 setter → 不注入,只 warn 一次', async () => {
|
|
52
|
+
const module = {};
|
|
53
|
+
const warn = logger();
|
|
54
|
+
const libs = withLibRunners(libsOf(module), { opencli: runner }, warn);
|
|
55
|
+
|
|
56
|
+
await libs.load(needOf(['opencli']), 'src/utils.js');
|
|
57
|
+
await libs.load(needOf(['opencli']), 'src/utils.js');
|
|
58
|
+
|
|
59
|
+
expect(warn.warn).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(String(warn.warn.mock.calls[0]?.[0])).toContain('没有 setOpencliRunner');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('声明了外壳没有的运行时 → 不注入,只 warn 一次', async () => {
|
|
64
|
+
const module = { setGitRunner: vi.fn() };
|
|
65
|
+
const warn = logger();
|
|
66
|
+
const libs = withLibRunners(libsOf(module), { opencli: runner }, warn);
|
|
67
|
+
|
|
68
|
+
await libs.load(needOf(['git']), 'src/utils.js');
|
|
69
|
+
|
|
70
|
+
expect(module.setGitRunner).not.toHaveBeenCalled();
|
|
71
|
+
expect(warn.warn).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(String(warn.warn.mock.calls[0]?.[0])).toContain('没有这个执行器');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('模块导出不是对象(函数 / 原始值)也不炸', async () => {
|
|
76
|
+
const warn = logger();
|
|
77
|
+
const libs = withLibRunners(libsOf(() => undefined), { opencli: runner }, warn);
|
|
78
|
+
|
|
79
|
+
await expect(libs.load(needOf(['opencli']), 'src/utils.js')).resolves.toBeTypeOf('function');
|
|
80
|
+
expect(warn.warn).toHaveBeenCalledTimes(1);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { DataError, DataRequest, DataResult } from '../contract/data.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 一条 opencli 命令的执行器:同一形状的 `execFile('opencli', args)`,结果只留 stdout。
|
|
5
|
+
*/
|
|
6
|
+
export type OpencliRunner = (
|
|
7
|
+
args: readonly string[],
|
|
8
|
+
) => Promise<{ readonly stdout: string }>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 读缝(`DataReader.read`)的形状;模块直接把自己的 `data.read` 传进来即可。
|
|
12
|
+
*/
|
|
13
|
+
export type OpencliRead = (
|
|
14
|
+
request: DataRequest,
|
|
15
|
+
) => Promise<DataResult<unknown>>;
|
|
16
|
+
|
|
17
|
+
export interface OpencliRunnerOptions {
|
|
18
|
+
readonly timeoutMs?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const AUTH_REQUIRED_EXIT_CODE = 77;
|
|
22
|
+
|
|
23
|
+
function textOf(value: unknown): string {
|
|
24
|
+
return typeof value === 'string' ? value : '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function failedOf(args: readonly string[], error: DataError | undefined): Error {
|
|
28
|
+
const head = `Command failed: opencli ${args.join(' ')}`;
|
|
29
|
+
const auth = error?.pageCode === 'auth-required';
|
|
30
|
+
const helpUrl = textOf(error?.helpUrl);
|
|
31
|
+
const detail = textOf(error?.detail);
|
|
32
|
+
const hint = textOf(error?.hint);
|
|
33
|
+
const failed = new Error(
|
|
34
|
+
[
|
|
35
|
+
head,
|
|
36
|
+
...(auth
|
|
37
|
+
? ['code: AUTH_REQUIRED', `message: ${textOf(error?.message)}`]
|
|
38
|
+
: [textOf(error?.message) || 'opencli 这条命令没跑通']),
|
|
39
|
+
...(detail === '' ? [] : [detail]),
|
|
40
|
+
...(hint === '' ? [] : [hint]),
|
|
41
|
+
...(auth && helpUrl !== ''
|
|
42
|
+
? [`help: Please open Chrome or Chromium and log in to ${helpUrl}`]
|
|
43
|
+
: []),
|
|
44
|
+
].join('\n'),
|
|
45
|
+
);
|
|
46
|
+
if (auth) (failed as Error & { code?: number }).code = AUTH_REQUIRED_EXIT_CODE;
|
|
47
|
+
return failed;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 把读缝包成一条 opencli 命令的执行器:给还按 `execFile('opencli', args)` 收结果的编排库用,
|
|
52
|
+
* 让它们跑在面板用的那份 opencli 上(同一套插件、同一份登录态)。
|
|
53
|
+
*
|
|
54
|
+
* 失败按 opencli 自己的约定还原:未登录是 `code: AUTH_REQUIRED` + 退出码 77,编排库的
|
|
55
|
+
* 「等登录再重试」才接得上;其它失败保留 `Command failed: opencli …` 那句外壳。
|
|
56
|
+
* @param read 模块手里的读缝(`data.read`)。
|
|
57
|
+
* @param options `timeoutMs` 透传给读缝;不传就用读缝自己的默认值。
|
|
58
|
+
* @returns 执行器;直接交给编排库的 `setOpencliRunner`。
|
|
59
|
+
*/
|
|
60
|
+
export function opencliRunnerOf(
|
|
61
|
+
read: OpencliRead,
|
|
62
|
+
options: OpencliRunnerOptions = {},
|
|
63
|
+
): OpencliRunner {
|
|
64
|
+
return async (args) => {
|
|
65
|
+
const result = await read({
|
|
66
|
+
kind: 'command',
|
|
67
|
+
adapter: 'opencli',
|
|
68
|
+
argv: [...args],
|
|
69
|
+
...(options.timeoutMs === undefined
|
|
70
|
+
? {}
|
|
71
|
+
: { timeoutMs: options.timeoutMs }),
|
|
72
|
+
});
|
|
73
|
+
if (result.ok) return { stdout: JSON.stringify(result.data) };
|
|
74
|
+
throw failedOf(args, result.error);
|
|
75
|
+
};
|
|
76
|
+
}
|
package/src/host.ts
CHANGED
|
@@ -34,6 +34,8 @@ export * from './host/tokens.js';
|
|
|
34
34
|
export * from './host/tool-approval.js';
|
|
35
35
|
export * from './host/tool-kit.js';
|
|
36
36
|
export * from './host/unit-face.js';
|
|
37
|
+
export * from './host/opencli-runner.js';
|
|
38
|
+
export * from './host/lib-runners.js';
|
|
37
39
|
export * from './host/value-handoff.js';
|
|
38
40
|
export * from './host/workspace-store.js';
|
|
39
41
|
export * from './host/write-result.js';
|