mirror of
https://gitee.com/mirrors/AllinSSL.git
synced 2026-03-18 04:02:02 +08:00
【调整】增加部署雨云
This commit is contained in:
91
frontend/plugin/vite-plugin-turborepo-deploy/.gitignore
vendored
Normal file
91
frontend/plugin/vite-plugin-turborepo-deploy/.gitignore
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-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
|
||||
|
||||
# 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
|
||||
|
||||
# IDE files
|
||||
.idea
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Vite
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Vitest
|
||||
/coverage
|
||||
136
frontend/plugin/vite-plugin-turborepo-deploy/CHANGELOG.md
Normal file
136
frontend/plugin/vite-plugin-turborepo-deploy/CHANGELOG.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# 更新日志
|
||||
|
||||
## [3.1.0] - 2023-XX-XX
|
||||
|
||||
### 新增
|
||||
|
||||
- **本地同步目标路径增强**:
|
||||
- `LocalSyncConfig.target` 现在支持字符串数组,可以将一个源路径同步到多个目标路径
|
||||
- 添加了文件分发功能,一次配置可以将文件复制到多个目标
|
||||
- 优化了目标路径配置验证,支持数组格式
|
||||
- 更新了相关文档和示例
|
||||
|
||||
- **Git项目管理增强**:
|
||||
- 添加了 `GitProjectConfig.discardChanges` 选项,允许自动丢弃未提交的更改
|
||||
- 当设置为 `true` 时,会在拉取前自动执行 `git checkout -- .` 和 `git clean -fd`
|
||||
- 增强了错误处理,可以处理因未提交更改导致的拉取失败
|
||||
- 添加了相关日志,提供更清晰的操作过程
|
||||
|
||||
### 改进
|
||||
|
||||
- 优化了本地同步功能的代码结构,将目标路径统一处理为数组
|
||||
- 更新了 README 文档,添加了使用 target 数组的示例和 discardChanges 选项的说明
|
||||
- 完善了类型定义,提供更好的 TypeScript 支持
|
||||
- 改进了 Git 项目管理的错误处理,提供更友好的错误信息和恢复机制
|
||||
|
||||
## [3.0.0] - 2023-XX-XX
|
||||
|
||||
### 重大变更
|
||||
|
||||
- **Git 项目存储路径统一化**:
|
||||
- 所有 Git 项目都集中存放在工作区根目录的 `.sync-git` 目录下
|
||||
- `GitProjectConfig.targetDir` 现在相对于 `.sync-git` 目录,而非工作区根目录
|
||||
- `AutoCommitConfig` 中的项目路径也相对于 `.sync-git` 目录
|
||||
- 更新了相关路径计算逻辑和错误处理
|
||||
- 提供更清晰的路径解析日志
|
||||
|
||||
- **智能自动提交完全分离**:
|
||||
- 完全移除了 `GitProjectAutoCommitConfig` 与 Git 项目配置的关联
|
||||
- 将 `GitProjectAutoCommitConfig` 标记为废弃
|
||||
- 自动提交模块现在完全依赖 `AutoCommitConfig` 配置
|
||||
|
||||
- **任务执行机制调整**:
|
||||
- 所有任务现在都在构建完成后的 `closeBundle` 钩子中执行
|
||||
- 移除了 `buildStart` 和 `buildEnd` 阶段的分离执行
|
||||
- 按固定顺序依次执行:Git项目管理 → 本地文件同步 → 自动提交
|
||||
- 前一任务出错会中止后续任务执行
|
||||
|
||||
### 新增
|
||||
|
||||
- **工作区根目录检测**:
|
||||
- 增强了工作区根目录检测功能,支持多种 monorepo 工具:
|
||||
- Turborepo (turbo.json)
|
||||
- PNPM Workspaces (pnpm-workspace.yaml)
|
||||
- Yarn/NPM Workspaces (package.json 中的 workspaces 字段)
|
||||
- 所有路径计算现在基于工作区根目录,而非 Vite 项目根目录
|
||||
- 支持 monorepo 中的子项目使用相同配置
|
||||
|
||||
### 改进
|
||||
|
||||
- **配置验证增强**:
|
||||
- 增加了对自动提交配置中路径的验证,确保使用相对路径
|
||||
- 优化了错误消息,提供更具体的问题描述和解决建议
|
||||
- 确保 Git 项目管理必须成功,失败时会中止后续任务
|
||||
|
||||
- **文档更新**:
|
||||
- 更新了 README,详细说明 Git 项目路径和工作区检测
|
||||
- 添加了更多配置示例,突出显示路径关系
|
||||
- 更新了配置表格,明确标注任务执行顺序
|
||||
- 完善了工作原理部分,说明任务执行机制
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复了多个路径计算问题,确保路径解析一致性
|
||||
- 修正了自动提交模块中潜在的路径解析错误
|
||||
- 优化了 Git 项目管理错误处理逻辑
|
||||
|
||||
## [2.1.0] - 2023-XX-XX
|
||||
|
||||
### 新增
|
||||
|
||||
- **工作区根目录检测**:
|
||||
- 自动检测Turborepo/PNPM/Yarn/NPM工作区根目录
|
||||
- 所有路径(Git项目、文件同步、日志等)基于工作区根目录计算
|
||||
- 支持monorepo中的多个子项目共享配置
|
||||
|
||||
### 改进
|
||||
|
||||
- 更新了日志系统,路径计算现基于工作区根目录
|
||||
- 改进了路径解析逻辑,支持绝对路径和相对路径
|
||||
- 添加了工作区检测日志,方便调试和确认
|
||||
|
||||
## [2.0.0] - 2023-XX-XX
|
||||
|
||||
### 重大变更
|
||||
|
||||
- **插件架构调整**:移除任务编排系统,改为基于Vite构建钩子的分阶段执行
|
||||
- 移除了 `taskOrder` 配置字段
|
||||
- Git项目管理现在在编译前阶段 (`buildStart`钩子) 执行
|
||||
- 本地文件同步和自动提交在编译后阶段 (`buildEnd`钩子) 执行
|
||||
- 修改了插件主要流程,不再需要手动指定任务顺序
|
||||
|
||||
- **智能自动提交模块独立化**:
|
||||
- 从 `GitProjectConfig` 中移除了 `autoCommit` 字段
|
||||
- 创建了独立的 `AutoCommitConfig` 配置接口
|
||||
- 更新了配置验证逻辑,适应新的数据结构
|
||||
- 自动提交现在作为完全独立的模块运行
|
||||
|
||||
### 新增
|
||||
|
||||
- 基于Vite构建周期的分阶段执行机制:
|
||||
- 更合理的任务执行顺序:Git项目更新 → 编译 → 文件同步 → 自动提交
|
||||
- 更明确的错误处理策略:编译前错误会终止构建,编译后错误可选择忽略
|
||||
|
||||
- 为 `AutoCommitConfig` 添加了新的配置选项:
|
||||
- `enableSharedCommits`: 控制是否启用跨项目的共享提交信息 (默认: true)
|
||||
- `insertSeparator`: 控制是否在提交后插入分隔符 (默认: true)
|
||||
|
||||
- 自动提交项目现可独立指定分支,与 Git 项目管理分支分离
|
||||
|
||||
### 改进
|
||||
|
||||
- 代码结构更加清晰,各模块职责明确
|
||||
- 更新了文档,清晰说明各任务的执行阶段
|
||||
- 优化了配置验证逻辑,提供更友好的错误信息
|
||||
- 改进了自动提交流程中的共享提交信息机制
|
||||
- 更新了配置表格,添加了执行阶段说明
|
||||
- 添加了工作流程图,帮助用户理解插件执行机制
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复了类型定义中的问题,确保类型安全
|
||||
- 修正了一些可能导致异常的边缘情况
|
||||
|
||||
## [1.2.0] - 2023-XX-XX
|
||||
|
||||
...之前的更新日志...
|
||||
290
frontend/plugin/vite-plugin-turborepo-deploy/README.md
Normal file
290
frontend/plugin/vite-plugin-turborepo-deploy/README.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# vite-plugin-turborepo-deploy
|
||||
|
||||
Vite插件,用于自动化Turborepo工作区编译部署,包含本地文件同步、Git项目管理和智能自动提交功能。
|
||||
|
||||
## 功能特点
|
||||
|
||||
### 1. 本地文件同步
|
||||
- 支持多种同步模式:复制、镜像、增量更新
|
||||
- 支持目标目录清空、仅添加新文件
|
||||
- 灵活的文件过滤规则:正则表达式、glob模式
|
||||
- 自动解析相对路径
|
||||
- 在构建完成后执行
|
||||
|
||||
### 2. Git项目管理
|
||||
- 多项目支持:配置多个Git项目的拉取/更新任务
|
||||
- 自动分支管理:自动切换到指定分支
|
||||
- 专注于仓库维护,不包含自动提交功能
|
||||
- 集中存放:所有Git项目统一存放在工作区根目录的`.sync-git`目录下
|
||||
- 在构建完成后最先执行
|
||||
|
||||
### 3. 独立的智能自动提交模块
|
||||
- 完全独立于Git项目管理,作为单独模块运行
|
||||
- 在Git项目管理和文件同步后执行,保证数据一致性
|
||||
- 支持强大的自动提交功能:
|
||||
- 监听特定开发者的提交
|
||||
- 提交分隔符识别
|
||||
- 跨项目的共享提交信息机制
|
||||
- 自动处理重复分隔符
|
||||
- 支持多项目并发处理
|
||||
- 在构建完成后最后执行
|
||||
|
||||
### 4. 顺序执行任务
|
||||
- 所有任务在构建完成后的`closeBundle`钩子中执行
|
||||
- 按固定顺序依次执行:Git项目管理 → 本地文件同步 → 自动提交
|
||||
- 前一任务出错会中止后续任务
|
||||
|
||||
### 5. 日志记录系统
|
||||
- 多级日志:error、warn、info、verbose
|
||||
- 控制台彩色输出
|
||||
- 按日期生成日志文件
|
||||
|
||||
### 6. 工作区根目录检测
|
||||
- 自动检测Turborepo/PNPM/Yarn/NPM工作区根目录
|
||||
- 所有路径(Git项目、文件同步、日志等)都基于工作区根目录
|
||||
- 支持monorepo中的子项目使用相同的配置
|
||||
- 所有Git项目统一存放在`.sync-git`目录下,便于管理
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install vite-plugin-turborepo-deploy --save-dev
|
||||
# 或
|
||||
yarn add vite-plugin-turborepo-deploy --dev
|
||||
# 或
|
||||
pnpm add vite-plugin-turborepo-deploy --save-dev
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
在`vite.config.ts`中配置插件:
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
import { defineConfig } from 'vite';
|
||||
import turborepoDeploy from 'vite-plugin-turborepo-deploy';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
turborepoDeploy({
|
||||
// 本地文件同步配置 (在Git项目管理后执行)
|
||||
// 路径相对于工作区根目录
|
||||
localSync: [
|
||||
{
|
||||
source: 'dist',
|
||||
target: 'deploy/public', // 相对于工作区根目录
|
||||
mode: 'mirror',
|
||||
clearTarget: true
|
||||
},
|
||||
{
|
||||
source: 'src/assets',
|
||||
target: 'deploy/assets', // 相对于工作区根目录
|
||||
excludeDirs: ['**/tmp'],
|
||||
excludeFiles: ['**/*.psd']
|
||||
},
|
||||
{
|
||||
source: 'dist/shared',
|
||||
target: [ // 使用数组实现文件分发到多个目标路径
|
||||
'deploy/site-a/shared',
|
||||
'deploy/site-b/shared',
|
||||
'deploy/site-c/shared'
|
||||
],
|
||||
mode: 'incremental'
|
||||
}
|
||||
],
|
||||
|
||||
// Git项目管理配置 (最先执行)
|
||||
// 所有Git项目都存放在工作区根目录的.sync-git目录下
|
||||
gitProjects: [
|
||||
{
|
||||
repo: 'git@github.com:example-org/api-gateway.git',
|
||||
branch: 'develop',
|
||||
targetDir: 'api-gateway', // 相对于.sync-git目录
|
||||
projectName: 'API网关', // 用于日志中清晰标识
|
||||
updateIfExists: true,
|
||||
discardChanges: false // 默认不丢弃未提交的更改
|
||||
},
|
||||
{
|
||||
repo: 'git@github.com:example-org/user-service.git',
|
||||
branch: 'feature/new-endpoint',
|
||||
targetDir: 'user-service', // 相对于.sync-git目录
|
||||
projectName: '用户服务',
|
||||
updateIfExists: true,
|
||||
discardChanges: true // 自动丢弃所有未提交的更改(谨慎使用)
|
||||
}
|
||||
],
|
||||
|
||||
// 自动提交配置 (最后执行)
|
||||
// 路径相对于.sync-git目录
|
||||
autoCommit: {
|
||||
// 启用在项目间共享提交信息
|
||||
enableSharedCommits: true,
|
||||
// 在提交后添加分隔符
|
||||
insertSeparator: true,
|
||||
// 要处理的项目列表
|
||||
projects: [
|
||||
{
|
||||
targetDir: 'api-gateway', // 相对于.sync-git目录
|
||||
projectName: 'API网关', // 用于日志标识
|
||||
watchAuthor: '张三', // 作为提交信息来源
|
||||
maxScanCount: 100,
|
||||
commitSeparator: '/** 提交分隔符 **/',
|
||||
message: 'chore(api-gateway): auto merge [skip ci]',
|
||||
push: true,
|
||||
// 不使用共享提交信息,作为提交信息源
|
||||
useSharedCommits: false,
|
||||
// 可以指定分支,不指定则使用当前分支
|
||||
branch: 'develop'
|
||||
},
|
||||
{
|
||||
targetDir: 'user-service', // 相对于.sync-git目录
|
||||
projectName: '用户服务',
|
||||
// 不需要watchAuthor,因为使用共享提交信息
|
||||
useSharedCommits: true, // 使用共享信息
|
||||
message: 'chore(user-service): auto sync from upstream [skip ci]',
|
||||
push: true,
|
||||
branch: 'feature/new-endpoint'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 日志配置
|
||||
// 路径相对于工作区根目录
|
||||
logger: {
|
||||
level: 'info',
|
||||
writeToFile: true,
|
||||
logDir: '.sync-log' // 相对于工作区根目录
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
## 工作区根目录检测
|
||||
|
||||
插件会自动检测工作区的根目录,具体检测规则如下:
|
||||
|
||||
1. 查找 `turbo.json` 文件的存在(Turborepo)
|
||||
2. 检查 `package.json` 中的 `workspaces` 配置(Yarn/NPM Workspaces)
|
||||
3. 查找 `pnpm-workspace.yaml` 文件的存在(PNPM Workspaces)
|
||||
|
||||
如果找到以上任一标志,则使用该目录作为工作区根目录;如果未找到,则使用Vite项目的根目录。
|
||||
|
||||
**注意**:
|
||||
- 所有配置中的相对路径都相对于工作区根目录,而非Vite项目的根目录。这使得多个子项目可以共享相同的配置。
|
||||
- Git项目都存放在工作区根目录下的`.sync-git`目录中,配置中的`targetDir`是相对于`.sync-git`目录的路径。
|
||||
- 自动提交中的`targetDir`也是相对于`.sync-git`目录的路径。
|
||||
|
||||
## 配置选项
|
||||
|
||||
### 主配置
|
||||
|
||||
| 选项 | 类型 | 默认值 | 描述 | 执行顺序 |
|
||||
|------|------|--------|------|---------|
|
||||
| `gitProjects` | `Array<GitProjectConfig>` | - | Git项目管理配置数组 | 1 |
|
||||
| `localSync` | `Array<LocalSyncConfig>` | - | 本地文件同步配置数组 | 2 |
|
||||
| `autoCommit` | `AutoCommitConfig` | - | 独立的自动提交模块配置 | 3 |
|
||||
| `logger` | `LoggerConfig` | - | 日志配置 | 全局 |
|
||||
|
||||
### LocalSyncConfig
|
||||
|
||||
| 选项 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `source` | `string` | - | 源目录/文件(相对于工作区根目录) |
|
||||
| `target` | `string \| string[]` | - | 目标目录/文件(相对于工作区根目录),可以是单个路径或多个路径数组以实现文件分发 |
|
||||
| `mode` | `'copy' \| 'mirror' \| 'incremental'` | `'incremental'` | 同步模式 |
|
||||
| `clearTarget` | `boolean` | `false` | 是否同步前清空目标目录 |
|
||||
| `addOnly` | `boolean` | `false` | 是否仅添加新文件 |
|
||||
| `exclude` | `string[]` | - | 排除文件/目录的正则表达式数组 |
|
||||
| `excludeDirs` | `string[]` | - | 排除目录的glob模式数组 |
|
||||
| `excludeFiles` | `string[]` | - | 排除文件的glob模式数组 |
|
||||
|
||||
### GitProjectConfig
|
||||
|
||||
| 选项 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `repo` | `string` | - | 仓库地址(SSH或HTTPS) |
|
||||
| `branch` | `string` | - | 目标分支 |
|
||||
| `targetDir` | `string` | - | 存放目录(相对于.sync-git目录) |
|
||||
| `projectName` | `string` | - | 项目名称(用于日志) |
|
||||
| `updateIfExists` | `boolean` | `true` | 存在时是否更新 |
|
||||
| `discardChanges` | `boolean` | `false` | 是否自动丢弃未提交的更改,设为true时会执行git checkout -- . 和 git clean -fd |
|
||||
|
||||
### AutoCommitConfig
|
||||
|
||||
| 选项 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `projects` | `Array<AutoCommitProjectConfig>` | - | 自动提交项目配置数组 |
|
||||
| `enableSharedCommits` | `boolean` | `true` | 是否启用共享提交信息功能 |
|
||||
| `insertSeparator` | `boolean` | `true` | 是否在提交后插入分隔符 |
|
||||
|
||||
### AutoCommitProjectConfig
|
||||
|
||||
| 选项 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `targetDir` | `string` | - | 项目目录(相对于.sync-git目录) |
|
||||
| `projectName` | `string` | `targetDir` | 项目名称(用于日志) |
|
||||
| `watchAuthor` | `string` | - | 监听的开发者用户名(非共享模式必须) |
|
||||
| `maxScanCount` | `number` | `50` | 最大扫描提交记录数 |
|
||||
| `commitSeparator` | `string` | `'/** 提交分隔符 **/'` | 提交分隔符 |
|
||||
| `message` | `string` | - | 自动提交消息模板 |
|
||||
| `push` | `boolean` | `false` | 是否推送到远程 |
|
||||
| `useSharedCommits` | `boolean` | `false` | 是否使用共享提交信息 |
|
||||
| `branch` | `string` | 当前分支 | 要操作的分支 |
|
||||
|
||||
### LoggerConfig
|
||||
|
||||
| 选项 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `level` | `'error' \| 'warn' \| 'info' \| 'verbose'` | `'info'` | 日志级别 |
|
||||
| `writeToFile` | `boolean` | `true` | 是否写入日志文件 |
|
||||
| `logDir` | `string` | `'.sync-log'` | 日志目录(相对于工作区根目录) |
|
||||
|
||||
## 工作原理
|
||||
|
||||
### 插件执行流程
|
||||
|
||||
插件在Vite构建完成后执行所有任务:
|
||||
|
||||
1. **初始化阶段**(`configResolved`钩子):
|
||||
- 检测Turborepo工作区根目录
|
||||
- 所有路径计算基于工作区根目录
|
||||
- 加载并验证配置
|
||||
|
||||
2. **构建完成阶段**(`closeBundle`钩子):
|
||||
- 按固定顺序依次执行:
|
||||
- Git项目管理:克隆或更新指定的仓库
|
||||
- 本地文件同步:处理编译生成的文件
|
||||
- 智能自动提交:将更改提交到Git仓库
|
||||
|
||||
### 本地文件同步
|
||||
|
||||
- **复制模式**:简单复制源到目标,不处理目标中已存在的文件
|
||||
- **镜像模式**:镜像同步,删除目标中不存在于源的文件
|
||||
- **增量模式**:仅覆盖已变更文件
|
||||
|
||||
### Git项目管理
|
||||
|
||||
1. 检查项目是否存在:
|
||||
- 存在则执行`git pull`更新
|
||||
- 不存在则执行`git clone`
|
||||
2. 切换到指定分支
|
||||
3. 不再包含自动提交功能,仅负责仓库维护
|
||||
|
||||
### 独立的自动提交机制
|
||||
|
||||
1. 作为单独模块运行,在Git项目管理和文件同步后执行
|
||||
2. 自动提交流程:
|
||||
- 扫描指定作者的提交记录
|
||||
- 识别提交分隔符,获取有效提交
|
||||
- 生成合并提交信息
|
||||
- 推送到远程仓库(如果配置)
|
||||
- 插入新的提交分隔符
|
||||
|
||||
3. 共享提交信息机制:
|
||||
- 第一个成功获取提交信息的项目,其结果将被缓存
|
||||
- 后续项目可以使用此共享信息进行提交
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
75
frontend/plugin/vite-plugin-turborepo-deploy/package.json
Normal file
75
frontend/plugin/vite-plugin-turborepo-deploy/package.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "@baota/vite-plugin-turborepo-deploy",
|
||||
"version": "3.1.0",
|
||||
"description": "Vite plugin for automated Turborepo workspace build deployment, local file sync, and Git management.",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "vite build",
|
||||
"test": "vitest",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\""
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"development": {
|
||||
"import": "./src/index.ts",
|
||||
"require": "./src/index.ts"
|
||||
},
|
||||
"default": {
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"keywords": [
|
||||
"vite",
|
||||
"vite-plugin",
|
||||
"turborepo",
|
||||
"deploy",
|
||||
"git",
|
||||
"sync"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"chalk": "^5.3.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"ora": "^8.0.1",
|
||||
"picomatch": "^3.0.1",
|
||||
"simple-git": "^3.22.0",
|
||||
"yauzl": "^3.2.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/picomatch": "^2.3.3",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||
"@typescript-eslint/parser": "^7.1.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"prettier": "^3.2.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-dts": "^3.7.3",
|
||||
"vitest": "^1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": ">=3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const DEFAULT_PLUGIN_NAME = 'vite-plugin-turborepo-deploy';
|
||||
export const DEFAULT_COMMIT_SEPARATOR = '/** 提交分隔符 **/';
|
||||
export const DEFAULT_GIT_MAX_SCAN_COUNT = 50;
|
||||
export const DEFAULT_LOG_LEVEL = 'info';
|
||||
|
||||
// Add other constants as needed
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { AutoCommitConfig } from "../types";
|
||||
import type { Logger } from "./logger";
|
||||
import simpleGit, { SimpleGit, SimpleGitOptions } from "simple-git";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { createError } from "./utils";
|
||||
import { smartCheckoutBranch } from "./gitHandler";
|
||||
|
||||
const DEFAULT_COMMIT_SEPARATOR = "/** 提交分隔符 **/";
|
||||
const DEFAULT_MAX_SCAN_COUNT = 50;
|
||||
const DEFAULT_GIT_DIR = ".sync-git";
|
||||
|
||||
/**
|
||||
* 执行自动提交操作
|
||||
*
|
||||
* @param config 自动提交配置
|
||||
* @param workspaceRoot 工作区根目录
|
||||
* @param logger 日志记录器
|
||||
* @param sharedCommitMessagesHolder 共享提交信息的容器
|
||||
*/
|
||||
export async function performAutoCommit(
|
||||
config: AutoCommitConfig,
|
||||
workspaceRoot: string,
|
||||
logger: Logger,
|
||||
sharedCommitMessagesHolder: { current: string[] | null },
|
||||
): Promise<void> {
|
||||
logger.info("开始自动提交操作...");
|
||||
|
||||
// 重置共享提交信息(如果启用)
|
||||
const enableSharedCommits = config.enableSharedCommits !== false;
|
||||
if (enableSharedCommits) {
|
||||
sharedCommitMessagesHolder.current = null;
|
||||
logger.info("已重置共享提交信息缓冲区");
|
||||
}
|
||||
|
||||
// 确保.sync-git目录存在
|
||||
const syncGitDir = path.resolve(workspaceRoot, DEFAULT_GIT_DIR);
|
||||
|
||||
for (const project of config.projects) {
|
||||
// 计算Git项目的绝对路径,targetDir现在是相对于.sync-git目录的
|
||||
const projectDir = path.resolve(syncGitDir, project.targetDir);
|
||||
const projectName = project.projectName || project.targetDir;
|
||||
|
||||
logger.info(`处理自动提交项目: ${projectName} (路径: ${projectDir})`);
|
||||
|
||||
try {
|
||||
// 确保目录存在并且是Git仓库
|
||||
if (!fs.existsSync(projectDir)) {
|
||||
logger.warn(`项目目录 ${projectDir} 不存在,跳过此项目`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const gitOptions: Partial<SimpleGitOptions> = {
|
||||
baseDir: projectDir,
|
||||
binary: "git",
|
||||
maxConcurrentProcesses: 6,
|
||||
};
|
||||
|
||||
const git: SimpleGit = simpleGit(gitOptions);
|
||||
|
||||
if (!(await git.checkIsRepo())) {
|
||||
logger.warn(`${projectDir} 不是有效的Git仓库,跳过此项目`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果指定了分支,切换到该分支
|
||||
if (project.branch) {
|
||||
const currentBranch = (await git.branchLocal()).current;
|
||||
if (currentBranch !== project.branch) {
|
||||
logger.info(`切换到分支 ${project.branch}...`);
|
||||
// 使用智能分支检出函数
|
||||
await smartCheckoutBranch(
|
||||
git,
|
||||
project.branch,
|
||||
projectName,
|
||||
logger,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行自动提交
|
||||
await handleProjectAutoCommit(
|
||||
git,
|
||||
project,
|
||||
projectName,
|
||||
logger,
|
||||
sharedCommitMessagesHolder,
|
||||
enableSharedCommits,
|
||||
config.insertSeparator !== false,
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`处理项目 ${projectName} 自动提交时出错: ${error.message}`,
|
||||
error,
|
||||
);
|
||||
// 软错误,继续执行下一个项目
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("自动提交操作完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个项目的自动提交
|
||||
*/
|
||||
async function handleProjectAutoCommit(
|
||||
git: SimpleGit,
|
||||
project: AutoCommitConfig["projects"][0],
|
||||
projectName: string,
|
||||
logger: Logger,
|
||||
sharedCommitMessagesHolder: { current: string[] | null },
|
||||
enableSharedCommits: boolean,
|
||||
insertSeparator: boolean,
|
||||
) {
|
||||
let commitsToProcess: string[] = [];
|
||||
|
||||
const useSharedCommits = enableSharedCommits && project.useSharedCommits;
|
||||
|
||||
if (useSharedCommits && sharedCommitMessagesHolder.current) {
|
||||
logger.info(`[${projectName}] 使用共享提交信息`);
|
||||
commitsToProcess = [...sharedCommitMessagesHolder.current];
|
||||
} else {
|
||||
if (!project.watchAuthor) {
|
||||
logger.warn(
|
||||
`[${projectName}] 未定义watchAuthor且未使用共享提交,跳过自动提交`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[${projectName}] 扫描 ${project.watchAuthor} 的提交...`);
|
||||
|
||||
const log = await git.log({
|
||||
"--author": project.watchAuthor,
|
||||
"--max-count": project.maxScanCount || DEFAULT_MAX_SCAN_COUNT,
|
||||
"--pretty": "%H %s", // hash和主题
|
||||
});
|
||||
|
||||
const separator = project.commitSeparator || DEFAULT_COMMIT_SEPARATOR;
|
||||
let foundSeparator = false;
|
||||
let tempCommits: string[] = [];
|
||||
|
||||
for (const commit of log.all) {
|
||||
if (commit.message.includes(separator)) {
|
||||
logger.info(`[${projectName}] 找到提交分隔符: "${commit.message}"`);
|
||||
foundSeparator = true;
|
||||
break;
|
||||
}
|
||||
tempCommits.unshift(`[${commit.hash.substring(0, 7)}] ${commit.message}`); // 添加到开头以保持顺序
|
||||
}
|
||||
|
||||
if (foundSeparator) {
|
||||
commitsToProcess = tempCommits; // 分隔符之后的提交(已反转并正确排序)
|
||||
} else if (log.all.length > 0) {
|
||||
// 模式2:没有分隔符,取作者的最新提交
|
||||
const latestCommit = log.all[0];
|
||||
commitsToProcess = [
|
||||
`[${latestCommit.hash.substring(0, 7)}] ${latestCommit.message}`,
|
||||
];
|
||||
logger.info(
|
||||
`[${projectName}] 未找到分隔符。使用 ${project.watchAuthor} 的最新提交: ${commitsToProcess[0]}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 为共享提交缓冲区填充数据(如果启用且是非共享提交消费者)
|
||||
if (
|
||||
enableSharedCommits &&
|
||||
commitsToProcess.length > 0 &&
|
||||
!sharedCommitMessagesHolder.current &&
|
||||
!project.useSharedCommits
|
||||
) {
|
||||
logger.info(
|
||||
`[${projectName}] 将 ${commitsToProcess.length} 条提交存入共享缓冲区`,
|
||||
);
|
||||
sharedCommitMessagesHolder.current = [...commitsToProcess];
|
||||
}
|
||||
}
|
||||
|
||||
if (commitsToProcess.length === 0) {
|
||||
logger.info(`[${projectName}] 没有要处理的新提交`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[${projectName}] 准备提交 ${commitsToProcess.length} 个更改`);
|
||||
|
||||
// 检查工作区状态
|
||||
const status = await git.status();
|
||||
if (!status.isClean()) {
|
||||
logger.info(`[${projectName}] 工作目录有未提交的更改,暂存所有更改`);
|
||||
await git.add("./*");
|
||||
} else {
|
||||
logger.info(
|
||||
`[${projectName}] 工作目录干净,没有本地更改需要提交。这是正常的,将继续处理同步提交信息`,
|
||||
);
|
||||
}
|
||||
|
||||
// 创建提交信息
|
||||
const commitMessageBody = commitsToProcess
|
||||
.map((msg, idx) => `${idx + 1}. ${msg}`)
|
||||
.join("\n");
|
||||
|
||||
const finalCommitMessage = (
|
||||
project.message ||
|
||||
`[自动合并] 包含 ${commitsToProcess.length} 次提交:\n\n${commitMessageBody}\n\n${project.commitSeparator || DEFAULT_COMMIT_SEPARATOR}`
|
||||
).replace("N", commitsToProcess.length.toString());
|
||||
|
||||
logger.info(`[${projectName}] 提交信息:\n${finalCommitMessage}`);
|
||||
await git.commit(finalCommitMessage);
|
||||
|
||||
if (project.push) {
|
||||
const branch = project.branch || (await git.branchLocal()).current;
|
||||
logger.info(`[${projectName}] 推送到 origin ${branch}...`);
|
||||
await git.push("origin", branch);
|
||||
}
|
||||
|
||||
// 插入新的分隔符提交(如果配置启用)
|
||||
if (insertSeparator) {
|
||||
const separatorCommitMessage =
|
||||
project.commitSeparator || DEFAULT_COMMIT_SEPARATOR;
|
||||
logger.info(
|
||||
`[${projectName}] 插入新的分隔符提交: "${separatorCommitMessage}"`,
|
||||
);
|
||||
await git.commit(separatorCommitMessage, ["--allow-empty"]);
|
||||
|
||||
if (project.push) {
|
||||
const branch = project.branch || (await git.branchLocal()).current;
|
||||
logger.info(`[${projectName}] 推送分隔符提交到 origin ${branch}...`);
|
||||
await git.push("origin", branch);
|
||||
}
|
||||
|
||||
// 处理重复分隔符
|
||||
const logAfter = await git.log({ "--max-count": "2", "--pretty": "%s" });
|
||||
if (
|
||||
logAfter.all.length === 2 &&
|
||||
logAfter.all[0].message === separatorCommitMessage &&
|
||||
logAfter.all[1].message === separatorCommitMessage
|
||||
) {
|
||||
logger.info(`[${projectName}] 检测到重复分隔符,正在清理...`);
|
||||
await git.reset(["--hard", "HEAD~1"]);
|
||||
|
||||
if (project.push) {
|
||||
const branch = project.branch || (await git.branchLocal()).current;
|
||||
logger.warn(`[${projectName}] 强制推送以修复远程重复分隔符`);
|
||||
await git.push("origin", branch, ["--force"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[${projectName}] 自动提交处理完成`);
|
||||
}
|
||||
173
frontend/plugin/vite-plugin-turborepo-deploy/src/core/config.ts
Normal file
173
frontend/plugin/vite-plugin-turborepo-deploy/src/core/config.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { ResolvedConfig } from 'vite';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
TurborepoDeployConfig,
|
||||
VitePluginTurborepoDeployOptions,
|
||||
LocalSyncConfig as LocalSyncConfigType,
|
||||
GitProjectConfig as GitProjectConfigType,
|
||||
GitProjectAutoCommitConfig as GitProjectAutoCommitConfigType,
|
||||
AutoCommitConfig as AutoCommitConfigType,
|
||||
} from "../types";
|
||||
import { createLogger, Logger } from "./logger";
|
||||
import path from "path";
|
||||
|
||||
// 通用的自动提交项目配置模式
|
||||
const AutoCommitProjectSchema = z.object({
|
||||
targetDir: z
|
||||
.string()
|
||||
.min(1, { message: "AutoCommit project targetDir cannot be empty" }),
|
||||
projectName: z.string().optional(),
|
||||
watchAuthor: z.string().optional(),
|
||||
maxScanCount: z.number().int().positive().optional().default(50),
|
||||
commitSeparator: z.string().optional().default("/** 提交分隔符 **/"),
|
||||
message: z.string().optional(),
|
||||
push: z.boolean().optional().default(false),
|
||||
useSharedCommits: z.boolean().optional().default(false),
|
||||
branch: z.string().optional(),
|
||||
});
|
||||
|
||||
// AutoCommit配置模式
|
||||
const AutoCommitConfigSchema = z
|
||||
.object({
|
||||
projects: z.array(AutoCommitProjectSchema),
|
||||
insertSeparator: z.boolean().optional().default(true),
|
||||
enableSharedCommits: z.boolean().optional().default(true),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// 确保至少有一个项目不使用共享提交信息(作为源),或禁用了共享
|
||||
if (data.enableSharedCommits) {
|
||||
const hasSourceProject = data.projects.some(
|
||||
(project) => !project.useSharedCommits && project.watchAuthor,
|
||||
);
|
||||
return hasSourceProject;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"When enableSharedCommits is true, at least one project must not use shared commits and have a watchAuthor defined",
|
||||
path: ["projects"],
|
||||
},
|
||||
);
|
||||
|
||||
// Git项目配置模式
|
||||
const GitProjectConfigSchema = z.object({
|
||||
repo: z.string().refine(
|
||||
(value) => {
|
||||
// 支持HTTPS格式: https://github.com/user/repo.git
|
||||
const httpsPattern = /^https:\/\/[^\s\/$.?#].[^\s]*\.git$/;
|
||||
// 支持SSH格式: git@github.com:user/repo.git 或 ssh://git@host:port/path/repo.git
|
||||
const sshPattern = /^(git@[^\s:]+:[^\s]+\.git|ssh:\/\/[^\s]+\.git)$/;
|
||||
|
||||
return httpsPattern.test(value) || sshPattern.test(value);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Invalid Git repository URL. Supported formats: https://github.com/user/repo.git or git@github.com:user/repo.git",
|
||||
},
|
||||
),
|
||||
branch: z.string().min(1, { message: "Git branch cannot be empty" }),
|
||||
targetDir: z.string().min(1, { message: "Git targetDir cannot be empty" }),
|
||||
projectName: z.string().optional(),
|
||||
updateIfExists: z.boolean().optional().default(true),
|
||||
discardChanges: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
// 本地同步配置模式
|
||||
const LocalSyncConfigSchema = z.object({
|
||||
source: z.string().min(1, { message: "LocalSync source cannot be empty" }),
|
||||
target: z.union([
|
||||
z.string().min(1, { message: "LocalSync target cannot be empty" }),
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1, { message: "LocalSync target items cannot be empty" }),
|
||||
)
|
||||
.nonempty({ message: "LocalSync targets array cannot be empty" }),
|
||||
]),
|
||||
mode: z
|
||||
.enum(["copy", "mirror", "incremental"])
|
||||
.optional()
|
||||
.default("incremental"),
|
||||
clearTarget: z.boolean().optional().default(false),
|
||||
addOnly: z.boolean().optional().default(false),
|
||||
exclude: z.array(z.string()).optional(),
|
||||
excludeDirs: z.array(z.string()).optional(),
|
||||
excludeFiles: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// 插件主配置模式
|
||||
const TurborepoDeployConfigSchema = z
|
||||
.object({
|
||||
localSync: z.array(LocalSyncConfigSchema).optional(),
|
||||
gitProjects: z.array(GitProjectConfigSchema).optional(),
|
||||
autoCommit: AutoCommitConfigSchema.optional(),
|
||||
logger: z
|
||||
.object({
|
||||
level: z.enum(["error", "warn", "info", "verbose"]).optional(),
|
||||
writeToFile: z.boolean().optional(),
|
||||
logDir: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// 确保至少配置了一个任务
|
||||
if (
|
||||
Object.keys(data).length > 0 &&
|
||||
!data.localSync &&
|
||||
!data.gitProjects &&
|
||||
!data.autoCommit
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Plugin configured but no tasks (localSync, gitProjects, or autoCommit) are defined.",
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 加载并验证插件配置
|
||||
*
|
||||
* @param options 用户提供的配置选项
|
||||
* @param workspaceRoot 工作区根目录
|
||||
* @returns 验证并处理后的配置对象
|
||||
*/
|
||||
export function loadConfig(
|
||||
options: VitePluginTurborepoDeployOptions | undefined,
|
||||
workspaceRoot: string,
|
||||
): TurborepoDeployConfig {
|
||||
if (!options || Object.keys(options).length === 0) {
|
||||
return {} as TurborepoDeployConfig; // 返回空对象,插件将在buildEnd中跳过
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedConfig = TurborepoDeployConfigSchema.parse(options);
|
||||
|
||||
// 验证自动提交配置中的路径
|
||||
if (parsedConfig.autoCommit) {
|
||||
for (const project of parsedConfig.autoCommit.projects) {
|
||||
// 所有项目路径现在都是相对于 .sync-git 目录的
|
||||
if (path.isAbsolute(project.targetDir)) {
|
||||
throw new Error(
|
||||
`AutoCommit 项目路径 '${project.targetDir}' 不应是绝对路径。请使用相对于 .sync-git 目录的路径。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsedConfig as TurborepoDeployConfig;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new Error(
|
||||
`Configuration validation failed: ${error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
throw new Error("Unknown error while parsing plugin configuration.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import type { GitProjectConfig } from '../types';
|
||||
import type { Logger } from "./logger";
|
||||
import simpleGit, { SimpleGit, SimpleGitOptions } from 'simple-git';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
// 默认的Git项目存放目录
|
||||
const DEFAULT_GIT_DIR = '.sync-git';
|
||||
|
||||
/**
|
||||
* 检查仓库是否具有未提交的更改
|
||||
* @param git SimpleGit实例
|
||||
* @param logger 日志记录器
|
||||
* @returns 是否有未提交的更改
|
||||
*/
|
||||
async function hasUncommittedChanges(git: SimpleGit, logger: Logger): Promise<boolean> {
|
||||
try {
|
||||
// 检查仓库是否为空
|
||||
const hasFiles = await git.raw(['ls-files']).then(output => !!output.trim());
|
||||
if (!hasFiles) {
|
||||
// 空仓库没有未提交的更改
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取状态
|
||||
const status = await git.status();
|
||||
|
||||
// 检查是否有未跟踪的文件
|
||||
const hasUntracked = status.not_added.length > 0;
|
||||
|
||||
// 检查是否有已修改但未暂存的文件
|
||||
const hasModified = status.modified.length > 0;
|
||||
|
||||
// 检查是否有已暂存的更改
|
||||
const hasStaged = status.staged.length > 0;
|
||||
|
||||
// 检查是否有已删除但未暂存的文件
|
||||
const hasDeleted = status.deleted.length > 0;
|
||||
|
||||
// 检查是否有冲突的文件
|
||||
const hasConflicted = status.conflicted.length > 0;
|
||||
|
||||
return hasUntracked || hasModified || hasStaged || hasDeleted || hasConflicted || !status.isClean();
|
||||
} catch (error: any) {
|
||||
logger.warn(`检查未提交更改时出错: ${error.message},将假设没有未提交更改`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能分支检出函数
|
||||
* 按以下策略检出分支:
|
||||
* 1. 检查本地分支是否存在,存在则直接检出
|
||||
* 2. 检查远程分支是否存在,存在则创建本地分支并检出
|
||||
* 3. 都不存在则抛出清晰的错误信息
|
||||
*
|
||||
* @param git SimpleGit实例
|
||||
* @param branchName 要检出的分支名称
|
||||
* @param projectName 项目名称(用于日志)
|
||||
* @param logger 日志记录器
|
||||
* @param forceCheckout 是否强制检出
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function smartCheckoutBranch(
|
||||
git: SimpleGit,
|
||||
branchName: string,
|
||||
projectName: string,
|
||||
logger: Logger,
|
||||
forceCheckout: boolean = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 1. 检查本地分支是否存在
|
||||
const localBranches = await git.branchLocal();
|
||||
const localBranchExists = localBranches.all.includes(branchName);
|
||||
|
||||
if (localBranchExists) {
|
||||
logger.info(`${projectName}: 本地分支 ${branchName} 存在,直接检出...`);
|
||||
if (forceCheckout) {
|
||||
await git.checkout(["-f", branchName]);
|
||||
logger.info(`${projectName}: 成功强制检出本地分支 ${branchName}`);
|
||||
} else {
|
||||
await git.checkout(branchName);
|
||||
logger.info(`${projectName}: 成功检出本地分支 ${branchName}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 检查远程分支是否存在
|
||||
logger.info(`${projectName}: 本地分支 ${branchName} 不存在,检查远程分支...`);
|
||||
const remoteBranches = await git.branch(['-r']);
|
||||
const remoteBranchName = `origin/${branchName}`;
|
||||
const remoteBranchExists = remoteBranches.all.some(branch =>
|
||||
branch.includes(remoteBranchName) || branch.includes(branchName)
|
||||
);
|
||||
|
||||
if (remoteBranchExists) {
|
||||
logger.info(`${projectName}: 远程分支 ${remoteBranchName} 存在,创建并检出本地分支...`);
|
||||
if (forceCheckout) {
|
||||
await git.checkout(["-b", branchName, remoteBranchName, "-f"]);
|
||||
logger.info(`${projectName}: 成功强制创建并检出分支 ${branchName} (来源: ${remoteBranchName})`);
|
||||
} else {
|
||||
await git.checkout(["-b", branchName, remoteBranchName]);
|
||||
logger.info(`${projectName}: 成功创建并检出分支 ${branchName} (来源: ${remoteBranchName})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 分支完全不存在
|
||||
const availableBranches = [...localBranches.all, ...remoteBranches.all];
|
||||
throw new Error(
|
||||
`分支 '${branchName}' 在本地和远程都不存在。` +
|
||||
`可用的分支有: ${availableBranches.join(', ')}`
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
// 如果是我们自己抛出的错误,直接重新抛出
|
||||
if (error.message.includes('在本地和远程都不存在')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 处理其他Git操作错误
|
||||
logger.error(`${projectName}: 分支检出过程中出错: ${error.message}`);
|
||||
throw new Error(`无法检出分支 '${branchName}': ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地丢弃工作区中的所有更改
|
||||
* @param git SimpleGit实例
|
||||
* @param projectName 项目名称
|
||||
* @param logger 日志记录器
|
||||
*/
|
||||
async function safelyDiscardChanges(git: SimpleGit, projectName: string, logger: Logger): Promise<void> {
|
||||
try {
|
||||
// 检查仓库是否为空或是否有已跟踪的文件
|
||||
const trackedFiles = await git.raw(['ls-files']);
|
||||
if (!trackedFiles.trim()) {
|
||||
logger.info(`${projectName}: 仓库为空或没有已跟踪的文件,跳过丢弃更改操作`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取具体的更改状态
|
||||
const status = await git.status();
|
||||
|
||||
if (status.modified.length > 0 || status.deleted.length > 0) {
|
||||
logger.info(`${projectName}: 丢弃已修改或已删除但未暂存的文件更改`);
|
||||
await git.checkout(['--', '.']);
|
||||
}
|
||||
|
||||
if (status.staged.length > 0) {
|
||||
logger.info(`${projectName}: 丢弃已暂存的更改`);
|
||||
await git.reset(['HEAD', '--', '.']);
|
||||
if (status.staged.length > 0) {
|
||||
await git.checkout(['--', '.']);
|
||||
}
|
||||
}
|
||||
|
||||
if (status.not_added.length > 0) {
|
||||
logger.info(`${projectName}: 删除未跟踪的文件`);
|
||||
await git.clean('fd');
|
||||
}
|
||||
|
||||
logger.info(`${projectName}: 已丢弃所有本地更改`);
|
||||
} catch (error: any) {
|
||||
logger.warn(`${projectName}: 丢弃更改时出错: ${error.message},尝试继续操作`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新Git项目,在编译前执行
|
||||
* 所有Git项目都存放在workspaceRoot/.sync-git目录下
|
||||
*
|
||||
* @param configs Git项目配置数组
|
||||
* @param workspaceRoot 工作区根目录
|
||||
* @param logger 日志记录器
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function updateGitProjects(
|
||||
configs: GitProjectConfig[],
|
||||
workspaceRoot: string,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
logger.info("开始Git项目初始化...");
|
||||
|
||||
// 确保.sync-git目录存在
|
||||
const syncGitDir = path.resolve(workspaceRoot, DEFAULT_GIT_DIR);
|
||||
await fs.ensureDir(syncGitDir);
|
||||
logger.info(`Git项目根目录: ${syncGitDir}`);
|
||||
|
||||
// 检查是否所有Git项目都已准备就绪的标志
|
||||
let allProjectsReady = true;
|
||||
|
||||
for (const config of configs) {
|
||||
// 构建Git项目路径,放在.sync-git目录下
|
||||
const relativeProjectDir = config.targetDir;
|
||||
const absoluteProjectDir = path.resolve(syncGitDir, relativeProjectDir);
|
||||
const projectName = config.projectName || config.targetDir;
|
||||
|
||||
logger.info(
|
||||
`处理Git项目: ${projectName} (仓库: ${config.repo}, 分支: ${config.branch})`,
|
||||
);
|
||||
|
||||
const gitOptions: Partial<SimpleGitOptions> = {
|
||||
baseDir: absoluteProjectDir,
|
||||
binary: "git",
|
||||
maxConcurrentProcesses: 6,
|
||||
};
|
||||
|
||||
try {
|
||||
// 检查项目目录是否存在
|
||||
const dirExists = await fs.pathExists(absoluteProjectDir);
|
||||
if (!dirExists) {
|
||||
logger.info(`项目目录不存在: ${absoluteProjectDir},将创建并克隆仓库`);
|
||||
await fs.ensureDir(absoluteProjectDir);
|
||||
}
|
||||
|
||||
const git: SimpleGit = simpleGit(gitOptions);
|
||||
|
||||
// 检查是否为Git仓库
|
||||
const isRepo = dirExists && (await git.checkIsRepo().catch(() => false));
|
||||
|
||||
if (!isRepo) {
|
||||
logger.info(`正在克隆 ${config.repo} 到 ${absoluteProjectDir}...`);
|
||||
// 确保父目录存在
|
||||
await fs.ensureDir(path.dirname(absoluteProjectDir));
|
||||
// 克隆仓库
|
||||
await simpleGit(path.dirname(absoluteProjectDir)).clone(
|
||||
config.repo,
|
||||
path.basename(absoluteProjectDir),
|
||||
[`--branch=${config.branch}`],
|
||||
);
|
||||
logger.info(`克隆成功。`);
|
||||
} else {
|
||||
if (config.updateIfExists !== false) {
|
||||
logger.info(`正在获取并拉取 ${projectName} 的更新...`);
|
||||
await git.fetch();
|
||||
|
||||
// 提前获取当前分支信息
|
||||
const branchInfo = await git.branchLocal();
|
||||
const currentBranch = branchInfo.current;
|
||||
logger.info(`${projectName}: 当前分支 ${currentBranch}`);
|
||||
|
||||
// 检查是否有未提交的更改
|
||||
const uncommittedChanges = await hasUncommittedChanges(git, logger);
|
||||
|
||||
if (uncommittedChanges) {
|
||||
if (config.discardChanges) {
|
||||
logger.warn(
|
||||
`${projectName}: 检测到未提交的更改,根据配置将丢弃所有本地修改...`,
|
||||
);
|
||||
// 安全地丢弃所有更改
|
||||
await safelyDiscardChanges(git, projectName, logger);
|
||||
} else {
|
||||
logger.warn(
|
||||
`${projectName}: 检测到未提交的更改。如需自动丢弃这些更改,请设置 discardChanges: true`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info(`${projectName}: 没有检测到未提交的更改,继续操作...`);
|
||||
}
|
||||
|
||||
// 检查当前分支是否为目标分支
|
||||
if (currentBranch !== config.branch) {
|
||||
logger.info(
|
||||
`${projectName}: 需要从分支 ${currentBranch} 切换到分支 ${config.branch}...`,
|
||||
);
|
||||
try {
|
||||
// 使用智能分支检出函数
|
||||
await smartCheckoutBranch(
|
||||
git,
|
||||
config.branch,
|
||||
projectName,
|
||||
logger,
|
||||
false,
|
||||
);
|
||||
} catch (checkoutError: any) {
|
||||
if (config.discardChanges) {
|
||||
logger.warn(
|
||||
`${projectName}: 切换分支失败: ${checkoutError.message},尝试强制切换...`,
|
||||
);
|
||||
// 使用强制检出
|
||||
await smartCheckoutBranch(
|
||||
git,
|
||||
config.branch,
|
||||
projectName,
|
||||
logger,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
throw checkoutError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`${projectName}: 已经在目标分支 ${config.branch} 上,无需切换`,
|
||||
);
|
||||
}
|
||||
|
||||
// 执行拉取操作
|
||||
logger.info(`${projectName}: 从远程拉取最新更改...`);
|
||||
try {
|
||||
await git.pull("origin", config.branch, { "--rebase": "true" });
|
||||
logger.info(`${projectName}: 成功更新分支 ${config.branch}。`);
|
||||
} catch (pullError: any) {
|
||||
if (
|
||||
(pullError.message.includes("You have unstaged changes") ||
|
||||
pullError.message.includes(
|
||||
"Your local changes to the following files would be overwritten",
|
||||
)) &&
|
||||
config.discardChanges
|
||||
) {
|
||||
logger.warn(
|
||||
`拉取失败 (${pullError.message}),尝试丢弃更改后重新拉取...`,
|
||||
);
|
||||
|
||||
// 尝试中止可能进行中的变基
|
||||
try {
|
||||
await git.rebase(["--abort"]);
|
||||
} catch (e) {
|
||||
// 忽略错误,因为可能没有正在进行的变基
|
||||
}
|
||||
|
||||
// 安全地丢弃所有更改
|
||||
await safelyDiscardChanges(git, projectName, logger);
|
||||
|
||||
// 重新尝试拉取
|
||||
await git.pull("origin", config.branch, { "--rebase": "true" });
|
||||
logger.info(`丢弃更改后成功更新 ${projectName}。`);
|
||||
} else {
|
||||
throw pullError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`项目 ${projectName} 已存在且 updateIfExists 为 false,跳过更新。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`处理Git项目 ${projectName} 时出错: ${error.message}`,
|
||||
error,
|
||||
);
|
||||
// 标记有项目未准备就绪
|
||||
allProjectsReady = false;
|
||||
// 编译前阶段出错,中止编译流程
|
||||
throw new Error(
|
||||
`Git项目 ${projectName} 初始化失败,编译中止: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (allProjectsReady) {
|
||||
logger.info("所有Git项目初始化完成,可以开始编译。");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
/**
|
||||
* 本地文件同步模塊 - 跨平台實現
|
||||
*
|
||||
* 使用 Node.js 原生庫實現跨平台文件壓縮和解壓功能:
|
||||
* - archiver: 跨平台壓縮庫,替代 Unix zip 命令
|
||||
* - yauzl: 跨平台解壓庫,替代 Unix unzip 命令
|
||||
* - fs-extra: 增強的文件系統操作
|
||||
*
|
||||
* 支持 Windows、Linux、macOS 等所有 Node.js 支持的平台
|
||||
*/
|
||||
|
||||
import type { LocalSyncConfig } from "../types";
|
||||
import type { Logger } from "./logger";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import picomatch from "picomatch"; // For glob matching if not using regex directly
|
||||
import os from "os";
|
||||
import archiver from "archiver";
|
||||
import yauzl from "yauzl";
|
||||
import { isSubdirectoryOf, analyzePathRelationship } from "./utils";
|
||||
|
||||
// 缓存已创建的临时压缩文件
|
||||
interface CompressionCache {
|
||||
[sourcePathKey: string]: {
|
||||
zipFile: string; // 压缩文件路径
|
||||
excludeOptions: string; // 排除选项字符串
|
||||
expiry: number; // 过期时间戳
|
||||
};
|
||||
}
|
||||
|
||||
// 全局压缩缓存对象
|
||||
const compressionCache: CompressionCache = {};
|
||||
|
||||
// 缓存过期时间(毫秒)
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5分钟
|
||||
|
||||
/**
|
||||
* 处理源路径,将'/'特殊字符解释为工作区根目录
|
||||
* @param sourcePath 原始配置的源路径
|
||||
* @param workspaceRoot 工作区根目录
|
||||
* @returns 处理后的实际源路径
|
||||
*/
|
||||
function resolveSourcePath(sourcePath: string, workspaceRoot: string): string {
|
||||
// 如果源路径是'/',则将其解释为工作区根目录
|
||||
if (sourcePath === "/") {
|
||||
return workspaceRoot;
|
||||
}
|
||||
// 否则正常解析路径
|
||||
return path.resolve(workspaceRoot, sourcePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建临时目录用于压缩操作
|
||||
* @returns 临时目录路径
|
||||
*/
|
||||
async function createTempDir(): Promise<string> {
|
||||
const tempDir = path.join(os.tmpdir(), `turborepo-deploy-${Date.now()}`);
|
||||
await fs.ensureDir(tempDir);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成排除模式數組,適配 archiver 庫的 ignore 選項
|
||||
* @param config 同步配置
|
||||
* @param sourcePath 源路径
|
||||
* @param targetPath 目标路径
|
||||
* @param tempDir 临时目录
|
||||
* @returns 排除模式數組
|
||||
*/
|
||||
function generateExcludePatterns(
|
||||
config: LocalSyncConfig,
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
tempDir: string,
|
||||
): string[] {
|
||||
const excludePatterns: string[] = [];
|
||||
|
||||
// 處理排除目錄
|
||||
if (config.excludeDirs && config.excludeDirs.length > 0) {
|
||||
config.excludeDirs.forEach((dir) => {
|
||||
// 移除通配符前綴,轉換為 glob 模式
|
||||
const baseDirName = dir.replace(/^\*\*\//, "");
|
||||
excludePatterns.push(`**/${baseDirName}/**`);
|
||||
excludePatterns.push(`${baseDirName}/**`);
|
||||
});
|
||||
}
|
||||
|
||||
// 處理排除文件
|
||||
if (config.excludeFiles && config.excludeFiles.length > 0) {
|
||||
config.excludeFiles.forEach((file) => {
|
||||
const baseFileName = file.replace(/^\*\*\//, "");
|
||||
excludePatterns.push(`**/${baseFileName}`);
|
||||
excludePatterns.push(`${baseFileName}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 處理正則排除
|
||||
if (config.exclude && config.exclude.length > 0) {
|
||||
config.exclude.forEach((pattern) => {
|
||||
// 將正則模式轉換為 glob 模式
|
||||
excludePatterns.push(`**/*${pattern}*`);
|
||||
excludePatterns.push(`*${pattern}*`);
|
||||
});
|
||||
}
|
||||
|
||||
// 始終排除目標路徑,避免遞歸
|
||||
const relativeTargetPath = path.relative(sourcePath, targetPath);
|
||||
if (relativeTargetPath && relativeTargetPath !== ".") {
|
||||
excludePatterns.push(`${relativeTargetPath}/**`);
|
||||
excludePatterns.push(`**/${relativeTargetPath}/**`);
|
||||
}
|
||||
|
||||
// 排除所有 .sync-git 目錄
|
||||
excludePatterns.push("**/.sync-git/**");
|
||||
excludePatterns.push(".sync-git/**");
|
||||
|
||||
// 排除臨時目錄
|
||||
const tempDirName = path.basename(tempDir);
|
||||
excludePatterns.push(`**/${tempDirName}/**`);
|
||||
excludePatterns.push(`${tempDirName}/**`);
|
||||
|
||||
return excludePatterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存键
|
||||
* @param sourcePath 源路径
|
||||
* @param config 同步配置
|
||||
* @returns 缓存键
|
||||
*/
|
||||
function getCacheKey(sourcePath: string, config: LocalSyncConfig): string {
|
||||
// 使用源路径和排除规则作为缓存键
|
||||
return `${sourcePath}_${JSON.stringify({
|
||||
excludeDirs: config.excludeDirs || [],
|
||||
excludeFiles: config.excludeFiles || [],
|
||||
exclude: config.exclude || [],
|
||||
})}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期缓存
|
||||
*/
|
||||
function cleanExpiredCache(): void {
|
||||
const now = Date.now();
|
||||
for (const key in compressionCache) {
|
||||
if (compressionCache[key].expiry < now) {
|
||||
// 尝试删除过期的缓存文件
|
||||
try {
|
||||
if (fs.existsSync(compressionCache[key].zipFile)) {
|
||||
fs.unlinkSync(compressionCache[key].zipFile);
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略删除错误
|
||||
}
|
||||
delete compressionCache[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 處理同步錯誤,提供具體的診斷信息和解決建議
|
||||
* @param error 捕獲的錯誤
|
||||
* @param sourcePath 源路徑
|
||||
* @param targetPath 目標路徑
|
||||
* @param config 同步配置
|
||||
* @param logger 日誌記錄器
|
||||
*/
|
||||
function handleSyncError(
|
||||
error: Error,
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
config: LocalSyncConfig,
|
||||
logger: Logger,
|
||||
): void {
|
||||
logger.error(`❌ 同步失敗: ${sourcePath} -> ${targetPath}`);
|
||||
|
||||
// 根據錯誤類型提供具體的診斷和建議
|
||||
const errorMessage = error.message.toLowerCase();
|
||||
|
||||
if (
|
||||
errorMessage.includes("cannot copy") &&
|
||||
errorMessage.includes("subdirectory")
|
||||
) {
|
||||
logger.error(`🚨 檢測到自引用複製錯誤 - 這正是我們修復的問題!`);
|
||||
logger.error(` 錯誤詳情: ${error.message}`);
|
||||
logger.error(` 這表示路徑檢測邏輯可能仍有問題,請檢查:`);
|
||||
logger.error(` 1. 源路徑: ${sourcePath}`);
|
||||
logger.error(` 2. 目標路徑: ${targetPath}`);
|
||||
logger.error(` 3. 路徑關係檢測是否正確工作`);
|
||||
logger.error(` 💡 解決方案:`);
|
||||
logger.error(` - 確保目標路徑不是源路徑的子目錄`);
|
||||
logger.error(` - 或者使用相對路徑配置`);
|
||||
logger.error(` - 檢查 excludeDirs 配置是否包含目標目錄`);
|
||||
} else if (
|
||||
errorMessage.includes("enoent") ||
|
||||
errorMessage.includes("no such file")
|
||||
) {
|
||||
logger.error(`📁 文件或目錄不存在錯誤`);
|
||||
logger.error(` 錯誤詳情: ${error.message}`);
|
||||
logger.error(` 💡 解決方案:`);
|
||||
logger.error(` - 檢查源路徑是否存在: ${sourcePath}`);
|
||||
logger.error(` - 確保父目錄有寫入權限`);
|
||||
logger.error(` - 檢查路徑中是否包含特殊字符`);
|
||||
} else if (
|
||||
errorMessage.includes("eacces") ||
|
||||
errorMessage.includes("permission denied")
|
||||
) {
|
||||
logger.error(`🔒 權限錯誤`);
|
||||
logger.error(` 錯誤詳情: ${error.message}`);
|
||||
logger.error(` 💡 解決方案:`);
|
||||
logger.error(` - 檢查目標目錄的寫入權限`);
|
||||
logger.error(` - 確保沒有文件被其他程序占用`);
|
||||
logger.error(` - 在 Windows 上可能需要以管理員身份運行`);
|
||||
} else if (
|
||||
errorMessage.includes("enospc") ||
|
||||
errorMessage.includes("no space")
|
||||
) {
|
||||
logger.error(`💾 磁盤空間不足錯誤`);
|
||||
logger.error(` 錯誤詳情: ${error.message}`);
|
||||
logger.error(` 💡 解決方案:`);
|
||||
logger.error(` - 清理磁盤空間`);
|
||||
logger.error(` - 檢查目標磁盤的可用空間`);
|
||||
} else if (
|
||||
errorMessage.includes("emfile") ||
|
||||
errorMessage.includes("too many open files")
|
||||
) {
|
||||
logger.error(`📂 文件句柄過多錯誤`);
|
||||
logger.error(` 錯誤詳情: ${error.message}`);
|
||||
logger.error(` 💡 解決方案:`);
|
||||
logger.error(` - 增加系統文件句柄限制`);
|
||||
logger.error(` - 檢查是否有文件泄漏`);
|
||||
logger.error(` - 考慮使用 excludeDirs 減少處理的文件數量`);
|
||||
} else {
|
||||
logger.error(`❓ 未知錯誤`);
|
||||
logger.error(` 錯誤詳情: ${error.message}`);
|
||||
logger.error(` 💡 通用解決方案:`);
|
||||
logger.error(` - 檢查網絡連接(如果涉及遠程路徑)`);
|
||||
logger.error(` - 確保所有路徑都是有效的`);
|
||||
logger.error(` - 嘗試減少同步的文件數量`);
|
||||
}
|
||||
|
||||
// 提供配置建議
|
||||
logger.error(`⚙️ 當前配置信息:`);
|
||||
logger.error(` 模式: ${config.mode || "incremental"}`);
|
||||
logger.error(` 清空目標: ${config.clearTarget || false}`);
|
||||
logger.error(` 僅添加: ${config.addOnly || false}`);
|
||||
logger.error(` 排除目錄數量: ${config.excludeDirs?.length || 0}`);
|
||||
logger.error(` 排除文件數量: ${config.excludeFiles?.length || 0}`);
|
||||
|
||||
// 記錄完整的錯誤棧以便調試
|
||||
logger.verbose(`完整錯誤棧: ${error.stack}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用压缩方式处理源目录到子目录的复制,支持缓存
|
||||
* @param sourcePath 源路径
|
||||
* @param targetPath 目标路径
|
||||
* @param config 同步配置
|
||||
* @param logger 日志记录器
|
||||
*/
|
||||
async function syncViaCompression(
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
config: LocalSyncConfig,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
logger.info(`目标路径是源路径的子目录或相同路径,使用压缩方案同步...`);
|
||||
|
||||
// 清理过期缓存
|
||||
cleanExpiredCache();
|
||||
|
||||
// 获取缓存键
|
||||
const cacheKey = getCacheKey(sourcePath, config);
|
||||
|
||||
// 创建临时目录(可能不需要,取决于是否有缓存)
|
||||
let tempDir: string | null = null;
|
||||
let tempZipFile: string;
|
||||
let needToCreateZip = true;
|
||||
|
||||
// 检查缓存
|
||||
if (compressionCache[cacheKey]) {
|
||||
// 使用缓存的压缩文件
|
||||
logger.info(`找到源路径 ${sourcePath} 的缓存压缩文件,跳过压缩步骤`);
|
||||
tempZipFile = compressionCache[cacheKey].zipFile;
|
||||
needToCreateZip = false;
|
||||
} else {
|
||||
// 创建新的临时目录和压缩文件
|
||||
tempDir = await createTempDir();
|
||||
tempZipFile = path.join(tempDir, "source.zip");
|
||||
}
|
||||
|
||||
try {
|
||||
if (needToCreateZip) {
|
||||
// 需要创建新的压缩文件
|
||||
const excludePatterns = generateExcludePatterns(
|
||||
config,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
tempDir!,
|
||||
);
|
||||
|
||||
// 使用跨平台壓縮函數
|
||||
logger.info(`压缩源目录 ${sourcePath} 到临时文件 ${tempZipFile}...`);
|
||||
await createZipWithArchiver(
|
||||
sourcePath,
|
||||
tempZipFile,
|
||||
excludePatterns,
|
||||
logger,
|
||||
);
|
||||
|
||||
// 将新创建的压缩文件加入缓存
|
||||
compressionCache[cacheKey] = {
|
||||
zipFile: tempZipFile,
|
||||
excludeOptions: excludePatterns.join(","),
|
||||
expiry: Date.now() + CACHE_TTL,
|
||||
};
|
||||
logger.verbose(
|
||||
`已将压缩文件添加到缓存,缓存键: ${cacheKey.substring(0, 30)}...`,
|
||||
);
|
||||
}
|
||||
|
||||
// 清空目标目录(如果配置了clearTarget)
|
||||
if (config.clearTarget) {
|
||||
logger.info(`清空目标目录 ${targetPath}...`);
|
||||
await fs.emptyDir(targetPath);
|
||||
}
|
||||
await fs.ensureDir(targetPath);
|
||||
|
||||
// 使用跨平台解壓函數
|
||||
logger.info(`解压临时文件到目标目录 ${targetPath}...`);
|
||||
await extractZipWithYauzl(tempZipFile, targetPath, logger);
|
||||
|
||||
logger.info(`成功通过压缩方案同步 ${sourcePath} 到 ${targetPath}`);
|
||||
} catch (error: any) {
|
||||
logger.error(`压缩同步过程出错: ${error.message}`, error);
|
||||
|
||||
// 发生错误时,从缓存中移除该条目
|
||||
if (compressionCache[cacheKey]) {
|
||||
delete compressionCache[cacheKey];
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
// 只清理我们在这次调用中创建的临时目录
|
||||
// 缓存的临时文件会在过期后或进程结束时清理
|
||||
if (tempDir && needToCreateZip) {
|
||||
try {
|
||||
// 只移除临时目录,不移除压缩文件(已添加到缓存)
|
||||
const tempDirFiles = await fs.readdir(tempDir);
|
||||
for (const file of tempDirFiles) {
|
||||
if (file !== path.basename(tempZipFile)) {
|
||||
await fs.remove(path.join(tempDir, file));
|
||||
}
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
logger.warn(`清理临时文件失败: ${cleanupError}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 archiver 庫創建跨平台壓縮文件
|
||||
* @param sourcePath 源路徑
|
||||
* @param targetZipFile 目標zip文件路徑
|
||||
* @param excludePatterns 排除模式數組
|
||||
* @param logger 日誌記錄器
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async function createZipWithArchiver(
|
||||
sourcePath: string,
|
||||
targetZipFile: string,
|
||||
excludePatterns: string[],
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 確保目標目錄存在
|
||||
fs.ensureDirSync(path.dirname(targetZipFile));
|
||||
|
||||
// 創建輸出流
|
||||
const output = fs.createWriteStream(targetZipFile);
|
||||
|
||||
// 創建歸檔器實例,使用最高壓縮級別
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
|
||||
// 監聽輸出流事件
|
||||
output.on("close", () => {
|
||||
logger.info(`壓縮完成,總共 ${archive.pointer()} 字節`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
output.on("error", (err) => {
|
||||
logger.error(`輸出流錯誤: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// 監聽歸檔器錯誤事件
|
||||
archive.on("error", (err) => {
|
||||
logger.error(`壓縮過程錯誤: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// 監聽進度事件
|
||||
archive.on("progress", (progress) => {
|
||||
logger.verbose(
|
||||
`壓縮進度: 已處理 ${progress.entries.processed}/${progress.entries.total} 個條目`,
|
||||
);
|
||||
});
|
||||
|
||||
// 將歸檔器輸出管道連接到文件
|
||||
archive.pipe(output);
|
||||
|
||||
try {
|
||||
// 添加目錄及其內容,使用排除規則
|
||||
archive.glob("**/*", {
|
||||
cwd: sourcePath,
|
||||
ignore: excludePatterns,
|
||||
dot: true, // 包含隱藏文件
|
||||
});
|
||||
|
||||
logger.info(`開始壓縮 ${sourcePath} 到 ${targetZipFile}...`);
|
||||
logger.verbose(`排除模式: ${excludePatterns.join(", ")}`);
|
||||
|
||||
// 完成歸檔器
|
||||
archive.finalize();
|
||||
} catch (error: any) {
|
||||
logger.error(`壓縮設置錯誤: ${error.message}`);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 yauzl 庫創建跨平台解壓文件
|
||||
* @param zipFile 壓縮文件路徑
|
||||
* @param targetPath 目標解壓路徑
|
||||
* @param logger 日誌記錄器
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async function extractZipWithYauzl(
|
||||
zipFile: string,
|
||||
targetPath: string,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 確保目標目錄存在
|
||||
fs.ensureDirSync(targetPath);
|
||||
|
||||
let extractedCount = 0;
|
||||
let totalEntries = 0;
|
||||
|
||||
// 打開 zip 文件
|
||||
yauzl.open(zipFile, { lazyEntries: true }, (err, zipfile) => {
|
||||
if (err) {
|
||||
logger.error(`無法打開壓縮文件 ${zipFile}: ${err.message}`);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!zipfile) {
|
||||
const error = new Error("zipfile is undefined");
|
||||
logger.error(`壓縮文件對象為空: ${zipFile}`);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
totalEntries = zipfile.entryCount;
|
||||
logger.info(
|
||||
`開始解壓 ${zipFile} 到 ${targetPath},共 ${totalEntries} 個條目`,
|
||||
);
|
||||
|
||||
// 監聽條目事件
|
||||
zipfile.on("entry", (entry) => {
|
||||
const entryPath = entry.fileName;
|
||||
const fullPath = path.join(targetPath, entryPath);
|
||||
|
||||
// 路徑安全檢查,防止目錄遍歷攻擊
|
||||
const normalizedPath = path.normalize(fullPath);
|
||||
if (!normalizedPath.startsWith(path.normalize(targetPath))) {
|
||||
logger.error(`檢測到不安全的路徑: ${entryPath}`);
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
// 檢查是否為目錄
|
||||
if (entryPath.endsWith("/")) {
|
||||
// 創建目錄
|
||||
fs.ensureDirSync(fullPath);
|
||||
logger.verbose(`創建目錄: ${entryPath}`);
|
||||
extractedCount++;
|
||||
|
||||
// 繼續讀取下一個條目
|
||||
zipfile.readEntry();
|
||||
} else {
|
||||
// 提取文件
|
||||
zipfile.openReadStream(entry, (err, readStream) => {
|
||||
if (err) {
|
||||
logger.error(`無法讀取文件 ${entryPath}: ${err.message}`);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!readStream) {
|
||||
logger.error(`讀取流為空: ${entryPath}`);
|
||||
reject(new Error(`無法創建讀取流: ${entryPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 確保父目錄存在
|
||||
fs.ensureDirSync(path.dirname(fullPath));
|
||||
|
||||
// 創建寫入流
|
||||
const writeStream = fs.createWriteStream(fullPath);
|
||||
|
||||
// 處理流錯誤
|
||||
readStream.on("error", (err) => {
|
||||
logger.error(`讀取流錯誤 ${entryPath}: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
writeStream.on("error", (err) => {
|
||||
logger.error(`寫入流錯誤 ${entryPath}: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// 文件寫入完成
|
||||
writeStream.on("close", () => {
|
||||
extractedCount++;
|
||||
logger.verbose(
|
||||
`提取文件: ${entryPath} (${extractedCount}/${totalEntries})`,
|
||||
);
|
||||
|
||||
// 繼續讀取下一個條目
|
||||
zipfile.readEntry();
|
||||
});
|
||||
|
||||
// 將讀取流管道連接到寫入流
|
||||
readStream.pipe(writeStream);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 監聽結束事件
|
||||
zipfile.on("end", () => {
|
||||
logger.info(`解壓完成,共提取 ${extractedCount} 個條目`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
// 監聽錯誤事件
|
||||
zipfile.on("error", (err) => {
|
||||
logger.error(`解壓過程錯誤: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// 開始讀取第一個條目
|
||||
zipfile.readEntry();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function performLocalSync(
|
||||
configs: LocalSyncConfig[],
|
||||
workspaceRoot: string,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
logger.info("开始本地文件同步...");
|
||||
|
||||
for (const config of configs) {
|
||||
// 使用新的源路径解析函数
|
||||
const sourcePath = resolveSourcePath(config.source, workspaceRoot);
|
||||
|
||||
// 输出实际的源路径,方便调试
|
||||
if (config.source === "/") {
|
||||
logger.info(`源路径 '/' 被解析为工作区根目录: ${sourcePath}`);
|
||||
}
|
||||
|
||||
// 检查源路径是否存在
|
||||
if (!(await fs.pathExists(sourcePath))) {
|
||||
logger.warn(`源路径 ${sourcePath} 不存在。跳过此同步任务。`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 将所有目标统一处理为数组
|
||||
const targets = Array.isArray(config.target)
|
||||
? config.target
|
||||
: [config.target];
|
||||
|
||||
logger.info(`为源路径 ${sourcePath} 处理 ${targets.length} 个目标`);
|
||||
|
||||
// 对每个目标路径执行同步
|
||||
for (const target of targets) {
|
||||
const targetPath = path.resolve(workspaceRoot, target);
|
||||
|
||||
// 检查目标路径是否是源路径的子目录或相同目录
|
||||
// 使用工具函數進行路徑比較,確保跨平台兼容性
|
||||
const pathAnalysis = analyzePathRelationship(targetPath, sourcePath);
|
||||
const isSubdirectory = isSubdirectoryOf(targetPath, sourcePath);
|
||||
|
||||
// 添加详细的路径调试日誌輸出
|
||||
logger.verbose(`路径正规化处理:`);
|
||||
logger.verbose(
|
||||
` 源路径: ${sourcePath} -> ${pathAnalysis.normalizedSource}`,
|
||||
);
|
||||
logger.verbose(
|
||||
` 目标路径: ${targetPath} -> ${pathAnalysis.normalizedTarget}`,
|
||||
);
|
||||
|
||||
logger.verbose(`子目录检测结果: ${isSubdirectory}`);
|
||||
if (isSubdirectory) {
|
||||
logger.verbose(`子目录检测详情:`);
|
||||
logger.verbose(` startsWith 检查: ${pathAnalysis.startsWithCheck}`);
|
||||
logger.verbose(` 相等检查: ${pathAnalysis.equalityCheck}`);
|
||||
logger.verbose(` 路径分隔符: '${pathAnalysis.separator}'`);
|
||||
}
|
||||
|
||||
// 配置驗證和用戶友好的錯誤處理
|
||||
await validateAndWarnPathConfiguration(
|
||||
config,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
isSubdirectory,
|
||||
pathAnalysis,
|
||||
logger,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`正在同步 ${sourcePath} 到 ${targetPath} (模式: ${config.mode || "incremental"})`,
|
||||
);
|
||||
|
||||
try {
|
||||
// 如果目标是源的子目录,使用压缩方案
|
||||
if (isSubdirectory) {
|
||||
logger.info(
|
||||
`目标路径 ${targetPath} 是源路径 ${sourcePath} 的子目录或相同目录,使用压缩同步方案。`,
|
||||
);
|
||||
await syncViaCompression(sourcePath, targetPath, config, logger);
|
||||
logger.info(`成功同步 ${config.source} 到 ${target}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 以下是原来的同步逻辑,处理非子目录的情况
|
||||
if (config.clearTarget) {
|
||||
logger.info(`正在清空目标目录 ${targetPath}...`);
|
||||
await fs.emptyDir(targetPath);
|
||||
}
|
||||
|
||||
await fs.ensureDir(path.dirname(targetPath)); // 确保目标父目录存在
|
||||
|
||||
const options: fs.CopyOptions = {
|
||||
overwrite: config.mode !== "copy" && !config.addOnly, // 镜像和增量模式时覆盖
|
||||
errorOnExist: false, // 避免在copy模式时出错
|
||||
filter: (src, dest) => {
|
||||
if (config.addOnly && fs.existsSync(dest)) {
|
||||
logger.verbose(`跳过 ${src} 因为它已存在于目标中 (仅添加模式)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取相对于源路径的相对路径
|
||||
const relativeSrc = path.relative(sourcePath, src);
|
||||
|
||||
// 如果是根目录的情况,需要特殊处理以匹配排除规则
|
||||
if (config.source === "/" && relativeSrc) {
|
||||
// 检查是否匹配任何排除目录
|
||||
const firstSegment = relativeSrc.split(path.sep)[0];
|
||||
|
||||
// 检查顶级目录是否在排除列表中
|
||||
if (
|
||||
config.excludeDirs?.some((dir) => {
|
||||
// 去掉可能的通配符前缀,获取基本目录名
|
||||
const baseDirName = dir.replace(/^\*\*\//, "");
|
||||
return (
|
||||
firstSegment === baseDirName ||
|
||||
picomatch.isMatch(relativeSrc, dir)
|
||||
);
|
||||
})
|
||||
) {
|
||||
logger.verbose(
|
||||
`排除目录 ${relativeSrc} 因为匹配 'excludeDirs' glob/正则`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 正则排除(文件和目录)
|
||||
if (
|
||||
config.exclude?.some((pattern) =>
|
||||
new RegExp(pattern).test(relativeSrc),
|
||||
)
|
||||
) {
|
||||
logger.verbose(`排除 ${relativeSrc} 因为匹配 'exclude' 正则`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(src);
|
||||
if (stats.isDirectory()) {
|
||||
if (
|
||||
config.excludeDirs?.some((pattern) =>
|
||||
picomatch.isMatch(relativeSrc, pattern),
|
||||
)
|
||||
) {
|
||||
logger.verbose(
|
||||
`排除目录 ${relativeSrc} 因为匹配 'excludeDirs' glob/正则`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
config.excludeFiles?.some((pattern) =>
|
||||
picomatch.isMatch(relativeSrc, pattern),
|
||||
)
|
||||
) {
|
||||
logger.verbose(
|
||||
`排除文件 ${relativeSrc} 因为匹配 'excludeFiles' glob/正则`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
if (config.mode === "mirror") {
|
||||
// 对于镜像模式,fs-extra的copySync/copy不会删除多余的文件
|
||||
logger.info(
|
||||
`正在镜像同步 ${sourcePath} 到 ${targetPath}。注意:真正的镜像可能需要目标为空或由'clearTarget'处理`,
|
||||
);
|
||||
|
||||
// 实现真正的镜像模式
|
||||
if (!config.clearTarget) {
|
||||
// 如果未使用clearTarget,我们需要自己实现镜像逻辑
|
||||
// 1. 获取目标中的所有文件
|
||||
const targetFiles = await getAllFiles(targetPath);
|
||||
|
||||
// 2. 复制源到目标
|
||||
await fs.copy(sourcePath, targetPath, options);
|
||||
|
||||
// 3. 重新获取所有源文件(现在已复制到目标)
|
||||
const sourceFiles = await getAllFiles(sourcePath);
|
||||
const sourceRelativePaths = sourceFiles.map((file) =>
|
||||
path.relative(sourcePath, file),
|
||||
);
|
||||
|
||||
// 4. 删除目标中不在源中的文件
|
||||
for (const targetFile of targetFiles) {
|
||||
const relativePath = path.relative(targetPath, targetFile);
|
||||
if (
|
||||
!sourceRelativePaths.includes(relativePath) &&
|
||||
fs.statSync(targetFile).isFile()
|
||||
) {
|
||||
logger.verbose(`删除目标中多余的文件: ${targetFile}`);
|
||||
await fs.remove(targetFile);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果使用了clearTarget,直接复制即可
|
||||
await fs.copy(sourcePath, targetPath, options);
|
||||
}
|
||||
} else {
|
||||
// 复制或增量模式
|
||||
await fs.copy(sourcePath, targetPath, options);
|
||||
}
|
||||
|
||||
logger.info(`成功同步 ${config.source} 到 ${target}`);
|
||||
} catch (error: any) {
|
||||
// 增強的錯誤處理,提供具體的診斷信息
|
||||
handleSyncError(error, sourcePath, targetPath, config, logger);
|
||||
// 软错误:继续执行其他任务
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info("本地文件同步完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 驗證路徑配置並提供用戶友好的警告和建議
|
||||
* @param config 同步配置
|
||||
* @param sourcePath 源路徑
|
||||
* @param targetPath 目標路徑
|
||||
* @param isSubdirectory 是否為子目錄
|
||||
* @param pathAnalysis 路徑分析結果
|
||||
* @param logger 日誌記錄器
|
||||
*/
|
||||
async function validateAndWarnPathConfiguration(
|
||||
config: LocalSyncConfig,
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
isSubdirectory: boolean,
|
||||
pathAnalysis: ReturnType<typeof analyzePathRelationship>,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
// 檢查相同路徑的情況
|
||||
if (pathAnalysis.equalityCheck) {
|
||||
logger.warn(`⚠️ 源路徑和目標路徑相同: ${sourcePath}`);
|
||||
logger.warn(` 這可能表示配置錯誤,請檢查您的 localSync 配置`);
|
||||
logger.warn(` 建議:修改 target 路徑以避免自我複製`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 檢查子目錄情況的配置建議
|
||||
if (isSubdirectory) {
|
||||
logger.info(`🔍 檢測到目標路徑是源路徑的子目錄,將使用壓縮方案`);
|
||||
|
||||
// 針對不同模式提供建議
|
||||
if (config.mode === "mirror") {
|
||||
logger.warn(`⚠️ 鏡像模式 + 子目錄配置可能導致不必要的複雜性`);
|
||||
logger.warn(` 建議:考慮使用 'copy' 或 'incremental' 模式`);
|
||||
}
|
||||
|
||||
// 檢查是否缺少必要的排除配置
|
||||
if (!config.excludeDirs || config.excludeDirs.length === 0) {
|
||||
logger.warn(`⚠️ 子目錄同步時建議配置 excludeDirs 以避免無限遞歸`);
|
||||
logger.warn(
|
||||
` 建議:添加 excludeDirs: ['.sync-git', 'node_modules', '.git']`,
|
||||
);
|
||||
}
|
||||
|
||||
// 特別警告常見的錯誤模式
|
||||
const relativePath = path.relative(sourcePath, targetPath);
|
||||
if (relativePath.includes(".sync-git")) {
|
||||
logger.info(`✅ 檢測到目標在 .sync-git 目錄中,這是推薦的配置`);
|
||||
} else {
|
||||
logger.warn(`⚠️ 目標路徑不在 .sync-git 目錄中: ${relativePath}`);
|
||||
logger.warn(` 建議:將目標設置為 '.sync-git/your-target' 以保持組織性`);
|
||||
}
|
||||
}
|
||||
|
||||
// 檢查路徑格式問題
|
||||
if (sourcePath.includes("\\") && targetPath.includes("/")) {
|
||||
logger.warn(`⚠️ 檢測到混合路徑分隔符,已自動正規化處理`);
|
||||
logger.info(` 原始: 源='${sourcePath}' 目標='${targetPath}'`);
|
||||
logger.info(
|
||||
` 正規化: 源='${pathAnalysis.normalizedSource}' 目標='${pathAnalysis.normalizedTarget}'`,
|
||||
);
|
||||
}
|
||||
|
||||
// 檢查潛在的性能問題
|
||||
if (config.source === "/" && !config.excludeDirs?.includes("node_modules")) {
|
||||
logger.warn(`⚠️ 從根目錄 '/' 同步時強烈建議排除 node_modules`);
|
||||
logger.warn(
|
||||
` 建議:添加 excludeDirs: ['node_modules', '.git', 'dist', 'build']`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归获取目录中的所有文件路径
|
||||
* @param dir 要扫描的目录
|
||||
* @returns 文件路径数组
|
||||
*/
|
||||
async function getAllFiles(dir: string): Promise<string[]> {
|
||||
let results: string[] = [];
|
||||
if (!fs.existsSync(dir)) return results;
|
||||
|
||||
const list = await fs.readdir(dir);
|
||||
for (const file of list) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
const subFiles = await getAllFiles(filePath);
|
||||
results = results.concat(subFiles);
|
||||
} else {
|
||||
results.push(filePath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
105
frontend/plugin/vite-plugin-turborepo-deploy/src/core/logger.ts
Normal file
105
frontend/plugin/vite-plugin-turborepo-deploy/src/core/logger.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import chalk from "chalk";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
|
||||
export type LogLevel = "error" | "warn" | "info" | "verbose";
|
||||
|
||||
export interface Logger {
|
||||
error: (message: string, error?: Error) => void;
|
||||
warn: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
verbose: (message: string) => void;
|
||||
setLogLevel: (level: LogLevel) => void;
|
||||
}
|
||||
|
||||
const LogLevelOrder: Record<LogLevel, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
verbose: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建日志记录器
|
||||
*
|
||||
* @param workspaceRoot 工作区根目录
|
||||
* @param initialLevel 初始日志级别
|
||||
* @param writeToFile 是否写入日志文件
|
||||
* @param logDir 日志目录路径
|
||||
* @returns 日志记录器实例
|
||||
*/
|
||||
export function createLogger(
|
||||
workspaceRoot: string,
|
||||
initialLevel: LogLevel = "info",
|
||||
writeToFile: boolean = true,
|
||||
logDir: string = ".sync-log",
|
||||
): Logger {
|
||||
let currentLogLevel = initialLevel;
|
||||
const pluginName = chalk.cyan("[vite-plugin-turborepo-deploy]");
|
||||
|
||||
// 确保日志目录存在
|
||||
const logDirPath = path.isAbsolute(logDir)
|
||||
? logDir
|
||||
: path.resolve(workspaceRoot, logDir);
|
||||
if (writeToFile) {
|
||||
fs.ensureDirSync(logDirPath);
|
||||
}
|
||||
|
||||
// 创建日志文件名(按日期)
|
||||
const today = new Date();
|
||||
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
const logFilePath = path.join(logDirPath, `${dateStr}_deploy.log`);
|
||||
|
||||
const log = (level: LogLevel, message: string, error?: Error) => {
|
||||
if (LogLevelOrder[level] <= LogLevelOrder[currentLogLevel]) {
|
||||
// 控制台输出
|
||||
let formattedMessage = `${pluginName} `;
|
||||
if (level === "error") formattedMessage += chalk.red(`ERROR: ${message}`);
|
||||
else if (level === "warn")
|
||||
formattedMessage += chalk.yellow(`WARN: ${message}`);
|
||||
else if (level === "info") formattedMessage += chalk.green(message);
|
||||
else formattedMessage += chalk.dim(message);
|
||||
|
||||
console.log(formattedMessage);
|
||||
if (
|
||||
error &&
|
||||
(level === "error" ||
|
||||
LogLevelOrder.verbose <= LogLevelOrder[currentLogLevel])
|
||||
) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
// 文件日志
|
||||
if (writeToFile) {
|
||||
try {
|
||||
const timestamp = new Date().toISOString();
|
||||
let logEntry = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
|
||||
|
||||
if (
|
||||
error &&
|
||||
(level === "error" ||
|
||||
LogLevelOrder.verbose <= LogLevelOrder[currentLogLevel])
|
||||
) {
|
||||
logEntry += `[${timestamp}] [${level.toUpperCase()}] Error details: ${error.stack || error.message}\n`;
|
||||
}
|
||||
|
||||
fs.appendFileSync(logFilePath, logEntry);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`${pluginName} ${chalk.red(`ERROR: Failed to write to log file: ${e instanceof Error ? e.message : "Unknown error"}`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
error: (message, error) => log("error", message, error),
|
||||
warn: (message) => log("warn", message),
|
||||
info: (message) => log("info", message),
|
||||
verbose: (message) => log("verbose", message),
|
||||
setLogLevel: (level: LogLevel) => {
|
||||
currentLogLevel = level;
|
||||
},
|
||||
};
|
||||
}
|
||||
195
frontend/plugin/vite-plugin-turborepo-deploy/src/core/utils.ts
Normal file
195
frontend/plugin/vite-plugin-turborepo-deploy/src/core/utils.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
// plugin/vite-plugin-turborepo-deploy/src/core/utils.ts
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
|
||||
/**
|
||||
* 检测并获取 Turborepo 工作区根目录
|
||||
* 通过查找 turbo.json 或 package.json 中的 workspaces 配置来确定
|
||||
*
|
||||
* @param startDir 开始搜索的目录(通常是 Vite 项目根目录)
|
||||
* @returns 工作区根目录的绝对路径,如果未找到则返回 startDir
|
||||
*/
|
||||
export function findWorkspaceRoot(startDir: string): string {
|
||||
let currentDir = startDir;
|
||||
|
||||
// 限制向上查找的层级,避免无限循环
|
||||
const maxLevels = 10;
|
||||
let level = 0;
|
||||
|
||||
while (level < maxLevels) {
|
||||
// 检查 turbo.json 是否存在(Turborepo 项目标志)
|
||||
if (fs.existsSync(path.join(currentDir, "turbo.json"))) {
|
||||
return currentDir;
|
||||
}
|
||||
|
||||
// 检查 package.json 中的 workspaces 配置(pnpm/yarn/npm workspace)
|
||||
const packageJsonPath = path.join(currentDir, "package.json");
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageJson = fs.readJSONSync(packageJsonPath);
|
||||
if (packageJson.workspaces || packageJson.pnpm?.workspaces) {
|
||||
return currentDir;
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果读取出错,继续向上查找
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 pnpm-workspace.yaml(pnpm workspace)
|
||||
if (fs.existsSync(path.join(currentDir, "pnpm-workspace.yaml"))) {
|
||||
return currentDir;
|
||||
}
|
||||
|
||||
// 向上一级目录继续搜索
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
// 已经到达根目录,停止搜索
|
||||
break;
|
||||
}
|
||||
|
||||
currentDir = parentDir;
|
||||
level++;
|
||||
}
|
||||
|
||||
// 未找到工作区根目录,返回原始目录
|
||||
return startDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析相对于工作区根目录的路径
|
||||
* @param viteRoot Vite项目的根目录
|
||||
* @param relativePath 要解析的相对路径
|
||||
* @returns 绝对路径
|
||||
*/
|
||||
export function resolvePath(viteRoot: string, relativePath: string): string {
|
||||
const workspaceRoot = findWorkspaceRoot(viteRoot);
|
||||
return path.resolve(workspaceRoot, relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带有时间戳的错误对象,可以标记为关键错误
|
||||
* @param message 错误消息
|
||||
* @param isCritical 是否为关键错误(会中断整个流程)
|
||||
* @returns 带有附加属性的Error对象
|
||||
*/
|
||||
export function createError(
|
||||
message: string,
|
||||
isCritical = false,
|
||||
): Error & { isCritical: boolean; timestamp: Date } {
|
||||
const error = new Error(message) as Error & {
|
||||
isCritical: boolean;
|
||||
timestamp: Date;
|
||||
};
|
||||
error.isCritical = isCritical;
|
||||
error.timestamp = new Date();
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保目录存在,如果不存在则创建
|
||||
* @param dirPath 目录路径
|
||||
*/
|
||||
export async function ensureDirectoryExists(dirPath: string): Promise<void> {
|
||||
await fs.ensureDir(dirPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为YYYY-MM-DD格式
|
||||
* @param date 日期对象
|
||||
* @returns 格式化的日期字符串
|
||||
*/
|
||||
export function formatDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路径是否为绝对路径
|
||||
* @param filePath 文件路径
|
||||
* @returns 是否为绝对路径
|
||||
*/
|
||||
export function isAbsolutePath(filePath: string): boolean {
|
||||
return path.isAbsolute(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地删除文件,如果文件不存在则忽略错误
|
||||
* @param filePath 文件路径
|
||||
*/
|
||||
export async function safeRemoveFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
await fs.remove(filePath);
|
||||
} catch (error) {
|
||||
// 如果文件不存在,忽略错误
|
||||
if (error instanceof Error && error.message === "ENOENT") {
|
||||
// 将未知类型的 error 转换为正确的类型或处理可能不存在的 code 属性
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正規化多個路徑,解決跨平台兼容性問題
|
||||
* @param paths 需要正規化的路徑數組
|
||||
* @returns 正規化後的路徑數組
|
||||
*/
|
||||
export function normalizePaths(...paths: string[]): string[] {
|
||||
return paths.map((p) => path.normalize(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查目標路徑是否是源路徑的子目錄或相同目錄
|
||||
* 使用路徑正規化處理,確保跨平台兼容性
|
||||
* @param targetPath 目標路徑
|
||||
* @param sourcePath 源路徑
|
||||
* @returns 如果目標路徑是源路徑的子目錄或相同目錄則返回 true
|
||||
*/
|
||||
export function isSubdirectoryOf(
|
||||
targetPath: string,
|
||||
sourcePath: string,
|
||||
): boolean {
|
||||
// 正規化路徑以確保跨平台兼容性
|
||||
const normalizedTarget = path.normalize(targetPath);
|
||||
const normalizedSource = path.normalize(sourcePath);
|
||||
|
||||
// 檢查是否為子目錄或相同目錄
|
||||
return (
|
||||
normalizedTarget.startsWith(normalizedSource + path.sep) ||
|
||||
normalizedTarget === normalizedSource
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查路徑關係的詳細信息,便於調試
|
||||
* @param targetPath 目標路徑
|
||||
* @param sourcePath 源路徑
|
||||
* @returns 包含檢查詳情的對象
|
||||
*/
|
||||
export function analyzePathRelationship(
|
||||
targetPath: string,
|
||||
sourcePath: string,
|
||||
): {
|
||||
isSubdirectory: boolean;
|
||||
normalizedTarget: string;
|
||||
normalizedSource: string;
|
||||
startsWithCheck: boolean;
|
||||
equalityCheck: boolean;
|
||||
separator: string;
|
||||
} {
|
||||
const normalizedTarget = path.normalize(targetPath);
|
||||
const normalizedSource = path.normalize(sourcePath);
|
||||
const startsWithCheck = normalizedTarget.startsWith(
|
||||
normalizedSource + path.sep,
|
||||
);
|
||||
const equalityCheck = normalizedTarget === normalizedSource;
|
||||
|
||||
return {
|
||||
isSubdirectory: startsWithCheck || equalityCheck,
|
||||
normalizedTarget,
|
||||
normalizedSource,
|
||||
startsWithCheck,
|
||||
equalityCheck,
|
||||
separator: path.sep,
|
||||
};
|
||||
}
|
||||
118
frontend/plugin/vite-plugin-turborepo-deploy/src/index.ts
Normal file
118
frontend/plugin/vite-plugin-turborepo-deploy/src/index.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { Plugin, ResolvedConfig } from 'vite';
|
||||
import { VitePluginTurborepoDeployOptions, TurborepoDeployConfig } from './types';
|
||||
import { loadConfig } from "./core/config";
|
||||
import { createLogger } from "./core/logger";
|
||||
import { performLocalSync } from "./core/localSync";
|
||||
import { updateGitProjects } from "./core/gitHandler";
|
||||
import { performAutoCommit } from "./core/autoCommitHandler";
|
||||
import { findWorkspaceRoot } from "./core/utils";
|
||||
import path from "path";
|
||||
|
||||
export default function turborepoDeploy(
|
||||
options?: VitePluginTurborepoDeployOptions,
|
||||
): Plugin {
|
||||
let viteConfig: ResolvedConfig;
|
||||
let pluginConfig: TurborepoDeployConfig;
|
||||
let logger: ReturnType<typeof createLogger>;
|
||||
let workspaceRoot: string;
|
||||
|
||||
// 共享提交信息的状态容器
|
||||
const sharedCommitMessagesHolder = { current: null as string[] | null };
|
||||
|
||||
return {
|
||||
name: "vite-plugin-turborepo-deploy",
|
||||
apply: "build", // 仅在构建过程中应用
|
||||
|
||||
// 配置解析时钩子
|
||||
configResolved(resolvedConfig) {
|
||||
viteConfig = resolvedConfig;
|
||||
|
||||
// 获取工作区根目录
|
||||
workspaceRoot = findWorkspaceRoot(viteConfig.root);
|
||||
const isWorkspace = workspaceRoot !== viteConfig.root;
|
||||
|
||||
// 创建日志记录器,基于工作区根目录
|
||||
const logDir = options?.logger?.logDir || ".sync-log";
|
||||
const logPath = path.isAbsolute(logDir)
|
||||
? logDir
|
||||
: path.resolve(workspaceRoot, logDir);
|
||||
|
||||
logger = createLogger(
|
||||
workspaceRoot,
|
||||
options?.logger?.level || "info",
|
||||
options?.logger?.writeToFile !== false, // 默认为true
|
||||
logPath,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`检测到${isWorkspace ? "Turborepo工作区," : ""}根目录: ${workspaceRoot}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// 加载配置,使用工作区根目录
|
||||
pluginConfig = loadConfig(options, workspaceRoot);
|
||||
logger.info("Turborepo Deploy 插件已配置。");
|
||||
} catch (error: any) {
|
||||
logger.error(`配置错误: ${error.message}`);
|
||||
throw error; // 配置无效时停止构建
|
||||
}
|
||||
},
|
||||
|
||||
// 关闭构建时钩子:执行所有任务
|
||||
async closeBundle() {
|
||||
if (Object.keys(pluginConfig).length === 0) {
|
||||
logger.info("未配置部署任务。");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("开始执行部署任务...");
|
||||
|
||||
try {
|
||||
// 1. 首先执行Git项目管理
|
||||
if (pluginConfig.gitProjects && pluginConfig.gitProjects.length > 0) {
|
||||
logger.info("开始执行Git项目管理...");
|
||||
try {
|
||||
await updateGitProjects(
|
||||
pluginConfig.gitProjects,
|
||||
workspaceRoot,
|
||||
logger,
|
||||
);
|
||||
logger.info("Git项目初始化任务成功完成。");
|
||||
} catch (e: any) {
|
||||
logger.error(`Git项目初始化错误: ${e.message}`, e);
|
||||
// Git项目管理失败必须终止后续任务
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
logger.info("未配置Git项目,跳过Git项目初始化阶段。");
|
||||
}
|
||||
|
||||
// 2. 执行本地文件同步
|
||||
if (pluginConfig.localSync && pluginConfig.localSync.length > 0) {
|
||||
logger.info("开始执行本地文件同步...");
|
||||
await performLocalSync(pluginConfig.localSync, workspaceRoot, logger);
|
||||
logger.info("本地文件同步任务完成。");
|
||||
}
|
||||
|
||||
// 3. 执行自动提交(重置共享提交信息)
|
||||
if (pluginConfig.autoCommit) {
|
||||
logger.info("开始执行智能自动提交...");
|
||||
sharedCommitMessagesHolder.current = null; // 重置共享提交信息
|
||||
await performAutoCommit(
|
||||
pluginConfig.autoCommit,
|
||||
workspaceRoot,
|
||||
logger,
|
||||
sharedCommitMessagesHolder,
|
||||
);
|
||||
logger.info("智能自动提交任务完成。");
|
||||
}
|
||||
|
||||
logger.info("所有部署任务成功完成。");
|
||||
} catch (e: any) {
|
||||
logger.error(`部署错误: ${e.message}`, e);
|
||||
// 关键错误终止整个流程
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
246
frontend/plugin/vite-plugin-turborepo-deploy/src/types.ts
Normal file
246
frontend/plugin/vite-plugin-turborepo-deploy/src/types.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Configuration for individual Git project auto-commit behavior.
|
||||
* @deprecated This interface is deprecated and will be removed in a future version. Use AutoCommitConfig instead.
|
||||
*/
|
||||
export interface GitProjectAutoCommitConfig {
|
||||
/**
|
||||
* Whether to enable auto-commit for this project.
|
||||
* @default false
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* The Git author username to watch for commits.
|
||||
* Required if `useSharedCommits` is false or if this project is intended as a source for shared commits.
|
||||
*/
|
||||
watchAuthor?: string;
|
||||
/**
|
||||
* Maximum number of recent commits to scan.
|
||||
* @default 50
|
||||
*/
|
||||
maxScanCount?: number;
|
||||
/**
|
||||
* Special marker string to identify commit segment points.
|
||||
* @default "/** 提交分隔符 **\/"
|
||||
*/
|
||||
commitSeparator?: string;
|
||||
/**
|
||||
* Template for the auto-generated commit message.
|
||||
* (Optional, a default will be provided if not set)
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* Whether to push to the remote repository after committing.
|
||||
* @default false
|
||||
*/
|
||||
push?: boolean;
|
||||
/**
|
||||
* Whether to attempt using shared commit information from a previous project.
|
||||
* If true and shared info is available, `watchAuthor`, `maxScanCount`, etc., might be skipped for this project.
|
||||
* If shared info is not available, it will fall back to its own scanning logic if configured.
|
||||
* @default false
|
||||
*/
|
||||
useSharedCommits?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for managing a single Git project.
|
||||
*/
|
||||
export interface GitProjectConfig {
|
||||
/**
|
||||
* The repository URL (SSH or HTTPS).
|
||||
*/
|
||||
repo: string;
|
||||
/**
|
||||
* The target branch to checkout and operate on.
|
||||
*/
|
||||
branch: string;
|
||||
/**
|
||||
* Directory to store the cloned/updated project.
|
||||
* Note: All Git projects will be placed under the `.sync-git` directory in workspace root.
|
||||
* This path is relative to the `.sync-git` directory, not to the workspace root directly.
|
||||
* For example, if targetDir is 'api', the actual location will be '<workspace_root>/.sync-git/api'.
|
||||
*/
|
||||
targetDir: string;
|
||||
/**
|
||||
* Optional: A name for the project, used for logging and potentially as an identifier for shared commits.
|
||||
*/
|
||||
projectName?: string;
|
||||
/**
|
||||
* Whether to update the project if it already exists.
|
||||
* @default true
|
||||
*/
|
||||
updateIfExists?: boolean;
|
||||
/**
|
||||
* Whether to discard all uncommitted changes before pulling.
|
||||
* If true, runs git checkout -- . && git clean -fd to remove all local changes.
|
||||
* Use with caution, as this will permanently delete local changes.
|
||||
* @default false
|
||||
*/
|
||||
discardChanges?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the auto-commit module which operates independently.
|
||||
*/
|
||||
export interface AutoCommitConfig {
|
||||
/**
|
||||
* Git projects to run auto-commit operations on
|
||||
*/
|
||||
projects: Array<{
|
||||
/**
|
||||
* Directory of the git project (relative to workspace root)
|
||||
*/
|
||||
targetDir: string;
|
||||
/**
|
||||
* Optional: A name for the project, used for logging and as identifier for shared commits.
|
||||
* If not provided, targetDir will be used as the project name.
|
||||
*/
|
||||
projectName?: string;
|
||||
/**
|
||||
* The Git author username to watch for commits.
|
||||
* Required if `useSharedCommits` is false or if this project is intended as a source for shared commits.
|
||||
*/
|
||||
watchAuthor?: string;
|
||||
/**
|
||||
* Maximum number of recent commits to scan.
|
||||
* @default 50
|
||||
*/
|
||||
maxScanCount?: number;
|
||||
/**
|
||||
* Special marker string to identify commit segment points.
|
||||
* @default "/** 提交分隔符 **\/"
|
||||
*/
|
||||
commitSeparator?: string;
|
||||
/**
|
||||
* Template for the auto-generated commit message.
|
||||
* (Optional, a default will be provided if not set)
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* Whether to push to the remote repository after committing.
|
||||
* @default false
|
||||
*/
|
||||
push?: boolean;
|
||||
/**
|
||||
* Whether to attempt using shared commit information from a previous project.
|
||||
* @default false
|
||||
*/
|
||||
useSharedCommits?: boolean;
|
||||
/**
|
||||
* Target branch to perform auto-commit on.
|
||||
* If not specified, the current branch will be used.
|
||||
*/
|
||||
branch?: string;
|
||||
}>;
|
||||
/**
|
||||
* Whether to insert commit separator after auto-commit
|
||||
* @default true
|
||||
*/
|
||||
insertSeparator?: boolean;
|
||||
/**
|
||||
* Whether to enable shared commit buffer across projects
|
||||
* @default true
|
||||
*/
|
||||
enableSharedCommits?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a single local file/directory synchronization task.
|
||||
*/
|
||||
export interface LocalSyncConfig {
|
||||
/**
|
||||
* Source directory/file (relative to workspace root).
|
||||
*/
|
||||
source: string;
|
||||
/**
|
||||
* Target directory/file (relative to workspace root).
|
||||
* Can be a single path or an array of paths for distribution to multiple targets.
|
||||
*/
|
||||
target: string | string[];
|
||||
/**
|
||||
* Synchronization mode.
|
||||
* - `copy`: Simple copy, doesn\'t handle existing files in target.
|
||||
* - `mirror`: Mirror sync, deletes files in target not present in source.
|
||||
* - `incremental`: Incremental update, only overwrites changed files.
|
||||
* @default \'incremental\'
|
||||
*/
|
||||
mode?: "copy" | "mirror" | "incremental";
|
||||
/**
|
||||
* Whether to clear the target directory before synchronization.
|
||||
* @default false
|
||||
*/
|
||||
clearTarget?: boolean;
|
||||
/**
|
||||
* If true, only adds files/directories from source that do not exist in target.
|
||||
* Does not modify or delete existing files in target.
|
||||
* @default false
|
||||
*/
|
||||
addOnly?: boolean;
|
||||
/**
|
||||
* Array of regular expressions to exclude files/directories.
|
||||
*/
|
||||
exclude?: string[];
|
||||
/**
|
||||
* Array of glob patterns or regular expressions for directories to exclude.
|
||||
*/
|
||||
excludeDirs?: string[];
|
||||
/**
|
||||
* Array of glob patterns or regular expressions for files to exclude.
|
||||
*/
|
||||
excludeFiles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the logger.
|
||||
*/
|
||||
export interface LoggerConfig {
|
||||
/**
|
||||
* The log level to use.
|
||||
* - `error`: Only log errors.
|
||||
* - `warn`: Log errors, warnings, and info messages.
|
||||
* - `verbose`: Log all messages including debug information.
|
||||
* @default 'info'
|
||||
*/
|
||||
level?: "error" | "warn" | "info" | "verbose";
|
||||
|
||||
/**
|
||||
* Whether to write logs to file.
|
||||
* @default true
|
||||
*/
|
||||
writeToFile?: boolean;
|
||||
|
||||
/**
|
||||
* Directory to store log files, relative to workspace root.
|
||||
* @default '.sync-log'
|
||||
*/
|
||||
logDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main configuration for the Turborepo Deploy Vite plugin.
|
||||
*/
|
||||
export interface TurborepoDeployConfig {
|
||||
/**
|
||||
* Configuration for local file/directory synchronization tasks.
|
||||
* 在编译后执行。
|
||||
*/
|
||||
localSync?: Array<LocalSyncConfig>;
|
||||
/**
|
||||
* Configuration for Git project management (clone/update).
|
||||
* 在编译前执行。
|
||||
*/
|
||||
gitProjects?: Array<GitProjectConfig>;
|
||||
/**
|
||||
* Configuration for auto-commit functionality.
|
||||
* This runs separately after build.
|
||||
* 在编译后执行。
|
||||
*/
|
||||
autoCommit?: AutoCommitConfig;
|
||||
/**
|
||||
* Logger configuration.
|
||||
*/
|
||||
logger?: LoggerConfig;
|
||||
}
|
||||
|
||||
// Utility type for the plugin itself
|
||||
export interface VitePluginTurborepoDeployOptions extends TurborepoDeployConfig {}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { performAutoCommit } from "../../src/core/autoCommitHandler";
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
|
||||
// 模拟依赖
|
||||
vi.mock("simple-git", () => {
|
||||
// 创建简单的模拟实现
|
||||
const mockGit = {
|
||||
checkIsRepo: vi.fn().mockResolvedValue(true),
|
||||
branchLocal: vi.fn().mockResolvedValue({ current: "develop" }),
|
||||
log: vi.fn().mockResolvedValue({
|
||||
all: [
|
||||
{ hash: "1234567", message: "一般提交1" },
|
||||
{ hash: "2345678", message: "一般提交2" },
|
||||
{ hash: "3456789", message: "/** 提交分隔符 **/" },
|
||||
{ hash: "4567890", message: "之前的提交" },
|
||||
],
|
||||
}),
|
||||
status: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ isClean: vi.fn().mockReturnValue(true) }),
|
||||
add: vi.fn().mockResolvedValue(undefined),
|
||||
commit: vi.fn().mockResolvedValue(undefined),
|
||||
push: vi.fn().mockResolvedValue(undefined),
|
||||
checkout: vi.fn().mockResolvedValue(undefined),
|
||||
reset: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
return {
|
||||
default: vi.fn().mockReturnValue(mockGit),
|
||||
__esModule: true,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("fs-extra", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
__esModule: true,
|
||||
}));
|
||||
|
||||
// 模拟日志记录器
|
||||
const mockLogger = {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
verbose: vi.fn(),
|
||||
setLogLevel: vi.fn(),
|
||||
};
|
||||
|
||||
describe("performAutoCommit", () => {
|
||||
const mockViteRoot = "/test/root";
|
||||
const mockSharedCommitMessagesHolder = { current: null as string[] | null };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("应该处理单个项目的自动提交 (独立模块)", async () => {
|
||||
const config = {
|
||||
projects: [
|
||||
{
|
||||
targetDir: "services/api-gateway",
|
||||
projectName: "api-gateway",
|
||||
watchAuthor: "张三",
|
||||
push: true,
|
||||
},
|
||||
],
|
||||
enableSharedCommits: true,
|
||||
insertSeparator: true,
|
||||
};
|
||||
|
||||
await performAutoCommit(
|
||||
config,
|
||||
mockViteRoot,
|
||||
mockLogger,
|
||||
mockSharedCommitMessagesHolder,
|
||||
);
|
||||
|
||||
// 验证日志和基本操作
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("开始自动提交操作...");
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("已重置共享提交信息缓冲区");
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"处理自动提交项目: api-gateway",
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("自动提交操作完成");
|
||||
|
||||
// 验证Git操作
|
||||
const simpleGit = require("simple-git").default;
|
||||
const mockGit = simpleGit();
|
||||
expect(mockGit.log).toHaveBeenCalled();
|
||||
expect(mockGit.commit).toHaveBeenCalledTimes(2); // 一次正常提交,一次分隔符
|
||||
expect(mockGit.push).toHaveBeenCalledTimes(2); // 一次正常推送,一次分隔符推送
|
||||
});
|
||||
|
||||
it("应该在禁用共享提交时不重置共享缓冲区", async () => {
|
||||
const config = {
|
||||
projects: [
|
||||
{
|
||||
targetDir: "services/api-gateway",
|
||||
projectName: "api-gateway",
|
||||
watchAuthor: "张三",
|
||||
},
|
||||
],
|
||||
enableSharedCommits: false,
|
||||
insertSeparator: true,
|
||||
};
|
||||
|
||||
await performAutoCommit(
|
||||
config,
|
||||
mockViteRoot,
|
||||
mockLogger,
|
||||
mockSharedCommitMessagesHolder,
|
||||
);
|
||||
|
||||
// 验证没有重置共享缓冲区
|
||||
expect(mockLogger.info).not.toHaveBeenCalledWith(
|
||||
"已重置共享提交信息缓冲区",
|
||||
);
|
||||
});
|
||||
|
||||
it("应该在项目不存在时跳过处理", async () => {
|
||||
// 模拟文件不存在
|
||||
vi.mocked(fs.existsSync).mockReturnValueOnce(false);
|
||||
|
||||
const config = {
|
||||
projects: [
|
||||
{
|
||||
targetDir: "services/non-existent",
|
||||
projectName: "non-existent",
|
||||
watchAuthor: "张三",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await performAutoCommit(
|
||||
config,
|
||||
mockViteRoot,
|
||||
mockLogger,
|
||||
mockSharedCommitMessagesHolder,
|
||||
);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("不存在,跳过此项目"),
|
||||
);
|
||||
// 验证没有进行Git操作
|
||||
const simpleGit = require("simple-git").default;
|
||||
expect(simpleGit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("应该使用共享提交信息", async () => {
|
||||
// 设置共享提交信息
|
||||
mockSharedCommitMessagesHolder.current = ["[abcdef1] 共享的提交信息"];
|
||||
|
||||
const config = {
|
||||
projects: [
|
||||
{
|
||||
targetDir: "services/consumer",
|
||||
projectName: "consumer",
|
||||
useSharedCommits: true,
|
||||
push: true,
|
||||
},
|
||||
],
|
||||
enableSharedCommits: true,
|
||||
};
|
||||
|
||||
await performAutoCommit(
|
||||
config,
|
||||
mockViteRoot,
|
||||
mockLogger,
|
||||
mockSharedCommitMessagesHolder,
|
||||
);
|
||||
|
||||
// 验证使用了共享提交信息
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("[consumer] 使用共享提交信息");
|
||||
|
||||
// 验证提交操作
|
||||
const simpleGit = require("simple-git").default;
|
||||
const mockGit = simpleGit();
|
||||
expect(mockGit.commit).toHaveBeenCalled();
|
||||
expect(mockGit.push).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("应该在没有提交时跳过", async () => {
|
||||
// 模拟没有提交记录
|
||||
const simpleGit = require("simple-git").default;
|
||||
const mockGit = simpleGit();
|
||||
vi.mocked(mockGit.log).mockResolvedValueOnce({ all: [] });
|
||||
|
||||
const config = {
|
||||
projects: [
|
||||
{
|
||||
targetDir: "services/no-commits",
|
||||
projectName: "no-commits",
|
||||
watchAuthor: "张三",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await performAutoCommit(
|
||||
config,
|
||||
mockViteRoot,
|
||||
mockLogger,
|
||||
mockSharedCommitMessagesHolder,
|
||||
);
|
||||
|
||||
// 验证没有进行提交操作
|
||||
expect(mockGit.commit).not.toHaveBeenCalled();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"[no-commits] 没有要处理的新提交",
|
||||
);
|
||||
});
|
||||
|
||||
it("应该处理分支切换", async () => {
|
||||
const config = {
|
||||
projects: [
|
||||
{
|
||||
targetDir: "services/branch-test",
|
||||
projectName: "branch-test",
|
||||
watchAuthor: "张三",
|
||||
branch: "feature/test",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 模拟当前分支不是目标分支
|
||||
const simpleGit = require("simple-git").default;
|
||||
const mockGit = simpleGit();
|
||||
vi.mocked(mockGit.branchLocal).mockResolvedValueOnce({ current: "main" });
|
||||
|
||||
await performAutoCommit(
|
||||
config,
|
||||
mockViteRoot,
|
||||
mockLogger,
|
||||
mockSharedCommitMessagesHolder,
|
||||
);
|
||||
|
||||
// 验证分支切换
|
||||
expect(mockGit.checkout).toHaveBeenCalledWith("feature/test");
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("切换到分支 feature/test...");
|
||||
});
|
||||
|
||||
it("应该支持多项目并发处理", async () => {
|
||||
const config = {
|
||||
projects: [
|
||||
{
|
||||
targetDir: "services/api-gateway",
|
||||
projectName: "api-gateway",
|
||||
watchAuthor: "张三",
|
||||
push: true,
|
||||
},
|
||||
{
|
||||
targetDir: "services/user-service",
|
||||
projectName: "user-service",
|
||||
useSharedCommits: true,
|
||||
push: true,
|
||||
},
|
||||
],
|
||||
enableSharedCommits: true,
|
||||
};
|
||||
|
||||
await performAutoCommit(
|
||||
config,
|
||||
mockViteRoot,
|
||||
mockLogger,
|
||||
mockSharedCommitMessagesHolder,
|
||||
);
|
||||
|
||||
// 验证处理了两个项目
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"处理自动提交项目: api-gateway",
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"处理自动提交项目: user-service",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { loadConfig, processTasks } from "../../src/core/config";
|
||||
import path from "path";
|
||||
|
||||
// 模拟依赖
|
||||
vi.mock("../../src/core/logger", () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
verbose: vi.fn(),
|
||||
setLogLevel: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/core/localSync", () => ({
|
||||
performLocalSync: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/core/gitHandler", () => ({
|
||||
updateGitProjects: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// 导入模拟的模块
|
||||
import { performLocalSync } from "../../src/core/localSync";
|
||||
import { updateGitProjects } from "../../src/core/gitHandler";
|
||||
|
||||
describe("loadConfig", () => {
|
||||
it("应该返回空对象当没有提供选项时", () => {
|
||||
const config = loadConfig(undefined, "/test/root");
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it("应该验证并返回有效的配置", () => {
|
||||
const options = {
|
||||
localSync: [{ source: "src", target: "dist" }],
|
||||
gitProjects: [
|
||||
{
|
||||
repo: "https://github.com/example/repo.git",
|
||||
branch: "main",
|
||||
targetDir: "services/repo",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const config = loadConfig(options, "/test/root");
|
||||
expect(config).toEqual(options);
|
||||
});
|
||||
|
||||
it("应该在无效配置时抛出错误", () => {
|
||||
const invalidOptions = {
|
||||
localSync: [
|
||||
{ source: "", target: "dist" }, // 无效的source
|
||||
],
|
||||
};
|
||||
|
||||
expect(() => loadConfig(invalidOptions, "/test/root")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("processTasks", () => {
|
||||
const mockLogger = {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
verbose: vi.fn(),
|
||||
setLogLevel: vi.fn(),
|
||||
};
|
||||
|
||||
const mockViteConfig = {
|
||||
root: "/test/root",
|
||||
} as any;
|
||||
|
||||
const mockSharedCommitMessagesHolder = { current: null as string[] | null };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("应该按顺序执行配置的任务", async () => {
|
||||
const config = {
|
||||
localSync: [{ source: "src", target: "dist" }],
|
||||
gitProjects: [
|
||||
{
|
||||
repo: "https://github.com/example/repo.git",
|
||||
branch: "main",
|
||||
targetDir: "services/repo",
|
||||
},
|
||||
],
|
||||
taskOrder: ["localSync", "updateGitProjects"],
|
||||
};
|
||||
|
||||
await processTasks(
|
||||
config,
|
||||
mockViteConfig,
|
||||
mockLogger,
|
||||
mockSharedCommitMessagesHolder,
|
||||
);
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"执行任务顺序: localSync, updateGitProjects",
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("开始任务: localSync");
|
||||
expect(performLocalSync).toHaveBeenCalledWith(
|
||||
config.localSync,
|
||||
mockViteConfig.root,
|
||||
mockLogger,
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("任务 localSync 完成。");
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("开始任务: updateGitProjects");
|
||||
expect(updateGitProjects).toHaveBeenCalledWith(
|
||||
config.gitProjects,
|
||||
mockViteConfig.root,
|
||||
mockLogger,
|
||||
mockSharedCommitMessagesHolder,
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"任务 updateGitProjects 完成。",
|
||||
);
|
||||
});
|
||||
|
||||
it("应该使用默认任务顺序当未指定时", async () => {
|
||||
const config = {
|
||||
localSync: [{ source: "src", target: "dist" }],
|
||||
gitProjects: [
|
||||
{
|
||||
repo: "https://github.com/example/repo.git",
|
||||
branch: "main",
|
||||
targetDir: "services/repo",
|
||||
},
|
||||
],
|
||||
// 未指定taskOrder
|
||||
};
|
||||
|
||||
await processTasks(
|
||||
config,
|
||||
mockViteConfig,
|
||||
mockLogger,
|
||||
mockSharedCommitMessagesHolder,
|
||||
);
|
||||
|
||||
// 默认顺序: localSync, updateGitProjects
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"执行任务顺序: localSync, updateGitProjects",
|
||||
);
|
||||
expect(performLocalSync).toHaveBeenCalled();
|
||||
expect(updateGitProjects).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("应该处理任务错误并继续执行", async () => {
|
||||
const config = {
|
||||
localSync: [{ source: "src", target: "dist" }],
|
||||
gitProjects: [
|
||||
{
|
||||
repo: "https://github.com/example/repo.git",
|
||||
branch: "main",
|
||||
targetDir: "services/repo",
|
||||
},
|
||||
],
|
||||
taskOrder: ["localSync", "updateGitProjects"],
|
||||
};
|
||||
|
||||
const error = new Error("测试错误");
|
||||
(performLocalSync as any).mockRejectedValueOnce(error);
|
||||
|
||||
await processTasks(
|
||||
config,
|
||||
mockViteConfig,
|
||||
mockLogger,
|
||||
mockSharedCommitMessagesHolder,
|
||||
);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
"任务 localSync 执行错误: 测试错误",
|
||||
error,
|
||||
);
|
||||
expect(updateGitProjects).toHaveBeenCalled(); // 第二个任务仍然执行
|
||||
});
|
||||
|
||||
it("应该在关键错误时中断流程", async () => {
|
||||
const config = {
|
||||
localSync: [{ source: "src", target: "dist" }],
|
||||
gitProjects: [
|
||||
{
|
||||
repo: "https://github.com/example/repo.git",
|
||||
branch: "main",
|
||||
targetDir: "services/repo",
|
||||
},
|
||||
],
|
||||
taskOrder: ["localSync", "updateGitProjects"],
|
||||
};
|
||||
|
||||
const criticalError = new Error("关键错误") as Error & {
|
||||
isCritical: boolean;
|
||||
};
|
||||
criticalError.isCritical = true;
|
||||
(performLocalSync as any).mockRejectedValueOnce(criticalError);
|
||||
|
||||
await expect(
|
||||
processTasks(
|
||||
config,
|
||||
mockViteConfig,
|
||||
mockLogger,
|
||||
mockSharedCommitMessagesHolder,
|
||||
),
|
||||
).rejects.toThrow("关键错误");
|
||||
|
||||
expect(updateGitProjects).not.toHaveBeenCalled(); // 第二个任务不应执行
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import {
|
||||
resolvePath,
|
||||
createError,
|
||||
formatDate,
|
||||
isAbsolutePath,
|
||||
safeRemoveFile,
|
||||
isSubdirectoryOf,
|
||||
normalizePaths,
|
||||
analyzePathRelationship,
|
||||
} from "../../src/core/utils";
|
||||
|
||||
// 创建临时测试文件的路径
|
||||
const tmpDir = path.join(process.cwd(), "tests", "tmp");
|
||||
const tmpFile = path.join(tmpDir, "test-file.txt");
|
||||
|
||||
// 确保临时目录存在
|
||||
beforeAll(async () => {
|
||||
await fs.ensureDir(tmpDir);
|
||||
});
|
||||
|
||||
// 测试后清理
|
||||
afterAll(async () => {
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
describe("resolvePath", () => {
|
||||
it("应该正确解析相对路径", () => {
|
||||
const viteRoot = "/test/root";
|
||||
const relativePath = "src/components";
|
||||
const expected = path.resolve(viteRoot, relativePath);
|
||||
|
||||
expect(resolvePath(viteRoot, relativePath)).toBe(expected);
|
||||
});
|
||||
|
||||
it("应该保留绝对路径", () => {
|
||||
const viteRoot = "/test/root";
|
||||
const absolutePath = "/absolute/path";
|
||||
|
||||
expect(resolvePath(viteRoot, absolutePath)).toBe(absolutePath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createError", () => {
|
||||
it("应该创建非关键错误", () => {
|
||||
const message = "Test error";
|
||||
const error = createError(message);
|
||||
|
||||
expect(error.message).toBe(message);
|
||||
expect(error.isCritical).toBe(false);
|
||||
expect(error.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("应该创建关键错误", () => {
|
||||
const message = "Critical error";
|
||||
const error = createError(message, true);
|
||||
|
||||
expect(error.message).toBe(message);
|
||||
expect(error.isCritical).toBe(true);
|
||||
expect(error.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("应该正确格式化日期", () => {
|
||||
const date = new Date(2023, 0, 1); // 2023-01-01
|
||||
expect(formatDate(date)).toBe("2023-01-01");
|
||||
|
||||
const date2 = new Date(2023, 11, 31); // 2023-12-31
|
||||
expect(formatDate(date2)).toBe("2023-12-31");
|
||||
});
|
||||
|
||||
it("应该在月份和日期前补零", () => {
|
||||
const date = new Date(2023, 0, 1); // 2023-01-01
|
||||
expect(formatDate(date)).toBe("2023-01-01");
|
||||
|
||||
const date2 = new Date(2023, 8, 9); // 2023-09-09
|
||||
expect(formatDate(date2)).toBe("2023-09-09");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAbsolutePath", () => {
|
||||
it("应该识别绝对路径", () => {
|
||||
const absolutePath = path.resolve("/absolute/path");
|
||||
expect(isAbsolutePath(absolutePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("应该识别相对路径", () => {
|
||||
expect(isAbsolutePath("relative/path")).toBe(false);
|
||||
expect(isAbsolutePath("./relative/path")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeRemoveFile", () => {
|
||||
it("应该删除存在的文件", async () => {
|
||||
// 创建测试文件
|
||||
await fs.writeFile(tmpFile, "test content");
|
||||
expect(fs.existsSync(tmpFile)).toBe(true);
|
||||
|
||||
// 删除文件
|
||||
await safeRemoveFile(tmpFile);
|
||||
expect(fs.existsSync(tmpFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("应该安全地处理不存在的文件", async () => {
|
||||
const nonExistentFile = path.join(tmpDir, "non-existent.txt");
|
||||
|
||||
// 确保文件不存在
|
||||
if (fs.existsSync(nonExistentFile)) {
|
||||
await fs.remove(nonExistentFile);
|
||||
}
|
||||
|
||||
// 不应抛出错误
|
||||
await expect(safeRemoveFile(nonExistentFile)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePaths", () => {
|
||||
it("应该正规化单个路径", () => {
|
||||
const paths = normalizePaths("C:/test\\path//file.txt");
|
||||
expect(paths).toHaveLength(1);
|
||||
expect(paths[0]).toBe(path.normalize("C:/test\\path//file.txt"));
|
||||
});
|
||||
|
||||
it("应该正规化多个路径", () => {
|
||||
const input = ["C:/test\\path1", "D:\\test/path2", "./relative\\path"];
|
||||
const result = normalizePaths(...input);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toBe(path.normalize("C:/test\\path1"));
|
||||
expect(result[1]).toBe(path.normalize("D:\\test/path2"));
|
||||
expect(result[2]).toBe(path.normalize("./relative\\path"));
|
||||
});
|
||||
|
||||
it("应该处理空路径数组", () => {
|
||||
const result = normalizePaths();
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSubdirectoryOf", () => {
|
||||
describe("Unix/Linux 路径格式", () => {
|
||||
it("应该识别子目录", () => {
|
||||
expect(isSubdirectoryOf("/root/sub", "/root")).toBe(true);
|
||||
expect(isSubdirectoryOf("/root/sub/deep", "/root")).toBe(true);
|
||||
expect(isSubdirectoryOf("/root/sub/deep/file.txt", "/root")).toBe(true);
|
||||
});
|
||||
|
||||
it("应该识别相同目录", () => {
|
||||
expect(isSubdirectoryOf("/root", "/root")).toBe(true);
|
||||
expect(isSubdirectoryOf("/root/", "/root")).toBe(true);
|
||||
expect(isSubdirectoryOf("/root", "/root/")).toBe(true);
|
||||
});
|
||||
|
||||
it("应该拒绝非子目录", () => {
|
||||
expect(isSubdirectoryOf("/other", "/root")).toBe(false);
|
||||
expect(isSubdirectoryOf("/root-similar", "/root")).toBe(false);
|
||||
expect(isSubdirectoryOf("/", "/root")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Windows 路径格式", () => {
|
||||
it("应该识别子目录", () => {
|
||||
expect(isSubdirectoryOf("C:\\root\\sub", "C:\\root")).toBe(true);
|
||||
expect(isSubdirectoryOf("C:\\root\\sub\\deep", "C:\\root")).toBe(true);
|
||||
});
|
||||
|
||||
it("应该识别相同目录", () => {
|
||||
expect(isSubdirectoryOf("C:\\root", "C:\\root")).toBe(true);
|
||||
expect(isSubdirectoryOf("C:\\root\\", "C:\\root")).toBe(true);
|
||||
});
|
||||
|
||||
it("应该拒绝非子目录", () => {
|
||||
expect(isSubdirectoryOf("D:\\root", "C:\\root")).toBe(false);
|
||||
expect(isSubdirectoryOf("C:\\other", "C:\\root")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("混合路径分隔符處理", () => {
|
||||
it("应该处理混合的路径分隔符", () => {
|
||||
// 這是實際錯誤場景:正規化應該統一分隔符
|
||||
expect(isSubdirectoryOf("C:/root\\.sync-git\\target", "C:\\root")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isSubdirectoryOf("C:\\root/.sync-git/target", "C:/root")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("应该处理尾随分隔符", () => {
|
||||
expect(isSubdirectoryOf("C:/root/sub/", "C:/root/")).toBe(true);
|
||||
expect(isSubdirectoryOf("C:\\root\\sub\\", "C:\\root\\")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("邊界條件測試", () => {
|
||||
it("应该处理根目录", () => {
|
||||
if (process.platform === "win32") {
|
||||
expect(isSubdirectoryOf("C:\\", "C:\\")).toBe(true);
|
||||
expect(isSubdirectoryOf("C:\\sub", "C:\\")).toBe(true);
|
||||
} else {
|
||||
expect(isSubdirectoryOf("/", "/")).toBe(true);
|
||||
expect(isSubdirectoryOf("/sub", "/")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("应该处理相对路径", () => {
|
||||
expect(isSubdirectoryOf("./sub", ".")).toBe(true);
|
||||
expect(isSubdirectoryOf("../other", ".")).toBe(false);
|
||||
expect(isSubdirectoryOf("sub/deep", "sub")).toBe(true);
|
||||
});
|
||||
|
||||
it("应该处理空字符串和特殊字符", () => {
|
||||
expect(isSubdirectoryOf("", "")).toBe(true);
|
||||
expect(isSubdirectoryOf("a", "")).toBe(false);
|
||||
expect(isSubdirectoryOf("", "a")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyzePathRelationship", () => {
|
||||
it("应该提供详细的路径分析", () => {
|
||||
const targetPath = "C:/root\\.sync-git\\target";
|
||||
const sourcePath = "C:\\root";
|
||||
|
||||
const analysis = analyzePathRelationship(targetPath, sourcePath);
|
||||
|
||||
expect(analysis.isSubdirectory).toBe(true);
|
||||
expect(analysis.normalizedTarget).toBe(path.normalize(targetPath));
|
||||
expect(analysis.normalizedSource).toBe(path.normalize(sourcePath));
|
||||
expect(analysis.startsWithCheck).toBe(true);
|
||||
expect(analysis.equalityCheck).toBe(false);
|
||||
expect(analysis.separator).toBe(path.sep);
|
||||
});
|
||||
|
||||
it("应该分析相同路径", () => {
|
||||
const targetPath = "C:\\root";
|
||||
const sourcePath = "C:/root";
|
||||
|
||||
const analysis = analyzePathRelationship(targetPath, sourcePath);
|
||||
|
||||
expect(analysis.isSubdirectory).toBe(true);
|
||||
expect(analysis.startsWithCheck).toBe(false);
|
||||
expect(analysis.equalityCheck).toBe(true);
|
||||
});
|
||||
|
||||
it("应该分析非子目录关系", () => {
|
||||
const targetPath = "/other/path";
|
||||
const sourcePath = "/root";
|
||||
|
||||
const analysis = analyzePathRelationship(targetPath, sourcePath);
|
||||
|
||||
expect(analysis.isSubdirectory).toBe(false);
|
||||
expect(analysis.startsWithCheck).toBe(false);
|
||||
expect(analysis.equalityCheck).toBe(false);
|
||||
});
|
||||
|
||||
it("应该包含正確的分隔符信息", () => {
|
||||
const analysis = analyzePathRelationship("any", "path");
|
||||
|
||||
expect(analysis.separator).toBe(path.sep);
|
||||
if (process.platform === "win32") {
|
||||
expect(analysis.separator).toBe("\\");
|
||||
} else {
|
||||
expect(analysis.separator).toBe("/");
|
||||
}
|
||||
});
|
||||
});
|
||||
21
frontend/plugin/vite-plugin-turborepo-deploy/tsconfig.json
Normal file
21
frontend/plugin/vite-plugin-turborepo-deploy/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"lib": ["ESNext", "DOM"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
108
frontend/plugin/vite-plugin-turborepo-deploy/vite.config.ts
Normal file
108
frontend/plugin/vite-plugin-turborepo-deploy/vite.config.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
name: "VitePluginTurborepoDeploy",
|
||||
fileName: (format) => `index.${format === "es" ? "mjs" : "cjs"}`,
|
||||
formats: ["es", "cjs"],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
// Vite
|
||||
"vite",
|
||||
// Node.js built-ins
|
||||
"path",
|
||||
"os",
|
||||
"crypto",
|
||||
"child_process",
|
||||
"util",
|
||||
"fs",
|
||||
"stream",
|
||||
"events",
|
||||
"zlib",
|
||||
"assert",
|
||||
"constants",
|
||||
"url",
|
||||
"buffer",
|
||||
"string_decoder",
|
||||
// Node.js prefixed modules
|
||||
"node:path",
|
||||
"node:os",
|
||||
"node:crypto",
|
||||
"node:child_process",
|
||||
"node:util",
|
||||
"node:fs",
|
||||
"node:fs/promises",
|
||||
"node:stream",
|
||||
"node:events",
|
||||
"node:zlib",
|
||||
"node:assert",
|
||||
"node:constants",
|
||||
"node:url",
|
||||
"node:buffer",
|
||||
"node:string_decoder",
|
||||
// Third-party dependencies
|
||||
"fs-extra",
|
||||
"simple-git",
|
||||
"chalk",
|
||||
"ora",
|
||||
"zod",
|
||||
"picomatch",
|
||||
"archiver",
|
||||
"yauzl",
|
||||
],
|
||||
output: {
|
||||
globals: {
|
||||
vite: "Vite",
|
||||
path: "path",
|
||||
os: "os",
|
||||
crypto: "crypto",
|
||||
child_process: "childProcess",
|
||||
util: "util",
|
||||
fs: "fs",
|
||||
stream: "stream",
|
||||
events: "events",
|
||||
zlib: "zlib",
|
||||
assert: "assert",
|
||||
constants: "constants",
|
||||
url: "url",
|
||||
buffer: "buffer",
|
||||
string_decoder: "stringDecoder",
|
||||
"fs-extra": "fsExtra",
|
||||
"simple-git": "simpleGit",
|
||||
chalk: "Chalk",
|
||||
ora: "Ora",
|
||||
zod: "Zod",
|
||||
picomatch: "picomatch",
|
||||
archiver: "archiver",
|
||||
yauzl: "yauzl",
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
minify: false, // Easier debugging for the plugin itself
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
insertTypesEntry: true,
|
||||
outDir: "dist",
|
||||
staticImport: true,
|
||||
}),
|
||||
],
|
||||
// 优化构建过程中的代码分析
|
||||
optimizeDeps: {
|
||||
// 预构建这些依赖以提高开发模式下的性能
|
||||
include: ["fs-extra", "simple-git", "chalk", "ora", "zod", "picomatch"],
|
||||
// 告诉 Vite 这些是 ESM / CJS 依赖
|
||||
esbuildOptions: {
|
||||
// Node.js 全局变量定义
|
||||
define: {
|
||||
global: "globalThis",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
coverage: {
|
||||
reporter: ["text", "json", "html"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.d.ts"],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
name: "vite-plugin-turborepo-deploy",
|
||||
fileName: (format) => `index.${format}.js`,
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
"fs",
|
||||
"path",
|
||||
"os",
|
||||
"child_process",
|
||||
"util",
|
||||
"fs-extra",
|
||||
"picomatch",
|
||||
"simple-git",
|
||||
"chalk",
|
||||
"zod",
|
||||
"vite",
|
||||
],
|
||||
output: {
|
||||
globals: {
|
||||
vite: "vite",
|
||||
fs: "fs",
|
||||
path: "path",
|
||||
os: "os",
|
||||
child_process: "childProcess",
|
||||
util: "util",
|
||||
"fs-extra": "fse",
|
||||
picomatch: "picomatch",
|
||||
"simple-git": "simpleGit",
|
||||
chalk: "chalk",
|
||||
zod: "zod",
|
||||
},
|
||||
},
|
||||
},
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user