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 ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Hans Tool
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL,
12
+ DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER
13
+ RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF
14
+ CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
15
+ CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # signalk-automation-plugin
2
+
3
+ YAML automations for [Signal K](https://signalk.org), in the spirit of Home Assistant: files you can git, helpers with restart behaviour, and a small webapp for last-run / decision records.
4
+
5
+ Not Node-RED. Not a Home Assistant YAML clone. Actions are Signal K PUTs, helper writes, notifications, delays, and optional scripts.
6
+
7
+ ## In
8
+
9
+ - YAML in an automations directory (preferably a git repo). Live snapshot lives in plugin data, not plugin options.
10
+ - Helpers (`boolean` / `number` / `string` / `select`) with `on_start`:
11
+ - `restore` — survive reboot (save in plugin data, restore on start)
12
+ - `default` — set `default` on every start
13
+ - `none` — do nothing on start
14
+ - Path triggers, `choose`, zones, cron `schedule`
15
+ - Actions: `put`, `helper`, `notify`, `delay`, `run`
16
+ - `run:` executes a file under configured `scriptsDir`. **stdout is the value; any stderr is fail**
17
+ - Webapp: per-automation on/off, last result, last N decision records, live YAML, activate another git commit
18
+
19
+ ## Out
20
+
21
+ - Node-RED flows
22
+ - HA templates / Jinja
23
+ - Arbitrary shell strings (only files under `scriptsDir`, no `..`)
24
+
25
+ ## Plugin config
26
+
27
+ | Option | Meaning |
28
+ | --- | --- |
29
+ | Automations git / YAML directory | Folder with `*.yaml` at the top level. Git repo (or a subdir of one) → commit list in the webapp; “make live” archives only this folder |
30
+ | Allowed scripts directory | Only relative `run.file` paths under this dir |
31
+ | Decision records to keep | Ring buffer per automation |
32
+ | Script timeout | Seconds |
33
+
34
+ Do not paste automations into the admin form. Runtime state is `~/.signalk/plugin-config-data/signalk-automation-plugin/` (`state.json`, `live/`, `traces/`).
35
+
36
+ ## Example
37
+
38
+ See `examples/automations.yaml`. Copy or point `automationsDir` at a repo of your own; bundled examples are `enabled: false`.
39
+
40
+ ```yaml
41
+ helpers:
42
+ starlink_manual:
43
+ type: boolean
44
+ default: false
45
+ on_start: restore # survive reboot
46
+ latch_on_external_put: electrical.switches.starlink.state
47
+
48
+ automations:
49
+ - id: start_anchorwatch
50
+ trigger:
51
+ - path: winches.windlass.rode
52
+ above: 2
53
+ unit: m
54
+ for: 10s
55
+ action:
56
+ - put: navigation.anchor.watch
57
+ value: true
58
+ ```
59
+
60
+ Script action:
61
+
62
+ ```yaml
63
+ - run:
64
+ file: check-harbour.py
65
+ as: harbour_ok # optional helper id to store stdout
66
+ ```
67
+
68
+ ## Webapp
69
+
70
+ Webapps → **Automations**. Reads go through `/signalk/v1/api/signalk-automation-plugin/` (SK 2.x). Writes (on/off, make live, helper reset) use `/plugins/signalk-automation-plugin/` and need an admin session.
71
+
72
+ ## Auto-publish
73
+
74
+ GitHub Action `.github/workflows/release.yml` patch-bumps and publishes to npm at most once per UTC day when `plugin/`, `lib/`, or `public/` changed. Trusted Publisher (OIDC), workflow filename **must stay** `release.yml`.
75
+
76
+ The first npm version cannot use OIDC. Publish `0.0.1` once from a logged-in machine, then add the trusted publisher for `htool/signalk-automation-plugin` / `release.yml`.
@@ -0,0 +1,98 @@
1
+ helpers:
2
+ starlink_manual:
3
+ type: boolean
4
+ name: Starlink handmatig
5
+ default: false
6
+ on_start: restore
7
+ latch_on_external_put: electrical.switches.starlink.state
8
+ charge_hold:
9
+ type: boolean
10
+ name: Thuis 60–80% laden
11
+ default: true
12
+ on_start: default
13
+
14
+ zones:
15
+ home_harbour:
16
+ name: Thuishaven
17
+ lat: 52.37
18
+ lon: 5.22
19
+ radius: 150
20
+
21
+ automations:
22
+ - id: start_anchorwatch
23
+ alias: Ketting uit → anchorwatch
24
+ enabled: false
25
+ trigger:
26
+ - path: winches.windlass.rode
27
+ above: 2
28
+ unit: m
29
+ for: 10s
30
+ action:
31
+ - put: navigation.anchor.watch
32
+ value: true
33
+
34
+ - id: shore_charge
35
+ alias: Walstroom laadbeleid
36
+ enabled: false
37
+ trigger:
38
+ - path: electrical.chargers.shore.connected
39
+ - path: electrical.batteries.house.capacity.stateOfCharge
40
+ - path: navigation.position
41
+ choose:
42
+ - alias: onderweg volladen
43
+ conditions:
44
+ - path: electrical.chargers.shore.connected
45
+ is: true
46
+ - not:
47
+ zone: home_harbour
48
+ action:
49
+ - put: electrical.switches.dolphinCharger.state
50
+ value: 1
51
+ - alias: thuis onder 60%
52
+ conditions:
53
+ - path: electrical.chargers.shore.connected
54
+ is: true
55
+ - zone: home_harbour
56
+ - path: electrical.batteries.house.capacity.stateOfCharge
57
+ below: 0.6
58
+ action:
59
+ - put: electrical.switches.dolphinCharger.state
60
+ value: 1
61
+ - alias: thuis boven 80%
62
+ conditions:
63
+ - zone: home_harbour
64
+ - path: electrical.batteries.house.capacity.stateOfCharge
65
+ above: 0.8
66
+ action:
67
+ - put: electrical.switches.dolphinCharger.state
68
+ value: 0
69
+
70
+ - id: starlink_standby
71
+ alias: Starlink standby-update
72
+ enabled: false
73
+ trigger:
74
+ - schedule: "0 4 * * *"
75
+ condition:
76
+ - helper: starlink_manual
77
+ is: false
78
+ action:
79
+ - put: electrical.switches.starlink.state
80
+ value: true
81
+ - delay: 15m
82
+ - put: electrical.switches.starlink.state
83
+ value: false
84
+
85
+ - id: harbour_via_script
86
+ alias: Voorbeeld script-conditie
87
+ enabled: false
88
+ trigger:
89
+ - path: electrical.chargers.shore.connected
90
+ is: true
91
+ condition:
92
+ - run: echo.sh
93
+ equals: home
94
+ action:
95
+ - notify:
96
+ path: electrical.chargers.shore
97
+ state: normal
98
+ message: script said home
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ printf '%s\n' home
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ printf '%s\n' nope >&2
3
+ exit 1
package/lib/engine.js ADDED
@@ -0,0 +1,352 @@
1
+ 'use strict'
2
+
3
+ const { toSi, parseDuration } = require('./units')
4
+ const { inZone } = require('./geo')
5
+ const { runScript } = require('./scripts')
6
+ const { helperPath } = require('./helpers')
7
+
8
+ function collectPaths (doc) {
9
+ const paths = new Set()
10
+ for (const auto of doc.automations || []) {
11
+ for (const t of auto.trigger || []) addPathsFromClause(paths, t)
12
+ for (const c of auto.condition || []) addPathsFromClause(paths, c)
13
+ for (const branch of auto.choose || []) {
14
+ for (const c of branch.conditions || []) addPathsFromClause(paths, c)
15
+ }
16
+ if (auto.latch_on_external_put) paths.add(auto.latch_on_external_put)
17
+ }
18
+ for (const def of Object.values(doc.helpers || {})) {
19
+ if (def.latch_on_external_put) paths.add(def.latch_on_external_put)
20
+ }
21
+ return Array.from(paths)
22
+ }
23
+
24
+ function addPathsFromClause (paths, clause) {
25
+ if (!clause || typeof clause !== 'object') return
26
+ if (clause.path) paths.add(clause.path)
27
+ if (clause.zone || clause.not && clause.not.zone) paths.add('navigation.position')
28
+ if (clause.helper) paths.add(helperPath(clause.helper))
29
+ if (clause.not) addPathsFromClause(paths, clause.not)
30
+ if (Array.isArray(clause.all)) clause.all.forEach((c) => addPathsFromClause(paths, c))
31
+ if (Array.isArray(clause.any)) clause.any.forEach((c) => addPathsFromClause(paths, c))
32
+ }
33
+
34
+ function getByPath (values, p) {
35
+ if (Object.prototype.hasOwnProperty.call(values, p)) return values[p]
36
+ return undefined
37
+ }
38
+
39
+ function compareNumber (actual, op, expected, unit) {
40
+ const a = Number(actual)
41
+ const b = toSi(expected, unit)
42
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return false
43
+ if (op === 'above' || op === 'gt') return a > b
44
+ if (op === 'below' || op === 'lt') return a < b
45
+ if (op === 'gte') return a >= b
46
+ if (op === 'lte') return a <= b
47
+ return a === b
48
+ }
49
+
50
+ function evalClause (clause, ctx) {
51
+ if (!clause || typeof clause !== 'object') {
52
+ return { pass: false, error: 'empty clause', actual: null }
53
+ }
54
+ if (clause.not) {
55
+ const inner = evalClause(clause.not, ctx)
56
+ return {
57
+ pass: inner.error ? false : !inner.pass,
58
+ error: inner.error,
59
+ actual: inner.actual,
60
+ clause: { not: true }
61
+ }
62
+ }
63
+ if (Array.isArray(clause.all)) {
64
+ const parts = clause.all.map((c) => evalClause(c, ctx))
65
+ return {
66
+ pass: parts.every((p) => p.pass),
67
+ error: parts.find((p) => p.error)?.error || null,
68
+ actual: parts,
69
+ clause: { all: true }
70
+ }
71
+ }
72
+ if (Array.isArray(clause.any)) {
73
+ const parts = clause.any.map((c) => evalClause(c, ctx))
74
+ return {
75
+ pass: parts.some((p) => p.pass),
76
+ error: parts.find((p) => p.error)?.error || null,
77
+ actual: parts,
78
+ clause: { any: true }
79
+ }
80
+ }
81
+ if (clause.zone) {
82
+ const pos = getByPath(ctx.values, 'navigation.position')
83
+ const zone = ctx.zones[clause.zone]
84
+ const pass = zone ? inZone(pos, zone) : false
85
+ return {
86
+ pass,
87
+ error: zone ? null : 'Unknown zone: ' + clause.zone,
88
+ actual: pos,
89
+ path: 'navigation.position',
90
+ expected: clause.zone
91
+ }
92
+ }
93
+ if (clause.run) {
94
+ return {
95
+ async: true,
96
+ run: typeof clause.run === 'string' ? { file: clause.run } : clause.run,
97
+ equals: clause.equals,
98
+ is: clause.is
99
+ }
100
+ }
101
+ if (clause.schedule) {
102
+ const when = ctx.now ? new Date(ctx.now) : new Date()
103
+ const fromTick = ctx.trigger && ctx.trigger.schedule
104
+ const pass = fromTick ? cronMatch(clause.schedule, when) : false
105
+ return {
106
+ pass,
107
+ error: null,
108
+ actual: clause.schedule,
109
+ expected: fromTick ? ctx.trigger.schedule : 'schedule tick'
110
+ }
111
+ }
112
+ const path = clause.helper ? helperPath(clause.helper) : clause.path
113
+ if (!path) {
114
+ return { pass: false, error: 'clause needs path, helper, zone, or run', actual: null }
115
+ }
116
+ const actual = getByPath(ctx.values, path)
117
+ let pass = false
118
+ if (Object.prototype.hasOwnProperty.call(clause, 'is')) {
119
+ pass = actual === clause.is
120
+ } else if (Object.prototype.hasOwnProperty.call(clause, 'equals')) {
121
+ pass = actual === clause.equals || String(actual) === String(clause.equals)
122
+ } else if (Object.prototype.hasOwnProperty.call(clause, 'above')) {
123
+ pass = compareNumber(actual, 'above', clause.above, clause.unit)
124
+ } else if (Object.prototype.hasOwnProperty.call(clause, 'below')) {
125
+ pass = compareNumber(actual, 'below', clause.below, clause.unit)
126
+ } else if (Object.prototype.hasOwnProperty.call(clause, 'to')) {
127
+ pass = actual === clause.to
128
+ } else {
129
+ pass = actual !== undefined
130
+ }
131
+ return { pass, error: null, actual, path, expected: expectedOf(clause) }
132
+ }
133
+
134
+ function expectedOf (clause) {
135
+ if (clause.is !== undefined) return clause.is
136
+ if (clause.equals !== undefined) return clause.equals
137
+ if (clause.above !== undefined) return { above: clause.above, unit: clause.unit }
138
+ if (clause.below !== undefined) return { below: clause.below, unit: clause.unit }
139
+ if (clause.to !== undefined) return clause.to
140
+ return true
141
+ }
142
+
143
+ async function evalClauseAsync (clause, ctx) {
144
+ const first = evalClause(clause, ctx)
145
+ if (!first.async) return first
146
+ const ran = await ctx.runScript(first.run)
147
+ if (!ran.ok) {
148
+ return {
149
+ pass: false,
150
+ error: ran.error,
151
+ actual: ran.stdout,
152
+ run: first.run.file || first.run
153
+ }
154
+ }
155
+ let pass = true
156
+ if (first.equals !== undefined) pass = ran.value === first.equals || String(ran.value) === String(first.equals)
157
+ else if (first.is !== undefined) pass = ran.value === first.is
158
+ else pass = Boolean(ran.value)
159
+ return {
160
+ pass,
161
+ error: null,
162
+ actual: ran.value,
163
+ run: first.run.file || first.run
164
+ }
165
+ }
166
+
167
+ async function evalList (list, ctx) {
168
+ const results = []
169
+ for (const clause of list || []) {
170
+ const r = await evalClauseAsync(clause, ctx)
171
+ results.push(r)
172
+ if (!r.pass) return { pass: false, results }
173
+ }
174
+ return { pass: true, results }
175
+ }
176
+
177
+ function triggerMatchesPath (auto, changedPath) {
178
+ if (!changedPath) return true
179
+ const paths = new Set()
180
+ for (const t of auto.trigger || []) addPathsFromClause(paths, t)
181
+ const hasSchedule = (auto.trigger || []).some((t) => t.schedule)
182
+ if (paths.size === 0) return !hasSchedule
183
+ return paths.has(changedPath)
184
+ }
185
+
186
+ function cronMatch (expr, date) {
187
+ if (!expr) return false
188
+ const parts = String(expr).trim().split(/\s+/)
189
+ if (parts.length < 5) return false
190
+ const [min, hour, dom, mon, dow] = parts
191
+ return (
192
+ fieldMatch(min, date.getMinutes()) &&
193
+ fieldMatch(hour, date.getHours()) &&
194
+ fieldMatch(dom, date.getDate()) &&
195
+ fieldMatch(mon, date.getMonth() + 1) &&
196
+ fieldMatch(dow, date.getDay())
197
+ )
198
+ }
199
+
200
+ function fieldMatch (field, value) {
201
+ if (field === '*') return true
202
+ return String(field)
203
+ .split(',')
204
+ .some((bit) => {
205
+ if (bit.includes('/')) {
206
+ const [range, step] = bit.split('/')
207
+ const st = Number(step)
208
+ if (!Number.isFinite(st) || st <= 0) return false
209
+ if (range === '*') return value % st === 0
210
+ }
211
+ if (bit.includes('-')) {
212
+ const [a, b] = bit.split('-').map(Number)
213
+ return value >= a && value <= b
214
+ }
215
+ return Number(bit) === value
216
+ })
217
+ }
218
+
219
+ async function evaluateAutomation (auto, ctx) {
220
+ const record = {
221
+ ts: new Date(ctx.now || Date.now()).toISOString(),
222
+ id: auto.id,
223
+ result: 'skipped',
224
+ trigger: ctx.trigger || null,
225
+ conditions: [],
226
+ choose: null,
227
+ actions: []
228
+ }
229
+ if (!ctx.enabled) {
230
+ record.reason = 'disabled'
231
+ return record
232
+ }
233
+
234
+ const trig = await evalList(auto.trigger || [], ctx)
235
+ record.triggerResults = trig.results
236
+ if ((auto.trigger || []).length && !trig.pass) {
237
+ record.reason = 'trigger not matched'
238
+ return record
239
+ }
240
+
241
+ const cond = await evalList(auto.condition || [], ctx)
242
+ record.conditions = cond.results
243
+ if ((auto.condition || []).length && !cond.pass) {
244
+ const failed = cond.results.find((r) => r.error)
245
+ record.result = failed ? 'failure' : 'skipped'
246
+ record.reason = failed ? failed.error : 'condition not met'
247
+ return record
248
+ }
249
+
250
+ let actions = auto.action || []
251
+ if (auto.choose && auto.choose.length) {
252
+ let picked = null
253
+ for (const branch of auto.choose) {
254
+ const br = await evalList(branch.conditions || [], ctx)
255
+ if (br.pass) {
256
+ picked = branch
257
+ record.choose = branch.id || branch.alias || 'branch'
258
+ actions = branch.action || []
259
+ break
260
+ }
261
+ }
262
+ if (!picked) {
263
+ record.reason = 'no matching choose branch'
264
+ return record
265
+ }
266
+ }
267
+
268
+ for (const action of actions) {
269
+ const ran = await runAction(action, ctx)
270
+ record.actions.push(ran)
271
+ if (!ran.ok) {
272
+ record.result = 'failure'
273
+ record.reason = ran.error
274
+ return record
275
+ }
276
+ }
277
+ record.result = 'ok'
278
+ return record
279
+ }
280
+
281
+ async function runAction (action, ctx) {
282
+ if (!action || typeof action !== 'object') {
283
+ return { ok: false, error: 'empty action' }
284
+ }
285
+ if (action.put) {
286
+ try {
287
+ await ctx.put(action.put, action.value)
288
+ return { ok: true, type: 'put', path: action.put, value: action.value }
289
+ } catch (err) {
290
+ return { ok: false, type: 'put', path: action.put, error: err.message }
291
+ }
292
+ }
293
+ if (action.helper) {
294
+ try {
295
+ ctx.setHelper(action.helper, action.value)
296
+ return { ok: true, type: 'helper', helper: action.helper, value: action.value }
297
+ } catch (err) {
298
+ return { ok: false, type: 'helper', helper: action.helper, error: err.message }
299
+ }
300
+ }
301
+ if (action.notify) {
302
+ const n = action.notify
303
+ try {
304
+ await ctx.notify(n.path, n.state || 'alert', n.message || '')
305
+ return { ok: true, type: 'notify', path: n.path, state: n.state }
306
+ } catch (err) {
307
+ return { ok: false, type: 'notify', error: err.message }
308
+ }
309
+ }
310
+ if (action.delay != null) {
311
+ const ms = parseDuration(action.delay)
312
+ await ctx.sleep(ms)
313
+ return { ok: true, type: 'delay', ms }
314
+ }
315
+ if (action.run) {
316
+ const spec = typeof action.run === 'string' ? { file: action.run } : action.run
317
+ const ran = await ctx.runScript(spec)
318
+ if (!ran.ok) {
319
+ return { ok: false, type: 'run', file: spec.file, error: ran.error, stdout: ran.stdout }
320
+ }
321
+ if (action.as) {
322
+ ctx.setHelper(action.as, ran.value)
323
+ }
324
+ return { ok: true, type: 'run', file: spec.file, value: ran.value, stdout: ran.stdout }
325
+ }
326
+ return { ok: false, error: 'unknown action' }
327
+ }
328
+
329
+ function makeRunScript (opts) {
330
+ return function (spec) {
331
+ return runScript({
332
+ scriptsDir: opts.scriptsDir,
333
+ file: spec.file,
334
+ args: spec.args,
335
+ timeoutMs: opts.timeoutMs,
336
+ env: spec.env
337
+ })
338
+ }
339
+ }
340
+
341
+ module.exports = {
342
+ collectPaths,
343
+ evalClause,
344
+ evalClauseAsync,
345
+ evalList,
346
+ evaluateAutomation,
347
+ triggerMatchesPath,
348
+ cronMatch,
349
+ runAction,
350
+ makeRunScript,
351
+ parseDuration
352
+ }
package/lib/geo.js ADDED
@@ -0,0 +1,32 @@
1
+ 'use strict'
2
+
3
+ const EARTH_M = 6371000
4
+
5
+ function haversineM (a, b) {
6
+ const lat1 = toRad(a.lat)
7
+ const lat2 = toRad(b.lat)
8
+ const dLat = toRad(b.lat - a.lat)
9
+ const dLon = toRad(b.lon - a.lon)
10
+ const h =
11
+ Math.sin(dLat / 2) ** 2 +
12
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2
13
+ return 2 * EARTH_M * Math.asin(Math.min(1, Math.sqrt(h)))
14
+ }
15
+
16
+ function inZone (position, zone) {
17
+ if (!position || !zone) return false
18
+ if (!Number.isFinite(position.latitude) || !Number.isFinite(position.longitude)) {
19
+ return false
20
+ }
21
+ const d = haversineM(
22
+ { lat: position.latitude, lon: position.longitude },
23
+ { lat: zone.lat, lon: zone.lon }
24
+ )
25
+ return d <= Number(zone.radius)
26
+ }
27
+
28
+ function toRad (deg) {
29
+ return (Number(deg) * Math.PI) / 180
30
+ }
31
+
32
+ module.exports = { haversineM, inZone }
package/lib/git.js ADDED
@@ -0,0 +1,112 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { spawnSync } = require('child_process')
6
+
7
+ function runGit (args, cwd) {
8
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
9
+ if (r.status !== 0) {
10
+ const err = (r.stderr || r.stdout || '').trim()
11
+ throw new Error('git ' + args.join(' ') + ' failed: ' + err)
12
+ }
13
+ return (r.stdout || '').trim()
14
+ }
15
+
16
+ function isGitRepo (dir) {
17
+ if (!dir || !fs.existsSync(dir)) return false
18
+ try {
19
+ runGit(['rev-parse', '--is-inside-work-tree'], dir)
20
+ return true
21
+ } catch (_) {
22
+ return false
23
+ }
24
+ }
25
+
26
+ function listCommits (dir, limit) {
27
+ if (!isGitRepo(dir)) return []
28
+ const n = limit > 0 ? limit : 30
29
+ const out = runGit(
30
+ ['log', '-n', String(n), '--pretty=format:%H%x09%cI%x09%s'],
31
+ dir
32
+ )
33
+ if (!out) return []
34
+ return out.split('\n').map((line) => {
35
+ const [sha, date, ...rest] = line.split('\t')
36
+ return { sha, date, subject: rest.join('\t') }
37
+ })
38
+ }
39
+
40
+ function currentSha (dir) {
41
+ if (!isGitRepo(dir)) return null
42
+ try {
43
+ return runGit(['rev-parse', 'HEAD'], dir)
44
+ } catch (_) {
45
+ return null
46
+ }
47
+ }
48
+
49
+ function archiveTreeish (repoDir, sha) {
50
+ const root = path.resolve(runGit(['rev-parse', '--show-toplevel'], repoDir))
51
+ const dir = path.resolve(repoDir)
52
+ const rel = path.relative(root, dir).replace(/\\/g, '/')
53
+ if (!rel || rel === '.') return sha
54
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
55
+ throw new Error('automationsDir is outside the git repository')
56
+ }
57
+ return sha + ':' + rel
58
+ }
59
+
60
+ function materializeCommit (repoDir, sha, destDir) {
61
+ if (!sha) throw new Error('Commit sha is required')
62
+ if (!isGitRepo(repoDir)) throw new Error('automationsDir is not a git repository')
63
+ runGit(['cat-file', '-t', sha], repoDir)
64
+ const treeish = archiveTreeish(repoDir, sha)
65
+ const root = path.resolve(runGit(['rev-parse', '--show-toplevel'], repoDir))
66
+ const parent = path.dirname(path.resolve(destDir))
67
+ fs.mkdirSync(parent, { recursive: true })
68
+ const tmp = fs.mkdtempSync(path.join(parent, 'live-tmp-'))
69
+ try {
70
+ const archive = spawnSync('git', ['archive', '--format=tar', treeish], {
71
+ cwd: root,
72
+ encoding: 'buffer',
73
+ maxBuffer: 20 * 1024 * 1024
74
+ })
75
+ if (archive.status !== 0) {
76
+ throw new Error(
77
+ 'git archive failed: ' + String(archive.stderr || archive.stdout || '')
78
+ )
79
+ }
80
+ const tar = spawnSync('tar', ['-x', '-C', tmp], {
81
+ input: archive.stdout,
82
+ encoding: 'buffer'
83
+ })
84
+ if (tar.status !== 0) {
85
+ throw new Error('tar extract failed: ' + String(tar.stderr || ''))
86
+ }
87
+ const bak = destDir + '.bak'
88
+ if (fs.existsSync(bak)) fs.rmSync(bak, { recursive: true, force: true })
89
+ if (fs.existsSync(destDir)) fs.renameSync(destDir, bak)
90
+ try {
91
+ fs.renameSync(tmp, destDir)
92
+ if (fs.existsSync(bak)) fs.rmSync(bak, { recursive: true, force: true })
93
+ } catch (err) {
94
+ if (fs.existsSync(bak) && !fs.existsSync(destDir)) {
95
+ fs.renameSync(bak, destDir)
96
+ }
97
+ throw err
98
+ }
99
+ } finally {
100
+ if (fs.existsSync(tmp)) fs.rmSync(tmp, { recursive: true, force: true })
101
+ }
102
+ return destDir
103
+ }
104
+
105
+ module.exports = {
106
+ runGit,
107
+ isGitRepo,
108
+ listCommits,
109
+ currentSha,
110
+ archiveTreeish,
111
+ materializeCommit
112
+ }