@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
|
@@ -14,8 +14,9 @@ import {createResources} from './resources.js';
|
|
|
14
14
|
import {errorRecord} from './diagnostics.js';
|
|
15
15
|
import {markdownHeader,markdownEvent,markdownResult} from './markdown-log.js';
|
|
16
16
|
import {createRunLogs,validateKeepRuns} from './run-logs.js';
|
|
17
|
+
import {openPluginState,withPluginMutation,assertPluginState,savePluginState,leaseInstallation} from './plugin-installations.js';
|
|
17
18
|
|
|
18
|
-
export async function createApplication({projectDir=process.cwd(),workspace,outputDir,plugins,config={},maintenance=false}={}) {
|
|
19
|
+
export async function createApplication({projectDir=process.cwd(),workspace,outputDir,plugins,config={},maintenance=false,omitInstalledPlugin}={}) {
|
|
19
20
|
projectDir=await prepareBundleWorkspace(path.resolve(projectDir),workspace);
|
|
20
21
|
const project=await readJson(path.join(projectDir,'flow.project.json'),{plugins:[]});
|
|
21
22
|
const output=path.resolve(outputDir??process.env.FLOW_OUTPUT_DIR??(project.readOnly?path.join(process.cwd(),'flow-results'):path.join(projectDir,'.flow','runs')));
|
|
@@ -24,13 +25,14 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
24
25
|
const resources=createResources(),operations=new Set();let closing,launching=0;
|
|
25
26
|
const runLogs=createRunLogs(output,{projectDir,allowLegacy:output===path.join(projectDir,'.flow','runs')}),writingRuns=new Set();
|
|
26
27
|
validateKeepRuns(project.logs?.keepRuns??null);
|
|
27
|
-
|
|
28
|
+
const opened=await openPluginState(projectDir,{readOnly:project.readOnly});
|
|
29
|
+
let host,state=opened.state,releaseInstallation=opened.release,busy=false;
|
|
28
30
|
const app={
|
|
29
31
|
projectDir,project,outputDir:output,store,
|
|
30
32
|
get host(){return host;},
|
|
31
33
|
get maintenance(){return maintenance;},
|
|
32
34
|
catalog:()=>host.catalog(),
|
|
33
|
-
listPlugins:()=>[...host.definitions.values()].map(p=>({id:p.id,version:p.version,title:p.title,description:p.description,platforms:p.platforms,architectures:p.architectures,environments:p.environments,config:p.config,runtime:host.runtimeStatus().find(r=>r.id===p.id),removable:!!state.dependencies?.[p.id]})),
|
|
35
|
+
listPlugins:()=>[...host.definitions.values()].map(p=>({id:p.id,version:p.version,title:p.title,description:p.description,platforms:p.platforms,architectures:p.architectures,environments:p.environments,config:p.config,runtime:host.runtimeStatus().find(r=>r.id===p.id),removable:!!(state.dependencies?.[p.id]||state.links?.[p.id]),development:!!state.links?.[p.id],warnings:host.warnings.filter(item=>item.plugin===p.id).map(item=>item.message)})),
|
|
34
36
|
listCommands:()=>host.commands(),
|
|
35
37
|
listWorkflows:()=>store.list(),
|
|
36
38
|
async saveWorkflow(w){if(project.readOnly)throw new FlowError('此分发包仅用于运行;请在开发项目中编辑');return store.save(w);},
|
|
@@ -114,14 +116,20 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
114
116
|
if(busy)throw new FlowError('项目正在更新,请稍后修改日志设置');
|
|
115
117
|
busy=true;
|
|
116
118
|
try{
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
await withPluginMutation(projectDir,async()=>{
|
|
120
|
+
const file=path.join(projectDir,'flow.project.json'),latest=await readJson(file,project);
|
|
121
|
+
if(latest.readOnly)throw new FlowError('只读运行包不能保存日志设置');
|
|
122
|
+
const nextProject={...latest,logs:{...latest.logs,keepRuns}},temp=`${file}.${randomUUID()}.tmp`;
|
|
123
|
+
await writeFile(temp,JSON.stringify(nextProject,null,2));await rename(temp,file);
|
|
124
|
+
for(const key of Object.keys(project))delete project[key];Object.assign(project,nextProject);
|
|
125
|
+
await pruneLogs();
|
|
126
|
+
});
|
|
120
127
|
}finally{busy=false;}
|
|
121
128
|
},
|
|
122
129
|
cancelRun(id){const r=runs.get(id);if(r?.status==='running')r.controller.abort();return !!r;},
|
|
123
130
|
async installPlugin(source){return change({add:source});},
|
|
124
|
-
async
|
|
131
|
+
async linkPlugin(source){return change({link:path.resolve(projectDir,source)});},
|
|
132
|
+
async removePlugin(name){if(resources.active(name))throw new FlowError('请先停止此插件提供的 Web');if(!state.dependencies?.[name]&&!state.links?.[name])throw new FlowError('此插件来自发行配置,不能在此移除');return change({remove:name});},
|
|
125
133
|
async pack(options){const {packApplication}=await import('@mytools/pack');return packApplication(app,options);},
|
|
126
134
|
async packPlan(options){const {planApplication}=await import('@mytools/pack');return planApplication(app,options);},
|
|
127
135
|
async configurePlugin(id,value){
|
|
@@ -133,11 +141,18 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
133
141
|
value=validate(host.definitions.get(id).config,value,`${id} 配置`);
|
|
134
142
|
busy=true;let nextHost;
|
|
135
143
|
try{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
144
|
+
await withPluginMutation(projectDir,async()=>{
|
|
145
|
+
await assertPluginState(projectDir,state);
|
|
146
|
+
const file=path.join(projectDir,'flow.project.json'),latest=await readJson(file,project);
|
|
147
|
+
if(latest.readOnly)throw new FlowError('只读项目不能保存配置');
|
|
148
|
+
const nextConfig={...latest.config,[id]:value};
|
|
149
|
+
nextHost=await createHost({plugins:[...host.definitions.values()].map(p=>({plugin:p,root:p.root})),config:{...nextConfig,...config},services,environmentOverrides:latest.environments,runtimeEnabled:!maintenance});
|
|
150
|
+
const nextProject={...latest,config:nextConfig},temp=`${file}.${randomUUID()}.tmp`;
|
|
139
151
|
await writeFile(temp,JSON.stringify(nextProject,null,2));await rename(temp,file);
|
|
140
|
-
const old=host;host=nextHost;nextHost=null;
|
|
152
|
+
const old=host;host=nextHost;nextHost=null;
|
|
153
|
+
for(const key of Object.keys(project))delete project[key];Object.assign(project,nextProject);
|
|
154
|
+
await old.close();
|
|
155
|
+
});
|
|
141
156
|
}finally{try{if(nextHost)await nextHost.close();}finally{busy=false;}}
|
|
142
157
|
},
|
|
143
158
|
async setMaintenance(enabled){
|
|
@@ -167,17 +182,22 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
167
182
|
const errors=[];
|
|
168
183
|
try{await resources.close();}catch(error){errors.push(error);}
|
|
169
184
|
try{await host.close();}catch(error){errors.push(error);}
|
|
185
|
+
try{await releaseInstallation();}catch(error){errors.push(error);}
|
|
170
186
|
if(errors.length)throw new AggregateError(errors,`应用关闭失败:${errors.map(e=>e.message).join(';')}`);
|
|
171
187
|
});return closing;
|
|
172
188
|
},
|
|
173
189
|
};
|
|
174
|
-
|
|
190
|
+
let initial;
|
|
175
191
|
const services={application:app,runProcess};
|
|
176
192
|
const build=extra=>createHost({plugins:[...initial,...extra],config:{...project.config,...config},services,environmentOverrides:project.environments,runtimeEnabled:!maintenance});
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
193
|
+
try{
|
|
194
|
+
initial=plugins??await Promise.all((project.plugins??[]).map(spec=>loadPlugin(spec,project.baseBundle?.root??projectDir)));
|
|
195
|
+
const external=[];
|
|
196
|
+
for(const name of Object.keys(state.dependencies??{}))if(!maintenance||name!==omitInstalledPlugin)external.push(await loadPlugin(name,state.root));
|
|
197
|
+
for(const [name,source] of Object.entries(state.links??{}))if(!maintenance||name!==omitInstalledPlugin)external.push(await loadPlugin(source,projectDir));
|
|
198
|
+
host=await build(external);
|
|
199
|
+
}catch(error){await releaseInstallation();throw error;}
|
|
200
|
+
for(const name of ['startRun','startCommand','startWeb','saveWorkflow','removeWorkflow','installPlugin','linkPlugin','removePlugin','configurePlugin','configureLogs','setMaintenance','pack']){
|
|
181
201
|
const original=app[name],isRun=['startRun','startCommand'].includes(name);
|
|
182
202
|
app[name]=async(...args)=>{
|
|
183
203
|
if(closing)throw new FlowError('应用已关闭');
|
|
@@ -193,19 +213,23 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
193
213
|
if(project.readOnly)throw new FlowError('运行包不支持安装或移除插件');
|
|
194
214
|
assertMutable();
|
|
195
215
|
if(busy || launching || [...runs.values()].some(r=>r.status==='running'))throw new FlowError('请等待当前运行或安装结束');
|
|
196
|
-
busy=true;let nextHost;
|
|
216
|
+
busy=true;let nextHost,nextRelease;
|
|
197
217
|
try {
|
|
218
|
+
return await withPluginMutation(projectDir,async()=>{
|
|
219
|
+
await assertPluginState(projectDir,state);
|
|
198
220
|
const next=await installGeneration(projectDir,{...operation,state});
|
|
199
221
|
for(const p of host.definitions.values())if(resources.active(p.id)){
|
|
200
222
|
const candidate=[...initial,...next.loaded].find(e=>(e.plugin??e).id===p.id);
|
|
201
223
|
if(!candidate||candidate.root!==p.root||(candidate.plugin??candidate).version!==p.version)throw new FlowError('请先停止活动 Web,再替换其提供者');
|
|
202
224
|
}
|
|
203
225
|
nextHost=await build(next.loaded);
|
|
204
|
-
|
|
205
|
-
await
|
|
206
|
-
const old=host;host=nextHost;nextHost=null;state=next.state;
|
|
226
|
+
nextRelease=await leaseInstallation(projectDir,next.state.root);
|
|
227
|
+
await savePluginState(projectDir,next.state,state);
|
|
228
|
+
const old=host,oldRelease=releaseInstallation;host=nextHost;nextHost=null;state=next.state;releaseInstallation=nextRelease;nextRelease=null;
|
|
229
|
+
try{await old.close();}finally{await oldRelease();}
|
|
207
230
|
return app.listPlugins();
|
|
208
|
-
|
|
231
|
+
});
|
|
232
|
+
} finally {try{if(nextHost)await nextHost.close();}finally{try{if(nextRelease)await nextRelease();}finally{busy=false;}}}
|
|
209
233
|
}
|
|
210
234
|
return app;
|
|
211
235
|
}
|
|
@@ -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
|
|
|
@@ -27,6 +30,17 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
27
30
|
};
|
|
28
31
|
return bound;
|
|
29
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
|
+
}
|
|
30
44
|
for (const entry of plugins) {
|
|
31
45
|
const plugin = entry.plugin ?? entry;
|
|
32
46
|
validateDeclarations(plugin);validateCommands(plugin,commandNames);
|
|
@@ -80,6 +94,7 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
80
94
|
}
|
|
81
95
|
return {
|
|
82
96
|
definitions, actions,
|
|
97
|
+
warnings:[...definitions.values()].flatMap(plugin=>platformWarnings(plugin).map(message=>({plugin:plugin.id,message}))),
|
|
83
98
|
hasRuntime:runtime.hasRuntime,
|
|
84
99
|
runtimeStatus:()=>runtime.status(),
|
|
85
100
|
commands(){return [...commands.values()].map(({run,config,...metadata})=>structuredClone(metadata));},
|
|
@@ -92,9 +107,9 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
92
107
|
try{
|
|
93
108
|
const getService=runtime.bindServices(plugin.id);
|
|
94
109
|
context.signal?.throwIfAborted();
|
|
95
|
-
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});
|
|
96
111
|
context.signal?.throwIfAborted();
|
|
97
|
-
return validate(command.output,JSON.parse(JSON.stringify(result??{})),`/${name}
|
|
112
|
+
return validate(command.output,JSON.parse(JSON.stringify(result??{})),`/${name} 输出`,{useDefaults:false});
|
|
98
113
|
}catch(cause){throw pluginError(cause,{plugin:plugin.id,command:`/${name}`});}
|
|
99
114
|
},
|
|
100
115
|
catalog() { return [...actions.values()].map(({run,config,root,...metadata})=>structuredClone(metadata)); },
|
|
@@ -105,12 +120,12 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
105
120
|
try{
|
|
106
121
|
const getService=runtime.bindServices(action.plugin);
|
|
107
122
|
context.signal?.throwIfAborted();
|
|
108
|
-
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});
|
|
109
124
|
context.signal?.throwIfAborted();
|
|
110
125
|
// 通过 JSON 边界验证返回值,避免函数、循环引用等进入工作流状态。
|
|
111
126
|
const encoded = JSON.stringify(result);
|
|
112
127
|
if (encoded === undefined) throw new FlowError(`${id} 输出必须可 JSON 序列化`);
|
|
113
|
-
return validate(action.output, JSON.parse(encoded),`${id}
|
|
128
|
+
return validate(action.output, JSON.parse(encoded),`${id} 输出`,{useDefaults:false});
|
|
114
129
|
}catch(cause){throw pluginError(cause,{plugin:action.plugin,action:id});}
|
|
115
130
|
},
|
|
116
131
|
async service(name,options) {
|
|
@@ -8,12 +8,15 @@ import { resolveProject, initializeProject } from '../src/project.js';
|
|
|
8
8
|
import {waitForCliRun,cliRunResult} from '../src/cli-run.js';
|
|
9
9
|
import {VERSION} from '../src/version.js';
|
|
10
10
|
import {listDocumentation,readDocumentation,findDocumentationRoot,exportDocumentation} from '../src/documentation.js';
|
|
11
|
+
import {collectInstallations} from '../src/plugin-installations.js';
|
|
12
|
+
import {prepareBundleWorkspace} from '../src/bundle-workspace.js';
|
|
13
|
+
import {readJson} from '../src/plugins.js';
|
|
11
14
|
|
|
12
15
|
const program=new Command().name('fst').description('Fishtools · 插件式工作流工作台').version(VERSION);
|
|
13
16
|
program.option('--maintenance','维护模式:不启动有状态服务插件');
|
|
14
17
|
program.option('--project <directory>','项目目录(默认当前项目或用户工作区)').option('--workspace <directory>','便携包的可写工作区').option('--output <directory>','日志与结果目录').option('--config <file>','插件配置覆盖文件').option('--host <host>','Web 监听地址','127.0.0.1').option('--port <port>','Web 监听端口','8765');
|
|
15
|
-
async function app(){const o=program.opts();return createApplication({projectDir:await resolveProject(o.project,o.workspace),outputDir:o.output,maintenance
|
|
16
|
-
async function use(fn){const a=await app();try{return await fn(a);}finally{await a.close();}}
|
|
18
|
+
async function app({management=false,removeInstalled}={}){const o=program.opts();return createApplication({projectDir:await resolveProject(o.project,o.workspace),outputDir:o.output,maintenance:management||!!o.maintenance,omitInstalledPlugin:removeInstalled,config:o.config?JSON.parse(await readFile(o.config,'utf8')):{}});}
|
|
19
|
+
async function use(fn,options){const a=await app(options);try{for(const warning of a.host.warnings)console.error(`警告:${warning.message}`);return await fn(a);}finally{await a.close();}}
|
|
17
20
|
program.command('init [directory]').description('创建项目,保留已有配置与工作流').action(async directory=>console.log(`Fishtools 项目:${await initializeProject(path.resolve(directory??process.cwd()))}`));
|
|
18
21
|
program.command('docs [topic]').description('阅读或导出随安装提供的离线文档').option('--path','只打印文档路径').option('--export <directory>','导出完整资料到新目录').action(async(topic,options)=>{
|
|
19
22
|
if(options.export){if(topic||options.path)throw new Error('--export 不可与文档主题或 --path 同用');console.log(await exportDocumentation(options.export));return;}
|
|
@@ -32,8 +35,27 @@ program.command('run <id>').option('--input <file>','JSON 输入文件').action(
|
|
|
32
35
|
}));
|
|
33
36
|
const plugin=program.command('plugin').description('管理插件');
|
|
34
37
|
plugin.command('list').action(()=>use(a=>console.log(JSON.stringify(a.listPlugins(),null,2))));
|
|
35
|
-
plugin.command('add <source>').action(source=>use(async a=>console.log(JSON.stringify(await a.installPlugin(source),null,2))));
|
|
36
|
-
plugin.command('
|
|
38
|
+
plugin.command('add <source>').action(source=>use(async a=>console.log(JSON.stringify(await a.installPlugin(source),null,2)),{management:true}));
|
|
39
|
+
plugin.command('link <directory>').description('引用本地开发源码,修改后重启 fst 生效').action(directory=>use(async a=>console.log(JSON.stringify(await a.linkPlugin(path.resolve(directory)),null,2)),{management:true}));
|
|
40
|
+
plugin.command('remove <name>').action(name=>use(async a=>console.log(JSON.stringify(await a.removePlugin(name),null,2)),{management:true,removeInstalled:name}));
|
|
41
|
+
plugin.command('validate <directory>').description('检查可信插件声明,不执行动作或运行时服务').option('--json','输出结构化报告').action(async(directory,options)=>{
|
|
42
|
+
const {validatePluginDirectory}=await import('../src/plugin-tools.js');const report=await validatePluginDirectory(path.resolve(directory));
|
|
43
|
+
if(options.json)console.log(JSON.stringify(report,null,2));else{
|
|
44
|
+
console.log(`${report.valid?'检查通过':'检查未通过'}:${report.plugin?.id??directory}`);
|
|
45
|
+
for(const item of report.diagnostics)console.log(`${item.severity==='error'?'错误':'警告'} [${item.code}] ${item.path??''} ${item.message}`);
|
|
46
|
+
}
|
|
47
|
+
if(!report.valid)process.exitCode=1;
|
|
48
|
+
});
|
|
49
|
+
plugin.command('create <name> [directory]').description('创建可独立安装的插件骨架').action(async(name,directory)=>{
|
|
50
|
+
const {createPlugin}=await import('../src/plugin-tools.js');const result=await createPlugin(name,path.resolve(directory??name));
|
|
51
|
+
console.log(`插件已创建:${result.directory}\n运行 fst plugin validate "${result.directory}" 检查;使用 fst plugin link "${result.directory}" 开发`);
|
|
52
|
+
});
|
|
53
|
+
plugin.command('gc').description('预览未引用安装批次;--apply 先备份再清理').option('--apply','压缩备份后清理安全批次').action(async options=>{
|
|
54
|
+
const settings=program.opts(),projectDir=await prepareBundleWorkspace(await resolveProject(settings.project,settings.workspace),settings.workspace);
|
|
55
|
+
const project=await readJson(path.join(projectDir,'flow.project.json'),{});
|
|
56
|
+
if(project.readOnly)throw new Error('只读项目不能清理插件批次');
|
|
57
|
+
console.log(JSON.stringify(await collectInstallations(projectDir,{apply:!!options.apply}),null,2));
|
|
58
|
+
});
|
|
37
59
|
const packOptions=command=>command.requiredOption('--target <target>','win32-x64 或 linux-x64-glibc').option('--out <directory>','导出目录','dist').option('--read-only','导出只读精简运行包').option('--environments <file>','环境来源覆盖 JSON').option('--plan','只查看打包资源计划');
|
|
38
60
|
async function pack(a,entries,o,portable=false){
|
|
39
61
|
const options={entries,target:o.target,outDir:o.out,includeWeb:!!o.web,readOnly:!!o.readOnly,portable,environments:o.environments?JSON.parse(await readFile(o.environments,'utf8')):{}};
|
|
@@ -46,7 +68,7 @@ program.command('command [name]').description('列出插件命令或执行动作
|
|
|
46
68
|
const run=await a.startCommand(name,o.input?JSON.parse(await readFile(o.input,'utf8')):{});
|
|
47
69
|
const result=cliRunResult(await waitForCliRun(a,run));console.log(JSON.stringify(result.value,null,2));process.exitCode=result.exitCode;
|
|
48
70
|
}));
|
|
49
|
-
program.command('configure <plugin> <file>').description('从 JSON 保存插件配置').action((id,file)=>use(async a=>{await a.configurePlugin(id,JSON.parse(await readFile(file,'utf8')));console.log('插件配置已保存');}));
|
|
71
|
+
program.command('configure <plugin> <file>').description('从 JSON 保存插件配置').action((id,file)=>use(async a=>{await a.configurePlugin(id,JSON.parse(await readFile(file,'utf8')));console.log('插件配置已保存');},{management:true}));
|
|
50
72
|
async function web(o){
|
|
51
73
|
const a=await app();let server;
|
|
52
74
|
try{server=await a.startWeb({host:o.host,port:Number(o.port)});}catch(error){await a.close();throw error;}
|
|
@@ -14,8 +14,9 @@ import {createResources} from './resources.js';
|
|
|
14
14
|
import {errorRecord} from './diagnostics.js';
|
|
15
15
|
import {markdownHeader,markdownEvent,markdownResult} from './markdown-log.js';
|
|
16
16
|
import {createRunLogs,validateKeepRuns} from './run-logs.js';
|
|
17
|
+
import {openPluginState,withPluginMutation,assertPluginState,savePluginState,leaseInstallation} from './plugin-installations.js';
|
|
17
18
|
|
|
18
|
-
export async function createApplication({projectDir=process.cwd(),workspace,outputDir,plugins,config={},maintenance=false}={}) {
|
|
19
|
+
export async function createApplication({projectDir=process.cwd(),workspace,outputDir,plugins,config={},maintenance=false,omitInstalledPlugin}={}) {
|
|
19
20
|
projectDir=await prepareBundleWorkspace(path.resolve(projectDir),workspace);
|
|
20
21
|
const project=await readJson(path.join(projectDir,'flow.project.json'),{plugins:[]});
|
|
21
22
|
const output=path.resolve(outputDir??process.env.FLOW_OUTPUT_DIR??(project.readOnly?path.join(process.cwd(),'flow-results'):path.join(projectDir,'.flow','runs')));
|
|
@@ -24,13 +25,14 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
24
25
|
const resources=createResources(),operations=new Set();let closing,launching=0;
|
|
25
26
|
const runLogs=createRunLogs(output,{projectDir,allowLegacy:output===path.join(projectDir,'.flow','runs')}),writingRuns=new Set();
|
|
26
27
|
validateKeepRuns(project.logs?.keepRuns??null);
|
|
27
|
-
|
|
28
|
+
const opened=await openPluginState(projectDir,{readOnly:project.readOnly});
|
|
29
|
+
let host,state=opened.state,releaseInstallation=opened.release,busy=false;
|
|
28
30
|
const app={
|
|
29
31
|
projectDir,project,outputDir:output,store,
|
|
30
32
|
get host(){return host;},
|
|
31
33
|
get maintenance(){return maintenance;},
|
|
32
34
|
catalog:()=>host.catalog(),
|
|
33
|
-
listPlugins:()=>[...host.definitions.values()].map(p=>({id:p.id,version:p.version,title:p.title,description:p.description,platforms:p.platforms,architectures:p.architectures,environments:p.environments,config:p.config,runtime:host.runtimeStatus().find(r=>r.id===p.id),removable:!!state.dependencies?.[p.id]})),
|
|
35
|
+
listPlugins:()=>[...host.definitions.values()].map(p=>({id:p.id,version:p.version,title:p.title,description:p.description,platforms:p.platforms,architectures:p.architectures,environments:p.environments,config:p.config,runtime:host.runtimeStatus().find(r=>r.id===p.id),removable:!!(state.dependencies?.[p.id]||state.links?.[p.id]),development:!!state.links?.[p.id],warnings:host.warnings.filter(item=>item.plugin===p.id).map(item=>item.message)})),
|
|
34
36
|
listCommands:()=>host.commands(),
|
|
35
37
|
listWorkflows:()=>store.list(),
|
|
36
38
|
async saveWorkflow(w){if(project.readOnly)throw new FlowError('此分发包仅用于运行;请在开发项目中编辑');return store.save(w);},
|
|
@@ -114,14 +116,20 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
114
116
|
if(busy)throw new FlowError('项目正在更新,请稍后修改日志设置');
|
|
115
117
|
busy=true;
|
|
116
118
|
try{
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
await withPluginMutation(projectDir,async()=>{
|
|
120
|
+
const file=path.join(projectDir,'flow.project.json'),latest=await readJson(file,project);
|
|
121
|
+
if(latest.readOnly)throw new FlowError('只读运行包不能保存日志设置');
|
|
122
|
+
const nextProject={...latest,logs:{...latest.logs,keepRuns}},temp=`${file}.${randomUUID()}.tmp`;
|
|
123
|
+
await writeFile(temp,JSON.stringify(nextProject,null,2));await rename(temp,file);
|
|
124
|
+
for(const key of Object.keys(project))delete project[key];Object.assign(project,nextProject);
|
|
125
|
+
await pruneLogs();
|
|
126
|
+
});
|
|
120
127
|
}finally{busy=false;}
|
|
121
128
|
},
|
|
122
129
|
cancelRun(id){const r=runs.get(id);if(r?.status==='running')r.controller.abort();return !!r;},
|
|
123
130
|
async installPlugin(source){return change({add:source});},
|
|
124
|
-
async
|
|
131
|
+
async linkPlugin(source){return change({link:path.resolve(projectDir,source)});},
|
|
132
|
+
async removePlugin(name){if(resources.active(name))throw new FlowError('请先停止此插件提供的 Web');if(!state.dependencies?.[name]&&!state.links?.[name])throw new FlowError('此插件来自发行配置,不能在此移除');return change({remove:name});},
|
|
125
133
|
async pack(options){const {packApplication}=await import('@mytools/pack');return packApplication(app,options);},
|
|
126
134
|
async packPlan(options){const {planApplication}=await import('@mytools/pack');return planApplication(app,options);},
|
|
127
135
|
async configurePlugin(id,value){
|
|
@@ -133,11 +141,18 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
133
141
|
value=validate(host.definitions.get(id).config,value,`${id} 配置`);
|
|
134
142
|
busy=true;let nextHost;
|
|
135
143
|
try{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
144
|
+
await withPluginMutation(projectDir,async()=>{
|
|
145
|
+
await assertPluginState(projectDir,state);
|
|
146
|
+
const file=path.join(projectDir,'flow.project.json'),latest=await readJson(file,project);
|
|
147
|
+
if(latest.readOnly)throw new FlowError('只读项目不能保存配置');
|
|
148
|
+
const nextConfig={...latest.config,[id]:value};
|
|
149
|
+
nextHost=await createHost({plugins:[...host.definitions.values()].map(p=>({plugin:p,root:p.root})),config:{...nextConfig,...config},services,environmentOverrides:latest.environments,runtimeEnabled:!maintenance});
|
|
150
|
+
const nextProject={...latest,config:nextConfig},temp=`${file}.${randomUUID()}.tmp`;
|
|
139
151
|
await writeFile(temp,JSON.stringify(nextProject,null,2));await rename(temp,file);
|
|
140
|
-
const old=host;host=nextHost;nextHost=null;
|
|
152
|
+
const old=host;host=nextHost;nextHost=null;
|
|
153
|
+
for(const key of Object.keys(project))delete project[key];Object.assign(project,nextProject);
|
|
154
|
+
await old.close();
|
|
155
|
+
});
|
|
141
156
|
}finally{try{if(nextHost)await nextHost.close();}finally{busy=false;}}
|
|
142
157
|
},
|
|
143
158
|
async setMaintenance(enabled){
|
|
@@ -167,17 +182,22 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
167
182
|
const errors=[];
|
|
168
183
|
try{await resources.close();}catch(error){errors.push(error);}
|
|
169
184
|
try{await host.close();}catch(error){errors.push(error);}
|
|
185
|
+
try{await releaseInstallation();}catch(error){errors.push(error);}
|
|
170
186
|
if(errors.length)throw new AggregateError(errors,`应用关闭失败:${errors.map(e=>e.message).join(';')}`);
|
|
171
187
|
});return closing;
|
|
172
188
|
},
|
|
173
189
|
};
|
|
174
|
-
|
|
190
|
+
let initial;
|
|
175
191
|
const services={application:app,runProcess};
|
|
176
192
|
const build=extra=>createHost({plugins:[...initial,...extra],config:{...project.config,...config},services,environmentOverrides:project.environments,runtimeEnabled:!maintenance});
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
193
|
+
try{
|
|
194
|
+
initial=plugins??await Promise.all((project.plugins??[]).map(spec=>loadPlugin(spec,project.baseBundle?.root??projectDir)));
|
|
195
|
+
const external=[];
|
|
196
|
+
for(const name of Object.keys(state.dependencies??{}))if(!maintenance||name!==omitInstalledPlugin)external.push(await loadPlugin(name,state.root));
|
|
197
|
+
for(const [name,source] of Object.entries(state.links??{}))if(!maintenance||name!==omitInstalledPlugin)external.push(await loadPlugin(source,projectDir));
|
|
198
|
+
host=await build(external);
|
|
199
|
+
}catch(error){await releaseInstallation();throw error;}
|
|
200
|
+
for(const name of ['startRun','startCommand','startWeb','saveWorkflow','removeWorkflow','installPlugin','linkPlugin','removePlugin','configurePlugin','configureLogs','setMaintenance','pack']){
|
|
181
201
|
const original=app[name],isRun=['startRun','startCommand'].includes(name);
|
|
182
202
|
app[name]=async(...args)=>{
|
|
183
203
|
if(closing)throw new FlowError('应用已关闭');
|
|
@@ -193,19 +213,23 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
193
213
|
if(project.readOnly)throw new FlowError('运行包不支持安装或移除插件');
|
|
194
214
|
assertMutable();
|
|
195
215
|
if(busy || launching || [...runs.values()].some(r=>r.status==='running'))throw new FlowError('请等待当前运行或安装结束');
|
|
196
|
-
busy=true;let nextHost;
|
|
216
|
+
busy=true;let nextHost,nextRelease;
|
|
197
217
|
try {
|
|
218
|
+
return await withPluginMutation(projectDir,async()=>{
|
|
219
|
+
await assertPluginState(projectDir,state);
|
|
198
220
|
const next=await installGeneration(projectDir,{...operation,state});
|
|
199
221
|
for(const p of host.definitions.values())if(resources.active(p.id)){
|
|
200
222
|
const candidate=[...initial,...next.loaded].find(e=>(e.plugin??e).id===p.id);
|
|
201
223
|
if(!candidate||candidate.root!==p.root||(candidate.plugin??candidate).version!==p.version)throw new FlowError('请先停止活动 Web,再替换其提供者');
|
|
202
224
|
}
|
|
203
225
|
nextHost=await build(next.loaded);
|
|
204
|
-
|
|
205
|
-
await
|
|
206
|
-
const old=host;host=nextHost;nextHost=null;state=next.state;
|
|
226
|
+
nextRelease=await leaseInstallation(projectDir,next.state.root);
|
|
227
|
+
await savePluginState(projectDir,next.state,state);
|
|
228
|
+
const old=host,oldRelease=releaseInstallation;host=nextHost;nextHost=null;state=next.state;releaseInstallation=nextRelease;nextRelease=null;
|
|
229
|
+
try{await old.close();}finally{await oldRelease();}
|
|
207
230
|
return app.listPlugins();
|
|
208
|
-
|
|
231
|
+
});
|
|
232
|
+
} finally {try{if(nextHost)await nextHost.close();}finally{try{if(nextRelease)await nextRelease();}finally{busy=false;}}}
|
|
209
233
|
}
|
|
210
234
|
return app;
|
|
211
235
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import {FlowError} from '@mytools/sdk';
|
|
2
2
|
import {conditionOperators} from './control.js';
|
|
3
|
+
import {resolveSchemaPath} from './schema-path.js';
|
|
4
|
+
import {inside} from './environments.js';
|
|
5
|
+
import path from 'node:path';
|
|
3
6
|
|
|
4
7
|
export const builtinCommands=[
|
|
5
8
|
['workflows','选择已有工作流'],['new','新建工作流'],['add','添加插件能力或子工作流'],
|
|
@@ -10,23 +13,38 @@ export const builtinCommands=[
|
|
|
10
13
|
|
|
11
14
|
export function validateDeclarations(plugin){
|
|
12
15
|
for(const [name,action] of Object.entries(plugin.actions??{})){
|
|
16
|
+
if(!action||typeof action!=='object'||Array.isArray(action))throw new FlowError(`动作 ${name} 的声明必须为对象`,{code:'VALIDATION'});
|
|
13
17
|
if(action.conditions!==undefined&&(!action.conditions||typeof action.conditions!=='object'||Array.isArray(action.conditions)))throw new FlowError(`动作 ${name} 的 conditions 必须为对象`,{code:'VALIDATION'});
|
|
14
18
|
for(const [key,preset] of Object.entries(action.conditions??{})){
|
|
15
19
|
if(!preset||typeof preset.title!=='string'||!preset.title.trim()||typeof preset.path!=='string'||!preset.path||preset.path.split('.').some(p=>!p||['__proto__','constructor','prototype'].includes(p))||!conditionOperators.includes(preset.op)||(preset.op!=='exists'&&!Object.hasOwn(preset,'value')))throw new FlowError(`动作 ${name} 的判定预设 ${key} 无效`,{code:'VALIDATION'});
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if(schema?.properties&&Object.hasOwn(schema.properties,part))schema=schema.properties[part];
|
|
19
|
-
else if(schema?.type==='array'&&/^\d+$/.test(part))schema=schema.items;
|
|
20
|
-
else throw new FlowError(`判定预设 ${key} 引用了未声明的输出字段 ${preset.path}`,{code:'VALIDATION'});
|
|
21
|
-
}
|
|
20
|
+
try{resolveSchemaPath(action.output,preset.path);}
|
|
21
|
+
catch{throw new FlowError(`判定预设 ${key} 引用了未声明的输出字段 ${preset.path}`,{code:'VALIDATION'});}
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
for(const key of ['platforms','architectures'])if(plugin[key]!==undefined&&(!Array.isArray(plugin[key])||!plugin[key].every(x=>typeof x==='string'&&x)))throw new FlowError(`${plugin.id} 的 ${key}
|
|
25
|
-
for(const declarations of [plugin.environments,...Object.
|
|
24
|
+
for(const key of ['platforms','architectures'])if(plugin[key]!==undefined&&(!Array.isArray(plugin[key])||!plugin[key].length||!plugin[key].every(x=>typeof x==='string'&&x.trim())||new Set(plugin[key]).size!==plugin[key].length))throw new FlowError(`${plugin.id} 的 ${key} 必须是非空且不重复的字符串数组`,{code:'VALIDATION'});
|
|
25
|
+
for(const [owner,location,declarations] of [['插件','plugin.environments',plugin.environments],...Object.entries(plugin.actions??{}).map(([name,action])=>[`动作 ${name}`,`plugin.actions.${name}.environments`,action.environments])]){
|
|
26
|
+
if(declarations!==undefined&&(!declarations||typeof declarations!=='object'||Array.isArray(declarations)))throw new FlowError(`${owner} 的 environments 必须为对象`,{code:'VALIDATION'});
|
|
26
27
|
for(const [id,e] of Object.entries(declarations??{})){
|
|
28
|
+
const at=`${location}.${id}`;
|
|
29
|
+
const invalid=(field,message)=>{throw new FlowError(message,{code:'VALIDATION',details:{path:`${at}.${field}`}});};
|
|
27
30
|
if(!/^[\w-]+$/.test(id)||!e||!['host','bundled','tool'].includes(e.source))throw new FlowError(`环境声明无效:${plugin.id}/${id}`);
|
|
31
|
+
const validString=value=>typeof value==='string'&&Boolean(value.trim());
|
|
32
|
+
if((e.source==='host'||e.executable!==undefined)&&!(e.source==='host'?(validString(e.executable)||Array.isArray(e.executable)&&e.executable.length>0&&e.executable.every(validString)&&new Set(e.executable).size===e.executable.length):validString(e.executable)))throw new FlowError(`环境 executable 声明无效:${plugin.id}/${id}`,{code:'VALIDATION'});
|
|
28
33
|
if(e.allowedSources&&(!Array.isArray(e.allowedSources)||!e.allowedSources.includes(e.source)||!e.allowedSources.every(x=>['host','bundled','tool'].includes(x))))throw new FlowError(`环境来源范围无效:${id}`);
|
|
29
34
|
if(e.source==='tool'&&id!=='node')throw new FlowError(`工具尚未提供环境:${id}`);
|
|
35
|
+
if(e.source==='bundled'||e.artifacts!==undefined){
|
|
36
|
+
const artifacts=e.artifacts;
|
|
37
|
+
if(!artifacts||typeof artifacts!=='object'||Array.isArray(artifacts)||e.source==='bundled'&&!Object.keys(artifacts).length)invalid('artifacts',`环境 ${plugin.id}/${id} 的 artifacts 必须为非空目标字典`);
|
|
38
|
+
for(const [target,artifact] of Object.entries(artifacts)){
|
|
39
|
+
if(!target.trim()||!artifact||typeof artifact!=='object'||Array.isArray(artifact))invalid(`artifacts.${target}`,`环境 ${plugin.id}/${id} 的目标资源无效:${target}`);
|
|
40
|
+
// 与运行时使用同一包内路径规则;此处只查声明,不读取目标平台文件。
|
|
41
|
+
let directory;
|
|
42
|
+
try{if(typeof artifact.directory!=='string'||!artifact.directory.trim())throw new Error();directory=inside(process.cwd(),artifact.directory);}
|
|
43
|
+
catch{invalid(`artifacts.${target}.directory`,`环境 ${plugin.id}/${id} 的资源目录必须是包内相对路径`);}
|
|
44
|
+
try{if(typeof artifact.executable!=='string'||!artifact.executable.trim()||path.normalize(artifact.executable)==='.')throw new Error();inside(directory,artifact.executable);}
|
|
45
|
+
catch{invalid(`artifacts.${target}.executable`,`环境 ${plugin.id}/${id} 的程序路径必须是包内相对路径`);}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
30
48
|
}
|
|
31
49
|
}
|
|
32
50
|
}
|
|
@@ -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
|
|
|
@@ -27,6 +30,17 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
27
30
|
};
|
|
28
31
|
return bound;
|
|
29
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
|
+
}
|
|
30
44
|
for (const entry of plugins) {
|
|
31
45
|
const plugin = entry.plugin ?? entry;
|
|
32
46
|
validateDeclarations(plugin);validateCommands(plugin,commandNames);
|
|
@@ -80,6 +94,7 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
80
94
|
}
|
|
81
95
|
return {
|
|
82
96
|
definitions, actions,
|
|
97
|
+
warnings:[...definitions.values()].flatMap(plugin=>platformWarnings(plugin).map(message=>({plugin:plugin.id,message}))),
|
|
83
98
|
hasRuntime:runtime.hasRuntime,
|
|
84
99
|
runtimeStatus:()=>runtime.status(),
|
|
85
100
|
commands(){return [...commands.values()].map(({run,config,...metadata})=>structuredClone(metadata));},
|
|
@@ -92,9 +107,9 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
92
107
|
try{
|
|
93
108
|
const getService=runtime.bindServices(plugin.id);
|
|
94
109
|
context.signal?.throwIfAborted();
|
|
95
|
-
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});
|
|
96
111
|
context.signal?.throwIfAborted();
|
|
97
|
-
return validate(command.output,JSON.parse(JSON.stringify(result??{})),`/${name}
|
|
112
|
+
return validate(command.output,JSON.parse(JSON.stringify(result??{})),`/${name} 输出`,{useDefaults:false});
|
|
98
113
|
}catch(cause){throw pluginError(cause,{plugin:plugin.id,command:`/${name}`});}
|
|
99
114
|
},
|
|
100
115
|
catalog() { return [...actions.values()].map(({run,config,root,...metadata})=>structuredClone(metadata)); },
|
|
@@ -105,12 +120,12 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
105
120
|
try{
|
|
106
121
|
const getService=runtime.bindServices(action.plugin);
|
|
107
122
|
context.signal?.throwIfAborted();
|
|
108
|
-
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});
|
|
109
124
|
context.signal?.throwIfAborted();
|
|
110
125
|
// 通过 JSON 边界验证返回值,避免函数、循环引用等进入工作流状态。
|
|
111
126
|
const encoded = JSON.stringify(result);
|
|
112
127
|
if (encoded === undefined) throw new FlowError(`${id} 输出必须可 JSON 序列化`);
|
|
113
|
-
return validate(action.output, JSON.parse(encoded),`${id}
|
|
128
|
+
return validate(action.output, JSON.parse(encoded),`${id} 输出`,{useDefaults:false});
|
|
114
129
|
}catch(cause){throw pluginError(cause,{plugin:action.plugin,action:id});}
|
|
115
130
|
},
|
|
116
131
|
async service(name,options) {
|