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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -0
  3. package/dist/web/assets/codicon-DCmgc-ay.ttf +0 -0
  4. package/dist/web/assets/index-Bdf2J0nm.js +140 -0
  5. package/dist/web/assets/index-DkhtWYJx.css +1 -0
  6. package/dist/web/assets/monaco-langs-DZ6hB11b.js +1423 -0
  7. package/dist/web/assets/monaco-langs-DyX1CsEw.css +1 -0
  8. package/dist/web/assets/tsMode-THvwQw-l.js +16 -0
  9. package/dist/web/index.html +164 -0
  10. package/dist/web/monacoeditorwork/editor.worker.bundle.js +13519 -0
  11. package/dist/web/monacoeditorwork/ts.worker.bundle.js +256353 -0
  12. package/package.json +84 -10
  13. package/src/config.js +53 -0
  14. package/src/elastic.js +19 -0
  15. package/src/index.js +1184 -0
  16. package/src/influx.js +25 -0
  17. package/src/lib/mqtt-wildcards.js +34 -0
  18. package/src/lib/parse-payload.js +29 -0
  19. package/src/lib/redis.js +74 -0
  20. package/src/lib/shedb-core.js +447 -0
  21. package/src/lib/shedb-worker.js +126 -0
  22. package/src/lib/state-store.js +97 -0
  23. package/src/lib/storage.js +74 -0
  24. package/src/matter/controller.js +307 -0
  25. package/src/sandbox/api.js +57 -0
  26. package/src/sandbox/elastic-sandbox.js +88 -0
  27. package/src/sandbox/influx-sandbox.js +107 -0
  28. package/src/sandbox/matter-sandbox.js +92 -0
  29. package/src/sandbox/shedb-sandbox.js +89 -0
  30. package/src/sandbox/stdlib.js +132 -0
  31. package/src/scripts/hello.js +3 -0
  32. package/src/web/ai-api.js +443 -0
  33. package/src/web/config-api.js +34 -0
  34. package/src/web/deps-api.js +138 -0
  35. package/src/web/git-api.js +188 -0
  36. package/src/web/log-ws.js +71 -0
  37. package/src/web/matter-api.js +102 -0
  38. package/src/web/mqtt-api.js +65 -0
  39. package/src/web/scripts-api.js +192 -0
  40. package/src/web/server.js +130 -0
  41. package/src/web/shedb-api.js +140 -0
  42. package/src/web/shedb.js +168 -0
  43. package/index.js +0 -0
@@ -0,0 +1,443 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AI Assistant REST API — Express router mounted at /she/ai
5
+ *
6
+ * Proxies chat requests to a configured LLM provider (Ollama, LM Studio,
7
+ * OpenAI, or Anthropic), assembling context (MQTT state, sheDB doc IDs,
8
+ * Matter devices, she API reference) server-side based on per-request flags.
9
+ *
10
+ * Routes:
11
+ * GET /she/ai/config → { configured, provider, model, baseUrl }
12
+ * POST /she/ai/chat → { message, usage? } (non-streaming)
13
+ * POST /she/ai/chat/stream → SSE data: {"token":"..."} (streaming)
14
+ * data: [DONE]
15
+ *
16
+ * Call init(store) once after the state store is created.
17
+ */
18
+
19
+ const express = require('express');
20
+ const fs = require('fs');
21
+
22
+ const router = express.Router();
23
+ let _store = null;
24
+
25
+ /**
26
+ * @param {import('../lib/state-store')} store
27
+ */
28
+ function init(store) {
29
+ _store = store;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // she API reference — injected into the system prompt when requested
34
+ // ---------------------------------------------------------------------------
35
+ const SHE_API_REF = `## she sandbox API
36
+
37
+ Scripts run in a sandboxed VM. The \`she\` object is injected automatically.
38
+
39
+ ### Script conventions
40
+ - First lines: /* global she */ then 'use strict';
41
+ - No require() — the module system is not available
42
+ - All subscriptions and schedules persist across reconnects
43
+
44
+ ### MQTT
45
+ she.mqtt.sub(topic, [opts], cb) Subscribe; wildcards: + (1 level) # (multi)
46
+ +//sensor → +/status/sensor shorthand
47
+ opts.change: true = only fire when value changes
48
+ she.mqtt.pub(topic, payload, [opts]) Publish; opts: { qos, retain }
49
+ she.mqtt.get(topic) Current retained value (sync)
50
+ she.mqtt.set(topic, val) Publish as retained
51
+ she.mqtt.link(src, target, [fn]) Forward src changes to target; optional transform
52
+ she.mqtt.age(topic) Seconds since topic last received a message
53
+ she.mqtt.on('connect'|'disconnect', cb) MQTT lifecycle events
54
+
55
+ ### Scheduling
56
+ she.schedule(pattern, [opts], cb)
57
+ pattern: cron string | Date | suncalc event name
58
+ suncalc events: 'sunrise' 'sunset' 'dawn' 'dusk'
59
+ 'nauticalDawn' 'nauticalDusk' 'solarNoon' 'night'
60
+ opts.shift: seconds offset (e.g. -1800 = 30 min before event)
61
+ opts.random: max random delay in seconds added to the trigger time
62
+
63
+ ### Universal key-value API
64
+ she.on(key, cb) Subscribe. Key prefixes: mqtt:: var:: matter::
65
+ she.set(key, val) Set value (mqtt:: or var:: namespaces)
66
+ she.get(key) Current value
67
+ she.getObject(key) Current { val, ts, lc } state object
68
+
69
+ ### Variable system (var:: namespace)
70
+ Topics prefixed with "var" (default) are persisted as retained MQTT messages
71
+ and available across scripts via she.get('var::name') / she.set('var::name', v).
72
+
73
+ ### sheDB
74
+ she.db.get(id) Get document (undefined if not found)
75
+ she.db.set(id, doc) Create or overwrite document
76
+ she.db.extend(id, partial) Deep-merge partial into existing document
77
+ she.db.delete(id) Delete document
78
+ she.db.sub(pattern, cb) Subscribe to document changes (MQTT wildcard)
79
+ she.db.query(filter, mapFn, [reduceFn]) Synchronous ad-hoc query → Array
80
+
81
+ ### Matter
82
+ she.matter.sub(nodeId, endpointId, cluster, attr, cb) Subscribe to attribute
83
+ she.matter.unsub(listenerId)
84
+ she.matter.get(nodeId, endpointId, cluster, attr) → Promise<value>
85
+ she.matter.send(nodeId, endpointId, cluster, cmd, [args]) → Promise<result>
86
+
87
+ ### Helpers
88
+ she.timer(src, target, ms) Pulse target=1 for ms after src goes truthy
89
+ she.combineBool(srcs[], target) Publish OR of source values to target
90
+ she.combineMax(srcs[], target) Publish maximum of source values to target
91
+ she.link(src, target, [fn]) Alias for she.mqtt.link
92
+ she.age(topic) Alias for she.mqtt.age
93
+ she.now() Current timestamp in ms
94
+ she.debug / .info / .warn / .error Structured logging (prefixed with script name)
95
+ she.global Shared mutable object across all scripts`;
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Helpers
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Read the ai config section from config.json.
103
+ * Returns null if unavailable.
104
+ * @param {string|undefined} configPath
105
+ * @returns {{ provider?: string, baseUrl?: string, model?: string, apiKey?: string }|null}
106
+ */
107
+ function readAiConfig(configPath) {
108
+ if (!configPath) return null;
109
+ try {
110
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
111
+ return cfg.ai || null;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Build the full system prompt, including optional context sections.
119
+ *
120
+ * @param {object} requestCtx { apiref, mqtt, shedb, matter }
121
+ * @param {{ path?: string, content?: string }|null} currentScript
122
+ * @param {import('../lib/state-store')|null} store
123
+ * @returns {string}
124
+ */
125
+ function buildSystemPrompt(requestCtx, currentScript, store) {
126
+ const parts = [
127
+ `You are SHE Assistant, an expert AI pair programmer for she (smart-home-engine).
128
+ she is a Node.js daemon that runs user JavaScript scripts in a sandboxed VM for home automation.
129
+ When proposing changes to a script, always output the COMPLETE new file content in a single fenced \`\`\`javascript code block. Never output partial diffs or fragments — the user applies the full file at once.
130
+ Keep any existing header comments and the 'use strict'; directive.`,
131
+ ];
132
+
133
+ if (requestCtx.apiref) {
134
+ parts.push(SHE_API_REF);
135
+ }
136
+
137
+ if (currentScript?.path && typeof currentScript.content === 'string') {
138
+ parts.push(`## Current script: ${currentScript.path}\n\`\`\`javascript\n${currentScript.content}\n\`\`\``);
139
+ }
140
+
141
+ if (requestCtx.mqtt && store) {
142
+ const topics = [];
143
+ for (const [topic, obj] of store.mqttEntries()) {
144
+ topics.push(`${topic}: ${JSON.stringify(obj.val)}`);
145
+ if (topics.length >= 100) {
146
+ topics.push('… (truncated)');
147
+ break;
148
+ }
149
+ }
150
+ if (topics.length > 0) {
151
+ parts.push(`## Current MQTT state\n${topics.join('\n')}`);
152
+ }
153
+ }
154
+
155
+ if (requestCtx.shedb) {
156
+ try {
157
+ const core = require('./shedb').getCore();
158
+ if (core) {
159
+ const ids = typeof core.listIds === 'function' ? core.listIds() : [];
160
+ if (ids.length > 0) {
161
+ parts.push(`## sheDB document IDs (${ids.length} total)\n${ids.slice(0, 200).join('\n')}`);
162
+ }
163
+ }
164
+ } catch {
165
+ // shedb not initialised — skip silently
166
+ }
167
+ }
168
+
169
+ if (requestCtx.matter) {
170
+ try {
171
+ const controller = require('../matter/controller');
172
+ if (typeof controller.listPaired === 'function') {
173
+ const nodes = controller.listPaired();
174
+ if (nodes.length > 0) {
175
+ const list = nodes.map((n) => ` nodeId ${n.nodeId}: ${n.label || 'unnamed'}`).join('\n');
176
+ parts.push(`## Paired Matter devices\n${list}`);
177
+ }
178
+ }
179
+ } catch {
180
+ // matter not initialised — skip silently
181
+ }
182
+ }
183
+
184
+ return parts.join('\n\n');
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Provider adapters — non-streaming
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /**
192
+ * @param {{ baseUrl?: string, model: string, apiKey?: string }} config
193
+ * @param {Array<{role:string,content:string}>} messages
194
+ */
195
+ async function callOpenAICompat(config, messages) {
196
+ const base = (config.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
197
+ const url = `${base}/v1/chat/completions`;
198
+ const headers = { 'Content-Type': 'application/json' };
199
+ if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
200
+
201
+ const res = await fetch(url, {
202
+ method: 'POST',
203
+ headers,
204
+ body: JSON.stringify({ model: config.model, messages, stream: false }),
205
+ });
206
+
207
+ if (!res.ok) {
208
+ const text = await res.text().catch(() => '');
209
+ throw new Error(`LLM API error ${res.status}: ${text.slice(0, 300)}`);
210
+ }
211
+
212
+ const json = await res.json();
213
+ const choice = json.choices?.[0];
214
+ const message = choice?.message?.content ?? choice?.text ?? '';
215
+ const usage = json.usage
216
+ ? {
217
+ prompt_tokens: json.usage.prompt_tokens,
218
+ completion_tokens: json.usage.completion_tokens,
219
+ }
220
+ : undefined;
221
+ return { message, usage };
222
+ }
223
+
224
+ /**
225
+ * @param {{ model: string, apiKey?: string }} config
226
+ * @param {Array<{role:string,content:string}>} messages — first may be role:'system'
227
+ */
228
+ async function callAnthropic(config, messages) {
229
+ const systemMsg = messages.find((m) => m.role === 'system');
230
+ const userMessages = messages.filter((m) => m.role !== 'system');
231
+
232
+ const headers = {
233
+ 'Content-Type': 'application/json',
234
+ 'x-api-key': config.apiKey || '',
235
+ 'anthropic-version': '2023-06-01',
236
+ };
237
+
238
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
239
+ method: 'POST',
240
+ headers,
241
+ body: JSON.stringify({
242
+ model: config.model,
243
+ system: systemMsg?.content || '',
244
+ messages: userMessages,
245
+ max_tokens: 4096,
246
+ }),
247
+ });
248
+
249
+ if (!res.ok) {
250
+ const text = await res.text().catch(() => '');
251
+ throw new Error(`Anthropic API error ${res.status}: ${text.slice(0, 300)}`);
252
+ }
253
+
254
+ const json = await res.json();
255
+ const message = json.content?.[0]?.text ?? '';
256
+ const usage = json.usage
257
+ ? {
258
+ prompt_tokens: json.usage.input_tokens,
259
+ completion_tokens: json.usage.output_tokens,
260
+ }
261
+ : undefined;
262
+ return { message, usage };
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Provider adapters — streaming
267
+ // ---------------------------------------------------------------------------
268
+
269
+ /**
270
+ * Parse an SSE ReadableStream, calling onToken for each non-null extracted value.
271
+ * @param {ReadableStream} body
272
+ * @param {(json:object)=>string|null|undefined} tokenExtractor
273
+ * @param {(token:string)=>void} onToken
274
+ */
275
+ async function parseSseStream(body, tokenExtractor, onToken) {
276
+ const reader = body.getReader();
277
+ const decoder = new TextDecoder();
278
+ let buffer = '';
279
+
280
+ try {
281
+ while (true) {
282
+ const { done, value } = await reader.read();
283
+ if (done) break;
284
+ buffer += decoder.decode(value, { stream: true });
285
+ const lines = buffer.split('\n');
286
+ buffer = lines.pop() ?? '';
287
+
288
+ for (const line of lines) {
289
+ if (!line.startsWith('data: ')) continue;
290
+ const data = line.slice(6).trim();
291
+ if (data === '[DONE]') return;
292
+ try {
293
+ const json = JSON.parse(data);
294
+ const token = tokenExtractor(json);
295
+ if (token) onToken(token);
296
+ } catch {
297
+ // skip malformed JSON lines
298
+ }
299
+ }
300
+ }
301
+ } finally {
302
+ reader.releaseLock();
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Stream tokens from an OpenAI-compatible endpoint.
308
+ * Calls onToken(str) for each chunk, resolves when stream ends.
309
+ */
310
+ async function streamOpenAICompat(config, messages, onToken) {
311
+ const base = (config.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
312
+ const url = `${base}/v1/chat/completions`;
313
+ const headers = { 'Content-Type': 'application/json' };
314
+ if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
315
+
316
+ const res = await fetch(url, {
317
+ method: 'POST',
318
+ headers,
319
+ body: JSON.stringify({ model: config.model, messages, stream: true }),
320
+ });
321
+
322
+ if (!res.ok) {
323
+ const text = await res.text().catch(() => '');
324
+ throw new Error(`LLM API error ${res.status}: ${text.slice(0, 300)}`);
325
+ }
326
+
327
+ await parseSseStream(res.body, (json) => json.choices?.[0]?.delta?.content, onToken);
328
+ }
329
+
330
+ /**
331
+ * Stream tokens from Anthropic Messages API.
332
+ */
333
+ async function streamAnthropic(config, messages, onToken) {
334
+ const systemMsg = messages.find((m) => m.role === 'system');
335
+ const userMessages = messages.filter((m) => m.role !== 'system');
336
+
337
+ const headers = {
338
+ 'Content-Type': 'application/json',
339
+ 'x-api-key': config.apiKey || '',
340
+ 'anthropic-version': '2023-06-01',
341
+ };
342
+
343
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
344
+ method: 'POST',
345
+ headers,
346
+ body: JSON.stringify({
347
+ model: config.model,
348
+ system: systemMsg?.content || '',
349
+ messages: userMessages,
350
+ max_tokens: 4096,
351
+ stream: true,
352
+ }),
353
+ });
354
+
355
+ if (!res.ok) {
356
+ const text = await res.text().catch(() => '');
357
+ throw new Error(`Anthropic API error ${res.status}: ${text.slice(0, 300)}`);
358
+ }
359
+
360
+ await parseSseStream(res.body, (json) => json.delta?.text, onToken);
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Routes
365
+ // ---------------------------------------------------------------------------
366
+
367
+ // GET /she/ai/config
368
+ router.get('/config', (req, res) => {
369
+ const ai = readAiConfig(req.app.locals.configPath);
370
+ res.json({
371
+ configured: !!(ai?.provider && ai?.model),
372
+ provider: ai?.provider || '',
373
+ model: ai?.model || '',
374
+ baseUrl: ai?.baseUrl || '',
375
+ });
376
+ });
377
+
378
+ // POST /she/ai/chat — non-streaming
379
+ router.post('/chat', async (req, res) => {
380
+ const ai = readAiConfig(req.app.locals.configPath);
381
+ if (!ai?.provider || !ai?.model) {
382
+ return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
383
+ }
384
+
385
+ const { messages = [], currentScript, context = {} } = req.body || {};
386
+ if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
387
+
388
+ const systemPrompt = buildSystemPrompt(context, currentScript ?? null, _store);
389
+ const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
390
+
391
+ try {
392
+ let result;
393
+ if (ai.provider === 'anthropic') {
394
+ result = await callAnthropic(ai, fullMessages);
395
+ } else {
396
+ result = await callOpenAICompat(ai, fullMessages);
397
+ }
398
+ res.json(result);
399
+ } catch (e) {
400
+ res.status(500).json({ error: e.message });
401
+ }
402
+ });
403
+
404
+ // POST /she/ai/chat/stream — SSE streaming
405
+ router.post('/chat/stream', async (req, res) => {
406
+ const ai = readAiConfig(req.app.locals.configPath);
407
+ if (!ai?.provider || !ai?.model) {
408
+ return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
409
+ }
410
+
411
+ const { messages = [], currentScript, context = {} } = req.body || {};
412
+ if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
413
+
414
+ res.set({
415
+ 'Content-Type': 'text/event-stream',
416
+ 'Cache-Control': 'no-cache',
417
+ 'Connection': 'keep-alive',
418
+ });
419
+ res.flushHeaders();
420
+
421
+ const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
422
+
423
+ const systemPrompt = buildSystemPrompt(context, currentScript ?? null, _store);
424
+ const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
425
+
426
+ try {
427
+ const onToken = (t) => send({ token: t });
428
+
429
+ if (ai.provider === 'anthropic') {
430
+ await streamAnthropic(ai, fullMessages, onToken);
431
+ } else {
432
+ await streamOpenAICompat(ai, fullMessages, onToken);
433
+ }
434
+
435
+ res.write('data: [DONE]\n\n');
436
+ res.end();
437
+ } catch (e) {
438
+ send({ error: e.message });
439
+ res.end();
440
+ }
441
+ });
442
+
443
+ module.exports = { router, init };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { getConfigPath } = require('../lib/storage');
7
+
8
+ const DEFAULT_CONFIG_PATH = getConfigPath();
9
+
10
+ const router = express.Router();
11
+
12
+ router.get('/', (req, res) => {
13
+ const configPath = req.app.locals.configPath || DEFAULT_CONFIG_PATH;
14
+ let fileConfig = {};
15
+ try {
16
+ fileConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
17
+ } catch {
18
+ // config file does not exist yet — return empty object
19
+ }
20
+ res.json(fileConfig);
21
+ });
22
+
23
+ router.put('/', (req, res) => {
24
+ const configPath = req.app.locals.configPath || DEFAULT_CONFIG_PATH;
25
+ try {
26
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
27
+ fs.writeFileSync(configPath, JSON.stringify(req.body, null, 2), 'utf8');
28
+ res.json({ ok: true, restartRequired: true, configPath });
29
+ } catch (err) {
30
+ res.status(500).json({ error: err.message });
31
+ }
32
+ });
33
+
34
+ module.exports = { router, DEFAULT_CONFIG_PATH };
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const { execFile } = require('child_process');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const https = require('https');
8
+ const { STORAGE_ROOT } = require('../lib/storage');
9
+
10
+ const router = express.Router();
11
+
12
+ /** Ensure ~/.she/package.json exists so npm commands work. */
13
+ function ensurePackageJson() {
14
+ const pkgPath = path.join(STORAGE_ROOT, 'package.json');
15
+ if (!fs.existsSync(pkgPath)) {
16
+ fs.writeFileSync(
17
+ pkgPath,
18
+ JSON.stringify(
19
+ {
20
+ name: 'she-user-scripts',
21
+ version: '1.0.0',
22
+ private: true,
23
+ description: 'User-installed npm packages for she scripts',
24
+ dependencies: {},
25
+ },
26
+ null,
27
+ 2,
28
+ ) + '\n',
29
+ 'utf8',
30
+ );
31
+ }
32
+ }
33
+
34
+ function readPackageJson() {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(path.join(STORAGE_ROOT, 'package.json'), 'utf8'));
37
+ } catch {
38
+ return { dependencies: {} };
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Strict npm package-name validation.
44
+ * Allows scoped (@scope/name) and plain names; lowercase; no path traversal.
45
+ */
46
+ function isValidPkgName(name) {
47
+ return typeof name === 'string' && name.length > 0 && name.length <= 214 && /^(@[a-z0-9][a-z0-9_\-.]*\/)?[a-z0-9][a-z0-9_\-.]*$/.test(name);
48
+ }
49
+
50
+ /** Allow semver ranges, tags, and dist-tags (no shell-special chars). */
51
+ function isValidVersion(v) {
52
+ return typeof v === 'string' && v.length > 0 && v.length <= 50 && /^[a-z0-9_\-.*^~>=<|]+$/i.test(v);
53
+ }
54
+
55
+ // GET /she/deps — list installed packages from ~/.she/package.json
56
+ router.get('/', (req, res) => {
57
+ const pkg = readPackageJson();
58
+ const deps = pkg.dependencies || {};
59
+ res.json(Object.entries(deps).map(([name, version]) => ({ name, version })));
60
+ });
61
+
62
+ // GET /she/deps/search?q=term — search the npm registry
63
+ router.get('/search', (req, res) => {
64
+ const q = String(req.query.q ?? '').trim();
65
+ if (!q) return res.status(400).json({ error: 'Missing q parameter' });
66
+
67
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(q)}&size=20`;
68
+ const npmReq = https.get(url, { timeout: 10000 }, (npmRes) => {
69
+ let data = '';
70
+ npmRes.on('data', (chunk) => {
71
+ data += chunk;
72
+ });
73
+ npmRes.on('end', () => {
74
+ try {
75
+ const parsed = JSON.parse(data);
76
+ const results = (parsed.objects ?? []).map((obj) => ({
77
+ name: obj.package.name,
78
+ version: obj.package.version,
79
+ description: obj.package.description ?? '',
80
+ }));
81
+ res.json(results);
82
+ } catch {
83
+ if (!res.headersSent) res.status(502).json({ error: 'Failed to parse npm registry response' });
84
+ }
85
+ });
86
+ });
87
+ npmReq.on('error', (err) => {
88
+ if (!res.headersSent) res.status(502).json({ error: err.message });
89
+ });
90
+ npmReq.on('timeout', () => {
91
+ npmReq.destroy();
92
+ if (!res.headersSent) res.status(504).json({ error: 'npm registry timeout' });
93
+ });
94
+ });
95
+
96
+ // POST /she/deps/install — { name, version? }
97
+ router.post('/install', (req, res) => {
98
+ const name = String(req.body?.name ?? '').trim();
99
+ const version = req.body?.version ? String(req.body.version).trim() : null;
100
+
101
+ if (!isValidPkgName(name)) return res.status(400).json({ error: 'Invalid package name' });
102
+ if (version !== null && !isValidVersion(version)) return res.status(400).json({ error: 'Invalid version specifier' });
103
+
104
+ ensurePackageJson();
105
+ const spec = version ? `${name}@${version}` : name;
106
+ execFile('npm', ['install', '--save', spec], { cwd: STORAGE_ROOT, timeout: 120000 }, (err, stdout, stderr) => {
107
+ if (err) return res.status(500).json({ error: stderr || err.message, stdout });
108
+ res.json({ ok: true, stdout, stderr });
109
+ });
110
+ });
111
+
112
+ // POST /she/deps/remove — { name }
113
+ router.post('/remove', (req, res) => {
114
+ const name = String(req.body?.name ?? '').trim();
115
+
116
+ if (!isValidPkgName(name)) return res.status(400).json({ error: 'Invalid package name' });
117
+
118
+ ensurePackageJson();
119
+ execFile('npm', ['uninstall', '--save', name], { cwd: STORAGE_ROOT, timeout: 60000 }, (err, stdout, stderr) => {
120
+ if (err) return res.status(500).json({ error: stderr || err.message, stdout });
121
+ res.json({ ok: true, stdout, stderr });
122
+ });
123
+ });
124
+
125
+ // POST /she/deps/update — { name }
126
+ router.post('/update', (req, res) => {
127
+ const name = String(req.body?.name ?? '').trim();
128
+
129
+ if (!isValidPkgName(name)) return res.status(400).json({ error: 'Invalid package name' });
130
+
131
+ ensurePackageJson();
132
+ execFile('npm', ['install', '--save', `${name}@latest`], { cwd: STORAGE_ROOT, timeout: 120000 }, (err, stdout, stderr) => {
133
+ if (err) return res.status(500).json({ error: stderr || err.message, stdout });
134
+ res.json({ ok: true, stdout, stderr });
135
+ });
136
+ });
137
+
138
+ module.exports = { router };