forge-remote 0.1.28 → 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 +339 -0
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) {
|
|
@@ -660,6 +682,323 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
660
682
|
await cmdRef.update({ result: prResult });
|
|
661
683
|
break;
|
|
662
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
|
+
}
|
|
663
1002
|
}
|
|
664
1003
|
await cmdRef.update({ status: "completed" });
|
|
665
1004
|
} catch (e) {
|