smart-home-engine 1.0.10 → 1.1.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.
@@ -0,0 +1,216 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ssh-deploy.js — SSH/SFTP file deployment helper for remote broker management.
5
+ *
6
+ * Uses the `ssh2` npm package for SFTP uploads and remote command execution.
7
+ * SSH keypair generation delegates to the `ssh-keygen` CLI tool.
8
+ *
9
+ * Usage:
10
+ * const ssh = require('./ssh-deploy');
11
+ * await ssh.uploadFile(sshConfig, localPath, remotePath);
12
+ * const { stdout } = await ssh.runCommand(sshConfig, 'sudo systemctl reload mosquitto');
13
+ * const pubkey = await ssh.generateKeypair(identityFile);
14
+ */
15
+
16
+ const { execFile } = require('child_process');
17
+ const { promisify } = require('util');
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const os = require('os');
21
+
22
+ const execFileAsync = promisify(execFile);
23
+
24
+ // ssh2 is an optional dependency — only loaded when needed
25
+ let _ssh2 = null;
26
+ function getSsh2() {
27
+ if (!_ssh2) {
28
+ try {
29
+ _ssh2 = require('ssh2');
30
+ } catch {
31
+ throw new Error('ssh2 package not installed — run: npm install ssh2');
32
+ }
33
+ }
34
+ return _ssh2;
35
+ }
36
+
37
+ function expandHome(p) {
38
+ if (typeof p === 'string' && (p.startsWith('~/') || p === '~')) {
39
+ return path.join(os.homedir(), p.slice(2));
40
+ }
41
+ return p;
42
+ }
43
+
44
+ /**
45
+ * Build ssh2 connection options from she broker.ssh config.
46
+ * @param {object} sshConfig - config.broker.ssh
47
+ */
48
+ function buildConnectOpts(sshConfig) {
49
+ const identityFile = expandHome(sshConfig.identityFile || '~/.she/broker_id_ed25519');
50
+ let privateKey;
51
+ try {
52
+ privateKey = fs.readFileSync(identityFile);
53
+ } catch (err) {
54
+ throw new Error(`Cannot read SSH identity file ${identityFile}: ${err.message}`);
55
+ }
56
+
57
+ return {
58
+ host: sshConfig.host,
59
+ port: sshConfig.port || 22,
60
+ username: sshConfig.user || 'she',
61
+ privateKey,
62
+ readyTimeout: 10000,
63
+ keepaliveInterval: 0,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Connect to the remote host, execute a command, and return stdout/stderr.
69
+ * @param {object} sshConfig - config.broker.ssh
70
+ * @param {string} command
71
+ * @returns {Promise<{ stdout: string, stderr: string }>}
72
+ */
73
+ function runCommand(sshConfig, command) {
74
+ const { Client } = getSsh2();
75
+ const opts = buildConnectOpts(sshConfig);
76
+
77
+ return new Promise((resolve, reject) => {
78
+ const conn = new Client();
79
+ let stdout = '';
80
+ let stderr = '';
81
+
82
+ conn.on('ready', () => {
83
+ conn.exec(command, (err, stream) => {
84
+ if (err) {
85
+ conn.end();
86
+ return reject(err);
87
+ }
88
+ stream.on('close', (code) => {
89
+ conn.end();
90
+ if (code !== 0) {
91
+ reject(new Error(`Remote command exited ${code}: ${stderr.trim() || stdout.trim()}`));
92
+ } else {
93
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
94
+ }
95
+ });
96
+ stream.on('data', (d) => {
97
+ stdout += d.toString();
98
+ });
99
+ stream.stderr.on('data', (d) => {
100
+ stderr += d.toString();
101
+ });
102
+ });
103
+ });
104
+
105
+ conn.on('error', reject);
106
+ conn.connect(opts);
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Upload a local file to the remote host via SFTP.
112
+ * @param {object} sshConfig
113
+ * @param {string} localPath
114
+ * @param {string} remotePath
115
+ */
116
+ function uploadFile(sshConfig, localPath, remotePath) {
117
+ const { Client } = getSsh2();
118
+ const opts = buildConnectOpts(sshConfig);
119
+
120
+ return new Promise((resolve, reject) => {
121
+ const conn = new Client();
122
+ conn.on('ready', () => {
123
+ conn.sftp((err, sftp) => {
124
+ if (err) {
125
+ conn.end();
126
+ return reject(err);
127
+ }
128
+ sftp.fastPut(localPath, remotePath, (err2) => {
129
+ conn.end();
130
+ if (err2) reject(err2);
131
+ else resolve();
132
+ });
133
+ });
134
+ });
135
+ conn.on('error', reject);
136
+ conn.connect(opts);
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Upload a string as file content to the remote host.
142
+ * @param {object} sshConfig
143
+ * @param {string} content
144
+ * @param {string} remotePath
145
+ */
146
+ async function uploadContent(sshConfig, content, remotePath) {
147
+ // Write to a temp file, then SFTP upload, then delete temp
148
+ const tmp = path.join(os.tmpdir(), `she-ssh-${Date.now()}.tmp`);
149
+ fs.writeFileSync(tmp, content, 'utf8');
150
+ try {
151
+ await uploadFile(sshConfig, tmp, remotePath);
152
+ } finally {
153
+ try {
154
+ fs.unlinkSync(tmp);
155
+ } catch {
156
+ /* ignore */
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Test SSH connectivity. Resolves to { ok: true } on success, or throws.
163
+ * @param {object} sshConfig
164
+ */
165
+ function testConnection(sshConfig) {
166
+ return runCommand(sshConfig, 'echo ok').then(() => ({ ok: true }));
167
+ }
168
+
169
+ /**
170
+ * Generate an Ed25519 SSH keypair using the system ssh-keygen binary.
171
+ * @param {string} identityFile - path for the private key (e.g. ~/.she/broker_id_ed25519)
172
+ * @returns {Promise<string>} the public key text
173
+ */
174
+ async function generateKeypair(identityFile) {
175
+ const expandedPath = expandHome(identityFile || '~/.she/broker_id_ed25519');
176
+ const dir = path.dirname(expandedPath);
177
+ fs.mkdirSync(dir, { recursive: true });
178
+
179
+ // Remove existing key if present
180
+ try {
181
+ fs.unlinkSync(expandedPath);
182
+ } catch {
183
+ /* ok */
184
+ }
185
+ try {
186
+ fs.unlinkSync(expandedPath + '.pub');
187
+ } catch {
188
+ /* ok */
189
+ }
190
+
191
+ await execFileAsync(
192
+ 'ssh-keygen',
193
+ [
194
+ '-t',
195
+ 'ed25519',
196
+ '-f',
197
+ expandedPath,
198
+ '-N',
199
+ '', // no passphrase
200
+ '-C',
201
+ 'she-broker',
202
+ ],
203
+ { timeout: 15000 },
204
+ );
205
+
206
+ try {
207
+ fs.chmodSync(expandedPath, 0o600);
208
+ } catch {
209
+ /* ignore */
210
+ }
211
+
212
+ const pubkey = fs.readFileSync(expandedPath + '.pub', 'utf8');
213
+ return pubkey.trim();
214
+ }
215
+
216
+ module.exports = { runCommand, uploadFile, uploadContent, testConnection, generateKeypair };
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * broker sandbox module — adds she.broker.* to every script context.
5
+ *
6
+ * Loaded automatically by loadSandbox() in index.js (all *.js files in
7
+ * src/sandbox/ are scanned). If dynsec is not configured, she.broker is still
8
+ * defined but every method rejects with a descriptive error so scripts can
9
+ * detect the situation gracefully.
10
+ *
11
+ * she.broker API:
12
+ * she.broker.createUser(username, password) → Promise
13
+ * she.broker.deleteUser(username) → Promise
14
+ * she.broker.setPassword(username, password) → Promise
15
+ * she.broker.listUsers() → Promise<User[]>
16
+ * she.broker.getUser(username) → Promise<User>
17
+ * she.broker.createRole(rolename) → Promise
18
+ * she.broker.deleteRole(rolename) → Promise
19
+ * she.broker.listRoles() → Promise<Role[]>
20
+ * she.broker.getRole(rolename) → Promise<Role>
21
+ * she.broker.addACL(rolename, {type, topic, allow}) → Promise
22
+ * she.broker.removeACL(rolename, {type, topic}) → Promise
23
+ * she.broker.assignRole(username, rolename) → Promise
24
+ * she.broker.revokeRole(username, rolename) → Promise
25
+ * she.broker.createGroup(groupname) → Promise
26
+ * she.broker.deleteGroup(groupname) → Promise
27
+ * she.broker.listGroups() → Promise<Group[]>
28
+ * she.broker.addToGroup(username, groupname) → Promise
29
+ * she.broker.removeFromGroup(username, groupname) → Promise
30
+ * she.broker.assignRoleToGroup(groupname, rolename) → Promise
31
+ *
32
+ * ACL types (dynsec):
33
+ * 'publishClientSend' | 'publishClientReceive' |
34
+ * 'subscribeLiteral' | 'subscribePattern' |
35
+ * 'unsubscribeLiteral'| 'unsubscribePattern'
36
+ */
37
+
38
+ const dynsec = require('../lib/dynsec');
39
+
40
+ module.exports = function (she) {
41
+ she.broker = {
42
+ // ── Users ──────────────────────────────────────────────────────────────
43
+ createUser(username, password) {
44
+ return dynsec.createClient(username, password);
45
+ },
46
+ deleteUser(username) {
47
+ return dynsec.deleteClient(username);
48
+ },
49
+ setPassword(username, password) {
50
+ return dynsec.setClientPassword(username, password);
51
+ },
52
+ listUsers() {
53
+ return dynsec.listClients(/* verbose= */ true);
54
+ },
55
+ getUser(username) {
56
+ return dynsec.getClient(username);
57
+ },
58
+
59
+ // ── Roles ──────────────────────────────────────────────────────────────
60
+ createRole(rolename) {
61
+ return dynsec.createRole(rolename);
62
+ },
63
+ deleteRole(rolename) {
64
+ return dynsec.deleteRole(rolename);
65
+ },
66
+ listRoles() {
67
+ return dynsec.listRoles(/* verbose= */ true);
68
+ },
69
+ getRole(rolename) {
70
+ return dynsec.getRole(rolename);
71
+ },
72
+ /**
73
+ * Add an ACL rule to a role.
74
+ * @param {string} rolename
75
+ * @param {{ type: string, topic: string, allow: boolean }} acl
76
+ */
77
+ addACL(rolename, { type, topic, allow }) {
78
+ return dynsec.addRoleACL(rolename, type, topic, allow);
79
+ },
80
+ removeACL(rolename, { type, topic }) {
81
+ return dynsec.removeRoleACL(rolename, type, topic);
82
+ },
83
+
84
+ // ── Role ↔ user assignment ─────────────────────────────────────────────
85
+ assignRole(username, rolename) {
86
+ return dynsec.addClientRole(username, rolename);
87
+ },
88
+ revokeRole(username, rolename) {
89
+ return dynsec.removeClientRole(username, rolename);
90
+ },
91
+
92
+ // ── Groups ─────────────────────────────────────────────────────────────
93
+ createGroup(groupname) {
94
+ return dynsec.createGroup(groupname);
95
+ },
96
+ deleteGroup(groupname) {
97
+ return dynsec.deleteGroup(groupname);
98
+ },
99
+ listGroups() {
100
+ return dynsec.listGroups(/* verbose= */ true);
101
+ },
102
+ addToGroup(username, groupname) {
103
+ // dynsec groups are addressed by group; client is the arg
104
+ return dynsec.addGroupClient(groupname, username);
105
+ },
106
+ removeFromGroup(username, groupname) {
107
+ return dynsec.removeGroupClient(groupname, username);
108
+ },
109
+ assignRoleToGroup(groupname, rolename) {
110
+ return dynsec.addGroupRole(groupname, rolename);
111
+ },
112
+ };
113
+ };
@@ -161,10 +161,7 @@ module.exports = function (she, ctx = {}) {
161
161
  if (!signal) {
162
162
  const ac = new AbortController();
163
163
  signal = ac.signal;
164
- timer = setTimeout(
165
- () => ac.abort(new Error(`she.http.fetch timed out after ${TIMEOUT_MS / 1000}s`)),
166
- TIMEOUT_MS,
167
- );
164
+ timer = setTimeout(() => ac.abort(new Error(`she.http.fetch timed out after ${TIMEOUT_MS / 1000}s`)), TIMEOUT_MS);
168
165
  }
169
166
  return fetch(url, { ...options, signal })
170
167
  .then((r) => {
package/src/web/ai-api.js CHANGED
@@ -449,7 +449,7 @@ router.post('/prompt', (req, res) => {
449
449
  router.post('/chat', async (req, res) => {
450
450
  const ai = readAiConfig(req.app.locals.configPath);
451
451
  const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride, extraFiles } = req.body || {};
452
- const effectiveModel = (modelOverride && typeof modelOverride === 'string') ? modelOverride : ai?.model;
452
+ const effectiveModel = modelOverride && typeof modelOverride === 'string' ? modelOverride : ai?.model;
453
453
  if (!ai?.provider || !effectiveModel) {
454
454
  return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
455
455
  }
@@ -480,7 +480,7 @@ router.post('/chat', async (req, res) => {
480
480
  router.post('/chat/stream', async (req, res) => {
481
481
  const ai = readAiConfig(req.app.locals.configPath);
482
482
  const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride, extraFiles } = req.body || {};
483
- const effectiveModel = (modelOverride && typeof modelOverride === 'string') ? modelOverride : ai?.model;
483
+ const effectiveModel = modelOverride && typeof modelOverride === 'string' ? modelOverride : ai?.model;
484
484
  if (!ai?.provider || !effectiveModel) {
485
485
  return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
486
486
  }
@@ -553,15 +553,21 @@ router.get('/conversations', (req, res) => {
553
553
  ensureAiDir();
554
554
  let list = [];
555
555
  try {
556
- const files = fs.readdirSync(AI_DIR).filter(f => f.endsWith('.json'));
557
- list = files.map(f => {
558
- try {
559
- const data = JSON.parse(fs.readFileSync(path.join(AI_DIR, f), 'utf8'));
560
- return { id: data.id, title: data.title || data.id, updatedAt: data.updatedAt || 0 };
561
- } catch { return null; }
562
- }).filter(Boolean);
556
+ const files = fs.readdirSync(AI_DIR).filter((f) => f.endsWith('.json'));
557
+ list = files
558
+ .map((f) => {
559
+ try {
560
+ const data = JSON.parse(fs.readFileSync(path.join(AI_DIR, f), 'utf8'));
561
+ return { id: data.id, title: data.title || data.id, updatedAt: data.updatedAt || 0 };
562
+ } catch {
563
+ return null;
564
+ }
565
+ })
566
+ .filter(Boolean);
563
567
  list.sort((a, b) => b.updatedAt - a.updatedAt);
564
- } catch { /* empty dir */ }
568
+ } catch {
569
+ /* empty dir */
570
+ }
565
571
  res.json(list);
566
572
  });
567
573
 
@@ -593,7 +599,11 @@ router.put('/conversations/:id', (req, res) => {
593
599
  router.delete('/conversations/:id', (req, res) => {
594
600
  const p = convPath(req.params.id);
595
601
  if (!p) return res.status(400).json({ error: 'invalid id' });
596
- try { fs.unlinkSync(p); } catch { /* already gone */ }
602
+ try {
603
+ fs.unlinkSync(p);
604
+ } catch {
605
+ /* already gone */
606
+ }
597
607
  res.json({ ok: true });
598
608
  });
599
609