wiim2mqtt 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/upnp.js ADDED
@@ -0,0 +1,535 @@
1
+ /**
2
+ * Minimal UPnP client for one MediaRenderer: device description, SOAP actions and GENA
3
+ * event subscriptions with a single NOTIFY listener.
4
+ *
5
+ * Subscription lifecycle (ROADMAP W-9):
6
+ * - SUBSCRIBE with TIMEOUT Second-1800, renew at timeout - 60 s.
7
+ * - Any renewal failure (WiiM answers 412 after a while) → 'lost', UNSUBSCRIBE best effort,
8
+ * fresh SUBSCRIBE with backoff 2 s … 60 s (cap), up to 5 min while the callback is unreachable.
9
+ * - The initial NOTIFY must arrive within `initialNotifyTimeout` (30 s), otherwise the callback
10
+ * is assumed unreachable → 'lost' with code ENOTIFY and the same retry loop.
11
+ */
12
+
13
+ import {EventEmitter} from 'node:events';
14
+ import dgram from 'node:dgram';
15
+ import http from 'node:http';
16
+ import {URL} from 'node:url';
17
+ import {XMLParser} from 'fast-xml-parser';
18
+ import {parsePropertySet, parseLastChange} from './didl.js';
19
+
20
+ export const SERVICES = {
21
+ AVTransport: 'urn:schemas-upnp-org:service:AVTransport:1',
22
+ RenderingControl: 'urn:schemas-upnp-org:service:RenderingControl:1',
23
+ PlayQueue: 'urn:schemas-wiimu-com:service:PlayQueue:1',
24
+ };
25
+
26
+ export class UpnpError extends Error {
27
+ constructor(code, message, extra = {}) {
28
+ super(message);
29
+ this.name = 'UpnpError';
30
+ this.code = code;
31
+ Object.assign(this, extra);
32
+ }
33
+ }
34
+
35
+ const xml = new XMLParser({
36
+ ignoreAttributes: false,
37
+ attributeNamePrefix: '',
38
+ ignoreDeclaration: true,
39
+ removeNSPrefix: true,
40
+ parseTagValue: false,
41
+ trimValues: true,
42
+ isArray: (name) => name === 'service' || name === 'device',
43
+ });
44
+
45
+ function escapeXml(text) {
46
+ return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
47
+ }
48
+
49
+ /** Find the address this host uses to reach `host` (no packet is sent). */
50
+ export function detectLocalAddress(host, port = 1900) {
51
+ return new Promise((resolve, reject) => {
52
+ const socket = dgram.createSocket('udp4');
53
+ socket.once('error', (err) => {
54
+ socket.close();
55
+ reject(err);
56
+ });
57
+ socket.connect(port, host, () => {
58
+ try {
59
+ const {address} = socket.address();
60
+ socket.close();
61
+ resolve(address);
62
+ } catch (err) {
63
+ socket.close();
64
+ reject(err);
65
+ }
66
+ });
67
+ });
68
+ }
69
+
70
+ /** Parse description.xml → {udn, friendlyName, model, manufacturer, services: {AVTransport: {controlURL, eventSubURL, serviceType}}} */
71
+ export function parseDescription(body, baseUrl) {
72
+ const doc = xml.parse(body);
73
+ const root = doc.root;
74
+ const device = root && Array.isArray(root.device) ? root.device[0] : root && root.device;
75
+ if (!device) {
76
+ throw new UpnpError('EDESCRIBE', 'description.xml without <device>');
77
+ }
78
+ const base = (root && root.URLBase) || baseUrl;
79
+ const services = {};
80
+ const list = device.serviceList && device.serviceList.service;
81
+ for (const service of Array.isArray(list) ? list : list ? [list] : []) {
82
+ const name = Object.keys(SERVICES).find((key) => SERVICES[key] === service.serviceType);
83
+ const key = name || String(service.serviceType).split(':').slice(-2, -1)[0];
84
+ services[key] = {
85
+ serviceType: service.serviceType,
86
+ serviceId: service.serviceId,
87
+ controlURL: new URL(service.controlURL, base).toString(),
88
+ eventSubURL: service.eventSubURL ? new URL(service.eventSubURL, base).toString() : undefined,
89
+ };
90
+ }
91
+ return {
92
+ udn: device.UDN,
93
+ friendlyName: device.friendlyName,
94
+ model: device.modelName,
95
+ manufacturer: device.manufacturer,
96
+ deviceType: device.deviceType,
97
+ services,
98
+ };
99
+ }
100
+
101
+ /** Parse a SOAP response body → the action's out arguments as {Name: value} */
102
+ export function parseSoapResponse(body, action) {
103
+ const doc = xml.parse(body);
104
+ const env = doc.Envelope;
105
+ const bodyEl = env && env.Body;
106
+ if (!bodyEl) {
107
+ throw new UpnpError('ESOAP', `${action}: not a soap envelope`);
108
+ }
109
+ if (bodyEl.Fault) {
110
+ const detail = bodyEl.Fault.detail && bodyEl.Fault.detail.UPnPError;
111
+ throw new UpnpError(
112
+ 'ESOAP',
113
+ `${action}: upnp error ${detail ? detail.errorCode : ''} ${detail ? detail.errorDescription : bodyEl.Fault.faultstring || ''}`.trim(),
114
+ {
115
+ errorCode: detail ? Number(detail.errorCode) : undefined,
116
+ errorDescription: detail ? detail.errorDescription : undefined,
117
+ },
118
+ );
119
+ }
120
+ const response = bodyEl[`${action}Response`];
121
+ if (!response || typeof response !== 'object') {
122
+ return {};
123
+ }
124
+ const result = {};
125
+ for (const [key, value] of Object.entries(response)) {
126
+ if (key === 'xmlns:u' || key === 'xmlns') {
127
+ continue;
128
+ }
129
+ result[key] =
130
+ value === null || value === undefined
131
+ ? ''
132
+ : typeof value === 'object'
133
+ ? (value['#text'] ?? '')
134
+ : String(value);
135
+ }
136
+ return result;
137
+ }
138
+
139
+ function httpRequest(url, {method = 'GET', headers = {}, body, timeout = 10000} = {}) {
140
+ return new Promise((resolve, reject) => {
141
+ const req = http.request(url, {method, headers, timeout}, (res) => {
142
+ const chunks = [];
143
+ res.on('data', (c) => chunks.push(c));
144
+ res.on('end', () =>
145
+ resolve({
146
+ statusCode: res.statusCode,
147
+ headers: res.headers,
148
+ body: Buffer.concat(chunks).toString('utf8'),
149
+ }),
150
+ );
151
+ res.on('error', reject);
152
+ });
153
+ req.on('timeout', () => req.destroy(new UpnpError('ETIMEDOUT', `${method} ${url}: timeout`)));
154
+ req.on('error', (err) =>
155
+ reject(
156
+ err instanceof UpnpError
157
+ ? err
158
+ : new UpnpError('ECONNFAILED', `${method} ${url}: ${err.message}`, {cause: err}),
159
+ ),
160
+ );
161
+ if (body !== undefined) {
162
+ req.write(body);
163
+ }
164
+ req.end();
165
+ });
166
+ }
167
+
168
+ export class UpnpClient extends EventEmitter {
169
+ /**
170
+ * @param {object} options
171
+ * @param {string} options.host device address
172
+ * @param {number} [options.port] description port (49152)
173
+ * @param {string} [options.callbackHost] address the device uses to reach us (default: detected)
174
+ * @param {number} [options.callbackPort] listener port (0 = ephemeral)
175
+ * @param {number} [options.subscriptionTimeout] requested GENA timeout in seconds
176
+ * @param {number} [options.initialNotifyTimeout] ms to wait for the initial NOTIFY
177
+ * @param {object} [options.log]
178
+ * @param {object} [options.timers] {setTimeout, clearTimeout} for tests
179
+ */
180
+ constructor({
181
+ host,
182
+ port = 49152,
183
+ callbackHost,
184
+ callbackPort = 0,
185
+ subscriptionTimeout = 1800,
186
+ initialNotifyTimeout = 30000,
187
+ log,
188
+ timers,
189
+ }) {
190
+ super();
191
+ this.host = host;
192
+ this.port = port;
193
+ this.baseUrl = `http://${host}:${port}`;
194
+ this.callbackHost = callbackHost;
195
+ this.callbackPort = callbackPort;
196
+ this.subscriptionTimeout = subscriptionTimeout;
197
+ this.initialNotifyTimeout = initialNotifyTimeout;
198
+ this.log = log || {debug() {}, info() {}, warn() {}};
199
+ this.timers = timers || {setTimeout, clearTimeout};
200
+ this.description = undefined;
201
+ this.server = undefined;
202
+ this.subscriptions = new Map(); // service name → {sid, timeout, timer, backoff, initialTimer, stopped}
203
+ this.stopped = false;
204
+ }
205
+
206
+ async describe() {
207
+ const res = await httpRequest(`${this.baseUrl}/description.xml`);
208
+ if (res.statusCode !== 200) {
209
+ throw new UpnpError('EDESCRIBE', `description.xml: http ${res.statusCode}`);
210
+ }
211
+ this.description = parseDescription(res.body, this.baseUrl);
212
+ return this.description;
213
+ }
214
+
215
+ service(name) {
216
+ const service = this.description && this.description.services[name];
217
+ if (!service) {
218
+ throw new UpnpError('ENOSERVICE', `service ${name} not offered by the device`);
219
+ }
220
+ return service;
221
+ }
222
+
223
+ /** Invoke a SOAP action. */
224
+ async action(serviceName, actionName, args = {}) {
225
+ const service = this.service(serviceName);
226
+ const argXml = Object.entries(args)
227
+ .map(([k, v]) => `<${k}>${escapeXml(v)}</${k}>`)
228
+ .join('');
229
+ const body =
230
+ '<?xml version="1.0" encoding="utf-8"?>' +
231
+ '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' +
232
+ `<s:Body><u:${actionName} xmlns:u="${service.serviceType}">${argXml}</u:${actionName}></s:Body></s:Envelope>`;
233
+ this.log.debug('upnp >', serviceName, actionName, args);
234
+ const res = await httpRequest(service.controlURL, {
235
+ method: 'POST',
236
+ headers: {
237
+ 'Content-Type': 'text/xml; charset="utf-8"',
238
+ SOAPACTION: `"${service.serviceType}#${actionName}"`,
239
+ 'Content-Length': Buffer.byteLength(body),
240
+ },
241
+ body,
242
+ });
243
+ this.log.debug(
244
+ 'upnp <',
245
+ serviceName,
246
+ actionName,
247
+ res.statusCode,
248
+ res.body.length > 500 ? res.body.slice(0, 500) + '…' : res.body,
249
+ );
250
+ const result = parseSoapResponse(res.body, actionName);
251
+ if (res.statusCode !== 200) {
252
+ throw new UpnpError('ESOAP', `${actionName}: http ${res.statusCode}`, {statusCode: res.statusCode});
253
+ }
254
+ return result;
255
+ }
256
+
257
+ /** AVTransport GetInfoEx (LinkPlay extension): everything about the current track in one call. */
258
+ async getInfoEx() {
259
+ const r = await this.action('AVTransport', 'GetInfoEx', {InstanceID: 0});
260
+ return {
261
+ transportState: r.CurrentTransportState,
262
+ status: r.CurrentTransportStatus,
263
+ track: r.Track,
264
+ duration: r.TrackDuration,
265
+ position: r.RelTime,
266
+ metadata: r.TrackMetaData,
267
+ uri: r.TrackURI,
268
+ loopMode: r.LoopMode,
269
+ playMedium: r.PlayMedium,
270
+ trackSource: r.TrackSource,
271
+ volume: r.CurrentVolume,
272
+ mute: r.CurrentMute,
273
+ slaveList: r.SlaveList,
274
+ raw: r,
275
+ };
276
+ }
277
+
278
+ /** PlayQueue GetKeyMapping → {maxNumber, presets: [{number, name, source, pic}]} */
279
+ async getKeyMapping() {
280
+ const r = await this.action('PlayQueue', 'GetKeyMapping', {});
281
+ const queue = r.QueueContext || '';
282
+ const doc = xml.parse(queue);
283
+ const context = doc.KeyList || doc.QueueContext || doc;
284
+ const presets = [];
285
+ let maxNumber = 0;
286
+ const walk = (obj) => {
287
+ if (!obj || typeof obj !== 'object') {
288
+ return;
289
+ }
290
+ for (const [key, value] of Object.entries(obj)) {
291
+ if (/^MaxNumber$/i.test(key)) {
292
+ maxNumber = Number(value) || maxNumber;
293
+ } else if (/^Key\d+$/i.test(key) && value && typeof value === 'object') {
294
+ presets.push({
295
+ number: Number(key.replace(/^Key/i, '')),
296
+ name: String(value.Name || '').replace(/_#~.*$/, ''),
297
+ source: String(value.Source || ''),
298
+ pic: String(value.PicUrl || ''),
299
+ url: String(value.Url || ''),
300
+ });
301
+ } else if (value && typeof value === 'object') {
302
+ walk(value);
303
+ }
304
+ }
305
+ };
306
+ walk(context);
307
+ presets.sort((a, b) => a.number - b.number);
308
+ return {maxNumber: maxNumber || presets.length, presets: presets.filter((p) => p.name || p.url)};
309
+ }
310
+
311
+ /*
312
+ * GENA
313
+ */
314
+
315
+ async startListener() {
316
+ if (this.server) {
317
+ return;
318
+ }
319
+ if (!this.callbackHost) {
320
+ this.callbackHost = await detectLocalAddress(this.host);
321
+ }
322
+ this.server = http.createServer((req, res) => this.handleNotify(req, res));
323
+ await new Promise((resolve, reject) => {
324
+ this.server.once('error', reject);
325
+ this.server.listen(this.callbackPort, '0.0.0.0', () => {
326
+ this.server.off('error', reject);
327
+ this.callbackPort = this.server.address().port;
328
+ resolve();
329
+ });
330
+ });
331
+ this.log.debug('upnp listener', this.callbackUrl('<service>'));
332
+ }
333
+
334
+ callbackUrl(serviceName) {
335
+ return `http://${this.callbackHost}:${this.callbackPort}/notify/${serviceName}`;
336
+ }
337
+
338
+ handleNotify(req, res) {
339
+ const chunks = [];
340
+ req.on('data', (c) => chunks.push(c));
341
+ req.on('end', () => {
342
+ const body = Buffer.concat(chunks).toString('utf8');
343
+ const sid = req.headers.sid;
344
+ const serviceName = String(req.url || '').split('/')[2];
345
+ const sub = this.subscriptions.get(serviceName);
346
+ if (req.method !== 'NOTIFY' || !sub || sub.sid !== sid) {
347
+ this.log.debug('upnp < notify ignored', req.method, req.url, sid);
348
+ res.writeHead(412);
349
+ res.end();
350
+ return;
351
+ }
352
+ res.writeHead(200);
353
+ res.end();
354
+ this.log.debug('upnp < notify', serviceName, body.length > 800 ? body.slice(0, 800) + '…' : body);
355
+ if (sub.initialTimer) {
356
+ this.timers.clearTimeout(sub.initialTimer);
357
+ sub.initialTimer = undefined;
358
+ }
359
+ sub.backoff = 0;
360
+ let variables;
361
+ try {
362
+ variables = parsePropertySet(body);
363
+ if (variables.LastChange !== undefined) {
364
+ variables = {...variables, ...parseLastChange(variables.LastChange)};
365
+ delete variables.LastChange;
366
+ }
367
+ } catch (err) {
368
+ this.log.warn('upnp notify parse error', err.message);
369
+ return;
370
+ }
371
+ this.emit('event', {service: serviceName, variables, raw: body});
372
+ });
373
+ }
374
+
375
+ /** Subscribe to the given services (names from SERVICES) and keep the subscriptions alive. */
376
+ async subscribe(serviceNames) {
377
+ await this.startListener();
378
+ this.stopped = false;
379
+ for (const name of serviceNames) {
380
+ if (!this.description.services[name] || !this.description.services[name].eventSubURL) {
381
+ this.log.debug('upnp service', name, 'not evented on this device');
382
+ continue;
383
+ }
384
+ if (!this.subscriptions.has(name)) {
385
+ this.subscriptions.set(name, {sid: undefined, timer: undefined, initialTimer: undefined, backoff: 0});
386
+ }
387
+ await this.doSubscribe(name);
388
+ }
389
+ }
390
+
391
+ async doSubscribe(name) {
392
+ const sub = this.subscriptions.get(name);
393
+ if (!sub || this.stopped) {
394
+ return;
395
+ }
396
+ const service = this.service(name);
397
+ try {
398
+ const res = await httpRequest(service.eventSubURL, {
399
+ method: 'SUBSCRIBE',
400
+ headers: {
401
+ CALLBACK: `<${this.callbackUrl(name)}>`,
402
+ NT: 'upnp:event',
403
+ TIMEOUT: `Second-${this.subscriptionTimeout}`,
404
+ },
405
+ });
406
+ if (res.statusCode !== 200 || !res.headers.sid) {
407
+ throw new UpnpError('ESUBSCRIBE', `${name}: subscribe answered http ${res.statusCode}`, {
408
+ statusCode: res.statusCode,
409
+ });
410
+ }
411
+ sub.sid = res.headers.sid;
412
+ sub.timeout = parseTimeout(res.headers.timeout, this.subscriptionTimeout);
413
+ this.scheduleRenew(name);
414
+ sub.initialTimer = this.timers.setTimeout(() => {
415
+ sub.initialTimer = undefined;
416
+ this.onLost(
417
+ name,
418
+ new UpnpError(
419
+ 'ENOTIFY',
420
+ `${name}: no initial event within ${this.initialNotifyTimeout / 1000} s (callback ${this.callbackUrl(name)} unreachable from the device?)`,
421
+ ),
422
+ );
423
+ }, this.initialNotifyTimeout);
424
+ this.log.debug('upnp subscribed', name, sub.sid, 'timeout', sub.timeout);
425
+ this.emit('subscribed', {service: name, sid: sub.sid, timeout: sub.timeout});
426
+ } catch (err) {
427
+ this.onLost(name, err);
428
+ }
429
+ }
430
+
431
+ scheduleRenew(name) {
432
+ const sub = this.subscriptions.get(name);
433
+ if (!sub) {
434
+ return;
435
+ }
436
+ const delay = Math.max(30, sub.timeout - 60) * 1000;
437
+ this.timers.clearTimeout(sub.timer);
438
+ sub.timer = this.timers.setTimeout(() => this.renew(name), delay);
439
+ }
440
+
441
+ async renew(name) {
442
+ const sub = this.subscriptions.get(name);
443
+ if (!sub || !sub.sid || this.stopped) {
444
+ return;
445
+ }
446
+ const service = this.service(name);
447
+ try {
448
+ const res = await httpRequest(service.eventSubURL, {
449
+ method: 'SUBSCRIBE',
450
+ headers: {SID: sub.sid, TIMEOUT: `Second-${this.subscriptionTimeout}`},
451
+ });
452
+ if (res.statusCode !== 200) {
453
+ throw new UpnpError('ERENEW', `${name}: renew answered http ${res.statusCode}`, {
454
+ statusCode: res.statusCode,
455
+ });
456
+ }
457
+ sub.timeout = parseTimeout(res.headers.timeout, this.subscriptionTimeout);
458
+ this.scheduleRenew(name);
459
+ this.log.debug('upnp renewed', name, sub.sid, 'timeout', sub.timeout);
460
+ this.emit('renewed', {service: name, sid: sub.sid, timeout: sub.timeout});
461
+ } catch (err) {
462
+ this.onLost(name, err);
463
+ }
464
+ }
465
+
466
+ /** A subscription failed (subscribe, renew or no initial event): clean up and retry with backoff. */
467
+ onLost(name, error) {
468
+ const sub = this.subscriptions.get(name);
469
+ if (!sub || this.stopped) {
470
+ return;
471
+ }
472
+ this.timers.clearTimeout(sub.timer);
473
+ this.timers.clearTimeout(sub.initialTimer);
474
+ sub.timer = undefined;
475
+ sub.initialTimer = undefined;
476
+ const oldSid = sub.sid;
477
+ sub.sid = undefined;
478
+ if (oldSid) {
479
+ this.doUnsubscribe(name, oldSid).catch(() => {});
480
+ }
481
+ sub.backoff = Math.min(sub.backoff ? sub.backoff * 2 : 2, error && error.code === 'ENOTIFY' ? 300 : 60);
482
+ this.emit('lost', {service: name, error, retryIn: sub.backoff});
483
+ sub.timer = this.timers.setTimeout(() => this.doSubscribe(name), sub.backoff * 1000);
484
+ }
485
+
486
+ /** Drop every subscription and subscribe afresh (after missed events). */
487
+ resubscribe(reason) {
488
+ for (const name of this.subscriptions.keys()) {
489
+ const sub = this.subscriptions.get(name);
490
+ sub.backoff = 0;
491
+ this.onLost(name, new UpnpError('ERESUBSCRIBE', `${name}: ${reason}`));
492
+ }
493
+ }
494
+
495
+ async doUnsubscribe(name, sid) {
496
+ const service = this.service(name);
497
+ await httpRequest(service.eventSubURL, {method: 'UNSUBSCRIBE', headers: {SID: sid}, timeout: 2000});
498
+ }
499
+
500
+ /** Stop renewals, unsubscribe everything (best effort) and close the listener. */
501
+ async stop() {
502
+ this.stopped = true;
503
+ const pending = [];
504
+ for (const [name, sub] of this.subscriptions) {
505
+ this.timers.clearTimeout(sub.timer);
506
+ this.timers.clearTimeout(sub.initialTimer);
507
+ if (sub.sid) {
508
+ pending.push(this.doUnsubscribe(name, sub.sid).catch(() => {}));
509
+ sub.sid = undefined;
510
+ }
511
+ }
512
+ await Promise.all(pending);
513
+ this.subscriptions.clear();
514
+ if (this.server) {
515
+ await new Promise((resolve) => this.server.close(() => resolve()));
516
+ this.server.closeAllConnections?.();
517
+ this.server = undefined;
518
+ }
519
+ }
520
+
521
+ /** true while at least one subscription has a SID */
522
+ get subscribed() {
523
+ for (const sub of this.subscriptions.values()) {
524
+ if (sub.sid) {
525
+ return true;
526
+ }
527
+ }
528
+ return false;
529
+ }
530
+ }
531
+
532
+ function parseTimeout(header, fallback) {
533
+ const match = /Second-(\d+)/i.exec(String(header || ''));
534
+ return match ? Number(match[1]) : fallback;
535
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "wiim2mqtt",
3
+ "version": "0.1.0",
4
+ "description": "Interface between WiiM (LinkPlay) audio streamers and MQTT, with Home Assistant discovery",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "preferGlobal": true,
8
+ "bin": {
9
+ "wiim2mqtt": "index.js"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "config.js",
14
+ "lib/"
15
+ ],
16
+ "engines": {
17
+ "node": "^20.19 || ^22.12 || >=24"
18
+ },
19
+ "scripts": {
20
+ "start": "node index.js",
21
+ "lint": "eslint . && prettier --check .",
22
+ "format": "prettier --write . && eslint --fix .",
23
+ "test": "node --test",
24
+ "test:watch": "node --test --watch"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/hobbyquaker/wiim2mqtt.git"
29
+ },
30
+ "keywords": [
31
+ "mqtt",
32
+ "smarthome",
33
+ "mqtt-smarthome",
34
+ "home-automation",
35
+ "home-assistant",
36
+ "wiim",
37
+ "linkplay",
38
+ "upnp",
39
+ "audio",
40
+ "streamer"
41
+ ],
42
+ "author": "Sebastian Raff <hobbyquaker@gmail.com> (https://hobbyquaker.github.io)",
43
+ "license": "MIT",
44
+ "bugs": {
45
+ "url": "https://github.com/hobbyquaker/wiim2mqtt/issues"
46
+ },
47
+ "homepage": "https://github.com/hobbyquaker/wiim2mqtt",
48
+ "dependencies": {
49
+ "fast-xml-parser": "^4.5.0",
50
+ "mqtt": "^5.5.0",
51
+ "yargs": "^17.7.2"
52
+ },
53
+ "devDependencies": {
54
+ "@eslint/js": "^9.0.0",
55
+ "eslint": "^9.0.0",
56
+ "eslint-config-prettier": "^10.0.0",
57
+ "globals": "^16.0.0",
58
+ "prettier": "^3.0.0"
59
+ }
60
+ }