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/src/logger.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Terminal logger with colors and formatting for the Forge Remote relay.
3
+ *
4
+ * Gives the desktop user clear, real-time feedback about what's happening.
5
+ */
6
+
7
+ // ANSI color codes.
8
+ const RESET = "\x1b[0m";
9
+ const BOLD = "\x1b[1m";
10
+ const DIM = "\x1b[2m";
11
+ const RED = "\x1b[31m";
12
+ const GREEN = "\x1b[32m";
13
+ const YELLOW = "\x1b[33m";
14
+ const BLUE = "\x1b[34m";
15
+ const MAGENTA = "\x1b[35m";
16
+ const CYAN = "\x1b[36m";
17
+ const WHITE = "\x1b[37m";
18
+ const BG_GREEN = "\x1b[42m";
19
+ const BG_RED = "\x1b[41m";
20
+ const BG_YELLOW = "\x1b[43m";
21
+ const BG_BLUE = "\x1b[44m";
22
+
23
+ function timestamp() {
24
+ return new Date().toLocaleTimeString("en-US", { hour12: false });
25
+ }
26
+
27
+ /** General info message. */
28
+ export function info(msg) {
29
+ console.log(`${DIM}${timestamp()}${RESET} ${BLUE}ℹ${RESET} ${msg}`);
30
+ }
31
+
32
+ /** Success message with green checkmark. */
33
+ export function success(msg) {
34
+ console.log(`${DIM}${timestamp()}${RESET} ${GREEN}✓${RESET} ${msg}`);
35
+ }
36
+
37
+ /** Warning message. */
38
+ export function warn(msg) {
39
+ console.log(
40
+ `${DIM}${timestamp()}${RESET} ${YELLOW}⚠${RESET} ${YELLOW}${msg}${RESET}`,
41
+ );
42
+ }
43
+
44
+ /** Error message. */
45
+ export function error(msg) {
46
+ console.log(
47
+ `${DIM}${timestamp()}${RESET} ${RED}✗${RESET} ${RED}${msg}${RESET}`,
48
+ );
49
+ }
50
+
51
+ /** Session-related event (bold session context). */
52
+ export function session(sessionId, msg) {
53
+ const short = sessionId.slice(0, 8);
54
+ console.log(
55
+ `${DIM}${timestamp()}${RESET} ${MAGENTA}◆${RESET} ${DIM}[${short}]${RESET} ${msg}`,
56
+ );
57
+ }
58
+
59
+ /** Command received from mobile. */
60
+ export function command(type, detail) {
61
+ console.log(
62
+ `${DIM}${timestamp()}${RESET} ${CYAN}↓${RESET} ${BOLD}Command:${RESET} ${type}${detail ? ` — ${detail}` : ""}`,
63
+ );
64
+ }
65
+
66
+ /** Tool call by Claude. */
67
+ export function toolCall(sessionId, toolName, input) {
68
+ const short = sessionId.slice(0, 8);
69
+ const preview =
70
+ typeof input === "string"
71
+ ? input.slice(0, 80)
72
+ : JSON.stringify(input).slice(0, 80);
73
+ console.log(
74
+ `${DIM}${timestamp()}${RESET} ${YELLOW}⚡${RESET} ${DIM}[${short}]${RESET} ${BOLD}${toolName}${RESET} ${DIM}${preview}${RESET}`,
75
+ );
76
+ }
77
+
78
+ /** Claude output (truncated preview). */
79
+ export function claudeOutput(sessionId, text) {
80
+ const short = sessionId.slice(0, 8);
81
+ const preview = text.replace(/\n/g, " ").slice(0, 100);
82
+ console.log(
83
+ `${DIM}${timestamp()}${RESET} ${GREEN}▸${RESET} ${DIM}[${short}]${RESET} ${preview}${text.length > 100 ? "..." : ""}`,
84
+ );
85
+ }
86
+
87
+ /** Session started banner. */
88
+ export function sessionStarted({ sessionId, project, model, prompt }) {
89
+ const short = sessionId.slice(0, 8);
90
+ const line = "─".repeat(60);
91
+ console.log(`\n${CYAN}${line}${RESET}`);
92
+ console.log(`${CYAN} ${BOLD}NEW SESSION${RESET} ${DIM}${short}${RESET}`);
93
+ console.log(`${CYAN}${line}${RESET}`);
94
+ console.log(` ${BOLD}Project:${RESET} ${project}`);
95
+ console.log(` ${BOLD}Model:${RESET} ${model}`);
96
+ console.log(
97
+ ` ${BOLD}Prompt:${RESET} ${(prompt || "").slice(0, 80)}${(prompt || "").length > 80 ? "..." : ""}`,
98
+ );
99
+ console.log(`${CYAN}${line}${RESET}\n`);
100
+ }
101
+
102
+ /** Session ended banner. */
103
+ export function sessionEnded({
104
+ sessionId,
105
+ status,
106
+ duration,
107
+ toolCalls,
108
+ messages,
109
+ }) {
110
+ const short = sessionId.slice(0, 8);
111
+ const line = "─".repeat(60);
112
+ const color = status === "completed" ? GREEN : RED;
113
+ const icon = status === "completed" ? "✓" : "✗";
114
+ console.log(`\n${color}${line}${RESET}`);
115
+ console.log(
116
+ `${color} ${icon} SESSION ${status.toUpperCase()}${RESET} ${DIM}${short}${RESET}`,
117
+ );
118
+ console.log(`${color}${line}${RESET}`);
119
+ console.log(` ${BOLD}Duration:${RESET} ${duration}`);
120
+ console.log(` ${BOLD}Tool calls:${RESET} ${toolCalls}`);
121
+ console.log(` ${BOLD}Messages:${RESET} ${messages}`);
122
+ console.log(`${color}${line}${RESET}\n`);
123
+ }
124
+
125
+ /** Relay startup banner. */
126
+ export function banner(hostname, platform, desktopId, projectCount) {
127
+ const line = "═".repeat(50);
128
+ console.log(`\n${BOLD}${CYAN}${line}${RESET}`);
129
+ console.log(`${BOLD}${CYAN} ⚡ FORGE REMOTE RELAY${RESET}`);
130
+ console.log(`${BOLD}${CYAN}${line}${RESET}`);
131
+ console.log(` ${BOLD}Host:${RESET} ${hostname}`);
132
+ console.log(` ${BOLD}Platform:${RESET} ${platform}`);
133
+ console.log(` ${BOLD}Desktop:${RESET} ${desktopId}`);
134
+ console.log(` ${BOLD}Projects:${RESET} ${projectCount} found`);
135
+ console.log(`${BOLD}${CYAN}${line}${RESET}`);
136
+ console.log(` ${GREEN}Listening for commands...${RESET}`);
137
+ console.log(` ${DIM}Press Ctrl+C to stop${RESET}\n`);
138
+ }
139
+
140
+ /** Heartbeat tick (only show occasionally to avoid noise). */
141
+ let heartbeatCount = 0;
142
+ export function heartbeat() {
143
+ heartbeatCount++;
144
+ // Only log every 10th heartbeat (every 5 minutes).
145
+ if (heartbeatCount % 10 === 0) {
146
+ console.log(
147
+ `${DIM}${timestamp()} ♥ Heartbeat #${heartbeatCount} — relay still running${RESET}`,
148
+ );
149
+ }
150
+ }
@@ -0,0 +1,103 @@
1
+ import { readdirSync, statSync, existsSync } from "fs";
2
+ import { join, basename } from "path";
3
+ import { homedir } from "os";
4
+
5
+ // Directories to scan (one level deep) for projects.
6
+ const SCAN_DIRS = [
7
+ join(homedir(), "Documents"),
8
+ join(homedir(), "Projects"),
9
+ join(homedir(), "Developer"),
10
+ join(homedir(), "code"),
11
+ join(homedir(), "dev"),
12
+ join(homedir(), "work"),
13
+ join(homedir(), "src"),
14
+ join(homedir(), "repos"),
15
+ join(homedir(), "Desktop"),
16
+ ];
17
+
18
+ // Files that indicate a directory is a project root.
19
+ const PROJECT_MARKERS = [
20
+ ".git",
21
+ "package.json",
22
+ "pubspec.yaml",
23
+ "Cargo.toml",
24
+ "go.mod",
25
+ "pyproject.toml",
26
+ "requirements.txt",
27
+ "Gemfile",
28
+ "pom.xml",
29
+ "build.gradle",
30
+ "Makefile",
31
+ "CMakeLists.txt",
32
+ ];
33
+
34
+ /**
35
+ * Scan common directories for projects.
36
+ * Returns an array of { path, name, lastOpened } objects.
37
+ */
38
+ export async function scanProjects() {
39
+ const projects = [];
40
+ const seen = new Set();
41
+
42
+ for (const scanDir of SCAN_DIRS) {
43
+ if (!existsSync(scanDir)) continue;
44
+
45
+ try {
46
+ const entries = readdirSync(scanDir, { withFileTypes: true });
47
+ for (const entry of entries) {
48
+ if (!entry.isDirectory()) continue;
49
+ if (entry.name.startsWith(".")) continue;
50
+
51
+ const dirPath = join(scanDir, entry.name);
52
+ if (seen.has(dirPath)) continue;
53
+
54
+ if (isProject(dirPath)) {
55
+ seen.add(dirPath);
56
+ const stat = statSync(dirPath);
57
+ projects.push({
58
+ path: dirPath,
59
+ name: basename(dirPath),
60
+ lastOpened: stat.mtime.toISOString(),
61
+ });
62
+ }
63
+
64
+ // Also scan one level deeper for nested project dirs
65
+ // (e.g. ~/Documents/IronForgeApps/Mobile/ForgeRemote).
66
+ try {
67
+ const subEntries = readdirSync(dirPath, { withFileTypes: true });
68
+ for (const sub of subEntries) {
69
+ if (!sub.isDirectory() || sub.name.startsWith(".")) continue;
70
+ const subPath = join(dirPath, sub.name);
71
+ if (seen.has(subPath)) continue;
72
+
73
+ if (isProject(subPath)) {
74
+ seen.add(subPath);
75
+ const stat = statSync(subPath);
76
+ projects.push({
77
+ path: subPath,
78
+ name: basename(subPath),
79
+ lastOpened: stat.mtime.toISOString(),
80
+ });
81
+ }
82
+ }
83
+ } catch {
84
+ // Skip subdirs we can't read.
85
+ }
86
+ }
87
+ } catch {
88
+ // Skip dirs we can't read.
89
+ }
90
+ }
91
+
92
+ // Sort by lastOpened descending.
93
+ projects.sort((a, b) => b.lastOpened.localeCompare(a.lastOpened));
94
+
95
+ return projects;
96
+ }
97
+
98
+ function isProject(dirPath) {
99
+ for (const marker of PROJECT_MARKERS) {
100
+ if (existsSync(join(dirPath, marker))) return true;
101
+ }
102
+ return false;
103
+ }
@@ -0,0 +1,204 @@
1
+ import { execSync, exec } from "child_process";
2
+ import { readFileSync, unlinkSync, existsSync } from "fs";
3
+ import { getDb, FieldValue } from "./firebase.js";
4
+ import * as log from "./logger.js";
5
+
6
+ /**
7
+ * Captures periodic screenshots from the iOS Simulator (or Android emulator)
8
+ * and uploads them to Firestore so the mobile app can display a live preview
9
+ * of native apps being built by Claude.
10
+ *
11
+ * Detection:
12
+ * - macOS: `xcrun simctl list devices booted` for iOS Simulator
13
+ * - Android: `adb devices` for connected emulators
14
+ *
15
+ * Capture:
16
+ * - iOS: `xcrun simctl io booted screenshot <path>`
17
+ * - Android: `adb exec-out screencap -p` (binary PNG to stdout)
18
+ *
19
+ * Images are resized to 400px wide via `sips` (macOS) to keep payloads
20
+ * small, then base64-encoded and stored in the session doc.
21
+ */
22
+
23
+ const activeCaptures = new Map(); // sessionId → intervalId
24
+
25
+ const CAPTURE_INTERVAL_MS = 5_000;
26
+ const TEMP_PATH_PREFIX = "/tmp/forge-preview-";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Public API
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Start periodic screenshot capture for a session.
34
+ * Auto-detects whether an iOS Simulator or Android emulator is running.
35
+ */
36
+ export function startCapturing(sessionId) {
37
+ if (activeCaptures.has(sessionId)) return;
38
+
39
+ const target = detectTarget();
40
+ if (!target) {
41
+ log.info(`[screenshot] No simulator/emulator detected — skipping capture`);
42
+ return;
43
+ }
44
+
45
+ log.success(
46
+ `[screenshot] Starting capture for session ${sessionId.slice(0, 8)} (${target.type}: ${target.name})`,
47
+ );
48
+
49
+ // Take an initial screenshot immediately, then repeat.
50
+ captureAndUpload(sessionId, target);
51
+
52
+ const intervalId = setInterval(() => {
53
+ captureAndUpload(sessionId, target);
54
+ }, CAPTURE_INTERVAL_MS);
55
+
56
+ activeCaptures.set(sessionId, intervalId);
57
+ }
58
+
59
+ /**
60
+ * Stop capturing for a session.
61
+ */
62
+ export function stopCapturing(sessionId) {
63
+ const intervalId = activeCaptures.get(sessionId);
64
+ if (intervalId) {
65
+ clearInterval(intervalId);
66
+ activeCaptures.delete(sessionId);
67
+ log.info(
68
+ `[screenshot] Stopped capture for session ${sessionId.slice(0, 8)}`,
69
+ );
70
+ }
71
+ // Clean up temp file.
72
+ const tmpFile = `${TEMP_PATH_PREFIX}${sessionId}.png`;
73
+ try {
74
+ if (existsSync(tmpFile)) unlinkSync(tmpFile);
75
+ } catch {
76
+ // ignore
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Stop all active captures (relay shutdown cleanup).
82
+ */
83
+ export function stopAllCaptures() {
84
+ for (const [sessionId] of activeCaptures) {
85
+ stopCapturing(sessionId);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Check whether any simulator/emulator is currently running.
91
+ */
92
+ export function hasActiveSimulator() {
93
+ return detectTarget() !== null;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Target detection
98
+ // ---------------------------------------------------------------------------
99
+
100
+ function detectTarget() {
101
+ // Try iOS Simulator first (macOS only).
102
+ if (process.platform === "darwin") {
103
+ try {
104
+ const output = execSync("xcrun simctl list devices booted", {
105
+ encoding: "utf-8",
106
+ timeout: 5000,
107
+ stdio: ["pipe", "pipe", "pipe"],
108
+ });
109
+ // Look for booted devices — lines like: "iPhone 15 (UUID) (Booted)"
110
+ const match = output.match(/^\s+(.+?)\s+\([A-F0-9-]+\)\s+\(Booted\)/im);
111
+ if (match) {
112
+ return { type: "ios-simulator", name: match[1].trim() };
113
+ }
114
+ } catch {
115
+ // xcrun not available or no booted simulators.
116
+ }
117
+ }
118
+
119
+ // Try Android emulator.
120
+ try {
121
+ const output = execSync("adb devices", {
122
+ encoding: "utf-8",
123
+ timeout: 5000,
124
+ stdio: ["pipe", "pipe", "pipe"],
125
+ });
126
+ const lines = output.split("\n").filter((l) => l.includes("emulator"));
127
+ if (lines.length > 0) {
128
+ const deviceId = lines[0].split("\t")[0];
129
+ return { type: "android-emulator", name: deviceId };
130
+ }
131
+ } catch {
132
+ // adb not available.
133
+ }
134
+
135
+ return null;
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Capture + upload
140
+ // ---------------------------------------------------------------------------
141
+
142
+ async function captureAndUpload(sessionId, target) {
143
+ const tmpFile = `${TEMP_PATH_PREFIX}${sessionId}.png`;
144
+
145
+ try {
146
+ // Capture screenshot.
147
+ if (target.type === "ios-simulator") {
148
+ execSync(`xcrun simctl io booted screenshot "${tmpFile}"`, {
149
+ timeout: 10_000,
150
+ stdio: ["pipe", "pipe", "pipe"],
151
+ });
152
+ } else if (target.type === "android-emulator") {
153
+ // adb screencap outputs binary PNG data.
154
+ execSync(`adb exec-out screencap -p > "${tmpFile}"`, {
155
+ timeout: 10_000,
156
+ shell: true,
157
+ stdio: ["pipe", "pipe", "pipe"],
158
+ });
159
+ }
160
+
161
+ if (!existsSync(tmpFile)) return;
162
+
163
+ // Resize to 400px wide to keep Firestore payload small.
164
+ if (process.platform === "darwin") {
165
+ try {
166
+ execSync(
167
+ `sips --resampleWidth 400 "${tmpFile}" --out "${tmpFile}" 2>/dev/null`,
168
+ {
169
+ timeout: 5000,
170
+ shell: true,
171
+ stdio: ["pipe", "pipe", "pipe"],
172
+ },
173
+ );
174
+ } catch {
175
+ // If sips fails, we still upload the full-size image.
176
+ }
177
+ }
178
+
179
+ // Read and base64-encode.
180
+ const imageBuffer = readFileSync(tmpFile);
181
+ const base64 = imageBuffer.toString("base64");
182
+
183
+ // Store in Firestore session doc.
184
+ const db = getDb();
185
+ await db
186
+ .collection("sessions")
187
+ .doc(sessionId)
188
+ .update({
189
+ previewScreenshot: {
190
+ data: base64,
191
+ capturedAt: FieldValue.serverTimestamp(),
192
+ target: target.type,
193
+ deviceName: target.name,
194
+ },
195
+ lastActivity: FieldValue.serverTimestamp(),
196
+ });
197
+ } catch (err) {
198
+ // Don't spam logs — capture failures happen when the simulator is briefly
199
+ // busy (e.g. during app launch/restart).
200
+ if (err.message && !err.message.includes("timed out")) {
201
+ log.warn(`[screenshot] Capture failed: ${err.message}`);
202
+ }
203
+ }
204
+ }