my-durable 0.0.10 → 0.0.12

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/bin/update.mjs ADDED
@@ -0,0 +1,198 @@
1
+ // `dpi update` — self-update with the two Windows hazards handled explicitly:
2
+ //
3
+ // 1. EBUSY: the running daemon (and a running TUI) hold files inside
4
+ // `…\npm\node_modules\my-durable\bin`, so npm cannot rename the package
5
+ // folder while they live. The updater therefore stops the device daemon
6
+ // first and starts it again afterwards — a background daemon must not be
7
+ // the reason an update fails, and it must not silently stay down either.
8
+ // 2. EBADENGINE: our dependencies need Node >= 22.19.0. npm only WARNS, then
9
+ // installs something that can fail at runtime. The updater refuses up front
10
+ // with an actionable message instead.
11
+ //
12
+ // Everything here is dependency-injected so it can be tested without npm, a
13
+ // network, or a real daemon.
14
+ import { spawn } from "node:child_process";
15
+
16
+ /** Numeric triple + prerelease flag. A prerelease counts as older. */
17
+ export function rank(version) {
18
+ const [core, ...pre] = String(version).trim().split("-");
19
+ const nums = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
20
+ return { nums: [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0], pre: pre.length > 0 };
21
+ }
22
+
23
+ /** -1 | 0 | 1 — semver-ish ordering, enough for "is there a newer release". */
24
+ export function compareVersions(a, b) {
25
+ const x = rank(a);
26
+ const y = rank(b);
27
+ for (let i = 0; i < 3; i += 1) {
28
+ if (x.nums[i] !== y.nums[i]) return x.nums[i] < y.nums[i] ? -1 : 1;
29
+ }
30
+ if (x.pre !== y.pre) return x.pre ? -1 : 1;
31
+ return 0;
32
+ }
33
+
34
+ /**
35
+ * Does `current` satisfy `required` when required looks like ">=22.19.0" /
36
+ * ">=22" / "22.x"? Only the forms our own package.json can carry are supported;
37
+ * an unknown form is treated as satisfied so a future syntax never blocks an
38
+ * update for no reason.
39
+ */
40
+ export function satisfiesEngine(current, required) {
41
+ const m = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(String(required).trim());
42
+ if (!m) return true;
43
+ const want = [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)];
44
+ const have = rank(current).nums;
45
+ for (let i = 0; i < 3; i += 1) {
46
+ if (have[i] !== want[i]) return have[i] > want[i];
47
+ }
48
+ return true;
49
+ }
50
+
51
+ /** npm on Windows reports negative errno values as huge unsigned ints. */
52
+ export function normalizeExitCode(code) {
53
+ if (typeof code !== "number") return 1;
54
+ return code > 2 ** 31 ? code - 2 ** 32 : code;
55
+ }
56
+
57
+ /** A failed install that means "a process still holds the package folder". */
58
+ export function isBusyFailure(text) {
59
+ return /EBUSY|EPERM|resource busy or locked/i.test(String(text));
60
+ }
61
+
62
+ /**
63
+ * Run the update. Deps:
64
+ * argv — process argv (for --check)
65
+ * pkg — this package's package.json contents
66
+ * platform — process.platform
67
+ * nodeVersion — process.versions.node
68
+ * fetchLatest — async () => version string
69
+ * daemon — { running(): Promise<boolean>, stop(): Promise<void>, start(): Promise<void> }
70
+ * runNpm — (version) => Promise<{ code: number; output: string }>
71
+ * print / printErr
72
+ */
73
+ export async function runUpdate(deps) {
74
+ const { pkg, print, printErr } = deps;
75
+ const current = pkg.version;
76
+ const checkOnly = deps.argv.includes("--check") || deps.argv.includes("-c");
77
+
78
+ let latest;
79
+ try {
80
+ latest = await deps.fetchLatest();
81
+ } catch (error) {
82
+ printErr(`dpi update: cannot reach the npm registry (${error.message})`);
83
+ return 1;
84
+ }
85
+
86
+ const order = compareVersions(current, latest);
87
+ if (order >= 0) {
88
+ print(`dpi ${current} is up to date (latest ${latest}).`);
89
+ return 0;
90
+ }
91
+ print(`dpi ${current} -> ${latest} available.`);
92
+ if (checkOnly) {
93
+ print("Run `dpi update` to install it.");
94
+ return 0;
95
+ }
96
+
97
+ // Refuse before touching npm: a warned-about engine is a broken install later.
98
+ const required = pkg.engines?.node;
99
+ if (required && !satisfiesEngine(deps.nodeVersion, required)) {
100
+ printErr(
101
+ `dpi update: this release needs Node ${required}, you have ${deps.nodeVersion}.`,
102
+ );
103
+ printErr(" Upgrade Node first (nvm install --lts / winget upgrade OpenJS.NodeJS.LTS), then retry.");
104
+ return 1;
105
+ }
106
+
107
+ // Windows: the daemon (and a TUI) hold files inside the package folder, so npm
108
+ // cannot rename it. Stop the daemon, install, then bring it back.
109
+ const wasRunning = await deps.daemon.running();
110
+ if (wasRunning) {
111
+ print("stopping the device daemon so npm can replace the package…");
112
+ await deps.daemon.stop();
113
+ }
114
+
115
+ let result = await deps.runNpm(latest);
116
+
117
+ // A stale TUI can still hold the folder; retry once with the reason spelled out.
118
+ if (result.code !== 0 && isBusyFailure(result.output)) {
119
+ printErr("dpi update: the package folder is still in use — close any running `dpi tui` window.");
120
+ printErr(" Retrying once…");
121
+ result = await deps.runNpm(latest);
122
+ }
123
+
124
+ if (result.code !== 0) {
125
+ printErr(`dpi update: npm failed (exit ${normalizeExitCode(result.code)}); still on ${current}.`);
126
+ if (isBusyFailure(result.output))
127
+ printErr(" Close every dpi window (tui + device) and run `dpi update` again.");
128
+ if (wasRunning) await deps.daemon.start();
129
+ return 1;
130
+ }
131
+
132
+ print(`dpi updated ${current} -> ${latest}.`);
133
+ if (wasRunning) {
134
+ print("restarting the device daemon…");
135
+ await deps.daemon.start();
136
+ }
137
+ return 0;
138
+ }
139
+
140
+ // ------------------------------------------------------------------ real deps
141
+
142
+ /** Spawn npm correctly on every platform (npm is npm.cmd on Windows). */
143
+ export function spawnNpm(args) {
144
+ return new Promise((resolve) => {
145
+ const onWindows = process.platform === "win32";
146
+ const child = spawn(onWindows ? "npm.cmd" : "npm", args, {
147
+ stdio: ["ignore", "pipe", "pipe"],
148
+ shell: onWindows,
149
+ });
150
+ let output = "";
151
+ child.stdout?.on("data", (x) => {
152
+ output += x;
153
+ process.stdout.write(x);
154
+ });
155
+ child.stderr?.on("data", (x) => {
156
+ output += x;
157
+ process.stderr.write(x);
158
+ });
159
+ child.on("error", (error) => resolve({ code: 1, output: `${output}${error.message}` }));
160
+ child.on("exit", (code) => resolve({ code: code ?? 1, output }));
161
+ });
162
+ }
163
+
164
+ export async function fetchLatestVersion() {
165
+ const res = await fetch("https://registry.npmjs.org/my-durable/latest", {
166
+ headers: { accept: "application/json" },
167
+ });
168
+ if (!res.ok) throw new Error(`registry responded ${res.status}`);
169
+ const version = (await res.json()).version;
170
+ // This string reaches a command line (shell on Windows): a malformed value
171
+ // must stop the update instead of running.
172
+ if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/.test(String(version)))
173
+ throw new Error(`registry returned a malformed version: ${version}`);
174
+ return version;
175
+ }
176
+
177
+ /** Daemon control through the same CLI, so update needs no TS import. */
178
+ export function daemonControl(dpiPath, port) {
179
+ const call = (args) =>
180
+ new Promise((resolve) => {
181
+ const child = spawn(process.execPath, [dpiPath, ...args], { stdio: "ignore" });
182
+ child.on("error", () => resolve(1));
183
+ child.on("exit", (code) => resolve(code ?? 1));
184
+ });
185
+ const running = async () => {
186
+ try {
187
+ const res = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(800) });
188
+ return res.ok;
189
+ } catch {
190
+ return false;
191
+ }
192
+ };
193
+ return {
194
+ running,
195
+ stop: () => call(["device", "stop"]),
196
+ start: () => call(["device", "start"]),
197
+ };
198
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-durable",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
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": {
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "homepage": "https://github.com/baphuongna/my-durable#readme",
14
14
  "engines": {
15
- "node": ">=22.0.0"
15
+ "node": ">=22.19.0"
16
16
  },
17
17
  "bin": {
18
18
  "dpi": "bin/dpi.mjs"
@@ -141,6 +141,11 @@ const SUITES = {
141
141
  cmd: ["node", "tests/newsession-ui.mjs"],
142
142
  title: "real PTY session creation on a keyless device + empty-state layout",
143
143
  },
144
+ "ui:tui-keymap": {
145
+ cwd: "apps/dpi-tui",
146
+ cmd: ["node", "tests/keymap-ui.mjs"],
147
+ title: "real PTY keymap: every advertised key drives a real backend",
148
+ },
144
149
  "ui:tui-welcome": {
145
150
  cwd: "apps/dpi-tui",
146
151
  cmd: ["node", "tests/welcome-ui.mjs"],
@@ -165,6 +170,7 @@ const PHASE_SUITES = {
165
170
  "ui:pairing",
166
171
  "ui:tui",
167
172
  "ui:tui-session",
173
+ "ui:tui-keymap",
168
174
  "ui:tui-welcome",
169
175
  ],
170
176
  };