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 +32 -0
- package/dist/cli.js +88 -9
- package/dist/client.d.ts +5 -0
- package/dist/client.js +10 -1
- package/dist/init.js +31 -0
- package/dist/util.d.ts +2 -0
- package/dist/util.js +11 -0
- package/docs/cli.md +1 -1
- package/docs/snippets.md +26 -8
- package/package.json +1 -1
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
|
-
.
|
|
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
|
|
612
|
-
const bytes = await client.
|
|
613
|
-
const
|
|
614
|
-
|
|
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 ${
|
|
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
|
-
|
|
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`
|
|
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
|
-
##
|
|
319
|
+
## Full remote backup
|
|
320
320
|
|
|
321
321
|
```sh
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
|