@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.
Files changed (52) hide show
  1. package/README.md +2 -2
  2. package/manual/README.md +2 -2
  3. package/manual/docs/examples/script-plugin/LICENSE +21 -0
  4. package/manual/docs/examples/script-plugin/README.md +64 -0
  5. package/manual/docs/examples/script-plugin/index.js +39 -0
  6. package/manual/docs/examples/script-plugin/package.json +9 -0
  7. package/manual/docs/examples/script-plugin/scripts/benchmark.py +23 -0
  8. package/manual/docs/plugin-development.md +49 -8
  9. package/manual/docs/releases.md +11 -0
  10. package/manual/docs/roadmap.md +3 -1
  11. package/manual/docs/superpowers/plans/2026-10-12-plugin-developer-experience.md +50 -0
  12. package/manual/docs/superpowers/reviews/2026-10-12-plugin-release-review.md +20 -0
  13. package/manual/docs/superpowers/specs/2026-10-12-plugin-developer-experience.md +22 -0
  14. package/manual/docs/verification.md +17 -0
  15. package/manual/packages/core/src/application.js +45 -21
  16. package/manual/packages/core/src/host.js +20 -5
  17. package/node_modules/@mytools/core/bin/fst.js +27 -5
  18. package/node_modules/@mytools/core/package.json +2 -0
  19. package/node_modules/@mytools/core/src/application.js +45 -21
  20. package/node_modules/@mytools/core/src/contracts.js +26 -8
  21. package/node_modules/@mytools/core/src/environments.js +39 -1
  22. package/node_modules/@mytools/core/src/host.js +20 -5
  23. package/node_modules/@mytools/core/src/plugin-installations.js +84 -0
  24. package/node_modules/@mytools/core/src/plugin-tools.js +133 -0
  25. package/node_modules/@mytools/core/src/plugins.js +16 -6
  26. package/node_modules/@mytools/core/src/process.js +16 -7
  27. package/node_modules/@mytools/core/src/schema-path.js +31 -0
  28. package/node_modules/@mytools/core/src/validation.js +22 -9
  29. package/node_modules/@mytools/core/src/version.js +1 -1
  30. package/node_modules/@mytools/core/src/workflows.js +2 -15
  31. package/node_modules/@mytools/pack/dependencies.js +8 -1
  32. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +1168 -1168
  33. package/node_modules/{tar → minipass-flush/node_modules/minipass}/node_modules/yallist/README.md +7 -8
  34. package/node_modules/minipass-flush/node_modules/minipass/node_modules/yallist/package.json +29 -0
  35. package/node_modules/yallist/README.md +8 -7
  36. package/node_modules/yallist/package.json +51 -12
  37. package/package.json +15 -15
  38. package/node_modules/tar/node_modules/yallist/package.json +0 -68
  39. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/LICENSE +0 -0
  40. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/iterator.js +0 -0
  41. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/yallist.js +0 -0
  42. /package/node_modules/{tar/node_modules/yallist → yallist}/LICENSE.md +0 -0
  43. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.d.ts +0 -0
  44. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.d.ts.map +0 -0
  45. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.js +0 -0
  46. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.js.map +0 -0
  47. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/package.json +0 -0
  48. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.d.ts +0 -0
  49. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.d.ts.map +0 -0
  50. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.js +0 -0
  51. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.js.map +0 -0
  52. /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
- let host,state=await readJson(path.join(projectDir,'.flow','plugins.json'),{}),busy=false;
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
- const logs={...project.logs,keepRuns},file=path.join(projectDir,'flow.project.json'),temp=`${file}.${randomUUID()}.tmp`;
118
- await mkdir(projectDir,{recursive:true});await writeFile(temp,JSON.stringify({...project,logs},null,2));await rename(temp,file);project.logs=logs;
119
- await pruneLogs();
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 removePlugin(name){if(resources.active(name))throw new FlowError('请先停止此插件提供的 Web');if(!state.dependencies?.[name])throw new FlowError('此插件来自发行配置,不能在此移除');return change({remove:name});},
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
- const nextConfig={...project.config,[id]:value};
137
- nextHost=await createHost({plugins:[...host.definitions.values()].map(p=>({plugin:p,root:p.root})),config:{...nextConfig,...config},services,environmentOverrides:project.environments,runtimeEnabled:!maintenance});
138
- const nextProject={...project,config:nextConfig},file=path.join(projectDir,'flow.project.json'),temp=`${file}.${randomUUID()}.tmp`;
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;project.config=nextConfig;await old.close();
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
- const initial=plugins??await Promise.all((project.plugins??[]).map(spec=>loadPlugin(spec,project.baseBundle?.root??projectDir)));
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
- const external=[];
178
- for(const name of Object.keys(state.dependencies??{}))external.push(await loadPlugin(name,state.root));
179
- host=await build(external);
180
- for(const name of ['startRun','startCommand','startWeb','saveWorkflow','removeWorkflow','installPlugin','removePlugin','configurePlugin','configureLogs','setMaintenance','pack']){
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
- const file=path.join(projectDir,'.flow','plugins.json'),temp=`${file}.tmp`;
205
- await writeFile(temp,JSON.stringify(next.state,null,2));await rename(temp,file);
206
- const old=host;host=nextHost;nextHost=null;state=next.state;await old.close();
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
- } finally {try{if(nextHost)await nextHost.close();}finally{busy=false;}}
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 {resolveEnvironments} from './environments.js';
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} 输入`),{...bindContext(context),getService,config:command.config,resourceDir:plugin.root,environments:resolveEnvironments(plugin,undefined,environmentOverrides[plugin.id])});
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} 输入`), {...bindContext(context),getService, config:action.config, resourceDir:action.root,environments:resolveEnvironments(definitions.get(action.plugin),action,environmentOverrides[action.plugin])});
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:!!o.maintenance,config:o.config?JSON.parse(await readFile(o.config,'utf8')):{}});}
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('remove <name>').action(name=>use(async a=>console.log(JSON.stringify(await a.removePlugin(name),null,2))));
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;}
@@ -29,7 +29,9 @@
29
29
  "cordis": "3.18.1",
30
30
  "execa": "10.1.0",
31
31
  "semver": "7.7.4",
32
+ "tar": "7.5.22",
32
33
  "tree-kill": "1.2.2",
34
+ "validate-npm-package-name": "8.0.0",
33
35
  "which": "7.0.0",
34
36
  "yaml": "2.9.1"
35
37
  }
@@ -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
- let host,state=await readJson(path.join(projectDir,'.flow','plugins.json'),{}),busy=false;
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
- const logs={...project.logs,keepRuns},file=path.join(projectDir,'flow.project.json'),temp=`${file}.${randomUUID()}.tmp`;
118
- await mkdir(projectDir,{recursive:true});await writeFile(temp,JSON.stringify({...project,logs},null,2));await rename(temp,file);project.logs=logs;
119
- await pruneLogs();
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 removePlugin(name){if(resources.active(name))throw new FlowError('请先停止此插件提供的 Web');if(!state.dependencies?.[name])throw new FlowError('此插件来自发行配置,不能在此移除');return change({remove:name});},
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
- const nextConfig={...project.config,[id]:value};
137
- nextHost=await createHost({plugins:[...host.definitions.values()].map(p=>({plugin:p,root:p.root})),config:{...nextConfig,...config},services,environmentOverrides:project.environments,runtimeEnabled:!maintenance});
138
- const nextProject={...project,config:nextConfig},file=path.join(projectDir,'flow.project.json'),temp=`${file}.${randomUUID()}.tmp`;
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;project.config=nextConfig;await old.close();
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
- const initial=plugins??await Promise.all((project.plugins??[]).map(spec=>loadPlugin(spec,project.baseBundle?.root??projectDir)));
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
- const external=[];
178
- for(const name of Object.keys(state.dependencies??{}))external.push(await loadPlugin(name,state.root));
179
- host=await build(external);
180
- for(const name of ['startRun','startCommand','startWeb','saveWorkflow','removeWorkflow','installPlugin','removePlugin','configurePlugin','configureLogs','setMaintenance','pack']){
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
- const file=path.join(projectDir,'.flow','plugins.json'),temp=`${file}.tmp`;
205
- await writeFile(temp,JSON.stringify(next.state,null,2));await rename(temp,file);
206
- const old=host;host=nextHost;nextHost=null;state=next.state;await old.close();
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
- } finally {try{if(nextHost)await nextHost.close();}finally{busy=false;}}
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
- let schema=action.output;
17
- for(const part of preset.path.split('.')){
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.values(plugin.actions??{}).map(a=>a.environments)]){
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'||Object.keys(selected).some(k=>k!=='source')))throw new FlowError(`环境来源覆盖无效:${plugin.id}/${id}`);
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 {resolveEnvironments} from './environments.js';
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} 输入`),{...bindContext(context),getService,config:command.config,resourceDir:plugin.root,environments:resolveEnvironments(plugin,undefined,environmentOverrides[plugin.id])});
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} 输入`), {...bindContext(context),getService, config:action.config, resourceDir:action.root,environments:resolveEnvironments(definitions.get(action.plugin),action,environmentOverrides[action.plugin])});
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) {