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.
Files changed (66) hide show
  1. package/README.md +29 -10
  2. package/dist/android-BW8i_YxU.mjs +204 -0
  3. package/dist/android-Bcmev2s2.mjs +2334 -0
  4. package/dist/android-K63_0UfF.mjs +727 -0
  5. package/dist/android-cas-D6HPZ6UQ.mjs +1078 -0
  6. package/dist/android-cas-compiler.mjs +2 -2
  7. package/dist/app-install-NFxUahnH.mjs +1149 -0
  8. package/dist/build-lock-BsVoLDaq.mjs +645 -0
  9. package/dist/build-slots-D0b6sJyo.mjs +152 -0
  10. package/dist/{cache-manifest-4tH64LQ9.mjs → cache-manifest-Cu4_Znw9.mjs} +2 -2
  11. package/dist/cache-manifest.mjs +1 -1
  12. package/dist/cli.mjs +24 -27166
  13. package/dist/collector-run.d.mts +1 -0
  14. package/dist/collector-run.mjs +7 -5
  15. package/dist/command-output-8wHMEde0.mjs +289 -0
  16. package/dist/{config-7kmtuhO2.mjs → config-CMtIaTk9.mjs} +44 -13
  17. package/dist/deps-BLBsTNDS.mjs +435 -0
  18. package/dist/dev-client-RpvXxCXV.mjs +620 -0
  19. package/dist/device-CoCPmazS.mjs +250 -0
  20. package/dist/device-lease-Dpik41RY.mjs +335 -0
  21. package/dist/device-pool-B5pEVa3t.mjs +408 -0
  22. package/dist/device-remote-E81KAdXf.mjs +1209 -0
  23. package/dist/doctor-CqPeMsSm.mjs +1130 -0
  24. package/dist/doctor-D_CX7ib5.mjs +530 -0
  25. package/dist/error-diagnostics-Dw8Gn3yw.mjs +636 -0
  26. package/dist/{exec-bsN9MJXb.mjs → exec-CyylIdq9.mjs} +4 -2
  27. package/dist/gc-B7KkDinF.mjs +1665 -0
  28. package/dist/guide-BS2Xc24o.mjs +4503 -0
  29. package/dist/ios-B7KLgIp5.mjs +2733 -0
  30. package/dist/ios-D1h1Di39.mjs +451 -0
  31. package/dist/ios-device-D3pzTKQE.mjs +81 -0
  32. package/dist/ios-device-xNt0Lg6v.mjs +413 -0
  33. package/dist/logs-DcX9AvRF.mjs +176 -0
  34. package/dist/logs-query-oqn47Ffx.mjs +273 -0
  35. package/dist/metro-COslRK_F.mjs +184 -0
  36. package/dist/metro-store-CDDZHtPl.mjs +92 -0
  37. package/dist/ownership-CGPart6T.mjs +60 -0
  38. package/dist/ownership-CYOyqxUo.mjs +475 -0
  39. package/dist/ownership-claim-7zRxxTfZ.mjs +569 -0
  40. package/dist/{project-Dit1CckW.mjs → project-BBTVABUz.mjs} +12 -3
  41. package/dist/reclaim-C5Jmuahx.mjs +348 -0
  42. package/dist/reload-Z8nKifAk.mjs +332 -0
  43. package/dist/remote-cache-DDmJ1Roq.mjs +994 -0
  44. package/dist/{server-bare-BmTVVmBB.mjs → server-bare-DEk37Mz2.mjs} +8 -2
  45. package/dist/{server-expo-CHJe1r6j.mjs → server-expo-C3ktAwUG.mjs} +6 -5
  46. package/dist/server-expo-Drd7ksHu.mjs +2 -0
  47. package/dist/settings-DwU1_U_q.mjs +708 -0
  48. package/dist/spawn-entry-nCE6m770.mjs +14 -0
  49. package/dist/start-BrY4EuIg.mjs +822 -0
  50. package/dist/{state-2nush3MK.mjs → state-CZ-ER-8d.mjs} +21 -22
  51. package/dist/stats-BCWEVLrM.mjs +307 -0
  52. package/dist/stats-BsF15Bti.mjs +62 -0
  53. package/dist/status-DFI1Hfpg.mjs +393 -0
  54. package/dist/stop-B7DR4awK.mjs +3 -0
  55. package/dist/stop-Btg770vV.mjs +591 -0
  56. package/dist/supervisor-run.d.mts +0 -1
  57. package/dist/supervisor-run.mjs +4 -4
  58. package/dist/workspace-process-lock-BKfdys1q.mjs +47 -0
  59. package/dist/worktree-BPTbI-LN.mjs +1322 -0
  60. package/dist/worktree-C79xx_kM.mjs +543 -0
  61. package/dist/xcode-DeKCribl.mjs +1832 -0
  62. package/package.json +5 -5
  63. package/shim/bundle-response.cjs +5 -0
  64. package/dist/android-jc8MnUyL.mjs +0 -514
  65. package/dist/metro-store-COLl1pOk.mjs +0 -1249
  66. 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 };