forge-remote 2.1.5 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/firestore.rules CHANGED
@@ -134,6 +134,20 @@ service cloud.firestore {
134
134
  allow update: if isSignedIn() && isValidSize();
135
135
  }
136
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
+
137
151
  // ---- User profiles (for preferences) ----
138
152
  match /users/{userId} {
139
153
  allow read, write: if request.auth != null && request.auth.uid == userId;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "forge-remote",
3
- "version": "2.1.5",
4
- "description": "Desktop relay for Forge Remote — mobile command center for AI coding agents",
3
+ "version": "2.2.0",
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",
@@ -0,0 +1,119 @@
1
+ // Forge Remote — Auto-Approve Engine
2
+ // Copyright (c) 2025-2026 Iron Forge Apps
3
+ //
4
+ // Reads `auto_approve_patterns` from Firestore and decides whether to
5
+ // auto-approve an incoming permission request before pinging the user's
6
+ // phone. Always defers to manual approval for high/critical risk.
7
+
8
+ import {
9
+ collection,
10
+ doc,
11
+ getDocs,
12
+ getDoc,
13
+ setDoc,
14
+ serverTimestamp,
15
+ } from "firebase/firestore";
16
+ import { db } from "./firebase.js";
17
+ import * as log from "./logger.js";
18
+
19
+ const HIGH_RISK_TOOLS = new Set([
20
+ "Bash:rm",
21
+ "Bash:dropdb",
22
+ "Bash:git push --force",
23
+ "Bash:firebase deploy",
24
+ ]);
25
+
26
+ const cache = new Map();
27
+ let lastRefresh = 0;
28
+ const REFRESH_INTERVAL_MS = 30_000;
29
+
30
+ function normalize(preview) {
31
+ return preview.trim().replace(/\s+/g, " ");
32
+ }
33
+
34
+ function patternKey(tool, preview) {
35
+ return `${tool}:${normalize(preview)}`;
36
+ }
37
+
38
+ function classifyRisk(tool, preview) {
39
+ const k = patternKey(tool, preview);
40
+ for (const high of HIGH_RISK_TOOLS) {
41
+ if (k.startsWith(high)) return "high";
42
+ }
43
+ if (tool === "Read" || tool === "Glob" || tool === "Grep") return "low";
44
+ if (tool === "Edit" || tool === "Write") return "medium";
45
+ if (tool === "Bash") return "medium";
46
+ return "medium";
47
+ }
48
+
49
+ async function refreshCache() {
50
+ const now = Date.now();
51
+ if (now - lastRefresh < REFRESH_INTERVAL_MS) return;
52
+ try {
53
+ const snap = await getDocs(collection(db, "auto_approve_patterns"));
54
+ cache.clear();
55
+ snap.forEach((d) => cache.set(d.id, d.data()));
56
+ lastRefresh = now;
57
+ } catch (e) {
58
+ log.warn(`Auto-approve cache refresh failed: ${e.message}`);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Returns 'auto-allow' if the request matches an auto-approve pattern,
64
+ * otherwise null (caller should prompt the phone normally).
65
+ */
66
+ export async function maybeAutoApprove({ tool, preview, sessionId }) {
67
+ const risk = classifyRisk(tool, preview);
68
+ if (risk === "high" || risk === "critical") return null;
69
+
70
+ await refreshCache();
71
+ const key = patternKey(tool, preview);
72
+ const safeId = key.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 60);
73
+ const pattern = cache.get(safeId);
74
+ if (!pattern || !pattern.autoApprove) return null;
75
+
76
+ // Log to Firestore for the audit trail.
77
+ try {
78
+ await setDoc(doc(db, "auto_approve_audit", `${Date.now()}-${safeId}`), {
79
+ patternKey: key,
80
+ preview,
81
+ decision: "auto-allow",
82
+ risk,
83
+ sessionId: sessionId ?? null,
84
+ decidedAt: serverTimestamp(),
85
+ });
86
+ } catch (e) {
87
+ log.warn(`Auto-approve audit write failed: ${e.message}`);
88
+ }
89
+
90
+ log.info(`Auto-approved ${tool}: ${preview.slice(0, 60)}`);
91
+ return "auto-allow";
92
+ }
93
+
94
+ /**
95
+ * Update a pattern's counters after a manual decision (the mobile app
96
+ * usually does this directly, but the relay calls this when a permission
97
+ * times out).
98
+ */
99
+ export async function recordRelayDecision({ tool, preview, decision }) {
100
+ const key = patternKey(tool, preview);
101
+ const safeId = key.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 60);
102
+ const ref = doc(db, "auto_approve_patterns", safeId);
103
+ const existing = await getDoc(ref);
104
+ const current = existing.exists() ? existing.data() : {};
105
+ await setDoc(
106
+ ref,
107
+ {
108
+ key,
109
+ tool,
110
+ preview,
111
+ risk: classifyRisk(tool, preview),
112
+ approvals: (current.approvals ?? 0) + (decision === "allow" ? 1 : 0),
113
+ denials: (current.denials ?? 0) + (decision === "deny" ? 1 : 0),
114
+ autoApprove: current.autoApprove ?? false,
115
+ updatedAt: serverTimestamp(),
116
+ },
117
+ { merge: true },
118
+ );
119
+ }
@@ -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
  // ---------------------------------------------------------------------------