@yanglaofish/fishtools 0.2.0 → 0.4.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 +43 -17
- package/manual/README.md +43 -17
- package/manual/docs/examples/pipeline-reader/LICENSE +21 -0
- package/manual/docs/examples/pipeline-reader/index.js +14 -0
- package/manual/docs/examples/pipeline-reader/package.json +7 -0
- package/manual/docs/examples/pipeline-writer/LICENSE +21 -0
- package/manual/docs/examples/pipeline-writer/index.js +17 -0
- package/manual/docs/examples/pipeline-writer/package.json +7 -0
- package/manual/docs/examples/script-plugin/LICENSE +21 -0
- package/manual/docs/examples/script-plugin/README.md +64 -0
- package/manual/docs/examples/script-plugin/index.js +39 -0
- package/manual/docs/examples/script-plugin/package.json +9 -0
- package/manual/docs/examples/script-plugin/scripts/benchmark.py +23 -0
- package/manual/docs/plugin-development.md +75 -14
- package/manual/docs/releases.md +30 -1
- package/manual/docs/research/2026-10-11-plugin-kernel-capabilities.md +167 -0
- package/manual/docs/roadmap.md +8 -1
- package/manual/docs/superpowers/plans/2026-10-11-control-observation.md +1 -1
- package/manual/docs/superpowers/plans/2026-10-11-public-plugin-fixes.md +40 -0
- package/manual/docs/superpowers/plans/2026-10-11-terminal-hierarchy.md +40 -0
- package/manual/docs/superpowers/plans/2026-10-12-plugin-developer-experience.md +50 -0
- package/manual/docs/superpowers/reviews/2026-10-12-plugin-release-review.md +20 -0
- package/manual/docs/superpowers/specs/2026-10-11-collapsed-plugin-output.md +23 -0
- package/manual/docs/superpowers/specs/2026-10-12-plugin-developer-experience.md +22 -0
- package/manual/docs/verification.md +100 -0
- package/manual/packages/core/src/application.js +235 -0
- package/manual/packages/core/src/engine.js +93 -0
- package/manual/packages/core/src/host.js +22 -5
- package/manual/packages/core/src/runtime.js +135 -0
- package/manual/packages/core/src/terminal/run-feed.js +78 -0
- package/manual/packages/core/src/terminal/session.js +438 -0
- package/manual/packages/core/src/terminal/view.js +283 -0
- package/manual/tests/application.test.js +30 -0
- package/node_modules/@mytools/core/bin/fst.js +27 -5
- package/node_modules/@mytools/core/package.json +2 -0
- package/node_modules/@mytools/core/src/application.js +46 -21
- package/node_modules/@mytools/core/src/contracts.js +27 -9
- package/node_modules/@mytools/core/src/documentation.js +1 -1
- package/node_modules/@mytools/core/src/engine.js +1 -1
- package/node_modules/@mytools/core/src/environments.js +39 -1
- package/node_modules/@mytools/core/src/host.js +22 -5
- package/node_modules/@mytools/core/src/markdown-log.js +1 -1
- package/node_modules/@mytools/core/src/plugin-installations.js +84 -0
- package/node_modules/@mytools/core/src/plugin-tools.js +133 -0
- package/node_modules/@mytools/core/src/plugins.js +16 -6
- package/node_modules/@mytools/core/src/process.js +30 -8
- package/node_modules/@mytools/core/src/schema-path.js +31 -0
- package/node_modules/@mytools/core/src/terminal/model.js +1 -1
- package/node_modules/@mytools/core/src/terminal/navigation.js +17 -0
- package/node_modules/@mytools/core/src/terminal/presentation.js +3 -3
- package/node_modules/@mytools/core/src/terminal/run-feed.js +78 -0
- package/node_modules/@mytools/core/src/terminal/run-presentation.js +60 -0
- package/node_modules/@mytools/core/src/terminal/run-viewer.js +30 -12
- package/node_modules/@mytools/core/src/terminal/session.js +118 -50
- package/node_modules/@mytools/core/src/terminal/theme.js +1 -0
- package/node_modules/@mytools/core/src/terminal/view.js +125 -28
- package/node_modules/@mytools/core/src/terminal/workflow-menu.js +50 -0
- package/node_modules/@mytools/core/src/validation.js +24 -9
- package/node_modules/@mytools/core/src/version.js +1 -1
- package/node_modules/@mytools/core/src/workflows.js +13 -15
- package/node_modules/@mytools/pack/dependencies.js +8 -1
- package/node_modules/@mytools/pack/index.js +4 -2
- package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +871 -870
- package/node_modules/{tar → minipass-flush/node_modules/minipass}/node_modules/yallist/README.md +7 -8
- package/node_modules/minipass-flush/node_modules/minipass/node_modules/yallist/package.json +29 -0
- package/node_modules/yallist/README.md +8 -7
- package/node_modules/yallist/package.json +51 -12
- package/package.json +15 -15
- package/node_modules/tar/node_modules/yallist/package.json +0 -68
- /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/LICENSE +0 -0
- /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/iterator.js +0 -0
- /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/yallist.js +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/LICENSE.md +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.d.ts +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.d.ts.map +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.js +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.js.map +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/package.json +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.d.ts +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.d.ts.map +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.js +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.js.map +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/package.json +0 -0
|
@@ -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,...base,message,data}),
|
|
60
|
+
log:(message,data,meta={})=>emit({type:'log',stream:meta.stream,...(meta.audience==='user'?{audience:'user'}:{}),...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){
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import {FlowError} from '@mytools/sdk';
|
|
3
|
+
import which from 'which';
|
|
3
4
|
|
|
4
5
|
export const currentTarget=()=>`${process.platform}-${process.arch}${process.platform==='linux'?'-glibc':''}`;
|
|
5
6
|
export function inside(root,relative){
|
|
@@ -12,10 +13,14 @@ export function environmentDeclarations(plugin,action){return {...plugin.environ
|
|
|
12
13
|
export function resolveEnvironments(plugin,action,overrides={},target=currentTarget()){
|
|
13
14
|
return Object.fromEntries(Object.entries(environmentDeclarations(plugin,action)).map(([id,declaration])=>{
|
|
14
15
|
const selected=overrides[id];
|
|
15
|
-
if(selected!==undefined&&(!selected||typeof selected!=='object'||Array.isArray(selected)||typeof selected.source!=='string'||
|
|
16
|
+
if(selected!==undefined&&(!selected||typeof selected!=='object'||Array.isArray(selected)||Object.keys(selected).some(k=>!['source','executable'].includes(k))||selected.source!==undefined&&typeof selected.source!=='string'||selected.executable!==undefined&&(typeof selected.executable!=='string'||!selected.executable.trim())))throw new FlowError(`环境来源覆盖无效:${plugin.id}/${id}`);
|
|
16
17
|
const source=selected?.source??declaration.source;
|
|
17
18
|
if(!(declaration.allowedSources??[declaration.source]).includes(source))throw new FlowError(`环境 ${plugin.id}/${id} 不允许来源 ${source}`);
|
|
18
19
|
const e={...declaration,source};delete e.artifacts;
|
|
20
|
+
if(selected?.executable!==undefined){
|
|
21
|
+
if(source!=='host')throw new FlowError(`只有 host 环境允许覆盖解释器:${plugin.id}/${id}`);
|
|
22
|
+
e.executable=selected.executable;
|
|
23
|
+
}
|
|
19
24
|
if(source==='tool'){
|
|
20
25
|
if(id!=='node')throw new FlowError(`工具尚未提供环境:${id}`);
|
|
21
26
|
e.executable=process.execPath;
|
|
@@ -27,3 +32,36 @@ export function resolveEnvironments(plugin,action,overrides={},target=currentTar
|
|
|
27
32
|
return [id,e];
|
|
28
33
|
}));
|
|
29
34
|
}
|
|
35
|
+
|
|
36
|
+
// 打包只解析声明;真正调用时才在当前工作区选择候选,避免将开发机路径写入跨平台分发包。
|
|
37
|
+
export async function prepareEnvironments(plugin,action,overrides={}, {cwd=process.cwd()}={}){
|
|
38
|
+
const resolved=resolveEnvironments(plugin,action,overrides);
|
|
39
|
+
for(const [id,environment] of Object.entries(resolved)){
|
|
40
|
+
if(environment.source!=='host'||!Array.isArray(environment.executable)&&overrides[id]?.executable===undefined)continue;
|
|
41
|
+
const candidates=Array.isArray(environment.executable)?environment.executable:[environment.executable];
|
|
42
|
+
const windows=process.platform==='win32',directory=path.resolve(cwd);
|
|
43
|
+
const searchDirectories=[...(windows?[directory]:[]),...(process.env.PATH??'').split(path.delimiter)]
|
|
44
|
+
.map(entry=>path.resolve(directory,entry.replace(/^"(.*)"$/,'$1')));
|
|
45
|
+
let found;
|
|
46
|
+
for(const candidate of candidates){
|
|
47
|
+
const direct=path.isAbsolute(candidate)||candidate.includes('/')||windows&&candidate.includes('\\');
|
|
48
|
+
// 使用绝对候选,防止 which 在 Windows 擅自优先搜索宿主启动目录。
|
|
49
|
+
const paths=direct?[path.resolve(directory,candidate)]:searchDirectories.map(base=>path.resolve(base,candidate));
|
|
50
|
+
for(const executable of paths){
|
|
51
|
+
found=await which(executable,{nothrow:true});
|
|
52
|
+
if(found)break;
|
|
53
|
+
}
|
|
54
|
+
if(found)break;
|
|
55
|
+
}
|
|
56
|
+
if(!found)throw new FlowError(`环境 ${plugin.id}/${id} 未找到程序:${candidates.join('、')}`,{code:'ENOENT',details:{plugin:plugin.id,environment:id,candidates}});
|
|
57
|
+
environment.executable=path.resolve(found);
|
|
58
|
+
}
|
|
59
|
+
return resolved;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function platformWarnings(plugin,platform=process.platform,architecture=process.arch){
|
|
63
|
+
const warnings=[];
|
|
64
|
+
if(plugin.platforms&&!plugin.platforms.includes(platform))warnings.push(`插件 ${plugin.id} 的平台声明不包含当前 ${platform},继续执行;请核实插件支持范围`);
|
|
65
|
+
if(plugin.architectures&&!plugin.architectures.includes(architecture))warnings.push(`插件 ${plugin.id} 的架构声明不包含当前 ${architecture},继续执行;请核实插件支持范围`);
|
|
66
|
+
return warnings;
|
|
67
|
+
}
|
|
@@ -3,7 +3,10 @@ import semver from 'semver';
|
|
|
3
3
|
import { SDK_VERSION, FlowError } from '@mytools/sdk';
|
|
4
4
|
import { validate, emptyObject } from './validation.js';
|
|
5
5
|
import {validateDeclarations,validateCommands} from './contracts.js';
|
|
6
|
-
import {
|
|
6
|
+
import {prepareEnvironments,platformWarnings} from './environments.js';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import {mkdir} from 'node:fs/promises';
|
|
9
|
+
import {createHash} from 'node:crypto';
|
|
7
10
|
import {createRuntime} from './runtime.js';
|
|
8
11
|
import {pluginError} from './diagnostics.js';
|
|
9
12
|
|
|
@@ -16,6 +19,8 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
16
19
|
const commandNames=new Set(),commands=new Map();
|
|
17
20
|
function bindContext(context){
|
|
18
21
|
const bound={...services,...context},execute=bound.runProcess;
|
|
22
|
+
// 面向用户的输出与诊断日志共用持久化通道,只区分默认可见性。
|
|
23
|
+
bound.print=(message,data)=>Promise.resolve(bound.log?.(message,data,{audience:'user'}));
|
|
19
24
|
if(typeof execute==='function')bound.runProcess=(file,args,options={})=>{
|
|
20
25
|
const signal=context.signal&&options.signal?AbortSignal.any([context.signal,options.signal]):context.signal??options.signal;
|
|
21
26
|
return execute(file,args,{...options,signal,onOutput:async({stream,message})=>{
|
|
@@ -25,6 +30,17 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
25
30
|
};
|
|
26
31
|
return bound;
|
|
27
32
|
}
|
|
33
|
+
async function pluginContext(plugin,action,context){
|
|
34
|
+
const bound=bindContext(context),application=services.application;
|
|
35
|
+
const projectDir=application?.projectDir;
|
|
36
|
+
// 固定长度摘要避免 scoped 包名分隔符、大小写碰撞及合法长包名超出文件名上限。
|
|
37
|
+
const stateDir=projectDir&&!application.project.readOnly?path.join(projectDir,'.flow','plugin-state',createHash('sha256').update(plugin.id).digest('hex')):undefined;
|
|
38
|
+
if(stateDir)await mkdir(stateDir,{recursive:true});
|
|
39
|
+
for(const message of platformWarnings(plugin))await bound.log?.(message,{code:'PLATFORM_MISMATCH'},{audience:'user'});
|
|
40
|
+
const environments=await prepareEnvironments(plugin,action,environmentOverrides[plugin.id],{cwd:projectDir??process.cwd()});
|
|
41
|
+
context.signal?.throwIfAborted();
|
|
42
|
+
return {...bound,projectDir,stateDir,environments};
|
|
43
|
+
}
|
|
28
44
|
for (const entry of plugins) {
|
|
29
45
|
const plugin = entry.plugin ?? entry;
|
|
30
46
|
validateDeclarations(plugin);validateCommands(plugin,commandNames);
|
|
@@ -78,6 +94,7 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
78
94
|
}
|
|
79
95
|
return {
|
|
80
96
|
definitions, actions,
|
|
97
|
+
warnings:[...definitions.values()].flatMap(plugin=>platformWarnings(plugin).map(message=>({plugin:plugin.id,message}))),
|
|
81
98
|
hasRuntime:runtime.hasRuntime,
|
|
82
99
|
runtimeStatus:()=>runtime.status(),
|
|
83
100
|
commands(){return [...commands.values()].map(({run,config,...metadata})=>structuredClone(metadata));},
|
|
@@ -90,9 +107,9 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
90
107
|
try{
|
|
91
108
|
const getService=runtime.bindServices(plugin.id);
|
|
92
109
|
context.signal?.throwIfAborted();
|
|
93
|
-
const result=await command.run(validate(command.input??emptyObject,input,`/${name} 输入`),{...
|
|
110
|
+
const result=await command.run(validate(command.input??emptyObject,input,`/${name} 输入`),{...await pluginContext(plugin,undefined,context),getService,config:command.config,resourceDir:plugin.root});
|
|
94
111
|
context.signal?.throwIfAborted();
|
|
95
|
-
return validate(command.output,JSON.parse(JSON.stringify(result??{})),`/${name}
|
|
112
|
+
return validate(command.output,JSON.parse(JSON.stringify(result??{})),`/${name} 输出`,{useDefaults:false});
|
|
96
113
|
}catch(cause){throw pluginError(cause,{plugin:plugin.id,command:`/${name}`});}
|
|
97
114
|
},
|
|
98
115
|
catalog() { return [...actions.values()].map(({run,config,root,...metadata})=>structuredClone(metadata)); },
|
|
@@ -103,12 +120,12 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
103
120
|
try{
|
|
104
121
|
const getService=runtime.bindServices(action.plugin);
|
|
105
122
|
context.signal?.throwIfAborted();
|
|
106
|
-
const result = await action.run(validate(action.input ?? emptyObject,input,`${id} 输入`), {...
|
|
123
|
+
const result = await action.run(validate(action.input ?? emptyObject,input,`${id} 输入`), {...await pluginContext(definitions.get(action.plugin),action,context),getService, config:action.config, resourceDir:action.root});
|
|
107
124
|
context.signal?.throwIfAborted();
|
|
108
125
|
// 通过 JSON 边界验证返回值,避免函数、循环引用等进入工作流状态。
|
|
109
126
|
const encoded = JSON.stringify(result);
|
|
110
127
|
if (encoded === undefined) throw new FlowError(`${id} 输出必须可 JSON 序列化`);
|
|
111
|
-
return validate(action.output, JSON.parse(encoded),`${id}
|
|
128
|
+
return validate(action.output, JSON.parse(encoded),`${id} 输出`,{useDefaults:false});
|
|
112
129
|
}catch(cause){throw pluginError(cause,{plugin:action.plugin,action:id});}
|
|
113
130
|
},
|
|
114
131
|
async service(name,options) {
|
|
@@ -32,7 +32,7 @@ export function markdownHeader(run){
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export function markdownEvent(event){
|
|
35
|
-
let output=`### ${names[event.type]??'事件'}\n\n`;
|
|
35
|
+
let output=`### ${event.type==='log'&&event.audience==='user'?'用户输出':names[event.type]??'事件'}\n\n`;
|
|
36
36
|
const lines=[`时间:${event.time}`];
|
|
37
37
|
for(const [key,label] of [['callPath','调用路径'],['action','动作'],['plugin','插件'],['command','命令'],['stream','输出流'],['durationMs','耗时(毫秒)']]){
|
|
38
38
|
if(event[key]!==undefined)lines.push(`${label}:${event[key]}`);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {mkdir,readFile,writeFile,rename,readdir,realpath,lstat,rm,unlink,rmdir} from 'node:fs/promises';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {isDeepStrictEqual} from 'node:util';
|
|
5
|
+
import {create as archive} from 'tar';
|
|
6
|
+
import {FlowError} from '@mytools/sdk';
|
|
7
|
+
|
|
8
|
+
async function json(file,fallback){try{return JSON.parse(await readFile(file,'utf8'));}catch(error){if(error.code==='ENOENT')return fallback;throw error;}}
|
|
9
|
+
const flow=project=>path.join(project,'.flow');
|
|
10
|
+
const stateFile=project=>path.join(flow(project),'plugins.json');
|
|
11
|
+
export const readPluginState=project=>json(stateFile(project),{});
|
|
12
|
+
|
|
13
|
+
// 所有安装/引用/回收操作共用工作区锁,防止其他 fst 进程同时切换批次。
|
|
14
|
+
export async function withPluginMutation(project,callback){
|
|
15
|
+
await mkdir(flow(project),{recursive:true});const lock=path.join(flow(project),'plugin-mutation.lock');
|
|
16
|
+
try{await mkdir(lock);}catch(error){if(error.code==='EEXIST')throw new FlowError('插件状态正在被其他进程修改;若上次异常退出,请确认没有活动 fst 后移除 .flow/plugin-mutation.lock',{code:'PLUGIN_BUSY'});throw error;}
|
|
17
|
+
try{return await callback();}finally{await rmdir(lock);}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function leaseInstallation(project,root){
|
|
21
|
+
if(!root)return async()=>{};
|
|
22
|
+
const directory=path.join(flow(project),'installation-leases');await mkdir(directory,{recursive:true});
|
|
23
|
+
const file=path.join(directory,`${randomUUID()}.json`);
|
|
24
|
+
await writeFile(file,JSON.stringify({pid:process.pid,root:path.resolve(root)}));
|
|
25
|
+
return async()=>{try{await unlink(file);}catch(error){if(error.code!=='ENOENT')throw error;}};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function openPluginState(project,{readOnly=false}={}){
|
|
29
|
+
const existing=await readPluginState(project);
|
|
30
|
+
if(readOnly||!existing.root)return {state:existing,release:async()=>{}};
|
|
31
|
+
return withPluginMutation(project,async()=>{const state=await readPluginState(project);return {state,release:await leaseInstallation(project,state.root)};});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function assertPluginState(project,expected){
|
|
35
|
+
if(!isDeepStrictEqual(await readPluginState(project),expected))throw new FlowError('插件状态已被其他进程修改,请重启当前宿主后再操作',{code:'PLUGIN_STATE_CHANGED'});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function savePluginState(project,next,previous){
|
|
39
|
+
const backup=path.join(flow(project),'plugins.previous.json'),id=randomUUID();
|
|
40
|
+
await writeFile(`${backup}.${id}.tmp`,JSON.stringify(previous,null,2));await rename(`${backup}.${id}.tmp`,backup);
|
|
41
|
+
const file=stateFile(project);await writeFile(`${file}.${id}.tmp`,JSON.stringify(next,null,2));await rename(`${file}.${id}.tmp`,file);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function collectInstallations(project,{apply=false}={}){
|
|
45
|
+
// 预览仅读取,不创建锁或备份目录;执行时在锁内重新计算,不能复用旧预览。
|
|
46
|
+
const collect=async()=>{
|
|
47
|
+
const base=path.join(flow(project),'installations'),state=await readPluginState(project),previous=await json(path.join(flow(project),'plugins.previous.json'),{});
|
|
48
|
+
const protectedRoots=new Set([state.root,state.previousRoot,previous.root].filter(Boolean).map(root=>path.resolve(root)));
|
|
49
|
+
let leases=[];try{leases=await readdir(path.join(flow(project),'installation-leases'));}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
50
|
+
for(const file of leases){
|
|
51
|
+
const lease=await json(path.join(flow(project),'installation-leases',file),null);if(!lease)continue;
|
|
52
|
+
// 无法确认进程已退出时宁可保留;PID 重用只会保守地多保留一个批次。
|
|
53
|
+
try{process.kill(lease.pid,0);protectedRoots.add(path.resolve(lease.root));}catch(error){if(error.code!=='ESRCH')protectedRoots.add(path.resolve(lease.root));}
|
|
54
|
+
}
|
|
55
|
+
let entries=[];try{entries=await readdir(base,{withFileTypes:true});}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
56
|
+
const candidates=[],kept=[],removed=[];
|
|
57
|
+
const canonicalBase=entries.length?await realpath(base):base;
|
|
58
|
+
if(entries.length){
|
|
59
|
+
const projectRoot=await realpath(project),relative=path.relative(projectRoot,canonicalBase);
|
|
60
|
+
if(relative==='..'||relative.startsWith('..'+path.sep)||path.isAbsolute(relative)||(await lstat(base)).isSymbolicLink())throw new FlowError('安装目录越界或是目录链接,拒绝清理');
|
|
61
|
+
for(const root of [...protectedRoots]){try{protectedRoots.add(await realpath(root));}catch(error){if(error.code!=='ENOENT')throw error;}}
|
|
62
|
+
}
|
|
63
|
+
for(const entry of entries){
|
|
64
|
+
const directory=path.join(base,entry.name);
|
|
65
|
+
if(!entry.isDirectory()||!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/.test(entry.name)){kept.push({directory,reason:'非受管批次'});continue;}
|
|
66
|
+
const physical=await realpath(directory);
|
|
67
|
+
if(path.dirname(physical)!==canonicalBase||(await lstat(directory)).isSymbolicLink()||protectedRoots.has(physical)){kept.push({directory,reason:'当前、恢复、活动批次或链接'});continue;}
|
|
68
|
+
const pkg=await json(path.join(directory,'package.json'),{});
|
|
69
|
+
if(pkg.name!=='flow-project-plugins'){kept.push({directory,reason:'无法确认批次归属'});continue;}
|
|
70
|
+
candidates.push({directory});
|
|
71
|
+
if(apply){
|
|
72
|
+
const backups=path.join(flow(project),'installation-backups');await mkdir(backups,{recursive:true});
|
|
73
|
+
const backup=path.join(backups,`${entry.name}-${randomUUID()}.tgz`),temporary=`${backup}.tmp`;
|
|
74
|
+
await archive({cwd:canonicalBase,file:temporary,gzip:true,follow:false,portable:true},[entry.name]);
|
|
75
|
+
await rename(temporary,backup);
|
|
76
|
+
// 备份完成后再次确认真实绝对路径仍是本项目的直接子目录,才允许递归删除。
|
|
77
|
+
if(await realpath(directory)!==physical||path.dirname(physical)!==canonicalBase)throw new FlowError('安装批次路径在清理期间改变,已停止');
|
|
78
|
+
await rm(physical,{recursive:true,maxRetries:5,retryDelay:200});removed.push({directory,backup});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {candidates,kept,removed};
|
|
82
|
+
};
|
|
83
|
+
return apply?withPluginMutation(project,collect):collect();
|
|
84
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {readFile,writeFile,mkdir,mkdtemp,rename,rm,access} from 'node:fs/promises';
|
|
3
|
+
import semver from 'semver';
|
|
4
|
+
import validateNpmPackageName from 'validate-npm-package-name';
|
|
5
|
+
import {SDK_VERSION} from '@mytools/sdk';
|
|
6
|
+
import {loadPlugin} from './plugins.js';
|
|
7
|
+
import {validateDeclarations,validateCommands} from './contracts.js';
|
|
8
|
+
import {assertSchema} from './validation.js';
|
|
9
|
+
import {platformWarnings} from './environments.js';
|
|
10
|
+
|
|
11
|
+
const validNpmName=name=>typeof name==='string'&&validateNpmPackageName(name).validForNewPackages===true;
|
|
12
|
+
const hasOwn=(value,key)=>Object.hasOwn(value,key);
|
|
13
|
+
|
|
14
|
+
function inspectRequired(schema,location,warning,seen=new WeakSet()){
|
|
15
|
+
if(!schema||typeof schema!=='object'||seen.has(schema))return;
|
|
16
|
+
seen.add(schema);
|
|
17
|
+
if(Array.isArray(schema.required)){
|
|
18
|
+
schema.required.forEach((name,index)=>{
|
|
19
|
+
if(typeof name==='string'&&!hasOwn(schema.properties??{},name))warning('REQUIRED_UNDECLARED',`必填字段 ${name} 未在同层 properties 声明;请检查 required 所在层级`,`${location}.required[${index}]`);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
for(const key of ['properties','patternProperties','$defs','definitions']){
|
|
23
|
+
for(const [name,child] of Object.entries(schema[key]??{}))inspectRequired(child,`${location}.${key}.${name}`,warning,seen);
|
|
24
|
+
}
|
|
25
|
+
for(const key of ['items','additionalProperties','not','if','then','else'])inspectRequired(schema[key],`${location}.${key}`,warning,seen);
|
|
26
|
+
for(const key of ['allOf','anyOf','oneOf','prefixItems']){
|
|
27
|
+
if(Array.isArray(schema[key]))schema[key].forEach((child,index)=>inspectRequired(child,`${location}.${key}[${index}]`,warning,seen));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function validatePluginDirectory(directory){
|
|
32
|
+
const root=path.resolve(directory),diagnostics=[];
|
|
33
|
+
const report=(severity,code,message,location)=>diagnostics.push({severity,code,message,...(location?{path:location}:{})});
|
|
34
|
+
const error=(code,message,location)=>report('error',code,message,location);
|
|
35
|
+
const warning=(code,message,location)=>report('warning',code,message,location);
|
|
36
|
+
let pkg,plugin;
|
|
37
|
+
try{pkg=JSON.parse(await readFile(path.join(root,'package.json'),'utf8'));}
|
|
38
|
+
catch(cause){error('PACKAGE_READ_FAILED',`无法读取 package.json:${cause.message}`,'package.json');return {valid:false,plugin:{},diagnostics};}
|
|
39
|
+
if(!pkg||typeof pkg!=='object'||Array.isArray(pkg)){error('INVALID_PACKAGE','package.json 必须是对象','package.json');return {valid:false,plugin:{},diagnostics};}
|
|
40
|
+
if(!validNpmName(pkg.name))error('INVALID_PACKAGE_NAME','package.json 的 name 必须是合法 npm 包名','package.json.name');
|
|
41
|
+
if(!semver.valid(pkg.version))error('INVALID_PACKAGE_VERSION','package.json 的 version 必须是 semver 版本','package.json.version');
|
|
42
|
+
if(pkg.type!=='module')error('ESM_REQUIRED','插件必须声明 type: module','package.json.type');
|
|
43
|
+
try{({plugin}=await loadPlugin('.',root));}
|
|
44
|
+
catch(cause){error('PLUGIN_LOAD_FAILED',`无法导入插件:${cause.message}`,'package.json.main');return {valid:false,plugin:{},diagnostics};}
|
|
45
|
+
if(!plugin||typeof plugin!=='object'||Array.isArray(plugin)){error('INVALID_PLUGIN','默认导出必须是插件定义对象','plugin');return {valid:false,plugin:{},diagnostics};}
|
|
46
|
+
const identity={id:plugin.id,version:plugin.version};
|
|
47
|
+
if(!validNpmName(plugin.id))error('INVALID_PLUGIN_ID','插件 id 必须是合法 npm 包名','plugin.id');
|
|
48
|
+
else if(plugin.id!==pkg.name)error('PLUGIN_ID_MISMATCH',`插件 id ${plugin.id} 与包名 ${pkg.name} 不一致`,'plugin.id');
|
|
49
|
+
if(!semver.valid(plugin.version))error('INVALID_PLUGIN_VERSION','插件 version 必须是 semver 版本','plugin.version');
|
|
50
|
+
else if(plugin.version!==pkg.version)error('PLUGIN_VERSION_MISMATCH',`插件 version ${plugin.version} 与包版本 ${pkg.version} 不一致`,'plugin.version');
|
|
51
|
+
if(typeof plugin.sdk!=='string'||!semver.validRange(plugin.sdk))error('INVALID_SDK_RANGE','插件 sdk 必须明确声明有效的 semver 范围','plugin.sdk');
|
|
52
|
+
else if(!semver.satisfies(SDK_VERSION,plugin.sdk))error('SDK_INCOMPATIBLE',`插件 SDK 范围 ${plugin.sdk} 不包含当前 ${SDK_VERSION}`,'plugin.sdk');
|
|
53
|
+
for(const [key,location] of [['platforms','plugin.platforms'],['architectures','plugin.architectures']]){
|
|
54
|
+
if(plugin[key]===undefined)warning(`${key.toUpperCase()}_UNDECLARED`,`${key} 未声明,请明确插件支持范围`,location);
|
|
55
|
+
}
|
|
56
|
+
try{validateDeclarations(plugin);}catch(cause){error('INVALID_DECLARATION',cause.message,cause.details?.path??'plugin');}
|
|
57
|
+
if(['platforms','architectures'].every(key=>plugin[key]===undefined||Array.isArray(plugin[key]))) {
|
|
58
|
+
for(const message of platformWarnings(plugin))warning('PLATFORM_MISMATCH',message,message.includes('架构')?'plugin.architectures':'plugin.platforms');
|
|
59
|
+
}
|
|
60
|
+
try{validateCommands(plugin,new Set());}catch(cause){error('INVALID_COMMAND',cause.message,'plugin.commands');}
|
|
61
|
+
for(const [location,schema] of [['plugin.config',plugin.config]]){
|
|
62
|
+
if(schema!==undefined){try{assertSchema(schema,location);inspectRequired(schema,location,warning);}catch(cause){error('INVALID_SCHEMA',cause.message,location);}}
|
|
63
|
+
}
|
|
64
|
+
if(plugin.actions!==undefined&&(!plugin.actions||typeof plugin.actions!=='object'||Array.isArray(plugin.actions)))error('INVALID_ACTIONS','actions 必须是动作字典','plugin.actions');
|
|
65
|
+
for(const [name,action] of Object.entries(plugin.actions??{})){
|
|
66
|
+
const location=`plugin.actions.${name}`;
|
|
67
|
+
if(!action||typeof action!=='object'){error('INVALID_ACTION','动作必须是对象',location);continue;}
|
|
68
|
+
if(typeof action.run!=='function')error('MISSING_RUN','动作必须声明 run 函数',`${location}.run`);
|
|
69
|
+
for(const key of ['input','output']){
|
|
70
|
+
const schema=action[key],at=`${location}.${key}`;
|
|
71
|
+
if(schema===undefined){error('MISSING_SCHEMA',`动作 ${name} 必须明确声明 ${key} schema`,at);continue;}
|
|
72
|
+
try{assertSchema(schema,at);inspectRequired(schema,at,warning);}catch(cause){error('INVALID_SCHEMA',cause.message,at);}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if(plugin.commands!==undefined&&(!plugin.commands||typeof plugin.commands!=='object'||Array.isArray(plugin.commands)))error('INVALID_COMMANDS','commands 必须是命令字典','plugin.commands');
|
|
76
|
+
for(const [name,command] of Object.entries(plugin.commands??{})){
|
|
77
|
+
if(!command||typeof command!=='object'||command.action)continue;
|
|
78
|
+
for(const key of ['input','output']){
|
|
79
|
+
const schema=command[key],at=`plugin.commands.${name}.${key}`;
|
|
80
|
+
if(schema===undefined){error('MISSING_SCHEMA',`命令 ${name} 必须明确声明 ${key} schema`,at);continue;}
|
|
81
|
+
try{assertSchema(schema,at);inspectRequired(schema,at,warning);}catch(cause){error('INVALID_SCHEMA',cause.message,at);}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if(plugin.dependencies!==undefined){
|
|
85
|
+
if(!plugin.dependencies||typeof plugin.dependencies!=='object'||Array.isArray(plugin.dependencies))error('INVALID_DEPENDENCIES','dependencies 必须是插件 ID 到版本范围的字典','plugin.dependencies');
|
|
86
|
+
else for(const [name,range] of Object.entries(plugin.dependencies)){
|
|
87
|
+
if(!validNpmName(name))error('INVALID_DEPENDENCY_ID',`依赖插件 ID 无效:${name}`,`plugin.dependencies.${name}`);
|
|
88
|
+
if(typeof range!=='string'||!semver.validRange(range))error('INVALID_DEPENDENCY_RANGE',`依赖 ${name} 的版本范围无效`,`plugin.dependencies.${name}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if(plugin.runtime!==undefined){
|
|
92
|
+
const runtime=plugin.runtime;
|
|
93
|
+
if(!runtime||typeof runtime!=='object'||Array.isArray(runtime))error('INVALID_RUNTIME','runtime 必须是声明对象','plugin.runtime');
|
|
94
|
+
else{
|
|
95
|
+
if(typeof runtime.apply!=='function')error('INVALID_RUNTIME','runtime 必须提供 apply 函数','plugin.runtime.apply');
|
|
96
|
+
for(const key of ['provides','inject']){
|
|
97
|
+
const names=runtime[key]??[];
|
|
98
|
+
if(!Array.isArray(names)||names.some(name=>typeof name!=='string'||!/^\w[\w.-]*$/.test(name))||new Set(names).size!==names.length)
|
|
99
|
+
error('INVALID_RUNTIME',`runtime.${key} 必须是不重复的有效服务名数组`,`plugin.runtime.${key}`);
|
|
100
|
+
}
|
|
101
|
+
if(Array.isArray(runtime.provides)&&Array.isArray(runtime.inject)&&runtime.inject.some(name=>runtime.provides.includes(name)))
|
|
102
|
+
error('RUNTIME_SELF_DEPENDENCY','runtime.inject 不能依赖本插件提供的服务','plugin.runtime.inject');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {valid:!diagnostics.some(item=>item.severity==='error'),plugin:identity,diagnostics};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function scaffold(name){
|
|
109
|
+
const packageJson={name,version:'1.0.0',type:'module',main:'index.js',files:['index.js','test','README.md','LICENSE'],scripts:{test:'node --test'},license:'MIT',engines:{node:'>=24.15.0'}};
|
|
110
|
+
const plugin=`// 直接导出插件定义,无需安装内部 SDK。\nexport default {\n id:${JSON.stringify(name)},version:'1.0.0',sdk:'^0.1.0',\n title:'嵌套输入示例',description:'演示业务结果、嵌套输入和判定预设。',\n platforms:['win32','linux','darwin'],architectures:['x64','arm64'],\n actions:{\n check:{\n title:'检查请求',description:'根据嵌套请求生成业务结果。',\n input:{type:'object',properties:{request:{type:'object',properties:{name:{type:'string',minLength:1,title:'姓名'},enabled:{type:'boolean',title:'启用'}},required:['name','enabled'],additionalProperties:false}},required:['request'],additionalProperties:false},\n output:{type:'object',properties:{ok:{type:'boolean'},message:{type:'string'}},required:['ok','message'],additionalProperties:false},\n conditions:{passed:{title:'业务通过',path:'ok',op:'eq',value:true}},\n async run({request},{signal}={}){\n signal?.throwIfAborted?.();\n return request.enabled?{ok:true,message:\`你好,\${request.name}\`}:{ok:false,message:'已禁用'};\n },\n },\n },\n};\n`;
|
|
111
|
+
const testSource=`import test from 'node:test';\nimport assert from 'node:assert/strict';\nimport plugin from '../index.js';\n\ntest('嵌套参数和业务判定',async()=>{\n assert.deepEqual(await plugin.actions.check.run({request:{name:'阿青',enabled:true}}),{ok:true,message:'你好,阿青'});\n assert.deepEqual(await plugin.actions.check.run({request:{name:'阿青',enabled:false}}),{ok:false,message:'已禁用'});\n});\n`;
|
|
112
|
+
const readme=`# ${name}\n\n可独立安装的 Fishtools 插件示例,不依赖尚未公开发布的 SDK。\n\n## 使用\n\n在本目录运行 \`npm test\`,并运行 \`fst plugin validate .\` 检查声明。安装后可将 \`${name}:check\` 加入工作流。\n\n输入示例:\n\n\`\`\`json\n{"request":{"name":"阿青","enabled":true}}\n\`\`\`\n\n输出含 \`ok\` 与 \`message\`。\`ok:false\` 表示业务未通过,但动作仍正常返回;\`conditions.passed\` 可供工作流分支使用。请同步修改 package.json 和 index.js 中的名称、版本,并根据实际支持情况调整 platforms / architectures。\n`;
|
|
113
|
+
const license=`MIT License\n\nCopyright (c) ${new Date().getFullYear()} ${name}\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the "Software"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n`;
|
|
114
|
+
return {'package.json':`${JSON.stringify(packageJson,null,2)}\n`,'index.js':plugin,'README.md':readme,'LICENSE':license,'test/plugin.test.js':testSource};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function createPlugin(name,directory=path.resolve(name)){
|
|
118
|
+
if(!validNpmName(name))throw new Error('插件名称必须是合法 npm 包名(可带 @scope)');
|
|
119
|
+
const target=path.resolve(directory),parent=path.dirname(target);
|
|
120
|
+
await mkdir(parent,{recursive:true});
|
|
121
|
+
try{await access(target);throw new Error(`目标目录已存在:${target}`);}catch(cause){if(cause.code!=='ENOENT')throw cause;}
|
|
122
|
+
const staging=await mkdtemp(path.join(parent,'.plugin-create-'));
|
|
123
|
+
try{
|
|
124
|
+
for(const [file,contents] of Object.entries(scaffold(name))){
|
|
125
|
+
const destination=path.join(staging,file);
|
|
126
|
+
await mkdir(path.dirname(destination),{recursive:true});
|
|
127
|
+
await writeFile(destination,contents,{flag:'wx'});
|
|
128
|
+
}
|
|
129
|
+
try{await access(target);throw new Error(`目标目录已存在:${target}`);}catch(cause){if(cause.code!=='ENOENT')throw cause;}
|
|
130
|
+
await rename(staging,target);
|
|
131
|
+
}catch(cause){await rm(staging,{recursive:true,force:true});throw cause;}
|
|
132
|
+
return {directory:target};
|
|
133
|
+
}
|
|
@@ -35,21 +35,31 @@ export async function npm(args,options={}) {
|
|
|
35
35
|
return runProcess('npm',args,{timeout:180000,...options});
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export async function installGeneration(projectDir,{add,remove,state}) {
|
|
38
|
+
export async function installGeneration(projectDir,{add,remove,link,state}) {
|
|
39
39
|
const root=path.join(projectDir,'.flow','installations',randomUUID());
|
|
40
40
|
await mkdir(root,{recursive:true});
|
|
41
41
|
const dependencies={...(state?.dependencies??{})};
|
|
42
|
-
|
|
42
|
+
const links={...(state?.links??{})};
|
|
43
|
+
if(remove){delete dependencies[remove];delete links[remove];}
|
|
44
|
+
if(link){
|
|
45
|
+
const source=path.resolve(link),pkg=await readJson(path.join(source,'package.json'));
|
|
46
|
+
const loaded=await loadPlugin(source,projectDir);
|
|
47
|
+
if(!pkg.name||pkg.name!==loaded.plugin?.id||pkg.version!==loaded.plugin?.version)throw new FlowError('开发插件的包名/版本必须与插件 id/version 一致',{code:'VALIDATION'});
|
|
48
|
+
links[pkg.name]=source;delete dependencies[pkg.name];
|
|
49
|
+
}
|
|
43
50
|
await writeFile(path.join(root,'package.json'),JSON.stringify({name:'flow-project-plugins',private:true,dependencies},null,2));
|
|
44
51
|
let source=add;
|
|
45
52
|
if(add) {
|
|
46
53
|
try {await access(path.resolve(projectDir,add));source=path.resolve(projectDir,add);} catch {}
|
|
47
54
|
if(source.startsWith('-')) throw new FlowError('插件来源不能以选项符号开头',{code:'VALIDATION'});
|
|
48
55
|
}
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
if(source||Object.keys(dependencies).length){
|
|
57
|
+
const result=await npm(['install',...(source?[source]:[]),'--save-exact','--ignore-scripts','--install-links','--no-audit','--no-fund'],{cwd:root});
|
|
58
|
+
if(result.exitCode!==0) throw new FlowError(`插件安装失败:${result.stderr}`,{code:'INSTALL_FAILED'});
|
|
59
|
+
}
|
|
51
60
|
const pkg=await readJson(path.join(root,'package.json'));
|
|
52
61
|
const loaded=[];
|
|
53
|
-
for(const name of Object.keys(pkg.dependencies??{})) loaded.push(await loadPlugin(name,root));
|
|
54
|
-
|
|
62
|
+
for(const name of Object.keys(pkg.dependencies??{})){delete links[name];loaded.push(await loadPlugin(name,root));}
|
|
63
|
+
for(const source of Object.values(links))loaded.push(await loadPlugin(source,projectDir));
|
|
64
|
+
return {state:{root,dependencies:pkg.dependencies??{},links,...(state?.root?{previousRoot:state.root}:{})},loaded};
|
|
55
65
|
}
|
|
@@ -2,14 +2,35 @@ import { execa } from 'execa';
|
|
|
2
2
|
import treeKill from 'tree-kill';
|
|
3
3
|
import which from 'which';
|
|
4
4
|
import { FlowError } from '@mytools/sdk';
|
|
5
|
-
import
|
|
5
|
+
import path from 'node:path';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const outputText=bytes=>bytes??new Uint8Array();
|
|
8
|
+
const stripFinalNewline=text=>text.endsWith('\r\n')?text.slice(0,-2):text.endsWith('\n')?text.slice(0,-1):text;
|
|
9
|
+
|
|
10
|
+
export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxBuffer=4*1024*1024,encoding='utf8',onOutput}={}) {
|
|
11
|
+
if(typeof encoding!=='string')throw new TypeError(`不支持的程序输出编码:${String(encoding)}`);
|
|
12
|
+
const label=/^(utf16le|ucs2|ucs-2)$/i.test(encoding)?'utf-16le':encoding;
|
|
13
|
+
let decoderEncoding;
|
|
14
|
+
try {decoderEncoding=new TextDecoder(label).encoding;}
|
|
15
|
+
catch {throw new TypeError(`不支持的程序输出编码:${encoding}`);}
|
|
8
16
|
if(signal?.aborted) throw new FlowError('操作已取消',{code:'CANCELLED'});
|
|
9
17
|
let executable;
|
|
10
|
-
try {
|
|
18
|
+
try {
|
|
19
|
+
// 不改变宿主 cwd;程序路径和 PATH 中相对目录都以子进程 cwd 为基准。
|
|
20
|
+
const directory=path.resolve(cwd??process.cwd()),windows=process.platform==='win32';
|
|
21
|
+
const hasPath=path.isAbsolute(file)||file.includes('/')||(windows&&file.includes('\\'));
|
|
22
|
+
const search=hasPath?[path.resolve(directory,file)]:[
|
|
23
|
+
...(windows?[directory]:[]),...(env?.PATH??(windows?env?.Path:undefined)??process.env.PATH??'').split(path.delimiter),
|
|
24
|
+
].map(entry=>path.resolve(directory,entry.replace(/^"(.*)"$/,'$1'),file));
|
|
25
|
+
for(const candidate of search){
|
|
26
|
+
executable=await which(candidate,{nothrow:true,pathExt:env?.PATHEXT??process.env.PATHEXT});
|
|
27
|
+
if(executable)break;
|
|
28
|
+
}
|
|
29
|
+
if(!executable)throw Object.assign(new Error(`not found: ${file}`),{code:'ENOENT'});
|
|
30
|
+
}
|
|
11
31
|
catch(cause){throw new FlowError(`未找到程序:${file}`,{code:'ENOENT',details:{executable:file},cause});}
|
|
12
|
-
|
|
32
|
+
// Execa 收集原始字节;文本只在这里按同一编码解码,避免返回值被默认 UTF-8 解码。
|
|
33
|
+
const child=execa(executable,args,{cwd,env,reject:false,windowsHide:true,maxBuffer,timeout:0,shell:false,encoding:'buffer',stripFinalNewline:false});
|
|
13
34
|
let reason,killing,outputError,outputFailed=false,outputWrites=Promise.resolve();
|
|
14
35
|
const stop=kind=>{
|
|
15
36
|
if(reason) return; reason=kind;
|
|
@@ -28,14 +49,14 @@ export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxB
|
|
|
28
49
|
const flush=[];
|
|
29
50
|
for(const [name,stream] of [['stdout',child.stdout],['stderr',child.stderr]]){
|
|
30
51
|
let bytes=0,ended=false;
|
|
31
|
-
const decoder=new
|
|
32
|
-
const finish=()=>{if(!ended){ended=true;emit(name,decoder.
|
|
52
|
+
const decoder=new TextDecoder(decoderEncoding);
|
|
53
|
+
const finish=()=>{if(!ended){ended=true;emit(name,decoder.decode());}};
|
|
33
54
|
flush.push(finish);stream?.once('end',finish);
|
|
34
55
|
stream?.on('data',chunk=>{
|
|
35
56
|
bytes+=Buffer.byteLength(chunk);
|
|
36
57
|
// 流量超限时立即回收整棵进程树,不继续扩张待落盘的日志队列。
|
|
37
58
|
if(bytes>maxBuffer){stop('output-limit');return;}
|
|
38
|
-
if(onOutput)emit(name,decoder.
|
|
59
|
+
if(onOutput)emit(name,decoder.decode(chunk,{stream:true}));
|
|
39
60
|
});
|
|
40
61
|
}
|
|
41
62
|
const timer=timeout>0 ? setTimeout(()=>stop('timeout'),timeout) : null;
|
|
@@ -54,5 +75,6 @@ export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxB
|
|
|
54
75
|
if(reason)throw new FlowError(reason==='cancelled'?'操作已取消':'程序运行超时',{code:reason==='cancelled'?'CANCELLED':'TIMEOUT'});
|
|
55
76
|
if(result.isMaxBuffer)throw new FlowError('程序输出超过上限',{code:'OUTPUT_LIMIT'});
|
|
56
77
|
if(result.exitCode===undefined&&result.failed)throw new FlowError(`无法启动程序 ${file}:${result.shortMessage??result.message}`,{code:result.code??'SPAWN_FAILED'});
|
|
57
|
-
|
|
78
|
+
const decode=bytes=>stripFinalNewline(new TextDecoder(decoderEncoding).decode(outputText(bytes)));
|
|
79
|
+
return {exitCode:result.exitCode,stdout:decode(result.stdout),stderr:decode(result.stderr)};
|
|
58
80
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {FlowError} from '@mytools/sdk';
|
|
2
|
+
|
|
3
|
+
const forbidden=new Set(['__proto__','constructor','prototype']);
|
|
4
|
+
const missing=ref=>{throw new FlowError(`引用字段不存在:${ref}`,{code:'VALIDATION'});};
|
|
5
|
+
|
|
6
|
+
// 路径只用于已声明的字段;开放对象可继续引用,带类型的动态键则继续逐层检查。
|
|
7
|
+
export function resolveSchemaPath(schema,fields,ref=Array.isArray(fields)?fields.join('.'):fields){
|
|
8
|
+
const parts=Array.isArray(fields)?fields:typeof fields==='string'?fields.split('.'):[];
|
|
9
|
+
if(!parts.length)return schema;
|
|
10
|
+
if(parts.some(part=>typeof part!=='string'||!part||forbidden.has(part)))missing(ref);
|
|
11
|
+
let current=schema;
|
|
12
|
+
for(const field of parts){
|
|
13
|
+
if(current===true){continue;}
|
|
14
|
+
if(current===false||!current||typeof current!=='object'||Array.isArray(current))missing(ref);
|
|
15
|
+
const types=Array.isArray(current.type)?current.type:[current.type];
|
|
16
|
+
const isArray=types.includes('array')||current.items!==undefined;
|
|
17
|
+
if(isArray){
|
|
18
|
+
if(!/^(0|[1-9]\d*)$/.test(field))missing(ref);
|
|
19
|
+
if(Array.isArray(current.items))current=current.items[Number(field)]??current.additionalItems??true;
|
|
20
|
+
else current=current.items??true;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if(current.properties&&Object.hasOwn(current.properties,field)){
|
|
24
|
+
current=current.properties[field];continue;
|
|
25
|
+
}
|
|
26
|
+
if(!types.includes('object')&&types.some(type=>type!==undefined))missing(ref);
|
|
27
|
+
current=current.additionalProperties??true;
|
|
28
|
+
}
|
|
29
|
+
if(current===false)missing(ref);
|
|
30
|
+
return current;
|
|
31
|
+
}
|
|
@@ -15,7 +15,7 @@ export function parseCommand(text){
|
|
|
15
15
|
}
|
|
16
16
|
if(quote)throw new Error('文件路径的引号未闭合');
|
|
17
17
|
if(started)args.push(token);
|
|
18
|
-
if(!args[0]?.startsWith('/'))throw new Error('
|
|
18
|
+
if(!args[0]?.startsWith('/'))throw Object.assign(new Error('这不是斜杠命令。输入 / 选择操作;执行工作流请用 /run 工作流ID。'),{code:'COMMAND_INPUT'});
|
|
19
19
|
args[0]=args[0].slice(1);return args;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {builtinCommands} from '../contracts.js';
|
|
2
|
+
|
|
3
|
+
const visible=['workflows','run','pack','logs','plugin','config','tools','help','exit'];
|
|
4
|
+
// 入口由插件 ID 推导;冲突时加前缀,不能覆盖内置或作者注册的命令。
|
|
5
|
+
export function commandMenu(plugins,commands){
|
|
6
|
+
const entries=builtinCommands.filter(command=>visible.includes(command.name)).map(command=>({...command,description:`[内置] ${command.description}`}));
|
|
7
|
+
const used=new Set([...builtinCommands.map(c=>c.name),...commands.map(c=>c.name)]);
|
|
8
|
+
const groups=plugins.filter(plugin=>commands.some(c=>c.plugin===plugin.id)).sort((a,b)=>a.id.localeCompare(b.id));
|
|
9
|
+
for(const plugin of groups){
|
|
10
|
+
const leaf=plugin.id.split('/').at(-1).replace(/[^\w.-]/g,'-');
|
|
11
|
+
let name=/^[a-zA-Z]/.test(leaf)?leaf:`plugin-${leaf}`;
|
|
12
|
+
if(groups.filter(p=>p.id.split('/').at(-1)===leaf).length>1)name=plugin.id.replace(/^@/,'').replace(/[^\w.-]/g,'-');
|
|
13
|
+
while(used.has(name))name='plugin-'+name;
|
|
14
|
+
used.add(name);entries.push({name,plugin:plugin.id,description:`[插件] ${plugin.title??plugin.id} · ${plugin.id}`});
|
|
15
|
+
}
|
|
16
|
+
return entries;
|
|
17
|
+
}
|
|
@@ -11,8 +11,8 @@ export function gettingStarted(flows,{readOnly=false}={}){
|
|
|
11
11
|
return [
|
|
12
12
|
sample?`试运行 /run ${sample.id}\n${sample.description??sample.title??'按提示填写参数,执行后查看结果。'}`:'当前工作区还没有工作流。',
|
|
13
13
|
'',
|
|
14
|
-
`/workflows
|
|
15
|
-
|
|
14
|
+
`/workflows ${readOnly?'浏览工作流':'管理工作流'} /plugin 插件 /config 工具配置`,
|
|
15
|
+
`/logs 回看执行记录、输入输出与日志 /help 帮助`,
|
|
16
16
|
].join('\n');
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -37,6 +37,6 @@ export function pluginDetails(plugin,actions,commands){
|
|
|
37
37
|
'## 环境声明',
|
|
38
38
|
...[plugin,...actions].flatMap(item=>Object.entries(item.environments??{}).map(([name,e])=>`- ${item.id} / ${name}:${({host:'使用宿主机环境',bundled:'随包携带',tool:'使用 Fishtools 自带环境'})[e.source]??e.source}`)),
|
|
39
39
|
'环境与平台由插件作者声明;具体执行结果请在 /logs 查看。',
|
|
40
|
-
'编排用法:/
|
|
40
|
+
'编排用法:/workflows 新建工作流 → 添加步骤并选择上述能力 → 填写参数 → 保存 → 运行。也可在主面板用 /run 选择已保存的工作流。',
|
|
41
41
|
].join('\n\n');
|
|
42
42
|
}
|