forge-remote 0.1.0
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 +49 -0
- package/src/cli.js +213 -0
- package/src/desktop.js +121 -0
- package/src/firebase.js +46 -0
- package/src/init.js +645 -0
- package/src/logger.js +150 -0
- package/src/project-scanner.js +103 -0
- package/src/screenshot-manager.js +204 -0
- package/src/session-manager.js +1539 -0
- package/src/tunnel-manager.js +201 -0
package/src/init.js
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
// Forge Remote Relay — Desktop Agent for Forge Remote
|
|
2
|
+
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
3
|
+
// Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
|
|
4
|
+
// AGPL-3.0 License — See LICENSE
|
|
5
|
+
|
|
6
|
+
import { execSync } from "child_process";
|
|
7
|
+
import { hostname, platform, homedir } from "os";
|
|
8
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
|
|
9
|
+
import { join, dirname } from "path";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
import { initializeApp, cert } from "firebase-admin/app";
|
|
12
|
+
import { getFirestore, FieldValue } from "firebase-admin/firestore";
|
|
13
|
+
import qrcode from "qrcode-terminal";
|
|
14
|
+
import chalk from "chalk";
|
|
15
|
+
|
|
16
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run a shell command and return stdout (trimmed).
|
|
20
|
+
* Returns null if the command fails.
|
|
21
|
+
*/
|
|
22
|
+
function run(cmd, { silent = false } = {}) {
|
|
23
|
+
try {
|
|
24
|
+
return execSync(cmd, {
|
|
25
|
+
encoding: "utf-8",
|
|
26
|
+
stdio: silent ? "pipe" : ["pipe", "pipe", "pipe"],
|
|
27
|
+
}).trim();
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run a shell command, throwing on failure with a descriptive message.
|
|
35
|
+
*/
|
|
36
|
+
function runOrFail(cmd, errorMsg) {
|
|
37
|
+
try {
|
|
38
|
+
return execSync(cmd, {
|
|
39
|
+
encoding: "utf-8",
|
|
40
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
41
|
+
}).trim();
|
|
42
|
+
} catch (e) {
|
|
43
|
+
const stderr = e.stderr ? e.stderr.toString().trim() : e.message;
|
|
44
|
+
throw new Error(`${errorMsg}\n Command: ${cmd}\n ${stderr}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get a sanitized hostname suitable for a Firebase project ID.
|
|
50
|
+
* Max 30 chars total (including "forge-remote-" prefix), alphanumeric + hyphens.
|
|
51
|
+
*/
|
|
52
|
+
function sanitizedHostname() {
|
|
53
|
+
const raw = hostname()
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/\.local$/, "")
|
|
56
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
57
|
+
.replace(/-+/g, "-")
|
|
58
|
+
.replace(/^-|-$/g, "");
|
|
59
|
+
// "forge-remote-" is 13 chars, so hostname portion is max 17 chars.
|
|
60
|
+
return raw.slice(0, 17);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get platform name from os.platform().
|
|
65
|
+
*/
|
|
66
|
+
function getPlatformName() {
|
|
67
|
+
const p = platform();
|
|
68
|
+
if (p === "darwin") return "macos";
|
|
69
|
+
if (p === "win32") return "windows";
|
|
70
|
+
return "linux";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get a stable desktop ID based on hostname.
|
|
75
|
+
*/
|
|
76
|
+
function getDesktopId() {
|
|
77
|
+
const name = hostname()
|
|
78
|
+
.toLowerCase()
|
|
79
|
+
.replace(/[^a-z0-9]/g, "-");
|
|
80
|
+
return `desktop-${name}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Walk up from a starting directory to find a file.
|
|
85
|
+
*/
|
|
86
|
+
function findFileUpwards(filename, startDir) {
|
|
87
|
+
let dir = startDir;
|
|
88
|
+
while (true) {
|
|
89
|
+
const candidate = join(dir, filename);
|
|
90
|
+
if (existsSync(candidate)) return candidate;
|
|
91
|
+
const parent = dirname(dir);
|
|
92
|
+
if (parent === dir) return null; // reached filesystem root
|
|
93
|
+
dir = parent;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Main init flow ─────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Run the full Forge Remote initialization:
|
|
101
|
+
* 1. Check Firebase CLI
|
|
102
|
+
* 2. Check Firebase login
|
|
103
|
+
* 3. Generate / confirm project name
|
|
104
|
+
* 4. Create Firebase project
|
|
105
|
+
* 5. Create web app
|
|
106
|
+
* 6. Get SDK config
|
|
107
|
+
* 7. Enable Firestore
|
|
108
|
+
* 8. Deploy security rules (if found)
|
|
109
|
+
* 9. Set up service account credentials
|
|
110
|
+
* 10. Register desktop in Firestore
|
|
111
|
+
* 11. Build QR payload
|
|
112
|
+
* 12. Display QR code + success message
|
|
113
|
+
*/
|
|
114
|
+
export async function runInit({ projectId: overrideProjectId } = {}) {
|
|
115
|
+
const forgeDir = join(homedir(), ".forge-remote");
|
|
116
|
+
const line = "═".repeat(50);
|
|
117
|
+
|
|
118
|
+
console.log(`\n${chalk.bold.cyan(line)}`);
|
|
119
|
+
console.log(chalk.bold.cyan(" ⚡ FORGE REMOTE INIT"));
|
|
120
|
+
console.log(`${chalk.bold.cyan(line)}\n`);
|
|
121
|
+
console.log(
|
|
122
|
+
chalk.dim(" This command will set up a Firebase project for Forge Remote"),
|
|
123
|
+
);
|
|
124
|
+
console.log(
|
|
125
|
+
chalk.dim(" and generate a QR code to pair with the mobile app.\n"),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// ── Step 1: Check Firebase CLI ──────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
console.log(chalk.bold("\n📋 Step 1/12 — Checking Firebase CLI...\n"));
|
|
131
|
+
|
|
132
|
+
const firebaseVersion = run("firebase --version", { silent: true });
|
|
133
|
+
if (!firebaseVersion) {
|
|
134
|
+
console.error(chalk.red("Firebase CLI not found."));
|
|
135
|
+
console.error(
|
|
136
|
+
chalk.yellow("Install it with:\n\n npm install -g firebase-tools\n"),
|
|
137
|
+
);
|
|
138
|
+
console.error(chalk.dim("Then run this command again: forge-remote init"));
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
console.log(chalk.green(` ✓ Firebase CLI ${firebaseVersion}`));
|
|
142
|
+
|
|
143
|
+
// ── Step 2: Check Firebase login ────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
console.log(chalk.bold("\n📋 Step 2/12 — Checking Firebase login...\n"));
|
|
146
|
+
|
|
147
|
+
const loginList = run("firebase login:list", { silent: true });
|
|
148
|
+
const isLoggedIn = loginList && !loginList.includes("No authorized accounts");
|
|
149
|
+
|
|
150
|
+
if (!isLoggedIn) {
|
|
151
|
+
console.log(
|
|
152
|
+
chalk.yellow(" Not logged into Firebase. Opening login flow...\n"),
|
|
153
|
+
);
|
|
154
|
+
try {
|
|
155
|
+
execSync("firebase login", { stdio: "inherit" });
|
|
156
|
+
} catch {
|
|
157
|
+
console.error(chalk.red("\n Firebase login failed or was cancelled."));
|
|
158
|
+
console.error(
|
|
159
|
+
chalk.dim(" Run `firebase login` manually, then retry.\n"),
|
|
160
|
+
);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Re-check after login.
|
|
166
|
+
const loginListAfter = run("firebase login:list", { silent: true });
|
|
167
|
+
if (!loginListAfter || loginListAfter.includes("No authorized accounts")) {
|
|
168
|
+
console.error(chalk.red(" Firebase login required. Aborting.\n"));
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
console.log(chalk.green(" ✓ Logged into Firebase"));
|
|
172
|
+
|
|
173
|
+
// ── Step 3: Generate project name ───────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
console.log(chalk.bold("\n📋 Step 3/12 — Generating project name...\n"));
|
|
176
|
+
|
|
177
|
+
const defaultProjectId = `forge-remote-${sanitizedHostname()}`;
|
|
178
|
+
const projectId = overrideProjectId || defaultProjectId;
|
|
179
|
+
console.log(chalk.dim(` Using project ID: ${projectId}`));
|
|
180
|
+
if (!overrideProjectId) {
|
|
181
|
+
console.log(
|
|
182
|
+
chalk.dim(` (Override with: forge-remote init --project-id <id>)`),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Validate project ID.
|
|
187
|
+
if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(projectId)) {
|
|
188
|
+
console.error(
|
|
189
|
+
chalk.red(
|
|
190
|
+
"\n Invalid project ID. Must be 6-30 chars, lowercase alphanumeric + hyphens,",
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
console.error(
|
|
194
|
+
chalk.red(
|
|
195
|
+
" starting with a letter and ending with a letter or number.\n",
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log(chalk.green(` ✓ Project ID: ${projectId}`));
|
|
202
|
+
|
|
203
|
+
// ── Step 4: Create Firebase project ─────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
console.log(chalk.bold("\n📋 Step 4/12 — Creating Firebase project...\n"));
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const createResult = runOrFail(
|
|
209
|
+
`firebase projects:create ${projectId} --display-name "Forge Remote"`,
|
|
210
|
+
"Failed to create Firebase project.",
|
|
211
|
+
);
|
|
212
|
+
console.log(chalk.green(` ✓ Firebase project created: ${projectId}`));
|
|
213
|
+
console.log(chalk.dim(` ${createResult.split("\n").pop()}`));
|
|
214
|
+
} catch (e) {
|
|
215
|
+
const msg = e.message || "";
|
|
216
|
+
if (
|
|
217
|
+
msg.includes("already exists") ||
|
|
218
|
+
msg.includes("ALREADY_EXISTS") ||
|
|
219
|
+
msg.includes("already being used")
|
|
220
|
+
) {
|
|
221
|
+
console.log(
|
|
222
|
+
chalk.yellow(` ⚠ Project "${projectId}" already exists — reusing it.`),
|
|
223
|
+
);
|
|
224
|
+
} else {
|
|
225
|
+
console.error(chalk.red(` ✗ ${e.message}`));
|
|
226
|
+
console.error(
|
|
227
|
+
chalk.dim(
|
|
228
|
+
"\n If the project ID is taken, re-run with a different name.\n",
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Step 5: Create web app ──────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
console.log(chalk.bold("\n📋 Step 5/12 — Creating web app...\n"));
|
|
238
|
+
|
|
239
|
+
let appId = null;
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const createAppOutput = runOrFail(
|
|
243
|
+
`firebase apps:create web "Forge Remote Mobile" --project ${projectId}`,
|
|
244
|
+
"Failed to create web app.",
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Parse the app ID from the output.
|
|
248
|
+
// The output typically contains "App ID: 1:xxxx:web:xxxx" or similar.
|
|
249
|
+
const appIdMatch = createAppOutput.match(/(?:App ID|appId)[:\s]+(\S+)/i);
|
|
250
|
+
if (appIdMatch) {
|
|
251
|
+
appId = appIdMatch[1];
|
|
252
|
+
}
|
|
253
|
+
console.log(chalk.green(` ✓ Web app created`));
|
|
254
|
+
} catch (e) {
|
|
255
|
+
const msg = e.message || "";
|
|
256
|
+
if (msg.includes("already exists") || msg.includes("ALREADY_EXISTS")) {
|
|
257
|
+
console.log(
|
|
258
|
+
chalk.yellow(" ⚠ Web app already exists — fetching existing app ID."),
|
|
259
|
+
);
|
|
260
|
+
} else {
|
|
261
|
+
console.error(chalk.red(` ✗ ${e.message}`));
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// If we didn't parse the app ID from creation output, list apps to find it.
|
|
267
|
+
if (!appId) {
|
|
268
|
+
try {
|
|
269
|
+
const appsList = runOrFail(
|
|
270
|
+
`firebase apps:list --project ${projectId}`,
|
|
271
|
+
"Failed to list apps.",
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// Look for a WEB app ID in the table output.
|
|
275
|
+
const lines = appsList.split("\n");
|
|
276
|
+
for (const appLine of lines) {
|
|
277
|
+
if (appLine.includes("WEB") || appLine.includes("web")) {
|
|
278
|
+
const idMatch = appLine.match(/1:\d+:web:[a-f0-9]+/);
|
|
279
|
+
if (idMatch) {
|
|
280
|
+
appId = idMatch[0];
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!appId) {
|
|
287
|
+
// Try a broader pattern — any app ID in the list.
|
|
288
|
+
for (const appLine of lines) {
|
|
289
|
+
const idMatch = appLine.match(/1:\d+:web:[a-f0-9]+/);
|
|
290
|
+
if (idMatch) {
|
|
291
|
+
appId = idMatch[0];
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch (e) {
|
|
297
|
+
console.error(chalk.red(` ✗ Could not determine web app ID.`));
|
|
298
|
+
console.error(chalk.dim(` ${e.message}\n`));
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!appId) {
|
|
304
|
+
console.error(chalk.red(" ✗ Could not determine web app ID."));
|
|
305
|
+
console.error(
|
|
306
|
+
chalk.dim(
|
|
307
|
+
" Run `firebase apps:list --project " + projectId + "` to find it.\n",
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
process.exit(1);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
console.log(chalk.green(` ✓ App ID: ${appId}`));
|
|
314
|
+
|
|
315
|
+
// ── Step 6: Get SDK config ──────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
console.log(chalk.bold("\n📋 Step 6/12 — Fetching SDK config...\n"));
|
|
318
|
+
|
|
319
|
+
let sdkConfig = {};
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const sdkOutput = runOrFail(
|
|
323
|
+
`firebase apps:sdkconfig web ${appId} --project ${projectId}`,
|
|
324
|
+
"Failed to get SDK config.",
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// Parse the config object from the output.
|
|
328
|
+
// The output contains a JS object like: { apiKey: "...", authDomain: "...", ... }
|
|
329
|
+
const parseField = (field) => {
|
|
330
|
+
const regex = new RegExp(`${field}:\\s*"([^"]+)"`);
|
|
331
|
+
const match = sdkOutput.match(regex);
|
|
332
|
+
return match ? match[1] : null;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
sdkConfig = {
|
|
336
|
+
apiKey: parseField("apiKey"),
|
|
337
|
+
authDomain: parseField("authDomain"),
|
|
338
|
+
projectId: parseField("projectId"),
|
|
339
|
+
storageBucket: parseField("storageBucket"),
|
|
340
|
+
messagingSenderId: parseField("messagingSenderId"),
|
|
341
|
+
appId: parseField("appId"),
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Validate we got the critical fields.
|
|
345
|
+
if (!sdkConfig.apiKey || !sdkConfig.projectId) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
"Could not parse apiKey or projectId from SDK config output.",
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
console.log(chalk.green(" ✓ SDK config retrieved"));
|
|
352
|
+
console.log(chalk.dim(` apiKey: ${sdkConfig.apiKey}`));
|
|
353
|
+
console.log(chalk.dim(` projectId: ${sdkConfig.projectId}`));
|
|
354
|
+
console.log(chalk.dim(` appId: ${sdkConfig.appId}`));
|
|
355
|
+
console.log(
|
|
356
|
+
chalk.dim(` messagingSenderId: ${sdkConfig.messagingSenderId}`),
|
|
357
|
+
);
|
|
358
|
+
console.log(
|
|
359
|
+
chalk.dim(` storageBucket: ${sdkConfig.storageBucket}`),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
// Cache SDK config so `pair` can read it without the Firebase CLI.
|
|
363
|
+
const sdkCachePath = join(forgeDir, "sdk-config.json");
|
|
364
|
+
writeFileSync(sdkCachePath, JSON.stringify(sdkConfig, null, 2));
|
|
365
|
+
} catch (e) {
|
|
366
|
+
console.error(chalk.red(` ✗ ${e.message}`));
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Step 7: Enable Firestore ────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
console.log(chalk.bold("\n📋 Step 7/12 — Enabling Firestore...\n"));
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
runOrFail(
|
|
376
|
+
`firebase firestore:databases:create default --project ${projectId} --location=nam5`,
|
|
377
|
+
"Failed to enable Firestore.",
|
|
378
|
+
);
|
|
379
|
+
console.log(chalk.green(" ✓ Firestore enabled (nam5 — US multi-region)"));
|
|
380
|
+
} catch (e) {
|
|
381
|
+
const msg = e.message || "";
|
|
382
|
+
if (
|
|
383
|
+
msg.includes("already exists") ||
|
|
384
|
+
msg.includes("ALREADY_EXISTS") ||
|
|
385
|
+
msg.includes("database already exists")
|
|
386
|
+
) {
|
|
387
|
+
console.log(chalk.yellow(" ⚠ Firestore already enabled — continuing."));
|
|
388
|
+
} else {
|
|
389
|
+
console.error(chalk.red(` ✗ ${e.message}`));
|
|
390
|
+
console.error(
|
|
391
|
+
chalk.dim("\n You may need to enable Firestore manually at:"),
|
|
392
|
+
);
|
|
393
|
+
console.error(
|
|
394
|
+
chalk.dim(
|
|
395
|
+
` https://console.firebase.google.com/project/${projectId}/firestore\n`,
|
|
396
|
+
),
|
|
397
|
+
);
|
|
398
|
+
process.exit(1);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── Step 8: Deploy security rules ───────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
console.log(
|
|
405
|
+
chalk.bold("\n📋 Step 8/12 — Deploying Firestore security rules...\n"),
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// Walk up from the relay directory to find firestore.rules in the project root.
|
|
409
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
410
|
+
const rulesPath = findFileUpwards("firestore.rules", join(__dirname, ".."));
|
|
411
|
+
|
|
412
|
+
if (rulesPath) {
|
|
413
|
+
console.log(chalk.dim(` Found rules at: ${rulesPath}`));
|
|
414
|
+
const rulesDir = dirname(rulesPath);
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
// Deploy rules using the directory containing firestore.rules.
|
|
418
|
+
runOrFail(
|
|
419
|
+
`firebase deploy --only firestore:rules --project ${projectId}`,
|
|
420
|
+
"Failed to deploy Firestore security rules.",
|
|
421
|
+
);
|
|
422
|
+
console.log(chalk.green(" ✓ Firestore security rules deployed"));
|
|
423
|
+
} catch (e) {
|
|
424
|
+
console.warn(chalk.yellow(` ⚠ Could not deploy rules automatically.`));
|
|
425
|
+
console.warn(chalk.dim(` ${e.message}`));
|
|
426
|
+
console.warn(
|
|
427
|
+
chalk.dim(
|
|
428
|
+
" You can deploy them manually: firebase deploy --only firestore:rules --project " +
|
|
429
|
+
projectId,
|
|
430
|
+
),
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
} else {
|
|
434
|
+
console.log(chalk.yellow(" ⚠ No firestore.rules file found — skipping."));
|
|
435
|
+
console.log(
|
|
436
|
+
chalk.dim(
|
|
437
|
+
" You can add rules later and deploy with: firebase deploy --only firestore:rules --project " +
|
|
438
|
+
projectId,
|
|
439
|
+
),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ── Step 9: Set up service account credentials ──────────────────────────
|
|
444
|
+
|
|
445
|
+
console.log(
|
|
446
|
+
chalk.bold("\n📋 Step 9/12 — Setting up service account credentials...\n"),
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const saKeyPath = join(forgeDir, "service-account.json");
|
|
450
|
+
|
|
451
|
+
// Ensure ~/.forge-remote directory exists.
|
|
452
|
+
if (!existsSync(forgeDir)) {
|
|
453
|
+
mkdirSync(forgeDir, { recursive: true });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let saReady = false;
|
|
457
|
+
|
|
458
|
+
if (existsSync(saKeyPath)) {
|
|
459
|
+
console.log(
|
|
460
|
+
chalk.green(` ✓ Service account key already exists at ${saKeyPath}`),
|
|
461
|
+
);
|
|
462
|
+
saReady = true;
|
|
463
|
+
} else {
|
|
464
|
+
// Try to find the service account email for this project.
|
|
465
|
+
let saEmail = null;
|
|
466
|
+
|
|
467
|
+
try {
|
|
468
|
+
const saListOutput = run(
|
|
469
|
+
`gcloud iam service-accounts list --project ${projectId} --format="value(email)" --filter="email:firebase-adminsdk"`,
|
|
470
|
+
{ silent: true },
|
|
471
|
+
);
|
|
472
|
+
if (saListOutput) {
|
|
473
|
+
saEmail = saListOutput.split("\n")[0].trim();
|
|
474
|
+
}
|
|
475
|
+
} catch {
|
|
476
|
+
// gcloud not available — fall through to manual instructions.
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (saEmail) {
|
|
480
|
+
// gcloud is available — try to generate the key automatically.
|
|
481
|
+
console.log(chalk.dim(` Found service account: ${saEmail}`));
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
runOrFail(
|
|
485
|
+
`gcloud iam service-accounts keys create "${saKeyPath}" --iam-account="${saEmail}" --project="${projectId}"`,
|
|
486
|
+
"Failed to create service account key.",
|
|
487
|
+
);
|
|
488
|
+
console.log(
|
|
489
|
+
chalk.green(` ✓ Service account key saved to ${saKeyPath}`),
|
|
490
|
+
);
|
|
491
|
+
saReady = true;
|
|
492
|
+
} catch (e) {
|
|
493
|
+
console.warn(chalk.yellow(" ⚠ Could not generate key automatically."));
|
|
494
|
+
console.warn(chalk.dim(` ${e.message}`));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!saReady) {
|
|
499
|
+
// Fallback: guide the user to download manually.
|
|
500
|
+
const consoleUrl = `https://console.firebase.google.com/project/${projectId}/settings/serviceaccounts/adminsdk`;
|
|
501
|
+
|
|
502
|
+
console.log(
|
|
503
|
+
chalk.yellow(" gcloud CLI not available or key generation failed."),
|
|
504
|
+
);
|
|
505
|
+
console.log(
|
|
506
|
+
chalk.yellow(" Please download the service account key manually:\n"),
|
|
507
|
+
);
|
|
508
|
+
console.log(chalk.bold(` 1. Open: ${chalk.cyan(consoleUrl)}`));
|
|
509
|
+
console.log(chalk.bold(' 2. Click "Generate new private key"'));
|
|
510
|
+
console.log(
|
|
511
|
+
chalk.bold(` 3. Save the file to: ${chalk.cyan(saKeyPath)}\n`),
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
console.log(
|
|
515
|
+
chalk.yellow(
|
|
516
|
+
` Save the key to ${chalk.cyan(saKeyPath)} and re-run, or run ${chalk.cyan("forge-remote start")} once the key is in place.\n`,
|
|
517
|
+
),
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ── Step 10: Register desktop in Firestore ──────────────────────────────
|
|
523
|
+
|
|
524
|
+
console.log(
|
|
525
|
+
chalk.bold("\n📋 Step 10/12 — Registering desktop in Firestore...\n"),
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
const desktopId = getDesktopId();
|
|
529
|
+
const platformName = getPlatformName();
|
|
530
|
+
const host = hostname();
|
|
531
|
+
|
|
532
|
+
if (saReady) {
|
|
533
|
+
try {
|
|
534
|
+
const serviceAccount = JSON.parse(readFileSync(saKeyPath, "utf-8"));
|
|
535
|
+
|
|
536
|
+
initializeApp({
|
|
537
|
+
credential: cert(serviceAccount),
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const db = getFirestore();
|
|
541
|
+
const desktopRef = db.collection("desktops").doc(desktopId);
|
|
542
|
+
const doc = await desktopRef.get();
|
|
543
|
+
|
|
544
|
+
if (doc.exists) {
|
|
545
|
+
await desktopRef.update({
|
|
546
|
+
status: "online",
|
|
547
|
+
lastHeartbeat: FieldValue.serverTimestamp(),
|
|
548
|
+
platform: platformName,
|
|
549
|
+
hostname: host,
|
|
550
|
+
});
|
|
551
|
+
} else {
|
|
552
|
+
await desktopRef.set({
|
|
553
|
+
ownerUid: "", // Will be set during pairing.
|
|
554
|
+
hostname: host,
|
|
555
|
+
platform: platformName,
|
|
556
|
+
status: "online",
|
|
557
|
+
lastHeartbeat: FieldValue.serverTimestamp(),
|
|
558
|
+
projects: [],
|
|
559
|
+
createdAt: FieldValue.serverTimestamp(),
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
console.log(
|
|
564
|
+
chalk.green(` ✓ Desktop registered: ${desktopId} (${host})`),
|
|
565
|
+
);
|
|
566
|
+
} catch (e) {
|
|
567
|
+
console.warn(
|
|
568
|
+
chalk.yellow(` ⚠ Could not register desktop: ${e.message}`),
|
|
569
|
+
);
|
|
570
|
+
console.warn(
|
|
571
|
+
chalk.dim(" The relay will register on first `forge-remote start`.\n"),
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
} else {
|
|
575
|
+
console.log(
|
|
576
|
+
chalk.yellow(
|
|
577
|
+
" ⚠ Skipping desktop registration (no service account key).",
|
|
578
|
+
),
|
|
579
|
+
);
|
|
580
|
+
console.log(
|
|
581
|
+
chalk.dim(" The relay will register on first `forge-remote start`.\n"),
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── Step 11: Build QR payload ───────────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
console.log(chalk.bold("\n📋 Step 11/12 — Building pairing QR code...\n"));
|
|
588
|
+
|
|
589
|
+
const qrPayload = {
|
|
590
|
+
v: 1,
|
|
591
|
+
firebase: {
|
|
592
|
+
apiKey: sdkConfig.apiKey,
|
|
593
|
+
projectId: sdkConfig.projectId,
|
|
594
|
+
appId: sdkConfig.appId,
|
|
595
|
+
messagingSenderId: sdkConfig.messagingSenderId,
|
|
596
|
+
storageBucket: sdkConfig.storageBucket,
|
|
597
|
+
},
|
|
598
|
+
desktop: {
|
|
599
|
+
id: desktopId,
|
|
600
|
+
hostname: host,
|
|
601
|
+
platform: platformName,
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const qrString = JSON.stringify(qrPayload);
|
|
606
|
+
|
|
607
|
+
// ── Step 12: Display QR code ────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
console.log(chalk.bold("\n📋 Step 12/12 — Displaying QR code...\n"));
|
|
610
|
+
|
|
611
|
+
console.log(
|
|
612
|
+
chalk.cyan(" Scan this QR code with the Forge Remote mobile app:\n"),
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
qrcode.generate(qrString, { small: true });
|
|
616
|
+
|
|
617
|
+
console.log(chalk.dim("\n Raw pairing data (for manual entry):\n"));
|
|
618
|
+
console.log(chalk.dim(` ${qrString}\n`));
|
|
619
|
+
|
|
620
|
+
// ── Success! ────────────────────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
const successLine = "═".repeat(50);
|
|
623
|
+
|
|
624
|
+
console.log(`\n${chalk.bold.green(successLine)}`);
|
|
625
|
+
console.log(chalk.bold.green(" ✓ FORGE REMOTE SETUP COMPLETE"));
|
|
626
|
+
console.log(`${chalk.bold.green(successLine)}\n`);
|
|
627
|
+
|
|
628
|
+
console.log(chalk.bold(" What's next:\n"));
|
|
629
|
+
console.log(` 1. Scan the QR code above with the Forge Remote app`);
|
|
630
|
+
console.log(` 2. Start the relay: ${chalk.cyan("forge-remote start")}`);
|
|
631
|
+
console.log(
|
|
632
|
+
` 3. Open a Claude Code session and watch it appear in the app!\n`,
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
console.log(chalk.dim(" Your Firebase project:"));
|
|
636
|
+
console.log(
|
|
637
|
+
chalk.dim(` https://console.firebase.google.com/project/${projectId}\n`),
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
console.log(
|
|
641
|
+
chalk.dim(
|
|
642
|
+
" Love Forge Remote? Support us: https://github.com/sponsors/AirForgeApps\n",
|
|
643
|
+
),
|
|
644
|
+
);
|
|
645
|
+
}
|