my-durable 0.0.10 → 0.0.11
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/apps/dpi-backend/src/pair-cli.ts +489 -0
- package/apps/dpi-backend/src/routes.ts +6 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/{index-CoYMgU-2.js → index-Ct_cPQy9.js} +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +1 -1
- package/apps/dpi-sdk/src/protocol/pairing.ts +6 -1
- package/apps/dpi-tui/src/app.ts +72 -16
- package/apps/dpi-tui/src/pickers.ts +1 -1
- package/apps/dpi-tui/src/views.ts +4 -3
- package/bin/dpi.mjs +43 -66
- package/bin/update.d.mts +24 -0
- package/bin/update.mjs +198 -0
- package/package.json +2 -2
- package/scripts/verify.mjs +6 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing from the CLI — both directions, no browser involved.
|
|
3
|
+
*
|
|
4
|
+
* dpi pair <code|pairUrl|offerLink> [--hub <origin>] pair THIS machine
|
|
5
|
+
* dpi pair --hub <origin> this machine asks the hub
|
|
6
|
+
* itself; prints the identity
|
|
7
|
+
* block and waits for approval
|
|
8
|
+
* dpi pair offer [--hub <origin>] operator: mint an offer link
|
|
9
|
+
* for a NEW machine (paste it there)
|
|
10
|
+
* dpi pair list [--hub <origin>] operator: who is waiting
|
|
11
|
+
* dpi pair approve <name|codeId> [--hub <origin>] operator: approve that machine
|
|
12
|
+
* dpi pair revoke <name|deviceId> [--hub <origin>] operator: revoke its credential
|
|
13
|
+
*
|
|
14
|
+
* The device side talks to the LOCAL backend (token-authed /v1/pairing), the
|
|
15
|
+
* operator side talks to the HUB (admin token). Both accept the token from the
|
|
16
|
+
* environment or from the file each component already bootstrapped, so a device
|
|
17
|
+
* with `dpi device` running needs no arguments at all.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
export interface PairCliDeps {
|
|
24
|
+
env?: Record<string, string | undefined>;
|
|
25
|
+
fetchImpl?: typeof fetch;
|
|
26
|
+
print?: (line: string) => void;
|
|
27
|
+
/** Poll interval while waiting for approval (tests shrink it). */
|
|
28
|
+
pollMs?: number;
|
|
29
|
+
/** Give up waiting after this long (0 = wait forever, the default). */
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
/** Resolves after `ms` — injectable so tests do not sleep for real. */
|
|
32
|
+
sleep?: (ms: number) => Promise<void>;
|
|
33
|
+
/** Directory that holds the hub's admin.token (default ~/.local/share/durable-pi). */
|
|
34
|
+
hubHome?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const expand = (p: string) => (p === "~" ? homedir() : p.startsWith("~/") ? join(homedir(), p.slice(2)) : p);
|
|
38
|
+
const realSleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
39
|
+
|
|
40
|
+
function io(deps: PairCliDeps) {
|
|
41
|
+
return {
|
|
42
|
+
env: deps.env ?? process.env,
|
|
43
|
+
fetch: deps.fetchImpl ?? fetch,
|
|
44
|
+
print: deps.print ?? ((line: string) => process.stdout.write(`${line}\n`)),
|
|
45
|
+
pollMs: deps.pollMs ?? 1000,
|
|
46
|
+
timeoutMs: deps.timeoutMs ?? 0,
|
|
47
|
+
sleep: deps.sleep ?? realSleep,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The local device API: DPI_PORT + the token from env or its env file. */
|
|
52
|
+
export function localTarget(env: Record<string, string | undefined>) {
|
|
53
|
+
const home = expand(env.DPI_HOME ?? join(homedir(), ".dpi"));
|
|
54
|
+
const port = Number(env.DPI_PORT ?? "4780");
|
|
55
|
+
let token = env.DPI_TOKEN ?? "";
|
|
56
|
+
if (!token) {
|
|
57
|
+
for (const file of [env.DPI_ENV_FILE, join(home, "backend.env")]) {
|
|
58
|
+
if (!file) continue;
|
|
59
|
+
try {
|
|
60
|
+
const m = readFileSync(expand(file), "utf8").match(/^DPI_TOKEN=(.+)$/m);
|
|
61
|
+
if (m) {
|
|
62
|
+
token = m[1].trim();
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
/* no env file yet */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { url: `http://127.0.0.1:${port}`, token, home };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The hub: origin from --hub / DPI_HUB_URL, admin token from env or its file. */
|
|
74
|
+
export function hubTarget(env: Record<string, string | undefined>, hubHome?: string) {
|
|
75
|
+
const origin = (env.DPI_HUB_URL ?? "").replace(/\/+$/, "");
|
|
76
|
+
let token = env.DPI_ADMIN_TOKEN ?? "";
|
|
77
|
+
if (!token) {
|
|
78
|
+
const home = hubHome ?? expand(env.DPI_HOME ?? join(homedir(), ".local/share/durable-pi"));
|
|
79
|
+
for (const file of [env.DPI_ADMIN_TOKEN_FILE, join(home, "admin.token")]) {
|
|
80
|
+
if (!file) continue;
|
|
81
|
+
try {
|
|
82
|
+
token = readFileSync(expand(file), "utf8").trim();
|
|
83
|
+
if (token) break;
|
|
84
|
+
} catch {
|
|
85
|
+
/* not the hub's home */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { origin, token };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function jsonOrThrow(res: Response, what: string) {
|
|
93
|
+
const text = await res.text();
|
|
94
|
+
let body: unknown;
|
|
95
|
+
try {
|
|
96
|
+
body = JSON.parse(text);
|
|
97
|
+
} catch {
|
|
98
|
+
throw Error(`${what}: hub returned ${res.status} (not JSON): ${text.slice(0, 200)}`);
|
|
99
|
+
}
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
const err = (body as { error?: { message?: string } }).error;
|
|
102
|
+
throw Error(`${what}: ${err?.message ?? `HTTP ${res.status}`}`);
|
|
103
|
+
}
|
|
104
|
+
return body as Record<string, unknown>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Print the block an operator compares with the hub's approval card. */
|
|
108
|
+
function identityLines(status: {
|
|
109
|
+
deviceName?: string;
|
|
110
|
+
fingerprint?: string;
|
|
111
|
+
hostname?: string;
|
|
112
|
+
platform?: string;
|
|
113
|
+
}): string[] {
|
|
114
|
+
return [
|
|
115
|
+
` device ${status.deviceName ?? "?"}`,
|
|
116
|
+
...(status.hostname ? [` hostname ${status.hostname}`] : []),
|
|
117
|
+
...(status.platform ? [` platform ${status.platform}`] : []),
|
|
118
|
+
` fingerprint ${status.fingerprint ?? "?"}`,
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type PairAction = "device" | "offer" | "list" | "approve" | "revoke";
|
|
123
|
+
|
|
124
|
+
/** Parse `dpi pair …` argv into an action plus its arguments. */
|
|
125
|
+
export function parsePairCommand(argv: string[]): {
|
|
126
|
+
action: PairAction;
|
|
127
|
+
target?: string;
|
|
128
|
+
hub?: string;
|
|
129
|
+
force: boolean;
|
|
130
|
+
wait: boolean;
|
|
131
|
+
watch: boolean;
|
|
132
|
+
} {
|
|
133
|
+
const hubIdx = argv.indexOf("--hub");
|
|
134
|
+
const hub = hubIdx >= 0 ? argv[hubIdx + 1] : undefined;
|
|
135
|
+
const force = argv.includes("--force");
|
|
136
|
+
const wait = argv.includes("--wait") || argv.includes("--yes");
|
|
137
|
+
const watch = argv.includes("--watch");
|
|
138
|
+
const rest = argv.filter((a, i) => !a.startsWith("--") && argv[i - 1] !== "--hub");
|
|
139
|
+
const [first, second] = rest;
|
|
140
|
+
if (first === "offer") return { action: "offer", hub, force, wait, watch };
|
|
141
|
+
if (first === "list") return { action: "list", hub, force, wait, watch };
|
|
142
|
+
if (first === "approve") return { action: "approve", target: second, hub, force, wait, watch };
|
|
143
|
+
if (first === "revoke") return { action: "revoke", target: second, hub, force, wait, watch };
|
|
144
|
+
// Anything else is the thing to pair with: a code, a pair URL or an offer link.
|
|
145
|
+
return { action: "device", target: first, hub, force, wait, watch };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Pair THIS machine. With a code/link the hub already knows the request; with
|
|
150
|
+
* only `--hub` the machine asks the hub itself (device-initiated enrollment) and
|
|
151
|
+
* prints its identity so the operator can compare before approving.
|
|
152
|
+
*/
|
|
153
|
+
export async function pairThisDevice(
|
|
154
|
+
args: { target?: string; hub?: string; force: boolean },
|
|
155
|
+
deps: PairCliDeps = {},
|
|
156
|
+
): Promise<number> {
|
|
157
|
+
const { env, fetch: f, print, pollMs, timeoutMs, sleep } = io(deps);
|
|
158
|
+
const local = localTarget(env);
|
|
159
|
+
if (!local.token) {
|
|
160
|
+
print("no local device token — is `dpi device` installed here? (looked in DPI_TOKEN / backend.env)");
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
const auth = { authorization: `Bearer ${local.token}`, "content-type": "application/json" };
|
|
164
|
+
|
|
165
|
+
const body: Record<string, unknown> = {};
|
|
166
|
+
if (args.target) {
|
|
167
|
+
if (/^https?:\/\//i.test(args.target)) body.pairUrl = args.target;
|
|
168
|
+
else body.code = args.target;
|
|
169
|
+
if (args.hub) body.hub = args.hub;
|
|
170
|
+
} else if (args.hub) {
|
|
171
|
+
// Device-initiated: no secret to carry between machines.
|
|
172
|
+
body.hub = args.hub;
|
|
173
|
+
} else {
|
|
174
|
+
print("pair needs a code, a pair link, or --hub <origin> to ask the hub itself");
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
if (args.force) body.force = true;
|
|
178
|
+
|
|
179
|
+
let started: Record<string, unknown>;
|
|
180
|
+
try {
|
|
181
|
+
started = await jsonOrThrow(
|
|
182
|
+
await f(`${local.url}/v1/pairing/start`, { method: "POST", headers: auth, body: JSON.stringify(body) }),
|
|
183
|
+
"pairing start",
|
|
184
|
+
);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
print(`pairing failed: ${(error as Error).message}`);
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const status = started as {
|
|
191
|
+
state?: string;
|
|
192
|
+
deviceName?: string;
|
|
193
|
+
fingerprint?: string;
|
|
194
|
+
hostname?: string;
|
|
195
|
+
platform?: string;
|
|
196
|
+
pending?: { hubOrigin?: string };
|
|
197
|
+
lastError?: string;
|
|
198
|
+
};
|
|
199
|
+
const hubOrigin = status.pending?.hubOrigin ?? args.hub ?? "(hub)";
|
|
200
|
+
if (status.state === "paired") {
|
|
201
|
+
print(`already paired${status.pending?.hubOrigin ? ` to ${status.pending.hubOrigin}` : ""} — use --force to switch hubs`);
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
print(`pairing with ${hubOrigin}`);
|
|
205
|
+
for (const line of identityLines(status)) print(line);
|
|
206
|
+
print(" compare the fingerprint with the hub's approval card, then approve there");
|
|
207
|
+
print(" (web: hub → Machines waiting for you · cli: dpi pair list / dpi pair approve <name>)");
|
|
208
|
+
|
|
209
|
+
const deadline = timeoutMs ? Date.now() + timeoutMs : 0;
|
|
210
|
+
for (;;) {
|
|
211
|
+
await sleep(pollMs);
|
|
212
|
+
let now: Record<string, unknown>;
|
|
213
|
+
try {
|
|
214
|
+
now = await jsonOrThrow(await f(`${local.url}/v1/pairing`, { headers: auth }), "pairing status");
|
|
215
|
+
} catch (error) {
|
|
216
|
+
print(`lost the local backend: ${(error as Error).message}`);
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
const state = String(now.state ?? "");
|
|
220
|
+
if (state === "paired") {
|
|
221
|
+
print(`paired ✓ ${String((now as { hubOrigin?: string }).hubOrigin ?? hubOrigin)}`);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
if (state === "unpaired" || state === "failed") {
|
|
225
|
+
print(`pairing ended: ${String((now as { lastError?: string }).lastError ?? state)}`);
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
|
228
|
+
if (deadline && Date.now() > deadline) {
|
|
229
|
+
print("still waiting for approval — approve it and run `dpi pair` again, or wait longer with --wait");
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Operator side: mint an offer link for a machine that has no CLI pairing yet. */
|
|
236
|
+
async function pairOffer(deps: PairCliDeps, hubArg?: string): Promise<number> {
|
|
237
|
+
const { env, fetch: f, print } = io(deps);
|
|
238
|
+
const hub = hubTarget(env, deps.hubHome);
|
|
239
|
+
const origin = hubArg ?? hub.origin;
|
|
240
|
+
if (!origin) return print("offer needs --hub <origin> (or DPI_HUB_URL)"), 1;
|
|
241
|
+
if (!hub.token) return print("offer needs the hub admin token (DPI_ADMIN_TOKEN or the hub's admin.token)"), 1;
|
|
242
|
+
try {
|
|
243
|
+
const minted = await jsonOrThrow(
|
|
244
|
+
await f(`${origin}/api/pair/code`, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: { authorization: `Bearer ${hub.token}`, "content-type": "application/json" },
|
|
247
|
+
body: "{}",
|
|
248
|
+
}),
|
|
249
|
+
"mint offer",
|
|
250
|
+
);
|
|
251
|
+
print(`offer for a new machine (expires in ${Math.round((Number(minted.expiresAt) - Date.now()) / 60000)} min):`);
|
|
252
|
+
print(` ${String(minted.offerUrl ?? minted.pairUrl)}`);
|
|
253
|
+
print(` code ${String(minted.code)}`);
|
|
254
|
+
print("paste that link on the machine: dpi pair <link> (or dpi tui --pair <link>)");
|
|
255
|
+
return 0;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
print(`offer failed: ${(error as Error).message}`);
|
|
258
|
+
return 1;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
interface PendingCode {
|
|
263
|
+
codeId: string;
|
|
264
|
+
deviceName?: string;
|
|
265
|
+
hostname?: string;
|
|
266
|
+
platform?: string;
|
|
267
|
+
keyFingerprint?: string;
|
|
268
|
+
phase?: string;
|
|
269
|
+
enrollmentId?: string;
|
|
270
|
+
deviceUid?: string;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Watch cadence: the caller's pollMs (tests) or 2 s. */
|
|
274
|
+
function watchPoll(deps: PairCliDeps): number {
|
|
275
|
+
return deps.pollMs ?? 2000;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function printPending(c: PendingCode, print: (l: string) => void = (l) => process.stdout.write(`${l}\n`)) {
|
|
279
|
+
print(` ${c.deviceName ?? "(unnamed)"} · ${c.phase ?? "?"}`);
|
|
280
|
+
print(` hostname ${c.hostname ?? "?"}`);
|
|
281
|
+
print(` platform ${c.platform ?? "?"}`);
|
|
282
|
+
print(` fingerprint ${c.keyFingerprint ?? "?"}`);
|
|
283
|
+
print(` approve with: dpi pair approve ${c.deviceName ?? c.codeId}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Pending codes or a ready-to-print error string (watch loops never throw). */
|
|
287
|
+
async function pendingCodesOrError(deps: PairCliDeps, hubArg?: string): Promise<PendingCode[] | string> {
|
|
288
|
+
const { env, fetch: f } = io(deps);
|
|
289
|
+
const hub = hubTarget(env, deps.hubHome);
|
|
290
|
+
const origin = hubArg ?? hub.origin;
|
|
291
|
+
if (!origin) return "list needs --hub <origin> (or DPI_HUB_URL)";
|
|
292
|
+
if (!hub.token) return "list needs the hub admin token (DPI_ADMIN_TOKEN or the hub's admin.token)";
|
|
293
|
+
try {
|
|
294
|
+
return await pendingCodes(origin, hub.token, f);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
return `list failed: ${(error as Error).message}`;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function pendingCodes(origin: string, token: string, f: typeof fetch): Promise<PendingCode[]> {
|
|
301
|
+
const body = await jsonOrThrow(
|
|
302
|
+
await f(`${origin}/api/pair/codes`, { headers: { authorization: `Bearer ${token}` } }),
|
|
303
|
+
"list pending",
|
|
304
|
+
);
|
|
305
|
+
return (body.codes ?? []) as PendingCode[];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function pairList(deps: PairCliDeps, hubArg?: string, watch = false): Promise<number> {
|
|
309
|
+
const { env, fetch: f, print, pollMs, sleep } = io(deps);
|
|
310
|
+
if (watch) {
|
|
311
|
+
// Live: reprint only when the set of waiting machines changes, so the
|
|
312
|
+
// operator sees a device appear (or leave) without re-running the command.
|
|
313
|
+
let previous = "";
|
|
314
|
+
for (;;) {
|
|
315
|
+
const snapshot = await pendingCodesOrError(deps, hubArg);
|
|
316
|
+
if (typeof snapshot === "string") return print(snapshot), 1;
|
|
317
|
+
const key = snapshot.map((c) => `${c.codeId}:${c.phase}:${c.deviceName ?? ""}`).join("|");
|
|
318
|
+
if (key !== previous) {
|
|
319
|
+
previous = key;
|
|
320
|
+
print(`— ${new Date().toLocaleTimeString()} —`);
|
|
321
|
+
if (!snapshot.length) print("nothing is waiting for approval");
|
|
322
|
+
for (const c of snapshot) printPending(c);
|
|
323
|
+
}
|
|
324
|
+
await sleep(watchPoll(deps));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const hub = hubTarget(env, deps.hubHome);
|
|
328
|
+
const origin = hubArg ?? hub.origin;
|
|
329
|
+
if (!origin) return print("list needs --hub <origin> (or DPI_HUB_URL)"), 1;
|
|
330
|
+
if (!hub.token) return print("list needs the hub admin token (DPI_ADMIN_TOKEN or the hub's admin.token)"), 1;
|
|
331
|
+
try {
|
|
332
|
+
const codes = await pendingCodes(origin, hub.token, f);
|
|
333
|
+
if (!codes.length) {
|
|
334
|
+
print("nothing is waiting for approval");
|
|
335
|
+
print(` mint an offer for a new machine: dpi pair offer --hub ${origin}`);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
print(`${codes.length} machine${codes.length === 1 ? "" : "s"} waiting on ${origin}:`);
|
|
339
|
+
for (const c of codes) printPending(c, print);
|
|
340
|
+
return 0;
|
|
341
|
+
} catch (error) {
|
|
342
|
+
print(`list failed: ${(error as Error).message}`);
|
|
343
|
+
return 1;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function pairApprove(deps: PairCliDeps, target?: string, hubArg?: string): Promise<number> {
|
|
348
|
+
const { env, fetch: f, print } = io(deps);
|
|
349
|
+
const hub = hubTarget(env, deps.hubHome);
|
|
350
|
+
const origin = hubArg ?? hub.origin;
|
|
351
|
+
if (!origin) return print("approve needs --hub <origin> (or DPI_HUB_URL)"), 1;
|
|
352
|
+
if (!hub.token) return print("approve needs the hub admin token (DPI_ADMIN_TOKEN or the hub's admin.token)"), 1;
|
|
353
|
+
if (!target) return print("approve needs a machine name or code id (see: dpi pair list)"), 1;
|
|
354
|
+
try {
|
|
355
|
+
const codes = await pendingCodes(origin, hub.token, f);
|
|
356
|
+
const code =
|
|
357
|
+
codes.find((c) => c.codeId === target) ??
|
|
358
|
+
codes.find((c) => (c.deviceName ?? "").toLowerCase() === target.toLowerCase());
|
|
359
|
+
if (!code) {
|
|
360
|
+
print(`nothing waiting matches “${target}” — run: dpi pair list`);
|
|
361
|
+
return 1;
|
|
362
|
+
}
|
|
363
|
+
// The approval body is the hub's own view of the request; the fingerprint is
|
|
364
|
+
// the hub-computed one, never what the device claimed.
|
|
365
|
+
const approved = await jsonOrThrow(
|
|
366
|
+
await f(`${origin}/api/pair/codes/${code.codeId}/approve`, {
|
|
367
|
+
method: "POST",
|
|
368
|
+
headers: { authorization: `Bearer ${hub.token}`, "content-type": "application/json" },
|
|
369
|
+
body: JSON.stringify({
|
|
370
|
+
enrollmentId: code.enrollmentId,
|
|
371
|
+
deviceUid: code.deviceUid,
|
|
372
|
+
keyFingerprint: code.keyFingerprint,
|
|
373
|
+
}),
|
|
374
|
+
}),
|
|
375
|
+
"approve",
|
|
376
|
+
);
|
|
377
|
+
print(`approved ${code.deviceName ?? code.codeId} (${String(approved.phase ?? "approved")})`);
|
|
378
|
+
print(` fingerprint ${code.keyFingerprint ?? "?"}`);
|
|
379
|
+
return 0;
|
|
380
|
+
} catch (error) {
|
|
381
|
+
print(`approve failed: ${(error as Error).message}`);
|
|
382
|
+
return 1;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function pairRevoke(deps: PairCliDeps, target?: string, hubArg?: string): Promise<number> {
|
|
387
|
+
const { env, fetch: f, print } = io(deps);
|
|
388
|
+
const hub = hubTarget(env, deps.hubHome);
|
|
389
|
+
const origin = hubArg ?? hub.origin;
|
|
390
|
+
if (!origin) return print("revoke needs --hub <origin> (or DPI_HUB_URL)"), 1;
|
|
391
|
+
if (!hub.token) return print("revoke needs the hub admin token (DPI_ADMIN_TOKEN or the hub's admin.token)"), 1;
|
|
392
|
+
if (!target) return print("revoke needs a device name or id (see: dpi devices)"), 1;
|
|
393
|
+
try {
|
|
394
|
+
const net = await jsonOrThrow(
|
|
395
|
+
await f(`${origin}/api/network`, { headers: { authorization: `Bearer ${hub.token}` } }),
|
|
396
|
+
"list devices",
|
|
397
|
+
);
|
|
398
|
+
const devices = (net.devices ?? []) as Array<{ id: string; name: string; deviceUid?: string }>;
|
|
399
|
+
const device =
|
|
400
|
+
devices.find((d) => d.id === target) ?? devices.find((d) => d.name.toLowerCase() === target.toLowerCase());
|
|
401
|
+
if (!device) {
|
|
402
|
+
print(`no device matches “${target}” — known: ${devices.map((d) => d.name).join(", ") || "none"}`);
|
|
403
|
+
return 1;
|
|
404
|
+
}
|
|
405
|
+
await jsonOrThrow(
|
|
406
|
+
await f(`${origin}/api/devices/${device.id}/revoke`, {
|
|
407
|
+
method: "POST",
|
|
408
|
+
headers: { authorization: `Bearer ${hub.token}`, "content-type": "application/json" },
|
|
409
|
+
body: JSON.stringify({ reason: "revoked from the cli" }),
|
|
410
|
+
}),
|
|
411
|
+
"revoke",
|
|
412
|
+
);
|
|
413
|
+
print(`revoked ${device.name} — its next request will be refused`);
|
|
414
|
+
return 0;
|
|
415
|
+
} catch (error) {
|
|
416
|
+
print(`revoke failed: ${(error as Error).message}`);
|
|
417
|
+
return 1;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Entry point for `dpi pair …`. */
|
|
422
|
+
export async function runPairCommand(argv: string[], deps: PairCliDeps = {}): Promise<number> {
|
|
423
|
+
const parsed = parsePairCommand(argv);
|
|
424
|
+
if (parsed.action === "offer") return pairOffer(deps, parsed.hub);
|
|
425
|
+
if (parsed.action === "list") return pairList(deps, parsed.hub, parsed.watch);
|
|
426
|
+
if (parsed.action === "approve") return pairApprove(deps, parsed.target, parsed.hub);
|
|
427
|
+
if (parsed.action === "revoke") return pairRevoke(deps, parsed.target, parsed.hub);
|
|
428
|
+
return pairThisDevice({ target: parsed.target, hub: parsed.hub, force: parsed.force }, deps);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
interface HubDevice {
|
|
432
|
+
id: string;
|
|
433
|
+
name: string;
|
|
434
|
+
status?: string;
|
|
435
|
+
transport?: string;
|
|
436
|
+
deviceUid?: string;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** `dpi devices [--watch]` — the paired machines and their live status. */
|
|
440
|
+
export async function listDevices(deps: PairCliDeps = {}, hubArg?: string, watch = false): Promise<number> {
|
|
441
|
+
const { env, fetch: f, print, sleep } = io(deps);
|
|
442
|
+
const hub = hubTarget(env, deps.hubHome);
|
|
443
|
+
const origin = hubArg ?? hub.origin;
|
|
444
|
+
if (!origin) return print("devices needs --hub <origin> (or DPI_HUB_URL)"), 1;
|
|
445
|
+
if (!hub.token) return print("devices needs the hub admin token (DPI_ADMIN_TOKEN or the hub's admin.token)"), 1;
|
|
446
|
+
let previous = "";
|
|
447
|
+
for (;;) {
|
|
448
|
+
let devices: HubDevice[];
|
|
449
|
+
try {
|
|
450
|
+
const net = await jsonOrThrow(
|
|
451
|
+
await f(`${origin}/api/network`, { headers: { authorization: `Bearer ${hub.token}` } }),
|
|
452
|
+
"list devices",
|
|
453
|
+
);
|
|
454
|
+
devices = (net.devices ?? []) as HubDevice[];
|
|
455
|
+
} catch (error) {
|
|
456
|
+
print(`devices failed: ${(error as Error).message}`);
|
|
457
|
+
return 1;
|
|
458
|
+
}
|
|
459
|
+
const key = devices.map((d) => `${d.id}:${d.status ?? ""}`).join("|");
|
|
460
|
+
if (!watch || key !== previous) {
|
|
461
|
+
previous = key;
|
|
462
|
+
if (!devices.length) print(`no devices on ${origin}`);
|
|
463
|
+
for (const d of devices)
|
|
464
|
+
print(` ${(d.status ?? "?").padEnd(9)} ${d.name} · ${d.transport ?? "?"} · ${d.id}`);
|
|
465
|
+
}
|
|
466
|
+
if (!watch) return 0;
|
|
467
|
+
await sleep(watchPoll(deps));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export const PAIR_USAGE = `dpi pair — pairing from the CLI (no browser needed)
|
|
472
|
+
|
|
473
|
+
dpi pair <code|pairUrl|offerLink> [--hub <origin>] [--force]
|
|
474
|
+
pair THIS machine (runs on the device; needs the local dpi device)
|
|
475
|
+
dpi pair --hub <origin>
|
|
476
|
+
this machine asks the hub itself and prints its identity; approve on the
|
|
477
|
+
hub (web or \`dpi pair approve\`) — nothing is copied between machines
|
|
478
|
+
dpi pair offer [--hub <origin>]
|
|
479
|
+
operator: mint an offer link for a new machine (paste it there)
|
|
480
|
+
dpi pair list [--hub <origin>]
|
|
481
|
+
operator: machines waiting for approval, with hostname/platform/fingerprint
|
|
482
|
+
dpi pair approve <name|codeId> [--hub <origin>]
|
|
483
|
+
operator: approve that machine
|
|
484
|
+
dpi pair revoke <name|deviceId> [--hub <origin>]
|
|
485
|
+
operator: revoke a paired machine's credential
|
|
486
|
+
|
|
487
|
+
Hub admin token: DPI_ADMIN_TOKEN, or the hub's admin.token (DPI_HOME).
|
|
488
|
+
Device token: DPI_TOKEN, or the device's backend.env (DPI_HOME).
|
|
489
|
+
`;
|
|
@@ -59,14 +59,18 @@ export function v1(
|
|
|
59
59
|
const input = req.body as { code?: string; hub?: string; pairUrl?: string; force?: boolean };
|
|
60
60
|
// A pasted pair URL (v1) or offer link (v2) carries the code itself — a pairUrl-only body
|
|
61
61
|
// is the TUI/daemon paste path, so code is required only when pairUrl is absent.
|
|
62
|
+
// Three shapes: a code (+ optional hub), a pasted pair URL, or just
|
|
63
|
+
// `{hub}` — the device asks the hub itself (device-initiated), which is
|
|
64
|
+
// what `dpi pair --hub <origin>` sends. Nothing is copied between
|
|
65
|
+
// machines in that last form.
|
|
62
66
|
if (
|
|
63
67
|
!input ||
|
|
64
68
|
(input.code !== undefined && typeof input.code !== "string") ||
|
|
65
69
|
(input.hub !== undefined && typeof input.hub !== "string") ||
|
|
66
70
|
(input.pairUrl !== undefined && typeof input.pairUrl !== "string") ||
|
|
67
|
-
(input.code === undefined && input.pairUrl === undefined)
|
|
71
|
+
(input.code === undefined && input.pairUrl === undefined && !input.hub)
|
|
68
72
|
)
|
|
69
|
-
throw invalid("body: { code
|
|
73
|
+
throw invalid("body: { code?: string, hub?: string, pairUrl?: string, force?: boolean } — code, pairUrl or hub required");
|
|
70
74
|
await pairing.start({ code: input.code ?? "", hub: input.hub, pairUrl: input.pairUrl, force: input.force });
|
|
71
75
|
res.json(pairing.status());
|
|
72
76
|
} catch (e) {
|