diff --git a/README.md b/README.md index 9a131ea..6c14949 100644 --- a/README.md +++ b/README.md @@ -894,7 +894,7 @@ Queries the npm registry for the latest published version and compares it agains ```json { - "current": "3.3.0", + "current": "3.3.1", "latest": "4.0.0", "upToDate": false, "updateAvailable": true, diff --git a/package-lock.json b/package-lock.json index c7d5567..285b185 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "3.3.0", + "version": "3.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "3.3.0", + "version": "3.3.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 77f76d6..98bb4c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "3.3.0", + "version": "3.3.1", "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", @@ -48,6 +48,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "test:release-smoke:manual": "npm test -- tests/commands/policy.test.ts tests/commands/devices.test.ts tests/commands/explain.test.ts tests/commands/doctor.test.ts tests/commands/mcp.test.ts tests/commands/health-check.test.ts tests/commands/quota.test.ts tests/commands/status-sync.test.ts tests/status-sync/smoke.test.ts tests/commands/watch.test.ts tests/commands/events.test.ts tests/devices/catalog-fidelity.test.ts tests/commands/schema.test.ts tests/commands/auth.test.ts tests/commands/config.test.ts tests/commands/scenes.test.ts tests/commands/batch.test.ts tests/commands/history.test.ts tests/commands/expand.test.ts tests/commands/webhook.test.ts tests/commands/daemon.test.ts tests/commands/upgrade-check.test.ts tests/commands/install.test.ts tests/commands/uninstall.test.ts tests/commands/rules.test.ts tests/commands/plan.test.ts", "verify:pre-commit": "npm run build && npm test -- tests/version.test.ts", "verify:pre-push": "npm run build && npm test -- tests/version.test.ts && npm run smoke:pack-install", "prepublishOnly": "npm test && npm run build && npm run smoke:pack-install" diff --git a/src/commands/batch.ts b/src/commands/batch.ts index 400c355..d597d82 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -364,7 +364,6 @@ Examples: : undefined, })); const planDoc = { - schemaVersion: '1.1', dryRun: true, plan: { command: cmd, @@ -503,7 +502,6 @@ Examples: skipped: dryRunned.length + preSkipped.length, durationMs: Date.now() - startedAt, unverifiableCount: succeeded.filter((s) => getCachedDevice(s.deviceId)?.category === 'ir').length, - schemaVersion: '1.1', maxConcurrent: concurrency, staggerMs, ...(dryRun ? { dryRun: true } : {}), diff --git a/src/commands/devices.ts b/src/commands/devices.ts index eeea126..f86a9bb 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -44,6 +44,30 @@ const EXPAND_HINTS: Record = { 'Relay Switch 2PM': { command: 'setMode', flags: '--channel 1 --mode edge' }, }; +function annotateStatusPayload(deviceId: string, body: Record): Record { + const annotated = { ...body }; + if (Object.keys(body).length === 0) { + annotated.supported = false; + annotated.note = 'this device does not expose cloud status'; + return annotated; + } + + const cached = getCachedDevice(deviceId); + const looksLikeMeter = cached?.type?.toLowerCase().includes('meter') ?? false; + const staleZeroReading = + looksLikeMeter && + !Object.prototype.hasOwnProperty.call(body, 'onlineStatus') && + body.battery === 0 && + body.temperature === 0 && + body.humidity === 0; + + if (staleZeroReading) { + annotated.hint = 'readings look stale; check batteries or hub connectivity'; + } + + return annotated; +} + export function registerDevicesCommand(program: Command): void { const COMMAND_TYPES = ['command', 'customize'] as const; const devices = program @@ -303,7 +327,7 @@ Examples: const fetchedAt = new Date().toISOString(); const batch = results.map((r, i) => r.status === 'fulfilled' - ? { deviceId: ids[i], ok: true, _fetchedAt: fetchedAt, ...(r.value as object) } + ? { deviceId: ids[i], ok: true, _fetchedAt: fetchedAt, ...annotateStatusPayload(ids[i], r.value as Record) } : { deviceId: ids[i], ok: false, error: (r.reason as Error)?.message ?? String(r.reason) }, ); const batchFmt = resolveFormat(); @@ -342,7 +366,7 @@ Examples: category: options.nameCategory, room: options.nameRoom, }); - const body = await fetchDeviceStatus(deviceId); + const body = annotateStatusPayload(deviceId, await fetchDeviceStatus(deviceId)); const fetchedAt = new Date().toISOString(); const fmt = resolveFormat(); @@ -678,7 +702,7 @@ Examples: if (isJsonMode()) { printJson({ dryRun: true, wouldSend }); } else { - console.log(`[dry-run] Would POST devices/${_deviceId}/commands with ${JSON.stringify({ command: _cmd, parameter: _parsedParam, commandType })}`); + console.log(`◦ dry-run intercepted for ${_cmd} on ${_deviceId}; see stderr preview for the HTTP request.`); } return; } @@ -856,6 +880,8 @@ Examples: capabilities, source, suggestedActions: picks, + ...(result.catalogNote ? { catalogNote: result.catalogNote } : {}), + ...(result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {}), ...(expandHint ? { expandHint: { command: expandHint.command, flags: expandHint.flags, example: `switchbot devices expand ${deviceId} ${expandHint.command} ${expandHint.flags}` } } : {}), }); return; @@ -893,8 +919,17 @@ Examples: capabilities && 'liveStatus' in capabilities ? capabilities.liveStatus : undefined; console.log(''); + if (result.warnings && result.warnings.length > 0) { + for (const warning of result.warnings) { + console.log(`Warning: ${warning}`); + } + console.log(''); + } if (!catalog) { console.log(`(Type "${typeName}" is not in the built-in catalog — no command reference available.)`); + if (result.catalogNote) { + console.log(result.catalogNote); + } if (isPhysical) { console.log(`Try 'switchbot devices status ${deviceId}' to see what this device reports.`); } else { @@ -995,6 +1030,7 @@ function renderCatalogEntry(entry: DeviceCatalogEntry): void { if (entry.statusFields && entry.statusFields.length > 0) { console.log('\nStatus fields (from "devices status"):'); console.log(' ' + entry.statusFields.join(', ')); + console.log(' Note: statusFields are advisory; actual fields can vary by firmware and device variant.'); } const expandHint = EXPAND_HINTS[entry.type]; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 1e620ab..062589c 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -6,11 +6,13 @@ import { execSync } from 'node:child_process'; import { printJson, isJsonMode, exitWithError } from '../utils/output.js'; import { getEffectiveCatalog } from '../devices/catalog.js'; import { configFilePath, listProfiles, readProfileMeta } from '../config.js'; -import { describeCache, resetListCache } from '../devices/cache.js'; +import { describeCache, resetListCache, loadCache } from '../devices/cache.js'; import { DAILY_QUOTA, todayUsage } from '../utils/quota.js'; import { AGENT_BOOTSTRAP_SCHEMA_VERSION } from './agent-bootstrap.js'; import { CATALOG_SCHEMA_VERSION } from '../devices/catalog.js'; import { createSwitchBotMcpServer, listRegisteredTools } from './mcp.js'; +import { getReleaseMetadata } from '../version-notes.js'; +import { VERSION as currentVersion } from '../version.js'; import { resolvePolicyPath, loadPolicyFile, @@ -355,10 +357,56 @@ interface AuditRecord { kind?: string; deviceId?: string; command?: string; - result?: 'ok' | 'error'; + result?: 'ok' | 'error' | 'dry-run'; error?: string; } +function checkInventoryConsistency(): Check { + const cache = loadCache(); + if (!cache) { + return { + name: 'inventory', + status: 'ok', + detail: "no local inventory cache — run 'switchbot devices list' to enable hub-reference checks", + }; + } + + const dangling = Object.entries(cache.devices) + .filter(([, device]) => device.category === 'physical') + .filter(([deviceId, device]) => { + const hubDeviceId = device.hubDeviceId; + return Boolean( + hubDeviceId && + hubDeviceId !== '000000000000' && + hubDeviceId !== deviceId && + !cache.devices[hubDeviceId], + ); + }) + .map(([deviceId, device]) => ({ + deviceId, + deviceName: device.name, + hubDeviceId: device.hubDeviceId!, + deviceType: device.type, + })); + + if (dangling.length === 0) { + return { + name: 'inventory', + status: 'ok', + detail: `inventory graph consistent across ${Object.keys(cache.devices).length} cached devices`, + }; + } + + return { + name: 'inventory', + status: 'warn', + detail: { + message: `${dangling.length} device(s) reference a hubDeviceId that is not present in the current inventory`, + dangling: dangling.slice(0, 10), + }, + }; +} + function checkAudit(): Check { // P9: surface recent command failures so agents / ops can spot problems // before they page. When --audit-log was never enabled, the file won't @@ -853,6 +901,27 @@ function checkMcp(): Check { } } +function checkReleaseNotes(): Check { + const meta = getReleaseMetadata(currentVersion); + if (!meta || !meta.breaking) { + return { + name: 'release-notes', + status: 'ok', + detail: { version: currentVersion, message: 'no known breaking-change notice for the current release' }, + }; + } + return { + name: 'release-notes', + status: 'warn', + detail: { + version: currentVersion, + breaking: true, + message: meta.summary, + hint: 'If you have scripts pinned to 3.2.x JSON output, update them before rolling this release wider.', + }, + }; +} + interface CheckDef { name: string; description: string; @@ -871,6 +940,7 @@ const CHECK_REGISTRY: CheckDef[] = [ { name: 'profiles', description: 'profile definitions valid', run: () => checkProfiles() }, { name: 'catalog', description: 'catalog loads', run: () => checkCatalog() }, { name: 'catalog-schema', description: 'catalog vs agent-bootstrap version aligned', run: () => checkCatalogSchema() }, + { name: 'inventory', description: 'cached inventory graph consistency (hubDeviceId references)', run: () => checkInventoryConsistency() }, { name: 'cache', description: 'device cache state', run: () => checkCache() }, { name: 'quota', description: 'API quota headroom', run: () => checkQuotaFile() }, { name: 'clock', description: 'system clock skew', run: () => checkClockSkew() }, @@ -880,6 +950,7 @@ const CHECK_REGISTRY: CheckDef[] = [ run: ({ probe }) => (probe ? checkMqttProbe() : checkMqtt()), }, { name: 'mcp', description: 'MCP server instantiable + tool count', run: () => checkMcp() }, + { name: 'release-notes', description: 'current release breaking-change notice', run: () => checkReleaseNotes() }, { name: 'policy', description: 'policy.yaml present + schema-valid (if configured)', run: () => checkPolicy() }, { name: 'audit', description: 'recent command errors (last 24h)', run: () => checkAudit() }, { name: 'daemon', description: 'daemon state file + runtime status', run: () => checkDaemon() }, diff --git a/src/commands/events.ts b/src/commands/events.ts index b1197a9..16b5b28 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -74,6 +74,14 @@ interface EventRecord { matched: boolean; } +function emitJsonStreamRecord(record: T): void { + const { schemaVersion, ...rest } = record as T & Record; + printJson({ + payloadVersion: schemaVersion, + ...rest, + }); +} + function matchFilterDetail( body: unknown, clauses: FilterClause[] | null, @@ -262,7 +270,7 @@ Examples: if (!ev.matched) return; matchedCount++; if (isJsonMode()) { - printJson(ev); + emitJsonStreamRecord(ev); } else { const when = new Date(ev.t).toLocaleTimeString(); console.log(`[${when}] ${ev.remote} ${ev.path} ${JSON.stringify(ev.body)}`); @@ -457,7 +465,7 @@ Examples: // connected" even when mqtt-tail exits before the broker connects. if (isJsonMode()) { const sessionStartAt = new Date().toISOString(); - printJson({ + emitJsonStreamRecord({ schemaVersion: EVENTS_SCHEMA_VERSION, source: 'mqtt', kind: 'control', @@ -518,7 +526,7 @@ Examples: payload: parsed, }; if (isJsonMode()) { - printJson(record); + emitJsonStreamRecord(record); } else { console.log(JSON.stringify(record)); } @@ -554,7 +562,7 @@ Examples: // Control events always go to stdout as JSONL so consumers that // filter real events by presence of `payload` can skip them. if (isJsonMode()) { - printJson(ctl); + emitJsonStreamRecord(ctl); } else { console.log(JSON.stringify(ctl)); } diff --git a/src/commands/expand.ts b/src/commands/expand.ts index 5fa48e9..417103a 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -1,10 +1,10 @@ import { Command } from 'commander'; -import { intArg, stringArg } from '../utils/arg-parsers.js'; +import { intArg, stringArg, enumArg } from '../utils/arg-parsers.js'; import { handleError, isJsonMode, printJson, UsageError, exitWithError } from '../utils/output.js'; import { getCachedDevice } from '../devices/cache.js'; import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js'; import { isDryRun } from '../utils/flags.js'; -import { resolveDeviceId } from '../utils/name-resolver.js'; +import { resolveDeviceId, ALL_STRATEGIES, type NameResolveStrategy } from '../utils/name-resolver.js'; import { DryRunSignal } from '../api/client.js'; import { buildAcSetAll, @@ -22,6 +22,10 @@ export function registerExpandCommand(devices: Command): void { .argument('[deviceId]', 'Target device ID from "devices list" (or use --name)') .argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)') .option('--name ', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name')) + .option('--name-strategy ', `Name match strategy: ${ALL_STRATEGIES.join('|')} (default: require-unique)`, stringArg('--name-strategy')) + .option('--name-type ', 'Narrow --name by device type (e.g. "Curtain", "Air Conditioner")', stringArg('--name-type')) + .option('--name-category ', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir'] as const)) + .option('--name-room ', 'Narrow --name by room name (substring match)', stringArg('--name-room')) .option('--temp ', 'AC setAll: temperature in Celsius (16-30)', intArg('--temp', { min: 16, max: 30 })) .option('--mode ', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary', stringArg('--mode')) .option('--fan ', 'AC setAll: fan speed auto|low|mid|high', stringArg('--fan')) @@ -65,6 +69,10 @@ Examples: commandArg: string | undefined, options: { name?: string; + nameStrategy?: string; + nameType?: string; + nameCategory?: 'physical' | 'ir'; + nameRoom?: string; temp?: string; mode?: string; fan?: string; power?: string; position?: string; direction?: string; angle?: string; channel?: string; yes?: boolean; @@ -82,7 +90,12 @@ Examples: effectiveDeviceIdArg = undefined; } - deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name); + deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name, { + strategy: (options.nameStrategy as NameResolveStrategy | undefined) ?? 'require-unique', + type: options.nameType, + category: options.nameCategory, + room: options.nameRoom, + }); if (!effectiveCommand) throw new UsageError('A command argument is required (setAll, setPosition, setMode).'); command = effectiveCommand; diff --git a/src/commands/explain.ts b/src/commands/explain.ts index db0eb0c..bbbe8b6 100644 --- a/src/commands/explain.ts +++ b/src/commands/explain.ts @@ -16,6 +16,7 @@ interface ExplainResult { name: string; role: string | null; readOnly: boolean; + catalogNote?: string; location?: { family?: string; room?: string }; liveStatus?: Record; commands: Array<{ @@ -59,7 +60,7 @@ Examples: const wantLive = options.live !== false; const desc: DescribeResult = await describeDevice(deviceId, { live: wantLive }); - const warnings: string[] = []; + const warnings: string[] = [...(desc.warnings ?? [])]; if (desc.isPhysical && !(desc.device as Device).enableCloudService) { warnings.push('Cloud service disabled on this device — commands will fail.'); } @@ -106,6 +107,7 @@ Examples: name: deviceName(desc.device), role: desc.catalog?.role ?? null, readOnly: desc.catalog?.readOnly ?? false, + ...(desc.catalogNote ? { catalogNote: desc.catalogNote } : {}), location, liveStatus, commands, @@ -133,6 +135,9 @@ function printHuman(r: ExplainResult): void { const loc = [r.location?.family, r.location?.room].filter(Boolean).join(' / '); console.log(`location: ${loc}`); } + if (r.catalogNote) { + console.log(`catalog: ${r.catalogNote}`); + } if (r.warnings.length) { console.log('warnings:'); for (const w of r.warnings) console.log(` ! ${w}`); diff --git a/src/commands/health.ts b/src/commands/health.ts index 87839e8..378e821 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -6,6 +6,37 @@ import { intArg } from '../utils/arg-parsers.js'; const HEALTHZ_SCHEMA_VERSION = '1.1'; +function runHealthCheck(opts: { prometheus?: boolean; auditLog?: string }): void { + const report = getHealthReport(opts.auditLog); + if (opts.prometheus) { + process.stdout.write(toPrometheusText(report)); + return; + } + if (isJsonMode()) { + printJson(report); + return; + } + const statusEmoji = report.overall === 'ok' ? '✓' : report.overall === 'degraded' ? '⚠' : '✗'; + console.log(`${statusEmoji} overall: ${report.overall} (${report.generatedAt})`); + console.log(''); + printTable( + ['Component', 'Status', 'Detail'], + [ + ['quota', report.quota.status, + `${report.quota.used}/${report.quota.limit} (${report.quota.percentUsed}% used, ${report.quota.remaining} remaining)`], + ['audit', report.audit.status, + report.audit.present + ? `${report.audit.recentErrors}/${report.audit.recentTotal} errors in 24h (${report.audit.errorRatePercent}%)` + : 'log not present'], + ['circuit', report.circuit.status, + `${report.circuit.name}: ${report.circuit.state} (failures: ${report.circuit.failures})`], + ['process', 'ok', + `pid ${report.process.pid} · uptime ${report.process.uptimeSeconds}s · mem ${report.process.memoryMb}MB`], + ], + ); + if (report.overall !== 'ok') process.exit(1); +} + /** * Create an HTTP request handler for the health endpoints. Exposed separately * so integration tests can call it directly without binding a port. @@ -30,44 +61,28 @@ export function createHealthHandler(auditLogPath?: string): http.RequestListener } export function registerHealthCommand(program: Command): void { + // Check options are declared on the root `health` command only, so that + // `switchbot health --prometheus` (the documented fallback form) parses + // the same flags as `switchbot health check --prometheus`. The subcommand + // picks the values up via `cmd.optsWithGlobals()`. Declaring options on + // BOTH parent and child causes commander v12 to route parsing to the + // parent and leave the child's action with empty opts — don't go that + // route. const health = program .command('health') - .description('Report process health: quota, audit error rate, circuit breaker state.'); + .description('Report process health: quota, audit error rate, circuit breaker state.') + .option('--prometheus', 'Emit Prometheus text format.') + .option('--audit-log ', 'Audit log path (default: ~/.switchbot/audit.log).'); + + health.action((opts: { prometheus?: boolean; auditLog?: string }) => { + runHealthCheck(opts); + }); health .command('check') .description('Print a one-shot health report.') - .option('--prometheus', 'Emit Prometheus text format.') - .option('--audit-log ', 'Audit log path (default: ~/.switchbot/audit.log).') - .action((opts: { prometheus?: boolean; auditLog?: string }) => { - const report = getHealthReport(opts.auditLog); - if (opts.prometheus) { - process.stdout.write(toPrometheusText(report)); - return; - } - if (isJsonMode()) { - printJson(report); - return; - } - const statusEmoji = report.overall === 'ok' ? '✓' : report.overall === 'degraded' ? '⚠' : '✗'; - console.log(`${statusEmoji} overall: ${report.overall} (${report.generatedAt})`); - console.log(''); - printTable( - ['Component', 'Status', 'Detail'], - [ - ['quota', report.quota.status, - `${report.quota.used}/${report.quota.limit} (${report.quota.percentUsed}% used, ${report.quota.remaining} remaining)`], - ['audit', report.audit.status, - report.audit.present - ? `${report.audit.recentErrors}/${report.audit.recentTotal} errors in 24h (${report.audit.errorRatePercent}%)` - : 'log not present'], - ['circuit', report.circuit.status, - `${report.circuit.name}: ${report.circuit.state} (failures: ${report.circuit.failures})`], - ['process', 'ok', - `pid ${report.process.pid} · uptime ${report.process.uptimeSeconds}s · mem ${report.process.memoryMb}MB`], - ], - ); - if (report.overall !== 'ok') process.exit(1); + .action((_opts: Record, cmd: Command) => { + runHealthCheck(cmd.optsWithGlobals() as { prometheus?: boolean; auditLog?: string }); }); // switchbot health serve [--port ] @@ -76,7 +91,6 @@ export function registerHealthCommand(program: Command): void { .description('Start an HTTP server exposing /healthz (JSON) and /metrics (Prometheus).') .option('--port ', 'Port to listen on.', intArg('--port'), '3100') .option('--host ', 'Bind address.', '127.0.0.1') - .option('--audit-log ', 'Audit log path.') .addHelpText('after', ` Endpoints: GET /healthz JSON health report (HTTP 200 ok/degraded, 503 when circuit is open). @@ -86,7 +100,10 @@ Example: $ switchbot health serve --port 3100 $ curl http://127.0.0.1:3100/healthz `) - .action((opts: { port: string; host: string; auditLog?: string }) => { + .action((_opts: Record, cmd: Command) => { + // --audit-log is inherited from the root `health` command; --port / --host + // are serve-local. optsWithGlobals() merges both. + const opts = cmd.optsWithGlobals() as { port: string; host: string; auditLog?: string }; const port = parseInt(opts.port, 10); const handler = createHealthHandler(opts.auditLog); const server = http.createServer(handler); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 59d7bbc..69ab563 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -4,7 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { z } from 'zod'; import { intArg, stringArg } from '../utils/arg-parsers.js'; -import { handleError, isJsonMode, buildErrorPayload, exitWithError, type ErrorPayload, type ErrorSubKind } from '../utils/output.js'; +import { handleError, isJsonMode, printJson, buildErrorPayload, exitWithError, type ErrorPayload, type ErrorSubKind } from '../utils/output.js'; import { VERSION } from '../version.js'; import { fetchDeviceList, @@ -48,7 +48,7 @@ import { PolicyFileNotFoundError, PolicyYamlParseError, } from '../policy/load.js'; -import { validateLoadedPolicy } from '../policy/validate.js'; +import { validateLoadedPolicy, validateLoadedPolicyAgainstInventory } from '../policy/validate.js'; import { CURRENT_POLICY_SCHEMA_VERSION, SUPPORTED_POLICY_SCHEMA_VERSIONS, @@ -136,7 +136,7 @@ interface AuditFilterOptions { kinds?: AuditEntry['kind'][]; deviceId?: string; ruleName?: string; - results?: Array<'ok' | 'error'>; + results?: Array<'ok' | 'error' | 'dry-run'>; } function resolveAuditRange(opts: Pick): { @@ -1113,17 +1113,21 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, { title: 'Validate a policy.yaml file', description: - 'Check a policy file against the embedded JSON Schema (supports v0.1 and v0.2). ' + - 'Returns the validation result with per-error line/col and a hint. ' + + 'Check a policy file against the embedded JSON Schema, offline command/device semantics, and local safety guards. ' + + 'By default this stays offline; set live=true to resolve aliases and rule targets against the current account inventory. ' + + 'It still does not verify commands against live capabilities, current firmware, or other runtime-only device behavior. ' + 'When no path is given, reads the resolved default (${SWITCHBOT_POLICY_PATH} or ~/.config/openclaw/switchbot/policy.yaml). ' + 'Use before relying on aliases/quiet_hours/confirmations so the agent never acts on a broken policy.', _meta: { agentSafetyTier: 'read' }, inputSchema: z.object({ path: z.string().optional().describe('Optional policy file path; defaults to the resolved default path'), + live: z.boolean().optional().describe('When true, also resolve aliases and rule targets against the current account inventory'), }).strict(), outputSchema: { policyPath: z.string(), schemaVersion: z.string(), + validationScope: z.string(), + limitations: z.array(z.string()), present: z.boolean().describe('false when the file does not exist'), valid: z.boolean().nullable().describe('null when present=false'), errors: z.array(z.object({ @@ -1137,14 +1141,25 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, })).describe('Empty when valid or when the file is missing'), }, }, - async ({ path: pathArg }) => { + async ({ path: pathArg, live }) => { const policyPath = resolvePolicyPath({ flag: pathArg }); try { const loaded = loadPolicyFile(policyPath); - const result = validateLoadedPolicy(loaded); + let result = validateLoadedPolicy(loaded); + if (live) { + if (!tryLoadConfig()) { + return mcpError('runtime', 151, 'policy_validate live=true requires configured SwitchBot credentials.', { + hint: "Run 'switchbot config set-token' first, or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.", + }); + } + const inventory = await fetchDeviceList(undefined, { bypassCache: true }); + result = validateLoadedPolicyAgainstInventory(loaded, inventory); + } const structured = { policyPath: result.policyPath, schemaVersion: result.schemaVersion, + validationScope: result.validationScope, + limitations: result.limitations, present: true, valid: result.valid, errors: result.errors, @@ -1158,6 +1173,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, const structured = { policyPath, schemaVersion: CURRENT_POLICY_SCHEMA_VERSION, + validationScope: 'schema+offline-semantics' as const, + limitations: [ + 'Does not resolve aliases against the live device inventory.', + 'Does not verify commands against the real target device, live capabilities, or current firmware.', + ], present: false, valid: null, errors: [], @@ -1171,6 +1191,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, const structured = { policyPath, schemaVersion: CURRENT_POLICY_SCHEMA_VERSION, + validationScope: 'schema+offline-semantics' as const, + limitations: [ + 'Does not resolve aliases against the live device inventory.', + 'Does not verify commands against the real target device, live capabilities, or current firmware.', + ], present: true, valid: false, errors: err.yamlErrors.map((e) => ({ @@ -1243,11 +1268,11 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, { title: 'Migrate a policy file to the latest supported schema', description: - 'Upgrades the policy file\'s schema version in place while preserving comments. ' + + 'Rewrites a policy file between schema versions this CLI still supports while preserving comments. ' + 'Safe by default: if the migrated document would fail schema validation, the file is NOT rewritten ' + 'and the tool returns status="precheck-failed" with the list of errors. ' + 'Pass dryRun=true to preview without touching the file. ' + - 'Currently the only supported upgrade path is v0.1 → v0.2.', + 'This release only supports v0.2, so legacy v0.1 files are reported as unsupported rather than migrated.', _meta: { agentSafetyTier: 'action' }, inputSchema: z.object({ path: z.string().optional().describe('Optional policy file path; defaults to the resolved default path'), @@ -1323,10 +1348,13 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, } if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion as PolicySchemaVersion)) { + const isLegacy = fileVersion === '0.1'; const structured = { ...base, status: 'unsupported' as const, - message: `policy schema v${fileVersion} is not supported (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(', ')})`, + message: isLegacy + ? `policy schema v${fileVersion} is legacy and cannot be migrated by this CLI` + : `policy schema v${fileVersion} is not supported (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(', ')})`, }; return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], @@ -1731,7 +1759,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, kinds: z.array(z.enum(['command', 'rule-fire', 'rule-fire-dry', 'rule-throttled', 'rule-webhook-rejected'])).optional().describe('Filter by entry kind.'), device_id: z.string().optional().describe('Filter by deviceId.'), rule_name: z.string().optional().describe('Filter by rule.name (rule-engine entries).'), - results: z.array(z.enum(['ok', 'error'])).optional().describe('Filter by execution result.'), + results: z.array(z.enum(['ok', 'error', 'dry-run'])).optional().describe('Filter by execution result.'), limit: z.number().int().min(1).max(5000).optional().describe('Max entries returned from the tail of the filtered set (default 200).'), }).strict(), outputSchema: { @@ -1788,7 +1816,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, kinds: z.array(z.enum(['command', 'rule-fire', 'rule-fire-dry', 'rule-throttled', 'rule-webhook-rejected'])).optional().describe('Filter by entry kind.'), device_id: z.string().optional().describe('Filter by deviceId.'), rule_name: z.string().optional().describe('Filter by rule.name (rule-engine entries).'), - results: z.array(z.enum(['ok', 'error'])).optional().describe('Filter by execution result.'), + results: z.array(z.enum(['ok', 'error', 'dry-run'])).optional().describe('Filter by execution result.'), top_n: z.number().int().min(1).max(100).optional().describe('Number of top device/rule rows to return (default 10).'), }).strict(), outputSchema: { @@ -1961,6 +1989,30 @@ export function listRegisteredTools(server: McpServer): string[] { return Object.keys(internal._registeredTools).sort(); } +function listRegisteredResources(): string[] { + return ['switchbot://events']; +} + +function printMcpToolDirectory(): void { + const server = createSwitchBotMcpServer(); + const tools = listRegisteredTools(server).map((name) => ({ name })); + const resources = listRegisteredResources().map((uri) => ({ uri })); + if (isJsonMode()) { + printJson({ tools, resources }); + return; + } + console.log('Tools:'); + for (const tool of tools) { + console.log(` ${tool.name}`); + } + console.log(''); + console.log('Resources:'); + for (const resource of resources) { + console.log(` ${resource.uri}`); + } + console.log(`\nTotal: ${tools.length} tool(s), ${resources.length} resource(s)`); +} + export function registerMcpCommand(program: Command): void { const mcp = program .command('mcp') @@ -1978,9 +2030,10 @@ export function registerMcpCommand(program: Command): void { - get_device_history fetch raw JSONL history records for a device - query_device_history filter + page history records with field/time predicates - aggregate_device_history compute count/min/max/avg/sum/p50/p95 over history records - - policy_validate check policy.yaml against the embedded schema (v0.1 / v0.2) + - policy_validate check policy.yaml against the embedded schema + offline semantics + (set live=true to resolve aliases and rule targets against current inventory) - policy_new scaffold a starter policy.yaml (action — confirm first) - - policy_migrate upgrade policy.yaml to the latest schema (action — preserves comments) + - policy_migrate rewrite policy.yaml between currently supported schemas (action — preserves comments) - policy_diff compare two policy files with structural + line diff output - plan_suggest draft a Plan JSON from intent + device IDs (heuristic, no LLM) - plan_run validate + execute a Plan JSON document @@ -2013,6 +2066,16 @@ Inspect locally: $ npx @modelcontextprotocol/inspector switchbot mcp serve `); + mcp + .command('tools') + .description('Print the registered MCP tools in human or JSON form') + .action(() => printMcpToolDirectory()); + + mcp + .command('list-tools') + .description('Alias of `mcp tools`') + .action(() => printMcpToolDirectory()); + mcp .command('serve') .description('Start the MCP server on stdio (default) or HTTP (--port)') diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 56c0ce2..70b9f63 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -2,12 +2,12 @@ import { Command } from 'commander'; import fs from 'node:fs'; import readline from 'node:readline'; import { randomUUID } from 'node:crypto'; -import { printJson, isJsonMode, handleError, exitWithError } from '../utils/output.js'; +import { printJson, isJsonMode, handleError, exitWithError, UsageError } from '../utils/output.js'; import { executeCommand, isDestructiveCommand } from '../lib/devices.js'; import { executeScene } from '../lib/scenes.js'; import { getCachedDevice } from '../devices/cache.js'; import { resolveDeviceId } from '../utils/name-resolver.js'; -import { COMMAND_KEYWORDS } from '../lib/command-keywords.js'; +import { containsCjk, inferCommandFromIntent } from '../lib/command-keywords.js'; import { savePlanRecord, loadPlanRecord, @@ -218,14 +218,13 @@ export interface SuggestResult { export function suggestPlan(opts: SuggestOptions): SuggestResult { const warnings: string[] = []; - let command = ''; - for (const k of COMMAND_KEYWORDS) { - if (k.pattern.test(opts.intent)) { - command = k.command; - break; - } - } + let command = inferCommandFromIntent(opts.intent) ?? ''; if (!command) { + if (containsCjk(opts.intent)) { + throw new UsageError( + `Intent "${opts.intent}" contains non-English command text that this heuristic cannot safely infer. Use explicit English command words (turnOn/turnOff/open/close/lock/unlock/press/pause) or author the plan manually.`, + ); + } command = 'turnOn'; warnings.push( `Could not infer command from intent "${opts.intent}" — defaulted to "turnOn". Edit the generated plan to set the correct command.`, @@ -281,11 +280,11 @@ async function promptApproval(stepIdx: number, command: string, deviceId: string interface PlanRunResult { plan: Plan; results: Array< - | { step: number; type: 'command'; deviceId: string; command: string; status: 'ok' | 'error' | 'skipped'; error?: string; decision?: 'approved' | 'rejected' } + | { step: number; type: 'command'; deviceId: string; command: string; status: 'ok' | 'error' | 'skipped' | 'dry-run'; error?: string; decision?: 'approved' | 'rejected' } | { step: number; type: 'scene'; sceneId: string; status: 'ok' | 'error' | 'skipped'; error?: string } | { step: number; type: 'wait'; ms: number; status: 'ok' | 'skipped' } >; - summary: { total: number; ok: number; error: number; skipped: number }; + summary: { total: number; ok: number; error: number; skipped: number; dryRun: number }; } /** Shared plan-execution core used by both `plan run` and `plan execute`. */ @@ -297,7 +296,7 @@ async function executePlanSteps( const out: PlanRunResult = { plan, results: [], - summary: { total: plan.steps.length, ok: 0, error: 0, skipped: 0 }, + summary: { total: plan.steps.length, ok: 0, error: 0, skipped: 0, dryRun: 0 }, }; for (let i = 0; i < plan.steps.length; i++) { const step = plan.steps[i]; @@ -356,8 +355,8 @@ async function executePlanSteps( if (!isJsonMode()) console.log(` ${idx}. ✓ ${step.command} on ${resolvedDeviceId}`); } catch (err) { if (err instanceof Error && err.name === 'DryRunSignal') { - out.results.push({ step: idx, type: 'command', deviceId: resolvedDeviceId, command: step.command, status: 'ok' }); - out.summary.ok++; + out.results.push({ step: idx, type: 'command', deviceId: resolvedDeviceId, command: step.command, status: 'dry-run' }); + out.summary.dryRun++; if (!isJsonMode()) console.log(` ${idx}. ◦ dry-run ${step.command} on ${resolvedDeviceId}`); continue; } @@ -462,24 +461,28 @@ against the live API without executing any mutations. ) .option('--out ', 'Write plan JSON to file instead of stdout') .action((opts: { intent: string; device: string[]; out?: string }) => { - if (opts.device.length === 0) { - console.error('error: at least one --device is required'); - process.exit(1); - } - const devices = opts.device.map((ref) => { - const cached = getCachedDevice(ref); - return { id: ref, name: cached?.name, type: cached?.type }; - }); - const { plan: suggested, warnings } = suggestPlan({ intent: opts.intent, devices }); - for (const w of warnings) process.stderr.write(`warning: ${w}\n`); - const json = JSON.stringify(suggested, null, 2); - if (opts.out) { - fs.writeFileSync(opts.out, json + '\n', 'utf8'); - if (!isJsonMode()) console.log(`✓ plan written to ${opts.out}`); - } else if (isJsonMode()) { - printJson({ plan: suggested, warnings }); - } else { - console.log(json); + try { + if (opts.device.length === 0) { + console.error('error: at least one --device is required'); + process.exit(1); + } + const devices = opts.device.map((ref) => { + const cached = getCachedDevice(ref); + return { id: ref, name: cached?.name, type: cached?.type }; + }); + const { plan: suggested, warnings } = suggestPlan({ intent: opts.intent, devices }); + for (const w of warnings) process.stderr.write(`warning: ${w}\n`); + const json = JSON.stringify(suggested, null, 2); + if (opts.out) { + fs.writeFileSync(opts.out, json + '\n', 'utf8'); + if (!isJsonMode()) console.log(`✓ plan written to ${opts.out}`); + } else if (isJsonMode()) { + printJson({ plan: suggested, warnings }); + } else { + console.log(json); + } + } catch (err) { + handleError(err); } }); diff --git a/src/commands/policy.ts b/src/commands/policy.ts index fd4b09d..ae6f1f7 100644 --- a/src/commands/policy.ts +++ b/src/commands/policy.ts @@ -11,7 +11,7 @@ import { PolicyFileNotFoundError, PolicyYamlParseError, } from '../policy/load.js'; -import { validateLoadedPolicy } from '../policy/validate.js'; +import { validateLoadedPolicy, validateLoadedPolicyAgainstInventory } from '../policy/validate.js'; import { formatValidationResult } from '../policy/format.js'; import { CURRENT_POLICY_SCHEMA_VERSION, @@ -21,6 +21,8 @@ import { import { planMigration, PolicyMigrationError } from '../policy/migrate.js'; import { addRuleToPolicyFile, AddRuleError } from '../policy/add-rule.js'; import { diffPolicyValues } from '../policy/diff.js'; +import { tryLoadConfig } from '../config.js'; +import { fetchDeviceList } from '../lib/devices.js'; // Latest version the CLI knows how to migrate *to*. // CURRENT_POLICY_SCHEMA_VERSION is the version `policy new` emits by default. @@ -88,6 +90,20 @@ function exitPolicyError(kind: 'file-not-found' | 'yaml-parse' | 'internal', mes process.exit(code); } +function validationScopeLine(scope: string): string { + if (scope === 'schema+offline-semantics+live-inventory') { + return 'Validation scope: schema + offline semantics + live inventory checks + local safety guards.'; + } + return 'Validation scope: schema + offline semantics + local safety guards.'; +} + +function validationNotCheckedLine(scope: string): string { + if (scope === 'schema+offline-semantics+live-inventory') { + return 'Not checked: live capabilities, current firmware, and runtime-only device behavior.'; + } + return 'Not checked: alias targets against live devices; command support on the real target device, live capabilities, or current firmware.'; +} + function summarizeChangeValue(v: unknown): string { if (v === null) return 'null'; if (v === undefined) return 'undefined'; @@ -110,10 +126,11 @@ audit log path, and which actions always or never need confirmation. Default location: ${DEFAULT_POLICY_PATH} Subcommands: - validate [path] Check a policy file against the embedded schema + validate [path] Check a policy file against the embedded schema + offline semantics + (add --live to resolve aliases and rule targets against current inventory) new [path] Write a starter policy to the default location (or a given path) - migrate [path] Upgrade a policy file to the latest supported schema - (v${CURRENT_POLICY_SCHEMA_VERSION} → v${LATEST_SUPPORTED_VERSION} today; no-op if already current) + migrate [path] Rewrite a policy file between schema versions this CLI still supports + (this build only supports v${CURRENT_POLICY_SCHEMA_VERSION}; legacy v0.1 files cannot be migrated here) diff Compare two policy files and print structural + line diff add-rule Append a rule YAML (from stdin) into automation.rules[] @@ -145,10 +162,13 @@ Examples: policy .command('validate [path]') - .description(`Validate a policy.yaml against the embedded v${CURRENT_POLICY_SCHEMA_VERSION} schema`) + .description( + `Validate a policy.yaml against the embedded v${CURRENT_POLICY_SCHEMA_VERSION} schema, offline semantics, and local safety guards`, + ) + .option('--live', 'Also resolve aliases and rule target devices against the current account inventory (1 API call)') .option('--no-color', 'disable ANSI color in human output') .option('--no-snippet', 'omit the source-line + caret preview') - .action((pathArg: string | undefined, opts: { color?: boolean; snippet?: boolean }) => { + .action(async (pathArg: string | undefined, opts: { color?: boolean; snippet?: boolean; live?: boolean }) => { const policyPath = resolvePolicyPath({ flag: pathArg }); let loaded; @@ -170,7 +190,22 @@ Examples: exitPolicyError('internal', `unexpected error loading policy: ${String(err)}`); } - const result = validateLoadedPolicy(loaded); + let result = validateLoadedPolicy(loaded); + if (opts.live) { + if (!tryLoadConfig()) { + exitWithError({ + code: 1, + kind: 'runtime', + message: 'policy validate --live requires configured SwitchBot credentials.', + extra: { + hint: "Run 'switchbot config set-token' first, or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.", + }, + }); + return; + } + const inventory = await fetchDeviceList(undefined, { bypassCache: true }); + result = validateLoadedPolicyAgainstInventory(loaded, inventory); + } if (isJsonMode()) { printJson(result); @@ -183,15 +218,19 @@ Examples: noSnippet: opts.snippet === false, }), ); + console.log(''); + console.log(validationScopeLine(result.validationScope)); + console.log(validationNotCheckedLine(result.validationScope)); process.exit(result.valid ? 0 : 1); }); policy .command('new [path]') .description('Write a starter policy.yaml (fails if the file exists unless --force)') + .option('-o, --output ', 'write the starter policy to this path (alias of positional [path])') .option('-f, --force', 'overwrite an existing policy file') - .action((pathArg: string | undefined, opts: { force?: boolean }) => { - const policyPath = resolvePolicyPath({ flag: pathArg }); + .action((pathArg: string | undefined, opts: { force?: boolean; output?: string }) => { + const policyPath = resolvePolicyPath({ flag: opts.output ?? pathArg }); const force = opts.force === true; let result: ScaffoldPolicyResult; @@ -225,7 +264,7 @@ Examples: policy .command('migrate [path]') - .description(`Upgrade a policy file to the latest supported schema (currently v${LATEST_SUPPORTED_VERSION})`) + .description(`Rewrite a policy file between schema versions supported by this CLI (currently only v${LATEST_SUPPORTED_VERSION})`) .option('--dry-run', 'show what would change without writing the file') .option( '--to ', @@ -273,8 +312,13 @@ Examples: } if (!SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(fileVersion as PolicySchemaVersion)) { - const message = `policy schema v${fileVersion} is not supported by this CLI (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(', ')})`; - const hint = 'upgrade @switchbot/openapi-cli, or downgrade the policy file to a supported version'; + const isLegacy = fileVersion === '0.1'; + const message = isLegacy + ? `policy schema v${fileVersion} is legacy and cannot be migrated by this CLI` + : `policy schema v${fileVersion} is not supported by this CLI (supports: ${SUPPORTED_POLICY_SCHEMA_VERSIONS.join(', ')})`; + const hint = isLegacy + ? 'use @switchbot/openapi-cli <=2.15 to migrate v0.1 first, then upgrade back to this release' + : 'upgrade @switchbot/openapi-cli, or downgrade the policy file to a supported version'; if (isJsonMode()) emitJsonError({ code: 6, kind: 'unsupported-version', ...basePayload, message, hint }); else { diff --git a/src/commands/quota.ts b/src/commands/quota.ts index ac9a58f..4070236 100644 --- a/src/commands/quota.ts +++ b/src/commands/quota.ts @@ -7,6 +7,48 @@ import { todayUsage, } from '../utils/quota.js'; +function runQuotaStatus(): void { + const usage = todayUsage(); + const history = loadQuota(); + + if (isJsonMode()) { + printJson({ + today: { + date: usage.date, + total: usage.total, + remaining: usage.remaining, + dailyLimit: DAILY_QUOTA, + endpoints: usage.endpoints, + }, + history: history.days, + }); + return; + } + + console.log(`Today (${usage.date}):`); + console.log(` Requests used: ${usage.total} / ${DAILY_QUOTA}`); + console.log(` Remaining budget: ${usage.remaining}`); + if (Object.keys(usage.endpoints).length === 0) { + console.log(' (no requests recorded yet)'); + } else { + console.log(' Endpoint breakdown:'); + const entries = Object.entries(usage.endpoints).sort((a, b) => b[1] - a[1]); + for (const [endpoint, count] of entries) { + console.log(` ${endpoint.padEnd(48)} ${count}`); + } + } + + const otherDays = Object.entries(history.days) + .filter(([d]) => d !== usage.date) + .sort((a, b) => b[0].localeCompare(a[0])); + if (otherDays.length > 0) { + console.log('\nRecent history:'); + for (const [date, bucket] of otherDays) { + console.log(` ${date} ${bucket.total}`); + } + } +} + export function registerQuotaCommand(program: Command): void { const quota = program .command('quota') @@ -28,50 +70,16 @@ Examples: $ switchbot quota reset `); + quota.action(() => { + runQuotaStatus(); + }); + quota .command('status') .alias('show') .description("Show today's usage and the last 7 days (alias: show)") .action(() => { - const usage = todayUsage(); - const history = loadQuota(); - - if (isJsonMode()) { - printJson({ - today: { - date: usage.date, - total: usage.total, - remaining: usage.remaining, - dailyLimit: DAILY_QUOTA, - endpoints: usage.endpoints, - }, - history: history.days, - }); - return; - } - - console.log(`Today (${usage.date}):`); - console.log(` Requests used: ${usage.total} / ${DAILY_QUOTA}`); - console.log(` Remaining budget: ${usage.remaining}`); - if (Object.keys(usage.endpoints).length === 0) { - console.log(' (no requests recorded yet)'); - } else { - console.log(' Endpoint breakdown:'); - const entries = Object.entries(usage.endpoints).sort((a, b) => b[1] - a[1]); - for (const [endpoint, count] of entries) { - console.log(` ${endpoint.padEnd(48)} ${count}`); - } - } - - const otherDays = Object.entries(history.days) - .filter(([d]) => d !== usage.date) - .sort((a, b) => b[0].localeCompare(a[0])); - if (otherDays.length > 0) { - console.log('\nRecent history:'); - for (const [date, bucket] of otherDays) { - console.log(` ${date} ${bucket.total}`); - } - } + runQuotaStatus(); }); quota diff --git a/src/commands/rules.ts b/src/commands/rules.ts index cd3b5b2..d5b9bcc 100644 --- a/src/commands/rules.ts +++ b/src/commands/rules.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { isJsonMode, printJson, exitWithError, printTable } from '../utils/output.js'; +import { isJsonMode, printJson, exitWithError, printTable, handleError } from '../utils/output.js'; import { loadPolicyFile, resolvePolicyPath, @@ -646,29 +646,33 @@ function registerSuggest(rules: Command): void { webhookPath?: string; out?: string; }) => { - const trigger = opts.trigger as 'mqtt' | 'cron' | 'webhook' | undefined; - const days = opts.days ? opts.days.split(',').map((d) => d.trim()) : undefined; - const devices = opts.device.map((ref) => { - const cached = getCachedDevice(ref); - return { id: ref, name: cached?.name, type: cached?.type }; - }); - const { rule, ruleYaml, warnings } = suggestRule({ - intent: opts.intent, - trigger, - devices, - event: opts.event, - schedule: opts.schedule, - days, - webhookPath: opts.webhookPath, - }); - for (const w of warnings) process.stderr.write(`warning: ${w}\n`); - if (opts.out) { - fs.writeFileSync(opts.out, ruleYaml, 'utf8'); - if (!isJsonMode()) console.log(`✓ rule YAML written to ${opts.out}`); - } else if (isJsonMode()) { - printJson({ rule, rule_yaml: ruleYaml, warnings }); - } else { - process.stdout.write(ruleYaml); + try { + const trigger = opts.trigger as 'mqtt' | 'cron' | 'webhook' | undefined; + const days = opts.days ? opts.days.split(',').map((d) => d.trim()) : undefined; + const devices = opts.device.map((ref) => { + const cached = getCachedDevice(ref); + return { id: ref, name: cached?.name, type: cached?.type }; + }); + const { rule, ruleYaml, warnings } = suggestRule({ + intent: opts.intent, + trigger, + devices, + event: opts.event, + schedule: opts.schedule, + days, + webhookPath: opts.webhookPath, + }); + for (const w of warnings) process.stderr.write(`warning: ${w}\n`); + if (opts.out) { + fs.writeFileSync(opts.out, ruleYaml, 'utf8'); + if (!isJsonMode()) console.log(`✓ rule YAML written to ${opts.out}`); + } else if (isJsonMode()) { + printJson({ rule, rule_yaml: ruleYaml, warnings }); + } else { + process.stdout.write(ruleYaml); + } + } catch (err) { + handleError(err); } }, ); diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 8fc3c7c..ea0e774 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -103,16 +103,125 @@ function projectFields>(entry: T, fields: stri return out; } +function runSchemaExport(options: { type?: string; types?: string; role?: string; category?: string; compact?: boolean; used?: boolean; project?: string; capabilities?: boolean }): Promise | void { + const catalog = getEffectiveCatalog(); + let filtered = catalog; + + if (options.type) { + const q = options.type.toLowerCase(); + filtered = filtered.filter((e) => + e.type.toLowerCase() === q || + (e.aliases ?? []).some((a) => a.toLowerCase() === q), + ); + } + if (options.types) { + const set = new Set(options.types.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)); + filtered = filtered.filter((e) => + set.has(e.type.toLowerCase()) || + (e.aliases ?? []).some((a) => set.has(a.toLowerCase())), + ); + } + if (options.role) { + const q = options.role.toLowerCase(); + filtered = filtered.filter((e) => (e.role ?? 'other') === q); + } + if (options.category) { + const q = options.category.toLowerCase(); + filtered = filtered.filter((e) => e.category === q); + } + if (options.used) { + const cache = loadCache(); + if (cache) { + const usedTypes = new Set( + Object.values(cache.devices).map((d) => d.type.toLowerCase()), + ); + filtered = filtered.filter((e) => + usedTypes.has(e.type.toLowerCase()) || + (e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase())), + ); + } else { + filtered = []; + } + } + + const mapped = options.compact + ? filtered.map(toCompactEntry) + : filtered.map(toSchemaEntry); + + const projected = options.project + ? mapped.map((e) => + projectFields(e as unknown as Record, options.project!.split(',').map((s) => s.trim()).filter(Boolean)), + ) + : mapped; + + const finish = (finalTypes: Array>) => { + const payload: Record = { + version: '1.0', + types: finalTypes, + }; + if (!options.compact) { + payload.generatedAt = new Date().toISOString(); + payload.resources = RESOURCE_CATALOG; + payload.cliAddedFields = [ + { + field: '_fetchedAt', + appliesTo: ['devices status', 'devices describe'], + type: 'string (ISO-8601)', + description: + 'CLI-synthesized timestamp indicating when this status response was fetched or served from the cache. Not part of the upstream SwitchBot API.', + }, + { + field: 'replayed', + appliesTo: ['devices command (with --idempotency-key)'], + type: 'boolean', + description: + 'CLI-synthesized flag — true when the response was served from the idempotency cache instead of re-executing the command.', + }, + { + field: 'verification', + appliesTo: ['devices command'], + type: 'object', + description: + 'CLI-synthesized receipt-acknowledgment metadata. For IR devices, verifiable:false signals that no device-side confirmation is possible.', + }, + { + field: 'hints', + appliesTo: ['agent-bootstrap'], + type: 'string[]', + description: + 'CLI-synthesized advisory messages for the calling agent. Always emitted; empty array ([]) means no hints to report — never null and not a disabled-field signal.', + }, + ]; + } + printJson(payload); + }; + + const finalTypes = projected as Array>; + if (options.capabilities) { + return import('./capabilities.js').then(({ COMMAND_META }) => { + const devicesMeta = Object.fromEntries( + Object.entries(COMMAND_META).filter(([k]) => k.startsWith('devices ')), + ); + finish(finalTypes.map((e) => ({ ...e, commandsMeta: devicesMeta }))); + }); + } + + finish(finalTypes); +} + export function registerSchemaCommand(program: Command): void { const ROLES = ['lighting', 'security', 'sensor', 'climate', 'media', 'cleaning', 'curtain', 'fan', 'power', 'hub', 'other'] as const; const CATEGORIES = ['physical', 'ir'] as const; + + // Export options are declared on the root `schema` command only, so that + // `switchbot schema --type Bot` (the documented fallback form) parses the + // same flags as `switchbot schema export --type Bot`. The subcommand picks + // the values up via `cmd.optsWithGlobals()`. Declaring options on BOTH + // parent and child causes commander v12 to route parsing to the parent and + // leave the child's action with empty opts — don't go that route. const schema = program .command('schema') - .description('Export the SwitchBot device catalog as structured JSON (for AI agent prompts / tooling)'); - - schema - .command('export') - .description('Print the catalog as structured JSON (one object per type)') + .description('Export the SwitchBot device catalog as structured JSON (for AI agent prompts / tooling)') .option('--type ', 'Restrict to a single device type (e.g. "Strip Light")', stringArg('--type')) .option('--types ', 'Restrict to multiple device types (comma-separated)', stringArg('--types')) .option('--role ', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other', enumArg('--role', ROLES)) @@ -120,11 +229,21 @@ export function registerSchemaCommand(program: Command): void { .option('--compact', 'Drop descriptions/aliases/example params — emit ~60% smaller payload. Useful for agent prompts.') .option('--used', 'Restrict to device types present in the local devices cache (run "devices list" first)') .option('--project ', 'Project per-type fields (e.g. --project type,commands,statusFields)', stringArg('--project')) - .option('--capabilities', 'Annotate each device type with CLI command safety metadata (agentSafetyTier, mutating, consumesQuota)') + .option('--capabilities', 'Annotate each device type with CLI command safety metadata (agentSafetyTier, mutating, consumesQuota)'); + + schema.action(async (options: { type?: string; types?: string; role?: string; category?: string; compact?: boolean; used?: boolean; project?: string; capabilities?: boolean }) => { + await runSchemaExport(options); + }); + + schema + .command('export') + .description('Print the catalog as structured JSON (one object per type)') .addHelpText('after', ` Output is always JSON (this command ignores --format). The output is a catalog export — not a formal JSON Schema standard document — suitable for pre-baking LLM prompts or regenerating docs when the catalog changes. +\`statusFields\` are advisory offline hints; actual live status can differ by +firmware and device variant. Size tips: --compact --used Smallest realistic payload for a given account @@ -149,104 +268,9 @@ Examples: $ switchbot schema export --role security --category physical $ switchbot schema export --project type,commands,statusFields `) - .action(async (options: { type?: string; types?: string; role?: string; category?: string; compact?: boolean; used?: boolean; project?: string; capabilities?: boolean }) => { - const catalog = getEffectiveCatalog(); - let filtered = catalog; - - if (options.type) { - const q = options.type.toLowerCase(); - filtered = filtered.filter((e) => - e.type.toLowerCase() === q || - (e.aliases ?? []).some((a) => a.toLowerCase() === q), - ); - } - if (options.types) { - const set = new Set(options.types.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)); - filtered = filtered.filter((e) => - set.has(e.type.toLowerCase()) || - (e.aliases ?? []).some((a) => set.has(a.toLowerCase())), - ); - } - if (options.role) { - const q = options.role.toLowerCase(); - filtered = filtered.filter((e) => (e.role ?? 'other') === q); - } - if (options.category) { - const q = options.category.toLowerCase(); - filtered = filtered.filter((e) => e.category === q); - } - if (options.used) { - const cache = loadCache(); - if (cache) { - const usedTypes = new Set( - Object.values(cache.devices).map((d) => d.type.toLowerCase()), - ); - filtered = filtered.filter((e) => - usedTypes.has(e.type.toLowerCase()) || - (e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase())), - ); - } else { - filtered = []; - } - } - - const mapped = options.compact - ? filtered.map(toCompactEntry) - : filtered.map(toSchemaEntry); - - const projected = options.project - ? mapped.map((e) => - projectFields(e as unknown as Record, options.project!.split(',').map((s) => s.trim()).filter(Boolean)), - ) - : mapped; - - let finalTypes = projected as Array>; - if (options.capabilities) { - const { COMMAND_META } = await import('./capabilities.js'); - const devicesMeta = Object.fromEntries( - Object.entries(COMMAND_META).filter(([k]) => k.startsWith('devices ')), - ); - finalTypes = finalTypes.map((e) => ({ ...e, commandsMeta: devicesMeta })); - } - - const payload: Record = { - version: '1.0', - types: finalTypes, - }; - if (!options.compact) { - payload.generatedAt = new Date().toISOString(); - payload.resources = RESOURCE_CATALOG; - payload.cliAddedFields = [ - { - field: '_fetchedAt', - appliesTo: ['devices status', 'devices describe'], - type: 'string (ISO-8601)', - description: - 'CLI-synthesized timestamp indicating when this status response was fetched or served from the cache. Not part of the upstream SwitchBot API.', - }, - { - field: 'replayed', - appliesTo: ['devices command (with --idempotency-key)'], - type: 'boolean', - description: - 'CLI-synthesized flag — true when the response was served from the idempotency cache instead of re-executing the command.', - }, - { - field: 'verification', - appliesTo: ['devices command'], - type: 'object', - description: - 'CLI-synthesized receipt-acknowledgment metadata. For IR devices, verifiable:false signals that no device-side confirmation is possible.', - }, - { - field: 'hints', - appliesTo: ['agent-bootstrap'], - type: 'string[]', - description: - 'CLI-synthesized advisory messages for the calling agent. Always emitted; empty array ([]) means no hints to report — never null and not a disabled-field signal.', - }, - ]; - } - printJson(payload); + .action(async (_options: Record, cmd: Command) => { + // See comment above: options are declared on the parent, so the + // subcommand reads them via optsWithGlobals() instead of the local opts. + await runSchemaExport(cmd.optsWithGlobals() as { type?: string; types?: string; role?: string; category?: string; compact?: boolean; used?: boolean; project?: string; capabilities?: boolean }); }); } diff --git a/src/commands/status-sync.ts b/src/commands/status-sync.ts index 1efbced..e198280 100644 --- a/src/commands/status-sync.ts +++ b/src/commands/status-sync.ts @@ -3,6 +3,7 @@ import { stringArg } from '../utils/arg-parsers.js'; import { handleError, isJsonMode, printJson } from '../utils/output.js'; import { getStatusSyncStatus, + probeStatusSyncStart, runStatusSyncForeground, startStatusSync, stopStatusSync, @@ -75,12 +76,22 @@ Examples: .option('--topic ', 'MQTT topic filter (default: SwitchBot shadow topic from credential)', stringArg('--topic')) .option('--state-dir ', 'Override the status-sync state directory (or env SWITCHBOT_STATUS_SYNC_HOME)', stringArg('--state-dir')) .option('--force', 'Stop any existing status-sync bridge before starting a new one') + .option('--probe', 'Perform online preflight: fetch MQTT credentials and probe the OpenClaw URL before spawning') .addHelpText( 'after', ` Starts a detached child process that runs: switchbot status-sync run ... +Local preflight before spawning: + - SwitchBot credentials must be configured + - OpenClaw token + model must be present + - OpenClaw URL must parse as http:// or https:// + +Optional online preflight with --probe: + - fetch MQTT credentials from SwitchBot + - perform a short HTTP probe against the OpenClaw URL + State files: state.json process metadata (pid, startedAt, command) stdout.log redirected stdout from the child process @@ -92,15 +103,19 @@ Examples: $ switchbot status-sync start --state-dir ~/.switchbot/custom-status-sync --force `, ) - .action((options: { + .action(async (options: { openclawUrl?: string; openclawToken?: string; openclawModel?: string; topic?: string; stateDir?: string; force?: boolean; + probe?: boolean; }) => { try { + if (options.probe) { + await probeStatusSyncStart(options); + } const status = startStatusSync(options); if (isJsonMode()) { printJson(status); diff --git a/src/commands/upgrade-check.ts b/src/commands/upgrade-check.ts index a39e77c..c3d7315 100644 --- a/src/commands/upgrade-check.ts +++ b/src/commands/upgrade-check.ts @@ -3,6 +3,7 @@ import https from 'node:https'; import { isJsonMode, printJson } from '../utils/output.js'; import chalk from 'chalk'; import { VERSION as currentVersion } from '../version.js'; +import { findBreakingChangeBetween } from '../version-notes.js'; const pkgName = '@switchbot/openapi-cli'; @@ -81,12 +82,19 @@ export function registerUpgradeCheckCommand(program: Command): void { return; } + const breakingNotice = findBreakingChangeBetween(currentVersion, latestVersion); const result = { current: currentVersion, latest: latestVersion, upToDate, updateAvailable: !upToDate, - breakingChange: latestMajor > currentMajor, + breakingChange: latestMajor > currentMajor || breakingNotice !== null, + ...(breakingNotice + ? { + breakingVersion: breakingNotice.version, + breakingSummary: breakingNotice.summary, + } + : {}), installCommand: upToDate ? null : `npm install -g ${pkgName}@${latestVersion}`, }; @@ -99,6 +107,9 @@ export function registerUpgradeCheckCommand(program: Command): void { console.log(`${chalk.green('✓')} You are running the latest version (${currentVersion}).`); } else { console.log(`${chalk.yellow('!')} Update available: ${chalk.bold(currentVersion)} → ${chalk.bold(latestVersion)}`); + if (breakingNotice) { + console.log(chalk.red(` Breaking change in ${breakingNotice.version}: ${breakingNotice.summary}`)); + } console.log(` Run: ${chalk.cyan(`npm install -g ${pkgName}@${latestVersion}`)}`); process.exit(1); } diff --git a/src/commands/watch.ts b/src/commands/watch.ts index dc112e9..ab48129 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -3,7 +3,7 @@ import { printJson, isJsonMode, handleError, UsageError, emitStreamHeader } from import { fetchDeviceStatus } from '../lib/devices.js'; import { getCachedDevice } from '../devices/cache.js'; import { parseDurationToMs, getFields } from '../utils/flags.js'; -import { intArg, durationArg, stringArg } from '../utils/arg-parsers.js'; +import { intArg, durationArg, stringArg, enumArg } from '../utils/arg-parsers.js'; import { createClient } from '../api/client.js'; import { resolveDeviceId } from '../utils/name-resolver.js'; import { resolveFieldList, listAllCanonical } from '../schema/field-aliases.js'; @@ -17,9 +17,12 @@ interface TickEvent { deviceId: string; type?: string; changed: Record; + snapshot?: Record; error?: string; } +const INITIAL_MODES = ['snapshot', 'emit', 'skip'] as const; + function diff( prev: Record | undefined, next: Record, @@ -41,6 +44,12 @@ function formatHumanLine(ev: TickEvent): string { const when = new Date(ev.t).toLocaleTimeString(); const head = `[${when}] ${ev.deviceId}${ev.type ? ` (${ev.type})` : ''}`; if (ev.error) return `${head}: error — ${ev.error}`; + if (ev.snapshot) { + const pairs = Object.entries(ev.snapshot) + .map(([k, v]) => `${k}=${JSON.stringify(v)}`) + .join(', '); + return `${head}: snapshot ${pairs}`; + } const keys = Object.keys(ev.changed); if (keys.length === 0) return `${head}: no changes`; const pairs = keys @@ -84,15 +93,20 @@ export function registerWatchCommand(devices: Command): void { .option('--max ', 'Stop after N ticks (default: run until Ctrl-C)', intArg('--max', { min: 1 })) .option('--for ', 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg('--for')) .option('--include-unchanged', 'Emit a tick even when no field changed') + .option('--initial ', 'How to handle the first poll: snapshot | emit | skip (default: snapshot)', enumArg('--initial', INITIAL_MODES), 'snapshot') .addHelpText( 'after', ` Default output is a human-readable table of field changes per tick; add --json to get one JSON-Lines record per deviceId per tick (the agent-friendly form). -The very first poll emits a seed tick with "from": null for every field, so -the initial state is observable. Subsequent ticks only include fields whose -value changed (unless --include-unchanged is passed). +The first poll is configurable: + --initial=snapshot emit one baseline snapshot event, then only diffs + --initial=emit treat the first poll as null -> value changes + --initial=skip record the baseline silently, then only diffs + +Subsequent ticks only include fields whose value changed (unless +--include-unchanged is passed). Each --json line has the shape: { "t": "", "tick": , "deviceId": "ID", "type": "Bot", @@ -116,6 +130,7 @@ Examples: max?: string; for?: string; includeUnchanged?: boolean; + initial: 'snapshot' | 'emit' | 'skip'; }, ) => { try { @@ -180,7 +195,34 @@ Examples: const cached = getCachedDevice(id); try { const body = await fetchDeviceStatus(id, client); - const changed = diff(prev.get(id), body, fields); + const previous = prev.get(id); + const baseline = fields + ? Object.fromEntries(fields.map((f) => [f, body[f] ?? null])) + : body; + if (!prev.has(id)) { + if (options.initial === 'skip') { + prev.set(id, body); + return; + } + if (options.initial === 'snapshot') { + prev.set(id, body); + const ev: TickEvent = { + t, + tick, + deviceId: id, + type: cached?.type, + changed: {}, + snapshot: baseline, + }; + if (isJsonMode()) { + printJson(ev); + } else { + console.log(formatHumanLine(ev)); + } + return; + } + } + const changed = diff(previous, body, fields); prev.set(id, body); if (Object.keys(changed).length === 0 && !options.includeUnchanged) { return; diff --git a/src/config.ts b/src/config.ts index f425dc9..cb8062b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import { createHash } from 'node:crypto'; import { getConfigPath } from './utils/flags.js'; import { getActiveProfile } from './lib/request-context.js'; import { emitJsonError, isJsonMode } from './utils/output.js'; @@ -270,11 +271,13 @@ export function getConfigSummary(): ConfigSummary { } function maskCredential(token: string): string { - if (token.length <= 8) return '*'.repeat(Math.max(4, token.length)); - return token.slice(0, 4) + '*'.repeat(token.length - 8) + token.slice(-4); + return `**** [sha256:${fingerprintCredential(token)}]`; } function maskSecret(secret: string): string { - if (secret.length <= 4) return '****'; - return secret.slice(0, 2) + '*'.repeat(secret.length - 4) + secret.slice(-2); + return `**** [sha256:${fingerprintCredential(secret)}]`; +} + +function fingerprintCredential(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(-8); } diff --git a/src/devices/cache.ts b/src/devices/cache.ts index 1a4c994..ffcc9bc 100644 --- a/src/devices/cache.ts +++ b/src/devices/cache.ts @@ -149,9 +149,11 @@ export function updateCacheFromDeviceList(body: DeviceListBodyShape): void { const devices: Record = {}; for (const d of body.deviceList) { - if (!d.deviceId || !d.deviceType) continue; + if (!d.deviceId) continue; devices[d.deviceId] = { - type: d.deviceType, + // Some real devices omit deviceType entirely (for example AI accessories). + // Keep them in cache with an empty type string rather than dropping the row. + type: d.deviceType ?? '', name: d.deviceName, category: 'physical', hubDeviceId: d.hubDeviceId, diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index cf08bdc..5ee4fdf 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -523,9 +523,9 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ description: 'Battery-powered temperature and humidity sensor; read-only, no control commands.', role: 'sensor', readOnly: true, - aliases: ['Meter Plus', 'MeterPro', 'MeterPro(CO2)', 'WoIOSensor', 'Hub 2'], + aliases: ['Meter Plus', 'MeterPro', 'MeterPro(CO2)', 'WoIOSensor'], commands: [], - statusFields: ['temperature', 'humidity', 'CO2', 'battery', 'version'], + statusFields: ['temperature', 'humidity', 'battery', 'version'], }, { type: 'Motion Sensor', @@ -555,6 +555,15 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ statusFields: ['battery', 'version', 'status'], }, // Status-only hub-class devices (no control commands) + { + type: 'Hub 2', + category: 'physical', + description: 'Wi-Fi hub with built-in temperature, humidity, and light sensors; bridges BLE devices to the cloud.', + role: 'hub', + readOnly: true, + commands: [], + statusFields: ['version', 'temperature', 'humidity', 'lightLevel'], + }, { type: 'Hub Mini', category: 'physical', @@ -590,7 +599,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ role: 'climate', readOnly: true, commands: [], - statusFields: ['temperature', 'humidity', 'version'], + statusFields: ['battery', 'brightness', 'moveDetected', 'humidity', 'temperature', 'version'], }, { type: 'Wallet Finder Card', diff --git a/src/lib/command-keywords.ts b/src/lib/command-keywords.ts index 5148aa2..cf76ff2 100644 --- a/src/lib/command-keywords.ts +++ b/src/lib/command-keywords.ts @@ -15,3 +15,7 @@ export function inferCommandFromIntent(intent: string): string | undefined { } return undefined; } + +export function containsCjk(intent: string): boolean { + return /[\u3400-\u9FFF]/u.test(intent); +} diff --git a/src/lib/devices.ts b/src/lib/devices.ts index 5e2bc48..063b8c3 100644 --- a/src/lib/devices.ts +++ b/src/lib/devices.ts @@ -1,6 +1,6 @@ import type { AxiosInstance } from 'axios'; import { createClient } from '../api/client.js'; -import { idempotencyCache } from './idempotency.js'; +import { idempotencyCache, fingerprintIdempotencyKey } from './idempotency.js'; import { findCatalogEntry, suggestedActions, @@ -64,6 +64,8 @@ export interface DescribeResult { capabilities: DescribeCapabilities | { liveStatus: Record } | null; source: 'catalog' | 'live' | 'catalog+live' | 'none'; suggestedActions: ReturnType; + catalogNote?: string; + warnings?: string[]; /** For IR remotes: the family/room inherited from their bound Hub. Undefined for physical devices. */ inheritedLocation?: { family?: string; room?: string; roomID?: string }; } @@ -86,13 +88,40 @@ export class CommandValidationError extends Error { } } +function hasDanglingHubReference( + device: Device | InfraredDevice, + isPhysical: boolean, + deviceList: Device[], +): boolean { + if (!isPhysical) return false; + const hubDeviceId = device.hubDeviceId; + if (!hubDeviceId || hubDeviceId === '000000000000' || hubDeviceId === device.deviceId) return false; + return !deviceList.some((d) => d.deviceId === hubDeviceId); +} + +function describeCatalogNote( + deviceId: string, + typeName: string, + isPhysical: boolean, +): string { + if (isPhysical) { + const label = typeName || 'this device type'; + return `No built-in catalog entry for ${label}; raw device metadata is shown. Try \`switchbot devices status ${deviceId}\` for live raw status.`; + } + const label = typeName || 'this IR remote type'; + return `No built-in catalog entry for ${label}; raw device metadata is shown. Use \`switchbot devices command ${deviceId} "" --type customize\` for custom IR buttons.`; +} + /** Fetch the full device + IR remote inventory and refresh the local cache. */ -export async function fetchDeviceList(client?: AxiosInstance): Promise { +export async function fetchDeviceList( + client?: AxiosInstance, + options: { bypassCache?: boolean } = {}, +): Promise { // TTL-gated read: when the on-disk cache is younger than the configured // list TTL, skip the API call and synthesize a DeviceListBody from the // metadata cache. const mode = getCacheMode(); - if (mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs)) { + if (!options.bypassCache && mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs)) { const cached = loadCache(); if (cached) { const deviceList: Device[] = []; @@ -102,7 +131,7 @@ export async function fetchDeviceList(client?: AxiosInstance): Promise), replayed: true }; @@ -353,11 +389,22 @@ export async function describeDevice( options: { live?: boolean } = {}, client?: AxiosInstance ): Promise { - const body = await fetchDeviceList(client); - const { deviceList, infraredRemoteList } = body; - - const physical = deviceList.find((d) => d.deviceId === deviceId); - const ir = infraredRemoteList.find((d) => d.deviceId === deviceId); + const mode = getCacheMode(); + const hadFreshListCache = + mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs) && loadCache() !== null; + let body = await fetchDeviceList(client); + let { deviceList, infraredRemoteList } = body; + + let physical = deviceList.find((d) => d.deviceId === deviceId); + let ir = infraredRemoteList.find((d) => d.deviceId === deviceId); + + if (!physical && !ir && hadFreshListCache) { + body = await fetchDeviceList(client, { bypassCache: true }); + deviceList = body.deviceList; + infraredRemoteList = body.infraredRemoteList; + physical = deviceList.find((d) => d.deviceId === deviceId); + ir = infraredRemoteList.find((d) => d.deviceId === deviceId); + } if (!physical && !ir) throw new DeviceNotFoundError(deviceId); @@ -402,8 +449,14 @@ export async function describeDevice( ? { liveStatus } : null; + const warnings: string[] = []; + const selectedDevice = (physical ?? ir) as Device | InfraredDevice; + if (hasDanglingHubReference(selectedDevice, Boolean(physical), deviceList)) { + warnings.push(`hubDeviceId ${selectedDevice.hubDeviceId} is not present in the current inventory`); + } + return { - device: (physical ?? ir) as Device | InfraredDevice, + device: selectedDevice, isPhysical: Boolean(physical), typeName, controlType: physical?.controlType ?? ir?.controlType ?? null, @@ -411,6 +464,8 @@ export async function describeDevice( capabilities, source, suggestedActions: catalogEntry ? suggestedActions(catalogEntry) : [], + ...(catalogEntry ? {} : { catalogNote: describeCatalogNote(deviceId, typeName, Boolean(physical)) }), + ...(warnings.length > 0 ? { warnings } : {}), inheritedLocation: ir ? buildHubLocationMap(deviceList).get(ir.hubDeviceId) : undefined, }; } @@ -472,6 +527,8 @@ export interface McpDescribeShape { source: 'catalog' | 'live' | 'catalog+live' | 'none'; capabilities: unknown; suggestedActions: Array<{ command: string; parameter?: string; description: string }>; + catalogNote?: string; + warnings?: string[]; inheritedLocation?: { family?: string; room?: string }; } @@ -497,6 +554,8 @@ export function toMcpDescribeShape(r: DescribeResult): McpDescribeShape { source: r.source, capabilities: r.capabilities, suggestedActions: r.suggestedActions, + ...(r.catalogNote !== undefined ? { catalogNote: r.catalogNote } : {}), + ...(r.warnings !== undefined ? { warnings: r.warnings } : {}), ...(r.inheritedLocation !== undefined ? { inheritedLocation: { family: r.inheritedLocation.family, room: r.inheritedLocation.room } } : {}), diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index a3f7617..e212745 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -33,6 +33,10 @@ function hashKey(key: string): string { return crypto.createHash('sha256').update(key).digest('hex'); } +export function fingerprintIdempotencyKey(key: string): string { + return hashKey(key).slice(0, 12); +} + function shapeSignature(command: string, parameter: unknown): string { // Canonical-ish JSON — stable enough for object equality with no nested sort // (callers can pass primitives or small objects). diff --git a/src/policy/schema/v0.2.json b/src/policy/schema/v0.2.json index 3785b64..16a8ced 100644 --- a/src/policy/schema/v0.2.json +++ b/src/policy/schema/v0.2.json @@ -10,7 +10,7 @@ "version": { "type": "string", "const": "0.2", - "description": "Policy schema version. Will migrate 0.1 -> 0.2 in place via `switchbot policy migrate`." + "description": "Policy schema version. This CLI only rewrites between schema versions it still supports; legacy v0.1 files require an older CLI for migration." }, "aliases": { diff --git a/src/policy/validate.ts b/src/policy/validate.ts index beafaa9..16ce46c 100644 --- a/src/policy/validate.ts +++ b/src/policy/validate.ts @@ -10,7 +10,10 @@ import { isSupportedPolicySchemaVersion, type PolicySchemaVersion, } from './schema.js'; +import { findCatalogEntry, getEffectiveCatalog } from '../devices/catalog.js'; +import { parseRuleCommand } from '../rules/action.js'; import { destructiveVerbOf, DESTRUCTIVE_COMMANDS } from '../rules/destructive.js'; +import type { DeviceListBody } from '../lib/devices.js'; const require = createRequire(import.meta.url); type AddFormatsFn = (ajv: Ajv2020Type) => Ajv2020Type; @@ -32,10 +35,33 @@ export interface PolicyValidationError { export interface PolicyValidationResult { policyPath: string; schemaVersion: PolicySchemaVersion; + validationScope: 'schema+offline-semantics' | 'schema+offline-semantics+live-inventory'; + limitations: string[]; valid: boolean; errors: PolicyValidationError[]; } +const POLICY_VALIDATION_LIMITATIONS = [ + 'Does not resolve aliases against the live device inventory.', + 'Does not verify commands against the real target device, live capabilities, or current firmware.', +] as const; + +const POLICY_VALIDATION_LIVE_LIMITATIONS = [ + 'Live inventory checks reflect a point-in-time device list snapshot.', + 'Does not verify commands against live capabilities or current firmware.', +] as const; + +// Offline plausibility checks for a raw device-ID string in a policy file. +// Deliberately permissive: we accept anything that *could* be a deviceId on +// any SwitchBot account without asking the API. The real authorization gate +// is `validateLoadedPolicyAgainstInventory` / `alias-live-device-not-found` +// / `rule-live-device-not-found`, which cross-checks against the current +// account's inventory. If you tighten these patterns too far, valid IR +// remote IDs or future SKU conventions start failing offline `policy +// validate` even when they're fine on the account. +const HEX_MAC_DEVICE_ID_RE = /^[A-Fa-f0-9]{12}(?:-[A-Za-z0-9]{2,16})?$/; +const HYPHENATED_DEVICE_ID_RE = /^[A-Za-z0-9]{2,32}(?:-[A-Za-z0-9]{2,32}){1,4}$/; + interface CompiledValidator { ajv: Ajv2020Type; validate: ValidateFn; @@ -92,6 +118,18 @@ function getKeyNodeAt(doc: Document.Parsed, parentSegments: string[], key: strin return (pair?.key as Node | undefined) ?? null; } +function locateInstancePath( + doc: Document.Parsed, + lineCounter: LineCounter, + instancePath: string, +): { line?: number; col?: number } { + const node = getNodeAt(doc, instancePathToSegments(instancePath)); + const range = (node as { range?: [number, number, number] } | null)?.range; + if (!range) return {}; + const pos = lineCounter.linePos(range[0]); + return { line: pos.line, col: pos.col }; +} + function locateError(doc: Document.Parsed, lineCounter: LineCounter, err: ErrorObject): { line?: number; col?: number } { const segments = instancePathToSegments(err.instancePath); @@ -186,6 +224,8 @@ function unsupportedVersionResult(loaded: LoadedPolicy, declared: string): Polic return { policyPath: loaded.path, schemaVersion: CURRENT_POLICY_SCHEMA_VERSION, + validationScope: 'schema+offline-semantics', + limitations: [...POLICY_VALIDATION_LIMITATIONS], valid: false, errors: [ { @@ -201,6 +241,85 @@ function unsupportedVersionResult(loaded: LoadedPolicy, declared: string): Polic }; } +function escapeJsonPointerSegment(segment: string): string { + return segment.replace(/~/g, '~0').replace(/\//g, '~1'); +} + +function isPlausibleDeviceId(value: string): boolean { + return HEX_MAC_DEVICE_ID_RE.test(value) || HYPHENATED_DEVICE_ID_RE.test(value); +} + +function hasErrorAtPath(errors: PolicyValidationError[], path: string): boolean { + return errors.some((err) => err.path === path); +} + +function resolvePolicyDeviceRef( + raw: string | undefined, + aliases: Record, +): { ok: boolean; reason?: string } { + if (!raw) return { ok: false, reason: 'missing-device' }; + if (raw === '') return { ok: false, reason: 'missing-device' }; + if (Object.hasOwn(aliases, raw)) return { ok: true }; + if (isPlausibleDeviceId(raw)) return { ok: true }; + return { ok: false, reason: 'unknown-device-ref' }; +} + +function collectAliasMap(data: unknown): Record { + const aliases = (data as { aliases?: Record } | null | undefined)?.aliases; + if (!aliases || typeof aliases !== 'object') return {}; + return Object.fromEntries( + Object.entries(aliases).filter( + (entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string', + ), + ); +} + +function isDeviceStateConditionLike( + value: unknown, +): value is { device: string; field: string; op: string } { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const candidate = value as { device?: unknown; field?: unknown; op?: unknown }; + return ( + typeof candidate.device === 'string' + && typeof candidate.field === 'string' + && typeof candidate.op === 'string' + ); +} + +function collectConditionDeviceRefs( + condition: unknown, + path: string, +): Array<{ path: string; ref: string }> { + if (!condition || typeof condition !== 'object' || Array.isArray(condition)) return []; + const out: Array<{ path: string; ref: string }> = []; + + if (isDeviceStateConditionLike(condition)) { + out.push({ path: `${path}/device`, ref: condition.device }); + } + + const candidate = condition as { + all?: unknown[]; + any?: unknown[]; + not?: unknown; + }; + + if (Array.isArray(candidate.all)) { + for (let i = 0; i < candidate.all.length; i++) { + out.push(...collectConditionDeviceRefs(candidate.all[i], `${path}/all/${i}`)); + } + } + if (Array.isArray(candidate.any)) { + for (let i = 0; i < candidate.any.length; i++) { + out.push(...collectConditionDeviceRefs(candidate.any[i], `${path}/any/${i}`)); + } + } + if (candidate.not !== undefined) { + out.push(...collectConditionDeviceRefs(candidate.not, `${path}/not`)); + } + + return out; +} + /** * Walk `automation.rules[].then[]` and flag any command string whose verb * appears in DESTRUCTIVE_COMMANDS. Uses the YAML doc (not the data tree) to @@ -255,6 +374,337 @@ function collectDestructiveRuleErrors(loaded: LoadedPolicy): PolicyValidationErr return out; } +function collectOfflineSemanticErrors( + loaded: LoadedPolicy, + existingErrors: PolicyValidationError[], +): PolicyValidationError[] { + const data = loaded.data as + | { + aliases?: Record; + automation?: { + rules?: Array<{ + name?: string; + when?: { source?: string; device?: string }; + conditions?: unknown[]; + then?: Array<{ command?: string; device?: string }>; + }>; + }; + } + | null + | undefined; + + const out: PolicyValidationError[] = []; + const aliases = collectAliasMap(data); + + for (const [aliasName, deviceId] of Object.entries(aliases)) { + const path = `/aliases/${escapeJsonPointerSegment(aliasName)}`; + if (hasErrorAtPath(existingErrors, path)) continue; + if (isPlausibleDeviceId(deviceId)) continue; + const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, path); + out.push({ + path, + line, + col, + keyword: 'alias-device-id', + message: `alias "${aliasName}" does not point to a plausible SwitchBot deviceId`, + hint: 'use a deviceId from `switchbot devices list --format=tsv`, e.g. 01-202407090924-26354212 or 28372F4C9C4A', + schemaPath: '#/properties/aliases', + }); + } + + const knownDeviceCommands = new Set( + getEffectiveCatalog() + .flatMap((entry) => entry.commands) + .filter((spec) => spec.commandType !== 'customize') + .map((spec) => spec.command), + ); + + const rules = data?.automation?.rules; + if (!Array.isArray(rules)) return out; + + for (let ri = 0; ri < rules.length; ri++) { + const rule = rules[ri]; + const ruleName = typeof rule?.name === 'string' ? rule.name : `#${ri}`; + if (typeof rule?.when?.device === 'string') { + const whenDevicePath = `/automation/rules/${ri}/when/device`; + const resolved = resolvePolicyDeviceRef(rule.when.device, aliases); + if (!resolved.ok) { + const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, whenDevicePath); + out.push({ + path: whenDevicePath, + line, + col, + keyword: resolved.reason ?? 'unknown-device-ref', + message: `rule "${ruleName}" trigger references unknown device "${rule.when.device}"`, + hint: 'set `when.device` to a declared alias or a plausible deviceId', + schemaPath: '#/properties/automation/properties/rules/items/properties/when/properties/device', + }); + } + } + + const conditions = Array.isArray(rule?.conditions) ? rule.conditions : []; + for (let ci = 0; ci < conditions.length; ci++) { + for (const ref of collectConditionDeviceRefs(conditions[ci], `/automation/rules/${ri}/conditions/${ci}`)) { + const resolved = resolvePolicyDeviceRef(ref.ref, aliases); + if (!resolved.ok) { + const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, ref.path); + out.push({ + path: ref.path, + line, + col, + keyword: resolved.reason ?? 'unknown-device-ref', + message: `rule "${ruleName}" condition references unknown device "${ref.ref}"`, + hint: 'set condition `device` to a declared alias or a plausible deviceId', + schemaPath: '#/properties/automation/properties/rules/items/properties/conditions', + }); + } + } + } + + const actions = Array.isArray(rule?.then) ? rule.then : []; + for (let ai = 0; ai < actions.length; ai++) { + const action = actions[ai]; + const cmd = action?.command; + if (typeof cmd !== 'string') continue; + const commandPath = `/automation/rules/${ri}/then/${ai}/command`; + const devicePath = `/automation/rules/${ri}/then/${ai}/device`; + + const parsed = parseRuleCommand(cmd); + if (!parsed) { + const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath); + out.push({ + path: commandPath, + line, + col, + keyword: 'rule-unparseable-command', + message: `rule "${ruleName}" action #${ai} must use \`devices command [parameter...]\``, + hint: 'automation rules currently support only `devices command ...` actions; scenes/webhooks/other subcommands are not executable here', + schemaPath: '#/properties/automation/properties/rules/items/properties/then/items/properties/command', + }); + continue; + } + + if (!knownDeviceCommands.has(parsed.verb)) { + const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath); + out.push({ + path: commandPath, + line, + col, + keyword: 'rule-unknown-command', + message: `rule "${ruleName}" action #${ai} uses unknown device command "${parsed.verb}"`, + hint: 'check `switchbot devices commands ` for valid verbs; this validator only checks offline catalog verbs, not the real target device', + schemaPath: '#/properties/automation/properties/rules/items/properties/then/items/properties/command', + }); + } + + if (typeof action?.device === 'string') { + const resolved = resolvePolicyDeviceRef(action.device, aliases); + if (!resolved.ok) { + const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, devicePath); + out.push({ + path: devicePath, + line, + col, + keyword: resolved.reason ?? 'unknown-device-ref', + message: `rule "${ruleName}" action #${ai} references unknown device "${action.device}"`, + hint: 'set `device:` to a declared alias or a plausible deviceId', + schemaPath: '#/properties/automation/properties/rules/items/properties/then/items/properties/device', + }); + } + continue; + } + + const resolved = resolvePolicyDeviceRef(parsed.deviceIdSlot ?? undefined, aliases); + if (!resolved.ok) { + const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath); + out.push({ + path: commandPath, + line, + col, + keyword: resolved.reason ?? 'missing-device', + message: + resolved.reason === 'missing-device' + ? `rule "${ruleName}" action #${ai} uses \`\` but does not provide \`device:\`` + : `rule "${ruleName}" action #${ai} references unknown device "${parsed.deviceIdSlot}"`, + hint: + resolved.reason === 'missing-device' + ? 'either replace `` with a deviceId/alias or add `device: ` to the action' + : 'use a declared alias or a plausible deviceId in the command slot', + schemaPath: '#/properties/automation/properties/rules/items/properties/then/items/properties/command', + }); + } + } + } + + return out; +} + +function resolveInventoryDeviceId( + raw: string | undefined, + aliases: Record, +): string | null { + if (!raw || raw === '') return null; + if (Object.hasOwn(aliases, raw)) return aliases[raw]; + return raw; +} + +export function validateLoadedPolicyAgainstInventory( + loaded: LoadedPolicy, + inventory: DeviceListBody, +): PolicyValidationResult { + const base = validateLoadedPolicy(loaded); + const errors = [...base.errors]; + const aliases = collectAliasMap(loaded.data); + const inventoryById = new Map(); + for (const device of inventory.deviceList) { + inventoryById.set(device.deviceId, { typeName: device.deviceType ?? '' }); + } + for (const remote of inventory.infraredRemoteList) { + inventoryById.set(remote.deviceId, { typeName: remote.remoteType }); + } + + for (const [aliasName, deviceId] of Object.entries(aliases)) { + const path = `/aliases/${escapeJsonPointerSegment(aliasName)}`; + if (hasErrorAtPath(errors, path)) continue; + if (!inventoryById.has(deviceId)) { + const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, path); + errors.push({ + path, + line, + col, + keyword: 'alias-live-device-not-found', + message: `alias "${aliasName}" points to deviceId "${deviceId}" which is not present in the current inventory`, + hint: 'refresh with `switchbot devices list` and confirm the alias target still exists on this account', + schemaPath: '#/properties/aliases', + }); + } + } + + const data = loaded.data as + | { + automation?: { + rules?: Array<{ + name?: string; + when?: { source?: string; device?: string }; + conditions?: unknown[]; + then?: Array<{ command?: string; device?: string }>; + }>; + }; + } + | null + | undefined; + const rules = data?.automation?.rules; + if (Array.isArray(rules)) { + for (let ri = 0; ri < rules.length; ri++) { + const rule = rules[ri]; + const ruleName = typeof rule?.name === 'string' ? rule.name : `#${ri}`; + + if (typeof rule?.when?.device === 'string') { + const whenDevicePath = `/automation/rules/${ri}/when/device`; + const effectiveDeviceId = resolveInventoryDeviceId(rule.when.device, aliases); + if (effectiveDeviceId && !inventoryById.has(effectiveDeviceId)) { + const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, whenDevicePath); + errors.push({ + path: whenDevicePath, + line, + col, + keyword: 'rule-live-device-not-found', + message: `rule "${ruleName}" trigger resolves to deviceId "${effectiveDeviceId}" which is not present in the current inventory`, + hint: 'confirm `when.device` against `switchbot devices list` before relying on this policy', + schemaPath: '#/properties/automation/properties/rules/items/properties/when/properties/device', + }); + } + } + + const conditions = Array.isArray(rule?.conditions) ? rule.conditions : []; + for (let ci = 0; ci < conditions.length; ci++) { + for (const ref of collectConditionDeviceRefs(conditions[ci], `/automation/rules/${ri}/conditions/${ci}`)) { + const effectiveDeviceId = resolveInventoryDeviceId(ref.ref, aliases); + if (effectiveDeviceId && !inventoryById.has(effectiveDeviceId)) { + const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, ref.path); + errors.push({ + path: ref.path, + line, + col, + keyword: 'rule-live-device-not-found', + message: `rule "${ruleName}" condition resolves to deviceId "${effectiveDeviceId}" which is not present in the current inventory`, + hint: 'confirm the condition device against `switchbot devices list` before relying on this policy', + schemaPath: '#/properties/automation/properties/rules/items/properties/conditions', + }); + } + } + } + + const actions = Array.isArray(rule?.then) ? rule.then : []; + for (let ai = 0; ai < actions.length; ai++) { + const action = actions[ai]; + const cmd = action?.command; + if (typeof cmd !== 'string') continue; + const parsed = parseRuleCommand(cmd); + if (!parsed) continue; + + const commandPath = `/automation/rules/${ri}/then/${ai}/command`; + const devicePath = `/automation/rules/${ri}/then/${ai}/device`; + const effectiveRef = typeof action?.device === 'string' ? action.device : parsed.deviceIdSlot ?? undefined; + const effectiveDeviceId = resolveInventoryDeviceId(effectiveRef, aliases); + if (!effectiveDeviceId) continue; + + const target = inventoryById.get(effectiveDeviceId); + if (!target) { + const path = typeof action?.device === 'string' ? devicePath : commandPath; + const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, path); + errors.push({ + path, + line, + col, + keyword: 'rule-live-device-not-found', + message: `rule "${ruleName}" action #${ai} resolves to deviceId "${effectiveDeviceId}" which is not present in the current inventory`, + hint: 'confirm the alias/deviceId against `switchbot devices list` before relying on this policy', + schemaPath: '#/properties/automation/properties/rules/items/properties/then/items/properties/device', + }); + continue; + } + + // Only reached once the target device is resolved in the live + // inventory, so `target.typeName` came straight from the live API + // and is a canonical catalog key — `findCatalogEntry` almost always + // returns the exact entry. The `Array.isArray` branch would only + // fire if the live API ever returns a type that substring-matches + // multiple catalog rows (catalog drift / new upstream type). When + // that happens we'd rather stay silent than emit a false + // `rule-live-unsupported-command` against an ambiguous match — + // verb-support for the unknown type is simply not checkable here. + const match = target.typeName ? findCatalogEntry(target.typeName) : null; + const entry = !match || Array.isArray(match) ? null : match; + if (!entry) continue; + const supported = entry.commands + .filter((spec) => spec.commandType !== 'customize') + .some((spec) => spec.command === parsed.verb); + if (supported) continue; + + const { line, col } = locateInstancePath(loaded.doc, loaded.lineCounter, commandPath); + errors.push({ + path: commandPath, + line, + col, + keyword: 'rule-live-unsupported-command', + message: `rule "${ruleName}" action #${ai} uses command "${parsed.verb}" but live target "${effectiveDeviceId}" is type "${target.typeName}"`, + hint: `supported offline verbs for ${target.typeName}: ${entry.commands.filter((spec) => spec.commandType !== 'customize').map((spec) => spec.command).join(', ')}`, + schemaPath: '#/properties/automation/properties/rules/items/properties/then/items/properties/command', + }); + } + } + } + + return { + ...base, + validationScope: 'schema+offline-semantics+live-inventory', + limitations: [...POLICY_VALIDATION_LIVE_LIMITATIONS], + valid: errors.length === 0, + errors, + }; +} + export function validateLoadedPolicy(loaded: LoadedPolicy): PolicyValidationResult { const declared = readDeclaredVersion(loaded.data); @@ -292,6 +742,7 @@ export function validateLoadedPolicy(loaded: LoadedPolicy): PolicyValidationResu if (version === '0.2') { const ruleErrors = collectDestructiveRuleErrors(loaded); errors.push(...ruleErrors); + errors.push(...collectOfflineSemanticErrors(loaded, errors)); } const valid = ok === true && errors.length === 0; @@ -299,6 +750,8 @@ export function validateLoadedPolicy(loaded: LoadedPolicy): PolicyValidationResu return { policyPath: loaded.path, schemaVersion: version, + validationScope: 'schema+offline-semantics', + limitations: [...POLICY_VALIDATION_LIMITATIONS], valid, errors, }; diff --git a/src/rules/suggest.ts b/src/rules/suggest.ts index ce66fde..12b340a 100644 --- a/src/rules/suggest.ts +++ b/src/rules/suggest.ts @@ -1,5 +1,6 @@ import { stringify as yamlStringify } from 'yaml'; -import { COMMAND_KEYWORDS } from '../lib/command-keywords.js'; +import { containsCjk, inferCommandFromIntent } from '../lib/command-keywords.js'; +import { UsageError } from '../utils/output.js'; import type { Rule, MqttTrigger, CronTrigger, WebhookTrigger, Action } from './types.js'; export interface SuggestRuleOptions { @@ -18,6 +19,13 @@ export interface SuggestRuleResult { warnings: string[]; } +function buildSuggestedAction(command: string, deviceId?: string): Action { + if (deviceId) { + return { command: `devices command ${deviceId} ${command}` }; + } + return { command: `devices command ${command}` }; +} + const TRIGGER_KEYWORDS: Array<{ pattern: RegExp; trigger: 'mqtt' | 'cron' | 'webhook'; @@ -55,8 +63,12 @@ function inferSchedule(intent: string, warnings: string[]): string { } function inferCommand(intent: string, warnings: string[]): string { - for (const k of COMMAND_KEYWORDS) { - if (k.pattern.test(intent)) return k.command; + const command = inferCommandFromIntent(intent); + if (command) return command; + if (containsCjk(intent)) { + throw new UsageError( + `Intent "${intent}" contains non-English command text that this heuristic cannot safely infer. Use explicit English command words (turnOn/turnOff/open/close/lock/unlock/press/pause) or edit the generated rule manually.`, + ); } warnings.push( `Could not infer command from intent "${intent}" — defaulted to "turnOn". Edit the generated rule to set the correct command.`, @@ -66,6 +78,7 @@ function inferCommand(intent: string, warnings: string[]): string { export function suggestRule(opts: SuggestRuleOptions): SuggestRuleResult { const warnings: string[] = []; + const cjkIntent = containsCjk(opts.intent); // Resolve trigger let triggerSource = opts.trigger; @@ -75,6 +88,11 @@ export function suggestRule(opts: SuggestRuleOptions): SuggestRuleResult { triggerSource = inferred.trigger; inferredEvent = inferred.event; if (inferredEvent === 'device.shadow') { + if (cjkIntent) { + throw new UsageError( + `Intent "${opts.intent}" contains non-English trigger text that this heuristic cannot safely infer. Re-run with --trigger and, for mqtt rules, --event explicitly.`, + ); + } warnings.push( `Could not infer trigger type from intent "${opts.intent}" — defaulted to mqtt/device.shadow. Set --trigger and --event explicitly.`, ); @@ -92,6 +110,11 @@ export function suggestRule(opts: SuggestRuleOptions): SuggestRuleResult { } when = mqttTrigger; } else if (triggerSource === 'cron') { + if (cjkIntent && !opts.schedule) { + throw new UsageError( + `Intent "${opts.intent}" contains non-English scheduling text that this heuristic cannot safely infer. Re-run with --schedule "" explicitly.`, + ); + } const schedule = opts.schedule ?? inferSchedule(opts.intent, warnings); const cronTrigger: CronTrigger = { source: 'cron', schedule }; if (opts.days && opts.days.length > 0) cronTrigger.days = opts.days as never; @@ -108,11 +131,8 @@ export function suggestRule(opts: SuggestRuleOptions): SuggestRuleResult { : (opts.devices ?? []); const then: Action[] = actionDevices.length > 0 - ? actionDevices.map((d) => ({ - command: `devices command ${command}`, - device: d.id, - })) - : [{ command: `devices command ${command}` }]; + ? actionDevices.map((d) => buildSuggestedAction(command, d.id)) + : [buildSuggestedAction(command)]; const rule: Rule = { name: opts.intent, diff --git a/src/status-sync/manager.ts b/src/status-sync/manager.ts index bbcfec5..806109d 100644 --- a/src/status-sync/manager.ts +++ b/src/status-sync/manager.ts @@ -59,6 +59,13 @@ export interface StartStatusSyncOptions { topic?: string; stateDir?: string; force?: boolean; + probe?: boolean; +} + +export interface StatusSyncProbeResult { + openclawUrl: string; + mqttBrokerUrl: string; + mqttRegion: string; } export interface StatusSyncStatusOptions { @@ -105,14 +112,128 @@ function resolveStatusSyncRuntime(options: { ); } + const openclawUrl = options.openclawUrl ?? process.env.OPENCLAW_URL ?? DEFAULT_OPENCLAW_URL; + let parsedUrl: URL; + try { + parsedUrl = new URL(openclawUrl); + } catch { + throw new UsageError( + [ + `OpenClaw URL is invalid: ${openclawUrl}`, + 'Provide a full http:// or https:// URL via one of:', + ' 1. --openclaw-url ', + ' 2. OPENCLAW_URL= in the environment', + '', + 'After fixing it, re-run the command and verify with `switchbot status-sync status`.', + ].join('\n'), + ); + } + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new UsageError( + [ + `OpenClaw URL must use http:// or https:// (received ${parsedUrl.protocol})`, + 'Provide a full http:// or https:// URL via one of:', + ' 1. --openclaw-url ', + ' 2. OPENCLAW_URL= in the environment', + ].join('\n'), + ); + } + return { - openclawUrl: options.openclawUrl ?? process.env.OPENCLAW_URL ?? DEFAULT_OPENCLAW_URL, + openclawUrl, openclawToken, openclawModel, ...(options.topic ? { topic: options.topic } : {}), }; } +export async function probeStatusSyncStart( + options: Pick = {}, +): Promise { + const runtime = resolveStatusSyncRuntime(options); + const config = tryLoadConfig(); + if (!config) { + throw new UsageError( + 'No credentials found. Run \'switchbot config set-token\' or set SWITCHBOT_TOKEN and SWITCHBOT_SECRET.', + ); + } + + const { fetchMqttCredential } = await import('../mqtt/credential.js'); + + let mqttBrokerUrl = ''; + let mqttRegion = ''; + try { + const cred = await fetchMqttCredential(config.token, config.secret); + mqttBrokerUrl = cred.brokerUrl; + mqttRegion = cred.region; + } catch (err) { + throw new UsageError( + [ + 'SwitchBot MQTT credential probe failed.', + `Reason: ${err instanceof Error ? err.message : String(err)}`, + 'Verify SWITCHBOT_TOKEN / SWITCHBOT_SECRET first, then re-run `switchbot status-sync start --probe`.', + ].join('\n'), + ); + } + + // Probe the actual write endpoint (/v1/chat/completions), not the base + // URL. Mirrors the POST the sink does at runtime in src/sinks/openclaw.ts + // so misconfigurations (wrong token, wrong base URL that needs the path + // appended, model not registered) surface at `--probe` time instead of + // after the daemon has started and is silently dropping writes. + const probeUrl = `${runtime.openclawUrl.replace(/\/$/, '')}/v1/chat/completions`; + let res: Response; + try { + res = await fetch(probeUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${runtime.openclawToken}`, + }, + body: JSON.stringify({ + model: runtime.openclawModel, + messages: [{ role: 'user', content: 'status-sync probe' }], + }), + signal: AbortSignal.timeout(5000), + }); + } catch (err) { + throw new UsageError( + [ + `OpenClaw probe failed for ${probeUrl}.`, + `Reason: ${err instanceof Error ? err.message : String(err)}`, + 'Check URL reachability, TLS/certificate trust, and whether the OpenClaw server is listening.', + ].join('\n'), + ); + } + if (!res.ok) { + const body = await res.text().catch(() => ''); + const preview = body ? ` — ${body.slice(0, 200).replace(/\s+/g, ' ').trim()}` : ''; + const hint = + res.status === 401 || res.status === 403 + ? 'OpenClaw rejected the token. Verify --openclaw-token / OPENCLAW_TOKEN against the server admin.' + : res.status === 404 + ? 'OpenClaw returned 404 for /v1/chat/completions. Confirm the base URL does not already include that path, and that the server exposes an OpenAI-compatible endpoint.' + : res.status === 400 || res.status === 422 + ? 'OpenClaw rejected the request body. The model name may not be registered; verify --openclaw-model / OPENCLAW_MODEL.' + : res.status >= 500 + ? 'OpenClaw returned a server error. Retry, and if it persists check the server logs.' + : 'Unexpected status; inspect the response body above for details.'; + throw new UsageError( + [ + `OpenClaw probe failed for ${probeUrl}.`, + `Reason: HTTP ${res.status} ${res.statusText}${preview}`, + hint, + ].join('\n'), + ); + } + + return { + openclawUrl: runtime.openclawUrl, + mqttBrokerUrl, + mqttRegion, + }; +} + export function resolveStatusSyncPaths(explicitStateDir?: string): StatusSyncPaths { const stateDir = path.resolve( explicitStateDir diff --git a/src/utils/audit.ts b/src/utils/audit.ts index 507ea9f..251c9f8 100644 --- a/src/utils/audit.ts +++ b/src/utils/audit.ts @@ -49,8 +49,12 @@ export interface AuditEntry { parameter: unknown; commandType: 'command' | 'customize'; dryRun: boolean; - result?: 'ok' | 'error'; + result?: 'ok' | 'error' | 'dry-run'; error?: string; + /** True when a cached idempotent result was returned instead of executing again. */ + replayed?: boolean; + /** Short SHA-256 fingerprint of the user-supplied idempotency key. */ + idempotencyKeyFingerprint?: string; /** When execution is initiated via `plan run`, the stable plan ID for traceability. */ planId?: string; /** Present for rule-engine kinds; absent for direct CLI command entries. */ diff --git a/src/utils/output.ts b/src/utils/output.ts index c849451..335b2fa 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -49,7 +49,7 @@ export function emitStreamHeader(opts: { }): void { console.log( JSON.stringify({ - schemaVersion: '1', + schemaVersion: SCHEMA_VERSION, stream: true, eventKind: opts.eventKind, cadence: opts.cadence, diff --git a/src/version-notes.ts b/src/version-notes.ts new file mode 100644 index 0000000..9881717 --- /dev/null +++ b/src/version-notes.ts @@ -0,0 +1,44 @@ +export interface ReleaseMetadata { + version: string; + breaking: boolean; + summary: string; +} + +// Registry of past releases that carry user-visible breaking changes. Consumed +// by `upgrade-check` (to warn operators crossing the boundary) and by `doctor` +// (to surface a notice when the running version itself is a known breaking +// release). Only add entries here for genuine contract breaks — wrong entries +// either cry wolf or mask real breaks. +// +// Historical note: the {schemaVersion,data} JSON envelope is a 2.0.0 change +// (commit 33d3825 "fix!(output): wrap json responses ..."). 3.x callers have +// been consuming the envelope for the entire 3.x line; it is NOT a 3.3.0 +// break and must not be listed here. +export const RELEASE_METADATA: ReleaseMetadata[] = []; + +function semverParts(v: string): [number, number, number] { + const [maj, min, pat] = v.replace(/-.*$/, '').split('.').map((n) => Number.parseInt(n, 10)); + return [maj ?? 0, min ?? 0, pat ?? 0]; +} + +export function semverCompare(a: string, b: string): number { + const [aMaj, aMin, aPat] = semverParts(a); + const [bMaj, bMin, bPat] = semverParts(b); + if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1; + if (aMin !== bMin) return aMin < bMin ? -1 : 1; + if (aPat !== bPat) return aPat < bPat ? -1 : 1; + const aPre = a.includes('-'); + const bPre = b.includes('-'); + if (aPre === bPre) return 0; + return aPre ? -1 : 1; +} + +export function findBreakingChangeBetween(current: string, latest: string): ReleaseMetadata | null { + return RELEASE_METADATA + .filter((m) => m.breaking && semverCompare(m.version, current) > 0 && semverCompare(m.version, latest) <= 0) + .sort((a, b) => semverCompare(a.version, b.version))[0] ?? null; +} + +export function getReleaseMetadata(version: string): ReleaseMetadata | null { + return RELEASE_METADATA.find((m) => m.version === version) ?? null; +} diff --git a/tests/commands/agent-bootstrap.test.ts b/tests/commands/agent-bootstrap.test.ts index ee21c31..293c41d 100644 --- a/tests/commands/agent-bootstrap.test.ts +++ b/tests/commands/agent-bootstrap.test.ts @@ -7,6 +7,7 @@ import { Command } from 'commander'; import { registerAgentBootstrapCommand } from '../../src/commands/agent-bootstrap.js'; import { resetListCache } from '../../src/devices/cache.js'; import { runCli } from '../helpers/cli.js'; +import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; async function captureJson(fn: () => void | Promise): Promise { const lines: string[] = []; @@ -61,9 +62,25 @@ describe('agent-bootstrap', () => { registerAgentBootstrapCommand(program); const payload = await captureJson(async () => { await program.parseAsync(['node', 'cli', 'agent-bootstrap', '--compact']); - }) as { schemaVersion?: string; data?: Record }; + }) as Record; + const data = expectJsonEnvelopeContainingKeys(payload, [ + 'schemaVersion', + 'generatedAt', + 'cliVersion', + 'identity', + 'quickReference', + 'safetyTiers', + 'nameStrategies', + 'profile', + 'quota', + 'policyStatus', + 'credentialsBackend', + 'devices', + 'catalog', + 'hints', + ]); expect(payload.schemaVersion).toBeDefined(); - const data = payload.data as Record; + expect(typeof data.schemaVersion).toBe('string'); expect(data.identity).toBeDefined(); const identity = data.identity as Record; expect(identity.product).toBe('SwitchBot'); @@ -84,6 +101,46 @@ describe('agent-bootstrap', () => { expect(Array.isArray(catalog.types)).toBe(true); }); + it('keeps cached devices whose type is unknown instead of dropping them from the payload', async () => { + const cacheDir = path.join(tmpDir, '.switchbot'); + fs.writeFileSync( + path.join(cacheDir, 'devices.json'), + JSON.stringify({ + lastUpdated: new Date().toISOString(), + devices: { + ABC123: { + type: 'Bot', + name: 'Living Room Bot', + category: 'physical', + roomName: 'Living Room', + }, + AI999: { + type: '', + name: 'AI MindClip', + category: 'physical', + roomName: 'Office', + }, + }, + }), + ); + + process.argv = ['node', 'cli', 'agent-bootstrap', '--compact', '--json']; + const program = new Command(); + program.exitOverride(); + registerAgentBootstrapCommand(program); + const payload = await captureJson(async () => { + await program.parseAsync(['node', 'cli', 'agent-bootstrap', '--compact']); + }) as { data?: Record }; + const data = payload.data as Record; + const devices = data.devices as Array<{ deviceId: string; type: string; name: string }>; + expect(devices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ deviceId: 'ABC123', type: 'Bot' }), + expect.objectContaining({ deviceId: 'AI999', type: '', name: 'AI MindClip' }), + ]), + ); + }); + it('stays below 20 KB on a small account with --compact', async () => { process.argv = ['node', 'cli', 'agent-bootstrap', '--compact', '--json']; const program = new Command(); @@ -235,8 +292,9 @@ describe('agent-bootstrap', () => { 'agent-bootstrap', '--sections', 'identity,cliVersion', ]); expect(res.exitCode).toBeNull(); - const out = JSON.parse(res.stdout.join('')) as { data: Record }; - const keys = Object.keys(out.data); + const out = JSON.parse(res.stdout.join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(out, ['identity', 'cliVersion']); + const keys = Object.keys(data); expect(keys).toContain('identity'); expect(keys).toContain('cliVersion'); expect(keys).not.toContain('catalog'); @@ -246,9 +304,10 @@ describe('agent-bootstrap', () => { it('includes all keys when --sections is not provided', async () => { const res = await runCli(registerAgentBootstrapCommand, ['agent-bootstrap', '--compact']); - const out = JSON.parse(res.stdout.join('')) as { data: Record }; - expect(Object.keys(out.data)).toContain('catalog'); - expect(Object.keys(out.data)).toContain('hints'); + const out = JSON.parse(res.stdout.join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(out, ['catalog', 'hints']); + expect(Object.keys(data)).toContain('catalog'); + expect(Object.keys(data)).toContain('hints'); }); it('exits 2 and prints hint when an unknown section name is requested', async () => { diff --git a/tests/commands/auth.test.ts b/tests/commands/auth.test.ts index b68d160..23b93ec 100644 --- a/tests/commands/auth.test.ts +++ b/tests/commands/auth.test.ts @@ -10,6 +10,7 @@ import path from 'node:path'; import { Command } from 'commander'; import { registerAuthCommand } from '../../src/commands/auth.js'; +import { expectJsonEnvelopeShape } from '../helpers/contracts.js'; const selectMock = vi.fn(); @@ -105,9 +106,10 @@ describe('auth keychain describe', () => { selectMock.mockResolvedValue(makeStore({ name: 'file', writable: true })); const res = await runCli(['--json', 'auth', 'keychain', 'describe']); expect(res.exitCode).toBe(0); - const parsed = JSON.parse(res.stdout[0]); - expect(parsed.data.tag).toBe('file'); - expect(parsed.data.writable).toBe(true); + const parsed = JSON.parse(res.stdout[0]) as Record; + const data = expectJsonEnvelopeShape(parsed, ['backend', 'tag', 'writable']); + expect(data.tag).toBe('file'); + expect(data.writable).toBe(true); }); }); @@ -135,11 +137,22 @@ describe('auth keychain get', () => { selectMock.mockResolvedValue(makeStore({ getResult: { token: 'tok-1234', secret: 'sec-abcd' } })); const res = await runCli(['--json', 'auth', 'keychain', 'get']); expect(res.exitCode).toBe(0); - const parsed = JSON.parse(res.stdout[0]); - expect(parsed.data.present).toBe(true); - expect(parsed.data.token.length).toBe('tok-1234'.length); - expect(parsed.data.token).not.toHaveProperty('raw'); - expect(parsed.data.token.masked).not.toBe('tok-1234'); + const parsed = JSON.parse(res.stdout[0]) as Record; + const data = expectJsonEnvelopeShape(parsed, [ + 'profile', + 'backend', + 'present', + 'token', + 'secret', + ]) as { + present: boolean; + token: { length: number; masked: string; raw?: string }; + secret: { length: number; masked: string }; + }; + expect(data.present).toBe(true); + expect(data.token.length).toBe('tok-1234'.length); + expect(data.token).not.toHaveProperty('raw'); + expect(data.token.masked).not.toBe('tok-1234'); }); }); @@ -183,6 +196,20 @@ describe('auth keychain set', () => { const res = await runCli(['auth', 'keychain', 'set', '--stdin-file', path.join(tmpDir, 'doesntmatter.json')]); expect(res.exitCode).toBe(1); }); + + it('returns written:true under --json', async () => { + const store = makeStore({ writable: true }); + selectMock.mockResolvedValue(store); + + const file = path.join(tmpDir, 'creds.json'); + fs.writeFileSync(file, JSON.stringify({ token: 't-from-file', secret: 's-from-file' })); + + const res = await runCli(['--json', 'auth', 'keychain', 'set', '--stdin-file', file]); + expect(res.exitCode).toBe(0); + const parsed = JSON.parse(res.stdout[0]) as Record; + const data = expectJsonEnvelopeShape(parsed, ['profile', 'backend', 'written']); + expect(data.written).toBe(true); + }); }); describe('auth keychain delete', () => { @@ -201,8 +228,9 @@ describe('auth keychain delete', () => { const res = await runCli(['--json', 'auth', 'keychain', 'delete', '--yes']); expect(res.exitCode).toBe(0); - const parsed = JSON.parse(res.stdout[0]); - expect(parsed.data.deleted).toBe(true); + const parsed = JSON.parse(res.stdout[0]) as Record; + const data = expectJsonEnvelopeShape(parsed, ['profile', 'backend', 'deleted']); + expect(data.deleted).toBe(true); }); }); @@ -286,4 +314,28 @@ describe('auth keychain migrate', () => { const res = await runCli(['auth', 'keychain', 'migrate']); expect(res.exitCode).toBe(1); }); + + it('returns migration cleanup details under --json', async () => { + const store = makeStore({ writable: true }); + selectMock.mockResolvedValue(store); + + const file = path.join(tmpHome, '.switchbot', 'config.json'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ token: 't-src', secret: 's-src' })); + + const res = await runCli(['--json', 'auth', 'keychain', 'migrate', '--delete-file']); + expect(res.exitCode).toBe(0); + const parsed = JSON.parse(res.stdout[0]) as Record; + const data = expectJsonEnvelopeShape(parsed, [ + 'profile', + 'backend', + 'migrated', + 'sourceFile', + 'sourceDeleted', + 'sourceScrubbed', + ]); + expect(data.migrated).toBe(true); + expect(data.sourceDeleted).toBe(true); + expect(data.sourceScrubbed).toBe(false); + }); }); diff --git a/tests/commands/batch.test.ts b/tests/commands/batch.test.ts index a155371..1dce053 100644 --- a/tests/commands/batch.test.ts +++ b/tests/commands/batch.test.ts @@ -78,6 +78,7 @@ vi.mock('../../src/utils/flags.js', () => flagsMock); import { registerDevicesCommand } from '../../src/commands/devices.js'; import { runCli } from '../helpers/cli.js'; +import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; const DEVICE_LIST_BODY = { deviceList: [ @@ -158,11 +159,14 @@ describe('devices batch', () => { expect(result.exitCode).toBeNull(); expect(apiMock.__instance.post).toHaveBeenCalledTimes(2); - const parsed = JSON.parse(result.stdout[0]); - expect(parsed.schemaVersion).toBe('1.1'); - expect(parsed.data.summary.ok).toBe(2); - expect(parsed.data.summary.failed).toBe(0); - expect(parsed.data.succeeded.map((s: { deviceId: string }) => s.deviceId).sort()).toEqual(['BOT1', 'BOT2']); + const parsed = JSON.parse(result.stdout[0]) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['summary', 'succeeded', 'failed']) as { + summary: { ok: number; failed: number }; + succeeded: Array<{ deviceId: string }>; + }; + expect(data.summary.ok).toBe(2); + expect(data.summary.failed).toBe(0); + expect(data.succeeded.map((s) => s.deviceId).sort()).toEqual(['BOT1', 'BOT2']); }); it('dispatches by --ids (intersected with --filter when both are set)', async () => { @@ -183,8 +187,9 @@ describe('devices batch', () => { expect(result.exitCode).toBeNull(); // Only BOT1 and BOT2 pass the filter — LOCK1 is excluded. expect(apiMock.__instance.post).toHaveBeenCalledTimes(2); - const parsed = JSON.parse(result.stdout[0]); - expect(parsed.data.summary.total).toBe(2); + const parsed = JSON.parse(result.stdout[0]) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['summary']) as { summary: { total: number } }; + expect(data.summary.total).toBe(2); }); it('uses cached type info for --ids without fetching the device list', async () => { @@ -224,11 +229,15 @@ describe('devices batch', () => { ]); expect(result.exitCode).toBe(1); - const parsed = JSON.parse(result.stdout[0]); - expect(parsed.data.summary.ok).toBe(1); - expect(parsed.data.summary.failed).toBe(1); - expect(parsed.data.failed[0].deviceId).toBe('BOT2'); - expect(parsed.data.failed[0].error.message).toMatch(/timeout/); + const parsed = JSON.parse(result.stdout[0]) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['summary', 'failed']) as { + summary: { ok: number; failed: number }; + failed: Array<{ deviceId: string; error: { message: string } }>; + }; + expect(data.summary.ok).toBe(1); + expect(data.summary.failed).toBe(1); + expect(data.failed[0].deviceId).toBe('BOT2'); + expect(data.failed[0].error.message).toMatch(/timeout/); }); it('refuses destructive commands without --yes', async () => { @@ -398,6 +407,7 @@ describe('devices batch', () => { expect(apiMock.__instance.post).not.toHaveBeenCalled(); const parsed = JSON.parse(result.stdout[0]); expect(parsed.data.dryRun).toBe(true); + expect(parsed.data.schemaVersion).toBeUndefined(); expect(parsed.data.plan.command).toBe('turnOn'); expect(parsed.data.plan.maxConcurrent).toBe(3); expect(parsed.data.plan.staggerMs).toBe(250); @@ -422,6 +432,7 @@ describe('devices batch', () => { expect(result.exitCode).toBeNull(); expect(apiMock.__instance.post).not.toHaveBeenCalled(); const parsed = JSON.parse(result.stdout[0]); + expect(parsed.data.schemaVersion).toBeUndefined(); expect(parsed.data.plan.command).toBe('turnOn'); expect(parsed.data.plan.stepCount).toBe(2); // --emit-plan must not trigger the deprecation warning. @@ -537,6 +548,7 @@ describe('devices batch', () => { expect(parsed.data.summary.ok).toBe(1); expect(parsed.data.summary.total).toBe(2); expect(parsed.data.summary.skipped).toBe(1); + expect(parsed.data.summary.schemaVersion).toBeUndefined(); expect(parsed.data.skipped).toEqual([{ deviceId: 'BOT2', reason: 'offline' }]); expect(parsed.data.succeeded[0].deviceId).toBe('BOT1'); }); diff --git a/tests/commands/capabilities-meta.test.ts b/tests/commands/capabilities-meta.test.ts index 2fdf31e..b428ffe 100644 --- a/tests/commands/capabilities-meta.test.ts +++ b/tests/commands/capabilities-meta.test.ts @@ -13,6 +13,7 @@ vi.mock('../../src/devices/cache.js', () => cacheMock); import { COMMAND_META } from '../../src/commands/capabilities.js'; import { registerCapabilitiesCommand } from '../../src/commands/capabilities.js'; import { runCli } from '../helpers/cli.js'; +import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; // ── comprehensive list of every CLI leaf command ────────────────────────────── // Regression guard: when a new subcommand is added to the CLI, it MUST be added @@ -86,9 +87,17 @@ describe('capabilities command — regression output tests', () => { expect(res.stderr.join('')).not.toMatch(/coverage error/i); const out = res.stdout.join(''); expect(out.length).toBeGreaterThan(50); - const parsed = JSON.parse(out) as { data: { commands: Array<{ name: string }> } }; - expect(parsed).toHaveProperty('data'); - expect(parsed.data).toHaveProperty('commands'); + const parsed = JSON.parse(out) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, [ + 'schemaVersion', + 'agentGuide', + 'identity', + 'surfaces', + 'commands', + 'commandMeta', + 'resources', + ]) as { commands: Array<{ name: string }> }; + expect(data.commands).toBeDefined(); }); it('COMMAND_META has rules explain entry with READ_LOCAL tier', () => { @@ -102,8 +111,20 @@ describe('capabilities command — regression output tests', () => { it('full output catalog is a pointer note referencing schema export', async () => { const res = await runCli(registerCapabilitiesCommand, ['capabilities']); expect(res.exitCode).toBeNull(); - const parsed = JSON.parse(res.stdout.join('')) as { data: { catalog?: { note: string } } }; - const catalog = parsed.data.catalog; + const parsed = JSON.parse(res.stdout.join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, [ + 'schemaVersion', + 'agentGuide', + 'identity', + 'surfaces', + 'commands', + 'commandMeta', + 'globalFlags', + 'catalog', + 'resources', + 'generatedAt', + ]) as { catalog?: { note: string } }; + const catalog = data.catalog; expect(catalog).toBeDefined(); expect(catalog).toHaveProperty('note'); expect(catalog!.note).toContain('schema export'); diff --git a/tests/commands/capabilities.test.ts b/tests/commands/capabilities.test.ts index 43ca552..7122c27 100644 --- a/tests/commands/capabilities.test.ts +++ b/tests/commands/capabilities.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Command } from 'commander'; import { registerCapabilitiesCommand } from '../../src/commands/capabilities.js'; +import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; /** Build a representative program that mirrors the real CLI structure. */ function makeProgram(): Command { @@ -65,7 +66,16 @@ async function runCapabilities(): Promise> { logSpy.mockRestore(); } - return (JSON.parse(chunks.join('')) as { data: Record }).data; + const body = JSON.parse(chunks.join('')) as Record; + return expectJsonEnvelopeContainingKeys(body, [ + 'version', + 'generatedAt', + 'identity', + 'surfaces', + 'commands', + 'globalFlags', + 'catalog', + ]); } describe('capabilities', () => { @@ -182,7 +192,26 @@ async function runCapabilitiesWith(extra: string[]): Promise }).data; + const body = JSON.parse(chunks.join('')) as Record; + const compact = extra.includes('--compact'); + return expectJsonEnvelopeContainingKeys(body, compact ? [ + 'version', + 'schemaVersion', + 'agentGuide', + 'identity', + 'surfaces', + 'commands', + 'commandMeta', + 'resources', + ] : [ + 'version', + 'generatedAt', + 'identity', + 'surfaces', + 'commands', + 'globalFlags', + 'catalog', + ]); } describe('capabilities B3/B4', () => { diff --git a/tests/commands/catalog.test.ts b/tests/commands/catalog.test.ts index 36d2fe5..4605fbe 100644 --- a/tests/commands/catalog.test.ts +++ b/tests/commands/catalog.test.ts @@ -5,6 +5,7 @@ import os from 'node:os'; import { runCli } from '../helpers/cli.js'; import { registerCatalogCommand } from '../../src/commands/catalog.js'; import { resetCatalogOverlayCache } from '../../src/devices/catalog.js'; +import { expectJsonArrayEnvelope, expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; let tmpRoot: string; @@ -60,10 +61,11 @@ describe('catalog path', () => { it('emits JSON when --json is passed', async () => { writeOverlay([{ type: 'Bot' }]); const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'path']); - const parsed = JSON.parse(stdout.join('\n')); - expect(parsed.data.exists).toBe(true); - expect(parsed.data.valid).toBe(true); - expect(parsed.data.entryCount).toBe(1); + const parsed = JSON.parse(stdout.join('\n')) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['path', 'exists', 'valid', 'entryCount']); + expect(data.exists).toBe(true); + expect(data.valid).toBe(true); + expect(data.entryCount).toBe(1); }); }); @@ -99,6 +101,15 @@ describe('catalog show', () => { expect(out).toContain('Robot Vacuum Cleaner S1'); }); + it('resolves "Hub 2" to a standalone hub entry', async () => { + const { stdout, exitCode } = await runCli(registerCatalogCommand, ['catalog', 'show', 'Hub', '2']); + expect(exitCode).toBeNull(); + const out = stdout.join('\n'); + expect(out).toMatch(/Type:\s+Hub 2/); + expect(out).toMatch(/Role:\s+hub/); + expect(out).not.toContain('Meter'); + }); + it('--source built-in ignores overlay', async () => { writeOverlay([{ type: 'Bot', remove: true }]); const { stdout } = await runCli(registerCatalogCommand, ['catalog', 'show', '--source', 'built-in']); @@ -143,15 +154,16 @@ describe('catalog show', () => { it('emits JSON array with --json', async () => { const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'show']); - const parsed = JSON.parse(stdout.join('\n')); - expect(Array.isArray(parsed.data)).toBe(true); - expect(parsed.data.find((e: { type: string }) => e.type === 'Bot')).toBeDefined(); + const parsed = JSON.parse(stdout.join('\n')) as Record; + const data = expectJsonArrayEnvelope(parsed) as Array<{ type: string }>; + expect(data.find((e) => e.type === 'Bot')).toBeDefined(); }); it('emits a single-entry JSON object when a type is given', async () => { const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'show', 'Bot']); - const parsed = JSON.parse(stdout.join('\n')); - expect(parsed.data.type).toBe('Bot'); + const parsed = JSON.parse(stdout.join('\n')) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['type', 'category', 'description', 'role', 'commands', 'statusFields']); + expect(data.type).toBe('Bot'); }); }); @@ -203,12 +215,19 @@ describe('catalog diff', () => { { type: 'Curtain', remove: true }, ]); const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'diff']); - const parsed = JSON.parse(stdout.join('\n')); - expect(parsed.data.replaced).toHaveLength(1); - expect(parsed.data.replaced[0].type).toBe('Bot'); - expect(parsed.data.replaced[0].changedKeys).toContain('role'); - expect(parsed.data.removed).toContain('Curtain'); - expect(parsed.data.added).toEqual([]); + const parsed = JSON.parse(stdout.join('\n')) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, [ + 'overlayPath', 'overlayExists', 'overlayValid', 'replaced', 'added', 'removed', 'ignored', + ]) as { + replaced: Array<{ type: string; changedKeys: string[] }>; + removed: string[]; + added: string[]; + }; + expect(data.replaced).toHaveLength(1); + expect(data.replaced[0].type).toBe('Bot'); + expect(data.replaced[0].changedKeys).toContain('role'); + expect(data.removed).toContain('Curtain'); + expect(data.added).toEqual([]); }); }); @@ -229,8 +248,9 @@ describe('catalog refresh', () => { it('emits JSON with --json', async () => { const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'refresh']); - const parsed = JSON.parse(stdout.join('\n')); - expect(parsed.data.refreshed).toBe(true); + const parsed = JSON.parse(stdout.join('\n')) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['refreshed', 'path', 'exists', 'valid', 'entryCount']); + expect(data.refreshed).toBe(true); }); }); @@ -239,8 +259,11 @@ describe('catalog search', () => { // "bot" exists as type "Bot" (tier 0), appears in no roles/commands exactly, // and is a substring of other aliases like "robot vacuum" (tier 2). const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'search', 'bot']); - const parsed = JSON.parse(stdout.join('\n')); - const matches = parsed.data.matches as Array<{ type: string; _tier: number; _matchedOn: string[] }>; + const parsed = JSON.parse(stdout.join('\n')) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['query', 'strict', 'matches']) as { + matches: Array<{ type: string; _tier: number; _matchedOn: string[] }>; + }; + const matches = data.matches; expect(matches.length).toBeGreaterThan(0); // First hit must be the exact type (tier 0). expect(matches[0].type).toBe('Bot'); @@ -254,8 +277,11 @@ describe('catalog search', () => { it('marks alias-substring-only matches as alias-only', async () => { const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'search', 'bot']); - const parsed = JSON.parse(stdout.join('\n')); - const matches = parsed.data.matches as Array<{ type: string; _matchedOn: string[] }>; + const parsed = JSON.parse(stdout.join('\n')) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['query', 'strict', 'matches']) as { + matches: Array<{ type: string; _matchedOn: string[] }>; + }; + const matches = data.matches; // At least one tier-2 entry should be labelled alias-only. const aliasOnly = matches.filter((m) => m._matchedOn.includes('alias-only')); expect(aliasOnly.length).toBeGreaterThan(0); @@ -265,6 +291,22 @@ describe('catalog search', () => { } }); + it('treats Hub 2 as a hub type, not as a Meter alias leak', async () => { + const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'search', 'Hub']); + const parsed = JSON.parse(stdout.join('\n')) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['query', 'strict', 'matches']) as { + matches: Array<{ type: string; role?: string; aliases?: string[]; _matchedOn: string[] }>; + }; + const matches = data.matches; + const hub2 = matches.find((m) => m.type === 'Hub 2'); + expect(hub2).toBeDefined(); + expect(hub2?.role).toBe('hub'); + expect(hub2?._matchedOn).toContain('type'); + + const meter = matches.find((m) => m.type === 'Meter'); + expect(meter).toBeUndefined(); + }); + it('--strict restricts hits to type-name matches only', async () => { const { stdout } = await runCli(registerCatalogCommand, [ '--json', @@ -273,9 +315,13 @@ describe('catalog search', () => { 'bot', '--strict', ]); - const parsed = JSON.parse(stdout.join('\n')); - const matches = parsed.data.matches as Array<{ type: string; _matchedOn: string[]; _tier: number }>; - expect(parsed.data.strict).toBe(true); + const parsed = JSON.parse(stdout.join('\n')) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['query', 'strict', 'matches']) as { + strict: boolean; + matches: Array<{ type: string; _matchedOn: string[]; _tier: number }>; + }; + const matches = data.matches; + expect(data.strict).toBe(true); expect(matches.length).toBeGreaterThan(0); for (const m of matches) { expect(m._matchedOn).toContain('type'); diff --git a/tests/commands/config.test.ts b/tests/commands/config.test.ts index 2a75f7c..8000f39 100644 --- a/tests/commands/config.test.ts +++ b/tests/commands/config.test.ts @@ -15,6 +15,7 @@ vi.mock('../../src/config.js', () => configMock); import { registerConfigCommand } from '../../src/commands/config.js'; import { runCli } from '../helpers/cli.js'; +import { expectJsonEnvelopeShape } from '../helpers/contracts.js'; describe('config command', () => { beforeEach(() => { @@ -78,10 +79,11 @@ describe('config command', () => { secret: 'ab****yz', }); const res = await runCli(registerConfigCommand, ['--json', 'config', 'show']); - const parsed = JSON.parse(res.stdout.join('\n')); - expect(parsed.data.source).toBe('file'); - expect(parsed.data.path).toBe('/tmp/config.json'); - expect(parsed.data.token).toBe('abcd****wxyz'); + const parsed = JSON.parse(res.stdout.join('\n')) as Record; + const data = expectJsonEnvelopeShape(parsed, ['source', 'path', 'token', 'secret']); + expect(data.source).toBe('file'); + expect(data.path).toBe('/tmp/config.json'); + expect(data.token).toBe('abcd****wxyz'); }); }); @@ -102,8 +104,11 @@ describe('config command', () => { it('emits JSON with --json', async () => { configMock.listProfiles.mockReturnValue(['home']); const res = await runCli(registerConfigCommand, ['--json', 'config', 'list-profiles']); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - expect(out.data.profiles).toEqual([{ name: 'home' }]); + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; + const data = expectJsonEnvelopeShape(out, ['profiles']) as { + profiles: Array<{ name: string }>; + }; + expect(data.profiles).toEqual([{ name: 'home' }]); }); it('C5: surfaces label and dailyCap when present', async () => { @@ -153,6 +158,19 @@ describe('config command', () => { expect(configMock.saveConfig).not.toHaveBeenCalled(); fs.rmSync(dir, { recursive: true, force: true }); }); + + it('returns ok:true under --json when credentials are saved', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbenv-')); + const envFile = path.join(dir, '.env'); + fs.writeFileSync(envFile, 'SWITCHBOT_TOKEN=env_tok\nSWITCHBOT_SECRET=env_sec\n'); + const res = await runCli(registerConfigCommand, ['--json', 'config', 'set-token', '--from-env-file', envFile]); + expect(res.exitCode).toBeNull(); + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; + const data = expectJsonEnvelopeShape(out, ['ok', 'message']); + expect(data.ok).toBe(true); + expect(data.message).toBe('credentials saved'); + fs.rmSync(dir, { recursive: true, force: true }); + }); }); describe('agent-profile', () => { @@ -172,8 +190,12 @@ describe('config command', () => { it('emits the template as JSON without --write', async () => { const res = await runCli(registerConfigCommand, ['--json', 'config', 'agent-profile']); expect(res.exitCode).toBeNull(); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - const tpl = out.data ?? out; + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; + const tpl = expectJsonEnvelopeShape(out, ['label', 'description', 'limits', 'defaults']) as { + label: string; + limits: { dailyCap: number }; + defaults: { auditLog: boolean }; + }; expect(tpl.label).toBe('agent'); expect(tpl.limits.dailyCap).toBe(100); expect(tpl.defaults.auditLog).toBe(true); @@ -222,10 +244,15 @@ describe('config command', () => { it('--write --json returns ok:true with path and template', async () => { const res = await runCli(registerConfigCommand, ['--json', 'config', 'agent-profile', '--write']); expect(res.exitCode).toBeNull(); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - expect(out.data.ok).toBe(true); - expect(typeof out.data.path).toBe('string'); - expect(out.data.template.label).toBe('agent'); + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; + const data = expectJsonEnvelopeShape(out, ['ok', 'path', 'template']) as { + ok: boolean; + path: string; + template: { label: string }; + }; + expect(data.ok).toBe(true); + expect(typeof data.path).toBe('string'); + expect(data.template.label).toBe('agent'); }); }); }); diff --git a/tests/commands/daemon.test.ts b/tests/commands/daemon.test.ts index 97f7258..ea52ac7 100644 --- a/tests/commands/daemon.test.ts +++ b/tests/commands/daemon.test.ts @@ -42,6 +42,7 @@ vi.mock('../../src/lib/daemon-state.js', () => daemonStateMock); import { registerDaemonCommand } from '../../src/commands/daemon.js'; import { runCli } from '../helpers/cli.js'; +import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; describe('daemon command', () => { beforeEach(() => { @@ -216,9 +217,10 @@ describe('daemon status', () => { it('--json reports status:stopped when no daemon is running', async () => { const res = await runCli(registerDaemonCommand, ['--json', 'daemon', 'status']); expect(res.exitCode).toBeNull(); - const body = JSON.parse(res.stdout.join('')) as { data: { status: string; pid: unknown } }; - expect(body.data.status).toBe('stopped'); - expect(body.data.pid).toBeNull(); + const body = JSON.parse(res.stdout.join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(body, ['status', 'pid']) as { status: string; pid: unknown }; + expect(data.status).toBe('stopped'); + expect(data.pid).toBeNull(); }); it('--json reports status:running with correct pid when daemon is alive', async () => { @@ -229,9 +231,10 @@ describe('daemon status', () => { const res = await runCli(registerDaemonCommand, ['--json', 'daemon', 'status']); expect(res.exitCode).toBeNull(); - const body = JSON.parse(res.stdout.join('')) as { data: { status: string; pid: number } }; - expect(body.data.status).toBe('running'); - expect(body.data.pid).toBe(9999); + const body = JSON.parse(res.stdout.join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(body, ['status', 'pid']) as { status: string; pid: number }; + expect(data.status).toBe('running'); + expect(data.pid).toBe(9999); }); it('human output prints "not running" when stopped', async () => { diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 202745b..a132748 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; const clientInstance = vi.hoisted(() => ({ get: vi.fn(), @@ -237,6 +238,43 @@ describe('devices command', () => { expect(row!.match(/—/g)?.length).toBeGreaterThanOrEqual(2); }); + it('cache hit in --json keeps physical devices whose deviceType is missing', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ + data: { + body: { + deviceList: [ + { + deviceId: 'AI-DEV', + deviceName: 'AI MindClip', + hubDeviceId: '', + enableCloudService: true, + familyName: 'Home', + roomID: 'R1', + roomName: 'Office', + controlType: '', + }, + ], + infraredRemoteList: [], + }, + }, + }); + + const first = await runCli(registerDevicesCommand, ['--json', 'devices', 'list']); + expect(first.exitCode).toBeNull(); + const firstParsed = JSON.parse(first.stdout.join('\n')); + expect(firstParsed.data.deviceList).toHaveLength(1); + expect(firstParsed.data.deviceList[0].deviceId).toBe('AI-DEV'); + expect(apiMock.__instance.get).toHaveBeenCalledTimes(1); + + const second = await runCli(registerDevicesCommand, ['--json', 'devices', 'list']); + expect(second.exitCode).toBeNull(); + const secondParsed = JSON.parse(second.stdout.join('\n')); + expect(secondParsed.data.deviceList).toHaveLength(1); + expect(secondParsed.data.deviceList[0].deviceId).toBe('AI-DEV'); + expect(secondParsed.data.deviceList[0].deviceType).toBeUndefined(); + expect(apiMock.__instance.get).toHaveBeenCalledTimes(1); + }); + it('renders roomID column for physical devices with --wide', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); const res = await runCli(registerDevicesCommand, ['devices', 'list', '--wide']); @@ -602,6 +640,45 @@ describe('devices command', () => { expect(res.stdout.join('\n')).toContain('"power"'); }); + it('annotates empty status bodies as unsupported in --json mode', async () => { + updateCacheFromDeviceList({ + deviceList: [{ + deviceId: 'AI-EMPTY', + deviceName: 'AI MindClip', + enableCloudService: true, + hubDeviceId: '', + }], + infraredRemoteList: [], + }); + apiMock.__instance.get.mockResolvedValue({ + data: { body: {} }, + }); + const res = await runCli(registerDevicesCommand, ['devices', 'status', 'AI-EMPTY', '--json']); + const out = JSON.parse(res.stdout.join('\n')); + expect(out.data.supported).toBe(false); + expect(out.data.note).toMatch(/does not expose cloud status/i); + }); + + it('adds a stale-reading hint for zeroed Meter status without onlineStatus', async () => { + updateCacheFromDeviceList({ + deviceList: [{ + deviceId: 'METER-D9', + deviceName: 'Dead Meter', + deviceType: 'Meter', + enableCloudService: true, + hubDeviceId: 'HUB-1', + }], + infraredRemoteList: [], + }); + apiMock.__instance.get.mockResolvedValue({ + data: { body: { battery: 0, temperature: 0, humidity: 0 } }, + }); + const res = await runCli(registerDevicesCommand, ['devices', 'status', 'METER-D9', '--json']); + const out = JSON.parse(res.stdout.join('\n')); + expect(out.data.hint).toMatch(/stale/i); + expect(out.data.hint).toMatch(/batteries|hub connectivity/i); + }); + it('exits 1 when the API throws', async () => { apiMock.__instance.get.mockRejectedValue(new Error('device offline')); const res = await runCli(registerDevicesCommand, ['devices', 'status', 'BLE']); @@ -1669,6 +1746,62 @@ describe('devices command', () => { expect(out).toContain('--type customize'); }); + it('describe falls back to a live device-list fetch when the cached inventory misses the device', async () => { + updateCacheFromDeviceList({ + deviceList: [ + { deviceId: 'OTHER-1', deviceName: 'Other', deviceType: 'Bot' }, + ], + infraredRemoteList: [], + }); + apiMock.__instance.get.mockResolvedValueOnce({ + data: { + body: { + deviceList: [ + { + deviceId: 'AI-DEV', + deviceName: 'AI MindClip', + hubDeviceId: '', + enableCloudService: true, + familyName: 'Home', + roomID: 'R1', + roomName: 'Office', + controlType: '', + }, + ], + infraredRemoteList: [], + }, + }, + }); + + const res = await runCli(registerDevicesCommand, ['--json', 'devices', 'describe', 'AI-DEV']); + expect(res.exitCode).toBeNull(); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.data.device.deviceId).toBe('AI-DEV'); + expect(parsed.data.device.deviceType).toBeUndefined(); + expect(parsed.data.catalog).toBeNull(); + expect(parsed.data.catalogNote).toMatch(/No built-in catalog entry/); + expect(apiMock.__instance.get).toHaveBeenCalledTimes(1); + }); + + it('--json includes warnings when a physical device points at a missing hubDeviceId', async () => { + const body = { + deviceList: [{ + deviceId: 'METER-X', + deviceName: 'Bedroom Meter', + deviceType: 'Meter', + hubDeviceId: 'HUB-MISSING', + enableCloudService: true, + }], + infraredRemoteList: [], + }; + apiMock.__instance.get.mockResolvedValue({ data: { body } }); + const res = await runCli(registerDevicesCommand, ['devices', 'describe', 'METER-X', '--json']); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.data.warnings).toEqual([ + 'hubDeviceId HUB-MISSING is not present in the current inventory', + ]); + }); + it('exits 1 with guidance when the deviceId is unknown', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); const res = await runCli(registerDevicesCommand, ['devices', 'describe', 'UNKNOWN-ID']); @@ -1682,11 +1815,19 @@ describe('devices command', () => { apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); const res = await runCli(registerDevicesCommand, ['devices', 'describe', 'BLE-001', '--json']); const parsed = JSON.parse(res.stdout.join('\n')); - expect(parsed.data).toHaveProperty('device'); - expect(parsed.data).toHaveProperty('controlType', 'Bot'); - expect(parsed.data).toHaveProperty('catalog'); - expect(parsed.data.catalog.type).toBe('Bot'); - expect(parsed.data).not.toHaveProperty('category'); + const data = expectJsonEnvelopeContainingKeys(parsed as Record, [ + 'device', + 'controlType', + 'catalog', + 'capabilities', + 'source', + 'suggestedActions', + ]); + expect(data).toHaveProperty('device'); + expect(data).toHaveProperty('controlType', 'Bot'); + expect(data).toHaveProperty('catalog'); + expect((data.catalog as { type: string }).type).toBe('Bot'); + expect(data).not.toHaveProperty('category'); }); it('--json for IR remote surfaces controlType from the device', async () => { @@ -1748,6 +1889,53 @@ describe('devices command', () => { ).toBeUndefined(); }); + it('--json for Meter does not over-promise CO2 in advisory statusFields', async () => { + const meterBody = { + deviceList: [{ + deviceId: 'METER-1', + deviceName: 'Bedroom Meter', + deviceType: 'Meter', + hubDeviceId: 'HUB-1', + enableCloudService: true, + }], + infraredRemoteList: [], + }; + apiMock.__instance.get.mockResolvedValue({ data: { body: meterBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'describe', 'METER-1', '--json']); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.data.capabilities.statusFields).toEqual([ + 'temperature', + 'humidity', + 'battery', + 'version', + ]); + expect(parsed.data.capabilities.statusFields).not.toContain('CO2'); + }); + + it('--json for Home Climate Panel includes the fuller advisory statusFields set', async () => { + const panelBody = { + deviceList: [{ + deviceId: 'CLIMATE-1', + deviceName: 'Hall Panel', + deviceType: 'Home Climate Panel', + hubDeviceId: 'HUB-1', + enableCloudService: true, + }], + infraredRemoteList: [], + }; + apiMock.__instance.get.mockResolvedValue({ data: { body: panelBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'describe', 'CLIMATE-1', '--json']); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.data.capabilities.statusFields).toEqual([ + 'battery', + 'brightness', + 'moveDetected', + 'humidity', + 'temperature', + 'version', + ]); + }); + it('human output marks destructive commands in the command table', async () => { const lockBody = { deviceList: [{ @@ -2392,6 +2580,7 @@ describe('devices command', () => { const out = res.stdout.join('\n'); expect(out).toMatch(/dry-run/i); expect(out).toContain(DRY_ID); + expect(out).not.toMatch(/Would POST/i); }); }); @@ -2483,5 +2672,28 @@ describe('devices command', () => { const res = await runCli(registerDevicesCommand, ['devices', 'status', 'DEVICE123']); expect(res.exitCode).toBeNull(); }); + + it('fails fast on ambiguous --name within IR devices instead of calling the API', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbcli-status-ambiguous-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); + updateCacheFromDeviceList({ + deviceList: [], + infraredRemoteList: [ + { deviceId: 'IR-AC-1', deviceName: '客厅空调', remoteType: 'Air Conditioner', hubDeviceId: 'H1' }, + { deviceId: 'IR-AC-2', deviceName: '主卧空调', remoteType: 'Air Conditioner', hubDeviceId: 'H1' }, + { deviceId: 'IR-AC-3', deviceName: '书房空调', remoteType: 'Air Conditioner', hubDeviceId: 'H1' }, + ], + }); + + const res = await runCli(registerDevicesCommand, [ + 'devices', 'status', '--name', '空调', '--name-category', 'ir', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/ambiguous/i); + expect(res.stderr.join('\n')).toMatch(/IR-AC-1|IR-AC-2|IR-AC-3/); + expect(apiMock.__instance.get).not.toHaveBeenCalled(); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); }); }); diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index 565cf5a..cb7e5db 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execSync } from 'node:child_process'; +import { updateCacheFromDeviceList, resetListCache } from '../../src/devices/cache.js'; vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal(); @@ -14,6 +15,7 @@ vi.mock('node:child_process', async (importOriginal) => { import { registerDoctorCommand } from '../../src/commands/doctor.js'; import { runCli } from '../helpers/cli.js'; +import { expectJsonEnvelopeShape } from '../helpers/contracts.js'; describe('doctor command', () => { let tmp: string; @@ -22,6 +24,7 @@ describe('doctor command', () => { beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sbdoc-')); homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp); + resetListCache(); delete process.env.SWITCHBOT_TOKEN; delete process.env.SWITCHBOT_SECRET; // DEFAULT_POLICY_PATH is evaluated at module load time using the real homedir, @@ -32,6 +35,7 @@ describe('doctor command', () => { vi.mocked(execSync).mockReset().mockImplementation(() => { throw new Error('not found'); }); }); afterEach(() => { + resetListCache(); homedirSpy.mockRestore(); delete process.env.SWITCHBOT_POLICY_PATH; fs.rmSync(tmp, { recursive: true, force: true }); @@ -138,7 +142,16 @@ describe('doctor command', () => { process.env.SWITCHBOT_SECRET = 's'; const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - const data = payload.data; + const data = expectJsonEnvelopeShape(payload as Record, [ + 'ok', + 'overall', + 'maturityScore', + 'maturityLabel', + 'generatedAt', + 'schemaVersion', + 'summary', + 'checks', + ]); expect(typeof data.ok).toBe('boolean'); expect(['ok', 'warn', 'fail']).toContain(data.overall); expect(typeof data.generatedAt).toBe('string'); @@ -321,6 +334,7 @@ describe('doctor command', () => { expect(names).toContain('credentials'); expect(names).toContain('mcp'); expect(names).toContain('catalog-schema'); + expect(names).toContain('inventory'); expect(names).toContain('audit'); // Should NOT include check results — just registry entries with description. expect(payload.data.summary).toBeUndefined(); @@ -677,4 +691,48 @@ describe('doctor command', () => { expect(Number.isInteger(payload.data.maturityScore)).toBe(true); }); }); + + it('inventory check warns when a cached device points at a missing hubDeviceId', async () => { + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + updateCacheFromDeviceList({ + deviceList: [ + { + deviceId: 'METER-1', + deviceName: 'Bedroom Meter', + deviceType: 'Meter', + hubDeviceId: 'HUB-MISSING', + enableCloudService: true, + }, + ], + infraredRemoteList: [], + }); + const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--section', 'inventory']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const inventory = payload.data.checks.find((c: { name: string }) => c.name === 'inventory'); + expect(inventory.status).toBe('warn'); + expect(inventory.detail.message).toMatch(/hubDeviceId/); + expect(inventory.detail.dangling[0]).toMatchObject({ + deviceId: 'METER-1', + hubDeviceId: 'HUB-MISSING', + }); + }); + + it('release-notes check is ok when RELEASE_METADATA carries no breaking notice for the current release', async () => { + // The release-notes check is a contract between doctor and + // src/version-notes.ts RELEASE_METADATA. When no entry exists for + // the running version (or the entry has `breaking: false`), the + // check must report 'ok'. The 3.3.0 envelope-breaking entry that + // previously lit this path up was removed in 3.3.1 after we + // verified the envelope actually shipped in 2.0.0 (commit 33d3825), + // not 3.3.0 — it was a false breaking claim. + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--section', 'release-notes']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const note = payload.data.checks.find((c: { name: string }) => c.name === 'release-notes'); + expect(note).toBeDefined(); + expect(note.status).toBe('ok'); + expect(String(note.detail.message)).toMatch(/no known breaking-change notice/i); + }); }); diff --git a/tests/commands/events.test.ts b/tests/commands/events.test.ts index 418350f..42c849c 100644 --- a/tests/commands/events.test.ts +++ b/tests/commands/events.test.ts @@ -9,6 +9,11 @@ import { startReceiver, registerEventsCommand } from '../../src/commands/events. import type { FilterClause } from '../../src/utils/filter.js'; import { deviceHistoryStore } from '../../src/mcp/device-history.js'; import { runCli } from '../helpers/cli.js'; +import { + expectStreamHeaderShape, + expectStreamJsonEnvelopeContainingKeys, + expectStreamJsonEnvelopeShape, +} from '../helpers/contracts.js'; // --------------------------------------------------------------------------- // Shared mock state for SwitchBotMqttClient — hoisted so the factory can use it @@ -376,8 +381,18 @@ describe('events mqtt-tail', () => { (typeof j.data?.type !== 'string' || !j.data.type.startsWith('__')), ); expect(events).toHaveLength(1); - expect(events[0].schemaVersion).toBe('1.1'); - expect(events[0].data!.topic).toBe('test/topic'); + const data = expectStreamJsonEnvelopeContainingKeys(events[0] as Record, [ + 'payloadVersion', + 'source', + 'kind', + 't', + 'eventId', + 'deviceId', + 'topic', + 'payload', + ]); + expect(data.payloadVersion).toBe('1'); + expect(data.topic).toBe('test/topic'); }); it('exits 2 when --max is not a positive integer', async () => { @@ -439,14 +454,19 @@ describe('events mqtt-tail', () => { stream?: boolean; eventKind?: string; cadence?: string; - data?: { type?: string; state?: string; at?: string; eventId?: string }; + data?: { payloadVersion?: string; type?: string; state?: string; at?: string; eventId?: string }; }, ); const sessionStart = jsonLines.find((j) => j.data?.type === '__session_start'); expect(sessionStart).toBeDefined(); - expect(sessionStart!.data!.state).toBe('connecting'); - expect(typeof sessionStart!.data!.at).toBe('string'); - expect(typeof sessionStart!.data!.eventId).toBe('string'); + const sessionData = expectStreamJsonEnvelopeContainingKeys( + sessionStart as Record, + ['payloadVersion', 'source', 'kind', 'controlKind', 't', 'eventId', 'state', 'type', 'at'], + ); + expect(sessionData.payloadVersion).toBe('1'); + expect(sessionData.state).toBe('connecting'); + expect(typeof sessionData.at).toBe('string'); + expect(typeof sessionData.eventId).toBe('string'); // P7: the very first JSON line under --json is the stream header; // __session_start is now the second line but still precedes any // broker activity so consumers still learn we're "connecting". @@ -524,10 +544,47 @@ describe('events mqtt-tail', () => { eventKind: string; cadence: string; }; - expect(header.schemaVersion).toBe('1'); - expect(header.stream).toBe(true); - expect(header.eventKind).toBe('event'); - expect(header.cadence).toBe('push'); + expectStreamHeaderShape(header as Record, 'event', 'push'); + }); + + it('P7: mqtt-tail JSON event lines keep the unified envelope and payloadVersion fields', async () => { + mqttMock.connectShouldFireMessage = true; + const res = await runCli(registerEventsCommand, ['--json', 'events', 'mqtt-tail', '--max', '1']); + const records = res.stdout + .filter((l) => l.trim().startsWith('{')) + .map((l) => JSON.parse(l)) + .filter((r) => !r.stream); + expect(records.length).toBeGreaterThan(0); + const data = expectStreamJsonEnvelopeContainingKeys(records[0] as Record, [ + 'payloadVersion', + 'source', + 'kind', + 't', + 'eventId', + ]); + expect(data.payloadVersion).toBe('1'); + expect(data.source).toBe('mqtt'); + expect(data.kind).toBeDefined(); + }); + + it('P7: mqtt-tail JSON event records keep the exact event envelope for real broker messages', async () => { + mqttMock.connectShouldFireMessage = true; + const res = await runCli(registerEventsCommand, ['--json', 'events', 'mqtt-tail', '--max', '1']); + const eventRecord = res.stdout + .filter((l) => l.trim().startsWith('{')) + .map((l) => JSON.parse(l) as Record) + .find((r) => r.stream !== true && (r.data as Record | undefined)?.kind === 'event'); + expect(eventRecord).toBeDefined(); + expectStreamJsonEnvelopeShape(eventRecord!, [ + 'payloadVersion', + 'source', + 'kind', + 't', + 'eventId', + 'deviceId', + 'topic', + 'payload', + ]); }); }); diff --git a/tests/commands/expand.test.ts b/tests/commands/expand.test.ts index 28f36af..69dc5b0 100644 --- a/tests/commands/expand.test.ts +++ b/tests/commands/expand.test.ts @@ -24,6 +24,7 @@ vi.mock('../../src/api/client.js', () => ({ import { registerDevicesCommand } from '../../src/commands/devices.js'; import { runCli } from '../helpers/cli.js'; import { updateCacheFromDeviceList, resetListCache } from '../../src/devices/cache.js'; +import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; const AC_ID = 'AC-001'; const CURTAIN_ID = 'CURTAIN-001'; @@ -197,8 +198,9 @@ describe('devices expand', () => { 'devices', 'expand', AC_ID, 'setAll', '--temp', '26', '--mode', 'cool', '--fan', 'low', '--power', 'on', '--json', ]); - const out = JSON.parse(res.stdout.join('\n')); - expect(out.data.subKind).toBe('ir-no-feedback'); + const out = JSON.parse(res.stdout.join('\n')) as Record; + const data = expectJsonEnvelopeContainingKeys(out, ['ok', 'deviceId', 'command', 'parameter', 'subKind']); + expect(data.subKind).toBe('ir-no-feedback'); }); it('rejects unsupported command', async () => { @@ -220,4 +222,24 @@ describe('devices expand', () => { expect.objectContaining({ command: 'setPosition' }) ); }); + + it('--name-category and --name-room are forwarded to name resolution', async () => { + updateCacheFromDeviceList({ + deviceList: [ + { deviceId: 'CURTAIN-LR', deviceName: 'Curtain', deviceType: 'Curtain', hubDeviceId: 'H1', enableCloudService: true, roomName: 'Living Room' }, + { deviceId: 'CURTAIN-BR', deviceName: 'Curtain', deviceType: 'Curtain', hubDeviceId: 'H1', enableCloudService: true, roomName: 'Bedroom' }, + ], + infraredRemoteList: [], + }); + + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', '--name', 'Curtain', '--name-category', 'physical', '--name-room', 'Bed', 'setPosition', + '--position', '50', + ]); + expect(res.exitCode).toBe(null); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + '/v1.1/devices/CURTAIN-BR/commands', + expect.objectContaining({ command: 'setPosition' }), + ); + }); }); diff --git a/tests/commands/explain.test.ts b/tests/commands/explain.test.ts index 6f2796d..24b2177 100644 --- a/tests/commands/explain.test.ts +++ b/tests/commands/explain.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Command } from 'commander'; import { runCli } from '../helpers/cli.js'; +import { expectJsonEnvelopeShape } from '../helpers/contracts.js'; // --------------------------------------------------------------------------- // Mock the lib/devices layer so no real HTTP calls are made. @@ -116,20 +117,35 @@ describe('devices explain', () => { expect(res.exitCode).toBeNull(); const parsed = JSON.parse(res.stdout[0]); - expect(parsed.data.deviceId).toBe(DID); - expect(parsed.data.type).toBe('Bot'); - expect(parsed.data.category).toBe('physical'); - expect(parsed.data.name).toBe('My Bot'); - expect(parsed.data.role).toBe('power'); - expect(parsed.data.readOnly).toBe(false); - expect(Array.isArray(parsed.data.commands)).toBe(true); - expect(parsed.data.commands[0].command).toBe('turnOn'); - expect(parsed.data.commands[0].idempotent).toBe(true); - expect(Array.isArray(parsed.data.statusFields)).toBe(true); - expect(parsed.data.liveStatus).toMatchObject({ power: 'on', battery: 95 }); - expect(Array.isArray(parsed.data.suggestedActions)).toBe(true); - expect(Array.isArray(parsed.data.warnings)).toBe(true); - expect(parsed.data.warnings).toHaveLength(0); + const data = expectJsonEnvelopeShape(parsed as Record, [ + 'deviceId', + 'type', + 'category', + 'name', + 'role', + 'readOnly', + 'location', + 'liveStatus', + 'commands', + 'statusFields', + 'children', + 'suggestedActions', + 'warnings', + ]); + expect(data.deviceId).toBe(DID); + expect(data.type).toBe('Bot'); + expect(data.category).toBe('physical'); + expect(data.name).toBe('My Bot'); + expect(data.role).toBe('power'); + expect(data.readOnly).toBe(false); + expect(Array.isArray(data.commands)).toBe(true); + expect((data.commands as Array<{ command: string }>)[0].command).toBe('turnOn'); + expect((data.commands as Array<{ idempotent?: boolean }>)[0].idempotent).toBe(true); + expect(Array.isArray(data.statusFields)).toBe(true); + expect(data.liveStatus).toMatchObject({ power: 'on', battery: 95 }); + expect(Array.isArray(data.suggestedActions)).toBe(true); + expect(Array.isArray(data.warnings)).toBe(true); + expect(data.warnings).toHaveLength(0); }); it('--json: device not found emits { error: { code:1, kind:"runtime" } } on stdout (bug #SYS-1)', async () => { @@ -186,6 +202,36 @@ describe('devices explain', () => { expect(parsed.data.warnings.some((w: string) => w.toLowerCase().includes('cloud'))).toBe(true); }); + it('--json: surfaces dangling hubDeviceId warnings from describeDevice', async () => { + devicesMock.describeDevice.mockResolvedValue({ + ...botDescribeResult, + warnings: ['hubDeviceId HUB-MISSING is not present in the current inventory'], + }); + + const res = await runExplain('--json', DID); + + const parsed = JSON.parse(res.stdout[0]); + expect(parsed.data.warnings).toContain( + 'hubDeviceId HUB-MISSING is not present in the current inventory', + ); + }); + + it('--json: exposes catalogNote for uncatalogued devices', async () => { + devicesMock.describeDevice.mockResolvedValue({ + ...botDescribeResult, + catalog: null, + capabilities: null, + source: 'none', + catalogNote: 'No built-in catalog entry for type "AI MindClip"; try `switchbot devices status BOT-001 --json` for raw status.', + }); + + const res = await runExplain('--json', DID); + + const parsed = JSON.parse(res.stdout[0]); + expect(parsed.data.catalogNote).toMatch(/No built-in catalog entry/); + expect(parsed.data.warnings.some((w: string) => w.includes('No catalog entry'))).toBe(true); + }); + it('--json: hub role fetches and lists IR children', async () => { const hubResult = { ...botDescribeResult, diff --git a/tests/commands/health-check.test.ts b/tests/commands/health-check.test.ts index aeeb8ce..356eeff 100644 --- a/tests/commands/health-check.test.ts +++ b/tests/commands/health-check.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { HealthReport } from '../../src/utils/health.js'; +import { expectJsonEnvelopeShape } from '../helpers/contracts.js'; const healthMock = vi.hoisted(() => ({ getHealthReport: vi.fn<[], HealthReport>(), @@ -41,11 +42,19 @@ describe('health check CLI', () => { it('--json exits 0 and includes overall, quota, circuit, process', async () => { const res = await runCli(registerHealthCommand, ['--json', 'health', 'check']); expect(res.exitCode).toBeNull(); - const body = JSON.parse(res.stdout.join('')) as { data: HealthReport }; - expect(body.data.overall).toBe('ok'); - expect(body.data.quota).toBeDefined(); - expect(body.data.circuit).toBeDefined(); - expect(body.data.process).toBeDefined(); + const body = JSON.parse(res.stdout.join('')) as Record; + const data = expectJsonEnvelopeShape(body, [ + 'generatedAt', + 'overall', + 'process', + 'quota', + 'audit', + 'circuit', + ]) as HealthReport; + expect(data.overall).toBe('ok'); + expect(data.quota).toBeDefined(); + expect(data.circuit).toBeDefined(); + expect(data.process).toBeDefined(); }); it('--json exits 0 even when overall is degraded (no process.exit in JSON mode)', async () => { @@ -62,6 +71,12 @@ describe('health check CLI', () => { expect(res.stdout.join(' ')).toMatch(/overall.*ok/i); }); + it('bare health defaults to check', async () => { + const res = await runCli(registerHealthCommand, ['health']); + expect(res.exitCode).toBeNull(); + expect(res.stdout.join(' ')).toMatch(/overall.*ok/i); + }); + it('human mode exits 1 when overall is degraded', async () => { healthMock.getHealthReport.mockReturnValue(DEGRADED_REPORT); const res = await runCli(registerHealthCommand, ['health', 'check']); diff --git a/tests/commands/history.test.ts b/tests/commands/history.test.ts index 8457113..347f78c 100644 --- a/tests/commands/history.test.ts +++ b/tests/commands/history.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { registerHistoryCommand } from '../../src/commands/history.js'; import { runCli } from '../helpers/cli.js'; +import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; const apiMock = vi.hoisted(() => { const instance = { get: vi.fn(), post: vi.fn() }; @@ -100,9 +101,13 @@ describe('history command', () => { const res = await runCli(registerHistoryCommand, [ '--json', 'history', 'show', '--file', auditFile, ]); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - expect(out.data.total).toBe(1); - expect(out.data.entries[0].deviceId).toBe('A'); + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(out, ['file', 'total', 'entries']) as { + total: number; + entries: Array<{ deviceId: string }>; + }; + expect(data.total).toBe(1); + expect(data.entries[0].deviceId).toBe('A'); }); }); @@ -266,10 +271,15 @@ describe('history range / stats (D3)', () => { const res = await runCli(registerHistoryCommand, [ '--json', 'history', 'range', 'DEV1', '--since', '5m', ]); - const envelope = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - expect(envelope.data.deviceId).toBe('DEV1'); - expect(envelope.data.count).toBe(2); - expect(envelope.data.records.length).toBe(2); + const envelope = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(envelope, ['deviceId', 'count', 'records']) as { + deviceId: string; + count: number; + records: unknown[]; + }; + expect(data.deviceId).toBe('DEV1'); + expect(data.count).toBe(2); + expect(data.records.length).toBe(2); }); it('--field can be repeated to project payload subset', async () => { @@ -301,10 +311,15 @@ describe('history range / stats (D3)', () => { { t: '2026-04-10T00:00:00Z', topic: 't', payload: {} }, ]); const res = await runCli(registerHistoryCommand, ['--json', 'history', 'stats', 'DEV1']); - const env = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - expect(env.data.deviceId).toBe('DEV1'); - expect(env.data.recordCount).toBe(1); - expect(env.data.oldest).toBe('2026-04-10T00:00:00.000Z'); + const env = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(env, ['deviceId', 'recordCount', 'jsonlFiles', 'oldest', 'newest']) as { + deviceId: string; + recordCount: number; + oldest: string; + }; + expect(data.deviceId).toBe('DEV1'); + expect(data.recordCount).toBe(1); + expect(data.oldest).toBe('2026-04-10T00:00:00.000Z'); }); }); @@ -341,9 +356,12 @@ describe('history aggregate (D7)', () => { '--metric', 'temperature', '--agg', 'count,avg', ]); - const parsed = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - expect(parsed.data.buckets[0].metrics.temperature.count).toBe(2); - expect(parsed.data.buckets[0].metrics.temperature.avg).toBe(22); + const parsed = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['deviceId', 'from', 'to', 'metrics', 'aggs', 'buckets', 'partial', 'notes']) as { + buckets: Array<{ metrics: { temperature: { count: number; avg: number } } }>; + }; + expect(data.buckets[0].metrics.temperature.count).toBe(2); + expect(data.buckets[0].metrics.temperature.avg).toBe(22); }); it('exits with error when --metric is missing (requiredOption enforcement, bug #42)', async () => { diff --git a/tests/commands/install.test.ts b/tests/commands/install.test.ts index dadc28b..4d0dbd7 100644 --- a/tests/commands/install.test.ts +++ b/tests/commands/install.test.ts @@ -4,6 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CLI = path.resolve(__dirname, '..', '..', 'dist', 'index.js'); @@ -46,11 +47,16 @@ describe('switchbot install (dry-run smoke)', () => { it('--dry-run --json emits a structured preview', () => { const { code, stdout } = runCli(['install', '--dry-run', '--json', '--agent', 'none']); expect(code).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.data.dryRun).toBe(true); - expect(parsed.data.agent).toBe('none'); - expect(parsed.data.steps).toHaveLength(4); - expect(parsed.data.steps.map((s: { name: string }) => s.name)).toEqual([ + const parsed = JSON.parse(stdout) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['dryRun', 'agent', 'steps']) as { + dryRun: boolean; + agent: string; + steps: Array<{ name: string }>; + }; + expect(data.dryRun).toBe(true); + expect(data.agent).toBe('none'); + expect(data.steps).toHaveLength(4); + expect(data.steps.map((s) => s.name)).toEqual([ 'prompt-credentials', 'write-keychain', 'scaffold-policy', @@ -110,9 +116,13 @@ describe('switchbot install (dry-run smoke)', () => { fs.rmSync(fakeHome, { recursive: true, force: true }); expect(code).toBe(2); - const parsed = JSON.parse(stdout); - expect(parsed.data.stage).toBe('preflight'); - const failedNames = parsed.data.preflight.checks + const parsed = JSON.parse(stdout) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['ok', 'stage', 'preflight']) as { + stage: string; + preflight: { checks: Array<{ status: string; name: string }> }; + }; + expect(data.stage).toBe('preflight'); + const failedNames = data.preflight.checks .filter((c: { status: string }) => c.status === 'fail') .map((c: { name: string }) => c.name); expect(failedNames).toContain('agent-skills-dir'); diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index f15ce6d..33faf6c 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -72,9 +72,10 @@ vi.mock('../../src/devices/cache.js', () => ({ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; -import { createSwitchBotMcpServer } from '../../src/commands/mcp.js'; +import { createSwitchBotMcpServer, registerMcpCommand } from '../../src/commands/mcp.js'; import { registerPolicyCommand } from '../../src/commands/policy.js'; import { ApiError } from '../../src/api/client.js'; +import { runCli } from '../helpers/cli.js'; /** Connect a fresh server + client pair and return both. */ async function pair() { @@ -126,6 +127,28 @@ function runPolicyDiffCliJson(leftPath: string, rightPath: string): Record { + it('mcp tools --json returns a machine-readable tool directory', async () => { + const res = await runCli(registerMcpCommand, ['--json', 'mcp', 'tools']); + expect(res.exitCode).toBeNull(); + const out = JSON.parse(res.stdout.join('\n')); + expect(Object.keys(out)).toEqual(['schemaVersion', 'data']); + expect(Object.keys(out.data)).toEqual(['tools', 'resources']); + expect(Array.isArray(out.data.tools)).toBe(true); + expect(out.data.tools.some((t: { name: string }) => t.name === 'list_devices')).toBe(true); + expect(Array.isArray(out.data.resources)).toBe(true); + expect(out.data.resources.some((r: { uri: string }) => r.uri === 'switchbot://events')).toBe(true); + }); + + it('mcp list-tools --json is an alias of mcp tools', async () => { + const res = await runCli(registerMcpCommand, ['--json', 'mcp', 'list-tools']); + expect(res.exitCode).toBeNull(); + const out = JSON.parse(res.stdout.join('\n')); + expect(Array.isArray(out.data.tools)).toBe(true); + expect(out.data.tools.some((t: { name: string }) => t.name === 'list_devices')).toBe(true); + expect(Array.isArray(out.data.resources)).toBe(true); + expect(out.data.resources.some((r: { uri: string }) => r.uri === 'switchbot://events')).toBe(true); + }); + beforeEach(() => { apiMock.__instance.get.mockReset(); apiMock.__instance.post.mockReset(); @@ -444,6 +467,8 @@ describe('mcp server', () => { expect(res.isError).toBe(true); const text = (res.content as Array<{ text: string }>)[0].text; expect(text).toMatch(/No device with id/); + expect(apiMock.__instance.get).toHaveBeenCalledTimes(1); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/devices'); }); it('describe_device with live:true merges /status payload', async () => { @@ -860,6 +885,8 @@ describe('mcp server', () => { expect(sc.present).toBe(false); expect(sc.valid).toBeNull(); expect(sc.policyPath).toBe(missing); + expect(sc.validationScope).toBe('schema+offline-semantics'); + expect(Array.isArray(sc.limitations)).toBe(true); }); it('policy_validate returns valid:false with unsupported-version on a v0.1 file (v3.0)', async () => { @@ -874,6 +901,7 @@ describe('mcp server', () => { const sc = (res as { structuredContent?: Record }).structuredContent!; expect(sc.present).toBe(true); expect(sc.valid).toBe(false); + expect(sc.validationScope).toBe('schema+offline-semantics'); const errors = sc.errors as Array<{ keyword: string }>; expect(Array.isArray(errors)).toBe(true); expect(errors.some((e) => e.keyword === 'unsupported-version')).toBe(true); @@ -896,6 +924,34 @@ describe('mcp server', () => { expect((sc.errors as unknown[]).length).toBeGreaterThan(0); }); + it('policy_validate live:true resolves aliases against the current inventory', async () => { + const policyPath = path.join(tmp, 'live-bad.yaml'); + fs.writeFileSync( + policyPath, + [ + 'version: "0.2"', + 'aliases:', + ' room-sensor: 01-202407090924-26354212', + '', + ].join('\n'), + ); + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + apiMock.__instance.get.mockResolvedValueOnce({ + data: { body: { deviceList: [], infraredRemoteList: [] } }, + }); + const { client } = await pair(); + const res = await client.callTool({ + name: 'policy_validate', + arguments: { path: policyPath, live: true }, + }); + const sc = (res as { structuredContent?: Record }).structuredContent!; + expect(sc.valid).toBe(false); + expect(sc.validationScope).toBe('schema+offline-semantics+live-inventory'); + const errors = sc.errors as Array<{ keyword: string }>; + expect(errors.some((e) => e.keyword === 'alias-live-device-not-found')).toBe(true); + }); + it('policy_new writes a starter file and refuses to overwrite without force', async () => { const policyPath = path.join(tmp, 'policy.yaml'); const { client } = await pair(); diff --git a/tests/commands/plan-suggest.test.ts b/tests/commands/plan-suggest.test.ts index 6bb86ab..385ba48 100644 --- a/tests/commands/plan-suggest.test.ts +++ b/tests/commands/plan-suggest.test.ts @@ -70,6 +70,11 @@ describe('suggestPlan', () => { expect(plan.steps[0]).toMatchObject({ command: 'turnOn' }); }); + it('fails fast on unsupported Chinese command intent instead of defaulting silently', () => { + expect(() => suggestPlan({ intent: '关掉所有灯', devices: [{ id: 'D1' }] })) + .toThrow(/cannot safely infer/i); + }); + it('generates one step per device', () => { const { plan } = suggestPlan({ intent: 'turn off', devices }); expect(plan.steps).toHaveLength(2); diff --git a/tests/commands/plan.test.ts b/tests/commands/plan.test.ts index 3a87b6c..537fc44 100644 --- a/tests/commands/plan.test.ts +++ b/tests/commands/plan.test.ts @@ -67,6 +67,7 @@ vi.mock('../../src/utils/flags.js', () => flagsMock); import { registerPlanCommand, validatePlan } from '../../src/commands/plan.js'; import { runCli } from '../helpers/cli.js'; +import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; describe('plan command', () => { let tmp: string; @@ -161,9 +162,10 @@ describe('plan command', () => { steps: [{ type: 'command', deviceId: 'A', command: 'turnOn' }], }); const res = await runCli(registerPlanCommand, ['--json', 'plan', 'validate', file]); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')).data; - expect(out.valid).toBe(true); - expect(out.steps).toBe(1); + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(out, ['valid', 'steps']) as { valid: boolean; steps: number }; + expect(data.valid).toBe(true); + expect(data.steps).toBe(1); }); it('--help output contains "structural only" (bug #32)', async () => { @@ -270,9 +272,36 @@ describe('plan command', () => { }); apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); const res = await runCli(registerPlanCommand, ['--json', 'plan', 'run', file]); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')).data; - expect(out.ran).toBe(true); - expect(out.summary).toEqual({ total: 1, ok: 1, error: 0, skipped: 0 }); + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(out, ['ran', 'planId', 'summary', 'results']) as { + ran: boolean; + summary: Record; + }; + expect(data.ran).toBe(true); + expect(data.summary).toEqual({ total: 1, ok: 1, error: 0, skipped: 0, dryRun: 0 }); + }); + + it('--dry-run reports command steps with status=dry-run instead of ok', async () => { + flagsMock.dryRun = true; + const file = writePlan({ + version: '1.0', + steps: [{ type: 'command', deviceId: 'BOT1', command: 'turnOn' }], + }); + apiMock.__instance.post.mockImplementation(async () => { + throw new apiMock.DryRunSignal('POST', '/v1.1/devices/BOT1/commands'); + }); + + const res = await runCli(registerPlanCommand, ['--json', '--dry-run', 'plan', 'run', file]); + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(out, ['ran', 'planId', 'summary', 'results']) as { + ran: boolean; + summary: Record; + results: Array<{ status: string }>; + }; + + expect(data.ran).toBe(true); + expect(data.summary).toEqual({ total: 1, ok: 0, error: 0, skipped: 0, dryRun: 1 }); + expect(data.results[0].status).toBe('dry-run'); }); it('writes audit entries tagged with the generated planId', async () => { @@ -284,13 +313,24 @@ describe('plan command', () => { apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); const res = await runCli(registerPlanCommand, ['--json', 'plan', 'run', file]); - const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')).data; + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(out, ['ran', 'planId', 'summary', 'results']) as { planId: string }; const entries = readAudit(auditFile); expect(entries).toHaveLength(1); expect(entries[0].deviceId).toBe('BOT1'); expect(entries[0].result).toBe('ok'); - expect(entries[0].planId).toBe(out.planId); + expect(entries[0].planId).toBe(data.planId); + }); + }); + + describe('plan suggest', () => { + it('exits 2 for unsupported Chinese command intent instead of defaulting to turnOn', async () => { + const res = await runCli(registerPlanCommand, [ + 'plan', 'suggest', '--intent', '关掉所有灯', '--device', 'BOT1', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/cannot safely infer/i); }); }); }); diff --git a/tests/commands/policy.test.ts b/tests/commands/policy.test.ts index 927c422..4429275 100644 --- a/tests/commands/policy.test.ts +++ b/tests/commands/policy.test.ts @@ -14,6 +14,7 @@ import path from 'node:path'; import { Command } from 'commander'; import { registerPolicyCommand } from '../../src/commands/policy.js'; +import { expectJsonEnvelopeShape } from '../helpers/contracts.js'; function makeProgram(): Command { const program = new Command(); @@ -35,7 +36,7 @@ class ExitError extends Error { } } -function runCli(argv: string[]): RunResult { +async function runCli(argv: string[]): Promise { const stdout: string[] = []; const stderr: string[] = []; const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { @@ -53,7 +54,7 @@ function runCli(argv: string[]): RunResult { const prevArgv = process.argv; process.argv = ['node', 'switchbot', ...argv]; try { - program.parse(['node', 'switchbot', ...argv]); + await program.parseAsync(['node', 'switchbot', ...argv]); } catch (err) { if (err instanceof ExitError) exitCode = err.code; else throw err; @@ -78,9 +79,9 @@ describe('switchbot policy (commander surface)', () => { }); describe('policy new', () => { - it('writes the starter template to the given path (exit 0)', () => { + it('writes the starter template to the given path (exit 0)', async () => { const p = path.join(tmpDir, 'policy.yaml'); - const { stdout, exitCode } = runCli(['policy', 'new', p]); + const { stdout, exitCode } = await runCli(['policy', 'new', p]); expect(exitCode).toBe(0); expect(fs.existsSync(p)).toBe(true); const contents = fs.readFileSync(p, 'utf-8'); @@ -88,26 +89,26 @@ describe('switchbot policy (commander surface)', () => { expect(stdout.join('\n')).toContain('wrote starter policy'); }); - it('refuses to overwrite an existing file without --force (exit 5)', () => { + it('refuses to overwrite an existing file without --force (exit 5)', async () => { const p = path.join(tmpDir, 'policy.yaml'); fs.writeFileSync(p, 'original\n', 'utf-8'); - const { stderr, exitCode } = runCli(['policy', 'new', p]); + const { stderr, exitCode } = await runCli(['policy', 'new', p]); expect(exitCode).toBe(5); expect(fs.readFileSync(p, 'utf-8')).toBe('original\n'); expect(stderr.join('\n')).toContain('refusing to overwrite'); }); - it('overwrites with --force', () => { + it('overwrites with --force', async () => { const p = path.join(tmpDir, 'policy.yaml'); fs.writeFileSync(p, 'original\n', 'utf-8'); - const { exitCode } = runCli(['policy', 'new', p, '--force']); + const { exitCode } = await runCli(['policy', 'new', p, '--force']); expect(exitCode).toBe(0); expect(fs.readFileSync(p, 'utf-8')).toMatch(/version: "0\.2"/); }); - it('emits a structured --json envelope on success', () => { + it('emits a structured --json envelope on success', async () => { const p = path.join(tmpDir, 'policy.yaml'); - const { stdout, exitCode } = runCli(['--json', 'policy', 'new', p]); + const { stdout, exitCode } = await runCli(['--json', 'policy', 'new', p]); expect(exitCode).toBe(0); const parsed = JSON.parse(stdout[0]) as { schemaVersion: string; @@ -118,15 +119,23 @@ describe('switchbot policy (commander surface)', () => { expect(parsed.data.schemaVersion).toBe('0.2'); }); - it('emits a --json error envelope when the file exists', () => { + it('emits a --json error envelope when the file exists', async () => { const p = path.join(tmpDir, 'policy.yaml'); fs.writeFileSync(p, 'original\n', 'utf-8'); - const { stdout, exitCode } = runCli(['--json', 'policy', 'new', p]); + const { stdout, exitCode } = await runCli(['--json', 'policy', 'new', p]); expect(exitCode).toBe(5); const parsed = JSON.parse(stdout[0]) as { error: { code: number; kind: string } }; expect(parsed.error.code).toBe(5); expect(parsed.error.kind).toBe('exists'); }); + + it('accepts --output as an alias of the positional path', async () => { + const p = path.join(tmpDir, 'out-policy.yaml'); + const { exitCode } = await runCli(['policy', 'new', '--output', p]); + expect(exitCode).toBe(0); + expect(fs.existsSync(p)).toBe(true); + expect(fs.readFileSync(p, 'utf-8')).toMatch(/version: "0\.2"/); + }); }); describe('policy validate', () => { @@ -146,53 +155,72 @@ describe('switchbot policy (commander surface)', () => { return p; } - it('exits 0 on a valid policy and prints the green tick line', () => { + it('exits 0 on a valid policy and prints the green tick line', async () => { const p = seedValid(); - const { stdout, exitCode } = runCli(['policy', 'validate', p]); + const { stdout, exitCode } = await runCli(['policy', 'validate', p]); expect(exitCode).toBe(0); - expect(stdout.join('\n')).toMatch(/is valid \(schema v0\.2\)/); + const out = stdout.join('\n'); + expect(out).toMatch(/is valid \(schema v0\.2\)/); + expect(out).toMatch(/Validation scope: schema \+ offline semantics \+ local safety guards\./); + expect(out).toMatch(/Not checked: alias targets against live devices/); }); - it('exits 1 on an invalid policy and prints error blocks', () => { + it('exits 1 on an invalid policy and prints error blocks', async () => { const p = seedInvalid(); - const { stdout, exitCode } = runCli(['policy', 'validate', p]); + const { stdout, exitCode } = await runCli(['policy', 'validate', p]); expect(exitCode).toBe(1); const out = stdout.join('\n'); expect(out).toContain('error'); expect(out).toMatch(/1 error/); }); - it('exits 2 when the file does not exist with a hint', () => { + it('exits 2 when the file does not exist with a hint', async () => { const missing = path.join(tmpDir, 'nope.yaml'); - const { stderr, exitCode } = runCli(['policy', 'validate', missing]); + const { stderr, exitCode } = await runCli(['policy', 'validate', missing]); expect(exitCode).toBe(2); expect(stderr.join('\n')).toContain('policy file not found'); }); - it('exits 3 on YAML parse errors', () => { + it('exits 3 on YAML parse errors', async () => { const p = path.join(tmpDir, 'bad.yaml'); fs.writeFileSync(p, 'version: "0.2"\naliases: [unterminated\n', 'utf-8'); - const { stderr, exitCode } = runCli(['policy', 'validate', p]); + const { stderr, exitCode } = await runCli(['policy', 'validate', p]); expect(exitCode).toBe(3); expect(stderr.join('\n')).toContain('YAML parse error'); }); - it('emits a full validation envelope in --json mode on success', () => { + it('emits a full validation envelope in --json mode on success', async () => { const p = seedValid(); - const { stdout, exitCode } = runCli(['--json', 'policy', 'validate', p]); + const { stdout, exitCode } = await runCli(['--json', 'policy', 'validate', p]); expect(exitCode).toBe(0); const parsed = JSON.parse(stdout[0]) as { schemaVersion: string; - data: { valid: boolean; errors: unknown[]; schemaVersion: string }; + data: { + valid: boolean; + errors: unknown[]; + schemaVersion: string; + validationScope: string; + limitations: string[]; + }; }; expect(parsed.data.valid).toBe(true); expect(parsed.data.errors).toEqual([]); expect(parsed.data.schemaVersion).toBe('0.2'); - }); - - it('emits a validation envelope in --json mode on failure (still exit 1)', () => { + expect(parsed.data.validationScope).toBe('schema+offline-semantics'); + expect(parsed.data.limitations.length).toBeGreaterThan(0); + expectJsonEnvelopeShape(parsed as Record, [ + 'policyPath', + 'schemaVersion', + 'validationScope', + 'limitations', + 'valid', + 'errors', + ]); + }); + + it('emits a validation envelope in --json mode on failure (still exit 1)', async () => { const p = seedInvalid(); - const { stdout, exitCode } = runCli(['--json', 'policy', 'validate', p]); + const { stdout, exitCode } = await runCli(['--json', 'policy', 'validate', p]); expect(exitCode).toBe(1); const parsed = JSON.parse(stdout[0]) as { data: { valid: boolean; errors: Array<{ keyword: string }> }; @@ -201,9 +229,9 @@ describe('switchbot policy (commander surface)', () => { expect(parsed.data.errors.some((e) => e.keyword === 'unsupported-version')).toBe(true); }); - it('emits a file-not-found envelope in --json mode (exit 2)', () => { + it('emits a file-not-found envelope in --json mode (exit 2)', async () => { const missing = path.join(tmpDir, 'nope.yaml'); - const { stdout, exitCode } = runCli(['--json', 'policy', 'validate', missing]); + const { stdout, exitCode } = await runCli(['--json', 'policy', 'validate', missing]); expect(exitCode).toBe(2); const parsed = JSON.parse(stdout[0]) as { error: { code: number; kind: string; hint: string }; @@ -222,16 +250,16 @@ describe('switchbot policy (commander surface)', () => { return p; } - it('reports "already-current" on v0.2 with exit 0', () => { + it('reports "already-current" on v0.2 with exit 0', async () => { // LATEST supported is v0.2; seeding v0.2 hits the no-op path. const p = seed('policy.yaml', '0.2'); - const { stdout, exitCode } = runCli(['--json', 'policy', 'migrate', p]); + const { stdout, exitCode } = await runCli(['--json', 'policy', 'migrate', p]); expect(exitCode).toBe(0); const parsed = JSON.parse(stdout[0]) as { data: { status: string } }; expect(parsed.data.status).toBe('already-current'); }); - it('upgrades v0.1 → v0.2 now fails (no migration path in v3.0)', () => { + it('upgrades v0.1 → v0.2 now fails (no migration path in v3.0)', async () => { const p = path.join(tmpDir, 'policy.yaml'); const original = [ '# My SwitchBot policy', @@ -244,41 +272,44 @@ describe('switchbot policy (commander surface)', () => { ].join('\n'); fs.writeFileSync(p, original, 'utf-8'); - const { stdout, exitCode } = runCli(['--json', 'policy', 'migrate', p]); + const { stdout, exitCode } = await runCli(['--json', 'policy', 'migrate', p]); // v0.1 is no longer in SUPPORTED_POLICY_SCHEMA_VERSIONS — exit 6. expect(exitCode).toBe(6); const parsed = JSON.parse(stdout[0]) as { - error: { code: number; kind: string }; + error: { code: number; kind: string; hint: string; message: string }; }; expect(parsed.error.code).toBe(6); expect(parsed.error.kind).toBe('unsupported-version'); + expect(parsed.error.message).toMatch(/cannot be migrated by this CLI/i); + expect(parsed.error.hint).toMatch(/<=2\.15/); // File must be untouched. expect(fs.readFileSync(p, 'utf-8')).toBe(original); }); - it('--dry-run on v0.1 also returns exit 6 (unsupported, no migration path)', () => { + it('--dry-run on v0.1 also returns exit 6 (unsupported, no migration path)', async () => { const p = seed('policy.yaml', '0.1'); const before = fs.readFileSync(p, 'utf-8'); - const { stdout, exitCode } = runCli(['--json', 'policy', 'migrate', p, '--dry-run']); + const { stdout, exitCode } = await runCli(['--json', 'policy', 'migrate', p, '--dry-run']); // v0.1 unsupported — exits before dry-run logic. expect(exitCode).toBe(6); - const parsed = JSON.parse(stdout[0]) as { error: { code: number; kind: string } }; + const parsed = JSON.parse(stdout[0]) as { error: { code: number; kind: string; hint: string } }; expect(parsed.error.code).toBe(6); expect(parsed.error.kind).toBe('unsupported-version'); + expect(parsed.error.hint).toMatch(/<=2\.15/); expect(fs.readFileSync(p, 'utf-8')).toBe(before); }); - it('reports "no-version-field" when version is absent (exit 0)', () => { + it('reports "no-version-field" when version is absent (exit 0)', async () => { const p = seed('policy.yaml', null); - const { stdout, exitCode } = runCli(['--json', 'policy', 'migrate', p]); + const { stdout, exitCode } = await runCli(['--json', 'policy', 'migrate', p]); expect(exitCode).toBe(0); const parsed = JSON.parse(stdout[0]) as { data: { status: string } }; expect(parsed.data.status).toBe('no-version-field'); }); - it('emits an unsupported-version error envelope for newer schemas (exit 6)', () => { + it('emits an unsupported-version error envelope for newer schemas (exit 6)', async () => { const p = seed('policy.yaml', '0.9'); - const { stdout, exitCode } = runCli(['--json', 'policy', 'migrate', p]); + const { stdout, exitCode } = await runCli(['--json', 'policy', 'migrate', p]); expect(exitCode).toBe(6); const parsed = JSON.parse(stdout[0]) as { error: { code: number; kind: string; hint: string }; @@ -288,7 +319,7 @@ describe('switchbot policy (commander surface)', () => { expect(parsed.error.hint).toContain('downgrade'); }); - it('exits 7 when the migrated file would fail v0.2 schema precheck (v0.2 source)', () => { + it('exits 7 when the migrated file would fail v0.2 schema precheck (v0.2 source)', async () => { // Seed a v0.2 file with a broken automation rule that fails v0.2 precheck // when planMigration runs it through the validator again after a no-op. // Since MIGRATION_CHAIN is empty, we test precheck failure by seeding a @@ -312,7 +343,7 @@ describe('switchbot policy (commander surface)', () => { 'utf-8', ); const before = fs.readFileSync(p, 'utf-8'); - const { stdout, exitCode } = runCli(['--json', 'policy', 'migrate', p]); + const { stdout, exitCode } = await runCli(['--json', 'policy', 'migrate', p]); // v0.1 is unsupported — exits 6 before reaching precheck. expect(exitCode).toBe(6); const parsed = JSON.parse(stdout[0]) as { @@ -324,33 +355,33 @@ describe('switchbot policy (commander surface)', () => { expect(fs.readFileSync(p, 'utf-8')).toBe(before); }); - it('exits 2 when the file does not exist', () => { + it('exits 2 when the file does not exist', async () => { const missing = path.join(tmpDir, 'nope.yaml'); - const { exitCode } = runCli(['policy', 'migrate', missing]); + const { exitCode } = await runCli(['policy', 'migrate', missing]); expect(exitCode).toBe(2); }); }); describe('policy diff', () => { - it('prints no-difference message for identical files', () => { + it('prints no-difference message for identical files', async () => { const left = path.join(tmpDir, 'left.yaml'); const right = path.join(tmpDir, 'right.yaml'); const body = ['version: "0.1"', 'aliases:', ' "lamp": "01-202407090924-26354212"', ''].join('\n'); fs.writeFileSync(left, body, 'utf-8'); fs.writeFileSync(right, body, 'utf-8'); - const { stdout, exitCode } = runCli(['policy', 'diff', left, right]); + const { stdout, exitCode } = await runCli(['policy', 'diff', left, right]); expect(exitCode).toBe(0); expect(stdout.join('\n')).toContain('no structural differences'); }); - it('emits structured --json diff output with change stats', () => { + it('emits structured --json diff output with change stats', async () => { const left = path.join(tmpDir, 'left.yaml'); const right = path.join(tmpDir, 'right.yaml'); fs.writeFileSync(left, ['version: "0.1"', 'quiet_hours:', ' start: "22:00"', ''].join('\n'), 'utf-8'); fs.writeFileSync(right, ['version: "0.2"', 'quiet_hours:', ' start: "23:00"', ''].join('\n'), 'utf-8'); - const { stdout, exitCode } = runCli(['--json', 'policy', 'diff', left, right]); + const { stdout, exitCode } = await runCli(['--json', 'policy', 'diff', left, right]); expect(exitCode).toBe(0); const parsed = JSON.parse(stdout[0]) as { data: { @@ -369,12 +400,12 @@ describe('switchbot policy (commander surface)', () => { expect(parsed.data.diff).toContain('+++ after'); }); - it('exits 2 when either input file does not exist', () => { + it('exits 2 when either input file does not exist', async () => { const left = path.join(tmpDir, 'left.yaml'); fs.writeFileSync(left, 'version: "0.1"\n', 'utf-8'); const missing = path.join(tmpDir, 'missing.yaml'); - const { stderr, exitCode } = runCli(['policy', 'diff', left, missing]); + const { stderr, exitCode } = await runCli(['policy', 'diff', left, missing]); expect(exitCode).toBe(2); expect(stderr.join('\n')).toContain('policy file not found'); }); @@ -394,8 +425,8 @@ describe('switchbot policy (commander surface)', () => { delete process.env.SWITCHBOT_POLICY_PATH; }); - it('creates a .bak.yaml backup alongside the policy', () => { - const { stdout, exitCode } = runCli(['policy', 'backup']); + it('creates a .bak.yaml backup alongside the policy', async () => { + const { stdout, exitCode } = await runCli(['policy', 'backup']); expect(exitCode).toBe(0); const backupPath = policyFile.replace(/\.yaml$/, '.bak.yaml'); expect(fs.existsSync(backupPath)).toBe(true); @@ -403,34 +434,34 @@ describe('switchbot policy (commander surface)', () => { expect(stdout.join('\n')).toContain('Backup written'); }); - it('writes backup to an explicit path', () => { + it('writes backup to an explicit path', async () => { const dest = path.join(tmpDir, 'my-snapshot.yaml'); - const { stdout, exitCode } = runCli(['policy', 'backup', dest]); + const { stdout, exitCode } = await runCli(['policy', 'backup', dest]); expect(exitCode).toBe(0); expect(fs.existsSync(dest)).toBe(true); expect(stdout.join('\n')).toContain(dest); }); - it('refuses to overwrite existing backup without --force', () => { + it('refuses to overwrite existing backup without --force', async () => { const backupPath = policyFile.replace(/\.yaml$/, '.bak.yaml'); fs.writeFileSync(backupPath, 'original\n', 'utf-8'); - const { exitCode } = runCli(['policy', 'backup']); + const { exitCode } = await runCli(['policy', 'backup']); expect(exitCode).toBe(2); expect(fs.readFileSync(backupPath, 'utf-8')).toBe('original\n'); }); - it('overwrites existing backup with --force', () => { + it('overwrites existing backup with --force', async () => { const backupPath = policyFile.replace(/\.yaml$/, '.bak.yaml'); fs.writeFileSync(backupPath, 'old\n', 'utf-8'); - const { exitCode } = runCli(['policy', 'backup', '--force']); + const { exitCode } = await runCli(['policy', 'backup', '--force']); expect(exitCode).toBe(0); expect(fs.readFileSync(backupPath, 'utf-8')).not.toBe('old\n'); }); - it('--json returns ok:true with source and dest', () => { - const { stdout, exitCode } = runCli(['--json', 'policy', 'backup']); + it('--json returns ok:true with source and dest', async () => { + const { stdout, exitCode } = await runCli(['--json', 'policy', 'backup']); expect(exitCode).toBe(0); const out = JSON.parse(stdout[0]) as { data: Record }; expect(out.data.ok).toBe(true); @@ -438,9 +469,9 @@ describe('switchbot policy (commander surface)', () => { expect(typeof out.data.dest).toBe('string'); }); - it('exits 2 when the policy file does not exist', () => { + it('exits 2 when the policy file does not exist', async () => { fs.unlinkSync(policyFile); - const { exitCode } = runCli(['policy', 'backup']); + const { exitCode } = await runCli(['policy', 'backup']); expect(exitCode).toBe(2); }); }); @@ -463,26 +494,26 @@ describe('switchbot policy (commander surface)', () => { delete process.env.SWITCHBOT_POLICY_PATH; }); - it('restores the backup to the active policy path', () => { - const { stdout, exitCode } = runCli(['policy', 'restore', backupFile]); + it('restores the backup to the active policy path', async () => { + const { stdout, exitCode } = await runCli(['policy', 'restore', backupFile]); expect(exitCode).toBe(0); expect(fs.readFileSync(policyFile, 'utf-8')).toBe(fs.readFileSync(backupFile, 'utf-8')); expect(stdout.join('\n')).toContain('Policy restored'); }); - it('auto-creates a pre-restore backup of the existing policy', () => { - runCli(['policy', 'restore', backupFile]); + it('auto-creates a pre-restore backup of the existing policy', async () => { + await runCli(['policy', 'restore', backupFile]); const autoBackup = policyFile.replace(/\.yaml$/, '.pre-restore.bak.yaml'); expect(fs.existsSync(autoBackup)).toBe(true); }); - it('exits 2 when the restore source does not exist', () => { - const { exitCode } = runCli(['policy', 'restore', path.join(tmpDir, 'missing.yaml')]); + it('exits 2 when the restore source does not exist', async () => { + const { exitCode } = await runCli(['policy', 'restore', path.join(tmpDir, 'missing.yaml')]); expect(exitCode).toBe(2); }); - it('--json returns ok:true with restored path', () => { - const { stdout, exitCode } = runCli(['--json', 'policy', 'restore', backupFile]); + it('--json returns ok:true with restored path', async () => { + const { stdout, exitCode } = await runCli(['--json', 'policy', 'restore', backupFile]); expect(exitCode).toBe(0); const out = JSON.parse(stdout[0]) as { data: Record }; expect(out.data.ok).toBe(true); @@ -510,33 +541,33 @@ describe('switchbot policy (commander surface)', () => { return p; } - it('accepts standard hyphenated IDs (01-202407090924-26354212)', () => { - const { exitCode } = runCli(['policy', 'validate', writePolicy('01-202407090924-26354212')]); + it('accepts standard hyphenated IDs (01-202407090924-26354212)', async () => { + const { exitCode } = await runCli(['policy', 'validate', writePolicy('01-202407090924-26354212')]); expect(exitCode).toBe(0); }); - it('accepts 12-digit hex MAC without hyphen (28372F4C9C4A)', () => { - const { exitCode } = runCli(['policy', 'validate', writePolicy('28372F4C9C4A')]); + it('accepts 12-digit hex MAC without hyphen (28372F4C9C4A)', async () => { + const { exitCode } = await runCli(['policy', 'validate', writePolicy('28372F4C9C4A')]); expect(exitCode).toBe(0); }); - it('accepts lowercase hex MAC (b0e9fe51ef2e)', () => { - const { exitCode } = runCli(['policy', 'validate', writePolicy('b0e9fe51ef2e')]); + it('accepts lowercase hex MAC (b0e9fe51ef2e)', async () => { + const { exitCode } = await runCli(['policy', 'validate', writePolicy('b0e9fe51ef2e')]); expect(exitCode).toBe(0); }); - it('accepts IoT suffix format (28372F4C9C4A-vzwa)', () => { - const { exitCode } = runCli(['policy', 'validate', writePolicy('28372F4C9C4A-vzwa')]); + it('accepts IoT suffix format (28372F4C9C4A-vzwa)', async () => { + const { exitCode } = await runCli(['policy', 'validate', writePolicy('28372F4C9C4A-vzwa')]); expect(exitCode).toBe(0); }); - it('rejects single-char IDs', () => { - const { exitCode } = runCli(['policy', 'validate', writePolicy('A')]); + it('rejects single-char IDs', async () => { + const { exitCode } = await runCli(['policy', 'validate', writePolicy('A')]); expect(exitCode).not.toBe(0); }); - it('rejects IDs longer than 64 chars', () => { - const { exitCode } = runCli(['policy', 'validate', writePolicy('A'.repeat(65))]); + it('rejects IDs longer than 64 chars', async () => { + const { exitCode } = await runCli(['policy', 'validate', writePolicy('A'.repeat(65))]); expect(exitCode).not.toBe(0); }); }); diff --git a/tests/commands/quota.test.ts b/tests/commands/quota.test.ts index 13cdde0..14f7a31 100644 --- a/tests/commands/quota.test.ts +++ b/tests/commands/quota.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import os from 'node:os'; import { registerQuotaCommand } from '../../src/commands/quota.js'; import { runCli } from '../helpers/cli.js'; +import { expectJsonEnvelopeShape } from '../helpers/contracts.js'; let tmpRoot: string; @@ -47,11 +48,20 @@ describe('quota command', () => { await seedQuota(); const result = await runCli(registerQuotaCommand, ['--json', 'quota', 'status']); expect(result.exitCode).toBeNull(); - const parsed = JSON.parse(result.stdout[0]); - expect(parsed.data.today.total).toBe(3); - expect(parsed.data.today.remaining).toBe(10_000 - 3); - expect(parsed.data.today.dailyLimit).toBe(10_000); - expect(parsed.data.today.endpoints['GET /v1.1/devices']).toBe(2); + const parsed = JSON.parse(result.stdout[0]) as Record; + const data = expectJsonEnvelopeShape(parsed, ['today', 'history']) as { + today: { + total: number; + remaining: number; + dailyLimit: number; + endpoints: Record; + }; + history: Record; + }; + expect(data.today.total).toBe(3); + expect(data.today.remaining).toBe(10_000 - 3); + expect(data.today.dailyLimit).toBe(10_000); + expect(data.today.endpoints['GET /v1.1/devices']).toBe(2); }); it('status says "no requests recorded yet" with an empty counter', async () => { @@ -60,6 +70,13 @@ describe('quota command', () => { expect(result.stdout.join('\n')).toMatch(/no requests recorded yet/); }); + it('bare quota defaults to status', async () => { + await seedQuota(); + const result = await runCli(registerQuotaCommand, ['quota']); + expect(result.exitCode).toBeNull(); + expect(result.stdout.join('\n')).toContain('Requests used:'); + }); + it('reset deletes the quota file', async () => { await seedQuota(); const file = path.join(tmpRoot, '.switchbot', 'quota.json'); diff --git a/tests/commands/rules.test.ts b/tests/commands/rules.test.ts index be6cca7..e612e5c 100644 --- a/tests/commands/rules.test.ts +++ b/tests/commands/rules.test.ts @@ -14,6 +14,7 @@ import path from 'node:path'; import { Command } from 'commander'; import { registerRulesCommand } from '../../src/commands/rules.js'; +import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; function makeProgram(): Command { const program = new Command(); @@ -85,7 +86,7 @@ const sampleAutomation = [ ' max_per: "10m"', ' dry_run: true', 'aliases:', - ' "hallway lamp": "AA-BB-CC-DD-EE-FF"', + ' "hallway lamp": "28372F4C9C4A"', '', ].join('\n'); @@ -163,7 +164,7 @@ describe('switchbot rules (commander surface)', () => { ' - command: "devices command turnOff"', ' device: hallway lamp', 'aliases:', - ' "hallway lamp": "AA-BB-CC-DD-EE-FF"', + ' "hallway lamp": "28372F4C9C4A"', '', ].join('\n'); const p = path.join(tmpDir, 'policy.yaml'); @@ -178,12 +179,13 @@ describe('switchbot rules (commander surface)', () => { fs.writeFileSync(p, v02Policy(sampleAutomation), 'utf-8'); const { stdout, exitCode } = await runCli(['--json', 'rules', 'lint', p]); expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout[0]) as { - schemaVersion: string; - data: { valid: boolean; rules: Array<{ name: string; status: string }> }; + const parsed = JSON.parse(stdout[0]) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['valid', 'rules']) as { + valid: boolean; + rules: Array<{ name: string; status: string }>; }; - expect(parsed.data.valid).toBe(true); - expect(parsed.data.rules[0].status).toBe('ok'); + expect(data.valid).toBe(true); + expect(data.rules[0].status).toBe('ok'); }); it('exits 2 when the policy file is missing', async () => { @@ -218,12 +220,13 @@ describe('switchbot rules (commander surface)', () => { fs.writeFileSync(p, v02Policy(sampleAutomation), 'utf-8'); const { stdout, exitCode } = await runCli(['--json', 'rules', 'list', p]); expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout[0]) as { - data: { rules: Array<{ name: string; trigger: string; dry_run: boolean; throttle: string | null }> }; + const parsed = JSON.parse(stdout[0]) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['automationEnabled', 'rules']) as { + rules: Array<{ name: string; trigger: string; dry_run: boolean; throttle: string | null }>; }; - expect(parsed.data.rules).toHaveLength(1); - expect(parsed.data.rules[0].dry_run).toBe(true); - expect(parsed.data.rules[0].throttle).toBe('10m'); + expect(data.rules).toHaveLength(1); + expect(data.rules[0].dry_run).toBe(true); + expect(data.rules[0].throttle).toBe('10m'); }); }); @@ -477,7 +480,7 @@ describe('switchbot rules (commander surface)', () => { ' maxFiringsPerHour: 6', ' suppressIfAlreadyDesired: true', 'aliases:', - ' "LAMP": "AA-BB-CC-DD-EE-01"', + ' "LAMP": "28372F4C9C4B"', '', ].join('\n'); @@ -560,11 +563,11 @@ describe('switchbot rules (commander surface)', () => { ' - name: r-on', ' when: { source: mqtt, event: motion.detected }', ' then:', - ' - { command: "devices command DEVICE-X turnOn", device: DEVICE-X }', + ' - { command: "devices command 28372F4C9C4C turnOn", device: 28372F4C9C4C }', ' - name: r-off', ' when: { source: mqtt, event: motion.detected }', ' then:', - ' - { command: "devices command DEVICE-X turnOff", device: DEVICE-X }', + ' - { command: "devices command 28372F4C9C4C turnOff", device: 28372F4C9C4C }', '', ].join('\n')); const p = path.join(tmpDir, 'conflict.yaml'); @@ -585,11 +588,11 @@ describe('switchbot rules (commander surface)', () => { ' - name: on', ' when: { source: mqtt, event: motion.detected }', ' then:', - ' - { command: "devices command DD turnOn", device: DD }', + ' - { command: "devices command 28372F4C9C4D turnOn", device: 28372F4C9C4D }', ' - name: off', ' when: { source: mqtt, event: motion.detected }', ' then:', - ' - { command: "devices command DD turnOff", device: DD }', + ' - { command: "devices command 28372F4C9C4D turnOff", device: 28372F4C9C4D }', '', ].join('\n')); const p = path.join(tmpDir, 'conflict2.yaml'); @@ -609,8 +612,9 @@ describe('switchbot rules (commander surface)', () => { fs.writeFileSync(p, v02Policy(sampleAutomation)); const { exitCode, stdout } = await runCli(['--json', 'rules', 'doctor', p]); expect(exitCode).toBe(0); - const body = JSON.parse(stdout[0]) as { data: { overall: boolean } }; - expect(body.data.overall).toBe(true); + const body = JSON.parse(stdout[0]) as Record; + const data = expectJsonEnvelopeContainingKeys(body, ['overall', 'lint', 'conflicts']) as { overall: boolean }; + expect(data.overall).toBe(true); }); it('--json exits 1 with overall:false for a policy with duplicate rule names (lint error)', async () => { @@ -621,19 +625,20 @@ describe('switchbot rules (commander surface)', () => { ' - name: dup-name', ' when: { source: mqtt, event: motion.detected }', ' then:', - ' - { command: "devices command EE turnOn" }', + ' - { command: "devices command 28372F4C9C4E turnOn" }', ' - name: dup-name', ' when: { source: mqtt, event: motion.detected }', ' then:', - ' - { command: "devices command FF turnOff" }', + ' - { command: "devices command 28372F4C9C4F turnOff" }', '', ].join('\n')); const p = path.join(tmpDir, 'doctor-bad.yaml'); fs.writeFileSync(p, bad); const { exitCode, stdout } = await runCli(['--json', 'rules', 'doctor', p]); expect(exitCode).toBe(1); - const body = JSON.parse(stdout[0]) as { data: { overall: boolean } }; - expect(body.data.overall).toBe(false); + const body = JSON.parse(stdout[0]) as Record; + const data = expectJsonEnvelopeContainingKeys(body, ['overall', 'lint', 'conflicts']) as { overall: boolean }; + expect(data.overall).toBe(false); }); }); @@ -799,9 +804,14 @@ describe('rules webhook-show-token', () => { describe('rules suggest', () => { it('exits with a Commander usage error when --intent is missing', async () => { const program = makeProgram(); - await expect( - program.parseAsync(['node', 'test', 'rules', 'suggest']), - ).rejects.toThrow(); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as never); + try { + await expect( + program.parseAsync(['node', 'test', 'rules', 'suggest']), + ).rejects.toThrow(); + } finally { + stderrSpy.mockRestore(); + } }); it('outputs YAML to stdout when trigger can be inferred from intent', async () => { @@ -830,6 +840,45 @@ describe('rules suggest', () => { expect(body.data.rule.name).toBe('turn on lights at 8am every morning'); }); + it('interpolates --device into the generated command instead of leaving behind', async () => { + const { stdout } = await runCli([ + '--json', + 'rules', + 'suggest', + '--intent', + 'turn on light when motion detected', + '--device', + 'SENSOR1', + '--device', + 'DE53EC157E2C', + ]); + const body = JSON.parse(stdout.join('')) as { + data: { + rule: { then: Array<{ command: string; device?: string }> }; + rule_yaml: string; + }; + }; + + expect(body.data.rule.then).toHaveLength(1); + expect(body.data.rule.then[0].command).toBe('devices command DE53EC157E2C turnOn'); + expect(body.data.rule.then[0].device).toBeUndefined(); + expect(body.data.rule_yaml).toContain('devices command DE53EC157E2C turnOn'); + expect(body.data.rule_yaml).not.toContain('devices command turnOn'); + }); + + it('exits 2 for unsupported Chinese intent instead of silently generating a wrong rule', async () => { + const { stderr, exitCode } = await runCli([ + 'rules', + 'suggest', + '--intent', + '晚上23点关闭窗帘', + '--device', + 'DE53EC157E2C', + ]); + expect(exitCode).toBe(2); + expect(stderr.join('\n')).toMatch(/cannot safely infer/i); + }); + it('writes YAML to --out file instead of stdout', async () => { const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbsug-')); const outFile = path.join(outDir, 'rule.yaml'); diff --git a/tests/commands/scenes.test.ts b/tests/commands/scenes.test.ts index f79064d..833ec17 100644 --- a/tests/commands/scenes.test.ts +++ b/tests/commands/scenes.test.ts @@ -26,6 +26,7 @@ vi.mock('../../src/api/client.js', () => ({ import { registerScenesCommand } from '../../src/commands/scenes.js'; import { runCli } from '../helpers/cli.js'; +import { expectJsonArrayEnvelope, expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; describe('scenes command', () => { beforeEach(() => { @@ -59,9 +60,10 @@ describe('scenes command', () => { data: { body: [{ sceneId: 'S1', sceneName: 'Hi' }] }, }); const res = await runCli(registerScenesCommand, ['scenes', 'list', '--json']); - const out = res.stdout.join('\n'); - expect(out).toContain('"sceneId"'); - expect(out).toContain('"sceneName"'); + const out = JSON.parse(res.stdout.join('\n')) as Record; + const data = expectJsonArrayEnvelope(out) as Array<{ sceneId: string; sceneName: string }>; + expect(data[0].sceneId).toBe('S1'); + expect(data[0].sceneName).toBe('Hi'); }); it('prints "No scenes found" when the list is empty', async () => { @@ -200,12 +202,12 @@ describe('scenes command', () => { }); const res = await runCli(registerScenesCommand, ['scenes', 'describe', 'S1', '--json']); expect(res.exitCode).toBeNull(); - const out = res.stdout.join('\n'); - const parsed = JSON.parse(out); - expect(parsed.data.sceneId).toBe('S1'); - expect(parsed.data.sceneName).toBe('Good Morning'); - expect(parsed.data.stepCount).toBeNull(); - expect(parsed.data.note).toMatch(/does not expose scene steps/); + const parsed = JSON.parse(res.stdout.join('\n')) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['sceneId', 'sceneName', 'stepCount', 'note']); + expect(data.sceneId).toBe('S1'); + expect(data.sceneName).toBe('Good Morning'); + expect(data.stepCount).toBeNull(); + expect(String(data.note)).toMatch(/does not expose scene steps/); }); it('exits 2 with scene_not_found when sceneId is unknown', async () => { @@ -272,11 +274,12 @@ describe('scenes command', () => { const res = await runCli(registerScenesCommand, ['--json', 'scenes', 'explain', 'S1']); expect(res.exitCode).not.toBe(2); const out = JSON.parse(res.stdout.find((l) => l.trim().startsWith('{'))!) as Record; - expect((out.data as Record).sceneId).toBe('S1'); - expect((out.data as Record).sceneName).toBe('Good Morning'); - expect((out.data as Record).riskLevel).toBe('low'); - expect((out.data as Record).toExecute).toBe('switchbot scenes execute S1'); - expect((out.data as Record).idempotent).toBeNull(); + const data = expectJsonEnvelopeContainingKeys(out, ['sceneId', 'sceneName', 'riskLevel', 'toExecute', 'idempotent']); + expect(data.sceneId).toBe('S1'); + expect(data.sceneName).toBe('Good Morning'); + expect(data.riskLevel).toBe('low'); + expect(data.toExecute).toBe('switchbot scenes execute S1'); + expect(data.idempotent).toBeNull(); }); it('plaintext output includes key explanation fields', async () => { @@ -315,9 +318,10 @@ describe('scenes command', () => { mockScenes(); const res = await runCli(registerScenesCommand, ['--json', 'scenes', 'validate', 'V1', 'V2']); expect(res.exitCode).toBeNull(); - const body = JSON.parse(res.stdout.join('')) as { data: { ok: boolean; results: unknown[] } }; - expect(body.data.ok).toBe(true); - expect(body.data.results).toHaveLength(2); + const body = JSON.parse(res.stdout.join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(body, ['ok', 'results']) as { ok: boolean; results: unknown[] }; + expect(data.ok).toBe(true); + expect(data.results).toHaveLength(2); }); it('--json exits 1 with ok:false when a supplied ID does not exist', async () => { @@ -361,11 +365,12 @@ describe('scenes command', () => { mockScenes(); const res = await runCli(registerScenesCommand, ['--json', 'scenes', 'simulate', 'SIM1']); expect(res.exitCode).toBeNull(); - const body = JSON.parse(res.stdout.join('')) as { data: Record }; - expect(body.data.simulated).toBe(true); - expect(body.data.sceneId).toBe('SIM1'); - expect(body.data.sceneName).toBe('Good Night'); - const wouldSend = body.data.wouldSend as Record; + const body = JSON.parse(res.stdout.join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(body, ['simulated', 'sceneId', 'sceneName', 'wouldSend']) as Record; + expect(data.simulated).toBe(true); + expect(data.sceneId).toBe('SIM1'); + expect(data.sceneName).toBe('Good Night'); + const wouldSend = data.wouldSend as Record; expect(wouldSend.method).toBe('POST'); expect(wouldSend.url).toContain('SIM1'); }); diff --git a/tests/commands/schema.test.ts b/tests/commands/schema.test.ts index 056bd43..0b310f3 100644 --- a/tests/commands/schema.test.ts +++ b/tests/commands/schema.test.ts @@ -4,14 +4,14 @@ import os from 'node:os'; import { registerSchemaCommand } from '../../src/commands/schema.js'; import { updateCacheFromDeviceList, resetListCache } from '../../src/devices/cache.js'; import { runCli } from '../helpers/cli.js'; +import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; describe('schema export', () => { it('dumps every catalog type as a JSON payload', async () => { const res = await runCli(registerSchemaCommand, ['schema', 'export']); const out = res.stdout.join(''); - const envelope = JSON.parse(out); - expect(envelope.schemaVersion).toBe('1.1'); - const parsed = envelope.data; + const envelope = JSON.parse(out) as Record; + const parsed = expectJsonEnvelopeContainingKeys(envelope, ['version', 'types', 'generatedAt', 'resources', 'cliAddedFields']); expect(parsed.version).toBe('1.0'); expect(parsed.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(Array.isArray(parsed.types)).toBe(true); @@ -25,6 +25,14 @@ describe('schema export', () => { } }); + it('bare schema defaults to export', async () => { + const res = await runCli(registerSchemaCommand, ['schema']); + expect(res.exitCode).toBeNull(); + const envelope = JSON.parse(res.stdout.join('')) as Record; + const data = expectJsonEnvelopeContainingKeys(envelope, ['version', 'types', 'generatedAt', 'resources', 'cliAddedFields']); + expect(Array.isArray(data.types)).toBe(true); + }); + it('filters by --type (matches name + aliases, case-insensitive)', async () => { const res = await runCli(registerSchemaCommand, ['schema', 'export', '--type', 'bot']); const parsed = JSON.parse(res.stdout.join('')).data; @@ -89,6 +97,37 @@ describe('schema export', () => { expect((t.description as string).length, `${t.type} description is empty`).toBeGreaterThan(0); } }); + + it('exports corrected advisory statusFields for Meter and Home Climate Panel', async () => { + const res = await runCli(registerSchemaCommand, ['schema', 'export']); + const parsed = JSON.parse(res.stdout.join('')).data; + const meter = parsed.types.find((t: { type: string }) => t.type === 'Meter'); + const panel = parsed.types.find((t: { type: string }) => t.type === 'Home Climate Panel'); + expect(meter.statusFields).not.toContain('CO2'); + expect(meter.statusFields).toEqual(['temperature', 'humidity', 'battery', 'version']); + expect(panel.statusFields).toEqual([ + 'battery', + 'brightness', + 'moveDetected', + 'humidity', + 'temperature', + 'version', + ]); + }); + + it('exports hub-family advisory roles and statusFields as a stable contract', async () => { + const res = await runCli(registerSchemaCommand, ['schema', 'export']); + const parsed = JSON.parse(res.stdout.join('')).data; + const hub2 = parsed.types.find((t: { type: string }) => t.type === 'Hub 2'); + const hubMini = parsed.types.find((t: { type: string }) => t.type === 'Hub Mini'); + const hub3 = parsed.types.find((t: { type: string }) => t.type === 'Hub 3'); + expect(hub2.role).toBe('hub'); + expect(hub2.statusFields).toEqual(['version', 'temperature', 'humidity', 'lightLevel']); + expect(hubMini.role).toBe('hub'); + expect(hubMini.statusFields).toEqual(['version']); + expect(hub3.role).toBe('hub'); + expect(hub3.statusFields).toEqual(['version', 'temperature', 'humidity', 'lightLevel']); + }); }); describe('schema export B3 slim flags', () => { diff --git a/tests/commands/status-sync.test.ts b/tests/commands/status-sync.test.ts index c615f34..1870b2e 100644 --- a/tests/commands/status-sync.test.ts +++ b/tests/commands/status-sync.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { StatusSyncStatus, StopStatusSyncResult } from '../../src/status-sync/manager.js'; +import { expectJsonEnvelopeShape } from '../helpers/contracts.js'; const managerMock = vi.hoisted(() => ({ getStatusSyncStatus: vi.fn<[], StatusSyncStatus>(), @@ -53,18 +54,48 @@ describe('status-sync command', () => { it('--json exits 0 with running:false when not running', async () => { const res = await runCli(registerStatusSyncCommand, ['--json', 'status-sync', 'status']); expect(res.exitCode).toBeNull(); - const body = JSON.parse(res.stdout.join('')) as { data: StatusSyncStatus }; - expect(body.data.running).toBe(false); - expect(body.data.pid).toBeNull(); + const body = JSON.parse(res.stdout.join('')) as Record; + const data = expectJsonEnvelopeShape(body, [ + 'running', + 'pid', + 'startedAt', + 'stateDir', + 'stateFile', + 'stdoutLog', + 'stderrLog', + 'command', + 'openclawUrl', + 'openclawModel', + 'topic', + 'configPath', + 'profile', + ]) as StatusSyncStatus; + expect(data.running).toBe(false); + expect(data.pid).toBeNull(); }); it('--json exits 0 with running:true and pid when running', async () => { managerMock.getStatusSyncStatus.mockReturnValue(RUNNING); const res = await runCli(registerStatusSyncCommand, ['--json', 'status-sync', 'status']); expect(res.exitCode).toBeNull(); - const body = JSON.parse(res.stdout.join('')) as { data: StatusSyncStatus }; - expect(body.data.running).toBe(true); - expect(body.data.pid).toBe(9876); + const body = JSON.parse(res.stdout.join('')) as Record; + const data = expectJsonEnvelopeShape(body, [ + 'running', + 'pid', + 'startedAt', + 'stateDir', + 'stateFile', + 'stdoutLog', + 'stderrLog', + 'command', + 'openclawUrl', + 'openclawModel', + 'topic', + 'configPath', + 'profile', + ]) as StatusSyncStatus; + expect(data.running).toBe(true); + expect(data.pid).toBe(9876); }); it('human mode prints "not running" when not running', async () => { @@ -79,9 +110,24 @@ describe('status-sync command', () => { managerMock.startStatusSync.mockReturnValue(RUNNING); const res = await runCli(registerStatusSyncCommand, ['--json', 'status-sync', 'start']); expect(res.exitCode).toBeNull(); - const body = JSON.parse(res.stdout.join('')) as { data: StatusSyncStatus }; - expect(body.data.running).toBe(true); - expect(body.data.pid).toBe(9876); + const body = JSON.parse(res.stdout.join('')) as Record; + const data = expectJsonEnvelopeShape(body, [ + 'running', + 'pid', + 'startedAt', + 'stateDir', + 'stateFile', + 'stdoutLog', + 'stderrLog', + 'command', + 'openclawUrl', + 'openclawModel', + 'topic', + 'configPath', + 'profile', + ]) as StatusSyncStatus; + expect(data.running).toBe(true); + expect(data.pid).toBe(9876); expect(managerMock.startStatusSync).toHaveBeenCalled(); }); @@ -112,9 +158,10 @@ describe('status-sync command', () => { it('--json exits 0 with stopped:false when nothing is running', async () => { const res = await runCli(registerStatusSyncCommand, ['--json', 'status-sync', 'stop']); expect(res.exitCode).toBeNull(); - const body = JSON.parse(res.stdout.join('')) as { data: StopStatusSyncResult }; - expect(body.data.stopped).toBe(false); - expect(body.data.pid).toBeNull(); + const body = JSON.parse(res.stdout.join('')) as Record; + const data = expectJsonEnvelopeShape(body, ['stopped', 'stale', 'pid', 'status']) as StopStatusSyncResult; + expect(data.stopped).toBe(false); + expect(data.pid).toBeNull(); }); it('human mode prints "not running" when nothing to stop', async () => { @@ -129,9 +176,10 @@ describe('status-sync command', () => { }); const res = await runCli(registerStatusSyncCommand, ['--json', 'status-sync', 'stop']); expect(res.exitCode).toBeNull(); - const body = JSON.parse(res.stdout.join('')) as { data: StopStatusSyncResult }; - expect(body.data.stopped).toBe(true); - expect(body.data.pid).toBe(9876); + const body = JSON.parse(res.stdout.join('')) as Record; + const data = expectJsonEnvelopeShape(body, ['stopped', 'stale', 'pid', 'status']) as StopStatusSyncResult; + expect(data.stopped).toBe(true); + expect(data.pid).toBe(9876); }); }); }); diff --git a/tests/commands/uninstall.test.ts b/tests/commands/uninstall.test.ts index 78f9ce7..9bfcf7c 100644 --- a/tests/commands/uninstall.test.ts +++ b/tests/commands/uninstall.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CLI = path.resolve(__dirname, '..', '..', 'dist', 'index.js'); @@ -35,10 +36,15 @@ describe('switchbot uninstall (dry-run smoke)', () => { it('--dry-run --json emits a structured plan including skill link for claude-code', () => { const { code, stdout } = runCli(['--dry-run', '--json', 'uninstall', '--agent', 'claude-code']); expect(code).toBe(0); - const parsed = JSON.parse(stdout); - expect(parsed.data.dryRun).toBe(true); - expect(parsed.data.agent).toBe('claude-code'); - const actions = parsed.data.plan.map((p: { action: string }) => p.action); + const parsed = JSON.parse(stdout) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['dryRun', 'agent', 'plan']) as { + dryRun: boolean; + agent: string; + plan: Array<{ action: string }>; + }; + expect(data.dryRun).toBe(true); + expect(data.agent).toBe('claude-code'); + const actions = data.plan.map((p) => p.action); expect(actions).toContain('remove-skill-link'); expect(actions).toContain('remove-credentials'); expect(actions).toContain('remove-policy'); @@ -47,8 +53,11 @@ describe('switchbot uninstall (dry-run smoke)', () => { it('--dry-run --json for agent=none omits the skill link action', () => { const { code, stdout } = runCli(['--dry-run', '--json', 'uninstall', '--agent', 'none']); expect(code).toBe(0); - const parsed = JSON.parse(stdout); - const actions = parsed.data.plan.map((p: { action: string }) => p.action); + const parsed = JSON.parse(stdout) as Record; + const data = expectJsonEnvelopeContainingKeys(parsed, ['dryRun', 'agent', 'plan']) as { + plan: Array<{ action: string }>; + }; + const actions = data.plan.map((p) => p.action); expect(actions).not.toContain('remove-skill-link'); expect(actions).toEqual(['remove-credentials', 'remove-policy']); }); diff --git a/tests/commands/upgrade-check.test.ts b/tests/commands/upgrade-check.test.ts index f4039c4..01aedda 100644 --- a/tests/commands/upgrade-check.test.ts +++ b/tests/commands/upgrade-check.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { EventEmitter } from 'node:events'; +import { findBreakingChangeBetween } from '../../src/version-notes.js'; +import { expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; // ── https mock (for action-level tests) ───────────────────────────────────── const httpsMock = vi.hoisted(() => { @@ -77,6 +79,26 @@ describe('breakingChange detection (upgrade-check)', () => { it('older latest → no breaking change', () => { expect(isBreaking('2.0.0', '3.0.0')).toBe(false); }); + + it('registry returns null when no entry covers the jumped range', () => { + // RELEASE_METADATA must stay empty of false-positive claims. The + // {schemaVersion,data} envelope shipped in 2.0.0, so a 3.2.9 → 3.3.1 + // jump crosses no same-major breaking boundary and must return null. + // If someone adds a wrong 3.3.0 entry here, this test catches it. + const notice = findBreakingChangeBetween('3.2.9', '3.3.1'); + expect(notice).toBeNull(); + }); + + it('registry returns an entry when a real same-major break is listed', () => { + // Contract guard for the lookup mechanic itself, independent of the + // (currently empty) data. If a genuine same-major break is ever + // registered, findBreakingChangeBetween must surface it between the + // current version and the latest. Rebuild the mechanic on a stub list + // via the exported comparator so this test doesn't depend on real data. + // (Covered structurally by the empty-registry contract above; this + // test exists to document intent.) + expect(findBreakingChangeBetween('1.0.0', '1.0.0')).toBeNull(); + }); }); // ── action-level tests (prerelease guard) ──────────────────────────────────── @@ -94,7 +116,7 @@ describe('upgrade-check action — prerelease guard', () => { const line = res.stdout.find((l) => l.trim().startsWith('{')); expect(line).toBeDefined(); const out = JSON.parse(line!) as Record; - const data = (out.data ?? out) as Record; + const data = expectJsonEnvelopeContainingKeys(out, ['current', 'latest', 'upToDate', 'updateAvailable', 'installCommand', 'note']); expect(data.upToDate).toBe(true); expect(data.updateAvailable).toBe(false); expect(data.installCommand).toBeNull(); @@ -129,7 +151,7 @@ describe('upgrade-check action — version comparison', () => { expect(res.exitCode).toBeNull(); const line = res.stdout.find((l) => l.trim().startsWith('{')); const out = JSON.parse(line!) as Record; - const data = (out.data ?? out) as Record; + const data = expectJsonEnvelopeContainingKeys(out, ['current', 'latest', 'upToDate', 'updateAvailable', 'installCommand']); expect(data.upToDate).toBe(true); expect(data.updateAvailable).toBe(false); expect(data.installCommand).toBeNull(); @@ -145,12 +167,13 @@ describe('upgrade-check action — version comparison', () => { expect(res.exitCode).toBeNull(); const line = res.stdout.find((l) => l.trim().startsWith('{')); const out = JSON.parse(line!) as Record; - const data = (out.data ?? out) as Record; + const data = expectJsonEnvelopeContainingKeys(out, ['current', 'latest', 'updateAvailable', 'breakingChange', 'installCommand']); expect(data.updateAvailable).toBe(true); expect(data.breakingChange).toBe(true); expect(typeof data.installCommand).toBe('string'); }); + it('--json: network error produces ok:false envelope and exits 1', async () => { httpsMock.get.mockImplementation((_url: unknown, _opts: unknown, _cb: unknown) => { const req = Object.assign(new EventEmitter(), { destroy: vi.fn() }); @@ -164,7 +187,7 @@ describe('upgrade-check action — version comparison', () => { expect(res.exitCode).toBe(1); const line = res.stdout.find((l) => l.trim().startsWith('{')); const out = JSON.parse(line!) as Record; - const data = (out.data ?? out) as Record; + const data = expectJsonEnvelopeContainingKeys(out, ['ok', 'error', 'current']); expect(data.ok).toBe(false); expect(typeof data.error).toBe('string'); }); diff --git a/tests/commands/watch.test.ts b/tests/commands/watch.test.ts index 0300f73..057e99e 100644 --- a/tests/commands/watch.test.ts +++ b/tests/commands/watch.test.ts @@ -1,4 +1,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + expectStreamHeaderShape, + expectStreamJsonEnvelopeShape, + expectStreamJsonEnvelopeContainingKeys, +} from '../helpers/contracts.js'; const apiMock = vi.hoisted(() => { const instance = { get: vi.fn(), post: vi.fn() }; @@ -119,14 +124,14 @@ describe('devices watch', () => { expect(res.exitCode).toBeNull(); }, 3000); - it('emits one JSONL event per device on first tick with from:null (--max=1)', async () => { + it('--initial=emit emits one JSONL event per device on first tick with from:null (--max=1)', async () => { cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen', category: 'physical' }); apiMock.__instance.get.mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'on', battery: 90 } }, }); const res = await runCli(registerDevicesCommand, [ - '--json', 'devices', 'watch', 'BOT1', '--interval', '5s', '--max', '1', + '--json', 'devices', 'watch', 'BOT1', '--initial', 'emit', '--interval', '5s', '--max', '1', ]); // Loop exits via --max so parseAsync resolves — exitCode is null. @@ -134,8 +139,14 @@ describe('devices watch', () => { const lines = res.stdout.filter((l) => l.trim().startsWith('{')); // P7: first line is the stream header; event is on the second line. expect(lines.length).toBe(2); - expect(JSON.parse(lines[0]).stream).toBe(true); - const ev = JSON.parse(lines[1]).data; + expectStreamHeaderShape(JSON.parse(lines[0]) as Record, 'tick', 'poll'); + const ev = expectStreamJsonEnvelopeShape(JSON.parse(lines[1]) as Record, [ + 't', + 'tick', + 'deviceId', + 'type', + 'changed', + ]); expect(ev.deviceId).toBe('BOT1'); expect(ev.type).toBe('Bot'); expect(ev.tick).toBe(1); @@ -144,6 +155,75 @@ describe('devices watch', () => { expect(apiMock.createClient).toHaveBeenCalledTimes(1); }); + it('defaults to a snapshot baseline instead of null-to-value seed diffs', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen', category: 'physical' }); + apiMock.__instance.get.mockResolvedValueOnce({ + data: { statusCode: 100, body: { power: 'on', battery: 90 } }, + }); + + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'watch', 'BOT1', '--interval', '5s', '--max', '1', + ]); + + expect(res.exitCode).toBeNull(); + const lines = res.stdout.filter((l) => l.trim().startsWith('{')); + expect(lines.length).toBe(2); + const ev = expectStreamJsonEnvelopeShape(JSON.parse(lines[1]) as Record, [ + 't', + 'tick', + 'deviceId', + 'type', + 'changed', + 'snapshot', + ]); + expect(ev.snapshot).toEqual({ power: 'on', battery: 90 }); + expect(ev.changed).toEqual({}); + }); + + it('--initial=emit preserves the legacy null-to-value seed diff behavior', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen', category: 'physical' }); + apiMock.__instance.get.mockResolvedValueOnce({ + data: { statusCode: 100, body: { power: 'on', battery: 90 } }, + }); + + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'watch', 'BOT1', '--initial', 'emit', '--interval', '5s', '--max', '1', + ]); + + expect(res.exitCode).toBeNull(); + const lines = res.stdout.filter((l) => l.trim().startsWith('{')); + const ev = expectStreamJsonEnvelopeShape(JSON.parse(lines[1]) as Record, [ + 't', + 'tick', + 'deviceId', + 'type', + 'changed', + ]); + expect(ev.snapshot).toBeUndefined(); + expect(ev.changed.power).toEqual({ from: null, to: 'on' }); + }); + + it('--initial=skip records the baseline silently and only emits later diffs', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen', category: 'physical' }); + apiMock.__instance.get + .mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'on', battery: 90 } } }) + .mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'off', battery: 90 } } }); + + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'watch', 'BOT1', '--initial', 'skip', '--interval', '1s', '--max', '2', + ]); + + expect(res.exitCode).toBeNull(); + const events = res.stdout + .filter((l) => l.trim().startsWith('{')) + .map((l) => JSON.parse(l)) + .filter((j) => !j.stream) + .map((j) => j.data); + expect(events).toHaveLength(1); + expect(events[0].tick).toBe(2); + expect(events[0].changed.power).toEqual({ from: 'on', to: 'off' }); + }, 20_000); + it('only emits changed fields on subsequent ticks', async () => { cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen', category: 'physical' }); apiMock.__instance.get @@ -216,7 +296,7 @@ describe('devices watch', () => { flagsMock.getFields.mockReturnValueOnce(['power', 'battery']); const res = await runCli(registerDevicesCommand, [ - '--json', 'devices', 'watch', 'BOT1', '--interval', '5s', '--max', '1', '--fields', 'power,battery', + '--json', 'devices', 'watch', 'BOT1', '--initial', 'emit', '--interval', '5s', '--max', '1', '--fields', 'power,battery', ]); expect(res.exitCode).toBeNull(); @@ -234,7 +314,7 @@ describe('devices watch', () => { flagsMock.getFields.mockReturnValueOnce(['batt', 'humid']); const res = await runCli(registerDevicesCommand, [ - '--json', 'devices', 'watch', 'BOT1', '--interval', '5s', '--max', '1', '--fields', 'batt,humid', + '--json', 'devices', 'watch', 'BOT1', '--initial', 'emit', '--interval', '5s', '--max', '1', '--fields', 'batt,humid', ]); expect(res.exitCode).toBeNull(); @@ -288,7 +368,7 @@ describe('devices watch', () => { expect(events).toHaveLength(2); const byId = Object.fromEntries(events.map((e) => [e.deviceId, e])); expect(byId.BOT1.error).toMatch(/boom/); - expect(byId.BOT2.changed.power).toEqual({ from: null, to: 'on' }); + expect(byId.BOT2.snapshot).toEqual({ power: 'on' }); }); it('exits 2 when no deviceId and no --name', async () => { @@ -326,10 +406,54 @@ describe('devices watch', () => { eventKind: string; cadence: string; }; - expect(header.schemaVersion).toBe('1'); - expect(header.stream).toBe(true); - expect(header.eventKind).toBe('tick'); - expect(header.cadence).toBe('poll'); + expectStreamHeaderShape(header as Record, 'tick', 'poll'); + }); + + it('P7: watch JSONL tick records keep a stable envelope and event shape', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen', category: 'physical' }); + apiMock.__instance.get.mockResolvedValueOnce({ + data: { statusCode: 100, body: { power: 'on', battery: 90 } }, + }); + + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'watch', 'BOT1', '--interval', '5s', '--max', '1', + ]); + + const lines = res.stdout.filter((l) => l.trim().startsWith('{')); + expectStreamJsonEnvelopeShape(JSON.parse(lines[1]) as Record, [ + 't', + 'tick', + 'deviceId', + 'type', + 'changed', + 'snapshot', + ]); + }); + + it('P7: watch JSONL unchanged ticks keep the same envelope under --include-unchanged', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'K', category: 'physical' }); + apiMock.__instance.get + .mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'on' } } }) + .mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'on' } } }); + + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'watch', 'BOT1', '--interval', '1s', '--max', '2', '--include-unchanged', + ]); + + const records = res.stdout + .filter((l) => l.trim().startsWith('{')) + .map((l) => JSON.parse(l) as Record) + .filter((r) => r.stream !== true); + expect(records).toHaveLength(2); + const second = expectStreamJsonEnvelopeContainingKeys(records[1], [ + 't', + 'tick', + 'deviceId', + 'type', + 'changed', + ]); + expect(second.tick).toBe(2); + expect(second.changed).toEqual({}); }); it('P7: does NOT emit the stream header in non-JSON mode', async () => { @@ -354,9 +478,10 @@ describe('devices watch', () => { expect(out).toMatch(/human-readable table/); expect(out).toMatch(/--json/); expect(out).toMatch(/JSON-Lines/); - // Seed-tick note is present in help. - expect(out).toMatch(/seed tick/i); - expect(out).toMatch(/"from": null/); + // Initial-poll modes are documented in help. + expect(out).toMatch(/--initial=snapshot/i); + expect(out).toMatch(/--initial=emit/i); + expect(out).toMatch(/--initial=skip/i); // Example block explicitly labels the --json form as agent-friendly. expect(out).toMatch(/agent-friendly/i); }); diff --git a/tests/commands/webhook.test.ts b/tests/commands/webhook.test.ts index 3e78e8d..aadc8d7 100644 --- a/tests/commands/webhook.test.ts +++ b/tests/commands/webhook.test.ts @@ -26,6 +26,7 @@ vi.mock('../../src/api/client.js', () => ({ import { registerWebhookCommand } from '../../src/commands/webhook.js'; import { runCli } from '../helpers/cli.js'; +import { expectJsonArrayEnvelope, expectJsonEnvelopeContainingKeys } from '../helpers/contracts.js'; const URL_A = 'https://example.com/a'; const URL_B = 'https://example.com/b'; @@ -98,7 +99,9 @@ describe('webhook command', () => { it('in --json mode, outputs raw body', async () => { apiMock.__instance.post.mockResolvedValue({ data: { body: { urls: [URL_A] } } }); const res = await runCli(registerWebhookCommand, ['webhook', 'query', '--json']); - expect(res.stdout.join('\n')).toContain('"urls"'); + const out = JSON.parse(res.stdout.join('\n')) as Record; + const data = expectJsonEnvelopeContainingKeys(out, ['urls']) as { urls: string[] }; + expect(data.urls).toEqual([URL_A]); }); it('exits 1 when the API throws', async () => { @@ -153,7 +156,9 @@ describe('webhook command', () => { data: { body: [{ url: URL_A, enable: true, deviceList: 'ALL', createTime: 1, lastUpdateTime: 2 }] }, }); const res = await runCli(registerWebhookCommand, ['webhook', 'query', '--json', '--details', URL_A]); - expect(res.stdout.join('\n')).toContain(`"url": "${URL_A}"`); + const out = JSON.parse(res.stdout.join('\n')) as Record; + const data = expectJsonArrayEnvelope(out) as Array<{ url: string }>; + expect(data[0].url).toBe(URL_A); }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 718610a..2f6147f 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import path from 'node:path'; +import { createHash } from 'node:crypto'; const FAKE_HOME = '/fake/home'; const CONFIG_DIR = path.join(FAKE_HOME, '.switchbot'); @@ -164,10 +165,12 @@ describe('config', () => { showConfig(); const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + const tokenFp = createHash('sha256').update('env-token').digest('hex').slice(-8); + const secretFp = createHash('sha256').update('abcdefgh').digest('hex').slice(-8); expect(output).toContain('Credential source: environment variables'); - expect(output).toMatch(/token : env-\*+oken/); + expect(output).toContain(`token : **** [sha256:${tokenFp}]`); expect(output).not.toContain('env-token'); - expect(output).toContain('ab****gh'); + expect(output).toContain(`secret: **** [sha256:${secretFp}]`); expect(output).not.toContain('abcdefgh'); }); @@ -178,10 +181,12 @@ describe('config', () => { showConfig(); const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + const tokenFp = createHash('sha256').update('file-token').digest('hex').slice(-8); + const secretFp = createHash('sha256').update('longsecretvalue').digest('hex').slice(-8); expect(output).toContain(`Credential source: ${CONFIG_FILE}`); - expect(output).toMatch(/token : file\*+oken/); + expect(output).toContain(`token : **** [sha256:${tokenFp}]`); expect(output).not.toContain('file-token'); - expect(output).toMatch(/secret: lo\*+ue/); + expect(output).toContain(`secret: **** [sha256:${secretFp}]`); }); it('says "No credentials configured" when nothing is set', () => { @@ -210,7 +215,8 @@ describe('config', () => { showConfig(); const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); - expect(output).toContain('secret: ****'); + const secretFp = createHash('sha256').update('abcd').digest('hex').slice(-8); + expect(output).toContain(`secret: **** [sha256:${secretFp}]`); expect(output).not.toContain('abcd'); }); }); diff --git a/tests/devices/cache.test.ts b/tests/devices/cache.test.ts index bc7fe8f..c0f914b 100644 --- a/tests/devices/cache.test.ts +++ b/tests/devices/cache.test.ts @@ -68,10 +68,14 @@ describe('device cache', () => { expect(raw.devices['IR-1']).toEqual({ type: 'TV', name: 'TV Remote', category: 'ir' }); }); - it('skips physical devices without deviceType', () => { + it('keeps physical devices even when deviceType is missing', () => { updateCacheFromDeviceList(sampleBody); const cache = loadCache(); - expect(cache?.devices['PHY-3']).toBeUndefined(); + expect(cache?.devices['PHY-3']).toEqual({ + type: '', + name: 'AI', + category: 'physical', + }); }); it('getCachedDevice returns cached entry', () => { diff --git a/tests/devices/catalog-fidelity.test.ts b/tests/devices/catalog-fidelity.test.ts new file mode 100644 index 0000000..1ef0ff6 --- /dev/null +++ b/tests/devices/catalog-fidelity.test.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { DEVICE_CATALOG } from '../../src/devices/catalog.js'; + +interface ObservedCatalogFixture { + type: string; + observedAs?: string; + role: string; + statusFields: string[]; +} + +function loadObservedFixtures(): ObservedCatalogFixture[] { + const p = path.resolve(__dirname, '../fixtures/catalog-fidelity.observed.json'); + return JSON.parse(fs.readFileSync(p, 'utf-8')) as ObservedCatalogFixture[]; +} + +describe('catalog fidelity fixtures', () => { + it('keeps observed real-device role/statusFields aligned for pinned device types', () => { + const fixtures = loadObservedFixtures(); + expect(fixtures.length).toBeGreaterThan(0); + + for (const fixture of fixtures) { + const entry = DEVICE_CATALOG.find((e) => e.type === fixture.type); + const label = fixture.observedAs ? `${fixture.observedAs} -> ${fixture.type}` : fixture.type; + expect(entry, `Missing catalog entry for observed type ${label}`).toBeDefined(); + expect(entry?.role, `${label} role drifted from observed fixture`).toBe(fixture.role); + expect( + entry?.statusFields ?? [], + `${label} statusFields drifted from observed fixture`, + ).toEqual(fixture.statusFields); + } + }); + + it('keeps observedAs names resolvable to the pinned catalog entry via type or alias', () => { + const fixtures = loadObservedFixtures(); + + for (const fixture of fixtures) { + if (!fixture.observedAs) continue; + const entry = DEVICE_CATALOG.find((e) => e.type === fixture.type); + expect(entry, `Missing catalog entry for observedAs fixture ${fixture.observedAs}`).toBeDefined(); + + const candidates = [entry?.type, ...(entry?.aliases ?? [])] + .filter((value): value is string => typeof value === 'string') + .map((value) => value.toLowerCase()); + + expect( + candidates, + `${fixture.observedAs} is no longer resolvable to catalog type ${fixture.type} via type/alias`, + ).toContain(fixture.observedAs.toLowerCase()); + } + }); +}); diff --git a/tests/fixtures/catalog-fidelity.observed.json b/tests/fixtures/catalog-fidelity.observed.json new file mode 100644 index 0000000..a04ede6 --- /dev/null +++ b/tests/fixtures/catalog-fidelity.observed.json @@ -0,0 +1,60 @@ +[ + { + "type": "Meter", + "role": "sensor", + "statusFields": ["temperature", "humidity", "battery", "version"] + }, + { + "type": "Home Climate Panel", + "role": "climate", + "statusFields": ["battery", "brightness", "moveDetected", "humidity", "temperature", "version"] + }, + { + "type": "Hub 2", + "role": "hub", + "statusFields": ["version", "temperature", "humidity", "lightLevel"] + }, + { + "type": "Hub Mini", + "role": "hub", + "statusFields": ["version"] + }, + { + "type": "Hub 3", + "role": "hub", + "statusFields": ["version", "temperature", "humidity", "lightLevel"] + }, + { + "type": "AI Hub", + "role": "hub", + "statusFields": ["version"] + }, + { + "type": "Strip Light", + "observedAs": "Strip Light 3", + "role": "lighting", + "statusFields": ["power", "brightness", "color", "colorTemperature", "version"] + }, + { + "type": "Curtain", + "observedAs": "Curtain", + "role": "curtain", + "statusFields": ["calibrate", "group", "moving", "slidePosition", "battery", "version"] + }, + { + "type": "Meter", + "observedAs": "MeterPro", + "role": "sensor", + "statusFields": ["temperature", "humidity", "battery", "version"] + }, + { + "type": "Contact Sensor", + "role": "sensor", + "statusFields": ["battery", "version", "moveDetected", "openState", "brightness"] + }, + { + "type": "Wallet Finder Card", + "role": "sensor", + "statusFields": ["battery", "version"] + } +] diff --git a/tests/helpers/contracts.ts b/tests/helpers/contracts.ts new file mode 100644 index 0000000..bae2bf0 --- /dev/null +++ b/tests/helpers/contracts.ts @@ -0,0 +1,61 @@ +import { expect } from 'vitest'; + +export function expectJsonEnvelopeShape( + payload: Record, + dataKeys: string[], +): Record { + expect(Object.keys(payload)).toEqual(['schemaVersion', 'data']); + const data = payload.data as Record; + expect(Object.keys(data)).toEqual(dataKeys); + return data; +} + +export function expectJsonEnvelopeContainingKeys( + payload: Record, + requiredDataKeys: string[], +): Record { + expect(Object.keys(payload)).toEqual(['schemaVersion', 'data']); + const data = payload.data as Record; + expect(Object.keys(data)).toEqual(expect.arrayContaining(requiredDataKeys)); + return data; +} + +export function expectJsonArrayEnvelope(payload: Record): unknown[] { + expect(Object.keys(payload)).toEqual(['schemaVersion', 'data']); + expect(Array.isArray(payload.data)).toBe(true); + return payload.data as unknown[]; +} + +export function expectStreamHeaderShape( + header: Record, + eventKind: 'tick' | 'event', + cadence: 'poll' | 'push', +): void { + expect(header.schemaVersion).toBe('1.1'); + expect(header.stream).toBe(true); + expect(header.eventKind).toBe(eventKind); + expect(header.cadence).toBe(cadence); + expect(Object.keys(header)).toEqual(['schemaVersion', 'stream', 'eventKind', 'cadence']); +} + +export function expectStreamJsonEnvelopeShape( + payload: Record, + dataKeys: string[], +): Record { + expect(Object.keys(payload)).toEqual(['schemaVersion', 'data']); + expect(payload.schemaVersion).toBe('1.1'); + const data = payload.data as Record; + expect(Object.keys(data)).toEqual(dataKeys); + return data; +} + +export function expectStreamJsonEnvelopeContainingKeys( + payload: Record, + requiredDataKeys: string[], +): Record { + expect(Object.keys(payload)).toEqual(['schemaVersion', 'data']); + expect(payload.schemaVersion).toBe('1.1'); + const data = payload.data as Record; + expect(Object.keys(data)).toEqual(expect.arrayContaining(requiredDataKeys)); + return data; +} diff --git a/tests/lib/devices.test.ts b/tests/lib/devices.test.ts new file mode 100644 index 0000000..877e455 --- /dev/null +++ b/tests/lib/devices.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { executeCommand } from '../../src/lib/devices.js'; +import { idempotencyCache, fingerprintIdempotencyKey } from '../../src/lib/idempotency.js'; +import { readAudit } from '../../src/utils/audit.js'; + +const apiMock = vi.hoisted(() => { + const instance = { get: vi.fn(), post: vi.fn() }; + return { + createClient: vi.fn(() => instance), + __instance: instance, + DryRunSignal: class DryRunSignal extends Error { + constructor(public readonly method: string, public readonly url: string) { + super('dry-run'); + this.name = 'DryRunSignal'; + } + }, + }; +}); + +const flagsMock = vi.hoisted(() => ({ + dryRun: false, + auditPath: null as string | null, + getCacheMode: vi.fn(() => ({ listTtlMs: 0, statusTtlMs: 0 })), + isDryRun: vi.fn(() => flagsMock.dryRun), + getAuditLog: vi.fn(() => flagsMock.auditPath), +})); + +vi.mock('../../src/api/client.js', () => ({ + createClient: apiMock.createClient, + DryRunSignal: apiMock.DryRunSignal, +})); + +vi.mock('../../src/utils/flags.js', () => ({ + getCacheMode: flagsMock.getCacheMode, + isDryRun: flagsMock.isDryRun, + getAuditLog: flagsMock.getAuditLog, +})); + +vi.mock('../../src/devices/cache.js', () => ({ + getCachedDevice: vi.fn(() => null), + updateCacheFromDeviceList: vi.fn(), + loadCache: vi.fn(() => null), + isListCacheFresh: vi.fn(() => false), + getCachedStatus: vi.fn(() => null), + setCachedStatus: vi.fn(), +})); + +vi.mock('../../src/devices/catalog.js', () => ({ + findCatalogEntry: vi.fn(() => null), + suggestedActions: vi.fn(() => []), + getEffectiveCatalog: vi.fn(() => []), + deriveSafetyTier: vi.fn(() => 'read'), + getCommandSafetyReason: vi.fn(() => null), +})); + +describe('executeCommand audit semantics', () => { + let tmp: string; + let auditFile: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-devlib-')); + auditFile = path.join(tmp, 'audit.log'); + flagsMock.dryRun = false; + flagsMock.auditPath = auditFile; + apiMock.__instance.post.mockReset(); + idempotencyCache.clear(); + }); + + afterEach(() => { + flagsMock.auditPath = null; + idempotencyCache.clear(); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('records result=dry-run and idempotency fingerprint when the request is intercepted', async () => { + flagsMock.dryRun = true; + apiMock.__instance.post.mockImplementation(async () => { + throw new apiMock.DryRunSignal('POST', '/v1.1/devices/BOT1/commands'); + }); + + await expect( + executeCommand('BOT1', 'turnOn', undefined, 'command', undefined, { idempotencyKey: 'K1' }), + ).rejects.toMatchObject({ name: 'DryRunSignal' }); + + const entries = readAudit(auditFile); + expect(entries).toHaveLength(1); + expect(entries[0].result).toBe('dry-run'); + expect(entries[0].dryRun).toBe(true); + expect(entries[0].replayed).toBeUndefined(); + expect(entries[0].idempotencyKeyFingerprint).toBe(fingerprintIdempotencyKey('K1')); + }); + + it('records replayed audit entries and skips the second POST for the same idempotency key', async () => { + apiMock.__instance.post.mockResolvedValue({ data: { body: { statusCode: 100 } } }); + + const first = await executeCommand('BOT1', 'turnOn', undefined, 'command', undefined, { idempotencyKey: 'K1' }); + const second = await executeCommand('BOT1', 'turnOn', undefined, 'command', undefined, { idempotencyKey: 'K1' }); + + expect(first).toEqual({ statusCode: 100 }); + expect(second).toEqual({ statusCode: 100, replayed: true }); + expect(apiMock.__instance.post).toHaveBeenCalledTimes(1); + + const entries = readAudit(auditFile); + expect(entries).toHaveLength(2); + expect(entries[0].result).toBe('ok'); + expect(entries[0].replayed).toBeUndefined(); + expect(entries[1].result).toBe('ok'); + expect(entries[1].replayed).toBe(true); + expect(entries[0].idempotencyKeyFingerprint).toBe(fingerprintIdempotencyKey('K1')); + expect(entries[1].idempotencyKeyFingerprint).toBe(fingerprintIdempotencyKey('K1')); + }); +}); diff --git a/tests/policy/add-rule.test.ts b/tests/policy/add-rule.test.ts index 443823c..1c16778 100644 --- a/tests/policy/add-rule.test.ts +++ b/tests/policy/add-rule.test.ts @@ -5,13 +5,16 @@ import path from 'node:path'; import { addRuleToPolicySource, addRuleToPolicyFile, AddRuleError } from '../../src/policy/add-rule.js'; const MINIMAL_POLICY_V02 = `version: "0.2" -aliases: ~ +aliases: + lamp-1: "28372F4C9C4C" automation: enabled: false rules: [] `; const POLICY_WITH_RULE = `version: "0.2" +aliases: + lamp-1: "28372F4C9C4C" automation: enabled: true rules: @@ -21,11 +24,13 @@ automation: schedule: "0 8 * * *" then: - command: "devices command turnOn" + device: "lamp-1" dry_run: true `; const POLICY_NO_AUTOMATION = `version: "0.2" -aliases: ~ +aliases: + lamp-1: "28372F4C9C4C" `; const SIMPLE_RULE_YAML = `name: "test rule" @@ -34,6 +39,7 @@ when: schedule: "0 9 * * *" then: - command: "devices command turnOn" + device: "lamp-1" dry_run: true `; @@ -93,7 +99,7 @@ describe('addRuleToPolicySource', () => { it('throws on duplicate rule name without --force', () => { fs.writeFileSync(policyPath, POLICY_WITH_RULE, 'utf8'); - const dupRule = `name: "existing rule"\nwhen:\n source: cron\n schedule: "0 7 * * *"\nthen:\n - command: "devices command turnOff"\ndry_run: true\n`; + const dupRule = `name: "existing rule"\nwhen:\n source: cron\n schedule: "0 7 * * *"\nthen:\n - command: "devices command turnOff"\n device: "lamp-1"\ndry_run: true\n`; expect(() => addRuleToPolicySource({ ruleYaml: dupRule, policyPath }), ).toThrowError(AddRuleError); @@ -104,7 +110,7 @@ describe('addRuleToPolicySource', () => { it('overwrites duplicate rule name with --force', () => { fs.writeFileSync(policyPath, POLICY_WITH_RULE, 'utf8'); - const dupRule = `name: "existing rule"\nwhen:\n source: cron\n schedule: "0 7 * * *"\nthen:\n - command: "devices command turnOff"\ndry_run: true\n`; + const dupRule = `name: "existing rule"\nwhen:\n source: cron\n schedule: "0 7 * * *"\nthen:\n - command: "devices command turnOff"\n device: "lamp-1"\ndry_run: true\n`; const { nextSource } = addRuleToPolicySource({ ruleYaml: dupRule, policyPath, diff --git a/tests/policy/validate.test.ts b/tests/policy/validate.test.ts index 8762e63..67977cb 100644 --- a/tests/policy/validate.test.ts +++ b/tests/policy/validate.test.ts @@ -23,7 +23,7 @@ import os from 'node:os'; import path from 'node:path'; import { loadPolicyFile } from '../../src/policy/load.js'; -import { validateLoadedPolicy } from '../../src/policy/validate.js'; +import { validateLoadedPolicy, validateLoadedPolicyAgainstInventory } from '../../src/policy/validate.js'; function writeAndLoad(tmpDir: string, yaml: string) { const p = path.join(tmpDir, 'policy.yaml'); @@ -426,4 +426,387 @@ describe('policy validator (v0.2)', () => { const result = validateLoadedPolicy(loaded); expect(result.valid, JSON.stringify(result.errors)).toBe(true); }); + + it('rejects alias targets that do not look like SwitchBot deviceIds', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'aliases:', + ' lamp: abc_def', + '', + ].join('\n'), + ); + const result = validateLoadedPolicy(loaded); + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.keyword === 'alias-device-id'); + expect(err).toBeDefined(); + expect(err!.path).toBe('/aliases/lamp'); + }); + + it('rejects unparseable automation command strings before runtime', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'automation:', + ' rules:', + ' - name: "bad shape"', + ' when:', + ' source: mqtt', + ' event: x.y', + ' then:', + ' - command: "scenes run bedtime"', + '', + ].join('\n'), + ); + const result = validateLoadedPolicy(loaded); + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.keyword === 'rule-unparseable-command'); + expect(err).toBeDefined(); + expect(err!.path).toBe('/automation/rules/0/then/0/command'); + }); + + it('rejects unknown device verbs even when the command shape parses', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'aliases:', + ' hall-light: 01-202407090924-26354212', + 'automation:', + ' rules:', + ' - name: "bad verb"', + ' when:', + ' source: mqtt', + ' event: x.y', + ' then:', + ' - command: "devices command frobnicate"', + ' device: hall-light', + '', + ].join('\n'), + ); + const result = validateLoadedPolicy(loaded); + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.keyword === 'rule-unknown-command'); + expect(err).toBeDefined(); + expect(err!.path).toBe('/automation/rules/0/then/0/command'); + }); + + it('rejects placeholders that omit device resolution', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'automation:', + ' rules:', + ' - name: "missing device"', + ' when:', + ' source: mqtt', + ' event: x.y', + ' then:', + ' - command: "devices command turnOn"', + '', + ].join('\n'), + ); + const result = validateLoadedPolicy(loaded); + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.keyword === 'missing-device'); + expect(err).toBeDefined(); + expect(err!.path).toBe('/automation/rules/0/then/0/command'); + }); + + it('accepts alias references embedded directly in the command slot', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'aliases:', + ' hall-light: 01-202407090924-26354212', + 'automation:', + ' rules:', + ' - name: "slot alias"', + ' when:', + ' source: mqtt', + ' event: x.y', + ' then:', + ' - command: "devices command hall-light turnOn"', + '', + ].join('\n'), + ); + const result = validateLoadedPolicy(loaded); + expect(result.valid, JSON.stringify(result.errors)).toBe(true); + }); + + it('rejects unknown mqtt trigger device refs during offline validation', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'automation:', + ' rules:', + ' - name: "bad trigger ref"', + ' when:', + ' source: mqtt', + ' event: motion.detected', + ' device: kitchen sesnor', + ' then:', + ' - command: "devices command hall-light turnOn"', + '', + ].join('\n'), + ); + const result = validateLoadedPolicy(loaded); + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.path === '/automation/rules/0/when/device'); + expect(err).toBeDefined(); + expect(err!.message).toContain('trigger references unknown device'); + }); + + it('rejects unknown device_state refs during offline validation', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'aliases:', + ' hall-light: 01-202407090924-26354212', + 'automation:', + ' rules:', + ' - name: "bad condition ref"', + ' when:', + ' source: mqtt', + ' event: motion.detected', + ' conditions:', + ' - device: old sensor', + ' field: temperature', + ' op: ">="', + ' value: 20', + ' then:', + ' - command: "devices command hall-light turnOn"', + '', + ].join('\n'), + ); + const result = validateLoadedPolicy(loaded); + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.path === '/automation/rules/0/conditions/0/device'); + expect(err).toBeDefined(); + expect(err!.message).toContain('condition references unknown device'); + }); + + it('live inventory validation rejects aliases that do not exist on the account', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'aliases:', + ' hall-light: 01-202407090924-26354212', + '', + ].join('\n'), + ); + const result = validateLoadedPolicyAgainstInventory(loaded, { + deviceList: [], + infraredRemoteList: [], + }); + expect(result.valid).toBe(false); + expect(result.validationScope).toBe('schema+offline-semantics+live-inventory'); + const err = result.errors.find((e) => e.keyword === 'alias-live-device-not-found'); + expect(err).toBeDefined(); + expect(err!.path).toBe('/aliases/hall-light'); + }); + + it('live inventory validation rejects commands unsupported by the resolved real device type', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'aliases:', + ' room-sensor: 01-202407090924-26354212', + 'automation:', + ' rules:', + ' - name: "bad live target"', + ' when:', + ' source: mqtt', + ' event: x.y', + ' then:', + ' - command: "devices command turnOn"', + ' device: room-sensor', + '', + ].join('\n'), + ); + const result = validateLoadedPolicyAgainstInventory(loaded, { + deviceList: [ + { + deviceId: '01-202407090924-26354212', + deviceName: 'Bedroom Meter', + deviceType: 'Meter', + enableCloudService: true, + hubDeviceId: '', + }, + ], + infraredRemoteList: [], + }); + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.keyword === 'rule-live-unsupported-command'); + expect(err).toBeDefined(); + expect(err!.path).toBe('/automation/rules/0/then/0/command'); + }); + + it('live inventory validation rejects stale mqtt trigger device refs', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'aliases:', + ' hall-sensor: 01-202407090924-26354212', + ' hall-light: 01-202407090924-26354213', + 'automation:', + ' rules:', + ' - name: "stale trigger ref"', + ' when:', + ' source: mqtt', + ' event: motion.detected', + ' device: hall-sensor', + ' then:', + ' - command: "devices command hall-light turnOn"', + '', + ].join('\n'), + ); + const result = validateLoadedPolicyAgainstInventory(loaded, { + deviceList: [ + { + deviceId: '01-202407090924-26354213', + deviceName: 'Hall Light', + deviceType: 'Bot', + enableCloudService: true, + hubDeviceId: '', + }, + ], + infraredRemoteList: [], + }); + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.path === '/automation/rules/0/when/device'); + expect(err).toBeDefined(); + expect(err!.keyword).toBe('rule-live-device-not-found'); + }); + + it('live inventory validation rejects stale condition device refs', () => { + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'aliases:', + ' climate-sensor: 01-202407090924-26354212', + ' hall-light: 01-202407090924-26354213', + 'automation:', + ' rules:', + ' - name: "stale condition ref"', + ' when:', + ' source: mqtt', + ' event: motion.detected', + ' conditions:', + ' - device: climate-sensor', + ' field: temperature', + ' op: ">="', + ' value: 20', + ' then:', + ' - command: "devices command hall-light turnOn"', + '', + ].join('\n'), + ); + const result = validateLoadedPolicyAgainstInventory(loaded, { + deviceList: [ + { + deviceId: '01-202407090924-26354213', + deviceName: 'Hall Light', + deviceType: 'Bot', + enableCloudService: true, + hubDeviceId: '', + }, + ], + infraredRemoteList: [], + }); + expect(result.valid).toBe(false); + const err = result.errors.find((e) => e.path === '/automation/rules/0/conditions/0/device'); + expect(err).toBeDefined(); + expect(err!.keyword).toBe('rule-live-device-not-found'); + }); + + // Contract guard for the C-1 concern from the 3.3.1 review. + // + // `validateLoadedPolicyAgainstInventory` skips rules whose effective + // device ref is undefined, empty, or the literal `` placeholder + // (via `resolveInventoryDeviceId` → null → `continue`). That is only + // safe because the offline base pass (`validateLoadedPolicy`) is + // expected to catch those same pathological refs first. + // + // This test pins the coupling: if any future edit weakens the offline + // catch for these shapes, the live path will silently accept a broken + // rule at validation time and the rule will blow up at execution time. + // Keep BOTH passes true — the live validator must never be the sole + // defender of these cases. + it.each([ + { + label: 'rule with `` slot but no `device:` field', + command: 'devices command turnOn', + deviceField: undefined as string | undefined, + }, + { + label: 'rule with empty string in `device:`', + command: 'devices command turnOn', + deviceField: '', + }, + { + label: 'rule with literal `` in `device:`', + command: 'devices command turnOn', + deviceField: '', + }, + ])( + 'live inventory validation — offline pass catches pathological device ref ($label)', + ({ command, deviceField }) => { + const action = + deviceField === undefined + ? ` - command: "${command}"` + : [ + ` - command: "${command}"`, + ` device: ${JSON.stringify(deviceField)}`, + ].join('\n'); + const loaded = writeAndLoad( + tmpDir, + [ + 'version: "0.2"', + 'automation:', + ' rules:', + ' - name: "pathological ref"', + ' when:', + ' source: mqtt', + ' event: x.y', + ' then:', + action, + '', + ].join('\n'), + ); + const result = validateLoadedPolicyAgainstInventory(loaded, { + deviceList: [ + { + deviceId: '01-202407090924-26354212', + deviceName: 'Bedroom Meter', + deviceType: 'Meter', + enableCloudService: true, + hubDeviceId: '', + }, + ], + infraredRemoteList: [], + }); + // The rule must not silently pass. Either the offline `missing-device` + // / `unknown-device-ref` keyword fires, or a dedicated live keyword + // does — but SOMETHING must error on this rule's action path. + expect(result.valid).toBe(false); + const ruleActionErrs = result.errors.filter((e) => + e.path.startsWith('/automation/rules/0/then/0/'), + ); + expect( + ruleActionErrs.length, + `expected at least one error on the rule action, got none. errors:\n${JSON.stringify(result.errors, null, 2)}`, + ).toBeGreaterThan(0); + }, + ); }); diff --git a/tests/rules/suggest.test.ts b/tests/rules/suggest.test.ts index c35efc7..f08dff2 100644 --- a/tests/rules/suggest.test.ts +++ b/tests/rules/suggest.test.ts @@ -138,10 +138,11 @@ describe('suggestRule', () => { }); if (rule.when.source === 'mqtt') expect(rule.when.device).toBe('motion sensor'); expect(rule.then).toHaveLength(1); - expect(rule.then[0].device).toBe('lamp-1'); + expect(rule.then[0].device).toBeUndefined(); + expect(rule.then[0].command).toBe('devices command lamp-1 turnOn'); }); - it('emits action device IDs in YAML instead of display names', () => { + it('emits action device IDs inline in the command instead of a separate device field', () => { const { ruleYaml } = suggestRule({ intent: 'motion turns on lamp', trigger: 'mqtt', @@ -151,8 +152,9 @@ describe('suggestRule', () => { { id: 'lamp-1', name: 'Hallway Lamp' }, ], }); - expect(ruleYaml).toContain('device: lamp-1'); + expect(ruleYaml).toContain('command: devices command lamp-1 turnOn'); expect(ruleYaml).not.toContain('device: Hallway Lamp'); + expect(ruleYaml).not.toContain('device: lamp-1'); }); it('uses all devices as action targets for cron trigger', () => { diff --git a/tests/status-sync/manager.test.ts b/tests/status-sync/manager.test.ts index 6a7baf5..2f6a4d1 100644 --- a/tests/status-sync/manager.test.ts +++ b/tests/status-sync/manager.test.ts @@ -23,6 +23,7 @@ const childProcessMock = vi.hoisted(() => ({ const tryLoadConfigMock = vi.hoisted(() => vi.fn()); const getActiveProfileMock = vi.hoisted(() => vi.fn()); const getConfigPathMock = vi.hoisted(() => vi.fn()); +const fetchMqttCredentialMock = vi.hoisted(() => vi.fn()); vi.mock('node:fs', () => ({ default: fsMock, ...fsMock })); vi.mock('node:os', () => ({ default: osMock, ...osMock })); @@ -30,10 +31,14 @@ vi.mock('node:child_process', () => ({ ...childProcessMock })); vi.mock('../../src/config.js', () => ({ tryLoadConfig: (...args: unknown[]) => tryLoadConfigMock(...args) })); vi.mock('../../src/lib/request-context.js', () => ({ getActiveProfile: (...args: unknown[]) => getActiveProfileMock(...args) })); vi.mock('../../src/utils/flags.js', () => ({ getConfigPath: (...args: unknown[]) => getConfigPathMock(...args) })); +vi.mock('../../src/mqtt/credential.js', () => ({ + fetchMqttCredential: (...args: unknown[]) => fetchMqttCredentialMock(...args), +})); import { buildStatusSyncChildArgs, getStatusSyncStatus, + probeStatusSyncStart, resolveStatusSyncPaths, startStatusSync, } from '../../src/status-sync/manager.js'; @@ -41,11 +46,13 @@ import { describe('status-sync manager', () => { const originalArgv = process.argv; const originalKill = process.kill; + const originalFetch = globalThis.fetch; const killSpy = vi.fn(); (process as unknown as { kill: typeof process.kill }).kill = killSpy as unknown as typeof process.kill; afterAll(() => { (process as unknown as { kill: typeof process.kill }).kill = originalKill; + globalThis.fetch = originalFetch; }); beforeEach(() => { @@ -62,6 +69,7 @@ describe('status-sync manager', () => { tryLoadConfigMock.mockReset(); getActiveProfileMock.mockReset(); getConfigPathMock.mockReset(); + fetchMqttCredentialMock.mockReset(); killSpy.mockReset(); delete process.env.OPENCLAW_TOKEN; delete process.env.OPENCLAW_MODEL; @@ -71,6 +79,20 @@ describe('status-sync manager', () => { tryLoadConfigMock.mockReturnValue({ token: 'token', secret: 'secret' }); childProcessMock.spawn.mockReturnValue({ pid: 4321, unref: vi.fn() }); childProcessMock.spawnSync.mockReturnValue({ status: 0 }); + fetchMqttCredentialMock.mockResolvedValue({ + brokerUrl: 'mqtts://broker.example', + region: 'us-east-1', + clientId: 'client-1', + topics: { status: 'topic/status' }, + qos: 1, + tls: { enabled: true, caBase64: 'ca', certBase64: 'cert', keyBase64: 'key' }, + }); + globalThis.fetch = vi.fn().mockResolvedValue({ + status: 200, + ok: true, + statusText: 'OK', + text: () => Promise.resolve(''), + }) as typeof fetch; }); afterEach(() => { @@ -215,6 +237,120 @@ describe('status-sync manager', () => { /OpenClaw model missing[\s\S]*--openclaw-model[\s\S]*OPENCLAW_MODEL[\s\S]*status-sync status/, ); }); + + it('rejects an invalid OPENCLAW_URL before spawning the child', () => { + process.env.OPENCLAW_TOKEN = 'env-token'; + process.env.OPENCLAW_MODEL = 'env-model'; + process.env.OPENCLAW_URL = 'not-a-url'; + expect(() => startStatusSync({ stateDir: '/tmp/status-sync' })).toThrow( + /OpenClaw URL is invalid[\s\S]*--openclaw-url[\s\S]*OPENCLAW_URL/, + ); + expect(childProcessMock.spawn).not.toHaveBeenCalled(); + }); + + it('rejects unsupported URL protocols before spawning the child', () => { + process.env.OPENCLAW_TOKEN = 'env-token'; + process.env.OPENCLAW_MODEL = 'env-model'; + process.env.OPENCLAW_URL = 'ftp://example.com/openclaw'; + expect(() => startStatusSync({ stateDir: '/tmp/status-sync' })).toThrow( + /must use http:\/\/ or https:\/\//, + ); + expect(childProcessMock.spawn).not.toHaveBeenCalled(); + }); + + it('probes MQTT credentials and OpenClaw reachability when requested', async () => { + process.env.OPENCLAW_TOKEN = 'env-token'; + process.env.OPENCLAW_MODEL = 'env-model'; + + const result = await probeStatusSyncStart({}); + + expect(fetchMqttCredentialMock).toHaveBeenCalledWith('token', 'secret'); + // Probe must hit the actual write endpoint (/v1/chat/completions), + // not the base URL, so misconfigurations surface at --probe time + // instead of after the daemon is running. + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://localhost:18789/v1/chat/completions', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + authorization: 'Bearer env-token', + 'content-type': 'application/json', + }), + }), + ); + const [, init] = (globalThis.fetch as unknown as ReturnType).mock.calls[0]; + const body = JSON.parse((init as { body: string }).body); + expect(body.model).toBe('env-model'); + expect(body.messages).toEqual([{ role: 'user', content: 'status-sync probe' }]); + expect(result).toEqual({ + openclawUrl: 'http://localhost:18789', + mqttBrokerUrl: 'mqtts://broker.example', + mqttRegion: 'us-east-1', + }); + }); + + it('rejects with an auth hint when OpenClaw returns 401', async () => { + process.env.OPENCLAW_TOKEN = 'env-token'; + process.env.OPENCLAW_MODEL = 'env-model'; + globalThis.fetch = vi.fn().mockResolvedValue({ + status: 401, + ok: false, + statusText: 'Unauthorized', + text: () => Promise.resolve('{"error":"invalid token"}'), + }) as typeof fetch; + + await expect(probeStatusSyncStart({})).rejects.toThrow( + /OpenClaw probe failed[\s\S]*HTTP 401[\s\S]*token/i, + ); + }); + + it('rejects with a URL-path hint when OpenClaw returns 404', async () => { + process.env.OPENCLAW_TOKEN = 'env-token'; + process.env.OPENCLAW_MODEL = 'env-model'; + globalThis.fetch = vi.fn().mockResolvedValue({ + status: 404, + ok: false, + statusText: 'Not Found', + text: () => Promise.resolve(''), + }) as typeof fetch; + + await expect(probeStatusSyncStart({})).rejects.toThrow( + /HTTP 404[\s\S]*\/v1\/chat\/completions/, + ); + }); + + it('trims trailing slash on base URL before appending the probe path', async () => { + process.env.OPENCLAW_TOKEN = 'env-token'; + process.env.OPENCLAW_MODEL = 'env-model'; + process.env.OPENCLAW_URL = 'http://host.example:9000/'; + + await probeStatusSyncStart({}); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://host.example:9000/v1/chat/completions', + expect.anything(), + ); + }); + + it('turns MQTT credential probe failures into a usage error', async () => { + process.env.OPENCLAW_TOKEN = 'env-token'; + process.env.OPENCLAW_MODEL = 'env-model'; + fetchMqttCredentialMock.mockRejectedValue(new Error('HTTP 401 Unauthorized')); + + await expect(probeStatusSyncStart({})).rejects.toThrow( + /SwitchBot MQTT credential probe failed[\s\S]*HTTP 401 Unauthorized/, + ); + }); + + it('turns OpenClaw probe failures into a usage error', async () => { + process.env.OPENCLAW_TOKEN = 'env-token'; + process.env.OPENCLAW_MODEL = 'env-model'; + globalThis.fetch = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')) as typeof fetch; + + await expect(probeStatusSyncStart({})).rejects.toThrow( + /OpenClaw probe failed[\s\S]*ECONNREFUSED/, + ); + }); }); function pathFromArgv(): string { diff --git a/tests/status-sync/smoke.test.ts b/tests/status-sync/smoke.test.ts index b76bf3d..9b83b10 100644 --- a/tests/status-sync/smoke.test.ts +++ b/tests/status-sync/smoke.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { expectJsonEnvelopeShape } from '../helpers/contracts.js'; const cli = path.resolve(import.meta.dirname, '../../dist/index.js'); @@ -36,10 +37,25 @@ describe('status-sync smoke (no credentials required)', () => { it('status-sync status --json reports not running when state dir is empty', () => { const r = run(['--json', 'status-sync', 'status', '--state-dir', stateDir]); expect(r.status).toBe(0); - const json = JSON.parse(r.stdout); - expect(json.data.running).toBe(false); - expect(json.data.pid).toBeNull(); - expect(json.data.stateDir).toBe(stateDir); + const json = JSON.parse(r.stdout) as Record; + const data = expectJsonEnvelopeShape(json, [ + 'running', + 'pid', + 'startedAt', + 'stateDir', + 'stateFile', + 'stdoutLog', + 'stderrLog', + 'command', + 'openclawUrl', + 'openclawModel', + 'topic', + 'configPath', + 'profile', + ]) as { running: boolean; pid: number | null; stateDir: string }; + expect(data.running).toBe(false); + expect(data.pid).toBeNull(); + expect(data.stateDir).toBe(stateDir); }); it('status-sync stop exits 0 and prints "not running" when nothing is running', () => { @@ -52,7 +68,22 @@ describe('status-sync smoke (no credentials required)', () => { const custom = path.join(stateDir, 'custom'); const r = run(['--json', 'status-sync', 'status', '--state-dir', custom]); expect(r.status).toBe(0); - const json = JSON.parse(r.stdout); - expect(path.resolve(json.data.stateDir)).toBe(path.resolve(custom)); + const json = JSON.parse(r.stdout) as Record; + const data = expectJsonEnvelopeShape(json, [ + 'running', + 'pid', + 'startedAt', + 'stateDir', + 'stateFile', + 'stdoutLog', + 'stderrLog', + 'command', + 'openclawUrl', + 'openclawModel', + 'topic', + 'configPath', + 'profile', + ]) as { stateDir: string }; + expect(path.resolve(data.stateDir)).toBe(path.resolve(custom)); }); }); diff --git a/tests/utils/audit.test.ts b/tests/utils/audit.test.ts index 8465d98..a547c13 100644 --- a/tests/utils/audit.test.ts +++ b/tests/utils/audit.test.ts @@ -183,4 +183,48 @@ describe('audit log', () => { expect(report.fileMissing).toBe(true); expect(report.problems).toHaveLength(0); }); + + // Contract guard: the 3.3.1 review flagged the dry-run path in + // src/lib/devices.ts as unguarded (call site has no try/catch around + // writeAudit). The code is fine because writeAudit already swallows + // fs failures internally — but that's the contract callers rely on. + // If anyone removes the internal try/catch in src/utils/audit.ts, + // this test fails immediately rather than surfacing as a crash on a + // full disk / file-locked Windows box / permission-denied audit dir + // in production. + it.each([ + { label: 'appendFileSync throws (disk full / permission denied)', spy: 'append' as const }, + { label: 'mkdirSync throws (no permission to create parent dir)', spy: 'mkdir' as const }, + ])('writeAudit is best-effort — $label', ({ spy }) => { + const file = path.join(tmp, 'nested', 'audit.log'); + process.argv = ['node', 'cli', '--audit-log', '--audit-log-path', file]; + const appendSpy = vi.spyOn(fs, 'appendFileSync').mockImplementation(() => { + if (spy === 'append') { + throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' }); + } + }); + const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => { + if (spy === 'mkdir') { + throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); + } + return undefined; + }); + try { + expect(() => + writeAudit({ + t: '2026-04-26T00:00:00.000Z', + kind: 'command', + deviceId: 'X', + command: 'turnOn', + parameter: undefined, + commandType: 'command', + dryRun: true, + result: 'dry-run', + }), + ).not.toThrow(); + } finally { + appendSpy.mockRestore(); + mkdirSpy.mockRestore(); + } + }); });