@yanglaofish/fishtools 0.5.3 → 0.6.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.
- package/README.md +3 -3
- package/manual/README.md +3 -3
- package/manual/docs/modules.md +2 -0
- package/manual/docs/plans/2026-10-13-effective-inputs-and-tables.md +39 -0
- package/manual/docs/plans/2026-10-13-plugin-defaults.md +45 -0
- package/manual/docs/plans/2026-10-14-issue7-recovery.md +33 -0
- package/manual/docs/plans/2026-10-14-issue8-10-workflows.md +26 -0
- package/manual/docs/plans/2026-10-14-issue9-completion.md +47 -0
- package/manual/docs/plans/2026-10-14-managed-execution.md +58 -0
- package/manual/docs/plans/2026-10-14-plugin-discovery-history.md +41 -0
- package/manual/docs/plans/2026-10-14-structured-reports.md +77 -0
- package/manual/docs/plans/2026-10-14-unreleased-review.md +47 -0
- package/manual/docs/plugin-development.md +161 -8
- package/manual/docs/releases.md +25 -6
- package/manual/docs/superpowers/plans/2026-10-13-tui-readable-sessions.md +2 -2
- package/manual/docs/superpowers/reviews/2026-10-13-tui-sessions-review.md +2 -0
- package/manual/docs/superpowers/reviews/2026-10-14-0.6.0-release-review.md +19 -0
- package/manual/docs/verification.md +17 -0
- package/manual/packages/core/README.md +6 -0
- package/manual/packages/core/src/application.js +15 -9
- package/manual/packages/core/src/plugin-catalog.js +20 -2
- package/manual/packages/core/src/plugin-loading.js +24 -0
- package/manual/packages/core/src/version.js +1 -1
- package/manual/packages/fst-cli/README.md +5 -1
- package/manual/packages/fst-cli/src/terminal/command-menu.js +3 -2
- package/manual/packages/fst-cli/src/terminal/file-autocomplete.js +106 -0
- package/manual/packages/fst-cli/src/terminal/log-viewer.js +4 -3
- package/manual/packages/fst-cli/src/terminal/navigation.js +23 -1
- package/manual/packages/fst-cli/src/terminal/presentation.js +2 -1
- package/manual/packages/fst-cli/src/terminal/report-presentation.js +38 -0
- package/manual/packages/fst-cli/src/terminal/result-table.js +66 -0
- package/manual/packages/fst-cli/src/terminal/run-feed.js +42 -17
- package/manual/packages/fst-cli/src/terminal/run-presentation.js +51 -17
- package/manual/packages/fst-cli/src/terminal/run-viewer.js +15 -12
- package/manual/packages/fst-cli/src/terminal/schema.js +114 -4
- package/manual/packages/fst-cli/src/terminal/session-history.js +2 -2
- package/manual/packages/fst-cli/src/terminal/session.js +40 -15
- package/manual/packages/fst-cli/src/terminal/view.js +80 -68
- package/manual/packages/fst-cli/src/terminal/workflow-menu.js +3 -1
- package/manual/packages/fst-devtools/README.md +2 -0
- package/manual/packages/fst-devtools/src/plugin-quality.js +4 -1
- package/manual/packages/fst-devtools/src/plugin-tools.js +1 -0
- package/manual/packages/fst-devtools/src/process-usage.js +62 -0
- package/manual/packages/fst-platform/README.md +5 -0
- package/manual/packages/fst-platform/package.json +1 -0
- package/manual/packages/fst-platform/src/managed-process.js +36 -0
- package/manual/packages/fst-platform/src/process.js +7 -2
- package/manual/packages/fst-plugin/README.md +4 -0
- package/manual/packages/fst-plugin/src/contracts.js +3 -0
- package/manual/packages/fst-plugin/src/host.js +38 -23
- package/manual/packages/fst-plugin/src/runtime.js +14 -8
- package/manual/packages/fst-workflow/src/engine.js +1 -1
- package/manual/packages/fst-workspace/README.md +4 -0
- package/manual/packages/fst-workspace/src/plugin-registry.js +1 -1
- package/manual/packages/fst-workspace/src/plugins.js +5 -3
- package/manual/packages/fst-workspace/src/workflows.js +27 -9
- package/manual/packages/sdk/README.md +10 -0
- package/manual/packages/sdk/inputs.js +53 -0
- package/manual/packages/sdk/package.json +2 -0
- package/manual/packages/sdk/reports.js +53 -0
- package/node_modules/@mytools/core/README.md +6 -0
- package/node_modules/@mytools/core/src/application.js +15 -9
- package/node_modules/@mytools/core/src/plugin-catalog.js +20 -2
- package/node_modules/@mytools/core/src/plugin-loading.js +24 -0
- package/node_modules/@mytools/core/src/version.js +1 -1
- package/node_modules/@mytools/fst-cli/README.md +5 -1
- package/node_modules/@mytools/fst-cli/src/terminal/command-menu.js +3 -2
- package/node_modules/@mytools/fst-cli/src/terminal/file-autocomplete.js +106 -0
- package/node_modules/@mytools/fst-cli/src/terminal/log-viewer.js +4 -3
- package/node_modules/@mytools/fst-cli/src/terminal/navigation.js +23 -1
- package/node_modules/@mytools/fst-cli/src/terminal/presentation.js +2 -1
- package/node_modules/@mytools/fst-cli/src/terminal/report-presentation.js +38 -0
- package/node_modules/@mytools/fst-cli/src/terminal/result-table.js +66 -0
- package/node_modules/@mytools/fst-cli/src/terminal/run-feed.js +42 -17
- package/node_modules/@mytools/fst-cli/src/terminal/run-presentation.js +51 -17
- package/node_modules/@mytools/fst-cli/src/terminal/run-viewer.js +15 -12
- package/node_modules/@mytools/fst-cli/src/terminal/schema.js +114 -4
- package/node_modules/@mytools/fst-cli/src/terminal/session-history.js +2 -2
- package/node_modules/@mytools/fst-cli/src/terminal/session.js +40 -15
- package/node_modules/@mytools/fst-cli/src/terminal/view.js +80 -68
- package/node_modules/@mytools/fst-cli/src/terminal/workflow-menu.js +3 -1
- package/node_modules/@mytools/fst-devtools/README.md +2 -0
- package/node_modules/@mytools/fst-devtools/src/plugin-quality.js +4 -1
- package/node_modules/@mytools/fst-devtools/src/plugin-tools.js +1 -0
- package/node_modules/@mytools/fst-devtools/src/process-usage.js +62 -0
- package/node_modules/@mytools/fst-platform/README.md +5 -0
- package/node_modules/@mytools/fst-platform/package.json +1 -0
- package/node_modules/@mytools/fst-platform/src/managed-process.js +36 -0
- package/node_modules/@mytools/fst-platform/src/process.js +7 -2
- package/node_modules/@mytools/fst-plugin/README.md +4 -0
- package/node_modules/@mytools/fst-plugin/src/contracts.js +3 -0
- package/node_modules/@mytools/fst-plugin/src/host.js +38 -23
- package/node_modules/@mytools/fst-plugin/src/runtime.js +14 -8
- package/node_modules/@mytools/fst-workflow/src/engine.js +1 -1
- package/node_modules/@mytools/fst-workspace/README.md +4 -0
- package/node_modules/@mytools/fst-workspace/src/plugin-registry.js +1 -1
- package/node_modules/@mytools/fst-workspace/src/plugins.js +5 -3
- package/node_modules/@mytools/fst-workspace/src/workflows.js +27 -9
- package/node_modules/@mytools/sdk/README.md +10 -0
- package/node_modules/@mytools/sdk/inputs.js +53 -0
- package/node_modules/@mytools/sdk/package.json +2 -0
- package/node_modules/@mytools/sdk/reports.js +53 -0
- package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +1032 -1032
- package/package.json +1 -1
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
|
|
20
20
|
## 维护与验证
|
|
21
21
|
|
|
22
|
+
`plugin lint` 的 `UNMANAGED_PROCESS` 迁移警告识别可追溯的 child_process、execa、cross-spawn、shelljs.exec 及平台底层 runProcess 调用,指向文件行列并建议上下文 ctx.runProcess。只导入未调用、注释、字符串及无法确认的局部重名不据此告警。诊断保持 warning,不拒绝旧插件;复杂动态代码、依赖内部和解释器内部不保证覆盖,不构成安全隔离。
|
|
23
|
+
|
|
22
24
|
本模块负责上述职责的实现、文档与回归;跨模块接口调整需要同时检查生产者与消费者。具体维护人员尚未指定。
|
|
23
25
|
|
|
24
26
|
在仓库根目录执行:
|
|
@@ -3,6 +3,7 @@ import {readFile,realpath} from 'node:fs/promises';
|
|
|
3
3
|
import {isBuiltin,createRequire} from 'node:module';
|
|
4
4
|
import {fileURLToPath,pathToFileURL} from 'node:url';
|
|
5
5
|
import {parse} from 'acorn';
|
|
6
|
+
import {inspectProcessUsage} from './process-usage.js';
|
|
6
7
|
|
|
7
8
|
const nonempty=value=>typeof value==='string'&&value.trim().length>0;
|
|
8
9
|
function inspectSchema(schema,location,warning,seen=new WeakSet()){
|
|
@@ -84,6 +85,8 @@ function sourceReferences(ast,commonjs,warning,location){
|
|
|
84
85
|
base?.type==='MemberExpression'&&!base.computed&&base.property.name==='url'&&base.object.type==='MetaProperty'&&base.object.meta.name==='import')loaders.add(node.id.name);
|
|
85
86
|
});
|
|
86
87
|
// 保守跳过文件内重名绑定;不把局部对象当作 Node 全局对象。
|
|
88
|
+
const isRequire=node=>node.callee?.type==='Identifier'&&((commonjs&&!bindings.has('require')&&node.callee.name==='require')||loaders.has(node.callee.name));
|
|
89
|
+
inspectProcessUsage(ast,{bindings,isRequire,walk},warning,location);
|
|
87
90
|
walk(ast,node=>{
|
|
88
91
|
const at=`${location}:${node.loc.start.line}:${node.loc.start.column+1}`;
|
|
89
92
|
if(['ImportDeclaration','ExportNamedDeclaration','ExportAllDeclaration','ImportExpression'].includes(node.type)&&typeof node.source?.value==='string'){
|
|
@@ -91,7 +94,7 @@ function sourceReferences(ast,commonjs,warning,location){
|
|
|
91
94
|
}
|
|
92
95
|
if(node.type!=='CallExpression')return;
|
|
93
96
|
const callee=node.callee;
|
|
94
|
-
if(
|
|
97
|
+
if(isRequire(node)&&typeof node.arguments[0]?.value==='string'){
|
|
95
98
|
references.push({specifier:node.arguments[0].value,at,commonjs:true});
|
|
96
99
|
}
|
|
97
100
|
if(!bindings.has('console')&&callee.type==='MemberExpression'&&callee.object.type==='Identifier'&&callee.object.name==='console'&&
|
|
@@ -38,6 +38,7 @@ export async function lintPluginDirectory(directory){
|
|
|
38
38
|
const quality=await import('./plugin-quality.js');
|
|
39
39
|
return {...await inspectPluginDirectory(directory,quality),notes:[...validationNotes(),
|
|
40
40
|
{code:'STATIC_SCAN_LIMITS',message:'源码检查仅覆盖入口可达的本地 JavaScript 和字面量依赖;动态加载、别名调用和局部重名可能无法识别。质量警告不会修改文件,也不代表运行结果或安全保证。'},
|
|
41
|
+
{code:'UNMANAGED_PROCESS_LIMITS',message:'进程检查只定位可识别的直接调用;动态计算、复杂别名、依赖包及 Python/Shell 内部执行不保证覆盖。迁移警告不阻止旧插件加载,不构成安全隔离或自动提权。'},
|
|
41
42
|
]};
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const processMembers=new Set(['spawn','spawnSync','exec','execSync','execFile','execFileSync','fork']);
|
|
2
|
+
const sources=new Set(['node:child_process','child_process','execa','cross-spawn','shelljs','@mytools/fst-platform']);
|
|
3
|
+
const property=node=>node.computed?node.property?.value:node.property?.name;
|
|
4
|
+
|
|
5
|
+
function launches({source,members}){
|
|
6
|
+
const names=members[0]==='default'?members.slice(1):members;
|
|
7
|
+
if(['node:child_process','child_process'].includes(source))return names.length===1&&processMembers.has(names[0]);
|
|
8
|
+
if(source==='execa')return names.length===0||names.length===1&&['execa','execaSync','execaCommand','execaCommandSync','execaNode','$'].includes(names[0]);
|
|
9
|
+
if(source==='cross-spawn')return names.length===0||names.length===1&&['spawn','sync'].includes(names[0]);
|
|
10
|
+
if(source==='shelljs')return names.length===1&&names[0]==='exec';
|
|
11
|
+
return source==='@mytools/fst-platform'&&names.length===1&&names[0]==='runProcess';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 仅诊断能追溯到已知模块的调用,不以模块导入本身判定违规,也不执行源码。
|
|
15
|
+
export function inspectProcessUsage(ast,{bindings,isRequire,walk},warning,location){
|
|
16
|
+
const imported=new Map(),variables=new Map(),mutated=new Set();
|
|
17
|
+
const rootName=node=>node?.type==='Identifier'?node.name:node?.type==='MemberExpression'?rootName(node.object):undefined;
|
|
18
|
+
walk(ast,node=>{
|
|
19
|
+
if(node.type==='ImportDeclaration'&&sources.has(node.source.value))for(const spec of node.specifiers){
|
|
20
|
+
imported.set(spec.local.name,{source:node.source.value,members:spec.type==='ImportNamespaceSpecifier'?[]:[spec.type==='ImportDefaultSpecifier'?'default':spec.imported.name??spec.imported.value]});
|
|
21
|
+
}
|
|
22
|
+
if(node.type==='VariableDeclarator'){
|
|
23
|
+
if(node.id.type==='Identifier')variables.set(node.id.name,{value:node.init,members:[]});
|
|
24
|
+
else if(node.id.type==='ObjectPattern')for(const spec of node.id.properties){
|
|
25
|
+
if(spec.type==='Property'&&spec.value.type==='Identifier'&&(!spec.computed||typeof spec.key.value==='string'))variables.set(spec.value.name,{value:node.init,members:[spec.computed?spec.key.value:spec.key.name??spec.key.value]});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if(['AssignmentExpression','UpdateExpression','UnaryExpression'].includes(node.type)){
|
|
29
|
+
const target=node.left??node.argument;
|
|
30
|
+
if(target?.type==='MemberExpression'&&(node.type!=='UnaryExpression'||node.operator==='delete'))mutated.add(rootName(target));
|
|
31
|
+
if(node.type==='UpdateExpression'&&target?.type==='Identifier')mutated.add(target.name);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
function resolve(node,seen=new Set()){
|
|
35
|
+
if(!node)return;
|
|
36
|
+
if(node.type==='AwaitExpression'||node.type==='ChainExpression')return resolve(node.argument??node.expression,seen);
|
|
37
|
+
if(node.type==='Identifier'){
|
|
38
|
+
if(bindings.get(node.name)!==1||mutated.has(node.name)||seen.has(node.name))return;
|
|
39
|
+
if(imported.has(node.name))return imported.get(node.name);
|
|
40
|
+
const variable=variables.get(node.name);if(!variable)return;
|
|
41
|
+
const result=resolve(variable.value,new Set([...seen,node.name]));
|
|
42
|
+
return result&&{source:result.source,members:[...result.members,...variable.members]};
|
|
43
|
+
}
|
|
44
|
+
if(node.type==='MemberExpression'){
|
|
45
|
+
const name=property(node);if(typeof name!=='string')return;
|
|
46
|
+
const result=resolve(node.object,seen);return result&&{source:result.source,members:[...result.members,name]};
|
|
47
|
+
}
|
|
48
|
+
// Execa 的对象参数只绑定选项,返回的函数被调用时才启动程序。
|
|
49
|
+
if(node.type==='CallExpression'&&node.arguments[0]?.type==='ObjectExpression'){
|
|
50
|
+
const target=resolve(node.callee,seen);
|
|
51
|
+
if(target?.source==='execa'&&launches(target))return target;
|
|
52
|
+
}
|
|
53
|
+
const source=node.type==='ImportExpression'?node.source?.value:node.type==='CallExpression'&&isRequire(node)?node.arguments[0]?.value:undefined;
|
|
54
|
+
if(sources.has(source))return {source,members:[]};
|
|
55
|
+
}
|
|
56
|
+
walk(ast,node=>{
|
|
57
|
+
if(!['CallExpression','TaggedTemplateExpression'].includes(node.type))return;
|
|
58
|
+
const target=resolve(node.callee??node.tag);if(!target||!launches(target))return;
|
|
59
|
+
if(target.source==='execa'&&node.type==='CallExpression'&&node.arguments[0]?.type==='ObjectExpression')return;
|
|
60
|
+
warning('UNMANAGED_PROCESS',`直接调用 ${target.source}${target.members.length?'.'+target.members.join('.'):''} 可能绕过统一执行记录、取消与代理规则;执行外部程序请改用 ctx.runProcess(file, args, options)。此提示不代表该调用已执行`,`${location}:${node.loc.start.line}:${node.loc.start.column+1}`);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
| 导入路径 | 实现位置 |
|
|
13
13
|
| --- | --- |
|
|
14
14
|
| `@mytools/fst-platform` | `./src/process.js` |
|
|
15
|
+
| `@mytools/fst-platform/managed-process` | `./src/managed-process.js` |
|
|
15
16
|
| `@mytools/fst-platform/environments` | `./src/environments.js` |
|
|
16
17
|
| `@mytools/fst-platform/bundle` | `./src/bundle.js` |
|
|
17
18
|
| `@mytools/fst-platform/json` | `./src/json.js` |
|
|
@@ -37,6 +38,10 @@ node --test tests/modules.test.js tests/process.test.js tests/environments.test.
|
|
|
37
38
|
|
|
38
39
|
## 子进程环境
|
|
39
40
|
|
|
41
|
+
`createProcessRunner({execute=runProcess,signal,log,audience})` 是内核调用方共用的受管包装,返回签名兼容 `runProcess(file,args,options)` 的函数。每次调用独立生成 `processId`,先写指令日志再执行自定义 onStart,先写输出日志再执行自定义 onOutput;合并调用级与上下文取消信号,不改变底层并发。插件经宿主 `ctx.runProcess` 使用,不能直接导入本入口代替上下文。权限检查由 plugin 负责,本包装不授予权限、不进行命令黑名单拦截。
|
|
42
|
+
|
|
43
|
+
受管调用可指定 `logPolicy:false` 隐藏自动记录的参数与脚本,或 `{redactArgs:[零起算下标],omitArgs:true,omitScript:true}` 按需选择;实际参数和调用方 onStart 保留原值。有参数被隐藏时脚本也隐藏。无效策略执行前拒绝。该策略不自动识别密钥、不处理插件主动输出及已有日志;平台自动生成的进程启动失败信息也不再拼接 Execa 的完整命令行。
|
|
44
|
+
|
|
40
45
|
`/shell` 的 `runShell(script,{shell='auto',runProcess,temporaryDir,...processOptions})` 使用唯一临时脚本文件,避免长脚本超过系统命令行长度上限,运行结束后删除本次文件。默认 Windows PowerShell / Linux sh,可选 powershell、pwsh、sh、bash;PowerShell 使用 UTF-8 BOM,其他 Shell 使用无 BOM UTF-8。插件传入 `ctx.runProcess` 与 `temporaryDir:ctx.outputDir`,保留当前代理、取消与日志规则,并兼容只读包目录。`shellInvocation` 保留构造简短命令参数的兼容接口。
|
|
41
46
|
|
|
42
47
|
Windows 的 `shell:'auto'` 对单行 `ipconfig` / `ipconfig.exe` 及其字面量参数(如 `ipconfig /all`)直接执行系统目录中的 `ipconfig.exe`,读取当前系统 OEM 代码页后统一解码结果和实时输出。自动适配限于 TextDecoder 支持的 OEM 866、874、932、936、949、950、65001;中文 Windows 的 OEM 936 使用 GBK。代码页探测失败或代码页不受支持(如 437、850)时保留原 Shell 路径。变量、管道、多行、重定向等脚本继续按原 PowerShell 语义执行;复杂脚本中的旧编码与 UTF-8 混合输出没有自动转码。`onStart` 的 `script` 字段保留用户原始指令,直接执行的简单命令同时记录实际程序路径和参数。
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import {runProcess} from './process.js';
|
|
3
|
+
import {FlowError} from '@mytools/sdk';
|
|
4
|
+
|
|
5
|
+
function invocationFilter(policy,args){
|
|
6
|
+
if(policy===undefined)return value=>value;
|
|
7
|
+
const invalid=()=>{throw new FlowError('logPolicy 必须为 false 或含 redactArgs、omitArgs、omitScript 的有效对象;参数下标从 0 开始且不能越界',{code:'VALIDATION'});};
|
|
8
|
+
if(policy!==false&&(!policy||typeof policy!=='object'||Array.isArray(policy)))invalid();
|
|
9
|
+
if(policy!==false){
|
|
10
|
+
if(Object.keys(policy).some(key=>!['redactArgs','omitArgs','omitScript'].includes(key)))invalid();
|
|
11
|
+
for(const key of ['omitArgs','omitScript'])if(policy[key]!==undefined&&typeof policy[key]!=='boolean')invalid();
|
|
12
|
+
if(policy.redactArgs!==undefined&&(!Array.isArray(policy.redactArgs)||policy.redactArgs.some(index=>!Number.isSafeInteger(index)||index<0||index>=(args?.length??0))))invalid();
|
|
13
|
+
}
|
|
14
|
+
const omitted=policy===false||policy.omitArgs===true,indices=new Set(policy.redactArgs??[]);
|
|
15
|
+
const omitScript=omitted||indices.size>0||policy.omitScript===true;
|
|
16
|
+
return value=>({...value,
|
|
17
|
+
...(Array.isArray(value.args)?{args:omitted?(value.args.length?['[参数已隐藏]']:[]):value.args.map((arg,index)=>indices.has(index)?'[已隐藏]':arg)}:{}),
|
|
18
|
+
...(value.script!==undefined&&omitScript?{script:'[原始脚本已隐藏]'}:{}),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 每次调用独立绑定日志和取消信号;共享入口不串行化进程,也不授予额外权限。
|
|
23
|
+
export function createProcessRunner({execute=runProcess,signal,log,audience}={}){
|
|
24
|
+
return (file,args,options={})=>{
|
|
25
|
+
const {logPolicy,...executionOptions}=options,displayInvocation=invocationFilter(logPolicy,args);
|
|
26
|
+
const processId=randomUUID();
|
|
27
|
+
const combined=signal&&options.signal?AbortSignal.any([signal,options.signal]):signal??options.signal;
|
|
28
|
+
return execute(file,args,{...executionOptions,signal:combined,onStart:async invocation=>{
|
|
29
|
+
await log?.('执行程序',displayInvocation(invocation),{kind:'process',processId});
|
|
30
|
+
await options.onStart?.(invocation);
|
|
31
|
+
},onOutput:async({stream,message})=>{
|
|
32
|
+
await log?.(message,undefined,{stream,processId,...(audience?{audience}:{})});
|
|
33
|
+
await options.onOutput?.({stream,message});
|
|
34
|
+
}});
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -5,6 +5,8 @@ import { FlowError } from '@mytools/sdk';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
const stripFinalNewline=text=>text.endsWith('\r\n')?text.slice(0,-2):text.endsWith('\n')?text.slice(0,-1):text;
|
|
8
|
+
// 执行库的校验错误和启动错误都可能包含完整 argv,仅保留安全的错误代码。
|
|
9
|
+
const spawnFailure=(file,code='SPAWN_FAILED')=>new FlowError(`无法启动程序 ${file}:${code};请检查参数格式、执行目录、文件权限及程序依赖`,{code});
|
|
8
10
|
|
|
9
11
|
export async function runProcess(file,args=[],{cwd,env,extendEnv=true,signal,timeout=30000,maxBuffer=4*1024*1024,encoding='utf8',onOutput,onStart,commandText,outputMode='buffer',summaryBytes=65536}={}) {
|
|
10
12
|
if(!['buffer','stream'].includes(outputMode))throw new TypeError('outputMode 必须为 buffer 或 stream');
|
|
@@ -38,7 +40,9 @@ export async function runProcess(file,args=[],{cwd,env,extendEnv=true,signal,tim
|
|
|
38
40
|
if(signal?.aborted)throw new FlowError('操作已取消',{code:'CANCELLED'});
|
|
39
41
|
// 平台统一收集原始字节和执行上限;Execa 的超限收集器会提前关闭管道,
|
|
40
42
|
// 导致持续输出的父进程因 EPIPE 退出,taskkill 尚未遍历就失去父 PID。
|
|
41
|
-
|
|
43
|
+
let child;
|
|
44
|
+
try{child=execa(executable,args,{cwd,env,extendEnv,reject:false,windowsHide:true,maxBuffer,buffer:false,timeout:0,shell:false,encoding:'buffer',stripFinalNewline:false});}
|
|
45
|
+
catch(error){throw spawnFailure(file,error.code);}
|
|
42
46
|
let reason,killing,outputError,outputFailed=false,outputWrites=Promise.resolve();
|
|
43
47
|
let markStopped;const stopped=new Promise(resolve=>{markStopped=resolve;});
|
|
44
48
|
const outputBytes={stdout:0,stderr:0},tails={stdout:'',stderr:''},buffers={stdout:[],stderr:[]};let outputTruncated=false;
|
|
@@ -110,7 +114,8 @@ export async function runProcess(file,args=[],{cwd,env,extendEnv=true,signal,tim
|
|
|
110
114
|
if(reason==='output-limit')throw new FlowError('程序输出超过上限',{code:'OUTPUT_LIMIT'});
|
|
111
115
|
if(reason)throw new FlowError(reason==='cancelled'?'操作已取消':'程序运行超时',{code:reason==='cancelled'?'CANCELLED':'TIMEOUT'});
|
|
112
116
|
if(result.isMaxBuffer)throw new FlowError('程序输出超过上限',{code:'OUTPUT_LIMIT'});
|
|
113
|
-
|
|
117
|
+
// Execa 的短错误包含完整 argv,不能重新写回已由受管入口隐藏的敏感参数。
|
|
118
|
+
if(result.exitCode===undefined&&result.failed)throw spawnFailure(file,result.code);
|
|
114
119
|
const decode=chunks=>stripFinalNewline(new TextDecoder(decoderEncoding).decode(Buffer.concat(chunks)));
|
|
115
120
|
if(streaming)return {exitCode:result.exitCode,stdout:stripFinalNewline(tails.stdout),stderr:stripFinalNewline(tails.stderr),outputMode,outputTruncated,outputBytes};
|
|
116
121
|
return {exitCode:result.exitCode,stdout:decode(buffers.stdout),stderr:decode(buffers.stderr)};
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
|
|
21
21
|
## 维护与验证
|
|
22
22
|
|
|
23
|
+
动作及命令的上下文 runProcess 使用 platform/managed-process 的共用包装,统一指令、输出关联与取消信号;代理仍由宿主按动作构造。权限预检发生在动作主体之前,不自动提权。共享包装不串行化调用;宿主同进程信任模型不变,开发约束不能替代安全隔离。
|
|
24
|
+
|
|
25
|
+
动作、动作别名命令及独立交互命令共用 SDK 输入解析。顶层输入字段的 `x-fst.fromConfig` 声明先取实际插件配置,再回退到输入 schema 默认值;显式输入优先。`host.previewInput(target, input)` 返回有效值及来源,管理界面无需读取整份宿主配置。声明由 `validateDeclarations` 检查,插件加载和开发工具使用同一规则。
|
|
26
|
+
|
|
23
27
|
本模块负责上述职责的实现、文档与回归;跨模块接口调整需要同时检查生产者与消费者。具体维护人员尚未指定。
|
|
24
28
|
|
|
25
29
|
在仓库根目录执行:
|
|
@@ -3,6 +3,7 @@ import {conditionOperators} from '@mytools/sdk/conditions';
|
|
|
3
3
|
import {resolveSchemaPath} from '@mytools/sdk/schema-path';
|
|
4
4
|
import {inside} from '@mytools/fst-platform/environments';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import {validateInputRules} from '@mytools/sdk/inputs';
|
|
6
7
|
|
|
7
8
|
export const builtinCommands=[
|
|
8
9
|
['workflows','选择已有工作流'],['new','新建工作流'],['add','添加插件能力或子工作流'],
|
|
@@ -37,6 +38,8 @@ function validatePrivilegeDeclarations(plugin){
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export function validateDeclarations(plugin){
|
|
41
|
+
for(const [name,owner] of Object.entries(plugin.actions??{}))validateInputRules(owner?.input,plugin.config,`plugin.actions.${name}.input`);
|
|
42
|
+
for(const [name,owner] of Object.entries(plugin.commands??{}))if(!owner?.action)validateInputRules(owner?.input,plugin.config,`plugin.commands.${name}.input`);
|
|
40
43
|
for(const [name,action] of Object.entries(plugin.actions??{})){
|
|
41
44
|
if(!action||typeof action!=='object'||Array.isArray(action))throw new FlowError(`动作 ${name} 的声明必须为对象`,{code:'VALIDATION'});
|
|
42
45
|
if(action.conditions!==undefined&&(!action.conditions||typeof action.conditions!=='object'||Array.isArray(action.conditions)))throw new FlowError(`动作 ${name} 的 conditions 必须为对象`,{code:'VALIDATION'});
|
|
@@ -2,15 +2,17 @@ import { Context } from 'cordis';
|
|
|
2
2
|
import semver from 'semver';
|
|
3
3
|
import { SDK_VERSION, FlowError } from '@mytools/sdk';
|
|
4
4
|
import { validate, emptyObject } from '@mytools/sdk/schema';
|
|
5
|
+
import {resolveInput} from '@mytools/sdk/inputs';
|
|
5
6
|
import {validateDeclarations,validateCommands} from './contracts.js';
|
|
6
7
|
import {prepareEnvironments,platformWarnings} from '@mytools/fst-platform/environments';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import {mkdir} from 'node:fs/promises';
|
|
9
10
|
import {createHash} from 'node:crypto';
|
|
10
|
-
import {createRuntime,validateRuntimes} from './runtime.js';
|
|
11
|
+
import {createRuntime,validateRuntimes,validateRuntimeDeclaration} from './runtime.js';
|
|
11
12
|
import {pluginError} from '@mytools/sdk/diagnostics';
|
|
12
13
|
import {validateNetworkPolicy,resolveNetworkPolicy,createHttpClient,buildProcessEnv} from '@mytools/fst-platform/network';
|
|
13
14
|
import {resolvePrivilegeRequirement,resolvePrivilegePreflight,checkStaticPrivilege,runPrivilegePreflight} from './privileges.js';
|
|
15
|
+
import {createProcessRunner} from '@mytools/fst-platform/managed-process';
|
|
14
16
|
|
|
15
17
|
export async function createHost({ plugins = [], config = {}, services = {}, project,network,environmentOverrides={},runtimeEnabled=true,allowUnknownPrivileges=false } = {}) {
|
|
16
18
|
const currentNetwork=()=>typeof network==='function'?network():network;
|
|
@@ -25,16 +27,7 @@ export async function createHost({ plugins = [], config = {}, services = {}, pro
|
|
|
25
27
|
const bound={...capabilities,...context},execute=bound.runProcess;
|
|
26
28
|
// 面向用户的输出与诊断日志共用持久化通道,只区分默认可见性。
|
|
27
29
|
bound.print=(message,data)=>Promise.resolve(bound.log?.(message,data,{audience:'user'}));
|
|
28
|
-
if(typeof execute==='function')bound.runProcess=(
|
|
29
|
-
const signal=context.signal&&options.signal?AbortSignal.any([context.signal,options.signal]):context.signal??options.signal;
|
|
30
|
-
return execute(file,args,{...options,signal,onStart:async invocation=>{
|
|
31
|
-
await bound.log?.('执行程序',invocation,{kind:'process'});
|
|
32
|
-
await options.onStart?.(invocation);
|
|
33
|
-
},onOutput:async({stream,message})=>{
|
|
34
|
-
await bound.log?.(message,undefined,{stream});
|
|
35
|
-
await options.onOutput?.({stream,message});
|
|
36
|
-
}});
|
|
37
|
-
};
|
|
30
|
+
if(typeof execute==='function')bound.runProcess=createProcessRunner({execute,signal:context.signal,log:bound.log});
|
|
38
31
|
return bound;
|
|
39
32
|
}
|
|
40
33
|
async function pluginContext(plugin,action,context){
|
|
@@ -161,6 +154,12 @@ export async function createHost({ plugins = [], config = {}, services = {}, pro
|
|
|
161
154
|
}
|
|
162
155
|
},
|
|
163
156
|
commands(){assertReadable();return [...commands.values()].map(({run,preflight,config,...metadata})=>structuredClone(metadata));},
|
|
157
|
+
previewInput(target,input={}){
|
|
158
|
+
assertReadable();
|
|
159
|
+
const owner=target.startsWith('/')?commands.get(target.slice(1)):actions.get(target);
|
|
160
|
+
if(!owner)throw new FlowError(`参数目标不存在:${target}`,{code:'VALIDATION'});
|
|
161
|
+
return resolveInput(owner.input??emptyObject,input,owner.config,{check:false,label:`${target} 输入`});
|
|
162
|
+
},
|
|
164
163
|
async invokeCommand(name,input,context={}){
|
|
165
164
|
assertHealthy();
|
|
166
165
|
const command=commands.get(name);if(!command)throw new FlowError(`命令不存在:/${name}`);
|
|
@@ -170,7 +169,7 @@ export async function createHost({ plugins = [], config = {}, services = {}, pro
|
|
|
170
169
|
try{
|
|
171
170
|
const getService=runtime.bindServices(plugin.id);
|
|
172
171
|
context.signal?.throwIfAborted();
|
|
173
|
-
const checked=
|
|
172
|
+
const checked=resolveInput(command.input??emptyObject,input,command.config,{label:`/${name} 输入`}).value;
|
|
174
173
|
const bound=await pluginContext(plugin,undefined,context);
|
|
175
174
|
let result;
|
|
176
175
|
try{
|
|
@@ -189,7 +188,7 @@ export async function createHost({ plugins = [], config = {}, services = {}, pro
|
|
|
189
188
|
try{
|
|
190
189
|
const getService=runtime.bindServices(action.plugin);
|
|
191
190
|
context.signal?.throwIfAborted();
|
|
192
|
-
const checked=
|
|
191
|
+
const checked=resolveInput(action.input??emptyObject,input,action.config,{label:`${id} 输入`}).value;
|
|
193
192
|
const plugin=definitions.get(action.plugin),bound=await pluginContext(plugin,action,context);
|
|
194
193
|
let result;
|
|
195
194
|
try{
|
|
@@ -219,25 +218,41 @@ export async function createHost({ plugins = [], config = {}, services = {}, pro
|
|
|
219
218
|
};
|
|
220
219
|
}
|
|
221
220
|
|
|
222
|
-
//
|
|
223
|
-
export function
|
|
221
|
+
// 单插件契约检查不依赖其他插件,供启动恢复路径精确归属故障。
|
|
222
|
+
export function validatePluginDefinition(entry){
|
|
223
|
+
const plugin=entry?.plugin??entry;
|
|
224
|
+
if(!plugin||typeof plugin!=='object'||Array.isArray(plugin)||typeof plugin.id!=='string'||!plugin.id)throw new FlowError('插件默认导出必须是包含 id 的对象',{code:'VALIDATION'});
|
|
225
|
+
validateDeclarations(plugin);validateCommands(plugin,new Set());
|
|
226
|
+
if(!semver.satisfies(SDK_VERSION,plugin.sdk??'^0.1.0'))throw new FlowError(`插件 ${plugin.id} 与 SDK ${SDK_VERSION} 不兼容`,{code:'VALIDATION'});
|
|
227
|
+
for(const [name,action] of Object.entries(plugin.actions??{}))if(typeof action?.run!=='function')throw new FlowError(`动作 ${plugin.id}:${name} 缺少执行函数`,{code:'VALIDATION'});
|
|
228
|
+
for(const [name,extension] of Object.entries(plugin.services??{}))if(typeof extension!=='function')throw new FlowError(`服务 ${name} 缺少执行函数`,{code:'VALIDATION'});
|
|
229
|
+
validateRuntimeDeclaration(plugin,new Context());
|
|
230
|
+
return plugin;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 恢复启动只豁免已确认故障的节点;其他可确定的声明和依赖错误仍明确拒绝。
|
|
234
|
+
export function validatePluginDefinitions(entries,{unavailable=new Set(),blocked=new Set()}={}){
|
|
224
235
|
const definitions=new Map(),commandNames=new Set(),serviceNames=new Set();
|
|
225
236
|
for(const entry of entries){
|
|
226
237
|
const plugin=entry.plugin??entry;
|
|
227
|
-
validateDeclarations(plugin);validateCommands(plugin,commandNames);
|
|
228
238
|
if(!plugin.id||definitions.has(plugin.id))throw new FlowError(`插件标识缺失或重复:${plugin.id}`,{code:'VALIDATION'});
|
|
229
|
-
if(
|
|
230
|
-
|
|
231
|
-
for(const
|
|
239
|
+
if(unavailable.has(plugin.id)){definitions.set(plugin.id,{...plugin,root:entry.root});continue;}
|
|
240
|
+
validatePluginDefinition(entry);validateCommands(plugin,commandNames);
|
|
241
|
+
for(const name of Object.keys(plugin.services??{})){
|
|
232
242
|
if(serviceNames.has(name))throw new FlowError(`服务重复:${name}`,{code:'VALIDATION'});
|
|
233
|
-
if(typeof extension!=='function')throw new FlowError(`服务 ${name} 缺少执行函数`,{code:'VALIDATION'});
|
|
234
243
|
serviceNames.add(name);
|
|
235
244
|
}
|
|
236
245
|
definitions.set(plugin.id,{...plugin,root:entry.root});
|
|
237
246
|
}
|
|
238
|
-
for(const plugin of definitions.values())
|
|
239
|
-
|
|
247
|
+
for(const plugin of definitions.values()){
|
|
248
|
+
// 此节点已隔离且不会执行;它的损坏依赖不能阻断其他健康插件恢复。
|
|
249
|
+
if(unavailable.has(plugin.id))continue;
|
|
250
|
+
for(const [id,range] of Object.entries(plugin.dependencies??{})){
|
|
251
|
+
// 无法读取目标版本时保留恢复入口;已知版本仍须满足健康消费者的声明。
|
|
252
|
+
if(unavailable.has(id)&&!semver.valid(definitions.get(id)?.version))continue;
|
|
253
|
+
if(!definitions.has(id)||!semver.satisfies(definitions.get(id).version,range))throw new FlowError(`插件 ${plugin.id} 缺少兼容依赖 ${id}@${range}`,{code:'VALIDATION'});
|
|
254
|
+
}
|
|
240
255
|
}
|
|
241
|
-
validateRuntimes(definitions,new Context());
|
|
256
|
+
validateRuntimes(new Map([...definitions].filter(([id])=>!unavailable.has(id))),new Context(),{blocked});
|
|
242
257
|
return definitions;
|
|
243
258
|
}
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import {FlowError} from '@mytools/sdk';
|
|
2
2
|
|
|
3
|
+
export function validateRuntimeDeclaration(p,ctx){
|
|
4
|
+
const r=p.runtime;if(r===undefined)return;
|
|
5
|
+
if(!r||typeof r.apply!=='function')throw new FlowError(`${p.id} runtime 必须提供 apply`);
|
|
6
|
+
for(const key of ['provides','inject']){
|
|
7
|
+
const names=r[key]??[];
|
|
8
|
+
if(!Array.isArray(names)||names.some(n=>typeof n!=='string'||!/^\w[\w.-]*$/.test(n)||n in ctx)||new Set(names).size!==names.length)throw new FlowError(`${p.id} runtime.${key} 服务名称无效或重复`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
// 静态声明用于诊断与打包,运行时激活仍由 Cordis inject 驱动。
|
|
4
|
-
export function validateRuntimes(definitions,ctx){
|
|
13
|
+
export function validateRuntimes(definitions,ctx,{blocked=new Set()}={}){
|
|
5
14
|
const providers=new Map(),ordered=[],visiting=new Set(),visited=new Set();
|
|
6
15
|
for(const p of definitions.values()){
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
for(const key of ['provides','inject']){
|
|
10
|
-
const names=r[key]??[];
|
|
11
|
-
if(!Array.isArray(names)||names.some(n=>typeof n!=='string'||!/^\w[\w.-]*$/.test(n)||n in ctx)||new Set(names).size!==names.length)throw new FlowError(`${p.id} runtime.${key} 服务名称无效或重复`);
|
|
12
|
-
}
|
|
13
|
-
for(const name of r.provides??[]){
|
|
16
|
+
validateRuntimeDeclaration(p,ctx);
|
|
17
|
+
for(const name of p.runtime?.provides??[]){
|
|
14
18
|
if(providers.has(name))throw new FlowError(`服务重复:${name}(${providers.get(name)}、${p.id})`);
|
|
15
19
|
providers.set(name,p.id);
|
|
16
20
|
}
|
|
@@ -21,6 +25,8 @@ export function validateRuntimes(definitions,ctx){
|
|
|
21
25
|
visiting.add(id);const p=definitions.get(id);
|
|
22
26
|
for(const name of p.runtime?.inject??[]){
|
|
23
27
|
const owner=providers.get(name);
|
|
28
|
+
// 故障依赖的声明不可读取时,只跳过无法确认归属的服务;已知冲突与环仍检查。
|
|
29
|
+
if(!owner&&blocked.has(id))continue;
|
|
24
30
|
if(!owner)throw new FlowError(`${id} 缺少服务提供者:${name}`);
|
|
25
31
|
if(owner!==id&&!Object.hasOwn(p.dependencies??{},owner))throw new FlowError(`${id} 的 dependencies 必须声明服务 ${name} 的提供者 ${owner}`);
|
|
26
32
|
visit(owner);
|
|
@@ -57,7 +57,7 @@ export async function runWorkflow({store,host,id,input={},signal,onEvent=()=>{},
|
|
|
57
57
|
let output;
|
|
58
58
|
if(step.action)output=await host.invoke(step.action,args,{
|
|
59
59
|
...context,signal:currentSignal,runId,stepId:step.id,callPath,workflow:w,
|
|
60
|
-
log:(message,data,meta={})=>emit({type:'log',stream:meta.stream,...(meta.audience==='user'?{audience:'user'}:{}),...(['process','source'].includes(meta.kind)?{kind:meta.kind}:{}),...base,message,data}),
|
|
60
|
+
log:(message,data,meta={})=>emit({type:'log',stream:meta.stream,...(typeof meta.processId==='string'?{processId:meta.processId}:{}),...(meta.audience==='user'?{audience:'user'}:{}),...(['process','source'].includes(meta.kind)?{kind:meta.kind}:{}),...base,message,data}),
|
|
61
61
|
});
|
|
62
62
|
else if(step.workflow)output=await execute(store.childId(step.workflow),args,callPath,currentSignal,reportFailure);
|
|
63
63
|
else if(step.branch){
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
|
|
29
29
|
`/shortcuts` 提供 `validateShortcuts` 校验、`bindShortcutCommands` 在用户配置时补齐命令提供者身份,以及 `describeShortcuts` 生成可用或失效状态。绑定函数可传入 previous 保留旧项:未绑定命令修改参数或重命名仍保持失效,需重新选择目标。命令项使用 command 与 plugin 同时匹配,动作项使用稳定 action ID;不执行其他插件的同名能力。实际保存、备份与宿主装配由 core 协调。
|
|
30
30
|
|
|
31
|
+
`WorkflowStore.list()` 保留正常工作流,同时返回 `status: 'invalid'` 的损坏文件诊断条目,`diagnostic` 含文件路径、错误消息以及可获得的 YAML 行列。诊断条目不能保存或复制。`list({strict:true})` 遇错误即拒绝,删除和卸载依赖扫描必须使用此模式;`get/check/run` 保持严格读取。`resolve()` 对根及全部子流程的输入 Schema 提前编译,打包与执行共用闭包预检,不会先执行前置业务步骤才发现子流程声明错误。
|
|
32
|
+
|
|
33
|
+
`installGeneration` 的可选 `recoverInstalled` 回调仅用于纯卸载时重新读取剩余旧插件,交由 core 提供恢复条目;默认调用、新安装和链接保持严格加载。
|
|
34
|
+
|
|
31
35
|
本模块负责上述职责的实现、文档与回归;跨模块接口调整需要同时检查生产者与消费者。具体维护人员尚未指定。
|
|
32
36
|
|
|
33
37
|
在仓库根目录执行:
|
|
@@ -33,7 +33,7 @@ function referencedActions(steps,id,result=new Set()){
|
|
|
33
33
|
export async function registryWorkflowReferences(registryDir,id){
|
|
34
34
|
const projects=await readJson(path.join(registryDir,'.flow','workspaces.json'),[]),references=[];
|
|
35
35
|
for(const projectDir of projects){
|
|
36
|
-
for(const workflow of await new WorkflowStore(path.join(projectDir,'workflows')).list()){
|
|
36
|
+
for(const workflow of await new WorkflowStore(path.join(projectDir,'workflows')).list({strict:true})){
|
|
37
37
|
const actions=referencedActions(workflow.steps,id);
|
|
38
38
|
if(actions.length)references.push({projectDir,id:workflow.id,title:workflow.title??workflow.id,actions});
|
|
39
39
|
}
|
|
@@ -5,7 +5,7 @@ import {FlowError} from '@mytools/sdk';
|
|
|
5
5
|
import {readJson} from '@mytools/fst-platform/json';
|
|
6
6
|
import {npm} from '@mytools/fst-platform/npm';
|
|
7
7
|
import {loadPlugin} from '@mytools/fst-plugin/loader';
|
|
8
|
-
export async function installGeneration(projectDir,{add,remove,link,state,sourceDir=projectDir}) {
|
|
8
|
+
export async function installGeneration(projectDir,{add,remove,link,state,sourceDir=projectDir,recoverInstalled}) {
|
|
9
9
|
const root=path.join(projectDir,'.flow','installations',randomUUID());
|
|
10
10
|
await mkdir(root,{recursive:true});
|
|
11
11
|
const dependencies={...(state?.dependencies??{})};
|
|
@@ -32,7 +32,9 @@ export async function installGeneration(projectDir,{add,remove,link,state,source
|
|
|
32
32
|
}
|
|
33
33
|
const pkg=await readJson(path.join(root,'package.json'));
|
|
34
34
|
const loaded=[];
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// 卸载故障插件时允许保留其他旧故障;安装/链接操作仍严格校验新批次。
|
|
36
|
+
const load=remove&&!add&&!link&&recoverInstalled?recoverInstalled:(_id,spec,base)=>loadPlugin(spec,base);
|
|
37
|
+
for(const name of Object.keys(pkg.dependencies??{})){delete links[name];disabledLegacy.delete(name);loaded.push(await load(name,name,root));}
|
|
38
|
+
for(const [name,source] of Object.entries(links))loaded.push(await load(name,source,projectDir,{linked:true}));
|
|
37
39
|
return {state:{root,dependencies:pkg.dependencies??{},links,...(state?.global?{global:true,disabledLegacy:[...disabledLegacy]}:{}),...(state?.root?{previousRoot:state.root}:{})},loaded};
|
|
38
40
|
}
|
|
@@ -12,14 +12,21 @@ export class WorkflowStore {
|
|
|
12
12
|
file(id) { assertId(id); return path.join(this.root,`${id}.yaml`); }
|
|
13
13
|
async get(id) {
|
|
14
14
|
const file=this.file(id);
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
try{
|
|
16
|
+
const physical=await realpath(file);
|
|
17
|
+
const root=await realpath(this.root);
|
|
18
|
+
if (!physical.startsWith(root+path.sep)) throw new FlowError('工作流越界,必须位于工作流目录内',{code:'VALIDATION'});
|
|
19
|
+
const definition=YAML.parse(await readFile(file,'utf8'));
|
|
20
|
+
if (definition?.id !== id) throw new FlowError(`工作流 ID 与文件名不一致:${id}`,{code:'VALIDATION'});
|
|
21
|
+
return this.validateShape(definition);
|
|
22
|
+
}catch(error){
|
|
23
|
+
const position=error.linePos?.[0];
|
|
24
|
+
const details={file,...(position?{line:position.line,column:position.col}:{}),reason:error.code};
|
|
25
|
+
throw new FlowError(`工作流读取失败:${file}${position?`:${position.line}:${position.col}`:''}\n${error.message}`,{code:'VALIDATION',details,cause:error});
|
|
26
|
+
}
|
|
21
27
|
}
|
|
22
28
|
validateShape(w) {
|
|
29
|
+
if(w?.status==='invalid')throw new FlowError(w.diagnostic?.message??'损坏工作流的诊断条目不能保存或复制,请先修复原文件',{code:'VALIDATION'});
|
|
23
30
|
assertId(w?.id);
|
|
24
31
|
if (w.schemaVersion!==1 || !Array.isArray(w.steps)) throw new FlowError('工作流格式错误:需要 schemaVersion: 1 与 steps 列表',{code:'VALIDATION'});
|
|
25
32
|
validateStepList(w.steps,assertId);
|
|
@@ -66,16 +73,26 @@ export class WorkflowStore {
|
|
|
66
73
|
throw error;
|
|
67
74
|
}
|
|
68
75
|
}
|
|
69
|
-
async list() {
|
|
76
|
+
async list({strict=false}={}) {
|
|
70
77
|
await mkdir(this.root,{recursive:true});
|
|
71
78
|
const files=await readdir(this.root);
|
|
72
|
-
return Promise.all(files.filter(f=>f.endsWith('.yaml')).sort().map(f=>
|
|
79
|
+
return Promise.all(files.filter(f=>f.endsWith('.yaml')).sort().map(async f=>{
|
|
80
|
+
const id=f.slice(0,-5);
|
|
81
|
+
try{return await this.get(id);}
|
|
82
|
+
catch(error){
|
|
83
|
+
if(strict)throw error;
|
|
84
|
+
const file=path.join(this.root,f),diagnostic={...error.details,file,message:error.message};
|
|
85
|
+
// 保留数组协议和已有 Web 展示字段;此条目仅供诊断,不是可保存的定义。
|
|
86
|
+
return {id,title:`不可用:${f}`,description:`${file}\n${error.message}`,status:'invalid',diagnostic,inputs:false,steps:[],outputs:{}};
|
|
87
|
+
}
|
|
88
|
+
}));
|
|
73
89
|
}
|
|
74
90
|
async remove(id,{backup=true}={}){
|
|
75
91
|
if(typeof backup!=='boolean')throw new FlowError('backup 必须是布尔值',{code:'VALIDATION'});
|
|
76
92
|
await this.get(id);
|
|
77
93
|
const refers=steps=>steps.some(step=>step.workflow&&this.childId(step.workflow)===id||step.branch&&(refers(step.branch.then)||refers(step.branch.else??[]))||step.parallel&&refers(step.parallel.steps));
|
|
78
|
-
|
|
94
|
+
// 文件无法读取时不能证明不存在引用,必须阻止删除。
|
|
95
|
+
const dependents=(await this.list({strict:true})).filter(flow=>flow.id!==id&&refers(flow.steps));
|
|
79
96
|
if(dependents.length)throw new FlowError(`工作流仍被引用:${dependents.map(flow=>flow.title??flow.id).join('、')}`);
|
|
80
97
|
if(!backup){await unlink(this.file(id));return null;}
|
|
81
98
|
const directory=path.join(path.dirname(this.root),'.flow','deleted-workflows');
|
|
@@ -95,6 +112,7 @@ export class WorkflowStore {
|
|
|
95
112
|
if(workflows.has(id)) return;
|
|
96
113
|
visiting.add(id);
|
|
97
114
|
const w=await this.get(id);
|
|
115
|
+
assertSchema(w.inputs??emptyObject,`${this.file(id)} inputs schema`);
|
|
98
116
|
const checker=previous=>ref=>{
|
|
99
117
|
const p=referenceParts(ref);
|
|
100
118
|
if(p[0]==='steps' && !previous.has(p[1])) throw new FlowError(`${id} 引用必须指向前序步骤:${ref}`,{code:'VALIDATION'});
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
| --- | --- |
|
|
14
14
|
| `@mytools/sdk` | `./index.js` |
|
|
15
15
|
| `@mytools/sdk/schema` | `./validation.js` |
|
|
16
|
+
| `@mytools/sdk/inputs` | `./inputs.js` |
|
|
17
|
+
| `@mytools/sdk/reports` | `./reports.js` |
|
|
16
18
|
| `@mytools/sdk/schema-path` | `./schema-path.js` |
|
|
17
19
|
| `@mytools/sdk/diagnostics` | `./diagnostics.js` |
|
|
18
20
|
| `@mytools/sdk/conditions` | `./conditions.js` |
|
|
@@ -29,3 +31,11 @@
|
|
|
29
31
|
```sh
|
|
30
32
|
node --test tests/modules.test.js tests/plugin-contract.test.js tests/schema-diagnostics.test.js
|
|
31
33
|
```
|
|
34
|
+
|
|
35
|
+
## 输入解析
|
|
36
|
+
|
|
37
|
+
`@mytools/sdk/inputs` 提供 `validateInputRules`、`hasInputRules`、`resolveInput`。输入字段 `x-fst` 支持 `fromConfig`、`unsetLabel` 和 `unit`,由插件宿主与 TUI 共用。`resolveInput` 返回有效输入和字段来源,始终复制输入;配置继承优先于输入固定默认值,显式输入优先于继承。声明范围与示例见插件开发文档。
|
|
38
|
+
|
|
39
|
+
## 业务报告
|
|
40
|
+
|
|
41
|
+
`@mytools/sdk/reports` 提供 `reportSchema`、`createReport(content)`、`inspectReport(value)`。v1 支持标题、摘要、分级提醒、建议、业务分组及推荐列;SDK 不负责界面渲染。helper 校验并复制 JSON 内容,错误码为 `REPORT_INVALID`。检查接口返回 `none` / `valid` / `invalid` / `unsupported` 状态,合法时 `report` 指向原值(只读使用),非法时包含 `message` 和可选 `details`。普通输出无需接入;已有插件可将报告放入 `output.report`,保留原数据字段。完整示例见 `docs/plugin-development.md`。
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {FlowError} from '@mytools/sdk';
|
|
2
|
+
import {assertSchema,validate,emptyObject} from './validation.js';
|
|
3
|
+
|
|
4
|
+
const object=value=>value!==null&&typeof value==='object'&&!Array.isArray(value);
|
|
5
|
+
const complex=['$ref','oneOf','anyOf','allOf','if','then','else','dependencies','patternProperties'];
|
|
6
|
+
export const hasInputRules=schema=>Object.values(schema?.properties??{}).some(field=>field?.['x-fst']!==undefined);
|
|
7
|
+
|
|
8
|
+
// 仅解释已声明的顶层输入规则,不分析插件执行代码或按同名字段猜测继承关系。
|
|
9
|
+
export function validateInputRules(schema,configSchema,label='input'){
|
|
10
|
+
const fail=(at,message)=>{throw new FlowError(`${at} x-fst:${message}`,{code:'VALIDATION',details:{path:at}});};
|
|
11
|
+
const visit=(node,at,allowed=false)=>{
|
|
12
|
+
if(!object(node))return;
|
|
13
|
+
const rule=node['x-fst'];
|
|
14
|
+
if(rule!==undefined){
|
|
15
|
+
if(!allowed||schema.type!=='object'||complex.some(key=>schema[key]!==undefined||node[key]!==undefined))fail(at,'只支持简单输入对象的顶层字段');
|
|
16
|
+
if(!object(rule)||Object.keys(rule).some(key=>!['fromConfig','unsetLabel','unit'].includes(key)))fail(at,'仅支持 fromConfig、unsetLabel、unit');
|
|
17
|
+
for(const [key,value] of Object.entries(rule))if(typeof value!=='string'||!value.trim())fail(at,`${key} 必须为非空字符串`);
|
|
18
|
+
if(rule.fromConfig!==undefined){
|
|
19
|
+
let current=configSchema;
|
|
20
|
+
for(const part of rule.fromConfig.split('.')){
|
|
21
|
+
if(!part||['__proto__','constructor','prototype'].includes(part)||!Object.hasOwn(current?.properties??{},part))fail(at,`配置路径未声明:${rule.fromConfig}`);
|
|
22
|
+
current=current.properties[part];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for(const key of ['properties','patternProperties','$defs','definitions','dependencies','dependentSchemas'])for(const [name,child] of Object.entries(node[key]??{}))visit(child,`${at}.${key}.${name}`,node===schema&&key==='properties');
|
|
27
|
+
for(const key of ['items','additionalItems','additionalProperties','contains','not','if','then','else','propertyNames']){
|
|
28
|
+
if(Array.isArray(node[key]))node[key].forEach((child,index)=>visit(child,`${at}.${key}.${index}`));
|
|
29
|
+
else visit(node[key],`${at}.${key}`);
|
|
30
|
+
}
|
|
31
|
+
for(const key of ['allOf','anyOf','oneOf','prefixItems'])node[key]?.forEach(child=>visit(child,`${at}.${key}`));
|
|
32
|
+
};
|
|
33
|
+
visit(schema,label);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveInput(schema=emptyObject,input={},config={}, {check=true,label='参数'}={}){
|
|
37
|
+
const value=structuredClone(input),fields={};
|
|
38
|
+
if(object(value))for(const [key,field] of Object.entries(schema?.properties??{})){
|
|
39
|
+
const rule=field?.['x-fst']??{};
|
|
40
|
+
let source=Object.hasOwn(value,key)&&value[key]!==undefined?'input':'omitted';
|
|
41
|
+
if(source==='omitted'&&rule.fromConfig){
|
|
42
|
+
let inherited=config;
|
|
43
|
+
for(const part of rule.fromConfig.split('.'))inherited=inherited!=null&&Object.hasOwn(inherited,part)?inherited[part]:undefined;
|
|
44
|
+
if(inherited!==undefined){Object.defineProperty(value,key,{value:structuredClone(inherited),enumerable:true,writable:true,configurable:true});source='config';}
|
|
45
|
+
}
|
|
46
|
+
Object.defineProperty(fields,key,{value:{source,...(source==='config'?{path:rule.fromConfig}:{}),...(rule.unit?{unit:rule.unit}:{}),...(rule.unsetLabel?{unsetLabel:rule.unsetLabel}:{})},enumerable:true});
|
|
47
|
+
}
|
|
48
|
+
// 预览允许缺少必填项;校验器仍与执行共用,默认值只作用于副本。
|
|
49
|
+
const effective=check?validate(schema,value,label):value;
|
|
50
|
+
if(!check&&schema!=null)assertSchema(schema,label)(effective);
|
|
51
|
+
for(const [key,field] of Object.entries(fields))if(field.source==='omitted'&&effective?.[key]!==undefined)field.source='default';
|
|
52
|
+
return {value:effective,fields};
|
|
53
|
+
}
|