@yanglaofish/fishtools 0.5.0 → 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 +44 -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 +70 -0
- 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 +39 -1
- package/manual/packages/core/README.md +3 -1
- package/manual/packages/core/package.json +1 -0
- package/manual/packages/core/src/application.js +126 -20
- 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 +8 -1
- package/manual/packages/fst-cli/package.json +1 -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/run-feed.js +24 -9
- 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-logs/README.md +11 -0
- package/manual/packages/fst-logs/package.json +2 -0
- package/manual/packages/fst-logs/src/run-logs.js +8 -23
- package/manual/packages/fst-logs/src/run-preview.js +18 -0
- package/manual/packages/fst-logs/src/step-summaries.js +28 -0
- package/manual/packages/fst-platform/README.md +17 -0
- package/manual/packages/fst-platform/package.json +1 -0
- package/manual/packages/fst-platform/src/process.js +12 -8
- package/manual/packages/fst-platform/src/shell.js +58 -0
- package/manual/packages/fst-plugin/src/contracts.js +6 -2
- package/manual/packages/fst-plugin/src/host.js +7 -4
- package/manual/packages/fst-plugin/src/runtime.js +8 -5
- 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 +126 -20
- 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 +8 -1
- package/node_modules/@mytools/fst-cli/package.json +1 -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/run-feed.js +24 -9
- 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-logs/README.md +11 -0
- package/node_modules/@mytools/fst-logs/package.json +2 -0
- package/node_modules/@mytools/fst-logs/src/run-logs.js +8 -23
- package/node_modules/@mytools/fst-logs/src/run-preview.js +18 -0
- package/node_modules/@mytools/fst-logs/src/step-summaries.js +28 -0
- package/node_modules/@mytools/fst-platform/README.md +17 -0
- package/node_modules/@mytools/fst-platform/package.json +1 -0
- package/node_modules/@mytools/fst-platform/src/process.js +12 -8
- 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-plugin/src/host.js +7 -4
- package/node_modules/@mytools/fst-plugin/src/runtime.js +8 -5
- 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 +1212 -1187
- package/package.json +3 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import {createHash} from 'node:crypto';
|
|
4
|
+
import {definePlugin,FlowError} from '@mytools/sdk';
|
|
5
|
+
import {runShell} from '@mytools/fst-platform/shell';
|
|
6
|
+
|
|
7
|
+
export const COMMANDS_PLUGIN_ID='@mytools/commands';
|
|
8
|
+
export const commandsPluginRoot=path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
const reserved=new Set(['__proto__','constructor','prototype']);
|
|
11
|
+
const validObject=value=>value!==null&&typeof value==='object'&&!Array.isArray(value)&&[Object.prototype,null].includes(Object.getPrototypeOf(value));
|
|
12
|
+
const validText=value=>typeof value==='string'&&Boolean(value.trim())&&!value.includes('\u0000');
|
|
13
|
+
const invalid=(location,message)=>{throw new FlowError(`${location}:${message}`,{code:'VALIDATION',details:{path:location}});};
|
|
14
|
+
function fields(value,allowed,location){
|
|
15
|
+
if(!validObject(value))invalid(location,'必须为字段对象');
|
|
16
|
+
for(const key of Reflect.ownKeys(value))if(typeof key!=='string'||!allowed.includes(key))invalid(`${location}.${String(key)}`,'包含未知字段');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateCommandPresets(presets){
|
|
20
|
+
if(!validObject(presets))invalid('commandPresets','必须为命令注册对象');
|
|
21
|
+
for(const id of Reflect.ownKeys(presets)){
|
|
22
|
+
const location=`commandPresets.${String(id)}`;
|
|
23
|
+
if(typeof id!=='string'||!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/.test(id)||reserved.has(id))invalid(location,'ID 只能使用 ASCII 字母、数字、下划线和短横线,最多 80 字符,且不能为原型保留名称');
|
|
24
|
+
const preset=presets[id];
|
|
25
|
+
fields(preset,['title','script','shell','timeout','parameters','requireRoot'],location);
|
|
26
|
+
if(!validText(preset.title))invalid(`${location}.title`,'标题必须为非空字符串');
|
|
27
|
+
if(!validText(preset.script))invalid(`${location}.script`,'命令文本必须为非空字符串,且不能包含 NUL 字符');
|
|
28
|
+
if(preset.shell!==undefined&&!['auto','powershell','pwsh','sh','bash'].includes(preset.shell))invalid(`${location}.shell`,'Shell 必须为 auto、powershell、pwsh、sh 或 bash');
|
|
29
|
+
if(preset.timeout!==undefined&&(!Number.isSafeInteger(preset.timeout)||preset.timeout<0||preset.timeout>2147483647))invalid(`${location}.timeout`,'超时必须为 0 到 2147483647 的整数毫秒,0 表示不限时');
|
|
30
|
+
if(preset.requireRoot!==undefined&&typeof preset.requireRoot!=='boolean')invalid(`${location}.requireRoot`,'root 需求必须为布尔值');
|
|
31
|
+
if(preset.parameters===undefined)continue;
|
|
32
|
+
if(!validObject(preset.parameters))invalid(`${location}.parameters`,'参数必须为字段对象');
|
|
33
|
+
const names=new Set();
|
|
34
|
+
for(const name of Reflect.ownKeys(preset.parameters)){
|
|
35
|
+
const at=`${location}.parameters.${String(name)}`;
|
|
36
|
+
if(typeof name!=='string'||!/^[a-zA-Z_][a-zA-Z0-9_]{0,79}$/.test(name)||reserved.has(name)||Object.hasOwn(Object.prototype,name))invalid(at,'参数名只能使用 ASCII 字母、数字和下划线,不能以数字开头或使用原型保留名称');
|
|
37
|
+
const portable=name.toLowerCase();
|
|
38
|
+
if(names.has(portable))invalid(at,'参数名不能仅以大小写区分,以免 Windows 环境变量冲突');
|
|
39
|
+
names.add(portable);
|
|
40
|
+
const field=preset.parameters[name];
|
|
41
|
+
fields(field,['type','title','default','required'],at);
|
|
42
|
+
if(!['string','number','boolean'].includes(field.type))invalid(`${at}.type`,'参数类型必须为 string、number 或 boolean');
|
|
43
|
+
if(field.title!==undefined&&!validText(field.title))invalid(`${at}.title`,'参数标题必须为非空字符串');
|
|
44
|
+
if(field.required!==undefined&&typeof field.required!=='boolean')invalid(`${at}.required`,'必填声明必须为布尔值');
|
|
45
|
+
if(Object.hasOwn(field,'default')&&(typeof field.default!==field.type||field.type==='number'&&!Number.isFinite(field.default)||field.type==='string'&&field.default.includes('\u0000')))invalid(`${at}.default`,'默认值必须符合参数类型且可作为环境变量传递');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
try{return structuredClone(presets);}
|
|
49
|
+
catch(cause){throw new FlowError('命令注册项必须可复制和保存',{code:'VALIDATION',cause});}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function inputSchema(parameters){
|
|
53
|
+
const properties=Object.fromEntries(Object.entries(parameters).map(([name,{type,title,default:value}])=>[name,{
|
|
54
|
+
type,title:title??name,...(type==='string'?{pattern:'^[^\\u0000]*$'}:{}),
|
|
55
|
+
...(Object.hasOwn(parameters[name],'default')?{default:value}:{}),
|
|
56
|
+
}]));
|
|
57
|
+
return {type:'object',properties,required:Object.entries(parameters).filter(([,field])=>field.required).map(([name])=>name),additionalProperties:false};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const outputSchema={type:'object',properties:{
|
|
61
|
+
exitCode:{type:'integer',title:'退出码'},stdout:{type:'string',title:'标准输出摘要'},stderr:{type:'string',title:'标准错误摘要'},
|
|
62
|
+
outputBytes:{type:'object',title:'完整输出字节数',properties:{stdout:{type:'integer',minimum:0},stderr:{type:'integer',minimum:0}},required:['stdout','stderr'],additionalProperties:false},
|
|
63
|
+
outputTruncated:{type:'boolean',title:'摘要已截断'},
|
|
64
|
+
},required:['exitCode','stdout','stderr','outputBytes','outputTruncated'],additionalProperties:false};
|
|
65
|
+
|
|
66
|
+
export function createCommandsPlugin(presets={}){
|
|
67
|
+
const checked=validateCommandPresets(presets);
|
|
68
|
+
const actions=Object.fromEntries(Object.entries(checked).map(([id,preset])=>{
|
|
69
|
+
const parameters=preset.parameters??{};
|
|
70
|
+
return [id,{
|
|
71
|
+
title:preset.title,description:'执行已注册的非交互 Shell 命令,完整输出保存到运行日志。',
|
|
72
|
+
input:inputSchema(parameters),output:structuredClone(outputSchema),
|
|
73
|
+
...(preset.requireRoot?{requirements:{privilege:{linux:{level:'root',reason:`命令“${preset.title}”声明需要 Linux root 权限`,remediation:'请使用具备 root 权限的会话运行 Fishtools,或修改命令以使用普通用户权限。'}}}}:{}),
|
|
74
|
+
async run(input,ctx){
|
|
75
|
+
// 仅传递字段值;参数中的引号、换行和命令替换都不会进入脚本源码。
|
|
76
|
+
const env=Object.fromEntries(Object.keys(parameters).map(name=>[`FST_INPUT_${name}`,Object.hasOwn(input,name)?String(input[name]):'']));
|
|
77
|
+
const {exitCode,stdout,stderr,outputBytes,outputTruncated}=await runShell(preset.script,{
|
|
78
|
+
shell:preset.shell??'auto',runProcess:ctx.runProcess,temporaryDir:ctx.outputDir,
|
|
79
|
+
signal:ctx.signal,cwd:ctx.projectDir,env,outputMode:'stream',summaryBytes:65536,
|
|
80
|
+
...(preset.timeout!==undefined?{timeout:preset.timeout}:{}),
|
|
81
|
+
});
|
|
82
|
+
const output={exitCode,stdout,stderr,outputBytes,outputTruncated};
|
|
83
|
+
if(exitCode!==0)throw new FlowError(`命令“${preset.title}”执行失败(退出码 ${exitCode}):${(stderr||stdout||'没有输出').slice(-2000)}`,{code:'EXECUTION_FAILED',details:output});
|
|
84
|
+
return output;
|
|
85
|
+
},
|
|
86
|
+
}];
|
|
87
|
+
}));
|
|
88
|
+
// run 的函数文本不包含闭包中的脚本;摘要使定向更新能识别脚本、Shell 和超时变化。
|
|
89
|
+
const commandPresetDigest=createHash('sha256').update(JSON.stringify(checked)).digest('hex');
|
|
90
|
+
return definePlugin({id:COMMANDS_PLUGIN_ID,version:'0.1.0',commandPresetDigest,title:'轻量命令',description:'将项目中注册的系统命令作为稳定的工作流动作执行。',platforms:['win32','linux'],
|
|
91
|
+
requirements:{privilege:{linux:{level:'user'},win32:{level:'user'}}},actions});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default createCommandsPlugin();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mytools/commands",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.js",
|
|
8
|
+
"./package.json": "./package.json"
|
|
9
|
+
},
|
|
10
|
+
"files": ["index.js", "README.md", "LICENSE"],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"engines": {"node": ">=24.15.0"},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@mytools/sdk": "0.1.0",
|
|
15
|
+
"@mytools/fst-platform": "0.1.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
组合各能力模块、协调运行与关闭、提供 CLI/Web 共用应用接口及导出声明快照。日志写入和缓存由 logs 负责;导出实现通过可选 pack 依赖加载。
|
|
8
8
|
|
|
9
|
-
内部依赖:`@mytools/sdk`、`@mytools/fst-platform`、`@mytools/fst-logs`、`@mytools/fst-workflow`、`@mytools/fst-plugin`、`@mytools/fst-workspace`、`@mytools/pack`。
|
|
9
|
+
内部依赖:`@mytools/sdk`、`@mytools/fst-platform`、`@mytools/fst-logs`、`@mytools/fst-workflow`、`@mytools/fst-plugin`、`@mytools/fst-workspace`、官方 `@mytools/commands`、`@mytools/pack`。
|
|
10
10
|
`@mytools/pack` 是可选 peer 依赖;普通发行包含它,只读发行省略它。既有 `/process`、`/bundle`、`/environments` 子入口保留兼容转发。
|
|
11
11
|
|
|
12
12
|
## 公开入口
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
|
|
26
26
|
## 维护与验证
|
|
27
27
|
|
|
28
|
+
`app.startShell(script,options)` 执行临时命令,`app.startAction(id,input,context)` 调用任意动作,两者均纳入运行记录与关闭等待。`startCommand(name,input,{expectedPlugin,...context})` 可固定命令提供者,启动时再次检查,防止参数填写期间被其他同名能力替换。`configureCommandPresets` 和 `configureShortcuts` 在备份、冲突检查与变更锁内保存项目设置;命令快捷项在用户明确设置时绑定提供者 ID,普通安装/卸载和未改变目标的整组保存不会重新绑定;已有未绑定项必须明确提供 plugin 或在界面重新选择目标。注册项变化通过定向宿主更新生效,导出快照保留所需定义与有效快捷项。
|
|
29
|
+
|
|
28
30
|
本模块负责上述职责的实现、文档与回归;跨模块接口调整需要同时检查生产者与消费者。具体维护人员尚未指定。
|
|
29
31
|
|
|
30
32
|
在仓库根目录执行:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readFile, writeFile, rename, rm } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { EventEmitter } from 'node:events';
|
|
@@ -7,6 +7,9 @@ import { createHost } from '@mytools/fst-plugin';
|
|
|
7
7
|
import { WorkflowStore } from '@mytools/fst-workspace/workflows';
|
|
8
8
|
import { runWorkflow } from '@mytools/fst-workflow';
|
|
9
9
|
import { runProcess } from '@mytools/fst-platform';
|
|
10
|
+
import {runShell} from '@mytools/fst-platform/shell';
|
|
11
|
+
import {COMMANDS_PLUGIN_ID,commandsPluginRoot,createCommandsPlugin,validateCommandPresets} from '@mytools/commands';
|
|
12
|
+
import {validateShortcuts,describeShortcuts,bindShortcutCommands} from '@mytools/fst-workspace/shortcuts';
|
|
10
13
|
import {createFileProbe} from '@mytools/fst-platform/fs';
|
|
11
14
|
import {readJson} from '@mytools/fst-platform/json';
|
|
12
15
|
import {loadPlugin} from '@mytools/fst-plugin/loader';
|
|
@@ -20,9 +23,22 @@ import {createExportSnapshot} from './export-snapshot.js';
|
|
|
20
23
|
import {createRunLogs,validateKeepRuns} from '@mytools/fst-logs';
|
|
21
24
|
import {openPluginState,withPluginMutation,assertPluginState,savePluginState,leaseInstallation} from '@mytools/fst-workspace/installations';
|
|
22
25
|
import {planPluginUpdate,changedPluginSources} from './plugin-update.js';
|
|
23
|
-
import {validateNetworkPolicy,inspectNetworkPolicy} from '@mytools/fst-platform/network';
|
|
26
|
+
import {validateNetworkPolicy,inspectNetworkPolicy,resolveNetworkPolicy,buildProcessEnv} from '@mytools/fst-platform/network';
|
|
24
27
|
import {resolvePrivilegeRequirement,checkStaticPrivilege} from '@mytools/fst-plugin/privileges';
|
|
25
28
|
|
|
29
|
+
function declaredCommands(entries){
|
|
30
|
+
return entries.flatMap(entry=>{const plugin=entry.plugin??entry;return Object.keys(plugin.commands??{}).map(name=>({name,plugin:plugin.id}));});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function workflowActions(steps,found=new Set()){
|
|
34
|
+
for(const step of steps??[]){
|
|
35
|
+
if(step.action)found.add(step.action);
|
|
36
|
+
if(step.branch){workflowActions(step.branch.then,found);workflowActions(step.branch.else,found);}
|
|
37
|
+
if(step.parallel)workflowActions(step.parallel.steps,found);
|
|
38
|
+
}
|
|
39
|
+
return found;
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
export async function createApplication({projectDir=process.cwd(),workspace,outputDir,plugins,config={},maintenance=false,omitInstalledPlugin,allowUnknownPrivileges=false}={}) {
|
|
27
43
|
projectDir=await prepareBundleWorkspace(path.resolve(projectDir),workspace);
|
|
28
44
|
const project=await readJson(path.join(projectDir,'flow.project.json'),{plugins:[]});
|
|
@@ -47,9 +63,14 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
47
63
|
catalog:()=>host.catalog(),
|
|
48
64
|
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,requirements:p.requirements,privileges:pluginPrivileges(p),runtime:{...host.runtimeStatus().find(r=>r.id===p.id),...(p.runtime?{updatePolicy:p.runtime.updatePolicy??'maintenance'}:{})},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)})),
|
|
49
65
|
listCommands:()=>host.commands(),
|
|
66
|
+
listShortcuts:()=>describeShortcuts(project.shortcuts??{},{actions:host.catalog(),commands:host.commands()}),
|
|
67
|
+
async configureShortcuts(value){return updateUserCommands({shortcuts:value});},
|
|
68
|
+
async configureCommandPresets(value,options={}){
|
|
69
|
+
return updateUserCommands({commandPresets:value,...(options.shortcuts!==undefined?{shortcuts:options.shortcuts}:{})},options);
|
|
70
|
+
},
|
|
50
71
|
listWorkflows:()=>store.list(),
|
|
51
|
-
async saveWorkflow(w){return mutateWorkflow(()=>store.save(w));},
|
|
52
|
-
async duplicateWorkflow(definition,options){return mutateWorkflow(()=>store.duplicate(definition,options));},
|
|
72
|
+
async saveWorkflow(w){return mutateWorkflow(()=>store.save(w),w);},
|
|
73
|
+
async duplicateWorkflow(definition,options){return mutateWorkflow(()=>store.duplicate(definition,options),definition);},
|
|
53
74
|
async removeWorkflow(id){return mutateWorkflow(()=>store.remove(id));},
|
|
54
75
|
async checkWorkflow(id){
|
|
55
76
|
host.assertHealthy();
|
|
@@ -71,7 +92,29 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
71
92
|
async startCommand(name,input={},context={}){
|
|
72
93
|
if(busy)throw new FlowError('插件更新中,请稍后调用');
|
|
73
94
|
const networkPolicy=structuredClone(project.network??{});
|
|
74
|
-
return app.executeTracked(`/${name}`,(activeHost,runContext)=>
|
|
95
|
+
return app.executeTracked(`/${name}`,(activeHost,runContext)=>{
|
|
96
|
+
if(context.expectedPlugin!==undefined&&!activeHost.commands().some(command=>command.name===name&&command.plugin===context.expectedPlugin)){
|
|
97
|
+
throw new FlowError('命令目标插件已变更,请重新选择目标后执行',{code:'COMMAND_TARGET_CHANGED',details:{command:name,plugin:context.expectedPlugin}});
|
|
98
|
+
}
|
|
99
|
+
return activeHost.invokeCommand(name,input,{...context,...runContext,networkPolicy});
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
async startAction(id,input={},context={}){
|
|
103
|
+
if(busy)throw new FlowError('插件更新中,请稍后调用');
|
|
104
|
+
const networkPolicy=structuredClone(project.network??{});
|
|
105
|
+
return app.executeTracked(id,(activeHost,runContext)=>activeHost.invoke(id,input,{...context,...runContext,networkPolicy}));
|
|
106
|
+
},
|
|
107
|
+
async startShell(script,{shell='auto',timeout=30000}={}){
|
|
108
|
+
if(busy)throw new FlowError('项目更新中,请稍后执行命令');
|
|
109
|
+
if(!Number.isSafeInteger(timeout)||timeout<0||timeout>2147483647)throw new FlowError('命令超时必须为 0 到 2147483647 毫秒,0 表示不限时',{code:'VALIDATION'});
|
|
110
|
+
const policy=resolveNetworkPolicy(structuredClone(project.network??{}));
|
|
111
|
+
const env=buildProcessEnv(policy);
|
|
112
|
+
return app.executeTracked('!命令',async(_host,context)=>{
|
|
113
|
+
const result=await runShell(script,{shell,temporaryDir:context.outputDir,cwd:projectDir,env,extendEnv:false,signal:context.signal,timeout,outputMode:'stream',summaryBytes:65536,
|
|
114
|
+
onOutput:({stream,message})=>context.log(message,undefined,{stream})});
|
|
115
|
+
if(result.exitCode!==0)throw new FlowError(`命令执行失败,退出码 ${result.exitCode}`,{code:'EXECUTION_FAILED',details:result});
|
|
116
|
+
return result;
|
|
117
|
+
});
|
|
75
118
|
},
|
|
76
119
|
async executeTracked(id,execute){
|
|
77
120
|
const runId=randomUUID(),controller=new AbortController();
|
|
@@ -245,7 +288,11 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
245
288
|
let initial;
|
|
246
289
|
const services={application:app,runProcess,fs:fileProbe};
|
|
247
290
|
const hostProject=()=>({directory:projectDir,readOnly:!!project.readOnly});
|
|
248
|
-
const build=extra=>
|
|
291
|
+
const build=extra=>{
|
|
292
|
+
const entries=entriesWithPresets([...initial,...extra],project.commandPresets??{});
|
|
293
|
+
validateShortcuts(project.shortcuts??{},{reservedNames:declaredCommands(entries).map(command=>command.name)});
|
|
294
|
+
return createHost({plugins:entries,config:{...project.config,...config},services,project:hostProject(),network:()=>project.network,environmentOverrides:project.environments,runtimeEnabled:!maintenance,allowUnknownPrivileges});
|
|
295
|
+
};
|
|
249
296
|
try{
|
|
250
297
|
initial=plugins??await Promise.all((project.plugins??[]).map(spec=>loadPlugin(spec,project.baseBundle?.root??projectDir)));
|
|
251
298
|
const external=[];
|
|
@@ -253,8 +300,8 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
253
300
|
for(const [name,source] of Object.entries(state.links??{}))if(!maintenance||name!==omitInstalledPlugin)external.push(await loadPlugin(source,projectDir));
|
|
254
301
|
host=await build(external);
|
|
255
302
|
}catch(error){await fileProbe.close();await opened.release();throw error;}
|
|
256
|
-
for(const name of ['startRun','startCommand','startWeb','saveWorkflow','duplicateWorkflow','removeWorkflow','installPlugin','linkPlugin','removePlugin','configurePlugin','configureNetworkPolicy','configureLogs','setMaintenance','pack']){
|
|
257
|
-
const original=app[name],isRun=['startRun','startCommand'].includes(name);
|
|
303
|
+
for(const name of ['startRun','startCommand','startAction','startShell','startWeb','saveWorkflow','duplicateWorkflow','removeWorkflow','installPlugin','linkPlugin','removePlugin','configurePlugin','configureNetworkPolicy','configureLogs','configureCommandPresets','configureShortcuts','setMaintenance','pack']){
|
|
304
|
+
const original=app[name],isRun=['startRun','startCommand','startAction','startShell'].includes(name);
|
|
258
305
|
app[name]=async(...args)=>{
|
|
259
306
|
if(closing)throw new FlowError('应用已关闭');
|
|
260
307
|
if(isRun)launching++;
|
|
@@ -262,11 +309,16 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
262
309
|
try{return await task;}finally{operations.delete(task);if(isRun)launching--;}
|
|
263
310
|
};
|
|
264
311
|
}
|
|
265
|
-
async function mutateWorkflow(operation){
|
|
312
|
+
async function mutateWorkflow(operation,definition){
|
|
266
313
|
if(project.readOnly)throw new FlowError('只读运行包不能修改工作流');
|
|
267
314
|
if(busy)throw new FlowError('插件变更期间暂不能修改工作流',{code:'PLUGIN_BUSY'});
|
|
268
315
|
return withPluginMutation(projectDir,async()=>{
|
|
269
316
|
await assertPluginState(projectDir,state);
|
|
317
|
+
if(definition){
|
|
318
|
+
const workflow=store.validateShape(definition),latest=await readJson(path.join(projectDir,'flow.project.json'),project);
|
|
319
|
+
const missing=[...workflowActions(workflow.steps)].filter(id=>id.startsWith(`${COMMANDS_PLUGIN_ID}:`)&&!Object.hasOwn(latest.commandPresets??{},id.slice(COMMANDS_PLUGIN_ID.length+1)));
|
|
320
|
+
if(missing.length)throw new FlowError('引用的注册命令已删除,请重新选择动作后保存',{code:'COMMAND_NOT_FOUND',details:{actions:missing}});
|
|
321
|
+
}
|
|
270
322
|
return operation();
|
|
271
323
|
});
|
|
272
324
|
}
|
|
@@ -280,6 +332,55 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
280
332
|
await writeFile(backup,original,{flag:'wx',mode:0o600});
|
|
281
333
|
return backup;
|
|
282
334
|
}
|
|
335
|
+
function entriesWithPresets(entries,presets){
|
|
336
|
+
const definition=createCommandsPlugin(presets);
|
|
337
|
+
const result=entries.filter(entry=>(entry.plugin??entry).id!==COMMANDS_PLUGIN_ID);
|
|
338
|
+
if(Object.keys(definition.actions).length)result.push({plugin:definition,root:commandsPluginRoot});
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
async function updateUserCommands(changes,options={}){
|
|
342
|
+
if(project.readOnly)throw new FlowError('只读运行包不能修改注册命令或快捷名称');
|
|
343
|
+
if(busy||launching||app.listRuns().some(run=>run.status==='running'))throw new FlowError('请等待运行或安装结束');
|
|
344
|
+
const requested=structuredClone(changes);
|
|
345
|
+
if(Object.hasOwn(requested,'commandPresets'))requested.commandPresets=validateCommandPresets(requested.commandPresets);
|
|
346
|
+
busy=true;
|
|
347
|
+
try{return await withPluginMutation(projectDir,async()=>{
|
|
348
|
+
await assertPluginState(projectDir,state);
|
|
349
|
+
const file=path.join(projectDir,'flow.project.json'),latest=await readJson(file,project);
|
|
350
|
+
if(latest.readOnly)throw new FlowError('只读运行包不能修改注册命令或快捷名称');
|
|
351
|
+
for(const field of Object.keys(requested))if(!isDeepStrictEqual(latest[field]??{},project[field]??{})){
|
|
352
|
+
throw new FlowError('注册命令或快捷名称已被另一实例修改,请重新打开工作区再保存',{code:'USER_COMMANDS_CONFLICT',details:{field}});
|
|
353
|
+
}
|
|
354
|
+
const nextProject={...latest,...requested};
|
|
355
|
+
const presets=validateCommandPresets(nextProject.commandPresets??{});
|
|
356
|
+
const oldEntries=[...host.definitions.values()].map(plugin=>({plugin,root:plugin.root}));
|
|
357
|
+
const entries=entriesWithPresets(oldEntries,presets);
|
|
358
|
+
const reservedNames=declaredCommands(entries).map(command=>command.name);
|
|
359
|
+
const shortcuts=Object.hasOwn(requested,'shortcuts')?bindShortcutCommands(nextProject.shortcuts??{},{commands:declaredCommands(entries),previous:latest.shortcuts??{}}):nextProject.shortcuts??{};
|
|
360
|
+
nextProject.shortcuts=validateShortcuts(shortcuts,{reservedNames});
|
|
361
|
+
const removed=new Set(Object.keys(project.commandPresets??{}).filter(id=>!Object.hasOwn(presets,id)).map(id=>`${COMMANDS_PLUGIN_ID}:${id}`));
|
|
362
|
+
if(removed.size){
|
|
363
|
+
const workflows=(await findPluginWorkflowReferences(COMMANDS_PLUGIN_ID)).filter(workflow=>workflow.actions.some(action=>removed.has(action)));
|
|
364
|
+
if(workflows.length)throw new FlowError(`命令仍被工作流使用:${workflows.map(workflow=>workflow.title).join('、')}`,{code:'COMMAND_IN_USE',details:{workflows}});
|
|
365
|
+
}
|
|
366
|
+
const effectiveConfig={...latest.config,...config},oldConfig={...project.config,...config};
|
|
367
|
+
const nextDefinitions=new Map(entries.map(entry=>{const plugin=entry.plugin??entry;return [plugin.id,plugin];}));
|
|
368
|
+
const changed=new Set(isDeepStrictEqual(presets,project.commandPresets??{})?[]:[COMMANDS_PLUGIN_ID]);
|
|
369
|
+
const plan=planPluginUpdate(host.definitions,nextDefinitions,oldConfig,effectiveConfig,changed);
|
|
370
|
+
assertMaintenance(plan,options);
|
|
371
|
+
const backup=await backupProjectFile(file,latest,'commands');
|
|
372
|
+
if(plan.affected.length)await host.replace({plugins:entries,config:effectiveConfig,affected:plan.affected});
|
|
373
|
+
const temp=`${file}.${randomUUID()}.tmp`;
|
|
374
|
+
try{await writeFile(temp,JSON.stringify(nextProject,null,2));await rename(temp,file);}
|
|
375
|
+
catch(error){
|
|
376
|
+
if(plan.affected.length)try{await host.replace({plugins:oldEntries,config:oldConfig,affected:plan.affected});}
|
|
377
|
+
catch(recovery){host.invalidate();throw new AggregateError([error,recovery],`命令设置保存失败且恢复失败:${error.message};${recovery.message}`);}
|
|
378
|
+
throw error;
|
|
379
|
+
}finally{await rm(temp,{force:true});}
|
|
380
|
+
for(const key of Object.keys(project))delete project[key];Object.assign(project,nextProject);
|
|
381
|
+
return {backup,commandPresets:structuredClone(presets),shortcuts:structuredClone(nextProject.shortcuts)};
|
|
382
|
+
});}finally{busy=false;}
|
|
383
|
+
}
|
|
283
384
|
function pluginPrivileges(plugin){
|
|
284
385
|
const report=action=>{
|
|
285
386
|
const requirement=resolvePrivilegeRequirement(plugin,action);
|
|
@@ -293,7 +394,15 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
293
394
|
if(active.length)throw new FlowError(`请先停止受影响插件提供的 Web:${active.join('、')}`,{code:'WEB_ACTIVE',details:{plugins:active}});
|
|
294
395
|
}
|
|
295
396
|
function workflowPrivileges(closure,entryId){
|
|
296
|
-
const flows=new Map(closure.workflows.map(w=>[w.id,w])),report=[];
|
|
397
|
+
const flows=new Map(closure.workflows.map(w=>[w.id,w])),report=[],stoppingFlows=new Map();
|
|
398
|
+
const mayStopFlow=id=>{
|
|
399
|
+
if(!stoppingFlows.has(id))stoppingFlows.set(id,(flows.get(id)?.steps??[]).some(mayStopStep));
|
|
400
|
+
return stoppingFlows.get(id);
|
|
401
|
+
};
|
|
402
|
+
const mayStopStep=step=>!!step.stop
|
|
403
|
+
||!!step.workflow&&mayStopFlow(store.childId(step.workflow))
|
|
404
|
+
||!!step.branch&&[...step.branch.then,...(step.branch.else??[])].some(mayStopStep)
|
|
405
|
+
||!!step.parallel&&step.parallel.steps.some(mayStopStep);
|
|
297
406
|
const visit=(id,mandatory)=>{
|
|
298
407
|
const workflow=flows.get(id);
|
|
299
408
|
if(!workflow)return;
|
|
@@ -308,8 +417,9 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
308
417
|
}
|
|
309
418
|
if(step.workflow)visit(store.childId(step.workflow),needed);
|
|
310
419
|
if(step.branch){steps(step.branch.then,false);steps(step.branch.else??[],false);}
|
|
311
|
-
|
|
312
|
-
if(step.
|
|
420
|
+
// 并行兄弟可能在动作开始前停止整组;嵌套 stop 也会传播到后续步骤。
|
|
421
|
+
if(step.parallel)steps(step.parallel.steps,needed&&!step.parallel.steps.some(mayStopStep));
|
|
422
|
+
if(mayStopStep(step))available=false;
|
|
313
423
|
}
|
|
314
424
|
};
|
|
315
425
|
steps(workflow.steps,mandatory);
|
|
@@ -318,14 +428,9 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
318
428
|
}
|
|
319
429
|
async function findPluginWorkflowReferences(id){
|
|
320
430
|
const refs=[];
|
|
321
|
-
const collect=(steps,found)=>{for(const step of steps??[]){
|
|
322
|
-
if(typeof step.action==='string'&&step.action.startsWith(`${id}:`))found.add(step.action);
|
|
323
|
-
if(step.branch){collect(step.branch.then,found);collect(step.branch.else,found);}
|
|
324
|
-
if(step.parallel)collect(step.parallel.steps,found);
|
|
325
|
-
}};
|
|
326
431
|
for(const workflow of await store.list()){
|
|
327
|
-
const
|
|
328
|
-
if(
|
|
432
|
+
const actions=[...workflowActions(workflow.steps)].filter(action=>action.startsWith(`${id}:`));
|
|
433
|
+
if(actions.length)refs.push({id:workflow.id,title:workflow.title??workflow.id,actions});
|
|
329
434
|
}
|
|
330
435
|
return refs;
|
|
331
436
|
}
|
|
@@ -362,8 +467,9 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
362
467
|
if(workflows.length&&!options.force)throw new FlowError(`插件 ${name} 仍被工作流引用:${workflows.map(w=>w.id).join('、')}`,{code:'PLUGIN_IN_USE',details:{workflows}});
|
|
363
468
|
}
|
|
364
469
|
const next=await installGeneration(projectDir,{...operation,state});
|
|
365
|
-
const nextEntries=[...initial,...next.loaded],activeConfig={...project.config,...config},effectiveConfig={...latest.config,...config};
|
|
470
|
+
const nextEntries=entriesWithPresets([...initial,...next.loaded],latest.commandPresets??{}),activeConfig={...project.config,...config},effectiveConfig={...latest.config,...config};
|
|
366
471
|
candidate=await createHost({plugins:nextEntries,config:effectiveConfig,services,project:hostProject(),network:()=>project.network,environmentOverrides:project.environments,runtimeEnabled:false,allowUnknownPrivileges});
|
|
472
|
+
validateShortcuts(latest.shortcuts??{},{reservedNames:declaredCommands(nextEntries).map(command=>command.name)});
|
|
367
473
|
const sourceChanges=await changedPluginSources(host.definitions,candidate.definitions,{oldInstallationRoot:state.root,newInstallationRoot:next.state.root});
|
|
368
474
|
const plan=planPluginUpdate(host.definitions,candidate.definitions,activeConfig,effectiveConfig,sourceChanges);
|
|
369
475
|
assertMaintenance(plan,options);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {fileURLToPath} from 'node:url';
|
|
2
2
|
import {FlowError} from '@mytools/sdk';
|
|
3
|
+
import {COMMANDS_PLUGIN_ID} from '@mytools/commands';
|
|
3
4
|
|
|
4
5
|
function selectedNetwork(network,ids){
|
|
5
6
|
if(!network)return undefined;
|
|
@@ -28,6 +29,24 @@ export async function createExportSnapshot({store,host,project},{entries,portabl
|
|
|
28
29
|
for(const id of closure.plugins)ids.add(id);
|
|
29
30
|
if(includeWeb){if(!web)throw new FlowError('未安装提供 Web 服务的插件');ids.add(web.id);}
|
|
30
31
|
for(const id of ids)for(const dependency of Object.keys(host.definitions.get(id)?.dependencies??{}))ids.add(dependency);
|
|
32
|
+
const usedActions=new Set();
|
|
33
|
+
const scan=steps=>{for(const step of steps??[]){
|
|
34
|
+
if(step.action)usedActions.add(step.action);
|
|
35
|
+
if(step.branch){scan(step.branch.then);scan(step.branch.else);}
|
|
36
|
+
if(step.parallel)scan(step.parallel.steps);
|
|
37
|
+
}};
|
|
38
|
+
for(const workflow of closure.workflows)scan(workflow.steps);
|
|
39
|
+
const commandPresets=Object.fromEntries(Object.entries(project.commandPresets??{}).filter(([id])=>ids.has(COMMANDS_PLUGIN_ID)&&(portable||usedActions.has(`${COMMANDS_PLUGIN_ID}:${id}`))));
|
|
40
|
+
const commandAllowed=id=>!id.startsWith(`${COMMANDS_PLUGIN_ID}:`)||Object.hasOwn(commandPresets,id.slice(COMMANDS_PLUGIN_ID.length+1));
|
|
41
|
+
const commands=new Map(host.commands().map(command=>[command.name,command]));
|
|
42
|
+
const shortcuts=Object.fromEntries(Object.entries(project.shortcuts??{}).filter(([,target])=>{
|
|
43
|
+
if(target.action){
|
|
44
|
+
const action=host.actions.get(target.action);
|
|
45
|
+
return action&&ids.has(action.plugin)&&commandAllowed(target.action);
|
|
46
|
+
}
|
|
47
|
+
const command=commands.get(target.command);
|
|
48
|
+
return command&&target.plugin===command.plugin&&ids.has(command.plugin)&&(!command.action||commandAllowed(`${command.plugin}:${command.action}`));
|
|
49
|
+
}));
|
|
31
50
|
const plugins=[...ids].map(id=>{
|
|
32
51
|
const p=host.definitions.get(id);
|
|
33
52
|
if(!p?.root)throw new FlowError('内存中临时定义的插件不能打包,请先安装为标准包');
|
|
@@ -37,6 +56,6 @@ export async function createExportSnapshot({store,host,project},{entries,portabl
|
|
|
37
56
|
['actions',Object.fromEntries(Object.entries(p.actions??{}).map(([name,action])=>[name,{environments:action.environments,requirements:action.requirements}]))],
|
|
38
57
|
]);
|
|
39
58
|
});
|
|
40
|
-
return structuredClone({entries,closure,plugins,project:{environments:project.environments,network:selectedNetwork(project.network,ids),logs:project.logs,baseBundle:project.baseBundle},
|
|
59
|
+
return structuredClone({entries,closure,plugins,project:{environments:project.environments,network:selectedNetwork(project.network,ids),logs:project.logs,baseBundle:project.baseBundle,commandPresets,shortcuts},
|
|
41
60
|
toolRoot:fileURLToPath(new URL('../../fst-cli',import.meta.url))});
|
|
42
61
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// 由 npm version 的生命周期脚本同步,内部插件 API 版本独立维护。
|
|
2
|
-
export const VERSION="0.5.
|
|
2
|
+
export const VERSION="0.5.2";
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
fst 命令入口、交互终端、输入呈现和运行观察。通过公开应用和模块接口工作,业务执行不在界面中重复实现。
|
|
8
8
|
|
|
9
|
-
内部依赖:`@mytools/sdk`、`@mytools/core`、`@mytools/fst-workspace`、`@mytools/fst-platform`、`@mytools/fst-plugin`、`@mytools/fst-devtools`。
|
|
9
|
+
内部依赖:`@mytools/sdk`、`@mytools/core`、`@mytools/fst-workspace`、`@mytools/fst-platform`、`@mytools/fst-plugin`、`@mytools/fst-devtools`、`@mytools/fst-logs`。
|
|
10
10
|
## 公开入口
|
|
11
11
|
|
|
12
12
|
| 导入路径 | 实现位置 |
|
|
@@ -33,6 +33,8 @@ fst 命令入口、交互终端、输入呈现和运行观察。通过公开应
|
|
|
33
33
|
|
|
34
34
|
## 维护与验证
|
|
35
35
|
|
|
36
|
+
TUI 支持 `!` 系统命令、`/commands` 常用命令注册和 `/shortcuts` 任意插件能力快捷名。主输入框与命令编辑框使用 Shift+Enter / Alt+Enter 换行、Enter 提交;真实多行粘贴保持换行及缩进。使用说明可通过 `fst docs commands` 离线阅读。
|
|
37
|
+
|
|
36
38
|
本模块负责上述职责的实现、文档与回归;跨模块接口调整需要同时检查生产者与消费者。具体维护人员尚未指定。
|
|
37
39
|
|
|
38
40
|
在仓库根目录执行:
|
|
@@ -52,3 +54,8 @@ node --test tests/modules.test.js tests/cli.test.js tests/terminal.test.js
|
|
|
52
54
|
## 完整日志分页
|
|
53
55
|
|
|
54
56
|
`fst logs` 列出历史,`fst logs <id> --cursor 0 --limit 65536 --json` 返回正文和下一页字节游标。TUI 运行详情按 `p` 打开完整日志,每页读取后可前后翻页或刷新。主面板保留有界近期摘要;日志清理失败显示维护警告。细节见 `fst docs generation`。
|
|
57
|
+
|
|
58
|
+
主面板通过 `fst-logs/steps` 共用步骤摘要缓存,按最近更新顺序淘汰,同时限制条数和字节量。
|
|
59
|
+
摘要不保存业务 input/output;缺失的状态显示“摘要已省略”,已有错误仍可查看。
|
|
60
|
+
运行输入、最终结果与错误通过 `fst-logs/preview` 保存有界预览,避免业务完成后又缓存完整大结果。
|
|
61
|
+
展开时明确提示结果为摘要,完整结果仍在日志中;不会替换业务调用方取得的完整输出。
|