diff --git a/.github/workflows/back-merge.yml b/.github/workflows/back-merge.yml new file mode 100644 index 0000000..20ee40c --- /dev/null +++ b/.github/workflows/back-merge.yml @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: 2026 FocusMCP contributors +# SPDX-License-Identifier: MIT + +name: Back-merge main → develop + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: back-merge-${{ github.ref }} + cancel-in-progress: false + +jobs: + back-merge: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + ref: develop + token: ${{ secrets.RELEASE_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Check skip flag + id: check-skip + env: + COMMIT_MSG: ${{ github.event.head_commit.message }} + run: | + if echo "$COMMIT_MSG" | grep -qF '[skip back-merge]'; then + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + + - name: Attempt back-merge from main + id: merge + if: steps.check-skip.outputs.skip == 'false' + continue-on-error: true + run: | + set -euo pipefail + git fetch origin main + if git merge-base --is-ancestor origin/main HEAD; then + echo "merged=false" >> $GITHUB_OUTPUT + echo "Develop is already up to date with main — nothing to back-merge." + exit 0 + fi + if git merge origin/main --no-edit -m "chore(back-merge): main → develop after release [skip ci]"; then + echo "merged=true" >> $GITHUB_OUTPUT + else + echo "merged=false" >> $GITHUB_OUTPUT + echo "::warning ::Back-merge failed (conflicts). A manual PR is required." + git merge --abort || true + exit 1 + fi + + - name: Push develop + if: steps.merge.outputs.merged == 'true' + run: git push origin develop + + - name: Open PR if conflicts + if: failure() && steps.merge.outcome == 'failure' + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.RELEASE_TOKEN }} + base: develop + branch: chore/back-merge-main-${{ github.run_id }} + title: "chore: back-merge main → develop (manual conflict resolution required)" + body: | + Automatic back-merge from main failed due to conflicts. Resolve manually. + commit-message: "chore: open back-merge PR after auto-merge conflict" diff --git a/.github/workflows/dev-publish.yml b/.github/workflows/dev-publish.yml index 4d5cd45..b5fa490 100644 --- a/.github/workflows/dev-publish.yml +++ b/.github/workflows/dev-publish.yml @@ -31,21 +31,13 @@ jobs: node-version: 22 registry-url: https://registry.npmjs.org scope: '@focus-mcp' - - name: Compute dev version - id: version + - name: Apply changeset snapshot version run: | - LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || git rev-list --max-parents=0 HEAD) - DEV_NUM=$(git rev-list --count ${LAST_TAG}..HEAD) - BASE_VERSION=$(node -e "const p=require('./package.json'); console.log(p.version || '0.1.0')") - DEV_VERSION="${BASE_VERSION}-dev.${DEV_NUM}" - echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT" - echo "Dev version: ${DEV_VERSION}" - - name: Set dev version - run: npm version "${DEV_VERSION}" --no-git-tag-version - env: - DEV_VERSION: ${{ steps.version.outputs.version }} + # Produces versions like 1.9.0-dev-20260428-abc1234 from pending changesets. + # Falls back gracefully when no changesets are pending (no-op). + pnpm changeset version --snapshot dev || true - run: pnpm build - name: Publish with dev tag - run: npm publish --tag dev --access public + run: pnpm changeset publish --no-git-tag --tag dev env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ca64224..a221e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @focus-mcp/cli +## 1.8.1 + +### Patch Changes + +- 9d82945: feat(cli): FOCUS_BENCH_MODE env var skips meta tools (focus_list, focus_install, etc.) for benchmark isolation. Default behavior unchanged. + ## 1.8.0 ### Minor Changes diff --git a/README.md b/README.md index 68e6f5d..ebe6edd 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: MIT # @focus-mcp/cli -> Focus your AI agents on what matters. Reduce context from 200k to ~2k tokens. +> Focus your AI agents on what matters. **Measured savings: 65.9% on output tokens** across 29 bricks ([details](https://github.com/focus-mcp/marketplace/blob/main/benchmarks/equivalence-report.md)). [![npm](https://img.shields.io/npm/v/@focus-mcp/cli)](https://www.npmjs.com/package/@focus-mcp/cli) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) diff --git a/package.json b/package.json index 859d599..4259d96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@focus-mcp/cli", - "version": "1.8.0", + "version": "1.8.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", @@ -80,7 +80,7 @@ "@commitlint/cli": "^19.6.0", "@commitlint/config-conventional": "^19.6.0", "@commitlint/types": "^19.8.1", - "@focus-mcp/core": "file:../core/packages/core", + "@focus-mcp/core": "^1.2.0", "@types/node": "^22.10.0", "@types/react": "^18.3.0", "@vitest/coverage-v8": "^3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa04d70..adf0e64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,8 +34,8 @@ importers: specifier: ^19.8.1 version: 19.8.1 '@focus-mcp/core': - specifier: file:../core/packages/core - version: file:../core/packages/core + specifier: ^1.2.0 + version: 1.2.0 '@types/node': specifier: ^22.10.0 version: 22.19.17 @@ -440,8 +440,8 @@ packages: cpu: [x64] os: [win32] - '@focus-mcp/core@file:../core/packages/core': - resolution: {directory: ../core/packages/core, type: directory} + '@focus-mcp/core@1.2.0': + resolution: {integrity: sha512-Qt6jOfXYg41ryEbXNStBeQ3bKPWFZgOxC38l/pbriXpC32vx6xCU2lxOy9Gk/HHHv1vqu2txME/UZES8PT/mYA==} '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} @@ -2687,7 +2687,7 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@focus-mcp/core@file:../core/packages/core': + '@focus-mcp/core@1.2.0': dependencies: '@opentelemetry/api': 1.9.1 diff --git a/src/bin/focus.ts b/src/bin/focus.ts index b5533ab..a1c4b0f 100644 --- a/src/bin/focus.ts +++ b/src/bin/focus.ts @@ -348,6 +348,7 @@ async function main(argv: string[]): Promise { case 'reinstall': return runReinstall(rest); case 'upgrade': + case 'update': return runUpgrade(rest); case 'search': return runSearch(rest); diff --git a/src/commands/add.ts b/src/commands/add.ts index 80373b3..c1e7831 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -284,7 +284,7 @@ export async function addManyCommand({ if (!force) { const ver = centerJson.bricks[brickName]?.version ?? 'unknown'; messages.push( - `Brick "${brickName}" is already installed (version ${ver}). Use \`focus update\` to upgrade.`, + `Brick "${brickName}" is already installed (version ${ver}). Use \`focus upgrade\` (or \`focus update\`) to upgrade.`, ); continue; } diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts index c43d045..615004a 100644 --- a/src/commands/start.test.ts +++ b/src/commands/start.test.ts @@ -30,6 +30,7 @@ const { mockAddCommand, mockRemoveCommand, mockCatalogCommand, + mockUpgradeCommand, } = vi.hoisted(() => { const mockListen = vi.fn(); const mockOnce = vi.fn(); @@ -71,6 +72,12 @@ const { mockAddCommand: vi.fn().mockResolvedValue('installed ok'), mockRemoveCommand: vi.fn().mockResolvedValue('removed ok'), mockCatalogCommand: vi.fn().mockResolvedValue('catalog ok'), + mockUpgradeCommand: vi.fn().mockResolvedValue({ + upgraded: 1, + upToDate: 0, + failed: 0, + output: 'echo: 1.0.0 → 2.0.0\n\n1 upgraded, 0 up-to-date, 0 failed', + }), }; }); @@ -96,6 +103,7 @@ vi.mock('./search.ts', () => ({ searchCommand: mockSearchCommand })); vi.mock('./add.ts', () => ({ addCommand: mockAddCommand })); vi.mock('./remove.ts', () => ({ removeCommand: mockRemoveCommand })); vi.mock('./catalog.ts', () => ({ catalogCommand: mockCatalogCommand })); +vi.mock('./upgrade.ts', () => ({ upgradeCommand: mockUpgradeCommand })); vi.mock('../adapters/catalog-store-adapter.ts', () => ({ FilesystemCatalogStoreAdapter: vi.fn().mockImplementation(() => ({})), @@ -188,6 +196,13 @@ describe('startCommand', () => { mockRegister.mockReset(); mockSendToolListChanged.mockReset(); mockSendToolListChanged.mockResolvedValue(undefined); + mockUpgradeCommand.mockReset(); + mockUpgradeCommand.mockResolvedValue({ + upgraded: 1, + upToDate: 0, + failed: 0, + output: 'echo: 1.0.0 → 2.0.0\n\n1 upgraded, 0 up-to-date, 0 failed', + }); }); afterEach(() => { @@ -308,7 +323,7 @@ describe('startCommand', () => { const handler = listToolsCall[1] as () => Promise<{ tools: unknown[] }>; const result = await handler(); - // Should include the brick tool + 11 internal tools + // Should include the brick tool + 12 internal tools expect(result.tools).toEqual( expect.arrayContaining([ { @@ -324,12 +339,13 @@ describe('startCommand', () => { expect.objectContaining({ name: 'focus_install' }), expect.objectContaining({ name: 'focus_remove' }), expect.objectContaining({ name: 'focus_update' }), + expect.objectContaining({ name: 'focus_upgrade' }), expect.objectContaining({ name: 'focus_catalog_add' }), expect.objectContaining({ name: 'focus_catalog_list' }), expect.objectContaining({ name: 'focus_catalog_remove' }), ]), ); - expect((result.tools as unknown[]).length).toBe(12); + expect((result.tools as unknown[]).length).toBe(13); void promise; }); @@ -1717,7 +1733,14 @@ describe('startCommand', () => { }); describe('focus_update', () => { - it('returns "not yet implemented" placeholder', async () => { + it('updates all bricks when called without arguments (happy path)', async () => { + mockUpgradeCommand.mockResolvedValue({ + upgraded: 2, + upToDate: 1, + failed: 0, + output: 'echo: 1.0.0 → 2.0.0\ngit: 0.9.0 → 1.0.0\nfs — already at latest (3.0.0)\n\n2 upgraded, 1 up-to-date, 0 failed', + }); + const { startCommand } = await import('./start.ts'); const promise = startCommand([]); await new Promise((r) => setTimeout(r, 10)); @@ -1738,7 +1761,214 @@ describe('startCommand', () => { }); expect(result.isError).toBeUndefined(); - expect(result.content[0]?.text).toContain('not yet implemented'); + expect(result.content[0]?.text).toContain('2 upgraded'); + expect(mockUpgradeCommand).toHaveBeenCalledWith( + expect.objectContaining({ all: true, check: false }), + ); + + void promise; + }); + + it('updates a specific brick when brick argument is provided', async () => { + mockUpgradeCommand.mockResolvedValue({ + upgraded: 1, + upToDate: 0, + failed: 0, + output: 'echo: 1.0.0 → 2.0.0\n\n1 upgraded, 0 up-to-date, 0 failed', + }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_update', arguments: { brick: 'echo' } }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]?.text).toContain('echo: 1.0.0 → 2.0.0'); + expect(mockUpgradeCommand).toHaveBeenCalledWith( + expect.objectContaining({ brickName: 'echo', check: false }), + ); + + void promise; + }); + + it('returns dry-run output when check=true (--check flag)', async () => { + mockUpgradeCommand.mockResolvedValue({ + upgraded: 1, + upToDate: 0, + failed: 0, + output: 'echo: 1.0.0 → 2.0.0\n\n1 would upgrade, 0 up-to-date, 0 failed', + }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_update', arguments: { check: true } }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]?.text).toContain('would upgrade'); + expect(mockUpgradeCommand).toHaveBeenCalledWith( + expect.objectContaining({ check: true }), + ); + + void promise; + }); + + it('returns isError when upgradeCommand throws', async () => { + mockUpgradeCommand.mockRejectedValue(new Error('no catalog source')); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_update', arguments: {} }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Update failed'); + expect(result.content[0]?.text).toContain('no catalog source'); + + void promise; + }); + }); + + describe('focus_upgrade', () => { + it('upgrades all bricks when called without arguments (happy path)', async () => { + mockUpgradeCommand.mockResolvedValue({ + upgraded: 1, + upToDate: 0, + failed: 0, + output: 'echo: 1.0.0 → 2.0.0\n\n1 upgraded, 0 up-to-date, 0 failed', + }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_upgrade', arguments: {} }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]?.text).toContain('1 upgraded'); + expect(mockUpgradeCommand).toHaveBeenCalledWith( + expect.objectContaining({ all: true, check: false }), + ); + + void promise; + }); + + it('returns dry-run output when check=true (--check flag)', async () => { + mockUpgradeCommand.mockResolvedValue({ + upgraded: 1, + upToDate: 0, + failed: 0, + output: 'echo: 1.0.0 → 2.0.0\n\n1 would upgrade, 0 up-to-date, 0 failed', + }); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_upgrade', arguments: { check: true } }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0]?.text).toContain('would upgrade'); + expect(mockUpgradeCommand).toHaveBeenCalledWith( + expect.objectContaining({ check: true }), + ); + + void promise; + }); + + it('returns isError when upgradeCommand throws', async () => { + mockUpgradeCommand.mockRejectedValue(new Error('no catalog source')); + + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const callToolCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'CallToolRequestSchema', + ); + if (!callToolCall) throw new Error('CallTool handler not registered'); + const handler = callToolCall[1] as (req: { + params: { name: string; arguments?: Record }; + }) => Promise<{ + content: Array<{ type: string; text: string }>; + isError?: boolean; + }>; + + const result = await handler({ + params: { name: 'focus_upgrade', arguments: {} }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toContain('Upgrade failed'); + expect(result.content[0]?.text).toContain('no catalog source'); void promise; }); @@ -2081,4 +2311,99 @@ describe('startCommand', () => { void promise; }); + + // ---------- FOCUS_BENCH_MODE — meta tool isolation ---------- + + it('FOCUS_BENCH_MODE=true skips meta tools (focus_list, focus_install, etc.)', async () => { + const originalEnv = process.env['FOCUS_BENCH_MODE']; + process.env['FOCUS_BENCH_MODE'] = 'true'; + try { + mockListTools.mockReturnValue([]); + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const listToolsCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'ListToolsRequestSchema', + ); + if (!listToolsCall) throw new Error('ListTools handler not registered'); + + const handler = listToolsCall[1] as () => Promise<{ tools: unknown[] }>; + const result = await handler(); + + // No meta tools should be present + const names = (result.tools as Array<{ name: string }>).map((t) => t.name); + const META_TOOL_NAMES = [ + 'focus_list', + 'focus_load', + 'focus_unload', + 'focus_reload', + 'focus_search', + 'focus_install', + 'focus_remove', + 'focus_update', + 'focus_upgrade', + 'focus_catalog_add', + 'focus_catalog_list', + 'focus_catalog_remove', + ]; + for (const metaName of META_TOOL_NAMES) { + expect(names).not.toContain(metaName); + } + + void promise; + } finally { + if (originalEnv === undefined) { + delete process.env['FOCUS_BENCH_MODE']; + } else { + process.env['FOCUS_BENCH_MODE'] = originalEnv; + } + } + }); + + it('FOCUS_BENCH_MODE absent registers all meta tools', async () => { + const originalEnv = process.env['FOCUS_BENCH_MODE']; + delete process.env['FOCUS_BENCH_MODE']; + try { + mockListTools.mockReturnValue([]); + const { startCommand } = await import('./start.ts'); + const promise = startCommand([]); + await new Promise((r) => setTimeout(r, 10)); + + const listToolsCall = mockSetRequestHandler.mock.calls.find( + (call) => call[0] === 'ListToolsRequestSchema', + ); + if (!listToolsCall) throw new Error('ListTools handler not registered'); + + const handler = listToolsCall[1] as () => Promise<{ tools: unknown[] }>; + const result = await handler(); + + const names = (result.tools as Array<{ name: string }>).map((t) => t.name); + const META_TOOL_NAMES = [ + 'focus_list', + 'focus_load', + 'focus_unload', + 'focus_reload', + 'focus_search', + 'focus_install', + 'focus_remove', + 'focus_update', + 'focus_upgrade', + 'focus_catalog_add', + 'focus_catalog_list', + 'focus_catalog_remove', + ]; + for (const metaName of META_TOOL_NAMES) { + expect(names).toContain(metaName); + } + + void promise; + } finally { + if (originalEnv === undefined) { + delete process.env['FOCUS_BENCH_MODE']; + } else { + process.env['FOCUS_BENCH_MODE'] = originalEnv; + } + } + }); }); diff --git a/src/commands/start.ts b/src/commands/start.ts index fc99036..75371a6 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -22,6 +22,7 @@ import { addCommand } from './add.ts'; import { catalogCommand } from './catalog.ts'; import { removeCommand } from './remove.ts'; import { searchCommand } from './search.ts'; +import { upgradeCommand } from './upgrade.ts'; /** * Enrich a start-time error message with actionable suggestions. @@ -65,6 +66,7 @@ async function loadSingleBrick(brickName: string, bricksDir: string): Promise
{ const { values } = parseArgs({ args: argv, @@ -121,6 +123,176 @@ export async function startCommand(argv: string[] = []): Promise { { capabilities: { tools: {} } }, ); + // When FOCUS_BENCH_MODE=true (or 1), skip all meta tools so bench agents see + // only the brick tools they are supposed to measure. + const isBenchMode = + process.env['FOCUS_BENCH_MODE'] === 'true' || process.env['FOCUS_BENCH_MODE'] === '1'; + + const metaTools = isBenchMode + ? [] + : [ + { + name: 'focus_list', + description: 'List all loaded bricks and their tools', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'focus_load', + description: + 'Load (activate) an installed brick — its tools become available immediately', + inputSchema: { + type: 'object', + properties: { name: { type: 'string', description: 'Brick name to load' } }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'focus_unload', + description: + 'Unload (deactivate) a running brick — its tools are removed immediately', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Brick name to unload' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'focus_reload', + description: + 'Reload a brick — stop, reimport from disk, restart. Tools are updated immediately.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Brick name to reload' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'focus_search', + description: 'Search the marketplace catalog for available bricks', + inputSchema: { + type: 'object', + properties: { query: { type: 'string', description: 'Search query' } }, + required: ['query'], + additionalProperties: false, + }, + }, + { + name: 'focus_install', + description: 'Install a brick from the marketplace catalog', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Brick name to install' }, + version: { + type: 'string', + description: 'Version to install (optional)', + }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'focus_remove', + description: 'Remove an installed brick', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Brick name to remove' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + { + name: 'focus_update', + description: 'Update one or all installed bricks to their latest catalog version', + inputSchema: { + type: 'object', + properties: { + brick: { + type: 'string', + description: + 'Brick name to update (optional — updates all if omitted)', + }, + all: { + type: 'boolean', + description: + 'Update all installed bricks (default when brick is omitted)', + }, + check: { + type: 'boolean', + description: + 'Dry-run: list upgradable bricks without applying changes', + }, + }, + additionalProperties: false, + }, + }, + { + name: 'focus_upgrade', + description: + 'Upgrade one or all installed bricks to their latest catalog version (alias for focus_update)', + inputSchema: { + type: 'object', + properties: { + brick: { + type: 'string', + description: + 'Brick name to upgrade (optional — upgrades all if omitted)', + }, + all: { + type: 'boolean', + description: + 'Upgrade all installed bricks (default when brick is omitted)', + }, + check: { + type: 'boolean', + description: + 'Dry-run: list upgradable bricks without applying changes', + }, + }, + additionalProperties: false, + }, + }, + { + name: 'focus_catalog_add', + description: 'Add a catalog source URL', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'Catalog source URL to add' }, + }, + required: ['url'], + additionalProperties: false, + }, + }, + { + name: 'focus_catalog_list', + description: 'List all configured catalog sources', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'focus_catalog_remove', + description: 'Remove a catalog source URL', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'Catalog source URL to remove' }, + }, + required: ['url'], + additionalProperties: false, + }, + }, + ]; + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ ...focusMcp.router.listTools().map((t) => ({ @@ -128,120 +300,7 @@ export async function startCommand(argv: string[] = []): Promise { description: t.description, inputSchema: t.inputSchema, })), - { - name: 'focus_list', - description: 'List all loaded bricks and their tools', - inputSchema: { type: 'object', properties: {}, additionalProperties: false }, - }, - { - name: 'focus_load', - description: - 'Load (activate) an installed brick — its tools become available immediately', - inputSchema: { - type: 'object', - properties: { name: { type: 'string', description: 'Brick name to load' } }, - required: ['name'], - additionalProperties: false, - }, - }, - { - name: 'focus_unload', - description: - 'Unload (deactivate) a running brick — its tools are removed immediately', - inputSchema: { - type: 'object', - properties: { name: { type: 'string', description: 'Brick name to unload' } }, - required: ['name'], - additionalProperties: false, - }, - }, - { - name: 'focus_reload', - description: - 'Reload a brick — stop, reimport from disk, restart. Tools are updated immediately.', - inputSchema: { - type: 'object', - properties: { name: { type: 'string', description: 'Brick name to reload' } }, - required: ['name'], - additionalProperties: false, - }, - }, - { - name: 'focus_search', - description: 'Search the marketplace catalog for available bricks', - inputSchema: { - type: 'object', - properties: { query: { type: 'string', description: 'Search query' } }, - required: ['query'], - additionalProperties: false, - }, - }, - { - name: 'focus_install', - description: 'Install a brick from the marketplace catalog', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: 'Brick name to install' }, - version: { type: 'string', description: 'Version to install (optional)' }, - }, - required: ['name'], - additionalProperties: false, - }, - }, - { - name: 'focus_remove', - description: 'Remove an installed brick', - inputSchema: { - type: 'object', - properties: { name: { type: 'string', description: 'Brick name to remove' } }, - required: ['name'], - additionalProperties: false, - }, - }, - { - name: 'focus_update', - description: 'Update an installed brick to the latest version', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Brick name to update (optional, updates all if omitted)', - }, - }, - additionalProperties: false, - }, - }, - { - name: 'focus_catalog_add', - description: 'Add a catalog source URL', - inputSchema: { - type: 'object', - properties: { - url: { type: 'string', description: 'Catalog source URL to add' }, - }, - required: ['url'], - additionalProperties: false, - }, - }, - { - name: 'focus_catalog_list', - description: 'List all configured catalog sources', - inputSchema: { type: 'object', properties: {}, additionalProperties: false }, - }, - { - name: 'focus_catalog_remove', - description: 'Remove a catalog source URL', - inputSchema: { - type: 'object', - properties: { - url: { type: 'string', description: 'Catalog source URL to remove' }, - }, - required: ['url'], - additionalProperties: false, - }, - }, + ...metaTools, ], })); @@ -249,326 +308,371 @@ export async function startCommand(argv: string[] = []): Promise { server.setRequestHandler(CallToolRequestSchema, async (req) => { const { name, arguments: args } = req.params; - // Internal tools — handled before dispatching to brick router - if (name === 'focus_list') { - const bricks = focusMcp.registry.getBricks(); - if (bricks.length === 0) { - return { content: [{ type: 'text' as const, text: 'No bricks loaded.' }] }; + // Meta tools are disabled in bench mode — skip all focus_* handlers and + // fall through to the brick router (which will return an unknown-tool error). + if (!isBenchMode) { + // Internal tools — handled before dispatching to brick router + if (name === 'focus_list') { + const bricks = focusMcp.registry.getBricks(); + if (bricks.length === 0) { + return { content: [{ type: 'text' as const, text: 'No bricks loaded.' }] }; + } + const lines = bricks.map((b) => { + const status = focusMcp.registry.getStatus(b.manifest.name); + const toolNames = + b.manifest.tools.map((t) => t.name).join(', ') || '(no tools)'; + return `- ${b.manifest.name} [${status}]: ${toolNames}`; + }); + return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; } - const lines = bricks.map((b) => { - const status = focusMcp.registry.getStatus(b.manifest.name); - const toolNames = b.manifest.tools.map((t) => t.name).join(', ') || '(no tools)'; - return `- ${b.manifest.name} [${status}]: ${toolNames}`; - }); - return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; - } - if (name === 'focus_load') { - const brickName = (args as Record)?.['name']; - if (typeof brickName !== 'string' || brickName.trim() === '') { - return { - content: [{ type: 'text' as const, text: 'Missing or invalid brick name.' }], - isError: true, - }; - } - if (focusMcp.registry.getBrick(brickName)) { - return { - content: [ - { - type: 'text' as const, - text: `Brick "${brickName}" is already loaded.`, - }, - ], - isError: true, - }; - } - try { - const brick = await loadSingleBrick(brickName, activeBricksDir); - focusMcp.registry.register(brick); - const ctx = { bus: focusMcp.bus, config: {}, logger: minimalLogger }; - await brick.start(ctx); - focusMcp.registry.setStatus(brickName, 'running'); - await server.sendToolListChanged(); - const toolNames = brick.manifest.tools.map((t) => t.name).join(', '); - return { - content: [ - { - type: 'text' as const, - text: `Brick "${brickName}" loaded. Tools: ${toolNames}`, - }, - ], - }; - } catch (err) { - return { - content: [ - { - type: 'text' as const, - text: `Failed to load "${brickName}": ${err instanceof Error ? err.message : String(err)}`, - }, - ], - isError: true, - }; + if (name === 'focus_load') { + const brickName = (args as Record)?.['name']; + if (typeof brickName !== 'string' || brickName.trim() === '') { + return { + content: [ + { type: 'text' as const, text: 'Missing or invalid brick name.' }, + ], + isError: true, + }; + } + if (focusMcp.registry.getBrick(brickName)) { + return { + content: [ + { + type: 'text' as const, + text: `Brick "${brickName}" is already loaded.`, + }, + ], + isError: true, + }; + } + try { + const brick = await loadSingleBrick(brickName, activeBricksDir); + focusMcp.registry.register(brick); + const ctx = { bus: focusMcp.bus, config: {}, logger: minimalLogger }; + await brick.start(ctx); + focusMcp.registry.setStatus(brickName, 'running'); + await server.sendToolListChanged(); + const toolNames = brick.manifest.tools.map((t) => t.name).join(', '); + return { + content: [ + { + type: 'text' as const, + text: `Brick "${brickName}" loaded. Tools: ${toolNames}`, + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to load "${brickName}": ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } } - } - if (name === 'focus_unload') { - const brickName = (args as Record)?.['name']; - if (typeof brickName !== 'string' || brickName.trim() === '') { - return { - content: [{ type: 'text' as const, text: 'Missing or invalid brick name.' }], - isError: true, - }; - } - const brick = focusMcp.registry.getBrick(brickName); - if (!brick) { - return { - content: [{ type: 'text' as const, text: `Brick "${brickName}" not found.` }], - isError: true, - }; - } - try { - await brick.stop(); - focusMcp.registry.setStatus(brickName, 'stopped'); - focusMcp.registry.unregister(brickName); - await server.sendToolListChanged(); - return { - content: [ - { - type: 'text' as const, - text: `Brick "${brickName}" unloaded successfully.`, - }, - ], - }; - } catch (err) { - return { - content: [ - { - type: 'text' as const, - text: `Failed to unload "${brickName}": ${err instanceof Error ? err.message : String(err)}`, - }, - ], - isError: true, - }; + if (name === 'focus_unload') { + const brickName = (args as Record)?.['name']; + if (typeof brickName !== 'string' || brickName.trim() === '') { + return { + content: [ + { type: 'text' as const, text: 'Missing or invalid brick name.' }, + ], + isError: true, + }; + } + const brick = focusMcp.registry.getBrick(brickName); + if (!brick) { + return { + content: [ + { type: 'text' as const, text: `Brick "${brickName}" not found.` }, + ], + isError: true, + }; + } + try { + await brick.stop(); + focusMcp.registry.setStatus(brickName, 'stopped'); + focusMcp.registry.unregister(brickName); + await server.sendToolListChanged(); + return { + content: [ + { + type: 'text' as const, + text: `Brick "${brickName}" unloaded successfully.`, + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to unload "${brickName}": ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } } - } - if (name === 'focus_reload') { - const brickName = (args as Record)?.['name']; - if (typeof brickName !== 'string' || brickName.trim() === '') { - return { - content: [{ type: 'text' as const, text: 'Missing or invalid brick name.' }], - isError: true, - }; - } - const existing = focusMcp.registry.getBrick(brickName); - if (!existing) { - return { - content: [{ type: 'text' as const, text: `Brick "${brickName}" not found.` }], - isError: true, - }; - } - try { - await existing.stop(); - focusMcp.registry.unregister(brickName); - const brick = await loadSingleBrick(brickName, activeBricksDir); - focusMcp.registry.register(brick); - const ctx = { bus: focusMcp.bus, config: {}, logger: minimalLogger }; - await brick.start(ctx); - focusMcp.registry.setStatus(brickName, 'running'); - await server.sendToolListChanged(); - const toolNames = brick.manifest.tools.map((t) => t.name).join(', '); - return { - content: [ - { - type: 'text' as const, - text: `Brick "${brickName}" reloaded. Tools: ${toolNames}`, - }, - ], - }; - } catch (err) { - return { - content: [ - { - type: 'text' as const, - text: `Failed to reload "${brickName}": ${err instanceof Error ? err.message : String(err)}`, - }, - ], - isError: true, - }; + if (name === 'focus_reload') { + const brickName = (args as Record)?.['name']; + if (typeof brickName !== 'string' || brickName.trim() === '') { + return { + content: [ + { type: 'text' as const, text: 'Missing or invalid brick name.' }, + ], + isError: true, + }; + } + const existing = focusMcp.registry.getBrick(brickName); + if (!existing) { + return { + content: [ + { type: 'text' as const, text: `Brick "${brickName}" not found.` }, + ], + isError: true, + }; + } + try { + await existing.stop(); + focusMcp.registry.unregister(brickName); + const brick = await loadSingleBrick(brickName, activeBricksDir); + focusMcp.registry.register(brick); + const ctx = { bus: focusMcp.bus, config: {}, logger: minimalLogger }; + await brick.start(ctx); + focusMcp.registry.setStatus(brickName, 'running'); + await server.sendToolListChanged(); + const toolNames = brick.manifest.tools.map((t) => t.name).join(', '); + return { + content: [ + { + type: 'text' as const, + text: `Brick "${brickName}" reloaded. Tools: ${toolNames}`, + }, + ], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to reload "${brickName}": ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } } - } - if (name === 'focus_search') { - const query = (args as Record)?.['query']; - if (typeof query !== 'string') { - return { - content: [{ type: 'text' as const, text: 'Missing or invalid query.' }], - isError: true, - }; - } - try { - const io = { - fetch: new HttpFetchAdapter(), - store: new FilesystemCatalogStoreAdapter(), - }; - const result = await searchCommand({ query, io }); - const text = - result.errors.length > 0 - ? `${result.output}\n\nWarnings:\n${result.errors.join('\n')}` - : result.output; - return { content: [{ type: 'text' as const, text }] }; - } catch (err) { - return { - content: [ - { - type: 'text' as const, - text: `Search failed: ${err instanceof Error ? err.message : String(err)}`, - }, - ], - isError: true, - }; + if (name === 'focus_search') { + const query = (args as Record)?.['query']; + if (typeof query !== 'string') { + return { + content: [{ type: 'text' as const, text: 'Missing or invalid query.' }], + isError: true, + }; + } + try { + const io = { + fetch: new HttpFetchAdapter(), + store: new FilesystemCatalogStoreAdapter(), + }; + const result = await searchCommand({ query, io }); + const text = + result.errors.length > 0 + ? `${result.output}\n\nWarnings:\n${result.errors.join('\n')}` + : result.output; + return { content: [{ type: 'text' as const, text }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Search failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } } - } - if (name === 'focus_install') { - const brickName = (args as Record)?.['name']; - if (typeof brickName !== 'string' || brickName.trim() === '') { - return { - content: [{ type: 'text' as const, text: 'Missing or invalid brick name.' }], - isError: true, - }; - } - try { - const io = { - fetch: new HttpFetchAdapter(), - store: new FilesystemCatalogStoreAdapter(), - installer: new NpmInstallerAdapter(), - }; - const result = await addCommand({ brickName, io }); - return { content: [{ type: 'text' as const, text: result }] }; - } catch (err) { - return { - content: [ - { - type: 'text' as const, - text: `Install failed: ${err instanceof Error ? err.message : String(err)}`, - }, - ], - isError: true, - }; + if (name === 'focus_install') { + const brickName = (args as Record)?.['name']; + if (typeof brickName !== 'string' || brickName.trim() === '') { + return { + content: [ + { type: 'text' as const, text: 'Missing or invalid brick name.' }, + ], + isError: true, + }; + } + try { + const io = { + fetch: new HttpFetchAdapter(), + store: new FilesystemCatalogStoreAdapter(), + installer: new NpmInstallerAdapter(), + }; + const result = await addCommand({ brickName, io }); + return { content: [{ type: 'text' as const, text: result }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Install failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } } - } - if (name === 'focus_remove') { - const brickName = (args as Record)?.['name']; - if (typeof brickName !== 'string' || brickName.trim() === '') { - return { - content: [{ type: 'text' as const, text: 'Missing or invalid brick name.' }], - isError: true, - }; - } - try { - const io = { installer: new NpmInstallerAdapter() }; - const result = await removeCommand({ brickName, io }); - return { content: [{ type: 'text' as const, text: result }] }; - } catch (err) { - return { - content: [ - { - type: 'text' as const, - text: `Remove failed: ${err instanceof Error ? err.message : String(err)}`, - }, - ], - isError: true, - }; + if (name === 'focus_remove') { + const brickName = (args as Record)?.['name']; + if (typeof brickName !== 'string' || brickName.trim() === '') { + return { + content: [ + { type: 'text' as const, text: 'Missing or invalid brick name.' }, + ], + isError: true, + }; + } + try { + const io = { installer: new NpmInstallerAdapter() }; + const result = await removeCommand({ brickName, io }); + return { content: [{ type: 'text' as const, text: result }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Remove failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } } - } - - if (name === 'focus_update') { - return { - content: [{ type: 'text' as const, text: 'focus_update: not yet implemented' }], - }; - } - if (name === 'focus_catalog_add') { - const url = (args as Record)?.['url']; - if (typeof url !== 'string' || url.trim() === '') { - return { - content: [{ type: 'text' as const, text: 'Missing or invalid URL.' }], - isError: true, - }; - } - try { - const io = { store: new FilesystemCatalogStoreAdapter() }; - // Derive a name from the URL (last path segment without extension) - const urlName = - url - .split('/') - .filter(Boolean) - .pop() - ?.replace(/\.json$/i, '') ?? url; - const result = await catalogCommand({ - subcommand: 'add', - url, - name: urlName, - io, - }); - return { content: [{ type: 'text' as const, text: result }] }; - } catch (err) { - return { - content: [ - { - type: 'text' as const, - text: `Catalog add failed: ${err instanceof Error ? err.message : String(err)}`, - }, - ], - isError: true, - }; + if (name === 'focus_update' || name === 'focus_upgrade') { + const rawArgs = args as Record | undefined; + const brickName = + typeof rawArgs?.['brick'] === 'string' ? rawArgs['brick'] : undefined; + const all = rawArgs?.['all'] === true; + const check = rawArgs?.['check'] === true; + try { + const io = { + fetch: new HttpFetchAdapter(), + store: new FilesystemCatalogStoreAdapter(), + installer: new NpmInstallerAdapter(), + }; + const result = await upgradeCommand({ + ...(brickName !== undefined ? { brickName } : {}), + all: all || brickName === undefined, + check, + io, + }); + return { content: [{ type: 'text' as const, text: result.output }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `${name === 'focus_upgrade' ? 'Upgrade' : 'Update'} failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } } - } - if (name === 'focus_catalog_list') { - try { - const io = { store: new FilesystemCatalogStoreAdapter() }; - const result = await catalogCommand({ subcommand: 'list', io }); - return { content: [{ type: 'text' as const, text: result }] }; - } catch (err) { - return { - content: [ - { - type: 'text' as const, - text: `Catalog list failed: ${err instanceof Error ? err.message : String(err)}`, - }, - ], - isError: true, - }; + if (name === 'focus_catalog_add') { + const url = (args as Record)?.['url']; + if (typeof url !== 'string' || url.trim() === '') { + return { + content: [{ type: 'text' as const, text: 'Missing or invalid URL.' }], + isError: true, + }; + } + try { + const io = { store: new FilesystemCatalogStoreAdapter() }; + // Derive a name from the URL (last path segment without extension) + const urlName = + url + .split('/') + .filter(Boolean) + .pop() + ?.replace(/\.json$/i, '') ?? url; + const result = await catalogCommand({ + subcommand: 'add', + url, + name: urlName, + io, + }); + return { content: [{ type: 'text' as const, text: result }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Catalog add failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } } - } - if (name === 'focus_catalog_remove') { - const url = (args as Record)?.['url']; - if (typeof url !== 'string' || url.trim() === '') { - return { - content: [{ type: 'text' as const, text: 'Missing or invalid URL.' }], - isError: true, - }; - } - try { - const io = { store: new FilesystemCatalogStoreAdapter() }; - const result = await catalogCommand({ subcommand: 'remove', url, io }); - return { content: [{ type: 'text' as const, text: result }] }; - } catch (err) { - return { - content: [ - { - type: 'text' as const, - text: `Catalog remove failed: ${err instanceof Error ? err.message : String(err)}`, - }, - ], - isError: true, - }; + if (name === 'focus_catalog_list') { + try { + const io = { store: new FilesystemCatalogStoreAdapter() }; + const result = await catalogCommand({ subcommand: 'list', io }); + return { content: [{ type: 'text' as const, text: result }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Catalog list failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } } - } + + if (name === 'focus_catalog_remove') { + const url = (args as Record)?.['url']; + if (typeof url !== 'string' || url.trim() === '') { + return { + content: [{ type: 'text' as const, text: 'Missing or invalid URL.' }], + isError: true, + }; + } + try { + const io = { store: new FilesystemCatalogStoreAdapter() }; + const result = await catalogCommand({ subcommand: 'remove', url, io }); + return { content: [{ type: 'text' as const, text: result }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Catalog remove failed: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } // end focus_catalog_remove + } // end !isBenchMode // Brick tools (existing dispatch) try { diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index b11644b..e8d2efd 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -4,9 +4,9 @@ /** * focus upgrade [] [--all] [--check] * - * Re-installs one or all bricks at the latest catalog version, preserving the - * `enabled` state from center.json. Equivalent to `focus remove + focus add` - * but in a single atomic command per brick. + * Thin wrapper around core.executeUpgrade. + * Loads the aggregated catalog from the configured sources, then + * delegates all orchestration to @focus-mcp/core. * * --check dry-run: list upgradable bricks without making any changes. * @@ -14,20 +14,12 @@ */ import { - type AggregatedCatalog, aggregateCatalogs, - compareSemver, createDefaultStore, - executeInstall, - executeRemove, + executeUpgrade, fetchAllCatalogs, - findBrickAcrossCatalogs, getEnabledSources, parseCatalogStore, - parseCenterJson, - parseCenterLock, - planInstall, - planRemove, } from '@focus-mcp/core'; import type { CatalogStoreIO } from '../adapters/catalog-store-adapter.ts'; import type { FetchIO } from '../adapters/http-fetch-adapter.ts'; @@ -60,7 +52,7 @@ export interface UpgradeSummary { // ---------- catalog loading ---------- -async function loadAggregatedCatalog(io: UpgradeIO): Promise { +async function loadAggregatedCatalog(io: UpgradeIO) { const rawStore = await io.store.readStore(); let store = parseCatalogStore(rawStore); if (store.sources.length === 0) { @@ -83,111 +75,12 @@ async function loadAggregatedCatalog(io: UpgradeIO): Promise return aggregateCatalogs(results); } -// ---------- upgrade one brick ---------- - -interface UpgradeOneResult { - status: 'upgraded' | 'up-to-date' | 'failed' | 'would-upgrade'; - message: string; -} - -async function upgradeOne( - brickName: string, - aggregated: AggregatedCatalog, - io: UpgradeIO, - check: boolean, -): Promise { - const rawCenter = await io.installer.readCenterJson(); - const rawLock = await io.installer.readCenterLock(); - const centerJson = parseCenterJson(rawCenter); - const centerLock = parseCenterLock(rawLock); - - const installed = centerJson.bricks[brickName]; - if (installed === undefined) { - return { - status: 'failed', - message: `"${brickName}" is not installed — use \`focus add ${brickName}\` first.`, - }; - } - - const brick = findBrickAcrossCatalogs(aggregated, brickName); - if (brick === undefined) { - return { - status: 'failed', - message: `"${brickName}": not found in any catalog.`, - }; - } - - const currentVersion = installed.version; - const latestVersion = brick.version; - - const cmp = compareSemver(latestVersion, currentVersion); - if (cmp <= 0) { - return { - status: 'up-to-date', - message: `${brickName} — already at latest (${currentVersion})`, - }; - } - - if (check) { - return { - status: 'would-upgrade', - message: `${brickName}: ${currentVersion} → ${latestVersion}`, - }; - } - - // Preserve `enabled` state before remove - const wasEnabled = installed.enabled; - - try { - // Remove old version - const { npmPackage } = planRemove(brickName, centerJson, centerLock); - await executeRemove(io.installer, brickName, npmPackage, centerJson, centerLock); - - // Re-read state after remove, then install new version - const rawCenter2 = await io.installer.readCenterJson(); - const rawLock2 = await io.installer.readCenterLock(); - const centerJson2 = parseCenterJson(rawCenter2); - const centerLock2 = parseCenterLock(rawLock2); - - const plan = planInstall(brick, brick.catalogUrl); - await executeInstall(io.installer, plan, centerJson2, centerLock2); - - // If brick was disabled, restore disabled state - if (!wasEnabled) { - const rawCenter3 = await io.installer.readCenterJson(); - const centerJson3 = parseCenterJson(rawCenter3) as { - bricks: Record< - string, - { version: string; enabled: boolean; config?: Record } - >; - }; - const entry3 = centerJson3.bricks[brickName]; - if (entry3 !== undefined) { - entry3.enabled = false; - await io.installer.writeCenterJson( - centerJson3 as Parameters[0], - ); - } - } - - return { - status: 'upgraded', - message: `${brickName}: ${currentVersion} → ${latestVersion}`, - }; - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - return { - status: 'failed', - message: `"${brickName}": ${errMsg}`, - }; - } -} - // ---------- public API ---------- /** * Upgrade one or all bricks to their latest catalog version. * + * Loads the catalog then delegates to core.executeUpgrade. * Returns a summary with counts and human-readable output. */ export async function upgradeCommand({ @@ -196,48 +89,12 @@ export async function upgradeCommand({ check = false, io, }: UpgradeCommandInput): Promise { - const aggregated = await loadAggregatedCatalog(io); - - // Determine target brick list - let targets: string[]; - - if (all || brickName === undefined || brickName.trim().length === 0) { - const rawCenter = await io.installer.readCenterJson(); - const centerJson = parseCenterJson(rawCenter); - targets = Object.keys(centerJson.bricks); - if (targets.length === 0) { - return { - upgraded: 0, - upToDate: 0, - failed: 0, - output: 'No bricks installed.', - }; - } - } else { - targets = [brickName.trim()]; - } - - let upgraded = 0; - let upToDate = 0; - let failed = 0; - const lines: string[] = []; - - for (const target of targets) { - const result = await upgradeOne(target, aggregated, io, check); - lines.push(result.message); - if (result.status === 'upgraded') upgraded++; - else if (result.status === 'up-to-date') upToDate++; - else if (result.status === 'would-upgrade') - upgraded++; // counts as "would upgrade" - else failed++; - } - - const summary = check - ? `${upgraded} would upgrade, ${upToDate} up-to-date, ${failed} failed` - : `${upgraded} upgraded, ${upToDate} up-to-date, ${failed} failed`; - - lines.push(''); - lines.push(summary); - - return { upgraded, upToDate, failed, output: lines.join('\n') }; + const catalog = await loadAggregatedCatalog(io); + return executeUpgrade({ + ...(brickName !== undefined ? { brickName } : {}), + all, + check, + catalog, + io: { installer: io.installer }, + }); }