@yanglaofish/fishtools 0.3.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 +2 -2
- package/manual/README.md +2 -2
- 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 +49 -8
- package/manual/docs/releases.md +11 -0
- package/manual/docs/roadmap.md +3 -1
- 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-12-plugin-developer-experience.md +22 -0
- package/manual/docs/verification.md +17 -0
- package/manual/packages/core/src/application.js +45 -21
- package/manual/packages/core/src/host.js +20 -5
- 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 +45 -21
- package/node_modules/@mytools/core/src/contracts.js +26 -8
- package/node_modules/@mytools/core/src/environments.js +39 -1
- package/node_modules/@mytools/core/src/host.js +20 -5
- 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 +16 -7
- package/node_modules/@mytools/core/src/schema-path.js +31 -0
- package/node_modules/@mytools/core/src/validation.js +22 -9
- package/node_modules/@mytools/core/src/version.js +1 -1
- package/node_modules/@mytools/core/src/workflows.js +2 -15
- package/node_modules/@mytools/pack/dependencies.js +8 -1
- package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +1168 -1168
- 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
|
@@ -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,10 +2,17 @@ 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 { StringDecoder } from 'node:string_decoder';
|
|
6
5
|
import path from 'node:path';
|
|
7
6
|
|
|
8
|
-
|
|
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}`);}
|
|
9
16
|
if(signal?.aborted) throw new FlowError('操作已取消',{code:'CANCELLED'});
|
|
10
17
|
let executable;
|
|
11
18
|
try {
|
|
@@ -22,7 +29,8 @@ export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxB
|
|
|
22
29
|
if(!executable)throw Object.assign(new Error(`not found: ${file}`),{code:'ENOENT'});
|
|
23
30
|
}
|
|
24
31
|
catch(cause){throw new FlowError(`未找到程序:${file}`,{code:'ENOENT',details:{executable:file},cause});}
|
|
25
|
-
|
|
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});
|
|
26
34
|
let reason,killing,outputError,outputFailed=false,outputWrites=Promise.resolve();
|
|
27
35
|
const stop=kind=>{
|
|
28
36
|
if(reason) return; reason=kind;
|
|
@@ -41,14 +49,14 @@ export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxB
|
|
|
41
49
|
const flush=[];
|
|
42
50
|
for(const [name,stream] of [['stdout',child.stdout],['stderr',child.stderr]]){
|
|
43
51
|
let bytes=0,ended=false;
|
|
44
|
-
const decoder=new
|
|
45
|
-
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());}};
|
|
46
54
|
flush.push(finish);stream?.once('end',finish);
|
|
47
55
|
stream?.on('data',chunk=>{
|
|
48
56
|
bytes+=Buffer.byteLength(chunk);
|
|
49
57
|
// 流量超限时立即回收整棵进程树,不继续扩张待落盘的日志队列。
|
|
50
58
|
if(bytes>maxBuffer){stop('output-limit');return;}
|
|
51
|
-
if(onOutput)emit(name,decoder.
|
|
59
|
+
if(onOutput)emit(name,decoder.decode(chunk,{stream:true}));
|
|
52
60
|
});
|
|
53
61
|
}
|
|
54
62
|
const timer=timeout>0 ? setTimeout(()=>stop('timeout'),timeout) : null;
|
|
@@ -67,5 +75,6 @@ export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxB
|
|
|
67
75
|
if(reason)throw new FlowError(reason==='cancelled'?'操作已取消':'程序运行超时',{code:reason==='cancelled'?'CANCELLED':'TIMEOUT'});
|
|
68
76
|
if(result.isMaxBuffer)throw new FlowError('程序输出超过上限',{code:'OUTPUT_LIMIT'});
|
|
69
77
|
if(result.exitCode===undefined&&result.failed)throw new FlowError(`无法启动程序 ${file}:${result.shortMessage??result.message}`,{code:result.code??'SPAWN_FAILED'});
|
|
70
|
-
|
|
78
|
+
const decode=bytes=>stripFinalNewline(new TextDecoder(decoderEncoding).decode(outputText(bytes)));
|
|
79
|
+
return {exitCode:result.exitCode,stdout:decode(result.stdout),stderr:decode(result.stderr)};
|
|
71
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
|
+
}
|
|
@@ -1,29 +1,42 @@
|
|
|
1
1
|
import Ajv from 'ajv';
|
|
2
2
|
import { FlowError } from '@mytools/sdk';
|
|
3
3
|
|
|
4
|
-
const ajv = new Ajv({ allErrors: true, strict: false
|
|
5
|
-
const validators = new WeakMap();
|
|
6
|
-
const booleanValidators = new Map();
|
|
4
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
5
|
+
const validators = {true:new WeakMap(),false:new WeakMap()};
|
|
6
|
+
const booleanValidators = {true:new Map(),false:new Map()};
|
|
7
7
|
export const emptyObject = { type: 'object', properties: {}, additionalProperties: false };
|
|
8
8
|
|
|
9
|
-
export function assertSchema(schema,label) {
|
|
9
|
+
export function assertSchema(schema,label,{useDefaults=true}={}) {
|
|
10
10
|
if (!(typeof schema==='boolean'||schema&&typeof schema==='object'&&!Array.isArray(schema))) throw new FlowError(`${label} 必须声明有效的 JSON Schema`,{code:'VALIDATION'});
|
|
11
|
-
const cache=typeof schema==='boolean'?booleanValidators:validators;
|
|
11
|
+
const cache=(typeof schema==='boolean'?booleanValidators:validators)[Boolean(useDefaults)];
|
|
12
12
|
let check=cache.get(schema);
|
|
13
13
|
if(!check){
|
|
14
14
|
// 每份定义独立解析 $id/$ref,避免插件重载或不同插件共用 ID 时串用约束。
|
|
15
15
|
// 编译结果仍按对象缓存;注册表随校验器释放,不污染后续安装批次。
|
|
16
|
-
try{check=new Ajv({allErrors:true,strict:false,useDefaults:
|
|
16
|
+
try{check=new Ajv({allErrors:true,strict:false,useDefaults:Boolean(useDefaults)}).compile(schema);cache.set(schema,check);}
|
|
17
17
|
catch(cause){throw new FlowError(`${label} JSON Schema 无效:${cause.message}`,{code:'VALIDATION',cause});}
|
|
18
18
|
}
|
|
19
19
|
return check;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
const pointerToken=value=>String(value).replaceAll('~','~0').replaceAll('/','~1');
|
|
23
|
+
function diagnostic(error){
|
|
24
|
+
let instancePath=error.instancePath;
|
|
25
|
+
if(error.keyword==='required')instancePath+=`/${pointerToken(error.params.missingProperty)}`;
|
|
26
|
+
if(error.keyword==='additionalProperties')instancePath+=`/${pointerToken(error.params.additionalProperty)}`;
|
|
27
|
+
// 保留 Ajv 原有 instancePath 语义,新字段提供指向缺失/额外属性的完整位置。
|
|
28
|
+
return {...error,jsonPointer:instancePath};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function validate(schema, value, label,{useDefaults=true}={}) {
|
|
23
32
|
if (schema==null) return structuredClone(value);
|
|
24
|
-
const check = assertSchema(schema,label);
|
|
33
|
+
const check = assertSchema(schema,label,{useDefaults});
|
|
25
34
|
const copy = structuredClone(value);
|
|
26
|
-
if (!check(copy))
|
|
35
|
+
if (!check(copy)) {
|
|
36
|
+
const details=check.errors.map(diagnostic);
|
|
37
|
+
const locations=details.map(error=>`${error.jsonPointer||'/'} (${error.schemaPath})`).join(';');
|
|
38
|
+
throw new FlowError(`${label}: ${ajv.errorsText(check.errors, { separator: ';' })};位置 ${locations}`, { code: 'VALIDATION', details });
|
|
39
|
+
}
|
|
27
40
|
return copy;
|
|
28
41
|
}
|
|
29
42
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// 由 npm version 的生命周期脚本同步,内部插件 API 版本独立维护。
|
|
2
|
-
export const VERSION="0.
|
|
2
|
+
export const VERSION="0.4.0";
|
|
@@ -4,22 +4,9 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import YAML from 'yaml';
|
|
5
5
|
import { FlowError } from '@mytools/sdk';
|
|
6
6
|
import { assertId, assertSchema, references, referenceParts, emptyObject } from './validation.js';
|
|
7
|
+
import {resolveSchemaPath} from './schema-path.js';
|
|
7
8
|
import {validateCondition,validateStepList} from './control.js';
|
|
8
9
|
|
|
9
|
-
function fieldSchema(schema,fields,ref){
|
|
10
|
-
let current=schema;
|
|
11
|
-
for(const field of fields){
|
|
12
|
-
if(!current||current===true)return {};
|
|
13
|
-
if(current.properties&&Object.hasOwn(current.properties,field)){current=current.properties[field];continue;}
|
|
14
|
-
if(current.type==='array'&&/^\d+$/.test(field)){current=current.items;continue;}
|
|
15
|
-
if(current.additionalProperties===false||current===false||(current.type&&!['object','array'].includes(current.type))){
|
|
16
|
-
throw new FlowError(`引用字段不存在:${ref}`,{code:'VALIDATION'});
|
|
17
|
-
}
|
|
18
|
-
return {};
|
|
19
|
-
}
|
|
20
|
-
return current??{};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
10
|
export class WorkflowStore {
|
|
24
11
|
constructor(root) { this.root = path.resolve(root); }
|
|
25
12
|
file(id) { assertId(id); return path.join(this.root,`${id}.yaml`); }
|
|
@@ -76,7 +63,7 @@ export class WorkflowStore {
|
|
|
76
63
|
const checker=previous=>ref=>{
|
|
77
64
|
const p=referenceParts(ref);
|
|
78
65
|
if(p[0]==='steps' && !previous.has(p[1])) throw new FlowError(`${id} 引用必须指向前序步骤:${ref}`,{code:'VALIDATION'});
|
|
79
|
-
return
|
|
66
|
+
return resolveSchemaPath(p[0]==='inputs'?(w.inputs??emptyObject):previous.get(p[1]),p.slice(p[0]==='inputs'?1:3),ref);
|
|
80
67
|
};
|
|
81
68
|
const resolveList=async(steps,inherited=new Map(),parallel=false)=>{
|
|
82
69
|
const previous=new Map(inherited),own={};
|
|
@@ -24,7 +24,12 @@ export async function collectPackages(roots) {
|
|
|
24
24
|
}
|
|
25
25
|
if(!trees.has(base))trees.set(base,await new Arborist({path:base}).loadActual());
|
|
26
26
|
const tree=trees.get(base);
|
|
27
|
-
|
|
27
|
+
let found=[...tree.inventory.values()].find(n=>path.relative(n.realpath,root)==='' && !n.isLink);
|
|
28
|
+
// 开发引用可能嵌套在无关项目下,其上层 lock 不代表当前插件属于该依赖树。
|
|
29
|
+
if(!found&&base!==root){
|
|
30
|
+
if(!trees.has(root))trees.set(root,await new Arborist({path:root}).loadActual());
|
|
31
|
+
found=[...trees.get(root).inventory.values()].find(n=>path.relative(n.realpath,root)===''&&!n.isLink);
|
|
32
|
+
}
|
|
28
33
|
if(!found)throw new FlowError(`无法解析包的生产依赖:${root}`);
|
|
29
34
|
return found;
|
|
30
35
|
}
|
|
@@ -66,6 +71,8 @@ export async function copyPackages(nodes,destination,target,{filters=new Map()}=
|
|
|
66
71
|
const filter=filters.get(packageRoot);
|
|
67
72
|
const files=[...new Set([...await packlist(await new Arborist({path:packageRoot}).loadActual()),...(filter?.extraFiles??[])])];
|
|
68
73
|
for(const file of files){
|
|
74
|
+
// 项目可以与开发插件共用根目录;宿主私有状态和安装批次永远不进入插件快照。
|
|
75
|
+
if(file.split(/[\\/]/)[0]==='.flow')continue;
|
|
69
76
|
if(filter&&!filter.include(file))continue;
|
|
70
77
|
const source=path.resolve(packageRoot,file),physical=await realpath(source);
|
|
71
78
|
// 两端先解析实际路径,再按系统规则比较,兼容 Windows 大小写、短路径和目录联接。
|