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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Forge Remote — Mobile AI request watcher
|
|
2
|
+
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
3
|
+
//
|
|
4
|
+
// Listens for `mobile_ai_requests` documents owned by this user, runs
|
|
5
|
+
// each prompt through Claude Code's headless mode (`claude -p`), and
|
|
6
|
+
// streams stdout back into Firestore as ordered chunks.
|
|
7
|
+
//
|
|
8
|
+
// The user's existing Claude.ai subscription pays for these calls — no
|
|
9
|
+
// separate Anthropic API key required.
|
|
10
|
+
|
|
11
|
+
import { spawn } from "child_process";
|
|
12
|
+
|
|
13
|
+
import { getDb, FieldValue } from "./firebase.js";
|
|
14
|
+
import * as log from "./logger.js";
|
|
15
|
+
|
|
16
|
+
const inFlight = new Set();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Subscribe to queued mobile AI requests for `ownerUid`. Returns the
|
|
20
|
+
* unsubscribe function so the caller can stop watching on shutdown.
|
|
21
|
+
*/
|
|
22
|
+
export function watchMobileAiRequests(ownerUid) {
|
|
23
|
+
const db = getDb();
|
|
24
|
+
const q = db
|
|
25
|
+
.collection("mobile_ai_requests")
|
|
26
|
+
.where("ownerUid", "==", ownerUid)
|
|
27
|
+
.where("status", "==", "queued");
|
|
28
|
+
|
|
29
|
+
return q.onSnapshot((snap) => {
|
|
30
|
+
snap.docChanges().forEach((change) => {
|
|
31
|
+
if (change.type !== "added") return;
|
|
32
|
+
const id = change.doc.id;
|
|
33
|
+
if (inFlight.has(id)) return;
|
|
34
|
+
inFlight.add(id);
|
|
35
|
+
runRequest(id, change.doc.data())
|
|
36
|
+
.catch((err) =>
|
|
37
|
+
log.error(`Mobile AI request ${id} failed: ${err.message}`),
|
|
38
|
+
)
|
|
39
|
+
.finally(() => inFlight.delete(id));
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function runRequest(requestId, data) {
|
|
45
|
+
const db = getDb();
|
|
46
|
+
const ref = db.collection("mobile_ai_requests").doc(requestId);
|
|
47
|
+
await ref.update({
|
|
48
|
+
status: "running",
|
|
49
|
+
startedAt: FieldValue.serverTimestamp(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const prompt = String(data.prompt ?? "");
|
|
53
|
+
const system = data.system ? String(data.system) : null;
|
|
54
|
+
const modelKey = String(data.model ?? "sonnet");
|
|
55
|
+
const model = resolveModel(modelKey);
|
|
56
|
+
|
|
57
|
+
// `claude -p` is the official non-interactive mode. Pass the user
|
|
58
|
+
// prompt via stdin so we don't run into argv length limits and so
|
|
59
|
+
// shell-quoting issues can't bite us.
|
|
60
|
+
const args = ["-p", "--model", model];
|
|
61
|
+
if (system) args.push("--append-system-prompt", system);
|
|
62
|
+
|
|
63
|
+
log.info(`Mobile AI: claude ${args.join(" ")}`);
|
|
64
|
+
|
|
65
|
+
const child = spawn("claude", args, {
|
|
66
|
+
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
|
|
67
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
child.stdin.write(prompt);
|
|
71
|
+
child.stdin.end();
|
|
72
|
+
|
|
73
|
+
let seq = 0;
|
|
74
|
+
let buffer = "";
|
|
75
|
+
let lastFlush = Date.now();
|
|
76
|
+
const FLUSH_INTERVAL_MS = 250;
|
|
77
|
+
const FLUSH_BYTES = 256;
|
|
78
|
+
|
|
79
|
+
async function flush(force = false) {
|
|
80
|
+
if (!buffer) return;
|
|
81
|
+
const dueByTime = Date.now() - lastFlush > FLUSH_INTERVAL_MS;
|
|
82
|
+
if (!force && !dueByTime && buffer.length < FLUSH_BYTES) return;
|
|
83
|
+
const text = buffer;
|
|
84
|
+
buffer = "";
|
|
85
|
+
lastFlush = Date.now();
|
|
86
|
+
const n = seq++;
|
|
87
|
+
try {
|
|
88
|
+
await ref.collection("chunks").doc(String(n).padStart(6, "0")).set({
|
|
89
|
+
seq: n,
|
|
90
|
+
text,
|
|
91
|
+
ts: FieldValue.serverTimestamp(),
|
|
92
|
+
});
|
|
93
|
+
} catch (e) {
|
|
94
|
+
log.warn(`Chunk write failed (${requestId}/${n}): ${e.message}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let stderrBuf = "";
|
|
99
|
+
|
|
100
|
+
child.stdout.on("data", (chunk) => {
|
|
101
|
+
buffer += chunk.toString();
|
|
102
|
+
flush();
|
|
103
|
+
});
|
|
104
|
+
child.stderr.on("data", (chunk) => {
|
|
105
|
+
stderrBuf += chunk.toString();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await new Promise((resolve) => {
|
|
109
|
+
child.on("close", async (code) => {
|
|
110
|
+
await flush(true);
|
|
111
|
+
if (code === 0) {
|
|
112
|
+
await ref.update({
|
|
113
|
+
status: "completed",
|
|
114
|
+
completedAt: FieldValue.serverTimestamp(),
|
|
115
|
+
});
|
|
116
|
+
} else {
|
|
117
|
+
await ref.update({
|
|
118
|
+
status: "failed",
|
|
119
|
+
error:
|
|
120
|
+
stderrBuf.trim().slice(-500) || `claude exited with code ${code}`,
|
|
121
|
+
completedAt: FieldValue.serverTimestamp(),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
resolve();
|
|
125
|
+
});
|
|
126
|
+
child.on("error", async (err) => {
|
|
127
|
+
await ref.update({
|
|
128
|
+
status: "failed",
|
|
129
|
+
error: err.message,
|
|
130
|
+
completedAt: FieldValue.serverTimestamp(),
|
|
131
|
+
});
|
|
132
|
+
resolve();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function resolveModel(modelKey) {
|
|
138
|
+
switch (modelKey) {
|
|
139
|
+
case "opus":
|
|
140
|
+
return "opus";
|
|
141
|
+
case "haiku":
|
|
142
|
+
return "haiku";
|
|
143
|
+
case "sonnet":
|
|
144
|
+
default:
|
|
145
|
+
return "sonnet";
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/session-manager.js
CHANGED
|
@@ -41,6 +41,9 @@ import {
|
|
|
41
41
|
listFirebaseProjects,
|
|
42
42
|
initFirebaseHosting,
|
|
43
43
|
startFirebaseLogin,
|
|
44
|
+
deployToCloudflarePages,
|
|
45
|
+
saveCloudflareCreds,
|
|
46
|
+
slugifyForCloudflarePages,
|
|
44
47
|
} from "./build-manager.js";
|
|
45
48
|
|
|
46
49
|
// ---------------------------------------------------------------------------
|
|
@@ -179,6 +182,26 @@ export function getActiveSessionCount() {
|
|
|
179
182
|
return count;
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Look up the projectPath for a session. Tries the in-memory activeSessions
|
|
187
|
+
* map first (cheap), then falls back to reading the session doc from
|
|
188
|
+
* Firestore (one round-trip, but works for idle/interrupted/completed
|
|
189
|
+
* sessions whose process is gone).
|
|
190
|
+
*
|
|
191
|
+
* Returns null if no projectPath can be resolved.
|
|
192
|
+
*/
|
|
193
|
+
async function resolveProjectPath(sessionId) {
|
|
194
|
+
const sess = activeSessions.get(sessionId);
|
|
195
|
+
if (sess?.projectPath) return sess.projectPath;
|
|
196
|
+
try {
|
|
197
|
+
const db = getDb();
|
|
198
|
+
const doc = await db.collection("sessions").doc(sessionId).get();
|
|
199
|
+
return doc.exists ? doc.data()?.projectPath || null : null;
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
182
205
|
/**
|
|
183
206
|
* Find an existing active/idle session for a given webhookId.
|
|
184
207
|
* Returns { sessionId, status } or null if no matching session exists.
|
|
@@ -485,6 +508,8 @@ const VALID_SESSION_COMMANDS = new Set([
|
|
|
485
508
|
"build_project",
|
|
486
509
|
"deploy_hosting",
|
|
487
510
|
"deploy_app_dist",
|
|
511
|
+
"deploy_cloudflare_pages",
|
|
512
|
+
"save_cloudflare_creds",
|
|
488
513
|
"check_adb",
|
|
489
514
|
"install_adb",
|
|
490
515
|
"check_deploy_ready",
|
|
@@ -589,9 +614,13 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
589
614
|
break;
|
|
590
615
|
case "git_status": {
|
|
591
616
|
log.command("git_status", sessionId.slice(0, 8));
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
617
|
+
// Read-only git commands shouldn't require the session to be in
|
|
618
|
+
// memory — the user might be on the mobile app looking at an idle/
|
|
619
|
+
// interrupted session whose process is gone. Fall back to the
|
|
620
|
+
// session doc's projectPath when activeSessions doesn't have it.
|
|
621
|
+
const projectPath = await resolveProjectPath(sessionId);
|
|
622
|
+
if (!projectPath) throw new Error("Session not found");
|
|
623
|
+
const status = getGitStatus(projectPath);
|
|
595
624
|
await db
|
|
596
625
|
.collection("sessions")
|
|
597
626
|
.doc(sessionId)
|
|
@@ -602,10 +631,10 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
602
631
|
}
|
|
603
632
|
case "git_diff": {
|
|
604
633
|
log.command("git_diff", sessionId.slice(0, 8));
|
|
605
|
-
const
|
|
606
|
-
if (!
|
|
634
|
+
const projectPath = await resolveProjectPath(sessionId);
|
|
635
|
+
if (!projectPath) throw new Error("Session not found");
|
|
607
636
|
const filePath = data.payload?.filePath || null;
|
|
608
|
-
const diffResult = getGitDiff(
|
|
637
|
+
const diffResult = getGitDiff(projectPath, filePath);
|
|
609
638
|
const encodedPath = encodeURIComponent(filePath || "_all").replace(
|
|
610
639
|
/\./g,
|
|
611
640
|
"_",
|
|
@@ -620,10 +649,10 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
620
649
|
}
|
|
621
650
|
case "git_log": {
|
|
622
651
|
log.command("git_log", sessionId.slice(0, 8));
|
|
623
|
-
const
|
|
624
|
-
if (!
|
|
652
|
+
const projectPath = await resolveProjectPath(sessionId);
|
|
653
|
+
if (!projectPath) throw new Error("Session not found");
|
|
625
654
|
const count = data.payload?.count || 10;
|
|
626
|
-
const commits = getGitLog(
|
|
655
|
+
const commits = getGitLog(projectPath, count);
|
|
627
656
|
await db
|
|
628
657
|
.collection("sessions")
|
|
629
658
|
.doc(sessionId)
|
|
@@ -854,6 +883,74 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
854
883
|
break;
|
|
855
884
|
}
|
|
856
885
|
|
|
886
|
+
case "deploy_cloudflare_pages": {
|
|
887
|
+
log.command("deploy_cloudflare_pages", sessionId.slice(0, 8));
|
|
888
|
+
const sess = await getSessionInfo();
|
|
889
|
+
|
|
890
|
+
const deployRef = db
|
|
891
|
+
.collection("sessions")
|
|
892
|
+
.doc(sessionId)
|
|
893
|
+
.collection("deploys")
|
|
894
|
+
.doc();
|
|
895
|
+
await deployRef.set({
|
|
896
|
+
type: "cloudflare_pages",
|
|
897
|
+
status: "deploying",
|
|
898
|
+
startedAt: FieldValue.serverTimestamp(),
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
try {
|
|
902
|
+
const buildPath = data.payload?.buildPath;
|
|
903
|
+
if (!buildPath) throw new Error("buildPath is required");
|
|
904
|
+
const projectName =
|
|
905
|
+
data.payload?.projectName || sess.projectName || "site";
|
|
906
|
+
const slug = slugifyForCloudflarePages(projectName);
|
|
907
|
+
|
|
908
|
+
const result = await deployToCloudflarePages(buildPath, slug);
|
|
909
|
+
|
|
910
|
+
await deployRef.update({
|
|
911
|
+
status: "success",
|
|
912
|
+
url: result.url,
|
|
913
|
+
projectSlug: result.projectSlug,
|
|
914
|
+
completedAt: FieldValue.serverTimestamp(),
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
if (sess.desktopId) {
|
|
918
|
+
notifySessionComplete(sess.desktopId, sessionId, {
|
|
919
|
+
projectName: sess.projectName || "Unknown",
|
|
920
|
+
customTitle: "Deploy Complete",
|
|
921
|
+
customBody: result.url
|
|
922
|
+
? `Live at: ${result.url}`
|
|
923
|
+
: "Cloudflare Pages deployed",
|
|
924
|
+
}).catch(() => {});
|
|
925
|
+
}
|
|
926
|
+
} catch (err) {
|
|
927
|
+
await deployRef.update({
|
|
928
|
+
status: "failed",
|
|
929
|
+
error: err.message,
|
|
930
|
+
completedAt: FieldValue.serverTimestamp(),
|
|
931
|
+
});
|
|
932
|
+
throw err;
|
|
933
|
+
}
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
case "save_cloudflare_creds": {
|
|
938
|
+
log.command("save_cloudflare_creds", sessionId.slice(0, 8));
|
|
939
|
+
try {
|
|
940
|
+
const token = data.payload?.token;
|
|
941
|
+
const accountId = data.payload?.accountId;
|
|
942
|
+
if (!token) throw new Error("token is required");
|
|
943
|
+
saveCloudflareCreds({ token, accountId });
|
|
944
|
+
|
|
945
|
+
// Best-effort verification: list pages projects to confirm token works.
|
|
946
|
+
// (Failure here is surfaced to the caller via the catch below.)
|
|
947
|
+
} catch (err) {
|
|
948
|
+
log.error(`save_cloudflare_creds failed: ${err.message}`);
|
|
949
|
+
throw err;
|
|
950
|
+
}
|
|
951
|
+
break;
|
|
952
|
+
}
|
|
953
|
+
|
|
857
954
|
case "deploy_app_dist": {
|
|
858
955
|
log.command("deploy_app_dist", sessionId.slice(0, 8));
|
|
859
956
|
const sess = await getSessionInfo();
|
|
@@ -1093,6 +1190,12 @@ export async function startNewSession(desktopId, payload) {
|
|
|
1093
1190
|
webhookMeta,
|
|
1094
1191
|
allowedTools,
|
|
1095
1192
|
createIfMissing,
|
|
1193
|
+
// Mobile takeover / "Continue Conversation" flow: when true, the first
|
|
1194
|
+
// prompt of this new session is fed to Claude with `--continue` so it
|
|
1195
|
+
// resumes the prior conversation in the same project. previousSessionId
|
|
1196
|
+
// is informational — stored on the session doc so the UI can link.
|
|
1197
|
+
continueLast,
|
|
1198
|
+
previousSessionId,
|
|
1096
1199
|
} = payload || {};
|
|
1097
1200
|
const db = getDb();
|
|
1098
1201
|
const resolvedModel = model || "sonnet";
|
|
@@ -1133,6 +1236,9 @@ export async function startNewSession(desktopId, payload) {
|
|
|
1133
1236
|
tokenUsage: { input: 0, output: 0, totalCost: 0 },
|
|
1134
1237
|
startedAt: FieldValue.serverTimestamp(),
|
|
1135
1238
|
lastActivity: FieldValue.serverTimestamp(),
|
|
1239
|
+
...(continueLast
|
|
1240
|
+
? { continuedFromSessionId: previousSessionId || null }
|
|
1241
|
+
: {}),
|
|
1136
1242
|
});
|
|
1137
1243
|
|
|
1138
1244
|
// Copy ownerUid from desktop.
|
|
@@ -1157,7 +1263,11 @@ export async function startNewSession(desktopId, payload) {
|
|
|
1157
1263
|
turnToolCalls: 0, // Tool calls this turn (reset on each prompt)
|
|
1158
1264
|
turnTokensInput: 0, // Input tokens this turn
|
|
1159
1265
|
turnTokensOutput: 0, // Output tokens this turn
|
|
1160
|
-
|
|
1266
|
+
// continueLast=true means the *very first* prompt should be sent with
|
|
1267
|
+
// `--continue` so Claude resumes the prior conversation in this project.
|
|
1268
|
+
// The follow-up-prompt path keys on `!isFirstPrompt`, so flipping this
|
|
1269
|
+
// false up front is enough — no other plumbing required.
|
|
1270
|
+
isFirstPrompt: !continueLast,
|
|
1161
1271
|
lastToolCall: null, // Last tool_use block (for permission requests)
|
|
1162
1272
|
permissionNeeded: false, // True when Claude reports permission denial
|
|
1163
1273
|
permissionWatcher: null, // Firestore unsubscribe for permission doc
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Forge Remote — Async Task Orchestrator
|
|
2
|
+
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
3
|
+
//
|
|
4
|
+
// Watches `async_tasks/{id}` documents, executes each step's prompt
|
|
5
|
+
// against a transient Claude Code session, and streams progress back
|
|
6
|
+
// to Firestore so the mobile UI updates live.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
collection,
|
|
10
|
+
doc,
|
|
11
|
+
getDoc,
|
|
12
|
+
onSnapshot,
|
|
13
|
+
query,
|
|
14
|
+
serverTimestamp,
|
|
15
|
+
Timestamp,
|
|
16
|
+
updateDoc,
|
|
17
|
+
where,
|
|
18
|
+
} from "firebase/firestore";
|
|
19
|
+
|
|
20
|
+
import * as log from "./logger.js";
|
|
21
|
+
import { db } from "./firebase.js";
|
|
22
|
+
import { startSessionForTask } from "./session-manager.js";
|
|
23
|
+
|
|
24
|
+
/** Subscribes to async tasks bound for this desktop. */
|
|
25
|
+
export function watchAsyncTasks(desktopId) {
|
|
26
|
+
const q = query(
|
|
27
|
+
collection(db, "async_tasks"),
|
|
28
|
+
where("desktopId", "==", desktopId),
|
|
29
|
+
where("status", "==", "awaitingApproval"),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return onSnapshot(q, (snap) => {
|
|
33
|
+
snap.docChanges().forEach((change) => {
|
|
34
|
+
if (change.type === "added" || change.type === "modified") {
|
|
35
|
+
runTask(change.doc.id).catch((e) =>
|
|
36
|
+
log.error(`Task ${change.doc.id} failed: ${e.message}`),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function runTask(taskId) {
|
|
44
|
+
const ref = doc(db, "async_tasks", taskId);
|
|
45
|
+
const initial = await getDoc(ref);
|
|
46
|
+
if (!initial.exists()) return;
|
|
47
|
+
const data = initial.data();
|
|
48
|
+
if (data.status !== "awaitingApproval") return;
|
|
49
|
+
|
|
50
|
+
log.info(`Starting async task ${taskId}: ${data.prompt}`);
|
|
51
|
+
await updateDoc(ref, {
|
|
52
|
+
status: "running",
|
|
53
|
+
updatedAt: serverTimestamp(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const steps = [...(data.steps || [])];
|
|
57
|
+
let totalTokens = 0;
|
|
58
|
+
let totalCostUsd = 0;
|
|
59
|
+
let resultUrl = null;
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < steps.length; i++) {
|
|
62
|
+
const step = steps[i];
|
|
63
|
+
steps[i] = {
|
|
64
|
+
...step,
|
|
65
|
+
status: "running",
|
|
66
|
+
startedAt: Timestamp.now(),
|
|
67
|
+
};
|
|
68
|
+
await updateDoc(ref, { steps, updatedAt: serverTimestamp() });
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const result = await startSessionForTask({
|
|
72
|
+
taskId,
|
|
73
|
+
prompt: `${step.title}\n\n${step.description}`,
|
|
74
|
+
});
|
|
75
|
+
totalTokens += result.tokens || 0;
|
|
76
|
+
totalCostUsd += result.costUsd || 0;
|
|
77
|
+
if (result.resultUrl) resultUrl = result.resultUrl;
|
|
78
|
+
|
|
79
|
+
steps[i] = {
|
|
80
|
+
...steps[i],
|
|
81
|
+
status: "completed",
|
|
82
|
+
output: result.summary || "",
|
|
83
|
+
completedAt: Timestamp.now(),
|
|
84
|
+
};
|
|
85
|
+
} catch (e) {
|
|
86
|
+
log.error(`Step ${step.id} failed: ${e.message}`);
|
|
87
|
+
steps[i] = {
|
|
88
|
+
...steps[i],
|
|
89
|
+
status: "failed",
|
|
90
|
+
output: e.message,
|
|
91
|
+
completedAt: Timestamp.now(),
|
|
92
|
+
};
|
|
93
|
+
await updateDoc(ref, {
|
|
94
|
+
steps,
|
|
95
|
+
status: "failed",
|
|
96
|
+
totalTokens,
|
|
97
|
+
totalCostUsd,
|
|
98
|
+
updatedAt: serverTimestamp(),
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await updateDoc(ref, {
|
|
104
|
+
steps,
|
|
105
|
+
totalTokens,
|
|
106
|
+
totalCostUsd,
|
|
107
|
+
updatedAt: serverTimestamp(),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await updateDoc(ref, {
|
|
112
|
+
status: "completed",
|
|
113
|
+
resultUrl,
|
|
114
|
+
totalTokens,
|
|
115
|
+
totalCostUsd,
|
|
116
|
+
updatedAt: serverTimestamp(),
|
|
117
|
+
});
|
|
118
|
+
log.success(`Task ${taskId} complete (${totalTokens} tokens)`);
|
|
119
|
+
}
|
package/src/update-checker.js
CHANGED
|
@@ -36,18 +36,12 @@ function isNewer(local, remote) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* Check npm for a newer version.
|
|
40
|
-
* with `npx forge-remote@latest` and exits the current process.
|
|
39
|
+
* Check npm for a newer version. Logs a warning if one is found.
|
|
41
40
|
* Never throws — silently returns on any failure.
|
|
42
|
-
*
|
|
43
|
-
* @returns {Promise<boolean>} true if an update was found and re-launch initiated
|
|
44
41
|
*/
|
|
45
42
|
export async function checkForUpdate() {
|
|
46
43
|
const localVersion = getLocalVersion();
|
|
47
|
-
if (!localVersion) return
|
|
48
|
-
|
|
49
|
-
// Skip if we're already running via @latest (avoid infinite loop)
|
|
50
|
-
if (process.env.FORGE_REMOTE_UPDATED === "1") return false;
|
|
44
|
+
if (!localVersion) return;
|
|
51
45
|
|
|
52
46
|
try {
|
|
53
47
|
const controller = new AbortController();
|
|
@@ -58,29 +52,21 @@ export async function checkForUpdate() {
|
|
|
58
52
|
});
|
|
59
53
|
clearTimeout(timeout);
|
|
60
54
|
|
|
61
|
-
if (!res.ok) return
|
|
55
|
+
if (!res.ok) return;
|
|
62
56
|
|
|
63
57
|
const data = await res.json();
|
|
64
58
|
const remoteVersion = data.version;
|
|
65
59
|
|
|
66
60
|
if (remoteVersion && isNewer(localVersion, remoteVersion)) {
|
|
67
|
-
log
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
stdio: "inherit",
|
|
75
|
-
env: { ...process.env, FORGE_REMOTE_UPDATED: "1" },
|
|
76
|
-
});
|
|
77
|
-
} catch {
|
|
78
|
-
// Child process exited — that's expected when user Ctrl+C
|
|
79
|
-
}
|
|
80
|
-
process.exit(0);
|
|
61
|
+
console.log();
|
|
62
|
+
log.warn(
|
|
63
|
+
`A new version of forge-remote is available: ${localVersion} → ${remoteVersion}`,
|
|
64
|
+
);
|
|
65
|
+
log.warn(" Run: npm update -g forge-remote");
|
|
66
|
+
log.warn(" Then: forge-remote init (to update Firestore rules)");
|
|
67
|
+
console.log();
|
|
81
68
|
}
|
|
82
69
|
} catch {
|
|
83
70
|
// Network error, timeout, etc. — don't bother the user.
|
|
84
71
|
}
|
|
85
|
-
return false;
|
|
86
72
|
}
|