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:
+45
-2
@@ -1,8 +1,51 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Expo / React Native
|
||||
.expo/
|
||||
.expo-shared/
|
||||
expo-env.d.ts
|
||||
web-build/
|
||||
|
||||
# EAS
|
||||
.eas/
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
credentials.json
|
||||
google-services.json
|
||||
GoogleService-Info.plist
|
||||
|
||||
# Native prebuild (generated by expo prebuild)
|
||||
ios/
|
||||
android/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Test / coverage
|
||||
coverage/
|
||||
|
||||
@@ -1,86 +1,120 @@
|
||||
# Pi Mobile
|
||||
|
||||
Prototype mobile remote control for [Pi](https://pi.dev/docs/latest) coding agents.
|
||||
Remote control for [Pi](https://pi.dev/docs/latest) coding agents from your phone.
|
||||
|
||||
Phone connects to a hub running on your computer over the local network. The hub embeds the Pi SDK in-process and exposes session management plus streaming chat over Socket.IO.
|
||||
|
||||
## Prerequisites
|
||||
## Quick start (developers)
|
||||
|
||||
- Node.js 20+
|
||||
- Pi installed and authenticated on the host machine (`pi` CLI with `/login` or provider API keys)
|
||||
- Expo Go on your phone (or iOS Simulator / Android emulator)
|
||||
- Phone and computer on the same LAN
|
||||
### 1. Start the hub on your computer
|
||||
|
||||
## Project structure
|
||||
No clone required:
|
||||
|
||||
```
|
||||
apps/api/ Express + Socket.IO hub with Pi SDK
|
||||
apps/mobile/ Expo Router mobile app
|
||||
```bash
|
||||
npx pi-mobile-hub@latest
|
||||
```
|
||||
|
||||
## Setup
|
||||
Or from this repo during development:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Run the API hub
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
npm run dev:api
|
||||
```
|
||||
|
||||
Or directly:
|
||||
The hub listens on `0.0.0.0:8787` by default and prints:
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The server listens on `0.0.0.0:8787` by default and prints LAN URLs on startup.
|
||||
- LAN URLs to connect from your phone
|
||||
- A **pairing code**
|
||||
- A **QR code** encoding a `pimobile://connect` deep link
|
||||
|
||||
Custom port:
|
||||
|
||||
```bash
|
||||
npm run start -w @pi-mobile/api -- --port 9000
|
||||
npx pi-mobile-hub --port 9000
|
||||
```
|
||||
|
||||
## Run the mobile app
|
||||
Disable pairing auth on trusted networks only:
|
||||
|
||||
In a second terminal:
|
||||
```bash
|
||||
npx pi-mobile-hub --no-auth
|
||||
```
|
||||
|
||||
### 2. Install the mobile app
|
||||
|
||||
**Option A — Prebuilt app (recommended)**
|
||||
|
||||
Install the Pi Mobile app from TestFlight (iOS) or the APK link (Android). See [Releasing](#releasing) if you are building it yourself.
|
||||
|
||||
**Option B — Expo Go (development)**
|
||||
|
||||
```bash
|
||||
npm run dev:mobile
|
||||
```
|
||||
|
||||
Scan the QR code with Expo Go, then:
|
||||
Scan the QR code with Expo Go.
|
||||
|
||||
1. Enter your computer's LAN IP and port (`8787`)
|
||||
2. Tap **Connect**
|
||||
3. Create a session by choosing a folder under `~/`
|
||||
4. Send prompts and watch streamed assistant text + tool activity
|
||||
5. Resume persisted Pi sessions from the list
|
||||
### 3. Pair and connect
|
||||
|
||||
1. Open Pi Mobile on your phone
|
||||
2. Scan the QR code printed by `pi-mobile-hub` in your terminal
|
||||
- Or enter your computer's LAN IP, port (`8787`), and pairing code manually
|
||||
3. Tap **Connect**
|
||||
4. Create a session by choosing a folder under `~/`
|
||||
5. Send prompts and watch streamed assistant text + tool activity
|
||||
6. Resume persisted Pi sessions from the list
|
||||
|
||||
Saved computers remember the pairing code for one-tap reconnect.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- Pi installed and authenticated on the host machine (`pi` CLI with `/login` or provider API keys)
|
||||
- Phone and computer on the same LAN
|
||||
- For prebuilt mobile installs: iOS or Android device
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
apps/api/ Express + Socket.IO hub (`pi-mobile-hub` npm package)
|
||||
apps/mobile/ Expo Router mobile app
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev:api # hub on :8787
|
||||
npm run dev:mobile # Expo dev server
|
||||
npm run build:api # compile hub for publishing
|
||||
```
|
||||
|
||||
## API surface
|
||||
|
||||
### HTTP
|
||||
|
||||
- `GET /health` — liveness check
|
||||
- `GET /fs/list?path=~` — folder-only explorer rooted at home
|
||||
- `GET /health` — liveness check (`authRequired` indicates whether pairing is enabled)
|
||||
- `GET /fs/list?path=~` — folder-only explorer rooted at home (requires pairing token)
|
||||
- `POST /fs/mkdir` — create folder under home (requires pairing token)
|
||||
|
||||
Authenticated HTTP requests must include header `x-pi-mobile-token: <pairing-code>`.
|
||||
|
||||
### Socket.IO (client → server)
|
||||
|
||||
| Event | Payload |
|
||||
|-------|---------|
|
||||
| `sessions:list` | — |
|
||||
| `runtime:create` | `{ cwd, name? }` |
|
||||
| `runtime:create` | `{ cwd, name?, model?, thinkingLevel? }` |
|
||||
| `runtime:open` | `{ sessionFile }` |
|
||||
| `runtime:attach` | `{ runtimeId }` |
|
||||
| `runtime:model:set` | `{ runtimeId, model }` |
|
||||
| `runtime:thinking:set` | `{ runtimeId, level }` |
|
||||
| `runtime:thinking:cycle` | `{ runtimeId }` |
|
||||
| `models:list` | `{ runtimeId?, cwd? }` |
|
||||
| `chat:prompt` | `{ runtimeId, message }` |
|
||||
| `chat:abort` | `{ runtimeId }` |
|
||||
|
||||
Socket connections must pass `auth: { token: "<pairing-code>" }` when pairing is enabled.
|
||||
|
||||
### Socket.IO (server → client)
|
||||
|
||||
| Event | Payload |
|
||||
@@ -88,17 +122,83 @@ Scan the QR code with Expo Go, then:
|
||||
| `sessions:list:result` | `{ persisted, live }` |
|
||||
| `runtime:created` / `runtime:opened` | `{ runtimeId, sessionId, cwd, sessionName?, sessionFile? }` |
|
||||
| `chat:event` | `{ runtimeId, event }` |
|
||||
| `chat:history` | `{ runtimeId, messages }` |
|
||||
| `error` | `{ message }` |
|
||||
|
||||
## Pairing & security
|
||||
|
||||
Each hub start generates a fresh pairing token unless `--no-auth` is passed. The token is required for:
|
||||
|
||||
- Socket.IO connections (`handshake.auth.token`)
|
||||
- Folder HTTP endpoints (`x-pi-mobile-token` header)
|
||||
|
||||
The mobile app stores the token per saved computer. Scanning the terminal QR code auto-fills host, port, and token via `pimobile://connect?host=...&port=...&token=...`.
|
||||
|
||||
This is LAN pairing, not full remote access over the internet. Do not expose the hub to untrusted networks without additional hardening.
|
||||
|
||||
## Releasing
|
||||
|
||||
### Hub (`pi-mobile-hub` on npm)
|
||||
|
||||
From `apps/api`:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm version patch
|
||||
npm publish
|
||||
```
|
||||
|
||||
Users can then run `npx pi-mobile-hub@latest`.
|
||||
|
||||
### Mobile app (EAS)
|
||||
|
||||
One-time setup:
|
||||
|
||||
```bash
|
||||
cd apps/mobile
|
||||
npm install -g eas-cli
|
||||
eas login
|
||||
eas init
|
||||
```
|
||||
|
||||
Update `app.json` → `extra.eas.projectId` with the ID from `eas init`.
|
||||
|
||||
Build for internal testing:
|
||||
|
||||
```bash
|
||||
# iOS (TestFlight via internal distribution)
|
||||
eas build --platform ios --profile preview
|
||||
|
||||
# Android (shareable APK)
|
||||
eas build --platform android --profile preview
|
||||
```
|
||||
|
||||
Submit to stores:
|
||||
|
||||
```bash
|
||||
eas build --platform ios --profile production
|
||||
eas submit --platform ios
|
||||
|
||||
eas build --platform android --profile production
|
||||
eas submit --platform android
|
||||
```
|
||||
|
||||
Optional OTA updates for JS-only changes:
|
||||
|
||||
```bash
|
||||
eas update --branch production --message "Describe the change"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Security/auth are intentionally out of scope for this prototype.
|
||||
- The folder browser only exposes directories under your home folder.
|
||||
- Pi credentials are read from the host environment / Pi auth storage — the mobile app does not handle login.
|
||||
- Multiple live runtimes can run concurrently; switch between them from the Sessions screen.
|
||||
|
||||
## Success checklist
|
||||
|
||||
- [ ] Start hub via `npx pi-mobile-hub`
|
||||
- [ ] Pair via QR code or manual pairing code
|
||||
- [ ] Connect from phone via LAN IP
|
||||
- [ ] Browse folders starting at `~/`
|
||||
- [ ] Create a new Pi session
|
||||
|
||||
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 = {};
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
"userInterfaceStyle": "dark",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "dev.pi.mobile"
|
||||
},
|
||||
"android": {
|
||||
"package": "dev.pi.mobile",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#0a0a0a"
|
||||
}
|
||||
@@ -20,6 +22,11 @@
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "REPLACE_WITH_EAS_PROJECT_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export default function ConnectScreen() {
|
||||
host,
|
||||
port,
|
||||
computerName,
|
||||
token,
|
||||
connected,
|
||||
connecting,
|
||||
error,
|
||||
@@ -62,6 +63,7 @@ export default function ConnectScreen() {
|
||||
setHost,
|
||||
setPort,
|
||||
setComputerName,
|
||||
setToken,
|
||||
connect,
|
||||
connectToSavedComputer,
|
||||
removeSavedComputerEntry,
|
||||
@@ -80,7 +82,10 @@ export default function ConnectScreen() {
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.brand}>Pi Mobile</Text>
|
||||
<Text style={styles.subtitle}>Remote control for Pi agents on your computer.</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Remote control for Pi agents on your computer. Scan the QR code from pi-mobile-hub, or enter
|
||||
your computer details manually.
|
||||
</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.label}>Computer name</Text>
|
||||
@@ -115,6 +120,17 @@ export default function ConnectScreen() {
|
||||
style={styles.input}
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Pairing code</Text>
|
||||
<TextInput
|
||||
value={token}
|
||||
onChangeText={setToken}
|
||||
placeholder="From pi-mobile-hub terminal"
|
||||
placeholderTextColor="#666"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.input}
|
||||
/>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Pressable
|
||||
|
||||
@@ -139,7 +139,7 @@ function NoSearchResults({ query }: { query: string }) {
|
||||
}
|
||||
|
||||
export default function FolderPickerScreen() {
|
||||
const { baseUrl, addProject, savedProjects, error } = useSocket();
|
||||
const { baseUrl, token, addProject, savedProjects, error } = useSocket();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [current, setCurrent] = useState<FolderListResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -161,7 +161,7 @@ export default function FolderPickerScreen() {
|
||||
setLocalError(null);
|
||||
setSearch("");
|
||||
try {
|
||||
const result = (await listFolders(baseUrl, path)) as FolderListResult;
|
||||
const result = (await listFolders(baseUrl, path, token)) as FolderListResult;
|
||||
setCurrent(result);
|
||||
} catch (loadError) {
|
||||
const message = loadError instanceof Error ? loadError.message : "Failed to load folders";
|
||||
@@ -170,7 +170,7 @@ export default function FolderPickerScreen() {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[baseUrl],
|
||||
[baseUrl, token],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -217,7 +217,7 @@ export default function FolderPickerScreen() {
|
||||
setCreatingFolder(true);
|
||||
setNewFolderError(null);
|
||||
try {
|
||||
await createFolder(baseUrl, current.path, trimmed);
|
||||
await createFolder(baseUrl, current.path, trimmed, token);
|
||||
setNewFolderVisible(false);
|
||||
await loadPath(current.path);
|
||||
} catch (createError) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
saveLastModel,
|
||||
upsertSavedComputer,
|
||||
} from "@/lib/api";
|
||||
import { parseConnectDeepLink } from "@/lib/deeplink";
|
||||
import {
|
||||
createSocket,
|
||||
listModels as requestModels,
|
||||
@@ -38,11 +39,13 @@ import type {
|
||||
ThinkingLevel,
|
||||
} from "@/lib/types";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import * as Linking from "expo-linking";
|
||||
|
||||
type SocketContextValue = {
|
||||
host: string;
|
||||
port: string;
|
||||
computerName: string;
|
||||
token: string;
|
||||
baseUrl: string | null;
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
@@ -56,8 +59,10 @@ type SocketContextValue = {
|
||||
setHost: (host: string) => void;
|
||||
setPort: (port: string) => void;
|
||||
setComputerName: (name: string) => void;
|
||||
setToken: (token: string) => void;
|
||||
connect: () => Promise<boolean>;
|
||||
connectToSavedComputer: (computer: SavedComputer) => Promise<boolean>;
|
||||
connectFromDeepLink: (url: string) => Promise<boolean>;
|
||||
removeSavedComputerEntry: (id: string) => Promise<void>;
|
||||
disconnect: () => void;
|
||||
refreshSessions: () => void;
|
||||
@@ -89,6 +94,7 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
||||
const [host, setHost] = useState("");
|
||||
const [port, setPort] = useState("8787");
|
||||
const [computerName, setComputerName] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
@@ -118,6 +124,7 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
||||
setHost(recent.host);
|
||||
setPort(recent.port);
|
||||
setComputerName(recent.name);
|
||||
setToken(recent.token ?? "");
|
||||
void loadSavedProjects(recent.host, recent.port).then(setSavedProjects);
|
||||
});
|
||||
}, []);
|
||||
@@ -168,20 +175,28 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const connectWithHost = useCallback(
|
||||
async (nextHost: string, nextPort: string, nextName?: string) => {
|
||||
async (
|
||||
nextHost: string,
|
||||
nextPort: string,
|
||||
nextName?: string,
|
||||
nextToken?: string,
|
||||
) => {
|
||||
setConnecting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const nextBaseUrl = buildBaseUrl(nextHost, nextPort);
|
||||
const healthy = await checkHealth(nextBaseUrl);
|
||||
const trimmedToken = nextToken?.trim();
|
||||
const healthy = await checkHealth(nextBaseUrl, trimmedToken);
|
||||
if (!healthy) {
|
||||
throw new Error("Could not reach Pi Mobile API");
|
||||
}
|
||||
|
||||
disconnect();
|
||||
|
||||
const socket = createSocket(nextBaseUrl, {
|
||||
const socket = createSocket(
|
||||
nextBaseUrl,
|
||||
{
|
||||
onConnect: () => {
|
||||
setConnected(true);
|
||||
setConnecting(false);
|
||||
@@ -215,7 +230,9 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
||||
listener(payload);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
trimmedToken,
|
||||
);
|
||||
|
||||
socketRef.current = socket;
|
||||
setBaseUrl(nextBaseUrl);
|
||||
@@ -224,12 +241,16 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
||||
if (nextName !== undefined) {
|
||||
setComputerName(nextName);
|
||||
}
|
||||
await saveHostConfig({ host: nextHost, port: nextPort });
|
||||
if (nextToken !== undefined) {
|
||||
setToken(nextToken);
|
||||
}
|
||||
await saveHostConfig({ host: nextHost, port: nextPort, token: trimmedToken });
|
||||
const nextComputers = await upsertSavedComputer(
|
||||
savedComputers,
|
||||
nextHost,
|
||||
nextPort,
|
||||
nextName ?? computerName,
|
||||
trimmedToken,
|
||||
);
|
||||
setSavedComputers(nextComputers);
|
||||
const projects = await loadSavedProjects(nextHost, nextPort);
|
||||
@@ -247,16 +268,58 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
return connectWithHost(host, port, computerName);
|
||||
}, [connectWithHost, host, port, computerName]);
|
||||
return connectWithHost(host, port, computerName, token);
|
||||
}, [connectWithHost, host, port, computerName, token]);
|
||||
|
||||
const connectToSavedComputer = useCallback(
|
||||
async (computer: SavedComputer) => {
|
||||
return connectWithHost(computer.host, computer.port, computer.name);
|
||||
return connectWithHost(
|
||||
computer.host,
|
||||
computer.port,
|
||||
computer.name,
|
||||
computer.token,
|
||||
);
|
||||
},
|
||||
[connectWithHost],
|
||||
);
|
||||
|
||||
const connectFromDeepLink = useCallback(
|
||||
async (url: string) => {
|
||||
const params = parseConnectDeepLink(url);
|
||||
if (!params) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return connectWithHost(
|
||||
params.host,
|
||||
params.port,
|
||||
params.name ?? params.host,
|
||||
params.token,
|
||||
);
|
||||
},
|
||||
[connectWithHost],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDeepLink = (url: string) => {
|
||||
void connectFromDeepLink(url);
|
||||
};
|
||||
|
||||
void Linking.getInitialURL().then((url) => {
|
||||
if (url) {
|
||||
handleDeepLink(url);
|
||||
}
|
||||
});
|
||||
|
||||
const subscription = Linking.addEventListener("url", (event) => {
|
||||
handleDeepLink(event.url);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [connectFromDeepLink]);
|
||||
|
||||
const refreshSessions = useCallback(() => {
|
||||
socketRef.current?.emit("sessions:list");
|
||||
}, []);
|
||||
@@ -384,6 +447,7 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
||||
host,
|
||||
port,
|
||||
computerName,
|
||||
token,
|
||||
baseUrl,
|
||||
connected,
|
||||
connecting,
|
||||
@@ -398,8 +462,10 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
||||
setHost,
|
||||
setPort,
|
||||
setComputerName,
|
||||
setToken,
|
||||
connect,
|
||||
connectToSavedComputer,
|
||||
connectFromDeepLink,
|
||||
removeSavedComputerEntry,
|
||||
disconnect,
|
||||
refreshSessions,
|
||||
@@ -419,6 +485,7 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
||||
host,
|
||||
port,
|
||||
computerName,
|
||||
token,
|
||||
baseUrl,
|
||||
connected,
|
||||
connecting,
|
||||
@@ -432,6 +499,7 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
||||
removeProject,
|
||||
connect,
|
||||
connectToSavedComputer,
|
||||
connectFromDeepLink,
|
||||
removeSavedComputerEntry,
|
||||
disconnect,
|
||||
refreshSessions,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
+27
-5
@@ -116,6 +116,7 @@ export async function upsertSavedComputer(
|
||||
host: string,
|
||||
port: string,
|
||||
name?: string,
|
||||
token?: string,
|
||||
): Promise<SavedComputer[]> {
|
||||
const trimmedHost = host.trim();
|
||||
const trimmedPort = port.trim() || "8787";
|
||||
@@ -126,6 +127,7 @@ export async function upsertSavedComputer(
|
||||
const key = computerKey(trimmedHost, trimmedPort);
|
||||
const existing = computers.find((entry) => computerKey(entry.host, entry.port) === key);
|
||||
const trimmedName = name?.trim();
|
||||
const trimmedToken = token?.trim();
|
||||
|
||||
if (existing) {
|
||||
const next = computers.map((entry) =>
|
||||
@@ -135,6 +137,7 @@ export async function upsertSavedComputer(
|
||||
name: trimmedName || entry.name,
|
||||
host: trimmedHost,
|
||||
port: trimmedPort,
|
||||
token: trimmedToken || entry.token,
|
||||
lastUsedAt: Date.now(),
|
||||
}
|
||||
: entry,
|
||||
@@ -149,6 +152,7 @@ export async function upsertSavedComputer(
|
||||
name: trimmedName || trimmedHost,
|
||||
host: trimmedHost,
|
||||
port: trimmedPort,
|
||||
token: trimmedToken,
|
||||
lastUsedAt: Date.now(),
|
||||
},
|
||||
...computers,
|
||||
@@ -191,8 +195,20 @@ export async function saveSavedProjects(
|
||||
await AsyncStorage.setItem(projectsStorageKey(host, port), JSON.stringify(projects));
|
||||
}
|
||||
|
||||
export async function checkHealth(baseUrl: string): Promise<boolean> {
|
||||
const response = await fetch(`${baseUrl}/health`);
|
||||
function authHeaders(token?: string): HeadersInit | undefined {
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
"x-pi-mobile-token": token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkHealth(baseUrl: string, token?: string): Promise<boolean> {
|
||||
const response = await fetch(`${baseUrl}/health`, {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
@@ -201,8 +217,10 @@ export async function checkHealth(baseUrl: string): Promise<boolean> {
|
||||
return data.ok === true;
|
||||
}
|
||||
|
||||
export async function listFolders(baseUrl: string, path = "~") {
|
||||
const response = await fetch(`${baseUrl}/fs/list?path=${encodeURIComponent(path)}`);
|
||||
export async function listFolders(baseUrl: string, path = "~", token?: string) {
|
||||
const response = await fetch(`${baseUrl}/fs/list?path=${encodeURIComponent(path)}`, {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(body.error ?? "Failed to list folders");
|
||||
@@ -215,10 +233,14 @@ export async function createFolder(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
name: string,
|
||||
token?: string,
|
||||
): Promise<{ name: string; path: string }> {
|
||||
const response = await fetch(`${baseUrl}/fs/mkdir`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...authHeaders(token),
|
||||
},
|
||||
body: JSON.stringify({ path, name }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export type ConnectDeepLinkParams = {
|
||||
host: string;
|
||||
port: string;
|
||||
token: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export function parseConnectDeepLink(url: string): ConnectDeepLinkParams | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "pimobile:") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = parsed.pathname.replace(/^\/+/, "");
|
||||
if (path !== "connect" && parsed.host !== "connect") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = parsed.searchParams.get("host")?.trim();
|
||||
const port = parsed.searchParams.get("port")?.trim() || "8787";
|
||||
const token = parsed.searchParams.get("token")?.trim();
|
||||
const name = parsed.searchParams.get("name")?.trim();
|
||||
|
||||
if (!host || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { host, port, token, name: name || undefined };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,22 @@ export type SocketCallbacks = {
|
||||
onChatHistory?: (payload: ChatHistoryPayload) => void;
|
||||
};
|
||||
|
||||
export function createSocket(baseUrl: string, callbacks: SocketCallbacks): Socket {
|
||||
export function createSocket(
|
||||
baseUrl: string,
|
||||
callbacks: SocketCallbacks,
|
||||
token?: string,
|
||||
): Socket {
|
||||
const socket = io(baseUrl, {
|
||||
transports: ["websocket"],
|
||||
autoConnect: true,
|
||||
auth: token ? { token } : undefined,
|
||||
});
|
||||
|
||||
socket.on("connect", () => callbacks.onConnect?.());
|
||||
socket.on("disconnect", () => callbacks.onDisconnect?.());
|
||||
socket.on("connect_error", (error) => {
|
||||
callbacks.onError?.(error.message || "Connection failed");
|
||||
});
|
||||
socket.on("error", (payload: { message?: string }) => {
|
||||
callbacks.onError?.(payload.message ?? "Socket error");
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ export type ChatItem =
|
||||
export type HostConfig = {
|
||||
host: string;
|
||||
port: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export type SavedComputer = {
|
||||
@@ -114,5 +115,6 @@ export type SavedComputer = {
|
||||
name: string;
|
||||
host: string;
|
||||
port: string;
|
||||
token?: string;
|
||||
lastUsedAt: number;
|
||||
};
|
||||
|
||||
Generated
+28
-5
@@ -10,20 +10,28 @@
|
||||
]
|
||||
},
|
||||
"apps/api": {
|
||||
"name": "@pi-mobile/api",
|
||||
"name": "pi-mobile-hub",
|
||||
"version": "0.1.0",
|
||||
"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"
|
||||
},
|
||||
"bin": {
|
||||
"pi-mobile-hub": "bin/pi-mobile-hub.js"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"apps/api/node_modules/@types/node": {
|
||||
@@ -36,6 +44,14 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"apps/api/node_modules/qrcode-terminal": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
|
||||
"integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
|
||||
"bin": {
|
||||
"qrcode-terminal": "bin/qrcode-terminal.js"
|
||||
}
|
||||
},
|
||||
"apps/mobile": {
|
||||
"name": "@pi-mobile/mobile",
|
||||
"version": "0.1.0",
|
||||
@@ -4760,10 +4776,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pi-mobile/api": {
|
||||
"resolved": "apps/api",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@pi-mobile/mobile": {
|
||||
"resolved": "apps/mobile",
|
||||
"link": true
|
||||
@@ -5614,6 +5626,13 @@
|
||||
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode-terminal": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz",
|
||||
"integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
@@ -10124,6 +10143,10 @@
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pi-mobile-hub": {
|
||||
"resolved": "apps/api",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
||||
+2
-2
@@ -5,9 +5,9 @@
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev:api": "npm run dev -w @pi-mobile/api",
|
||||
"dev:api": "npm run dev -w pi-mobile-hub",
|
||||
"dev:mobile": "npm run start -w @pi-mobile/mobile",
|
||||
"build:api": "npm run build -w @pi-mobile/api"
|
||||
"build:api": "npm run build -w pi-mobile-hub"
|
||||
},
|
||||
"overrides": {
|
||||
"react": "19.1.0",
|
||||
|
||||
Reference in New Issue
Block a user