@switchbot/openapi-cli 2.7.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +481 -103
- package/dist/api/client.js +23 -1
- package/dist/commands/agent-bootstrap.js +47 -2
- package/dist/commands/auth.js +354 -0
- package/dist/commands/batch.js +20 -4
- package/dist/commands/capabilities.js +155 -65
- package/dist/commands/config.js +109 -0
- package/dist/commands/daemon.js +367 -0
- package/dist/commands/devices.js +62 -11
- package/dist/commands/doctor.js +417 -8
- package/dist/commands/events.js +3 -3
- package/dist/commands/explain.js +1 -2
- package/dist/commands/health.js +113 -0
- package/dist/commands/install.js +246 -0
- package/dist/commands/mcp.js +888 -7
- package/dist/commands/plan.js +379 -103
- package/dist/commands/policy.js +586 -0
- package/dist/commands/rules.js +875 -0
- package/dist/commands/scenes.js +140 -0
- package/dist/commands/schema.js +0 -2
- package/dist/commands/status-sync.js +131 -0
- package/dist/commands/uninstall.js +237 -0
- package/dist/commands/upgrade-check.js +88 -0
- package/dist/config.js +14 -0
- package/dist/credentials/backends/file.js +101 -0
- package/dist/credentials/backends/linux.js +129 -0
- package/dist/credentials/backends/macos.js +129 -0
- package/dist/credentials/backends/windows.js +215 -0
- package/dist/credentials/keychain.js +88 -0
- package/dist/credentials/prime.js +52 -0
- package/dist/devices/catalog.js +4 -10
- package/dist/index.js +30 -1
- package/dist/install/default-steps.js +257 -0
- package/dist/install/preflight.js +212 -0
- package/dist/install/steps.js +67 -0
- package/dist/lib/command-keywords.js +17 -0
- package/dist/lib/daemon-state.js +46 -0
- package/dist/lib/destructive-mode.js +12 -0
- package/dist/lib/devices.js +1 -1
- package/dist/lib/plan-store.js +68 -0
- package/dist/policy/add-rule.js +124 -0
- package/dist/policy/diff.js +91 -0
- package/dist/policy/examples/policy.example.yaml +99 -0
- package/dist/policy/format.js +57 -0
- package/dist/policy/load.js +61 -0
- package/dist/policy/migrate.js +67 -0
- package/dist/policy/schema/v0.2.json +331 -0
- package/dist/policy/schema.js +18 -0
- package/dist/policy/validate.js +262 -0
- package/dist/rules/action.js +205 -0
- package/dist/rules/audit-query.js +89 -0
- package/dist/rules/conflict-analyzer.js +203 -0
- package/dist/rules/cron-scheduler.js +186 -0
- package/dist/rules/destructive.js +52 -0
- package/dist/rules/engine.js +757 -0
- package/dist/rules/matcher.js +230 -0
- package/dist/rules/pid-file.js +95 -0
- package/dist/rules/quiet-hours.js +45 -0
- package/dist/rules/suggest.js +95 -0
- package/dist/rules/throttle.js +116 -0
- package/dist/rules/types.js +34 -0
- package/dist/rules/webhook-listener.js +223 -0
- package/dist/rules/webhook-token.js +90 -0
- package/dist/status-sync/manager.js +268 -0
- package/dist/utils/audit.js +12 -2
- package/dist/utils/health.js +101 -0
- package/dist/utils/output.js +72 -23
- package/dist/utils/retry.js +81 -0
- package/package.json +12 -4
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const COMMAND_KEYWORDS = [
|
|
2
|
+
{ pattern: /\boff\b|\bturn.?off\b|\bstop\b/i, command: 'turnOff' },
|
|
3
|
+
{ pattern: /\bon\b|\bturn.?on\b|\bstart\b/i, command: 'turnOn' },
|
|
4
|
+
{ pattern: /\bpress\b|\bclick\b|\btap\b/i, command: 'press' },
|
|
5
|
+
{ pattern: /\block\b/i, command: 'lock' },
|
|
6
|
+
{ pattern: /\bunlock\b/i, command: 'unlock' },
|
|
7
|
+
{ pattern: /\bopen\b|\braise\b|\bup\b/i, command: 'open' },
|
|
8
|
+
{ pattern: /\bclose\b|\blower\b|\bdown\b/i, command: 'close' },
|
|
9
|
+
{ pattern: /\bpause\b/i, command: 'pause' },
|
|
10
|
+
];
|
|
11
|
+
export function inferCommandFromIntent(intent) {
|
|
12
|
+
for (const k of COMMAND_KEYWORDS) {
|
|
13
|
+
if (k.pattern.test(intent))
|
|
14
|
+
return k.command;
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
function getStateDir() {
|
|
5
|
+
return path.join(os.homedir(), '.switchbot');
|
|
6
|
+
}
|
|
7
|
+
function getDaemonPidFile() {
|
|
8
|
+
return path.join(getStateDir(), 'daemon.pid');
|
|
9
|
+
}
|
|
10
|
+
function getDaemonLogFile() {
|
|
11
|
+
return path.join(getStateDir(), 'daemon.log');
|
|
12
|
+
}
|
|
13
|
+
function getDaemonStateFile() {
|
|
14
|
+
return path.join(getStateDir(), 'daemon.state.json');
|
|
15
|
+
}
|
|
16
|
+
function getHealthzPidFile() {
|
|
17
|
+
return path.join(getStateDir(), 'healthz.pid');
|
|
18
|
+
}
|
|
19
|
+
export const DAEMON_PID_FILE = getDaemonPidFile();
|
|
20
|
+
export const DAEMON_LOG_FILE = getDaemonLogFile();
|
|
21
|
+
export const DAEMON_STATE_FILE = getDaemonStateFile();
|
|
22
|
+
export const HEALTHZ_PID_FILE = getHealthzPidFile();
|
|
23
|
+
function ensureStateDir() {
|
|
24
|
+
fs.mkdirSync(getStateDir(), { recursive: true, mode: 0o700 });
|
|
25
|
+
}
|
|
26
|
+
export function writeDaemonState(state) {
|
|
27
|
+
ensureStateDir();
|
|
28
|
+
fs.writeFileSync(getDaemonStateFile(), JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
29
|
+
}
|
|
30
|
+
export function readDaemonState() {
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(getDaemonStateFile(), 'utf-8');
|
|
33
|
+
return JSON.parse(raw);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function removeDaemonState() {
|
|
40
|
+
try {
|
|
41
|
+
fs.unlinkSync(getDaemonStateFile());
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// best effort
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { getActiveProfile } from './request-context.js';
|
|
2
|
+
const DIRECT_DESTRUCTIVE_PROFILES = new Set(['dev', 'development']);
|
|
3
|
+
export function allowsDirectDestructiveExecution(profile = getActiveProfile()) {
|
|
4
|
+
if (process.env.SWITCHBOT_ALLOW_DIRECT_DESTRUCTIVE === '1')
|
|
5
|
+
return true;
|
|
6
|
+
if (!profile)
|
|
7
|
+
return false;
|
|
8
|
+
return DIRECT_DESTRUCTIVE_PROFILES.has(profile.toLowerCase());
|
|
9
|
+
}
|
|
10
|
+
export function destructiveExecutionHint() {
|
|
11
|
+
return "Use 'switchbot plan save <file>' -> 'switchbot plan review <planId>' -> 'switchbot plan approve <planId>' -> 'switchbot plan execute <planId>' instead.";
|
|
12
|
+
}
|
package/dist/lib/devices.js
CHANGED
|
@@ -101,6 +101,7 @@ export async function executeCommand(deviceId, cmd, parameter, commandType, clie
|
|
|
101
101
|
parameter,
|
|
102
102
|
commandType,
|
|
103
103
|
dryRun: isDryRun(),
|
|
104
|
+
...(options?.planId ? { planId: options.planId } : {}),
|
|
104
105
|
};
|
|
105
106
|
// Wrap in idempotency cache if key is provided
|
|
106
107
|
const execute = async () => {
|
|
@@ -272,7 +273,6 @@ export async function describeDevice(deviceId, options = {}, client) {
|
|
|
272
273
|
return {
|
|
273
274
|
...c,
|
|
274
275
|
safetyTier: tier,
|
|
275
|
-
destructive: tier === 'destructive',
|
|
276
276
|
...(reason ? { safetyReason: reason } : {}),
|
|
277
277
|
};
|
|
278
278
|
}),
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
export const PLANS_DIR = path.join(os.homedir(), '.switchbot', 'plans');
|
|
6
|
+
function ensurePlansDir() {
|
|
7
|
+
fs.mkdirSync(PLANS_DIR, { recursive: true, mode: 0o700 });
|
|
8
|
+
}
|
|
9
|
+
function planPath(planId) {
|
|
10
|
+
return path.join(PLANS_DIR, `${planId}.json`);
|
|
11
|
+
}
|
|
12
|
+
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
13
|
+
function assertValidPlanId(planId) {
|
|
14
|
+
if (!UUID_V4_RE.test(planId)) {
|
|
15
|
+
throw new Error(`invalid planId: ${planId}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function savePlanRecord(plan) {
|
|
19
|
+
ensurePlansDir();
|
|
20
|
+
const record = {
|
|
21
|
+
planId: randomUUID(),
|
|
22
|
+
createdAt: new Date().toISOString(),
|
|
23
|
+
status: 'pending',
|
|
24
|
+
plan,
|
|
25
|
+
};
|
|
26
|
+
fs.writeFileSync(planPath(record.planId), JSON.stringify(record, null, 2), { mode: 0o600 });
|
|
27
|
+
return record;
|
|
28
|
+
}
|
|
29
|
+
export function loadPlanRecord(planId) {
|
|
30
|
+
assertValidPlanId(planId);
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(planPath(planId), 'utf-8');
|
|
33
|
+
return JSON.parse(raw);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function updatePlanRecord(planId, updates) {
|
|
40
|
+
assertValidPlanId(planId);
|
|
41
|
+
const record = loadPlanRecord(planId);
|
|
42
|
+
if (!record)
|
|
43
|
+
throw new Error(`Plan ${planId} not found in ${PLANS_DIR}`);
|
|
44
|
+
const updated = { ...record, ...updates };
|
|
45
|
+
fs.writeFileSync(planPath(planId), JSON.stringify(updated, null, 2), { mode: 0o600 });
|
|
46
|
+
return updated;
|
|
47
|
+
}
|
|
48
|
+
export function listPlanRecords() {
|
|
49
|
+
try {
|
|
50
|
+
if (!fs.existsSync(PLANS_DIR))
|
|
51
|
+
return [];
|
|
52
|
+
return fs
|
|
53
|
+
.readdirSync(PLANS_DIR)
|
|
54
|
+
.filter((f) => f.endsWith('.json'))
|
|
55
|
+
.flatMap((f) => {
|
|
56
|
+
try {
|
|
57
|
+
return [JSON.parse(fs.readFileSync(path.join(PLANS_DIR, f), 'utf-8'))];
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { parseDocument, isMap, isSeq, isScalar, LineCounter } from 'yaml';
|
|
2
|
+
import { parse as yamlParse } from 'yaml';
|
|
3
|
+
import { loadPolicyFile } from './load.js';
|
|
4
|
+
import { validateLoadedPolicy } from './validate.js';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
export class AddRuleError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(message, code) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.name = 'AddRuleError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function buildDiff(before, after) {
|
|
15
|
+
const beforeLines = before.split('\n');
|
|
16
|
+
const afterLines = after.split('\n');
|
|
17
|
+
const lines = ['--- before', '+++ after'];
|
|
18
|
+
let i = 0;
|
|
19
|
+
let j = 0;
|
|
20
|
+
while (i < beforeLines.length || j < afterLines.length) {
|
|
21
|
+
const b = beforeLines[i];
|
|
22
|
+
const a = afterLines[j];
|
|
23
|
+
if (i < beforeLines.length && j < afterLines.length && b === a) {
|
|
24
|
+
lines.push(` ${b}`);
|
|
25
|
+
i++;
|
|
26
|
+
j++;
|
|
27
|
+
}
|
|
28
|
+
else if (j < afterLines.length && (i >= beforeLines.length || b !== a)) {
|
|
29
|
+
lines.push(`+${a}`);
|
|
30
|
+
j++;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
lines.push(`-${b}`);
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return lines.join('\n');
|
|
38
|
+
}
|
|
39
|
+
function isNullNode(node) {
|
|
40
|
+
return isScalar(node) && node.value === null;
|
|
41
|
+
}
|
|
42
|
+
export function addRuleToPolicySource(opts) {
|
|
43
|
+
const loaded = loadPolicyFile(opts.policyPath);
|
|
44
|
+
const beforeSource = loaded.source;
|
|
45
|
+
// Parse the incoming rule
|
|
46
|
+
let ruleObj;
|
|
47
|
+
try {
|
|
48
|
+
ruleObj = yamlParse(opts.ruleYaml);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
throw new AddRuleError(`Could not parse rule YAML: ${err.message}`, 'invalid-rule-yaml');
|
|
52
|
+
}
|
|
53
|
+
if (!ruleObj || typeof ruleObj !== 'object' || Array.isArray(ruleObj)) {
|
|
54
|
+
throw new AddRuleError('Rule YAML must be a single mapping object', 'invalid-rule-shape');
|
|
55
|
+
}
|
|
56
|
+
const ruleName = ruleObj['name'];
|
|
57
|
+
if (typeof ruleName !== 'string' || !ruleName) {
|
|
58
|
+
throw new AddRuleError('Rule must have a non-empty "name" field', 'missing-rule-name');
|
|
59
|
+
}
|
|
60
|
+
// Clone the document using source round-trip (preserves comments)
|
|
61
|
+
const clone = parseDocument(beforeSource, { keepSourceTokens: true });
|
|
62
|
+
if (!isMap(clone.contents)) {
|
|
63
|
+
throw new AddRuleError('Policy root must be a YAML mapping', 'invalid-policy-shape');
|
|
64
|
+
}
|
|
65
|
+
// Ensure automation block exists
|
|
66
|
+
let automationNode = clone.contents.get('automation', true);
|
|
67
|
+
if (!automationNode || isNullNode(automationNode)) {
|
|
68
|
+
clone.setIn(['automation'], clone.createNode({ enabled: false, rules: [] }));
|
|
69
|
+
automationNode = clone.contents.get('automation', true);
|
|
70
|
+
}
|
|
71
|
+
// Ensure automation.rules exists and is a sequence
|
|
72
|
+
const rulesNode = clone.getIn(['automation', 'rules'], true);
|
|
73
|
+
if (!rulesNode || isNullNode(rulesNode)) {
|
|
74
|
+
clone.setIn(['automation', 'rules'], clone.createNode([]));
|
|
75
|
+
}
|
|
76
|
+
else if (!isSeq(rulesNode)) {
|
|
77
|
+
throw new AddRuleError('automation.rules exists but is not a sequence; cannot append', 'invalid-rules-shape');
|
|
78
|
+
}
|
|
79
|
+
// Duplicate name check — use JS conversion for simplicity
|
|
80
|
+
const policyJs = clone.toJS({ maxAliasCount: 100 });
|
|
81
|
+
const existingRulesJs = policyJs['automation']?.['rules'];
|
|
82
|
+
const existingRulesArr = Array.isArray(existingRulesJs) ? existingRulesJs : [];
|
|
83
|
+
const duplicateIdx = existingRulesArr.findIndex((r) => r?.['name'] === ruleName);
|
|
84
|
+
if (duplicateIdx !== -1 && !opts.force) {
|
|
85
|
+
throw new AddRuleError(`Rule named "${ruleName}" already exists. Use --force to overwrite.`, 'duplicate-rule-name');
|
|
86
|
+
}
|
|
87
|
+
if (duplicateIdx !== -1 && opts.force) {
|
|
88
|
+
const rulesSeq = clone.getIn(['automation', 'rules'], true);
|
|
89
|
+
rulesSeq.items.splice(duplicateIdx, 1);
|
|
90
|
+
}
|
|
91
|
+
// Enable automation if requested
|
|
92
|
+
if (opts.enableAutomation) {
|
|
93
|
+
clone.setIn(['automation', 'enabled'], true);
|
|
94
|
+
}
|
|
95
|
+
// Append the rule
|
|
96
|
+
const ruleNode = clone.createNode(ruleObj);
|
|
97
|
+
const rulesSeq = clone.getIn(['automation', 'rules'], true);
|
|
98
|
+
rulesSeq.items.push(ruleNode);
|
|
99
|
+
const nextSource = String(clone);
|
|
100
|
+
// Validate the resulting policy
|
|
101
|
+
const reLC = new LineCounter();
|
|
102
|
+
const reDoc = parseDocument(nextSource, { lineCounter: reLC, keepSourceTokens: true });
|
|
103
|
+
const validation = validateLoadedPolicy({
|
|
104
|
+
path: opts.policyPath,
|
|
105
|
+
source: nextSource,
|
|
106
|
+
doc: reDoc,
|
|
107
|
+
lineCounter: reLC,
|
|
108
|
+
data: reDoc.toJS({ maxAliasCount: 100 }),
|
|
109
|
+
});
|
|
110
|
+
if (!validation.valid) {
|
|
111
|
+
const msgs = validation.errors.map((e) => ` line ${e.line}: ${e.message}`).join('\n');
|
|
112
|
+
throw new AddRuleError(`Policy would be invalid after adding the rule:\n${msgs}`, 'validation-failed');
|
|
113
|
+
}
|
|
114
|
+
const diff = buildDiff(beforeSource, nextSource);
|
|
115
|
+
return { ruleName, diff, nextSource };
|
|
116
|
+
}
|
|
117
|
+
export function addRuleToPolicyFile(opts) {
|
|
118
|
+
const result = addRuleToPolicySource(opts);
|
|
119
|
+
if (!opts.dryRun) {
|
|
120
|
+
fs.writeFileSync(opts.policyPath, result.nextSource, 'utf8');
|
|
121
|
+
return { ...result, written: true };
|
|
122
|
+
}
|
|
123
|
+
return { ...result, written: false };
|
|
124
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export const MAX_POLICY_DIFF_CHANGES = 200;
|
|
2
|
+
function isPlainObject(v) {
|
|
3
|
+
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
4
|
+
}
|
|
5
|
+
function collectPolicyDiff(left, right, at, out, limit) {
|
|
6
|
+
if (out.length >= limit)
|
|
7
|
+
return;
|
|
8
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
9
|
+
const maxLen = Math.max(left.length, right.length);
|
|
10
|
+
for (let i = 0; i < maxLen; i++) {
|
|
11
|
+
if (out.length >= limit)
|
|
12
|
+
return;
|
|
13
|
+
const path = `${at}[${i}]`;
|
|
14
|
+
if (i >= left.length) {
|
|
15
|
+
out.push({ path, kind: 'added', after: right[i] });
|
|
16
|
+
}
|
|
17
|
+
else if (i >= right.length) {
|
|
18
|
+
out.push({ path, kind: 'removed', before: left[i] });
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
collectPolicyDiff(left[i], right[i], path, out, limit);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (isPlainObject(left) && isPlainObject(right)) {
|
|
27
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
28
|
+
for (const key of [...keys].sort()) {
|
|
29
|
+
if (out.length >= limit)
|
|
30
|
+
return;
|
|
31
|
+
const path = at === '$' ? `$.${key}` : `${at}.${key}`;
|
|
32
|
+
const leftHas = Object.prototype.hasOwnProperty.call(left, key);
|
|
33
|
+
const rightHas = Object.prototype.hasOwnProperty.call(right, key);
|
|
34
|
+
if (!leftHas && rightHas) {
|
|
35
|
+
out.push({ path, kind: 'added', after: right[key] });
|
|
36
|
+
}
|
|
37
|
+
else if (leftHas && !rightHas) {
|
|
38
|
+
out.push({ path, kind: 'removed', before: left[key] });
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
collectPolicyDiff(left[key], right[key], path, out, limit);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!Object.is(left, right)) {
|
|
47
|
+
out.push({ path: at, kind: 'changed', before: left, after: right });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function buildLineDiff(before, after) {
|
|
51
|
+
const beforeLines = before.split('\n');
|
|
52
|
+
const afterLines = after.split('\n');
|
|
53
|
+
const lines = ['--- before', '+++ after'];
|
|
54
|
+
let i = 0;
|
|
55
|
+
let j = 0;
|
|
56
|
+
while (i < beforeLines.length || j < afterLines.length) {
|
|
57
|
+
const b = beforeLines[i];
|
|
58
|
+
const a = afterLines[j];
|
|
59
|
+
if (i < beforeLines.length && j < afterLines.length && b === a) {
|
|
60
|
+
lines.push(` ${b}`);
|
|
61
|
+
i++;
|
|
62
|
+
j++;
|
|
63
|
+
}
|
|
64
|
+
else if (j < afterLines.length && (i >= beforeLines.length || b !== a)) {
|
|
65
|
+
lines.push(`+${a}`);
|
|
66
|
+
j++;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
lines.push(`-${b}`);
|
|
70
|
+
i++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
export function diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource, maxChanges = MAX_POLICY_DIFF_CHANGES) {
|
|
76
|
+
const changes = [];
|
|
77
|
+
collectPolicyDiff(leftDoc, rightDoc, '$', changes, maxChanges);
|
|
78
|
+
const equal = changes.length === 0;
|
|
79
|
+
return {
|
|
80
|
+
equal,
|
|
81
|
+
changeCount: changes.length,
|
|
82
|
+
truncated: changes.length >= maxChanges,
|
|
83
|
+
stats: {
|
|
84
|
+
added: changes.filter((c) => c.kind === 'added').length,
|
|
85
|
+
removed: changes.filter((c) => c.kind === 'removed').length,
|
|
86
|
+
changed: changes.filter((c) => c.kind === 'changed').length,
|
|
87
|
+
},
|
|
88
|
+
changes,
|
|
89
|
+
diff: buildLineDiff(leftSource, rightSource),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# SwitchBot policy — example
|
|
3
|
+
# ============================================================================
|
|
4
|
+
# Copy this file to your user config directory and edit it:
|
|
5
|
+
#
|
|
6
|
+
# mkdir -p ~/.switchbot
|
|
7
|
+
# cp policy.example.yaml ~/.switchbot/policy.yaml
|
|
8
|
+
#
|
|
9
|
+
# Every section is OPTIONAL. If a field isn't set, the CLI/agent layer falls back to
|
|
10
|
+
# a safe default (documented next to each field).
|
|
11
|
+
#
|
|
12
|
+
# Agents read this file before every session. They never write to it
|
|
13
|
+
# without showing you the diff and asking first.
|
|
14
|
+
# ============================================================================
|
|
15
|
+
|
|
16
|
+
# Schema version. Do not remove this line — the skill uses it to detect
|
|
17
|
+
# breaking changes and migrate your file when a newer schema ships.
|
|
18
|
+
version: "0.2"
|
|
19
|
+
|
|
20
|
+
# ----------------------------------------------------------------------------
|
|
21
|
+
# aliases — friendly names the agent can resolve to real devices
|
|
22
|
+
# ----------------------------------------------------------------------------
|
|
23
|
+
# The #1 reason to have a policy file. Without aliases, the agent has to
|
|
24
|
+
# guess which device you mean when you say "the bedroom light", and it can
|
|
25
|
+
# guess wrong if two devices have similar names.
|
|
26
|
+
#
|
|
27
|
+
# Get each deviceId from:
|
|
28
|
+
# switchbot devices list --format=tsv
|
|
29
|
+
#
|
|
30
|
+
# The format is: "what the user says": "<deviceId>"
|
|
31
|
+
# Quote the key if it contains spaces or non-ASCII characters.
|
|
32
|
+
aliases:
|
|
33
|
+
# "living room light": "01-202407090924-26354212"
|
|
34
|
+
# "bedroom AC": "02-202502111234-85411230"
|
|
35
|
+
# "front door lock": "03-202501201700-99887766"
|
|
36
|
+
# "kitchen plug": "04-202503081500-55443322"
|
|
37
|
+
|
|
38
|
+
# ----------------------------------------------------------------------------
|
|
39
|
+
# confirmations — which actions require explicit user approval
|
|
40
|
+
# ----------------------------------------------------------------------------
|
|
41
|
+
# The skill already refuses destructive actions (locks, deletions) by
|
|
42
|
+
# default. Use this section to adjust the defaults for your account.
|
|
43
|
+
#
|
|
44
|
+
# always_confirm: extra actions that need confirmation even though they
|
|
45
|
+
# wouldn't by default (e.g. you never want the agent to
|
|
46
|
+
# turn on the AC without asking).
|
|
47
|
+
# never_confirm: actions that normally confirm but you trust (NEVER add
|
|
48
|
+
# destructive actions here — the skill will reject that).
|
|
49
|
+
confirmations:
|
|
50
|
+
always_confirm:
|
|
51
|
+
# - "setTargetTemperature"
|
|
52
|
+
# - "setThermostatMode"
|
|
53
|
+
|
|
54
|
+
never_confirm:
|
|
55
|
+
# - "turnOn"
|
|
56
|
+
# - "turnOff"
|
|
57
|
+
|
|
58
|
+
# ----------------------------------------------------------------------------
|
|
59
|
+
# quiet_hours — during these hours, every mutation requires confirmation
|
|
60
|
+
# ----------------------------------------------------------------------------
|
|
61
|
+
# Times are 24-hour, local system time. If omitted, no quiet hours apply.
|
|
62
|
+
quiet_hours:
|
|
63
|
+
# start: "22:00"
|
|
64
|
+
# end: "08:00"
|
|
65
|
+
|
|
66
|
+
# ----------------------------------------------------------------------------
|
|
67
|
+
# audit — where to log every action the agent takes
|
|
68
|
+
# ----------------------------------------------------------------------------
|
|
69
|
+
# The skill ALWAYS logs mutations and destructive actions. This section
|
|
70
|
+
# controls where the log goes and how long it's kept.
|
|
71
|
+
audit:
|
|
72
|
+
# Path for the audit log. "~" is expanded. JSON Lines format.
|
|
73
|
+
log_path: "~/.switchbot/audit.log"
|
|
74
|
+
|
|
75
|
+
# How long to keep log lines. "never" disables rotation. Accepts units:
|
|
76
|
+
# d (days), w (weeks), m (months). Default: "90d".
|
|
77
|
+
retention: "90d"
|
|
78
|
+
|
|
79
|
+
# ----------------------------------------------------------------------------
|
|
80
|
+
# automation — Phase 4 (rule engine). Leave `enabled: false` for now.
|
|
81
|
+
# ----------------------------------------------------------------------------
|
|
82
|
+
# The rule engine ships in Phase 4. This section is reserved so the schema
|
|
83
|
+
# validates today; if you set `enabled: true` before Phase 4 lands, the
|
|
84
|
+
# skill will warn you and ignore it.
|
|
85
|
+
automation:
|
|
86
|
+
enabled: false
|
|
87
|
+
# rules: []
|
|
88
|
+
|
|
89
|
+
# ----------------------------------------------------------------------------
|
|
90
|
+
# cli — optional CLI-level overrides
|
|
91
|
+
# ----------------------------------------------------------------------------
|
|
92
|
+
cli:
|
|
93
|
+
# Which profile to use if you have multiple SwitchBot accounts. The CLI
|
|
94
|
+
# supports `switchbot --profile <name>`. Default: "default".
|
|
95
|
+
profile: "default"
|
|
96
|
+
|
|
97
|
+
# Device cache TTL. The skill refreshes the cache when it's older than
|
|
98
|
+
# this. Defaults to the CLI's own default (typically 5 minutes).
|
|
99
|
+
# cache_ttl: "5m"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import chalk, { Chalk } from 'chalk';
|
|
2
|
+
const noColorChalk = new Chalk({ level: 0 });
|
|
3
|
+
function colorize(enabled) {
|
|
4
|
+
return enabled ? chalk : noColorChalk;
|
|
5
|
+
}
|
|
6
|
+
function snippet(source, line, col, length, c) {
|
|
7
|
+
const lines = source.split(/\r?\n/);
|
|
8
|
+
if (line < 1 || line > lines.length)
|
|
9
|
+
return '';
|
|
10
|
+
const lineText = lines[line - 1];
|
|
11
|
+
const gutter = ` ${line} | `;
|
|
12
|
+
const pad = ' '.repeat(gutter.length);
|
|
13
|
+
const caretStart = Math.max(0, col - 1);
|
|
14
|
+
const caretLen = Math.max(1, length);
|
|
15
|
+
const caret = `${' '.repeat(caretStart)}${c.red('^'.repeat(caretLen))}`;
|
|
16
|
+
return `${c.dim(gutter)}${lineText}\n${c.dim(pad)}${caret}`;
|
|
17
|
+
}
|
|
18
|
+
function estimateTokenLength(source, line, col) {
|
|
19
|
+
const lines = source.split(/\r?\n/);
|
|
20
|
+
if (line < 1 || line > lines.length)
|
|
21
|
+
return 1;
|
|
22
|
+
const lineText = lines[line - 1];
|
|
23
|
+
const start = Math.max(0, col - 1);
|
|
24
|
+
if (start >= lineText.length)
|
|
25
|
+
return 1;
|
|
26
|
+
const rest = lineText.slice(start);
|
|
27
|
+
const quoted = rest.match(/^(['"]).*?\1/);
|
|
28
|
+
if (quoted)
|
|
29
|
+
return quoted[0].length;
|
|
30
|
+
const token = rest.match(/^[^\s,\[\]{}]+/);
|
|
31
|
+
return token ? token[0].length : 1;
|
|
32
|
+
}
|
|
33
|
+
function formatError(err, policyPath, source, opts) {
|
|
34
|
+
const c = colorize(opts.color ?? true);
|
|
35
|
+
const loc = err.line !== undefined && err.col !== undefined ? `${err.line}:${err.col}` : '(unknown)';
|
|
36
|
+
const header = `${c.cyan(policyPath)}:${c.yellow(loc)}`;
|
|
37
|
+
const body = [`${c.red.bold('error')}: ${err.message}`];
|
|
38
|
+
if (err.line !== undefined && err.col !== undefined && !opts.noSnippet) {
|
|
39
|
+
const len = estimateTokenLength(source, err.line, err.col);
|
|
40
|
+
const snip = snippet(source, err.line, err.col, len, c);
|
|
41
|
+
if (snip)
|
|
42
|
+
body.unshift(snip);
|
|
43
|
+
}
|
|
44
|
+
if (err.hint)
|
|
45
|
+
body.push(`${c.green.bold('hint')}: ${err.hint}`);
|
|
46
|
+
return [header, ...body].join('\n');
|
|
47
|
+
}
|
|
48
|
+
export function formatValidationResult(result, source, opts = {}) {
|
|
49
|
+
const c = colorize(opts.color ?? true);
|
|
50
|
+
if (result.valid) {
|
|
51
|
+
return `${c.green.bold('✓')} ${result.policyPath} is valid (schema v${result.schemaVersion})`;
|
|
52
|
+
}
|
|
53
|
+
const blocks = result.errors.map((e) => formatError(e, result.policyPath, source, opts));
|
|
54
|
+
const count = result.errors.length;
|
|
55
|
+
const footer = `${c.red.bold(`✗ ${count} ${count === 1 ? 'error' : 'errors'}`)} in ${result.policyPath} (schema v${result.schemaVersion})`;
|
|
56
|
+
return [...blocks, '', footer].join('\n\n').replace(/\n{3,}/g, '\n\n');
|
|
57
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { parseDocument, LineCounter } from 'yaml';
|
|
5
|
+
export const DEFAULT_POLICY_PATH = join(homedir(), '.config', 'openclaw', 'switchbot', 'policy.yaml');
|
|
6
|
+
export function resolvePolicyPath(options = {}) {
|
|
7
|
+
const { flag, env = process.env } = options;
|
|
8
|
+
if (flag && flag.trim().length > 0)
|
|
9
|
+
return resolve(flag);
|
|
10
|
+
const fromEnv = env.SWITCHBOT_POLICY_PATH;
|
|
11
|
+
if (fromEnv && fromEnv.trim().length > 0)
|
|
12
|
+
return resolve(fromEnv);
|
|
13
|
+
return DEFAULT_POLICY_PATH;
|
|
14
|
+
}
|
|
15
|
+
export class PolicyFileNotFoundError extends Error {
|
|
16
|
+
policyPath;
|
|
17
|
+
constructor(policyPath) {
|
|
18
|
+
super(`policy file not found: ${policyPath}`);
|
|
19
|
+
this.policyPath = policyPath;
|
|
20
|
+
this.name = 'PolicyFileNotFoundError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class PolicyYamlParseError extends Error {
|
|
24
|
+
policyPath;
|
|
25
|
+
yamlErrors;
|
|
26
|
+
constructor(message, policyPath, yamlErrors) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.policyPath = policyPath;
|
|
29
|
+
this.yamlErrors = yamlErrors;
|
|
30
|
+
this.name = 'PolicyYamlParseError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function loadPolicyFile(policyPath) {
|
|
34
|
+
let source;
|
|
35
|
+
try {
|
|
36
|
+
source = readFileSync(policyPath, 'utf-8');
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
const e = err;
|
|
40
|
+
if (e.code === 'ENOENT')
|
|
41
|
+
throw new PolicyFileNotFoundError(policyPath);
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
const lineCounter = new LineCounter();
|
|
45
|
+
const doc = parseDocument(source, { lineCounter, keepSourceTokens: true });
|
|
46
|
+
if (doc.errors.length > 0) {
|
|
47
|
+
const yamlErrors = doc.errors.map((e) => {
|
|
48
|
+
const pos = e.pos?.[0];
|
|
49
|
+
const loc = pos !== undefined ? lineCounter.linePos(pos) : undefined;
|
|
50
|
+
return { line: loc?.line, col: loc?.col, message: e.message };
|
|
51
|
+
});
|
|
52
|
+
throw new PolicyYamlParseError(doc.errors[0].message, policyPath, yamlErrors);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
path: policyPath,
|
|
56
|
+
source,
|
|
57
|
+
doc,
|
|
58
|
+
lineCounter,
|
|
59
|
+
data: doc.toJS({ maxAliasCount: 100 }),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { isMap, isScalar, parseDocument, LineCounter } from 'yaml';
|
|
2
|
+
import { validateLoadedPolicy } from './validate.js';
|
|
3
|
+
export class PolicyMigrationError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(message, code) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = 'PolicyMigrationError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const MIGRATION_CHAIN = [];
|
|
12
|
+
function bumpVersionScalar(doc, target) {
|
|
13
|
+
if (!isMap(doc.contents)) {
|
|
14
|
+
throw new PolicyMigrationError('policy root must be a YAML mapping (got null or an array)', 'invalid-shape');
|
|
15
|
+
}
|
|
16
|
+
const pair = doc.contents.items.find((p) => isScalar(p.key) && p.key.value === 'version');
|
|
17
|
+
if (!pair || !isScalar(pair.value)) {
|
|
18
|
+
throw new PolicyMigrationError('policy has no `version` scalar to migrate; add `version: "0.2"` (or `"0.1"`) and retry', 'no-version-field');
|
|
19
|
+
}
|
|
20
|
+
pair.value.value = target;
|
|
21
|
+
}
|
|
22
|
+
function findPlan(from, to) {
|
|
23
|
+
const chain = [];
|
|
24
|
+
let cur = from;
|
|
25
|
+
while (cur !== to) {
|
|
26
|
+
const step = MIGRATION_CHAIN.find((p) => p.fromVersion === cur);
|
|
27
|
+
if (!step) {
|
|
28
|
+
throw new PolicyMigrationError(`no migration path from v${from} to v${to} (missing step at v${cur})`, 'no-path');
|
|
29
|
+
}
|
|
30
|
+
chain.push(step);
|
|
31
|
+
cur = step.toVersion;
|
|
32
|
+
}
|
|
33
|
+
return chain;
|
|
34
|
+
}
|
|
35
|
+
export function planMigration(loaded, from, to) {
|
|
36
|
+
if (from === to) {
|
|
37
|
+
const precheck = validateLoadedPolicy(loaded);
|
|
38
|
+
return { changed: false, fromVersion: from, toVersion: to, nextSource: loaded.source, precheck };
|
|
39
|
+
}
|
|
40
|
+
const plan = findPlan(from, to);
|
|
41
|
+
// Round-trip through source instead of Document.clone(): keeps comments +
|
|
42
|
+
// anchors intact, works across yaml library versions, and leaves the
|
|
43
|
+
// caller's `loaded.doc` untouched.
|
|
44
|
+
const nextLineCounter = new LineCounter();
|
|
45
|
+
const clone = parseDocument(loaded.source, {
|
|
46
|
+
lineCounter: nextLineCounter,
|
|
47
|
+
keepSourceTokens: true,
|
|
48
|
+
});
|
|
49
|
+
for (const step of plan)
|
|
50
|
+
step.migrate(clone);
|
|
51
|
+
const nextSource = String(clone);
|
|
52
|
+
// Re-parse after serialization so `doc` and `source` stay in sync for the
|
|
53
|
+
// validator's line/col mapping.
|
|
54
|
+
const reLineCounter = new LineCounter();
|
|
55
|
+
const reDoc = parseDocument(nextSource, {
|
|
56
|
+
lineCounter: reLineCounter,
|
|
57
|
+
keepSourceTokens: true,
|
|
58
|
+
});
|
|
59
|
+
const precheck = validateLoadedPolicy({
|
|
60
|
+
path: loaded.path,
|
|
61
|
+
source: nextSource,
|
|
62
|
+
doc: reDoc,
|
|
63
|
+
lineCounter: reLineCounter,
|
|
64
|
+
data: reDoc.toJS({ maxAliasCount: 100 }),
|
|
65
|
+
});
|
|
66
|
+
return { changed: true, fromVersion: from, toVersion: to, nextSource, precheck };
|
|
67
|
+
}
|