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 +16 -0
- package/README.md +18 -15
- package/dist/cli.js +70 -1
- package/dist/init.js +18 -9
- package/dist/sync.d.ts +44 -0
- package/dist/sync.js +241 -0
- package/docs/cli.md +1 -0
- package/docs/snippets.md +27 -6
- package/package.json +1 -1
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.
|
|
166
|
-
|
|
167
|
-
npm run uc --
|
|
168
|
-
npm run uc -- diagnose
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
>
|
|
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/`.
|
|
309
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
\`npx uc-config
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
|