smart-home-engine 1.42.0 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-fKlQBrQH.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-C5CNdJWG.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-fKlQBrQH.js"></script>
175
+ <script type="module" crossorigin src="/assets/index-C5CNdJWG.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-55TivCbD.css">
178
+ <link rel="stylesheet" crossorigin href="/assets/index-DDpHIMRc.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.42.0",
3
+ "version": "1.44.0",
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": {
@@ -31,6 +31,8 @@
31
31
  # she-servicectl files <adapter> <instance> JSON listing of /etc/<adapter>/ and /var/lib/<adapter>/<instance>/
32
32
  # she-servicectl file <adapter> <instance> read|write <path> a file inside those two directories (write: content on stdin)
33
33
  # she-servicectl asset <adapter> <relpath> a file shipped in the adapter package (example, schema)
34
+ # she-servicectl node update [--lts|--latest] install/refresh tj/n (downloaded, no npm), then n install lts (or latest) — JSON with the version before and after
35
+ # she-servicectl restart-all restart every running instance on this host (after a node update, say)
34
36
  # she-servicectl self-update replace this script with the one on stdin (she ships it)
35
37
  # she-servicectl remove-key drop the public key on stdin from the calling user's authorized_keys
36
38
  # she-servicectl teardown [--force] remove she from this host: that key, the sudoers rule, this script,
@@ -43,10 +45,17 @@
43
45
 
44
46
  set -eu
45
47
 
46
- VERSION=12
48
+ VERSION=13
47
49
  BROKER_ENV=/etc/mqtt-interfaces/broker.env
48
50
  UNIT_DIR=/etc/systemd/system
49
51
 
52
+ # tj/n, pinned: n is a self-contained bash script, so it is installed by downloading that one
53
+ # file — not with `npm install -g n`, which needs a node that may not be there yet and would
54
+ # drop n into whatever prefix the current node uses (where the node n installs then shadows it).
55
+ N_VERSION=10.2.0
56
+ N_BIN=/usr/local/bin/n
57
+ N_URL=https://raw.githubusercontent.com/tj/n/v$N_VERSION/bin/n
58
+
50
59
  die() { printf 'she-servicectl: %s\n' "$*" >&2; exit 2; }
51
60
 
52
61
  # ── validation ──────────────────────────────────────────────────────────────────
@@ -104,6 +113,11 @@ need_any() {
104
113
 
105
114
  # ── helpers ─────────────────────────────────────────────────────────────────────
106
115
 
116
+ # a JSON string, or null when the value is empty
117
+ json_str() {
118
+ [ -n "$1" ] && printf '"%s"' "$(json_escape "$1")" || printf 'null'
119
+ }
120
+
107
121
  json_escape() {
108
122
  # minimal JSON string escaping for the few free-form values (timestamps, paths)
109
123
  printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\n\r'
@@ -651,6 +665,120 @@ cmd_teardown() {
651
665
  printf 'removed %s\n' "$self"
652
666
  }
653
667
 
668
+ # ── node ────────────────────────────────────────────────────────────────────────
669
+ # Node itself is managed with tj/n (https://github.com/tj/n). Running services keep the
670
+ # binary they were started with, so restart-all is what a version change needs afterwards.
671
+
672
+ # Download the pinned n and put it at $1. Checked the way self-update checks this script:
673
+ # interpreter line, VERSION, and a syntax check — before anything is moved into place.
674
+ fetch_n() {
675
+ tmp=$(mktemp) || die "mktemp failed"
676
+ if command -v curl >/dev/null 2>&1; then
677
+ curl -fsSL -o "$tmp" "$N_URL" || { rm -f "$tmp"; die "could not download n $N_VERSION from $N_URL"; }
678
+ elif command -v wget >/dev/null 2>&1; then
679
+ wget -qO "$tmp" "$N_URL" || { rm -f "$tmp"; die "could not download n $N_VERSION from $N_URL"; }
680
+ else
681
+ rm -f "$tmp"
682
+ die "neither curl nor wget on this host — cannot fetch n (and n needs one of them itself)"
683
+ fi
684
+ head -n 1 "$tmp" | grep -q '^#!.*bash' || { rm -f "$tmp"; die "downloaded file is not the n script"; }
685
+ grep -q "^VERSION=\"\{0,1\}$N_VERSION" "$tmp" || { rm -f "$tmp"; die "downloaded n is not version $N_VERSION"; }
686
+ bash -n "$tmp" 2>/dev/null || { rm -f "$tmp"; die "downloaded n failed the syntax check"; }
687
+ install -m 0755 "$tmp" "$1" || { rm -f "$tmp"; die "could not install n to $1"; }
688
+ rm -f "$tmp"
689
+ }
690
+
691
+ cmd_node() {
692
+ [ $# -ge 1 ] || die "usage: node update [--lts|--stable]"
693
+ action=$1
694
+ shift
695
+ [ "$action" = update ] || die "unknown node action: $action"
696
+ # n's labels: lts = newest long-term-support release, latest = newest release of all.
697
+ # (n also accepts "stable", but only as its own old alias for lts — not a third channel.)
698
+ spec=lts
699
+ while [ $# -gt 0 ]; do
700
+ case "$1" in
701
+ --lts) spec=lts ;;
702
+ --latest) spec=latest ;;
703
+ --stable) spec=lts ;;
704
+ *) die "unknown option: $1" ;;
705
+ esac
706
+ shift
707
+ done
708
+
709
+ command -v bash >/dev/null 2>&1 || die "n is a bash script and this host has no bash (Alpine: apk add bash curl)"
710
+ N_PREFIX=${N_PREFIX:-/usr/local}
711
+ export N_PREFIX
712
+
713
+ before=$(node --version 2>/dev/null || true)
714
+
715
+ have=""
716
+ if [ -x "$N_BIN" ]; then have=$("$N_BIN" --version 2>/dev/null || true); fi
717
+ n_installed=false
718
+ if [ "$have" != "$N_VERSION" ]; then
719
+ fetch_n "$N_BIN"
720
+ n_installed=true
721
+ fi
722
+
723
+ "$N_BIN" install "$spec" >&2 || die "n install $spec failed"
724
+
725
+ hash -r 2>/dev/null || true
726
+ installed_path="$N_PREFIX/bin/node"
727
+ installed=$("$installed_path" --version 2>/dev/null || true)
728
+ active_path=$(command -v node 2>/dev/null || true)
729
+ after=$(node --version 2>/dev/null || true)
730
+ # n put a node in N_PREFIX, but another one (distro package, nvm, an /opt/nodeXX wrapper)
731
+ # still wins on PATH — that is the version the adapters keep running on.
732
+ mismatch=false
733
+ if [ -n "$installed" ] && [ "$after" != "$installed" ]; then mismatch=true; fi
734
+
735
+ printf '{"spec":"%s","before":%s,"after":%s,"installed":%s,"activePath":%s,"installedPath":"%s","mismatch":%s,"n":"%s","nInstalled":%s}\n' \
736
+ "$spec" \
737
+ "$(json_str "$before")" "$(json_str "$after")" "$(json_str "$installed")" "$(json_str "$active_path")" \
738
+ "$(json_escape "$installed_path")" "$mismatch" "$N_VERSION" "$n_installed"
739
+ }
740
+
741
+ # Restart every instance that is currently running — units that are stopped or disabled
742
+ # on purpose stay that way.
743
+ cmd_restart_all() {
744
+ [ $# -eq 0 ] || die "usage: restart-all"
745
+ first=1
746
+ printf '{"restarted":['
747
+ for unit in "$UNIT_DIR"/*@.service; do
748
+ [ -f "$unit" ] || continue
749
+ a=$(basename "$unit" @.service)
750
+ valid_adapter "$a" || continue
751
+ installed_adapter "$a" || continue
752
+ for envf in "/etc/$a"/*.env; do
753
+ [ -f "$envf" ] || continue
754
+ i=$(basename "$envf" .env)
755
+ valid_instance "$i" || continue
756
+ systemctl is-active --quiet "$a@$i.service" || continue
757
+ if err=$(systemctl restart "$a@$i.service" 2>&1); then ok=true; else ok=false; fi
758
+ [ $first -eq 1 ] || printf ','
759
+ first=0
760
+ printf '{"adapter":"%s","instance":"%s","ok":%s,"error":%s}' \
761
+ "$a" "$i" "$ok" \
762
+ "$([ "$ok" = true ] && printf 'null' || printf '"%s"' "$(json_escape "$err")")"
763
+ done
764
+ done
765
+ for unit in "$UNIT_DIR"/*.service; do
766
+ [ -f "$unit" ] || continue
767
+ a=$(basename "$unit" .service)
768
+ case "$a" in *@) continue ;; esac
769
+ valid_adapter "$a" || continue
770
+ legacy_unit "$a" || continue
771
+ systemctl is-active --quiet "$a.service" || continue
772
+ if err=$(systemctl restart "$a.service" 2>&1); then ok=true; else ok=false; fi
773
+ [ $first -eq 1 ] || printf ','
774
+ first=0
775
+ printf '{"adapter":"%s","instance":"-","ok":%s,"error":%s}' \
776
+ "$a" "$ok" \
777
+ "$([ "$ok" = true ] && printf 'null' || printf '"%s"' "$(json_escape "$err")")"
778
+ done
779
+ printf ']}\n'
780
+ }
781
+
654
782
  cmd_npm() {
655
783
  [ $# -ge 2 ] || die "usage: npm <adapter> version|origin|install|update|uninstall [--purge]"
656
784
  need_adapter "$1"
@@ -697,6 +825,8 @@ case "$c" in
697
825
  install) cmd_install "$@" ;;
698
826
  uninstall) cmd_uninstall "$@" ;;
699
827
  npm) cmd_npm "$@" ;;
828
+ node) cmd_node "$@" ;;
829
+ restart-all) cmd_restart_all "$@" ;;
700
830
  files) cmd_files "$@" ;;
701
831
  file) cmd_file "$@" ;;
702
832
  asset) cmd_asset "$@" ;;
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Which Node.js versions a host could be updated to — the two labels tj/n understands:
5
+ * `lts` (newest Long Term Support release) and `latest` (newest official release).
6
+ *
7
+ * she asks nodejs.org once for all hosts and caches the answer, so the Hosts tab can show
8
+ * what a click would install and hide the button when the host already runs it. Best-effort:
9
+ * every failure yields the last good answer, or nulls — nothing throws.
10
+ */
11
+
12
+ const INDEX_URL = 'https://nodejs.org/dist/index.json';
13
+ const TTL = 6 * 60 * 60 * 1000;
14
+
15
+ let _fetch = (...args) => fetch(...args);
16
+
17
+ /** Test hook: replace the fetch implementation. */
18
+ function setFetch(fn) {
19
+ _fetch = fn;
20
+ }
21
+
22
+ /** @type {{lts: string|null, ltsName: string|null, latest: string|null, ts: number, ok: boolean}|null} */
23
+ let _cache = null;
24
+ /** @type {Promise<object>|null} */
25
+ let _inflight = null;
26
+
27
+ function clearCache() {
28
+ _cache = null;
29
+ _inflight = null;
30
+ }
31
+
32
+ /**
33
+ * @param {{force?: boolean, now?: number}} [opts]
34
+ * @returns {Promise<{lts: string|null, ltsName: string|null, latest: string|null, fetchedAt: number|null, stale: boolean}>}
35
+ */
36
+ async function releases(opts = {}) {
37
+ const now = opts.now ?? Date.now();
38
+ if (_cache && !opts.force && now - _cache.ts < TTL) {
39
+ return { lts: _cache.lts, ltsName: _cache.ltsName, latest: _cache.latest, fetchedAt: _cache.ts, stale: !_cache.ok };
40
+ }
41
+ if (_inflight) return _inflight;
42
+
43
+ _inflight = (async () => {
44
+ try {
45
+ const res = await _fetch(INDEX_URL, { signal: AbortSignal.timeout(15000) });
46
+ if (!res.ok) throw new Error(String(res.status));
47
+ const list = await res.json();
48
+ if (!Array.isArray(list) || list.length === 0) throw new Error('unexpected index.json');
49
+ // index.json is ordered newest first; an LTS entry carries its codename in `lts`
50
+ const latest = typeof list[0].version === 'string' ? list[0].version : null;
51
+ const ltsEntry = list.find((e) => e && e.lts && typeof e.version === 'string') || null;
52
+ _cache = { lts: ltsEntry ? ltsEntry.version : null, ltsName: ltsEntry ? String(ltsEntry.lts) : null, latest, ts: now, ok: true };
53
+ } catch {
54
+ // keep whatever was known before, just mark it stale
55
+ _cache = _cache ? { ..._cache, ts: now, ok: false } : { lts: null, ltsName: null, latest: null, ts: now, ok: false };
56
+ } finally {
57
+ _inflight = null;
58
+ }
59
+ return { lts: _cache.lts, ltsName: _cache.ltsName, latest: _cache.latest, fetchedAt: _cache.ok ? _cache.ts : null, stale: !_cache.ok };
60
+ })();
61
+
62
+ return _inflight;
63
+ }
64
+
65
+ module.exports = { releases, setFetch, clearCache, INDEX_URL, 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 = 12; // must match VERSION in service/she-servicectl (test/unit/helper-version.test.js)
29
+ const HELPER_VERSION = 13; // must match VERSION in service/she-servicectl (test/unit/helper-version.test.js)
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). */
@@ -50,4 +50,51 @@ router.put('/', (req, res) => {
50
50
  }
51
51
  });
52
52
 
53
+ /**
54
+ * PATCH / — write a single subtree, leaving the rest of the file as it is on disk.
55
+ *
56
+ * Body: { path: 'broker.ssh', value: … }. A page that edits one branch of the config
57
+ * must not send the whole document back: it would carry a snapshot taken when the page
58
+ * was opened and silently revert anything another page wrote in the meantime (that is
59
+ * how a freshly configured services.hosts list disappeared when the broker SSH page was
60
+ * saved afterwards). Omitting `value` deletes the key.
61
+ */
62
+ router.patch('/', (req, res) => {
63
+ const configPath = req.app.locals.configPath || DEFAULT_CONFIG_PATH;
64
+ try {
65
+ const { path: dotted, value } = req.body || {};
66
+ if (typeof dotted !== 'string' || !dotted) return res.status(400).json({ error: 'path required' });
67
+ const segments = dotted.split('.');
68
+ if (segments.length > 5 || !segments.every((k) => /^[A-Za-z0-9_-]+$/.test(k))) {
69
+ return res.status(400).json({ error: `invalid config path: ${dotted}` });
70
+ }
71
+
72
+ let config = {};
73
+ try {
74
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
75
+ } catch {
76
+ /* ok — file may not exist yet */
77
+ }
78
+ if (!config || typeof config !== 'object' || Array.isArray(config)) config = {};
79
+
80
+ const before = JSON.stringify(config[segments[0]]);
81
+ let node = config;
82
+ for (const key of segments.slice(0, -1)) {
83
+ if (!node[key] || typeof node[key] !== 'object' || Array.isArray(node[key])) node[key] = {};
84
+ node = node[key];
85
+ }
86
+ const last = segments[segments.length - 1];
87
+ if (value === undefined) delete node[last];
88
+ else node[last] = value;
89
+
90
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
91
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
92
+
93
+ const restartRequired = !FRONTEND_ONLY_KEYS.has(segments[0]) && before !== JSON.stringify(config[segments[0]]);
94
+ res.json({ ok: true, restartRequired, configPath });
95
+ } catch (err) {
96
+ res.status(500).json({ error: err.message });
97
+ }
98
+ });
99
+
53
100
  module.exports = { router, DEFAULT_CONFIG_PATH };
@@ -29,6 +29,9 @@
29
29
  * GET /she/services/ssh/pubkey public key of the services identity (I5)
30
30
  * POST /she/services/ssh/keygen generate it
31
31
  * POST /she/services/hosts/:host/test run `she-servicectl version` → ok / code
32
+ * GET /she/services/node/releases[?refresh=1] what a node update would install: the newest lts and latest release
33
+ * POST /she/services/hosts/:host/node/update { channel: lts|latest } update Node.js with tj/n (helper v13)
34
+ * POST /she/services/hosts/:host/instances/restart-all restart every running instance (after a node update)
32
35
  * POST /she/services/hosts/:host/helper/deploy scp the helper to a remote host, install it, print the sudoers line
33
36
  * POST /she/services/hosts/:host/helper/remove { mode: key|all, force? } remove she from the host (I11), drop the host entry
34
37
  * POST /she/services/setup/token mint a one-time token + the curl | bash command (I9)
@@ -54,6 +57,7 @@ const dns = require('dns');
54
57
  const { analyzeServices, wipeTopics, LOG_LEVELS } = require('../lib/services-inventory');
55
58
  const { shapeDevices, discoverTarget } = require('../lib/device-discovery');
56
59
  const npmRegistry = require('../lib/npm-registry');
60
+ const nodeReleases = require('../lib/node-releases');
57
61
  const catalog = require('../lib/services-catalog');
58
62
  const os = require('os');
59
63
  const path = require('path');
@@ -1030,6 +1034,65 @@ router.post('/hosts/:host/adapters/:adapter/update', async (req, res) => {
1030
1034
  }
1031
1035
  });
1032
1036
 
1037
+ /** The two node commands need helper v13; older hosts answer with "unknown command". */
1038
+ function nodeHostError(res, err) {
1039
+ if (/unknown command/i.test(err.message || '')) {
1040
+ return res.status(400).json({
1041
+ error: 'the she-servicectl helper on this host is too old for Node.js management — update it on the Hosts tab',
1042
+ code: 'HELPER_OUTDATED',
1043
+ });
1044
+ }
1045
+ return hostError(res, err);
1046
+ }
1047
+
1048
+ // GET /she/services/node/releases[?refresh=1] — what the update buttons would install
1049
+ router.get('/node/releases', async (req, res) => {
1050
+ res.json(await nodeReleases.releases({ force: req.query.refresh === '1' }));
1051
+ });
1052
+
1053
+ // POST /she/services/hosts/:host/node/update { channel?: 'lts' | 'latest' }
1054
+ router.post('/hosts/:host/node/update', async (req, res) => {
1055
+ const entry = resolve(req, res);
1056
+ if (!entry) return;
1057
+ // 'stable' is n's old alias for lts — accepted so an older client keeps working
1058
+ const channel = (req.body && req.body.channel) === 'stable' ? 'lts' : (req.body && req.body.channel) || 'lts';
1059
+ if (channel !== 'lts' && channel !== 'latest') return res.status(400).json({ error: "channel must be 'lts' or 'latest'" });
1060
+ try {
1061
+ // downloading and unpacking a node build on a small host takes a while
1062
+ const { stdout } = await entry.driver.exec(['node', 'update', '--' + channel], { timeout: 900000 });
1063
+ let result = {};
1064
+ try {
1065
+ result = JSON.parse(stdout);
1066
+ } catch {
1067
+ return res.status(500).json({ error: 'helper returned unexpected output', output: stdout });
1068
+ }
1069
+ invalidateHosts();
1070
+ res.json({ ok: true, ...result, restartRequired: result.before !== result.after });
1071
+ } catch (err) {
1072
+ nodeHostError(res, err);
1073
+ }
1074
+ });
1075
+
1076
+ // POST /she/services/hosts/:host/instances/restart-all
1077
+ router.post('/hosts/:host/instances/restart-all', async (req, res) => {
1078
+ const entry = resolve(req, res);
1079
+ if (!entry) return;
1080
+ try {
1081
+ const { stdout } = await entry.driver.exec(['restart-all'], { timeout: 300000 });
1082
+ let result = { restarted: [] };
1083
+ try {
1084
+ result = JSON.parse(stdout);
1085
+ } catch {
1086
+ return res.status(500).json({ error: 'helper returned unexpected output', output: stdout });
1087
+ }
1088
+ const restarted = (result.restarted || []).filter((r) => r.ok);
1089
+ const failed = (result.restarted || []).filter((r) => !r.ok);
1090
+ res.json({ ok: failed.length === 0, restarted, failed });
1091
+ } catch (err) {
1092
+ nodeHostError(res, err);
1093
+ }
1094
+ });
1095
+
1033
1096
  // POST /she/services/hosts/:host/units/:adapter/-/migrate { name } — legacy <adapter>.service → <adapter>@<name>
1034
1097
  router.post('/hosts/:host/units/:adapter/' + LEGACY + '/migrate', async (req, res) => {
1035
1098
  const entry = resolve(req, res, { adapter: true });