forge-remote 0.1.1 → 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/13 — 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/13 — 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"));
240
+
241
+ // ── Step 3: Create Firebase project ─────────────────────────────────────
174
242
 
175
- console.log(chalk.bold("\n📋 Step 3/13Generating project name...\n"));
243
+ console.log(chalk.bold("\n📋 Step 3/11Creating Firebase project...\n"));
176
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,14 +272,7 @@ 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/13 — Creating Firebase project...\n"));
206
-
207
- // Check if the project already exists by querying it directly.
208
- // `firebase apps:list` succeeds (exit 0) if the project exists, fails otherwise.
275
+ // Check if the project already exists.
209
276
  let projectExists = false;
210
277
  try {
211
278
  execSync(`firebase apps:list --project ${projectId}`, {
@@ -214,7 +281,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
214
281
  });
215
282
  projectExists = true;
216
283
  } catch {
217
- // Command failed — project doesn't exist or isn't accessible.
284
+ // Project doesn't exist or isn't accessible.
218
285
  }
219
286
 
220
287
  if (projectExists) {
@@ -223,12 +290,11 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
223
290
  );
224
291
  } else {
225
292
  try {
226
- const createResult = runOrFail(
293
+ runOrFail(
227
294
  `firebase projects:create ${projectId} --display-name "Forge Remote"`,
228
295
  "Failed to create Firebase project.",
229
296
  );
230
297
  console.log(chalk.green(` ✓ Firebase project created: ${projectId}`));
231
- console.log(chalk.dim(` ${createResult.split("\n").pop()}`));
232
298
  } catch (e) {
233
299
  const msg = e.message || "";
234
300
  if (
@@ -245,7 +311,8 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
245
311
  console.error(chalk.red(` ✗ ${e.message}`));
246
312
  console.error(
247
313
  chalk.dim(
248
- "\n If the project ID is taken by another account, re-run with a different name.\n",
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",
249
316
  ),
250
317
  );
251
318
  process.exit(1);
@@ -253,59 +320,64 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
253
320
  }
254
321
  }
255
322
 
256
- // ── Step 5: Create web app ──────────────────────────────────────────────
323
+ console.log(chalk.green(` ✓ Project ID: ${projectId}`));
324
+
325
+ // ── Step 4: Create web app + get SDK config ─────────────────────────────
257
326
 
258
- console.log(chalk.bold("\n📋 Step 5/13 — Creating web app...\n"));
327
+ console.log(
328
+ chalk.bold("\n📋 Step 4/11 — Creating web app & fetching SDK config...\n"),
329
+ );
259
330
 
260
331
  let appId = null;
261
332
 
333
+ // First, check if a web app already exists for this project.
262
334
  try {
263
- const createAppOutput = runOrFail(
264
- `firebase apps:create web "Forge Remote Mobile" --project ${projectId}`,
265
- "Failed to create web app.",
335
+ const appsList = runOrFail(
336
+ `firebase apps:list --project ${projectId}`,
337
+ "Failed to list apps.",
266
338
  );
267
339
 
268
- // Parse the app ID from the output.
269
- // The output typically contains "App ID: 1:xxxx:web:xxxx" or similar.
270
- const appIdMatch = createAppOutput.match(/(?:App ID|appId)[:\s]+(\S+)/i);
271
- if (appIdMatch) {
272
- appId = appIdMatch[1];
273
- }
274
- console.log(chalk.green(` ✓ Web app created`));
275
- } catch (e) {
276
- const msg = e.message || "";
277
- if (msg.includes("already exists") || msg.includes("ALREADY_EXISTS")) {
278
- console.log(
279
- chalk.yellow(" ⚠ Web app already exists — fetching existing app ID."),
280
- );
281
- } else {
282
- console.error(chalk.red(` ✗ ${e.message}`));
283
- 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
+ }
284
347
  }
348
+ } catch {
349
+ // Could not list apps — will try to create one.
285
350
  }
286
351
 
287
- // If we didn't parse the app ID from creation output, list apps to find it.
288
- if (!appId) {
352
+ if (appId) {
353
+ console.log(chalk.green(` Existing web app found: ${appId}`));
354
+ } else {
355
+ // No existing web app — create one.
289
356
  try {
290
- const appsList = runOrFail(
291
- `firebase apps:list --project ${projectId}`,
292
- "Failed to list apps.",
357
+ const createAppOutput = runOrFail(
358
+ `firebase apps:create web "Forge Remote Mobile" --project ${projectId}`,
359
+ "Failed to create web app.",
293
360
  );
294
361
 
295
- // Look for a WEB app ID in the table output.
296
- const lines = appsList.split("\n");
297
- for (const appLine of lines) {
298
- if (appLine.includes("WEB") || appLine.includes("web")) {
299
- const idMatch = appLine.match(/1:\d+:web:[a-f0-9]+/);
300
- if (idMatch) {
301
- appId = idMatch[0];
302
- break;
303
- }
304
- }
362
+ const appIdMatch = createAppOutput.match(/(?:App ID|appId)[:\s]+(\S+)/i);
363
+ if (appIdMatch) {
364
+ appId = appIdMatch[1];
305
365
  }
366
+ console.log(chalk.green(" ✓ Web app created"));
367
+ } catch (e) {
368
+ console.error(chalk.red(` ✗ ${e.message}`));
369
+ process.exit(1);
370
+ }
306
371
 
307
- if (!appId) {
308
- // 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");
309
381
  for (const appLine of lines) {
310
382
  const idMatch = appLine.match(/1:\d+:web:[a-f0-9]+/);
311
383
  if (idMatch) {
@@ -313,30 +385,25 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
313
385
  break;
314
386
  }
315
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);
316
392
  }
317
- } catch (e) {
318
- console.error(chalk.red(` ✗ Could not determine web app ID.`));
319
- console.error(chalk.dim(` ${e.message}\n`));
320
- process.exit(1);
321
393
  }
322
394
  }
323
395
 
324
396
  if (!appId) {
325
397
  console.error(chalk.red(" ✗ Could not determine web app ID."));
326
398
  console.error(
327
- chalk.dim(
328
- " Run `firebase apps:list --project " + projectId + "` to find it.\n",
329
- ),
399
+ chalk.dim(` Run: firebase apps:list --project ${projectId}\n`),
330
400
  );
331
401
  process.exit(1);
332
402
  }
333
403
 
334
404
  console.log(chalk.green(` ✓ App ID: ${appId}`));
335
405
 
336
- // ── Step 6: Get SDK config ──────────────────────────────────────────────
337
-
338
- console.log(chalk.bold("\n📋 Step 6/13 — Fetching SDK config...\n"));
339
-
406
+ // Get SDK config.
340
407
  let sdkConfig = {};
341
408
 
342
409
  try {
@@ -345,8 +412,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
345
412
  "Failed to get SDK config.",
346
413
  );
347
414
 
348
- // Parse the config — output may be JSON (quoted keys) or JS object (unquoted keys).
349
- // Try JSON.parse first, fall back to regex.
415
+ // Parse the config — output may be JSON or JS object.
350
416
  const jsonMatch = sdkOutput.match(/\{[\s\S]*\}/);
351
417
  if (jsonMatch) {
352
418
  try {
@@ -377,7 +443,6 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
377
443
  }
378
444
  }
379
445
 
380
- // Validate we got the critical fields.
381
446
  if (!sdkConfig.apiKey || !sdkConfig.projectId) {
382
447
  throw new Error(
383
448
  "Could not parse apiKey or projectId from SDK config output.",
@@ -403,229 +468,275 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
403
468
  process.exit(1);
404
469
  }
405
470
 
406
- // ── Step 7: Enable Firestore ────────────────────────────────────────────
471
+ // ── Step 5: Enable Firestore ────────────────────────────────────────────
407
472
 
408
- console.log(chalk.bold("\n📋 Step 7/13 — Enabling Firestore...\n"));
473
+ console.log(chalk.bold("\n📋 Step 5/11 — Enabling Firestore...\n"));
409
474
 
410
- try {
411
- runOrFail(
412
- `firebase firestore:databases:create default --project ${projectId} --location=nam5`,
413
- "Failed to enable Firestore.",
414
- );
415
- console.log(chalk.green(" ✓ Firestore enabled (nam5 — US multi-region)"));
416
- } catch (e) {
417
- const msg = e.message || "";
418
- if (
419
- msg.includes("already exists") ||
420
- msg.includes("ALREADY_EXISTS") ||
421
- msg.includes("database already exists")
422
- ) {
423
- console.log(chalk.yellow(" ⚠ Firestore already enabled — continuing."));
424
- } else if (msg.includes("billing") || msg.includes("403")) {
425
- console.warn(
426
- chalk.yellow(" Firestore requires billing or manual setup."),
427
- );
428
- console.warn(chalk.bold("\n Create Firestore manually:"));
429
- console.warn(
430
- chalk.cyan(
431
- ` https://console.firebase.google.com/project/${projectId}/firestore`,
432
- ),
433
- );
434
- console.warn(
435
- chalk.dim(
436
- ' → Click "Create database" nam5 (US) Start in test mode\n',
437
- ),
438
- );
439
- } else {
440
- console.warn(chalk.yellow(` ⚠ ${e.message}`));
441
- console.warn(
442
- chalk.dim("\n You may need to enable Firestore manually at:"),
443
- );
444
- console.warn(
445
- chalk.dim(
446
- ` https://console.firebase.google.com/project/${projectId}/firestore\n`,
447
- ),
448
- );
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
+ }
449
548
  }
450
549
  }
451
550
 
452
- // ── Step 8: Enable Anonymous Auth ──────────────────────────────────────
551
+ // ── Step 6: Enable Anonymous Authentication ─────────────────────────────
453
552
 
454
553
  console.log(
455
- chalk.bold("\n📋 Step 8/13 — Enabling Anonymous Authentication...\n"),
554
+ chalk.bold("\n📋 Step 6/11 — Enabling Anonymous Authentication...\n"),
456
555
  );
457
556
 
458
557
  try {
459
- // Use the Identity Toolkit REST API to enable anonymous sign-in.
460
- // This requires a gcloud access token.
461
- const gcToken = run("gcloud auth print-access-token", { silent: true });
462
- if (!gcToken) {
463
- throw new Error("gcloud CLI not available or not authenticated.");
464
- }
465
-
466
- const curlCmd = `curl -s -X PATCH "https://identitytoolkit.googleapis.com/admin/v2/projects/${projectId}/config?updateMask=signIn.anonymous.enabled" -H "Authorization: Bearer ${gcToken}" -H "Content-Type: application/json" -H "X-Goog-User-Project: ${projectId}" -d '{"signIn":{"anonymous":{"enabled":true}}}'`;
467
-
468
- const authResult = run(curlCmd, { silent: true });
469
- if (authResult && authResult.includes('"enabled": true')) {
470
- console.log(chalk.green(" ✓ Anonymous Authentication enabled"));
471
- } else if (authResult && authResult.includes("error")) {
472
- throw new Error(authResult);
473
- } else {
474
- console.log(chalk.green(" ✓ Anonymous Authentication enabled"));
475
- }
558
+ await enableAnonymousAuth(projectId);
559
+ console.log(chalk.green(" ✓ Anonymous Authentication enabled"));
560
+ results.anonymousAuthEnabled = true;
476
561
  } catch (e) {
477
- console.warn(
478
- chalk.yellow(" ⚠ Could not enable Anonymous Auth automatically."),
479
- );
480
- console.warn(chalk.dim(` ${e.message}`));
481
- console.warn(chalk.bold("\n Enable it manually:"));
482
- console.warn(
483
- chalk.dim(
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(
484
567
  ` https://console.firebase.google.com/project/${projectId}/authentication/providers`,
485
568
  ),
486
569
  );
487
- console.warn(chalk.dim(" → Click Anonymous → Enable → Save\n"));
570
+ console.error(chalk.dim(" → Click Anonymous → Enable → Save\n"));
488
571
  }
489
572
 
490
- // ── Step 9: Deploy security rules ───────────────────────────────────────
573
+ // ── Step 7: Install cloudflared tunnel binary ────────────────────────────
491
574
 
492
575
  console.log(
493
- chalk.bold("\n📋 Step 9/13Deploying Firestore security rules...\n"),
576
+ chalk.bold("\n📋 Step 7/11Installing cloudflared tunnel binary...\n"),
494
577
  );
495
578
 
496
- // Walk up from the relay directory to find firestore.rules in the project root.
497
- const __dirname = dirname(fileURLToPath(import.meta.url));
498
- const rulesPath = findFileUpwards("firestore.rules", join(__dirname, ".."));
499
-
500
- if (rulesPath) {
501
- console.log(chalk.dim(` Found rules at: ${rulesPath}`));
502
- const rulesDir = dirname(rulesPath);
503
-
504
- try {
505
- // Deploy rules using the directory containing firestore.rules.
506
- runOrFail(
507
- `firebase deploy --only firestore:rules --project ${projectId}`,
508
- "Failed to deploy Firestore security rules.",
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"),
509
587
  );
510
- console.log(chalk.green(" ✓ Firestore security rules deployed"));
511
- } catch (e) {
512
- console.warn(chalk.yellow(` ⚠ Could not deploy rules automatically.`));
513
- console.warn(chalk.dim(` ${e.message}`));
514
- console.warn(
515
- chalk.dim(
516
- " You can deploy them manually: firebase deploy --only firestore:rules --project " +
517
- projectId,
518
- ),
588
+ console.log(
589
+ chalk.dim(" Live preview tunnels may fall back to localtunnel"),
519
590
  );
520
591
  }
521
- } else {
522
- 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
+ );
596
+ console.log(
597
+ chalk.dim(
598
+ " Live preview will use localtunnel as fallback (may show splash page)",
599
+ ),
600
+ );
523
601
  console.log(
524
602
  chalk.dim(
525
- " You can add rules later and deploy with: firebase deploy --only firestore:rules --project " +
526
- projectId,
603
+ " Install manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n",
527
604
  ),
528
605
  );
529
606
  }
530
607
 
531
- // ── Step 10: Set up service account credentials ─────────────────────────
608
+ // ── Step 8: Service account key ─────────────────────────────────────────
532
609
 
533
610
  console.log(
534
- chalk.bold("\n📋 Step 10/13 — Setting up service account credentials...\n"),
611
+ chalk.bold("\n📋 Step 8/11 — Setting up service account credentials...\n"),
535
612
  );
536
613
 
537
614
  const saKeyPath = join(forgeDir, "service-account.json");
538
615
 
539
- // Ensure ~/.forge-remote directory exists.
540
- if (!existsSync(forgeDir)) {
541
- mkdirSync(forgeDir, { recursive: true });
542
- }
543
-
544
- let saReady = false;
545
-
546
616
  if (existsSync(saKeyPath)) {
547
- console.log(
548
- chalk.green(` ✓ Service account key already exists at ${saKeyPath}`),
549
- );
550
- saReady = true;
551
- } else {
552
- // Try to find the service account email for this project.
553
- let saEmail = null;
554
-
617
+ // Verify the existing key is for the correct project.
555
618
  try {
556
- const saListOutput = run(
557
- `gcloud iam service-accounts list --project ${projectId} --format="value(email)" --filter="email:firebase-adminsdk"`,
558
- { silent: true },
559
- );
560
- if (saListOutput) {
561
- 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;
562
634
  }
563
635
  } catch {
564
- // gcloud not available fall through to manual instructions.
636
+ // Can't parseregenerate.
637
+ console.log(chalk.yellow(" ⚠ Existing key is corrupt. Regenerating..."));
565
638
  }
639
+ }
566
640
 
567
- if (saEmail) {
568
- // gcloud is available — try to generate the key automatically.
569
- console.log(chalk.dim(` Found service account: ${saEmail}`));
570
-
571
- try {
572
- runOrFail(
573
- `gcloud iam service-accounts keys create "${saKeyPath}" --iam-account="${saEmail}" --project="${projectId}"`,
574
- "Failed to create service account key.",
575
- );
576
- console.log(
577
- 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.",
578
653
  );
579
- saReady = true;
580
- } catch (e) {
581
- console.warn(chalk.yellow(" ⚠ Could not generate key automatically."));
582
- console.warn(chalk.dim(` ${e.message}`));
583
654
  }
584
- }
655
+ console.log(chalk.dim(` Found: ${sa.email}`));
585
656
 
586
- if (!saReady) {
587
- // Fallback: guide the user to download manually.
588
- 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}`));
589
666
 
590
- console.log(
591
- chalk.yellow(" gcloud CLI not available or key generation failed."),
592
- );
593
- console.log(
594
- chalk.yellow(" Please download the service account key manually:\n"),
595
- );
596
- console.log(chalk.bold(` 1. Open: ${chalk.cyan(consoleUrl)}`));
597
- console.log(chalk.bold(' 2. Click "Generate new private key"'));
598
- 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(
599
672
  chalk.bold(` 3. Save the file to: ${chalk.cyan(saKeyPath)}\n`),
600
673
  );
674
+ }
675
+ }
601
676
 
602
- console.log(
603
- chalk.yellow(
604
- ` Save the key to ${chalk.cyan(saKeyPath)} and re-run, or run ${chalk.cyan("forge-remote start")} once the key is in place.\n`,
605
- ),
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.`,
606
692
  );
607
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
+ );
608
708
  }
609
709
 
610
- // ── Step 11: Register desktop in Firestore ──────────────────────────────
710
+ // ── Step 9: Register desktop + verify connection ────────────────────────
611
711
 
612
712
  console.log(
613
- chalk.bold("\n📋 Step 11/13 — Registering desktop in Firestore...\n"),
713
+ chalk.bold(
714
+ "\n📋 Step 10/11 — Registering desktop & verifying connection...\n",
715
+ ),
614
716
  );
615
717
 
616
- const desktopId = getDesktopId();
718
+ // Use cached desktop ID if available (stable across network changes).
719
+ const desktopId = cachedConfig.desktopId || getDesktopId();
617
720
  const platformName = getPlatformName();
618
721
  const host = hostname();
619
722
 
620
- if (saReady) {
723
+ if (results.serviceAccountKeyReady && results.firestoreEnabled) {
621
724
  try {
622
725
  const serviceAccount = JSON.parse(readFileSync(saKeyPath, "utf-8"));
623
726
 
624
- initializeApp({
625
- credential: cert(serviceAccount),
626
- });
627
-
727
+ initializeApp({ credential: cert(serviceAccount) });
628
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.
629
740
  const desktopRef = db.collection("desktops").doc(desktopId);
630
741
  const doc = await desktopRef.get();
631
742
 
@@ -638,7 +749,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
638
749
  });
639
750
  } else {
640
751
  await desktopRef.set({
641
- ownerUid: "", // Will be set during pairing.
752
+ ownerUid: null, // Will be set when the mobile app scans the QR code.
642
753
  hostname: host,
643
754
  platform: platformName,
644
755
  status: "online",
@@ -651,18 +762,21 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
651
762
  console.log(
652
763
  chalk.green(` ✓ Desktop registered: ${desktopId} (${host})`),
653
764
  );
765
+ results.desktopRegistered = true;
654
766
  } catch (e) {
655
- console.warn(
656
- chalk.yellow(` Could not register desktop: ${e.message}`),
767
+ console.error(
768
+ chalk.red(` Connection verification failed: ${e.message}`),
657
769
  );
658
- console.warn(
659
- 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
+ ),
660
774
  );
661
775
  }
662
776
  } else {
663
777
  console.log(
664
778
  chalk.yellow(
665
- " ⚠ Skipping desktop registration (no service account key).",
779
+ " ⚠ Skipping (missing service account key or Firestore not ready).",
666
780
  ),
667
781
  );
668
782
  console.log(
@@ -670,9 +784,79 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
670
784
  );
671
785
  }
672
786
 
673
- // ── Step 12: Build QR payload ───────────────────────────────────────────
787
+ // ── Step 10: QR code or failure summary ─────────────────────────────────
788
+
789
+ console.log(chalk.bold("\n📋 Step 11/11 — Finalizing...\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 ─────────────────
674
853
 
675
- console.log(chalk.bold("\n📋 Step 12/13 Building pairing QR code...\n"));
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
+ );
676
860
 
677
861
  const qrPayload = {
678
862
  v: 1,
@@ -692,10 +876,6 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
692
876
 
693
877
  const qrString = JSON.stringify(qrPayload);
694
878
 
695
- // ── Step 13: Display QR code ────────────────────────────────────────────
696
-
697
- console.log(chalk.bold("\n📋 Step 13/13 — Displaying QR code...\n"));
698
-
699
879
  console.log(
700
880
  chalk.cyan(" Scan this QR code with the Forge Remote mobile app:\n"),
701
881
  );
@@ -705,8 +885,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
705
885
  console.log(chalk.dim("\n Raw pairing data (for manual entry):\n"));
706
886
  console.log(chalk.dim(` ${qrString}\n`));
707
887
 
708
- // ── Success! ────────────────────────────────────────────────────────────
709
-
888
+ // Success banner.
710
889
  const successLine = "═".repeat(50);
711
890
 
712
891
  console.log(`\n${chalk.bold.green(successLine)}`);
@@ -714,10 +893,10 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
714
893
  console.log(`${chalk.bold.green(successLine)}\n`);
715
894
 
716
895
  console.log(chalk.bold(" What's next:\n"));
717
- 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");
718
897
  console.log(` 2. Start the relay: ${chalk.cyan("forge-remote start")}`);
719
898
  console.log(
720
- ` 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",
721
900
  );
722
901
 
723
902
  console.log(chalk.dim(" Your Firebase project:"));
@@ -727,7 +906,7 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
727
906
 
728
907
  console.log(
729
908
  chalk.dim(
730
- " Love Forge Remote? Support us: https://github.com/sponsors/AirForgeApps\n",
909
+ " Love Forge Remote? Support us: https://github.com/sponsors/IronForgeApps\n",
731
910
  ),
732
911
  );
733
912
  }