diff --git a/CHANGELOG.md b/CHANGELOG.md index b19cea2..d6b2e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # @focus-mcp/cli +## 2.4.1 + +### Patch Changes + +- 5131bb7: feat(source): implement readPackageVersion() on FilesystemBrickSource + + Adds the optional BrickSource.readPackageVersion() hook (introduced in @focus-mcp/core) so the brick loader can inject the package.json version into the manifest when mcp-brick.json doesn't carry a "version" field. Without this, every brick load throws INVALID_VERSION because the disk manifest and the JS module's exported manifest both lack a SemVer version field. + + Resolution mirrors readManifest(): prefer require.resolve('@focus-mcp/brick-/package.json'), fall back to walking up from the package main entry if /package.json isn't in the exports field, and return undefined when no version can be found (loader keeps its existing INVALID_VERSION behaviour as a last resort). + ## 2.4.0 ### Minor Changes diff --git a/package.json b/package.json index 8dedf65..ab25d1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@focus-mcp/cli", - "version": "2.4.0", + "version": "2.4.1", "private": false, "description": "Focus your AI agents on what matters. 68+ bricks, 1 MCP server, modular context — from 200k to 2k tokens. Works with Claude Code, Cursor, Codex.", "license": "MIT", diff --git a/src/source/filesystem-source.test.ts b/src/source/filesystem-source.test.ts index 25fcb1b..535efdc 100644 --- a/src/source/filesystem-source.test.ts +++ b/src/source/filesystem-source.test.ts @@ -369,4 +369,125 @@ describe('FilesystemBrickSource', () => { const result = await source.readManifest('x'); expect(result).toEqual(manifest); }); + + // ---------- readPackageVersion ---------- + + it('readPackageVersion() returns the version from package.json (direct resolve)', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + mockResolve.mockReturnValue('/fake/bricks/brick-a/package.json'); + mockReadFile.mockResolvedValue( + JSON.stringify({ name: '@focus-mcp/brick-a', version: '1.2.3' }), + ); + + const source = new FilesystemBrickSource({ + centerJson: { bricks: {} }, + bricksDir: '/fake/bricks', + }); + + const result = await source.readPackageVersion('brick-a'); + + expect(mockResolve).toHaveBeenCalledWith('@focus-mcp/brick-brick-a/package.json'); + expect(result).toBe('1.2.3'); + }); + + it('readPackageVersion() falls back to walking up from main when /package.json is not exported', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + // 1st call (/package.json): throws ERR_PACKAGE_PATH_NOT_EXPORTED. + // 2nd call (the package main): returns dist entry path. + const notExported = Object.assign(new Error('not exported'), { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }); + mockResolve + .mockImplementationOnce(() => { + throw notExported; + }) + .mockReturnValueOnce('/fake/bricks/brick-a/dist/index.js'); + mockReadFile.mockResolvedValue(JSON.stringify({ version: '2.0.0' })); + + const source = new FilesystemBrickSource({ + centerJson: { bricks: {} }, + bricksDir: '/fake/bricks', + }); + + const result = await source.readPackageVersion('brick-a'); + + expect(result).toBe('2.0.0'); + }); + + it('readPackageVersion() returns undefined when package.json has no version field', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + mockResolve.mockReturnValue('/fake/bricks/brick-a/package.json'); + mockReadFile.mockResolvedValue(JSON.stringify({ name: '@focus-mcp/brick-a' })); + + const source = new FilesystemBrickSource({ + centerJson: { bricks: {} }, + bricksDir: '/fake/bricks', + }); + + const result = await source.readPackageVersion('brick-a'); + expect(result).toBeUndefined(); + }); + + it('readPackageVersion() returns undefined when neither direct resolve nor walk-up find a package.json', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + const notFound = Object.assign(new Error('module not found'), { code: 'MODULE_NOT_FOUND' }); + // 1st call throws, 2nd call returns a path so we enter the walk-up loop. + mockResolve + .mockImplementationOnce(() => { + throw notFound; + }) + .mockReturnValueOnce('/fake/bricks/x/y/z/index.js'); + // Every readFile during walk-up throws (no package.json found). + mockReadFile.mockRejectedValue(new Error('ENOENT')); + + const source = new FilesystemBrickSource({ + centerJson: { bricks: {} }, + bricksDir: '/fake/bricks', + }); + + const result = await source.readPackageVersion('brick-a'); + expect(result).toBeUndefined(); + }); + + it('readPackageVersion() returns undefined when the package itself is not installed', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + // Both the /package.json resolve AND the main-entry resolve throw + // MODULE_NOT_FOUND — the package is genuinely absent. JSDoc says + // we should return undefined rather than propagating. + const notFound = Object.assign(new Error('module not found'), { code: 'MODULE_NOT_FOUND' }); + mockResolve.mockImplementation(() => { + throw notFound; + }); + + const source = new FilesystemBrickSource({ + centerJson: { bricks: {} }, + bricksDir: '/fake/bricks', + }); + + const result = await source.readPackageVersion('brick-a'); + expect(result).toBeUndefined(); + }); + + it('readPackageVersion() throws if resolved path escapes bricksDir (symlink attack)', async () => { + const { FilesystemBrickSource } = await import('./filesystem-source.ts'); + + // Resolver returns a path outside bricksDir — assertWithinBricksDir + // must throw to prevent symlink/alias escapes (same protection as + // readManifest / loadModule). + mockResolve.mockReturnValue('/etc/passwd/package.json'); + mockRealpathSync.mockImplementation((p: string) => p); + mockReadFile.mockResolvedValue(JSON.stringify({ version: '1.0.0' })); + + const source = new FilesystemBrickSource({ + centerJson: { bricks: {} }, + bricksDir: '/fake/bricks', + }); + + await expect(source.readPackageVersion('brick-a')).rejects.toThrow(/escapes bricksDir/); + }); }); diff --git a/src/source/filesystem-source.ts b/src/source/filesystem-source.ts index 03d612e..a5fbc79 100644 --- a/src/source/filesystem-source.ts +++ b/src/source/filesystem-source.ts @@ -113,4 +113,85 @@ export class FilesystemBrickSource implements BrickSource { const cacheBuster = `?t=${Date.now()}`; return import(`${pathToFileURL(entry).href}${cacheBuster}`); } + + /** + * Read the brick's package.json `version` field so the core loader can + * inject it into the manifest when `mcp-brick.json` itself doesn't carry + * a `version` (the current shape of all `@focus-mcp/brick-*` manifests). + * + * Returns `undefined` if the package.json can't be found or is missing + * a string `version` field — the loader is free to fall back to its + * existing INVALID_VERSION behaviour in that case. + */ + async readPackageVersion(name: string): Promise { + const brickName = safeBrickName(name); + const require = createRequire(pathToFileURL(`${this.#bricksDir}/`).href); + const pkgPath = await resolvePackageJsonPath(require, brickName); + if (pkgPath === undefined) return undefined; + assertWithinBricksDir(pkgPath, this.#bricksDir); + const raw = await readFile(pkgPath, 'utf-8'); + return extractVersion(raw); + } +} + +/** + * Resolve the path to `@focus-mcp/brick-/package.json`, with a fallback + * walk-up from the package main entry. Returns `undefined` only when the + * package is genuinely absent or has no package.json reachable from its main. + */ +async function resolvePackageJsonPath( + require: NodeJS.Require, + brickName: string, +): Promise { + try { + // Preferred: `./package.json` is unconditionally resolvable since + // Node 14, even when an `exports` field restricts other paths. + return require.resolve(`@focus-mcp/brick-${brickName}/package.json`); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED' && code !== 'MODULE_NOT_FOUND') { + throw err; + } + } + // Fallback: walk up from the package main entry. + const pkgMain = resolvePackageMain(require, brickName); + if (pkgMain === undefined) return undefined; + return await walkUpForPackageJson(dirname(pkgMain)); +} + +function resolvePackageMain(require: NodeJS.Require, brickName: string): string | undefined { + try { + return require.resolve(`@focus-mcp/brick-${brickName}`); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND') return undefined; + throw err; + } +} + +async function walkUpForPackageJson(startDir: string): Promise { + let dir = startDir; + while (true) { + const candidate = join(dir, 'package.json'); + try { + await readFile(candidate, 'utf-8'); + return candidate; + } catch { + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } + } +} + +function extractVersion(raw: string): string | undefined { + const parsed: unknown = JSON.parse(raw); + if ( + parsed !== null && + typeof parsed === 'object' && + 'version' in parsed && + typeof (parsed as { version: unknown }).version === 'string' + ) { + return (parsed as { version: string }).version; + } + return undefined; }