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
@@ -0,0 +1,394 @@
1
+ /**
2
+ * The welcome screen: the app opens first, connection is a state — not a
3
+ * precondition. With no explicit device/url/pair argument, `dpi` boots here
4
+ * instead of printing usage: pick how to connect, in the UI.
5
+ *
6
+ * Pure decision/probe logic lives here so it is unit-testable; the UI reuses
7
+ * the Picker/Prompt idioms and the exact code paths the flags use:
8
+ * - pairing → parsePairArgs + runPairing (LocalPairing, unchanged semantics)
9
+ * - saving → registry.upsert({... kind: "url", source: "ui"}) — the --save path
10
+ * - local → 127.0.0.1:DPI_PORT with DPI_TOKEN from env or DPI_HOME/backend.env
11
+ */
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { matchesKey, type SelectItem } from "@earendil-works/pi-tui";
15
+ import { DpiClient } from "../../dpi-sdk/src/client.ts";
16
+ import type { DeviceInfo } from "../../dpi-sdk/src/api.ts";
17
+ import { isDpiError } from "../../dpi-sdk/src/errors.ts";
18
+ import { DEVICE_ID, dpiHome, normalizeUrl, validateToken, type DeviceRow } from "../../dpi-sdk/src/node/registry.ts";
19
+ import type { App, DeviceRef } from "./app.ts";
20
+ import { Picker } from "./pickers.ts";
21
+ import { parsePairArgs, runPairing, type PairArgs } from "./pairing.ts";
22
+ import { c } from "./theme.ts";
23
+
24
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
25
+
26
+ // ------------------------------------------------------------------ startup decision
27
+
28
+ export interface StartupArgs {
29
+ deviceRef?: string;
30
+ url?: string;
31
+ pair?: string;
32
+ }
33
+
34
+ export type StartupAction =
35
+ | { kind: "explicit" }
36
+ | { kind: "auto-open"; device: DeviceRow }
37
+ | { kind: "picker"; devices: DeviceRow[] }
38
+ | { kind: "welcome" };
39
+
40
+ /**
41
+ * What bare `dpi` does at boot. Explicit arguments (device positional, --url,
42
+ * --pair, DPI_DEVICE/DPI_URL) keep their dedicated path; otherwise the registry
43
+ * decides: one connectable device opens it, several open the device picker,
44
+ * none (or none usable) open the welcome screen.
45
+ */
46
+ export function resolveStartupAction(input: {
47
+ devices: DeviceRow[];
48
+ env?: NodeJS.ProcessEnv;
49
+ args?: StartupArgs;
50
+ }): StartupAction {
51
+ const { devices, env = {}, args = {} } = input;
52
+ if (args.deviceRef || args.url || args.pair !== undefined || env.DPI_DEVICE || env.DPI_URL) return { kind: "explicit" };
53
+ const connectable = devices.filter((d) => d.url && d.token);
54
+ if (connectable.length === 1) return { kind: "auto-open", device: connectable[0]! };
55
+ if (connectable.length > 1) return { kind: "picker", devices: connectable };
56
+ return { kind: "welcome" };
57
+ }
58
+
59
+ /** The device the App is built around while no real one exists yet. */
60
+ export const WELCOME_DEVICE: DeviceRef = { id: "no-device", name: "No device", url: "http://127.0.0.1:9", token: "" };
61
+
62
+ /** A minimal DeviceInfo for the welcome state — no models, nothing callable. */
63
+ export function emptyDeviceInfo() {
64
+ return {
65
+ uid: "",
66
+ name: "",
67
+ hostname: "",
68
+ platform: "",
69
+ apiVersion: 1 as const,
70
+ durableVersion: "",
71
+ startedAt: 0,
72
+ startDirs: [],
73
+ models: [],
74
+ defaultModel: "",
75
+ storage: { dataDir: "", catalogPath: "", appStatePath: "" },
76
+ capabilities: [],
77
+ };
78
+ }
79
+
80
+ // ------------------------------------------------------------------ pair-arg assembly (from the prompted values)
81
+
82
+ /**
83
+ * Assemble PairArgs from what the user pasted/answered: a pair URL carries its
84
+ * own origin; a plain code needs the hub from the answer — no hardcoded default.
85
+ * Undefined when there is nothing to drive (no code/URL) or no origin at all.
86
+ */
87
+ export function assemblePairArgs(codeOrUrl: string, hubAnswer?: string): PairArgs | undefined {
88
+ const parsed = parsePairArgs(codeOrUrl);
89
+ if (!parsed.code && !parsed.pairUrl) return undefined;
90
+ const hub = parsed.hub ?? (parsed.pairUrl ? undefined : hubAnswer?.trim() || undefined);
91
+ if (!parsed.pairUrl && !hub) return undefined;
92
+ return { code: parsed.code, pairUrl: parsed.pairUrl, hub, force: parsed.force };
93
+ }
94
+
95
+ // ------------------------------------------------------------------ local backend discovery
96
+
97
+ /** Minimal KEY=VALUE env-file parser (comments, quotes, junk lines ignored). */
98
+ export function parseEnvFile(text: string): Record<string, string> {
99
+ const out: Record<string, string> = {};
100
+ for (const line of text.split("\n")) {
101
+ const trimmed = line.trim();
102
+ if (!trimmed || trimmed.startsWith("#")) continue;
103
+ const eq = trimmed.indexOf("=");
104
+ if (eq <= 0) continue;
105
+ const key = trimmed.slice(0, eq).trim();
106
+ let value = trimmed.slice(eq + 1).trim();
107
+ if (
108
+ (value.startsWith('"') && value.endsWith('"') && value.length > 1) ||
109
+ (value.startsWith("'") && value.endsWith("'") && value.length > 1)
110
+ )
111
+ value = value.slice(1, -1);
112
+ out[key] = value;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ export interface LocalEnv {
118
+ token?: string;
119
+ port: number;
120
+ envFile: string;
121
+ }
122
+
123
+ /**
124
+ * Resolve the local backend's token/port: env first, then DPI_HOME/backend.env
125
+ * (written by `dpi device`). Primes `env` in place so LocalPairing.fromEnv()
126
+ * and the probe agree; never overwrites values the caller already set.
127
+ */
128
+ export function applyBackendEnv(env: NodeJS.ProcessEnv = process.env, home = dpiHome()): LocalEnv {
129
+ const envFile = join(home, "backend.env");
130
+ const file = existsSync(envFile) ? parseEnvFile(readFileSync(envFile, "utf8")) : {};
131
+ if (!env.DPI_TOKEN && file.DPI_TOKEN) env.DPI_TOKEN = file.DPI_TOKEN;
132
+ if (!env.DPI_PORT && file.DPI_PORT) env.DPI_PORT = file.DPI_PORT;
133
+ return { token: env.DPI_TOKEN || undefined, port: Number(env.DPI_PORT ?? 4780) || 4780, envFile };
134
+ }
135
+
136
+ export type LocalProbe = { ok: true; info: DeviceInfo } | { ok: false; reason: "unreachable" | "unauthorized" };
137
+
138
+ export function localBackendUrl(port: number) {
139
+ return `http://127.0.0.1:${port}`;
140
+ }
141
+
142
+ /** Probe the local backend's /v1/device with its token; never throws. */
143
+ export async function probeLocalBackend(url: string, token: string, fetchImpl: typeof fetch = fetch): Promise<LocalProbe> {
144
+ try {
145
+ const r = await fetchImpl(`${url}/v1/device`, { headers: { authorization: `Bearer ${token}` } });
146
+ if (r.status === 401) return { ok: false, reason: "unauthorized" };
147
+ if (!r.ok) return { ok: false, reason: "unreachable" };
148
+ return { ok: true, info: (await r.json()) as DeviceInfo };
149
+ } catch {
150
+ return { ok: false, reason: "unreachable" };
151
+ }
152
+ }
153
+
154
+ /** Plain words for the probe outcome — the failure says exactly how to start one. */
155
+ export function describeLocalProbe(probe: LocalProbe, port: number): string {
156
+ if (probe.ok) return `local dpi-backend on 127.0.0.1:${port}: ${probe.info.name || probe.info.hostname || "unnamed device"}`;
157
+ if (probe.reason === "unauthorized")
158
+ return `the local dpi-backend on 127.0.0.1:${port} rejected the token — check DPI_TOKEN (env or ${join(dpiHome(), "backend.env")})`;
159
+ return `no local dpi-backend on 127.0.0.1:${port} — start one with: dpi device`;
160
+ }
161
+
162
+ // ------------------------------------------------------------------ ids and device input
163
+
164
+ /** Suggest a registry id from a hostname/device name; dashes for the rest. */
165
+ export function suggestDeviceId(...sources: Array<string | undefined>): string {
166
+ for (const source of sources) {
167
+ if (!source) continue;
168
+ const id = source
169
+ .toLowerCase()
170
+ .replace(/[^a-z0-9-]+/g, "-")
171
+ .replace(/^-+/, "")
172
+ .replace(/-+$/, "")
173
+ .slice(0, 32)
174
+ .replace(/-+$/, "");
175
+ if (DEVICE_ID.test(id)) return id;
176
+ }
177
+ return "device";
178
+ }
179
+
180
+ /** URL + token validation for "add a device by URL", reusing the registry's own rules. */
181
+ export function validateDeviceInput(urlAnswer: string, tokenAnswer: string): { url: string; token: string } | { error: string } {
182
+ try {
183
+ const url = normalizeUrl(urlAnswer);
184
+ try {
185
+ return { url, token: validateToken(tokenAnswer) };
186
+ } catch (e) {
187
+ return { error: `token: ${(e as Error).message}` };
188
+ }
189
+ } catch (e) {
190
+ return { error: (e as Error).message };
191
+ }
192
+ }
193
+
194
+ // ------------------------------------------------------------------ the welcome screen
195
+
196
+ export function welcomeItems(deviceCount: number, port: number): SelectItem[] {
197
+ // Values lead with the bracket letter, so typing it jumps to the action (the
198
+ // Picker filters on the value); enter selects it.
199
+ const items: SelectItem[] = [
200
+ { value: "pair", label: "[p] Pair this machine", description: "code or pair URL from the hub wizard" },
201
+ { value: "add-url", label: "[a] Add a device by URL", description: "http://host:port + its DPI_TOKEN" },
202
+ { value: "detect-local", label: "[d] Detect the local backend", description: `probe 127.0.0.1:${port} (DPI_TOKEN or ~/.dpi/backend.env)` },
203
+ ];
204
+ if (deviceCount > 0)
205
+ items.push({
206
+ value: "switch-device",
207
+ label: "[s] Switch device",
208
+ description: `${deviceCount} saved device${deviceCount === 1 ? "" : "s"}`,
209
+ });
210
+ return items;
211
+ }
212
+
213
+ /** The welcome picker: same idioms as every other picker; q/esc quits. */
214
+ export class WelcomePicker extends Picker {
215
+ private cancel: () => void;
216
+ constructor(items: SelectItem[], onPick: (item: SelectItem) => void, onCancel: () => void, subtitle?: string) {
217
+ super("No device yet.", items, onPick, onCancel, "↑↓ move │ enter select │ q quit", subtitle);
218
+ this.cancel = onCancel;
219
+ }
220
+ handleInput(data: string) {
221
+ if (data === "q" || matchesKey(data, "escape")) return this.cancel();
222
+ return super.handleInput(data);
223
+ }
224
+ }
225
+
226
+ /** Open the welcome screen overlay. `notice` names an unreachable auto-connect. */
227
+ export function showWelcome(app: App, opts: { notice?: string } = {}) {
228
+ const devices = app.registry?.list() ?? [];
229
+ const subtitle = opts.notice
230
+ ? c.err(opts.notice)
231
+ : c.muted("This is the app — connect a device to get started:");
232
+ let picked = false; // an action was chosen: closing the overlay is a step, not a quit
233
+ app.overlay(
234
+ new WelcomePicker(
235
+ welcomeItems(devices.length, applyBackendEnv().port),
236
+ (item) => {
237
+ picked = true;
238
+ app.closeOverlay();
239
+ void runWelcomeAction(app, String(item.value));
240
+ },
241
+ () => {
242
+ picked = true;
243
+ app.exit();
244
+ },
245
+ subtitle,
246
+ ),
247
+ () => {
248
+ if (!picked) app.exit(); // ctrl+c on the welcome screen quits the app
249
+ },
250
+ );
251
+ }
252
+
253
+ /**
254
+ * A welcome sub-picker (switch device, confirm local…): picking runs the action;
255
+ * every other close — esc, ctrl+c — comes back to the welcome screen.
256
+ */
257
+ function stepPicker(app: App, label: string, items: SelectItem[], onPickValue: (value: string) => void, subtitle?: string) {
258
+ let picked = false;
259
+ app.overlay(
260
+ new Picker(
261
+ label,
262
+ items,
263
+ (item) => {
264
+ picked = true;
265
+ app.closeOverlay();
266
+ onPickValue(String(item.value));
267
+ },
268
+ () => {
269
+ picked = true;
270
+ app.closeOverlay();
271
+ showWelcome(app);
272
+ },
273
+ undefined,
274
+ subtitle,
275
+ ),
276
+ () => {
277
+ if (!picked) showWelcome(app);
278
+ },
279
+ );
280
+ }
281
+
282
+ async function runWelcomeAction(app: App, action: string) {
283
+ if (action === "pair") return welcomePair(app);
284
+ if (action === "add-url") return welcomeAddUrl(app);
285
+ if (action === "detect-local") return welcomeLocal(app);
286
+ if (action === "switch-device") return welcomeSwitch(app);
287
+ }
288
+
289
+ /** [p] Pair this machine — the same LocalPairing path as `--pair` and /pair. */
290
+ async function welcomePair(app: App) {
291
+ const pasted = await app.prompt("Pairing code or pair URL", "");
292
+ if (pasted === undefined) return showWelcome(app);
293
+ let hub: string | undefined;
294
+ const first = parsePairArgs(pasted);
295
+ if (first.code && !first.hub && !first.pairUrl) {
296
+ const answer = await app.prompt("Hub origin (https://hub.example.com)", "");
297
+ if (answer === undefined) return showWelcome(app);
298
+ hub = answer.trim() || undefined;
299
+ }
300
+ const args = assemblePairArgs(pasted, hub);
301
+ if (!args) {
302
+ app.flash("Need a pair URL, or a code plus its hub origin — mint a code in the hub wizard.", true);
303
+ return showWelcome(app);
304
+ }
305
+ // Prime DPI_TOKEN/DPI_PORT from ~/.dpi/backend.env so LocalPairing.fromEnv() finds them.
306
+ applyBackendEnv();
307
+ await runPairing(app, args);
308
+ // The pairing panel stays open on its terminal state; welcome returns once it closes.
309
+ while (app.overlayCount > 0) await sleep(150);
310
+ showWelcome(app);
311
+ }
312
+
313
+ /** [a] Add a device by URL — verify against the device, then the --save path. */
314
+ async function welcomeAddUrl(app: App) {
315
+ const urlAnswer = await app.prompt("Device URL (http://host:port)", "");
316
+ if (urlAnswer === undefined || !urlAnswer.trim()) return showWelcome(app);
317
+ const token = await app.promptSecret("Device token (DPI_TOKEN)");
318
+ if (token === undefined || !token) return showWelcome(app);
319
+ const input = validateDeviceInput(urlAnswer, token);
320
+ if ("error" in input) {
321
+ app.flash(input.error, true);
322
+ return showWelcome(app);
323
+ }
324
+ app.flash(`Connecting to ${input.url}…`);
325
+ let info;
326
+ try {
327
+ info = await new DpiClient(input.url, input.token).device({ timeoutMs: 8000 });
328
+ } catch (e) {
329
+ app.flash(
330
+ isDpiError(e) && e.code === "unauthorized"
331
+ ? `token rejected by ${input.url}`
332
+ : `cannot reach ${input.url} (${(e as Error).message})`,
333
+ true,
334
+ );
335
+ return showWelcome(app);
336
+ }
337
+ const suggested = suggestDeviceId(new URL(input.url).hostname, info.name, info.hostname);
338
+ const id = await app.prompt("Save as", suggested);
339
+ if (id === undefined || !id.trim()) return showWelcome(app);
340
+ if (!DEVICE_ID.test(id.trim())) {
341
+ app.flash(`Device id must match ${DEVICE_ID} (got "${id.trim()}")`, true);
342
+ return showWelcome(app);
343
+ }
344
+ await saveAndOpen(app, id.trim(), input.url, input.token, info.name);
345
+ }
346
+
347
+ /** [d] Detect the local backend — probe, then offer to save + open it. */
348
+ async function welcomeLocal(app: App) {
349
+ const local = applyBackendEnv();
350
+ app.flash(`Probing 127.0.0.1:${local.port}…`);
351
+ const probe = local.token
352
+ ? await probeLocalBackend(localBackendUrl(local.port), local.token)
353
+ : ({ ok: false, reason: "unreachable" } as const);
354
+ if (!probe.ok) {
355
+ app.flash(describeLocalProbe(probe, local.port), true);
356
+ return showWelcome(app);
357
+ }
358
+ const url = localBackendUrl(local.port);
359
+ const id = suggestDeviceId(probe.info.hostname, probe.info.name);
360
+ stepPicker(
361
+ app,
362
+ `Local backend: ${probe.info.name || probe.info.hostname || url}`,
363
+ [{ value: "open", label: `Save as "${id}" and open`, description: `${url} │ token from DPI_TOKEN / backend.env` }],
364
+ () => void saveAndOpen(app, id, url, local.token!, probe.info.name),
365
+ );
366
+ }
367
+
368
+ /** [s] Switch device — the saved list; cancel comes back to the welcome screen. */
369
+ function welcomeSwitch(app: App) {
370
+ const devices = app.registry?.list() ?? [];
371
+ if (!devices.length) return showWelcome(app);
372
+ stepPicker(
373
+ app,
374
+ "Devices",
375
+ devices.map((d) => ({
376
+ value: d.id,
377
+ label: d.id,
378
+ description: `${d.kind} │ ${d.url ?? "tunnel"}${d.token ? "" : " │ no token"}`,
379
+ })),
380
+ (id) => void app.switchDevice(id),
381
+ );
382
+ }
383
+
384
+ /** The --save mechanism (registry.upsert, kind "url", source "ui"), then open. */
385
+ async function saveAndOpen(app: App, id: string, url: string, token: string, name?: string) {
386
+ if (!app.registry) return app.flash("No registry — start with --url/--token.", true);
387
+ try {
388
+ app.registry.upsert({ id, name: name || id, url, kind: "url", source: "ui", token });
389
+ } catch (e) {
390
+ app.flash((e as Error).message, true);
391
+ return showWelcome(app);
392
+ }
393
+ await app.switchDevice(id);
394
+ }
package/bin/dpi.mjs ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ // `dpi` — one command for the Durable Pi stack.
3
+ // dpi hub [--port N] [--host H] [--public-host H] web UI + device tunnel server
4
+ // dpi device device-side daemon
5
+ // dpi tui terminal admin client
6
+ // Every subcommand runs the TypeScript entry through tsx, so no build step is needed.
7
+ import { access } from "node:fs/promises";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ /** Subcommand → TypeScript entry, relative to this file. */
11
+ const COMMANDS = {
12
+ hub: "../apps/dpi-playground/apps/dpi-playground-hub/src/main.ts",
13
+ device: "../apps/dpi-backend/src/main.ts",
14
+ tui: "../apps/dpi-tui/src/main.ts",
15
+ };
16
+
17
+ const HELP = `dpi — Durable Pi (bare "dpi" opens the TUI)
18
+
19
+ Usage:
20
+ dpi hub [--port 4781] [--host 127.0.0.1] [--public-host <host>]
21
+ Web UI + device tunnel server (devices dial this).
22
+ dpi device
23
+ Device-side daemon (pairs to a hub, serves one machine's agents).
24
+ dpi tui
25
+ Terminal admin client.
26
+ dpi help | dpi --help | dpi -h
27
+ dpi version | dpi --version | dpi -v
28
+ dpi update [--check]
29
+ --check compare the installed version with the registry, change nothing
30
+ (no flag) install the latest published version globally
31
+
32
+ Environment (hub): DPI_HOME, DPI_HUB_PORT, DPI_HUB_HOST, DPI_HUB_PUBLIC_HOST,
33
+ DPI_HUB_ORIGIN, DPI_HUB_REQUIRE_TLS
34
+ Environment (device): DPI_HUB_URL, DPI_HOME
35
+ `;
36
+
37
+ /**
38
+ * tsx is a dependency of the published package, but a bare repository checkout has it only
39
+ * inside the per-app node_modules. Try the normal resolution first, then the known local
40
+ * copies, so `dpi` works in both places without a root install.
41
+ */
42
+ async function loadTsx() {
43
+ try {
44
+ return await import("tsx/esm/api");
45
+ } catch (error) {
46
+ if (error?.code !== "ERR_MODULE_NOT_FOUND") throw error;
47
+ }
48
+ const candidates = [
49
+ "../apps/dpi-playground/node_modules/tsx/dist/esm/api/index.mjs",
50
+ "../apps/dpi-backend/node_modules/tsx/dist/esm/api/index.mjs",
51
+ "../apps/dpi-sdk/node_modules/tsx/dist/esm/api/index.mjs",
52
+ "../apps/dpi-tui/node_modules/tsx/dist/esm/api/index.mjs",
53
+ ];
54
+ for (const relative of candidates) {
55
+ const url = new URL(relative, import.meta.url);
56
+ try {
57
+ await access(fileURLToPath(url));
58
+ return await import(url.href);
59
+ } catch {
60
+ // try the next one
61
+ }
62
+ }
63
+ throw new Error(
64
+ "dpi: cannot find tsx. Install the package (npm install -g my-durable) or run npm install in an app directory.",
65
+ );
66
+ }
67
+
68
+ const [first, ...rest] = process.argv.slice(2);
69
+
70
+ // Bare `dpi` opens the TUI — the app, not a help screen (convention of the
71
+ // reference clients: the product IS the terminal UI; subcommands are the extras).
72
+ const command = !first || first === "tui" ? "tui" : first;
73
+ const commandRest = !first ? [] : rest;
74
+
75
+ if (command === "help" || command === "--help" || command === "-h") {
76
+ process.stdout.write(HELP);
77
+ process.exit(0);
78
+ }
79
+
80
+ if (command === "version" || command === "--version" || command === "-v") {
81
+ const { readFileSync } = await import("node:fs");
82
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
83
+ process.stdout.write(`dpi (my-durable) ${pkg.version}\n`);
84
+ process.exit(0);
85
+ }
86
+
87
+ if (command === "update" || command === "upgrade") {
88
+ const { readFileSync } = await import("node:fs");
89
+ const { spawn } = await import("node:child_process");
90
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
91
+ const current = pkg.version;
92
+ const checkOnly = commandRest.includes("--check") || commandRest.includes("-c");
93
+
94
+ let latest;
95
+ try {
96
+ const res = await fetch("https://registry.npmjs.org/my-durable/latest", {
97
+ headers: { accept: "application/json" },
98
+ });
99
+ if (!res.ok) throw new Error(`registry responded ${res.status}`);
100
+ latest = (await res.json()).version;
101
+ } catch (error) {
102
+ process.stderr.write(`dpi update: cannot reach the npm registry (${error.message})\n`);
103
+ process.exit(1);
104
+ }
105
+
106
+ // Numeric triple compare; a prerelease tag on either side counts as older.
107
+ const rank = (v) => {
108
+ const [core, ...pre] = String(v).split("-");
109
+ const nums = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
110
+ return { nums: [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0], pre: pre.length > 0 };
111
+ };
112
+ const a = rank(current);
113
+ const b = rank(latest);
114
+ let order = 0;
115
+ for (let i = 0; i < 3; i += 1) {
116
+ if (a.nums[i] !== b.nums[i]) {
117
+ order = a.nums[i] < b.nums[i] ? -1 : 1;
118
+ break;
119
+ }
120
+ }
121
+ if (order === 0 && a.pre !== b.pre) order = a.pre ? -1 : 1;
122
+
123
+ if (order >= 0) {
124
+ process.stdout.write(`dpi ${current} is up to date (latest ${latest}).\n`);
125
+ process.exit(0);
126
+ }
127
+ process.stdout.write(`dpi ${current} -> ${latest} available.\n`);
128
+ if (checkOnly) {
129
+ process.stdout.write("Run `dpi update` to install it.\n");
130
+ process.exit(0);
131
+ }
132
+
133
+ const child = spawn("npm", ["install", "-g", `my-durable@${latest}`], { stdio: "inherit" });
134
+ child.on("error", (error) => {
135
+ process.stderr.write(`dpi update: cannot run npm (${error.message})\n`);
136
+ process.exit(1);
137
+ });
138
+ child.on("exit", (code) => {
139
+ if (code !== 0) {
140
+ process.stderr.write(`dpi update: npm exited ${code}; still on ${current}.\n`);
141
+ process.exit(code ?? 1);
142
+ }
143
+ process.stdout.write(`dpi updated ${current} -> ${latest}.\n`);
144
+ process.exit(0);
145
+ });
146
+ } else if (!Object.hasOwn(COMMANDS, command)) {
147
+ process.stderr.write(`dpi: unknown command '${command}'\n\n${HELP}`);
148
+ process.exit(2);
149
+ } else {
150
+ // The entries parse process.argv.slice(2) themselves: hand them their own args.
151
+ process.argv = [process.argv[0], process.argv[1], ...commandRest];
152
+ const { register } = await loadTsx();
153
+ register();
154
+ await import(COMMANDS[command]);
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-durable",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Durable Pi — the durable agent network: hub web UI, device backend daemon, TUI admin and the shared SDK, packaged as one runnable monorepo install",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,7 +15,7 @@
15
15
  "node": ">=22.0.0"
16
16
  },
17
17
  "bin": {
18
- "md": "bin/md.mjs"
18
+ "dpi": "bin/dpi.mjs"
19
19
  },
20
20
  "scripts": {
21
21
  "hub": "tsx apps/dpi-playground/apps/dpi-playground-hub/src/main.ts",