sailkick-boat 0.13.4 → 0.14.3
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 +88 -38
- package/index.js +183 -118
- package/lib/account/index.js +64 -42
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
# sailkick-boat
|
|
2
2
|
|
|
3
|
-
> ## ⚠️ Project status: early alpha —
|
|
3
|
+
> ## ⚠️ Project status: early alpha — invite only
|
|
4
4
|
>
|
|
5
|
-
> This plugin is the **boat-side companion of the sailkick platform**
|
|
6
|
-
>
|
|
7
|
-
>
|
|
8
|
-
>
|
|
9
|
-
> environment (a private LAN).
|
|
5
|
+
> This plugin is the **boat-side companion of the sailkick platform**: **cloud
|
|
6
|
+
> telemetry** (gapless boat→shore sync of your vessel's data into a central InfluxDB)
|
|
7
|
+
> and a **local proxy that keeps the sailkick app and its charts/maps fully usable
|
|
8
|
+
> offline** on board. The service runs at **[www.sailkick.io](https://www.sailkick.io)**.
|
|
10
9
|
>
|
|
11
|
-
>
|
|
12
|
-
>
|
|
13
|
-
>
|
|
14
|
-
>
|
|
15
|
-
>
|
|
10
|
+
> **Registration is invite-only** and happens on the website, not in the plugin — so
|
|
11
|
+
> installing this from the Signal K Appstore is not enough on its own. Expect breaking
|
|
12
|
+
> changes while the version is 0.x.
|
|
13
|
+
>
|
|
14
|
+
> **Want an invite, or more information?** Get in touch:
|
|
15
|
+
> **[info@sailkick.io](mailto:info@sailkick.io)**. Early testers are welcome.
|
|
16
|
+
> Self-hosters can point the plugin at their own sailkick server and InfluxDB v2
|
|
17
|
+
> instead — see [Config](#config).
|
|
16
18
|
|
|
17
19
|
One Signal K plugin, independently-toggleable modules — so the boat stays
|
|
18
20
|
"just SignalK + plugins":
|
|
@@ -79,7 +81,7 @@ POST /plugins/sailkick-boat/cache/clear # default kee
|
|
|
79
81
|
POST /plugins/sailkick-boat/cache/clear?keep=tiles,terrain,history # refresh app shell; keep tiles + ring log
|
|
80
82
|
POST /plugins/sailkick-boat/cache/clear?prefix=tiles/seamap # nuke one tileset
|
|
81
83
|
```
|
|
82
|
-
(The persistent history ring log lives under `<
|
|
84
|
+
(The persistent history ring log lives under `<dataDir>/history`, so keep `history`
|
|
83
85
|
when clearing — the default keep already does. A hand-typed `find` clear should add
|
|
84
86
|
`! -name history` alongside `! -name tiles ! -name terrain`.)
|
|
85
87
|
|
|
@@ -94,13 +96,14 @@ passage area on demand.
|
|
|
94
96
|
`coastlineMaxZoom 8` (~12k tiles) + `seabedMaxZoom 6` (~5.5k). Idempotent (re-runs
|
|
95
97
|
hit cache only) and self-throttling — it reuses the circuit breaker, so it goes quiet
|
|
96
98
|
offline and resumes when back online. Progress shows in the plugin status line.
|
|
97
|
-
Config: `
|
|
99
|
+
Config: **Download a worldwide base map on start** (`seedEnabled`); zoom levels and
|
|
100
|
+
concurrency are constants (hand-editable as `proxy.seed.*`).
|
|
98
101
|
- **Download around the boat** (settings dropdowns, no token) — the easiest way to
|
|
99
102
|
cache a passage area: in Plugin Config pick a **Radius around boat** (25/50/100/200 nm)
|
|
100
103
|
and a **Detail level** (Overview z12 … Harbor z15), then save. The plugin reads the
|
|
101
104
|
boat's current position from local SignalK, builds a box, and warms the chart layers
|
|
102
105
|
in the background (progress in the status line). Idempotent; re-saving tops up. An
|
|
103
|
-
oversized radius+detail is refused (reduce one). Config `
|
|
106
|
+
oversized radius+detail is refused (reduce one). Config `prefetchRadiusNm` / `prefetchDetailZoom`.
|
|
104
107
|
- **Region prefetch (API)** — for scripted/arbitrary boxes, warm the detailed chart layers for an area:
|
|
105
108
|
```
|
|
106
109
|
POST /plugins/sailkick-boat/prefetch/region
|
|
@@ -123,8 +126,8 @@ cookie on HTTP, so login just loops. Since the boat is single-tenant and its dat
|
|
|
123
126
|
endpoints aren't server-gated, the proxy serves `/api/config` with `auth.required`
|
|
124
127
|
forced to `false` (and `historyAvailable` forced on when history is served locally),
|
|
125
128
|
so the boat's own app opens with no password, fully offline. Everything else in the
|
|
126
|
-
config passes through untouched.
|
|
127
|
-
the
|
|
129
|
+
config passes through untouched. (Hand-edit `proxy.openAccess: false` to keep the cloud
|
|
130
|
+
login gate — there is no toggle, since the gate cannot complete over the mirror anyway.)
|
|
128
131
|
|
|
129
132
|
## Local history (offline Trends + track)
|
|
130
133
|
Two ways, chosen automatically:
|
|
@@ -135,7 +138,7 @@ Two ways, chosen automatically:
|
|
|
135
138
|
**SignalK on a Victron GX / Venus OS**. Same JSON contract, fully offline, no database.
|
|
136
139
|
(`historyAvailable` reports true either way, so the app shows the Trends panel.)
|
|
137
140
|
- **Persistent (append-log):** the ring is saved as a JSONL append-log at
|
|
138
|
-
`<
|
|
141
|
+
`<dataDir>/history/history-ring.jsonl` (on the SSD/USB with the tiles; override with
|
|
139
142
|
`proxy.history.ringDir`), so it **survives restarts**. Each sample appends one line; the file is
|
|
140
143
|
compacted (atomic rewrite to the current window) only rarely, so a long passage
|
|
141
144
|
writes < ~1 GB (vs the ~600 GB a full-rewrite snapshot would). Config
|
|
@@ -156,26 +159,57 @@ works **offline** with the boat's own data. Only when neither a local InfluxDB n
|
|
|
156
159
|
telemetry is available do these paths **fall through to the cloud mirror**, so an
|
|
157
160
|
online boat is never worse off than before.
|
|
158
161
|
|
|
159
|
-
##
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
is
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
162
|
+
## Setup: register on the web, then paste the token
|
|
163
|
+
1. Register your boat at **[www.sailkick.io](https://www.sailkick.io)** (an invite code
|
|
164
|
+
is needed — see above). The signup screen shows your boat's ingest credentials.
|
|
165
|
+
2. **Copy the "Write token"** — it is shown **once** and cannot be recovered. If you
|
|
166
|
+
lose it you need a new one minted.
|
|
167
|
+
3. In Plugin Config, fill the **Sailkick account** section — **boat name + write
|
|
168
|
+
token** — and save.
|
|
169
|
+
|
|
170
|
+
That is the whole handshake. The plugin resolves everything else locally (`bucket` =
|
|
171
|
+
`<slug>_raw`, `org` = `sailkick`) and never calls the app for configuration, so setup
|
|
172
|
+
works with no internet and there is nothing to re-fetch after a restart.
|
|
173
|
+
|
|
174
|
+
> **Ignore the "Influx URL", "Organization" and "Bucket" on that screen** — they are for
|
|
175
|
+
> the community `signalk-to-influxdb-v2` plugin. This plugin always writes to
|
|
176
|
+
> `https://sync.sailkick.io`; the endpoint is fleet-wide and cannot be set from the UI,
|
|
177
|
+
> so a wrong value can never redirect your telemetry. (Self-hosters: hand-edit
|
|
178
|
+
> `sync.influxUrl` in the plugin config JSON.) If the status line ever warns that sync is
|
|
179
|
+
> writing to a local address, telemetry is not leaving the boat.
|
|
180
|
+
|
|
181
|
+
Registration deliberately happens **only** in the web app. The plugin cannot sign up,
|
|
182
|
+
so there is no way to half-create an account or burn an invite from the boat.
|
|
183
|
+
|
|
184
|
+
> Changing the token later — after a rotation, say — is just editing the field and
|
|
185
|
+
> saving. Pasted values are trimmed, and the boat name is lower-cased for you.
|
|
186
|
+
|
|
187
|
+
## Config
|
|
188
|
+
The page is deliberately small — everything else has a right answer and is a constant
|
|
189
|
+
in `index.js`.
|
|
190
|
+
|
|
191
|
+
- **Sailkick account**: `slug` (boat name), `writeToken`
|
|
192
|
+
- **Telemetry sync → cloud**: `enabled`
|
|
193
|
+
- **Offline app & maps**: `enabled`, `proxyPort` (default 8080), `localSignalkUrl`
|
|
194
|
+
(default `http://127.0.0.1:3000`), `dataDir`, `seedEnabled`, `prefetchRadiusNm`,
|
|
195
|
+
`prefetchDetailZoom`, `historyToken`
|
|
196
|
+
|
|
197
|
+
`dataDir` is the one storage location — cached maps, the telemetry spool and the
|
|
198
|
+
history ring log all live under it. **Put it on the SSD/USB disk, not the SD card.**
|
|
199
|
+
Leave it blank and each part falls back to its historical spot under the plugin data
|
|
200
|
+
dir.
|
|
201
|
+
|
|
202
|
+
`historyToken` is optional and only matters if the boat already runs its own InfluxDB.
|
|
203
|
+
Blank — the normal case — uses the built-in DB-less ring.
|
|
204
|
+
|
|
205
|
+
Cache-manifest polling is always on (no toggle): tile freshness comes from the cloud
|
|
206
|
+
announcing bakes, and without it a re-baked dataset would never refresh.
|
|
207
|
+
|
|
208
|
+
**Self-hosting / advanced.** Every module still reads its option before falling back to
|
|
209
|
+
the constant, so anything removed from the page can be set by hand in
|
|
210
|
+
`~/.signalk/plugin-config-data/sailkick-boat.json` — `sync.influxUrl`, `sync.token`,
|
|
211
|
+
`proxy.sailkickUrl`, `proxy.seed.coastlineMaxZoom`, the ring window, the retry backoff,
|
|
212
|
+
and so on. Values saved by earlier versions keep working after an upgrade.
|
|
179
213
|
|
|
180
214
|
Point your chart app / browser at:
|
|
181
215
|
`http://<boat>:3000/plugins/sailkick-boat/p/`
|
|
@@ -185,7 +219,17 @@ Point your chart app / browser at:
|
|
|
185
219
|
cd ~/.signalk && npm install sailkick-boat # or a packed tarball
|
|
186
220
|
```
|
|
187
221
|
Enable + configure under **Server → Plugin Config → "Sailkick boat companion"**.
|
|
188
|
-
|
|
222
|
+
Set **Data directory** to a path on the SSD (or leave blank for the plugin data dir).
|
|
223
|
+
|
|
224
|
+
### Victron GX / Venus OS (Cerbo, Ekrano)
|
|
225
|
+
Works on Venus OS Large (Signal K enabled). Point **Data directory** at USB/SD storage
|
|
226
|
+
(internal flash is small). Leave the history token
|
|
227
|
+
blank — the DB-less telemetry ring is used automatically. **Note:** a GX only sees
|
|
228
|
+
Victron data (batteries, solar, tanks) by default; **position/wind/speed/depth
|
|
229
|
+
require the boat's NMEA2000 backbone on the VE.Can port** (250 kbit/s N2K profile +
|
|
230
|
+
a VE.Can↔Micro-C drop cable; leave the spare RJ45 unterminated) or a USB GPS/N2K
|
|
231
|
+
gateway. Without a position source, the live boat / trends / area-download stay
|
|
232
|
+
idle — energy telemetry still syncs to the cloud.
|
|
189
233
|
|
|
190
234
|
## Dev / tests
|
|
191
235
|
```bash
|
|
@@ -193,5 +237,11 @@ npm install && npm test # proxy: mirror/cache/offline + Express route; sync: s
|
|
|
193
237
|
```
|
|
194
238
|
Supersedes the separate `signalk-to-influxdb-gapless` + `signalk-tile-cache` plugins.
|
|
195
239
|
|
|
240
|
+
## Contact
|
|
241
|
+
Questions, feedback, or want to test the app on your own boat?
|
|
242
|
+
Email **[info@sailkick.io](mailto:info@sailkick.io)** — happy to help with setup, and
|
|
243
|
+
early testers are very welcome. Bug reports and feature requests are also fine as
|
|
244
|
+
[GitHub issues](https://github.com/lauchat/sailkick-boat/issues).
|
|
245
|
+
|
|
196
246
|
## License
|
|
197
247
|
MIT
|
package/index.js
CHANGED
|
@@ -1,16 +1,61 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const path = require('path')
|
|
4
|
+
|
|
3
5
|
const { createSync } = require('./lib/sync')
|
|
4
6
|
const { createProxy } = require('./lib/proxy')
|
|
5
7
|
const { createTelemetry } = require('./lib/telemetry')
|
|
6
8
|
const { createHistory } = require('./lib/history')
|
|
7
|
-
const { resolveAccountConfig
|
|
9
|
+
const { resolveAccountConfig } = require('./lib/account')
|
|
8
10
|
|
|
9
11
|
// sailkick-boat: one Signal K plugin, two independently-toggleable modules —
|
|
10
12
|
// sync : gapless store-and-forward of self telemetry to InfluxDB (data OUT)
|
|
11
13
|
// proxy: offline-first caching mirror of the sailkick host (data IN)
|
|
12
14
|
// Kept as separate modules so a proxy fault can't wedge the data-critical sync.
|
|
13
15
|
|
|
16
|
+
// Fixed cloud endpoints. Boats never choose these, so they are constants rather than
|
|
17
|
+
// config fields — but every module still reads its option first (see startModules), so
|
|
18
|
+
// a hand-edited plugin-config JSON can override any of them for self-hosting.
|
|
19
|
+
const SAILKICK_APP_URL = 'https://www.sailkick.io' // signup + mirror upstream
|
|
20
|
+
const SAILKICK_INFLUX_URL = 'https://sync.sailkick.io' // data egress; pairing overrides this
|
|
21
|
+
|
|
22
|
+
// Tuning that has a right answer. Deliberately NOT on the config page: a boat owner has
|
|
23
|
+
// no basis to choose these, and a wrong value silently degrades sync or the cache.
|
|
24
|
+
const SYNC_TUNING = {
|
|
25
|
+
org: 'sailkick',
|
|
26
|
+
batchSize: 1000,
|
|
27
|
+
flushIntervalMs: 1000,
|
|
28
|
+
maxBufferBytes: 500 * 1024 * 1024,
|
|
29
|
+
subscribePeriodMs: 1000,
|
|
30
|
+
requestTimeoutMs: 30000,
|
|
31
|
+
retryMinMs: 1000,
|
|
32
|
+
retryMaxMs: 60000
|
|
33
|
+
}
|
|
34
|
+
const PROXY_TUNING = { requestTimeoutMs: 20000, localPaths: ['/signalk'], telemetryPath: '/ws/telemetry' }
|
|
35
|
+
const MANIFEST = { enabled: true, path: '/api/cache-manifest', pollIntervalSec: 300 }
|
|
36
|
+
const SEED_TUNING = { coastlineMaxZoom: 8, seabedMaxZoom: 6, concurrency: 4 }
|
|
37
|
+
const PREFETCH_TUNING = { concurrency: 4 }
|
|
38
|
+
const HISTORY_TUNING = {
|
|
39
|
+
influxUrl: 'http://127.0.0.1:8086',
|
|
40
|
+
org: 'signalk',
|
|
41
|
+
bucket: 'signalk',
|
|
42
|
+
requestTimeoutMs: 15000,
|
|
43
|
+
ringPersist: true,
|
|
44
|
+
ringWindowSec: 86400,
|
|
45
|
+
ringSampleSec: 15
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A *cloud* endpoint on loopback means telemetry never leaves the boat. On the wire it
|
|
49
|
+
// is indistinguishable from a normal offline backlog — the spool just grows — so it has
|
|
50
|
+
// to be called out explicitly. (The local history DB is legitimately on 127.0.0.1; this
|
|
51
|
+
// check is only ever applied to the sync path.)
|
|
52
|
+
function isLoopbackUrl (u) {
|
|
53
|
+
try {
|
|
54
|
+
const h = new URL(u).hostname.replace(/^\[|\]$/g, '')
|
|
55
|
+
return h === 'localhost' || h === '::1' || /^127\./.test(h)
|
|
56
|
+
} catch { return false }
|
|
57
|
+
}
|
|
58
|
+
|
|
14
59
|
module.exports = function (app) {
|
|
15
60
|
let sync = null
|
|
16
61
|
let proxy = null
|
|
@@ -18,13 +63,14 @@ module.exports = function (app) {
|
|
|
18
63
|
let history = null
|
|
19
64
|
let statusTimer = null
|
|
20
65
|
let accountStatus = null
|
|
66
|
+
let syncWarning = null
|
|
21
67
|
|
|
22
68
|
const plugin = {
|
|
23
69
|
id: 'sailkick-boat',
|
|
24
70
|
name: 'Sailkick boat companion',
|
|
25
71
|
description:
|
|
26
|
-
'Boat-side sailkick: gapless telemetry sync to
|
|
27
|
-
'caching mirror of the sailkick
|
|
72
|
+
'Boat-side sailkick: gapless telemetry sync to the cloud + an offline-first ' +
|
|
73
|
+
'caching mirror of the sailkick app. Each feature can be toggled on/off.'
|
|
28
74
|
}
|
|
29
75
|
|
|
30
76
|
plugin.schema = {
|
|
@@ -32,106 +78,47 @@ module.exports = function (app) {
|
|
|
32
78
|
properties: {
|
|
33
79
|
account: {
|
|
34
80
|
type: 'object',
|
|
35
|
-
title: 'Sailkick account
|
|
36
|
-
description: '
|
|
81
|
+
title: 'Sailkick account',
|
|
82
|
+
description: 'Register your boat at www.sailkick.io first — the signup screen shows a write token. Paste it here together with your boat name. Keep that token safe: it is shown only once and cannot be recovered.',
|
|
37
83
|
properties: {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
password: { type: 'string', title: 'Password' }
|
|
84
|
+
slug: { type: 'string', title: 'Boat name', description: 'Exactly as registered on the website.' },
|
|
85
|
+
writeToken: { type: 'string', title: 'Write token', description: 'From the signup screen ("Write token"). Everything else — Influx URL, organization, bucket — is derived from it and your boat name.' }
|
|
41
86
|
}
|
|
42
87
|
},
|
|
43
88
|
sync: {
|
|
44
89
|
type: 'object',
|
|
45
|
-
title: 'Telemetry sync →
|
|
90
|
+
title: 'Telemetry sync → cloud',
|
|
91
|
+
description: 'Send this vessel\'s data to your sailkick account. Buffered on disk, so an offline passage or a restart loses nothing. Requires pairing above.',
|
|
46
92
|
properties: {
|
|
47
|
-
enabled: { type: 'boolean', title: 'Enable telemetry sync', default: true }
|
|
48
|
-
influxUrl: { type: 'string', title: 'InfluxDB URL' },
|
|
49
|
-
org: { type: 'string', title: 'Organization', default: 'sailkick' },
|
|
50
|
-
bucket: { type: 'string', title: 'Bucket (your <slug>_raw)' },
|
|
51
|
-
token: { type: 'string', title: 'Write token (scoped to your bucket)' },
|
|
52
|
-
spoolDir: { type: 'string', title: 'Buffer directory', description: 'Default: plugin data dir. Put on the SSD.' },
|
|
53
|
-
batchSize: { type: 'number', title: 'Batch size (points/file)', default: 1000 },
|
|
54
|
-
flushIntervalMs: { type: 'number', title: 'Flush interval (ms)', default: 1000 },
|
|
55
|
-
maxBufferBytes: { type: 'number', title: 'Max on-disk buffer (bytes)', default: 524288000 },
|
|
56
|
-
subscribePeriodMs: { type: 'number', title: 'Min interval per path (ms)', default: 1000 },
|
|
57
|
-
requestTimeoutMs: { type: 'number', title: 'Upload timeout (ms)', default: 30000 },
|
|
58
|
-
retryMinMs: { type: 'number', title: 'Retry backoff min (ms)', default: 1000 },
|
|
59
|
-
retryMaxMs: { type: 'number', title: 'Retry backoff max (ms)', default: 60000 }
|
|
93
|
+
enabled: { type: 'boolean', title: 'Enable telemetry sync', default: true }
|
|
60
94
|
}
|
|
61
95
|
},
|
|
62
96
|
proxy: {
|
|
63
97
|
type: 'object',
|
|
64
|
-
title: '
|
|
98
|
+
title: 'Offline app & maps',
|
|
99
|
+
description: 'Serve the sailkick app on board — charts, trends and live data keep working with no internet.',
|
|
65
100
|
properties: {
|
|
66
|
-
enabled: { type: 'boolean', title: 'Enable
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
description: 'Poll the cloud\'s bake manifest so tiles/app refresh lazily when a dataset is re-baked. Tiles are otherwise pinned (never expire by time).',
|
|
79
|
-
properties: {
|
|
80
|
-
enabled: { type: 'boolean', title: 'Poll the cache manifest', default: true },
|
|
81
|
-
path: { type: 'string', title: 'Manifest path on the sailkick host', default: '/api/cache-manifest' },
|
|
82
|
-
pollIntervalSec: { type: 'number', title: 'Poll interval (s)', default: 300 }
|
|
83
|
-
}
|
|
101
|
+
enabled: { type: 'boolean', title: 'Enable the offline mirror', default: true },
|
|
102
|
+
proxyPort: { type: 'number', title: 'Mirror port', description: 'Open the app at http://<boat>:<port>/. 0 = disable the standalone server.', default: 8080 },
|
|
103
|
+
localSignalkUrl: { type: 'string', title: 'Local SignalK URL', description: 'Where this plugin reaches SignalK for live data. Change only if SignalK is not on the default port.', default: 'http://127.0.0.1:3000' },
|
|
104
|
+
dataDir: { type: 'string', title: 'Data directory', description: 'Where cached maps, the telemetry buffer and local history live. Put this on the SSD/USB disk — not the SD card. Blank = the plugin data dir.' },
|
|
105
|
+
seedEnabled: { type: 'boolean', title: 'Download a worldwide base map on start', description: 'Caches a low-zoom coastline + seabed base (~17k tiles) so a usable map exists offline everywhere. Runs once, resumes if interrupted.', default: true },
|
|
106
|
+
prefetchRadiusNm: {
|
|
107
|
+
type: 'number',
|
|
108
|
+
title: 'Download detailed charts around the boat',
|
|
109
|
+
description: 'On save, caches the full chart detail for this radius around the boat\'s current position. Progress shows in the status line.',
|
|
110
|
+
enum: [0, 25, 50, 100, 200],
|
|
111
|
+
enumNames: ['Off', '25 nm', '50 nm', '100 nm', '200 nm'],
|
|
112
|
+
default: 0
|
|
84
113
|
},
|
|
85
|
-
|
|
86
|
-
type: '
|
|
87
|
-
title: '
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
influxUrl: { type: 'string', title: 'Local InfluxDB URL', default: 'http://127.0.0.1:8086' },
|
|
92
|
-
org: { type: 'string', title: 'Organization', default: 'signalk' },
|
|
93
|
-
bucket: { type: 'string', title: 'Bucket', default: 'signalk' },
|
|
94
|
-
token: { type: 'string', title: 'Read token (scoped to the bucket)', description: 'Set to serve full history from a local InfluxDB. Leave blank to use the DB-less live-telemetry ring (Victron GX / no InfluxDB).' },
|
|
95
|
-
requestTimeoutMs: { type: 'number', title: 'Query timeout (ms)', default: 15000 },
|
|
96
|
-
ringPersist: { type: 'boolean', title: 'Persist the DB-less ring across restarts', description: 'Append-log in the plugin data dir. Off = in-memory only (lost on restart). Ignored when a token is set (InfluxDB is used).', default: true },
|
|
97
|
-
ringWindowSec: { type: 'number', title: 'DB-less ring window (s)', description: 'How much history the ring keeps: 86400 = 24h (default), up to 2592000 = 30d for long passages. Resolution auto-coarsens for large windows. NB: the app currently caps history requests at 24h.', default: 86400 },
|
|
98
|
-
ringSampleSec: { type: 'number', title: 'DB-less ring sample interval (s)', description: 'Auto-raised for large windows so the ring stays bounded (~50k samples).', default: 15 },
|
|
99
|
-
ringDir: { type: 'string', title: 'Ring log directory (override)', description: 'Where the persistent ring log lives. Default: a "history" folder under the cache directory (storeDir), so it sits on the SSD/USB with the tiles. Set to override.' }
|
|
100
|
-
}
|
|
114
|
+
prefetchDetailZoom: {
|
|
115
|
+
type: 'number',
|
|
116
|
+
title: '…at this detail level',
|
|
117
|
+
enum: [12, 13, 14, 15],
|
|
118
|
+
enumNames: ['Overview (z12)', 'Coastal (z13)', 'Detailed (z14)', 'Harbor (z15)'],
|
|
119
|
+
default: 13
|
|
101
120
|
},
|
|
102
|
-
|
|
103
|
-
type: 'object',
|
|
104
|
-
title: 'Offline base seed (global coastline + seabed)',
|
|
105
|
-
description: 'On start, cache a worldwide low-zoom base so a usable map exists offline everywhere. Idempotent; self-throttles when offline. Enable the "Coastline"/depth layers in the app to see it.',
|
|
106
|
-
properties: {
|
|
107
|
-
enabled: { type: 'boolean', title: 'Seed the global base on start', default: true },
|
|
108
|
-
coastlineMaxZoom: { type: 'number', title: 'Coastline max zoom', description: 'Sparse vector coastline z0..N (z8 ≈ 12k tiles).', default: 8 },
|
|
109
|
-
seabedMaxZoom: { type: 'number', title: 'Seabed (bathy) max zoom', description: 'Dense depth raster z0..N (z6 ≈ 5.5k tiles).', default: 6 },
|
|
110
|
-
concurrency: { type: 'number', title: 'Parallel fetches', default: 4 }
|
|
111
|
-
}
|
|
112
|
-
},
|
|
113
|
-
prefetch: {
|
|
114
|
-
type: 'object',
|
|
115
|
-
title: 'Offline area download (around the boat)',
|
|
116
|
-
description: 'On save, cache the detailed chart layers for a radius around the boat\'s current position (from local SignalK), so a passage area is fully offline. Progress shows in the status line above.',
|
|
117
|
-
properties: {
|
|
118
|
-
radiusNm: {
|
|
119
|
-
type: 'number',
|
|
120
|
-
title: 'Radius around boat',
|
|
121
|
-
enum: [0, 25, 50, 100, 200],
|
|
122
|
-
enumNames: ['Off', '25 nm', '50 nm', '100 nm', '200 nm'],
|
|
123
|
-
default: 0
|
|
124
|
-
},
|
|
125
|
-
detailZoom: {
|
|
126
|
-
type: 'number',
|
|
127
|
-
title: 'Detail level',
|
|
128
|
-
enum: [12, 13, 14, 15],
|
|
129
|
-
enumNames: ['Overview (z12)', 'Coastal (z13)', 'Detailed (z14)', 'Harbor (z15)'],
|
|
130
|
-
default: 13
|
|
131
|
-
},
|
|
132
|
-
concurrency: { type: 'number', title: 'Parallel fetches', default: 4 }
|
|
133
|
-
}
|
|
134
|
-
}
|
|
121
|
+
historyToken: { type: 'string', title: 'Local InfluxDB read token (optional)', description: 'Only if this boat already runs its own InfluxDB (e.g. signalk-to-influxdb-v2) and you want full history from it. Leave blank — the plugin then keeps its own lightweight history, which is what most boats want.' }
|
|
135
122
|
}
|
|
136
123
|
}
|
|
137
124
|
}
|
|
@@ -139,59 +126,135 @@ module.exports = function (app) {
|
|
|
139
126
|
|
|
140
127
|
plugin.start = function (options) {
|
|
141
128
|
const opts = options || {}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
accountStatus = `account: ${opts.account.slug} — no config (${r.error || 'not reachable, no cache'})`
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
startModules(opts)
|
|
157
|
-
})().catch((e) => (app.error || console.error)('[sailkick-boat] start failed: ' + e.message))
|
|
129
|
+
try {
|
|
130
|
+
// Purely local: the boat registers on the website, the owner pastes the write
|
|
131
|
+
// token here. Nothing to fetch, so nothing that can fail while offline.
|
|
132
|
+
const r = resolveAccountConfig(app, opts.account)
|
|
133
|
+
if (r.bundle) accountStatus = `account: ${r.bundle.slug}`
|
|
134
|
+
else if (r.error) accountStatus = `account: ${r.error}`
|
|
135
|
+
else accountStatus = 'account: not configured — register at www.sailkick.io, then paste your write token'
|
|
136
|
+
startModules(opts, r.bundle)
|
|
137
|
+
} catch (e) {
|
|
138
|
+
(app.error || console.error)('[sailkick-boat] start failed: ' + e.message)
|
|
139
|
+
}
|
|
158
140
|
}
|
|
159
141
|
|
|
160
|
-
function startModules (opts) {
|
|
142
|
+
function startModules (opts, bundle) {
|
|
143
|
+
const s = opts.sync || {}
|
|
144
|
+
const p = opts.proxy || {}
|
|
145
|
+
const b = bundle || {}
|
|
146
|
+
// One "Data directory" now drives cache + spool + ring log. `storeDir` is its
|
|
147
|
+
// pre-0.14 name — keep honouring it so an upgrade doesn't strand an existing tile
|
|
148
|
+
// cache. When nothing is set we pass undefined and let each module apply its own
|
|
149
|
+
// historical default (<pluginData>/store, <pluginData>/spool), so an upgrade never
|
|
150
|
+
// orphans a populated cache or a spool full of unsent telemetry.
|
|
151
|
+
const store = p.dataDir || p.storeDir || undefined
|
|
152
|
+
const spoolDir = s.spoolDir || (p.dataDir ? path.join(p.dataDir, 'spool') : undefined)
|
|
153
|
+
|
|
161
154
|
// --- sync module (isolated: its failure must not affect the proxy) ---
|
|
162
|
-
|
|
163
|
-
|
|
155
|
+
// Pairing wins over hand-written fields; hand-written fields win over the constants.
|
|
156
|
+
if (s.enabled !== false) {
|
|
157
|
+
const syncOpts = {
|
|
158
|
+
...SYNC_TUNING,
|
|
159
|
+
// The cloud endpoint is fleet-wide and fixed. A pasted or cached bundle can
|
|
160
|
+
// never set it — the app's signup screen hands out its own http://localhost:8086,
|
|
161
|
+
// and a boat that believed it would spool to loopback forever, looking healthy.
|
|
162
|
+
// `sync.influxUrl` survives only as a self-hosting escape hatch: it is not in
|
|
163
|
+
// the schema, so it cannot arrive by pasting, only by editing the config JSON.
|
|
164
|
+
influxUrl: s.influxUrl || SAILKICK_INFLUX_URL,
|
|
165
|
+
org: b.org || s.org || SYNC_TUNING.org,
|
|
166
|
+
bucket: b.bucket || s.bucket,
|
|
167
|
+
token: b.writeToken || s.token,
|
|
168
|
+
spoolDir,
|
|
169
|
+
batchSize: s.batchSize || SYNC_TUNING.batchSize,
|
|
170
|
+
flushIntervalMs: s.flushIntervalMs || SYNC_TUNING.flushIntervalMs,
|
|
171
|
+
maxBufferBytes: s.maxBufferBytes || SYNC_TUNING.maxBufferBytes,
|
|
172
|
+
subscribePeriodMs: s.subscribePeriodMs || SYNC_TUNING.subscribePeriodMs,
|
|
173
|
+
requestTimeoutMs: s.requestTimeoutMs || SYNC_TUNING.requestTimeoutMs,
|
|
174
|
+
retryMinMs: s.retryMinMs || SYNC_TUNING.retryMinMs,
|
|
175
|
+
retryMaxMs: s.retryMaxMs || SYNC_TUNING.retryMaxMs
|
|
176
|
+
}
|
|
177
|
+
if (isLoopbackUrl(syncOpts.influxUrl)) {
|
|
178
|
+
syncWarning = `sync: ⚠ writing to ${syncOpts.influxUrl} — telemetry is NOT reaching the cloud`
|
|
179
|
+
;(app.error || console.error)(`[sailkick-boat] sync target ${syncOpts.influxUrl} is a local address — telemetry will not reach the cloud`)
|
|
180
|
+
}
|
|
181
|
+
try { sync = createSync(app, syncOpts); sync.start() } catch (e) {
|
|
164
182
|
(app.error || console.error)('[sailkick-boat] sync start failed: ' + e.message)
|
|
165
183
|
sync = null
|
|
166
184
|
}
|
|
167
185
|
}
|
|
168
186
|
|
|
169
187
|
// --- proxy module (+ /ws/telemetry provider and /api/history from local sources) ---
|
|
170
|
-
if (
|
|
171
|
-
const
|
|
172
|
-
|
|
188
|
+
if (p.enabled !== false) {
|
|
189
|
+
const oldSeed = p.seed || {}
|
|
190
|
+
const oldPrefetch = p.prefetch || {}
|
|
191
|
+
const oldHistory = p.history || {}
|
|
192
|
+
const pOpts = {
|
|
193
|
+
sailkickUrl: p.sailkickUrl || SAILKICK_APP_URL,
|
|
194
|
+
storeDir: store,
|
|
195
|
+
proxyPort: p.proxyPort == null ? 8080 : p.proxyPort,
|
|
196
|
+
localSignalkUrl: p.localSignalkUrl || 'http://127.0.0.1:3000',
|
|
197
|
+
localPaths: (p.localPaths && p.localPaths.length) ? p.localPaths : PROXY_TUNING.localPaths,
|
|
198
|
+
telemetryPath: p.telemetryPath || PROXY_TUNING.telemetryPath,
|
|
199
|
+
requestTimeoutMs: p.requestTimeoutMs || PROXY_TUNING.requestTimeoutMs,
|
|
200
|
+
openAccess: p.openAccess !== false, // single-tenant boat: the cloud login gate can't work over plain HTTP
|
|
201
|
+
manifest: MANIFEST, // always on — freshness comes from the cloud announcing bakes
|
|
202
|
+
seed: {
|
|
203
|
+
...SEED_TUNING,
|
|
204
|
+
// Never seed an unpaired boat: it would pull ~17k tiles from a host this
|
|
205
|
+
// install has no account on, and retry forever behind the circuit breaker.
|
|
206
|
+
// Pair first, then the worldwide base downloads on the next start.
|
|
207
|
+
enabled: !!b.writeToken && (p.seedEnabled != null ? p.seedEnabled !== false : oldSeed.enabled !== false),
|
|
208
|
+
coastlineMaxZoom: oldSeed.coastlineMaxZoom || SEED_TUNING.coastlineMaxZoom,
|
|
209
|
+
seabedMaxZoom: oldSeed.seabedMaxZoom || SEED_TUNING.seabedMaxZoom,
|
|
210
|
+
concurrency: oldSeed.concurrency || SEED_TUNING.concurrency
|
|
211
|
+
},
|
|
212
|
+
prefetch: {
|
|
213
|
+
radiusNm: p.prefetchRadiusNm != null ? p.prefetchRadiusNm : oldPrefetch.radiusNm,
|
|
214
|
+
detailZoom: p.prefetchDetailZoom != null ? p.prefetchDetailZoom : oldPrefetch.detailZoom,
|
|
215
|
+
concurrency: oldPrefetch.concurrency || PREFETCH_TUNING.concurrency
|
|
216
|
+
},
|
|
217
|
+
history: {
|
|
218
|
+
...HISTORY_TUNING,
|
|
219
|
+
enabled: oldHistory.enabled !== false,
|
|
220
|
+
influxUrl: oldHistory.influxUrl || HISTORY_TUNING.influxUrl,
|
|
221
|
+
org: oldHistory.org || HISTORY_TUNING.org,
|
|
222
|
+
bucket: oldHistory.bucket || HISTORY_TUNING.bucket,
|
|
223
|
+
token: p.historyToken || oldHistory.token || '',
|
|
224
|
+
ringPersist: oldHistory.ringPersist !== false,
|
|
225
|
+
ringWindowSec: oldHistory.ringWindowSec || HISTORY_TUNING.ringWindowSec,
|
|
226
|
+
ringSampleSec: oldHistory.ringSampleSec || HISTORY_TUNING.ringSampleSec,
|
|
227
|
+
ringDir: oldHistory.ringDir
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (p.serveTelemetry !== false) {
|
|
173
232
|
try {
|
|
174
233
|
telemetry = createTelemetry(app, {})
|
|
175
234
|
telemetry.start()
|
|
176
|
-
pOpts.telemetryUpgrade = (req,
|
|
235
|
+
pOpts.telemetryUpgrade = (req, sock, head) => telemetry.handleUpgrade(req, sock, head)
|
|
177
236
|
} catch (e) {
|
|
178
237
|
(app.error || console.error)('[sailkick-boat] telemetry start failed: ' + e.message)
|
|
179
238
|
telemetry = null
|
|
180
239
|
}
|
|
181
240
|
}
|
|
182
|
-
if (
|
|
241
|
+
if (pOpts.history.enabled !== false) {
|
|
183
242
|
try {
|
|
184
|
-
// ringSource = the telemetry module: when no local InfluxDB is
|
|
185
|
-
// (e.g. a Victron GX), history serves a DB-less ring from
|
|
186
|
-
// storeDir
|
|
187
|
-
history = createHistory(app, { ...
|
|
243
|
+
// ringSource = the telemetry module: when no local InfluxDB token is set
|
|
244
|
+
// (the common case, e.g. a Victron GX), history serves a DB-less ring from
|
|
245
|
+
// live telemetry. storeDir puts the persistent ring log on the SSD.
|
|
246
|
+
history = createHistory(app, { ...pOpts.history, ringSource: telemetry, storeDir: store })
|
|
188
247
|
history.start()
|
|
189
248
|
pOpts.history = history // proxy dispatches /api/history to it when available()
|
|
190
249
|
} catch (e) {
|
|
191
250
|
(app.error || console.error)('[sailkick-boat] history start failed: ' + e.message)
|
|
192
251
|
history = null
|
|
252
|
+
pOpts.history = null
|
|
193
253
|
}
|
|
254
|
+
} else {
|
|
255
|
+
pOpts.history = null
|
|
194
256
|
}
|
|
257
|
+
|
|
195
258
|
try { proxy = createProxy(app, pOpts); proxy.start() } catch (e) {
|
|
196
259
|
(app.error || console.error)('[sailkick-boat] proxy start failed: ' + e.message)
|
|
197
260
|
proxy = null
|
|
@@ -206,6 +269,7 @@ module.exports = function (app) {
|
|
|
206
269
|
if (!app.setPluginStatus) return
|
|
207
270
|
const parts = []
|
|
208
271
|
if (accountStatus) parts.push(accountStatus)
|
|
272
|
+
if (syncWarning) parts.push(syncWarning)
|
|
209
273
|
if (sync) parts.push(sync.status())
|
|
210
274
|
if (proxy) parts.push(proxy.status())
|
|
211
275
|
if (telemetry) parts.push(telemetry.status())
|
|
@@ -225,6 +289,7 @@ module.exports = function (app) {
|
|
|
225
289
|
history = null
|
|
226
290
|
proxy = null
|
|
227
291
|
accountStatus = null
|
|
292
|
+
syncWarning = null
|
|
228
293
|
}
|
|
229
294
|
|
|
230
295
|
// Mounted by Signal K at /plugins/sailkick-boat. Handlers dispatch to the live
|
package/lib/account/index.js
CHANGED
|
@@ -1,65 +1,87 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
// Sailkick account
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// Sailkick account config — a pure local resolver, no network.
|
|
4
|
+
//
|
|
5
|
+
// Registration happens in the web app: the user redeems an invite there, and the
|
|
6
|
+
// signup screen shows the boat's ingest credentials once (see the app's
|
|
7
|
+
// public/ui/login-overlay.js). They paste the write token into this plugin, and that
|
|
8
|
+
// is the whole handshake. The plugin deliberately does NOT sign up on its own —
|
|
9
|
+
// one registration path means no half-created accounts and no spent-invite dead ends.
|
|
10
|
+
//
|
|
11
|
+
// Everything else about the bundle is derivable, so only two fields are asked for:
|
|
12
|
+
// bucket = "<slug>_raw" (the app's convention — server/auth/registry.js bucketFor)
|
|
13
|
+
// org = "sailkick" (single org, fleet-wide)
|
|
14
|
+
//
|
|
15
|
+
// This bundle deliberately carries NO Influx URL. "Which boat am I" and "where is the
|
|
16
|
+
// cloud" are separate questions, and the second one has a single fleet-wide answer that
|
|
17
|
+
// index.js owns. Letting a pasted or cached value set the host is how telemetry ends up
|
|
18
|
+
// spooling to a loopback address forever while the status line looks healthy — the app's
|
|
19
|
+
// signup screen has been handing out its own internal http://localhost:8086.
|
|
20
|
+
//
|
|
21
|
+
// NB the write token cannot be re-fetched: the app shows it once at signup and never
|
|
22
|
+
// stores it (registry.js keeps only passwordHash + influxReadToken). Losing it means
|
|
23
|
+
// minting a new one, so the pasted value is the source of truth here.
|
|
8
24
|
|
|
9
25
|
const fs = require('fs')
|
|
10
26
|
const path = require('path')
|
|
11
27
|
|
|
12
|
-
|
|
13
|
-
return !!(account && account.sailkickUrl && account.slug && account.password)
|
|
14
|
-
}
|
|
28
|
+
const DEFAULT_ORG = 'sailkick'
|
|
15
29
|
|
|
16
30
|
function cacheFileFor (app) {
|
|
17
31
|
const dataDir = (app.getDataDirPath && app.getDataDirPath()) || '.'
|
|
18
32
|
return path.join(dataDir, 'account.json')
|
|
19
33
|
}
|
|
20
34
|
|
|
35
|
+
// Legacy/manual escape hatch: a hand-written (or pre-0.14.2) account.json still works,
|
|
36
|
+
// so an install that was set up before the token field existed keeps syncing. Any
|
|
37
|
+
// influxUrl in such a file is dropped — those were copied off the signup screen, which
|
|
38
|
+
// was showing the server's own loopback address.
|
|
21
39
|
function readCache (file) {
|
|
22
40
|
try {
|
|
23
41
|
const j = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
24
|
-
|
|
42
|
+
if (!j || !j.writeToken) return null
|
|
43
|
+
const { influxUrl, ...rest } = j
|
|
44
|
+
return rest
|
|
25
45
|
} catch { return null }
|
|
26
46
|
}
|
|
27
47
|
|
|
28
|
-
|
|
29
|
-
try {
|
|
30
|
-
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
31
|
-
const tmp = `${file}.tmp-${process.pid}`
|
|
32
|
-
fs.writeFileSync(tmp, JSON.stringify(bundle), { mode: 0o600 })
|
|
33
|
-
fs.renameSync(tmp, file)
|
|
34
|
-
} catch {}
|
|
35
|
-
}
|
|
48
|
+
const clean = (v) => String(v == null ? '' : v).trim()
|
|
36
49
|
|
|
37
|
-
// Returns
|
|
38
|
-
// bundle
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
// Returns one of:
|
|
51
|
+
// { bundle, source: 'config' } token pasted into the plugin config
|
|
52
|
+
// { bundle, source: 'cache' } from a hand-written/legacy account.json
|
|
53
|
+
// { bundle: null, source: null, error? }
|
|
54
|
+
//
|
|
55
|
+
// bundle = { slug, org, bucket, writeToken } — no influxUrl by design, see above.
|
|
56
|
+
function resolveAccountConfig (app, account) {
|
|
57
|
+
const acct = account || {}
|
|
58
|
+
const slug = clean(acct.slug).toLowerCase()
|
|
59
|
+
const writeToken = clean(acct.writeToken)
|
|
60
|
+
|
|
61
|
+
if (slug && writeToken) {
|
|
62
|
+
return {
|
|
63
|
+
source: 'config',
|
|
64
|
+
bundle: {
|
|
65
|
+
slug,
|
|
66
|
+
org: clean(acct.org) || DEFAULT_ORG,
|
|
67
|
+
bucket: clean(acct.bucket) || `${slug}_raw`,
|
|
68
|
+
writeToken
|
|
69
|
+
}
|
|
54
70
|
}
|
|
55
|
-
const j = await resp.json().catch(() => null)
|
|
56
|
-
if (!j || j.ok === false || !j.writeToken) return fallback('unexpected /api/boat/config response')
|
|
57
|
-
const bundle = { influxUrl: j.influxUrl, org: j.org, bucket: j.bucket, writeToken: j.writeToken, readToken: j.readToken }
|
|
58
|
-
writeCache(file, bundle)
|
|
59
|
-
return { bundle, source: 'live' }
|
|
60
|
-
} catch (e) {
|
|
61
|
-
return fallback(e.message) // offline / unreachable → use the cached bundle
|
|
62
71
|
}
|
|
72
|
+
|
|
73
|
+
const cached = readCache(cacheFileFor(app))
|
|
74
|
+
if (cached) return { bundle: cached, source: 'cache' }
|
|
75
|
+
|
|
76
|
+
// Half-filled: say which half, so the status line is actionable.
|
|
77
|
+
if (writeToken) return { bundle: null, source: null, error: 'boat name is missing' }
|
|
78
|
+
if (slug) return { bundle: null, source: null, error: 'write token is missing' }
|
|
79
|
+
return { bundle: null, source: null }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// True when sync has credentials to work with.
|
|
83
|
+
function accountConfigured (app, account) {
|
|
84
|
+
return !!resolveAccountConfig(app, account).bundle
|
|
63
85
|
}
|
|
64
86
|
|
|
65
|
-
module.exports = { resolveAccountConfig, accountConfigured }
|
|
87
|
+
module.exports = { resolveAccountConfig, accountConfigured, cacheFileFor, DEFAULT_ORG }
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sailkick-boat",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "EARLY ALPHA —
|
|
3
|
+
"version": "0.14.3",
|
|
4
|
+
"description": "EARLY ALPHA — cloud telemetry + offline maps for sailkick boats (www.sailkick.io; register on the web, paste the write token). Gapless boat→cloud telemetry sync to InfluxDB, and a local proxy that keeps the sailkick app and its charts/maps working fully offline on board.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "node --test"
|