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:
Executable
+9
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user