@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.
- package/README.md +3 -3
- package/manual/README.md +3 -3
- package/manual/docs/modules.md +2 -0
- package/manual/docs/plans/2026-10-13-effective-inputs-and-tables.md +39 -0
- package/manual/docs/plans/2026-10-13-plugin-defaults.md +45 -0
- package/manual/docs/plans/2026-10-14-issue7-recovery.md +33 -0
- package/manual/docs/plans/2026-10-14-issue8-10-workflows.md +26 -0
- package/manual/docs/plans/2026-10-14-issue9-completion.md +47 -0
- package/manual/docs/plans/2026-10-14-managed-execution.md +58 -0
- package/manual/docs/plans/2026-10-14-plugin-discovery-history.md +41 -0
- package/manual/docs/plans/2026-10-14-structured-reports.md +77 -0
- package/manual/docs/plans/2026-10-14-unreleased-review.md +47 -0
- package/manual/docs/plugin-development.md +161 -8
- package/manual/docs/releases.md +25 -6
- package/manual/docs/superpowers/plans/2026-10-13-tui-readable-sessions.md +2 -2
- package/manual/docs/superpowers/reviews/2026-10-13-tui-sessions-review.md +2 -0
- package/manual/docs/superpowers/reviews/2026-10-14-0.6.0-release-review.md +19 -0
- package/manual/docs/verification.md +17 -0
- package/manual/packages/core/README.md +6 -0
- package/manual/packages/core/src/application.js +15 -9
- package/manual/packages/core/src/plugin-catalog.js +20 -2
- package/manual/packages/core/src/plugin-loading.js +24 -0
- package/manual/packages/core/src/version.js +1 -1
- package/manual/packages/fst-cli/README.md +5 -1
- package/manual/packages/fst-cli/src/terminal/command-menu.js +3 -2
- package/manual/packages/fst-cli/src/terminal/file-autocomplete.js +106 -0
- package/manual/packages/fst-cli/src/terminal/log-viewer.js +4 -3
- package/manual/packages/fst-cli/src/terminal/navigation.js +23 -1
- package/manual/packages/fst-cli/src/terminal/presentation.js +2 -1
- package/manual/packages/fst-cli/src/terminal/report-presentation.js +38 -0
- package/manual/packages/fst-cli/src/terminal/result-table.js +66 -0
- package/manual/packages/fst-cli/src/terminal/run-feed.js +42 -17
- package/manual/packages/fst-cli/src/terminal/run-presentation.js +51 -17
- package/manual/packages/fst-cli/src/terminal/run-viewer.js +15 -12
- package/manual/packages/fst-cli/src/terminal/schema.js +114 -4
- package/manual/packages/fst-cli/src/terminal/session-history.js +2 -2
- package/manual/packages/fst-cli/src/terminal/session.js +40 -15
- package/manual/packages/fst-cli/src/terminal/view.js +80 -68
- package/manual/packages/fst-cli/src/terminal/workflow-menu.js +3 -1
- package/manual/packages/fst-devtools/README.md +2 -0
- package/manual/packages/fst-devtools/src/plugin-quality.js +4 -1
- package/manual/packages/fst-devtools/src/plugin-tools.js +1 -0
- package/manual/packages/fst-devtools/src/process-usage.js +62 -0
- package/manual/packages/fst-platform/README.md +5 -0
- package/manual/packages/fst-platform/package.json +1 -0
- package/manual/packages/fst-platform/src/managed-process.js +36 -0
- package/manual/packages/fst-platform/src/process.js +7 -2
- package/manual/packages/fst-plugin/README.md +4 -0
- package/manual/packages/fst-plugin/src/contracts.js +3 -0
- package/manual/packages/fst-plugin/src/host.js +38 -23
- package/manual/packages/fst-plugin/src/runtime.js +14 -8
- package/manual/packages/fst-workflow/src/engine.js +1 -1
- package/manual/packages/fst-workspace/README.md +4 -0
- package/manual/packages/fst-workspace/src/plugin-registry.js +1 -1
- package/manual/packages/fst-workspace/src/plugins.js +5 -3
- package/manual/packages/fst-workspace/src/workflows.js +27 -9
- package/manual/packages/sdk/README.md +10 -0
- package/manual/packages/sdk/inputs.js +53 -0
- package/manual/packages/sdk/package.json +2 -0
- package/manual/packages/sdk/reports.js +53 -0
- package/node_modules/@mytools/core/README.md +6 -0
- package/node_modules/@mytools/core/src/application.js +15 -9
- package/node_modules/@mytools/core/src/plugin-catalog.js +20 -2
- package/node_modules/@mytools/core/src/plugin-loading.js +24 -0
- package/node_modules/@mytools/core/src/version.js +1 -1
- package/node_modules/@mytools/fst-cli/README.md +5 -1
- package/node_modules/@mytools/fst-cli/src/terminal/command-menu.js +3 -2
- package/node_modules/@mytools/fst-cli/src/terminal/file-autocomplete.js +106 -0
- package/node_modules/@mytools/fst-cli/src/terminal/log-viewer.js +4 -3
- package/node_modules/@mytools/fst-cli/src/terminal/navigation.js +23 -1
- package/node_modules/@mytools/fst-cli/src/terminal/presentation.js +2 -1
- package/node_modules/@mytools/fst-cli/src/terminal/report-presentation.js +38 -0
- package/node_modules/@mytools/fst-cli/src/terminal/result-table.js +66 -0
- package/node_modules/@mytools/fst-cli/src/terminal/run-feed.js +42 -17
- package/node_modules/@mytools/fst-cli/src/terminal/run-presentation.js +51 -17
- package/node_modules/@mytools/fst-cli/src/terminal/run-viewer.js +15 -12
- package/node_modules/@mytools/fst-cli/src/terminal/schema.js +114 -4
- package/node_modules/@mytools/fst-cli/src/terminal/session-history.js +2 -2
- package/node_modules/@mytools/fst-cli/src/terminal/session.js +40 -15
- package/node_modules/@mytools/fst-cli/src/terminal/view.js +80 -68
- package/node_modules/@mytools/fst-cli/src/terminal/workflow-menu.js +3 -1
- package/node_modules/@mytools/fst-devtools/README.md +2 -0
- package/node_modules/@mytools/fst-devtools/src/plugin-quality.js +4 -1
- package/node_modules/@mytools/fst-devtools/src/plugin-tools.js +1 -0
- package/node_modules/@mytools/fst-devtools/src/process-usage.js +62 -0
- package/node_modules/@mytools/fst-platform/README.md +5 -0
- package/node_modules/@mytools/fst-platform/package.json +1 -0
- package/node_modules/@mytools/fst-platform/src/managed-process.js +36 -0
- package/node_modules/@mytools/fst-platform/src/process.js +7 -2
- package/node_modules/@mytools/fst-plugin/README.md +4 -0
- package/node_modules/@mytools/fst-plugin/src/contracts.js +3 -0
- package/node_modules/@mytools/fst-plugin/src/host.js +38 -23
- package/node_modules/@mytools/fst-plugin/src/runtime.js +14 -8
- package/node_modules/@mytools/fst-workflow/src/engine.js +1 -1
- package/node_modules/@mytools/fst-workspace/README.md +4 -0
- package/node_modules/@mytools/fst-workspace/src/plugin-registry.js +1 -1
- package/node_modules/@mytools/fst-workspace/src/plugins.js +5 -3
- package/node_modules/@mytools/fst-workspace/src/workflows.js +27 -9
- package/node_modules/@mytools/sdk/README.md +10 -0
- package/node_modules/@mytools/sdk/inputs.js +53 -0
- package/node_modules/@mytools/sdk/package.json +2 -0
- package/node_modules/@mytools/sdk/reports.js +53 -0
- package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +1032 -1032
- 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
|
-
|
|
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.
|
|
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.
|
|
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{
|
|
178
|
-
|
|
179
|
-
|
|
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&¤t.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{
|
|
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 {
|
|
2
|
-
|
|
3
|
-
import {
|
|
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?'收起':'展开'}
|
|
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
|
|
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.
|
|
155
|
-
this.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
268
|
-
|
|
269
|
-
title.render=width=>
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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` 字段保留用户原始指令,直接执行的简单命令同时记录实际程序路径和参数。
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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)};
|