stim 1.11.0 → 1.13.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 (82) hide show
  1. package/README.md +3 -1
  2. package/dist/{activity-BtG4Va9l.mjs → activity-DfVKZTEB.mjs} +44 -26
  3. package/dist/{android-BA2i_KAV.mjs → android-BHfAu2mf.mjs} +1 -1
  4. package/dist/{android-C1q4l523.mjs → android-CD8pr8Q2.mjs} +112 -54
  5. package/dist/{android-cas-DfJkwCXW.mjs → android-cas-B9Kv9aIy.mjs} +12 -10
  6. package/dist/{android-C4zG2Tuc.mjs → android-qx_31wJo.mjs} +414 -118
  7. package/dist/{app-install-DHut6JnV.mjs → app-install-CDFY2nUL.mjs} +1 -1
  8. package/dist/budget-E5o91NR1.mjs +1739 -0
  9. package/dist/{build-plan-CeAYgKAx.mjs → build-plan-BTa93o_r.mjs} +125 -107
  10. package/dist/{build-progress-CDwogz6R.mjs → build-progress-l7d-X-ZP.mjs} +76 -8
  11. package/dist/{cache-manifest-Pubfp8wV.mjs → cache-manifest-DWPF4RKJ.mjs} +1 -1
  12. package/dist/cache-manifest.mjs +1 -1
  13. package/dist/cdp-Dq_ypAlJ.mjs +115 -0
  14. package/dist/chrome-B5hWgQJS.mjs +61 -0
  15. package/dist/cli.mjs +17 -16
  16. package/dist/collector-run.mjs +15 -9
  17. package/dist/{command-output-D5z2_bcF.mjs → command-output-BIL7rlX7.mjs} +1 -1
  18. package/dist/{config-BEq0Qw_a.mjs → config-0jCcMM0E.mjs} +10 -1
  19. package/dist/created-devices-Cd558h31.mjs +37 -0
  20. package/dist/{dependency-state-r9UesA9a.mjs → dependency-state-DNN0yfx6.mjs} +1 -1
  21. package/dist/{deps-OF7CD5Dx.mjs → deps-2Wo81np9.mjs} +2 -2
  22. package/dist/{device-BwH2iI3O.mjs → device-Cf7vl-so.mjs} +7 -7
  23. package/dist/{device-lease-D83nqeMX.mjs → device-lease-CL9dZf73.mjs} +55 -6
  24. package/dist/{device-pool-DHj7cdPy.mjs → device-pool-MuoiyRcu.mjs} +4 -4
  25. package/dist/{device-remote-BQZSnnmE.mjs → device-remote-DT9ngZiD.mjs} +254 -579
  26. package/dist/{doctor-D8QdMRMf.mjs → doctor-D2iysNNH.mjs} +50 -20
  27. package/dist/{error-diagnostics-UYFSQwRy.mjs → error-diagnostics-BoiOhck8.mjs} +7 -7
  28. package/dist/{gc-COZteyiq.mjs → gc-eA-PRBX8.mjs} +1022 -131
  29. package/dist/{guide-NDvTmMi-.mjs → guide-BfZ0OmiP.mjs} +909 -132
  30. package/dist/{guide-status-CWvti2Lg.mjs → guide-status-BaMYu55M.mjs} +2 -2
  31. package/dist/{idle-NETbeNiQ.mjs → idle-DxLi5OkN.mjs} +43 -10
  32. package/dist/{in-use-DVhlSoyn.mjs → in-use-wYEaKQM9.mjs} +11 -7
  33. package/dist/{ios-CQLKpNfr.mjs → ios-CcfCeZ19.mjs} +103 -58
  34. package/dist/{ios-DArbmwer.mjs → ios-CmEzZiOj.mjs} +31 -22
  35. package/dist/{ios-device-CwDZo0X4.mjs → ios-device-BW-t_Lkv.mjs} +1 -1
  36. package/dist/{launch-verify-CuPDGXdr.mjs → launch-verify-CuJaBpEa.mjs} +33 -20
  37. package/dist/{logs-B44a1UeU.mjs → logs-DcSRbC2t.mjs} +12 -11
  38. package/dist/{merge-state-DDxS-XIZ.mjs → merge-state-ytFQCeh4.mjs} +38 -8
  39. package/dist/{metro-BWLOJ_Wr.mjs → metro-BPeY1YMh.mjs} +5 -5
  40. package/dist/{named-ports-Dc7neAlc.mjs → named-ports-n7tnJsI8.mjs} +78 -6
  41. package/dist/native-runtime-CiiFHt0e.mjs +74 -0
  42. package/dist/{ownership-D2xbLVoz.mjs → ownership-Bz2kETAS.mjs} +2 -2
  43. package/dist/{ownership-BiojdtAp.mjs → ownership-PUP4Dwai.mjs} +175 -217
  44. package/dist/page-BTu7I60K.mjs +29 -0
  45. package/dist/{ports-DwBaRFJs.mjs → ports-C5UutqpJ.mjs} +5 -5
  46. package/dist/{project-BXZzOABt.mjs → project-DGeclhvO.mjs} +55 -87
  47. package/dist/{reload-By9Dwsrt.mjs → reload-C-cV5yy_.mjs} +80 -21
  48. package/dist/remote-cache--aehr4Pr.mjs +526 -0
  49. package/dist/{server-bare-D_RK1RCj.mjs → server-bare-Bq8BpSIo.mjs} +1 -1
  50. package/dist/server-expo-BeYwP_Xy.mjs +2 -0
  51. package/dist/{server-expo-o6H5gcBc.mjs → server-expo-DUCg52-K.mjs} +2 -2
  52. package/dist/{settings-C0ugALSC.mjs → settings-ssVBToDR.mjs} +29 -17
  53. package/dist/{settings-B5oFZEri.mjs → settings-xB0Frkg6.mjs} +13 -4
  54. package/dist/settings.schema.json +74 -2
  55. package/dist/{simslim-CIR3IYJO.mjs → simslim-Bbg9UWrt.mjs} +13 -6
  56. package/dist/{slot-launch-rggO1Esw.mjs → slot-launch-BIWEW-wI.mjs} +4 -4
  57. package/dist/{spawn-claims-BJwXTMPt.mjs → spawn-claims-PT8aQpCa.mjs} +1 -1
  58. package/dist/{start-BSsf2tUs.mjs → start-BZhItkiL.mjs} +110 -18
  59. package/dist/{start-i8zc9VmS.mjs → start-Rw2GD3nV.mjs} +1 -1
  60. package/dist/{state-WGdIbi_d.mjs → state-DwAU2_-D.mjs} +15 -3
  61. package/dist/state-l1pYwOre.mjs +148 -0
  62. package/dist/{stats-DBYJ5bxL.mjs → stats-CaSwmsSZ.mjs} +4 -4
  63. package/dist/{stats-BAgJaLA4.mjs → stats-D8rUTPHd.mjs} +1 -1
  64. package/dist/{status-CitcBRsC.mjs → status-TJlL0HWo.mjs} +649 -116
  65. package/dist/{status-Q-WDEGCB.mjs → status-sszP4XKU.mjs} +130 -28
  66. package/dist/stim-desktop-CXt8-Z3J.mjs +40 -0
  67. package/dist/{stop-DIhrvu0B.mjs → stop-BZ2qMiBP.mjs} +50 -18
  68. package/dist/{stop-rst5mL14.mjs → stop-DskDKNTU.mjs} +2 -2
  69. package/dist/supervisor-run.mjs +11 -11
  70. package/dist/web-run.d.mts +22 -0
  71. package/dist/web-run.mjs +481 -0
  72. package/dist/web-sWXd5iek.mjs +394 -0
  73. package/dist/{worktree-DhxDTK44.mjs → worktree-3cBa-bQt.mjs} +1 -1
  74. package/dist/{worktree-KOjMX3zA.mjs → worktree-D4Do_XiQ.mjs} +229 -21
  75. package/dist/{worktree-BlizKDpZ.mjs → worktree-DeBQSVtR.mjs} +3 -1
  76. package/dist/{xcode-B5SPD--o.mjs → xcode-LM6A8cNZ.mjs} +7 -7
  77. package/package.json +5 -5
  78. package/dist/budget-De3P2rq_.mjs +0 -620
  79. package/dist/native-run-D7yB_AsB.mjs +0 -143
  80. package/dist/server-expo-B-CCUJZj.mjs +0 -2
  81. package/dist/workspace-process-lock-Cx74RSDZ.mjs +0 -57
  82. package/dist/workspaces-COD2xCz-.mjs +0 -1013
@@ -0,0 +1,1739 @@
1
+ import { B as deviceSlotPlatforms, E as withConfigLock$1, F as measuredDirectorySize, I as volumeRootFor, M as formatBytes, N as isOnMountedVolume, O as process_identity_exports, P as listMountedVolumes, U as projectDeviceSlots, _ as loadConfig$1, a as clearDevice, d as getConfigDir, g as isPathPrefix, j as directorySize, o as clearSupervisor, p as getProject } from "./config-0jCcMM0E.mjs";
2
+ import { i as registeredCaches } from "./cache-manifest-DWPF4RKJ.mjs";
3
+ import { C as sharedBuildCache, N as workspaceName$1, c as resolveProjectMetro, h as readWorkspaceState$1, k as workspaceDir, n as killMetroTree, y as workspaceLastUsed } from "./metro-BPeY1YMh.mjs";
4
+ import { n as supervisorError } from "./errors-B6W7FkxQ.mjs";
5
+ import { s as clearWorkspaceSupervisor } from "./state-DwAU2_-D.mjs";
6
+ import { a as withWorkspaceProcessLock, o as workspaceProcessLockError } from "./named-ports-n7tnJsI8.mjs";
7
+ import { l as phaseLine, u as plural } from "./command-output-BIL7rlX7.mjs";
8
+ import { i as listLeaseFiles, r as leaseIsExpired } from "./device-lease-CL9dZf73.mjs";
9
+ import { o as workspaceActivity } from "./activity-DfVKZTEB.mjs";
10
+ import { F as ownedAvdDirectory, R as ownedAvdSerialResolver, at as dropParked, k as listAvds, lt as readParked, nt as isStimOwnedSim, tt as isStimOwnedAvd } from "./android-qx_31wJo.mjs";
11
+ import { f as teardownOwnedAvd, g as teardownParkedIosSim, h as teardownParkedAvd, m as teardownOwnedIosSim, p as teardownOwnedBrowser, t as resolveSupervisorTarget } from "./ownership-PUP4Dwai.mjs";
12
+ import { d as listIosDeviceTypes, g as parseRuntimeVersion, l as listAllIosSims } from "./ios-CmEzZiOj.mjs";
13
+ import { c as recordGcResult, i as idleShutdownCandidates, n as collectOwnedDeviceActivity, o as shutDownIdleDevices, s as workspaceBuildInProgress, t as collectIdleDevices } from "./idle-DxLi5OkN.mjs";
14
+ import { i as canonicalPath, n as withIdleWorkspace, r as workspaceInUse, t as emptyWorkspaceDir } from "./in-use-wYEaKQM9.mjs";
15
+ import { c as formatSpace, r as capacity, s as environmentState } from "./status-sszP4XKU.mjs";
16
+ import { existsSync, statSync, statfsSync } from "node:fs";
17
+ import { existsSync as existsSync$1, lstatSync as lstatSync$1, readdirSync as readdirSync$1, realpathSync as realpathSync$1, rmSync as rmSync$1, statSync as statSync$1 } from "fs";
18
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
19
+ import { METRO_NAMED_CACHE_LAYOUT } from "@stim-cli/core";
20
+ import { isJsonObject, readJsonFile, settingDefinition, settingValueError } from "@stim-cli/core/state";
21
+ import { dirname as dirname$1, join as join$1, sep as sep$1 } from "node:path";
22
+ import { homedir, tmpdir } from "os";
23
+ import { homedir as homedir$1, platform, totalmem as totalmem$1 } from "node:os";
24
+ import chalk from "chalk";
25
+ //#region src/supervisor/cache-reset.ts
26
+ async function stopOwnedMetro(root) {
27
+ const project = getProject(root);
28
+ const port = project?.metroPort;
29
+ const target = resolveSupervisorTarget({
30
+ state: readWorkspaceState$1(root)?.supervisor,
31
+ record: project?.supervisor,
32
+ reservedPort: port
33
+ });
34
+ if (target.status === "unverified") return {
35
+ status: "unverified",
36
+ reason: target.reason ?? "unknown identity"
37
+ };
38
+ if (port && target.status !== "ours" && !(await resolveProjectMetro(port, root)).missing) return {
39
+ status: "external",
40
+ reason: `a dev server Stim did not start answers on port ${port}`
41
+ };
42
+ if (target.status !== "ours") return { status: "not-running" };
43
+ const expected = {
44
+ pid: target.pid,
45
+ processToken: target.processToken
46
+ };
47
+ if (!killMetroTree(target.pid, target.processToken) || !await (0, process_identity_exports.waitForProcessExit)(expected, 1e4)) return {
48
+ status: "stuck",
49
+ reason: `supervisor pid ${target.pid} did not exit`
50
+ };
51
+ if (port && !(await resolveProjectMetro(port, root)).missing) return {
52
+ status: "port-occupied",
53
+ reason: `port ${port} is still occupied after stopping Metro`
54
+ };
55
+ clearWorkspaceSupervisor(root, expected);
56
+ clearSupervisor(root, expected);
57
+ return { status: "stopped" };
58
+ }
59
+ async function stopOwnedMetroForReset(root) {
60
+ const stopped = await stopOwnedMetro(root);
61
+ switch (stopped.status) {
62
+ case "unverified": throw supervisorError("STIM_SUPERVISOR_EXITED", `Cannot reset Metro: ${stopped.reason}.`, "Stop it with the tool that started it, then retry. Stim leaves unverified processes alone.");
63
+ case "external": throw supervisorError("STIM_SUPERVISOR_EXITED", "Cannot reset an externally started dev server.", "Stop it with the tool that started it, then retry `stim start --reset-cache`.");
64
+ case "stuck": throw supervisorError("STIM_SUPERVISOR_EXITED", "The owned Metro supervisor could not be stopped for a cache reset.", "Inspect `stim status` and `stim logs`, then retry. No cache reset was recorded.");
65
+ case "port-occupied": throw supervisorError("STIM_SUPERVISOR_EXITED", `Port ${getProject(root)?.metroPort} is still occupied after stopping Metro.`, "Inspect the listener before retrying. Stim does not terminate unrecorded processes.");
66
+ }
67
+ }
68
+ //#endregion
69
+ //#region src/cache/caches.ts
70
+ function xcodeDerivedDataRoot() {
71
+ return join(homedir(), "Library", "Developer", "Xcode", "DerivedData");
72
+ }
73
+ function compilationCache() {
74
+ const dir = join(xcodeDerivedDataRoot(), "CompilationCache.noindex");
75
+ if (!existsSync$1(dir)) return null;
76
+ return {
77
+ name: "Xcode compilation cache",
78
+ dir,
79
+ prune: "atomic",
80
+ note: "index-backed, so it can only be emptied whole"
81
+ };
82
+ }
83
+ function gradleBuildCache() {
84
+ const gradleHome = process.env.GRADLE_USER_HOME || join(homedir(), ".gradle");
85
+ const dir = resolve(gradleHome, "caches", "build-cache-1");
86
+ if (!existsSync$1(dir)) return null;
87
+ return {
88
+ name: "Gradle build cache",
89
+ dir,
90
+ prune: "report-only",
91
+ note: "shared with every Gradle build; report only, never pruned or emptied by Stim"
92
+ };
93
+ }
94
+ function metroFileMaps() {
95
+ const root = tmpdir();
96
+ if (!existsSync$1(root)) return null;
97
+ let names;
98
+ try {
99
+ names = readdirSync$1(root).filter((n) => n.startsWith("metro-file-map-"));
100
+ } catch {
101
+ return null;
102
+ }
103
+ const uid = process.getuid?.();
104
+ const files = [];
105
+ let bytes = 0;
106
+ for (const n of names) try {
107
+ const st = statSync$1(join(root, n));
108
+ if (uid !== void 0 && st.uid !== uid) continue;
109
+ files.push(join(root, n));
110
+ bytes += st.size;
111
+ } catch {}
112
+ if (!files.length) return null;
113
+ return {
114
+ name: "Metro file maps",
115
+ dir: root,
116
+ files,
117
+ bytes,
118
+ prune: "entries",
119
+ note: `${files.length} file(s), one per project root Metro has served`
120
+ };
121
+ }
122
+ function discoverCaches() {
123
+ const gradle = gradleBuildCache();
124
+ const gradleDir = gradle ? canonicalCacheDir(gradle.dir) : null;
125
+ const registered = protectNestedMetroAncestors(suppressLegacyMetroAncestors(registeredCaches().map((c) => Object.assign({}, c, {
126
+ name: c.name ?? c.dir,
127
+ note: c.note ?? "registered",
128
+ prune: c.prune,
129
+ source: "registered"
130
+ }))));
131
+ const detected = [
132
+ compilationCache(),
133
+ gradle,
134
+ metroFileMaps()
135
+ ].filter((c) => Boolean(c)).map((c) => Object.assign({}, c, { source: "detected" }));
136
+ return mergeCacheDescriptors([...registered, ...detected], gradleDir);
137
+ }
138
+ function suppressLegacyMetroAncestors(caches) {
139
+ const current = caches.filter(isCurrentMetroStore);
140
+ return caches.filter((cache) => {
141
+ if (!isLegacyMetroStore(cache)) return true;
142
+ return !current.some((candidate) => isNamedMetroChild(cache.dir, candidate.dir));
143
+ });
144
+ }
145
+ function protectNestedMetroAncestors(caches) {
146
+ const current = caches.filter(isCurrentMetroStore);
147
+ return caches.map((cache) => {
148
+ if (!isCurrentMetroStore(cache)) return cache;
149
+ if (!current.some((candidate) => isNamedMetroChild(cache.dir, candidate.dir))) return cache;
150
+ return Object.assign({}, cache, {
151
+ prune: "report-only",
152
+ note: "named Metro store is also an override parent; report only while its child is registered"
153
+ });
154
+ });
155
+ }
156
+ function isLegacyMetroStore(cache) {
157
+ return isMetroStore(cache) && cache.layout === void 0;
158
+ }
159
+ function isCurrentMetroStore(cache) {
160
+ return isMetroStore(cache) && cache.layout === METRO_NAMED_CACHE_LAYOUT;
161
+ }
162
+ function isMetroStore(cache) {
163
+ return cache.source === "registered" && cache.name === "Metro transform cache" && cache.prune === "entries" && cache.entriesDepth === 2;
164
+ }
165
+ function isNamedMetroChild(parent, candidate) {
166
+ const dir = canonicalCacheDir(parent);
167
+ const child = canonicalCacheDir(candidate);
168
+ return dirname(child) === dir || canonicalCacheDir(dirname(candidate)) === dir;
169
+ }
170
+ function mergeCacheDescriptors(caches, gradleDir) {
171
+ const merged = [];
172
+ for (const cache of caches) {
173
+ const descriptor = protectGradleCache(cache, gradleDir);
174
+ const dir = canonicalCacheDir(descriptor.dir);
175
+ const exact = merged.findIndex((candidate) => canonicalCacheDir(candidate.dir) === dir);
176
+ if (exact !== -1) {
177
+ const current = merged[exact];
178
+ if (pruneStrength(descriptor.prune) > pruneStrength(current.prune)) merged[exact] = Object.assign({}, current, {
179
+ prune: descriptor.prune,
180
+ note: descriptor.note
181
+ });
182
+ continue;
183
+ }
184
+ if (gradleDir && isGradleProtectedDescriptor(descriptor, gradleDir)) {
185
+ if (merged.some((candidate) => {
186
+ const candidateDir = canonicalCacheDir(candidate.dir);
187
+ return isGradleProtectedDescriptor(candidate, gradleDir) && cachePathContains(candidateDir, dir);
188
+ })) continue;
189
+ for (let i = merged.length - 1; i >= 0; i--) {
190
+ const candidate = merged[i];
191
+ const candidateDir = canonicalCacheDir(candidate.dir);
192
+ if (isGradleProtectedDescriptor(candidate, gradleDir) && cachePathContains(dir, candidateDir)) merged.splice(i, 1);
193
+ }
194
+ }
195
+ merged.push(descriptor);
196
+ }
197
+ return merged;
198
+ }
199
+ function protectGradleCache(cache, gradleDir) {
200
+ if (cache.files) return cache;
201
+ if (!gradleDir || !cachePathsOverlap(canonicalCacheDir(cache.dir), gradleDir)) return cache;
202
+ return Object.assign({}, cache, {
203
+ prune: "report-only",
204
+ note: "shared with every Gradle build; report only, never pruned or emptied by Stim"
205
+ });
206
+ }
207
+ function isGradleProtectedDescriptor(cache, gradleDir) {
208
+ return cache.prune === "report-only" && cachePathsOverlap(canonicalCacheDir(cache.dir), gradleDir);
209
+ }
210
+ function pruneStrength(prune) {
211
+ if (prune === "report-only") return 3;
212
+ if (prune === "atomic") return 2;
213
+ return 1;
214
+ }
215
+ function cachePathsOverlap(left, right) {
216
+ return cachePathContains(left, right) || cachePathContains(right, left);
217
+ }
218
+ function cachePathContains(parent, child) {
219
+ const rel = relative(parent, child);
220
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
221
+ }
222
+ function canonicalCacheDir(dir) {
223
+ try {
224
+ return realpathSync$1(dir);
225
+ } catch {
226
+ return resolve(dir);
227
+ }
228
+ }
229
+ function sizeCaches(caches) {
230
+ return caches.map((c) => Object.assign({}, c, { bytes: c.bytes ?? directorySize(c.dir) }));
231
+ }
232
+ function pruneCache(cache, { olderThanDays, now = Date.now() } = {}) {
233
+ const cutoff = now - olderThanDays * 24 * 60 * 60 * 1e3;
234
+ if (cache.prune === "report-only") return {
235
+ removed: 0,
236
+ bytes: 0,
237
+ skipped: "report-only shared cache; Stim never deletes it"
238
+ };
239
+ if (cache.prune === "atomic") return {
240
+ removed: 0,
241
+ bytes: 0,
242
+ skipped: "index-backed; empty it whole or not at all"
243
+ };
244
+ const entries = cache.files ?? entriesAtDepth(cache.dir, cache.entriesDepth ?? 1);
245
+ let removed = 0;
246
+ let bytes = 0;
247
+ let failed = 0;
248
+ for (const entry of entries) {
249
+ let used;
250
+ let size;
251
+ try {
252
+ const st = statSync$1(entry);
253
+ used = Math.max(st.atimeMs, st.mtimeMs);
254
+ size = st.isDirectory() ? directorySize(entry) : st.size;
255
+ } catch {
256
+ continue;
257
+ }
258
+ if (used >= cutoff) continue;
259
+ try {
260
+ rmSync$1(entry, {
261
+ recursive: true,
262
+ force: true
263
+ });
264
+ removed++;
265
+ bytes += size;
266
+ } catch {
267
+ failed++;
268
+ }
269
+ }
270
+ return {
271
+ removed,
272
+ bytes,
273
+ failed,
274
+ skipped: null
275
+ };
276
+ }
277
+ function entriesAtDepth(dir, depth) {
278
+ let level = [dir];
279
+ for (let i = 0; i < depth; i++) {
280
+ const next = [];
281
+ for (const parent of level) {
282
+ let names;
283
+ try {
284
+ names = readdirSync$1(parent);
285
+ } catch {
286
+ continue;
287
+ }
288
+ for (const name of names) next.push(join(parent, name));
289
+ }
290
+ level = next;
291
+ }
292
+ return level;
293
+ }
294
+ //#endregion
295
+ //#region src/commands/gc/caches.ts
296
+ const DAY_MS$2 = 864e5;
297
+ function cacheSweepIsScoped() {
298
+ return Boolean(process.env.STIM_HOME);
299
+ }
300
+ function isInsideConfigDir(dir) {
301
+ const root = canonicalPath(getConfigDir());
302
+ const target = canonicalPath(dir);
303
+ const rel = relative(root, target);
304
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
305
+ }
306
+ function machineGlobalReason(cache) {
307
+ if (!cacheSweepIsScoped()) return null;
308
+ if (cache.source === "registered") return null;
309
+ if (isInsideConfigDir(cache.dir)) return null;
310
+ return `STIM_HOME scopes this config, but ${cache.dir} is outside it and therefore machine-global`;
311
+ }
312
+ const EVERY_CACHE = "all";
313
+ const WORKSPACE_OUTPUTS = "workspaces";
314
+ function includesWorkspaceOutputs(name) {
315
+ const wanted = name?.trim().toLowerCase();
316
+ return !wanted || wanted === EVERY_CACHE || wanted === WORKSPACE_OUTPUTS;
317
+ }
318
+ function selectCaches(caches, name) {
319
+ if (!name) return caches;
320
+ const wanted = name.trim().toLowerCase();
321
+ if (wanted === EVERY_CACHE) return caches;
322
+ if (wanted === WORKSPACE_OUTPUTS) return [];
323
+ return caches.filter((c) => c.name.toLowerCase().includes(wanted) || c.dir.toLowerCase().includes(wanted));
324
+ }
325
+ function planCacheEmptying(caches, all) {
326
+ const annotated = caches.map((c) => Object.assign({}, c, { machineGlobal: machineGlobalReason(c) }));
327
+ if (!all) return annotated;
328
+ return annotated.map((c) => {
329
+ if (c.prune === "report-only") return Object.assign({}, c, {
330
+ willEmpty: false,
331
+ emptySkipped: "report-only shared cache; Stim never deletes it"
332
+ });
333
+ if (c.machineGlobal) return Object.assign({}, c, {
334
+ willEmpty: false,
335
+ emptySkipped: c.machineGlobal
336
+ });
337
+ if (!ownsItsDirectory(c)) return Object.assign({}, c, {
338
+ willEmpty: false,
339
+ emptySkipped: `${c.dir} is not a directory this cache owns`
340
+ });
341
+ return Object.assign({}, c, {
342
+ willEmpty: true,
343
+ emptySkipped: null
344
+ });
345
+ });
346
+ }
347
+ function ownsItsDirectory(cache) {
348
+ if (Array.isArray(cache.files)) return false;
349
+ const dir = canonicalPath(cache.dir);
350
+ if (dirname(dir) === dir) return false;
351
+ return ![
352
+ homedir(),
353
+ tmpdir(),
354
+ getConfigDir()
355
+ ].map(canonicalPath).includes(dir);
356
+ }
357
+ function emptyCache(cache) {
358
+ if (cache.prune === "report-only") return {
359
+ removed: 0,
360
+ bytes: 0,
361
+ skipped: "report-only shared cache; Stim never deletes it"
362
+ };
363
+ if (cache.prune !== "atomic") return pruneCache(cache, {
364
+ olderThanDays: 0,
365
+ now: Date.now() + DAY_MS$2
366
+ });
367
+ let names;
368
+ try {
369
+ names = readdirSync$1(cache.dir);
370
+ } catch (e) {
371
+ return {
372
+ removed: 0,
373
+ bytes: 0,
374
+ skipped: `could not read ${cache.dir}: ${e.message}`
375
+ };
376
+ }
377
+ let removed = 0;
378
+ let failed = 0;
379
+ for (const name of names) try {
380
+ rmSync$1(join(cache.dir, name), {
381
+ recursive: true,
382
+ force: true
383
+ });
384
+ removed++;
385
+ } catch {
386
+ failed++;
387
+ }
388
+ return {
389
+ removed,
390
+ bytes: failed ? 0 : cache.bytes ?? 0,
391
+ failed,
392
+ skipped: null
393
+ };
394
+ }
395
+ function reportFailures(cache, failed) {
396
+ if (!failed) return;
397
+ const message = `${failed} entr${failed === 1 ? "y" : "ies"} in ${cache.dir} could not be removed`;
398
+ console.log(chalk.red(`${cache.name}: ${message}`));
399
+ recordGcResult("cache", "failed", cache.name, {
400
+ id: cache.dir,
401
+ detail: message
402
+ });
403
+ process.exitCode = 1;
404
+ }
405
+ function trimCaches(caches, olderThan) {
406
+ let cacheBytes = 0;
407
+ const trimmed = [];
408
+ for (const c of caches) {
409
+ if (c.machineGlobal) {
410
+ console.log(chalk.yellow(`Left ${c.name} alone: ${c.machineGlobal}`));
411
+ recordGcResult("cache", "kept", c.name, {
412
+ id: c.dir,
413
+ detail: c.machineGlobal
414
+ });
415
+ continue;
416
+ }
417
+ const r = pruneCache(c, { olderThanDays: olderThan });
418
+ if (r.skipped) {
419
+ console.log(chalk.yellow(`Left ${c.name} alone: ${r.skipped}`));
420
+ recordGcResult("cache", "kept", c.name, {
421
+ id: c.dir,
422
+ detail: r.skipped
423
+ });
424
+ } else if (r.removed) {
425
+ cacheBytes += r.bytes;
426
+ trimmed.push(c);
427
+ recordGcResult("cache", "done", c.name, {
428
+ id: c.dir,
429
+ bytes: r.bytes
430
+ });
431
+ console.log(chalk.green(`Trimmed ${c.name}: ${r.removed} entr${r.removed === 1 ? "y" : "ies"} (${formatBytes(r.bytes)})`));
432
+ } else if (!r.failed) console.log(chalk.dim(`${c.name}: nothing older than ${olderThan}d`));
433
+ reportFailures(c, r.failed);
434
+ }
435
+ if (cacheBytes) console.log(chalk.dim(`Trimmed ${formatBytes(cacheBytes)} of shared cache. The next build that wanted those entries pays to rebuild them.`));
436
+ return trimmed;
437
+ }
438
+ function emptyCaches(caches) {
439
+ let cacheBytes = 0;
440
+ for (const c of caches) {
441
+ if (!c.willEmpty) {
442
+ console.log(chalk.yellow(`Left ${c.name} alone: ${c.emptySkipped}`));
443
+ recordGcResult("cache", "kept", c.name, {
444
+ id: c.dir,
445
+ detail: c.emptySkipped ?? null
446
+ });
447
+ continue;
448
+ }
449
+ const r = emptyCache(c);
450
+ if (r.skipped) {
451
+ console.log(chalk.yellow(`Left ${c.name} alone: ${r.skipped}`));
452
+ recordGcResult("cache", "kept", c.name, {
453
+ id: c.dir,
454
+ detail: r.skipped
455
+ });
456
+ } else if (r.removed) {
457
+ cacheBytes += r.bytes;
458
+ recordGcResult("cache", "done", c.name, {
459
+ id: c.dir,
460
+ bytes: r.bytes
461
+ });
462
+ console.log(chalk.green(`Emptied ${c.name}: ${r.removed} entr${r.removed === 1 ? "y" : "ies"} (${formatBytes(r.bytes)})`));
463
+ } else if (!r.failed) console.log(chalk.dim(`${c.name}: already empty`));
464
+ reportFailures(c, r.failed);
465
+ }
466
+ if (cacheBytes) console.log(chalk.dim(`Emptied ${formatBytes(cacheBytes)} of shared cache. Every build that wanted any of it now pays to rebuild it.`));
467
+ }
468
+ //#endregion
469
+ //#region src/commands/gc/devices.ts
470
+ const DEVICE_LIST_TIMEOUT_MS$1 = 3e4;
471
+ const AVD_SIZE_TIMEOUT_MS = 5e3;
472
+ const DAY_MS$1 = 864e5;
473
+ function describeKept(ref) {
474
+ return ref.mounted ? `referenced by ${ref.path}` : `referenced by ${ref.path} (volume not mounted; kept just in case)`;
475
+ }
476
+ function findOrphanedDevices({ sims = [], avds = [], config, isMounted, deadProjects = [] }) {
477
+ const dead = new Set(deadProjects);
478
+ const referenced = /* @__PURE__ */ new Map();
479
+ for (const [path, proj] of Object.entries(config?.projects || {})) {
480
+ if (dead.has(path)) continue;
481
+ const mounted = isMounted ? isMounted(path) : true;
482
+ for (const { platforms } of projectDeviceSlots(proj)) {
483
+ const ios = platforms.ios;
484
+ if (ios?.deviceUdid) referenced.set(ios.deviceUdid, {
485
+ path,
486
+ mounted
487
+ });
488
+ const android = platforms.android;
489
+ if (android?.avdName) referenced.set(android.avdName, {
490
+ path,
491
+ mounted
492
+ });
493
+ }
494
+ }
495
+ for (const sim of readParked("ios", { config })) referenced.set(sim.udid, {
496
+ path: "the simulator pool",
497
+ mounted: true
498
+ });
499
+ for (const avd of readParked("android", { config })) referenced.set(avd.name, {
500
+ path: "the emulator pool",
501
+ mounted: true
502
+ });
503
+ const orphaned = [];
504
+ const kept = [];
505
+ const unverified = [];
506
+ for (const sim of sims) {
507
+ if (!sim?.name?.startsWith("stim-")) continue;
508
+ const ref = referenced.get(sim.udid);
509
+ const device = {
510
+ kind: "ios",
511
+ id: sim.udid,
512
+ name: sim.name
513
+ };
514
+ if (!ref && !isStimCreated(device)) unverified.push(device);
515
+ else if (!ref) orphaned.push({
516
+ ...device,
517
+ ...simBytes(sim)
518
+ });
519
+ else kept.push({
520
+ kind: "ios",
521
+ id: sim.udid,
522
+ name: sim.name,
523
+ reason: describeKept(ref)
524
+ });
525
+ }
526
+ for (const avdName of avds) {
527
+ if (!avdName?.startsWith("stim-")) continue;
528
+ const ref = referenced.get(avdName);
529
+ const device = {
530
+ kind: "android",
531
+ id: avdName,
532
+ name: avdName
533
+ };
534
+ if (!ref && !isStimCreated(device)) unverified.push(device);
535
+ else if (!ref) orphaned.push(device);
536
+ else kept.push({
537
+ kind: "android",
538
+ id: avdName,
539
+ name: avdName,
540
+ reason: describeKept(ref)
541
+ });
542
+ }
543
+ return {
544
+ orphaned,
545
+ kept,
546
+ unverified
547
+ };
548
+ }
549
+ function simBytes(sim) {
550
+ return typeof sim.dataPathSize === "number" ? { bytes: sim.dataPathSize } : {};
551
+ }
552
+ function isStimCreated(device) {
553
+ return device.kind === "ios" ? isStimOwnedSim({
554
+ udid: device.id,
555
+ name: device.name
556
+ }) : isStimOwnedAvd(device.id);
557
+ }
558
+ function unverifiedDeviceCommand(device) {
559
+ if (device.kind === "ios") return `xcrun simctl delete ${device.id}`;
560
+ return device.directory ? `rm -rf ${JSON.stringify(device.directory)}` : `avdmanager delete avd -n ${JSON.stringify(device.id)}`;
561
+ }
562
+ function findStaleProjectDevices({ config, sims = [], avds = [], olderThanDays, now = Date.now(), lastTouched, deadProjects = [] }) {
563
+ if (!Number.isFinite(olderThanDays) || typeof lastTouched !== "function") return [];
564
+ const cutoff = now - olderThanDays * DAY_MS$1;
565
+ const dead = new Set(deadProjects);
566
+ const liveSims = new Map(sims.filter((s) => s?.name?.startsWith("stim-")).map((s) => [s.udid, s]));
567
+ const liveAvds = new Set(avds.filter((a) => typeof a === "string" && a.startsWith("stim-")));
568
+ const stale = [];
569
+ for (const [path, proj] of Object.entries(config?.projects || {})) {
570
+ if (dead.has(path) || !isAbsolute(path)) continue;
571
+ const touched = lastTouched(path);
572
+ if (!Number.isFinite(touched) || touched >= cutoff) continue;
573
+ const idleDays = Math.floor((now - touched) / DAY_MS$1);
574
+ for (const { slot, platforms } of projectDeviceSlots(proj)) {
575
+ const ios = platforms.ios;
576
+ const sim = ios?.owned && ios.deviceUdid ? liveSims.get(ios.deviceUdid) : void 0;
577
+ if (sim) stale.push({
578
+ kind: "ios",
579
+ id: sim.udid,
580
+ name: sim.name,
581
+ project: path,
582
+ ...slot === "default" ? {} : { slot },
583
+ idleDays,
584
+ ...simBytes(sim)
585
+ });
586
+ const android = platforms.android;
587
+ if (android?.owned && android.avdName && liveAvds.has(android.avdName)) stale.push({
588
+ kind: "android",
589
+ id: android.avdName,
590
+ name: android.avdName,
591
+ project: path,
592
+ idleDays,
593
+ ...slot === "default" ? {} : { slot }
594
+ });
595
+ }
596
+ }
597
+ return stale;
598
+ }
599
+ function findStaleDeviceRecords({ config, sims = [], avds = [], deadProjects = [], simsChecked = true, avdsChecked = true }) {
600
+ const dead = new Set(deadProjects);
601
+ const liveSims = new Set(sims.map((s) => s?.udid).filter(Boolean));
602
+ const liveAvds = new Set(avds.filter((a) => typeof a === "string"));
603
+ const stale = [];
604
+ for (const [path, proj] of Object.entries(config?.projects || {})) {
605
+ if (dead.has(path)) continue;
606
+ for (const { slot, platforms } of projectDeviceSlots(proj)) {
607
+ const ios = platforms.ios;
608
+ if (simsChecked && ios?.deviceUdid && !liveSims.has(ios.deviceUdid)) stale.push({
609
+ kind: "ios",
610
+ id: ios.deviceUdid,
611
+ project: path,
612
+ ...slot === "default" ? {} : { slot },
613
+ owned: Boolean(ios.owned)
614
+ });
615
+ const android = platforms.android;
616
+ if (avdsChecked && android?.avdName && !liveAvds.has(android.avdName)) stale.push({
617
+ kind: "android",
618
+ id: android.avdName,
619
+ project: path,
620
+ ...slot === "default" ? {} : { slot },
621
+ owned: Boolean(android.owned)
622
+ });
623
+ }
624
+ }
625
+ return stale;
626
+ }
627
+ function describeUnverifiableDevices(simNames = [], avdNames = [], { reason = "no Stim config found" } = {}) {
628
+ const ours = [...simNames, ...avdNames].filter((n) => typeof n === "string" && n.startsWith("stim-"));
629
+ if (ours.length === 0) return [`${reason}; device sweep skipped`];
630
+ return [`${reason}, so ${ours.length} stim-created device(s) cannot be verified as orphaned: ${ours.join(", ")}`, "they were NOT touched. If they are stale, delete them with `xcrun simctl delete <udid>` or `avdmanager delete avd -n <name>`"];
631
+ }
632
+ function withAndroidAvdSizes(devices, { avdDirectory = ownedAvdDirectory, size = directorySize } = {}) {
633
+ return devices.map((device) => {
634
+ if (device.kind !== "android") return device;
635
+ const dir = device.orphanedDirectory?.directory ?? avdDirectory(device.id);
636
+ if (!dir) return device;
637
+ try {
638
+ const bytes = size(dir, { timeoutMs: AVD_SIZE_TIMEOUT_MS });
639
+ return bytes > 0 ? {
640
+ ...device,
641
+ bytes
642
+ } : device;
643
+ } catch {
644
+ return device;
645
+ }
646
+ });
647
+ }
648
+ function describeParkedSims(records, sims, deviceTypes, { simsChecked = true } = {}) {
649
+ const listed = new Map(sims.map((sim) => [sim.udid, sim]));
650
+ return records.map((record) => {
651
+ const sim = listed.get(record.udid);
652
+ return {
653
+ udid: record.udid,
654
+ name: sim?.name ?? record.name,
655
+ model: deviceTypes.find((d) => d.identifier === record.deviceTypeIdentifier)?.name ?? null,
656
+ runtime: sim ? parseRuntimeVersion(sim.runtime) : parseRuntimeVersion(record.runtimeIdentifier),
657
+ parkedAt: record.parkedAt,
658
+ bytes: typeof sim?.dataPathSize === "number" ? sim.dataPathSize : null,
659
+ listed: simsChecked ? Boolean(sim) : null
660
+ };
661
+ });
662
+ }
663
+ function parkedAtLeast(records, { olderThanDays = null, now = Date.now() }) {
664
+ if (olderThanDays === null) return [...records];
665
+ const cutoff = now - olderThanDays * DAY_MS$1;
666
+ return records.filter((record) => Date.parse(record.parkedAt) <= cutoff);
667
+ }
668
+ function collectParkedSims(deps, age = {}) {
669
+ const records = parkedAtLeast(readParked("ios"), age);
670
+ if (records.length === 0) return [];
671
+ let sims;
672
+ let deviceTypes = [];
673
+ try {
674
+ sims = (deps.listAllIosSims ?? listAllIosSims)({
675
+ timeoutMs: DEVICE_LIST_TIMEOUT_MS$1,
676
+ includeUnavailable: true
677
+ });
678
+ } catch {
679
+ return describeParkedSims(records, [], [], { simsChecked: false });
680
+ }
681
+ try {
682
+ deviceTypes = (deps.listIosDeviceTypes ?? listIosDeviceTypes)();
683
+ } catch {}
684
+ return describeParkedSims(records, sims, deviceTypes);
685
+ }
686
+ function poolConfigurationDeviceProfile(configuration) {
687
+ try {
688
+ const entries = JSON.parse(configuration);
689
+ const entry = Array.isArray(entries) ? entries.find((pair) => Array.isArray(pair) && pair[0] === "hw.device.name") : void 0;
690
+ return typeof entry?.[1] === "string" ? entry[1] : null;
691
+ } catch {
692
+ return null;
693
+ }
694
+ }
695
+ function collectParkedAvds(deps, age = {}) {
696
+ const records = parkedAtLeast(readParked("android"), age);
697
+ if (!records.length) return [];
698
+ let avds = null;
699
+ try {
700
+ avds = (deps.listAvds ?? listAvds)();
701
+ } catch {}
702
+ return records.map((record) => {
703
+ let bytes = null;
704
+ try {
705
+ const directory = (deps.avdDirectory ?? ownedAvdDirectory)(record.name);
706
+ if (directory) bytes = (deps.directorySize ?? directorySize)(directory, { timeoutMs: AVD_SIZE_TIMEOUT_MS });
707
+ } catch {}
708
+ return {
709
+ name: record.name,
710
+ systemImage: record.systemImage,
711
+ deviceProfile: poolConfigurationDeviceProfile(record.configuration),
712
+ parkedAt: record.parkedAt,
713
+ bytes,
714
+ listed: avds === null ? null : avds.includes(record.name)
715
+ };
716
+ });
717
+ }
718
+ function deleteParkedAvds(avds) {
719
+ let failures = 0;
720
+ for (const avd of avds) {
721
+ if (avd.listed === null) {
722
+ failures++;
723
+ console.log(chalk.red(`Could not verify parked android emulator ${avd.name}; its pool record was kept.`));
724
+ recordGcResult("parkedDevice", "failed", avd.name, {
725
+ id: avd.name,
726
+ detail: "Stim could not list emulators to verify it; its pool record was kept. Retry stim gc --delete."
727
+ });
728
+ continue;
729
+ }
730
+ const result = teardownParkedAvd(avd.name);
731
+ if (result.status === "failed") {
732
+ failures++;
733
+ console.log(chalk.red(`Failed to delete parked android emulator ${avd.name}: ${result.reason}`));
734
+ recordGcResult("parkedDevice", "failed", avd.name, {
735
+ id: avd.name,
736
+ detail: result.reason
737
+ });
738
+ } else if (result.status === "torn-down") {
739
+ console.log(chalk.green(`Deleted parked android emulator ${avd.name}`));
740
+ recordGcResult("parkedDevice", "done", avd.name, {
741
+ id: avd.name,
742
+ bytes: avd.bytes
743
+ });
744
+ } else console.log(chalk.dim(`Skipped ${avd.name}; it is no longer parked.`));
745
+ }
746
+ return failures;
747
+ }
748
+ function deleteParkedSims(parkedSims, deps = {}) {
749
+ let failures = 0;
750
+ let emptied = 0;
751
+ for (const sim of parkedSims) {
752
+ if (sim.listed === null) {
753
+ failures++;
754
+ console.log(chalk.red(`Could not verify parked ios sim ${sim.name} (${sim.udid}); its pool record was kept.`));
755
+ recordGcResult("parkedDevice", "failed", sim.name, {
756
+ id: sim.udid,
757
+ detail: "Stim could not list simulators to verify it; its pool record was kept. Retry stim gc --delete."
758
+ });
759
+ continue;
760
+ }
761
+ try {
762
+ const teardown = sim.listed ? teardownParkedIosSim(sim.udid, {
763
+ label: sim.name,
764
+ deleteSim: deps.deleteParkedIosSim
765
+ }) : null;
766
+ if (!(sim.listed ? teardown?.status === "torn-down" : dropParked("ios", sim.udid))) {
767
+ if (teardown?.status === "failed") {
768
+ failures++;
769
+ console.log(chalk.red(`Failed to delete parked ios sim ${sim.name}: ${teardown.reason}`));
770
+ recordGcResult("parkedDevice", "failed", sim.name, {
771
+ id: sim.udid,
772
+ detail: teardown.reason
773
+ });
774
+ } else console.log(chalk.dim(`Skipped ${sim.name} (${sim.udid}); it is no longer parked.`));
775
+ continue;
776
+ }
777
+ emptied++;
778
+ recordGcResult("parkedDevice", "done", sim.name, {
779
+ id: sim.udid,
780
+ bytes: sim.listed ? sim.bytes : null
781
+ });
782
+ console.log(chalk.green(sim.listed ? `Deleted parked ios sim ${sim.name} (${sim.udid})` : `Dropped the parked record for ${sim.name} (${sim.udid}); it is not on this machine.`));
783
+ } catch (err) {
784
+ failures++;
785
+ console.log(chalk.red(`Failed to delete parked ios sim ${sim.name}: ${err?.message || err}`));
786
+ recordGcResult("parkedDevice", "failed", sim.name, {
787
+ id: sim.udid,
788
+ detail: err?.message || String(err)
789
+ });
790
+ }
791
+ }
792
+ if (emptied) console.log(chalk.dim(` emptied ${plural(emptied, "parked simulator")} from the pool`));
793
+ return failures;
794
+ }
795
+ function deviceSweepIsScoped() {
796
+ return Boolean(process.env.STIM_HOME);
797
+ }
798
+ function collectDeviceLeases(now) {
799
+ const expired = [];
800
+ const kept = [];
801
+ for (const entry of listLeaseFiles()) {
802
+ const lease = entry.lease;
803
+ if (!lease) {
804
+ kept.push({
805
+ name: entry.name,
806
+ path: entry.path,
807
+ reason: "it does not parse as a lease, so no run may take that device"
808
+ });
809
+ continue;
810
+ }
811
+ if (leaseIsExpired(lease, now)) {
812
+ expired.push(entry);
813
+ continue;
814
+ }
815
+ if (!existsSync$1(lease.holder)) kept.push({
816
+ name: entry.name,
817
+ path: entry.path,
818
+ reason: `${lease.holder} is not on this machine, but the lease runs until ${lease.expiresAt}`
819
+ });
820
+ }
821
+ return {
822
+ expired,
823
+ kept
824
+ };
825
+ }
826
+ function deleteProjectDevices(orphanedDevices, staleDevices, staleDeviceRecords) {
827
+ let deleteFailures = 0;
828
+ function reap(d, onlyIfMissing = false) {
829
+ const r = d.kind === "ios" ? teardownOwnedIosSim(d.id, {
830
+ del: true,
831
+ label: d.name
832
+ }) : teardownOwnedAvd(d.name, {
833
+ del: true,
834
+ onlyIfMissing,
835
+ ..."project" in d ? { owner: {
836
+ projectPath: d.project,
837
+ slot: d.slot
838
+ } } : {},
839
+ ..."project" in d ? { onRemoved: () => {
840
+ if (clearDevice(d.project, d.kind, d.slot, d.id)) console.log(chalk.dim(` cleared the ${d.kind} record for ${d.project}`));
841
+ } } : {},
842
+ ..."orphanedDirectory" in d ? { orphanedDirectory: d.orphanedDirectory } : {}
843
+ });
844
+ const what = d.kind === "ios" ? `ios sim ${d.name} (${d.id})` : `android avd ${d.name}`;
845
+ const result = {
846
+ id: d.id,
847
+ bytes: d.bytes ?? null
848
+ };
849
+ if (r.status === "torn-down") {
850
+ console.log(chalk.green(`Deleted ${what}`));
851
+ recordGcResult("device", "done", d.name, result);
852
+ } else if (r.status === "missing") console.log(chalk.dim(`${what} is already gone; nothing to delete.`));
853
+ else if (r.status === "skipped") {
854
+ console.log(chalk.yellow(`Skipped ${what}: ${r.reason} -- left for a later gc`));
855
+ recordGcResult("device", "kept", d.name, {
856
+ ...result,
857
+ detail: `${r.reason}; left for a later gc`
858
+ });
859
+ } else {
860
+ deleteFailures++;
861
+ console.log(chalk.red(`Failed to delete ${d.kind} device ${d.name}: ${r.reason}`));
862
+ recordGcResult("device", "failed", d.name, {
863
+ ...result,
864
+ detail: r.reason
865
+ });
866
+ }
867
+ return r.status;
868
+ }
869
+ for (const d of orphanedDevices) reap(d);
870
+ for (const d of staleDevices) {
871
+ const status = reap(d);
872
+ if (d.kind === "ios" && (status === "torn-down" || status === "missing")) {
873
+ clearDevice(d.project, d.kind, d.slot, d.id);
874
+ console.log(chalk.dim(` cleared the ${d.kind} record for ${d.project}`));
875
+ }
876
+ }
877
+ for (const r of staleDeviceRecords) if (r.kind === "android" && r.owned) reap({
878
+ ...r,
879
+ name: r.id,
880
+ idleDays: 0
881
+ }, true);
882
+ else if (withConfigLock$1(() => {
883
+ if (r.kind === "android" && deviceSlotPlatforms(getProject(r.project), r.slot)?.android?.owned) return false;
884
+ return clearDevice(r.project, r.kind, r.slot, r.id);
885
+ })) {
886
+ console.log(chalk.green(`Cleared the ${r.kind} record for ${r.project} (${r.id} is not on this machine)`));
887
+ recordGcResult("deviceRecord", "done", r.project, { id: r.id });
888
+ }
889
+ return deleteFailures;
890
+ }
891
+ //#endregion
892
+ //#region src/commands/gc/workspaces.ts
893
+ function workspacesRoot() {
894
+ return join(getConfigDir(), "workspaces");
895
+ }
896
+ function isInsideWorkspaces(dir) {
897
+ const rel = relative(canonicalPath(workspacesRoot()), canonicalPath(dir));
898
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
899
+ }
900
+ function readRecordedRoot(dir, name) {
901
+ let parsed;
902
+ try {
903
+ parsed = readJsonFile(join(dir, "workspace.json"));
904
+ } catch (error) {
905
+ return {
906
+ projectRoot: null,
907
+ problem: error?.code === "ENOENT" ? "it has no workspace.json" : "its workspace.json does not parse"
908
+ };
909
+ }
910
+ const projectRoot = isJsonObject(parsed) ? parsed.projectRoot : void 0;
911
+ if (typeof projectRoot !== "string" || !isAbsolute(projectRoot)) return {
912
+ projectRoot: null,
913
+ problem: "its workspace.json records no absolute project root"
914
+ };
915
+ if (workspaceName$1(projectRoot) !== name) return {
916
+ projectRoot: null,
917
+ problem: `its workspace.json records ${projectRoot}, whose workspace directory has another name`
918
+ };
919
+ return {
920
+ projectRoot,
921
+ problem: null
922
+ };
923
+ }
924
+ function listWorkspaceDirs() {
925
+ const root = workspacesRoot();
926
+ let names;
927
+ try {
928
+ names = readdirSync$1(root).toSorted();
929
+ } catch {
930
+ return [];
931
+ }
932
+ const entries = [];
933
+ for (const name of names) {
934
+ const dir = join(root, name);
935
+ try {
936
+ if (!statSync$1(dir).isDirectory()) continue;
937
+ } catch {
938
+ continue;
939
+ }
940
+ entries.push({
941
+ dir,
942
+ ...readRecordedRoot(dir, name)
943
+ });
944
+ }
945
+ return entries;
946
+ }
947
+ function classifyWorkspaceDirs(entries, { registryKeys, exists, isMounted, inUse }) {
948
+ const orphaned = [];
949
+ const skipped = [];
950
+ for (const { dir, projectRoot, problem } of entries) {
951
+ if (projectRoot === null) {
952
+ skipped.push({
953
+ dir,
954
+ reason: `workspace directory not resolved: ${problem ?? "unknown project root"}`
955
+ });
956
+ continue;
957
+ }
958
+ const present = exists(projectRoot);
959
+ if (present === null) {
960
+ skipped.push({
961
+ dir,
962
+ reason: `cannot tell whether its project root ${projectRoot} still exists`
963
+ });
964
+ continue;
965
+ }
966
+ if (present) continue;
967
+ if (registryKeys.some((key) => isPathPrefix(projectRoot, key))) continue;
968
+ if (!isMounted(projectRoot)) {
969
+ skipped.push({
970
+ dir,
971
+ reason: `the volume of its project root ${projectRoot} is not mounted`
972
+ });
973
+ continue;
974
+ }
975
+ const reasons = inUse(projectRoot);
976
+ if (reasons.length) {
977
+ skipped.push({
978
+ dir,
979
+ reason: `its project root ${projectRoot} is gone, but ${reasons.join("; ")}`
980
+ });
981
+ continue;
982
+ }
983
+ orphaned.push({
984
+ dir,
985
+ projectRoot
986
+ });
987
+ }
988
+ return {
989
+ orphaned,
990
+ skipped
991
+ };
992
+ }
993
+ function rootPresence(path) {
994
+ try {
995
+ lstatSync$1(path);
996
+ return true;
997
+ } catch (error) {
998
+ const code = error?.code;
999
+ return code === "ENOENT" || code === "ENOTDIR" ? false : null;
1000
+ }
1001
+ }
1002
+ function collectOrphanedWorkspaces(registryKeys, mountedVolumes) {
1003
+ const classified = classifyWorkspaceDirs(listWorkspaceDirs(), {
1004
+ registryKeys,
1005
+ exists: rootPresence,
1006
+ isMounted: (path) => isOnMountedVolume(path, mountedVolumes),
1007
+ inUse: (root) => workspaceInUse(root)
1008
+ });
1009
+ return {
1010
+ orphaned: classified.orphaned.map((entry) => Object.assign({}, entry, { bytes: sizeOf(entry.dir) })),
1011
+ skipped: classified.skipped
1012
+ };
1013
+ }
1014
+ const REBUILD_COST = "The next build of an unchanged app installs from the shared build cache; after a native change the compilation cache speeds the rebuild, but on React Native 0.86 Swift does not use it (explicit modules are off), so that build recompiles Swift.";
1015
+ const WORKSPACE_OUTPUT_DIRS = [
1016
+ "derived-data",
1017
+ "gradle-build",
1018
+ "android-cas",
1019
+ "cache-provider"
1020
+ ];
1021
+ const DAY_MS = 864e5;
1022
+ function planWorkspaceOutputs(entries, { olderThan, now }) {
1023
+ return entries.map(({ dir, projectRoot, problem, bytes, lastUsed, inUse }) => {
1024
+ const idleDays = Number.isFinite(lastUsed) ? Math.max(0, Math.floor((now - lastUsed) / DAY_MS)) : null;
1025
+ const [keptCode, keptReason] = projectRoot === null ? ["unresolved", `workspace directory not resolved: ${problem ?? "unknown project root"}`] : inUse.length ? ["in-use", `in use: ${inUse.join("; ")}`] : olderThan === null ? [null, null] : idleDays === null ? ["last-use-unknown", "its last use is unknown"] : idleDays < olderThan ? ["recently-used", `used ${idleDays}d ago, within --older-than ${olderThan}`] : [null, null];
1026
+ return {
1027
+ dir,
1028
+ projectRoot,
1029
+ bytes,
1030
+ idleDays,
1031
+ willClear: keptCode === null,
1032
+ keptCode,
1033
+ keptReason
1034
+ };
1035
+ });
1036
+ }
1037
+ const SIZE_TIMEOUT_MS = 6e4;
1038
+ function sizeOf(dir) {
1039
+ return measuredDirectorySize(dir, { timeoutMs: SIZE_TIMEOUT_MS });
1040
+ }
1041
+ function outputPaths(dir) {
1042
+ return WORKSPACE_OUTPUT_DIRS.map((name) => join(dir, name)).filter((path) => existsSync$1(path));
1043
+ }
1044
+ function outputBytes(paths) {
1045
+ let total = 0;
1046
+ for (const path of paths) {
1047
+ const size = sizeOf(path);
1048
+ if (size === null) return null;
1049
+ total += size;
1050
+ }
1051
+ return total;
1052
+ }
1053
+ function collectWorkspaceOutputs({ olderThan, now, exclude = [], measure = true }) {
1054
+ const mountedVolumes = listMountedVolumes();
1055
+ const entries = listWorkspaceDirs().filter((entry) => !exclude.includes(entry.dir)).filter((entry) => entry.projectRoot === null || isOnMountedVolume(entry.projectRoot, mountedVolumes)).map((entry) => Object.assign({}, entry, { paths: outputPaths(entry.dir) })).filter((entry) => entry.paths.length > 0).map(({ paths, ...entry }) => Object.assign({}, entry, { bytes: measure ? outputBytes(paths) : null })).map((entry) => Object.assign({}, entry, {
1056
+ lastUsed: entry.projectRoot === null ? NaN : workspaceLastUsed(entry.projectRoot),
1057
+ inUse: entry.projectRoot === null ? [] : workspaceInUse(entry.projectRoot)
1058
+ }));
1059
+ return {
1060
+ root: workspacesRoot(),
1061
+ workspaces: planWorkspaceOutputs(entries, {
1062
+ olderThan,
1063
+ now
1064
+ })
1065
+ };
1066
+ }
1067
+ async function clearWorkspaceOutputs(report, { olderThan, now = Date.now() }) {
1068
+ let failures = 0;
1069
+ let cleared = 0;
1070
+ const clearedRoots = [];
1071
+ for (const entry of report.workspaces) {
1072
+ const root = entry.projectRoot;
1073
+ if (!entry.willClear || root === null) {
1074
+ if (entry.keptReason) {
1075
+ console.log(chalk.dim(`Kept the build outputs of ${root ?? entry.dir}: ${entry.keptReason}`));
1076
+ recordGcResult("workspaceOutputs", "kept", root ?? entry.dir, {
1077
+ bytes: entry.bytes,
1078
+ detail: entry.keptReason
1079
+ });
1080
+ }
1081
+ continue;
1082
+ }
1083
+ let run;
1084
+ try {
1085
+ run = await withIdleWorkspace(root, () => {
1086
+ const [again] = planWorkspaceOutputs([{
1087
+ ...entry,
1088
+ problem: null,
1089
+ lastUsed: workspaceLastUsed(root),
1090
+ inUse: []
1091
+ }], {
1092
+ olderThan,
1093
+ now
1094
+ });
1095
+ if (again?.keptReason) return again.keptReason;
1096
+ for (const name of WORKSPACE_OUTPUT_DIRS) rmSync$1(join(entry.dir, name), {
1097
+ recursive: true,
1098
+ force: true
1099
+ });
1100
+ return null;
1101
+ }, { purpose: "gc" });
1102
+ } catch (error) {
1103
+ failures++;
1104
+ console.log(chalk.red(`Could not clear the build outputs of ${root}: ${error?.message || String(error)}`));
1105
+ recordGcResult("workspaceOutputs", "failed", root, {
1106
+ bytes: entry.bytes,
1107
+ detail: error?.message || String(error)
1108
+ });
1109
+ continue;
1110
+ }
1111
+ const kept = run.ran ? run.value : `in use: ${run.reasons.join("; ")}`;
1112
+ if (kept) {
1113
+ console.log(chalk.yellow(`Kept the build outputs of ${root}: ${kept}`));
1114
+ recordGcResult("workspaceOutputs", "kept", root, {
1115
+ bytes: entry.bytes,
1116
+ detail: kept
1117
+ });
1118
+ continue;
1119
+ }
1120
+ cleared += entry.bytes ?? 0;
1121
+ clearedRoots.push(root);
1122
+ console.log(chalk.green(`Cleared the build outputs of ${root} (${sizeText(entry.bytes)})`));
1123
+ recordGcResult("workspaceOutputs", "done", root, { bytes: entry.bytes });
1124
+ }
1125
+ if (cleared) console.log(chalk.dim(`Cleared ${formatBytes(cleared)} of workspace build outputs. ${REBUILD_COST}`));
1126
+ return {
1127
+ failures,
1128
+ cleared: clearedRoots
1129
+ };
1130
+ }
1131
+ function stillOrphaned({ dir, projectRoot }) {
1132
+ if (readRecordedRoot(dir, basename(dir)).projectRoot !== projectRoot || rootPresence(projectRoot) !== false || !isOnMountedVolume(projectRoot)) return false;
1133
+ return !Object.keys(loadConfig$1()?.projects ?? {}).some((key) => isPathPrefix(projectRoot, key));
1134
+ }
1135
+ async function deleteOrphanedWorkspaces(orphaned) {
1136
+ let failures = 0;
1137
+ for (const entry of orphaned) {
1138
+ if (!existsSync$1(entry.dir)) continue;
1139
+ let run;
1140
+ try {
1141
+ run = await withIdleWorkspace(entry.projectRoot, async () => {
1142
+ if (!stillOrphaned(entry)) return false;
1143
+ const browser = await teardownOwnedBrowser(entry.projectRoot, { deleteProfile: true });
1144
+ if (browser.status === "failed" || browser.status === "skipped") throw new Error(`its owned Chrome profile was kept: ${browser.reason}`);
1145
+ emptyWorkspaceDir(entry.dir);
1146
+ return true;
1147
+ }, { purpose: "gc" });
1148
+ } catch (error) {
1149
+ failures++;
1150
+ console.log(chalk.red(`Could not remove ${entry.dir}: ${error?.message || error}`));
1151
+ recordGcResult("workspaceDirectory", "failed", entry.dir, {
1152
+ bytes: entry.bytes ?? null,
1153
+ detail: error?.message || String(error)
1154
+ });
1155
+ continue;
1156
+ }
1157
+ if (!run.ran) {
1158
+ console.log(chalk.yellow(`Kept ${entry.dir}: ${run.reasons.join("; ")}`));
1159
+ recordGcResult("workspaceDirectory", "kept", entry.dir, {
1160
+ bytes: entry.bytes ?? null,
1161
+ detail: run.reasons.join("; ")
1162
+ });
1163
+ continue;
1164
+ }
1165
+ if (!run.value) {
1166
+ console.log(chalk.yellow(`Kept ${entry.dir}: it can no longer be confirmed orphaned.`));
1167
+ recordGcResult("workspaceDirectory", "kept", entry.dir, {
1168
+ bytes: entry.bytes ?? null,
1169
+ detail: "it can no longer be confirmed orphaned"
1170
+ });
1171
+ continue;
1172
+ }
1173
+ const size = entry.bytes === void 0 ? "" : ` (${sizeText(entry.bytes)})`;
1174
+ console.log(chalk.green(`Removed the orphaned workspace directory ${entry.dir}${size}`));
1175
+ recordGcResult("workspaceDirectory", "done", entry.dir, { bytes: entry.bytes ?? null });
1176
+ }
1177
+ return failures;
1178
+ }
1179
+ function sizeText(bytes) {
1180
+ return bytes === null ? "size unknown" : formatBytes(bytes);
1181
+ }
1182
+ //#endregion
1183
+ //#region src/budget.ts
1184
+ const MB = 1048576;
1185
+ const GB_IN_MB = 1024;
1186
+ const COMMITTED_MEMORY_SHARE = .6;
1187
+ const STALE_CACHE_DAYS = 14;
1188
+ const DEVICE_LIST_TIMEOUT_MS = 1e4;
1189
+ const USAGE_TIMEOUT_MS = 1e4;
1190
+ const BUDGET_SETTING_REMEDY = "Fix the value with `stim settings set <key> <value>`, or unset the environment variable. Run `stim guide settings` for the budget keys.";
1191
+ function valueAt(source, key) {
1192
+ let node = source;
1193
+ for (const part of key.split(".")) {
1194
+ if (node === null || typeof node !== "object" || Array.isArray(node)) return void 0;
1195
+ node = node[part];
1196
+ }
1197
+ return node;
1198
+ }
1199
+ function machineNumber(key, config, env) {
1200
+ const setting = settingDefinition(key);
1201
+ const fromEnv = setting.env ? env[setting.env] : void 0;
1202
+ if (fromEnv !== void 0 && fromEnv !== "") {
1203
+ const value = fromEnv.trim() === "" ? NaN : Number(fromEnv);
1204
+ const problem = settingValueError(setting, value);
1205
+ return problem ? {
1206
+ value: void 0,
1207
+ error: `Invalid ${setting.env} value ${JSON.stringify(fromEnv)}. Expected ${problem}.`
1208
+ } : {
1209
+ value,
1210
+ error: null
1211
+ };
1212
+ }
1213
+ if (env.STIM_HOME && setting.scopedHomeValue !== void 0) return {
1214
+ value: setting.scopedHomeValue,
1215
+ error: null
1216
+ };
1217
+ const raw = valueAt(config, key);
1218
+ if (raw === void 0) return {
1219
+ value: typeof setting.default === "number" ? setting.default : void 0,
1220
+ error: null
1221
+ };
1222
+ const problem = settingValueError(setting, raw);
1223
+ return problem ? {
1224
+ value: void 0,
1225
+ error: `Invalid ${key} value ${JSON.stringify(raw)}. Expected ${problem}.`
1226
+ } : {
1227
+ value: raw,
1228
+ error: null
1229
+ };
1230
+ }
1231
+ function resolveBudget({ config = loadConfig$1(), env = process.env, totalMemoryMb = totalmem$1() / MB } = {}) {
1232
+ const minFree = machineNumber("budget.minFreeDiskGb", config, env);
1233
+ const hardFloor = machineNumber("budget.hardFloorDiskGb", config, env);
1234
+ const memory = machineNumber("budget.maxCommittedMemoryGb", config, env);
1235
+ const live = machineNumber("budget.maxLiveWorkspaces", config, env);
1236
+ return {
1237
+ budget: {
1238
+ minFreeDiskMb: (minFree.value ?? 0) * GB_IN_MB,
1239
+ hardFloorDiskMb: (hardFloor.value ?? 0) * GB_IN_MB,
1240
+ maxCommittedMemoryMb: memory.value === void 0 ? Math.round(totalMemoryMb * COMMITTED_MEMORY_SHARE) : memory.value * GB_IN_MB,
1241
+ maxLiveWorkspaces: live.value ?? 0
1242
+ },
1243
+ error: minFree.error ?? hardFloor.error ?? memory.error ?? live.error
1244
+ };
1245
+ }
1246
+ function existingAncestor(path) {
1247
+ let current = path;
1248
+ while (!existsSync(current) && dirname$1(current) !== current) current = dirname$1(current);
1249
+ return current;
1250
+ }
1251
+ function readVolumeSpace(paths) {
1252
+ const seen = /* @__PURE__ */ new Set();
1253
+ const volumes = [];
1254
+ for (const path of paths) try {
1255
+ const existing = existingAncestor(path);
1256
+ const device = statSync(existing).dev;
1257
+ if (seen.has(device)) continue;
1258
+ seen.add(device);
1259
+ const fs = statfsSync(existing);
1260
+ volumes.push({
1261
+ volume: volumeRootFor(path),
1262
+ freeMb: fs.bavail * fs.bsize / MB
1263
+ });
1264
+ } catch {}
1265
+ return volumes;
1266
+ }
1267
+ function estimateMemoryUse({ config, sims, androidSerial, supervisorRunning }) {
1268
+ const simsByUdid = Object.fromEntries((sims ?? []).map((sim) => [sim.udid, sim]));
1269
+ const runtime = (avdName) => {
1270
+ if (!avdName) return null;
1271
+ const serial = androidSerial(avdName);
1272
+ return {
1273
+ serial,
1274
+ state: serial ? "detected" : "not-detected"
1275
+ };
1276
+ };
1277
+ const states = Object.entries(config?.projects ?? {}).map(([path, project]) => {
1278
+ const pid = supervisorRunning(path, project);
1279
+ const slots = projectDeviceSlots(project);
1280
+ return environmentState({
1281
+ ...project,
1282
+ __path: path
1283
+ }, {
1284
+ simsByUdid,
1285
+ simsAvailable: sims !== null,
1286
+ metro: pid === null ? { missing: true } : { metro: { pid } },
1287
+ androidRuntime: project.platforms?.android?.owned ? runtime(project.platforms.android.avdName) : null,
1288
+ androidRuntimes: Object.fromEntries(slots.slice(1).map(({ slot, platforms }) => [slot, platforms.android?.owned ? runtime(platforms.android.avdName) : null]))
1289
+ });
1290
+ });
1291
+ const { committedMb, liveCount } = capacity(states, 0);
1292
+ return {
1293
+ committedMb,
1294
+ liveWorkspaces: liveCount
1295
+ };
1296
+ }
1297
+ function listSims() {
1298
+ try {
1299
+ return listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
1300
+ } catch {
1301
+ return null;
1302
+ }
1303
+ }
1304
+ function runningSupervisorPid(path, project) {
1305
+ const target = resolveSupervisorTarget({
1306
+ state: readWorkspaceState$1(path)?.supervisor,
1307
+ record: project.supervisor,
1308
+ reservedPort: project.metroPort
1309
+ });
1310
+ return target.status === "ours" ? target.pid ?? null : null;
1311
+ }
1312
+ function readMemoryUse() {
1313
+ const resolve = ownedAvdSerialResolver({ timeoutMs: 5e3 });
1314
+ return estimateMemoryUse({
1315
+ config: loadConfig$1(),
1316
+ sims: listSims(),
1317
+ androidSerial: (avdName) => {
1318
+ try {
1319
+ return resolve(avdName).serial ?? null;
1320
+ } catch {
1321
+ return null;
1322
+ }
1323
+ },
1324
+ supervisorRunning: runningSupervisorPid
1325
+ });
1326
+ }
1327
+ function budgetShortfalls(budget, measure) {
1328
+ const floor = Math.max(budget.minFreeDiskMb, budget.hardFloorDiskMb);
1329
+ return {
1330
+ disk: floor > 0 ? measure.volumes.filter((v) => v.freeMb < floor) : [],
1331
+ hardFloor: budget.hardFloorDiskMb > 0 ? measure.volumes.filter((v) => v.freeMb < budget.hardFloorDiskMb) : [],
1332
+ memory: Boolean(budget.maxCommittedMemoryMb > 0 && measure.memory && measure.memory.committedMb > budget.maxCommittedMemoryMb),
1333
+ workspaces: Boolean(budget.maxLiveWorkspaces > 0 && measure.memory && measure.memory.liveWorkspaces > budget.maxLiveWorkspaces)
1334
+ };
1335
+ }
1336
+ const DISK_ONLY = ["workspace-outputs", "stale-cache-entries"];
1337
+ function reclaimPlan(short) {
1338
+ const disk = short.disk.length > 0;
1339
+ return [...disk || short.memory || short.workspaces ? ["idle-devices", "idle-dev-servers"] : [], ...disk ? DISK_ONLY : []];
1340
+ }
1341
+ function overBudget(short) {
1342
+ return short.disk.length > 0 || short.memory || short.workspaces;
1343
+ }
1344
+ function isOtherWorkspace(root) {
1345
+ const self = canonicalPath(root);
1346
+ return (path) => canonicalPath(path) !== self;
1347
+ }
1348
+ function deviceLabel(device) {
1349
+ return `${device.kind} ${device.name} in ${device.project}`;
1350
+ }
1351
+ function reclaimIdleDevices({ root, dryRun, note, now }) {
1352
+ if (deviceSweepIsScoped()) {
1353
+ note(chalk.dim(phaseLine("budget", "idle devices left alone: STIM_HOME scopes this config, devices are global")));
1354
+ return {
1355
+ targets: [],
1356
+ failures: 0
1357
+ };
1358
+ }
1359
+ const other = isOtherWorkspace(root);
1360
+ const collect = (at) => idleShutdownCandidates(collectIdleDevices(loadConfig$1(), listSims() ?? [], [], at).filter((device) => other(device.project)), 0);
1361
+ const idle = collect(now);
1362
+ if (dryRun || idle.length === 0) return {
1363
+ targets: idle.map(deviceLabel),
1364
+ failures: 0
1365
+ };
1366
+ const { failures, shutDown } = shutDownIdleDevices(idle, 0, () => collect(Date.now()));
1367
+ return {
1368
+ targets: shutDown.map(deviceLabel),
1369
+ failures
1370
+ };
1371
+ }
1372
+ function idleSince(path, now) {
1373
+ const activity = workspaceActivity(path, now);
1374
+ const at = Date.parse(activity.lastActivityAt ?? "");
1375
+ return activity.state === "idle" && Number.isFinite(at) ? at : null;
1376
+ }
1377
+ async function reclaimIdleDevServers({ root, dryRun, note, now }) {
1378
+ const config = loadConfig$1();
1379
+ const other = isOtherWorkspace(root);
1380
+ const serving = Object.entries(config?.projects ?? {}).flatMap(([path, project]) => {
1381
+ const pid = other(path) ? runningSupervisorPid(path, project) : null;
1382
+ return pid !== null && !workspaceBuildInProgress(path) && idleSince(path, now) !== null ? [{
1383
+ path,
1384
+ pid
1385
+ }] : [];
1386
+ });
1387
+ if (serving.length === 0) return {
1388
+ targets: [],
1389
+ failures: 0
1390
+ };
1391
+ const sims = listSims();
1392
+ if (sims === null) {
1393
+ note(chalk.dim(phaseLine("budget", "dev servers left alone: simulators could not be listed to prove them idle")));
1394
+ return {
1395
+ targets: [],
1396
+ failures: 0
1397
+ };
1398
+ }
1399
+ const busy = new Set(collectOwnedDeviceActivity(config, sims, now).filter((device) => device.activity.state !== "idle").map((device) => device.project));
1400
+ const idle = serving.filter(({ path }) => !busy.has(path));
1401
+ if (dryRun) return {
1402
+ targets: idle.map(({ path }) => path),
1403
+ failures: 0
1404
+ };
1405
+ const targets = [];
1406
+ let failures = 0;
1407
+ const stillIdle = (path, pid) => {
1408
+ const project = loadConfig$1()?.projects?.[path];
1409
+ return Boolean(project && runningSupervisorPid(path, project) === pid && !workspaceBuildInProgress(path) && idleSince(path, Date.now()) !== null);
1410
+ };
1411
+ for (const { path, pid } of idle) {
1412
+ let run;
1413
+ try {
1414
+ run = await withIdleWorkspace(path, async () => {
1415
+ try {
1416
+ return await withWorkspaceProcessLock(workspaceDir(path), "metro-start", async () => stillIdle(path, pid) ? stopOwnedMetro(path) : "busy", {
1417
+ external: true,
1418
+ waitMs: 0,
1419
+ ownerPurpose: "budget reclaim"
1420
+ });
1421
+ } catch (error) {
1422
+ if (workspaceProcessLockError(error)) return "starting";
1423
+ throw error;
1424
+ }
1425
+ }, {
1426
+ purpose: "budget reclaim",
1427
+ supervisor: false
1428
+ });
1429
+ } catch (error) {
1430
+ failures++;
1431
+ note(chalk.red(`Could not stop the dev server of ${path}: ${error?.message || error}`));
1432
+ continue;
1433
+ }
1434
+ if (!run.ran) {
1435
+ note(chalk.yellow(`Kept the dev server of ${path}: ${run.reasons.join("; ")}`));
1436
+ continue;
1437
+ }
1438
+ const stopped = run.value;
1439
+ if (stopped === "busy" || stopped === "starting") {
1440
+ const why = stopped === "busy" ? "it is no longer idle" : "a stim start holds its metro-start lock";
1441
+ note(chalk.dim(`Kept the dev server of ${path}: ${why}`));
1442
+ } else if (stopped.status === "stopped") {
1443
+ targets.push(path);
1444
+ note(chalk.green(`Stopped the idle dev server of ${path}`));
1445
+ } else if ("reason" in stopped) {
1446
+ if (stopped.status === "stuck") failures++;
1447
+ note(chalk.yellow(`Kept the dev server of ${path}: ${stopped.reason}`));
1448
+ }
1449
+ }
1450
+ return {
1451
+ targets,
1452
+ failures
1453
+ };
1454
+ }
1455
+ async function reclaimWorkspaceOutputs({ root, dryRun, now, diskRecovered }) {
1456
+ const other = isOtherWorkspace(root);
1457
+ const report = collectWorkspaceOutputs({
1458
+ olderThan: null,
1459
+ now,
1460
+ measure: false
1461
+ });
1462
+ const clearable = report.workspaces.flatMap((entry) => {
1463
+ const project = entry.projectRoot;
1464
+ const since = project !== null && entry.willClear && other(project) ? idleSince(project, now) : null;
1465
+ return since === null ? [] : [{
1466
+ entry,
1467
+ since
1468
+ }];
1469
+ }).toSorted((a, b) => a.since - b.since).map(({ entry }) => entry);
1470
+ if (dryRun) return {
1471
+ targets: clearable.map((entry) => entry.projectRoot),
1472
+ failures: 0
1473
+ };
1474
+ const targets = [];
1475
+ let failures = 0;
1476
+ for (const entry of clearable) {
1477
+ const result = await clearWorkspaceOutputs({
1478
+ ...report,
1479
+ workspaces: [entry]
1480
+ }, {
1481
+ olderThan: null,
1482
+ now
1483
+ });
1484
+ failures += result.failures;
1485
+ targets.push(...result.cleared);
1486
+ if (diskRecovered()) break;
1487
+ }
1488
+ return {
1489
+ targets,
1490
+ failures
1491
+ };
1492
+ }
1493
+ function cacheLabel(cache) {
1494
+ return `${cache.name} ${cache.dir}`;
1495
+ }
1496
+ function reclaimStaleCacheEntries({ dryRun }) {
1497
+ const caches = planCacheEmptying(discoverCaches(), false).filter((cache) => cache.prune !== "report-only" && cache.prune !== "atomic" && !cache.machineGlobal);
1498
+ if (dryRun) return {
1499
+ targets: caches.map(cacheLabel),
1500
+ failures: 0
1501
+ };
1502
+ return {
1503
+ targets: trimCaches(caches, STALE_CACHE_DAYS).map(cacheLabel),
1504
+ failures: 0
1505
+ };
1506
+ }
1507
+ const STEPS = {
1508
+ "idle-devices": reclaimIdleDevices,
1509
+ "idle-dev-servers": reclaimIdleDevServers,
1510
+ "workspace-outputs": reclaimWorkspaceOutputs,
1511
+ "stale-cache-entries": reclaimStaleCacheEntries
1512
+ };
1513
+ const STEP_ACTIONS = {
1514
+ "idle-devices": "shut down idle owned devices",
1515
+ "idle-dev-servers": "stop idle dev servers",
1516
+ "workspace-outputs": "clear build outputs of idle workspaces",
1517
+ "stale-cache-entries": `trim cache entries unused for ${STALE_CACHE_DAYS} days`
1518
+ };
1519
+ async function runStep(step, context) {
1520
+ const log = console.log;
1521
+ const exitCode = process.exitCode;
1522
+ console.log = console.error;
1523
+ try {
1524
+ const result = await STEPS[step](context);
1525
+ return process.exitCode === exitCode ? result : {
1526
+ ...result,
1527
+ failures: result.failures + 1
1528
+ };
1529
+ } catch (error) {
1530
+ context.note(chalk.red(phaseLine("budget", `${STEP_ACTIONS[step]} failed: ${error?.message || error}`)));
1531
+ return {
1532
+ targets: [],
1533
+ failures: 1
1534
+ };
1535
+ } finally {
1536
+ console.log = log;
1537
+ process.exitCode = exitCode;
1538
+ }
1539
+ }
1540
+ function spaceUsers() {
1541
+ const home = homedir$1();
1542
+ const candidates = [
1543
+ ["Stim state", getConfigDir()],
1544
+ ["shared build cache", sharedBuildCache()],
1545
+ ...platform() === "darwin" ? [["simulators", join$1(home, "Library", "Developer", "CoreSimulator", "Devices")], ["Xcode DerivedData", join$1(home, "Library", "Developer", "Xcode", "DerivedData")]] : [],
1546
+ ["Android emulators", join$1(home, ".android", "avd")]
1547
+ ];
1548
+ const measured = /* @__PURE__ */ new Set();
1549
+ const users = [];
1550
+ for (const [label, path] of candidates) {
1551
+ const canonical = canonicalPath(path);
1552
+ if (measured.has(canonical) || [...measured].some((dir) => canonical.startsWith(`${dir}${sep$1}`))) continue;
1553
+ measured.add(canonical);
1554
+ if (!existsSync(path)) continue;
1555
+ const bytes = measuredDirectorySize(path, { timeoutMs: USAGE_TIMEOUT_MS });
1556
+ if (bytes) users.push({
1557
+ label: `${label} ${path}`,
1558
+ bytes
1559
+ });
1560
+ }
1561
+ return users.toSorted((a, b) => b.bytes - a.bytes).map(({ label, bytes }) => `${label}: ${formatBytes(bytes)}`);
1562
+ }
1563
+ const DEFAULT_DEPS = {
1564
+ volumes: readVolumeSpace,
1565
+ memory: readMemoryUse,
1566
+ step: runStep,
1567
+ usage: spaceUsers,
1568
+ now: Date.now
1569
+ };
1570
+ const LOW_DISK_REMEDY = "Run `stim gc` to see what else Stim can reclaim, then `stim gc --delete` (add `--cache all` to empty the shared caches, or `--worktrees` to remove finished worktrees). Free space outside Stim from the largest uses listed, or lower budget.hardFloorDiskGb.";
1571
+ function describeShortfall(budget, measure, short) {
1572
+ const floor = Math.max(budget.minFreeDiskMb, budget.hardFloorDiskMb);
1573
+ return [
1574
+ ...short.disk.map((v) => `${formatSpace(v.freeMb)} free on ${v.volume}, below the ${formatSpace(floor)} floor`),
1575
+ ...short.memory && measure.memory ? [`roughly ${formatSpace(measure.memory.committedMb)} of memory committed, over the ${formatSpace(budget.maxCommittedMemoryMb)} budget`] : [],
1576
+ ...short.workspaces && measure.memory ? [`${measure.memory.liveWorkspaces} live workspaces, over the limit of ${budget.maxLiveWorkspaces}`] : []
1577
+ ];
1578
+ }
1579
+ async function enforceBudget({ root, note, dryRun = false, budget: resolved }, overrides = {}) {
1580
+ const deps = {
1581
+ ...DEFAULT_DEPS,
1582
+ ...overrides
1583
+ };
1584
+ let budget = resolved;
1585
+ if (!budget) {
1586
+ const read = resolveBudget();
1587
+ if (read.error) return {
1588
+ status: "invalid",
1589
+ refusal: {
1590
+ code: "STIM_BAD_ARG",
1591
+ message: read.error,
1592
+ remedy: BUDGET_SETTING_REMEDY
1593
+ }
1594
+ };
1595
+ budget = read.budget;
1596
+ }
1597
+ const paths = [root, getConfigDir()];
1598
+ const measureNow = () => ({
1599
+ volumes: budget.minFreeDiskMb > 0 || budget.hardFloorDiskMb > 0 ? deps.volumes(paths) : [],
1600
+ memory: budget.maxCommittedMemoryMb > 0 || budget.maxLiveWorkspaces > 0 ? deps.memory() : null
1601
+ });
1602
+ let measure = measureNow();
1603
+ let short = budgetShortfalls(budget, measure);
1604
+ const reclaimed = [];
1605
+ const plan = reclaimPlan(short);
1606
+ if (plan.length) note(chalk.yellow(phaseLine("budget", `${describeShortfall(budget, measure, short).join("; ")}; ${dryRun ? "would reclaim" : "reclaiming"}`)));
1607
+ for (const step of plan) {
1608
+ if (!dryRun && (!overBudget(short) || DISK_ONLY.includes(step) && short.disk.length === 0)) break;
1609
+ const freeBefore = measure.volumes.reduce((sum, v) => sum + v.freeMb, 0);
1610
+ const result = await deps.step(step, {
1611
+ root,
1612
+ dryRun,
1613
+ note,
1614
+ now: deps.now(),
1615
+ diskRecovered: () => budgetShortfalls(budget, {
1616
+ volumes: deps.volumes(paths),
1617
+ memory: null
1618
+ }).disk.length === 0
1619
+ });
1620
+ if (!dryRun) {
1621
+ measure = measureNow();
1622
+ short = budgetShortfalls(budget, measure);
1623
+ }
1624
+ if (result.targets.length === 0 && result.failures === 0) continue;
1625
+ const freedMb = Math.max(0, Math.round(measure.volumes.reduce((sum, v) => sum + v.freeMb, 0) - freeBefore));
1626
+ reclaimed.push({
1627
+ step,
1628
+ ...result,
1629
+ ...dryRun ? {} : { freedMb }
1630
+ });
1631
+ const outcome = dryRun ? result.targets.join(", ") : `${result.targets.length}${result.failures ? `, ${result.failures} failed` : ""} (${formatSpace(freedMb)} freed)`;
1632
+ note(chalk.dim(phaseLine("budget", `${STEP_ACTIONS[step]}: ${outcome}`)));
1633
+ }
1634
+ if (!dryRun && short.hardFloor.length) {
1635
+ const tight = short.hardFloor.map((v) => `${formatSpace(v.freeMb)} on ${v.volume}`).join(" and ");
1636
+ const users = deps.usage();
1637
+ return {
1638
+ status: "refused",
1639
+ reclaimed,
1640
+ refusal: {
1641
+ code: "STIM_LOW_DISK",
1642
+ message: `Only ${tight} is free, below the ${formatSpace(budget.hardFloorDiskMb)} hard floor (budget.hardFloorDiskGb)${reclaimed.length ? " after reclaiming what Stim safely could" : ""}, so this run could fill the disk.` + (users.length ? ` Largest uses: ${users.join("; ")}.` : ""),
1643
+ remedy: LOW_DISK_REMEDY
1644
+ }
1645
+ };
1646
+ }
1647
+ if (!dryRun && overBudget(short)) note(chalk.yellow(phaseLine("budget", `still over budget: ${describeShortfall(budget, measure, short).join("; ")}`)));
1648
+ return {
1649
+ status: "ok",
1650
+ reclaimed,
1651
+ budget,
1652
+ measure,
1653
+ short
1654
+ };
1655
+ }
1656
+ async function budgetGate(args, overrides = {}) {
1657
+ const outcome = await enforceBudget(args, overrides);
1658
+ return {
1659
+ reclaimed: "reclaimed" in outcome ? outcome.reclaimed : [],
1660
+ refusal: outcome.status === "ok" ? null : outcome.refusal
1661
+ };
1662
+ }
1663
+ const gb = (mb) => Math.round(mb / GB_IN_MB * 10) / 10;
1664
+ function budgetFindings(budget, measure, short, plan) {
1665
+ const findings = [];
1666
+ const steps = plan.length ? plan.map((entry) => `${STEP_ACTIONS[entry.step]} (${entry.targets.join(", ")})`).join("; then ") : "nothing Stim can reclaim on its own right now";
1667
+ if (short.disk.length) {
1668
+ const refuses = short.hardFloor.length ? ` Below ${formatSpace(budget.hardFloorDiskMb)} it refuses with STIM_LOW_DISK when reclaiming does not free enough.` : "";
1669
+ findings.push({
1670
+ level: "cost",
1671
+ title: "Free disk is below the Stim budget",
1672
+ detail: `${describeShortfall(budget, measure, {
1673
+ ...short,
1674
+ memory: false,
1675
+ workspaces: false
1676
+ }).join("; ")}. The next ios, android or start will first ${steps}.${refuses}`,
1677
+ fix: "Run `stim gc` to see what Stim can reclaim, then `stim gc --delete`. Free space outside Stim, or lower budget.minFreeDiskGb."
1678
+ });
1679
+ }
1680
+ if (short.memory || short.workspaces) findings.push({
1681
+ level: "note",
1682
+ title: "Live environments are over the memory budget",
1683
+ detail: `${describeShortfall(budget, measure, {
1684
+ ...short,
1685
+ disk: []
1686
+ }).join("; ")}. The next ios, android or start shuts down idle owned devices and stops idle dev servers in other workspaces first.`,
1687
+ fix: "Run `stim status` to see what is live, and `stim stop` in workspaces you own that you no longer need."
1688
+ });
1689
+ return findings;
1690
+ }
1691
+ async function inspectBudget(root, deps = {}) {
1692
+ const read = resolveBudget();
1693
+ if (read.error) return {
1694
+ report: null,
1695
+ findings: [{
1696
+ level: "cost",
1697
+ title: "A budget setting is invalid",
1698
+ detail: read.error,
1699
+ fix: BUDGET_SETTING_REMEDY
1700
+ }]
1701
+ };
1702
+ const outcome = await enforceBudget({
1703
+ root,
1704
+ note: () => {},
1705
+ dryRun: true,
1706
+ budget: read.budget
1707
+ }, deps);
1708
+ if (outcome.status !== "ok") return {
1709
+ report: null,
1710
+ findings: []
1711
+ };
1712
+ const { budget, measure, short, reclaimed } = outcome;
1713
+ return {
1714
+ report: {
1715
+ budget: {
1716
+ minFreeDiskGb: gb(budget.minFreeDiskMb),
1717
+ hardFloorDiskGb: gb(budget.hardFloorDiskMb),
1718
+ maxCommittedMemoryGb: gb(budget.maxCommittedMemoryMb),
1719
+ maxLiveWorkspaces: budget.maxLiveWorkspaces
1720
+ },
1721
+ volumes: measure.volumes.map((v) => ({
1722
+ volume: v.volume,
1723
+ freeGb: gb(v.freeMb)
1724
+ })),
1725
+ memory: measure.memory ? {
1726
+ committedGb: gb(measure.memory.committedMb),
1727
+ liveWorkspaces: measure.memory.liveWorkspaces
1728
+ } : null,
1729
+ plan: reclaimed
1730
+ },
1731
+ findings: budgetFindings(budget, measure, short, reclaimed)
1732
+ };
1733
+ }
1734
+ function budgetLine(report) {
1735
+ const { budget } = report;
1736
+ return `${report.volumes.length ? `${report.volumes.map((v) => `${v.freeGb} GB free on ${v.volume}`).join(", ")} (reclaims below ${Math.max(budget.minFreeDiskGb, budget.hardFloorDiskGb)} GB, ${budget.hardFloorDiskGb ? `refuses below ${budget.hardFloorDiskGb} GB` : "never refuses"})` : "disk budget off"}; ${report.memory ? `roughly ${report.memory.committedGb} GB of a ${budget.maxCommittedMemoryGb} GB memory budget committed` : "memory budget off"}`;
1737
+ }
1738
+ //#endregion
1739
+ export { includesWorkspaceOutputs as A, deviceSweepIsScoped as C, unverifiedDeviceCommand as D, findStaleProjectDevices as E, sizeCaches as F, stopOwnedMetroForReset as I, selectCaches as M, trimCaches as N, withAndroidAvdSizes as O, discoverCaches as P, describeUnverifiableDevices as S, findStaleDeviceRecords as T, collectParkedSims as _, REBUILD_COST as a, deleteProjectDevices as b, collectOrphanedWorkspaces as c, isInsideWorkspaces as d, listWorkspaceDirs as f, collectParkedAvds as g, collectDeviceLeases as h, machineNumber as i, planCacheEmptying as j, emptyCaches as k, collectWorkspaceOutputs as l, DEVICE_LIST_TIMEOUT_MS$1 as m, budgetLine as n, WORKSPACE_OUTPUT_DIRS as o, sizeText as p, inspectBudget as r, clearWorkspaceOutputs as s, budgetGate as t, deleteOrphanedWorkspaces as u, deleteParkedAvds as v, findOrphanedDevices as w, describeParkedSims as x, deleteParkedSims as y };