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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ All notable changes to `@switchbot/openapi-cli` are documented in this file.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.3.2] - 2026-04-26

### Fixed

- **`switchbot capabilities` crashed on 3.3.1 with `capabilities metadata
coverage error: missing:mcp list-tools, missing:mcp tools`.** 3.3.0 added
the `mcp tools` and `mcp list-tools` subcommands to `mcp.ts` but did not
register them in `COMMAND_META`. The runtime coverage check inside
`capabilities` fires against the fully-registered program, but the
existing `capabilities-meta` unit test only registered the `capabilities`
command in isolation — so the gap never surfaced in CI and only showed
up in the post-publish tarball smoke. Entries are now present and the
command prints its manifest again.

### Added

- **Regression guard test `capabilities-program-coverage.test.ts`.**
Registers every `register*Command` from `src/index.ts` (mirrored order)
against a fresh `Command`, then actually invokes
`capabilities --compact` so `validateCommandMetaCoverage` walks the
complete command tree. Any future leaf command added without a
`COMMAND_META` entry now fails this test before publish instead of on
users' machines.

## [3.3.1] - 2026-04-26

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -894,7 +894,7 @@ Queries the npm registry for the latest published version and compares it agains

```json
{
"current": "3.3.1",
"current": "3.3.2",
"latest": "4.0.0",
"upToDate": false,
"updateAvailable": true,
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@switchbot/openapi-cli",
"version": "3.3.1",
"version": "3.3.2",
"description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
"keywords": [
"switchbot",
Expand Down
2 changes: 2 additions & 0 deletions src/commands/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ export const COMMAND_META: Record<string, CommandMeta> = {
'history aggregate': READ_LOCAL,
'install': ACTION_LOCAL,
'mcp serve': READ_LOCAL,
'mcp tools': READ_LOCAL,
'mcp list-tools': READ_LOCAL,
'plan schema': READ_LOCAL,
'plan validate': READ_LOCAL,
'plan suggest': READ_LOCAL,
Expand Down
2 changes: 1 addition & 1 deletion tests/commands/capabilities-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const ALL_EXPECTED_LEAF_COMMANDS = [
'history show', 'history replay', 'history range', 'history stats',
'history verify', 'history aggregate',
'install',
'mcp serve',
'mcp serve', 'mcp tools', 'mcp list-tools',
'plan schema', 'plan validate', 'plan suggest', 'plan run',
'plan save', 'plan list', 'plan review', 'plan approve', 'plan execute',
'policy validate', 'policy new', 'policy migrate', 'policy diff',
Expand Down
102 changes: 102 additions & 0 deletions tests/commands/capabilities-program-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';

// Catalog and cache are imported transitively by many register*Command calls;
// mock them so the registration does not try to load real files.
const catalogMock = vi.hoisted(() => ({
getEffectiveCatalog: vi.fn(() => []),
deriveSafetyTier: vi.fn(() => 'action' as const),
deriveStatusQueries: vi.fn(() => []),
findCatalogEntry: vi.fn(() => null),
}));
const cacheMock = vi.hoisted(() => ({
loadCache: vi.fn(() => ({ list: [], status: {} })),
}));
vi.mock('../../src/devices/catalog.js', async (importActual) => {
const actual = await importActual<typeof import('../../src/devices/catalog.js')>();
return { ...actual, ...catalogMock };
});
vi.mock('../../src/devices/cache.js', async (importActual) => {
const actual = await importActual<typeof import('../../src/devices/cache.js')>();
return { ...actual, ...cacheMock };
});

// This test is the regression guard for v3.3.1 where `mcp tools` and
// `mcp list-tools` were registered in mcp.ts but not added to COMMAND_META.
// The coverage check inside `capabilities` only fires when the FULL program
// (every register*Command) has been wired up — the existing unit tests only
// registered `capabilities` itself, so the missing entries slipped past CI.
// This test registers every top-level command the real CLI registers, then
// invokes `capabilities` so `validateCommandMetaCoverage` sees the whole tree.

// Imports are awaited sequentially rather than in Promise.all — on Windows
// under vitest, a 25-way Promise.all of dynamic imports of ESM modules (each
// with its own transitive chain) starves the import queue and wedges for >30s.
// Serial imports finish in ~1.5s.
async function buildFullProgram(): Promise<Command> {
const program = new Command();
// Mirror src/index.ts registration order.
const modules: Array<[string, string]> = [
['../../src/commands/config.js', 'registerConfigCommand'],
['../../src/commands/devices.js', 'registerDevicesCommand'],
['../../src/commands/scenes.js', 'registerScenesCommand'],
['../../src/commands/webhook.js', 'registerWebhookCommand'],
['../../src/commands/completion.js', 'registerCompletionCommand'],
['../../src/commands/mcp.js', 'registerMcpCommand'],
['../../src/commands/quota.js', 'registerQuotaCommand'],
['../../src/commands/catalog.js', 'registerCatalogCommand'],
['../../src/commands/cache.js', 'registerCacheCommand'],
['../../src/commands/events.js', 'registerEventsCommand'],
['../../src/commands/doctor.js', 'registerDoctorCommand'],
['../../src/commands/schema.js', 'registerSchemaCommand'],
['../../src/commands/history.js', 'registerHistoryCommand'],
['../../src/commands/plan.js', 'registerPlanCommand'],
['../../src/commands/capabilities.js', 'registerCapabilitiesCommand'],
['../../src/commands/agent-bootstrap.js', 'registerAgentBootstrapCommand'],
['../../src/commands/policy.js', 'registerPolicyCommand'],
['../../src/commands/rules.js', 'registerRulesCommand'],
['../../src/commands/auth.js', 'registerAuthCommand'],
['../../src/commands/install.js', 'registerInstallCommand'],
['../../src/commands/uninstall.js', 'registerUninstallCommand'],
['../../src/commands/status-sync.js', 'registerStatusSyncCommand'],
['../../src/commands/health.js', 'registerHealthCommand'],
['../../src/commands/upgrade-check.js', 'registerUpgradeCheckCommand'],
['../../src/commands/daemon.js', 'registerDaemonCommand'],
];
for (const [modPath, fnName] of modules) {
const mod = (await import(modPath)) as Record<string, (p: Command) => void>;
mod[fnName](program);
}
program.exitOverride();
return program;
}

describe('capabilities coverage against the fully-registered CLI', () => {
it(
'validateCommandMetaCoverage passes for every leaf command in the real program',
{ timeout: 30_000 },
async () => {
const program = await buildFullProgram();
const stdout: string[] = [];
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
stdout.push(args.map(String).join(' '));
});
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`__exit__:${code}`);
}) as never);
try {
await program.parseAsync(['node', 'switchbot', 'capabilities', '--compact']);
} finally {
logSpy.mockRestore();
exitSpy.mockRestore();
}
const out = stdout.join('');
expect(out.length).toBeGreaterThan(50);
const parsed = JSON.parse(out) as { data?: { commands?: Array<{ name: string }> } };
expect(parsed.data).toBeDefined();
expect(parsed.data!.commands).toBeDefined();
// Sanity: the coverage check would have thrown before printJson ran,
// so reaching this line already proves every leaf has COMMAND_META.
},
);
});
Loading