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.
- package/README.md +4 -1
- package/dist/web/assets/{index-B_L247qc.css → index-DKIgEFlE.css} +1 -1
- package/dist/web/assets/index-DttCbWJj.js +230 -0
- package/dist/web/assets/{tsMode-D1vQpxKE.js → tsMode-BcbP1HsB.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 +295 -0
- package/src/lib/ssh-deploy.js +162 -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 +787 -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,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
|
+
};
|
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
|
|