uc-config 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.
@@ -0,0 +1,540 @@
1
+ import { createHash } from "node:crypto";
2
+ import { basename, extname } from "node:path";
3
+ import { isObject, resolveRefs, hash } from "./util.js";
4
+ import { validateRequest } from "./schema.js";
5
+ import { verifyArtifact } from "./compiler.js";
6
+ export const ADAPTER = "ucr3-rest-04b0d08-v1";
7
+ const enc = (s) => encodeURIComponent(String(s));
8
+ export function route(r, id) {
9
+ const parent = enc(r.parent);
10
+ const itemId = enc(id ?? r.id ?? "");
11
+ const simple = {
12
+ activity: ["/activities", "entityId", "entity_id"],
13
+ macro: ["/macros", "entityId", "entity_id"],
14
+ remote: ["/remotes", "entityId", "entity_id"],
15
+ profile: ["/profiles", "profileId", "profile_id"],
16
+ activityGroup: ["/activity_groups", "groupId", "group_id"],
17
+ driver: ["/intg/drivers", "driverId", "driver_id"],
18
+ integration: ["/intg/instances", "intgId", "integration_id"],
19
+ entity: ["/entities", "entityId", "entity_id"],
20
+ dock: ["/docks/devices", "dockId", "dock_id"],
21
+ driverArchive: ["/intg/drivers", "driverId", "driver_id"],
22
+ };
23
+ const base = simple[r.kind];
24
+ if (base)
25
+ return {
26
+ collection: base[0],
27
+ item: `${base[0]}/${itemId}`,
28
+ collectionTemplate: base[0],
29
+ itemTemplate: `${base[0]}/{${base[1]}}`,
30
+ idField: base[2],
31
+ };
32
+ if (["activityPage", "remotePage", "profilePage", "profileGroup"].includes(r.kind)) {
33
+ const profile = r.kind.startsWith("profile");
34
+ const collection = `/${profile ? "profiles" : r.kind === "activityPage" ? "activities" : "remotes"}/${parent}/${profile ? (r.kind === "profileGroup" ? "groups" : "pages") : "ui/pages"}`;
35
+ const template = `/${profile ? "profiles" : r.kind === "activityPage" ? "activities" : "remotes"}/{${profile ? "profileId" : "entityId"}}/${profile ? (r.kind === "profileGroup" ? "groups" : "pages") : "ui/pages"}`;
36
+ const field = r.kind === "profileGroup" ? "group_id" : "page_id";
37
+ return {
38
+ collection,
39
+ item: `${collection}/${itemId}`,
40
+ collectionTemplate: template,
41
+ itemTemplate: `${template}/{${field === "group_id" ? "groupId" : "pageId"}}`,
42
+ idField: field,
43
+ };
44
+ }
45
+ if (r.kind === "settings")
46
+ return {
47
+ collection: `/cfg/${r.id}`,
48
+ item: `/cfg/${r.id}`,
49
+ collectionTemplate: `/cfg/${r.id}`,
50
+ itemTemplate: `/cfg/${r.id}`,
51
+ idField: "id",
52
+ singleton: true,
53
+ };
54
+ if (r.kind === "irCode")
55
+ return {
56
+ collection: `/remotes/${parent}/ir`,
57
+ item: `/remotes/${parent}/ir/${itemId}`,
58
+ collectionTemplate: "/remotes/{entityId}/ir",
59
+ itemTemplate: "/remotes/{entityId}/ir/{cmdId}",
60
+ idField: "id",
61
+ };
62
+ if (r.kind === "pairing")
63
+ return {
64
+ collection: `/remotes/${parent}/bt`,
65
+ item: `/remotes/${parent}/bt/pairing`,
66
+ collectionTemplate: "/remotes/{entityId}/bt",
67
+ itemTemplate: "/remotes/{entityId}/bt/pairing",
68
+ idField: "id",
69
+ singleton: true,
70
+ };
71
+ if (r.kind === "activityButton" || r.kind === "remoteButton") {
72
+ const prefix = r.kind === "activityButton" ? "activities" : "remotes";
73
+ const button = enc((r.id ?? "").split("/")[0]);
74
+ return {
75
+ collection: `/${prefix}/${parent}/buttons`,
76
+ item: `/${prefix}/${parent}/buttons/${button}`,
77
+ collectionTemplate: `/${prefix}/{entityId}/buttons`,
78
+ itemTemplate: `/${prefix}/{entityId}/buttons/{buttonId}`,
79
+ idField: "button",
80
+ singleton: true,
81
+ };
82
+ }
83
+ if (r.kind === "asset")
84
+ return {
85
+ collection: `/resources/${enc(r.resourceType)}`,
86
+ item: `/resources/${enc(r.resourceType)}/${itemId}`,
87
+ collectionTemplate: "/resources/{type}",
88
+ itemTemplate: "/resources/{type}/{id}",
89
+ idField: "id",
90
+ };
91
+ throw new Error(`Unsupported resource kind ${r.kind}`);
92
+ }
93
+ export function desired(r) {
94
+ return r.kind === "asset" || r.kind === "driverArchive"
95
+ ? { sha256: r.sha256 }
96
+ : r.data;
97
+ }
98
+ export function importShape(r, value) {
99
+ const v = structuredClone(value);
100
+ if (["activity", "macro"].includes(r.kind) &&
101
+ isObject(v.options) &&
102
+ !Array.isArray(v.options.entity_ids)) {
103
+ const included = Array.isArray(v.options.included_entities)
104
+ ? v.options.included_entities
105
+ : Array.isArray(v.entities)
106
+ ? v.entities
107
+ : [];
108
+ v.options.entity_ids = included
109
+ .filter(isObject)
110
+ .map((e) => e.entity_id)
111
+ .filter(Boolean);
112
+ }
113
+ if (r.kind === "irCode" && isObject(v.code))
114
+ return v.code;
115
+ return v;
116
+ }
117
+ export class Adapter {
118
+ client;
119
+ constructor(client) {
120
+ this.client = client;
121
+ }
122
+ included;
123
+ async includedEntity(id) {
124
+ this.included ??= (async () => {
125
+ const found = new Map();
126
+ for (const kind of ["activities", "macros"]) {
127
+ for (const summary of await this.client.list(`/${kind}`)) {
128
+ const item = await this.client.get(`/${kind}/${enc(String(summary.entity_id))}`);
129
+ const list = isObject(item.options) &&
130
+ Array.isArray(item.options.included_entities)
131
+ ? item.options.included_entities.filter(isObject)
132
+ : [];
133
+ for (const x of list)
134
+ if (typeof x.entity_id === "string" &&
135
+ Array.isArray(x.entity_commands) &&
136
+ !found.has(x.entity_id))
137
+ found.set(x.entity_id, x);
138
+ }
139
+ }
140
+ return found;
141
+ })();
142
+ return (await this.included).get(id);
143
+ }
144
+ async observe(original, state, key) {
145
+ const r = resolveRefs(original, state);
146
+ let id = state.bindings[key]?.id ?? r.id;
147
+ if (r.kind === "asset" && !id)
148
+ id = `uc-${r.sha256}${extname(r.file ?? "")}`;
149
+ if (r.kind === "entity" && !id) {
150
+ const matches = (await this.client.list("/entities")).filter((e) => e.integration_id === r.parent && e.entity_id === r.create?.entity_id);
151
+ if (matches.length > 1)
152
+ throw new Error(`${key}: ambiguous entity`);
153
+ if (matches.length)
154
+ id = String(matches[0].entity_id);
155
+ }
156
+ const rt = route(r, id);
157
+ if (r.kind === "asset") {
158
+ const found = (await this.client.list(rt.collection)).find((x) => x.id === id);
159
+ if (!found)
160
+ return { resource: r, id, value: null };
161
+ // Remote files are content-addressed by our upload name. Existing foreign files must be imported explicitly.
162
+ if (!state.bindings[key] && !String(id).startsWith(`uc-${r.sha256}`))
163
+ throw new Error(`${key}: asset identity cannot be verified`);
164
+ const bytes = await this.client.download(route(r, id).item);
165
+ return {
166
+ resource: r,
167
+ id,
168
+ value: { sha256: createHash("sha256").update(bytes).digest("hex") },
169
+ };
170
+ }
171
+ if (r.kind === "driverArchive") {
172
+ const found = id ? await this.client.maybe(rt.item) : null;
173
+ if (found && !state.bindings[key])
174
+ throw new Error(`${key}: existing driver needs explicit inventory/adoption; cannot verify installed archive hash`);
175
+ return {
176
+ resource: r,
177
+ id,
178
+ value: found ? { sha256: state.bindings[key].resource.sha256 } : null,
179
+ };
180
+ }
181
+ if (id || rt.singleton) {
182
+ let value = await this.client.maybe(rt.item);
183
+ if (value)
184
+ value = importShape(r, value);
185
+ if (value &&
186
+ r.kind === "integration" &&
187
+ r.data.driver_id &&
188
+ value.driver_id !== r.data.driver_id)
189
+ throw new Error(`${key}: existing integration belongs to a different driver`);
190
+ if (value &&
191
+ r.kind === "entity" &&
192
+ r.parent &&
193
+ value.integration_id !== r.parent)
194
+ throw new Error(`${key}: existing entity belongs to a different integration`);
195
+ if (!value && r.kind === "irCode")
196
+ await this.client.get(rt.collection);
197
+ if (!value &&
198
+ (r.kind === "activityButton" || r.kind === "remoteButton")) {
199
+ await this.client.get(rt.collection);
200
+ value = {};
201
+ }
202
+ if (!value &&
203
+ rt.singleton &&
204
+ r.kind !== "activityButton" &&
205
+ r.kind !== "remoteButton")
206
+ throw new Error(`${key}: required endpoint unavailable: ${rt.item}`);
207
+ return { resource: r, id: id ?? String(r.parent ?? r.kind), value };
208
+ }
209
+ // Collection read is also the non-mutating capability check before creating.
210
+ const list = await this.client.list(r.kind === "dock"
211
+ ? "/docks"
212
+ : r.kind === "remote"
213
+ ? `${rt.collection}?kind=${r.create?.kind ?? "IR"}`
214
+ : rt.collection);
215
+ const name = r.data.name ?? r.create?.name;
216
+ if (name && list.some((x) => hash(x.name) === hash(name)))
217
+ throw new Error(`${key}: matching name already exists; import or set its explicit id`);
218
+ return { resource: r, value: null };
219
+ }
220
+ validate(r, create) {
221
+ const rt = route(r, r.id);
222
+ if (r.kind === "integration") {
223
+ if (create)
224
+ validateRequest("/intg/setup", "POST", r.create);
225
+ return;
226
+ }
227
+ if (r.kind === "dock" && create) {
228
+ validateRequest("/docks/setup", "POST", r.create);
229
+ return;
230
+ }
231
+ if (["asset", "driverArchive", "pairing"].includes(r.kind))
232
+ return;
233
+ if (r.kind === "entity" && create) {
234
+ validateRequest("/intg/instances/{intgId}/entities/{entityId}", "POST", r.data);
235
+ return;
236
+ }
237
+ if (create && !rt.singleton) {
238
+ validateRequest(r.kind === "irCode" ? rt.itemTemplate : rt.collectionTemplate, "POST", r.create ?? r.data);
239
+ }
240
+ if (Object.keys(r.data).length)
241
+ validateRequest(rt.itemTemplate, "PATCH", r.data);
242
+ }
243
+ async validateCommands(r) {
244
+ if (![
245
+ "activity",
246
+ "macro",
247
+ "activityButton",
248
+ "activityPage",
249
+ "remoteButton",
250
+ "remotePage",
251
+ ].includes(r.kind))
252
+ return;
253
+ let entities = [];
254
+ let entityIds = [];
255
+ let live;
256
+ const own = r.kind === "activity" || r.kind === "macro";
257
+ if (own) {
258
+ const options = r.data.options;
259
+ if (isObject(options) && Array.isArray(options.entity_ids))
260
+ entityIds = options.entity_ids.map(String);
261
+ if (r.id) {
262
+ const a = await this.client.get(`/${r.kind === "activity" ? "activities" : "macros"}/${enc(r.id)}`);
263
+ live = a.options;
264
+ if (isObject(a.options) && Array.isArray(a.options.included_entities))
265
+ entities = a.options.included_entities.filter(isObject);
266
+ }
267
+ }
268
+ else if (r.kind.startsWith("activity")) {
269
+ const a = await this.client.get(`/activities/${enc(r.parent)}`);
270
+ entities =
271
+ isObject(a.options) && Array.isArray(a.options.included_entities)
272
+ ? a.options.included_entities.filter(isObject)
273
+ : Array.isArray(a.entities)
274
+ ? a.entities.filter(isObject)
275
+ : [];
276
+ if (isObject(a.options) && Array.isArray(a.options.entity_ids))
277
+ entityIds = a.options.entity_ids.map(String);
278
+ else
279
+ entityIds = entities.map((e) => String(e.entity_id));
280
+ }
281
+ const commands = [];
282
+ function collect(v) {
283
+ if (Array.isArray(v))
284
+ v.forEach(collect);
285
+ else if (isObject(v)) {
286
+ if (typeof v.cmd_id === "string")
287
+ commands.push(v);
288
+ else
289
+ Object.values(v).forEach(collect);
290
+ }
291
+ }
292
+ collect(r.data);
293
+ // Commands already live on this activity/macro, byte-for-byte, were accepted
294
+ // by the remote. Some drivers accept parameter values outside their advertised
295
+ // source_list, so re-validating an unchanged step would block unrelated edits.
296
+ const liveCommands = new Set();
297
+ (function walk(v) {
298
+ if (Array.isArray(v))
299
+ v.forEach(walk);
300
+ else if (isObject(v)) {
301
+ if (typeof v.cmd_id === "string")
302
+ liveCommands.add(commandKey(v));
303
+ else
304
+ for (const [k, x] of Object.entries(v))
305
+ if (k !== "included_entities")
306
+ walk(x);
307
+ }
308
+ })(live);
309
+ if (r.kind.endsWith("Button")) {
310
+ const layouts = await this.client.get("/cfg/device/button_layout");
311
+ const names = new Set();
312
+ function scan(x) {
313
+ if (Array.isArray(x))
314
+ x.forEach(scan);
315
+ else if (isObject(x)) {
316
+ if (typeof x.button === "string")
317
+ names.add(x.button);
318
+ Object.values(x).forEach(scan);
319
+ }
320
+ }
321
+ scan(layouts);
322
+ if (!names.has((r.id ?? "").split("/")[0]))
323
+ throw new Error(`Unsupported physical button ${r.id}`);
324
+ }
325
+ for (const c of commands) {
326
+ if (r.kind.startsWith("remote")) {
327
+ if (c.entity_id !== undefined)
328
+ throw new Error("Remote-local bindings must not contain entity_id");
329
+ }
330
+ else if (typeof c.entity_id !== "string" ||
331
+ !entityIds.includes(c.entity_id))
332
+ throw new Error(`Command ${c.cmd_id}: entity must be included in activity/macro`);
333
+ const id = String(c.entity_id ?? r.parent);
334
+ let e = entities.find((x) => x.entity_id === id);
335
+ // An entity newly added to this activity/macro is not in its live
336
+ // included_entities yet, and /entities/{id} omits entity_commands. Use the
337
+ // remote's own command metadata from another activity/macro that includes it.
338
+ if (!e)
339
+ e = await this.includedEntity(id);
340
+ if (!e)
341
+ e = await this.client.get(`/entities/${enc(id)}`);
342
+ if (!e)
343
+ throw new Error(`Missing entity ${id}`);
344
+ if (e.available === false)
345
+ throw new Error(`Entity ${id} is unavailable`);
346
+ const all = [
347
+ ...(Array.isArray(e.simple_commands) ? e.simple_commands : []),
348
+ ...(Array.isArray(e.entity_commands) ? e.entity_commands : []),
349
+ ];
350
+ if (isObject(e.options) && Array.isArray(e.options.simple_commands))
351
+ all.push(...e.options.simple_commands);
352
+ // No fabricated feature-to-command mapping: exact identifiers must be available.
353
+ if (!all.length) {
354
+ const remote = await this.client.maybe(`/remotes/${enc(id)}`);
355
+ if (remote) {
356
+ if (Array.isArray(remote.simple_commands))
357
+ all.push(...remote.simple_commands);
358
+ if (isObject(remote.options) &&
359
+ Array.isArray(remote.options.simple_commands))
360
+ all.push(...remote.options.simple_commands);
361
+ }
362
+ }
363
+ const ids = all.map((x) => typeof x === "string"
364
+ ? x
365
+ : isObject(x)
366
+ ? String(x.cmd_id ?? x.id ?? "")
367
+ : "");
368
+ if (!ids.includes(String(c.cmd_id)))
369
+ throw new Error(`Cannot verify command ${c.cmd_id} on ${id}; refresh inventory/command metadata`);
370
+ if (Array.isArray(e.entity_commands) &&
371
+ e.entity_commands.includes(c.cmd_id) &&
372
+ !liveCommands.has(commandKey(c))) {
373
+ const catalog = await this.client.get("/cfg/entity/commands");
374
+ const metadata = Array.isArray(catalog)
375
+ ? catalog.filter(isObject).find((x) => x.id === c.cmd_id)
376
+ : undefined;
377
+ if (!metadata)
378
+ throw new Error(`No parameter metadata for ${c.cmd_id}`);
379
+ validateCommandParameters(c, metadata, await this.client.get(`/entities/${enc(id)}`));
380
+ }
381
+ }
382
+ }
383
+ async assertDeletable(r, id) {
384
+ if (![
385
+ "activity",
386
+ "macro",
387
+ "remote",
388
+ "activityPage",
389
+ "remotePage",
390
+ "profilePage",
391
+ "profileGroup",
392
+ "activityGroup",
393
+ "profile",
394
+ "irCode",
395
+ "activityButton",
396
+ "remoteButton",
397
+ ].includes(r.kind))
398
+ throw new Error(`Deletion of ${r.kind} is not supported safely; detach explicitly with state forget`);
399
+ if (r.kind.endsWith("Button"))
400
+ return;
401
+ const contains = (value) => typeof value === "string"
402
+ ? value === id
403
+ : Array.isArray(value)
404
+ ? value.some(contains)
405
+ : isObject(value)
406
+ ? Object.values(value).some(contains)
407
+ : false;
408
+ const records = [];
409
+ for (const [path, field] of [
410
+ ["/activities", "entity_id"],
411
+ ["/macros", "entity_id"],
412
+ ["/profiles", "profile_id"],
413
+ ["/activity_groups", "group_id"],
414
+ ]) {
415
+ for (const item of await this.client.list(path)) {
416
+ const identifier = String(item[field]);
417
+ if (identifier !== id)
418
+ records.push({
419
+ path: `${path}/${identifier}`,
420
+ data: await this.client.get(`${path}/${enc(identifier)}`),
421
+ });
422
+ if (path === "/profiles")
423
+ for (const type of ["pages", "groups"])
424
+ for (const child of await this.client.list(`/profiles/${enc(identifier)}/${type}`)) {
425
+ if (child[type === "pages" ? "page_id" : "group_id"] !== id)
426
+ records.push({
427
+ path: `/profiles/${identifier}/${type}`,
428
+ data: child,
429
+ });
430
+ }
431
+ }
432
+ }
433
+ for (const entity of await this.client.list("/entities"))
434
+ if (entity.entity_type === "remote" && entity.entity_id !== id) {
435
+ const path = `/remotes/${enc(entity.entity_id)}`;
436
+ records.push({ path, data: await this.client.get(path) });
437
+ }
438
+ // Page definitions embedded in their parent are not inbound references.
439
+ const references = records.filter((record) => {
440
+ const data = structuredClone(record.data);
441
+ if (r.kind === "activityPage" || r.kind === "remotePage")
442
+ if (isObject(data.options) && isObject(data.options.user_interface))
443
+ delete data.options.user_interface;
444
+ if (r.kind === "irCode") {
445
+ const hasCommand = (value) => Array.isArray(value)
446
+ ? value.some(hasCommand)
447
+ : isObject(value)
448
+ ? (value.cmd_id === id &&
449
+ (value.entity_id === r.parent ||
450
+ (value.entity_id === undefined &&
451
+ record.path === `/remotes/${enc(r.parent)}`))) ||
452
+ Object.values(value).some(hasCommand)
453
+ : false;
454
+ return hasCommand(data);
455
+ }
456
+ return contains(data);
457
+ });
458
+ if (references.length)
459
+ throw new Error(`Still referenced by ${references.map((x) => x.path).join(", ")}; remove references and apply that plan first`);
460
+ if (r.kind === "profile") {
461
+ for (const child of ["pages", "groups"])
462
+ if ((await this.client.list(`/profiles/${enc(id)}/${child}`)).length)
463
+ throw new Error("Profile still contains pages/groups; remove them first");
464
+ }
465
+ }
466
+ async upload(r, update = false) {
467
+ const bytes = await verifyArtifact(r);
468
+ const form = new FormData();
469
+ const filename = r.kind === "asset"
470
+ ? `uc-${r.sha256}${extname(r.file)}`
471
+ : basename(r.file);
472
+ form.set("file", new Blob([new Uint8Array(bytes)]), filename);
473
+ const path = r.kind === "asset"
474
+ ? route(r).collection
475
+ : `/intg/install${update ? "?update=true" : ""}`;
476
+ const { data } = await this.client.request("POST", path, form);
477
+ const response = Array.isArray(data) ? data[0] : data;
478
+ if (!isObject(response))
479
+ throw new Error("Upload returned no identifier");
480
+ const id = r.kind === "asset" ? response.id : (response.driver_id ?? r.id);
481
+ if (typeof id !== "string")
482
+ throw new Error("Upload returned no identifier");
483
+ return id;
484
+ }
485
+ }
486
+ function commandKey(c) {
487
+ const params = isObject(c.params) ? c.params : {};
488
+ return JSON.stringify([
489
+ c.cmd_id,
490
+ c.entity_id ?? null,
491
+ Object.keys(params)
492
+ .sort()
493
+ .map((k) => [k, params[k]]),
494
+ ]);
495
+ }
496
+ export function validateCommandParameters(command, metadata, entity) {
497
+ const params = isObject(command.params) ? command.params : {};
498
+ const definitions = Array.isArray(metadata.params)
499
+ ? metadata.params.filter(isObject)
500
+ : [];
501
+ for (const key of Object.keys(params))
502
+ if (!definitions.some((p) => p.param === key))
503
+ throw new Error(`Unknown parameter ${key} for ${command.cmd_id}`);
504
+ for (const p of definitions) {
505
+ const key = String(p.param);
506
+ const value = params[key];
507
+ if (value === undefined) {
508
+ if (p.optional !== true)
509
+ throw new Error(`Missing parameter ${key} for ${command.cmd_id}`);
510
+ continue;
511
+ }
512
+ let valid = true;
513
+ if (p.type === "number")
514
+ valid =
515
+ typeof value === "number" &&
516
+ Number.isFinite(value) &&
517
+ (p.min === undefined || value >= Number(p.min)) &&
518
+ (p.max === undefined || value <= Number(p.max));
519
+ else if (p.type === "bool")
520
+ valid = typeof value === "boolean";
521
+ else if (p.type === "enum")
522
+ valid = Array.isArray(p.values) && p.values.includes(value);
523
+ else if (p.type === "regex") {
524
+ valid = typeof value === "string";
525
+ if (valid && typeof p.regex === "string")
526
+ valid = new RegExp(p.regex).test(value);
527
+ }
528
+ else if (p.type === "selection" && isObject(p.items)) {
529
+ const source = entity[String(p.items.source)];
530
+ const choices = isObject(source)
531
+ ? source[String(p.items.field)]
532
+ : undefined;
533
+ valid = Array.isArray(choices) && choices.includes(value);
534
+ }
535
+ else
536
+ throw new Error(`Unsupported parameter metadata for ${key}`);
537
+ if (!valid)
538
+ throw new Error(`Invalid parameter ${key} for ${command.cmd_id}`);
539
+ }
540
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};