@firstpick/pi-package-remote-webui 0.1.0

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/index.ts ADDED
@@ -0,0 +1,284 @@
1
+ import { spawn, type ChildProcessByStdio } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
5
+ import type { Readable } from "node:stream";
6
+ import { fileURLToPath } from "node:url";
7
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ REMOTE_WIDGET_KEY,
10
+ RemoteWebuiController,
11
+ buildRemoteWidgetLines,
12
+ closeRemoteWebui,
13
+ formatStatus,
14
+ generateQrLines,
15
+ openRemoteWebui,
16
+ parseRemoteArgs,
17
+ requiresOpenConfirmation,
18
+ usage,
19
+ } from "./lib/remote-core.mjs";
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const packageRoot = __dirname;
23
+ const require = createRequire(import.meta.url);
24
+ const LOCAL_HOST = "127.0.0.1";
25
+
26
+ const OPEN_WARNING = [
27
+ "Pi Web UI can control Pi/WebUI and run allowed tools from connected browsers.",
28
+ "Remote PIN auth is off by default; enable it in Web UI Controls if you want a 4-digit PIN for non-local clients.",
29
+ "",
30
+ "Only open this on a trusted local network.",
31
+ "",
32
+ "Open to local network?",
33
+ ].join("\n");
34
+
35
+ type WebuiChild = ChildProcessByStdio<null, Readable, Readable>;
36
+
37
+ type RemoteOptions = {
38
+ action: "open" | "status" | "close" | "refresh";
39
+ port: number;
40
+ name?: string;
41
+ yes: boolean;
42
+ };
43
+
44
+ function resolveExistingPath(candidate: string | undefined): string | undefined {
45
+ if (!candidate) return undefined;
46
+ return existsSync(candidate) ? candidate : undefined;
47
+ }
48
+
49
+ function resolveWebuiBin(): string {
50
+ const candidates: Array<() => string | undefined> = [
51
+ () => resolveExistingPath(require.resolve("@firstpick/pi-package-webui/bin/pi-webui.mjs")),
52
+ () => resolveExistingPath(path.join(path.dirname(require.resolve("@firstpick/pi-package-webui/package.json")), "bin", "pi-webui.mjs")),
53
+ () => resolveExistingPath(path.resolve(packageRoot, "..", "pi-package-webui", "bin", "pi-webui.mjs")),
54
+ ];
55
+
56
+ for (const candidate of candidates) {
57
+ try {
58
+ const resolved = candidate();
59
+ if (resolved) return resolved;
60
+ } catch {
61
+ // Try next resolution strategy.
62
+ }
63
+ }
64
+
65
+ throw new Error("Could not locate @firstpick/pi-package-webui/bin/pi-webui.mjs. Install @firstpick/pi-package-webui or run from the npm-packages checkout.");
66
+ }
67
+
68
+ function appendBoundedOutput(current: string, chunk: Buffer | string, maxChars = 20_000): string {
69
+ const next = current + String(chunk);
70
+ return next.length > maxChars ? next.slice(-maxChars) : next;
71
+ }
72
+
73
+ function releaseStartedChild(child: WebuiChild): void {
74
+ child.stdout.removeAllListeners("data");
75
+ child.stderr.removeAllListeners("data");
76
+ (child.stdout as Readable & { unref?: () => void }).unref?.();
77
+ (child.stderr as Readable & { unref?: () => void }).unref?.();
78
+ child.unref();
79
+ }
80
+
81
+ function terminateFailedChild(child: WebuiChild): void {
82
+ if (child.exitCode === null) child.kill("SIGTERM");
83
+ setTimeout(() => {
84
+ if (child.exitCode === null) child.kill("SIGKILL");
85
+ }, 2_000).unref?.();
86
+ child.stdout.destroy();
87
+ child.stderr.destroy();
88
+ }
89
+
90
+ async function spawnWebui(options: RemoteOptions, ctx: ExtensionCommandContext): Promise<void> {
91
+ const webuiBin = resolveWebuiBin();
92
+ const args = [webuiBin, "--host", LOCAL_HOST, "--port", String(options.port), "--cwd", ctx.cwd];
93
+ if (options.name) args.push("--name", options.name);
94
+
95
+ const child = spawn(process.execPath, args, {
96
+ cwd: ctx.cwd,
97
+ env: { ...process.env },
98
+ detached: true,
99
+ stdio: ["ignore", "pipe", "pipe"],
100
+ windowsHide: true,
101
+ });
102
+
103
+ await new Promise<void>((resolve, reject) => {
104
+ let settled = false;
105
+ let output = "";
106
+ const finish = (error?: Error) => {
107
+ if (settled) return;
108
+ settled = true;
109
+ clearTimeout(startedTimer);
110
+ child.off("error", onError);
111
+ child.off("exit", onExit);
112
+ if (error) {
113
+ terminateFailedChild(child);
114
+ reject(error);
115
+ } else {
116
+ releaseStartedChild(child);
117
+ resolve();
118
+ }
119
+ };
120
+ const onError = (error: Error) => finish(error);
121
+ const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
122
+ finish(new Error(`Pi Web UI exited before startup (${code ?? signal ?? "unknown"}). Output:\n${output.trim() || "(no output)"}`));
123
+ };
124
+
125
+ child.stdout.on("data", (chunk) => {
126
+ output = appendBoundedOutput(output, chunk);
127
+ });
128
+ child.stderr.on("data", (chunk) => {
129
+ output = appendBoundedOutput(output, chunk);
130
+ });
131
+ child.once("error", onError);
132
+ child.once("exit", onExit);
133
+
134
+ // If the process survives this short preflight, the controller health poll
135
+ // will perform the authoritative readiness check.
136
+ const startedTimer = setTimeout(() => finish(), 250);
137
+ });
138
+ }
139
+
140
+ function setRemoteStatus(ctx: ExtensionCommandContext, text?: string): void {
141
+ ctx.ui.setStatus(REMOTE_WIDGET_KEY, text);
142
+ }
143
+
144
+ function clearRemoteWidget(ctx: ExtensionCommandContext): void {
145
+ ctx.ui.setWidget(REMOTE_WIDGET_KEY, undefined);
146
+ }
147
+
148
+ function truncatePlainLine(line: string, width: number): string {
149
+ if (width <= 0) return "";
150
+ return line.length > width ? line.slice(0, width) : line;
151
+ }
152
+
153
+ function formatWidgetLine(line: string, width: number): string {
154
+ if (width <= 0) return "";
155
+ if (width === 1) return " ";
156
+ return ` ${truncatePlainLine(line, width - 1)}`;
157
+ }
158
+
159
+ function setFullRemoteWidget(ctx: ExtensionCommandContext, lines: string[]): void {
160
+ if (ctx.mode !== "tui") {
161
+ ctx.ui.setWidget(REMOTE_WIDGET_KEY, lines, { placement: "aboveEditor" });
162
+ return;
163
+ }
164
+
165
+ const widgetLines = lines.map((line) => String(line ?? ""));
166
+ ctx.ui.setWidget(
167
+ REMOTE_WIDGET_KEY,
168
+ () => ({
169
+ render: (width: number) => widgetLines.map((line) => formatWidgetLine(line, width)),
170
+ invalidate: () => {},
171
+ }),
172
+ { placement: "aboveEditor" },
173
+ );
174
+ }
175
+
176
+ async function renderRemoteWidget(ctx: ExtensionCommandContext, result: { url: string; network?: unknown; started?: boolean }): Promise<void> {
177
+ const qrLines = await generateQrLines(result.url);
178
+ const lines = buildRemoteWidgetLines({ url: result.url, qrLines, network: result.network, started: result.started });
179
+ setFullRemoteWidget(ctx, lines);
180
+ setRemoteStatus(ctx, `remote ${result.url}`);
181
+ }
182
+
183
+ async function confirmRemoteOpen(options: RemoteOptions, ctx: ExtensionCommandContext): Promise<boolean> {
184
+ if (!requiresOpenConfirmation(options)) return true;
185
+ if (!ctx.hasUI) throw new Error("/remote requires confirmation. Re-run with /remote --yes in non-interactive modes.");
186
+ return await ctx.ui.confirm("Open Pi Web UI to LAN?", OPEN_WARNING);
187
+ }
188
+
189
+ async function handleStatus(options: RemoteOptions, ctx: ExtensionCommandContext, controller: RemoteWebuiController): Promise<void> {
190
+ setRemoteStatus(ctx, "checking remote webui…");
191
+ try {
192
+ const status = await controller.status(options.port);
193
+ ctx.ui.notify(formatStatus(status), status.online ? "info" : "warning");
194
+ } finally {
195
+ setRemoteStatus(ctx, undefined);
196
+ }
197
+ }
198
+
199
+ async function handleRefresh(options: RemoteOptions, ctx: ExtensionCommandContext, controller: RemoteWebuiController): Promise<void> {
200
+ setRemoteStatus(ctx, "refreshing remote QR…");
201
+ try {
202
+ const status = await controller.status(options.port);
203
+ if (!status.online) {
204
+ clearRemoteWidget(ctx);
205
+ ctx.ui.notify(`${formatStatus(status)}\n\nRun /remote to start and open it.`, "warning");
206
+ return;
207
+ }
208
+ if (!status.network?.open || !status.url || !/^https?:\/\//i.test(status.url)) {
209
+ clearRemoteWidget(ctx);
210
+ ctx.ui.notify(`${formatStatus(status)}\n\nRun /remote to open LAN access and show a QR code.`, "warning");
211
+ return;
212
+ }
213
+ await renderRemoteWidget(ctx, { url: status.url, network: status.network, started: false });
214
+ ctx.ui.notify(`Pi Remote WebUI QR refreshed:\n${status.url}`, "info");
215
+ } finally {
216
+ setRemoteStatus(ctx, undefined);
217
+ }
218
+ }
219
+
220
+ async function handleClose(options: RemoteOptions, ctx: ExtensionCommandContext, controller: RemoteWebuiController): Promise<void> {
221
+ setRemoteStatus(ctx, "closing remote webui…");
222
+ try {
223
+ const result = await closeRemoteWebui(options, { controller });
224
+ clearRemoteWidget(ctx);
225
+ setRemoteStatus(ctx, undefined);
226
+ if (!result.online) {
227
+ ctx.ui.notify("Pi Web UI is not running; cleared the remote QR widget.", "warning");
228
+ return;
229
+ }
230
+ ctx.ui.notify("Pi Web UI LAN access closed. The local Web UI server may keep running on localhost.", "info");
231
+ } finally {
232
+ setRemoteStatus(ctx, undefined);
233
+ }
234
+ }
235
+
236
+ async function handleOpen(options: RemoteOptions, ctx: ExtensionCommandContext, controller: RemoteWebuiController): Promise<void> {
237
+ if (!(await confirmRemoteOpen(options, ctx))) {
238
+ ctx.ui.notify("Remote WebUI cancelled; LAN access was not opened.", "info");
239
+ return;
240
+ }
241
+
242
+ setRemoteStatus(ctx, "opening remote webui…");
243
+ const result = await openRemoteWebui(options, {
244
+ controller,
245
+ startWebui: async (startOptions: RemoteOptions) => spawnWebui(startOptions, ctx),
246
+ });
247
+ await renderRemoteWidget(ctx, result);
248
+ ctx.ui.notify(`Pi Remote WebUI ready:\n${result.url}\n\nScan the QR code above from your phone.`, "info");
249
+ }
250
+
251
+ export default function remoteWebuiExtension(pi: ExtensionAPI) {
252
+ pi.registerCommand("remote", {
253
+ description: "Open Pi Web UI to a trusted LAN and show a mobile QR code",
254
+ handler: async (args, ctx) => {
255
+ let options: RemoteOptions;
256
+ try {
257
+ options = parseRemoteArgs(args) as RemoteOptions;
258
+ } catch (error) {
259
+ ctx.ui.notify(`${error instanceof Error ? error.message : String(error)}\n${usage()}`, "error");
260
+ return;
261
+ }
262
+
263
+ const controller = new RemoteWebuiController();
264
+ try {
265
+ if (options.action === "status") {
266
+ await handleStatus(options, ctx, controller);
267
+ return;
268
+ }
269
+ if (options.action === "refresh") {
270
+ await handleRefresh(options, ctx, controller);
271
+ return;
272
+ }
273
+ if (options.action === "close") {
274
+ await handleClose(options, ctx, controller);
275
+ return;
276
+ }
277
+ await handleOpen(options, ctx, controller);
278
+ } catch (error) {
279
+ setRemoteStatus(ctx, undefined);
280
+ ctx.ui.notify(`Pi Remote WebUI failed:\n${error instanceof Error ? error.message : String(error)}\n${usage()}`, "error");
281
+ }
282
+ },
283
+ });
284
+ }
@@ -0,0 +1,350 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+
3
+ export const DEFAULT_PORT = 31415;
4
+ export const DEFAULT_LOCAL_HOST = "127.0.0.1";
5
+ export const DEFAULT_START_TIMEOUT_MS = 12_000;
6
+ export const DEFAULT_NETWORK_TIMEOUT_MS = 8_000;
7
+ export const DEFAULT_POLL_MS = 250;
8
+
9
+ export const REMOTE_WIDGET_KEY = "pi-remote-webui";
10
+
11
+ const ACTIONS = new Set(["open", "status", "close", "refresh"]);
12
+
13
+ export function tokenizeArgs(input = "") {
14
+ const tokens = [];
15
+ let current = "";
16
+ let quote;
17
+ let escaped = false;
18
+
19
+ for (const char of String(input || "")) {
20
+ if (escaped) {
21
+ current += char;
22
+ escaped = false;
23
+ continue;
24
+ }
25
+ if (char === "\\") {
26
+ escaped = true;
27
+ continue;
28
+ }
29
+ if (quote) {
30
+ if (char === quote) quote = undefined;
31
+ else current += char;
32
+ continue;
33
+ }
34
+ if (char === "\"" || char === "'") {
35
+ quote = char;
36
+ continue;
37
+ }
38
+ if (/\s/.test(char)) {
39
+ if (current) {
40
+ tokens.push(current);
41
+ current = "";
42
+ }
43
+ continue;
44
+ }
45
+ current += char;
46
+ }
47
+
48
+ if (escaped) current += "\\";
49
+ if (quote) throw new Error(`Unclosed ${quote} quote`);
50
+ if (current) tokens.push(current);
51
+ return tokens;
52
+ }
53
+
54
+ function takeValue(tokens, index, flag) {
55
+ const value = tokens[index + 1];
56
+ if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`);
57
+ return value;
58
+ }
59
+
60
+ export function parsePort(value, label = "port") {
61
+ const port = Number.parseInt(String(value || ""), 10);
62
+ if (!Number.isFinite(port) || port <= 0 || port > 65535 || String(port) !== String(value).trim()) {
63
+ throw new Error(`${label} must be a TCP port between 1 and 65535`);
64
+ }
65
+ return port;
66
+ }
67
+
68
+ export function parseRemoteArgs(args = "") {
69
+ const options = {
70
+ action: "open",
71
+ port: DEFAULT_PORT,
72
+ name: undefined,
73
+ yes: false,
74
+ };
75
+ const tokens = tokenizeArgs(args);
76
+ let actionSeen = false;
77
+
78
+ for (let i = 0; i < tokens.length; i++) {
79
+ const token = tokens[i];
80
+ const lower = token.toLowerCase();
81
+
82
+ if (ACTIONS.has(lower) && !actionSeen) {
83
+ options.action = lower === "open" ? "open" : lower;
84
+ actionSeen = true;
85
+ continue;
86
+ }
87
+ if (token === "--yes" || token === "-y") {
88
+ options.yes = true;
89
+ continue;
90
+ }
91
+ if (token === "--port") {
92
+ options.port = parsePort(takeValue(tokens, i, token), "--port");
93
+ i++;
94
+ continue;
95
+ }
96
+ if (token.startsWith("--port=")) {
97
+ options.port = parsePort(token.slice("--port=".length), "--port");
98
+ continue;
99
+ }
100
+ if (token === "--name") {
101
+ options.name = takeValue(tokens, i, token).trim();
102
+ if (!options.name) throw new Error("--name requires a non-empty value");
103
+ i++;
104
+ continue;
105
+ }
106
+ if (token.startsWith("--name=")) {
107
+ options.name = token.slice("--name=".length).trim();
108
+ if (!options.name) throw new Error("--name requires a non-empty value");
109
+ continue;
110
+ }
111
+ if (/^\d+$/.test(token)) {
112
+ options.port = parsePort(token, "port");
113
+ continue;
114
+ }
115
+
116
+ throw new Error(`Unknown option: ${token}`);
117
+ }
118
+
119
+ return options;
120
+ }
121
+
122
+ export function usage() {
123
+ return [
124
+ "Usage: /remote [status|close|refresh] [port] [--port N] [--name NAME] [--yes]",
125
+ "Opens the existing Pi Web UI to a trusted local network and shows a QR code for mobile.",
126
+ ].join("\n");
127
+ }
128
+
129
+ export function requiresOpenConfirmation(options) {
130
+ return options?.action === "open" && options?.yes !== true;
131
+ }
132
+
133
+ export function localBaseUrl(port) {
134
+ return `http://${DEFAULT_LOCAL_HOST}:${parsePort(port)}`;
135
+ }
136
+
137
+ export function endpointUrl(port, path) {
138
+ return `${localBaseUrl(port)}${path.startsWith("/") ? path : `/${path}`}`;
139
+ }
140
+
141
+ export async function fetchJsonWithTimeout(url, init = {}, timeoutMs = 1_500, fetchImpl = globalThis.fetch) {
142
+ if (typeof fetchImpl !== "function") throw new Error("fetch is not available in this Node.js runtime");
143
+ const controller = new AbortController();
144
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
145
+ try {
146
+ const response = await fetchImpl(url, { ...init, signal: controller.signal });
147
+ const body = await response.json().catch(() => undefined);
148
+ return { ok: response.ok, status: response.status, body };
149
+ } catch (error) {
150
+ return { ok: false, status: 0, body: undefined, error };
151
+ } finally {
152
+ clearTimeout(timeout);
153
+ }
154
+ }
155
+
156
+ export class RemoteWebuiController {
157
+ constructor({ fetchImpl = globalThis.fetch, sleepImpl = sleep } = {}) {
158
+ this.fetchImpl = fetchImpl;
159
+ this.sleepImpl = sleepImpl;
160
+ }
161
+
162
+ async probeHealth(port, timeoutMs = 1_200) {
163
+ const result = await fetchJsonWithTimeout(endpointUrl(port, "/api/health"), {}, timeoutMs, this.fetchImpl);
164
+ const body = result.body;
165
+ if (result.ok && body?.ok === true && typeof body.webuiVersion === "string") {
166
+ return { online: true, status: result.status, data: body };
167
+ }
168
+ return {
169
+ online: false,
170
+ status: result.status,
171
+ error: body?.error || result.error?.message || "No Pi Web UI responded at this URL",
172
+ };
173
+ }
174
+
175
+ async waitForHealth(port, { timeoutMs = DEFAULT_START_TIMEOUT_MS, pollMs = DEFAULT_POLL_MS } = {}) {
176
+ const deadline = Date.now() + timeoutMs;
177
+ let last = await this.probeHealth(port, Math.min(1_200, timeoutMs));
178
+ while (!last.online && Date.now() < deadline) {
179
+ await this.sleepImpl(pollMs);
180
+ last = await this.probeHealth(port, Math.min(1_200, Math.max(200, deadline - Date.now())));
181
+ }
182
+ if (!last.online) throw new Error(`Timed out waiting for Pi Web UI at ${localBaseUrl(port)}/`);
183
+ return last;
184
+ }
185
+
186
+ async getNetwork(port, timeoutMs = 1_500) {
187
+ const result = await fetchJsonWithTimeout(endpointUrl(port, "/api/network"), {}, timeoutMs, this.fetchImpl);
188
+ if (result.ok && result.body?.ok === true && result.body.data && typeof result.body.data === "object") return result.body.data;
189
+ throw new Error(result.body?.error || "Failed to read Pi Web UI network status");
190
+ }
191
+
192
+ async openNetwork(port, timeoutMs = 1_500) {
193
+ const result = await fetchJsonWithTimeout(endpointUrl(port, "/api/network/open"), { method: "POST" }, timeoutMs, this.fetchImpl);
194
+ if (result.ok && result.body?.ok === true) return result.body.data;
195
+ throw new Error(result.body?.error || "Failed to open Pi Web UI to the local network");
196
+ }
197
+
198
+ async closeNetwork(port, timeoutMs = 1_500) {
199
+ const result = await fetchJsonWithTimeout(endpointUrl(port, "/api/network/close"), { method: "POST" }, timeoutMs, this.fetchImpl);
200
+ if (result.ok && result.body?.ok === true) return result.body.data;
201
+ throw new Error(result.body?.error || "Failed to close Pi Web UI network access");
202
+ }
203
+
204
+ async waitForNetworkOpen(port, { timeoutMs = DEFAULT_NETWORK_TIMEOUT_MS, pollMs = DEFAULT_POLL_MS } = {}) {
205
+ const deadline = Date.now() + timeoutMs;
206
+ let last;
207
+ do {
208
+ last = await this.getNetwork(port, Math.min(1_500, Math.max(200, deadline - Date.now())));
209
+ if (last?.open === true && selectLanUrl(last)) return last;
210
+ await this.sleepImpl(pollMs);
211
+ } while (Date.now() < deadline);
212
+
213
+ if (last?.open === true) {
214
+ throw new Error("Pi Web UI is open to the network, but no LAN URL was reported. Check Wi-Fi/LAN connectivity.");
215
+ }
216
+ throw new Error("Timed out waiting for Pi Web UI to open to the local network");
217
+ }
218
+
219
+ async status(port) {
220
+ const health = await this.probeHealth(port);
221
+ if (!health.online) return { online: false, url: `${localBaseUrl(port)}/`, health };
222
+ let network;
223
+ try {
224
+ network = await this.getNetwork(port);
225
+ } catch (error) {
226
+ network = health.data?.network;
227
+ if (!network) return { online: true, url: `${localBaseUrl(port)}/`, health, error: error?.message || String(error) };
228
+ }
229
+ return { online: true, url: selectLanUrl(network) || network?.localUrl || `${localBaseUrl(port)}/`, health, network };
230
+ }
231
+ }
232
+
233
+ export function selectLanUrl(network) {
234
+ const urls = Array.isArray(network?.networkUrls) ? network.networkUrls : [];
235
+ return urls.find((url) => typeof url === "string" && /^https?:\/\//i.test(url)) || undefined;
236
+ }
237
+
238
+ export async function openRemoteWebui(options, { controller, startWebui }) {
239
+ if (!controller) throw new Error("RemoteWebuiController is required");
240
+ let health = await controller.probeHealth(options.port);
241
+ let started = false;
242
+
243
+ if (!health.online) {
244
+ if (typeof startWebui !== "function") throw new Error("Pi Web UI is not running and no start function is available");
245
+ await startWebui(options);
246
+ started = true;
247
+ health = await controller.waitForHealth(options.port);
248
+ }
249
+
250
+ let network;
251
+ try {
252
+ network = await controller.getNetwork(options.port);
253
+ } catch {
254
+ network = health.data?.network;
255
+ }
256
+
257
+ if (!network?.open || network?.closing) {
258
+ await controller.openNetwork(options.port);
259
+ network = await controller.waitForNetworkOpen(options.port);
260
+ } else if (!selectLanUrl(network)) {
261
+ network = await controller.waitForNetworkOpen(options.port, { timeoutMs: 2_000 });
262
+ }
263
+
264
+ const url = selectLanUrl(network);
265
+ if (!url) throw new Error("No LAN URL was reported by Pi Web UI. Connect this machine to a local network and retry.");
266
+ return { started, health, network, url };
267
+ }
268
+
269
+ export async function closeRemoteWebui(options, { controller }) {
270
+ if (!controller) throw new Error("RemoteWebuiController is required");
271
+ const health = await controller.probeHealth(options.port);
272
+ if (!health.online) return { online: false, url: `${localBaseUrl(options.port)}/`, health };
273
+ const network = await controller.closeNetwork(options.port);
274
+ return { online: true, url: `${localBaseUrl(options.port)}/`, health, network };
275
+ }
276
+
277
+ export function formatStatus(status) {
278
+ if (!status?.online) {
279
+ return [
280
+ "Pi Remote WebUI status",
281
+ "",
282
+ `URL: ${status?.url || "unknown"}`,
283
+ "Online: no",
284
+ `Error: ${status?.health?.error || "offline"}`,
285
+ "",
286
+ "Start and show QR with: /remote",
287
+ ].join("\n");
288
+ }
289
+
290
+ const network = status.network || status.health?.data?.network || {};
291
+ const networkUrls = Array.isArray(network.networkUrls) ? network.networkUrls : [];
292
+ const state = network.open ? (network.opening ? "opening to LAN" : "open to LAN") : network.closing ? "closing" : "local only";
293
+ const lines = [
294
+ "Pi Remote WebUI status",
295
+ "",
296
+ `URL: ${status.url || selectLanUrl(network) || network.localUrl || "unknown"}`,
297
+ "Online: yes",
298
+ `Network: ${state}`,
299
+ `Bind: ${network.host || "unknown"}:${network.port || "?"}`,
300
+ ];
301
+ if (networkUrls.length) lines.push(`LAN URLs: ${networkUrls.join(", ")}`);
302
+ if (status.health?.data?.webuiVersion) lines.push(`Version: ${status.health.data.webuiVersion}`);
303
+ if (status.error) lines.push(`Warning: ${status.error}`);
304
+ return lines.join("\n");
305
+ }
306
+
307
+ export function buildRemoteWidgetLines({ url, qrLines = [], network = {}, started = false } = {}) {
308
+ const auth = network?.auth || {};
309
+ const authLine = auth.enabled ? `Remote PIN auth: on${auth.pin ? ` · PIN ${auth.pin}` : ""}` : "Remote PIN auth: off";
310
+ const warningLine = auth.enabled
311
+ ? "Trusted LAN only. Anyone with this URL and PIN can control Pi/WebUI."
312
+ : "Trusted LAN only. Remote PIN auth is off; anyone with this URL can control Pi/WebUI.";
313
+ const lines = [
314
+ "Pi Remote WebUI",
315
+ "",
316
+ "Scan with your phone:",
317
+ "",
318
+ ...qrLines,
319
+ "",
320
+ url || selectLanUrl(network) || network.localUrl || "(no URL)",
321
+ authLine,
322
+ "",
323
+ warningLine,
324
+ "Close LAN access with: /remote close",
325
+ ];
326
+ if (started) lines.push("Started a Pi Web UI server for this session.");
327
+ return lines;
328
+ }
329
+
330
+ export async function generateQrLines(url, { qrcodeModule } = {}) {
331
+ let qrcode = qrcodeModule;
332
+ if (!qrcode) {
333
+ try {
334
+ qrcode = (await import("qrcode-terminal")).default || (await import("qrcode-terminal"));
335
+ } catch {
336
+ return ["[QR generator unavailable: qrcode-terminal is not installed]"];
337
+ }
338
+ }
339
+
340
+ return new Promise((resolve) => {
341
+ try {
342
+ qrcode.generate(url, { small: true }, (qrText) => {
343
+ const lines = String(qrText || "").split(/\r?\n/);
344
+ resolve(lines.filter((line, index, array) => line.trim() || (index > 0 && index < array.length - 1)));
345
+ });
346
+ } catch (error) {
347
+ resolve([`[QR generation failed: ${error?.message || String(error)}]`]);
348
+ }
349
+ });
350
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@firstpick/pi-package-remote-webui",
3
+ "version": "0.1.0",
4
+ "description": "Pi /remote command that opens the existing Pi Web UI to a trusted LAN and shows a QR code for mobile.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-remote-webui#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Firstp1ck/npm-packages.git",
10
+ "directory": "pi-package-remote-webui"
11
+ },
12
+ "type": "module",
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi",
16
+ "pi-coding-agent",
17
+ "webui",
18
+ "remote",
19
+ "mobile",
20
+ "qr",
21
+ "extension"
22
+ ],
23
+ "pi": {
24
+ "extensions": [
25
+ "./index.ts"
26
+ ]
27
+ },
28
+ "scripts": {
29
+ "check": "node --check lib/remote-core.mjs && node tests/run-all.mjs",
30
+ "test": "node tests/run-all.mjs"
31
+ },
32
+ "dependencies": {
33
+ "@firstpick/pi-package-webui": "^0.3.8",
34
+ "qrcode-terminal": "^0.12.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@earendil-works/pi-coding-agent": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "files": [
45
+ "index.ts",
46
+ "lib",
47
+ "tests",
48
+ "docs",
49
+ "README.md",
50
+ "LICENSE"
51
+ ],
52
+ "engines": {
53
+ "node": ">=22.19.0"
54
+ }
55
+ }