forge-remote 0.1.0 → 0.1.2

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/src/init.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // AGPL-3.0 License — See LICENSE
5
5
 
6
6
  import { execSync } from "child_process";
7
+ import { createInterface } from "readline";
7
8
  import { hostname, platform, homedir } from "os";
8
9
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
9
10
  import { join, dirname } from "path";
@@ -13,6 +14,19 @@ import { getFirestore, FieldValue } from "firebase-admin/firestore";
13
14
  import qrcode from "qrcode-terminal";
14
15
  import chalk from "chalk";
15
16
 
17
+ import {
18
+ readRefreshToken,
19
+ enableAnonymousAuth,
20
+ enableApi,
21
+ findFirebaseAdminSdkAccount,
22
+ createServiceAccountKey,
23
+ deployFirestoreRules,
24
+ } from "./google-auth.js";
25
+ import {
26
+ installCloudflared,
27
+ isCloudflaredWorking,
28
+ } from "./cloudflared-installer.js";
29
+
16
30
  // ─── Helpers ────────────────────────────────────────────────────────────────
17
31
 
18
32
  /**
@@ -81,16 +95,29 @@ function getDesktopId() {
81
95
  }
82
96
 
83
97
  /**
84
- * Walk up from a starting directory to find a file.
98
+ * Prompt the user and wait for Enter.
99
+ */
100
+ function waitForEnter(message) {
101
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
102
+ return new Promise((resolve) => {
103
+ rl.question(message, () => {
104
+ rl.close();
105
+ resolve();
106
+ });
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Open a URL in the default browser (best-effort, no throw).
85
112
  */
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;
113
+ function openBrowser(url) {
114
+ const p = platform();
115
+ try {
116
+ if (p === "darwin") execSync(`open "${url}"`, { stdio: "pipe" });
117
+ else if (p === "win32") execSync(`start "" "${url}"`, { stdio: "pipe" });
118
+ else execSync(`xdg-open "${url}"`, { stdio: "pipe" });
119
+ } catch {
120
+ // Silently fail — URL is always printed for manual opening.
94
121
  }
95
122
  }
96
123
 
@@ -98,51 +125,80 @@ function findFileUpwards(filename, startDir) {
98
125
 
99
126
  /**
100
127
  * Run the full Forge Remote initialization:
128
+ *
101
129
  * 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
130
+ * 2. Firebase login (+ verify refresh token)
131
+ * 3. Create Firebase project
132
+ * 4. Create web app + get SDK config
133
+ * 5. Enable Firestore (with Blaze plan guidance)
134
+ * 6. Enable Anonymous Authentication (via REST API, no gcloud)
135
+ * 7. Service account key (via REST API, no gcloud)
136
+ * 8. Deploy Firestore security rules (via REST API, bundled rules file)
137
+ * 9. Register desktop + verify connection
138
+ * 10. Show QR code (only if all critical steps passed)
113
139
  */
114
140
  export async function runInit({ projectId: overrideProjectId } = {}) {
115
141
  const forgeDir = join(homedir(), ".forge-remote");
142
+ const configPath = join(forgeDir, "config.json");
116
143
  const line = "═".repeat(50);
117
144
 
145
+ // Ensure ~/.forge-remote directory exists.
146
+ if (!existsSync(forgeDir)) {
147
+ mkdirSync(forgeDir, { recursive: true });
148
+ }
149
+
150
+ // Load cached config from a previous init (project ID, desktop ID).
151
+ // This prevents hostname changes (e.g. different Wi-Fi networks on macOS)
152
+ // from creating a brand-new Firebase project each time.
153
+ let cachedConfig = {};
154
+ if (existsSync(configPath)) {
155
+ try {
156
+ cachedConfig = JSON.parse(readFileSync(configPath, "utf-8"));
157
+ } catch {
158
+ // Corrupt file — will be overwritten at the end.
159
+ }
160
+ }
161
+
162
+ // Track critical step results — QR code is gated on ALL passing.
163
+ const results = {
164
+ firestoreEnabled: false,
165
+ anonymousAuthEnabled: false,
166
+ serviceAccountKeyReady: false,
167
+ rulesDeployed: false,
168
+ desktopRegistered: false,
169
+ };
170
+
118
171
  console.log(`\n${chalk.bold.cyan(line)}`);
119
172
  console.log(chalk.bold.cyan(" ⚡ FORGE REMOTE INIT"));
120
173
  console.log(`${chalk.bold.cyan(line)}\n`);
121
174
  console.log(
122
- chalk.dim(" This command will set up a Firebase project for Forge Remote"),
175
+ chalk.dim(
176
+ " This will set up a Firebase project for Forge Remote and generate",
177
+ ),
123
178
  );
124
179
  console.log(
125
- chalk.dim(" and generate a QR code to pair with the mobile app.\n"),
180
+ chalk.dim(" a QR code to pair with the mobile app. Fully automated.\n"),
126
181
  );
127
182
 
128
183
  // ── Step 1: Check Firebase CLI ──────────────────────────────────────────
129
184
 
130
- console.log(chalk.bold("\n📋 Step 1/12 — Checking Firebase CLI...\n"));
185
+ console.log(chalk.bold("\n📋 Step 1/11 — Checking Firebase CLI...\n"));
131
186
 
132
187
  const firebaseVersion = run("firebase --version", { silent: true });
133
188
  if (!firebaseVersion) {
134
- console.error(chalk.red("Firebase CLI not found."));
189
+ console.error(chalk.red("Firebase CLI not found.\n"));
190
+ console.error(chalk.bold(" Install it with:\n"));
191
+ console.error(chalk.cyan(" npm install -g firebase-tools\n"));
135
192
  console.error(
136
- chalk.yellow("Install it with:\n\n npm install -g firebase-tools\n"),
193
+ chalk.dim(" Then run this command again: forge-remote init\n"),
137
194
  );
138
- console.error(chalk.dim("Then run this command again: forge-remote init"));
139
195
  process.exit(1);
140
196
  }
141
197
  console.log(chalk.green(` ✓ Firebase CLI ${firebaseVersion}`));
142
198
 
143
- // ── Step 2: Check Firebase login ────────────────────────────────────────
199
+ // ── Step 2: Firebase login ──────────────────────────────────────────────
144
200
 
145
- console.log(chalk.bold("\n📋 Step 2/12 — Checking Firebase login...\n"));
201
+ console.log(chalk.bold("\n📋 Step 2/11 — Checking Firebase login...\n"));
146
202
 
147
203
  const loginList = run("firebase login:list", { silent: true });
148
204
  const isLoggedIn = loginList && !loginList.includes("No authorized accounts");
@@ -170,14 +226,32 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
170
226
  }
171
227
  console.log(chalk.green(" ✓ Logged into Firebase"));
172
228
 
173
- // ── Step 3: Generate project name ───────────────────────────────────────
229
+ // Verify the refresh token is available (needed for REST API calls later).
230
+ const refreshToken = readRefreshToken();
231
+ if (!refreshToken) {
232
+ console.error(
233
+ chalk.red(" ✗ Could not find Firebase CLI refresh token after login.\n"),
234
+ );
235
+ console.error(chalk.dim(" Try logging out and back in:"));
236
+ console.error(chalk.dim(" firebase logout && firebase login\n"));
237
+ process.exit(1);
238
+ }
239
+ console.log(chalk.green(" ✓ Auth token verified"));
174
240
 
175
- console.log(chalk.bold("\n📋 Step 3/12 Generating project name...\n"));
241
+ // ── Step 3: Create Firebase project ─────────────────────────────────────
176
242
 
243
+ console.log(chalk.bold("\n📋 Step 3/11 — Creating Firebase project...\n"));
244
+
245
+ // Priority: 1) CLI override, 2) cached from previous init, 3) hostname-based.
177
246
  const defaultProjectId = `forge-remote-${sanitizedHostname()}`;
178
- const projectId = overrideProjectId || defaultProjectId;
247
+ const projectId =
248
+ overrideProjectId || cachedConfig.projectId || defaultProjectId;
179
249
  console.log(chalk.dim(` Using project ID: ${projectId}`));
180
- if (!overrideProjectId) {
250
+ if (cachedConfig.projectId && !overrideProjectId) {
251
+ console.log(
252
+ chalk.dim(` (Cached from previous init — stable across networks)`),
253
+ );
254
+ } else if (!overrideProjectId) {
181
255
  console.log(
182
256
  chalk.dim(` (Override with: forge-remote init --project-id <id>)`),
183
257
  );
@@ -198,93 +272,112 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
198
272
  process.exit(1);
199
273
  }
200
274
 
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
-
275
+ // Check if the project already exists.
276
+ let projectExists = false;
207
277
  try {
208
- const createResult = runOrFail(
209
- `firebase projects:create ${projectId} --display-name "Forge Remote"`,
210
- "Failed to create Firebase project.",
278
+ execSync(`firebase apps:list --project ${projectId}`, {
279
+ encoding: "utf-8",
280
+ stdio: "pipe",
281
+ });
282
+ projectExists = true;
283
+ } catch {
284
+ // Project doesn't exist or isn't accessible.
285
+ }
286
+
287
+ if (projectExists) {
288
+ console.log(
289
+ chalk.yellow(` ⚠ Project "${projectId}" already exists — reusing it.`),
211
290
  );
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
- ),
291
+ } else {
292
+ try {
293
+ runOrFail(
294
+ `firebase projects:create ${projectId} --display-name "Forge Remote"`,
295
+ "Failed to create Firebase project.",
230
296
  );
231
- process.exit(1);
297
+ console.log(chalk.green(` ✓ Firebase project created: ${projectId}`));
298
+ } catch (e) {
299
+ const msg = e.message || "";
300
+ if (
301
+ msg.includes("already exists") ||
302
+ msg.includes("ALREADY_EXISTS") ||
303
+ msg.includes("already being used")
304
+ ) {
305
+ console.log(
306
+ chalk.yellow(
307
+ ` ⚠ Project "${projectId}" already exists — reusing it.`,
308
+ ),
309
+ );
310
+ } else {
311
+ console.error(chalk.red(` ✗ ${e.message}`));
312
+ console.error(
313
+ chalk.dim(
314
+ "\n If the project ID is taken by another account, re-run with:\n" +
315
+ " forge-remote init --project-id <your-custom-id>\n",
316
+ ),
317
+ );
318
+ process.exit(1);
319
+ }
232
320
  }
233
321
  }
234
322
 
235
- // ── Step 5: Create web app ──────────────────────────────────────────────
323
+ console.log(chalk.green(` ✓ Project ID: ${projectId}`));
236
324
 
237
- console.log(chalk.bold("\n📋 Step 5/12 Creating web app...\n"));
325
+ // ── Step 4: Create web app + get SDK config ─────────────────────────────
326
+
327
+ console.log(
328
+ chalk.bold("\n📋 Step 4/11 — Creating web app & fetching SDK config...\n"),
329
+ );
238
330
 
239
331
  let appId = null;
240
332
 
333
+ // First, check if a web app already exists for this project.
241
334
  try {
242
- const createAppOutput = runOrFail(
243
- `firebase apps:create web "Forge Remote Mobile" --project ${projectId}`,
244
- "Failed to create web app.",
335
+ const appsList = runOrFail(
336
+ `firebase apps:list --project ${projectId}`,
337
+ "Failed to list apps.",
245
338
  );
246
339
 
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);
340
+ const lines = appsList.split("\n");
341
+ for (const appLine of lines) {
342
+ const idMatch = appLine.match(/1:\d+:web:[a-f0-9]+/);
343
+ if (idMatch) {
344
+ appId = idMatch[0];
345
+ break;
346
+ }
263
347
  }
348
+ } catch {
349
+ // Could not list apps — will try to create one.
264
350
  }
265
351
 
266
- // If we didn't parse the app ID from creation output, list apps to find it.
267
- if (!appId) {
352
+ if (appId) {
353
+ console.log(chalk.green(` Existing web app found: ${appId}`));
354
+ } else {
355
+ // No existing web app — create one.
268
356
  try {
269
- const appsList = runOrFail(
270
- `firebase apps:list --project ${projectId}`,
271
- "Failed to list apps.",
357
+ const createAppOutput = runOrFail(
358
+ `firebase apps:create web "Forge Remote Mobile" --project ${projectId}`,
359
+ "Failed to create web app.",
272
360
  );
273
361
 
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
- }
362
+ const appIdMatch = createAppOutput.match(/(?:App ID|appId)[:\s]+(\S+)/i);
363
+ if (appIdMatch) {
364
+ appId = appIdMatch[1];
284
365
  }
366
+ console.log(chalk.green(" ✓ Web app created"));
367
+ } catch (e) {
368
+ console.error(chalk.red(` ✗ ${e.message}`));
369
+ process.exit(1);
370
+ }
285
371
 
286
- if (!appId) {
287
- // Try a broader pattern — any app ID in the list.
372
+ // If we still don't have the app ID, list apps to find it.
373
+ if (!appId) {
374
+ try {
375
+ const appsList = runOrFail(
376
+ `firebase apps:list --project ${projectId}`,
377
+ "Failed to list apps.",
378
+ );
379
+
380
+ const lines = appsList.split("\n");
288
381
  for (const appLine of lines) {
289
382
  const idMatch = appLine.match(/1:\d+:web:[a-f0-9]+/);
290
383
  if (idMatch) {
@@ -292,30 +385,25 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
292
385
  break;
293
386
  }
294
387
  }
388
+ } catch (e) {
389
+ console.error(chalk.red(" ✗ Could not determine web app ID."));
390
+ console.error(chalk.dim(` ${e.message}\n`));
391
+ process.exit(1);
295
392
  }
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
393
  }
301
394
  }
302
395
 
303
396
  if (!appId) {
304
397
  console.error(chalk.red(" ✗ Could not determine web app ID."));
305
398
  console.error(
306
- chalk.dim(
307
- " Run `firebase apps:list --project " + projectId + "` to find it.\n",
308
- ),
399
+ chalk.dim(` Run: firebase apps:list --project ${projectId}\n`),
309
400
  );
310
401
  process.exit(1);
311
402
  }
312
403
 
313
404
  console.log(chalk.green(` ✓ App ID: ${appId}`));
314
405
 
315
- // ── Step 6: Get SDK config ──────────────────────────────────────────────
316
-
317
- console.log(chalk.bold("\n📋 Step 6/12 — Fetching SDK config...\n"));
318
-
406
+ // Get SDK config.
319
407
  let sdkConfig = {};
320
408
 
321
409
  try {
@@ -324,24 +412,37 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
324
412
  "Failed to get SDK config.",
325
413
  );
326
414
 
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.
415
+ // Parse the config output may be JSON or JS object.
416
+ const jsonMatch = sdkOutput.match(/\{[\s\S]*\}/);
417
+ if (jsonMatch) {
418
+ try {
419
+ const parsed = JSON.parse(jsonMatch[0]);
420
+ sdkConfig = {
421
+ apiKey: parsed.apiKey || null,
422
+ authDomain: parsed.authDomain || null,
423
+ projectId: parsed.projectId || null,
424
+ storageBucket: parsed.storageBucket || null,
425
+ messagingSenderId: parsed.messagingSenderId || null,
426
+ appId: parsed.appId || null,
427
+ };
428
+ } catch {
429
+ // Not valid JSON — fall back to regex.
430
+ const parseField = (field) => {
431
+ const regex = new RegExp(`"?${field}"?:\\s*"([^"]+)"`);
432
+ const match = sdkOutput.match(regex);
433
+ return match ? match[1] : null;
434
+ };
435
+ sdkConfig = {
436
+ apiKey: parseField("apiKey"),
437
+ authDomain: parseField("authDomain"),
438
+ projectId: parseField("projectId"),
439
+ storageBucket: parseField("storageBucket"),
440
+ messagingSenderId: parseField("messagingSenderId"),
441
+ appId: parseField("appId"),
442
+ };
443
+ }
444
+ }
445
+
345
446
  if (!sdkConfig.apiKey || !sdkConfig.projectId) {
346
447
  throw new Error(
347
448
  "Could not parse apiKey or projectId from SDK config output.",
@@ -367,177 +468,275 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
367
468
  process.exit(1);
368
469
  }
369
470
 
370
- // ── Step 7: Enable Firestore ────────────────────────────────────────────
471
+ // ── Step 5: Enable Firestore ────────────────────────────────────────────
371
472
 
372
- console.log(chalk.bold("\n📋 Step 7/12 — Enabling Firestore...\n"));
473
+ console.log(chalk.bold("\n📋 Step 5/11 — Enabling Firestore...\n"));
373
474
 
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);
475
+ // First check if Firestore already exists by listing databases.
476
+ const existingDbs = run(
477
+ `firebase firestore:databases:list --project ${projectId}`,
478
+ { silent: true },
479
+ );
480
+
481
+ if (existingDbs && existingDbs.includes("(default)")) {
482
+ console.log(chalk.yellow(" ⚠ Firestore already enabled continuing."));
483
+ results.firestoreEnabled = true;
484
+ } else {
485
+ const maxFirestoreRetries = 2;
486
+
487
+ for (let attempt = 0; attempt < maxFirestoreRetries; attempt++) {
488
+ try {
489
+ runOrFail(
490
+ `firebase firestore:databases:create default --project ${projectId} --location=nam5`,
491
+ "Failed to enable Firestore.",
492
+ );
493
+ console.log(
494
+ chalk.green(" ✓ Firestore enabled (nam5 — US multi-region)"),
495
+ );
496
+ results.firestoreEnabled = true;
497
+ break;
498
+ } catch (e) {
499
+ const msg = e.message || "";
500
+ // Also capture stdout/stderr from the original exec error for detection.
501
+ const fullOutput = [msg, e.stdout || "", e.stderr || ""].join("\n");
502
+
503
+ if (
504
+ fullOutput.includes("already exists") ||
505
+ fullOutput.includes("ALREADY_EXISTS") ||
506
+ fullOutput.includes("database already exists")
507
+ ) {
508
+ console.log(
509
+ chalk.yellow(" ⚠ Firestore already enabled — continuing."),
510
+ );
511
+ results.firestoreEnabled = true;
512
+ break;
513
+ }
514
+
515
+ if (
516
+ (fullOutput.includes("billing") ||
517
+ fullOutput.includes("FAILED_PRECONDITION") ||
518
+ fullOutput.includes("403")) &&
519
+ attempt === 0
520
+ ) {
521
+ // First attempt failed due to billing — guide the user through upgrading.
522
+ console.log(
523
+ chalk.yellow(
524
+ "\n Firestore requires the Blaze (pay-as-you-go) plan.",
525
+ ),
526
+ );
527
+ console.log(
528
+ chalk.yellow(
529
+ " The Blaze plan has a generous free tier — you likely won't be charged.\n",
530
+ ),
531
+ );
532
+
533
+ const billingUrl = `https://console.firebase.google.com/project/${projectId}/usage/details`;
534
+ console.log(chalk.bold(` Opening: ${chalk.cyan(billingUrl)}\n`));
535
+ openBrowser(billingUrl);
536
+
537
+ await waitForEnter(
538
+ chalk.bold(" Press Enter after upgrading to Blaze plan... "),
539
+ );
540
+ console.log(chalk.dim("\n Retrying Firestore creation...\n"));
541
+ continue;
542
+ }
543
+
544
+ // Final attempt failed.
545
+ console.error(chalk.red(` ✗ ${e.message}`));
546
+ break;
547
+ }
399
548
  }
400
549
  }
401
550
 
402
- // ── Step 8: Deploy security rules ───────────────────────────────────────
551
+ // ── Step 6: Enable Anonymous Authentication ─────────────────────────────
403
552
 
404
553
  console.log(
405
- chalk.bold("\n📋 Step 8/12Deploying Firestore security rules...\n"),
554
+ chalk.bold("\n📋 Step 6/11Enabling Anonymous Authentication...\n"),
406
555
  );
407
556
 
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, ".."));
557
+ try {
558
+ await enableAnonymousAuth(projectId);
559
+ console.log(chalk.green(" Anonymous Authentication enabled"));
560
+ results.anonymousAuthEnabled = true;
561
+ } catch (e) {
562
+ console.error(chalk.red(` ✗ Could not enable Anonymous Auth.`));
563
+ console.error(chalk.dim(` ${e.message}\n`));
564
+ console.error(chalk.bold(" Enable it manually:"));
565
+ console.error(
566
+ chalk.cyan(
567
+ ` https://console.firebase.google.com/project/${projectId}/authentication/providers`,
568
+ ),
569
+ );
570
+ console.error(chalk.dim(" → Click Anonymous → Enable → Save\n"));
571
+ }
411
572
 
412
- if (rulesPath) {
413
- console.log(chalk.dim(` Found rules at: ${rulesPath}`));
414
- const rulesDir = dirname(rulesPath);
573
+ // ── Step 7: Install cloudflared tunnel binary ────────────────────────────
415
574
 
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.",
575
+ console.log(
576
+ chalk.bold("\n📋 Step 7/11 Installing cloudflared tunnel binary...\n"),
577
+ );
578
+
579
+ try {
580
+ const cfPath = await installCloudflared();
581
+ console.log(chalk.green(` ✓ cloudflared installed at ${cfPath}`));
582
+ if (isCloudflaredWorking(cfPath)) {
583
+ console.log(chalk.green(" ✓ cloudflared verified and functional"));
584
+ } else {
585
+ console.log(
586
+ chalk.yellow(" ⚠ cloudflared installed but verification failed"),
421
587
  );
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
- ),
588
+ console.log(
589
+ chalk.dim(" Live preview tunnels may fall back to localtunnel"),
431
590
  );
432
591
  }
433
- } else {
434
- console.log(chalk.yellow(" ⚠ No firestore.rules file found — skipping."));
592
+ } catch (e) {
593
+ console.log(
594
+ chalk.yellow(` ⚠ Could not install cloudflared: ${e.message}`),
595
+ );
435
596
  console.log(
436
597
  chalk.dim(
437
- " You can add rules later and deploy with: firebase deploy --only firestore:rules --project " +
438
- projectId,
598
+ " Live preview will use localtunnel as fallback (may show splash page)",
599
+ ),
600
+ );
601
+ console.log(
602
+ chalk.dim(
603
+ " Install manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n",
439
604
  ),
440
605
  );
441
606
  }
442
607
 
443
- // ── Step 9: Set up service account credentials ──────────────────────────
608
+ // ── Step 8: Service account key ─────────────────────────────────────────
444
609
 
445
610
  console.log(
446
- chalk.bold("\n📋 Step 9/12 — Setting up service account credentials...\n"),
611
+ chalk.bold("\n📋 Step 8/11 — Setting up service account credentials...\n"),
447
612
  );
448
613
 
449
614
  const saKeyPath = join(forgeDir, "service-account.json");
450
615
 
451
- // Ensure ~/.forge-remote directory exists.
452
- if (!existsSync(forgeDir)) {
453
- mkdirSync(forgeDir, { recursive: true });
454
- }
455
-
456
- let saReady = false;
457
-
458
616
  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
-
617
+ // Verify the existing key is for the correct project.
467
618
  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();
619
+ const existingKey = JSON.parse(readFileSync(saKeyPath, "utf-8"));
620
+ if (existingKey.project_id && existingKey.project_id !== projectId) {
621
+ console.log(
622
+ chalk.yellow(
623
+ ` ⚠ Existing key is for project "${existingKey.project_id}", not "${projectId}".`,
624
+ ),
625
+ );
626
+ console.log(
627
+ chalk.yellow(" Generating a new key for the correct project..."),
628
+ );
629
+ } else {
630
+ console.log(
631
+ chalk.green(` ✓ Service account key already exists at ${saKeyPath}`),
632
+ );
633
+ results.serviceAccountKeyReady = true;
474
634
  }
475
635
  } catch {
476
- // gcloud not available fall through to manual instructions.
636
+ // Can't parseregenerate.
637
+ console.log(chalk.yellow(" ⚠ Existing key is corrupt. Regenerating..."));
477
638
  }
639
+ }
478
640
 
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}`),
641
+ if (!results.serviceAccountKeyReady) {
642
+ try {
643
+ // Enable the IAM API (required to list/create service account keys).
644
+ console.log(chalk.dim(" Enabling IAM API (if not already enabled)..."));
645
+ await enableApi(projectId, "iam.googleapis.com");
646
+ console.log(chalk.green(" ✓ IAM API enabled"));
647
+
648
+ console.log(chalk.dim(" Finding Firebase Admin SDK service account..."));
649
+ const sa = await findFirebaseAdminSdkAccount(projectId);
650
+ if (!sa) {
651
+ throw new Error(
652
+ "No firebase-adminsdk service account found for this project.",
490
653
  );
491
- saReady = true;
492
- } catch (e) {
493
- console.warn(chalk.yellow(" ⚠ Could not generate key automatically."));
494
- console.warn(chalk.dim(` ${e.message}`));
495
654
  }
496
- }
655
+ console.log(chalk.dim(` Found: ${sa.email}`));
497
656
 
498
- if (!saReady) {
499
- // Fallback: guide the user to download manually.
500
- const consoleUrl = `https://console.firebase.google.com/project/${projectId}/settings/serviceaccounts/adminsdk`;
657
+ console.log(chalk.dim(" Generating private key..."));
658
+ const keyJson = await createServiceAccountKey(projectId, sa.email);
659
+ writeFileSync(saKeyPath, JSON.stringify(keyJson, null, 2), {
660
+ mode: 0o600,
661
+ });
662
+ console.log(chalk.green(` ✓ Service account key saved to ${saKeyPath}`));
663
+ results.serviceAccountKeyReady = true;
664
+ } catch (e) {
665
+ console.error(chalk.red(` ✗ ${e.message}`));
501
666
 
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(
667
+ const consoleUrl = `https://console.firebase.google.com/project/${projectId}/settings/serviceaccounts/adminsdk`;
668
+ console.error(chalk.bold("\n Download the key manually:"));
669
+ console.error(chalk.bold(` 1. Open: ${chalk.cyan(consoleUrl)}`));
670
+ console.error(chalk.bold(' 2. Click "Generate new private key"'));
671
+ console.error(
511
672
  chalk.bold(` 3. Save the file to: ${chalk.cyan(saKeyPath)}\n`),
512
673
  );
674
+ }
675
+ }
513
676
 
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
- ),
677
+ // ── Step 8: Deploy Firestore security rules ─────────────────────────────
678
+
679
+ console.log(
680
+ chalk.bold("\n📋 Step 9/11 — Deploying Firestore security rules...\n"),
681
+ );
682
+
683
+ try {
684
+ // Read the bundled rules file from the package directory.
685
+ const __dirname = dirname(fileURLToPath(import.meta.url));
686
+ const packageRoot = join(__dirname, "..");
687
+ const rulesPath = join(packageRoot, "firestore.rules");
688
+
689
+ if (!existsSync(rulesPath)) {
690
+ throw new Error(
691
+ `Rules file not found at ${rulesPath}. Package may be corrupted.`,
518
692
  );
519
693
  }
694
+
695
+ const rulesContent = readFileSync(rulesPath, "utf-8");
696
+ console.log(chalk.dim(" Deploying rules via Firebase Rules API..."));
697
+
698
+ await deployFirestoreRules(projectId, rulesContent);
699
+ console.log(chalk.green(" ✓ Firestore security rules deployed"));
700
+ results.rulesDeployed = true;
701
+ } catch (e) {
702
+ console.error(chalk.red(` ✗ Could not deploy rules: ${e.message}`));
703
+ console.error(
704
+ chalk.dim(
705
+ ` You can deploy manually: firebase deploy --only firestore:rules --project ${projectId}\n`,
706
+ ),
707
+ );
520
708
  }
521
709
 
522
- // ── Step 10: Register desktop in Firestore ──────────────────────────────
710
+ // ── Step 9: Register desktop + verify connection ────────────────────────
523
711
 
524
712
  console.log(
525
- chalk.bold("\n📋 Step 10/12 — Registering desktop in Firestore...\n"),
713
+ chalk.bold(
714
+ "\n📋 Step 10/11 — Registering desktop & verifying connection...\n",
715
+ ),
526
716
  );
527
717
 
528
- const desktopId = getDesktopId();
718
+ // Use cached desktop ID if available (stable across network changes).
719
+ const desktopId = cachedConfig.desktopId || getDesktopId();
529
720
  const platformName = getPlatformName();
530
721
  const host = hostname();
531
722
 
532
- if (saReady) {
723
+ if (results.serviceAccountKeyReady && results.firestoreEnabled) {
533
724
  try {
534
725
  const serviceAccount = JSON.parse(readFileSync(saKeyPath, "utf-8"));
535
726
 
536
- initializeApp({
537
- credential: cert(serviceAccount),
538
- });
539
-
727
+ initializeApp({ credential: cert(serviceAccount) });
540
728
  const db = getFirestore();
729
+
730
+ // Verification: write, read, delete a test document.
731
+ console.log(chalk.dim(" Verifying Firestore connection..."));
732
+ const testDocRef = db.collection("_forge_remote_init_test").doc();
733
+ await testDocRef.set({ test: true, ts: FieldValue.serverTimestamp() });
734
+ const snap = await testDocRef.get();
735
+ if (!snap.exists) throw new Error("Verification read failed.");
736
+ await testDocRef.delete();
737
+ console.log(chalk.green(" ✓ Firestore connection verified"));
738
+
739
+ // Register the desktop.
541
740
  const desktopRef = db.collection("desktops").doc(desktopId);
542
741
  const doc = await desktopRef.get();
543
742
 
@@ -550,7 +749,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
550
749
  });
551
750
  } else {
552
751
  await desktopRef.set({
553
- ownerUid: "", // Will be set during pairing.
752
+ ownerUid: null, // Will be set when the mobile app scans the QR code.
554
753
  hostname: host,
555
754
  platform: platformName,
556
755
  status: "online",
@@ -563,18 +762,21 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
563
762
  console.log(
564
763
  chalk.green(` ✓ Desktop registered: ${desktopId} (${host})`),
565
764
  );
765
+ results.desktopRegistered = true;
566
766
  } catch (e) {
567
- console.warn(
568
- chalk.yellow(` Could not register desktop: ${e.message}`),
767
+ console.error(
768
+ chalk.red(` Connection verification failed: ${e.message}`),
569
769
  );
570
- console.warn(
571
- chalk.dim(" The relay will register on first `forge-remote start`.\n"),
770
+ console.error(
771
+ chalk.dim(
772
+ " The relay will attempt to register on first `forge-remote start`.\n",
773
+ ),
572
774
  );
573
775
  }
574
776
  } else {
575
777
  console.log(
576
778
  chalk.yellow(
577
- " ⚠ Skipping desktop registration (no service account key).",
779
+ " ⚠ Skipping (missing service account key or Firestore not ready).",
578
780
  ),
579
781
  );
580
782
  console.log(
@@ -582,9 +784,79 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
582
784
  );
583
785
  }
584
786
 
585
- // ── Step 11: Build QR payload ───────────────────────────────────────────
787
+ // ── Step 10: QR code or failure summary ─────────────────────────────────
586
788
 
587
- console.log(chalk.bold("\n📋 Step 11/12Building pairing QR code...\n"));
789
+ console.log(chalk.bold("\n📋 Step 11/11Finalizing...\n"));
790
+
791
+ const CRITICAL_STEPS = [
792
+ "firestoreEnabled",
793
+ "anonymousAuthEnabled",
794
+ "serviceAccountKeyReady",
795
+ "rulesDeployed",
796
+ "desktopRegistered",
797
+ ];
798
+
799
+ const stepLabels = {
800
+ firestoreEnabled: {
801
+ label: "Firestore database",
802
+ fix: `https://console.firebase.google.com/project/${projectId}/firestore`,
803
+ },
804
+ anonymousAuthEnabled: {
805
+ label: "Anonymous Authentication",
806
+ fix: `https://console.firebase.google.com/project/${projectId}/authentication/providers`,
807
+ },
808
+ serviceAccountKeyReady: {
809
+ label: "Service account key",
810
+ fix: `https://console.firebase.google.com/project/${projectId}/settings/serviceaccounts/adminsdk`,
811
+ },
812
+ rulesDeployed: {
813
+ label: "Firestore security rules",
814
+ fix: `firebase deploy --only firestore:rules --project ${projectId}`,
815
+ },
816
+ desktopRegistered: {
817
+ label: "Desktop registration",
818
+ fix: "Will auto-register on first `forge-remote start`",
819
+ },
820
+ };
821
+
822
+ const failures = CRITICAL_STEPS.filter((step) => !results[step]);
823
+
824
+ if (failures.length > 0) {
825
+ // ── FAILURE — show summary and do NOT show QR code ──────────────────
826
+
827
+ const failLine = "═".repeat(50);
828
+ console.log(`\n${chalk.bold.red(failLine)}`);
829
+ console.log(
830
+ chalk.bold.red(" SETUP INCOMPLETE — Some steps need attention"),
831
+ );
832
+ console.log(`${chalk.bold.red(failLine)}\n`);
833
+
834
+ for (const step of CRITICAL_STEPS) {
835
+ const info = stepLabels[step];
836
+ if (results[step]) {
837
+ console.log(chalk.green(` ✓ ${info.label}`));
838
+ } else {
839
+ console.log(chalk.red(` ✗ ${info.label}`));
840
+ console.log(chalk.dim(` → ${info.fix}`));
841
+ }
842
+ }
843
+
844
+ console.log(
845
+ chalk.bold(
846
+ `\n Fix the issues above, then re-run: ${chalk.cyan("forge-remote init")}\n`,
847
+ ),
848
+ );
849
+ process.exit(1);
850
+ }
851
+
852
+ // ── SUCCESS — all critical steps passed, show QR code ─────────────────
853
+
854
+ // Persist project ID and desktop ID so re-runs (even on different networks)
855
+ // always target the same Firebase project and desktop document.
856
+ writeFileSync(configPath, JSON.stringify({ projectId, desktopId }, null, 2));
857
+ console.log(
858
+ chalk.dim(` Config saved to ${configPath} (stable across networks)`),
859
+ );
588
860
 
589
861
  const qrPayload = {
590
862
  v: 1,
@@ -604,10 +876,6 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
604
876
 
605
877
  const qrString = JSON.stringify(qrPayload);
606
878
 
607
- // ── Step 12: Display QR code ────────────────────────────────────────────
608
-
609
- console.log(chalk.bold("\n📋 Step 12/12 — Displaying QR code...\n"));
610
-
611
879
  console.log(
612
880
  chalk.cyan(" Scan this QR code with the Forge Remote mobile app:\n"),
613
881
  );
@@ -617,8 +885,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
617
885
  console.log(chalk.dim("\n Raw pairing data (for manual entry):\n"));
618
886
  console.log(chalk.dim(` ${qrString}\n`));
619
887
 
620
- // ── Success! ────────────────────────────────────────────────────────────
621
-
888
+ // Success banner.
622
889
  const successLine = "═".repeat(50);
623
890
 
624
891
  console.log(`\n${chalk.bold.green(successLine)}`);
@@ -626,10 +893,10 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
626
893
  console.log(`${chalk.bold.green(successLine)}\n`);
627
894
 
628
895
  console.log(chalk.bold(" What's next:\n"));
629
- console.log(` 1. Scan the QR code above with the Forge Remote app`);
896
+ console.log(" 1. Scan the QR code above with the Forge Remote app");
630
897
  console.log(` 2. Start the relay: ${chalk.cyan("forge-remote start")}`);
631
898
  console.log(
632
- ` 3. Open a Claude Code session and watch it appear in the app!\n`,
899
+ " 3. Open a Claude Code session and watch it appear in the app!\n",
633
900
  );
634
901
 
635
902
  console.log(chalk.dim(" Your Firebase project:"));
@@ -639,7 +906,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
639
906
 
640
907
  console.log(
641
908
  chalk.dim(
642
- " Love Forge Remote? Support us: https://github.com/sponsors/AirForgeApps\n",
909
+ " Love Forge Remote? Support us: https://github.com/sponsors/IronForgeApps\n",
643
910
  ),
644
911
  );
645
912
  }