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,236 @@
1
+ import { route, importShape } from "./adapter.js";
2
+ import { ApiError } from "./client.js";
3
+ import { isObject, secretFields } from "./util.js";
4
+ import { writable } from "./schema.js";
5
+ export async function inventory(client) {
6
+ const result = {
7
+ version: await client.version(),
8
+ collections: {},
9
+ settings: {},
10
+ unsupported: [],
11
+ };
12
+ for (const [name, path, field] of [
13
+ ["entities", "/entities", "entity_id"],
14
+ ["activities", "/activities", "entity_id"],
15
+ ["macros", "/macros", "entity_id"],
16
+ ["profiles", "/profiles", "profile_id"],
17
+ ["activityGroups", "/activity_groups", "group_id"],
18
+ ["drivers", "/intg/drivers", "driver_id"],
19
+ ["integrations", "/intg/instances", "integration_id"],
20
+ ["docks", "/docks", "dock_id"],
21
+ ["irRemotes", "/remotes?kind=IR", "entity_id"],
22
+ ["btRemotes", "/remotes?kind=BT", "entity_id"],
23
+ ["externalRemotes", "/remotes?kind=EXTERNAL", "entity_id"],
24
+ ]) {
25
+ try {
26
+ const list = await client.list(path);
27
+ const base = name === "docks" ? "/docks/devices" : path.split("?")[0];
28
+ result.collections[name] = await Promise.all(list.map((x) => client.get(`${base}/${encodeURIComponent(String(x[field]))}`)));
29
+ }
30
+ catch (e) {
31
+ if (e instanceof ApiError && [404, 405].includes(e.status))
32
+ result.unsupported.push(path);
33
+ else
34
+ throw e;
35
+ }
36
+ }
37
+ for (const section of [
38
+ "device",
39
+ "display",
40
+ "button",
41
+ "haptic",
42
+ "localization",
43
+ "power_saving",
44
+ "sound",
45
+ "bt",
46
+ "profile",
47
+ "voice_control",
48
+ ]) {
49
+ try {
50
+ result.settings[section] = await client.get(`/cfg/${section}`);
51
+ }
52
+ catch (e) {
53
+ if (e instanceof ApiError && [404, 405].includes(e.status))
54
+ result.unsupported.push(`/cfg/${section}`);
55
+ else
56
+ throw e;
57
+ }
58
+ }
59
+ return result;
60
+ }
61
+ export async function importConfig(client) {
62
+ const inv = await inventory(client);
63
+ const resources = {};
64
+ const warnings = [...inv.unsupported.map((p) => `Unsupported endpoint ${p}`)];
65
+ let serial = 0;
66
+ const add = (r) => {
67
+ const key = `${r.kind}${++serial}`;
68
+ resources[key] = r;
69
+ return key;
70
+ };
71
+ for (const [collection, kind, field] of [
72
+ ["activities", "activity", "entity_id"],
73
+ ["macros", "macro", "entity_id"],
74
+ ["profiles", "profile", "profile_id"],
75
+ ["activityGroups", "activityGroup", "group_id"],
76
+ ["integrations", "integration", "integration_id"],
77
+ ["docks", "dock", "dock_id"],
78
+ ["irRemotes", "remote", "entity_id"],
79
+ ["btRemotes", "remote", "entity_id"],
80
+ ["externalRemotes", "remote", "entity_id"],
81
+ ]) {
82
+ for (const item of inv.collections[collection] ?? []) {
83
+ const id = String(item[field]);
84
+ const r = { kind, id, data: {} };
85
+ const rt = route(r, id);
86
+ r.data = writable(rt.itemTemplate, "patch", importShape(r, item));
87
+ if (kind === "integration")
88
+ r.data = { name: item.name, driver_id: item.driver_id };
89
+ if (kind === "dock")
90
+ r.data = Object.fromEntries(Object.entries(r.data).filter(([k]) => !["token", "wifi"].includes(k)));
91
+ const secrets = secretFields(r.data);
92
+ if (secrets.length) {
93
+ warnings.push(`${id}: secret-bearing settings require manual secret() references`);
94
+ continue;
95
+ }
96
+ const key = add(r);
97
+ if (kind === "integration" || kind === "dock")
98
+ warnings.push(`${key}: existing resource imported; supply create/setup inputs and secret references to reproduce on a new target`);
99
+ if (kind === "remote" && collection !== "externalRemotes")
100
+ warnings.push(`${key}: add creation parameters from inventory for fresh-target provisioning; Bluetooth pairings require pairing steps`);
101
+ if (kind === "activity" || kind === "remote") {
102
+ const prefix = kind === "activity" ? "activities" : "remotes";
103
+ try {
104
+ const buttons = await client.get(`/${prefix}/${encodeURIComponent(id)}/buttons`);
105
+ if (!Array.isArray(buttons))
106
+ throw new Error("Unexpected button list");
107
+ for (const b of buttons.filter(isObject))
108
+ for (const press of ["short_press", "long_press"])
109
+ if (isObject(b[press]))
110
+ add({
111
+ kind: kind === "activity" ? "activityButton" : "remoteButton",
112
+ parent: { $ref: key },
113
+ id: `${b.button}/${press}`,
114
+ data: { [press]: b[press] },
115
+ });
116
+ const pages = await client.list(`/${prefix}/${encodeURIComponent(id)}/ui/pages`);
117
+ for (const p of pages)
118
+ add({
119
+ kind: kind === "activity" ? "activityPage" : "remotePage",
120
+ parent: { $ref: key },
121
+ id: String(p.page_id),
122
+ data: writable(`/${prefix}/{entityId}/ui/pages/{pageId}`, "patch", p),
123
+ });
124
+ }
125
+ catch (e) {
126
+ if (e instanceof ApiError && [404, 405].includes(e.status))
127
+ warnings.push(`${key}: button/page endpoints unavailable; customization not imported`);
128
+ else
129
+ throw e;
130
+ }
131
+ }
132
+ if (kind === "profile")
133
+ for (const child of ["pages", "groups"]) {
134
+ const list = await client.list(`/profiles/${encodeURIComponent(id)}/${child}`);
135
+ for (const c of list) {
136
+ const group = child === "groups";
137
+ add({
138
+ kind: group ? "profileGroup" : "profilePage",
139
+ parent: { $ref: key },
140
+ id: String(c[group ? "group_id" : "page_id"]),
141
+ data: writable(`/profiles/{profileId}/${child}/{${group ? "groupId" : "pageId"}}`, "patch", c),
142
+ });
143
+ }
144
+ }
145
+ if (collection === "irRemotes") {
146
+ const ir = await client.get(`/remotes/${encodeURIComponent(id)}/ir`);
147
+ if (Array.isArray(ir.codes))
148
+ for (const code of ir.codes.filter(isObject))
149
+ if (typeof code.cmd_id === "string" &&
150
+ isObject(code.code) &&
151
+ code.code.value &&
152
+ code.code.format)
153
+ add({
154
+ kind: "irCode",
155
+ parent: { $ref: key },
156
+ id: code.cmd_id,
157
+ data: {
158
+ format: code.code.format,
159
+ value: code.code.value,
160
+ },
161
+ });
162
+ }
163
+ }
164
+ }
165
+ for (const item of inv.collections.entities ?? []) {
166
+ if (["activity", "macro", "remote"].includes(String(item.entity_type)) ||
167
+ typeof item.integration_id !== "string" ||
168
+ !item.integration_id)
169
+ continue;
170
+ add({
171
+ kind: "entity",
172
+ id: String(item.entity_id),
173
+ parent: item.integration_id,
174
+ data: writable("/entities/{entityId}", "patch", item),
175
+ });
176
+ }
177
+ const usedDrivers = new Set((inv.collections.integrations ?? []).map((i) => i.driver_id));
178
+ for (const item of inv.collections.drivers ?? []) {
179
+ if (!usedDrivers.has(item.driver_id))
180
+ continue;
181
+ const data = writable("/intg/drivers/{driverId}", "patch", item);
182
+ // Credentials returned by the API must never be exported into source.
183
+ delete data.token;
184
+ if (secretFields(data).length) {
185
+ warnings.push(`${item.driver_id}: driver settings contain secrets; provide secret references manually`);
186
+ continue;
187
+ }
188
+ add({ kind: "driver", id: String(item.driver_id), data });
189
+ }
190
+ for (const [section, data] of Object.entries(inv.settings)) {
191
+ const picked = writable(`/cfg/${section}`, "patch", data);
192
+ if (section === "voice_control" &&
193
+ isObject(picked.voice_assistant) &&
194
+ Object.keys(picked.voice_assistant).length === 0) {
195
+ delete picked.voice_assistant;
196
+ warnings.push("voice_control: disabled assistant has no writable entity_id; its empty read-only representation is preserved but not owned.");
197
+ }
198
+ if (!Object.keys(picked).length)
199
+ continue;
200
+ if (!secretFields(picked).length)
201
+ add({ kind: "settings", id: section, data: picked });
202
+ else
203
+ warnings.push(`${section}: secret-bearing settings omitted; configure secret references`);
204
+ }
205
+ warnings.push("Imported arrays (page items, sequences, membership) are owned as whole fields. Review before adoption.");
206
+ warnings.push("Driver installation archives, external-service deployment and binary assets cannot be reconstructed from this import; declare their local artifacts separately.");
207
+ return { config: { schemaVersion: 1, resources }, warnings };
208
+ }
209
+ export function generateBindings(inv) {
210
+ const entities = inv.collections.entities ?? [];
211
+ const ids = entities.map((e) => String(e.entity_id));
212
+ const entries = entities.map((e, i) => {
213
+ const detail = [...(inv.collections.activities ?? [])]
214
+ .flatMap((a) => isObject(a.options) && Array.isArray(a.options.included_entities)
215
+ ? a.options.included_entities.filter(isObject)
216
+ : Array.isArray(a.entities)
217
+ ? a.entities.filter(isObject)
218
+ : [])
219
+ .find((x) => x.entity_id === e.entity_id);
220
+ const commands = [
221
+ ...(Array.isArray(detail?.entity_commands) ? detail.entity_commands : []),
222
+ ...(Array.isArray(detail?.simple_commands) ? detail.simple_commands : []),
223
+ ...(isObject(e.options) && Array.isArray(e.options.simple_commands)
224
+ ? e.options.simple_commands
225
+ : []),
226
+ ]
227
+ .map((c) => typeof c === "string"
228
+ ? c
229
+ : isObject(c)
230
+ ? String(c.cmd_id ?? c.id ?? "")
231
+ : "")
232
+ .filter(Boolean);
233
+ return ` entity${i + 1}: { id: ${JSON.stringify(e.entity_id)}, name: ${JSON.stringify(e.name)}, commands: ${JSON.stringify([...new Set(commands)])} },`;
234
+ });
235
+ return `// Generated from remote inventory. Exact command IDs; rename aliases as needed.\nexport const devices = {\n${entries.join("\n")}\n} as const;\nexport type EntityId = ${ids.length ? ids.map((id) => JSON.stringify(id)).join(" | ") : "never"};\n`;
236
+ }
@@ -0,0 +1,91 @@
1
+ export type Json = null | boolean | number | string | Json[] | {
2
+ [key: string]: Json;
3
+ };
4
+ export type ObjectValue = {
5
+ [key: string]: Json;
6
+ };
7
+ export type Ref = {
8
+ $ref: string;
9
+ };
10
+ export type Secret = {
11
+ $secret: string;
12
+ version?: string;
13
+ };
14
+ export type Id = string | Ref;
15
+ export type Kind = "activity" | "macro" | "remote" | "profile" | "profilePage" | "profileGroup" | "activityGroup" | "activityPage" | "remotePage" | "activityButton" | "remoteButton" | "settings" | "driver" | "integration" | "entity" | "dock" | "irCode" | "pairing" | "asset" | "driverArchive";
16
+ export interface Resource {
17
+ kind: Kind;
18
+ id?: string;
19
+ parent?: Id;
20
+ data: ObjectValue;
21
+ create?: ObjectValue;
22
+ dependsOn?: string[];
23
+ file?: string;
24
+ sha256?: string;
25
+ resourceType?: string;
26
+ }
27
+ export interface Config {
28
+ schemaVersion: 1;
29
+ resources: Record<string, Resource>;
30
+ }
31
+ export interface Version {
32
+ model: string;
33
+ address: string;
34
+ api: string;
35
+ core: string;
36
+ [key: string]: unknown;
37
+ }
38
+ export interface Target {
39
+ host: string;
40
+ identity: string;
41
+ version: Version;
42
+ tokenEnv: string;
43
+ }
44
+ export interface Binding {
45
+ id: string;
46
+ resource: Resource;
47
+ baseline: ObjectValue;
48
+ setupHash?: string;
49
+ }
50
+ export interface SetupCheckpoint {
51
+ key: string;
52
+ kind: "integration" | "dock";
53
+ id: string;
54
+ resource: Resource;
55
+ beforeIds: string[];
56
+ existingId?: string;
57
+ status: string;
58
+ }
59
+ export interface State {
60
+ version: 1;
61
+ revision: number;
62
+ identity: string;
63
+ bindings: Record<string, Binding>;
64
+ setups: Record<string, SetupCheckpoint>;
65
+ }
66
+ export interface Observation {
67
+ id?: string;
68
+ value: ObjectValue | null;
69
+ resource: Resource;
70
+ }
71
+ export interface Operation {
72
+ key: string;
73
+ action: "create" | "update" | "adopt" | "forget" | "delete" | "reconfigure";
74
+ resource: Resource;
75
+ id?: string;
76
+ before: ObjectValue | null;
77
+ desired: ObjectValue;
78
+ }
79
+ export interface Plan {
80
+ version: 1;
81
+ adapter: string;
82
+ target: Target;
83
+ stateRevision: number;
84
+ configHash: string;
85
+ operations: Operation[];
86
+ deferred: string[];
87
+ conflicts: string[];
88
+ createdAt: string;
89
+ digest: string;
90
+ }
91
+ export declare const emptyState: (identity: string) => State;
package/dist/model.js ADDED
@@ -0,0 +1,7 @@
1
+ export const emptyState = (identity) => ({
2
+ version: 1,
3
+ revision: 0,
4
+ identity,
5
+ bindings: {},
6
+ setups: {},
7
+ });
@@ -0,0 +1,12 @@
1
+ import type { Config, ObjectValue, Plan, State, Target } from "./model.js";
2
+ import { Adapter } from "./adapter.js";
3
+ export interface PlanOptions {
4
+ overwriteDrift?: boolean;
5
+ adoptDrift?: boolean;
6
+ prune?: boolean;
7
+ }
8
+ export declare function configurationView(live: ObjectValue, shape: ObjectValue, baseline?: ObjectValue): ObjectValue;
9
+ export declare function makePlan(config: Config, target: Target, state: State, adapter: Adapter, options?: PlanOptions): Promise<Plan>;
10
+ export declare const planDigest: (plan: Plan) => string;
11
+ export declare function checkPlan(plan: Plan): void;
12
+ export declare function formatPlan(plan: Plan): string;
@@ -0,0 +1,248 @@
1
+ import { ADAPTER, desired } from "./adapter.js";
2
+ import { order, validateConfig } from "./compiler.js";
3
+ import { equal, get, hash, isObject, leaves, merge, project, references, resolveRefs, redact, } from "./util.js";
4
+ export function configurationView(live, shape, baseline) {
5
+ const view = project(live, shape);
6
+ function secrets(target, wanted, prior) {
7
+ for (const [k, v] of Object.entries(wanted))
8
+ if (isObject(v)) {
9
+ if ("$secret" in v) {
10
+ if (prior?.[k])
11
+ target[k] = prior[k];
12
+ else
13
+ delete target[k];
14
+ }
15
+ else {
16
+ const child = isObject(target[k]) ? target[k] : {};
17
+ secrets(child, v, isObject(prior?.[k]) ? prior[k] : undefined);
18
+ if (Object.keys(child).length || isObject(target[k]))
19
+ target[k] = child;
20
+ }
21
+ }
22
+ }
23
+ secrets(view, shape, baseline);
24
+ return view;
25
+ }
26
+ export async function makePlan(config, target, state, adapter, options = {}) {
27
+ validateConfig(config);
28
+ await adapter.client.verifyTarget(target);
29
+ if (state.identity !== target.identity)
30
+ throw new Error("State belongs to a different remote");
31
+ if (options.overwriteDrift && options.adoptDrift)
32
+ throw new Error("Choose either adopt or overwrite drift");
33
+ const operations = [];
34
+ const deferred = [];
35
+ const conflicts = [];
36
+ const changed = new Set();
37
+ // Existing resources can be adopted and referenced in one read-only planning pass.
38
+ const observedState = structuredClone(state);
39
+ for (const key of order(config)) {
40
+ const original = config.resources[key];
41
+ const deps = [...references(original), ...(original.dependsOn ?? [])];
42
+ if (deps.some((dep) => !observedState.bindings[dep] || changed.has(dep))) {
43
+ deferred.push(key);
44
+ changed.add(key);
45
+ continue;
46
+ }
47
+ try {
48
+ if (state.setups[key]) {
49
+ deferred.push(key);
50
+ changed.add(key);
51
+ continue;
52
+ }
53
+ const obs = await adapter.observe(original, observedState, key);
54
+ const r = obs.resource;
55
+ const want = desired(r);
56
+ const bound = state.bindings[key];
57
+ if (bound && r.id && bound.id !== r.id)
58
+ throw new Error("Explicit id differs from saved mapping; use a different logical key or explicit state adoption");
59
+ if (bound &&
60
+ (bound.resource.kind !== r.kind ||
61
+ !equal(resolveRefs(bound.resource.parent, state), r.parent)))
62
+ throw new Error("Resource kind/parent changed; explicit migration required");
63
+ if (bound &&
64
+ r.kind === "integration" &&
65
+ r.create &&
66
+ !equal(bound.resource.create, original.create)) {
67
+ const old = resolveRefs(bound.resource, state);
68
+ if (old.create?.driver_id !== r.create.driver_id ||
69
+ old.data.device_id !== r.data.device_id)
70
+ throw new Error("Integration driver/device identity changed; explicit replacement required");
71
+ if (!equal(old.create?.setup_data, r.create.setup_data)) {
72
+ if (!obs.value)
73
+ throw new Error("Integration missing");
74
+ operations.push({
75
+ key,
76
+ action: "reconfigure",
77
+ resource: original,
78
+ id: obs.id,
79
+ before: configurationView(obs.value, merge(bound.baseline, want), bound.baseline),
80
+ desired: want,
81
+ });
82
+ changed.add(key);
83
+ continue;
84
+ }
85
+ }
86
+ if (bound &&
87
+ ["dock", "remote", "entity"].includes(r.kind) &&
88
+ r.create &&
89
+ !equal(bound.resource.create, original.create)) {
90
+ const immutable = (x) => Object.fromEntries(Object.entries(x.create ?? {}).filter(([k]) => !["name", "icon", "description"].includes(k)));
91
+ if (!equal(immutable(bound.resource), immutable(original)))
92
+ throw new Error("Immutable provisioning inputs changed; explicit replacement required");
93
+ }
94
+ if (!obs.value && bound)
95
+ throw new Error("Managed resource disappeared; reconcile its state before recreation");
96
+ if (!obs.value) {
97
+ adapter.validate(r, true);
98
+ if (![
99
+ "asset",
100
+ "driverArchive",
101
+ "entity",
102
+ "integration",
103
+ "dock",
104
+ "activity",
105
+ "macro",
106
+ ].includes(r.kind))
107
+ await adapter.validateCommands(r);
108
+ const initial = ["activity", "macro"].includes(r.kind)
109
+ ? (r.create ?? want)
110
+ : want;
111
+ if (!equal(initial, want))
112
+ deferred.push(key);
113
+ operations.push({
114
+ key,
115
+ action: "create",
116
+ resource: original,
117
+ ...(obs.id ? { id: obs.id } : {}),
118
+ before: null,
119
+ desired: initial,
120
+ });
121
+ changed.add(key);
122
+ continue;
123
+ }
124
+ adapter.validate(r, false);
125
+ let effective = want;
126
+ const before = configurationView(obs.value, merge(bound?.baseline ?? {}, want), bound?.baseline);
127
+ const conflictsHere = [];
128
+ if (bound)
129
+ for (const [path, previous] of leaves(bound.baseline)) {
130
+ const intended = get(want, path);
131
+ if (intended === undefined)
132
+ continue;
133
+ const current = get(before, path);
134
+ if (!equal(current, previous) && !equal(current, intended))
135
+ conflictsHere.push(path.join("."));
136
+ }
137
+ if (conflictsHere.length && !options.overwriteDrift) {
138
+ if (options.adoptDrift) {
139
+ // Adoption requires editing source to current values before the next apply.
140
+ throw new Error(`Drift at ${conflictsHere.join(", ")}; import current configuration and update source to adopt it`);
141
+ }
142
+ throw new Error(`Drift at ${conflictsHere.join(", ")}; import/adopt live changes or explicitly plan --overwrite-drift`);
143
+ }
144
+ const changedData = !equal(configurationView(obs.value, effective, bound?.baseline), effective);
145
+ if (changedData)
146
+ await adapter.validateCommands({
147
+ ...r,
148
+ ...(obs.id ? { id: obs.id } : {}),
149
+ });
150
+ if (changedData ||
151
+ !bound ||
152
+ !equal(bound.baseline, effective) ||
153
+ !equal(bound.resource, original)) {
154
+ operations.push({
155
+ key,
156
+ action: changedData ? "update" : "adopt",
157
+ resource: original,
158
+ id: obs.id,
159
+ before,
160
+ desired: effective,
161
+ });
162
+ if (changedData)
163
+ changed.add(key);
164
+ else if (obs.id)
165
+ observedState.bindings[key] = {
166
+ id: obs.id,
167
+ resource: original,
168
+ baseline: effective,
169
+ };
170
+ }
171
+ }
172
+ catch (e) {
173
+ conflicts.push(`${key}: ${e.message}`);
174
+ changed.add(key);
175
+ }
176
+ }
177
+ for (const key of Object.keys(state.bindings))
178
+ if (!config.resources[key]) {
179
+ if (!options.prune) {
180
+ conflicts.push(`${key}: managed resource removed from source; plan --prune to delete or state forget to relinquish ownership`);
181
+ continue;
182
+ }
183
+ const binding = state.bindings[key];
184
+ try {
185
+ await adapter.assertDeletable(resolveRefs(binding.resource, state), binding.id);
186
+ const obs = await adapter.observe(binding.resource, state, key);
187
+ if (!obs.value)
188
+ throw new Error("Resource already absent; use state forget");
189
+ operations.push({
190
+ key,
191
+ action: "delete",
192
+ resource: binding.resource,
193
+ id: binding.id,
194
+ before: configurationView(obs.value, binding.baseline, binding.baseline),
195
+ desired: binding.baseline,
196
+ });
197
+ }
198
+ catch (e) {
199
+ conflicts.push(`${key}: ${e.message}`);
200
+ }
201
+ }
202
+ const plan = {
203
+ version: 1,
204
+ adapter: ADAPTER,
205
+ target,
206
+ stateRevision: state.revision,
207
+ configHash: hash(config),
208
+ operations,
209
+ deferred,
210
+ conflicts,
211
+ createdAt: new Date().toISOString(),
212
+ digest: "",
213
+ };
214
+ plan.digest = planDigest(plan);
215
+ return plan;
216
+ }
217
+ export const planDigest = (plan) => hash({ ...plan, digest: "" });
218
+ export function checkPlan(plan) {
219
+ if (plan.version !== 1 ||
220
+ plan.adapter !== ADAPTER ||
221
+ plan.digest !== planDigest(plan))
222
+ throw new Error("Invalid, modified or incompatible plan; regenerate it");
223
+ if (plan.conflicts.length)
224
+ throw new Error(`Plan has conflicts:\n${plan.conflicts.join("\n")}`);
225
+ }
226
+ export function formatPlan(plan) {
227
+ return [
228
+ `Target: ${plan.target.host} (${plan.target.version.model}, core ${plan.target.version.core})`,
229
+ ...plan.operations.flatMap((op) => {
230
+ const header = `${op.action === "create" ? "+" : op.action === "delete" ? "-" : op.action === "adopt" ? "=" : "~"} ${op.action} ${op.key} (${op.resource.kind}${op.id ? `, ${op.id}` : ""})`;
231
+ const display = (value) => {
232
+ const text = value === undefined ? "(absent)" : JSON.stringify(redact(value));
233
+ return text.length > 180
234
+ ? text.slice(0, 180) + "… (full value in saved plan)"
235
+ : text;
236
+ };
237
+ const changes = leaves(op.desired)
238
+ .filter(([path, v]) => !equal(get(op.before, path), v))
239
+ .map(([path, v]) => ` ${path.join(".")}: ${display(get(op.before, path))} -> ${display(v)}`);
240
+ if (op.action === "reconfigure")
241
+ changes.push(` setup inputs: ${display(op.resource.create?.setup_data)}`);
242
+ return [header, ...changes];
243
+ }),
244
+ ...plan.deferred.map((key) => `… deferred ${key}: apply prerequisites/resume setup, then replan`),
245
+ ...plan.conflicts.map((c) => `! ${c}`),
246
+ `${plan.operations.length} operations, ${plan.deferred.length} deferred, ${plan.conflicts.length} conflicts`,
247
+ ].join("\n");
248
+ }
@@ -0,0 +1,4 @@
1
+ import { Adapter } from "./adapter.js";
2
+ import type { Journal } from "./engine.js";
3
+ import type { Plan, State, Target } from "./model.js";
4
+ export declare function rollbackPlan(journal: Journal, target: Target, state: State, adapter: Adapter): Promise<Plan>;
@@ -0,0 +1,63 @@
1
+ import { ADAPTER } from "./adapter.js";
2
+ import { configurationView, planDigest } from "./planner.js";
3
+ import { equal, get, hash, leaves, merge } from "./util.js";
4
+ export async function rollbackPlan(journal, target, state, adapter) {
5
+ await adapter.client.verifyTarget(target);
6
+ const operations = [];
7
+ const conflicts = [];
8
+ for (const entry of [...journal.entries].reverse()) {
9
+ const b = state.bindings[entry.key];
10
+ if (!b) {
11
+ conflicts.push(`${entry.key}: missing mapping; deletion/uncertain creation requires explicit recovery`);
12
+ continue;
13
+ }
14
+ if (entry.before === null) {
15
+ conflicts.push(`${entry.key}: created resource has no prior image; remove it through a separate prune plan`);
16
+ continue;
17
+ }
18
+ if (["integration", "dock", "driverArchive", "asset", "pairing"].includes(b.resource.kind)) {
19
+ conflicts.push(`${entry.key}: provisioning/pairing/upload side effects require explicit recovery`);
20
+ continue;
21
+ }
22
+ const obs = await adapter.observe(b.resource, state, entry.key);
23
+ if (!obs.value) {
24
+ conflicts.push(`${entry.key}: no live resource`);
25
+ continue;
26
+ }
27
+ const shape = merge(b.baseline, entry.before);
28
+ const current = configurationView(obs.value, shape, b.baseline);
29
+ if (!equal(configurationView(obs.value, b.baseline, b.baseline), b.baseline)) {
30
+ conflicts.push(`${entry.key}: changed after apply; reconcile drift before rollback`);
31
+ continue;
32
+ }
33
+ const absent = leaves(b.baseline).filter(([path]) => get(entry.before, path) === undefined);
34
+ if (absent.length) {
35
+ conflicts.push(`${entry.key}: fields absent before apply require explicit clearing: ${absent.map(([p]) => p.join(".")).join(", ")}`);
36
+ continue;
37
+ }
38
+ const r = { ...b.resource, data: entry.before };
39
+ adapter.validate(r, false);
40
+ operations.push({
41
+ key: entry.key,
42
+ action: "update",
43
+ id: b.id,
44
+ resource: r,
45
+ before: current,
46
+ desired: entry.before,
47
+ });
48
+ }
49
+ const plan = {
50
+ version: 1,
51
+ adapter: ADAPTER,
52
+ target,
53
+ stateRevision: state.revision,
54
+ configHash: hash(journal),
55
+ operations,
56
+ deferred: [],
57
+ conflicts,
58
+ createdAt: new Date().toISOString(),
59
+ digest: "",
60
+ };
61
+ plan.digest = planDigest(plan);
62
+ return plan;
63
+ }
@@ -0,0 +1,4 @@
1
+ import type { ObjectValue } from "./model.js";
2
+ export declare const spec: any;
3
+ export declare function validateRequest(path: string, method: string, data: unknown): void;
4
+ export declare function writable(path: string, method: string, value: ObjectValue): ObjectValue;