forge-remote 2.1.6 → 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 -75
- package/package.json +3 -2
- package/src/build-manager.js +185 -39
- package/src/claude-session-watcher.js +569 -0
- package/src/cli.js +13 -124
- package/src/session-manager.js +97 -195
- package/src/update-checker.js +10 -24
package/src/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import { hostname, homedir } from "os";
|
|
|
12
12
|
import qrcode from "qrcode-terminal";
|
|
13
13
|
|
|
14
14
|
import { runInit } from "./init.js";
|
|
15
|
-
import { initFirebase
|
|
15
|
+
import { initFirebase } from "./firebase.js";
|
|
16
16
|
import {
|
|
17
17
|
registerDesktop,
|
|
18
18
|
markOffline,
|
|
@@ -32,19 +32,13 @@ import { stopAllCaptures } from "./screenshot-manager.js";
|
|
|
32
32
|
import { scanProjects } from "./project-scanner.js";
|
|
33
33
|
import { startWebhookServer, stopWebhookServer } from "./webhook-server.js";
|
|
34
34
|
import { watchWebhookConfigs, stopWatching } from "./webhook-watcher.js";
|
|
35
|
+
import {
|
|
36
|
+
startClaudeSessionWatcher,
|
|
37
|
+
stopClaudeSessionWatcher,
|
|
38
|
+
} from "./claude-session-watcher.js";
|
|
35
39
|
import * as log from "./logger.js";
|
|
36
40
|
import { checkForUpdate } from "./update-checker.js";
|
|
37
41
|
import { deployFirestoreRules } from "./google-auth.js";
|
|
38
|
-
import { pairWithCloud } from "./cloud-mode.js";
|
|
39
|
-
import { watchMobileAiRequests } from "./mobile-ai-watcher.js";
|
|
40
|
-
import {
|
|
41
|
-
saveTestflightCreds,
|
|
42
|
-
openXcodeSigning,
|
|
43
|
-
checkIosReadiness,
|
|
44
|
-
} from "./ios-setup.js";
|
|
45
|
-
import { readFile as fsReadFile } from "node:fs/promises";
|
|
46
|
-
import { createInterface } from "node:readline/promises";
|
|
47
|
-
import { stdin as input, stdout as output } from "node:process";
|
|
48
42
|
// Channel auto-registration disabled — channels require
|
|
49
43
|
// --dangerously-load-development-channels flag during research preview.
|
|
50
44
|
// import { ensureChannelRegistered } from "./channel-setup.js";
|
|
@@ -174,30 +168,8 @@ program
|
|
|
174
168
|
.description(
|
|
175
169
|
"Start the desktop relay (register, heartbeat, listen for commands)",
|
|
176
170
|
)
|
|
177
|
-
.
|
|
178
|
-
.action(async (opts) => {
|
|
171
|
+
.action(async () => {
|
|
179
172
|
await checkForUpdate();
|
|
180
|
-
|
|
181
|
-
if (opts.cloud) {
|
|
182
|
-
// Managed mode — pair via 6-digit code, then run a minimal loop
|
|
183
|
-
// that streams sessions to the cloud project. The full cloud
|
|
184
|
-
// session-manager is wired up once auth + pairing land.
|
|
185
|
-
try {
|
|
186
|
-
const { userUid, desktopId } = await pairWithCloud();
|
|
187
|
-
log.success(`Cloud relay running for ${userUid} (${desktopId})`);
|
|
188
|
-
log.info(
|
|
189
|
-
"Cloud session-manager streaming is wired in a follow-up — " +
|
|
190
|
-
"pair-only mode for now.",
|
|
191
|
-
);
|
|
192
|
-
// Keep the process alive so the user can Ctrl+C to exit.
|
|
193
|
-
await new Promise(() => {});
|
|
194
|
-
} catch (e) {
|
|
195
|
-
log.error(e.message);
|
|
196
|
-
process.exit(1);
|
|
197
|
-
}
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
173
|
initFirebase();
|
|
202
174
|
const desktopId = await registerDesktop();
|
|
203
175
|
|
|
@@ -225,25 +197,6 @@ program
|
|
|
225
197
|
|
|
226
198
|
listenForCommands(desktopId);
|
|
227
199
|
|
|
228
|
-
// Start the Mobile AI watcher so the phone's "Ask Claude" features
|
|
229
|
-
// can route through this relay (using the local Claude subscription).
|
|
230
|
-
let stopMobileAi = () => {};
|
|
231
|
-
try {
|
|
232
|
-
const desktopDoc = await getDb()
|
|
233
|
-
.collection("desktops")
|
|
234
|
-
.doc(desktopId)
|
|
235
|
-
.get();
|
|
236
|
-
const ownerUid = desktopDoc.data()?.ownerUid;
|
|
237
|
-
if (ownerUid) {
|
|
238
|
-
stopMobileAi = watchMobileAiRequests(ownerUid);
|
|
239
|
-
log.info("Mobile AI watcher started (relay-via-Claude)");
|
|
240
|
-
} else {
|
|
241
|
-
log.info("Skipping Mobile AI watcher — desktop has no ownerUid yet");
|
|
242
|
-
}
|
|
243
|
-
} catch (e) {
|
|
244
|
-
log.warn(`Mobile AI watcher init failed: ${e.message}`);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
200
|
// Start webhook server with tunnel for external integrations.
|
|
248
201
|
const webhookServer = await startWebhookServer(desktopId);
|
|
249
202
|
if (webhookServer) {
|
|
@@ -251,6 +204,12 @@ program
|
|
|
251
204
|
log.info(`Webhook server ready at ${webhookServer.tunnelUrl}`);
|
|
252
205
|
}
|
|
253
206
|
|
|
207
|
+
// Mirror externally-spawned Claude Code sessions (those launched in
|
|
208
|
+
// a terminal, not via the mobile app). Reads from ~/.claude/projects
|
|
209
|
+
// and surfaces messages to Firestore for projects the user opted in
|
|
210
|
+
// to mirror. Per-project opt-in lives on the desktop doc.
|
|
211
|
+
await startClaudeSessionWatcher(desktopId);
|
|
212
|
+
|
|
254
213
|
// Print startup banner.
|
|
255
214
|
log.banner(hostname(), getPlatformName(), desktopId, projects.length);
|
|
256
215
|
|
|
@@ -274,10 +233,8 @@ program
|
|
|
274
233
|
|
|
275
234
|
try {
|
|
276
235
|
stopWatching();
|
|
277
|
-
try {
|
|
278
|
-
stopMobileAi();
|
|
279
|
-
} catch {}
|
|
280
236
|
await stopWebhookServer();
|
|
237
|
+
await stopClaudeSessionWatcher();
|
|
281
238
|
await shutdownAllSessions();
|
|
282
239
|
stopAllTunnels();
|
|
283
240
|
stopAllCaptures();
|
|
@@ -422,72 +379,4 @@ program
|
|
|
422
379
|
}
|
|
423
380
|
});
|
|
424
381
|
|
|
425
|
-
program
|
|
426
|
-
.command("setup-testflight")
|
|
427
|
-
.description(
|
|
428
|
-
"Save App Store Connect API credentials so iOS builds can upload to TestFlight",
|
|
429
|
-
)
|
|
430
|
-
.option("--key-id <id>", "App Store Connect API Key ID (10-char uppercase)")
|
|
431
|
-
.option("--issuer-id <id>", "App Store Connect Issuer ID (UUID)")
|
|
432
|
-
.option("--key-file <path>", "Path to AuthKey_XYZ.p8 from App Store Connect")
|
|
433
|
-
.action(async (opts) => {
|
|
434
|
-
const rl = createInterface({ input, output });
|
|
435
|
-
try {
|
|
436
|
-
const apiKeyId =
|
|
437
|
-
opts.keyId || (await rl.question("App Store Connect Key ID: ")).trim();
|
|
438
|
-
const apiIssuerId =
|
|
439
|
-
opts.issuerId ||
|
|
440
|
-
(await rl.question("App Store Connect Issuer ID (UUID): ")).trim();
|
|
441
|
-
const keyPath =
|
|
442
|
-
opts.keyFile ||
|
|
443
|
-
(await rl.question("Path to AuthKey_XYZ.p8 file: ")).trim();
|
|
444
|
-
|
|
445
|
-
if (!apiKeyId || !apiIssuerId || !keyPath) {
|
|
446
|
-
log.error("All three values are required.");
|
|
447
|
-
process.exit(1);
|
|
448
|
-
}
|
|
449
|
-
const apiKeyContent = await fsReadFile(keyPath, "utf-8");
|
|
450
|
-
saveTestflightCreds({ apiKeyId, apiIssuerId, apiKeyContent });
|
|
451
|
-
log.success(
|
|
452
|
-
"TestFlight credentials saved. Run an iOS build then choose " +
|
|
453
|
-
"'Upload to TestFlight' on your phone.",
|
|
454
|
-
);
|
|
455
|
-
} catch (e) {
|
|
456
|
-
log.error(`Setup failed: ${e.message}`);
|
|
457
|
-
process.exit(1);
|
|
458
|
-
} finally {
|
|
459
|
-
rl.close();
|
|
460
|
-
}
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
program
|
|
464
|
-
.command("setup-ios-signing")
|
|
465
|
-
.description(
|
|
466
|
-
"Open Xcode at the Runner project so you can configure code signing",
|
|
467
|
-
)
|
|
468
|
-
.option("--project <path>", "Project root (defaults to current directory)")
|
|
469
|
-
.action(async (opts) => {
|
|
470
|
-
const projectPath = opts.project || process.cwd();
|
|
471
|
-
try {
|
|
472
|
-
const opened = openXcodeSigning(projectPath);
|
|
473
|
-
log.info("");
|
|
474
|
-
log.info("In Xcode:");
|
|
475
|
-
log.info(" 1. Click 'Runner' in the project navigator");
|
|
476
|
-
log.info(" 2. Open the 'Signing & Capabilities' tab");
|
|
477
|
-
log.info(" 3. Pick your Team and a unique bundle identifier");
|
|
478
|
-
log.info(" 4. Make sure 'Automatically manage signing' is checked");
|
|
479
|
-
log.info(`Opened: ${opened}`);
|
|
480
|
-
|
|
481
|
-
const report = checkIosReadiness(projectPath);
|
|
482
|
-
log.info("");
|
|
483
|
-
log.info(`Signing ready: ${report.signing.ok ? "yes" : "not yet"}`);
|
|
484
|
-
if (!report.signing.ok && report.signing.hint) {
|
|
485
|
-
log.info(`Hint: ${report.signing.hint}`);
|
|
486
|
-
}
|
|
487
|
-
} catch (e) {
|
|
488
|
-
log.error(`Couldn't open Xcode: ${e.message}`);
|
|
489
|
-
process.exit(1);
|
|
490
|
-
}
|
|
491
|
-
});
|
|
492
|
-
|
|
493
382
|
program.parse();
|
package/src/session-manager.js
CHANGED
|
@@ -41,13 +41,10 @@ import {
|
|
|
41
41
|
listFirebaseProjects,
|
|
42
42
|
initFirebaseHosting,
|
|
43
43
|
startFirebaseLogin,
|
|
44
|
+
deployToCloudflarePages,
|
|
45
|
+
saveCloudflareCreds,
|
|
46
|
+
slugifyForCloudflarePages,
|
|
44
47
|
} from "./build-manager.js";
|
|
45
|
-
import { uploadToTestFlight, distribute } from "./distribution.js";
|
|
46
|
-
import {
|
|
47
|
-
checkIosReadiness,
|
|
48
|
-
saveTestflightCreds,
|
|
49
|
-
openXcodeSigning,
|
|
50
|
-
} from "./ios-setup.js";
|
|
51
48
|
|
|
52
49
|
// ---------------------------------------------------------------------------
|
|
53
50
|
// Resolve the user's shell environment so spawned processes inherit PATH,
|
|
@@ -185,6 +182,26 @@ export function getActiveSessionCount() {
|
|
|
185
182
|
return count;
|
|
186
183
|
}
|
|
187
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
|
+
|
|
188
205
|
/**
|
|
189
206
|
* Find an existing active/idle session for a given webhookId.
|
|
190
207
|
* Returns { sessionId, status } or null if no matching session exists.
|
|
@@ -491,11 +508,8 @@ const VALID_SESSION_COMMANDS = new Set([
|
|
|
491
508
|
"build_project",
|
|
492
509
|
"deploy_hosting",
|
|
493
510
|
"deploy_app_dist",
|
|
494
|
-
"
|
|
495
|
-
"
|
|
496
|
-
"check_ios_ready",
|
|
497
|
-
"save_testflight_creds",
|
|
498
|
-
"open_xcode_signing",
|
|
511
|
+
"deploy_cloudflare_pages",
|
|
512
|
+
"save_cloudflare_creds",
|
|
499
513
|
"check_adb",
|
|
500
514
|
"install_adb",
|
|
501
515
|
"check_deploy_ready",
|
|
@@ -600,9 +614,13 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
600
614
|
break;
|
|
601
615
|
case "git_status": {
|
|
602
616
|
log.command("git_status", sessionId.slice(0, 8));
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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);
|
|
606
624
|
await db
|
|
607
625
|
.collection("sessions")
|
|
608
626
|
.doc(sessionId)
|
|
@@ -613,10 +631,10 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
613
631
|
}
|
|
614
632
|
case "git_diff": {
|
|
615
633
|
log.command("git_diff", sessionId.slice(0, 8));
|
|
616
|
-
const
|
|
617
|
-
if (!
|
|
634
|
+
const projectPath = await resolveProjectPath(sessionId);
|
|
635
|
+
if (!projectPath) throw new Error("Session not found");
|
|
618
636
|
const filePath = data.payload?.filePath || null;
|
|
619
|
-
const diffResult = getGitDiff(
|
|
637
|
+
const diffResult = getGitDiff(projectPath, filePath);
|
|
620
638
|
const encodedPath = encodeURIComponent(filePath || "_all").replace(
|
|
621
639
|
/\./g,
|
|
622
640
|
"_",
|
|
@@ -631,10 +649,10 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
631
649
|
}
|
|
632
650
|
case "git_log": {
|
|
633
651
|
log.command("git_log", sessionId.slice(0, 8));
|
|
634
|
-
const
|
|
635
|
-
if (!
|
|
652
|
+
const projectPath = await resolveProjectPath(sessionId);
|
|
653
|
+
if (!projectPath) throw new Error("Session not found");
|
|
636
654
|
const count = data.payload?.count || 10;
|
|
637
|
-
const commits = getGitLog(
|
|
655
|
+
const commits = getGitLog(projectPath, count);
|
|
638
656
|
await db
|
|
639
657
|
.collection("sessions")
|
|
640
658
|
.doc(sessionId)
|
|
@@ -865,8 +883,8 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
865
883
|
break;
|
|
866
884
|
}
|
|
867
885
|
|
|
868
|
-
case "
|
|
869
|
-
log.command("
|
|
886
|
+
case "deploy_cloudflare_pages": {
|
|
887
|
+
log.command("deploy_cloudflare_pages", sessionId.slice(0, 8));
|
|
870
888
|
const sess = await getSessionInfo();
|
|
871
889
|
|
|
872
890
|
const deployRef = db
|
|
@@ -875,7 +893,7 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
875
893
|
.collection("deploys")
|
|
876
894
|
.doc();
|
|
877
895
|
await deployRef.set({
|
|
878
|
-
type: "
|
|
896
|
+
type: "cloudflare_pages",
|
|
879
897
|
status: "deploying",
|
|
880
898
|
startedAt: FieldValue.serverTimestamp(),
|
|
881
899
|
});
|
|
@@ -883,37 +901,26 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
883
901
|
try {
|
|
884
902
|
const buildPath = data.payload?.buildPath;
|
|
885
903
|
if (!buildPath) throw new Error("buildPath is required");
|
|
904
|
+
const projectName =
|
|
905
|
+
data.payload?.projectName || sess.projectName || "site";
|
|
906
|
+
const slug = slugifyForCloudflarePages(projectName);
|
|
886
907
|
|
|
887
|
-
const
|
|
888
|
-
const groups = data.payload?.groups;
|
|
889
|
-
const testers = data.payload?.testers;
|
|
890
|
-
const releaseNotes = data.payload?.releaseNotes;
|
|
891
|
-
|
|
892
|
-
const distResult = await deployToAppDistribution(
|
|
893
|
-
sess.projectPath,
|
|
894
|
-
buildPath,
|
|
895
|
-
{
|
|
896
|
-
projectId,
|
|
897
|
-
groups,
|
|
898
|
-
testers,
|
|
899
|
-
releaseNotes,
|
|
900
|
-
},
|
|
901
|
-
);
|
|
908
|
+
const result = await deployToCloudflarePages(buildPath, slug);
|
|
902
909
|
|
|
903
910
|
await deployRef.update({
|
|
904
911
|
status: "success",
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
appTesterUrl: distResult.appTesterUrl || null,
|
|
908
|
-
appId: distResult.appId || null,
|
|
912
|
+
url: result.url,
|
|
913
|
+
projectSlug: result.projectSlug,
|
|
909
914
|
completedAt: FieldValue.serverTimestamp(),
|
|
910
915
|
});
|
|
911
916
|
|
|
912
917
|
if (sess.desktopId) {
|
|
913
918
|
notifySessionComplete(sess.desktopId, sessionId, {
|
|
914
919
|
projectName: sess.projectName || "Unknown",
|
|
915
|
-
customTitle: "
|
|
916
|
-
customBody:
|
|
920
|
+
customTitle: "Deploy Complete",
|
|
921
|
+
customBody: result.url
|
|
922
|
+
? `Live at: ${result.url}`
|
|
923
|
+
: "Cloudflare Pages deployed",
|
|
917
924
|
}).catch(() => {});
|
|
918
925
|
}
|
|
919
926
|
} catch (err) {
|
|
@@ -927,87 +934,25 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
927
934
|
break;
|
|
928
935
|
}
|
|
929
936
|
|
|
930
|
-
case "
|
|
931
|
-
log.command("
|
|
932
|
-
const sess = await getSessionInfo();
|
|
933
|
-
const report = checkIosReadiness(sess.projectPath);
|
|
934
|
-
await db
|
|
935
|
-
.collection("sessions")
|
|
936
|
-
.doc(sessionId)
|
|
937
|
-
.collection("buildData")
|
|
938
|
-
.doc("iosReadiness")
|
|
939
|
-
.set({
|
|
940
|
-
...report,
|
|
941
|
-
checkedAt: FieldValue.serverTimestamp(),
|
|
942
|
-
});
|
|
943
|
-
break;
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
case "save_testflight_creds": {
|
|
947
|
-
log.command("save_testflight_creds", sessionId.slice(0, 8));
|
|
937
|
+
case "save_cloudflare_creds": {
|
|
938
|
+
log.command("save_cloudflare_creds", sessionId.slice(0, 8));
|
|
948
939
|
try {
|
|
949
|
-
const
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
appStoreConnectUrl: data.payload?.appStoreConnectUrl,
|
|
954
|
-
});
|
|
955
|
-
// Re-publish readiness so the wizard can advance.
|
|
956
|
-
const sess = await getSessionInfo();
|
|
957
|
-
const report = checkIosReadiness(sess.projectPath);
|
|
958
|
-
await db
|
|
959
|
-
.collection("sessions")
|
|
960
|
-
.doc(sessionId)
|
|
961
|
-
.collection("buildData")
|
|
962
|
-
.doc("iosReadiness")
|
|
963
|
-
.set({
|
|
964
|
-
...report,
|
|
965
|
-
lastSavedKeyId: manifest.apiKeyId,
|
|
966
|
-
checkedAt: FieldValue.serverTimestamp(),
|
|
967
|
-
});
|
|
968
|
-
} catch (err) {
|
|
969
|
-
await db
|
|
970
|
-
.collection("sessions")
|
|
971
|
-
.doc(sessionId)
|
|
972
|
-
.collection("buildData")
|
|
973
|
-
.doc("iosReadiness")
|
|
974
|
-
.set(
|
|
975
|
-
{
|
|
976
|
-
saveError: err.message,
|
|
977
|
-
checkedAt: FieldValue.serverTimestamp(),
|
|
978
|
-
},
|
|
979
|
-
{ merge: true },
|
|
980
|
-
);
|
|
981
|
-
throw err;
|
|
982
|
-
}
|
|
983
|
-
break;
|
|
984
|
-
}
|
|
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 });
|
|
985
944
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
const sess = await getSessionInfo();
|
|
989
|
-
try {
|
|
990
|
-
const opened = openXcodeSigning(sess.projectPath);
|
|
991
|
-
await db
|
|
992
|
-
.collection("sessions")
|
|
993
|
-
.doc(sessionId)
|
|
994
|
-
.collection("buildData")
|
|
995
|
-
.doc("iosReadiness")
|
|
996
|
-
.set(
|
|
997
|
-
{
|
|
998
|
-
xcodeOpenedAt: FieldValue.serverTimestamp(),
|
|
999
|
-
xcodeOpenedPath: opened,
|
|
1000
|
-
},
|
|
1001
|
-
{ merge: true },
|
|
1002
|
-
);
|
|
945
|
+
// Best-effort verification: list pages projects to confirm token works.
|
|
946
|
+
// (Failure here is surfaced to the caller via the catch below.)
|
|
1003
947
|
} catch (err) {
|
|
948
|
+
log.error(`save_cloudflare_creds failed: ${err.message}`);
|
|
1004
949
|
throw err;
|
|
1005
950
|
}
|
|
1006
951
|
break;
|
|
1007
952
|
}
|
|
1008
953
|
|
|
1009
|
-
case "
|
|
1010
|
-
log.command("
|
|
954
|
+
case "deploy_app_dist": {
|
|
955
|
+
log.command("deploy_app_dist", sessionId.slice(0, 8));
|
|
1011
956
|
const sess = await getSessionInfo();
|
|
1012
957
|
|
|
1013
958
|
const deployRef = db
|
|
@@ -1016,7 +961,7 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
1016
961
|
.collection("deploys")
|
|
1017
962
|
.doc();
|
|
1018
963
|
await deployRef.set({
|
|
1019
|
-
type: "
|
|
964
|
+
type: "app_distribution",
|
|
1020
965
|
status: "deploying",
|
|
1021
966
|
startedAt: FieldValue.serverTimestamp(),
|
|
1022
967
|
});
|
|
@@ -1025,92 +970,36 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
1025
970
|
const buildPath = data.payload?.buildPath;
|
|
1026
971
|
if (!buildPath) throw new Error("buildPath is required");
|
|
1027
972
|
|
|
1028
|
-
const
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
appStoreConnectUrl: data.payload?.appStoreConnectUrl,
|
|
1033
|
-
});
|
|
1034
|
-
|
|
1035
|
-
await deployRef.update({
|
|
1036
|
-
status: "success",
|
|
1037
|
-
testflightUrl: result.testflightUrl,
|
|
1038
|
-
openUrl: result.openUrl,
|
|
1039
|
-
completedAt: FieldValue.serverTimestamp(),
|
|
1040
|
-
});
|
|
1041
|
-
|
|
1042
|
-
if (sess.desktopId) {
|
|
1043
|
-
notifySessionComplete(sess.desktopId, sessionId, {
|
|
1044
|
-
projectName: sess.projectName || "Unknown",
|
|
1045
|
-
customTitle: "TestFlight Upload Complete",
|
|
1046
|
-
customBody: "Open TestFlight on your device to install",
|
|
1047
|
-
}).catch(() => {});
|
|
1048
|
-
}
|
|
1049
|
-
} catch (err) {
|
|
1050
|
-
await deployRef.update({
|
|
1051
|
-
status: "failed",
|
|
1052
|
-
error: err.message,
|
|
1053
|
-
completedAt: FieldValue.serverTimestamp(),
|
|
1054
|
-
});
|
|
1055
|
-
throw err;
|
|
1056
|
-
}
|
|
1057
|
-
break;
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
case "deploy_distribute": {
|
|
1061
|
-
// Smart per-platform distribute: ios → TestFlight, web → hosting
|
|
1062
|
-
// preview channel, android → App Distribution.
|
|
1063
|
-
log.command("deploy_distribute", sessionId.slice(0, 8));
|
|
1064
|
-
const sess = await getSessionInfo();
|
|
1065
|
-
|
|
1066
|
-
const deployRef = db
|
|
1067
|
-
.collection("sessions")
|
|
1068
|
-
.doc(sessionId)
|
|
1069
|
-
.collection("deploys")
|
|
1070
|
-
.doc();
|
|
1071
|
-
await deployRef.set({
|
|
1072
|
-
type: "distribute",
|
|
1073
|
-
status: "deploying",
|
|
1074
|
-
startedAt: FieldValue.serverTimestamp(),
|
|
1075
|
-
});
|
|
1076
|
-
|
|
1077
|
-
try {
|
|
1078
|
-
const platform = data.payload?.platform;
|
|
1079
|
-
const buildPath = data.payload?.buildPath;
|
|
1080
|
-
if (!platform) throw new Error("platform is required");
|
|
973
|
+
const projectId = data.payload?.projectId;
|
|
974
|
+
const groups = data.payload?.groups;
|
|
975
|
+
const testers = data.payload?.testers;
|
|
976
|
+
const releaseNotes = data.payload?.releaseNotes;
|
|
1081
977
|
|
|
1082
|
-
const
|
|
1083
|
-
|
|
1084
|
-
projectPath: sess.projectPath,
|
|
978
|
+
const distResult = await deployToAppDistribution(
|
|
979
|
+
sess.projectPath,
|
|
1085
980
|
buildPath,
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
981
|
+
{
|
|
982
|
+
projectId,
|
|
983
|
+
groups,
|
|
984
|
+
testers,
|
|
985
|
+
releaseNotes,
|
|
986
|
+
},
|
|
987
|
+
);
|
|
1090
988
|
|
|
1091
989
|
await deployRef.update({
|
|
1092
990
|
status: "success",
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
openUrl: result.openUrl || null,
|
|
1098
|
-
consoleUrl: result.consoleUrl || null,
|
|
1099
|
-
testerUrl: result.testerUrl || null,
|
|
1100
|
-
appTesterUrl: result.appTesterUrl || null,
|
|
991
|
+
consoleUrl: distResult.consoleUrl || null,
|
|
992
|
+
testerUrl: distResult.testerUrl || null,
|
|
993
|
+
appTesterUrl: distResult.appTesterUrl || null,
|
|
994
|
+
appId: distResult.appId || null,
|
|
1101
995
|
completedAt: FieldValue.serverTimestamp(),
|
|
1102
996
|
});
|
|
1103
997
|
|
|
1104
998
|
if (sess.desktopId) {
|
|
1105
|
-
const titleByBackend = {
|
|
1106
|
-
testflight: "TestFlight Upload Complete",
|
|
1107
|
-
hosting_preview: "Preview Live",
|
|
1108
|
-
app_distribution: "App Distributed",
|
|
1109
|
-
};
|
|
1110
999
|
notifySessionComplete(sess.desktopId, sessionId, {
|
|
1111
1000
|
projectName: sess.projectName || "Unknown",
|
|
1112
|
-
customTitle:
|
|
1113
|
-
customBody:
|
|
1001
|
+
customTitle: "App Distributed",
|
|
1002
|
+
customBody: "New build available for testers",
|
|
1114
1003
|
}).catch(() => {});
|
|
1115
1004
|
}
|
|
1116
1005
|
} catch (err) {
|
|
@@ -1272,7 +1161,7 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
1272
1161
|
|
|
1273
1162
|
const MODEL_DISPLAY_NAMES = {
|
|
1274
1163
|
sonnet: "Claude Sonnet 4.6",
|
|
1275
|
-
opus: "Claude Opus 4.
|
|
1164
|
+
opus: "Claude Opus 4.6",
|
|
1276
1165
|
haiku: "Claude Haiku 4.5",
|
|
1277
1166
|
};
|
|
1278
1167
|
|
|
@@ -1301,6 +1190,12 @@ export async function startNewSession(desktopId, payload) {
|
|
|
1301
1190
|
webhookMeta,
|
|
1302
1191
|
allowedTools,
|
|
1303
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,
|
|
1304
1199
|
} = payload || {};
|
|
1305
1200
|
const db = getDb();
|
|
1306
1201
|
const resolvedModel = model || "sonnet";
|
|
@@ -1341,6 +1236,9 @@ export async function startNewSession(desktopId, payload) {
|
|
|
1341
1236
|
tokenUsage: { input: 0, output: 0, totalCost: 0 },
|
|
1342
1237
|
startedAt: FieldValue.serverTimestamp(),
|
|
1343
1238
|
lastActivity: FieldValue.serverTimestamp(),
|
|
1239
|
+
...(continueLast
|
|
1240
|
+
? { continuedFromSessionId: previousSessionId || null }
|
|
1241
|
+
: {}),
|
|
1344
1242
|
});
|
|
1345
1243
|
|
|
1346
1244
|
// Copy ownerUid from desktop.
|
|
@@ -1365,7 +1263,11 @@ export async function startNewSession(desktopId, payload) {
|
|
|
1365
1263
|
turnToolCalls: 0, // Tool calls this turn (reset on each prompt)
|
|
1366
1264
|
turnTokensInput: 0, // Input tokens this turn
|
|
1367
1265
|
turnTokensOutput: 0, // Output tokens this turn
|
|
1368
|
-
|
|
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,
|
|
1369
1271
|
lastToolCall: null, // Last tool_use block (for permission requests)
|
|
1370
1272
|
permissionNeeded: false, // True when Claude reports permission denial
|
|
1371
1273
|
permissionWatcher: null, // Firestore unsubscribe for permission doc
|
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
|
}
|