forge-remote 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/package.json +49 -0
- package/src/cli.js +213 -0
- package/src/desktop.js +121 -0
- package/src/firebase.js +46 -0
- package/src/init.js +645 -0
- package/src/logger.js +150 -0
- package/src/project-scanner.js +103 -0
- package/src/screenshot-manager.js +204 -0
- package/src/session-manager.js +1539 -0
- package/src/tunnel-manager.js +201 -0
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "forge-remote",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Desktop relay for Forge Remote — monitor and control Claude Code sessions from your phone",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "AGPL-3.0",
|
|
7
|
+
"author": "Daniel Wendel <daniel@ironforgeapps.com> (https://ironforgeapps.com)",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/IronForgeApps/forge-remote.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/IronForgeApps/forge-remote/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/IronForgeApps/forge-remote#readme",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.0.0"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"forge-remote": "./src/cli.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"src/",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"start": "node src/cli.js start",
|
|
29
|
+
"pair": "node src/cli.js pair"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"claude",
|
|
33
|
+
"claude-code",
|
|
34
|
+
"anthropic",
|
|
35
|
+
"mobile",
|
|
36
|
+
"remote",
|
|
37
|
+
"relay",
|
|
38
|
+
"firebase",
|
|
39
|
+
"developer-tools"
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"chalk": "^5.4.0",
|
|
43
|
+
"commander": "^13.0.0",
|
|
44
|
+
"firebase-admin": "^13.0.0",
|
|
45
|
+
"localtunnel": "^2.0.2",
|
|
46
|
+
"qrcode-terminal": "^0.12.0",
|
|
47
|
+
"uuid": "^11.0.0"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Forge Remote Relay — Desktop Agent for Forge Remote
|
|
4
|
+
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
5
|
+
// Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
|
|
6
|
+
// AGPL-3.0 License — See LICENSE
|
|
7
|
+
|
|
8
|
+
import { program } from "commander";
|
|
9
|
+
import { execSync } from "child_process";
|
|
10
|
+
import { readFileSync, existsSync, writeFileSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { hostname, homedir } from "os";
|
|
13
|
+
import qrcode from "qrcode-terminal";
|
|
14
|
+
|
|
15
|
+
import { runInit } from "./init.js";
|
|
16
|
+
import { initFirebase } from "./firebase.js";
|
|
17
|
+
import {
|
|
18
|
+
registerDesktop,
|
|
19
|
+
markOffline,
|
|
20
|
+
startHeartbeat,
|
|
21
|
+
createPairingToken,
|
|
22
|
+
updateProjects,
|
|
23
|
+
getPlatformName,
|
|
24
|
+
getDesktopId,
|
|
25
|
+
} from "./desktop.js";
|
|
26
|
+
import {
|
|
27
|
+
listenForCommands,
|
|
28
|
+
shutdownAllSessions,
|
|
29
|
+
cleanupOrphanedSessions,
|
|
30
|
+
} from "./session-manager.js";
|
|
31
|
+
import { stopAllTunnels } from "./tunnel-manager.js";
|
|
32
|
+
import { stopAllCaptures } from "./screenshot-manager.js";
|
|
33
|
+
import { scanProjects } from "./project-scanner.js";
|
|
34
|
+
import * as log from "./logger.js";
|
|
35
|
+
|
|
36
|
+
program
|
|
37
|
+
.name("forge-remote")
|
|
38
|
+
.description("Desktop relay for Forge Remote")
|
|
39
|
+
.version("0.1.0");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Load Firebase web SDK config.
|
|
43
|
+
* Tries: 1) cached file from `init`, 2) Firebase CLI `apps:sdkconfig`.
|
|
44
|
+
*/
|
|
45
|
+
function loadSdkConfig() {
|
|
46
|
+
const forgeDir = join(homedir(), ".forge-remote");
|
|
47
|
+
const cachedPath = join(forgeDir, "sdk-config.json");
|
|
48
|
+
|
|
49
|
+
// 1. Try cached config (saved by `init`).
|
|
50
|
+
if (existsSync(cachedPath)) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(readFileSync(cachedPath, "utf-8"));
|
|
53
|
+
} catch {
|
|
54
|
+
// Fall through to CLI.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 2. Get project ID from service account key.
|
|
59
|
+
const saPath = join(forgeDir, "service-account.json");
|
|
60
|
+
if (!existsSync(saPath)) return null;
|
|
61
|
+
|
|
62
|
+
let projectId;
|
|
63
|
+
try {
|
|
64
|
+
const sa = JSON.parse(readFileSync(saPath, "utf-8"));
|
|
65
|
+
projectId = sa.project_id;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 3. Fetch via Firebase CLI.
|
|
71
|
+
try {
|
|
72
|
+
const output = execSync(
|
|
73
|
+
`firebase apps:sdkconfig web --project ${projectId}`,
|
|
74
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const parse = (field) => {
|
|
78
|
+
const m = output.match(new RegExp(`${field}:\\s*"([^"]+)"`));
|
|
79
|
+
return m ? m[1] : null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const config = {
|
|
83
|
+
apiKey: parse("apiKey"),
|
|
84
|
+
authDomain: parse("authDomain"),
|
|
85
|
+
projectId: parse("projectId"),
|
|
86
|
+
storageBucket: parse("storageBucket"),
|
|
87
|
+
messagingSenderId: parse("messagingSenderId"),
|
|
88
|
+
appId: parse("appId"),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (!config.apiKey || !config.projectId) return null;
|
|
92
|
+
|
|
93
|
+
// Cache for next time.
|
|
94
|
+
writeFileSync(cachedPath, JSON.stringify(config, null, 2));
|
|
95
|
+
return config;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
program
|
|
102
|
+
.command("start")
|
|
103
|
+
.description(
|
|
104
|
+
"Start the desktop relay (register, heartbeat, listen for commands)",
|
|
105
|
+
)
|
|
106
|
+
.action(async () => {
|
|
107
|
+
initFirebase();
|
|
108
|
+
const desktopId = await registerDesktop();
|
|
109
|
+
|
|
110
|
+
// Scan and register projects.
|
|
111
|
+
const projects = await scanProjects();
|
|
112
|
+
await updateProjects(desktopId, projects);
|
|
113
|
+
|
|
114
|
+
// Clean up any orphaned sessions from a previous relay run.
|
|
115
|
+
await cleanupOrphanedSessions(desktopId);
|
|
116
|
+
|
|
117
|
+
const heartbeat = startHeartbeat(desktopId);
|
|
118
|
+
|
|
119
|
+
listenForCommands(desktopId);
|
|
120
|
+
|
|
121
|
+
// Print startup banner.
|
|
122
|
+
log.banner(hostname(), getPlatformName(), desktopId, projects.length);
|
|
123
|
+
|
|
124
|
+
// Graceful shutdown with timeout so Ctrl+C always works.
|
|
125
|
+
let shuttingDown = false;
|
|
126
|
+
const cleanup = async () => {
|
|
127
|
+
if (shuttingDown) {
|
|
128
|
+
log.info("Force quitting...");
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
shuttingDown = true;
|
|
132
|
+
|
|
133
|
+
log.info("Shutting down relay...");
|
|
134
|
+
clearInterval(heartbeat);
|
|
135
|
+
|
|
136
|
+
// Give cleanup 5 seconds, then force exit.
|
|
137
|
+
const forceExit = setTimeout(() => {
|
|
138
|
+
log.info("Cleanup timed out. Force quitting.");
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}, 5000);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
await shutdownAllSessions();
|
|
144
|
+
stopAllTunnels();
|
|
145
|
+
stopAllCaptures();
|
|
146
|
+
await markOffline(desktopId);
|
|
147
|
+
log.success("Desktop marked offline. Goodbye!");
|
|
148
|
+
} catch (e) {
|
|
149
|
+
log.error(`Cleanup error: ${e.message}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
clearTimeout(forceExit);
|
|
153
|
+
process.exit(0);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
process.on("SIGINT", cleanup);
|
|
157
|
+
process.on("SIGTERM", cleanup);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
program
|
|
161
|
+
.command("pair")
|
|
162
|
+
.description("Generate a pairing QR code for the mobile app")
|
|
163
|
+
.action(async () => {
|
|
164
|
+
initFirebase();
|
|
165
|
+
const desktopId = await registerDesktop();
|
|
166
|
+
|
|
167
|
+
// Build the BYOF-compatible QR payload with Firebase SDK config.
|
|
168
|
+
const sdkConfig = loadSdkConfig();
|
|
169
|
+
if (!sdkConfig) {
|
|
170
|
+
console.error("\nCould not load Firebase SDK config.");
|
|
171
|
+
console.error(
|
|
172
|
+
"Run `forge-remote init` first, or ensure the Firebase CLI is installed.\n",
|
|
173
|
+
);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const pairingData = JSON.stringify({
|
|
178
|
+
v: 1,
|
|
179
|
+
firebase: {
|
|
180
|
+
apiKey: sdkConfig.apiKey,
|
|
181
|
+
projectId: sdkConfig.projectId,
|
|
182
|
+
appId: sdkConfig.appId,
|
|
183
|
+
messagingSenderId: sdkConfig.messagingSenderId,
|
|
184
|
+
storageBucket: sdkConfig.storageBucket,
|
|
185
|
+
},
|
|
186
|
+
desktop: {
|
|
187
|
+
id: desktopId,
|
|
188
|
+
hostname: hostname(),
|
|
189
|
+
platform: getPlatformName(),
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
console.log("\n=== Forge Remote Pairing ===\n");
|
|
194
|
+
console.log("Scan this QR code with the Forge Remote app:\n");
|
|
195
|
+
qrcode.generate(pairingData, { small: true });
|
|
196
|
+
|
|
197
|
+
console.log("\nRaw pairing data (for manual entry):\n");
|
|
198
|
+
console.log(pairingData);
|
|
199
|
+
console.log();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
program
|
|
203
|
+
.command("init")
|
|
204
|
+
.description("Set up Firebase project and generate pairing QR code")
|
|
205
|
+
.option(
|
|
206
|
+
"--project-id <id>",
|
|
207
|
+
"Custom Firebase project ID (default: forge-remote-<hostname>)",
|
|
208
|
+
)
|
|
209
|
+
.action(async (opts) => {
|
|
210
|
+
await runInit({ projectId: opts.projectId });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
program.parse();
|
package/src/desktop.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { getDb, FieldValue, Timestamp } from "./firebase.js";
|
|
2
|
+
import { hostname, platform } from "os";
|
|
3
|
+
import { v4 as uuidv4 } from "uuid";
|
|
4
|
+
import * as log from "./logger.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Register or update this desktop in Firestore.
|
|
8
|
+
* Returns the desktop document ID.
|
|
9
|
+
*/
|
|
10
|
+
export async function registerDesktop() {
|
|
11
|
+
const db = getDb();
|
|
12
|
+
const desktopId = getDesktopId();
|
|
13
|
+
|
|
14
|
+
const desktopRef = db.collection("desktops").doc(desktopId);
|
|
15
|
+
const doc = await desktopRef.get();
|
|
16
|
+
|
|
17
|
+
const platformName = getPlatformName();
|
|
18
|
+
|
|
19
|
+
if (doc.exists) {
|
|
20
|
+
await desktopRef.update({
|
|
21
|
+
status: "online",
|
|
22
|
+
lastHeartbeat: FieldValue.serverTimestamp(),
|
|
23
|
+
platform: platformName,
|
|
24
|
+
hostname: hostname(),
|
|
25
|
+
});
|
|
26
|
+
} else {
|
|
27
|
+
await desktopRef.set({
|
|
28
|
+
ownerUid: "", // Will be set during pairing.
|
|
29
|
+
hostname: hostname(),
|
|
30
|
+
platform: platformName,
|
|
31
|
+
status: "online",
|
|
32
|
+
lastHeartbeat: FieldValue.serverTimestamp(),
|
|
33
|
+
projects: [],
|
|
34
|
+
createdAt: FieldValue.serverTimestamp(),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(`Desktop registered: ${desktopId} (${hostname()})`);
|
|
39
|
+
return desktopId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mark desktop as offline.
|
|
44
|
+
*/
|
|
45
|
+
export async function markOffline(desktopId) {
|
|
46
|
+
const db = getDb();
|
|
47
|
+
await db.collection("desktops").doc(desktopId).update({
|
|
48
|
+
status: "offline",
|
|
49
|
+
lastHeartbeat: FieldValue.serverTimestamp(),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Start a heartbeat interval that updates lastHeartbeat every 30s.
|
|
55
|
+
*/
|
|
56
|
+
export function startHeartbeat(desktopId) {
|
|
57
|
+
const interval = setInterval(async () => {
|
|
58
|
+
try {
|
|
59
|
+
const db = getDb();
|
|
60
|
+
await db.collection("desktops").doc(desktopId).update({
|
|
61
|
+
lastHeartbeat: FieldValue.serverTimestamp(),
|
|
62
|
+
});
|
|
63
|
+
log.heartbeat();
|
|
64
|
+
} catch (e) {
|
|
65
|
+
log.error(`Heartbeat failed: ${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
}, 30_000);
|
|
68
|
+
|
|
69
|
+
return interval;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Update the project list for this desktop.
|
|
74
|
+
*/
|
|
75
|
+
export async function updateProjects(desktopId, projects) {
|
|
76
|
+
const db = getDb();
|
|
77
|
+
await db.collection("desktops").doc(desktopId).update({ projects });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create a pairing token for this desktop.
|
|
82
|
+
*/
|
|
83
|
+
export async function createPairingToken(desktopId) {
|
|
84
|
+
const db = getDb();
|
|
85
|
+
const tokenId = uuidv4().slice(0, 8); // Short code for easy manual entry.
|
|
86
|
+
|
|
87
|
+
await db
|
|
88
|
+
.collection("pairingTokens")
|
|
89
|
+
.doc(tokenId)
|
|
90
|
+
.set({
|
|
91
|
+
desktopId,
|
|
92
|
+
hostname: hostname(),
|
|
93
|
+
platform: getPlatformName(),
|
|
94
|
+
token: tokenId,
|
|
95
|
+
expiresAt: Timestamp.fromDate(new Date(Date.now() + 10 * 60 * 1000)), // 10 min
|
|
96
|
+
used: false,
|
|
97
|
+
claimedBy: null,
|
|
98
|
+
createdAt: FieldValue.serverTimestamp(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return tokenId;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get a stable desktop ID based on hostname.
|
|
106
|
+
* In production you'd store this in a config file.
|
|
107
|
+
*/
|
|
108
|
+
export function getDesktopId() {
|
|
109
|
+
// Use a consistent ID so the same machine always maps to the same doc.
|
|
110
|
+
const name = hostname()
|
|
111
|
+
.toLowerCase()
|
|
112
|
+
.replace(/[^a-z0-9]/g, "-");
|
|
113
|
+
return `desktop-${name}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getPlatformName() {
|
|
117
|
+
const p = platform();
|
|
118
|
+
if (p === "darwin") return "macos";
|
|
119
|
+
if (p === "win32") return "windows";
|
|
120
|
+
return "linux";
|
|
121
|
+
}
|
package/src/firebase.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { initializeApp, cert } from "firebase-admin/app";
|
|
2
|
+
import { getFirestore, FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
3
|
+
import { readFileSync, existsSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
|
|
7
|
+
let db;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Initialize Firebase Admin SDK.
|
|
11
|
+
* Looks for a service account key at:
|
|
12
|
+
* 1. FORGE_REMOTE_SA_KEY env var
|
|
13
|
+
* 2. ~/.forge-remote/service-account.json
|
|
14
|
+
*/
|
|
15
|
+
export function initFirebase() {
|
|
16
|
+
const envPath = process.env.FORGE_REMOTE_SA_KEY;
|
|
17
|
+
const defaultPath = join(homedir(), ".forge-remote", "service-account.json");
|
|
18
|
+
const keyPath = envPath || defaultPath;
|
|
19
|
+
|
|
20
|
+
if (!existsSync(keyPath)) {
|
|
21
|
+
console.error(`\nFirebase service account key not found at: ${keyPath}`);
|
|
22
|
+
console.error("Download it from:");
|
|
23
|
+
console.error(
|
|
24
|
+
" https://console.firebase.google.com/project/forge-remote/settings/serviceaccounts/adminsdk",
|
|
25
|
+
);
|
|
26
|
+
console.error(`Then save it to: ${defaultPath}\n`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const serviceAccount = JSON.parse(readFileSync(keyPath, "utf-8"));
|
|
31
|
+
|
|
32
|
+
initializeApp({
|
|
33
|
+
credential: cert(serviceAccount),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
db = getFirestore();
|
|
37
|
+
return db;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getDb() {
|
|
41
|
+
if (!db)
|
|
42
|
+
throw new Error("Firebase not initialized. Call initFirebase() first.");
|
|
43
|
+
return db;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { FieldValue, Timestamp };
|