smart-home-engine 1.34.3 → 1.35.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.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-BtBtSJng.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-DxSKkp8l.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -172,10 +172,10 @@
172
172
  }
173
173
  })();
174
174
  </script>
175
- <script type="module" crossorigin src="/assets/index-BtBtSJng.js"></script>
175
+ <script type="module" crossorigin src="/assets/index-DxSKkp8l.js"></script>
176
176
  <link rel="modulepreload" crossorigin href="/assets/monaco-langs-DKJI_o9J.js">
177
177
  <link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
178
- <link rel="stylesheet" crossorigin href="/assets/index-CXLA5efM.css">
178
+ <link rel="stylesheet" crossorigin href="/assets/index-ji2CpixA.css">
179
179
  </head>
180
180
  <body>
181
181
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "1.34.3",
3
+ "version": "1.35.1",
4
4
  "description": "Node.js based script runner for use in MQTT based Smart Home environments",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -24,7 +24,7 @@
24
24
  # she-servicectl schema <adapter>
25
25
  # she-servicectl install <adapter> <instance> (options as KEY=VALUE lines on stdin)
26
26
  # she-servicectl uninstall <adapter> <instance>
27
- # she-servicectl npm <adapter> version|origin|install|update
27
+ # she-servicectl npm <adapter> version|origin|install|update|uninstall [--purge] (uninstall: package, template unit; --purge also /etc and /var/lib)
28
28
  # she-servicectl files <adapter> <instance> JSON listing of /etc/<adapter>/ and /var/lib/<adapter>/<instance>/
29
29
  # she-servicectl file <adapter> <instance> read|write <path> a file inside those two directories (write: content on stdin)
30
30
  # she-servicectl asset <adapter> <relpath> a file shipped in the adapter package (example, schema)
@@ -40,7 +40,7 @@
40
40
 
41
41
  set -eu
42
42
 
43
- VERSION=8
43
+ VERSION=9
44
44
  BROKER_ENV=/etc/mqtt-interfaces/broker.env
45
45
  UNIT_DIR=/etc/systemd/system
46
46
 
@@ -599,9 +599,25 @@ cmd_teardown() {
599
599
  }
600
600
 
601
601
  cmd_npm() {
602
- [ $# -eq 2 ] || die "usage: npm <adapter> version|origin|install|update"
602
+ [ $# -ge 2 ] || die "usage: npm <adapter> version|origin|install|update|uninstall [--purge]"
603
603
  need_adapter "$1"
604
604
  case "$2" in
605
+ uninstall)
606
+ # the package and what it left behind; instances must be gone (she removes them first)
607
+ if [ -n "$(ls "/etc/$1"/*.env 2>/dev/null)" ]; then die "instances of $1 still exist — uninstall them first"; fi
608
+ npm=$(adapter_npm "$1")
609
+ node=$(adapter_node "$1")
610
+ PATH="$(dirname "$node"):$PATH" "$npm" uninstall -g "$1" --no-audit --no-fund 2>&1 || true
611
+ dir=$(adapter_dir "$1" 2>/dev/null || true)
612
+ if [ -n "$dir" ] && [ -d "$dir" ]; then case "$dir" in */node_modules/"$1") rm -rf "$dir"; printf 'removed %s\n' "$dir" ;; esac; fi
613
+ [ -L "/usr/local/bin/$1" ] && [ ! -e "/usr/local/bin/$1" ] && rm -f "/usr/local/bin/$1"
614
+ if [ -f "$UNIT_DIR/$1@.service" ]; then rm -f "$UNIT_DIR/$1@.service"; systemctl daemon-reload; printf 'removed %s\n' "$UNIT_DIR/$1@.service"; fi
615
+ if [ "${3:-}" = "--purge" ]; then
616
+ for d in "/etc/$1" "/var/lib/$1"; do [ -d "$d" ] && rm -rf "$d" && printf 'removed %s\n' "$d"; done
617
+ else
618
+ for d in "/etc/$1" "/var/lib/$1"; do [ -d "$d" ] && rmdir "$d" 2>/dev/null && printf 'removed empty %s\n' "$d"; done
619
+ fi
620
+ printf '%s uninstalled\n' "$1" ;;
605
621
  version) adapter_version "$1"; printf '\n' ;;
606
622
  origin) adapter_origin "$1"; printf '\n' ;;
607
623
  install|update)
@@ -10,10 +10,14 @@
10
10
  * package when `versions[dist-tags.latest].dependencies` has mqtt-interfaces-core
11
11
  * 3. full packument for the members only → description, homepage, repository, `mqttInterfaces`
12
12
  *
13
- * Cached for 24 h with the last good result kept on failure; `refresh` forces a new sweep.
13
+ * Cached for 24 h in memory and on disk (the data dir), answered from the cache at once and
14
+ * refreshed in the background — see catalog(); the last good result is kept on failure.
14
15
  * Best effort throughout: a publisher that fails is reported in `errors`, the rest still lists.
15
16
  */
16
17
 
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
17
21
  const CORE = 'mqtt-interfaces-core';
18
22
  const REGISTRY = 'https://registry.npmjs.org';
19
23
  const TTL = 24 * 60 * 60 * 1000;
@@ -23,12 +27,35 @@ function setFetch(fn) {
23
27
  _fetch = fn;
24
28
  }
25
29
 
26
- /** @type {{key: string, ts: number, result: object, promise?: Promise<object>} | null} */
30
+ /** @type {{key: string, ts: number, result: object|null, promise?: Promise<object>} | null} */
27
31
  let _cache = null;
32
+ let _file = null;
28
33
  function clearCache() {
29
34
  _cache = null;
30
35
  }
31
36
 
37
+ /** Persist the catalog under the data dir so a restarted daemon answers at once (best effort). */
38
+ function init({ file } = {}) {
39
+ _file = file || null;
40
+ _cache = null;
41
+ if (!_file) return;
42
+ try {
43
+ const saved = JSON.parse(fs.readFileSync(_file, 'utf8'));
44
+ if (saved && typeof saved.key === 'string' && saved.result && Array.isArray(saved.result.packages)) _cache = { key: saved.key, ts: saved.ts || 0, result: saved.result };
45
+ } catch {
46
+ /* no cache yet */
47
+ }
48
+ }
49
+ function persist() {
50
+ if (!_file || !_cache || !_cache.result) return;
51
+ try {
52
+ fs.mkdirSync(path.dirname(_file), { recursive: true });
53
+ fs.writeFileSync(_file, JSON.stringify({ key: _cache.key, ts: _cache.ts, result: _cache.result }), 'utf8');
54
+ } catch {
55
+ /* read-only data dir — the in-memory copy still works */
56
+ }
57
+ }
58
+
32
59
  function validPublisher(p) {
33
60
  return typeof p === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/i.test(p);
34
61
  }
@@ -107,38 +134,57 @@ async function sweep(publishers, now) {
107
134
  return { packages, publishers, errors, fetchedAt: now };
108
135
  }
109
136
 
137
+ /** A sweep in the background; the last good list survives a failed one. */
138
+ function startSweep(list, key, now) {
139
+ const previous = _cache && _cache.key === key ? _cache.result : null;
140
+ const promise = sweep(list, now)
141
+ .then((r) => {
142
+ // keep the last good list when the whole sweep produced nothing but errors
143
+ if (r.packages.length === 0 && r.errors.length > 0 && previous && previous.packages.length > 0) return { ...previous, errors: r.errors, stale: true };
144
+ return r;
145
+ })
146
+ .then((result) => {
147
+ _cache = { key, ts: now, result };
148
+ persist();
149
+ return result;
150
+ })
151
+ .catch((err) => {
152
+ _cache = previous ? { key, ts: now, result: previous } : null;
153
+ if (previous) return { ...previous, errors: [{ error: err.message }], stale: true };
154
+ throw err;
155
+ });
156
+ _cache = { key, ts: now, result: previous, promise };
157
+ promise.finally(() => {
158
+ if (_cache && _cache.promise === promise) delete _cache.promise;
159
+ });
160
+ return promise;
161
+ }
162
+
110
163
  /**
111
- * The catalog for the given trusted publishers (cached).
164
+ * The catalog for the given trusted publishers. Answers from the cache (memory, or the file from a
165
+ * previous run) at once and refreshes in the background when the cache is older than a day or
166
+ * `force` is set — `refreshing: true` tells the caller to ask again. Only the very first sweep (no
167
+ * cache at all) or `wait: true` makes the call wait for the registry.
112
168
  * @param {string[]} publishers
113
- * @param {{force?: boolean, now?: number}} [opts]
169
+ * @param {{force?: boolean, wait?: boolean, now?: number}} [opts]
114
170
  */
115
171
  async function catalog(publishers, opts = {}) {
116
172
  const list = [...new Set((publishers || []).filter(validPublisher).map((p) => p.toLowerCase()))];
117
173
  const now = opts.now ?? Date.now();
118
174
  const key = list.join(',');
119
- if (list.length === 0) return { packages: [], publishers: [], errors: [], fetchedAt: now, cached: false };
120
- if (_cache && _cache.key === key && !opts.force) {
121
- if (_cache.promise) return { ...(await _cache.promise), cached: true };
122
- if (now - _cache.ts < TTL) return { ..._cache.result, cached: true };
123
- }
124
- const previous = _cache && _cache.key === key ? _cache.result : null;
125
- const promise = sweep(list, now).then((r) => {
126
- // keep the last good list when the whole sweep produced nothing but errors
127
- if (r.packages.length === 0 && r.errors.length > 0 && previous && previous.packages.length > 0) {
128
- return { ...previous, errors: r.errors, stale: true };
129
- }
130
- return r;
131
- });
132
- _cache = { key, ts: now, result: previous, promise };
133
- try {
134
- const result = await promise;
135
- _cache = { key, ts: now, result };
136
- return { ...result, cached: false };
137
- } catch (err) {
138
- _cache = previous ? { key, ts: now, result: previous } : null;
139
- if (previous) return { ...previous, errors: [{ error: err.message }], stale: true, cached: true };
140
- throw err;
141
- }
175
+ if (list.length === 0) return { packages: [], publishers: [], errors: [], fetchedAt: now, cached: false, refreshing: false };
176
+ const entry = _cache && _cache.key === key ? _cache : null;
177
+ const fresh = entry && entry.result && now - entry.ts < TTL;
178
+ if ((!fresh || opts.force) && !(entry && entry.promise)) startSweep(list, key, now);
179
+ const cur = _cache;
180
+ if (cur.result && !opts.wait) return { ...cur.result, cached: true, refreshing: Boolean(cur.promise) };
181
+ const result = await (cur.promise || Promise.resolve(cur.result));
182
+ return { ...result, cached: !cur.promise, refreshing: false };
183
+ }
184
+
185
+ /** Whether a background sweep is running. */
186
+ function refreshing() {
187
+ return Boolean(_cache && _cache.promise);
142
188
  }
143
189
 
144
- module.exports = { catalog, sweep, packagesOf, coreDependency, details, validPublisher, setFetch, clearCache, CORE, REGISTRY };
190
+ module.exports = { catalog, sweep, packagesOf, coreDependency, details, validPublisher, setFetch, clearCache, init, refreshing, CORE, REGISTRY, TTL };
@@ -26,7 +26,7 @@ const { execFile, spawn } = require('child_process');
26
26
  const sshDeploy = require('./ssh-deploy');
27
27
 
28
28
  const HELPER = '/usr/local/bin/she-servicectl';
29
- const HELPER_VERSION = 8; // must match VERSION in service/she-servicectl
29
+ const HELPER_VERSION = 9; // must match VERSION in service/she-servicectl
30
30
  /** Default identity for services.hosts[].ssh — one key for all managed hosts (I5). */
31
31
  const DEFAULT_SERVICES_IDENTITY = '~/.she/ssh/services_id_ed25519';
32
32
  /** The helper as shipped with this she version (copied to remote hosts by the deploy route). */
@@ -18,6 +18,7 @@
18
18
  * POST /she/services/hosts/:host/adapters/:adapter/update { force? } → npm install -g, restart instances
19
19
  * POST /she/services/hosts/:host/units/:adapter/:instance/:action start|stop|restart|enable|disable
20
20
  * DELETE /she/services/hosts/:host/units/:adapter/:instance <adapter> --uninstall
21
+ * POST /she/services/hosts/:host/adapters/:adapter/uninstall every instance (--uninstall, dynsec identity), then the package, /etc and /var/lib
21
22
  * GET /she/services/hosts/:host/units/:adapter/:instance/logs journal tail (?n=)
22
23
  * POST /she/services/hosts/:host/units/:adapter/:instance/logs/follow start/renew a journal follower (serviceLog WS)
23
24
  * DELETE /she/services/hosts/:host/units/:adapter/:instance/logs/follow stop it
@@ -885,22 +886,52 @@ router.post('/hosts/:host/units/:adapter/:instance/:action', async (req, res) =>
885
886
  });
886
887
 
887
888
  // DELETE /she/services/hosts/:host/units/:adapter/:instance — uninstall
889
+ /** <adapter> --uninstall --name <instance> on the host, plus the instance's dynsec identity if it has one. */
890
+ async function uninstallInstance(entry, adapter, instance) {
891
+ stopFollower(followKey(entry.driver.name, adapter, instance));
892
+ let dynsecClient = null;
893
+ try {
894
+ dynsecClient = parseEnvFile((await entry.driver.exec(['env', adapter, instance, 'read'])).stdout)[DYNSEC_MARKER] || null;
895
+ } catch {
896
+ /* no env file */
897
+ }
898
+ const { stdout } = await entry.driver.exec(['uninstall', adapter, instance], { timeout: 60000 });
899
+ if (dynsecClient) await deleteInstanceCredentials(dynsecClient);
900
+ return { output: stdout, dynsecRemoved: Boolean(dynsecClient) };
901
+ }
902
+
888
903
  router.delete('/hosts/:host/units/:adapter/:instance', async (req, res) => {
889
904
  const entry = resolve(req, res, { adapter: true, instance: true });
890
905
  if (!entry) return;
891
906
  if (req.params.instance === LEGACY) return res.status(400).json({ error: 'a legacy unit is not uninstalled from here — migrate it to an instance first', code: 'LEGACY' });
892
907
  try {
893
- stopFollower(followKey(entry.driver.name, req.params.adapter, req.params.instance));
894
- let dynsecClient = null;
895
- try {
896
- dynsecClient = parseEnvFile((await entry.driver.exec(['env', req.params.adapter, req.params.instance, 'read'])).stdout)[DYNSEC_MARKER] || null;
897
- } catch {
898
- /* no env file */
908
+ res.json({ ok: true, ...(await uninstallInstance(entry, req.params.adapter, req.params.instance)) });
909
+ } catch (err) {
910
+ hostError(res, err);
911
+ }
912
+ });
913
+
914
+ // POST /she/services/hosts/:host/adapters/:adapter/uninstall — the adapter with all its instances
915
+ router.post('/hosts/:host/adapters/:adapter/uninstall', async (req, res) => {
916
+ const entry = resolve(req, res, { adapter: true });
917
+ if (!entry) return;
918
+ const adapter = req.params.adapter;
919
+ try {
920
+ const list = parseList((await entry.driver.exec(['list'])).stdout);
921
+ if ((list.legacy || []).some((l) => l.adapter === adapter)) {
922
+ return res.status(409).json({ error: `${adapter} still runs as a pre-core unit (${adapter}.service) — migrate or stop it by hand first`, code: 'LEGACY' });
923
+ }
924
+ const instances = (list.instances || []).filter((i) => i.adapter === adapter).map((i) => i.instance);
925
+ const removed = [];
926
+ for (const instance of instances) {
927
+ await uninstallInstance(entry, adapter, instance);
928
+ removed.push(instance);
899
929
  }
900
- const { stdout } = await entry.driver.exec(['uninstall', req.params.adapter, req.params.instance], { timeout: 60000 });
901
- if (dynsecClient) await deleteInstanceCredentials(dynsecClient);
902
- res.json({ ok: true, output: stdout, dynsecRemoved: Boolean(dynsecClient) });
930
+ const { stdout } = await entry.driver.exec(['npm', adapter, 'uninstall', '--purge'], { timeout: 120000 });
931
+ invalidateHosts();
932
+ res.json({ ok: true, removedInstances: removed, output: stdout });
903
933
  } catch (err) {
934
+ invalidateHosts();
904
935
  hostError(res, err);
905
936
  }
906
937
  });
@@ -1247,6 +1278,33 @@ router.post('/hosts/:host/units/:adapter/:instance/file/create', async (req, res
1247
1278
  });
1248
1279
 
1249
1280
  // ── I7: adapter catalog ───────────────────────────────────────────────────────
1281
+ // persisted under the data dir; a daily timer keeps it fresh so the page never waits for npm
1282
+ try {
1283
+ catalog.init({ file: path.join(require('../lib/storage').getStoragePath('services'), 'catalog.json') });
1284
+ } catch {
1285
+ /* memory only */
1286
+ }
1287
+ let _catalogConfigPath = null;
1288
+ router.use((req, res, next) => {
1289
+ if (req.app && req.app.locals && req.app.locals.configPath) _catalogConfigPath = req.app.locals.configPath;
1290
+ next();
1291
+ });
1292
+ const _catalogTimer = setInterval(
1293
+ () => {
1294
+ if (!_catalogConfigPath) return;
1295
+ try {
1296
+ const cfg = JSON.parse(fs.readFileSync(_catalogConfigPath, 'utf8'));
1297
+ const svc = cfg.services || {};
1298
+ if (svc.enabled !== true) return;
1299
+ const list = Array.isArray(svc.trustedPublishers) ? svc.trustedPublishers : ['hobbyquaker'];
1300
+ catalog.catalog(list.filter(catalog.validPublisher)).catch(() => {}); // refreshes only when older than a day
1301
+ } catch {
1302
+ /* unreadable config */
1303
+ }
1304
+ },
1305
+ 60 * 60 * 1000,
1306
+ );
1307
+ _catalogTimer.unref();
1250
1308
 
1251
1309
  const DEFAULT_PUBLISHERS = ['hobbyquaker'];
1252
1310
  function trustedPublishers(req) {
@@ -1258,6 +1316,7 @@ function trustedPublishers(req) {
1258
1316
  // GET /she/services/catalog[?refresh=1]
1259
1317
  router.get('/catalog', async (req, res) => {
1260
1318
  try {
1319
+ // answers from the cache at once; a refresh (older than a day, or ?refresh=1) runs in the background and the client asks again while `refreshing`
1261
1320
  const r = await catalog.catalog(trustedPublishers(req), { force: req.query.refresh === '1' });
1262
1321
  res.json(r);
1263
1322
  } catch (err) {