forge-remote 2.1.6 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/firestore.rules CHANGED
@@ -115,24 +115,6 @@ service cloud.firestore {
115
115
  }
116
116
  }
117
117
 
118
- // ---- Mobile AI requests (relay-via-Claude) ----
119
- //
120
- // The mobile app writes a request, the relay reads it, executes
121
- // `claude -p` headlessly, and streams stdout into chunks/. The
122
- // relay also flips status to 'completed' or 'failed' and may
123
- // append an `error` field on failure.
124
- match /mobile_ai_requests/{requestId} {
125
- allow read: if isSignedIn();
126
- allow create: if isSignedIn()
127
- && request.resource.data.ownerUid == request.auth.uid;
128
- allow update: if isSignedIn();
129
- allow delete: if isSignedIn();
130
-
131
- match /chunks/{chunkId} {
132
- allow read, create, delete: if isSignedIn();
133
- }
134
- }
135
-
136
118
  // ---- Pairing tokens ----
137
119
  match /pairingTokens/{tokenId} {
138
120
  allow read: if isSignedIn();
@@ -152,6 +134,20 @@ service cloud.firestore {
152
134
  allow update: if isSignedIn() && isValidSize();
153
135
  }
154
136
 
137
+ // ---- Forge Hall (RPG party / quests / inventory / battle history) ----
138
+ // BYOF: single-player. State is stored as a single doc at
139
+ // forge_hall/state
140
+ // with subcollections for inventory items + battle history. The mobile
141
+ // app is the only writer; relay reads are read-only for now.
142
+ match /forge_hall/{docId} {
143
+ allow read: if isSignedIn();
144
+ allow write: if isSignedIn();
145
+
146
+ match /{subColl}/{subDoc} {
147
+ allow read, write: if isSignedIn();
148
+ }
149
+ }
150
+
155
151
  // ---- User profiles (for preferences) ----
156
152
  match /users/{userId} {
157
153
  allow read, write: if request.auth != null && request.auth.uid == userId;
@@ -161,63 +157,6 @@ service cloud.firestore {
161
157
  }
162
158
  }
163
159
 
164
- // ---- Forge Remote Cloud (managed mode) — multi-tenant ----
165
- //
166
- // In Cloud Mode, every user lives under /users/{uid}/* and may only
167
- // read/write their own subtree. Pairing codes are world-readable for
168
- // the duration of pairing (10 min TTL enforced by the relay + GC fn),
169
- // but only the desktop relay that created a code may write back the
170
- // claim metadata, and only the matching user may set userUid.
171
-
172
- match /users/{userId}/desktops/{desktopId} {
173
- allow read, write: if isOwner(userId);
174
-
175
- match /{sub=**} {
176
- allow read, write: if isOwner(userId);
177
- }
178
- }
179
-
180
- match /users/{userId}/sessions/{sessionId} {
181
- allow read, write: if isOwner(userId);
182
-
183
- match /{sub=**} {
184
- allow read, write: if isOwner(userId);
185
- }
186
- }
187
-
188
- match /users/{userId}/ai_credits/{docId} {
189
- allow read: if isOwner(userId);
190
- // Only Cloud Functions (admin SDK) write here.
191
- }
192
-
193
- // Cloud-mode mirror of mobile_ai_requests. Identical contract,
194
- // scoped under the user's subtree.
195
- match /users/{userId}/mobile_ai_requests/{requestId} {
196
- allow read, write: if isOwner(userId);
197
- match /chunks/{chunkId} {
198
- allow read, write: if isOwner(userId);
199
- }
200
- }
201
-
202
- match /pairing_codes/{code} {
203
- // Anyone signed-in can read (so the desktop can listen for claim).
204
- allow read: if isSignedIn();
205
- // The relay writes the initial doc with admin SDK; we still allow
206
- // create from clients during transition. The mobile app updates it
207
- // once with userUid set to the caller.
208
- allow create: if isSignedIn();
209
- allow update: if isSignedIn()
210
- && request.resource.data.userUid == request.auth.uid
211
- && resource.data.userUid == null;
212
- allow delete: if isSignedIn();
213
- }
214
-
215
- // Public read-only session replays (Phase 6).
216
- match /shared_sessions/{shareId} {
217
- allow read: if true;
218
- allow create, update, delete: if false; // Cloud Function only.
219
- }
220
-
221
160
  // Deny everything else by default.
222
161
  match /{document=**} {
223
162
  allow read, write: if false;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "forge-remote",
3
- "version": "2.1.6",
4
- "description": "Desktop relay for Forge Remote — mobile command center for AI coding agents",
3
+ "version": "2.2.1",
4
+ "description": "Desktop relay for Forge Remote — monitor and control Claude Code sessions from your phone",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
7
7
  "author": "Daniel Wendel <daniel@ironforgeapps.com> (https://ironforgeapps.com)",
@@ -34,6 +34,7 @@
34
34
  ],
35
35
  "dependencies": {
36
36
  "chalk": "^5.4.0",
37
+ "chokidar": "^4.0.3",
37
38
  "commander": "^13.0.0",
38
39
  "firebase-admin": "^13.0.0",
39
40
  "localtunnel": "^2.0.2",
@@ -2,7 +2,7 @@
2
2
  // Copyright (c) 2025-2026 Iron Forge Apps
3
3
  // Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
4
4
 
5
- import { execSync, spawn } from "child_process";
5
+ import { execSync, spawn, spawnSync } from "child_process";
6
6
  import path from "node:path";
7
7
  import {
8
8
  existsSync,
@@ -231,60 +231,88 @@ export async function runBuild(projectPath, platform, onOutput) {
231
231
  process.env.DEVELOPER_DIR ||
232
232
  "/Applications/Xcode.app/Contents/Developer",
233
233
  };
234
- try {
235
- const output = execSync(buildCmd, {
236
- cwd: projectPath,
237
- env: buildEnv,
238
- timeout: 600000, // 10 minutes
239
- encoding: "utf-8",
240
- stdio: ["pipe", "pipe", "pipe"],
241
- shell: shell,
242
- });
234
+ // For iOS builds, run flutter clean first to avoid stale storyboard caches
235
+ const fullCmd =
236
+ platform === "ios"
237
+ ? `flutter clean > /dev/null 2>&1; ${buildCmd}`
238
+ : buildCmd;
239
+ const proc = spawn(shell, ["-l", "-c", fullCmd], {
240
+ cwd: projectPath,
241
+ env: buildEnv,
242
+ stdio: ["pipe", "pipe", "pipe"],
243
+ });
243
244
 
244
- const duration = Math.round((Date.now() - startTime) / 1000);
245
- const outputPath =
246
- projectInfo.outputPaths[platform] ||
247
- projectInfo.outputPaths.web ||
248
- projectInfo.outputPaths.default ||
249
- null;
245
+ // Timeout: 10 minutes max for any build
246
+ const buildTimeout = setTimeout(() => {
247
+ log.warn(`Build timed out after 10 minutes: ${buildCmd}`);
248
+ try {
249
+ proc.kill("SIGTERM");
250
+ } catch {}
251
+ setTimeout(() => {
252
+ try {
253
+ proc.kill("SIGKILL");
254
+ } catch {}
255
+ }, 5000);
256
+ reject(new Error(`Build timed out after 10 minutes`));
257
+ }, 600000);
250
258
 
251
- // Stream output lines to callback
252
- for (const line of output.split("\n")) {
259
+ proc.stdout.on("data", (data) => {
260
+ const text = data.toString();
261
+ for (const line of text.split("\n")) {
253
262
  const trimmed = line.trim();
254
263
  if (trimmed) {
255
264
  outputLines.push(trimmed);
256
265
  if (onOutput) onOutput(trimmed, "stdout");
257
266
  }
258
267
  }
268
+ });
259
269
 
260
- log.info(`Build succeeded in ${duration}s`);
261
- resolve({
262
- success: true,
263
- output: outputLines.join("\n"),
264
- duration,
265
- outputPath: outputPath ? path.resolve(projectPath, outputPath) : null,
266
- framework: projectInfo.framework,
267
- platform,
268
- });
269
- } catch (err) {
270
- const duration = Math.round((Date.now() - startTime) / 1000);
271
- const stderr = err.stderr?.toString() || "";
272
- const stdout = err.stdout?.toString() || "";
273
- const allOutput = (stdout + "\n" + stderr).trim();
274
-
275
- // Stream output lines to callback
276
- for (const line of allOutput.split("\n")) {
270
+ proc.stderr.on("data", (data) => {
271
+ const text = data.toString();
272
+ for (const line of text.split("\n")) {
277
273
  const trimmed = line.trim();
278
274
  if (trimmed) {
279
275
  outputLines.push(trimmed);
280
276
  if (onOutput) onOutput(trimmed, "stderr");
281
277
  }
282
278
  }
279
+ });
283
280
 
284
- const tail = outputLines.slice(-15).join("\n");
285
- log.error(`Build failed (exit code ${err.status}) after ${duration}s`);
286
- reject(new Error(`Build failed (exit code ${err.status})\n${tail}`));
287
- }
281
+ proc.on("error", (err) => {
282
+ clearTimeout(buildTimeout);
283
+ reject(new Error(`Build process error: ${err.message}`));
284
+ });
285
+
286
+ proc.on("close", (code) => {
287
+ clearTimeout(buildTimeout);
288
+ const duration = Math.round((Date.now() - startTime) / 1000);
289
+ const outputPath =
290
+ projectInfo.outputPaths[platform] ||
291
+ projectInfo.outputPaths.web ||
292
+ projectInfo.outputPaths.default ||
293
+ null;
294
+
295
+ if (code === 0) {
296
+ log.info(`Build succeeded in ${duration}s`);
297
+ resolve({
298
+ success: true,
299
+ output: outputLines.join("\n"),
300
+ duration,
301
+ outputPath: outputPath ? path.resolve(projectPath, outputPath) : null,
302
+ framework: projectInfo.framework,
303
+ platform,
304
+ });
305
+ } else {
306
+ const tail = outputLines.slice(-15).join("\n");
307
+ log.error(`Build failed (exit code ${code})`);
308
+ reject(new Error(`Build failed (exit code ${code})\n${tail}`));
309
+ }
310
+ });
311
+
312
+ proc.on("error", (err) => {
313
+ log.error(`Build process error: ${err.message}`);
314
+ reject(new Error(`Build process error: ${err.message}`));
315
+ });
288
316
  });
289
317
  }
290
318
 
@@ -698,6 +726,124 @@ export function startFirebaseLogin() {
698
726
  return { pid: proc.pid };
699
727
  }
700
728
 
729
+ // ---------------------------------------------------------------------------
730
+ // Cloudflare Pages Deploy
731
+ // ---------------------------------------------------------------------------
732
+
733
+ import { homedir } from "os";
734
+ import { join as joinPath } from "path";
735
+
736
+ /** Read Cloudflare API token + account ID from ~/.forge-remote/config.json. */
737
+ function getCloudflareCreds() {
738
+ const configPath = joinPath(homedir(), ".forge-remote", "config.json");
739
+ if (!existsSync(configPath)) {
740
+ throw new Error(
741
+ "Cloudflare not configured. Save your API token via the mobile app: " +
742
+ "Settings → Integrations → Cloudflare.",
743
+ );
744
+ }
745
+ const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
746
+ const token = cfg.cloudflareApiToken;
747
+ const accountId = cfg.cloudflareAccountId;
748
+ if (!token) {
749
+ throw new Error(
750
+ "Cloudflare API token missing. Add it via the mobile app: " +
751
+ "Settings → Integrations → Cloudflare.",
752
+ );
753
+ }
754
+ return { token, accountId };
755
+ }
756
+
757
+ /** Persist Cloudflare creds to ~/.forge-remote/config.json. */
758
+ export function saveCloudflareCreds({ token, accountId }) {
759
+ const configPath = joinPath(homedir(), ".forge-remote", "config.json");
760
+ let cfg = {};
761
+ if (existsSync(configPath)) {
762
+ cfg = JSON.parse(readFileSync(configPath, "utf-8"));
763
+ }
764
+ if (token !== undefined) cfg.cloudflareApiToken = token;
765
+ if (accountId !== undefined) cfg.cloudflareAccountId = accountId;
766
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2));
767
+ }
768
+
769
+ /**
770
+ * Slugify a project name for Cloudflare Pages.
771
+ * Lowercase, alphanumeric + hyphens, max 58 chars (Pages limit).
772
+ */
773
+ export function slugifyForCloudflarePages(name) {
774
+ let slug = (name || "")
775
+ .toLowerCase()
776
+ .replace(/[^a-z0-9-]+/g, "-")
777
+ .replace(/^-+|-+$/g, "")
778
+ .replace(/-{2,}/g, "-");
779
+ if (slug.length === 0) slug = "forge-remote-app";
780
+ if (slug.length > 58) slug = slug.slice(0, 58).replace(/-$/, "");
781
+ return slug;
782
+ }
783
+
784
+ /**
785
+ * Deploy a built static site to Cloudflare Pages via Wrangler.
786
+ *
787
+ * Uses spawnSync with array args (no shell) — no injection surface.
788
+ *
789
+ * @param {string} buildPath — absolute path to the built static directory
790
+ * @param {string} projectSlug — Cloudflare Pages project name (already slugified)
791
+ * @returns {Promise<{ url: string|null, output: string, projectSlug: string }>}
792
+ */
793
+ export async function deployToCloudflarePages(buildPath, projectSlug) {
794
+ if (!existsSync(buildPath)) {
795
+ throw new Error(`Build path does not exist: ${buildPath}`);
796
+ }
797
+ const safeSlug = slugifyForCloudflarePages(projectSlug);
798
+ const { token, accountId } = getCloudflareCreds();
799
+
800
+ // npx fetches Wrangler on first use (~50MB cache); subsequent deploys are fast.
801
+ const args = [
802
+ "--yes",
803
+ "wrangler@latest",
804
+ "pages",
805
+ "deploy",
806
+ buildPath,
807
+ `--project-name=${safeSlug}`,
808
+ "--commit-dirty=true",
809
+ ];
810
+
811
+ log.info(
812
+ `Deploying to Cloudflare Pages (${safeSlug}): npx ${args.join(" ")}`,
813
+ );
814
+
815
+ const result = spawnSync("npx", args, {
816
+ timeout: 240_000,
817
+ encoding: "utf-8",
818
+ env: {
819
+ ...process.env,
820
+ CLOUDFLARE_API_TOKEN: token,
821
+ ...(accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {}),
822
+ WRANGLER_SEND_METRICS: "false",
823
+ },
824
+ });
825
+
826
+ if (result.error) throw result.error;
827
+ const stdout = result.stdout || "";
828
+ const stderr = result.stderr || "";
829
+ const combined = stdout + "\n" + stderr;
830
+
831
+ if (result.status !== 0) {
832
+ throw new Error(
833
+ `Wrangler exited with code ${result.status}: ${combined.slice(0, 500)}`,
834
+ );
835
+ }
836
+
837
+ // Parse deployment URL.
838
+ const aliasMatch = combined.match(/alias URL:\s+(https?:\/\/[^\s]+)/i);
839
+ const previewMatch = combined.match(/(https?:\/\/[a-z0-9-]+\.pages\.dev)/i);
840
+ const url =
841
+ aliasMatch?.[1] || previewMatch?.[1] || `https://${safeSlug}.pages.dev`;
842
+
843
+ log.info(`Cloudflare Pages deployed: ${url}`);
844
+ return { url, output: combined, projectSlug: safeSlug };
845
+ }
846
+
701
847
  // ---------------------------------------------------------------------------
702
848
  // Helpers
703
849
  // ---------------------------------------------------------------------------