my-durable 0.0.4 → 0.0.5
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/config.ts +20 -3
- package/apps/dpi-backend/src/main.ts +2 -0
- package/apps/dpi-backend/src/pairing.ts +42 -11
- package/apps/dpi-backend/src/routes.ts +11 -3
- package/apps/dpi-playground/README.md +7 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +3 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/auth.ts +108 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +18 -3
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/LoginCard.vue +59 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +32 -4
- package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +4 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +24 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +168 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +14 -2
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +79 -6
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +42 -7
- package/apps/dpi-sdk/src/protocol/limits.ts +17 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +201 -1
- package/apps/dpi-tui/src/app.ts +25 -1
- package/apps/dpi-tui/src/main.ts +95 -51
- package/apps/dpi-tui/src/pickers.ts +98 -0
- package/apps/dpi-tui/src/welcome.ts +394 -0
- package/bin/dpi.mjs +14 -9
- package/package.json +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +0 -77
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +0 -1
package/apps/dpi-tui/src/main.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
// dpi-tui: a pi-like terminal client for one Durable Agent Network device.
|
|
2
|
+
// dpi-tui the app; welcome screen when nothing to connect to,
|
|
3
|
+
// the single saved device when there is exactly one
|
|
2
4
|
// dpi-tui <device>[:<session>[:<agent>]] device from ~/.dpi/network.sqlite (seeded from dpi-network.yaml)
|
|
3
5
|
// dpi-tui --url <url> --token <token> [--save <id>]
|
|
4
6
|
// dpi-tui mac-mini --new triage create a session (in the device's first start dir) and attach
|
|
5
7
|
import { parseArgs } from "node:util";
|
|
6
8
|
import { DpiClient } from "../../dpi-sdk/src/client.ts";
|
|
9
|
+
import type { DeviceInfo } from "../../dpi-sdk/src/api.ts";
|
|
7
10
|
import { isDpiError } from "../../dpi-sdk/src/errors.ts";
|
|
8
11
|
import { NetworkRegistry } from "../../dpi-sdk/src/node/registry.ts";
|
|
9
12
|
import { syncConfigToRegistry, tryLoadNetworkConfig } from "../../dpi-sdk/src/node/config.ts";
|
|
10
13
|
import { App, type DeviceRef } from "./app.ts";
|
|
14
|
+
import { pickDevice } from "./pickers.ts";
|
|
15
|
+
import { emptyDeviceInfo, resolveStartupAction, showWelcome, WELCOME_DEVICE } from "./welcome.ts";
|
|
11
16
|
|
|
12
|
-
const USAGE = `usage: dpi-tui
|
|
17
|
+
const USAGE = `usage: dpi-tui [device[:session[:agent]]] [options]
|
|
13
18
|
dpi-tui --url <url> --token <token> [--save <id>]
|
|
19
|
+
dpi-tui no arguments: the app opens (welcome screen when no
|
|
20
|
+
device is saved; the one saved device when there is one)
|
|
14
21
|
|
|
15
22
|
-s, --session <ref> session id or name (else a picker opens)
|
|
16
23
|
-a, --agent <ref> agent id, "main" or name (default: the session's root agent)
|
|
@@ -59,11 +66,85 @@ const [deviceRef = "", sessionRef = values.session, agentRef = values.agent] = (
|
|
|
59
66
|
positionals[0] ?? (url ? "" : (process.env.DPI_DEVICE ?? ""))
|
|
60
67
|
).split(":");
|
|
61
68
|
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
/** The shared tail of every path that has a device: --save, --new, the App, --pair, open. */
|
|
70
|
+
async function start(device: DeviceRef, client: DpiClient, info: DeviceInfo, opts: { devicePicker?: boolean } = {}) {
|
|
71
|
+
if (values.save && url)
|
|
72
|
+
registry.upsert({ id: values.save, name: values.save, url: device.url, kind: "url", source: "ui", token: device.token });
|
|
73
|
+
|
|
74
|
+
let session = sessionRef;
|
|
75
|
+
if (values.new) {
|
|
76
|
+
const cwd = values.cwd ?? (await client.dirs("")).dirs[0]?.path;
|
|
77
|
+
if (!cwd) fail(`${device.id} has no start dirs; pass --cwd`);
|
|
78
|
+
const modelId = values.model ?? info.defaultModel;
|
|
79
|
+
const model = info.models.find((m) => m.id === modelId);
|
|
80
|
+
if (!model) fail(`Unknown model ${modelId}. Available: ${info.models.map((m) => m.id).join(", ")}`);
|
|
81
|
+
const created = await client
|
|
82
|
+
.createSession({ name: values.new, cwd, model: { provider: model.provider, modelId: model.id } })
|
|
83
|
+
.catch((e: Error) => fail(`Could not create the session: ${e.message}`));
|
|
84
|
+
session = created.id;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const app = new App(device, client, info, registry);
|
|
88
|
+
|
|
89
|
+
// Bootstrap local pairing (--pair CODE --hub ORIGIN [--force]): runs the same
|
|
90
|
+
// controller as /pair, against the LOCAL backend, before the UI opens. A bare
|
|
91
|
+
// --pair without a value points at the slash command instead of guessing.
|
|
92
|
+
if (values.pair !== undefined || values.hub !== undefined) {
|
|
93
|
+
const { runPairing, parsePairArgs, LocalPairing } = await import("./pairing.ts");
|
|
94
|
+
if (!values.pair) {
|
|
95
|
+
process.stdout.write("--pair needs the code from the hub wizard (or its pair URL).\nInside dpi-tui, run: /pair <CODE> --hub <origin>\n");
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
LocalPairing.fromEnv();
|
|
100
|
+
} catch (e) {
|
|
101
|
+
fail((e as Error).message);
|
|
102
|
+
}
|
|
103
|
+
await app.open(session, agentRef);
|
|
104
|
+
await runPairing(app, parsePairArgs([values.pair, ...(values.hub ? ["--hub", values.hub] : []), ...(values.force ? ["--force"] : [])].join(" ")));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await app.open(session, agentRef);
|
|
108
|
+
if (opts.devicePicker) pickDevice(app);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The app opens first; connection is a state, not a precondition. With no explicit
|
|
112
|
+
// device/url/pair: one saved device opens it directly, several open on the first
|
|
113
|
+
// with the device picker on top, none open the welcome screen.
|
|
114
|
+
const startup = resolveStartupAction({ devices: registry.list(), env: process.env, args: { deviceRef, url, pair: values.pair } });
|
|
115
|
+
|
|
116
|
+
if (startup.kind === "welcome") {
|
|
117
|
+
showWelcome(new App(WELCOME_DEVICE, new DpiClient(WELCOME_DEVICE.url, WELCOME_DEVICE.token), emptyDeviceInfo(), registry));
|
|
118
|
+
} else if (startup.kind === "auto-open" || startup.kind === "picker") {
|
|
119
|
+
const row = startup.kind === "auto-open" ? startup.device : startup.devices[0]!;
|
|
120
|
+
const device: DeviceRef = { id: row.id, name: row.name, url: row.url!, token: row.token! };
|
|
121
|
+
const client = new DpiClient(device.url, device.token);
|
|
122
|
+
try {
|
|
123
|
+
const info = await client.device({ timeoutMs: 8000 });
|
|
124
|
+
await start(device, client, info, { devicePicker: startup.kind === "picker" });
|
|
125
|
+
} catch (e) {
|
|
126
|
+
// An auto-connected device that is unreachable falls back to a screen that says
|
|
127
|
+
// so and offers the picker + welcome actions — never back to usage text.
|
|
128
|
+
showWelcome(new App(WELCOME_DEVICE, new DpiClient(WELCOME_DEVICE.url, WELCOME_DEVICE.token), emptyDeviceInfo(), registry), {
|
|
129
|
+
notice:
|
|
130
|
+
isDpiError(e) && e.code === "unauthorized"
|
|
131
|
+
? `${device.id}: token rejected by ${device.url}`
|
|
132
|
+
: `${device.id} is offline at ${device.url} (${(e as Error).message})`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
} else if (url) {
|
|
64
136
|
const token = values.token ?? process.env.DPI_TOKEN ?? "";
|
|
65
137
|
if (!token) fail("--url needs --token (or DPI_TOKEN)");
|
|
66
|
-
device = { id: values.save ?? "adhoc", name: values.save ?? new URL(url).host, url: url.replace(/\/+$/, ""), token };
|
|
138
|
+
const device: DeviceRef = { id: values.save ?? "adhoc", name: values.save ?? new URL(url).host, url: url.replace(/\/+$/, ""), token };
|
|
139
|
+
const client = new DpiClient(device.url, device.token);
|
|
140
|
+
const info = await client.device({ timeoutMs: 8000 }).catch((e: unknown) =>
|
|
141
|
+
fail(
|
|
142
|
+
isDpiError(e) && e.code === "unauthorized"
|
|
143
|
+
? `${device.id}: token rejected by ${device.url}`
|
|
144
|
+
: `${device.id} is offline at ${device.url} (${(e as Error).message})`,
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
await start(device, client, info);
|
|
67
148
|
} else {
|
|
68
149
|
const known = registry.list().map((d) => d.id);
|
|
69
150
|
if (!deviceRef) fail(`${USAGE}\n\nKnown devices: ${known.join(", ") || "none"}`);
|
|
@@ -72,51 +153,14 @@ if (url) {
|
|
|
72
153
|
if (!row.token) fail(`No token for ${row.id}. Run: just net register ${row.id}`);
|
|
73
154
|
if (!row.url)
|
|
74
155
|
fail(`Device ${row.id} is paired over the tunnel — it has no LAN URL. Attach through the hub web UI, or pair this machine directly.`);
|
|
75
|
-
device = { id: row.id, name: row.name, url: row.url, token: row.token };
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
)
|
|
85
|
-
);
|
|
86
|
-
if (values.save && url)
|
|
87
|
-
registry.upsert({ id: values.save, name: values.save, url: device.url, kind: "url", source: "ui", token: device.token });
|
|
88
|
-
|
|
89
|
-
let session = sessionRef;
|
|
90
|
-
if (values.new) {
|
|
91
|
-
const cwd = values.cwd ?? (await client.dirs("")).dirs[0]?.path;
|
|
92
|
-
if (!cwd) fail(`${device.id} has no start dirs; pass --cwd`);
|
|
93
|
-
const modelId = values.model ?? info.defaultModel;
|
|
94
|
-
const model = info.models.find((m) => m.id === modelId);
|
|
95
|
-
if (!model) fail(`Unknown model ${modelId}. Available: ${info.models.map((m) => m.id).join(", ")}`);
|
|
96
|
-
const created = await client
|
|
97
|
-
.createSession({ name: values.new, cwd, model: { provider: model.provider, modelId: model.id } })
|
|
98
|
-
.catch((e: Error) => fail(`Could not create the session: ${e.message}`));
|
|
99
|
-
session = created.id;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const app = new App(device, client, info, registry);
|
|
103
|
-
|
|
104
|
-
// Bootstrap local pairing (--pair CODE --hub ORIGIN [--force]): runs the same
|
|
105
|
-
// controller as /pair, against the LOCAL backend, before the UI opens. A bare
|
|
106
|
-
// --pair without a value points at the slash command instead of guessing.
|
|
107
|
-
if (values.pair !== undefined || values.hub !== undefined) {
|
|
108
|
-
const { runPairing, parsePairArgs, LocalPairing } = await import("./pairing.ts");
|
|
109
|
-
if (!values.pair) {
|
|
110
|
-
process.stdout.write("--pair needs the code from the hub wizard (or its pair URL).\nInside dpi-tui, run: /pair <CODE> --hub <origin>\n");
|
|
111
|
-
process.exit(1);
|
|
112
|
-
}
|
|
113
|
-
try {
|
|
114
|
-
LocalPairing.fromEnv();
|
|
115
|
-
} catch (e) {
|
|
116
|
-
fail((e as Error).message);
|
|
117
|
-
}
|
|
118
|
-
await app.open(session, agentRef);
|
|
119
|
-
await runPairing(app, parsePairArgs([values.pair, ...(values.hub ? ["--hub", values.hub] : []), ...(values.force ? ["--force"] : [])].join(" ")));
|
|
120
|
-
} else {
|
|
121
|
-
await app.open(session, agentRef);
|
|
156
|
+
const device: DeviceRef = { id: row.id, name: row.name, url: row.url, token: row.token };
|
|
157
|
+
const client = new DpiClient(device.url, device.token);
|
|
158
|
+
const info = await client.device({ timeoutMs: 8000 }).catch((e: unknown) =>
|
|
159
|
+
fail(
|
|
160
|
+
isDpiError(e) && e.code === "unauthorized"
|
|
161
|
+
? `${device.id}: token rejected by ${device.url}`
|
|
162
|
+
: `${device.id} is offline at ${device.url} (${(e as Error).message})`,
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
await start(device, client, info);
|
|
122
166
|
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
Container,
|
|
5
5
|
Input,
|
|
6
6
|
matchesKey,
|
|
7
|
+
renderFakeCursor,
|
|
7
8
|
SelectList,
|
|
8
9
|
truncateToWidth,
|
|
9
10
|
type Component,
|
|
@@ -35,6 +36,7 @@ export class Picker extends Box {
|
|
|
35
36
|
onPick: (item: SelectItem) => void,
|
|
36
37
|
onCancel: () => void,
|
|
37
38
|
hint = "↑↓ move │ enter select │ type to filter │ esc close",
|
|
39
|
+
subtitle?: string,
|
|
38
40
|
) {
|
|
39
41
|
super(1, 0, panelBg);
|
|
40
42
|
this.title = new Title(c.bold(c.accent(label)));
|
|
@@ -42,6 +44,7 @@ export class Picker extends Box {
|
|
|
42
44
|
this.list.onSelect = onPick;
|
|
43
45
|
this.list.onCancel = onCancel;
|
|
44
46
|
this.addChild(this.title);
|
|
47
|
+
if (subtitle) this.addChild(new Title(subtitle));
|
|
45
48
|
this.addChild(this.list);
|
|
46
49
|
this.addChild(new Title(c.muted(hint)));
|
|
47
50
|
}
|
|
@@ -79,6 +82,101 @@ export class Prompt extends Box implements Focusable {
|
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
/** A single-line input that never renders its value — for tokens. */
|
|
86
|
+
export class SecretInput implements Component, Focusable {
|
|
87
|
+
private value = "";
|
|
88
|
+
private cursor = 0;
|
|
89
|
+
private pasteBuffer?: string;
|
|
90
|
+
focused = false;
|
|
91
|
+
onSubmit?: (value: string) => void;
|
|
92
|
+
onEscape?: () => void;
|
|
93
|
+
getValue() {
|
|
94
|
+
return this.value;
|
|
95
|
+
}
|
|
96
|
+
handleInput(data: string) {
|
|
97
|
+
if (this.pasteBuffer !== undefined) {
|
|
98
|
+
this.pasteBuffer += data;
|
|
99
|
+
const end = this.pasteBuffer.indexOf("\x1b[201~");
|
|
100
|
+
if (end === -1) return;
|
|
101
|
+
const pasted = this.pasteBuffer.slice(0, end);
|
|
102
|
+
const rest = this.pasteBuffer.slice(end + 6);
|
|
103
|
+
this.pasteBuffer = undefined;
|
|
104
|
+
if (pasted) this.insert(pasted);
|
|
105
|
+
if (rest) this.handleInput(rest);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (data.startsWith("\x1b[200~")) {
|
|
109
|
+
const end = data.indexOf("\x1b[201~");
|
|
110
|
+
if (end === -1) {
|
|
111
|
+
this.pasteBuffer = data.slice(6);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const pasted = data.slice(6, end);
|
|
115
|
+
const rest = data.slice(end + 6);
|
|
116
|
+
if (pasted) this.insert(pasted);
|
|
117
|
+
if (rest) this.handleInput(rest);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (matchesKey(data, "escape")) return this.onEscape?.();
|
|
121
|
+
if (data === "\n" || matchesKey(data, "enter")) return this.onSubmit?.(this.value);
|
|
122
|
+
if (matchesKey(data, "backspace")) {
|
|
123
|
+
if (this.cursor > 0) this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor--);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (matchesKey(data, "delete")) {
|
|
127
|
+
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (matchesKey(data, "left")) return (this.cursor = Math.max(0, this.cursor - 1));
|
|
131
|
+
if (matchesKey(data, "right")) return (this.cursor = Math.min(this.value.length, this.cursor + 1));
|
|
132
|
+
if (matchesKey(data, "home") || data === "\x01") return (this.cursor = 0);
|
|
133
|
+
if (matchesKey(data, "end") || data === "\x05") return (this.cursor = this.value.length);
|
|
134
|
+
if (data === "\x15") {
|
|
135
|
+
this.value = "";
|
|
136
|
+
this.cursor = 0;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
this.insert(data);
|
|
140
|
+
}
|
|
141
|
+
private insert(text: string) {
|
|
142
|
+
let clean = "";
|
|
143
|
+
for (const ch of text) if (ch >= " " && ch !== "\x7f") clean += ch;
|
|
144
|
+
if (!clean) return;
|
|
145
|
+
this.value = this.value.slice(0, this.cursor) + clean + this.value.slice(this.cursor);
|
|
146
|
+
this.cursor += clean.length;
|
|
147
|
+
}
|
|
148
|
+
render(width: number): string[] {
|
|
149
|
+
const bullets = "•".repeat(this.value.length);
|
|
150
|
+
const at = this.focused ? renderFakeCursor(bullets.slice(this.cursor, this.cursor + 1) || " ") : bullets.slice(this.cursor, this.cursor + 1) || " ";
|
|
151
|
+
return [truncateToWidth(bullets.slice(0, this.cursor) + at + bullets.slice(this.cursor + 1), width)];
|
|
152
|
+
}
|
|
153
|
+
invalidate() {}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Like Prompt, but the typed value is only ever shown as bullets. */
|
|
157
|
+
export class SecretPrompt extends Box implements Focusable {
|
|
158
|
+
readonly input = new SecretInput();
|
|
159
|
+
private _focused = false;
|
|
160
|
+
get focused() {
|
|
161
|
+
return this._focused;
|
|
162
|
+
}
|
|
163
|
+
set focused(value: boolean) {
|
|
164
|
+
this._focused = value;
|
|
165
|
+
this.input.focused = value;
|
|
166
|
+
}
|
|
167
|
+
constructor(label: string, onSubmit: (value: string) => void, onCancel: () => void) {
|
|
168
|
+
super(1, 0, panelBg);
|
|
169
|
+
this.addChild(new Title(c.bold(c.accent(label))));
|
|
170
|
+
this.input.onSubmit = onSubmit;
|
|
171
|
+
this.input.onEscape = onCancel;
|
|
172
|
+
this.addChild(this.input);
|
|
173
|
+
this.addChild(new Title(c.muted("paste or type the token │ enter confirm │ esc cancel")));
|
|
174
|
+
}
|
|
175
|
+
handleInput(data: string) {
|
|
176
|
+
this.input.handleInput(data);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
82
180
|
/** A read-only panel that closes on Esc or Enter (help, tasks). */
|
|
83
181
|
export class Panel extends Box {
|
|
84
182
|
private body = new Container();
|