Package Pi Mobile for developer distribution.

Publish the hub as pi-mobile-hub with CLI flags and LAN pairing auth, add mobile deep-link pairing and EAS build config, and expand gitignore for Expo/EAS artifacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-09 14:54:09 -05:00
co-authored by Cursor
parent 62cbc5357b
commit bb9999de58
19 changed files with 676 additions and 92 deletions
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env node
import("../dist/index.js").catch((error) => {
console.error(
"Failed to start pi-mobile-hub:",
error instanceof Error ? error.message : error,
);
process.exit(1);
});
+19 -3
View File
@@ -1,24 +1,40 @@
{
"name": "@pi-mobile/api",
"name": "pi-mobile-hub",
"version": "0.1.0",
"private": true,
"description": "Pi Mobile API hub — remote control Pi agents from your phone",
"type": "module",
"bin": {
"pi-mobile-hub": "./bin/pi-mobile-hub.js"
},
"files": [
"bin",
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc"
"build": "tsc",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@earendil-works/pi-coding-agent": "latest",
"cors": "^2.8.5",
"express": "^4.21.2",
"qrcode-terminal": "^0.12.0",
"socket.io": "^4.8.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^22.10.2",
"@types/qrcode-terminal": "^0.12.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
},
"engines": {
"node": ">=20"
}
}
+92
View File
@@ -0,0 +1,92 @@
import { randomBytes } from "node:crypto";
import type { NextFunction, Request, Response } from "express";
import type { Socket } from "socket.io";
import qrcode from "qrcode-terminal";
export type AuthConfig = {
enabled: boolean;
token?: string;
};
export function createAuthConfig(noAuth: boolean): AuthConfig {
if (noAuth) {
return { enabled: false };
}
return {
enabled: true,
token: randomBytes(24).toString("hex"),
};
}
export function isAuthorized(auth: AuthConfig, token: string | undefined): boolean {
if (!auth.enabled) {
return true;
}
return typeof token === "string" && token.length > 0 && token === auth.token;
}
export function createHttpAuthMiddleware(auth: AuthConfig) {
return (req: Request, res: Response, next: NextFunction) => {
if (!auth.enabled) {
next();
return;
}
const headerToken = req.header("x-pi-mobile-token");
const queryToken = typeof req.query.token === "string" ? req.query.token : undefined;
const token = headerToken ?? queryToken;
if (!isAuthorized(auth, token)) {
res.status(401).json({ error: "Unauthorized" });
return;
}
next();
};
}
export function createSocketAuthMiddleware(auth: AuthConfig) {
return (socket: Socket, next: (error?: Error) => void) => {
if (!auth.enabled) {
next();
return;
}
const token =
typeof socket.handshake.auth?.token === "string" ? socket.handshake.auth.token : undefined;
if (!isAuthorized(auth, token)) {
next(new Error("Unauthorized"));
return;
}
next();
};
}
export function buildConnectDeepLink(host: string, port: number, token: string): string {
const params = new URLSearchParams({
host,
port: String(port),
token,
});
return `pimobile://connect?${params.toString()}`;
}
export function printPairingInfo(addresses: string[], port: number, token: string): void {
console.log("\nPairing code:");
console.log(` ${token}`);
console.log("\nConnect from Pi Mobile using one of these addresses:");
for (const address of addresses) {
const deepLink = buildConnectDeepLink(address, port, token);
console.log(`\n http://${address}:${port}`);
console.log(` ${deepLink}`);
console.log("");
qrcode.generate(deepLink, { small: true });
}
console.log("\nScan the QR code with Pi Mobile, or enter the pairing code manually.");
}
+94
View File
@@ -0,0 +1,94 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
export type CliOptions = {
port: number;
noAuth: boolean;
help: boolean;
version: boolean;
};
const DEFAULT_PORT = 8787;
function getPackageVersion(): string {
try {
const dir = dirname(fileURLToPath(import.meta.url));
const packageJsonPath = join(dir, "..", "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: string };
return packageJson.version ?? "0.0.0";
} catch {
return "0.0.0";
}
}
export function parseCliArgs(argv: string[]): CliOptions {
let port = Number(process.env.PORT);
if (Number.isNaN(port)) {
port = DEFAULT_PORT;
}
let noAuth = false;
let help = false;
let version = false;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
help = true;
continue;
}
if (arg === "--version" || arg === "-v") {
version = true;
continue;
}
if (arg === "--no-auth") {
noAuth = true;
continue;
}
if (arg === "--port" || arg === "-p") {
const value = Number(argv[index + 1]);
if (!Number.isNaN(value)) {
port = value;
index += 1;
}
continue;
}
if (arg.startsWith("--port=")) {
const value = Number(arg.slice("--port=".length));
if (!Number.isNaN(value)) {
port = value;
}
}
}
return { port, noAuth, help, version };
}
export function printHelp(): void {
console.log(`pi-mobile-hub — Pi Mobile API hub
Usage:
pi-mobile-hub [options]
Options:
-p, --port <number> Port to listen on (default: 8787)
--no-auth Disable pairing token requirement
-h, --help Show this help message
-v, --version Show version number
Examples:
npx pi-mobile-hub
npx pi-mobile-hub --port 9000
npx pi-mobile-hub --no-auth
`);
}
export function printVersion(): void {
console.log(getPackageVersion());
}
+38 -21
View File
@@ -3,25 +3,16 @@ import express from "express";
import { createServer } from "node:http";
import { networkInterfaces } from "node:os";
import { Server } from "socket.io";
import {
createAuthConfig,
createHttpAuthMiddleware,
printPairingInfo,
} from "./auth.js";
import { parseCliArgs, printHelp, printVersion } from "./cli.js";
import { createFolder, listFolders } from "./fs.js";
import { RuntimeRegistry } from "./runtime-registry.js";
import { registerSocketHandlers } from "./socket-handlers.js";
const DEFAULT_PORT = 8787;
function getPort(): number {
const argIndex = process.argv.indexOf("--port");
if (argIndex !== -1) {
const value = Number(process.argv[argIndex + 1]);
if (!Number.isNaN(value)) {
return value;
}
}
const envPort = Number(process.env.PORT);
return Number.isNaN(envPort) ? DEFAULT_PORT : envPort;
}
function getLanAddresses(): string[] {
const addresses: string[] = [];
const interfaces = networkInterfaces();
@@ -41,6 +32,19 @@ function getLanAddresses(): string[] {
return addresses;
}
const cli = parseCliArgs(process.argv.slice(2));
if (cli.help) {
printHelp();
process.exit(0);
}
if (cli.version) {
printVersion();
process.exit(0);
}
const auth = createAuthConfig(cli.noAuth);
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
@@ -51,15 +55,20 @@ const io = new Server(httpServer, {
});
const registry = new RuntimeRegistry();
const requireAuth = createHttpAuthMiddleware(auth);
app.use(cors());
app.use(express.json());
app.get("/health", (_req, res) => {
res.json({ ok: true, service: "pi-mobile-api" });
res.json({
ok: true,
service: "pi-mobile-api",
authRequired: auth.enabled,
});
});
app.get("/fs/list", async (req, res) => {
app.get("/fs/list", requireAuth, async (req, res) => {
try {
const path = typeof req.query.path === "string" ? req.query.path : "~";
const result = await listFolders(path);
@@ -70,7 +79,7 @@ app.get("/fs/list", async (req, res) => {
}
});
app.post("/fs/mkdir", async (req, res) => {
app.post("/fs/mkdir", requireAuth, async (req, res) => {
try {
const path = typeof req.body?.path === "string" ? req.body.path : "~";
const name = typeof req.body?.name === "string" ? req.body.name : "";
@@ -82,13 +91,21 @@ app.post("/fs/mkdir", async (req, res) => {
}
});
registerSocketHandlers(io, registry);
registerSocketHandlers(io, registry, auth);
const port = getPort();
const port = cli.port;
httpServer.listen(port, "0.0.0.0", () => {
const lan = getLanAddresses();
console.log(`Pi Mobile API listening on 0.0.0.0:${port}`);
if (lan.length > 0) {
if (auth.enabled && auth.token) {
if (lan.length > 0) {
printPairingInfo(lan, port, auth.token);
} else {
printPairingInfo(["127.0.0.1"], port, auth.token);
console.log("Could not detect a LAN address. Use your computer's LAN IP on port", port);
}
} else if (lan.length > 0) {
console.log("Connect from your phone using:");
for (const address of lan) {
console.log(` http://${address}:${port}`);
+8 -1
View File
@@ -1,4 +1,5 @@
import type { Server, Socket } from "socket.io";
import { createSocketAuthMiddleware, type AuthConfig } from "./auth.js";
import { serializeMessages } from "./message-history.js";
import type { RuntimeRegistry } from "./runtime-registry.js";
import type {
@@ -15,7 +16,13 @@ type AttachState = {
unsubscribe?: () => void;
};
export function registerSocketHandlers(io: Server, registry: RuntimeRegistry) {
export function registerSocketHandlers(
io: Server,
registry: RuntimeRegistry,
auth: AuthConfig,
) {
io.use(createSocketAuthMiddleware(auth));
io.on("connection", (socket: Socket) => {
const state: AttachState = {};