uc-config 0.1.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/LICENSE +24 -0
- package/README.md +229 -0
- package/dist/adapter.d.ts +26 -0
- package/dist/adapter.js +540 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +555 -0
- package/dist/client.d.ts +25 -0
- package/dist/client.js +182 -0
- package/dist/compiler.d.ts +5 -0
- package/dist/compiler.js +155 -0
- package/dist/diagnose.d.ts +41 -0
- package/dist/diagnose.js +133 -0
- package/dist/dsl.d.ts +67 -0
- package/dist/dsl.js +103 -0
- package/dist/engine.d.ts +32 -0
- package/dist/engine.js +276 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/init.d.ts +7 -0
- package/dist/init.js +124 -0
- package/dist/inventory.d.ts +14 -0
- package/dist/inventory.js +236 -0
- package/dist/model.d.ts +91 -0
- package/dist/model.js +7 -0
- package/dist/planner.d.ts +12 -0
- package/dist/planner.js +248 -0
- package/dist/recovery.d.ts +4 -0
- package/dist/recovery.js +63 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +75 -0
- package/dist/transport.d.ts +2 -0
- package/dist/transport.js +101 -0
- package/dist/util.d.ts +18 -0
- package/dist/util.js +140 -0
- package/dist/wire.d.ts +17199 -0
- package/docs/cli.md +125 -0
- package/docs/configuration-authoring.md +196 -0
- package/docs/handoff.md +24 -0
- package/docs/snippets.md +312 -0
- package/examples/full-setup.ts +113 -0
- package/package.json +67 -0
- package/remote.config.example.ts +65 -0
- package/vendor/README.md +9 -0
- package/vendor/core-api.json +1 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
4
|
+
import { resolve, join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { Writable } from "node:stream";
|
|
7
|
+
import { CoreClient, resolveSecrets } from "./client.js";
|
|
8
|
+
import { Adapter } from "./adapter.js";
|
|
9
|
+
import { compile, validateConfig } from "./compiler.js";
|
|
10
|
+
import { Engine, PendingSetup } from "./engine.js";
|
|
11
|
+
import { formatPlan, makePlan } from "./planner.js";
|
|
12
|
+
import { inventory, importConfig, generateBindings } from "./inventory.js";
|
|
13
|
+
import { emptyState, } from "./model.js";
|
|
14
|
+
import { readJson, saveJson, redact, withLock, isObject } from "./util.js";
|
|
15
|
+
import { rollbackPlan } from "./recovery.js";
|
|
16
|
+
import { validateRequest } from "./schema.js";
|
|
17
|
+
import { diagnose, formatDiagnosis } from "./diagnose.js";
|
|
18
|
+
import { init } from "./init.js";
|
|
19
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
20
|
+
const pkgVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
21
|
+
const program = new Command()
|
|
22
|
+
.name("uc-config")
|
|
23
|
+
.description("TypeScript configuration and provisioning for Unfolded Circle Remote 3")
|
|
24
|
+
.version(pkgVersion)
|
|
25
|
+
.option("--workspace <path>", "project directory", process.cwd());
|
|
26
|
+
// UC_TARGET, else the workspace's only target, else "home".
|
|
27
|
+
function implicitTarget() {
|
|
28
|
+
if (process.env.UC_TARGET)
|
|
29
|
+
return process.env.UC_TARGET;
|
|
30
|
+
const ws = process.argv.indexOf("--workspace");
|
|
31
|
+
const dir = resolve(ws > 0 ? process.argv[ws + 1] : process.cwd());
|
|
32
|
+
try {
|
|
33
|
+
const names = readdirSync(join(dir, ".uc", "targets"))
|
|
34
|
+
.filter((f) => f.endsWith(".json"))
|
|
35
|
+
.map((f) => f.slice(0, -5));
|
|
36
|
+
if (names.length === 1)
|
|
37
|
+
return names[0];
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// no targets yet
|
|
41
|
+
}
|
|
42
|
+
return "home";
|
|
43
|
+
}
|
|
44
|
+
const defaultTarget = implicitTarget();
|
|
45
|
+
const root = () => resolve(program.opts().workspace);
|
|
46
|
+
const local = (name) => join(root(), ".uc", name);
|
|
47
|
+
const targetName = (name) => {
|
|
48
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name))
|
|
49
|
+
throw new Error("Invalid target name");
|
|
50
|
+
return name;
|
|
51
|
+
};
|
|
52
|
+
const targetFile = (name) => local(`targets/${targetName(name)}.json`);
|
|
53
|
+
const stateFile = (name) => local(`state/${targetName(name)}.json`);
|
|
54
|
+
const journalFile = (name) => local(`journals/${targetName(name)}.json`);
|
|
55
|
+
async function load(name) {
|
|
56
|
+
let target;
|
|
57
|
+
try {
|
|
58
|
+
target = await readJson(targetFile(name));
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
if (e.code === "ENOENT")
|
|
62
|
+
throw new Error(`No target "${name}" in this workspace. Run: uc-config connect ${name} --host http://<REMOTE_IP>`);
|
|
63
|
+
throw e;
|
|
64
|
+
}
|
|
65
|
+
let token = process.env[target.tokenEnv];
|
|
66
|
+
if (!token) {
|
|
67
|
+
try {
|
|
68
|
+
token = (await readJson(local("credentials.json")))[target.identity];
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
if (e.code !== "ENOENT")
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const client = new CoreClient(target.host, token);
|
|
76
|
+
let state;
|
|
77
|
+
try {
|
|
78
|
+
state = await readJson(stateFile(name));
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
if (e.code !== "ENOENT")
|
|
82
|
+
throw e;
|
|
83
|
+
state = emptyState(target.identity);
|
|
84
|
+
}
|
|
85
|
+
const adapter = new Adapter(client);
|
|
86
|
+
const engine = new Engine(adapter, {
|
|
87
|
+
save: (s) => saveJson(stateFile(name), s),
|
|
88
|
+
journal: async (j) => {
|
|
89
|
+
await saveJson(local(`journals/history/${targetName(name)}-${j.planDigest}.json`), j);
|
|
90
|
+
await saveJson(journalFile(name), j);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
return { target, client, state, adapter, engine };
|
|
94
|
+
}
|
|
95
|
+
async function hiddenPrompt(label) {
|
|
96
|
+
if (!process.stdin.isTTY)
|
|
97
|
+
throw new Error("Use UC_PIN or run auth in an interactive terminal");
|
|
98
|
+
let muted = false;
|
|
99
|
+
const sink = new Writable({
|
|
100
|
+
write(chunk, _encoding, callback) {
|
|
101
|
+
if (!muted)
|
|
102
|
+
process.stdout.write(chunk);
|
|
103
|
+
callback();
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
const rl = createInterface({
|
|
107
|
+
input: process.stdin,
|
|
108
|
+
output: sink,
|
|
109
|
+
terminal: true,
|
|
110
|
+
});
|
|
111
|
+
process.stdout.write(label);
|
|
112
|
+
muted = true;
|
|
113
|
+
try {
|
|
114
|
+
return await rl.question("");
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
rl.close();
|
|
118
|
+
process.stdout.write("\n");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
program
|
|
122
|
+
.command("connect <name>")
|
|
123
|
+
.requiredOption("--host <url>")
|
|
124
|
+
.option("--token-env <name>", "API key environment variable", "UC_API_KEY")
|
|
125
|
+
.action(async (name, o) => {
|
|
126
|
+
const client = new CoreClient(o.host);
|
|
127
|
+
const version = await client.version();
|
|
128
|
+
if (version.model !== "UCR3" || typeof version.address !== "string")
|
|
129
|
+
throw new Error("Expected an identifiable Remote 3");
|
|
130
|
+
const target = {
|
|
131
|
+
host: client.base.origin,
|
|
132
|
+
identity: version.address,
|
|
133
|
+
version,
|
|
134
|
+
tokenEnv: o.tokenEnv,
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
const old = await readJson(targetFile(name));
|
|
138
|
+
if (old.identity !== target.identity)
|
|
139
|
+
throw new Error("Target name already refers to another remote; use a new name");
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
if (e.code !== "ENOENT")
|
|
143
|
+
throw e;
|
|
144
|
+
}
|
|
145
|
+
await saveJson(targetFile(name), target);
|
|
146
|
+
console.log(`Connected ${name}: ${version.model}, core ${version.core}, API ${version.api}. No configuration changed.`);
|
|
147
|
+
});
|
|
148
|
+
program
|
|
149
|
+
.command("auth")
|
|
150
|
+
.option("--target <name>", "target", defaultTarget)
|
|
151
|
+
.action(async (o) => {
|
|
152
|
+
const { target } = await load(o.target);
|
|
153
|
+
const pin = process.env.UC_PIN ?? (await hiddenPrompt("Web configurator PIN: "));
|
|
154
|
+
const client = new CoreClient(target.host);
|
|
155
|
+
validateRequest("/auth/api_keys", "POST", {
|
|
156
|
+
name: "uc-config",
|
|
157
|
+
scopes: ["admin"],
|
|
158
|
+
});
|
|
159
|
+
const { data } = await client.request("POST", "/auth/api_keys", { name: "uc-config", scopes: ["admin"] }, {
|
|
160
|
+
Authorization: `Basic ${Buffer.from(`web-configurator:${pin}`).toString("base64")}`,
|
|
161
|
+
});
|
|
162
|
+
if (typeof data.api_key !== "string")
|
|
163
|
+
throw new Error("No API key returned");
|
|
164
|
+
let credentials = {};
|
|
165
|
+
try {
|
|
166
|
+
credentials = await readJson(local("credentials.json"));
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
if (e.code !== "ENOENT")
|
|
170
|
+
throw e;
|
|
171
|
+
}
|
|
172
|
+
credentials[target.identity] = data.api_key;
|
|
173
|
+
await saveJson(local("credentials.json"), credentials);
|
|
174
|
+
console.log("API key saved in .uc/credentials.json (mode 0600, gitignored). Approve it on the remote if requested, then run doctor.");
|
|
175
|
+
});
|
|
176
|
+
program
|
|
177
|
+
.command("doctor")
|
|
178
|
+
.option("--target <name>", "target", defaultTarget)
|
|
179
|
+
.action(async (o) => {
|
|
180
|
+
const { client, target } = await load(o.target);
|
|
181
|
+
await client.verifyTarget(target);
|
|
182
|
+
console.log(`Identity verified; core ${target.version.core}, reported API ${target.version.api}`);
|
|
183
|
+
for (const path of [
|
|
184
|
+
"/entities",
|
|
185
|
+
"/activities",
|
|
186
|
+
"/intg/drivers",
|
|
187
|
+
"/intg/instances",
|
|
188
|
+
"/intg/setup",
|
|
189
|
+
"/docks",
|
|
190
|
+
"/profiles",
|
|
191
|
+
"/cfg/device/button_layout",
|
|
192
|
+
"/cfg/device/screen_layout",
|
|
193
|
+
]) {
|
|
194
|
+
try {
|
|
195
|
+
await client.get(path);
|
|
196
|
+
console.log(`OK ${path}`);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
console.log(`FAIL ${e.message}`);
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
program
|
|
205
|
+
.command("inventory")
|
|
206
|
+
.option("--target <name>", "target", defaultTarget)
|
|
207
|
+
.option("--out <file>", "JSON output", ".uc/inventory.json")
|
|
208
|
+
.option("--bindings <file>", "TypeScript device bindings")
|
|
209
|
+
.action(async (o) => {
|
|
210
|
+
const { client } = await load(o.target);
|
|
211
|
+
const inv = await inventory(client);
|
|
212
|
+
await saveJson(resolve(root(), o.out), redact(inv));
|
|
213
|
+
if (o.bindings)
|
|
214
|
+
await saveText(o.bindings, generateBindings(inv));
|
|
215
|
+
console.log(`Inventory written to ${o.out}${inv.unsupported.length ? `; ${inv.unsupported.length} unavailable endpoints` : ""}`);
|
|
216
|
+
});
|
|
217
|
+
async function saveText(file, contents) {
|
|
218
|
+
const dest = resolve(root(), file);
|
|
219
|
+
await mkdir(resolve(dest, ".."), { recursive: true });
|
|
220
|
+
await writeFile(dest, contents, { mode: 0o600, flag: "wx" });
|
|
221
|
+
}
|
|
222
|
+
program
|
|
223
|
+
.command("import")
|
|
224
|
+
.option("--target <name>", "target", defaultTarget)
|
|
225
|
+
.option("--out <file>", "TypeScript output", "remote.config.ts")
|
|
226
|
+
.action(async (o) => {
|
|
227
|
+
const { client } = await load(o.target);
|
|
228
|
+
const { config, warnings } = await importConfig(client);
|
|
229
|
+
validateConfig(config);
|
|
230
|
+
await saveText(o.out, `import { defineRemote } from 'uc-config';\n\nexport default defineRemote(${JSON.stringify(config, null, 2)});\n`);
|
|
231
|
+
await saveJson(local("import-warnings.json"), warnings);
|
|
232
|
+
console.log(`Wrote ${o.out}; no ownership or remote changes.\n${warnings.map((w) => `- ${w}`).join("\n")}`);
|
|
233
|
+
});
|
|
234
|
+
program
|
|
235
|
+
.command("compile")
|
|
236
|
+
.description("Compile TypeScript configuration into a local JSON snapshot")
|
|
237
|
+
.option("--config <file>", "TypeScript source", "remote.config.ts")
|
|
238
|
+
.option("--out <file>", "IR output", ".uc/build.json")
|
|
239
|
+
.action(async (o) => {
|
|
240
|
+
const config = await compile(resolve(root(), o.config));
|
|
241
|
+
await saveJson(resolve(root(), o.out), config);
|
|
242
|
+
console.log(`Compiled ${Object.keys(config.resources).length} resources to ${o.out}`);
|
|
243
|
+
});
|
|
244
|
+
program
|
|
245
|
+
.command("plan")
|
|
246
|
+
.option("--target <name>", "target", defaultTarget)
|
|
247
|
+
.option("--build <file>", "compiled IR", ".uc/build.json")
|
|
248
|
+
.option("--out <file>", "saved plan", ".uc/plan.json")
|
|
249
|
+
.option("--overwrite-drift", "explicitly replace edits to managed fields")
|
|
250
|
+
.option("--prune", "delete removed, previously managed resources after checking references")
|
|
251
|
+
.action(async (o) => {
|
|
252
|
+
const { target, state, adapter } = await load(o.target);
|
|
253
|
+
const config = await readJson(resolve(root(), o.build));
|
|
254
|
+
const plan = await makePlan(config, target, state, adapter, {
|
|
255
|
+
overwriteDrift: !!o.overwriteDrift,
|
|
256
|
+
prune: !!o.prune,
|
|
257
|
+
});
|
|
258
|
+
await saveJson(resolve(root(), o.out), plan);
|
|
259
|
+
console.log(formatPlan(plan));
|
|
260
|
+
if (plan.conflicts.length)
|
|
261
|
+
process.exitCode = 2;
|
|
262
|
+
});
|
|
263
|
+
program
|
|
264
|
+
.command("apply <plan>")
|
|
265
|
+
.option("--target <name>", "target", defaultTarget)
|
|
266
|
+
.option("--adopt-only", "record existing configuration locally; refuse any remote mutations")
|
|
267
|
+
.action(async (file, o) => {
|
|
268
|
+
await withLock(local(`locks/${targetName(o.target)}.lock`), async () => {
|
|
269
|
+
const { target, state, engine } = await load(o.target);
|
|
270
|
+
const plan = await readJson(resolve(root(), file));
|
|
271
|
+
if (o.adoptOnly && plan.operations.some((op) => op.action !== "adopt"))
|
|
272
|
+
throw new Error("Plan is not adoption-only; no changes made");
|
|
273
|
+
if (plan.target.identity !== target.identity ||
|
|
274
|
+
plan.target.host !== target.host)
|
|
275
|
+
throw new Error("Plan target differs from selected target");
|
|
276
|
+
try {
|
|
277
|
+
const journal = await readJson(journalFile(o.target));
|
|
278
|
+
if (journal.status === "failed" || journal.status === "running")
|
|
279
|
+
throw new Error("Previous apply is unresolved; run resume and inspect journal before applying again");
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
if (e.code !== "ENOENT")
|
|
283
|
+
throw e;
|
|
284
|
+
}
|
|
285
|
+
console.log(formatPlan(plan));
|
|
286
|
+
await engine.apply(plan, state);
|
|
287
|
+
console.log(`Applied and verified. ${plan.deferred.length ? "Replan deferred resources." : "Run check to confirm convergence."}`);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
program
|
|
291
|
+
.command("check")
|
|
292
|
+
.option("--target <name>", "target", defaultTarget)
|
|
293
|
+
.option("--build <file>", "compiled IR", ".uc/build.json")
|
|
294
|
+
.action(async (o) => {
|
|
295
|
+
const { target, state, adapter } = await load(o.target);
|
|
296
|
+
const plan = await makePlan(await readJson(resolve(root(), o.build)), target, state, adapter);
|
|
297
|
+
console.log(formatPlan(plan));
|
|
298
|
+
if (plan.operations.length || plan.conflicts.length || plan.deferred.length)
|
|
299
|
+
process.exitCode = 2;
|
|
300
|
+
});
|
|
301
|
+
program
|
|
302
|
+
.command("resume")
|
|
303
|
+
.option("--target <name>", "target", defaultTarget)
|
|
304
|
+
.action(async (o) => {
|
|
305
|
+
await withLock(local(`locks/${targetName(o.target)}.lock`), async () => {
|
|
306
|
+
const { target, state, client, engine } = await load(o.target);
|
|
307
|
+
await client.verifyTarget(target);
|
|
308
|
+
console.log((await engine.resume(state)).join("\n") ||
|
|
309
|
+
"No pending setup processes.");
|
|
310
|
+
let journal;
|
|
311
|
+
try {
|
|
312
|
+
journal = await readJson(journalFile(o.target));
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
if (e.code === "ENOENT") {
|
|
316
|
+
console.log("Compile and plan the next phase.");
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
throw e;
|
|
320
|
+
}
|
|
321
|
+
const uncertain = journal.entries.filter((e) => (e.status === "intent" || e.status === "created") &&
|
|
322
|
+
!state.bindings[e.key] &&
|
|
323
|
+
!state.setups[e.key]);
|
|
324
|
+
if (uncertain.length)
|
|
325
|
+
throw new Error(`Uncertain writes: ${uncertain.map((e) => e.key).join(", ")}. Inspect inventory and use state adopt with the exact resulting id; no automatic retry.`);
|
|
326
|
+
journal.status = Object.keys(state.setups).length ? "paused" : "complete";
|
|
327
|
+
await saveJson(journalFile(o.target), journal);
|
|
328
|
+
console.log("Saved progress retained. Compile and plan to reconcile remaining configuration.");
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
const setup = program.command("setup");
|
|
332
|
+
setup
|
|
333
|
+
.command("status <key>")
|
|
334
|
+
.option("--target <name>", "target", defaultTarget)
|
|
335
|
+
.action(async (key, o) => {
|
|
336
|
+
const { state, client } = await load(o.target);
|
|
337
|
+
const s = state.setups[key];
|
|
338
|
+
if (!s)
|
|
339
|
+
throw new Error("No pending setup for this key");
|
|
340
|
+
const info = await client.get(`/${s.kind === "integration" ? "intg" : "docks"}/setup/${encodeURIComponent(s.id)}`);
|
|
341
|
+
console.log(JSON.stringify(redact(info), null, 2));
|
|
342
|
+
});
|
|
343
|
+
setup
|
|
344
|
+
.command("respond <key>")
|
|
345
|
+
.option("--target <name>", "target", defaultTarget)
|
|
346
|
+
.option("--input <file>", "JSON request body; use secret references")
|
|
347
|
+
.option("--confirm", "confirm requested physical action")
|
|
348
|
+
.action(async (key, o) => {
|
|
349
|
+
await withLock(local(`locks/${targetName(o.target)}.lock`), async () => {
|
|
350
|
+
const { state, client, target } = await load(o.target);
|
|
351
|
+
await client.verifyTarget(target);
|
|
352
|
+
const s = state.setups[key];
|
|
353
|
+
if (!s)
|
|
354
|
+
throw new Error("No pending setup");
|
|
355
|
+
if (!!o.input === !!o.confirm)
|
|
356
|
+
throw new Error("Provide exactly one of --input or --confirm");
|
|
357
|
+
const body = o.confirm
|
|
358
|
+
? { confirm: true }
|
|
359
|
+
: await resolveSecrets(await readJson(resolve(root(), o.input)));
|
|
360
|
+
const template = s.kind === "integration"
|
|
361
|
+
? "/intg/setup/{driverId}"
|
|
362
|
+
: "/docks/setup/{dockId}";
|
|
363
|
+
validateRequest(template, "PUT", body);
|
|
364
|
+
await client.request("PUT", `/${s.kind === "integration" ? "intg" : "docks"}/setup/${encodeURIComponent(s.id)}`, body);
|
|
365
|
+
console.log("Response submitted. Run resume.");
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
const pair = program.command("pairing");
|
|
369
|
+
pair
|
|
370
|
+
.command("status <remoteId>")
|
|
371
|
+
.option("--target <name>", "target", defaultTarget)
|
|
372
|
+
.action(async (id, o) => {
|
|
373
|
+
const { client } = await load(o.target);
|
|
374
|
+
console.log(JSON.stringify(redact(await client.get(`/remotes/${encodeURIComponent(id)}/bt/pairing`)), null, 2));
|
|
375
|
+
});
|
|
376
|
+
pair
|
|
377
|
+
.command("respond <remoteId>")
|
|
378
|
+
.option("--target <name>", "target", defaultTarget)
|
|
379
|
+
.requiredOption("--input <file>", "Pairing response JSON; passkey can use secret() JSON reference")
|
|
380
|
+
.action(async (id, o) => {
|
|
381
|
+
const { client, target } = await load(o.target);
|
|
382
|
+
await client.verifyTarget(target);
|
|
383
|
+
const body = await resolveSecrets(await readJson(resolve(root(), o.input)));
|
|
384
|
+
validateRequest("/remotes/{entityId}/bt/pairing", "POST", body);
|
|
385
|
+
await client.request("POST", `/remotes/${encodeURIComponent(id)}/bt/pairing`, body);
|
|
386
|
+
console.log("Pairing response submitted. Replan to verify pairing.");
|
|
387
|
+
});
|
|
388
|
+
const stateCmd = program.command("state");
|
|
389
|
+
stateCmd
|
|
390
|
+
.command("adopt <key> <id>")
|
|
391
|
+
.option("--target <name>", "target", defaultTarget)
|
|
392
|
+
.option("--build <file>", "compiled IR", ".uc/build.json")
|
|
393
|
+
.action(async (key, id, o) => {
|
|
394
|
+
await withLock(local(`locks/${targetName(o.target)}.lock`), async () => {
|
|
395
|
+
const { state, adapter, target } = await load(o.target);
|
|
396
|
+
await adapter.client.verifyTarget(target);
|
|
397
|
+
if (state.bindings[key])
|
|
398
|
+
throw new Error("Key already bound; use state move or forget first");
|
|
399
|
+
const config = await readJson(resolve(root(), o.build));
|
|
400
|
+
const r = config.resources[key];
|
|
401
|
+
if (!r)
|
|
402
|
+
throw new Error("Unknown configuration key");
|
|
403
|
+
const obs = await adapter.observe({ ...r, id }, state, key);
|
|
404
|
+
if (!obs.value)
|
|
405
|
+
throw new Error("No such remote resource");
|
|
406
|
+
state.bindings[key] = { id, resource: r, baseline: {} };
|
|
407
|
+
delete state.setups[key];
|
|
408
|
+
state.revision++;
|
|
409
|
+
await saveJson(stateFile(o.target), state);
|
|
410
|
+
console.log("Identity adopted; no remote changes. Resume, then plan field ownership.");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
stateCmd
|
|
414
|
+
.command("forget <key>")
|
|
415
|
+
.option("--target <name>", "target", defaultTarget)
|
|
416
|
+
.action(async (key, o) => {
|
|
417
|
+
await withLock(local(`locks/${targetName(o.target)}.lock`), async () => {
|
|
418
|
+
const { state } = await load(o.target);
|
|
419
|
+
if (!state.bindings[key])
|
|
420
|
+
throw new Error("Unknown managed key");
|
|
421
|
+
delete state.bindings[key];
|
|
422
|
+
state.revision++;
|
|
423
|
+
await saveJson(stateFile(o.target), state);
|
|
424
|
+
console.log("Ownership removed. Remote resource preserved.");
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
stateCmd
|
|
428
|
+
.command("move <from> <to>")
|
|
429
|
+
.option("--target <name>", "target", defaultTarget)
|
|
430
|
+
.action(async (from, to, o) => {
|
|
431
|
+
await withLock(local(`locks/${targetName(o.target)}.lock`), async () => {
|
|
432
|
+
const { state } = await load(o.target);
|
|
433
|
+
if (!state.bindings[from] ||
|
|
434
|
+
state.bindings[to] ||
|
|
435
|
+
!/^\w[\w.-]*$/.test(to))
|
|
436
|
+
throw new Error("Invalid source/destination key");
|
|
437
|
+
state.bindings[to] = state.bindings[from];
|
|
438
|
+
delete state.bindings[from];
|
|
439
|
+
const rewrite = (x) => Array.isArray(x)
|
|
440
|
+
? x.map(rewrite)
|
|
441
|
+
: isObject(x)
|
|
442
|
+
? Object.fromEntries(Object.entries(x).map(([k, v]) => [
|
|
443
|
+
k,
|
|
444
|
+
k === "$ref" && v === from ? to : rewrite(v),
|
|
445
|
+
]))
|
|
446
|
+
: x;
|
|
447
|
+
state.bindings = rewrite(state.bindings);
|
|
448
|
+
state.revision++;
|
|
449
|
+
await saveJson(stateFile(o.target), state);
|
|
450
|
+
console.log("State key moved; update source references and recompile.");
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
program
|
|
454
|
+
.command("rollback")
|
|
455
|
+
.option("--target <name>", "target", defaultTarget)
|
|
456
|
+
.option("--journal <file>", "journal to compensate; defaults to latest")
|
|
457
|
+
.option("--out <file>", "rollback plan", ".uc/rollback-plan.json")
|
|
458
|
+
.action(async (o) => {
|
|
459
|
+
const { target, state, adapter } = await load(o.target);
|
|
460
|
+
const journal = await readJson(o.journal ? resolve(root(), o.journal) : journalFile(o.target));
|
|
461
|
+
const plan = await rollbackPlan(journal, target, state, adapter);
|
|
462
|
+
await saveJson(resolve(root(), o.out), plan);
|
|
463
|
+
console.log(formatPlan(plan));
|
|
464
|
+
console.log("Rollback plan saved; review and apply explicitly. Source must also be reverted to retain the rollback.");
|
|
465
|
+
if (plan.conflicts.length)
|
|
466
|
+
process.exitCode = 2;
|
|
467
|
+
});
|
|
468
|
+
program
|
|
469
|
+
.command("backup")
|
|
470
|
+
.option("--target <name>", "target", defaultTarget)
|
|
471
|
+
.requiredOption("--out <file>", "native backup archive")
|
|
472
|
+
.action(async (o) => {
|
|
473
|
+
await withLock(local(`locks/${targetName(o.target)}.lock`), async () => {
|
|
474
|
+
const { client, target } = await load(o.target);
|
|
475
|
+
await client.verifyTarget(target);
|
|
476
|
+
console.log("Exporting native backup: the remote temporarily stops integrations and docks. Keep the archive private; it may contain credentials.");
|
|
477
|
+
const bytes = await client.download("/system/backup/export");
|
|
478
|
+
const file = resolve(root(), o.out);
|
|
479
|
+
await mkdir(resolve(file, ".."), { recursive: true });
|
|
480
|
+
await writeFile(file, bytes, { mode: 0o600, flag: "wx" });
|
|
481
|
+
console.log(`Backup saved to ${o.out}`);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
const ir = program.command("ir");
|
|
485
|
+
ir.command("learn <emitterId>")
|
|
486
|
+
.option("--target <name>", "target", defaultTarget)
|
|
487
|
+
.option("--timeout <seconds>", "learning window", "60")
|
|
488
|
+
.action(async (id, o) => {
|
|
489
|
+
const seconds = Number(o.timeout);
|
|
490
|
+
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 300)
|
|
491
|
+
throw new Error("Timeout must be 1–300 seconds");
|
|
492
|
+
const { client, target } = await load(o.target);
|
|
493
|
+
await client.verifyTarget(target);
|
|
494
|
+
await client.request("PUT", `/ir/emitters/${encodeURIComponent(id)}/learn?timeout=${seconds}`);
|
|
495
|
+
console.log("Learning started. Press the original remote button, then run ir capture.");
|
|
496
|
+
});
|
|
497
|
+
ir.command("capture <emitterId>")
|
|
498
|
+
.option("--target <name>", "target", defaultTarget)
|
|
499
|
+
.requiredOption("--out <file>", "learned code JSON")
|
|
500
|
+
.action(async (id, o) => {
|
|
501
|
+
const { client } = await load(o.target);
|
|
502
|
+
const codes = await client.get(`/ir/emitters/${encodeURIComponent(id)}/learn`);
|
|
503
|
+
await saveJson(resolve(root(), o.out), codes);
|
|
504
|
+
console.log("Captured learning status/codes. Assign chosen codes to irCode() resources.");
|
|
505
|
+
});
|
|
506
|
+
program
|
|
507
|
+
.command("init")
|
|
508
|
+
.description("Scaffold a private config workspace (package.json, tsconfig, .gitignore, AGENTS.md)")
|
|
509
|
+
.action(async () => {
|
|
510
|
+
const r = await init(root(), pkgVersion);
|
|
511
|
+
for (const f of r.written)
|
|
512
|
+
console.log(`created ${f}`);
|
|
513
|
+
if (r.packageJsonUpdated)
|
|
514
|
+
console.log("added uc-config to package.json");
|
|
515
|
+
for (const f of r.skipped)
|
|
516
|
+
console.log(`kept existing ${f}`);
|
|
517
|
+
console.log("Next: npm install, then start your coding agent here and ask it to set up your Remote 3.");
|
|
518
|
+
});
|
|
519
|
+
program
|
|
520
|
+
.command("diagnose")
|
|
521
|
+
.description("Read-only health check: orphaned entity references and disconnected integrations")
|
|
522
|
+
.option("--target <name>", "target", defaultTarget)
|
|
523
|
+
.option("--json", "machine-readable output")
|
|
524
|
+
.action(async (o) => {
|
|
525
|
+
const { client, target } = await load(o.target);
|
|
526
|
+
await client.verifyTarget(target);
|
|
527
|
+
const result = await diagnose(client);
|
|
528
|
+
console.log(o.json ? JSON.stringify(result, null, 2) : formatDiagnosis(result));
|
|
529
|
+
if (result.orphans.length || result.problems.length)
|
|
530
|
+
process.exitCode = 2;
|
|
531
|
+
});
|
|
532
|
+
program
|
|
533
|
+
.command("api <method> <path>")
|
|
534
|
+
.description("Raw authenticated Core API request (path relative to /api). Non-GET requires --write.")
|
|
535
|
+
.option("--target <name>", "target", defaultTarget)
|
|
536
|
+
.option("--data <json>", "JSON request body")
|
|
537
|
+
.option("--write", "allow a non-GET request")
|
|
538
|
+
.action(async (method, path, o) => {
|
|
539
|
+
const verb = method.toUpperCase();
|
|
540
|
+
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(verb))
|
|
541
|
+
throw new Error(`Unsupported method ${method}`);
|
|
542
|
+
if (verb !== "GET" && !o.write)
|
|
543
|
+
throw new Error(`${verb} changes the remote; rerun with --write once intended`);
|
|
544
|
+
const { client, target } = await load(o.target);
|
|
545
|
+
await client.verifyTarget(target);
|
|
546
|
+
const body = o.data === undefined ? undefined : JSON.parse(o.data);
|
|
547
|
+
const { data } = await client.request(verb, path, body);
|
|
548
|
+
console.log(JSON.stringify(redact(data), null, 2));
|
|
549
|
+
});
|
|
550
|
+
program.parseAsync().catch((e) => {
|
|
551
|
+
console.error(e instanceof PendingSetup
|
|
552
|
+
? `Paused: ${e.message}`
|
|
553
|
+
: `Error: ${e.message}`);
|
|
554
|
+
process.exitCode = e instanceof PendingSetup ? 3 : 1;
|
|
555
|
+
});
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ObjectValue, Target, Version } from "./model.js";
|
|
2
|
+
export declare class ApiError extends Error {
|
|
3
|
+
status: number;
|
|
4
|
+
method: string;
|
|
5
|
+
path: string;
|
|
6
|
+
constructor(status: number, method: string, path: string);
|
|
7
|
+
}
|
|
8
|
+
export declare class CoreClient {
|
|
9
|
+
private token?;
|
|
10
|
+
private transport;
|
|
11
|
+
private timeout;
|
|
12
|
+
readonly base: URL;
|
|
13
|
+
constructor(host: string, token?: string | undefined, transport?: typeof fetch, timeout?: number);
|
|
14
|
+
request<T = ObjectValue>(method: string, path: string, body?: unknown, extra?: Record<string, string>): Promise<{
|
|
15
|
+
data: T;
|
|
16
|
+
headers: Headers;
|
|
17
|
+
}>;
|
|
18
|
+
get<T = ObjectValue>(path: string): Promise<T>;
|
|
19
|
+
maybe(path: string): Promise<ObjectValue | null>;
|
|
20
|
+
list(path: string): Promise<ObjectValue[]>;
|
|
21
|
+
download(path: string): Promise<Uint8Array>;
|
|
22
|
+
version(): Promise<Version>;
|
|
23
|
+
verifyTarget(target: Target): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export declare function resolveSecrets<T>(v: T): Promise<T>;
|