forge-remote 0.1.27 → 0.1.29

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-remote",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
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,586 @@
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 { existsSync, readFileSync, writeFileSync } from "node:fs";
8
+ import * as log from "./logger.js";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Project Detection
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Detect project type from the project directory.
16
+ * Returns: { type, framework, buildCommands, outputPaths }
17
+ */
18
+ export function detectProjectType(projectPath) {
19
+ // Check for Flutter / Dart
20
+ const pubspecPath = path.join(projectPath, "pubspec.yaml");
21
+ if (existsSync(pubspecPath)) {
22
+ const pubspec = readFileSync(pubspecPath, "utf-8");
23
+ if (pubspec.includes("flutter:")) {
24
+ return {
25
+ type: "mobile",
26
+ framework: "flutter",
27
+ buildCommands: {
28
+ android: "flutter build apk --release",
29
+ ios: "flutter build ipa --release",
30
+ web: "flutter build web --release",
31
+ },
32
+ outputPaths: {
33
+ android: "build/app/outputs/flutter-apk/app-release.apk",
34
+ ios: "build/ios/ipa/*.ipa",
35
+ web: "build/web",
36
+ },
37
+ };
38
+ }
39
+ return {
40
+ type: "dart",
41
+ framework: "dart",
42
+ buildCommands: { default: "dart compile exe" },
43
+ outputPaths: {},
44
+ };
45
+ }
46
+
47
+ // Check for package.json (Node / React / Next / Vue / etc.)
48
+ const pkgPath = path.join(projectPath, "package.json");
49
+ if (existsSync(pkgPath)) {
50
+ let pkg;
51
+ try {
52
+ pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
53
+ } catch {
54
+ return {
55
+ type: "web",
56
+ framework: "node",
57
+ buildCommands: { web: "npm run build" },
58
+ outputPaths: { web: "dist" },
59
+ };
60
+ }
61
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
62
+
63
+ if (deps["next"]) {
64
+ return {
65
+ type: "web",
66
+ framework: "nextjs",
67
+ buildCommands: { web: "npm run build" },
68
+ outputPaths: { web: ".next" },
69
+ };
70
+ }
71
+ if (deps["react-scripts"] || deps["react"]) {
72
+ return {
73
+ type: "web",
74
+ framework: "react",
75
+ buildCommands: { web: "npm run build" },
76
+ outputPaths: { web: "build" },
77
+ };
78
+ }
79
+ if (deps["vue"]) {
80
+ return {
81
+ type: "web",
82
+ framework: "vue",
83
+ buildCommands: { web: "npm run build" },
84
+ outputPaths: { web: "dist" },
85
+ };
86
+ }
87
+ if (deps["@angular/core"]) {
88
+ return {
89
+ type: "web",
90
+ framework: "angular",
91
+ buildCommands: { web: "npm run build" },
92
+ outputPaths: { web: "dist" },
93
+ };
94
+ }
95
+ if (deps["svelte"] || deps["@sveltejs/kit"]) {
96
+ return {
97
+ type: "web",
98
+ framework: "svelte",
99
+ buildCommands: { web: "npm run build" },
100
+ outputPaths: { web: "build" },
101
+ };
102
+ }
103
+ // Generic Node project
104
+ return {
105
+ type: "web",
106
+ framework: "node",
107
+ buildCommands: { web: "npm run build" },
108
+ outputPaths: { web: "dist" },
109
+ };
110
+ }
111
+
112
+ // Check for Cargo.toml (Rust)
113
+ if (existsSync(path.join(projectPath, "Cargo.toml"))) {
114
+ return {
115
+ type: "binary",
116
+ framework: "rust",
117
+ buildCommands: { default: "cargo build --release" },
118
+ outputPaths: { default: "target/release" },
119
+ };
120
+ }
121
+
122
+ // Check for go.mod (Go)
123
+ if (existsSync(path.join(projectPath, "go.mod"))) {
124
+ return {
125
+ type: "binary",
126
+ framework: "go",
127
+ buildCommands: { default: "go build -o ./build/" },
128
+ outputPaths: { default: "build" },
129
+ };
130
+ }
131
+
132
+ // Check for index.html (static site)
133
+ if (existsSync(path.join(projectPath, "index.html"))) {
134
+ return {
135
+ type: "web",
136
+ framework: "static",
137
+ buildCommands: {},
138
+ outputPaths: { web: "." },
139
+ };
140
+ }
141
+
142
+ return {
143
+ type: "unknown",
144
+ framework: "unknown",
145
+ buildCommands: {},
146
+ outputPaths: {},
147
+ };
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Build Runner
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /**
155
+ * Run a build command and stream output.
156
+ * @param {string} projectPath — absolute path to the project root
157
+ * @param {string} platform — "web", "android", "ios", or "default"
158
+ * @param {(line: string, stream: "stdout"|"stderr") => void} [onOutput] — callback for each output line
159
+ * @returns {Promise<{ success: boolean, output: string, duration: number, outputPath: string|null, framework: string, platform: string }>}
160
+ */
161
+ export async function runBuild(projectPath, platform, onOutput) {
162
+ const projectInfo = detectProjectType(projectPath);
163
+ const buildCmd =
164
+ projectInfo.buildCommands[platform] ||
165
+ projectInfo.buildCommands.web ||
166
+ projectInfo.buildCommands.default;
167
+
168
+ if (!buildCmd) {
169
+ throw new Error(
170
+ `No build command for platform "${platform}" in ${projectInfo.framework} project`,
171
+ );
172
+ }
173
+
174
+ log.info(
175
+ `Starting ${projectInfo.framework} build for platform "${platform}": ${buildCmd}`,
176
+ );
177
+
178
+ const startTime = Date.now();
179
+ const outputLines = [];
180
+
181
+ return new Promise((resolve, reject) => {
182
+ const [cmd, ...args] = buildCmd.split(" ");
183
+ const proc = spawn(cmd, args, {
184
+ cwd: projectPath,
185
+ shell: true,
186
+ env: { ...process.env },
187
+ stdio: ["pipe", "pipe", "pipe"],
188
+ });
189
+
190
+ proc.stdout.on("data", (data) => {
191
+ const text = data.toString();
192
+ for (const line of text.split("\n")) {
193
+ const trimmed = line.trim();
194
+ if (trimmed) {
195
+ outputLines.push(trimmed);
196
+ if (onOutput) onOutput(trimmed, "stdout");
197
+ }
198
+ }
199
+ });
200
+
201
+ proc.stderr.on("data", (data) => {
202
+ const text = data.toString();
203
+ for (const line of text.split("\n")) {
204
+ const trimmed = line.trim();
205
+ if (trimmed) {
206
+ outputLines.push(trimmed);
207
+ if (onOutput) onOutput(trimmed, "stderr");
208
+ }
209
+ }
210
+ });
211
+
212
+ proc.on("close", (code) => {
213
+ const duration = Math.round((Date.now() - startTime) / 1000);
214
+ const outputPath =
215
+ projectInfo.outputPaths[platform] ||
216
+ projectInfo.outputPaths.web ||
217
+ projectInfo.outputPaths.default ||
218
+ null;
219
+
220
+ if (code === 0) {
221
+ log.info(`Build succeeded in ${duration}s`);
222
+ resolve({
223
+ success: true,
224
+ output: outputLines.join("\n"),
225
+ duration,
226
+ outputPath: outputPath ? path.resolve(projectPath, outputPath) : null,
227
+ framework: projectInfo.framework,
228
+ platform,
229
+ });
230
+ } else {
231
+ const tail = outputLines.slice(-15).join("\n");
232
+ log.error(`Build failed (exit code ${code})`);
233
+ reject(new Error(`Build failed (exit code ${code})\n${tail}`));
234
+ }
235
+ });
236
+
237
+ proc.on("error", (err) => {
238
+ log.error(`Build process error: ${err.message}`);
239
+ reject(new Error(`Build process error: ${err.message}`));
240
+ });
241
+ });
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Firebase Hosting Deploy
246
+ // ---------------------------------------------------------------------------
247
+
248
+ /**
249
+ * Deploy to Firebase Hosting.
250
+ * @param {string} projectPath — project root (must contain firebase.json)
251
+ * @param {string} [outputDir] — build output directory (unused if firebase.json is configured)
252
+ * @param {{ projectId?: string, channelId?: string }} [options]
253
+ * @returns {Promise<{ url: string|null, output: string, projectId?: string }>}
254
+ */
255
+ export async function deployToFirebaseHosting(
256
+ projectPath,
257
+ outputDir,
258
+ options = {},
259
+ ) {
260
+ const { projectId, channelId } = options;
261
+
262
+ assertCliAvailable("firebase");
263
+
264
+ // Preview channel deploy vs. live deploy
265
+ let cmd;
266
+ if (channelId) {
267
+ cmd = `firebase hosting:channel:deploy ${channelId} --only hosting`;
268
+ } else {
269
+ cmd = `firebase deploy --only hosting`;
270
+ }
271
+
272
+ if (projectId) {
273
+ cmd += ` --project ${projectId}`;
274
+ }
275
+
276
+ log.info(`Deploying to Firebase Hosting: ${cmd}`);
277
+
278
+ const result = execSync(cmd, {
279
+ cwd: projectPath,
280
+ timeout: 120_000,
281
+ encoding: "utf-8",
282
+ env: { ...process.env },
283
+ });
284
+
285
+ // Parse the hosting URL from output
286
+ const channelUrlMatch = result.match(/Channel URL: (https?:\/\/[^\s]+)/);
287
+ const urlMatch = result.match(/Hosting URL: (https?:\/\/[^\s]+)/);
288
+ const url = channelUrlMatch?.[1] || urlMatch?.[1] || null;
289
+
290
+ log.info(`Firebase Hosting deployed${url ? `: ${url}` : ""}`);
291
+ return { url, output: result, projectId };
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Firebase App Distribution
296
+ // ---------------------------------------------------------------------------
297
+
298
+ /**
299
+ * Deploy to Firebase App Distribution.
300
+ * @param {string} projectPath — project root
301
+ * @param {string} buildPath — path to the APK/IPA file
302
+ * @param {{ projectId?: string, groups?: string, testers?: string, releaseNotes?: string }} [options]
303
+ * @returns {Promise<{ output: string }>}
304
+ */
305
+ export async function deployToAppDistribution(
306
+ projectPath,
307
+ buildPath,
308
+ options = {},
309
+ ) {
310
+ const { projectId, groups, testers, releaseNotes } = options;
311
+
312
+ assertCliAvailable("firebase");
313
+
314
+ let cmd = `firebase appdistribution:distribute "${buildPath}"`;
315
+
316
+ if (projectId) cmd += ` --project ${projectId}`;
317
+ if (groups) cmd += ` --groups "${groups}"`;
318
+ if (testers) cmd += ` --testers "${testers}"`;
319
+ if (releaseNotes) {
320
+ const escaped = releaseNotes.replace(/"/g, '\\"');
321
+ cmd += ` --release-notes "${escaped}"`;
322
+ }
323
+
324
+ log.info(`Distributing build via App Distribution: ${buildPath}`);
325
+
326
+ const result = execSync(cmd, {
327
+ cwd: projectPath,
328
+ timeout: 300_000, // 5 min for large APKs/IPAs
329
+ encoding: "utf-8",
330
+ env: { ...process.env },
331
+ });
332
+
333
+ log.info("App Distribution upload complete");
334
+ return { output: result };
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // ADB (Android Debug Bridge) Helpers
339
+ // ---------------------------------------------------------------------------
340
+
341
+ /**
342
+ * Check if ADB wireless debugging is available and a device is connected.
343
+ * @returns {{ connected: boolean, deviceId: string|null, deviceName: string|null }}
344
+ */
345
+ export function checkAdbDevice() {
346
+ try {
347
+ const result = execSync("adb devices -l", {
348
+ timeout: 5_000,
349
+ encoding: "utf-8",
350
+ });
351
+ const lines = result
352
+ .split("\n")
353
+ .filter((l) => l.includes("device") && !l.startsWith("List"));
354
+ if (lines.length > 0) {
355
+ const parts = lines[0].split(/\s+/);
356
+ const deviceId = parts[0];
357
+ const modelMatch = lines[0].match(/model:(\S+)/);
358
+ return {
359
+ connected: true,
360
+ deviceId,
361
+ deviceName: modelMatch?.[1] || deviceId,
362
+ };
363
+ }
364
+ } catch {
365
+ /* ADB not installed or timed out */
366
+ }
367
+ return { connected: false, deviceId: null, deviceName: null };
368
+ }
369
+
370
+ /**
371
+ * Install APK directly to a connected Android device via ADB.
372
+ * @param {string} apkPath — path to the .apk file
373
+ * @param {string} [deviceId] — specific device serial (optional)
374
+ * @returns {{ output: string }}
375
+ */
376
+ export function installViaAdb(apkPath, deviceId) {
377
+ assertCliAvailable("adb");
378
+
379
+ const cmd = deviceId
380
+ ? `adb -s ${deviceId} install -r "${apkPath}"`
381
+ : `adb install -r "${apkPath}"`;
382
+
383
+ log.info(`Installing APK via ADB: ${apkPath}`);
384
+
385
+ const result = execSync(cmd, {
386
+ timeout: 120_000,
387
+ encoding: "utf-8",
388
+ });
389
+
390
+ log.info("ADB install complete");
391
+ return { output: result };
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // Helpers
396
+ // ---------------------------------------------------------------------------
397
+
398
+ /**
399
+ * Assert that a CLI tool is available on the PATH.
400
+ * Throws a user-friendly error if not found.
401
+ */
402
+ // ---------------------------------------------------------------------------
403
+ // Deploy Readiness Check & Setup
404
+ // ---------------------------------------------------------------------------
405
+
406
+ /**
407
+ * Check if Firebase CLI is installed and authenticated.
408
+ * Check if the project has firebase.json and .firebaserc.
409
+ * Returns a readiness report.
410
+ */
411
+ export function checkDeployReadiness(projectPath) {
412
+ const report = {
413
+ firebaseCli: { installed: false, version: null },
414
+ firebaseAuth: { loggedIn: false, account: null },
415
+ firebaseConfig: {
416
+ hasFirebaseJson: false,
417
+ hasFirebaserc: false,
418
+ projectId: null,
419
+ },
420
+ hosting: { configured: false, publicDir: null },
421
+ appDistribution: { available: false },
422
+ issues: [],
423
+ ready: false,
424
+ };
425
+
426
+ // Check Firebase CLI
427
+ try {
428
+ const version = execSync("firebase --version", {
429
+ timeout: 5000,
430
+ encoding: "utf-8",
431
+ }).trim();
432
+ report.firebaseCli = { installed: true, version };
433
+ } catch {
434
+ report.issues.push({
435
+ code: "NO_CLI",
436
+ message: "Firebase CLI not installed",
437
+ fix: "npm install -g firebase-tools",
438
+ });
439
+ }
440
+
441
+ // Check Firebase auth
442
+ if (report.firebaseCli.installed) {
443
+ try {
444
+ const result = execSync("firebase login:list", {
445
+ timeout: 10000,
446
+ encoding: "utf-8",
447
+ });
448
+ const accounts = result.match(/[\w.-]+@[\w.-]+/g);
449
+ if (accounts && accounts.length > 0) {
450
+ report.firebaseAuth = { loggedIn: true, account: accounts[0] };
451
+ } else {
452
+ report.issues.push({
453
+ code: "NOT_LOGGED_IN",
454
+ message: "Not logged in to Firebase",
455
+ fix: "firebase login",
456
+ });
457
+ }
458
+ } catch {
459
+ report.issues.push({
460
+ code: "NOT_LOGGED_IN",
461
+ message: "Not logged in to Firebase",
462
+ fix: "firebase login",
463
+ });
464
+ }
465
+ }
466
+
467
+ // Check firebase.json
468
+ const firebaseJsonPath = path.join(projectPath, "firebase.json");
469
+ if (existsSync(firebaseJsonPath)) {
470
+ report.firebaseConfig.hasFirebaseJson = true;
471
+ try {
472
+ const config = JSON.parse(readFileSync(firebaseJsonPath, "utf-8"));
473
+ if (config.hosting) {
474
+ report.hosting = {
475
+ configured: true,
476
+ publicDir: config.hosting.public || config.hosting[0]?.public || null,
477
+ };
478
+ }
479
+ } catch {
480
+ /* malformed firebase.json */
481
+ }
482
+ } else {
483
+ report.issues.push({
484
+ code: "NO_FIREBASE_JSON",
485
+ message: "No firebase.json found in project",
486
+ fix: "Will be created during setup",
487
+ });
488
+ }
489
+
490
+ // Check .firebaserc
491
+ const firebasercPath = path.join(projectPath, ".firebaserc");
492
+ if (existsSync(firebasercPath)) {
493
+ report.firebaseConfig.hasFirebaserc = true;
494
+ try {
495
+ const rc = JSON.parse(readFileSync(firebasercPath, "utf-8"));
496
+ report.firebaseConfig.projectId = rc.projects?.default || null;
497
+ } catch {
498
+ /* malformed .firebaserc */
499
+ }
500
+ }
501
+
502
+ report.ready =
503
+ report.firebaseCli.installed &&
504
+ report.firebaseAuth.loggedIn &&
505
+ report.firebaseConfig.hasFirebaseJson &&
506
+ report.hosting.configured;
507
+
508
+ return report;
509
+ }
510
+
511
+ /**
512
+ * List Firebase projects available to the user.
513
+ */
514
+ export function listFirebaseProjects() {
515
+ try {
516
+ const result = execSync("firebase projects:list --json", {
517
+ timeout: 30000,
518
+ encoding: "utf-8",
519
+ });
520
+ const data = JSON.parse(result);
521
+ if (data.status === "success" && Array.isArray(data.result)) {
522
+ return data.result.map((p) => ({
523
+ projectId: p.projectId,
524
+ displayName: p.displayName,
525
+ projectNumber: p.projectNumber,
526
+ }));
527
+ }
528
+ } catch {
529
+ /* firebase CLI not available or not logged in */
530
+ }
531
+ return [];
532
+ }
533
+
534
+ /**
535
+ * Initialize Firebase Hosting for a project.
536
+ * Creates firebase.json and .firebaserc.
537
+ */
538
+ export function initFirebaseHosting(projectPath, projectId, publicDir) {
539
+ // Write .firebaserc
540
+ const firebasercPath = path.join(projectPath, ".firebaserc");
541
+ const rc = { projects: { default: projectId } };
542
+ writeFileSync(firebasercPath, JSON.stringify(rc, null, 2));
543
+
544
+ // Write firebase.json
545
+ const firebaseJsonPath = path.join(projectPath, "firebase.json");
546
+ const config = {
547
+ hosting: {
548
+ public: publicDir,
549
+ ignore: ["firebase.json", "**/.*", "**/node_modules/**"],
550
+ rewrites: [{ source: "**", destination: "/index.html" }],
551
+ },
552
+ };
553
+ writeFileSync(firebaseJsonPath, JSON.stringify(config, null, 2));
554
+
555
+ return { firebaseJsonPath, firebasercPath };
556
+ }
557
+
558
+ /**
559
+ * Run firebase login in the background (opens browser on desktop).
560
+ */
561
+ export function startFirebaseLogin() {
562
+ const proc = spawn("firebase", ["login", "--no-localhost"], {
563
+ shell: true,
564
+ stdio: ["pipe", "pipe", "pipe"],
565
+ detached: true,
566
+ });
567
+ proc.unref();
568
+ return { pid: proc.pid };
569
+ }
570
+
571
+ // ---------------------------------------------------------------------------
572
+ // Helpers
573
+ // ---------------------------------------------------------------------------
574
+
575
+ function assertCliAvailable(cliName) {
576
+ try {
577
+ execSync(`which ${cliName}`, { stdio: "pipe" });
578
+ } catch {
579
+ const hints = {
580
+ firebase:
581
+ "Firebase CLI not found. Install it: npm install -g firebase-tools",
582
+ adb: "ADB not found. Install Android SDK platform-tools.",
583
+ };
584
+ throw new Error(hints[cliName] || `${cliName} CLI not found on PATH`);
585
+ }
586
+ }
@@ -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,
@@ -460,6 +472,16 @@ const VALID_SESSION_COMMANDS = new Set([
460
472
  "git_commit",
461
473
  "git_push",
462
474
  "create_pr",
475
+ "detect_project",
476
+ "build_project",
477
+ "deploy_hosting",
478
+ "deploy_app_dist",
479
+ "check_adb",
480
+ "install_adb",
481
+ "check_deploy_ready",
482
+ "list_firebase_projects",
483
+ "init_firebase_hosting",
484
+ "start_firebase_login",
463
485
  ]);
464
486
 
465
487
  async function handleSessionCommand(sessionId, commandDoc) {
@@ -522,6 +544,7 @@ async function handleSessionCommand(sessionId, commandDoc) {
522
544
  sessionId,
523
545
  data.payload?.prompt,
524
546
  data.payload?.imageBase64,
547
+ data.payload?.model,
525
548
  );
526
549
  break;
527
550
  case "stop_session":
@@ -659,6 +682,323 @@ async function handleSessionCommand(sessionId, commandDoc) {
659
682
  await cmdRef.update({ result: prResult });
660
683
  break;
661
684
  }
685
+
686
+ // ---------------------------------------------------------------
687
+ // Build & Deploy commands
688
+ // ---------------------------------------------------------------
689
+
690
+ case "detect_project": {
691
+ log.command("detect_project", sessionId.slice(0, 8));
692
+ const sess = activeSessions.get(sessionId);
693
+ if (!sess) throw new Error("Session not found");
694
+ const projectInfo = detectProjectType(sess.projectPath);
695
+ await db
696
+ .collection("sessions")
697
+ .doc(sessionId)
698
+ .collection("buildData")
699
+ .doc("projectInfo")
700
+ .set({
701
+ ...projectInfo,
702
+ detectedAt: FieldValue.serverTimestamp(),
703
+ });
704
+ break;
705
+ }
706
+
707
+ case "build_project": {
708
+ const platform = data.payload?.platform || "web";
709
+ log.command("build_project", `${sessionId.slice(0, 8)} [${platform}]`);
710
+ const sess = activeSessions.get(sessionId);
711
+ if (!sess) throw new Error("Session not found");
712
+
713
+ // Create a build record with status: building
714
+ const buildRef = db
715
+ .collection("sessions")
716
+ .doc(sessionId)
717
+ .collection("builds")
718
+ .doc();
719
+ await buildRef.set({
720
+ platform,
721
+ status: "building",
722
+ startedAt: FieldValue.serverTimestamp(),
723
+ projectPath: sess.projectPath,
724
+ });
725
+
726
+ try {
727
+ const result = await runBuild(
728
+ sess.projectPath,
729
+ platform,
730
+ (line, _stream) => {
731
+ // Stream latest output line to Firestore (best-effort)
732
+ buildRef
733
+ .update({
734
+ lastOutput: line,
735
+ lastActivity: FieldValue.serverTimestamp(),
736
+ })
737
+ .catch(() => {});
738
+ },
739
+ );
740
+
741
+ await buildRef.update({
742
+ status: "success",
743
+ duration: result.duration,
744
+ outputPath: result.outputPath,
745
+ framework: result.framework,
746
+ completedAt: FieldValue.serverTimestamp(),
747
+ });
748
+
749
+ // Notify mobile app
750
+ if (sess.desktopId) {
751
+ notifySessionComplete(sess.desktopId, sessionId, {
752
+ projectName: sess.projectName || "Unknown",
753
+ customTitle: "Build Complete",
754
+ customBody: `${result.framework} ${platform} build succeeded (${result.duration}s)`,
755
+ }).catch(() => {});
756
+ }
757
+ } catch (err) {
758
+ await buildRef.update({
759
+ status: "failed",
760
+ error: err.message,
761
+ completedAt: FieldValue.serverTimestamp(),
762
+ });
763
+ throw err; // Re-throw so the outer handler marks the command as failed
764
+ }
765
+ break;
766
+ }
767
+
768
+ case "deploy_hosting": {
769
+ log.command("deploy_hosting", sessionId.slice(0, 8));
770
+ const sess = activeSessions.get(sessionId);
771
+ if (!sess) throw new Error("Session not found");
772
+
773
+ const deployRef = db
774
+ .collection("sessions")
775
+ .doc(sessionId)
776
+ .collection("deploys")
777
+ .doc();
778
+ await deployRef.set({
779
+ type: "hosting",
780
+ status: "deploying",
781
+ startedAt: FieldValue.serverTimestamp(),
782
+ });
783
+
784
+ try {
785
+ const channelId = data.payload?.channelId || `preview-${Date.now()}`;
786
+ const projectId = data.payload?.projectId;
787
+ const outputDir = data.payload?.outputDir;
788
+
789
+ const result = await deployToFirebaseHosting(
790
+ sess.projectPath,
791
+ outputDir,
792
+ { projectId, channelId },
793
+ );
794
+
795
+ await deployRef.update({
796
+ status: "success",
797
+ url: result.url,
798
+ channelId,
799
+ completedAt: FieldValue.serverTimestamp(),
800
+ });
801
+
802
+ if (sess.desktopId) {
803
+ notifySessionComplete(sess.desktopId, sessionId, {
804
+ projectName: sess.projectName || "Unknown",
805
+ customTitle: "Deploy Complete",
806
+ customBody: result.url
807
+ ? `Live at: ${result.url}`
808
+ : "Firebase Hosting deployed",
809
+ }).catch(() => {});
810
+ }
811
+ } catch (err) {
812
+ await deployRef.update({
813
+ status: "failed",
814
+ error: err.message,
815
+ completedAt: FieldValue.serverTimestamp(),
816
+ });
817
+ throw err;
818
+ }
819
+ break;
820
+ }
821
+
822
+ case "deploy_app_dist": {
823
+ log.command("deploy_app_dist", sessionId.slice(0, 8));
824
+ const sess = activeSessions.get(sessionId);
825
+ if (!sess) throw new Error("Session not found");
826
+
827
+ const deployRef = db
828
+ .collection("sessions")
829
+ .doc(sessionId)
830
+ .collection("deploys")
831
+ .doc();
832
+ await deployRef.set({
833
+ type: "app_distribution",
834
+ status: "deploying",
835
+ startedAt: FieldValue.serverTimestamp(),
836
+ });
837
+
838
+ try {
839
+ const buildPath = data.payload?.buildPath;
840
+ if (!buildPath) throw new Error("buildPath is required");
841
+
842
+ const projectId = data.payload?.projectId;
843
+ const groups = data.payload?.groups;
844
+ const testers = data.payload?.testers;
845
+ const releaseNotes = data.payload?.releaseNotes;
846
+
847
+ await deployToAppDistribution(sess.projectPath, buildPath, {
848
+ projectId,
849
+ groups,
850
+ testers,
851
+ releaseNotes,
852
+ });
853
+
854
+ await deployRef.update({
855
+ status: "success",
856
+ completedAt: FieldValue.serverTimestamp(),
857
+ });
858
+
859
+ if (sess.desktopId) {
860
+ notifySessionComplete(sess.desktopId, sessionId, {
861
+ projectName: sess.projectName || "Unknown",
862
+ customTitle: "App Distributed",
863
+ customBody: "New build available for testers",
864
+ }).catch(() => {});
865
+ }
866
+ } catch (err) {
867
+ await deployRef.update({
868
+ status: "failed",
869
+ error: err.message,
870
+ completedAt: FieldValue.serverTimestamp(),
871
+ });
872
+ throw err;
873
+ }
874
+ break;
875
+ }
876
+
877
+ case "check_adb": {
878
+ log.command("check_adb", sessionId.slice(0, 8));
879
+ const device = checkAdbDevice();
880
+ await db
881
+ .collection("sessions")
882
+ .doc(sessionId)
883
+ .collection("buildData")
884
+ .doc("adbStatus")
885
+ .set({
886
+ ...device,
887
+ checkedAt: FieldValue.serverTimestamp(),
888
+ });
889
+ break;
890
+ }
891
+
892
+ case "install_adb": {
893
+ log.command("install_adb", sessionId.slice(0, 8));
894
+ const apkPath = data.payload?.apkPath;
895
+ const deviceId = data.payload?.deviceId;
896
+ if (!apkPath) throw new Error("apkPath is required");
897
+
898
+ try {
899
+ installViaAdb(apkPath, deviceId);
900
+ await db
901
+ .collection("sessions")
902
+ .doc(sessionId)
903
+ .collection("buildData")
904
+ .doc("adbInstall")
905
+ .set({
906
+ status: "success",
907
+ apkPath,
908
+ installedAt: FieldValue.serverTimestamp(),
909
+ });
910
+ } catch (err) {
911
+ await db
912
+ .collection("sessions")
913
+ .doc(sessionId)
914
+ .collection("buildData")
915
+ .doc("adbInstall")
916
+ .set({
917
+ status: "failed",
918
+ error: err.message,
919
+ apkPath,
920
+ attemptedAt: FieldValue.serverTimestamp(),
921
+ });
922
+ throw err;
923
+ }
924
+ break;
925
+ }
926
+
927
+ // ---------------------------------------------------------------
928
+ // Deploy Readiness & Setup commands
929
+ // ---------------------------------------------------------------
930
+
931
+ case "check_deploy_ready": {
932
+ log.command("check_deploy_ready", sessionId.slice(0, 8));
933
+ const sess = activeSessions.get(sessionId);
934
+ if (!sess) throw new Error("Session not found");
935
+ const report = checkDeployReadiness(sess.projectPath);
936
+ await db
937
+ .collection("sessions")
938
+ .doc(sessionId)
939
+ .collection("buildData")
940
+ .doc("deployReadiness")
941
+ .set({
942
+ ...report,
943
+ checkedAt: FieldValue.serverTimestamp(),
944
+ });
945
+ break;
946
+ }
947
+
948
+ case "list_firebase_projects": {
949
+ log.command("list_firebase_projects", sessionId.slice(0, 8));
950
+ const projects = listFirebaseProjects();
951
+ await db
952
+ .collection("sessions")
953
+ .doc(sessionId)
954
+ .collection("buildData")
955
+ .doc("firebaseProjects")
956
+ .set({
957
+ projects,
958
+ fetchedAt: FieldValue.serverTimestamp(),
959
+ });
960
+ break;
961
+ }
962
+
963
+ case "init_firebase_hosting": {
964
+ log.command("init_firebase_hosting", sessionId.slice(0, 8));
965
+ const sess = activeSessions.get(sessionId);
966
+ if (!sess) throw new Error("Session not found");
967
+ const projectId = data.payload?.projectId;
968
+ const publicDir = data.payload?.publicDir || "build/web";
969
+ if (!projectId) throw new Error("projectId is required");
970
+
971
+ initFirebaseHosting(sess.projectPath, projectId, publicDir);
972
+ await db
973
+ .collection("sessions")
974
+ .doc(sessionId)
975
+ .collection("buildData")
976
+ .doc("deployReadiness")
977
+ .update({
978
+ "firebaseConfig.hasFirebaseJson": true,
979
+ "firebaseConfig.hasFirebaserc": true,
980
+ "firebaseConfig.projectId": projectId,
981
+ "hosting.configured": true,
982
+ "hosting.publicDir": publicDir,
983
+ ready: true,
984
+ });
985
+ break;
986
+ }
987
+
988
+ case "start_firebase_login": {
989
+ log.command("start_firebase_login", sessionId.slice(0, 8));
990
+ startFirebaseLogin();
991
+ await db
992
+ .collection("sessions")
993
+ .doc(sessionId)
994
+ .collection("buildData")
995
+ .doc("deployReadiness")
996
+ .update({
997
+ loginStarted: true,
998
+ loginStartedAt: FieldValue.serverTimestamp(),
999
+ });
1000
+ break;
1001
+ }
662
1002
  }
663
1003
  await cmdRef.update({ status: "completed" });
664
1004
  } catch (e) {
@@ -830,7 +1170,7 @@ export async function startNewSession(desktopId, payload) {
830
1170
  // Send a follow-up prompt (spawns a new process with --continue)
831
1171
  // ---------------------------------------------------------------------------
832
1172
 
833
- async function sendFollowUpPrompt(sessionId, prompt, imageBase64) {
1173
+ async function sendFollowUpPrompt(sessionId, prompt, imageBase64, model) {
834
1174
  const session = activeSessions.get(sessionId);
835
1175
  if (!session) {
836
1176
  throw new Error("Session not found. It may have ended.");
@@ -895,6 +1235,12 @@ async function sendFollowUpPrompt(sessionId, prompt, imageBase64) {
895
1235
  // duplicate this write to avoid showing the message twice.
896
1236
  const db = getDb();
897
1237
 
1238
+ // If the mobile app sent a model preference, use it for this follow-up.
1239
+ if (model && model !== session.model) {
1240
+ log.session(sessionId, `Model override: ${session.model} → ${model}`);
1241
+ session.model = model;
1242
+ }
1243
+
898
1244
  let finalPrompt = prompt;
899
1245
  // Image path prepending disabled — see note above.
900
1246
  // if (imagePath) {