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.
- package/dist/web/assets/{index-B_L247qc.css → index-DKIgEFlE.css} +1 -1
- package/dist/web/assets/index-YxGnpZAh.js +230 -0
- package/dist/web/assets/{tsMode-D1vQpxKE.js → tsMode-DAKcfE4c.js} +1 -1
- package/dist/web/index.html +16 -6
- package/package.json +1 -1
- package/src/index.js +4 -0
- package/src/lib/ca.js +474 -0
- package/src/lib/dynsec.js +295 -0
- package/src/lib/mosquitto-conf.js +287 -0
- package/src/lib/ssh-deploy.js +216 -0
- package/src/sandbox/broker-sandbox.js +113 -0
- package/src/sandbox/stdlib.js +1 -4
- package/src/web/ai-api.js +21 -11
- package/src/web/broker-api.js +761 -0
- package/src/web/config-api.js +6 -4
- package/src/web/deps-api.js +4 -5
- package/src/web/git-api.js +2 -8
- package/src/web/log-ws.js +1 -1
- package/src/web/scripts-api.js +8 -2
- package/src/web/server.js +8 -2
- package/dist/web/assets/index-DPRSyXE_.js +0 -230
|
@@ -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
|
+
};
|
package/src/sandbox/stdlib.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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 {
|
|
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 {
|
|
602
|
+
try {
|
|
603
|
+
fs.unlinkSync(p);
|
|
604
|
+
} catch {
|
|
605
|
+
/* already gone */
|
|
606
|
+
}
|
|
597
607
|
res.json({ ok: true });
|
|
598
608
|
});
|
|
599
609
|
|