@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,331 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://schemas.openclaw.ai/switchbot/v0.2/policy.json",
|
|
4
|
+
"title": "OpenClaw SwitchBot policy v0.2",
|
|
5
|
+
"description": "Tightens the `automation.rules[]` shape that v0.1 left as a loose `array of object`. Validator reads this when the policy file's top-level `version` field is \"0.2\". See docs/design/phase4-rules-schema.md for the field-level rationale.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["version"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"version": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"const": "0.2",
|
|
13
|
+
"description": "Policy schema version. Will migrate 0.1 -> 0.2 in place via `switchbot policy migrate`."
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
"aliases": {
|
|
17
|
+
"type": ["object", "null"],
|
|
18
|
+
"description": "Unchanged from v0.1.",
|
|
19
|
+
"additionalProperties": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"pattern": "^[A-Z0-9]{2,}-[A-Z0-9-]+$"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
"confirmations": {
|
|
26
|
+
"type": ["object", "null"],
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"description": "Unchanged from v0.1.",
|
|
29
|
+
"properties": {
|
|
30
|
+
"always_confirm": {
|
|
31
|
+
"type": ["array", "null"],
|
|
32
|
+
"uniqueItems": true,
|
|
33
|
+
"items": { "type": "string", "minLength": 1 }
|
|
34
|
+
},
|
|
35
|
+
"never_confirm": {
|
|
36
|
+
"type": ["array", "null"],
|
|
37
|
+
"uniqueItems": true,
|
|
38
|
+
"items": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"minLength": 1,
|
|
41
|
+
"not": {
|
|
42
|
+
"enum": ["lock", "unlock", "deleteWebhook", "deleteScene", "factoryReset"]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
"quiet_hours": {
|
|
50
|
+
"type": ["object", "null"],
|
|
51
|
+
"additionalProperties": false,
|
|
52
|
+
"description": "Unchanged from v0.1.",
|
|
53
|
+
"properties": {
|
|
54
|
+
"start": { "type": "string", "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" },
|
|
55
|
+
"end": { "type": "string", "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" }
|
|
56
|
+
},
|
|
57
|
+
"dependentRequired": { "start": ["end"], "end": ["start"] }
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
"audit": {
|
|
61
|
+
"type": ["object", "null"],
|
|
62
|
+
"additionalProperties": false,
|
|
63
|
+
"properties": {
|
|
64
|
+
"log_path": { "type": "string", "minLength": 1 },
|
|
65
|
+
"retention": { "type": "string", "pattern": "^(never|\\d+[dwm])$" }
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
"automation": {
|
|
70
|
+
"type": ["object", "null"],
|
|
71
|
+
"description": "In v0.2, `rules[]` gets a real shape. `enabled: false` still fully disables the engine regardless of rules defined.",
|
|
72
|
+
"additionalProperties": false,
|
|
73
|
+
"properties": {
|
|
74
|
+
"enabled": {
|
|
75
|
+
"type": "boolean",
|
|
76
|
+
"default": false
|
|
77
|
+
},
|
|
78
|
+
"rules": {
|
|
79
|
+
"type": ["array", "null"],
|
|
80
|
+
"items": { "$ref": "#/$defs/rule" }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
"cli": {
|
|
86
|
+
"type": ["object", "null"],
|
|
87
|
+
"additionalProperties": false,
|
|
88
|
+
"description": "Unchanged from v0.1.",
|
|
89
|
+
"properties": {
|
|
90
|
+
"profile": { "type": "string", "minLength": 1, "default": "default" },
|
|
91
|
+
"cache_ttl": { "type": "string", "pattern": "^\\d+[smh]$" }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
"$defs": {
|
|
97
|
+
"rule": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"additionalProperties": false,
|
|
100
|
+
"required": ["name", "when", "then"],
|
|
101
|
+
"properties": {
|
|
102
|
+
"name": {
|
|
103
|
+
"type": "string",
|
|
104
|
+
"minLength": 1,
|
|
105
|
+
"description": "Human label used in audit log and dry-run output. Unique per policy file."
|
|
106
|
+
},
|
|
107
|
+
"enabled": {
|
|
108
|
+
"type": "boolean",
|
|
109
|
+
"default": true,
|
|
110
|
+
"description": "Lets you disable a single rule without deleting it."
|
|
111
|
+
},
|
|
112
|
+
"when": { "$ref": "#/$defs/trigger" },
|
|
113
|
+
"conditions": {
|
|
114
|
+
"type": ["array", "null"],
|
|
115
|
+
"description": "Optional AND-joined gates evaluated after the trigger matches. All must pass for the rule to fire.",
|
|
116
|
+
"items": { "$ref": "#/$defs/condition" }
|
|
117
|
+
},
|
|
118
|
+
"then": {
|
|
119
|
+
"type": "array",
|
|
120
|
+
"minItems": 1,
|
|
121
|
+
"description": "One or more actions executed in order. If any action fails, the remainder still runs (policy log records each result).",
|
|
122
|
+
"items": { "$ref": "#/$defs/action" }
|
|
123
|
+
},
|
|
124
|
+
"throttle": {
|
|
125
|
+
"type": ["object", "null"],
|
|
126
|
+
"additionalProperties": false,
|
|
127
|
+
"description": "Optional rate limit. Applied per-rule, keyed by the trigger's deviceId when present.",
|
|
128
|
+
"properties": {
|
|
129
|
+
"max_per": {
|
|
130
|
+
"type": "string",
|
|
131
|
+
"pattern": "^\\d+[smh]$",
|
|
132
|
+
"description": "Minimum spacing between fires, e.g. \"10m\". Later triggers inside the window are suppressed and audited."
|
|
133
|
+
},
|
|
134
|
+
"dedupe_window": {
|
|
135
|
+
"type": "string",
|
|
136
|
+
"pattern": "^\\d+[smh]$",
|
|
137
|
+
"description": "Deduplicate identical events arriving within this window after the last fire. Complements max_per: use when rapid sensor bursts should collapse into one action."
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
"required": ["max_per"]
|
|
141
|
+
},
|
|
142
|
+
"cooldown": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"pattern": "^\\d+[smh]$",
|
|
145
|
+
"description": "Shorthand cooldown — minimum pause after a rule fires before it can fire again. Equivalent to throttle.max_per but at rule level. Takes precedence over throttle.max_per when both are set."
|
|
146
|
+
},
|
|
147
|
+
"requires_stable_for": {
|
|
148
|
+
"type": "string",
|
|
149
|
+
"pattern": "^\\d+[smh]$",
|
|
150
|
+
"description": "Hysteresis guard — the triggering device state must remain stable (unchanged) for this duration before the rule fires. Prevents action storms from transient sensor flicker. Validated at lint; enforcement is best-effort in the engine."
|
|
151
|
+
},
|
|
152
|
+
"hysteresis": {
|
|
153
|
+
"type": "string",
|
|
154
|
+
"pattern": "^\\d+[smh]$",
|
|
155
|
+
"description": "Alias for requires_stable_for with clearer semantics. Takes precedence when both are set. The triggering state must be continuously observed for this duration before the rule fires."
|
|
156
|
+
},
|
|
157
|
+
"maxFiringsPerHour": {
|
|
158
|
+
"type": "integer",
|
|
159
|
+
"minimum": 1,
|
|
160
|
+
"description": "Maximum number of times this rule may fire in any rolling 60-minute window. Applied after cooldown/throttle checks. Useful as an absolute safety cap for high-frequency MQTT triggers."
|
|
161
|
+
},
|
|
162
|
+
"suppressIfAlreadyDesired": {
|
|
163
|
+
"type": "boolean",
|
|
164
|
+
"description": "When true, skip the rule's actions if the target device's last-known cached state already matches the expected outcome of the command (e.g. skip turnOn if powerState is already 'on'). Best-effort: requires a warm device cache."
|
|
165
|
+
},
|
|
166
|
+
"dry_run": {
|
|
167
|
+
"type": "boolean",
|
|
168
|
+
"default": true,
|
|
169
|
+
"description": "When true, actions write to the audit log (kind=dry-run) but do NOT hit the SwitchBot API."
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
"trigger": {
|
|
175
|
+
"type": "object",
|
|
176
|
+
"oneOf": [
|
|
177
|
+
{ "$ref": "#/$defs/triggerMqtt" },
|
|
178
|
+
{ "$ref": "#/$defs/triggerCron" },
|
|
179
|
+
{ "$ref": "#/$defs/triggerWebhook" }
|
|
180
|
+
]
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
"triggerMqtt": {
|
|
184
|
+
"type": "object",
|
|
185
|
+
"additionalProperties": false,
|
|
186
|
+
"required": ["source", "event"],
|
|
187
|
+
"properties": {
|
|
188
|
+
"source": { "const": "mqtt" },
|
|
189
|
+
"event": {
|
|
190
|
+
"type": "string",
|
|
191
|
+
"description": "Event type from `switchbot events mqtt-tail --json`, e.g. `motion.detected`, `contact.opened`, `button.pressed`, `device.shadow`."
|
|
192
|
+
},
|
|
193
|
+
"device": {
|
|
194
|
+
"type": "string",
|
|
195
|
+
"description": "Optional filter by deviceId or alias. Matches the trigger's `deviceId` payload field."
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
"triggerCron": {
|
|
201
|
+
"type": "object",
|
|
202
|
+
"additionalProperties": false,
|
|
203
|
+
"required": ["source", "schedule"],
|
|
204
|
+
"properties": {
|
|
205
|
+
"source": { "const": "cron" },
|
|
206
|
+
"schedule": {
|
|
207
|
+
"type": "string",
|
|
208
|
+
"description": "Standard 5-field cron (minute hour dom month dow). Interpreted in local system timezone."
|
|
209
|
+
},
|
|
210
|
+
"days": {
|
|
211
|
+
"type": "array",
|
|
212
|
+
"description": "Optional weekday filter applied after the cron expression fires. Values are full-name or 3-letter day abbreviations (case-insensitive): mon/monday … sun/sunday. When omitted, all days pass.",
|
|
213
|
+
"uniqueItems": true,
|
|
214
|
+
"minItems": 1,
|
|
215
|
+
"items": {
|
|
216
|
+
"type": "string",
|
|
217
|
+
"enum": ["mon", "tue", "wed", "thu", "fri", "sat", "sun",
|
|
218
|
+
"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
"triggerWebhook": {
|
|
225
|
+
"type": "object",
|
|
226
|
+
"additionalProperties": false,
|
|
227
|
+
"required": ["source", "path"],
|
|
228
|
+
"properties": {
|
|
229
|
+
"source": { "const": "webhook" },
|
|
230
|
+
"path": {
|
|
231
|
+
"type": "string",
|
|
232
|
+
"pattern": "^/[a-z0-9/_-]+$",
|
|
233
|
+
"description": "Local HTTP path the rule engine listens on. Auth + transport are configured elsewhere (Phase 3)."
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
"condition": {
|
|
239
|
+
"description": "Predicate evaluated after the trigger matches. Leaf shapes: time_between, device_state. Composites: all (AND), any (OR), not (negation). `additionalProperties: false` lives on each `oneOf` branch so keys are validated per-shape.",
|
|
240
|
+
"oneOf": [
|
|
241
|
+
{
|
|
242
|
+
"type": "object",
|
|
243
|
+
"additionalProperties": false,
|
|
244
|
+
"required": ["time_between"],
|
|
245
|
+
"properties": {
|
|
246
|
+
"time_between": {
|
|
247
|
+
"type": "array",
|
|
248
|
+
"items": { "type": "string", "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$" },
|
|
249
|
+
"minItems": 2,
|
|
250
|
+
"maxItems": 2,
|
|
251
|
+
"description": "Two HH:MM strings: [start, end]. End-before-start means overnight window."
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"type": "object",
|
|
257
|
+
"additionalProperties": false,
|
|
258
|
+
"required": ["device", "field", "op", "value"],
|
|
259
|
+
"properties": {
|
|
260
|
+
"device": { "type": "string", "description": "deviceId or alias" },
|
|
261
|
+
"field": { "type": "string", "description": "status field name, e.g. `online`, `power`, `brightness`" },
|
|
262
|
+
"op": { "enum": ["==", "!=", "<", ">", "<=", ">="] },
|
|
263
|
+
"value": { "description": "Literal to compare against. Booleans, strings, numbers." }
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
"type": "object",
|
|
268
|
+
"additionalProperties": false,
|
|
269
|
+
"required": ["all"],
|
|
270
|
+
"properties": {
|
|
271
|
+
"all": {
|
|
272
|
+
"type": "array",
|
|
273
|
+
"minItems": 1,
|
|
274
|
+
"items": { "$ref": "#/$defs/condition" },
|
|
275
|
+
"description": "All sub-conditions must be true (logical AND)."
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
"type": "object",
|
|
281
|
+
"additionalProperties": false,
|
|
282
|
+
"required": ["any"],
|
|
283
|
+
"properties": {
|
|
284
|
+
"any": {
|
|
285
|
+
"type": "array",
|
|
286
|
+
"minItems": 1,
|
|
287
|
+
"items": { "$ref": "#/$defs/condition" },
|
|
288
|
+
"description": "At least one sub-condition must be true (logical OR)."
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
"type": "object",
|
|
294
|
+
"additionalProperties": false,
|
|
295
|
+
"required": ["not"],
|
|
296
|
+
"properties": {
|
|
297
|
+
"not": {
|
|
298
|
+
"$ref": "#/$defs/condition",
|
|
299
|
+
"description": "Negates the sub-condition."
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
]
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
"action": {
|
|
307
|
+
"type": "object",
|
|
308
|
+
"additionalProperties": false,
|
|
309
|
+
"required": ["command"],
|
|
310
|
+
"properties": {
|
|
311
|
+
"command": {
|
|
312
|
+
"type": "string",
|
|
313
|
+
"description": "A CLI invocation fragment, e.g. `devices command <id> turnOn`. The engine prepends `switchbot` and appends `--audit-log`."
|
|
314
|
+
},
|
|
315
|
+
"device": {
|
|
316
|
+
"type": "string",
|
|
317
|
+
"description": "deviceId or alias resolved before building the command. Substituted into the `<id>` slot."
|
|
318
|
+
},
|
|
319
|
+
"args": {
|
|
320
|
+
"type": ["object", "null"],
|
|
321
|
+
"description": "Extra key/value pairs rendered as `--key value` flags."
|
|
322
|
+
},
|
|
323
|
+
"on_error": {
|
|
324
|
+
"enum": ["continue", "stop"],
|
|
325
|
+
"default": "continue",
|
|
326
|
+
"description": "If this action fails, should the rule keep executing its remaining `then[]` entries?"
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
export const SUPPORTED_POLICY_SCHEMA_VERSIONS = ['0.2'];
|
|
4
|
+
export const CURRENT_POLICY_SCHEMA_VERSION = '0.2';
|
|
5
|
+
const schemaCache = new Map();
|
|
6
|
+
export function loadPolicySchema(version = CURRENT_POLICY_SCHEMA_VERSION) {
|
|
7
|
+
const cached = schemaCache.get(version);
|
|
8
|
+
if (cached)
|
|
9
|
+
return cached;
|
|
10
|
+
const url = new URL(`./schema/v${version}.json`, import.meta.url);
|
|
11
|
+
const raw = readFileSync(fileURLToPath(url), 'utf-8');
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
schemaCache.set(version, parsed);
|
|
14
|
+
return parsed;
|
|
15
|
+
}
|
|
16
|
+
export function isSupportedPolicySchemaVersion(v) {
|
|
17
|
+
return typeof v === 'string' && SUPPORTED_POLICY_SCHEMA_VERSIONS.includes(v);
|
|
18
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
3
|
+
import { isMap, isSeq, isScalar } from 'yaml';
|
|
4
|
+
import { loadPolicyFile } from './load.js';
|
|
5
|
+
import { loadPolicySchema, CURRENT_POLICY_SCHEMA_VERSION, SUPPORTED_POLICY_SCHEMA_VERSIONS, isSupportedPolicySchemaVersion, } from './schema.js';
|
|
6
|
+
import { destructiveVerbOf, DESTRUCTIVE_COMMANDS } from '../rules/destructive.js';
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const addFormats = require('ajv-formats');
|
|
9
|
+
const validators = new Map();
|
|
10
|
+
function getValidator(version) {
|
|
11
|
+
const cached = validators.get(version);
|
|
12
|
+
if (cached)
|
|
13
|
+
return cached;
|
|
14
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false, allowUnionTypes: true });
|
|
15
|
+
addFormats(ajv);
|
|
16
|
+
const schema = loadPolicySchema(version);
|
|
17
|
+
const validate = ajv.compile(schema);
|
|
18
|
+
const compiled = { ajv, validate };
|
|
19
|
+
validators.set(version, compiled);
|
|
20
|
+
return compiled;
|
|
21
|
+
}
|
|
22
|
+
function instancePathToSegments(instancePath) {
|
|
23
|
+
if (!instancePath)
|
|
24
|
+
return [];
|
|
25
|
+
return instancePath
|
|
26
|
+
.slice(1)
|
|
27
|
+
.split('/')
|
|
28
|
+
.map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
29
|
+
}
|
|
30
|
+
function getNodeAt(doc, segments) {
|
|
31
|
+
let current = doc.contents;
|
|
32
|
+
for (const seg of segments) {
|
|
33
|
+
if (isMap(current)) {
|
|
34
|
+
const pair = current.items.find((p) => {
|
|
35
|
+
const k = p.key;
|
|
36
|
+
if (isScalar(k))
|
|
37
|
+
return String(k.value) === seg;
|
|
38
|
+
return false;
|
|
39
|
+
});
|
|
40
|
+
if (!pair)
|
|
41
|
+
return null;
|
|
42
|
+
current = pair.value;
|
|
43
|
+
}
|
|
44
|
+
else if (isSeq(current)) {
|
|
45
|
+
const idx = Number(seg);
|
|
46
|
+
if (!Number.isInteger(idx))
|
|
47
|
+
return null;
|
|
48
|
+
current = current.items[idx];
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return current ?? null;
|
|
55
|
+
}
|
|
56
|
+
function getKeyNodeAt(doc, parentSegments, key) {
|
|
57
|
+
const parent = parentSegments.length === 0 ? doc.contents : getNodeAt(doc, parentSegments);
|
|
58
|
+
if (!parent || !isMap(parent))
|
|
59
|
+
return null;
|
|
60
|
+
const pair = parent.items.find((p) => isScalar(p.key) && String(p.key.value) === key);
|
|
61
|
+
return pair?.key ?? null;
|
|
62
|
+
}
|
|
63
|
+
function locateError(doc, lineCounter, err) {
|
|
64
|
+
const segments = instancePathToSegments(err.instancePath);
|
|
65
|
+
if (err.keyword === 'additionalProperties') {
|
|
66
|
+
const bad = err.params.additionalProperty;
|
|
67
|
+
if (bad) {
|
|
68
|
+
const keyNode = getKeyNodeAt(doc, segments, bad);
|
|
69
|
+
const range = keyNode?.range;
|
|
70
|
+
if (range) {
|
|
71
|
+
const pos = lineCounter.linePos(range[0]);
|
|
72
|
+
return { line: pos.line, col: pos.col };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (err.keyword === 'required' || err.keyword === 'dependentRequired') {
|
|
77
|
+
const node = getNodeAt(doc, segments);
|
|
78
|
+
const range = node?.range;
|
|
79
|
+
if (range) {
|
|
80
|
+
const pos = lineCounter.linePos(range[0]);
|
|
81
|
+
return { line: pos.line, col: pos.col };
|
|
82
|
+
}
|
|
83
|
+
return { line: 1, col: 1 };
|
|
84
|
+
}
|
|
85
|
+
const node = getNodeAt(doc, segments);
|
|
86
|
+
const range = node?.range;
|
|
87
|
+
if (!range)
|
|
88
|
+
return {};
|
|
89
|
+
const pos = lineCounter.linePos(range[0]);
|
|
90
|
+
return { line: pos.line, col: pos.col };
|
|
91
|
+
}
|
|
92
|
+
function humanMessage(err) {
|
|
93
|
+
const path = err.instancePath || '(root)';
|
|
94
|
+
switch (err.keyword) {
|
|
95
|
+
case 'required':
|
|
96
|
+
return `missing required property "${err.params.missingProperty}"`;
|
|
97
|
+
case 'additionalProperties':
|
|
98
|
+
return `unknown property "${err.params.additionalProperty}"`;
|
|
99
|
+
case 'dependentRequired': {
|
|
100
|
+
const { property, missingProperty } = err.params;
|
|
101
|
+
const parent = path === '(root)' ? '' : `${path}: `;
|
|
102
|
+
return `${parent}when "${property}" is set, "${missingProperty}" is also required`;
|
|
103
|
+
}
|
|
104
|
+
case 'pattern':
|
|
105
|
+
return `${path} does not match pattern ${err.params.pattern}`;
|
|
106
|
+
case 'const':
|
|
107
|
+
return `${path} must be exactly ${JSON.stringify(err.params.allowedValue)}`;
|
|
108
|
+
case 'enum':
|
|
109
|
+
return `${path} must be one of ${JSON.stringify(err.params.allowedValues)}`;
|
|
110
|
+
case 'type':
|
|
111
|
+
return `${path} must be ${err.params.type}`;
|
|
112
|
+
case 'not':
|
|
113
|
+
return `${path} is not allowed here`;
|
|
114
|
+
default:
|
|
115
|
+
return `${path} ${err.message ?? 'is invalid'}`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function hintFor(err) {
|
|
119
|
+
if (err.keyword === 'pattern' && err.instancePath.startsWith('/aliases/')) {
|
|
120
|
+
return 'paste the deviceId from `switchbot devices list --format=tsv`, e.g. 01-202407090924-26354212';
|
|
121
|
+
}
|
|
122
|
+
if (err.keyword === 'not' && err.instancePath.startsWith('/confirmations/never_confirm/')) {
|
|
123
|
+
return 'destructive actions (lock/unlock/delete*/factoryReset) cannot be pre-approved in policy.yaml';
|
|
124
|
+
}
|
|
125
|
+
if (err.keyword === 'const' && err.instancePath === '/version') {
|
|
126
|
+
const supported = SUPPORTED_POLICY_SCHEMA_VERSIONS.map((v) => `"${v}"`).join(' / ');
|
|
127
|
+
return `this CLI supports policy schema versions ${supported}; run \`switchbot policy migrate\` to upgrade an older file`;
|
|
128
|
+
}
|
|
129
|
+
if (err.keyword === 'required' && err.instancePath === '') {
|
|
130
|
+
const missing = err.params.missingProperty;
|
|
131
|
+
if (missing === 'version')
|
|
132
|
+
return `add \`version: "${CURRENT_POLICY_SCHEMA_VERSION}"\` at the top of the file`;
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
function readDeclaredVersion(data) {
|
|
137
|
+
if (data && typeof data === 'object' && 'version' in data) {
|
|
138
|
+
const v = data.version;
|
|
139
|
+
if (typeof v === 'string')
|
|
140
|
+
return v;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
function unsupportedVersionResult(loaded, declared) {
|
|
145
|
+
const supported = SUPPORTED_POLICY_SCHEMA_VERSIONS.map((v) => `"${v}"`).join(' / ');
|
|
146
|
+
const isLegacy = declared === '0.1';
|
|
147
|
+
const hint = isLegacy
|
|
148
|
+
? `v0.1 policy support was removed in v3.0. Run \`switchbot policy migrate\` with CLI ≤2.15 first, then upgrade.`
|
|
149
|
+
: `supported versions: ${supported}. upgrade the CLI or downgrade the file.`;
|
|
150
|
+
return {
|
|
151
|
+
policyPath: loaded.path,
|
|
152
|
+
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
153
|
+
valid: false,
|
|
154
|
+
errors: [
|
|
155
|
+
{
|
|
156
|
+
path: '/version',
|
|
157
|
+
line: 1,
|
|
158
|
+
col: 1,
|
|
159
|
+
keyword: 'unsupported-version',
|
|
160
|
+
message: `policy schema version "${declared}" is not supported by this CLI`,
|
|
161
|
+
hint,
|
|
162
|
+
schemaPath: '#/properties/version',
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Walk `automation.rules[].then[]` and flag any command string whose verb
|
|
169
|
+
* appears in DESTRUCTIVE_COMMANDS. Uses the YAML doc (not the data tree) to
|
|
170
|
+
* get accurate line/col on the offending node.
|
|
171
|
+
*
|
|
172
|
+
* This is deliberately a post-ajv pass rather than a schema rule because
|
|
173
|
+
* JSON Schema cannot parse a command string and compare the verb slot to a
|
|
174
|
+
* blocklist. Keeping it in JS also lets `src/rules/destructive.ts` be the
|
|
175
|
+
* single source of truth shared with the runtime executor.
|
|
176
|
+
*/
|
|
177
|
+
function collectDestructiveRuleErrors(loaded) {
|
|
178
|
+
const data = loaded.data;
|
|
179
|
+
const rules = data?.automation?.rules;
|
|
180
|
+
if (!Array.isArray(rules))
|
|
181
|
+
return [];
|
|
182
|
+
const out = [];
|
|
183
|
+
for (let ri = 0; ri < rules.length; ri++) {
|
|
184
|
+
const rule = rules[ri];
|
|
185
|
+
const actions = Array.isArray(rule?.then) ? rule.then : [];
|
|
186
|
+
for (let ai = 0; ai < actions.length; ai++) {
|
|
187
|
+
const cmd = actions[ai]?.command;
|
|
188
|
+
if (typeof cmd !== 'string')
|
|
189
|
+
continue;
|
|
190
|
+
const verb = destructiveVerbOf(cmd);
|
|
191
|
+
if (!verb)
|
|
192
|
+
continue;
|
|
193
|
+
const instancePath = `/automation/rules/${ri}/then/${ai}/command`;
|
|
194
|
+
const segments = instancePath.slice(1).split('/');
|
|
195
|
+
const node = getNodeAt(loaded.doc, segments);
|
|
196
|
+
const range = node?.range;
|
|
197
|
+
let line;
|
|
198
|
+
let col;
|
|
199
|
+
if (range) {
|
|
200
|
+
const pos = loaded.lineCounter.linePos(range[0]);
|
|
201
|
+
line = pos.line;
|
|
202
|
+
col = pos.col;
|
|
203
|
+
}
|
|
204
|
+
const ruleName = typeof rule?.name === 'string' ? rule.name : `#${ri}`;
|
|
205
|
+
out.push({
|
|
206
|
+
path: instancePath,
|
|
207
|
+
line,
|
|
208
|
+
col,
|
|
209
|
+
keyword: 'rule-destructive-action',
|
|
210
|
+
message: `rule "${ruleName}" action #${ai} uses destructive command "${verb}"`,
|
|
211
|
+
hint: `destructive verbs (${DESTRUCTIVE_COMMANDS.join(', ')}) cannot be pre-approved in automation rules; run them via the interactive CLI so the confirmation gate fires`,
|
|
212
|
+
schemaPath: '#/properties/automation/properties/rules/items/properties/then/items/properties/command',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
export function validateLoadedPolicy(loaded) {
|
|
219
|
+
const declared = readDeclaredVersion(loaded.data);
|
|
220
|
+
if (declared !== undefined && !isSupportedPolicySchemaVersion(declared)) {
|
|
221
|
+
return unsupportedVersionResult(loaded, declared);
|
|
222
|
+
}
|
|
223
|
+
const version = isSupportedPolicySchemaVersion(declared)
|
|
224
|
+
? declared
|
|
225
|
+
: CURRENT_POLICY_SCHEMA_VERSION;
|
|
226
|
+
const { validate } = getValidator(version);
|
|
227
|
+
const ok = validate(loaded.data);
|
|
228
|
+
const errors = [];
|
|
229
|
+
if (!ok && validate.errors) {
|
|
230
|
+
for (const err of validate.errors) {
|
|
231
|
+
const { line, col } = locateError(loaded.doc, loaded.lineCounter, err);
|
|
232
|
+
errors.push({
|
|
233
|
+
path: err.instancePath || '',
|
|
234
|
+
line,
|
|
235
|
+
col,
|
|
236
|
+
keyword: err.keyword,
|
|
237
|
+
message: humanMessage(err),
|
|
238
|
+
hint: hintFor(err),
|
|
239
|
+
schemaPath: err.schemaPath,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// v0.2-only post-hook: destructive verbs like `unlock` / `factoryReset`
|
|
244
|
+
// cannot be pre-approved via rules, even if ajv considers the command
|
|
245
|
+
// string well-formed. Schema can't express this because `command` is a
|
|
246
|
+
// free-form string; we parse the verb in JS and append errors.
|
|
247
|
+
if (version === '0.2') {
|
|
248
|
+
const ruleErrors = collectDestructiveRuleErrors(loaded);
|
|
249
|
+
errors.push(...ruleErrors);
|
|
250
|
+
}
|
|
251
|
+
const valid = ok === true && errors.length === 0;
|
|
252
|
+
return {
|
|
253
|
+
policyPath: loaded.path,
|
|
254
|
+
schemaVersion: version,
|
|
255
|
+
valid,
|
|
256
|
+
errors,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
export function validatePolicyFile(policyPath) {
|
|
260
|
+
const loaded = loadPolicyFile(policyPath);
|
|
261
|
+
return validateLoadedPolicy(loaded);
|
|
262
|
+
}
|