@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
|
@@ -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);
|
|
@@ -17,12 +17,15 @@
|
|
|
17
17
|
| `@mytools/fst-workspace/plugins` | `./src/plugins.js` |
|
|
18
18
|
| `@mytools/fst-workspace/installations` | `./src/plugin-installations.js` |
|
|
19
19
|
| `@mytools/fst-workspace/bundles` | `./src/bundle-workspace.js` |
|
|
20
|
+
| `@mytools/fst-workspace/shortcuts` | `./src/shortcuts.js` |
|
|
20
21
|
| `@mytools/fst-workspace/package.json` | `./package.json` |
|
|
21
22
|
|
|
22
23
|
仅依赖 exports 声明的入口,禁止消费者跨包读取 src/bin 私有文件。入口路径由本模块维护,修改协议时同步消费方回归。
|
|
23
24
|
|
|
24
25
|
## 维护与验证
|
|
25
26
|
|
|
27
|
+
`/shortcuts` 提供 `validateShortcuts` 校验、`bindShortcutCommands` 在用户配置时补齐命令提供者身份,以及 `describeShortcuts` 生成可用或失效状态。绑定函数可传入 previous 保留旧项:未绑定命令修改参数或重命名仍保持失效,需重新选择目标。命令项使用 command 与 plugin 同时匹配,动作项使用稳定 action ID;不执行其他插件的同名能力。实际保存、备份与宿主装配由 core 协调。
|
|
28
|
+
|
|
26
29
|
本模块负责上述职责的实现、文档与回归;跨模块接口调整需要同时检查生产者与消费者。具体维护人员尚未指定。
|
|
27
30
|
|
|
28
31
|
在仓库根目录执行:
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
".": "./src/index.js",
|
|
9
9
|
"./workflows": "./src/workflows.js",
|
|
10
10
|
"./project": "./src/project.js",
|
|
11
|
+
"./shortcuts": "./src/shortcuts.js",
|
|
11
12
|
"./plugins": "./src/plugins.js",
|
|
12
13
|
"./installations": "./src/plugin-installations.js",
|
|
13
14
|
"./bundles": "./src/bundle-workspace.js",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {FlowError} from '@mytools/sdk';
|
|
2
|
+
import {builtinCommands,isCommandName} from '@mytools/fst-plugin/contracts';
|
|
3
|
+
import {isBuiltin} from 'node:module';
|
|
4
|
+
|
|
5
|
+
const dangerousFields=new Set(['__proto__','constructor','prototype']);
|
|
6
|
+
const shortcutFields=new Set(['action','command','plugin','input']);
|
|
7
|
+
|
|
8
|
+
function validPluginId(value){
|
|
9
|
+
if(typeof value!=='string'||!value.length||value.length>214||value!==value.toLowerCase()||isBuiltin(value)||['node_modules','favicon.ico'].includes(value))return false;
|
|
10
|
+
const parts=/^(?:@([^/]+)\/)?([^/]+)$/.exec(value);
|
|
11
|
+
if(!parts||/^[._-]/.test(value)||parts[2].startsWith('.'))return false;
|
|
12
|
+
return (!parts[1]||/^[a-z0-9_.~'!()*-]+$/.test(parts[1]))&&/^[a-z0-9_.-]+$/.test(parts[2]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function invalid(location,message){
|
|
16
|
+
throw new FlowError(message,{code:'VALIDATION',details:{path:location}});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function plainObject(value){
|
|
20
|
+
return value!==null&&typeof value==='object'&&!Array.isArray(value)&&[Object.prototype,null].includes(Object.getPrototypeOf(value));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function entries(value,location){
|
|
24
|
+
if(!plainObject(value))invalid(location,`${location} 必须为普通 JSON 对象`);
|
|
25
|
+
return Reflect.ownKeys(value).map(key=>{
|
|
26
|
+
if(typeof key!=='string'||dangerousFields.has(key))invalid(location,`${location} 包含危险或非 JSON 字段`);
|
|
27
|
+
const field=Object.getOwnPropertyDescriptor(value,key);
|
|
28
|
+
if(!field.enumerable||!Object.hasOwn(field,'value'))invalid(`${location}.${key}`,`${location}.${key} 必须为普通 JSON 字段`);
|
|
29
|
+
return [key,field.value];
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cloneJson(value,location,ancestors=new Set()){
|
|
34
|
+
if(value===null||typeof value==='string'||typeof value==='boolean')return value;
|
|
35
|
+
if(typeof value==='number'&&Number.isFinite(value))return value;
|
|
36
|
+
if(typeof value!=='object')invalid(location,`${location} 必须为可序列化的 JSON 值`);
|
|
37
|
+
if(ancestors.has(value))invalid(location,`${location} 不能包含循环引用`);
|
|
38
|
+
ancestors.add(value);
|
|
39
|
+
try{
|
|
40
|
+
if(Array.isArray(value)){
|
|
41
|
+
if(Object.getPrototypeOf(value)!==Array.prototype||Reflect.ownKeys(value).length!==value.length+1)invalid(location,`${location} 必须为普通且完整的 JSON 数组`);
|
|
42
|
+
const copy=[];
|
|
43
|
+
for(let index=0;index<value.length;index++){
|
|
44
|
+
const field=Object.getOwnPropertyDescriptor(value,String(index));
|
|
45
|
+
if(!field?.enumerable||!Object.hasOwn(field,'value'))invalid(`${location}[${index}]`,`${location} 必须为普通且完整的 JSON 数组`);
|
|
46
|
+
copy.push(cloneJson(field.value,`${location}[${index}]`,ancestors));
|
|
47
|
+
}
|
|
48
|
+
return copy;
|
|
49
|
+
}
|
|
50
|
+
return Object.fromEntries(entries(value,location).map(([key,nested])=>[key,cloneJson(nested,`${location}.${key}`,ancestors)]));
|
|
51
|
+
}finally{ancestors.delete(value);}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateShortcuts(shortcuts,{reservedNames=[]}={}){
|
|
55
|
+
if(!Array.isArray(reservedNames)||reservedNames.some(name=>typeof name!=='string'))invalid('reservedNames','保留名称必须为字符串数组');
|
|
56
|
+
const reserved=new Set([...builtinCommands.map(command=>command.name),...reservedNames].map(name=>name.normalize('NFC')));
|
|
57
|
+
const copy={};
|
|
58
|
+
for(const [name,shortcut] of entries(shortcuts,'shortcuts')){
|
|
59
|
+
const location=`shortcuts.${name}`;
|
|
60
|
+
// 明确拒绝未规范化名称,避免两个视觉相同的入口指向不同能力。
|
|
61
|
+
if(name!==name.normalize('NFC'))invalid(location,`快捷名称必须使用 NFC 规范形式:${name}`);
|
|
62
|
+
if(!isCommandName(name))invalid(location,`快捷名称无效:/${name}`);
|
|
63
|
+
if(reserved.has(name))invalid(location,`快捷名称与现有命令冲突:/${name}`);
|
|
64
|
+
reserved.add(name);
|
|
65
|
+
const fields=entries(shortcut,location);
|
|
66
|
+
if(fields.some(([key])=>!shortcutFields.has(key)))invalid(location,`快捷项 ${name} 包含未声明字段`);
|
|
67
|
+
const hasAction=Object.hasOwn(shortcut,'action'),hasCommand=Object.hasOwn(shortcut,'command');
|
|
68
|
+
if(hasAction===hasCommand)invalid(location,`快捷项 ${name} 必须声明 action 或 command 之一`);
|
|
69
|
+
const field=hasAction?'action':'command',target=shortcut[field];
|
|
70
|
+
if(hasAction){
|
|
71
|
+
const separator=typeof target==='string'?target.lastIndexOf(':'):-1;
|
|
72
|
+
if(separator<=0||separator===target.length-1||/[\s\p{C}]/u.test(target))invalid(`${location}.action`,`快捷项 ${name} 的 action 必须为稳定动作 ID`);
|
|
73
|
+
}else if(!isCommandName(target))invalid(`${location}.command`,`快捷项 ${name} 的 command 名称无效`);
|
|
74
|
+
copy[name]={[field]:target};
|
|
75
|
+
if(Object.hasOwn(shortcut,'plugin')){
|
|
76
|
+
if(hasAction)invalid(`${location}.plugin`,`动作快捷项 ${name} 不能声明 plugin`);
|
|
77
|
+
if(!validPluginId(shortcut.plugin))invalid(`${location}.plugin`,`快捷项 ${name} 的 plugin 必须为合法插件包 ID`);
|
|
78
|
+
copy[name].plugin=shortcut.plugin;
|
|
79
|
+
}
|
|
80
|
+
if(Object.hasOwn(shortcut,'input')){
|
|
81
|
+
if(!plainObject(shortcut.input))invalid(`${location}.input`,`快捷项 ${name} 的 input 必须为普通 JSON 对象`);
|
|
82
|
+
copy[name].input=cloneJson(shortcut.input,`${location}.input`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return copy;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function bindShortcutCommands(shortcuts,{commands=[],previous={}}={}){
|
|
89
|
+
const copy=validateShortcuts(shortcuts);
|
|
90
|
+
const old=validateShortcuts(previous),unbound=new Set(Object.values(old).filter(shortcut=>shortcut.command&&shortcut.plugin===undefined).map(shortcut=>shortcut.command));
|
|
91
|
+
for(const [name,shortcut] of Object.entries(copy)){
|
|
92
|
+
// 已有未绑定项即使改参数或重命名也需明确选择目标,不能被整组保存顺带启用。
|
|
93
|
+
if(!Object.hasOwn(shortcut,'command')||Object.hasOwn(shortcut,'plugin'))continue;
|
|
94
|
+
if(old[name]?.command===shortcut.command||unbound.has(shortcut.command))continue;
|
|
95
|
+
const command=commands.find(command=>command.name===shortcut.command);
|
|
96
|
+
if(command?.plugin!==undefined)shortcut.plugin=command.plugin;
|
|
97
|
+
}
|
|
98
|
+
return validateShortcuts(copy);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function describeShortcuts(shortcuts,{actions=[],commands=[]}={}){
|
|
102
|
+
const actionIndex=new Map(actions.map(action=>[action.id,action]));
|
|
103
|
+
return Object.entries(validateShortcuts(shortcuts)).map(([name,shortcut])=>{
|
|
104
|
+
const field=Object.hasOwn(shortcut,'action')?'action':'command';
|
|
105
|
+
const id=shortcut[field];
|
|
106
|
+
let definition;
|
|
107
|
+
if(field==='action')definition=actionIndex.get(id);
|
|
108
|
+
else if(shortcut.plugin!==undefined)definition=commands.find(command=>command.name===id&&command.plugin===shortcut.plugin);
|
|
109
|
+
const descriptions=field==='action'?[definition?.title,definition?.description]:[definition?.description];
|
|
110
|
+
const target={[field]:id};
|
|
111
|
+
if(shortcut.plugin!==undefined)target.plugin=shortcut.plugin;
|
|
112
|
+
const entry={name,target,input:shortcut.input??{},available:Boolean(definition),description:descriptions.find(value=>typeof value==='string'&&value.trim())??id};
|
|
113
|
+
if(!definition){
|
|
114
|
+
if(field==='command'&&shortcut.plugin===undefined)entry.reason='尚未绑定目标插件,请重新选择目标';
|
|
115
|
+
else entry.reason=`${field==='action'?'动作':'命令'}不可用或尚未安装:${shortcut.plugin?`${shortcut.plugin} → `:''}${id}`;
|
|
116
|
+
}
|
|
117
|
+
return entry;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -78,7 +78,7 @@ export async function packApplication(app,{entries,target,outDir='dist',includeW
|
|
|
78
78
|
for(const w of closure.workflows)await writeFile(path.join(workflowDir,`${w.id}.yaml`),YAML.stringify(w));
|
|
79
79
|
const config={};
|
|
80
80
|
for(const p of plugins)if(p.defaultConfig)config[p.id]=p.defaultConfig;
|
|
81
|
-
await writeFile(path.join(directory,'flow.project.json'),JSON.stringify({readOnly,plugins:plugins.map(p=>`./app/node_modules/${p.id}`),config,entries,environments:overrides,network:snapshot.project.network,logs:snapshot.project.logs},null,2));
|
|
81
|
+
await writeFile(path.join(directory,'flow.project.json'),JSON.stringify({readOnly,plugins:plugins.map(p=>`./app/node_modules/${p.id}`),config,entries,environments:overrides,network:snapshot.project.network,logs:snapshot.project.logs,commandPresets:snapshot.project.commandPresets,shortcuts:snapshot.project.shortcuts},null,2));
|
|
82
82
|
const bundleId=randomUUID();
|
|
83
83
|
await writeFile(path.join(directory,'bundle.json'),JSON.stringify({format:2,id:bundleId,editable:!readOnly}));
|
|
84
84
|
await writeFile(path.join(directory,'package.json'),JSON.stringify({private:true,type:'module'},null,2));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mytools contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# 轻量命令插件
|
|
2
|
+
|
|
3
|
+
官方包 `@mytools/commands` 将项目注册的系统命令生成标准工作流动作,动作标识为 `@mytools/commands:注册ID`。修改中文标题或脚本不会改变注册 ID。默认导出的插件没有动作,由 Fishtools 根据项目的 `commandPresets` 装配。
|
|
4
|
+
|
|
5
|
+
## 注册格式
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"greet": {
|
|
10
|
+
"title": "中文问候",
|
|
11
|
+
"script": "printf '%s\\n' \"$FST_INPUT_name\"",
|
|
12
|
+
"shell": "sh",
|
|
13
|
+
"timeout": 30000,
|
|
14
|
+
"parameters": {
|
|
15
|
+
"name": {"type": "string", "title": "姓名", "default": "朋友", "required": true}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`title` 与 `script` 必须填写;`shell` 省略或设为 `auto` 时,Windows 使用 PowerShell,Linux 使用 sh。也可显式选择 `powershell`、`pwsh`、`sh`、`bash`;目标机器须自行提供相应解释器和外部程序。脚本保留原始多行、引号、管道与重定向,并通过唯一临时文件交给解释器,避免长脚本超过系统命令行长度限制。PowerShell 文件使用 UTF-8 BOM 并固定 UTF-8 输出,最后命令失败时返回非零退出码。
|
|
22
|
+
|
|
23
|
+
`timeout` 为可选的整数毫秒,范围为 0 到 2147483647,默认沿用受管进程的 30000 毫秒;`0` 表示不限时。第一版用于非交互命令。
|
|
24
|
+
|
|
25
|
+
注册 ID 最多 80 字符,只能使用 ASCII 字母、数字、下划线、短横线,且以字母或数字开头。`parameters` 为字段对象;字段类型只接受 `string`、`number`、`boolean`,可选中文 `title`、同类型 `default` 和布尔 `required`。参数名以 ASCII 字母或下划线开头,后续可含字母、数字、下划线,最多 80 字符;不能仅以大小写区分,也不能使用原型保留名称。未知字段和不符合类型的默认值会被拒绝。
|
|
26
|
+
|
|
27
|
+
## 参数与权限
|
|
28
|
+
|
|
29
|
+
参数通过 `FST_INPUT_字段名` 环境变量传入;字符串原样保留,数值和布尔值转换为文本。Linux 脚本使用 `"$FST_INPUT_name"`,PowerShell 使用 `$env:FST_INPUT_name`,例如 `Write-Output $env:FST_INPUT_name`。请按所选 Shell 的规则引用环境变量。字段值不会拼接到脚本源码。
|
|
30
|
+
|
|
31
|
+
无参数动作输入为 `{}`。默认以普通用户权限执行。`requireRoot: true` 声明该动作在 Linux 需要 root,宿主执行前预检并显示原因与处理建议;Windows 使用普通用户声明。
|
|
32
|
+
|
|
33
|
+
## 输出与接口
|
|
34
|
+
|
|
35
|
+
动作复用宿主的代理规则、取消信号、受管进程与完整日志。返回固定字段 `exitCode`、`stdout`、`stderr`、`outputBytes`、`outputTruncated`。`stdout` 和 `stderr` 各保留最多 65536 字节的尾部,`outputBytes` 分别记录两条流的完整字节数,`outputTruncated` 表示尾部摘要是否截断。完整输出可在运行日志中查看。
|
|
36
|
+
|
|
37
|
+
临时脚本优先写入本次运行的 `outputDir`;未提供时使用系统临时目录。完成、失败、超时和取消后删除本次文件,保留同目录中的其他文件。PowerShell 文件执行使用本次进程的 `ExecutionPolicy Bypass`,沿用原先命令文本可执行的行为。
|
|
38
|
+
|
|
39
|
+
非零退出抛出 `EXECUTION_FAILED`,错误包含退出码和输出摘要;超时、取消及解释器缺失沿用宿主错误语义。
|
|
40
|
+
|
|
41
|
+
包公开导出 `COMMANDS_PLUGIN_ID`、自身目录 `commandsPluginRoot`、`validateCommandPresets(presets)` 和 `createCommandsPlugin(presets = {})`。校验函数返回独立副本;工厂生成标准插件定义,输入校验与默认值由宿主统一处理。
|