forge-remote 0.1.28 → 0.1.30

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,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-remote",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "Desktop relay for Forge Remote — monitor and control Claude Code sessions from your phone",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,744 @@
1
+ // Forge Remote Relay — Build & Deploy Manager
2
+ // Copyright (c) 2025-2026 Iron Forge Apps
3
+ // Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
4
+
5
+ import { execSync, spawn } from "child_process";
6
+ import path from "node:path";
7
+ import {
8
+ existsSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ readdirSync,
12
+ statSync,
13
+ } from "node:fs";
14
+ import * as log from "./logger.js";
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
+
53
+ // ---------------------------------------------------------------------------
54
+ // Project Detection
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Detect project type from the project directory.
59
+ * Returns: { type, framework, buildCommands, outputPaths }
60
+ */
61
+ export function detectProjectType(projectPath) {
62
+ // Check for Flutter / Dart
63
+ const pubspecPath = path.join(projectPath, "pubspec.yaml");
64
+ if (existsSync(pubspecPath)) {
65
+ const pubspec = readFileSync(pubspecPath, "utf-8");
66
+ if (pubspec.includes("flutter:")) {
67
+ return {
68
+ type: "mobile",
69
+ framework: "flutter",
70
+ buildCommands: {
71
+ android: "flutter build apk --release",
72
+ ios: "flutter build ipa --release",
73
+ web: "flutter build web --release",
74
+ },
75
+ outputPaths: {
76
+ android: "build/app/outputs/flutter-apk/app-release.apk",
77
+ ios: "build/ios/ipa/*.ipa",
78
+ web: "build/web",
79
+ },
80
+ };
81
+ }
82
+ return {
83
+ type: "dart",
84
+ framework: "dart",
85
+ buildCommands: { default: "dart compile exe" },
86
+ outputPaths: {},
87
+ };
88
+ }
89
+
90
+ // Check for package.json (Node / React / Next / Vue / etc.)
91
+ const pkgPath = path.join(projectPath, "package.json");
92
+ if (existsSync(pkgPath)) {
93
+ let pkg;
94
+ try {
95
+ pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
96
+ } catch {
97
+ return {
98
+ type: "web",
99
+ framework: "node",
100
+ buildCommands: { web: "npm run build" },
101
+ outputPaths: { web: "dist" },
102
+ };
103
+ }
104
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
105
+
106
+ if (deps["next"]) {
107
+ return {
108
+ type: "web",
109
+ framework: "nextjs",
110
+ buildCommands: { web: "npm run build" },
111
+ outputPaths: { web: ".next" },
112
+ };
113
+ }
114
+ if (deps["react-scripts"] || deps["react"]) {
115
+ return {
116
+ type: "web",
117
+ framework: "react",
118
+ buildCommands: { web: "npm run build" },
119
+ outputPaths: { web: "build" },
120
+ };
121
+ }
122
+ if (deps["vue"]) {
123
+ return {
124
+ type: "web",
125
+ framework: "vue",
126
+ buildCommands: { web: "npm run build" },
127
+ outputPaths: { web: "dist" },
128
+ };
129
+ }
130
+ if (deps["@angular/core"]) {
131
+ return {
132
+ type: "web",
133
+ framework: "angular",
134
+ buildCommands: { web: "npm run build" },
135
+ outputPaths: { web: "dist" },
136
+ };
137
+ }
138
+ if (deps["svelte"] || deps["@sveltejs/kit"]) {
139
+ return {
140
+ type: "web",
141
+ framework: "svelte",
142
+ buildCommands: { web: "npm run build" },
143
+ outputPaths: { web: "build" },
144
+ };
145
+ }
146
+ // Generic Node project
147
+ return {
148
+ type: "web",
149
+ framework: "node",
150
+ buildCommands: { web: "npm run build" },
151
+ outputPaths: { web: "dist" },
152
+ };
153
+ }
154
+
155
+ // Check for Cargo.toml (Rust)
156
+ if (existsSync(path.join(projectPath, "Cargo.toml"))) {
157
+ return {
158
+ type: "binary",
159
+ framework: "rust",
160
+ buildCommands: { default: "cargo build --release" },
161
+ outputPaths: { default: "target/release" },
162
+ };
163
+ }
164
+
165
+ // Check for go.mod (Go)
166
+ if (existsSync(path.join(projectPath, "go.mod"))) {
167
+ return {
168
+ type: "binary",
169
+ framework: "go",
170
+ buildCommands: { default: "go build -o ./build/" },
171
+ outputPaths: { default: "build" },
172
+ };
173
+ }
174
+
175
+ // Check for index.html (static site)
176
+ if (existsSync(path.join(projectPath, "index.html"))) {
177
+ return {
178
+ type: "web",
179
+ framework: "static",
180
+ buildCommands: {},
181
+ outputPaths: { web: "." },
182
+ };
183
+ }
184
+
185
+ return {
186
+ type: "unknown",
187
+ framework: "unknown",
188
+ buildCommands: {},
189
+ outputPaths: {},
190
+ };
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Build Runner
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Run a build command and stream output.
199
+ * @param {string} projectPath — absolute path to the project root
200
+ * @param {string} platform — "web", "android", "ios", or "default"
201
+ * @param {(line: string, stream: "stdout"|"stderr") => void} [onOutput] — callback for each output line
202
+ * @returns {Promise<{ success: boolean, output: string, duration: number, outputPath: string|null, framework: string, platform: string }>}
203
+ */
204
+ export async function runBuild(projectPath, platform, onOutput) {
205
+ const projectInfo = detectProjectType(projectPath);
206
+ const buildCmd =
207
+ projectInfo.buildCommands[platform] ||
208
+ projectInfo.buildCommands.web ||
209
+ projectInfo.buildCommands.default;
210
+
211
+ if (!buildCmd) {
212
+ throw new Error(
213
+ `No build command for platform "${platform}" in ${projectInfo.framework} project`,
214
+ );
215
+ }
216
+
217
+ log.info(
218
+ `Starting ${projectInfo.framework} build for platform "${platform}": ${buildCmd}`,
219
+ );
220
+
221
+ const startTime = Date.now();
222
+ const outputLines = [];
223
+
224
+ return new Promise((resolve, reject) => {
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], {
240
+ cwd: projectPath,
241
+ env: buildEnv,
242
+ stdio: ["pipe", "pipe", "pipe"],
243
+ });
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
+
259
+ proc.stdout.on("data", (data) => {
260
+ const text = data.toString();
261
+ for (const line of text.split("\n")) {
262
+ const trimmed = line.trim();
263
+ if (trimmed) {
264
+ outputLines.push(trimmed);
265
+ if (onOutput) onOutput(trimmed, "stdout");
266
+ }
267
+ }
268
+ });
269
+
270
+ proc.stderr.on("data", (data) => {
271
+ const text = data.toString();
272
+ for (const line of text.split("\n")) {
273
+ const trimmed = line.trim();
274
+ if (trimmed) {
275
+ outputLines.push(trimmed);
276
+ if (onOutput) onOutput(trimmed, "stderr");
277
+ }
278
+ }
279
+ });
280
+
281
+ proc.on("error", (err) => {
282
+ clearTimeout(buildTimeout);
283
+ reject(new Error(`Build process error: ${err.message}`));
284
+ });
285
+
286
+ proc.on("close", (code) => {
287
+ clearTimeout(buildTimeout);
288
+ const duration = Math.round((Date.now() - startTime) / 1000);
289
+ const outputPath =
290
+ projectInfo.outputPaths[platform] ||
291
+ projectInfo.outputPaths.web ||
292
+ projectInfo.outputPaths.default ||
293
+ null;
294
+
295
+ if (code === 0) {
296
+ log.info(`Build succeeded in ${duration}s`);
297
+ resolve({
298
+ success: true,
299
+ output: outputLines.join("\n"),
300
+ duration,
301
+ outputPath: outputPath ? path.resolve(projectPath, outputPath) : null,
302
+ framework: projectInfo.framework,
303
+ platform,
304
+ });
305
+ } else {
306
+ const tail = outputLines.slice(-15).join("\n");
307
+ log.error(`Build failed (exit code ${code})`);
308
+ reject(new Error(`Build failed (exit code ${code})\n${tail}`));
309
+ }
310
+ });
311
+
312
+ proc.on("error", (err) => {
313
+ log.error(`Build process error: ${err.message}`);
314
+ reject(new Error(`Build process error: ${err.message}`));
315
+ });
316
+ });
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // Firebase Hosting Deploy
321
+ // ---------------------------------------------------------------------------
322
+
323
+ /**
324
+ * Deploy to Firebase Hosting.
325
+ * @param {string} projectPath — project root (must contain firebase.json)
326
+ * @param {string} [outputDir] — build output directory (unused if firebase.json is configured)
327
+ * @param {{ projectId?: string, channelId?: string }} [options]
328
+ * @returns {Promise<{ url: string|null, output: string, projectId?: string }>}
329
+ */
330
+ export async function deployToFirebaseHosting(
331
+ projectPath,
332
+ outputDir,
333
+ options = {},
334
+ ) {
335
+ const { projectId, channelId } = options;
336
+
337
+ assertCliAvailable("firebase");
338
+
339
+ // Preview channel deploy vs. live deploy
340
+ let cmd;
341
+ if (channelId) {
342
+ cmd = `firebase hosting:channel:deploy ${channelId} --only hosting`;
343
+ } else {
344
+ cmd = `firebase deploy --only hosting`;
345
+ }
346
+
347
+ if (projectId) {
348
+ cmd += ` --project ${projectId}`;
349
+ }
350
+
351
+ log.info(`Deploying to Firebase Hosting: ${cmd}`);
352
+
353
+ const result = execSync(cmd, {
354
+ cwd: projectPath,
355
+ timeout: 120_000,
356
+ encoding: "utf-8",
357
+ env: { ...process.env },
358
+ });
359
+
360
+ // Parse the hosting URL from output
361
+ const channelUrlMatch = result.match(/Channel URL: (https?:\/\/[^\s]+)/);
362
+ const urlMatch = result.match(/Hosting URL: (https?:\/\/[^\s]+)/);
363
+ const url = channelUrlMatch?.[1] || urlMatch?.[1] || null;
364
+
365
+ log.info(`Firebase Hosting deployed${url ? `: ${url}` : ""}`);
366
+ return { url, output: result, projectId };
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Firebase App Distribution
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /**
374
+ * Deploy to Firebase App Distribution.
375
+ * @param {string} projectPath — project root
376
+ * @param {string} buildPath — path to the APK/IPA file
377
+ * @param {{ projectId?: string, groups?: string, testers?: string, releaseNotes?: string }} [options]
378
+ * @returns {Promise<{ output: string }>}
379
+ */
380
+ export async function deployToAppDistribution(
381
+ projectPath,
382
+ buildPath,
383
+ options = {},
384
+ ) {
385
+ const { projectId, appId, groups, testers, releaseNotes } = options;
386
+
387
+ assertCliAvailable("firebase");
388
+
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}"`;
438
+
439
+ if (projectId) cmd += ` --project ${projectId}`;
440
+ if (groups) cmd += ` --groups "${groups}"`;
441
+ if (testers) cmd += ` --testers "${testers}"`;
442
+ if (releaseNotes) {
443
+ const escaped = releaseNotes.replace(/"/g, '\\"');
444
+ cmd += ` --release-notes "${escaped}"`;
445
+ }
446
+
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}`);
464
+
465
+ const result = execSync(cmd, {
466
+ cwd: projectPath,
467
+ timeout: 300_000,
468
+ encoding: "utf-8",
469
+ env: { ...process.env },
470
+ });
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
+
485
+ log.info("App Distribution upload complete");
486
+ return {
487
+ output: result,
488
+ consoleUrl: consoleUrlMatch?.[1] || null,
489
+ testerUrl: testerUrlMatch?.[1] || null,
490
+ appTesterUrl,
491
+ appId: detectedAppId,
492
+ };
493
+ }
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // ADB (Android Debug Bridge) Helpers
497
+ // ---------------------------------------------------------------------------
498
+
499
+ /**
500
+ * Check if ADB wireless debugging is available and a device is connected.
501
+ * @returns {{ connected: boolean, deviceId: string|null, deviceName: string|null }}
502
+ */
503
+ export function checkAdbDevice() {
504
+ try {
505
+ const result = execSync("adb devices -l", {
506
+ timeout: 5_000,
507
+ encoding: "utf-8",
508
+ });
509
+ const lines = result
510
+ .split("\n")
511
+ .filter((l) => l.includes("device") && !l.startsWith("List"));
512
+ if (lines.length > 0) {
513
+ const parts = lines[0].split(/\s+/);
514
+ const deviceId = parts[0];
515
+ const modelMatch = lines[0].match(/model:(\S+)/);
516
+ return {
517
+ connected: true,
518
+ deviceId,
519
+ deviceName: modelMatch?.[1] || deviceId,
520
+ };
521
+ }
522
+ } catch {
523
+ /* ADB not installed or timed out */
524
+ }
525
+ return { connected: false, deviceId: null, deviceName: null };
526
+ }
527
+
528
+ /**
529
+ * Install APK directly to a connected Android device via ADB.
530
+ * @param {string} apkPath — path to the .apk file
531
+ * @param {string} [deviceId] — specific device serial (optional)
532
+ * @returns {{ output: string }}
533
+ */
534
+ export function installViaAdb(apkPath, deviceId) {
535
+ assertCliAvailable("adb");
536
+
537
+ const cmd = deviceId
538
+ ? `adb -s ${deviceId} install -r "${apkPath}"`
539
+ : `adb install -r "${apkPath}"`;
540
+
541
+ log.info(`Installing APK via ADB: ${apkPath}`);
542
+
543
+ const result = execSync(cmd, {
544
+ timeout: 120_000,
545
+ encoding: "utf-8",
546
+ });
547
+
548
+ log.info("ADB install complete");
549
+ return { output: result };
550
+ }
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // Helpers
554
+ // ---------------------------------------------------------------------------
555
+
556
+ /**
557
+ * Assert that a CLI tool is available on the PATH.
558
+ * Throws a user-friendly error if not found.
559
+ */
560
+ // ---------------------------------------------------------------------------
561
+ // Deploy Readiness Check & Setup
562
+ // ---------------------------------------------------------------------------
563
+
564
+ /**
565
+ * Check if Firebase CLI is installed and authenticated.
566
+ * Check if the project has firebase.json and .firebaserc.
567
+ * Returns a readiness report.
568
+ */
569
+ export function checkDeployReadiness(projectPath) {
570
+ const report = {
571
+ firebaseCli: { installed: false, version: null },
572
+ firebaseAuth: { loggedIn: false, account: null },
573
+ firebaseConfig: {
574
+ hasFirebaseJson: false,
575
+ hasFirebaserc: false,
576
+ projectId: null,
577
+ },
578
+ hosting: { configured: false, publicDir: null },
579
+ appDistribution: { available: false },
580
+ issues: [],
581
+ ready: false,
582
+ };
583
+
584
+ // Check Firebase CLI
585
+ try {
586
+ const version = execSync("firebase --version", {
587
+ timeout: 5000,
588
+ encoding: "utf-8",
589
+ }).trim();
590
+ report.firebaseCli = { installed: true, version };
591
+ } catch {
592
+ report.issues.push({
593
+ code: "NO_CLI",
594
+ message: "Firebase CLI not installed",
595
+ fix: "npm install -g firebase-tools",
596
+ });
597
+ }
598
+
599
+ // Check Firebase auth
600
+ if (report.firebaseCli.installed) {
601
+ try {
602
+ const result = execSync("firebase login:list", {
603
+ timeout: 10000,
604
+ encoding: "utf-8",
605
+ });
606
+ const accounts = result.match(/[\w.-]+@[\w.-]+/g);
607
+ if (accounts && accounts.length > 0) {
608
+ report.firebaseAuth = { loggedIn: true, account: accounts[0] };
609
+ } else {
610
+ report.issues.push({
611
+ code: "NOT_LOGGED_IN",
612
+ message: "Not logged in to Firebase",
613
+ fix: "firebase login",
614
+ });
615
+ }
616
+ } catch {
617
+ report.issues.push({
618
+ code: "NOT_LOGGED_IN",
619
+ message: "Not logged in to Firebase",
620
+ fix: "firebase login",
621
+ });
622
+ }
623
+ }
624
+
625
+ // Check firebase.json
626
+ const firebaseJsonPath = path.join(projectPath, "firebase.json");
627
+ if (existsSync(firebaseJsonPath)) {
628
+ report.firebaseConfig.hasFirebaseJson = true;
629
+ try {
630
+ const config = JSON.parse(readFileSync(firebaseJsonPath, "utf-8"));
631
+ if (config.hosting) {
632
+ report.hosting = {
633
+ configured: true,
634
+ publicDir: config.hosting.public || config.hosting[0]?.public || null,
635
+ };
636
+ }
637
+ } catch {
638
+ /* malformed firebase.json */
639
+ }
640
+ } else {
641
+ report.issues.push({
642
+ code: "NO_FIREBASE_JSON",
643
+ message: "No firebase.json found in project",
644
+ fix: "Will be created during setup",
645
+ });
646
+ }
647
+
648
+ // Check .firebaserc
649
+ const firebasercPath = path.join(projectPath, ".firebaserc");
650
+ if (existsSync(firebasercPath)) {
651
+ report.firebaseConfig.hasFirebaserc = true;
652
+ try {
653
+ const rc = JSON.parse(readFileSync(firebasercPath, "utf-8"));
654
+ report.firebaseConfig.projectId = rc.projects?.default || null;
655
+ } catch {
656
+ /* malformed .firebaserc */
657
+ }
658
+ }
659
+
660
+ report.ready =
661
+ report.firebaseCli.installed &&
662
+ report.firebaseAuth.loggedIn &&
663
+ report.firebaseConfig.hasFirebaseJson &&
664
+ report.hosting.configured;
665
+
666
+ return report;
667
+ }
668
+
669
+ /**
670
+ * List Firebase projects available to the user.
671
+ */
672
+ export function listFirebaseProjects() {
673
+ try {
674
+ const result = execSync("firebase projects:list --json", {
675
+ timeout: 30000,
676
+ encoding: "utf-8",
677
+ });
678
+ const data = JSON.parse(result);
679
+ if (data.status === "success" && Array.isArray(data.result)) {
680
+ return data.result.map((p) => ({
681
+ projectId: p.projectId,
682
+ displayName: p.displayName,
683
+ projectNumber: p.projectNumber,
684
+ }));
685
+ }
686
+ } catch {
687
+ /* firebase CLI not available or not logged in */
688
+ }
689
+ return [];
690
+ }
691
+
692
+ /**
693
+ * Initialize Firebase Hosting for a project.
694
+ * Creates firebase.json and .firebaserc.
695
+ */
696
+ export function initFirebaseHosting(projectPath, projectId, publicDir) {
697
+ // Write .firebaserc
698
+ const firebasercPath = path.join(projectPath, ".firebaserc");
699
+ const rc = { projects: { default: projectId } };
700
+ writeFileSync(firebasercPath, JSON.stringify(rc, null, 2));
701
+
702
+ // Write firebase.json
703
+ const firebaseJsonPath = path.join(projectPath, "firebase.json");
704
+ const config = {
705
+ hosting: {
706
+ public: publicDir,
707
+ ignore: ["firebase.json", "**/.*", "**/node_modules/**"],
708
+ rewrites: [{ source: "**", destination: "/index.html" }],
709
+ },
710
+ };
711
+ writeFileSync(firebaseJsonPath, JSON.stringify(config, null, 2));
712
+
713
+ return { firebaseJsonPath, firebasercPath };
714
+ }
715
+
716
+ /**
717
+ * Run firebase login in the background (opens browser on desktop).
718
+ */
719
+ export function startFirebaseLogin() {
720
+ const proc = spawn("firebase", ["login", "--no-localhost"], {
721
+ shell: true,
722
+ stdio: ["pipe", "pipe", "pipe"],
723
+ detached: true,
724
+ });
725
+ proc.unref();
726
+ return { pid: proc.pid };
727
+ }
728
+
729
+ // ---------------------------------------------------------------------------
730
+ // Helpers
731
+ // ---------------------------------------------------------------------------
732
+
733
+ function assertCliAvailable(cliName) {
734
+ try {
735
+ execSync(`which ${cliName}`, { stdio: "pipe" });
736
+ } catch {
737
+ const hints = {
738
+ firebase:
739
+ "Firebase CLI not found. Install it: npm install -g firebase-tools",
740
+ adb: "ADB not found. Install Android SDK platform-tools.",
741
+ };
742
+ throw new Error(hints[cliName] || `${cliName} CLI not found on PATH`);
743
+ }
744
+ }
@@ -30,6 +30,18 @@ import {
30
30
  getGitLog,
31
31
  createPullRequest,
32
32
  } from "./git-manager.js";
33
+ import {
34
+ detectProjectType,
35
+ runBuild,
36
+ deployToFirebaseHosting,
37
+ deployToAppDistribution,
38
+ checkAdbDevice,
39
+ installViaAdb,
40
+ checkDeployReadiness,
41
+ listFirebaseProjects,
42
+ initFirebaseHosting,
43
+ startFirebaseLogin,
44
+ } from "./build-manager.js";
33
45
 
34
46
  // ---------------------------------------------------------------------------
35
47
  // Resolve the user's shell environment so spawned processes inherit PATH,
@@ -435,13 +447,22 @@ function watchSessionCommands(sessionId) {
435
447
  activeSessions.set(`cmd-watcher-${sessionId}`, true);
436
448
 
437
449
  const db = getDb();
450
+ console.log(
451
+ `[DEBUG] watchSessionCommands: watching session ${sessionId.slice(0, 8)}`,
452
+ );
438
453
  db.collection("sessions")
439
454
  .doc(sessionId)
440
455
  .collection("commands")
441
456
  .where("status", "==", "pending")
442
457
  .onSnapshot((snap) => {
458
+ console.log(
459
+ `[DEBUG] commands snapshot for ${sessionId.slice(0, 8)}: ${snap.docChanges().length} changes`,
460
+ );
443
461
  for (const change of snap.docChanges()) {
444
462
  if (change.type === "added") {
463
+ console.log(
464
+ `[DEBUG] pending command: ${change.doc.data().type} for ${sessionId.slice(0, 8)}`,
465
+ );
445
466
  handleSessionCommand(sessionId, change.doc);
446
467
  }
447
468
  }
@@ -460,6 +481,16 @@ const VALID_SESSION_COMMANDS = new Set([
460
481
  "git_commit",
461
482
  "git_push",
462
483
  "create_pr",
484
+ "detect_project",
485
+ "build_project",
486
+ "deploy_hosting",
487
+ "deploy_app_dist",
488
+ "check_adb",
489
+ "install_adb",
490
+ "check_deploy_ready",
491
+ "list_firebase_projects",
492
+ "init_firebase_hosting",
493
+ "start_firebase_login",
463
494
  ]);
464
495
 
465
496
  async function handleSessionCommand(sessionId, commandDoc) {
@@ -470,6 +501,10 @@ async function handleSessionCommand(sessionId, commandDoc) {
470
501
  const data = commandDoc.data();
471
502
  if (data.status !== "pending") return;
472
503
 
504
+ console.log(
505
+ `[DEBUG] handleSessionCommand: type=${data.type} session=${sessionId.slice(0, 8)} cmdId=${commandDoc.id.slice(0, 8)}`,
506
+ );
507
+
473
508
  const db = getDb();
474
509
  const cmdRef = db
475
510
  .collection("sessions")
@@ -511,6 +546,21 @@ async function handleSessionCommand(sessionId, commandDoc) {
511
546
 
512
547
  await cmdRef.update({ status: "processing" });
513
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
+
514
564
  try {
515
565
  switch (data.type) {
516
566
  case "send_prompt":
@@ -660,6 +710,335 @@ async function handleSessionCommand(sessionId, commandDoc) {
660
710
  await cmdRef.update({ result: prResult });
661
711
  break;
662
712
  }
713
+
714
+ // ---------------------------------------------------------------
715
+ // Build & Deploy commands
716
+ // ---------------------------------------------------------------
717
+
718
+ case "detect_project": {
719
+ log.command("detect_project", sessionId.slice(0, 8));
720
+ const sess = await getSessionInfo();
721
+ console.log(
722
+ `[DEBUG] detect_project: projectPath="${sess.projectPath}"`,
723
+ );
724
+ const projectInfo = detectProjectType(sess.projectPath);
725
+ console.log(
726
+ `[DEBUG] detect_project: result=`,
727
+ JSON.stringify(projectInfo),
728
+ );
729
+ await db
730
+ .collection("sessions")
731
+ .doc(sessionId)
732
+ .collection("buildData")
733
+ .doc("projectInfo")
734
+ .set({
735
+ ...projectInfo,
736
+ detectedAt: FieldValue.serverTimestamp(),
737
+ });
738
+ break;
739
+ }
740
+
741
+ case "build_project": {
742
+ const platform = data.payload?.platform || "web";
743
+ log.command("build_project", `${sessionId.slice(0, 8)} [${platform}]`);
744
+ const sess = await getSessionInfo();
745
+
746
+ // Create a build record with status: building
747
+ const buildRef = db
748
+ .collection("sessions")
749
+ .doc(sessionId)
750
+ .collection("builds")
751
+ .doc();
752
+ await buildRef.set({
753
+ platform,
754
+ status: "building",
755
+ startedAt: FieldValue.serverTimestamp(),
756
+ projectPath: sess.projectPath,
757
+ });
758
+
759
+ try {
760
+ const result = await runBuild(
761
+ sess.projectPath,
762
+ platform,
763
+ (line, stream) => {
764
+ // Log build output to relay console
765
+ const prefix = stream === "stderr" ? "⚠" : "▸";
766
+ console.log(` ${prefix} [build] ${line}`);
767
+ // Stream latest output line to Firestore (best-effort)
768
+ buildRef
769
+ .update({
770
+ lastOutput: line,
771
+ lastActivity: FieldValue.serverTimestamp(),
772
+ })
773
+ .catch(() => {});
774
+ },
775
+ );
776
+
777
+ await buildRef.update({
778
+ status: "success",
779
+ duration: result.duration,
780
+ outputPath: result.outputPath,
781
+ framework: result.framework,
782
+ completedAt: FieldValue.serverTimestamp(),
783
+ });
784
+
785
+ // Notify mobile app
786
+ if (sess.desktopId) {
787
+ notifySessionComplete(sess.desktopId, sessionId, {
788
+ projectName: sess.projectName || "Unknown",
789
+ customTitle: "Build Complete",
790
+ customBody: `${result.framework} ${platform} build succeeded (${result.duration}s)`,
791
+ }).catch(() => {});
792
+ }
793
+ } catch (err) {
794
+ await buildRef.update({
795
+ status: "failed",
796
+ error: err.message,
797
+ completedAt: FieldValue.serverTimestamp(),
798
+ });
799
+ throw err; // Re-throw so the outer handler marks the command as failed
800
+ }
801
+ break;
802
+ }
803
+
804
+ case "deploy_hosting": {
805
+ log.command("deploy_hosting", sessionId.slice(0, 8));
806
+ const sess = await getSessionInfo();
807
+
808
+ const deployRef = db
809
+ .collection("sessions")
810
+ .doc(sessionId)
811
+ .collection("deploys")
812
+ .doc();
813
+ await deployRef.set({
814
+ type: "hosting",
815
+ status: "deploying",
816
+ startedAt: FieldValue.serverTimestamp(),
817
+ });
818
+
819
+ try {
820
+ const channelId = data.payload?.channelId || `preview-${Date.now()}`;
821
+ const projectId = data.payload?.projectId;
822
+ const outputDir = data.payload?.outputDir;
823
+
824
+ const result = await deployToFirebaseHosting(
825
+ sess.projectPath,
826
+ outputDir,
827
+ { projectId, channelId },
828
+ );
829
+
830
+ await deployRef.update({
831
+ status: "success",
832
+ url: result.url,
833
+ channelId,
834
+ completedAt: FieldValue.serverTimestamp(),
835
+ });
836
+
837
+ if (sess.desktopId) {
838
+ notifySessionComplete(sess.desktopId, sessionId, {
839
+ projectName: sess.projectName || "Unknown",
840
+ customTitle: "Deploy Complete",
841
+ customBody: result.url
842
+ ? `Live at: ${result.url}`
843
+ : "Firebase Hosting deployed",
844
+ }).catch(() => {});
845
+ }
846
+ } catch (err) {
847
+ await deployRef.update({
848
+ status: "failed",
849
+ error: err.message,
850
+ completedAt: FieldValue.serverTimestamp(),
851
+ });
852
+ throw err;
853
+ }
854
+ break;
855
+ }
856
+
857
+ case "deploy_app_dist": {
858
+ log.command("deploy_app_dist", sessionId.slice(0, 8));
859
+ const sess = await getSessionInfo();
860
+
861
+ const deployRef = db
862
+ .collection("sessions")
863
+ .doc(sessionId)
864
+ .collection("deploys")
865
+ .doc();
866
+ await deployRef.set({
867
+ type: "app_distribution",
868
+ status: "deploying",
869
+ startedAt: FieldValue.serverTimestamp(),
870
+ });
871
+
872
+ try {
873
+ const buildPath = data.payload?.buildPath;
874
+ if (!buildPath) throw new Error("buildPath is required");
875
+
876
+ const projectId = data.payload?.projectId;
877
+ const groups = data.payload?.groups;
878
+ const testers = data.payload?.testers;
879
+ const releaseNotes = data.payload?.releaseNotes;
880
+
881
+ const distResult = await deployToAppDistribution(
882
+ sess.projectPath,
883
+ buildPath,
884
+ {
885
+ projectId,
886
+ groups,
887
+ testers,
888
+ releaseNotes,
889
+ },
890
+ );
891
+
892
+ await deployRef.update({
893
+ status: "success",
894
+ consoleUrl: distResult.consoleUrl || null,
895
+ testerUrl: distResult.testerUrl || null,
896
+ appTesterUrl: distResult.appTesterUrl || null,
897
+ appId: distResult.appId || null,
898
+ completedAt: FieldValue.serverTimestamp(),
899
+ });
900
+
901
+ if (sess.desktopId) {
902
+ notifySessionComplete(sess.desktopId, sessionId, {
903
+ projectName: sess.projectName || "Unknown",
904
+ customTitle: "App Distributed",
905
+ customBody: "New build available for testers",
906
+ }).catch(() => {});
907
+ }
908
+ } catch (err) {
909
+ await deployRef.update({
910
+ status: "failed",
911
+ error: err.message,
912
+ completedAt: FieldValue.serverTimestamp(),
913
+ });
914
+ throw err;
915
+ }
916
+ break;
917
+ }
918
+
919
+ case "check_adb": {
920
+ log.command("check_adb", sessionId.slice(0, 8));
921
+ const device = checkAdbDevice();
922
+ await db
923
+ .collection("sessions")
924
+ .doc(sessionId)
925
+ .collection("buildData")
926
+ .doc("adbStatus")
927
+ .set({
928
+ ...device,
929
+ checkedAt: FieldValue.serverTimestamp(),
930
+ });
931
+ break;
932
+ }
933
+
934
+ case "install_adb": {
935
+ log.command("install_adb", sessionId.slice(0, 8));
936
+ const apkPath = data.payload?.apkPath;
937
+ const deviceId = data.payload?.deviceId;
938
+ if (!apkPath) throw new Error("apkPath is required");
939
+
940
+ try {
941
+ installViaAdb(apkPath, deviceId);
942
+ await db
943
+ .collection("sessions")
944
+ .doc(sessionId)
945
+ .collection("buildData")
946
+ .doc("adbInstall")
947
+ .set({
948
+ status: "success",
949
+ apkPath,
950
+ installedAt: FieldValue.serverTimestamp(),
951
+ });
952
+ } catch (err) {
953
+ await db
954
+ .collection("sessions")
955
+ .doc(sessionId)
956
+ .collection("buildData")
957
+ .doc("adbInstall")
958
+ .set({
959
+ status: "failed",
960
+ error: err.message,
961
+ apkPath,
962
+ attemptedAt: FieldValue.serverTimestamp(),
963
+ });
964
+ throw err;
965
+ }
966
+ break;
967
+ }
968
+
969
+ // ---------------------------------------------------------------
970
+ // Deploy Readiness & Setup commands
971
+ // ---------------------------------------------------------------
972
+
973
+ case "check_deploy_ready": {
974
+ log.command("check_deploy_ready", sessionId.slice(0, 8));
975
+ const sess = await getSessionInfo();
976
+ const report = checkDeployReadiness(sess.projectPath);
977
+ await db
978
+ .collection("sessions")
979
+ .doc(sessionId)
980
+ .collection("buildData")
981
+ .doc("deployReadiness")
982
+ .set({
983
+ ...report,
984
+ checkedAt: FieldValue.serverTimestamp(),
985
+ });
986
+ break;
987
+ }
988
+
989
+ case "list_firebase_projects": {
990
+ log.command("list_firebase_projects", sessionId.slice(0, 8));
991
+ const projects = listFirebaseProjects();
992
+ await db
993
+ .collection("sessions")
994
+ .doc(sessionId)
995
+ .collection("buildData")
996
+ .doc("firebaseProjects")
997
+ .set({
998
+ projects,
999
+ fetchedAt: FieldValue.serverTimestamp(),
1000
+ });
1001
+ break;
1002
+ }
1003
+
1004
+ case "init_firebase_hosting": {
1005
+ log.command("init_firebase_hosting", sessionId.slice(0, 8));
1006
+ const sess = await getSessionInfo();
1007
+ const projectId = data.payload?.projectId;
1008
+ const publicDir = data.payload?.publicDir || "build/web";
1009
+ if (!projectId) throw new Error("projectId is required");
1010
+
1011
+ initFirebaseHosting(sess.projectPath, projectId, publicDir);
1012
+ await db
1013
+ .collection("sessions")
1014
+ .doc(sessionId)
1015
+ .collection("buildData")
1016
+ .doc("deployReadiness")
1017
+ .update({
1018
+ "firebaseConfig.hasFirebaseJson": true,
1019
+ "firebaseConfig.hasFirebaserc": true,
1020
+ "firebaseConfig.projectId": projectId,
1021
+ "hosting.configured": true,
1022
+ "hosting.publicDir": publicDir,
1023
+ ready: true,
1024
+ });
1025
+ break;
1026
+ }
1027
+
1028
+ case "start_firebase_login": {
1029
+ log.command("start_firebase_login", sessionId.slice(0, 8));
1030
+ startFirebaseLogin();
1031
+ await db
1032
+ .collection("sessions")
1033
+ .doc(sessionId)
1034
+ .collection("buildData")
1035
+ .doc("deployReadiness")
1036
+ .update({
1037
+ loginStarted: true,
1038
+ loginStartedAt: FieldValue.serverTimestamp(),
1039
+ });
1040
+ break;
1041
+ }
663
1042
  }
664
1043
  await cmdRef.update({ status: "completed" });
665
1044
  } catch (e) {