@switchbot/openapi-cli 2.6.4 → 3.0.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 +385 -103
- package/dist/api/client.js +13 -12
- package/dist/commands/agent-bootstrap.js +67 -16
- package/dist/commands/auth.js +354 -0
- package/dist/commands/batch.js +26 -21
- package/dist/commands/capabilities.js +29 -21
- package/dist/commands/catalog.js +4 -3
- package/dist/commands/config.js +57 -37
- package/dist/commands/devices.js +63 -37
- package/dist/commands/doctor.js +539 -26
- package/dist/commands/events.js +115 -26
- package/dist/commands/expand.js +7 -15
- package/dist/commands/explain.js +10 -7
- package/dist/commands/history.js +12 -18
- package/dist/commands/identity.js +59 -0
- package/dist/commands/install.js +246 -0
- package/dist/commands/mcp.js +895 -15
- package/dist/commands/plan.js +111 -15
- package/dist/commands/policy.js +469 -0
- package/dist/commands/rules.js +657 -0
- package/dist/commands/schema.js +20 -12
- package/dist/commands/status-sync.js +131 -0
- package/dist/commands/uninstall.js +237 -0
- package/dist/commands/watch.js +15 -2
- 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 +118 -11
- package/dist/devices/resources.js +270 -0
- package/dist/index.js +39 -4
- 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/devices.js +15 -5
- 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 +302 -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/cron-scheduler.js +186 -0
- package/dist/rules/destructive.js +52 -0
- package/dist/rules/engine.js +567 -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 +78 -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/schema/field-aliases.js +95 -0
- package/dist/status-sync/manager.js +268 -0
- package/dist/utils/audit.js +12 -2
- package/dist/utils/help-json.js +54 -0
- package/dist/utils/output.js +17 -0
- package/package.json +12 -4
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule action executor — the only place that calls into `executeCommand`
|
|
3
|
+
* from the rules pipeline.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities:
|
|
6
|
+
* 1. Parse the `command` string into a `{ deviceId, verb, parameter }`
|
|
7
|
+
* tuple, rejecting shapes the PoC doesn't understand.
|
|
8
|
+
* 2. Enforce the destructive-command blocklist as a second line of
|
|
9
|
+
* defence (the validator should have caught it at load time — this
|
|
10
|
+
* protects against hand-crafted engine inputs).
|
|
11
|
+
* 3. Resolve `action.device` (alias or deviceId) into the `<id>`
|
|
12
|
+
* slot.
|
|
13
|
+
* 4. Branch on `dry_run`: dry-run writes audit with kind
|
|
14
|
+
* `rule-fire-dry` and returns without touching the API.
|
|
15
|
+
* 5. Live run delegates to `executeCommand`, then re-writes audit
|
|
16
|
+
* with the rule-scoped kind + fireId so `rules tail` / `replay`
|
|
17
|
+
* can correlate multi-action fires.
|
|
18
|
+
*/
|
|
19
|
+
import { executeCommand } from '../lib/devices.js';
|
|
20
|
+
import { writeAudit } from '../utils/audit.js';
|
|
21
|
+
import { isDestructiveCommand } from './destructive.js';
|
|
22
|
+
const DEVICES_COMMAND_RE = /^devices\s+command\s+(\S+)\s+(\S+)(?:\s+(.*))?$/;
|
|
23
|
+
export function parseRuleCommand(cmd) {
|
|
24
|
+
const m = DEVICES_COMMAND_RE.exec(cmd.trim());
|
|
25
|
+
if (!m)
|
|
26
|
+
return null;
|
|
27
|
+
const deviceIdSlot = m[1];
|
|
28
|
+
const verb = m[2];
|
|
29
|
+
const rest = (m[3] ?? '').trim();
|
|
30
|
+
return {
|
|
31
|
+
deviceIdSlot,
|
|
32
|
+
verb,
|
|
33
|
+
parameterTokens: rest.length === 0 ? [] : rest.split(/\s+/),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Alias-first resolver — falls back to the raw value (assumed deviceId). */
|
|
37
|
+
export function resolveActionDevice(explicit, slot, aliases) {
|
|
38
|
+
// Explicit device field on the action wins.
|
|
39
|
+
const candidate = explicit ?? (slot && slot !== '<id>' ? slot : null);
|
|
40
|
+
if (!candidate)
|
|
41
|
+
return null;
|
|
42
|
+
if (aliases[candidate])
|
|
43
|
+
return aliases[candidate];
|
|
44
|
+
return candidate;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Render a parameter for SwitchBot's command API. For the PoC we pass
|
|
48
|
+
* the raw token string for single-token args, join with `:` for
|
|
49
|
+
* multi-token args (matches the CLI's `devices command` convention),
|
|
50
|
+
* and `undefined` when no tokens were supplied (the SDK substitutes
|
|
51
|
+
* `'default'`).
|
|
52
|
+
*/
|
|
53
|
+
function renderParameter(tokens) {
|
|
54
|
+
if (tokens.length === 0)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (tokens.length === 1)
|
|
57
|
+
return tokens[0];
|
|
58
|
+
return tokens.join(':');
|
|
59
|
+
}
|
|
60
|
+
export async function executeRuleAction(action, ctx) {
|
|
61
|
+
const parsed = parseRuleCommand(action.command);
|
|
62
|
+
if (!parsed) {
|
|
63
|
+
writeAudit({
|
|
64
|
+
t: new Date().toISOString(),
|
|
65
|
+
kind: 'rule-fire',
|
|
66
|
+
deviceId: 'unknown',
|
|
67
|
+
command: action.command,
|
|
68
|
+
parameter: null,
|
|
69
|
+
commandType: 'command',
|
|
70
|
+
dryRun: true,
|
|
71
|
+
result: 'error',
|
|
72
|
+
error: 'unparseable-command',
|
|
73
|
+
rule: {
|
|
74
|
+
name: ctx.rule.name,
|
|
75
|
+
triggerSource: ctx.rule.when.source,
|
|
76
|
+
fireId: ctx.fireId,
|
|
77
|
+
reason: 'unparseable-command',
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
return { ok: false, error: 'unparseable-command', blocked: true };
|
|
81
|
+
}
|
|
82
|
+
if (isDestructiveCommand(action.command)) {
|
|
83
|
+
writeAudit({
|
|
84
|
+
t: new Date().toISOString(),
|
|
85
|
+
kind: 'rule-fire',
|
|
86
|
+
deviceId: resolveActionDevice(action.device, parsed.deviceIdSlot, ctx.aliases) ?? 'unknown',
|
|
87
|
+
command: action.command,
|
|
88
|
+
parameter: null,
|
|
89
|
+
commandType: 'command',
|
|
90
|
+
dryRun: true,
|
|
91
|
+
result: 'error',
|
|
92
|
+
error: `destructive-verb:${parsed.verb}`,
|
|
93
|
+
rule: {
|
|
94
|
+
name: ctx.rule.name,
|
|
95
|
+
triggerSource: ctx.rule.when.source,
|
|
96
|
+
fireId: ctx.fireId,
|
|
97
|
+
reason: `destructive verb "${parsed.verb}" refused at runtime`,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
return { ok: false, error: `destructive-verb:${parsed.verb}`, blocked: true, verb: parsed.verb };
|
|
101
|
+
}
|
|
102
|
+
const deviceId = resolveActionDevice(action.device, parsed.deviceIdSlot, ctx.aliases);
|
|
103
|
+
if (!deviceId || deviceId === '<id>') {
|
|
104
|
+
writeAudit({
|
|
105
|
+
t: new Date().toISOString(),
|
|
106
|
+
kind: 'rule-fire',
|
|
107
|
+
deviceId: 'unknown',
|
|
108
|
+
command: action.command,
|
|
109
|
+
parameter: null,
|
|
110
|
+
commandType: 'command',
|
|
111
|
+
dryRun: true,
|
|
112
|
+
result: 'error',
|
|
113
|
+
error: 'missing-device',
|
|
114
|
+
rule: {
|
|
115
|
+
name: ctx.rule.name,
|
|
116
|
+
triggerSource: ctx.rule.when.source,
|
|
117
|
+
fireId: ctx.fireId,
|
|
118
|
+
reason: 'action omitted `device` and command used `<id>` placeholder',
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
return { ok: false, error: 'missing-device', verb: parsed.verb };
|
|
122
|
+
}
|
|
123
|
+
const dryRun = ctx.globalDryRun === true || ctx.rule.dry_run === true;
|
|
124
|
+
const parameter = renderParameter(parsed.parameterTokens);
|
|
125
|
+
if (dryRun) {
|
|
126
|
+
writeAudit({
|
|
127
|
+
t: new Date().toISOString(),
|
|
128
|
+
kind: 'rule-fire-dry',
|
|
129
|
+
deviceId,
|
|
130
|
+
command: parsed.verb,
|
|
131
|
+
parameter: parameter ?? 'default',
|
|
132
|
+
commandType: 'command',
|
|
133
|
+
dryRun: true,
|
|
134
|
+
result: 'ok',
|
|
135
|
+
rule: {
|
|
136
|
+
name: ctx.rule.name,
|
|
137
|
+
triggerSource: ctx.rule.when.source,
|
|
138
|
+
matchedDevice: deviceId,
|
|
139
|
+
fireId: ctx.fireId,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
return { ok: true, dryRun: true, deviceId, verb: parsed.verb };
|
|
143
|
+
}
|
|
144
|
+
if (ctx.skipApiCall) {
|
|
145
|
+
writeAudit({
|
|
146
|
+
t: new Date().toISOString(),
|
|
147
|
+
kind: 'rule-fire',
|
|
148
|
+
deviceId,
|
|
149
|
+
command: parsed.verb,
|
|
150
|
+
parameter: parameter ?? 'default',
|
|
151
|
+
commandType: 'command',
|
|
152
|
+
dryRun: false,
|
|
153
|
+
result: 'ok',
|
|
154
|
+
rule: {
|
|
155
|
+
name: ctx.rule.name,
|
|
156
|
+
triggerSource: ctx.rule.when.source,
|
|
157
|
+
matchedDevice: deviceId,
|
|
158
|
+
fireId: ctx.fireId,
|
|
159
|
+
reason: 'api-skipped',
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
return { ok: true, deviceId, verb: parsed.verb };
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
await executeCommand(deviceId, parsed.verb, parameter, 'command', ctx.httpClient);
|
|
166
|
+
writeAudit({
|
|
167
|
+
t: new Date().toISOString(),
|
|
168
|
+
kind: 'rule-fire',
|
|
169
|
+
deviceId,
|
|
170
|
+
command: parsed.verb,
|
|
171
|
+
parameter: parameter ?? 'default',
|
|
172
|
+
commandType: 'command',
|
|
173
|
+
dryRun: false,
|
|
174
|
+
result: 'ok',
|
|
175
|
+
rule: {
|
|
176
|
+
name: ctx.rule.name,
|
|
177
|
+
triggerSource: ctx.rule.when.source,
|
|
178
|
+
matchedDevice: deviceId,
|
|
179
|
+
fireId: ctx.fireId,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
return { ok: true, deviceId, verb: parsed.verb };
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
186
|
+
writeAudit({
|
|
187
|
+
t: new Date().toISOString(),
|
|
188
|
+
kind: 'rule-fire',
|
|
189
|
+
deviceId,
|
|
190
|
+
command: parsed.verb,
|
|
191
|
+
parameter: parameter ?? 'default',
|
|
192
|
+
commandType: 'command',
|
|
193
|
+
dryRun: false,
|
|
194
|
+
result: 'error',
|
|
195
|
+
error: msg,
|
|
196
|
+
rule: {
|
|
197
|
+
name: ctx.rule.name,
|
|
198
|
+
triggerSource: ctx.rule.when.source,
|
|
199
|
+
matchedDevice: deviceId,
|
|
200
|
+
fireId: ctx.fireId,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
return { ok: false, error: msg, deviceId, verb: parsed.verb };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared filters + aggregations over the audit log for
|
|
3
|
+
* `switchbot rules tail` and `switchbot rules replay`.
|
|
4
|
+
*
|
|
5
|
+
* All functions are pure — no I/O, no clock reads — so they can be
|
|
6
|
+
* unit-tested with fixture arrays. The CLI entry points handle file
|
|
7
|
+
* reading, `--follow` tailing, and human vs JSON rendering.
|
|
8
|
+
*/
|
|
9
|
+
/** The subset of audit kinds the rules engine emits. */
|
|
10
|
+
export const RULE_AUDIT_KINDS = [
|
|
11
|
+
'rule-fire',
|
|
12
|
+
'rule-fire-dry',
|
|
13
|
+
'rule-throttled',
|
|
14
|
+
'rule-webhook-rejected',
|
|
15
|
+
];
|
|
16
|
+
/** Keep entries that are rule-engine emitted and match the filter. */
|
|
17
|
+
export function filterRuleAudits(entries, filter = {}) {
|
|
18
|
+
const kinds = new Set(filter.kinds ?? RULE_AUDIT_KINDS);
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const e of entries) {
|
|
21
|
+
if (!kinds.has(e.kind))
|
|
22
|
+
continue;
|
|
23
|
+
if (filter.sinceMs !== undefined) {
|
|
24
|
+
const ms = Date.parse(e.t);
|
|
25
|
+
if (!Number.isFinite(ms) || ms < filter.sinceMs)
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (filter.ruleName !== undefined) {
|
|
29
|
+
if (e.rule?.name !== filter.ruleName)
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
out.push(e);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/** Aggregate a filtered stream into per-rule counters. */
|
|
37
|
+
export function aggregateRuleAudits(entries) {
|
|
38
|
+
const byRule = new Map();
|
|
39
|
+
let webhookRejectedCount = 0;
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
if (e.kind === 'rule-webhook-rejected' && !e.rule) {
|
|
42
|
+
webhookRejectedCount++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const name = e.rule?.name;
|
|
46
|
+
if (!name)
|
|
47
|
+
continue;
|
|
48
|
+
let s = byRule.get(name);
|
|
49
|
+
if (!s) {
|
|
50
|
+
s = {
|
|
51
|
+
rule: name,
|
|
52
|
+
fires: 0,
|
|
53
|
+
driesFires: 0,
|
|
54
|
+
throttled: 0,
|
|
55
|
+
errors: 0,
|
|
56
|
+
errorRate: 0,
|
|
57
|
+
firstAt: null,
|
|
58
|
+
lastAt: null,
|
|
59
|
+
triggerSource: null,
|
|
60
|
+
};
|
|
61
|
+
byRule.set(name, s);
|
|
62
|
+
}
|
|
63
|
+
if (e.kind === 'rule-fire')
|
|
64
|
+
s.fires++;
|
|
65
|
+
else if (e.kind === 'rule-fire-dry')
|
|
66
|
+
s.driesFires++;
|
|
67
|
+
else if (e.kind === 'rule-throttled')
|
|
68
|
+
s.throttled++;
|
|
69
|
+
if (e.result === 'error')
|
|
70
|
+
s.errors++;
|
|
71
|
+
if (!s.firstAt || e.t < s.firstAt)
|
|
72
|
+
s.firstAt = e.t;
|
|
73
|
+
if (!s.lastAt || e.t > s.lastAt)
|
|
74
|
+
s.lastAt = e.t;
|
|
75
|
+
const source = e.rule?.triggerSource;
|
|
76
|
+
if (source) {
|
|
77
|
+
if (s.triggerSource === null)
|
|
78
|
+
s.triggerSource = source;
|
|
79
|
+
else if (s.triggerSource !== source)
|
|
80
|
+
s.triggerSource = 'mixed';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const s of byRule.values()) {
|
|
84
|
+
const denom = s.fires + s.driesFires;
|
|
85
|
+
s.errorRate = denom === 0 ? 0 : s.errors / denom;
|
|
86
|
+
}
|
|
87
|
+
const summaries = [...byRule.values()].sort((a, b) => b.fires + b.driesFires - (a.fires + a.driesFires));
|
|
88
|
+
return { total: entries.length, summaries, webhookRejectedCount };
|
|
89
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron trigger scheduler for the rules engine.
|
|
3
|
+
*
|
|
4
|
+
* Each cron rule gets its own scheduler entry. On every tick the
|
|
5
|
+
* scheduler synthesises an `EngineEvent` with `source: 'cron'` and hands
|
|
6
|
+
* it to the same dispatch path the MQTT pipeline uses, so conditions,
|
|
7
|
+
* throttle, and action execution behave identically regardless of
|
|
8
|
+
* trigger source.
|
|
9
|
+
*
|
|
10
|
+
* Tests can drive the scheduler deterministically via `fireNowForTest()`
|
|
11
|
+
* — the scheduler's internal timer still uses `setTimeout`, which means
|
|
12
|
+
* `vi.useFakeTimers()` plus `vi.advanceTimersByTime()` also work. Croner
|
|
13
|
+
* is used only for `nextRun(fromDate)` calculations; we own the
|
|
14
|
+
* timer/dispatch loop so the engine can drain events through a single
|
|
15
|
+
* serialised queue.
|
|
16
|
+
*/
|
|
17
|
+
import { Cron } from 'croner';
|
|
18
|
+
/** Maps JS getDay() (0=Sun) to 3-letter abbreviation. */
|
|
19
|
+
const JS_DAY_TO_ABBR = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
|
20
|
+
/** Expand a days[] entry to its canonical 3-letter abbr so comparisons are O(1). */
|
|
21
|
+
function normaliseDay(d) {
|
|
22
|
+
return d.toLowerCase().slice(0, 3);
|
|
23
|
+
}
|
|
24
|
+
/** Return true if `t` falls on one of the listed days (or days is absent/empty). */
|
|
25
|
+
export function matchesDayFilter(days, t) {
|
|
26
|
+
if (!days || days.length === 0)
|
|
27
|
+
return true;
|
|
28
|
+
const todayAbbr = JS_DAY_TO_ABBR[t.getDay()];
|
|
29
|
+
return days.some((d) => normaliseDay(d) === todayAbbr);
|
|
30
|
+
}
|
|
31
|
+
export class CronScheduler {
|
|
32
|
+
opts;
|
|
33
|
+
entries = new Map();
|
|
34
|
+
started = false;
|
|
35
|
+
stopped = false;
|
|
36
|
+
constructor(opts) {
|
|
37
|
+
this.opts = opts;
|
|
38
|
+
}
|
|
39
|
+
getScheduledFor(ruleName) {
|
|
40
|
+
const s = this.entries.get(ruleName);
|
|
41
|
+
if (!s)
|
|
42
|
+
return null;
|
|
43
|
+
return { schedule: s.schedule, nextAt: s.nextAt };
|
|
44
|
+
}
|
|
45
|
+
hasRegistered(ruleName) {
|
|
46
|
+
return this.entries.has(ruleName);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Register a cron rule. Validates the pattern eagerly — an invalid
|
|
50
|
+
* schedule throws synchronously so engine start can surface the error.
|
|
51
|
+
*/
|
|
52
|
+
register(rule) {
|
|
53
|
+
if (rule.when.source !== 'cron') {
|
|
54
|
+
throw new Error(`CronScheduler.register called for non-cron rule "${rule.name}"`);
|
|
55
|
+
}
|
|
56
|
+
if (this.entries.has(rule.name)) {
|
|
57
|
+
throw new Error(`CronScheduler: duplicate rule name "${rule.name}"`);
|
|
58
|
+
}
|
|
59
|
+
const schedule = rule.when.schedule;
|
|
60
|
+
let pattern;
|
|
61
|
+
try {
|
|
62
|
+
pattern = new Cron(schedule, { paused: true });
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
throw new Error(`CronScheduler: invalid cron expression for rule "${rule.name}": ${schedule} (${err instanceof Error ? err.message : String(err)})`);
|
|
66
|
+
}
|
|
67
|
+
const entry = {
|
|
68
|
+
rule,
|
|
69
|
+
schedule,
|
|
70
|
+
pattern,
|
|
71
|
+
timer: null,
|
|
72
|
+
nextAt: null,
|
|
73
|
+
};
|
|
74
|
+
this.entries.set(rule.name, entry);
|
|
75
|
+
if (this.started && !this.stopped)
|
|
76
|
+
this.arm(entry);
|
|
77
|
+
}
|
|
78
|
+
unregister(ruleName) {
|
|
79
|
+
const e = this.entries.get(ruleName);
|
|
80
|
+
if (!e)
|
|
81
|
+
return;
|
|
82
|
+
if (e.timer)
|
|
83
|
+
clearTimeout(e.timer);
|
|
84
|
+
try {
|
|
85
|
+
e.pattern.stop();
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// croner throws when already stopped — ignore.
|
|
89
|
+
}
|
|
90
|
+
this.entries.delete(ruleName);
|
|
91
|
+
}
|
|
92
|
+
start() {
|
|
93
|
+
if (this.stopped) {
|
|
94
|
+
throw new Error('CronScheduler: cannot start after stop().');
|
|
95
|
+
}
|
|
96
|
+
if (this.started)
|
|
97
|
+
return;
|
|
98
|
+
this.started = true;
|
|
99
|
+
for (const entry of this.entries.values())
|
|
100
|
+
this.arm(entry);
|
|
101
|
+
}
|
|
102
|
+
stop() {
|
|
103
|
+
if (this.stopped)
|
|
104
|
+
return;
|
|
105
|
+
this.stopped = true;
|
|
106
|
+
this.started = false;
|
|
107
|
+
for (const e of this.entries.values()) {
|
|
108
|
+
if (e.timer)
|
|
109
|
+
clearTimeout(e.timer);
|
|
110
|
+
e.timer = null;
|
|
111
|
+
try {
|
|
112
|
+
e.pattern.stop();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// ignore
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Test helper — compute the pattern's next run after a reference
|
|
121
|
+
* timestamp without actually scheduling it. Handy for regression tests.
|
|
122
|
+
*/
|
|
123
|
+
nextRunAfter(ruleName, after) {
|
|
124
|
+
const e = this.entries.get(ruleName);
|
|
125
|
+
if (!e)
|
|
126
|
+
return null;
|
|
127
|
+
return e.pattern.nextRun(after) ?? null;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Test helper — fire a rule immediately, bypassing the timer. Used by
|
|
131
|
+
* unit tests to skip vi.advanceTimersByTime logic when the focus is on
|
|
132
|
+
* dispatch behaviour, not scheduling accuracy.
|
|
133
|
+
*/
|
|
134
|
+
async fireNowForTest(ruleName) {
|
|
135
|
+
const e = this.entries.get(ruleName);
|
|
136
|
+
if (!e)
|
|
137
|
+
throw new Error(`CronScheduler.fireNowForTest: no rule "${ruleName}"`);
|
|
138
|
+
await this.fire(e);
|
|
139
|
+
}
|
|
140
|
+
nowDate() {
|
|
141
|
+
return this.opts.now ? this.opts.now() : new Date();
|
|
142
|
+
}
|
|
143
|
+
arm(entry) {
|
|
144
|
+
if (this.stopped)
|
|
145
|
+
return;
|
|
146
|
+
const now = this.nowDate();
|
|
147
|
+
const next = entry.pattern.nextRun(now);
|
|
148
|
+
if (!next) {
|
|
149
|
+
entry.nextAt = null;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
entry.nextAt = next;
|
|
153
|
+
const delayMs = Math.max(0, next.getTime() - now.getTime());
|
|
154
|
+
entry.timer = setTimeout(() => {
|
|
155
|
+
entry.timer = null;
|
|
156
|
+
// Fire and then re-arm, regardless of outcome — we never want one
|
|
157
|
+
// misbehaving rule to kill its own future ticks.
|
|
158
|
+
this.fire(entry)
|
|
159
|
+
.catch(() => undefined)
|
|
160
|
+
.finally(() => {
|
|
161
|
+
if (!this.stopped && this.entries.has(entry.rule.name))
|
|
162
|
+
this.arm(entry);
|
|
163
|
+
});
|
|
164
|
+
}, delayMs);
|
|
165
|
+
// Unref so a process with only cron rules still exits on SIGINT when
|
|
166
|
+
// the user expects (e.g. in integration tests).
|
|
167
|
+
if (typeof entry.timer.unref === 'function') {
|
|
168
|
+
entry.timer.unref();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async fire(entry) {
|
|
172
|
+
const when = this.nowDate();
|
|
173
|
+
// Apply the optional day-of-week filter before dispatching.
|
|
174
|
+
const trigger = entry.rule.when;
|
|
175
|
+
if (trigger.source === 'cron' && !matchesDayFilter(trigger.days, when)) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const event = {
|
|
179
|
+
source: 'cron',
|
|
180
|
+
event: entry.schedule,
|
|
181
|
+
t: when,
|
|
182
|
+
payload: { schedule: entry.schedule },
|
|
183
|
+
};
|
|
184
|
+
await this.opts.dispatch(entry.rule, event);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destructive command parsing — single source of truth shared between the
|
|
3
|
+
* policy validator post-hook (rejects destructive commands inside
|
|
4
|
+
* `automation.rules[].then[].command`) and the runtime executor (second-
|
|
5
|
+
* line guard that refuses to shell out even if validation was bypassed).
|
|
6
|
+
*/
|
|
7
|
+
export const DESTRUCTIVE_COMMANDS = [
|
|
8
|
+
'lock',
|
|
9
|
+
'unlock',
|
|
10
|
+
'deleteWebhook',
|
|
11
|
+
'deleteScene',
|
|
12
|
+
'factoryReset',
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Parse the verb out of a rule action command string. The expected form
|
|
16
|
+
* mirrors what the engine will eventually build: `devices command <id> <verb> [args...]`.
|
|
17
|
+
* We also accept scene shorthands (`scenes run <id>`, `webhooks delete <id>`).
|
|
18
|
+
*
|
|
19
|
+
* Returns null for anything we cannot confidently attribute to a known verb
|
|
20
|
+
* slot — the validator treats null as "probably fine, let the engine's own
|
|
21
|
+
* guard handle it if it's not."
|
|
22
|
+
*/
|
|
23
|
+
export function extractVerb(cmd) {
|
|
24
|
+
const trimmed = cmd.trim();
|
|
25
|
+
if (!trimmed)
|
|
26
|
+
return null;
|
|
27
|
+
const tokens = trimmed.split(/\s+/);
|
|
28
|
+
// `devices command <id> <verb> [args]`
|
|
29
|
+
if (tokens[0] === 'devices' && tokens[1] === 'command' && tokens.length >= 4) {
|
|
30
|
+
return tokens[3];
|
|
31
|
+
}
|
|
32
|
+
// `webhooks delete <id>` → verb is "deleteWebhook"
|
|
33
|
+
if (tokens[0] === 'webhooks' && tokens[1] === 'delete')
|
|
34
|
+
return 'deleteWebhook';
|
|
35
|
+
// `scenes delete <id>` → verb is "deleteScene"
|
|
36
|
+
if (tokens[0] === 'scenes' && tokens[1] === 'delete')
|
|
37
|
+
return 'deleteScene';
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
export function isDestructiveCommand(cmd) {
|
|
41
|
+
const verb = extractVerb(cmd);
|
|
42
|
+
if (!verb)
|
|
43
|
+
return false;
|
|
44
|
+
return DESTRUCTIVE_COMMANDS.includes(verb);
|
|
45
|
+
}
|
|
46
|
+
export function destructiveVerbOf(cmd) {
|
|
47
|
+
const verb = extractVerb(cmd);
|
|
48
|
+
if (verb && DESTRUCTIVE_COMMANDS.includes(verb)) {
|
|
49
|
+
return verb;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|