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.
- package/apps/dpi-backend/src/daemon.ts +21 -9
- package/apps/dpi-tui/src/main.ts +24 -0
- package/apps/dpi-tui/src/welcome.ts +44 -7
- package/bin/update.d.mts +2 -0
- package/bin/update.mjs +17 -4
- package/package.json +1 -1
- package/scripts/verify.mjs +6 -0
|
@@ -242,25 +242,37 @@ function pidFrom(paths: DaemonPaths): number | undefined {
|
|
|
242
242
|
|
|
243
243
|
// ---------------------------------------------------------------- commands
|
|
244
244
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
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
|
|
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(
|
|
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) ?? "?"})`);
|
package/apps/dpi-tui/src/main.ts
CHANGED
|
@@ -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
|
|
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
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
108
|
+
if (compareVersions(deps.nodeVersion, REQUIRED_NODE_FLOOR) < 0) {
|
|
100
109
|
printErr(
|
|
101
|
-
`dpi update:
|
|
110
|
+
`dpi update: Node ${deps.nodeVersion} is too old — durable needs Node >= ${REQUIRED_NODE_FLOOR} (node:sqlite).`,
|
|
102
111
|
);
|
|
103
|
-
printErr(" Upgrade Node
|
|
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.
|
|
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": {
|
package/scripts/verify.mjs
CHANGED
|
@@ -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
|
|