@yanglaofish/fishtools 0.5.1 → 0.5.2
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 +4 -2
- package/manual/README.md +4 -2
- package/manual/docs/deployment.md +2 -2
- package/manual/docs/modules.md +2 -0
- package/manual/docs/plugin-development.md +3 -1
- package/manual/docs/releases.md +32 -6
- package/manual/docs/superpowers/plans/2026-10-13-command-shortcuts.md +107 -0
- package/manual/docs/superpowers/reviews/2026-10-13-0.5.0-followup-review.md +4 -1
- package/manual/docs/superpowers/reviews/2026-10-13-command-shortcuts-review.md +63 -0
- package/manual/docs/superpowers/specs/2026-10-12-workflow-variation-design.md +1 -1
- package/manual/docs/superpowers/specs/2026-10-13-command-shortcuts-design.md +56 -0
- package/manual/docs/superpowers/specs/2026-10-13-issue-4-workflow-menu.md +28 -0
- package/manual/docs/terminal-commands.md +64 -0
- package/manual/docs/verification.md +26 -1
- package/manual/packages/core/README.md +3 -1
- package/manual/packages/core/package.json +1 -0
- package/manual/packages/core/src/application.js +114 -17
- package/manual/packages/core/src/export-snapshot.js +20 -1
- package/manual/packages/core/src/version.js +1 -1
- package/manual/packages/fst-cli/README.md +2 -0
- package/manual/packages/fst-cli/src/terminal/command-menu.js +186 -0
- package/manual/packages/fst-cli/src/terminal/navigation.js +4 -3
- package/manual/packages/fst-cli/src/terminal/session.js +35 -4
- package/manual/packages/fst-cli/src/terminal/view.js +43 -8
- package/manual/packages/fst-cli/src/terminal/workflow-menu.js +1 -6
- package/manual/packages/fst-devtools/src/documentation.js +2 -1
- package/manual/packages/fst-platform/README.md +11 -0
- package/manual/packages/fst-platform/package.json +1 -0
- package/manual/packages/fst-platform/src/process.js +11 -7
- package/manual/packages/fst-platform/src/shell.js +58 -0
- package/manual/packages/fst-plugin/src/contracts.js +6 -2
- package/manual/packages/fst-workspace/README.md +3 -0
- package/manual/packages/fst-workspace/package.json +1 -0
- package/manual/packages/fst-workspace/src/shortcuts.js +119 -0
- package/manual/packages/pack/index.js +1 -1
- package/manual/plugins/commands/LICENSE +21 -0
- package/manual/plugins/commands/README.md +41 -0
- package/manual/plugins/commands/index.js +94 -0
- package/manual/plugins/commands/package.json +17 -0
- package/node_modules/@mytools/commands/LICENSE +21 -0
- package/node_modules/@mytools/commands/README.md +41 -0
- package/node_modules/@mytools/commands/index.js +94 -0
- package/node_modules/@mytools/commands/package.json +17 -0
- package/node_modules/@mytools/core/README.md +3 -1
- package/node_modules/@mytools/core/package.json +1 -0
- package/node_modules/@mytools/core/src/application.js +114 -17
- package/node_modules/@mytools/core/src/export-snapshot.js +20 -1
- package/node_modules/@mytools/core/src/version.js +1 -1
- package/node_modules/@mytools/fst-cli/README.md +2 -0
- package/node_modules/@mytools/fst-cli/src/terminal/command-menu.js +186 -0
- package/node_modules/@mytools/fst-cli/src/terminal/navigation.js +4 -3
- package/node_modules/@mytools/fst-cli/src/terminal/session.js +35 -4
- package/node_modules/@mytools/fst-cli/src/terminal/view.js +43 -8
- package/node_modules/@mytools/fst-cli/src/terminal/workflow-menu.js +1 -6
- package/node_modules/@mytools/fst-devtools/src/documentation.js +2 -1
- package/node_modules/@mytools/fst-platform/README.md +11 -0
- package/node_modules/@mytools/fst-platform/package.json +1 -0
- package/node_modules/@mytools/fst-platform/src/process.js +11 -7
- package/node_modules/@mytools/fst-platform/src/shell.js +58 -0
- package/node_modules/@mytools/fst-plugin/src/contracts.js +6 -2
- package/node_modules/@mytools/fst-workspace/README.md +3 -0
- package/node_modules/@mytools/fst-workspace/package.json +1 -0
- package/node_modules/@mytools/fst-workspace/src/shortcuts.js +119 -0
- package/node_modules/@mytools/pack/index.js +1 -1
- package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +1447 -1423
- package/package.json +3 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import {isCommandName} from '@mytools/fst-plugin/contracts';
|
|
3
|
+
import {editValue} from './schema.js';
|
|
4
|
+
|
|
5
|
+
export async function runShortcut(session,entry,args=[]){
|
|
6
|
+
if(!entry.available)throw new Error(`快捷名称 /${entry.name} 已失效:${entry.reason??'目标不存在'}`);
|
|
7
|
+
const {app}=session,target=entry.target;
|
|
8
|
+
if(target.command){
|
|
9
|
+
const command=app.listCommands().find(command=>command.name===target.command&&command.plugin===target.plugin);
|
|
10
|
+
if(!command)throw new Error(`快捷名称 /${entry.name} 的目标命令不存在`);
|
|
11
|
+
return session.pluginCommand({...command,name:entry.name,targetName:command.name,shortcutInput:entry.input??{}},args);
|
|
12
|
+
}
|
|
13
|
+
const action=app.catalog().find(action=>action.id===target.action);
|
|
14
|
+
if(!action)throw new Error(`快捷名称 /${entry.name} 的目标动作不存在`);
|
|
15
|
+
const plugin=action.plugin??action.id.slice(0,action.id.lastIndexOf(':'));
|
|
16
|
+
return session.pluginCommand({...action,name:entry.name,plugin,action:action.id.slice(plugin.length+1),actionId:action.id,shortcutInput:entry.input??{}},args);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function parameters(session,value={}){
|
|
20
|
+
const result=structuredClone(value),{ui}=session;
|
|
21
|
+
for(;;){
|
|
22
|
+
const choice=await ui.select('命令参数(脚本通过 FST_INPUT_参数名 环境变量读取)',[
|
|
23
|
+
...Object.entries(result).map(([name,field])=>({value:`field:${name}`,label:field.title??name,description:`${name} · ${field.type}`})),
|
|
24
|
+
{value:'new',label:'添加参数'},{value:'back',label:'完成参数设置'},
|
|
25
|
+
]);
|
|
26
|
+
if(choice==='back')return result;
|
|
27
|
+
const existing=choice.startsWith('field:')?choice.slice(6):undefined;
|
|
28
|
+
if(existing){
|
|
29
|
+
const operation=await ui.select(`参数 ${existing}`,[{value:'edit',label:'编辑'},{value:'remove',label:'删除'},{value:'back',label:'返回'}]);
|
|
30
|
+
if(operation==='back')continue;
|
|
31
|
+
if(operation==='remove'){delete result[existing];continue;}
|
|
32
|
+
}
|
|
33
|
+
const name=existing??(await ui.input('参数名(英文字母、数字、下划线,不能以数字开头)',{files:false})).trim();
|
|
34
|
+
if(!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)||['__proto__','constructor','prototype'].includes(name)){ui.show('参数名无效,请重新填写。');continue;}
|
|
35
|
+
if(!existing&&Object.hasOwn(result,name)){ui.show('参数名已经存在。');continue;}
|
|
36
|
+
const title=await ui.input('参数显示名称',{value:result[name]?.title??name,files:false});
|
|
37
|
+
const type=await ui.select('参数类型',[{value:'string',label:'文本'},{value:'number',label:'数字'},{value:'boolean',label:'是 / 否'}]);
|
|
38
|
+
const field={title,type,required:await ui.confirm('这个参数必须填写吗?')};
|
|
39
|
+
if(await ui.confirm('设置默认值吗?'))field.default=await editValue(ui,{type},result[name]?.default,{baseDir:session.baseDir,required:true,label:'默认值'});
|
|
40
|
+
result[name]=field;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function editPreset(session,id,current){
|
|
45
|
+
const {ui,app}=session,preset=structuredClone(current??{shell:'auto'});
|
|
46
|
+
preset.title=(await ui.input('常用命令名称',{value:preset.title??'',files:false})).trim();
|
|
47
|
+
preset.script=await ui.input('命令文本 · Shift+Enter 换行,Enter 提交(Alt+Enter 备用)',{value:preset.script??'',files:false,multiline:true});
|
|
48
|
+
const action=`@mytools/commands:${id}`;
|
|
49
|
+
const oldName=Object.keys(app.project.shortcuts??{}).find(name=>app.project.shortcuts[name].action===action);
|
|
50
|
+
let shortcut=oldName??(isCommandName(preset.title)?preset.title:'');
|
|
51
|
+
for(;;){
|
|
52
|
+
const choice=await ui.select('常用命令设置',[
|
|
53
|
+
{value:'save',label:'保存',description:shortcut?`快捷执行 /${shortcut}`:'只注册工作流动作'},
|
|
54
|
+
{value:'title',label:'修改名称'},{value:'script',label:'编辑多行命令'},
|
|
55
|
+
{value:'shortcut',label:'快捷名称',description:shortcut||'未设置'},
|
|
56
|
+
{value:'shell',label:'命令解释器',description:preset.shell??'auto'},
|
|
57
|
+
{value:'timeout',label:'超时时间',description:preset.timeout===0?'不限时':`${(preset.timeout??30000)/1000} 秒`},
|
|
58
|
+
{value:'parameters',label:'参数',description:`${Object.keys(preset.parameters??{}).length} 项,可不设置`},
|
|
59
|
+
{value:'root',label:'Linux root 权限要求',description:preset.requireRoot?'需要 root':'按当前用户权限执行'},
|
|
60
|
+
{value:'back',label:'取消,返回'},
|
|
61
|
+
]);
|
|
62
|
+
if(choice==='back')return;
|
|
63
|
+
if(choice==='title')preset.title=(await ui.input('常用命令名称',{value:preset.title,files:false})).trim();
|
|
64
|
+
else if(choice==='script')preset.script=await ui.input('命令文本 · Shift+Enter 换行,Enter 提交(Alt+Enter 备用)',{value:preset.script,files:false,multiline:true});
|
|
65
|
+
else if(choice==='shortcut')shortcut=(await ui.input('快捷名称(支持中文,不含空格或 /;留空不设置)',{value:shortcut,files:false})).trim();
|
|
66
|
+
else if(choice==='shell')preset.shell=await ui.select('命令解释器',[
|
|
67
|
+
{value:'auto',label:'按系统选择(Windows PowerShell / Linux sh)'},
|
|
68
|
+
{value:'powershell',label:'Windows PowerShell'},{value:'pwsh',label:'PowerShell 7(pwsh)'},
|
|
69
|
+
{value:'sh',label:'sh'},{value:'bash',label:'bash'},
|
|
70
|
+
]);
|
|
71
|
+
else if(choice==='timeout'){
|
|
72
|
+
const seconds=Number(await ui.input('超时时间(秒,0 表示不限时)',{value:String((preset.timeout??30000)/1000),files:false}));
|
|
73
|
+
if(!Number.isFinite(seconds)||!Number.isSafeInteger(seconds*1000)||seconds<0||seconds*1000>2147483647){ui.show('超时时间无效。');continue;}
|
|
74
|
+
preset.timeout=seconds*1000;
|
|
75
|
+
}else if(choice==='parameters')preset.parameters=await parameters(session,preset.parameters);
|
|
76
|
+
else if(choice==='root')preset.requireRoot=await ui.confirm('这个命令在 Linux 上必须使用 root 吗?执行前会检查,不会自动提权。');
|
|
77
|
+
else if(choice==='save'){
|
|
78
|
+
const next={...app.project.commandPresets,[id]:preset},shortcuts=structuredClone(app.project.shortcuts??{});
|
|
79
|
+
if(oldName)delete shortcuts[oldName];
|
|
80
|
+
if(shortcut){
|
|
81
|
+
if(Object.hasOwn(shortcuts,shortcut)){ui.show(`快捷名称 /${shortcut} 已存在,请选择其他名称。`);continue;}
|
|
82
|
+
shortcuts[shortcut]={...(oldName?app.project.shortcuts[oldName]:{}),action};
|
|
83
|
+
}
|
|
84
|
+
try{
|
|
85
|
+
const result=await session.withMaintenance(options=>app.configureCommandPresets(next,{...options,shortcuts}));
|
|
86
|
+
if(result===null){ui.show('已取消保存。');continue;}
|
|
87
|
+
ui.show(`常用命令已保存,修改前配置已备份。${shortcut?`输入 /${shortcut} 可直接运行。`:''}也可在工作流中添加此动作。`);
|
|
88
|
+
return;
|
|
89
|
+
}catch(error){ui.show(`保存失败:${error.message}`);}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function commandPresetsMenu(session){
|
|
95
|
+
const {app,ui}=session;
|
|
96
|
+
for(;;){
|
|
97
|
+
const choice=await ui.select('常用命令',[
|
|
98
|
+
...(!app.project.readOnly?[{value:'new',label:'注册常用命令'}]:[]),
|
|
99
|
+
...Object.entries(app.project.commandPresets??{}).map(([id,preset])=>({value:`preset:${id}`,label:preset.title,description:id})),
|
|
100
|
+
{value:'back',label:'返回主面板'},
|
|
101
|
+
]);
|
|
102
|
+
if(choice==='back')return;
|
|
103
|
+
if(choice==='new'){session.writable();await editPreset(session,`command-${randomUUID().slice(0,8)}`);continue;}
|
|
104
|
+
const id=choice.slice(7),preset=app.project.commandPresets?.[id];if(!preset)continue;
|
|
105
|
+
const operation=await ui.select(preset.title,[{value:'run',label:'运行'},
|
|
106
|
+
...(!app.project.readOnly?[{value:'edit',label:'编辑'},{value:'remove',label:'删除注册项'}]:[]),{value:'back',label:'返回'}]);
|
|
107
|
+
if(operation==='run')await runShortcut(session,{name:preset.title,target:{action:`@mytools/commands:${id}`},input:{},available:true});
|
|
108
|
+
else if(operation==='edit'){session.writable();await editPreset(session,id,preset);}
|
|
109
|
+
else if(operation==='remove'){
|
|
110
|
+
session.writable();if(!await ui.confirm(`删除 ${preset.title}?指向它的快捷名称会显示失效;引用中的工作流需先调整。`))continue;
|
|
111
|
+
const next=structuredClone(app.project.commandPresets);delete next[id];
|
|
112
|
+
try{const result=await session.withMaintenance(options=>app.configureCommandPresets(next,options));if(result!==null)ui.show('注册项已删除,修改前配置已备份。');}
|
|
113
|
+
catch(error){ui.show(`删除失败:${error.message}`);}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function targets(app){
|
|
119
|
+
return [...app.catalog().map(action=>({value:`action:${action.id}`,label:`动作:${action.title??action.id}`,description:action.id})),
|
|
120
|
+
...app.listCommands().map(command=>({value:`command:${command.name}`,label:`命令:/${command.name}`,description:command.description}))];
|
|
121
|
+
}
|
|
122
|
+
function shortcutTarget(app,selected){
|
|
123
|
+
if(selected.startsWith('action:'))return {action:selected.slice(7)};
|
|
124
|
+
const name=selected.slice(8),command=app.listCommands().find(command=>command.name===name);
|
|
125
|
+
if(!command)throw new Error('目标命令已失效,请重新选择');
|
|
126
|
+
return {command:name,plugin:command.plugin};
|
|
127
|
+
}
|
|
128
|
+
async function editShortcut(session,oldName){
|
|
129
|
+
const {app,ui}=session,old=app.project.shortcuts?.[oldName];
|
|
130
|
+
let name=(await ui.input('快捷名称(支持中文,不含空格或 /)',{value:oldName??'',files:false})).trim();
|
|
131
|
+
let target=old?structuredClone(old):{};
|
|
132
|
+
if(!old){
|
|
133
|
+
const choices=targets(app);if(!choices.length){ui.show('当前没有可选择的插件动作或命令。');return;}
|
|
134
|
+
const selected=await ui.select('选择快捷名称指向的能力',choices);
|
|
135
|
+
target=shortcutTarget(app,selected);
|
|
136
|
+
}
|
|
137
|
+
for(;;){
|
|
138
|
+
const choice=await ui.select(`快捷名称 /${name}`,[
|
|
139
|
+
{value:'save',label:'保存'},{value:'name',label:'修改快捷名称'},
|
|
140
|
+
{value:'target',label:'选择目标',description:target.action??target.command},
|
|
141
|
+
{value:'input',label:'设置默认参数',description:'可留空,运行时按需要填写'},
|
|
142
|
+
{value:'back',label:'取消,返回'},
|
|
143
|
+
]);
|
|
144
|
+
if(choice==='back')return;
|
|
145
|
+
if(choice==='name')name=(await ui.input('快捷名称',{value:name,files:false})).trim();
|
|
146
|
+
else if(choice==='target'){
|
|
147
|
+
const selected=await ui.select('选择目标',targets(app));
|
|
148
|
+
target=shortcutTarget(app,selected);
|
|
149
|
+
}else if(choice==='input'){
|
|
150
|
+
const metadata=target.action?app.catalog().find(action=>action.id===target.action):app.listCommands().find(command=>command.name===target.command&&command.plugin===target.plugin);
|
|
151
|
+
if(!metadata){ui.show('目标已失效,请先重新选择目标。');continue;}
|
|
152
|
+
target.input=await editValue(ui,metadata.input??{type:'object',properties:{},additionalProperties:false},target.input??{},{baseDir:session.baseDir,required:true,label:'快捷名称默认参数'});
|
|
153
|
+
}else if(choice==='save'){
|
|
154
|
+
const next=structuredClone(app.project.shortcuts??{});if(oldName)delete next[oldName];
|
|
155
|
+
if(Object.hasOwn(next,name)){ui.show('快捷名称已经存在,请选择其他名称。');continue;}
|
|
156
|
+
next[name]=target;
|
|
157
|
+
try{await app.configureShortcuts(next);ui.show('快捷名称已保存,修改前配置已备份。');return;}
|
|
158
|
+
catch(error){ui.show(`保存失败:${error.message}`);}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function shortcutsMenu(session){
|
|
164
|
+
const {app,ui}=session;
|
|
165
|
+
for(;;){
|
|
166
|
+
const entries=app.listShortcuts(),choice=await ui.select('自定义快捷名称',[
|
|
167
|
+
...(!app.project.readOnly?[{value:'new',label:'添加快捷名称'}]:[]),
|
|
168
|
+
...entries.map(entry=>({value:`shortcut:${entry.name}`,label:`/${entry.name}${entry.available?'':' [失效]'}`,description:entry.available?entry.description:entry.reason})),
|
|
169
|
+
{value:'back',label:'返回主面板'},
|
|
170
|
+
]);
|
|
171
|
+
if(choice==='back')return;
|
|
172
|
+
if(choice==='new'){session.writable();await editShortcut(session);continue;}
|
|
173
|
+
const name=choice.slice(9),entry=entries.find(entry=>entry.name===name);if(!entry)continue;
|
|
174
|
+
const operation=await ui.select(`/${name}`,[
|
|
175
|
+
...(entry.available?[{value:'run',label:'运行'}]:[]),
|
|
176
|
+
...(!app.project.readOnly?[{value:'edit',label:'编辑'},{value:'remove',label:'删除快捷名称'}]:[]),{value:'back',label:'返回'},
|
|
177
|
+
]);
|
|
178
|
+
if(operation==='run')await runShortcut(session,entry);
|
|
179
|
+
else if(operation==='edit'){session.writable();await editShortcut(session,name);}
|
|
180
|
+
else if(operation==='remove'){
|
|
181
|
+
session.writable();if(!await ui.confirm(`删除快捷名称 /${name}?`))continue;
|
|
182
|
+
const next=structuredClone(app.project.shortcuts??{});delete next[name];
|
|
183
|
+
await app.configureShortcuts(next);ui.show('快捷名称已删除,插件能力仍可使用。');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {builtinCommands} from '@mytools/fst-plugin/contracts';
|
|
2
2
|
|
|
3
|
-
const visible=['workflows','run','pack','logs','plugin','config','tools','help','exit'];
|
|
3
|
+
const visible=['workflows','run','pack','logs','plugin','commands','shortcuts','config','tools','help','exit'];
|
|
4
4
|
// 入口由插件 ID 推导;冲突时加前缀,不能覆盖内置或作者注册的命令。
|
|
5
|
-
export function commandMenu(plugins,commands){
|
|
5
|
+
export function commandMenu(plugins,commands,shortcuts=[]){
|
|
6
6
|
const entries=builtinCommands.filter(command=>visible.includes(command.name)).map(command=>({...command,description:`[内置] ${command.description}`}));
|
|
7
|
-
const used=new Set([...builtinCommands.map(c=>c.name),...commands.map(c=>c.name)]);
|
|
7
|
+
const used=new Set([...builtinCommands.map(c=>c.name),...commands.map(c=>c.name),...shortcuts.map(entry=>entry.name)]);
|
|
8
|
+
entries.push(...shortcuts.map(entry=>({name:entry.name,shortcut:true,description:`[快捷] ${entry.available?entry.description:`已失效:${entry.reason}`}`})));
|
|
8
9
|
const groups=plugins.filter(plugin=>commands.some(c=>c.plugin===plugin.id)).sort((a,b)=>a.id.localeCompare(b.id));
|
|
9
10
|
for(const plugin of groups){
|
|
10
11
|
const leaf=plugin.id.split('/').at(-1).replace(/[^\w.-]/g,'-');
|
|
@@ -9,6 +9,8 @@ import {gettingStarted,pluginStatus,pluginDetails} from './presentation.js';
|
|
|
9
9
|
import {readableValue,readableError} from './run-presentation.js';
|
|
10
10
|
import {commandMenu} from './navigation.js';
|
|
11
11
|
import {workflowMenu} from './workflow-menu.js';
|
|
12
|
+
import {commandPresetsMenu,shortcutsMenu,runShortcut} from './command-menu.js';
|
|
13
|
+
import {validate} from '@mytools/sdk/schema';
|
|
12
14
|
import {resolvePrivilegeRequirement,checkStaticPrivilege} from '@mytools/fst-plugin/privileges';
|
|
13
15
|
|
|
14
16
|
export class TerminalSession{
|
|
@@ -24,7 +26,7 @@ export class TerminalSession{
|
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
current(){if(!this.flow)throw new Error('先用 /workflows 打开或新建工作流');return this.flow;}
|
|
27
|
-
commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands());}
|
|
29
|
+
commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands(),this.app.listShortcuts?.()??[]);}
|
|
28
30
|
async discard(){return !this.dirty||await this.ui.confirm('当前修改未保存,是否放弃修改?');}
|
|
29
31
|
async mayExit(){
|
|
30
32
|
if(!await this.discard())return false;
|
|
@@ -41,7 +43,7 @@ export class TerminalSession{
|
|
|
41
43
|
while(!this.exiting){
|
|
42
44
|
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?'未保存':'已保存'}`:''}`);
|
|
43
45
|
let text;
|
|
44
|
-
try{text=await this.ui.input('fst >
|
|
46
|
+
try{text=await this.ui.input('fst > / 操作 · ! 系统命令 · Shift+Enter 换行 · Enter 提交',{commands:this.commands(),workflows:await this.app.listWorkflows(),multiline:true});}
|
|
45
47
|
catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.mayExit())break;}catch{}continue;}
|
|
46
48
|
if(!text.trim())continue;
|
|
47
49
|
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}`);}
|
|
@@ -57,19 +59,25 @@ export class TerminalSession{
|
|
|
57
59
|
finally{this.commandDepth--;if(outer&&!this.exiting)this.ui.endCommand?.();}
|
|
58
60
|
}
|
|
59
61
|
async executeCommand(text){
|
|
62
|
+
if(text.trimStart().startsWith('!'))return this.shellCommand(text.trimStart().slice(1));
|
|
60
63
|
const [command,...args]=parseCommand(text);
|
|
64
|
+
const shortcut=this.app.listShortcuts?.().find(entry=>entry.name===command);
|
|
65
|
+
if(shortcut)return runShortcut(this,shortcut,args);
|
|
61
66
|
const entry=this.commands().find(c=>c.name===command&&c.plugin);
|
|
62
67
|
if(entry){if(args.length)throw new Error(`/${command} 请从二级菜单选择能力`);return this.pluginSkills(entry.plugin);}
|
|
63
68
|
const pluginCommand=this.app.listCommands().find(c=>c.name===command);
|
|
64
69
|
if(pluginCommand)return this.pluginCommand(pluginCommand,args);
|
|
65
70
|
if(command!=='run'&&args.length)throw new Error(`/${command} 使用交互选择,请不要附加参数`);
|
|
66
71
|
switch(command){
|
|
72
|
+
case 'commands':return commandPresetsMenu(this);
|
|
73
|
+
case 'shortcuts':return shortcutsMenu(this);
|
|
67
74
|
case 'help':await this.ui.viewMarkdown('Fishtools 使用指南',[
|
|
68
75
|
'# Fishtools',
|
|
69
76
|
'将插件能力组合为工作流,在终端内编辑、运行并查看结果。',
|
|
70
77
|
`当前工作区:${this.app.projectDir}`,
|
|
71
78
|
`文件路径起点:${this.baseDir}`,
|
|
72
79
|
'## 常用操作',gettingStarted(await this.app.listWorkflows(),this.app.project),
|
|
80
|
+
'## 系统命令与快捷名称','输入 ! 后接系统命令可直接执行,输出和状态保留在主面板;/commands 注册常用命令供快捷执行与工作流编排,/shortcuts 给任意插件动作或命令设置中文快捷名称。主输入框和命令编辑框使用 Shift+Enter 换行、Enter 提交,Alt+Enter 为备用换行;支持多行粘贴。系统命令按当前 Shell 语法执行,目标机器需要已安装相应外部程序。',
|
|
73
81
|
'## 编排工作流','/workflows 浏览、新建、打开工作流,进入后添加步骤、编辑参数、设置输出、保存或删除。删除会保留备份,仍被其他流程引用时不能删除。/run 选择已保存的工作流,或补全工作流 ID/中文标题后执行已保存版本,不会要求保存编辑草稿。要执行未保存修改,在工作流编辑菜单选择运行,再选择保存并运行;返回编辑会保留草稿。',
|
|
74
82
|
'## 主面板','运行期间可以继续输入命令,打开配置、管理插件或执行其他工作流。↑/↓ 回看本次启动已提交的命令,回到底部恢复草稿;补全菜单打开时仍用于选择候选。Ctrl+O 展开或收起执行详情,PgUp/PgDn 或滚轮浏览执行过程;/logs 回看各次执行的步骤、输入输出和日志。关闭菜单不会清空运行内容。',
|
|
75
83
|
'## 命令分层','/config 配置 Fishtools,/plugin 管理插件及各插件配置。输入 /插件入口名 后进入该插件的能力菜单;补全列表标明 [内置] 或 [插件]。/tools 包含 Web 与离线文档。旧 /new、/add、/steps、/outputs、/save、/plugins 以及插件的原始命令仍可直接输入。',
|
|
@@ -118,14 +126,26 @@ export class TerminalSession{
|
|
|
118
126
|
}
|
|
119
127
|
async pluginCommand(command,args){
|
|
120
128
|
if(args.length>1)throw new Error(`用法:/${command.name} [@参数.json]`);
|
|
121
|
-
|
|
129
|
+
let input;
|
|
130
|
+
if(args.length)input=await readRunInput(args[0],this.baseDir);
|
|
131
|
+
else{
|
|
132
|
+
const initial=structuredClone(command.shortcutInput??{});
|
|
133
|
+
const empty=command.input?.type==='object'&&command.input.properties&&Object.keys(command.input.properties).length===0;
|
|
134
|
+
if(command.shortcutInput!==undefined||empty){
|
|
135
|
+
try{input=validate(command.input,initial,command.description??command.name);}
|
|
136
|
+
catch{input=await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description});}
|
|
137
|
+
}else input=command.input?await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description}):initial;
|
|
138
|
+
}
|
|
122
139
|
const ui=Object.fromEntries(['input','select','show','confirm'].map(name=>[name,this.ui[name].bind(this.ui)]));
|
|
123
140
|
const definition=this.app.host.definitions.get(command.plugin);
|
|
141
|
+
if(!definition)throw new Error('目标插件已移除,请重新选择目标后执行');
|
|
124
142
|
const requirement=resolvePrivilegeRequirement(definition,command.action);
|
|
125
143
|
const unknown=requirement.declared&&checkStaticPrivilege(requirement).status==='unknown';
|
|
126
144
|
const allowUnknownPrivileges=unknown?await this.ui.confirm(`/${command.name} 的权限需要运行时核查。若检查结果仍无法确认,是否允许本次继续?\n${requirement.reason??''}\n${requirement.remediation??''}`):false;
|
|
127
145
|
if(unknown&&!allowUnknownPrivileges)return;
|
|
128
|
-
const
|
|
146
|
+
const targetName=command.targetName??command.name;
|
|
147
|
+
if(!command.actionId&&!this.app.listCommands().some(current=>current.name===targetName&¤t.plugin===command.plugin))throw new Error('命令目标插件已变更,请重新选择目标后执行');
|
|
148
|
+
const run=command.actionId?await this.app.startAction(command.actionId,input,{ui,allowUnknownPrivileges}):await this.app.startCommand(targetName,input,{ui,allowUnknownPrivileges,expectedPlugin:command.plugin});
|
|
129
149
|
if(this.ui.trackRun){
|
|
130
150
|
this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:command.description??command.name,input});
|
|
131
151
|
if(command.action){void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;}
|
|
@@ -135,6 +155,17 @@ export class TerminalSession{
|
|
|
135
155
|
try{const result=await run.done;if(!this.ui.trackRun)this.ui.show(`插件执行${runStatuses[result.status]??result.status}\n${result.error?readableError(result.error):readableValue(result.output)}\n/logs 查看执行详情与历史日志`);}
|
|
136
156
|
finally{unsubscribe();done();}
|
|
137
157
|
}
|
|
158
|
+
async shellCommand(script){
|
|
159
|
+
const run=await this.app.startShell(script);
|
|
160
|
+
if(this.ui.trackRun){
|
|
161
|
+
this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:'系统命令'});
|
|
162
|
+
void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;
|
|
163
|
+
}
|
|
164
|
+
const unsubscribe=this.app.subscribe(event=>{if(event.runId===run.id&&event.type==='log')this.ui.show(event.message);});
|
|
165
|
+
const done=this.ui.busy('正在执行系统命令 · Ctrl+C 取消',()=>this.app.cancelRun(run.id));
|
|
166
|
+
try{const result=await run.done;this.ui.show(`命令${runStatuses[result.status]??result.status}\n${result.error?readableError(result.error):readableValue(result.output)}\n/logs 查看完整日志`);}
|
|
167
|
+
finally{unsubscribe();done();}
|
|
168
|
+
}
|
|
138
169
|
async configure(selectedId){
|
|
139
170
|
this.writable();
|
|
140
171
|
const plugins=this.app.listPlugins().filter(p=>p.config);
|
|
@@ -76,7 +76,20 @@ export class TerminalView{
|
|
|
76
76
|
input(data);
|
|
77
77
|
},resize);
|
|
78
78
|
this.header=new Text('',1,0);this.transcript=new Text('',1,0);this.prompt=new Container();this.hint=new Text('',1,0);
|
|
79
|
-
this.hint.render=width=>
|
|
79
|
+
this.hint.render=width=>{
|
|
80
|
+
const lines=[''];
|
|
81
|
+
if(this.editor&&(this.homeInput||this.multilineInput)){
|
|
82
|
+
// 按完整按键分组换行,窄屏也能看清主键和备用键。
|
|
83
|
+
let line='';
|
|
84
|
+
for(const hint of ['Shift+Enter 换行','Enter 提交','Alt+Enter 备用换行']){
|
|
85
|
+
const next=line?line+' · '+hint:hint;
|
|
86
|
+
if(line&&visibleWidth(next)>width-1){lines.push(line);line=hint;}else line=next;
|
|
87
|
+
}
|
|
88
|
+
lines.push(line);
|
|
89
|
+
}
|
|
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 退出'));
|
|
91
|
+
return lines.map(line=>line?fitLine(' '+palette.muted(line),width):'');
|
|
92
|
+
};
|
|
80
93
|
const renderTranscript=this.transcript.render.bind(this.transcript);
|
|
81
94
|
this.headingLines=(width,mark='FISHtools')=>{
|
|
82
95
|
const room=Math.max(1,width-2),lines=[''];
|
|
@@ -127,7 +140,7 @@ export class TerminalView{
|
|
|
127
140
|
browseCommands(data){
|
|
128
141
|
const direction=matchesKey(data,'up')?-1:matchesKey(data,'down')?1:0;
|
|
129
142
|
// 补全菜单保留方向键;多行编辑保留光标移动,不把配置值放进命令历史。
|
|
130
|
-
if(!direction||!this.editor||this.editor.isShowingAutocomplete()||this.editor.
|
|
143
|
+
if(!direction||!this.editor||this.editor.isShowingAutocomplete()||this.editor.getExpandedText().includes('\n')||!this.commandHistory.length)return false;
|
|
131
144
|
const current=this.commandHistoryIndex??this.commandHistory.length;
|
|
132
145
|
const next=Math.max(0,Math.min(this.commandHistory.length,current+direction));
|
|
133
146
|
if(next===current)return true;
|
|
@@ -235,19 +248,40 @@ export class TerminalView{
|
|
|
235
248
|
if(message.startsWith('fst >'))title.render=()=>[];
|
|
236
249
|
this.prompt.clear();this.prompt.addChild(title);this.prompt.addChild(component);this.tui.setFocus(component);this.tui.requestRender();
|
|
237
250
|
try{return await new Promise((resolve,reject)=>{this.pending={resolve,reject};component.onSubmit=text=>{
|
|
238
|
-
|
|
239
|
-
|
|
251
|
+
// Shell 历史保留末尾换行,避免再次执行时改变反斜杠续行等语义。
|
|
252
|
+
const historyText=text.trimStart().startsWith('!')?text:text.trim();
|
|
253
|
+
if(this.homeInput&&historyText&&this.commandHistory.at(-1)!==historyText){
|
|
254
|
+
this.commandHistory.push(historyText);if(this.commandHistory.length>100)this.commandHistory.shift();
|
|
240
255
|
}
|
|
241
256
|
resolve(text);
|
|
242
257
|
};component.onSelect=item=>resolve(item.value);component.onCancel=()=>reject(cancelled());});}
|
|
243
|
-
finally{if(this.homeInput)this.terminal.write(mouseOff);this.pending=null;this.editor=null;this.homeInput=false;this.prompt.clear();this.tui.requestRender();}
|
|
258
|
+
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();}
|
|
244
259
|
}
|
|
245
|
-
input(message,{value='',commands=[],files=true,workflows=[]}={}){
|
|
260
|
+
input(message,{value='',commands=[],files=true,workflows=[],multiline=false}={}){
|
|
246
261
|
const editor=new Editor(this.tui,{borderColor:palette.border,selectList:theme},{paddingX:1});
|
|
262
|
+
const handleInput=editor.handleInput.bind(editor);
|
|
263
|
+
editor.handleInput=data=>{
|
|
264
|
+
// ProcessTerminal 已把分段粘贴合并为完整事件;公开插入接口保留脚本制表符和换行。
|
|
265
|
+
if(data.startsWith('\x1b[200~')&&data.endsWith('\x1b[201~')){
|
|
266
|
+
const text=safe(data.slice(6,-6).replace(/\r\n?/g,'\n'));
|
|
267
|
+
if(this.homeInput||multiline||text.includes('\n')){editor.insertTextAtCursor(text);return;}
|
|
268
|
+
}
|
|
269
|
+
if(matchesKey(data,'shift+enter')||matchesKey(data,'alt+enter')||data==='\x1b[13;2~'){
|
|
270
|
+
editor.insertTextAtCursor('\n');return;
|
|
271
|
+
}
|
|
272
|
+
// 反斜杠属于命令原文,不能触发上游的“反斜杠后 Enter 换行”兼容键位。
|
|
273
|
+
if(data==='\\'){editor.insertTextAtCursor(data);return;}
|
|
274
|
+
if(matchesKey(data,'enter')&&!editor.isShowingAutocomplete()){
|
|
275
|
+
if(!editor.disableSubmit){const text=editor.getExpandedText();editor.setText('');editor.onSubmit?.(text);}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
handleInput(data);
|
|
279
|
+
};
|
|
247
280
|
const renderEditor=editor.render.bind(editor);
|
|
248
281
|
editor.render=width=>{
|
|
249
|
-
|
|
250
|
-
|
|
282
|
+
// 上游 visibleWidth 按三个空格计算 Tab;只扩展显示,提交仍保留原始 Tab。
|
|
283
|
+
if(width<8)return renderEditor(width).map(line=>line.replaceAll('\t',' '));
|
|
284
|
+
const inner=width-4,lines=renderEditor(inner).map(line=>line.replaceAll('\t',' '));let closed=false;
|
|
251
285
|
return lines.map((line,i)=>{
|
|
252
286
|
if(i===0)return ' '+palette.border('╭'+'─'.repeat(inner)+'╮');
|
|
253
287
|
if(!closed&&/^─+$/.test(safe(line))){closed=true;return ' '+palette.border('╰'+'─'.repeat(inner)+'╯');}
|
|
@@ -256,6 +290,7 @@ export class TerminalView{
|
|
|
256
290
|
});
|
|
257
291
|
};
|
|
258
292
|
this.homeInput=message.startsWith('fst >');
|
|
293
|
+
this.multilineInput=multiline;
|
|
259
294
|
this.commandHistoryIndex=null;
|
|
260
295
|
if(this.homeInput)editor.onChange=text=>{
|
|
261
296
|
if(this.commandHistoryIndex!==null&&text!==this.commandHistory[this.commandHistoryIndex])this.commandHistoryIndex=null;
|
|
@@ -4,7 +4,6 @@ export async function workflowMenu(session){
|
|
|
4
4
|
const workflows=await app.listWorkflows();
|
|
5
5
|
const choice=await ui.select('工作流 · 选择后编辑或执行',[
|
|
6
6
|
...workflows.map(flow=>({value:`workflow:${flow.id}`,label:flow.title??flow.id,description:`${flow.id} · ${flow.steps.length} 步`})),
|
|
7
|
-
...(!app.project.readOnly?workflows.map(flow=>({value:`duplicate:${flow.id}`,label:`复制 ${flow.title??flow.id} 为新工作流`,description:`从已保存版本 ${flow.id} 创建副本` })):[]),
|
|
8
7
|
...(!app.project.readOnly?[{value:'new',label:'+ 新建工作流'}]:[]),
|
|
9
8
|
...(session.dirty?[{value:'draft',label:`继续编辑草稿:${session.flow.title}`}]:[]),
|
|
10
9
|
{value:'back',label:'返回主面板'},
|
|
@@ -12,11 +11,7 @@ export async function workflowMenu(session){
|
|
|
12
11
|
if(choice==='back')return;
|
|
13
12
|
try{
|
|
14
13
|
const id=choice.startsWith('workflow:')?choice.slice(9):null;
|
|
15
|
-
if(choice.
|
|
16
|
-
if(!await session.discard())continue;
|
|
17
|
-
await duplicateIntoSession(session,workflows.find(flow=>flow.id===choice.slice(10)));
|
|
18
|
-
}
|
|
19
|
-
else if(choice==='new'){if(await session.execute('/new')===false)continue;}
|
|
14
|
+
if(choice==='new'){if(await session.execute('/new')===false)continue;}
|
|
20
15
|
else if(choice!=='draft'&&session.flow?.id!==id){
|
|
21
16
|
if(!await session.discard())continue;
|
|
22
17
|
session.flow=structuredClone(workflows.find(flow=>flow.id===id));session.dirty=false;
|
|
@@ -3,6 +3,7 @@ import {fileURLToPath} from 'node:url';
|
|
|
3
3
|
import {access,readFile,readdir,lstat,mkdir,copyFile,realpath} from 'node:fs/promises';
|
|
4
4
|
|
|
5
5
|
const topics=[
|
|
6
|
+
['commands','系统命令与快捷名称','docs/terminal-commands.md'],
|
|
6
7
|
['plugins','插件开发规范','docs/plugin-development.md'],
|
|
7
8
|
['deployment','安装与部署','docs/deployment.md'],
|
|
8
9
|
['control','控制流与运行观察规范','docs/superpowers/specs/2026-10-11-control-observation.md'],
|
|
@@ -14,7 +15,7 @@ const topics=[
|
|
|
14
15
|
['readme','Fishtools 使用说明','README.md'],
|
|
15
16
|
];
|
|
16
17
|
// 保留文档链接所需的目录布局;参考代码仅供阅读,不是第二份运行时。
|
|
17
|
-
const assets=['README.md','LICENSE','THIRD_PARTY_NOTICES.md','docs','examples/service-plugins','plugins/examples','plugins/environment','plugins/web/README.md','packages/sdk','packages/core','packages/pack','packages/fst-logs','packages/fst-platform','packages/fst-workflow','packages/fst-plugin','packages/fst-workspace','packages/fst-devtools','packages/fst-cli','tests/application.test.js'];
|
|
18
|
+
const assets=['README.md','LICENSE','THIRD_PARTY_NOTICES.md','docs','examples/service-plugins','plugins/examples','plugins/environment','plugins/commands','plugins/web/README.md','packages/sdk','packages/core','packages/pack','packages/fst-logs','packages/fst-platform','packages/fst-workflow','packages/fst-plugin','packages/fst-workspace','packages/fst-devtools','packages/fst-cli','tests/application.test.js'];
|
|
18
19
|
const excluded=new Set(['node_modules','dist','.git','.cache','__pycache__','.pytest_cache','coverage','public']);
|
|
19
20
|
const inside=(parent,child)=>{const relative=path.relative(parent,child);return relative===''||(!relative.startsWith(`..${path.sep}`)&&relative!=='..'&&!path.isAbsolute(relative));};
|
|
20
21
|
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
| `@mytools/fst-platform/npm` | `./src/npm.js` |
|
|
19
19
|
| `@mytools/fst-platform/network` | `./src/network.js` |
|
|
20
20
|
| `@mytools/fst-platform/fs` | `./src/file-probe.js` |
|
|
21
|
+
| `@mytools/fst-platform/shell` | `./src/shell.js` |
|
|
21
22
|
| `@mytools/fst-platform/package.json` | `./package.json` |
|
|
22
23
|
|
|
23
24
|
仅依赖 exports 声明的入口,禁止消费者跨包读取 src/bin 私有文件。入口路径由本模块维护,修改协议时同步消费方回归。
|
|
@@ -32,11 +33,21 @@
|
|
|
32
33
|
node --test tests/modules.test.js tests/process.test.js tests/environments.test.js
|
|
33
34
|
```
|
|
34
35
|
|
|
36
|
+
进程停止专项:`node --test tests/process.test.js tests/process-stream.test.js tests/process-tree-stop.test.js`。
|
|
37
|
+
|
|
35
38
|
## 子进程环境
|
|
36
39
|
|
|
40
|
+
`/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
|
+
|
|
37
42
|
`runProcess(file,args,{env,extendEnv=true})` 默认把 `env` 覆盖到宿主环境上。
|
|
38
43
|
当调用方已按动作代理规则构造完整环境时,传 `extendEnv:false`,防止已移除的代理变量被重新继承。
|
|
39
44
|
|
|
45
|
+
缓冲模式由平台按每条输出流的原始字节计算 `maxBuffer`,默认每条流 4 MiB;超限返回 `OUTPUT_LIMIT`。
|
|
46
|
+
`maxBuffer` 必须为非负数,`0` 仅允许空输出,`Infinity` 表示调用方显式取消缓冲上限;对象、`NaN` 和负数会在启动前被拒绝。
|
|
47
|
+
流式模式通过 `onOutput` 消费输出,仅保留 `summaryBytes` 指定的尾部摘要。
|
|
48
|
+
超限、取消、超时或输出回调失败时,平台终止整棵进程树,等待回收结束后返回错误。
|
|
49
|
+
停止过程中继续排空并丢弃管道输出,避免提前关闭管道使父进程因 `EPIPE` 退出,导致 Windows `taskkill /T` 无法找到后代进程。
|
|
50
|
+
|
|
40
51
|
## 隔离文件探测
|
|
41
52
|
|
|
42
53
|
`createFileProbe({timeout=2000,maxConcurrent=2,maxPending=16,allowRemote=false})` 创建只读探测器。
|
|
@@ -4,11 +4,11 @@ import which from 'which';
|
|
|
4
4
|
import { FlowError } from '@mytools/sdk';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
-
const outputText=bytes=>bytes??new Uint8Array();
|
|
8
7
|
const stripFinalNewline=text=>text.endsWith('\r\n')?text.slice(0,-2):text.endsWith('\n')?text.slice(0,-1):text;
|
|
9
8
|
|
|
10
9
|
export async function runProcess(file,args=[],{cwd,env,extendEnv=true,signal,timeout=30000,maxBuffer=4*1024*1024,encoding='utf8',onOutput,outputMode='buffer',summaryBytes=65536}={}) {
|
|
11
10
|
if(!['buffer','stream'].includes(outputMode))throw new TypeError('outputMode 必须为 buffer 或 stream');
|
|
11
|
+
if(typeof maxBuffer!=='number'||Number.isNaN(maxBuffer)||maxBuffer<0)throw new TypeError('maxBuffer 必须为非负数,可用 Infinity 表示不限制缓冲');
|
|
12
12
|
const streaming=outputMode==='stream';
|
|
13
13
|
if(streaming&&typeof onOutput!=='function')throw new TypeError('stream 模式必须提供 onOutput 消费输出');
|
|
14
14
|
if(!Number.isSafeInteger(summaryBytes)||summaryBytes<0||summaryBytes>1024*1024)throw new TypeError('summaryBytes 必须为 0 到 1048576 的整数');
|
|
@@ -33,11 +33,12 @@ export async function runProcess(file,args=[],{cwd,env,extendEnv=true,signal,tim
|
|
|
33
33
|
if(!executable)throw Object.assign(new Error(`not found: ${file}`),{code:'ENOENT'});
|
|
34
34
|
}
|
|
35
35
|
catch(cause){throw new FlowError(`未找到程序:${file}`,{code:'ENOENT',details:{executable:file},cause});}
|
|
36
|
-
// Execa
|
|
37
|
-
|
|
36
|
+
// 平台统一收集原始字节和执行上限;Execa 的超限收集器会提前关闭管道,
|
|
37
|
+
// 导致持续输出的父进程因 EPIPE 退出,taskkill 尚未遍历就失去父 PID。
|
|
38
|
+
const child=execa(executable,args,{cwd,env,extendEnv,reject:false,windowsHide:true,maxBuffer,buffer:false,timeout:0,shell:false,encoding:'buffer',stripFinalNewline:false});
|
|
38
39
|
let reason,killing,outputError,outputFailed=false,outputWrites=Promise.resolve();
|
|
39
40
|
let markStopped;const stopped=new Promise(resolve=>{markStopped=resolve;});
|
|
40
|
-
const outputBytes={stdout:0,stderr:0},tails={stdout:'',stderr:''};let outputTruncated=false;
|
|
41
|
+
const outputBytes={stdout:0,stderr:0},tails={stdout:'',stderr:''},buffers={stdout:[],stderr:[]};let outputTruncated=false;
|
|
41
42
|
const remember=(name,message)=>{
|
|
42
43
|
const bytes=Buffer.from(tails[name]+message);let start=Math.max(0,bytes.length-summaryBytes);
|
|
43
44
|
if(start){outputTruncated=true;while(start<bytes.length&&(bytes[start]&0xc0)===0x80)start++;}
|
|
@@ -67,7 +68,8 @@ export async function runProcess(file,args=[],{cwd,env,extendEnv=true,signal,tim
|
|
|
67
68
|
const decoder=new TextDecoder(decoderEncoding);
|
|
68
69
|
try{
|
|
69
70
|
for await(const chunk of stream){
|
|
70
|
-
|
|
71
|
+
// 停止后继续排空并丢弃输出,避免迭代器 break 提前关闭管道触发 EPIPE。
|
|
72
|
+
if(reason)continue;outputBytes[name]+=chunk.byteLength;
|
|
71
73
|
const message=decoder.decode(chunk,{stream:true});remember(name,message);
|
|
72
74
|
await Promise.race([emit(name,message),stopped]);
|
|
73
75
|
}
|
|
@@ -81,9 +83,11 @@ export async function runProcess(file,args=[],{cwd,env,extendEnv=true,signal,tim
|
|
|
81
83
|
const finish=()=>{if(!ended){ended=true;emit(name,decoder.decode());}};
|
|
82
84
|
flush.push(finish);stream?.once('end',finish);
|
|
83
85
|
stream?.on('data',chunk=>{
|
|
86
|
+
if(reason)return;
|
|
84
87
|
bytes+=Buffer.byteLength(chunk);
|
|
85
88
|
// 流量超限时立即回收整棵进程树,不继续扩张待落盘的日志队列。
|
|
86
89
|
if(bytes>maxBuffer){stop('output-limit');return;}
|
|
90
|
+
buffers[name].push(chunk);
|
|
87
91
|
if(onOutput)emit(name,decoder.decode(chunk,{stream:true}));
|
|
88
92
|
});
|
|
89
93
|
}
|
|
@@ -104,7 +108,7 @@ export async function runProcess(file,args=[],{cwd,env,extendEnv=true,signal,tim
|
|
|
104
108
|
if(reason)throw new FlowError(reason==='cancelled'?'操作已取消':'程序运行超时',{code:reason==='cancelled'?'CANCELLED':'TIMEOUT'});
|
|
105
109
|
if(result.isMaxBuffer)throw new FlowError('程序输出超过上限',{code:'OUTPUT_LIMIT'});
|
|
106
110
|
if(result.exitCode===undefined&&result.failed)throw new FlowError(`无法启动程序 ${file}:${result.shortMessage??result.message}`,{code:result.code??'SPAWN_FAILED'});
|
|
107
|
-
const decode=
|
|
111
|
+
const decode=chunks=>stripFinalNewline(new TextDecoder(decoderEncoding).decode(Buffer.concat(chunks)));
|
|
108
112
|
if(streaming)return {exitCode:result.exitCode,stdout:stripFinalNewline(tails.stdout),stderr:stripFinalNewline(tails.stderr),outputMode,outputTruncated,outputBytes};
|
|
109
|
-
return {exitCode:result.exitCode,stdout:decode(
|
|
113
|
+
return {exitCode:result.exitCode,stdout:decode(buffers.stdout),stderr:decode(buffers.stderr)};
|
|
110
114
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {FlowError} from '@mytools/sdk';
|
|
2
|
+
import {randomUUID} from 'node:crypto';
|
|
3
|
+
import {mkdir,open,rm} from 'node:fs/promises';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {runProcess as executeProcess} from './process.js';
|
|
7
|
+
|
|
8
|
+
const shells=new Set(['auto','powershell','pwsh','sh','bash']);
|
|
9
|
+
const powerShellArgs=['-NoLogo','-NoProfile','-NonInteractive','-OutputFormat','Text'];
|
|
10
|
+
|
|
11
|
+
function selectShell(script,shell,platform){
|
|
12
|
+
if(typeof script!=='string'||!script.trim()||script.includes('\u0000'))throw new FlowError('命令文本必须是非空字符串,且不能包含 NUL 字符',{code:'VALIDATION'});
|
|
13
|
+
if(!shells.has(shell))throw new FlowError(`不支持的 Shell:${String(shell)}`,{code:'VALIDATION'});
|
|
14
|
+
return shell==='auto'?(platform==='win32'?'powershell':'sh'):shell;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function powerShellSource(script){
|
|
18
|
+
// $? 在脚本最后一条命令后立即读取,避免沿用更早的外部命令退出码。
|
|
19
|
+
return `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
20
|
+
$OutputEncoding = [Console]::OutputEncoding
|
|
21
|
+
& {
|
|
22
|
+
${script}
|
|
23
|
+
if (-not $?) {
|
|
24
|
+
if ($LASTEXITCODE) { exit $LASTEXITCODE }
|
|
25
|
+
exit 1
|
|
26
|
+
}
|
|
27
|
+
exit 0
|
|
28
|
+
}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function shellInvocation(script,{shell='auto',platform=process.platform}={}){
|
|
32
|
+
const file=selectShell(script,shell,platform);
|
|
33
|
+
if(file==='sh'||file==='bash')return {file,args:['-c',script]};
|
|
34
|
+
// 编码只改变传输形式;保留用户的引号、管道、换行及尾部注释。
|
|
35
|
+
const source=powerShellSource(script);
|
|
36
|
+
const encoded=Buffer.from(source,'utf16le').toString('base64');
|
|
37
|
+
// 普通 -Command 避免 Windows PowerShell 在 -EncodedCommand 下把 stderr 序列化为 CLIXML。
|
|
38
|
+
const command=`& ([ScriptBlock]::Create([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${encoded}'))))`;
|
|
39
|
+
return {file,args:[...powerShellArgs,'-Command',command]};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runShell(script,{shell='auto',runProcess:execute=executeProcess,temporaryDir=tmpdir(),...processOptions}={}){
|
|
43
|
+
const file=selectShell(script,shell,process.platform),powershell=file==='powershell'||file==='pwsh';
|
|
44
|
+
if(processOptions.signal?.aborted)throw new FlowError('操作已取消',{code:'CANCELLED'});
|
|
45
|
+
const directory=path.resolve(temporaryDir);
|
|
46
|
+
await mkdir(directory,{recursive:true});
|
|
47
|
+
const scriptFile=path.join(directory,`fst-shell-${randomUUID()}.${powershell?'ps1':'sh'}`);
|
|
48
|
+
// 独占创建后才承担清理责任;不覆盖已有文件,也不清空调用者的输出目录。
|
|
49
|
+
const handle=await open(scriptFile,'wx',0o600);
|
|
50
|
+
try{
|
|
51
|
+
try{
|
|
52
|
+
// Windows PowerShell 5.1 读取 UTF-8 文件需要 BOM;POSIX Shell 使用无 BOM 原文。
|
|
53
|
+
await handle.writeFile(powershell?'\uFEFF'+powerShellSource(script):script,'utf8');
|
|
54
|
+
}finally{await handle.close();}
|
|
55
|
+
const args=powershell?[...powerShellArgs,'-ExecutionPolicy','Bypass','-File',scriptFile]:[scriptFile];
|
|
56
|
+
return await execute(file,args,processOptions);
|
|
57
|
+
}finally{await rm(scriptFile,{force:true});}
|
|
58
|
+
}
|
|
@@ -7,7 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
export const builtinCommands=[
|
|
8
8
|
['workflows','选择已有工作流'],['new','新建工作流'],['add','添加插件能力或子工作流'],
|
|
9
9
|
['steps','设置步骤参数、排序或删除'],['outputs','设置最终输出'],['save','保存工作流'],
|
|
10
|
-
['run','运行工作流,可附带 @参数.json'],['runs','运行记录:选择任意一次执行,查看进度、结果和日志'],['logs','查看运行日志'],['plugin','管理插件'],['plugins','管理插件(兼容旧入口)'],['config','配置 Fishtools'],['tools','Web 与离线文档'],
|
|
10
|
+
['run','运行工作流,可附带 @参数.json'],['runs','运行记录:选择任意一次执行,查看进度、结果和日志'],['logs','查看运行日志'],['plugin','管理插件'],['plugins','管理插件(兼容旧入口)'],['commands','管理常用命令'],['shortcuts','管理自定义快捷名称'],['config','配置 Fishtools'],['tools','Web 与离线文档'],
|
|
11
11
|
['pack','选择工作流打包'],['web','启动 Web 工作台'],['docs','阅读离线开发与使用文档'],['help','查看操作说明'],['exit','退出终端工作台'],
|
|
12
12
|
].map(([name,description])=>({name,description}));
|
|
13
13
|
|
|
@@ -76,9 +76,13 @@ export function validateDeclarations(plugin){
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
export function isCommandName(name){
|
|
80
|
+
return typeof name==='string'&&name===name.normalize('NFC')&&/^\p{L}[\p{L}\p{N}_.-]*$/u.test(name);
|
|
81
|
+
}
|
|
82
|
+
|
|
79
83
|
export function validateCommands(plugin,registered){
|
|
80
84
|
for(const [name,c] of Object.entries(plugin.commands??{})){
|
|
81
|
-
if(
|
|
85
|
+
if(!isCommandName(name)||builtinCommands.some(x=>x.name===name)||registered.has(name))throw new FlowError(`命令名称无效或冲突:/${name}`);
|
|
82
86
|
if(!c||Boolean(c.action)===Boolean(c.run)||c.run&&typeof c.run!=='function')throw new FlowError(`命令 /${name} 必须声明 action 或 run`);
|
|
83
87
|
if(c.action&&!plugin.actions?.[c.action])throw new FlowError(`命令 /${name} 指向不存在的动作:${c.action}`);
|
|
84
88
|
registered.add(name);
|