@yanglaofish/fishtools 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +3 -3
  2. package/manual/README.md +3 -3
  3. package/manual/docs/modules.md +2 -0
  4. package/manual/docs/plans/2026-10-13-effective-inputs-and-tables.md +39 -0
  5. package/manual/docs/plans/2026-10-13-plugin-defaults.md +45 -0
  6. package/manual/docs/plans/2026-10-14-issue7-recovery.md +33 -0
  7. package/manual/docs/plans/2026-10-14-issue8-10-workflows.md +26 -0
  8. package/manual/docs/plans/2026-10-14-issue9-completion.md +47 -0
  9. package/manual/docs/plans/2026-10-14-managed-execution.md +58 -0
  10. package/manual/docs/plans/2026-10-14-plugin-discovery-history.md +41 -0
  11. package/manual/docs/plans/2026-10-14-structured-reports.md +77 -0
  12. package/manual/docs/plans/2026-10-14-unreleased-review.md +47 -0
  13. package/manual/docs/plugin-development.md +161 -8
  14. package/manual/docs/releases.md +25 -6
  15. package/manual/docs/superpowers/plans/2026-10-13-tui-readable-sessions.md +2 -2
  16. package/manual/docs/superpowers/reviews/2026-10-13-tui-sessions-review.md +2 -0
  17. package/manual/docs/superpowers/reviews/2026-10-14-0.6.0-release-review.md +19 -0
  18. package/manual/docs/verification.md +17 -0
  19. package/manual/packages/core/README.md +6 -0
  20. package/manual/packages/core/src/application.js +15 -9
  21. package/manual/packages/core/src/plugin-catalog.js +20 -2
  22. package/manual/packages/core/src/plugin-loading.js +24 -0
  23. package/manual/packages/core/src/version.js +1 -1
  24. package/manual/packages/fst-cli/README.md +5 -1
  25. package/manual/packages/fst-cli/src/terminal/command-menu.js +3 -2
  26. package/manual/packages/fst-cli/src/terminal/file-autocomplete.js +106 -0
  27. package/manual/packages/fst-cli/src/terminal/log-viewer.js +4 -3
  28. package/manual/packages/fst-cli/src/terminal/navigation.js +23 -1
  29. package/manual/packages/fst-cli/src/terminal/presentation.js +2 -1
  30. package/manual/packages/fst-cli/src/terminal/report-presentation.js +38 -0
  31. package/manual/packages/fst-cli/src/terminal/result-table.js +66 -0
  32. package/manual/packages/fst-cli/src/terminal/run-feed.js +42 -17
  33. package/manual/packages/fst-cli/src/terminal/run-presentation.js +51 -17
  34. package/manual/packages/fst-cli/src/terminal/run-viewer.js +15 -12
  35. package/manual/packages/fst-cli/src/terminal/schema.js +114 -4
  36. package/manual/packages/fst-cli/src/terminal/session-history.js +2 -2
  37. package/manual/packages/fst-cli/src/terminal/session.js +40 -15
  38. package/manual/packages/fst-cli/src/terminal/view.js +80 -68
  39. package/manual/packages/fst-cli/src/terminal/workflow-menu.js +3 -1
  40. package/manual/packages/fst-devtools/README.md +2 -0
  41. package/manual/packages/fst-devtools/src/plugin-quality.js +4 -1
  42. package/manual/packages/fst-devtools/src/plugin-tools.js +1 -0
  43. package/manual/packages/fst-devtools/src/process-usage.js +62 -0
  44. package/manual/packages/fst-platform/README.md +5 -0
  45. package/manual/packages/fst-platform/package.json +1 -0
  46. package/manual/packages/fst-platform/src/managed-process.js +36 -0
  47. package/manual/packages/fst-platform/src/process.js +7 -2
  48. package/manual/packages/fst-plugin/README.md +4 -0
  49. package/manual/packages/fst-plugin/src/contracts.js +3 -0
  50. package/manual/packages/fst-plugin/src/host.js +38 -23
  51. package/manual/packages/fst-plugin/src/runtime.js +14 -8
  52. package/manual/packages/fst-workflow/src/engine.js +1 -1
  53. package/manual/packages/fst-workspace/README.md +4 -0
  54. package/manual/packages/fst-workspace/src/plugin-registry.js +1 -1
  55. package/manual/packages/fst-workspace/src/plugins.js +5 -3
  56. package/manual/packages/fst-workspace/src/workflows.js +27 -9
  57. package/manual/packages/sdk/README.md +10 -0
  58. package/manual/packages/sdk/inputs.js +53 -0
  59. package/manual/packages/sdk/package.json +2 -0
  60. package/manual/packages/sdk/reports.js +53 -0
  61. package/node_modules/@mytools/core/README.md +6 -0
  62. package/node_modules/@mytools/core/src/application.js +15 -9
  63. package/node_modules/@mytools/core/src/plugin-catalog.js +20 -2
  64. package/node_modules/@mytools/core/src/plugin-loading.js +24 -0
  65. package/node_modules/@mytools/core/src/version.js +1 -1
  66. package/node_modules/@mytools/fst-cli/README.md +5 -1
  67. package/node_modules/@mytools/fst-cli/src/terminal/command-menu.js +3 -2
  68. package/node_modules/@mytools/fst-cli/src/terminal/file-autocomplete.js +106 -0
  69. package/node_modules/@mytools/fst-cli/src/terminal/log-viewer.js +4 -3
  70. package/node_modules/@mytools/fst-cli/src/terminal/navigation.js +23 -1
  71. package/node_modules/@mytools/fst-cli/src/terminal/presentation.js +2 -1
  72. package/node_modules/@mytools/fst-cli/src/terminal/report-presentation.js +38 -0
  73. package/node_modules/@mytools/fst-cli/src/terminal/result-table.js +66 -0
  74. package/node_modules/@mytools/fst-cli/src/terminal/run-feed.js +42 -17
  75. package/node_modules/@mytools/fst-cli/src/terminal/run-presentation.js +51 -17
  76. package/node_modules/@mytools/fst-cli/src/terminal/run-viewer.js +15 -12
  77. package/node_modules/@mytools/fst-cli/src/terminal/schema.js +114 -4
  78. package/node_modules/@mytools/fst-cli/src/terminal/session-history.js +2 -2
  79. package/node_modules/@mytools/fst-cli/src/terminal/session.js +40 -15
  80. package/node_modules/@mytools/fst-cli/src/terminal/view.js +80 -68
  81. package/node_modules/@mytools/fst-cli/src/terminal/workflow-menu.js +3 -1
  82. package/node_modules/@mytools/fst-devtools/README.md +2 -0
  83. package/node_modules/@mytools/fst-devtools/src/plugin-quality.js +4 -1
  84. package/node_modules/@mytools/fst-devtools/src/plugin-tools.js +1 -0
  85. package/node_modules/@mytools/fst-devtools/src/process-usage.js +62 -0
  86. package/node_modules/@mytools/fst-platform/README.md +5 -0
  87. package/node_modules/@mytools/fst-platform/package.json +1 -0
  88. package/node_modules/@mytools/fst-platform/src/managed-process.js +36 -0
  89. package/node_modules/@mytools/fst-platform/src/process.js +7 -2
  90. package/node_modules/@mytools/fst-plugin/README.md +4 -0
  91. package/node_modules/@mytools/fst-plugin/src/contracts.js +3 -0
  92. package/node_modules/@mytools/fst-plugin/src/host.js +38 -23
  93. package/node_modules/@mytools/fst-plugin/src/runtime.js +14 -8
  94. package/node_modules/@mytools/fst-workflow/src/engine.js +1 -1
  95. package/node_modules/@mytools/fst-workspace/README.md +4 -0
  96. package/node_modules/@mytools/fst-workspace/src/plugin-registry.js +1 -1
  97. package/node_modules/@mytools/fst-workspace/src/plugins.js +5 -3
  98. package/node_modules/@mytools/fst-workspace/src/workflows.js +27 -9
  99. package/node_modules/@mytools/sdk/README.md +10 -0
  100. package/node_modules/@mytools/sdk/inputs.js +53 -0
  101. package/node_modules/@mytools/sdk/package.json +2 -0
  102. package/node_modules/@mytools/sdk/reports.js +53 -0
  103. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +1032 -1032
  104. package/package.json +1 -1
@@ -1,13 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import {FlowError} from '@mytools/sdk';
2
3
  import { isDeepStrictEqual } from 'node:util';
3
4
  import path from 'node:path';
4
5
  import { parseCommand, fileReference, readRunInput, newWorkflow, setBinding, stepMetadata, outputSources, stepAt, sequenceAt, pruneRuntimeInputs } from './model.js';
5
6
  import { editCondition } from './conditions.js';
6
7
  import { runStatuses } from './run-viewer.js';
7
- import { editValue, needsJsonEditor } from './schema.js';
8
+ import { editValue, needsJsonEditor, commandInput, inputEntries } from './schema.js';
9
+ import {hasInputRules} from '@mytools/sdk/inputs';
8
10
  import {gettingStarted,pluginStatus,pluginDetails} from './presentation.js';
9
11
  import {readableValue,readableError,runCategory,runCategoryNames} from './run-presentation.js';
10
- import {commandMenu} from './navigation.js';
12
+ import {commandMenu,commandCompletions} from './navigation.js';
11
13
  import {workflowMenu} from './workflow-menu.js';
12
14
  import {commandPresetsMenu,shortcutsMenu,runShortcut} from './command-menu.js';
13
15
  import {validate} from '@mytools/sdk/schema';
@@ -30,6 +32,7 @@ export class TerminalSession{
30
32
  loadWorkflow(flow){this.flow=structuredClone(flow);this.savedFlow=structuredClone(flow);this.dirty=false;}
31
33
  updateWorkflow(flow){this.flow=flow;this.dirty=!isDeepStrictEqual(flow,this.savedFlow);}
32
34
  commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands(),this.app.listShortcuts?.()??[]);}
35
+ completions(){return commandCompletions(this.app.listPlugins?.()??[],this.app.listCommands(),this.app.listShortcuts?.()??[]);}
33
36
  async discard(){return !this.dirty||await this.ui.confirm('当前修改未保存,是否放弃修改?');}
34
37
  async mayExit(){
35
38
  if(!await this.discard())return false;
@@ -42,13 +45,15 @@ export class TerminalSession{
42
45
  }
43
46
  async run(){
44
47
  await this.startHistory();
45
- this.ui.show(gettingStarted(await this.app.listWorkflows(),this.app.project),{kind:'welcome'});
48
+ const workflows=await this.app.listWorkflows();
49
+ this.ui.show(gettingStarted(workflows.filter(flow=>flow.status!=='invalid'),this.app.project),{kind:'welcome'});
50
+ for(const flow of workflows.filter(flow=>flow.status==='invalid'))this.ui.show(`工作流不可用:${flow.diagnostic.message}`);
46
51
  for(const warning of this.app.host?.warnings??[])this.ui.show(`声明提示:${warning.message}`);
47
52
  try{
48
53
  while(!this.exiting){
49
54
  this.ui.summary(`欢迎使用 Fishtools · 插件编排工作台\n当前工作区:${this.app.projectDir}\n${this.app.listPlugins().length} 个插件 · ${(await this.app.listWorkflows()).length} 个工作流${this.flow?` · ${this.flow.title??this.flow.id} · ${this.dirty?'未保存':'已保存'}`:''}`);
50
55
  let text;
51
- try{text=await this.ui.input('fst > / 操作 · ! 系统命令 · Shift+Enter 换行 · Enter 提交',{commands:this.commands(),workflows:await this.app.listWorkflows(),multiline:true});}
56
+ try{text=await this.ui.input('fst > / 操作 · ! 系统命令 · Shift+Enter 换行 · Enter 提交',{commands:this.completions(),workflows:await this.app.listWorkflows(),multiline:true});}
52
57
  catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.mayExit())break;}catch{}continue;}
53
58
  if(!text.trim())continue;
54
59
  try{await this.execute(text);}catch(e){if(e.code!=='PROMPT_CANCELLED'||!this.ui.endCommand)this.ui.show(e.code==='PROMPT_CANCELLED'?'已返回主面板。':`${e.code==='COMMAND_INPUT'?'输入提示':'操作失败'}:${e.message}`);}
@@ -100,7 +105,7 @@ export class TerminalSession{
100
105
  const [command,...args]=parseCommand(text);
101
106
  const shortcut=this.app.listShortcuts?.().find(entry=>entry.name===command);
102
107
  if(shortcut)return runShortcut(this,shortcut,args);
103
- const entry=this.commands().find(c=>c.name===command&&c.plugin);
108
+ const entry=this.completions().find(c=>c.name===command&&c.plugin);
104
109
  if(entry){if(args.length)throw new Error(`/${command} 请从二级菜单选择能力`);return this.pluginSkills(entry.plugin);}
105
110
  const pluginCommand=this.app.listCommands().find(c=>c.name===command);
106
111
  if(pluginCommand)return this.pluginCommand(pluginCommand,args);
@@ -166,17 +171,31 @@ export class TerminalSession{
166
171
  default:throw new Error(`未知操作 /${command},输入 /help 查看可用操作`);
167
172
  }
168
173
  }
169
- async pluginCommand(command,args){
174
+ async pluginCommand(command,args,{fromMenu=false}={}){
170
175
  if(args.length>1)throw new Error(`用法:/${command.name} [@参数.json]`);
171
176
  let input;
172
177
  if(args.length)input=await readRunInput(args[0],this.baseDir);
173
178
  else{
174
179
  const initial=structuredClone(command.shortcutInput??{});
180
+ const target=command.actionId??`/${command.targetName??command.name}`;
181
+ const preview=this.app.previewInput?value=>this.app.previewInput(target,value):undefined;
175
182
  const empty=command.input?.type==='object'&&command.input.properties&&Object.keys(command.input.properties).length===0;
176
- if(command.shortcutInput!==undefined||empty){
177
- try{input=validate(command.input,initial,command.description??command.name);}
178
- catch{input=await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description});}
179
- }else input=command.input?await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description}):initial;
183
+ if(command.shortcutInput!==undefined||empty||!command.input){
184
+ try{
185
+ if(hasInputRules(command.input)&&preview){validate(command.input,preview(initial).value,command.description??command.name,{useDefaults:false});input=initial;}
186
+ else input=validate(command.input,initial,command.description??command.name);
187
+ }
188
+ catch{
189
+ if(hasInputRules(command.input)){
190
+ const result=await commandInput(this.ui,command.input,initial,{baseDir:this.baseDir,label:command.description,preview});
191
+ if(result.cancelled)return;input=result.value;
192
+ }else input=await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description});
193
+ }
194
+ }else{
195
+ const result=await commandInput(this.ui,command.input,initial,{baseDir:this.baseDir,label:command.description??command.name,preview});
196
+ if(result.cancelled)return;
197
+ input=result.value;
198
+ }
180
199
  }
181
200
  const ui=Object.fromEntries(['input','select','show','confirm'].map(name=>[name,this.ui[name].bind(this.ui)]));
182
201
  const definition=this.app.host.definitions.get(command.plugin);
@@ -188,6 +207,7 @@ export class TerminalSession{
188
207
  const targetName=command.targetName??command.name;
189
208
  if(!command.actionId&&!this.app.listCommands().some(current=>current.name===targetName&&current.plugin===command.plugin))throw new Error('命令目标插件已变更,请重新选择目标后执行');
190
209
  const run=command.actionId?await this.app.startAction(command.actionId,input,{ui,allowUnknownPrivileges}):await this.app.startCommand(targetName,input,{ui,allowUnknownPrivileges,expectedPlugin:command.plugin});
210
+ if(fromMenu)this.ui.recordCommand?.(`/${targetName}`,{display:true});
191
211
  if(this.ui.trackRun){
192
212
  this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:command.description??command.name,input,done:run.done});
193
213
  if(command.action){void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;}
@@ -330,8 +350,10 @@ export class TerminalSession{
330
350
  }
331
351
  const props=Object.entries(meta.input?.properties??{});if(!props.length)return;
332
352
  while(true){
353
+ const effective=step.action&&hasInputRules(meta.input)&&this.app.previewInput?this.app.previewInput(step.action,step.with??{}):undefined;
354
+ const descriptions=effective?inputEntries(meta.input,effective.value,effective.fields):undefined;
333
355
  const selection=await this.ui.select(`${meta.title} · 选择要设置的参数`,[
334
- ...props.map(([key,s])=>({value:`field:${key}`,label:`${s.title??key}${meta.input.required?.includes(key)?' *':''}`,description:JSON.stringify(step.with?.[key]??s.default)??'未填写'})),
356
+ ...props.map(([key,s],index)=>({value:`field:${key}`,label:`${s.title??key}${meta.input.required?.includes(key)?' *':''}`,description:step.with?.[key]?.$ref?`运行时绑定:${step.with[key].$ref}`:descriptions?.[index]??JSON.stringify(step.with?.[key]??s.default)??'未填写'})),
335
357
  {value:'done',label:'完成参数设置'},
336
358
  ]);
337
359
  if(selection==='done')return;
@@ -340,7 +362,7 @@ export class TerminalSession{
340
362
  const choices=outputSources(flow,index,catalog,workflows).filter(s=>!schema.type||!s.schema?.type||schema.type===s.schema.type);
341
363
  const mode=await this.ui.select(`${schema.title??key} · 输入来源`,[
342
364
  {value:'fixed',label:'填写固定值 / @本地文件路径'},{value:'runtime',label:'每次运行时填写'},
343
- ...choices.map(({value,label})=>({value,label})),...(!required?[{value:'unset',label:`不传此参数${schema.default!==undefined?',使用已声明的默认值':''}`}]:[]),
365
+ ...choices.map(({value,label})=>({value,label})),...(!required||schema.default!==undefined||schema['x-fst']?.fromConfig?[{value:'unset',label:schema['x-fst']?.fromConfig?'恢复继承插件配置':`不传此参数${schema.default!==undefined?',使用已声明的默认值':''}`}]:[]),
344
366
  ]);
345
367
  if(mode==='unset')setBinding(flow,index,key,'fixed',schema,undefined);
346
368
  else if(mode==='fixed'){
@@ -429,7 +451,7 @@ export class TerminalSession{
429
451
  const choice=await this.ui.select(`[插件] ${plugin?.title??id} · 选择能力`,[
430
452
  ...commands.map(c=>({value:`command:${c.name}`,label:c.description??c.name,description:`/${c.name}`})),{value:'back',label:'返回主面板'},
431
453
  ]);
432
- if(choice!=='back')await this.pluginCommand(commands.find(c=>`command:${c.name}`===choice),[]);
454
+ if(choice!=='back')await this.pluginCommand(commands.find(c=>`command:${c.name}`===choice),[],{fromMenu:true});
433
455
  }
434
456
  async toolConfig(){
435
457
  while(true){
@@ -599,7 +621,7 @@ export class TerminalSession{
599
621
  if(choice==='back')return;
600
622
  if(choice==='details')await this.ui.viewMarkdown(plugin.title??id,pluginDetails(plugin,actions,declaredCommands));
601
623
  else if(choice==='configure')await this.configure(id);
602
- else if(choice.startsWith('command:'))await this.pluginCommand(commands.find(c=>c.name===choice.slice(8)),[]);
624
+ else if(choice.startsWith('command:'))await this.pluginCommand(commands.find(c=>c.name===choice.slice(8)),[],{fromMenu:true});
603
625
  else if(choice==='remove'){
604
626
  this.writable();if(await this.ui.confirm(`${plugin.scope==='global'?'全局卸载(影响所有工作区)':'移除'} ${id}?`)){
605
627
  let removed;
@@ -672,5 +694,8 @@ export async function startTerminal(app,options={}){
672
694
  const ui=new TerminalView({baseDir:options.baseDir??process.cwd(),previewLabel:options.previewLabel}),session=new TerminalSession(app,ui,options);
673
695
  const terminate=()=>{session.exiting=true;ui.pending?.reject(Object.assign(new Error('退出'),{code:'PROMPT_CANCELLED'}));for(const r of app.listRuns())if(r.status==='running')app.cancelRun(r.id);};
674
696
  process.once('SIGTERM',terminate);
675
- try{ui.start();await session.run();}finally{process.removeListener('SIGTERM',terminate);await ui.close();}
697
+ try{ui.start();await session.run();}finally{
698
+ process.removeListener('SIGTERM',terminate);const cleanup=await ui.close();
699
+ if(cleanup?.incomplete)throw new FlowError(`终端已关闭,但仍有 ${cleanup.unreaped} 个文件探测进程未回收`,{code:'FS_CLEANUP_INCOMPLETE',details:cleanup});
700
+ }
676
701
  }
@@ -1,10 +1,10 @@
1
- import { readdirSync, statSync } from 'node:fs';
2
- import path from 'node:path';
3
- import { homedir } from 'node:os';
4
- import { TUI, Text, Container, Editor, SelectList, ProcessTerminal, CombinedAutocompleteProvider, matchesKey, truncateToWidth, visibleWidth } from '@mariozechner/pi-tui';
1
+ import {FileAutocomplete,createAutocompleteProbe} from './file-autocomplete.js';
2
+ export {FileAutocomplete} from './file-autocomplete.js';
3
+ import { TUI, Text, Container, Editor, SelectList, ProcessTerminal, matchesKey, truncateToWidth, visibleWidth } from '@mariozechner/pi-tui';
5
4
  import { MarkdownLogViewer } from './log-viewer.js';
6
5
  import { RunViewer, mouseOn, mouseOff, mouseWheel } from './run-viewer.js';
7
6
  import {RunFeed} from './run-feed.js';
7
+ import {presentationCatalog} from './run-presentation.js';
8
8
  import { logo } from './presentation.js';
9
9
  import { VERSION } from '@mytools/core/version';
10
10
  import {cleanText as safe,palette,menuTheme as theme,fitLine,shortenPath} from './theme.js';
@@ -13,44 +13,9 @@ export const cancelled=()=>Object.assign(new Error('已返回主菜单'),{code:'
13
13
  // 上游菜单按字符数分配描述宽度,中文双宽字符需要再按终端列宽收口。
14
14
  function fit(component){const render=component.render.bind(component);component.render=width=>render(width).map(line=>truncateToWidth(line,width));return component;}
15
15
 
16
- // Pi 的 @ 模糊搜索依赖额外的 fd 程序。这里按目录补全,离线目标机无需安装 fd。
17
- export class FileAutocomplete extends CombinedAutocompleteProvider{
18
- constructor(commands,baseDir,workflows=[]){super(commands,baseDir);this.baseDir=baseDir;this.workflows=workflows;}
19
- getSuggestions(lines,line,col){
20
- const run=lines[line].slice(0,col).match(/^\/run\s+([^@"\s]*)$/);
21
- if(run){
22
- const query=run[1].toLowerCase(),items=this.workflows.filter(flow=>`${flow.id} ${flow.title??''}`.toLowerCase().includes(query)).map(flow=>({value:flow.id,label:flow.title??flow.id,description:flow.id,workflow:true}));
23
- return items.length?{prefix:run[1],items}:null;
24
- }
25
- const prefix=lines[line].slice(0,col).match(/(?:^|\s)(@"[^"\n]*|@[^\s]*)$/)?.[1];
26
- if(!prefix)return super.getSuggestions(lines,line,col);
27
- const raw=prefix.slice(1).replace(/^"/,'').replaceAll('\\','/');
28
- const expanded=raw.startsWith('~/')?path.join(homedir(),raw.slice(2)):raw;
29
- const absolute=path.resolve(this.baseDir,expanded||'.');
30
- const directory=!raw||raw.endsWith('/')?absolute:path.dirname(absolute);
31
- const query=!raw||raw.endsWith('/')?'':path.basename(absolute).toLowerCase();
32
- try{
33
- const items=readdirSync(directory,{withFileTypes:true}).filter(e=>e.name.toLowerCase().startsWith(query)).map(e=>{
34
- let dir=e.isDirectory();if(e.isSymbolicLink()){try{dir=statSync(path.join(directory,e.name)).isDirectory();}catch{}}
35
- const head=raw.slice(0,raw.lastIndexOf('/')+1),file=head+e.name+(dir?'/':'');
36
- return {value:`@${/[\s"]/.test(file)?JSON.stringify(file):file}`,label:safe(e.name)+(dir?'/':''),description:dir?'目录':'文件',directory:dir};
37
- }).sort((a,b)=>(b.description==='目录')-(a.description==='目录')||a.label.localeCompare(b.label)).slice(0,100);
38
- return items.length?{prefix,items}:null;
39
- }catch{return null;}
40
- }
41
- getForceFileSuggestions(lines,line,col){return this.getSuggestions(lines,line,col);}
42
- applyCompletion(lines,line,col,item,prefix){
43
- if(item.workflow){const next=[...lines],before=lines[line].slice(0,col-prefix.length),text=item.value+' ';next[line]=before+text+lines[line].slice(col);return {lines:next,cursorLine:line,cursorCol:before.length+text.length};}
44
- if(!prefix.startsWith('@'))return super.applyCompletion(lines,line,col,item,prefix);
45
- const before=lines[line].slice(0,col-prefix.length);
46
- let after=lines[line].slice(col);if(after.startsWith('"'))after=after.slice(1);
47
- const completed=item.value+(item.directory?'':' '),next=[...lines];next[line]=before+completed+after;
48
- return {lines:next,cursorLine:line,cursorCol:before.length+completed.length-(item.directory&&item.value.endsWith('"')?1:0)};
49
- }
50
- }
51
-
52
16
  export class TerminalView{
53
- constructor({baseDir,previewLabel,terminal=new ProcessTerminal()}={}){
17
+ constructor({baseDir,previewLabel,terminal=new ProcessTerminal(),fileAutocompleteOptions={}}={}){
18
+ this.fileAutocompleteOptions=fileAutocompleteOptions;this.fileProviders=new Set();
54
19
  this.baseDir=baseDir;this.terminal=terminal;this.tui=new TUI(terminal);this.logs=[];this.runViews=new Map();
55
20
  this.historyOffset=0;this.commandHistory=[];this.commandHistoryIndex=null;this.feed=new RunFeed(()=>this.refreshTranscript());
56
21
  const originalStart=terminal.start.bind(terminal);
@@ -69,6 +34,7 @@ export class TerminalView{
69
34
  }
70
35
  if(matchesKey(data,'ctrl+c')){if(this.runViewer){this.runViewer.onClose();return;}if(this.cancelRun)this.cancelRun();this.pending?.reject(cancelled());return;}
71
36
  if(matchesKey(data,'escape')&&this.pending&&!this.logViewer&&!this.runViewer){
37
+ this.editor?.autocompleteProvider?.cancelPending?.();
72
38
  // 编辑器自己处理补全菜单 Escape,第二次 Escape 返回上一级。
73
39
  if(this.editor?.isShowingAutocomplete?.()){input(data);return;}
74
40
  this.pending.reject(cancelled());return;
@@ -87,10 +53,11 @@ export class TerminalView{
87
53
  }
88
54
  lines.push(line);
89
55
  }
90
- lines.push(this.homeInput?(width<45?`/ 操作 · ! 命令 · Ctrl+O ${this.feed.expanded?'收起':'展开'}`:`/ 操作 · ! 命令 · Ctrl+O ${this.feed.expanded?'收起':'展开'}详情 · /logs 回看 · Ctrl+C 退出`):this.editor&&this.multilineInput?'↑↓ 移动 · Tab 补全 · Esc 返回 · Ctrl+C 退出':(width<45?'↑↓ 选择 Enter 确认 Esc 返回':'↑↓ 选择 Enter 确认 Tab 补全 Esc 返回 Ctrl+C 退出'));
56
+ lines.push(this.homeInput?(width<45?`/ 操作 · ! 命令 · Ctrl+O ${this.feed.expanded?'收起':'展开'}`:`/ 操作 · ! 命令 · Ctrl+O ${this.feed.expanded?'收起':'展开'}原始输出 · /logs 回看 · Ctrl+C 退出`):this.editor&&this.multilineInput?'↑↓ 移动 · Tab 补全 · Esc 返回 · Ctrl+C 退出':(width<45?'↑↓ 选择 Enter 确认 Esc 返回':'↑↓ 选择 Enter 确认 Tab 补全 Esc 返回 Ctrl+C 退出'));
91
57
  return lines.map(line=>line?fitLine(' '+palette.muted(line),width):'');
92
58
  };
93
- const renderTranscript=this.transcript.render.bind(this.transcript);
59
+ const renderText=this.transcript.render.bind(this.transcript);
60
+ const renderTranscript=width=>{if(this.transcriptWidth!==width)this.updateTranscript(width);return renderText(width);};
94
61
  this.headingLines=(width,mark='FISHtools')=>{
95
62
  const room=Math.max(1,width-2),lines=[''];
96
63
  const summary=(this.summaryText??'').split('\n');
@@ -151,14 +118,31 @@ export class TerminalView{
151
118
  this.tui.requestRender();return true;
152
119
  }
153
120
  async close(){
154
- if(this.closed)return;this.closed=true;
155
- this.feed.close();
156
- // Pi 的渲染排在 nextTick;先排空最后一帧,再恢复终端,避免退出后隐藏光标。
157
- await new Promise(resolve=>process.nextTick(resolve));this.tui.stop();
158
- if(this.started)this.terminal.write(mouseOff+'\x1b[?1049l');
159
- if(this.terminal instanceof ProcessTerminal)process.stdin.pause();
121
+ if(this.closing)return this.closing;this.closed=true;
122
+ return this.closing=(async()=>{
123
+ try{
124
+ await Promise.all([...this.fileProviders].map(provider=>provider.close()));this.fileProviders.clear();
125
+ return await this.fileProbe?.close();
126
+ }finally{
127
+ this.feed.close();
128
+ // Pi 的渲染排在 nextTick;先排空最后一帧,再恢复终端,避免退出后隐藏光标。
129
+ await new Promise(resolve=>process.nextTick(resolve));this.tui.stop();
130
+ if(this.started)this.terminal.write(mouseOff+'\x1b[?1049l');
131
+ if(this.terminal instanceof ProcessTerminal)process.stdin.pause();
132
+ }
133
+ })();
160
134
  }
161
135
  summary(text){this.summaryText=safe(text);this.tui.requestRender();}
136
+ recordCommand(text,{display=false}={}){
137
+ // 表单参数不写入可召回命令;Shell 原文仍保留末尾换行。
138
+ const command=text.trimStart().startsWith('!')?text:text.trim();
139
+ if(!command)return;
140
+ if(this.commandHistory.at(-1)!==command){
141
+ this.commandHistory.push(command);if(this.commandHistory.length>100)this.commandHistory.shift();
142
+ this.commandHistoryIndex=null;this.onHistoryChange?.();
143
+ }
144
+ if(display)this.feed.message('› '+safe(command));
145
+ }
162
146
  beginCommand(text){
163
147
  const name=text.trim().split(/\s+/)[0].replace(/^\//,'');
164
148
  const labels={workflows:'工作流',run:'运行工作流',plugin:'插件管理',plugins:'插件管理',config:'工具配置',pack:'打包',logs:'执行记录',runs:'执行记录',tools:'工具',help:'帮助',exit:'退出'};
@@ -168,12 +152,15 @@ export class TerminalView{
168
152
  endCommand(){
169
153
  this.commandLocation=null;this.tui.requestRender();
170
154
  }
171
- transcriptText(){return [...this.logs,this.feed.text()].filter(Boolean).join('\n\n');}
155
+ transcriptText(width=this.terminal.columns){return [...this.logs,this.feed.text(Math.max(1,width-2))].filter(Boolean).join('\n\n');}
172
156
  refreshTranscript(){
173
157
  const width=this.terminal.columns,previous=this.transcript.render===undefined?0:new Text(safe(this.lastTranscript??''),1,0).render(width).length;
174
- const text=safe(this.transcriptText());
158
+ const text=safe(this.transcriptText(width));
175
159
  if(this.historyOffset>0)this.historyOffset=Math.max(0,this.historyOffset+new Text(text,1,0).render(width).length-previous);
176
- this.lastTranscript=text;this.transcript.setText(text.split('\n').map(line=>line.startsWith('✗ ')?palette.error(line):/^[›✓●] /.test(line)?palette.brand(line):line.startsWith('↩ ')?palette.muted(line):line).join('\n'));this.tui.requestRender();this.onHistoryChange?.();
160
+ this.updateTranscript(width,text);this.tui.requestRender();this.onHistoryChange?.();
161
+ }
162
+ updateTranscript(width,text=safe(this.transcriptText(width))){
163
+ this.transcriptWidth=width;this.lastTranscript=text;this.transcript.setText(text.split('\n').map(line=>line.startsWith('✗ ')?palette.error(line):/^[›✓●] /.test(line)?palette.brand(line):line.startsWith('↩ ')?palette.muted(line):line).join('\n'));
177
164
  }
178
165
  trackRun(options){this.feed.track(options);}
179
166
  clearMessages(){this.logs=[];this.refreshTranscript();}
@@ -190,7 +177,7 @@ export class TerminalView{
190
177
  try{
191
178
  await new Promise((resolve,reject)=>{
192
179
  this.pending={resolve,reject};
193
- this.logViewer=new MarkdownLogViewer({title:safe(title),text:safe(text),terminal:this.terminal,onClose:resolve,requestRender:()=>this.tui.requestRender()});
180
+ this.logViewer=new MarkdownLogViewer({title:safe(title),text,terminal:this.terminal,onClose:resolve,requestRender:()=>this.tui.requestRender()});
194
181
  this.terminal.write(mouseOn);
195
182
  // Overlay 的基底为空,旧 transcript 不再撑高滚动区域;关闭后原样恢复。
196
183
  this.tui.children=[];
@@ -217,7 +204,7 @@ export class TerminalView{
217
204
  try{
218
205
  await new Promise((resolve,reject)=>{
219
206
  this.pending={resolve,reject};
220
- const viewer=new RunViewer({record:initial,flow:initial.status==='running'?flow:undefined,workflows,catalog:app.catalog?.()??[],terminal:this.terminal,onClose:resolve,onCancel:()=>app.cancelRun(runId),onReadLogs:app.readLogPage?()=>{readPages=true;resolve();}:undefined,onReadOutput:()=>{renderedOutput=viewer.getRenderedOutput();resolve();},requestRender:()=>this.tui.requestRender()});
207
+ const viewer=new RunViewer({record:initial,flow:initial.status==='running'?flow:undefined,workflows,catalog:presentationCatalog(app),terminal:this.terminal,onClose:resolve,onCancel:()=>app.cancelRun(runId),onReadLogs:app.readLogPage?()=>{readPages=true;resolve();}:undefined,onReadOutput:()=>{renderedOutput=width=>viewer.getRenderedOutput(width,{markdown:true});resolve();},requestRender:()=>this.tui.requestRender()});
221
208
  this.runViewer=viewer;Object.assign(viewer,this.runViews.get(runId)??{});
222
209
  if(initialView!==undefined){viewer.view=initialView;viewer.offset=0;}
223
210
  const stopElapsed=()=>{if(elapsedTimer){clearInterval(elapsedTimer);elapsedTimer=null;}};
@@ -264,18 +251,16 @@ export class TerminalView{
264
251
  }
265
252
  async ask(message,component){
266
253
  fit(component);
267
- const title=new Text(palette.brand('› ')+palette.text(safe(message)),1,0);
268
- const renderTitle=title.render.bind(title);
269
- title.render=width=>[fitLine(' '+palette.border('─'.repeat(Math.max(1,width-2))),width),...renderTitle(width)];
270
- if(message.startsWith('fst >'))title.render=()=>[];
254
+ const title=new Text('',1,0);
255
+ // 参数预览可随窗口尺寸重新排版;普通字符串标题保持原有行为。
256
+ title.render=width=>{
257
+ const text=typeof message==='function'?message(width,this.terminal.rows):message;
258
+ return [fitLine(' '+palette.border('─'.repeat(Math.max(1,width-2))),width),...new Text(palette.brand('› ')+palette.text(safe(text)),1,0).render(width)];
259
+ };
260
+ if(typeof message==='string'&&message.startsWith('fst >'))title.render=()=>[];
271
261
  this.prompt.clear();this.prompt.addChild(title);this.prompt.addChild(component);this.tui.setFocus(component);this.tui.requestRender();
272
262
  try{return await new Promise((resolve,reject)=>{this.pending={resolve,reject};component.onSubmit=text=>{
273
- // Shell 历史保留末尾换行,避免再次执行时改变反斜杠续行等语义。
274
- const historyText=text.trimStart().startsWith('!')?text:text.trim();
275
- if(this.homeInput&&historyText&&this.commandHistory.at(-1)!==historyText){
276
- this.commandHistory.push(historyText);if(this.commandHistory.length>100)this.commandHistory.shift();
277
- this.onHistoryChange?.();
278
- }
263
+ if(this.homeInput)this.recordCommand(text);
279
264
  resolve(text);
280
265
  };component.onSelect=item=>resolve(item.value);component.onCancel=()=>reject(cancelled());});}
281
266
  finally{if(this.homeInput)this.terminal.write(mouseOff);this.pending=null;this.editor=null;this.homeInput=false;this.multilineInput=false;this.prompt.clear();this.tui.requestRender();}
@@ -321,14 +306,41 @@ export class TerminalView{
321
306
  if(this.homeInput)this.terminal.write(mouseOn);
322
307
  this.editor=editor;editor.setText(this.homeInput?(this.homeDraft??value):value);
323
308
  if(this.homeInput)this.homeDraft=undefined;
324
- if(files||commands.length)editor.setAutocompleteProvider(new FileAutocomplete(commands,this.baseDir,workflows));
325
- return this.ask(message,editor);
309
+ let provider;
310
+ if(files||commands.length){
311
+ // 预算属于整个终端;未退出的 worker 不能因切换输入框而被遗忘。
312
+ this.fileProbe??=createAutocompleteProbe(this.fileAutocompleteOptions);
313
+ provider=new FileAutocomplete(commands,this.baseDir,workflows,{...this.fileAutocompleteOptions,probe:this.fileProbe,ownsProbe:false,files,onUpdate:snapshot=>{
314
+ const cursor=editor.getCursor();
315
+ if(this.closed||this.editor!==editor||!this.pending||this.logViewer||this.runViewer||editor.getText()!==snapshot.lines.join('\n')||cursor.line!==snapshot.line||cursor.col!==snapshot.col)return;
316
+ // pi-tui 的 updateAutocomplete 只更新已打开的菜单;冷缓存要用此入口重新建立候选。
317
+ // 此调用只更新菜单,绝不调用 applyCompletion 或合成 Tab 按键。
318
+ editor.tryTriggerAutocomplete();this.tui.requestRender();
319
+ }});
320
+ this.fileProviders.add(provider);editor.setAutocompleteProvider(provider);
321
+ const input=editor.handleInput.bind(editor);
322
+ editor.handleInput=data=>{
323
+ const before=editor.getText();provider.cancelPending();input(data);
324
+ const cursor=editor.getCursor(),text=editor.getLines()[cursor.line].slice(0,cursor.col);
325
+ // 冷缓存时上游已收起菜单,继续输入不会走 updateAutocomplete,需再次触发查询。
326
+ if(editor.getText()!==before&&text&&!/\s$/.test(text)&&!editor.isShowingAutocomplete())editor.tryTriggerAutocomplete();
327
+ };
328
+ }
329
+ return this.ask(message,editor).finally(async()=>{if(provider){await provider.close();this.fileProviders.delete(provider);}});
326
330
  }
327
331
  async select(message,items){
328
332
  if(!items.length)throw new Error('当前没有可选择的项目');
329
- const list=new SelectList(items.map(i=>({...i,label:safe(i.label).replace(/\s+/g,' '),description:i.description?safe(i.description).replace(/\s+/g,' '):undefined})),8,theme);
333
+ const visibleItems=()=>items.filter(item=>!item.hidden?.()).map(i=>({...i,label:safe(i.label).replace(/\s+/g,' '),description:i.description?safe(i.description).replace(/\s+/g,' '):undefined}));
334
+ const list=new SelectList(visibleItems(),8,theme);
330
335
  list.render=width=>{
331
- const reserved=this.headingLines(width).length+new Text(palette.text(safe(message)),1,0).render(width).length+this.hint.render(width).length+2;
336
+ // 缩放窗口后保留当前选项;只显示此刻确实需要的展开入口。
337
+ const selected=list.getSelectedItem()?.value;
338
+ const previousIndex=list.selectedIndex;
339
+ list.filteredItems=visibleItems();
340
+ const selectedIndex=list.filteredItems.findIndex(item=>item.value===selected);
341
+ list.selectedIndex=selectedIndex>=0?selectedIndex:Math.min(previousIndex,list.filteredItems.length-1);
342
+ const text=typeof message==='function'?message(width,this.terminal.rows):message;
343
+ const reserved=this.headingLines(width).length+new Text(palette.text(safe(text)),1,0).render(width).length+this.hint.render(width).length+2;
332
344
  list.maxVisible=Math.max(1,Math.min(8,this.terminal.rows-reserved));
333
345
  const start=Math.max(0,Math.min(list.selectedIndex-Math.floor(list.maxVisible/2),list.filteredItems.length-list.maxVisible));
334
346
  const end=Math.min(start+list.maxVisible,list.filteredItems.length),lines=[];
@@ -3,7 +3,7 @@ export async function workflowMenu(session){
3
3
  while(true){
4
4
  const workflows=await app.listWorkflows();
5
5
  const choice=await ui.select('工作流 · 选择后编辑或执行',[
6
- ...workflows.map(flow=>({value:`workflow:${flow.id}`,label:flow.title??flow.id,description:`${flow.id} · ${flow.steps.length} 步`})),
6
+ ...workflows.map(flow=>({value:`workflow:${flow.id}`,label:flow.title??flow.id,description:flow.status==='invalid'?flow.diagnostic.message:`${flow.id} · ${flow.steps.length} 步`})),
7
7
  ...(!app.project.readOnly?[{value:'new',label:'+ 新建工作流'}]:[]),
8
8
  ...(session.dirty?[{value:'draft',label:`继续编辑草稿:${session.flow.title}`}]:[]),
9
9
  {value:'back',label:'返回主面板'},
@@ -11,6 +11,8 @@ export async function workflowMenu(session){
11
11
  if(choice==='back')return;
12
12
  try{
13
13
  const id=choice.startsWith('workflow:')?choice.slice(9):null;
14
+ const selected=workflows.find(flow=>flow.id===id);
15
+ if(selected?.status==='invalid'){ui.show(`工作流不可用:${selected.diagnostic.message}`);continue;}
14
16
  if(choice==='new'){if(await session.execute('/new')===false)continue;}
15
17
  else if(choice!=='draft'&&session.flow?.id!==id){
16
18
  if(!await session.discard())continue;
@@ -19,6 +19,8 @@
19
19
 
20
20
  ## 维护与验证
21
21
 
22
+ `plugin lint` 的 `UNMANAGED_PROCESS` 迁移警告识别可追溯的 child_process、execa、cross-spawn、shelljs.exec 及平台底层 runProcess 调用,指向文件行列并建议上下文 ctx.runProcess。只导入未调用、注释、字符串及无法确认的局部重名不据此告警。诊断保持 warning,不拒绝旧插件;复杂动态代码、依赖内部和解释器内部不保证覆盖,不构成安全隔离。
23
+
22
24
  本模块负责上述职责的实现、文档与回归;跨模块接口调整需要同时检查生产者与消费者。具体维护人员尚未指定。
23
25
 
24
26
  在仓库根目录执行:
@@ -3,6 +3,7 @@ import {readFile,realpath} from 'node:fs/promises';
3
3
  import {isBuiltin,createRequire} from 'node:module';
4
4
  import {fileURLToPath,pathToFileURL} from 'node:url';
5
5
  import {parse} from 'acorn';
6
+ import {inspectProcessUsage} from './process-usage.js';
6
7
 
7
8
  const nonempty=value=>typeof value==='string'&&value.trim().length>0;
8
9
  function inspectSchema(schema,location,warning,seen=new WeakSet()){
@@ -84,6 +85,8 @@ function sourceReferences(ast,commonjs,warning,location){
84
85
  base?.type==='MemberExpression'&&!base.computed&&base.property.name==='url'&&base.object.type==='MetaProperty'&&base.object.meta.name==='import')loaders.add(node.id.name);
85
86
  });
86
87
  // 保守跳过文件内重名绑定;不把局部对象当作 Node 全局对象。
88
+ const isRequire=node=>node.callee?.type==='Identifier'&&((commonjs&&!bindings.has('require')&&node.callee.name==='require')||loaders.has(node.callee.name));
89
+ inspectProcessUsage(ast,{bindings,isRequire,walk},warning,location);
87
90
  walk(ast,node=>{
88
91
  const at=`${location}:${node.loc.start.line}:${node.loc.start.column+1}`;
89
92
  if(['ImportDeclaration','ExportNamedDeclaration','ExportAllDeclaration','ImportExpression'].includes(node.type)&&typeof node.source?.value==='string'){
@@ -91,7 +94,7 @@ function sourceReferences(ast,commonjs,warning,location){
91
94
  }
92
95
  if(node.type!=='CallExpression')return;
93
96
  const callee=node.callee;
94
- if(callee.type==='Identifier'&&((commonjs&&!bindings.has('require')&&callee.name==='require')||loaders.has(callee.name))&&typeof node.arguments[0]?.value==='string'){
97
+ if(isRequire(node)&&typeof node.arguments[0]?.value==='string'){
95
98
  references.push({specifier:node.arguments[0].value,at,commonjs:true});
96
99
  }
97
100
  if(!bindings.has('console')&&callee.type==='MemberExpression'&&callee.object.type==='Identifier'&&callee.object.name==='console'&&
@@ -38,6 +38,7 @@ export async function lintPluginDirectory(directory){
38
38
  const quality=await import('./plugin-quality.js');
39
39
  return {...await inspectPluginDirectory(directory,quality),notes:[...validationNotes(),
40
40
  {code:'STATIC_SCAN_LIMITS',message:'源码检查仅覆盖入口可达的本地 JavaScript 和字面量依赖;动态加载、别名调用和局部重名可能无法识别。质量警告不会修改文件,也不代表运行结果或安全保证。'},
41
+ {code:'UNMANAGED_PROCESS_LIMITS',message:'进程检查只定位可识别的直接调用;动态计算、复杂别名、依赖包及 Python/Shell 内部执行不保证覆盖。迁移警告不阻止旧插件加载,不构成安全隔离或自动提权。'},
41
42
  ]};
42
43
  }
43
44
 
@@ -0,0 +1,62 @@
1
+ const processMembers=new Set(['spawn','spawnSync','exec','execSync','execFile','execFileSync','fork']);
2
+ const sources=new Set(['node:child_process','child_process','execa','cross-spawn','shelljs','@mytools/fst-platform']);
3
+ const property=node=>node.computed?node.property?.value:node.property?.name;
4
+
5
+ function launches({source,members}){
6
+ const names=members[0]==='default'?members.slice(1):members;
7
+ if(['node:child_process','child_process'].includes(source))return names.length===1&&processMembers.has(names[0]);
8
+ if(source==='execa')return names.length===0||names.length===1&&['execa','execaSync','execaCommand','execaCommandSync','execaNode','$'].includes(names[0]);
9
+ if(source==='cross-spawn')return names.length===0||names.length===1&&['spawn','sync'].includes(names[0]);
10
+ if(source==='shelljs')return names.length===1&&names[0]==='exec';
11
+ return source==='@mytools/fst-platform'&&names.length===1&&names[0]==='runProcess';
12
+ }
13
+
14
+ // 仅诊断能追溯到已知模块的调用,不以模块导入本身判定违规,也不执行源码。
15
+ export function inspectProcessUsage(ast,{bindings,isRequire,walk},warning,location){
16
+ const imported=new Map(),variables=new Map(),mutated=new Set();
17
+ const rootName=node=>node?.type==='Identifier'?node.name:node?.type==='MemberExpression'?rootName(node.object):undefined;
18
+ walk(ast,node=>{
19
+ if(node.type==='ImportDeclaration'&&sources.has(node.source.value))for(const spec of node.specifiers){
20
+ imported.set(spec.local.name,{source:node.source.value,members:spec.type==='ImportNamespaceSpecifier'?[]:[spec.type==='ImportDefaultSpecifier'?'default':spec.imported.name??spec.imported.value]});
21
+ }
22
+ if(node.type==='VariableDeclarator'){
23
+ if(node.id.type==='Identifier')variables.set(node.id.name,{value:node.init,members:[]});
24
+ else if(node.id.type==='ObjectPattern')for(const spec of node.id.properties){
25
+ if(spec.type==='Property'&&spec.value.type==='Identifier'&&(!spec.computed||typeof spec.key.value==='string'))variables.set(spec.value.name,{value:node.init,members:[spec.computed?spec.key.value:spec.key.name??spec.key.value]});
26
+ }
27
+ }
28
+ if(['AssignmentExpression','UpdateExpression','UnaryExpression'].includes(node.type)){
29
+ const target=node.left??node.argument;
30
+ if(target?.type==='MemberExpression'&&(node.type!=='UnaryExpression'||node.operator==='delete'))mutated.add(rootName(target));
31
+ if(node.type==='UpdateExpression'&&target?.type==='Identifier')mutated.add(target.name);
32
+ }
33
+ });
34
+ function resolve(node,seen=new Set()){
35
+ if(!node)return;
36
+ if(node.type==='AwaitExpression'||node.type==='ChainExpression')return resolve(node.argument??node.expression,seen);
37
+ if(node.type==='Identifier'){
38
+ if(bindings.get(node.name)!==1||mutated.has(node.name)||seen.has(node.name))return;
39
+ if(imported.has(node.name))return imported.get(node.name);
40
+ const variable=variables.get(node.name);if(!variable)return;
41
+ const result=resolve(variable.value,new Set([...seen,node.name]));
42
+ return result&&{source:result.source,members:[...result.members,...variable.members]};
43
+ }
44
+ if(node.type==='MemberExpression'){
45
+ const name=property(node);if(typeof name!=='string')return;
46
+ const result=resolve(node.object,seen);return result&&{source:result.source,members:[...result.members,name]};
47
+ }
48
+ // Execa 的对象参数只绑定选项,返回的函数被调用时才启动程序。
49
+ if(node.type==='CallExpression'&&node.arguments[0]?.type==='ObjectExpression'){
50
+ const target=resolve(node.callee,seen);
51
+ if(target?.source==='execa'&&launches(target))return target;
52
+ }
53
+ const source=node.type==='ImportExpression'?node.source?.value:node.type==='CallExpression'&&isRequire(node)?node.arguments[0]?.value:undefined;
54
+ if(sources.has(source))return {source,members:[]};
55
+ }
56
+ walk(ast,node=>{
57
+ if(!['CallExpression','TaggedTemplateExpression'].includes(node.type))return;
58
+ const target=resolve(node.callee??node.tag);if(!target||!launches(target))return;
59
+ if(target.source==='execa'&&node.type==='CallExpression'&&node.arguments[0]?.type==='ObjectExpression')return;
60
+ warning('UNMANAGED_PROCESS',`直接调用 ${target.source}${target.members.length?'.'+target.members.join('.'):''} 可能绕过统一执行记录、取消与代理规则;执行外部程序请改用 ctx.runProcess(file, args, options)。此提示不代表该调用已执行`,`${location}:${node.loc.start.line}:${node.loc.start.column+1}`);
61
+ });
62
+ }
@@ -12,6 +12,7 @@
12
12
  | 导入路径 | 实现位置 |
13
13
  | --- | --- |
14
14
  | `@mytools/fst-platform` | `./src/process.js` |
15
+ | `@mytools/fst-platform/managed-process` | `./src/managed-process.js` |
15
16
  | `@mytools/fst-platform/environments` | `./src/environments.js` |
16
17
  | `@mytools/fst-platform/bundle` | `./src/bundle.js` |
17
18
  | `@mytools/fst-platform/json` | `./src/json.js` |
@@ -37,6 +38,10 @@ node --test tests/modules.test.js tests/process.test.js tests/environments.test.
37
38
 
38
39
  ## 子进程环境
39
40
 
41
+ `createProcessRunner({execute=runProcess,signal,log,audience})` 是内核调用方共用的受管包装,返回签名兼容 `runProcess(file,args,options)` 的函数。每次调用独立生成 `processId`,先写指令日志再执行自定义 onStart,先写输出日志再执行自定义 onOutput;合并调用级与上下文取消信号,不改变底层并发。插件经宿主 `ctx.runProcess` 使用,不能直接导入本入口代替上下文。权限检查由 plugin 负责,本包装不授予权限、不进行命令黑名单拦截。
42
+
43
+ 受管调用可指定 `logPolicy:false` 隐藏自动记录的参数与脚本,或 `{redactArgs:[零起算下标],omitArgs:true,omitScript:true}` 按需选择;实际参数和调用方 onStart 保留原值。有参数被隐藏时脚本也隐藏。无效策略执行前拒绝。该策略不自动识别密钥、不处理插件主动输出及已有日志;平台自动生成的进程启动失败信息也不再拼接 Execa 的完整命令行。
44
+
40
45
  `/shell` 的 `runShell(script,{shell='auto',runProcess,temporaryDir,...processOptions})` 使用唯一临时脚本文件,避免长脚本超过系统命令行长度上限,运行结束后删除本次文件。默认 Windows PowerShell / Linux sh,可选 powershell、pwsh、sh、bash;PowerShell 使用 UTF-8 BOM,其他 Shell 使用无 BOM UTF-8。插件传入 `ctx.runProcess` 与 `temporaryDir:ctx.outputDir`,保留当前代理、取消与日志规则,并兼容只读包目录。`shellInvocation` 保留构造简短命令参数的兼容接口。
41
46
 
42
47
  Windows 的 `shell:'auto'` 对单行 `ipconfig` / `ipconfig.exe` 及其字面量参数(如 `ipconfig /all`)直接执行系统目录中的 `ipconfig.exe`,读取当前系统 OEM 代码页后统一解码结果和实时输出。自动适配限于 TextDecoder 支持的 OEM 866、874、932、936、949、950、65001;中文 Windows 的 OEM 936 使用 GBK。代码页探测失败或代码页不受支持(如 437、850)时保留原 Shell 路径。变量、管道、多行、重定向等脚本继续按原 PowerShell 语义执行;复杂脚本中的旧编码与 UTF-8 混合输出没有自动转码。`onStart` 的 `script` 字段保留用户原始指令,直接执行的简单命令同时记录实际程序路径和参数。
@@ -6,6 +6,7 @@
6
6
  "main": "src/process.js",
7
7
  "exports": {
8
8
  ".": "./src/process.js",
9
+ "./managed-process": "./src/managed-process.js",
9
10
  "./shell": "./src/shell.js",
10
11
  "./environments": "./src/environments.js",
11
12
  "./bundle": "./src/bundle.js",
@@ -0,0 +1,36 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {runProcess} from './process.js';
3
+ import {FlowError} from '@mytools/sdk';
4
+
5
+ function invocationFilter(policy,args){
6
+ if(policy===undefined)return value=>value;
7
+ const invalid=()=>{throw new FlowError('logPolicy 必须为 false 或含 redactArgs、omitArgs、omitScript 的有效对象;参数下标从 0 开始且不能越界',{code:'VALIDATION'});};
8
+ if(policy!==false&&(!policy||typeof policy!=='object'||Array.isArray(policy)))invalid();
9
+ if(policy!==false){
10
+ if(Object.keys(policy).some(key=>!['redactArgs','omitArgs','omitScript'].includes(key)))invalid();
11
+ for(const key of ['omitArgs','omitScript'])if(policy[key]!==undefined&&typeof policy[key]!=='boolean')invalid();
12
+ if(policy.redactArgs!==undefined&&(!Array.isArray(policy.redactArgs)||policy.redactArgs.some(index=>!Number.isSafeInteger(index)||index<0||index>=(args?.length??0))))invalid();
13
+ }
14
+ const omitted=policy===false||policy.omitArgs===true,indices=new Set(policy.redactArgs??[]);
15
+ const omitScript=omitted||indices.size>0||policy.omitScript===true;
16
+ return value=>({...value,
17
+ ...(Array.isArray(value.args)?{args:omitted?(value.args.length?['[参数已隐藏]']:[]):value.args.map((arg,index)=>indices.has(index)?'[已隐藏]':arg)}:{}),
18
+ ...(value.script!==undefined&&omitScript?{script:'[原始脚本已隐藏]'}:{}),
19
+ });
20
+ }
21
+
22
+ // 每次调用独立绑定日志和取消信号;共享入口不串行化进程,也不授予额外权限。
23
+ export function createProcessRunner({execute=runProcess,signal,log,audience}={}){
24
+ return (file,args,options={})=>{
25
+ const {logPolicy,...executionOptions}=options,displayInvocation=invocationFilter(logPolicy,args);
26
+ const processId=randomUUID();
27
+ const combined=signal&&options.signal?AbortSignal.any([signal,options.signal]):signal??options.signal;
28
+ return execute(file,args,{...executionOptions,signal:combined,onStart:async invocation=>{
29
+ await log?.('执行程序',displayInvocation(invocation),{kind:'process',processId});
30
+ await options.onStart?.(invocation);
31
+ },onOutput:async({stream,message})=>{
32
+ await log?.(message,undefined,{stream,processId,...(audience?{audience}:{})});
33
+ await options.onOutput?.({stream,message});
34
+ }});
35
+ };
36
+ }
@@ -5,6 +5,8 @@ import { FlowError } from '@mytools/sdk';
5
5
  import path from 'node:path';
6
6
 
7
7
  const stripFinalNewline=text=>text.endsWith('\r\n')?text.slice(0,-2):text.endsWith('\n')?text.slice(0,-1):text;
8
+ // 执行库的校验错误和启动错误都可能包含完整 argv,仅保留安全的错误代码。
9
+ const spawnFailure=(file,code='SPAWN_FAILED')=>new FlowError(`无法启动程序 ${file}:${code};请检查参数格式、执行目录、文件权限及程序依赖`,{code});
8
10
 
9
11
  export async function runProcess(file,args=[],{cwd,env,extendEnv=true,signal,timeout=30000,maxBuffer=4*1024*1024,encoding='utf8',onOutput,onStart,commandText,outputMode='buffer',summaryBytes=65536}={}) {
10
12
  if(!['buffer','stream'].includes(outputMode))throw new TypeError('outputMode 必须为 buffer 或 stream');
@@ -38,7 +40,9 @@ export async function runProcess(file,args=[],{cwd,env,extendEnv=true,signal,tim
38
40
  if(signal?.aborted)throw new FlowError('操作已取消',{code:'CANCELLED'});
39
41
  // 平台统一收集原始字节和执行上限;Execa 的超限收集器会提前关闭管道,
40
42
  // 导致持续输出的父进程因 EPIPE 退出,taskkill 尚未遍历就失去父 PID。
41
- const child=execa(executable,args,{cwd,env,extendEnv,reject:false,windowsHide:true,maxBuffer,buffer:false,timeout:0,shell:false,encoding:'buffer',stripFinalNewline:false});
43
+ let child;
44
+ try{child=execa(executable,args,{cwd,env,extendEnv,reject:false,windowsHide:true,maxBuffer,buffer:false,timeout:0,shell:false,encoding:'buffer',stripFinalNewline:false});}
45
+ catch(error){throw spawnFailure(file,error.code);}
42
46
  let reason,killing,outputError,outputFailed=false,outputWrites=Promise.resolve();
43
47
  let markStopped;const stopped=new Promise(resolve=>{markStopped=resolve;});
44
48
  const outputBytes={stdout:0,stderr:0},tails={stdout:'',stderr:''},buffers={stdout:[],stderr:[]};let outputTruncated=false;
@@ -110,7 +114,8 @@ export async function runProcess(file,args=[],{cwd,env,extendEnv=true,signal,tim
110
114
  if(reason==='output-limit')throw new FlowError('程序输出超过上限',{code:'OUTPUT_LIMIT'});
111
115
  if(reason)throw new FlowError(reason==='cancelled'?'操作已取消':'程序运行超时',{code:reason==='cancelled'?'CANCELLED':'TIMEOUT'});
112
116
  if(result.isMaxBuffer)throw new FlowError('程序输出超过上限',{code:'OUTPUT_LIMIT'});
113
- if(result.exitCode===undefined&&result.failed)throw new FlowError(`无法启动程序 ${file}:${result.shortMessage??result.message}`,{code:result.code??'SPAWN_FAILED'});
117
+ // Execa 的短错误包含完整 argv,不能重新写回已由受管入口隐藏的敏感参数。
118
+ if(result.exitCode===undefined&&result.failed)throw spawnFailure(file,result.code);
114
119
  const decode=chunks=>stripFinalNewline(new TextDecoder(decoderEncoding).decode(Buffer.concat(chunks)));
115
120
  if(streaming)return {exitCode:result.exitCode,stdout:stripFinalNewline(tails.stdout),stderr:stripFinalNewline(tails.stderr),outputMode,outputTruncated,outputBytes};
116
121
  return {exitCode:result.exitCode,stdout:decode(buffers.stdout),stderr:decode(buffers.stderr)};