@yanglaofish/fishtools 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -17
- package/manual/README.md +43 -17
- package/manual/docs/examples/pipeline-reader/LICENSE +21 -0
- package/manual/docs/examples/pipeline-reader/index.js +14 -0
- package/manual/docs/examples/pipeline-reader/package.json +7 -0
- package/manual/docs/examples/pipeline-writer/LICENSE +21 -0
- package/manual/docs/examples/pipeline-writer/index.js +17 -0
- package/manual/docs/examples/pipeline-writer/package.json +7 -0
- package/manual/docs/examples/script-plugin/LICENSE +21 -0
- package/manual/docs/examples/script-plugin/README.md +64 -0
- package/manual/docs/examples/script-plugin/index.js +39 -0
- package/manual/docs/examples/script-plugin/package.json +9 -0
- package/manual/docs/examples/script-plugin/scripts/benchmark.py +23 -0
- package/manual/docs/plugin-development.md +75 -14
- package/manual/docs/releases.md +30 -1
- package/manual/docs/research/2026-10-11-plugin-kernel-capabilities.md +167 -0
- package/manual/docs/roadmap.md +8 -1
- package/manual/docs/superpowers/plans/2026-10-11-control-observation.md +1 -1
- package/manual/docs/superpowers/plans/2026-10-11-public-plugin-fixes.md +40 -0
- package/manual/docs/superpowers/plans/2026-10-11-terminal-hierarchy.md +40 -0
- package/manual/docs/superpowers/plans/2026-10-12-plugin-developer-experience.md +50 -0
- package/manual/docs/superpowers/reviews/2026-10-12-plugin-release-review.md +20 -0
- package/manual/docs/superpowers/specs/2026-10-11-collapsed-plugin-output.md +23 -0
- package/manual/docs/superpowers/specs/2026-10-12-plugin-developer-experience.md +22 -0
- package/manual/docs/verification.md +100 -0
- package/manual/packages/core/src/application.js +235 -0
- package/manual/packages/core/src/engine.js +93 -0
- package/manual/packages/core/src/host.js +22 -5
- package/manual/packages/core/src/runtime.js +135 -0
- package/manual/packages/core/src/terminal/run-feed.js +78 -0
- package/manual/packages/core/src/terminal/session.js +438 -0
- package/manual/packages/core/src/terminal/view.js +283 -0
- package/manual/tests/application.test.js +30 -0
- package/node_modules/@mytools/core/bin/fst.js +27 -5
- package/node_modules/@mytools/core/package.json +2 -0
- package/node_modules/@mytools/core/src/application.js +46 -21
- package/node_modules/@mytools/core/src/contracts.js +27 -9
- package/node_modules/@mytools/core/src/documentation.js +1 -1
- package/node_modules/@mytools/core/src/engine.js +1 -1
- package/node_modules/@mytools/core/src/environments.js +39 -1
- package/node_modules/@mytools/core/src/host.js +22 -5
- package/node_modules/@mytools/core/src/markdown-log.js +1 -1
- package/node_modules/@mytools/core/src/plugin-installations.js +84 -0
- package/node_modules/@mytools/core/src/plugin-tools.js +133 -0
- package/node_modules/@mytools/core/src/plugins.js +16 -6
- package/node_modules/@mytools/core/src/process.js +30 -8
- package/node_modules/@mytools/core/src/schema-path.js +31 -0
- package/node_modules/@mytools/core/src/terminal/model.js +1 -1
- package/node_modules/@mytools/core/src/terminal/navigation.js +17 -0
- package/node_modules/@mytools/core/src/terminal/presentation.js +3 -3
- package/node_modules/@mytools/core/src/terminal/run-feed.js +78 -0
- package/node_modules/@mytools/core/src/terminal/run-presentation.js +60 -0
- package/node_modules/@mytools/core/src/terminal/run-viewer.js +30 -12
- package/node_modules/@mytools/core/src/terminal/session.js +118 -50
- package/node_modules/@mytools/core/src/terminal/theme.js +1 -0
- package/node_modules/@mytools/core/src/terminal/view.js +125 -28
- package/node_modules/@mytools/core/src/terminal/workflow-menu.js +50 -0
- package/node_modules/@mytools/core/src/validation.js +24 -9
- package/node_modules/@mytools/core/src/version.js +1 -1
- package/node_modules/@mytools/core/src/workflows.js +13 -15
- package/node_modules/@mytools/pack/dependencies.js +8 -1
- package/node_modules/@mytools/pack/index.js +4 -2
- package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +871 -870
- package/node_modules/{tar → minipass-flush/node_modules/minipass}/node_modules/yallist/README.md +7 -8
- package/node_modules/minipass-flush/node_modules/minipass/node_modules/yallist/package.json +29 -0
- package/node_modules/yallist/README.md +8 -7
- package/node_modules/yallist/package.json +51 -12
- package/package.json +15 -15
- package/node_modules/tar/node_modules/yallist/package.json +0 -68
- /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/LICENSE +0 -0
- /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/iterator.js +0 -0
- /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/yallist.js +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/LICENSE.md +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.d.ts +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.d.ts.map +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.js +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.js.map +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/package.json +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.d.ts +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.d.ts.map +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.js +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.js.map +0 -0
- /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/package.json +0 -0
|
@@ -3,7 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { TUI, Text, Container, Editor, SelectList, ProcessTerminal, CombinedAutocompleteProvider, matchesKey, truncateToWidth, visibleWidth } from '@mariozechner/pi-tui';
|
|
5
5
|
import { MarkdownLogViewer } from './log-viewer.js';
|
|
6
|
-
import { RunViewer, mouseOn, mouseOff } from './run-viewer.js';
|
|
6
|
+
import { RunViewer, mouseOn, mouseOff, mouseWheel } from './run-viewer.js';
|
|
7
|
+
import {RunFeed} from './run-feed.js';
|
|
7
8
|
import { logo } from './presentation.js';
|
|
8
9
|
import { VERSION } from '../version.js';
|
|
9
10
|
import {cleanText as safe,palette,menuTheme as theme,fitLine,shortenPath} from './theme.js';
|
|
@@ -14,8 +15,13 @@ function fit(component){const render=component.render.bind(component);component.
|
|
|
14
15
|
|
|
15
16
|
// Pi 的 @ 模糊搜索依赖额外的 fd 程序。这里按目录补全,离线目标机无需安装 fd。
|
|
16
17
|
export class FileAutocomplete extends CombinedAutocompleteProvider{
|
|
17
|
-
constructor(commands,baseDir){super(commands,baseDir);this.baseDir=baseDir;}
|
|
18
|
+
constructor(commands,baseDir,workflows=[]){super(commands,baseDir);this.baseDir=baseDir;this.workflows=workflows;}
|
|
18
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
|
+
}
|
|
19
25
|
const prefix=lines[line].slice(0,col).match(/(?:^|\s)(@"[^"\n]*|@[^\s]*)$/)?.[1];
|
|
20
26
|
if(!prefix)return super.getSuggestions(lines,line,col);
|
|
21
27
|
const raw=prefix.slice(1).replace(/^"/,'').replaceAll('\\','/');
|
|
@@ -34,6 +40,7 @@ export class FileAutocomplete extends CombinedAutocompleteProvider{
|
|
|
34
40
|
}
|
|
35
41
|
getForceFileSuggestions(lines,line,col){return this.getSuggestions(lines,line,col);}
|
|
36
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};}
|
|
37
44
|
if(!prefix.startsWith('@'))return super.applyCompletion(lines,line,col,item,prefix);
|
|
38
45
|
const before=lines[line].slice(0,col-prefix.length);
|
|
39
46
|
let after=lines[line].slice(col);if(after.startsWith('"'))after=after.slice(1);
|
|
@@ -43,10 +50,23 @@ export class FileAutocomplete extends CombinedAutocompleteProvider{
|
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
export class TerminalView{
|
|
46
|
-
constructor({baseDir,terminal=new ProcessTerminal()}={}){
|
|
47
|
-
this.baseDir=baseDir;this.terminal=terminal;this.tui=new TUI(terminal);this.logs=[];
|
|
53
|
+
constructor({baseDir,previewLabel,terminal=new ProcessTerminal()}={}){
|
|
54
|
+
this.baseDir=baseDir;this.terminal=terminal;this.tui=new TUI(terminal);this.logs=[];this.runViews=new Map();
|
|
55
|
+
this.historyOffset=0;this.commandHistory=[];this.commandHistoryIndex=null;this.feed=new RunFeed(()=>this.refreshTranscript());
|
|
48
56
|
const originalStart=terminal.start.bind(terminal);
|
|
49
57
|
terminal.start=(input,resize)=>originalStart(data=>{
|
|
58
|
+
if(this.homeInput&&!this.runViewer&&!this.logViewer){
|
|
59
|
+
if(this.browseCommands(data))return;
|
|
60
|
+
if(matchesKey(data,'ctrl+o')){this.feed.expanded=!this.feed.expanded;this.historyOffset=0;this.refreshTranscript();return;}
|
|
61
|
+
const wheel=mouseWheel(data),page=Math.max(1,Math.floor(this.terminal.rows/2));
|
|
62
|
+
if(wheel||matchesKey(data,'pageUp')||matchesKey(data,'pageDown')){
|
|
63
|
+
this.historyOffset=Math.max(0,this.historyOffset+(wheel?-wheel*3:matchesKey(data,'pageUp')?page:-page));this.tui.requestRender();return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if(matchesKey(data,'f4')&&this.homeInput&&this.pending&&!this.runViewer&&!this.logViewer){
|
|
67
|
+
// 快捷入口不提交或丢弃当前草稿,返回首页时继续编辑。
|
|
68
|
+
this.homeDraft=this.editor.getText();this.pending.resolve('/logs');return;
|
|
69
|
+
}
|
|
50
70
|
if(matchesKey(data,'ctrl+c')){if(this.runViewer){this.runViewer.onClose();return;}if(this.cancelRun)this.cancelRun();this.pending?.reject(cancelled());return;}
|
|
51
71
|
if(matchesKey(data,'escape')&&this.pending&&!this.logViewer&&!this.runViewer){
|
|
52
72
|
// 编辑器自己处理补全菜单 Escape,第二次 Escape 返回上一级。
|
|
@@ -56,47 +76,99 @@ export class TerminalView{
|
|
|
56
76
|
input(data);
|
|
57
77
|
},resize);
|
|
58
78
|
this.header=new Text('',1,0);this.transcript=new Text('',1,0);this.prompt=new Container();this.hint=new Text('',1,0);
|
|
59
|
-
this.hint.render=width=>['',fitLine(' '+palette.muted(width<45?'↑↓ 选择 Enter 确认 Esc 返回':'↑↓ 选择 Enter 确认 Tab 补全 Esc 返回 Ctrl+C 退出'),width)];
|
|
79
|
+
this.hint.render=width=>['',fitLine(' '+palette.muted(this.homeInput?(width<45?`/ 操作 · Ctrl+O ${this.feed.expanded?'收起':'展开'}`:`/ 操作 · Ctrl+O ${this.feed.expanded?'收起':'展开'}详情 · /logs 回看 · Ctrl+C 退出`):(width<45?'↑↓ 选择 Enter 确认 Esc 返回':'↑↓ 选择 Enter 确认 Tab 补全 Esc 返回 Ctrl+C 退出')),width)];
|
|
60
80
|
const renderTranscript=this.transcript.render.bind(this.transcript);
|
|
61
81
|
this.headingLines=(width,mark='FISHtools')=>{
|
|
62
82
|
const room=Math.max(1,width-2),lines=[''];
|
|
83
|
+
const summary=(this.summaryText??'').split('\n');
|
|
84
|
+
const workspace=summary.find(line=>line.startsWith('当前工作区:'));
|
|
85
|
+
const details=summary.filter(line=>line!==workspace&&!line.startsWith('[F4')&&!line.startsWith('欢迎使用'));
|
|
63
86
|
if(mark===logo){
|
|
64
|
-
|
|
87
|
+
const side=[palette.text('FISHtools')+' '+palette.muted(`v${VERSION}`),palette.text('欢迎使用 · 插件编排工作台'),palette.muted(details[0]??'')];
|
|
88
|
+
lines.push(...logo.split('\n').map((line,i)=>' '+palette.brand(line)+' '+side[i]));
|
|
65
89
|
}else lines.push(' '+palette.brand('FISH')+palette.text('tools')+' '+palette.muted(`v${VERSION}`));
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}else lines.push(' '+(i===0?palette.text:palette.muted)(fitLine(line,room)));
|
|
90
|
+
if(previewLabel)lines.push(' '+palette.brand(safe(previewLabel)));
|
|
91
|
+
if(workspace){
|
|
92
|
+
const label='当前工作区:',folder=shortenPath(workspace.slice(label.length),Math.max(1,room-visibleWidth(label)));
|
|
93
|
+
lines.push(' '+palette.muted(label)+palette.text(folder));
|
|
71
94
|
}
|
|
95
|
+
lines.push(' '+palette.muted('位置:')+palette.brand('主面板')+(this.commandLocation?palette.muted(' › ')+palette.text(this.commandLocation):''));
|
|
96
|
+
if(this.activeRuns?.())lines.push(' '+palette.muted(`${this.activeRuns()} 个运行中 · /logs 查看详情`));
|
|
72
97
|
lines.push('');return lines.map(line=>fitLine(line,width));
|
|
73
98
|
};
|
|
74
99
|
// 先保证输入与菜单可见,空间充足才展示三行字标。
|
|
75
100
|
this.header.render=width=>{
|
|
76
101
|
const full=this.headingLines(width,logo),other=renderTranscript(width).length+this.prompt.render(width).length+this.hint.render(width).length;
|
|
77
|
-
|
|
102
|
+
let lines=width>=55&&this.terminal.rows>=18&&full.length+other<=this.terminal.rows&&!this.feed.entries.length?full:this.headingLines(width);
|
|
103
|
+
const budget=Math.max(0,this.terminal.rows-this.prompt.render(width).length-this.hint.render(width).length-1);
|
|
104
|
+
if(lines.length>budget)lines=lines.filter(line=>safe(line).trim());
|
|
105
|
+
if(lines.length>budget)lines=lines.slice(0,budget);
|
|
106
|
+
return lines;
|
|
78
107
|
};
|
|
79
108
|
this.transcript.render=width=>{
|
|
80
109
|
const lines=renderTranscript(width),budget=Math.max(0,this.terminal.rows-this.header.render(width).length-this.prompt.render(width).length-this.hint.render(width).length);
|
|
81
|
-
if(lines.length<=budget)return lines;
|
|
110
|
+
if(lines.length<=budget){this.historyOffset=0;return lines;}
|
|
82
111
|
if(!budget)return [];
|
|
112
|
+
if(this.feed.entries.length){
|
|
113
|
+
const count=Math.max(0,budget-1),max=Math.max(0,lines.length-count);this.historyOffset=Math.min(this.historyOffset,max);
|
|
114
|
+
const end=lines.length-this.historyOffset,start=Math.max(0,end-count);
|
|
115
|
+
return [...lines.slice(start,end),fitLine(' '+palette.muted(`PgUp/PgDn/滚轮 查看过程 · ${this.historyOffset?'历史位置':'跟随最新'}`),width)];
|
|
116
|
+
}
|
|
83
117
|
const excerpt=this.logs.length<=1?lines.slice(0,budget-1):lines.slice(-(budget-1)||lines.length);
|
|
84
118
|
return [...excerpt,fitLine(' '+palette.muted('… /help 完整帮助 · /logs 运行日志'),width)];
|
|
85
119
|
};
|
|
86
120
|
this.tui.addChild(this.header);this.tui.addChild(this.transcript);this.tui.addChild(this.prompt);this.tui.addChild(this.hint);
|
|
87
121
|
}
|
|
88
|
-
start(){
|
|
122
|
+
start(){
|
|
123
|
+
if(this.started)return;this.started=true;
|
|
124
|
+
// 独立屏幕避免全屏阅读与主终端的滚动历史混在一起。
|
|
125
|
+
this.terminal.write('\x1b[?1049h\x1b[2J\x1b[H');this.tui.start();
|
|
126
|
+
}
|
|
127
|
+
browseCommands(data){
|
|
128
|
+
const direction=matchesKey(data,'up')?-1:matchesKey(data,'down')?1:0;
|
|
129
|
+
// 补全菜单保留方向键;多行编辑保留光标移动,不把配置值放进命令历史。
|
|
130
|
+
if(!direction||!this.editor||this.editor.isShowingAutocomplete()||this.editor.getText().includes('\n')||!this.commandHistory.length)return false;
|
|
131
|
+
const current=this.commandHistoryIndex??this.commandHistory.length;
|
|
132
|
+
const next=Math.max(0,Math.min(this.commandHistory.length,current+direction));
|
|
133
|
+
if(next===current)return true;
|
|
134
|
+
if(this.commandHistoryIndex===null)this.commandHistoryDraft=this.editor.getText();
|
|
135
|
+
this.commandHistoryIndex=next;
|
|
136
|
+
this.editor.setText(next===this.commandHistory.length?this.commandHistoryDraft:this.commandHistory[next]);
|
|
137
|
+
if(next===this.commandHistory.length)this.commandHistoryIndex=null;
|
|
138
|
+
this.tui.requestRender();return true;
|
|
139
|
+
}
|
|
89
140
|
async close(){
|
|
141
|
+
if(this.closed)return;this.closed=true;
|
|
142
|
+
this.feed.close();
|
|
90
143
|
// Pi 的渲染排在 nextTick;先排空最后一帧,再恢复终端,避免退出后隐藏光标。
|
|
91
144
|
await new Promise(resolve=>process.nextTick(resolve));this.tui.stop();
|
|
145
|
+
if(this.started)this.terminal.write(mouseOff+'\x1b[?1049l');
|
|
92
146
|
if(this.terminal instanceof ProcessTerminal)process.stdin.pause();
|
|
93
147
|
}
|
|
94
148
|
summary(text){this.summaryText=safe(text);this.tui.requestRender();}
|
|
95
|
-
|
|
149
|
+
beginCommand(text){
|
|
150
|
+
const name=text.trim().split(/\s+/)[0].replace(/^\//,'');
|
|
151
|
+
const labels={workflows:'工作流',run:'运行工作流',plugin:'插件管理',plugins:'插件管理',config:'工具配置',pack:'打包',logs:'执行记录',runs:'执行记录',tools:'工具',help:'帮助',exit:'退出'};
|
|
152
|
+
this.commandLocation=labels[name]??'/'+name;this.historyOffset=0;
|
|
153
|
+
this.clearMessages();this.feed.message('› '+safe(text));
|
|
154
|
+
}
|
|
155
|
+
endCommand(){
|
|
156
|
+
this.commandLocation=null;this.tui.requestRender();
|
|
157
|
+
}
|
|
158
|
+
transcriptText(){return [...this.logs,this.feed.text()].filter(Boolean).join('\n\n');}
|
|
159
|
+
refreshTranscript(){
|
|
160
|
+
const width=this.terminal.columns,previous=this.transcript.render===undefined?0:new Text(safe(this.lastTranscript??''),1,0).render(width).length;
|
|
161
|
+
const text=safe(this.transcriptText());
|
|
162
|
+
if(this.historyOffset>0)this.historyOffset=Math.max(0,this.historyOffset+new Text(text,1,0).render(width).length-previous);
|
|
163
|
+
this.lastTranscript=text;this.transcript.setText(text.split('\n').map(line=>line.startsWith('✗ ')?palette.error(line):/^[›✓●] /.test(line)?palette.brand(line):line.startsWith('↩ ')?palette.muted(line):line).join('\n'));this.tui.requestRender();
|
|
164
|
+
}
|
|
165
|
+
trackRun(options){this.feed.track(options);}
|
|
166
|
+
clearMessages(){this.logs=[];this.refreshTranscript();}
|
|
96
167
|
show(message,{kind='message'}={}){
|
|
97
168
|
const text=safe(message).slice(0,6000);
|
|
98
169
|
const formatted=kind==='welcome'?text.split('\n').map((line,i)=>i===0?palette.text(line):palette.muted(line)).join('\n'):text;
|
|
99
|
-
|
|
170
|
+
if(kind!=='welcome'){this.feed.message(formatted);return;}
|
|
171
|
+
this.logs.push(formatted);this.logs=this.logs.slice(-6);this.refreshTranscript();
|
|
100
172
|
}
|
|
101
173
|
async viewMarkdown(title,text){
|
|
102
174
|
const previousPending=this.pending??null,previousViewer=this.logViewer;
|
|
@@ -118,29 +190,42 @@ export class TerminalView{
|
|
|
118
190
|
this.terminal.write(mouseOff);
|
|
119
191
|
}
|
|
120
192
|
}
|
|
121
|
-
async observeRun({app,runId,flow,workflows=[]}){
|
|
193
|
+
async observeRun({app,runId,record,flow,workflows=[],initialView}){
|
|
122
194
|
const previousPending=this.pending??null,children=this.tui.children;let overlay,unsubscribe;
|
|
195
|
+
const initial=app.getRun(runId)??record??await app.readRun(runId);
|
|
196
|
+
if(!initial)throw new Error('这次运行记录已不存在');
|
|
123
197
|
try{
|
|
124
198
|
await new Promise((resolve,reject)=>{
|
|
125
199
|
this.pending={resolve,reject};
|
|
126
|
-
this.runViewer=new RunViewer({record:app.
|
|
200
|
+
this.runViewer=new RunViewer({record:initial,flow:initial.status==='running'?flow:undefined,workflows,catalog:app.catalog?.()??[],terminal:this.terminal,onClose:resolve,onCancel:()=>app.cancelRun(runId),requestRender:()=>this.tui.requestRender()});
|
|
201
|
+
Object.assign(this.runViewer,this.runViews.get(runId)??{});
|
|
202
|
+
if(initialView!==undefined){this.runViewer.view=initialView;this.runViewer.offset=0;}
|
|
127
203
|
unsubscribe=app.subscribe(event=>{if(event.runId===runId)this.runViewer?.update(app.getRun(runId));});
|
|
128
204
|
this.tui.children=[];this.terminal.write(mouseOn);
|
|
129
205
|
overlay=this.tui.showOverlay(this.runViewer,{width:'100%',maxHeight:'100%',anchor:'top-left',margin:0});this.tui.requestRender(true);
|
|
130
206
|
});
|
|
131
207
|
}finally{
|
|
208
|
+
if(this.runViewer){const {view,selected,offset,follow,allLogs,raw}=this.runViewer;this.runViews.set(runId,{view,selected,offset,follow,allLogs,raw});}
|
|
209
|
+
if(this.runViews.size>30)this.runViews.delete(this.runViews.keys().next().value);
|
|
132
210
|
unsubscribe?.();this.runViewer=null;this.tui.children=children;this.pending=previousPending;overlay?.hide();this.terminal.write(mouseOff);this.tui.requestRender(true);
|
|
133
211
|
}
|
|
134
212
|
}
|
|
135
213
|
async ask(message,component){
|
|
136
214
|
fit(component);
|
|
137
|
-
const title=new Text(palette.text(safe(message)),1,0);
|
|
138
|
-
|
|
215
|
+
const title=new Text(palette.brand('› ')+palette.text(safe(message)),1,0);
|
|
216
|
+
const renderTitle=title.render.bind(title);
|
|
217
|
+
title.render=width=>[fitLine(' '+palette.border('─'.repeat(Math.max(1,width-2))),width),...renderTitle(width)];
|
|
218
|
+
if(message.startsWith('fst >'))title.render=()=>[];
|
|
139
219
|
this.prompt.clear();this.prompt.addChild(title);this.prompt.addChild(component);this.tui.setFocus(component);this.tui.requestRender();
|
|
140
|
-
try{return await new Promise((resolve,reject)=>{this.pending={resolve,reject};component.onSubmit=
|
|
141
|
-
|
|
220
|
+
try{return await new Promise((resolve,reject)=>{this.pending={resolve,reject};component.onSubmit=text=>{
|
|
221
|
+
if(this.homeInput&&text.trim()&&this.commandHistory.at(-1)!==text.trim()){
|
|
222
|
+
this.commandHistory.push(text.trim());if(this.commandHistory.length>100)this.commandHistory.shift();
|
|
223
|
+
}
|
|
224
|
+
resolve(text);
|
|
225
|
+
};component.onSelect=item=>resolve(item.value);component.onCancel=()=>reject(cancelled());});}
|
|
226
|
+
finally{if(this.homeInput)this.terminal.write(mouseOff);this.pending=null;this.editor=null;this.homeInput=false;this.prompt.clear();this.tui.requestRender();}
|
|
142
227
|
}
|
|
143
|
-
input(message,{value='',commands=[],files=true}={}){
|
|
228
|
+
input(message,{value='',commands=[],files=true,workflows=[]}={}){
|
|
144
229
|
const editor=new Editor(this.tui,{borderColor:palette.border,selectList:theme},{paddingX:1});
|
|
145
230
|
const renderEditor=editor.render.bind(editor);
|
|
146
231
|
editor.render=width=>{
|
|
@@ -153,15 +238,22 @@ export class TerminalView{
|
|
|
153
238
|
return ' '+palette.border('│')+line+' '.repeat(Math.max(0,inner-visibleWidth(line)))+palette.border('│');
|
|
154
239
|
});
|
|
155
240
|
};
|
|
156
|
-
this.
|
|
157
|
-
|
|
241
|
+
this.homeInput=message.startsWith('fst >');
|
|
242
|
+
this.commandHistoryIndex=null;
|
|
243
|
+
if(this.homeInput)editor.onChange=text=>{
|
|
244
|
+
if(this.commandHistoryIndex!==null&&text!==this.commandHistory[this.commandHistoryIndex])this.commandHistoryIndex=null;
|
|
245
|
+
};
|
|
246
|
+
if(this.homeInput)this.terminal.write(mouseOn);
|
|
247
|
+
this.editor=editor;editor.setText(this.homeInput?(this.homeDraft??value):value);
|
|
248
|
+
if(this.homeInput)this.homeDraft=undefined;
|
|
249
|
+
if(files||commands.length)editor.setAutocompleteProvider(new FileAutocomplete(commands,this.baseDir,workflows));
|
|
158
250
|
return this.ask(message,editor);
|
|
159
251
|
}
|
|
160
|
-
select(message,items){
|
|
252
|
+
async select(message,items){
|
|
161
253
|
if(!items.length)throw new Error('当前没有可选择的项目');
|
|
162
254
|
const list=new SelectList(items.map(i=>({...i,label:safe(i.label).replace(/\s+/g,' '),description:i.description?safe(i.description).replace(/\s+/g,' '):undefined})),8,theme);
|
|
163
255
|
list.render=width=>{
|
|
164
|
-
const reserved=this.headingLines(width).length+new Text(palette.text(safe(message)),1,0).render(width).length+this.hint.render(width).length+
|
|
256
|
+
const reserved=this.headingLines(width).length+new Text(palette.text(safe(message)),1,0).render(width).length+this.hint.render(width).length+2;
|
|
165
257
|
list.maxVisible=Math.max(1,Math.min(8,this.terminal.rows-reserved));
|
|
166
258
|
const start=Math.max(0,Math.min(list.selectedIndex-Math.floor(list.maxVisible/2),list.filteredItems.length-list.maxVisible));
|
|
167
259
|
const end=Math.min(start+list.maxVisible,list.filteredItems.length),lines=[];
|
|
@@ -176,7 +268,12 @@ export class TerminalView{
|
|
|
176
268
|
if(start>0||end<list.filteredItems.length)lines.push(palette.muted(` ${list.selectedIndex+1} / ${list.filteredItems.length}`));
|
|
177
269
|
return lines.length?lines:[palette.muted(' 没有匹配项')];
|
|
178
270
|
};
|
|
179
|
-
|
|
271
|
+
const value=await this.ask(message,list);
|
|
272
|
+
if(this.commandLocation){
|
|
273
|
+
const item=items.find(item=>item.value===value);
|
|
274
|
+
if(item&&value!=='back'&&value!=='cancel')this.feed.message(`· ${safe(item.label??item.value)}`);
|
|
275
|
+
}
|
|
276
|
+
return value;
|
|
180
277
|
}
|
|
181
278
|
async confirm(message){return await this.select(message,[{value:'no',label:'否,返回'},{value:'yes',label:'是,继续'}])==='yes';}
|
|
182
279
|
busy(message,cancel){
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export async function workflowMenu(session){
|
|
2
|
+
const {app,ui}=session;
|
|
3
|
+
while(true){
|
|
4
|
+
const workflows=await app.listWorkflows();
|
|
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} 步`})),
|
|
7
|
+
...(!app.project.readOnly?[{value:'new',label:'+ 新建工作流'}]:[]),
|
|
8
|
+
...(session.dirty?[{value:'draft',label:`继续编辑草稿:${session.flow.title}`}]:[]),
|
|
9
|
+
{value:'back',label:'返回主面板'},
|
|
10
|
+
]);
|
|
11
|
+
if(choice==='back')return;
|
|
12
|
+
try{
|
|
13
|
+
const id=choice.startsWith('workflow:')?choice.slice(9):null;
|
|
14
|
+
if(choice==='new'){if(await session.execute('/new')===false)continue;}
|
|
15
|
+
else if(choice!=='draft'&&session.flow?.id!==id){
|
|
16
|
+
if(!await session.discard())continue;
|
|
17
|
+
session.flow=structuredClone(workflows.find(flow=>flow.id===id));session.dirty=false;
|
|
18
|
+
}
|
|
19
|
+
if(await editWorkflow(session)==='running')return;
|
|
20
|
+
}catch(error){if(error.code!=='PROMPT_CANCELLED')throw error;}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function editWorkflow(session){
|
|
25
|
+
const {app,ui}=session;
|
|
26
|
+
while(true){
|
|
27
|
+
const flow=session.current();
|
|
28
|
+
const choice=await ui.select(`${flow.title??flow.id} · ${flow.steps.length} 步 · ${session.dirty?'未保存':'已保存'}`, [
|
|
29
|
+
{value:'run',label:'运行工作流'},
|
|
30
|
+
...(!app.project.readOnly?[
|
|
31
|
+
{value:'add',label:'添加步骤 / 控制流'}, {value:'steps',label:'编辑步骤、参数与顺序'},
|
|
32
|
+
{value:'outputs',label:'设置最终输出'},{value:'save',label:'保存工作流'},
|
|
33
|
+
{value:'delete',label:'删除工作流(保留可恢复备份)'},
|
|
34
|
+
]:[{value:'steps',label:'查看步骤'}]),
|
|
35
|
+
{value:'back',label:'返回工作流列表(保留草稿)'},
|
|
36
|
+
]);
|
|
37
|
+
if(choice==='back')return;
|
|
38
|
+
try{
|
|
39
|
+
if(choice==='delete'){
|
|
40
|
+
if(!await ui.confirm(`删除 ${flow.title??flow.id}?已保存文件会移入备份,未保存修改将放弃。`))continue;
|
|
41
|
+
const exists=(await app.listWorkflows()).some(w=>w.id===flow.id);
|
|
42
|
+
const backup=exists?await app.removeWorkflow(flow.id):null;
|
|
43
|
+
session.flow=null;session.dirty=false;ui.show(backup?`工作流已移入备份:${backup}`:'未保存的草稿已删除。');return;
|
|
44
|
+
}
|
|
45
|
+
if(choice==='run'){
|
|
46
|
+
if(await session.startRun([],{draft:true}))return 'running';
|
|
47
|
+
}else await session.execute('/'+choice);
|
|
48
|
+
}catch(error){if(error.code!=='PROMPT_CANCELLED')throw error;}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -1,27 +1,42 @@
|
|
|
1
1
|
import Ajv from 'ajv';
|
|
2
2
|
import { FlowError } from '@mytools/sdk';
|
|
3
3
|
|
|
4
|
-
const ajv = new Ajv({ allErrors: true, strict: false
|
|
5
|
-
const validators = new WeakMap();
|
|
6
|
-
const booleanValidators = new Map();
|
|
4
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
5
|
+
const validators = {true:new WeakMap(),false:new WeakMap()};
|
|
6
|
+
const booleanValidators = {true:new Map(),false:new Map()};
|
|
7
7
|
export const emptyObject = { type: 'object', properties: {}, additionalProperties: false };
|
|
8
8
|
|
|
9
|
-
export function assertSchema(schema,label) {
|
|
9
|
+
export function assertSchema(schema,label,{useDefaults=true}={}) {
|
|
10
10
|
if (!(typeof schema==='boolean'||schema&&typeof schema==='object'&&!Array.isArray(schema))) throw new FlowError(`${label} 必须声明有效的 JSON Schema`,{code:'VALIDATION'});
|
|
11
|
-
const cache=typeof schema==='boolean'?booleanValidators:validators;
|
|
11
|
+
const cache=(typeof schema==='boolean'?booleanValidators:validators)[Boolean(useDefaults)];
|
|
12
12
|
let check=cache.get(schema);
|
|
13
13
|
if(!check){
|
|
14
|
-
|
|
14
|
+
// 每份定义独立解析 $id/$ref,避免插件重载或不同插件共用 ID 时串用约束。
|
|
15
|
+
// 编译结果仍按对象缓存;注册表随校验器释放,不污染后续安装批次。
|
|
16
|
+
try{check=new Ajv({allErrors:true,strict:false,useDefaults:Boolean(useDefaults)}).compile(schema);cache.set(schema,check);}
|
|
15
17
|
catch(cause){throw new FlowError(`${label} JSON Schema 无效:${cause.message}`,{code:'VALIDATION',cause});}
|
|
16
18
|
}
|
|
17
19
|
return check;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
const pointerToken=value=>String(value).replaceAll('~','~0').replaceAll('/','~1');
|
|
23
|
+
function diagnostic(error){
|
|
24
|
+
let instancePath=error.instancePath;
|
|
25
|
+
if(error.keyword==='required')instancePath+=`/${pointerToken(error.params.missingProperty)}`;
|
|
26
|
+
if(error.keyword==='additionalProperties')instancePath+=`/${pointerToken(error.params.additionalProperty)}`;
|
|
27
|
+
// 保留 Ajv 原有 instancePath 语义,新字段提供指向缺失/额外属性的完整位置。
|
|
28
|
+
return {...error,jsonPointer:instancePath};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function validate(schema, value, label,{useDefaults=true}={}) {
|
|
21
32
|
if (schema==null) return structuredClone(value);
|
|
22
|
-
const check = assertSchema(schema,label);
|
|
33
|
+
const check = assertSchema(schema,label,{useDefaults});
|
|
23
34
|
const copy = structuredClone(value);
|
|
24
|
-
if (!check(copy))
|
|
35
|
+
if (!check(copy)) {
|
|
36
|
+
const details=check.errors.map(diagnostic);
|
|
37
|
+
const locations=details.map(error=>`${error.jsonPointer||'/'} (${error.schemaPath})`).join(';');
|
|
38
|
+
throw new FlowError(`${label}: ${ajv.errorsText(check.errors, { separator: ';' })};位置 ${locations}`, { code: 'VALIDATION', details });
|
|
39
|
+
}
|
|
25
40
|
return copy;
|
|
26
41
|
}
|
|
27
42
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// 由 npm version 的生命周期脚本同步,内部插件 API 版本独立维护。
|
|
2
|
-
export const VERSION="0.
|
|
2
|
+
export const VERSION="0.4.0";
|
|
@@ -4,22 +4,9 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import YAML from 'yaml';
|
|
5
5
|
import { FlowError } from '@mytools/sdk';
|
|
6
6
|
import { assertId, assertSchema, references, referenceParts, emptyObject } from './validation.js';
|
|
7
|
+
import {resolveSchemaPath} from './schema-path.js';
|
|
7
8
|
import {validateCondition,validateStepList} from './control.js';
|
|
8
9
|
|
|
9
|
-
function fieldSchema(schema,fields,ref){
|
|
10
|
-
let current=schema;
|
|
11
|
-
for(const field of fields){
|
|
12
|
-
if(!current||current===true)return {};
|
|
13
|
-
if(current.properties&&Object.hasOwn(current.properties,field)){current=current.properties[field];continue;}
|
|
14
|
-
if(current.type==='array'&&/^\d+$/.test(field)){current=current.items;continue;}
|
|
15
|
-
if(current.additionalProperties===false||current===false||(current.type&&!['object','array'].includes(current.type))){
|
|
16
|
-
throw new FlowError(`引用字段不存在:${ref}`,{code:'VALIDATION'});
|
|
17
|
-
}
|
|
18
|
-
return {};
|
|
19
|
-
}
|
|
20
|
-
return current??{};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
10
|
export class WorkflowStore {
|
|
24
11
|
constructor(root) { this.root = path.resolve(root); }
|
|
25
12
|
file(id) { assertId(id); return path.join(this.root,`${id}.yaml`); }
|
|
@@ -51,6 +38,17 @@ export class WorkflowStore {
|
|
|
51
38
|
const files=await readdir(this.root);
|
|
52
39
|
return Promise.all(files.filter(f=>f.endsWith('.yaml')).sort().map(f=>this.get(f.slice(0,-5))));
|
|
53
40
|
}
|
|
41
|
+
async remove(id){
|
|
42
|
+
await this.get(id);
|
|
43
|
+
const refers=steps=>steps.some(step=>step.workflow&&this.childId(step.workflow)===id||step.branch&&(refers(step.branch.then)||refers(step.branch.else??[]))||step.parallel&&refers(step.parallel.steps));
|
|
44
|
+
const dependents=(await this.list()).filter(flow=>flow.id!==id&&refers(flow.steps));
|
|
45
|
+
if(dependents.length)throw new FlowError(`工作流仍被引用:${dependents.map(flow=>flow.title??flow.id).join('、')}`);
|
|
46
|
+
const directory=path.join(path.dirname(this.root),'.flow','deleted-workflows');
|
|
47
|
+
await mkdir(directory,{recursive:true});
|
|
48
|
+
const backup=path.join(directory,`${id}-${randomUUID()}.yaml`);
|
|
49
|
+
// 同项目内原子移动,备份保留完整原文;复制回原文件名即可恢复。
|
|
50
|
+
await rename(this.file(id),backup);return backup;
|
|
51
|
+
}
|
|
54
52
|
childId(reference) {
|
|
55
53
|
if(typeof reference!=='string' || !/^(\.\/)?[A-Za-z0-9][A-Za-z0-9_-]*\.yaml$/.test(reference)) throw new FlowError(`子流程必须位于工作流目录内:${reference}`,{code:'VALIDATION'});
|
|
56
54
|
return reference.replace(/^\.\//,'').slice(0,-5);
|
|
@@ -65,7 +63,7 @@ export class WorkflowStore {
|
|
|
65
63
|
const checker=previous=>ref=>{
|
|
66
64
|
const p=referenceParts(ref);
|
|
67
65
|
if(p[0]==='steps' && !previous.has(p[1])) throw new FlowError(`${id} 引用必须指向前序步骤:${ref}`,{code:'VALIDATION'});
|
|
68
|
-
return
|
|
66
|
+
return resolveSchemaPath(p[0]==='inputs'?(w.inputs??emptyObject):previous.get(p[1]),p.slice(p[0]==='inputs'?1:3),ref);
|
|
69
67
|
};
|
|
70
68
|
const resolveList=async(steps,inherited=new Map(),parallel=false)=>{
|
|
71
69
|
const previous=new Map(inherited),own={};
|
|
@@ -24,7 +24,12 @@ export async function collectPackages(roots) {
|
|
|
24
24
|
}
|
|
25
25
|
if(!trees.has(base))trees.set(base,await new Arborist({path:base}).loadActual());
|
|
26
26
|
const tree=trees.get(base);
|
|
27
|
-
|
|
27
|
+
let found=[...tree.inventory.values()].find(n=>path.relative(n.realpath,root)==='' && !n.isLink);
|
|
28
|
+
// 开发引用可能嵌套在无关项目下,其上层 lock 不代表当前插件属于该依赖树。
|
|
29
|
+
if(!found&&base!==root){
|
|
30
|
+
if(!trees.has(root))trees.set(root,await new Arborist({path:root}).loadActual());
|
|
31
|
+
found=[...trees.get(root).inventory.values()].find(n=>path.relative(n.realpath,root)===''&&!n.isLink);
|
|
32
|
+
}
|
|
28
33
|
if(!found)throw new FlowError(`无法解析包的生产依赖:${root}`);
|
|
29
34
|
return found;
|
|
30
35
|
}
|
|
@@ -66,6 +71,8 @@ export async function copyPackages(nodes,destination,target,{filters=new Map()}=
|
|
|
66
71
|
const filter=filters.get(packageRoot);
|
|
67
72
|
const files=[...new Set([...await packlist(await new Arborist({path:packageRoot}).loadActual()),...(filter?.extraFiles??[])])];
|
|
68
73
|
for(const file of files){
|
|
74
|
+
// 项目可以与开发插件共用根目录;宿主私有状态和安装批次永远不进入插件快照。
|
|
75
|
+
if(file.split(/[\\/]/)[0]==='.flow')continue;
|
|
69
76
|
if(filter&&!filter.include(file))continue;
|
|
70
77
|
const source=path.resolve(packageRoot,file),physical=await realpath(source);
|
|
71
78
|
// 两端先解析实际路径,再按系统规则比较,兼容 Windows 大小写、短路径和目录联接。
|
|
@@ -26,10 +26,12 @@ async function prepare(app,{entries,target,includeWeb=false,portable=false,envir
|
|
|
26
26
|
if(!portable&&!entries?.length)throw new FlowError('请至少选择一个工作流');
|
|
27
27
|
if(!['win32-x64','linux-x64-glibc'].includes(target))throw new FlowError('请选择支持的目标平台');
|
|
28
28
|
const closure=await app.store.resolve(entries,app.host);
|
|
29
|
-
const
|
|
29
|
+
const webProvider=[...app.host.definitions.values()].find(plugin=>typeof plugin.services?.web==='function');
|
|
30
|
+
const ids=new Set(portable?[...app.host.definitions.keys()].filter(id=>id!==webProvider?.id||includeWeb):closure.plugins);
|
|
30
31
|
for(const id of closure.plugins)ids.add(id);
|
|
32
|
+
if(includeWeb){if(!webProvider)throw new FlowError('未安装提供 Web 服务的插件');ids.add(webProvider.id);}
|
|
33
|
+
// Web 与其他入口使用相同的依赖闭包,包含提供者自身的传递依赖。
|
|
31
34
|
for(const id of ids)for(const dependency of Object.keys(app.host.definitions.get(id)?.dependencies??{}))ids.add(dependency);
|
|
32
|
-
if(includeWeb){if(!app.host.definitions.has('@mytools/web'))throw new FlowError('未安装官方 Web 插件');ids.add('@mytools/web');}
|
|
33
35
|
const plugins=[...ids].map(id=>app.host.definitions.get(id));
|
|
34
36
|
if(plugins.some(p=>!p.root))throw new FlowError('内存中临时定义的插件不能打包,请先安装为标准包');
|
|
35
37
|
const overrides=structuredClone(app.project.environments??{});
|