feat(KB-503): complete Step 1 — project context resolution utilities
- Add defaultProjectId field to GlobalSettings type - Create project-context.ts with resolveProject(), detectProjectFromCwd() - Implement getDefaultProject(), setDefaultProject(), clearDefaultProject() - Add TaskStore caching with getStoreForProject() and clearStoreCache() - Add formatProjectLine() for CLI display - Create comprehensive tests (7 tests passing) Project resolution order: --project flag → default project → CWD detection
This commit is contained in:
146
packages/cli/src/__tests__/project-context.test.ts
Normal file
146
packages/cli/src/__tests__/project-context.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Tests for project-context.ts
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import {
|
||||
resolveProject,
|
||||
getDefaultProject,
|
||||
setDefaultProject,
|
||||
clearDefaultProject,
|
||||
detectProjectFromCwd,
|
||||
formatProjectLine,
|
||||
getStoreForProject,
|
||||
clearStoreCache,
|
||||
} from "../project-context.js";
|
||||
import { CentralCore, GlobalSettingsStore, type RegisteredProject } from "@fusion/core";
|
||||
|
||||
describe("project-context", () => {
|
||||
let tempDir: string;
|
||||
let globalDir: string;
|
||||
let central: CentralCore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-test-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "kb-global-"));
|
||||
central = new CentralCore(globalDir);
|
||||
await central.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await central.close();
|
||||
clearStoreCache();
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
function createMockProject(name: string, parentDir: string = tempDir): string {
|
||||
const projectPath = join(parentDir, name);
|
||||
mkdirSync(join(projectPath, ".kb"), { recursive: true });
|
||||
writeFileSync(join(projectPath, ".kb", "kb.db"), "");
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
describe("detectProjectFromCwd", () => {
|
||||
it("should find project from CWD when .kb/kb.db exists", async () => {
|
||||
const projectPath = createMockProject("my-project");
|
||||
const project = await central.registerProject({
|
||||
name: "my-project",
|
||||
path: resolve(projectPath),
|
||||
});
|
||||
|
||||
const found = await detectProjectFromCwd(projectPath, central);
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.id).toBe(project.id);
|
||||
expect(found?.name).toBe("my-project");
|
||||
});
|
||||
|
||||
it("should walk up directory tree to find project", async () => {
|
||||
const projectPath = createMockProject("my-project");
|
||||
const subDir = join(projectPath, "src", "components");
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
|
||||
const project = await central.registerProject({
|
||||
name: "my-project",
|
||||
path: resolve(projectPath),
|
||||
});
|
||||
|
||||
const found = await detectProjectFromCwd(subDir, central);
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.id).toBe(project.id);
|
||||
});
|
||||
|
||||
it("should return undefined when no project found", async () => {
|
||||
const randomDir = join(tempDir, "random");
|
||||
mkdirSync(randomDir, { recursive: true });
|
||||
|
||||
const found = await detectProjectFromCwd(randomDir, central);
|
||||
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatProjectLine", () => {
|
||||
it("should format default project with asterisk", () => {
|
||||
const project: RegisteredProject = {
|
||||
id: "proj_123",
|
||||
name: "my-app",
|
||||
path: "/path/to/app",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const line = formatProjectLine(project, true);
|
||||
|
||||
expect(line).toContain("* ");
|
||||
expect(line).toContain("my-app");
|
||||
expect(line).toContain("/path/to/app");
|
||||
expect(line).toContain("[active]");
|
||||
});
|
||||
|
||||
it("should format non-default project without asterisk", () => {
|
||||
const project: RegisteredProject = {
|
||||
id: "proj_456",
|
||||
name: "other-app",
|
||||
path: "/path/to/other",
|
||||
status: "paused",
|
||||
isolationMode: "child-process",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const line = formatProjectLine(project, false);
|
||||
|
||||
expect(line).not.toContain("*");
|
||||
expect(line).toContain("other-app");
|
||||
expect(line).toContain("[paused]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProject errors", () => {
|
||||
it("should throw for unknown project name", async () => {
|
||||
await expect(resolveProject("unknown-project", tempDir)).rejects.toThrow(
|
||||
"not found"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw when no project can be resolved", async () => {
|
||||
const randomDir = join(tempDir, "no-project-here");
|
||||
mkdirSync(randomDir, { recursive: true });
|
||||
|
||||
await expect(resolveProject(undefined, randomDir)).rejects.toThrow(
|
||||
"No kb project found"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
275
packages/cli/src/project-context.ts
Normal file
275
packages/cli/src/project-context.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Project context resolution utilities for multi-project CLI operations.
|
||||
*
|
||||
* Provides project detection, resolution, and TaskStore management
|
||||
* for operating on tasks across multiple registered projects.
|
||||
*/
|
||||
|
||||
import { TaskStore, type RegisteredProject, CentralCore, GlobalSettingsStore } from "@fusion/core";
|
||||
import { isAbsolute, resolve, dirname } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
/** Project context for CLI operations */
|
||||
export interface ProjectContext {
|
||||
/** Project ID */
|
||||
projectId: string;
|
||||
/** Absolute path to project directory */
|
||||
projectPath: string;
|
||||
/** Project name */
|
||||
projectName: string;
|
||||
/** TaskStore instance for this project */
|
||||
store: TaskStore;
|
||||
}
|
||||
|
||||
/** Cache of TaskStore instances by project ID to avoid re-initialization */
|
||||
const storeCache = new Map<string, TaskStore>();
|
||||
|
||||
/**
|
||||
* Resolve a project from explicit name flag, default project, or CWD detection.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. If `projectNameFlag` provided: look up by name (case-insensitive) or ID (exact)
|
||||
* 2. Else if default project set in global settings: use that project
|
||||
* 3. Else: auto-detect from CWD by finding nearest `.kb/kb.db`
|
||||
*
|
||||
* @param projectNameFlag - Optional explicit project name/ID from --project flag
|
||||
* @param cwd - Current working directory for CWD detection (default: process.cwd())
|
||||
* @returns ProjectContext with resolved project and initialized TaskStore
|
||||
* @throws Error if project not found or no project detected from CWD
|
||||
*/
|
||||
export async function resolveProject(
|
||||
projectNameFlag?: string,
|
||||
cwd: string = process.cwd()
|
||||
): Promise<ProjectContext> {
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
try {
|
||||
let project: RegisteredProject | undefined;
|
||||
|
||||
// 1. Explicit --project flag
|
||||
if (projectNameFlag) {
|
||||
project = await findProjectByNameOrId(central, projectNameFlag);
|
||||
if (!project) {
|
||||
throw new Error(
|
||||
`Project '${projectNameFlag}' not found. Run 'kb project list' to see registered projects.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Default project from global settings
|
||||
if (!project) {
|
||||
const defaultProject = await getDefaultProject();
|
||||
if (defaultProject) {
|
||||
project = await central.getProject(defaultProject.id);
|
||||
// If default project was deleted from registry, clear it
|
||||
if (!project) {
|
||||
await clearDefaultProject();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Auto-detect from CWD
|
||||
if (!project) {
|
||||
project = await detectProjectFromCwd(cwd, central);
|
||||
if (!project) {
|
||||
throw new Error(
|
||||
`No kb project found in current directory. Use --project or run from a project directory.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const store = await getStoreForProject(project.id, project.path);
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
projectPath: project.path,
|
||||
projectName: project.name,
|
||||
store,
|
||||
};
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default project from global settings.
|
||||
* Returns undefined if no default is set or if the project no longer exists.
|
||||
*/
|
||||
export async function getDefaultProject(): Promise<RegisteredProject | undefined> {
|
||||
const globalStore = new GlobalSettingsStore();
|
||||
await globalStore.init();
|
||||
|
||||
const settings = await globalStore.getSettings();
|
||||
if (!settings.defaultProjectId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
try {
|
||||
return await central.getProject(settings.defaultProjectId);
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default project in global settings.
|
||||
* @param projectId - Project ID to set as default
|
||||
* @throws Error if project not found
|
||||
*/
|
||||
export async function setDefaultProject(projectId: string): Promise<void> {
|
||||
// Verify project exists
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
try {
|
||||
const project = await central.getProject(projectId);
|
||||
if (!project) {
|
||||
throw new Error(`Project '${projectId}' not found.`);
|
||||
}
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
|
||||
const globalStore = new GlobalSettingsStore();
|
||||
await globalStore.init();
|
||||
await globalStore.updateSettings({ defaultProjectId: projectId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the default project setting.
|
||||
*/
|
||||
export async function clearDefaultProject(): Promise<void> {
|
||||
const globalStore = new GlobalSettingsStore();
|
||||
await globalStore.init();
|
||||
const current = await globalStore.getSettings();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { defaultProjectId: _, ...rest } = current;
|
||||
await globalStore.updateSettings(rest as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a project from the current working directory by walking up
|
||||
* the directory tree looking for `.kb/kb.db`.
|
||||
*
|
||||
* @param cwd - Starting directory (typically process.cwd())
|
||||
* @param central - Initialized CentralCore instance
|
||||
* @returns Registered project if found, undefined otherwise
|
||||
*/
|
||||
export async function detectProjectFromCwd(
|
||||
cwd: string,
|
||||
central: CentralCore
|
||||
): Promise<RegisteredProject | undefined> {
|
||||
let currentDir = resolve(cwd);
|
||||
|
||||
// Walk up the directory tree
|
||||
while (true) {
|
||||
// Check for kb database
|
||||
const kbPath = resolve(currentDir, ".kb", "kb.db");
|
||||
if (existsSync(kbPath)) {
|
||||
// Found a kb project - check if it's registered
|
||||
const project = await central.getProjectByPath(currentDir);
|
||||
if (project) {
|
||||
return project;
|
||||
}
|
||||
// Not registered, but has .kb/kb.db - still use it as a valid project
|
||||
// This allows unregistered projects to work with the CLI
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Move up to parent
|
||||
const parentDir = dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
// Reached root, stop
|
||||
break;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a project by name (case-insensitive) or ID (exact match).
|
||||
*/
|
||||
async function findProjectByNameOrId(
|
||||
central: CentralCore,
|
||||
nameOrId: string
|
||||
): Promise<RegisteredProject | undefined> {
|
||||
// First try exact ID match
|
||||
const byId = await central.getProject(nameOrId);
|
||||
if (byId) {
|
||||
return byId;
|
||||
}
|
||||
|
||||
// Then try case-insensitive name match
|
||||
const allProjects = await central.listProjects();
|
||||
const lowerName = nameOrId.toLowerCase();
|
||||
return allProjects.find((p) => p.name.toLowerCase() === lowerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a TaskStore for a project.
|
||||
* Stores are cached by project ID to avoid re-initialization.
|
||||
*
|
||||
* @param projectId - Project ID for cache key
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Initialized TaskStore
|
||||
*/
|
||||
export async function getStoreForProject(
|
||||
projectId: string,
|
||||
projectPath: string
|
||||
): Promise<TaskStore> {
|
||||
// Check cache first
|
||||
const cached = storeCache.get(projectId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Create new store
|
||||
const store = new TaskStore(projectPath);
|
||||
await store.init();
|
||||
|
||||
// Cache it
|
||||
storeCache.set(projectId, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the store cache. Useful for testing or memory management.
|
||||
*/
|
||||
export function clearStoreCache(): void {
|
||||
storeCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a project for display in CLI output.
|
||||
*
|
||||
* @param project - Registered project
|
||||
* @param isDefault - Whether this is the default project
|
||||
* @returns Formatted string like "* my-app /path/to/project [active]"
|
||||
*/
|
||||
export function formatProjectLine(project: RegisteredProject, isDefault: boolean): string {
|
||||
const marker = isDefault ? "* " : " ";
|
||||
const status = project.status;
|
||||
const name = project.name;
|
||||
const path = project.path;
|
||||
return `${marker}${name} ${path} [${status}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a TaskStore for a project specified by name or the current directory.
|
||||
* This is a convenience function for commands that need a store.
|
||||
*
|
||||
* @param projectName - Optional project name/ID from --project flag
|
||||
* @param cwd - Current working directory
|
||||
* @returns Initialized TaskStore
|
||||
*/
|
||||
export async function getStore(
|
||||
projectName?: string,
|
||||
cwd: string = process.cwd()
|
||||
): Promise<TaskStore> {
|
||||
const context = await resolveProject(projectName, cwd);
|
||||
return context.store;
|
||||
}
|
||||
Reference in New Issue
Block a user