forge-remote 0.1.29 → 2.1.1

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/firestore.rules CHANGED
@@ -54,12 +54,14 @@ service cloud.firestore {
54
54
  && request.resource.data.keys().hasAll(['ownerUid', 'desktopId', 'status']);
55
55
  allow update: if isSignedIn()
56
56
  && isValidSize();
57
+ allow delete: if isSignedIn();
57
58
 
58
59
  // ---- Messages (subcollection) ----
59
60
  match /messages/{messageId} {
60
61
  allow read: if isSignedIn();
61
62
  allow create: if isSignedIn()
62
- && request.resource.data.size() < 500000; // 500KB max per message
63
+ && request.resource.data.size() < 500000;
64
+ allow delete: if isSignedIn();
63
65
  }
64
66
 
65
67
  // ---- Commands (subcollection) ----
@@ -67,22 +69,49 @@ service cloud.firestore {
67
69
  allow read: if isSignedIn();
68
70
  allow create: if isSignedIn();
69
71
  allow update: if isSignedIn();
72
+ allow delete: if isSignedIn();
70
73
  }
71
74
 
72
75
  // ---- Permissions (subcollection) ----
73
76
  match /permissions/{permId} {
74
77
  allow read: if isSignedIn();
75
78
  allow update: if isSignedIn();
79
+ allow delete: if isSignedIn();
76
80
  }
77
81
 
78
82
  // ---- Tool calls (subcollection) ----
79
83
  match /toolCalls/{toolCallId} {
80
84
  allow read: if isSignedIn();
85
+ allow delete: if isSignedIn();
81
86
  }
82
87
 
83
88
  // ---- Git data (subcollection) ----
84
89
  match /gitData/{docId} {
85
90
  allow read: if isSignedIn();
91
+ allow delete: if isSignedIn();
92
+ }
93
+
94
+ // ---- Builds (subcollection) ----
95
+ match /builds/{buildId} {
96
+ allow read: if isSignedIn();
97
+ allow delete: if isSignedIn();
98
+ }
99
+
100
+ // ---- Deploys (subcollection) ----
101
+ match /deploys/{deployId} {
102
+ allow read: if isSignedIn();
103
+ allow delete: if isSignedIn();
104
+ }
105
+
106
+ // ---- Build data (subcollection — project info, ADB status, deploy readiness) ----
107
+ match /buildData/{docId} {
108
+ allow read: if isSignedIn();
109
+ }
110
+
111
+ // ---- Session summary (subcollection) ----
112
+ match /summary/{docId} {
113
+ allow read: if isSignedIn();
114
+ allow delete: if isSignedIn();
86
115
  }
87
116
  }
88
117
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "forge-remote",
3
- "version": "0.1.29",
4
- "description": "Desktop relay for Forge Remote — monitor and control Claude Code sessions from your phone",
3
+ "version": "2.1.1",
4
+ "description": "Desktop relay for Forge Remote — mobile command center for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
7
7
  "author": "Daniel Wendel <daniel@ironforgeapps.com> (https://ironforgeapps.com)",
@@ -4,9 +4,52 @@
4
4
 
5
5
  import { execSync, spawn } from "child_process";
6
6
  import path from "node:path";
7
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import {
8
+ existsSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ readdirSync,
12
+ statSync,
13
+ } from "node:fs";
8
14
  import * as log from "./logger.js";
9
15
 
16
+ /**
17
+ * Resolve a path that may contain glob patterns (e.g., *.ipa) to an actual file.
18
+ * Returns the resolved absolute path, or the original if no glob.
19
+ */
20
+ function resolveGlobPath(basePath, filePath) {
21
+ const fullPath = path.isAbsolute(filePath)
22
+ ? filePath
23
+ : path.join(basePath, filePath);
24
+
25
+ if (!fullPath.includes("*")) {
26
+ return fullPath;
27
+ }
28
+
29
+ // Manual glob resolution for simple *.ext patterns
30
+ const dir = path.dirname(fullPath);
31
+ const pattern = path.basename(fullPath);
32
+
33
+ if (!existsSync(dir)) return fullPath;
34
+
35
+ const ext = pattern.replace("*", "");
36
+ try {
37
+ const files = readdirSync(dir).filter((f) => f.endsWith(ext));
38
+ if (files.length > 0) {
39
+ // Return the most recently modified file
40
+ const resolved = files
41
+ .map((f) => {
42
+ const p = path.join(dir, f);
43
+ return { name: f, path: p, mtime: statSync(p).mtimeMs };
44
+ })
45
+ .sort((a, b) => b.mtime - a.mtime)[0];
46
+ return resolved.path;
47
+ }
48
+ } catch {}
49
+
50
+ return fullPath;
51
+ }
52
+
10
53
  // ---------------------------------------------------------------------------
11
54
  // Project Detection
12
55
  // ---------------------------------------------------------------------------
@@ -179,14 +222,40 @@ export async function runBuild(projectPath, platform, onOutput) {
179
222
  const outputLines = [];
180
223
 
181
224
  return new Promise((resolve, reject) => {
182
- const [cmd, ...args] = buildCmd.split(" ");
183
- const proc = spawn(cmd, args, {
225
+ // Use login shell with full Xcode environment for iOS builds.
226
+ const shell = process.env.SHELL || "/bin/zsh";
227
+ const buildEnv = {
228
+ ...process.env,
229
+ // Ensure Xcode tools are available
230
+ DEVELOPER_DIR:
231
+ process.env.DEVELOPER_DIR ||
232
+ "/Applications/Xcode.app/Contents/Developer",
233
+ };
234
+ // For iOS builds, run flutter clean first to avoid stale storyboard caches
235
+ const fullCmd =
236
+ platform === "ios"
237
+ ? `flutter clean > /dev/null 2>&1; ${buildCmd}`
238
+ : buildCmd;
239
+ const proc = spawn(shell, ["-l", "-c", fullCmd], {
184
240
  cwd: projectPath,
185
- shell: true,
186
- env: { ...process.env },
241
+ env: buildEnv,
187
242
  stdio: ["pipe", "pipe", "pipe"],
188
243
  });
189
244
 
245
+ // Timeout: 10 minutes max for any build
246
+ const buildTimeout = setTimeout(() => {
247
+ log.warn(`Build timed out after 10 minutes: ${buildCmd}`);
248
+ try {
249
+ proc.kill("SIGTERM");
250
+ } catch {}
251
+ setTimeout(() => {
252
+ try {
253
+ proc.kill("SIGKILL");
254
+ } catch {}
255
+ }, 5000);
256
+ reject(new Error(`Build timed out after 10 minutes`));
257
+ }, 600000);
258
+
190
259
  proc.stdout.on("data", (data) => {
191
260
  const text = data.toString();
192
261
  for (const line of text.split("\n")) {
@@ -209,7 +278,13 @@ export async function runBuild(projectPath, platform, onOutput) {
209
278
  }
210
279
  });
211
280
 
281
+ proc.on("error", (err) => {
282
+ clearTimeout(buildTimeout);
283
+ reject(new Error(`Build process error: ${err.message}`));
284
+ });
285
+
212
286
  proc.on("close", (code) => {
287
+ clearTimeout(buildTimeout);
213
288
  const duration = Math.round((Date.now() - startTime) / 1000);
214
289
  const outputPath =
215
290
  projectInfo.outputPaths[platform] ||
@@ -307,11 +382,59 @@ export async function deployToAppDistribution(
307
382
  buildPath,
308
383
  options = {},
309
384
  ) {
310
- const { projectId, groups, testers, releaseNotes } = options;
385
+ const { projectId, appId, groups, testers, releaseNotes } = options;
311
386
 
312
387
  assertCliAvailable("firebase");
313
388
 
314
- let cmd = `firebase appdistribution:distribute "${buildPath}"`;
389
+ // Resolve glob patterns (e.g., build/ios/ipa/*.ipa → actual filename)
390
+ const resolvedPath = resolveGlobPath(projectPath, buildPath);
391
+ if (!existsSync(resolvedPath)) {
392
+ throw new Error(`Build artifact not found: ${resolvedPath}`);
393
+ }
394
+ log.info(`Resolved build path: ${resolvedPath}`);
395
+
396
+ // Auto-detect Firebase App ID if not provided
397
+ let detectedAppId = appId;
398
+ if (!detectedAppId) {
399
+ // Try GoogleService-Info.plist (iOS)
400
+ if (resolvedPath.endsWith(".ipa")) {
401
+ const plistPath = path.join(
402
+ projectPath,
403
+ "ios/Runner/GoogleService-Info.plist",
404
+ );
405
+ if (existsSync(plistPath)) {
406
+ try {
407
+ const plist = readFileSync(plistPath, "utf-8");
408
+ const match = plist.match(
409
+ /<key>GOOGLE_APP_ID<\/key>\s*<string>([^<]+)<\/string>/,
410
+ );
411
+ if (match) detectedAppId = match[1];
412
+ } catch {}
413
+ }
414
+ }
415
+ // Try google-services.json (Android)
416
+ if (resolvedPath.endsWith(".apk") || resolvedPath.endsWith(".aab")) {
417
+ const jsonPath = path.join(
418
+ projectPath,
419
+ "android/app/google-services.json",
420
+ );
421
+ if (existsSync(jsonPath)) {
422
+ try {
423
+ const gs = JSON.parse(readFileSync(jsonPath, "utf-8"));
424
+ detectedAppId = gs.client?.[0]?.client_info?.mobilesdk_app_id;
425
+ } catch {}
426
+ }
427
+ }
428
+ }
429
+
430
+ if (!detectedAppId) {
431
+ throw new Error(
432
+ "Firebase App ID not found. Add GoogleService-Info.plist (iOS) or google-services.json (Android) to the project, or pass appId in the command payload.",
433
+ );
434
+ }
435
+
436
+ log.info(`Using Firebase App ID: ${detectedAppId}`);
437
+ let cmd = `firebase appdistribution:distribute "${resolvedPath}" --app "${detectedAppId}"`;
315
438
 
316
439
  if (projectId) cmd += ` --project ${projectId}`;
317
440
  if (groups) cmd += ` --groups "${groups}"`;
@@ -321,17 +444,52 @@ export async function deployToAppDistribution(
321
444
  cmd += ` --release-notes "${escaped}"`;
322
445
  }
323
446
 
324
- log.info(`Distributing build via App Distribution: ${buildPath}`);
447
+ // Auto-add the device owner as a tester if no testers/groups specified
448
+ if (!groups && !testers) {
449
+ // Try to get the Firebase auth email
450
+ try {
451
+ const authEmail = execSync("firebase login:list 2>/dev/null", {
452
+ timeout: 5000,
453
+ encoding: "utf-8",
454
+ });
455
+ const emailMatch = authEmail.match(/[\w.-]+@[\w.-]+\.\w+/);
456
+ if (emailMatch) {
457
+ cmd += ` --testers "${emailMatch[0]}"`;
458
+ log.info(`Auto-adding tester: ${emailMatch[0]}`);
459
+ }
460
+ } catch {}
461
+ }
462
+
463
+ log.info(`Distributing build via App Distribution: ${resolvedPath}`);
325
464
 
326
465
  const result = execSync(cmd, {
327
466
  cwd: projectPath,
328
- timeout: 300_000, // 5 min for large APKs/IPAs
467
+ timeout: 300_000,
329
468
  encoding: "utf-8",
330
469
  env: { ...process.env },
331
470
  });
332
471
 
472
+ // Try to extract the testing URI from the output
473
+ // Firebase CLI outputs: "View this release in the Firebase console: <url>"
474
+ const consoleUrlMatch = result.match(
475
+ /(?:View this release.*?|Firebase console.*?)(https:\/\/console\.firebase\.google\.com[^\s]+)/i,
476
+ );
477
+ // Also look for the tester download link
478
+ const testerUrlMatch = result.match(
479
+ /(https:\/\/appdistribution\.firebase\.google\.com[^\s]+)/i,
480
+ );
481
+ // Firebase App Tester — the user needs to open the App Tester app
482
+ // on their device to download. The console URL lets them manage releases.
483
+ const appTesterUrl = "https://appdistribution.firebase.google.com/testerapps";
484
+
333
485
  log.info("App Distribution upload complete");
334
- return { output: result };
486
+ return {
487
+ output: result,
488
+ consoleUrl: consoleUrlMatch?.[1] || null,
489
+ testerUrl: testerUrlMatch?.[1] || null,
490
+ appTesterUrl,
491
+ appId: detectedAppId,
492
+ };
335
493
  }
336
494
 
337
495
  // ---------------------------------------------------------------------------
@@ -447,13 +447,22 @@ function watchSessionCommands(sessionId) {
447
447
  activeSessions.set(`cmd-watcher-${sessionId}`, true);
448
448
 
449
449
  const db = getDb();
450
+ console.log(
451
+ `[DEBUG] watchSessionCommands: watching session ${sessionId.slice(0, 8)}`,
452
+ );
450
453
  db.collection("sessions")
451
454
  .doc(sessionId)
452
455
  .collection("commands")
453
456
  .where("status", "==", "pending")
454
457
  .onSnapshot((snap) => {
458
+ console.log(
459
+ `[DEBUG] commands snapshot for ${sessionId.slice(0, 8)}: ${snap.docChanges().length} changes`,
460
+ );
455
461
  for (const change of snap.docChanges()) {
456
462
  if (change.type === "added") {
463
+ console.log(
464
+ `[DEBUG] pending command: ${change.doc.data().type} for ${sessionId.slice(0, 8)}`,
465
+ );
457
466
  handleSessionCommand(sessionId, change.doc);
458
467
  }
459
468
  }
@@ -492,6 +501,10 @@ async function handleSessionCommand(sessionId, commandDoc) {
492
501
  const data = commandDoc.data();
493
502
  if (data.status !== "pending") return;
494
503
 
504
+ console.log(
505
+ `[DEBUG] handleSessionCommand: type=${data.type} session=${sessionId.slice(0, 8)} cmdId=${commandDoc.id.slice(0, 8)}`,
506
+ );
507
+
495
508
  const db = getDb();
496
509
  const cmdRef = db
497
510
  .collection("sessions")
@@ -533,6 +546,21 @@ async function handleSessionCommand(sessionId, commandDoc) {
533
546
 
534
547
  await cmdRef.update({ status: "processing" });
535
548
 
549
+ // Helper: get session info from activeSessions or fall back to Firestore.
550
+ // Needed for build/deploy/git commands that can run on non-relay sessions.
551
+ async function getSessionInfo() {
552
+ const local = activeSessions.get(sessionId);
553
+ if (local) return local;
554
+ const doc = await db.collection("sessions").doc(sessionId).get();
555
+ if (!doc.exists) throw new Error("Session not found");
556
+ const d = doc.data();
557
+ return {
558
+ projectPath: d.projectPath,
559
+ projectName: d.projectName,
560
+ desktopId: d.desktopId,
561
+ };
562
+ }
563
+
536
564
  try {
537
565
  switch (data.type) {
538
566
  case "send_prompt":
@@ -689,9 +717,15 @@ async function handleSessionCommand(sessionId, commandDoc) {
689
717
 
690
718
  case "detect_project": {
691
719
  log.command("detect_project", sessionId.slice(0, 8));
692
- const sess = activeSessions.get(sessionId);
693
- if (!sess) throw new Error("Session not found");
720
+ const sess = await getSessionInfo();
721
+ console.log(
722
+ `[DEBUG] detect_project: projectPath="${sess.projectPath}"`,
723
+ );
694
724
  const projectInfo = detectProjectType(sess.projectPath);
725
+ console.log(
726
+ `[DEBUG] detect_project: result=`,
727
+ JSON.stringify(projectInfo),
728
+ );
695
729
  await db
696
730
  .collection("sessions")
697
731
  .doc(sessionId)
@@ -707,8 +741,7 @@ async function handleSessionCommand(sessionId, commandDoc) {
707
741
  case "build_project": {
708
742
  const platform = data.payload?.platform || "web";
709
743
  log.command("build_project", `${sessionId.slice(0, 8)} [${platform}]`);
710
- const sess = activeSessions.get(sessionId);
711
- if (!sess) throw new Error("Session not found");
744
+ const sess = await getSessionInfo();
712
745
 
713
746
  // Create a build record with status: building
714
747
  const buildRef = db
@@ -727,7 +760,10 @@ async function handleSessionCommand(sessionId, commandDoc) {
727
760
  const result = await runBuild(
728
761
  sess.projectPath,
729
762
  platform,
730
- (line, _stream) => {
763
+ (line, stream) => {
764
+ // Log build output to relay console
765
+ const prefix = stream === "stderr" ? "⚠" : "▸";
766
+ console.log(` ${prefix} [build] ${line}`);
731
767
  // Stream latest output line to Firestore (best-effort)
732
768
  buildRef
733
769
  .update({
@@ -767,8 +803,7 @@ async function handleSessionCommand(sessionId, commandDoc) {
767
803
 
768
804
  case "deploy_hosting": {
769
805
  log.command("deploy_hosting", sessionId.slice(0, 8));
770
- const sess = activeSessions.get(sessionId);
771
- if (!sess) throw new Error("Session not found");
806
+ const sess = await getSessionInfo();
772
807
 
773
808
  const deployRef = db
774
809
  .collection("sessions")
@@ -821,8 +856,7 @@ async function handleSessionCommand(sessionId, commandDoc) {
821
856
 
822
857
  case "deploy_app_dist": {
823
858
  log.command("deploy_app_dist", sessionId.slice(0, 8));
824
- const sess = activeSessions.get(sessionId);
825
- if (!sess) throw new Error("Session not found");
859
+ const sess = await getSessionInfo();
826
860
 
827
861
  const deployRef = db
828
862
  .collection("sessions")
@@ -844,15 +878,23 @@ async function handleSessionCommand(sessionId, commandDoc) {
844
878
  const testers = data.payload?.testers;
845
879
  const releaseNotes = data.payload?.releaseNotes;
846
880
 
847
- await deployToAppDistribution(sess.projectPath, buildPath, {
848
- projectId,
849
- groups,
850
- testers,
851
- releaseNotes,
852
- });
881
+ const distResult = await deployToAppDistribution(
882
+ sess.projectPath,
883
+ buildPath,
884
+ {
885
+ projectId,
886
+ groups,
887
+ testers,
888
+ releaseNotes,
889
+ },
890
+ );
853
891
 
854
892
  await deployRef.update({
855
893
  status: "success",
894
+ consoleUrl: distResult.consoleUrl || null,
895
+ testerUrl: distResult.testerUrl || null,
896
+ appTesterUrl: distResult.appTesterUrl || null,
897
+ appId: distResult.appId || null,
856
898
  completedAt: FieldValue.serverTimestamp(),
857
899
  });
858
900
 
@@ -930,8 +972,7 @@ async function handleSessionCommand(sessionId, commandDoc) {
930
972
 
931
973
  case "check_deploy_ready": {
932
974
  log.command("check_deploy_ready", sessionId.slice(0, 8));
933
- const sess = activeSessions.get(sessionId);
934
- if (!sess) throw new Error("Session not found");
975
+ const sess = await getSessionInfo();
935
976
  const report = checkDeployReadiness(sess.projectPath);
936
977
  await db
937
978
  .collection("sessions")
@@ -962,8 +1003,7 @@ async function handleSessionCommand(sessionId, commandDoc) {
962
1003
 
963
1004
  case "init_firebase_hosting": {
964
1005
  log.command("init_firebase_hosting", sessionId.slice(0, 8));
965
- const sess = activeSessions.get(sessionId);
966
- if (!sess) throw new Error("Session not found");
1006
+ const sess = await getSessionInfo();
967
1007
  const projectId = data.payload?.projectId;
968
1008
  const publicDir = data.payload?.publicDir || "build/web";
969
1009
  if (!projectId) throw new Error("projectId is required");