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 +14 -0
- package/package.json +3 -2
- package/src/auto-approve-engine.js +119 -0
- package/src/build-manager.js +185 -39
- package/src/claude-session-watcher.js +569 -0
- package/src/cli.js +11 -0
- package/src/cloud-mode.js +126 -0
- package/src/distribution.js +192 -0
- package/src/ios-setup.js +281 -0
- package/src/mobile-ai-watcher.js +147 -0
- package/src/session-manager.js +120 -10
- package/src/task-orchestrator.js +119 -0
- package/src/update-checker.js +10 -24
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.
|
|
4
|
-
"description": "Desktop relay for Forge Remote —
|
|
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
|
+
}
|
package/src/build-manager.js
CHANGED
|
@@ -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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
reject(new Error(`Build
|
|
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
|
// ---------------------------------------------------------------------------
|