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:
xiongxiao
2026-03-14 17:22:00 +08:00
committed by cnb
parent 1aa46b92c0
commit df616df952
10 changed files with 372 additions and 58 deletions

4
src/commander.ts Normal file
View File

@@ -0,0 +1,4 @@
import { parse } from '@kevisual/router/commander'
import { app } from './index'
parse({ app: app as any });

View File

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

View File

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

View File

@@ -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, // 监听路径(支持数组)

View File

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

View File

@@ -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
View 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);

View File

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