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:
gsxdsm
2026-03-31 22:37:26 -07:00
parent 24335d1176
commit 48484843aa
2 changed files with 421 additions and 0 deletions

View 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"
);
});
});
});

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