stim 1.0.0 → 1.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/README.md +29 -10
- package/dist/android-BW8i_YxU.mjs +204 -0
- package/dist/android-Bcmev2s2.mjs +2334 -0
- package/dist/android-K63_0UfF.mjs +727 -0
- package/dist/android-cas-D6HPZ6UQ.mjs +1078 -0
- package/dist/android-cas-compiler.mjs +2 -2
- package/dist/app-install-NFxUahnH.mjs +1149 -0
- package/dist/build-lock-BsVoLDaq.mjs +645 -0
- package/dist/build-slots-D0b6sJyo.mjs +152 -0
- package/dist/{cache-manifest-4tH64LQ9.mjs → cache-manifest-Cu4_Znw9.mjs} +2 -2
- package/dist/cache-manifest.mjs +1 -1
- package/dist/cli.mjs +24 -27166
- package/dist/collector-run.d.mts +1 -0
- package/dist/collector-run.mjs +7 -5
- package/dist/command-output-8wHMEde0.mjs +289 -0
- package/dist/{config-7kmtuhO2.mjs → config-CMtIaTk9.mjs} +44 -13
- package/dist/deps-BLBsTNDS.mjs +435 -0
- package/dist/dev-client-RpvXxCXV.mjs +620 -0
- package/dist/device-CoCPmazS.mjs +250 -0
- package/dist/device-lease-Dpik41RY.mjs +335 -0
- package/dist/device-pool-B5pEVa3t.mjs +408 -0
- package/dist/device-remote-E81KAdXf.mjs +1209 -0
- package/dist/doctor-CqPeMsSm.mjs +1130 -0
- package/dist/doctor-D_CX7ib5.mjs +530 -0
- package/dist/error-diagnostics-Dw8Gn3yw.mjs +636 -0
- package/dist/{exec-bsN9MJXb.mjs → exec-CyylIdq9.mjs} +4 -2
- package/dist/gc-B7KkDinF.mjs +1665 -0
- package/dist/guide-BS2Xc24o.mjs +4503 -0
- package/dist/ios-B7KLgIp5.mjs +2733 -0
- package/dist/ios-D1h1Di39.mjs +451 -0
- package/dist/ios-device-D3pzTKQE.mjs +81 -0
- package/dist/ios-device-xNt0Lg6v.mjs +413 -0
- package/dist/logs-DcX9AvRF.mjs +176 -0
- package/dist/logs-query-oqn47Ffx.mjs +273 -0
- package/dist/metro-COslRK_F.mjs +184 -0
- package/dist/metro-store-CDDZHtPl.mjs +92 -0
- package/dist/ownership-CGPart6T.mjs +60 -0
- package/dist/ownership-CYOyqxUo.mjs +475 -0
- package/dist/ownership-claim-7zRxxTfZ.mjs +569 -0
- package/dist/{project-Dit1CckW.mjs → project-BBTVABUz.mjs} +12 -3
- package/dist/reclaim-C5Jmuahx.mjs +348 -0
- package/dist/reload-Z8nKifAk.mjs +332 -0
- package/dist/remote-cache-DDmJ1Roq.mjs +994 -0
- package/dist/{server-bare-BmTVVmBB.mjs → server-bare-DEk37Mz2.mjs} +8 -2
- package/dist/{server-expo-CHJe1r6j.mjs → server-expo-C3ktAwUG.mjs} +6 -5
- package/dist/server-expo-Drd7ksHu.mjs +2 -0
- package/dist/settings-DwU1_U_q.mjs +708 -0
- package/dist/spawn-entry-nCE6m770.mjs +14 -0
- package/dist/start-BrY4EuIg.mjs +822 -0
- package/dist/{state-2nush3MK.mjs → state-CZ-ER-8d.mjs} +21 -22
- package/dist/stats-BCWEVLrM.mjs +307 -0
- package/dist/stats-BsF15Bti.mjs +62 -0
- package/dist/status-DFI1Hfpg.mjs +393 -0
- package/dist/stop-B7DR4awK.mjs +3 -0
- package/dist/stop-Btg770vV.mjs +591 -0
- package/dist/supervisor-run.d.mts +0 -1
- package/dist/supervisor-run.mjs +4 -4
- package/dist/workspace-process-lock-BKfdys1q.mjs +47 -0
- package/dist/worktree-BPTbI-LN.mjs +1322 -0
- package/dist/worktree-C79xx_kM.mjs +543 -0
- package/dist/xcode-DeKCribl.mjs +1832 -0
- package/package.json +5 -5
- package/shim/bundle-response.cjs +5 -0
- package/dist/android-jc8MnUyL.mjs +0 -514
- package/dist/metro-store-COLl1pOk.mjs +0 -1249
- package/dist/server-expo-BZJm--wA.mjs +0 -2
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
import { t as getExecutor } from "./exec-CyylIdq9.mjs";
|
|
2
|
+
import { k as withDirLock, u as getConfigDir } from "./config-CMtIaTk9.mjs";
|
|
3
|
+
import { D as workspaceLogsDir, O as workspaceName, T as workspaceDir, a as clearRemoteSession, c as readMetroTunnel, k as workspaceStateFile, u as readRemoteSession } from "./state-CZ-ER-8d.mjs";
|
|
4
|
+
import { r as pidExists } from "./metro-COslRK_F.mjs";
|
|
5
|
+
import { z as planMetroReach } from "./settings-DwU1_U_q.mjs";
|
|
6
|
+
import { T as readMetroRecords, a as LAUNCH_ERROR, b as isBundleProof, p as devClientDeepLink, r as INSTALL_ERROR } from "./app-install-NFxUahnH.mjs";
|
|
7
|
+
import { S as sanitizeDeviceLabel } from "./ios-D1h1Di39.mjs";
|
|
8
|
+
import { t as withWorkspaceProcessLock } from "./workspace-process-lock-BKfdys1q.mjs";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
//#region src/engine/metro-gate.ts
|
|
13
|
+
const REMOTE_METRO_WRONG = "STIM_REMOTE_METRO_WRONG";
|
|
14
|
+
const GATE_TIMEOUT_MS = 25e3;
|
|
15
|
+
const POLL_MS = 250;
|
|
16
|
+
function probeBundleUrl(origin, platform, entryPoint = "index") {
|
|
17
|
+
const entry = entryPoint.replace(/^\/+/, "").replace(/\.(?:[cm]?[jt]sx?)$/, "");
|
|
18
|
+
return `${origin.replace(/\/+$/, "")}/${entry}.bundle?platform=${platform}&dev=true`;
|
|
19
|
+
}
|
|
20
|
+
const defaultProbe = async (url, signal) => {
|
|
21
|
+
try {
|
|
22
|
+
return (await fetch(url, {
|
|
23
|
+
signal,
|
|
24
|
+
redirect: "follow"
|
|
25
|
+
})).status;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
async function gateMetroOrigin({ origin, metroPort, platform, entryPoint = "index", readRecords, isProof, probe = defaultProbe, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), timeoutMs = GATE_TIMEOUT_MS }) {
|
|
31
|
+
const since = now();
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const request = probe(probeBundleUrl(origin, platform, entryPoint), controller.signal);
|
|
34
|
+
request.catch(() => {});
|
|
35
|
+
try {
|
|
36
|
+
const deadline = now() + timeoutMs;
|
|
37
|
+
while (now() < deadline) {
|
|
38
|
+
for (const record of readRecords()) if (isProof(record, since)) return { ok: true };
|
|
39
|
+
await sleep(POLL_MS);
|
|
40
|
+
}
|
|
41
|
+
} finally {
|
|
42
|
+
controller.abort();
|
|
43
|
+
}
|
|
44
|
+
const status = await request.catch(() => null);
|
|
45
|
+
return {
|
|
46
|
+
failed: true,
|
|
47
|
+
code: REMOTE_METRO_WRONG,
|
|
48
|
+
reason: describeMiss(origin, metroPort, status),
|
|
49
|
+
remedy: `Check that ${origin} forwards to port ${metroPort} on THIS machine. A tunnel built for a port this workspace no longer holds will answer normally and serve a different project. \`stim start\` prints the port it reserved.`
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function describeMiss(origin, metroPort, status) {
|
|
53
|
+
if (status === null) return `${origin} did not answer, so it cannot be this workspace's Metro (port ${metroPort}).`;
|
|
54
|
+
if (status >= 500) return `${origin} answered ${status}, so it reached a tunnel but not a dev server (port ${metroPort} may no longer be forwarded).`;
|
|
55
|
+
return `${origin} answered ${status}, but the request never reached THIS workspace's Metro on port ${metroPort} -- it is serving a different dev server.`;
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/engine/agent-device.ts
|
|
59
|
+
const DAEMON_TOKEN_ENV = "AGENT_DEVICE_DAEMON_AUTH_TOKEN";
|
|
60
|
+
const PROFILE_FILE = "agent-device.remote.json";
|
|
61
|
+
function sessionNameFor(label) {
|
|
62
|
+
return `stim-${sanitizeDeviceLabel(label)}`;
|
|
63
|
+
}
|
|
64
|
+
function remoteProfilePath(root) {
|
|
65
|
+
return join(workspaceDir(root), PROFILE_FILE);
|
|
66
|
+
}
|
|
67
|
+
function remoteProfile({ daemon, platform, label }) {
|
|
68
|
+
const scope = sessionNameFor(label);
|
|
69
|
+
return {
|
|
70
|
+
daemonBaseUrl: daemon.baseUrl,
|
|
71
|
+
daemonTransport: "http",
|
|
72
|
+
platform,
|
|
73
|
+
session: scope,
|
|
74
|
+
tenant: scope,
|
|
75
|
+
runId: scope,
|
|
76
|
+
sessionIsolation: "tenant"
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function daemonEnv(daemon) {
|
|
80
|
+
return { [DAEMON_TOKEN_ENV]: daemon.token };
|
|
81
|
+
}
|
|
82
|
+
function withProfile(profilePath, args) {
|
|
83
|
+
return [
|
|
84
|
+
...args,
|
|
85
|
+
"--remote-config",
|
|
86
|
+
profilePath
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
function connectArgs(profilePath) {
|
|
90
|
+
return withProfile(profilePath, ["connect", "--force"]);
|
|
91
|
+
}
|
|
92
|
+
function installArgs(profilePath, artifactPath) {
|
|
93
|
+
return withProfile(profilePath, ["install", artifactPath]);
|
|
94
|
+
}
|
|
95
|
+
function openArgs(profilePath, bundleId, url, metro = null) {
|
|
96
|
+
const positional = url ? [bundleId, url] : [bundleId];
|
|
97
|
+
const hint = metro ? [
|
|
98
|
+
"--metro-host",
|
|
99
|
+
metro.host,
|
|
100
|
+
"--metro-port",
|
|
101
|
+
metro.port
|
|
102
|
+
] : [];
|
|
103
|
+
return withProfile(profilePath, [
|
|
104
|
+
"open",
|
|
105
|
+
...positional,
|
|
106
|
+
"--relaunch",
|
|
107
|
+
...hint
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
function metroHintFrom(origin) {
|
|
111
|
+
let url;
|
|
112
|
+
try {
|
|
113
|
+
url = new URL(origin);
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const port = url.port || (url.protocol === "https:" ? "443" : url.protocol === "http:" ? "80" : "");
|
|
118
|
+
return port ? {
|
|
119
|
+
host: url.hostname,
|
|
120
|
+
port
|
|
121
|
+
} : null;
|
|
122
|
+
}
|
|
123
|
+
function acceptAlertArgs(profilePath) {
|
|
124
|
+
return withProfile(profilePath, ["alert", "accept"]);
|
|
125
|
+
}
|
|
126
|
+
function disconnectArgs(profilePath) {
|
|
127
|
+
return withProfile(profilePath, ["disconnect"]);
|
|
128
|
+
}
|
|
129
|
+
function closeArgs(profilePath) {
|
|
130
|
+
return withProfile(profilePath, ["close"]);
|
|
131
|
+
}
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/engine/eas-simulator.ts
|
|
134
|
+
const OWNED_PREFIX = "stim-";
|
|
135
|
+
const LIVE_STATUSES$1 = ["new", "in-progress"];
|
|
136
|
+
const LIVE_SESSION_STATUSES = /* @__PURE__ */ new Set(["NEW", "IN_PROGRESS"]);
|
|
137
|
+
const TERMINAL_SESSION_STATUSES = /* @__PURE__ */ new Set(["STOPPED", "ERRORED"]);
|
|
138
|
+
const SESSION_PLATFORMS = /* @__PURE__ */ new Set(["ios", "android"]);
|
|
139
|
+
function ownedSessionName(label) {
|
|
140
|
+
const clean = sanitizeDeviceLabel(label);
|
|
141
|
+
return `${OWNED_PREFIX}${clean.startsWith(OWNED_PREFIX) ? clean.slice(5) : clean}`;
|
|
142
|
+
}
|
|
143
|
+
function isOwnedSessionName(name) {
|
|
144
|
+
return typeof name === "string" && name.startsWith(OWNED_PREFIX);
|
|
145
|
+
}
|
|
146
|
+
function createSessionArgs({ label, platform, maxDurationMinutes = null }) {
|
|
147
|
+
const args = [
|
|
148
|
+
"sim",
|
|
149
|
+
"--platform",
|
|
150
|
+
platform,
|
|
151
|
+
"--json",
|
|
152
|
+
"--non-interactive",
|
|
153
|
+
"--out-config-type",
|
|
154
|
+
"env",
|
|
155
|
+
"--name",
|
|
156
|
+
ownedSessionName(label)
|
|
157
|
+
];
|
|
158
|
+
if (maxDurationMinutes) args.push("--max-duration-minutes", String(maxDurationMinutes));
|
|
159
|
+
return args;
|
|
160
|
+
}
|
|
161
|
+
function getSessionArgs(sessionId) {
|
|
162
|
+
return [
|
|
163
|
+
"simulator:get",
|
|
164
|
+
"--id",
|
|
165
|
+
sessionId,
|
|
166
|
+
"--json",
|
|
167
|
+
"--non-interactive"
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
function stopSessionArgs(sessionId) {
|
|
171
|
+
return [
|
|
172
|
+
"simulator:stop",
|
|
173
|
+
"--id",
|
|
174
|
+
sessionId,
|
|
175
|
+
"--json",
|
|
176
|
+
"--non-interactive"
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
function listOwnedSessionsArgs(after = null) {
|
|
180
|
+
const args = [
|
|
181
|
+
"simulator:list",
|
|
182
|
+
"--name",
|
|
183
|
+
OWNED_PREFIX,
|
|
184
|
+
...LIVE_STATUSES$1.flatMap((s) => ["--status", s]),
|
|
185
|
+
"--limit",
|
|
186
|
+
"100",
|
|
187
|
+
"--json",
|
|
188
|
+
"--non-interactive"
|
|
189
|
+
];
|
|
190
|
+
if (after) args.push("--after", after);
|
|
191
|
+
return args;
|
|
192
|
+
}
|
|
193
|
+
function remoteDaemonFrom(config) {
|
|
194
|
+
if (!isRecord(config)) return null;
|
|
195
|
+
const typename = str(config["__typename"]);
|
|
196
|
+
if (typename && typename !== "AgentDeviceRunSessionRemoteConfig") return null;
|
|
197
|
+
const baseUrl = str(config.agentDeviceRemoteSessionUrl);
|
|
198
|
+
const token = str(config.agentDeviceRemoteSessionToken);
|
|
199
|
+
if (!baseUrl || !token) return null;
|
|
200
|
+
const daemon = {
|
|
201
|
+
baseUrl,
|
|
202
|
+
token
|
|
203
|
+
};
|
|
204
|
+
const preview = str(config.webPreviewUrl);
|
|
205
|
+
if (preview) daemon.webPreviewUrl = preview;
|
|
206
|
+
return daemon;
|
|
207
|
+
}
|
|
208
|
+
function parseJson(stdout) {
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(stdout);
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function isRecord(value) {
|
|
216
|
+
return typeof value === "object" && value !== null;
|
|
217
|
+
}
|
|
218
|
+
function str(value) {
|
|
219
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
220
|
+
}
|
|
221
|
+
function inspectSessionForTeardown(stdout, sessionId) {
|
|
222
|
+
const data = parseJson(stdout);
|
|
223
|
+
if (!isRecord(data)) return {
|
|
224
|
+
action: "refused",
|
|
225
|
+
reason: `Session ${sessionId} lookup did not return valid JSON.`
|
|
226
|
+
};
|
|
227
|
+
const id = str(data.id);
|
|
228
|
+
if (!id) return {
|
|
229
|
+
action: "refused",
|
|
230
|
+
reason: `Session ${sessionId} lookup returned no session id.`
|
|
231
|
+
};
|
|
232
|
+
if (id !== sessionId) return {
|
|
233
|
+
action: "refused",
|
|
234
|
+
reason: `Session ${sessionId} lookup returned different session ${id}.`
|
|
235
|
+
};
|
|
236
|
+
const status = str(data.status);
|
|
237
|
+
if (!status) return {
|
|
238
|
+
action: "refused",
|
|
239
|
+
reason: `Session ${sessionId} lookup returned no status.`
|
|
240
|
+
};
|
|
241
|
+
const name = str(data.name);
|
|
242
|
+
if (!isOwnedSessionName(name)) return {
|
|
243
|
+
action: "refused",
|
|
244
|
+
reason: `Session ${sessionId} is not owned by Stim (name: ${name ?? "missing"}).`
|
|
245
|
+
};
|
|
246
|
+
if (TERMINAL_SESSION_STATUSES.has(status)) return {
|
|
247
|
+
action: "already-stopped",
|
|
248
|
+
name,
|
|
249
|
+
status
|
|
250
|
+
};
|
|
251
|
+
if (!LIVE_SESSION_STATUSES.has(status)) return {
|
|
252
|
+
action: "refused",
|
|
253
|
+
reason: `Session ${sessionId} has unknown status ${status}.`
|
|
254
|
+
};
|
|
255
|
+
return {
|
|
256
|
+
action: "stop",
|
|
257
|
+
name,
|
|
258
|
+
status
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function isDefinitiveMissingSessionError(error, sessionId) {
|
|
262
|
+
const candidate = error;
|
|
263
|
+
const stderr = typeof candidate?.stderr === "string" ? candidate.stderr : "";
|
|
264
|
+
const message = typeof candidate?.message === "string" ? candidate.message : "";
|
|
265
|
+
const escapedSessionId = sessionId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
266
|
+
const missingSession = new RegExp(`(?:device[\\s-]*run[\\s-]*session|simulator[\\s-]*session)[\\s:#]*\\b${escapedSessionId}\\b[\\s,:]*(?:(?:was\\s+)?not\\s+found|does\\s+not\\s+exist)\\b`, "i");
|
|
267
|
+
return `${stderr}\n${message}`.split(/\r?\n/).map((line) => line.replace(/\s+/g, " ").trim()).some((line) => missingSession.test(line));
|
|
268
|
+
}
|
|
269
|
+
function verifyStoppedSession(stdout, sessionId) {
|
|
270
|
+
const stopped = parseStoppedSession(stdout);
|
|
271
|
+
if (!stopped) return {
|
|
272
|
+
ok: false,
|
|
273
|
+
reason: `Stop for session ${sessionId} did not return valid JSON with an id.`
|
|
274
|
+
};
|
|
275
|
+
if (stopped.id !== sessionId) return {
|
|
276
|
+
ok: false,
|
|
277
|
+
reason: `Stop for session ${sessionId} returned different session ${stopped.id}.`
|
|
278
|
+
};
|
|
279
|
+
if (!stopped.status || !TERMINAL_SESSION_STATUSES.has(stopped.status)) return {
|
|
280
|
+
ok: false,
|
|
281
|
+
reason: `Stop for session ${sessionId} returned unknown status ${stopped.status ?? "missing"}.`
|
|
282
|
+
};
|
|
283
|
+
return { ok: true };
|
|
284
|
+
}
|
|
285
|
+
function parseCreatedSession(stdout) {
|
|
286
|
+
const data = parseJson(stdout);
|
|
287
|
+
if (!isRecord(data)) return null;
|
|
288
|
+
const id = str(data.id);
|
|
289
|
+
if (!id) return null;
|
|
290
|
+
return {
|
|
291
|
+
id,
|
|
292
|
+
name: str(data.name),
|
|
293
|
+
url: str(data.deviceRunSessionUrl),
|
|
294
|
+
daemon: remoteDaemonFrom(data.remoteConfig)
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function parseSessionListPage(stdout) {
|
|
298
|
+
const data = parseJson(stdout);
|
|
299
|
+
if (!isRecord(data) || !Array.isArray(data.sessions)) return {
|
|
300
|
+
ok: false,
|
|
301
|
+
reason: "EAS session list did not return valid JSON with a sessions array."
|
|
302
|
+
};
|
|
303
|
+
if (!isRecord(data.pageInfo) || typeof data.pageInfo.hasNextPage !== "boolean") return {
|
|
304
|
+
ok: false,
|
|
305
|
+
reason: "EAS session list page did not return valid pagination information."
|
|
306
|
+
};
|
|
307
|
+
const endCursor = data.pageInfo.endCursor;
|
|
308
|
+
if (endCursor !== null && typeof endCursor !== "string") return {
|
|
309
|
+
ok: false,
|
|
310
|
+
reason: "EAS session list returned a malformed pagination cursor."
|
|
311
|
+
};
|
|
312
|
+
return {
|
|
313
|
+
ok: true,
|
|
314
|
+
page: {
|
|
315
|
+
sessions: data.sessions,
|
|
316
|
+
hasNextPage: data.pageInfo.hasNextPage,
|
|
317
|
+
endCursor
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function findOrphanedOwnedSessions({ sessions, recordedSessionIds, projectScope }) {
|
|
322
|
+
const scope = typeof projectScope === "string" ? projectScope.trim() : "";
|
|
323
|
+
if (!scope) return {
|
|
324
|
+
orphaned: [],
|
|
325
|
+
notices: ["EAS session list has no current-project scope."]
|
|
326
|
+
};
|
|
327
|
+
const recorded = new Set(recordedSessionIds.filter((id) => typeof id === "string" && id.length > 0));
|
|
328
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
329
|
+
const conflicts = /* @__PURE__ */ new Set();
|
|
330
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
331
|
+
const notices = [];
|
|
332
|
+
for (const raw of sessions) {
|
|
333
|
+
if (!isRecord(raw)) {
|
|
334
|
+
notices.push("EAS session list contains a malformed entry.");
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const id = str(raw.id);
|
|
338
|
+
const name = str(raw.name);
|
|
339
|
+
if (!id) {
|
|
340
|
+
if (isOwnedSessionName(name)) notices.push("EAS session list contains an owned entry with no session id.");
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (!name) {
|
|
344
|
+
blocked.add(id);
|
|
345
|
+
candidates.delete(id);
|
|
346
|
+
notices.push(`EAS session ${id} has no name and cannot be classified as Stim-owned.`);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (!isOwnedSessionName(name)) continue;
|
|
350
|
+
const status = str(raw.status)?.toUpperCase().replace(/-/g, "_") ?? null;
|
|
351
|
+
if (!status) {
|
|
352
|
+
blocked.add(id);
|
|
353
|
+
candidates.delete(id);
|
|
354
|
+
notices.push(`Owned EAS session ${id} has no status.`);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (TERMINAL_SESSION_STATUSES.has(status)) continue;
|
|
358
|
+
if (!LIVE_SESSION_STATUSES.has(status)) {
|
|
359
|
+
blocked.add(id);
|
|
360
|
+
candidates.delete(id);
|
|
361
|
+
notices.push(`Owned EAS session ${id} has unknown status ${status}.`);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const platformValue = str(raw.platform);
|
|
365
|
+
const normalizedPlatform = platformValue?.toLowerCase() ?? null;
|
|
366
|
+
if (!normalizedPlatform || !SESSION_PLATFORMS.has(normalizedPlatform)) {
|
|
367
|
+
blocked.add(id);
|
|
368
|
+
candidates.delete(id);
|
|
369
|
+
notices.push(platformValue ? `Owned EAS session ${id} has unknown platform ${platformValue}.` : `Owned EAS session ${id} has no platform.`);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (recorded.has(id) || blocked.has(id)) continue;
|
|
373
|
+
const candidate = {
|
|
374
|
+
id,
|
|
375
|
+
name,
|
|
376
|
+
status,
|
|
377
|
+
platform: normalizedPlatform,
|
|
378
|
+
projectScope: scope
|
|
379
|
+
};
|
|
380
|
+
const previous = candidates.get(id);
|
|
381
|
+
if (!previous) {
|
|
382
|
+
candidates.set(id, candidate);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (previous.name !== candidate.name || previous.status !== candidate.status || previous.platform !== candidate.platform) {
|
|
386
|
+
conflicts.add(id);
|
|
387
|
+
candidates.delete(id);
|
|
388
|
+
notices.push(`EAS session list contains conflicting entries for session ${id}.`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
for (const id of [...conflicts, ...blocked]) candidates.delete(id);
|
|
392
|
+
return {
|
|
393
|
+
orphaned: [...candidates.values()],
|
|
394
|
+
notices
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function parseStoppedSession(stdout) {
|
|
398
|
+
const data = parseJson(stdout);
|
|
399
|
+
if (!isRecord(data)) return null;
|
|
400
|
+
const id = str(data.id);
|
|
401
|
+
if (!id) return null;
|
|
402
|
+
return {
|
|
403
|
+
id,
|
|
404
|
+
status: str(data.status)
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
//#endregion
|
|
408
|
+
//#region src/engine/eas-session-ledger.ts
|
|
409
|
+
function easMachineStateRoot() {
|
|
410
|
+
return join(homedir(), ".stim", "machine", "eas");
|
|
411
|
+
}
|
|
412
|
+
function easSessionLedgerFile(root = easMachineStateRoot()) {
|
|
413
|
+
return join(root, "sessions.json");
|
|
414
|
+
}
|
|
415
|
+
function validClaim(id, value) {
|
|
416
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
417
|
+
const claim = value;
|
|
418
|
+
if (claim.sessionId !== id) return null;
|
|
419
|
+
if (typeof claim.name !== "string" || !claim.name.startsWith("stim-")) return null;
|
|
420
|
+
if (claim.platform !== "ios" && claim.platform !== "android") return null;
|
|
421
|
+
if (typeof claim.workspaceRoot !== "string" || !isAbsolute(claim.workspaceRoot)) return null;
|
|
422
|
+
if (typeof claim.workspaceHome !== "string" || !isAbsolute(claim.workspaceHome)) return null;
|
|
423
|
+
if (typeof claim.stateFile !== "string" || !isAbsolute(claim.stateFile)) return null;
|
|
424
|
+
const workspaceRoot = resolve(claim.workspaceRoot);
|
|
425
|
+
const workspaceHome = resolve(claim.workspaceHome);
|
|
426
|
+
const stateFile = resolve(claim.stateFile);
|
|
427
|
+
const stateRelative = relative(workspaceHome, stateFile);
|
|
428
|
+
if (!stateRelative || stateRelative.startsWith("..") || isAbsolute(stateRelative)) return null;
|
|
429
|
+
if (stateFile !== join(workspaceHome, "workspaces", workspaceName(workspaceRoot), "state.json")) return null;
|
|
430
|
+
return {
|
|
431
|
+
sessionId: id,
|
|
432
|
+
name: claim.name,
|
|
433
|
+
platform: claim.platform,
|
|
434
|
+
workspaceRoot,
|
|
435
|
+
workspaceHome,
|
|
436
|
+
stateFile
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
function readEasSessionLedger(root = easMachineStateRoot()) {
|
|
440
|
+
const file = easSessionLedgerFile(root);
|
|
441
|
+
if (!existsSync(file)) return {
|
|
442
|
+
claims: /* @__PURE__ */ new Map(),
|
|
443
|
+
notice: null,
|
|
444
|
+
safe: true
|
|
445
|
+
};
|
|
446
|
+
let parsed;
|
|
447
|
+
try {
|
|
448
|
+
parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
449
|
+
} catch (error) {
|
|
450
|
+
return {
|
|
451
|
+
claims: /* @__PURE__ */ new Map(),
|
|
452
|
+
notice: `EAS ownership ledger ${file} is unreadable: ${error.message}`,
|
|
453
|
+
safe: false
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {
|
|
457
|
+
claims: /* @__PURE__ */ new Map(),
|
|
458
|
+
notice: `EAS ownership ledger ${file} is malformed.`,
|
|
459
|
+
safe: false
|
|
460
|
+
};
|
|
461
|
+
const ledger = parsed;
|
|
462
|
+
if (ledger.version !== 1 || !ledger.claims || typeof ledger.claims !== "object" || Array.isArray(ledger.claims)) return {
|
|
463
|
+
claims: /* @__PURE__ */ new Map(),
|
|
464
|
+
notice: `EAS ownership ledger ${file} is malformed.`,
|
|
465
|
+
safe: false
|
|
466
|
+
};
|
|
467
|
+
const claims = /* @__PURE__ */ new Map();
|
|
468
|
+
for (const [id, value] of Object.entries(ledger.claims)) {
|
|
469
|
+
const claim = validClaim(id, value);
|
|
470
|
+
if (!claim) return {
|
|
471
|
+
claims: /* @__PURE__ */ new Map(),
|
|
472
|
+
notice: `EAS ownership ledger ${file} is malformed.`,
|
|
473
|
+
safe: false
|
|
474
|
+
};
|
|
475
|
+
claims.set(id, claim);
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
claims,
|
|
479
|
+
notice: null,
|
|
480
|
+
safe: true
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function writeLedger(root, ledger) {
|
|
484
|
+
mkdirSync(root, { recursive: true });
|
|
485
|
+
const file = easSessionLedgerFile(root);
|
|
486
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
487
|
+
writeFileSync(tmp, `${JSON.stringify(ledger, null, 2)}\n`);
|
|
488
|
+
try {
|
|
489
|
+
renameSync(tmp, file);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
rmSync(tmp, { force: true });
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function recordEasSessionClaim(claim, root = easMachineStateRoot()) {
|
|
496
|
+
const normalized = validClaim(claim.sessionId, claim);
|
|
497
|
+
if (!normalized) throw new Error(`Invalid EAS session claim for ${claim.sessionId}.`);
|
|
498
|
+
withDirLock(join(root, "ledger.lock"), () => {
|
|
499
|
+
const current = readEasSessionLedger(root);
|
|
500
|
+
if (!current.safe) throw new Error(current.notice ?? "EAS ownership ledger is unreadable.");
|
|
501
|
+
current.claims.set(normalized.sessionId, normalized);
|
|
502
|
+
writeLedger(root, {
|
|
503
|
+
version: 1,
|
|
504
|
+
claims: Object.fromEntries(current.claims)
|
|
505
|
+
});
|
|
506
|
+
}, { ensureParent: () => mkdirSync(dirname(join(root, "ledger.lock")), { recursive: true }) });
|
|
507
|
+
}
|
|
508
|
+
function removeEasSessionClaim(sessionId, root = easMachineStateRoot()) {
|
|
509
|
+
const file = easSessionLedgerFile(root);
|
|
510
|
+
if (!existsSync(file)) return false;
|
|
511
|
+
return withDirLock(join(root, "ledger.lock"), () => {
|
|
512
|
+
const current = readEasSessionLedger(root);
|
|
513
|
+
if (!current.safe) return false;
|
|
514
|
+
if (!current.claims.delete(sessionId)) return false;
|
|
515
|
+
writeLedger(root, {
|
|
516
|
+
version: 1,
|
|
517
|
+
claims: Object.fromEntries(current.claims)
|
|
518
|
+
});
|
|
519
|
+
return true;
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
//#endregion
|
|
523
|
+
//#region src/engine/eas-project-lock.ts
|
|
524
|
+
const EAS_PROJECT_LOCK_WAIT_MS = 24e4;
|
|
525
|
+
function withEasProjectLock(_root, fn, options = {}) {
|
|
526
|
+
const { machineRoot = easMachineStateRoot(), ...lockOptions } = options;
|
|
527
|
+
return withWorkspaceProcessLock(machineRoot, "eas-project", fn, {
|
|
528
|
+
waitMs: EAS_PROJECT_LOCK_WAIT_MS,
|
|
529
|
+
...lockOptions,
|
|
530
|
+
external: true
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
//#endregion
|
|
534
|
+
//#region src/engine/device-remote.ts
|
|
535
|
+
const REMOTE_SESSION_ERROR = "STIM_NO_REMOTE_SESSION";
|
|
536
|
+
const REMOTE_METRO_ERROR = "STIM_REMOTE_METRO_UNREACHABLE";
|
|
537
|
+
function resolveMetroOrigin({ metroPort, publicUrl = null, mode = "auto", isExpo = false, available = [] }) {
|
|
538
|
+
const plan = planMetroReach({
|
|
539
|
+
mode,
|
|
540
|
+
metroPort,
|
|
541
|
+
publicUrl,
|
|
542
|
+
isExpo,
|
|
543
|
+
available
|
|
544
|
+
});
|
|
545
|
+
if ("origin" in plan) return plan;
|
|
546
|
+
if ("failed" in plan) return plan;
|
|
547
|
+
return {
|
|
548
|
+
failed: "Metro has no address the device can reach yet.",
|
|
549
|
+
remedy: "This is a Stim bug: the tunnel step did not run before the launch."
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function describe(err) {
|
|
553
|
+
const e = err;
|
|
554
|
+
return (typeof e?.stderr === "string" ? e.stderr.trim() : "") || String(e?.message ?? err);
|
|
555
|
+
}
|
|
556
|
+
const PROXY_CREDENTIAL_ENV = ["AGENT_DEVICE_DAEMON_BASE_URL", "AGENT_DEVICE_DAEMON_AUTH_TOKEN"];
|
|
557
|
+
function easExecOptions(root) {
|
|
558
|
+
return {
|
|
559
|
+
cwd: root,
|
|
560
|
+
omitEnv: PROXY_CREDENTIAL_ENV
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
const EAS_OPERATION_TIMEOUT_MS = 3e4;
|
|
564
|
+
function easBoundedExecOptions(root) {
|
|
565
|
+
return {
|
|
566
|
+
...easExecOptions(root),
|
|
567
|
+
timeoutMs: EAS_OPERATION_TIMEOUT_MS
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
function writeProfile(ctx, daemon) {
|
|
571
|
+
const path = remoteProfilePath(ctx.root);
|
|
572
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
573
|
+
const profile = remoteProfile({
|
|
574
|
+
daemon,
|
|
575
|
+
platform: ctx.platform ?? "ios",
|
|
576
|
+
label: ctx.label
|
|
577
|
+
});
|
|
578
|
+
writeFileSync(path, `${JSON.stringify(profile, null, 2)}\n`);
|
|
579
|
+
return path;
|
|
580
|
+
}
|
|
581
|
+
function remoteDeviceDeps(ctx) {
|
|
582
|
+
let session = null;
|
|
583
|
+
let createdSession = null;
|
|
584
|
+
const easEnv = easExecOptions(ctx.root);
|
|
585
|
+
return {
|
|
586
|
+
checkCapacity: () => null,
|
|
587
|
+
ensureDevice: async () => ({
|
|
588
|
+
deviceName: ctx.backend === "proxy" ? "remote device (your daemon)" : "EAS Simulator",
|
|
589
|
+
owned: true,
|
|
590
|
+
remote: true
|
|
591
|
+
}),
|
|
592
|
+
ensureBooted: async ({ out = () => {} } = {}) => {
|
|
593
|
+
session = null;
|
|
594
|
+
createdSession = null;
|
|
595
|
+
const note = (message) => {
|
|
596
|
+
try {
|
|
597
|
+
out(message);
|
|
598
|
+
} catch {}
|
|
599
|
+
};
|
|
600
|
+
let daemon = ctx.existingDaemon ?? null;
|
|
601
|
+
let id = null;
|
|
602
|
+
let createdHere = null;
|
|
603
|
+
if (!daemon) {
|
|
604
|
+
const recorded = readRemoteSession(ctx.root);
|
|
605
|
+
if (recorded && recorded.platform !== (ctx.platform ?? "ios")) return {
|
|
606
|
+
failed: true,
|
|
607
|
+
code: "STIM_REMOTE_PLATFORM_MISMATCH",
|
|
608
|
+
reason: `Session ${recorded.sessionId} belongs to ${recorded.platform ?? "an unknown platform"}, not ${ctx.platform ?? "ios"}.`,
|
|
609
|
+
remedy: "Run `stim stop` for this workspace before selecting a different remote platform."
|
|
610
|
+
};
|
|
611
|
+
if (recorded) {
|
|
612
|
+
const existing = readLiveDaemon(ctx, recorded.sessionId);
|
|
613
|
+
if (existing) {
|
|
614
|
+
note(`Reusing EAS Simulator session ${recorded.sessionId}.`);
|
|
615
|
+
daemon = existing;
|
|
616
|
+
id = recorded.sessionId;
|
|
617
|
+
} else {
|
|
618
|
+
note(`Recorded session ${recorded.sessionId} is not usable; verifying it before replacement.`);
|
|
619
|
+
const cleanup = teardownRemote(ctx, { sessionId: recorded.sessionId });
|
|
620
|
+
if (cleanup.status === "failed" || cleanup.reason) return {
|
|
621
|
+
failed: true,
|
|
622
|
+
code: "STIM_REMOTE_SESSION_CLEANUP",
|
|
623
|
+
reason: cleanup.reason ?? `Could not verify recorded EAS session ${recorded.sessionId}.`,
|
|
624
|
+
remedy: "Inspect the recorded session, then run `stim stop` again."
|
|
625
|
+
};
|
|
626
|
+
clearRemoteSession(ctx.root, recorded.sessionId);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (!daemon) {
|
|
631
|
+
note("Creating an EAS Simulator session (this takes a moment).");
|
|
632
|
+
let stdout;
|
|
633
|
+
try {
|
|
634
|
+
stdout = getExecutor().runFile(ctx.easBin, createSessionArgs({
|
|
635
|
+
label: ctx.label,
|
|
636
|
+
platform: ctx.platform ?? "ios",
|
|
637
|
+
maxDurationMinutes: ctx.maxDurationMinutes ?? null
|
|
638
|
+
}), easEnv);
|
|
639
|
+
} catch (err) {
|
|
640
|
+
return {
|
|
641
|
+
failed: true,
|
|
642
|
+
reason: `eas sim failed: ${describe(err)}`
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
const created = parseCreatedSession(stdout);
|
|
646
|
+
if (!created) return {
|
|
647
|
+
failed: true,
|
|
648
|
+
reason: "eas sim returned no session; run it by hand to see what it reported."
|
|
649
|
+
};
|
|
650
|
+
id = created.id;
|
|
651
|
+
createdHere = created.id;
|
|
652
|
+
daemon = created.daemon ?? await waitForDaemon(ctx, created.id, note, { sleep: ctx.sleep ?? defaultSleep });
|
|
653
|
+
if (!daemon) return abandonSession(ctx, created.id, `Session ${created.id} never published an agent-device endpoint. It may be an appium or argent session rather than an agent-device one.`);
|
|
654
|
+
}
|
|
655
|
+
let profilePath;
|
|
656
|
+
try {
|
|
657
|
+
profilePath = writeProfile(ctx, daemon);
|
|
658
|
+
} catch (err) {
|
|
659
|
+
const reason = `Could not write the remote connection profile: ${describe(err)}`;
|
|
660
|
+
if (createdHere) return abandonSession(ctx, createdHere, reason);
|
|
661
|
+
return {
|
|
662
|
+
failed: true,
|
|
663
|
+
reason
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
getExecutor().runFile(ctx.agentDeviceBin, closeArgs(profilePath), {
|
|
668
|
+
cwd: ctx.root,
|
|
669
|
+
env: daemonEnv(daemon)
|
|
670
|
+
});
|
|
671
|
+
} catch {}
|
|
672
|
+
try {
|
|
673
|
+
getExecutor().runFile(ctx.agentDeviceBin, connectArgs(profilePath), {
|
|
674
|
+
cwd: ctx.root,
|
|
675
|
+
env: daemonEnv(daemon)
|
|
676
|
+
});
|
|
677
|
+
} catch (err) {
|
|
678
|
+
if (createdHere) return abandonSession(ctx, createdHere, `agent-device connect failed: ${describe(err)}`);
|
|
679
|
+
return {
|
|
680
|
+
failed: true,
|
|
681
|
+
reason: `agent-device connect failed: ${describe(err)}`
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
session = {
|
|
685
|
+
id,
|
|
686
|
+
daemon,
|
|
687
|
+
profilePath
|
|
688
|
+
};
|
|
689
|
+
createdSession = createdHere;
|
|
690
|
+
if (daemon.webPreviewUrl) note(`Watch this device: ${daemon.webPreviewUrl}`);
|
|
691
|
+
return {
|
|
692
|
+
ok: true,
|
|
693
|
+
udid: id ?? daemonHostLabel(daemon.baseUrl)
|
|
694
|
+
};
|
|
695
|
+
},
|
|
696
|
+
installArtifact: (artifactPath) => {
|
|
697
|
+
if (!session) return notConnected(INSTALL_ERROR);
|
|
698
|
+
try {
|
|
699
|
+
getExecutor().runFile(ctx.agentDeviceBin, installArgs(session.profilePath, artifactPath), {
|
|
700
|
+
cwd: ctx.root,
|
|
701
|
+
env: daemonEnv(session.daemon)
|
|
702
|
+
});
|
|
703
|
+
return {
|
|
704
|
+
ok: true,
|
|
705
|
+
appPath: artifactPath
|
|
706
|
+
};
|
|
707
|
+
} catch (err) {
|
|
708
|
+
return {
|
|
709
|
+
failed: true,
|
|
710
|
+
code: INSTALL_ERROR,
|
|
711
|
+
reason: `agent-device install failed for ${artifactPath}: ${describe(err)}`
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
launchApp: ({ appId, metroPort, devClientScheme = null }) => {
|
|
716
|
+
if (!session) return notConnected(LAUNCH_ERROR);
|
|
717
|
+
if (metroPort === null) try {
|
|
718
|
+
getExecutor().runFile(ctx.agentDeviceBin, openArgs(session.profilePath, appId, null, null), {
|
|
719
|
+
cwd: ctx.root,
|
|
720
|
+
env: daemonEnv(session.daemon)
|
|
721
|
+
});
|
|
722
|
+
return {
|
|
723
|
+
ok: true,
|
|
724
|
+
mode: "launch"
|
|
725
|
+
};
|
|
726
|
+
} catch (err) {
|
|
727
|
+
return {
|
|
728
|
+
failed: true,
|
|
729
|
+
code: LAUNCH_ERROR,
|
|
730
|
+
reason: `agent-device open ${appId} failed: ${describe(err)}`
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
const origin = resolveMetroOrigin({
|
|
734
|
+
metroPort,
|
|
735
|
+
publicUrl: ctx.publicMetroUrl ?? null,
|
|
736
|
+
mode: ctx.tunnelMode ?? "auto",
|
|
737
|
+
isExpo: ctx.isExpo ?? false
|
|
738
|
+
});
|
|
739
|
+
if ("failed" in origin) return {
|
|
740
|
+
failed: true,
|
|
741
|
+
code: REMOTE_METRO_ERROR,
|
|
742
|
+
reason: `${origin.failed} ${origin.remedy}`
|
|
743
|
+
};
|
|
744
|
+
const url = devClientScheme ? devClientDeepLink(devClientScheme, origin.origin) : null;
|
|
745
|
+
try {
|
|
746
|
+
getExecutor().runFile(ctx.agentDeviceBin, openArgs(session.profilePath, appId, url, metroHintFrom(origin.origin)), {
|
|
747
|
+
cwd: ctx.root,
|
|
748
|
+
env: daemonEnv(session.daemon)
|
|
749
|
+
});
|
|
750
|
+
if (url) try {
|
|
751
|
+
getExecutor().runFile(ctx.agentDeviceBin, acceptAlertArgs(session.profilePath), {
|
|
752
|
+
cwd: ctx.root,
|
|
753
|
+
env: daemonEnv(session.daemon)
|
|
754
|
+
});
|
|
755
|
+
} catch {}
|
|
756
|
+
return {
|
|
757
|
+
ok: true,
|
|
758
|
+
mode: url ? "openurl" : "launch",
|
|
759
|
+
url: url ?? void 0,
|
|
760
|
+
jsLocation: origin.origin
|
|
761
|
+
};
|
|
762
|
+
} catch (err) {
|
|
763
|
+
return {
|
|
764
|
+
failed: true,
|
|
765
|
+
code: LAUNCH_ERROR,
|
|
766
|
+
reason: `agent-device open ${appId} failed: ${describe(err)}`
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
createdSessionId: () => createdSession,
|
|
771
|
+
abandonCreatedSession: () => {
|
|
772
|
+
if (!createdSession) return {
|
|
773
|
+
ok: true,
|
|
774
|
+
sessionId: null
|
|
775
|
+
};
|
|
776
|
+
const stopped = stopCreatedSession(ctx, createdSession);
|
|
777
|
+
if (stopped.ok) {
|
|
778
|
+
session = null;
|
|
779
|
+
createdSession = null;
|
|
780
|
+
}
|
|
781
|
+
return stopped;
|
|
782
|
+
},
|
|
783
|
+
webPreviewUrl: () => session?.daemon.webPreviewUrl ?? null
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
async function resolveRemoteContext({ root, label, backend, platform = "ios", easBin, env = process.env, lookupAgentDevice = defaultLookupAgentDevice, maxDurationMinutes = null }) {
|
|
787
|
+
const agentDeviceBin = lookupAgentDevice();
|
|
788
|
+
if (!agentDeviceBin) return {
|
|
789
|
+
failed: "agent-device is not on PATH, and it is what drives a remote device.",
|
|
790
|
+
remedy: `Install it (\`npm i -g agent-device\`), then run \`stim ${platform} --remote ${backend}\` again.`
|
|
791
|
+
};
|
|
792
|
+
const baseUrl = env.AGENT_DEVICE_DAEMON_BASE_URL?.trim();
|
|
793
|
+
const token = env.AGENT_DEVICE_DAEMON_AUTH_TOKEN?.trim();
|
|
794
|
+
let existingDaemon = null;
|
|
795
|
+
if (backend === "proxy") {
|
|
796
|
+
if (!baseUrl && !token) return {
|
|
797
|
+
failed: "The proxy backend requires AGENT_DEVICE_DAEMON_BASE_URL and AGENT_DEVICE_DAEMON_AUTH_TOKEN.",
|
|
798
|
+
remedy: "Export AGENT_DEVICE_DAEMON_BASE_URL and AGENT_DEVICE_DAEMON_AUTH_TOKEN, then run the device command with `--remote proxy` again.",
|
|
799
|
+
code: "STIM_REMOTE_PROXY_CONFIG"
|
|
800
|
+
};
|
|
801
|
+
if (!baseUrl) return {
|
|
802
|
+
failed: "The proxy backend requires AGENT_DEVICE_DAEMON_BASE_URL.",
|
|
803
|
+
remedy: "Export AGENT_DEVICE_DAEMON_BASE_URL, then run the device command with `--remote proxy` again.",
|
|
804
|
+
code: "STIM_REMOTE_PROXY_CONFIG"
|
|
805
|
+
};
|
|
806
|
+
if (!token) return {
|
|
807
|
+
failed: "The proxy backend requires AGENT_DEVICE_DAEMON_AUTH_TOKEN.",
|
|
808
|
+
remedy: "Export AGENT_DEVICE_DAEMON_AUTH_TOKEN, then run the device command with `--remote proxy` again.",
|
|
809
|
+
code: "STIM_REMOTE_PROXY_CONFIG"
|
|
810
|
+
};
|
|
811
|
+
existingDaemon = {
|
|
812
|
+
baseUrl,
|
|
813
|
+
token
|
|
814
|
+
};
|
|
815
|
+
} else if (!easBin) return {
|
|
816
|
+
failed: "The eas backend requires eas-cli.",
|
|
817
|
+
remedy: "Install eas-cli, then run the device command with `--remote eas` again.",
|
|
818
|
+
code: "STIM_REMOTE_EAS_UNAVAILABLE"
|
|
819
|
+
};
|
|
820
|
+
return { ctx: {
|
|
821
|
+
root,
|
|
822
|
+
label,
|
|
823
|
+
backend,
|
|
824
|
+
platform,
|
|
825
|
+
easBin: easBin ?? "",
|
|
826
|
+
agentDeviceBin,
|
|
827
|
+
maxDurationMinutes,
|
|
828
|
+
publicMetroUrl: null,
|
|
829
|
+
existingDaemon
|
|
830
|
+
} };
|
|
831
|
+
}
|
|
832
|
+
function binOnPath(bin) {
|
|
833
|
+
try {
|
|
834
|
+
return Boolean(getExecutor().runQuiet(`command -v ${bin}`, { timeoutMs: 5e3 }));
|
|
835
|
+
} catch {
|
|
836
|
+
return false;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
function defaultLookupAgentDevice() {
|
|
840
|
+
const found = getExecutor().runQuiet("command -v agent-device", { timeoutMs: 5e3 });
|
|
841
|
+
return (String(found ?? "").split("\n")[0] ?? "").trim() || null;
|
|
842
|
+
}
|
|
843
|
+
const DAEMON_WAIT_MS = 18e4;
|
|
844
|
+
const DAEMON_POLL_MS = 5e3;
|
|
845
|
+
async function waitForDaemon(ctx, sessionId, out, { sleep = defaultSleep } = {}) {
|
|
846
|
+
const attempts = Math.ceil(DAEMON_WAIT_MS / DAEMON_POLL_MS);
|
|
847
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
848
|
+
const daemon = readDaemon(ctx, sessionId);
|
|
849
|
+
if (daemon) return daemon;
|
|
850
|
+
if (attempt === 0) out(`Waiting for session ${sessionId} to become reachable.`);
|
|
851
|
+
await sleep(DAEMON_POLL_MS);
|
|
852
|
+
}
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
function defaultSleep(ms) {
|
|
856
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
857
|
+
}
|
|
858
|
+
function stopCreatedSession(ctx, sessionId) {
|
|
859
|
+
let stopOutput;
|
|
860
|
+
try {
|
|
861
|
+
stopOutput = getExecutor().runFile(ctx.easBin, stopSessionArgs(sessionId), easBoundedExecOptions(ctx.root));
|
|
862
|
+
} catch (err) {
|
|
863
|
+
return {
|
|
864
|
+
failed: true,
|
|
865
|
+
code: "STIM_REMOTE_SESSION_CLEANUP",
|
|
866
|
+
reason: `Stim could not stop session ${sessionId} (${describe(err)}). The session bills until its cap.`,
|
|
867
|
+
remedy: `Run \`eas simulator:stop --id ${sessionId}\`.`,
|
|
868
|
+
sessionId
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
const verified = verifyStoppedSession(stopOutput, sessionId);
|
|
872
|
+
if (!verified.ok) return {
|
|
873
|
+
failed: true,
|
|
874
|
+
code: "STIM_REMOTE_SESSION_CLEANUP",
|
|
875
|
+
reason: `Stim could not verify that session ${sessionId} stopped (${verified.reason}). The session bills until its cap.`,
|
|
876
|
+
remedy: `Run \`eas simulator:stop --id ${sessionId}\`.`,
|
|
877
|
+
sessionId
|
|
878
|
+
};
|
|
879
|
+
return {
|
|
880
|
+
ok: true,
|
|
881
|
+
sessionId
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
function abandonSession(ctx, sessionId, reason) {
|
|
885
|
+
const stopped = stopCreatedSession(ctx, sessionId);
|
|
886
|
+
if (stopped.ok) return {
|
|
887
|
+
failed: true,
|
|
888
|
+
reason: `${reason} The session was stopped.`
|
|
889
|
+
};
|
|
890
|
+
return {
|
|
891
|
+
failed: true,
|
|
892
|
+
code: stopped.code,
|
|
893
|
+
reason: `${reason} ${stopped.reason} ${stopped.remedy}`,
|
|
894
|
+
remedy: stopped.remedy
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
function withRemoteSessionLock(root, fn, options = {}) {
|
|
898
|
+
let canonicalRoot;
|
|
899
|
+
try {
|
|
900
|
+
canonicalRoot = realpathSync(root);
|
|
901
|
+
} catch {
|
|
902
|
+
canonicalRoot = resolve(root);
|
|
903
|
+
}
|
|
904
|
+
return withWorkspaceProcessLock(workspaceDir(canonicalRoot), "remote-session", fn, {
|
|
905
|
+
external: true,
|
|
906
|
+
waitMs: 24e4,
|
|
907
|
+
...options
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
async function ensureRemoteBootOwned({ root, platform, sessionName, startedAt, boot, createdSessionId, abandonCreatedSession, writeState, register = () => {}, withProjectLock = withEasProjectLock, withLock = withRemoteSessionLock, removeEasSessionClaim: removeClaim = removeEasSessionClaim, ledgerRoot = easMachineStateRoot() }) {
|
|
911
|
+
try {
|
|
912
|
+
return await withProjectLock(root, () => withLock(root, async () => {
|
|
913
|
+
register();
|
|
914
|
+
const booted = await boot();
|
|
915
|
+
if (booted.failed) return booted;
|
|
916
|
+
const sessionId = createdSessionId();
|
|
917
|
+
if (!sessionId) return booted;
|
|
918
|
+
try {
|
|
919
|
+
recordEasSessionClaim({
|
|
920
|
+
sessionId,
|
|
921
|
+
name: sessionName,
|
|
922
|
+
platform,
|
|
923
|
+
workspaceRoot: root,
|
|
924
|
+
workspaceHome: getConfigDir(),
|
|
925
|
+
stateFile: workspaceStateFile(root)
|
|
926
|
+
}, ledgerRoot);
|
|
927
|
+
writeState(root, { remoteDevice: {
|
|
928
|
+
platform,
|
|
929
|
+
sessionId,
|
|
930
|
+
startedAt
|
|
931
|
+
} });
|
|
932
|
+
return booted;
|
|
933
|
+
} catch (err) {
|
|
934
|
+
const cleanup = abandonCreatedSession();
|
|
935
|
+
if (cleanup.ok) {
|
|
936
|
+
let removalFailure = "the claim store did not remove it";
|
|
937
|
+
try {
|
|
938
|
+
if (removeClaim(sessionId, ledgerRoot)) return {
|
|
939
|
+
failed: true,
|
|
940
|
+
code: "STIM_REMOTE_SESSION_STATE",
|
|
941
|
+
reason: `Could not record EAS session ${sessionId}: ${describe(err)}. The session was stopped.`,
|
|
942
|
+
remedy: "Fix workspace state storage, then retry the remote command."
|
|
943
|
+
};
|
|
944
|
+
} catch (error) {
|
|
945
|
+
removalFailure = describe(error);
|
|
946
|
+
}
|
|
947
|
+
return {
|
|
948
|
+
failed: true,
|
|
949
|
+
code: "STIM_REMOTE_SESSION_CLEANUP",
|
|
950
|
+
reason: `Could not record EAS session ${sessionId}: ${describe(err)}. The session was stopped, but its ownership claim could not be removed: ${removalFailure}.`,
|
|
951
|
+
remedy: `The stopped session claim remains under ${ledgerRoot}. Repair that ownership ledger before retrying the remote command.`
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
return {
|
|
955
|
+
failed: true,
|
|
956
|
+
code: cleanup.code ?? "STIM_REMOTE_SESSION_CLEANUP",
|
|
957
|
+
reason: `Could not record EAS session ${sessionId}: ${describe(err)}. ${cleanup.reason ?? ""}`.trim(),
|
|
958
|
+
remedy: cleanup.remedy ?? `Run \`eas simulator:stop --id ${sessionId}\`.`
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
}), {
|
|
962
|
+
ownerPurpose: "EAS remote start",
|
|
963
|
+
machineRoot: ledgerRoot
|
|
964
|
+
});
|
|
965
|
+
} catch (err) {
|
|
966
|
+
return {
|
|
967
|
+
failed: true,
|
|
968
|
+
code: err.code ?? "STIM_NO_REMOTE_SESSION",
|
|
969
|
+
reason: describe(err),
|
|
970
|
+
remedy: "Retry the remote command after the other workspace operation finishes."
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
function daemonHostLabel(baseUrl) {
|
|
975
|
+
try {
|
|
976
|
+
return new URL(baseUrl).host;
|
|
977
|
+
} catch {
|
|
978
|
+
return baseUrl;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function notConnected(code) {
|
|
982
|
+
return {
|
|
983
|
+
failed: true,
|
|
984
|
+
code,
|
|
985
|
+
reason: "No remote session is connected; the device step did not complete."
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
const LIVE_STATUSES = /* @__PURE__ */ new Set(["NEW", "IN_PROGRESS"]);
|
|
989
|
+
function readLiveDaemon(ctx, sessionId) {
|
|
990
|
+
let stdout;
|
|
991
|
+
try {
|
|
992
|
+
stdout = getExecutor().runFile(ctx.easBin, getSessionArgs(sessionId), easBoundedExecOptions(ctx.root));
|
|
993
|
+
} catch {
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
try {
|
|
997
|
+
const inspection = inspectSessionForTeardown(stdout, sessionId);
|
|
998
|
+
if (inspection.action !== "stop" || !LIVE_STATUSES.has(inspection.status)) return null;
|
|
999
|
+
return remoteDaemonFrom(JSON.parse(stdout).remoteConfig);
|
|
1000
|
+
} catch {
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
function readDaemon(ctx, sessionId) {
|
|
1005
|
+
let stdout;
|
|
1006
|
+
try {
|
|
1007
|
+
stdout = getExecutor().runFile(ctx.easBin, getSessionArgs(sessionId), easBoundedExecOptions(ctx.root));
|
|
1008
|
+
} catch {
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
try {
|
|
1012
|
+
return remoteDaemonFrom(JSON.parse(stdout).remoteConfig);
|
|
1013
|
+
} catch {
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
function remoteIosDeps(ctx) {
|
|
1018
|
+
const shared = {
|
|
1019
|
+
...ctx,
|
|
1020
|
+
platform: "ios"
|
|
1021
|
+
};
|
|
1022
|
+
const core = remoteDeviceDeps(shared);
|
|
1023
|
+
return {
|
|
1024
|
+
ctx: shared,
|
|
1025
|
+
checkDeviceCapacity: core.checkCapacity,
|
|
1026
|
+
ensureOwnedDevice: core.ensureDevice,
|
|
1027
|
+
ensureBooted: core.ensureBooted,
|
|
1028
|
+
installIosApp: ({ appPath }) => core.installArtifact(appPath),
|
|
1029
|
+
launchIosApp: ({ bundleId, metroPort, devClientScheme }) => core.launchApp({
|
|
1030
|
+
appId: bundleId,
|
|
1031
|
+
metroPort,
|
|
1032
|
+
devClientScheme
|
|
1033
|
+
}),
|
|
1034
|
+
createdSessionId: core.createdSessionId,
|
|
1035
|
+
abandonCreatedSession: core.abandonCreatedSession,
|
|
1036
|
+
webPreviewUrl: core.webPreviewUrl
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
function remoteAndroidDeps(ctx) {
|
|
1040
|
+
const shared = {
|
|
1041
|
+
...ctx,
|
|
1042
|
+
platform: "android"
|
|
1043
|
+
};
|
|
1044
|
+
const core = remoteDeviceDeps(shared);
|
|
1045
|
+
return {
|
|
1046
|
+
ctx: shared,
|
|
1047
|
+
checkCapacity: core.checkCapacity,
|
|
1048
|
+
ensureDevice: core.ensureDevice,
|
|
1049
|
+
ensureDeviceBooted: async (opts) => {
|
|
1050
|
+
const booted = await core.ensureBooted(opts);
|
|
1051
|
+
return booted.ok ? {
|
|
1052
|
+
ok: true,
|
|
1053
|
+
serial: booted.udid
|
|
1054
|
+
} : booted;
|
|
1055
|
+
},
|
|
1056
|
+
install: ({ apkPath }) => core.installArtifact(apkPath),
|
|
1057
|
+
launch: ({ packageName, metroPort, devClientScheme }) => core.launchApp({
|
|
1058
|
+
appId: packageName,
|
|
1059
|
+
metroPort,
|
|
1060
|
+
devClientScheme
|
|
1061
|
+
}),
|
|
1062
|
+
createdSessionId: core.createdSessionId,
|
|
1063
|
+
abandonCreatedSession: core.abandonCreatedSession,
|
|
1064
|
+
webPreviewUrl: core.webPreviewUrl
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
function endRecordedSession({ root, sessionId, easBin, lookupAgentDevice, ledgerRoot }) {
|
|
1068
|
+
const agentDeviceBin = (lookupAgentDevice ?? defaultLookupAgentDevice)();
|
|
1069
|
+
if (!easBin) return {
|
|
1070
|
+
status: "failed",
|
|
1071
|
+
reason: `No eas-cli to stop session ${sessionId} with; it keeps billing until its max duration. Run \`eas simulator:stop --id ${sessionId}\`.`
|
|
1072
|
+
};
|
|
1073
|
+
return teardownRemote({
|
|
1074
|
+
root,
|
|
1075
|
+
label: "",
|
|
1076
|
+
backend: "eas",
|
|
1077
|
+
easBin,
|
|
1078
|
+
agentDeviceBin: agentDeviceBin ?? "",
|
|
1079
|
+
existingDaemon: null,
|
|
1080
|
+
easLedgerRoot: ledgerRoot
|
|
1081
|
+
}, { sessionId });
|
|
1082
|
+
}
|
|
1083
|
+
function teardownRemote(ctx, { sessionId }) {
|
|
1084
|
+
const profilePath = remoteProfilePath(ctx.root);
|
|
1085
|
+
try {
|
|
1086
|
+
getExecutor().runFile(ctx.agentDeviceBin, disconnectArgs(profilePath), { cwd: ctx.root });
|
|
1087
|
+
} catch {}
|
|
1088
|
+
if (!sessionId) return { status: "torn-down" };
|
|
1089
|
+
const ledger = readEasSessionLedger(ctx.easLedgerRoot);
|
|
1090
|
+
const claimNeedsRemoval = !ledger.safe || ledger.claims.has(sessionId);
|
|
1091
|
+
const removeClaim = () => {
|
|
1092
|
+
try {
|
|
1093
|
+
if ((ctx.removeEasSessionClaim ?? removeEasSessionClaim)(sessionId, ctx.easLedgerRoot) || !claimNeedsRemoval) return void 0;
|
|
1094
|
+
return `EAS session ${sessionId} is stopped, but its ownership claim could not be removed. The claim and workspace record were kept for reconciliation. Re-run stop.`;
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
return `EAS session ${sessionId} is stopped, but its ownership claim could not be removed: ${describe(error)}. The claim and workspace record were kept for reconciliation. Re-run stop.`;
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
let sessionOutput;
|
|
1100
|
+
try {
|
|
1101
|
+
sessionOutput = getExecutor().runFile(ctx.easBin, getSessionArgs(sessionId), easBoundedExecOptions(ctx.root));
|
|
1102
|
+
} catch (err) {
|
|
1103
|
+
if (isDefinitiveMissingSessionError(err, sessionId)) return {
|
|
1104
|
+
status: "torn-down",
|
|
1105
|
+
reason: removeClaim()
|
|
1106
|
+
};
|
|
1107
|
+
return {
|
|
1108
|
+
status: "failed",
|
|
1109
|
+
reason: `Could not verify EAS session ${sessionId}: ${describe(err)}. The session record was kept for retry.`
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
const inspection = inspectSessionForTeardown(sessionOutput, sessionId);
|
|
1113
|
+
if (inspection.action === "refused") return {
|
|
1114
|
+
status: "failed",
|
|
1115
|
+
reason: `${inspection.reason} The session record was kept for retry.`
|
|
1116
|
+
};
|
|
1117
|
+
if (inspection.action === "already-stopped") return {
|
|
1118
|
+
status: "torn-down",
|
|
1119
|
+
reason: removeClaim()
|
|
1120
|
+
};
|
|
1121
|
+
let stopOutput;
|
|
1122
|
+
try {
|
|
1123
|
+
stopOutput = getExecutor().runFile(ctx.easBin, stopSessionArgs(sessionId), easBoundedExecOptions(ctx.root));
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
return {
|
|
1126
|
+
status: "failed",
|
|
1127
|
+
reason: `eas simulator:stop ${sessionId} failed: ${describe(err)}. The session record was kept for retry.`
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
const verified = verifyStoppedSession(stopOutput, sessionId);
|
|
1131
|
+
if (!verified.ok) return {
|
|
1132
|
+
status: "failed",
|
|
1133
|
+
reason: `${verified.reason} The session record was kept for retry.`
|
|
1134
|
+
};
|
|
1135
|
+
return {
|
|
1136
|
+
status: "torn-down",
|
|
1137
|
+
reason: removeClaim()
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
async function ensureMetroReachable({ ctx, metroPort, isExpo, tunnelMode = "auto", publicUrl = null, available = [], env = process.env, readTunnelRecord = readMetroTunnel, isTunnelAlive = pidExists, gateOrigin = gateMetroOrigin }) {
|
|
1141
|
+
const { root } = ctx;
|
|
1142
|
+
const platform = ctx.platform ?? "ios";
|
|
1143
|
+
const publicMetroUrl = env["STIM_METRO_PUBLIC_URL"]?.trim() || publicUrl || null;
|
|
1144
|
+
const recorded = readTunnelRecord(root);
|
|
1145
|
+
const effectiveAvailable = recorded?.kind === "managed" && isTunnelAlive(recorded.pid) ? [recorded.provider, ...available.filter((provider) => provider !== recorded.provider)] : available;
|
|
1146
|
+
const plan = planMetroReach({
|
|
1147
|
+
mode: tunnelMode,
|
|
1148
|
+
metroPort,
|
|
1149
|
+
publicUrl: publicMetroUrl,
|
|
1150
|
+
isExpo,
|
|
1151
|
+
available: effectiveAvailable
|
|
1152
|
+
});
|
|
1153
|
+
if ("failed" in plan) return plan;
|
|
1154
|
+
let resolvedUrl = null;
|
|
1155
|
+
let gate = false;
|
|
1156
|
+
if ("origin" in plan) {
|
|
1157
|
+
resolvedUrl = plan.origin;
|
|
1158
|
+
gate = plan.gate;
|
|
1159
|
+
} else if ("expoTunnel" in plan) {
|
|
1160
|
+
if (!recorded || recorded.kind !== "expo") return {
|
|
1161
|
+
failed: "This workspace's Metro tunnels itself (metro.tunnel is expo, or auto on an Expo project), but no Expo tunnel URL is recorded.",
|
|
1162
|
+
remedy: "Run `stim start --remote` so Expo can establish its own tunnel before a remote device connects."
|
|
1163
|
+
};
|
|
1164
|
+
resolvedUrl = recorded.url;
|
|
1165
|
+
gate = true;
|
|
1166
|
+
} else {
|
|
1167
|
+
const port = Number(metroPort);
|
|
1168
|
+
const providerMatches = tunnelMode === "auto" || recorded?.kind === "managed" && recorded.provider === plan.start;
|
|
1169
|
+
if (recorded && recorded.kind === "managed" && recorded.port === port && providerMatches && isTunnelAlive(recorded.pid)) resolvedUrl = recorded.url;
|
|
1170
|
+
else return {
|
|
1171
|
+
failed: `No live managed Metro tunnel is recorded for port ${port}.`,
|
|
1172
|
+
remedy: "Run `stim start --remote`, then retry the device command."
|
|
1173
|
+
};
|
|
1174
|
+
gate = true;
|
|
1175
|
+
}
|
|
1176
|
+
if (gate && resolvedUrl) {
|
|
1177
|
+
const logsDir = workspaceLogsDir(root);
|
|
1178
|
+
const result = await gateOrigin({
|
|
1179
|
+
origin: resolvedUrl,
|
|
1180
|
+
metroPort,
|
|
1181
|
+
platform,
|
|
1182
|
+
entryPoint: bundleEntryPoint(root, isExpo),
|
|
1183
|
+
readRecords: () => readMetroRecords(logsDir),
|
|
1184
|
+
isProof: isBundleProof
|
|
1185
|
+
});
|
|
1186
|
+
if (result.failed) return {
|
|
1187
|
+
failed: result.reason ?? "Metro gate failed.",
|
|
1188
|
+
remedy: result.remedy ?? "",
|
|
1189
|
+
code: REMOTE_METRO_WRONG
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
ctx.publicMetroUrl = resolvedUrl;
|
|
1193
|
+
return { ok: true };
|
|
1194
|
+
}
|
|
1195
|
+
function bundleEntryPoint(root, isExpo) {
|
|
1196
|
+
if (!isExpo) return "index";
|
|
1197
|
+
try {
|
|
1198
|
+
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf-8"));
|
|
1199
|
+
if (typeof pkg.main !== "string" || !pkg.main.trim()) return "index";
|
|
1200
|
+
const main = pkg.main.trim();
|
|
1201
|
+
const local = main.startsWith(".") || /\.[cm]?[jt]sx?$/.test(main) || existsSync(resolve(root, main));
|
|
1202
|
+
const normalized = main.replace(/^\.\//, "").replace(/\.(?:[cm]?[jt]sx?)$/, "");
|
|
1203
|
+
return local ? normalized : `node_modules/${normalized}`;
|
|
1204
|
+
} catch {
|
|
1205
|
+
return "index";
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
//#endregion
|
|
1209
|
+
export { verifyStoppedSession as C, stopSessionArgs as S, inspectSessionForTeardown as _, ensureMetroReachable as a, ownedSessionName as b, remoteIosDeps as c, withEasProjectLock as d, easMachineStateRoot as f, getSessionArgs as g, findOrphanedOwnedSessions as h, endRecordedSession as i, resolveRemoteContext as l, removeEasSessionClaim as m, binOnPath as n, ensureRemoteBootOwned as o, readEasSessionLedger as p, bundleEntryPoint as r, remoteAndroidDeps as s, REMOTE_SESSION_ERROR as t, withRemoteSessionLock as u, isDefinitiveMissingSessionError as v, gateMetroOrigin as w, parseSessionListPage as x, listOwnedSessionsArgs as y };
|