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,126 @@
|
|
|
1
|
+
// Forge Remote — Cloud Mode (managed pairing)
|
|
2
|
+
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
3
|
+
|
|
4
|
+
import { initializeApp } from "firebase/app";
|
|
5
|
+
import {
|
|
6
|
+
getFirestore,
|
|
7
|
+
doc,
|
|
8
|
+
setDoc,
|
|
9
|
+
onSnapshot,
|
|
10
|
+
deleteDoc,
|
|
11
|
+
serverTimestamp,
|
|
12
|
+
Timestamp,
|
|
13
|
+
} from "firebase/firestore";
|
|
14
|
+
import { hostname } from "os";
|
|
15
|
+
import qrcode from "qrcode-terminal";
|
|
16
|
+
|
|
17
|
+
import * as log from "./logger.js";
|
|
18
|
+
import { getPlatformName, getDesktopId } from "./desktop.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Forge Remote Cloud (managed Firebase project) — public web SDK config.
|
|
22
|
+
* Replace these placeholders with the real `forge-remote-cloud` values
|
|
23
|
+
* once the project has been provisioned.
|
|
24
|
+
*/
|
|
25
|
+
const CLOUD_CONFIG = {
|
|
26
|
+
apiKey: "REPLACE_WITH_CLOUD_WEB_API_KEY",
|
|
27
|
+
authDomain: "forge-remote-cloud.firebaseapp.com",
|
|
28
|
+
projectId: "forge-remote-cloud",
|
|
29
|
+
storageBucket: "forge-remote-cloud.firebasestorage.app",
|
|
30
|
+
messagingSenderId: "REPLACE_WITH_CLOUD_SENDER_ID",
|
|
31
|
+
appId: "REPLACE_WITH_CLOUD_WEB_APP_ID",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function isCloudConfigured() {
|
|
35
|
+
return !CLOUD_CONFIG.apiKey.startsWith("REPLACE_WITH_");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function generatePairingCode() {
|
|
39
|
+
return String(Math.floor(100000 + Math.random() * 900000));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Cloud-mode start: prints a 6-digit code, waits for the mobile app to
|
|
44
|
+
* claim it, then registers the desktop under `users/{uid}/desktops/`.
|
|
45
|
+
*
|
|
46
|
+
* Returns when the desktop has been claimed and `desktopId` is known.
|
|
47
|
+
*/
|
|
48
|
+
export async function pairWithCloud() {
|
|
49
|
+
if (!isCloudConfigured()) {
|
|
50
|
+
log.error(
|
|
51
|
+
"Cloud mode is not configured yet. Use BYOF: `npx forge-remote init`.",
|
|
52
|
+
);
|
|
53
|
+
log.info("Cloud project provisioning is in progress — check back soon.");
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const app = initializeApp(CLOUD_CONFIG, "forge-remote-cloud");
|
|
58
|
+
const db = getFirestore(app);
|
|
59
|
+
|
|
60
|
+
const code = generatePairingCode();
|
|
61
|
+
const desktopId = await getDesktopId();
|
|
62
|
+
const expiresAt = Timestamp.fromDate(new Date(Date.now() + 10 * 60 * 1000));
|
|
63
|
+
|
|
64
|
+
await setDoc(doc(db, "pairing_codes", code), {
|
|
65
|
+
desktopId,
|
|
66
|
+
hostname: hostname(),
|
|
67
|
+
platform: getPlatformName(),
|
|
68
|
+
createdAt: serverTimestamp(),
|
|
69
|
+
expiresAt,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
log.info("");
|
|
73
|
+
log.info("════════════════════════════════════════════");
|
|
74
|
+
log.info(` Forge Remote Cloud — Pairing Code: ${code}`);
|
|
75
|
+
log.info("════════════════════════════════════════════");
|
|
76
|
+
log.info("");
|
|
77
|
+
log.info(
|
|
78
|
+
"In the Forge Remote app, choose 'Get Started Free' and enter the code.",
|
|
79
|
+
);
|
|
80
|
+
log.info("Code expires in 10 minutes.");
|
|
81
|
+
qrcode.generate(`forgeremote://pair/${code}`, { small: true });
|
|
82
|
+
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const timeout = setTimeout(
|
|
85
|
+
() => {
|
|
86
|
+
reject(
|
|
87
|
+
new Error("Pairing code expired. Restart with --cloud to retry."),
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
10 * 60 * 1000,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const unsub = onSnapshot(doc(db, "pairing_codes", code), async (snap) => {
|
|
94
|
+
if (!snap.exists()) return;
|
|
95
|
+
const data = snap.data();
|
|
96
|
+
const userUid = data.userUid;
|
|
97
|
+
if (!userUid) return;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await setDoc(
|
|
101
|
+
doc(db, "users", userUid, "desktops", desktopId),
|
|
102
|
+
{
|
|
103
|
+
id: desktopId,
|
|
104
|
+
hostname: hostname(),
|
|
105
|
+
platform: getPlatformName(),
|
|
106
|
+
ownerUid: userUid,
|
|
107
|
+
status: "online",
|
|
108
|
+
lastHeartbeat: serverTimestamp(),
|
|
109
|
+
createdAt: serverTimestamp(),
|
|
110
|
+
},
|
|
111
|
+
{ merge: true },
|
|
112
|
+
);
|
|
113
|
+
await deleteDoc(doc(db, "pairing_codes", code));
|
|
114
|
+
log.success(`Paired with user ${userUid}`);
|
|
115
|
+
clearTimeout(timeout);
|
|
116
|
+
unsub();
|
|
117
|
+
resolve({ userUid, desktopId, app, db });
|
|
118
|
+
} catch (e) {
|
|
119
|
+
log.error(`Cloud pairing failed: ${e.message}`);
|
|
120
|
+
clearTimeout(timeout);
|
|
121
|
+
unsub();
|
|
122
|
+
reject(e);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Forge Remote — Build distribution
|
|
2
|
+
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
3
|
+
//
|
|
4
|
+
// Per-platform "publish this build somewhere installable" flows.
|
|
5
|
+
//
|
|
6
|
+
// web → existing deployToFirebaseHosting (channel deploy)
|
|
7
|
+
// android → existing deployToAppDistribution
|
|
8
|
+
// ios → uploadToTestFlight (this file)
|
|
9
|
+
//
|
|
10
|
+
// TestFlight is the right answer for iOS because Apple won't let you
|
|
11
|
+
// install a .ipa from a download URL — the user has to go through
|
|
12
|
+
// TestFlight, an MDM, or have their UDID registered against an ad-hoc
|
|
13
|
+
// profile. TestFlight Internal Testing (up to 100 testers) skips App
|
|
14
|
+
// Store review entirely, so it's a clean "build → install on phone"
|
|
15
|
+
// experience.
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from "child_process";
|
|
18
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
import * as log from "./logger.js";
|
|
22
|
+
import { loadTestflightCreds } from "./ios-setup.js";
|
|
23
|
+
|
|
24
|
+
function which(cmd) {
|
|
25
|
+
try {
|
|
26
|
+
execFileSync("/bin/sh", ["-c", `command -v ${cmd}`], {
|
|
27
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
28
|
+
});
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveIpa(projectPath, buildPath) {
|
|
36
|
+
const full = path.isAbsolute(buildPath)
|
|
37
|
+
? buildPath
|
|
38
|
+
: path.join(projectPath, buildPath);
|
|
39
|
+
if (!full.includes("*") && existsSync(full)) return full;
|
|
40
|
+
// Glob fallback for `build/ios/ipa/*.ipa`.
|
|
41
|
+
const dir = path.dirname(full);
|
|
42
|
+
if (!existsSync(dir)) return full;
|
|
43
|
+
try {
|
|
44
|
+
const files = readdirSync(dir)
|
|
45
|
+
.filter((f) => f.endsWith(".ipa"))
|
|
46
|
+
.map((f) => ({
|
|
47
|
+
p: path.join(dir, f),
|
|
48
|
+
m: statSync(path.join(dir, f)).mtimeMs,
|
|
49
|
+
}))
|
|
50
|
+
.sort((a, b) => b.m - a.m);
|
|
51
|
+
return files[0]?.p || full;
|
|
52
|
+
} catch {
|
|
53
|
+
return full;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Upload an .ipa to TestFlight using `xcrun altool`. Requires App Store
|
|
59
|
+
* Connect API key credentials — either:
|
|
60
|
+
*
|
|
61
|
+
* 1. ASC_API_KEY_PATH env var pointing at AuthKey_XYZ.p8, plus
|
|
62
|
+
* ASC_KEY_ID and ASC_ISSUER_ID env vars.
|
|
63
|
+
* 2. Username + app-specific password (legacy fallback) via
|
|
64
|
+
* APPLE_ID and APP_SPECIFIC_PASSWORD env vars.
|
|
65
|
+
*
|
|
66
|
+
* Returns:
|
|
67
|
+
* {
|
|
68
|
+
* uploaded: true,
|
|
69
|
+
* testflightUrl: "https://appstoreconnect.apple.com/.../testflight",
|
|
70
|
+
* openUrl: "itms-beta://"
|
|
71
|
+
* }
|
|
72
|
+
*/
|
|
73
|
+
export async function uploadToTestFlight(projectPath, buildPath, options = {}) {
|
|
74
|
+
if (!which("xcrun")) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
"xcrun not found. TestFlight upload requires Xcode Command Line Tools.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ipaPath = resolveIpa(projectPath, buildPath);
|
|
81
|
+
if (!existsSync(ipaPath)) {
|
|
82
|
+
throw new Error(`IPA not found at ${ipaPath}. Run an iOS build first.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Resolve credentials — saved manifest first, then options/env overrides.
|
|
86
|
+
const saved = loadTestflightCreds() || {};
|
|
87
|
+
const apiKeyPath =
|
|
88
|
+
options.apiKeyPath || process.env.ASC_API_KEY_PATH || saved.apiKeyPath;
|
|
89
|
+
const apiKeyId = options.apiKeyId || process.env.ASC_KEY_ID || saved.apiKeyId;
|
|
90
|
+
const apiIssuerId =
|
|
91
|
+
options.apiIssuerId || process.env.ASC_ISSUER_ID || saved.apiIssuerId;
|
|
92
|
+
|
|
93
|
+
const appleId = options.appleId || process.env.APPLE_ID;
|
|
94
|
+
const appPassword =
|
|
95
|
+
options.appSpecificPassword || process.env.APP_SPECIFIC_PASSWORD;
|
|
96
|
+
|
|
97
|
+
let authArgs;
|
|
98
|
+
if (apiKeyPath && apiKeyId && apiIssuerId) {
|
|
99
|
+
if (!existsSync(apiKeyPath)) {
|
|
100
|
+
throw new Error(`ASC API key not found at ${apiKeyPath}`);
|
|
101
|
+
}
|
|
102
|
+
authArgs = [
|
|
103
|
+
"--apiKey",
|
|
104
|
+
apiKeyId,
|
|
105
|
+
"--apiIssuer",
|
|
106
|
+
apiIssuerId,
|
|
107
|
+
"--apiKeyPath",
|
|
108
|
+
apiKeyPath,
|
|
109
|
+
];
|
|
110
|
+
} else if (appleId && appPassword) {
|
|
111
|
+
authArgs = ["--username", appleId, "--password", appPassword];
|
|
112
|
+
} else {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"TestFlight upload needs App Store Connect credentials. Set " +
|
|
115
|
+
"ASC_API_KEY_PATH + ASC_KEY_ID + ASC_ISSUER_ID, or APPLE_ID + " +
|
|
116
|
+
"APP_SPECIFIC_PASSWORD.",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
log.info(`Uploading ${path.basename(ipaPath)} to TestFlight...`);
|
|
121
|
+
|
|
122
|
+
const args = [
|
|
123
|
+
"altool",
|
|
124
|
+
"--upload-app",
|
|
125
|
+
"--type",
|
|
126
|
+
"ios",
|
|
127
|
+
"--file",
|
|
128
|
+
ipaPath,
|
|
129
|
+
...authArgs,
|
|
130
|
+
"--output-format",
|
|
131
|
+
"json",
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
const startedAt = Date.now();
|
|
135
|
+
try {
|
|
136
|
+
execFileSync("xcrun", args, {
|
|
137
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
138
|
+
timeout: 30 * 60 * 1000, // 30 min — uploads are slow.
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
throw new Error(`altool upload failed: ${err.message}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const duration = Math.round((Date.now() - startedAt) / 1000);
|
|
145
|
+
log.success(`TestFlight upload complete (${duration}s)`);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
uploaded: true,
|
|
149
|
+
duration,
|
|
150
|
+
testflightUrl:
|
|
151
|
+
options.appStoreConnectUrl || "https://appstoreconnect.apple.com/apps",
|
|
152
|
+
openUrl: "itms-beta://",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Smart router: given a platform + build path, pick the appropriate
|
|
158
|
+
* distribution backend. Used by the `deploy_distribute` relay command.
|
|
159
|
+
*/
|
|
160
|
+
export async function distribute({
|
|
161
|
+
platform,
|
|
162
|
+
projectPath,
|
|
163
|
+
buildPath,
|
|
164
|
+
options = {},
|
|
165
|
+
// Lazy-loaded to avoid a circular import with build-manager.
|
|
166
|
+
hostingFn,
|
|
167
|
+
appDistFn,
|
|
168
|
+
}) {
|
|
169
|
+
switch (platform) {
|
|
170
|
+
case "ios":
|
|
171
|
+
return {
|
|
172
|
+
backend: "testflight",
|
|
173
|
+
result: await uploadToTestFlight(projectPath, buildPath, options),
|
|
174
|
+
};
|
|
175
|
+
case "web":
|
|
176
|
+
if (!hostingFn) throw new Error("hostingFn not provided");
|
|
177
|
+
return {
|
|
178
|
+
backend: "hosting_preview",
|
|
179
|
+
result: await hostingFn(projectPath, buildPath, {
|
|
180
|
+
...options,
|
|
181
|
+
channelId: options.channelId || `preview-${Date.now()}`,
|
|
182
|
+
}),
|
|
183
|
+
};
|
|
184
|
+
case "android":
|
|
185
|
+
default:
|
|
186
|
+
if (!appDistFn) throw new Error("appDistFn not provided");
|
|
187
|
+
return {
|
|
188
|
+
backend: "app_distribution",
|
|
189
|
+
result: await appDistFn(projectPath, buildPath, options),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/ios-setup.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Forge Remote — iOS readiness + TestFlight credential setup
|
|
2
|
+
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
3
|
+
//
|
|
4
|
+
// Two prerequisites for iOS distribution:
|
|
5
|
+
// 1. Code signing — needs a Development Team set in Runner.xcodeproj
|
|
6
|
+
// and a matching signing identity in the Mac's keychain. Has to be
|
|
7
|
+
// configured in Xcode (Apple won't let us automate it).
|
|
8
|
+
// 2. App Store Connect API key — for `xcrun altool` uploads. Saved to
|
|
9
|
+
// ~/.forge-remote/asc-credentials.json (mode 0600) so future
|
|
10
|
+
// uploads just work.
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from "child_process";
|
|
13
|
+
import {
|
|
14
|
+
chmodSync,
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
statSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { homedir } from "os";
|
|
23
|
+
|
|
24
|
+
import * as log from "./logger.js";
|
|
25
|
+
|
|
26
|
+
const FORGE_DIR = path.join(homedir(), ".forge-remote");
|
|
27
|
+
const CREDS_PATH = path.join(FORGE_DIR, "asc-credentials.json");
|
|
28
|
+
const KEYS_DIR = path.join(FORGE_DIR, "asc-keys");
|
|
29
|
+
|
|
30
|
+
function ensureForgeDir() {
|
|
31
|
+
if (!existsSync(FORGE_DIR)) mkdirSync(FORGE_DIR, { recursive: true });
|
|
32
|
+
if (!existsSync(KEYS_DIR))
|
|
33
|
+
mkdirSync(KEYS_DIR, { recursive: true, mode: 0o700 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Readiness check ─────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Inspect a Flutter project for iOS-distribution readiness. Pure-read,
|
|
40
|
+
* never modifies anything. Returns:
|
|
41
|
+
*
|
|
42
|
+
* {
|
|
43
|
+
* signing: {
|
|
44
|
+
* ok: boolean,
|
|
45
|
+
* teamId: string | null,
|
|
46
|
+
* projectPath: string,
|
|
47
|
+
* hint: string | null,
|
|
48
|
+
* },
|
|
49
|
+
* testflight: {
|
|
50
|
+
* ok: boolean,
|
|
51
|
+
* hasApiKey: boolean,
|
|
52
|
+
* keyId: string | null,
|
|
53
|
+
* issuerId: string | null,
|
|
54
|
+
* hint: string | null,
|
|
55
|
+
* },
|
|
56
|
+
* xcode: { ok: boolean, hint: string | null },
|
|
57
|
+
* ready: boolean,
|
|
58
|
+
* }
|
|
59
|
+
*/
|
|
60
|
+
export function checkIosReadiness(projectPath) {
|
|
61
|
+
const xcode = checkXcodeAvailable();
|
|
62
|
+
const signing = checkSigning(projectPath);
|
|
63
|
+
const testflight = checkTestflightCreds();
|
|
64
|
+
return {
|
|
65
|
+
xcode,
|
|
66
|
+
signing,
|
|
67
|
+
testflight,
|
|
68
|
+
ready: xcode.ok && signing.ok && testflight.ok,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function checkXcodeAvailable() {
|
|
73
|
+
try {
|
|
74
|
+
execFileSync("xcrun", ["--version"], {
|
|
75
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
76
|
+
});
|
|
77
|
+
return { ok: true, hint: null };
|
|
78
|
+
} catch {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
hint: "Install Xcode Command Line Tools: xcode-select --install",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function checkSigning(projectPath) {
|
|
87
|
+
const xcodeprojPath = path.join(
|
|
88
|
+
projectPath,
|
|
89
|
+
"ios",
|
|
90
|
+
"Runner.xcodeproj",
|
|
91
|
+
"project.pbxproj",
|
|
92
|
+
);
|
|
93
|
+
if (!existsSync(xcodeprojPath)) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
teamId: null,
|
|
97
|
+
projectPath: xcodeprojPath,
|
|
98
|
+
hint: "No ios/Runner.xcodeproj found. Is this a Flutter iOS project?",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Read the pbxproj and look for DEVELOPMENT_TEAM in any non-default
|
|
103
|
+
// build configuration. Empty string or missing = not configured.
|
|
104
|
+
let pbx;
|
|
105
|
+
try {
|
|
106
|
+
pbx = readFileSync(xcodeprojPath, "utf-8");
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
teamId: null,
|
|
111
|
+
projectPath: xcodeprojPath,
|
|
112
|
+
hint: `Failed to read ${xcodeprojPath}: ${e.message}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const matches = [...pbx.matchAll(/DEVELOPMENT_TEAM = ([A-Z0-9]+);/g)]
|
|
117
|
+
.map((m) => m[1])
|
|
118
|
+
.filter((t) => t && t !== '""');
|
|
119
|
+
|
|
120
|
+
if (matches.length === 0) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
teamId: null,
|
|
124
|
+
projectPath: xcodeprojPath,
|
|
125
|
+
hint:
|
|
126
|
+
"Open Xcode → Runner → Signing & Capabilities → pick your Team. " +
|
|
127
|
+
"Or run `npx forge-remote setup-ios-signing` to open Xcode now.",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Confirm there's at least one matching signing identity in the keychain.
|
|
132
|
+
let hasIdentity = true;
|
|
133
|
+
try {
|
|
134
|
+
const out = execFileSync(
|
|
135
|
+
"security",
|
|
136
|
+
["find-identity", "-v", "-p", "codesigning"],
|
|
137
|
+
{ stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
|
|
138
|
+
);
|
|
139
|
+
hasIdentity = /Apple (Development|Distribution)/.test(out);
|
|
140
|
+
} catch {
|
|
141
|
+
hasIdentity = false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!hasIdentity) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
teamId: matches[0],
|
|
148
|
+
projectPath: xcodeprojPath,
|
|
149
|
+
hint:
|
|
150
|
+
"No Apple signing identity found in your keychain. In Xcode → " +
|
|
151
|
+
"Settings → Accounts, sign in with your Apple ID and download " +
|
|
152
|
+
"manual profiles.",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
teamId: matches[0],
|
|
159
|
+
projectPath: xcodeprojPath,
|
|
160
|
+
hint: null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function checkTestflightCreds() {
|
|
165
|
+
if (!existsSync(CREDS_PATH)) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
hasApiKey: false,
|
|
169
|
+
keyId: null,
|
|
170
|
+
issuerId: null,
|
|
171
|
+
hint:
|
|
172
|
+
"Save your App Store Connect API key from the mobile app, or " +
|
|
173
|
+
"run `npx forge-remote setup-testflight`.",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const data = JSON.parse(readFileSync(CREDS_PATH, "utf-8"));
|
|
178
|
+
const keyPath = data.apiKeyPath;
|
|
179
|
+
const ok =
|
|
180
|
+
data.apiKeyId && data.apiIssuerId && keyPath && existsSync(keyPath);
|
|
181
|
+
return {
|
|
182
|
+
ok,
|
|
183
|
+
hasApiKey: !!keyPath,
|
|
184
|
+
keyId: data.apiKeyId || null,
|
|
185
|
+
issuerId: data.apiIssuerId || null,
|
|
186
|
+
hint: ok ? null : "Saved credentials are incomplete. Re-run setup.",
|
|
187
|
+
};
|
|
188
|
+
} catch (e) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
hasApiKey: false,
|
|
192
|
+
keyId: null,
|
|
193
|
+
issuerId: null,
|
|
194
|
+
hint: `Couldn't read credentials: ${e.message}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Credential persistence ─────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Save App Store Connect API credentials. The mobile app passes the .p8
|
|
203
|
+
* file's contents inline (base64 or raw) since the phone can't push a
|
|
204
|
+
* file. We write the .p8 to ~/.forge-remote/asc-keys/AuthKey_<id>.p8
|
|
205
|
+
* and a manifest pointing to it.
|
|
206
|
+
*
|
|
207
|
+
* @param {{ apiKeyId: string, apiIssuerId: string, apiKeyContent: string,
|
|
208
|
+
* appStoreConnectUrl?: string }} creds
|
|
209
|
+
*/
|
|
210
|
+
export function saveTestflightCreds(creds) {
|
|
211
|
+
const { apiKeyId, apiIssuerId, apiKeyContent, appStoreConnectUrl } = creds;
|
|
212
|
+
if (!apiKeyId || !apiIssuerId || !apiKeyContent) {
|
|
213
|
+
throw new Error("apiKeyId, apiIssuerId, and apiKeyContent are required");
|
|
214
|
+
}
|
|
215
|
+
if (!/^[A-Z0-9]+$/.test(apiKeyId)) {
|
|
216
|
+
throw new Error("apiKeyId must be uppercase alphanumeric");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
ensureForgeDir();
|
|
220
|
+
|
|
221
|
+
const keyPath = path.join(KEYS_DIR, `AuthKey_${apiKeyId}.p8`);
|
|
222
|
+
// Normalize: accept "BEGIN PRIVATE KEY" content as-is, or base64 of it.
|
|
223
|
+
const trimmed = apiKeyContent.trim();
|
|
224
|
+
const content = trimmed.startsWith("-----BEGIN")
|
|
225
|
+
? trimmed + "\n"
|
|
226
|
+
: Buffer.from(trimmed, "base64").toString("utf-8");
|
|
227
|
+
writeFileSync(keyPath, content, { mode: 0o600 });
|
|
228
|
+
|
|
229
|
+
const manifest = {
|
|
230
|
+
apiKeyId,
|
|
231
|
+
apiIssuerId,
|
|
232
|
+
apiKeyPath: keyPath,
|
|
233
|
+
appStoreConnectUrl: appStoreConnectUrl || null,
|
|
234
|
+
updatedAt: new Date().toISOString(),
|
|
235
|
+
};
|
|
236
|
+
writeFileSync(CREDS_PATH, JSON.stringify(manifest, null, 2), {
|
|
237
|
+
mode: 0o600,
|
|
238
|
+
});
|
|
239
|
+
// Belt and suspenders: chmod in case writeFileSync mode was masked.
|
|
240
|
+
chmodSync(CREDS_PATH, 0o600);
|
|
241
|
+
chmodSync(keyPath, 0o600);
|
|
242
|
+
|
|
243
|
+
log.success(`Saved TestFlight credentials to ${CREDS_PATH}`);
|
|
244
|
+
return manifest;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Load saved credentials, or null if none exist. */
|
|
248
|
+
export function loadTestflightCreds() {
|
|
249
|
+
if (!existsSync(CREDS_PATH)) return null;
|
|
250
|
+
try {
|
|
251
|
+
const stat = statSync(CREDS_PATH);
|
|
252
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
253
|
+
log.warn(`Tightening permissions on ${CREDS_PATH}`);
|
|
254
|
+
chmodSync(CREDS_PATH, 0o600);
|
|
255
|
+
}
|
|
256
|
+
return JSON.parse(readFileSync(CREDS_PATH, "utf-8"));
|
|
257
|
+
} catch (e) {
|
|
258
|
+
log.warn(`Failed to load ASC credentials: ${e.message}`);
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ─── Xcode helpers ───────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Open Runner.xcworkspace (or .xcodeproj as a fallback) so the user can
|
|
267
|
+
* configure signing. Returns the path that was opened.
|
|
268
|
+
*/
|
|
269
|
+
export function openXcodeSigning(projectPath) {
|
|
270
|
+
const workspace = path.join(projectPath, "ios", "Runner.xcworkspace");
|
|
271
|
+
const xcodeproj = path.join(projectPath, "ios", "Runner.xcodeproj");
|
|
272
|
+
const target = existsSync(workspace) ? workspace : xcodeproj;
|
|
273
|
+
if (!existsSync(target)) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`No Xcode project found under ${path.join(projectPath, "ios")}`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
execFileSync("open", [target], { stdio: ["ignore", "ignore", "ignore"] });
|
|
279
|
+
log.info(`Opened ${target}`);
|
|
280
|
+
return target;
|
|
281
|
+
}
|