uc-config 0.2.5 → 0.3.1

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,38 @@ 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.1
13
+
14
+ Action required: optional, run `npx uc-config init --refresh-docs` to get the
15
+ backup instructions in AGENTS.md.
16
+
17
+ - `backup` takes no arguments now: it saves the remote's full native backup to
18
+ `backups/<model>-<timestamp>.tar` (extension from the remote's filename) and
19
+ never overwrites, so older backups are kept. `--out` still works.
20
+ - `backup --list` lists existing backups without contacting the remote.
21
+ `doctor` reports how many there are, or suggests making the first one.
22
+ - `backup` writes `backups/.gitignore` so archives (unencrypted, with
23
+ integration credentials) stay out of git even in existing workspaces. New
24
+ workspaces also ignore `backups/` in the top-level `.gitignore`.
25
+ - AGENTS.md: agents offer a first backup when none exists, always ask before
26
+ running one, and never restore.
27
+
28
+ ## 0.3.0
29
+
30
+ Action required: run `npx uc-config init --refresh-docs`. The agent
31
+ instructions now start every change with `sync`.
32
+
33
+ - New `sync` command: pulls the live remote into `remote.config.ts`, local
34
+ state and `generated/devices.ts` in one step, with a three-way merge. Remote
35
+ edits are pulled, unapplied local edits are kept, edits on both sides are
36
+ reported as conflicts and left alone. Remote-only resources are added and
37
+ remote deletions (confirmed by a direct re-read) are removed. Never writes to
38
+ the remote. `--dry-run` reports without writing.
39
+ - On a folder with no `remote.config.ts`, `sync` does the whole first import:
40
+ import, bindings and adoption. Setup is now `init`, `sync`, `check`.
41
+ - `sync` only rewrites the plain form that `import` writes. A config using
42
+ helpers or code is refused with a pointer to the manual steps.
43
+
12
44
  ## 0.2.5
13
45
 
14
46
  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";
@@ -16,7 +17,7 @@ import { rollbackPlan } from "./recovery.js";
16
17
  import { validateRequest } from "./schema.js";
17
18
  import { diagnose, formatDiagnosis } from "./diagnose.js";
18
19
  import { init } from "./init.js";
19
- import { readFileSync, readdirSync } from "node:fs";
20
+ import { readFileSync, readdirSync, statSync } from "node:fs";
20
21
  const pkgVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
21
22
  const program = new Command()
22
23
  .name("uc-config")
@@ -266,6 +267,10 @@ program
266
267
  process.exitCode = 1;
267
268
  }
268
269
  }
270
+ const backups = listBackups(root());
271
+ console.log(backups.length
272
+ ? `Full backups: ${backups.length} (latest ${backups.at(-1).file})`
273
+ : "No full backup yet. Offer the user one: npx uc-config backup");
269
274
  });
270
275
  program
271
276
  .command("inventory")
@@ -297,6 +302,74 @@ program
297
302
  await saveJson(local("import-warnings.json"), warnings);
298
303
  console.log(`Wrote ${o.out}; no ownership or remote changes.\n${warnings.map((w) => `- ${w}`).join("\n")}`);
299
304
  });
305
+ program
306
+ .command("sync")
307
+ .description("Pull the live remote into remote.config.ts and state; keeps unapplied local edits. Never writes to the remote.")
308
+ .option("--target <name>", "target", defaultTarget)
309
+ .option("--config <file>", "TypeScript source", "remote.config.ts")
310
+ .option("--bindings <file>", "device bindings to refresh", "generated/devices.ts")
311
+ .option("--dry-run", "report what would change without writing files")
312
+ .action(async (o) => {
313
+ await withLock(local(`locks/${targetName(o.target)}.lock`), async () => {
314
+ const { target, client, state, adapter } = await load(o.target);
315
+ await client.verifyTarget(target);
316
+ if (state.identity !== target.identity)
317
+ throw new Error("State belongs to a different remote");
318
+ try {
319
+ const journal = await readJson(journalFile(o.target));
320
+ if (journal.status === "failed" || journal.status === "running")
321
+ throw new Error("Previous apply is unresolved; run resume before syncing");
322
+ }
323
+ catch (e) {
324
+ if (e.code !== "ENOENT")
325
+ throw e;
326
+ }
327
+ const file = resolve(root(), o.config);
328
+ let text;
329
+ try {
330
+ text = await readFile(file, "utf8");
331
+ }
332
+ catch (e) {
333
+ if (e.code !== "ENOENT")
334
+ throw e;
335
+ }
336
+ // First run: no config yet. Import and adopt everything as it is.
337
+ if (text === undefined && Object.keys(state.bindings).length)
338
+ 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.`);
339
+ const source = text === undefined
340
+ ? { schemaVersion: 1, resources: {} }
341
+ : parseSource(text);
342
+ if (!source)
343
+ 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").`);
344
+ validateConfig(source);
345
+ const inv = await inventory(client);
346
+ const { config: live } = await importConfig(client);
347
+ const result = await syncConfig(source, state, live, remoteGone(adapter, state));
348
+ validateConfig(result.config);
349
+ const firstRun = text === undefined;
350
+ console.log(firstRun
351
+ ? `Imported and adopted ${result.changes.length} resources from the remote.`
352
+ : formatSync(result.changes));
353
+ const conflicts = result.changes.some((c) => c.action === "conflict");
354
+ if (conflicts)
355
+ process.exitCode = 2;
356
+ if (o.dryRun)
357
+ return console.log("Dry run: no files written.");
358
+ const touched = result.changes.some((c) => ["pull", "add", "remove", "applied"].includes(c.action));
359
+ if (touched) {
360
+ const tmp = `${file}.sync.tmp`;
361
+ await writeFile(tmp, renderSource(result.config), { mode: 0o600 });
362
+ await saveJson(stateFile(o.target), result.state);
363
+ await rename(tmp, file);
364
+ }
365
+ const bindings = resolve(root(), o.bindings);
366
+ await mkdir(resolve(bindings, ".."), { recursive: true });
367
+ await writeFile(bindings, generateBindings(inv));
368
+ console.log(touched
369
+ ? `Updated ${o.config}, ${o.bindings} and local state. No remote changes. Next: compile, then plan should show only your own edits.`
370
+ : `${o.config} already matches the remote. Refreshed ${o.bindings}.`);
371
+ });
372
+ });
300
373
  program
301
374
  .command("compile")
302
375
  .description("Compile TypeScript configuration into a local JSON snapshot")
@@ -533,20 +606,60 @@ program
533
606
  });
534
607
  program
535
608
  .command("backup")
609
+ .description("Full native backup of the remote into backups/ (timestamped; never overwrites). Briefly stops integrations and docks.")
536
610
  .option("--target <name>", "target", defaultTarget)
537
- .requiredOption("--out <file>", "native backup archive")
611
+ .option("--out <file>", "archive path (default: backups/<timestamp>)")
612
+ .option("--list", "list existing backups; no remote access")
538
613
  .action(async (o) => {
614
+ if (o.list) {
615
+ const list = listBackups(root());
616
+ console.log(list.length
617
+ ? list.map((b) => `${b.file} ${b.size}`).join("\n")
618
+ : "No full backups in backups/. Run: npx uc-config backup");
619
+ return;
620
+ }
539
621
  await withLock(local(`locks/${targetName(o.target)}.lock`), async () => {
540
622
  const { client, target } = await load(o.target);
541
623
  await client.verifyTarget(target);
542
- console.log("Exporting native backup: the remote temporarily stops integrations and docks. Keep the archive private; it may contain credentials.");
543
- const bytes = await client.download("/system/backup/export");
544
- const file = resolve(root(), o.out);
545
- await mkdir(resolve(file, ".."), { recursive: true });
624
+ console.log("Exporting full backup: the remote stops integrations and docks for a few seconds, then restarts them.");
625
+ const { bytes, filename } = await client.downloadFile("/system/backup/export");
626
+ const ext = /\.(tar\.gz|tgz|tar|zip)$/i.exec(filename ?? "")?.[0] ?? ".tar";
627
+ const stamp = new Date()
628
+ .toISOString()
629
+ .replace(/:/g, "")
630
+ .replace(/\..+/, "");
631
+ const out = o.out ??
632
+ join(BACKUP_DIR, `${target.version.model.toLowerCase()}-${stamp}${ext}`);
633
+ const file = resolve(root(), out);
634
+ await mkdir(resolve(file, ".."), { recursive: true, mode: 0o700 });
635
+ // The archive holds integration credentials: keep backups/ out of git.
636
+ if (resolve(file, "..") === resolve(root(), BACKUP_DIR))
637
+ await writeFile(resolve(root(), BACKUP_DIR, ".gitignore"), "# Full remote backups contain credentials. Never commit them.\n*\n");
546
638
  await writeFile(file, bytes, { mode: 0o600, flag: "wx" });
547
- console.log(`Backup saved to ${o.out}`);
639
+ console.log(`Backup saved to ${out} (${formatSize(bytes.length)}). It is unencrypted and contains integration credentials: keep it private. Restore it from the web configurator.`);
548
640
  });
549
641
  });
642
+ const BACKUP_DIR = "backups";
643
+ function formatSize(n) {
644
+ return n > 1048576
645
+ ? `${(n / 1048576).toFixed(1)} MB`
646
+ : `${Math.ceil(n / 1024)} KB`;
647
+ }
648
+ function listBackups(dir) {
649
+ try {
650
+ const d = join(dir, BACKUP_DIR);
651
+ return readdirSync(d)
652
+ .filter((f) => /\.(tar\.gz|tgz|tar|zip)$/i.test(f))
653
+ .sort()
654
+ .map((f) => ({
655
+ file: join(BACKUP_DIR, f),
656
+ size: formatSize(statSync(join(d, f)).size),
657
+ }));
658
+ }
659
+ catch {
660
+ return [];
661
+ }
662
+ }
550
663
  const ir = program.command("ir");
551
664
  ir.command("learn <emitterId>")
552
665
  .option("--target <name>", "target", defaultTarget)
package/dist/client.d.ts CHANGED
@@ -19,6 +19,11 @@ export declare class CoreClient {
19
19
  maybe(path: string): Promise<ObjectValue | null>;
20
20
  list(path: string): Promise<ObjectValue[]>;
21
21
  download(path: string): Promise<Uint8Array>;
22
+ /** Bytes plus the server-suggested filename (Content-Disposition), if any. */
23
+ downloadFile(path: string): Promise<{
24
+ bytes: Uint8Array;
25
+ filename?: string;
26
+ }>;
22
27
  version(): Promise<Version>;
23
28
  verifyTarget(target: Target): Promise<void>;
24
29
  }
package/dist/client.js CHANGED
@@ -122,6 +122,10 @@ export class CoreClient {
122
122
  throw new Error(`${path}: pagination limit exceeded`);
123
123
  }
124
124
  async download(path) {
125
+ return (await this.downloadFile(path)).bytes;
126
+ }
127
+ /** Bytes plus the server-suggested filename (Content-Disposition), if any. */
128
+ async downloadFile(path) {
125
129
  if (!path.startsWith("/") || path.startsWith("//") || path.includes(".."))
126
130
  throw new Error("Invalid API path");
127
131
  const response = await this.transport(new URL(path.slice(1), this.base), {
@@ -131,7 +135,12 @@ export class CoreClient {
131
135
  });
132
136
  if (!response.ok)
133
137
  throw new ApiError(response.status, "GET", path);
134
- return new Uint8Array(await response.arrayBuffer());
138
+ const disposition = response.headers.get("content-disposition") ?? "";
139
+ const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition);
140
+ return {
141
+ bytes: new Uint8Array(await response.arrayBuffer()),
142
+ ...(m ? { filename: decodeURIComponent(m[1]) } : {}),
143
+ };
135
144
  }
136
145
  async version() {
137
146
  return this.get("/pub/version");
package/dist/init.js CHANGED
@@ -40,19 +40,30 @@ 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
+ 7. If \`npx uc-config backup --list\` shows no backups, offer the user a full
49
+ backup (see "Full backup" below). Ask first; don't run it unprompted.
48
50
 
49
- If \`.uc/\` already exists, reuse it. Don't re-auth, re-import or re-adopt.
51
+ If \`.uc/\` already exists, reuse it. Don't re-auth. If remote.config.ts or
52
+ \`.uc/state\` is lost, the remote still has everything: run sync again.
50
53
 
51
54
  ## Every change
52
55
 
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).
56
+ The remote is the source of truth; this folder is a working copy of it.
57
+
58
+ 1. \`npx uc-config sync\` first. It pulls edits made on the remote (web
59
+ configurator, driver updates) into remote.config.ts and refreshes
60
+ generated/devices.ts, keeping any unapplied local edits. If it reports a
61
+ conflict (\`!\`), stop and ask the user which value to keep.
62
+ 2. Edit remote.config.ts.
63
+ 3. \`npx tsc --noEmit\`, \`npx uc-config compile\`,
64
+ \`npx uc-config plan --out .uc/plan.json\`, review,
65
+ \`npx uc-config apply .uc/plan.json\`, \`npx uc-config check\` (must be 0 operations).
66
+ 4. Commit, so git keeps a history of what changed.
56
67
 
57
68
  - Use only entity IDs and cmd_ids from generated/devices.ts or a fresh inventory.
58
69
  Never invent command names.
@@ -64,6 +75,24 @@ Edit remote.config.ts, then: \`npx tsc --noEmit\`, \`npx uc-config compile\`,
64
75
  - Never use --overwrite-drift or --prune without the user's say-so.
65
76
  - Never put secrets in remote.config.ts or print .uc/credentials.json.
66
77
 
78
+ ## Full backup
79
+
80
+ \`npx uc-config backup\` saves the remote's own full backup (the same archive
81
+ as the web configurator's backup page) to \`backups/<model>-<timestamp>.tar\`.
82
+ It covers activities, macros, pages, integrations and their settings, icons
83
+ and IR codes, and is what you restore after a factory reset.
84
+
85
+ - Whenever you work in this folder and \`backup --list\` (or \`doctor\`) shows
86
+ no backup, ask the user once whether they'd like one now.
87
+ - Always ask before running it, and tell the user first: integrations and docks
88
+ stop for a few seconds and the remote shouldn't be used meanwhile.
89
+ - Each run adds a new file; older backups are kept. \`backup --list\` lists them.
90
+ Only delete old ones when the user asks.
91
+ - Archives are unencrypted and contain integration credentials. backups/ is
92
+ gitignored: never commit, upload or print them.
93
+ - Restoring replaces the remote's configuration: the user does that in the web
94
+ configurator. Never restore from here.
95
+
67
96
  ## When something is broken
68
97
 
69
98
  Run \`npx uc-config diagnose\`. It is read-only and prints a \`fix:\` per issue.
@@ -117,6 +146,8 @@ const tsconfig = {
117
146
  const gitignore = `node_modules/
118
147
  # Credentials, state and journals. Back up .uc/state and .uc/journals privately.
119
148
  .uc/
149
+ # Full remote backups: unencrypted, contain integration credentials.
150
+ backups/
120
151
  .env
121
152
  .env.*
122
153
  `;
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. |
@@ -29,7 +30,7 @@ the workspace's only target if exactly one is connected; otherwise `home`.
29
30
  | `ir learn/capture <emitterId>` | yes | Learn IR codes from a physical remote. |
30
31
  | `state adopt <key> <id>` / `forget` / `move` | no | Edit local ownership bindings. |
31
32
  | `rollback [--out f]` | no | Build a compensating plan from the last journal. |
32
- | `backup --out f` | stops intgs | Native full backup. Disruptive; not for routine use. |
33
+ | `backup [--out f] [--list]` | stops intgs | Full native backup to `backups/<timestamp>`; keeps older ones. Ask first. |
33
34
  | `api <METHOD> <path> [--data json] [--write]` | only with --write | Raw authenticated Core API call; output redacted. |
34
35
 
35
36
  Exit codes: `0` ok, `1` error, `2` drift/conflicts/deferred/diagnose findings,
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
 
@@ -295,18 +316,25 @@ npm run uc -- rollback --out .uc/rollback.json # builds a compensating plan
295
316
  npm run uc -- apply .uc/rollback.json
296
317
  ```
297
318
 
298
- ## Back up everything (scheduled job friendly)
319
+ ## Full remote backup
299
320
 
300
321
  ```sh
301
- D=backups/$(date +%F); mkdir -p "$D"
302
- npm run -s uc -- inventory --out "$D/inventory.json" # redacted raw state
303
- npm run -s uc -- import --out "$D/remote.config.ts" # readable snapshot
304
- tar czf "$D/dotuc.tgz" --exclude credentials.json .uc # ownership state
305
- curl -sf http://<REMOTE_IP>:9999/api/backups/download -o "$D/intg-manager.json" # Integration Manager
322
+ npx uc-config backup --list # existing backups (no remote access)
323
+ npx uc-config backup # new backups/<model>-<timestamp>.tar
306
324
  ```
307
325
 
308
- `npm run uc -- backup --out PRIVATE_PATH` produces the remote's native full
309
- backup, but it **temporarily stops integrations and docks**, so don't schedule it.
326
+ This is the remote's own full backup, the same archive the web configurator
327
+ downloads: activities, macros, pages, integrations and their settings, icons
328
+ and IR codes. Each run adds a file and keeps the older ones. `--out <path>`
329
+ saves somewhere else.
330
+
331
+ - It **stops integrations and docks for a few seconds**. Ask the user first,
332
+ and don't schedule it.
333
+ - The archive is unencrypted and contains integration credentials.
334
+ `backups/` is gitignored (and gets its own `.gitignore`); keep it private.
335
+ - It does not include Wi-Fi settings, the admin or web-configurator PIN, or
336
+ API keys, so `npx uc-config auth` is needed again after a restore.
337
+ - Restore it in the web configurator. uc-config never restores.
310
338
 
311
339
  ## Nightly health check (for an agent cron job)
312
340
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uc-config",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
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",