diff --git a/src/commands/list/docs.ts b/src/commands/list/docs.ts index 15e4599..bb4e747 100644 --- a/src/commands/list/docs.ts +++ b/src/commands/list/docs.ts @@ -2,70 +2,79 @@ import { Command, Flags } from '@oclif/core' import { daemonListDocs } from '../../daemon/daemon-list-docs.js' +import type { Doc } from '../../daemon/types.js' import { projectFlag } from '../../flags/project-flag.js' -import { - ensureInitialized, - NotInitializedError, -} from '../../utils/ensure-initialized.js' +import { ensureInitialized, NotInitializedError } from '../../utils/ensure-initialized.js' +import { groupByProject } from '../../utils/group-by-project.js' +import { listAcrossProjects } from '../../utils/list-across-projects.js' import { resolveProjectPath } from '../../utils/resolve-project-path.js' -/** - * List all documentation files - */ // eslint-disable-next-line custom/no-default-class-export, class-export/class-export export default class ListDocs extends Command { // eslint-disable-next-line no-restricted-syntax static override description = 'List all documentation files' - // eslint-disable-next-line no-restricted-syntax static override aliases = ['doc:list'] - // eslint-disable-next-line no-restricted-syntax static override examples = [ '<%= config.bin %> list docs', - '<%= config.bin %> list docs --json', - '<%= config.bin %> list docs --project centy-daemon', + '<%= config.bin %> list docs --all', + '<%= config.bin %> list docs -a --json', ] - // eslint-disable-next-line no-restricted-syntax static override flags = { - json: Flags.boolean({ - description: 'Output as JSON', - default: false, - }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), + all: Flags.boolean({ char: 'a', description: 'List from all projects', default: false }), project: projectFlag, } public async run(): Promise { const { flags } = await this.parse(ListDocs) + if (flags.all) return this.listAll(flags) const cwd = await resolveProjectPath(flags.project) - try { await ensureInitialized(cwd) } catch (error) { - if (error instanceof NotInitializedError) { - this.error(error.message) - } + if (error instanceof NotInitializedError) this.error(error.message) throw error instanceof Error ? error : new Error(String(error)) } + const response = await daemonListDocs({ projectPath: cwd }) + if (flags.json) return void this.log(JSON.stringify(response.docs, null, 2)) + if (response.docs.length === 0) return void this.log('No docs found.') + this.log(`Found ${response.totalCount} doc(s):\n`) + for (const doc of response.docs) this.log(`${doc.slug}: ${doc.title}`) + } - const response = await daemonListDocs({ - projectPath: cwd, + private async listAll(flags: { json: boolean }): Promise { + const result = await listAcrossProjects({ + async listFn(projectPath) { + const r = await daemonListDocs({ projectPath }) + return r.docs + }, }) - if (flags.json) { - this.log(JSON.stringify(response.docs, null, 2)) - return + const docs = result.items.map(i => ({ + doc: i.entity, projectName: i.projectName, projectPath: i.projectPath, + })) + return void this.log(JSON.stringify({ docs, totalCount: docs.length, errors: result.errors }, null, 2)) } - - if (response.docs.length === 0) { - this.log('No docs found.') - return + if (result.items.length === 0) { + this.log('No docs found across all projects.') + return void this.printErrors(result.errors) + } + this.log(`Found ${result.items.length} doc(s) across all projects:\n`) + for (const [name, items] of groupByProject(result.items)) { + this.log(`--- ${name} (${items.length} doc(s)) ---`) + for (const i of items) this.log(` ${i.entity.slug}: ${i.entity.title}`) + this.log('') } + this.printErrors(result.errors) + } - this.log(`Found ${response.totalCount} doc(s):\n`) - for (const doc of response.docs) { - this.log(`${doc.slug}: ${doc.title}`) + private printErrors(errors: string[]): void { + if (errors.length > 0) { + this.warn('Some projects could not be searched:') + for (const err of errors) this.warn(` - ${err}`) } } } diff --git a/src/commands/list/issues.ts b/src/commands/list/issues.ts index c63d0cc..bcdb0bd 100644 --- a/src/commands/list/issues.ts +++ b/src/commands/list/issues.ts @@ -2,101 +2,98 @@ import { Command, Flags } from '@oclif/core' import { daemonListIssues } from '../../daemon/daemon-list-issues.js' +import type { Issue } from '../../daemon/types.js' import { projectFlag } from '../../flags/project-flag.js' -import { - ensureInitialized, - NotInitializedError, -} from '../../utils/ensure-initialized.js' +import { ensureInitialized, NotInitializedError } from '../../utils/ensure-initialized.js' +import { groupByProject } from '../../utils/group-by-project.js' +import { listAcrossProjects } from '../../utils/list-across-projects.js' import { resolveProjectPath } from '../../utils/resolve-project-path.js' -/** - * List all issues in the .centy/issues folder - */ +const formatIssue = (issue: Issue): string => { + const meta = issue.metadata + const priority = meta !== undefined + ? (meta.priorityLabel !== '' ? meta.priorityLabel : `P${meta.priority}`) + : 'P?' + const status = meta !== undefined ? meta.status : 'unknown' + const draft = meta !== undefined && meta.draft === true ? ' [DRAFT]' : '' + return `#${issue.displayNumber} [${priority}] [${status}]${draft} ${issue.title}` +} + // eslint-disable-next-line custom/no-default-class-export, class-export/class-export export default class ListIssues extends Command { // eslint-disable-next-line no-restricted-syntax static override description = 'List all issues' - // eslint-disable-next-line no-restricted-syntax static override aliases = ['issue:list'] - // eslint-disable-next-line no-restricted-syntax static override examples = [ '<%= config.bin %> list issues', - '<%= config.bin %> list issues --status open', - '<%= config.bin %> list issues --priority 1', - '<%= config.bin %> list issues --project centy-daemon', - '<%= config.bin %> list issues --draft', - '<%= config.bin %> list issues --no-draft', + '<%= config.bin %> list issues --all', + '<%= config.bin %> list issues -a --status open', ] - // eslint-disable-next-line no-restricted-syntax static override flags = { - status: Flags.string({ - char: 's', - description: 'Filter by status (e.g., open, in-progress, closed)', - }), - priority: Flags.integer({ - char: 'p', - description: 'Filter by priority level (1 = highest)', - }), - json: Flags.boolean({ - description: 'Output as JSON', - default: false, - }), - draft: Flags.boolean({ - description: - 'Filter by draft status (--draft for drafts only, --no-draft for non-drafts)', - allowNo: true, - }), + status: Flags.string({ char: 's', description: 'Filter by status' }), + priority: Flags.integer({ char: 'p', description: 'Filter by priority' }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), + draft: Flags.boolean({ description: 'Filter by draft status', allowNo: true }), + all: Flags.boolean({ char: 'a', description: 'List from all projects', default: false }), project: projectFlag, } public async run(): Promise { const { flags } = await this.parse(ListIssues) + if (flags.all) return this.listAll(flags) const cwd = await resolveProjectPath(flags.project) - try { await ensureInitialized(cwd) } catch (error) { - if (error instanceof NotInitializedError) { - this.error(error.message) - } + if (error instanceof NotInitializedError) this.error(error.message) throw error instanceof Error ? error : new Error(String(error)) } - const response = await daemonListIssues({ - projectPath: cwd, - status: flags.status, - priority: flags.priority, - draft: flags.draft, + projectPath: cwd, status: flags.status, priority: flags.priority, draft: flags.draft, }) + if (flags.json) return void this.log(JSON.stringify(response.issues, null, 2)) + if (response.issues.length === 0) return void this.log('No issues found.') + this.log(`Found ${response.totalCount} issue(s):\n`) + for (const issue of response.issues) this.log(formatIssue(issue)) + } + private async listAll(flags: { + status?: string; priority?: number; draft?: boolean; json: boolean + }): Promise { + const result = await listAcrossProjects({ + async listFn(projectPath) { + const r = await daemonListIssues({ + projectPath, status: flags.status, priority: flags.priority, draft: flags.draft, + }) + return r.issues + }, + }) if (flags.json) { - this.log(JSON.stringify(response.issues, null, 2)) - return + const issues = result.items.map(i => ({ + issue: i.entity, projectName: i.projectName, projectPath: i.projectPath, + })) + return void this.log(JSON.stringify({ issues, totalCount: issues.length, errors: result.errors }, null, 2)) } - - if (response.issues.length === 0) { - this.log('No issues found.') - return + if (result.items.length === 0) { + this.log('No issues found across all projects.') + return void this.printErrors(result.errors) } + this.log(`Found ${result.items.length} issue(s) across all projects:\n`) + for (const [name, items] of groupByProject(result.items)) { + this.log(`--- ${name} (${items.length} issue(s)) ---`) + for (const i of items) this.log(` ${formatIssue(i.entity)}`) + this.log('') + } + this.printErrors(result.errors) + } - this.log(`Found ${response.totalCount} issue(s):\n`) - for (const issue of response.issues) { - const meta = issue.metadata - const priority = - meta !== undefined - ? meta.priorityLabel !== '' - ? meta.priorityLabel - : `P${meta.priority}` - : 'P?' - const status = meta !== undefined ? meta.status : 'unknown' - const draftIndicator = - meta !== undefined && meta.draft === true ? ' [DRAFT]' : '' - this.log( - `#${issue.displayNumber} [${priority}] [${status}]${draftIndicator} ${issue.title}` - ) + private printErrors(errors: string[]): void { + if (errors.length > 0) { + this.warn('Some projects could not be searched:') + for (const err of errors) this.warn(` - ${err}`) } } } diff --git a/src/commands/list/prs.ts b/src/commands/list/prs.ts index 6bcf31b..ea48cbe 100644 --- a/src/commands/list/prs.ts +++ b/src/commands/list/prs.ts @@ -2,103 +2,96 @@ import { Command, Flags } from '@oclif/core' import { daemonListPrs } from '../../daemon/daemon-list-prs.js' +import type { PullRequest } from '../../daemon/types.js' import { projectFlag } from '../../flags/project-flag.js' -import { - ensureInitialized, - NotInitializedError, -} from '../../utils/ensure-initialized.js' +import { ensureInitialized, NotInitializedError } from '../../utils/ensure-initialized.js' +import { groupByProject } from '../../utils/group-by-project.js' +import { listAcrossProjects } from '../../utils/list-across-projects.js' import { resolveProjectPath } from '../../utils/resolve-project-path.js' -/** - * List all pull requests in the .centy/prs folder - */ +const formatPr = (pr: PullRequest): string => { + const meta = pr.metadata + const priority = meta !== undefined + ? (meta.priorityLabel !== '' ? meta.priorityLabel : `P${meta.priority}`) : 'P?' + const status = meta !== undefined ? meta.status : 'unknown' + const branches = meta !== undefined ? `${meta.sourceBranch} -> ${meta.targetBranch}` : '? -> ?' + return `#${pr.displayNumber} [${priority}] [${status}] ${pr.title}\n ${branches}` +} + // eslint-disable-next-line custom/no-default-class-export, class-export/class-export export default class ListPrs extends Command { // eslint-disable-next-line no-restricted-syntax static override description = 'List all pull requests' - // eslint-disable-next-line no-restricted-syntax static override aliases = ['pr:list'] - // eslint-disable-next-line no-restricted-syntax - static override examples = [ - '<%= config.bin %> list prs', - '<%= config.bin %> list prs --status open', - '<%= config.bin %> list prs --source feature-branch', - '<%= config.bin %> list prs --target main', - '<%= config.bin %> list prs --project centy-daemon', - ] - + static override examples = ['<%= config.bin %> list prs', '<%= config.bin %> list prs --all'] // eslint-disable-next-line no-restricted-syntax static override flags = { - status: Flags.string({ - char: 's', - description: 'Filter by status (draft, open, merged, closed)', - }), - source: Flags.string({ - description: 'Filter by source branch', - }), - target: Flags.string({ - description: 'Filter by target branch', - }), - priority: Flags.integer({ - char: 'p', - description: 'Filter by priority level (1 = highest)', - }), - json: Flags.boolean({ - description: 'Output as JSON', - default: false, - }), + status: Flags.string({ char: 's', description: 'Filter by status' }), + source: Flags.string({ description: 'Filter by source branch' }), + target: Flags.string({ description: 'Filter by target branch' }), + priority: Flags.integer({ char: 'p', description: 'Filter by priority' }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), + all: Flags.boolean({ char: 'a', description: 'List from all projects', default: false }), project: projectFlag, } public async run(): Promise { const { flags } = await this.parse(ListPrs) + if (flags.all) return this.listAll(flags) const cwd = await resolveProjectPath(flags.project) - try { await ensureInitialized(cwd) } catch (error) { - if (error instanceof NotInitializedError) { - this.error(error.message) - } + if (error instanceof NotInitializedError) this.error(error.message) throw error instanceof Error ? error : new Error(String(error)) } - const response = await daemonListPrs({ - projectPath: cwd, - status: flags.status, - sourceBranch: flags.source, - targetBranch: flags.target, - priority: flags.priority, + projectPath: cwd, status: flags.status, sourceBranch: flags.source, + targetBranch: flags.target, priority: flags.priority, }) + if (flags.json) return void this.log(JSON.stringify(response.prs, null, 2)) + if (response.prs.length === 0) return void this.log('No pull requests found.') + this.log(`Found ${response.totalCount} PR(s):\n`) + for (const pr of response.prs) this.log(formatPr(pr)) + } + private async listAll(flags: { + status?: string; source?: string; target?: string; priority?: number; json: boolean + }): Promise { + const result = await listAcrossProjects({ + async listFn(projectPath) { + const r = await daemonListPrs({ + projectPath, status: flags.status, sourceBranch: flags.source, + targetBranch: flags.target, priority: flags.priority, + }) + return r.prs + }, + }) if (flags.json) { - this.log(JSON.stringify(response.prs, null, 2)) - return + const prs = result.items.map(i => ({ + pr: i.entity, projectName: i.projectName, projectPath: i.projectPath, + })) + return void this.log(JSON.stringify({ prs, totalCount: prs.length, errors: result.errors }, null, 2)) } - - if (response.prs.length === 0) { - this.log('No pull requests found.') - return + if (result.items.length === 0) { + this.log('No PRs found across all projects.') + return void this.printErrors(result.errors) } + this.log(`Found ${result.items.length} PR(s) across all projects:\n`) + for (const [name, items] of groupByProject(result.items)) { + this.log(`--- ${name} (${items.length} PR(s)) ---`) + for (const i of items) this.log(` ${formatPr(i.entity)}`) + this.log('') + } + this.printErrors(result.errors) + } - this.log(`Found ${response.totalCount} PR(s):\n`) - for (const pr of response.prs) { - const meta = pr.metadata - const priority = - meta !== undefined - ? meta.priorityLabel !== '' - ? meta.priorityLabel - : `P${meta.priority}` - : 'P?' - const status = meta !== undefined ? meta.status : 'unknown' - const branches = - meta !== undefined - ? `${meta.sourceBranch} -> ${meta.targetBranch}` - : '? -> ?' - this.log(`#${pr.displayNumber} [${priority}] [${status}] ${pr.title}`) - this.log(` ${branches}`) + private printErrors(errors: string[]): void { + if (errors.length > 0) { + this.warn('Some projects could not be searched:') + for (const err of errors) this.warn(` - ${err}`) } } } diff --git a/src/commands/list/users.ts b/src/commands/list/users.ts index 4debe9a..d7d2b55 100644 --- a/src/commands/list/users.ts +++ b/src/commands/list/users.ts @@ -2,82 +2,82 @@ import { Command, Flags } from '@oclif/core' import { daemonListUsers } from '../../daemon/daemon-list-users.js' +import type { User } from '../../daemon/types.js' import { projectFlag } from '../../flags/project-flag.js' -import { - ensureInitialized, - NotInitializedError, -} from '../../utils/ensure-initialized.js' +import { ensureInitialized, NotInitializedError } from '../../utils/ensure-initialized.js' +import { groupByProject } from '../../utils/group-by-project.js' +import { listAcrossProjects } from '../../utils/list-across-projects.js' import { resolveProjectPath } from '../../utils/resolve-project-path.js' -/** - * List all users in the project - */ +const formatUser = (user: User): string => { + const gitNames = user.gitUsernames.length > 0 ? ` (git: ${user.gitUsernames.join(', ')})` : '' + const email = user.email !== undefined && user.email !== '' ? ` <${user.email}>` : '' + return `${user.id}: ${user.name}${email}${gitNames}` +} + // eslint-disable-next-line custom/no-default-class-export, class-export/class-export export default class ListUsers extends Command { // eslint-disable-next-line no-restricted-syntax static override description = 'List all users in the project' - // eslint-disable-next-line no-restricted-syntax static override aliases = ['user:list'] - // eslint-disable-next-line no-restricted-syntax - static override examples = [ - '<%= config.bin %> list users', - '<%= config.bin %> list users --json', - '<%= config.bin %> list users --git-username johndoe', - '<%= config.bin %> list users --project centy-daemon', - ] - + static override examples = ['<%= config.bin %> list users', '<%= config.bin %> list users --all'] // eslint-disable-next-line no-restricted-syntax static override flags = { - 'git-username': Flags.string({ - char: 'g', - description: 'Filter by git username', - }), - json: Flags.boolean({ - description: 'Output as JSON', - default: false, - }), + 'git-username': Flags.string({ char: 'g', description: 'Filter by git username' }), + json: Flags.boolean({ description: 'Output as JSON', default: false }), + all: Flags.boolean({ char: 'a', description: 'List from all projects', default: false }), project: projectFlag, } public async run(): Promise { const { flags } = await this.parse(ListUsers) + if (flags.all) return this.listAll(flags) const cwd = await resolveProjectPath(flags.project) - try { await ensureInitialized(cwd) } catch (error) { - if (error instanceof NotInitializedError) { - this.error(error.message) - } + if (error instanceof NotInitializedError) this.error(error.message) throw error instanceof Error ? error : new Error(String(error)) } + const response = await daemonListUsers({ projectPath: cwd, gitUsername: flags['git-username'] }) + if (flags.json) return void this.log(JSON.stringify(response.users, null, 2)) + if (response.users.length === 0) return void this.log('No users found.') + this.log(`Found ${response.totalCount} user(s):\n`) + for (const user of response.users) this.log(` ${formatUser(user)}`) + } - const response = await daemonListUsers({ - projectPath: cwd, - gitUsername: flags['git-username'], + private async listAll(flags: { 'git-username'?: string; json: boolean }): Promise { + const result = await listAcrossProjects({ + async listFn(projectPath) { + const r = await daemonListUsers({ projectPath, gitUsername: flags['git-username'] }) + return r.users + }, }) - if (flags.json) { - this.log(JSON.stringify(response.users, null, 2)) - return + const users = result.items.map(i => ({ + user: i.entity, projectName: i.projectName, projectPath: i.projectPath, + })) + return void this.log(JSON.stringify({ users, totalCount: users.length, errors: result.errors }, null, 2)) } - - if (response.users.length === 0) { - this.log('No users found.') - return + if (result.items.length === 0) { + this.log('No users found across all projects.') + return void this.printErrors(result.errors) } + this.log(`Found ${result.items.length} user(s) across all projects:\n`) + for (const [name, items] of groupByProject(result.items)) { + this.log(`--- ${name} (${items.length} user(s)) ---`) + for (const i of items) this.log(` ${formatUser(i.entity)}`) + this.log('') + } + this.printErrors(result.errors) + } - this.log(`Found ${response.totalCount} user(s):\n`) - for (const user of response.users) { - const gitNames = - user.gitUsernames.length > 0 - ? ` (git: ${user.gitUsernames.join(', ')})` - : '' - const email = - user.email !== undefined && user.email !== '' ? ` <${user.email}>` : '' - this.log(` ${user.id}: ${user.name}${email}${gitNames}`) + private printErrors(errors: string[]): void { + if (errors.length > 0) { + this.warn('Some projects could not be searched:') + for (const err of errors) this.warn(` - ${err}`) } } } diff --git a/src/utils/group-by-project.spec.ts b/src/utils/group-by-project.spec.ts new file mode 100644 index 0000000..f828f51 --- /dev/null +++ b/src/utils/group-by-project.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { groupByProject } from './group-by-project.js' + +describe('groupByProject', () => { + it('should group items by project name', () => { + const items = [ + { id: '1', projectName: 'project1' }, + { id: '2', projectName: 'project2' }, + { id: '3', projectName: 'project1' }, + ] + + const result = groupByProject(items) + + expect(result.size).toBe(2) + expect(result.get('project1')).toHaveLength(2) + expect(result.get('project2')).toHaveLength(1) + }) + + it('should return empty map for empty input', () => { + const result = groupByProject([]) + expect(result.size).toBe(0) + }) + + it('should handle single project', () => { + const items = [ + { id: '1', projectName: 'project1' }, + { id: '2', projectName: 'project1' }, + ] + + const result = groupByProject(items) + + expect(result.size).toBe(1) + expect(result.get('project1')).toHaveLength(2) + }) +}) diff --git a/src/utils/group-by-project.ts b/src/utils/group-by-project.ts new file mode 100644 index 0000000..3e93d97 --- /dev/null +++ b/src/utils/group-by-project.ts @@ -0,0 +1,24 @@ +/** + * Groups items by project name + */ +interface ItemWithProject { + projectName: string +} + +/** + * Groups items by their project name + */ +export function groupByProject( + items: T[] +): Map { + const grouped = new Map() + for (const item of items) { + const existing = grouped.get(item.projectName) + if (existing !== undefined) { + existing.push(item) + } else { + grouped.set(item.projectName, [item]) + } + } + return grouped +} diff --git a/src/utils/list-across-projects.spec.ts b/src/utils/list-across-projects.spec.ts new file mode 100644 index 0000000..f797bcf --- /dev/null +++ b/src/utils/list-across-projects.spec.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockDaemonListProjects = vi.fn() + +vi.mock('../daemon/daemon-list-projects.js', () => ({ + daemonListProjects: (...args: unknown[]) => mockDaemonListProjects(...args), +})) + +describe('listAcrossProjects', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should aggregate entities from all projects', async () => { + const { listAcrossProjects } = await import('./list-across-projects.js') + mockDaemonListProjects.mockResolvedValue({ + projects: [ + { path: '/project1', name: 'project1' }, + { path: '/project2', name: 'project2' }, + ], + totalCount: 2, + }) + + const listFn = vi + .fn() + .mockResolvedValueOnce([{ id: '1' }, { id: '2' }]) + .mockResolvedValueOnce([{ id: '3' }]) + + const result = await listAcrossProjects({ listFn }) + + expect(result.items).toHaveLength(3) + expect(result.errors).toHaveLength(0) + expect(result.items[0]).toEqual({ + entity: { id: '1' }, + projectName: 'project1', + projectPath: '/project1', + }) + }) + + it('should handle errors from individual projects', async () => { + const { listAcrossProjects } = await import('./list-across-projects.js') + mockDaemonListProjects.mockResolvedValue({ + projects: [ + { path: '/project1', name: 'project1' }, + { path: '/project2', name: 'project2' }, + ], + totalCount: 2, + }) + + const listFn = vi + .fn() + .mockResolvedValueOnce([{ id: '1' }]) + .mockRejectedValueOnce(new Error('Access denied')) + + const result = await listAcrossProjects({ listFn }) + + expect(result.items).toHaveLength(1) + expect(result.errors).toHaveLength(1) + expect(result.errors[0]).toContain('project2') + expect(result.errors[0]).toContain('Access denied') + }) + + it('should return empty results when no projects exist', async () => { + const { listAcrossProjects } = await import('./list-across-projects.js') + mockDaemonListProjects.mockResolvedValue({ + projects: [], + totalCount: 0, + }) + + const listFn = vi.fn() + + const result = await listAcrossProjects({ listFn }) + + expect(result.items).toHaveLength(0) + expect(result.errors).toHaveLength(0) + expect(listFn).not.toHaveBeenCalled() + }) +}) diff --git a/src/utils/list-across-projects.ts b/src/utils/list-across-projects.ts new file mode 100644 index 0000000..56efd49 --- /dev/null +++ b/src/utils/list-across-projects.ts @@ -0,0 +1,53 @@ +/** + * Utility for listing entities across all tracked projects + */ +import { daemonListProjects } from '../daemon/daemon-list-projects.js' + +interface EntityWithProject { + entity: T + projectName: string + projectPath: string +} + +interface ListAcrossProjectsResult { + items: EntityWithProject[] + errors: string[] +} + +interface ListAcrossProjectsOptions { + listFn: (projectPath: string) => Promise +} + +/** + * Lists entities across all tracked, initialized projects + */ +export async function listAcrossProjects( + options: ListAcrossProjectsOptions +): Promise> { + const projectsResponse = await daemonListProjects({ + includeUninitialized: false, + includeStale: false, + includeArchived: false, + }) + + const items: EntityWithProject[] = [] + const errors: string[] = [] + + for (const project of projectsResponse.projects) { + try { + const entities = await options.listFn(project.path) + for (const entity of entities) { + items.push({ + entity, + projectName: project.name, + projectPath: project.path, + }) + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + errors.push(`${project.name}: ${errorMsg}`) + } + } + + return { items, errors } +}