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