smart-home-engine 0.0.1 → 0.10.4
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/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/web/assets/codicon-DCmgc-ay.ttf +0 -0
- package/dist/web/assets/index-Bdf2J0nm.js +140 -0
- package/dist/web/assets/index-DkhtWYJx.css +1 -0
- package/dist/web/assets/monaco-langs-DZ6hB11b.js +1423 -0
- package/dist/web/assets/monaco-langs-DyX1CsEw.css +1 -0
- package/dist/web/assets/tsMode-THvwQw-l.js +16 -0
- package/dist/web/index.html +164 -0
- package/dist/web/monacoeditorwork/editor.worker.bundle.js +13519 -0
- package/dist/web/monacoeditorwork/ts.worker.bundle.js +256353 -0
- package/package.json +84 -10
- package/src/config.js +53 -0
- package/src/elastic.js +19 -0
- package/src/index.js +1184 -0
- package/src/influx.js +25 -0
- package/src/lib/mqtt-wildcards.js +34 -0
- package/src/lib/parse-payload.js +29 -0
- package/src/lib/redis.js +74 -0
- package/src/lib/shedb-core.js +447 -0
- package/src/lib/shedb-worker.js +126 -0
- package/src/lib/state-store.js +97 -0
- package/src/lib/storage.js +74 -0
- package/src/matter/controller.js +307 -0
- package/src/sandbox/api.js +57 -0
- package/src/sandbox/elastic-sandbox.js +88 -0
- package/src/sandbox/influx-sandbox.js +107 -0
- package/src/sandbox/matter-sandbox.js +92 -0
- package/src/sandbox/shedb-sandbox.js +89 -0
- package/src/sandbox/stdlib.js +132 -0
- package/src/scripts/hello.js +3 -0
- package/src/web/ai-api.js +443 -0
- package/src/web/config-api.js +34 -0
- package/src/web/deps-api.js +138 -0
- package/src/web/git-api.js +188 -0
- package/src/web/log-ws.js +71 -0
- package/src/web/matter-api.js +102 -0
- package/src/web/mqtt-api.js +65 -0
- package/src/web/scripts-api.js +192 -0
- package/src/web/server.js +130 -0
- package/src/web/shedb-api.js +140 -0
- package/src/web/shedb.js +168 -0
- package/index.js +0 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const { execFile } = require('child_process');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const router = express.Router();
|
|
8
|
+
|
|
9
|
+
function getRoot(req) {
|
|
10
|
+
return req.app.locals.scriptDir || null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run a git command in the given cwd.
|
|
15
|
+
* Resolves with { stdout, stderr }; rejects with an error that carries .stderr and .stdout.
|
|
16
|
+
*/
|
|
17
|
+
function git(args, cwd, timeout = 30000) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
execFile('git', args, { cwd, timeout }, (err, stdout, stderr) => {
|
|
20
|
+
if (err) {
|
|
21
|
+
const e = new Error(stderr.trim() || err.message);
|
|
22
|
+
e.stderr = stderr;
|
|
23
|
+
e.stdout = stdout;
|
|
24
|
+
reject(e);
|
|
25
|
+
} else {
|
|
26
|
+
resolve({ stdout, stderr });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Find the git root for the given directory.
|
|
34
|
+
* Returns null when the directory is not inside a git repository.
|
|
35
|
+
*/
|
|
36
|
+
async function getGitRoot(dir) {
|
|
37
|
+
try {
|
|
38
|
+
const { stdout } = await git(['rev-parse', '--show-toplevel'], dir);
|
|
39
|
+
return stdout.trim().replace(/\//g, path.sep);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve a safe absolute path within the script root.
|
|
47
|
+
* Returns null when the path escapes the root (traversal attempt).
|
|
48
|
+
*/
|
|
49
|
+
function safePath(root, relPath) {
|
|
50
|
+
const abs = path.resolve(root, relPath.replace(/^\/+/, ''));
|
|
51
|
+
if (!abs.startsWith(root + path.sep) && abs !== root) return null;
|
|
52
|
+
return abs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Validate commit message: non-empty, no null bytes. */
|
|
56
|
+
function isValidMessage(msg) {
|
|
57
|
+
return typeof msg === 'string' && msg.trim().length > 0 && !msg.includes('\0');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// GET /she/git/status
|
|
61
|
+
// Returns branch name, changed files, and ahead/behind counts vs. upstream.
|
|
62
|
+
router.get('/status', async (req, res) => {
|
|
63
|
+
const scriptDir = getRoot(req);
|
|
64
|
+
if (!scriptDir) return res.status(500).json({ error: 'scriptDir not configured' });
|
|
65
|
+
|
|
66
|
+
const gitRoot = await getGitRoot(scriptDir);
|
|
67
|
+
if (!gitRoot) return res.status(404).json({ error: 'Not a git repository' });
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const [statusOut, branchOut] = await Promise.all([git(['status', '--porcelain', '-u'], gitRoot), git(['rev-parse', '--abbrev-ref', 'HEAD'], gitRoot)]);
|
|
71
|
+
|
|
72
|
+
const branch = branchOut.stdout.trim();
|
|
73
|
+
const changes = statusOut.stdout
|
|
74
|
+
.split('\n')
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.map((line) => ({
|
|
77
|
+
status: line.slice(0, 2).trim(),
|
|
78
|
+
file: line.slice(3),
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
let ahead = 0;
|
|
82
|
+
let behind = 0;
|
|
83
|
+
try {
|
|
84
|
+
const { stdout } = await git(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], gitRoot);
|
|
85
|
+
const parts = stdout.trim().split(/\s+/);
|
|
86
|
+
ahead = parseInt(parts[0], 10) || 0;
|
|
87
|
+
behind = parseInt(parts[1], 10) || 0;
|
|
88
|
+
} catch {
|
|
89
|
+
// No upstream configured — leave as 0.
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
res.json({ branch, changes, ahead, behind });
|
|
93
|
+
} catch (e) {
|
|
94
|
+
res.status(500).json({ error: e.message });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// GET /she/git/remotes
|
|
99
|
+
// Returns the configured git remotes: [{ name, fetch, push }].
|
|
100
|
+
router.get('/remotes', async (req, res) => {
|
|
101
|
+
const scriptDir = getRoot(req);
|
|
102
|
+
if (!scriptDir) return res.status(500).json({ error: 'scriptDir not configured' });
|
|
103
|
+
|
|
104
|
+
const gitRoot = await getGitRoot(scriptDir);
|
|
105
|
+
if (!gitRoot) return res.status(404).json({ error: 'Not a git repository' });
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const { stdout } = await git(['remote', '-v'], gitRoot);
|
|
109
|
+
const remotes = {};
|
|
110
|
+
for (const line of stdout.split('\n').filter(Boolean)) {
|
|
111
|
+
const m = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
|
|
112
|
+
if (!m) continue;
|
|
113
|
+
const [, name, url, type] = m;
|
|
114
|
+
if (!remotes[name]) remotes[name] = { name, fetch: '', push: '' };
|
|
115
|
+
remotes[name][type] = url;
|
|
116
|
+
}
|
|
117
|
+
res.json(Object.values(remotes));
|
|
118
|
+
} catch (e) {
|
|
119
|
+
res.status(500).json({ error: e.message });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// POST /she/git/commit
|
|
124
|
+
// Body: { path?: string, files?: string[], message: string }
|
|
125
|
+
// Stages the given file(s) (or the whole scriptDir when neither is provided) and commits.
|
|
126
|
+
router.post('/commit', async (req, res) => {
|
|
127
|
+
const scriptDir = getRoot(req);
|
|
128
|
+
if (!scriptDir) return res.status(500).json({ error: 'scriptDir not configured' });
|
|
129
|
+
|
|
130
|
+
const message = String(req.body?.message ?? '').trim();
|
|
131
|
+
if (!isValidMessage(message)) return res.status(400).json({ error: 'Invalid or empty commit message' });
|
|
132
|
+
|
|
133
|
+
// Accept a single path or an array of paths.
|
|
134
|
+
let relPaths;
|
|
135
|
+
if (req.body?.path) {
|
|
136
|
+
relPaths = [String(req.body.path)];
|
|
137
|
+
} else if (Array.isArray(req.body?.files) && req.body.files.length > 0) {
|
|
138
|
+
relPaths = req.body.files.map(String);
|
|
139
|
+
} else {
|
|
140
|
+
relPaths = [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const gitRoot = await getGitRoot(scriptDir);
|
|
144
|
+
if (!gitRoot) return res.status(404).json({ error: 'Not a git repository' });
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
if (relPaths.length > 0) {
|
|
148
|
+
for (const rel of relPaths) {
|
|
149
|
+
const abs = safePath(scriptDir, rel);
|
|
150
|
+
if (!abs) return res.status(400).json({ error: `Invalid path: ${rel}` });
|
|
151
|
+
// Convert to forward slashes for git portability.
|
|
152
|
+
const relToRoot = path.relative(gitRoot, abs).replace(/\\/g, '/');
|
|
153
|
+
await git(['add', relToRoot], gitRoot);
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
// Stage the entire script directory.
|
|
157
|
+
const scriptDirRel = path.relative(gitRoot, scriptDir).replace(/\\/g, '/');
|
|
158
|
+
await git(['add', scriptDirRel + '/'], gitRoot);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await git(['commit', '-m', message], gitRoot);
|
|
162
|
+
res.json({ ok: true });
|
|
163
|
+
} catch (e) {
|
|
164
|
+
res.status(500).json({ error: e.message, stderr: e.stderr });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// POST /she/git/push
|
|
169
|
+
// Body: { remote?: string } — defaults to "origin".
|
|
170
|
+
router.post('/push', async (req, res) => {
|
|
171
|
+
const scriptDir = getRoot(req);
|
|
172
|
+
if (!scriptDir) return res.status(500).json({ error: 'scriptDir not configured' });
|
|
173
|
+
|
|
174
|
+
const remote = String(req.body?.remote ?? 'origin').trim();
|
|
175
|
+
if (!/^[a-zA-Z0-9_./-]+$/.test(remote)) return res.status(400).json({ error: 'Invalid remote name' });
|
|
176
|
+
|
|
177
|
+
const gitRoot = await getGitRoot(scriptDir);
|
|
178
|
+
if (!gitRoot) return res.status(404).json({ error: 'Not a git repository' });
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const { stdout, stderr } = await git(['push', remote], gitRoot, 60000);
|
|
182
|
+
res.json({ ok: true, stdout, stderr });
|
|
183
|
+
} catch (e) {
|
|
184
|
+
res.status(500).json({ error: e.message, stderr: e.stderr });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
module.exports = { router };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { WebSocketServer } = require('ws');
|
|
4
|
+
|
|
5
|
+
let _wss = null;
|
|
6
|
+
const _clients = new Set();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Attach a WebSocketServer to an existing http.Server.
|
|
10
|
+
* Clients connect to /she/ws. Once connected they receive:
|
|
11
|
+
* - { type: 'log', line: string } — every pino log line
|
|
12
|
+
* - { type: 'ping' } — keepalive every 30 s
|
|
13
|
+
*
|
|
14
|
+
* @param {import('http').Server} httpServer
|
|
15
|
+
*/
|
|
16
|
+
function attachWss(httpServer) {
|
|
17
|
+
_wss = new WebSocketServer({ server: httpServer, path: '/she/ws' });
|
|
18
|
+
|
|
19
|
+
_wss.on('connection', (ws) => {
|
|
20
|
+
_clients.add(ws);
|
|
21
|
+
ws.on('close', () => _clients.delete(ws));
|
|
22
|
+
ws.on('error', () => _clients.delete(ws));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Keepalive ping every 30 s
|
|
26
|
+
const pingInterval = setInterval(() => {
|
|
27
|
+
const msg = JSON.stringify({ type: 'ping' });
|
|
28
|
+
for (const ws of _clients) {
|
|
29
|
+
if (ws.readyState === ws.OPEN) ws.send(msg);
|
|
30
|
+
}
|
|
31
|
+
}, 30_000);
|
|
32
|
+
|
|
33
|
+
_wss.on('close', () => clearInterval(pingInterval));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Broadcast an arbitrary JSON message to all connected WebSocket clients.
|
|
38
|
+
* @param {object} msg - must be JSON-serialisable
|
|
39
|
+
*/
|
|
40
|
+
function broadcast(msg) {
|
|
41
|
+
if (_clients.size === 0) return;
|
|
42
|
+
const str = JSON.stringify(msg);
|
|
43
|
+
for (const ws of _clients) {
|
|
44
|
+
if (ws.readyState === ws.OPEN) ws.send(str);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Broadcast a structured log entry to all connected WebSocket clients.
|
|
50
|
+
* @param {{ level: string, msg: string, ts: number }} entry
|
|
51
|
+
*/
|
|
52
|
+
function broadcastLog(entry) {
|
|
53
|
+
broadcast({ type: 'log', ...entry });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Close the WebSocket server.
|
|
58
|
+
* @returns {Promise<void>}
|
|
59
|
+
*/
|
|
60
|
+
function closeWss() {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
if (_wss) {
|
|
63
|
+
_wss.close(resolve);
|
|
64
|
+
_wss = null;
|
|
65
|
+
} else {
|
|
66
|
+
resolve();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { attachWss, broadcast, broadcastLog, closeWss };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Matter controller REST API — Express router mounted at /she/matter
|
|
5
|
+
*
|
|
6
|
+
* Routes:
|
|
7
|
+
* GET /she/matter/devices → list paired nodes
|
|
8
|
+
* POST /she/matter/commission → commission a device
|
|
9
|
+
* GET /she/matter/devices/:nodeId → node detail (endpoints + clusters)
|
|
10
|
+
* DELETE /she/matter/devices/:nodeId → unpair a device
|
|
11
|
+
* POST /she/matter/devices/:nodeId/command → invoke a cluster command
|
|
12
|
+
*
|
|
13
|
+
* All NodeIds are represented as decimal strings in JSON.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const express = require('express');
|
|
17
|
+
|
|
18
|
+
const router = express.Router();
|
|
19
|
+
|
|
20
|
+
function getController() {
|
|
21
|
+
return require('../matter/controller');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function notReady(res) {
|
|
25
|
+
return res.status(503).json({ error: 'Matter controller not started (--matter-storage not set)' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isReady() {
|
|
29
|
+
try {
|
|
30
|
+
const c = getController();
|
|
31
|
+
return typeof c.listPaired === 'function';
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// GET /she/matter/devices — list paired nodes
|
|
38
|
+
router.get('/devices', (req, res) => {
|
|
39
|
+
if (!isReady()) return notReady(res);
|
|
40
|
+
try {
|
|
41
|
+
res.json(getController().listPaired());
|
|
42
|
+
} catch (err) {
|
|
43
|
+
res.status(500).json({ error: err.message });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// POST /she/matter/commission — { passcode, discriminator? } or { pairingCode }
|
|
48
|
+
router.post('/commission', async (req, res) => {
|
|
49
|
+
if (!isReady()) return notReady(res);
|
|
50
|
+
const body = req.body;
|
|
51
|
+
if (!body || (body.passcode === undefined && body.pairingCode === undefined)) {
|
|
52
|
+
return res.status(400).json({ error: 'body must contain passcode or pairingCode' });
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const nodeId = await getController().commission(body);
|
|
56
|
+
res.status(201).json({ nodeId });
|
|
57
|
+
} catch (err) {
|
|
58
|
+
res.status(500).json({ error: err.message });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// GET /she/matter/devices/:nodeId — node detail
|
|
63
|
+
router.get('/devices/:nodeId', (req, res) => {
|
|
64
|
+
if (!isReady()) return notReady(res);
|
|
65
|
+
try {
|
|
66
|
+
const endpoints = getController().getEndpoints(req.params.nodeId);
|
|
67
|
+
res.json({ nodeId: req.params.nodeId, endpoints });
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (err.message.includes('not found')) return res.status(404).json({ error: err.message });
|
|
70
|
+
res.status(500).json({ error: err.message });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// DELETE /she/matter/devices/:nodeId — unpair
|
|
75
|
+
router.delete('/devices/:nodeId', async (req, res) => {
|
|
76
|
+
if (!isReady()) return notReady(res);
|
|
77
|
+
try {
|
|
78
|
+
await getController().unpair(req.params.nodeId);
|
|
79
|
+
res.json({ ok: true });
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err.message.includes('not found')) return res.status(404).json({ error: err.message });
|
|
82
|
+
res.status(500).json({ error: err.message });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// POST /she/matter/devices/:nodeId/command — { endpointId, clusterName, command, args? }
|
|
87
|
+
router.post('/devices/:nodeId/command', async (req, res) => {
|
|
88
|
+
if (!isReady()) return notReady(res);
|
|
89
|
+
const { endpointId, clusterName, command, args } = req.body || {};
|
|
90
|
+
if (endpointId === undefined || !clusterName || !command) {
|
|
91
|
+
return res.status(400).json({ error: 'body must contain endpointId, clusterName, command' });
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const result = await getController().sendCommand(req.params.nodeId, Number(endpointId), clusterName, command, args ?? {});
|
|
95
|
+
res.json({ ok: true, result: result ?? null });
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err.message.includes('not found')) return res.status(404).json({ error: err.message });
|
|
98
|
+
res.status(500).json({ error: err.message });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
module.exports = { router };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MQTT state REST API — Express router mounted at /she/mqtt
|
|
5
|
+
*
|
|
6
|
+
* Routes:
|
|
7
|
+
* GET /she/mqtt/state → snapshot of all retained MQTT topic values
|
|
8
|
+
* POST /she/mqtt/publish → publish a message to a topic
|
|
9
|
+
*
|
|
10
|
+
* Call init(store, getMqttClient) once after the state store is created.
|
|
11
|
+
* The getMqttClient callback is a zero-arg function returning the live mqtt client
|
|
12
|
+
* (or null when no broker is configured).
|
|
13
|
+
*
|
|
14
|
+
* State changes for mqtt:: keys are automatically broadcast to WebSocket clients
|
|
15
|
+
* as { type: 'mqtt', topic, val, ts }.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const express = require('express');
|
|
19
|
+
const { broadcast } = require('./log-ws');
|
|
20
|
+
|
|
21
|
+
const router = express.Router();
|
|
22
|
+
|
|
23
|
+
let _store = null;
|
|
24
|
+
let _getMqtt = () => null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Initialise the MQTT API with the state store and an MQTT client getter.
|
|
28
|
+
* @param {import('../lib/state-store')} store
|
|
29
|
+
* @param {() => import('mqtt').MqttClient | null} getMqttClient
|
|
30
|
+
*/
|
|
31
|
+
function init(store, getMqttClient) {
|
|
32
|
+
_store = store;
|
|
33
|
+
_getMqtt = getMqttClient;
|
|
34
|
+
|
|
35
|
+
// Forward every mqtt:: state change to connected WebSocket clients
|
|
36
|
+
store.on('change', (key, val, obj) => {
|
|
37
|
+
if (!key.startsWith('mqtt::')) return;
|
|
38
|
+
broadcast({ type: 'mqtt', topic: key.slice(6), val: obj.val, ts: obj.ts });
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// GET /she/mqtt/state — sorted snapshot of all retained MQTT topic values
|
|
43
|
+
router.get('/state', (req, res) => {
|
|
44
|
+
if (!_store) return res.json([]);
|
|
45
|
+
const result = [];
|
|
46
|
+
for (const [topic, obj] of _store.mqttEntries()) {
|
|
47
|
+
result.push({ topic, val: obj.val, ts: obj.ts });
|
|
48
|
+
}
|
|
49
|
+
result.sort((a, b) => a.topic.localeCompare(b.topic));
|
|
50
|
+
res.json(result);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// POST /she/mqtt/publish — { topic, payload, retain?, qos? }
|
|
54
|
+
router.post('/publish', (req, res) => {
|
|
55
|
+
const mqtt = _getMqtt();
|
|
56
|
+
if (!mqtt) return res.status(503).json({ error: 'MQTT not connected' });
|
|
57
|
+
const { topic, payload, retain = false, qos = 0 } = req.body || {};
|
|
58
|
+
if (!topic) return res.status(400).json({ error: 'topic required' });
|
|
59
|
+
mqtt.publish(topic, String(payload ?? ''), { retain, qos }, (err) => {
|
|
60
|
+
if (err) return res.status(500).json({ error: err.message });
|
|
61
|
+
res.json({ ok: true });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
module.exports = { router, init };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const router = express.Router();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a safe absolute path within the script root.
|
|
11
|
+
* Returns null if the resolved path escapes the root (traversal attempt).
|
|
12
|
+
* @param {string} root - absolute script directory
|
|
13
|
+
* @param {string} relPath - client-supplied relative path
|
|
14
|
+
* @returns {string|null}
|
|
15
|
+
*/
|
|
16
|
+
function safePath(root, relPath) {
|
|
17
|
+
const abs = path.resolve(root, relPath.replace(/^\/+/, ''));
|
|
18
|
+
if (!abs.startsWith(root + path.sep) && abs !== root) return null;
|
|
19
|
+
return abs;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getRoot(req) {
|
|
23
|
+
return req.app.locals.scriptDir || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True if the directory itself contains a .shelib marker. */
|
|
27
|
+
function hasShelibMarker(absDir) {
|
|
28
|
+
return fs.existsSync(path.join(absDir, '.shelib'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Flat list of all .js files with metadata and lib flag. */
|
|
32
|
+
function walk(dir, base, parentIsLib) {
|
|
33
|
+
let entries;
|
|
34
|
+
try {
|
|
35
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
const lib = parentIsLib || (base !== '' && hasShelibMarker(dir));
|
|
40
|
+
const results = [];
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (entry.name === '.shelib') continue;
|
|
43
|
+
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
results.push(...walk(path.join(dir, entry.name), rel, lib));
|
|
46
|
+
} else if (entry.name.endsWith('.js')) {
|
|
47
|
+
const stat = fs.statSync(path.join(dir, entry.name));
|
|
48
|
+
results.push({ path: rel, size: stat.size, mtime: stat.mtimeMs, lib });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Nested tree of all .js files and subdirectories.
|
|
56
|
+
* Each node: { type:'file'|'dir', name, path, lib, size?, mtime?, children? }
|
|
57
|
+
*/
|
|
58
|
+
function buildTree(dir, base, parentIsLib) {
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const selfIsLib = base !== '' && hasShelibMarker(dir);
|
|
66
|
+
const lib = parentIsLib || selfIsLib;
|
|
67
|
+
const result = [];
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (entry.name === '.shelib') continue;
|
|
70
|
+
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
71
|
+
const abs = path.join(dir, entry.name);
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
const childIsLib = lib || hasShelibMarker(abs);
|
|
74
|
+
const children = buildTree(abs, rel, childIsLib);
|
|
75
|
+
result.push({ type: 'dir', name: entry.name, path: rel, lib: childIsLib, children });
|
|
76
|
+
} else if (entry.name.endsWith('.js')) {
|
|
77
|
+
const stat = fs.statSync(abs);
|
|
78
|
+
result.push({ type: 'file', name: entry.name, path: rel, lib, size: stat.size, mtime: stat.mtimeMs });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
result.sort((a, b) => {
|
|
82
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
83
|
+
return a.name.localeCompare(b.name);
|
|
84
|
+
});
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// All routes dispatched via router.use to avoid path-to-regexp v8 wildcard issues.
|
|
89
|
+
router.use((req, res) => {
|
|
90
|
+
const root = getRoot(req);
|
|
91
|
+
if (!root) return res.status(503).json({ error: 'scriptDir not configured' });
|
|
92
|
+
|
|
93
|
+
const method = req.method.toUpperCase();
|
|
94
|
+
const filePath = req.path.replace(/^\/+/, '');
|
|
95
|
+
|
|
96
|
+
// GET /she/scripts — flat list of all .js files with metadata
|
|
97
|
+
if (method === 'GET' && !filePath) {
|
|
98
|
+
return res.json(walk(root, '', false));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// GET /she/scripts/tree — nested tree structure
|
|
102
|
+
if (method === 'GET' && filePath === 'tree') {
|
|
103
|
+
return res.json(buildTree(root, '', false));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// GET /she/scripts/<path> — read file content
|
|
107
|
+
if (method === 'GET') {
|
|
108
|
+
const abs = safePath(root, filePath);
|
|
109
|
+
if (!abs) return res.status(400).json({ error: 'Invalid path' });
|
|
110
|
+
try {
|
|
111
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
112
|
+
return res.json({ path: filePath, content });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (err.code === 'ENOENT') return res.status(404).json({ error: 'Not found' });
|
|
115
|
+
return res.status(500).json({ error: err.message });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// PUT /she/scripts/<path> — create or overwrite file
|
|
120
|
+
if (method === 'PUT') {
|
|
121
|
+
const abs = safePath(root, filePath);
|
|
122
|
+
if (!abs) return res.status(400).json({ error: 'Invalid path' });
|
|
123
|
+
const basename = path.basename(abs);
|
|
124
|
+
if (!basename.endsWith('.js') && basename !== '.shelib') {
|
|
125
|
+
return res.status(400).json({ error: 'Only .js and .shelib files are allowed' });
|
|
126
|
+
}
|
|
127
|
+
const content = typeof req.body?.content === 'string' ? req.body.content : null;
|
|
128
|
+
if (content === null) return res.status(400).json({ error: 'Missing body.content string' });
|
|
129
|
+
try {
|
|
130
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
131
|
+
fs.writeFileSync(abs, content, 'utf8');
|
|
132
|
+
const stat = fs.statSync(abs);
|
|
133
|
+
return res.json({ ok: true, path: filePath, size: stat.size, mtime: stat.mtimeMs });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return res.status(500).json({ error: err.message });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// DELETE /she/scripts/<path> — delete file
|
|
140
|
+
if (method === 'DELETE') {
|
|
141
|
+
const abs = safePath(root, filePath);
|
|
142
|
+
if (!abs) return res.status(400).json({ error: 'Invalid path' });
|
|
143
|
+
try {
|
|
144
|
+
fs.unlinkSync(abs);
|
|
145
|
+
return res.json({ ok: true });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (err.code === 'ENOENT') return res.status(404).json({ error: 'Not found' });
|
|
148
|
+
return res.status(500).json({ error: err.message });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// POST /she/scripts/mkdir — create a directory
|
|
153
|
+
if (method === 'POST' && filePath === 'mkdir') {
|
|
154
|
+
const dirPath = typeof req.body?.path === 'string' ? req.body.path : null;
|
|
155
|
+
if (!dirPath) return res.status(400).json({ error: 'Missing body.path string' });
|
|
156
|
+
const abs = safePath(root, dirPath);
|
|
157
|
+
if (!abs) return res.status(400).json({ error: 'Invalid path' });
|
|
158
|
+
try {
|
|
159
|
+
fs.mkdirSync(abs, { recursive: true });
|
|
160
|
+
return res.json({ ok: true, path: dirPath });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return res.status(500).json({ error: err.message });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// POST /she/scripts/<path>/rename — rename / move file
|
|
167
|
+
if (method === 'POST') {
|
|
168
|
+
if (!filePath.endsWith('/rename')) return res.status(404).json({ error: 'Not found' });
|
|
169
|
+
const srcPath = filePath.slice(0, -'/rename'.length);
|
|
170
|
+
const abs = safePath(root, srcPath);
|
|
171
|
+
if (!abs) return res.status(400).json({ error: 'Invalid path' });
|
|
172
|
+
const newName = req.body?.newPath;
|
|
173
|
+
if (typeof newName !== 'string' || !newName) {
|
|
174
|
+
return res.status(400).json({ error: 'Missing body.newPath string' });
|
|
175
|
+
}
|
|
176
|
+
const absNew = safePath(root, newName);
|
|
177
|
+
if (!absNew) return res.status(400).json({ error: 'Invalid newPath' });
|
|
178
|
+
if (!absNew.endsWith('.js')) return res.status(400).json({ error: 'Only .js files are allowed' });
|
|
179
|
+
try {
|
|
180
|
+
fs.mkdirSync(path.dirname(absNew), { recursive: true });
|
|
181
|
+
fs.renameSync(abs, absNew);
|
|
182
|
+
return res.json({ ok: true, path: newName });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (err.code === 'ENOENT') return res.status(404).json({ error: 'Not found' });
|
|
185
|
+
return res.status(500).json({ error: err.message });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
res.status(405).json({ error: 'Method not allowed' });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
module.exports = { router };
|