smart-home-engine 1.0.10 → 1.1.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.
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ssh-deploy.js — SSH/SCP file deployment helper for remote broker management.
5
+ *
6
+ * Shells out to the system ssh and scp clients — no npm dependencies required.
7
+ * SSH keypair generation uses the system ssh-keygen binary.
8
+ *
9
+ * Requires ssh, scp, and ssh-keygen available in PATH on the she host.
10
+ *
11
+ * StrictHostKeyChecking=accept-new trusts new hosts on first connect and
12
+ * verifies the key on subsequent connections, protecting against MITM after
13
+ * the initial handshake without blocking automation.
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
+ function expandHome(p) {
25
+ if (typeof p === 'string' && (p.startsWith('~/') || p === '~')) {
26
+ return path.join(os.homedir(), p.slice(2));
27
+ }
28
+ return p;
29
+ }
30
+
31
+ /**
32
+ * Build the common ssh argument list (flags only, no target/command).
33
+ * @param {object} sshConfig - config.broker.ssh
34
+ * @returns {string[]}
35
+ */
36
+ function sshArgs(sshConfig) {
37
+ const identityFile = expandHome(sshConfig.identityFile || '~/.she/ssh/broker_id_ed25519');
38
+ return ['-i', identityFile, '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-p', String(sshConfig.port || 22)];
39
+ }
40
+
41
+ /**
42
+ * Build the scp argument list prefix.
43
+ * scp uses -P (capital) for port, unlike ssh which uses -p.
44
+ * @param {object} sshConfig
45
+ * @returns {string[]}
46
+ */
47
+ function scpArgs(sshConfig) {
48
+ const identityFile = expandHome(sshConfig.identityFile || '~/.she/ssh/broker_id_ed25519');
49
+ return ['-i', identityFile, '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-P', String(sshConfig.port || 22)];
50
+ }
51
+
52
+ function sshTarget(sshConfig) {
53
+ return `${sshConfig.user || 'she'}@${sshConfig.host}`;
54
+ }
55
+
56
+ /**
57
+ * Run a command on the remote host via the system ssh client.
58
+ * @param {object} sshConfig - config.broker.ssh
59
+ * @param {string} command
60
+ * @returns {Promise<{ stdout: string, stderr: string }>}
61
+ */
62
+ async function runCommand(sshConfig, command) {
63
+ const args = [...sshArgs(sshConfig), sshTarget(sshConfig), command];
64
+ try {
65
+ const { stdout, stderr } = await execFileAsync('ssh', args, { timeout: 15000 });
66
+ return { stdout: stdout.trim(), stderr: stderr.trim() };
67
+ } catch (err) {
68
+ const detail = (err.stderr || '').trim() || (err.stdout || '').trim() || err.message;
69
+ throw new Error(detail);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Read the content of a file on the remote host via ssh cat.
75
+ * @param {object} sshConfig
76
+ * @param {string} remotePath
77
+ * @returns {Promise<string>}
78
+ */
79
+ async function readRemoteFile(sshConfig, remotePath) {
80
+ const { stdout } = await runCommand(sshConfig, `cat -- "${remotePath}"`);
81
+ return stdout;
82
+ }
83
+
84
+ /**
85
+ * Upload a local file to the remote host via scp.
86
+ * @param {object} sshConfig
87
+ * @param {string} localPath
88
+ * @param {string} remotePath
89
+ */
90
+ async function uploadFile(sshConfig, localPath, remotePath) {
91
+ const args = [...scpArgs(sshConfig), localPath, `${sshTarget(sshConfig)}:${remotePath}`];
92
+ try {
93
+ await execFileAsync('scp', args, { timeout: 30000 });
94
+ } catch (err) {
95
+ const detail = (err.stderr || '').trim() || err.message;
96
+ throw new Error(detail);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Upload a string as file content to the remote host.
102
+ * Writes to a local temp file then uploads via scp.
103
+ * @param {object} sshConfig
104
+ * @param {string} content
105
+ * @param {string} remotePath
106
+ */
107
+ async function uploadContent(sshConfig, content, remotePath) {
108
+ const tmp = path.join(os.tmpdir(), `she-ssh-${Date.now()}.tmp`);
109
+ fs.writeFileSync(tmp, content, 'utf8');
110
+ try {
111
+ await uploadFile(sshConfig, tmp, remotePath);
112
+ } finally {
113
+ try {
114
+ fs.unlinkSync(tmp);
115
+ } catch {
116
+ /* ignore */
117
+ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Test SSH connectivity. Resolves to { ok: true } on success, or throws.
123
+ * @param {object} sshConfig
124
+ */
125
+ async function testConnection(sshConfig) {
126
+ await runCommand(sshConfig, 'echo ok');
127
+ return { ok: true };
128
+ }
129
+
130
+ /**
131
+ * Generate an Ed25519 SSH keypair using the system ssh-keygen binary.
132
+ * @param {string} identityFile - path for the private key (e.g. ~/.she/broker_id_ed25519)
133
+ * @returns {Promise<string>} the public key text
134
+ */
135
+ async function generateKeypair(identityFile) {
136
+ const expandedPath = expandHome(identityFile || '~/.she/ssh/broker_id_ed25519');
137
+ const dir = path.dirname(expandedPath);
138
+ fs.mkdirSync(dir, { recursive: true });
139
+
140
+ try {
141
+ fs.unlinkSync(expandedPath);
142
+ } catch {
143
+ /* ok */
144
+ }
145
+ try {
146
+ fs.unlinkSync(expandedPath + '.pub');
147
+ } catch {
148
+ /* ok */
149
+ }
150
+
151
+ await execFileAsync('ssh-keygen', ['-t', 'ed25519', '-f', expandedPath, '-N', '', '-C', 'she-broker'], { timeout: 15000 });
152
+
153
+ try {
154
+ fs.chmodSync(expandedPath, 0o600);
155
+ } catch {
156
+ /* ignore */
157
+ }
158
+
159
+ return fs.readFileSync(expandedPath + '.pub', 'utf8').trim();
160
+ }
161
+
162
+ module.exports = { runCommand, readRemoteFile, 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