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,32 @@
1
+ import { Adapter } from "./adapter.js";
2
+ import type { ObjectValue, Plan, SetupCheckpoint, State } from "./model.js";
3
+ export interface Journal {
4
+ planDigest: string;
5
+ status: "running" | "paused" | "failed" | "complete";
6
+ entries: Array<{
7
+ key: string;
8
+ status: "intent" | "created" | "verified" | "awaiting-user";
9
+ id?: string;
10
+ before: ObjectValue | null;
11
+ }>;
12
+ }
13
+ export declare class PendingSetup extends Error {
14
+ }
15
+ export interface Store {
16
+ save(state: State): Promise<void>;
17
+ journal(journal: Journal): Promise<void>;
18
+ }
19
+ export declare class Engine {
20
+ readonly adapter: Adapter;
21
+ readonly store: Store;
22
+ constructor(adapter: Adapter, store: Store);
23
+ apply(plan: Plan, state: State): Promise<{
24
+ state: State;
25
+ journal: Journal;
26
+ }>;
27
+ private precondition;
28
+ private create;
29
+ private update;
30
+ finishSetup(setup: SetupCheckpoint, state: State): Promise<string>;
31
+ resume(state: State): Promise<string[]>;
32
+ }
package/dist/engine.js ADDED
@@ -0,0 +1,276 @@
1
+ import { route } from "./adapter.js";
2
+ import { resolveSecrets } from "./client.js";
3
+ import { configurationView, checkPlan } from "./planner.js";
4
+ import { equal, hash, isObject, merge, project, resolveRefs } from "./util.js";
5
+ import { validateRequest } from "./schema.js";
6
+ export class PendingSetup extends Error {
7
+ }
8
+ export class Engine {
9
+ adapter;
10
+ store;
11
+ constructor(adapter, store) {
12
+ this.adapter = adapter;
13
+ this.store = store;
14
+ }
15
+ async apply(plan, state) {
16
+ checkPlan(plan);
17
+ await this.adapter.client.verifyTarget(plan.target);
18
+ if (state.identity !== plan.target.identity ||
19
+ state.revision !== plan.stateRevision)
20
+ throw new Error("State changed since plan; replan");
21
+ // Validate every precondition before issuing any writes. Only proven adoption
22
+ // IDs can be used for dependent preflight reads; newly created IDs are deferred.
23
+ const observedState = structuredClone(state);
24
+ for (const op of plan.operations) {
25
+ await this.precondition(op, observedState);
26
+ if (op.action === "adopt" && op.id)
27
+ observedState.bindings[op.key] = {
28
+ id: op.id,
29
+ resource: op.resource,
30
+ baseline: op.desired,
31
+ };
32
+ }
33
+ const journal = {
34
+ planDigest: plan.digest,
35
+ status: "running",
36
+ entries: [],
37
+ };
38
+ await this.store.journal(journal);
39
+ try {
40
+ for (const op of plan.operations) {
41
+ await this.precondition(op, state);
42
+ const r = resolveRefs(op.resource, state);
43
+ let id = op.id;
44
+ const entry = {
45
+ key: op.key,
46
+ status: "intent",
47
+ ...(id ? { id } : {}),
48
+ before: op.before,
49
+ };
50
+ journal.entries.push(entry);
51
+ await this.store.journal(journal);
52
+ if (op.action === "delete") {
53
+ await this.adapter.assertDeletable(r, id);
54
+ const rt = route(r, id);
55
+ const path = r.kind.endsWith("Button")
56
+ ? `${rt.item}/${encodeURIComponent((r.id ?? "").split("/")[1])}`
57
+ : rt.item;
58
+ await this.adapter.client.request("DELETE", path);
59
+ if (await this.adapter.client.maybe(path))
60
+ throw new Error(`${op.key}: deletion not verified`);
61
+ delete state.bindings[op.key];
62
+ state.revision++;
63
+ await this.store.save(state);
64
+ entry.status = "verified";
65
+ await this.store.journal(journal);
66
+ continue;
67
+ }
68
+ if (op.action === "create" ||
69
+ op.action === "reconfigure" ||
70
+ (op.action === "update" &&
71
+ ["asset", "driverArchive"].includes(r.kind))) {
72
+ id =
73
+ op.action === "update"
74
+ ? await this.adapter.upload(r, true)
75
+ : await this.create(op, r, state, journal);
76
+ entry.id = id;
77
+ entry.status = "created";
78
+ await this.store.journal(journal);
79
+ // ID is durable before configuration writes; on failure baseline remains empty.
80
+ state.bindings[op.key] = { id, resource: op.resource, baseline: {} };
81
+ state.revision++;
82
+ await this.store.save(state);
83
+ }
84
+ if (!id)
85
+ throw new Error(`${op.key}: missing identity`);
86
+ if (op.action !== "adopt" &&
87
+ !["asset", "driverArchive"].includes(r.kind))
88
+ await this.update(r, id, op.desired);
89
+ const obs = await this.adapter.observe({ ...op.resource, id }, state, op.key);
90
+ const actual = obs.value && configurationView(obs.value, op.desired, op.desired);
91
+ if (!equal(actual, op.desired))
92
+ throw new Error(`${op.key}: read-back differs from desired configuration`);
93
+ state.bindings[op.key] = {
94
+ id,
95
+ resource: op.resource,
96
+ baseline: op.desired,
97
+ };
98
+ state.revision++;
99
+ await this.store.save(state);
100
+ entry.status = "verified";
101
+ await this.store.journal(journal);
102
+ }
103
+ journal.status = "complete";
104
+ await this.store.journal(journal);
105
+ return { state, journal };
106
+ }
107
+ catch (e) {
108
+ journal.status = e instanceof PendingSetup ? "paused" : "failed";
109
+ await this.store.journal(journal);
110
+ throw e;
111
+ }
112
+ }
113
+ async precondition(op, state) {
114
+ const obs = await this.adapter.observe(op.resource, state, op.key);
115
+ const current = obs.value &&
116
+ configurationView(obs.value, merge(state.bindings[op.key]?.baseline ?? {}, op.desired), state.bindings[op.key]?.baseline);
117
+ if (!equal(current, op.before))
118
+ throw new Error(`${op.key}: remote changed since plan; replan`);
119
+ }
120
+ async create(op, r, state, journal) {
121
+ this.adapter.validate(r, true);
122
+ if (r.kind === "asset" || r.kind === "driverArchive")
123
+ return this.adapter.upload(r);
124
+ if (r.kind === "integration" || r.kind === "dock") {
125
+ const path = r.kind === "integration" ? "/intg/setup" : "/docks/setup";
126
+ const before = await this.adapter.client.list(r.kind === "integration" ? "/intg/instances" : "/docks");
127
+ const ids = before.map((x) => String(x[r.kind === "integration" ? "integration_id" : "dock_id"]));
128
+ if (r.kind === "integration") {
129
+ state.setups[op.key] = {
130
+ key: op.key,
131
+ kind: "integration",
132
+ id: String(r.create?.driver_id),
133
+ resource: op.resource,
134
+ beforeIds: ids,
135
+ ...(op.action === "reconfigure" ? { existingId: op.id } : {}),
136
+ status: "STARTING",
137
+ };
138
+ state.revision++;
139
+ await this.store.save(state);
140
+ }
141
+ const setupBody = await resolveSecrets(op.action === "reconfigure"
142
+ ? { ...r.create, reconfigure: true }
143
+ : r.create);
144
+ validateRequest(path, "POST", setupBody);
145
+ const { data } = await this.adapter.client.request("POST", path, setupBody);
146
+ if (typeof data.id !== "string")
147
+ throw new Error("Setup returned no id");
148
+ const setup = {
149
+ key: op.key,
150
+ kind: r.kind,
151
+ id: data.id,
152
+ resource: op.resource,
153
+ beforeIds: ids,
154
+ ...(op.action === "reconfigure" ? { existingId: op.id } : {}),
155
+ status: String(data.state),
156
+ };
157
+ state.setups[op.key] = setup;
158
+ state.revision++;
159
+ await this.store.save(state);
160
+ journal.entries.at(-1).id = data.id;
161
+ journal.entries.at(-1).status = "awaiting-user";
162
+ await this.store.journal(journal);
163
+ return this.finishSetup(setup, state);
164
+ }
165
+ if (r.kind === "entity") {
166
+ const path = `/intg/instances/${encodeURIComponent(String(r.parent))}/entities/${encodeURIComponent(String(r.create?.entity_id))}`;
167
+ const { data } = await this.adapter.client.request("POST", path, await resolveSecrets(r.data));
168
+ if (typeof data.entity_id !== "string")
169
+ throw new Error("Entity provisioning returned no entity_id");
170
+ return data.entity_id;
171
+ }
172
+ const rt = route(r, op.id);
173
+ const path = r.kind === "irCode" ? rt.item : rt.collection;
174
+ const createBody = await resolveSecrets(r.create ?? r.data);
175
+ validateRequest(r.kind === "irCode" ? rt.itemTemplate : rt.collectionTemplate, "POST", createBody);
176
+ const { data } = await this.adapter.client.request("POST", path, createBody);
177
+ const id = data[rt.idField] ?? op.id ?? r.id;
178
+ if (typeof id !== "string")
179
+ throw new Error(`${op.key}: create returned no ${rt.idField}`);
180
+ return id;
181
+ }
182
+ async update(r, id, data) {
183
+ const rt = route(r, id);
184
+ if (r.kind === "pairing") {
185
+ const live = await this.adapter.client.get(rt.item);
186
+ if (live.paired === true) {
187
+ if (!equal(project(live, data), data))
188
+ throw new Error("Bluetooth peer differs; explicit unpairing/replacement required");
189
+ return;
190
+ }
191
+ if (live.pairing_enabled !== true)
192
+ await this.adapter.client.request("PUT", `${rt.item}?enabled=true`);
193
+ throw new PendingSetup(`Bluetooth pairing enabled for ${String(r.parent)}. Pair on the device; use pairing respond for any passkey, then replan.`);
194
+ }
195
+ if (!Object.keys(data).length)
196
+ return;
197
+ let body = data;
198
+ const current = await this.adapter.client.get(rt.item);
199
+ body = Object.fromEntries(Object.entries(data).map(([k, v]) => [
200
+ k,
201
+ isObject(v) && !("$secret" in v) && isObject(current[k])
202
+ ? merge(current[k], v)
203
+ : v,
204
+ ]));
205
+ if (["activity", "macro"].includes(r.kind) && isObject(body.options)) {
206
+ for (const k of [
207
+ "included_entities",
208
+ "editable",
209
+ "activity_group",
210
+ "button_mapping",
211
+ "user_interface",
212
+ ])
213
+ delete body.options[k];
214
+ }
215
+ if (r.kind === "integration") {
216
+ body = Object.fromEntries(Object.entries(body).filter(([k]) => !["driver_id", "device_id"].includes(k)));
217
+ }
218
+ if (r.kind === "driver") {
219
+ body = Object.fromEntries(Object.entries(body).filter(([k]) => k !== "driver_id"));
220
+ }
221
+ if (Object.keys(body).length) {
222
+ validateRequest(rt.itemTemplate, "PATCH", body);
223
+ const resolvedBody = await resolveSecrets(body);
224
+ validateRequest(rt.itemTemplate, "PATCH", resolvedBody);
225
+ await this.adapter.client.request("PATCH", rt.item, resolvedBody);
226
+ }
227
+ }
228
+ async finishSetup(setup, state) {
229
+ const r = resolveRefs(setup.resource, state);
230
+ const base = setup.kind === "integration" ? "/intg/setup" : "/docks/setup";
231
+ const status = await this.adapter.client.get(`${base}/${encodeURIComponent(setup.id)}`);
232
+ setup.status = String(status.state);
233
+ await this.store.save(state);
234
+ if (status.state === "ERROR")
235
+ throw new Error(`${setup.key}: setup failed (${String(status.error)}); inspect setup status before restarting`);
236
+ if (status.state !== "OK")
237
+ throw new PendingSetup(`${setup.key}: ${status.state}; run setup status/resume${status.state === "WAIT_USER_ACTION" ? " and provide the requested input" : ""}`);
238
+ const list = await this.adapter.client.list(setup.kind === "integration" ? "/intg/instances" : "/docks");
239
+ const field = setup.kind === "integration" ? "integration_id" : "dock_id";
240
+ const matches = list.filter((x) => (setup.existingId
241
+ ? x[field] === setup.existingId
242
+ : !setup.beforeIds.includes(String(x[field]))) &&
243
+ (setup.kind === "dock"
244
+ ? x.dock_id === setup.id
245
+ : x.driver_id === r.create?.driver_id &&
246
+ (!r.data.device_id || x.device_id === r.data.device_id)));
247
+ if (matches.length !== 1)
248
+ throw new Error(`${setup.key}: setup finished but resulting identity is ambiguous; inspect inventory and adopt explicitly`);
249
+ const id = String(matches[0][field]);
250
+ state.bindings[setup.key] = {
251
+ id,
252
+ resource: setup.resource,
253
+ baseline: {},
254
+ setupHash: hash(setup.resource.create),
255
+ };
256
+ delete state.setups[setup.key];
257
+ state.revision++;
258
+ await this.store.save(state);
259
+ return id;
260
+ }
261
+ async resume(state) {
262
+ const messages = [];
263
+ for (const s of Object.values(state.setups))
264
+ try {
265
+ const id = await this.finishSetup(s, state);
266
+ messages.push(`${s.key}: provisioned ${id}; replan configuration`);
267
+ }
268
+ catch (e) {
269
+ if (e instanceof PendingSetup)
270
+ messages.push(e.message);
271
+ else
272
+ throw e;
273
+ }
274
+ return messages;
275
+ }
276
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./dsl.js";
2
+ export type { Config, Resource, Ref, Secret, Id, Json, ObjectValue, } from "./model.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from "./dsl.js";
package/dist/init.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export interface InitResult {
2
+ written: string[];
3
+ skipped: string[];
4
+ packageJsonUpdated: boolean;
5
+ }
6
+ /** Scaffold a private config workspace. Never overwrites existing files. */
7
+ export declare function init(dir: string, version: string): Promise<InitResult>;
package/dist/init.js ADDED
@@ -0,0 +1,124 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ const agents = (version) => `# Remote 3 configuration (uc-config)
4
+
5
+ This folder holds the user's Unfolded Circle Remote 3 configuration. It is
6
+ managed with the \`uc-config\` CLI (v${version}). Reference docs ship with the package:
7
+
8
+ - node_modules/uc-config/README.md: setup, diagnostics, Integration Manager
9
+ - node_modules/uc-config/docs/snippets.md: recipes for common tasks (start here)
10
+ - node_modules/uc-config/docs/cli.md: every command, recovery
11
+ - node_modules/uc-config/docs/configuration-authoring.md: config syntax and ownership
12
+
13
+ ## First run (no .uc/targets/ yet)
14
+
15
+ 1. \`npx uc-config connect home --host http://<IP>\`. Ask the user for the IP if not given.
16
+ 2. Ask the user to run \`npx uc-config auth\` in their own terminal (the web
17
+ configurator must be enabled on the remote). Never ask for the PIN in chat.
18
+ 3. \`npx uc-config doctor\`
19
+ 4. \`npx uc-config inventory --bindings generated/devices.ts\`
20
+ 5. \`npx uc-config import --out remote.config.ts\`
21
+ 6. \`npx uc-config compile && npx uc-config plan --out .uc/plan.json\`. Expect only \`= adopt\` operations.
22
+ 7. \`npx uc-config apply .uc/plan.json --adopt-only && npx uc-config check\`
23
+ 8. \`git init && git add -A && git commit -m "Import Remote 3 config"\`
24
+
25
+ If \`.uc/\` already exists, reuse it. Don't re-auth, re-import or re-adopt.
26
+
27
+ ## Every change
28
+
29
+ Edit remote.config.ts, then: \`npx tsc --noEmit\`, \`npx uc-config compile\`,
30
+ \`npx uc-config plan --out .uc/plan.json\`, review, \`npx uc-config apply .uc/plan.json\`,
31
+ \`npx uc-config check\` (must be 0 operations).
32
+
33
+ - Use only entity IDs and cmd_ids from generated/devices.ts or a fresh inventory.
34
+ Never invent command names.
35
+ - A command must belong to an entity in the activity's/macro's \`entity_ids\`.
36
+ - Arrays (entity_ids, sequences, page items) are replaced whole: keep existing
37
+ entries and order.
38
+ - Preserve resource keys and native \`id\`s. Never hand-edit files in .uc/.
39
+ - The plan must contain only the intended change. Anything else: stop and ask.
40
+ - Never use --overwrite-drift or --prune without the user's say-so.
41
+ - Never put secrets in remote.config.ts or print .uc/credentials.json.
42
+
43
+ ## When something is broken
44
+
45
+ Run \`npx uc-config diagnose\`. It is read-only and prints a \`fix:\` per issue.
46
+ Re-adding a dropped entity the integration still offers is safe to do
47
+ directly. Ask before anything else. Integration installs and updates belong to
48
+ the UC Integration Manager (http://<remote>:9999); run diagnose after any update.
49
+
50
+ ## Upgrading the tool
51
+
52
+ \`npm update uc-config\`, then compile and plan. The plan must show 0 operations
53
+ before you make any other change.
54
+ `;
55
+ const tsconfig = {
56
+ compilerOptions: {
57
+ target: "ES2023",
58
+ module: "NodeNext",
59
+ moduleResolution: "NodeNext",
60
+ strict: true,
61
+ noEmit: true,
62
+ skipLibCheck: true,
63
+ },
64
+ include: ["remote.config.ts", "generated/**/*.ts"],
65
+ };
66
+ const gitignore = `node_modules/
67
+ # Credentials, state and journals. Back up .uc/state and .uc/journals privately.
68
+ .uc/
69
+ .env
70
+ .env.*
71
+ `;
72
+ /** Scaffold a private config workspace. Never overwrites existing files. */
73
+ export async function init(dir, version) {
74
+ await mkdir(dir, { recursive: true });
75
+ const result = {
76
+ written: [],
77
+ skipped: [],
78
+ packageJsonUpdated: false,
79
+ };
80
+ const put = async (name, contents) => {
81
+ try {
82
+ await writeFile(join(dir, name), contents, { flag: "wx" });
83
+ result.written.push(name);
84
+ }
85
+ catch (e) {
86
+ if (e.code !== "EEXIST")
87
+ throw e;
88
+ result.skipped.push(name);
89
+ }
90
+ };
91
+ const pkgPath = join(dir, "package.json");
92
+ let pkg;
93
+ try {
94
+ pkg = JSON.parse(await readFile(pkgPath, "utf8"));
95
+ }
96
+ catch (e) {
97
+ if (e.code !== "ENOENT")
98
+ throw e;
99
+ }
100
+ if (!pkg) {
101
+ await put("package.json", JSON.stringify({
102
+ name: "my-remote",
103
+ private: true,
104
+ type: "module",
105
+ dependencies: { "uc-config": `^${version}` },
106
+ devDependencies: { typescript: "^5.9.3" },
107
+ }, null, 2) + "\n");
108
+ }
109
+ else if (!pkg.dependencies?.["uc-config"] &&
110
+ !pkg.devDependencies?.["uc-config"]) {
111
+ pkg.dependencies = { ...pkg.dependencies, "uc-config": `^${version}` };
112
+ if (!pkg.type)
113
+ pkg.type = "module";
114
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
115
+ result.packageJsonUpdated = true;
116
+ }
117
+ else {
118
+ result.skipped.push("package.json");
119
+ }
120
+ await put("tsconfig.json", JSON.stringify(tsconfig, null, 2) + "\n");
121
+ await put(".gitignore", gitignore);
122
+ await put("AGENTS.md", agents(version));
123
+ return result;
124
+ }
@@ -0,0 +1,14 @@
1
+ import { CoreClient } from "./client.js";
2
+ import type { Config, ObjectValue } from "./model.js";
3
+ export interface Inventory {
4
+ version: unknown;
5
+ collections: Record<string, ObjectValue[]>;
6
+ settings: Record<string, ObjectValue>;
7
+ unsupported: string[];
8
+ }
9
+ export declare function inventory(client: CoreClient): Promise<Inventory>;
10
+ export declare function importConfig(client: CoreClient): Promise<{
11
+ config: Config;
12
+ warnings: string[];
13
+ }>;
14
+ export declare function generateBindings(inv: Inventory): string;