feat(project): add workspace directory configuration and management (#1)
- Add projectDir field to Project model for workspace directory management - Implement workspace directory creation, validation and Git initialization - Add workspace status query endpoint with directory info and Git status - Create GitManager for Git repository operations (clone, branch, commit info) - Add PathValidator for secure path validation and traversal attack prevention - Implement execution queue with concurrency control for build tasks - Refactor project list UI to remove edit/delete actions from cards - Add project settings tab in detail page with edit/delete functionality - Add icons to all tabs (History, Code, Settings) - Implement time formatting with dayjs in YYYY-MM-DD HH:mm:ss format - Display all timestamps using browser's local timezone - Update PipelineRunner to use workspace directory for command execution - Add workspace status card showing directory path, size, Git info - Enhance CreateProjectModal with repository URL validation Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -61,12 +61,12 @@ export class ExecutionQueue {
|
||||
const pendingDeployments = await prisma.deployment.findMany({
|
||||
where: {
|
||||
status: 'pending',
|
||||
valid: 1
|
||||
valid: 1,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
pipelineId: true
|
||||
}
|
||||
pipelineId: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Found ${pendingDeployments.length} pending deployments`);
|
||||
@@ -126,21 +126,25 @@ export class ExecutionQueue {
|
||||
const pendingDeployments = await prisma.deployment.findMany({
|
||||
where: {
|
||||
status: 'pending',
|
||||
valid: 1
|
||||
valid: 1,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
pipelineId: true
|
||||
}
|
||||
pipelineId: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Found ${pendingDeployments.length} pending deployments in polling`);
|
||||
console.log(
|
||||
`Found ${pendingDeployments.length} pending deployments in polling`,
|
||||
);
|
||||
|
||||
// 检查这些任务是否已经在队列中,如果没有则添加
|
||||
for (const deployment of pendingDeployments) {
|
||||
// 检查是否已经在运行队列中
|
||||
if (!runningDeployments.has(deployment.id)) {
|
||||
console.log(`Adding deployment ${deployment.id} to queue from polling`);
|
||||
console.log(
|
||||
`Adding deployment ${deployment.id} to queue from polling`,
|
||||
);
|
||||
await this.addTask(deployment.id, deployment.pipelineId);
|
||||
}
|
||||
}
|
||||
@@ -154,7 +158,10 @@ export class ExecutionQueue {
|
||||
* @param deploymentId 部署ID
|
||||
* @param pipelineId 流水线ID
|
||||
*/
|
||||
public async addTask(deploymentId: number, pipelineId: number): Promise<void> {
|
||||
public async addTask(
|
||||
deploymentId: number,
|
||||
pipelineId: number,
|
||||
): Promise<void> {
|
||||
// 检查是否已经在运行队列中
|
||||
if (runningDeployments.has(deploymentId)) {
|
||||
console.log(`Deployment ${deploymentId} is already queued or running`);
|
||||
@@ -196,7 +203,7 @@ export class ExecutionQueue {
|
||||
}
|
||||
|
||||
// 添加一个小延迟以避免过度占用资源
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
this.isProcessing = false;
|
||||
@@ -207,9 +214,35 @@ export class ExecutionQueue {
|
||||
* @param deploymentId 部署ID
|
||||
* @param pipelineId 流水线ID
|
||||
*/
|
||||
private async executePipeline(deploymentId: number, pipelineId: number): Promise<void> {
|
||||
private async executePipeline(
|
||||
deploymentId: number,
|
||||
pipelineId: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const runner = new PipelineRunner(deploymentId);
|
||||
// 获取部署信息以获取项目和 projectDir
|
||||
const deployment = await prisma.deployment.findUnique({
|
||||
where: { id: deploymentId },
|
||||
include: {
|
||||
Project: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!deployment || !deployment.Project) {
|
||||
throw new Error(
|
||||
`Deployment ${deploymentId} or associated project not found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!deployment.Project.projectDir) {
|
||||
throw new Error(
|
||||
`项目 "${deployment.Project.name}" 未配置工作目录,无法执行流水线`,
|
||||
);
|
||||
}
|
||||
|
||||
const runner = new PipelineRunner(
|
||||
deploymentId,
|
||||
deployment.Project.projectDir,
|
||||
);
|
||||
await runner.run(pipelineId);
|
||||
} catch (error) {
|
||||
console.error('执行流水线失败:', error);
|
||||
@@ -227,7 +260,7 @@ export class ExecutionQueue {
|
||||
} {
|
||||
return {
|
||||
pendingCount: pendingQueue.length,
|
||||
runningCount: runningDeployments.size
|
||||
runningCount: runningDeployments.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
280
apps/server/libs/git-manager.ts
Normal file
280
apps/server/libs/git-manager.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Git 管理器
|
||||
* 封装 Git 操作:克隆、更新、分支切换等
|
||||
*/
|
||||
|
||||
import { $ } from 'zx';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { log } from './logger';
|
||||
|
||||
/**
|
||||
* 工作目录状态
|
||||
*/
|
||||
export const WorkspaceDirStatus = {
|
||||
NOT_CREATED: 'not_created', // 目录不存在
|
||||
EMPTY: 'empty', // 目录存在但为空
|
||||
NO_GIT: 'no_git', // 目录存在但不是 Git 仓库
|
||||
READY: 'ready', // 目录存在且包含 Git 仓库
|
||||
} as const;
|
||||
|
||||
export type WorkspaceDirStatus =
|
||||
(typeof WorkspaceDirStatus)[keyof typeof WorkspaceDirStatus];
|
||||
|
||||
/**
|
||||
* 工作目录状态信息
|
||||
*/
|
||||
export interface WorkspaceStatus {
|
||||
status: WorkspaceDirStatus;
|
||||
exists: boolean;
|
||||
isEmpty?: boolean;
|
||||
hasGit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Git仓库信息
|
||||
*/
|
||||
export interface GitInfo {
|
||||
branch?: string;
|
||||
lastCommit?: string;
|
||||
lastCommitMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Git管理器类
|
||||
*/
|
||||
export class GitManager {
|
||||
static readonly TAG = 'GitManager';
|
||||
/**
|
||||
* 检查工作目录状态
|
||||
*/
|
||||
static async checkWorkspaceStatus(dirPath: string): Promise<WorkspaceStatus> {
|
||||
try {
|
||||
// 检查目录是否存在
|
||||
const stats = await fs.stat(dirPath);
|
||||
if (!stats.isDirectory()) {
|
||||
return {
|
||||
status: WorkspaceDirStatus.NOT_CREATED,
|
||||
exists: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 检查目录是否为空
|
||||
const files = await fs.readdir(dirPath);
|
||||
if (files.length === 0) {
|
||||
return {
|
||||
status: WorkspaceDirStatus.EMPTY,
|
||||
exists: true,
|
||||
isEmpty: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 检查是否包含 .git 目录
|
||||
const gitDir = path.join(dirPath, '.git');
|
||||
try {
|
||||
const gitStats = await fs.stat(gitDir);
|
||||
if (gitStats.isDirectory()) {
|
||||
return {
|
||||
status: WorkspaceDirStatus.READY,
|
||||
exists: true,
|
||||
isEmpty: false,
|
||||
hasGit: true,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
status: WorkspaceDirStatus.NO_GIT,
|
||||
exists: true,
|
||||
isEmpty: false,
|
||||
hasGit: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: WorkspaceDirStatus.NO_GIT,
|
||||
exists: true,
|
||||
isEmpty: false,
|
||||
hasGit: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {
|
||||
status: WorkspaceDirStatus.NOT_CREATED,
|
||||
exists: false,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆仓库到指定目录
|
||||
* @param repoUrl 仓库URL
|
||||
* @param dirPath 目标目录
|
||||
* @param branch 分支名
|
||||
* @param token Gitea access token(可选)
|
||||
*/
|
||||
static async cloneRepository(
|
||||
repoUrl: string,
|
||||
dirPath: string,
|
||||
branch: string,
|
||||
token?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
log.info(
|
||||
GitManager.TAG,
|
||||
'Cloning repository: %s to %s (branch: %s)',
|
||||
repoUrl,
|
||||
dirPath,
|
||||
branch,
|
||||
);
|
||||
|
||||
// 如果提供了token,嵌入到URL中
|
||||
let cloneUrl = repoUrl;
|
||||
if (token) {
|
||||
const url = new URL(repoUrl);
|
||||
url.username = token;
|
||||
cloneUrl = url.toString();
|
||||
}
|
||||
|
||||
// 使用 zx 执行 git clone(浅克隆)
|
||||
$.verbose = false; // 禁止打印敏感信息
|
||||
await $`git clone --depth 1 --branch ${branch} ${cloneUrl} ${dirPath}`;
|
||||
$.verbose = true;
|
||||
|
||||
log.info(GitManager.TAG, 'Repository cloned successfully: %s', dirPath);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
GitManager.TAG,
|
||||
'Failed to clone repository: %s to %s, error: %s',
|
||||
repoUrl,
|
||||
dirPath,
|
||||
(error as Error).message,
|
||||
);
|
||||
throw new Error(`克隆仓库失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新已存在的仓库
|
||||
* @param dirPath 仓库目录
|
||||
* @param branch 目标分支
|
||||
*/
|
||||
static async updateRepository(
|
||||
dirPath: string,
|
||||
branch: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
log.info(
|
||||
GitManager.TAG,
|
||||
'Updating repository: %s (branch: %s)',
|
||||
dirPath,
|
||||
branch,
|
||||
);
|
||||
|
||||
$.verbose = false;
|
||||
// 切换到仓库目录
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(dirPath);
|
||||
|
||||
try {
|
||||
// 获取最新代码
|
||||
await $`git fetch --depth 1 origin ${branch}`;
|
||||
// 切换到目标分支
|
||||
await $`git checkout ${branch}`;
|
||||
// 拉取最新代码
|
||||
await $`git pull origin ${branch}`;
|
||||
|
||||
log.info(
|
||||
GitManager.TAG,
|
||||
'Repository updated successfully: %s (branch: %s)',
|
||||
dirPath,
|
||||
branch,
|
||||
);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
$.verbose = true;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(
|
||||
GitManager.TAG,
|
||||
'Failed to update repository: %s (branch: %s), error: %s',
|
||||
dirPath,
|
||||
branch,
|
||||
(error as Error).message,
|
||||
);
|
||||
throw new Error(`更新仓库失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Git仓库信息
|
||||
*/
|
||||
static async getGitInfo(dirPath: string): Promise<GitInfo> {
|
||||
try {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(dirPath);
|
||||
|
||||
try {
|
||||
$.verbose = false;
|
||||
const branchResult = await $`git branch --show-current`;
|
||||
const commitResult = await $`git rev-parse --short HEAD`;
|
||||
const messageResult = await $`git log -1 --pretty=%B`;
|
||||
$.verbose = true;
|
||||
|
||||
return {
|
||||
branch: branchResult.stdout.trim(),
|
||||
lastCommit: commitResult.stdout.trim(),
|
||||
lastCommitMessage: messageResult.stdout.trim(),
|
||||
};
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(
|
||||
GitManager.TAG,
|
||||
'Failed to get git info: %s, error: %s',
|
||||
dirPath,
|
||||
(error as Error).message,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建目录(递归)
|
||||
*/
|
||||
static async ensureDirectory(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
log.info(GitManager.TAG, 'Directory created: %s', dirPath);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
GitManager.TAG,
|
||||
'Failed to create directory: %s, error: %s',
|
||||
dirPath,
|
||||
(error as Error).message,
|
||||
);
|
||||
throw new Error(`创建目录失败: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目录大小
|
||||
*/
|
||||
static async getDirectorySize(dirPath: string): Promise<number> {
|
||||
try {
|
||||
const { stdout } = await $`du -sb ${dirPath}`;
|
||||
const size = Number.parseInt(stdout.split('\t')[0], 10);
|
||||
return size;
|
||||
} catch (error) {
|
||||
log.error(
|
||||
GitManager.TAG,
|
||||
'Failed to get directory size: %s, error: %s',
|
||||
dirPath,
|
||||
(error as Error).message,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
apps/server/libs/path-validator.ts
Normal file
67
apps/server/libs/path-validator.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 路径验证工具
|
||||
* 用于验证项目工作目录路径的合法性
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* 项目目录路径验证schema
|
||||
*/
|
||||
export const projectDirSchema = z
|
||||
.string()
|
||||
.min(1, '工作目录路径不能为空')
|
||||
.refine(path.isAbsolute, '工作目录路径必须是绝对路径')
|
||||
.refine((v) => !v.includes('..'), '不能包含路径遍历字符')
|
||||
.refine((v) => !v.includes('~'), '不能包含用户目录符号')
|
||||
.refine((v) => !/[<>:"|?*\x00-\x1f]/.test(v), '包含非法字符')
|
||||
.refine((v) => path.normalize(v) === v, '路径格式不规范');
|
||||
|
||||
/**
|
||||
* 验证路径格式
|
||||
* @param dirPath 待验证的路径
|
||||
* @returns 验证结果
|
||||
*/
|
||||
export function validateProjectDir(dirPath: string): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
try {
|
||||
projectDirSchema.parse(dirPath);
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { valid: false, error: error.issues[0].message };
|
||||
}
|
||||
return { valid: false, error: '路径验证失败' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路径是否为绝对路径
|
||||
*/
|
||||
export function isAbsolutePath(dirPath: string): boolean {
|
||||
return path.isAbsolute(dirPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路径是否包含非法字符
|
||||
*/
|
||||
export function hasIllegalCharacters(dirPath: string): boolean {
|
||||
return /[<>:"|?*\x00-\x1f]/.test(dirPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查路径是否包含路径遍历
|
||||
*/
|
||||
export function hasPathTraversal(dirPath: string): boolean {
|
||||
return dirPath.includes('..') || dirPath.includes('~');
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化路径
|
||||
*/
|
||||
export function normalizePath(dirPath: string): string {
|
||||
return path.normalize(dirPath);
|
||||
}
|
||||
Reference in New Issue
Block a user