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 +1 -1
- package/src/build-manager.js +586 -0
- package/src/session-manager.js +347 -1
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/session-manager.js
CHANGED
|
@@ -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) {
|