stim 1.1.0 → 1.3.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 (64) hide show
  1. package/README.md +37 -0
  2. package/dist/{android-CC5bUer7.mjs → android-BW8i_YxU.mjs} +1 -1
  3. package/dist/android-BqVIJoG7.mjs +1368 -0
  4. package/dist/{android-DnZlT3aU.mjs → android-CqcW1hrP.mjs} +205 -58
  5. package/dist/{android-cas-DGlTStWu.mjs → android-cas-BR5YJBYv.mjs} +6 -6
  6. package/dist/android-cas-compiler.mjs +1 -1
  7. package/dist/{app-install-oZIQRbdD.mjs → app-install-DIp9riNx.mjs} +29 -21
  8. package/dist/{build-slots-CgacA9zQ.mjs → build-slots-C3-R4XQr.mjs} +3 -3
  9. package/dist/{cache-manifest-oeH18ERr.mjs → cache-manifest-CXR-Y8pH.mjs} +1 -1
  10. package/dist/cache-manifest.mjs +1 -1
  11. package/dist/cli.mjs +13 -13
  12. package/dist/collector-run.d.mts +4 -1
  13. package/dist/collector-run.mjs +36 -11
  14. package/dist/{config-D7uu8Gbq.mjs → config-CcefMY2Q.mjs} +94 -13
  15. package/dist/{deps-64wVnpZc.mjs → deps-Spz8ozgo.mjs} +2 -2
  16. package/dist/{dev-client-Bvky1hya.mjs → dev-client-BjisHDB5.mjs} +250 -10
  17. package/dist/{device-DJKv5Qej.mjs → device-BkiW-gkk.mjs} +19 -11
  18. package/dist/{device-lease-D-G3SgVe.mjs → device-lease-BJYybF-2.mjs} +107 -83
  19. package/dist/{device-pool-BlHXnYYs.mjs → device-pool-CPbLGfrB.mjs} +19 -13
  20. package/dist/{device-remote-CCgkG82J.mjs → device-remote-AkYCKS8U.mjs} +8 -8
  21. package/dist/{doctor-DlVxTtMq.mjs → doctor-Bmv86UUW.mjs} +18 -26
  22. package/dist/{doctor-BRF9aAJ3.mjs → doctor-fAMXC4vl.mjs} +10 -10
  23. package/dist/{error-diagnostics-B5IJwv3-.mjs → error-diagnostics-DHC31rXA.mjs} +65 -55
  24. package/dist/{exec-bsN9MJXb.mjs → exec-CyylIdq9.mjs} +4 -2
  25. package/dist/{gc-DhXdgSdt.mjs → gc-GGu-8ULN.mjs} +90 -67
  26. package/dist/{guide-DrhrDBo5.mjs → guide-92CPA21s.mjs} +414 -47
  27. package/dist/{ios-CK1Vrk6Q.mjs → ios-BSXFeO5H.mjs} +200 -59
  28. package/dist/{ios-BZxKPuqP.mjs → ios-Deh5js5V.mjs} +147 -40
  29. package/dist/{ios-device-CdSm0GDw.mjs → ios-device-CWOM_S44.mjs} +2 -2
  30. package/dist/{ios-device-CYbuRNXR.mjs → ios-device-D3pzTKQE.mjs} +1 -1
  31. package/dist/{logs-Dxoy16Qv.mjs → logs-DPNF4C9s.mjs} +9 -6
  32. package/dist/{logs-query-oqn47Ffx.mjs → logs-query-R-Cqc6Qv.mjs} +22 -5
  33. package/dist/{metro-B01H3EuD.mjs → metro-CORqXyTL.mjs} +3 -3
  34. package/dist/{ndjson-DcAtEx_K.mjs → ndjson-BZwI1NBv.mjs} +5 -2
  35. package/dist/{ownership-D8HmSv2E.mjs → ownership-8O073GIC.mjs} +3 -3
  36. package/dist/{ownership-B9QIdQOw.mjs → ownership-BpCKiRlQ.mjs} +4 -4
  37. package/dist/{ownership-claim-CeJLEpjD.mjs → ownership-claim-Bf2CAfw3.mjs} +4 -3
  38. package/dist/{project-COj6nzzh.mjs → project-fKmuIaMI.mjs} +2 -2
  39. package/dist/{reclaim-BPvN2HlP.mjs → reclaim-iW9Cad7k.mjs} +80 -73
  40. package/dist/{reload-DYZEBbZy.mjs → reload-DazHDSFA.mjs} +32 -24
  41. package/dist/{server-bare-BYHqEeyw.mjs → server-bare-DEzPZLvf.mjs} +7 -2
  42. package/dist/server-expo-44em5Pmg.mjs +2 -0
  43. package/dist/{server-expo-BEMr_m6p.mjs → server-expo-DizT4OMk.mjs} +5 -5
  44. package/dist/{settings-BSwrY4FZ.mjs → settings-DEJf6_kJ.mjs} +26 -5
  45. package/dist/slot-launch-cci0xvmC.mjs +19 -0
  46. package/dist/{start-ByCylYtB.mjs → start-mObjQH2W.mjs} +12 -12
  47. package/dist/{state-IYEY-zi8.mjs → state-B3XA2wp2.mjs} +9 -9
  48. package/dist/{stats-wqxPm7Tv.mjs → stats-BR-kwj0B.mjs} +1 -1
  49. package/dist/{stats-DZ4Q5J-7.mjs → stats-CaaQr4yN.mjs} +3 -3
  50. package/dist/{status-Dia0XTqG.mjs → status-BxQNqu9t.mjs} +86 -25
  51. package/dist/{stop-D6o5zAdm.mjs → stop-CrHGcjrV.mjs} +2 -2
  52. package/dist/{stop-BApyaF21.mjs → stop-DimGXEKY.mjs} +93 -42
  53. package/dist/supervisor-run.d.mts +1 -2
  54. package/dist/supervisor-run.mjs +5 -5
  55. package/dist/{remote-cache-DOi5-zLj.mjs → teardown-ClFp1Aax.mjs} +889 -830
  56. package/dist/{workspace-process-lock-CxOO9-pf.mjs → workspace-process-lock-3NcQnZ0X.mjs} +1 -1
  57. package/dist/{worktree-DxEmUI_n.mjs → worktree-DxTgN1CJ.mjs} +16 -16
  58. package/dist/{worktree-Dx3ZC5lS.mjs → worktree-Eq6TLH8N.mjs} +7 -3
  59. package/dist/{xcode-CDB6fytw.mjs → xcode-D7sAp-Zy.mjs} +256 -103
  60. package/package.json +5 -5
  61. package/shim/bundle-response.cjs +5 -0
  62. package/dist/android-xmyGBNy5.mjs +0 -665
  63. package/dist/build-lock-vE45ZzJZ.mjs +0 -645
  64. package/dist/server-expo-rb0k-0w7.mjs +0 -2
@@ -1,934 +1,993 @@
1
- import { t as getExecutor } from "./exec-bsN9MJXb.mjs";
2
- import { C as withConfigLock, g as loadConfig, s as ensureConfig, y as saveConfig } from "./config-D7uu8Gbq.mjs";
3
- import { r as createNdjsonWriter } from "./ndjson-DcAtEx_K.mjs";
4
- import { D as workspaceLogsDir } from "./state-IYEY-zi8.mjs";
5
- import { l as resolvePackageJson } from "./project-COj6nzzh.mjs";
1
+ import { t as getExecutor } from "./exec-CyylIdq9.mjs";
2
+ import { B as removeSlotDevice, C as withConfigLock, F as assignSlotDevice, L as deviceSlotPlatforms, g as loadConfig, s as ensureConfig, y as saveConfig, z as projectDeviceSlots } from "./config-CcefMY2Q.mjs";
3
+ import { r as createNdjsonWriter } from "./ndjson-BZwI1NBv.mjs";
4
+ import { D as workspaceLogsDir } from "./state-B3XA2wp2.mjs";
5
+ import { l as resolvePackageJson } from "./project-fKmuIaMI.mjs";
6
6
  import { n as stripAnsi } from "./process-output-C7IlIzd7.mjs";
7
- import { S as shutdownIosSim, a as deleteIosSim, b as resolveOwnedIosSim, g as renameIosSim, h as parseRuntimeVersion, m as parkedSimName, o as deleteParkedIosSim, r as clearAppDataContainer, s as findAppDataContainer, u as listIosDeviceTypes } from "./ios-BZxKPuqP.mjs";
8
- import { A as resolveOwnedAvdSerial, C as ownedAvdMatchesConfiguration, M as shutdownAndroidEmulator, N as waitForAndroidEmulatorShutdown, T as ownedAvdSystemImage, d as deleteAvd, o as assertOwnedAvdStopped } from "./android-xmyGBNy5.mjs";
9
- import { n as expoBinFromPackage, r as expoBinPath, s as findBinUpward } from "./server-expo-BEMr_m6p.mjs";
7
+ import { E as ownedAvdMatchesConfiguration, F as shutdownAndroidEmulator, I as waitForAndroidEmulatorShutdown, N as resolveOwnedAvdSerial, O as ownedAvdSystemImage, S as listOrphanedAvdDirectories, T as ownedAvdDirectory, l as avdStorageRoots, o as assertOwnedAvdStopped, p as deleteAvd, s as avdPathExists } from "./android-BqVIJoG7.mjs";
8
+ import { n as expoBinFromPackage, r as expoBinPath, s as findBinUpward } from "./server-expo-DizT4OMk.mjs";
9
+ import { C as shutdownIosSim, _ as renameIosSim, a as deleteIosSim, d as listIosDeviceTypes, g as parseRuntimeVersion, h as parkedSimName, o as deleteParkedIosSim, r as clearAppDataContainer, s as findAppDataContainer, x as resolveOwnedIosSim } from "./ios-Deh5js5V.mjs";
10
10
  import { createRequire } from "node:module";
11
- import { existsSync, readFileSync } from "node:fs";
11
+ import { existsSync, lstatSync, readFileSync, renameSync, rmSync } from "node:fs";
12
12
  import { randomUUID } from "node:crypto";
13
- import { join } from "node:path";
13
+ import { dirname, join } from "node:path";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import { format } from "node:util";
16
16
  import { AsyncLocalStorage } from "node:async_hooks";
17
- const POOL_SETTING_REMEDY = "Run `stim guide settings` for the device pool bounds and where it can be set.";
18
- const MAX_SETTING = {
19
- ios: {
20
- key: "iosParkedMax",
21
- env: "STIM_POOL_IOS_PARKED_MAX"
22
- },
23
- android: {
24
- key: "androidParkedMax",
25
- env: "STIM_POOL_ANDROID_PARKED_MAX"
26
- }
27
- };
28
- function parseMax(raw, strings) {
29
- const value = strings && typeof raw === "string" ? /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : NaN : raw;
30
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) return null;
31
- return value;
17
+ //#region src/engine/remote-cache.ts
18
+ const EAS_PROVIDER_PACKAGE = "eas-build-cache-provider";
19
+ const RESOLVE_TIMEOUT_MS = 3e4;
20
+ const UPLOAD_TIMEOUT_MS = 6e4;
21
+ const CONFIG_TIMEOUT_MS = 3e4;
22
+ const DYNAMIC_CONFIG_FILES = [
23
+ "app.config.ts",
24
+ "app.config.js",
25
+ "app.config.mjs",
26
+ "app.config.cjs"
27
+ ];
28
+ const TIMED_OUT = Symbol("timed-out");
29
+ function dynamicConfigFile(root) {
30
+ for (const name of DYNAMIC_CONFIG_FILES) if (existsSync(join(root, name))) return name;
31
+ return null;
32
32
  }
33
- function parkedMaxSetting(platform, { config, env = process.env } = {}) {
34
- const { key, env: envKey } = MAX_SETTING[platform];
35
- const fromEnv = env[envKey];
36
- const explicit = fromEnv !== void 0 && fromEnv !== "";
37
- const pool = (config === void 0 ? loadConfig() : config)?.pool;
38
- if (!explicit && pool !== void 0 && (pool === null || typeof pool !== "object" || Array.isArray(pool))) return {
39
- max: 0,
40
- error: "Invalid pool value. Expected an object with simulator and emulator bounds."
41
- };
42
- const fromConfig = pool !== null && typeof pool === "object" && !Array.isArray(pool) ? pool[key] : void 0;
43
- const raw = explicit ? fromEnv : fromConfig;
44
- if (raw === void 0) return {
45
- max: env.STIM_HOME ? 0 : 3,
46
- error: null
47
- };
48
- const parsed = parseMax(raw, explicit);
49
- if (parsed === null) return {
50
- max: 0,
51
- error: `Invalid ${explicit ? envKey : `pool.${key}`} value ${JSON.stringify(raw)}. Expected a whole number of parked ${platform === "ios" ? "simulators" : "emulators"}, 0 or more.`
52
- };
53
- return {
54
- max: explicit ? parsed : env.STIM_HOME ? 0 : parsed,
55
- error: null
56
- };
33
+ function providerFromConfig(config) {
34
+ if (!config || typeof config !== "object") return null;
35
+ const expoField = config.expo;
36
+ const exp = expoField && typeof expoField === "object" ? expoField : config;
37
+ return exp.buildCacheProvider ?? exp.experiments?.buildCacheProvider ?? null;
57
38
  }
58
- function isParkedRecord(value, platform) {
59
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
60
- const record = value;
61
- return typeof record.udid === "string" && typeof record.name === "string" && record.name.startsWith("stim-") && typeof record.parkedAt === "string" && (platform === "android" ? record.udid === record.name && /^stim-[A-Za-z0-9._-]+$/.test(record.name) && typeof record.systemImage === "string" && typeof record.configuration === "string" : typeof record.deviceTypeIdentifier === "string" && typeof record.runtimeIdentifier === "string" && typeof record.simslimManaged === "boolean" && (record.bundleId === void 0 || typeof record.bundleId === "string") && (record.cacheKey === void 0 || typeof record.cacheKey === "string"));
39
+ function ownerFromConfig(config) {
40
+ if (!config || typeof config !== "object") return null;
41
+ const expoField = config.expo;
42
+ const owner = (expoField && typeof expoField === "object" ? expoField : config).owner;
43
+ return typeof owner === "string" && owner.trim() !== "" ? owner.trim() : null;
62
44
  }
63
- function poolBlock(config) {
64
- const raw = config?.parked;
65
- const block = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
66
- const read = (platform) => {
67
- const list = block[platform];
68
- return Array.isArray(list) ? list.filter((record) => isParkedRecord(record, platform)) : [];
69
- };
70
- return {
71
- ios: read("ios"),
72
- android: read("android")
45
+ function normalizeProvider(raw) {
46
+ if (raw === null || raw === void 0 || raw === "") return null;
47
+ if (raw === "eas") return {
48
+ name: "eas",
49
+ reference: EAS_PROVIDER_PACKAGE,
50
+ options: {}
73
51
  };
52
+ if (typeof raw === "object") {
53
+ const obj = raw;
54
+ if (typeof obj.plugin === "string" && obj.plugin.trim() !== "") {
55
+ const reference = obj.plugin.trim();
56
+ return {
57
+ name: reference,
58
+ reference,
59
+ options: obj.options && typeof obj.options === "object" ? obj.options : {}
60
+ };
61
+ }
62
+ }
63
+ return { invalid: typeof raw === "string" ? `"${raw}"` : JSON.stringify(raw) };
74
64
  }
75
- function readParked(platform, { config } = {}) {
76
- return poolBlock(config === void 0 ? loadConfig() : config)[platform];
77
- }
78
- function writeParked(config, platform, records) {
79
- config.parked = {
80
- ...poolBlock(config),
81
- [platform]: records
82
- };
65
+ function runOptionsFor(platform) {
66
+ return platform === "android" ? { variant: "debug" } : { configuration: "Debug" };
83
67
  }
84
- function oldestFirst(records) {
85
- return records.toSorted((a, b) => String(a.parkedAt).localeCompare(String(b.parkedAt)));
68
+ function isProviderPlugin(plugin) {
69
+ if (!plugin || typeof plugin !== "object") return false;
70
+ const p = plugin;
71
+ const resolves = typeof p.resolveBuildCache === "function" || typeof p.resolveRemoteBuildCache === "function";
72
+ const uploads = typeof p.uploadBuildCache === "function" || typeof p.uploadRemoteBuildCache === "function";
73
+ return resolves && uploads;
86
74
  }
87
- function selectParked(records, { deviceTypeIdentifier, runtimeIdentifier }) {
88
- return oldestFirst(records.filter((r) => r.deletionClaim === void 0 && r.deviceTypeIdentifier === deviceTypeIdentifier && r.runtimeIdentifier === runtimeIdentifier));
75
+ function cacheLevel(value) {
76
+ return value === "local" || value === "remote" ? value : false;
89
77
  }
90
- function evictOverflow(records, max) {
91
- if (records.length <= max) return {
92
- keep: [...records],
93
- evicted: []
94
- };
95
- const ordered = oldestFirst(records);
96
- const evicted = ordered.slice(0, ordered.length - max);
97
- const dropped = new Set(evicted.map((r) => r.udid));
98
- return {
99
- keep: records.filter((r) => !dropped.has(r.udid)),
100
- evicted
101
- };
78
+ function exitAfterFlush(code = 0, { exit = (c) => process.exit(c), stream = process.stdout } = {}) {
79
+ try {
80
+ stream.write("", () => exit(code));
81
+ } catch {
82
+ exit(code);
83
+ }
102
84
  }
103
- function parkSim({ platform, projectPath, record, max }) {
104
- return withConfigLock(() => {
105
- const cfg = ensureConfig();
106
- if (platform === "android") {
107
- const current = cfg.projects[projectPath]?.platforms?.android;
108
- if (!current?.owned || current.avdName !== record.udid) throw new Error("The emulator assignment changed before parking.");
85
+ function readProjectConfig(root, { run = null, timeoutMs = CONFIG_TIMEOUT_MS } = {}) {
86
+ const dynamic = dynamicConfigFile(root);
87
+ if (!dynamic) {
88
+ const file = join(root, "app.json");
89
+ if (!existsSync(file)) return {
90
+ config: null,
91
+ source: null
92
+ };
93
+ try {
94
+ return {
95
+ config: JSON.parse(readFileSync(file, "utf-8")),
96
+ source: "app.json"
97
+ };
98
+ } catch (err) {
99
+ return { unavailable: `app.json could not be parsed: ${firstLine(err)}` };
109
100
  }
110
- const { keep, evicted } = evictOverflow([...readParked(platform, { config: cfg }).filter((r) => r.udid !== record.udid), record], max);
111
- writeParked(cfg, platform, [...keep, ...evicted]);
112
- const platforms = cfg.projects?.[projectPath]?.platforms;
113
- if (platforms) delete platforms[platform];
114
- saveConfig(cfg);
115
- return evicted;
116
- });
117
- }
118
- function adoptParked({ platform, projectPath, udid, device }) {
119
- return withConfigLock(() => {
120
- const cfg = ensureConfig();
121
- const records = readParked(platform, { config: cfg });
122
- const taken = records.find((r) => r.udid === udid);
123
- if (!taken || taken.deletionClaim !== void 0) return null;
124
- if (platform === "android" && cfg.projects[projectPath]?.platforms?.android) return null;
125
- writeParked(cfg, platform, records.filter((r) => r.udid !== udid));
126
- const project = cfg.projects[projectPath];
127
- if (!project) throw new Error(`Project not registered: ${projectPath}`);
128
- project.platforms = project.platforms || {};
129
- project.platforms[platform] = device;
130
- saveConfig(cfg);
131
- return taken;
132
- });
101
+ }
102
+ const bin = expoBinPath(root);
103
+ if (!bin) return { unavailable: `${dynamic} is code, so it has to be evaluated to read its buildCacheProvider, and the \`expo\` package is not resolvable from this project (no expo/package.json to read a bin from, and no node_modules/.bin/expo here or in any parent)` };
104
+ const exec = run || ((file, args, opts) => getExecutor().runFile(file, args, opts));
105
+ let stdout;
106
+ try {
107
+ stdout = exec(bin, [
108
+ "config",
109
+ "--json",
110
+ root
111
+ ], { timeoutMs });
112
+ } catch (err) {
113
+ return { unavailable: `\`expo config --json\` failed: ${firstLine(err)}` };
114
+ }
115
+ try {
116
+ return {
117
+ config: JSON.parse(String(stdout)),
118
+ source: dynamic
119
+ };
120
+ } catch (err) {
121
+ return { unavailable: `\`expo config --json\` did not print JSON: ${firstLine(err)}` };
122
+ }
133
123
  }
134
- function isProcessAlive(pid) {
124
+ async function loadPlugin(projectRoot, reference, { requireFrom = null } = {}) {
125
+ const localRequire = requireFrom ? requireFrom(projectRoot) : createRequire(join(projectRoot, "package.json"));
126
+ const file = localRequire.resolve(reference);
127
+ let mod;
135
128
  try {
136
- process.kill(pid, 0);
137
- return true;
138
- } catch (error) {
139
- return error.code === "EPERM";
129
+ mod = localRequire(file);
130
+ } catch (err) {
131
+ if (err?.code !== "ERR_REQUIRE_ESM") throw err;
132
+ mod = await import(pathToFileURL(file).href);
140
133
  }
134
+ const plugin = mod?.default ?? mod;
135
+ if (!isProviderPlugin(plugin)) throw new Error(`"${reference}" does not export resolveBuildCache and uploadBuildCache functions`);
136
+ return plugin;
141
137
  }
142
- function parseDeletionClaim(value) {
143
- if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
144
- const claim = value;
145
- if (!Number.isSafeInteger(claim.pid) || claim.pid <= 0) return null;
146
- if (typeof claim.token !== "string" || claim.token.length === 0) return null;
138
+ async function loadProjectProvider(projectRoot, { isExpo = true, run = null, requireFrom = null, timeoutMs = CONFIG_TIMEOUT_MS } = {}) {
139
+ if (!isExpo) return { none: true };
140
+ const read = readProjectConfig(projectRoot, {
141
+ run,
142
+ timeoutMs
143
+ });
144
+ if (read.unavailable) return { unavailable: read.unavailable };
145
+ const normalized = normalizeProvider(providerFromConfig(read.config));
146
+ if (!normalized) return { none: true };
147
+ if (normalized.invalid) return { unavailable: `buildCacheProvider is ${normalized.invalid}, which is not "eas" or { plugin: <module> }` };
148
+ if (normalized.reference === "@stim-cli/expo-build-cache") return { none: true };
149
+ let plugin;
150
+ try {
151
+ plugin = await loadPlugin(projectRoot, normalized.reference, { requireFrom });
152
+ } catch (err) {
153
+ const missing = err?.code === "MODULE_NOT_FOUND";
154
+ return { unavailable: normalized.name === "eas" && missing ? `the EAS build cache needs the \`${EAS_PROVIDER_PACKAGE}\` package, which is not installed in this project` : `${normalized.name} could not be loaded: ${firstLine(err)}` };
155
+ }
147
156
  return {
148
- pid: claim.pid,
149
- token: claim.token
157
+ provider: {
158
+ plugin,
159
+ options: normalized.options ?? {}
160
+ },
161
+ name: normalized.name,
162
+ owner: ownerFromConfig(read.config)
150
163
  };
151
164
  }
152
- function claimParkedRemoval(platform, udid) {
153
- return withConfigLock(() => {
154
- const cfg = loadConfig();
155
- if (!cfg) return null;
156
- const records = readParked(platform, { config: cfg });
157
- const record = records.find((candidate) => candidate.udid === udid);
158
- if (!record) return null;
159
- const existingClaim = parseDeletionClaim(record.deletionClaim);
160
- if (record.deletionClaim !== void 0 && (!existingClaim || isProcessAlive(existingClaim.pid))) return null;
161
- const token = randomUUID();
162
- const claimedRecord = {
163
- ...record,
164
- deletionClaim: {
165
- pid: process.pid,
166
- token
167
- }
168
- };
169
- writeParked(cfg, platform, records.map((candidate) => candidate.udid === udid ? claimedRecord : candidate));
170
- saveConfig(cfg);
171
- const claimed = { ...record };
172
- delete claimed.deletionClaim;
173
- return {
174
- record: claimed,
175
- token
176
- };
177
- });
178
- }
179
- function clearParkedRemovalClaim(platform, udid, token) {
180
- withConfigLock(() => {
181
- const cfg = loadConfig();
182
- if (!cfg) return;
183
- const records = readParked(platform, { config: cfg });
184
- const record = records.find((candidate) => candidate.udid === udid);
185
- if (!record || parseDeletionClaim(record.deletionClaim)?.token !== token) return;
186
- const restored = { ...record };
187
- delete restored.deletionClaim;
188
- writeParked(cfg, platform, records.map((candidate) => candidate.udid === udid ? restored : candidate));
189
- saveConfig(cfg);
190
- });
191
- }
192
- function removeParkedAfter(platform, udid, beforeRemove) {
193
- const claim = claimParkedRemoval(platform, udid);
194
- if (!claim) return null;
195
- try {
196
- beforeRemove(claim.record);
197
- } catch (error) {
198
- try {
199
- clearParkedRemovalClaim(platform, udid, claim.token);
200
- } catch {}
201
- throw error;
165
+ const WHOAMI_TIMEOUT_MS = 15e3;
166
+ const EAS_CLI_PACKAGE = "eas-cli";
167
+ const EAS_CLI_BIN = "eas";
168
+ function parseWhoami({ stdout = "", stderr = "", exitCode = 0 } = {}) {
169
+ const out = stripAnsi(stdout);
170
+ const combined = `${out}\n${stripAnsi(stderr)}`;
171
+ if (/^\s*Not logged in\s*$/m.test(combined)) return { loggedOut: true };
172
+ if (exitCode !== 0) return { unknown: firstLine(combined.trim() || `eas whoami exited ${exitCode}`) };
173
+ const lines = out.split("\n").map((line) => line.trim()).filter((line) => line !== "");
174
+ if (lines.length === 0) return { unknown: "eas whoami printed nothing" };
175
+ const raw = lines[0];
176
+ if (raw === void 0) return { unknown: "eas whoami printed nothing" };
177
+ const viaToken = /\(authenticated using EXPO_TOKEN\)\s*$/.test(raw);
178
+ const display = raw.replace(/\s*\(authenticated using EXPO_TOKEN\)\s*$/, "").trim();
179
+ const accounts = [];
180
+ for (const line of lines) {
181
+ const name = /^\u2022\s*(\S+)\s*\(Role:/.exec(line)?.[1];
182
+ if (name !== void 0) accounts.push(name);
202
183
  }
203
- return withConfigLock(() => {
204
- const cfg = loadConfig();
205
- if (!cfg) return null;
206
- const records = readParked(platform, { config: cfg });
207
- if (parseDeletionClaim(records.find((candidate) => candidate.udid === udid)?.deletionClaim)?.token !== claim.token) return null;
208
- writeParked(cfg, platform, records.filter((candidate) => candidate.udid !== udid));
209
- saveConfig(cfg);
210
- return claim.record;
211
- });
212
- }
213
- function dropParked(platform, udid) {
214
- return removeParkedAfter(platform, udid, () => {}) !== null;
184
+ if (accounts.length === 0 && !display.endsWith("(robot)") && display !== "robot") accounts.push(display);
185
+ return {
186
+ loggedIn: true,
187
+ account: display,
188
+ accounts: accounts.length ? accounts : null,
189
+ viaToken
190
+ };
215
191
  }
216
- //#endregion
217
- //#region src/teardown.ts
218
- function teardownParkedAvd(avdName) {
219
- try {
220
- return removeParkedAfter("android", avdName, () => {
221
- const result = teardownOwnedAvd(avdName, { del: true });
222
- if (result.status !== "torn-down" && result.status !== "missing") throw new Error(result.reason);
223
- }) ? {
224
- status: "torn-down",
225
- label: avdName
226
- } : {
227
- status: "skipped",
228
- kind: "not-parked",
229
- reason: "emulator is no longer parked"
230
- };
231
- } catch (error) {
232
- return {
233
- status: "failed",
234
- reason: String(error?.message || error)
235
- };
236
- }
192
+ function isEasAuthFailureText(text) {
193
+ const t = stripAnsi(text || "");
194
+ if (t.trim() === "") return false;
195
+ return /not logged in/i.test(t) || /\beas login\b/i.test(t) || /EXPO_TOKEN/.test(t) || /unauthorized/i.test(t) || /not authorized/i.test(t) || /authentication (failed|required)/i.test(t);
237
196
  }
238
- function teardownParkedIosSim(udid, { label, deleteSim = deleteParkedIosSim } = {}) {
239
- try {
240
- const removed = removeParkedAfter("ios", udid, () => deleteSim(udid));
241
- if (!removed) return {
242
- status: "skipped",
243
- kind: "not-parked",
244
- reason: "simulator is no longer parked"
245
- };
246
- return {
247
- status: "torn-down",
248
- label: label ?? removed.name
249
- };
250
- } catch (error) {
251
- return {
252
- status: "failed",
253
- reason: String(error?.message || error)
254
- };
255
- }
197
+ function easAuthNote(status) {
198
+ const { code, account, owner, reason, phase = "resolve" } = status || {};
199
+ const because = reason ? ` (${reason})` : "";
200
+ if (code === "logged-out") return `eas is not authenticated${because} -- run \`eas login\` (or set EXPO_TOKEN); ` + (phase === "upload" ? "this build stayed in the local cache" : "building with the local cache only");
201
+ if (code === "wrong-account") return `eas is authenticated as ${account}, and this project's owner is ${owner} -- run \`eas login\` as a member of ${owner} (or set EXPO_TOKEN); consulting the EAS cache anyway`;
202
+ return null;
256
203
  }
257
- function parkOwnedIosSim(udid, park) {
258
- const resolved = resolveOwnedIosSim(udid);
259
- if (resolved.missing) throw new Error(`simulator ${udid} disappeared after shutdown`);
260
- if (resolved.notOwned) throw new Error(`simulator ${udid} is now named ${JSON.stringify(resolved.notOwned)}`);
261
- const sim = resolved.sim;
262
- if (sim.state !== "Shutdown") throw new Error(`simulator ${udid} is still ${sim.state} after shutdown`);
263
- const model = listIosDeviceTypes().find((d) => d.identifier === sim.deviceTypeIdentifier)?.name ?? null;
264
- const runtime = parseRuntimeVersion(sim.runtime);
265
- if (park.bundleId) {
266
- if (!sim.dataPath) throw new Error(`simulator ${udid} did not report a data path for app cleanup`);
267
- const container = findAppDataContainer(sim.dataPath, park.bundleId);
268
- if (container) clearAppDataContainer(container);
269
- }
270
- const name = parkedSimName(sim.udid, {
271
- model,
272
- runtime
273
- });
274
- renameIosSim(sim.udid, name);
275
- const record = {
276
- udid: sim.udid,
277
- name,
278
- deviceTypeIdentifier: sim.deviceTypeIdentifier,
279
- runtimeIdentifier: sim.runtime,
280
- parkedAt: (/* @__PURE__ */ new Date()).toISOString(),
281
- simslimManaged: Boolean(park.simslimManaged),
282
- ...park.bundleId ? { bundleId: park.bundleId } : {},
283
- ...park.cacheKey ? { cacheKey: park.cacheKey } : {}
204
+ function resolveEasCliBin(projectRoot, { lookupPath = null, timeoutMs = 5e3 } = {}) {
205
+ const fromPackage = expoBinFromPackage(resolvePackageJson(projectRoot, EAS_CLI_PACKAGE), EAS_CLI_BIN);
206
+ if (fromPackage) return {
207
+ file: fromPackage,
208
+ source: "project"
284
209
  };
285
- return {
286
- record,
287
- evicted: parkSim({
288
- platform: "ios",
289
- projectPath: park.projectPath,
290
- record,
291
- max: park.max
292
- })
210
+ const shim = findBinUpward(projectRoot, EAS_CLI_BIN);
211
+ if (shim) return {
212
+ file: shim,
213
+ source: "project"
293
214
  };
215
+ const onPath = lookupPath ? lookupPath() : getExecutor().runQuiet(`command -v ${EAS_CLI_BIN}`, { timeoutMs });
216
+ const file = (String(onPath || "").split("\n")[0] ?? "").trim();
217
+ return file ? {
218
+ file,
219
+ source: "path"
220
+ } : null;
294
221
  }
295
- function teardownOwnedIosSim(udid, { del = false, label, park } = {}) {
296
- let parkFallback;
222
+ const easAuthCache = /* @__PURE__ */ new Map();
223
+ function checkEasAuth({ projectRoot, owner = null, run = null, resolveBin = null, timeoutMs = WHOAMI_TIMEOUT_MS, cache = easAuthCache }) {
224
+ const key = `${projectRoot}::${owner || ""}`;
225
+ if (cache?.has(key)) return cache.get(key);
226
+ const answer = probeEasAuth({
227
+ projectRoot,
228
+ owner,
229
+ run,
230
+ resolveBin,
231
+ timeoutMs
232
+ });
233
+ cache?.set(key, answer);
234
+ return answer;
235
+ }
236
+ function probeEasAuth({ projectRoot, owner, run, resolveBin, timeoutMs }) {
237
+ const bin = (resolveBin || resolveEasCliBin)(projectRoot);
238
+ if (!bin) return {
239
+ failed: true,
240
+ code: "no-cli",
241
+ reason: "no `eas` executable is resolvable from this project or on PATH",
242
+ remedy: "Install eas-cli (`npm i -g eas-cli`, or as a project devDependency), then run `eas login`."
243
+ };
244
+ const exec = run || ((file, args, opts) => getExecutor().runFile(file, args, opts));
245
+ let result;
297
246
  try {
298
- const resolved = resolveOwnedIosSim(udid);
299
- if (resolved.notOwned) return {
300
- status: "skipped",
301
- kind: "not-owned",
302
- reason: `sim is now named "${resolved.notOwned}", not Stim-owned by name`
303
- };
304
- if (resolved.missing) return { status: "missing" };
305
- shutdownIosSim(udid);
306
- const sim = resolved.sim;
307
- if (del && park && park.max > 0) try {
308
- const { record, evicted } = parkOwnedIosSim(udid, park);
309
- const removed = [];
310
- const failures = [];
311
- for (const entry of evicted) {
312
- const result = teardownParkedIosSim(entry.udid, { label: entry.name });
313
- if (result.status === "torn-down") removed.push({
314
- udid: entry.udid,
315
- name: entry.name
316
- });
317
- else if (result.status === "failed") failures.push(`could not delete evicted ${entry.name} (${entry.udid}): ${result.reason}`);
318
- }
319
- return {
320
- status: "torn-down",
321
- label: label ?? sim.name ?? udid,
322
- parked: {
323
- udid: record.udid,
324
- name: record.name
325
- },
326
- evicted: removed,
327
- ...failures.length ? { evictionFailures: failures } : {}
328
- };
329
- } catch (e) {
330
- parkFallback = String(e?.message || e);
331
- }
332
- if (del) deleteIosSim(udid);
333
- return {
334
- status: "torn-down",
335
- label: label ?? sim.name ?? udid,
336
- ...parkFallback ? { parkFallback } : {}
247
+ result = {
248
+ stdout: String(exec(bin.file, ["whoami"], { timeoutMs }) ?? ""),
249
+ exitCode: 0
337
250
  };
338
251
  } catch (e) {
339
- return {
340
- status: "failed",
341
- reason: String(e?.message || e),
342
- ...parkFallback ? { parkFallback } : {}
252
+ const err = e;
253
+ if (err?.code === "ETIMEDOUT" || err?.signal === "SIGTERM" || err?.killed) return { unknown: `eas whoami timed out after ${timeoutMs}ms` };
254
+ result = {
255
+ stdout: String(err?.stdout ?? ""),
256
+ stderr: String(err?.stderr ?? err?.message ?? ""),
257
+ exitCode: typeof err?.status === "number" ? err.status : 1
343
258
  };
344
259
  }
260
+ const parsed = parseWhoami(result);
261
+ if (parsed.loggedOut) return {
262
+ failed: true,
263
+ code: "logged-out",
264
+ reason: "eas whoami says \"Not logged in\"",
265
+ remedy: "Run `eas login` (or set EXPO_TOKEN)."
266
+ };
267
+ if (parsed.unknown) return { unknown: parsed.unknown };
268
+ if (owner && Array.isArray(parsed.accounts) && !parsed.accounts.includes(owner)) return {
269
+ failed: true,
270
+ code: "wrong-account",
271
+ account: parsed.account,
272
+ accounts: parsed.accounts,
273
+ owner,
274
+ reason: `eas is authenticated as ${parsed.account} (accounts: ${parsed.accounts.join(", ")}), which does not include this project's owner ${owner}`,
275
+ remedy: `Run \`eas login\` as a member of ${owner}, or set EXPO_TOKEN to a token for it.`
276
+ };
277
+ return {
278
+ ok: true,
279
+ account: parsed.account,
280
+ accounts: parsed.accounts,
281
+ viaToken: parsed.viaToken,
282
+ source: bin.source
283
+ };
345
284
  }
346
- function teardownOwnedAvd(avdName, { del = false, park, waitForShutdown = waitForAndroidEmulatorShutdown, assertStopped = assertOwnedAvdStopped, resolveAvd = resolveOwnedAvdSerial } = {}) {
347
- let parkFallback;
348
- try {
349
- const resolved = resolveAvd(avdName);
350
- if (resolved.notOwned) return {
351
- status: "skipped",
352
- kind: "not-owned",
353
- reason: `AVD ${avdName} is not Stim-owned by name`
354
- };
355
- if (resolved.missing) return { status: "missing" };
356
- const serial = resolved.serial;
357
- if (serial) waitForShutdown(avdName, (timeoutMs) => shutdownAndroidEmulator(serial, timeoutMs));
358
- else assertStopped(avdName);
359
- if (del) {
360
- const current = resolveAvd(avdName);
361
- if (current.notOwned) return {
362
- status: "skipped",
363
- kind: "not-owned",
364
- reason: `AVD ${avdName} is not Stim-owned by name`
365
- };
366
- if (current.missing) return { status: "missing" };
367
- if (current.serial) throw new Error(`Owned AVD ${avdName} started again before deletion.`);
368
- assertStopped(avdName);
369
- if (park && park.max > 0) try {
370
- const systemImage = ownedAvdSystemImage(avdName);
371
- if (!systemImage || !park.configuration || !ownedAvdMatchesConfiguration(avdName, park.configuration)) throw new Error("the AVD has no verified creation configuration");
372
- const evicted = parkSim({
373
- platform: "android",
374
- projectPath: park.projectPath,
375
- max: park.max,
376
- record: {
377
- udid: avdName,
378
- name: avdName,
379
- systemImage,
380
- configuration: park.configuration,
381
- parkedAt: (/* @__PURE__ */ new Date()).toISOString()
382
- }
383
- });
384
- const removed = [];
385
- const failures = [];
386
- for (const entry of evicted) {
387
- const result = teardownParkedAvd(entry.name);
388
- if (result.status === "torn-down") removed.push({
389
- udid: entry.name,
390
- name: entry.name,
391
- platform: "android"
392
- });
393
- else if (result.status === "failed") failures.push(`could not delete evicted ${entry.name}: ${result.reason}`);
394
- }
395
- return {
396
- status: "torn-down",
397
- label: avdName,
398
- parked: {
399
- udid: avdName,
400
- name: avdName,
401
- platform: "android"
402
- },
403
- evicted: removed,
404
- ...failures.length ? { evictionFailures: failures } : {}
405
- };
406
- } catch (error) {
407
- parkFallback = String(error?.message || error);
408
- }
409
- deleteAvd(avdName);
410
- }
411
- return {
412
- status: "torn-down",
413
- label: avdName,
414
- serial: resolved.serial ?? null,
415
- ...parkFallback ? { parkFallback } : {}
416
- };
417
- } catch (e) {
418
- return {
419
- status: "failed",
420
- reason: String(e?.message || e)
421
- };
285
+ const capturing = [];
286
+ const abandoned = /* @__PURE__ */ new Set();
287
+ let patched = null;
288
+ const PROVIDER_CONTEXT = new AsyncLocalStorage();
289
+ function uploadDestination(lines) {
290
+ const text = (lines || []).map(stripAnsi);
291
+ for (const line of text) {
292
+ const url = /(https?:\/\/[^\s'"]+)/.exec(line)?.[1];
293
+ if (url !== void 0) return url.replace(/[.,)\]]+$/, "");
294
+ }
295
+ for (const line of text) {
296
+ const slug = /\bto\s+(@?[\w.-]+\/[\w.-]+)/i.exec(line)?.[1];
297
+ if (slug !== void 0) return slug.replace(/[.,)\]]+$/, "");
422
298
  }
423
- }
424
- //#endregion
425
- //#region src/engine/remote-cache.ts
426
- const EAS_PROVIDER_PACKAGE = "eas-build-cache-provider";
427
- const RESOLVE_TIMEOUT_MS = 3e4;
428
- const UPLOAD_TIMEOUT_MS = 6e4;
429
- const CONFIG_TIMEOUT_MS = 3e4;
430
- const DYNAMIC_CONFIG_FILES = [
431
- "app.config.ts",
432
- "app.config.js",
433
- "app.config.mjs",
434
- "app.config.cjs"
435
- ];
436
- const TIMED_OUT = Symbol("timed-out");
437
- function dynamicConfigFile(root) {
438
- for (const name of DYNAMIC_CONFIG_FILES) if (existsSync(join(root, name))) return name;
439
299
  return null;
440
300
  }
441
- function providerFromConfig(config) {
442
- if (!config || typeof config !== "object") return null;
443
- const expoField = config.expo;
444
- const exp = expoField && typeof expoField === "object" ? expoField : config;
445
- return exp.buildCacheProvider ?? exp.experiments?.buildCacheProvider ?? null;
301
+ function providerNote(text) {
302
+ return `${"cache".padEnd(11)} ${text}`;
446
303
  }
447
- function ownerFromConfig(config) {
448
- if (!config || typeof config !== "object") return null;
449
- const expoField = config.expo;
450
- const owner = (expoField && typeof expoField === "object" ? expoField : config).owner;
451
- return typeof owner === "string" && owner.trim() !== "" ? owner.trim() : null;
304
+ function currentFrame() {
305
+ const store = PROVIDER_CONTEXT.getStore();
306
+ return store && (capturing.includes(store) || abandoned.has(store)) ? store : null;
452
307
  }
453
- function normalizeProvider(raw) {
454
- if (raw === null || raw === void 0 || raw === "") return null;
455
- if (raw === "eas") return {
456
- name: "eas",
457
- reference: EAS_PROVIDER_PACKAGE,
458
- options: {}
308
+ function install() {
309
+ if (patched && process.stdout.write === patched.patchedWrite) return;
310
+ const stdout = process.stdout;
311
+ const write = stdout.write.bind(stdout);
312
+ const log = console.log;
313
+ const passthrough = write;
314
+ const patchedWrite = ((chunk, encoding, callback) => {
315
+ const frame = currentFrame();
316
+ if (!frame) return passthrough(chunk, encoding, callback);
317
+ const cb = typeof encoding === "function" ? encoding : callback;
318
+ absorb(frame, typeof chunk === "string" ? chunk : String(chunk));
319
+ if (typeof cb === "function") cb();
320
+ return true;
321
+ });
322
+ const patchedLog = (...args) => {
323
+ const frame = currentFrame();
324
+ if (!frame) return log(...args);
325
+ absorb(frame, `${format(...args)}\n`);
459
326
  };
460
- if (typeof raw === "object") {
461
- const obj = raw;
462
- if (typeof obj.plugin === "string" && obj.plugin.trim() !== "") {
463
- const reference = obj.plugin.trim();
464
- return {
465
- name: reference,
466
- reference,
467
- options: obj.options && typeof obj.options === "object" ? obj.options : {}
468
- };
327
+ stdout.write = patchedWrite;
328
+ console.log = patchedLog;
329
+ patched = {
330
+ stdout,
331
+ write,
332
+ log,
333
+ patchedWrite,
334
+ patchedLog
335
+ };
336
+ }
337
+ function uninstall() {
338
+ if (!patched || capturing.length || abandoned.size) return;
339
+ if (patched.stdout.write === patched.patchedWrite) patched.stdout.write = patched.write;
340
+ if (console.log === patched.patchedLog) console.log = patched.log;
341
+ patched = null;
342
+ }
343
+ function absorb(frame, chunk) {
344
+ try {
345
+ process.stderr.write(chunk);
346
+ } catch {}
347
+ frame.pending += chunk;
348
+ let index = frame.pending.indexOf("\n");
349
+ while (index !== -1) {
350
+ emitLine(frame, frame.pending.slice(0, index));
351
+ frame.pending = frame.pending.slice(index + 1);
352
+ index = frame.pending.indexOf("\n");
353
+ }
354
+ }
355
+ function emitLine(frame, raw) {
356
+ const line = stripAnsi(raw).trim();
357
+ if (line === "") return;
358
+ frame.lines.push(line);
359
+ const writer = frame.writer();
360
+ try {
361
+ writer?.write({
362
+ src: "build",
363
+ level: "debug",
364
+ event: "provider",
365
+ msg: line
366
+ });
367
+ } catch {}
368
+ frame.onLine?.(line);
369
+ }
370
+ function beginCapture({ logWriter = null, projectRoot = null, platform = null, onLine = null } = {}) {
371
+ let own = null;
372
+ const frame = {
373
+ lines: [],
374
+ pending: "",
375
+ onLine,
376
+ writer: () => {
377
+ if (logWriter) return logWriter;
378
+ if (own) return own;
379
+ if (!projectRoot || !platform) return null;
380
+ own = createNdjsonWriter(join(workspaceLogsDir(projectRoot), `build-${platform}.ndjson`));
381
+ return own;
382
+ },
383
+ close: () => {
384
+ own?.close?.();
385
+ own = null;
469
386
  }
387
+ };
388
+ capturing.push(frame);
389
+ install();
390
+ return frame;
391
+ }
392
+ function endCapture(frame) {
393
+ if (!frame) return;
394
+ const at = capturing.lastIndexOf(frame);
395
+ if (at !== -1) capturing.splice(at, 1);
396
+ abandoned.delete(frame);
397
+ if (frame.pending !== "") {
398
+ const rest = frame.pending;
399
+ frame.pending = "";
400
+ emitLine(frame, rest);
470
401
  }
471
- return { invalid: typeof raw === "string" ? `"${raw}"` : JSON.stringify(raw) };
402
+ frame.close();
403
+ uninstall();
472
404
  }
473
- function runOptionsFor(platform) {
474
- return platform === "android" ? { variant: "debug" } : { configuration: "Debug" };
405
+ function abandonCapture(frame, work) {
406
+ if (!frame) return;
407
+ const at = capturing.lastIndexOf(frame);
408
+ if (at !== -1) capturing.splice(at, 1);
409
+ abandoned.add(frame);
410
+ Promise.resolve(work).then(() => endCapture(frame), () => endCapture(frame));
475
411
  }
476
- function isProviderPlugin(plugin) {
477
- if (!plugin || typeof plugin !== "object") return false;
478
- const p = plugin;
479
- const resolves = typeof p.resolveBuildCache === "function" || typeof p.resolveRemoteBuildCache === "function";
480
- const uploads = typeof p.uploadBuildCache === "function" || typeof p.uploadRemoteBuildCache === "function";
481
- return resolves && uploads;
412
+ async function resolveRemote({ provider, platform, projectRoot, fingerprintHash, runOptions = null, timeoutMs = RESOLVE_TIMEOUT_MS, logWriter = null } = {}) {
413
+ if (!provider?.plugin || !fingerprintHash) return null;
414
+ const opts = runOptions || runOptionsFor(platform);
415
+ const outcome = await withBudget(async () => {
416
+ const props = {
417
+ fingerprintHash: await providerFingerprint({
418
+ provider,
419
+ platform,
420
+ projectRoot,
421
+ runOptions: opts
422
+ }) ?? fingerprintHash,
423
+ platform,
424
+ runOptions: opts,
425
+ projectRoot
426
+ };
427
+ return typeof provider.plugin.resolveBuildCache === "function" ? provider.plugin.resolveBuildCache(props, provider.options) : provider.plugin.resolveRemoteBuildCache(props, provider.options);
428
+ }, timeoutMs, {
429
+ logWriter,
430
+ projectRoot,
431
+ platform
432
+ });
433
+ if (outcome.timedOut) return { timedOut: true };
434
+ if (outcome.error) return { failed: firstLine(outcome.error) };
435
+ const appPath = typeof outcome.value === "string" ? outcome.value.trim() : "";
436
+ if (!appPath) return null;
437
+ if (!existsSync(appPath)) return { failed: `returned ${appPath}, which does not exist` };
438
+ return { appPath };
482
439
  }
483
- function cacheLevel(value) {
484
- return value === "local" || value === "remote" ? value : false;
440
+ async function uploadRemote({ provider, platform, projectRoot, fingerprintHash, buildPath, runOptions = null, timeoutMs = UPLOAD_TIMEOUT_MS, logWriter = null, note = (line) => console.error(line) } = {}) {
441
+ if (!provider?.plugin || !fingerprintHash || !buildPath) return { skipped: true };
442
+ const opts = runOptions || runOptionsFor(platform);
443
+ let announced = false;
444
+ const onLine = (line) => {
445
+ if (announced) return;
446
+ const dest = uploadDestination([line]);
447
+ if (!dest) return;
448
+ announced = true;
449
+ note(providerNote(`uploading to ${dest}`));
450
+ };
451
+ const outcome = await withBudget(async () => {
452
+ const props = {
453
+ projectRoot,
454
+ platform,
455
+ fingerprintHash: await providerFingerprint({
456
+ provider,
457
+ platform,
458
+ projectRoot,
459
+ runOptions: opts
460
+ }) ?? fingerprintHash,
461
+ buildPath,
462
+ runOptions: opts
463
+ };
464
+ return typeof provider.plugin.uploadBuildCache === "function" ? provider.plugin.uploadBuildCache(props, provider.options) : provider.plugin.uploadRemoteBuildCache(props, provider.options);
465
+ }, timeoutMs, {
466
+ logWriter,
467
+ projectRoot,
468
+ platform,
469
+ onLine
470
+ });
471
+ if (outcome.timedOut) return { timedOut: true };
472
+ if (outcome.error) return { failed: firstLine(outcome.error) };
473
+ const destination = uploadDestination(outcome.lines);
474
+ return destination ? {
475
+ uploaded: true,
476
+ destination
477
+ } : { uploaded: true };
485
478
  }
486
- function exitAfterFlush(code = 0, { exit = (c) => process.exit(c), stream = process.stdout } = {}) {
479
+ async function providerFingerprint({ provider, platform, projectRoot, runOptions }) {
480
+ if (typeof provider.plugin.calculateFingerprintHash !== "function") return null;
487
481
  try {
488
- stream.write("", () => exit(code));
482
+ const hash = await provider.plugin.calculateFingerprintHash({
483
+ projectRoot,
484
+ platform,
485
+ runOptions
486
+ }, provider.options);
487
+ return typeof hash === "string" && hash.trim() !== "" ? hash.trim() : null;
489
488
  } catch {
490
- exit(code);
489
+ return null;
491
490
  }
492
491
  }
493
- function readProjectConfig(root, { run = null, timeoutMs = CONFIG_TIMEOUT_MS } = {}) {
494
- const dynamic = dynamicConfigFile(root);
495
- if (!dynamic) {
496
- const file = join(root, "app.json");
497
- if (!existsSync(file)) return {
498
- config: null,
499
- source: null
500
- };
501
- try {
492
+ async function withBudget(factory, ms, capture) {
493
+ let timer = null;
494
+ const timeout = new Promise((resolve) => {
495
+ timer = setTimeout(() => resolve(TIMED_OUT), ms);
496
+ timer?.unref?.();
497
+ });
498
+ const frame = beginCapture(capture || {});
499
+ const work = Promise.resolve().then(() => PROVIDER_CONTEXT.run(frame, factory)).then((value) => ({ value }), (error) => ({ error }));
500
+ try {
501
+ const settled = await Promise.race([work, timeout]);
502
+ if (settled === TIMED_OUT) {
503
+ abandonCapture(frame, work);
502
504
  return {
503
- config: JSON.parse(readFileSync(file, "utf-8")),
504
- source: "app.json"
505
+ timedOut: true,
506
+ lines: frame.lines
505
507
  };
506
- } catch (err) {
507
- return { unavailable: `app.json could not be parsed: ${firstLine(err)}` };
508
508
  }
509
- }
510
- const bin = expoBinPath(root);
511
- if (!bin) return { unavailable: `${dynamic} is code, so it has to be evaluated to read its buildCacheProvider, and the \`expo\` package is not resolvable from this project (no expo/package.json to read a bin from, and no node_modules/.bin/expo here or in any parent)` };
512
- const exec = run || ((file, args, opts) => getExecutor().runFile(file, args, opts));
513
- let stdout;
514
- try {
515
- stdout = exec(bin, [
516
- "config",
517
- "--json",
518
- root
519
- ], { timeoutMs });
520
- } catch (err) {
521
- return { unavailable: `\`expo config --json\` failed: ${firstLine(err)}` };
522
- }
523
- try {
509
+ endCapture(frame);
524
510
  return {
525
- config: JSON.parse(String(stdout)),
526
- source: dynamic
511
+ ...settled,
512
+ lines: frame.lines
527
513
  };
528
514
  } catch (err) {
529
- return { unavailable: `\`expo config --json\` did not print JSON: ${firstLine(err)}` };
515
+ endCapture(frame);
516
+ throw err;
517
+ } finally {
518
+ if (timer) clearTimeout(timer);
530
519
  }
531
520
  }
532
- async function loadPlugin(projectRoot, reference, { requireFrom = null } = {}) {
533
- const localRequire = requireFrom ? requireFrom(projectRoot) : createRequire(join(projectRoot, "package.json"));
534
- const file = localRequire.resolve(reference);
535
- let mod;
536
- try {
537
- mod = localRequire(file);
538
- } catch (err) {
539
- if (err?.code !== "ERR_REQUIRE_ESM") throw err;
540
- mod = await import(pathToFileURL(file).href);
541
- }
542
- const plugin = mod?.default ?? mod;
543
- if (!isProviderPlugin(plugin)) throw new Error(`"${reference}" does not export resolveBuildCache and uploadBuildCache functions`);
544
- return plugin;
521
+ function firstLine(err) {
522
+ const line = (String(err?.message || err || "unknown error").trim().split("\n")[0] ?? "").trim();
523
+ return line.length > 200 ? `${line.slice(0, 197)}...` : line;
545
524
  }
546
- async function loadProjectProvider(projectRoot, { isExpo = true, run = null, requireFrom = null, timeoutMs = CONFIG_TIMEOUT_MS } = {}) {
547
- if (!isExpo) return { none: true };
548
- const read = readProjectConfig(projectRoot, {
549
- run,
550
- timeoutMs
551
- });
552
- if (read.unavailable) return { unavailable: read.unavailable };
553
- const normalized = normalizeProvider(providerFromConfig(read.config));
554
- if (!normalized) return { none: true };
555
- if (normalized.invalid) return { unavailable: `buildCacheProvider is ${normalized.invalid}, which is not "eas" or { plugin: <module> }` };
556
- if (normalized.reference === "@stim-cli/expo-build-cache") return { none: true };
557
- let plugin;
558
- try {
559
- plugin = await loadPlugin(projectRoot, normalized.reference, { requireFrom });
560
- } catch (err) {
561
- const missing = err?.code === "MODULE_NOT_FOUND";
562
- return { unavailable: normalized.name === "eas" && missing ? `the EAS build cache needs the \`${EAS_PROVIDER_PACKAGE}\` package, which is not installed in this project` : `${normalized.name} could not be loaded: ${firstLine(err)}` };
525
+ const POOL_SETTING_REMEDY = "Run `stim guide settings` for the device pool bounds and where it can be set.";
526
+ const MAX_SETTING = {
527
+ ios: {
528
+ key: "iosParkedMax",
529
+ env: "STIM_POOL_IOS_PARKED_MAX"
530
+ },
531
+ android: {
532
+ key: "androidParkedMax",
533
+ env: "STIM_POOL_ANDROID_PARKED_MAX"
563
534
  }
564
- return {
565
- provider: {
566
- plugin,
567
- options: normalized.options ?? {}
568
- },
569
- name: normalized.name,
570
- owner: ownerFromConfig(read.config)
571
- };
535
+ };
536
+ function parseMax(raw, strings) {
537
+ const value = strings && typeof raw === "string" ? /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : NaN : raw;
538
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) return null;
539
+ return value;
572
540
  }
573
- const WHOAMI_TIMEOUT_MS = 15e3;
574
- const EAS_CLI_PACKAGE = "eas-cli";
575
- const EAS_CLI_BIN = "eas";
576
- function parseWhoami({ stdout = "", stderr = "", exitCode = 0 } = {}) {
577
- const out = stripAnsi(stdout);
578
- const combined = `${out}\n${stripAnsi(stderr)}`;
579
- if (/^\s*Not logged in\s*$/m.test(combined)) return { loggedOut: true };
580
- if (exitCode !== 0) return { unknown: firstLine(combined.trim() || `eas whoami exited ${exitCode}`) };
581
- const lines = out.split("\n").map((line) => line.trim()).filter((line) => line !== "");
582
- if (lines.length === 0) return { unknown: "eas whoami printed nothing" };
583
- const raw = lines[0];
584
- if (raw === void 0) return { unknown: "eas whoami printed nothing" };
585
- const viaToken = /\(authenticated using EXPO_TOKEN\)\s*$/.test(raw);
586
- const display = raw.replace(/\s*\(authenticated using EXPO_TOKEN\)\s*$/, "").trim();
587
- const accounts = [];
588
- for (const line of lines) {
589
- const name = /^\u2022\s*(\S+)\s*\(Role:/.exec(line)?.[1];
590
- if (name !== void 0) accounts.push(name);
591
- }
592
- if (accounts.length === 0 && !display.endsWith("(robot)") && display !== "robot") accounts.push(display);
541
+ function parkedMaxSetting(platform, { config, env = process.env } = {}) {
542
+ const { key, env: envKey } = MAX_SETTING[platform];
543
+ const fromEnv = env[envKey];
544
+ const explicit = fromEnv !== void 0 && fromEnv !== "";
545
+ const pool = (config === void 0 ? loadConfig() : config)?.pool;
546
+ if (!explicit && pool !== void 0 && (pool === null || typeof pool !== "object" || Array.isArray(pool))) return {
547
+ max: 0,
548
+ error: "Invalid pool value. Expected an object with simulator and emulator bounds."
549
+ };
550
+ const fromConfig = pool !== null && typeof pool === "object" && !Array.isArray(pool) ? pool[key] : void 0;
551
+ const raw = explicit ? fromEnv : fromConfig;
552
+ if (raw === void 0) return {
553
+ max: env.STIM_HOME ? 0 : 3,
554
+ error: null
555
+ };
556
+ const parsed = parseMax(raw, explicit);
557
+ if (parsed === null) return {
558
+ max: 0,
559
+ error: `Invalid ${explicit ? envKey : `pool.${key}`} value ${JSON.stringify(raw)}. Expected a whole number of parked ${platform === "ios" ? "simulators" : "emulators"}, 0 or more.`
560
+ };
593
561
  return {
594
- loggedIn: true,
595
- account: display,
596
- accounts: accounts.length ? accounts : null,
597
- viaToken
562
+ max: explicit ? parsed : env.STIM_HOME ? 0 : parsed,
563
+ error: null
598
564
  };
599
565
  }
600
- function isEasAuthFailureText(text) {
601
- const t = stripAnsi(text || "");
602
- if (t.trim() === "") return false;
603
- return /not logged in/i.test(t) || /\beas login\b/i.test(t) || /EXPO_TOKEN/.test(t) || /unauthorized/i.test(t) || /not authorized/i.test(t) || /authentication (failed|required)/i.test(t);
604
- }
605
- function easAuthNote(status) {
606
- const { code, account, owner, reason, phase = "resolve" } = status || {};
607
- const because = reason ? ` (${reason})` : "";
608
- if (code === "logged-out") return `eas is not authenticated${because} -- run \`eas login\` (or set EXPO_TOKEN); ` + (phase === "upload" ? "this build stayed in the local cache" : "building with the local cache only");
609
- if (code === "wrong-account") return `eas is authenticated as ${account}, and this project's owner is ${owner} -- run \`eas login\` as a member of ${owner} (or set EXPO_TOKEN); consulting the EAS cache anyway`;
610
- return null;
566
+ function isParkedRecord(value, platform) {
567
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
568
+ const record = value;
569
+ return typeof record.udid === "string" && typeof record.name === "string" && record.name.startsWith("stim-") && typeof record.parkedAt === "string" && (platform === "android" ? record.udid === record.name && /^stim-[A-Za-z0-9._-]+$/.test(record.name) && typeof record.systemImage === "string" && typeof record.configuration === "string" : typeof record.deviceTypeIdentifier === "string" && typeof record.runtimeIdentifier === "string" && typeof record.simslimManaged === "boolean" && (record.bundleId === void 0 || typeof record.bundleId === "string") && (record.cacheKey === void 0 || typeof record.cacheKey === "string"));
611
570
  }
612
- function resolveEasCliBin(projectRoot, { lookupPath = null, timeoutMs = 5e3 } = {}) {
613
- const fromPackage = expoBinFromPackage(resolvePackageJson(projectRoot, EAS_CLI_PACKAGE), EAS_CLI_BIN);
614
- if (fromPackage) return {
615
- file: fromPackage,
616
- source: "project"
571
+ function poolBlock(config) {
572
+ const raw = config?.parked;
573
+ const block = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
574
+ const read = (platform) => {
575
+ const list = block[platform];
576
+ return Array.isArray(list) ? list.filter((record) => isParkedRecord(record, platform)) : [];
617
577
  };
618
- const shim = findBinUpward(projectRoot, EAS_CLI_BIN);
619
- if (shim) return {
620
- file: shim,
621
- source: "project"
578
+ return {
579
+ ios: read("ios"),
580
+ android: read("android")
622
581
  };
623
- const onPath = lookupPath ? lookupPath() : getExecutor().runQuiet(`command -v ${EAS_CLI_BIN}`, { timeoutMs });
624
- const file = (String(onPath || "").split("\n")[0] ?? "").trim();
625
- return file ? {
626
- file,
627
- source: "path"
628
- } : null;
629
582
  }
630
- const easAuthCache = /* @__PURE__ */ new Map();
631
- function checkEasAuth({ projectRoot, owner = null, run = null, resolveBin = null, timeoutMs = WHOAMI_TIMEOUT_MS, cache = easAuthCache }) {
632
- const key = `${projectRoot}::${owner || ""}`;
633
- if (cache?.has(key)) return cache.get(key);
634
- const answer = probeEasAuth({
635
- projectRoot,
636
- owner,
637
- run,
638
- resolveBin,
639
- timeoutMs
640
- });
641
- cache?.set(key, answer);
642
- return answer;
583
+ function readParked(platform, { config } = {}) {
584
+ return poolBlock(config === void 0 ? loadConfig() : config)[platform];
643
585
  }
644
- function probeEasAuth({ projectRoot, owner, run, resolveBin, timeoutMs }) {
645
- const bin = (resolveBin || resolveEasCliBin)(projectRoot);
646
- if (!bin) return {
647
- failed: true,
648
- code: "no-cli",
649
- reason: "no `eas` executable is resolvable from this project or on PATH",
650
- remedy: "Install eas-cli (`npm i -g eas-cli`, or as a project devDependency), then run `eas login`."
651
- };
652
- const exec = run || ((file, args, opts) => getExecutor().runFile(file, args, opts));
653
- let result;
654
- try {
655
- result = {
656
- stdout: String(exec(bin.file, ["whoami"], { timeoutMs }) ?? ""),
657
- exitCode: 0
658
- };
659
- } catch (e) {
660
- const err = e;
661
- if (err?.code === "ETIMEDOUT" || err?.signal === "SIGTERM" || err?.killed) return { unknown: `eas whoami timed out after ${timeoutMs}ms` };
662
- result = {
663
- stdout: String(err?.stdout ?? ""),
664
- stderr: String(err?.stderr ?? err?.message ?? ""),
665
- exitCode: typeof err?.status === "number" ? err.status : 1
666
- };
667
- }
668
- const parsed = parseWhoami(result);
669
- if (parsed.loggedOut) return {
670
- failed: true,
671
- code: "logged-out",
672
- reason: "eas whoami says \"Not logged in\"",
673
- remedy: "Run `eas login` (or set EXPO_TOKEN)."
586
+ function writeParked(config, platform, records) {
587
+ config.parked = {
588
+ ...poolBlock(config),
589
+ [platform]: records
674
590
  };
675
- if (parsed.unknown) return { unknown: parsed.unknown };
676
- if (owner && Array.isArray(parsed.accounts) && !parsed.accounts.includes(owner)) return {
677
- failed: true,
678
- code: "wrong-account",
679
- account: parsed.account,
680
- accounts: parsed.accounts,
681
- owner,
682
- reason: `eas is authenticated as ${parsed.account} (accounts: ${parsed.accounts.join(", ")}), which does not include this project's owner ${owner}`,
683
- remedy: `Run \`eas login\` as a member of ${owner}, or set EXPO_TOKEN to a token for it.`
591
+ }
592
+ function oldestFirst(records) {
593
+ return records.toSorted((a, b) => String(a.parkedAt).localeCompare(String(b.parkedAt)));
594
+ }
595
+ function selectParked(records, { deviceTypeIdentifier, runtimeIdentifier }) {
596
+ return oldestFirst(records.filter((r) => r.deletionClaim === void 0 && r.deviceTypeIdentifier === deviceTypeIdentifier && r.runtimeIdentifier === runtimeIdentifier));
597
+ }
598
+ function evictOverflow(records, max) {
599
+ if (records.length <= max) return {
600
+ keep: [...records],
601
+ evicted: []
684
602
  };
685
- return {
686
- ok: true,
687
- account: parsed.account,
688
- accounts: parsed.accounts,
689
- viaToken: parsed.viaToken,
690
- source: bin.source
603
+ const ordered = oldestFirst(records);
604
+ const evicted = ordered.slice(0, ordered.length - max);
605
+ const dropped = new Set(evicted.map((r) => r.udid));
606
+ return {
607
+ keep: records.filter((r) => !dropped.has(r.udid)),
608
+ evicted
691
609
  };
692
610
  }
693
- const capturing = [];
694
- const abandoned = /* @__PURE__ */ new Set();
695
- let patched = null;
696
- const PROVIDER_CONTEXT = new AsyncLocalStorage();
697
- function uploadDestination(lines) {
698
- const text = (lines || []).map(stripAnsi);
699
- for (const line of text) {
700
- const url = /(https?:\/\/[^\s'"]+)/.exec(line)?.[1];
701
- if (url !== void 0) return url.replace(/[.,)\]]+$/, "");
702
- }
703
- for (const line of text) {
704
- const slug = /\bto\s+(@?[\w.-]+\/[\w.-]+)/i.exec(line)?.[1];
705
- if (slug !== void 0) return slug.replace(/[.,)\]]+$/, "");
611
+ function parkSim({ platform, projectPath, slot = "default", record, max }) {
612
+ return withConfigLock(() => {
613
+ const cfg = ensureConfig();
614
+ const current = deviceSlotPlatforms(cfg.projects[projectPath], slot)?.[platform];
615
+ const currentId = platform === "ios" ? current?.deviceUdid : current?.avdName;
616
+ if (!current?.owned || currentId !== record.udid) throw new Error(`The ${platform === "ios" ? "simulator" : "emulator"} assignment changed before parking.`);
617
+ const { keep, evicted } = evictOverflow([...readParked(platform, { config: cfg }).filter((r) => r.udid !== record.udid), record], max);
618
+ writeParked(cfg, platform, [...keep, ...evicted]);
619
+ const project = cfg.projects[projectPath];
620
+ if (project) removeSlotDevice(project, platform, slot);
621
+ saveConfig(cfg);
622
+ return evicted;
623
+ });
624
+ }
625
+ function adoptParked({ platform, projectPath, slot = "default", udid, device }) {
626
+ return withConfigLock(() => {
627
+ const cfg = ensureConfig();
628
+ const records = readParked(platform, { config: cfg });
629
+ const taken = records.find((r) => r.udid === udid);
630
+ if (!taken || taken.deletionClaim !== void 0) return null;
631
+ if (deviceSlotPlatforms(cfg.projects[projectPath], slot)?.[platform]) return null;
632
+ writeParked(cfg, platform, records.filter((r) => r.udid !== udid));
633
+ const project = cfg.projects[projectPath];
634
+ if (!project) throw new Error(`Project not registered: ${projectPath}`);
635
+ assignSlotDevice(project, platform, device, slot);
636
+ saveConfig(cfg);
637
+ return taken;
638
+ });
639
+ }
640
+ function isProcessAlive(pid) {
641
+ try {
642
+ process.kill(pid, 0);
643
+ return true;
644
+ } catch (error) {
645
+ return error.code === "EPERM";
706
646
  }
707
- return null;
708
647
  }
709
- function providerNote(text) {
710
- return `${"cache".padEnd(11)} ${text}`;
648
+ function parseDeletionClaim(value) {
649
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
650
+ const claim = value;
651
+ if (!Number.isSafeInteger(claim.pid) || claim.pid <= 0) return null;
652
+ if (typeof claim.token !== "string" || claim.token.length === 0) return null;
653
+ return {
654
+ pid: claim.pid,
655
+ token: claim.token
656
+ };
711
657
  }
712
- function currentFrame() {
713
- const store = PROVIDER_CONTEXT.getStore();
714
- return store && (capturing.includes(store) || abandoned.has(store)) ? store : null;
658
+ function claimParkedRemoval(platform, udid) {
659
+ return withConfigLock(() => {
660
+ const cfg = loadConfig();
661
+ if (!cfg) return null;
662
+ const records = readParked(platform, { config: cfg });
663
+ const record = records.find((candidate) => candidate.udid === udid);
664
+ if (!record) return null;
665
+ const existingClaim = parseDeletionClaim(record.deletionClaim);
666
+ if (record.deletionClaim !== void 0 && (!existingClaim || isProcessAlive(existingClaim.pid))) return null;
667
+ const token = randomUUID();
668
+ const claimedRecord = {
669
+ ...record,
670
+ deletionClaim: {
671
+ pid: process.pid,
672
+ token
673
+ }
674
+ };
675
+ writeParked(cfg, platform, records.map((candidate) => candidate.udid === udid ? claimedRecord : candidate));
676
+ saveConfig(cfg);
677
+ const claimed = { ...record };
678
+ delete claimed.deletionClaim;
679
+ return {
680
+ record: claimed,
681
+ token
682
+ };
683
+ });
715
684
  }
716
- function install() {
717
- if (patched && process.stdout.write === patched.patchedWrite) return;
718
- const stdout = process.stdout;
719
- const write = stdout.write.bind(stdout);
720
- const log = console.log;
721
- const passthrough = write;
722
- const patchedWrite = ((chunk, encoding, callback) => {
723
- const frame = currentFrame();
724
- if (!frame) return passthrough(chunk, encoding, callback);
725
- const cb = typeof encoding === "function" ? encoding : callback;
726
- absorb(frame, typeof chunk === "string" ? chunk : String(chunk));
727
- if (typeof cb === "function") cb();
728
- return true;
685
+ function clearParkedRemovalClaim(platform, udid, token) {
686
+ withConfigLock(() => {
687
+ const cfg = loadConfig();
688
+ if (!cfg) return;
689
+ const records = readParked(platform, { config: cfg });
690
+ const record = records.find((candidate) => candidate.udid === udid);
691
+ if (!record || parseDeletionClaim(record.deletionClaim)?.token !== token) return;
692
+ const restored = { ...record };
693
+ delete restored.deletionClaim;
694
+ writeParked(cfg, platform, records.map((candidate) => candidate.udid === udid ? restored : candidate));
695
+ saveConfig(cfg);
729
696
  });
730
- const patchedLog = (...args) => {
731
- const frame = currentFrame();
732
- if (!frame) return log(...args);
733
- absorb(frame, `${format(...args)}\n`);
734
- };
735
- stdout.write = patchedWrite;
736
- console.log = patchedLog;
737
- patched = {
738
- stdout,
739
- write,
740
- log,
741
- patchedWrite,
742
- patchedLog
743
- };
744
697
  }
745
- function uninstall() {
746
- if (!patched || capturing.length || abandoned.size) return;
747
- if (patched.stdout.write === patched.patchedWrite) patched.stdout.write = patched.write;
748
- if (console.log === patched.patchedLog) console.log = patched.log;
749
- patched = null;
698
+ function removeParkedAfter(platform, udid, beforeRemove) {
699
+ const claim = claimParkedRemoval(platform, udid);
700
+ if (!claim) return null;
701
+ try {
702
+ beforeRemove(claim.record);
703
+ } catch (error) {
704
+ try {
705
+ clearParkedRemovalClaim(platform, udid, claim.token);
706
+ } catch {}
707
+ throw error;
708
+ }
709
+ return withConfigLock(() => {
710
+ const cfg = loadConfig();
711
+ if (!cfg) return null;
712
+ const records = readParked(platform, { config: cfg });
713
+ if (parseDeletionClaim(records.find((candidate) => candidate.udid === udid)?.deletionClaim)?.token !== claim.token) return null;
714
+ writeParked(cfg, platform, records.filter((candidate) => candidate.udid !== udid));
715
+ saveConfig(cfg);
716
+ return claim.record;
717
+ });
750
718
  }
751
- function absorb(frame, chunk) {
719
+ function dropParked(platform, udid) {
720
+ return removeParkedAfter(platform, udid, () => {}) !== null;
721
+ }
722
+ //#endregion
723
+ //#region src/teardown.ts
724
+ function teardownParkedAvd(avdName) {
752
725
  try {
753
- process.stderr.write(chunk);
754
- } catch {}
755
- frame.pending += chunk;
756
- let index = frame.pending.indexOf("\n");
757
- while (index !== -1) {
758
- emitLine(frame, frame.pending.slice(0, index));
759
- frame.pending = frame.pending.slice(index + 1);
760
- index = frame.pending.indexOf("\n");
726
+ return removeParkedAfter("android", avdName, () => {
727
+ const result = teardownOwnedAvd(avdName, {
728
+ del: true,
729
+ owner: { pool: true }
730
+ });
731
+ if (result.status !== "torn-down" && result.status !== "missing") throw new Error(result.reason);
732
+ }) ? {
733
+ status: "torn-down",
734
+ label: avdName
735
+ } : {
736
+ status: "skipped",
737
+ kind: "not-parked",
738
+ reason: "emulator is no longer parked"
739
+ };
740
+ } catch (error) {
741
+ return {
742
+ status: "failed",
743
+ reason: String(error?.message || error)
744
+ };
761
745
  }
762
746
  }
763
- function emitLine(frame, raw) {
764
- const line = stripAnsi(raw).trim();
765
- if (line === "") return;
766
- frame.lines.push(line);
767
- const writer = frame.writer();
747
+ function teardownParkedIosSim(udid, { label, deleteSim = deleteParkedIosSim } = {}) {
768
748
  try {
769
- writer?.write({
770
- src: "build",
771
- level: "debug",
772
- event: "provider",
773
- msg: line
774
- });
775
- } catch {}
776
- frame.onLine?.(line);
749
+ const removed = removeParkedAfter("ios", udid, () => deleteSim(udid));
750
+ if (!removed) return {
751
+ status: "skipped",
752
+ kind: "not-parked",
753
+ reason: "simulator is no longer parked"
754
+ };
755
+ return {
756
+ status: "torn-down",
757
+ label: label ?? removed.name
758
+ };
759
+ } catch (error) {
760
+ return {
761
+ status: "failed",
762
+ reason: String(error?.message || error)
763
+ };
764
+ }
777
765
  }
778
- function beginCapture({ logWriter = null, projectRoot = null, platform = null, onLine = null } = {}) {
779
- let own = null;
780
- const frame = {
781
- lines: [],
782
- pending: "",
783
- onLine,
784
- writer: () => {
785
- if (logWriter) return logWriter;
786
- if (own) return own;
787
- if (!projectRoot || !platform) return null;
788
- own = createNdjsonWriter(join(workspaceLogsDir(projectRoot), `build-${platform}.ndjson`));
789
- return own;
790
- },
791
- close: () => {
792
- own?.close?.();
793
- own = null;
794
- }
766
+ function parkOwnedIosSim(udid, park) {
767
+ const resolved = resolveOwnedIosSim(udid);
768
+ if (resolved.missing) throw new Error(`simulator ${udid} disappeared after shutdown`);
769
+ if (resolved.notOwned) throw new Error(`simulator ${udid} is now named ${JSON.stringify(resolved.notOwned)}`);
770
+ const sim = resolved.sim;
771
+ if (sim.state !== "Shutdown") throw new Error(`simulator ${udid} is still ${sim.state} after shutdown`);
772
+ const model = listIosDeviceTypes().find((d) => d.identifier === sim.deviceTypeIdentifier)?.name ?? null;
773
+ const runtime = parseRuntimeVersion(sim.runtime);
774
+ if (park.bundleId) {
775
+ if (!sim.dataPath) throw new Error(`simulator ${udid} did not report a data path for app cleanup`);
776
+ const container = findAppDataContainer(sim.dataPath, park.bundleId);
777
+ if (container) clearAppDataContainer(container);
778
+ }
779
+ const name = parkedSimName(sim.udid, {
780
+ model,
781
+ runtime
782
+ });
783
+ renameIosSim(sim.udid, name);
784
+ const record = {
785
+ udid: sim.udid,
786
+ name,
787
+ deviceTypeIdentifier: sim.deviceTypeIdentifier,
788
+ runtimeIdentifier: sim.runtime,
789
+ parkedAt: (/* @__PURE__ */ new Date()).toISOString(),
790
+ simslimManaged: Boolean(park.simslimManaged),
791
+ ...park.bundleId ? { bundleId: park.bundleId } : {},
792
+ ...park.cacheKey ? { cacheKey: park.cacheKey } : {}
793
+ };
794
+ return {
795
+ record,
796
+ evicted: parkSim({
797
+ platform: "ios",
798
+ projectPath: park.projectPath,
799
+ slot: park.slot,
800
+ record,
801
+ max: park.max
802
+ })
795
803
  };
796
- capturing.push(frame);
797
- install();
798
- return frame;
799
804
  }
800
- function endCapture(frame) {
801
- if (!frame) return;
802
- const at = capturing.lastIndexOf(frame);
803
- if (at !== -1) capturing.splice(at, 1);
804
- abandoned.delete(frame);
805
- if (frame.pending !== "") {
806
- const rest = frame.pending;
807
- frame.pending = "";
808
- emitLine(frame, rest);
805
+ function teardownOwnedIosSim(udid, { del = false, label, park } = {}) {
806
+ let parkFallback;
807
+ try {
808
+ const resolved = resolveOwnedIosSim(udid);
809
+ if (resolved.notOwned) return {
810
+ status: "skipped",
811
+ kind: "not-owned",
812
+ reason: `sim is now named "${resolved.notOwned}", not Stim-owned by name`
813
+ };
814
+ if (resolved.missing) return { status: "missing" };
815
+ shutdownIosSim(udid);
816
+ const sim = resolved.sim;
817
+ if (del && park && park.max > 0) try {
818
+ const { record, evicted } = parkOwnedIosSim(udid, park);
819
+ const removed = [];
820
+ const failures = [];
821
+ for (const entry of evicted) {
822
+ const result = teardownParkedIosSim(entry.udid, { label: entry.name });
823
+ if (result.status === "torn-down") removed.push({
824
+ udid: entry.udid,
825
+ name: entry.name
826
+ });
827
+ else if (result.status === "failed") failures.push(`could not delete evicted ${entry.name} (${entry.udid}): ${result.reason}`);
828
+ }
829
+ return {
830
+ status: "torn-down",
831
+ label: label ?? sim.name ?? udid,
832
+ parked: {
833
+ udid: record.udid,
834
+ name: record.name
835
+ },
836
+ evicted: removed,
837
+ ...failures.length ? { evictionFailures: failures } : {}
838
+ };
839
+ } catch (e) {
840
+ parkFallback = String(e?.message || e);
841
+ }
842
+ if (del) deleteIosSim(udid);
843
+ return {
844
+ status: "torn-down",
845
+ label: label ?? sim.name ?? udid,
846
+ ...parkFallback ? { parkFallback } : {}
847
+ };
848
+ } catch (e) {
849
+ return {
850
+ status: "failed",
851
+ reason: String(e?.message || e),
852
+ ...parkFallback ? { parkFallback } : {}
853
+ };
809
854
  }
810
- frame.close();
811
- uninstall();
812
- }
813
- function abandonCapture(frame, work) {
814
- if (!frame) return;
815
- const at = capturing.lastIndexOf(frame);
816
- if (at !== -1) capturing.splice(at, 1);
817
- abandoned.add(frame);
818
- Promise.resolve(work).then(() => endCapture(frame), () => endCapture(frame));
819
855
  }
820
- async function resolveRemote({ provider, platform, projectRoot, fingerprintHash, runOptions = null, timeoutMs = RESOLVE_TIMEOUT_MS, logWriter = null } = {}) {
821
- if (!provider?.plugin || !fingerprintHash) return null;
822
- const opts = runOptions || runOptionsFor(platform);
823
- const outcome = await withBudget(async () => {
824
- const props = {
825
- fingerprintHash: await providerFingerprint({
826
- provider,
827
- platform,
828
- projectRoot,
829
- runOptions: opts
830
- }) ?? fingerprintHash,
831
- platform,
832
- runOptions: opts,
833
- projectRoot
834
- };
835
- return typeof provider.plugin.resolveBuildCache === "function" ? provider.plugin.resolveBuildCache(props, provider.options) : provider.plugin.resolveRemoteBuildCache(props, provider.options);
836
- }, timeoutMs, {
837
- logWriter,
838
- projectRoot,
839
- platform
856
+ function removeOrphanedAvdDirectory(candidate, owner) {
857
+ const detached = withConfigLock(() => {
858
+ const current = listOrphanedAvdDirectories(candidate.name).find((entry) => entry.directory === candidate.directory);
859
+ if (!current || current.name !== candidate.name || current.dev !== candidate.dev || current.ino !== candidate.ino) throw new Error(`AVD data at ${candidate.directory} changed or is registered; it was kept.`);
860
+ const config = loadConfig();
861
+ if (!config) throw new Error("Cannot verify AVD references without a Stim config; the data was kept.");
862
+ for (const [projectPath, project] of Object.entries(config.projects)) for (const { platforms } of projectDeviceSlots(project)) {
863
+ if (platforms.android?.avdName !== candidate.name) continue;
864
+ if (!owner || !("projectPath" in owner) || owner.projectPath !== projectPath || !platforms.android.owned) throw new Error(`AVD ${candidate.name} is referenced by ${projectPath}; its data was kept.`);
865
+ }
866
+ if (readParked("android", { config }).some((entry) => entry.name === candidate.name) && !(owner && "pool" in owner)) throw new Error(`AVD ${candidate.name} is in the emulator pool; its data was kept.`);
867
+ assertOwnedAvdStopped(candidate.name, { resolveDirectory: () => candidate.directory });
868
+ const destination = join(dirname(candidate.directory), `stim-gc-${randomUUID()}.avd`);
869
+ renameSync(candidate.directory, destination);
870
+ return destination;
840
871
  });
841
- if (outcome.timedOut) return { timedOut: true };
842
- if (outcome.error) return { failed: firstLine(outcome.error) };
843
- const appPath = typeof outcome.value === "string" ? outcome.value.trim() : "";
844
- if (!appPath) return null;
845
- if (!existsSync(appPath)) return { failed: `returned ${appPath}, which does not exist` };
846
- return { appPath };
872
+ const current = lstatSync(detached);
873
+ if (!current.isDirectory() || current.dev !== candidate.dev || current.ino !== candidate.ino) throw new Error(`Detached AVD data changed at ${detached}; it was kept.`);
874
+ try {
875
+ rmSync(detached, { recursive: true });
876
+ } catch (error) {
877
+ throw new Error(`Could not remove AVD data at ${detached}; retry gc --delete: ${error.message}`, { cause: error });
878
+ }
847
879
  }
848
- async function uploadRemote({ provider, platform, projectRoot, fingerprintHash, buildPath, runOptions = null, timeoutMs = UPLOAD_TIMEOUT_MS, logWriter = null, note = (line) => console.error(line) } = {}) {
849
- if (!provider?.plugin || !fingerprintHash || !buildPath) return { skipped: true };
850
- const opts = runOptions || runOptionsFor(platform);
851
- let announced = false;
852
- const onLine = (line) => {
853
- if (announced) return;
854
- const dest = uploadDestination([line]);
855
- if (!dest) return;
856
- announced = true;
857
- note(providerNote(`uploading to ${dest}`));
880
+ function teardownUnregisteredAvd(avdName, del, owner, surveyed) {
881
+ const candidates = surveyed ? [surveyed] : listOrphanedAvdDirectories(avdName);
882
+ if (candidates.length === 0) {
883
+ if (avdStorageRoots().some((root) => avdPathExists(join(root, `${avdName}.ini`)) || avdPathExists(join(root, `${avdName}.avd`)))) throw new Error(`AVD ${avdName} is not listed but its storage remains; it was kept.`);
884
+ return { status: "missing" };
885
+ }
886
+ if (!del) return {
887
+ status: "skipped",
888
+ reason: "AVD registration is missing; its data was kept."
858
889
  };
859
- const outcome = await withBudget(async () => {
860
- const props = {
861
- projectRoot,
862
- platform,
863
- fingerprintHash: await providerFingerprint({
864
- provider,
865
- platform,
866
- projectRoot,
867
- runOptions: opts
868
- }) ?? fingerprintHash,
869
- buildPath,
870
- runOptions: opts
871
- };
872
- return typeof provider.plugin.uploadBuildCache === "function" ? provider.plugin.uploadBuildCache(props, provider.options) : provider.plugin.uploadRemoteBuildCache(props, provider.options);
873
- }, timeoutMs, {
874
- logWriter,
875
- projectRoot,
876
- platform,
877
- onLine
878
- });
879
- if (outcome.timedOut) return { timedOut: true };
880
- if (outcome.error) return { failed: firstLine(outcome.error) };
881
- const destination = uploadDestination(outcome.lines);
882
- return destination ? {
883
- uploaded: true,
884
- destination
885
- } : { uploaded: true };
886
- }
887
- async function providerFingerprint({ provider, platform, projectRoot, runOptions }) {
888
- if (typeof provider.plugin.calculateFingerprintHash !== "function") return null;
889
- try {
890
- const hash = await provider.plugin.calculateFingerprintHash({
891
- projectRoot,
892
- platform,
893
- runOptions
894
- }, provider.options);
895
- return typeof hash === "string" && hash.trim() !== "" ? hash.trim() : null;
896
- } catch {
897
- return null;
890
+ for (const candidate of candidates) {
891
+ if (candidate.name !== avdName) throw new Error(`AVD data does not match ${avdName}; it was kept.`);
892
+ removeOrphanedAvdDirectory(candidate, owner);
898
893
  }
894
+ return {
895
+ status: "torn-down",
896
+ label: avdName
897
+ };
899
898
  }
900
- async function withBudget(factory, ms, capture) {
901
- let timer = null;
902
- const timeout = new Promise((resolve) => {
903
- timer = setTimeout(() => resolve(TIMED_OUT), ms);
904
- timer?.unref?.();
905
- });
906
- const frame = beginCapture(capture || {});
907
- const work = Promise.resolve().then(() => PROVIDER_CONTEXT.run(frame, factory)).then((value) => ({ value }), (error) => ({ error }));
899
+ function teardownOwnedAvd(avdName, { del = false, park, owner, orphanedDirectory, waitForShutdown = waitForAndroidEmulatorShutdown, assertStopped = assertOwnedAvdStopped, resolveAvd = resolveOwnedAvdSerial } = {}) {
900
+ let parkFallback;
908
901
  try {
909
- const settled = await Promise.race([work, timeout]);
910
- if (settled === TIMED_OUT) {
911
- abandonCapture(frame, work);
912
- return {
913
- timedOut: true,
914
- lines: frame.lines
902
+ if (!/^stim-[A-Za-z0-9._-]+$/.test(avdName)) return {
903
+ status: "skipped",
904
+ kind: "not-owned",
905
+ reason: `AVD ${avdName} is not Stim-owned by name`
906
+ };
907
+ const resolved = resolveAvd(avdName);
908
+ if (resolved.notOwned) return {
909
+ status: "skipped",
910
+ kind: "not-owned",
911
+ reason: `AVD ${avdName} is not Stim-owned by name`
912
+ };
913
+ if (resolved.missing) return teardownUnregisteredAvd(avdName, del, owner, orphanedDirectory);
914
+ if (orphanedDirectory) return {
915
+ status: "skipped",
916
+ reason: "AVD registration appeared; its data was kept."
917
+ };
918
+ const serial = resolved.serial;
919
+ if (serial) waitForShutdown(avdName, (timeoutMs) => shutdownAndroidEmulator(serial, timeoutMs));
920
+ else assertStopped(avdName);
921
+ if (del) {
922
+ const current = resolveAvd(avdName);
923
+ if (current.notOwned) return {
924
+ status: "skipped",
925
+ kind: "not-owned",
926
+ reason: `AVD ${avdName} is not Stim-owned by name`
915
927
  };
928
+ if (current.missing) return teardownUnregisteredAvd(avdName, del, owner);
929
+ if (current.serial) throw new Error(`Owned AVD ${avdName} started again before deletion.`);
930
+ assertStopped(avdName);
931
+ if (park && park.max > 0) try {
932
+ const systemImage = ownedAvdSystemImage(avdName);
933
+ if (!systemImage || !park.configuration || !ownedAvdMatchesConfiguration(avdName, park.configuration)) throw new Error("the AVD has no verified creation configuration");
934
+ const evicted = parkSim({
935
+ platform: "android",
936
+ projectPath: park.projectPath,
937
+ slot: park.slot,
938
+ max: park.max,
939
+ record: {
940
+ udid: avdName,
941
+ name: avdName,
942
+ systemImage,
943
+ configuration: park.configuration,
944
+ parkedAt: (/* @__PURE__ */ new Date()).toISOString()
945
+ }
946
+ });
947
+ const removed = [];
948
+ const failures = [];
949
+ for (const entry of evicted) {
950
+ const result = teardownParkedAvd(entry.name);
951
+ if (result.status === "torn-down") removed.push({
952
+ udid: entry.name,
953
+ name: entry.name,
954
+ platform: "android"
955
+ });
956
+ else if (result.status === "failed") failures.push(`could not delete evicted ${entry.name}: ${result.reason}`);
957
+ }
958
+ return {
959
+ status: "torn-down",
960
+ label: avdName,
961
+ parked: {
962
+ udid: avdName,
963
+ name: avdName,
964
+ platform: "android"
965
+ },
966
+ evicted: removed,
967
+ ...failures.length ? { evictionFailures: failures } : {}
968
+ };
969
+ } catch (error) {
970
+ parkFallback = String(error?.message || error);
971
+ }
972
+ const directory = ownedAvdDirectory(avdName);
973
+ const paths = avdStorageRoots().map((root) => join(root, `${avdName}.ini`));
974
+ if (directory) paths.push(directory);
975
+ deleteAvd(avdName);
976
+ const remaining = paths.filter(avdPathExists);
977
+ if (remaining.length) throw new Error(`AVD ${avdName} was only partially deleted; data remains at ${remaining.join(", ")}.`);
916
978
  }
917
- endCapture(frame);
918
979
  return {
919
- ...settled,
920
- lines: frame.lines
980
+ status: "torn-down",
981
+ label: avdName,
982
+ serial: resolved.serial ?? null,
983
+ ...parkFallback ? { parkFallback } : {}
984
+ };
985
+ } catch (e) {
986
+ return {
987
+ status: "failed",
988
+ reason: String(e?.message || e)
921
989
  };
922
- } catch (err) {
923
- endCapture(frame);
924
- throw err;
925
- } finally {
926
- if (timer) clearTimeout(timer);
927
990
  }
928
991
  }
929
- function firstLine(err) {
930
- const line = (String(err?.message || err || "unknown error").trim().split("\n")[0] ?? "").trim();
931
- return line.length > 200 ? `${line.slice(0, 197)}...` : line;
932
- }
933
992
  //#endregion
934
- export { selectParked as C, readParked as S, teardownParkedIosSim as _, easAuthNote as a, dropParked as b, loadProjectProvider as c, resolveEasCliBin as d, resolveRemote as f, teardownParkedAvd as g, teardownOwnedIosSim as h, checkEasAuth as i, ownerFromConfig as l, teardownOwnedAvd as m, UPLOAD_TIMEOUT_MS as n, exitAfterFlush as o, uploadRemote as p, cacheLevel as r, isEasAuthFailureText as s, RESOLVE_TIMEOUT_MS as t, providerFromConfig as u, POOL_SETTING_REMEDY as v, parkedMaxSetting as x, adoptParked as y };
993
+ export { uploadRemote as C, resolveRemote as S, isEasAuthFailureText as _, POOL_SETTING_REMEDY as a, providerFromConfig as b, parkedMaxSetting as c, RESOLVE_TIMEOUT_MS as d, UPLOAD_TIMEOUT_MS as f, exitAfterFlush as g, easAuthNote as h, teardownParkedIosSim as i, readParked as l, checkEasAuth as m, teardownOwnedIosSim as n, adoptParked as o, cacheLevel as p, teardownParkedAvd as r, dropParked as s, teardownOwnedAvd as t, selectParked as u, loadProjectProvider as v, resolveEasCliBin as x, ownerFromConfig as y };