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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ tmp/
smoke-v3/
switchbot-skill/
docs/superpowers/
docs/smoke-*
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ switchbot doctor
switchbot doctor --json
```

Runs local checks (Node version, credentials, profiles, catalog, catalog-schema, cache, quota, clock, MQTT, policy, MCP, keychain, path, inventory, audit, daemon, health, notify-connectivity, release-notes) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). The `notify-connectivity` check probes webhook URLs declared in `type: notify` actions. Use this to diagnose connectivity or config issues before running automation.
Runs local checks (Node version, credentials, profiles, catalog, catalog-schema, catalog-coverage, cache, quota, clock, MQTT, policy, MCP, keychain, path, inventory, audit, daemon, health, notify-connectivity, release-notes) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). The `notify-connectivity` check probes webhook URLs declared in `type: notify` actions. Use this to diagnose connectivity or config issues before running automation.

`--json` output includes `maturityScore` (0–100) and `maturityLabel` (`production-ready` / `mostly-ready` / `needs-work` / `not-ready`) to give an at-a-glance readiness rating:

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"hooks:install": "node scripts/install-git-hooks.mjs",
"lint:md": "markdownlint \"**/*.md\"",
"lint:md:changelog": "markdownlint CHANGELOG.md",
"lint:stdout": "bash scripts/lint-stdout.sh",
"prepare": "node scripts/install-git-hooks.mjs",
"start": "node dist/index.js",
"smoke:pack-install": "node scripts/smoke-pack-install.mjs",
Expand All @@ -51,6 +52,7 @@
"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",
"verify:release": "node scripts/verify-release.mjs",
"prepublishOnly": "npm test && npm run build && npm run smoke:pack-install"
},
"dependencies": {
Expand Down
51 changes: 51 additions & 0 deletions scripts/lint-stdout.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# scripts/lint-stdout.sh
# Prevents diagnostic/debug messages from leaking into stdout.
# Legitimate table-mode output (human-readable results) is allowed.
# This catches the class of bugs where dry-run, debug, or warning text
# goes to console.log instead of console.error.
set -euo pipefail

cd "$(dirname "$0")/.."

errors=0

# 1. No dry-run messages on stdout (must use console.error)
hits=$(grep -rn 'console\.log.*dry.run\|console\.log.*◦' src/commands/ 2>/dev/null | grep -v '// stdout-ok' || true)
if [ -n "$hits" ]; then
echo "ERROR: dry-run messages must use console.error, not console.log:"
echo "$hits"
errors=$((errors + 1))
fi

# 2. No bare Number() in param-validator validate* functions (must use parseStrictInt)
# Excludes: parseStrictInt itself, build* functions (pre-validated), Number.isX checks
bare_number=$(grep -n 'Number(' src/devices/param-validator.ts | grep -v 'parseStrictInt\|Number.isInteger\|Number.isNaN\|Number.isFinite\|// number-ok\|function parseStrictInt' || true)
if [ -n "$bare_number" ]; then
echo "WARNING: bare Number() in param-validator.ts — consider using parseStrictInt():"
echo "$bare_number"
echo "(add '// number-ok' comment to suppress if pre-validated)"
echo ""
fi

# 3. Every registerXxxCommand in src/commands/ must have a test file
missing_tests=""
for cmd in $(grep -roh 'export function register\w\+Command' src/commands/ | sed 's/export function //' | sort -u); do
if ! grep -rl "$cmd" tests/commands/ >/dev/null 2>&1; then
missing_tests="$missing_tests $cmd\n"
fi
done
if [ -n "$missing_tests" ]; then
echo "WARNING: commands without test coverage:"
printf "$missing_tests"
echo "(not blocking — add tests before next release)"
echo ""
fi

if [ "$errors" -gt 0 ]; then
echo ""
echo "FAILED: $errors check(s) failed"
exit 1
fi

echo "OK: all stdout/quality checks passed"
113 changes: 113 additions & 0 deletions scripts/verify-release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env node
/**
* scripts/verify-release.mjs
* Pre-release verification gate — checks that documented counts and versions
* match the actual codebase. Exits non-zero if any discrepancy is found.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');

function readFile(rel) {
return fs.readFileSync(path.join(ROOT, rel), 'utf-8');
}

function countMatches(content, regex) {
return (content.match(regex) || []).length;
}

let errors = 0;
let warnings = 0;

function fail(msg) {
console.error(` ✗ ${msg}`);
errors++;
}

function warn(msg) {
console.error(` ! ${msg}`);
warnings++;
}

function pass(msg) {
console.error(` ✓ ${msg}`);
}

console.error('verify-release: checking pre-release invariants\n');

// 1. MCP tool count
const mcpSrc = readFile('src/commands/mcp.ts');
const mcpToolCount = countMatches(mcpSrc, /server\.registerTool\(/g);
const agentGuide = readFile('docs/agent-guide.md');
const agentGuideMatch = agentGuide.match(/Available tools \((\d+)\)/);
const agentGuideCount = agentGuideMatch ? Number(agentGuideMatch[1]) : null;

if (agentGuideCount === null) {
fail('docs/agent-guide.md: could not find "Available tools (N)" heading');
} else if (mcpToolCount !== agentGuideCount) {
fail(`MCP tool count mismatch: code has ${mcpToolCount}, docs/agent-guide.md says ${agentGuideCount}`);
} else {
pass(`MCP tools: ${mcpToolCount} (code = docs/agent-guide.md)`);
}

// 2. Doctor check count
const doctorSrc = readFile('src/commands/doctor.ts');
const doctorChecks = new Set(doctorSrc.match(/name: '[^']+'/g) || []);
const doctorCount = doctorChecks.size;
const readme = readFile('README.md');
const readmeDoctorMatch = readme.match(/(\d+)\s*(?:health|doctor|diagnostic)\s*check/i);
if (readmeDoctorMatch) {
const readmeDoctorCount = Number(readmeDoctorMatch[1]);
if (doctorCount !== readmeDoctorCount) {
fail(`Doctor check count mismatch: code has ${doctorCount}, README says ${readmeDoctorCount}`);
} else {
pass(`Doctor checks: ${doctorCount} (code = README)`);
}
} else {
warn(`README.md: could not find doctor check count pattern — manual verification needed (code has ${doctorCount})`);
}

// 3. Audit version
const auditSrc = readFile('src/utils/audit.ts');
const auditVersionMatch = auditSrc.match(/AUDIT_VERSION\s*=\s*(\d+)/);
const auditVersion = auditVersionMatch ? Number(auditVersionMatch[1]) : null;
const auditDoc = readFile('docs/audit-log.md');
const auditDocMatch = auditDoc.match(/Current:\s*`(\d+)`/);
const auditDocVersion = auditDocMatch ? Number(auditDocMatch[1]) : null;

if (auditVersion === null) {
fail('src/utils/audit.ts: could not find AUDIT_VERSION constant');
} else if (auditDocVersion === null) {
fail('docs/audit-log.md: could not find "Current: `N`" pattern');
} else if (auditVersion !== auditDocVersion) {
fail(`Audit version mismatch: code has ${auditVersion}, docs/audit-log.md says ${auditDocVersion}`);
} else {
pass(`Audit version: ${auditVersion} (code = docs/audit-log.md)`);
}

// 4. package.json version vs tag (informational)
const pkg = JSON.parse(readFile('package.json'));
pass(`package.json version: ${pkg.version}`);

// 5. Test count (informational — just report, don't fail on mismatch since test count changes frequently)
const readmeTestMatch = readme.match(/(\d{3,})\s*tests/);
if (readmeTestMatch) {
const readmeTestCount = Number(readmeTestMatch[1]);
warn(`README says ${readmeTestCount} tests — run \`npm test\` and update if stale`);
} else {
warn('README.md: could not find test count pattern');
}

// Summary
console.error('');
if (errors > 0) {
console.error(`FAILED: ${errors} error(s), ${warnings} warning(s)`);
process.exit(1);
} else if (warnings > 0) {
console.error(`PASSED with ${warnings} warning(s)`);
} else {
console.error('PASSED: all checks green');
}
4 changes: 2 additions & 2 deletions src/commands/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,8 +511,8 @@ Examples:
printJson(result);
} else {
if (dryRunned.length > 0) {
console.log(`\nPlanned (dry-run): ${dryRunned.length} device(s)`);
for (const d of dryRunned) console.log(` - ${d.deviceId}`);
console.error(`\nPlanned (dry-run): ${dryRunned.length} device(s)`);
for (const d of dryRunned) console.error(` - ${d.deviceId}`);
}
if (preSkipped.length > 0) {
console.log(`\nSkipped (offline): ${preSkipped.length} device(s)`);
Expand Down
1 change: 1 addition & 0 deletions src/commands/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ Examples:
.option('--strict', 'Only return entries whose type name matches (skip alias/role/command fallbacks)')
.action((keyword: string, options: { strict?: boolean }) => {
try {
if (!keyword.trim()) throw new UsageError('catalog search requires a non-empty keyword.');
const q = keyword.toLowerCase();
const entries = getEffectiveCatalog();
const strict = options.strict === true;
Expand Down
31 changes: 21 additions & 10 deletions src/commands/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ Examples:
devices
.command('status')
.description('Query the real-time status of a specific device')
.argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)')
.argument('[deviceId...]', 'Device ID(s) from "devices list" (or use --name or --ids)')
.option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
.option('--name-strategy <s>', `Name match strategy: ${ALL_STRATEGIES.join('|')} (default: fuzzy)`, stringArg('--name-strategy'))
.option('--name-type <type>', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type'))
Expand All @@ -314,6 +314,7 @@ all field names returned by your specific device, then narrow with --fields.

Examples:
$ switchbot devices status ABC123DEF456
$ switchbot devices status ABC123 DEF456 GHI789
$ switchbot devices status --name "Living Room AC"
$ switchbot devices status ABC123DEF456 --json
$ switchbot devices status ABC123DEF456 --format yaml
Expand All @@ -322,13 +323,16 @@ Examples:
$ switchbot devices status --ids ABC123,DEF456,GHI789
$ switchbot devices status --ids ABC123,DEF456 --fields power,battery
`)
.action(async (deviceIdArg: string | undefined, options: { name?: string; nameStrategy?: string; nameType?: string; nameCategory?: 'physical' | 'ir'; nameRoom?: string; ids?: string }) => {
.action(async (deviceIdArgs: string[], options: { name?: string; nameStrategy?: string; nameType?: string; nameCategory?: 'physical' | 'ir'; nameRoom?: string; ids?: string }) => {
try {
// Batch mode: --ids id1,id2,id3
if (options.ids) {
// Batch mode: --ids id1,id2,id3 OR multiple positional args
const batchIds = options.ids
? options.ids.split(',').map((s) => s.trim()).filter(Boolean)
: deviceIdArgs.length > 1 ? deviceIdArgs : undefined;
if (batchIds) {
if (options.name) throw new UsageError('--ids and --name cannot be used together.');
const ids = options.ids.split(',').map((s) => s.trim()).filter(Boolean);
if (ids.length === 0) throw new UsageError('--ids requires at least one device ID.');
if (batchIds.length === 0) throw new UsageError('--ids requires at least one device ID.');
const ids = batchIds;
const results = await Promise.allSettled(ids.map((id) => fetchDeviceStatus(id)));
const fetchedAt = new Date().toISOString();
const batch = results.map((r, i) =>
Expand Down Expand Up @@ -366,7 +370,7 @@ Examples:
return;
}

const deviceId = resolveDeviceId(deviceIdArg, options.name, {
const deviceId = resolveDeviceId(deviceIdArgs[0], options.name, {
strategy: (options.nameStrategy as NameResolveStrategy | undefined) ?? 'fuzzy',
type: options.nameType,
category: options.nameCategory,
Expand Down Expand Up @@ -702,7 +706,7 @@ Examples:
if (isJsonMode()) {
printJson({ dryRun: true, wouldSend });
} else {
console.log(`◦ dry-run intercepted for ${_cmd} on ${_deviceId}; see stderr preview for the HTTP request.`);
console.error(`◦ dry-run intercepted for ${_cmd} on ${_deviceId}; see stderr preview for the HTTP request.`);
}
return;
}
Expand Down Expand Up @@ -1004,13 +1008,20 @@ function renderCatalogEntry(entry: DeviceCatalogEntry): void {
console.log(`Type: ${entry.type}`);
console.log(`Category: ${entry.category === 'ir' ? 'IR remote' : 'Physical device'}`);
if (entry.role) console.log(`Role: ${entry.role}`);
if (entry.readOnly) console.log(`ReadOnly: yes (status-only device, no control commands)`);
const hasStatusFields = (entry.statusFields?.length ?? 0) > 0;
if (entry.readOnly) {
console.log(hasStatusFields
? `ReadOnly: yes (status-only device, no control commands)`
: `ReadOnly: yes (no cloud control commands cataloged)`);
}
if (entry.aliases && entry.aliases.length > 0) {
console.log(`Aliases: ${entry.aliases.join(', ')}`);
}

if (entry.commands.length === 0) {
console.log('\nCommands: (none — status-only device)');
console.log(hasStatusFields
? '\nCommands: (none — status-only device)'
: '\nCommands: (none — no cloud control commands cataloged)');
} else {
console.log('\nCommands:');
const hasExamples = entry.commands.some((c) => c.exampleParams && c.exampleParams.length > 0);
Expand Down
26 changes: 26 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,26 @@ function checkCatalog(): Check {
};
}

function checkCatalogCoverage(): Check {
const cache = loadCache();
if (!cache) {
return { name: 'catalog-coverage', status: 'ok', detail: 'no device cache — run "switchbot devices list" first' };
}
const catalog = getEffectiveCatalog();
const catalogTypes = new Set(catalog.map((e) => e.type.toLowerCase()));
const aliases = new Set(catalog.flatMap((e) => (e.aliases ?? []).map((a) => a.toLowerCase())));
const deviceTypes = [...new Set(Object.values(cache.devices).map((d) => d.type))];
const missing = deviceTypes.filter((t) => !catalogTypes.has(t.toLowerCase()) && !aliases.has(t.toLowerCase()));
if (missing.length === 0) {
return { name: 'catalog-coverage', status: 'ok', detail: `all ${deviceTypes.length} device types have catalog entries` };
}
return {
name: 'catalog-coverage',
status: 'warn',
detail: { missing, message: `${missing.length} device type(s) without catalog entry` },
};
}

function checkCache(): Check {
try {
const info = describeCache();
Expand Down Expand Up @@ -402,6 +422,7 @@ function checkInventoryConsistency(): Check {
status: 'warn',
detail: {
message: `${dangling.length} device(s) reference a hubDeviceId that is not present in the current inventory`,
hint: 'This usually means the hub was removed or replaced. Re-pair affected devices in the SwitchBot app, or ignore if the devices still work.',
dangling: dangling.slice(0, 10),
},
};
Expand Down Expand Up @@ -976,6 +997,7 @@ const CHECK_REGISTRY: CheckDef[] = [
{ name: 'keychain', description: 'OS keychain backend availability and usage', run: () => checkKeychain() },
{ name: 'profiles', description: 'profile definitions valid', run: () => checkProfiles() },
{ name: 'catalog', description: 'catalog loads', run: () => checkCatalog() },
{ name: 'catalog-coverage', description: 'all cached device types have catalog entries', run: () => checkCatalogCoverage() },
{ 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() },
Expand Down Expand Up @@ -1051,6 +1073,7 @@ interface DoctorCliOptions {
fix?: boolean;
yes?: boolean;
probe?: boolean;
quiet?: boolean;
}

export function registerDoctorCommand(program: Command): void {
Expand All @@ -1062,6 +1085,7 @@ export function registerDoctorCommand(program: Command): void {
.option('--fix', 'Apply safe, reversible remediations for failing checks (e.g. clear stale cache)')
.option('--yes', 'Required together with --fix to confirm write actions')
.option('--probe', 'Perform live-probe variant of checks that support it (mqtt)')
.option('-q, --quiet', 'Only show warn/fail checks, hide passing checks')
.addHelpText('after', `
Runs a battery of local sanity checks and exits with code 0 only when every
check is 'ok'. 'warn' → exit 0 (informational); 'fail' → exit 1.
Expand Down Expand Up @@ -1159,7 +1183,9 @@ Examples:
if (fixes !== undefined) payload.fixes = fixes;
printJson(payload);
} else {
const quiet = Boolean(opts.quiet);
for (const c of checks) {
if (quiet && c.status === 'ok') continue;
const icon = c.status === 'ok' ? '✓' : c.status === 'warn' ? '!' : '✗';
const detailStr =
typeof c.detail === 'string'
Expand Down
2 changes: 1 addition & 1 deletion src/commands/expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ Examples:
if (isJsonMode()) {
printJson({ ok: true, dryRun: true, command, deviceId });
} else {
console.log(`◦ dry-run: ${command} would be sent to ${deviceId}`);
console.error(`◦ dry-run: ${command} would be sent to ${deviceId}`);
}
return;
}
Expand Down
Loading
Loading