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