openfeel 1.2.4 → 1.3.1
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/README.en.md +40 -74
- package/README.md +39 -62
- package/README.zh-CN.md +40 -74
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +90 -5
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +7 -2
- package/dist/commands/update.js.map +1 -1
- package/dist/core/config.js +2 -2
- package/dist/core/dsh-profile.d.ts +93 -0
- package/dist/core/dsh-profile.d.ts.map +1 -0
- package/dist/core/dsh-profile.js +400 -0
- package/dist/core/dsh-profile.js.map +1 -0
- package/dist/core/i18n-data/en.d.ts.map +1 -1
- package/dist/core/i18n-data/en.js +21 -2
- package/dist/core/i18n-data/en.js.map +1 -1
- package/dist/core/i18n-data/zh-CN.d.ts.map +1 -1
- package/dist/core/i18n-data/zh-CN.js +21 -2
- package/dist/core/i18n-data/zh-CN.js.map +1 -1
- package/dist/core/setup.d.ts +12 -1
- package/dist/core/setup.d.ts.map +1 -1
- package/dist/core/setup.js +25 -10
- package/dist/core/setup.js.map +1 -1
- package/dist/core/template-loader.d.ts.map +1 -1
- package/dist/core/template-loader.js +18 -9
- package/dist/core/template-loader.js.map +1 -1
- package/dist/core/update.d.ts +2 -0
- package/dist/core/update.d.ts.map +1 -1
- package/dist/core/update.js +25 -7
- package/dist/core/update.js.map +1 -1
- package/dsh/ACTION-GUIDE.md +366 -0
- package/dsh/ADAPTER.md +65 -0
- package/dsh/README.md +264 -0
- package/dsh/cordis.patch.yml +26 -0
- package/dsh/docs/compat/commands.md +873 -0
- package/dsh/docs/compat/kb-skills.md +883 -0
- package/dsh/docs/compat/persistence.md +1013 -0
- package/dsh/docs/compat/pipeline.md +1565 -0
- package/dsh/docs/compat/req.md +1243 -0
- package/dsh/docs/compat/stage.md +1437 -0
- package/dsh/lib/adapters/cli.js +624 -0
- package/dsh/lib/adapters/dashboard.js +393 -0
- package/dsh/lib/adapters/workspace.js +150 -0
- package/dsh/lib/index.js +189 -0
- package/dsh/lib/surface/command.js +186 -0
- package/dsh/lib/surface/prompt.js +131 -0
- package/dsh/lib/tools/index.js +1245 -0
- package/dsh/lib/tools/shared.js +320 -0
- package/dsh/package.json +45 -0
- package/package.json +9 -2
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenFeel × DSH 适配器 — CLI 执行器
|
|
3
|
+
*
|
|
4
|
+
* 本适配器**不重新实现** OpenFeel 的领域逻辑。所有读写都经由真实
|
|
5
|
+
* `openfeel` CLI 完成:插件只负责把 DSH 的 agent / 用户意图翻译成
|
|
6
|
+
* CLI 调用,再把结构化结果翻译回工具结果。
|
|
7
|
+
*
|
|
8
|
+
* 为什么这样做:
|
|
9
|
+
* - OpenFeel 的 `flow.json` 有 revision 乐观并发、锁、日志、跨文件对账等契约;
|
|
10
|
+
* 绕过 CLI 直接写盘会破坏这些不变量。
|
|
11
|
+
* - 门禁判定(phase 转移表、需求 done 门、REV 阻塞)是单一权威,必须在 CLI 侧。
|
|
12
|
+
* - 插件因此可以保持**零运行时依赖**,并且天然跟随 CLI 升级。
|
|
13
|
+
*
|
|
14
|
+
* 项目定位:CLI 用 `process.cwd()` 解析项目根,因此这里用 `cwd`
|
|
15
|
+
* 把每一次调用锚定到当前会话的工作区目录。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { existsSync, statSync } from 'node:fs';
|
|
20
|
+
import { delimiter, dirname, isAbsolute, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
/* ────────────────────────── 错误模型 ────────────────────────── */
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 适配器错误分类。
|
|
26
|
+
* @typedef {'refusal'|'internal'|'usage'|'cli-missing'|'timeout'} FeelErrorKind
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 适配器统一错误。
|
|
31
|
+
*
|
|
32
|
+
* `refusal` 是**预期结果**(门禁未过、状态不合法),调用方应据此纠正;
|
|
33
|
+
* 其它分类是环境/参数故障。
|
|
34
|
+
*/
|
|
35
|
+
export class FeelError extends Error {
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} message
|
|
38
|
+
* @param {object} [options]
|
|
39
|
+
* @param {FeelErrorKind} [options.kind]
|
|
40
|
+
* @param {string} [options.code]
|
|
41
|
+
* @param {Record<string, unknown>} [options.detail]
|
|
42
|
+
* @param {unknown} [options.cause]
|
|
43
|
+
*/
|
|
44
|
+
constructor(message, options = {}) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = 'FeelError';
|
|
47
|
+
this.kind = options.kind ?? 'refusal';
|
|
48
|
+
this.code = options.code ?? 'FEEL_ERROR';
|
|
49
|
+
this.detail = options.detail ?? {};
|
|
50
|
+
if (options.cause !== undefined) this.cause = options.cause;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 抛出门禁/状态拒绝(预期结果)。
|
|
56
|
+
* @param {string} message
|
|
57
|
+
* @param {string} [code]
|
|
58
|
+
* @param {Record<string, unknown>} [detail]
|
|
59
|
+
* @returns {never}
|
|
60
|
+
*/
|
|
61
|
+
export function refuse(message, code = 'FEEL_REFUSED', detail) {
|
|
62
|
+
throw new FeelError(message, { kind: 'refusal', code, detail });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 抛出参数非法。
|
|
67
|
+
* @param {string} message
|
|
68
|
+
* @param {string} [code]
|
|
69
|
+
* @returns {never}
|
|
70
|
+
*/
|
|
71
|
+
export function badUsage(message, code = 'FEEL_BAD_USAGE') {
|
|
72
|
+
throw new FeelError(message, { kind: 'usage', code });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 归一化任意错误为 FeelError。
|
|
77
|
+
* @param {unknown} error
|
|
78
|
+
* @param {string} [code]
|
|
79
|
+
* @returns {FeelError}
|
|
80
|
+
*/
|
|
81
|
+
export function toFeelError(error, code = 'FEEL_INTERNAL') {
|
|
82
|
+
if (error instanceof FeelError) return error;
|
|
83
|
+
if (error instanceof Error) {
|
|
84
|
+
return new FeelError(error.message, { kind: 'internal', code, cause: error });
|
|
85
|
+
}
|
|
86
|
+
return new FeelError(String(error), { kind: 'internal', code });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ────────────────────────── 执行器 ────────────────────────── */
|
|
90
|
+
|
|
91
|
+
/** 默认单次调用超时(毫秒)。CLI 冷启动约 0.3–1s,宽限到 120s 以容纳大 flow。 */
|
|
92
|
+
export const DEFAULT_TIMEOUT_MS = 120_000;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* CLI 调用结果。
|
|
96
|
+
* @typedef {object} CliResult
|
|
97
|
+
* @property {number} code 退出码。
|
|
98
|
+
* @property {string} stdout 标准输出(已去尾随空白)。
|
|
99
|
+
* @property {string} stderr 标准错误(已去尾随空白)。
|
|
100
|
+
* @property {number} durationMs 耗时。
|
|
101
|
+
* @property {string[]} argv 实际执行的参数。
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* OpenFeel CLI 执行器。
|
|
106
|
+
*/
|
|
107
|
+
export class FeelCli {
|
|
108
|
+
/**
|
|
109
|
+
* @param {object} [options]
|
|
110
|
+
* @param {string} [options.command] 可执行文件名,默认 `openfeel`。
|
|
111
|
+
* @param {string[]} [options.commandPrefix] 前缀参数(如 `[nodeScriptPath]`,用于源码运行)。
|
|
112
|
+
* @param {string} [options.cwd] 默认工作目录(会话工作区)。
|
|
113
|
+
* @param {number} [options.timeoutMs] 默认超时。
|
|
114
|
+
* @param {boolean} [options.log] 是否让 CLI 写运行日志,默认 false。
|
|
115
|
+
* @param {string} [options.encoding] 显式输出编码;默认 `utf8`。
|
|
116
|
+
* @param {Record<string, string>} [options.env] 追加环境变量。
|
|
117
|
+
*/
|
|
118
|
+
constructor(options = {}) {
|
|
119
|
+
this.command = options.command ?? 'openfeel';
|
|
120
|
+
this.commandPrefix = options.commandPrefix ?? [];
|
|
121
|
+
this.cwd = options.cwd;
|
|
122
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
123
|
+
this.log = options.log ?? false;
|
|
124
|
+
this.encoding = options.encoding ?? 'utf8';
|
|
125
|
+
this.env = options.env ?? {};
|
|
126
|
+
/** 最近一次成功探测的 CLI 版本,避免重复探测。 */
|
|
127
|
+
this.detectedVersion = undefined;
|
|
128
|
+
/**
|
|
129
|
+
* 已解析的可执行目标(惰性求值并缓存)。
|
|
130
|
+
* @type {{ command: string, prefix: string[] } | undefined}
|
|
131
|
+
*/
|
|
132
|
+
this.resolved = undefined;
|
|
133
|
+
/** 解析失败的原因(用于给出可操作的诊断)。 */
|
|
134
|
+
this.resolveError = undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 解析「到底该怎么启动 openfeel」。
|
|
139
|
+
*
|
|
140
|
+
* 为什么需要这一步:npm 的全局安装产物在 Windows 上是
|
|
141
|
+
* `openfeel.cmd` / `openfeel.ps1` 这类**脚本垫片**,且 npm 全局 bin
|
|
142
|
+
* 目录经常**不在**子进程继承的 PATH 里。直接 `spawn('openfeel')` 会
|
|
143
|
+
* 以 ENOENT 失败,即使命令在用户 shell 里可用。
|
|
144
|
+
*
|
|
145
|
+
* 解析顺序:
|
|
146
|
+
* 1. 显式配置且是**存在的文件** → 直接用(`.js` 用 node,`.cmd/.bat` 用 cmd)。
|
|
147
|
+
* 2. PATH 各目录下按平台候选后缀查找可执行文件。
|
|
148
|
+
* 3. `process.execPath` 同目录(即当前 Node 的安装目录——常见于
|
|
149
|
+
* DSH 打包运行时把 CLI 装在 node 旁边);再试 npm 全局 prefix 的
|
|
150
|
+
* `bin` 目录与 `node_modules/openfeel` 的 bin。
|
|
151
|
+
* 4. 都失败则请求失败,并**说明已尝试的目录**。
|
|
152
|
+
*
|
|
153
|
+
* @returns {{ command: string, prefix: string[] }}
|
|
154
|
+
*/
|
|
155
|
+
resolve() {
|
|
156
|
+
if (this.resolved !== undefined) return this.resolved;
|
|
157
|
+
if (this.resolveError !== undefined) throw this.resolveError;
|
|
158
|
+
|
|
159
|
+
const attempted = [];
|
|
160
|
+
const isWindows = process.platform === 'win32';
|
|
161
|
+
|
|
162
|
+
/** 把一个候选路径转成可 spawn 的 {command, prefix}。 */
|
|
163
|
+
const toLauncher = (/** @type {string} */ filePath) => {
|
|
164
|
+
const lower = filePath.toLowerCase();
|
|
165
|
+
if (lower.endsWith('.js') || lower.endsWith('.mjs') || lower.endsWith('.cjs')) {
|
|
166
|
+
return { command: process.execPath, prefix: [filePath] };
|
|
167
|
+
}
|
|
168
|
+
if (lower.endsWith('.cmd') || lower.endsWith('.bat')) {
|
|
169
|
+
return { command: isWindows ? 'cmd' : filePath, prefix: isWindows ? ['/d', '/s', '/c', filePath] : [] };
|
|
170
|
+
}
|
|
171
|
+
return { command: filePath, prefix: [] };
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** 若路径指向前缀文件则展开垫片,否则原样返回。 */
|
|
175
|
+
const expand = (/** @type {string} */ base) => {
|
|
176
|
+
if (!isWindows) return [base];
|
|
177
|
+
return [`${base}.cmd`, `${base}.exe`, `${base}.bat`, base];
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// 1) 显式配置:可能是裸命令名,也可能是文件路径。
|
|
181
|
+
if (this.command !== 'openfeel') {
|
|
182
|
+
if (isAbsolute(this.command) && existsSync(this.command)) {
|
|
183
|
+
const launcher = toLauncher(this.command);
|
|
184
|
+
this.resolved = { command: launcher.command, prefix: [...launcher.prefix, ...this.commandPrefix] };
|
|
185
|
+
return this.resolved;
|
|
186
|
+
}
|
|
187
|
+
if (!isAbsolute(this.command) && !this.command.includes('/') && !this.command.includes('\\')) {
|
|
188
|
+
// 裸命令名:继续走 PATH 解析。
|
|
189
|
+
attempted.push(`PATH(${this.command})`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** @type {string[]} */
|
|
194
|
+
const searchRoots = [];
|
|
195
|
+
for (const entry of (process.env.PATH ?? '').split(delimiter)) {
|
|
196
|
+
if (entry !== '') searchRoots.push(entry);
|
|
197
|
+
}
|
|
198
|
+
const nodeDir = dirname(process.execPath);
|
|
199
|
+
searchRoots.push(nodeDir);
|
|
200
|
+
// npm 全局 prefix 的 bin:<prefix>/openfeel 与 <prefix>/bin/openfeel。
|
|
201
|
+
searchRoots.push(nodeDir);
|
|
202
|
+
searchRoots.push(join(nodeDir, 'bin'));
|
|
203
|
+
searchRoots.push(join(nodeDir, 'node_modules', 'openfeel', 'bin'));
|
|
204
|
+
|
|
205
|
+
const bareName = this.command === 'openfeel' ? 'openfeel' : this.command;
|
|
206
|
+
for (const root of searchRoots) {
|
|
207
|
+
for (const candidate of expand(join(root, bareName))) {
|
|
208
|
+
if (!isFile(candidate)) continue;
|
|
209
|
+
const launcher = toLauncher(candidate);
|
|
210
|
+
this.resolved = { command: launcher.command, prefix: [...launcher.prefix, ...this.commandPrefix] };
|
|
211
|
+
return this.resolved;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
attempted.push(`<node>/openfeel*(${nodeDir})`);
|
|
216
|
+
|
|
217
|
+
// 4) 全部失败:保留失败原因,交由调用方渲染成安装指引。
|
|
218
|
+
this.resolveError = new FeelError(
|
|
219
|
+
`找不到 OpenFeel CLI 可执行文件「${this.command}」。已尝试:${[...new Set(attempted)].join('、')}。\n` +
|
|
220
|
+
'解决方式(任选其一):\n' +
|
|
221
|
+
' 1) 安装 CLI:npm install -g openfeel\n' +
|
|
222
|
+
' 2) 在本适配器配置中把 openfeel.command 设为可执行文件绝对路径(如 C:\\\\Users\\\\<you>\\\\AppData\\\\Roaming\\\\npm\\\\openfeel.cmd)\n' +
|
|
223
|
+
' 3) 若从源码运行,把 openfeel.command 设为 node、openfeel.commandPrefix 设为 [<repo>/bin/openfeel.js]',
|
|
224
|
+
{ kind: 'cli-missing', code: 'FEEL_CLI_NOT_FOUND' },
|
|
225
|
+
);
|
|
226
|
+
throw this.resolveError;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 组装一次调用的完整 argv(**不含**可执行文件本身)。
|
|
231
|
+
*
|
|
232
|
+
* 恒注入的全局选项:
|
|
233
|
+
* - `--no-color`:保证输出无 ANSI,便于解析。
|
|
234
|
+
* - `--encoding utf8`:`--json` 恒为 UTF-8,显式固定避免平台差异。
|
|
235
|
+
* - `--no-log`:默认不写 `~/.openfeel/cli/logs/`,以免在只读沙箱下失败。
|
|
236
|
+
*
|
|
237
|
+
* @param {string[]} args 子命令与业务参数。
|
|
238
|
+
* @returns {string[]}
|
|
239
|
+
*/
|
|
240
|
+
buildArgv(args) {
|
|
241
|
+
const global = ['--no-color', '--encoding', this.encoding];
|
|
242
|
+
if (!this.log) global.push('--no-log');
|
|
243
|
+
return [...global, ...args];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 执行一次 CLI 调用(不解析)。
|
|
248
|
+
* @param {string[]} args
|
|
249
|
+
* @param {object} [options]
|
|
250
|
+
* @param {string} [options.cwd] 覆盖工作目录。
|
|
251
|
+
* @param {number} [options.timeoutMs] 覆盖超时。
|
|
252
|
+
* @param {AbortSignal} [options.signal] 取消信号。
|
|
253
|
+
* @returns {Promise<CliResult>}
|
|
254
|
+
*/
|
|
255
|
+
async run(args, options = {}) {
|
|
256
|
+
const launcher = this.resolve();
|
|
257
|
+
const argv = [...launcher.prefix, ...this.buildArgv(args)];
|
|
258
|
+
const cwd = options.cwd ?? this.cwd;
|
|
259
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
|
260
|
+
const startedAt = Date.now();
|
|
261
|
+
|
|
262
|
+
/** @type {Record<string, string|undefined>} */
|
|
263
|
+
const env = {
|
|
264
|
+
...process.env,
|
|
265
|
+
// 无着色、无交互、固定编码。
|
|
266
|
+
NO_COLOR: '1',
|
|
267
|
+
FORCE_COLOR: '0',
|
|
268
|
+
CI: process.env.CI ?? '1',
|
|
269
|
+
...(this.log ? {} : { OPENFEEL_LOG: '0' }),
|
|
270
|
+
...this.env,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/** @type {CliResult} */
|
|
274
|
+
const result = await new Promise((resolve, reject) => {
|
|
275
|
+
/** @type {import('node:child_process').ChildProcessWithoutNullStreams} */
|
|
276
|
+
let child;
|
|
277
|
+
try {
|
|
278
|
+
child = spawn(launcher.command, argv, {
|
|
279
|
+
cwd,
|
|
280
|
+
env,
|
|
281
|
+
shell: false,
|
|
282
|
+
windowsHide: true,
|
|
283
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
284
|
+
});
|
|
285
|
+
} catch (error) {
|
|
286
|
+
reject(
|
|
287
|
+
new FeelError(`无法启动 OpenFeel CLI(${launcher.command}): ${messageOf(error)}`, {
|
|
288
|
+
kind: 'cli-missing',
|
|
289
|
+
code: 'FEEL_CLI_SPAWN_FAILED',
|
|
290
|
+
cause: error,
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** @type {Buffer[]} */
|
|
297
|
+
const stdoutChunks = [];
|
|
298
|
+
/** @type {Buffer[]} */
|
|
299
|
+
const stderrChunks = [];
|
|
300
|
+
let settled = false;
|
|
301
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
302
|
+
let timer;
|
|
303
|
+
|
|
304
|
+
const finish = (/** @type {number} */ code) => {
|
|
305
|
+
if (settled) return;
|
|
306
|
+
settled = true;
|
|
307
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
308
|
+
resolve({
|
|
309
|
+
code,
|
|
310
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf8').replace(/\s+$/, ''),
|
|
311
|
+
stderr: Buffer.concat(stderrChunks).toString('utf8').replace(/\s+$/, ''),
|
|
312
|
+
durationMs: Date.now() - startedAt,
|
|
313
|
+
argv,
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
child.stdout.on('data', (/** @type {Buffer} */ chunk) => stdoutChunks.push(chunk));
|
|
318
|
+
child.stderr.on('data', (/** @type {Buffer} */ chunk) => stderrChunks.push(chunk));
|
|
319
|
+
|
|
320
|
+
child.on('error', (error) => {
|
|
321
|
+
if (settled) return;
|
|
322
|
+
settled = true;
|
|
323
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
324
|
+
const missing = /** @type {{ code?: string }} */ (error).code === 'ENOENT';
|
|
325
|
+
reject(
|
|
326
|
+
new FeelError(
|
|
327
|
+
missing
|
|
328
|
+
? `OpenFeel CLI 无法执行(${launcher.command}):文件不存在或不可执行。请安装 npm install -g openfeel,或在本适配器配置中指定 openfeel.command。`
|
|
329
|
+
: `OpenFeel CLI 执行失败: ${messageOf(error)}`,
|
|
330
|
+
{
|
|
331
|
+
kind: missing ? 'cli-missing' : 'internal',
|
|
332
|
+
code: missing ? 'FEEL_CLI_NOT_FOUND' : 'FEEL_CLI_FAILED',
|
|
333
|
+
cause: error,
|
|
334
|
+
},
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
child.on('close', (code) => finish(code ?? 1));
|
|
340
|
+
|
|
341
|
+
timer = setTimeout(() => {
|
|
342
|
+
if (settled) return;
|
|
343
|
+
settled = true;
|
|
344
|
+
try {
|
|
345
|
+
child.kill('SIGKILL');
|
|
346
|
+
} catch {
|
|
347
|
+
/* ignore */
|
|
348
|
+
}
|
|
349
|
+
reject(
|
|
350
|
+
new FeelError(
|
|
351
|
+
`OpenFeel CLI 调用超时(${timeoutMs}ms): openfeel ${args.join(' ')}。若项目规模较大,可在配置中提高 openfeel.timeoutMs。`,
|
|
352
|
+
{ kind: 'timeout', code: 'FEEL_CLI_TIMEOUT', detail: { args, timeoutMs } },
|
|
353
|
+
),
|
|
354
|
+
);
|
|
355
|
+
}, timeoutMs);
|
|
356
|
+
|
|
357
|
+
if (options.signal !== undefined) {
|
|
358
|
+
if (options.signal.aborted) {
|
|
359
|
+
try {
|
|
360
|
+
child.kill('SIGKILL');
|
|
361
|
+
} catch {
|
|
362
|
+
/* ignore */
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
options.signal.addEventListener(
|
|
366
|
+
'abort',
|
|
367
|
+
() => {
|
|
368
|
+
try {
|
|
369
|
+
child.kill('SIGKILL');
|
|
370
|
+
} catch {
|
|
371
|
+
/* ignore */
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
{ once: true },
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 执行并以 JSON 解析输出。
|
|
385
|
+
*
|
|
386
|
+
* `--json` 是 OpenFeel 的机器可读契约(单文档、`schemaVersion` 开头)。
|
|
387
|
+
* 退出码非 0 时,把 stderr/stdout 归一化为 FeelError。
|
|
388
|
+
*
|
|
389
|
+
* @param {string[]} args 必须包含 `--json` 的调用参数。
|
|
390
|
+
* @param {object} [options]
|
|
391
|
+
* @returns {Promise<any>}
|
|
392
|
+
*/
|
|
393
|
+
async json(args, options = {}) {
|
|
394
|
+
const result = await this.run(args, options);
|
|
395
|
+
if (result.code !== 0) throw this.errorFromResult(result, args);
|
|
396
|
+
|
|
397
|
+
const text = result.stdout.trim();
|
|
398
|
+
if (text === '') {
|
|
399
|
+
return { schemaVersion: 1, ok: true, raw: '' };
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
return JSON.parse(text);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
throw new FeelError(
|
|
405
|
+
`OpenFeel CLI 返回了非 JSON 输出(命令: openfeel ${args.join(' ')}): ${truncate(text, 400)}`,
|
|
406
|
+
{ kind: 'internal', code: 'FEEL_CLI_BAD_JSON', cause: error, detail: { args } },
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* 执行并把人类可读输出原样返回。
|
|
413
|
+
* @param {string[]} args
|
|
414
|
+
* @param {object} [options]
|
|
415
|
+
* @returns {Promise<{ text: string, durationMs: number, argv: string[] }>}
|
|
416
|
+
*/
|
|
417
|
+
async text(args, options = {}) {
|
|
418
|
+
const result = await this.run(args, options);
|
|
419
|
+
if (result.code !== 0) throw this.errorFromResult(result, args);
|
|
420
|
+
return { text: result.stdout, durationMs: result.durationMs, argv: result.argv };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* 执行写命令:成功返回人类可读输出,失败抛出归一化错误。
|
|
425
|
+
*
|
|
426
|
+
* 写命令的门禁拒绝(例如 phase 不合法、需求 done 门未过)是**预期结果**,
|
|
427
|
+
* 以 `refusal` 分类抛出,让 agent 知道「这样做不对」而不是「系统坏了」。
|
|
428
|
+
*
|
|
429
|
+
* @param {string[]} args
|
|
430
|
+
* @param {object} [options]
|
|
431
|
+
* @returns {Promise<{ ok: true, text: string, durationMs: number }>}
|
|
432
|
+
*/
|
|
433
|
+
async write(args, options = {}) {
|
|
434
|
+
const result = await this.run(args, options);
|
|
435
|
+
if (result.code !== 0) throw this.errorFromResult(result, args);
|
|
436
|
+
return { ok: true, text: result.stdout, durationMs: result.durationMs };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* 探测 CLI 是否可用并返回版本号。
|
|
441
|
+
* @param {object} [options]
|
|
442
|
+
* @returns {Promise<string | undefined>} 不可用时返回 undefined。
|
|
443
|
+
*/
|
|
444
|
+
async probeVersion(options = {}) {
|
|
445
|
+
if (this.detectedVersion !== undefined) return this.detectedVersion;
|
|
446
|
+
try {
|
|
447
|
+
const result = await this.run(['--version'], { ...options, timeoutMs: 20_000 });
|
|
448
|
+
if (result.code !== 0) return undefined;
|
|
449
|
+
const version = result.stdout.trim().split(/\r?\n/).at(-1)?.trim();
|
|
450
|
+
if (version !== undefined && version !== '') this.detectedVersion = version;
|
|
451
|
+
return this.detectedVersion;
|
|
452
|
+
} catch {
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* 把一次失败的 CLI 调用归一化为错误。
|
|
459
|
+
*
|
|
460
|
+
* OpenFeel v1.2.2+ 在 `--json` 模式下把失败也输出为单文档 envelope:
|
|
461
|
+
* `{ schemaVersion, error: { code, kind, message, detail } }`。
|
|
462
|
+
* 适配器优先消费它(`kind` / `code` / `message` / `detail` 原样透传),
|
|
463
|
+
* 仅当 stdout 不是 envelope 时回退到旧路径。
|
|
464
|
+
*
|
|
465
|
+
* 退出码语义(单一权威在 CLI,这里只做翻译):
|
|
466
|
+
* - `0` 成功。
|
|
467
|
+
* - `2` **flow.json 乐观并发冲突**——唯一可重试信号,恒为 `refusal`。
|
|
468
|
+
* - `3` **内部故障**,恒为 `internal`。
|
|
469
|
+
* - `1` 其余失败:有 envelope 时按 `error.kind`;否则沿用「有无堆栈」启发式
|
|
470
|
+
* (门禁拒绝是本地化 stderr 一行、无堆栈;内部故障会打印堆栈)。
|
|
471
|
+
*
|
|
472
|
+
* @param {CliResult} result
|
|
473
|
+
* @param {string[]} args
|
|
474
|
+
* @returns {FeelError}
|
|
475
|
+
*/
|
|
476
|
+
errorFromResult(result, args) {
|
|
477
|
+
const combined = [result.stderr, result.stdout].filter((part) => part.trim() !== '').join('\n').trim();
|
|
478
|
+
const text = combined === '' ? `openfeel ${args.join(' ')} 失败(退出码 ${result.code})` : combined;
|
|
479
|
+
const context = {
|
|
480
|
+
args,
|
|
481
|
+
exitCode: result.code,
|
|
482
|
+
stdout: truncate(result.stdout, 2000),
|
|
483
|
+
stderr: truncate(result.stderr, 2000),
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
// 1) 新 envelope:`error` 为对象(契约 §4.3 的唯一判别规则)。
|
|
487
|
+
const doc = parseErrorDocument(result.stdout);
|
|
488
|
+
const envelopeError = isRecord(doc?.error) ? doc.error : undefined;
|
|
489
|
+
if (envelopeError !== undefined) {
|
|
490
|
+
const kind = result.code === 2
|
|
491
|
+
? 'refusal'
|
|
492
|
+
: result.code === 3
|
|
493
|
+
? 'internal'
|
|
494
|
+
: (envelopeError.kind === 'internal' ? 'internal' : 'refusal');
|
|
495
|
+
const code = typeof envelopeError.code === 'string' && envelopeError.code !== ''
|
|
496
|
+
? envelopeError.code
|
|
497
|
+
: (kind === 'internal' ? 'FEEL_CLI_INTERNAL' : 'FEEL_CLI_REFUSED');
|
|
498
|
+
const baseMessage = typeof envelopeError.message === 'string' && envelopeError.message !== ''
|
|
499
|
+
? envelopeError.message
|
|
500
|
+
: text;
|
|
501
|
+
|
|
502
|
+
/** @type {Record<string, unknown>} */
|
|
503
|
+
const detail = { ...context };
|
|
504
|
+
if (isRecord(envelopeError.detail)) {
|
|
505
|
+
Object.assign(detail, envelopeError.detail);
|
|
506
|
+
} else {
|
|
507
|
+
detail.envelopeDetail = envelopeError.detail ?? null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// 退出码 2 是唯一可重试信号:保留 envelope 的 code,并附加重试提示。
|
|
511
|
+
const message = result.code === 2
|
|
512
|
+
? `${baseMessage}\n\n提示:这是 flow.json 的乐观并发冲突(退出码 2,可重试)。请先重新读取状态(feel_status / feel_verify action=recover)再重试本次操作。`
|
|
513
|
+
: baseMessage;
|
|
514
|
+
return new FeelError(message, { kind, code, detail });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// 2) legacy `{ error: '...' }`(如 flow archive gate-rejected):仅 `errorKind` 可作权威信号。
|
|
518
|
+
const legacyKind = isRecord(doc) && typeof doc.error === 'string'
|
|
519
|
+
&& (doc.errorKind === 'refusal' || doc.errorKind === 'internal')
|
|
520
|
+
? doc.errorKind
|
|
521
|
+
: undefined;
|
|
522
|
+
|
|
523
|
+
// 退出码 2:唯一可重试信号(flow.json revision CAS 冲突)。
|
|
524
|
+
if (result.code === 2) {
|
|
525
|
+
return new FeelError(
|
|
526
|
+
`${text}\n\n提示:这是 flow.json 的乐观并发冲突(退出码 2,可重试)。请先重新读取状态(feel_status / feel_verify action=recover)再重试本次操作。`,
|
|
527
|
+
{
|
|
528
|
+
kind: 'refusal',
|
|
529
|
+
code: 'FEEL_FLOW_CONCURRENT',
|
|
530
|
+
detail: isRecord(doc) && typeof doc.error === 'string' ? { ...context, legacyError: doc.error } : context,
|
|
531
|
+
},
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 退出码 3:内部故障(v1.2.2+ 的稳定信号)。
|
|
536
|
+
if (result.code === 3) {
|
|
537
|
+
return new FeelError(text, {
|
|
538
|
+
kind: 'internal',
|
|
539
|
+
code: 'FEEL_CLI_INTERNAL',
|
|
540
|
+
detail: isRecord(doc) && typeof doc.error === 'string' ? { ...context, legacyError: doc.error } : context,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// 堆栈特征是「内部故障」的可靠标志;拒绝路径从不打印堆栈。
|
|
545
|
+
const hasStack = /^\s+at\s+/m.test(text);
|
|
546
|
+
const internalSignals = [
|
|
547
|
+
/Cannot find module/i,
|
|
548
|
+
/SyntaxError/i,
|
|
549
|
+
/Unexpected token/i,
|
|
550
|
+
/EACCES\b|EPERM\b|ENOSPC\b/,
|
|
551
|
+
/\[ERROR\]\[/,
|
|
552
|
+
];
|
|
553
|
+
const isInternal = legacyKind === 'internal'
|
|
554
|
+
|| (legacyKind === undefined && (hasStack || internalSignals.some((pattern) => pattern.test(text))));
|
|
555
|
+
|
|
556
|
+
return new FeelError(text, {
|
|
557
|
+
kind: isInternal ? 'internal' : 'refusal',
|
|
558
|
+
code: isInternal ? 'FEEL_CLI_INTERNAL' : 'FEEL_CLI_REFUSED',
|
|
559
|
+
detail: isRecord(doc) && typeof doc.error === 'string' ? { ...context, legacyError: doc.error } : context,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* 解析 stdout 里的错误 JSON 文档。
|
|
566
|
+
*
|
|
567
|
+
* 只接受单个 JSON 对象;空输出、人类文本、数组与非法 JSON 均返回 undefined,
|
|
568
|
+
* 由调用方回退到 legacy / 启发式路径。
|
|
569
|
+
*
|
|
570
|
+
* @param {string} stdout
|
|
571
|
+
* @returns {Record<string, any> | undefined}
|
|
572
|
+
*/
|
|
573
|
+
function parseErrorDocument(stdout) {
|
|
574
|
+
const text = stdout.trim();
|
|
575
|
+
if (text === '' || !text.startsWith('{')) return undefined;
|
|
576
|
+
try {
|
|
577
|
+
const doc = JSON.parse(text);
|
|
578
|
+
return isRecord(doc) ? doc : undefined;
|
|
579
|
+
} catch {
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* 判定是否为普通对象(排除 null 与数组)。
|
|
586
|
+
* @param {unknown} value
|
|
587
|
+
* @returns {value is Record<string, any>}
|
|
588
|
+
*/
|
|
589
|
+
function isRecord(value) {
|
|
590
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* 截断长文本用于错误消息。
|
|
595
|
+
* @param {string} text
|
|
596
|
+
* @param {number} max
|
|
597
|
+
* @returns {string}
|
|
598
|
+
*/
|
|
599
|
+
export function truncate(text, max) {
|
|
600
|
+
if (text.length <= max) return text;
|
|
601
|
+
return `${text.slice(0, max)}…(已截断,共 ${text.length} 字符)`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* 取错误消息。
|
|
606
|
+
* @param {unknown} error
|
|
607
|
+
* @returns {string}
|
|
608
|
+
*/
|
|
609
|
+
function messageOf(error) {
|
|
610
|
+
return error instanceof Error ? error.message : String(error);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* 判定是否为已存在的普通文件。
|
|
615
|
+
* @param {string} path
|
|
616
|
+
* @returns {boolean}
|
|
617
|
+
*/
|
|
618
|
+
function isFile(path) {
|
|
619
|
+
try {
|
|
620
|
+
return existsSync(path) && statSync(path).isFile();
|
|
621
|
+
} catch {
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
}
|