my-durable 0.0.4 → 0.0.6

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.
Files changed (29) hide show
  1. package/apps/dpi-backend/src/config.ts +20 -3
  2. package/apps/dpi-backend/src/main.ts +2 -0
  3. package/apps/dpi-backend/src/pairing.ts +42 -11
  4. package/apps/dpi-backend/src/routes.ts +11 -3
  5. package/apps/dpi-playground/README.md +7 -0
  6. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +1 -0
  7. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +77 -0
  8. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
  9. package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +3 -0
  10. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/auth.ts +108 -0
  11. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +18 -3
  12. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/LoginCard.vue +59 -0
  13. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +32 -4
  14. package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +4 -0
  15. package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +24 -0
  16. package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +168 -0
  17. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +14 -2
  18. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +79 -6
  19. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +42 -7
  20. package/apps/dpi-sdk/src/protocol/limits.ts +17 -0
  21. package/apps/dpi-sdk/src/protocol/pairing.ts +201 -1
  22. package/apps/dpi-tui/src/app.ts +25 -1
  23. package/apps/dpi-tui/src/main.ts +116 -52
  24. package/apps/dpi-tui/src/pickers.ts +98 -0
  25. package/apps/dpi-tui/src/welcome.ts +394 -0
  26. package/bin/dpi.mjs +14 -9
  27. package/package.json +1 -1
  28. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +0 -77
  29. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +0 -1
@@ -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 <device>[:<session>[:<agent>]] [options]
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,64 +66,121 @@ const [deviceRef = "", sessionRef = values.session, agentRef = values.agent] = (
59
66
  positionals[0] ?? (url ? "" : (process.env.DPI_DEVICE ?? ""))
60
67
  ).split(":");
61
68
 
62
- let device: DeviceRef;
63
- if (url) {
64
- const token = values.token ?? process.env.DPI_TOKEN ?? "";
65
- 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 };
67
- } else {
68
- const known = registry.list().map((d) => d.id);
69
- if (!deviceRef) fail(`${USAGE}\n\nKnown devices: ${known.join(", ") || "none"}`);
70
- const row = registry.get(deviceRef);
71
- if (!row) fail(`Unknown device "${deviceRef}". Known: ${known.join(", ") || "none (add one to dpi-network.yaml)"}`);
72
- if (!row.token) fail(`No token for ${row.id}. Run: just net register ${row.id}`);
73
- if (!row.url)
74
- 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
- }
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 });
77
73
 
78
- const client = new DpiClient(device.url, device.token);
79
- const info = await client.device({ timeoutMs: 8000 }).catch((e: unknown) =>
80
- fail(
81
- isDpiError(e) && e.code === "unauthorized"
82
- ? `${device.id}: token rejected by ${device.url}`
83
- : `${device.id} is offline at ${device.url} (${(e as Error).message})`,
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 });
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
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;
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);
100
109
  }
101
110
 
102
- const app = new App(device, client, info, registry);
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 } });
103
115
 
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
- }
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);
113
122
  try {
114
- LocalPairing.fromEnv();
123
+ const info = await client.device({ timeoutMs: 8000 });
124
+ await start(device, client, info, { devicePicker: startup.kind === "picker" });
115
125
  } catch (e) {
116
- fail((e as Error).message);
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
+ });
117
134
  }
118
- await app.open(session, agentRef);
119
- await runPairing(app, parsePairArgs([values.pair, ...(values.hub ? ["--hub", values.hub] : []), ...(values.force ? ["--force"] : [])].join(" ")));
135
+ } else if (url) {
136
+ const token = values.token ?? process.env.DPI_TOKEN ?? "";
137
+ if (!token) fail("--url needs --token (or DPI_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);
120
148
  } else {
121
- await app.open(session, agentRef);
149
+ const known = registry.list().map((d) => d.id);
150
+ if (!deviceRef && (values.pair !== undefined || values.hub !== undefined)) {
151
+ // Fresh-install flow: an explicit --pair (a pasted code, pair URL or offer link IS
152
+ // the whole configuration) must never die on usage text — pair this machine's LOCAL
153
+ // backend first, then the app opens on it. Mirrors the welcome screen's [p] action.
154
+ const { applyBackendEnv } = await import("./welcome.ts");
155
+ applyBackendEnv();
156
+ const { LocalPairing } = await import("./pairing.ts");
157
+ try {
158
+ LocalPairing.fromEnv();
159
+ } catch (e) {
160
+ fail((e as Error).message);
161
+ }
162
+ const port = Number(process.env.DPI_PORT ?? "4780");
163
+ const url = `http://127.0.0.1:${port}`;
164
+ const device: DeviceRef = { id: "local", name: "This machine", url, token: process.env.DPI_TOKEN ?? "" };
165
+ const client = new DpiClient(url, device.token);
166
+ const info = await client.device({ timeoutMs: 8000 }).catch((e: unknown) =>
167
+ fail(`no local dpi-backend on ${url} — start one with: dpi device (${(e as Error).message})`),
168
+ );
169
+ await start(device, client, info);
170
+ } else if (!deviceRef) fail(`${USAGE}\n\nKnown devices: ${known.join(", ") || "none"}`);
171
+ const row = registry.get(deviceRef);
172
+ if (!row) fail(`Unknown device "${deviceRef}". Known: ${known.join(", ") || "none (add one to dpi-network.yaml)"}`);
173
+ if (!row.token) fail(`No token for ${row.id}. Run: just net register ${row.id}`);
174
+ if (!row.url)
175
+ 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.`);
176
+ const device: DeviceRef = { id: row.id, name: row.name, url: row.url, token: row.token };
177
+ const client = new DpiClient(device.url, device.token);
178
+ const info = await client.device({ timeoutMs: 8000 }).catch((e: unknown) =>
179
+ fail(
180
+ isDpiError(e) && e.code === "unauthorized"
181
+ ? `${device.id}: token rejected by ${device.url}`
182
+ : `${device.id} is offline at ${device.url} (${(e as Error).message})`,
183
+ ),
184
+ );
185
+ await start(device, client, info);
122
186
  }
@@ -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();