signalk-automation-plugin 0.0.1
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/LICENSE +15 -0
- package/README.md +76 -0
- package/examples/automations.yaml +98 -0
- package/examples/scripts/echo.sh +2 -0
- package/examples/scripts/fail.sh +3 -0
- package/lib/engine.js +352 -0
- package/lib/geo.js +32 -0
- package/lib/git.js +112 -0
- package/lib/helpers.js +60 -0
- package/lib/runtime.js +270 -0
- package/lib/scripts.js +120 -0
- package/lib/store.js +75 -0
- package/lib/units.js +40 -0
- package/lib/yaml.js +204 -0
- package/package.json +40 -0
- package/plugin/index.js +340 -0
- package/public/app.js +155 -0
- package/public/index.html +39 -0
- package/public/style.css +70 -0
package/lib/helpers.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
function coerce (def, value) {
|
|
4
|
+
if (!def) return value
|
|
5
|
+
if (def.type === 'boolean') {
|
|
6
|
+
if (value === true || value === 'true' || value === 1 || value === '1') return true
|
|
7
|
+
if (value === false || value === 'false' || value === 0 || value === '0') return false
|
|
8
|
+
return Boolean(value)
|
|
9
|
+
}
|
|
10
|
+
if (def.type === 'number') {
|
|
11
|
+
const n = Number(value)
|
|
12
|
+
if (!Number.isFinite(n)) throw new Error('Helper ' + def.id + ' expects a number')
|
|
13
|
+
return n
|
|
14
|
+
}
|
|
15
|
+
const s = value == null ? '' : String(value)
|
|
16
|
+
if (def.type === 'select' && def.options.length && !def.options.includes(s)) {
|
|
17
|
+
throw new Error('Helper ' + def.id + ' value not in options')
|
|
18
|
+
}
|
|
19
|
+
return s
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function initHelpers (defs, persisted) {
|
|
23
|
+
const values = {}
|
|
24
|
+
const saved = persisted && typeof persisted === 'object' ? persisted : {}
|
|
25
|
+
for (const [id, def] of Object.entries(defs || {})) {
|
|
26
|
+
const mode = def.on_start || 'none'
|
|
27
|
+
if (mode === 'restore') {
|
|
28
|
+
if (Object.prototype.hasOwnProperty.call(saved, id)) {
|
|
29
|
+
values[id] = coerce(def, saved[id])
|
|
30
|
+
} else if (def.default !== undefined) {
|
|
31
|
+
values[id] = coerce(def, def.default)
|
|
32
|
+
}
|
|
33
|
+
} else if (mode === 'default') {
|
|
34
|
+
if (def.default !== undefined) values[id] = coerce(def, def.default)
|
|
35
|
+
}
|
|
36
|
+
// none: leave unset
|
|
37
|
+
}
|
|
38
|
+
return values
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function persistableHelpers (defs, values) {
|
|
42
|
+
const out = {}
|
|
43
|
+
for (const [id, def] of Object.entries(defs || {})) {
|
|
44
|
+
if (def.on_start !== 'restore') continue
|
|
45
|
+
if (!Object.prototype.hasOwnProperty.call(values, id)) continue
|
|
46
|
+
out[id] = values[id]
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function helperPath (id) {
|
|
52
|
+
return 'automations.helpers.' + id
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
coerce,
|
|
57
|
+
initHelpers,
|
|
58
|
+
persistableHelpers,
|
|
59
|
+
helperPath
|
|
60
|
+
}
|
package/lib/runtime.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const yaml = require('./yaml')
|
|
6
|
+
const helpers = require('./helpers')
|
|
7
|
+
const store = require('./store')
|
|
8
|
+
const git = require('./git')
|
|
9
|
+
const engine = require('./engine')
|
|
10
|
+
|
|
11
|
+
class Runtime {
|
|
12
|
+
constructor (opts) {
|
|
13
|
+
this.app = opts.app
|
|
14
|
+
this.pluginId = opts.pluginId
|
|
15
|
+
this.dataDir = opts.dataDir
|
|
16
|
+
this.automationsDir = opts.automationsDir
|
|
17
|
+
this.scriptsDir = opts.scriptsDir
|
|
18
|
+
this.traceCount = opts.traceCount || 20
|
|
19
|
+
this.scriptTimeoutMs = (opts.scriptTimeoutSeconds || 30) * 1000
|
|
20
|
+
this.now = opts.now || (() => Date.now())
|
|
21
|
+
this.sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
|
|
22
|
+
this.putFn = opts.put
|
|
23
|
+
this.notifyFn = opts.notify
|
|
24
|
+
this.log = opts.log || { debug () {}, error () {} }
|
|
25
|
+
|
|
26
|
+
this.doc = yaml.emptyDoc()
|
|
27
|
+
this.state = store.emptyState()
|
|
28
|
+
this.values = {}
|
|
29
|
+
this.helperValues = {}
|
|
30
|
+
this.lastTruthy = {}
|
|
31
|
+
this.pendingSince = {}
|
|
32
|
+
this.lastDebounce = {}
|
|
33
|
+
this.lastCronMinute = ''
|
|
34
|
+
this.unsubscribes = []
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
load () {
|
|
38
|
+
this.state = store.loadState(this.dataDir)
|
|
39
|
+
const live = store.liveDir(this.dataDir)
|
|
40
|
+
const from = fs.existsSync(live) ? live : this.automationsDir
|
|
41
|
+
this.doc = yaml.loadYamlDir(from)
|
|
42
|
+
this.helperValues = helpers.initHelpers(this.doc.helpers, this.state.helpers)
|
|
43
|
+
this.persistHelpers()
|
|
44
|
+
for (const [id, value] of Object.entries(this.helperValues)) {
|
|
45
|
+
this.values[helpers.helperPath(id)] = value
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
persistHelpers () {
|
|
50
|
+
this.state.helpers = helpers.persistableHelpers(this.doc.helpers, this.helperValues)
|
|
51
|
+
store.saveState(this.dataDir, this.state)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
isEnabled (auto) {
|
|
55
|
+
if (Object.prototype.hasOwnProperty.call(this.state.enabled, auto.id)) {
|
|
56
|
+
return !!this.state.enabled[auto.id]
|
|
57
|
+
}
|
|
58
|
+
return auto.enabled !== false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setEnabled (id, enabled) {
|
|
62
|
+
this.state.enabled[id] = !!enabled
|
|
63
|
+
store.saveState(this.dataDir, this.state)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setHelper (id, value) {
|
|
67
|
+
const def = this.doc.helpers[id]
|
|
68
|
+
if (!def) throw new Error('Unknown helper: ' + id)
|
|
69
|
+
const coerced = helpers.coerce(def, value)
|
|
70
|
+
this.helperValues[id] = coerced
|
|
71
|
+
this.values[helpers.helperPath(id)] = coerced
|
|
72
|
+
this.persistHelpers()
|
|
73
|
+
return coerced
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
resetHelper (id) {
|
|
77
|
+
const def = this.doc.helpers[id]
|
|
78
|
+
if (!def) throw new Error('Unknown helper: ' + id)
|
|
79
|
+
if (def.default === undefined) {
|
|
80
|
+
delete this.helperValues[id]
|
|
81
|
+
delete this.values[helpers.helperPath(id)]
|
|
82
|
+
} else {
|
|
83
|
+
this.helperValues[id] = helpers.coerce(def, def.default)
|
|
84
|
+
this.values[helpers.helperPath(id)] = this.helperValues[id]
|
|
85
|
+
}
|
|
86
|
+
this.persistHelpers()
|
|
87
|
+
return this.helperValues[id]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
setPathValue (p, value, meta) {
|
|
91
|
+
this.values[p] = value
|
|
92
|
+
if (meta && meta.$source) {
|
|
93
|
+
this.values[p + '.$source'] = meta.$source
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async handlePathChange (p, value, source) {
|
|
98
|
+
this.setPathValue(p, value, { $source: source })
|
|
99
|
+
this.applyLatches(p, source)
|
|
100
|
+
for (const auto of this.doc.automations) {
|
|
101
|
+
if (!engine.triggerMatchesPath(auto, p)) continue
|
|
102
|
+
await this.maybeRun(auto, { path: p, value })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
applyLatches (p, source) {
|
|
107
|
+
const fromSelf = source && String(source).includes(this.pluginId)
|
|
108
|
+
if (fromSelf) return
|
|
109
|
+
for (const def of Object.values(this.doc.helpers)) {
|
|
110
|
+
if (def.latch_on_external_put === p && def.type === 'boolean') {
|
|
111
|
+
if (this.values[p]) this.setHelper(def.id, true)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async tickSchedule (date) {
|
|
117
|
+
const d = date || new Date(this.now())
|
|
118
|
+
const key = d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate() + '-' + d.getHours() + '-' + d.getMinutes()
|
|
119
|
+
if (key === this.lastCronMinute) return
|
|
120
|
+
this.lastCronMinute = key
|
|
121
|
+
for (const auto of this.doc.automations) {
|
|
122
|
+
const schedules = (auto.trigger || []).filter((t) => t.schedule)
|
|
123
|
+
if (!schedules.length) continue
|
|
124
|
+
const hit = schedules.some((t) => engine.cronMatch(t.schedule, d))
|
|
125
|
+
if (hit) await this.maybeRun(auto, { schedule: schedules[0].schedule })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async maybeRun (auto, trigger) {
|
|
130
|
+
if (!this.isEnabled(auto)) return
|
|
131
|
+
const ctx = this.makeCtx(trigger)
|
|
132
|
+
const trig = await engine.evalList(auto.trigger || [], ctx)
|
|
133
|
+
const matched = !(auto.trigger || []).length || trig.pass
|
|
134
|
+
|
|
135
|
+
if (!matched) {
|
|
136
|
+
this.lastTruthy[auto.id] = false
|
|
137
|
+
delete this.pendingSince[auto.id]
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const hold = holdMs(auto)
|
|
142
|
+
if (hold > 0) {
|
|
143
|
+
if (!this.pendingSince[auto.id]) this.pendingSince[auto.id] = this.now()
|
|
144
|
+
if (this.now() - this.pendingSince[auto.id] < hold) return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const prev = !!this.lastTruthy[auto.id]
|
|
148
|
+
const edge = auto.edge || 'rising'
|
|
149
|
+
if (edge === 'rising' && prev) return
|
|
150
|
+
if (edge === 'falling') return
|
|
151
|
+
this.lastTruthy[auto.id] = true
|
|
152
|
+
|
|
153
|
+
if (auto.debounceMs && this.lastDebounce[auto.id] && this.now() - this.lastDebounce[auto.id] < auto.debounceMs) {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.lastDebounce[auto.id] = this.now()
|
|
158
|
+
const record = await engine.evaluateAutomation(auto, this.makeCtx(trigger))
|
|
159
|
+
this.state.lastRun[auto.id] = {
|
|
160
|
+
ts: record.ts,
|
|
161
|
+
result: record.result,
|
|
162
|
+
reason: record.reason || null
|
|
163
|
+
}
|
|
164
|
+
store.saveState(this.dataDir, this.state)
|
|
165
|
+
store.appendTrace(this.dataDir, auto.id, record, this.traceCount)
|
|
166
|
+
return record
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
makeCtx (trigger) {
|
|
170
|
+
return {
|
|
171
|
+
values: this.values,
|
|
172
|
+
zones: this.doc.zones,
|
|
173
|
+
enabled: true,
|
|
174
|
+
trigger,
|
|
175
|
+
now: this.now(),
|
|
176
|
+
put: (p, value) => this.put(p, value),
|
|
177
|
+
notify: (p, state, message) => this.notify(p, state, message),
|
|
178
|
+
setHelper: (id, value) => this.setHelper(id, value),
|
|
179
|
+
sleep: this.sleep,
|
|
180
|
+
runScript: engine.makeRunScript({
|
|
181
|
+
scriptsDir: this.scriptsDir,
|
|
182
|
+
timeoutMs: this.scriptTimeoutMs
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async put (p, value) {
|
|
188
|
+
this.values[p] = value
|
|
189
|
+
if (this.putFn) return this.putFn(p, value)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async notify (p, state, message) {
|
|
193
|
+
if (this.notifyFn) return this.notifyFn(p, state, message)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
snapshot () {
|
|
197
|
+
return {
|
|
198
|
+
started: true,
|
|
199
|
+
activeSha: this.state.activeSha,
|
|
200
|
+
automationsDir: this.automationsDir || null,
|
|
201
|
+
errors: this.doc.errors,
|
|
202
|
+
files: this.doc.files,
|
|
203
|
+
yaml: yaml.combinedYaml(this.doc),
|
|
204
|
+
helpers: Object.values(this.doc.helpers).map((def) => ({
|
|
205
|
+
id: def.id,
|
|
206
|
+
name: def.name,
|
|
207
|
+
type: def.type,
|
|
208
|
+
on_start: def.on_start,
|
|
209
|
+
default: def.default,
|
|
210
|
+
options: def.options,
|
|
211
|
+
value: Object.prototype.hasOwnProperty.call(this.helperValues, def.id)
|
|
212
|
+
? this.helperValues[def.id]
|
|
213
|
+
: null,
|
|
214
|
+
set: Object.prototype.hasOwnProperty.call(this.helperValues, def.id)
|
|
215
|
+
})),
|
|
216
|
+
automations: this.doc.automations.map((auto) => ({
|
|
217
|
+
id: auto.id,
|
|
218
|
+
alias: auto.alias,
|
|
219
|
+
enabled: this.isEnabled(auto),
|
|
220
|
+
yamlEnabled: auto.enabled !== false,
|
|
221
|
+
lastRun: this.state.lastRun[auto.id] || null
|
|
222
|
+
})),
|
|
223
|
+
zones: Object.values(this.doc.zones),
|
|
224
|
+
commits: git.isGitRepo(this.automationsDir)
|
|
225
|
+
? git.listCommits(this.automationsDir, 40)
|
|
226
|
+
: []
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
traces (id) {
|
|
231
|
+
return store.loadTraces(this.dataDir, id)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
activateCommit (sha) {
|
|
235
|
+
const dest = store.liveDir(this.dataDir)
|
|
236
|
+
const incoming = dest + '.incoming'
|
|
237
|
+
if (fs.existsSync(incoming)) fs.rmSync(incoming, { recursive: true, force: true })
|
|
238
|
+
git.materializeCommit(this.automationsDir, sha, incoming)
|
|
239
|
+
const next = yaml.loadYamlDir(incoming)
|
|
240
|
+
if (next.errors.length) {
|
|
241
|
+
fs.rmSync(incoming, { recursive: true, force: true })
|
|
242
|
+
const err = next.errors.map((e) => e.file + ': ' + e.message).join('; ')
|
|
243
|
+
throw new Error('YAML invalid, live not changed: ' + err)
|
|
244
|
+
}
|
|
245
|
+
const bak = dest + '.bak'
|
|
246
|
+
if (fs.existsSync(bak)) fs.rmSync(bak, { recursive: true, force: true })
|
|
247
|
+
if (fs.existsSync(dest)) fs.renameSync(dest, bak)
|
|
248
|
+
fs.renameSync(incoming, dest)
|
|
249
|
+
if (fs.existsSync(bak)) fs.rmSync(bak, { recursive: true, force: true })
|
|
250
|
+
this.state.activeSha = sha
|
|
251
|
+
store.saveState(this.dataDir, this.state)
|
|
252
|
+
this.load()
|
|
253
|
+
return this.snapshot()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function holdMs (auto) {
|
|
258
|
+
for (const t of auto.trigger || []) {
|
|
259
|
+
if (t.for) {
|
|
260
|
+
try {
|
|
261
|
+
return engine.parseDuration(t.for)
|
|
262
|
+
} catch (_) {
|
|
263
|
+
return 0
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return 0
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = { Runtime }
|
package/lib/scripts.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const { execFile } = require('child_process')
|
|
6
|
+
|
|
7
|
+
function resolveScript (scriptsDir, file) {
|
|
8
|
+
if (!scriptsDir) {
|
|
9
|
+
throw new Error('scriptsDir is not configured')
|
|
10
|
+
}
|
|
11
|
+
if (!file || typeof file !== 'string') {
|
|
12
|
+
throw new Error('Script file is required')
|
|
13
|
+
}
|
|
14
|
+
if (path.isAbsolute(file)) {
|
|
15
|
+
throw new Error('Script path must be relative to scriptsDir')
|
|
16
|
+
}
|
|
17
|
+
const root = path.resolve(scriptsDir)
|
|
18
|
+
const resolved = path.resolve(root, file)
|
|
19
|
+
const rel = path.relative(root, resolved)
|
|
20
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
21
|
+
throw new Error('Script path escapes scriptsDir')
|
|
22
|
+
}
|
|
23
|
+
if (!fs.existsSync(resolved)) {
|
|
24
|
+
throw new Error('Script not found: ' + file)
|
|
25
|
+
}
|
|
26
|
+
return resolved
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function interpreterFor (scriptPath) {
|
|
30
|
+
const ext = path.extname(scriptPath).toLowerCase()
|
|
31
|
+
if (ext === '.py') return { cmd: 'python3', args: [scriptPath] }
|
|
32
|
+
if (ext === '.sh') return { cmd: 'bash', args: [scriptPath] }
|
|
33
|
+
return { cmd: scriptPath, args: [] }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseOutput (stdout) {
|
|
37
|
+
const text = String(stdout || '').trim()
|
|
38
|
+
if (!text) return { stdout: '', value: '' }
|
|
39
|
+
if (
|
|
40
|
+
(text.startsWith('{') && text.endsWith('}')) ||
|
|
41
|
+
(text.startsWith('[') && text.endsWith(']'))
|
|
42
|
+
) {
|
|
43
|
+
try {
|
|
44
|
+
return { stdout: text, value: JSON.parse(text) }
|
|
45
|
+
} catch (_) {
|
|
46
|
+
/* fall through */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (text === 'true') return { stdout: text, value: true }
|
|
50
|
+
if (text === 'false') return { stdout: text, value: false }
|
|
51
|
+
const n = Number(text)
|
|
52
|
+
if (text !== '' && Number.isFinite(n)) return { stdout: text, value: n }
|
|
53
|
+
return { stdout: text, value: text }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function runScript (opts) {
|
|
57
|
+
const scriptsDir = opts.scriptsDir
|
|
58
|
+
const file = opts.file
|
|
59
|
+
const extraArgs = Array.isArray(opts.args) ? opts.args.map(String) : []
|
|
60
|
+
const timeout = opts.timeoutMs || 30000
|
|
61
|
+
const env = Object.assign({}, process.env, opts.env || {})
|
|
62
|
+
const exec = opts.execFile || execFile
|
|
63
|
+
|
|
64
|
+
let resolved
|
|
65
|
+
try {
|
|
66
|
+
resolved = resolveScript(scriptsDir, file)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return Promise.resolve({
|
|
69
|
+
ok: false,
|
|
70
|
+
error: err.message,
|
|
71
|
+
stdout: '',
|
|
72
|
+
stderr: '',
|
|
73
|
+
value: null
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const interp = interpreterFor(resolved)
|
|
78
|
+
const args = interp.args.concat(extraArgs)
|
|
79
|
+
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
exec(
|
|
82
|
+
interp.cmd,
|
|
83
|
+
args,
|
|
84
|
+
{ cwd: path.resolve(scriptsDir), timeout, env, maxBuffer: 1024 * 1024 },
|
|
85
|
+
(err, stdout, stderr) => {
|
|
86
|
+
const errText = String(stderr || '').trim()
|
|
87
|
+
if (errText) {
|
|
88
|
+
resolve({
|
|
89
|
+
ok: false,
|
|
90
|
+
error: 'stderr: ' + errText,
|
|
91
|
+
stdout: String(stdout || ''),
|
|
92
|
+
stderr: errText,
|
|
93
|
+
value: null
|
|
94
|
+
})
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if (err) {
|
|
98
|
+
resolve({
|
|
99
|
+
ok: false,
|
|
100
|
+
error: err.killed ? 'Script timed out' : err.message,
|
|
101
|
+
stdout: String(stdout || ''),
|
|
102
|
+
stderr: errText,
|
|
103
|
+
value: null
|
|
104
|
+
})
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
const parsed = parseOutput(stdout)
|
|
108
|
+
resolve({
|
|
109
|
+
ok: true,
|
|
110
|
+
error: null,
|
|
111
|
+
stdout: parsed.stdout,
|
|
112
|
+
stderr: '',
|
|
113
|
+
value: parsed.value
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { resolveScript, interpreterFor, parseOutput, runScript }
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
function emptyState () {
|
|
7
|
+
return {
|
|
8
|
+
helpers: {},
|
|
9
|
+
enabled: {},
|
|
10
|
+
activeSha: null,
|
|
11
|
+
lastRun: {}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function loadState (dir) {
|
|
16
|
+
const file = path.join(dir, 'state.json')
|
|
17
|
+
try {
|
|
18
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
19
|
+
return Object.assign(emptyState(), raw)
|
|
20
|
+
} catch (_) {
|
|
21
|
+
return emptyState()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function saveState (dir, state) {
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
27
|
+
const tmp = path.join(dir, 'state.json.tmp')
|
|
28
|
+
const file = path.join(dir, 'state.json')
|
|
29
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n')
|
|
30
|
+
fs.renameSync(tmp, file)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function tracesPath (dir, id) {
|
|
34
|
+
return path.join(dir, 'traces', safeId(id) + '.json')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadTraces (dir, id) {
|
|
38
|
+
try {
|
|
39
|
+
const data = JSON.parse(fs.readFileSync(tracesPath(dir, id), 'utf8'))
|
|
40
|
+
return Array.isArray(data) ? data : []
|
|
41
|
+
} catch (_) {
|
|
42
|
+
return []
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function appendTrace (dir, id, record, max) {
|
|
47
|
+
const cap = max > 0 ? max : 20
|
|
48
|
+
const list = loadTraces(dir, id)
|
|
49
|
+
list.unshift(record)
|
|
50
|
+
while (list.length > cap) list.pop()
|
|
51
|
+
const folder = path.join(dir, 'traces')
|
|
52
|
+
fs.mkdirSync(folder, { recursive: true })
|
|
53
|
+
const tmp = tracesPath(dir, id) + '.tmp'
|
|
54
|
+
fs.writeFileSync(tmp, JSON.stringify(list, null, 2) + '\n')
|
|
55
|
+
fs.renameSync(tmp, tracesPath(dir, id))
|
|
56
|
+
return list
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function liveDir (dir) {
|
|
60
|
+
return path.join(dir, 'live')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function safeId (id) {
|
|
64
|
+
return String(id || 'unknown').replace(/[^A-Za-z0-9._-]+/g, '_')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
emptyState,
|
|
69
|
+
loadState,
|
|
70
|
+
saveState,
|
|
71
|
+
loadTraces,
|
|
72
|
+
appendTrace,
|
|
73
|
+
liveDir,
|
|
74
|
+
tracesPath
|
|
75
|
+
}
|
package/lib/units.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const TO_SI = {
|
|
4
|
+
m: 1,
|
|
5
|
+
km: 1000,
|
|
6
|
+
nm: 1852,
|
|
7
|
+
kn: 0.514444,
|
|
8
|
+
kt: 0.514444,
|
|
9
|
+
'%': 0.01,
|
|
10
|
+
percent: 0.01,
|
|
11
|
+
s: 1,
|
|
12
|
+
min: 60,
|
|
13
|
+
h: 3600
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function toSi (value, unit) {
|
|
17
|
+
if (unit == null || unit === '') return value
|
|
18
|
+
const key = String(unit).trim()
|
|
19
|
+
const factor = TO_SI[key] != null ? TO_SI[key] : TO_SI[key.toLowerCase()]
|
|
20
|
+
if (factor == null) {
|
|
21
|
+
throw new Error('Unknown unit: ' + unit)
|
|
22
|
+
}
|
|
23
|
+
return Number(value) * factor
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseDuration (raw) {
|
|
27
|
+
if (raw == null) return 0
|
|
28
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
|
|
29
|
+
const s = String(raw).trim()
|
|
30
|
+
const m = s.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i)
|
|
31
|
+
if (!m) throw new Error('Invalid duration: ' + raw)
|
|
32
|
+
const n = Number(m[1])
|
|
33
|
+
const unit = (m[2] || 'ms').toLowerCase()
|
|
34
|
+
if (unit === 'ms') return n
|
|
35
|
+
if (unit === 's') return n * 1000
|
|
36
|
+
if (unit === 'm') return n * 60 * 1000
|
|
37
|
+
return n * 60 * 60 * 1000
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { toSi, parseDuration }
|