chore: update project version to 0.0.11 and enhance project management features
- Added new project initialization route to register git projects from a specified root directory. - Enhanced project management to support project status (active, inactive, unlive) and conditional listening. - Updated project listener to handle non-existent paths gracefully. - Improved project store to manage project documents with optional fields. - Refactored git utility functions for better pathname extraction from URLs. - Added command-line interface support for project management. - Updated project routes to handle new project types and improved error handling.
This commit is contained in:
4
src/commander.ts
Normal file
4
src/commander.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { parse } from '@kevisual/router/commander'
|
||||
import { app } from './index'
|
||||
|
||||
parse({ app: app as any });
|
||||
@@ -8,6 +8,8 @@ import './routes/auth';
|
||||
import './routes/project';
|
||||
import './routes/search';
|
||||
import './routes/file';
|
||||
import './routes/project-init';
|
||||
|
||||
import { manager } from './app';
|
||||
|
||||
if (import.meta.main) {
|
||||
|
||||
@@ -8,25 +8,11 @@ import path from 'node:path';
|
||||
import { CustomError } from "@kevisual/router";
|
||||
|
||||
type Project = {
|
||||
name?: string;
|
||||
path: string;
|
||||
/**
|
||||
* 用 git remote url 唯一标识项目,方便搜索结果展示和过滤
|
||||
*/
|
||||
repo: string;
|
||||
listener: ProjectListener;
|
||||
};
|
||||
} & ProjectDoc;
|
||||
|
||||
export type ProjectInput = Omit<Project, "listener">;
|
||||
export type ProjectInfo = Omit<Project, "listener"> & {
|
||||
status: "active" | "inactive";
|
||||
id?: string;
|
||||
title?: string;
|
||||
tags?: string[];
|
||||
summary?: string;
|
||||
description?: string;
|
||||
link?: string;
|
||||
};
|
||||
export type ProjectInput = ProjectDoc;
|
||||
export type ProjectInfo = ProjectDoc
|
||||
|
||||
type ProjectManagerOpt = {
|
||||
meiliSearchOptions?: {
|
||||
@@ -58,7 +44,8 @@ export class ProjectManager implements ProjectManagerInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化:从 store 加载已持久化的项目,检查本地目录存在后启动监听
|
||||
* 初始化:从 store 加载已持久化的项目,检查本地目录存在后
|
||||
* 只对 status 为 active 的项目启动监听,inactive 的只加载不监听
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
await this.projectStore.init();
|
||||
@@ -68,8 +55,13 @@ export class ProjectManager implements ProjectManagerInterface {
|
||||
console.log(`[ProjectManager] init: skip missing path ${doc.path}`);
|
||||
continue;
|
||||
}
|
||||
// 只对 active 状态的项目启动监听
|
||||
const shouldWatch = doc.status === 'active';
|
||||
try {
|
||||
await this.addProject({ name: doc.name, path: doc.path, repo: doc.repo });
|
||||
await this.addProject(
|
||||
{ name: doc.name, path: doc.path, repo: doc.repo },
|
||||
{ startListening: shouldWatch }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`[ProjectManager] init: failed to add project ${doc.path}`, err);
|
||||
}
|
||||
@@ -78,32 +70,46 @@ export class ProjectManager implements ProjectManagerInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加项目:创建监听器、启动监听、触发全量初始同步
|
||||
* 添加项目:创建监听器、可选启动监听、触发全量初始同步
|
||||
*/
|
||||
async addProject(input: ProjectInput): Promise<ProjectInfo> {
|
||||
async addProject(input: ProjectDoc, options: { startListening?: boolean } = {}): Promise<ProjectInfo> {
|
||||
const { startListening = true } = options;
|
||||
// 检查本地目录是否存在
|
||||
if (!fs.existsSync(input.path)) {
|
||||
if (!fs.existsSync(input.path!)) {
|
||||
throw new CustomError(`[ProjectManager] addProject: path does not exist: ${input.path}`);
|
||||
}
|
||||
// 若已存在则先停止旧监听
|
||||
if (this.projects.has(input.path)) {
|
||||
await this.stopProject(input.path);
|
||||
if (this.projects.has(input.path!)) {
|
||||
await this.stopProject(input.path!);
|
||||
}
|
||||
const listener = new ProjectListener({ projectPath: input.path, emitter: this.emitter });
|
||||
const listener = new ProjectListener({ projectPath: input.path!, emitter: this.emitter });
|
||||
const project: Project = { ...input, listener };
|
||||
|
||||
this.projects.set(input.path, project);
|
||||
|
||||
// 持久化存储项目信息
|
||||
this.projects.set(input.path!, project);
|
||||
const readmePath = path.join(input.path!, 'README.md');
|
||||
let description = '';
|
||||
if (!input.description && fs.existsSync(readmePath)) {
|
||||
const readmeContent = fs.readFileSync(readmePath, 'utf-8');
|
||||
description = readmeContent;
|
||||
}
|
||||
// 持久化存储项目信息(默认 active)
|
||||
this.projectStore.ensureIndex().then(() =>
|
||||
this.projectStore.addProject({ name: input.name, path: input.path, repo: input.repo })
|
||||
this.projectStore.addProject({
|
||||
name: input.name,
|
||||
path: input.path,
|
||||
repo: input.repo,
|
||||
status: startListening ? 'active' : 'inactive',
|
||||
description: input?.description || description,
|
||||
})
|
||||
).catch(err => {
|
||||
console.error('[ProjectManager] projectStore.addProject failed:', err);
|
||||
});
|
||||
|
||||
// 启动文件监听
|
||||
await listener.startListening();
|
||||
console.log(`[ProjectManager] watching: ${input.path}`);
|
||||
// 根据选项启动文件监听
|
||||
if (startListening) {
|
||||
await listener.startListening();
|
||||
console.log(`[ProjectManager] watching: ${input.path}`);
|
||||
}
|
||||
|
||||
// 触发全量初始同步(不阻塞返回)
|
||||
this.projectSearch.initialSync(input.path, input.repo).catch(err => {
|
||||
@@ -137,15 +143,37 @@ export class ProjectManager implements ProjectManagerInterface {
|
||||
const project = this.projects.get(projectPath);
|
||||
if (!project) return;
|
||||
await project.listener.stopListening();
|
||||
// 更新 store 中的 status
|
||||
this.projectStore.updateProject(projectPath, { status: 'inactive' }).catch(err => {
|
||||
console.error('[ProjectManager] updateProject status failed:', err);
|
||||
});
|
||||
console.log(`[ProjectManager] stopped: ${projectPath}`);
|
||||
}
|
||||
|
||||
/** 启动项目文件监听 */
|
||||
async startProject(projectPath: string): Promise<void> {
|
||||
const project = this.projects.get(projectPath);
|
||||
if (!project) return;
|
||||
await project.listener.startListening();
|
||||
// 更新 store 中的 status
|
||||
this.projectStore.updateProject(projectPath, { status: 'active' }).catch(err => {
|
||||
console.error('[ProjectManager] updateProject status failed:', err);
|
||||
});
|
||||
console.log(`[ProjectManager] started: ${projectPath}`);
|
||||
}
|
||||
|
||||
async getProjectInfo(project: Project): Promise<ProjectInfo> {
|
||||
const { listener, ...info } = project;
|
||||
const storeDoc: ProjectDoc | null = await this.projectStore.getProject(info.path).catch(() => null);
|
||||
// 优先从 store 读取 status,确保与 store 保持一致
|
||||
let status: any = storeDoc?.status === 'active' ? 'active' : 'inactive';
|
||||
const exists = fileIsExist(info.path);
|
||||
if (!exists) {
|
||||
status = 'unlive';
|
||||
}
|
||||
return {
|
||||
...info,
|
||||
status: listener.isWatching ? "active" : "inactive",
|
||||
status,
|
||||
id: storeDoc?.id,
|
||||
title: storeDoc?.title,
|
||||
tags: storeDoc?.tags,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import ParcelWatcher, { subscribe } from '@parcel/watcher';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import fs, { existsSync } from 'node:fs';
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { normalizeIgnorePattern, defaultIgnorePatterns } from '../utils';
|
||||
import { CustomError } from '@kevisual/router';
|
||||
|
||||
export class ProjectListener {
|
||||
projectPath: string;
|
||||
@@ -26,6 +27,10 @@ export class ProjectListener {
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'));
|
||||
}
|
||||
if (!existsSync(projectPath)) {
|
||||
console.warn(`[ProjectListener] Project path does not exist: ${projectPath}`);
|
||||
throw new CustomError(`Project path does not exist: ${projectPath}`);
|
||||
}
|
||||
const allIgnore = [...defaultIgnorePatterns, ...ignorePatterns];
|
||||
const sub = await subscribe(
|
||||
this.projectPath, // 监听路径(支持数组)
|
||||
|
||||
@@ -6,7 +6,7 @@ export type ProjectDoc = {
|
||||
/**
|
||||
* 文档 ID,使用 path 的 base64url 编码
|
||||
*/
|
||||
id: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
path: string;
|
||||
/**
|
||||
@@ -23,9 +23,9 @@ export type ProjectDoc = {
|
||||
description?: string;
|
||||
link?: string;
|
||||
/**
|
||||
* 项目状态:active 活动中 | inactive 非活动
|
||||
* 项目状态:active 活动中 | inactive 非活动 | unlive 未初始化
|
||||
*/
|
||||
status?: 'active' | 'inactive';
|
||||
status?: 'active' | 'inactive' | 'unlive';
|
||||
};
|
||||
|
||||
function projectId(projectPath: string): string {
|
||||
@@ -59,8 +59,8 @@ export class ProjectStore {
|
||||
async init() {
|
||||
await this.ensureIndex();
|
||||
}
|
||||
async addProject(input: { name?: string; path: string; repo: string; status?: 'active' | 'inactive' }): Promise<void> {
|
||||
const id = projectId(input.path);
|
||||
async addProject(input: Partial<ProjectDoc>): Promise<void> {
|
||||
const id = projectId(input.path!);
|
||||
|
||||
// 先查询是否已存在
|
||||
let existingDoc: ProjectDoc | null = null;
|
||||
@@ -74,14 +74,14 @@ export class ProjectStore {
|
||||
const doc: ProjectDoc = {
|
||||
id,
|
||||
name: input.name,
|
||||
path: input.path,
|
||||
repo: input.repo,
|
||||
path: input.path!,
|
||||
repo: input.repo!,
|
||||
// 保留原有字段
|
||||
title: existingDoc?.title,
|
||||
tags: existingDoc?.tags,
|
||||
summary: existingDoc?.summary,
|
||||
description: existingDoc?.description,
|
||||
link: existingDoc?.link,
|
||||
title: input.title ?? existingDoc?.title,
|
||||
tags: input.tags ?? existingDoc?.tags,
|
||||
summary: input.summary ?? existingDoc?.summary,
|
||||
description: input.description ?? existingDoc?.description,
|
||||
link: input.link ?? existingDoc?.link,
|
||||
status: input.status ?? existingDoc?.status ?? 'active',
|
||||
};
|
||||
|
||||
@@ -109,7 +109,7 @@ export class ProjectStore {
|
||||
}
|
||||
}
|
||||
|
||||
async updateProject(projectPath: string, opts: { name?: string; repo?: string; title?: string; tags?: string[]; summary?: string; description?: string; link?: string; status?: 'active' | 'inactive' }): Promise<void> {
|
||||
async updateProject(projectPath: string, opts: { name?: string; repo?: string; title?: string; tags?: string[]; summary?: string; description?: string; link?: string; status?: 'active' | 'inactive' | 'unlive' }): Promise<void> {
|
||||
const partial: Record<string, any> = { id: projectId(projectPath) };
|
||||
if (opts.name !== undefined) partial.name = opts.name;
|
||||
if (opts.repo !== undefined) partial.repo = opts.repo;
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import path from 'node:path';
|
||||
|
||||
export const getGitPathname = (repoPath: string): string | null => {
|
||||
export const getPathnameFromGitUrl = (url: string): string => {
|
||||
const _url = new URL(url.replace(/\.git$/, ''));
|
||||
const pathname = _url.pathname.replace(/^\/+/, '').replace(/\/+$/, ''); // 去除开头和结尾的斜杠
|
||||
return pathname;
|
||||
}
|
||||
export const getGitPathname = (repoPath: string): { url: string, pathname: string } | null => {
|
||||
try {
|
||||
const url = execSync('git config --get remote.origin.url', { cwd: repoPath, encoding: 'utf-8' }).trim();
|
||||
if (url) {
|
||||
const _url = new URL(url.replace(/\.git$/, ''));
|
||||
const pathname = _url.pathname.replace(/^\/+/, '').replace(/\/+$/, ''); // 去除开头和结尾的斜杠
|
||||
return pathname;
|
||||
return {
|
||||
url,
|
||||
pathname: getPathnameFromGitUrl(url),
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn(`[getGitPathname] Failed to get git remote url for ${repoPath}:`);
|
||||
console.warn(`[getGitPathname] Failed to get git remote url for ${repoPath}:`, err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
135
src/routes/project-init.ts
Normal file
135
src/routes/project-init.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { z } from 'zod'
|
||||
import { app, manager } from '../app.ts';
|
||||
import { getGitPathname, getPathnameFromGitUrl } from '../project/util/git';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
export const DEFAULT_ROOT_PATH = '/workspace/projects';
|
||||
const getProjectPaths = (ctx: any): { rootPath: string; projectPaths: string[] } => {
|
||||
const rootPath = path.resolve(ctx.query.rootPath || DEFAULT_ROOT_PATH);
|
||||
// 1. 检测文件夹是否存在
|
||||
if (!fs.existsSync(rootPath)) {
|
||||
ctx.throw(400, 'rootPath 指定的目录不存在');
|
||||
}
|
||||
// 2. 搜索第一层子目录,找到包含 .git 的目录, 如果存在把这个子目录的路径和 git 远程地址注册到系统中
|
||||
const projectPaths: string[] = [];
|
||||
|
||||
// 搜索第一层子目录中包含 .git 的目录
|
||||
try {
|
||||
const entries = fs.readdirSync(rootPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const fullPath = path.join(rootPath, entry.name);
|
||||
// 跳过 node_modules 和隐藏目录
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||
|
||||
// 如果包含 .git 目录,则认为是 git 项目
|
||||
if (fs.existsSync(path.join(fullPath, '.git'))) {
|
||||
projectPaths.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[project-init] Failed to read root directory ${rootPath}:`, err);
|
||||
}
|
||||
return {
|
||||
rootPath,
|
||||
projectPaths
|
||||
};
|
||||
}
|
||||
app.route({
|
||||
path: 'project',
|
||||
key: 'init',
|
||||
description: '初始化项目,通过root目录,搜索当前有git的项目列表,并将其注册到系统中',
|
||||
middleware: ['auth-admin'],
|
||||
metadata: {
|
||||
args: {
|
||||
rootPath: z.string().optional().describe('搜索项目的根目录绝对路径,默认为 /workspace/projects'),
|
||||
}
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const { rootPath, projectPaths } = getProjectPaths(ctx);
|
||||
// 3. 注册项目到系统中
|
||||
const results: Array<{ path: string; repo: string | null; success: boolean; error?: string }> = [];
|
||||
|
||||
for (const projectPath of projectPaths) {
|
||||
try {
|
||||
const gitRepo = getGitPathname(projectPath);
|
||||
const repo = gitRepo?.pathname || path.basename(projectPath);
|
||||
const name = path.basename(projectPath);
|
||||
await manager.addProject({ path: projectPath, repo, name, link: gitRepo?.url });
|
||||
results.push({ path: projectPath, repo: repo, success: true });
|
||||
console.log(`[project-init] Added project: ${projectPath}, repo: ${repo}`);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
results.push({ path: projectPath, repo: null, success: false, error });
|
||||
console.error(`[project-init] Failed to add project ${projectPath}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
rootPath,
|
||||
totalFound: projectPaths.length,
|
||||
results
|
||||
};
|
||||
}).addTo(app);
|
||||
|
||||
app.route({
|
||||
path: 'project',
|
||||
key: 'list-projects',
|
||||
description: '列出当前工作区已注册的项目',
|
||||
middleware: ['auth-admin'],
|
||||
metadata: {
|
||||
args: {
|
||||
rootPath: z.string().optional().describe('项目根目录绝对路径,默认为 /workspace/projects,指定后只列出该目录下的项目'),
|
||||
}
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const { rootPath, projectPaths } = getProjectPaths(ctx);
|
||||
ctx.body = {
|
||||
rootPath,
|
||||
list: projectPaths
|
||||
};
|
||||
}).addTo(app);
|
||||
|
||||
app.route({
|
||||
path: 'project',
|
||||
key: 'clone-cnb',
|
||||
description: 'clone 一个项目到projects目录下,并注册到系统中。',
|
||||
middleware: ['auth-admin'],
|
||||
metadata: {
|
||||
args: {
|
||||
filepath: z.string().optional().describe('新项目根目录的绝对路径,默认在 /workspace/projects 下以仓库名创建子目录'),
|
||||
repo: z.string().describe('代码仓库标识,用于搜索结果展示和过滤,格式如 owner/repo,例如 kevisual/cnb,必填'),
|
||||
}
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const { filepath = DEFAULT_ROOT_PATH, repo = '' } = ctx.query as { filepath: string; repo?: string; };
|
||||
let cloneRepoUrl = ''
|
||||
if (repo?.startsWith('http')) {
|
||||
cloneRepoUrl = repo;
|
||||
} else if (repo) {
|
||||
cloneRepoUrl = `https://cnb.cool/${repo}`;
|
||||
} else {
|
||||
ctx.throw(400, 'repo 参数不能为空,且必须是完整的 URL 或者 owner/repo 格式');
|
||||
}
|
||||
const name = getPathnameFromGitUrl(cloneRepoUrl);
|
||||
if (!name) {
|
||||
ctx.throw(400, '无法从 repo 参数解析出项目名称,请检查 repo 格式是否正确');
|
||||
}
|
||||
const targetPath = path.join(filepath, name);
|
||||
if (fs.existsSync(targetPath)) {
|
||||
ctx.throw(400, `目标路径已存在: ${targetPath}`);
|
||||
}
|
||||
const cloneCmd = `git clone ${cloneRepoUrl} ${targetPath}`;
|
||||
try {
|
||||
console.log(`[project-clone] Cloning repo ${cloneRepoUrl} to ${targetPath}...`);
|
||||
execSync(cloneCmd, { stdio: 'inherit' });
|
||||
console.log(`[project-clone] Clone successful, adding project...`);
|
||||
const info = await manager.addProject({ path: targetPath, repo: name, name, link: cloneRepoUrl });
|
||||
ctx.body = info;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[project-clone] Failed to clone and add project from ${cloneRepoUrl}:`, err);
|
||||
ctx.throw(500, `Failed to clone and add project: ${error}`);
|
||||
}
|
||||
}).addTo(app);
|
||||
@@ -17,22 +17,34 @@ app
|
||||
filepath: z.string().describe('项目根目录的绝对路径,必填'),
|
||||
repo: z.string().optional().describe('代码仓库标识,用于搜索结果展示和过滤,格式如 owner/repo,例如 kevisual/cnb,选填(默认自动从 git 配置读取)'),
|
||||
name: z.string().optional().describe('项目显示名称,用于搜索结果展示,选填(默认使用目录名)'),
|
||||
type: z.string().optional().describe('项目类型,filepath或cnb-repo,默认为filepath'),
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
let { filepath, repo, name } = ctx.query as { filepath: string; repo?: string; name?: string };
|
||||
let { filepath, repo, name, type = 'filepath' } = ctx.query as { filepath: string; repo?: string; name?: string; type?: string };
|
||||
if (!filepath) ctx.throw(400, 'filepath 不能为空');
|
||||
if (type === 'cnb-repo') {
|
||||
const msg = {
|
||||
path: 'project',
|
||||
key: 'clone-cnb',
|
||||
}
|
||||
const res = await app.run({ ...msg, payload: { repo: filepath } }, { state: ctx.state });
|
||||
ctx.forward(res);
|
||||
return
|
||||
}
|
||||
let link = '';
|
||||
if (!repo) {
|
||||
const gitPathname = getGitPathname(filepath);
|
||||
if (gitPathname) {
|
||||
repo = gitPathname;
|
||||
repo = gitPathname.pathname;
|
||||
link = gitPathname.url;
|
||||
} else {
|
||||
const pathParts = filepath.split('/');
|
||||
repo = pathParts[pathParts.length - 1];
|
||||
}
|
||||
}
|
||||
const info = await manager.addProject({ path: filepath, repo, name });
|
||||
const info = await manager.addProject({ path: filepath, repo, name, link: link || (repo ? `https://cnb.cool/${repo}` : undefined) });
|
||||
ctx.body = { success: true, data: info };
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
Reference in New Issue
Block a user