smart-home-engine 3.0.0-beta.0 → 3.0.0-beta.2

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-BZWF5ORr.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-DbYEAVZF.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -175,10 +175,10 @@
175
175
  }
176
176
  })();
177
177
  </script>
178
- <script type="module" crossorigin src="/assets/index-BZWF5ORr.js"></script>
178
+ <script type="module" crossorigin src="/assets/index-DbYEAVZF.js"></script>
179
179
  <link rel="modulepreload" crossorigin href="/assets/monaco-langs-DKJI_o9J.js">
180
180
  <link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
181
- <link rel="stylesheet" crossorigin href="/assets/index-CdLKmsX4.css">
181
+ <link rel="stylesheet" crossorigin href="/assets/index-eopsqoPx.css">
182
182
  </head>
183
183
  <body>
184
184
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "3.0.0-beta.0",
3
+ "version": "3.0.0-beta.2",
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": {
@@ -45,6 +45,9 @@ install -m 755 -o root -g root "$HELPER_SRC" "$HELPER_DST"
45
45
  echo "installed $HELPER_DST"
46
46
 
47
47
  # --- sudoers rules -------------------------------------------------------
48
+ # she's own update runs through the helper (she-servicectl she-update <version>). The npm line is
49
+ # the fallback for a daemon that still updates with the literal command (she up to 3.0.0-beta.1);
50
+ # it goes away in a later release.
48
51
  NPM_BIN="$(command -v npm)"
49
52
  SUDOERS_FILE=/etc/sudoers.d/she
50
53
  cat > "$SUDOERS_FILE" <<EOF
@@ -34,6 +34,7 @@
34
34
  # she-servicectl node update [--lts|--latest|--stable|--version X.Y.Z] install/refresh tj/n (downloaded, no npm), then n install <label|version> — JSON with the version before and after
35
35
  # she-servicectl restart-all restart every running instance on this host (after a node update, say)
36
36
  # she-servicectl self-update replace this script with the one on stdin (she ships it)
37
+ # she-servicectl she-update <version> she's own update: npm install -g smart-home-engine@<version> (the she host only)
37
38
  # she-servicectl remove-key drop the public key on stdin from the calling user's authorized_keys
38
39
  # she-servicectl teardown [--force] remove she from this host: that key, the sudoers rule, this script,
39
40
  # the she-services user (adapters and their instances stay untouched)
@@ -45,7 +46,7 @@
45
46
 
46
47
  set -eu
47
48
 
48
- VERSION=15
49
+ VERSION=16
49
50
  BROKER_ENV=/etc/mqtt-interfaces/broker.env
50
51
  UNIT_DIR=/etc/systemd/system
51
52
 
@@ -62,6 +63,9 @@ die() { printf 'she-servicectl: %s\n' "$*" >&2; exit 2; }
62
63
 
63
64
  valid_adapter() { printf '%s' "$1" | grep -Eq '^[a-z0-9][a-z0-9._-]{0,213}$'; }
64
65
  valid_instance() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9_.-]{1,64}$'; }
66
+ # a release version as npm publishes it (pre-release suffix allowed): nothing npm could read as a
67
+ # range, a dist-tag, a URL or a second package
68
+ valid_she_version() { printf '%s' "$1" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; }
65
69
 
66
70
  # a template unit written by mqtt-interfaces-core's installer: per-instance env file under /etc/<adapter>/
67
71
  installed_adapter() {
@@ -632,6 +636,22 @@ cmd_self_update() {
632
636
  printf 'she-servicectl updated %s -> %s at %s\n' "$VERSION" "$newver" "$self"
633
637
  }
634
638
 
639
+ # ── she's own update ────────────────────────────────────────────────────────────
640
+ # The update button of the she daemon (v16). she's update channels install an exact version, and
641
+ # the sudoers line `she --install` used to write for that — `npm install -g smart-home-engine`,
642
+ # matched literally by sudo — cannot take one; this command can, through the rule the helper
643
+ # already has. The version is checked strictly, npm's exit code is the command's, and the npm is
644
+ # the one next to the node that runs she (as for the adapters). Meant for the she host: the daemon
645
+ # calls it locally only, and the command refuses where no she is installed.
646
+ cmd_she_update() {
647
+ [ $# -eq 1 ] || die "usage: she-update <version>"
648
+ valid_she_version "$1" || die "invalid version: $1"
649
+ adapter_cmd she >/dev/null 2>&1 || die "she is not installed on this host"
650
+ npm=$(adapter_npm she)
651
+ node=$(adapter_node she)
652
+ PATH="$(dirname "$node"):$PATH" exec "$npm" install -g "smart-home-engine@$1" --no-audit --no-fund
653
+ }
654
+
635
655
  # ── removing she from this host ─────────────────────────────────────────────────
636
656
  # The account she logs in as is the sudo caller (root when she connects as root).
637
657
  caller_user() { printf '%s' "${SUDO_USER:-$(id -un)}"; }
@@ -869,6 +889,7 @@ case "$c" in
869
889
  file) cmd_file "$@" ;;
870
890
  asset) cmd_asset "$@" ;;
871
891
  self-update) cmd_self_update "$@" ;;
892
+ she-update) cmd_she_update "$@" ;;
872
893
  migrate) cmd_migrate "$@" ;;
873
894
  remove-key) cmd_remove_key "$@" ;;
874
895
  teardown) cmd_teardown "$@" ;;
package/src/index.js CHANGED
@@ -245,6 +245,7 @@ if (typeof config.port !== 'undefined') {
245
245
  process.exit(1);
246
246
  });
247
247
  require('./web/broker-api').setLogger(log);
248
+ require('./web/server').setLogger(log);
248
249
  }
249
250
 
250
251
  const chokidar = require('chokidar');
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+ /**
3
+ * she's own update: how the version the update check offers gets installed, and when the daemon
4
+ * may restart afterwards.
5
+ *
6
+ * The daemon runs as the `she` user without a general sudo. Since 3.0.0-beta.2 the install goes
7
+ * through the privileged helper — `she-servicectl she-update <version>` (helper v16) — which checks
8
+ * the version strictly and runs `npm install -g smart-home-engine@<version>` as root through the one
9
+ * sudoers rule every installation has. Before that, `she --install` wrote a second rule allowing
10
+ * exactly `npm install -g smart-home-engine`: sudo matches arguments literally, so the exact-version
11
+ * install the update channels need (3.0.0-beta.0) was refused — and the daemon restarted on the old
12
+ * version regardless, which made the failure invisible. That literal command installs npm's `latest`
13
+ * and is kept as the fallback for a host whose helper is still older than v16, but only when the
14
+ * offered version *is* `latest`; otherwise the update asks for the helper update first.
15
+ *
16
+ * The restart happens only after npm exited 0. A failure keeps the daemon running and reports npm's
17
+ * exit code and the last lines of its output.
18
+ */
19
+
20
+ const VERSION_RX = /^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/;
21
+ /** the first helper version with the `she-update` command */
22
+ const HELPER_SHE_UPDATE_VERSION = 16;
23
+ const PACKAGE = 'smart-home-engine';
24
+ const HELPER = '/usr/local/bin/she-servicectl';
25
+ const HELPER_NEEDED = 'update needs the current she-servicectl helper — Update helper on the Services → Hosts tab, or run `sudo she --install` once';
26
+ /** output lines kept for the log and the error message */
27
+ const TAIL_LINES = 12;
28
+ const TAIL_LINES_IN_ERROR = 3;
29
+
30
+ /** A release version as npm publishes it: `3.0.0`, `3.0.0-beta.2` — no ranges, tags, URLs or spaces. */
31
+ function isValidVersion(v) {
32
+ return typeof v === 'string' && VERSION_RX.test(v);
33
+ }
34
+
35
+ /** The helper version a driver reports, or null when the helper is missing, denied or unreadable. */
36
+ async function helperVersionOf(driver) {
37
+ try {
38
+ const { stdout } = await driver.exec(['version'], { timeout: 20000 });
39
+ const n = Number(String(stdout).trim());
40
+ return Number.isInteger(n) && n > 0 ? n : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * How to install `version`: through the helper when it has `she-update`, through the old literal
48
+ * sudo rule when the version is npm's `latest`, otherwise not at all.
49
+ * @param {{version: string, helperVersion: number|null, npmLatest?: string|null}} ctx
50
+ * @returns {{mode: 'helper'|'npm-latest'}|{error: string}}
51
+ */
52
+ function planUpdate({ version, helperVersion, npmLatest }) {
53
+ if (!isValidVersion(version)) return { error: `not a release version: ${version}` };
54
+ if (Number.isInteger(helperVersion) && helperVersion >= HELPER_SHE_UPDATE_VERSION) return { mode: 'helper' };
55
+ if (npmLatest && npmLatest === version) return { mode: 'npm-latest' };
56
+ return { error: HELPER_NEEDED };
57
+ }
58
+
59
+ /** The command a plan runs: `[cmd, argv]`. */
60
+ function commandFor(plan, version, { helper = HELPER } = {}) {
61
+ if (plan.mode === 'helper') return ['sudo', ['-n', helper, 'she-update', version]];
62
+ if (plan.mode === 'npm-latest') return ['sudo', ['npm', 'install', '-g', PACKAGE]];
63
+ throw new Error('no command for this plan');
64
+ }
65
+
66
+ /**
67
+ * Run the install and wait for it. Never rejects.
68
+ * @param {[string, string[]]} command
69
+ * @param {{spawn: Function, onLine?: (line: string) => void}} opts spawn: child_process.spawn or a stub
70
+ * @returns {Promise<{ok: boolean, code: number|null, signal: string|null, tail: string[], reason: string}>}
71
+ */
72
+ function runInstall([cmd, argv], { spawn, onLine } = {}) {
73
+ return new Promise((resolve) => {
74
+ const tail = [];
75
+ let partial = '';
76
+ const take = (chunk) => {
77
+ partial += String(chunk);
78
+ const lines = partial.split(/\r?\n/);
79
+ partial = lines.pop();
80
+ for (const raw of lines) {
81
+ const line = raw.replace(/\r/g, '').trimEnd();
82
+ if (!line.trim()) continue;
83
+ tail.push(line);
84
+ if (tail.length > TAIL_LINES) tail.shift();
85
+ if (onLine) onLine(line);
86
+ }
87
+ };
88
+ const finish = (ok, code, signal, reason) => {
89
+ if (partial.trim()) take('\n');
90
+ resolve({ ok, code, signal, tail: tail.slice(), reason });
91
+ };
92
+ let child;
93
+ try {
94
+ child = spawn(cmd, argv, { stdio: ['ignore', 'pipe', 'pipe'] });
95
+ } catch (err) {
96
+ finish(false, null, null, `could not start ${cmd}: ${err.message}`);
97
+ return;
98
+ }
99
+ if (child.stdout) child.stdout.on('data', take);
100
+ if (child.stderr) child.stderr.on('data', take);
101
+ let done = false;
102
+ child.on('error', (err) => {
103
+ if (done) return;
104
+ done = true;
105
+ finish(false, null, null, `could not start ${cmd}: ${err.message}`);
106
+ });
107
+ child.on('close', (code, signal) => {
108
+ if (done) return;
109
+ done = true;
110
+ if (code === 0) finish(true, 0, null, 'ok');
111
+ else finish(false, code, signal, signal ? `npm was killed by ${signal}` : `npm exited ${code}`);
112
+ });
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Install `version` as planned and restart only when npm succeeded.
118
+ * @param {{plan: {mode: string}, version: string, spawn: Function, onRestart: Function, log?: (level: string, msg: string) => void, helper?: string}} opts
119
+ * @returns {Promise<{ok: true}|{ok: false, error: string}>} `error` is what the UI shows
120
+ */
121
+ async function performUpdate({ plan, version, spawn, onRestart, log = () => {}, helper }) {
122
+ const command = commandFor(plan, version, { helper });
123
+ log('info', `update: installing ${PACKAGE}@${version} ${plan.mode === 'helper' ? 'through she-servicectl' : "through the literal sudo rule (npm's latest)"}`);
124
+ const r = await runInstall(command, { spawn, onLine: (line) => log('info', `update: ${line}`) });
125
+ if (r.ok) {
126
+ log('info', `update: ${version} installed, restarting`);
127
+ onRestart();
128
+ return { ok: true };
129
+ }
130
+ const tail = r.tail.slice(-TAIL_LINES_IN_ERROR).join(' | ');
131
+ const error = `update to ${version} failed: ${r.reason}${tail ? ' — ' + tail : ''}`;
132
+ log('error', `${error}${r.tail.length ? '\n' + r.tail.join('\n') : ''}`);
133
+ return { ok: false, error };
134
+ }
135
+
136
+ module.exports = { HELPER_SHE_UPDATE_VERSION, HELPER_NEEDED, PACKAGE, VERSION_RX, isValidVersion, helperVersionOf, planUpdate, commandFor, runInstall, performUpdate };
@@ -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 = 15; // must match VERSION in service/she-servicectl (test/unit/helper-version.test.js)
29
+ const HELPER_VERSION = 16; // 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). */
@@ -97,6 +97,21 @@ async function existsOnNpm(version) {
97
97
  return data && data.version === version;
98
98
  }
99
99
 
100
+ /**
101
+ * npm's `latest` dist-tag of the package, or null when the registry does not answer. Only the
102
+ * self-update's fallback asks (the literal `npm install -g smart-home-engine` installs exactly this).
103
+ */
104
+ async function npmLatest() {
105
+ try {
106
+ const res = await _fetch(`${NPM_REGISTRY}/${PACKAGE}/latest`, { signal: AbortSignal.timeout(10000) });
107
+ if (!res.ok) return null;
108
+ const data = await res.json();
109
+ return data && VERSION_RX.test(String(data.version)) ? data.version : null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
100
115
  /**
101
116
  * Check for an update on a channel.
102
117
  * @param {object} opts
@@ -149,4 +164,4 @@ async function checkUpdate(opts) {
149
164
  return result;
150
165
  }
151
166
 
152
- module.exports = { CHANNELS, PACKAGE, checkUpdate, newestOf, offeredVersion, versionOfTag, isPrerelease, normalizeChannel, setFetch };
167
+ module.exports = { CHANNELS, PACKAGE, checkUpdate, npmLatest, newestOf, offeredVersion, versionOfTag, isPrerelease, normalizeChannel, setFetch };
package/src/web/server.js CHANGED
@@ -6,6 +6,8 @@ const { spawn } = require('child_process');
6
6
  const pkg = require('../../package.json');
7
7
  const { router: configRouter, DEFAULT_CONFIG_PATH } = require('./config-api');
8
8
  const updateChannel = require('../lib/update-channel');
9
+ const selfUpdate = require('../lib/self-update');
10
+ const servicesHost = require('../lib/services-host');
9
11
  const { router: scriptsRouter } = require('./scripts-api');
10
12
  const { router: shedbRouter } = require('./shedb-api');
11
13
  const { router: matterRouter } = require('./matter-api');
@@ -127,7 +129,9 @@ function _updateFields() {
127
129
  }
128
130
  if (_update && _update.stable) f.latestStable = _update.stable;
129
131
  if (_update && _update.prerelease) f.latestBeta = _update.prerelease;
130
- if (_update && _update.error) f.updateError = _update.error;
132
+ if (_updateFailed) f.updateError = _updateFailed;
133
+ else if (_update && _update.error) f.updateError = _update.error;
134
+ if (_updating) f.updating = true;
131
135
  return f;
132
136
  }
133
137
 
@@ -137,21 +141,51 @@ app.post('/she/check-update', async (req, res) => {
137
141
  res.json({ latestVersion: r.latest, ..._updateFields() });
138
142
  });
139
143
 
140
- // Update — install the offered version exactly, then restart. Without a known version there is
141
- // nothing to install: `npm install -g smart-home-engine` would take npm's `latest`, which can be
142
- // older than a running pre-release.
143
- app.post('/she/update', (req, res) => {
144
+ // Update — install the offered version exactly, then restart — but only after npm succeeded.
145
+ // The install runs through the helper (`she-servicectl she-update <version>`, v16) when the host
146
+ // has it; a host with an older helper falls back to the literal `sudo npm install -g
147
+ // smart-home-engine` of the old sudoers rule when the offered version is npm's `latest`, and is
148
+ // asked to update the helper otherwise. Without a known version there is nothing to install.
149
+ let _updating = false; // an install is running
150
+ let _updateFailed = null; // the last failed install's message, until the next attempt
151
+
152
+ function _log(level, msg) {
153
+ if (_logger) _logger[level](msg);
154
+ else (level === 'error' || level === 'warn' ? console.error : console.log)(msg);
155
+ }
156
+
157
+ async function _planUpdate(version) {
158
+ const helperVersion = await selfUpdate.helperVersionOf(servicesHost.createLocalDriver());
159
+ // npm's `latest` matters only for the fallback, so it is fetched only when the helper is too old
160
+ const helperOk = Number.isInteger(helperVersion) && helperVersion >= selfUpdate.HELPER_SHE_UPDATE_VERSION;
161
+ return selfUpdate.planUpdate({ version, helperVersion, npmLatest: helperOk ? null : await updateChannel.npmLatest() });
162
+ }
163
+
164
+ app.post('/she/update', async (req, res) => {
144
165
  const version = _update && _update.latest;
145
166
  if (!version || _isDocker) {
146
167
  res.status(409).json({ ok: false, error: _isDocker ? 'a Docker container is updated by pulling a new image' : 'no update known — check for updates first' });
147
168
  return;
148
169
  }
149
- res.json({ ok: true, version });
150
- setTimeout(() => {
151
- // Use async spawn so the HTTP server (and event loop) remain responsive
152
- // while npm runs. spawnSync would block all HTTP polling from the UI.
153
- const child = spawn('sudo', ['npm', 'install', '-g', `${updateChannel.PACKAGE}@${version}`], { stdio: 'inherit' });
154
- child.on('close', () => _systemdRestart());
170
+ if (_updating) {
171
+ res.status(409).json({ ok: false, error: 'an update is already running' });
172
+ return;
173
+ }
174
+ const plan = await _planUpdate(version);
175
+ if (plan.error) {
176
+ _updateFailed = plan.error;
177
+ _log('warn', `update to ${version} not possible: ${plan.error}`);
178
+ res.status(409).json({ ok: false, error: plan.error });
179
+ return;
180
+ }
181
+ _updating = true;
182
+ _updateFailed = null;
183
+ res.json({ ok: true, version, via: plan.mode });
184
+ // async spawn, so the HTTP server (and the UI's polling) stays responsive while npm runs
185
+ setTimeout(async () => {
186
+ const r = await selfUpdate.performUpdate({ plan, version, spawn, onRestart: _systemdRestart, log: _log });
187
+ _updating = false;
188
+ if (!r.ok) _updateFailed = r.error;
155
189
  }, 200);
156
190
  });
157
191
 
@@ -356,4 +390,10 @@ function stopServer() {
356
390
  });
357
391
  }
358
392
 
359
- module.exports = { app, registerRoute, unregisterRoutesByScript, setStatsProvider, setHealthProvider, startServer, stopServer };
393
+ let _logger = null;
394
+ /** The daemon's logger; without one the update path writes to the console. */
395
+ function setLogger(log) {
396
+ _logger = log;
397
+ }
398
+
399
+ module.exports = { app, registerRoute, unregisterRoutesByScript, setStatsProvider, setHealthProvider, setLogger, startServer, stopServer };