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 +14 -75
- package/package.json +3 -2
- package/src/build-manager.js +185 -39
- package/src/claude-session-watcher.js +603 -0
- package/src/cli.js +13 -124
- package/src/session-manager.js +97 -195
- package/src/update-checker.js +10 -24
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
|
|
4
|
-
"description": "Desktop relay for Forge Remote —
|
|
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",
|
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
|
// ---------------------------------------------------------------------------
|