diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e088269..6cc358f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,9 +128,13 @@ jobs: fi pack-install-smoke: - name: Packed install smoke (esbuild — matches publish) - runs-on: ubuntu-latest + name: Packed install smoke (${{ matrix.os }}) + runs-on: ${{ matrix.os }} needs: test + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -139,7 +143,7 @@ jobs: cache: npm - run: npm ci - run: npm run build - - name: npm pack -> npm install tarball -> switchbot --version + - name: npm pack -> npm install tarball -> switchbot --version / policy new / policy validate run: npm run smoke:pack-install policy-schema-sync: diff --git a/CHANGELOG.md b/CHANGELOG.md index a295434..8e1ae6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,52 @@ All notable changes to `@switchbot/openapi-cli` are documented in this file. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.3.0] - 2026-04-26 + +### Fixed — P0 bundled-asset loader + +- `switchbot policy new`, `switchbot policy validate`, and the MCP `policy_new` + tool no longer fail at runtime when installed from the packed tarball. Under + esbuild bundling, `import.meta.url` points at `dist/index.js` instead of the + original source file, so the three call-sites that loaded embedded assets via + `new URL('', import.meta.url)` resolved to non-existent paths + (`dist/schema/v0.2.json` instead of `dist/policy/schema/v0.2.json`, etc.). + Fix: a new top-level `src/embedded-assets.ts` module, positioned at the + source-tree counterpart of `dist/index.js`, now owns the two asset-loading + functions (`readPolicySchemaJson`, `readPolicyExampleYaml`). Because + `embedded-assets.ts` and the bundle entry sit at the same relative depth, + `./policy/schema/...` and `./policy/examples/...` resolve identically under + tsx (dev) and under the bundle (prod) — no runtime fallback needed. All + three call-sites (`src/policy/schema.ts`, `src/commands/policy.ts`, + `src/commands/mcp.ts`) now route through those two helpers. +- `scripts/smoke-pack-install.mjs` now exercises the loader paths end-to-end + against the installed tarball — in addition to the existing `--version` + check, it runs `switchbot policy new /policy.yaml` (asserts the template + was written) and `switchbot policy validate --json` (asserts the + schema loads and validates). The exact bug class that slipped through 3.2.2 + would now fail the smoke before publish. + +### Changed — UX polish + +- `switchbot catalog search ` now ranks hits in three tiers: exact + type / exact alias matches first, role and command-name matches next, + alias-substring-only matches last. Alias-only rows are explicitly labelled + `alias-only` in the `matched_on` column (renamed from `matched`). A new + `--strict` flag restricts hits to type-name matches only and prints a + "(strict mode — try without --strict)" hint when nothing matches. +- `switchbot status-sync start` now prints a multi-line hint when + `OPENCLAW_TOKEN` or `OPENCLAW_MODEL` is missing — it names the flag, the env + var, a short pointer to the admin-issued token, and the recommended verify + step (`switchbot status-sync status`). +- `switchbot devices batch ... --skip-offline --dry-run` now separates + "Planned (dry-run)" from "Skipped (offline)" in the human-readable output and + the summary line reports `planned=N, skipped_offline=M` alongside existing + totals. No `[dry-run] Would POST ...` line is emitted for offline-skipped + devices (JSON mode already separated these keys; no schema change). +- `switchbot devices watch --help` clarifies that the default output is a + human-readable table and that `--json` is the agent-friendly JSON-Lines form, + with the seed-tick (`"from": null`) note surfaced near the top. + ## [3.2.2] - 2026-04-26 ### Changed — release pipeline diff --git a/README.md b/README.md index 00a0002..9a131ea 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.2.2", + "current": "3.3.0", "latest": "4.0.0", "upToDate": false, "updateAvailable": true, diff --git a/package-lock.json b/package-lock.json index 08ebe3f..c7d5567 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "3.2.2", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "3.2.2", + "version": "3.3.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index d031954..77f76d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "3.2.2", + "version": "3.3.0", "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", diff --git a/scripts/smoke-pack-install.mjs b/scripts/smoke-pack-install.mjs index 5165d95..a7c7ed3 100644 --- a/scripts/smoke-pack-install.mjs +++ b/scripts/smoke-pack-install.mjs @@ -1,5 +1,5 @@ -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { execFileSync, spawn } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, statSync, existsSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -43,25 +43,174 @@ try { stdio: 'inherit', }); - const actualVersion = process.platform === 'win32' - ? execFileSync(path.join(workDir, 'node_modules', '.bin', 'switchbot.cmd'), ['--version'], { + const switchbotBin = process.platform === 'win32' + ? path.join(workDir, 'node_modules', '.bin', 'switchbot.cmd') + : path.join(workDir, 'node_modules', '.bin', 'switchbot'); + + function runBin(args) { + if (process.platform === 'win32') { + return execFileSync(switchbotBin, args, { cwd: workDir, encoding: 'utf-8', shell: true, - }).trim() - : execFileSync(path.join(workDir, 'node_modules', '.bin', 'switchbot'), ['--version'], { - cwd: workDir, - encoding: 'utf-8', - }).trim(); + }); + } + return execFileSync(switchbotBin, args, { + cwd: workDir, + encoding: 'utf-8', + }); + } + // 1. --version (existing check) + const actualVersion = runBin(['--version']).trim(); if (actualVersion !== expectedVersion) { throw new Error(`Packed CLI version mismatch: expected ${expectedVersion}, got ${actualVersion}`); } - console.log(`pack-install smoke ok: switchbot --version -> ${actualVersion}`); + + // 2. policy new — exercises readPolicyExampleYaml for the example template. + // If the bundle's embedded-asset resolver can't find the template, this fails + // with ENOENT before writing the file — which is exactly the 3.2.2 P0. + const policyPath = path.join(workDir, 'policy.yaml'); + runBin(['policy', 'new', policyPath]); + const policyStat = statSync(policyPath); + if (policyStat.size < 500) { + throw new Error(`policy new wrote ${policyStat.size} bytes to ${policyPath}; expected >= 500`); + } + console.log(`pack-install smoke ok: policy new -> ${policyPath} (${policyStat.size} bytes)`); + + // 3. policy validate --json — exercises readPolicySchemaJson for v0.2.json. + // This is the other loader site and would also be broken by a future drift + // in dist/ asset layout. + const validateOut = runBin(['policy', 'validate', policyPath, '--json']); + let parsed; + try { + parsed = JSON.parse(validateOut); + } catch (e) { + throw new Error(`policy validate --json did not return JSON: ${validateOut}`); + } + if (parsed?.data?.valid !== true) { + throw new Error(`policy validate reported not valid: ${JSON.stringify(parsed)}`); + } + console.log(`pack-install smoke ok: policy validate -> { valid: true }`); + + // 4. MCP policy_new — third call-site of the embedded-asset loader. + // Spawns `switchbot mcp serve` (stdio), runs the MCP initialize handshake, + // then calls tools/call for policy_new. Exercises the same readPolicyExampleYaml + // as (2), but through the full MCP SDK bundling + StdioServerTransport path — + // which would independently break if a future change drops @modelcontextprotocol/sdk + // from the tarball or breaks stdio bootstrap. + const mcpPolicyPath = path.join(workDir, 'policy.mcp.yaml'); + await runMcpPolicyNewSmoke({ workDir, mcpPolicyPath }); + const mcpStat = statSync(mcpPolicyPath); + if (mcpStat.size < 500) { + throw new Error(`mcp policy_new wrote ${mcpStat.size} bytes to ${mcpPolicyPath}; expected >= 500`); + } + console.log(`pack-install smoke ok: mcp policy_new -> ${mcpPolicyPath} (${mcpStat.size} bytes)`); } finally { if (tarballPath) { rmSync(tarballPath, { force: true }); } rmSync(workDir, { recursive: true, force: true }); } + +/** + * Drive the stdio MCP server end-to-end: + * 1. spawn switchbot mcp serve + * 2. send `initialize` (JSON-RPC) + * 3. send `notifications/initialized` + * 4. send `tools/call` for policy_new with an explicit target path + force=true + * 5. read the response, assert success + * 6. close stdin -> graceful shutdown + * + * JSON-RPC framing is one message per line over stdout (NDJSON). The server + * may also emit operational logs on stderr ("MQTT disabled: ..." etc.); + * those are not part of the protocol and are ignored here. + */ +async function runMcpPolicyNewSmoke({ workDir, mcpPolicyPath }) { + const switchbotBin = process.platform === 'win32' + ? path.join(workDir, 'node_modules', '.bin', 'switchbot.cmd') + : path.join(workDir, 'node_modules', '.bin', 'switchbot'); + + const child = spawn(switchbotBin, ['mcp', 'serve'], { + cwd: workDir, + stdio: ['pipe', 'pipe', 'pipe'], + shell: process.platform === 'win32', + }); + + const pending = new Map(); // id -> { resolve, reject } + let stdoutBuf = ''; + let stderrBuf = ''; + let exited = false; + let exitCode = null; + + child.stdout.setEncoding('utf-8'); + child.stdout.on('data', (chunk) => { + stdoutBuf += chunk; + let idx; + while ((idx = stdoutBuf.indexOf('\n')) !== -1) { + const line = stdoutBuf.slice(0, idx).trim(); + stdoutBuf = stdoutBuf.slice(idx + 1); + if (!line) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + if (msg.id != null && pending.has(msg.id)) { + const entry = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) entry.reject(new Error(`MCP error: ${JSON.stringify(msg.error)}`)); + else entry.resolve(msg.result); + } + } + }); + child.stderr.setEncoding('utf-8'); + child.stderr.on('data', (chunk) => { stderrBuf += chunk; }); + child.on('exit', (code) => { exited = true; exitCode = code; }); + + const send = (obj) => { + if (exited) throw new Error(`mcp server exited (code=${exitCode}) before send. stderr:\n${stderrBuf}`); + child.stdin.write(JSON.stringify(obj) + '\n'); + }; + const request = (method, params) => new Promise((resolve, reject) => { + const id = Math.floor(Math.random() * 1e9); + pending.set(id, { resolve, reject }); + const timer = setTimeout(() => { + if (pending.has(id)) { + pending.delete(id); + reject(new Error(`MCP request timed out: ${method}. stderr:\n${stderrBuf}`)); + } + }, 15_000); + timer.unref?.(); + send({ jsonrpc: '2.0', id, method, params }); + }); + const notify = (method, params) => send({ jsonrpc: '2.0', method, params }); + + try { + await request('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'pack-install-smoke', version: '0.0.0' }, + }); + notify('notifications/initialized', {}); + const result = await request('tools/call', { + name: 'policy_new', + arguments: { path: mcpPolicyPath, force: true }, + }); + if (!result || result.isError) { + throw new Error(`policy_new returned error: ${JSON.stringify(result)}`); + } + const structured = result.structuredContent; + if (!structured || typeof structured.bytesWritten !== 'number' || structured.bytesWritten <= 0) { + throw new Error(`policy_new returned unexpected result: ${JSON.stringify(result)}`); + } + if (!existsSync(mcpPolicyPath)) { + throw new Error(`policy_new reported success but ${mcpPolicyPath} does not exist`); + } + } finally { + try { child.stdin.end(); } catch { /* ignore */ } + await new Promise((resolve) => { + if (exited) return resolve(); + child.on('exit', resolve); + setTimeout(() => { try { child.kill(); } catch { /* ignore */ } resolve(); }, 5_000).unref?.(); + }); + } +} diff --git a/src/commands/batch.ts b/src/commands/batch.ts index dfe433d..400c355 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -513,9 +513,22 @@ Examples: if (isJsonMode()) { printJson(result); } else { - console.log( - `\nSummary: ${result.summary.ok} ok, ${result.summary.failed} failed, ${result.summary.skipped} skipped (${result.summary.durationMs}ms)` - ); + if (dryRunned.length > 0) { + console.log(`\nPlanned (dry-run): ${dryRunned.length} device(s)`); + for (const d of dryRunned) console.log(` - ${d.deviceId}`); + } + if (preSkipped.length > 0) { + console.log(`\nSkipped (offline): ${preSkipped.length} device(s)`); + for (const d of preSkipped) console.log(` - ${d.deviceId}`); + } + const parts: string[] = [ + `${result.summary.ok} ok`, + `${result.summary.failed} failed`, + ]; + if (dryRunned.length > 0) parts.push(`${dryRunned.length} planned`); + if (preSkipped.length > 0) parts.push(`${preSkipped.length} skipped_offline`); + parts.push(`(${result.summary.durationMs}ms)`); + console.log(`\nSummary: ${parts.join(', ')}`); } // Non-zero exit when anything failed so scripts can react. diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index 9e72b40..4e854a9 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -170,41 +170,79 @@ Examples: .command('search') .description('Fuzzy search the effective catalog by type name, alias, role, or command name') .argument('', 'Substring to match (case-insensitive) against type, alias, role, or command') - .action((keyword: string) => { + .option('--strict', 'Only return entries whose type name matches (skip alias/role/command fallbacks)') + .action((keyword: string, options: { strict?: boolean }) => { try { const q = keyword.toLowerCase(); const entries = getEffectiveCatalog(); - const hits = entries.filter((e) => { - if (e.type.toLowerCase().includes(q)) return true; - if ((e.role ?? '').toLowerCase().includes(q)) return true; - if ((e.aliases ?? []).some((a) => a.toLowerCase().includes(q))) return true; - if (e.commands.some((c) => c.command.toLowerCase().includes(q))) return true; - return false; - }); + const strict = options.strict === true; + + type Hit = { + entry: DeviceCatalogEntry; + tier: 0 | 1 | 2; + matched: string[]; + }; + + const hits: Hit[] = []; + for (const e of entries) { + const matched: string[] = []; + const typeHit = e.type.toLowerCase().includes(q); + const aliasExact = (e.aliases ?? []).some((a) => a.toLowerCase() === q); + const aliasSubstr = (e.aliases ?? []).some((a) => a.toLowerCase().includes(q) && a.toLowerCase() !== q); + const roleHit = (e.role ?? '').toLowerCase().includes(q); + const cmdMatches = e.commands + .filter((c) => c.command.toLowerCase().includes(q)) + .map((c) => c.command); + + if (typeHit) matched.push('type'); + if (aliasExact) matched.push('alias'); + else if (aliasSubstr) matched.push('alias-only'); + if (roleHit) matched.push('role'); + if (cmdMatches.length > 0) matched.push(`commands[${cmdMatches.join(',')}]`); + + if (strict) { + if (!typeHit) continue; + } else if (matched.length === 0) { + continue; + } + + // Tier 0: type match or exact alias match. + // Tier 1: role or command-name match. + // Tier 2: alias-substring only (catch-all fallback). + let tier: 0 | 1 | 2; + if (typeHit || aliasExact) tier = 0; + else if (roleHit || cmdMatches.length > 0) tier = 1; + else tier = 2; + + hits.push({ entry: e, tier, matched }); + } + + hits.sort((a, b) => a.tier - b.tier); + if (isJsonMode()) { - printJson({ query: keyword, matches: hits }); + printJson({ + query: keyword, + strict, + matches: hits.map((h) => ({ ...h.entry, _matchedOn: h.matched, _tier: h.tier })), + }); return; } if (hits.length === 0) { - console.log(`No catalog entries match "${keyword}".`); + const suffix = strict ? ' (strict mode — try without --strict)' : ''; + console.log(`No catalog entries match "${keyword}"${suffix}.`); return; } const fmt = resolveFormat(); - const headers = ['type', 'category', 'role', 'matched']; - const rows = hits.map((e) => { - const matched: string[] = []; - if (e.type.toLowerCase().includes(q)) matched.push('type'); - if ((e.aliases ?? []).some((a) => a.toLowerCase().includes(q))) matched.push('alias'); - if ((e.role ?? '').toLowerCase().includes(q)) matched.push('role'); - const cmdMatches = e.commands - .filter((c) => c.command.toLowerCase().includes(q)) - .map((c) => c.command); - if (cmdMatches.length > 0) matched.push(`commands[${cmdMatches.join(',')}]`); - return [e.type, e.category, e.role ?? '—', matched.join(', ') || '—']; - }); + const headers = ['type', 'category', 'role', 'matched_on']; + const rows = hits.map((h) => [ + h.entry.type, + h.entry.category, + h.entry.role ?? '—', + h.matched.join(', ') || '—', + ]); renderRows(headers, rows, fmt, resolveFields()); if (fmt === 'table') { - console.log(`\n${hits.length} match${hits.length === 1 ? '' : 'es'} for "${keyword}"`); + console.log(`\n${hits.length} match${hits.length === 1 ? '' : 'es'} for "${keyword}"${strict ? ' (strict)' : ''}`); } } catch (error) { handleError(error); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 66fe6bc..59d7bbc 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -69,10 +69,10 @@ import { diffPolicyValues } from '../policy/diff.js'; const LATEST_SUPPORTED_VERSION: PolicySchemaVersion = SUPPORTED_POLICY_SCHEMA_VERSIONS[SUPPORTED_POLICY_SCHEMA_VERSIONS.length - 1]; -import { fileURLToPath } from 'node:url'; import { dirname as pathDirname, join as pathJoin } from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; +import { readPolicyExampleYaml } from '../embedded-assets.js'; /** * Factory — build an McpServer with the six SwitchBot tools registered. @@ -1221,8 +1221,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, context: { policyPath }, }); } - const templateUrl = new URL('../policy/examples/policy.example.yaml', import.meta.url); - const template = fs.readFileSync(fileURLToPath(templateUrl), 'utf-8'); + const template = readPolicyExampleYaml(); fs.mkdirSync(pathDirname(policyPath), { recursive: true }); fs.writeFileSync(policyPath, template, { encoding: 'utf-8' }); const structured = { diff --git a/src/commands/policy.ts b/src/commands/policy.ts index 669a9c2..fd4b09d 100644 --- a/src/commands/policy.ts +++ b/src/commands/policy.ts @@ -1,9 +1,9 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, statSync } from 'node:fs'; import { dirname, resolve as resolvePath } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; import { parse as yamlParse } from 'yaml'; import { printJson, emitJsonError, isJsonMode, exitWithError } from '../utils/output.js'; +import { readPolicyExampleYaml } from '../embedded-assets.js'; import { loadPolicyFile, resolvePolicyPath, @@ -28,8 +28,7 @@ const LATEST_SUPPORTED_VERSION: PolicySchemaVersion = SUPPORTED_POLICY_SCHEMA_VERSIONS[SUPPORTED_POLICY_SCHEMA_VERSIONS.length - 1]; function readEmbeddedTemplate(): string { - const url = new URL('../policy/examples/policy.example.yaml', import.meta.url); - return readFileSync(fileURLToPath(url), 'utf-8'); + return readPolicyExampleYaml(); } export class PolicyFileExistsError extends Error { diff --git a/src/commands/watch.ts b/src/commands/watch.ts index cc4b338..dc112e9 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -72,7 +72,7 @@ function sleep(ms: number, signal: AbortSignal): Promise { export function registerWatchCommand(devices: Command): void { devices .command('watch') - .description('Poll device status on an interval and emit field-level changes (JSONL)') + .description('Poll device status on an interval and emit field-level changes (human table by default; JSONL with --json for agents)') .argument('[deviceId...]', 'One or more deviceIds to watch (or use --name for one device)') .option('--name ', 'Resolve one device by fuzzy name (combined with any positional IDs)', stringArg('--name')) .option( @@ -87,16 +87,22 @@ export function registerWatchCommand(devices: Command): void { .addHelpText( 'after', ` -Each poll emits one JSON line per deviceId with the shape: +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). + +Each --json line has the shape: { "t": "", "tick": , "deviceId": "ID", "type": "Bot", "changed": { "power": { "from": "off", "to": "on" } } } -The very first poll has "from": null for every field (seed). - Examples: $ switchbot devices watch ABC123 --interval 10s $ switchbot devices watch ABC123 --fields battery,power --interval 1m $ switchbot devices watch ABC123 DEF456 --interval 30s --max 10 + # Agent-friendly: one JSONL record per tick, pipeable to jq $ switchbot devices watch ABC123 --json | jq 'select(.changed.power)' $ switchbot devices watch --name "Living Room AC" --interval 10s `, diff --git a/src/embedded-assets.ts b/src/embedded-assets.ts new file mode 100644 index 0000000..295a11e --- /dev/null +++ b/src/embedded-assets.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** + * Loaders for assets copied by `scripts/copy-assets.mjs` into `dist/policy/`. + * + * This module is deliberately placed at the top of `src/` so that, under tsx, + * `import.meta.url` points at `/src/embedded-assets.ts` — the exact + * source-tree counterpart of `/dist/index.js` in the bundle. That means + * `./policy/schema/v0.2.json` resolves to `src/policy/schema/v0.2.json` in + * dev and `dist/policy/schema/v0.2.json` in prod without any fallback + * probing. + * + * All policy-asset loaders (`src/policy/schema.ts`, + * `src/commands/policy.ts::readEmbeddedTemplate`, + * `src/commands/mcp.ts` policy_new handler) MUST route through the exported + * functions here — there is intentionally no generic `readEmbeddedAsset` + * helper exposed to the rest of the codebase, because any caller at a + * different source-tree depth would re-introduce the bundle-vs-source path + * drift that required the pre-3.3.0 fallback. + */ +function readAsset(relPath: string): string { + const resolved = fileURLToPath(new URL(relPath, import.meta.url)); + return readFileSync(resolved, 'utf-8'); +} + +export function readPolicySchemaJson(version: string): string { + return readAsset(`./policy/schema/v${version}.json`); +} + +export function readPolicyExampleYaml(): string { + return readAsset(`./policy/examples/policy.example.yaml`); +} diff --git a/src/policy/schema.ts b/src/policy/schema.ts index 27ab55b..b02b6c8 100644 --- a/src/policy/schema.ts +++ b/src/policy/schema.ts @@ -1,5 +1,4 @@ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { readPolicySchemaJson } from '../embedded-assets.js'; export type PolicySchemaVersion = '0.2'; @@ -12,8 +11,7 @@ export function loadPolicySchema(version: PolicySchemaVersion = CURRENT_POLICY_S const cached = schemaCache.get(version); if (cached) return cached; - const url = new URL(`./schema/v${version}.json`, import.meta.url); - const raw = readFileSync(fileURLToPath(url), 'utf-8'); + const raw = readPolicySchemaJson(version); const parsed = JSON.parse(raw) as object; schemaCache.set(version, parsed); return parsed; diff --git a/src/status-sync/manager.ts b/src/status-sync/manager.ts index 4e819a6..bbcfec5 100644 --- a/src/status-sync/manager.ts +++ b/src/status-sync/manager.ts @@ -79,12 +79,30 @@ function resolveStatusSyncRuntime(options: { const openclawToken = options.openclawToken ?? process.env.OPENCLAW_TOKEN; if (!openclawToken) { - throw new UsageError('--openclaw-token is required or set OPENCLAW_TOKEN in the environment.'); + throw new UsageError( + [ + 'OpenClaw token missing. Provide one of:', + ' 1. --openclaw-token ', + ' 2. OPENCLAW_TOKEN= in the environment', + '', + 'The token is issued by your OpenClaw server admin (same token you use for `events mqtt-tail --sink openclaw`).', + 'After setting it, re-run the command and verify with `switchbot status-sync status`.', + ].join('\n'), + ); } const openclawModel = options.openclawModel ?? process.env.OPENCLAW_MODEL; if (!openclawModel) { - throw new UsageError('--openclaw-model is required or set OPENCLAW_MODEL in the environment.'); + throw new UsageError( + [ + 'OpenClaw model missing. Provide one of:', + ' 1. --openclaw-model ', + ' 2. OPENCLAW_MODEL= in the environment', + '', + 'The model name maps this CLI to a registered agent/device on the OpenClaw side.', + 'After setting it, re-run the command and verify with `switchbot status-sync status`.', + ].join('\n'), + ); } return { diff --git a/tests/build/dist-assets.test.ts b/tests/build/dist-assets.test.ts new file mode 100644 index 0000000..b993397 --- /dev/null +++ b/tests/build/dist-assets.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; + +/** + * Product assertion for `scripts/copy-assets.mjs`. + * + * The 3.3.0 embedded-asset loader (`src/embedded-assets.ts`) expects these + * exact files to land at these exact paths under `dist/`. If a future change + * to `copy-assets.mjs` drops or relocates one of them, the loader still + * builds fine but `policy new` / `policy validate` fails at first use — + * which is the 3.2.2 P0. These tests fail the build instead. + * + * `tests/build/embedded-assets-invariant.test.ts` covers the complementary + * constraint: that only one source file reads bundled assets. Together they + * pin both sides of the contract. + */ + +const REPO_ROOT = path.resolve(); +const DIST = path.join(REPO_ROOT, 'dist'); + +const REQUIRED_ASSETS = [ + path.join('policy', 'schema', 'v0.2.json'), + path.join('policy', 'examples', 'policy.example.yaml'), +]; + +describe('copy-assets populates dist/ with the files the loader reads', () => { + beforeAll(() => { + // Run the copy step directly (cheap) rather than a full build. The + // build-prod job has already run dist/ through full build, but we want + // this test to also pass in isolation (`vitest tests/build/...`). + execSync('node scripts/copy-assets.mjs', { stdio: 'pipe' }); + }, 15_000); + + for (const rel of REQUIRED_ASSETS) { + it(`dist/${rel.replace(/\\/g, '/')} exists and is non-empty`, () => { + const abs = path.join(DIST, rel); + expect( + fs.existsSync(abs), + `Missing asset: ${abs}\n` + + `scripts/copy-assets.mjs did not produce this file, but src/embedded-assets.ts\n` + + `expects it at this exact path in the shipped tarball.`, + ).toBe(true); + const { size } = fs.statSync(abs); + expect(size, `${abs} is empty (0 bytes)`).toBeGreaterThan(0); + }); + } + + it('JSON schema asset parses as JSON', () => { + const schemaPath = path.join(DIST, 'policy', 'schema', 'v0.2.json'); + const raw = fs.readFileSync(schemaPath, 'utf-8'); + expect(() => JSON.parse(raw)).not.toThrow(); + }); +}); diff --git a/tests/build/embedded-assets-invariant.test.ts b/tests/build/embedded-assets-invariant.test.ts new file mode 100644 index 0000000..cc09d3c --- /dev/null +++ b/tests/build/embedded-assets-invariant.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Structural invariant that protects the 3.3.0 bundled-asset fix. + * + * The embedded-asset loader works because `src/embedded-assets.ts` sits at + * the source-tree counterpart of `dist/index.js`, so + * `new URL('./policy/schema/...', import.meta.url)` resolves to the same + * relative layout under tsx and under the production bundle. If ANY other + * file in `src/` calls `new URL(..., import.meta.url)` to read a bundled + * asset, the path is being interpreted against a different depth and will + * drift between dev and prod — exactly the bug class that shipped in 3.2.2. + * + * This test walks `src/`, greps every `.ts` file for that pattern, and + * fails if the set of matches is anything other than exactly + * `src/embedded-assets.ts`. A deep-module contributor reintroducing the + * helper lights this test up immediately. + */ + +const SRC_DIR = path.resolve('src'); +const PATTERN = /new URL\([^)]*import\.meta\.url/; +const EXPECTED_SOLE_MATCH = path.join('src', 'embedded-assets.ts'); + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...walk(full)); + } else if (entry.isFile() && entry.name.endsWith('.ts')) { + out.push(full); + } + } + return out; +} + +describe('embedded-asset loader is the sole reader of bundled assets', () => { + it('exactly one file in src/ uses new URL(..., import.meta.url)', () => { + const hits: string[] = []; + for (const file of walk(SRC_DIR)) { + const content = fs.readFileSync(file, 'utf-8'); + if (PATTERN.test(content)) { + hits.push(path.relative(process.cwd(), file).replace(/\\/g, '/')); + } + } + const expected = EXPECTED_SOLE_MATCH.replace(/\\/g, '/'); + expect( + hits, + `embedded-asset invariant broken.\n` + + `Expected exactly: [${expected}]\n` + + `Actual hits: ${JSON.stringify(hits, null, 2)}\n\n` + + `Any 'new URL(..., import.meta.url)' outside src/embedded-assets.ts resolves\n` + + `against the wrong source-tree depth and will break in the production bundle.\n` + + `Route the caller through an exported loader in src/embedded-assets.ts instead.`, + ).toEqual([expected]); + }); +}); diff --git a/tests/commands/batch.test.ts b/tests/commands/batch.test.ts index 75997a3..a155371 100644 --- a/tests/commands/batch.test.ts +++ b/tests/commands/batch.test.ts @@ -565,6 +565,44 @@ describe('devices batch', () => { expect(parsed.data.skipped).toBeUndefined(); }); + it('--skip-offline --dry-run human output separates "Planned (dry-run)" from "Skipped (offline)"', async () => { + flagsMock.dryRun = true; + cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen', category: 'physical' }); + cacheMock.map.set('BOT2', { type: 'Bot', name: 'Office', category: 'physical' }); + cacheMock.statusMap.set('BOT2', { + fetchedAt: new Date().toISOString(), + body: { onlineStatus: 'offline', power: 'off' }, + }); + // In dry-run, the axios interceptor throws DryRunSignal before any real + // request goes out; simulate that here so the inner loop records the + // dry-run outcome for BOT1 (BOT2 is filtered out by --skip-offline). + apiMock.__instance.post.mockImplementation(async () => { + throw new apiMock.DryRunSignal('POST', '/v1.1/devices/BOT1/commands'); + }); + + const result = await runCli(registerDevicesCommand, [ + 'devices', + 'batch', + 'turnOn', + '--ids', + 'BOT1,BOT2', + '--skip-offline', + ]); + + expect(result.exitCode).toBeNull(); + // Only BOT1 reaches the post path; BOT2 was pre-filtered. + expect(apiMock.__instance.post).toHaveBeenCalledTimes(1); + const out = result.stdout.join('\n'); + expect(out).toContain('Planned (dry-run): 1 device(s)'); + expect(out).toContain('- BOT1'); + expect(out).toContain('Skipped (offline): 1 device(s)'); + expect(out).toContain('- BOT2'); + // BOT2 must never show up in a dry-run POST log. + expect(out).not.toMatch(/Would POST.*BOT2/); + // Summary line now includes both "planned" and "skipped_offline" counts. + expect(out).toMatch(/Summary:.*1 planned.*1 skipped_offline/); + }); + it('bug28: batch over IR devices attaches subKind + verification and sets summary.unverifiableCount', async () => { cacheMock.map.set('IR1', { type: 'Air Conditioner', name: 'Living Room AC', category: 'ir' }); cacheMock.map.set('IR2', { type: 'Air Conditioner', name: 'Bedroom AC', category: 'ir' }); diff --git a/tests/commands/catalog.test.ts b/tests/commands/catalog.test.ts index 05c35e6..36d2fe5 100644 --- a/tests/commands/catalog.test.ts +++ b/tests/commands/catalog.test.ts @@ -233,3 +233,73 @@ describe('catalog refresh', () => { expect(parsed.data.refreshed).toBe(true); }); }); + +describe('catalog search', () => { + it('returns hits sorted by match-tier (type > role/command > alias-substring)', async () => { + // "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[] }>; + expect(matches.length).toBeGreaterThan(0); + // First hit must be the exact type (tier 0). + expect(matches[0].type).toBe('Bot'); + expect(matches[0]._tier).toBe(0); + expect(matches[0]._matchedOn).toContain('type'); + // Tiers are monotonically non-decreasing. + for (let i = 1; i < matches.length; i++) { + expect(matches[i]._tier).toBeGreaterThanOrEqual(matches[i - 1]._tier); + } + }); + + 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[] }>; + // 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); + // And that entry is NOT labelled 'alias' (the exact-alias tag). + for (const m of aliasOnly) { + expect(m._matchedOn).not.toContain('alias'); + } + }); + + it('--strict restricts hits to type-name matches only', async () => { + const { stdout } = await runCli(registerCatalogCommand, [ + '--json', + '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); + expect(matches.length).toBeGreaterThan(0); + for (const m of matches) { + expect(m._matchedOn).toContain('type'); + expect(m._tier).toBe(0); + } + }); + + it('--strict with no type match prints a helpful suffix', async () => { + const { stdout } = await runCli(registerCatalogCommand, [ + 'catalog', + 'search', + 'zzzzznonexistentzzz', + '--strict', + ]); + const out = stdout.join('\n'); + expect(out).toContain('No catalog entries match'); + expect(out).toContain('strict mode'); + }); + + it('table output labels the column matched_on', async () => { + const { stdout } = await runCli(registerCatalogCommand, ['catalog', 'search', 'Bot']); + const out = stdout.join('\n'); + expect(out).toContain('matched_on'); + // Legacy column header `matched` without the `_on` suffix must be gone. + expect(out).not.toMatch(/\bmatched\b(?!_)/); + }); +}); diff --git a/tests/commands/watch.test.ts b/tests/commands/watch.test.ts index 2bc2e43..0300f73 100644 --- a/tests/commands/watch.test.ts +++ b/tests/commands/watch.test.ts @@ -346,4 +346,18 @@ describe('devices watch', () => { const jsonLines = res.stdout.filter((l) => l.trim().startsWith('{')); expect(jsonLines.length).toBe(0); }); + + it('--help clarifies default format and calls out --json as agent-friendly', async () => { + const res = await runCli(registerDevicesCommand, ['devices', 'watch', '--help']); + const out = [...res.stdout, ...res.stderr].join('\n'); + // Help clarifies default (human table) and the agent form (JSONL with --json). + 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/); + // Example block explicitly labels the --json form as agent-friendly. + expect(out).toMatch(/agent-friendly/i); + }); }); diff --git a/tests/status-sync/manager.test.ts b/tests/status-sync/manager.test.ts index e957bec..6a7baf5 100644 --- a/tests/status-sync/manager.test.ts +++ b/tests/status-sync/manager.test.ts @@ -199,6 +199,22 @@ describe('status-sync manager', () => { expect(paths.stdoutLog).toMatch(/override[\\/]status-sync[\\/]stdout\.log$/); expect(paths.stderrLog).toMatch(/override[\\/]status-sync[\\/]stderr\.log$/); }); + + it('missing OPENCLAW_TOKEN error names both the flag and the env var and suggests a verify step', () => { + delete process.env.OPENCLAW_TOKEN; + process.env.OPENCLAW_MODEL = 'env-model'; + expect(() => startStatusSync({ stateDir: '/tmp/status-sync' })).toThrow( + /OpenClaw token missing[\s\S]*--openclaw-token[\s\S]*OPENCLAW_TOKEN[\s\S]*status-sync status/, + ); + }); + + it('missing OPENCLAW_MODEL error names both the flag and the env var and suggests a verify step', () => { + process.env.OPENCLAW_TOKEN = 'env-token'; + delete process.env.OPENCLAW_MODEL; + expect(() => startStatusSync({ stateDir: '/tmp/status-sync' })).toThrow( + /OpenClaw model missing[\s\S]*--openclaw-model[\s\S]*OPENCLAW_MODEL[\s\S]*status-sync status/, + ); + }); }); function pathFromArgv(): string {