Skip to content
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
2 changes: 1 addition & 1 deletion .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ inputs:
core-ref:
description: Git ref (branch, tag, SHA) of focus-mcp/core to check out.
required: false
default: main
default: develop

runs:
using: composite
Expand Down
96 changes: 96 additions & 0 deletions src/adapters/catalog-store-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// SPDX-FileCopyrightText: 2026 FocusMCP contributors
// SPDX-License-Identifier: MIT

import { homedir } from 'node:os';
import { join } from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
}));

import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { FilesystemCatalogStoreAdapter } from './catalog-store-adapter.ts';

const FOCUS_DIR = join(homedir(), '.focus');
const CATALOGS_PATH = join(FOCUS_DIR, 'catalogs.json');

describe('FilesystemCatalogStoreAdapter', () => {
let adapter: FilesystemCatalogStoreAdapter;

beforeEach(() => {
adapter = new FilesystemCatalogStoreAdapter();
vi.clearAllMocks();
});

describe('readStore()', () => {
it('returns { sources: [] } when file does not exist (ENOENT)', async () => {
const err = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
vi.mocked(readFile).mockRejectedValue(err);

const result = await adapter.readStore();

expect(result).toEqual({ sources: [] });
});

it('parses and returns valid JSON content', async () => {
const data = {
sources: [
{
url: 'https://example.com/catalog.json',
name: 'test',
enabled: true,
addedAt: '2026-01-01T00:00:00Z',
},
],
};
vi.mocked(readFile).mockResolvedValue(JSON.stringify(data));

const result = await adapter.readStore();

expect(result).toEqual(data);
expect(readFile).toHaveBeenCalledWith(CATALOGS_PATH, 'utf-8');
});

it('re-throws errors that are not ENOENT', async () => {
const err = Object.assign(new Error('Permission denied'), { code: 'EACCES' });
vi.mocked(readFile).mockRejectedValue(err);

await expect(adapter.readStore()).rejects.toThrow('Permission denied');
});

it('re-throws non-Error exceptions', async () => {
vi.mocked(readFile).mockRejectedValue('unexpected string error');

await expect(adapter.readStore()).rejects.toBe('unexpected string error');
});
});

describe('writeStore()', () => {
it('creates the directory and writes the file', async () => {
vi.mocked(mkdir).mockResolvedValue(undefined);
vi.mocked(writeFile).mockResolvedValue(undefined);

const data = {
sources: [
{
url: 'https://example.com/catalog.json',
name: 'test',
enabled: true,
addedAt: '2026-01-01T00:00:00Z',
},
],
};
await adapter.writeStore(data);

expect(mkdir).toHaveBeenCalledWith(FOCUS_DIR, { recursive: true });
expect(writeFile).toHaveBeenCalledWith(
CATALOGS_PATH,
JSON.stringify(data, null, 4),
'utf-8',
);
});
});
});
43 changes: 43 additions & 0 deletions src/adapters/catalog-store-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2026 FocusMCP contributors
// SPDX-License-Identifier: MIT

/**
* Filesystem implementation of CatalogStoreIO.
*
* Reads and writes the catalog source registry at ~/.focus/catalogs.json.
* Conforms to the CatalogStoreIO interface expected by @focusmcp/core
* marketplace/catalog-store pure functions.
*/

import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { CatalogStoreData, CatalogStoreIO } from '@focusmcp/core';

export type { CatalogStoreData, CatalogStoreIO };

const FOCUS_DIR = join(homedir(), '.focus');
const CATALOGS_PATH = join(FOCUS_DIR, 'catalogs.json');

export class FilesystemCatalogStoreAdapter implements CatalogStoreIO {
async readStore(): Promise<unknown> {
try {
const raw = await readFile(CATALOGS_PATH, 'utf-8');
return JSON.parse(raw) as unknown;
} catch (err: unknown) {
const isNotFound =
err instanceof Error &&
'code' in err &&
(err as { code: string }).code === 'ENOENT';
if (isNotFound) {
return { sources: [] };
}
throw err;
}
}

async writeStore(data: CatalogStoreData): Promise<void> {
await mkdir(FOCUS_DIR, { recursive: true });
await writeFile(CATALOGS_PATH, JSON.stringify(data, null, 4), 'utf-8');
}
}
68 changes: 68 additions & 0 deletions src/adapters/http-fetch-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2026 FocusMCP contributors
// SPDX-License-Identifier: MIT

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HttpFetchAdapter } from './http-fetch-adapter.ts';

describe('HttpFetchAdapter', () => {
let adapter: HttpFetchAdapter;

beforeEach(() => {
adapter = new HttpFetchAdapter();
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe('fetchJson()', () => {
it('returns parsed JSON on a successful response', async () => {
const payload = { bricks: ['official/echo'] };
const mockResponse = {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue(payload),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse));

const result = await adapter.fetchJson('https://example.com/catalog.json');

expect(result).toEqual(payload);
expect(fetch).toHaveBeenCalledWith('https://example.com/catalog.json');
});

it('throws an error when the response is not ok (404)', async () => {
const mockResponse = {
ok: false,
status: 404,
json: vi.fn(),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse));

await expect(adapter.fetchJson('https://example.com/missing.json')).rejects.toThrow(
'HTTP 404 fetching https://example.com/missing.json',
);
});

it('throws an error when the response is not ok (500)', async () => {
const mockResponse = {
ok: false,
status: 500,
json: vi.fn(),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse));

await expect(adapter.fetchJson('https://example.com/catalog.json')).rejects.toThrow(
'HTTP 500 fetching https://example.com/catalog.json',
);
});

it('propagates network errors', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));

await expect(adapter.fetchJson('https://example.com/catalog.json')).rejects.toThrow(
'Network error',
);
});
});
});
23 changes: 23 additions & 0 deletions src/adapters/http-fetch-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2026 FocusMCP contributors
// SPDX-License-Identifier: MIT

/**
* Node.js (≥ 22) implementation of FetchIO using the global fetch API.
*
* Conforms to the FetchIO interface expected by @focusmcp/core
* marketplace/catalog-fetcher pure functions.
*/

export interface FetchIO {
fetchJson(url: string): Promise<unknown>;
}

export class HttpFetchAdapter implements FetchIO {
async fetchJson(url: string): Promise<unknown> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status.toString()} fetching ${url}`);
}
return response.json() as Promise<unknown>;
}
}
Loading
Loading