mirror of
https://github.com/Lydanne/spaceflow.git
synced 2026-03-11 19:52:45 +08:00
chore: 初始化仓库
This commit is contained in:
94
cli/CHANGELOG.md
Normal file
94
cli/CHANGELOG.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Changelog
|
||||
|
||||
## [0.19.0](https://git.bjxgj.com/xgj/spaceflow/compare/@spaceflow/cli@0.18.0...@spaceflow/cli@0.19.0) (2026-02-15)
|
||||
|
||||
### 新特性
|
||||
|
||||
* **cli:** 新增 MCP Server 命令并集成 review 扩展的 MCP 工具 ([b794b36](https://git.bjxgj.com/xgj/spaceflow/commit/b794b36d90788c7eb4cbb253397413b4a080ae83))
|
||||
* **cli:** 新增 MCP Server 导出类型支持 ([9568cbd](https://git.bjxgj.com/xgj/spaceflow/commit/9568cbd14d4cfbdedaf2218379c72337af6db271))
|
||||
* **core:** 为所有命令添加 i18n 国际化支持 ([867c5d3](https://git.bjxgj.com/xgj/spaceflow/commit/867c5d3eccc285c8a68803b8aa2f0ffb86a94285))
|
||||
* **core:** 新增 GitLab 平台适配器并完善配置支持 ([47be9ad](https://git.bjxgj.com/xgj/spaceflow/commit/47be9adfa90944a9cb183e03286a7a96fec747f1))
|
||||
* **core:** 新增 Logger 全局日志工具并支持 plain/tui 双模式渲染 ([8baae7c](https://git.bjxgj.com/xgj/spaceflow/commit/8baae7c24139695a0e379e1c874023cd61dfc41b))
|
||||
* **docs:** 新增 VitePress 文档站点并完善项目文档 ([a79d620](https://git.bjxgj.com/xgj/spaceflow/commit/a79d6208e60390a44fa4c94621eb41ae20159e98))
|
||||
* **mcp:** 新增 MCP Inspector 交互式调试支持并优化工具日志输出 ([05fd2ee](https://git.bjxgj.com/xgj/spaceflow/commit/05fd2ee941c5f6088b769d1127cb7c0615626f8c))
|
||||
* **review:** 为 MCP 服务添加 i18n 国际化支持 ([a749054](https://git.bjxgj.com/xgj/spaceflow/commit/a749054eb73b775a5f5973ab1b86c04f2b2ddfba))
|
||||
* **review:** 新增规则级 includes 解析测试并修复文件级/规则级 includes 过滤逻辑 ([4baca71](https://git.bjxgj.com/xgj/spaceflow/commit/4baca71c17782fb92a95b3207f9c61e0b410b9ff))
|
||||
|
||||
### 修复BUG
|
||||
|
||||
* **actions:** 修正 pnpm setup 命令调用方式 ([8f014fa](https://git.bjxgj.com/xgj/spaceflow/commit/8f014fa90b74e20de4c353804d271b3ef6f1288f))
|
||||
* **mcp:** 添加 -y 选项确保 Inspector 自动安装依赖 ([a9201f7](https://git.bjxgj.com/xgj/spaceflow/commit/a9201f74bd9ddc5eba92beaaa676f377842863e0))
|
||||
|
||||
### 代码重构
|
||||
|
||||
* **claude:** 移除 .claude 目录及其 .gitignore 配置文件 ([91916a9](https://git.bjxgj.com/xgj/spaceflow/commit/91916a99f65da31c1d34e6f75b5cbea1d331ba35))
|
||||
* **cli:** 优化依赖安装流程并支持 .spaceflow 目录配置 ([5977631](https://git.bjxgj.com/xgj/spaceflow/commit/597763183eaa61bb024bba2703d75239650b54fb))
|
||||
* **cli:** 拆分 CLI 为独立包并重构扩展加载机制 ([b385d28](https://git.bjxgj.com/xgj/spaceflow/commit/b385d281575f29b823bb6dc4229a396a29c0e226))
|
||||
* **cli:** 移除 ExtensionModule 并优化扩展加载机制 ([8f7077d](https://git.bjxgj.com/xgj/spaceflow/commit/8f7077deaef4e5f4032662ff5ac925cd3c07fdb6))
|
||||
* **cli:** 调整依赖顺序并格式化导入语句 ([32a9c1c](https://git.bjxgj.com/xgj/spaceflow/commit/32a9c1cf834725a20f93b1f8f60b52692841a3e5))
|
||||
* **cli:** 重构 getPluginConfigFromPackageJson 方法以提高代码可读性 ([f5f6ed9](https://git.bjxgj.com/xgj/spaceflow/commit/f5f6ed9858cc4ca670e30fac469774bdc8f7b005))
|
||||
* **cli:** 重构扩展配置格式,支持 flow/command/skill 三种导出类型 ([958dc13](https://git.bjxgj.com/xgj/spaceflow/commit/958dc130621f78bbcc260224da16a5f16ae0b2b1))
|
||||
* **core:** 为 build/clear/commit 命令添加国际化支持 ([de82cb2](https://git.bjxgj.com/xgj/spaceflow/commit/de82cb2f1ed8cef0e446a2d42a1bf1f091e9c421))
|
||||
* **core:** 优化 list 命令输出格式并修复 MCP Inspector 包管理器兼容性 ([a019829](https://git.bjxgj.com/xgj/spaceflow/commit/a019829d3055c083aeb86ed60ce6629d13012d91))
|
||||
* **core:** 将 rspack 配置和工具函数中的 @spaceflow/cli 引用改为 @spaceflow/core ([3c301c6](https://git.bjxgj.com/xgj/spaceflow/commit/3c301c60f3e61b127db94481f5a19307f5ef00eb))
|
||||
* **core:** 将扩展依赖从 @spaceflow/cli 迁移到 @spaceflow/core ([6f9ffd4](https://git.bjxgj.com/xgj/spaceflow/commit/6f9ffd4061cecae4faaf3d051e3ca98a0b42b01f))
|
||||
* **core:** 提取 source 处理和包管理器工具函数到共享模块 ([ab3ff00](https://git.bjxgj.com/xgj/spaceflow/commit/ab3ff003d1cd586c0c4efc7841e6a93fe3477ace))
|
||||
* **core:** 新增 getEnvFilePaths 工具函数统一管理 .env 文件路径优先级 ([809fa18](https://git.bjxgj.com/xgj/spaceflow/commit/809fa18f3d0b8eabcb068988bab53d548eaf03ea))
|
||||
* **core:** 新增远程仓库规则拉取功能并支持 Git API 获取目录内容 ([69ade16](https://git.bjxgj.com/xgj/spaceflow/commit/69ade16c9069f9e1a90b3ef56dc834e33a3c0650))
|
||||
* **core:** 统一 LogLevel 类型定义并支持字符串/数字双模式 ([557f6b0](https://git.bjxgj.com/xgj/spaceflow/commit/557f6b0bc39fcfb0e3f773836cbbf08c1a8790ae))
|
||||
* **core:** 重构配置读取逻辑,新增 ConfigReaderService 并支持 .spaceflowrc 配置文件 ([72e88ce](https://git.bjxgj.com/xgj/spaceflow/commit/72e88ced63d03395923cdfb113addf4945162e54))
|
||||
* **i18n:** 将 locales 导入从命令文件迁移至扩展入口文件 ([0da5d98](https://git.bjxgj.com/xgj/spaceflow/commit/0da5d9886296c4183b24ad8c56140763f5a870a4))
|
||||
* **i18n:** 移除扩展元数据中的 locales 字段并改用 side-effect 自动注册 ([2c7d488](https://git.bjxgj.com/xgj/spaceflow/commit/2c7d488a9dfa59a99b95e40e3c449c28c2d433d8))
|
||||
* **mcp:** 使用 DTO + Swagger 装饰器替代手动 JSON Schema 定义 ([87ec262](https://git.bjxgj.com/xgj/spaceflow/commit/87ec26252dd295536bb090ae8b7e418eec96e1bd))
|
||||
* **mcp:** 升级 MCP SDK API 并优化 Inspector 调试配置 ([176d04a](https://git.bjxgj.com/xgj/spaceflow/commit/176d04a73fbbb8d115520d922f5fedb9a2961aa6))
|
||||
* **mcp:** 将 MCP 元数据存储从 Reflect Metadata 改为静态属性以支持跨模块访问 ([cac0ea2](https://git.bjxgj.com/xgj/spaceflow/commit/cac0ea2029e1b504bc4278ce72b3aa87fff88c84))
|
||||
* **test:** 迁移测试框架从 Jest 到 Vitest ([308f9d4](https://git.bjxgj.com/xgj/spaceflow/commit/308f9d49089019530588344a5e8880f5b6504a6a))
|
||||
* 优化构建流程并调整 MCP/review 日志输出级别 ([74072c0](https://git.bjxgj.com/xgj/spaceflow/commit/74072c04be7a45bfc0ab53b636248fe5c0e1e42a))
|
||||
* 将 .spaceflow/package.json 纳入版本控制并自动添加到根项目依赖 ([ab83d25](https://git.bjxgj.com/xgj/spaceflow/commit/ab83d2579cb5414ee3d78a9768fac2147a3d1ad9))
|
||||
* 将 GiteaSdkModule/GiteaSdkService 重命名为 GitProviderModule/GitProviderService ([462f492](https://git.bjxgj.com/xgj/spaceflow/commit/462f492bc2607cf508c5011d181c599cf17e00c9))
|
||||
* 恢复 pnpm catalog 配置并移除 .spaceflow 工作区导入器 ([217387e](https://git.bjxgj.com/xgj/spaceflow/commit/217387e2e8517a08162e9bcaf604893fd9bca736))
|
||||
* 迁移扩展依赖到 .spaceflow 工作区并移除 pnpm catalog ([c457c0f](https://git.bjxgj.com/xgj/spaceflow/commit/c457c0f8918171f1856b88bc007921d76c508335))
|
||||
* 重构 Extension 安装机制为 pnpm workspace 模式 ([469b12e](https://git.bjxgj.com/xgj/spaceflow/commit/469b12eac28f747b628e52a5125a3d5a538fba39))
|
||||
* 重构插件加载改为扩展模式 ([0e6e140](https://git.bjxgj.com/xgj/spaceflow/commit/0e6e140b19ea2cf6084afc261c555d2083fe04f9))
|
||||
|
||||
### 文档更新
|
||||
|
||||
* **guide:** 更新编辑器集成文档,补充四种导出类型说明和 MCP 注册机制 ([19a7409](https://git.bjxgj.com/xgj/spaceflow/commit/19a7409092c89d002f11ee51ebcb6863118429bd))
|
||||
* **guide:** 更新配置文件位置说明并补充 RC 文件支持 ([2214dc4](https://git.bjxgj.com/xgj/spaceflow/commit/2214dc4e197221971f5286b38ceaa6fcbcaa7884))
|
||||
|
||||
### 测试用例
|
||||
|
||||
* **core:** 新增 GiteaAdapter 完整单元测试并实现自动检测 provider 配置 ([c74f745](https://git.bjxgj.com/xgj/spaceflow/commit/c74f7458aed91ac7d12fb57ef1c24b3d2917c406))
|
||||
* **review:** 新增 DeletionImpactService 测试覆盖并配置 coverage 工具 ([50bfbfe](https://git.bjxgj.com/xgj/spaceflow/commit/50bfbfe37192641f1170ade8f5eb00e0e382af67))
|
||||
|
||||
### 其他修改
|
||||
|
||||
* **ci-scripts:** released version 0.18.0 [no ci] ([e17894a](https://git.bjxgj.com/xgj/spaceflow/commit/e17894a5af53ff040a0a17bc602d232f78415e1b))
|
||||
* **ci-shell:** released version 0.18.0 [no ci] ([f64fd80](https://git.bjxgj.com/xgj/spaceflow/commit/f64fd8009a6dd725f572c7e9fbf084d9320d5128))
|
||||
* **ci:** 迁移工作流从 Gitea 到 GitHub 并统一环境变量命名 ([57e3bae](https://git.bjxgj.com/xgj/spaceflow/commit/57e3bae635b324c8c4ea50a9fb667b6241fae0ef))
|
||||
* **config:** 将 git 推送白名单用户从 "Gitea Actions" 改为 "GiteaActions" ([fdbb865](https://git.bjxgj.com/xgj/spaceflow/commit/fdbb865341e6f02b26fca32b54a33b51bee11cad))
|
||||
* **config:** 将 git 推送白名单用户从 github-actions[bot] 改为 Gitea Actions ([9c39819](https://git.bjxgj.com/xgj/spaceflow/commit/9c39819a9f95f415068f7f0333770b92bc98321b))
|
||||
* **config:** 移除 review-spec 私有仓库依赖 ([8ae18f1](https://git.bjxgj.com/xgj/spaceflow/commit/8ae18f13c441b033d1cbc75119695a5cc5cb6a0b))
|
||||
* **core:** released version 0.1.0 [no ci] ([170fa67](https://git.bjxgj.com/xgj/spaceflow/commit/170fa670e98473c2377120656d23aae835c51997))
|
||||
* **core:** 禁用 i18next 初始化时的 locize.com 推广日志 ([a99fbb0](https://git.bjxgj.com/xgj/spaceflow/commit/a99fbb068441bc623efcf15a1dd7b6bd38c05f38))
|
||||
* **deps:** 移除 pnpm catalog 配置并更新依赖锁定 ([753fb9e](https://git.bjxgj.com/xgj/spaceflow/commit/753fb9e3e43b28054c75158193dc39ab4bab1af5))
|
||||
* **docs:** 统一文档脚本命名,为 VitePress 命令添加 docs: 前缀 ([3cc46ea](https://git.bjxgj.com/xgj/spaceflow/commit/3cc46eab3a600290f5064b8270902e586b9c5af4))
|
||||
* **i18n:** 配置 i18n-ally-next 自动提取键名生成策略 ([753c3dc](https://git.bjxgj.com/xgj/spaceflow/commit/753c3dc3f24f3c03c837d1ec2c505e8e3ce08b11))
|
||||
* **i18n:** 重构 i18n 配置并统一 locales 目录结构 ([3e94037](https://git.bjxgj.com/xgj/spaceflow/commit/3e94037fa6493b3b0e4a12ff6af9f4bea48ae217))
|
||||
* **period-summary:** released version 0.18.0 [no ci] ([f0df638](https://git.bjxgj.com/xgj/spaceflow/commit/f0df63804d06f8c75e04169ec98226d7a4f5d7f9))
|
||||
* **publish:** released version 0.20.0 [no ci] ([d347e3b](https://git.bjxgj.com/xgj/spaceflow/commit/d347e3b2041157d8dc6e3ade69b05a481b2ab371))
|
||||
* **review:** released version 0.28.0 [no ci] ([a2d89ed](https://git.bjxgj.com/xgj/spaceflow/commit/a2d89ed5f386eb6dd299c0d0a208856ce267ab5e))
|
||||
* **scripts:** 修正 setup 和 build 脚本的过滤条件,避免重复构建 cli 包 ([ffd2ffe](https://git.bjxgj.com/xgj/spaceflow/commit/ffd2ffedca08fd56cccb6a9fbd2b6bd106e367b6))
|
||||
* **templates:** 新增 MCP 工具插件模板 ([5f6df60](https://git.bjxgj.com/xgj/spaceflow/commit/5f6df60b60553f025414fd102d8a279cde097485))
|
||||
* **workflows:** 为所有 GitHub Actions 工作流添加 GIT_PROVIDER_TYPE 环境变量 ([a463574](https://git.bjxgj.com/xgj/spaceflow/commit/a463574de6755a0848a8d06267f029cb947132b0))
|
||||
* **workflows:** 在发布流程中添加 GIT_PROVIDER_TYPE 环境变量 ([a4bb388](https://git.bjxgj.com/xgj/spaceflow/commit/a4bb3881f39ad351e06c5502df6895805b169a28))
|
||||
* **workflows:** 在发布流程中添加扩展安装步骤 ([716be4d](https://git.bjxgj.com/xgj/spaceflow/commit/716be4d92641ccadb3eaf01af8a51189ec5e9ade))
|
||||
* **workflows:** 将发布流程的 Git 和 NPM 配置从 GitHub 迁移到 Gitea ([6d9acff](https://git.bjxgj.com/xgj/spaceflow/commit/6d9acff06c9a202432eb3d3d5552e6ac972712f5))
|
||||
* **workflows:** 将发布流程的 GITHUB_TOKEN 改为使用 CI_GITEA_TOKEN ([e7fe7b4](https://git.bjxgj.com/xgj/spaceflow/commit/e7fe7b4271802fcdbfc2553b180f710eed419335))
|
||||
* 为所有 commands 包添加 @spaceflow/cli 开发依赖 ([d4e6c83](https://git.bjxgj.com/xgj/spaceflow/commit/d4e6c8344ca736f7e55d7db698482e8fa2445684))
|
||||
* 优化依赖配置并移除 .spaceflow 包依赖 ([be5264e](https://git.bjxgj.com/xgj/spaceflow/commit/be5264e5e0fe1f53bbe3b44a9cb86dd94ab9d266))
|
||||
* 修正 postinstall 脚本命令格式 ([3f0820f](https://git.bjxgj.com/xgj/spaceflow/commit/3f0820f85dee88808de921c3befe2d332f34cc36))
|
||||
* 恢复 pnpm catalog 配置并更新依赖锁定 ([0b2295c](https://git.bjxgj.com/xgj/spaceflow/commit/0b2295c1f906d89ad3ba7a61b04c6e6b94f193ef))
|
||||
* 新增 .spaceflow/pnpm-workspace.yaml 防止被父级 workspace 接管并移除根项目 devDependencies 自动添加逻辑 ([61de3a2](https://git.bjxgj.com/xgj/spaceflow/commit/61de3a2b75e8a19b28563d2a6476158d19f6c5be))
|
||||
* 新增 postinstall 钩子自动执行 setup 脚本 ([64dae0c](https://git.bjxgj.com/xgj/spaceflow/commit/64dae0cb440bd5e777cb790f826ff2d9f8fe65ba))
|
||||
* 移除 postinstall 钩子避免依赖安装时自动执行构建 ([ea1dc85](https://git.bjxgj.com/xgj/spaceflow/commit/ea1dc85ce7d6cf23a98c13e2c21e3c3bcdf7dd79))
|
||||
50
cli/package.json
Normal file
50
cli/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@spaceflow/cli",
|
||||
"version": "0.19.0",
|
||||
"description": "Spaceflow CLI 工具",
|
||||
"license": "UNLICENSED",
|
||||
"author": "",
|
||||
"bin": {
|
||||
"space": "dist/cli.js",
|
||||
"spaceflow": "dist/cli.js"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rspack build -c rspack.config.mjs",
|
||||
"format": "oxfmt src --write",
|
||||
"lint": "oxlint src",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:cov": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@rspack/cli": "^1.7.4",
|
||||
"@rspack/core": "^1.7.4",
|
||||
"@spaceflow/core": "workspace:*",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"micromatch": "^4.0.8",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "catalog:",
|
||||
"@nestjs/config": "catalog:",
|
||||
"@nestjs/core": "catalog:",
|
||||
"nest-commander": "catalog:",
|
||||
"reflect-metadata": "catalog:",
|
||||
"rxjs": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "catalog:",
|
||||
"@nestjs/schematics": "catalog:",
|
||||
"@nestjs/testing": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"ts-node": "catalog:",
|
||||
"tsconfig-paths": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
70
cli/rspack.config.mjs
Normal file
70
cli/rspack.config.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, resolve } from "path";
|
||||
import rspack from "@rspack/core";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default {
|
||||
optimization: {
|
||||
minimize: false,
|
||||
},
|
||||
entry: {
|
||||
cli: "./src/cli.ts",
|
||||
},
|
||||
plugins: [
|
||||
new rspack.BannerPlugin({
|
||||
banner: "#!/usr/bin/env node",
|
||||
raw: true,
|
||||
entryOnly: true,
|
||||
include: /cli\.js$/,
|
||||
}),
|
||||
],
|
||||
target: "node",
|
||||
mode: process.env.NODE_ENV === "production" ? "production" : "development",
|
||||
output: {
|
||||
filename: "[name].js",
|
||||
path: resolve(__dirname, "dist"),
|
||||
library: { type: "module" },
|
||||
chunkFormat: "module",
|
||||
clean: true,
|
||||
},
|
||||
experiments: {
|
||||
outputModule: true,
|
||||
},
|
||||
externalsType: "module-import",
|
||||
externals: [
|
||||
{ micromatch: "node-commonjs micromatch" },
|
||||
/^(?!src\/)[^./]/, // node_modules 包(排除 src/ 别名)
|
||||
],
|
||||
resolve: {
|
||||
extensions: [".ts", ".js"],
|
||||
extensionAlias: {
|
||||
".js": [".ts", ".js"],
|
||||
},
|
||||
tsConfig: {
|
||||
configFile: resolve(__dirname, "tsconfig.json"),
|
||||
},
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
exclude: /node_modules/,
|
||||
loader: "builtin:swc-loader",
|
||||
options: {
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: "typescript",
|
||||
decorators: true,
|
||||
},
|
||||
transform: {
|
||||
legacyDecorator: true,
|
||||
decoratorMetadata: true,
|
||||
},
|
||||
target: "es2022",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
28
cli/src/cli.module.ts
Normal file
28
cli/src/cli.module.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import {
|
||||
StorageModule,
|
||||
OutputModule,
|
||||
configLoaders,
|
||||
ConfigReaderModule,
|
||||
getEnvFilePaths,
|
||||
} from "@spaceflow/core";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// 配置模块
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: configLoaders,
|
||||
envFilePath: getEnvFilePaths(),
|
||||
}),
|
||||
|
||||
// 基础能力模块
|
||||
StorageModule.forFeature(),
|
||||
OutputModule,
|
||||
ConfigReaderModule,
|
||||
|
||||
// 内置命令通过 internal-plugins.ts 以插件方式加载
|
||||
],
|
||||
})
|
||||
export class CliModule {}
|
||||
80
cli/src/cli.ts
Normal file
80
cli/src/cli.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { initI18n } from "@spaceflow/core";
|
||||
|
||||
// 必须在所有命令模块 import 之前初始化 i18n,装饰器在 import 时执行
|
||||
initI18n();
|
||||
|
||||
// 注册所有内部命令的 i18n 资源(side-effect),必须在 internal-extensions 之前
|
||||
import "./locales";
|
||||
|
||||
import { CommandFactory } from "nest-commander";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { OutputService, loadSpaceflowConfig } from "@spaceflow/core";
|
||||
import type { ExtensionModuleType } from "@spaceflow/core";
|
||||
import { ExtensionLoaderService } from "./extension-loader";
|
||||
import { CliModule } from "./cli.module";
|
||||
import { parseRunxArgs } from "./commands/runx/runx.utils";
|
||||
import { internalExtensions } from "./internal-extensions";
|
||||
|
||||
/**
|
||||
* 预处理 runx/x 命令的参数
|
||||
* 将 source 后的所有参数转换为 -- 分隔格式,避免被 commander 解析
|
||||
*/
|
||||
function preprocessRunxArgs(): void {
|
||||
const argv = process.argv;
|
||||
// 检查是否已经有 -- 分隔符
|
||||
if (argv.includes("--")) return;
|
||||
const { cmdIndex, sourceIndex } = parseRunxArgs(argv);
|
||||
if (cmdIndex === -1 || sourceIndex === -1) return;
|
||||
// 如果 source 后还有参数,插入 -- 分隔符
|
||||
if (sourceIndex + 1 < argv.length) {
|
||||
process.argv = [...argv.slice(0, sourceIndex + 1), "--", ...argv.slice(sourceIndex + 1)];
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
// 预处理 runx/x 命令参数
|
||||
preprocessRunxArgs();
|
||||
|
||||
await ConfigModule.envVariablesLoaded;
|
||||
|
||||
// 1. 加载 spaceflow.json 配置(运行时配置)
|
||||
loadSpaceflowConfig();
|
||||
|
||||
// 2. 注册内部 Extension
|
||||
const extensionLoader = new ExtensionLoaderService();
|
||||
const internalLoaded = extensionLoader.registerInternalExtensions(internalExtensions);
|
||||
|
||||
// 3. 从 .spaceflow/package.json 发现并加载外部 Extension
|
||||
const externalLoaded = await extensionLoader.discoverAndLoad();
|
||||
|
||||
// 合并所有 Extension 模块
|
||||
const extensionModules: ExtensionModuleType[] = [...internalLoaded, ...externalLoaded].map(
|
||||
(e) => e.module,
|
||||
);
|
||||
|
||||
// 4. 动态创建 CLI Module
|
||||
@Module({
|
||||
imports: [CliModule, ...extensionModules],
|
||||
})
|
||||
class DynamicCliModule {}
|
||||
|
||||
// 4. 创建并运行 CLI
|
||||
const app = await CommandFactory.createWithoutRunning(DynamicCliModule);
|
||||
const output = app.get(OutputService);
|
||||
|
||||
await CommandFactory.runApplication(app);
|
||||
|
||||
// Flush outputs after command execution
|
||||
output.flush();
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
.then(() => {
|
||||
// console.log("Bootstrap completed");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
74
cli/src/commands/build/build.command.ts
Normal file
74
cli/src/commands/build/build.command.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Command, CommandRunner, Option } from "nest-commander";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { BuildService } from "./build.service";
|
||||
import type { VerboseLevel } from "@spaceflow/core";
|
||||
|
||||
export interface BuildOptions {
|
||||
skill?: string;
|
||||
watch?: boolean;
|
||||
verbose?: VerboseLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建命令
|
||||
*
|
||||
* 用于构建 skills 目录下的插件包
|
||||
*
|
||||
* 用法:
|
||||
* spaceflow build [skill] # 构建指定或所有插件
|
||||
* spaceflow build --watch # 监听模式
|
||||
*/
|
||||
@Command({
|
||||
name: "build",
|
||||
arguments: "[skill]",
|
||||
description: t("build:description"),
|
||||
})
|
||||
export class BuildCommand extends CommandRunner {
|
||||
constructor(private readonly buildService: BuildService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(passedParams: string[], options: BuildOptions): Promise<void> {
|
||||
const skill = passedParams[0];
|
||||
const verbose = options.verbose ?? true;
|
||||
|
||||
try {
|
||||
if (options.watch) {
|
||||
await this.buildService.watch(skill, verbose);
|
||||
} else {
|
||||
const results = await this.buildService.build(skill, verbose);
|
||||
const hasErrors = results.some((r) => !r.success);
|
||||
if (hasErrors) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(t("build:buildFailed", { error: error.message }));
|
||||
if (error.stack) {
|
||||
console.error(t("common.stackTrace", { stack: error.stack }));
|
||||
}
|
||||
} else {
|
||||
console.error(t("build:buildFailed", { error }));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-w, --watch",
|
||||
description: t("build:options.watch"),
|
||||
})
|
||||
parseWatch(val: boolean): boolean {
|
||||
return val;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-v, --verbose",
|
||||
description: t("common.options.verbose"),
|
||||
})
|
||||
parseVerbose(_val: string, previous: VerboseLevel = 0): VerboseLevel {
|
||||
const current = typeof previous === "number" ? previous : previous ? 1 : 0;
|
||||
return Math.min(current + 1, 2) as VerboseLevel;
|
||||
}
|
||||
}
|
||||
8
cli/src/commands/build/build.module.ts
Normal file
8
cli/src/commands/build/build.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BuildCommand } from "./build.command";
|
||||
import { BuildService } from "./build.service";
|
||||
|
||||
@Module({
|
||||
providers: [BuildCommand, BuildService],
|
||||
})
|
||||
export class BuildModule {}
|
||||
312
cli/src/commands/build/build.service.ts
Normal file
312
cli/src/commands/build/build.service.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { rspack, type Compiler, type Configuration, type Stats } from "@rspack/core";
|
||||
import { readdir, stat } from "fs/promises";
|
||||
import { join, dirname } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { createPluginConfig } from "@spaceflow/core";
|
||||
import { shouldLog, type VerboseLevel, t } from "@spaceflow/core";
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
hasPackageJson: boolean;
|
||||
}
|
||||
|
||||
export interface BuildResult {
|
||||
skill: string;
|
||||
success: boolean;
|
||||
duration?: number;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BuildService {
|
||||
private readonly projectRoot = this.findProjectRoot();
|
||||
private readonly skillsDir = join(this.projectRoot, "skills");
|
||||
private readonly commandsDir = join(this.projectRoot, "commands");
|
||||
private readonly coreRoot = join(this.projectRoot, "core");
|
||||
private watchers: Map<string, Compiler> = new Map();
|
||||
|
||||
/**
|
||||
* 查找项目根目录(包含 spaceflow.json 或 .spaceflow/spaceflow.json 的目录)
|
||||
*/
|
||||
private findProjectRoot(): string {
|
||||
let dir = process.cwd();
|
||||
while (dir !== dirname(dir)) {
|
||||
// 检查根目录下的 spaceflow.json
|
||||
if (existsSync(join(dir, "spaceflow.json"))) {
|
||||
return dir;
|
||||
}
|
||||
// 检查 .spaceflow/spaceflow.json
|
||||
if (existsSync(join(dir, ".spaceflow", "spaceflow.json"))) {
|
||||
return dir;
|
||||
}
|
||||
dir = dirname(dir);
|
||||
}
|
||||
// 如果找不到,回退到 cwd
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建插件
|
||||
*/
|
||||
async build(skillName?: string, verbose: VerboseLevel = 1): Promise<BuildResult[]> {
|
||||
const skills = await this.getSkillsToBuild(skillName);
|
||||
|
||||
if (skills.length === 0) {
|
||||
if (shouldLog(verbose, 1)) console.log(t("build:noPlugins"));
|
||||
return [];
|
||||
}
|
||||
|
||||
if (shouldLog(verbose, 1))
|
||||
console.log(t("build:startBuilding", { count: skills.length }) + "\n");
|
||||
|
||||
const results: BuildResult[] = [];
|
||||
for (const skill of skills) {
|
||||
const result = await this.buildSkill(skill, verbose);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const failCount = results.filter((r) => !r.success).length;
|
||||
|
||||
if (shouldLog(verbose, 1))
|
||||
console.log("\n" + t("build:buildComplete", { success: successCount, fail: failCount }));
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听模式构建
|
||||
*/
|
||||
async watch(skillName?: string, verbose: VerboseLevel = 1): Promise<void> {
|
||||
const skills = await this.getSkillsToBuild(skillName);
|
||||
|
||||
if (skills.length === 0) {
|
||||
if (shouldLog(verbose, 1)) console.log(t("build:noPlugins"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldLog(verbose, 1))
|
||||
console.log(t("build:startWatching", { count: skills.length }) + "\n");
|
||||
|
||||
// 并行启动所有 watcher
|
||||
await Promise.all(skills.map((skill) => this.watchSkill(skill, verbose)));
|
||||
|
||||
// 保持进程运行
|
||||
await new Promise(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有 watcher
|
||||
*/
|
||||
async stopWatch(verbose: VerboseLevel = 1): Promise<void> {
|
||||
for (const [name, compiler] of this.watchers) {
|
||||
await new Promise<void>((resolve) => {
|
||||
compiler.close(() => {
|
||||
if (shouldLog(verbose, 1)) console.log(t("build:stopWatching", { name }));
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
this.watchers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取需要构建的插件列表
|
||||
*/
|
||||
private async getSkillsToBuild(skillName?: string): Promise<SkillInfo[]> {
|
||||
// 如果没有指定插件名,检查是否在插件目录中运行
|
||||
if (!skillName) {
|
||||
const currentSkill = this.detectCurrentSkill();
|
||||
if (currentSkill) {
|
||||
return [currentSkill];
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(this.skillsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = await readdir(this.skillsDir);
|
||||
const skills: SkillInfo[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith(".")) continue;
|
||||
|
||||
const skillPath = join(this.skillsDir, entry);
|
||||
const stats = await stat(skillPath);
|
||||
|
||||
if (!stats.isDirectory()) continue;
|
||||
|
||||
if (skillName && entry !== skillName) continue;
|
||||
|
||||
const packageJsonPath = join(skillPath, "package.json");
|
||||
const hasPackageJson = existsSync(packageJsonPath);
|
||||
|
||||
if (hasPackageJson) {
|
||||
skills.push({
|
||||
name: entry,
|
||||
path: skillPath,
|
||||
hasPackageJson,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测当前是否在插件目录中运行
|
||||
*/
|
||||
private detectCurrentSkill(): SkillInfo | null {
|
||||
const cwd = process.cwd();
|
||||
const packageJsonPath = join(cwd, "package.json");
|
||||
|
||||
// 检查当前目录是否有 package.json
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查是否在 skills 或 commands 目录下
|
||||
if (!cwd.startsWith(this.skillsDir) && !cwd.startsWith(this.commandsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取插件名(当前目录名)
|
||||
const name = cwd.split("/").pop() || "";
|
||||
|
||||
return {
|
||||
name,
|
||||
path: cwd,
|
||||
hasPackageJson: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建单个插件
|
||||
*/
|
||||
private async buildSkill(skill: SkillInfo, verbose: VerboseLevel = 1): Promise<BuildResult> {
|
||||
const startTime = Date.now();
|
||||
if (shouldLog(verbose, 1)) console.log(t("build:building", { name: skill.name }));
|
||||
|
||||
try {
|
||||
const config = await this.getConfig(skill);
|
||||
const compiler = rspack(config);
|
||||
|
||||
const stats = await new Promise<Stats>((resolve, reject) => {
|
||||
compiler.run((err, stats) => {
|
||||
compiler.close((closeErr) => {
|
||||
if (err) return reject(err);
|
||||
if (closeErr) return reject(closeErr);
|
||||
if (!stats) return reject(new Error("No stats returned"));
|
||||
resolve(stats);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const info = stats.toJson({ errors: true, warnings: true });
|
||||
|
||||
if (stats.hasErrors()) {
|
||||
const errors = info.errors?.map((e) => e.message) || [];
|
||||
console.log(t("build:buildFailedWithDuration", { duration }));
|
||||
errors.forEach((e) => console.log(` ${e}`));
|
||||
return { skill: skill.name, success: false, duration, errors };
|
||||
}
|
||||
|
||||
if (stats.hasWarnings()) {
|
||||
const warnings = info.warnings?.map((w) => w.message) || [];
|
||||
if (shouldLog(verbose, 1))
|
||||
console.log(t("build:buildWarnings", { duration, count: warnings.length }));
|
||||
return { skill: skill.name, success: true, duration, warnings };
|
||||
}
|
||||
|
||||
if (shouldLog(verbose, 1)) console.log(t("build:buildSuccess", { duration }));
|
||||
return { skill: skill.name, success: true, duration };
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(t("build:buildFailedWithMessage", { duration, message }));
|
||||
return { skill: skill.name, success: false, duration, errors: [message] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听单个插件
|
||||
*/
|
||||
private async watchSkill(skill: SkillInfo, verbose: VerboseLevel = 1): Promise<void> {
|
||||
if (shouldLog(verbose, 1)) console.log(t("build:watching", { name: skill.name }));
|
||||
|
||||
try {
|
||||
const config = await this.getConfig(skill);
|
||||
const compiler = rspack(config);
|
||||
|
||||
this.watchers.set(skill.name, compiler);
|
||||
|
||||
compiler.watch({}, (err, stats) => {
|
||||
if (err) {
|
||||
console.log(t("build:watchError", { name: skill.name, message: err.message }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stats) return;
|
||||
|
||||
const info = stats.toJson({ errors: true, warnings: true });
|
||||
|
||||
if (stats.hasErrors()) {
|
||||
console.log(t("build:watchBuildFailed", { name: skill.name }));
|
||||
info.errors?.forEach((e) => console.log(` ${e.message}`));
|
||||
} else if (stats.hasWarnings()) {
|
||||
if (shouldLog(verbose, 1))
|
||||
console.log(
|
||||
t("build:watchBuildWarnings", { name: skill.name, count: info.warnings?.length }),
|
||||
);
|
||||
} else {
|
||||
if (shouldLog(verbose, 1))
|
||||
console.log(t("build:watchBuildSuccess", { name: skill.name }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(t("build:watchInitFailed", { name: skill.name, message }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件的 rspack 配置
|
||||
*/
|
||||
private async getConfig(skill: SkillInfo): Promise<Configuration> {
|
||||
// 检查是否有自定义配置
|
||||
const customConfigPath = join(skill.path, "rspack.config.mjs");
|
||||
const customConfigPathJs = join(skill.path, "rspack.config.js");
|
||||
|
||||
if (existsSync(customConfigPath)) {
|
||||
const module = await import(customConfigPath);
|
||||
return module.default || module;
|
||||
}
|
||||
|
||||
if (existsSync(customConfigPathJs)) {
|
||||
const module = await import(customConfigPathJs);
|
||||
return module.default || module;
|
||||
}
|
||||
|
||||
// 使用默认配置
|
||||
return this.getDefaultConfig(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成默认的 rspack 配置
|
||||
*/
|
||||
private getDefaultConfig(skill: SkillInfo): Configuration {
|
||||
return createPluginConfig(
|
||||
{
|
||||
name: skill.name,
|
||||
path: skill.path,
|
||||
},
|
||||
{
|
||||
coreRoot: this.coreRoot,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
28
cli/src/commands/build/index.ts
Normal file
28
cli/src/commands/build/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
SpaceflowExtension,
|
||||
SpaceflowExtensionMetadata,
|
||||
ExtensionModuleType,
|
||||
} from "@spaceflow/core";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { BuildModule } from "./build.module";
|
||||
|
||||
export const buildMetadata: SpaceflowExtensionMetadata = {
|
||||
name: "build",
|
||||
commands: ["build"],
|
||||
version: "1.0.0",
|
||||
description: t("build:extensionDescription"),
|
||||
};
|
||||
|
||||
export class BuildExtension implements SpaceflowExtension {
|
||||
getMetadata(): SpaceflowExtensionMetadata {
|
||||
return buildMetadata;
|
||||
}
|
||||
|
||||
getModule(): ExtensionModuleType {
|
||||
return BuildModule;
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./build.module";
|
||||
export * from "./build.command";
|
||||
export * from "./build.service";
|
||||
67
cli/src/commands/clear/clear.command.ts
Normal file
67
cli/src/commands/clear/clear.command.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Command, CommandRunner, Option } from "nest-commander";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { ClearService } from "./clear.service";
|
||||
import type { VerboseLevel } from "@spaceflow/core";
|
||||
|
||||
export interface ClearCommandOptions {
|
||||
global?: boolean;
|
||||
verbose?: VerboseLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有安装的技能包
|
||||
*
|
||||
* 用法:
|
||||
* spaceflow clear # 清理本地安装的所有 skills
|
||||
* spaceflow clear -g # 清理全局安装的所有 skills
|
||||
*
|
||||
* 功能:
|
||||
* 1. 删除 .spaceflow/deps 目录下的所有依赖
|
||||
* 2. 删除各编辑器 skills 目录下的所有安装内容
|
||||
* 3. 删除各编辑器 commands 目录下的所有生成的 .md 文件
|
||||
*/
|
||||
@Command({
|
||||
name: "clear",
|
||||
description: t("clear:description"),
|
||||
})
|
||||
export class ClearCommand extends CommandRunner {
|
||||
constructor(private readonly clearService: ClearService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(_passedParams: string[], options: ClearCommandOptions): Promise<void> {
|
||||
const isGlobal = options.global ?? false;
|
||||
const verbose = options.verbose ?? true;
|
||||
|
||||
try {
|
||||
await this.clearService.execute(isGlobal, verbose);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(t("clear:clearFailed", { error: error.message }));
|
||||
if (error.stack) {
|
||||
console.error(t("common.stackTrace", { stack: error.stack }));
|
||||
}
|
||||
} else {
|
||||
console.error(t("clear:clearFailed", { error }));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-g, --global",
|
||||
description: t("clear:options.global"),
|
||||
})
|
||||
parseGlobal(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-v, --verbose",
|
||||
description: t("common.options.verbose"),
|
||||
})
|
||||
parseVerbose(_val: string, previous: VerboseLevel = 0): VerboseLevel {
|
||||
const current = typeof previous === "number" ? previous : previous ? 1 : 0;
|
||||
return Math.min(current + 1, 2) as VerboseLevel;
|
||||
}
|
||||
}
|
||||
8
cli/src/commands/clear/clear.module.ts
Normal file
8
cli/src/commands/clear/clear.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ClearCommand } from "./clear.command";
|
||||
import { ClearService } from "./clear.service";
|
||||
|
||||
@Module({
|
||||
providers: [ClearCommand, ClearService],
|
||||
})
|
||||
export class ClearModule {}
|
||||
161
cli/src/commands/clear/clear.service.ts
Normal file
161
cli/src/commands/clear/clear.service.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { readFile, rm } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { existsSync, readdirSync, lstatSync } from "fs";
|
||||
import { shouldLog, type VerboseLevel, t } from "@spaceflow/core";
|
||||
import { getEditorDirName, DEFAULT_EDITOR } from "@spaceflow/core";
|
||||
|
||||
@Injectable()
|
||||
export class ClearService {
|
||||
/**
|
||||
* 获取支持的编辑器列表
|
||||
*/
|
||||
protected async getSupportedEditors(configPath: string): Promise<string[]> {
|
||||
try {
|
||||
if (!existsSync(configPath)) return [DEFAULT_EDITOR];
|
||||
const content = await readFile(configPath, "utf-8");
|
||||
const config = JSON.parse(content);
|
||||
return config.support || [DEFAULT_EDITOR];
|
||||
} catch {
|
||||
return [DEFAULT_EDITOR];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行清理
|
||||
*/
|
||||
async execute(isGlobal = false, verbose: VerboseLevel = 1): Promise<void> {
|
||||
if (shouldLog(verbose, 1)) {
|
||||
if (isGlobal) {
|
||||
console.log(t("clear:clearingGlobal"));
|
||||
} else {
|
||||
console.log(t("clear:clearing"));
|
||||
}
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const home = process.env.HOME || process.env.USERPROFILE || "~";
|
||||
const configPath = join(cwd, "spaceflow.json");
|
||||
|
||||
// 1. 清理 .spaceflow/deps 目录
|
||||
await this.clearSpaceflowDeps(isGlobal, verbose);
|
||||
|
||||
// 2. 清理各编辑器的 skills 和 commands 目录
|
||||
const editors = await this.getSupportedEditors(configPath);
|
||||
|
||||
for (const editor of editors) {
|
||||
const editorDirName = getEditorDirName(editor);
|
||||
const editorRoot = isGlobal ? join(home, editorDirName) : join(cwd, editorDirName);
|
||||
await this.clearEditorDir(editorRoot, editor, verbose);
|
||||
}
|
||||
|
||||
if (shouldLog(verbose, 1)) console.log(t("clear:clearDone"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 .spaceflow 目录内部文件(保留 spaceflow.json)
|
||||
*/
|
||||
private async clearSpaceflowDeps(isGlobal: boolean, verbose: VerboseLevel = 1): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
const home = process.env.HOME || process.env.USERPROFILE || "~";
|
||||
const spaceflowRoot = isGlobal ? join(home, ".spaceflow") : join(cwd, ".spaceflow");
|
||||
|
||||
if (!existsSync(spaceflowRoot)) {
|
||||
if (shouldLog(verbose, 1)) console.log(t("clear:spaceflowNotExist"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 需要保留的文件
|
||||
const preserveFiles = ["spaceflow.json", "package.json"];
|
||||
|
||||
const entries = readdirSync(spaceflowRoot);
|
||||
const toDelete = entries.filter((entry) => !preserveFiles.includes(entry));
|
||||
|
||||
if (toDelete.length === 0) {
|
||||
if (shouldLog(verbose, 1)) console.log(t("clear:spaceflowNoClean"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldLog(verbose, 1))
|
||||
console.log(t("clear:clearingSpaceflow", { count: toDelete.length }));
|
||||
|
||||
for (const entry of toDelete) {
|
||||
const entryPath = join(spaceflowRoot, entry);
|
||||
try {
|
||||
await rm(entryPath, { recursive: true, force: true });
|
||||
if (shouldLog(verbose, 2)) console.log(t("clear:deleted", { entry }));
|
||||
} catch (error) {
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.warn(
|
||||
t("clear:deleteFailed", { entry }),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理编辑器目录下的 skills 和 commands
|
||||
*/
|
||||
private async clearEditorDir(
|
||||
editorRoot: string,
|
||||
editorName: string,
|
||||
verbose: VerboseLevel = 1,
|
||||
): Promise<void> {
|
||||
if (!existsSync(editorRoot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理 skills 目录
|
||||
const skillsDir = join(editorRoot, "skills");
|
||||
if (existsSync(skillsDir)) {
|
||||
const entries = readdirSync(skillsDir);
|
||||
if (entries.length > 0) {
|
||||
if (shouldLog(verbose, 1))
|
||||
console.log(t("clear:clearingSkills", { editor: editorName, count: entries.length }));
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(skillsDir, entry);
|
||||
try {
|
||||
await rm(entryPath, { recursive: true, force: true });
|
||||
if (shouldLog(verbose, 2)) console.log(t("clear:deleted", { entry }));
|
||||
} catch (error) {
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.warn(
|
||||
t("clear:deleteFailed", { entry }),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理 commands 目录中的 .md 文件
|
||||
const commandsDir = join(editorRoot, "commands");
|
||||
if (existsSync(commandsDir)) {
|
||||
const entries = readdirSync(commandsDir).filter((f) => f.endsWith(".md"));
|
||||
if (entries.length > 0) {
|
||||
if (shouldLog(verbose, 1))
|
||||
console.log(t("clear:clearingCommands", { editor: editorName, count: entries.length }));
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(commandsDir, entry);
|
||||
try {
|
||||
const stats = lstatSync(entryPath);
|
||||
if (stats.isFile()) {
|
||||
await rm(entryPath);
|
||||
if (shouldLog(verbose, 2)) console.log(t("clear:deleted", { entry }));
|
||||
}
|
||||
} catch (error) {
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.warn(
|
||||
t("clear:deleteFailed", { entry }),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
cli/src/commands/clear/index.ts
Normal file
28
cli/src/commands/clear/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
SpaceflowExtension,
|
||||
SpaceflowExtensionMetadata,
|
||||
ExtensionModuleType,
|
||||
} from "@spaceflow/core";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { ClearModule } from "./clear.module";
|
||||
|
||||
export const clearMetadata: SpaceflowExtensionMetadata = {
|
||||
name: "clear",
|
||||
commands: ["clear"],
|
||||
version: "1.0.0",
|
||||
description: t("clear:extensionDescription"),
|
||||
};
|
||||
|
||||
export class ClearExtension implements SpaceflowExtension {
|
||||
getMetadata(): SpaceflowExtensionMetadata {
|
||||
return clearMetadata;
|
||||
}
|
||||
|
||||
getModule(): ExtensionModuleType {
|
||||
return ClearModule;
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./clear.command";
|
||||
export * from "./clear.service";
|
||||
export * from "./clear.module";
|
||||
108
cli/src/commands/commit/commit.command.ts
Normal file
108
cli/src/commands/commit/commit.command.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Command, CommandRunner, Option } from "nest-commander";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { CommitService } from "./commit.service";
|
||||
import type { VerboseLevel } from "@spaceflow/core";
|
||||
|
||||
export interface CommitCommandOptions {
|
||||
verbose?: VerboseLevel;
|
||||
dryRun?: boolean;
|
||||
noVerify?: boolean;
|
||||
split?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit 命令
|
||||
*
|
||||
* 基于暂存区代码变更和历史 commit 自动生成规范的 commit message
|
||||
*
|
||||
* 用法:
|
||||
* spaceflow commit # 生成并提交
|
||||
* spaceflow commit --dry-run # 仅生成,不提交
|
||||
* spaceflow commit --verbose # 显示详细信息
|
||||
*/
|
||||
@Command({
|
||||
name: "commit",
|
||||
description: t("commit:description"),
|
||||
})
|
||||
export class CommitCommand extends CommandRunner {
|
||||
constructor(private readonly commitService: CommitService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(_passedParams: string[], options: CommitCommandOptions): Promise<void> {
|
||||
const verbose = options.verbose ?? 0;
|
||||
|
||||
try {
|
||||
const result = await this.commitService.generateAndCommit({
|
||||
verbose,
|
||||
dryRun: options.dryRun,
|
||||
noVerify: options.noVerify,
|
||||
split: options.split,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
if (options.dryRun) {
|
||||
console.log(t("commit:dryRunMode"));
|
||||
console.log(result.message);
|
||||
} else {
|
||||
if (result.commitCount && result.commitCount > 1) {
|
||||
console.log(t("commit:splitSuccess", { count: result.commitCount }));
|
||||
// 分批提交时已经实时打印了每个 commit,不再重复打印
|
||||
} else {
|
||||
console.log(t("commit:commitSuccess"));
|
||||
if (result.message) {
|
||||
console.log(result.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(t("commit:commitFailed", { error: result.error }));
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(t("common.executionFailed", { error: error.message }));
|
||||
if (error.stack) {
|
||||
console.error(t("common.stackTrace", { stack: error.stack }));
|
||||
}
|
||||
} else {
|
||||
console.error(t("common.executionFailed", { error }));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-d, --dry-run",
|
||||
description: t("commit:options.dryRun"),
|
||||
})
|
||||
parseDryRun(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-v, --verbose",
|
||||
description: t("common.options.verbose"),
|
||||
})
|
||||
parseVerbose(_val: string, previous: VerboseLevel = 0): VerboseLevel {
|
||||
// 每次 -v 增加一级,最高为 2
|
||||
const current = typeof previous === "number" ? previous : previous ? 1 : 0;
|
||||
return Math.min(current + 1, 2) as VerboseLevel;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-n, --no-verify",
|
||||
description: t("commit:options.noVerify"),
|
||||
})
|
||||
parseNoVerify(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-s, --split",
|
||||
description: t("commit:options.split"),
|
||||
})
|
||||
parseSplit(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
168
cli/src/commands/commit/commit.config.ts
Normal file
168
cli/src/commands/commit/commit.config.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { z } from "zod";
|
||||
import type { VerboseLevel } from "@spaceflow/core";
|
||||
|
||||
/**
|
||||
* Commit 类型定义
|
||||
*/
|
||||
export interface CommitType {
|
||||
type: string;
|
||||
section: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changelog 配置
|
||||
*/
|
||||
export interface CommitConfig {
|
||||
changelog?: {
|
||||
preset?: {
|
||||
type?: CommitType[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope 匹配规则 schema
|
||||
*/
|
||||
export const ScopeRuleSchema = z.object({
|
||||
/** glob 模式,如 "src/components/**", "docs/**" */
|
||||
pattern: z.string().describe("glob 模式,用于匹配文件路径"),
|
||||
/** 匹配后使用的 scope 名称 */
|
||||
scope: z.string().describe("匹配后使用的 scope 名称"),
|
||||
});
|
||||
|
||||
export type ScopeRule = z.infer<typeof ScopeRuleSchema>;
|
||||
|
||||
/**
|
||||
* Commit scope 配置 schema
|
||||
*/
|
||||
export const CommitScopeConfigSchema = z.object({
|
||||
/**
|
||||
* 分组策略
|
||||
* - "package": 按最近的 package.json 目录分组(默认)
|
||||
* - "rules": 仅使用自定义规则
|
||||
* - "rules-first": 优先使用自定义规则,未匹配则回退到 package 策略
|
||||
*/
|
||||
strategy: z.enum(["package", "rules", "rules-first"]).default("package").describe("文件分组策略"),
|
||||
/** 自定义匹配规则列表 */
|
||||
rules: z.array(ScopeRuleSchema).default([]).describe("自定义 scope 匹配规则"),
|
||||
});
|
||||
|
||||
export type CommitScopeConfig = z.infer<typeof CommitScopeConfigSchema>;
|
||||
|
||||
/**
|
||||
* Commit 命令选项
|
||||
*/
|
||||
export interface CommitOptions {
|
||||
verbose?: VerboseLevel;
|
||||
dryRun?: boolean;
|
||||
noVerify?: boolean;
|
||||
split?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit 执行结果
|
||||
*/
|
||||
export interface CommitResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
commitCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit 分组
|
||||
*/
|
||||
export interface CommitGroup {
|
||||
files: string[];
|
||||
reason: string;
|
||||
packageInfo?: { name: string; description?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* 拆分分析结果
|
||||
*/
|
||||
export interface SplitAnalysis {
|
||||
groups: CommitGroup[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 包信息
|
||||
*/
|
||||
export interface PackageInfo {
|
||||
/** 包名(package.json 中的 name) */
|
||||
name: string;
|
||||
/** 包描述 */
|
||||
description?: string;
|
||||
/** package.json 所在目录的绝对路径 */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结构化 Commit Message schema(仅 AI 生成的部分)
|
||||
*/
|
||||
export const CommitMessageContentSchema = z.object({
|
||||
/** commit 类型,如 feat, fix, refactor 等 */
|
||||
type: z.string().describe("commit 类型"),
|
||||
/** 影响范围,可选 */
|
||||
scope: z.string().optional().describe("影响范围(包名、模块名)"),
|
||||
/** 简短描述,不超过 50 个字符 */
|
||||
subject: z.string().describe("简短描述"),
|
||||
/** 详细描述,可选 */
|
||||
body: z.string().optional().describe("详细描述"),
|
||||
});
|
||||
|
||||
export type CommitMessageContent = z.infer<typeof CommitMessageContentSchema>;
|
||||
|
||||
/**
|
||||
* 完整的 Commit Message,包含文件和包上下文
|
||||
*/
|
||||
export interface CommitMessage extends CommitMessageContent {
|
||||
/** 涉及的文件列表(相对路径) */
|
||||
files?: string[];
|
||||
/** 所属包信息 */
|
||||
packageInfo?: PackageInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 commit message 字符串为结构化对象
|
||||
*/
|
||||
export function parseCommitMessage(message: string): CommitMessage {
|
||||
const lines = message.trim().split("\n");
|
||||
const firstLine = lines[0] || "";
|
||||
|
||||
// 匹配 type(scope): subject 或 type: subject
|
||||
const headerRegex = /^(\w+)(?:\(([^)]+)\))?:\s*(.+)$/;
|
||||
const match = firstLine.match(headerRegex);
|
||||
|
||||
if (!match) {
|
||||
// 无法解析,将整个内容作为 subject
|
||||
return {
|
||||
type: "chore",
|
||||
subject: firstLine || message.trim(),
|
||||
body: lines.slice(1).join("\n").trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const [, type, scope, subject] = match;
|
||||
const body = lines.slice(1).join("\n").trim() || undefined;
|
||||
|
||||
return {
|
||||
type,
|
||||
scope: scope || undefined,
|
||||
subject,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化结构化 commit message 为字符串
|
||||
*/
|
||||
export function formatCommitMessage(commit: CommitMessage): string {
|
||||
const { type, scope, subject, body } = commit;
|
||||
const header = scope ? `${type}(${scope}): ${subject}` : `${type}: ${subject}`;
|
||||
|
||||
if (body) {
|
||||
return `${header}\n\n${body}`;
|
||||
}
|
||||
return header;
|
||||
}
|
||||
25
cli/src/commands/commit/commit.module.ts
Normal file
25
cli/src/commands/commit/commit.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { CommitCommand } from "./commit.command";
|
||||
import { CommitService } from "./commit.service";
|
||||
import { ConfigReaderModule, LlmProxyModule, type LlmConfig } from "@spaceflow/core";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigReaderModule,
|
||||
LlmProxyModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const llmConfig = configService.get<LlmConfig>("llm");
|
||||
return {
|
||||
defaultAdapter: "openai",
|
||||
openai: llmConfig?.openai,
|
||||
claudeCode: llmConfig?.claudeCode,
|
||||
openCode: llmConfig?.openCode,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [CommitCommand, CommitService],
|
||||
})
|
||||
export class CommitModule {}
|
||||
952
cli/src/commands/commit/commit.service.ts
Normal file
952
cli/src/commands/commit/commit.service.ts
Normal file
@@ -0,0 +1,952 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigReaderService } from "@spaceflow/core";
|
||||
import { execSync, spawnSync } from "child_process";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import micromatch from "micromatch";
|
||||
import { dirname, join } from "path";
|
||||
import {
|
||||
LlmProxyService,
|
||||
type LlmMessage,
|
||||
LlmJsonPut,
|
||||
parallel,
|
||||
shouldLog,
|
||||
t,
|
||||
} from "@spaceflow/core";
|
||||
import {
|
||||
CommitScopeConfigSchema,
|
||||
formatCommitMessage,
|
||||
parseCommitMessage,
|
||||
type CommitConfig,
|
||||
type CommitGroup,
|
||||
type CommitMessage,
|
||||
type CommitOptions,
|
||||
type CommitResult,
|
||||
type CommitScopeConfig,
|
||||
type CommitType,
|
||||
type PackageInfo,
|
||||
type ScopeRule,
|
||||
type SplitAnalysis,
|
||||
} from "./commit.config";
|
||||
|
||||
// 重新导出类型,保持向后兼容
|
||||
export type {
|
||||
CommitConfig,
|
||||
CommitGroup,
|
||||
CommitMessage,
|
||||
CommitOptions,
|
||||
CommitResult,
|
||||
CommitScopeConfig,
|
||||
CommitType,
|
||||
PackageInfo,
|
||||
ScopeRule,
|
||||
SplitAnalysis,
|
||||
};
|
||||
export { formatCommitMessage, parseCommitMessage };
|
||||
|
||||
/**
|
||||
* Commit 上下文,包含生成 commit message 所需的所有信息
|
||||
*/
|
||||
interface CommitContext {
|
||||
files: string[];
|
||||
diff: string;
|
||||
scope: string;
|
||||
packageInfo?: PackageInfo;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CommitService {
|
||||
constructor(
|
||||
private readonly configReader: ConfigReaderService,
|
||||
private readonly llmProxyService: LlmProxyService,
|
||||
) {}
|
||||
|
||||
// ============================================================
|
||||
// Git 基础操作
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 执行 git 命令并返回输出
|
||||
*/
|
||||
private execGit(command: string, options?: { maxBuffer?: number }): string {
|
||||
return execSync(command, {
|
||||
encoding: "utf-8",
|
||||
maxBuffer: options?.maxBuffer ?? 1024 * 1024 * 10,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全执行 git 命令,失败返回空字符串
|
||||
*/
|
||||
private execGitSafe(command: string, options?: { maxBuffer?: number }): string {
|
||||
try {
|
||||
return this.execGit(command, options);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件列表(从 git 命令输出解析)
|
||||
*/
|
||||
private parseFileList(output: string): string[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((f) => f.trim())
|
||||
.filter((f) => f.length > 0);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 暂存区操作
|
||||
// ============================================================
|
||||
|
||||
getStagedFiles(): string[] {
|
||||
return this.parseFileList(this.execGitSafe("git diff --cached --name-only"));
|
||||
}
|
||||
|
||||
getStagedDiff(): string {
|
||||
try {
|
||||
return this.execGit("git diff --cached --no-color");
|
||||
} catch {
|
||||
throw new Error(t("commit:getDiffFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
hasStagedFiles(): boolean {
|
||||
return this.getStagedFiles().length > 0;
|
||||
}
|
||||
|
||||
getFileDiff(files: string[]): string {
|
||||
if (files.length === 0) return "";
|
||||
const fileArgs = files.map((f) => `"${f}"`).join(" ");
|
||||
return this.execGitSafe(`git diff --cached --no-color -- ${fileArgs}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工作区操作
|
||||
// ============================================================
|
||||
|
||||
getUnstagedFiles(): string[] {
|
||||
return this.parseFileList(this.execGitSafe("git diff --name-only"));
|
||||
}
|
||||
|
||||
getUntrackedFiles(): string[] {
|
||||
return this.parseFileList(this.execGitSafe("git ls-files --others --exclude-standard"));
|
||||
}
|
||||
|
||||
getAllWorkingFiles(): string[] {
|
||||
return [...new Set([...this.getUnstagedFiles(), ...this.getUntrackedFiles()])];
|
||||
}
|
||||
|
||||
hasWorkingFiles(): boolean {
|
||||
return this.getAllWorkingFiles().length > 0;
|
||||
}
|
||||
|
||||
getUnstagedFileDiff(files: string[]): string {
|
||||
if (files.length === 0) return "";
|
||||
const fileArgs = files.map((f) => `"${f}"`).join(" ");
|
||||
return this.execGitSafe(`git diff --no-color -- ${fileArgs}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 历史记录
|
||||
// ============================================================
|
||||
|
||||
getRecentCommits(count: number = 10): string {
|
||||
return this.execGitSafe(`git log --oneline -n ${count} --no-color`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 配置获取
|
||||
// ============================================================
|
||||
|
||||
getCommitTypes(): CommitType[] {
|
||||
const publishConfig = this.configReader.getPluginConfig<CommitConfig>("publish");
|
||||
|
||||
const defaultTypes: CommitType[] = [
|
||||
{ type: "feat", section: "新特性" },
|
||||
{ type: "fix", section: "修复BUG" },
|
||||
{ type: "perf", section: "性能优化" },
|
||||
{ type: "refactor", section: "代码重构" },
|
||||
{ type: "docs", section: "文档更新" },
|
||||
{ type: "style", section: "代码格式" },
|
||||
{ type: "test", section: "测试用例" },
|
||||
{ type: "chore", section: "其他修改" },
|
||||
];
|
||||
|
||||
return publishConfig?.changelog?.preset?.type || defaultTypes;
|
||||
}
|
||||
|
||||
getScopeConfig(): CommitScopeConfig {
|
||||
const commitConfig = this.configReader.getPluginConfig<Record<string, unknown>>("commit");
|
||||
return CommitScopeConfigSchema.parse(commitConfig ?? {});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Package.json 和 Scope 处理
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 从路径提取 scope(目录名)
|
||||
* 根目录返回空字符串
|
||||
*/
|
||||
extractScopeFromPath(packagePath: string): string {
|
||||
if (!packagePath || packagePath === process.cwd()) return "";
|
||||
const parts = packagePath.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找文件所属的 package.json
|
||||
*/
|
||||
findPackageForFile(file: string): PackageInfo {
|
||||
const cwd = process.cwd();
|
||||
let dir = dirname(join(cwd, file));
|
||||
|
||||
while (dir !== dirname(dir)) {
|
||||
const pkgPath = join(dir, "package.json");
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
return {
|
||||
name: pkg.name || "root",
|
||||
description: pkg.description,
|
||||
path: dir,
|
||||
};
|
||||
} catch {
|
||||
// 解析失败,继续向上
|
||||
}
|
||||
}
|
||||
dir = dirname(dir);
|
||||
}
|
||||
|
||||
// 回退到根目录
|
||||
return this.getRootPackageInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取根目录的 package.json 信息
|
||||
*/
|
||||
private getRootPackageInfo(): PackageInfo {
|
||||
const cwd = process.cwd();
|
||||
const pkgPath = join(cwd, "package.json");
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
return { name: pkg.name || "root", description: pkg.description, path: cwd };
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return { name: "root", path: cwd };
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 package.json 对文件分组
|
||||
*/
|
||||
groupFilesByPackage(files: string[]): Map<string, { files: string[]; packageInfo: PackageInfo }> {
|
||||
const groups = new Map<string, { files: string[]; packageInfo: PackageInfo }>();
|
||||
|
||||
for (const file of files) {
|
||||
const pkgInfo = this.findPackageForFile(file);
|
||||
const key = pkgInfo.path;
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, { files: [], packageInfo: pkgInfo });
|
||||
}
|
||||
groups.get(key)!.files.push(file);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据自定义规则匹配文件的 scope
|
||||
*/
|
||||
matchFileToScope(file: string, rules: ScopeRule[]): string | null {
|
||||
for (const rule of rules) {
|
||||
if (micromatch.isMatch(file, rule.pattern)) {
|
||||
return rule.scope;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据配置策略对文件分组
|
||||
*/
|
||||
groupFiles(
|
||||
files: string[],
|
||||
): Map<string, { files: string[]; scope: string; packageInfo?: PackageInfo }> {
|
||||
const config = this.getScopeConfig();
|
||||
const groups = new Map<string, { files: string[]; scope: string; packageInfo?: PackageInfo }>();
|
||||
|
||||
for (const file of files) {
|
||||
let scope: string | null = null;
|
||||
let packageInfo: PackageInfo | undefined;
|
||||
|
||||
// 规则匹配
|
||||
if (config.strategy === "rules" || config.strategy === "rules-first") {
|
||||
scope = this.matchFileToScope(file, config.rules || []);
|
||||
}
|
||||
|
||||
// 包目录匹配
|
||||
if (scope === null && (config.strategy === "package" || config.strategy === "rules-first")) {
|
||||
packageInfo = this.findPackageForFile(file);
|
||||
scope = this.extractScopeFromPath(packageInfo.path);
|
||||
}
|
||||
|
||||
const finalScope = scope || "";
|
||||
if (!groups.has(finalScope)) {
|
||||
groups.set(finalScope, { files: [], scope: finalScope, packageInfo });
|
||||
}
|
||||
groups.get(finalScope)!.files.push(file);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Commit 上下文获取
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 获取 commit 上下文(统一获取 files, diff, scope, packageInfo)
|
||||
*/
|
||||
getCommitContext(files: string[], useUnstaged = false): CommitContext {
|
||||
const diff = useUnstaged ? this.getUnstagedFileDiff(files) : this.getFileDiff(files);
|
||||
const packageGroups = this.groupFilesByPackage(files);
|
||||
const firstGroup = [...packageGroups.values()][0];
|
||||
const packageInfo = firstGroup?.packageInfo;
|
||||
const scope = packageInfo ? this.extractScopeFromPath(packageInfo.path) : "";
|
||||
|
||||
return { files, diff, scope, packageInfo };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Prompt 构建
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 构建 commit message 生成的 prompt
|
||||
*/
|
||||
private buildCommitPrompt(ctx: CommitContext): { system: string; user: string } {
|
||||
const commitTypes = this.getCommitTypes();
|
||||
const typesList = commitTypes.map((t) => `- ${t.type}: ${t.section}`).join("\n");
|
||||
const recentCommits = this.getRecentCommits();
|
||||
|
||||
const packageContext = ctx.packageInfo
|
||||
? `\n## 包信息\n- 包名: ${ctx.packageInfo.name}\n- scope: ${ctx.scope || "无(根目录)"}${ctx.packageInfo.description ? `\n- 描述: ${ctx.packageInfo.description}` : ""}`
|
||||
: "";
|
||||
|
||||
const system = `你是一个专业的 Git commit message 生成器。请根据提供的代码变更生成符合 Conventional Commits 规范的 commit message。
|
||||
|
||||
## Commit 类型规范
|
||||
${typesList}
|
||||
|
||||
## 输出格式
|
||||
请严格按照以下 JSON 格式输出,不要包含任何其他内容:
|
||||
{
|
||||
"type": "feat",
|
||||
"scope": "${ctx.scope}",
|
||||
"subject": "简短描述(不超过50字符,中文)",
|
||||
"body": "详细描述(可选,中文)"
|
||||
}
|
||||
|
||||
## 规则
|
||||
1. type 必须是上述类型之一
|
||||
2. scope ${ctx.scope ? `必须使用 "${ctx.scope}"` : "必须为空字符串(根目录不需要 scope)"}
|
||||
3. subject 是简短描述,不超过 50 个字符,使用中文
|
||||
4. body 是详细描述,可选,使用中文,如果没有则设为空字符串
|
||||
5. 如果变更涉及多个方面,选择最主要的类型`;
|
||||
|
||||
const truncatedDiff =
|
||||
ctx.diff.length > 8000 ? ctx.diff.substring(0, 8000) + "\n... (diff 过长,已截断)" : ctx.diff;
|
||||
|
||||
const user = `请根据以下信息生成 commit message:
|
||||
${packageContext}
|
||||
## 暂存的文件
|
||||
${ctx.files.join("\n")}
|
||||
|
||||
## 最近的 commit 历史(参考风格)
|
||||
${recentCommits || "无历史记录"}
|
||||
|
||||
## 代码变更 (diff)
|
||||
\`\`\`diff
|
||||
${truncatedDiff}
|
||||
\`\`\`
|
||||
|
||||
请直接输出 JSON 格式的 commit message。`;
|
||||
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AI 响应解析
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 解析 AI 响应为结构化 CommitMessage
|
||||
*/
|
||||
private async parseAIResponse(content: string, expectedScope: string): Promise<CommitMessage> {
|
||||
const jsonPut = new LlmJsonPut<{
|
||||
type: string;
|
||||
scope?: string;
|
||||
subject: string;
|
||||
body?: string;
|
||||
}>({
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", description: "commit 类型" },
|
||||
scope: { type: "string", description: "影响范围" },
|
||||
subject: { type: "string", description: "简短描述" },
|
||||
body: { type: "string", description: "详细描述" },
|
||||
},
|
||||
required: ["type", "subject"],
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = await jsonPut.parse(content, { disableRequestRetry: true });
|
||||
return this.normalizeScope(
|
||||
{
|
||||
type: parsed.type || "chore",
|
||||
subject: parsed.subject || "",
|
||||
scope: parsed.scope || undefined,
|
||||
body: parsed.body || undefined,
|
||||
},
|
||||
expectedScope,
|
||||
);
|
||||
} catch {
|
||||
return this.normalizeScope(parseCommitMessage(content), expectedScope);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 scope(强制使用预期值或移除)
|
||||
*/
|
||||
private normalizeScope(commit: CommitMessage, expectedScope: string): CommitMessage {
|
||||
return expectedScope ? { ...commit, scope: expectedScope } : { ...commit, scope: undefined };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Commit Message 生成
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 生成 commit message(核心方法)
|
||||
*/
|
||||
async generateCommitMessage(
|
||||
options?: CommitOptions & { files?: string[]; useUnstaged?: boolean },
|
||||
): Promise<CommitMessage> {
|
||||
// 获取文件列表
|
||||
const files = options?.files ?? this.getStagedFiles();
|
||||
if (files.length === 0) {
|
||||
throw new Error(t("commit:noFilesToCommit"));
|
||||
}
|
||||
|
||||
// 获取上下文
|
||||
const ctx = this.getCommitContext(files, options?.useUnstaged);
|
||||
if (!ctx.diff) {
|
||||
throw new Error(t("commit:noChanges"));
|
||||
}
|
||||
|
||||
// 构建 prompt
|
||||
const prompt = this.buildCommitPrompt(ctx);
|
||||
const messages: LlmMessage[] = [
|
||||
{ role: "system", content: prompt.system },
|
||||
{ role: "user", content: prompt.user },
|
||||
];
|
||||
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
console.log(t("commit:generatingMessage"));
|
||||
}
|
||||
|
||||
// 调用 AI
|
||||
const response = await this.llmProxyService.chat(messages, {
|
||||
verbose: options?.verbose,
|
||||
});
|
||||
|
||||
// 解析响应并填充上下文
|
||||
const commit = await this.parseAIResponse(response.content, ctx.scope);
|
||||
return {
|
||||
...commit,
|
||||
files: ctx.files,
|
||||
packageInfo: ctx.packageInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定文件生成 commit message(兼容旧 API)
|
||||
*/
|
||||
async generateCommitMessageForFiles(
|
||||
files: string[],
|
||||
options?: CommitOptions,
|
||||
useUnstaged = false,
|
||||
_packageInfo?: { name: string; description?: string },
|
||||
): Promise<CommitMessage> {
|
||||
return this.generateCommitMessage({ ...options, files, useUnstaged });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 拆分分析
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 分析如何拆分 commit
|
||||
*/
|
||||
async analyzeSplitStrategy(options?: CommitOptions, useUnstaged = false): Promise<SplitAnalysis> {
|
||||
const files = useUnstaged ? this.getUnstagedFiles() : this.getStagedFiles();
|
||||
const config = this.getScopeConfig();
|
||||
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
const strategyName =
|
||||
config.strategy === "rules"
|
||||
? t("commit:strategyRules")
|
||||
: config.strategy === "rules-first"
|
||||
? t("commit:strategyRulesFirst")
|
||||
: t("commit:strategyPackage");
|
||||
console.log(t("commit:groupingByStrategy", { strategy: strategyName }));
|
||||
}
|
||||
|
||||
const scopeGroups = this.groupFiles(files);
|
||||
|
||||
// 单个组:让 AI 进一步分析
|
||||
if (scopeGroups.size === 1) {
|
||||
const [, groupData] = [...scopeGroups.entries()][0];
|
||||
const packageInfo = groupData.packageInfo || {
|
||||
name: groupData.scope || "root",
|
||||
path: process.cwd(),
|
||||
};
|
||||
return this.analyzeWithinPackage(groupData.files, packageInfo, options, useUnstaged);
|
||||
}
|
||||
|
||||
// 多个组:每个组作为独立 commit
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
console.log(t("commit:detectedGroups", { count: scopeGroups.size }));
|
||||
}
|
||||
|
||||
const groups: CommitGroup[] = [];
|
||||
for (const [, groupData] of scopeGroups) {
|
||||
groups.push({
|
||||
files: groupData.files,
|
||||
reason: groupData.scope
|
||||
? t("commit:scopeChanges", { scope: groupData.scope })
|
||||
: t("commit:rootChanges"),
|
||||
packageInfo: groupData.packageInfo
|
||||
? { name: groupData.packageInfo.name, description: groupData.packageInfo.description }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return { groups };
|
||||
}
|
||||
|
||||
/**
|
||||
* 在单个包内分析拆分策略
|
||||
*/
|
||||
private async analyzeWithinPackage(
|
||||
files: string[],
|
||||
packageInfo: PackageInfo,
|
||||
options?: CommitOptions,
|
||||
useUnstaged = false,
|
||||
): Promise<SplitAnalysis> {
|
||||
const diff = useUnstaged ? this.getUnstagedFileDiff(files) : this.getFileDiff(files);
|
||||
const scope = this.extractScopeFromPath(packageInfo.path);
|
||||
const commitTypes = this.getCommitTypes();
|
||||
const typesList = commitTypes.map((t) => `- ${t.type}: ${t.section}`).join("\n");
|
||||
|
||||
const systemPrompt = `你是一个专业的 Git commit 拆分分析器。请根据暂存的文件和代码变更,分析如何将这些变更拆分为多个独立的 commit。
|
||||
|
||||
## 当前包信息
|
||||
- 包名: ${packageInfo.name}
|
||||
- scope: ${scope || "无"}
|
||||
${packageInfo.description ? `- 描述: ${packageInfo.description}` : ""}
|
||||
|
||||
## Commit 类型规范
|
||||
${typesList}
|
||||
|
||||
## 拆分原则
|
||||
1. **按逻辑拆分**:相关的功能变更放在一起
|
||||
2. **按业务拆分**:不同业务模块的变更分开
|
||||
3. **保持原子性**:每个 commit 是完整的、可独立理解的变更
|
||||
4. **最小化拆分**:如果变更本身是整体,不要强行拆分
|
||||
|
||||
## 输出格式
|
||||
请严格按照以下 JSON 格式输出:
|
||||
{
|
||||
"groups": [
|
||||
{ "files": ["file1.ts", "file2.ts"], "reason": "简短描述" }
|
||||
]
|
||||
}`;
|
||||
|
||||
const truncatedDiff =
|
||||
diff.length > 12000 ? diff.substring(0, 12000) + "\n... (diff 过长,已截断)" : diff;
|
||||
|
||||
const userPrompt = `请分析以下改动文件,决定如何拆分 commit:
|
||||
|
||||
## 改动的文件
|
||||
${files.join("\n")}
|
||||
|
||||
## 代码变更 (diff)
|
||||
\`\`\`diff
|
||||
${truncatedDiff}
|
||||
\`\`\`
|
||||
|
||||
请输出 JSON 格式的拆分策略。`;
|
||||
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
console.log(t("commit:analyzingSplit"));
|
||||
}
|
||||
|
||||
const response = await this.llmProxyService.chat(
|
||||
[
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
{ verbose: options?.verbose },
|
||||
);
|
||||
|
||||
return this.parseSplitAnalysis(response.content, files, packageInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析拆分分析结果
|
||||
*/
|
||||
private parseSplitAnalysis(
|
||||
content: string,
|
||||
files: string[],
|
||||
packageInfo: PackageInfo,
|
||||
): SplitAnalysis {
|
||||
let text = content
|
||||
.trim()
|
||||
.replace(/^```[\w]*\n?/, "")
|
||||
.replace(/\n?```$/, "")
|
||||
.trim();
|
||||
|
||||
try {
|
||||
const analysis = JSON.parse(text) as SplitAnalysis;
|
||||
|
||||
if (!analysis.groups || !Array.isArray(analysis.groups)) {
|
||||
throw new Error("Invalid response");
|
||||
}
|
||||
|
||||
const validFiles = new Set(files);
|
||||
|
||||
// 过滤无效文件,移除空组
|
||||
for (const group of analysis.groups) {
|
||||
group.files = (group.files || []).filter((f) => validFiles.has(f));
|
||||
}
|
||||
analysis.groups = analysis.groups.filter((g) => g.files.length > 0);
|
||||
|
||||
// 补充未分配的文件
|
||||
const assignedFiles = new Set(analysis.groups.flatMap((g) => g.files));
|
||||
const missingFiles = files.filter((f) => !assignedFiles.has(f));
|
||||
if (missingFiles.length > 0) {
|
||||
if (analysis.groups.length > 0) {
|
||||
analysis.groups[analysis.groups.length - 1].files.push(...missingFiles);
|
||||
} else {
|
||||
analysis.groups.push({ files: missingFiles, reason: t("commit:otherChanges") });
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 packageInfo
|
||||
for (const group of analysis.groups) {
|
||||
group.packageInfo = { name: packageInfo.name, description: packageInfo.description };
|
||||
}
|
||||
|
||||
return analysis;
|
||||
} catch {
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
files,
|
||||
reason: t("commit:allChanges"),
|
||||
packageInfo: { name: packageInfo.name, description: packageInfo.description },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Commit 执行
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 执行 git commit
|
||||
*/
|
||||
async commit(message: string, options?: CommitOptions): Promise<CommitResult> {
|
||||
if (options?.dryRun) {
|
||||
return { success: true, message: t("commit:dryRunMessage", { message }) };
|
||||
}
|
||||
|
||||
try {
|
||||
const args = ["commit", "-m", message];
|
||||
if (options?.noVerify) args.push("--no-verify");
|
||||
|
||||
const result = spawnSync("git", args, { encoding: "utf-8", stdio: "pipe" });
|
||||
|
||||
if (result.status !== 0) {
|
||||
return { success: false, error: result.stderr || result.stdout || t("commit:commitFail") };
|
||||
}
|
||||
return { success: true, message: result.stdout };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂存文件
|
||||
*/
|
||||
private stageFiles(files: string[]): { success: boolean; error?: string } {
|
||||
const result = spawnSync("git", ["add", ...files], { encoding: "utf-8", stdio: "pipe" });
|
||||
if (result.status !== 0) {
|
||||
return { success: false, error: t("commit:stageFilesFailed", { error: result.stderr }) };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置暂存区
|
||||
*/
|
||||
private resetStaging(): boolean {
|
||||
try {
|
||||
execSync("git reset HEAD", { encoding: "utf-8", stdio: "pipe" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 批量提交
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 排序 groups:子包优先,根目录最后
|
||||
*/
|
||||
private sortGroupsForCommit(groups: CommitGroup[]): CommitGroup[] {
|
||||
return [...groups].sort((a, b) => {
|
||||
const aScope = this.getGroupScope(a);
|
||||
const bScope = this.getGroupScope(b);
|
||||
if (!aScope && bScope) return 1;
|
||||
if (aScope && !bScope) return -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
private getGroupScope(group: CommitGroup): string {
|
||||
if (!group.packageInfo) return "";
|
||||
const pkgGroups = this.groupFilesByPackage(group.files);
|
||||
const first = [...pkgGroups.values()][0];
|
||||
return first ? this.extractScopeFromPath(first.packageInfo.path) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 分批提交
|
||||
*/
|
||||
async commitInBatches(options?: CommitOptions, useWorking = false): Promise<CommitResult> {
|
||||
const files = useWorking ? this.getAllWorkingFiles() : this.getStagedFiles();
|
||||
|
||||
if (files.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: useWorking ? t("commit:noWorkingChanges") : t("commit:noStagedFiles"),
|
||||
};
|
||||
}
|
||||
|
||||
const analysis = await this.analyzeSplitStrategy(options, useWorking);
|
||||
const sortedGroups = this.sortGroupsForCommit(analysis.groups);
|
||||
|
||||
// 单个组:简化处理
|
||||
if (sortedGroups.length <= 1) {
|
||||
const group = sortedGroups[0];
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
console.log(t("commit:singleCommit"));
|
||||
}
|
||||
|
||||
if (useWorking) {
|
||||
const stageResult = this.stageFiles(files);
|
||||
if (!stageResult.success) return { success: false, error: stageResult.error };
|
||||
}
|
||||
|
||||
const commitObj = await this.generateCommitMessage({ ...options, files });
|
||||
const message = formatCommitMessage(commitObj);
|
||||
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
console.log(t("commit:generatedMessage"));
|
||||
console.log("─".repeat(50));
|
||||
console.log(message);
|
||||
console.log("─".repeat(50));
|
||||
}
|
||||
|
||||
return this.commit(message, options);
|
||||
}
|
||||
|
||||
// 多个组:并行生成 message,顺序提交
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
console.log(t("commit:splitIntoCommits", { count: sortedGroups.length }));
|
||||
sortedGroups.forEach((g, i) => {
|
||||
const pkgStr = g.packageInfo ? ` [${g.packageInfo.name}]` : "";
|
||||
console.log(
|
||||
t("commit:groupItem", {
|
||||
index: i + 1,
|
||||
reason: g.reason,
|
||||
pkg: pkgStr,
|
||||
count: g.files.length,
|
||||
}),
|
||||
);
|
||||
});
|
||||
console.log(t("commit:parallelGenerating", { count: sortedGroups.length }));
|
||||
}
|
||||
|
||||
// 并行生成 messages
|
||||
const executor = parallel({ concurrency: 5 });
|
||||
const messageResults = await executor.map(
|
||||
sortedGroups,
|
||||
async (group: CommitGroup) =>
|
||||
this.generateCommitMessage({ ...options, files: group.files, useUnstaged: useWorking }),
|
||||
(group: CommitGroup) => group.files[0] || "unknown",
|
||||
);
|
||||
|
||||
const failedResults = messageResults.filter((r) => !r.success);
|
||||
if (failedResults.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: t("commit:generateMessageFailed", {
|
||||
errors: failedResults.map((r) => r.error?.message).join(", "),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const generatedMessages = messageResults.map((r) => r.result!);
|
||||
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
console.log(t("commit:allMessagesGenerated"));
|
||||
}
|
||||
|
||||
// Dry run 模式
|
||||
if (options?.dryRun) {
|
||||
const preview = sortedGroups
|
||||
.map((g, i) => {
|
||||
const pkgStr = g.packageInfo ? `\n包: ${g.packageInfo.name}` : "";
|
||||
return `[Commit ${i + 1}/${sortedGroups.length}] ${g.reason}${pkgStr}\n文件: ${g.files.join(", ")}\n\n${formatCommitMessage(generatedMessages[i])}`;
|
||||
})
|
||||
.join("\n\n" + "═".repeat(50) + "\n\n");
|
||||
return { success: true, message: preview, commitCount: sortedGroups.length };
|
||||
}
|
||||
|
||||
// 实际提交
|
||||
if (this.hasStagedFiles() && !this.resetStaging()) {
|
||||
return { success: false, error: t("commit:resetStagingFailed") };
|
||||
}
|
||||
|
||||
const committedMessages: string[] = [];
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < sortedGroups.length; i++) {
|
||||
const group = sortedGroups[i];
|
||||
const commitObj = generatedMessages[i];
|
||||
const messageStr = formatCommitMessage(commitObj);
|
||||
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
const pkgStr = group.packageInfo ? ` [${group.packageInfo.name}]` : "";
|
||||
console.log(
|
||||
t("commit:committingGroup", {
|
||||
current: i + 1,
|
||||
total: sortedGroups.length,
|
||||
reason: group.reason,
|
||||
pkg: pkgStr,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const stageResult = this.stageFiles(group.files);
|
||||
if (!stageResult.success) throw new Error(stageResult.error);
|
||||
|
||||
const diff = this.getFileDiff(group.files);
|
||||
if (!diff) {
|
||||
if (shouldLog(options?.verbose, 1)) console.log(t("commit:skippingNoChanges"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
console.log(t("commit:commitMessage"));
|
||||
console.log("─".repeat(50));
|
||||
console.log(messageStr);
|
||||
console.log("─".repeat(50));
|
||||
}
|
||||
|
||||
const commitResult = await this.commit(messageStr, options);
|
||||
if (!commitResult.success) throw new Error(commitResult.error);
|
||||
|
||||
successCount++;
|
||||
const shortMsg = formatCommitMessage({ ...commitObj, body: undefined });
|
||||
committedMessages.push(`✅ Commit ${i + 1}: ${shortMsg}`);
|
||||
console.log(committedMessages[committedMessages.length - 1]);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
committedMessages.push(t("commit:commitItemFailed", { index: i + 1, error: errorMsg }));
|
||||
|
||||
// 恢复剩余文件
|
||||
const remaining = sortedGroups.slice(i).flatMap((g) => g.files);
|
||||
if (remaining.length > 0) this.stageFiles(remaining);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: t("commit:commitItemFailedDetail", {
|
||||
index: i + 1,
|
||||
error: errorMsg,
|
||||
committed: committedMessages.join("\n"),
|
||||
}),
|
||||
commitCount: successCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: committedMessages.join("\n"), commitCount: successCount };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主入口
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 生成并提交(主入口)
|
||||
*/
|
||||
async generateAndCommit(options?: CommitOptions): Promise<CommitResult> {
|
||||
// Split 模式
|
||||
if (options?.split) {
|
||||
const hasWorking = this.hasWorkingFiles();
|
||||
const hasStaged = this.hasStagedFiles();
|
||||
|
||||
if (!hasWorking && !hasStaged) {
|
||||
return { success: false, error: t("commit:noChangesAll") };
|
||||
}
|
||||
|
||||
return this.commitInBatches(options, hasWorking);
|
||||
}
|
||||
|
||||
// 普通模式
|
||||
if (!this.hasStagedFiles()) {
|
||||
return { success: false, error: t("commit:noStagedFilesHint") };
|
||||
}
|
||||
|
||||
try {
|
||||
const commitObj = await this.generateCommitMessage(options);
|
||||
const message = formatCommitMessage(commitObj);
|
||||
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
console.log(t("commit:generatedMessage"));
|
||||
console.log("─".repeat(50));
|
||||
console.log(message);
|
||||
console.log("─".repeat(50));
|
||||
}
|
||||
|
||||
return this.commit(message, options);
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
}
|
||||
28
cli/src/commands/commit/index.ts
Normal file
28
cli/src/commands/commit/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
SpaceflowExtension,
|
||||
SpaceflowExtensionMetadata,
|
||||
ExtensionModuleType,
|
||||
} from "@spaceflow/core";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { CommitScopeConfigSchema } from "./commit.config";
|
||||
import { CommitModule } from "./commit.module";
|
||||
|
||||
/** commit 插件元数据 */
|
||||
export const commitMetadata: SpaceflowExtensionMetadata = {
|
||||
name: "commit",
|
||||
commands: ["commit"],
|
||||
configKey: "commit",
|
||||
configSchema: () => CommitScopeConfigSchema,
|
||||
version: "1.0.0",
|
||||
description: t("commit:extensionDescription"),
|
||||
};
|
||||
|
||||
export class CommitExtension implements SpaceflowExtension {
|
||||
getMetadata(): SpaceflowExtensionMetadata {
|
||||
return commitMetadata;
|
||||
}
|
||||
|
||||
getModule(): ExtensionModuleType {
|
||||
return CommitModule;
|
||||
}
|
||||
}
|
||||
150
cli/src/commands/create/create.command.ts
Normal file
150
cli/src/commands/create/create.command.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Command, CommandRunner, Option } from "nest-commander";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { CreateService } from "./create.service";
|
||||
import type { VerboseLevel } from "@spaceflow/core";
|
||||
|
||||
export interface CreateOptions {
|
||||
directory?: string;
|
||||
list?: boolean;
|
||||
from?: string;
|
||||
ref?: string;
|
||||
verbose?: VerboseLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建插件命令
|
||||
*
|
||||
* 用法:
|
||||
* spaceflow create <template> <name> [--directory <dir>]
|
||||
* spaceflow create --from <repo> <template> <name> 使用远程模板仓库
|
||||
* spaceflow create --list 查看可用模板
|
||||
*
|
||||
* 模板类型动态读取自 templates/ 目录或远程仓库
|
||||
*/
|
||||
@Command({
|
||||
name: "create",
|
||||
arguments: "[template] [name]",
|
||||
description: t("create:description"),
|
||||
})
|
||||
export class CreateCommand extends CommandRunner {
|
||||
constructor(protected readonly createService: CreateService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(passedParams: string[], options: CreateOptions): Promise<void> {
|
||||
const verbose = options.verbose ?? true;
|
||||
// 列出可用模板
|
||||
if (options.list) {
|
||||
await this.listTemplates(options, verbose);
|
||||
return;
|
||||
}
|
||||
|
||||
const template = passedParams[0];
|
||||
const name = passedParams[1];
|
||||
|
||||
// 无参数时显示帮助
|
||||
if (!template) {
|
||||
await this.showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
console.error(t("create:noName"));
|
||||
console.error(t("create:usage"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
// 如果指定了远程仓库,先获取模板
|
||||
if (options.from) {
|
||||
await this.createService.ensureRemoteTemplates(options.from, options.ref, verbose);
|
||||
}
|
||||
await this.createService.createFromTemplate(template, name, options, verbose);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(t("create:createFailed", { error: error.message }));
|
||||
if (error.stack) {
|
||||
console.error(t("common.stackTrace", { stack: error.stack }));
|
||||
}
|
||||
} else {
|
||||
console.error(t("create:createFailed", { error }));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-d, --directory <dir>",
|
||||
description: t("create:options.directory"),
|
||||
})
|
||||
parseDirectory(val: string): string {
|
||||
return val;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-l, --list",
|
||||
description: t("create:options.list"),
|
||||
})
|
||||
parseList(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-f, --from <repo>",
|
||||
description: t("create:options.from"),
|
||||
})
|
||||
parseFrom(val: string): string {
|
||||
return val;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-r, --ref <ref>",
|
||||
description: t("create:options.ref"),
|
||||
})
|
||||
parseRef(val: string): string {
|
||||
return val;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-v, --verbose",
|
||||
description: t("common.options.verbose"),
|
||||
})
|
||||
parseVerbose(_val: string, previous: VerboseLevel = 0): VerboseLevel {
|
||||
const current = typeof previous === "number" ? previous : previous ? 1 : 0;
|
||||
return Math.min(current + 1, 2) as VerboseLevel;
|
||||
}
|
||||
|
||||
protected async listTemplates(options: CreateOptions, verbose: VerboseLevel): Promise<void> {
|
||||
if (options.from) {
|
||||
await this.createService.ensureRemoteTemplates(options.from, options.ref, verbose);
|
||||
}
|
||||
const templates = await this.createService.getAvailableTemplates(options);
|
||||
console.log(t("create:availableTemplates"));
|
||||
for (const tpl of templates) {
|
||||
console.log(` - ${tpl}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected async showHelp(): Promise<void> {
|
||||
const templates = await this.createService.getAvailableTemplates();
|
||||
console.log("Usage: spaceflow create <template> <name> [options]");
|
||||
console.log("");
|
||||
console.log(t("create:availableTemplates"));
|
||||
for (const tpl of templates) {
|
||||
console.log(` - ${tpl}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log("Options:");
|
||||
console.log(` -d, --directory <dir> ${t("create:options.directory")}`);
|
||||
console.log(` -l, --list ${t("create:options.list")}`);
|
||||
console.log(` -f, --from <repo> ${t("create:options.from")}`);
|
||||
console.log(` -r, --ref <ref> ${t("create:options.ref")}`);
|
||||
console.log("");
|
||||
console.log("Examples:");
|
||||
console.log(" spaceflow create command my-cmd");
|
||||
console.log(" spaceflow create skills my-skill");
|
||||
console.log(" spaceflow create command my-cmd -d ./plugins/my-cmd");
|
||||
console.log(" spaceflow create -f https://github.com/user/templates command my-cmd");
|
||||
console.log(" spaceflow create -f git@gitea.example.com:org/tpl.git -r v1.0 api my-api");
|
||||
}
|
||||
}
|
||||
8
cli/src/commands/create/create.module.ts
Normal file
8
cli/src/commands/create/create.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CreateCommand } from "./create.command";
|
||||
import { CreateService } from "./create.service";
|
||||
|
||||
@Module({
|
||||
providers: [CreateCommand, CreateService],
|
||||
})
|
||||
export class CreateModule {}
|
||||
320
cli/src/commands/create/create.service.ts
Normal file
320
cli/src/commands/create/create.service.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { mkdir, writeFile, readFile, readdir, stat } from "fs/promises";
|
||||
import { join, resolve } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { execSync } from "child_process";
|
||||
import { createHash } from "crypto";
|
||||
import { homedir } from "os";
|
||||
import { shouldLog, type VerboseLevel, t } from "@spaceflow/core";
|
||||
|
||||
export interface CreateOptions {
|
||||
directory?: string;
|
||||
from?: string;
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
interface TemplateContext {
|
||||
name: string;
|
||||
pascalName: string;
|
||||
camelName: string;
|
||||
kebabName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建插件服务
|
||||
*/
|
||||
@Injectable()
|
||||
export class CreateService {
|
||||
// 缓存当前使用的远程模板目录
|
||||
private remoteTemplatesDir: string | null = null;
|
||||
|
||||
/**
|
||||
* 获取缓存目录路径
|
||||
*/
|
||||
protected getCacheDir(): string {
|
||||
return join(homedir(), ".cache", "spaceflow", "templates");
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算仓库 URL 的哈希值(用于缓存目录名)
|
||||
*/
|
||||
protected getRepoHash(repoUrl: string): string {
|
||||
return createHash("md5").update(repoUrl).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从仓库 URL 提取名称
|
||||
*/
|
||||
protected getRepoName(repoUrl: string): string {
|
||||
// 处理 git@host:org/repo.git 或 https://host/org/repo.git
|
||||
const match = repoUrl.match(/[/:]([^/]+\/[^/]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
return match[1].replace("/", "-");
|
||||
}
|
||||
return this.getRepoHash(repoUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保远程模板仓库已克隆/更新
|
||||
*/
|
||||
async ensureRemoteTemplates(
|
||||
repoUrl: string,
|
||||
ref?: string,
|
||||
verbose: VerboseLevel = 1,
|
||||
): Promise<string> {
|
||||
const cacheDir = this.getCacheDir();
|
||||
const repoName = this.getRepoName(repoUrl);
|
||||
const repoHash = this.getRepoHash(repoUrl);
|
||||
const targetDir = join(cacheDir, `${repoName}-${repoHash}`);
|
||||
|
||||
// 确保缓存目录存在
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
|
||||
if (existsSync(targetDir)) {
|
||||
// 已存在,更新仓库
|
||||
if (shouldLog(verbose, 1)) console.log(t("create:updatingRepo", { url: repoUrl }));
|
||||
try {
|
||||
execSync("git fetch --all", { cwd: targetDir, stdio: "pipe" });
|
||||
if (ref) {
|
||||
execSync(`git checkout ${ref}`, { cwd: targetDir, stdio: "pipe" });
|
||||
// 如果是分支,尝试 pull
|
||||
try {
|
||||
execSync(`git pull origin ${ref}`, { cwd: targetDir, stdio: "pipe" });
|
||||
} catch {
|
||||
// 可能是 tag,忽略 pull 错误
|
||||
}
|
||||
} else {
|
||||
execSync("git pull", { cwd: targetDir, stdio: "pipe" });
|
||||
}
|
||||
} catch (error) {
|
||||
if (shouldLog(verbose, 1)) console.warn(t("create:updateFailed"));
|
||||
}
|
||||
} else {
|
||||
// 不存在,克隆仓库
|
||||
if (shouldLog(verbose, 1)) console.log(t("create:cloningRepo", { url: repoUrl }));
|
||||
try {
|
||||
execSync(`git clone ${repoUrl} ${targetDir}`, { stdio: "pipe" });
|
||||
if (ref) {
|
||||
execSync(`git checkout ${ref}`, { cwd: targetDir, stdio: "pipe" });
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
t("create:cloneFailed", { error: error instanceof Error ? error.message : error }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置当前使用的远程模板目录
|
||||
this.remoteTemplatesDir = targetDir;
|
||||
if (shouldLog(verbose, 1)) console.log(t("create:repoReady", { dir: targetDir }));
|
||||
return targetDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板目录路径
|
||||
*/
|
||||
protected getTemplatesDir(options?: CreateOptions): string {
|
||||
// 如果有远程模板目录,优先使用
|
||||
if (options?.from && this.remoteTemplatesDir) {
|
||||
return this.remoteTemplatesDir;
|
||||
}
|
||||
// 尝试从项目根目录查找 templates
|
||||
const cwd = process.cwd();
|
||||
const templatesInCwd = join(cwd, "templates");
|
||||
if (existsSync(templatesInCwd)) {
|
||||
return templatesInCwd;
|
||||
}
|
||||
|
||||
// 尝试从父目录查找(在 core 子目录运行时)
|
||||
const templatesInParent = join(cwd, "..", "templates");
|
||||
if (existsSync(templatesInParent)) {
|
||||
return resolve(templatesInParent);
|
||||
}
|
||||
|
||||
// 尝试从 spaceflow 包目录查找
|
||||
const spaceflowRoot = join(cwd, "node_modules", "spaceflow", "templates");
|
||||
if (existsSync(spaceflowRoot)) {
|
||||
return spaceflowRoot;
|
||||
}
|
||||
|
||||
// 回退到相对于当前文件的路径(开发模式)
|
||||
// 从 core/dist/commands/create 回溯到项目根目录
|
||||
const devPath = resolve(__dirname, "..", "..", "..", "..", "templates");
|
||||
if (existsSync(devPath)) {
|
||||
return devPath;
|
||||
}
|
||||
|
||||
throw new Error(t("create:templatesDirNotFound"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的模板列表
|
||||
*/
|
||||
async getAvailableTemplates(options?: CreateOptions): Promise<string[]> {
|
||||
try {
|
||||
const templatesDir = this.getTemplatesDir(options);
|
||||
const entries = await readdir(templatesDir);
|
||||
const templates: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// 跳过隐藏目录
|
||||
if (entry.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
const entryPath = join(templatesDir, entry);
|
||||
const entryStat = await stat(entryPath);
|
||||
if (entryStat.isDirectory()) {
|
||||
templates.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return templates;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于模板创建插件
|
||||
*/
|
||||
async createFromTemplate(
|
||||
template: string,
|
||||
name: string,
|
||||
options: CreateOptions,
|
||||
verbose: VerboseLevel = 1,
|
||||
): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
// 默认目录为 <template>/<name>
|
||||
const targetDir = options.directory || join(template, name);
|
||||
const fullPath = join(cwd, targetDir);
|
||||
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.log(t("create:creatingPlugin", { template, name }));
|
||||
console.log(t("create:targetDir", { dir: targetDir }));
|
||||
}
|
||||
|
||||
// 检查目录是否已存在
|
||||
if (existsSync(fullPath)) {
|
||||
throw new Error(t("create:dirExists", { dir: targetDir }));
|
||||
}
|
||||
|
||||
// 从模板生成文件
|
||||
const templatesDir = this.getTemplatesDir(options);
|
||||
const templateDir = join(templatesDir, template);
|
||||
|
||||
if (!existsSync(templateDir)) {
|
||||
const available = await this.getAvailableTemplates(options);
|
||||
throw new Error(
|
||||
t("create:templateNotFound", {
|
||||
template,
|
||||
available: available.join(", ") || t("create:noTemplatesAvailable"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const context = this.createContext(name);
|
||||
await this.copyTemplateDir(templateDir, fullPath, context, verbose);
|
||||
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.log(t("create:pluginCreated", { template, name }));
|
||||
console.log("");
|
||||
console.log(t("create:nextSteps"));
|
||||
console.log(` cd ${targetDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建模板上下文
|
||||
*/
|
||||
protected createContext(name: string): TemplateContext {
|
||||
return {
|
||||
name,
|
||||
pascalName: this.toPascalCase(name),
|
||||
camelName: this.toCamelCase(name),
|
||||
kebabName: this.toKebabCase(name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归复制模板目录
|
||||
*/
|
||||
protected async copyTemplateDir(
|
||||
templateDir: string,
|
||||
targetDir: string,
|
||||
context: TemplateContext,
|
||||
verbose: VerboseLevel = 1,
|
||||
): Promise<void> {
|
||||
// 确保目标目录存在
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
|
||||
const entries = await readdir(templateDir);
|
||||
|
||||
for (const entry of entries) {
|
||||
const templatePath = join(templateDir, entry);
|
||||
const entryStat = await stat(templatePath);
|
||||
|
||||
if (entryStat.isDirectory()) {
|
||||
// 递归处理子目录
|
||||
const targetSubDir = join(targetDir, entry);
|
||||
await this.copyTemplateDir(templatePath, targetSubDir, context, verbose);
|
||||
} else if (entry.endsWith(".hbs")) {
|
||||
// 处理模板文件
|
||||
const content = await readFile(templatePath, "utf-8");
|
||||
const rendered = this.renderTemplate(content, context);
|
||||
|
||||
// 计算目标文件名(移除 .hbs 后缀,替换 __name__ 占位符)
|
||||
let targetFileName = entry.slice(0, -4); // 移除 .hbs
|
||||
targetFileName = targetFileName.replace(/__name__/g, context.kebabName);
|
||||
|
||||
const targetPath = join(targetDir, targetFileName);
|
||||
await writeFile(targetPath, rendered);
|
||||
if (shouldLog(verbose, 1)) console.log(t("create:fileCreated", { file: targetFileName }));
|
||||
} else {
|
||||
// 直接复制非模板文件
|
||||
const content = await readFile(templatePath);
|
||||
const targetPath = join(targetDir, entry);
|
||||
await writeFile(targetPath, content);
|
||||
if (shouldLog(verbose, 1)) console.log(t("create:fileCopied", { file: entry }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染模板(简单的 Handlebars 风格替换)
|
||||
*/
|
||||
protected renderTemplate(template: string, context: TemplateContext): string {
|
||||
return template
|
||||
.replace(/\{\{name\}\}/g, context.name)
|
||||
.replace(/\{\{pascalName\}\}/g, context.pascalName)
|
||||
.replace(/\{\{camelName\}\}/g, context.camelName)
|
||||
.replace(/\{\{kebabName\}\}/g, context.kebabName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 PascalCase
|
||||
*/
|
||||
protected toPascalCase(str: string): string {
|
||||
return str
|
||||
.split(/[-_]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 camelCase
|
||||
*/
|
||||
protected toCamelCase(str: string): string {
|
||||
const pascal = this.toPascalCase(str);
|
||||
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 kebab-case
|
||||
*/
|
||||
protected toKebabCase(str: string): string {
|
||||
return str
|
||||
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
||||
.replace(/[_\s]+/g, "-")
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
28
cli/src/commands/create/index.ts
Normal file
28
cli/src/commands/create/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
SpaceflowExtension,
|
||||
SpaceflowExtensionMetadata,
|
||||
ExtensionModuleType,
|
||||
} from "@spaceflow/core";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { CreateModule } from "./create.module";
|
||||
|
||||
export const createMetadata: SpaceflowExtensionMetadata = {
|
||||
name: "create",
|
||||
commands: ["create"],
|
||||
version: "1.0.0",
|
||||
description: t("create:extensionDescription"),
|
||||
};
|
||||
|
||||
export class CreateExtension implements SpaceflowExtension {
|
||||
getMetadata(): SpaceflowExtensionMetadata {
|
||||
return createMetadata;
|
||||
}
|
||||
|
||||
getModule(): ExtensionModuleType {
|
||||
return CreateModule;
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./create.module";
|
||||
export { CreateCommand } from "./create.command";
|
||||
export { CreateService } from "./create.service";
|
||||
55
cli/src/commands/dev/dev.command.ts
Normal file
55
cli/src/commands/dev/dev.command.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Command, CommandRunner, Option } from "nest-commander";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { BuildService } from "../build/build.service";
|
||||
import type { VerboseLevel } from "@spaceflow/core";
|
||||
|
||||
/**
|
||||
* 开发命令
|
||||
*
|
||||
* 用于开发 skills 目录下的插件包(监听模式)
|
||||
*
|
||||
* 用法:
|
||||
* spaceflow dev [skill] # 监听并构建指定或所有插件
|
||||
*/
|
||||
export interface DevOptions {
|
||||
verbose?: VerboseLevel;
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: "dev",
|
||||
arguments: "[skill]",
|
||||
description: t("dev:description"),
|
||||
})
|
||||
export class DevCommand extends CommandRunner {
|
||||
constructor(private readonly buildService: BuildService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(passedParams: string[], options: DevOptions): Promise<void> {
|
||||
const skill = passedParams[0];
|
||||
const verbose = options.verbose ?? true;
|
||||
|
||||
try {
|
||||
await this.buildService.watch(skill, verbose);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(t("dev:startFailed", { error: error.message }));
|
||||
if (error.stack) {
|
||||
console.error(t("common.stackTrace", { stack: error.stack }));
|
||||
}
|
||||
} else {
|
||||
console.error(t("dev:startFailed", { error }));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-v, --verbose",
|
||||
description: t("common.options.verbose"),
|
||||
})
|
||||
parseVerbose(_val: string, previous: VerboseLevel = 0): VerboseLevel {
|
||||
const current = typeof previous === "number" ? previous : previous ? 1 : 0;
|
||||
return Math.min(current + 1, 2) as VerboseLevel;
|
||||
}
|
||||
}
|
||||
8
cli/src/commands/dev/dev.module.ts
Normal file
8
cli/src/commands/dev/dev.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { DevCommand } from "./dev.command";
|
||||
import { BuildService } from "../build/build.service";
|
||||
|
||||
@Module({
|
||||
providers: [DevCommand, BuildService],
|
||||
})
|
||||
export class DevModule {}
|
||||
27
cli/src/commands/dev/index.ts
Normal file
27
cli/src/commands/dev/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
SpaceflowExtension,
|
||||
SpaceflowExtensionMetadata,
|
||||
ExtensionModuleType,
|
||||
} from "@spaceflow/core";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { DevModule } from "./dev.module";
|
||||
|
||||
export const devMetadata: SpaceflowExtensionMetadata = {
|
||||
name: "dev",
|
||||
commands: ["dev"],
|
||||
version: "1.0.0",
|
||||
description: t("dev:extensionDescription"),
|
||||
};
|
||||
|
||||
export class DevExtension implements SpaceflowExtension {
|
||||
getMetadata(): SpaceflowExtensionMetadata {
|
||||
return devMetadata;
|
||||
}
|
||||
|
||||
getModule(): ExtensionModuleType {
|
||||
return DevModule;
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./dev.module";
|
||||
export * from "./dev.command";
|
||||
33
cli/src/commands/install/index.ts
Normal file
33
cli/src/commands/install/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {
|
||||
SpaceflowExtension,
|
||||
SpaceflowExtensionMetadata,
|
||||
ExtensionModuleType,
|
||||
} from "@spaceflow/core";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { InstallModule } from "./install.module";
|
||||
|
||||
export const installMetadata: SpaceflowExtensionMetadata = {
|
||||
name: "install",
|
||||
commands: ["install", "i"],
|
||||
version: "1.0.0",
|
||||
description: t("install:extensionDescription"),
|
||||
};
|
||||
|
||||
export class InstallExtension implements SpaceflowExtension {
|
||||
getMetadata(): SpaceflowExtensionMetadata {
|
||||
return installMetadata;
|
||||
}
|
||||
|
||||
getModule(): ExtensionModuleType {
|
||||
return InstallModule;
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./install.module";
|
||||
export { InstallCommand, type InstallCommandOptions } from "./install.command";
|
||||
export {
|
||||
InstallService,
|
||||
type InstallOptions,
|
||||
type InstallContext,
|
||||
type SourceType,
|
||||
} from "./install.service";
|
||||
119
cli/src/commands/install/install.command.ts
Normal file
119
cli/src/commands/install/install.command.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Command, CommandRunner, Option } from "nest-commander";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { InstallService } from "./install.service";
|
||||
import type { VerboseLevel } from "@spaceflow/core";
|
||||
|
||||
export interface InstallCommandOptions {
|
||||
name?: string;
|
||||
global?: boolean;
|
||||
verbose?: VerboseLevel;
|
||||
ignoreErrors?: boolean; // 忽略错误,不退出进程
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装技能包命令
|
||||
*
|
||||
* 用法:
|
||||
* spaceflow install <source> [--name <名称>]
|
||||
* spaceflow install # 更新所有已安装的 skills
|
||||
*
|
||||
* 支持的 source 类型:
|
||||
* - 本地路径: ./skills/publish, skills/my-plugin
|
||||
* - npm 包: @spaceflow/plugin-review, spaceflow-plugin-deploy
|
||||
* - git 仓库: git@git.example.com:org/plugin.git
|
||||
*
|
||||
* 功能:
|
||||
* 1. 本地路径:注册到 spaceflow.json
|
||||
* 2. npm 包:执行 pnpm add <package>
|
||||
* 3. git 仓库:克隆到 .spaceflow/skills/<name> 并关联到支持的编辑器目录
|
||||
* 4. 更新 spaceflow.json 的 skills 字段
|
||||
*/
|
||||
@Command({
|
||||
name: "install",
|
||||
arguments: "[source]",
|
||||
description: t("install:description"),
|
||||
})
|
||||
export class InstallCommand extends CommandRunner {
|
||||
constructor(protected readonly installService: InstallService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(passedParams: string[], options: InstallCommandOptions): Promise<void> {
|
||||
const source = passedParams[0];
|
||||
const isGlobal = options.global ?? false;
|
||||
const verbose = options.verbose ?? true;
|
||||
|
||||
try {
|
||||
if (isGlobal) {
|
||||
// 全局安装:必须指定 source
|
||||
if (!source) {
|
||||
console.error(t("install:globalNoSource"));
|
||||
console.error(t("install:globalUsage"));
|
||||
process.exit(1);
|
||||
}
|
||||
await this.installService.installGlobal(
|
||||
{
|
||||
source,
|
||||
name: options.name,
|
||||
},
|
||||
verbose,
|
||||
);
|
||||
} else if (!source) {
|
||||
// 本地安装无参数:更新配置文件中的所有依赖
|
||||
await this.installService.updateAllSkills({ verbose });
|
||||
} else {
|
||||
// 本地安装有参数:安装指定的依赖
|
||||
const context = this.installService.getContext({
|
||||
source,
|
||||
name: options.name,
|
||||
});
|
||||
await this.installService.execute(context, verbose);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(t("install:installFailed", { error: error.message }));
|
||||
if (error.stack) {
|
||||
console.error(t("common.stackTrace", { stack: error.stack }));
|
||||
}
|
||||
} else {
|
||||
console.error(t("install:installFailed", { error }));
|
||||
}
|
||||
if (!options.ignoreErrors) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-n, --name <name>",
|
||||
description: t("install:options.name"),
|
||||
})
|
||||
parseName(val: string): string {
|
||||
return val;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-g, --global",
|
||||
description: t("install:options.global"),
|
||||
})
|
||||
parseGlobal(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-v, --verbose",
|
||||
description: t("common.options.verbose"),
|
||||
})
|
||||
parseVerbose(_val: string, previous: VerboseLevel = 0): VerboseLevel {
|
||||
const current = typeof previous === "number" ? previous : previous ? 1 : 0;
|
||||
return Math.min(current + 1, 2) as VerboseLevel;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "--ignore-errors",
|
||||
description: t("install:options.ignoreErrors"),
|
||||
})
|
||||
parseIgnoreErrors(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
9
cli/src/commands/install/install.module.ts
Normal file
9
cli/src/commands/install/install.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { InstallCommand } from "./install.command";
|
||||
import { InstallService } from "./install.service";
|
||||
|
||||
@Module({
|
||||
providers: [InstallCommand, InstallService],
|
||||
exports: [InstallService],
|
||||
})
|
||||
export class InstallModule {}
|
||||
1478
cli/src/commands/install/install.service.ts
Normal file
1478
cli/src/commands/install/install.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
28
cli/src/commands/list/index.ts
Normal file
28
cli/src/commands/list/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
SpaceflowExtension,
|
||||
SpaceflowExtensionMetadata,
|
||||
ExtensionModuleType,
|
||||
} from "@spaceflow/core";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { ListModule } from "./list.module";
|
||||
|
||||
export const listMetadata: SpaceflowExtensionMetadata = {
|
||||
name: "list",
|
||||
commands: ["list", "ls"],
|
||||
version: "1.0.0",
|
||||
description: t("list:extensionDescription"),
|
||||
};
|
||||
|
||||
export class ListExtension implements SpaceflowExtension {
|
||||
getMetadata(): SpaceflowExtensionMetadata {
|
||||
return listMetadata;
|
||||
}
|
||||
|
||||
getModule(): ExtensionModuleType {
|
||||
return ListModule;
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./list.module";
|
||||
export * from "./list.command";
|
||||
export * from "./list.service";
|
||||
40
cli/src/commands/list/list.command.ts
Normal file
40
cli/src/commands/list/list.command.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Command, CommandRunner, Option } from "nest-commander";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { ListService } from "./list.service";
|
||||
import type { VerboseLevel } from "@spaceflow/core";
|
||||
|
||||
/**
|
||||
* 列出已安装的技能包
|
||||
*
|
||||
* 用法:
|
||||
* spaceflow list
|
||||
*
|
||||
* 输出已安装的所有命令包及其信息
|
||||
*/
|
||||
export interface ListOptions {
|
||||
verbose?: VerboseLevel;
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: "list",
|
||||
description: t("list:description"),
|
||||
})
|
||||
export class ListCommand extends CommandRunner {
|
||||
constructor(private readonly listService: ListService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(passedParams: string[], options: ListOptions): Promise<void> {
|
||||
const verbose = options.verbose ?? true;
|
||||
await this.listService.execute(verbose);
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-v, --verbose",
|
||||
description: t("common.options.verbose"),
|
||||
})
|
||||
parseVerbose(_val: string, previous: VerboseLevel = 0): VerboseLevel {
|
||||
const current = typeof previous === "number" ? previous : previous ? 1 : 0;
|
||||
return Math.min(current + 1, 2) as VerboseLevel;
|
||||
}
|
||||
}
|
||||
9
cli/src/commands/list/list.module.ts
Normal file
9
cli/src/commands/list/list.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ListCommand } from "./list.command";
|
||||
import { ListService } from "./list.service";
|
||||
import { ExtensionLoaderService } from "../../extension-loader";
|
||||
|
||||
@Module({
|
||||
providers: [ListCommand, ListService, ExtensionLoaderService],
|
||||
})
|
||||
export class ListModule {}
|
||||
165
cli/src/commands/list/list.service.ts
Normal file
165
cli/src/commands/list/list.service.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { ExtensionLoaderService } from "../../extension-loader";
|
||||
import {
|
||||
shouldLog,
|
||||
type VerboseLevel,
|
||||
getEditorDirName,
|
||||
DEFAULT_EDITOR,
|
||||
getSourceType,
|
||||
normalizeSource,
|
||||
t,
|
||||
} from "@spaceflow/core";
|
||||
|
||||
interface SkillInfo {
|
||||
name: string;
|
||||
source: string;
|
||||
type: "npm" | "git" | "local";
|
||||
commands: string[];
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ListService {
|
||||
constructor(private readonly extensionLoader: ExtensionLoaderService) {}
|
||||
|
||||
/**
|
||||
* 获取支持的编辑器列表
|
||||
*/
|
||||
protected async getSupportedEditors(): Promise<string[]> {
|
||||
const configPath = join(process.cwd(), "spaceflow.json");
|
||||
try {
|
||||
if (!existsSync(configPath)) return [DEFAULT_EDITOR];
|
||||
const content = await readFile(configPath, "utf-8");
|
||||
const config = JSON.parse(content);
|
||||
return config.support || [DEFAULT_EDITOR];
|
||||
} catch {
|
||||
return [DEFAULT_EDITOR];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行列表展示
|
||||
*/
|
||||
async execute(verbose: VerboseLevel = 1): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
// 优先检查 .spaceflow/spaceflow.json,回退到 spaceflow.json
|
||||
let configPath = join(cwd, ".spaceflow", "spaceflow.json");
|
||||
if (!existsSync(configPath)) {
|
||||
configPath = join(cwd, "spaceflow.json");
|
||||
}
|
||||
|
||||
// 读取配置文件中的 skills
|
||||
const skills = await this.parseSkillsFromConfig(configPath);
|
||||
|
||||
if (Object.keys(skills).length === 0) {
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.log(t("list:noSkills"));
|
||||
console.log("");
|
||||
console.log(t("list:installHint"));
|
||||
console.log(" spaceflow install <npm-package>");
|
||||
console.log(" spaceflow install <git-url> --name <name>");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取已加载的 Extension 信息
|
||||
const loadedExtensions = this.extensionLoader.getLoadedExtensions();
|
||||
const loadedMap = new Map(loadedExtensions.map((e) => [e.name, e]));
|
||||
const editors = await this.getSupportedEditors();
|
||||
// 收集所有 skill 信息
|
||||
const skillInfos: SkillInfo[] = [];
|
||||
for (const [name, source] of Object.entries(skills)) {
|
||||
const type = getSourceType(source);
|
||||
const installed = await this.checkInstalled(name, source, type, editors);
|
||||
const loadedExt = loadedMap.get(name);
|
||||
skillInfos.push({ name, source, type, installed, commands: loadedExt?.commands ?? [] });
|
||||
}
|
||||
if (!shouldLog(verbose, 1)) return;
|
||||
// 计算最大名称宽度用于对齐
|
||||
const maxNameLen = Math.max(...skillInfos.map((s) => s.name.length), 10);
|
||||
const installedCount = skillInfos.filter((s) => s.installed).length;
|
||||
console.log(
|
||||
t("list:installedExtensions", { installed: installedCount, total: skillInfos.length }) + "\n",
|
||||
);
|
||||
for (const skill of skillInfos) {
|
||||
const icon = skill.installed ? "\x1b[32m✔\x1b[0m" : "\x1b[33m○\x1b[0m";
|
||||
const typeLabel =
|
||||
skill.type === "local"
|
||||
? "\x1b[36mlocal\x1b[0m"
|
||||
: skill.type === "npm"
|
||||
? "\x1b[35mnpm\x1b[0m"
|
||||
: "\x1b[33mgit\x1b[0m";
|
||||
const displaySource = this.getDisplaySource(skill.source, skill.type);
|
||||
console.log(` ${icon} ${skill.name.padEnd(maxNameLen + 2)} ${typeLabel} ${displaySource}`);
|
||||
// 显示命令列表
|
||||
if (skill.commands.length > 0) {
|
||||
console.log(
|
||||
` ${"".padEnd(maxNameLen)} ${t("list:commands", { commands: skill.commands.join(", ") })}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用于展示的 source 字符串
|
||||
*/
|
||||
private getDisplaySource(source: string, type: "npm" | "git" | "local"): string {
|
||||
if (type === "local") {
|
||||
return normalizeSource(source);
|
||||
}
|
||||
if (type === "git") {
|
||||
// 简化 git URL 展示
|
||||
const match = source.match(/[/:](\w+\/[\w.-]+?)(?:\.git)?$/);
|
||||
return match ? match[1] : source;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已安装
|
||||
*/
|
||||
private async checkInstalled(
|
||||
name: string,
|
||||
source: string,
|
||||
type: "npm" | "git" | "local",
|
||||
editors: string[],
|
||||
): Promise<boolean> {
|
||||
const cwd = process.cwd();
|
||||
if (type === "local") {
|
||||
const localPath = join(cwd, normalizeSource(source));
|
||||
return existsSync(localPath);
|
||||
} else if (type === "npm") {
|
||||
try {
|
||||
require.resolve(source);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const possiblePaths = [join(cwd, "skills", name)];
|
||||
for (const editor of editors) {
|
||||
const editorDirName = getEditorDirName(editor);
|
||||
possiblePaths.push(join(cwd, editorDirName, "skills", name));
|
||||
possiblePaths.push(join(cwd, editorDirName, "commands", name));
|
||||
}
|
||||
return possiblePaths.some((p) => existsSync(p));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从配置文件解析 dependencies
|
||||
*/
|
||||
private async parseSkillsFromConfig(configPath: string): Promise<Record<string, string>> {
|
||||
try {
|
||||
const content = await readFile(configPath, "utf-8");
|
||||
const config = JSON.parse(content);
|
||||
return config.dependencies || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
28
cli/src/commands/mcp/index.ts
Normal file
28
cli/src/commands/mcp/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
SpaceflowExtension,
|
||||
SpaceflowExtensionMetadata,
|
||||
ExtensionModuleType,
|
||||
} from "@spaceflow/core";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { McpModule } from "./mcp.module";
|
||||
|
||||
export const mcpMetadata: SpaceflowExtensionMetadata = {
|
||||
name: "mcp",
|
||||
commands: ["mcp"],
|
||||
version: "1.0.0",
|
||||
description: t("mcp:extensionDescription"),
|
||||
};
|
||||
|
||||
export class McpExtension implements SpaceflowExtension {
|
||||
getMetadata(): SpaceflowExtensionMetadata {
|
||||
return mcpMetadata;
|
||||
}
|
||||
|
||||
getModule(): ExtensionModuleType {
|
||||
return McpModule;
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./mcp.command";
|
||||
export * from "./mcp.service";
|
||||
export * from "./mcp.module";
|
||||
81
cli/src/commands/mcp/mcp.command.ts
Normal file
81
cli/src/commands/mcp/mcp.command.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Command, CommandRunner, Option, getPackageManager, t } from "@spaceflow/core";
|
||||
import { McpService } from "./mcp.service";
|
||||
import type { VerboseLevel } from "@spaceflow/core";
|
||||
|
||||
export interface McpOptions {
|
||||
verbose?: VerboseLevel;
|
||||
inspector?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP 命令
|
||||
* 启动 MCP Server,聚合所有已安装 flow 包的 MCP 工具
|
||||
*
|
||||
* 使用方式: spaceflow mcp
|
||||
*/
|
||||
@Command({
|
||||
name: "mcp",
|
||||
description: t("mcp:description"),
|
||||
})
|
||||
export class McpCommand extends CommandRunner {
|
||||
constructor(private readonly mcpService: McpService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(_passedParams: string[], options: McpOptions): Promise<void> {
|
||||
if (options.inspector) {
|
||||
await this.runInspector();
|
||||
} else {
|
||||
await this.mcpService.startServer(options.verbose);
|
||||
}
|
||||
}
|
||||
|
||||
private async runInspector(): Promise<void> {
|
||||
const { spawn } = await import("child_process");
|
||||
|
||||
console.error(t("mcp:inspectorStarting"));
|
||||
console.error(t("mcp:inspectorDebugCmd"));
|
||||
|
||||
const pm = getPackageManager();
|
||||
const dlxCmd = pm === "pnpm" ? "pnpm" : "npx";
|
||||
const dlxArgs = pm === "pnpm" ? ["dlx"] : ["-y"];
|
||||
const inspector = spawn(
|
||||
dlxCmd,
|
||||
[...dlxArgs, "@modelcontextprotocol/inspector", pm, "space", "mcp"],
|
||||
{
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env: { ...process.env, MODELCONTEXT_PROTOCOL_INSPECTOR: "true" },
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
);
|
||||
|
||||
inspector.on("error", (err) => {
|
||||
console.error(t("mcp:inspectorFailed", { error: err.message }));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
await new Promise<void>((_resolve) => {
|
||||
inspector.on("close", (code) => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-v, --verbose",
|
||||
description: t("common.options.verboseDebug"),
|
||||
})
|
||||
parseVerbose(_val: string, previous: VerboseLevel = 0): VerboseLevel {
|
||||
const current = typeof previous === "number" ? previous : previous ? 1 : 0;
|
||||
return Math.min(current + 1, 3) as VerboseLevel;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-i, --inspector",
|
||||
description: t("mcp:options.inspector"),
|
||||
})
|
||||
parseInspector(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
9
cli/src/commands/mcp/mcp.module.ts
Normal file
9
cli/src/commands/mcp/mcp.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@spaceflow/core";
|
||||
import { McpCommand } from "./mcp.command";
|
||||
import { McpService } from "./mcp.service";
|
||||
import { ExtensionLoaderService } from "../../extension-loader";
|
||||
|
||||
@Module({
|
||||
providers: [ExtensionLoaderService, McpService, McpCommand],
|
||||
})
|
||||
export class McpModule {}
|
||||
217
cli/src/commands/mcp/mcp.service.ts
Normal file
217
cli/src/commands/mcp/mcp.service.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { Injectable, t } from "@spaceflow/core";
|
||||
import type { VerboseLevel } from "@spaceflow/core";
|
||||
import { shouldLog, type McpToolMetadata } from "@spaceflow/core";
|
||||
import { ModuleRef } from "@nestjs/core";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { ExtensionLoaderService } from "../../extension-loader/extension-loader.service";
|
||||
|
||||
@Injectable()
|
||||
export class McpService {
|
||||
constructor(
|
||||
private readonly extensionLoader: ExtensionLoaderService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 启动 MCP Server
|
||||
* 扫描所有已安装的扩展,收集 MCP 工具并启动服务
|
||||
*/
|
||||
async startServer(verbose?: VerboseLevel): Promise<void> {
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.error(t("mcp:scanning"));
|
||||
}
|
||||
|
||||
// 加载所有扩展
|
||||
const extensions = await this.extensionLoader.discoverAndLoad();
|
||||
const allTools: Array<{ tool: McpToolMetadata; provider: any }> = [];
|
||||
|
||||
if (shouldLog(verbose, 2)) {
|
||||
console.error(t("mcp:foundExtensions", { count: extensions.length }));
|
||||
for (const ext of extensions) {
|
||||
const exportKeys = ext.exports ? Object.keys(ext.exports) : [];
|
||||
console.error(` - ${ext.name}: exports=[${exportKeys.join(", ")}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// 收集所有扩展的 MCP 工具
|
||||
for (const ext of extensions) {
|
||||
try {
|
||||
// 使用包的完整导出(而不是 NestJS 模块)
|
||||
const packageExports = ext.exports || {};
|
||||
|
||||
// 扫描模块导出,查找带有 @McpServer 装饰器的类
|
||||
for (const key of Object.keys(packageExports)) {
|
||||
const exported = packageExports[key];
|
||||
|
||||
// 直接检查静态属性(跨模块可访问)
|
||||
const hasMcpServer = !!(exported as any)?.__mcp_server__;
|
||||
|
||||
if (shouldLog(verbose, 2) && typeof exported === "function") {
|
||||
console.error(t("mcp:checkingExport", { key, hasMcpServer }));
|
||||
}
|
||||
|
||||
// 检查是否是带有 @McpServer 装饰器的类
|
||||
if (typeof exported === "function" && hasMcpServer) {
|
||||
try {
|
||||
// 优先从 NestJS 容器获取实例(支持依赖注入)
|
||||
let instance: any;
|
||||
try {
|
||||
instance = this.moduleRef.get(exported, { strict: false });
|
||||
if (shouldLog(verbose, 2)) {
|
||||
console.error(t("mcp:containerSuccess", { key }));
|
||||
}
|
||||
} catch (diError) {
|
||||
// 容器中没有,尝试直接实例化(可能缺少依赖)
|
||||
if (shouldLog(verbose, 2)) {
|
||||
console.error(
|
||||
t("mcp:containerFailed", {
|
||||
key,
|
||||
error: diError instanceof Error ? diError.message : diError,
|
||||
}),
|
||||
);
|
||||
}
|
||||
instance = new (exported as any)();
|
||||
}
|
||||
|
||||
// 直接读取静态属性获取工具和元数据
|
||||
const tools: McpToolMetadata[] = (exported as any).__mcp_tools__ || [];
|
||||
const serverMeta = (exported as any).__mcp_server__;
|
||||
|
||||
for (const tool of tools) {
|
||||
allTools.push({ tool, provider: instance });
|
||||
}
|
||||
|
||||
if (shouldLog(verbose, 1) && tools.length > 0) {
|
||||
const serverName = serverMeta?.name || ext.name;
|
||||
console.error(` 📦 ${serverName}: ${tools.map((t) => t.name).join(", ")}`);
|
||||
}
|
||||
} catch {
|
||||
// 实例化失败
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (shouldLog(verbose, 2)) {
|
||||
console.error(t("mcp:loadToolsFailed", { name: ext.name }), error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allTools.length === 0) {
|
||||
console.error(t("mcp:noToolsFound"));
|
||||
console.error(t("mcp:noToolsHint"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.error(t("mcp:toolsFound", { count: allTools.length }));
|
||||
}
|
||||
|
||||
// 启动 MCP Server
|
||||
await this.runServer(allTools, verbose);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 MCP Server
|
||||
*/
|
||||
private async runServer(
|
||||
allTools: Array<{ tool: McpToolMetadata; provider: any }>,
|
||||
verbose?: VerboseLevel,
|
||||
): Promise<void> {
|
||||
const server = new McpServer({ name: "spaceflow", version: "1.0.0" });
|
||||
|
||||
// 注册所有工具(使用 v2 API: server.registerTool)
|
||||
for (const { tool, provider } of allTools) {
|
||||
// 将 JSON Schema 转换为 Zod schema
|
||||
const schema = this.jsonSchemaToZod(tool.inputSchema);
|
||||
server.registerTool(
|
||||
tool.name,
|
||||
{
|
||||
description: tool.description,
|
||||
inputSchema: Object.keys(schema).length > 0 ? z.object(schema) : z.object({}),
|
||||
},
|
||||
async (args: any) => {
|
||||
try {
|
||||
const result = await provider[tool.methodName](args || {});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: typeof result === "string" ? result : JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 启动 stdio 传输
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.error(t("mcp:serverStarted", { count: allTools.length }));
|
||||
}
|
||||
|
||||
if (process.env.MODELCONTEXT_PROTOCOL_INSPECTOR) {
|
||||
await new Promise<void>((resolve) => {
|
||||
process.stdin.on("close", resolve);
|
||||
process.on("SIGINT", resolve);
|
||||
process.on("SIGTERM", resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON Schema 转换为 Zod schema
|
||||
*/
|
||||
private jsonSchemaToZod(jsonSchema?: Record<string, any>): Record<string, any> {
|
||||
if (!jsonSchema || !jsonSchema.properties) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const zodShape: Record<string, any> = {};
|
||||
for (const [key, prop] of Object.entries(jsonSchema.properties as Record<string, any>)) {
|
||||
const isRequired = jsonSchema.required?.includes(key);
|
||||
let zodType: any;
|
||||
|
||||
switch (prop.type) {
|
||||
case "string":
|
||||
zodType = z.string();
|
||||
break;
|
||||
case "number":
|
||||
zodType = z.number();
|
||||
break;
|
||||
case "boolean":
|
||||
zodType = z.boolean();
|
||||
break;
|
||||
case "array":
|
||||
zodType = z.array(z.any());
|
||||
break;
|
||||
default:
|
||||
zodType = z.any();
|
||||
}
|
||||
|
||||
if (prop.description) {
|
||||
zodType = zodType.describe(prop.description);
|
||||
}
|
||||
|
||||
zodShape[key] = isRequired ? zodType : zodType.optional();
|
||||
}
|
||||
|
||||
return zodShape;
|
||||
}
|
||||
}
|
||||
29
cli/src/commands/runx/index.ts
Normal file
29
cli/src/commands/runx/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
SpaceflowExtension,
|
||||
SpaceflowExtensionMetadata,
|
||||
ExtensionModuleType,
|
||||
} from "@spaceflow/core";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { RunxModule } from "./runx.module";
|
||||
|
||||
export const runxMetadata: SpaceflowExtensionMetadata = {
|
||||
name: "runx",
|
||||
commands: ["runx", "x"],
|
||||
version: "1.0.0",
|
||||
description: t("runx:extensionDescription"),
|
||||
};
|
||||
|
||||
export class RunxExtension implements SpaceflowExtension {
|
||||
getMetadata(): SpaceflowExtensionMetadata {
|
||||
return runxMetadata;
|
||||
}
|
||||
|
||||
getModule(): ExtensionModuleType {
|
||||
return RunxModule;
|
||||
}
|
||||
}
|
||||
|
||||
export { RunxModule } from "./runx.module";
|
||||
export { RunxCommand } from "./runx.command";
|
||||
export { RunxService } from "./runx.service";
|
||||
export * from "./runx.utils";
|
||||
79
cli/src/commands/runx/runx.command.ts
Normal file
79
cli/src/commands/runx/runx.command.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Command, CommandRunner, Option } from "nest-commander";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { RunxService } from "./runx.service";
|
||||
import { parseRunxArgs } from "./runx.utils";
|
||||
import type { VerboseLevel } from "@spaceflow/core";
|
||||
|
||||
export interface RunxCommandOptions {
|
||||
name?: string;
|
||||
verbose?: VerboseLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* runx 命令:全局安装 + 运行命令
|
||||
*
|
||||
* 用法:
|
||||
* spaceflow x <source> [args...]
|
||||
* spaceflow x ./commands/ci-scripts --help
|
||||
*
|
||||
* 功能:
|
||||
* 1. 全局安装指定的依赖(如果未安装)
|
||||
* 2. 运行该依赖提供的命令
|
||||
*/
|
||||
@Command({
|
||||
name: "runx",
|
||||
aliases: ["x"],
|
||||
arguments: "<source>",
|
||||
description: t("runx:description"),
|
||||
})
|
||||
export class RunxCommand extends CommandRunner {
|
||||
constructor(private readonly runxService: RunxService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(passedParams: string[], options: RunxCommandOptions): Promise<void> {
|
||||
// 使用工具函数解析参数
|
||||
const { source, args } = parseRunxArgs(process.argv);
|
||||
const verbose = options.verbose ?? true;
|
||||
if (!source) {
|
||||
console.error(t("runx:noSource"));
|
||||
console.error(t("runx:usage"));
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
await this.runxService.execute({
|
||||
source,
|
||||
name: options.name,
|
||||
args,
|
||||
verbose,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(t("runx:runFailed", { error: error.message }));
|
||||
if (error.stack) {
|
||||
console.error(t("common.stackTrace", { stack: error.stack }));
|
||||
}
|
||||
} else {
|
||||
console.error(t("runx:runFailed", { error }));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-n, --name <name>",
|
||||
description: t("runx:options.name"),
|
||||
})
|
||||
parseName(val: string): string {
|
||||
return val;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-v, --verbose",
|
||||
description: t("common.options.verbose"),
|
||||
})
|
||||
parseVerbose(_val: string, previous: VerboseLevel = 0): VerboseLevel {
|
||||
const current = typeof previous === "number" ? previous : previous ? 1 : 0;
|
||||
return Math.min(current + 1, 2) as VerboseLevel;
|
||||
}
|
||||
}
|
||||
10
cli/src/commands/runx/runx.module.ts
Normal file
10
cli/src/commands/runx/runx.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { RunxCommand } from "./runx.command";
|
||||
import { RunxService } from "./runx.service";
|
||||
import { InstallModule } from "../install/install.module";
|
||||
|
||||
@Module({
|
||||
imports: [InstallModule],
|
||||
providers: [RunxCommand, RunxService],
|
||||
})
|
||||
export class RunxModule {}
|
||||
167
cli/src/commands/runx/runx.service.ts
Normal file
167
cli/src/commands/runx/runx.service.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Injectable, Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { join } from "path";
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { spawn } from "child_process";
|
||||
import { InstallService } from "../install/install.service";
|
||||
import { CommandFactory } from "nest-commander";
|
||||
import type { SpaceflowExtension, VerboseLevel } from "@spaceflow/core";
|
||||
import {
|
||||
configLoaders,
|
||||
getEnvFilePaths,
|
||||
StorageModule,
|
||||
OutputModule,
|
||||
extractName,
|
||||
getSourceType,
|
||||
t,
|
||||
} from "@spaceflow/core";
|
||||
|
||||
export interface RunxOptions {
|
||||
source: string;
|
||||
name?: string;
|
||||
args: string[];
|
||||
verbose?: VerboseLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runx 服务
|
||||
* 全局安装依赖后运行命令
|
||||
*/
|
||||
@Injectable()
|
||||
export class RunxService {
|
||||
constructor(private readonly installService: InstallService) {}
|
||||
|
||||
/**
|
||||
* 执行 runx:全局安装 + 运行命令
|
||||
*/
|
||||
async execute(options: RunxOptions): Promise<void> {
|
||||
const { source, args } = options;
|
||||
const verbose = options.verbose ?? true;
|
||||
const name = options.name || extractName(source);
|
||||
const sourceType = getSourceType(source);
|
||||
|
||||
// npm 包直接使用 npx 执行
|
||||
if (sourceType === "npm") {
|
||||
if (verbose)
|
||||
console.log(t("runx:runningCommand", { command: `npx ${source} ${args.join(" ")}` }));
|
||||
await this.runWithNpx(source, args);
|
||||
return;
|
||||
}
|
||||
|
||||
// 第一步:全局安装(静默模式)
|
||||
await this.installService.installGlobal(
|
||||
{
|
||||
source,
|
||||
name: options.name,
|
||||
},
|
||||
false,
|
||||
);
|
||||
// 第二步:运行命令
|
||||
if (verbose) console.log(t("runx:runningCommand", { command: `${name} ${args.join(" ")}` }));
|
||||
await this.runCommand(name, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 npx 运行 npm 包
|
||||
*/
|
||||
private runWithNpx(packageName: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("npx", [packageName, ...args], {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(t("runx:npxExitCode", { package: packageName, code })));
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行已安装的命令
|
||||
* 创建包含共享模块的新实例并运行
|
||||
*/
|
||||
protected async runCommand(name: string, args: string[]): Promise<void> {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || "~";
|
||||
const depPath = join(home, ".spaceflow", "node_modules", name);
|
||||
// 检查命令是否存在
|
||||
if (!existsSync(depPath)) {
|
||||
throw new Error(t("runx:commandNotInstalled", { name }));
|
||||
}
|
||||
// 解析符号链接获取真实路径
|
||||
const realDepPath = realpathSync(depPath);
|
||||
// 检查是否有 dist/index.js
|
||||
const distPath = join(realDepPath, "dist", "index.js");
|
||||
if (!existsSync(distPath)) {
|
||||
throw new Error(t("runx:commandNotBuilt", { name }));
|
||||
}
|
||||
// 动态加载插件(使用 Function 构造器绕过 rspack 转换)
|
||||
const importUrl = `file://${distPath}`;
|
||||
const dynamicImport = new Function("url", "return import(url)");
|
||||
const pluginModule = await dynamicImport(importUrl);
|
||||
const PluginClass = pluginModule.default;
|
||||
if (!PluginClass) {
|
||||
throw new Error(t("runx:pluginNoExport", { name }));
|
||||
}
|
||||
const extension: SpaceflowExtension = new PluginClass();
|
||||
const metadata = extension.getMetadata();
|
||||
const commandModule = extension.getModule();
|
||||
// 如果插件只有一个命令,且用户没有指定子命令,自动补充
|
||||
const finalArgs = this.autoCompleteCommand(args, metadata.commands);
|
||||
// 创建动态模块并运行(包含配置和共享模块)
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: configLoaders,
|
||||
envFilePath: getEnvFilePaths(),
|
||||
}),
|
||||
StorageModule.forFeature(),
|
||||
OutputModule,
|
||||
commandModule,
|
||||
],
|
||||
})
|
||||
class DynamicRunxModule {}
|
||||
// 修改 process.argv 以传递参数
|
||||
const originalArgv = process.argv;
|
||||
process.argv = ["node", "runx", ...finalArgs];
|
||||
try {
|
||||
await CommandFactory.run(DynamicRunxModule, {
|
||||
logger: false,
|
||||
});
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动补充命令名
|
||||
* 如果插件只有一个命令,且用户没有指定子命令,自动在参数前补充命令名
|
||||
*/
|
||||
private autoCompleteCommand(args: string[], commands?: string[]): string[] {
|
||||
// 没有命令列表或为空,直接返回
|
||||
if (!commands || commands.length === 0) {
|
||||
return args;
|
||||
}
|
||||
// 如果只有一个命令
|
||||
if (commands.length === 1) {
|
||||
const cmdName = commands[0];
|
||||
// 检查用户是否已经指定了该命令
|
||||
if (args.length === 0 || args[0] !== cmdName) {
|
||||
// 如果第一个参数是选项(以 - 开头),说明用户没有指定子命令
|
||||
if (args.length === 0 || args[0].startsWith("-")) {
|
||||
return [cmdName, ...args];
|
||||
}
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
}
|
||||
83
cli/src/commands/runx/runx.utils.ts
Normal file
83
cli/src/commands/runx/runx.utils.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Runx 命令参数解析工具
|
||||
*/
|
||||
|
||||
export interface RunxParsedArgs {
|
||||
cmdIndex: number;
|
||||
sourceIndex: number;
|
||||
source: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找 runx/x 命令在 argv 中的位置
|
||||
*/
|
||||
export function findRunxCmdIndex(argv: string[]): number {
|
||||
return argv.findIndex((arg) => arg === "runx" || arg === "x");
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断参数是否为 -n/--name 选项
|
||||
*/
|
||||
export function isNameOption(arg: string): boolean {
|
||||
return arg === "-n" || arg === "--name" || arg.startsWith("-n=") || arg.startsWith("--name=");
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断参数是否为 -n/--name 选项(需要跳过下一个参数)
|
||||
*/
|
||||
export function isNameOptionWithValue(arg: string): boolean {
|
||||
return arg === "-n" || arg === "--name";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从参数列表中找到 source(跳过 -n/--name 选项)
|
||||
*/
|
||||
export function findSourceInArgs(args: string[]): { index: number; source: string } {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (isNameOptionWithValue(arg)) {
|
||||
i++; // 跳过选项值
|
||||
continue;
|
||||
}
|
||||
if (isNameOption(arg)) {
|
||||
continue;
|
||||
}
|
||||
if (!arg.startsWith("-")) {
|
||||
return { index: i, source: arg };
|
||||
}
|
||||
}
|
||||
return { index: -1, source: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 runx 命令的完整参数
|
||||
*/
|
||||
export function parseRunxArgs(argv: string[]): RunxParsedArgs {
|
||||
const cmdIndex = findRunxCmdIndex(argv);
|
||||
if (cmdIndex === -1) {
|
||||
return { cmdIndex: -1, sourceIndex: -1, source: "", args: [] };
|
||||
}
|
||||
const separatorIndex = argv.indexOf("--");
|
||||
if (separatorIndex === -1) {
|
||||
// 没有分隔符
|
||||
const remaining = argv.slice(cmdIndex + 1);
|
||||
const { index, source } = findSourceInArgs(remaining);
|
||||
return {
|
||||
cmdIndex,
|
||||
sourceIndex: index === -1 ? -1 : cmdIndex + 1 + index,
|
||||
source,
|
||||
args: [],
|
||||
};
|
||||
}
|
||||
// 有分隔符
|
||||
const beforeSeparator = argv.slice(cmdIndex + 1, separatorIndex);
|
||||
const afterSeparator = argv.slice(separatorIndex + 1);
|
||||
const { index, source } = findSourceInArgs(beforeSeparator);
|
||||
return {
|
||||
cmdIndex,
|
||||
sourceIndex: index === -1 ? -1 : cmdIndex + 1 + index,
|
||||
source,
|
||||
args: afterSeparator,
|
||||
};
|
||||
}
|
||||
27
cli/src/commands/schema/index.ts
Normal file
27
cli/src/commands/schema/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
SpaceflowExtension,
|
||||
SpaceflowExtensionMetadata,
|
||||
ExtensionModuleType,
|
||||
} from "@spaceflow/core";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { SchemaModule } from "./schema.module";
|
||||
|
||||
export const schemaMetadata: SpaceflowExtensionMetadata = {
|
||||
name: "schema",
|
||||
commands: ["schema"],
|
||||
version: "1.0.0",
|
||||
description: t("schema:extensionDescription"),
|
||||
};
|
||||
|
||||
export class SchemaExtension implements SpaceflowExtension {
|
||||
getMetadata(): SpaceflowExtensionMetadata {
|
||||
return schemaMetadata;
|
||||
}
|
||||
|
||||
getModule(): ExtensionModuleType {
|
||||
return SchemaModule;
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./schema.command";
|
||||
export * from "./schema.module";
|
||||
17
cli/src/commands/schema/schema.command.ts
Normal file
17
cli/src/commands/schema/schema.command.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Command, CommandRunner } from "nest-commander";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { SchemaGeneratorService } from "@spaceflow/core";
|
||||
|
||||
@Command({
|
||||
name: "schema",
|
||||
description: t("schema:description"),
|
||||
})
|
||||
export class SchemaCommand extends CommandRunner {
|
||||
constructor(private readonly schemaGenerator: SchemaGeneratorService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
this.schemaGenerator.generate();
|
||||
}
|
||||
}
|
||||
7
cli/src/commands/schema/schema.module.ts
Normal file
7
cli/src/commands/schema/schema.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SchemaCommand } from "./schema.command";
|
||||
|
||||
@Module({
|
||||
providers: [SchemaCommand],
|
||||
})
|
||||
export class SchemaModule {}
|
||||
28
cli/src/commands/setup/index.ts
Normal file
28
cli/src/commands/setup/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
SpaceflowExtension,
|
||||
SpaceflowExtensionMetadata,
|
||||
ExtensionModuleType,
|
||||
} from "@spaceflow/core";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { SetupModule } from "./setup.module";
|
||||
|
||||
export const setupMetadata: SpaceflowExtensionMetadata = {
|
||||
name: "setup",
|
||||
commands: ["setup"],
|
||||
version: "1.0.0",
|
||||
description: t("setup:extensionDescription"),
|
||||
};
|
||||
|
||||
export class SetupExtension implements SpaceflowExtension {
|
||||
getMetadata(): SpaceflowExtensionMetadata {
|
||||
return setupMetadata;
|
||||
}
|
||||
|
||||
getModule(): ExtensionModuleType {
|
||||
return SetupModule;
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./setup.command";
|
||||
export * from "./setup.service";
|
||||
export * from "./setup.module";
|
||||
47
cli/src/commands/setup/setup.command.ts
Normal file
47
cli/src/commands/setup/setup.command.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Command, CommandRunner, Option } from "nest-commander";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { SetupService } from "./setup.service";
|
||||
|
||||
export interface SetupCommandOptions {
|
||||
global?: boolean;
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: "setup",
|
||||
description: t("setup:description"),
|
||||
})
|
||||
export class SetupCommand extends CommandRunner {
|
||||
constructor(private readonly setupService: SetupService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(_passedParams: string[], options: SetupCommandOptions): Promise<void> {
|
||||
const isGlobal = options.global ?? false;
|
||||
|
||||
try {
|
||||
if (isGlobal) {
|
||||
await this.setupService.setupGlobal();
|
||||
} else {
|
||||
await this.setupService.setupLocal();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(t("setup:setupFailed", { error: error.message }));
|
||||
if (error.stack) {
|
||||
console.error(t("common.stackTrace", { stack: error.stack }));
|
||||
}
|
||||
} else {
|
||||
console.error(t("setup:setupFailed", { error }));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-g, --global",
|
||||
description: t("setup:options.global"),
|
||||
})
|
||||
parseGlobal(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
8
cli/src/commands/setup/setup.module.ts
Normal file
8
cli/src/commands/setup/setup.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SetupCommand } from "./setup.command";
|
||||
import { SetupService } from "./setup.service";
|
||||
|
||||
@Module({
|
||||
providers: [SetupCommand, SetupService],
|
||||
})
|
||||
export class SetupModule {}
|
||||
240
cli/src/commands/setup/setup.service.ts
Normal file
240
cli/src/commands/setup/setup.service.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
import stringify from "json-stringify-pretty-compact";
|
||||
import {
|
||||
CONFIG_FILE_NAME,
|
||||
RC_FILE_NAME,
|
||||
getConfigPath,
|
||||
readConfigSync,
|
||||
type SpaceflowConfig,
|
||||
SchemaGeneratorService,
|
||||
SPACEFLOW_DIR,
|
||||
ensureSpaceflowPackageJson,
|
||||
} from "@spaceflow/core";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
@Injectable()
|
||||
export class SetupService {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly schemaGenerator: SchemaGeneratorService,
|
||||
) {}
|
||||
/**
|
||||
* 本地初始化:创建 .spaceflow/ 目录和 package.json
|
||||
*/
|
||||
async setupLocal(): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
|
||||
// 1. 创建 .spaceflow/ 目录和 package.json
|
||||
const spaceflowDir = join(cwd, SPACEFLOW_DIR);
|
||||
ensureSpaceflowPackageJson(spaceflowDir, false, cwd);
|
||||
console.log(t("setup:dirCreated", { dir: spaceflowDir }));
|
||||
|
||||
// 2. 创建 spaceflow.json 配置文件(运行时配置)
|
||||
const configPath = getConfigPath(cwd);
|
||||
const rcPath = join(cwd, RC_FILE_NAME);
|
||||
if (!existsSync(configPath) && !existsSync(rcPath)) {
|
||||
this.schemaGenerator.generate();
|
||||
const defaultConfig: Partial<SpaceflowConfig> = {
|
||||
$schema: "./config-schema.json",
|
||||
support: ["claudeCode"],
|
||||
};
|
||||
writeFileSync(configPath, stringify(defaultConfig, { indent: 2 }) + "\n");
|
||||
console.log(t("setup:configGenerated", { path: configPath }));
|
||||
} else {
|
||||
const existingPath = existsSync(rcPath) ? rcPath : configPath;
|
||||
console.log(t("setup:configExists", { path: existingPath }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局初始化:创建 ~/.spaceflow/ 目录和 package.json,并合并配置
|
||||
*/
|
||||
async setupGlobal(): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
const globalDir = join(homedir(), SPACEFLOW_DIR);
|
||||
const globalConfigPath = join(globalDir, CONFIG_FILE_NAME);
|
||||
|
||||
// 1. 创建 ~/.spaceflow/ 目录和 package.json
|
||||
ensureSpaceflowPackageJson(globalDir, true, cwd);
|
||||
console.log(t("setup:dirCreated", { dir: globalDir }));
|
||||
|
||||
// 读取本地配置(支持 .spaceflow/spaceflow.json 和 .spaceflowrc)
|
||||
const localConfig = readConfigSync(cwd);
|
||||
if (Object.keys(localConfig).length > 0) {
|
||||
console.log(t("setup:localConfigRead"));
|
||||
}
|
||||
|
||||
// 读取本地 .env 文件并解析为配置
|
||||
const envPath = join(cwd, ".env");
|
||||
const envConfig = this.parseEnvToConfig(envPath);
|
||||
|
||||
const instanceConfig = this.configService.get<Partial<SpaceflowConfig>>("spaceflow") ?? {};
|
||||
|
||||
// 合并配置:本地配置(已含全局) < 实例配置 < 环境变量配置
|
||||
const mergedConfig = this.deepMerge(localConfig, instanceConfig, envConfig);
|
||||
|
||||
// 写入全局配置
|
||||
writeFileSync(globalConfigPath, stringify(mergedConfig, { indent: 2 }) + "\n");
|
||||
console.log(t("setup:globalConfigGenerated", { path: globalConfigPath }));
|
||||
|
||||
// 显示合并的环境变量
|
||||
if (Object.keys(envConfig).length > 0) {
|
||||
console.log(t("setup:envConfigMerged"));
|
||||
this.printConfigTree(envConfig, " ");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 .env 文件为配置对象
|
||||
* 支持嵌套格式:SPACEFLOW_GIT_PROVIDER_SERVER_URL -> { gitProvider: { serverUrl: "..." } }
|
||||
*/
|
||||
private parseEnvToConfig(envPath: string): Record<string, unknown> {
|
||||
if (!existsSync(envPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const content = readFileSync(envPath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// 跳过空行和注释
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
const eqIndex = trimmed.indexOf("=");
|
||||
if (eqIndex === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
let value = trimmed.slice(eqIndex + 1).trim();
|
||||
|
||||
// 移除引号
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
// 只处理 SPACEFLOW_ 前缀的环境变量
|
||||
if (!key.startsWith("SPACEFLOW_")) continue;
|
||||
|
||||
// 转换为嵌套配置
|
||||
// SPACEFLOW_GIT_PROVIDER_SERVER_URL -> gitProvider.serverUrl
|
||||
const parts = key
|
||||
.slice("SPACEFLOW_".length)
|
||||
.toLowerCase()
|
||||
.split("_")
|
||||
.map((part, index) => {
|
||||
// 第一个部分保持小写,后续部分转为 camelCase
|
||||
if (index === 0) return part;
|
||||
return part.charAt(0).toUpperCase() + part.slice(1);
|
||||
});
|
||||
|
||||
// 重新组织为嵌套结构
|
||||
// 例如: ["git", "Provider", "Server", "Url"] -> { gitProviderServerUrl: value }
|
||||
// 简化处理:按照常见模式分组
|
||||
this.setNestedValue(config, parts, value);
|
||||
}
|
||||
|
||||
console.log(t("setup:envRead", { path: envPath }));
|
||||
} catch {
|
||||
console.warn(t("setup:envReadFailed", { path: envPath }));
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置嵌套值
|
||||
* 例如: ["review", "Gitea", "Server", "Url"] 和 value
|
||||
* 结果: { review: { giteaServerUrl: value } }
|
||||
*/
|
||||
private setNestedValue(obj: Record<string, unknown>, parts: string[], value: string): void {
|
||||
if (parts.length === 0) return;
|
||||
|
||||
// 第一个部分作为顶级 key(如 review, commit 等)
|
||||
const topKey = parts[0];
|
||||
|
||||
if (parts.length === 1) {
|
||||
obj[topKey] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
// 剩余部分合并为 camelCase 作为嵌套 key
|
||||
// 例如: ["Gitea", "Server", "Url"] -> giteaServerUrl
|
||||
const restParts = parts.slice(1);
|
||||
const nestedKey = restParts
|
||||
.map((part, index) => (index === 0 ? part.toLowerCase() : part))
|
||||
.join("");
|
||||
|
||||
if (!obj[topKey] || typeof obj[topKey] !== "object") {
|
||||
obj[topKey] = {};
|
||||
}
|
||||
|
||||
(obj[topKey] as Record<string, unknown>)[nestedKey] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度合并对象
|
||||
*/
|
||||
private deepMerge<T extends Record<string, unknown>>(...objects: Partial<T>[]): Partial<T> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const obj of objects) {
|
||||
for (const key in obj) {
|
||||
const value = obj[key];
|
||||
const existing = result[key];
|
||||
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
existing !== null &&
|
||||
typeof existing === "object" &&
|
||||
!Array.isArray(existing)
|
||||
) {
|
||||
result[key] = this.deepMerge(
|
||||
existing as Record<string, unknown>,
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
} else if (value !== undefined) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result as Partial<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印配置树
|
||||
*/
|
||||
private printConfigTree(config: Record<string, unknown>, prefix: string): void {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
console.log(`${prefix}${key}:`);
|
||||
this.printConfigTree(value as Record<string, unknown>, prefix + " ");
|
||||
} else {
|
||||
// 隐藏敏感值
|
||||
const displayValue = this.isSensitiveKey(key) ? "***" : String(value);
|
||||
console.log(`${prefix}${key}: ${displayValue}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为敏感 key
|
||||
*/
|
||||
private isSensitiveKey(key: string): boolean {
|
||||
const sensitivePatterns = ["token", "secret", "password", "key", "apikey"];
|
||||
const lowerKey = key.toLowerCase();
|
||||
return sensitivePatterns.some((pattern) => lowerKey.includes(pattern));
|
||||
}
|
||||
}
|
||||
28
cli/src/commands/uninstall/index.ts
Normal file
28
cli/src/commands/uninstall/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
SpaceflowExtension,
|
||||
SpaceflowExtensionMetadata,
|
||||
ExtensionModuleType,
|
||||
} from "@spaceflow/core";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { UninstallModule } from "./uninstall.module";
|
||||
|
||||
export const uninstallMetadata: SpaceflowExtensionMetadata = {
|
||||
name: "uninstall",
|
||||
commands: ["uninstall", "un"],
|
||||
version: "1.0.0",
|
||||
description: t("uninstall:extensionDescription"),
|
||||
};
|
||||
|
||||
export class UninstallExtension implements SpaceflowExtension {
|
||||
getMetadata(): SpaceflowExtensionMetadata {
|
||||
return uninstallMetadata;
|
||||
}
|
||||
|
||||
getModule(): ExtensionModuleType {
|
||||
return UninstallModule;
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./uninstall.module";
|
||||
export * from "./uninstall.command";
|
||||
export * from "./uninstall.service";
|
||||
73
cli/src/commands/uninstall/uninstall.command.ts
Normal file
73
cli/src/commands/uninstall/uninstall.command.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Command, CommandRunner, Option } from "nest-commander";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { UninstallService } from "./uninstall.service";
|
||||
import type { VerboseLevel } from "@spaceflow/core";
|
||||
|
||||
export interface UninstallCommandOptions {
|
||||
global?: boolean;
|
||||
verbose?: VerboseLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载技能包命令
|
||||
*
|
||||
* 用法:
|
||||
* spaceflow uninstall <name>
|
||||
*
|
||||
* 功能:
|
||||
* 1. 从 spaceflow.json 的 skills 中移除
|
||||
* 2. npm 包:执行 pnpm remove <package>
|
||||
* 3. git 仓库:执行 git submodule deinit 并删除目录
|
||||
*/
|
||||
@Command({
|
||||
name: "uninstall",
|
||||
arguments: "<name>",
|
||||
description: t("uninstall:description"),
|
||||
})
|
||||
export class UninstallCommand extends CommandRunner {
|
||||
constructor(private readonly uninstallService: UninstallService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(passedParams: string[], options: UninstallCommandOptions): Promise<void> {
|
||||
const name = passedParams[0];
|
||||
const isGlobal = options.global ?? false;
|
||||
const verbose = options.verbose ?? true;
|
||||
|
||||
if (!name) {
|
||||
console.error(t("uninstall:noName"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.uninstallService.execute(name, isGlobal, verbose);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(t("uninstall:uninstallFailed", { error: error.message }));
|
||||
if (error.stack) {
|
||||
console.error(t("common.stackTrace", { stack: error.stack }));
|
||||
}
|
||||
} else {
|
||||
console.error(t("uninstall:uninstallFailed", { error }));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-g, --global",
|
||||
description: t("uninstall:options.global"),
|
||||
})
|
||||
parseGlobal(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-v, --verbose",
|
||||
description: t("common.options.verbose"),
|
||||
})
|
||||
parseVerbose(_val: string, previous: VerboseLevel = 0): VerboseLevel {
|
||||
const current = typeof previous === "number" ? previous : previous ? 1 : 0;
|
||||
return Math.min(current + 1, 2) as VerboseLevel;
|
||||
}
|
||||
}
|
||||
8
cli/src/commands/uninstall/uninstall.module.ts
Normal file
8
cli/src/commands/uninstall/uninstall.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { UninstallCommand } from "./uninstall.command";
|
||||
import { UninstallService } from "./uninstall.service";
|
||||
|
||||
@Module({
|
||||
providers: [UninstallCommand, UninstallService],
|
||||
})
|
||||
export class UninstallModule {}
|
||||
168
cli/src/commands/uninstall/uninstall.service.ts
Normal file
168
cli/src/commands/uninstall/uninstall.service.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { execSync } from "child_process";
|
||||
import { readFile, rm } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { existsSync, readdirSync } from "fs";
|
||||
import { shouldLog, type VerboseLevel, t } from "@spaceflow/core";
|
||||
import { getEditorDirName } from "@spaceflow/core";
|
||||
import { detectPackageManager } from "@spaceflow/core";
|
||||
import { getSpaceflowDir } from "@spaceflow/core";
|
||||
import { getConfigPath, getSupportedEditors, removeDependency } from "@spaceflow/core";
|
||||
|
||||
@Injectable()
|
||||
export class UninstallService {
|
||||
/**
|
||||
* 从 .spaceflow/node_modules/ 目录卸载 Extension
|
||||
* 所有类型的 Extension 都通过 pnpm remove 卸载
|
||||
*/
|
||||
private async uninstallExtension(
|
||||
name: string,
|
||||
isGlobal: boolean = false,
|
||||
verbose: VerboseLevel = 1,
|
||||
): Promise<void> {
|
||||
const spaceflowDir = getSpaceflowDir(isGlobal);
|
||||
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.log(t("uninstall:uninstallingExtension", { name }));
|
||||
console.log(t("uninstall:targetDir", { dir: spaceflowDir }));
|
||||
}
|
||||
|
||||
const pm = detectPackageManager(spaceflowDir);
|
||||
let cmd: string;
|
||||
if (pm === "pnpm") {
|
||||
cmd = `pnpm remove --prefix "${spaceflowDir}" ${name}`;
|
||||
} else {
|
||||
cmd = `npm uninstall --prefix "${spaceflowDir}" ${name}`;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(cmd, {
|
||||
cwd: process.cwd(),
|
||||
stdio: verbose ? "inherit" : "pipe",
|
||||
});
|
||||
} catch {
|
||||
if (shouldLog(verbose, 1)) console.warn(t("uninstall:extensionUninstallFailed", { name }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行卸载
|
||||
*/
|
||||
async execute(name: string, isGlobal = false, verbose: VerboseLevel = 1): Promise<void> {
|
||||
if (shouldLog(verbose, 1)) {
|
||||
if (isGlobal) {
|
||||
console.log(t("uninstall:uninstallingGlobal", { name }));
|
||||
} else {
|
||||
console.log(t("uninstall:uninstalling", { name }));
|
||||
}
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const configPath = getConfigPath(cwd);
|
||||
|
||||
// 1. 读取配置获取 source
|
||||
const dependencies = await this.parseSkillsFromConfig(configPath);
|
||||
let actualName = name;
|
||||
let config = dependencies[name];
|
||||
|
||||
// 如果通过 name 找不到,尝试通过 source 值查找(支持 @spaceflow/review 这样的 npm 包名)
|
||||
if (!config) {
|
||||
for (const [key, value] of Object.entries(dependencies)) {
|
||||
if (value === name) {
|
||||
actualName = key;
|
||||
config = value;
|
||||
if (shouldLog(verbose, 1)) console.log(t("uninstall:foundDependency", { key, value }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!config && !isGlobal) {
|
||||
throw new Error(t("uninstall:notRegistered", { name }));
|
||||
}
|
||||
|
||||
// 使用实际的 name 进行后续操作
|
||||
name = actualName;
|
||||
|
||||
// 2. 从 .spaceflow/node_modules/ 卸载 Extension
|
||||
await this.uninstallExtension(name, isGlobal, verbose);
|
||||
|
||||
// 3. 删除各个编辑器 commands/skills 中的复制文件
|
||||
const editors = getSupportedEditors(cwd);
|
||||
const home = process.env.HOME || process.env.USERPROFILE || "~";
|
||||
|
||||
for (const editor of editors) {
|
||||
const editorDirName = getEditorDirName(editor);
|
||||
const installRoot = isGlobal ? join(home, editorDirName) : join(cwd, editorDirName);
|
||||
await this.removeEditorFiles(installRoot, name, verbose);
|
||||
}
|
||||
|
||||
// 4. 从配置文件中移除(仅本地安装)
|
||||
if (!isGlobal && config) {
|
||||
this.removeFromConfig(name, cwd, verbose);
|
||||
}
|
||||
|
||||
if (shouldLog(verbose, 1)) console.log(t("uninstall:uninstallDone"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除编辑器目录中的 commands/skills 文件
|
||||
* install 现在是复制文件到编辑器目录,所以卸载时需要删除这些复制的文件/目录
|
||||
*/
|
||||
private async removeEditorFiles(
|
||||
installRoot: string,
|
||||
name: string,
|
||||
verbose: VerboseLevel = 1,
|
||||
): Promise<void> {
|
||||
// 删除 skills 目录中的文件
|
||||
const skillsDir = join(installRoot, "skills");
|
||||
if (existsSync(skillsDir)) {
|
||||
const entries = readdirSync(skillsDir);
|
||||
for (const entry of entries) {
|
||||
// 匹配 name 或 name-xxx 格式
|
||||
if (entry === name || entry.startsWith(`${name}-`)) {
|
||||
const targetPath = join(skillsDir, entry);
|
||||
if (shouldLog(verbose, 1)) console.log(t("uninstall:deletingSkill", { entry }));
|
||||
await rm(targetPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除 commands 目录中的 .md 文件
|
||||
const commandsDir = join(installRoot, "commands");
|
||||
if (existsSync(commandsDir)) {
|
||||
const entries = readdirSync(commandsDir);
|
||||
for (const entry of entries) {
|
||||
// 匹配 name.md 或 name-xxx.md 格式
|
||||
if (entry === `${name}.md` || entry.startsWith(`${name}-`)) {
|
||||
const targetPath = join(commandsDir, entry);
|
||||
if (shouldLog(verbose, 1)) console.log(t("uninstall:deletingCommand", { entry }));
|
||||
await rm(targetPath, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从配置文件解析 dependencies
|
||||
*/
|
||||
private async parseSkillsFromConfig(configPath: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const content = await readFile(configPath, "utf-8");
|
||||
const config = JSON.parse(content);
|
||||
return config.dependencies || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从配置文件中移除依赖
|
||||
*/
|
||||
private removeFromConfig(name: string, cwd: string, verbose: VerboseLevel = 1): void {
|
||||
const removed = removeDependency(name, cwd);
|
||||
if (removed && shouldLog(verbose, 1)) {
|
||||
console.log(t("uninstall:configUpdated", { path: getConfigPath(cwd) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
28
cli/src/commands/update/index.ts
Normal file
28
cli/src/commands/update/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
SpaceflowExtension,
|
||||
SpaceflowExtensionMetadata,
|
||||
ExtensionModuleType,
|
||||
} from "@spaceflow/core";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { UpdateModule } from "./update.module";
|
||||
|
||||
export const updateMetadata: SpaceflowExtensionMetadata = {
|
||||
name: "update",
|
||||
commands: ["update"],
|
||||
version: "1.0.0",
|
||||
description: t("update:extensionDescription"),
|
||||
};
|
||||
|
||||
export class UpdateExtension implements SpaceflowExtension {
|
||||
getMetadata(): SpaceflowExtensionMetadata {
|
||||
return updateMetadata;
|
||||
}
|
||||
|
||||
getModule(): ExtensionModuleType {
|
||||
return UpdateModule;
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./update.module";
|
||||
export { UpdateCommand, type UpdateCommandOptions } from "./update.command";
|
||||
export { UpdateService, type UpdateOptions } from "./update.service";
|
||||
81
cli/src/commands/update/update.command.ts
Normal file
81
cli/src/commands/update/update.command.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Command, CommandRunner, Option } from "nest-commander";
|
||||
import { t } from "@spaceflow/core";
|
||||
import { UpdateService } from "./update.service";
|
||||
import type { VerboseLevel } from "@spaceflow/core";
|
||||
|
||||
export interface UpdateCommandOptions {
|
||||
self?: boolean;
|
||||
verbose?: VerboseLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新依赖命令
|
||||
*
|
||||
* 用法:
|
||||
* spaceflow update # 更新所有依赖
|
||||
* spaceflow update <name> # 更新指定依赖
|
||||
* spaceflow update --self # 更新 CLI 自身
|
||||
*
|
||||
* 功能:
|
||||
* 1. npm 包:获取最新版本并安装
|
||||
* 2. git 仓库:拉取最新代码
|
||||
* 3. --self:更新 spaceflow CLI 自身
|
||||
*/
|
||||
@Command({
|
||||
name: "update",
|
||||
arguments: "[name]",
|
||||
description: t("update:description"),
|
||||
})
|
||||
export class UpdateCommand extends CommandRunner {
|
||||
constructor(private readonly updateService: UpdateService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(passedParams: string[], options: UpdateCommandOptions): Promise<void> {
|
||||
const name = passedParams[0];
|
||||
const verbose = options.verbose ?? 1;
|
||||
|
||||
try {
|
||||
if (options.self) {
|
||||
await this.updateService.updateSelf(verbose);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
const success = await this.updateService.updateDependency(name, verbose);
|
||||
if (!success) {
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
await this.updateService.updateAll(verbose);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(t("update:updateFailed", { error: error.message }));
|
||||
if (error.stack) {
|
||||
console.error(t("common.stackTrace", { stack: error.stack }));
|
||||
}
|
||||
} else {
|
||||
console.error(t("update:updateFailed", { error }));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "--self",
|
||||
description: t("update:options.self"),
|
||||
})
|
||||
parseSelf(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: "-v, --verbose",
|
||||
description: t("common.options.verbose"),
|
||||
})
|
||||
parseVerbose(_val: string, previous: VerboseLevel = 0): VerboseLevel {
|
||||
const current = typeof previous === "number" ? previous : previous ? 1 : 0;
|
||||
return Math.min(current + 1, 2) as VerboseLevel;
|
||||
}
|
||||
}
|
||||
9
cli/src/commands/update/update.module.ts
Normal file
9
cli/src/commands/update/update.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { UpdateCommand } from "./update.command";
|
||||
import { UpdateService } from "./update.service";
|
||||
|
||||
@Module({
|
||||
providers: [UpdateCommand, UpdateService],
|
||||
exports: [UpdateService],
|
||||
})
|
||||
export class UpdateModule {}
|
||||
375
cli/src/commands/update/update.service.ts
Normal file
375
cli/src/commands/update/update.service.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { execSync } from "child_process";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { shouldLog, type VerboseLevel, t } from "@spaceflow/core";
|
||||
import { getDependencies } from "@spaceflow/core";
|
||||
import type { SkillConfig } from "../install/install.service";
|
||||
|
||||
export interface UpdateOptions {
|
||||
name?: string;
|
||||
self?: boolean;
|
||||
verbose?: VerboseLevel;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UpdateService {
|
||||
protected getPackageManager(): string {
|
||||
const cwd = process.cwd();
|
||||
|
||||
if (existsSync(join(cwd, "pnpm-lock.yaml"))) {
|
||||
try {
|
||||
execSync("pnpm --version", { stdio: "ignore" });
|
||||
return "pnpm";
|
||||
} catch {
|
||||
// pnpm 不可用
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(join(cwd, "yarn.lock"))) {
|
||||
try {
|
||||
execSync("yarn --version", { stdio: "ignore" });
|
||||
return "yarn";
|
||||
} catch {
|
||||
// yarn 不可用
|
||||
}
|
||||
}
|
||||
|
||||
return "npm";
|
||||
}
|
||||
|
||||
protected isPnpmWorkspace(): boolean {
|
||||
return existsSync(join(process.cwd(), "pnpm-workspace.yaml"));
|
||||
}
|
||||
|
||||
isGitUrl(source: string): boolean {
|
||||
return (
|
||||
source.startsWith("git@") ||
|
||||
(source.startsWith("https://") && source.endsWith(".git")) ||
|
||||
source.endsWith(".git")
|
||||
);
|
||||
}
|
||||
|
||||
isLocalPath(source: string): boolean {
|
||||
return (
|
||||
source.startsWith("./") ||
|
||||
source.startsWith("../") ||
|
||||
source.startsWith("/") ||
|
||||
source.startsWith("skills/")
|
||||
);
|
||||
}
|
||||
|
||||
getSourceType(source: string): "npm" | "git" | "local" {
|
||||
if (this.isLocalPath(source)) return "local";
|
||||
if (this.isGitUrl(source)) return "git";
|
||||
return "npm";
|
||||
}
|
||||
|
||||
parseSkillConfig(config: SkillConfig): { source: string; ref?: string } {
|
||||
if (typeof config === "string") {
|
||||
return { source: config };
|
||||
}
|
||||
return { source: config.source, ref: config.ref };
|
||||
}
|
||||
|
||||
async getLatestNpmVersion(packageName: string): Promise<string | null> {
|
||||
try {
|
||||
const result = execSync(`npm view ${packageName} version`, {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentNpmVersion(packageName: string): Promise<string | null> {
|
||||
const packageJsonPath = join(process.cwd(), "package.json");
|
||||
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(packageJsonPath, "utf-8");
|
||||
const pkg = JSON.parse(content);
|
||||
const version = pkg.dependencies?.[packageName] || pkg.devDependencies?.[packageName];
|
||||
if (version) {
|
||||
return version.replace(/^[\^~>=<]+/, "");
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async updateNpmPackage(packageName: string, verbose: VerboseLevel = 1): Promise<boolean> {
|
||||
const pm = this.getPackageManager();
|
||||
const latestVersion = await this.getLatestNpmVersion(packageName);
|
||||
const currentVersion = await this.getCurrentNpmVersion(packageName);
|
||||
|
||||
if (!latestVersion) {
|
||||
if (shouldLog(verbose, 1)) console.log(t("update:cannotGetLatest", { package: packageName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentVersion === latestVersion) {
|
||||
if (shouldLog(verbose, 1))
|
||||
console.log(t("update:alreadyLatest", { package: packageName, version: currentVersion }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.log(
|
||||
t("update:versionChange", {
|
||||
package: packageName,
|
||||
current: currentVersion || t("update:unknown"),
|
||||
latest: latestVersion,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let cmd: string;
|
||||
if (pm === "pnpm") {
|
||||
cmd = this.isPnpmWorkspace()
|
||||
? `pnpm add -wD ${packageName}@latest`
|
||||
: `pnpm add -D ${packageName}@latest`;
|
||||
} else if (pm === "yarn") {
|
||||
cmd = `yarn add -D ${packageName}@latest`;
|
||||
} else {
|
||||
cmd = `npm install -D ${packageName}@latest`;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(cmd, {
|
||||
cwd: process.cwd(),
|
||||
stdio: verbose ? "inherit" : "pipe",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.error(
|
||||
t("update:npmUpdateFailed", { package: packageName }),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateGitRepo(depPath: string, name: string, verbose: VerboseLevel = 1): Promise<boolean> {
|
||||
const gitDir = join(depPath, ".git");
|
||||
|
||||
if (!existsSync(gitDir)) {
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.log(t("update:notGitRepo", { name }));
|
||||
console.log(t("update:reinstallHint"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (shouldLog(verbose, 1)) console.log(t("update:pullingLatest"));
|
||||
execSync("git pull", {
|
||||
cwd: depPath,
|
||||
stdio: verbose ? "inherit" : "pipe",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.error(
|
||||
t("update:gitUpdateFailed", { name }),
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 CLI 的安装方式
|
||||
* 返回: { isGlobal: boolean, pm: 'pnpm' | 'npm' | 'yarn', cwd?: string }
|
||||
*/
|
||||
protected detectCliInstallation(): { isGlobal: boolean; pm: string; cwd?: string } {
|
||||
try {
|
||||
// 获取当前执行的 CLI 路径
|
||||
const cliPath = process.argv[1];
|
||||
|
||||
// 检查是否在 node_modules 中(本地安装)
|
||||
if (cliPath.includes("node_modules")) {
|
||||
// 提取项目根目录(node_modules 的父目录)
|
||||
const nodeModulesIndex = cliPath.indexOf("node_modules");
|
||||
const projectRoot = cliPath.substring(0, nodeModulesIndex - 1);
|
||||
|
||||
// 检测项目使用的包管理器
|
||||
let pm = "npm";
|
||||
if (existsSync(join(projectRoot, "pnpm-lock.yaml"))) {
|
||||
pm = "pnpm";
|
||||
} else if (existsSync(join(projectRoot, "yarn.lock"))) {
|
||||
pm = "yarn";
|
||||
}
|
||||
|
||||
return { isGlobal: false, pm, cwd: projectRoot };
|
||||
}
|
||||
|
||||
// 检查是否是全局安装
|
||||
// 尝试检测 pnpm 全局
|
||||
try {
|
||||
const pnpmGlobalDir = execSync("pnpm root -g", {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
if (cliPath.startsWith(pnpmGlobalDir) || cliPath.includes(".pnpm")) {
|
||||
return { isGlobal: true, pm: "pnpm" };
|
||||
}
|
||||
} catch {
|
||||
// pnpm 不可用
|
||||
}
|
||||
|
||||
// 尝试检测 npm 全局
|
||||
try {
|
||||
const npmGlobalDir = execSync("npm root -g", {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
if (cliPath.startsWith(npmGlobalDir)) {
|
||||
return { isGlobal: true, pm: "npm" };
|
||||
}
|
||||
} catch {
|
||||
// npm 不可用
|
||||
}
|
||||
|
||||
// 默认认为是全局安装
|
||||
return { isGlobal: true, pm: "npm" };
|
||||
} catch {
|
||||
return { isGlobal: true, pm: "npm" };
|
||||
}
|
||||
}
|
||||
|
||||
async updateSelf(verbose: VerboseLevel = 1): Promise<boolean> {
|
||||
if (shouldLog(verbose, 1)) console.log(t("update:updatingCli"));
|
||||
|
||||
const cliPackageName = "@spaceflow/cli";
|
||||
const installation = this.detectCliInstallation();
|
||||
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.log(
|
||||
t("update:installMethod", {
|
||||
method: installation.isGlobal ? t("update:installGlobal") : t("update:installLocal"),
|
||||
pm: installation.pm,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (installation.isGlobal) {
|
||||
// 全局安装:使用对应包管理器的全局更新命令
|
||||
const cmd =
|
||||
installation.pm === "pnpm"
|
||||
? `pnpm update -g ${cliPackageName}`
|
||||
: installation.pm === "yarn"
|
||||
? `yarn global upgrade ${cliPackageName}`
|
||||
: `npm update -g ${cliPackageName}`;
|
||||
|
||||
if (shouldLog(verbose, 1)) console.log(t("update:executing", { cmd }));
|
||||
execSync(cmd, { stdio: verbose ? "inherit" : "pipe" });
|
||||
} else {
|
||||
// 本地安装:在项目目录中更新
|
||||
const cwd = installation.cwd || process.cwd();
|
||||
let cmd: string;
|
||||
|
||||
if (installation.pm === "pnpm") {
|
||||
const isPnpmWorkspace = existsSync(join(cwd, "pnpm-workspace.yaml"));
|
||||
cmd = isPnpmWorkspace
|
||||
? `pnpm add -wD ${cliPackageName}@latest`
|
||||
: `pnpm add -D ${cliPackageName}@latest`;
|
||||
} else if (installation.pm === "yarn") {
|
||||
cmd = `yarn add -D ${cliPackageName}@latest`;
|
||||
} else {
|
||||
cmd = `npm install -D ${cliPackageName}@latest`;
|
||||
}
|
||||
|
||||
if (shouldLog(verbose, 1)) console.log(t("update:executing", { cmd }));
|
||||
execSync(cmd, { cwd, stdio: verbose ? "inherit" : "pipe" });
|
||||
}
|
||||
|
||||
if (shouldLog(verbose, 1)) console.log(t("update:cliUpdateDone"));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.error(t("update:cliUpdateFailed"), error instanceof Error ? error.message : error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateDependency(name: string, verbose: VerboseLevel = 1): Promise<boolean> {
|
||||
const cwd = process.cwd();
|
||||
const dependencies = getDependencies(cwd);
|
||||
|
||||
if (!dependencies[name]) {
|
||||
if (shouldLog(verbose, 1)) console.log(t("update:depNotFound", { name }));
|
||||
return false;
|
||||
}
|
||||
|
||||
const { source } = this.parseSkillConfig(dependencies[name]);
|
||||
const sourceType = this.getSourceType(source);
|
||||
|
||||
if (shouldLog(verbose, 1)) console.log(t("update:updating", { name }));
|
||||
|
||||
if (sourceType === "npm") {
|
||||
return this.updateNpmPackage(source, verbose);
|
||||
} else if (sourceType === "git") {
|
||||
const depPath = join(cwd, ".spaceflow", "deps", name);
|
||||
return this.updateGitRepo(depPath, name, verbose);
|
||||
} else {
|
||||
if (shouldLog(verbose, 1)) console.log(t("update:localNoUpdate"));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async updateAll(verbose: VerboseLevel = 1): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
const dependencies = getDependencies(cwd);
|
||||
|
||||
if (Object.keys(dependencies).length === 0) {
|
||||
if (shouldLog(verbose, 1)) console.log(t("update:noDeps"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.log(t("update:updatingAll", { count: Object.keys(dependencies).length }));
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const [name, config] of Object.entries(dependencies)) {
|
||||
const { source } = this.parseSkillConfig(config);
|
||||
const sourceType = this.getSourceType(source);
|
||||
|
||||
console.log(`\n📦 ${name}`);
|
||||
|
||||
let success = false;
|
||||
if (sourceType === "npm") {
|
||||
success = await this.updateNpmPackage(source, verbose);
|
||||
} else if (sourceType === "git") {
|
||||
const depPath = join(cwd, ".spaceflow", "deps", name);
|
||||
success = await this.updateGitRepo(depPath, name, verbose);
|
||||
} else {
|
||||
if (shouldLog(verbose, 1)) console.log(t("update:localNoUpdate"));
|
||||
success = true;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n" + t("update:updateComplete", { success: successCount, fail: failCount }));
|
||||
}
|
||||
}
|
||||
338
cli/src/extension-loader/extension-loader.service.ts
Normal file
338
cli/src/extension-loader/extension-loader.service.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import { createRequire } from "module";
|
||||
import { homedir } from "os";
|
||||
import type {
|
||||
LoadedExtension,
|
||||
ExtensionModuleType,
|
||||
SpaceflowExtension,
|
||||
ExtensionDependencies,
|
||||
} from "@spaceflow/core";
|
||||
import {
|
||||
registerPluginConfig,
|
||||
registerPluginSchema,
|
||||
t,
|
||||
SPACEFLOW_DIR,
|
||||
PACKAGE_JSON,
|
||||
} from "@spaceflow/core";
|
||||
|
||||
@Injectable()
|
||||
export class ExtensionLoaderService {
|
||||
private loadedExtensions: Map<string, LoadedExtension> = new Map();
|
||||
|
||||
/**
|
||||
* 注册内部 Extension(用于内置命令)
|
||||
* 与外部 Extension 使用相同的接口,但不需要从文件系统加载
|
||||
*/
|
||||
registerInternalExtension(extension: SpaceflowExtension): LoadedExtension {
|
||||
const metadata = extension.getMetadata();
|
||||
|
||||
// 注册 Extension 配置到全局注册表
|
||||
if (metadata.configKey) {
|
||||
registerPluginConfig({
|
||||
name: metadata.name,
|
||||
configKey: metadata.configKey,
|
||||
configDependencies: metadata.configDependencies,
|
||||
configSchema: metadata.configSchema,
|
||||
});
|
||||
|
||||
// 注册 schema(如果有)
|
||||
if (metadata.configSchema) {
|
||||
registerPluginSchema({
|
||||
configKey: metadata.configKey,
|
||||
schemaFactory: metadata.configSchema as () => any,
|
||||
description: metadata.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const loadedExtension: LoadedExtension = {
|
||||
name: metadata.name,
|
||||
source: "internal",
|
||||
module: extension.getModule(),
|
||||
commands: metadata.commands,
|
||||
configKey: metadata.configKey,
|
||||
configDependencies: metadata.configDependencies,
|
||||
configSchema: metadata.configSchema,
|
||||
version: metadata.version,
|
||||
description: metadata.description,
|
||||
};
|
||||
|
||||
this.loadedExtensions.set(metadata.name, loadedExtension);
|
||||
return loadedExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量注册内部 Extension
|
||||
*/
|
||||
registerInternalExtensions(extensions: SpaceflowExtension[]): LoadedExtension[] {
|
||||
return extensions.map((ext) => this.registerInternalExtension(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 .spaceflow/package.json 发现并加载所有 Extension
|
||||
* 优先级:项目 .spaceflow/ > 全局 ~/.spaceflow/
|
||||
*/
|
||||
async discoverAndLoad(): Promise<LoadedExtension[]> {
|
||||
const extensions: LoadedExtension[] = [];
|
||||
|
||||
// 获取所有 .spaceflow 目录(按优先级从低到高)
|
||||
const spaceflowDirs = this.getSpaceflowDirs();
|
||||
|
||||
// 收集所有 dependencies,后面的覆盖前面的
|
||||
const allDependencies: ExtensionDependencies = {};
|
||||
for (const dir of spaceflowDirs) {
|
||||
const deps = this.readDependencies(dir);
|
||||
Object.assign(allDependencies, deps);
|
||||
}
|
||||
|
||||
// 需要跳过的核心依赖(不是 Extension)
|
||||
const corePackages = ["@spaceflow/core", "@spaceflow/cli"];
|
||||
|
||||
// 加载所有 Extension
|
||||
for (const [name, version] of Object.entries(allDependencies)) {
|
||||
// 跳过核心包
|
||||
if (corePackages.includes(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const extension = await this.loadExtension(name, version);
|
||||
if (extension) {
|
||||
extensions.push(extension);
|
||||
this.loadedExtensions.set(name, extension);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(t("extensionLoader.loadFailed", { name, error: message }));
|
||||
}
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 .spaceflow 目录列表(按优先级从低到高)
|
||||
*/
|
||||
getSpaceflowDirs(): string[] {
|
||||
const dirs: string[] = [];
|
||||
|
||||
// 1. 全局 ~/.spaceflow/
|
||||
const globalDir = path.join(homedir(), SPACEFLOW_DIR);
|
||||
if (fs.existsSync(globalDir)) {
|
||||
dirs.push(globalDir);
|
||||
}
|
||||
|
||||
// 2. 项目 .spaceflow/
|
||||
const localDir = path.join(process.cwd(), SPACEFLOW_DIR);
|
||||
if (fs.existsSync(localDir)) {
|
||||
dirs.push(localDir);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 .spaceflow/package.json 读取 dependencies
|
||||
*/
|
||||
readDependencies(spaceflowDir: string): ExtensionDependencies {
|
||||
const packageJsonPath = path.join(spaceflowDir, PACKAGE_JSON);
|
||||
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(packageJsonPath, "utf-8");
|
||||
const pkg = JSON.parse(content);
|
||||
return pkg.dependencies || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查包是否是一个有效的 flow 类型 Extension
|
||||
* 格式:spaceflow.exports 或 spaceflow.type === "flow"
|
||||
*/
|
||||
private isValidExtension(name: string, spaceflowDir: string): boolean {
|
||||
const nodeModulesPath = path.join(spaceflowDir, "node_modules", name);
|
||||
const packageJsonPath = path.join(nodeModulesPath, PACKAGE_JSON);
|
||||
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(packageJsonPath, "utf-8");
|
||||
const pkg = JSON.parse(content);
|
||||
const spaceflowConfig = pkg.spaceflow;
|
||||
|
||||
if (!spaceflowConfig) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 完整格式:检查 exports 中是否有 flow 类型
|
||||
if (spaceflowConfig.exports) {
|
||||
return Object.values(spaceflowConfig.exports).some(
|
||||
(exp: any) => !exp.type || exp.type === "flow",
|
||||
);
|
||||
}
|
||||
|
||||
// 简化格式:检查 type 是否为 flow(默认)
|
||||
if (spaceflowConfig.entry) {
|
||||
return !spaceflowConfig.type || spaceflowConfig.type === "flow";
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载单个 Extension
|
||||
*/
|
||||
async loadExtension(name: string, version: string): Promise<LoadedExtension | null> {
|
||||
// 尝试从项目 .spaceflow/node_modules 加载
|
||||
const localSpaceflowDir = path.join(process.cwd(), SPACEFLOW_DIR);
|
||||
|
||||
// 先检查是否是有效的 Extension
|
||||
if (!this.isValidExtension(name, localSpaceflowDir)) {
|
||||
const globalSpaceflowDir = path.join(homedir(), SPACEFLOW_DIR);
|
||||
if (!this.isValidExtension(name, globalSpaceflowDir)) {
|
||||
// 不是有效的 Extension,静默跳过(可能是 skills 包)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let extensionModule = await this.tryLoadFromDir(name, localSpaceflowDir);
|
||||
|
||||
// 如果本地没有,尝试从全局 ~/.spaceflow/node_modules 加载
|
||||
if (!extensionModule) {
|
||||
const globalSpaceflowDir = path.join(homedir(), SPACEFLOW_DIR);
|
||||
extensionModule = await this.tryLoadFromDir(name, globalSpaceflowDir);
|
||||
}
|
||||
|
||||
if (!extensionModule) {
|
||||
console.warn(`⚠️ Extension ${name} 未找到`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const ExtensionClass =
|
||||
extensionModule.default || extensionModule[Object.keys(extensionModule)[0]];
|
||||
|
||||
if (!ExtensionClass) {
|
||||
console.warn(`⚠️ Extension ${name} 没有导出有效的 Extension 类`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const extensionInstance: SpaceflowExtension = new ExtensionClass();
|
||||
const metadata = extensionInstance.getMetadata();
|
||||
|
||||
// 注册 Extension 配置到全局注册表
|
||||
if (metadata.configKey) {
|
||||
registerPluginConfig({
|
||||
name: metadata.name,
|
||||
configKey: metadata.configKey,
|
||||
configDependencies: metadata.configDependencies,
|
||||
configSchema: metadata.configSchema,
|
||||
});
|
||||
|
||||
// 注册 schema(如果有)
|
||||
if (metadata.configSchema) {
|
||||
registerPluginSchema({
|
||||
configKey: metadata.configKey,
|
||||
schemaFactory: metadata.configSchema as () => any,
|
||||
description: metadata.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
source: `${name}@${version}`,
|
||||
module: extensionInstance.getModule(),
|
||||
exports: extensionModule,
|
||||
commands: metadata.commands,
|
||||
configKey: metadata.configKey,
|
||||
configDependencies: metadata.configDependencies,
|
||||
configSchema: metadata.configSchema,
|
||||
version: metadata.version,
|
||||
description: metadata.description,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`加载 Extension 模块失败: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试从指定的 .spaceflow 目录加载 Extension
|
||||
*/
|
||||
private async tryLoadFromDir(name: string, spaceflowDir: string): Promise<any | null> {
|
||||
const packageJsonPath = path.join(spaceflowDir, PACKAGE_JSON);
|
||||
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 .spaceflow/package.json 作为基础路径创建 require
|
||||
const localRequire = createRequire(packageJsonPath);
|
||||
const resolvedPath = localRequire.resolve(name);
|
||||
|
||||
// 使用 Function 构造器来避免 rspack 转换这个 import
|
||||
const dynamicImport = new Function("url", "return import(url)");
|
||||
return await dynamicImport(`file://${resolvedPath}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已加载的 Extension
|
||||
*/
|
||||
getLoadedExtensions(): LoadedExtension[] {
|
||||
return Array.from(this.loadedExtensions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用命令
|
||||
*/
|
||||
getAvailableCommands(): string[] {
|
||||
const commands: string[] = [];
|
||||
for (const extension of this.loadedExtensions.values()) {
|
||||
commands.push(...extension.commands);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据命令名查找 Extension
|
||||
*/
|
||||
findExtensionByCommand(command: string): LoadedExtension | undefined {
|
||||
for (const extension of this.loadedExtensions.values()) {
|
||||
if (extension.commands.includes(command)) {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 Extension 模块(用于动态注入)
|
||||
*/
|
||||
getExtensionModules(): ExtensionModuleType[] {
|
||||
return this.getLoadedExtensions().map((e) => e.module);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除已加载的 Extension
|
||||
*/
|
||||
clear(): void {
|
||||
this.loadedExtensions.clear();
|
||||
}
|
||||
}
|
||||
1
cli/src/extension-loader/index.ts
Normal file
1
cli/src/extension-loader/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./extension-loader.service";
|
||||
38
cli/src/internal-extensions.ts
Normal file
38
cli/src/internal-extensions.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 内部 Extension 注册
|
||||
* 使用与外部 Extension 相同的 SpaceflowExtension 接口
|
||||
*/
|
||||
import type { SpaceflowExtension } from "@spaceflow/core";
|
||||
import { InstallExtension } from "./commands/install";
|
||||
import { UninstallExtension } from "./commands/uninstall";
|
||||
import { UpdateExtension } from "./commands/update";
|
||||
import { BuildExtension } from "./commands/build";
|
||||
import { DevExtension } from "./commands/dev";
|
||||
import { CreateExtension } from "./commands/create";
|
||||
import { ListExtension } from "./commands/list";
|
||||
import { ClearExtension } from "./commands/clear";
|
||||
import { RunxExtension } from "./commands/runx";
|
||||
import { SchemaExtension } from "./commands/schema";
|
||||
import { CommitExtension } from "./commands/commit";
|
||||
import { SetupExtension } from "./commands/setup";
|
||||
import { McpExtension } from "./commands/mcp";
|
||||
|
||||
/**
|
||||
* 内部 Extension 列表
|
||||
* 所有内置命令都在这里统一注册
|
||||
*/
|
||||
export const internalExtensions: SpaceflowExtension[] = [
|
||||
new InstallExtension(),
|
||||
new UninstallExtension(),
|
||||
new UpdateExtension(),
|
||||
new BuildExtension(),
|
||||
new DevExtension(),
|
||||
new CreateExtension(),
|
||||
new ListExtension(),
|
||||
new ClearExtension(),
|
||||
new RunxExtension(),
|
||||
new SchemaExtension(),
|
||||
new CommitExtension(),
|
||||
new SetupExtension(),
|
||||
new McpExtension(),
|
||||
];
|
||||
22
cli/src/locales/en/build.json
Normal file
22
cli/src/locales/en/build.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"description": "Build skill packages in the skills directory",
|
||||
"options.watch": "Watch for file changes and rebuild automatically",
|
||||
"buildFailed": "❌ Build failed: {{error}}",
|
||||
"extensionDescription": "Build plugins/skills",
|
||||
"noPlugins": "📦 No buildable plugins found",
|
||||
"startBuilding": "📦 Building {{count}} plugins...",
|
||||
"buildComplete": "✅ Build complete: {{success}} succeeded, {{fail}} failed",
|
||||
"startWatching": "👀 Watching {{count}} plugins...",
|
||||
"stopWatching": "🛑 Stop watching: {{name}}",
|
||||
"building": "🔨 Building: {{name}}",
|
||||
"buildSuccess": " ✅ Done ({{duration}}ms)",
|
||||
"buildFailedWithDuration": " ❌ Failed ({{duration}}ms)",
|
||||
"buildFailedWithMessage": " ❌ Failed ({{duration}}ms): {{message}}",
|
||||
"buildWarnings": " ⚠️ Done ({{duration}}ms) - {{count}} warnings",
|
||||
"watching": "👀 Watching: {{name}}",
|
||||
"watchError": " ❌ [{{name}}] Error: {{message}}",
|
||||
"watchBuildFailed": " ❌ [{{name}}] Build failed",
|
||||
"watchBuildWarnings": " ⚠️ [{{name}}] Build complete - {{count}} warnings",
|
||||
"watchBuildSuccess": " ✅ [{{name}}] Build complete",
|
||||
"watchInitFailed": " ❌ [{{name}}] Init failed: {{message}}"
|
||||
}
|
||||
16
cli/src/locales/en/clear.json
Normal file
16
cli/src/locales/en/clear.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Clear all installed skill packages",
|
||||
"options.global": "Clear installed content in global directory (~/.spaceflow)",
|
||||
"clearFailed": "❌ Clear failed: {{error}}",
|
||||
"extensionDescription": "Clear cache and temporary files",
|
||||
"clearingGlobal": "🧹 Clearing all skill packages (global)",
|
||||
"clearing": "🧹 Clearing all skill packages",
|
||||
"clearDone": "✅ Clear complete",
|
||||
"spaceflowNotExist": " .spaceflow does not exist, skipping",
|
||||
"spaceflowNoClean": " .spaceflow has nothing to clean",
|
||||
"clearingSpaceflow": " Clearing .spaceflow ({{count}} items, preserving config files)",
|
||||
"deleted": " Deleted: {{entry}}",
|
||||
"deleteFailed": " Warning: Failed to delete {{entry}}:",
|
||||
"clearingSkills": " Clearing {{editor}}/skills ({{count}} items)",
|
||||
"clearingCommands": " Clearing {{editor}}/commands ({{count}} .md files)"
|
||||
}
|
||||
45
cli/src/locales/en/commit.json
Normal file
45
cli/src/locales/en/commit.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"description": "Auto-generate conventional commit messages from staged changes",
|
||||
"options.dryRun": "Only generate commit message without committing",
|
||||
"options.noVerify": "Skip pre-commit and commit-msg hooks",
|
||||
"options.split": "Split into multiple commits by package/logic/domain",
|
||||
"dryRunMode": "\n🔍 Dry Run mode",
|
||||
"splitSuccess": "\n✅ Successfully split into {{count}} commits",
|
||||
"commitSuccess": "\n✅ Commit successful",
|
||||
"commitFailed": "\n❌ Commit failed: {{error}}",
|
||||
"extensionDescription": "Smart commit command using LLM to generate commit messages",
|
||||
"getDiffFailed": "Failed to get staged diff, make sure you are in a git repository",
|
||||
"noFilesToCommit": "No files to commit",
|
||||
"noChanges": "No changes found",
|
||||
"generatingMessage": "📝 Generating commit message with AI...",
|
||||
"strategyRules": "custom rules",
|
||||
"strategyRulesFirst": "rules-first",
|
||||
"strategyPackage": "package directory",
|
||||
"groupingByStrategy": "🔍 Grouping files by {{strategy}} strategy...",
|
||||
"detectedGroups": "📦 Detected {{count}} groups of changes",
|
||||
"scopeChanges": "{{scope}} changes",
|
||||
"rootChanges": "Root directory changes",
|
||||
"otherChanges": "Other changes",
|
||||
"allChanges": "All changes",
|
||||
"analyzingSplit": "🔍 Analyzing how to split commits within package...",
|
||||
"dryRunMessage": "[Dry Run] Will commit the following message:\n{{message}}",
|
||||
"commitFail": "Commit failed",
|
||||
"stageFilesFailed": "Failed to stage files: {{error}}",
|
||||
"noWorkingChanges": "No working directory changes",
|
||||
"noStagedFiles": "No staged files",
|
||||
"singleCommit": "📦 No split needed, committing as a single commit",
|
||||
"generatedMessage": "\n📋 Generated commit message:",
|
||||
"splitIntoCommits": "\n📦 Splitting into {{count}} commits",
|
||||
"groupItem": " {{index}}. {{reason}}{{pkg}} ({{count}} files)",
|
||||
"parallelGenerating": "\n🚀 Generating {{count}} commit messages in parallel...",
|
||||
"allMessagesGenerated": "✅ All commit messages generated",
|
||||
"generateMessageFailed": "Failed to generate commit messages: {{errors}}",
|
||||
"resetStagingFailed": "Failed to reset staging area",
|
||||
"committingGroup": "\n📝 Committing group {{current}}/{{total}}: {{reason}}{{pkg}}",
|
||||
"skippingNoChanges": "⏭️ Skipping: no actual changes",
|
||||
"commitMessage": "📋 Commit message:",
|
||||
"commitItemFailed": "❌ Commit {{index}} failed: {{error}}",
|
||||
"commitItemFailedDetail": "Commit {{index}} failed: {{error}}\n\nCompleted commits:\n{{committed}}",
|
||||
"noChangesAll": "No changes found, please modify files or use git add first",
|
||||
"noStagedFilesHint": "No staged files, please use git add first"
|
||||
}
|
||||
27
cli/src/locales/en/create.json
Normal file
27
cli/src/locales/en/create.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"description": "Create a new plugin from template",
|
||||
"options.directory": "Specify target directory",
|
||||
"options.list": "List available templates",
|
||||
"options.from": "Use a remote Git repository as template source",
|
||||
"options.ref": "Specify Git branch or tag (default: main)",
|
||||
"noName": "❌ Please specify a name",
|
||||
"usage": "Usage: spaceflow create <template> <name>",
|
||||
"createFailed": "❌ Creation failed: {{error}}",
|
||||
"availableTemplates": "Available templates:",
|
||||
"extensionDescription": "Create new plugin/skill from template",
|
||||
"updatingRepo": "🔄 Updating template repository: {{url}}",
|
||||
"updateFailed": "⚠️ Update failed, using cached version",
|
||||
"cloningRepo": "📥 Cloning template repository: {{url}}",
|
||||
"cloneFailed": "Failed to clone repository: {{error}}",
|
||||
"repoReady": "✅ Template repository ready: {{dir}}",
|
||||
"templatesDirNotFound": "Templates directory not found",
|
||||
"creatingPlugin": "📦 Creating {{template}} plugin: {{name}}",
|
||||
"targetDir": " Directory: {{dir}}",
|
||||
"dirExists": "Directory already exists: {{dir}}",
|
||||
"templateNotFound": "Template \"{{template}}\" not found. Available templates: {{available}}",
|
||||
"noTemplatesAvailable": "none",
|
||||
"pluginCreated": "✅ {{template}} plugin created: {{name}}",
|
||||
"nextSteps": "Next steps:",
|
||||
"fileCreated": " ✓ Created {{file}}",
|
||||
"fileCopied": " ✓ Copied {{file}}"
|
||||
}
|
||||
5
cli/src/locales/en/dev.json
Normal file
5
cli/src/locales/en/dev.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"description": "Dev mode - watch and auto-rebuild skill packages",
|
||||
"startFailed": "❌ Dev mode failed to start: {{error}}",
|
||||
"extensionDescription": "Start plugin dev mode (watch and auto-rebuild)"
|
||||
}
|
||||
71
cli/src/locales/en/install.json
Normal file
71
cli/src/locales/en/install.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"description": "Install skill packages (npm or git), update all skills when no arguments",
|
||||
"options.name": "Specify skill package name (auto-detected from source by default)",
|
||||
"options.global": "Install to global directory (~/.spaceflow) and link to global editor directories",
|
||||
"options.ignoreErrors": "Ignore installation errors without exiting",
|
||||
"globalNoSource": "❌ Global install requires a source argument",
|
||||
"globalUsage": " Usage: spaceflow install <source> -g [--name <name>]",
|
||||
"installFailed": "❌ Installation failed: {{error}}",
|
||||
"extensionDescription": "Install plugins/skills to project",
|
||||
"envPlaceholder": "<Please fill in {{key}}>",
|
||||
"registerMcp": " Register MCP Server: {{name}}",
|
||||
"mcpEnvHint": " ⚠️ Please configure environment variables in {{path}}: {{vars}}",
|
||||
"installingExtension": "📦 Installing Extension: {{source}}",
|
||||
"installDone": "✅ Installation complete: {{name}}",
|
||||
"typeLocal": " Type: local path",
|
||||
"sourcePath": " Source path: {{path}}",
|
||||
"typeGit": " Type: git repository",
|
||||
"sourceUrl": " Source: {{url}}",
|
||||
"typeNpm": " Type: npm package",
|
||||
"targetDir": " Target directory: {{dir}}",
|
||||
"extensionInstallFailed": "Extension installation failed: {{source}}",
|
||||
"creatingDir": " Creating directory: {{dir}}",
|
||||
"dirExistsSkip": " Directory exists, skipping clone",
|
||||
"cloningRepo": " Cloning repository...",
|
||||
"cloneFailed": "Failed to clone repository: {{error}}",
|
||||
"removingGit": " Removing .git directory...",
|
||||
"depsLinkExists": " deps link exists, skipping",
|
||||
"depsExists": " deps/{{name}} exists, skipping",
|
||||
"createDepsLink": " Creating deps link: {{dep}} -> {{source}}",
|
||||
"skillLinkExists": " skills/{{name}} link exists, skipping",
|
||||
"createSkillLink": " Creating skills link: skills/{{name}} -> {{target}}",
|
||||
"copySkill": " Copying skills/{{name}}",
|
||||
"globalInstalling": "🔄 Global install: {{name}} ({{dir}})",
|
||||
"pluginTypes": " Plugin types: {{types}}",
|
||||
"globalInstallDone": "\n✅ Global installation complete",
|
||||
"updatingAll": "🔄 Updating all dependencies...",
|
||||
"noDeps": " No dependencies in config file",
|
||||
"foundDeps": " Found {{count}} dependencies",
|
||||
"installingDeps": "\n📦 Installing dependencies...",
|
||||
"pmInstallFailed": " Warning: {{pm}} install failed",
|
||||
"depNotInstalled": " ⚠️ {{name}} not installed successfully, skipping",
|
||||
"allExtensionsDone": "\n✅ All extensions updated",
|
||||
"updatedPackageJson": " ✓ Updated .spaceflow/package.json",
|
||||
"symlinkExists": " ✓ Symlink already exists",
|
||||
"createSymlink": " Creating symlink: {{target}} -> {{source}}",
|
||||
"createSymlinkFailed": "Failed to create symlink: {{error}}",
|
||||
"pullFailed": " Warning: Failed to pull latest code",
|
||||
"checkoutVersion": " Checking out version: {{ref}}",
|
||||
"checkoutFailed": " Warning: Failed to checkout version",
|
||||
"installingDepsEllipsis": " Installing dependencies...",
|
||||
"depsInstallFailed": " Warning: Dependency installation failed",
|
||||
"depsInstalled": " ✓ Dependencies installed",
|
||||
"buildingPlugin": " Building plugin...",
|
||||
"buildFailed": " Warning: Build failed",
|
||||
"buildExists": " ✓ Build exists",
|
||||
"repoExists": " Repository exists, updating...",
|
||||
"repoUpdateFailed": " Warning: Repository update failed",
|
||||
"cloneRepoFailed": " Warning: Clone repository failed",
|
||||
"skillMdExists": " ✓ SKILL.md already exists",
|
||||
"skillMdGenerated": " ✓ Generated SKILL.md",
|
||||
"skillMdFailed": " Warning: Failed to generate SKILL.md",
|
||||
"commandMdGenerated": " ✓ Generated commands/{{name}}.md",
|
||||
"commandMdFailed": " Warning: Failed to generate commands/{{name}}.md",
|
||||
"configUpdated": " Config updated: {{path}}",
|
||||
"configAlreadyExists": " Config already contains this skill package",
|
||||
"depsUpToDate": " ✓ Dependencies are up to date",
|
||||
"buildUpToDate": " ✓ Build is up to date",
|
||||
"detailSection": "Details",
|
||||
"usageSection": "Usage",
|
||||
"commandDefault": "{{name}} command"
|
||||
}
|
||||
8
cli/src/locales/en/list.json
Normal file
8
cli/src/locales/en/list.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"description": "List installed skill packages",
|
||||
"extensionDescription": "List installed plugins/skills",
|
||||
"noSkills": "📦 No skill packages installed",
|
||||
"installHint": "Install skill packages with:",
|
||||
"installedExtensions": "📦 Installed extensions ({{installed}}/{{total}}):",
|
||||
"commands": "Commands: {{commands}}"
|
||||
}
|
||||
18
cli/src/locales/en/mcp.json
Normal file
18
cli/src/locales/en/mcp.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"description": "Start MCP Server with all installed extension tools",
|
||||
"options.inspector": "Start MCP Inspector for interactive debugging",
|
||||
"inspectorStarting": "🔍 Starting MCP Inspector...",
|
||||
"inspectorDebugCmd": " Debug command: pnpm space mcp",
|
||||
"inspectorFailed": "❌ Failed to start MCP Inspector: {{error}}",
|
||||
"extensionDescription": "Start MCP Server with all installed extension tools",
|
||||
"scanning": "🔍 Scanning installed extensions...",
|
||||
"foundExtensions": " Found {{count}} extensions",
|
||||
"checkingExport": " Checking {{key}}: __mcp_server__={{hasMcpServer}}",
|
||||
"containerSuccess": " ✅ Got {{key}} instance from container",
|
||||
"containerFailed": " ⚠️ Failed to get {{key}} from container: {{error}}",
|
||||
"loadToolsFailed": " ⚠️ {{name}}: Failed to load MCP tools",
|
||||
"noToolsFound": "❌ No MCP tools found",
|
||||
"noToolsHint": " Hint: Make sure you have installed MCP-enabled extensions that export mcpService or getMcpTools",
|
||||
"toolsFound": "✅ Found {{count}} MCP tools",
|
||||
"serverStarted": "🚀 MCP Server started with {{count}} tools"
|
||||
}
|
||||
13
cli/src/locales/en/runx.json
Normal file
13
cli/src/locales/en/runx.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"description": "Install dependency globally and run command",
|
||||
"options.name": "Specify command name (auto-detected from source by default)",
|
||||
"noSource": "❌ Please specify the dependency source to run",
|
||||
"usage": " Usage: spaceflow x <source> [args...]",
|
||||
"runFailed": "❌ Run failed: {{error}}",
|
||||
"extensionDescription": "Execute scripts provided by plugins",
|
||||
"runningCommand": "▶️ Running command: {{command}}",
|
||||
"npxExitCode": "npx {{package}} exit code: {{code}}",
|
||||
"commandNotInstalled": "Command {{name}} is not installed",
|
||||
"commandNotBuilt": "Command {{name}} is not built, missing dist/index.js",
|
||||
"pluginNoExport": "Plugin {{name}} has no default export"
|
||||
}
|
||||
4
cli/src/locales/en/schema.json
Normal file
4
cli/src/locales/en/schema.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"description": "Generate JSON Schema for configuration files",
|
||||
"extensionDescription": "Generate JSON Schema for configuration files"
|
||||
}
|
||||
14
cli/src/locales/en/setup.json
Normal file
14
cli/src/locales/en/setup.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"description": "Initialize spaceflow configuration files",
|
||||
"options.global": "Generate global config to ~/spaceflow/spaceflow.json (merge local .env and spaceflow.json)",
|
||||
"setupFailed": "❌ Initialization failed: {{error}}",
|
||||
"extensionDescription": "Initialize spaceflow configuration files",
|
||||
"dirCreated": "✅ Directory created: {{dir}}",
|
||||
"configGenerated": "✅ Config file generated: {{path}}",
|
||||
"configExists": "ℹ️ Config file already exists: {{path}}",
|
||||
"localConfigRead": "📖 Local config loaded",
|
||||
"globalConfigGenerated": "✅ Global config generated: {{path}}",
|
||||
"envConfigMerged": "📝 Merged environment variable config:",
|
||||
"envRead": "📖 Environment variables loaded: {{path}}",
|
||||
"envReadFailed": "⚠️ Failed to read .env file: {{path}}"
|
||||
}
|
||||
18
cli/src/locales/en/uninstall.json
Normal file
18
cli/src/locales/en/uninstall.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"description": "Uninstall a skill package",
|
||||
"options.global": "Uninstall from global directory (~/.spaceflow) and clean up editor links",
|
||||
"noName": "❌ Please specify the skill package name to uninstall",
|
||||
"uninstallFailed": "❌ Uninstall failed: {{error}}",
|
||||
"extensionDescription": "Uninstall installed plugins/skills",
|
||||
"uninstallingExtension": " Uninstalling extension: {{name}}",
|
||||
"targetDir": " Target directory: {{dir}}",
|
||||
"extensionUninstallFailed": " Warning: Extension uninstall failed: {{name}}",
|
||||
"uninstallingGlobal": "🗑️ Uninstalling skill package: {{name}} (global)",
|
||||
"uninstalling": "🗑️ Uninstalling skill package: {{name}}",
|
||||
"foundDependency": " Found dependency: {{key}} -> {{value}}",
|
||||
"notRegistered": "Skill package \"{{name}}\" is not registered in config",
|
||||
"uninstallDone": "✅ Uninstall complete",
|
||||
"deletingSkill": " Deleting: skills/{{entry}}",
|
||||
"deletingCommand": " Deleting: commands/{{entry}}",
|
||||
"configUpdated": " Config updated: {{path}}"
|
||||
}
|
||||
28
cli/src/locales/en/update.json
Normal file
28
cli/src/locales/en/update.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"description": "Update dependencies (latest npm version or git pull)",
|
||||
"options.self": "Update spaceflow CLI itself",
|
||||
"updateFailed": "❌ Update failed: {{error}}",
|
||||
"extensionDescription": "Update dependencies (latest npm version or git pull)",
|
||||
"cannotGetLatest": " ⚠️ Cannot get latest version of {{package}}",
|
||||
"alreadyLatest": " ✓ {{package}} is already up to date ({{version}})",
|
||||
"versionChange": " {{package}}: {{current}} -> {{latest}}",
|
||||
"unknown": "unknown",
|
||||
"npmUpdateFailed": " ❌ Failed to update {{package}}:",
|
||||
"notGitRepo": " ⚠️ {{name}} is not a git repository (.git may have been removed)",
|
||||
"reinstallHint": " Hint: Use spaceflow install <source> to reinstall",
|
||||
"pullingLatest": " Pulling latest code...",
|
||||
"gitUpdateFailed": " ❌ Failed to update {{name}}:",
|
||||
"updatingCli": "🔄 Updating spaceflow CLI...",
|
||||
"installMethod": " Install method: {{method}} ({{pm}})",
|
||||
"installGlobal": "global",
|
||||
"installLocal": "local",
|
||||
"executing": " Executing: {{cmd}}",
|
||||
"cliUpdateDone": "✅ CLI update complete",
|
||||
"cliUpdateFailed": "❌ CLI update failed:",
|
||||
"depNotFound": "❌ Dependency not found: {{name}}",
|
||||
"updating": "📦 Updating: {{name}}",
|
||||
"localNoUpdate": " ✓ Local path does not need updating",
|
||||
"noDeps": "📦 No dependencies found",
|
||||
"updatingAll": "🔄 Updating all dependencies ({{count}})...",
|
||||
"updateComplete": "✅ Update complete: {{success}} succeeded, {{fail}} failed"
|
||||
}
|
||||
132
cli/src/locales/index.ts
Normal file
132
cli/src/locales/index.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { addLocaleResources } from "@spaceflow/core";
|
||||
import buildZhCN from "./zh-cn/build.json";
|
||||
import buildEn from "./en/build.json";
|
||||
import clearZhCN from "./zh-cn/clear.json";
|
||||
import clearEn from "./en/clear.json";
|
||||
import commitZhCN from "./zh-cn/commit.json";
|
||||
import commitEn from "./en/commit.json";
|
||||
import createZhCN from "./zh-cn/create.json";
|
||||
import createEn from "./en/create.json";
|
||||
import devZhCN from "./zh-cn/dev.json";
|
||||
import devEn from "./en/dev.json";
|
||||
import installZhCN from "./zh-cn/install.json";
|
||||
import installEn from "./en/install.json";
|
||||
import listZhCN from "./zh-cn/list.json";
|
||||
import listEn from "./en/list.json";
|
||||
import mcpZhCN from "./zh-cn/mcp.json";
|
||||
import mcpEn from "./en/mcp.json";
|
||||
import runxZhCN from "./zh-cn/runx.json";
|
||||
import runxEn from "./en/runx.json";
|
||||
import schemaZhCN from "./zh-cn/schema.json";
|
||||
import schemaEn from "./en/schema.json";
|
||||
import setupZhCN from "./zh-cn/setup.json";
|
||||
import setupEn from "./en/setup.json";
|
||||
import uninstallZhCN from "./zh-cn/uninstall.json";
|
||||
import uninstallEn from "./en/uninstall.json";
|
||||
import updateZhCN from "./zh-cn/update.json";
|
||||
import updateEn from "./en/update.json";
|
||||
|
||||
type LocaleResource = Record<string, Record<string, string>>;
|
||||
|
||||
/** build 命令 i18n 资源 */
|
||||
export const buildLocales: LocaleResource = {
|
||||
"zh-CN": buildZhCN,
|
||||
en: buildEn,
|
||||
};
|
||||
|
||||
/** clear 命令 i18n 资源 */
|
||||
export const clearLocales: LocaleResource = {
|
||||
"zh-CN": clearZhCN,
|
||||
en: clearEn,
|
||||
};
|
||||
|
||||
/** commit 命令 i18n 资源 */
|
||||
export const commitLocales: LocaleResource = {
|
||||
"zh-CN": commitZhCN,
|
||||
en: commitEn,
|
||||
};
|
||||
|
||||
/** create 命令 i18n 资源 */
|
||||
export const createLocales: LocaleResource = {
|
||||
"zh-CN": createZhCN,
|
||||
en: createEn,
|
||||
};
|
||||
|
||||
/** dev 命令 i18n 资源 */
|
||||
export const devLocales: LocaleResource = {
|
||||
"zh-CN": devZhCN,
|
||||
en: devEn,
|
||||
};
|
||||
|
||||
/** install 命令 i18n 资源 */
|
||||
export const installLocales: LocaleResource = {
|
||||
"zh-CN": installZhCN,
|
||||
en: installEn,
|
||||
};
|
||||
|
||||
/** list 命令 i18n 资源 */
|
||||
export const listLocales: LocaleResource = {
|
||||
"zh-CN": listZhCN,
|
||||
en: listEn,
|
||||
};
|
||||
|
||||
/** mcp 命令 i18n 资源 */
|
||||
export const mcpLocales: LocaleResource = {
|
||||
"zh-CN": mcpZhCN,
|
||||
en: mcpEn,
|
||||
};
|
||||
|
||||
/** runx 命令 i18n 资源 */
|
||||
export const runxLocales: LocaleResource = {
|
||||
"zh-CN": runxZhCN,
|
||||
en: runxEn,
|
||||
};
|
||||
|
||||
/** schema 命令 i18n 资源 */
|
||||
export const schemaLocales: LocaleResource = {
|
||||
"zh-CN": schemaZhCN,
|
||||
en: schemaEn,
|
||||
};
|
||||
|
||||
/** setup 命令 i18n 资源 */
|
||||
export const setupLocales: LocaleResource = {
|
||||
"zh-CN": setupZhCN,
|
||||
en: setupEn,
|
||||
};
|
||||
|
||||
/** uninstall 命令 i18n 资源 */
|
||||
export const uninstallLocales: LocaleResource = {
|
||||
"zh-CN": uninstallZhCN,
|
||||
en: uninstallEn,
|
||||
};
|
||||
|
||||
/** update 命令 i18n 资源 */
|
||||
export const updateLocales: LocaleResource = {
|
||||
"zh-CN": updateZhCN,
|
||||
en: updateEn,
|
||||
};
|
||||
|
||||
/** 所有内部命令 i18n 资源映射 */
|
||||
const allLocales: Record<string, LocaleResource> = {
|
||||
build: buildLocales,
|
||||
clear: clearLocales,
|
||||
commit: commitLocales,
|
||||
create: createLocales,
|
||||
dev: devLocales,
|
||||
install: installLocales,
|
||||
list: listLocales,
|
||||
mcp: mcpLocales,
|
||||
runx: runxLocales,
|
||||
schema: schemaLocales,
|
||||
setup: setupLocales,
|
||||
uninstall: uninstallLocales,
|
||||
update: updateLocales,
|
||||
};
|
||||
|
||||
/**
|
||||
* 立即注册所有内部命令的 i18n 资源
|
||||
* 确保 @Command 装饰器中的 t() 在模块 import 时即可获取翻译
|
||||
*/
|
||||
for (const [ns, resources] of Object.entries(allLocales)) {
|
||||
addLocaleResources(ns, resources);
|
||||
}
|
||||
22
cli/src/locales/zh-cn/build.json
Normal file
22
cli/src/locales/zh-cn/build.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"description": "构建 skills 目录下的插件包",
|
||||
"options.watch": "监听文件变化并自动重新构建",
|
||||
"buildFailed": "❌ 构建失败: {{error}}",
|
||||
"extensionDescription": "构建插件/技能",
|
||||
"noPlugins": "📦 没有找到可构建的插件",
|
||||
"startBuilding": "📦 开始构建 {{count}} 个插件...",
|
||||
"buildComplete": "✅ 构建完成: {{success}} 成功, {{fail}} 失败",
|
||||
"startWatching": "👀 开始监听 {{count}} 个插件...",
|
||||
"stopWatching": "🛑 停止监听: {{name}}",
|
||||
"building": "🔨 构建: {{name}}",
|
||||
"buildSuccess": " ✅ 完成 ({{duration}}ms)",
|
||||
"buildFailedWithDuration": " ❌ 失败 ({{duration}}ms)",
|
||||
"buildFailedWithMessage": " ❌ 失败 ({{duration}}ms): {{message}}",
|
||||
"buildWarnings": " ⚠️ 完成 ({{duration}}ms) - {{count}} 警告",
|
||||
"watching": "👀 监听: {{name}}",
|
||||
"watchError": " ❌ [{{name}}] 错误: {{message}}",
|
||||
"watchBuildFailed": " ❌ [{{name}}] 构建失败",
|
||||
"watchBuildWarnings": " ⚠️ [{{name}}] 构建完成 - {{count}} 警告",
|
||||
"watchBuildSuccess": " ✅ [{{name}}] 构建完成",
|
||||
"watchInitFailed": " ❌ [{{name}}] 初始化失败: {{message}}"
|
||||
}
|
||||
16
cli/src/locales/zh-cn/clear.json
Normal file
16
cli/src/locales/zh-cn/clear.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "清理所有安装的技能包",
|
||||
"options.global": "清理全局目录 (~/.spaceflow) 的安装内容",
|
||||
"clearFailed": "❌ 清理失败: {{error}}",
|
||||
"extensionDescription": "清理缓存和临时文件",
|
||||
"clearingGlobal": "🧹 清理所有技能包 (全局)",
|
||||
"clearing": "🧹 清理所有技能包",
|
||||
"clearDone": "✅ 清理完成",
|
||||
"spaceflowNotExist": " .spaceflow 不存在,跳过",
|
||||
"spaceflowNoClean": " .spaceflow 无需清理",
|
||||
"clearingSpaceflow": " 清理 .spaceflow ({{count}} 项,保留配置文件)",
|
||||
"deleted": " 删除: {{entry}}",
|
||||
"deleteFailed": " 警告: 删除 {{entry}} 失败:",
|
||||
"clearingSkills": " 清理 {{editor}}/skills ({{count}} 项)",
|
||||
"clearingCommands": " 清理 {{editor}}/commands ({{count}} 个 .md 文件)"
|
||||
}
|
||||
45
cli/src/locales/zh-cn/commit.json
Normal file
45
cli/src/locales/zh-cn/commit.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"description": "基于暂存区变更自动生成规范的 commit message 并提交",
|
||||
"options.dryRun": "仅生成 commit message,不执行提交",
|
||||
"options.noVerify": "跳过 pre-commit 和 commit-msg hooks",
|
||||
"options.split": "按包/逻辑/业务拆分为多个 commit",
|
||||
"dryRunMode": "\n🔍 Dry Run 模式",
|
||||
"splitSuccess": "\n✅ 成功拆分为 {{count}} 个 commit",
|
||||
"commitSuccess": "\n✅ 提交成功",
|
||||
"commitFailed": "\n❌ 提交失败: {{error}}",
|
||||
"extensionDescription": "智能 commit 命令,使用 LLM 生成 commit message",
|
||||
"getDiffFailed": "获取暂存区 diff 失败,请确保在 git 仓库中运行",
|
||||
"noFilesToCommit": "没有文件需要提交",
|
||||
"noChanges": "没有变更内容",
|
||||
"generatingMessage": "📝 正在使用 AI 生成 commit message...",
|
||||
"strategyRules": "自定义规则",
|
||||
"strategyRulesFirst": "规则优先",
|
||||
"strategyPackage": "包目录",
|
||||
"groupingByStrategy": "🔍 正在按 {{strategy}} 策略分组文件...",
|
||||
"detectedGroups": "📦 检测到 {{count}} 个分组的变更",
|
||||
"scopeChanges": "{{scope}} 的变更",
|
||||
"rootChanges": "根目录变更",
|
||||
"otherChanges": "其他变更",
|
||||
"allChanges": "所有变更",
|
||||
"analyzingSplit": "🔍 正在分析包内如何拆分 commit...",
|
||||
"dryRunMessage": "[Dry Run] 将提交以下 message:\n{{message}}",
|
||||
"commitFail": "commit 失败",
|
||||
"stageFilesFailed": "暂存文件失败: {{error}}",
|
||||
"noWorkingChanges": "没有工作区改动",
|
||||
"noStagedFiles": "没有暂存的文件",
|
||||
"singleCommit": "📦 变更无需拆分,将作为单个 commit 提交",
|
||||
"generatedMessage": "\n📋 生成的 commit message:",
|
||||
"splitIntoCommits": "\n📦 将拆分为 {{count}} 个 commit",
|
||||
"groupItem": " {{index}}. {{reason}}{{pkg}} ({{count}} 个文件)",
|
||||
"parallelGenerating": "\n🚀 并行生成 {{count}} 个 commit message...",
|
||||
"allMessagesGenerated": "✅ 所有 commit message 生成完成",
|
||||
"generateMessageFailed": "生成 commit message 失败: {{errors}}",
|
||||
"resetStagingFailed": "重置暂存区失败",
|
||||
"committingGroup": "\n📝 正在提交第 {{current}}/{{total}} 组: {{reason}}{{pkg}}",
|
||||
"skippingNoChanges": "⏭️ 跳过: 没有实际变更",
|
||||
"commitMessage": "📋 Commit message:",
|
||||
"commitItemFailed": "❌ Commit {{index}} 失败: {{error}}",
|
||||
"commitItemFailedDetail": "第 {{index}} 个 commit 失败: {{error}}\n\n已完成的提交:\n{{committed}}",
|
||||
"noChangesAll": "没有任何改动,请先修改文件或使用 git add 添加文件",
|
||||
"noStagedFilesHint": "没有暂存的文件,请先使用 git add 添加文件"
|
||||
}
|
||||
27
cli/src/locales/zh-cn/create.json
Normal file
27
cli/src/locales/zh-cn/create.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"description": "基于模板创建新的插件",
|
||||
"options.directory": "指定创建目录",
|
||||
"options.list": "列出可用的模板",
|
||||
"options.from": "使用远程 Git 仓库作为模板源",
|
||||
"options.ref": "指定 Git 分支或标签(默认: main)",
|
||||
"noName": "❌ 请指定名称",
|
||||
"usage": "用法: spaceflow create <template> <name>",
|
||||
"createFailed": "❌ 创建失败: {{error}}",
|
||||
"availableTemplates": "可用模板:",
|
||||
"extensionDescription": "创建新的插件/技能模板",
|
||||
"updatingRepo": "🔄 更新模板仓库: {{url}}",
|
||||
"updateFailed": "⚠️ 更新失败,使用缓存版本",
|
||||
"cloningRepo": "📥 克隆模板仓库: {{url}}",
|
||||
"cloneFailed": "克隆仓库失败: {{error}}",
|
||||
"repoReady": "✅ 模板仓库就绪: {{dir}}",
|
||||
"templatesDirNotFound": "找不到模板目录",
|
||||
"creatingPlugin": "📦 创建 {{template}} 插件: {{name}}",
|
||||
"targetDir": " 目录: {{dir}}",
|
||||
"dirExists": "目录已存在: {{dir}}",
|
||||
"templateNotFound": "模板 \"{{template}}\" 不存在。可用模板: {{available}}",
|
||||
"noTemplatesAvailable": "无",
|
||||
"pluginCreated": "✅ {{template}} 插件创建完成: {{name}}",
|
||||
"nextSteps": "下一步:",
|
||||
"fileCreated": " ✓ 创建 {{file}}",
|
||||
"fileCopied": " ✓ 复制 {{file}}"
|
||||
}
|
||||
5
cli/src/locales/zh-cn/dev.json
Normal file
5
cli/src/locales/zh-cn/dev.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"description": "开发模式 - 监听 skills 目录下的插件包并自动重新构建",
|
||||
"startFailed": "❌ 开发模式启动失败: {{error}}",
|
||||
"extensionDescription": "启动插件开发模式(监听文件变化并自动重新构建)"
|
||||
}
|
||||
71
cli/src/locales/zh-cn/install.json
Normal file
71
cli/src/locales/zh-cn/install.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"description": "安装技能包(支持 npm 包和 git 仓库),不带参数时更新配置文件中的所有 skills",
|
||||
"options.name": "指定技能包名称(默认从 source 自动提取)",
|
||||
"options.global": "安装到全局目录 (~/.spaceflow) 并关联到全局编辑器目录",
|
||||
"options.ignoreErrors": "忽略安装错误,不退出进程",
|
||||
"globalNoSource": "❌ 全局安装必须指定 source 参数",
|
||||
"globalUsage": " 用法: spaceflow install <source> -g [--name <名称>]",
|
||||
"installFailed": "❌ 安装失败: {{error}}",
|
||||
"extensionDescription": "安装插件/技能到项目中",
|
||||
"envPlaceholder": "<请填写 {{key}}>",
|
||||
"registerMcp": " 注册 MCP Server: {{name}}",
|
||||
"mcpEnvHint": " ⚠️ 请在 {{path}} 中配置环境变量: {{vars}}",
|
||||
"installingExtension": "📦 安装 Extension: {{source}}",
|
||||
"installDone": "✅ 安装完成: {{name}}",
|
||||
"typeLocal": " 类型: 本地路径",
|
||||
"sourcePath": " 源路径: {{path}}",
|
||||
"typeGit": " 类型: git 仓库",
|
||||
"sourceUrl": " 源: {{url}}",
|
||||
"typeNpm": " 类型: npm 包",
|
||||
"targetDir": " 目标目录: {{dir}}",
|
||||
"extensionInstallFailed": "Extension 安装失败: {{source}}",
|
||||
"creatingDir": " 创建目录: {{dir}}",
|
||||
"dirExistsSkip": " 目录已存在,跳过克隆",
|
||||
"cloningRepo": " 克隆仓库...",
|
||||
"cloneFailed": "克隆仓库失败: {{error}}",
|
||||
"removingGit": " 移除 .git 目录...",
|
||||
"depsLinkExists": " deps 链接已存在,跳过",
|
||||
"depsExists": " deps/{{name}} 已存在,跳过",
|
||||
"createDepsLink": " 创建 deps 链接: {{dep}} -> {{source}}",
|
||||
"skillLinkExists": " skills/{{name}} 链接已存在,跳过",
|
||||
"createSkillLink": " 创建 skills 链接: skills/{{name}} -> {{target}}",
|
||||
"copySkill": " 复制 skills/{{name}}",
|
||||
"globalInstalling": "🔄 全局安装: {{name}} ({{dir}})",
|
||||
"pluginTypes": " 插件类型: {{types}}",
|
||||
"globalInstallDone": "\n✅ 全局安装完成",
|
||||
"updatingAll": "🔄 更新所有依赖...",
|
||||
"noDeps": " 配置文件中没有 dependencies",
|
||||
"foundDeps": " 找到 {{count}} 个依赖",
|
||||
"installingDeps": "\n📦 安装依赖...",
|
||||
"pmInstallFailed": " 警告: {{pm}} install 失败",
|
||||
"depNotInstalled": " ⚠️ {{name}} 未安装成功,跳过",
|
||||
"allExtensionsDone": "\n✅ 所有 Extension 更新完成",
|
||||
"updatedPackageJson": " ✓ 更新 .spaceflow/package.json",
|
||||
"symlinkExists": " ✓ 符号链接已存在",
|
||||
"createSymlink": " 创建符号链接: {{target}} -> {{source}}",
|
||||
"createSymlinkFailed": "创建符号链接失败: {{error}}",
|
||||
"pullFailed": " 警告: 拉取最新代码失败",
|
||||
"checkoutVersion": " 切换到版本: {{ref}}",
|
||||
"checkoutFailed": " 警告: 切换版本失败",
|
||||
"installingDepsEllipsis": " 安装依赖...",
|
||||
"depsInstallFailed": " 警告: 依赖安装失败",
|
||||
"depsInstalled": " ✓ 依赖已安装",
|
||||
"buildingPlugin": " 构建插件...",
|
||||
"buildFailed": " 警告: 构建失败",
|
||||
"buildExists": " ✓ 构建已存在",
|
||||
"repoExists": " 仓库已存在,更新...",
|
||||
"repoUpdateFailed": " 警告: 更新仓库失败",
|
||||
"cloneRepoFailed": " 警告: 克隆仓库失败",
|
||||
"skillMdExists": " ✓ SKILL.md 已存在",
|
||||
"skillMdGenerated": " ✓ 生成 SKILL.md",
|
||||
"skillMdFailed": " 警告: 生成 SKILL.md 失败",
|
||||
"commandMdGenerated": " ✓ 生成 commands/{{name}}.md",
|
||||
"commandMdFailed": " 警告: 生成 commands/{{name}}.md 失败",
|
||||
"configUpdated": " 更新配置文件: {{path}}",
|
||||
"configAlreadyExists": " 配置文件已包含该技能包",
|
||||
"depsUpToDate": " ✓ 依赖已是最新",
|
||||
"buildUpToDate": " ✓ 构建已是最新",
|
||||
"detailSection": "详细说明",
|
||||
"usageSection": "用法",
|
||||
"commandDefault": "{{name}} 命令"
|
||||
}
|
||||
8
cli/src/locales/zh-cn/list.json
Normal file
8
cli/src/locales/zh-cn/list.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"description": "列出已安装的技能包",
|
||||
"extensionDescription": "列出已安装的插件/技能",
|
||||
"noSkills": "📦 没有已安装的技能包",
|
||||
"installHint": "使用以下命令安装技能包:",
|
||||
"installedExtensions": "📦 已安装的扩展 ({{installed}}/{{total}}):",
|
||||
"commands": "命令: {{commands}}"
|
||||
}
|
||||
18
cli/src/locales/zh-cn/mcp.json
Normal file
18
cli/src/locales/zh-cn/mcp.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"description": "启动 MCP Server,提供所有已安装扩展的 MCP 工具",
|
||||
"options.inspector": "启动 MCP Inspector 进行交互式调试",
|
||||
"inspectorStarting": "🔍 启动 MCP Inspector...",
|
||||
"inspectorDebugCmd": " 调试命令: pnpm space mcp",
|
||||
"inspectorFailed": "❌ 启动 MCP Inspector 失败: {{error}}",
|
||||
"extensionDescription": "启动 MCP Server,提供所有已安装扩展的 MCP 工具",
|
||||
"scanning": "🔍 扫描已安装的扩展...",
|
||||
"foundExtensions": " 发现 {{count}} 个扩展",
|
||||
"checkingExport": " 检查 {{key}}: __mcp_server__={{hasMcpServer}}",
|
||||
"containerSuccess": " ✅ 从容器获取 {{key}} 实例成功",
|
||||
"containerFailed": " ⚠️ 从容器获取 {{key}} 失败: {{error}}",
|
||||
"loadToolsFailed": " ⚠️ {{name}}: 加载 MCP 工具失败",
|
||||
"noToolsFound": "❌ 没有找到任何 MCP 工具",
|
||||
"noToolsHint": " 提示: 确保已安装支持 MCP 的扩展,并导出 mcpService 或 getMcpTools",
|
||||
"toolsFound": "✅ 共发现 {{count}} 个 MCP 工具",
|
||||
"serverStarted": "🚀 MCP Server 已启动,共 {{count}} 个工具"
|
||||
}
|
||||
13
cli/src/locales/zh-cn/runx.json
Normal file
13
cli/src/locales/zh-cn/runx.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"description": "全局安装依赖并运行命令",
|
||||
"options.name": "指定命令名称(默认从 source 自动提取)",
|
||||
"noSource": "❌ 请指定要运行的依赖源",
|
||||
"usage": " 用法: spaceflow x <source> [args...]",
|
||||
"runFailed": "❌ 运行失败: {{error}}",
|
||||
"extensionDescription": "执行插件提供的脚本命令",
|
||||
"runningCommand": "▶️ 运行命令: {{command}}",
|
||||
"npxExitCode": "npx {{package}} 退出码: {{code}}",
|
||||
"commandNotInstalled": "命令 {{name}} 未安装",
|
||||
"commandNotBuilt": "命令 {{name}} 未构建,缺少 dist/index.js",
|
||||
"pluginNoExport": "插件 {{name}} 没有默认导出"
|
||||
}
|
||||
4
cli/src/locales/zh-cn/schema.json
Normal file
4
cli/src/locales/zh-cn/schema.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"description": "生成配置文件的 JSON Schema",
|
||||
"extensionDescription": "生成配置文件的 JSON Schema"
|
||||
}
|
||||
14
cli/src/locales/zh-cn/setup.json
Normal file
14
cli/src/locales/zh-cn/setup.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"description": "初始化 spaceflow 配置文件",
|
||||
"options.global": "生成全局配置到 ~/spaceflow/spaceflow.json(合并本地 .env 和 spaceflow.json)",
|
||||
"setupFailed": "❌ 初始化失败: {{error}}",
|
||||
"extensionDescription": "初始化 spaceflow 配置文件",
|
||||
"dirCreated": "✅ 已创建目录: {{dir}}",
|
||||
"configGenerated": "✅ 已生成配置文件: {{path}}",
|
||||
"configExists": "ℹ️ 配置文件已存在: {{path}}",
|
||||
"localConfigRead": "📖 已读取本地配置",
|
||||
"globalConfigGenerated": "✅ 已生成全局配置: {{path}}",
|
||||
"envConfigMerged": "📝 已合并环境变量配置:",
|
||||
"envRead": "📖 已读取环境变量: {{path}}",
|
||||
"envReadFailed": "⚠️ 无法读取 .env 文件: {{path}}"
|
||||
}
|
||||
18
cli/src/locales/zh-cn/uninstall.json
Normal file
18
cli/src/locales/zh-cn/uninstall.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"description": "卸载技能包",
|
||||
"options.global": "从全局目录 (~/.spaceflow) 卸载并清理各个编辑器的关联",
|
||||
"noName": "❌ 请指定要卸载的技能包名称",
|
||||
"uninstallFailed": "❌ 卸载失败: {{error}}",
|
||||
"extensionDescription": "卸载已安装的插件/技能",
|
||||
"uninstallingExtension": " 卸载 Extension: {{name}}",
|
||||
"targetDir": " 目标目录: {{dir}}",
|
||||
"extensionUninstallFailed": " 警告: Extension 卸载失败: {{name}}",
|
||||
"uninstallingGlobal": "🗑️ 卸载技能包: {{name}} (全局)",
|
||||
"uninstalling": "🗑️ 卸载技能包: {{name}}",
|
||||
"foundDependency": " 找到依赖: {{key}} -> {{value}}",
|
||||
"notRegistered": "技能包 \"{{name}}\" 未在配置文件中注册",
|
||||
"uninstallDone": "✅ 卸载完成",
|
||||
"deletingSkill": " 删除: skills/{{entry}}",
|
||||
"deletingCommand": " 删除: commands/{{entry}}",
|
||||
"configUpdated": " 更新配置文件: {{path}}"
|
||||
}
|
||||
28
cli/src/locales/zh-cn/update.json
Normal file
28
cli/src/locales/zh-cn/update.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"description": "更新依赖(npm 包获取最新版本,git 仓库拉取最新代码)",
|
||||
"options.self": "更新 spaceflow CLI 自身",
|
||||
"updateFailed": "❌ 更新失败: {{error}}",
|
||||
"extensionDescription": "更新依赖(npm 包获取最新版本,git 仓库拉取最新代码)",
|
||||
"cannotGetLatest": " ⚠️ 无法获取 {{package}} 的最新版本",
|
||||
"alreadyLatest": " ✓ {{package}} 已是最新版本 ({{version}})",
|
||||
"versionChange": " {{package}}: {{current}} -> {{latest}}",
|
||||
"unknown": "未知",
|
||||
"npmUpdateFailed": " ❌ 更新 {{package}} 失败:",
|
||||
"notGitRepo": " ⚠️ {{name}} 不是 git 仓库(可能已移除 .git)",
|
||||
"reinstallHint": " 提示: 使用 spaceflow install <source> 重新安装",
|
||||
"pullingLatest": " 拉取最新代码...",
|
||||
"gitUpdateFailed": " ❌ 更新 {{name}} 失败:",
|
||||
"updatingCli": "🔄 更新 spaceflow CLI...",
|
||||
"installMethod": " 安装方式: {{method}} ({{pm}})",
|
||||
"installGlobal": "全局",
|
||||
"installLocal": "本地",
|
||||
"executing": " 执行: {{cmd}}",
|
||||
"cliUpdateDone": "✅ CLI 更新完成",
|
||||
"cliUpdateFailed": "❌ CLI 更新失败:",
|
||||
"depNotFound": "❌ 未找到依赖: {{name}}",
|
||||
"updating": "📦 更新: {{name}}",
|
||||
"localNoUpdate": " ✓ 本地路径无需更新",
|
||||
"noDeps": "📦 没有找到依赖",
|
||||
"updatingAll": "🔄 更新所有依赖 ({{count}} 个)...",
|
||||
"updateComplete": "✅ 更新完成: {{success}} 成功, {{fail}} 失败"
|
||||
}
|
||||
23
cli/tsconfig.json
Normal file
23
cli/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
11
cli/vitest.config.ts
Normal file
11
cli/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
root: "src",
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["**/*.spec.ts"],
|
||||
passWithNoTests: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user