Skip to content
This repository was archived by the owner on Oct 16, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions apps/cli/src/commands/results/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
*/

import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { command, flag, number, option, optional, positional, string } from 'cmd-ts';
Expand Down Expand Up @@ -1282,6 +1283,120 @@ function handleFeedbackRead(c: C, { searchDir }: DataContext) {
return c.json(readFeedback(existsSync(resultsDir) ? resultsDir : searchDir));
}

function expandHomePath(inputPath: string): string {
if (inputPath === '~') return homedir();
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
return path.join(homedir(), inputPath.slice(2));
}
return inputPath;
}

function resolveBrowsePath(inputPath: string | undefined, cwd: string): string {
const trimmed = inputPath?.trim() ?? '';
const expanded = trimmed.length > 0 ? expandHomePath(trimmed) : cwd;
return path.resolve(cwd, expanded);
}

function hasAgentvDir(dirPath: string): boolean {
try {
return statSync(path.join(dirPath, '.agentv')).isDirectory();
} catch {
return false;
}
}

interface DirectoryBrowseEntry {
name: string;
path: string;
hasAgentv: boolean;
}

interface DirectoryBrowseResult {
path: string;
parentPath?: string;
current: DirectoryBrowseEntry;
entries: DirectoryBrowseEntry[];
}

function directoryBrowseEntry(dirPath: string): DirectoryBrowseEntry {
return {
name: path.basename(dirPath) || dirPath,
path: dirPath,
hasAgentv: hasAgentvDir(dirPath),
};
}

function browseFilesystemDirectories(
inputPath: string | undefined,
cwd: string,
): DirectoryBrowseResult {
const browsePath = resolveBrowsePath(inputPath, cwd);

if (!existsSync(browsePath)) {
throw new Error(`Directory not found: ${browsePath}`);
}

let stats: ReturnType<typeof statSync>;
try {
stats = statSync(browsePath);
} catch (err) {
throw new Error(`Unable to read directory: ${(err as Error).message}`);
}

if (!stats.isDirectory()) {
throw new Error(`Not a directory: ${browsePath}`);
}

let entries: DirectoryBrowseEntry[];
try {
entries = readdirSync(browsePath, { withFileTypes: true })
.map((entry) => {
const entryPath = path.join(browsePath, entry.name);
if (entry.isDirectory()) return directoryBrowseEntry(entryPath);
if (entry.isSymbolicLink()) {
try {
return statSync(entryPath).isDirectory() ? directoryBrowseEntry(entryPath) : null;
} catch {
return null;
}
}
return null;
})
.filter((entry): entry is ReturnType<typeof directoryBrowseEntry> => entry !== null)
.sort((a, b) => {
if (a.hasAgentv !== b.hasAgentv) return a.hasAgentv ? -1 : 1;
return a.name.localeCompare(b.name);
});
} catch (err) {
throw new Error(`Unable to read directory: ${(err as Error).message}`);
}

const parentPath = path.dirname(browsePath);
return {
path: browsePath,
parentPath: parentPath !== browsePath ? parentPath : undefined,
current: directoryBrowseEntry(browsePath),
entries,
};
}

function directoryBrowseEntryToWire(entry: DirectoryBrowseEntry) {
return {
name: entry.name,
path: entry.path,
has_agentv: entry.hasAgentv,
};
}

function directoryBrowseResultToWire(result: DirectoryBrowseResult) {
return {
path: result.path,
...(result.parentPath !== undefined && { parent_path: result.parentPath }),
current: directoryBrowseEntryToWire(result.current),
entries: result.entries.map(directoryBrowseEntryToWire),
};
}

async function handleRunTagsPut(c: C, { searchDir, projectId }: DataContext) {
const filename = c.req.param('filename') ?? '';
const meta = await findRunById(searchDir, filename, projectId);
Expand Down Expand Up @@ -1603,6 +1718,16 @@ export function createApp(
return c.json({ projects });
});

app.get('/api/filesystem/browse', (c) => {
try {
return c.json(
directoryBrowseResultToWire(browseFilesystemDirectories(c.req.query('path'), searchDir)),
);
} catch (err) {
return c.json({ error: (err as Error).message }, 400);
}
});

app.post('/api/projects', async (c) => {
if (readOnly) {
return c.json({ error: 'Dashboard is running in read-only mode' }, 403);
Expand Down
132 changes: 132 additions & 0 deletions apps/cli/test/commands/results/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,138 @@ describe('serve app', () => {
});
});

// ── GET /api/filesystem/browse ────────────────────────────────────────

describe('GET /api/filesystem/browse', () => {
it('lists child directories and marks AgentV project folders', async () => {
const projectDir = path.join(tempDir, 'project-with-agentv');
const bareDir = path.join(tempDir, 'plain-folder');
mkdirSync(path.join(projectDir, '.agentv'), { recursive: true });
mkdirSync(bareDir, { recursive: true });
writeFileSync(path.join(tempDir, 'not-a-folder.txt'), 'ignored');

const app = makeApp();
const res = await app.request(`/api/filesystem/browse?path=${encodeURIComponent(tempDir)}`);

expect(res.status).toBe(200);
const data = (await res.json()) as {
path: string;
parent_path?: string;
current: { name: string; path: string; has_agentv: boolean };
entries: Array<{ name: string; path: string; has_agentv: boolean }>;
};
expect(data.path).toBe(tempDir);
expect(data.parent_path).toBe(path.dirname(tempDir));
expect(data.current).toMatchObject({
name: path.basename(tempDir),
path: tempDir,
has_agentv: false,
});
expect(data.entries).toEqual([
{ name: 'project-with-agentv', path: projectDir, has_agentv: true },
{ name: 'plain-folder', path: bareDir, has_agentv: false },
{ name: 'studio-dist', path: studioDir, has_agentv: false },
]);
});

it('returns an understandable error for non-directory paths', async () => {
const filePath = path.join(tempDir, 'not-a-folder.txt');
writeFileSync(filePath, 'not a directory');

const app = makeApp();
const res = await app.request(`/api/filesystem/browse?path=${encodeURIComponent(filePath)}`);

expect(res.status).toBe(400);
const data = (await res.json()) as { error: string };
expect(data.error).toContain('Not a directory');
expect(data.error).toContain(filePath);
});
});

// ── POST /api/projects ────────────────────────────────────────────────

describe('POST /api/projects', () => {
it('registers a selected AgentV project directory', async () => {
const previousHome = process.env.AGENTV_HOME;
process.env.AGENTV_HOME = path.join(tempDir, 'agentv-home-register');

try {
const projectDir = path.join(tempDir, 'project-to-register');
mkdirSync(path.join(projectDir, '.agentv'), { recursive: true });

const app = makeApp();
const create = await app.request('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: projectDir }),
});

expect(create.status).toBe(201);
const created = (await create.json()) as {
id: string;
name: string;
path: string;
added_at: string;
last_opened_at: string;
};
expect(created).toMatchObject({
id: 'project-to-register',
name: 'project-to-register',
path: projectDir,
});
expect(created.added_at).toBeTruthy();
expect(created.last_opened_at).toBeTruthy();

const list = await app.request('/api/projects');
const data = (await list.json()) as { projects: Array<{ id: string; path: string }> };
expect(data.projects).toEqual([
expect.objectContaining({ id: 'project-to-register', path: projectDir }),
]);
} finally {
if (previousHome === undefined) {
process.env.AGENTV_HOME = undefined;
} else {
process.env.AGENTV_HOME = previousHome;
}
}
});
});

// ── DELETE /api/projects/:projectId ───────────────────────────────────

describe('DELETE /api/projects/:projectId', () => {
it('unregisters a project without deleting its directory', async () => {
const previousHome = process.env.AGENTV_HOME;
process.env.AGENTV_HOME = path.join(tempDir, 'agentv-home-remove');

try {
const projectDir = path.join(tempDir, 'project-to-remove');
mkdirSync(path.join(projectDir, '.agentv'), { recursive: true });
const entry = addProject(projectDir);

const app = makeApp();
const remove = await app.request(`/api/projects/${encodeURIComponent(entry.id)}`, {
method: 'DELETE',
});

expect(remove.status).toBe(200);
expect(await remove.json()).toEqual({ ok: true });
expect(existsSync(projectDir)).toBe(true);
expect(existsSync(path.join(projectDir, '.agentv'))).toBe(true);

const list = await app.request('/api/projects');
const data = (await list.json()) as { projects: Array<{ id: string }> };
expect(data.projects).toEqual([]);
} finally {
if (previousHome === undefined) {
process.env.AGENTV_HOME = undefined;
} else {
process.env.AGENTV_HOME = previousHome;
}
}
});
});

// ── GET /api/feedback ──────────────────────────────────────────────────

describe('GET /api/feedback', () => {
Expand Down
Loading
Loading