@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.
Files changed (83) hide show
  1. package/README.md +43 -17
  2. package/manual/README.md +43 -17
  3. package/manual/docs/examples/pipeline-reader/LICENSE +21 -0
  4. package/manual/docs/examples/pipeline-reader/index.js +14 -0
  5. package/manual/docs/examples/pipeline-reader/package.json +7 -0
  6. package/manual/docs/examples/pipeline-writer/LICENSE +21 -0
  7. package/manual/docs/examples/pipeline-writer/index.js +17 -0
  8. package/manual/docs/examples/pipeline-writer/package.json +7 -0
  9. package/manual/docs/examples/script-plugin/LICENSE +21 -0
  10. package/manual/docs/examples/script-plugin/README.md +64 -0
  11. package/manual/docs/examples/script-plugin/index.js +39 -0
  12. package/manual/docs/examples/script-plugin/package.json +9 -0
  13. package/manual/docs/examples/script-plugin/scripts/benchmark.py +23 -0
  14. package/manual/docs/plugin-development.md +75 -14
  15. package/manual/docs/releases.md +30 -1
  16. package/manual/docs/research/2026-10-11-plugin-kernel-capabilities.md +167 -0
  17. package/manual/docs/roadmap.md +8 -1
  18. package/manual/docs/superpowers/plans/2026-10-11-control-observation.md +1 -1
  19. package/manual/docs/superpowers/plans/2026-10-11-public-plugin-fixes.md +40 -0
  20. package/manual/docs/superpowers/plans/2026-10-11-terminal-hierarchy.md +40 -0
  21. package/manual/docs/superpowers/plans/2026-10-12-plugin-developer-experience.md +50 -0
  22. package/manual/docs/superpowers/reviews/2026-10-12-plugin-release-review.md +20 -0
  23. package/manual/docs/superpowers/specs/2026-10-11-collapsed-plugin-output.md +23 -0
  24. package/manual/docs/superpowers/specs/2026-10-12-plugin-developer-experience.md +22 -0
  25. package/manual/docs/verification.md +100 -0
  26. package/manual/packages/core/src/application.js +235 -0
  27. package/manual/packages/core/src/engine.js +93 -0
  28. package/manual/packages/core/src/host.js +22 -5
  29. package/manual/packages/core/src/runtime.js +135 -0
  30. package/manual/packages/core/src/terminal/run-feed.js +78 -0
  31. package/manual/packages/core/src/terminal/session.js +438 -0
  32. package/manual/packages/core/src/terminal/view.js +283 -0
  33. package/manual/tests/application.test.js +30 -0
  34. package/node_modules/@mytools/core/bin/fst.js +27 -5
  35. package/node_modules/@mytools/core/package.json +2 -0
  36. package/node_modules/@mytools/core/src/application.js +46 -21
  37. package/node_modules/@mytools/core/src/contracts.js +27 -9
  38. package/node_modules/@mytools/core/src/documentation.js +1 -1
  39. package/node_modules/@mytools/core/src/engine.js +1 -1
  40. package/node_modules/@mytools/core/src/environments.js +39 -1
  41. package/node_modules/@mytools/core/src/host.js +22 -5
  42. package/node_modules/@mytools/core/src/markdown-log.js +1 -1
  43. package/node_modules/@mytools/core/src/plugin-installations.js +84 -0
  44. package/node_modules/@mytools/core/src/plugin-tools.js +133 -0
  45. package/node_modules/@mytools/core/src/plugins.js +16 -6
  46. package/node_modules/@mytools/core/src/process.js +30 -8
  47. package/node_modules/@mytools/core/src/schema-path.js +31 -0
  48. package/node_modules/@mytools/core/src/terminal/model.js +1 -1
  49. package/node_modules/@mytools/core/src/terminal/navigation.js +17 -0
  50. package/node_modules/@mytools/core/src/terminal/presentation.js +3 -3
  51. package/node_modules/@mytools/core/src/terminal/run-feed.js +78 -0
  52. package/node_modules/@mytools/core/src/terminal/run-presentation.js +60 -0
  53. package/node_modules/@mytools/core/src/terminal/run-viewer.js +30 -12
  54. package/node_modules/@mytools/core/src/terminal/session.js +118 -50
  55. package/node_modules/@mytools/core/src/terminal/theme.js +1 -0
  56. package/node_modules/@mytools/core/src/terminal/view.js +125 -28
  57. package/node_modules/@mytools/core/src/terminal/workflow-menu.js +50 -0
  58. package/node_modules/@mytools/core/src/validation.js +24 -9
  59. package/node_modules/@mytools/core/src/version.js +1 -1
  60. package/node_modules/@mytools/core/src/workflows.js +13 -15
  61. package/node_modules/@mytools/pack/dependencies.js +8 -1
  62. package/node_modules/@mytools/pack/index.js +4 -2
  63. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +871 -870
  64. package/node_modules/{tar → minipass-flush/node_modules/minipass}/node_modules/yallist/README.md +7 -8
  65. package/node_modules/minipass-flush/node_modules/minipass/node_modules/yallist/package.json +29 -0
  66. package/node_modules/yallist/README.md +8 -7
  67. package/node_modules/yallist/package.json +51 -12
  68. package/package.json +15 -15
  69. package/node_modules/tar/node_modules/yallist/package.json +0 -68
  70. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/LICENSE +0 -0
  71. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/iterator.js +0 -0
  72. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/yallist.js +0 -0
  73. /package/node_modules/{tar/node_modules/yallist → yallist}/LICENSE.md +0 -0
  74. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.d.ts +0 -0
  75. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.d.ts.map +0 -0
  76. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.js +0 -0
  77. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.js.map +0 -0
  78. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/package.json +0 -0
  79. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.d.ts +0 -0
  80. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.d.ts.map +0 -0
  81. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.js +0 -0
  82. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.js.map +0 -0
  83. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/package.json +0 -0
@@ -0,0 +1,283 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { TUI, Text, Container, Editor, SelectList, ProcessTerminal, CombinedAutocompleteProvider, matchesKey, truncateToWidth, visibleWidth } from '@mariozechner/pi-tui';
5
+ import { MarkdownLogViewer } from './log-viewer.js';
6
+ import { RunViewer, mouseOn, mouseOff, mouseWheel } from './run-viewer.js';
7
+ import {RunFeed} from './run-feed.js';
8
+ import { logo } from './presentation.js';
9
+ import { VERSION } from '../version.js';
10
+ import {cleanText as safe,palette,menuTheme as theme,fitLine,shortenPath} from './theme.js';
11
+
12
+ export const cancelled=()=>Object.assign(new Error('已返回主菜单'),{code:'PROMPT_CANCELLED'});
13
+ // 上游菜单按字符数分配描述宽度,中文双宽字符需要再按终端列宽收口。
14
+ function fit(component){const render=component.render.bind(component);component.render=width=>render(width).map(line=>truncateToWidth(line,width));return component;}
15
+
16
+ // Pi 的 @ 模糊搜索依赖额外的 fd 程序。这里按目录补全,离线目标机无需安装 fd。
17
+ export class FileAutocomplete extends CombinedAutocompleteProvider{
18
+ constructor(commands,baseDir,workflows=[]){super(commands,baseDir);this.baseDir=baseDir;this.workflows=workflows;}
19
+ getSuggestions(lines,line,col){
20
+ const run=lines[line].slice(0,col).match(/^\/run\s+([^@"\s]*)$/);
21
+ if(run){
22
+ const query=run[1].toLowerCase(),items=this.workflows.filter(flow=>`${flow.id} ${flow.title??''}`.toLowerCase().includes(query)).map(flow=>({value:flow.id,label:flow.title??flow.id,description:flow.id,workflow:true}));
23
+ return items.length?{prefix:run[1],items}:null;
24
+ }
25
+ const prefix=lines[line].slice(0,col).match(/(?:^|\s)(@"[^"\n]*|@[^\s]*)$/)?.[1];
26
+ if(!prefix)return super.getSuggestions(lines,line,col);
27
+ const raw=prefix.slice(1).replace(/^"/,'').replaceAll('\\','/');
28
+ const expanded=raw.startsWith('~/')?path.join(homedir(),raw.slice(2)):raw;
29
+ const absolute=path.resolve(this.baseDir,expanded||'.');
30
+ const directory=!raw||raw.endsWith('/')?absolute:path.dirname(absolute);
31
+ const query=!raw||raw.endsWith('/')?'':path.basename(absolute).toLowerCase();
32
+ try{
33
+ const items=readdirSync(directory,{withFileTypes:true}).filter(e=>e.name.toLowerCase().startsWith(query)).map(e=>{
34
+ let dir=e.isDirectory();if(e.isSymbolicLink()){try{dir=statSync(path.join(directory,e.name)).isDirectory();}catch{}}
35
+ const head=raw.slice(0,raw.lastIndexOf('/')+1),file=head+e.name+(dir?'/':'');
36
+ return {value:`@${/[\s"]/.test(file)?JSON.stringify(file):file}`,label:safe(e.name)+(dir?'/':''),description:dir?'目录':'文件',directory:dir};
37
+ }).sort((a,b)=>(b.description==='目录')-(a.description==='目录')||a.label.localeCompare(b.label)).slice(0,100);
38
+ return items.length?{prefix,items}:null;
39
+ }catch{return null;}
40
+ }
41
+ getForceFileSuggestions(lines,line,col){return this.getSuggestions(lines,line,col);}
42
+ applyCompletion(lines,line,col,item,prefix){
43
+ if(item.workflow){const next=[...lines],before=lines[line].slice(0,col-prefix.length),text=item.value+' ';next[line]=before+text+lines[line].slice(col);return {lines:next,cursorLine:line,cursorCol:before.length+text.length};}
44
+ if(!prefix.startsWith('@'))return super.applyCompletion(lines,line,col,item,prefix);
45
+ const before=lines[line].slice(0,col-prefix.length);
46
+ let after=lines[line].slice(col);if(after.startsWith('"'))after=after.slice(1);
47
+ const completed=item.value+(item.directory?'':' '),next=[...lines];next[line]=before+completed+after;
48
+ return {lines:next,cursorLine:line,cursorCol:before.length+completed.length-(item.directory&&item.value.endsWith('"')?1:0)};
49
+ }
50
+ }
51
+
52
+ export class TerminalView{
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());
56
+ const originalStart=terminal.start.bind(terminal);
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
+ }
70
+ if(matchesKey(data,'ctrl+c')){if(this.runViewer){this.runViewer.onClose();return;}if(this.cancelRun)this.cancelRun();this.pending?.reject(cancelled());return;}
71
+ if(matchesKey(data,'escape')&&this.pending&&!this.logViewer&&!this.runViewer){
72
+ // 编辑器自己处理补全菜单 Escape,第二次 Escape 返回上一级。
73
+ if(this.editor?.isShowingAutocomplete?.()){input(data);return;}
74
+ this.pending.reject(cancelled());return;
75
+ }
76
+ input(data);
77
+ },resize);
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=>['',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)];
80
+ const renderTranscript=this.transcript.render.bind(this.transcript);
81
+ this.headingLines=(width,mark='FISHtools')=>{
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('欢迎使用'));
86
+ if(mark===logo){
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]));
89
+ }else lines.push(' '+palette.brand('FISH')+palette.text('tools')+' '+palette.muted(`v${VERSION}`));
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));
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 查看详情`));
97
+ lines.push('');return lines.map(line=>fitLine(line,width));
98
+ };
99
+ // 先保证输入与菜单可见,空间充足才展示三行字标。
100
+ this.header.render=width=>{
101
+ const full=this.headingLines(width,logo),other=renderTranscript(width).length+this.prompt.render(width).length+this.hint.render(width).length;
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;
107
+ };
108
+ this.transcript.render=width=>{
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);
110
+ if(lines.length<=budget){this.historyOffset=0;return lines;}
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
+ }
117
+ const excerpt=this.logs.length<=1?lines.slice(0,budget-1):lines.slice(-(budget-1)||lines.length);
118
+ return [...excerpt,fitLine(' '+palette.muted('… /help 完整帮助 · /logs 运行日志'),width)];
119
+ };
120
+ this.tui.addChild(this.header);this.tui.addChild(this.transcript);this.tui.addChild(this.prompt);this.tui.addChild(this.hint);
121
+ }
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
+ }
140
+ async close(){
141
+ if(this.closed)return;this.closed=true;
142
+ this.feed.close();
143
+ // Pi 的渲染排在 nextTick;先排空最后一帧,再恢复终端,避免退出后隐藏光标。
144
+ await new Promise(resolve=>process.nextTick(resolve));this.tui.stop();
145
+ if(this.started)this.terminal.write(mouseOff+'\x1b[?1049l');
146
+ if(this.terminal instanceof ProcessTerminal)process.stdin.pause();
147
+ }
148
+ summary(text){this.summaryText=safe(text);this.tui.requestRender();}
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();}
167
+ show(message,{kind='message'}={}){
168
+ const text=safe(message).slice(0,6000);
169
+ const formatted=kind==='welcome'?text.split('\n').map((line,i)=>i===0?palette.text(line):palette.muted(line)).join('\n'):text;
170
+ if(kind!=='welcome'){this.feed.message(formatted);return;}
171
+ this.logs.push(formatted);this.logs=this.logs.slice(-6);this.refreshTranscript();
172
+ }
173
+ async viewMarkdown(title,text){
174
+ const previousPending=this.pending??null,previousViewer=this.logViewer;
175
+ const children=this.tui.children;
176
+ let overlay;
177
+ try{
178
+ await new Promise((resolve,reject)=>{
179
+ this.pending={resolve,reject};
180
+ this.logViewer=new MarkdownLogViewer({title:safe(title),text:safe(text),terminal:this.terminal,onClose:resolve,requestRender:()=>this.tui.requestRender()});
181
+ this.terminal.write(mouseOn);
182
+ // Overlay 的基底为空,旧 transcript 不再撑高滚动区域;关闭后原样恢复。
183
+ this.tui.children=[];
184
+ overlay=this.tui.showOverlay(this.logViewer,{width:'100%',maxHeight:'100%',anchor:'top-left',margin:0});
185
+ this.tui.requestRender(true);
186
+ });
187
+ }finally{
188
+ this.tui.children=children;this.pending=previousPending;this.logViewer=previousViewer;
189
+ overlay?.hide();this.tui.requestRender(true);
190
+ this.terminal.write(mouseOff);
191
+ }
192
+ }
193
+ async observeRun({app,runId,record,flow,workflows=[],initialView}){
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('这次运行记录已不存在');
197
+ try{
198
+ await new Promise((resolve,reject)=>{
199
+ this.pending={resolve,reject};
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;}
203
+ unsubscribe=app.subscribe(event=>{if(event.runId===runId)this.runViewer?.update(app.getRun(runId));});
204
+ this.tui.children=[];this.terminal.write(mouseOn);
205
+ overlay=this.tui.showOverlay(this.runViewer,{width:'100%',maxHeight:'100%',anchor:'top-left',margin:0});this.tui.requestRender(true);
206
+ });
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);
210
+ unsubscribe?.();this.runViewer=null;this.tui.children=children;this.pending=previousPending;overlay?.hide();this.terminal.write(mouseOff);this.tui.requestRender(true);
211
+ }
212
+ }
213
+ async ask(message,component){
214
+ fit(component);
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=()=>[];
219
+ this.prompt.clear();this.prompt.addChild(title);this.prompt.addChild(component);this.tui.setFocus(component);this.tui.requestRender();
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();}
227
+ }
228
+ input(message,{value='',commands=[],files=true,workflows=[]}={}){
229
+ const editor=new Editor(this.tui,{borderColor:palette.border,selectList:theme},{paddingX:1});
230
+ const renderEditor=editor.render.bind(editor);
231
+ editor.render=width=>{
232
+ if(width<8)return renderEditor(width);
233
+ const inner=width-4,lines=renderEditor(inner);let closed=false;
234
+ return lines.map((line,i)=>{
235
+ if(i===0)return ' '+palette.border('╭'+'─'.repeat(inner)+'╮');
236
+ if(!closed&&/^─+$/.test(safe(line))){closed=true;return ' '+palette.border('╰'+'─'.repeat(inner)+'╯');}
237
+ if(closed)return ' '+line;
238
+ return ' '+palette.border('│')+line+' '.repeat(Math.max(0,inner-visibleWidth(line)))+palette.border('│');
239
+ });
240
+ };
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));
250
+ return this.ask(message,editor);
251
+ }
252
+ async select(message,items){
253
+ if(!items.length)throw new Error('当前没有可选择的项目');
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);
255
+ list.render=width=>{
256
+ const reserved=this.headingLines(width).length+new Text(palette.text(safe(message)),1,0).render(width).length+this.hint.render(width).length+2;
257
+ list.maxVisible=Math.max(1,Math.min(8,this.terminal.rows-reserved));
258
+ const start=Math.max(0,Math.min(list.selectedIndex-Math.floor(list.maxVisible/2),list.filteredItems.length-list.maxVisible));
259
+ const end=Math.min(start+list.maxVisible,list.filteredItems.length),lines=[];
260
+ for(let i=start;i<end;i++){
261
+ const item=list.filteredItems[i],selected=i===list.selectedIndex,available=Math.max(1,width-4);
262
+ const labelWidth=item.description&&width>=56?Math.min(32,Math.floor(available*0.45)):available;
263
+ const label=fitLine(item.label||item.value,labelWidth),prefix=selected?'› ':' ';
264
+ let line=' '+(selected?palette.brand(prefix+label):palette.text(prefix+label));
265
+ if(item.description&&width>=56)line+=' '.repeat(Math.max(2,labelWidth-visibleWidth(label)+2))+palette.muted(fitLine(item.description,Math.max(1,available-labelWidth-2)));
266
+ lines.push(fitLine(line,width));
267
+ }
268
+ if(start>0||end<list.filteredItems.length)lines.push(palette.muted(` ${list.selectedIndex+1} / ${list.filteredItems.length}`));
269
+ return lines.length?lines:[palette.muted(' 没有匹配项')];
270
+ };
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;
277
+ }
278
+ async confirm(message){return await this.select(message,[{value:'no',label:'否,返回'},{value:'yes',label:'是,继续'}])==='yes';}
279
+ busy(message,cancel){
280
+ this.cancelRun=cancel;this.prompt.clear();this.prompt.addChild(new Text(palette.text(safe(message)),1,0));this.tui.setFocus(this.prompt);this.tui.requestRender();
281
+ return ()=>{this.cancelRun=null;this.prompt.clear();this.tui.requestRender();};
282
+ }
283
+ }
@@ -4,6 +4,36 @@ import { mkdir, writeFile, readFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { createApplication } from '../packages/core/src/application.js';
6
6
  import { temporary, testPlugin, object } from './helpers.js';
7
+ import {validate} from '../packages/core/src/validation.js';
8
+
9
+ test('同 ID schema 隔离编译,保留内嵌引用且不串用旧约束',()=>{
10
+ const schema=type=>({$id:'https://fishtools.test/review/config',type:'object',definitions:{value:{type}},properties:{value:{$ref:'#/definitions/value'}},required:['value']});
11
+ assert.deepEqual(validate(schema('string'),{value:'old'},'旧配置'),{value:'old'});
12
+ assert.deepEqual(validate(schema('integer'),{value:2},'新配置'),{value:2});
13
+ assert.throws(()=>validate(schema('integer'),{value:'old'},'新配置'),/integer/);
14
+ });
15
+
16
+ test('外部带 $id 配置插件可升级,后续安装与卸载不被旧 schema 阻塞',async t=>{
17
+ const root=await temporary(t),projectDir=path.join(root,'project');
18
+ const fixture=async(version)=>{
19
+ const directory=path.join(root,'plugin-'+version);await mkdir(directory);
20
+ await writeFile(path.join(directory,'package.json'),JSON.stringify({name:'review-schema-plugin',version,type:'module',main:'index.js'}));
21
+ const config={$id:'https://fishtools.test/external/config',type:'object',properties:{version:{type:'string',const:version,default:version}}};
22
+ await writeFile(path.join(directory,'index.js'),`export default {id:'review-schema-plugin',version:${JSON.stringify(version)},config:${JSON.stringify(config)},commands:{'review.version':{action:'version'}},actions:{version:{input:{type:'object'},output:{type:'object'},async run(_, {config}){return config;}}}};`);
23
+ return directory;
24
+ };
25
+ const first=await fixture('1.0.0'),second=await fixture('1.0.1'),extra=path.join(root,'unrelated');await mkdir(extra);
26
+ await writeFile(path.join(extra,'package.json'),JSON.stringify({name:'review-unrelated',version:'1.0.0',type:'module',main:'index.js'}));
27
+ await writeFile(path.join(extra,'index.js'),"export default {id:'review-unrelated',version:'1.0.0'};");
28
+ const app=await createApplication({projectDir,plugins:[]});t.after(()=>app.close());
29
+ await app.installPlugin(first);await app.installPlugin(extra);await app.removePlugin('review-unrelated');await app.installPlugin(second);
30
+ assert.equal((await (await app.startCommand('review.version')).done).output.version,'1.0.1');
31
+ await assert.rejects(app.configurePlugin('review-schema-plugin',{version:'1.0.0'}),/配置/);
32
+ await app.close();
33
+ const restarted=await createApplication({projectDir});t.after(()=>restarted.close());
34
+ assert.equal((await (await restarted.startCommand('review.version')).done).output.version,'1.0.1');
35
+ await restarted.removePlugin('review-schema-plugin');assert.equal(restarted.listPlugins().length,0);
36
+ });
7
37
 
8
38
  test('运行快照独立于之后保存,状态与日志持久化',async t=>{
9
39
  const projectDir=await temporary(t);
@@ -8,12 +8,15 @@ import { resolveProject, initializeProject } from '../src/project.js';
8
8
  import {waitForCliRun,cliRunResult} from '../src/cli-run.js';
9
9
  import {VERSION} from '../src/version.js';
10
10
  import {listDocumentation,readDocumentation,findDocumentationRoot,exportDocumentation} from '../src/documentation.js';
11
+ import {collectInstallations} from '../src/plugin-installations.js';
12
+ import {prepareBundleWorkspace} from '../src/bundle-workspace.js';
13
+ import {readJson} from '../src/plugins.js';
11
14
 
12
15
  const program=new Command().name('fst').description('Fishtools · 插件式工作流工作台').version(VERSION);
13
16
  program.option('--maintenance','维护模式:不启动有状态服务插件');
14
17
  program.option('--project <directory>','项目目录(默认当前项目或用户工作区)').option('--workspace <directory>','便携包的可写工作区').option('--output <directory>','日志与结果目录').option('--config <file>','插件配置覆盖文件').option('--host <host>','Web 监听地址','127.0.0.1').option('--port <port>','Web 监听端口','8765');
15
- async function app(){const o=program.opts();return createApplication({projectDir:await resolveProject(o.project,o.workspace),outputDir:o.output,maintenance:!!o.maintenance,config:o.config?JSON.parse(await readFile(o.config,'utf8')):{}});}
16
- async function use(fn){const a=await app();try{return await fn(a);}finally{await a.close();}}
18
+ async function app({management=false,removeInstalled}={}){const o=program.opts();return createApplication({projectDir:await resolveProject(o.project,o.workspace),outputDir:o.output,maintenance:management||!!o.maintenance,omitInstalledPlugin:removeInstalled,config:o.config?JSON.parse(await readFile(o.config,'utf8')):{}});}
19
+ async function use(fn,options){const a=await app(options);try{for(const warning of a.host.warnings)console.error(`警告:${warning.message}`);return await fn(a);}finally{await a.close();}}
17
20
  program.command('init [directory]').description('创建项目,保留已有配置与工作流').action(async directory=>console.log(`Fishtools 项目:${await initializeProject(path.resolve(directory??process.cwd()))}`));
18
21
  program.command('docs [topic]').description('阅读或导出随安装提供的离线文档').option('--path','只打印文档路径').option('--export <directory>','导出完整资料到新目录').action(async(topic,options)=>{
19
22
  if(options.export){if(topic||options.path)throw new Error('--export 不可与文档主题或 --path 同用');console.log(await exportDocumentation(options.export));return;}
@@ -32,8 +35,27 @@ program.command('run <id>').option('--input <file>','JSON 输入文件').action(
32
35
  }));
33
36
  const plugin=program.command('plugin').description('管理插件');
34
37
  plugin.command('list').action(()=>use(a=>console.log(JSON.stringify(a.listPlugins(),null,2))));
35
- plugin.command('add <source>').action(source=>use(async a=>console.log(JSON.stringify(await a.installPlugin(source),null,2))));
36
- plugin.command('remove <name>').action(name=>use(async a=>console.log(JSON.stringify(await a.removePlugin(name),null,2))));
38
+ plugin.command('add <source>').action(source=>use(async a=>console.log(JSON.stringify(await a.installPlugin(source),null,2)),{management:true}));
39
+ plugin.command('link <directory>').description('引用本地开发源码,修改后重启 fst 生效').action(directory=>use(async a=>console.log(JSON.stringify(await a.linkPlugin(path.resolve(directory)),null,2)),{management:true}));
40
+ plugin.command('remove <name>').action(name=>use(async a=>console.log(JSON.stringify(await a.removePlugin(name),null,2)),{management:true,removeInstalled:name}));
41
+ plugin.command('validate <directory>').description('检查可信插件声明,不执行动作或运行时服务').option('--json','输出结构化报告').action(async(directory,options)=>{
42
+ const {validatePluginDirectory}=await import('../src/plugin-tools.js');const report=await validatePluginDirectory(path.resolve(directory));
43
+ if(options.json)console.log(JSON.stringify(report,null,2));else{
44
+ console.log(`${report.valid?'检查通过':'检查未通过'}:${report.plugin?.id??directory}`);
45
+ for(const item of report.diagnostics)console.log(`${item.severity==='error'?'错误':'警告'} [${item.code}] ${item.path??''} ${item.message}`);
46
+ }
47
+ if(!report.valid)process.exitCode=1;
48
+ });
49
+ plugin.command('create <name> [directory]').description('创建可独立安装的插件骨架').action(async(name,directory)=>{
50
+ const {createPlugin}=await import('../src/plugin-tools.js');const result=await createPlugin(name,path.resolve(directory??name));
51
+ console.log(`插件已创建:${result.directory}\n运行 fst plugin validate "${result.directory}" 检查;使用 fst plugin link "${result.directory}" 开发`);
52
+ });
53
+ plugin.command('gc').description('预览未引用安装批次;--apply 先备份再清理').option('--apply','压缩备份后清理安全批次').action(async options=>{
54
+ const settings=program.opts(),projectDir=await prepareBundleWorkspace(await resolveProject(settings.project,settings.workspace),settings.workspace);
55
+ const project=await readJson(path.join(projectDir,'flow.project.json'),{});
56
+ if(project.readOnly)throw new Error('只读项目不能清理插件批次');
57
+ console.log(JSON.stringify(await collectInstallations(projectDir,{apply:!!options.apply}),null,2));
58
+ });
37
59
  const packOptions=command=>command.requiredOption('--target <target>','win32-x64 或 linux-x64-glibc').option('--out <directory>','导出目录','dist').option('--read-only','导出只读精简运行包').option('--environments <file>','环境来源覆盖 JSON').option('--plan','只查看打包资源计划');
38
60
  async function pack(a,entries,o,portable=false){
39
61
  const options={entries,target:o.target,outDir:o.out,includeWeb:!!o.web,readOnly:!!o.readOnly,portable,environments:o.environments?JSON.parse(await readFile(o.environments,'utf8')):{}};
@@ -46,7 +68,7 @@ program.command('command [name]').description('列出插件命令或执行动作
46
68
  const run=await a.startCommand(name,o.input?JSON.parse(await readFile(o.input,'utf8')):{});
47
69
  const result=cliRunResult(await waitForCliRun(a,run));console.log(JSON.stringify(result.value,null,2));process.exitCode=result.exitCode;
48
70
  }));
49
- program.command('configure <plugin> <file>').description('从 JSON 保存插件配置').action((id,file)=>use(async a=>{await a.configurePlugin(id,JSON.parse(await readFile(file,'utf8')));console.log('插件配置已保存');}));
71
+ program.command('configure <plugin> <file>').description('从 JSON 保存插件配置').action((id,file)=>use(async a=>{await a.configurePlugin(id,JSON.parse(await readFile(file,'utf8')));console.log('插件配置已保存');},{management:true}));
50
72
  async function web(o){
51
73
  const a=await app();let server;
52
74
  try{server=await a.startWeb({host:o.host,port:Number(o.port)});}catch(error){await a.close();throw error;}
@@ -29,7 +29,9 @@
29
29
  "cordis": "3.18.1",
30
30
  "execa": "10.1.0",
31
31
  "semver": "7.7.4",
32
+ "tar": "7.5.22",
32
33
  "tree-kill": "1.2.2",
34
+ "validate-npm-package-name": "8.0.0",
33
35
  "which": "7.0.0",
34
36
  "yaml": "2.9.1"
35
37
  }
@@ -14,8 +14,9 @@ import {createResources} from './resources.js';
14
14
  import {errorRecord} from './diagnostics.js';
15
15
  import {markdownHeader,markdownEvent,markdownResult} from './markdown-log.js';
16
16
  import {createRunLogs,validateKeepRuns} from './run-logs.js';
17
+ import {openPluginState,withPluginMutation,assertPluginState,savePluginState,leaseInstallation} from './plugin-installations.js';
17
18
 
18
- export async function createApplication({projectDir=process.cwd(),workspace,outputDir,plugins,config={},maintenance=false}={}) {
19
+ export async function createApplication({projectDir=process.cwd(),workspace,outputDir,plugins,config={},maintenance=false,omitInstalledPlugin}={}) {
19
20
  projectDir=await prepareBundleWorkspace(path.resolve(projectDir),workspace);
20
21
  const project=await readJson(path.join(projectDir,'flow.project.json'),{plugins:[]});
21
22
  const output=path.resolve(outputDir??process.env.FLOW_OUTPUT_DIR??(project.readOnly?path.join(process.cwd(),'flow-results'):path.join(projectDir,'.flow','runs')));
@@ -24,16 +25,18 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
24
25
  const resources=createResources(),operations=new Set();let closing,launching=0;
25
26
  const runLogs=createRunLogs(output,{projectDir,allowLegacy:output===path.join(projectDir,'.flow','runs')}),writingRuns=new Set();
26
27
  validateKeepRuns(project.logs?.keepRuns??null);
27
- let host,state=await readJson(path.join(projectDir,'.flow','plugins.json'),{}),busy=false;
28
+ const opened=await openPluginState(projectDir,{readOnly:project.readOnly});
29
+ let host,state=opened.state,releaseInstallation=opened.release,busy=false;
28
30
  const app={
29
31
  projectDir,project,outputDir:output,store,
30
32
  get host(){return host;},
31
33
  get maintenance(){return maintenance;},
32
34
  catalog:()=>host.catalog(),
33
- listPlugins:()=>[...host.definitions.values()].map(p=>({id:p.id,version:p.version,title:p.title,description:p.description,platforms:p.platforms,architectures:p.architectures,environments:p.environments,config:p.config,runtime:host.runtimeStatus().find(r=>r.id===p.id),removable:!!state.dependencies?.[p.id]})),
35
+ listPlugins:()=>[...host.definitions.values()].map(p=>({id:p.id,version:p.version,title:p.title,description:p.description,platforms:p.platforms,architectures:p.architectures,environments:p.environments,config:p.config,runtime:host.runtimeStatus().find(r=>r.id===p.id),removable:!!(state.dependencies?.[p.id]||state.links?.[p.id]),development:!!state.links?.[p.id],warnings:host.warnings.filter(item=>item.plugin===p.id).map(item=>item.message)})),
34
36
  listCommands:()=>host.commands(),
35
37
  listWorkflows:()=>store.list(),
36
38
  async saveWorkflow(w){if(project.readOnly)throw new FlowError('此分发包仅用于运行;请在开发项目中编辑');return store.save(w);},
39
+ async removeWorkflow(id){if(project.readOnly)throw new FlowError('只读项目不能删除工作流');return store.remove(id);},
37
40
  checkWorkflow:id=>store.resolve([id],host),
38
41
  subscribe(fn){emitter.on('event',fn);return ()=>emitter.off('event',fn);},
39
42
  async startRun(id,input={}) {
@@ -113,14 +116,20 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
113
116
  if(busy)throw new FlowError('项目正在更新,请稍后修改日志设置');
114
117
  busy=true;
115
118
  try{
116
- const logs={...project.logs,keepRuns},file=path.join(projectDir,'flow.project.json'),temp=`${file}.${randomUUID()}.tmp`;
117
- await mkdir(projectDir,{recursive:true});await writeFile(temp,JSON.stringify({...project,logs},null,2));await rename(temp,file);project.logs=logs;
118
- await pruneLogs();
119
+ await withPluginMutation(projectDir,async()=>{
120
+ const file=path.join(projectDir,'flow.project.json'),latest=await readJson(file,project);
121
+ if(latest.readOnly)throw new FlowError('只读运行包不能保存日志设置');
122
+ const nextProject={...latest,logs:{...latest.logs,keepRuns}},temp=`${file}.${randomUUID()}.tmp`;
123
+ await writeFile(temp,JSON.stringify(nextProject,null,2));await rename(temp,file);
124
+ for(const key of Object.keys(project))delete project[key];Object.assign(project,nextProject);
125
+ await pruneLogs();
126
+ });
119
127
  }finally{busy=false;}
120
128
  },
121
129
  cancelRun(id){const r=runs.get(id);if(r?.status==='running')r.controller.abort();return !!r;},
122
130
  async installPlugin(source){return change({add:source});},
123
- async removePlugin(name){if(resources.active(name))throw new FlowError('请先停止此插件提供的 Web');if(!state.dependencies?.[name])throw new FlowError('此插件来自发行配置,不能在此移除');return change({remove:name});},
131
+ async linkPlugin(source){return change({link:path.resolve(projectDir,source)});},
132
+ async removePlugin(name){if(resources.active(name))throw new FlowError('请先停止此插件提供的 Web');if(!state.dependencies?.[name]&&!state.links?.[name])throw new FlowError('此插件来自发行配置,不能在此移除');return change({remove:name});},
124
133
  async pack(options){const {packApplication}=await import('@mytools/pack');return packApplication(app,options);},
125
134
  async packPlan(options){const {planApplication}=await import('@mytools/pack');return planApplication(app,options);},
126
135
  async configurePlugin(id,value){
@@ -132,11 +141,18 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
132
141
  value=validate(host.definitions.get(id).config,value,`${id} 配置`);
133
142
  busy=true;let nextHost;
134
143
  try{
135
- const nextConfig={...project.config,[id]:value};
136
- nextHost=await createHost({plugins:[...host.definitions.values()].map(p=>({plugin:p,root:p.root})),config:{...nextConfig,...config},services,environmentOverrides:project.environments,runtimeEnabled:!maintenance});
137
- const nextProject={...project,config:nextConfig},file=path.join(projectDir,'flow.project.json'),temp=`${file}.${randomUUID()}.tmp`;
144
+ await withPluginMutation(projectDir,async()=>{
145
+ await assertPluginState(projectDir,state);
146
+ const file=path.join(projectDir,'flow.project.json'),latest=await readJson(file,project);
147
+ if(latest.readOnly)throw new FlowError('只读项目不能保存配置');
148
+ const nextConfig={...latest.config,[id]:value};
149
+ nextHost=await createHost({plugins:[...host.definitions.values()].map(p=>({plugin:p,root:p.root})),config:{...nextConfig,...config},services,environmentOverrides:latest.environments,runtimeEnabled:!maintenance});
150
+ const nextProject={...latest,config:nextConfig},temp=`${file}.${randomUUID()}.tmp`;
138
151
  await writeFile(temp,JSON.stringify(nextProject,null,2));await rename(temp,file);
139
- const old=host;host=nextHost;nextHost=null;project.config=nextConfig;await old.close();
152
+ const old=host;host=nextHost;nextHost=null;
153
+ for(const key of Object.keys(project))delete project[key];Object.assign(project,nextProject);
154
+ await old.close();
155
+ });
140
156
  }finally{try{if(nextHost)await nextHost.close();}finally{busy=false;}}
141
157
  },
142
158
  async setMaintenance(enabled){
@@ -166,17 +182,22 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
166
182
  const errors=[];
167
183
  try{await resources.close();}catch(error){errors.push(error);}
168
184
  try{await host.close();}catch(error){errors.push(error);}
185
+ try{await releaseInstallation();}catch(error){errors.push(error);}
169
186
  if(errors.length)throw new AggregateError(errors,`应用关闭失败:${errors.map(e=>e.message).join(';')}`);
170
187
  });return closing;
171
188
  },
172
189
  };
173
- const initial=plugins??await Promise.all((project.plugins??[]).map(spec=>loadPlugin(spec,project.baseBundle?.root??projectDir)));
190
+ let initial;
174
191
  const services={application:app,runProcess};
175
192
  const build=extra=>createHost({plugins:[...initial,...extra],config:{...project.config,...config},services,environmentOverrides:project.environments,runtimeEnabled:!maintenance});
176
- const external=[];
177
- for(const name of Object.keys(state.dependencies??{}))external.push(await loadPlugin(name,state.root));
178
- host=await build(external);
179
- for(const name of ['startRun','startCommand','startWeb','saveWorkflow','installPlugin','removePlugin','configurePlugin','configureLogs','setMaintenance','pack']){
193
+ try{
194
+ initial=plugins??await Promise.all((project.plugins??[]).map(spec=>loadPlugin(spec,project.baseBundle?.root??projectDir)));
195
+ const external=[];
196
+ for(const name of Object.keys(state.dependencies??{}))if(!maintenance||name!==omitInstalledPlugin)external.push(await loadPlugin(name,state.root));
197
+ for(const [name,source] of Object.entries(state.links??{}))if(!maintenance||name!==omitInstalledPlugin)external.push(await loadPlugin(source,projectDir));
198
+ host=await build(external);
199
+ }catch(error){await releaseInstallation();throw error;}
200
+ for(const name of ['startRun','startCommand','startWeb','saveWorkflow','removeWorkflow','installPlugin','linkPlugin','removePlugin','configurePlugin','configureLogs','setMaintenance','pack']){
180
201
  const original=app[name],isRun=['startRun','startCommand'].includes(name);
181
202
  app[name]=async(...args)=>{
182
203
  if(closing)throw new FlowError('应用已关闭');
@@ -192,19 +213,23 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
192
213
  if(project.readOnly)throw new FlowError('运行包不支持安装或移除插件');
193
214
  assertMutable();
194
215
  if(busy || launching || [...runs.values()].some(r=>r.status==='running'))throw new FlowError('请等待当前运行或安装结束');
195
- busy=true;let nextHost;
216
+ busy=true;let nextHost,nextRelease;
196
217
  try {
218
+ return await withPluginMutation(projectDir,async()=>{
219
+ await assertPluginState(projectDir,state);
197
220
  const next=await installGeneration(projectDir,{...operation,state});
198
221
  for(const p of host.definitions.values())if(resources.active(p.id)){
199
222
  const candidate=[...initial,...next.loaded].find(e=>(e.plugin??e).id===p.id);
200
223
  if(!candidate||candidate.root!==p.root||(candidate.plugin??candidate).version!==p.version)throw new FlowError('请先停止活动 Web,再替换其提供者');
201
224
  }
202
225
  nextHost=await build(next.loaded);
203
- const file=path.join(projectDir,'.flow','plugins.json'),temp=`${file}.tmp`;
204
- await writeFile(temp,JSON.stringify(next.state,null,2));await rename(temp,file);
205
- const old=host;host=nextHost;nextHost=null;state=next.state;await old.close();
226
+ nextRelease=await leaseInstallation(projectDir,next.state.root);
227
+ await savePluginState(projectDir,next.state,state);
228
+ const old=host,oldRelease=releaseInstallation;host=nextHost;nextHost=null;state=next.state;releaseInstallation=nextRelease;nextRelease=null;
229
+ try{await old.close();}finally{await oldRelease();}
206
230
  return app.listPlugins();
207
- } finally {try{if(nextHost)await nextHost.close();}finally{busy=false;}}
231
+ });
232
+ } finally {try{if(nextHost)await nextHost.close();}finally{try{if(nextRelease)await nextRelease();}finally{busy=false;}}}
208
233
  }
209
234
  return app;
210
235
  }
@@ -1,32 +1,50 @@
1
1
  import {FlowError} from '@mytools/sdk';
2
2
  import {conditionOperators} from './control.js';
3
+ import {resolveSchemaPath} from './schema-path.js';
4
+ import {inside} from './environments.js';
5
+ import path from 'node:path';
3
6
 
4
7
  export const builtinCommands=[
5
8
  ['workflows','选择已有工作流'],['new','新建工作流'],['add','添加插件能力或子工作流'],
6
9
  ['steps','设置步骤参数、排序或删除'],['outputs','设置最终输出'],['save','保存工作流'],
7
- ['run','运行工作流,可附带 @参数.json'],['runs','观察运行中的步骤、输入输出与实时日志'],['logs','查看运行日志与保留设置'],['plugins','管理插件'],['config','设置插件配置'],
10
+ ['run','运行工作流,可附带 @参数.json'],['runs','运行记录:选择任意一次执行,查看进度、结果和日志'],['logs','查看运行日志'],['plugin','管理插件'],['plugins','管理插件(兼容旧入口)'],['config','配置 Fishtools'],['tools','Web 与离线文档'],
8
11
  ['pack','选择工作流打包'],['web','启动 Web 工作台'],['docs','阅读离线开发与使用文档'],['help','查看操作说明'],['exit','退出终端工作台'],
9
12
  ].map(([name,description])=>({name,description}));
10
13
 
11
14
  export function validateDeclarations(plugin){
12
15
  for(const [name,action] of Object.entries(plugin.actions??{})){
16
+ if(!action||typeof action!=='object'||Array.isArray(action))throw new FlowError(`动作 ${name} 的声明必须为对象`,{code:'VALIDATION'});
13
17
  if(action.conditions!==undefined&&(!action.conditions||typeof action.conditions!=='object'||Array.isArray(action.conditions)))throw new FlowError(`动作 ${name} 的 conditions 必须为对象`,{code:'VALIDATION'});
14
18
  for(const [key,preset] of Object.entries(action.conditions??{})){
15
19
  if(!preset||typeof preset.title!=='string'||!preset.title.trim()||typeof preset.path!=='string'||!preset.path||preset.path.split('.').some(p=>!p||['__proto__','constructor','prototype'].includes(p))||!conditionOperators.includes(preset.op)||(preset.op!=='exists'&&!Object.hasOwn(preset,'value')))throw new FlowError(`动作 ${name} 的判定预设 ${key} 无效`,{code:'VALIDATION'});
16
- let schema=action.output;
17
- for(const part of preset.path.split('.')){
18
- if(schema?.properties&&Object.hasOwn(schema.properties,part))schema=schema.properties[part];
19
- else if(schema?.type==='array'&&/^\d+$/.test(part))schema=schema.items;
20
- else throw new FlowError(`判定预设 ${key} 引用了未声明的输出字段 ${preset.path}`,{code:'VALIDATION'});
21
- }
20
+ try{resolveSchemaPath(action.output,preset.path);}
21
+ catch{throw new FlowError(`判定预设 ${key} 引用了未声明的输出字段 ${preset.path}`,{code:'VALIDATION'});}
22
22
  }
23
23
  }
24
- for(const key of ['platforms','architectures'])if(plugin[key]!==undefined&&(!Array.isArray(plugin[key])||!plugin[key].every(x=>typeof x==='string'&&x)))throw new FlowError(`${plugin.id} 的 ${key} 必须是字符串数组`);
25
- for(const declarations of [plugin.environments,...Object.values(plugin.actions??{}).map(a=>a.environments)]){
24
+ for(const key of ['platforms','architectures'])if(plugin[key]!==undefined&&(!Array.isArray(plugin[key])||!plugin[key].length||!plugin[key].every(x=>typeof x==='string'&&x.trim())||new Set(plugin[key]).size!==plugin[key].length))throw new FlowError(`${plugin.id} 的 ${key} 必须是非空且不重复的字符串数组`,{code:'VALIDATION'});
25
+ for(const [owner,location,declarations] of [['插件','plugin.environments',plugin.environments],...Object.entries(plugin.actions??{}).map(([name,action])=>[`动作 ${name}`,`plugin.actions.${name}.environments`,action.environments])]){
26
+ if(declarations!==undefined&&(!declarations||typeof declarations!=='object'||Array.isArray(declarations)))throw new FlowError(`${owner} 的 environments 必须为对象`,{code:'VALIDATION'});
26
27
  for(const [id,e] of Object.entries(declarations??{})){
28
+ const at=`${location}.${id}`;
29
+ const invalid=(field,message)=>{throw new FlowError(message,{code:'VALIDATION',details:{path:`${at}.${field}`}});};
27
30
  if(!/^[\w-]+$/.test(id)||!e||!['host','bundled','tool'].includes(e.source))throw new FlowError(`环境声明无效:${plugin.id}/${id}`);
31
+ const validString=value=>typeof value==='string'&&Boolean(value.trim());
32
+ if((e.source==='host'||e.executable!==undefined)&&!(e.source==='host'?(validString(e.executable)||Array.isArray(e.executable)&&e.executable.length>0&&e.executable.every(validString)&&new Set(e.executable).size===e.executable.length):validString(e.executable)))throw new FlowError(`环境 executable 声明无效:${plugin.id}/${id}`,{code:'VALIDATION'});
28
33
  if(e.allowedSources&&(!Array.isArray(e.allowedSources)||!e.allowedSources.includes(e.source)||!e.allowedSources.every(x=>['host','bundled','tool'].includes(x))))throw new FlowError(`环境来源范围无效:${id}`);
29
34
  if(e.source==='tool'&&id!=='node')throw new FlowError(`工具尚未提供环境:${id}`);
35
+ if(e.source==='bundled'||e.artifacts!==undefined){
36
+ const artifacts=e.artifacts;
37
+ if(!artifacts||typeof artifacts!=='object'||Array.isArray(artifacts)||e.source==='bundled'&&!Object.keys(artifacts).length)invalid('artifacts',`环境 ${plugin.id}/${id} 的 artifacts 必须为非空目标字典`);
38
+ for(const [target,artifact] of Object.entries(artifacts)){
39
+ if(!target.trim()||!artifact||typeof artifact!=='object'||Array.isArray(artifact))invalid(`artifacts.${target}`,`环境 ${plugin.id}/${id} 的目标资源无效:${target}`);
40
+ // 与运行时使用同一包内路径规则;此处只查声明,不读取目标平台文件。
41
+ let directory;
42
+ try{if(typeof artifact.directory!=='string'||!artifact.directory.trim())throw new Error();directory=inside(process.cwd(),artifact.directory);}
43
+ catch{invalid(`artifacts.${target}.directory`,`环境 ${plugin.id}/${id} 的资源目录必须是包内相对路径`);}
44
+ try{if(typeof artifact.executable!=='string'||!artifact.executable.trim()||path.normalize(artifact.executable)==='.')throw new Error();inside(directory,artifact.executable);}
45
+ catch{invalid(`artifacts.${target}.executable`,`环境 ${plugin.id}/${id} 的程序路径必须是包内相对路径`);}
46
+ }
47
+ }
30
48
  }
31
49
  }
32
50
  }
@@ -12,7 +12,7 @@ const topics=[
12
12
  ['readme','Fishtools 使用说明','README.md'],
13
13
  ];
14
14
  // 保留文档链接所需的目录布局;参考代码仅供阅读,不是第二份运行时。
15
- const assets=['README.md','LICENSE','THIRD_PARTY_NOTICES.md','docs','examples/service-plugins','plugins/examples','plugins/environment','packages/sdk','packages/core/src/host.js','packages/core/LICENSE','tests/application.test.js'];
15
+ const assets=['README.md','LICENSE','THIRD_PARTY_NOTICES.md','docs','examples/service-plugins','plugins/examples','plugins/environment','packages/sdk','packages/core/src/host.js','packages/core/src/runtime.js','packages/core/src/engine.js','packages/core/src/application.js','packages/core/src/terminal/view.js','packages/core/src/terminal/run-feed.js','packages/core/src/terminal/session.js','packages/core/LICENSE','tests/application.test.js'];
16
16
  const excluded=new Set(['node_modules','dist','.git','.cache','__pycache__','.pytest_cache','coverage','public']);
17
17
  const inside=(parent,child)=>{const relative=path.relative(parent,child);return relative===''||(!relative.startsWith(`..${path.sep}`)&&relative!=='..'&&!path.isAbsolute(relative));};
18
18