my-durable 0.0.13 → 0.0.15

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.
@@ -242,25 +242,37 @@ function pidFrom(paths: DaemonPaths): number | undefined {
242
242
 
243
243
  // ---------------------------------------------------------------- commands
244
244
 
245
- async function cmdStart(io: Io, rest: string[]): Promise<number> {
246
- const paths = daemonPaths(io.env);
247
- if ((await health(io))?.ok) {
248
- io.print(`already running (pid ${pidFrom(paths) ?? "?"}) on port ${portOf(io)}`);
249
- return 0;
250
- }
251
- const dpi = io.env.DPI_BIN ?? (process.argv[1]?.endsWith(".mjs") ? process.argv[1] : "");
245
+ /** The spawn `dpi device start` performs — exported so the TUI can bring the
246
+ * local backend up itself instead of telling the user to open another shell. */
247
+ export function startBackendDetached(
248
+ env: Record<string, string | undefined>,
249
+ rest: string[] = [],
250
+ ): { ok: boolean; pid?: number; log: string; reason?: string } {
251
+ const paths = daemonPaths(env);
252
+ const dpi = env.DPI_BIN ?? (process.argv[1]?.endsWith(".mjs") ? process.argv[1] : "");
252
253
  if (!dpi || !existsSync(dpi))
253
- return io.print("start needs the dpi launcher (run via `dpi device start`)"), 1;
254
+ return { ok: false, log: paths.log, reason: "no dpi launcher found" };
254
255
  mkdirSync(paths.home, { recursive: true, mode: 0o700 });
255
256
  const fd = openSync(paths.log, "a");
256
257
  const child = spawn(process.execPath, [dpi, "device", ...rest], {
257
258
  detached: true,
258
259
  stdio: ["ignore", fd, fd],
259
- env: cleanChildEnv(io.env),
260
+ env: cleanChildEnv(env),
260
261
  cwd: dirname(dpi),
261
262
  });
262
263
  child.unref();
263
264
  closeSync(fd);
265
+ return { ok: true, pid: child.pid, log: paths.log };
266
+ }
267
+
268
+ async function cmdStart(io: Io, rest: string[]): Promise<number> {
269
+ const paths = daemonPaths(io.env);
270
+ if ((await health(io))?.ok) {
271
+ io.print(`already running (pid ${pidFrom(paths) ?? "?"}) on port ${portOf(io)}`);
272
+ return 0;
273
+ }
274
+ const started = startBackendDetached(io.env, rest);
275
+ if (!started.ok) return io.print(started.reason ?? "start failed"), 1;
264
276
  io.print(`starting dpi device … (log: ${paths.log})`);
265
277
  if (await waitHealthUp(io, 20_000)) {
266
278
  io.print(`running on http://127.0.0.1:${portOf(io)} (pid ${pidFrom(paths) ?? "?"})`);
@@ -112,6 +112,16 @@ async function start(device: DeviceRef, client: DpiClient, info: DeviceInfo, opt
112
112
  if (opts.devicePicker) pickDevice(app);
113
113
  }
114
114
 
115
+ /** Loopback = the device this very machine runs; the TUI may start it itself. */
116
+ function isLoopbackUrl(url: string): boolean {
117
+ try {
118
+ const h = new URL(url).hostname.toLowerCase();
119
+ return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1";
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
115
125
  // The app opens first; connection is a state, not a precondition. With no explicit
116
126
  // device/url/pair: one saved device opens it directly, several open on the first
117
127
  // with the device picker on top, none open the welcome screen.
@@ -127,6 +137,19 @@ if (startup.kind === "welcome") {
127
137
  const info = await client.device({ timeoutMs: 8000 });
128
138
  await start(device, client, info, { devicePicker: startup.kind === "picker" });
129
139
  } catch (e) {
140
+ // The device this machine itself runs is loopback: nobody else can start
141
+ // it, so we do — exactly what `dpi device start` would, without leaving
142
+ // the TUI. A remote (tunnel) device stays an offline notice.
143
+ let info2: Awaited<ReturnType<typeof client.device>> | undefined;
144
+ if (isLoopbackUrl(device.url)) {
145
+ const { ensureLocalBackend } = await import("./welcome.ts");
146
+ const port = Number(new URL(device.url).port) || 4780;
147
+ const r = await ensureLocalBackend({ port, token: device.token });
148
+ if (r.probe.ok) info2 = await client.device({ timeoutMs: 8000 });
149
+ }
150
+ if (info2) {
151
+ await start(device, client, info2, { devicePicker: startup.kind === "picker" });
152
+ } else {
130
153
  // An auto-connected device that is unreachable falls back to a screen that says
131
154
  // so and offers the picker + welcome actions — never back to usage text.
132
155
  showWelcome(new App(WELCOME_DEVICE, new DpiClient(WELCOME_DEVICE.url, WELCOME_DEVICE.token), emptyDeviceInfo(), registry), {
@@ -135,6 +158,7 @@ if (startup.kind === "welcome") {
135
158
  ? `${device.id}: token rejected by ${device.url}`
136
159
  : `${device.id} is offline at ${device.url} (${(e as Error).message})`,
137
160
  });
161
+ }
138
162
  }
139
163
  } else if (url) {
140
164
  const token = values.token ?? process.env.DPI_TOKEN ?? "";
@@ -12,6 +12,7 @@
12
12
  import { existsSync, readFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { matchesKey, type SelectItem } from "@earendil-works/pi-tui";
15
+ import { startBackendDetached } from "../../dpi-backend/src/daemon.ts";
15
16
  import { DpiClient } from "../../dpi-sdk/src/client.ts";
16
17
  import type { DeviceInfo } from "../../dpi-sdk/src/api.ts";
17
18
  import { isDpiError } from "../../dpi-sdk/src/errors.ts";
@@ -156,7 +157,41 @@ export function describeLocalProbe(probe: LocalProbe, port: number): string {
156
157
  if (probe.ok) return `local dpi-backend on 127.0.0.1:${port}: ${probe.info.name || probe.info.hostname || "unnamed device"}`;
157
158
  if (probe.reason === "unauthorized")
158
159
  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
+ return `no local dpi-backend on 127.0.0.1:${port} — auto-start failed; run \`dpi device start\` and check ${join(dpiHome(), "device.log")}`;
161
+ }
162
+
163
+ export type EnsureStarter = () => { ok: boolean; log: string; pid?: number; reason?: string };
164
+ export type EnsureResult = { probe: LocalProbe; started: boolean; log?: string; reason?: string };
165
+
166
+ /**
167
+ * Probe the local backend and, if nothing answers, START one the way
168
+ * `dpi device start` does (detached, log to <home>/device.log) and wait for it.
169
+ * herdr spawns its own server; we do the same for the loopback device. A token
170
+ * rejection is never auto-started away — that's a credential problem.
171
+ */
172
+ export async function ensureLocalBackend(
173
+ opts: { port?: number; token?: string; fetchImpl?: typeof fetch; start?: EnsureStarter; waitMs?: number } = {},
174
+ ): Promise<EnsureResult> {
175
+ const port = opts.port ?? applyBackendEnv().port;
176
+ const probeOf = async (): Promise<LocalProbe> => {
177
+ // a first boot WRITES backend.env, so the token can appear mid-wait
178
+ const token = opts.token ?? applyBackendEnv({ ...process.env, ...(opts.token ? { DPI_TOKEN: opts.token } : {}) }).token;
179
+ if (!token) return { ok: false, reason: "unreachable" };
180
+ return probeLocalBackend(localBackendUrl(port), token, opts.fetchImpl);
181
+ };
182
+ const first = await probeOf();
183
+ if (first.ok || first.reason === "unauthorized") return { probe: first, started: false };
184
+ const start: EnsureStarter = opts.start ?? (() => startBackendDetached(process.env));
185
+ const r = start();
186
+ if (!r.ok) return { probe: first, started: false, reason: r.reason, log: r.log };
187
+ const deadline = Date.now() + (opts.waitMs ?? 15_000);
188
+ let last: LocalProbe = first;
189
+ while (Date.now() < deadline) {
190
+ await new Promise((res) => setTimeout(res, 400));
191
+ last = await probeOf();
192
+ if (last.ok) return { probe: last, started: true, log: r.log, reason: `pid ${r.pid}` };
193
+ }
194
+ return { probe: last, started: true, log: r.log };
160
195
  }
161
196
 
162
197
  // ------------------------------------------------------------------ ids and device input
@@ -354,20 +389,22 @@ async function welcomeAddUrl(app: App) {
354
389
  async function welcomeLocal(app: App) {
355
390
  const local = applyBackendEnv();
356
391
  app.flash(`Probing 127.0.0.1:${local.port}…`);
357
- const probe = local.token
358
- ? await probeLocalBackend(localBackendUrl(local.port), local.token)
359
- : ({ ok: false, reason: "unreachable" } as const);
360
- if (!probe.ok) {
361
- app.flash(describeLocalProbe(probe, local.port), true);
392
+ const ensured = await ensureLocalBackend({ port: local.port, token: local.token });
393
+ if (ensured.started && ensured.probe.ok) app.flash(`started the local dpi-backend (${ensured.reason})`);
394
+ if (!ensured.probe.ok) {
395
+ app.flash(describeLocalProbe(ensured.probe, local.port), true);
362
396
  return showWelcome(app);
363
397
  }
398
+ const probe = ensured.probe;
364
399
  const url = localBackendUrl(local.port);
365
400
  const id = suggestDeviceId(probe.info.hostname, probe.info.name);
366
401
  stepPicker(
367
402
  app,
368
403
  `Local backend: ${probe.info.name || probe.info.hostname || url}`,
369
404
  [{ value: "open", label: `Save as "${id}" and open`, description: `${url} │ token from DPI_TOKEN / backend.env` }],
370
- () => void saveAndOpen(app, id, url, local.token!, probe.info.name),
405
+ // local.token was read BEFORE a first boot may have written backend.env —
406
+ // re-resolve, the file exists now.
407
+ () => void saveAndOpen(app, id, url, applyBackendEnv().token!, probe.info.name),
371
408
  );
372
409
  }
373
410
 
package/bin/update.d.mts CHANGED
@@ -22,3 +22,5 @@ export function daemonControl(
22
22
  dpiPath: string,
23
23
  port: number,
24
24
  ): { running: () => Promise<boolean>; stop: () => Promise<number>; start: () => Promise<number> };
25
+ /** Below this the app cannot run at all (node:sqlite). The only hard block. */
26
+ export const REQUIRED_NODE_FLOOR: string;
package/bin/update.mjs CHANGED
@@ -31,6 +31,12 @@ export function compareVersions(a, b) {
31
31
  return 0;
32
32
  }
33
33
 
34
+ /**
35
+ * The floor we KNOW breaks: below 22.5.0 there is no node:sqlite, and the
36
+ * device dies at boot. This — and only this — is a hard block.
37
+ */
38
+ export const REQUIRED_NODE_FLOOR = "22.5.0";
39
+
34
40
  /**
35
41
  * Does `current` satisfy `required` when required looks like ">=22.19.0" /
36
42
  * ">=22" / "22.x"? Only the forms our own package.json can carry are supported;
@@ -94,15 +100,22 @@ export async function runUpdate(deps) {
94
100
  return 0;
95
101
  }
96
102
 
97
- // Refuse before touching npm: a warned-about engine is a broken install later.
103
+ // pi's philosophy, which we follow: engines metadata is ADVICE. pi declares
104
+ // node >=22.19.0 too, npm only warns, and nothing enforces it at runtime —
105
+ // so an update proceeds with a visible warning. We hard-block ONLY below the
106
+ // floor we know is broken (no node:sqlite ⇒ the device cannot even boot).
98
107
  const required = pkg.engines?.node;
99
- if (required && !satisfiesEngine(deps.nodeVersion, required)) {
108
+ if (compareVersions(deps.nodeVersion, REQUIRED_NODE_FLOOR) < 0) {
100
109
  printErr(
101
- `dpi update: this release needs Node ${required}, you have ${deps.nodeVersion}.`,
110
+ `dpi update: Node ${deps.nodeVersion} is too old — durable needs Node >= ${REQUIRED_NODE_FLOOR} (node:sqlite).`,
102
111
  );
103
- printErr(" Upgrade Node first (nvm install --lts / winget upgrade OpenJS.NodeJS.LTS), then retry.");
112
+ printErr(" Upgrade Node (nvm install --lts / winget upgrade OpenJS.NodeJS.LTS) and retry.");
104
113
  return 1;
105
114
  }
115
+ if (required && !satisfiesEngine(deps.nodeVersion, required)) {
116
+ print(`note: this release declares Node ${required}, you have ${deps.nodeVersion}.`);
117
+ print(` like pi, it installs and runs anyway; upgrade Node if anything misbehaves.`);
118
+ }
106
119
 
107
120
  // Windows: the daemon (and a TUI) hold files inside the package folder, so npm
108
121
  // cannot rename it. Stop the daemon, install, then bring it back.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-durable",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "Durable Pi \u2014 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": {
@@ -151,6 +151,11 @@ const SUITES = {
151
151
  cmd: ["node", "tests/welcome-ui.mjs"],
152
152
  title: "real PTY welcome screen, detect-local and quit",
153
153
  },
154
+ "ui:tui-ensure": {
155
+ cwd: "apps/dpi-tui",
156
+ cmd: ["node", "tests/ensure-ui.mjs"],
157
+ title: "real PTY [d] auto-starts the local dpi-backend and opens it",
158
+ },
154
159
  };
155
160
 
156
161
  const PHASE_GATES = ["test:verification", "check:sdk", "check:backend", "contract:backend", "build:playground", "check:tui"];
@@ -172,6 +177,7 @@ const PHASE_SUITES = {
172
177
  "ui:tui-session",
173
178
  "ui:tui-keymap",
174
179
  "ui:tui-welcome",
180
+ "ui:tui-ensure",
175
181
  ],
176
182
  };
177
183