uc-config 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,22 @@ npx uc-config init --refresh-docs
9
9
  npx uc-config compile && npx uc-config plan # must show 0 operations
10
10
  ```
11
11
 
12
+ ## 0.3.0
13
+
14
+ Action required: run `npx uc-config init --refresh-docs`. The agent
15
+ instructions now start every change with `sync`.
16
+
17
+ - New `sync` command: pulls the live remote into `remote.config.ts`, local
18
+ state and `generated/devices.ts` in one step, with a three-way merge. Remote
19
+ edits are pulled, unapplied local edits are kept, edits on both sides are
20
+ reported as conflicts and left alone. Remote-only resources are added and
21
+ remote deletions (confirmed by a direct re-read) are removed. Never writes to
22
+ the remote. `--dry-run` reports without writing.
23
+ - On a folder with no `remote.config.ts`, `sync` does the whole first import:
24
+ import, bindings and adoption. Setup is now `init`, `sync`, `check`.
25
+ - `sync` only rewrites the plain form that `import` writes. A config using
26
+ helpers or code is refused with a pointer to the manual steps.
27
+
12
28
  ## 0.2.5
13
29
 
14
30
  Action required: none.
package/README.md CHANGED
@@ -162,21 +162,17 @@ npm run uc -- auth
162
162
  # 4. Verify connectivity and API coverage
163
163
  npm run uc -- doctor
164
164
 
165
- # 5. Snapshot what is on the remote
166
- npm run uc -- inventory --bindings generated/devices.ts # entity IDs + commands
167
- npm run uc -- import --out remote.config.ts # editable config
168
- npm run uc -- diagnose # health check
165
+ # 5. Import and take ownership (local only; writes nothing to the remote).
166
+ # Writes remote.config.ts and generated/devices.ts, and adopts everything.
167
+ npm run uc -- sync
168
+ npm run uc -- diagnose # health check
169
169
 
170
- # 6. Take ownership (local only; writes nothing to the remote)
170
+ # 6. Confirm convergence
171
171
  npm run uc -- compile
172
- npm run uc -- plan --out .uc/plan.json # expect only "= adopt" operations
173
- npm run uc -- apply .uc/plan.json --adopt-only
174
-
175
- # 7. Confirm convergence
176
172
  npm run uc -- check # 0 operations, 0 conflicts
177
173
  ```
178
174
 
179
- After step 7 the workspace is ready. Every later change follows the edit loop below.
175
+ After step 6 the workspace is ready. Every later change follows the edit loop below.
180
176
 
181
177
  Notes for agents:
182
178
 
@@ -194,7 +190,11 @@ Notes for agents:
194
190
 
195
191
  ## The edit loop
196
192
 
193
+ The remote is the source of truth and this folder is a working copy, so pull
194
+ first: `sync` brings in anything changed on the remote since the last run.
195
+
197
196
  ```sh
197
+ npm run uc -- sync # pull remote edits; keeps your unapplied edits
198
198
  # edit remote.config.ts
199
199
  npm run check:examples # typecheck config + examples
200
200
  npm run uc -- compile # evaluate TS -> .uc/build.json (offline)
@@ -210,7 +210,7 @@ needs no apply. See [docs/snippets.md](docs/snippets.md) for common edits.
210
210
 
211
211
  **If the plan touches things you didn't edit, stop.** Those operations are
212
212
  earlier source changes that were never applied, or edits made on the remote
213
- since. Don't apply them along with your change: sync with the live remote first
213
+ since. Don't apply them along with your change: run `sync` first
214
214
  (see [Pull changes made in the web configurator](docs/snippets.md#pull-changes-made-in-the-web-configurator)),
215
215
  then replan until only your change is left.
216
216
 
@@ -289,12 +289,14 @@ buttons, pages, macros).
289
289
  > **Integration updates can drop configured entities**, even when the manager
290
290
  > says configuration is preserved. Every activity using them becomes orphaned.
291
291
  > Keep the manager's _Auto update_ setting off, and after every integration
292
- > update run:
292
+ > update run this in your config workspace:
293
293
  >
294
294
  > ```sh
295
- > npm run uc -- diagnose && npm run uc -- plan
295
+ > npx uc-config diagnose && npx uc-config plan
296
296
  > ```
297
297
  >
298
+ > (From a clone of this repo, use `npm run uc -- diagnose && npm run uc -- plan`.)
299
+ >
298
300
  > Updates marked as _not preserving configuration_ additionally require
299
301
  > re-running that integration's setup.
300
302
 
@@ -305,8 +307,9 @@ with `npx uc-config init` (see [Quick start](#quick-start)), in a **private**
305
307
  git repo. `init` gitignores `.uc/`, which holds credentials.
306
308
 
307
309
  Alternatively, use `--workspace <dir>` to point the CLI at any directory holding
308
- `remote.config.ts` and `.uc/`. Back up `.uc/state` and `.uc/journals`
309
- privately; losing them makes the next plan an adoption pass.
310
+ `remote.config.ts` and `.uc/`. Nothing here is irreplaceable: the remote holds
311
+ the configuration, and if `.uc/state` or `remote.config.ts` is lost, `sync`
312
+ rebuilds both from it. Git is useful as a history of changes, not as a backup.
310
313
 
311
314
  ## Reference
312
315
 
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { writeFile, mkdir } from "node:fs/promises";
3
+ import { writeFile, mkdir, readFile, rename } from "node:fs/promises";
4
+ import { syncConfig, parseSource, renderSource, formatSync, remoteGone, } from "./sync.js";
4
5
  import { resolve, join } from "node:path";
5
6
  import { createInterface } from "node:readline/promises";
6
7
  import { Writable } from "node:stream";
@@ -297,6 +298,74 @@ program
297
298
  await saveJson(local("import-warnings.json"), warnings);
298
299
  console.log(`Wrote ${o.out}; no ownership or remote changes.\n${warnings.map((w) => `- ${w}`).join("\n")}`);
299
300
  });
301
+ program
302
+ .command("sync")
303
+ .description("Pull the live remote into remote.config.ts and state; keeps unapplied local edits. Never writes to the remote.")
304
+ .option("--target <name>", "target", defaultTarget)
305
+ .option("--config <file>", "TypeScript source", "remote.config.ts")
306
+ .option("--bindings <file>", "device bindings to refresh", "generated/devices.ts")
307
+ .option("--dry-run", "report what would change without writing files")
308
+ .action(async (o) => {
309
+ await withLock(local(`locks/${targetName(o.target)}.lock`), async () => {
310
+ const { target, client, state, adapter } = await load(o.target);
311
+ await client.verifyTarget(target);
312
+ if (state.identity !== target.identity)
313
+ throw new Error("State belongs to a different remote");
314
+ try {
315
+ const journal = await readJson(journalFile(o.target));
316
+ if (journal.status === "failed" || journal.status === "running")
317
+ throw new Error("Previous apply is unresolved; run resume before syncing");
318
+ }
319
+ catch (e) {
320
+ if (e.code !== "ENOENT")
321
+ throw e;
322
+ }
323
+ const file = resolve(root(), o.config);
324
+ let text;
325
+ try {
326
+ text = await readFile(file, "utf8");
327
+ }
328
+ catch (e) {
329
+ if (e.code !== "ENOENT")
330
+ throw e;
331
+ }
332
+ // First run: no config yet. Import and adopt everything as it is.
333
+ if (text === undefined && Object.keys(state.bindings).length)
334
+ throw new Error(`${o.config} is missing but local state still manages ${Object.keys(state.bindings).length} resources. Restore the file (e.g. from git) rather than re-importing.`);
335
+ const source = text === undefined
336
+ ? { schemaVersion: 1, resources: {} }
337
+ : parseSource(text);
338
+ if (!source)
339
+ throw new Error(`${o.config} isn't in the plain form import writes (it uses helpers or code), so sync can't rewrite it without losing that. Use the manual steps in docs/snippets.md ("Pull changes made in the web configurator").`);
340
+ validateConfig(source);
341
+ const inv = await inventory(client);
342
+ const { config: live } = await importConfig(client);
343
+ const result = await syncConfig(source, state, live, remoteGone(adapter, state));
344
+ validateConfig(result.config);
345
+ const firstRun = text === undefined;
346
+ console.log(firstRun
347
+ ? `Imported and adopted ${result.changes.length} resources from the remote.`
348
+ : formatSync(result.changes));
349
+ const conflicts = result.changes.some((c) => c.action === "conflict");
350
+ if (conflicts)
351
+ process.exitCode = 2;
352
+ if (o.dryRun)
353
+ return console.log("Dry run: no files written.");
354
+ const touched = result.changes.some((c) => ["pull", "add", "remove", "applied"].includes(c.action));
355
+ if (touched) {
356
+ const tmp = `${file}.sync.tmp`;
357
+ await writeFile(tmp, renderSource(result.config), { mode: 0o600 });
358
+ await saveJson(stateFile(o.target), result.state);
359
+ await rename(tmp, file);
360
+ }
361
+ const bindings = resolve(root(), o.bindings);
362
+ await mkdir(resolve(bindings, ".."), { recursive: true });
363
+ await writeFile(bindings, generateBindings(inv));
364
+ console.log(touched
365
+ ? `Updated ${o.config}, ${o.bindings} and local state. No remote changes. Next: compile, then plan should show only your own edits.`
366
+ : `${o.config} already matches the remote. Refreshed ${o.bindings}.`);
367
+ });
368
+ });
300
369
  program
301
370
  .command("compile")
302
371
  .description("Compile TypeScript configuration into a local JSON snapshot")
package/dist/init.js CHANGED
@@ -40,19 +40,28 @@ exists, and skip steps that are done:
40
40
  enabled on the remote). Never ask for the PIN in chat, and never run \`auth\`
41
41
  yourself; it needs a real terminal.
42
42
  3. \`npx uc-config doctor\`
43
- 4. \`npx uc-config inventory --bindings generated/devices.ts\`
44
- 5. \`npx uc-config import --out remote.config.ts\`
45
- 6. \`npx uc-config compile && npx uc-config plan --out .uc/plan.json\`. Expect only \`= adopt\` operations.
46
- 7. \`npx uc-config apply .uc/plan.json --adopt-only && npx uc-config check\`
47
- 8. \`git init && git add -A && git commit -m "Import Remote 3 config"\`
43
+ 4. \`npx uc-config sync\`. With no remote.config.ts yet, it imports the remote,
44
+ writes remote.config.ts and generated/devices.ts, and adopts everything. It
45
+ never writes to the remote.
46
+ 5. \`npx uc-config compile && npx uc-config check\` (must be 0 operations).
47
+ 6. \`git init && git add -A && git commit -m "Import Remote 3 config"\`
48
48
 
49
- If \`.uc/\` already exists, reuse it. Don't re-auth, re-import or re-adopt.
49
+ If \`.uc/\` already exists, reuse it. Don't re-auth. If remote.config.ts or
50
+ \`.uc/state\` is lost, the remote still has everything: run sync again.
50
51
 
51
52
  ## Every change
52
53
 
53
- Edit remote.config.ts, then: \`npx tsc --noEmit\`, \`npx uc-config compile\`,
54
- \`npx uc-config plan --out .uc/plan.json\`, review, \`npx uc-config apply .uc/plan.json\`,
55
- \`npx uc-config check\` (must be 0 operations).
54
+ The remote is the source of truth; this folder is a working copy of it.
55
+
56
+ 1. \`npx uc-config sync\` first. It pulls edits made on the remote (web
57
+ configurator, driver updates) into remote.config.ts and refreshes
58
+ generated/devices.ts, keeping any unapplied local edits. If it reports a
59
+ conflict (\`!\`), stop and ask the user which value to keep.
60
+ 2. Edit remote.config.ts.
61
+ 3. \`npx tsc --noEmit\`, \`npx uc-config compile\`,
62
+ \`npx uc-config plan --out .uc/plan.json\`, review,
63
+ \`npx uc-config apply .uc/plan.json\`, \`npx uc-config check\` (must be 0 operations).
64
+ 4. Commit, so git keeps a history of what changed.
56
65
 
57
66
  - Use only entity IDs and cmd_ids from generated/devices.ts or a fresh inventory.
58
67
  Never invent command names.
package/dist/sync.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { Adapter } from "./adapter.js";
2
+ import type { Config, ObjectValue, State } from "./model.js";
3
+ /**
4
+ * What sync did (or would do) to one resource.
5
+ *
6
+ * - pull: changed on the remote, unchanged locally; source and baseline now match live.
7
+ * - add: exists only on the remote; added to source and adopted.
8
+ * - remove: deleted on the remote; removed from source and ownership dropped.
9
+ * - local: unapplied local edit, remote unchanged; kept for the next plan.
10
+ * - applied: the local edit is already live; baseline updated.
11
+ * - conflict: changed both locally and on the remote; nothing touched.
12
+ */
13
+ export interface SyncChange {
14
+ key: string;
15
+ action: "pull" | "add" | "remove" | "local" | "applied" | "conflict";
16
+ fields?: string[];
17
+ note?: string;
18
+ }
19
+ export interface SyncResult {
20
+ config: Config;
21
+ state: State;
22
+ changes: SyncChange[];
23
+ }
24
+ export declare function changedFields(a: ObjectValue, b: ObjectValue): string[];
25
+ /**
26
+ * Three-way merge of the live remote into local source and state.
27
+ *
28
+ * `live` is a fresh `import` of the remote. Source resources are matched to live
29
+ * ones by (kind, parent's native id, native id), never by logical key, so
30
+ * hand-chosen keys survive. `confirmGone` re-reads a resource the import did not
31
+ * return, so an endpoint the import skipped is never mistaken for a deletion.
32
+ * Pure apart from `confirmGone`; never writes to the remote.
33
+ */
34
+ export declare function syncConfig(source: Config, state: State, live: Config, confirmGone: (key: string) => Promise<boolean>): Promise<SyncResult>;
35
+ /** confirmGone for syncConfig: true only when a direct read proves absence. */
36
+ export declare const remoteGone: (adapter: Adapter, state: State) => (key: string) => Promise<boolean>;
37
+ /** Source text in the form `import` writes: plain JSON inside defineRemote(). */
38
+ export declare function renderSource(config: Config): string;
39
+ /**
40
+ * The config from an imported-form remote.config.ts, or null when the file uses
41
+ * code (helpers, variables, imports) that sync can't rewrite without losing it.
42
+ */
43
+ export declare function parseSource(text: string): Config | null;
44
+ export declare function formatSync(changes: SyncChange[]): string;
package/dist/sync.js ADDED
@@ -0,0 +1,241 @@
1
+ import { configurationView } from "./planner.js";
2
+ import { equal, get, isObject, leaves, merge, references } from "./util.js";
3
+ /** Kinds that `import` reproduces, so their absence from an import means something. */
4
+ const IMPORTED = new Set([
5
+ "activity",
6
+ "macro",
7
+ "profile",
8
+ "profilePage",
9
+ "profileGroup",
10
+ "activityGroup",
11
+ "activityPage",
12
+ "remotePage",
13
+ "activityButton",
14
+ "remoteButton",
15
+ "integration",
16
+ "dock",
17
+ "remote",
18
+ "irCode",
19
+ "entity",
20
+ "driver",
21
+ "settings",
22
+ ]);
23
+ export function changedFields(a, b) {
24
+ const paths = new Map();
25
+ for (const [p] of [...leaves(a), ...leaves(b)])
26
+ paths.set(p.join("."), p);
27
+ return [...paths]
28
+ .filter(([, p]) => !equal(get(a, p), get(b, p)))
29
+ .map(([name]) => name)
30
+ .sort();
31
+ }
32
+ const identity = (kind, parent, id) => JSON.stringify([kind, parent ?? "", id]);
33
+ /**
34
+ * Three-way merge of the live remote into local source and state.
35
+ *
36
+ * `live` is a fresh `import` of the remote. Source resources are matched to live
37
+ * ones by (kind, parent's native id, native id), never by logical key, so
38
+ * hand-chosen keys survive. `confirmGone` re-reads a resource the import did not
39
+ * return, so an endpoint the import skipped is never mistaken for a deletion.
40
+ * Pure apart from `confirmGone`; never writes to the remote.
41
+ */
42
+ export async function syncConfig(source, state, live, confirmGone) {
43
+ const config = structuredClone(source);
44
+ const next = structuredClone(state);
45
+ const changes = [];
46
+ const sourceParent = (p) => {
47
+ if (typeof p === "string")
48
+ return p;
49
+ if (isObject(p) && typeof p.$ref === "string")
50
+ return next.bindings[p.$ref]?.id ?? config.resources[p.$ref]?.id;
51
+ return undefined;
52
+ };
53
+ const sourceIndex = new Map();
54
+ for (const [key, r] of Object.entries(config.resources)) {
55
+ const id = next.bindings[key]?.id ?? r.id;
56
+ if (id)
57
+ sourceIndex.set(identity(r.kind, sourceParent(r.parent), id), key);
58
+ }
59
+ const liveParent = (p) => {
60
+ if (typeof p === "string")
61
+ return p;
62
+ if (isObject(p) && typeof p.$ref === "string")
63
+ return live.resources[p.$ref]?.id;
64
+ return undefined;
65
+ };
66
+ const liveToSource = new Map();
67
+ const seen = new Set();
68
+ for (const [liveKey, lr] of Object.entries(live.resources)) {
69
+ if (!lr.id)
70
+ continue;
71
+ const ident = identity(lr.kind, liveParent(lr.parent), lr.id);
72
+ seen.add(ident);
73
+ const key = sourceIndex.get(ident);
74
+ if (key) {
75
+ liveToSource.set(liveKey, key);
76
+ const s = config.resources[key];
77
+ const binding = next.bindings[key];
78
+ if (!binding)
79
+ continue; // in source but never adopted: plan adopts it
80
+ const S = s.data;
81
+ const B = binding.baseline;
82
+ const liveOwned = configurationView(lr.data, B, B);
83
+ const sourceChanged = !equal(S, B);
84
+ const liveChanged = !equal(liveOwned, B);
85
+ if (!liveChanged) {
86
+ if (sourceChanged)
87
+ changes.push({ key, action: "local", fields: changedFields(B, S) });
88
+ continue;
89
+ }
90
+ if (!sourceChanged) {
91
+ changes.push({
92
+ key,
93
+ action: "pull",
94
+ fields: changedFields(B, liveOwned),
95
+ });
96
+ s.data = liveOwned;
97
+ binding.baseline = structuredClone(liveOwned);
98
+ binding.resource = structuredClone(s);
99
+ continue;
100
+ }
101
+ const liveWanted = configurationView(lr.data, merge(B, S), B);
102
+ if (equal(liveWanted, S)) {
103
+ changes.push({ key, action: "applied", fields: changedFields(B, S) });
104
+ binding.baseline = structuredClone(S);
105
+ binding.resource = structuredClone(s);
106
+ continue;
107
+ }
108
+ changes.push({
109
+ key,
110
+ action: "conflict",
111
+ fields: [
112
+ ...new Set([...changedFields(B, S), ...changedFields(B, liveOwned)]),
113
+ ].sort(),
114
+ note: "changed locally and on the remote; resolve by hand",
115
+ });
116
+ continue;
117
+ }
118
+ // Only on the remote: add under the import's readable key, adopted as-is.
119
+ let parent = lr.parent;
120
+ if (isObject(lr.parent) && typeof lr.parent.$ref === "string") {
121
+ const mapped = liveToSource.get(lr.parent.$ref);
122
+ if (!mapped)
123
+ continue; // parent is in conflict or unmatched; skip child
124
+ parent = { $ref: mapped };
125
+ }
126
+ let newKey = liveKey;
127
+ for (let n = 2; newKey in config.resources; n++)
128
+ newKey = `${liveKey}_${n}`;
129
+ const r = {
130
+ ...structuredClone(lr),
131
+ ...(parent ? { parent } : {}),
132
+ };
133
+ config.resources[newKey] = r;
134
+ next.bindings[newKey] = {
135
+ id: lr.id,
136
+ resource: structuredClone(r),
137
+ baseline: structuredClone(lr.data),
138
+ };
139
+ liveToSource.set(liveKey, newKey);
140
+ sourceIndex.set(ident, newKey);
141
+ changes.push({ key: newKey, action: "add" });
142
+ }
143
+ // Owned resources the import no longer returns.
144
+ const removed = new Set();
145
+ for (const [key, r] of Object.entries(config.resources)) {
146
+ const binding = next.bindings[key];
147
+ if (!binding || !IMPORTED.has(r.kind))
148
+ continue;
149
+ if (seen.has(identity(r.kind, sourceParent(r.parent), binding.id)))
150
+ continue;
151
+ if (!(await confirmGone(key)))
152
+ continue;
153
+ if (!equal(r.data, binding.baseline)) {
154
+ changes.push({
155
+ key,
156
+ action: "conflict",
157
+ note: "deleted on the remote but has unapplied local edits",
158
+ });
159
+ continue;
160
+ }
161
+ removed.add(key);
162
+ }
163
+ // Children of a removed resource go with it.
164
+ for (let grew = true; grew;) {
165
+ grew = false;
166
+ for (const [key, r] of Object.entries(config.resources))
167
+ if (!removed.has(key) &&
168
+ [...references(r), ...(r.dependsOn ?? [])].some((d) => removed.has(d))) {
169
+ removed.add(key);
170
+ grew = true;
171
+ }
172
+ }
173
+ for (const key of removed) {
174
+ delete config.resources[key];
175
+ delete next.bindings[key];
176
+ changes.push({ key, action: "remove" });
177
+ }
178
+ if (!equal(next.bindings, state.bindings))
179
+ next.revision = state.revision + 1;
180
+ return { config, state: next, changes };
181
+ }
182
+ /** confirmGone for syncConfig: true only when a direct read proves absence. */
183
+ export const remoteGone = (adapter, state) => async (key) => {
184
+ const b = state.bindings[key];
185
+ if (!b)
186
+ return false;
187
+ try {
188
+ const obs = await adapter.observe({ ...b.resource, id: b.id }, state, key);
189
+ return (obs.value === null ||
190
+ (b.resource.kind.endsWith("Button") &&
191
+ Object.keys(obs.value).length === 0));
192
+ }
193
+ catch {
194
+ return false; // can't prove it's gone; leave it alone
195
+ }
196
+ };
197
+ const HEADER = "import { defineRemote } from 'uc-config';\n\n";
198
+ /** Source text in the form `import` writes: plain JSON inside defineRemote(). */
199
+ export function renderSource(config) {
200
+ return `${HEADER}export default defineRemote(${JSON.stringify(config, null, 2)});\n`;
201
+ }
202
+ /**
203
+ * The config from an imported-form remote.config.ts, or null when the file uses
204
+ * code (helpers, variables, imports) that sync can't rewrite without losing it.
205
+ */
206
+ export function parseSource(text) {
207
+ const m = /^\s*import\s*\{\s*defineRemote\s*\}\s*from\s*['"]uc-config['"];?\s*export\s+default\s+defineRemote\(([\s\S]*)\);?\s*$/.exec(text);
208
+ if (!m)
209
+ return null;
210
+ try {
211
+ const value = JSON.parse(m[1]);
212
+ return isObject(value) && isObject(value.resources)
213
+ ? value
214
+ : null;
215
+ }
216
+ catch {
217
+ return null;
218
+ }
219
+ }
220
+ export function formatSync(changes) {
221
+ const sign = {
222
+ pull: "<",
223
+ add: "+",
224
+ remove: "-",
225
+ local: "~",
226
+ applied: "=",
227
+ conflict: "!",
228
+ };
229
+ const words = {
230
+ pull: "pulled from remote",
231
+ add: "added from remote",
232
+ remove: "deleted on remote",
233
+ local: "unapplied local edit kept",
234
+ applied: "local edit already live",
235
+ conflict: "conflict",
236
+ };
237
+ const lines = changes.map((c) => `${sign[c.action]} ${c.key}: ${words[c.action]}${c.fields?.length ? ` (${c.fields.join(", ")})` : ""}${c.note ? `; ${c.note}` : ""}`);
238
+ const count = (a) => changes.filter((c) => c.action === a).length;
239
+ lines.push(`${count("pull")} pulled, ${count("add")} added, ${count("remove")} removed, ${count("local")} local edits kept, ${count("conflict")} conflicts`);
240
+ return lines.join("\n");
241
+ }
package/docs/cli.md CHANGED
@@ -19,6 +19,7 @@ the workspace's only target if exactly one is connected; otherwise `home`.
19
19
  | `diagnose [--json]` | no | Orphaned entity references, disconnected integrations, suggested fixes. |
20
20
  | `inventory [--out f] [--bindings f.ts]` | no | Raw (redacted) remote state; optional typed entity/command bindings. |
21
21
  | `import [--out f.ts]` | no | Generate editable config from the live remote. Never overwrites. |
22
+ | `sync [--dry-run]` | no | Pull the live remote into source, state and bindings; keeps local edits. |
22
23
  | `compile [--config f] [--out f]` | no | Evaluate TS config into `.uc/build.json`. Offline. |
23
24
  | `plan [--out f] [--prune] [--overwrite-drift]` | no | Three-way diff: source vs last-applied vs live. |
24
25
  | `apply <plan> [--adopt-only]` | yes | Execute a saved plan after re-verifying preconditions. |
package/docs/snippets.md CHANGED
@@ -277,15 +277,36 @@ npm run uc -- apply .uc/plan.json --adopt-only
277
277
  ## Pull changes made in the web configurator
278
278
 
279
279
  ```sh
280
- npm run uc -- import --out .uc/imported-$(date +%F).config.ts
281
- npm run uc -- compile --config .uc/imported-$(date +%F).config.ts --out .uc/imported-build.json
280
+ npx uc-config sync --dry-run # what would change; writes nothing
281
+ npx uc-config sync # update remote.config.ts, generated/devices.ts and state
282
+ ```
283
+
284
+ `sync` never writes to the remote. Per resource it prints:
285
+
286
+ | Mark | Meaning | What sync does |
287
+ | ---- | ---------------------------------------------- | ---------------------------------------------- |
288
+ | `<` | changed on the remote only | copies the live value into source and state |
289
+ | `+` | exists only on the remote | adds it to source and adopts it |
290
+ | `-` | deleted on the remote (confirmed by a re-read) | removes it (and its pages/buttons) from source |
291
+ | `~` | unapplied local edit, remote unchanged | keeps it; the next plan applies it |
292
+ | `=` | local edit already live | updates state |
293
+ | `!` | changed both locally and on the remote | touches nothing; exit 2. Pick a value by hand |
294
+
295
+ Resources are matched by kind and native id, so keys you renamed are kept. Run
296
+ it before every change; afterwards `plan` contains only your own edits.
297
+
298
+ `sync` rewrites `remote.config.ts`, so it only runs on the plain form that
299
+ `import`/`sync` write. If you converted the file to helpers or added code, sync
300
+ refuses. Then pull by hand:
301
+
302
+ ```sh
303
+ npx uc-config import --out .uc/imported-$(date +%F).config.ts
304
+ npx uc-config compile --config .uc/imported-$(date +%F).config.ts --out .uc/imported-build.json
282
305
  diff <(jq -S . .uc/build.json) <(jq -S . .uc/imported-build.json) | less
283
306
  ```
284
307
 
285
- Imported keys come from display names (`activity.watch_tv`), so they usually
286
- match yours, but a renamed activity gets a different key. Match resources by
287
- `kind` + `id` and copy the changed fields into `remote.config.ts`. Then `plan` should report
288
- 0 operations.
308
+ Match resources by `kind` + `id`, copy the changed fields into
309
+ `remote.config.ts`, and `plan` should report 0 operations.
289
310
 
290
311
  ## Undo the last apply
291
312
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uc-config",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Configuration-as-code for the Unfolded Circle Remote 3, designed to be driven by coding agents",
6
6
  "license": "MIT",