Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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('<relative>', 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 <tmp>/policy.yaml` (asserts the template
was written) and `switchbot policy validate <path> --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 <keyword>` 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -894,7 +894,7 @@ Queries the npm registry for the latest published version and compares it agains

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

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@switchbot/openapi-cli",
"version": "3.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",
Expand Down
169 changes: 159 additions & 10 deletions scripts/smoke-pack-install.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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?.();
});
}
}
19 changes: 16 additions & 3 deletions src/commands/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading