chore: 初始化仓库

This commit is contained in:
Lydanne
2026-02-15 22:02:21 +08:00
commit 08d011d63f
381 changed files with 87202 additions and 0 deletions

2
.gitconfig Normal file
View File

@@ -0,0 +1,2 @@
[submodule]
recurse = true

49
.github/workflows/pr-review-command.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: PR Review Command
on:
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
review:
# 仅在 PR 评论中触发,且评论以 /review 或 /ai-review 开头
if: |
github.event.issue.pull_request &&
(startsWith(github.event.comment.body, '/review') || startsWith(github.event.comment.body, '/ai-review'))
runs-on: ubuntu-node-24
steps:
- name: Checkout PR branch
uses: actions/checkout@v4
with:
ref: refs/pull/${{ github.event.issue.number }}/head
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set pnpm registry to npmmirror
run: |
pnpm config set registry https://registry.npmmirror.com
pnpm install -g @anthropic-ai/claude-code
pnpm install -g opencode-ai
- name: Run Review
uses: ./actions
env:
GIT_PROVIDER_TYPE: gitea
CLAUDE_CODE_BASE_URL: https://ark.cn-beijing.volces.com/api/coding
CLAUDE_CODE_AUTH_TOKEN: ${{ secrets.CLAUDE_CODE_AUTH_TOKEN }}
OPENAI_BASE_URL: https://ark.cn-beijing.volces.com/api/v3
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_MODEL: deepseek-v3-2-251201
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
from-comment: ${{ github.event.comment.body }}
dev-mode: "true"

51
.github/workflows/pr-review.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: PR Review
on:
pull_request:
types: [opened, synchronize, reopened, closed]
permissions:
contents: read # 仓库内容:只读
pull-requests: write # PR可写比如评论PR
issues: write # Issues可写
deployments: read # 部署:只读
id-token: write # ID Token可写
pages: write # Pages可写
packages: write # Packages可写
repository-projects: write # Repository Projects可写
security-events: write # Security Events可写
workflows: write # Workflows可写
jobs:
pr-review:
runs-on: ubuntu-node-24
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set pnpm registry to npmmirror
run: |
pnpm config set registry https://registry.npmmirror.com
pnpm install -g @anthropic-ai/claude-code
pnpm install -g opencode-ai
- name: Run Review
uses: ./actions
env:
GIT_PROVIDER_TYPE: gitea
CLAUDE_CODE_BASE_URL: https://ark.cn-beijing.volces.com/api/coding
CLAUDE_CODE_AUTH_TOKEN: ${{ secrets.CLAUDE_CODE_AUTH_TOKEN }}
OPENAI_BASE_URL: https://ark.cn-beijing.volces.com/api/v3
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_MODEL: deepseek-v3-2-251201
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
command: review
args: -vv -l openai
event-action: ${{ github.event.action }}
dev-mode: "true"

66
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: Publish
on:
push:
branches:
- main
workflow_dispatch:
inputs:
dry_run:
description: "Dry run mode (不实际执行)"
required: false
default: false
type: boolean
rehearsal:
description: "Rehearsal mode (执行 hooks 但不修改文件/git)"
required: false
default: false
type: boolean
env:
GIT_PROVIDER_TYPE: gitea
GITHUB_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
jobs:
publish:
runs-on: ubuntu-node-24
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.CI_GITEA_TOKEN }}
fetch-depth: 0
fetch-tags: true
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Git and NPM
run: |
git config user.name "GiteaActions"
git config user.email "GiteaActions@users.noreply.gitea.com"
echo "@spaceflow:registry=https://git.bjxgj.com/api/packages/xgj/npm/" >> .npmrc
echo "//git.bjxgj.com/api/packages/xgj/npm/:_authToken=${{ secrets.CI_GITEA_TOKEN }}" >> .npmrc
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run setup
- name: Install Extensions
run: pnpm spaceflow install
- name: Publish (Dry Run)
if: ${{ github.event.inputs.dry_run == 'true' }}
run: pnpm spaceflow publish --dry-run --ci
- name: Publish (Rehearsal)
if: ${{ github.event.inputs.rehearsal == 'true' && github.event.inputs.dry_run != 'true' }}
run: pnpm spaceflow publish --rehearsal --ci
- name: Publish
if: ${{ github.event.inputs.dry_run != 'true' && github.event.inputs.rehearsal != 'true' }}
run: pnpm spaceflow publish --ci

59
.github/workflows/test-actions.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Test Actions
on:
workflow_dispatch:
inputs:
command:
description: "Command to test"
required: true
type: choice
options:
- publish
- review
- ci-scripts
- ci-shell
args:
description: "Additional arguments for the command"
required: false
type: string
default: "--dry-run"
dev-mode:
description: "Enable development mode"
required: false
type: boolean
default: true
jobs:
test-action:
runs-on: ubuntu-node-20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set pnpm registry to npmmirror
run: |
pnpm config set registry https://registry.npmmirror.com
pnpm install -g @anthropic-ai/claude-code
pnpm install -g opencode-ai
- name: Test spaceflow action
uses: ./actions
env:
GIT_PROVIDER_TYPE: gitea
CLAUDE_CODE_BASE_URL: https://ark.cn-beijing.volces.com/api/coding
CLAUDE_CODE_AUTH_TOKEN: ${{ secrets.CLAUDE_CODE_AUTH_TOKEN }}
OPENAI_BASE_URL: https://ark.cn-beijing.volces.com/api/v3
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_MODEL: deepseek-v3-2-251201
# OPENAI_MODEL: doubao-seed-code-preview-251028
# OPENAI_MODEL: glm-4-7-251222
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
command: ${{ inputs.command }}
args: ${{ inputs.args }}
dev-mode: ${{ inputs.dev-mode }}

View File

@@ -0,0 +1,51 @@
name: Test Branch Protection
on:
workflow_dispatch:
jobs:
test-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Debug Tokens and Permissions
run: |
echo "=== Token Info ==="
echo "GITHUB_TOKEN length: ${#GITHUB_TOKEN}"
echo "GITHUB_TOKEN prefix: ${GITHUB_TOKEN:0:10}..."
echo ""
echo "=== Git Remote URL ==="
git remote -v
echo ""
echo "=== GitHub API User Info ==="
curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"https://api.github.com/user"
echo ""
echo ""
echo "=== Check Token Repo Permissions ==="
curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"https://api.github.com/repos/${{ github.repository }}" | grep -E '"permissions"|"admin"|"push"|"pull"'
echo ""
echo "=== Full Repo Info (permissions section) ==="
curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"https://api.github.com/repos/${{ github.repository }}" | jq '.permissions' 2>/dev/null || \
curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"https://api.github.com/repos/${{ github.repository }}" | grep -o '"permissions":{[^}]*}'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Write ci.md and commit
run: |
echo "Test commit at $(date)" >> ci.md
git add ci.md
git commit -m "test: branch protection test [no ci]"
git push

76
.github/workflows/test-command.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Run Spaceflow Command
on:
workflow_dispatch:
inputs:
command:
description: "Command to run (publish, review, ci-scripts, ci-shell, claude-config)"
required: true
type: choice
options:
- publish
- review
- ci-scripts
- ci-shell
- claude-config
args:
description: "Additional arguments for the command"
required: false
type: string
default: ""
dry-run:
description: "Run in dry-run mode"
required: false
type: boolean
default: true
jobs:
run-command:
runs-on: ubuntu-node-20
steps:
- name: Checkout
uses: actions/checkout@v4
# - name: Init submodules
# run: |
# git submodule sync --recursive
# git submodule update --init --recursive
- name: Print environment variables
run: |
echo "=== Environment Variables ==="
env | sort
echo "============================="
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install dependencies
run: |
pnpm config set registry https://registry.npmmirror.com
pnpm install -g @anthropic-ai/claude-code
pnpm install -g opencode-ai
pnpm install --frozen-lockfile
- name: Build all packages
run: pnpm run setup
- name: Install spaceflow plugins
run: pnpm spaceflow install --ignore-errors
- name: Run spaceflow command
env:
GIT_PROVIDER_TYPE: gitea
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CLAUDE_CODE_BASE_URL: https://ark.cn-beijing.volces.com/api/coding
CLAUDE_CODE_AUTH_TOKEN: ${{ secrets.CLAUDE_CODE_AUTH_TOKEN }}
OPENAI_BASE_URL: https://ark.cn-beijing.volces.com/api/v3
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_MODEL: deepseek-v3-2-251201
run: |
ARGS="${{ inputs.args }}"
if [ "${{ inputs.dry-run }}" = "true" ]; then
ARGS="$ARGS --dry-run"
fi
pnpm spaceflow ${{ inputs.command }} $ARGS

141
.gitignore vendored Normal file
View File

@@ -0,0 +1,141 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# npm config (may contain auth tokens)
.npmrc

4
.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
# 模板文件handlebars
templates/
dist/
.spaceflowrc

4
.spaceflow/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# Spaceflow Extension dependencies
node_modules/
pnpm-lock.yaml
config-schema.json

12
.spaceflow/package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "spaceflow-local",
"private": true,
"dependencies": {
"@spaceflow/ci-scripts": "link:../commands/ci-scripts",
"@spaceflow/ci-shell": "link:../commands/ci-shell",
"@spaceflow/core": "link:../core",
"@spaceflow/publish": "link:../commands/publish",
"@spaceflow/review": "link:../commands/review",
"review-spec.git": "git+ssh://git@git.bjxgj.com/xgj/review-spec.git"
}
}

View File

@@ -0,0 +1 @@
packages: []

48
.spaceflow/spaceflow.json Normal file
View File

@@ -0,0 +1,48 @@
{
"$schema": "./config-schema.json",
"review": {
"references": ["./references"],
"includes": ["*/**/*.ts", "!*/**/*.spec.*", "!*/**/*.config.*"],
"generateDescription": true,
"autoUpdatePrTitle": true,
"lineComments": true,
"verifyFixes": true,
"analyzeDeletions": false,
"deletionAnalysisMode": "open-code",
"concurrency": 1,
"retries": 3,
"retryDelay": 1000
},
"dependencies": {
"@spaceflow/ci-shell": "link:./commands/ci-shell",
"@spaceflow/ci-scripts": "link:./commands/ci-scripts",
"@spaceflow/review": "link:./commands/review",
"@spaceflow/publish": "link:./commands/publish"
},
"support": ["claudeCode"],
"publish": {
"monorepo": { "enabled": true, "propagateDeps": true },
"changelog": {
"preset": {
"type": [
{ "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": "其他修改" }
]
}
},
"npm": {
"publish": true,
"packageManager": "pnpm",
"tag": "latest",
"ignoreVersion": true,
"versionArgs": ["--workspaces false"]
},
"git": { "pushWhitelistUsernames": ["GiteaActions"] }
}
}

4
.spaceflowrc Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": ".spaceflow/config-schema.json",
"support": ["claudeCode", "windsurf"]
}

View File

@@ -0,0 +1,15 @@
# 自定义框架配置:匹配 @spaceflow/core 导出的 t() 函数
languageIds:
- typescript
# 匹配 t("key") 和 t("namespace:key") 调用
usageMatchRegex:
- "\\Wt\\(\\s*['\"`]({key})['\"`]"
# 提取模板
refactorTemplates:
- "t('$1')"
# 启用 namespace使用 : 作为分隔符
namespace: true
namespaceDelimiter: ":"

33
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,33 @@
{
"editor.formatOnSave": true,
"oxc.fmt.experimental": true,
"editor.defaultFormatter": "oxc.oxc-vscode",
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"cSpell.words": ["Gitea", "spaceflow"],
"typescript.preferences.preferTypeOnlyAutoImports": true,
"i18n-ally-next.sourceLanguage": "zh-cn",
"i18n-ally-next.displayLanguage": "zh-cn",
"i18n-ally-next.keystyle": "flat",
"i18n-ally-next.extract.keygenStrategy": "template",
"i18n-ally-next.extract.keygenTemplate": "{{package_dirname}}:",
"i18n-ally-next.namespace": true,
"i18n-ally-next.defaultNamespace": "translation",
"i18n-ally-next.dirStructure": "dir",
"i18n-ally-next.pathMatcher": "{locale}/{namespace}.{ext}",
"i18n-ally-next.extract.targetPickingStrategy": "auto",
"i18n-ally-next.annotationInPlace": true,
"i18n-ally-next.annotationInPlaceFullMatch": true,
"i18n-ally-next.annotationBrackets": ["`", "`"],
"i18n-ally-next.enabledFrameworks": [ "custom" ],
"i18n-ally-next.localesPaths": [
"cli/src/locales",
"core/src/locales",
"commands/ci-scripts/src/locales",
"commands/ci-shell/src/locales",
"commands/period-summary/src/locales",
"commands/publish/src/locales",
"commands/review/src/locales"
]
}

189
README.md Normal file
View File

@@ -0,0 +1,189 @@
# spaceflow
Spaceflow 的工作流系统,提供统一的 CI/CD 管理和 AI 代码审查能力。
## 功能特性
- **统一管理手动触发的 CI**:提供统一的 UI 系统管理手动触发的 CI 任务
- **可扩展的 UI 界面**支持命令行、Web 界面、飞书对话机器人等多种交互方式
- **可自定义的通知消息模板**:灵活配置通知消息格式
- **可扩展的通知方式**:支持多种通知渠道
- **按仓库隔离**:各仓库配置独立,互不干扰
- **PR 流程自动化**AI 审核、ESLint 检查、重复代码检查、自定义仓库脚本等
## 项目结构
```bash
spaceflow/
├── actions/ # GitHub Actions
├── core/ # 核心服务NestJS 应用)
│ └── src/
│ ├── commands/ # CLI 命令模块
│ │ ├── review/ # 代码审查
│ │ ├── publish/ # CI 发布
│ │ ├── ci-scripts/ # 自定义脚本执行
│ │ ├── ci-shell/ # Shell 命令执行
│ │ ├── claude-setup/ # Claude 配置
│ │ └── period-summary/ # 周期总结
│ └── shared/ # 共享模块
│ ├── feishu-sdk/ # 飞书 SDK
│ ├── git-sdk/ # Git 命令封装
│ ├── git-provider/ # Git Provider 适配器
│ ├── llm-proxy/ # LLM 统一代理
│ ├── review-spec/ # 审查规范管理
│ ├── review-report/ # 审查报告格式化
│ └── storage/ # 通用存储模块
├── .github/
│ └── workflows/ # GitHub Actions 工作流文件
└── spaceflow.json # 项目配置
```
## 核心命令
### review
基于 LLM 的自动化代码审查,支持 OpenAI、Claude 等多种 LLM 模式。
```bash
# 审查 PR
npx spaceflow review -p 123 -l openai
# 审查两个分支之间的差异
npx spaceflow review -b main --head feature/xxx -l openai
# 仅分析删除代码
npx spaceflow review -p 123 --deletion-only -l openai
```
详细文档:[Review 模块文档](core/src/commands/review/README.md)
### publish
自动化版本发布,基于 release-it 实现版本管理和变更日志生成。
### ci-scripts
执行仓库中的自定义脚本。
### ci-shell
执行自定义 Shell 命令。
### claude-setup
配置 Claude CLI 工具。
### period-summary
生成周期性工作总结,支持飞书消息推送。
## 配置
在项目根目录创建 `spaceflow.json` 配置文件:
```javascript
export default {
changelog: {
preset: {
type: [
{ type: "feat", section: "新特性" },
{ type: "fix", section: "修复BUG" },
// 更多配置...
],
},
},
review: {
claudeCode: {
baseUrl: process.env.CLAUDE_CODE_BASE_URL,
authToken: process.env.CLAUDE_CODE_AUTH_TOKEN,
model: process.env.CLAUDE_CODE_MODEL || "ark-code-latest",
},
openai: {
baseUrl: process.env.OPENAI_BASE_URL || "https://api.openai.com/v1",
apiKey: process.env.OPENAI_API_KEY,
model: process.env.OPENAI_MODEL || "gpt-4o",
},
includes: ["*/**/*.ts", "!*/**/*.spec.*", "!*/**/*.config.*"],
generateDescription: true,
lineComments: true,
verifyFixes: true,
analyzeDeletions: true,
concurrency: 10,
retries: 3,
},
/**
* 支持的编辑器列表,用于自动关联插件到对应的配置目录
* 可选值: "claudeCode" (.claude), "windsurf" (.windsurf), "cursor" (.cursor), "opencode" (.opencode)
* 默认值: ["claudeCode"]
*/
support: ["claudeCode", "windsurf", "cursor"],
};
```
## 插件系统
Spaceflow 支持将插件自动关联到多个编辑器的配置目录中。通过在 `spaceflow.json` 中配置 `support` 字段,你可以让安装的技能和命令同时支持多个 AI 编程工具。
### 支持的编辑器
- **claudeCode**: 关联到 `.claude/`
- **windsurf**: 关联到 `.windsurf/`
- **cursor**: 关联到 `.cursor/`
- **opencode**: 关联到 `.opencode/`
### 自动关联逻辑
当你运行 `spaceflow install` 时,系统会:
1. 下载/链接插件到 `.spaceflow/` 目录。
2. 根据 `support` 配置,在对应的编辑器目录下创建 `skills``commands` 的符号链接。
3. 如果是全局安装 (`-g`),则会关联到家目录下的对应编辑器目录(如 `~/.claude/`)。
## 开发
### 安装依赖
```bash
pnpm install
```
### 构建
```bash
pnpm build
```
### 运行测试
```bash
pnpm test
```
### 代码检查
```bash
pnpm lint
```
### 代码格式化
```bash
pnpm format
```
## GitHub Actions 工作流
项目包含多个预配置的 GitHub Actions 工作流:
- `pr-review.yml`:自动 PR AI 审查
- `pr-review-command.yml`:手动触发 PR 审查
- `core-command.yml`:运行任意 spaceflow 命令
- `actions-test.yml`Actions 测试
## Git Flow
参考飞书文档
## 许可证
UNLICENSED

13
actions/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
# Dependencies
node_modules/
# Build output
!dist/
# IDE
.idea/
*.swp
*.swo
# OS
.DS_Store

48
actions/action.yml Normal file
View File

@@ -0,0 +1,48 @@
name: "Spaceflow"
description: "Run spaceflow CLI commands in CI workflows"
author: "spaceflow"
inputs:
command:
description: "The spaceflow command to run (e.g. review, publish, ci-scripts, ci-shell, or get-output for extracting cached values)"
required: true
args:
description: "Additional arguments to pass to the command"
required: false
default: ""
from-comment:
description: "Parse command from PR comment (e.g. '/review -v 2 -l openai')"
required: false
default: ""
provider-url:
description: "Git Provider server URL (e.g. https://api.github.com)"
required: false
provider-token:
description: "Git Provider API token"
required: false
working-directory:
description: "Working directory for the command"
required: false
default: "."
dev-mode:
description: "Enable development mode (install deps and use nest to run)"
required: false
default: "false"
event-action:
description: "PR event action (opened, synchronize, closed, etc.)"
required: false
default: ""
outputs:
result:
description: "JSON string of all command outputs"
value:
description: "Extracted value from JSON (for get-output command)"
runs:
using: "node20"
main: "dist/index.js"
branding:
icon: "git-pull-request"
color: "green"

28063
actions/dist/index.js vendored Normal file

File diff suppressed because one or more lines are too long

1
actions/dist/index.js.map vendored Normal file

File diff suppressed because one or more lines are too long

131
actions/dist/licenses.txt vendored Normal file
View File

@@ -0,0 +1,131 @@
@actions/core
MIT
The MIT License (MIT)
Copyright 2019 GitHub
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@actions/exec
MIT
The MIT License (MIT)
Copyright 2019 GitHub
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@actions/http-client
MIT
Actions Http Client for Node.js
Copyright (c) GitHub, Inc.
All rights reserved.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@actions/io
MIT
The MIT License (MIT)
Copyright 2019 GitHub
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@fastify/busboy
MIT
Copyright Brian White. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
tunnel
MIT
The MIT License (MIT)
Copyright (c) 2012 Koichi Kobayashi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
undici
MIT
MIT License
Copyright (c) Matteo Collina and Undici contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1
actions/dist/sourcemap-register.js vendored Normal file

File diff suppressed because one or more lines are too long

19
actions/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "spaceflow-actions",
"version": "0.0.0",
"private": true,
"description": "Spaceflow Actions - GitHub/Gitea Actions for CI workflows",
"license": "UNLICENSED",
"main": "dist/index.js",
"scripts": {
"build": "ncc build src/index.js -o dist --source-map --license licenses.txt"
},
"dependencies": {
"@actions/cache": "^5.0.1",
"@actions/core": "^2.0.1",
"@actions/exec": "^2.0.0"
},
"devDependencies": {
"@vercel/ncc": "^0.38.3"
}
}

260
actions/src/index.js Normal file
View File

@@ -0,0 +1,260 @@
const core = require("@actions/core");
const exec = require("@actions/exec");
const path = require("path");
const fs = require("fs");
const os = require("os");
const OUTPUT_MARKER_START = "::spaceflow-output::";
const OUTPUT_MARKER_END = "::end::";
const CACHE_DIR = path.join(os.tmpdir(), "spaceflow-outputs");
/**
* Parse spaceflow output from stdout
* Format: ::spaceflow-output::{"key":"value"}::end::
*/
function parseOutputs(stdout) {
const outputs = {};
const regex = new RegExp(
`${OUTPUT_MARKER_START.replace(/:/g, "\\:")}(.+?)${OUTPUT_MARKER_END.replace(/:/g, "\\:")}`,
"g",
);
let match;
while ((match = regex.exec(stdout)) !== null) {
try {
const parsed = JSON.parse(match[1]);
Object.assign(outputs, parsed);
} catch {
core.warning(`Failed to parse output: ${match[1]}`);
}
}
return outputs;
}
/**
* Get value from object by path (e.g. "data.name" or "version")
*/
function getByPath(obj, pathStr) {
const parts = pathStr.split(".");
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
current = current[part];
}
return current;
}
/**
* Read outputs from cache file by cacheId
*/
function readFromCache(cacheId) {
const cacheFile = path.join(CACHE_DIR, `${cacheId}.json`);
if (!fs.existsSync(cacheFile)) {
return null;
}
try {
const content = fs.readFileSync(cacheFile, "utf-8");
return JSON.parse(content);
} catch {
return null;
}
}
/**
* Handle get-output command - extract value from cache by cacheId and path
* Usage: get-output --cache-id <uuid> --path <key>
*/
function handleGetOutput(argsStr) {
const args = argsStr.split(/\s+/).filter(Boolean);
let cacheId = "";
let jsonPath = "";
for (let i = 0; i < args.length; i++) {
if ((args[i] === "--cache-id" || args[i] === "-c") && i + 1 < args.length) {
cacheId = args[++i];
} else if ((args[i] === "--path" || args[i] === "-p") && i + 1 < args.length) {
jsonPath = args[++i];
}
}
if (!cacheId) {
core.setFailed("Missing --cache-id argument");
return;
}
if (!jsonPath) {
core.setFailed("Missing --path argument");
return;
}
const cached = readFromCache(cacheId);
if (!cached) {
core.setFailed(`Cache not found for id: ${cacheId}`);
return;
}
const value = getByPath(cached, jsonPath);
if (value === undefined) {
core.setFailed(`Path "${jsonPath}" not found in cached outputs`);
return;
}
const outputValue = typeof value === "object" ? JSON.stringify(value) : String(value);
core.setOutput("value", outputValue);
core.info(`Extracted value: ${outputValue}`);
}
/**
* Parse command from PR comment
* Format: /review [-v <level>] [-l <mode>] [--verify-fixes] ...
* Returns: { command: string, args: string }
*/
function parseFromComment(comment) {
const trimmed = comment.trim();
// 匹配 /command 格式
const match = trimmed.match(/^\/(\S+)\s*(.*)?$/);
if (!match) {
return null;
}
return {
command: match[1],
args: (match[2] || "").trim(),
};
}
async function run() {
try {
let command = core.getInput("command", { required: false });
let args = core.getInput("args");
const fromComment = core.getInput("from-comment");
// 如果提供了 from-comment从评论中解析命令和参数
if (fromComment) {
const parsed = parseFromComment(fromComment);
if (parsed) {
command = parsed.command;
args = parsed.args;
core.info(`📝 从评论解析: command=${command}, args=${args}`);
} else {
core.setFailed(`无法解析评论指令: ${fromComment}`);
return;
}
}
if (!command) {
core.setFailed("Missing command input");
return;
}
// Handle get-output command separately (no CLI execution needed)
if (command === "get-output") {
handleGetOutput(args);
return;
}
// 对于 review 命令,自动添加 --event-action 参数
const eventAction = core.getInput("event-action");
if (command === "review" && eventAction && !args.includes("--event-action")) {
args = args ? `${args} --event-action=${eventAction}` : `--event-action=${eventAction}`;
core.info(` PR 事件类型: ${eventAction}`);
}
const workingDirectory = core.getInput("working-directory") || ".";
const devMode = core.getInput("dev-mode") === "true";
// Get Git Provider server url and token from input or environment variables
const providerUrl = core.getInput("provider-url") || process.env.GIT_PROVIDER_URL || process.env.GITHUB_SERVER_URL || "";
const providerToken =
core.getInput("provider-token") || process.env.GIT_PROVIDER_TOKEN || process.env.GITHUB_TOKEN || "";
// Set environment variables for CLI to use
if (providerUrl) {
core.exportVariable("GIT_PROVIDER_URL", providerUrl);
}
if (providerToken) {
core.exportVariable("GIT_PROVIDER_TOKEN", providerToken);
core.setSecret(providerToken);
}
// Resolve core path - core/ is a sibling directory to actions/ in the repo
const actionsDir = path.resolve(__dirname, "..");
const repoRoot = path.resolve(actionsDir, "..");
// const corePath = path.resolve(repoRoot, "core");
// core.info(`Core path: ${corePath}`);
core.info(`Dev mode: ${devMode}`);
let execCmd;
let cmdArgs;
let execCwd;
if (devMode) {
// Development mode: install deps, build all, then install plugins
core.info("Installing dependencies...");
await exec.exec("pnpm", ["install"], { cwd: repoRoot });
core.info("Building all packages...");
await exec.exec("pnpm", ["run", "setup"], { cwd: repoRoot });
core.info("Installing spaceflow plugins...");
await exec.exec("pnpm", ["spaceflow", "install"], { cwd: repoRoot });
// Run the command
execCmd = "pnpm";
cmdArgs = ["spaceflow", command];
if (args) {
cmdArgs.push(...args.split(/\s+/).filter(Boolean));
}
cmdArgs.push("--ci");
execCwd = repoRoot;
} else {
// Production mode: use npx to install and run from local path
execCmd = "npx";
cmdArgs = ["-y", "spaceflow", command];
if (args) {
cmdArgs.push(...args.split(/\s+/).filter(Boolean));
}
cmdArgs.push("--ci");
execCwd = workingDirectory;
}
core.info(`Running: ${execCmd} ${cmdArgs.join(" ")}`);
core.info(`Working directory: ${execCwd}`);
core.info(`Command: ${command}`);
core.info(`Args: ${args}`);
// Capture stdout to parse outputs
let stdout = "";
const exitCode = await exec.exec(execCmd, cmdArgs, {
cwd: execCwd,
env: {
...process.env,
GITHUB_REPOSITORY: process.env.GITHUB_REPOSITORY || "",
GITHUB_REF_NAME: process.env.GITHUB_REF_NAME || "",
GITHUB_EVENT_PATH: process.env.GITHUB_EVENT_PATH || "",
},
listeners: {
stdout: (data) => {
stdout += data.toString();
},
},
});
// Parse and set outputs
const outputs = parseOutputs(stdout);
for (const [key, value] of Object.entries(outputs)) {
core.setOutput(key, value);
core.info(`Output: ${key}=${value}`);
}
if (exitCode !== 0) {
core.setFailed(`Command failed with exit code ${exitCode}`);
}
} catch (error) {
core.setFailed(error.message || "An unexpected error occurred");
}
}
run();

1
ci.md Normal file
View File

@@ -0,0 +1 @@
Test commit at Wed Jan 28 07:07:00 UTC 2026

94
cli/CHANGELOG.md Normal file
View 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
View 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
View 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
View 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
View 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);
});

View 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;
}
}

View 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 {}

View 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,
},
);
}
}

View 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";

View 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;
}
}

View 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 {}

View 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,
);
}
}
}
}
}
}
}

View 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";

View 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;
}
}

View 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;
}

View 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 {}

View 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) };
}
}
}

View 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;
}
}

View 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");
}
}

View 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 {}

View 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();
}
}

View 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";

View 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;
}
}

View 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 {}

View 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";

View 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";

View 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;
}
}

View 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 {}

File diff suppressed because it is too large Load Diff

View 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";

View 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;
}
}

View 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 {}

View 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 {};
}
}
}

View 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";

View 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;
}
}

View 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 {}

View 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;
}
}

View 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";

View 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;
}
}

View 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 {}

View 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;
}
}

View 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,
};
}

View 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";

View 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();
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { SchemaCommand } from "./schema.command";
@Module({
providers: [SchemaCommand],
})
export class SchemaModule {}

View 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";

View 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;
}
}

View 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 {}

View 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));
}
}

View 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";

View 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;
}
}

View 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 {}

View 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) }));
}
}
}

View 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";

View 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;
}
}

View 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 {}

View 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 }));
}
}

View 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();
}
}

View File

@@ -0,0 +1 @@
export * from "./extension-loader.service";

View 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(),
];

View 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}}"
}

View 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)"
}

View 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"
}

View 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}}"
}

View 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)"
}

View 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"
}

View 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}}"
}

View 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"
}

View 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"
}

View File

@@ -0,0 +1,4 @@
{
"description": "Generate JSON Schema for configuration files",
"extensionDescription": "Generate JSON Schema for configuration files"
}

View 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}}"
}

View 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}}"
}

View 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
View 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);
}

Some files were not shown because too many files have changed in this diff Show More