smart-home-engine 1.41.1 → 1.43.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-BSntxSQW.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-Crx1MbU9.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-BSntxSQW.js"></script>
175
+ <script type="module" crossorigin src="/assets/index-Crx1MbU9.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-CEoqAVCK.css">
178
+ <link rel="stylesheet" crossorigin href="/assets/index-dFdInRyC.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.41.1",
3
+ "version": "1.43.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] install/refresh tj/n (downloaded, no npm), then n stable (or n lts) — 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,117 @@ 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
+ spec=stable
697
+ while [ $# -gt 0 ]; do
698
+ case "$1" in
699
+ --lts) spec=lts ;;
700
+ --stable) spec=stable ;;
701
+ *) die "unknown option: $1" ;;
702
+ esac
703
+ shift
704
+ done
705
+
706
+ command -v bash >/dev/null 2>&1 || die "n is a bash script and this host has no bash (Alpine: apk add bash curl)"
707
+ N_PREFIX=${N_PREFIX:-/usr/local}
708
+ export N_PREFIX
709
+
710
+ before=$(node --version 2>/dev/null || true)
711
+
712
+ have=""
713
+ if [ -x "$N_BIN" ]; then have=$("$N_BIN" --version 2>/dev/null || true); fi
714
+ n_installed=false
715
+ if [ "$have" != "$N_VERSION" ]; then
716
+ fetch_n "$N_BIN"
717
+ n_installed=true
718
+ fi
719
+
720
+ "$N_BIN" install "$spec" >&2 || die "n install $spec failed"
721
+
722
+ hash -r 2>/dev/null || true
723
+ installed_path="$N_PREFIX/bin/node"
724
+ installed=$("$installed_path" --version 2>/dev/null || true)
725
+ active_path=$(command -v node 2>/dev/null || true)
726
+ after=$(node --version 2>/dev/null || true)
727
+ # n put a node in N_PREFIX, but another one (distro package, nvm, an /opt/nodeXX wrapper)
728
+ # still wins on PATH — that is the version the adapters keep running on.
729
+ mismatch=false
730
+ if [ -n "$installed" ] && [ "$after" != "$installed" ]; then mismatch=true; fi
731
+
732
+ printf '{"spec":"%s","before":%s,"after":%s,"installed":%s,"activePath":%s,"installedPath":"%s","mismatch":%s,"n":"%s","nInstalled":%s}\n' \
733
+ "$spec" \
734
+ "$(json_str "$before")" "$(json_str "$after")" "$(json_str "$installed")" "$(json_str "$active_path")" \
735
+ "$(json_escape "$installed_path")" "$mismatch" "$N_VERSION" "$n_installed"
736
+ }
737
+
738
+ # Restart every instance that is currently running — units that are stopped or disabled
739
+ # on purpose stay that way.
740
+ cmd_restart_all() {
741
+ [ $# -eq 0 ] || die "usage: restart-all"
742
+ first=1
743
+ printf '{"restarted":['
744
+ for unit in "$UNIT_DIR"/*@.service; do
745
+ [ -f "$unit" ] || continue
746
+ a=$(basename "$unit" @.service)
747
+ valid_adapter "$a" || continue
748
+ installed_adapter "$a" || continue
749
+ for envf in "/etc/$a"/*.env; do
750
+ [ -f "$envf" ] || continue
751
+ i=$(basename "$envf" .env)
752
+ valid_instance "$i" || continue
753
+ systemctl is-active --quiet "$a@$i.service" || continue
754
+ if err=$(systemctl restart "$a@$i.service" 2>&1); then ok=true; else ok=false; fi
755
+ [ $first -eq 1 ] || printf ','
756
+ first=0
757
+ printf '{"adapter":"%s","instance":"%s","ok":%s,"error":%s}' \
758
+ "$a" "$i" "$ok" \
759
+ "$([ "$ok" = true ] && printf 'null' || printf '"%s"' "$(json_escape "$err")")"
760
+ done
761
+ done
762
+ for unit in "$UNIT_DIR"/*.service; do
763
+ [ -f "$unit" ] || continue
764
+ a=$(basename "$unit" .service)
765
+ case "$a" in *@) continue ;; esac
766
+ valid_adapter "$a" || continue
767
+ legacy_unit "$a" || continue
768
+ systemctl is-active --quiet "$a.service" || continue
769
+ if err=$(systemctl restart "$a.service" 2>&1); then ok=true; else ok=false; fi
770
+ [ $first -eq 1 ] || printf ','
771
+ first=0
772
+ printf '{"adapter":"%s","instance":"-","ok":%s,"error":%s}' \
773
+ "$a" "$ok" \
774
+ "$([ "$ok" = true ] && printf 'null' || printf '"%s"' "$(json_escape "$err")")"
775
+ done
776
+ printf ']}\n'
777
+ }
778
+
654
779
  cmd_npm() {
655
780
  [ $# -ge 2 ] || die "usage: npm <adapter> version|origin|install|update|uninstall [--purge]"
656
781
  need_adapter "$1"
@@ -697,6 +822,8 @@ case "$c" in
697
822
  install) cmd_install "$@" ;;
698
823
  uninstall) cmd_uninstall "$@" ;;
699
824
  npm) cmd_npm "$@" ;;
825
+ node) cmd_node "$@" ;;
826
+ restart-all) cmd_restart_all "$@" ;;
700
827
  files) cmd_files "$@" ;;
701
828
  file) cmd_file "$@" ;;
702
829
  asset) cmd_asset "$@" ;;
@@ -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). */
@@ -16,6 +16,7 @@
16
16
  const express = require('express');
17
17
  const path = require('path');
18
18
  const fs = require('fs');
19
+ const os = require('os');
19
20
  const crypto = require('crypto');
20
21
  const { execFile, spawn } = require('child_process');
21
22
  const { promisify } = require('util');
@@ -30,6 +31,9 @@ const { getBrokerLogBuffer } = require('./log-ws');
30
31
  // Default SSH identity file respects the configured data directory
31
32
  const DEFAULT_SSH_KEY = path.join(sheConfig['data-dir'], 'ssh', 'broker_id_ed25519');
32
33
 
34
+ // ssh-deploy falls back to the account she runs under when broker.ssh.user is unset
35
+ const DEFAULT_SSH_USER = os.userInfo().username;
36
+
33
37
  const router = express.Router();
34
38
 
35
39
  let _log = null;
@@ -110,7 +114,7 @@ router.get('/status', (req, res) => {
110
114
  }
111
115
  }
112
116
 
113
- res.json({ dynsec: ds, sys, sshKeyDefault: DEFAULT_SSH_KEY, sshConfigured, sshHost });
117
+ res.json({ dynsec: ds, sys, sshKeyDefault: DEFAULT_SSH_KEY, sshUserDefault: DEFAULT_SSH_USER, sshConfigured, sshHost });
114
118
  });
115
119
 
116
120
  // ── mosquitto.conf ─────────────────────────────────────────────────────────────
@@ -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,8 @@
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
+ * POST /she/services/hosts/:host/node/update { channel: stable|lts } update Node.js with tj/n (helper v13)
33
+ * POST /she/services/hosts/:host/instances/restart-all restart every running instance (after a node update)
32
34
  * POST /she/services/hosts/:host/helper/deploy scp the helper to a remote host, install it, print the sudoers line
33
35
  * POST /she/services/hosts/:host/helper/remove { mode: key|all, force? } remove she from the host (I11), drop the host entry
34
36
  * POST /she/services/setup/token mint a one-time token + the curl | bash command (I9)
@@ -1030,6 +1032,59 @@ router.post('/hosts/:host/adapters/:adapter/update', async (req, res) => {
1030
1032
  }
1031
1033
  });
1032
1034
 
1035
+ /** The two node commands need helper v13; older hosts answer with "unknown command". */
1036
+ function nodeHostError(res, err) {
1037
+ if (/unknown command/i.test(err.message || '')) {
1038
+ return res.status(400).json({
1039
+ error: 'the she-servicectl helper on this host is too old for Node.js management — update it on the Hosts tab',
1040
+ code: 'HELPER_OUTDATED',
1041
+ });
1042
+ }
1043
+ return hostError(res, err);
1044
+ }
1045
+
1046
+ // POST /she/services/hosts/:host/node/update { channel?: 'stable' | 'lts' }
1047
+ router.post('/hosts/:host/node/update', async (req, res) => {
1048
+ const entry = resolve(req, res);
1049
+ if (!entry) return;
1050
+ const channel = (req.body && req.body.channel) || 'stable';
1051
+ if (channel !== 'stable' && channel !== 'lts') return res.status(400).json({ error: "channel must be 'stable' or 'lts'" });
1052
+ try {
1053
+ // downloading and unpacking a node build on a small host takes a while
1054
+ const { stdout } = await entry.driver.exec(['node', 'update', '--' + channel], { timeout: 900000 });
1055
+ let result = {};
1056
+ try {
1057
+ result = JSON.parse(stdout);
1058
+ } catch {
1059
+ return res.status(500).json({ error: 'helper returned unexpected output', output: stdout });
1060
+ }
1061
+ invalidateHosts();
1062
+ res.json({ ok: true, ...result, restartRequired: result.before !== result.after });
1063
+ } catch (err) {
1064
+ nodeHostError(res, err);
1065
+ }
1066
+ });
1067
+
1068
+ // POST /she/services/hosts/:host/instances/restart-all
1069
+ router.post('/hosts/:host/instances/restart-all', async (req, res) => {
1070
+ const entry = resolve(req, res);
1071
+ if (!entry) return;
1072
+ try {
1073
+ const { stdout } = await entry.driver.exec(['restart-all'], { timeout: 300000 });
1074
+ let result = { restarted: [] };
1075
+ try {
1076
+ result = JSON.parse(stdout);
1077
+ } catch {
1078
+ return res.status(500).json({ error: 'helper returned unexpected output', output: stdout });
1079
+ }
1080
+ const restarted = (result.restarted || []).filter((r) => r.ok);
1081
+ const failed = (result.restarted || []).filter((r) => !r.ok);
1082
+ res.json({ ok: failed.length === 0, restarted, failed });
1083
+ } catch (err) {
1084
+ nodeHostError(res, err);
1085
+ }
1086
+ });
1087
+
1033
1088
  // POST /she/services/hosts/:host/units/:adapter/-/migrate { name } — legacy <adapter>.service → <adapter>@<name>
1034
1089
  router.post('/hosts/:host/units/:adapter/' + LEGACY + '/migrate', async (req, res) => {
1035
1090
  const entry = resolve(req, res, { adapter: true });