【新增】插件git同步模块,用于同步项目内容,加速项目开发

【调整】前端暗色问题
This commit is contained in:
chudong
2025-05-14 16:50:54 +08:00
parent 819ffe8d99
commit 9af06c8780
215 changed files with 19918 additions and 9710 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -1,69 +0,0 @@
import { jest } from '@jest/globals'
import { TranslationAdapter } from '../src/translation/adapter/index.js'
import { AIBatchAdapter } from '../src/translation/adapter/aiBatchAdapter.js'
import { TraditionalApiAdapter } from '../src/translation/adapter/traditionalApiAdapter.js'
import { ZhipuAITranslator } from '../src/translation/ai/zhipuAI.js'
import * as api1 from '../src/translation/traditional/api1.js'
describe('翻译适配器测试', () => {
describe('TranslationAdapter基类', () => {
it('不应该允许直接实例化抽象基类', () => {
expect(() => new TranslationAdapter()).toThrow('翻译适配器:抽象类不能被直接实例化')
})
})
describe('AI批量翻译适配器', () => {
const adapter = new AIBatchAdapter()
const text = '你好'
const apiKey = 'test-key'
const languages = ['enUS', 'jaJP']
const maxRetries = 3
it('应该实现translate方法', () => {
expect(typeof adapter.translate).toBe('function')
})
it('应该正确处理翻译失败和重试机制', async () => {
const mockTranslate = jest
.spyOn(ZhipuAITranslator.prototype, 'translate')
.mockRejectedValueOnce(new Error('API调用失败'))
.mockResolvedValueOnce({
text,
translations: {
enUS: 'Hello',
jaJP: 'こんにちは',
},
})
const result = await adapter.translate(text, apiKey, languages, maxRetries)
expect(mockTranslate).toHaveBeenCalledTimes(2)
expect(result.translations.enUS).toBe('Hello')
expect(result.translations.jaJP).toBe('こんにちは')
})
})
describe('传统API翻译适配器', () => {
const adapter = new TraditionalApiAdapter(api1)
it('应该验证API模块的有效性', () => {
expect(() => new TraditionalApiAdapter()).toThrow('传统API适配器无效的API模块')
})
it('应该能够获取支持的语言列表', () => {
const supportedLanguages = adapter.getSupportedLanguages()
expect(Array.isArray(supportedLanguages)).toBe(true)
expect(supportedLanguages.length).toBeGreaterThan(0)
})
it('应该正确处理不支持的语言', async () => {
const text = '你好'
const apiKey = 'test-key'
const languages = ['invalidLang']
const maxRetries = 3
await expect(adapter.translate(text, apiKey, languages, maxRetries)).rejects.toThrow(
'传统API适配器不支持的目标语言',
)
})
})
})

View File

@@ -1,120 +0,0 @@
import { jest } from '@jest/globals'
import { promises as fs } from 'fs'
import path from 'path'
import { CacheManager } from '../src/cache/index.js'
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn(),
readFile: jest.fn(),
writeFile: jest.fn(),
access: jest.fn(),
},
}))
describe('CacheManager', () => {
const cachePath = './test-cache.json'
let cacheManager
beforeEach(() => {
cacheManager = new CacheManager(cachePath)
})
afterEach(() => {
jest.clearAllMocks()
})
describe('initCache', () => {
it('应该正确初始化缓存', async () => {
const mockCacheData = {
test: {
text: 'test',
translations: { enUS: 'test' },
timestamp: '2023-01-01T00:00:00.000Z',
},
}
fs.access.mockResolvedValueOnce()
fs.readFile.mockResolvedValueOnce(JSON.stringify(mockCacheData))
await cacheManager.initCache()
expect(cacheManager.cache.get('test')).toEqual(mockCacheData.test)
})
it('处理缓存文件不存在的情况', async () => {
fs.access.mockRejectedValueOnce(new Error('文件不存在'))
await cacheManager.initCache()
expect(cacheManager.cache.size).toBe(0)
})
})
describe('getCachedTranslations', () => {
beforeEach(async () => {
cacheManager.cache.set('hello', {
text: 'hello',
translations: {
enUS: 'Hello',
jaJP: 'こんにちは',
},
timestamp: '2023-01-01T00:00:00.000Z',
})
})
it('应该返回缓存的翻译', async () => {
const texts = ['hello', 'world']
const languages = ['enUS', 'jaJP']
const { cached, uncached } = await cacheManager.getCachedTranslations(texts, languages)
expect(cached.hello).toBeDefined()
expect(uncached).toContain('world')
})
it('检查缓存项是否包含所有必要的语言', async () => {
const texts = ['hello']
const languages = ['enUS', 'jaJP', 'zhCN']
const { cached, uncached } = await cacheManager.getCachedTranslations(texts, languages)
expect(uncached).toContain('hello')
expect(Object.keys(cached)).toHaveLength(0)
})
})
describe('updateCache', () => {
it('应该正确更新缓存', async () => {
const texts = ['test']
const translations = [
{
text: 'test',
translations: {
enUS: 'Test',
jaJP: 'テスト',
},
},
]
const languages = ['enUS', 'jaJP']
await cacheManager.updateCache(texts, translations, languages)
const cached = cacheManager.cache.get('test')
expect(cached.translations.enUS).toBe('Test')
expect(cached.translations.jaJP).toBe('テスト')
expect(fs.writeFile).toHaveBeenCalled()
})
})
describe('cleanCache', () => {
it('应该删除无效的缓存项', async () => {
cacheManager.cache.set('valid', { text: 'valid' })
cacheManager.cache.set('invalid', { text: 'invalid' })
await cacheManager.cleanCache(['valid'])
expect(cacheManager.cache.has('valid')).toBe(true)
expect(cacheManager.cache.has('invalid')).toBe(false)
expect(fs.writeFile).toHaveBeenCalled()
})
})
})

View File

@@ -1,133 +0,0 @@
import { jest } from '@jest/globals'
import { promises as fs } from 'fs'
import path from 'path'
import { LogManager } from '../src/logManagement/index.js'
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn(),
appendFile: jest.fn(),
readFile: jest.fn(),
readdir: jest.fn(),
stat: jest.fn(),
unlink: jest.fn(),
},
}))
describe('LogManager', () => {
const options = {
logPath: './test-logs',
errorLogFile: 'error.log',
infoLogFile: 'info.log',
}
let logManager
beforeEach(() => {
logManager = new LogManager(options)
jest.clearAllMocks()
})
describe('init', () => {
it('应该创建日志目录', async () => {
await logManager.init()
expect(fs.mkdir).toHaveBeenCalledWith(options.logPath, { recursive: true })
})
it('处理创建目录失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.mkdir.mockRejectedValueOnce(new Error('创建目录失败'))
await logManager.init()
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('logError', () => {
it('应该写入错误日志', async () => {
const error = new Error('测试错误')
await logManager.logError(error)
expect(fs.appendFile).toHaveBeenCalledWith(
expect.stringContaining('error.log'),
expect.stringContaining('测试错误'),
)
})
it('处理写入错误日志失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.appendFile.mockRejectedValueOnce(new Error('写入失败'))
await logManager.logError(new Error('测试错误'))
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('logInfo', () => {
it('应该写入信息日志', async () => {
const message = '测试信息'
await logManager.logInfo(message)
expect(fs.appendFile).toHaveBeenCalledWith(expect.stringContaining('info.log'), expect.stringContaining(message))
})
it('处理写入信息日志失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.appendFile.mockRejectedValueOnce(new Error('写入失败'))
await logManager.logInfo('测试信息')
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('cleanLogs', () => {
it('应该删除过期的日志文件', async () => {
const now = Date.now()
const oldDate = now - 7 * 24 * 60 * 60 * 1000 - 1000 // 7天+1秒前
fs.readdir.mockResolvedValueOnce(['old.log', 'new.log'])
fs.stat.mockImplementation((path) => {
return Promise.resolve({
mtimeMs: path.includes('old') ? oldDate : now,
})
})
await logManager.cleanLogs(7)
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('old.log'))
expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining('new.log'))
})
it('处理清理日志失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.readdir.mockRejectedValueOnce(new Error('读取目录失败'))
await logManager.cleanLogs(7)
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('getLogs', () => {
it('应该返回指定数量的日志行', async () => {
const logContent = '行1\n行2\n行3\n行4\n行5'
fs.readFile.mockResolvedValueOnce(logContent)
const lines = await logManager.getLogs('info', 3)
expect(lines).toHaveLength(3)
expect(lines[2]).toBe('行5')
})
it('处理读取日志失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.readFile.mockRejectedValueOnce(new Error('读取文件失败'))
const lines = await logManager.getLogs('error', 5)
expect(lines).toHaveLength(0)
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
})

View File

@@ -1,100 +0,0 @@
import { Utils } from '../src/utils/index.js'
describe('Utils Extended Features', () => {
describe('chunkArray', () => {
it('应该正确分块数组', () => {
const array = [1, 2, 3, 4, 5, 6, 7]
const size = 3
const chunks = Utils.chunkArray(array, size)
expect(chunks).toHaveLength(3)
expect(chunks[0]).toEqual([1, 2, 3])
expect(chunks[1]).toEqual([4, 5, 6])
expect(chunks[2]).toEqual([7])
})
it('处理空数组', () => {
const chunks = Utils.chunkArray([], 2)
expect(chunks).toHaveLength(0)
})
})
describe('delay', () => {
it('应该延迟执行指定时间', async () => {
const start = Date.now()
await Utils.delay(100)
const duration = Date.now() - start
expect(duration).toBeGreaterThanOrEqual(100)
})
})
describe('extractChineseTexts', () => {
it('应该正确提取中文内容', () => {
const content = `
$t('你好世界')
$t("测试文本")
$t('Hello World')
`
const templateRegex = /\$t\(['"]([^'"]+)['"]\)/g
const texts = Utils.extractChineseTexts(content, templateRegex)
expect(texts.size).toBe(2)
expect(texts.has('你好世界')).toBe(true)
expect(texts.has('测试文本')).toBe(true)
expect(texts.has('Hello World')).toBe(false)
})
})
describe('mergeTranslations', () => {
it('应该正确合并翻译结果', () => {
const target = {
key1: 'old value 1',
key2: 'old value 2',
}
const source = {
key1: 'new value 1',
key3: 'new value 3',
}
const result = Utils.mergeTranslations(target, source)
expect(result).toEqual({
key1: 'new value 1',
key2: 'old value 2',
key3: 'new value 3',
})
})
})
describe('isValidLanguageCode', () => {
it('应该验证语言代码格式', () => {
expect(Utils.isValidLanguageCode('zhCN')).toBe(true)
expect(Utils.isValidLanguageCode('enUS')).toBe(true)
expect(Utils.isValidLanguageCode('zh-CN')).toBe(false)
expect(Utils.isValidLanguageCode('123')).toBe(false)
})
})
describe('formatError', () => {
it('应该正确格式化错误信息', () => {
const error = new Error('测试错误')
const formatted = Utils.formatError(error)
expect(formatted).toHaveProperty('message', '测试错误')
expect(formatted).toHaveProperty('stack')
expect(formatted).toHaveProperty('timestamp')
expect(new Date(formatted.timestamp)).toBeInstanceOf(Date)
})
})
describe('generateId', () => {
it('应该生成唯一的标识符', () => {
const id1 = Utils.generateId()
const id2 = Utils.generateId()
expect(id1).toMatch(/^translation_\d+$/)
expect(id2).toMatch(/^translation_\d+$/)
expect(id1).not.toBe(id2)
})
})
})

View File

@@ -1,62 +0,0 @@
import { Utils } from '../src/utils/index.js'
describe('Utils', () => {
describe('isChineseText', () => {
it('应该正确识别中文文本', () => {
expect(Utils.isChineseText('你好')).toBe(true)
expect(Utils.isChineseText('Hello')).toBe(false)
expect(Utils.isChineseText('Hello 你好')).toBe(true)
})
})
describe('validateConfig', () => {
it('应该验证配置对象', () => {
const validConfig = {
apiKey: { zhipuAI: 'test-key' },
languages: ['zhCN', 'enUS'],
concurrency: 10,
interval: 1000,
}
const errors = Utils.validateConfig(validConfig)
expect(errors).toHaveLength(0)
})
it('应该检测无效的配置', () => {
const invalidConfig = {
apiKey: 'invalid',
languages: ['invalid'],
concurrency: -1,
interval: 'invalid',
}
const errors = Utils.validateConfig(invalidConfig)
expect(errors.length).toBeGreaterThan(0)
})
})
describe('parseLanguageCode', () => {
it('应该正确解析语言代码', () => {
const result = Utils.parseLanguageCode('zhCN')
expect(result).toEqual({
language: 'zh',
region: 'CN',
})
})
})
describe('formatTranslations', () => {
it('应该正确格式化翻译结果', () => {
const translations = {
hello: ' Hello World ',
welcome: ' 欢迎 ',
}
const formatted = Utils.formatTranslations(translations)
expect(formatted).toEqual({
hello: 'Hello World',
welcome: '欢迎',
})
})
})
})

View File

@@ -1,112 +0,0 @@
import { jest } from '@jest/globals'
import axios from 'axios'
import { ZhipuAITranslator } from '../src/translation/ai/zhipuAI.js'
jest.mock('axios')
describe('ZhipuAITranslator', () => {
const apiKey = 'test-key'
let translator
beforeEach(() => {
translator = new ZhipuAITranslator(apiKey)
jest.clearAllMocks()
})
describe('generatePrompt', () => {
it('应该生成正确的提示词', () => {
const text = '你好'
const languages = ['enUS', 'jaJP']
const prompt = translator.generatePrompt(text, languages)
expect(prompt).toContain('请将以下文本翻译成')
expect(prompt).toContain('en-US, ja-JP')
expect(prompt).toContain(text)
})
})
describe('translate', () => {
const text = '你好'
const languages = ['enUS', 'jaJP']
it('应该成功调用API并返回翻译结果', async () => {
const mockResponse = {
data: {
choices: [
{
message: {
content: JSON.stringify({
'en-US': 'Hello',
'ja-JP': 'こんにちは',
}),
},
},
],
},
}
axios.post.mockResolvedValueOnce(mockResponse)
const result = await translator.translate(text, languages)
expect(axios.post).toHaveBeenCalledWith(
expect.stringContaining('/chatglm_turbo'),
expect.any(Object),
expect.objectContaining({
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
}),
)
expect(result).toEqual({
text: '你好',
translations: {
enUS: 'Hello',
jaJP: 'こんにちは',
},
})
})
it('处理API调用失败的情况', async () => {
axios.post.mockRejectedValueOnce(new Error('API调用失败'))
await expect(translator.translate(text, languages)).rejects.toThrow('智谱AI翻译失败')
})
it('处理无效的API响应', async () => {
const mockResponse = {
data: {},
}
axios.post.mockResolvedValueOnce(mockResponse)
await expect(translator.translate(text, languages)).rejects.toThrow('无效的API响应')
})
})
describe('validateApiKey', () => {
it('应该成功验证有效的API密钥', async () => {
axios.get.mockResolvedValueOnce({})
const isValid = await translator.validateApiKey()
expect(isValid).toBe(true)
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('/validate'),
expect.objectContaining({
headers: {
Authorization: `Bearer ${apiKey}`,
},
}),
)
})
it('处理无效的API密钥', async () => {
axios.get.mockRejectedValueOnce(new Error('无效的密钥'))
const isValid = await translator.validateApiKey()
expect(isValid).toBe(false)
})
})
})

Binary file not shown.

Binary file not shown.

319
frontend/plugin/test.md Normal file
View File

@@ -0,0 +1,319 @@
# Vite插件开发需求文档Turborepo工作区编译部署自动化工具
## 一、项目概述
**项目名称**vite-plugin-turborepo-deploy
**目标**开发一个Vite插件实现Turborepo工作区编译后的自动化部署流程涵盖本地文件同步、Git项目管理包含可选的智能化自动提交功能并支持提交信息在项目间共享
## 二、功能需求
### 1. 本地文件同步功能
- **配置方式**:支持在插件配置中定义多个本地文件/目录同步任务
- **同步模式**
- `copy`:简单复制,不处理目标目录已存在文件
- `mirror`:镜像同步,删除目标目录中不存在于源的文件
- `incremental`:增量更新,仅覆盖已变更文件
- **清空目标目录**:支持通过`clearTarget`字段在同步前清空目标目录
- **全新添加模式**:支持`addOnly`字段,仅新增源中存在而目标中不存在的文件/目录
- **过滤规则**
- `exclude`:支持通过正则表达式排除特定文件/目录
- `excludeDirs`指定排除的目录支持glob或正则
- `excludeFiles`指定排除的文件支持glob或正则
- **路径解析**:所有相对路径自动基于项目根目录解析
### 2. Git项目管理与自动提交功能
- **多项目支持**可配置多个Git项目的拉取/更新任务。
- **分支管理**:支持指定拉取分支,自动切换到目标分支。
- **目录管理**
- 支持自定义项目存放目录。
- 支持重命名项目目录(通过`projectName`字段)。
- **自动操作**
- 检测项目是否存在:存在则执行`git pull`更新,不存在则执行`git clone`
- 拉取前自动切换到指定分支。
- 支持私有仓库通过SSH密钥或HTTPS认证
- **可选的自动提交 (Per-Project Auto Commit)**
- **启用方式**在每个Git项目配置中通过`autoCommit`对象启用和配置。
- **智能提交检测机制**(用于填充共享缓冲区或单个项目扫描):
- **开发者监听**通过配置指定需要监听的开发者用户名对应Git配置中的`user.name`)。
- **提交记录扫描**
- 默认扫描最近50条提交记录可通过配置调整
- 按时间倒序扫描,优先获取最新提交。
- **提交分隔符**:使用特殊标记(默认`/** 提交分隔符 **/`)识别分段点。
- **双模式处理**
- **模式1**:存在分隔符时,获取分隔符之后的所有提交。
- **模式2**:不存在分隔符时,获取最近一条指定开发者的提交。
- **共享提交信息机制 (Shared Commit Buffer)**:
- **目的**当多个Git项目基于同一组源变更如主仓库的特定开发者提交进行自动提交时允许一次获取提交信息并复用。
- **信息来源**:在`gitProjects`任务执行期间,第一个成功通过"智能提交检测机制"获取到提交信息的项目,其结果将被缓存为"共享提交信息"。
- **信息使用**后续的Git项目若在其`autoCommit`配置中设置了`useSharedCommits: true`,则会尝试使用此共享信息。若共享信息为空,或项目未配置使用,则回退到独立扫描其自身仓库。
- **生命周期**:共享提交信息在每次`updateGitProjects`任务开始时清空。
- **提交信息生成**
- 若项目配置为使用共享提交信息且缓冲区存在内容,则直接使用。
- 否则,使用从当前项目独立扫描获取的提交信息。
- 合并多条提交时,采用特定格式:
```markdown
[自动合并] 包含N次提交:
1. [commitHash1] commitMessage1
2. [commitHash2] commitMessage2
...
/** 提交分隔符 **/
```
- **提交后操作**无论是否使用共享信息自动提交成功后都在当前Git项目的**当前分支**插入一条新的、空的提交分隔符记录。
- **错误处理**(针对单个项目的自动提交过程):
- **无匹配提交**:若独立扫描未找到指定开发者的任何提交,在该项目中抛出警告但继续执行后续任务。
- **重复分隔符**:若检测到连续两条分隔符提交,自动清理冗余记录。
- **提交冲突**:在提交前检查工作区状态,存在未提交变更时抛出错误并跳过该项目的自动提交。
- **推送**:支持配置是否在自动提交后推送到远程仓库的对应分支。
### 3. 任务编排系统
- **顺序执行**:支持按数组顺序依次执行配置的任务。
- **内置任务**
- `localSync`:执行本地文件同步。
- `updateGitProjects`更新所有Git项目如果项目配置了自动提交则在此阶段执行包括处理共享提交信息逻辑
- **扩展机制**:支持自定义任务(通过插件钩子)。
### 4. 日志记录系统
- **日志文件**
- 自动在项目根目录下创建`.sync-log`目录。
- 按日期生成日志文件(如`2023-05-15_deploy.log`)。
- **日志级别**
- `error`:仅记录错误信息。
- `info`:记录关键步骤信息。
- `verbose`:记录详细执行过程(默认)。
- **控制台输出**
- 彩色日志显示。
- 关键步骤进度提示。
## 三、技术实现要求
### 1. 基础架构
- **开发语言**TypeScript所有源码必须为`.ts`或`.tsx`文件,禁止使用`any`类型。
- **Node.js 版本**:要求 Node.js 16 及以上,推荐 LTS 版本,需兼容 ESM。
- **Vite 版本**:兼容 Vite 3.x 及以上,插件需遵循 Vite 官方插件开发规范。
- **插件类型**:构建后插件(在`buildEnd`钩子触发),需支持异步任务。
- **依赖管理**:使用 pnpm/yarn/npm推荐 pnpm所有依赖需锁定版本。
### 2. 代码规范与最佳实践
- **类型安全**:全量使用 TypeScript 接口,避免类型推断不明确。
- **函数式编程**:优先使用函数式、声明式风格,避免类和全局变量。
- **模块化**:每个功能模块单独文件,导出命名函数,禁止默认导出。
- **注释规范**:所有导出函数、接口、类型需使用 JSDoc 注释,描述参数、返回值和用途。
- **错误处理**:所有异步操作需捕获异常,关键错误需中断流程并输出详细日志。
- **日志系统**日志输出需支持彩色chalk并写入`.sync-log`目录下的日志文件,日志内容需结构化,便于后续分析。
### 3. 性能与兼容性
- **异步并发**文件同步、Git 操作等 IO 密集型任务需支持并发,合理控制并发数,避免资源争用。
- **跨平台**:需兼容 Windows、Linux、macOS路径处理需使用 Node.js path 模块。
- **配置热重载**:插件配置变更后,支持自动重载,无需重启 Vite 服务。
- **资源优化**:插件自身体积需控制在合理范围,避免引入过大依赖。
### 4. 插件接口与类型定义
- **类型导出**:所有配置接口需导出,便于用户类型推断和 IDE 智能提示。
- **配置校验**:插件初始化时需校验用户配置,发现错误需抛出详细异常。
- **Vite 插件约定**:导出标准 Vite 插件对象,支持链式调用和多插件组合。
### 5. 依赖与工具
- `fs-extra`:文件操作
- `simple-git`Git 命令封装
- `chalk`:控制台彩色输出
- `ora`:进度指示器
- `zod` 或 `joi`:配置校验(推荐 zod
### 6. 测试与质量保障
- **单元测试**:使用 Vitest 或 Jest覆盖率需达 80% 以上。
- **集成测试**:模拟完整部署流程,验证各功能模块协作。
- **CI/CD**:集成 GitHub Actions自动化测试、构建和发布。
## 四、交互与输出
### 1. 控制台输出示例
```
🚀 [Turborepo Deploy] 开始执行部署流程...
🔄 正在同步本地文件...
✅ 已同步 dist → ../deploy/public (mirror模式)
✅ 已同步 src/assets → ../deploy/assets (incremental模式)
🔄 正在更新Git项目...
⏳ 正在处理 api-gateway (分支: develop)...
✅ 已更新至最新版本 (commit: abc123)
✨ 开始执行 api-gateway 的自动提交 (作为共享信息源)...
✅ 已扫描最近50条提交记录, 找到提交分隔符(位于 commit: abc123
✅ 识别到3条待同步提交, 已存入共享缓冲区。
- [def456] 修复登录验证问题
- [ghi789] 优化API响应格式
- [jkl012] 添加用户头像上传功能
✅ 已合并提交 (commit: mno345) 并推送到远程 (develop)
✅ 已在 api-gateway (develop) 插入新的提交分隔符 (commit: pqr678)
⏳ 正在处理 user-service (分支: feature/new-endpoint)...
✅ 已更新至最新版本 (commit: stu789)
✨ 开始执行 user-service 的自动提交 (使用共享信息)...
✅ 使用了来自 api-gateway 的3条共享提交信息。
✅ 已合并提交 (commit: vwx123) 并推送到远程 (feature/new-endpoint)
✅ 已在 user-service (feature/new-endpoint) 插入新的提交分隔符 (commit: yz034)
⏳ 正在处理 another-service (分支: main)...
✅ 已更新至最新版本 (commit: bcd234)
✨ 开始执行 another-service 的自动提交 (独立扫描)...
✅ 未找到指定开发者或分隔符的提交,未执行自动提交。
✅ [Turborepo Deploy] 部署完成!
```
### 2. 日志文件格式
```
[2023-05-15T14:30:21.123Z] [INFO] 开始执行部署流程...
[2023-05-15T14:30:21.456Z] [INFO] 正在同步本地文件...
[2023-05-15T14:30:23.789Z] [INFO] 已同步 dist → ../deploy/public (mirror模式)
[2023-05-15T14:30:25.012Z] [INFO] 已同步 src/assets → ../deploy/assets (incremental模式)
[2023-05-15T14:30:25.345Z] [INFO] 正在更新Git项目...
[2023-05-15T14:30:25.678Z] [INFO] [api-gateway] 正在处理项目 (分支: develop)...
[2023-05-15T14:30:30.234Z] [INFO] [api-gateway] 已更新至最新版本 (commit: abc123)
[2023-05-15T14:30:30.500Z] [INFO] [api-gateway] 开始执行自动提交 (作为共享信息源)...
[2023-05-15T14:30:31.890Z] [INFO] [api-gateway] 已扫描最近50条提交记录, 找到提交分隔符(位于 commit: abc123
[2023-05-15T14:30:32.123Z] [INFO] [api-gateway] 识别到3条待同步提交, 已存入共享缓冲区。
[2023-05-15T14:30:32.456Z] [INFO] [api-gateway] - [def456] 修复登录验证问题
[2023-05-15T14:30:32.789Z] [INFO] [api-gateway] - [ghi789] 优化API响应格式
[2023-05-15T14:30:33.012Z] [INFO] [api-gateway] - [jkl012] 添加用户头像上传功能
[2023-05-15T14:30:35.678Z] [INFO] [api-gateway] 已合并提交 (commit: mno345) 并推送到远程 (develop)
[2023-05-15T14:30:36.901Z] [INFO] [api-gateway] 已插入新的提交分隔符 (commit: pqr678)
[2023-05-15T14:30:37.100Z] [INFO] [user-service] 正在处理项目 (分支: feature/new-endpoint)...
[2023-05-15T14:30:40.234Z] [INFO] [user-service] 已更新至最新版本 (commit: stu789)
[2023-05-15T14:30:40.500Z] [INFO] [user-service] 开始执行自动提交 (使用共享信息)...
[2023-05-15T14:30:40.501Z] [INFO] [user-service] 使用了来自 api-gateway 的3条共享提交信息。
[2023-05-15T14:30:43.678Z] [INFO] [user-service] 已合并提交 (commit: vwx123) 并推送到远程 (feature/new-endpoint)
[2023-05-15T14:30:44.901Z] [INFO] [user-service] 已插入新的提交分隔符 (commit: yz034)
[2023-05-15T14:30:45.000Z] [INFO] [another-service] 正在处理项目 (分支: main)...
[2023-05-15T14:30:50.000Z] [INFO] 部署完成!
```
## 五、使用示例
```typescript
// vite.config.ts
import { defineConfig } from 'vite';
import turborepoDeploy from 'vite-plugin-turborepo-deploy';
export default defineConfig({
plugins: [
turborepoDeploy({
localSync: [
{ source: 'dist', target: '../deploy/public', mode: 'mirror', clearTarget: true },
{ source: 'src/assets', target: '../deploy/assets', excludeDirs: ['**/tmp'], excludeFiles: ['**/*.psd'] }
],
gitProjects: [
{
repo: 'git@github.com:example-org/api-gateway.git',
branch: 'develop',
targetDir: 'services/api-gateway',
projectName: 'api-gateway', // 用于日志中清晰标识源
autoCommit: { // 此项目将作为共享提交信息的来源
enabled: true,
watchAuthor: '张三',
maxScanCount: 100,
commitSeparator: '/** AUTO MERGE MARK **/',
message: 'chore(api-gateway): auto merge [skip ci]',
push: true
// useSharedCommits: false, // 或不设置默认为false它会尝试填充共享区
}
},
{
repo: 'git@github.com:example-org/user-service.git',
branch: 'feature/new-endpoint',
targetDir: 'services/user-service',
projectName: 'user-service',
autoCommit: { // 此项目将使用共享的提交信息
enabled: true,
useSharedCommits: true, // 明确指定使用共享信息
// watchAuthor, maxScanCount, commitSeparator 在此模式下可不填,若填写了则在共享区为空时作为回退
message: 'chore(user-service): auto sync from upstream [skip ci]',
push: true
}
},
{
repo: 'git@github.com:example-org/another-service.git',
branch: 'main',
targetDir: 'services/another-service',
projectName: 'another-service',
autoCommit: { // 此项目独立进行提交检测
enabled: true,
watchAuthor: '李四', // 不同的开发者或标准
message: 'chore(another-service): auto merge [skip ci]',
push: false // 可能只提交不推送
}
}
],
taskOrder: ['localSync', 'updateGitProjects']
})
]
});
```
## 六、测试要求
### 1. 单元测试
- 验证文件同步逻辑(包括清空、仅新增、过滤规则)。
- 验证Git项目管理核心操作克隆、拉取、分支切换
- 验证针对单个Git项目的自动提交智能检测逻辑包括开发者监听、分隔符处理、提交信息生成、提交后操作
- **验证共享提交信息缓冲机制**
- 源项目成功获取并缓存提交信息。
- 后续项目配置`useSharedCommits: true`时能正确使用缓存信息。
- 当共享缓冲为空时,配置了`useSharedCommits: true`的项目能正确回退到独立扫描(或按配置跳过)。
- 未配置`useSharedCommits: true`的项目不受共享缓冲影响。
- 验证任务编排顺序。
### 2. 集成测试
- 模拟完整部署流程包含多个Git项目组合使用独立提交、共享提交源、共享提交消费者等场景。
- 验证日志记录功能,清晰反映各项目的提交方式(独立、共享、来源)。
- 测试不同场景下单个Git项目的自动提交有分隔符、无分隔符、无匹配提交、提交冲突、推送成功/失败),包括作为共享源和消费者的不同行为。
### 3. 边缘情况测试
- 文件冲突处理。
- Git项目不存在或认证失败。
- 网络异常处理。
- 单个Git项目中重复提交分隔符处理。
- **共享提交信息在任务开始时被正确清空**。
## 七、交付物
1. **源代码**完整的TypeScript源码
2. **文档**
- 使用说明
- 配置参数文档
- 开发指南
3. **测试用例**:单元测试和集成测试代码
4. **示例项目**:演示插件功能的示例配置
### 3. 配置接口定义
```typescript
interface GitProjectAutoCommitConfig {
enabled?: boolean; // 是否启用默认false
watchAuthor?: string; // 监听的开发者用户名 (当useSharedCommits为false或作为共享提交源时建议填写)
maxScanCount?: number; // 最大扫描提交记录数默认50
commitSeparator?: string; // 提交分隔符,默认'/** 提交分隔符 **/'
message?: string; // 自动提交消息模板 (可选, 有默认值)
push?: boolean; // 是否推送到远程默认false
useSharedCommits?: boolean; // 可选是否尝试使用共享的提交信息默认false
}
interface GitProjectConfig {
repo: string; // 仓库地址SSH或HTTPS
branch: string; // 目标分支
targetDir: string; // 存放目录(相对项目根目录)
projectName?: string; // 可选:重命名项目目录
updateIfExists?: boolean; // 存在时是否更新默认true
autoCommit?: GitProjectAutoCommitConfig; // 可选的自动提交配置
}
interface LocalSyncConfig {
source: string; // 源目录/文件(相对项目根目录)
target: string; // 目标目录/文件(相对项目根目录)
mode?: 'copy' | 'mirror' | 'incremental'; // 同步模式,默认'incremental'
clearTarget?: boolean; // 是否同步前清空目标目录默认false
addOnly?: boolean; // 是否仅新增(目标不存在的文件/目录默认false
exclude?: string[]; // 排除文件/目录的正则表达式数组
excludeDirs?: string[]; // 排除目录glob或正则表达式
excludeFiles?: string[]; // 排除文件glob或正则表达式
}
interface TurborepoDeployConfig {
localSync?: Array<LocalSyncConfig>;
gitProjects?: Array<GitProjectConfig>;
taskOrder?: Array<'localSync' | 'updateGitProjects'>; // 任务执行顺序
}
```

View File

@@ -1,5 +1,5 @@
{
"name": "@baota/project-ftp-sync",
"name": "@baota/vite-plugin-ftp-sync",
"version": "1.0.0",
"description": "FTP/SFTP sync plugin for build process",
"main": "src/index.ts",

View File

@@ -11,6 +11,23 @@ export interface FtpSyncTarget {
clearRemote?: boolean;
}
// 创建日志工具
const logger = {
pluginName: "vite-plugin-ftp-sync",
info: (message: string) => {
console.log(`\x1b[36m[${logger.pluginName}]\x1b[0m \x1b[32m${message}\x1b[0m`);
},
warn: (message: string) => {
console.warn(`\x1b[36m[${logger.pluginName}]\x1b[0m \x1b[33m${message}\x1b[0m`);
},
error: (message: string, err?: any) => {
console.error(`\x1b[36m[${logger.pluginName}]\x1b[0m \x1b[31m${message}\x1b[0m`, err || "");
},
success: (message: string) => {
console.log(`\x1b[36m[${logger.pluginName}]\x1b[0m \x1b[32m${message}\x1b[0m`);
}
};
export function ftpSync(options: FtpSyncTarget[] | FtpSyncTarget): Plugin {
return {
name: "vite-plugin-ftp-sync",
@@ -29,27 +46,27 @@ export function ftpSync(options: FtpSyncTarget[] | FtpSyncTarget): Plugin {
});
const localPath = target.localPath || "dist";
console.log(
logger.info(
`开始同步文件到 SFTP 服务器 ${target.host}:${target.port} -> ${target.remotePath}`,
);
if (target.clearRemote) {
console.log(`正在清除远程目录 ${target.remotePath}...`);
logger.info(`正在清除远程目录 ${target.remotePath}...`);
try {
await sftp.rmdir(target.remotePath, true);
console.log(`远程目录 ${target.remotePath} 已清除`);
logger.success(`远程目录 ${target.remotePath} 已清除`);
} catch (err) {
console.warn(`清除远程目录失败,可能目录不存在: ${err}`);
logger.warn(`清除远程目录失败,可能目录不存在: ${err}`);
}
}
await sftp.uploadDir(localPath, target.remotePath);
console.log(`文件同步到 ${target.host} 完成!`);
logger.success(`文件同步到 ${target.host} 完成!`);
sftp.end();
return { target, success: true };
} catch (err) {
console.error(`SFTP 同步到 ${target.host} 失败:`, err);
logger.error(`SFTP 同步到 ${target.host} 失败:`, err);
sftp.end();
return { target, success: false, error: err };
}
@@ -62,7 +79,10 @@ export function ftpSync(options: FtpSyncTarget[] | FtpSyncTarget): Plugin {
);
if (failures.length > 0) {
logger.error(`部分 SFTP 同步失败: ${failures.length} 个目标`);
throw new Error(`部分 SFTP 同步失败: ${failures.length} 个目标`);
} else {
logger.success("所有同步任务已成功完成");
}
},
};

View File

@@ -1,5 +1,5 @@
{
"name": "@baota/plugin-i18n",
"name": "@baota/vite-plugin-i18n",
"version": "1.0.0",
"description": "A Vite plugin for automatic i18n translation using AI services",
"type": "module",
@@ -49,4 +49,4 @@
"^(\\.{1,2}/.*)\\.js$": "$1"
}
}
}
}

View 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

View 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
...之前的更新日志...

View 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

View File

@@ -0,0 +1,65 @@
{
"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.js",
"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": {
".": {
"import": "./dist/index.es.js",
"require": "./dist/index.cjs.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"keywords": [
"vite",
"vite-plugin",
"turborepo",
"deploy",
"git",
"sync"
],
"author": "",
"license": "MIT",
"engines": {
"node": ">=16"
},
"dependencies": {
"chalk": "^5.3.0",
"fs-extra": "^11.2.0",
"ora": "^8.0.1",
"picomatch": "^3.0.1",
"simple-git": "^3.22.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/node": "^20.11.24",
"@types/picomatch": "^2.3.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"
}
}

View File

@@ -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

View File

@@ -0,0 +1,242 @@
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";
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 git.checkout(project.branch);
}
}
// 执行自动提交
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}] 自动提交处理完成`);
}

View File

@@ -0,0 +1,160 @@
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().url({ message: "Invalid Git repository URL" }),
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.");
}
}

View File

@@ -0,0 +1,269 @@
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;
}
}
/**
* 安全地丢弃工作区中的所有更改
* @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 git.checkout(config.branch);
logger.info(`${projectName}: 成功切换到分支 ${config.branch}`);
} catch (checkoutError: any) {
if (config.discardChanges) {
logger.warn(
`${projectName}: 切换分支失败: ${checkoutError.message},尝试强制切换...`,
);
await git.checkout(["-f", config.branch]);
logger.info(
`${projectName}: 成功强制切换到分支 ${config.branch}`,
);
} 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项目初始化完成可以开始编译。");
}
}

View File

@@ -0,0 +1,476 @@
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 { exec as execCallback } from "child_process";
// 使用Promise包装exec函数不依赖util.promisify
const exec = (command: string): Promise<{ stdout: string; stderr: string }> => {
return new Promise((resolve, reject) => {
execCallback(command, (error, stdout, stderr) => {
if (error) {
reject(error);
return;
}
resolve({ stdout, stderr });
});
});
};
// 缓存已创建的临时压缩文件
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;
}
/**
* 生成排除选项字符串
* @param config 同步配置
* @param sourcePath 源路径
* @param targetPath 目标路径
* @param tempDir 临时目录
* @returns 排除选项字符串
*/
function generateExcludeOptions(
config: LocalSyncConfig,
sourcePath: string,
targetPath: string,
tempDir: string,
): string {
let excludeOptions = "";
// 处理排除目录
if (config.excludeDirs && config.excludeDirs.length > 0) {
const excludeDirsFormatted = config.excludeDirs
.map((dir) => {
// 移除通配符,获取基本目录名
const baseDirName = dir.replace(/^\*\*\//, "");
return `-x "*${baseDirName}*"`;
})
.join(" ");
excludeOptions += ` ${excludeDirsFormatted}`;
}
// 处理排除文件
if (config.excludeFiles && config.excludeFiles.length > 0) {
const excludeFilesFormatted = config.excludeFiles
.map((file) => {
return `-x "*${file.replace(/^\*\*\//, "")}*"`;
})
.join(" ");
excludeOptions += ` ${excludeFilesFormatted}`;
}
// 处理正则排除
if (config.exclude && config.exclude.length > 0) {
const excludeRegexFormatted = config.exclude
.map((pattern) => {
return `-x "*${pattern}*"`;
})
.join(" ");
excludeOptions += ` ${excludeRegexFormatted}`;
}
// 始终排除目标路径,避免递归
const relativeTargetPath = path.relative(sourcePath, targetPath);
if (relativeTargetPath) {
excludeOptions += ` -x "*${relativeTargetPath}*"`;
}
// 排除所有.sync-git目录
excludeOptions += ` -x "*.sync-git*"`;
// 排除临时目录
excludeOptions += ` -x "*${path.basename(tempDir)}*"`;
return excludeOptions;
}
/**
* 获取缓存键
* @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 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 excludeOptions = generateExcludeOptions(
config,
sourcePath,
targetPath,
tempDir!,
);
// 压缩源目录内容到临时文件
logger.info(`压缩源目录 ${sourcePath} 到临时文件 ${tempZipFile}...`);
const zipCmd = `cd "${sourcePath}" && zip -r "${tempZipFile}" .${excludeOptions}`;
logger.verbose(`执行命令: ${zipCmd}`);
await exec(zipCmd);
// 将新创建的压缩文件加入缓存
compressionCache[cacheKey] = {
zipFile: tempZipFile,
excludeOptions: excludeOptions,
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}...`);
const unzipCmd = `unzip -o "${tempZipFile}" -d "${targetPath}"`;
logger.verbose(`执行命令: ${unzipCmd}`);
await exec(unzipCmd);
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}`);
}
}
}
}
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 isSubdirectory =
targetPath.startsWith(sourcePath + path.sep) ||
targetPath === sourcePath;
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) {
logger.error(
`${sourcePath} 同步到 ${targetPath} 时出错: ${error.message}`,
error,
);
// 软错误:继续执行其他任务
}
}
}
logger.info("本地文件同步完成");
}
/**
* 递归获取目录中的所有文件路径
* @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;
}

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

View File

@@ -0,0 +1,130 @@
// 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.yamlpnpm 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;
}
}

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

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

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

View File

@@ -0,0 +1,70 @@
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}.js`,
formats: ["es", "cjs", "umd"],
},
rollupOptions: {
external: [
"vite",
"fs-extra",
"simple-git",
"chalk",
"ora",
"zod",
"path",
"os",
"crypto",
"child_process",
"util",
"fs",
"picomatch",
],
output: {
globals: {
vite: "Vite",
"fs-extra": "fsExtra",
"simple-git": "simpleGit",
chalk: "Chalk",
ora: "Ora",
zod: "Zod",
path: "path",
os: "os",
crypto: "crypto",
child_process: "childProcess",
util: "util",
fs: "fs",
picomatch: "picomatch",
},
},
},
sourcemap: true,
minify: false, // Easier debugging for the plugin itself
},
plugins: [
dts({
insertTypesEntry: true,
outputDir: "dist",
staticImport: true,
skipDiagnostics: false,
}),
],
// 优化构建过程中的代码分析
optimizeDeps: {
// 预构建这些依赖以提高开发模式下的性能
include: ["fs-extra", "simple-git", "chalk", "ora", "zod", "picomatch"],
// 告诉 Vite 这些是 ESM / CJS 依赖
esbuildOptions: {
// Node.js 全局变量定义
define: {
global: "globalThis",
},
},
},
});

View File

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