diff --git a/README.md b/README.md index 6d33b72..7b84289 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,10 @@ Run `/remote-control` to open the menu: - **Turn on / Turn off** — start or stop the server - **Configure URL** — set the base URL exposed by your local tunnel or proxy, saved to `~/.pi/agent/remote-control.json` +- **Transport** — switch between Surge Ponte and Tailscale - **Status** — show the QR code and connection URL (only when server is running) -> **Note:** On first use, you must configure the URL before the server can start. +> **Note:** On first use, you must configure the URL (Surge Ponte) or have Tailscale running before the server can start. To start the server automatically on launch: @@ -26,11 +27,11 @@ To start the server automatically on launch: pi --remote-control ``` -## Use case +## Transport modes -The remote-control server binds to `127.0.0.1` on the host running `pi` and is reached through a local tunnel or proxy. This example uses [Surge Ponte](https://kb.nssurge.com/surge-knowledge-base/guidelines/ponte), which provides an end-to-end encrypted device-to-device tunnel without exposing the server to the LAN. +### Surge Ponte (default) -The setup is: +The server binds to `127.0.0.1` and is reached through Surge Ponte, which provides an end-to-end encrypted device-to-device tunnel without exposing the server to the LAN. 1. Install this extension on the Mac that runs `pi`. 2. Enable Surge Ponte on that Mac and give it a device name such as `pi`. @@ -42,12 +43,43 @@ The setup is: In this setup, the browser URL is `http://pi.sgponte:`, where the port is assigned when the server starts. Use `Status` to get the current URL or scan the QR code — it changes each time the server restarts. +### Tailscale + +The server binds to `0.0.0.0` (protected by token auth) and is reachable via your Tailscale tailnet IP. This works on any OS with Tailscale installed — Linux, macOS, Windows — and any device on your tailnet, including Android and iOS. + +#### Setup (host machine running pi) + +1. Install and log in to Tailscale: + ```bash + # Linux + curl -fsSL https://tailscale.com/install.sh | sh + sudo tailscale up + + # macOS + brew install --cask tailscale + # Then open Tailscale from Applications and sign in + ``` +2. In `pi`, run `/remote-control`. +3. Choose **Transport: Surge Ponte** to switch it to **Transport: Tailscale ✓**. +4. Choose **Turn on**. +5. The connection URL will be shown as `http://100.x.y.z:/?token=...` with a QR code. + +#### Connect from Android + +1. Install [Tailscale](https://play.google.com/store/apps/details?id=com.tailscale.ipn) from the Play Store and sign in to the same tailnet. +2. Open `pi` on the host, run `/remote-control`, choose **Turn on**. +3. Choose **Status** to see the QR code and URL. +4. On Android, open the Tailscale app to confirm the host device is online, then open the URL shown in `pi`'s status in Chrome (or scan the QR code with your phone's camera). +5. The web UI loads — you can now send messages and stop the agent from your phone. + +> **Tip:** Bookmark the URL in Chrome on your phone so you can reconnect quickly. The session cookie lasts 24 hours. When the server restarts, you'll need the new token URL. + Here's what it looks like on iPhone — this is an actual session asking `pi` about its hardware environment: pi remote control on iPhone via pi.sgponte ## Security notes -- The server only listens on localhost. Remote access depends on whatever local tunnel or proxy you configure. +- The server only listens on localhost in Surge Ponte mode. In Tailscale mode, it binds to `0.0.0.0` but is only reachable via the Tailscale virtual interface (which enforces its own ACLs). - There is no multi-user authentication. Treat the connection URL as a secret for the lifetime of the session. - If you use a reverse proxy instead of Surge Ponte, configure it to terminate TLS at a fixed `https://` endpoint and forward to the server's dynamic backend port. Do not expose the dynamic port directly over a public network, as the server does not support HTTPS and any token or session cookie would be transmitted in cleartext. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 048b861..7110cec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,42 +6,55 @@ HTTP/WebSocket. The machine running pi is the **host**; any browser that connects is a **guest**. -The server binds to `127.0.0.1` only (never the LAN). A local proxy or tunnel -(e.g. Surge Ponte) forwards external traffic to it. The guest interacts -through a self-contained single-page app served inline — no CDN, no external -assets. +The server supports two transport modes. In **Surge Ponte** mode it binds to +`127.0.0.1` and is reached through a localhost proxy. In **Tailscale** mode it +binds to `0.0.0.0` and is reachable via the Tailscale tailnet IP, making it +accessible from any device on your tailnet including Android and iOS. The guest +interacts through a self-contained single-page app served inline — no CDN, no +external assets. ```mermaid graph LR subgraph Host["Host machine"] PI["pi process"] EXT["remote-control extension"] - SRV["HTTP + WS server — 127.0.0.1:port"] + SRV["HTTP + WS server"] PI -->|events| EXT EXT -->|broadcast| SRV SRV -->|sendUserMessage / abort| PI end - PROXY["Surge Ponte"] - SRV <-->|localhost| PROXY + TUNNEL["Surge Ponte or Tailscale"] + SRV <-->|localhost or tailnet| TUNNEL subgraph Guest["Guest browser"] UI["Single-page app"] end - PROXY <-->|HTTPS / WSS| UI + TUNNEL <-->|HTTPS / WSS| UI ``` +### Transport modes + +| Mode | Bind address | Reachable via | Notes | +|------|-------------|---------------|-------| +| Surge Ponte | `127.0.0.1` | Surge Ponte hostname | macOS only | +| Tailscale | `0.0.0.0` | Tailscale IP (100.x.y.z) | Any OS, works on Android/iOS | + +In Tailscale mode the server binds to `0.0.0.0` so the Tailscale virtual +interface can reach the port; access is still protected by the one-time token +and session cookie auth flow. + ## Files | File | Purpose | |------|---------| | `extensions/pi-remote-control/index.ts` | Extension entry point — registers flag, command, and event bridge | -| `extensions/pi-remote-control/server.ts` | HTTP + WebSocket server, auth enforcement, client management | +| `extensions/pi-remote-control/server.ts` | HTTP + WebSocket server: `startServer` (Surge), `startServerTailscale` (Tailscale), auth enforcement, client management | | `extensions/pi-remote-control/messages.ts` | Wire protocol: serialize session entries → `RenderMsg`, build `sync` payloads | | `extensions/pi-remote-control/html.ts` | Inline single-page web UI (self-contained, no external deps) | | `extensions/pi-remote-control/auth.ts` | One-time token generation/validation, session cookie helpers | -| `extensions/pi-remote-control/config.ts` | Read/write `remote-control.json`, public URL normalization and config UI | +| `extensions/pi-remote-control/config.ts` | Read/write `remote-control.json`, transport mode config, Tailscale IP detection (`detectTailscaleIp`), URL normalization | ## Authentication Flow @@ -122,7 +135,9 @@ interface RenderMsg { ```mermaid flowchart TD - SS["session_start"] -->|--remote-control flag| START["startServer()"] + SS["session_start"] -->|--remote-control flag| MODE{"Transport?"} + MODE -->|Surge Ponte| START["startServer()"] + MODE -->|Tailscale| START_TS["startServerTailscale()"] START --> SYNC0["sync all clients"] SW["session_switch"] --> SYNC1["scheduleSync()"] diff --git a/extensions/pi-remote-control/config.ts b/extensions/pi-remote-control/config.ts index fe39270..a0b6cbc 100644 --- a/extensions/pi-remote-control/config.ts +++ b/extensions/pi-remote-control/config.ts @@ -12,8 +12,11 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; const REMOTE_CONTROL_CONFIG_FILE = "remote-control.json"; +export type TransportMode = "surge" | "tailscale"; + export interface RemoteControlConfig { publicBaseUrl?: string; + transport?: TransportMode; } function getAgentDir(): string { @@ -54,7 +57,7 @@ export async function readRemoteControlConfig(): Promise { } } -async function writeRemoteControlConfig(config: RemoteControlConfig): Promise { +export async function writeRemoteControlConfig(config: RemoteControlConfig): Promise { const configPath = getRemoteControlConfigPath(); await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8"); @@ -104,3 +107,50 @@ export async function configureRemoteControlUI(ctx: ExtensionContext): Promise { + // Method 1: tailscale CLI + const { execSync } = await import("node:child_process"); + try { + const ip = execSync("tailscale ip -4", { encoding: "utf8", timeout: 5000 }).trim(); + if (ip && /^100\.\d+\.\d+\.\d+$/.test(ip)) return ip; + } catch { + // tailscale CLI not available or not logged in + } + + // Method 2: Tailscale local API (HTTP on 100.100.100.100) + const http = await import("node:http"); + return new Promise((resolve) => { + const req = http.get( + "http://100.100.100.100:9090/localapi/v0/status", + { timeout: 3000 }, + (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => { + try { + const status = JSON.parse(body); + const self = status.Self; + if (self?.TailscaleIPs?.length > 0) { + const ip4 = self.TailscaleIPs.find((ip: string) => ip.startsWith("100.")); + if (ip4) { resolve(ip4); return; } + } + } catch { /* parse error */ } + resolve(null); + }); + }, + ); + req.on("error", () => resolve(null)); + req.on("timeout", () => { req.destroy(); resolve(null); }); + }); +} + +export async function isTailscaleRunning(): Promise { + return (await detectTailscaleIp()) !== null; +} diff --git a/extensions/pi-remote-control/index.ts b/extensions/pi-remote-control/index.ts index 49a74e8..53cb328 100644 --- a/extensions/pi-remote-control/index.ts +++ b/extensions/pi-remote-control/index.ts @@ -1,9 +1,8 @@ /** * remote-control — Expose the running pi session over HTTP/WebSocket. * - * Starts an HTTP + WebSocket server on a free port, bound to 127.0.0.1 (localhost only). - * This is intended to sit behind a local port-forwarding proxy/tunnel that terminates on - * the same host (for example Tailscale/Surge), rather than accepting direct LAN traffic. + * Starts an HTTP + WebSocket server on a free port, bound to 127.0.0.1 (localhost only) + * for Surge Ponte mode, or 0.0.0.0 for Tailscale mode. * Access requires a one-time token (?token=...) which sets a session cookie for * subsequent requests. Run /remote-control to start the server and display the URL. * The browser is expected to use http(s):// and ws(s):// through that proxy. @@ -16,11 +15,15 @@ import { DynamicBorder, keyHint } from "@mariozechner/pi-coding-agent"; import { Container, Text } from "@mariozechner/pi-tui"; import { readRemoteControlConfig, + writeRemoteControlConfig, buildRemoteControlUrl, configureRemoteControlUI, + detectTailscaleIp, + isTailscaleRunning, + type TransportMode, } from "./config.js"; import { serializeMessage } from "./messages.js"; -import { type RemoteServer, startServer } from "./server.js"; +import { type RemoteServer, startServer, startServerTailscale } from "./server.js"; // ── Extension entry point ──────────────────────────────────────────────────── @@ -30,6 +33,7 @@ const QRCode = _require("qrcode") as { toString: (text: string, opts: any) => Pr export default function remoteControl(pi: ExtensionAPI) { let server: RemoteServer | undefined; let pendingSyncTimer: ReturnType | undefined; + let tailscaleIp: string | null = null; function scheduleSync(ctx: ExtensionContext): void { if (pendingSyncTimer) clearTimeout(pendingSyncTimer); @@ -66,23 +70,42 @@ export default function remoteControl(pi: ExtensionAPI) { if (pi.getFlag("remote-control") !== true) return; const config = await readRemoteControlConfig(); - const publicBaseUrl = config.publicBaseUrl?.trim(); - if (!publicBaseUrl) { - if (ctx.hasUI) { - ctx.ui.notify( - "--remote-control: no publicBaseUrl configured. Run /remote-control config first.", - "warning", - ); + const transport = config.transport ?? "surge"; + + if (transport === "tailscale") { + tailscaleIp = await detectTailscaleIp(); + if (!tailscaleIp) { + if (ctx.hasUI) { + ctx.ui.notify( + "--remote-control: Tailscale is not running. Run `tailscale up` first.", + "warning", + ); + } + return; + } + server = await startServerTailscale(pi, ctx); + server.onClientChange(() => updateStatus(ctx)); + const url = `http://${tailscaleIp}:${server.port}/?token=${server.token}`; + if (ctx.hasUI) { + ctx.ui.notify(`Remote-control started (Tailscale): ${url}`, "info"); + } + } else { + const publicBaseUrl = config.publicBaseUrl?.trim(); + if (!publicBaseUrl) { + if (ctx.hasUI) { + ctx.ui.notify( + "--remote-control: no publicBaseUrl configured. Run /remote-control config first.", + "warning", + ); + } + return; + } + server = await startServer(pi, ctx); + server.onClientChange(() => updateStatus(ctx)); + const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token); + if (ctx.hasUI) { + ctx.ui.notify(`Remote-control started: ${url}`, "info"); } - return; - } - - server = await startServer(pi, ctx); - server.onClientChange(() => updateStatus(ctx)); - const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token); - - if (ctx.hasUI) { - ctx.ui.notify(`Remote-control started: ${url}`, "info"); } updateStatus(ctx); }); @@ -166,10 +189,16 @@ export default function remoteControl(pi: ExtensionAPI) { if (!server) return; const config = await readRemoteControlConfig(); - const publicBaseUrl = config.publicBaseUrl?.trim(); - if (!publicBaseUrl) return; + const transport = config.transport ?? "surge"; + let url: string; - const url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token); + if (transport === "tailscale" && tailscaleIp) { + url = `http://${tailscaleIp}:${server.port}/?token=${server.token}`; + } else { + const publicBaseUrl = config.publicBaseUrl?.trim(); + if (!publicBaseUrl) return; + url = buildRemoteControlUrl(publicBaseUrl, server.port, server.token); + } // Generate QR code let qrLines: string[] = []; @@ -208,17 +237,27 @@ export default function remoteControl(pi: ExtensionAPI) { pi.registerCommand("remote-control", { description: "Remote control — start/stop server, configure, show connection info", - handler: async (args, ctx) => { + handler: async (_args, ctx) => { if (!ctx.hasUI) return; const isRunning = !!server; const config = await readRemoteControlConfig(); + const transport = config.transport ?? "surge"; const currentUrl = config.publicBaseUrl?.trim(); + // Detect Tailscale status + const tsRunning = await isTailscaleRunning(); + if (tsRunning) tailscaleIp = await detectTailscaleIp(); + const configLabel = currentUrl ? `Configure URL (${currentUrl})` : "Configure URL (not set)"; + const transportLabel = transport === "tailscale" + ? `Transport: Tailscale${tsRunning ? " ✓" : " (not running)"}` + : "Transport: Surge Ponte"; + const menuItems = [ isRunning ? "Turn off" : "Turn on", configLabel, + transportLabel, ...(isRunning ? ["Status"] : []), ]; @@ -226,16 +265,24 @@ export default function remoteControl(pi: ExtensionAPI) { if (choice === undefined) return; if (choice === "Turn on") { - const publicBaseUrl = currentUrl; - if (!publicBaseUrl) { - ctx.ui.notify("Set the public URL first — opening config…", "warning"); - await configureRemoteControlUI(ctx); - // Re-check after config - const updated = await readRemoteControlConfig(); - if (!updated.publicBaseUrl?.trim()) return; + if (transport === "tailscale") { + if (!tsRunning || !tailscaleIp) { + ctx.ui.notify("Tailscale is not running. Run `tailscale up` first.", "warning"); + return; + } + server = await startServerTailscale(pi, ctx); + server.onClientChange(() => updateStatus(ctx)); + } else { + const publicBaseUrl = currentUrl; + if (!publicBaseUrl) { + ctx.ui.notify("Set the public URL first — opening config…", "warning"); + await configureRemoteControlUI(ctx); + const updated = await readRemoteControlConfig(); + if (!updated.publicBaseUrl?.trim()) return; + } + server = await startServer(pi, ctx); + server.onClientChange(() => updateStatus(ctx)); } - server = await startServer(pi, ctx); - server.onClientChange(() => updateStatus(ctx)); updateStatus(ctx); ctx.ui.notify("Remote-control server started", "info"); await showConnectionInfo(ctx); @@ -248,6 +295,12 @@ export default function remoteControl(pi: ExtensionAPI) { } } else if (choice === configLabel) { await configureRemoteControlUI(ctx); + } else if (choice === transportLabel) { + // Toggle transport mode + const modes: TransportMode[] = ["surge", "tailscale"]; + const next = modes.find(m => m !== transport) ?? "surge"; + await writeRemoteControlConfig({ ...config, transport: next }); + ctx.ui.notify(`Transport switched to ${next === "tailscale" ? "Tailscale" : "Surge Ponte"}`, "info"); } else if (choice === "Status") { await showConnectionInfo(ctx); } diff --git a/extensions/pi-remote-control/server.ts b/extensions/pi-remote-control/server.ts index fd43234..9d5e058 100644 --- a/extensions/pi-remote-control/server.ts +++ b/extensions/pi-remote-control/server.ts @@ -35,6 +35,7 @@ export interface RemoteServer { onClientChange: (cb: () => void) => void; port: number; token: string; + bindAddress: string; } export function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise { @@ -258,6 +259,211 @@ export function startServer(pi: ExtensionAPI, ctx: ExtensionContext): Promise { + const clientChangeListeners: Array<() => void> = []; + const clients = new Set(); + const token = generateToken(); + const SESSION_TTL_MS = 86_400_000; + const validSessions = new Map(); + const pruneExpiredSessions = (): void => { + const now = Date.now(); + for (const [id, expiresAt] of validSessions) { + if (expiresAt <= now) validSessions.delete(id); + } + }; + + function isAuthenticated(req: any): boolean { + const cookies = parseCookies(req.headers.cookie); + const sessionId = cookies[SESSION_COOKIE]; + const sessionExpiry = sessionId ? validSessions.get(sessionId) : undefined; + if (sessionExpiry !== undefined && sessionExpiry > Date.now()) return true; + + const url = new URL(req.url ?? "/", "http://localhost"); + const providedToken = url.searchParams.get("token"); + if (providedToken && validateToken(providedToken, token)) return true; + + return false; + } + + function broadcast(msg: object): void { + const data = JSON.stringify(msg); + for (const client of clients) { + if (client.readyState === OPEN) { + try { + client.send(data); + } catch { /* ignore */ } + } + } + } + + function sync(currentCtx: ExtensionContext): void { + broadcast(buildSyncMessage(currentCtx)); + } + + const httpServer = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://localhost"); + const pathname = url.pathname; + + if (pathname === "/" || pathname === "/index.html") { + const cookies = parseCookies(req.headers.cookie); + const sc = cookies[SESSION_COOKIE]; + const hasValidSession = sc !== undefined && (validSessions.get(sc) ?? 0) > Date.now(); + const providedToken = url.searchParams.get("token"); + const hasValidToken = providedToken && validateToken(providedToken, token); + + if (!hasValidSession && !hasValidToken) { + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Forbidden — valid token required. Use the URL shown in the pi terminal."); + return; + } + + if (!hasValidSession && hasValidToken) { + pruneExpiredSessions(); + const sessionId = generateSessionId(); + validSessions.set(sessionId, Date.now() + SESSION_TTL_MS); + res.writeHead(302, { + "Set-Cookie": `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`, + Location: "/", + }); + res.end(); + return; + } + + const nonce = randomBytes(16).toString("base64"); + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": + `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'`, + }); + res.end(buildHTML(nonce)); + } else { + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Not found"); + } + }); + + const wss = new WebSocketServer({ noServer: true }); + + httpServer.on("error", (err: Error) => { + console.error("[remote-control] httpServer error:", err.message); + }); + + wss.on("error", (err: Error) => { + console.error("[remote-control] wss error:", err.message); + }); + + httpServer.on("upgrade", (request: any, socket: any, head: any) => { + const url = new URL(request.url, "http://localhost"); + if (url.pathname === "/ws") { + if (!isAuthenticated(request)) { + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + return; + } + wss.handleUpgrade(request, socket, head, (ws: any) => { + wss.emit("connection", ws, request); + }); + } else { + socket.destroy(); + } + }); + + wss.on("connection", (ws: any) => { + clients.add(ws); + for (const cb of clientChangeListeners) cb(); + + try { + ws.send(JSON.stringify(buildSyncMessage(ctx))); + } catch { /* client disconnected before first send */ } + + const RATE_WINDOW_MS = 60_000; + const RATE_MAX = 30; + const MAX_MSG_BYTES = 64 * 1024; + const recentPrompts: number[] = []; + + ws.on("message", (data: any) => { + if (data.length > MAX_MSG_BYTES) return; + let msg: any; + try { msg = JSON.parse(data.toString()); } catch { return; } + if (msg.type === "stop") { + if (!ctx.isIdle()) ctx.abort(); + return; + } + if (msg.type === "prompt" && typeof msg.text === "string" && msg.text.trim()) { + const text = msg.text.trim(); + const now = Date.now(); + const cutoff = now - RATE_WINDOW_MS; + while (recentPrompts.length > 0 && recentPrompts[0] < cutoff) recentPrompts.shift(); + if (recentPrompts.length >= RATE_MAX) return; + recentPrompts.push(now); + if (ctx.isIdle()) { + pi.sendUserMessage(text); + } else { + pi.sendUserMessage(text, { deliverAs: "followUp" }); + } + } + }); + + const onClose = () => { + clients.delete(ws); + broadcast({ type: "status", clientCount: clients.size }); + for (const cb of clientChangeListeners) cb(); + }; + ws.on("close", onClose); + ws.on("error", onClose); + }); + + return new Promise((resolve) => { + // Bind to 0.0.0.0 so the Tailscale virtual interface can reach the port. + // Auth is enforced via token + session cookie. + httpServer.listen(0, "0.0.0.0", () => { + resolve({ + broadcast, + sync, + stop: () => + new Promise((res) => { + for (const client of clients) { + try { client.terminate(); } catch { /* ignore */ } + } + clients.clear(); + const timeout = setTimeout(() => { + httpServer.close(() => {}); + httpServer.closeAllConnections?.(); + res(); + }, 2000); + wss.close(() => + httpServer.close(() => { + clearTimeout(timeout); + res(); + }), + ); + }), + clientCount: () => clients.size, + onClientChange: (cb: () => void) => { clientChangeListeners.push(cb); }, + get port() { + return (httpServer.address() as any)?.port ?? 0; + }, + get token() { + return token; + }, + get bindAddress() { + return "0.0.0.0"; + }, }); }); }); diff --git a/package-lock.json b/package-lock.json index fd8bd5e..5269db6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "ws": "^8.0.0" }, "devDependencies": { - "@types/qrcode": "^1.5.6" + "@types/qrcode": "^1.5.6", + "typescript": "^6.0.2" }, "peerDependencies": { "@mariozechner/pi-coding-agent": "*", @@ -3795,6 +3796,20 @@ "license": "0BSD", "peer": true }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", diff --git a/package.json b/package.json index 846772c..f791639 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@mariozechner/pi-tui": "*" }, "devDependencies": { - "@types/qrcode": "^1.5.6" + "@types/qrcode": "^1.5.6", + "typescript": "^6.0.2" } }