my-durable 0.0.3 → 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.
Files changed (31) hide show
  1. package/README.md +8 -6
  2. package/apps/dpi-backend/src/config.ts +20 -3
  3. package/apps/dpi-backend/src/main.ts +2 -0
  4. package/apps/dpi-backend/src/pairing.ts +42 -11
  5. package/apps/dpi-backend/src/routes.ts +11 -3
  6. package/apps/dpi-playground/README.md +7 -0
  7. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +1 -0
  8. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +77 -0
  9. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
  10. package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +3 -0
  11. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/auth.ts +108 -0
  12. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +18 -3
  13. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/LoginCard.vue +59 -0
  14. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +32 -4
  15. package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +4 -0
  16. package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +24 -0
  17. package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +168 -0
  18. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +14 -2
  19. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +79 -6
  20. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +42 -7
  21. package/apps/dpi-sdk/src/protocol/limits.ts +17 -0
  22. package/apps/dpi-sdk/src/protocol/pairing.ts +201 -1
  23. package/apps/dpi-tui/src/app.ts +25 -1
  24. package/apps/dpi-tui/src/main.ts +95 -51
  25. package/apps/dpi-tui/src/pickers.ts +98 -0
  26. package/apps/dpi-tui/src/welcome.ts +394 -0
  27. package/bin/dpi.mjs +155 -0
  28. package/package.json +2 -2
  29. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +0 -77
  30. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +0 -1
  31. package/bin/md.mjs +0 -88
@@ -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,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
- let device: DeviceRef;
63
- if (url) {
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
- 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 });
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();