uc-config 0.3.0 → 0.3.2

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.2
13
+
14
+ Action required: optional, run `npx uc-config init --refresh-docs`. Backups
15
+ made with 0.3.1 are zip files saved as `.tar`; rename them to `.zip`.
16
+
17
+ - `backup` detects the archive type from its contents. The remote sends no
18
+ filename and the archive is a zip, so 0.3.1 saved it with a `.tar` extension.
19
+ - `backup` also saves the UC Integration Manager's export beside the archive
20
+ (`<name>-intg-manager.json`). The native backup doesn't hold the setup of
21
+ community integrations (Onkyo, Oppo, Kaleidescape, ...); the manager's export
22
+ does. Default `http://<remote>:9999`; `--intg-manager <url>` sets and
23
+ remembers another address; `--no-intg-manager` skips it. A missing manager
24
+ never fails the backup.
25
+ - Docs and AGENTS.md no longer claim the native backup covers all integration
26
+ settings.
27
+
28
+ ## 0.3.1
29
+
30
+ Action required: optional, run `npx uc-config init --refresh-docs` to get the
31
+ backup instructions in AGENTS.md.
32
+
33
+ - `backup` takes no arguments now: it saves the remote's full native backup to
34
+ `backups/<model>-<timestamp>.tar` (extension from the remote's filename) and
35
+ never overwrites, so older backups are kept. `--out` still works.
36
+ - `backup --list` lists existing backups without contacting the remote.
37
+ `doctor` reports how many there are, or suggests making the first one.
38
+ - `backup` writes `backups/.gitignore` so archives (unencrypted, with
39
+ integration credentials) stay out of git even in existing workspaces. New
40
+ workspaces also ignore `backups/` in the top-level `.gitignore`.
41
+ - AGENTS.md: agents offer a first backup when none exists, always ask before
42
+ running one, and never restore.
43
+
12
44
  ## 0.3.0
13
45
 
14
46
  Action required: run `npx uc-config init --refresh-docs`. The agent
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Command } from "commander";
3
3
  import { writeFile, mkdir, readFile, rename } from "node:fs/promises";
4
4
  import { syncConfig, parseSource, renderSource, formatSync, remoteGone, } from "./sync.js";
5
- import { resolve, join } from "node:path";
5
+ import { resolve, join, relative } from "node:path";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import { Writable } from "node:stream";
8
8
  import { ApiError, CoreClient, resolveSecrets } from "./client.js";
@@ -12,12 +12,12 @@ import { Engine, PendingSetup } from "./engine.js";
12
12
  import { formatPlan, makePlan } from "./planner.js";
13
13
  import { inventory, importConfig, generateBindings } from "./inventory.js";
14
14
  import { emptyState, } from "./model.js";
15
- import { readJson, saveJson, redact, withLock, isObject } from "./util.js";
15
+ import { readJson, saveJson, redact, withLock, isObject, archiveExtension, } from "./util.js";
16
16
  import { rollbackPlan } from "./recovery.js";
17
17
  import { validateRequest } from "./schema.js";
18
18
  import { diagnose, formatDiagnosis } from "./diagnose.js";
19
19
  import { init } from "./init.js";
20
- import { readFileSync, readdirSync } from "node:fs";
20
+ import { readFileSync, readdirSync, statSync } from "node:fs";
21
21
  const pkgVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
22
22
  const program = new Command()
23
23
  .name("uc-config")
@@ -267,6 +267,10 @@ program
267
267
  process.exitCode = 1;
268
268
  }
269
269
  }
270
+ const backups = listBackups(root()).filter((b) => !b.file.endsWith("-intg-manager.json"));
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");
270
274
  });
271
275
  program
272
276
  .command("inventory")
@@ -602,20 +606,95 @@ program
602
606
  });
603
607
  program
604
608
  .command("backup")
609
+ .description("Full native backup of the remote into backups/ (timestamped; never overwrites). Briefly stops integrations and docks.")
605
610
  .option("--target <name>", "target", defaultTarget)
606
- .requiredOption("--out <file>", "native backup archive")
611
+ .option("--out <file>", "archive path (default: backups/<timestamp>)")
612
+ .option("--list", "list existing backups; no remote access")
613
+ .option("--intg-manager <url>", "Integration Manager base URL (remembered); default http://<remote>:9999")
614
+ .option("--no-intg-manager", "skip the Integration Manager backup")
607
615
  .action(async (o) => {
616
+ if (o.list) {
617
+ const list = listBackups(root());
618
+ console.log(list.length
619
+ ? list.map((b) => `${b.file} ${b.size}`).join("\n")
620
+ : "No full backups in backups/. Run: npx uc-config backup");
621
+ return;
622
+ }
608
623
  await withLock(local(`locks/${targetName(o.target)}.lock`), async () => {
609
624
  const { client, target } = await load(o.target);
610
625
  await client.verifyTarget(target);
611
- console.log("Exporting native backup: the remote temporarily stops integrations and docks. Keep the archive private; it may contain credentials.");
612
- const bytes = await client.download("/system/backup/export");
613
- const file = resolve(root(), o.out);
614
- await mkdir(resolve(file, ".."), { recursive: true });
626
+ console.log("Exporting full backup: the remote stops integrations and docks for a few seconds, then restarts them.");
627
+ const { bytes, filename } = await client.downloadFile("/system/backup/export");
628
+ const ext = archiveExtension(bytes, filename);
629
+ const stamp = new Date()
630
+ .toISOString()
631
+ .replace(/:/g, "")
632
+ .replace(/\..+/, "");
633
+ const out = o.out ??
634
+ join(BACKUP_DIR, `${target.version.model.toLowerCase()}-${stamp}${ext}`);
635
+ const file = resolve(root(), out);
636
+ await mkdir(resolve(file, ".."), { recursive: true, mode: 0o700 });
637
+ // The archive holds integration credentials: keep backups/ out of git.
638
+ if (resolve(file, "..") === resolve(root(), BACKUP_DIR))
639
+ await writeFile(resolve(root(), BACKUP_DIR, ".gitignore"), "# Full remote backups contain credentials. Never commit them.\n*\n");
615
640
  await writeFile(file, bytes, { mode: 0o600, flag: "wx" });
616
- console.log(`Backup saved to ${o.out}`);
641
+ 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.`);
642
+ if (o.intgManager === false)
643
+ return;
644
+ // Community integrations keep their setup data outside the native backup;
645
+ // the Integration Manager's export is what holds it.
646
+ const saved = local("intg-manager.json");
647
+ if (typeof o.intgManager === "string")
648
+ await saveJson(saved, { url: new URL(o.intgManager).origin });
649
+ let base = `${new URL(target.host).protocol}//${new URL(target.host).hostname}:9999`;
650
+ try {
651
+ base = (await readJson(saved)).url;
652
+ }
653
+ catch {
654
+ /* default */
655
+ }
656
+ const companion = file.replace(/(\.tar\.gz|\.[a-z0-9]+)$/i, "") + "-intg-manager.json";
657
+ try {
658
+ const res = await fetch(`${base}/api/v1/backups/export`, {
659
+ signal: AbortSignal.timeout(60000),
660
+ redirect: "error",
661
+ });
662
+ const body = await res.text();
663
+ const json = res.ok ? JSON.parse(body) : undefined;
664
+ if (!isObject(json) || !isObject(json.remotes))
665
+ throw new Error(`HTTP ${res.status}, not a manager backup`);
666
+ if (!Object.keys(json.remotes).some((k) => k.replace(/_/g, ":").toUpperCase() ===
667
+ target.identity.toUpperCase()))
668
+ console.log("Note: the Integration Manager backup has no entry for this remote.");
669
+ await writeFile(companion, body, { mode: 0o600, flag: "wx" });
670
+ console.log(`Integration Manager backup saved to ${relative(root(), companion)} (community integration settings; restore it in the Integration Manager).`);
671
+ }
672
+ catch (e) {
673
+ console.log(`No Integration Manager backup (${base}: ${e.message}). If you use community integrations and the manager runs elsewhere, rerun with --intg-manager http://<host>:9999.`);
674
+ }
617
675
  });
618
676
  });
677
+ const BACKUP_DIR = "backups";
678
+ function formatSize(n) {
679
+ return n > 1048576
680
+ ? `${(n / 1048576).toFixed(1)} MB`
681
+ : `${Math.ceil(n / 1024)} KB`;
682
+ }
683
+ function listBackups(dir) {
684
+ try {
685
+ const d = join(dir, BACKUP_DIR);
686
+ return readdirSync(d)
687
+ .filter((f) => /\.(tar\.gz|tgz|tar|zip|bin)$|-intg-manager\.json$/i.test(f))
688
+ .sort()
689
+ .map((f) => ({
690
+ file: join(BACKUP_DIR, f),
691
+ size: formatSize(statSync(join(d, f)).size),
692
+ }));
693
+ }
694
+ catch {
695
+ return [];
696
+ }
697
+ }
619
698
  const ir = program.command("ir");
620
699
  ir.command("learn <emitterId>")
621
700
  .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
@@ -45,6 +45,8 @@ exists, and skip steps that are done:
45
45
  never writes to the remote.
46
46
  5. \`npx uc-config compile && npx uc-config check\` (must be 0 operations).
47
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
51
  If \`.uc/\` already exists, reuse it. Don't re-auth. If remote.config.ts or
50
52
  \`.uc/state\` is lost, the remote still has everything: run sync again.
@@ -73,6 +75,33 @@ The remote is the source of truth; this folder is a working copy of it.
73
75
  - Never use --overwrite-drift or --prune without the user's say-so.
74
76
  - Never put secrets in remote.config.ts or print .uc/credentials.json.
75
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>.zip\`.
82
+ It covers activities, macros, pages, profiles, icons, IR codes and the
83
+ built-in integrations' settings, and is what you restore after a factory reset.
84
+
85
+ It does **not** hold the setup of community integrations installed through the
86
+ UC Integration Manager. When the manager is reachable (default
87
+ \`http://<remote>:9999\`; pass \`--intg-manager <url>\` once if it runs
88
+ elsewhere), \`backup\` also saves its export beside the archive as
89
+ \`<name>-intg-manager.json\`. If it reports no manager backup and the user has
90
+ community integrations, tell them those integrations would need setting up
91
+ again after a restore.
92
+
93
+ - Whenever you work in this folder and \`backup --list\` (or \`doctor\`) shows
94
+ no backup, ask the user once whether they'd like one now.
95
+ - Always ask before running it, and tell the user first: integrations and docks
96
+ stop for a few seconds and the remote shouldn't be used meanwhile.
97
+ - Each run adds a new file; older backups are kept. \`backup --list\` lists them.
98
+ Only delete old ones when the user asks.
99
+ - Archives are unencrypted and contain integration credentials. backups/ is
100
+ gitignored: never commit, upload or print them.
101
+ - Restoring replaces the remote's configuration: the user restores the archive
102
+ in the web configurator, then the manager file in the Integration Manager.
103
+ Never restore from here.
104
+
76
105
  ## When something is broken
77
106
 
78
107
  Run \`npx uc-config diagnose\`. It is read-only and prints a \`fix:\` per issue.
@@ -126,6 +155,8 @@ const tsconfig = {
126
155
  const gitignore = `node_modules/
127
156
  # Credentials, state and journals. Back up .uc/state and .uc/journals privately.
128
157
  .uc/
158
+ # Full remote backups: unencrypted, contain integration credentials.
159
+ backups/
129
160
  .env
130
161
  .env.*
131
162
  `;
package/dist/util.d.ts CHANGED
@@ -15,4 +15,6 @@ export declare function withLock<T>(file: string, fn: () => Promise<T>): Promise
15
15
  export declare function resolveRefs<T>(value: T, state: State): T;
16
16
  export declare function references(value: unknown): string[];
17
17
  export declare function redact(value: unknown): unknown;
18
+ /** Archive type from its magic bytes; the remote may not send a filename. */
19
+ export declare function archiveExtension(bytes: Uint8Array, filename?: string): string;
18
20
  export declare function secretFields(v: unknown, path?: string[]): string[];
package/dist/util.js CHANGED
@@ -129,6 +129,17 @@ export function redact(value) {
129
129
  sensitive.test(k) ? "[REDACTED]" : redact(v),
130
130
  ]));
131
131
  }
132
+ /** Archive type from its magic bytes; the remote may not send a filename. */
133
+ export function archiveExtension(bytes, filename) {
134
+ if (bytes[0] === 0x50 && bytes[1] === 0x4b)
135
+ return ".zip";
136
+ if (bytes[0] === 0x1f && bytes[1] === 0x8b)
137
+ return ".tar.gz";
138
+ if (bytes.length > 262 &&
139
+ String.fromCharCode(...bytes.slice(257, 262)) === "ustar")
140
+ return ".tar";
141
+ return /\.(tar\.gz|tgz|tar|zip)$/i.exec(filename ?? "")?.[0] ?? ".bin";
142
+ }
132
143
  export function secretFields(v, path = []) {
133
144
  if (!isObject(v) && !Array.isArray(v))
134
145
  return [];
package/docs/cli.md CHANGED
@@ -30,7 +30,7 @@ the workspace's only target if exactly one is connected; otherwise `home`.
30
30
  | `ir learn/capture <emitterId>` | yes | Learn IR codes from a physical remote. |
31
31
  | `state adopt <key> <id>` / `forget` / `move` | no | Edit local ownership bindings. |
32
32
  | `rollback [--out f]` | no | Build a compensating plan from the last journal. |
33
- | `backup --out f` | stops intgs | Native full backup. Disruptive; not for routine use. |
33
+ | `backup [--out f] [--list] [--intg-manager u]` | stops intgs | Native backup + Integration Manager export to `backups/`; keeps old ones. |
34
34
  | `api <METHOD> <path> [--data json] [--write]` | only with --write | Raw authenticated Core API call; output redacted. |
35
35
 
36
36
  Exit codes: `0` ok, `1` error, `2` drift/conflicts/deferred/diagnose findings,
package/docs/snippets.md CHANGED
@@ -316,18 +316,36 @@ npm run uc -- rollback --out .uc/rollback.json # builds a compensating plan
316
316
  npm run uc -- apply .uc/rollback.json
317
317
  ```
318
318
 
319
- ## Back up everything (scheduled job friendly)
319
+ ## Full remote backup
320
320
 
321
321
  ```sh
322
- D=backups/$(date +%F); mkdir -p "$D"
323
- npm run -s uc -- inventory --out "$D/inventory.json" # redacted raw state
324
- npm run -s uc -- import --out "$D/remote.config.ts" # readable snapshot
325
- tar czf "$D/dotuc.tgz" --exclude credentials.json .uc # ownership state
326
- 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>.zip
327
324
  ```
328
325
 
329
- `npm run uc -- backup --out PRIVATE_PATH` produces the remote's native full
330
- 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, profiles, icons, IR codes and the
328
+ built-in integrations' settings. Each run adds a file and keeps the older ones.
329
+ `--out <path>` saves somewhere else.
330
+
331
+ **Community integrations** (installed through the UC Integration Manager, e.g.
332
+ Onkyo, Oppo, Kaleidescape, Lutron) keep their setup data outside this archive.
333
+ `backup` therefore also downloads the manager's export
334
+ (`/api/v1/backups/export`) and saves it beside the archive as
335
+ `<name>-intg-manager.json`. It tries `http://<remote>:9999` by default; if the
336
+ manager runs elsewhere (Docker, another host), pass
337
+ `--intg-manager http://<host>:9999` once and it is remembered.
338
+ `--no-intg-manager` skips it. If the manager can't be reached, the native
339
+ backup is still saved and the command says so.
340
+
341
+ - It **stops integrations and docks for a few seconds**. Ask the user first,
342
+ and don't schedule it.
343
+ - The archive is unencrypted and contains integration credentials.
344
+ `backups/` is gitignored (and gets its own `.gitignore`); keep it private.
345
+ - It does not include Wi-Fi settings, the admin or web-configurator PIN, or
346
+ API keys, so `npx uc-config auth` is needed again after a restore.
347
+ - Restore the archive in the web configurator, then the `-intg-manager.json`
348
+ file in the Integration Manager. uc-config never restores.
331
349
 
332
350
  ## Nightly health check (for an agent cron job)
333
351
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uc-config",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",