smart-home-engine 1.21.0 → 1.34.3

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/src/index.js CHANGED
@@ -37,6 +37,12 @@ if (process.argv.includes('--install')) {
37
37
  // Ensure the data directory exists before anything else runs
38
38
  require('./lib/storage').ensureRoot();
39
39
 
40
+ // Secrets CLI (roadmap A5): she --secret-set <group>/<field> (value on stdin) | --secret-delete <group>[/<field>] | --secret-list
41
+ {
42
+ const idx = process.argv.findIndex((a) => a === '--secret-set' || a === '--secret-delete' || a === '--secret-list');
43
+ if (idx !== -1) process.exit(require('./lib/secrets').cli(process.argv.slice(idx)));
44
+ }
45
+
40
46
  // ---------------------------------------------------------------------------
41
47
  // Persistent JSON-Lines log file — written alongside the pino-pretty stream.
42
48
  // On each daemon start: rotate she.jsonl → she.jsonl.1, then open fresh.
@@ -44,6 +50,7 @@ require('./lib/storage').ensureRoot();
44
50
  const _fs = require('fs');
45
51
  const _path = require('path');
46
52
  const { LOGS_DIR } = require('./lib/storage');
53
+ const secrets = require('./lib/secrets');
47
54
  const _logFileCurrent = _path.join(LOGS_DIR, 'she.jsonl');
48
55
  const _logFilePrev = _path.join(LOGS_DIR, 'she.jsonl.1');
49
56
  try {
@@ -78,29 +85,40 @@ const { broadcastLog, broadcast, setWelcomeProvider } = require('./web/log-ws');
78
85
  const shedb = require('./web/shedb');
79
86
  const log = {
80
87
  debug: (...args) => {
81
- _pino.debug(args.join(' '));
82
- broadcastLog({ level: 'debug', msg: args.join(' '), ts: Date.now() });
83
- _writeLogLine('debug', args.join(' '));
88
+ const msg = secrets.redact(args.join(' '));
89
+ _pino.debug(msg);
90
+ broadcastLog({ level: 'debug', msg, ts: Date.now() });
91
+ _writeLogLine('debug', msg);
84
92
  },
85
93
  info: (...args) => {
86
- _pino.info(args.join(' '));
87
- broadcastLog({ level: 'info', msg: args.join(' '), ts: Date.now() });
88
- _writeLogLine('info', args.join(' '));
94
+ const msg = secrets.redact(args.join(' '));
95
+ _pino.info(msg);
96
+ broadcastLog({ level: 'info', msg, ts: Date.now() });
97
+ _writeLogLine('info', msg);
89
98
  },
90
99
  warn: (...args) => {
91
- _pino.warn(args.join(' '));
92
- broadcastLog({ level: 'warn', msg: args.join(' '), ts: Date.now() });
93
- _writeLogLine('warn', args.join(' '));
100
+ const msg = secrets.redact(args.join(' '));
101
+ _pino.warn(msg);
102
+ broadcastLog({ level: 'warn', msg, ts: Date.now() });
103
+ _writeLogLine('warn', msg);
94
104
  },
95
105
  error: (...args) => {
96
- _pino.error(args.join(' '));
97
- broadcastLog({ level: 'error', msg: args.join(' '), ts: Date.now() });
98
- _writeLogLine('error', args.join(' '));
106
+ const msg = secrets.redact(args.join(' '));
107
+ _pino.error(msg);
108
+ broadcastLog({ level: 'error', msg, ts: Date.now() });
109
+ _writeLogLine('error', msg);
99
110
  },
100
111
  setLevel: (level) => {
101
112
  _pino.level = level;
102
113
  },
103
114
  };
115
+
116
+ // Secrets store (roadmap A5): read once at startup; the UI/CLI keep it current afterwards.
117
+ {
118
+ const st = secrets.load();
119
+ if (st.status === 'locked' || st.status === 'error') log.warn('secrets: ' + st.error + ' — she.secrets.get() returns undefined until the key is available');
120
+ else if (st.groups > 0) log.info(`secrets: ${st.groups} group(s) loaded`);
121
+ }
104
122
  const pkg = require('../package.json');
105
123
 
106
124
  /**
@@ -362,6 +380,9 @@ function startOnce(reason) {
362
380
  // Wire up the MQTT API: pass the state store and a getter for the live MQTT client.
363
381
  // The getter always returns the current value of `mqtt` (null until connected).
364
382
  require('./web/mqtt-api').init(store, () => mqtt);
383
+ require('./web/services-api').init(store, () => mqtt, {
384
+ getMqttConfig: () => ({ url: config.url, username: config.mqttUsername, password: config.mqttPassword }),
385
+ });
365
386
  require('./web/ai-api').init(store);
366
387
 
367
388
  // MQTT message rate counter — reset on each stats poll
@@ -1182,6 +1203,19 @@ function runScript(script, name, _origin) {
1182
1203
  latitude: config.latitude,
1183
1204
  longitude: config.longitude,
1184
1205
  });
1206
+ // she.secrets (roadmap A5): read-only access to the secrets store; unknown paths warn once per script
1207
+ const _secretWarned = new Set();
1208
+ she.secrets = Object.freeze({
1209
+ get(p) {
1210
+ const v = secrets.get(p);
1211
+ if (v === undefined && !_secretWarned.has(String(p))) {
1212
+ _secretWarned.add(String(p));
1213
+ log.warn(logLabel, `secret "${p}" not found` + (secrets.status().status === 'locked' ? ' (secrets store is locked)' : ''));
1214
+ }
1215
+ return v;
1216
+ },
1217
+ has: (p) => secrets.has(p),
1218
+ });
1185
1219
  // she.setTimeout / she.clearTimeout — tracked versions for use by stdlib and
1186
1220
  // sandbox modules that don't have direct access to the Sandbox context.
1187
1221
  she.setTimeout = (fn, delay, ...args) => {
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Small npm registry client with a 24 h cache — "is there a newer version of
5
+ * <package>?" for the Services page (roadmap I4) and, later, the adapter
6
+ * catalog (I7). Best-effort: every failure yields null, nothing throws.
7
+ */
8
+
9
+ const semverCompare = require('semantic-compare');
10
+
11
+ const REGISTRY = 'https://registry.npmjs.org';
12
+ const TTL = 24 * 60 * 60 * 1000;
13
+
14
+ /** @type {Map<string, {version: string|null, ts: number, promise?: Promise<string|null>}>} */
15
+ const _cache = new Map();
16
+
17
+ let _fetch = (...args) => fetch(...args);
18
+
19
+ /** Test hook: replace the fetch implementation. */
20
+ function setFetch(fn) {
21
+ _fetch = fn;
22
+ }
23
+
24
+ function clearCache() {
25
+ _cache.clear();
26
+ }
27
+
28
+ function validName(name) {
29
+ return typeof name === 'string' && /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(name);
30
+ }
31
+
32
+ /**
33
+ * Latest published version of a package (dist-tag `latest`), cached for 24 h.
34
+ * @param {string} name
35
+ * @param {{force?: boolean, now?: number}} [opts]
36
+ * @returns {Promise<string|null>}
37
+ */
38
+ async function latestVersion(name, opts = {}) {
39
+ if (!validName(name)) return null;
40
+ const now = opts.now ?? Date.now();
41
+ const hit = _cache.get(name);
42
+ if (hit && !opts.force) {
43
+ if (hit.promise) return hit.promise;
44
+ if (now - hit.ts < TTL) return hit.version;
45
+ }
46
+ const promise = (async () => {
47
+ try {
48
+ const res = await _fetch(`${REGISTRY}/${name}/latest`, { signal: AbortSignal.timeout(10000) });
49
+ if (!res.ok) throw new Error(String(res.status));
50
+ const data = await res.json();
51
+ return typeof data.version === 'string' ? data.version : null;
52
+ } catch {
53
+ return hit ? hit.version : null; // keep the last good answer
54
+ }
55
+ })();
56
+ _cache.set(name, { version: hit ? hit.version : null, ts: now, promise });
57
+ const version = await promise;
58
+ _cache.set(name, { version, ts: now });
59
+ return version;
60
+ }
61
+
62
+ /**
63
+ * Newer version available? null when unknown.
64
+ * @param {string} name
65
+ * @param {string|null} installed
66
+ * @returns {Promise<{latest: string|null, updateAvailable: boolean|null}>}
67
+ */
68
+ async function updateInfo(name, installed) {
69
+ const latest = await latestVersion(name);
70
+ if (!latest || !installed) return { latest, updateAvailable: null };
71
+ let updateAvailable = null;
72
+ try {
73
+ updateAvailable = semverCompare(installed, latest) < 0;
74
+ } catch {
75
+ /* unparsable version */
76
+ }
77
+ return { latest, updateAvailable };
78
+ }
79
+
80
+ module.exports = { latestVersion, updateInfo, validName, setFetch, clearCache, REGISTRY };
@@ -0,0 +1,320 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Secrets store (roadmap A5): named groups of string fields, kept in an AES-256-GCM encrypted
5
+ * file under the config dir. Values never leave the daemon — the HTTP API is write-only, the only
6
+ * reader is script code via she.secrets — except *plain* fields (a username, a host): those are
7
+ * listed in clear, while *secret* fields are write-only; marking a field secret is one-way (A9).
8
+ * The key comes from SHE_SECRETS_KEY (hex or base64,
9
+ * 32 bytes) or from a key file next to the data, generated 0600 on first write.
10
+ *
11
+ * The point of the encryption is that a backup or a copied ~/.she holds no plaintext; the key
12
+ * file next to the data protects nothing against a local reader — see doc/security.md.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const crypto = require('crypto');
18
+ const { CONFIG_ROOT } = require('./storage');
19
+
20
+ const NAME_RE = /^[A-Za-z0-9_.-]{1,64}$/;
21
+ const MAX_VALUE = 64 * 1024;
22
+ const MIN_REDACT = 6;
23
+ const ALG = 'aes-256-gcm';
24
+
25
+ let _file = path.join(CONFIG_ROOT, 'secrets.enc');
26
+ let _keyFile = path.join(CONFIG_ROOT, 'secrets.key');
27
+ let _env = process.env;
28
+
29
+ /** @type {Record<string, Record<string, {value: string, changed: number}>>} */
30
+ let _data = {};
31
+ let _status = 'empty'; // empty | ok | locked | error
32
+ let _error = null;
33
+ let _keySource = null; // env | file | null
34
+ let _redactList = [];
35
+
36
+ /** Test hook: point the store at other files / another environment and forget everything. */
37
+ function init({ file, keyFile, env } = {}) {
38
+ if (file) _file = file;
39
+ if (keyFile) _keyFile = keyFile;
40
+ if (env) _env = env;
41
+ _data = {};
42
+ _status = 'empty';
43
+ _error = null;
44
+ _keySource = null;
45
+ _redactList = [];
46
+ }
47
+
48
+ /** Entries written before kinds existed carry no flag and were secrets by definition. */
49
+ function isSecret(e) {
50
+ return e.secret !== false;
51
+ }
52
+
53
+ function validName(n) {
54
+ return typeof n === 'string' && NAME_RE.test(n);
55
+ }
56
+
57
+ function parseKey(str, what) {
58
+ const s = String(str).trim();
59
+ let buf;
60
+ if (/^[0-9a-fA-F]{64}$/.test(s)) buf = Buffer.from(s, 'hex');
61
+ else buf = Buffer.from(s, 'base64');
62
+ if (buf.length !== 32) throw new Error(`${what} must be a 32-byte key as hex (64 chars) or base64`);
63
+ return buf;
64
+ }
65
+
66
+ /** The encryption key, or null when there is none and `create` is false. */
67
+ function loadKey({ create = false } = {}) {
68
+ if (_env.SHE_SECRETS_KEY) {
69
+ _keySource = 'env';
70
+ return parseKey(_env.SHE_SECRETS_KEY, 'SHE_SECRETS_KEY');
71
+ }
72
+ try {
73
+ const key = parseKey(fs.readFileSync(_keyFile, 'utf8'), _keyFile);
74
+ _keySource = 'file';
75
+ return key;
76
+ } catch (err) {
77
+ if (err.code !== 'ENOENT') throw err;
78
+ }
79
+ if (!create) {
80
+ _keySource = null;
81
+ return null;
82
+ }
83
+ const key = crypto.randomBytes(32);
84
+ fs.mkdirSync(path.dirname(_keyFile), { recursive: true });
85
+ fs.writeFileSync(_keyFile, key.toString('hex') + '\n', { mode: 0o600 });
86
+ _keySource = 'file';
87
+ return key;
88
+ }
89
+
90
+ function encrypt(key, plaintext) {
91
+ const iv = crypto.randomBytes(12);
92
+ const c = crypto.createCipheriv(ALG, key, iv);
93
+ const data = Buffer.concat([c.update(plaintext, 'utf8'), c.final()]);
94
+ return { v: 1, alg: ALG, iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), data: data.toString('base64') };
95
+ }
96
+
97
+ function decrypt(key, obj) {
98
+ const d = crypto.createDecipheriv(ALG, key, Buffer.from(obj.iv, 'base64'));
99
+ d.setAuthTag(Buffer.from(obj.tag, 'base64'));
100
+ return Buffer.concat([d.update(Buffer.from(obj.data, 'base64')), d.final()]).toString('utf8');
101
+ }
102
+
103
+ function rebuildRedact() {
104
+ const vals = new Set();
105
+ for (const g of Object.values(_data)) for (const f of Object.values(g)) if (isSecret(f) && f.value.length >= MIN_REDACT) vals.add(f.value);
106
+ _redactList = [...vals].sort((a, b) => b.length - a.length);
107
+ }
108
+
109
+ /**
110
+ * Read the file. Never creates anything. Returns the status.
111
+ * @returns {{status: string, error: string|null, groups: number}}
112
+ */
113
+ function load() {
114
+ _data = {};
115
+ _error = null;
116
+ let raw;
117
+ try {
118
+ raw = fs.readFileSync(_file, 'utf8');
119
+ } catch (err) {
120
+ if (err.code === 'ENOENT') {
121
+ _status = 'empty';
122
+ try {
123
+ loadKey();
124
+ } catch (e) {
125
+ _status = 'error';
126
+ _error = e.message;
127
+ }
128
+ rebuildRedact();
129
+ return status();
130
+ }
131
+ _status = 'error';
132
+ _error = err.message;
133
+ return status();
134
+ }
135
+ let key;
136
+ try {
137
+ key = loadKey();
138
+ } catch (err) {
139
+ _status = 'error';
140
+ _error = err.message;
141
+ return status();
142
+ }
143
+ if (!key) {
144
+ _status = 'locked';
145
+ _error = `no key: set SHE_SECRETS_KEY or restore ${_keyFile}`;
146
+ return status();
147
+ }
148
+ try {
149
+ const obj = JSON.parse(raw);
150
+ if (!obj || obj.alg !== ALG) throw new Error('unknown file format');
151
+ const parsed = JSON.parse(decrypt(key, obj));
152
+ _data = parsed && typeof parsed === 'object' ? parsed : {};
153
+ _status = 'ok';
154
+ } catch (err) {
155
+ _status = 'locked';
156
+ _error = /unable to authenticate|auth/i.test(err.message) ? 'wrong key: the file was encrypted with another key' : 'cannot read the file: ' + err.message;
157
+ }
158
+ rebuildRedact();
159
+ return status();
160
+ }
161
+
162
+ function save() {
163
+ const key = loadKey({ create: true });
164
+ fs.mkdirSync(path.dirname(_file), { recursive: true });
165
+ const tmp = _file + '.tmp';
166
+ fs.writeFileSync(tmp, JSON.stringify(encrypt(key, JSON.stringify(_data))) + '\n', { mode: 0o600 });
167
+ fs.renameSync(tmp, _file);
168
+ _status = 'ok';
169
+ _error = null;
170
+ rebuildRedact();
171
+ }
172
+
173
+ function assertWritable() {
174
+ if (_status === 'locked' || _status === 'error') {
175
+ const err = new Error('secrets store is locked: ' + _error);
176
+ err.code = 'LOCKED';
177
+ throw err;
178
+ }
179
+ }
180
+
181
+ /** Status for the UI / CLI: no values. */
182
+ function status() {
183
+ return { status: _status, error: _error, keySource: _keySource, file: _file, keyFile: _keyFile, groups: Object.keys(_data).length };
184
+ }
185
+
186
+ /** Names and change times only. */
187
+ function list() {
188
+ return Object.keys(_data)
189
+ .sort()
190
+ .map((name) => {
191
+ const fields = Object.keys(_data[name])
192
+ .sort()
193
+ .map((f) => {
194
+ const e = _data[name][f];
195
+ return isSecret(e) ? { name: f, changed: e.changed, secret: true } : { name: f, changed: e.changed, secret: false, value: e.value };
196
+ });
197
+ return { name, changed: Math.max(0, ...fields.map((f) => f.changed)), fields };
198
+ });
199
+ }
200
+
201
+ function splitPath(p) {
202
+ if (typeof p !== 'string') return null;
203
+ const parts = p.split('/');
204
+ if (parts.length > 2 || !parts.every(validName)) return null;
205
+ return parts;
206
+ }
207
+
208
+ /** 'group/field' → string, 'group' → frozen {field: value}; undefined when absent or locked. */
209
+ function get(p) {
210
+ const parts = splitPath(p);
211
+ if (!parts) return undefined;
212
+ const g = _data[parts[0]];
213
+ if (!g) return undefined;
214
+ if (parts.length === 1) {
215
+ const out = {};
216
+ for (const [f, v] of Object.entries(g)) out[f] = v.value;
217
+ return Object.freeze(out);
218
+ }
219
+ return g[parts[1]] ? g[parts[1]].value : undefined;
220
+ }
221
+
222
+ function has(p) {
223
+ return get(p) !== undefined;
224
+ }
225
+
226
+ /**
227
+ * Create or replace a field. `secret` decides the kind of a *new* field (default: secret);
228
+ * an existing secret field stays secret whatever is passed — the only way down is delete.
229
+ */
230
+ function set(group, field, value, opts = {}) {
231
+ const now = typeof opts === 'number' ? opts : (opts.now ?? Date.now());
232
+ assertWritable();
233
+ if (!validName(group) || !validName(field)) throw Object.assign(new Error('names: letters, digits, _ . - (max 64)'), { code: 'INVALID_NAME' });
234
+ if (typeof value !== 'string' || value.length === 0) throw Object.assign(new Error('value must be a non-empty string'), { code: 'INVALID_VALUE' });
235
+ if (Buffer.byteLength(value) > MAX_VALUE) throw Object.assign(new Error(`value exceeds ${MAX_VALUE} bytes`), { code: 'INVALID_VALUE' });
236
+ if (!_data[group]) _data[group] = {};
237
+ const existing = _data[group][field];
238
+ const secret = existing ? isSecret(existing) || opts.secret === true : opts.secret !== false;
239
+ _data[group][field] = { value, changed: now, secret };
240
+ save();
241
+ return { group, field, changed: now, secret };
242
+ }
243
+
244
+ /** Mark a plain field secret — one-way; returns false when the field does not exist. */
245
+ function mark(group, field) {
246
+ assertWritable();
247
+ if (!validName(group) || !validName(field)) throw Object.assign(new Error('names: letters, digits, _ . - (max 64)'), { code: 'INVALID_NAME' });
248
+ const e = _data[group] && _data[group][field];
249
+ if (!e) return false;
250
+ if (!isSecret(e)) {
251
+ e.secret = true;
252
+ save();
253
+ }
254
+ return true;
255
+ }
256
+
257
+ /** Remove a field, or a whole group when `field` is omitted. Returns false when nothing was there. */
258
+ function remove(group, field) {
259
+ assertWritable();
260
+ if (!validName(group) || (field !== undefined && !validName(field))) throw Object.assign(new Error('names: letters, digits, _ . - (max 64)'), { code: 'INVALID_NAME' });
261
+ const g = _data[group];
262
+ if (!g) return false;
263
+ if (field === undefined) delete _data[group];
264
+ else {
265
+ if (!g[field]) return false;
266
+ delete g[field];
267
+ if (Object.keys(g).length === 0) delete _data[group];
268
+ }
269
+ save();
270
+ return true;
271
+ }
272
+
273
+ /** Replace every known secret value (6+ chars) in a log line with ***. */
274
+ function redact(str) {
275
+ if (_redactList.length === 0 || typeof str !== 'string') return str;
276
+ let out = str;
277
+ for (const v of _redactList) if (out.includes(v)) out = out.split(v).join('***');
278
+ return out;
279
+ }
280
+
281
+ /**
282
+ * CLI: she --secret-set group/field [--plain] (value on stdin) | --secret-delete group[/field] | --secret-list.
283
+ * Returns the exit code.
284
+ */
285
+ function cli(argv, io = { stdout: process.stdout, stderr: process.stderr, stdin: () => fs.readFileSync(0, 'utf8') }) {
286
+ const [cmd, arg, flag] = argv;
287
+ const st = load();
288
+ const fail = (m) => {
289
+ io.stderr.write('she: ' + m + '\n');
290
+ return 1;
291
+ };
292
+ try {
293
+ if (cmd === '--secret-list') {
294
+ if (st.status === 'locked' || st.status === 'error') return fail('secrets store is locked: ' + st.error);
295
+ for (const g of list())
296
+ for (const f of g.fields) io.stdout.write(`${g.name}/${f.name}\t${new Date(f.changed).toISOString()}\t${f.secret ? 'secret' : 'plain: ' + f.value}\n`);
297
+ return 0;
298
+ }
299
+ if (cmd === '--secret-set') {
300
+ const parts = arg && splitPath(arg);
301
+ if (!parts || parts.length !== 2 || (flag !== undefined && flag !== '--plain')) return fail('usage: she --secret-set <group>/<field> [--plain] (value on stdin)');
302
+ const value = String(io.stdin()).replace(/\r?\n$/, '');
303
+ const r = set(parts[0], parts[1], value, { secret: flag !== '--plain' });
304
+ io.stdout.write(`set ${r.group}/${r.field} (${r.secret ? 'secret' : 'plain'})\n`);
305
+ return 0;
306
+ }
307
+ if (cmd === '--secret-delete') {
308
+ const parts = arg && splitPath(arg);
309
+ if (!parts) return fail('usage: she --secret-delete <group>[/<field>]');
310
+ if (!remove(parts[0], parts[1])) return fail(`no such secret: ${arg}`);
311
+ io.stdout.write(`deleted ${arg}\n`);
312
+ return 0;
313
+ }
314
+ return fail('unknown secrets command: ' + cmd);
315
+ } catch (err) {
316
+ return fail(err.message);
317
+ }
318
+ }
319
+
320
+ module.exports = { init, load, status, list, get, has, set, mark, remove, redact, cli, validName, NAME_RE, MAX_VALUE, MIN_REDACT };
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Adapter catalog (roadmap I7): the npm packages of the publishers the user trusts whose latest
5
+ * version depends on mqtt-interfaces-core. No keyword, no registration — depending on the core
6
+ * is the membership criterion.
7
+ *
8
+ * 1. registry search `maintainer:<publisher>` (official, one request per publisher)
9
+ * 2. abbreviated packument per hit (`application/vnd.npm.install-v1+json`, small) → keep the
10
+ * package when `versions[dist-tags.latest].dependencies` has mqtt-interfaces-core
11
+ * 3. full packument for the members only → description, homepage, repository, `mqttInterfaces`
12
+ *
13
+ * Cached for 24 h with the last good result kept on failure; `refresh` forces a new sweep.
14
+ * Best effort throughout: a publisher that fails is reported in `errors`, the rest still lists.
15
+ */
16
+
17
+ const CORE = 'mqtt-interfaces-core';
18
+ const REGISTRY = 'https://registry.npmjs.org';
19
+ const TTL = 24 * 60 * 60 * 1000;
20
+
21
+ let _fetch = (...args) => fetch(...args);
22
+ function setFetch(fn) {
23
+ _fetch = fn;
24
+ }
25
+
26
+ /** @type {{key: string, ts: number, result: object, promise?: Promise<object>} | null} */
27
+ let _cache = null;
28
+ function clearCache() {
29
+ _cache = null;
30
+ }
31
+
32
+ function validPublisher(p) {
33
+ return typeof p === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/i.test(p);
34
+ }
35
+
36
+ async function getJson(url, headers = {}) {
37
+ const res = await _fetch(url, { headers: { accept: 'application/json', ...headers }, signal: AbortSignal.timeout(15000) });
38
+ if (!res.ok) throw new Error(`${res.status} ${url}`);
39
+ return res.json();
40
+ }
41
+
42
+ /** Package names a publisher maintains (registry search; 250 is the API's page maximum). */
43
+ async function packagesOf(publisher) {
44
+ const data = await getJson(`${REGISTRY}/-/v1/search?text=${encodeURIComponent('maintainer:' + publisher)}&size=250`);
45
+ return (data.objects || [])
46
+ .map((o) => o.package)
47
+ .filter((p) => p && typeof p.name === 'string' && p.name !== CORE && !p.name.startsWith('@'))
48
+ .map((p) => ({ name: p.name, version: p.version, description: p.description || '', date: p.date || null, publisher: (p.publisher && p.publisher.username) || publisher }));
49
+ }
50
+
51
+ /** Does the latest version depend on the core? Returns the range or null. */
52
+ async function coreDependency(name) {
53
+ const data = await getJson(`${REGISTRY}/${name}`, { accept: 'application/vnd.npm.install-v1+json' });
54
+ const latest = data['dist-tags'] && data['dist-tags'].latest;
55
+ const v = latest && data.versions && data.versions[latest];
56
+ if (!v) return null;
57
+ const range = (v.dependencies && v.dependencies[CORE]) || null;
58
+ return range ? { range, version: latest } : null;
59
+ }
60
+
61
+ /** Details of a member from the full packument. */
62
+ async function details(name, version) {
63
+ const data = await getJson(`${REGISTRY}/${name}`);
64
+ const v = (data.versions && data.versions[version]) || {};
65
+ const repo = typeof data.repository === 'object' && data.repository ? data.repository.url : data.repository;
66
+ return {
67
+ description: v.description || data.description || '',
68
+ homepage: v.homepage || data.homepage || null,
69
+ repository: typeof repo === 'string' ? repo.replace(/^git\+/, '').replace(/\.git$/, '') : null,
70
+ mqttInterfaces: v.mqttInterfaces && typeof v.mqttInterfaces === 'object' ? v.mqttInterfaces : null,
71
+ deprecated: typeof v.deprecated === 'string' ? v.deprecated : null,
72
+ maintainers: Array.isArray(data.maintainers) ? data.maintainers.map((m) => m.name).filter(Boolean) : [],
73
+ published: data.time && data.time[version] ? data.time[version] : null,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Sweep the registry for the trusted publishers' adapters.
79
+ * @param {string[]} publishers
80
+ * @returns {Promise<{packages: object[], publishers: string[], errors: object[], fetchedAt: number}>}
81
+ */
82
+ async function sweep(publishers, now) {
83
+ const errors = [];
84
+ const seen = new Map();
85
+ for (const publisher of publishers) {
86
+ let list;
87
+ try {
88
+ list = await packagesOf(publisher);
89
+ } catch (err) {
90
+ errors.push({ publisher, error: err.message });
91
+ continue;
92
+ }
93
+ for (const p of list) {
94
+ if (seen.has(p.name)) continue;
95
+ try {
96
+ const dep = await coreDependency(p.name);
97
+ if (!dep) continue;
98
+ const d = await details(p.name, dep.version);
99
+ if (d.deprecated) continue;
100
+ seen.set(p.name, { name: p.name, version: dep.version, coreRange: dep.range, publisher, ...d });
101
+ } catch (err) {
102
+ errors.push({ package: p.name, error: err.message });
103
+ }
104
+ }
105
+ }
106
+ const packages = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
107
+ return { packages, publishers, errors, fetchedAt: now };
108
+ }
109
+
110
+ /**
111
+ * The catalog for the given trusted publishers (cached).
112
+ * @param {string[]} publishers
113
+ * @param {{force?: boolean, now?: number}} [opts]
114
+ */
115
+ async function catalog(publishers, opts = {}) {
116
+ const list = [...new Set((publishers || []).filter(validPublisher).map((p) => p.toLowerCase()))];
117
+ const now = opts.now ?? Date.now();
118
+ const key = list.join(',');
119
+ if (list.length === 0) return { packages: [], publishers: [], errors: [], fetchedAt: now, cached: false };
120
+ if (_cache && _cache.key === key && !opts.force) {
121
+ if (_cache.promise) return { ...(await _cache.promise), cached: true };
122
+ if (now - _cache.ts < TTL) return { ..._cache.result, cached: true };
123
+ }
124
+ const previous = _cache && _cache.key === key ? _cache.result : null;
125
+ const promise = sweep(list, now).then((r) => {
126
+ // keep the last good list when the whole sweep produced nothing but errors
127
+ if (r.packages.length === 0 && r.errors.length > 0 && previous && previous.packages.length > 0) {
128
+ return { ...previous, errors: r.errors, stale: true };
129
+ }
130
+ return r;
131
+ });
132
+ _cache = { key, ts: now, result: previous, promise };
133
+ try {
134
+ const result = await promise;
135
+ _cache = { key, ts: now, result };
136
+ return { ...result, cached: false };
137
+ } catch (err) {
138
+ _cache = previous ? { key, ts: now, result: previous } : null;
139
+ if (previous) return { ...previous, errors: [{ error: err.message }], stale: true, cached: true };
140
+ throw err;
141
+ }
142
+ }
143
+
144
+ module.exports = { catalog, sweep, packagesOf, coreDependency, details, validPublisher, setFetch, clearCache, CORE, REGISTRY };