tau-all-agent 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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/extensions/answer.ts +601 -0
  4. package/extensions/branch-term.ts +405 -0
  5. package/extensions/btw.ts +444 -0
  6. package/extensions/ghostty.ts +301 -0
  7. package/extensions/git-diff-stats.ts +277 -0
  8. package/extensions/git-pr-status.ts +286 -0
  9. package/extensions/insights.ts +2367 -0
  10. package/extensions/interlude.ts +144 -0
  11. package/extensions/loop.ts +529 -0
  12. package/extensions/memory.ts +1889 -0
  13. package/extensions/notify.ts +161 -0
  14. package/extensions/openai-fast.ts +227 -0
  15. package/extensions/openai-verbosity.ts +223 -0
  16. package/extensions/review.ts +4347 -0
  17. package/extensions/sandbox/index.ts +2578 -0
  18. package/extensions/telegram/README.md +81 -0
  19. package/extensions/telegram/daemon.mjs +1942 -0
  20. package/extensions/telegram/index.ts +1073 -0
  21. package/extensions/telegram/message-text.d.mts +1 -0
  22. package/extensions/telegram/message-text.mjs +16 -0
  23. package/extensions/usage/anthropic.ts +198 -0
  24. package/extensions/usage/github-copilot.ts +204 -0
  25. package/extensions/usage/google-gemini-cli.ts +232 -0
  26. package/extensions/usage/index.ts +2310 -0
  27. package/extensions/usage/minimax.ts +208 -0
  28. package/extensions/usage/openai-codex.ts +180 -0
  29. package/extensions/usage/openrouter.ts +168 -0
  30. package/extensions/usage/providers.ts +26 -0
  31. package/extensions/usage/shared.ts +156 -0
  32. package/extensions/usage/types.ts +53 -0
  33. package/extensions/usage/zai.ts +186 -0
  34. package/extensions/websearch/README.md +68 -0
  35. package/extensions/websearch/browser/chromium.ts +330 -0
  36. package/extensions/websearch/browser/discovery.ts +75 -0
  37. package/extensions/websearch/browser/firefox.ts +150 -0
  38. package/extensions/websearch/browser/sqlite.ts +38 -0
  39. package/extensions/websearch/config.ts +76 -0
  40. package/extensions/websearch/index.ts +312 -0
  41. package/extensions/websearch/normalize.ts +155 -0
  42. package/extensions/websearch/providers/anthropic.pi.ts +139 -0
  43. package/extensions/websearch/providers/gemini.browser.ts +185 -0
  44. package/extensions/websearch/providers/gemini.pi.ts +108 -0
  45. package/extensions/websearch/providers/openai-codex.browser.ts +77 -0
  46. package/extensions/websearch/providers/openai-codex.pi.ts +49 -0
  47. package/extensions/websearch/providers/openai-codex.shared.ts +123 -0
  48. package/extensions/websearch/providers/pi-model.shared.ts +86 -0
  49. package/extensions/websearch/providers/search-prompt.shared.ts +17 -0
  50. package/extensions/websearch/providers/shared.ts +76 -0
  51. package/extensions/websearch/types.ts +52 -0
  52. package/extensions/worktree.ts +2223 -0
  53. package/package.json +76 -0
  54. package/skills/browser-tools/SKILL.md +253 -0
  55. package/skills/browser-tools/scripts/browser-content.js +100 -0
  56. package/skills/browser-tools/scripts/browser-cookies.js +33 -0
  57. package/skills/browser-tools/scripts/browser-dismiss-cookies.js +455 -0
  58. package/skills/browser-tools/scripts/browser-eval.js +75 -0
  59. package/skills/browser-tools/scripts/browser-logs-tail.js +89 -0
  60. package/skills/browser-tools/scripts/browser-nav.js +28 -0
  61. package/skills/browser-tools/scripts/browser-net-summary.js +115 -0
  62. package/skills/browser-tools/scripts/browser-pick.js +133 -0
  63. package/skills/browser-tools/scripts/browser-screenshot.js +18 -0
  64. package/skills/browser-tools/scripts/browser-start.js +281 -0
  65. package/skills/browser-tools/scripts/browser-watch.js +321 -0
  66. package/skills/browser-tools/scripts/utils.js +60 -0
  67. package/skills/git-clean-history/SKILL.md +45 -0
  68. package/skills/git-commit/SKILL.md +55 -0
  69. package/skills/homeassistant-ops/SKILL.md +184 -0
  70. package/skills/homeassistant-ops/references/api.md +64 -0
  71. package/skills/homeassistant-ops/references/id_conventions.md +71 -0
  72. package/skills/homeassistant-ops/references/playbook.md +150 -0
  73. package/skills/homeassistant-ops/scripts/ha_ops.js +2942 -0
  74. package/skills/openscad/SKILL.md +149 -0
  75. package/skills/openscad/examples/parametric_box.scad +92 -0
  76. package/skills/openscad/examples/phone_stand.scad +95 -0
  77. package/skills/openscad/scripts/common.sh +49 -0
  78. package/skills/openscad/scripts/export-stl.sh +56 -0
  79. package/skills/openscad/scripts/extract-params.sh +147 -0
  80. package/skills/openscad/scripts/multi-preview.sh +68 -0
  81. package/skills/openscad/scripts/preview.sh +74 -0
  82. package/skills/openscad/scripts/render-with-params.sh +91 -0
  83. package/skills/openscad/scripts/validate.sh +46 -0
  84. package/skills/oracle/SKILL.md +64 -0
  85. package/skills/oracle/scripts/oracle +249 -0
  86. package/skills/oracle/scripts/oracle-bundle +227 -0
  87. package/skills/sentry/SKILL.md +198 -0
  88. package/skills/sentry/lib/auth.js +99 -0
  89. package/skills/sentry/scripts/fetch-event.js +325 -0
  90. package/skills/sentry/scripts/fetch-issue.js +368 -0
  91. package/skills/sentry/scripts/list-issues.js +245 -0
  92. package/skills/sentry/scripts/search-events.js +291 -0
  93. package/skills/sentry/scripts/search-logs.js +234 -0
  94. package/skills/update-changelog/SKILL.md +135 -0
  95. package/skills/web-design/SKILL.md +117 -0
@@ -0,0 +1,2942 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Home Assistant ops toolkit (single CLI).
5
+ *
6
+ * Commands:
7
+ * - cleanup
8
+ * - snapshot
9
+ * - rollback
10
+ * - find-references
11
+ * - traces
12
+ * - tail-events
13
+ * - name-review-from-backup
14
+ *
15
+ * Uses only Node built-ins (requires Node 22+ for fetch + WebSocket).
16
+ */
17
+
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import * as readlinePromises from "node:readline/promises";
21
+ import * as util from "node:util";
22
+
23
+ const { parseArgs } = util;
24
+
25
+ // -------------------------
26
+ // utils
27
+ // -------------------------
28
+
29
+ function utcTimestamp() {
30
+ // YYYYMMDDTHHMMSSZ
31
+ const d = new Date();
32
+ const pad = (n) => String(n).padStart(2, "0");
33
+ return (
34
+ String(d.getUTCFullYear()) +
35
+ pad(d.getUTCMonth() + 1) +
36
+ pad(d.getUTCDate()) +
37
+ "T" +
38
+ pad(d.getUTCHours()) +
39
+ pad(d.getUTCMinutes()) +
40
+ pad(d.getUTCSeconds()) +
41
+ "Z"
42
+ );
43
+ }
44
+
45
+ function utcNowIso() {
46
+ return new Date().toISOString();
47
+ }
48
+
49
+ function deriveWsUrl(httpUrl) {
50
+ const base = String(httpUrl || "")
51
+ .trim()
52
+ .replace(/\/+$/, "");
53
+ if (base.startsWith("https://")) {
54
+ return "wss://" + base.slice("https://".length) + "/api/websocket";
55
+ }
56
+ if (base.startsWith("http://")) {
57
+ return "ws://" + base.slice("http://".length) + "/api/websocket";
58
+ }
59
+ throw new Error(`Unsupported base URL: ${base}`);
60
+ }
61
+
62
+ function slugifyForEntityId(value) {
63
+ let v = String(value || "")
64
+ .trim()
65
+ .toLowerCase();
66
+ v = v.replace(/[^a-z0-9_ ]+/g, "");
67
+ v = v.replace(/\s+/g, "_");
68
+ v = v.replace(/_+/g, "_");
69
+ v = v.replace(/^_+|_+$/g, "");
70
+ return v;
71
+ }
72
+
73
+ function normalizeAreaName(value) {
74
+ return slugifyForEntityId(value).replace(/_/g, "");
75
+ }
76
+
77
+ function getStr(obj, key) {
78
+ if (!obj || typeof obj !== "object") return null;
79
+ const v = obj[key];
80
+ return typeof v === "string" ? v : null;
81
+ }
82
+
83
+ function getDict(obj, key) {
84
+ if (!obj || typeof obj !== "object") return null;
85
+ const v = obj[key];
86
+ return v && typeof v === "object" && !Array.isArray(v) ? v : null;
87
+ }
88
+
89
+ function sortListByKey(items, keyFields) {
90
+ if (!Array.isArray(items)) return items;
91
+ const keyOf = (item) => {
92
+ if (!item || typeof item !== "object" || Array.isArray(item)) return [1, String(item)];
93
+ for (const f of keyFields) {
94
+ if (typeof item[f] === "string") return [0, item[f]];
95
+ }
96
+ try {
97
+ return [0, stableStringify(item)];
98
+ } catch {
99
+ return [0, String(item)];
100
+ }
101
+ };
102
+ return [...items].sort((a, b) => {
103
+ const [pa, ka] = keyOf(a);
104
+ const [pb, kb] = keyOf(b);
105
+ if (pa !== pb) return pa - pb;
106
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
107
+ });
108
+ }
109
+
110
+ function stableStringify(value, indent = 0) {
111
+ const seen = new WeakSet();
112
+ const sortRec = (v) => {
113
+ if (!v || typeof v !== "object") return v;
114
+ if (seen.has(v)) return v;
115
+ seen.add(v);
116
+ if (Array.isArray(v)) return v.map(sortRec);
117
+ const out = {};
118
+ for (const k of Object.keys(v).sort()) out[k] = sortRec(v[k]);
119
+ return out;
120
+ };
121
+ return JSON.stringify(sortRec(value), null, indent);
122
+ }
123
+
124
+ function resolveEntityArea(entry, deviceIdToAreaId) {
125
+ const areaId = entry?.area_id;
126
+ if (areaId !== undefined && areaId !== null) {
127
+ return typeof areaId === "string" ? areaId : null;
128
+ }
129
+ const deviceId = entry?.device_id;
130
+ if (typeof deviceId === "string") {
131
+ const inherited = deviceIdToAreaId[deviceId];
132
+ return typeof inherited === "string" ? inherited : (inherited ?? null);
133
+ }
134
+ return null;
135
+ }
136
+
137
+ class ProgressReporter {
138
+ constructor(total, prefix = "", width = 30) {
139
+ this.total = total;
140
+ this.current = 0;
141
+ this.prefix = prefix;
142
+ this.width = width;
143
+ this.lastLen = 0;
144
+ }
145
+
146
+ update(n = 1) {
147
+ this.current += n;
148
+ this.#print();
149
+ }
150
+
151
+ finish() {
152
+ this.current = this.total;
153
+ this.#print();
154
+ process.stdout.write("\n");
155
+ }
156
+
157
+ #print() {
158
+ if (!this.total) return;
159
+ const pct = this.current / this.total;
160
+ const filled = Math.floor(this.width * pct);
161
+ let bar = "=".repeat(filled) + ">" + " ".repeat(Math.max(0, this.width - filled - 1));
162
+ if (filled >= this.width) bar = "=".repeat(this.width);
163
+ const output = `\r${this.prefix}${this.current}/${this.total} [${bar}] ${Math.round(pct * 100)}%`;
164
+ const padding = " ".repeat(Math.max(0, this.lastLen - output.length));
165
+ process.stdout.write(output + padding);
166
+ this.lastLen = output.length;
167
+ }
168
+ }
169
+
170
+ // -------------------------
171
+ // HA clients
172
+ // -------------------------
173
+
174
+ function getHaUrl() {
175
+ const url = String(process.env.HA_URL || "").trim();
176
+ if (!url) throw new Error("Missing HA_URL environment variable");
177
+ return url.replace(/\/+$/, "");
178
+ }
179
+
180
+ function getHaToken() {
181
+ const token = String(process.env.HA_TOKEN || "").trim();
182
+ if (!token) throw new Error("Missing HA_TOKEN environment variable");
183
+ return token;
184
+ }
185
+
186
+ class HARest {
187
+ constructor({ baseUrl, token, timeoutMs } = {}) {
188
+ this.baseUrl = (baseUrl || getHaUrl()).replace(/\/+$/, "");
189
+ this.token = token || getHaToken();
190
+ this.timeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000;
191
+ }
192
+
193
+ async request(method, apiPath, { jsonBody, expectedStatuses } = {}) {
194
+ const url = this.baseUrl + apiPath;
195
+ const expected = Array.isArray(expectedStatuses) ? expectedStatuses : [200];
196
+ const controller = new AbortController();
197
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
198
+
199
+ let res;
200
+ try {
201
+ const options = {
202
+ method,
203
+ headers: {
204
+ Authorization: `Bearer ${this.token}`,
205
+ },
206
+ signal: controller.signal,
207
+ };
208
+
209
+ if (jsonBody !== undefined) {
210
+ const normalizedMethod = method.toUpperCase();
211
+ if (normalizedMethod === "GET" || normalizedMethod === "HEAD") {
212
+ throw new Error(`${method} ${apiPath} cannot include a request body`);
213
+ }
214
+
215
+ options.headers["Content-Type"] = "application/json";
216
+ options.body = JSON.stringify(jsonBody);
217
+ }
218
+
219
+ res = await fetch(url, options);
220
+ } finally {
221
+ clearTimeout(timeout);
222
+ }
223
+
224
+ if (!expected.includes(res.status)) {
225
+ const text = await res.text().catch(() => "");
226
+ throw new Error(`${method} ${apiPath} -> ${res.status}\n${text}`);
227
+ }
228
+ return res;
229
+ }
230
+
231
+ async getJson(apiPath, { expectedStatuses } = {}) {
232
+ const res = await this.request("GET", apiPath, { expectedStatuses });
233
+ const text = await res.text();
234
+ if (!text) return null;
235
+ try {
236
+ return JSON.parse(text);
237
+ } catch {
238
+ if (res.status !== 200) return null;
239
+ throw new Error(`GET ${apiPath} returned non-JSON content`);
240
+ }
241
+ }
242
+
243
+ async postJson(apiPath, payload, { expectedStatuses } = {}) {
244
+ const res = await this.request("POST", apiPath, {
245
+ jsonBody: payload,
246
+ expectedStatuses,
247
+ });
248
+ const text = await res.text();
249
+ if (!text) return null;
250
+ return JSON.parse(text);
251
+ }
252
+ }
253
+
254
+ class HAWebSocket {
255
+ constructor({ wsUrl, token } = {}) {
256
+ const httpUrl = getHaUrl();
257
+ this.wsUrl = wsUrl || deriveWsUrl(httpUrl);
258
+ this.token = token || getHaToken();
259
+ this.nextMsgId = 1;
260
+ this.ws = null;
261
+ this.pending = new Map(); // id -> {resolve,reject}
262
+ this.ignoredCallIds = new Set();
263
+ this.queue = [];
264
+ this.queueWaiters = [];
265
+ }
266
+
267
+ async connect() {
268
+ this.ws = new WebSocket(this.wsUrl);
269
+
270
+ this.ws.addEventListener("message", (ev) => {
271
+ let msg;
272
+ try {
273
+ msg = JSON.parse(String(ev.data));
274
+ } catch {
275
+ return;
276
+ }
277
+ if (!msg || typeof msg !== "object") return;
278
+ const id = msg.id;
279
+ if (typeof id === "number" && this.pending.has(id)) {
280
+ const { resolve, reject } = this.pending.get(id);
281
+ this.pending.delete(id);
282
+ if (msg.success === false) {
283
+ reject(new Error(`WS call failed: ${msg.type}: ${JSON.stringify(msg)}`));
284
+ } else {
285
+ resolve(msg.result);
286
+ }
287
+ return;
288
+ }
289
+ if (typeof id === "number" && this.ignoredCallIds.has(id)) {
290
+ this.ignoredCallIds.delete(id);
291
+ return;
292
+ }
293
+ this.#enqueue(msg);
294
+ });
295
+
296
+ await new Promise((resolve, reject) => {
297
+ this.ws.addEventListener("open", () => resolve());
298
+ this.ws.addEventListener("error", (e) => reject(e));
299
+ });
300
+
301
+ const first = await this.recvJson({ timeoutMs: 10_000 });
302
+ if (first?.type !== "auth_required") {
303
+ throw new Error(`Unexpected initial WS message: ${JSON.stringify(first)}`);
304
+ }
305
+
306
+ this.sendJson({ type: "auth", access_token: this.token });
307
+
308
+ const second = await this.recvJson({ timeoutMs: 10_000 });
309
+ if (second?.type !== "auth_ok") {
310
+ throw new Error(`WS auth failed: ${JSON.stringify(second)}`);
311
+ }
312
+ return this;
313
+ }
314
+
315
+ async close() {
316
+ if (!this.ws) return;
317
+ try {
318
+ this.ws.close();
319
+ } catch {
320
+ // ignore
321
+ }
322
+ this.ws = null;
323
+ }
324
+
325
+ nextId() {
326
+ const id = this.nextMsgId;
327
+ this.nextMsgId += 1;
328
+ return id;
329
+ }
330
+
331
+ sendJson(payload) {
332
+ if (!this.ws) throw new Error("WebSocket not connected");
333
+ this.ws.send(JSON.stringify(payload));
334
+ }
335
+
336
+ async call(messageType, payload = {}, { timeoutMs = 30_000 } = {}) {
337
+ if (!this.ws) throw new Error("WebSocket not connected");
338
+ const id = this.nextId();
339
+ const msg = { id, type: messageType, ...payload };
340
+ const promise = new Promise((resolve, reject) => {
341
+ let timeout = null;
342
+ const wrapped = {
343
+ resolve: (value) => {
344
+ if (timeout) clearTimeout(timeout);
345
+ resolve(value);
346
+ },
347
+ reject: (err) => {
348
+ if (timeout) clearTimeout(timeout);
349
+ reject(err);
350
+ },
351
+ };
352
+ this.pending.set(id, wrapped);
353
+ if (timeoutMs) {
354
+ timeout = setTimeout(() => {
355
+ if (!this.pending.has(id)) return;
356
+ this.pending.delete(id);
357
+ this.ignoredCallIds.add(id);
358
+ wrapped.reject(new Error(`WS call timeout after ${timeoutMs}ms: ${messageType}`));
359
+ }, timeoutMs);
360
+ }
361
+ });
362
+ this.ws.send(JSON.stringify(msg));
363
+ return await promise;
364
+ }
365
+
366
+ #enqueue(msg) {
367
+ const waiter = this.queueWaiters.shift();
368
+ if (waiter) {
369
+ waiter.resolve(msg);
370
+ return;
371
+ }
372
+ this.queue.push(msg);
373
+ }
374
+
375
+ async recvJson({ timeoutMs } = {}) {
376
+ if (this.queue.length) return this.queue.shift();
377
+ const promise = new Promise((resolve, reject) => {
378
+ this.queueWaiters.push({ resolve, reject });
379
+ });
380
+ if (!timeoutMs) return await promise;
381
+ return await Promise.race([
382
+ promise,
383
+ new Promise((_, reject) => setTimeout(() => reject(new Error("WS recv timeout")), timeoutMs)),
384
+ ]);
385
+ }
386
+ }
387
+
388
+ // -------------------------
389
+ // CLI
390
+ // -------------------------
391
+
392
+ function printTopHelp() {
393
+ console.log("Usage: node scripts/ha_ops.js <command> [options]");
394
+ console.log("");
395
+ console.log("Commands:");
396
+ console.log(" cleanup Bulk cleanup (dry-run by default)");
397
+ console.log(" snapshot Create a JSON snapshot for diff/rollback");
398
+ console.log(" rollback Rollback registry changes from a snapshot");
399
+ console.log(" update-groups Update group helper memberships from a rename map");
400
+ console.log(" find-references Find entity_id/string references");
401
+ console.log(" traces List/view automation or script traces");
402
+ console.log(" tail-events Tail Home Assistant events over WebSocket");
403
+ console.log(" name-review-from-backup Offline naming analysis from a backup");
404
+ console.log("");
405
+ console.log("Environment:");
406
+ console.log(" HA_URL Home Assistant base URL (e.g. http://host:8123)");
407
+ console.log(" HA_TOKEN Long-lived access token");
408
+ }
409
+
410
+ function die(message, code = 2) {
411
+ process.stderr.write(String(message) + "\n");
412
+ process.exit(code);
413
+ }
414
+
415
+ async function run() {
416
+ const argv = process.argv.slice(2);
417
+ if (!argv.length || argv[0] === "-h" || argv[0] === "--help") {
418
+ printTopHelp();
419
+ return 0;
420
+ }
421
+
422
+ const command = argv[0];
423
+ const args = argv.slice(1);
424
+
425
+ try {
426
+ switch (command) {
427
+ case "cleanup":
428
+ return await cmdCleanup(args);
429
+ case "snapshot":
430
+ return await cmdSnapshot(args);
431
+ case "rollback":
432
+ return await cmdRollback(args);
433
+ case "find-references":
434
+ return await cmdFindReferences(args);
435
+ case "traces":
436
+ return await cmdTraces(args);
437
+ case "tail-events":
438
+ return await cmdTailEvents(args);
439
+ case "name-review-from-backup":
440
+ return await cmdNameReviewFromBackup(args);
441
+ case "update-groups":
442
+ return await cmdUpdateGroups(args);
443
+ default:
444
+ printTopHelp();
445
+ die(`Unknown command: ${command}`);
446
+ return 2;
447
+ }
448
+ } catch (e) {
449
+ die(e?.message || String(e), 1);
450
+ return 1;
451
+ }
452
+ }
453
+
454
+ // -------------------------
455
+ // cleanup
456
+ // -------------------------
457
+
458
+ const AVAILABLE_CLEANUP_STEPS = [
459
+ "rename-switch-suffix",
460
+ "create-groups",
461
+ "prefix-lights-cove",
462
+ "prefix-generic",
463
+ ];
464
+
465
+ function printCleanupHelp() {
466
+ console.log("Usage: node scripts/ha_ops.js cleanup [options]");
467
+ console.log("");
468
+ console.log("Options:");
469
+ console.log(
470
+ " --steps <csv> Steps to run (default: rename-switch-suffix,create-groups,prefix-lights-cove)",
471
+ );
472
+ console.log(" --pattern <cat:regex> Pattern for prefix-generic (repeatable)");
473
+ console.log(" --blueprint-pattern <re> Regex to match blueprint paths for create-groups step");
474
+ console.log(" --log <path> Log path (default: ha_cleanup_<timestamp>.md)");
475
+ console.log(" --apply Apply changes (required to mutate HA)");
476
+ console.log(" --json Output proposed changes as JSON (implies dry-run)");
477
+ console.log(" -h, --help Show this help");
478
+ console.log("");
479
+ console.log("Available steps:");
480
+ for (const s of AVAILABLE_CLEANUP_STEPS) console.log(` - ${s}`);
481
+ }
482
+
483
+ async function cmdCleanup(argv) {
484
+ const { values } = parseArgs({
485
+ args: argv,
486
+ allowPositionals: false,
487
+ options: {
488
+ help: { type: "boolean", short: "h" },
489
+ steps: {
490
+ type: "string",
491
+ default: "rename-switch-suffix,create-groups,prefix-lights-cove",
492
+ },
493
+ pattern: { type: "string", multiple: true, default: [] },
494
+ "blueprint-pattern": { type: "string", default: "" },
495
+ log: { type: "string", default: "" },
496
+ apply: { type: "boolean", default: false },
497
+ json: { type: "boolean", default: false },
498
+ },
499
+ });
500
+
501
+ if (values.help) {
502
+ printCleanupHelp();
503
+ return 0;
504
+ }
505
+
506
+ const steps = String(values.steps || "")
507
+ .split(",")
508
+ .map((s) => s.trim())
509
+ .filter(Boolean);
510
+ for (const step of steps) {
511
+ if (!AVAILABLE_CLEANUP_STEPS.includes(step)) {
512
+ die(`Unknown step: ${step}\nAvailable: ${AVAILABLE_CLEANUP_STEPS.join(", ")}`, 2);
513
+ }
514
+ }
515
+
516
+ if (values.apply && values.json) {
517
+ die("Cannot combine --apply and --json", 2);
518
+ }
519
+
520
+ const dryRun = !values.apply;
521
+
522
+ const genericPatterns = [];
523
+ for (const p of values.pattern || []) {
524
+ const raw = String(p);
525
+ const idx = raw.indexOf(":");
526
+ if (idx === -1) {
527
+ die(`Invalid pattern format: ${raw} (expected 'category:regex')`, 2);
528
+ }
529
+ const category = raw.slice(0, idx);
530
+ const re = raw.slice(idx + 1);
531
+ try {
532
+ genericPatterns.push([category, new RegExp(re)]);
533
+ } catch (e) {
534
+ die(`Invalid regex in pattern ${raw}: ${e?.message || e}`, 2);
535
+ }
536
+ }
537
+ if (steps.includes("prefix-generic") && !genericPatterns.length) {
538
+ die("prefix-generic step requires at least one --pattern", 2);
539
+ }
540
+
541
+ let rest;
542
+ try {
543
+ rest = new HARest();
544
+ } catch (e) {
545
+ die(e?.message || String(e), 2);
546
+ }
547
+
548
+ const timestamp = utcTimestamp();
549
+ const logPath = values.log ? String(values.log) : `ha_cleanup_${timestamp}.md`;
550
+
551
+ const states = await rest.getJson("/api/states");
552
+ if (!Array.isArray(states)) {
553
+ die("Failed to load states from Home Assistant", 1);
554
+ }
555
+
556
+ const ws = await new HAWebSocket().connect();
557
+ try {
558
+ const ctx = await cleanupLoadContext(rest, ws, states, dryRun);
559
+ for (const step of steps) {
560
+ if (step === "rename-switch-suffix") {
561
+ await cleanupStepRenameSwitchSuffix(ctx);
562
+ } else if (step === "create-groups") {
563
+ await cleanupStepCreateGroups(ctx, values["blueprint-pattern"] || "");
564
+ } else if (step === "prefix-lights-cove") {
565
+ await cleanupStepPrefixLightsCove(ctx);
566
+ } else if (step === "prefix-generic") {
567
+ await cleanupStepPrefixGeneric(ctx, genericPatterns);
568
+ }
569
+ }
570
+
571
+ if (values.json) {
572
+ console.log(
573
+ JSON.stringify(
574
+ {
575
+ dry_run: true,
576
+ steps,
577
+ renames: ctx.renames.map((r) => ({
578
+ entity_id: r.entityId,
579
+ old: r.oldName,
580
+ new: r.newName,
581
+ category: r.category,
582
+ })),
583
+ group_changes: ctx.groupChanges.map((g) => ({
584
+ automation: g.automationEntityId,
585
+ group_id: g.groupEntityId,
586
+ name: g.groupName,
587
+ switches: g.switches,
588
+ action: g.action,
589
+ })),
590
+ warnings: ctx.warnings,
591
+ },
592
+ null,
593
+ 2,
594
+ ),
595
+ );
596
+ return 0;
597
+ }
598
+
599
+ cleanupWriteLog(logPath, rest.baseUrl, timestamp, ctx, steps, dryRun);
600
+ console.log(`Log: ${logPath}`);
601
+ console.log(`Renames: ${ctx.renames.length}`);
602
+ console.log(`Group changes: ${ctx.groupChanges.length}`);
603
+ if (ctx.warnings.length) console.log(`Warnings: ${ctx.warnings.length}`);
604
+ return 0;
605
+ } finally {
606
+ await ws.close();
607
+ }
608
+ }
609
+
610
+ async function cleanupLoadContext(rest, ws, states, dryRun) {
611
+ const ctx = {
612
+ rest,
613
+ ws,
614
+ states,
615
+ areaIdToName: {},
616
+ normalizedAreaToId: {},
617
+ deviceIdToAreaId: {},
618
+ entityEntryById: {},
619
+ renames: [],
620
+ groupChanges: [],
621
+ warnings: [],
622
+ dryRun,
623
+ };
624
+
625
+ try {
626
+ const areas = await ws.call("config/area_registry/list");
627
+ if (Array.isArray(areas)) {
628
+ for (const area of areas) {
629
+ if (!area || typeof area !== "object") continue;
630
+ const areaId = getStr(area, "area_id");
631
+ const name = getStr(area, "name");
632
+ if (areaId && name) {
633
+ ctx.areaIdToName[areaId] = name;
634
+ const normalized = normalizeAreaName(name);
635
+ if (normalized) ctx.normalizedAreaToId[normalized] = areaId;
636
+ }
637
+ }
638
+ }
639
+ } catch (e) {
640
+ ctx.warnings.push(`Could not load area registry: ${e?.message || e}`);
641
+ }
642
+
643
+ try {
644
+ const devices = await ws.call("config/device_registry/list");
645
+ if (Array.isArray(devices)) {
646
+ for (const device of devices) {
647
+ if (!device || typeof device !== "object") continue;
648
+ const id = getStr(device, "id");
649
+ if (!id) continue;
650
+ ctx.deviceIdToAreaId[id] = device.area_id ?? null;
651
+ }
652
+ }
653
+ } catch (e) {
654
+ ctx.warnings.push(`Could not load device registry: ${e?.message || e}`);
655
+ }
656
+
657
+ try {
658
+ const entities = await ws.call("config/entity_registry/list");
659
+ if (Array.isArray(entities)) {
660
+ for (const ent of entities) {
661
+ if (!ent || typeof ent !== "object") continue;
662
+ const eid = getStr(ent, "entity_id");
663
+ if (eid) ctx.entityEntryById[eid] = ent;
664
+ }
665
+ }
666
+ } catch (e) {
667
+ ctx.warnings.push(`Could not load entity registry: ${e?.message || e}`);
668
+ }
669
+
670
+ ctx.resolveAreaName = (entityId) => {
671
+ const entry = ctx.entityEntryById[entityId];
672
+ if (!entry) return null;
673
+ const areaId = resolveEntityArea(entry, ctx.deviceIdToAreaId);
674
+ if (areaId && ctx.areaIdToName[areaId]) return ctx.areaIdToName[areaId];
675
+ return null;
676
+ };
677
+
678
+ ctx.resolveAreaId = (entityId) => {
679
+ const entry = ctx.entityEntryById[entityId];
680
+ if (!entry) return null;
681
+ return resolveEntityArea(entry, ctx.deviceIdToAreaId);
682
+ };
683
+
684
+ return ctx;
685
+ }
686
+
687
+ async function updateGroupMembers(rest, configEntryId, members) {
688
+ const optionsFlow = await rest.postJson("/api/config/config_entries/options/flow", {
689
+ handler: configEntryId,
690
+ });
691
+ const flowId = getStr(optionsFlow, "flow_id");
692
+ if (!flowId) {
693
+ throw new Error(`No flow_id in response: ${JSON.stringify(optionsFlow).slice(0, 200)}`);
694
+ }
695
+ const schema = optionsFlow.data_schema || [];
696
+ const hideMembersField = schema.find((f) => f.name === "hide_members");
697
+ const allField = schema.find((f) => f.name === "all");
698
+ const hideMembers = hideMembersField?.description?.suggested_value ?? true;
699
+ const all = allField?.description?.suggested_value ?? false;
700
+ await rest.postJson(`/api/config/config_entries/options/flow/${flowId}`, {
701
+ entities: members,
702
+ hide_members: hideMembers,
703
+ all,
704
+ });
705
+ }
706
+
707
+ async function cleanupRenameEntity(ctx, entityId, oldName, newName, category) {
708
+ if (ctx.dryRun) {
709
+ ctx.renames.push({ entityId, oldName, newName, category });
710
+ return true;
711
+ }
712
+ try {
713
+ await ctx.ws.call("config/entity_registry/update", {
714
+ entity_id: entityId,
715
+ name: newName,
716
+ });
717
+ ctx.renames.push({ entityId, oldName, newName, category });
718
+ return true;
719
+ } catch (e) {
720
+ ctx.warnings.push(`Failed to rename ${entityId}: ${e?.message || e}`);
721
+ return false;
722
+ }
723
+ }
724
+
725
+ async function cleanupStepRenameSwitchSuffix(ctx) {
726
+ const re = /^Lights(?: .+)? Switch$/;
727
+ const candidates = [];
728
+ for (const st of ctx.states) {
729
+ if (!st || typeof st !== "object") continue;
730
+ const entityId = getStr(st, "entity_id");
731
+ if (!entityId || !entityId.startsWith("switch.")) continue;
732
+ const attrs = getDict(st, "attributes") || {};
733
+ const friendly = getStr(attrs, "friendly_name");
734
+ if (friendly && re.test(friendly)) candidates.push([entityId, friendly]);
735
+ }
736
+ if (!candidates.length) return;
737
+ const progress = new ProgressReporter(candidates.length, "Renaming switch suffixes: ");
738
+ for (const [entityId, friendly] of candidates) {
739
+ const newName = friendly.slice(0, -" Switch".length);
740
+ const entry = ctx.entityEntryById[entityId];
741
+ if (entry && entry.name === newName) {
742
+ progress.update();
743
+ continue;
744
+ }
745
+ await cleanupRenameEntity(ctx, entityId, friendly, newName, "switch-suffix");
746
+ progress.update();
747
+ }
748
+ progress.finish();
749
+ }
750
+
751
+ function parseSyncAlias(alias) {
752
+ const raw = String(alias || "").trim();
753
+ if (!raw.startsWith("Sync ")) return null;
754
+ const name = raw.slice("Sync ".length).trim();
755
+ const suffixToKind = {
756
+ " Light Switches": "Lights",
757
+ " Lights": "Lights",
758
+ " Cove Switches": "Cove",
759
+ " Cove": "Cove",
760
+ };
761
+ for (const [suffix, kind] of Object.entries(suffixToKind)) {
762
+ if (!name.endsWith(suffix)) continue;
763
+ const area = name.slice(0, -suffix.length).trim();
764
+ if (area) return [area, kind];
765
+ }
766
+ return null;
767
+ }
768
+
769
+ function pickMainSwitch(switches) {
770
+ if (!switches.length) return null;
771
+ const suffixMatch = (parent, child) => {
772
+ if (!child.startsWith(parent + "_")) return false;
773
+ const suffix = child.slice((parent + "_").length);
774
+ return /^\d+$/.test(suffix);
775
+ };
776
+ const candidates = switches.map((candidate) => {
777
+ const matchCount = switches.filter(
778
+ (other) => other !== candidate && suffixMatch(candidate, other),
779
+ ).length;
780
+ return [matchCount, candidate.length, candidate];
781
+ });
782
+ candidates.sort((a, b) => b[0] - a[0] || a[1] - b[1] || (a[2] < b[2] ? -1 : 1));
783
+ const [bestMatchCount, , best] = candidates[0];
784
+ if (bestMatchCount > 0) return best;
785
+ const nonNumber = switches.filter((s) => !/_\d+$/.test(s));
786
+ if (nonNumber.length === 1) return nonNumber[0];
787
+ return null;
788
+ }
789
+
790
+ function deriveGroupEntityId(switches, automationEntityId, targetAreaId, switchAreaId) {
791
+ const main = pickMainSwitch(switches);
792
+ if (main) return `${main}_group`;
793
+
794
+ const stripped = switches.map((s) => s.replace(/_\d+$/, ""));
795
+ if (
796
+ stripped.length &&
797
+ stripped.every((s) => s === stripped[0]) &&
798
+ switches.some((s) => /_\d+$/.test(s))
799
+ ) {
800
+ return `${stripped[0]}_group`;
801
+ }
802
+
803
+ if (targetAreaId) {
804
+ const inArea = [...switches].filter((s) => switchAreaId[s] === targetAreaId).sort();
805
+ if (inArea.length === 1) return `${inArea[0]}_group`;
806
+ }
807
+
808
+ let objectId = automationEntityId.split(".", 2)[1] || automationEntityId;
809
+ if (objectId.startsWith("sync_")) objectId = objectId.slice("sync_".length);
810
+ if (objectId.endsWith("_light_switches"))
811
+ objectId = objectId.slice(0, -"_light_switches".length) + "_lights";
812
+ else if (objectId.endsWith("_light_switch"))
813
+ objectId = objectId.slice(0, -"_light_switch".length) + "_lights";
814
+ else if (objectId.endsWith("_switches")) objectId = objectId.slice(0, -"_switches".length);
815
+ return `switch.${objectId}_group`;
816
+ }
817
+
818
+ async function cleanupStepCreateGroups(ctx, blueprintPattern) {
819
+ const bpRe = blueprintPattern ? new RegExp(String(blueprintPattern)) : /sync.*switch/i;
820
+
821
+ const candidates = [];
822
+ for (const st of ctx.states) {
823
+ if (!st || typeof st !== "object") continue;
824
+ const entityId = getStr(st, "entity_id");
825
+ if (!entityId || !entityId.startsWith("automation.")) continue;
826
+ const attrs = getDict(st, "attributes") || {};
827
+ const alias = getStr(attrs, "friendly_name");
828
+ const automationId = getStr(attrs, "id");
829
+ if (!entityId || !alias || !automationId) continue;
830
+ const parsed = parseSyncAlias(alias);
831
+ if (!parsed) continue;
832
+
833
+ let cfg;
834
+ try {
835
+ cfg = await ctx.rest.getJson(`/api/config/automation/config/${automationId}`);
836
+ } catch (e) {
837
+ ctx.warnings.push(`Failed to load config for ${entityId}: ${e?.message || e}`);
838
+ continue;
839
+ }
840
+ if (!cfg || typeof cfg !== "object") continue;
841
+ const useBlueprint = getDict(cfg, "use_blueprint");
842
+ if (!useBlueprint) continue;
843
+ const blueprintPath = getStr(useBlueprint, "path");
844
+ if (!blueprintPath || !bpRe.test(blueprintPath)) continue;
845
+
846
+ const inputCfg = getDict(useBlueprint, "input") || {};
847
+ let switches = inputCfg.switch || inputCfg.switches;
848
+ if (typeof switches === "string") switches = [switches];
849
+ if (!Array.isArray(switches) || switches.length < 2) continue;
850
+ switches = switches.filter((s) => typeof s === "string" && s.startsWith("switch."));
851
+ if (switches.length < 2) continue;
852
+
853
+ candidates.push([entityId, alias, parsed, switches]);
854
+ }
855
+
856
+ if (!candidates.length) return;
857
+
858
+ const knownEntityIds = new Set(Object.keys(ctx.entityEntryById));
859
+ const progress = new ProgressReporter(candidates.length, "Creating/updating groups: ");
860
+
861
+ for (const [automationEntityId, alias, parsed, switches] of candidates) {
862
+ const [areaName, kind] = parsed;
863
+
864
+ const switchAreaId = {};
865
+ for (const sw of switches) switchAreaId[sw] = ctx.resolveAreaId(sw);
866
+
867
+ const targetAreaId = ctx.normalizedAreaToId[normalizeAreaName(areaName)] || null;
868
+ const displayAreaName = targetAreaId ? ctx.areaIdToName[targetAreaId] || areaName : areaName;
869
+ const groupName = `${displayAreaName} ${kind}`;
870
+ const desiredGroupId = deriveGroupEntityId(
871
+ switches,
872
+ automationEntityId,
873
+ targetAreaId,
874
+ switchAreaId,
875
+ );
876
+
877
+ if (ctx.dryRun) {
878
+ ctx.groupChanges.push({
879
+ automationEntityId,
880
+ automationAlias: alias,
881
+ switches,
882
+ groupEntityId: desiredGroupId,
883
+ groupName,
884
+ areaId: targetAreaId,
885
+ action: "planned",
886
+ });
887
+ progress.update();
888
+ continue;
889
+ }
890
+
891
+ let action = null;
892
+
893
+ // Rename *_all -> *_group if it's a group helper.
894
+ const candidateAll = desiredGroupId.replace(/_group$/, "") + "_all";
895
+ if (!knownEntityIds.has(desiredGroupId) && knownEntityIds.has(candidateAll)) {
896
+ const existing = ctx.entityEntryById[candidateAll];
897
+ if (existing && typeof existing === "object" && existing.platform === "group") {
898
+ try {
899
+ const updated = await ctx.ws.call("config/entity_registry/update", {
900
+ entity_id: candidateAll,
901
+ new_entity_id: desiredGroupId,
902
+ });
903
+ knownEntityIds.delete(candidateAll);
904
+ knownEntityIds.add(desiredGroupId);
905
+ delete ctx.entityEntryById[candidateAll];
906
+ let nextEntry = updated && typeof updated === "object" ? updated : null;
907
+ if (!nextEntry) {
908
+ try {
909
+ nextEntry = await ctx.ws.call("config/entity_registry/get", {
910
+ entity_id: desiredGroupId,
911
+ });
912
+ } catch (e) {
913
+ ctx.warnings.push(
914
+ `Renamed ${candidateAll} -> ${desiredGroupId} but failed to fetch new entity registry entry: ${e?.message || e}`,
915
+ );
916
+ }
917
+ }
918
+ if (nextEntry && typeof nextEntry === "object") {
919
+ ctx.entityEntryById[desiredGroupId] = nextEntry;
920
+ }
921
+ action = "renamed";
922
+ } catch (e) {
923
+ ctx.warnings.push(`Failed to rename ${candidateAll}: ${e?.message || e}`);
924
+ }
925
+ }
926
+ }
927
+
928
+ if (knownEntityIds.has(desiredGroupId)) {
929
+ // Update existing group helper.
930
+ try {
931
+ const groupEntry = ctx.entityEntryById[desiredGroupId];
932
+ if (!groupEntry || typeof groupEntry !== "object" || groupEntry.platform !== "group") {
933
+ ctx.warnings.push(`${desiredGroupId} exists but is not a group helper`);
934
+ progress.update();
935
+ continue;
936
+ }
937
+ const configEntryId = getStr(groupEntry, "config_entry_id");
938
+ if (!configEntryId) {
939
+ ctx.warnings.push(`${desiredGroupId} missing config_entry_id`);
940
+ progress.update();
941
+ continue;
942
+ }
943
+
944
+ await updateGroupMembers(ctx.rest, configEntryId, switches);
945
+
946
+ const payload = { entity_id: desiredGroupId, name: groupName };
947
+ if (targetAreaId) payload.area_id = targetAreaId;
948
+ const updated = await ctx.ws.call("config/entity_registry/update", payload);
949
+ if (updated && typeof updated === "object") ctx.entityEntryById[desiredGroupId] = updated;
950
+ action = action || "updated";
951
+ } catch (e) {
952
+ ctx.warnings.push(`Failed to update group ${desiredGroupId}: ${e?.message || e}`);
953
+ progress.update();
954
+ continue;
955
+ }
956
+ } else {
957
+ // Create new group helper.
958
+ try {
959
+ const flowStart = await ctx.rest.postJson("/api/config/config_entries/flow", {
960
+ handler: "group",
961
+ });
962
+ const flowId = getStr(flowStart, "flow_id");
963
+ if (!flowId) throw new Error(`Unexpected flow start: ${JSON.stringify(flowStart)}`);
964
+
965
+ await ctx.rest.postJson(`/api/config/config_entries/flow/${flowId}`, {
966
+ next_step_id: "switch",
967
+ });
968
+
969
+ const objectId = desiredGroupId.split(".", 2)[1] || desiredGroupId;
970
+ await ctx.rest.postJson(`/api/config/config_entries/flow/${flowId}`, {
971
+ name: objectId,
972
+ entities: switches,
973
+ hide_members: true,
974
+ all: false,
975
+ });
976
+
977
+ const payload = { entity_id: desiredGroupId, name: groupName };
978
+ if (targetAreaId) payload.area_id = targetAreaId;
979
+ const updated = await ctx.ws.call("config/entity_registry/update", payload);
980
+ if (updated && typeof updated === "object") ctx.entityEntryById[desiredGroupId] = updated;
981
+ knownEntityIds.add(desiredGroupId);
982
+ action = "created";
983
+ } catch (e) {
984
+ ctx.warnings.push(`Failed to create group ${desiredGroupId}: ${e?.message || e}`);
985
+ progress.update();
986
+ continue;
987
+ }
988
+ }
989
+
990
+ ctx.groupChanges.push({
991
+ automationEntityId,
992
+ automationAlias: alias,
993
+ switches,
994
+ groupEntityId: desiredGroupId,
995
+ groupName,
996
+ areaId: targetAreaId,
997
+ action: action || "updated",
998
+ });
999
+ progress.update();
1000
+ }
1001
+
1002
+ progress.finish();
1003
+ }
1004
+
1005
+ async function cleanupStepPrefixLightsCove(ctx) {
1006
+ const re = /^(Lights|Cove)\b/;
1007
+ const candidates = [];
1008
+ for (const st of ctx.states) {
1009
+ if (!st || typeof st !== "object") continue;
1010
+ const entityId = getStr(st, "entity_id");
1011
+ if (!entityId) continue;
1012
+ if (!(entityId.startsWith("switch.") || entityId.startsWith("light."))) continue;
1013
+ const attrs = getDict(st, "attributes") || {};
1014
+ const friendly = getStr(attrs, "friendly_name");
1015
+ if (friendly && re.test(friendly)) candidates.push([entityId, friendly]);
1016
+ }
1017
+ if (!candidates.length) return;
1018
+ const progress = new ProgressReporter(candidates.length, "Prefixing Lights/Cove names: ");
1019
+ for (const [entityId, friendly] of candidates) {
1020
+ const areaName = ctx.resolveAreaName(entityId);
1021
+ if (!areaName) {
1022
+ ctx.warnings.push(`${entityId}: cannot resolve area for prefix`);
1023
+ progress.update();
1024
+ continue;
1025
+ }
1026
+ if (friendly.startsWith(areaName + " ")) {
1027
+ progress.update();
1028
+ continue;
1029
+ }
1030
+ const newName = `${areaName} ${friendly}`;
1031
+ await cleanupRenameEntity(ctx, entityId, friendly, newName, "lights-cove-prefix");
1032
+ progress.update();
1033
+ }
1034
+ progress.finish();
1035
+ }
1036
+
1037
+ async function cleanupStepPrefixGeneric(ctx, patterns) {
1038
+ const candidates = [];
1039
+ for (const st of ctx.states) {
1040
+ if (!st || typeof st !== "object") continue;
1041
+ const entityId = getStr(st, "entity_id");
1042
+ if (!entityId) continue;
1043
+ const attrs = getDict(st, "attributes") || {};
1044
+ const friendly = getStr(attrs, "friendly_name");
1045
+ if (!friendly) continue;
1046
+ for (const [category, re] of patterns) {
1047
+ const m = re.exec(friendly);
1048
+ if (m && m.index === 0) {
1049
+ candidates.push([entityId, friendly, category]);
1050
+ break;
1051
+ }
1052
+ }
1053
+ }
1054
+ if (!candidates.length) return;
1055
+ const progress = new ProgressReporter(candidates.length, "Prefixing generic patterns: ");
1056
+ for (const [entityId, friendly, category] of candidates) {
1057
+ const areaName = ctx.resolveAreaName(entityId);
1058
+ if (!areaName) {
1059
+ ctx.warnings.push(`${entityId}: cannot resolve area for ${category} prefix`);
1060
+ progress.update();
1061
+ continue;
1062
+ }
1063
+ if (friendly.startsWith(areaName + " ")) {
1064
+ progress.update();
1065
+ continue;
1066
+ }
1067
+ const newName = `${areaName} ${friendly}`;
1068
+ await cleanupRenameEntity(ctx, entityId, friendly, newName, category);
1069
+ progress.update();
1070
+ }
1071
+ progress.finish();
1072
+ }
1073
+
1074
+ function cleanupWriteLog(logPath, baseUrl, timestamp, ctx, steps, dryRun) {
1075
+ const lines = [
1076
+ `# Home Assistant cleanup run (${timestamp})`,
1077
+ "",
1078
+ `- Instance: \`${baseUrl}\``,
1079
+ `- Steps: ${steps.join(", ")}`,
1080
+ `- Mode: ${dryRun ? "dry-run" : "applied"}`,
1081
+ "",
1082
+ ];
1083
+
1084
+ const byCategory = new Map();
1085
+ for (const r of ctx.renames) {
1086
+ const list = byCategory.get(r.category) || [];
1087
+ list.push(r);
1088
+ byCategory.set(r.category, list);
1089
+ }
1090
+
1091
+ lines.push("## Entity Renames");
1092
+ if (byCategory.size) {
1093
+ const cats = [...byCategory.keys()].sort();
1094
+ for (const cat of cats) {
1095
+ lines.push("", `### ${cat}`);
1096
+ const rs = [...byCategory.get(cat)].sort((a, b) => (a.entityId < b.entityId ? -1 : 1));
1097
+ for (const r of rs)
1098
+ lines.push(
1099
+ `- \`${r.entityId}\`: ${JSON.stringify(r.oldName)} → ${JSON.stringify(r.newName)}`,
1100
+ );
1101
+ }
1102
+ } else {
1103
+ lines.push("- (no changes)");
1104
+ }
1105
+ lines.push("");
1106
+
1107
+ lines.push("## Group Changes");
1108
+ if (ctx.groupChanges.length) {
1109
+ for (const g of ctx.groupChanges) {
1110
+ lines.push(
1111
+ `- \`${g.automationEntityId}\` (${JSON.stringify(g.automationAlias)}): ` +
1112
+ `${g.action} \`${g.groupEntityId}\` name=${JSON.stringify(g.groupName)} members=${JSON.stringify(g.switches)}`,
1113
+ );
1114
+ }
1115
+ } else {
1116
+ lines.push("- (no changes)");
1117
+ }
1118
+ lines.push("");
1119
+
1120
+ lines.push("## Warnings");
1121
+ if (ctx.warnings.length) {
1122
+ for (const w of ctx.warnings) lines.push(`- ${w}`);
1123
+ } else {
1124
+ lines.push("- (none)");
1125
+ }
1126
+ lines.push("");
1127
+
1128
+ fs.writeFileSync(logPath, lines.join("\n") + "\n", "utf-8");
1129
+ }
1130
+
1131
+ // -------------------------
1132
+ // snapshot
1133
+ // -------------------------
1134
+
1135
+ function printSnapshotHelp() {
1136
+ console.log("Usage: node scripts/ha_ops.js snapshot [options]");
1137
+ console.log("");
1138
+ console.log("Options:");
1139
+ console.log(" --out <path> Output JSON path (default: ha_snapshot_<timestamp>.json)");
1140
+ console.log(" --indent <n> JSON indentation (default: 2)");
1141
+ console.log(" --include-states Include /api/states (noisy for diffs)");
1142
+ console.log(" --no-automations Skip automation config snapshots");
1143
+ console.log(" --no-scripts Skip script config snapshots");
1144
+ console.log(" --no-scenes Skip scene config snapshots");
1145
+ console.log(" --no-lovelace Skip Lovelace dashboard config snapshots");
1146
+ console.log(" -h, --help Show this help");
1147
+ }
1148
+
1149
+ async function cmdSnapshot(argv) {
1150
+ const { values } = parseArgs({
1151
+ args: argv,
1152
+ allowPositionals: false,
1153
+ options: {
1154
+ help: { type: "boolean", short: "h" },
1155
+ out: { type: "string", default: "" },
1156
+ indent: { type: "string", default: "2" },
1157
+ "include-states": { type: "boolean", default: false },
1158
+ "no-automations": { type: "boolean", default: false },
1159
+ "no-scripts": { type: "boolean", default: false },
1160
+ "no-scenes": { type: "boolean", default: false },
1161
+ "no-lovelace": { type: "boolean", default: false },
1162
+ },
1163
+ });
1164
+
1165
+ if (values.help) {
1166
+ printSnapshotHelp();
1167
+ return 0;
1168
+ }
1169
+
1170
+ let rest;
1171
+ try {
1172
+ rest = new HARest();
1173
+ } catch (e) {
1174
+ die(e?.message || String(e), 2);
1175
+ }
1176
+
1177
+ const timestamp = utcTimestamp();
1178
+ const outPath = values.out ? String(values.out) : `ha_snapshot_${timestamp}.json`;
1179
+ const indent = Number(values.indent);
1180
+ if (!Number.isFinite(indent) || indent < 0) die(`Invalid --indent: ${values.indent}`, 2);
1181
+
1182
+ const snapshot = {
1183
+ meta: {
1184
+ generated_at: timestamp,
1185
+ url: rest.baseUrl,
1186
+ },
1187
+ registries: {},
1188
+ configs: {},
1189
+ warnings: [],
1190
+ };
1191
+
1192
+ try {
1193
+ const cfg = await rest.getJson("/api/config");
1194
+ if (cfg && typeof cfg === "object") {
1195
+ snapshot.meta.ha_version = cfg.version;
1196
+ snapshot.meta.location_name = cfg.location_name;
1197
+ snapshot.meta.time_zone = cfg.time_zone;
1198
+ }
1199
+ } catch (e) {
1200
+ snapshot.warnings.push(`Failed to load /api/config: ${e?.message || e}`);
1201
+ }
1202
+
1203
+ let states = [];
1204
+ try {
1205
+ const data = await rest.getJson("/api/states");
1206
+ if (Array.isArray(data)) {
1207
+ states = data.filter((s) => s && typeof s === "object");
1208
+ if (values["include-states"]) snapshot.states = states;
1209
+ }
1210
+ } catch (e) {
1211
+ snapshot.warnings.push(`Failed to load /api/states: ${e?.message || e}`);
1212
+ }
1213
+
1214
+ const ws = await new HAWebSocket().connect();
1215
+ try {
1216
+ try {
1217
+ const areas = await ws.call("config/area_registry/list");
1218
+ snapshot.registries.areas = sortListByKey(areas, ["area_id", "name"]);
1219
+ } catch (e) {
1220
+ snapshot.warnings.push(`Failed to load area registry: ${e?.message || e}`);
1221
+ }
1222
+
1223
+ try {
1224
+ const devices = await ws.call("config/device_registry/list");
1225
+ snapshot.registries.devices = sortListByKey(devices, ["id"]);
1226
+ } catch (e) {
1227
+ snapshot.warnings.push(`Failed to load device registry: ${e?.message || e}`);
1228
+ }
1229
+
1230
+ try {
1231
+ const entities = await ws.call("config/entity_registry/list");
1232
+ snapshot.registries.entities = sortListByKey(entities, ["entity_id"]);
1233
+ } catch (e) {
1234
+ snapshot.warnings.push(`Failed to load entity registry: ${e?.message || e}`);
1235
+ }
1236
+ } finally {
1237
+ await ws.close();
1238
+ }
1239
+
1240
+ // Automations
1241
+ if (!values["no-automations"]) {
1242
+ const automations = {};
1243
+ for (const st of states) {
1244
+ const entityId = getStr(st, "entity_id");
1245
+ if (!entityId || !entityId.startsWith("automation.")) continue;
1246
+ const attrs = st.attributes && typeof st.attributes === "object" ? st.attributes : {};
1247
+ const automationId = getStr(attrs, "id");
1248
+ if (!automationId) continue;
1249
+ const cfg = await rest
1250
+ .getJson(`/api/config/automation/config/${automationId}`, {
1251
+ expectedStatuses: [200, 404],
1252
+ })
1253
+ .catch(() => null);
1254
+ if (cfg && typeof cfg === "object") automations[entityId] = { id: automationId, config: cfg };
1255
+ }
1256
+ snapshot.configs.automations = Object.fromEntries(
1257
+ Object.entries(automations).sort(([a], [b]) => (a < b ? -1 : 1)),
1258
+ );
1259
+ }
1260
+
1261
+ // Scripts
1262
+ if (!values["no-scripts"]) {
1263
+ const scripts = {};
1264
+ for (const st of states) {
1265
+ const entityId = getStr(st, "entity_id");
1266
+ if (!entityId || !entityId.startsWith("script.")) continue;
1267
+ const attrs = st.attributes && typeof st.attributes === "object" ? st.attributes : {};
1268
+ const scriptId = getStr(attrs, "id");
1269
+ if (!scriptId) continue;
1270
+ const cfg = await rest
1271
+ .getJson(`/api/config/script/config/${scriptId}`, {
1272
+ expectedStatuses: [200, 404],
1273
+ })
1274
+ .catch(() => null);
1275
+ if (cfg && typeof cfg === "object") scripts[entityId] = { id: scriptId, config: cfg };
1276
+ }
1277
+ snapshot.configs.scripts = Object.fromEntries(
1278
+ Object.entries(scripts).sort(([a], [b]) => (a < b ? -1 : 1)),
1279
+ );
1280
+ }
1281
+
1282
+ // Scenes
1283
+ if (!values["no-scenes"]) {
1284
+ const scenes = {};
1285
+ for (const st of states) {
1286
+ const entityId = getStr(st, "entity_id");
1287
+ if (!entityId || !entityId.startsWith("scene.")) continue;
1288
+ const attrs = st.attributes && typeof st.attributes === "object" ? st.attributes : {};
1289
+ const sceneId = getStr(attrs, "id");
1290
+ if (!sceneId) continue;
1291
+ const cfg = await rest
1292
+ .getJson(`/api/config/scene/config/${sceneId}`, {
1293
+ expectedStatuses: [200, 404],
1294
+ })
1295
+ .catch(() => null);
1296
+ if (cfg && typeof cfg === "object") scenes[entityId] = { id: sceneId, config: cfg };
1297
+ }
1298
+ snapshot.configs.scenes = Object.fromEntries(
1299
+ Object.entries(scenes).sort(([a], [b]) => (a < b ? -1 : 1)),
1300
+ );
1301
+ }
1302
+
1303
+ // Lovelace
1304
+ if (!values["no-lovelace"]) {
1305
+ const lovelace = {};
1306
+ const dashboards = await rest
1307
+ .getJson("/api/lovelace/dashboards", { expectedStatuses: [200, 404] })
1308
+ .catch(() => null);
1309
+ if (Array.isArray(dashboards)) {
1310
+ for (const d of dashboards) {
1311
+ if (!d || typeof d !== "object") continue;
1312
+ const dashId = getStr(d, "id");
1313
+ if (!dashId) continue;
1314
+ const cfg = await rest
1315
+ .getJson(`/api/lovelace/config/${dashId}`, {
1316
+ expectedStatuses: [200, 404],
1317
+ })
1318
+ .catch(() => null);
1319
+ if (cfg && typeof cfg === "object") lovelace[dashId] = { dashboard: d, config: cfg };
1320
+ }
1321
+ } else {
1322
+ const cfg = await rest
1323
+ .getJson("/api/lovelace/config", { expectedStatuses: [200, 404] })
1324
+ .catch(() => null);
1325
+ if (cfg && typeof cfg === "object") {
1326
+ lovelace.default = { config: cfg };
1327
+ } else {
1328
+ snapshot.warnings.push("Lovelace API not available or not storage-based dashboards.");
1329
+ }
1330
+ }
1331
+ snapshot.configs.lovelace = Object.fromEntries(
1332
+ Object.entries(lovelace).sort(([a], [b]) => (a < b ? -1 : 1)),
1333
+ );
1334
+ }
1335
+
1336
+ fs.writeFileSync(outPath, stableStringify(snapshot, indent) + "\n", "utf-8");
1337
+
1338
+ const counts = {
1339
+ areas: (snapshot.registries.areas || []).length,
1340
+ devices: (snapshot.registries.devices || []).length,
1341
+ entities: (snapshot.registries.entities || []).length,
1342
+ automations: Object.keys(snapshot.configs.automations || {}).length,
1343
+ scripts: Object.keys(snapshot.configs.scripts || {}).length,
1344
+ scenes: Object.keys(snapshot.configs.scenes || {}).length,
1345
+ lovelace: Object.keys(snapshot.configs.lovelace || {}).length,
1346
+ };
1347
+
1348
+ console.log(outPath);
1349
+ console.log("counts:", JSON.stringify(counts, null, 0));
1350
+ if (snapshot.warnings.length) console.log(`warnings: ${snapshot.warnings.length}`);
1351
+ return 0;
1352
+ }
1353
+
1354
+ // -------------------------
1355
+ // rollback
1356
+ // -------------------------
1357
+
1358
+ function printRollbackHelp() {
1359
+ console.log("Usage: node scripts/ha_ops.js rollback <snapshot.json> [options]");
1360
+ console.log("");
1361
+ console.log("Options:");
1362
+ console.log(" --dry-run Show what would be changed without applying");
1363
+ console.log(" --yes, -y Apply changes without confirmation");
1364
+ console.log(" --log <path> Log file path (default: ha_rollback_<timestamp>.md)");
1365
+ console.log(" -h, --help Show this help");
1366
+ }
1367
+
1368
+ function desiredEntityFields(entry) {
1369
+ return {
1370
+ name: entry.name,
1371
+ area_id: entry.area_id,
1372
+ disabled_by: entry.disabled_by,
1373
+ hidden_by: entry.hidden_by,
1374
+ icon: entry.icon,
1375
+ };
1376
+ }
1377
+
1378
+ function formatValue(value) {
1379
+ if (value === null || value === undefined) return "(none)";
1380
+ return JSON.stringify(value);
1381
+ }
1382
+
1383
+ async function cmdRollback(argv) {
1384
+ const { values, positionals } = parseArgs({
1385
+ args: argv,
1386
+ allowPositionals: true,
1387
+ options: {
1388
+ help: { type: "boolean", short: "h" },
1389
+ "dry-run": { type: "boolean", default: false },
1390
+ yes: { type: "boolean", short: "y", default: false },
1391
+ log: { type: "string", default: "" },
1392
+ },
1393
+ });
1394
+
1395
+ if (values.help) {
1396
+ printRollbackHelp();
1397
+ return 0;
1398
+ }
1399
+
1400
+ if (!positionals.length) die("Missing snapshot path", 2);
1401
+ const snapshotPath = String(positionals[0]);
1402
+ if (!fs.existsSync(snapshotPath)) die(`Snapshot file not found: ${snapshotPath}`, 2);
1403
+
1404
+ let snapshot;
1405
+ try {
1406
+ snapshot = JSON.parse(fs.readFileSync(snapshotPath, "utf-8"));
1407
+ } catch (e) {
1408
+ die(`Failed to load snapshot: ${e?.message || e}`, 2);
1409
+ }
1410
+
1411
+ const snapshotEntities = snapshot?.registries?.entities;
1412
+ if (!Array.isArray(snapshotEntities) || !snapshotEntities.length)
1413
+ die("No entities found in snapshot", 2);
1414
+
1415
+ let rest;
1416
+ try {
1417
+ rest = new HARest();
1418
+ } catch (e) {
1419
+ die(e?.message || String(e), 2);
1420
+ }
1421
+
1422
+ const warnings = [];
1423
+ const rollbacks = [];
1424
+
1425
+ const ws = await new HAWebSocket().connect();
1426
+ try {
1427
+ const current = await ws.call("config/entity_registry/list");
1428
+ if (!Array.isArray(current)) die("Unexpected entity registry response", 2);
1429
+
1430
+ const currentByUniqueId = new Map();
1431
+ const currentByEntityId = new Map();
1432
+ for (const e of current) {
1433
+ if (!e || typeof e !== "object") continue;
1434
+ if (typeof e.unique_id === "string" && e.unique_id.trim())
1435
+ currentByUniqueId.set(e.unique_id, e);
1436
+ if (typeof e.entity_id === "string" && e.entity_id.trim())
1437
+ currentByEntityId.set(e.entity_id, e);
1438
+ }
1439
+
1440
+ const entityKey = (entry) => {
1441
+ const uid = entry.unique_id;
1442
+ if (typeof uid === "string" && uid.trim()) return ["unique_id", uid];
1443
+ const eid = entry.entity_id;
1444
+ if (typeof eid === "string" && eid.trim()) return ["entity_id", eid];
1445
+ return null;
1446
+ };
1447
+
1448
+ for (const snap of snapshotEntities) {
1449
+ if (!snap || typeof snap !== "object") continue;
1450
+ const key = entityKey(snap);
1451
+ if (!key) continue;
1452
+ const [kind, value] = key;
1453
+ const cur =
1454
+ kind === "unique_id" ? currentByUniqueId.get(value) : currentByEntityId.get(value);
1455
+ if (!cur) {
1456
+ if (kind === "unique_id") {
1457
+ warnings.push(
1458
+ `Snapshot entity with unique_id=${JSON.stringify(value)} not found in current registry`,
1459
+ );
1460
+ } else {
1461
+ warnings.push(
1462
+ `Snapshot entity with entity_id=${JSON.stringify(value)} not found in current registry`,
1463
+ );
1464
+ }
1465
+ continue;
1466
+ }
1467
+ const currentEntityId = getStr(cur, "entity_id");
1468
+ const targetEntityId = getStr(snap, "entity_id");
1469
+ if (!currentEntityId || !targetEntityId) continue;
1470
+
1471
+ const desired = desiredEntityFields(snap);
1472
+ const changed = {};
1473
+ for (const [k, v] of Object.entries(desired)) {
1474
+ if (cur[k] !== v) changed[k] = v;
1475
+ }
1476
+
1477
+ if (currentEntityId !== targetEntityId || Object.keys(changed).length) {
1478
+ rollbacks.push({
1479
+ uniqueId: getStr(snap, "unique_id"),
1480
+ currentEntityId,
1481
+ targetEntityId,
1482
+ fields: changed,
1483
+ });
1484
+ }
1485
+ }
1486
+ } finally {
1487
+ await ws.close();
1488
+ }
1489
+
1490
+ if (!rollbacks.length) {
1491
+ console.log("No changes needed - current registry matches snapshot");
1492
+ return 0;
1493
+ }
1494
+
1495
+ console.log(`Found ${rollbacks.length} entity registry rollback(s):\n`);
1496
+ for (const rb of [...rollbacks].sort((a, b) => (a.targetEntityId < b.targetEntityId ? -1 : 1))) {
1497
+ let header = `- ${rb.currentEntityId}`;
1498
+ if (rb.currentEntityId !== rb.targetEntityId) header += ` -> ${rb.targetEntityId}`;
1499
+ if (rb.uniqueId) header += ` (unique_id=${JSON.stringify(rb.uniqueId)})`;
1500
+ console.log(header);
1501
+ for (const [k, v] of Object.entries(rb.fields).sort(([a], [b]) => (a < b ? -1 : 1))) {
1502
+ console.log(` - ${k}: ${formatValue(v)}`);
1503
+ }
1504
+ }
1505
+
1506
+ if (values["dry-run"]) {
1507
+ console.log("\n[dry-run] No changes applied");
1508
+ return 0;
1509
+ }
1510
+
1511
+ if (!values.yes) {
1512
+ const rl = readlinePromises.createInterface({
1513
+ input: process.stdin,
1514
+ output: process.stdout,
1515
+ });
1516
+ try {
1517
+ const answer = String(await rl.question("Apply these rollbacks? [y/N] "))
1518
+ .trim()
1519
+ .toLowerCase();
1520
+ if (answer !== "y" && answer !== "yes") {
1521
+ console.log("Aborted");
1522
+ return 0;
1523
+ }
1524
+ } finally {
1525
+ rl.close();
1526
+ }
1527
+ }
1528
+
1529
+ const rbKey = (rb) => `${rb.uniqueId || ""}||${rb.currentEntityId}||${rb.targetEntityId}`;
1530
+ const renamedOk = new Set();
1531
+ const appliedRenames = [];
1532
+ const appliedUpdates = [];
1533
+ const applyWarnings = [];
1534
+
1535
+ const ws2 = await new HAWebSocket().connect();
1536
+ try {
1537
+ const renames = rollbacks.filter((r) => r.currentEntityId !== r.targetEntityId);
1538
+ const updates = rollbacks.filter((r) => Object.keys(r.fields).length);
1539
+
1540
+ if (renames.length) {
1541
+ const progress = new ProgressReporter(renames.length, "Restoring entity_ids: ");
1542
+ for (const rb of renames) {
1543
+ try {
1544
+ await ws2.call("config/entity_registry/update", {
1545
+ entity_id: rb.currentEntityId,
1546
+ new_entity_id: rb.targetEntityId,
1547
+ });
1548
+ renamedOk.add(rbKey(rb));
1549
+ appliedRenames.push(rb);
1550
+ } catch (e) {
1551
+ applyWarnings.push(
1552
+ `Failed to rename ${rb.currentEntityId} -> ${rb.targetEntityId}: ${e?.message || e}`,
1553
+ );
1554
+ }
1555
+ progress.update();
1556
+ }
1557
+ progress.finish();
1558
+ }
1559
+
1560
+ if (updates.length) {
1561
+ const progress = new ProgressReporter(updates.length, "Restoring registry fields: ");
1562
+ for (const rb of updates) {
1563
+ const entityIdForUpdate = renamedOk.has(rbKey(rb)) ? rb.targetEntityId : rb.currentEntityId;
1564
+ try {
1565
+ await ws2.call("config/entity_registry/update", {
1566
+ entity_id: entityIdForUpdate,
1567
+ ...rb.fields,
1568
+ });
1569
+ appliedUpdates.push({ ...rb, entityIdForUpdate });
1570
+ } catch (e) {
1571
+ applyWarnings.push(`Failed to update ${entityIdForUpdate}: ${e?.message || e}`);
1572
+ }
1573
+ progress.update();
1574
+ }
1575
+ progress.finish();
1576
+ }
1577
+ } finally {
1578
+ await ws2.close();
1579
+ }
1580
+
1581
+ const timestamp = utcTimestamp();
1582
+ const logPath = values.log ? String(values.log) : `ha_rollback_${timestamp}.md`;
1583
+ const lines = [
1584
+ `# Home Assistant rollback (${timestamp})`,
1585
+ "",
1586
+ `- Instance: \`${rest.baseUrl}\``,
1587
+ `- Snapshot: \`${snapshotPath}\``,
1588
+ "",
1589
+ "## Applied entity_id renames",
1590
+ ];
1591
+ if (appliedRenames.length) {
1592
+ for (const rb of appliedRenames)
1593
+ lines.push(`- \`${rb.currentEntityId}\` -> \`${rb.targetEntityId}\``);
1594
+ } else {
1595
+ lines.push("- (none)");
1596
+ }
1597
+ lines.push("");
1598
+ lines.push("## Applied registry field updates");
1599
+ if (appliedUpdates.length) {
1600
+ for (const rb of appliedUpdates) {
1601
+ lines.push(`- \`${rb.entityIdForUpdate}\``);
1602
+ for (const [k, v] of Object.entries(rb.fields).sort(([a], [b]) => (a < b ? -1 : 1))) {
1603
+ lines.push(` - ${k}: ${formatValue(v)}`);
1604
+ }
1605
+ }
1606
+ } else {
1607
+ lines.push("- (none)");
1608
+ }
1609
+ lines.push("");
1610
+
1611
+ lines.push("## Warnings");
1612
+ const allWarnings = [...warnings, ...applyWarnings];
1613
+ if (allWarnings.length) {
1614
+ for (const w of allWarnings) lines.push(`- ${w}`);
1615
+ } else {
1616
+ lines.push("- (none)");
1617
+ }
1618
+ lines.push("");
1619
+
1620
+ fs.writeFileSync(logPath, lines.join("\n") + "\n", "utf-8");
1621
+ console.log(`\nLog: ${logPath}`);
1622
+ if (allWarnings.length) console.log(`Warnings: ${allWarnings.length}`);
1623
+ return 0;
1624
+ }
1625
+
1626
+ // -------------------------
1627
+ // find-references
1628
+ // -------------------------
1629
+
1630
+ function printFindReferencesHelp() {
1631
+ console.log("Usage: node scripts/ha_ops.js find-references [options]");
1632
+ console.log("");
1633
+ console.log("Options:");
1634
+ console.log(" --needle <str> String to search for (repeatable)");
1635
+ console.log(" --needles-file <path> File with one needle per line (# comments allowed)");
1636
+ console.log(" --map-json <path> JSON mapping old->new (searches old keys)");
1637
+ console.log(" --backup-root <path> Path to a Home Assistant backup root (optional)");
1638
+ console.log(" --max-bytes <n> Max file size to scan in backups (default: 5000000)");
1639
+ console.log(" --out <path> Output markdown path (default: ha_refs_<timestamp>.md)");
1640
+ console.log(" --json-out <path> Optional JSON output path");
1641
+ console.log(" --no-live Skip live (API) scanning");
1642
+ console.log(" --no-backup Skip backup scanning");
1643
+ console.log(" -h, --help Show this help");
1644
+ }
1645
+
1646
+ function* iterStrings(value, p = "") {
1647
+ if (value && typeof value === "object") {
1648
+ if (Array.isArray(value)) {
1649
+ for (let i = 0; i < value.length; i++) {
1650
+ yield* iterStrings(value[i], `${p}[${i}]`);
1651
+ }
1652
+ return;
1653
+ }
1654
+ for (const [k, v] of Object.entries(value)) {
1655
+ const childPath = p ? `${p}.${k}` : String(k);
1656
+ yield* iterStrings(v, childPath);
1657
+ }
1658
+ return;
1659
+ }
1660
+ if (typeof value === "string") yield [p, value];
1661
+ }
1662
+
1663
+ function compileUnionRegex(needles) {
1664
+ const escaped = needles.filter(Boolean).map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
1665
+ if (!escaped.length) throw new Error("No needles provided");
1666
+ escaped.sort((a, b) => b.length - a.length);
1667
+ return new RegExp(escaped.join("|"));
1668
+ }
1669
+
1670
+ function loadNeedles(values) {
1671
+ const needles = [];
1672
+ for (const n of values.needle || []) {
1673
+ const s = String(n).trim();
1674
+ if (s) needles.push(s);
1675
+ }
1676
+ if (values["needles-file"]) {
1677
+ const content = fs.readFileSync(String(values["needles-file"]), "utf-8");
1678
+ for (const line of content.split(/\r?\n/)) {
1679
+ const s = line.trim();
1680
+ if (!s || s.startsWith("#")) continue;
1681
+ needles.push(s);
1682
+ }
1683
+ }
1684
+ if (values["map-json"]) {
1685
+ const mapping = JSON.parse(fs.readFileSync(String(values["map-json"]), "utf-8"));
1686
+ if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) {
1687
+ throw new Error("--map-json must be a JSON object mapping old->new");
1688
+ }
1689
+ for (const k of Object.keys(mapping)) {
1690
+ if (typeof k === "string" && k.trim()) needles.push(k.trim());
1691
+ }
1692
+ }
1693
+ const seen = new Set();
1694
+ const unique = [];
1695
+ for (const n of needles) {
1696
+ if (seen.has(n)) continue;
1697
+ seen.add(n);
1698
+ unique.push(n);
1699
+ }
1700
+ return unique;
1701
+ }
1702
+
1703
+ async function findLiveHits(rest, needleRe) {
1704
+ const hits = [];
1705
+ const warnings = [];
1706
+ const states = await rest.getJson("/api/states");
1707
+ if (!Array.isArray(states)) throw new Error("Unexpected /api/states response");
1708
+
1709
+ const scanConfig = (kind, owner, ownerId, config) => {
1710
+ if (!config || (typeof config !== "object" && !Array.isArray(config))) return;
1711
+ for (const [p, s] of iterStrings(config)) {
1712
+ const match = needleRe.exec(s);
1713
+ if (match)
1714
+ hits.push({
1715
+ kind,
1716
+ owner,
1717
+ owner_id: ownerId,
1718
+ path: p,
1719
+ needle: match[0],
1720
+ });
1721
+ }
1722
+ };
1723
+
1724
+ for (const st of states) {
1725
+ const entityId = getStr(st, "entity_id");
1726
+ if (!entityId || !entityId.startsWith("automation.")) continue;
1727
+ const attrs = st.attributes && typeof st.attributes === "object" ? st.attributes : {};
1728
+ const automationId = getStr(attrs, "id");
1729
+ if (!automationId) continue;
1730
+ const cfg = await rest
1731
+ .getJson(`/api/config/automation/config/${automationId}`, {
1732
+ expectedStatuses: [200, 404],
1733
+ })
1734
+ .catch(() => null);
1735
+ if (cfg && typeof cfg === "object" && Object.keys(cfg).length)
1736
+ scanConfig("automation", entityId, automationId, cfg);
1737
+ }
1738
+
1739
+ for (const st of states) {
1740
+ const entityId = getStr(st, "entity_id");
1741
+ if (!entityId || !entityId.startsWith("script.")) continue;
1742
+ const attrs = st.attributes && typeof st.attributes === "object" ? st.attributes : {};
1743
+ const scriptId = getStr(attrs, "id");
1744
+ if (!scriptId) continue;
1745
+ const cfg = await rest
1746
+ .getJson(`/api/config/script/config/${scriptId}`, {
1747
+ expectedStatuses: [200, 404],
1748
+ })
1749
+ .catch(() => null);
1750
+ if (cfg && typeof cfg === "object") scanConfig("script", entityId, scriptId, cfg);
1751
+ }
1752
+
1753
+ for (const st of states) {
1754
+ const entityId = getStr(st, "entity_id");
1755
+ if (!entityId || !entityId.startsWith("scene.")) continue;
1756
+ const attrs = st.attributes && typeof st.attributes === "object" ? st.attributes : {};
1757
+ const sceneId = getStr(attrs, "id");
1758
+ if (!sceneId) continue;
1759
+ const cfg = await rest
1760
+ .getJson(`/api/config/scene/config/${sceneId}`, {
1761
+ expectedStatuses: [200, 404],
1762
+ })
1763
+ .catch(() => null);
1764
+ if (cfg && typeof cfg === "object") scanConfig("scene", entityId, sceneId, cfg);
1765
+ }
1766
+
1767
+ const dashboards = await rest
1768
+ .getJson("/api/lovelace/dashboards", { expectedStatuses: [200, 404] })
1769
+ .catch(() => null);
1770
+ if (Array.isArray(dashboards)) {
1771
+ for (const d of dashboards) {
1772
+ const dashId = getStr(d, "id");
1773
+ if (!dashId) continue;
1774
+ const cfg = await rest
1775
+ .getJson(`/api/lovelace/config/${dashId}`, {
1776
+ expectedStatuses: [200, 404],
1777
+ })
1778
+ .catch(() => null);
1779
+ if (cfg && typeof cfg === "object") scanConfig("lovelace", dashId, null, cfg);
1780
+ }
1781
+ } else {
1782
+ const cfg = await rest
1783
+ .getJson("/api/lovelace/config", { expectedStatuses: [200, 404] })
1784
+ .catch(() => null);
1785
+ if (cfg && typeof cfg === "object") scanConfig("lovelace", "default", null, cfg);
1786
+ else warnings.push("Lovelace API not available or not storage-based dashboards.");
1787
+ }
1788
+
1789
+ return [hits, warnings];
1790
+ }
1791
+
1792
+ function listFilesRecursive(base) {
1793
+ const out = [];
1794
+ const stack = [base];
1795
+ while (stack.length) {
1796
+ const cur = stack.pop();
1797
+ let entries;
1798
+ try {
1799
+ entries = fs.readdirSync(cur, { withFileTypes: true });
1800
+ } catch {
1801
+ continue;
1802
+ }
1803
+ for (const ent of entries) {
1804
+ const full = path.join(cur, ent.name);
1805
+ if (ent.isDirectory()) {
1806
+ stack.push(full);
1807
+ } else if (ent.isFile()) {
1808
+ out.push(full);
1809
+ }
1810
+ }
1811
+ }
1812
+ return out;
1813
+ }
1814
+
1815
+ function defaultBackupFiles(base) {
1816
+ const candidates = [];
1817
+ for (const p of listFilesRecursive(base)) {
1818
+ const name = path.basename(p);
1819
+ if (name === "secrets.yaml") continue;
1820
+ if (path.basename(path.dirname(p)) === ".storage") {
1821
+ candidates.push(p);
1822
+ continue;
1823
+ }
1824
+ const ext = path.extname(p).toLowerCase();
1825
+ if (ext === ".yaml" || ext === ".yml" || ext === ".json") {
1826
+ candidates.push(p);
1827
+ }
1828
+ }
1829
+ return candidates;
1830
+ }
1831
+
1832
+ function findBackupHits(backupRoot, needleRe, maxBytes) {
1833
+ const warnings = [];
1834
+ const hits = [];
1835
+
1836
+ let base = backupRoot;
1837
+ const candidateBase = path.join(backupRoot, "homeassistant", "data");
1838
+ if (fs.existsSync(candidateBase)) base = candidateBase;
1839
+
1840
+ const files = defaultBackupFiles(base);
1841
+ for (const p of files) {
1842
+ let size;
1843
+ try {
1844
+ size = fs.statSync(p).size;
1845
+ } catch (e) {
1846
+ warnings.push(`${p}: failed to stat: ${e?.message || e}`);
1847
+ continue;
1848
+ }
1849
+ if (size > maxBytes) continue;
1850
+ let text;
1851
+ try {
1852
+ text = fs.readFileSync(p, "utf-8");
1853
+ } catch (e) {
1854
+ warnings.push(`${p}: failed to read: ${e?.message || e}`);
1855
+ continue;
1856
+ }
1857
+ const lines = text.split(/\r?\n/);
1858
+ for (let i = 0; i < lines.length; i++) {
1859
+ const m = needleRe.exec(lines[i]);
1860
+ if (m) hits.push({ path: p, line: i + 1, needle: m[0] });
1861
+ }
1862
+ }
1863
+
1864
+ return [hits, warnings];
1865
+ }
1866
+
1867
+ function writeRefsReport(outPath, needles, liveHits, liveWarnings, backupHits, backupWarnings) {
1868
+ const lines = [
1869
+ "# Home Assistant reference report",
1870
+ "",
1871
+ `- Generated: \`${utcTimestamp()}\``,
1872
+ `- Needles: ${needles.map((n) => `\`${n}\``).join(", ")}`,
1873
+ "",
1874
+ "## Live (API) hits",
1875
+ ];
1876
+ if (liveHits.length) {
1877
+ const sorted = [...liveHits].sort((a, b) => {
1878
+ const keyA = `${a.kind}||${a.owner}||${a.path}||${a.needle}`;
1879
+ const keyB = `${b.kind}||${b.owner}||${b.path}||${b.needle}`;
1880
+ return keyA < keyB ? -1 : 1;
1881
+ });
1882
+ for (const hit of sorted) {
1883
+ const ownerSuffix = hit.owner_id ? ` (id \`${hit.owner_id}\`)` : "";
1884
+ lines.push(
1885
+ `- ${hit.kind}: \`${hit.owner}\`${ownerSuffix} — matched \`${hit.needle}\` at \`${hit.path}\``,
1886
+ );
1887
+ }
1888
+ } else {
1889
+ lines.push("- (none)");
1890
+ }
1891
+ lines.push("");
1892
+
1893
+ lines.push("## Backup hits");
1894
+ if (backupHits.length) {
1895
+ const sorted = [...backupHits].sort((a, b) => {
1896
+ const keyA = `${a.path}||${a.line}||${a.needle}`;
1897
+ const keyB = `${b.path}||${b.line}||${b.needle}`;
1898
+ return keyA < keyB ? -1 : 1;
1899
+ });
1900
+ for (const hit of sorted)
1901
+ lines.push(`- \`${hit.path}\`:${hit.line}: matched \`${hit.needle}\``);
1902
+ } else {
1903
+ lines.push("- (none)");
1904
+ }
1905
+ lines.push("");
1906
+
1907
+ lines.push("## Warnings");
1908
+ const all = [...liveWarnings, ...backupWarnings];
1909
+ if (all.length) {
1910
+ for (const w of all) lines.push(`- ${w}`);
1911
+ } else {
1912
+ lines.push("- (none)");
1913
+ }
1914
+ lines.push("");
1915
+
1916
+ fs.writeFileSync(outPath, lines.join("\n") + "\n", "utf-8");
1917
+ }
1918
+
1919
+ function writeRefsJsonReport(
1920
+ outPath,
1921
+ url,
1922
+ backupRoot,
1923
+ needles,
1924
+ liveHits,
1925
+ liveWarnings,
1926
+ backupHits,
1927
+ backupWarnings,
1928
+ ) {
1929
+ const payload = {
1930
+ meta: {
1931
+ generated_at: utcTimestamp(),
1932
+ url,
1933
+ backup_root: backupRoot,
1934
+ needles,
1935
+ },
1936
+ live: { hits: liveHits, warnings: liveWarnings },
1937
+ backup: { hits: backupHits, warnings: backupWarnings },
1938
+ };
1939
+ fs.writeFileSync(outPath, stableStringify(payload, 2) + "\n", "utf-8");
1940
+ }
1941
+
1942
+ async function cmdFindReferences(argv) {
1943
+ const { values } = parseArgs({
1944
+ args: argv,
1945
+ allowPositionals: false,
1946
+ options: {
1947
+ help: { type: "boolean", short: "h" },
1948
+ needle: { type: "string", multiple: true, default: [] },
1949
+ "needles-file": { type: "string", default: "" },
1950
+ "map-json": { type: "string", default: "" },
1951
+ "backup-root": { type: "string", default: "" },
1952
+ "max-bytes": { type: "string", default: "5000000" },
1953
+ out: { type: "string", default: "" },
1954
+ "json-out": { type: "string", default: "" },
1955
+ "no-live": { type: "boolean", default: false },
1956
+ "no-backup": { type: "boolean", default: false },
1957
+ },
1958
+ });
1959
+
1960
+ if (values.help) {
1961
+ printFindReferencesHelp();
1962
+ return 0;
1963
+ }
1964
+
1965
+ let needles;
1966
+ try {
1967
+ needles = loadNeedles(values);
1968
+ } catch (e) {
1969
+ die(e?.message || String(e), 2);
1970
+ }
1971
+ if (!needles.length) die("No needles. Use --needle, --needles-file, or --map-json.", 2);
1972
+
1973
+ let needleRe;
1974
+ try {
1975
+ needleRe = compileUnionRegex(needles);
1976
+ } catch (e) {
1977
+ die(e?.message || String(e), 2);
1978
+ }
1979
+
1980
+ const liveHits = [];
1981
+ const liveWarnings = [];
1982
+ let url = "";
1983
+ if (!values["no-live"]) {
1984
+ try {
1985
+ const rest = new HARest();
1986
+ url = rest.baseUrl;
1987
+ const [hits, warnings] = await findLiveHits(rest, needleRe);
1988
+ liveHits.push(...hits);
1989
+ liveWarnings.push(...warnings);
1990
+ } catch (e) {
1991
+ liveWarnings.push(`Live scan failed: ${e?.message || e}`);
1992
+ }
1993
+ }
1994
+
1995
+ const backupHits = [];
1996
+ const backupWarnings = [];
1997
+ if (!values["no-backup"] && values["backup-root"]) {
1998
+ const maxBytes = Number(values["max-bytes"]);
1999
+ if (!Number.isFinite(maxBytes) || maxBytes < 0)
2000
+ die(`Invalid --max-bytes: ${values["max-bytes"]}`, 2);
2001
+ try {
2002
+ const [hits, warnings] = findBackupHits(String(values["backup-root"]), needleRe, maxBytes);
2003
+ backupHits.push(...hits);
2004
+ backupWarnings.push(...warnings);
2005
+ } catch (e) {
2006
+ backupWarnings.push(`Backup scan failed: ${e?.message || e}`);
2007
+ }
2008
+ }
2009
+
2010
+ if (values["no-backup"]) {
2011
+ backupWarnings.push("Backup scan skipped: --no-backup provided.");
2012
+ } else if (!values["backup-root"]) {
2013
+ backupWarnings.push("Backup scan skipped: no --backup-root provided.");
2014
+ }
2015
+
2016
+ const timestamp = utcTimestamp();
2017
+ const outPath = values.out ? String(values.out) : `ha_refs_${timestamp}.md`;
2018
+ writeRefsReport(outPath, needles, liveHits, liveWarnings, backupHits, backupWarnings);
2019
+ if (values["json-out"]) {
2020
+ writeRefsJsonReport(
2021
+ String(values["json-out"]),
2022
+ url,
2023
+ values["backup-root"],
2024
+ needles,
2025
+ liveHits,
2026
+ liveWarnings,
2027
+ backupHits,
2028
+ backupWarnings,
2029
+ );
2030
+ }
2031
+ console.log(outPath);
2032
+ return 0;
2033
+ }
2034
+
2035
+ // -------------------------
2036
+ // tail-events
2037
+ // -------------------------
2038
+
2039
+ function printTailEventsHelp() {
2040
+ console.log("Usage: node scripts/ha_ops.js tail-events [options]");
2041
+ console.log("");
2042
+ console.log("Options:");
2043
+ console.log(" --entity <id> Entity ID to include (repeatable)");
2044
+ console.log(
2045
+ " --event-type <type> Event type to subscribe to (repeatable, default: state_changed)",
2046
+ );
2047
+ console.log(" --device-ieee <ieee> Device IEEE to include (repeatable)");
2048
+ console.log(" --device-id <id> Device registry ID to include (repeatable)");
2049
+ console.log(" --seconds <n> Stop after N seconds (0 = run until interrupted)");
2050
+ console.log(" --raw Print raw JSON for each event");
2051
+ console.log(" -h, --help Show this help");
2052
+ }
2053
+
2054
+ function asList(value) {
2055
+ if (value === null || value === undefined) return [];
2056
+ return Array.isArray(value) ? value : [value];
2057
+ }
2058
+
2059
+ function formatCtx(ctx) {
2060
+ if (!ctx || typeof ctx !== "object") return "ctx=?";
2061
+ return `ctx.id=${String(ctx.id)} ctx.parent_id=${String(ctx.parent_id)} ctx.user_id=${String(ctx.user_id)}`;
2062
+ }
2063
+
2064
+ function stateSummary(state) {
2065
+ if (!state || typeof state !== "object") return "<?>";
2066
+ const s = state.state;
2067
+ const attrs = state.attributes && typeof state.attributes === "object" ? state.attributes : {};
2068
+ const name = attrs.friendly_name;
2069
+ if (typeof name === "string" && name) return `${String(s)} (${name})`;
2070
+ return String(s);
2071
+ }
2072
+
2073
+ function* iterEntityIdsFromEvent(event) {
2074
+ const data = event?.data;
2075
+ if (!data || typeof data !== "object") return;
2076
+ if (typeof data.entity_id === "string") {
2077
+ yield data.entity_id;
2078
+ return;
2079
+ }
2080
+ const serviceData = data.service_data;
2081
+ if (!serviceData || typeof serviceData !== "object") return;
2082
+ const target = serviceData.entity_id;
2083
+ if (typeof target === "string") yield target;
2084
+ else if (Array.isArray(target)) {
2085
+ for (const e of target) if (typeof e === "string") yield e;
2086
+ }
2087
+ }
2088
+
2089
+ function* iterDeviceIeeeFromEvent(event) {
2090
+ const data = event?.data;
2091
+ if (!data || typeof data !== "object") return;
2092
+ if (typeof data.device_ieee === "string") yield data.device_ieee;
2093
+ }
2094
+
2095
+ function* iterDeviceIdsFromEvent(event) {
2096
+ const data = event?.data;
2097
+ if (!data || typeof data !== "object") return;
2098
+ if (typeof data.device_id === "string") yield data.device_id;
2099
+ }
2100
+
2101
+ // -------------------------
2102
+ // traces
2103
+ // -------------------------
2104
+
2105
+ function printTracesHelp() {
2106
+ console.log("Usage: node scripts/ha_ops.js traces [options]");
2107
+ console.log("");
2108
+ console.log("List or view automation/script execution traces.");
2109
+ console.log("");
2110
+ console.log("Options:");
2111
+ console.log(
2112
+ " --domain <domain> Domain to query: automation or script (default: automation)",
2113
+ );
2114
+ console.log(
2115
+ " --item-id <id> Filter by numeric automation/script ID (from attributes.id)",
2116
+ );
2117
+ console.log(" --entity-id <entity_id> Filter by entity_id (e.g., automation.my_automation)");
2118
+ console.log(" --run-id <run_id> Get detailed trace for a specific run");
2119
+ console.log(" --limit <n> Max traces to show (default: 10)");
2120
+ console.log(" --json Output as JSON");
2121
+ console.log(" -h, --help Show this help");
2122
+ console.log("");
2123
+ console.log("Examples:");
2124
+ console.log(" # List recent automation traces");
2125
+ console.log(" node scripts/ha_ops.js traces");
2126
+ console.log("");
2127
+ console.log(" # List traces for a specific automation by entity_id");
2128
+ console.log(" node scripts/ha_ops.js traces --entity-id automation.doorbell_announce");
2129
+ console.log("");
2130
+ console.log(" # Get detailed trace for a specific run");
2131
+ console.log(" node scripts/ha_ops.js traces --item-id 1757182154251 --run-id abc123...");
2132
+ }
2133
+
2134
+ async function cmdTraces(argv) {
2135
+ const { values } = parseArgs({
2136
+ args: argv,
2137
+ allowPositionals: false,
2138
+ options: {
2139
+ help: { type: "boolean", short: "h" },
2140
+ domain: { type: "string", default: "automation" },
2141
+ "item-id": { type: "string", default: "" },
2142
+ "entity-id": { type: "string", default: "" },
2143
+ "run-id": { type: "string", default: "" },
2144
+ limit: { type: "string", default: "10" },
2145
+ json: { type: "boolean", default: false },
2146
+ },
2147
+ });
2148
+
2149
+ if (values.help) {
2150
+ printTracesHelp();
2151
+ return 0;
2152
+ }
2153
+
2154
+ const domain = values.domain;
2155
+ if (domain !== "automation" && domain !== "script") {
2156
+ die(`Invalid --domain: ${domain}. Must be 'automation' or 'script'.`, 2);
2157
+ }
2158
+
2159
+ const limit = Number(values.limit);
2160
+ if (!Number.isFinite(limit) || limit < 1) {
2161
+ die(`Invalid --limit: ${values.limit}`, 2);
2162
+ }
2163
+
2164
+ let itemId = values["item-id"];
2165
+ const entityId = values["entity-id"];
2166
+ const runId = values["run-id"];
2167
+
2168
+ // If entity-id is provided, resolve to item-id
2169
+ if (entityId && !itemId) {
2170
+ const rest = new HARest();
2171
+ const state = await rest.getJson(`/api/states/${entityId}`, {
2172
+ expectedStatuses: [200, 404],
2173
+ });
2174
+ if (!state) {
2175
+ die(`Entity not found: ${entityId}`, 2);
2176
+ }
2177
+ const attrs = state.attributes || {};
2178
+ const resolvedId = attrs.id;
2179
+ if (!resolvedId) {
2180
+ die(`Entity ${entityId} does not have an 'id' attribute (required for trace lookup)`, 2);
2181
+ }
2182
+ itemId = String(resolvedId);
2183
+ }
2184
+
2185
+ const ws = await new HAWebSocket().connect();
2186
+ try {
2187
+ if (runId) {
2188
+ // Get specific trace
2189
+ if (!itemId) {
2190
+ die("--item-id (or --entity-id) is required when using --run-id", 2);
2191
+ }
2192
+ const trace = await ws.call("trace/get", {
2193
+ domain,
2194
+ item_id: itemId,
2195
+ run_id: runId,
2196
+ });
2197
+
2198
+ if (values.json) {
2199
+ console.log(JSON.stringify(trace, null, 2));
2200
+ } else {
2201
+ printTraceDetail(trace, domain, itemId, runId);
2202
+ }
2203
+ } else {
2204
+ // List traces
2205
+ const payload = { domain };
2206
+ if (itemId) {
2207
+ payload.item_id = itemId;
2208
+ }
2209
+ const traces = await ws.call("trace/list", payload);
2210
+
2211
+ if (!Array.isArray(traces)) {
2212
+ die(`Unexpected response from trace/list: ${JSON.stringify(traces)}`, 1);
2213
+ }
2214
+
2215
+ // Sort by start time descending
2216
+ traces.sort((a, b) => {
2217
+ const ta = a.timestamp?.start || "";
2218
+ const tb = b.timestamp?.start || "";
2219
+ return tb.localeCompare(ta);
2220
+ });
2221
+
2222
+ const limited = traces.slice(0, limit);
2223
+
2224
+ if (values.json) {
2225
+ console.log(JSON.stringify(limited, null, 2));
2226
+ } else {
2227
+ if (limited.length === 0) {
2228
+ console.log(`No traces found for ${domain}${itemId ? ` (item_id=${itemId})` : ""}`);
2229
+ } else {
2230
+ console.log(
2231
+ `Found ${traces.length} trace(s) for ${domain}${itemId ? ` (item_id=${itemId})` : ""}, showing ${limited.length}:\n`,
2232
+ );
2233
+ for (const t of limited) {
2234
+ printTraceSummary(t);
2235
+ }
2236
+ }
2237
+ }
2238
+ }
2239
+ } finally {
2240
+ await ws.close();
2241
+ }
2242
+
2243
+ return 0;
2244
+ }
2245
+
2246
+ function printTraceSummary(trace) {
2247
+ const runId = trace.run_id || "?";
2248
+ const state = trace.state || "?";
2249
+ const scriptExec = trace.script_execution || "?";
2250
+ const lastStep = trace.last_step || "?";
2251
+ const start = trace.timestamp?.start || "?";
2252
+ const finish = trace.timestamp?.finish || "";
2253
+ const trigger = trace.trigger || "";
2254
+ const error = trace.error || "";
2255
+
2256
+ console.log(`Run: ${runId}`);
2257
+ console.log(` State: ${state} | Execution: ${scriptExec}`);
2258
+ console.log(` Last step: ${lastStep}`);
2259
+ console.log(` Start: ${start}${finish ? ` | Finish: ${finish}` : ""}`);
2260
+ if (trigger) console.log(` Trigger: ${trigger}`);
2261
+ if (error) console.log(` ERROR: ${error}`);
2262
+ console.log();
2263
+ }
2264
+
2265
+ function printTraceDetail(traceData, domain, itemId, runId) {
2266
+ console.log(`=== Trace Detail ===`);
2267
+ console.log(`Domain: ${domain}`);
2268
+ console.log(`Item ID: ${itemId}`);
2269
+ console.log(`Run ID: ${runId}`);
2270
+ console.log();
2271
+
2272
+ const trace = traceData.trace || {};
2273
+ const paths = Object.keys(trace).sort();
2274
+
2275
+ // Extract timestamps for timeline
2276
+ const timeline = [];
2277
+ for (const path of paths) {
2278
+ const steps = trace[path];
2279
+ if (!Array.isArray(steps)) continue;
2280
+ for (const step of steps) {
2281
+ const ts = step.timestamp;
2282
+ if (ts) {
2283
+ timeline.push({ path, timestamp: ts, step });
2284
+ }
2285
+ }
2286
+ }
2287
+
2288
+ // Sort by timestamp
2289
+ timeline.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
2290
+
2291
+ if (timeline.length === 0) {
2292
+ console.log("No trace steps found.");
2293
+ return;
2294
+ }
2295
+
2296
+ const startTime = new Date(timeline[0].timestamp);
2297
+ console.log("Timeline:");
2298
+ for (const { path, timestamp, step } of timeline) {
2299
+ const elapsed = ((new Date(timestamp) - startTime) / 1000).toFixed(3);
2300
+ let info = "";
2301
+
2302
+ const result = step.result || {};
2303
+ if (result.stop) {
2304
+ info = ` -> STOP: ${result.stop}`;
2305
+ } else if (result.error) {
2306
+ info = ` -> ERROR`;
2307
+ }
2308
+
2309
+ const changedVars = step.changed_variables || {};
2310
+ const varNames = Object.keys(changedVars);
2311
+ if (varNames.length > 0 && !info) {
2312
+ info = ` -> vars: ${varNames.join(", ")}`;
2313
+ }
2314
+
2315
+ console.log(` +${elapsed.padStart(7)}s: ${path}${info}`);
2316
+ }
2317
+ }
2318
+
2319
+ async function cmdTailEvents(argv) {
2320
+ const { values } = parseArgs({
2321
+ args: argv,
2322
+ allowPositionals: false,
2323
+ options: {
2324
+ help: { type: "boolean", short: "h" },
2325
+ entity: { type: "string", multiple: true, default: [] },
2326
+ "event-type": { type: "string", multiple: true, default: [] },
2327
+ "device-ieee": { type: "string", multiple: true, default: [] },
2328
+ "device-id": { type: "string", multiple: true, default: [] },
2329
+ seconds: { type: "string", default: "0" },
2330
+ raw: { type: "boolean", default: false },
2331
+ },
2332
+ });
2333
+
2334
+ if (values.help) {
2335
+ printTailEventsHelp();
2336
+ return 0;
2337
+ }
2338
+
2339
+ const seconds = Number(values.seconds);
2340
+ if (!Number.isFinite(seconds) || seconds < 0) die(`Invalid --seconds: ${values.seconds}`, 2);
2341
+
2342
+ const eventTypes = (values["event-type"].length ? values["event-type"] : ["state_changed"])
2343
+ .map(String)
2344
+ .filter(Boolean);
2345
+ const uniqueEventTypes = [...new Set(eventTypes)];
2346
+
2347
+ const entityFilter = new Set(values.entity.map(String));
2348
+ const deviceIeeeFilter = new Set(values["device-ieee"].map(String));
2349
+ const deviceIdFilter = new Set(values["device-id"].map(String));
2350
+
2351
+ const ws = await new HAWebSocket().connect();
2352
+ try {
2353
+ for (const eventType of uniqueEventTypes) {
2354
+ const id = ws.nextId();
2355
+ ws.sendJson({ id, type: "subscribe_events", event_type: eventType });
2356
+ const ack = await ws.recvJson({ timeoutMs: 10_000 });
2357
+ if (!ack || ack.success !== true)
2358
+ throw new Error(
2359
+ `Failed subscribing to ${JSON.stringify(eventType)}: ${JSON.stringify(ack)}`,
2360
+ );
2361
+ }
2362
+
2363
+ console.log(`[${utcNowIso()}] Subscribed to ${JSON.stringify(uniqueEventTypes)}`);
2364
+ if (entityFilter.size)
2365
+ console.log(
2366
+ `[${utcNowIso()}] Filtering to entities: ${JSON.stringify([...entityFilter].sort())}`,
2367
+ );
2368
+ if (deviceIeeeFilter.size)
2369
+ console.log(
2370
+ `[${utcNowIso()}] Filtering to device_ieee: ${JSON.stringify([...deviceIeeeFilter].sort())}`,
2371
+ );
2372
+ if (deviceIdFilter.size)
2373
+ console.log(
2374
+ `[${utcNowIso()}] Filtering to device_id: ${JSON.stringify([...deviceIdFilter].sort())}`,
2375
+ );
2376
+
2377
+ const stopAt = seconds > 0 ? Date.now() + seconds * 1000 : null;
2378
+ while (true) {
2379
+ if (stopAt && Date.now() >= stopAt) break;
2380
+ const msg = await ws.recvJson();
2381
+ if (!msg || msg.type !== "event") continue;
2382
+ const event = msg.event;
2383
+ if (!event || typeof event !== "object") continue;
2384
+
2385
+ if (entityFilter.size || deviceIeeeFilter.size || deviceIdFilter.size) {
2386
+ let matched = false;
2387
+ if (entityFilter.size) {
2388
+ const ids = new Set(iterEntityIdsFromEvent(event));
2389
+ matched = matched || [...ids].some((id) => entityFilter.has(id));
2390
+ }
2391
+ if (deviceIeeeFilter.size) {
2392
+ const ieees = new Set(iterDeviceIeeeFromEvent(event));
2393
+ matched = matched || [...ieees].some((id) => deviceIeeeFilter.has(id));
2394
+ }
2395
+ if (deviceIdFilter.size) {
2396
+ const dids = new Set(iterDeviceIdsFromEvent(event));
2397
+ matched = matched || [...dids].some((id) => deviceIdFilter.has(id));
2398
+ }
2399
+ if (!matched) continue;
2400
+ }
2401
+
2402
+ if (values.raw) {
2403
+ console.log(JSON.stringify(event));
2404
+ continue;
2405
+ }
2406
+
2407
+ const etype = event.event_type;
2408
+ const data = event.data && typeof event.data === "object" ? event.data : {};
2409
+ const now = utcNowIso();
2410
+
2411
+ if (etype === "state_changed") {
2412
+ const entityId = data.entity_id;
2413
+ const newState = data.new_state;
2414
+ const oldState = data.old_state;
2415
+ const ctx = newState && typeof newState === "object" ? newState.context : null;
2416
+ console.log(
2417
+ `[${now}] state_changed ${entityId}: ${stateSummary(oldState)} -> ${stateSummary(newState)}; ${formatCtx(ctx)}`,
2418
+ );
2419
+ } else if (etype === "call_service") {
2420
+ const domain = data.domain;
2421
+ const service = data.service;
2422
+ const serviceData = data.service_data;
2423
+ const ctxInfo = event.context;
2424
+ let targets = [];
2425
+ if (serviceData && typeof serviceData === "object") targets = asList(serviceData.entity_id);
2426
+ console.log(
2427
+ `[${now}] call_service ${domain}.${service} target=${JSON.stringify(targets)}; ${formatCtx(ctxInfo)}`,
2428
+ );
2429
+ } else {
2430
+ console.log(`[${now}] ${etype} ${JSON.stringify(data)}`);
2431
+ }
2432
+ }
2433
+ return 0;
2434
+ } finally {
2435
+ await ws.close();
2436
+ }
2437
+ }
2438
+
2439
+ // -------------------------
2440
+ // name-review-from-backup
2441
+ // -------------------------
2442
+
2443
+ const GENERIC_PATTERNS = [
2444
+ ["Motion/Occupancy", /\b(motion|occupancy|presence)\b/i],
2445
+ ["Door/Window", /\b(door|window)\b/i],
2446
+ ["Temperature", /\btemp(erature)?\b/i],
2447
+ ["Humidity", /\bhumidity\b/i],
2448
+ ["Illuminance", /\b(illuminance|lux)\b/i],
2449
+ ["Battery", /\bbattery\b/i],
2450
+ ["Power/Energy", /\b(power|energy|current|voltage)\b/i],
2451
+ ["Leak/Water", /\b(leak|water)\b/i],
2452
+ ["Smoke/CO", /\b(smoke|carbon monoxide|co)\b/i],
2453
+ ["Outlet/Plug", /\b(outlet|plug)\b/i],
2454
+ ["TV/Speaker", /\b(tv|speaker)\b/i],
2455
+ ["Camera", /\bcamera\b/i],
2456
+ ["Lock", /\block\b/i],
2457
+ ["Blinds/Cover", /\b(blinds|curtain|cover)\b/i],
2458
+ ["Fan", /\bfan\b/i],
2459
+ ];
2460
+
2461
+ const NOISE_NAME_PATTERN =
2462
+ /\b(rssi|lqi|linkquality|identify|device temperature|power factor|apparent power|summation delivered)\b/i;
2463
+ const LIGHTS_COVE_NAME_PATTERN = /\b(lights|cove)\b/i;
2464
+
2465
+ function printNameReviewHelp() {
2466
+ console.log(
2467
+ "Usage: node scripts/ha_ops.js name-review-from-backup --backup-root <path> [options]",
2468
+ );
2469
+ console.log("");
2470
+ console.log("Options:");
2471
+ console.log(" --backup-root <path> Path to the Home Assistant backup root (required)");
2472
+ console.log(
2473
+ " --out <path> Output markdown path (default: ha_name_review_<timestamp>.md)",
2474
+ );
2475
+ console.log(" --limit <n> Max candidates (default: 300)");
2476
+ console.log(" --include-diagnostic Include diagnostic/config entities");
2477
+ console.log(" --include-lights-cove Include entities mentioning 'Lights' or 'Cove'");
2478
+ console.log(" -h, --help Show this help");
2479
+ }
2480
+
2481
+ function normName(value) {
2482
+ return String(value || "")
2483
+ .trim()
2484
+ .toLowerCase()
2485
+ .replace(/\s+/g, " ");
2486
+ }
2487
+
2488
+ function startsWithAnyPrefix(value, prefixes) {
2489
+ const v = normName(value);
2490
+ for (const p of prefixes) {
2491
+ const pp = normName(p);
2492
+ if (!pp) continue;
2493
+ if (v === pp || v.startsWith(pp + " ")) return true;
2494
+ }
2495
+ return false;
2496
+ }
2497
+
2498
+ function deviceDisplayName(device) {
2499
+ return device.name_by_user || device.name || null;
2500
+ }
2501
+
2502
+ function entityDisplayName(entity, deviceName) {
2503
+ if (entity.name) return entity.name;
2504
+ const originalName = entity.original_name;
2505
+ if (entity.has_entity_name && deviceName && originalName) return `${deviceName} ${originalName}`;
2506
+ return originalName || entity.entity_id || "<unknown>";
2507
+ }
2508
+
2509
+ function loadRestoreStateFriendlyNames(storageDir) {
2510
+ const p = path.join(storageDir, "core.restore_state");
2511
+ if (!fs.existsSync(p)) return {};
2512
+ let payload;
2513
+ try {
2514
+ payload = JSON.parse(fs.readFileSync(p, "utf-8"));
2515
+ } catch {
2516
+ return {};
2517
+ }
2518
+ if (!payload || typeof payload !== "object" || !Array.isArray(payload.data)) return {};
2519
+ const friendly = {};
2520
+ for (const item of payload.data) {
2521
+ const state = item?.state;
2522
+ if (!state || typeof state !== "object") continue;
2523
+ const entityId = state.entity_id;
2524
+ const attrs = state.attributes;
2525
+ if (!attrs || typeof attrs !== "object") continue;
2526
+ const name = attrs.friendly_name;
2527
+ if (entityId && typeof name === "string" && name.trim()) friendly[entityId] = name.trim();
2528
+ }
2529
+ return friendly;
2530
+ }
2531
+
2532
+ function mdEscapeTableCell(value) {
2533
+ return String(value || "")
2534
+ .replace(/\\/g, "\\\\")
2535
+ .replace(/\|/g, "\\|")
2536
+ .replace(/\n/g, " ")
2537
+ .trim();
2538
+ }
2539
+
2540
+ async function cmdNameReviewFromBackup(argv) {
2541
+ const { values } = parseArgs({
2542
+ args: argv,
2543
+ allowPositionals: false,
2544
+ options: {
2545
+ help: { type: "boolean", short: "h" },
2546
+ "backup-root": { type: "string", default: "" },
2547
+ out: { type: "string", default: "" },
2548
+ limit: { type: "string", default: "300" },
2549
+ "include-diagnostic": { type: "boolean", default: false },
2550
+ "include-lights-cove": { type: "boolean", default: false },
2551
+ },
2552
+ });
2553
+
2554
+ if (values.help) {
2555
+ printNameReviewHelp();
2556
+ return 0;
2557
+ }
2558
+
2559
+ if (!values["backup-root"]) die("--backup-root is required", 2);
2560
+ const backupRoot = String(values["backup-root"]);
2561
+ const storageDir = path.join(backupRoot, "homeassistant", "data", ".storage");
2562
+
2563
+ const entityRegistryPath = path.join(storageDir, "core.entity_registry");
2564
+ const deviceRegistryPath = path.join(storageDir, "core.device_registry");
2565
+ const areaRegistryPath = path.join(storageDir, "core.area_registry");
2566
+
2567
+ for (const p of [entityRegistryPath, deviceRegistryPath, areaRegistryPath]) {
2568
+ if (!fs.existsSync(p)) die(`Missing expected backup file: ${p}`, 2);
2569
+ }
2570
+
2571
+ let entityPayload, devicePayload, areaPayload;
2572
+ try {
2573
+ entityPayload = JSON.parse(fs.readFileSync(entityRegistryPath, "utf-8"));
2574
+ devicePayload = JSON.parse(fs.readFileSync(deviceRegistryPath, "utf-8"));
2575
+ areaPayload = JSON.parse(fs.readFileSync(areaRegistryPath, "utf-8"));
2576
+ } catch (e) {
2577
+ die(`Failed to load backup registries: ${e?.message || e}`, 2);
2578
+ }
2579
+
2580
+ const entityRegistry = entityPayload?.data?.entities;
2581
+ const deviceRegistry = devicePayload?.data?.devices;
2582
+ const areaRegistry = areaPayload?.data?.areas;
2583
+ if (
2584
+ !Array.isArray(entityRegistry) ||
2585
+ !Array.isArray(deviceRegistry) ||
2586
+ !Array.isArray(areaRegistry)
2587
+ ) {
2588
+ die("Unexpected registry format (expected lists)", 2);
2589
+ }
2590
+
2591
+ const restoreFriendlyNames = loadRestoreStateFriendlyNames(storageDir);
2592
+
2593
+ const areasById = {};
2594
+ for (const a of areaRegistry)
2595
+ if (a && typeof a === "object" && typeof a.id === "string") areasById[a.id] = a;
2596
+ const devicesById = {};
2597
+ for (const d of deviceRegistry)
2598
+ if (d && typeof d === "object" && typeof d.id === "string") devicesById[d.id] = d;
2599
+
2600
+ const areaPrefixesById = {};
2601
+ for (const [areaId, area] of Object.entries(areasById)) {
2602
+ const prefixes = [];
2603
+ if (typeof area.name === "string") prefixes.push(area.name);
2604
+ if (Array.isArray(area.aliases)) {
2605
+ for (const a of area.aliases) if (typeof a === "string") prefixes.push(a);
2606
+ }
2607
+ areaPrefixesById[areaId] = prefixes.filter(Boolean);
2608
+ }
2609
+
2610
+ const resolveAreaId = (entity) => {
2611
+ if (typeof entity.area_id === "string" && entity.area_id) return entity.area_id;
2612
+ const deviceId = entity.device_id;
2613
+ if (typeof deviceId === "string" && devicesById[deviceId]) {
2614
+ const areaId = devicesById[deviceId].area_id;
2615
+ return typeof areaId === "string" ? areaId : null;
2616
+ }
2617
+ return null;
2618
+ };
2619
+
2620
+ const nameToAreaIds = new Map(); // normName -> Set(areaId)
2621
+ const resolved = [];
2622
+ for (const entity of entityRegistry) {
2623
+ if (!entity || typeof entity !== "object") continue;
2624
+ const areaId = resolveAreaId(entity);
2625
+ let deviceName = null;
2626
+ if (typeof entity.device_id === "string" && devicesById[entity.device_id]) {
2627
+ deviceName = deviceDisplayName(devicesById[entity.device_id]);
2628
+ }
2629
+ const entityId = entity.entity_id || "";
2630
+ const currentName = restoreFriendlyNames[entityId] || entityDisplayName(entity, deviceName);
2631
+ resolved.push([entity, areaId, deviceName, currentName]);
2632
+ if (areaId && currentName) {
2633
+ const key = normName(currentName);
2634
+ if (!nameToAreaIds.has(key)) nameToAreaIds.set(key, new Set());
2635
+ nameToAreaIds.get(key).add(areaId);
2636
+ }
2637
+ }
2638
+
2639
+ const interestingDomains = new Set([
2640
+ "binary_sensor",
2641
+ "sensor",
2642
+ "switch",
2643
+ "button",
2644
+ "cover",
2645
+ "fan",
2646
+ "climate",
2647
+ "media_player",
2648
+ "remote",
2649
+ "camera",
2650
+ "lock",
2651
+ "scene",
2652
+ "script",
2653
+ "timer",
2654
+ "input_boolean",
2655
+ "input_select",
2656
+ ]);
2657
+
2658
+ const candidates = [];
2659
+ let scanned = 0;
2660
+ let withArea = 0;
2661
+
2662
+ for (const [entity, areaId, _deviceName, currentName] of resolved) {
2663
+ const entityId = entity.entity_id || "";
2664
+ const domain = entityId.includes(".") ? entityId.split(".", 1)[0] : "";
2665
+ if (!domain || !interestingDomains.has(domain)) continue;
2666
+
2667
+ if (
2668
+ !values["include-diagnostic"] &&
2669
+ (entity.entity_category === "diagnostic" || entity.entity_category === "config")
2670
+ )
2671
+ continue;
2672
+ if (NOISE_NAME_PATTERN.test(currentName)) continue;
2673
+ if (!values["include-lights-cove"] && LIGHTS_COVE_NAME_PATTERN.test(currentName)) continue;
2674
+
2675
+ scanned += 1;
2676
+ if (!areaId || !areasById[areaId]) continue;
2677
+ withArea += 1;
2678
+
2679
+ const area = areasById[areaId];
2680
+ const areaName = typeof area.name === "string" && area.name ? area.name : areaId;
2681
+ const areaPrefixes = areaPrefixesById[areaId] || [areaName];
2682
+ if (startsWithAnyPrefix(currentName, areaPrefixes)) continue;
2683
+
2684
+ const suggestedName = `${areaName} ${currentName}`.trim();
2685
+ const reasons = [];
2686
+ let score = 0;
2687
+
2688
+ const spread = (nameToAreaIds.get(normName(currentName)) || new Set()).size;
2689
+ if (spread >= 2) {
2690
+ reasons.push(`Same name in ${spread} areas`);
2691
+ score += 100 + Math.min(spread, 10) * 5;
2692
+ }
2693
+
2694
+ const matchedKinds = [];
2695
+ for (const [kind, re] of GENERIC_PATTERNS) {
2696
+ if (re.test(currentName)) matchedKinds.push(kind);
2697
+ }
2698
+ if (matchedKinds.length) {
2699
+ reasons.push(`Generic: ${[...new Set(matchedKinds)].sort().join(", ")}`);
2700
+ score += 50;
2701
+ }
2702
+
2703
+ if (spread < 2 && !matchedKinds.length) continue;
2704
+
2705
+ const disabledBy = typeof entity.disabled_by === "string" ? entity.disabled_by : null;
2706
+ const hiddenBy = typeof entity.hidden_by === "string" ? entity.hidden_by : null;
2707
+ if (disabledBy || hiddenBy) {
2708
+ reasons.push("Hidden/disabled");
2709
+ score -= 10;
2710
+ }
2711
+ if (score <= 0) continue;
2712
+
2713
+ candidates.push({
2714
+ score,
2715
+ area_name: areaName,
2716
+ domain,
2717
+ entity_id: entityId,
2718
+ current_name: currentName,
2719
+ suggested_name: suggestedName,
2720
+ reasons,
2721
+ disabled_by: disabledBy,
2722
+ hidden_by: hiddenBy,
2723
+ });
2724
+ }
2725
+
2726
+ candidates.sort((a, b) => {
2727
+ if (b.score !== a.score) return b.score - a.score;
2728
+ const aKey = `${a.area_name.toLowerCase()}||${a.domain}||${a.entity_id}`;
2729
+ const bKey = `${b.area_name.toLowerCase()}||${b.domain}||${b.entity_id}`;
2730
+ return aKey < bKey ? -1 : 1;
2731
+ });
2732
+
2733
+ const limit = Number(values.limit);
2734
+ if (!Number.isFinite(limit) || limit < 0) die(`Invalid --limit: ${values.limit}`, 2);
2735
+
2736
+ const timestamp = utcTimestamp();
2737
+ const outPath = values.out ? String(values.out) : `ha_name_review_${timestamp}.md`;
2738
+
2739
+ const lines = [
2740
+ "# Home Assistant — entity name review (from backup)",
2741
+ "",
2742
+ `- Generated: \`${timestamp}\``,
2743
+ `- Backup: \`${backupRoot}\``,
2744
+ `- Entities scanned: \`${scanned}\``,
2745
+ `- With area resolved: \`${withArea}\``,
2746
+ `- Candidates (score>0): \`${candidates.length}\``,
2747
+ "",
2748
+ "## Top candidates",
2749
+ "",
2750
+ "| Score | Area | Entity | Current name | Suggested name | Reasons |",
2751
+ "| ---: | --- | --- | --- | --- | --- |",
2752
+ ];
2753
+ for (const c of candidates.slice(0, limit)) {
2754
+ lines.push(
2755
+ `| ${c.score} | ${mdEscapeTableCell(c.area_name)} | \`${c.entity_id}\` | ${mdEscapeTableCell(c.current_name)} | ${mdEscapeTableCell(c.suggested_name)} | ${mdEscapeTableCell(c.reasons.join("; "))} |`,
2756
+ );
2757
+ }
2758
+ lines.push(
2759
+ "",
2760
+ "## Notes",
2761
+ "",
2762
+ "- This report uses the backup registries; it won't reflect renames applied after that backup was taken.",
2763
+ "- Suggested names prefix the entity's current friendly name with the resolved area name.",
2764
+ "",
2765
+ );
2766
+ fs.writeFileSync(outPath, lines.join("\n"), "utf-8");
2767
+ console.log(outPath);
2768
+ return 0;
2769
+ }
2770
+
2771
+ // -------------------------
2772
+ // update-groups
2773
+ // -------------------------
2774
+
2775
+ function printUpdateGroupsHelp() {
2776
+ console.log("Usage: node scripts/ha_ops.js update-groups [options]");
2777
+ console.log("");
2778
+ console.log("Apply a rename map to group helper memberships. Entity ID renames");
2779
+ console.log("(via the entity registry) do NOT propagate to config-entry-based");
2780
+ console.log("group helpers. This command finds affected groups and rewrites");
2781
+ console.log("their member lists using the provided rename map.");
2782
+ console.log("");
2783
+ console.log("Options:");
2784
+ console.log(" --map-json <path> JSON file mapping old entity_ids to new (required)");
2785
+ console.log(" --dry-run Preview changes without applying (default: on)");
2786
+ console.log(" --apply Apply changes");
2787
+ console.log(" --json Output plan as JSON");
2788
+ console.log(" -h, --help Show this help");
2789
+ }
2790
+
2791
+ async function cmdUpdateGroups(argv) {
2792
+ const { values } = parseArgs({
2793
+ args: argv,
2794
+ options: {
2795
+ help: { type: "boolean", short: "h" },
2796
+ "map-json": { type: "string", default: "" },
2797
+ apply: { type: "boolean", default: false },
2798
+ json: { type: "boolean", default: false },
2799
+ },
2800
+ });
2801
+
2802
+ if (values.help) {
2803
+ printUpdateGroupsHelp();
2804
+ return 0;
2805
+ }
2806
+
2807
+ const mapPath = values["map-json"];
2808
+ if (!mapPath) die("--map-json is required", 2);
2809
+ if (!fs.existsSync(mapPath)) die(`File not found: ${mapPath}`, 2);
2810
+
2811
+ let renameMap;
2812
+ try {
2813
+ renameMap = JSON.parse(fs.readFileSync(mapPath, "utf-8"));
2814
+ } catch (e) {
2815
+ die(`Failed to parse ${mapPath}: ${e?.message || e}`, 2);
2816
+ }
2817
+ if (
2818
+ !renameMap ||
2819
+ typeof renameMap !== "object" ||
2820
+ Array.isArray(renameMap) ||
2821
+ !Object.keys(renameMap).length
2822
+ ) {
2823
+ die("Rename map must be a non-empty { oldId: newId } object", 2);
2824
+ }
2825
+
2826
+ const dryRun = !values.apply;
2827
+
2828
+ let rest;
2829
+ try {
2830
+ rest = new HARest();
2831
+ } catch (e) {
2832
+ die(e?.message || String(e), 2);
2833
+ }
2834
+
2835
+ const ws = await new HAWebSocket().connect();
2836
+ try {
2837
+ const entities = await ws.call("config/entity_registry/list");
2838
+ if (!Array.isArray(entities)) die("Unexpected entity registry response", 2);
2839
+
2840
+ // Find group helpers whose members overlap with the rename map.
2841
+ const oldIds = new Set(Object.keys(renameMap));
2842
+ const groupEntries = entities.filter(
2843
+ (e) => e && typeof e === "object" && e.platform === "group",
2844
+ );
2845
+
2846
+ const states = await rest.getJson("/api/states");
2847
+ if (!Array.isArray(states)) die("Failed to load states", 1);
2848
+ const stateByEid = new Map();
2849
+ for (const s of states) {
2850
+ if (s && typeof s === "object" && typeof s.entity_id === "string") {
2851
+ stateByEid.set(s.entity_id, s);
2852
+ }
2853
+ }
2854
+
2855
+ const plan = [];
2856
+ for (const entry of groupEntries) {
2857
+ const groupEid = getStr(entry, "entity_id");
2858
+ const configEntryId = getStr(entry, "config_entry_id");
2859
+ if (!groupEid || !configEntryId) continue;
2860
+
2861
+ const st = stateByEid.get(groupEid);
2862
+ const members = st && Array.isArray(st.attributes?.entity_id) ? st.attributes.entity_id : [];
2863
+
2864
+ const affected = members.filter((m) => oldIds.has(m));
2865
+ if (!affected.length) continue;
2866
+
2867
+ const newMembers = members.map((m) => renameMap[m] || m);
2868
+ plan.push({
2869
+ groupEid,
2870
+ configEntryId,
2871
+ oldMembers: members,
2872
+ newMembers,
2873
+ renames: affected.map((m) => [m, renameMap[m]]),
2874
+ });
2875
+ }
2876
+
2877
+ if (!plan.length) {
2878
+ console.log("No group helpers affected by this rename map.");
2879
+ return 0;
2880
+ }
2881
+
2882
+ if (values.json) {
2883
+ console.log(
2884
+ JSON.stringify(
2885
+ {
2886
+ dry_run: dryRun,
2887
+ groups: plan.map((item) => ({
2888
+ group: item.groupEid,
2889
+ renames: Object.fromEntries(item.renames),
2890
+ old_members: item.oldMembers,
2891
+ new_members: item.newMembers,
2892
+ })),
2893
+ },
2894
+ null,
2895
+ 2,
2896
+ ),
2897
+ );
2898
+ return 0;
2899
+ }
2900
+
2901
+ console.log(`${dryRun ? "[dry-run] " : ""}${plan.length} group(s) to update:\n`);
2902
+ for (const item of plan) {
2903
+ console.log(` ${item.groupEid}:`);
2904
+ for (const [old, nw] of item.renames) {
2905
+ console.log(` ${old} -> ${nw}`);
2906
+ }
2907
+ }
2908
+
2909
+ if (dryRun) {
2910
+ console.log("\nRe-run with --apply to apply.");
2911
+ return 0;
2912
+ }
2913
+
2914
+ const progress = new ProgressReporter(plan.length, "Updating groups: ");
2915
+ const failures = [];
2916
+
2917
+ for (const item of plan) {
2918
+ try {
2919
+ await updateGroupMembers(rest, item.configEntryId, item.newMembers);
2920
+ } catch (e) {
2921
+ failures.push(`${item.groupEid}: ${e?.message || e}`);
2922
+ }
2923
+ progress.update();
2924
+ }
2925
+ progress.finish();
2926
+
2927
+ if (failures.length) {
2928
+ console.log(`\n${failures.length} failure(s):`);
2929
+ for (const f of failures) console.log(` ${f}`);
2930
+ return 1;
2931
+ }
2932
+
2933
+ console.log(`\nUpdated ${plan.length} group(s).`);
2934
+ return 0;
2935
+ } finally {
2936
+ await ws.close();
2937
+ }
2938
+ }
2939
+
2940
+ run()
2941
+ .then((code) => process.exit(code))
2942
+ .catch((e) => die(e?.stack || e?.message || String(e), 1));