stim 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -10
- package/dist/android-BW8i_YxU.mjs +204 -0
- package/dist/android-Bcmev2s2.mjs +2334 -0
- package/dist/android-K63_0UfF.mjs +727 -0
- package/dist/android-cas-D6HPZ6UQ.mjs +1078 -0
- package/dist/android-cas-compiler.mjs +2 -2
- package/dist/app-install-NFxUahnH.mjs +1149 -0
- package/dist/build-lock-BsVoLDaq.mjs +645 -0
- package/dist/build-slots-D0b6sJyo.mjs +152 -0
- package/dist/{cache-manifest-4tH64LQ9.mjs → cache-manifest-Cu4_Znw9.mjs} +2 -2
- package/dist/cache-manifest.mjs +1 -1
- package/dist/cli.mjs +24 -27166
- package/dist/collector-run.d.mts +1 -0
- package/dist/collector-run.mjs +7 -5
- package/dist/command-output-8wHMEde0.mjs +289 -0
- package/dist/{config-7kmtuhO2.mjs → config-CMtIaTk9.mjs} +44 -13
- package/dist/deps-BLBsTNDS.mjs +435 -0
- package/dist/dev-client-RpvXxCXV.mjs +620 -0
- package/dist/device-CoCPmazS.mjs +250 -0
- package/dist/device-lease-Dpik41RY.mjs +335 -0
- package/dist/device-pool-B5pEVa3t.mjs +408 -0
- package/dist/device-remote-E81KAdXf.mjs +1209 -0
- package/dist/doctor-CqPeMsSm.mjs +1130 -0
- package/dist/doctor-D_CX7ib5.mjs +530 -0
- package/dist/error-diagnostics-Dw8Gn3yw.mjs +636 -0
- package/dist/{exec-bsN9MJXb.mjs → exec-CyylIdq9.mjs} +4 -2
- package/dist/gc-B7KkDinF.mjs +1665 -0
- package/dist/guide-BS2Xc24o.mjs +4503 -0
- package/dist/ios-B7KLgIp5.mjs +2733 -0
- package/dist/ios-D1h1Di39.mjs +451 -0
- package/dist/ios-device-D3pzTKQE.mjs +81 -0
- package/dist/ios-device-xNt0Lg6v.mjs +413 -0
- package/dist/logs-DcX9AvRF.mjs +176 -0
- package/dist/logs-query-oqn47Ffx.mjs +273 -0
- package/dist/metro-COslRK_F.mjs +184 -0
- package/dist/metro-store-CDDZHtPl.mjs +92 -0
- package/dist/ownership-CGPart6T.mjs +60 -0
- package/dist/ownership-CYOyqxUo.mjs +475 -0
- package/dist/ownership-claim-7zRxxTfZ.mjs +569 -0
- package/dist/{project-Dit1CckW.mjs → project-BBTVABUz.mjs} +12 -3
- package/dist/reclaim-C5Jmuahx.mjs +348 -0
- package/dist/reload-Z8nKifAk.mjs +332 -0
- package/dist/remote-cache-DDmJ1Roq.mjs +994 -0
- package/dist/{server-bare-BmTVVmBB.mjs → server-bare-DEk37Mz2.mjs} +8 -2
- package/dist/{server-expo-CHJe1r6j.mjs → server-expo-C3ktAwUG.mjs} +6 -5
- package/dist/server-expo-Drd7ksHu.mjs +2 -0
- package/dist/settings-DwU1_U_q.mjs +708 -0
- package/dist/spawn-entry-nCE6m770.mjs +14 -0
- package/dist/start-BrY4EuIg.mjs +822 -0
- package/dist/{state-2nush3MK.mjs → state-CZ-ER-8d.mjs} +21 -22
- package/dist/stats-BCWEVLrM.mjs +307 -0
- package/dist/stats-BsF15Bti.mjs +62 -0
- package/dist/status-DFI1Hfpg.mjs +393 -0
- package/dist/stop-B7DR4awK.mjs +3 -0
- package/dist/stop-Btg770vV.mjs +591 -0
- package/dist/supervisor-run.d.mts +0 -1
- package/dist/supervisor-run.mjs +4 -4
- package/dist/workspace-process-lock-BKfdys1q.mjs +47 -0
- package/dist/worktree-BPTbI-LN.mjs +1322 -0
- package/dist/worktree-C79xx_kM.mjs +543 -0
- package/dist/xcode-DeKCribl.mjs +1832 -0
- package/package.json +5 -5
- package/shim/bundle-response.cjs +5 -0
- package/dist/android-jc8MnUyL.mjs +0 -514
- package/dist/metro-store-COLl1pOk.mjs +0 -1249
- package/dist/server-expo-BZJm--wA.mjs +0 -2
|
@@ -0,0 +1,1665 @@
|
|
|
1
|
+
import { t as getExecutor } from "./exec-CyylIdq9.mjs";
|
|
2
|
+
import { A as directorySize, M as isOnMountedVolume, N as listMountedVolumes, P as volumeRootFor, g as loadConfig, i as clearDevice, j as formatBytes, u as getConfigDir, v as removeProject } from "./config-CMtIaTk9.mjs";
|
|
3
|
+
import { i as registeredCaches } from "./cache-manifest-Cu4_Znw9.mjs";
|
|
4
|
+
import { T as workspaceDir, k as workspaceStateFile } from "./state-CZ-ER-8d.mjs";
|
|
5
|
+
import { g as repoRoot, s as gitCommonDir } from "./worktree-C79xx_kM.mjs";
|
|
6
|
+
import { a as findProjectRoot, i as detectIsExpo } from "./project-BBTVABUz.mjs";
|
|
7
|
+
import { S as readParked, _ as teardownParkedIosSim, b as dropParked, d as resolveEasCliBin, g as teardownParkedAvd, h as teardownOwnedIosSim, m as teardownOwnedAvd, v as POOL_SETTING_REMEDY, x as parkedMaxSetting } from "./remote-cache-DDmJ1Roq.mjs";
|
|
8
|
+
import { A as resolveSettings, N as settingShapeErrors, a as SETTING_SHAPE_REMEDY } from "./settings-DwU1_U_q.mjs";
|
|
9
|
+
import { m as shortUdid, o as formatLongDuration, u as plural } from "./command-output-8wHMEde0.mjs";
|
|
10
|
+
import { d as listIosDeviceTypes, g as parseRuntimeVersion, u as listAllIosSims } from "./ios-D1h1Di39.mjs";
|
|
11
|
+
import { r as listBuildLocks } from "./build-lock-BsVoLDaq.mjs";
|
|
12
|
+
import { d as readClaimSet, o as clearFreeClaimSet } from "./ownership-claim-7zRxxTfZ.mjs";
|
|
13
|
+
import { i as listLeaseFiles, r as leaseIsExpired, u as removeExpiredLease } from "./device-lease-Dpik41RY.mjs";
|
|
14
|
+
import { n as listBuildSlots } from "./build-slots-D0b6sJyo.mjs";
|
|
15
|
+
import { S as listOrphanedAvdDirectories, T as ownedAvdDirectory, b as listAvds } from "./android-K63_0UfF.mjs";
|
|
16
|
+
import { C as verifyStoppedSession, S as stopSessionArgs, _ as inspectSessionForTeardown, d as withEasProjectLock, f as easMachineStateRoot, g as getSessionArgs, h as findOrphanedOwnedSessions, m as removeEasSessionClaim, p as readEasSessionLedger, u as withRemoteSessionLock, v as isDefinitiveMissingSessionError, x as parseSessionListPage, y as listOwnedSessionsArgs } from "./device-remote-E81KAdXf.mjs";
|
|
17
|
+
import { n as reclaimProject, t as describeDereferenced } from "./reclaim-C5Jmuahx.mjs";
|
|
18
|
+
import { InvalidArgumentError } from "commander";
|
|
19
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from "fs";
|
|
20
|
+
import { homedir, tmpdir } from "os";
|
|
21
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "path";
|
|
22
|
+
import { METRO_NAMED_CACHE_LAYOUT } from "@stim-cli/core";
|
|
23
|
+
import chalk from "chalk";
|
|
24
|
+
//#region src/caches.ts
|
|
25
|
+
function xcodeDerivedDataRoot() {
|
|
26
|
+
return join(homedir(), "Library", "Developer", "Xcode", "DerivedData");
|
|
27
|
+
}
|
|
28
|
+
function compilationCache() {
|
|
29
|
+
const dir = join(xcodeDerivedDataRoot(), "CompilationCache.noindex");
|
|
30
|
+
if (!existsSync(dir)) return null;
|
|
31
|
+
return {
|
|
32
|
+
name: "Xcode compilation cache",
|
|
33
|
+
dir,
|
|
34
|
+
prune: "atomic",
|
|
35
|
+
note: "index-backed, so it can only be emptied whole"
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function gradleBuildCache() {
|
|
39
|
+
const gradleHome = process.env.GRADLE_USER_HOME || join(homedir(), ".gradle");
|
|
40
|
+
const dir = resolve(gradleHome, "caches", "build-cache-1");
|
|
41
|
+
if (!existsSync(dir)) return null;
|
|
42
|
+
return {
|
|
43
|
+
name: "Gradle build cache",
|
|
44
|
+
dir,
|
|
45
|
+
prune: "report-only",
|
|
46
|
+
note: "shared with every Gradle build; report only, never pruned or emptied by Stim"
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function metroFileMaps() {
|
|
50
|
+
const root = tmpdir();
|
|
51
|
+
if (!existsSync(root)) return null;
|
|
52
|
+
let names;
|
|
53
|
+
try {
|
|
54
|
+
names = readdirSync(root).filter((n) => n.startsWith("metro-file-map-"));
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (!names.length) return null;
|
|
59
|
+
let bytes = 0;
|
|
60
|
+
for (const n of names) try {
|
|
61
|
+
bytes += statSync(join(root, n)).size;
|
|
62
|
+
} catch {}
|
|
63
|
+
return {
|
|
64
|
+
name: "Metro file maps",
|
|
65
|
+
dir: root,
|
|
66
|
+
files: names.map((n) => join(root, n)),
|
|
67
|
+
bytes,
|
|
68
|
+
prune: "entries",
|
|
69
|
+
note: `${names.length} file(s), one per project root Metro has served`
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function declaredCaches(paths) {
|
|
73
|
+
return (paths || []).map((p) => resolve(p.startsWith("~") ? join(homedir(), p.slice(1)) : p)).filter((p) => existsSync(p)).map((dir) => ({
|
|
74
|
+
name: "declared",
|
|
75
|
+
dir,
|
|
76
|
+
prune: "entries",
|
|
77
|
+
note: "from the `caches` setting"
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
function projectSettings(cwd) {
|
|
81
|
+
const root = findProjectRoot(cwd);
|
|
82
|
+
if (!root) return {};
|
|
83
|
+
return resolveSettings({
|
|
84
|
+
projectPath: root,
|
|
85
|
+
gitCommonDir: gitCommonDir(root),
|
|
86
|
+
repoRoot: repoRoot(root)
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function declaredCachePaths(cwd = process.cwd()) {
|
|
90
|
+
const declared = projectSettings(cwd).caches;
|
|
91
|
+
return Array.isArray(declared) ? declared : [];
|
|
92
|
+
}
|
|
93
|
+
function projectSettingShapeErrors(cwd = process.cwd()) {
|
|
94
|
+
return settingShapeErrors(projectSettings(cwd));
|
|
95
|
+
}
|
|
96
|
+
function discoverCaches({ declared = [] } = {}) {
|
|
97
|
+
const gradle = gradleBuildCache();
|
|
98
|
+
const gradleDir = gradle ? canonicalCacheDir(gradle.dir) : null;
|
|
99
|
+
const registered = protectNestedMetroAncestors(suppressLegacyMetroAncestors(registeredCaches().map((c) => Object.assign({}, c, {
|
|
100
|
+
name: c.name ?? c.dir,
|
|
101
|
+
note: c.note ?? "registered",
|
|
102
|
+
prune: c.prune,
|
|
103
|
+
source: "registered"
|
|
104
|
+
}))));
|
|
105
|
+
const detected = [
|
|
106
|
+
compilationCache(),
|
|
107
|
+
gradle,
|
|
108
|
+
metroFileMaps(),
|
|
109
|
+
...declaredCaches(declared)
|
|
110
|
+
].filter((c) => Boolean(c)).map((c) => Object.assign({}, c, { source: "detected" }));
|
|
111
|
+
return mergeCacheDescriptors([...registered, ...detected], gradleDir);
|
|
112
|
+
}
|
|
113
|
+
function suppressLegacyMetroAncestors(caches) {
|
|
114
|
+
const current = caches.filter(isCurrentMetroStore);
|
|
115
|
+
return caches.filter((cache) => {
|
|
116
|
+
if (!isLegacyMetroStore(cache)) return true;
|
|
117
|
+
return !current.some((candidate) => isNamedMetroChild(cache.dir, candidate.dir));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function protectNestedMetroAncestors(caches) {
|
|
121
|
+
const current = caches.filter(isCurrentMetroStore);
|
|
122
|
+
return caches.map((cache) => {
|
|
123
|
+
if (!isCurrentMetroStore(cache)) return cache;
|
|
124
|
+
if (!current.some((candidate) => isNamedMetroChild(cache.dir, candidate.dir))) return cache;
|
|
125
|
+
return Object.assign({}, cache, {
|
|
126
|
+
prune: "report-only",
|
|
127
|
+
note: "named Metro store is also an override parent; report only while its child is registered"
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function isLegacyMetroStore(cache) {
|
|
132
|
+
return isMetroStore(cache) && cache.layout === void 0;
|
|
133
|
+
}
|
|
134
|
+
function isCurrentMetroStore(cache) {
|
|
135
|
+
return isMetroStore(cache) && cache.layout === METRO_NAMED_CACHE_LAYOUT;
|
|
136
|
+
}
|
|
137
|
+
function isMetroStore(cache) {
|
|
138
|
+
return cache.source === "registered" && cache.name === "Metro transform cache" && cache.prune === "entries" && cache.entriesDepth === 2;
|
|
139
|
+
}
|
|
140
|
+
function isNamedMetroChild(parent, candidate) {
|
|
141
|
+
const dir = canonicalCacheDir(parent);
|
|
142
|
+
const child = canonicalCacheDir(candidate);
|
|
143
|
+
return dirname(child) === dir || canonicalCacheDir(dirname(candidate)) === dir;
|
|
144
|
+
}
|
|
145
|
+
function mergeCacheDescriptors(caches, gradleDir) {
|
|
146
|
+
const merged = [];
|
|
147
|
+
for (const cache of caches) {
|
|
148
|
+
const descriptor = protectGradleCache(cache, gradleDir);
|
|
149
|
+
const dir = canonicalCacheDir(descriptor.dir);
|
|
150
|
+
const exact = merged.findIndex((candidate) => canonicalCacheDir(candidate.dir) === dir);
|
|
151
|
+
if (exact !== -1) {
|
|
152
|
+
const current = merged[exact];
|
|
153
|
+
if (pruneStrength(descriptor.prune) > pruneStrength(current.prune)) merged[exact] = Object.assign({}, current, {
|
|
154
|
+
prune: descriptor.prune,
|
|
155
|
+
note: descriptor.note
|
|
156
|
+
});
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (gradleDir && isGradleProtectedDescriptor(descriptor, gradleDir)) {
|
|
160
|
+
if (merged.some((candidate) => {
|
|
161
|
+
const candidateDir = canonicalCacheDir(candidate.dir);
|
|
162
|
+
return isGradleProtectedDescriptor(candidate, gradleDir) && cachePathContains(candidateDir, dir);
|
|
163
|
+
})) continue;
|
|
164
|
+
for (let i = merged.length - 1; i >= 0; i--) {
|
|
165
|
+
const candidate = merged[i];
|
|
166
|
+
const candidateDir = canonicalCacheDir(candidate.dir);
|
|
167
|
+
if (isGradleProtectedDescriptor(candidate, gradleDir) && cachePathContains(dir, candidateDir)) merged.splice(i, 1);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
merged.push(descriptor);
|
|
171
|
+
}
|
|
172
|
+
return merged;
|
|
173
|
+
}
|
|
174
|
+
function protectGradleCache(cache, gradleDir) {
|
|
175
|
+
if (cache.files) return cache;
|
|
176
|
+
if (!gradleDir || !cachePathsOverlap(canonicalCacheDir(cache.dir), gradleDir)) return cache;
|
|
177
|
+
return Object.assign({}, cache, {
|
|
178
|
+
prune: "report-only",
|
|
179
|
+
note: "shared with every Gradle build; report only, never pruned or emptied by Stim"
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function isGradleProtectedDescriptor(cache, gradleDir) {
|
|
183
|
+
return cache.prune === "report-only" && cachePathsOverlap(canonicalCacheDir(cache.dir), gradleDir);
|
|
184
|
+
}
|
|
185
|
+
function pruneStrength(prune) {
|
|
186
|
+
if (prune === "report-only") return 3;
|
|
187
|
+
if (prune === "atomic") return 2;
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
190
|
+
function cachePathsOverlap(left, right) {
|
|
191
|
+
return cachePathContains(left, right) || cachePathContains(right, left);
|
|
192
|
+
}
|
|
193
|
+
function cachePathContains(parent, child) {
|
|
194
|
+
const rel = relative(parent, child);
|
|
195
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
196
|
+
}
|
|
197
|
+
function canonicalCacheDir(dir) {
|
|
198
|
+
try {
|
|
199
|
+
return realpathSync(dir);
|
|
200
|
+
} catch {
|
|
201
|
+
return resolve(dir);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function sizeCaches(caches) {
|
|
205
|
+
return caches.map((c) => Object.assign({}, c, { bytes: c.bytes ?? directorySize(c.dir) }));
|
|
206
|
+
}
|
|
207
|
+
function pruneCache(cache, { olderThanDays, now = Date.now() } = {}) {
|
|
208
|
+
const cutoff = now - olderThanDays * 24 * 60 * 60 * 1e3;
|
|
209
|
+
if (cache.prune === "report-only") return {
|
|
210
|
+
removed: 0,
|
|
211
|
+
bytes: 0,
|
|
212
|
+
skipped: "report-only shared cache; Stim never deletes it"
|
|
213
|
+
};
|
|
214
|
+
if (cache.prune === "atomic") return {
|
|
215
|
+
removed: 0,
|
|
216
|
+
bytes: 0,
|
|
217
|
+
skipped: "index-backed; empty it whole or not at all"
|
|
218
|
+
};
|
|
219
|
+
const entries = cache.files ?? entriesAtDepth(cache.dir, cache.entriesDepth ?? 1);
|
|
220
|
+
let removed = 0;
|
|
221
|
+
let bytes = 0;
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
let used;
|
|
224
|
+
let size;
|
|
225
|
+
try {
|
|
226
|
+
const st = statSync(entry);
|
|
227
|
+
used = Math.max(st.atimeMs, st.mtimeMs);
|
|
228
|
+
size = st.isDirectory() ? directorySize(entry) : st.size;
|
|
229
|
+
} catch {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (used >= cutoff) continue;
|
|
233
|
+
try {
|
|
234
|
+
rmSync(entry, {
|
|
235
|
+
recursive: true,
|
|
236
|
+
force: true
|
|
237
|
+
});
|
|
238
|
+
removed++;
|
|
239
|
+
bytes += size;
|
|
240
|
+
} catch {}
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
removed,
|
|
244
|
+
bytes,
|
|
245
|
+
skipped: null
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function entriesAtDepth(dir, depth) {
|
|
249
|
+
let level = [dir];
|
|
250
|
+
for (let i = 0; i < depth; i++) {
|
|
251
|
+
const next = [];
|
|
252
|
+
for (const parent of level) {
|
|
253
|
+
let names;
|
|
254
|
+
try {
|
|
255
|
+
names = readdirSync(parent);
|
|
256
|
+
} catch {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
for (const name of names) next.push(join(parent, name));
|
|
260
|
+
}
|
|
261
|
+
level = next;
|
|
262
|
+
}
|
|
263
|
+
return level;
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/commands/gc/paths.ts
|
|
267
|
+
function canonicalPath(p) {
|
|
268
|
+
try {
|
|
269
|
+
return realpathSync(p);
|
|
270
|
+
} catch {
|
|
271
|
+
return resolve(p);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
//#endregion
|
|
275
|
+
//#region src/commands/gc/caches.ts
|
|
276
|
+
const DAY_MS$1 = 864e5;
|
|
277
|
+
function cacheSweepIsScoped() {
|
|
278
|
+
return Boolean(process.env.STIM_HOME);
|
|
279
|
+
}
|
|
280
|
+
function isInsideConfigDir(dir) {
|
|
281
|
+
const root = canonicalPath(getConfigDir());
|
|
282
|
+
const target = canonicalPath(dir);
|
|
283
|
+
const rel = relative(root, target);
|
|
284
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
285
|
+
}
|
|
286
|
+
function machineGlobalReason(cache) {
|
|
287
|
+
if (!cacheSweepIsScoped()) return null;
|
|
288
|
+
if (cache.source === "registered") return null;
|
|
289
|
+
if (isInsideConfigDir(cache.dir)) return null;
|
|
290
|
+
return `STIM_HOME scopes this config, but ${cache.dir} is outside it and therefore machine-global`;
|
|
291
|
+
}
|
|
292
|
+
const EVERY_CACHE = "all";
|
|
293
|
+
function selectCaches(caches, name) {
|
|
294
|
+
if (!name) return caches;
|
|
295
|
+
const wanted = name.trim().toLowerCase();
|
|
296
|
+
if (wanted === EVERY_CACHE) return caches;
|
|
297
|
+
return caches.filter((c) => c.name.toLowerCase().includes(wanted) || c.dir.toLowerCase().includes(wanted));
|
|
298
|
+
}
|
|
299
|
+
function planCacheEmptying(caches, all) {
|
|
300
|
+
const annotated = caches.map((c) => Object.assign({}, c, { machineGlobal: machineGlobalReason(c) }));
|
|
301
|
+
if (!all) return annotated;
|
|
302
|
+
return annotated.map((c) => {
|
|
303
|
+
if (c.prune === "report-only") return Object.assign({}, c, {
|
|
304
|
+
willEmpty: false,
|
|
305
|
+
emptySkipped: "report-only shared cache; Stim never deletes it"
|
|
306
|
+
});
|
|
307
|
+
if (c.machineGlobal) return Object.assign({}, c, {
|
|
308
|
+
willEmpty: false,
|
|
309
|
+
emptySkipped: c.machineGlobal
|
|
310
|
+
});
|
|
311
|
+
if (!ownsItsDirectory(c)) return Object.assign({}, c, {
|
|
312
|
+
willEmpty: false,
|
|
313
|
+
emptySkipped: `${c.dir} is not a directory this cache owns`
|
|
314
|
+
});
|
|
315
|
+
return Object.assign({}, c, {
|
|
316
|
+
willEmpty: true,
|
|
317
|
+
emptySkipped: null
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
function ownsItsDirectory(cache) {
|
|
322
|
+
if (Array.isArray(cache.files)) return false;
|
|
323
|
+
const dir = canonicalPath(cache.dir);
|
|
324
|
+
if (dirname(dir) === dir) return false;
|
|
325
|
+
return ![
|
|
326
|
+
homedir(),
|
|
327
|
+
tmpdir(),
|
|
328
|
+
getConfigDir()
|
|
329
|
+
].map(canonicalPath).includes(dir);
|
|
330
|
+
}
|
|
331
|
+
function emptyCache(cache) {
|
|
332
|
+
if (cache.prune === "report-only") return {
|
|
333
|
+
removed: 0,
|
|
334
|
+
bytes: 0,
|
|
335
|
+
skipped: "report-only shared cache; Stim never deletes it"
|
|
336
|
+
};
|
|
337
|
+
if (cache.prune !== "atomic") return pruneCache(cache, {
|
|
338
|
+
olderThanDays: 0,
|
|
339
|
+
now: Date.now() + DAY_MS$1
|
|
340
|
+
});
|
|
341
|
+
let names;
|
|
342
|
+
try {
|
|
343
|
+
names = readdirSync(cache.dir);
|
|
344
|
+
} catch (e) {
|
|
345
|
+
return {
|
|
346
|
+
removed: 0,
|
|
347
|
+
bytes: 0,
|
|
348
|
+
skipped: `could not read ${cache.dir}: ${e.message}`
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
let removed = 0;
|
|
352
|
+
let failed = 0;
|
|
353
|
+
for (const name of names) try {
|
|
354
|
+
rmSync(join(cache.dir, name), {
|
|
355
|
+
recursive: true,
|
|
356
|
+
force: true
|
|
357
|
+
});
|
|
358
|
+
removed++;
|
|
359
|
+
} catch {
|
|
360
|
+
failed++;
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
removed,
|
|
364
|
+
bytes: failed ? 0 : cache.bytes ?? 0,
|
|
365
|
+
failed,
|
|
366
|
+
skipped: null
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function trimCaches(caches, olderThan) {
|
|
370
|
+
let cacheBytes = 0;
|
|
371
|
+
for (const c of caches) {
|
|
372
|
+
if (c.machineGlobal) {
|
|
373
|
+
console.log(chalk.yellow(`Left ${c.name} alone: ${c.machineGlobal}`));
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const r = pruneCache(c, { olderThanDays: olderThan });
|
|
377
|
+
if (r.skipped) console.log(chalk.yellow(`Left ${c.name} alone: ${r.skipped}`));
|
|
378
|
+
else if (r.removed) {
|
|
379
|
+
cacheBytes += r.bytes;
|
|
380
|
+
console.log(chalk.green(`Trimmed ${c.name}: ${r.removed} entr${r.removed === 1 ? "y" : "ies"} (${formatBytes(r.bytes)})`));
|
|
381
|
+
} else console.log(chalk.dim(`${c.name}: nothing older than ${olderThan}d`));
|
|
382
|
+
}
|
|
383
|
+
if (cacheBytes) console.log(chalk.dim(`Trimmed ${formatBytes(cacheBytes)} of shared cache. The next build that wanted those entries pays to rebuild them.`));
|
|
384
|
+
}
|
|
385
|
+
function emptyCaches(caches) {
|
|
386
|
+
let cacheBytes = 0;
|
|
387
|
+
for (const c of caches) {
|
|
388
|
+
if (!c.willEmpty) {
|
|
389
|
+
console.log(chalk.yellow(`Left ${c.name} alone: ${c.emptySkipped}`));
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const r = emptyCache(c);
|
|
393
|
+
if (r.skipped) console.log(chalk.yellow(`Left ${c.name} alone: ${r.skipped}`));
|
|
394
|
+
else if (r.removed) {
|
|
395
|
+
cacheBytes += r.bytes;
|
|
396
|
+
console.log(chalk.green(`Emptied ${c.name}: ${r.removed} entr${r.removed === 1 ? "y" : "ies"} (${formatBytes(r.bytes)})`));
|
|
397
|
+
} else console.log(chalk.dim(`${c.name}: already empty`));
|
|
398
|
+
if (r.failed) console.log(chalk.red(` ${r.failed} entr${r.failed === 1 ? "y" : "ies"} in ${c.dir} could not be removed`));
|
|
399
|
+
}
|
|
400
|
+
if (cacheBytes) console.log(chalk.dim(`Emptied ${formatBytes(cacheBytes)} of shared cache. Every build that wanted any of it now pays to rebuild it.`));
|
|
401
|
+
}
|
|
402
|
+
//#endregion
|
|
403
|
+
//#region src/commands/gc/devices.ts
|
|
404
|
+
const DEVICE_LIST_TIMEOUT_MS = 3e4;
|
|
405
|
+
const AVD_SIZE_TIMEOUT_MS = 5e3;
|
|
406
|
+
const DAY_MS = 864e5;
|
|
407
|
+
function describeKept(ref) {
|
|
408
|
+
return ref.mounted ? `referenced by ${ref.path}` : `referenced by ${ref.path} (volume not mounted; kept just in case)`;
|
|
409
|
+
}
|
|
410
|
+
function findOrphanedDevices({ sims = [], avds = [], config, isMounted, deadProjects = [] }) {
|
|
411
|
+
const dead = new Set(deadProjects);
|
|
412
|
+
const referenced = /* @__PURE__ */ new Map();
|
|
413
|
+
for (const [path, proj] of Object.entries(config?.projects || {})) {
|
|
414
|
+
if (dead.has(path)) continue;
|
|
415
|
+
const mounted = isMounted ? isMounted(path) : true;
|
|
416
|
+
const ios = proj?.platforms?.ios;
|
|
417
|
+
if (ios?.deviceUdid) referenced.set(ios.deviceUdid, {
|
|
418
|
+
path,
|
|
419
|
+
mounted
|
|
420
|
+
});
|
|
421
|
+
const android = proj?.platforms?.android;
|
|
422
|
+
if (android?.avdName) referenced.set(android.avdName, {
|
|
423
|
+
path,
|
|
424
|
+
mounted
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
for (const sim of readParked("ios", { config })) referenced.set(sim.udid, {
|
|
428
|
+
path: "the simulator pool",
|
|
429
|
+
mounted: true
|
|
430
|
+
});
|
|
431
|
+
for (const avd of readParked("android", { config })) referenced.set(avd.name, {
|
|
432
|
+
path: "the emulator pool",
|
|
433
|
+
mounted: true
|
|
434
|
+
});
|
|
435
|
+
const orphaned = [];
|
|
436
|
+
const kept = [];
|
|
437
|
+
for (const sim of sims) {
|
|
438
|
+
if (!sim?.name?.startsWith("stim-")) continue;
|
|
439
|
+
const ref = referenced.get(sim.udid);
|
|
440
|
+
if (!ref) orphaned.push({
|
|
441
|
+
kind: "ios",
|
|
442
|
+
id: sim.udid,
|
|
443
|
+
name: sim.name
|
|
444
|
+
});
|
|
445
|
+
else kept.push({
|
|
446
|
+
kind: "ios",
|
|
447
|
+
id: sim.udid,
|
|
448
|
+
name: sim.name,
|
|
449
|
+
reason: describeKept(ref)
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
for (const avdName of avds) {
|
|
453
|
+
if (!avdName?.startsWith("stim-")) continue;
|
|
454
|
+
const ref = referenced.get(avdName);
|
|
455
|
+
if (!ref) orphaned.push({
|
|
456
|
+
kind: "android",
|
|
457
|
+
id: avdName,
|
|
458
|
+
name: avdName
|
|
459
|
+
});
|
|
460
|
+
else kept.push({
|
|
461
|
+
kind: "android",
|
|
462
|
+
id: avdName,
|
|
463
|
+
name: avdName,
|
|
464
|
+
reason: describeKept(ref)
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
orphaned,
|
|
469
|
+
kept
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
function findStaleProjectDevices({ config, sims = [], avds = [], olderThanDays, now = Date.now(), lastTouched, deadProjects = [] }) {
|
|
473
|
+
if (!Number.isFinite(olderThanDays) || typeof lastTouched !== "function") return [];
|
|
474
|
+
const cutoff = now - olderThanDays * DAY_MS;
|
|
475
|
+
const dead = new Set(deadProjects);
|
|
476
|
+
const liveSims = new Map(sims.filter((s) => s?.name?.startsWith("stim-")).map((s) => [s.udid, s.name]));
|
|
477
|
+
const liveAvds = new Set(avds.filter((a) => typeof a === "string" && a.startsWith("stim-")));
|
|
478
|
+
const stale = [];
|
|
479
|
+
for (const [path, proj] of Object.entries(config?.projects || {})) {
|
|
480
|
+
if (dead.has(path) || !isAbsolute(path)) continue;
|
|
481
|
+
const touched = lastTouched(path);
|
|
482
|
+
if (!Number.isFinite(touched) || touched >= cutoff) continue;
|
|
483
|
+
const idleDays = Math.floor((now - touched) / DAY_MS);
|
|
484
|
+
const ios = proj?.platforms?.ios;
|
|
485
|
+
if (ios?.owned && ios.deviceUdid && liveSims.has(ios.deviceUdid)) stale.push({
|
|
486
|
+
kind: "ios",
|
|
487
|
+
id: ios.deviceUdid,
|
|
488
|
+
name: liveSims.get(ios.deviceUdid),
|
|
489
|
+
project: path,
|
|
490
|
+
idleDays
|
|
491
|
+
});
|
|
492
|
+
const android = proj?.platforms?.android;
|
|
493
|
+
if (android?.owned && android.avdName && liveAvds.has(android.avdName)) stale.push({
|
|
494
|
+
kind: "android",
|
|
495
|
+
id: android.avdName,
|
|
496
|
+
name: android.avdName,
|
|
497
|
+
project: path,
|
|
498
|
+
idleDays
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return stale;
|
|
502
|
+
}
|
|
503
|
+
function findStaleDeviceRecords({ config, sims = [], avds = [], deadProjects = [], simsChecked = true, avdsChecked = true }) {
|
|
504
|
+
const dead = new Set(deadProjects);
|
|
505
|
+
const liveSims = new Set(sims.map((s) => s?.udid).filter(Boolean));
|
|
506
|
+
const liveAvds = new Set(avds.filter((a) => typeof a === "string"));
|
|
507
|
+
const stale = [];
|
|
508
|
+
for (const [path, proj] of Object.entries(config?.projects || {})) {
|
|
509
|
+
if (dead.has(path)) continue;
|
|
510
|
+
const ios = proj?.platforms?.ios;
|
|
511
|
+
if (simsChecked && ios?.deviceUdid && !liveSims.has(ios.deviceUdid)) stale.push({
|
|
512
|
+
kind: "ios",
|
|
513
|
+
id: ios.deviceUdid,
|
|
514
|
+
project: path,
|
|
515
|
+
owned: Boolean(ios.owned)
|
|
516
|
+
});
|
|
517
|
+
const android = proj?.platforms?.android;
|
|
518
|
+
if (avdsChecked && android?.avdName && !liveAvds.has(android.avdName)) stale.push({
|
|
519
|
+
kind: "android",
|
|
520
|
+
id: android.avdName,
|
|
521
|
+
project: path,
|
|
522
|
+
owned: Boolean(android.owned)
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
return stale;
|
|
526
|
+
}
|
|
527
|
+
function describeUnverifiableDevices(simNames = [], avdNames = [], { reason = "no Stim config found" } = {}) {
|
|
528
|
+
const ours = [...simNames, ...avdNames].filter((n) => typeof n === "string" && n.startsWith("stim-"));
|
|
529
|
+
if (ours.length === 0) return [`${reason}; device sweep skipped`];
|
|
530
|
+
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>`"];
|
|
531
|
+
}
|
|
532
|
+
function withAndroidAvdSizes(devices, { avdDirectory = ownedAvdDirectory, size = directorySize } = {}) {
|
|
533
|
+
return devices.map((device) => {
|
|
534
|
+
if (device.kind !== "android") return device;
|
|
535
|
+
const dir = device.orphanedDirectory?.directory ?? avdDirectory(device.id);
|
|
536
|
+
if (!dir) return device;
|
|
537
|
+
try {
|
|
538
|
+
const bytes = size(dir, { timeoutMs: AVD_SIZE_TIMEOUT_MS });
|
|
539
|
+
return bytes > 0 ? {
|
|
540
|
+
...device,
|
|
541
|
+
bytes
|
|
542
|
+
} : device;
|
|
543
|
+
} catch {
|
|
544
|
+
return device;
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
function describeParkedSims(records, sims, deviceTypes, { simsChecked = true } = {}) {
|
|
549
|
+
const listed = new Map(sims.map((sim) => [sim.udid, sim]));
|
|
550
|
+
return records.map((record) => {
|
|
551
|
+
const sim = listed.get(record.udid);
|
|
552
|
+
return {
|
|
553
|
+
udid: record.udid,
|
|
554
|
+
name: sim?.name ?? record.name,
|
|
555
|
+
model: deviceTypes.find((d) => d.identifier === record.deviceTypeIdentifier)?.name ?? null,
|
|
556
|
+
runtime: sim ? parseRuntimeVersion(sim.runtime) : parseRuntimeVersion(record.runtimeIdentifier),
|
|
557
|
+
parkedAt: record.parkedAt,
|
|
558
|
+
bytes: typeof sim?.dataPathSize === "number" ? sim.dataPathSize : null,
|
|
559
|
+
listed: simsChecked ? Boolean(sim) : null
|
|
560
|
+
};
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
function collectParkedSims(deps) {
|
|
564
|
+
const records = readParked("ios");
|
|
565
|
+
if (records.length === 0) return [];
|
|
566
|
+
let sims;
|
|
567
|
+
let deviceTypes = [];
|
|
568
|
+
try {
|
|
569
|
+
sims = (deps.listAllIosSims ?? listAllIosSims)({
|
|
570
|
+
timeoutMs: DEVICE_LIST_TIMEOUT_MS,
|
|
571
|
+
includeUnavailable: true
|
|
572
|
+
});
|
|
573
|
+
} catch {
|
|
574
|
+
return describeParkedSims(records, [], [], { simsChecked: false });
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
deviceTypes = (deps.listIosDeviceTypes ?? listIosDeviceTypes)();
|
|
578
|
+
} catch {}
|
|
579
|
+
return describeParkedSims(records, sims, deviceTypes);
|
|
580
|
+
}
|
|
581
|
+
function collectParkedAvds(deps) {
|
|
582
|
+
const records = readParked("android");
|
|
583
|
+
if (!records.length) return [];
|
|
584
|
+
let avds = null;
|
|
585
|
+
try {
|
|
586
|
+
avds = (deps.listAvds ?? listAvds)();
|
|
587
|
+
} catch {}
|
|
588
|
+
return records.map((record) => {
|
|
589
|
+
let bytes = null;
|
|
590
|
+
try {
|
|
591
|
+
const directory = (deps.avdDirectory ?? ownedAvdDirectory)(record.name);
|
|
592
|
+
if (directory) bytes = (deps.directorySize ?? directorySize)(directory, { timeoutMs: AVD_SIZE_TIMEOUT_MS });
|
|
593
|
+
} catch {}
|
|
594
|
+
return {
|
|
595
|
+
name: record.name,
|
|
596
|
+
systemImage: record.systemImage,
|
|
597
|
+
parkedAt: record.parkedAt,
|
|
598
|
+
bytes,
|
|
599
|
+
listed: avds === null ? null : avds.includes(record.name)
|
|
600
|
+
};
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
function deleteParkedAvds(avds) {
|
|
604
|
+
let failures = 0;
|
|
605
|
+
for (const avd of avds) {
|
|
606
|
+
if (avd.listed === null) {
|
|
607
|
+
failures++;
|
|
608
|
+
console.log(chalk.red(`Could not verify parked android emulator ${avd.name}; its pool record was kept.`));
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const result = teardownParkedAvd(avd.name);
|
|
612
|
+
if (result.status === "failed") {
|
|
613
|
+
failures++;
|
|
614
|
+
console.log(chalk.red(`Failed to delete parked android emulator ${avd.name}: ${result.reason}`));
|
|
615
|
+
} else if (result.status === "torn-down") console.log(chalk.green(`Deleted parked android emulator ${avd.name}`));
|
|
616
|
+
else console.log(chalk.dim(`Skipped ${avd.name}; it is no longer parked.`));
|
|
617
|
+
}
|
|
618
|
+
return failures;
|
|
619
|
+
}
|
|
620
|
+
function deleteParkedSims(parkedSims, deps = {}) {
|
|
621
|
+
let failures = 0;
|
|
622
|
+
let emptied = 0;
|
|
623
|
+
for (const sim of parkedSims) {
|
|
624
|
+
if (sim.listed === null) {
|
|
625
|
+
failures++;
|
|
626
|
+
console.log(chalk.red(`Could not verify parked ios sim ${sim.name} (${sim.udid}); its pool record was kept.`));
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
try {
|
|
630
|
+
const teardown = sim.listed ? teardownParkedIosSim(sim.udid, {
|
|
631
|
+
label: sim.name,
|
|
632
|
+
deleteSim: deps.deleteParkedIosSim
|
|
633
|
+
}) : null;
|
|
634
|
+
if (!(sim.listed ? teardown?.status === "torn-down" : dropParked("ios", sim.udid))) {
|
|
635
|
+
if (teardown?.status === "failed") {
|
|
636
|
+
failures++;
|
|
637
|
+
console.log(chalk.red(`Failed to delete parked ios sim ${sim.name}: ${teardown.reason}`));
|
|
638
|
+
} else console.log(chalk.dim(`Skipped ${sim.name} (${sim.udid}); it is no longer parked.`));
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
emptied++;
|
|
642
|
+
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.`));
|
|
643
|
+
} catch (err) {
|
|
644
|
+
failures++;
|
|
645
|
+
console.log(chalk.red(`Failed to delete parked ios sim ${sim.name}: ${err?.message || err}`));
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (emptied) console.log(chalk.dim(` emptied ${plural(emptied, "parked simulator")} from the pool`));
|
|
649
|
+
return failures;
|
|
650
|
+
}
|
|
651
|
+
function deviceSweepIsScoped() {
|
|
652
|
+
return Boolean(process.env.STIM_HOME);
|
|
653
|
+
}
|
|
654
|
+
function collectDeviceLeases(now) {
|
|
655
|
+
const expired = [];
|
|
656
|
+
const kept = [];
|
|
657
|
+
for (const entry of listLeaseFiles()) {
|
|
658
|
+
const lease = entry.lease;
|
|
659
|
+
if (!lease) {
|
|
660
|
+
kept.push({
|
|
661
|
+
name: entry.name,
|
|
662
|
+
path: entry.path,
|
|
663
|
+
reason: "it does not parse as a lease, so no run may take that device"
|
|
664
|
+
});
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
if (leaseIsExpired(lease, now)) {
|
|
668
|
+
expired.push(entry);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (!existsSync(lease.holder)) kept.push({
|
|
672
|
+
name: entry.name,
|
|
673
|
+
path: entry.path,
|
|
674
|
+
reason: `${lease.holder} is not on this machine, but the lease runs until ${lease.expiresAt}`
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
expired,
|
|
679
|
+
kept
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
function deleteProjectDevices(orphanedDevices, staleDevices, staleDeviceRecords) {
|
|
683
|
+
let deleteFailures = 0;
|
|
684
|
+
function reap(d) {
|
|
685
|
+
const r = d.kind === "ios" ? teardownOwnedIosSim(d.id, {
|
|
686
|
+
del: true,
|
|
687
|
+
label: d.name
|
|
688
|
+
}) : teardownOwnedAvd(d.name, {
|
|
689
|
+
del: true,
|
|
690
|
+
..."project" in d ? { owner: { projectPath: d.project } } : {},
|
|
691
|
+
..."orphanedDirectory" in d ? { orphanedDirectory: d.orphanedDirectory } : {}
|
|
692
|
+
});
|
|
693
|
+
const what = d.kind === "ios" ? `ios sim ${d.name} (${d.id})` : `android avd ${d.name}`;
|
|
694
|
+
if (r.status === "torn-down") console.log(chalk.green(`Deleted ${what}`));
|
|
695
|
+
else if (r.status === "missing") console.log(chalk.dim(`${what} is already gone; nothing to delete.`));
|
|
696
|
+
else if (r.status === "skipped") console.log(chalk.yellow(`Skipped ${what}: ${r.reason} -- left for a later gc`));
|
|
697
|
+
else {
|
|
698
|
+
deleteFailures++;
|
|
699
|
+
console.log(chalk.red(`Failed to delete ${d.kind} device ${d.name}: ${r.reason}`));
|
|
700
|
+
}
|
|
701
|
+
return r.status;
|
|
702
|
+
}
|
|
703
|
+
for (const d of orphanedDevices) reap(d);
|
|
704
|
+
for (const d of staleDevices) {
|
|
705
|
+
const status = reap(d);
|
|
706
|
+
if (status === "torn-down" || status === "missing") {
|
|
707
|
+
clearDevice(d.project, d.kind);
|
|
708
|
+
console.log(chalk.dim(` cleared the ${d.kind} record for ${d.project}`));
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
for (const r of staleDeviceRecords) {
|
|
712
|
+
clearDevice(r.project, r.kind);
|
|
713
|
+
console.log(chalk.green(`Cleared the ${r.kind} record for ${r.project} (${r.id} is not on this machine)`));
|
|
714
|
+
}
|
|
715
|
+
return deleteFailures;
|
|
716
|
+
}
|
|
717
|
+
//#endregion
|
|
718
|
+
//#region src/commands/gc/eas-sessions.ts
|
|
719
|
+
const EAS_OPERATION_TIMEOUT_MS = 3e4;
|
|
720
|
+
const EAS_COLLECTION_TIMEOUT_MS = 6e4;
|
|
721
|
+
const EAS_MAX_LIST_PAGES = 100;
|
|
722
|
+
const EAS_LOCK_WAIT_MS = 0;
|
|
723
|
+
const EAS_LOCK_SNAPSHOT_RETRIES = 3;
|
|
724
|
+
const PROXY_CREDENTIAL_ENV = ["AGENT_DEVICE_DAEMON_BASE_URL", "AGENT_DEVICE_DAEMON_AUTH_TOKEN"];
|
|
725
|
+
function describeError(error) {
|
|
726
|
+
const candidate = error;
|
|
727
|
+
if (typeof candidate?.stderr === "string" && candidate.stderr.trim()) return candidate.stderr.trim();
|
|
728
|
+
if (typeof candidate?.message === "string" && candidate.message.trim()) return candidate.message.trim();
|
|
729
|
+
return String(error);
|
|
730
|
+
}
|
|
731
|
+
function readRecordedRemoteSessionIds(roots) {
|
|
732
|
+
const ids = /* @__PURE__ */ new Set();
|
|
733
|
+
const notices = [];
|
|
734
|
+
let safe = true;
|
|
735
|
+
for (const root of new Set(roots)) {
|
|
736
|
+
try {
|
|
737
|
+
if (!statSync(root).isDirectory()) throw new Error("path is not a directory");
|
|
738
|
+
readdirSync(root);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
safe = false;
|
|
741
|
+
notices.push(`${root} is not available as a readable workspace directory: ${describeError(error)}`);
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
const path = workspaceStateFile(root);
|
|
745
|
+
if (!existsSync(path)) continue;
|
|
746
|
+
let state;
|
|
747
|
+
try {
|
|
748
|
+
state = JSON.parse(readFileSync(path, "utf-8"));
|
|
749
|
+
} catch (error) {
|
|
750
|
+
safe = false;
|
|
751
|
+
notices.push(`${path} could not be read as valid JSON: ${describeError(error)}`);
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (!state || typeof state !== "object" || Array.isArray(state)) {
|
|
755
|
+
safe = false;
|
|
756
|
+
notices.push(`${path} does not contain a valid workspace state object.`);
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
const remote = state.remoteDevice;
|
|
760
|
+
if (remote === void 0) continue;
|
|
761
|
+
if (!remote || typeof remote !== "object" || Array.isArray(remote)) {
|
|
762
|
+
safe = false;
|
|
763
|
+
notices.push(`${path} has a malformed remote session record.`);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
const id = remote.sessionId;
|
|
767
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
768
|
+
safe = false;
|
|
769
|
+
notices.push(`${path} has a remote session record with no valid session id.`);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
ids.add(id);
|
|
773
|
+
}
|
|
774
|
+
return {
|
|
775
|
+
ids: [...ids],
|
|
776
|
+
notices,
|
|
777
|
+
safe
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
function claimStateAbsence(claim) {
|
|
781
|
+
const stateDir = dirname(claim.stateFile);
|
|
782
|
+
let entries;
|
|
783
|
+
try {
|
|
784
|
+
if (!statSync(stateDir).isDirectory()) throw new Error("path is not a directory");
|
|
785
|
+
entries = readdirSync(stateDir);
|
|
786
|
+
} catch (error) {
|
|
787
|
+
return {
|
|
788
|
+
absent: false,
|
|
789
|
+
notice: `${stateDir} is not available for EAS ownership verification: ${describeError(error)}`
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
if (!entries.includes(basename(claim.stateFile))) return {
|
|
793
|
+
absent: true,
|
|
794
|
+
notice: null
|
|
795
|
+
};
|
|
796
|
+
let state;
|
|
797
|
+
try {
|
|
798
|
+
state = JSON.parse(readFileSync(claim.stateFile, "utf-8"));
|
|
799
|
+
} catch (error) {
|
|
800
|
+
return {
|
|
801
|
+
absent: false,
|
|
802
|
+
notice: `${claim.stateFile} could not be read as valid JSON: ${describeError(error)}`
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
if (!state || typeof state !== "object" || Array.isArray(state)) return {
|
|
806
|
+
absent: false,
|
|
807
|
+
notice: `${claim.stateFile} does not contain a valid workspace state object.`
|
|
808
|
+
};
|
|
809
|
+
const remote = state.remoteDevice;
|
|
810
|
+
if (remote === void 0) return {
|
|
811
|
+
absent: true,
|
|
812
|
+
notice: null
|
|
813
|
+
};
|
|
814
|
+
if (!remote || typeof remote !== "object" || Array.isArray(remote)) return {
|
|
815
|
+
absent: false,
|
|
816
|
+
notice: `${claim.stateFile} has a malformed remote session record.`
|
|
817
|
+
};
|
|
818
|
+
const sessionId = remote.sessionId;
|
|
819
|
+
if (typeof sessionId !== "string" || !sessionId) return {
|
|
820
|
+
absent: false,
|
|
821
|
+
notice: `${claim.stateFile} has a remote session record with no valid session id.`
|
|
822
|
+
};
|
|
823
|
+
return {
|
|
824
|
+
absent: sessionId !== claim.sessionId,
|
|
825
|
+
notice: null
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function collectEasSessionSweep(config, deps) {
|
|
829
|
+
const projectRoot = (deps.findProjectRoot ?? findProjectRoot)(process.cwd());
|
|
830
|
+
const empty = (notices = []) => ({
|
|
831
|
+
projectScope: projectRoot,
|
|
832
|
+
orphaned: [],
|
|
833
|
+
notices,
|
|
834
|
+
deletionSafe: notices.length === 0
|
|
835
|
+
});
|
|
836
|
+
if (!projectRoot || !(deps.detectIsExpo ?? detectIsExpo)(projectRoot)) return empty();
|
|
837
|
+
if (deps.easSweepBlockedNotice) return empty([deps.easSweepBlockedNotice]);
|
|
838
|
+
const eas = (deps.resolveEasCliBin ?? resolveEasCliBin)(projectRoot);
|
|
839
|
+
if (!eas) return empty(["EAS session sweep skipped: eas-cli is not available for the current Expo project."]);
|
|
840
|
+
const recorded = readRecordedRemoteSessionIds([...Object.keys(config?.projects ?? {}), projectRoot]);
|
|
841
|
+
const ledgerRoot = deps.easLedgerRoot ?? easMachineStateRoot();
|
|
842
|
+
const ledger = readEasSessionLedger(ledgerRoot);
|
|
843
|
+
const run = deps.runEasFile ?? ((file, args, options) => getExecutor().runFile(file, args, options));
|
|
844
|
+
const sessions = [];
|
|
845
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
846
|
+
const now = deps.easNow ?? Date.now;
|
|
847
|
+
const collectionTimeoutMs = deps.easCollectionTimeoutMs ?? EAS_COLLECTION_TIMEOUT_MS;
|
|
848
|
+
const maxPages = deps.easMaxPages ?? EAS_MAX_LIST_PAGES;
|
|
849
|
+
const deadline = now() + collectionTimeoutMs;
|
|
850
|
+
let pageCount = 0;
|
|
851
|
+
let after = null;
|
|
852
|
+
while (true) {
|
|
853
|
+
if (pageCount >= maxPages) return empty([...recorded.notices, `EAS session list exceeded the maximum page limit of ${maxPages} for ${projectRoot}.`]);
|
|
854
|
+
const remainingMs = deadline - now();
|
|
855
|
+
if (remainingMs <= 0) return empty([...recorded.notices, `EAS session list exceeded its total time limit for ${projectRoot}.`]);
|
|
856
|
+
let stdout;
|
|
857
|
+
try {
|
|
858
|
+
stdout = run(eas.file, listOwnedSessionsArgs(after), {
|
|
859
|
+
cwd: projectRoot,
|
|
860
|
+
timeoutMs: Math.min(EAS_OPERATION_TIMEOUT_MS, remainingMs),
|
|
861
|
+
omitEnv: PROXY_CREDENTIAL_ENV
|
|
862
|
+
});
|
|
863
|
+
} catch (error) {
|
|
864
|
+
return empty([...recorded.notices, `EAS session list failed for ${projectRoot}: ${describeError(error)}`]);
|
|
865
|
+
}
|
|
866
|
+
pageCount++;
|
|
867
|
+
if (now() > deadline) return empty([...recorded.notices, `EAS session list exceeded its total time limit for ${projectRoot}.`]);
|
|
868
|
+
const parsed = parseSessionListPage(stdout);
|
|
869
|
+
if (!parsed.ok) return empty([...recorded.notices, parsed.reason]);
|
|
870
|
+
sessions.push(...parsed.page.sessions);
|
|
871
|
+
if (!parsed.page.hasNextPage) break;
|
|
872
|
+
const cursor = parsed.page.endCursor?.trim();
|
|
873
|
+
if (!cursor) return empty([...recorded.notices, "EAS session list has a next page but returned no pagination cursor."]);
|
|
874
|
+
if (seenCursors.has(cursor)) return empty([...recorded.notices, `EAS session list repeated pagination cursor ${cursor}.`]);
|
|
875
|
+
seenCursors.add(cursor);
|
|
876
|
+
after = cursor;
|
|
877
|
+
}
|
|
878
|
+
const lockedRoots = deps.easLockedRoots;
|
|
879
|
+
if (lockedRoots) {
|
|
880
|
+
if (easLockRoots(projectRoot, loadConfig()).some((root) => !lockedRoots.includes(root))) return empty([...recorded.notices, "EAS session sweep skipped: registered workspace roots changed after remote-session locks were acquired."]);
|
|
881
|
+
}
|
|
882
|
+
const compared = findOrphanedOwnedSessions({
|
|
883
|
+
sessions,
|
|
884
|
+
recordedSessionIds: recorded.ids,
|
|
885
|
+
projectScope: projectRoot
|
|
886
|
+
});
|
|
887
|
+
const notices = [...recorded.notices, ...compared.notices];
|
|
888
|
+
if (ledger.notice) notices.push(ledger.notice);
|
|
889
|
+
const orphaned = [];
|
|
890
|
+
if (ledger.safe && recorded.safe) for (const session of compared.orphaned) {
|
|
891
|
+
const claim = ledger.claims.get(session.id);
|
|
892
|
+
if (!claim || claim.name !== session.name || claim.platform !== session.platform) {
|
|
893
|
+
notices.push(`EAS session ${session.id} (${session.name}) is unclaimed or its fixed ownership record does not match; it was not selected for deletion.`);
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
const state = claimStateAbsence(claim);
|
|
897
|
+
if (state.notice) {
|
|
898
|
+
notices.push(state.notice);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
if (state.absent) orphaned.push(session);
|
|
902
|
+
}
|
|
903
|
+
return {
|
|
904
|
+
projectScope: projectRoot,
|
|
905
|
+
orphaned,
|
|
906
|
+
notices,
|
|
907
|
+
deletionSafe: recorded.safe && ledger.safe
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
function easLockRoots(projectRoot, config) {
|
|
911
|
+
return [...new Set([...Object.keys(config?.projects ?? {}), projectRoot].map(canonicalPath))].toSorted();
|
|
912
|
+
}
|
|
913
|
+
function unavailableLockRootNotice(roots) {
|
|
914
|
+
for (const root of roots) try {
|
|
915
|
+
if (!statSync(root).isDirectory()) throw new Error("path is not a directory");
|
|
916
|
+
readdirSync(root);
|
|
917
|
+
} catch (error) {
|
|
918
|
+
return `EAS session sweep skipped: ${root} is not available for remote-session lock inspection: ${describeError(error)}`;
|
|
919
|
+
}
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
function malformedRemoteSessionLockNotice(roots) {
|
|
923
|
+
for (const root of roots) {
|
|
924
|
+
const lockPath = join(workspaceDir(root), "remote-session.lock");
|
|
925
|
+
if (!existsSync(lockPath)) continue;
|
|
926
|
+
if (readClaimSet(lockPath).unresolved.length === 0) continue;
|
|
927
|
+
return `EAS session sweep skipped: the remote-session lock at ${lockPath} has a malformed owner record.`;
|
|
928
|
+
}
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
async function withRemoteSessionGcLocks(projectRoot, deps, fn) {
|
|
932
|
+
const withLock = deps.withRemoteSessionLock ?? withRemoteSessionLock;
|
|
933
|
+
const waitMs = deps.easLockWaitMs ?? EAS_LOCK_WAIT_MS;
|
|
934
|
+
const maxSnapshots = deps.easLockSnapshotRetries ?? EAS_LOCK_SNAPSHOT_RETRIES;
|
|
935
|
+
for (let attempt = 0; attempt < maxSnapshots; attempt++) {
|
|
936
|
+
const roots = easLockRoots(projectRoot, loadConfig());
|
|
937
|
+
const unavailable = unavailableLockRootNotice(roots);
|
|
938
|
+
if (unavailable) return fn({
|
|
939
|
+
...deps,
|
|
940
|
+
easSweepBlockedNotice: unavailable
|
|
941
|
+
});
|
|
942
|
+
const malformed = malformedRemoteSessionLockNotice(roots);
|
|
943
|
+
if (malformed) return fn({
|
|
944
|
+
...deps,
|
|
945
|
+
easSweepBlockedNotice: malformed
|
|
946
|
+
});
|
|
947
|
+
let expanded = false;
|
|
948
|
+
let coreStarted = false;
|
|
949
|
+
const acquire = (index) => index === roots.length ? (async () => {
|
|
950
|
+
if (easLockRoots(projectRoot, loadConfig()).some((root) => !roots.includes(root))) {
|
|
951
|
+
expanded = true;
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
coreStarted = true;
|
|
955
|
+
return fn({
|
|
956
|
+
...deps,
|
|
957
|
+
easSweepBlockedNotice: void 0,
|
|
958
|
+
easLockedRoots: roots
|
|
959
|
+
});
|
|
960
|
+
})() : withLock(roots[index], () => acquire(index + 1), { waitMs });
|
|
961
|
+
try {
|
|
962
|
+
const result = await acquire(0);
|
|
963
|
+
if (!expanded) return result;
|
|
964
|
+
} catch (error) {
|
|
965
|
+
if (coreStarted) throw error;
|
|
966
|
+
return fn({
|
|
967
|
+
...deps,
|
|
968
|
+
easSweepBlockedNotice: `EAS session sweep skipped: remote-session lock acquisition failed: ${describeError(error)}`
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return fn({
|
|
973
|
+
...deps,
|
|
974
|
+
easSweepBlockedNotice: `EAS session sweep skipped: registered workspace roots kept changing during remote-session lock acquisition.`
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
async function deleteEasSessions(easSessionSweep, deps) {
|
|
978
|
+
let deleteFailures = 0;
|
|
979
|
+
if (easSessionSweep.orphaned.length && easSessionSweep.deletionSafe && easSessionSweep.projectScope) {
|
|
980
|
+
const projectScope = easSessionSweep.projectScope;
|
|
981
|
+
const withProjectLock = deps.withEasProjectLock ?? withEasProjectLock;
|
|
982
|
+
let finalSweepStarted = false;
|
|
983
|
+
try {
|
|
984
|
+
await withProjectLock(projectScope, () => {
|
|
985
|
+
finalSweepStarted = true;
|
|
986
|
+
return withRemoteSessionGcLocks(projectScope, deps, async (lockedDeps) => {
|
|
987
|
+
if (lockedDeps.easSweepBlockedNotice) {
|
|
988
|
+
deleteFailures += easSessionSweep.orphaned.length;
|
|
989
|
+
console.log(chalk.red(`EAS session deletion refused: ${lockedDeps.easSweepBlockedNotice}`));
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const coordinatedDeps = lockedDeps;
|
|
993
|
+
const currentConfig = loadConfig();
|
|
994
|
+
const currentRoots = easLockRoots(projectScope, currentConfig);
|
|
995
|
+
const registryExpanded = coordinatedDeps.easLockedRoots && currentRoots.some((root) => !coordinatedDeps.easLockedRoots.includes(root));
|
|
996
|
+
const currentRecords = registryExpanded ? null : readRecordedRemoteSessionIds([...Object.keys(currentConfig?.projects ?? {}), projectScope]);
|
|
997
|
+
const ledgerRoot = coordinatedDeps.easLedgerRoot ?? easMachineStateRoot();
|
|
998
|
+
const ledger = readEasSessionLedger(ledgerRoot);
|
|
999
|
+
const removeClaim = coordinatedDeps.removeEasSessionClaim ?? removeEasSessionClaim;
|
|
1000
|
+
const reconcileClaim = (sessionId) => {
|
|
1001
|
+
let detail = "the claim store did not remove it";
|
|
1002
|
+
try {
|
|
1003
|
+
if (removeClaim(sessionId, ledgerRoot)) return;
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
detail = describeError(error);
|
|
1006
|
+
}
|
|
1007
|
+
deleteFailures++;
|
|
1008
|
+
console.log(chalk.red(`EAS session ${sessionId} is resolved, but its ownership claim could not be removed: ${detail}. The claim was retained for reconciliation.`));
|
|
1009
|
+
};
|
|
1010
|
+
if (registryExpanded) {
|
|
1011
|
+
deleteFailures += easSessionSweep.orphaned.length;
|
|
1012
|
+
console.log(chalk.red("EAS session deletion refused: registered workspace roots changed after classification."));
|
|
1013
|
+
} else if (!currentRecords?.safe || !ledger.safe) {
|
|
1014
|
+
deleteFailures += easSessionSweep.orphaned.length;
|
|
1015
|
+
for (const notice of [...currentRecords?.notices ?? [], ...ledger.notice ? [ledger.notice] : []]) console.log(chalk.red(`EAS session deletion refused: ${notice}`));
|
|
1016
|
+
} else {
|
|
1017
|
+
const recorded = new Set(currentRecords.ids);
|
|
1018
|
+
const eas = (coordinatedDeps.resolveEasCliBin ?? resolveEasCliBin)(projectScope);
|
|
1019
|
+
const run = coordinatedDeps.runEasFile ?? ((file, args, options) => getExecutor().runFile(file, args, options));
|
|
1020
|
+
for (const session of easSessionSweep.orphaned) {
|
|
1021
|
+
if (recorded.has(session.id)) {
|
|
1022
|
+
console.log(chalk.dim(`EAS session ${session.id} has a workspace record and was left running.`));
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
const claim = ledger.claims.get(session.id);
|
|
1026
|
+
const claimState = claim ? claimStateAbsence(claim) : null;
|
|
1027
|
+
if (!claim || claim.name !== session.name || claim.platform !== session.platform || !claimState?.absent || claimState.notice) {
|
|
1028
|
+
deleteFailures++;
|
|
1029
|
+
console.log(chalk.red(`Could not verify fixed ownership for EAS session ${session.id}: ${claimState?.notice ?? "the claim is missing, mismatched, or still recorded in workspace state"}.`));
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
if (!eas) {
|
|
1033
|
+
deleteFailures++;
|
|
1034
|
+
console.log(chalk.red(`Could not verify EAS session ${session.id}: eas-cli is not available.`));
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
const options = {
|
|
1038
|
+
cwd: session.projectScope,
|
|
1039
|
+
timeoutMs: EAS_OPERATION_TIMEOUT_MS,
|
|
1040
|
+
omitEnv: PROXY_CREDENTIAL_ENV
|
|
1041
|
+
};
|
|
1042
|
+
let live;
|
|
1043
|
+
try {
|
|
1044
|
+
live = run(eas.file, getSessionArgs(session.id), options);
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
if (isDefinitiveMissingSessionError(error, session.id)) {
|
|
1047
|
+
console.log(chalk.dim(`EAS session ${session.id} is already gone.`));
|
|
1048
|
+
reconcileClaim(session.id);
|
|
1049
|
+
} else {
|
|
1050
|
+
deleteFailures++;
|
|
1051
|
+
console.log(chalk.red(`Could not verify EAS session ${session.id}: ${describeError(error)}`));
|
|
1052
|
+
}
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
const inspection = inspectSessionForTeardown(live, session.id);
|
|
1056
|
+
if (inspection.action === "already-stopped") {
|
|
1057
|
+
console.log(chalk.dim(`EAS session ${session.id} is already stopped (${inspection.status}).`));
|
|
1058
|
+
reconcileClaim(session.id);
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
if (inspection.action === "refused") {
|
|
1062
|
+
deleteFailures++;
|
|
1063
|
+
console.log(chalk.red(`Could not stop EAS session ${session.id}: ${inspection.reason}`));
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
let stopped;
|
|
1067
|
+
try {
|
|
1068
|
+
stopped = run(eas.file, stopSessionArgs(session.id), options);
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
deleteFailures++;
|
|
1071
|
+
console.log(chalk.red(`Could not stop EAS session ${session.id}: ${describeError(error)}`));
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
const verified = verifyStoppedSession(stopped, session.id);
|
|
1075
|
+
if (!verified.ok) {
|
|
1076
|
+
deleteFailures++;
|
|
1077
|
+
console.log(chalk.red(`Could not verify EAS session ${session.id} stopped: ${verified.reason}`));
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
console.log(chalk.green(`Stopped EAS session ${session.id} (${session.name})`));
|
|
1081
|
+
reconcileClaim(session.id);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
}, {
|
|
1086
|
+
waitMs: 0,
|
|
1087
|
+
ownerPurpose: "EAS orphan deletion",
|
|
1088
|
+
machineRoot: deps.easLedgerRoot
|
|
1089
|
+
});
|
|
1090
|
+
} catch (error) {
|
|
1091
|
+
deleteFailures += easSessionSweep.orphaned.length;
|
|
1092
|
+
const phase = finalSweepStarted ? "failed" : "lock acquisition failed";
|
|
1093
|
+
console.log(chalk.red(`EAS session deletion ${phase}: ${describeError(error)}`));
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
return deleteFailures;
|
|
1097
|
+
}
|
|
1098
|
+
//#endregion
|
|
1099
|
+
//#region src/commands/gc/report.ts
|
|
1100
|
+
function shortKey(key) {
|
|
1101
|
+
const text = String(key ?? "");
|
|
1102
|
+
return text.length > 6 ? `${text.slice(0, 6)}..` : text;
|
|
1103
|
+
}
|
|
1104
|
+
function parkedAge(parkedAt, now) {
|
|
1105
|
+
const at = Date.parse(parkedAt);
|
|
1106
|
+
if (!Number.isFinite(at)) return "parked at an unknown time";
|
|
1107
|
+
return `parked ${formatLongDuration(Math.max(0, now - at))} ago`;
|
|
1108
|
+
}
|
|
1109
|
+
function formatParkedSimReport(parkedSims, now) {
|
|
1110
|
+
if (parkedSims.length === 0) return [];
|
|
1111
|
+
const known = parkedSims.filter((sim) => sim.bytes !== null);
|
|
1112
|
+
const total = known.reduce((sum, sim) => sum + (sim.bytes ?? 0), 0);
|
|
1113
|
+
const size = known.length === parkedSims.length ? `, ${formatBytes(total)}` : "";
|
|
1114
|
+
const lines = [`Parked simulators (${parkedSims.length}${size}):`];
|
|
1115
|
+
for (const sim of parkedSims) {
|
|
1116
|
+
const model = [sim.model, sim.runtime].filter(Boolean).join(" ");
|
|
1117
|
+
const age = parkedAge(sim.parkedAt, now);
|
|
1118
|
+
const bytes = sim.bytes === null ? "" : ` ${formatBytes(sim.bytes)}`;
|
|
1119
|
+
const gone = sim.listed === false ? " - not on this machine" : sim.listed === null ? " - listing unavailable; kept" : "";
|
|
1120
|
+
lines.push(` ios ${sim.name} (${shortUdid(sim.udid)})${model ? ` ${model}` : ""} ${age}${bytes}${gone}`);
|
|
1121
|
+
}
|
|
1122
|
+
lines.push(" --delete attempts verified deletions and keeps failures.");
|
|
1123
|
+
return lines;
|
|
1124
|
+
}
|
|
1125
|
+
function formatParkedAvdReport(parkedAvds, now) {
|
|
1126
|
+
if (!parkedAvds.length) return [];
|
|
1127
|
+
const lines = [];
|
|
1128
|
+
lines.push(`Parked emulators (${parkedAvds.length}):`);
|
|
1129
|
+
for (const avd of parkedAvds) {
|
|
1130
|
+
const listed = avd.listed === false ? " - not on this machine" : avd.listed === null ? " - listing unavailable; kept" : "";
|
|
1131
|
+
lines.push(` android ${avd.name} ${avd.systemImage} ${parkedAge(avd.parkedAt, now)}${avd.bytes === null ? "" : ` ${formatBytes(avd.bytes)}`}${listed}`);
|
|
1132
|
+
}
|
|
1133
|
+
lines.push(" --delete attempts verified deletions and keeps failures.");
|
|
1134
|
+
return lines;
|
|
1135
|
+
}
|
|
1136
|
+
function projectEntryLines(header, paths) {
|
|
1137
|
+
return paths.length ? [header, ...paths.map((path) => ` ${path}`)] : [];
|
|
1138
|
+
}
|
|
1139
|
+
function unresolvedLockLines(locks, slots) {
|
|
1140
|
+
const entries = [...locks.unresolved ?? [], ...slots.unresolved ?? []];
|
|
1141
|
+
if (entries.length === 0) return [];
|
|
1142
|
+
return [
|
|
1143
|
+
`Build locks and slots Stim cannot resolve (${entries.length}) - NOT touched, because a dead holder and a live one cannot be told apart:`,
|
|
1144
|
+
...entries.map((entry) => ` ${entry.path}`),
|
|
1145
|
+
" remove one yourself once you know nothing is building with it"
|
|
1146
|
+
];
|
|
1147
|
+
}
|
|
1148
|
+
function formatGcReport({ skipped = [], deadProjects = [], invalidProjects = [], parkedSims = [], parkedAvds = [], orphanedDevices = [], staleDevices = [], staleDeviceRecords = [], buildLocks = {
|
|
1149
|
+
stale: [],
|
|
1150
|
+
live: [],
|
|
1151
|
+
unresolved: []
|
|
1152
|
+
}, buildSlots = {
|
|
1153
|
+
stale: [],
|
|
1154
|
+
live: [],
|
|
1155
|
+
unresolved: []
|
|
1156
|
+
}, deviceLeases = {
|
|
1157
|
+
expired: [],
|
|
1158
|
+
kept: []
|
|
1159
|
+
}, deviceSweepNotices = [], easSessionSweep = {
|
|
1160
|
+
projectScope: null,
|
|
1161
|
+
orphaned: [],
|
|
1162
|
+
notices: [],
|
|
1163
|
+
deletionSafe: true
|
|
1164
|
+
}, caches = [], cacheScope = null, olderThan = null }, { now = Date.now() } = {}) {
|
|
1165
|
+
const lines = [];
|
|
1166
|
+
const staleLocks = buildLocks?.stale ?? [];
|
|
1167
|
+
const liveLocks = buildLocks?.live ?? [];
|
|
1168
|
+
const staleSlots = buildSlots?.stale ?? [];
|
|
1169
|
+
const expiredLeases = deviceLeases?.expired ?? [];
|
|
1170
|
+
if (cacheScope) lines.push(`Cache scope: "${cacheScope}". Devices, project entries and locks were not inspected.`);
|
|
1171
|
+
else if (deadProjects.length === 0 && invalidProjects.length === 0 && parkedSims.length === 0 && parkedAvds.length === 0 && orphanedDevices.length === 0 && staleDevices.length === 0 && staleDeviceRecords.length === 0 && staleLocks.length === 0 && staleSlots.length === 0 && expiredLeases.length === 0 && easSessionSweep.orphaned.length === 0) {
|
|
1172
|
+
const reasons = [];
|
|
1173
|
+
if (skipped.length > 0) reasons.push(`${skipped.length} entr${skipped.length === 1 ? "y" : "ies"} could not be checked`);
|
|
1174
|
+
if (deviceSweepNotices.length > 0) reasons.push("device sweep incomplete");
|
|
1175
|
+
if (easSessionSweep.notices.length > 0) reasons.push("EAS session sweep incomplete");
|
|
1176
|
+
if (reasons.length > 0) lines.push(`Nothing to reclaim (${reasons.join("; ")}; see below).`);
|
|
1177
|
+
else lines.push("Nothing to reclaim.");
|
|
1178
|
+
}
|
|
1179
|
+
lines.push(...projectEntryLines(`Dead project entries (${deadProjects.length}):`, deadProjects));
|
|
1180
|
+
lines.push(...projectEntryLines(`Invalid project entries (${invalidProjects.length}) - the key is not an absolute path:`, invalidProjects));
|
|
1181
|
+
lines.push(...formatParkedSimReport(parkedSims, now));
|
|
1182
|
+
lines.push(...formatParkedAvdReport(parkedAvds, now));
|
|
1183
|
+
if (orphanedDevices.length) {
|
|
1184
|
+
lines.push(`Orphaned devices (${orphanedDevices.length}):`);
|
|
1185
|
+
for (const d of orphanedDevices) lines.push(` ${d.kind} ${d.name} (${d.id})${deviceSizeSuffix(d)}`);
|
|
1186
|
+
}
|
|
1187
|
+
if (staleDevices.length) {
|
|
1188
|
+
lines.push(`Stale owned devices (${staleDevices.length}) - project untouched for ${olderThan ?? "?"}d or more:`);
|
|
1189
|
+
for (const d of staleDevices) {
|
|
1190
|
+
lines.push(` ${d.kind} ${d.name} (${d.id})${deviceSizeSuffix(d)}`);
|
|
1191
|
+
lines.push(` ${d.project} (idle ${d.idleDays}d)`);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
if (staleDeviceRecords.length) {
|
|
1195
|
+
lines.push(`Stale device records (${staleDeviceRecords.length}) - the device is gone, the project is not:`);
|
|
1196
|
+
for (const r of staleDeviceRecords) {
|
|
1197
|
+
lines.push(` ${r.kind} ${r.id} is not on this machine`);
|
|
1198
|
+
lines.push(` recorded by ${r.project}`);
|
|
1199
|
+
}
|
|
1200
|
+
lines.push(" --delete clears the RECORD only; there is no device left to touch.");
|
|
1201
|
+
}
|
|
1202
|
+
if (easSessionSweep.orphaned.length) {
|
|
1203
|
+
lines.push(`Orphaned EAS sessions (${easSessionSweep.orphaned.length}) - current EAS project only:`);
|
|
1204
|
+
for (const session of easSessionSweep.orphaned) {
|
|
1205
|
+
const details = [session.platform, session.status].filter(Boolean).join(", ");
|
|
1206
|
+
lines.push(` ${session.name} (${session.id})${details ? ` [${details}]` : ""}`);
|
|
1207
|
+
lines.push(` project scope: ${session.projectScope}`);
|
|
1208
|
+
lines.push(` remedy: eas simulator:stop --id ${session.id}`);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
if (staleLocks.length) {
|
|
1212
|
+
lines.push(`Stale build locks (${staleLocks.length}) - the process that was building is gone:`);
|
|
1213
|
+
for (const lock of staleLocks) {
|
|
1214
|
+
lines.push(` ${lock.platform} ${shortKey(lock.key)} (pid ${lock.pid ?? "?"} is not running)`);
|
|
1215
|
+
lines.push(` started by ${lock.projectRoot || "an unrecorded workspace"}`);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
if (staleSlots.length) {
|
|
1219
|
+
lines.push(`Stale build slots (${staleSlots.length}) - the process that was building is gone:`);
|
|
1220
|
+
for (const slot of staleSlots) {
|
|
1221
|
+
lines.push(` slot ${slot.index ?? "?"} (pid ${slot.pid ?? "?"} is not running)`);
|
|
1222
|
+
lines.push(` held by ${slot.projectRoot || "an unrecorded workspace"}`);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
lines.push(...unresolvedLockLines(buildLocks, buildSlots));
|
|
1226
|
+
if (liveLocks.length) {
|
|
1227
|
+
lines.push(`Builds in progress (${liveLocks.length}) - NOT touched, by anything:`);
|
|
1228
|
+
for (const lock of liveLocks) {
|
|
1229
|
+
lines.push(` ${lock.platform} ${shortKey(lock.key)} (pid ${lock.pid})`);
|
|
1230
|
+
lines.push(` building in ${lock.projectRoot || "an unrecorded workspace"}`);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
lines.push(...leaseGarbageLines(deviceLeases));
|
|
1234
|
+
if (deviceSweepNotices.length) {
|
|
1235
|
+
lines.push(`Device sweep notices (${deviceSweepNotices.length}):`);
|
|
1236
|
+
for (const notice of deviceSweepNotices) lines.push(` ${notice}`);
|
|
1237
|
+
}
|
|
1238
|
+
if (easSessionSweep.notices.length) {
|
|
1239
|
+
lines.push(`EAS session sweep notices (${easSessionSweep.notices.length}):`);
|
|
1240
|
+
for (const notice of easSessionSweep.notices) lines.push(` ${notice}`);
|
|
1241
|
+
}
|
|
1242
|
+
if (skipped.length) {
|
|
1243
|
+
lines.push(`Skipped (${skipped.length}) - not classified as dead:`);
|
|
1244
|
+
for (const entry of skipped) lines.push(` ${entry.dir}: ${entry.reason}`);
|
|
1245
|
+
}
|
|
1246
|
+
if (caches.length) {
|
|
1247
|
+
const total = caches.reduce((n, c) => n + (c.bytes ?? 0), 0);
|
|
1248
|
+
lines.push(`Shared build caches (${caches.length}) - alive, not garbage:`);
|
|
1249
|
+
for (const c of caches) {
|
|
1250
|
+
const tag = c.source ? ` (${c.source})` : "";
|
|
1251
|
+
lines.push(` ${formatBytes(c.bytes ?? 0).padStart(10)} ${c.name}${tag}`);
|
|
1252
|
+
lines.push(` ${c.dir}`);
|
|
1253
|
+
if (c.note) lines.push(` ${c.note}`);
|
|
1254
|
+
if (c.willEmpty) lines.push(" would be EMPTIED");
|
|
1255
|
+
else if (c.emptySkipped) lines.push(` would be left alone: ${c.emptySkipped}`);
|
|
1256
|
+
}
|
|
1257
|
+
lines.push(` total: ${formatBytes(total)}`);
|
|
1258
|
+
const doomed = caches.filter((c) => c.willEmpty);
|
|
1259
|
+
if (doomed.length) {
|
|
1260
|
+
const doomedBytes = doomed.reduce((n, c) => n + (c.bytes ?? 0), 0);
|
|
1261
|
+
lines.push(` would empty ${doomed.length} of these (${formatBytes(doomedBytes)})`);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return lines;
|
|
1265
|
+
}
|
|
1266
|
+
function leaseGarbageLines({ expired, kept }) {
|
|
1267
|
+
const lines = [];
|
|
1268
|
+
if (expired.length) {
|
|
1269
|
+
lines.push(`Expired device leases (${expired.length}) - the device is already free:`);
|
|
1270
|
+
for (const entry of expired) {
|
|
1271
|
+
const name = entry.lease?.deviceName ? ` (${entry.lease.deviceName})` : "";
|
|
1272
|
+
lines.push(` ${entry.platform} ${entry.id ?? entry.name}${name}`);
|
|
1273
|
+
lines.push(` held by ${entry.lease?.holder ?? "an unrecorded workspace"} until ${entry.lease?.expiresAt}`);
|
|
1274
|
+
}
|
|
1275
|
+
lines.push(" --delete removes the FILE only; an expired lease already holds nothing.");
|
|
1276
|
+
}
|
|
1277
|
+
if (kept.length) {
|
|
1278
|
+
lines.push(`Device lease files kept (${kept.length}) - reported, never deleted:`);
|
|
1279
|
+
for (const entry of kept) lines.push(` ${entry.name}: ${entry.reason}`);
|
|
1280
|
+
}
|
|
1281
|
+
return lines;
|
|
1282
|
+
}
|
|
1283
|
+
function deviceSizeSuffix(device) {
|
|
1284
|
+
return device.kind === "android" && device.bytes !== void 0 ? ` - ${formatBytes(device.bytes)} on disk` : "";
|
|
1285
|
+
}
|
|
1286
|
+
//#endregion
|
|
1287
|
+
//#region src/commands/gc.ts
|
|
1288
|
+
function removeInvalidProjectEntries(invalidProjects) {
|
|
1289
|
+
for (const path of invalidProjects) {
|
|
1290
|
+
removeProject(path);
|
|
1291
|
+
console.log(chalk.green(`Removed the invalid registry entry ${path}`));
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
function projectLastTouched(path) {
|
|
1295
|
+
try {
|
|
1296
|
+
return statSync(path).mtimeMs;
|
|
1297
|
+
} catch {
|
|
1298
|
+
return NaN;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
async function collectGcReport({ olderThan = null, cache = null, now = Date.now(), lastTouched = projectLastTouched } = {}, deps = {}) {
|
|
1302
|
+
const scope = typeof cache === "string" && cache.trim() ? cache : null;
|
|
1303
|
+
const all = scope !== null && olderThan === null;
|
|
1304
|
+
const caches = planCacheEmptying(sizeCaches(selectCaches(discoverCaches({ declared: declaredCachePaths() }), scope)), all);
|
|
1305
|
+
if (scope) return {
|
|
1306
|
+
skipped: [],
|
|
1307
|
+
deadProjects: [],
|
|
1308
|
+
invalidProjects: [],
|
|
1309
|
+
orphanedDevices: [],
|
|
1310
|
+
staleDevices: [],
|
|
1311
|
+
staleDeviceRecords: [],
|
|
1312
|
+
buildLocks: {
|
|
1313
|
+
stale: [],
|
|
1314
|
+
live: [],
|
|
1315
|
+
unresolved: []
|
|
1316
|
+
},
|
|
1317
|
+
buildSlots: {
|
|
1318
|
+
stale: [],
|
|
1319
|
+
live: [],
|
|
1320
|
+
unresolved: []
|
|
1321
|
+
},
|
|
1322
|
+
deviceLeases: {
|
|
1323
|
+
expired: [],
|
|
1324
|
+
kept: []
|
|
1325
|
+
},
|
|
1326
|
+
deviceSweepNotices: [],
|
|
1327
|
+
easSessionSweep: {
|
|
1328
|
+
projectScope: null,
|
|
1329
|
+
orphaned: [],
|
|
1330
|
+
notices: [],
|
|
1331
|
+
deletionSafe: true
|
|
1332
|
+
},
|
|
1333
|
+
parkedSims: [],
|
|
1334
|
+
parkedAvds: [],
|
|
1335
|
+
caches,
|
|
1336
|
+
cacheScope: scope,
|
|
1337
|
+
olderThan,
|
|
1338
|
+
all
|
|
1339
|
+
};
|
|
1340
|
+
const mountedVolumes = listMountedVolumes();
|
|
1341
|
+
const cfg = loadConfig();
|
|
1342
|
+
let easSessionSweep = deps.precollectedEasSessionSweep;
|
|
1343
|
+
if (!easSessionSweep) try {
|
|
1344
|
+
easSessionSweep = collectEasSessionSweep(cfg, deps);
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
easSessionSweep = {
|
|
1347
|
+
projectScope: null,
|
|
1348
|
+
orphaned: [],
|
|
1349
|
+
notices: [`EAS session sweep failed: ${describeError(error)}`],
|
|
1350
|
+
deletionSafe: false
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
const parkedSims = collectParkedSims(deps);
|
|
1354
|
+
const parkedAvds = collectParkedAvds(deps);
|
|
1355
|
+
const deadProjects = [];
|
|
1356
|
+
const invalidProjects = [];
|
|
1357
|
+
const skipped = [];
|
|
1358
|
+
for (const [path, project] of Object.entries(cfg?.projects || {})) {
|
|
1359
|
+
if (!isAbsolute(path)) {
|
|
1360
|
+
const claimed = describeDereferenced(project);
|
|
1361
|
+
if (claimed.length) skipped.push({
|
|
1362
|
+
dir: path,
|
|
1363
|
+
reason: `not an absolute path, so this record is invalid; kept because it still claims ${claimed.join(" and ")}; gc removes it once that claim is gone, or drop the entry from the config file by hand`
|
|
1364
|
+
});
|
|
1365
|
+
else invalidProjects.push(path);
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
if (existsSync(path)) continue;
|
|
1369
|
+
if (!isOnMountedVolume(path, mountedVolumes)) {
|
|
1370
|
+
const volume = volumeRootFor(path);
|
|
1371
|
+
skipped.push({
|
|
1372
|
+
dir: path,
|
|
1373
|
+
reason: `volume ${volume} is not mounted`
|
|
1374
|
+
});
|
|
1375
|
+
} else deadProjects.push(path);
|
|
1376
|
+
}
|
|
1377
|
+
const deviceSweepNotices = [];
|
|
1378
|
+
let orphanedDevices = [];
|
|
1379
|
+
let staleDevices = [];
|
|
1380
|
+
let staleDeviceRecords = [];
|
|
1381
|
+
const unsweepableReason = cfg === null ? "no Stim config found" : deviceSweepIsScoped() ? "STIM_HOME scopes this config, but simulators and AVDs are machine-global" : null;
|
|
1382
|
+
if (unsweepableReason) {
|
|
1383
|
+
let simNames = [];
|
|
1384
|
+
let avdNames = [];
|
|
1385
|
+
try {
|
|
1386
|
+
simNames = listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS }).map((s) => s.name);
|
|
1387
|
+
} catch {}
|
|
1388
|
+
try {
|
|
1389
|
+
avdNames = listAvds({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
|
|
1390
|
+
} catch {}
|
|
1391
|
+
deviceSweepNotices.push(...describeUnverifiableDevices(simNames, avdNames, { reason: unsweepableReason }));
|
|
1392
|
+
} else {
|
|
1393
|
+
let sims = [];
|
|
1394
|
+
let simsChecked = true;
|
|
1395
|
+
try {
|
|
1396
|
+
sims = listAllIosSims({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
|
|
1397
|
+
} catch {
|
|
1398
|
+
simsChecked = false;
|
|
1399
|
+
deviceSweepNotices.push(`ios device sweep skipped: simulator tooling did not answer within ${DEVICE_LIST_TIMEOUT_MS / 1e3}s`);
|
|
1400
|
+
}
|
|
1401
|
+
let avds = [];
|
|
1402
|
+
let avdsChecked = true;
|
|
1403
|
+
try {
|
|
1404
|
+
avds = listAvds({ timeoutMs: DEVICE_LIST_TIMEOUT_MS });
|
|
1405
|
+
} catch {
|
|
1406
|
+
avdsChecked = false;
|
|
1407
|
+
deviceSweepNotices.push(`android device sweep skipped: emulator tooling did not answer within ${DEVICE_LIST_TIMEOUT_MS / 1e3}s`);
|
|
1408
|
+
}
|
|
1409
|
+
let orphanedAvdDirectories = [];
|
|
1410
|
+
if (avdsChecked) try {
|
|
1411
|
+
orphanedAvdDirectories = listOrphanedAvdDirectories();
|
|
1412
|
+
} catch (error) {
|
|
1413
|
+
avdsChecked = false;
|
|
1414
|
+
deviceSweepNotices.push(`android data sweep skipped: ${error.message}`);
|
|
1415
|
+
}
|
|
1416
|
+
avds = [.../* @__PURE__ */ new Set([...avds, ...orphanedAvdDirectories.map((entry) => entry.name)])];
|
|
1417
|
+
const isMounted = (path) => isOnMountedVolume(path, mountedVolumes);
|
|
1418
|
+
orphanedDevices = withAndroidAvdSizes(findOrphanedDevices({
|
|
1419
|
+
sims,
|
|
1420
|
+
avds,
|
|
1421
|
+
config: cfg,
|
|
1422
|
+
isMounted,
|
|
1423
|
+
deadProjects
|
|
1424
|
+
}).orphaned.flatMap((device) => {
|
|
1425
|
+
const directories = device.kind === "android" ? orphanedAvdDirectories.filter((entry) => entry.name === device.name) : [];
|
|
1426
|
+
return directories.length ? directories.map((orphanedDirectory) => Object.assign({}, device, { orphanedDirectory })) : [device];
|
|
1427
|
+
}), {
|
|
1428
|
+
avdDirectory: deps.avdDirectory ?? ownedAvdDirectory,
|
|
1429
|
+
size: deps.directorySize ?? directorySize
|
|
1430
|
+
});
|
|
1431
|
+
staleDeviceRecords = findStaleDeviceRecords({
|
|
1432
|
+
config: cfg,
|
|
1433
|
+
sims,
|
|
1434
|
+
avds,
|
|
1435
|
+
deadProjects,
|
|
1436
|
+
simsChecked,
|
|
1437
|
+
avdsChecked
|
|
1438
|
+
});
|
|
1439
|
+
if (olderThan !== null) staleDevices = withAndroidAvdSizes(findStaleProjectDevices({
|
|
1440
|
+
config: cfg,
|
|
1441
|
+
sims,
|
|
1442
|
+
avds,
|
|
1443
|
+
olderThanDays: olderThan,
|
|
1444
|
+
now,
|
|
1445
|
+
lastTouched,
|
|
1446
|
+
deadProjects
|
|
1447
|
+
}), {
|
|
1448
|
+
avdDirectory: deps.avdDirectory ?? ownedAvdDirectory,
|
|
1449
|
+
size: deps.directorySize ?? directorySize
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
const locks = listBuildLocks();
|
|
1453
|
+
const slots = listBuildSlots();
|
|
1454
|
+
const deviceLeases = collectDeviceLeases(now);
|
|
1455
|
+
return {
|
|
1456
|
+
skipped,
|
|
1457
|
+
deadProjects,
|
|
1458
|
+
invalidProjects,
|
|
1459
|
+
parkedSims,
|
|
1460
|
+
parkedAvds,
|
|
1461
|
+
orphanedDevices,
|
|
1462
|
+
staleDevices,
|
|
1463
|
+
staleDeviceRecords,
|
|
1464
|
+
buildLocks: {
|
|
1465
|
+
stale: locks.filter((l) => !l.alive && !l.unresolved),
|
|
1466
|
+
live: locks.filter((l) => l.alive),
|
|
1467
|
+
unresolved: locks.filter((l) => l.unresolved)
|
|
1468
|
+
},
|
|
1469
|
+
buildSlots: {
|
|
1470
|
+
stale: slots.filter((s) => !s.alive && !s.unresolved),
|
|
1471
|
+
live: slots.filter((s) => s.alive),
|
|
1472
|
+
unresolved: slots.filter((s) => s.unresolved)
|
|
1473
|
+
},
|
|
1474
|
+
deviceLeases,
|
|
1475
|
+
deviceSweepNotices,
|
|
1476
|
+
easSessionSweep,
|
|
1477
|
+
caches,
|
|
1478
|
+
cacheScope: null,
|
|
1479
|
+
olderThan,
|
|
1480
|
+
all
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
async function runGc(opts = {}, deps = {}) {
|
|
1484
|
+
const poolError = parkedMaxSetting("ios").error || parkedMaxSetting("android").error;
|
|
1485
|
+
if (poolError) console.error(chalk.yellow(`${poolError} ${POOL_SETTING_REMEDY}`));
|
|
1486
|
+
const shapeErrors = (deps.settingShapeErrors ?? projectSettingShapeErrors)();
|
|
1487
|
+
if (shapeErrors.length) {
|
|
1488
|
+
for (const message of shapeErrors) console.error(chalk.red(message));
|
|
1489
|
+
console.error(chalk.dim(SETTING_SHAPE_REMEDY));
|
|
1490
|
+
process.exitCode = 1;
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
if (opts.cache) return runGcCore(opts, {
|
|
1494
|
+
...deps,
|
|
1495
|
+
precollectedEasSessionSweep: {
|
|
1496
|
+
projectScope: null,
|
|
1497
|
+
orphaned: [],
|
|
1498
|
+
notices: [],
|
|
1499
|
+
deletionSafe: true
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
let projectRoot = null;
|
|
1503
|
+
try {
|
|
1504
|
+
projectRoot = (deps.findProjectRoot ?? findProjectRoot)(process.cwd());
|
|
1505
|
+
if (!projectRoot) return runGcCore(opts, {
|
|
1506
|
+
...deps,
|
|
1507
|
+
precollectedEasSessionSweep: {
|
|
1508
|
+
projectScope: null,
|
|
1509
|
+
orphaned: [],
|
|
1510
|
+
notices: ["EAS session sweep skipped: no current project was available before EAS project lock acquisition."],
|
|
1511
|
+
deletionSafe: false
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
if (!(deps.detectIsExpo ?? detectIsExpo)(projectRoot)) return runGcCore(opts, {
|
|
1515
|
+
...deps,
|
|
1516
|
+
precollectedEasSessionSweep: {
|
|
1517
|
+
projectScope: projectRoot,
|
|
1518
|
+
orphaned: [],
|
|
1519
|
+
notices: [],
|
|
1520
|
+
deletionSafe: true
|
|
1521
|
+
}
|
|
1522
|
+
});
|
|
1523
|
+
} catch (error) {
|
|
1524
|
+
return runGcCore(opts, {
|
|
1525
|
+
...deps,
|
|
1526
|
+
precollectedEasSessionSweep: {
|
|
1527
|
+
projectScope: projectRoot,
|
|
1528
|
+
orphaned: [],
|
|
1529
|
+
notices: [`EAS session sweep skipped: project classification failed before EAS project lock acquisition: ${describeError(error)}`],
|
|
1530
|
+
deletionSafe: false
|
|
1531
|
+
}
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
const withProjectLock = deps.withEasProjectLock ?? withEasProjectLock;
|
|
1535
|
+
let sweepStarted = false;
|
|
1536
|
+
let easSessionSweep;
|
|
1537
|
+
try {
|
|
1538
|
+
easSessionSweep = await withProjectLock(projectRoot, () => {
|
|
1539
|
+
sweepStarted = true;
|
|
1540
|
+
return withRemoteSessionGcLocks(projectRoot, deps, (coordinatedDeps) => Promise.resolve(collectEasSessionSweep(loadConfig(), coordinatedDeps)));
|
|
1541
|
+
}, {
|
|
1542
|
+
waitMs: 0,
|
|
1543
|
+
ownerPurpose: "EAS orphan sweep",
|
|
1544
|
+
machineRoot: deps.easLedgerRoot
|
|
1545
|
+
});
|
|
1546
|
+
} catch (error) {
|
|
1547
|
+
easSessionSweep = {
|
|
1548
|
+
projectScope: projectRoot,
|
|
1549
|
+
orphaned: [],
|
|
1550
|
+
notices: [`EAS session sweep skipped: ${sweepStarted ? "EAS collection failed" : "EAS project lock acquisition failed"}: ${describeError(error)}`],
|
|
1551
|
+
deletionSafe: false
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
return runGcCore(opts, {
|
|
1555
|
+
...deps,
|
|
1556
|
+
precollectedEasSessionSweep: easSessionSweep
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
async function runGcCore(opts, deps) {
|
|
1560
|
+
const olderThan = typeof opts.olderThan === "number" ? opts.olderThan : null;
|
|
1561
|
+
const cache = typeof opts.cache === "string" && opts.cache.trim() ? opts.cache : null;
|
|
1562
|
+
const report = await collectGcReport({
|
|
1563
|
+
olderThan,
|
|
1564
|
+
cache
|
|
1565
|
+
}, deps);
|
|
1566
|
+
if (cache && report.caches.length === 0) {
|
|
1567
|
+
const names = [...new Set(discoverCaches({ declared: declaredCachePaths() }).map((c) => c.name))];
|
|
1568
|
+
console.log(chalk.yellow(`No shared cache carries "${cache}" in its name or directory.`));
|
|
1569
|
+
if (names.length) console.log(chalk.dim(`Caches on this machine: ${names.join(", ")}`));
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
const all = report.all;
|
|
1573
|
+
for (const line of formatGcReport(report)) console.log(line);
|
|
1574
|
+
const { deadProjects, invalidProjects, orphanedDevices, staleDevices, staleDeviceRecords, buildLocks, buildSlots, deviceLeases, easSessionSweep, caches } = report;
|
|
1575
|
+
const actionable = deadProjects.length + invalidProjects.length > 0 || report.parkedSims.length > 0 || report.parkedAvds.length > 0 || orphanedDevices.length > 0 || staleDevices.length > 0 || staleDeviceRecords.length > 0 || buildLocks.stale.length > 0 || buildSlots.stale.length > 0 || deviceLeases.expired.length > 0 || easSessionSweep.orphaned.length > 0 || (olderThan !== null || all) && caches.length > 0;
|
|
1576
|
+
if (!opts.delete) {
|
|
1577
|
+
if (all) console.log(chalk.dim("\nDry run. Re-run with --delete to empty the caches above."));
|
|
1578
|
+
else if (actionable) console.log(chalk.dim("\nDry run. Re-run with --delete to reclaim."));
|
|
1579
|
+
else if (caches.length) console.log(chalk.dim("\nPass --delete --cache all to empty the caches above, or --delete --older-than <days> to trim them."));
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
let deleteFailures = deleteParkedSims(report.parkedSims, deps) + deleteParkedAvds(report.parkedAvds);
|
|
1583
|
+
removeInvalidProjectEntries(invalidProjects);
|
|
1584
|
+
for (const path of deadProjects) {
|
|
1585
|
+
const result = await reclaimProject(path);
|
|
1586
|
+
if (result.keptEntry) console.log(chalk.yellow(`Could not fully prune ${path}; its registry entry was kept.`));
|
|
1587
|
+
else console.log(chalk.green(`Pruned ${path}`));
|
|
1588
|
+
for (const dir of result.removedWorkspaceDirs) console.log(chalk.dim(` removed workspace output ${dir}`));
|
|
1589
|
+
for (const dir of result.failedWorkspaceDirs) {
|
|
1590
|
+
console.log(chalk.red(` could not remove workspace output ${dir}`));
|
|
1591
|
+
deleteFailures += 1;
|
|
1592
|
+
}
|
|
1593
|
+
if (result.killedPid) console.log(chalk.dim(` killed orphaned Metro pid ${result.killedPid}`));
|
|
1594
|
+
if (result.stoppedSession) console.log(chalk.dim(` stopped remote session ${result.stoppedSession}`));
|
|
1595
|
+
if (result.stoppedTunnel) console.log(chalk.dim(` stopped ${result.stoppedTunnel} tunnel`));
|
|
1596
|
+
for (const s of result.skippedDevices) console.log(chalk.yellow(` ${s.name}: ${s.reason}`));
|
|
1597
|
+
}
|
|
1598
|
+
deleteFailures += deleteProjectDevices(orphanedDevices, staleDevices, staleDeviceRecords);
|
|
1599
|
+
for (const lock of buildLocks.stale) {
|
|
1600
|
+
const cleared = clearFreeClaimSet({
|
|
1601
|
+
root: lock.path,
|
|
1602
|
+
label: `${lock.platform} build`
|
|
1603
|
+
});
|
|
1604
|
+
if (cleared.status === "held") continue;
|
|
1605
|
+
if (cleared.status === "refused") {
|
|
1606
|
+
console.log(chalk.yellow(`Left the build lock at ${lock.path} alone: ${cleared.reason}`));
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
if (cleared.status === "failed") {
|
|
1610
|
+
deleteFailures++;
|
|
1611
|
+
console.log(chalk.red(`Failed to clear the build lock at ${lock.path}: ${cleared.reason}`));
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
console.log(chalk.green(`Cleared the ${lock.platform} build lock left by pid ${lock.pid ?? "?"} (${lock.projectRoot || "unrecorded workspace"})`));
|
|
1615
|
+
}
|
|
1616
|
+
for (const slot of buildSlots.stale) {
|
|
1617
|
+
const cleared = clearFreeClaimSet({
|
|
1618
|
+
root: slot.path,
|
|
1619
|
+
label: "build slot"
|
|
1620
|
+
});
|
|
1621
|
+
if (cleared.status === "held") continue;
|
|
1622
|
+
if (cleared.status === "refused") {
|
|
1623
|
+
console.log(chalk.yellow(`Left the build slot at ${slot.path} alone: ${cleared.reason}`));
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
if (cleared.status === "failed") {
|
|
1627
|
+
deleteFailures++;
|
|
1628
|
+
console.log(chalk.red(`Failed to clear the build slot at ${slot.path}: ${cleared.reason}`));
|
|
1629
|
+
continue;
|
|
1630
|
+
}
|
|
1631
|
+
console.log(chalk.green(`Cleared build slot ${slot.index ?? "?"} left by pid ${slot.pid ?? "?"} (${slot.projectRoot || "unrecorded workspace"})`));
|
|
1632
|
+
}
|
|
1633
|
+
for (const entry of deviceLeases.expired) try {
|
|
1634
|
+
if (removeExpiredLease(entry)) console.log(chalk.green(`Cleared the expired ${entry.platform} device lease on ${entry.id ?? entry.name} (held by ${entry.lease?.holder ?? "an unrecorded workspace"})`));
|
|
1635
|
+
else console.log(chalk.dim(`${entry.name} is no longer an expired lease; left alone.`));
|
|
1636
|
+
} catch (err) {
|
|
1637
|
+
deleteFailures++;
|
|
1638
|
+
console.log(chalk.red(`Failed to clear the device lease at ${entry.path}: ${err?.message || err}`));
|
|
1639
|
+
}
|
|
1640
|
+
deleteFailures += await deleteEasSessions(easSessionSweep, deps);
|
|
1641
|
+
if (deleteFailures) console.log(chalk.red(`\n${deleteFailures} entr${deleteFailures === 1 ? "y" : "ies"} could not be deleted; see above.`));
|
|
1642
|
+
if (all) {
|
|
1643
|
+
emptyCaches(caches);
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
if (olderThan === null) {
|
|
1647
|
+
if (caches.length) console.log(chalk.dim("Shared caches left alone: pass --cache all to empty them, or --older-than <days> to trim them."));
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
trimCaches(caches, olderThan);
|
|
1651
|
+
}
|
|
1652
|
+
function gcCommand(program) {
|
|
1653
|
+
program.command("gc").description("Report what Stim has left behind: dead project entries, orphaned owned devices and EAS sessions, records of devices that no longer exist, build locks whose builder is gone, expired physical-device leases, and the shared build caches. Reports by default; pass --delete to act.").option("--delete", "actually prune the reported entries and reap the reported devices").option("--older-than <days>", "also reap owned devices whose project has been untouched this long, and trim shared cache entries nothing has used in that time", (v) => {
|
|
1654
|
+
const n = parseInt(v, 10);
|
|
1655
|
+
if (!Number.isFinite(n) || String(n) !== String(v).trim()) throw new InvalidArgumentError("must be a whole number of days, e.g. --older-than 30");
|
|
1656
|
+
return n;
|
|
1657
|
+
}).option("--cache <name>", "act on the shared caches whose name or directory contains <name>, or every cache with --cache all, a reserved name that never selects a single cache. With --delete they are emptied whole, which is the only way to clear an index-backed cache; add --older-than <days> to trim them by age instead. Only those caches are reported; devices and project entries are not inspected. Caches outside the config dir are refused while STIM_HOME is set.", (v) => {
|
|
1658
|
+
if (!v.trim()) throw new InvalidArgumentError("must name a cache, e.g. --cache \"compilation cache\"");
|
|
1659
|
+
return v;
|
|
1660
|
+
}).action(async (opts) => {
|
|
1661
|
+
await runGc(opts);
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
//#endregion
|
|
1665
|
+
export { collectGcReport, gcCommand as default, deleteParkedSims, describeParkedSims, describeUnverifiableDevices, findOrphanedDevices, findStaleDeviceRecords, findStaleProjectDevices, formatGcReport, runGc, selectCaches };
|