forge-remote 0.1.4 → 0.1.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-remote",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "description": "Desktop relay for Forge Remote — monitor and control Claude Code sessions from your phone",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -80,6 +80,47 @@ export async function getAccessToken() {
80
80
  return data.access_token;
81
81
  }
82
82
 
83
+ // ─── Web App SDK Config ─────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Fetch the Firebase web app SDK config via REST API.
87
+ * This bypasses the `firebase apps:sdkconfig` CLI command, which sometimes
88
+ * returns 403 on newly created projects.
89
+ *
90
+ * Returns an object with apiKey, authDomain, projectId, storageBucket,
91
+ * messagingSenderId, and appId — or throws on failure.
92
+ */
93
+ export async function getWebAppConfig(projectId, appId) {
94
+ const token = await getAccessToken();
95
+
96
+ const response = await fetch(
97
+ `https://firebase.googleapis.com/v1beta1/projects/${projectId}/webApps/${appId}/config`,
98
+ {
99
+ headers: {
100
+ Authorization: `Bearer ${token}`,
101
+ "X-Goog-User-Project": projectId,
102
+ },
103
+ },
104
+ );
105
+
106
+ if (!response.ok) {
107
+ const err = await response.json().catch(() => ({}));
108
+ throw new Error(
109
+ `Failed to fetch web app config: ${err.error?.message || response.statusText}`,
110
+ );
111
+ }
112
+
113
+ const data = await response.json();
114
+ return {
115
+ apiKey: data.apiKey || null,
116
+ authDomain: data.authDomain || null,
117
+ projectId: data.projectId || null,
118
+ storageBucket: data.storageBucket || null,
119
+ messagingSenderId: data.messagingSenderId || null,
120
+ appId: data.appId || null,
121
+ };
122
+ }
123
+
83
124
  // ─── Anonymous Authentication ───────────────────────────────────────────────
84
125
 
85
126
  /**
package/src/init.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { execSync, execFileSync } from "child_process";
6
6
  import { createInterface } from "readline";
7
7
  import { hostname, platform, homedir } from "os";
8
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
8
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "fs";
9
9
  import { join, dirname } from "path";
10
10
  import { fileURLToPath } from "url";
11
11
  import { initializeApp, cert } from "firebase-admin/app";
@@ -15,6 +15,7 @@ import chalk from "chalk";
15
15
 
16
16
  import {
17
17
  readRefreshToken,
18
+ getWebAppConfig,
18
19
  enableAnonymousAuth,
19
20
  enableApi,
20
21
  findFirebaseAdminSdkAccount,
@@ -426,22 +427,40 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
426
427
 
427
428
  console.log(chalk.green(` ✓ App ID: ${appId}`));
428
429
 
429
- // Get SDK config (with retry newly created apps may take a few seconds to propagate).
430
+ // Get SDK config via REST API (bypasses flaky `firebase apps:sdkconfig` CLI).
430
431
  let sdkConfig = {};
431
432
 
432
433
  try {
433
- let sdkOutput = null;
434
+ // Primary method: REST API with retry for newly created apps.
434
435
  const maxSdkRetries = 4;
435
436
  const retryDelayMs = 5000; // 5 seconds between retries.
436
437
 
437
438
  for (let attempt = 1; attempt <= maxSdkRetries; attempt++) {
438
439
  try {
439
- sdkOutput = runOrFail(
440
- ["firebase", "apps:sdkconfig", "web", appId, "--project", projectId],
441
- "Failed to get SDK config.",
442
- );
440
+ sdkConfig = await getWebAppConfig(projectId, appId);
443
441
  break; // Success — exit retry loop.
444
442
  } catch (e) {
443
+ const errMsg = e.message || "";
444
+
445
+ // Detect deleted projects — no point retrying, bail immediately.
446
+ if (errMsg.includes("has been deleted") || errMsg.includes("DELETED")) {
447
+ // Clear stale cached config so next run starts fresh.
448
+ try {
449
+ rmSync(forgeDir, { recursive: true, force: true });
450
+ } catch {
451
+ // Best-effort cleanup.
452
+ }
453
+ throw new Error(
454
+ `Project "${projectId}" has been deleted and is pending permanent removal (30 days).\n\n` +
455
+ chalk.bold(
456
+ " The cached config has been cleared. Re-run with a new project ID:\n\n",
457
+ ) +
458
+ chalk.cyan(
459
+ ` forge-remote init --project-id forge-remote-${sanitizedHostname()}-2\n`,
460
+ ),
461
+ );
462
+ }
463
+
445
464
  if (attempt < maxSdkRetries) {
446
465
  console.log(
447
466
  chalk.yellow(
@@ -451,23 +470,23 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
451
470
  );
452
471
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
453
472
  } else {
454
- // All retries exhausted — throw with helpful instructions.
455
- const fallbackErr = new Error(
456
- `Could not retrieve SDK config after ${maxSdkRetries} attempts.\n\n` +
457
- chalk.bold(
458
- " This usually means the Firebase web app hasn't fully propagated yet.\n",
459
- ) +
473
+ throw new Error(
474
+ `Could not retrieve SDK config after ${maxSdkRetries} attempts.\n` +
475
+ ` Last error: ${errMsg}\n\n` +
460
476
  chalk.bold(" Try these steps:\n\n") +
461
477
  chalk.cyan(
462
478
  ` 1. Wait 30 seconds, then re-run: forge-remote init\n`,
463
479
  ) +
464
- chalk.cyan(` 2. Or run manually:\n`) +
480
+ chalk.cyan(
481
+ ` 2. Or get the config from the Firebase Console:\n`,
482
+ ) +
465
483
  chalk.dim(
466
- ` firebase apps:sdkconfig web ${appId} --project ${projectId}\n`,
484
+ ` https://console.firebase.google.com/project/${projectId}/settings/general\n`,
467
485
  ) +
468
486
  chalk.dim(
469
- ` Then copy the config into: ~/.forge-remote/sdk-config.json\n`,
487
+ ` Scroll to "Your apps" Web app → Config snippet\n`,
470
488
  ) +
489
+ chalk.dim(` Save it to: ~/.forge-remote/sdk-config.json\n`) +
471
490
  chalk.dim(
472
491
  ` Format: { "apiKey": "...", "authDomain": "...", "projectId": "...",\n`,
473
492
  ) +
@@ -475,46 +494,12 @@ export async function runInit({ projectId: overrideProjectId } = {}) {
475
494
  ` "storageBucket": "...", "messagingSenderId": "...", "appId": "..." }\n`,
476
495
  ),
477
496
  );
478
- throw fallbackErr;
479
497
  }
480
498
  }
481
499
  }
482
500
 
483
- // Parse the config — output may be JSON or JS object.
484
- const jsonMatch = sdkOutput.match(/\{[\s\S]*\}/);
485
- if (jsonMatch) {
486
- try {
487
- const parsed = JSON.parse(jsonMatch[0]);
488
- sdkConfig = {
489
- apiKey: parsed.apiKey || null,
490
- authDomain: parsed.authDomain || null,
491
- projectId: parsed.projectId || null,
492
- storageBucket: parsed.storageBucket || null,
493
- messagingSenderId: parsed.messagingSenderId || null,
494
- appId: parsed.appId || null,
495
- };
496
- } catch {
497
- // Not valid JSON — fall back to regex.
498
- const parseField = (field) => {
499
- const regex = new RegExp(`"?${field}"?:\\s*"([^"]+)"`);
500
- const match = sdkOutput.match(regex);
501
- return match ? match[1] : null;
502
- };
503
- sdkConfig = {
504
- apiKey: parseField("apiKey"),
505
- authDomain: parseField("authDomain"),
506
- projectId: parseField("projectId"),
507
- storageBucket: parseField("storageBucket"),
508
- messagingSenderId: parseField("messagingSenderId"),
509
- appId: parseField("appId"),
510
- };
511
- }
512
- }
513
-
514
501
  if (!sdkConfig.apiKey || !sdkConfig.projectId) {
515
- throw new Error(
516
- "Could not parse apiKey or projectId from SDK config output.",
517
- );
502
+ throw new Error("SDK config is missing apiKey or projectId.");
518
503
  }
519
504
 
520
505
  console.log(chalk.green(" ✓ SDK config retrieved"));