@stim-cli/core 1.9.0 → 1.10.1

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/dist/state.mjs ADDED
@@ -0,0 +1,1269 @@
1
+ import { E as withDirLock, S as workspaceStateDir, b as workspaceName, l as configDir, m as rotatedLogPath } from "./core-CJWLJIr6.mjs";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { StringDecoder } from "node:string_decoder";
6
+ //#region state/json-file.ts
7
+ function readJsonFile(path) {
8
+ return JSON.parse(readFileSync(path, "utf-8"));
9
+ }
10
+ function isJsonObject(value) {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
13
+ function readJsonObject(path) {
14
+ try {
15
+ const value = readJsonFile(path);
16
+ return isJsonObject(value) ? value : null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+ //#endregion
22
+ //#region state/paths.ts
23
+ function getConfigPath() {
24
+ return join(configDir(), "config.json");
25
+ }
26
+ function configLockPath() {
27
+ return join(configDir(), "config.lock");
28
+ }
29
+ function workspaceMetadataFile(projectRoot) {
30
+ return join(workspaceStateDir(projectRoot), "workspace.json");
31
+ }
32
+ function workspaceLogsDir(projectRoot) {
33
+ return join(workspaceStateDir(projectRoot), "logs");
34
+ }
35
+ function workspaceLogErrorIndex(projectRoot) {
36
+ return join(workspaceStateDir(projectRoot), "log-error-index.json");
37
+ }
38
+ function workspaceDerivedData(projectRoot) {
39
+ return join(workspaceStateDir(projectRoot), "derived-data");
40
+ }
41
+ function workspaceGradleBuild(projectRoot) {
42
+ return join(workspaceStateDir(projectRoot), "gradle-build");
43
+ }
44
+ function supervisorPidFile(projectRoot) {
45
+ return join(workspaceStateDir(projectRoot), "supervisor.pid");
46
+ }
47
+ function workspaceStateFile(projectRoot) {
48
+ return join(workspaceStateDir(projectRoot), "state.json");
49
+ }
50
+ function workspaceStateLock(projectRoot) {
51
+ return join(workspaceStateDir(projectRoot), "state.lock");
52
+ }
53
+ function supervisorLogFile(projectRoot) {
54
+ return join(workspaceLogsDir(projectRoot), "supervisor.log");
55
+ }
56
+ function emulatorLogFile(projectRoot) {
57
+ return join(workspaceLogsDir(projectRoot), "emulator.log");
58
+ }
59
+ function sharedCompilationCache() {
60
+ return join(configDir(), "compilation-cache");
61
+ }
62
+ function sharedCcache() {
63
+ return join(configDir(), "ccache");
64
+ }
65
+ function sharedGradle() {
66
+ return join(configDir(), "gradle");
67
+ }
68
+ function sharedPods() {
69
+ return join(configDir(), "pods");
70
+ }
71
+ function createdDevicesFile() {
72
+ return join(configDir(), "created-devices.json");
73
+ }
74
+ function createdDevicesLock() {
75
+ return join(configDir(), "created-devices.lock");
76
+ }
77
+ /** The EAS session ledger lives under the real home directory, never under STIM_HOME. */
78
+ function easMachineStateRoot() {
79
+ return join(homedir(), ".stim", "machine", "eas");
80
+ }
81
+ function easSessionLedgerFile(root = easMachineStateRoot()) {
82
+ return join(root, "sessions.json");
83
+ }
84
+ function easSessionLedgerLock(root = easMachineStateRoot()) {
85
+ return join(root, "ledger.lock");
86
+ }
87
+ //#endregion
88
+ //#region state/config.ts
89
+ function withConfigLock(fn) {
90
+ return withDirLock(configLockPath(), fn, { ensureParent: () => {
91
+ const dir = configDir();
92
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
93
+ } });
94
+ }
95
+ function configCorruptRepair(path = getConfigPath()) {
96
+ return `Repair the file, or move it aside to start over: mv "${path}" "${path}.broken"`;
97
+ }
98
+ function configCorrupt(reason, path = getConfigPath()) {
99
+ const corrupt = /* @__PURE__ */ new Error(`Stim config at ${path} ${reason}\nIt holds the records of the simulators and emulators Stim owns, so it is never reset automatically.
100
+ ` + configCorruptRepair(path));
101
+ corrupt.code = "STIM_CONFIG_CORRUPT";
102
+ return corrupt;
103
+ }
104
+ function loadConfig() {
105
+ const p = getConfigPath();
106
+ if (!existsSync(p)) return null;
107
+ let parsed;
108
+ try {
109
+ parsed = readJsonFile(p);
110
+ } catch (err) {
111
+ if (!(err instanceof SyntaxError)) throw err;
112
+ throw configCorrupt(`is not valid JSON: ${err.message}`, p);
113
+ }
114
+ return configFromJson(parsed, p);
115
+ }
116
+ function configFromJson(value, path) {
117
+ if (!isJsonObject(value)) throw configCorrupt(`is not a JSON object: ${JSON.stringify(value)}`, path);
118
+ return {
119
+ ...value,
120
+ projects: registryFromJson(value, "projects", path),
121
+ repos: registryFromJson(value, "repos", path)
122
+ };
123
+ }
124
+ /**
125
+ * An array or scalar container swallows every write silently: `JSON.stringify` drops a string key set on
126
+ * an array, so the record is gone by the time the file is written. Absent or null is read as empty; an
127
+ * unusable container or entry is refused.
128
+ */
129
+ function registryFromJson(config, key, path) {
130
+ const value = config[key];
131
+ if (value === void 0 || value === null) return {};
132
+ if (!isJsonObject(value)) throw configCorrupt(`has a ${key} that is not an object: ${JSON.stringify(value)}`, path);
133
+ const registry = {};
134
+ for (const [name, entry] of Object.entries(value)) {
135
+ if (!isJsonObject(entry)) throw configCorrupt(`has a ${key} entry ${JSON.stringify(name)} that is not an object: ${JSON.stringify(entry)}`, path);
136
+ registry[name] = entry;
137
+ }
138
+ return registry;
139
+ }
140
+ function getProject(projectPath) {
141
+ return loadConfig()?.projects?.[projectPath] || null;
142
+ }
143
+ function getConcurrencyLimits({ env = process.env } = {}) {
144
+ const c = loadConfig()?.concurrency || {};
145
+ return {
146
+ maxBuilds: resolveLimit(env.STIM_MAX_BUILDS, c.maxBuilds),
147
+ maxDevices: resolveLimit(env.STIM_MAX_DEVICES, c.maxDevices)
148
+ };
149
+ }
150
+ function resolveLimit(envVal, cfgVal) {
151
+ const raw = envVal !== void 0 && envVal !== null && envVal !== "" ? Number(envVal) : typeof cfgVal === "number" ? cfgVal : NaN;
152
+ if (!Number.isFinite(raw) || raw <= 0) return 0;
153
+ return Math.floor(raw);
154
+ }
155
+ function getProjectSettings(projectPath) {
156
+ return getProject(projectPath)?.settings || {};
157
+ }
158
+ function getProjectSetting(projectPath, dottedKey) {
159
+ return readNested(getProjectSettings(projectPath), dottedKey);
160
+ }
161
+ function getRepoSettings(gitCommonDir) {
162
+ return loadConfig()?.repos?.[gitCommonDir]?.settings || {};
163
+ }
164
+ function readNested(obj, dottedKey) {
165
+ if (!obj) return void 0;
166
+ const keys = dottedKey.split(".");
167
+ let cur = obj;
168
+ for (const k of keys) {
169
+ if (cur == null || typeof cur !== "object") return void 0;
170
+ cur = cur[k];
171
+ }
172
+ return cur;
173
+ }
174
+ function isPathPrefix(prefix, path) {
175
+ const head = comparablePath(prefix);
176
+ const candidate = comparablePath(path);
177
+ if (head === candidate) return true;
178
+ return candidate.startsWith(head.endsWith("/") ? head : `${head}/`);
179
+ }
180
+ function comparablePath(path) {
181
+ return sep === "/" ? path : path.replaceAll(sep, "/");
182
+ }
183
+ function findEnclosingWorktreeRoot(projectPath) {
184
+ const cfg = loadConfig();
185
+ if (!cfg?.projects) return null;
186
+ let best = null;
187
+ for (const [path, proj] of Object.entries(cfg.projects)) {
188
+ if (!proj?.worktreeRoot) continue;
189
+ if (!isPathPrefix(path, projectPath)) continue;
190
+ if (!best || path.length > best.length) best = path;
191
+ }
192
+ return best;
193
+ }
194
+ function allMetroPorts() {
195
+ const cfg = loadConfig();
196
+ if (!cfg?.projects) return [];
197
+ return Object.values(cfg.projects).map((p) => p.metroPort).filter((p) => typeof p === "number");
198
+ }
199
+ function findProjectByMetroPort(port) {
200
+ const cfg = loadConfig();
201
+ for (const [path, proj] of Object.entries(cfg?.projects || {})) if (proj.metroPort === port) return path;
202
+ return null;
203
+ }
204
+ //#endregion
205
+ //#region state/ledgers.ts
206
+ function ids(value) {
207
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string" && entry !== "") : [];
208
+ }
209
+ function readCreatedDevices() {
210
+ try {
211
+ const parsed = JSON.parse(readFileSync(createdDevicesFile(), "utf8"));
212
+ return {
213
+ ios: new Set(ids(parsed?.ios)),
214
+ android: new Set(ids(parsed?.android))
215
+ };
216
+ } catch {
217
+ return {
218
+ ios: /* @__PURE__ */ new Set(),
219
+ android: /* @__PURE__ */ new Set()
220
+ };
221
+ }
222
+ }
223
+ /** Returns the claim normalized to canonical paths, or null when it is not a valid claim for `id`. */
224
+ function validEasSessionClaim(id, value) {
225
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
226
+ const claim = value;
227
+ if (claim.sessionId !== id) return null;
228
+ if (typeof claim.name !== "string" || !claim.name.startsWith("stim-")) return null;
229
+ if (claim.platform !== "ios" && claim.platform !== "android") return null;
230
+ if (typeof claim.workspaceRoot !== "string" || !isAbsolute(claim.workspaceRoot)) return null;
231
+ if (typeof claim.workspaceHome !== "string" || !isAbsolute(claim.workspaceHome)) return null;
232
+ if (typeof claim.stateFile !== "string" || !isAbsolute(claim.stateFile)) return null;
233
+ const workspaceRoot = resolve(claim.workspaceRoot);
234
+ const workspaceHome = resolve(claim.workspaceHome);
235
+ const stateFile = resolve(claim.stateFile);
236
+ const stateRelative = relative(workspaceHome, stateFile);
237
+ if (!stateRelative || stateRelative.startsWith("..") || isAbsolute(stateRelative)) return null;
238
+ if (stateFile !== join(workspaceHome, "workspaces", workspaceName(workspaceRoot), "state.json")) return null;
239
+ return {
240
+ sessionId: id,
241
+ name: claim.name,
242
+ platform: claim.platform,
243
+ workspaceRoot,
244
+ workspaceHome,
245
+ stateFile
246
+ };
247
+ }
248
+ function readEasSessionLedger(root = easMachineStateRoot()) {
249
+ const file = easSessionLedgerFile(root);
250
+ if (!existsSync(file)) return {
251
+ claims: /* @__PURE__ */ new Map(),
252
+ notice: null,
253
+ safe: true
254
+ };
255
+ let parsed;
256
+ try {
257
+ parsed = JSON.parse(readFileSync(file, "utf-8"));
258
+ } catch (error) {
259
+ return {
260
+ claims: /* @__PURE__ */ new Map(),
261
+ notice: `EAS ownership ledger ${file} is unreadable: ${error.message}`,
262
+ safe: false
263
+ };
264
+ }
265
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {
266
+ claims: /* @__PURE__ */ new Map(),
267
+ notice: `EAS ownership ledger ${file} is malformed.`,
268
+ safe: false
269
+ };
270
+ const ledger = parsed;
271
+ if (ledger.version !== 1 || !ledger.claims || typeof ledger.claims !== "object" || Array.isArray(ledger.claims)) return {
272
+ claims: /* @__PURE__ */ new Map(),
273
+ notice: `EAS ownership ledger ${file} is malformed.`,
274
+ safe: false
275
+ };
276
+ const claims = /* @__PURE__ */ new Map();
277
+ for (const [id, value] of Object.entries(ledger.claims)) {
278
+ const claim = validEasSessionClaim(id, value);
279
+ if (!claim) return {
280
+ claims: /* @__PURE__ */ new Map(),
281
+ notice: `EAS ownership ledger ${file} is malformed.`,
282
+ safe: false
283
+ };
284
+ claims.set(id, claim);
285
+ }
286
+ return {
287
+ claims,
288
+ notice: null,
289
+ safe: true
290
+ };
291
+ }
292
+ //#endregion
293
+ //#region state/ndjson.ts
294
+ const LEVELS = [
295
+ "debug",
296
+ "info",
297
+ "warn",
298
+ "error",
299
+ "fatal"
300
+ ];
301
+ const SOURCES = [
302
+ "metro",
303
+ "client",
304
+ "device",
305
+ "build"
306
+ ];
307
+ function levelRank(level) {
308
+ const i = LEVELS.indexOf(level);
309
+ return i < 0 ? 0 : i;
310
+ }
311
+ function parseNdjsonLine(line) {
312
+ if (typeof line !== "string") return null;
313
+ const trimmed = line.trim();
314
+ if (!trimmed) return null;
315
+ let value;
316
+ try {
317
+ value = JSON.parse(trimmed);
318
+ } catch {
319
+ return null;
320
+ }
321
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
322
+ return value;
323
+ }
324
+ function parseNdjsonText(text) {
325
+ if (typeof text !== "string" || text === "") return [];
326
+ const out = [];
327
+ const lines = text.split("\n");
328
+ lines.pop();
329
+ for (const line of lines) {
330
+ const record = parseNdjsonLine(line);
331
+ if (record) out.push(record);
332
+ }
333
+ return out;
334
+ }
335
+ function readNdjsonGenerations(file) {
336
+ const out = [];
337
+ for (const path of [rotatedLogPath(file), file]) {
338
+ let text;
339
+ try {
340
+ text = readFileSync(path, "utf-8");
341
+ } catch {
342
+ continue;
343
+ }
344
+ for (const record of parseNdjsonText(text)) out.push(record);
345
+ }
346
+ return out;
347
+ }
348
+ //#endregion
349
+ //#region state/logs-query.ts
350
+ const SINCE_UNITS = {
351
+ s: 1e3,
352
+ m: 6e4,
353
+ h: 36e5
354
+ };
355
+ const SINCE_FORMS = "30s, 5m, 2h";
356
+ function parseSince(text) {
357
+ if (typeof text !== "string") return { error: `Invalid --since value ${JSON.stringify(text)}. Use a count and a unit, e.g. ${SINCE_FORMS}.` };
358
+ const m = /^(\d+)\s*([smh])$/i.exec(text.trim());
359
+ if (!m) return { error: `Invalid --since value "${text}". Use a count and a unit, e.g. ${SINCE_FORMS}.` };
360
+ const unit = (m[2] ?? "").toLowerCase();
361
+ return { ms: parseInt(m[1] ?? "", 10) * SINCE_UNITS[unit] };
362
+ }
363
+ function compileGrep(pattern) {
364
+ if (pattern instanceof RegExp) return { re: pattern };
365
+ try {
366
+ return { re: new RegExp(String(pattern)) };
367
+ } catch (err) {
368
+ return { error: `Invalid --grep pattern "${pattern}": ${err.message}` };
369
+ }
370
+ }
371
+ const ERROR_SOURCES = [
372
+ "metro",
373
+ "client",
374
+ "build"
375
+ ];
376
+ function markerWindow(records) {
377
+ let launchTs = null;
378
+ let bundleTs = null;
379
+ for (const r of records) {
380
+ if (r?.marker !== true) continue;
381
+ const ts = tsOf(r);
382
+ if (ts === null) continue;
383
+ if (r.src === "metro") {
384
+ if (bundleTs === null || ts > bundleTs) bundleTs = ts;
385
+ } else if (launchTs === null || ts > launchTs) launchTs = ts;
386
+ }
387
+ return {
388
+ launchTs,
389
+ bundleTs
390
+ };
391
+ }
392
+ function recordMatches(record, criteria = {}) {
393
+ if (!record) return false;
394
+ if (criteria.slot !== void 0 && (record.slot ?? "default") !== criteria.slot) return false;
395
+ const { sources, minLevel, grep, sinceTs, errorsOnly, markerTs, bundleMarkerTs } = criteria;
396
+ if (sources && sources.length > 0 && !sources.includes(record.src) && !(criteria.includeNativeCrashes && record.src === "device" && record.event === "native_crash")) return false;
397
+ if (minLevel && levelRank(record.level) < levelRank(minLevel)) return false;
398
+ if (errorsOnly) {
399
+ if (record.level !== "error" && record.level !== "fatal") return false;
400
+ if (typeof markerTs === "number") {
401
+ const ts = tsOf(record);
402
+ if (ts === null || ts <= markerTs) return false;
403
+ }
404
+ if (typeof bundleMarkerTs === "number" && record.src === "metro") {
405
+ const ts = tsOf(record);
406
+ if (ts === null || ts < bundleMarkerTs) return false;
407
+ }
408
+ }
409
+ if (typeof sinceTs === "number") {
410
+ const ts = tsOf(record);
411
+ if (ts === null || ts < sinceTs) return false;
412
+ }
413
+ if (grep) {
414
+ const re = grep instanceof RegExp ? grep : compileGrep(grep).re;
415
+ if (!re || String(record.msg ?? "").search(re) === -1) return false;
416
+ }
417
+ return true;
418
+ }
419
+ function buildCriteria({ slot, sources, minLevel, since, grep, errorsOnly, markerTs, bundleMarkerTs, now } = {}) {
420
+ const criteria = {
421
+ ...slot === void 0 ? {} : { slot },
422
+ errorsOnly: Boolean(errorsOnly),
423
+ includeNativeCrashes: Boolean(errorsOnly && !sources?.length)
424
+ };
425
+ if (sources && sources.length > 0) criteria.sources = sources;
426
+ else if (criteria.errorsOnly) criteria.sources = ERROR_SOURCES;
427
+ if (minLevel) criteria.minLevel = minLevel;
428
+ if (typeof markerTs === "number") criteria.markerTs = markerTs;
429
+ if (typeof bundleMarkerTs === "number") criteria.bundleMarkerTs = bundleMarkerTs;
430
+ if (since !== void 0 && since !== null && since !== "") {
431
+ const parsed = parseSince(since);
432
+ if (parsed.error) throw new Error(parsed.error);
433
+ criteria.sinceTs = (typeof now === "number" ? now : Date.now()) - parsed.ms;
434
+ }
435
+ if (grep !== void 0 && grep !== null && grep !== "") {
436
+ const compiled = compileGrep(grep);
437
+ if (compiled.error) throw new Error(compiled.error);
438
+ criteria.grep = compiled.re;
439
+ }
440
+ return criteria;
441
+ }
442
+ function logFiles(dir) {
443
+ let entries;
444
+ try {
445
+ entries = readdirSync(dir, { withFileTypes: true });
446
+ } catch {
447
+ return [];
448
+ }
449
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".ndjson")).map((e) => e.name).toSorted();
450
+ }
451
+ function fileSizes(dir) {
452
+ const sizes = {};
453
+ for (const name of logFiles(dir)) try {
454
+ sizes[name] = statSync(join(dir, name)).size;
455
+ } catch {}
456
+ return sizes;
457
+ }
458
+ function readLogRecords(dir, include = () => true) {
459
+ const all = [];
460
+ for (const name of logFiles(dir).filter(include)) for (const record of readNdjsonGenerations(join(dir, name))) all.push(record);
461
+ return sortByTs(all);
462
+ }
463
+ function isExpoErrorContext(record, event) {
464
+ if (record.src !== "metro" || record.raw !== true || record.event !== event || typeof record.msg !== "string") return false;
465
+ return /^Code: \S/.test(record.msg) || /^\s*>?\s*\d+\s*\|/.test(record.msg) || /^\s*\|\s*\^/.test(record.msg) || /^Call Stack$/.test(record.msg) || /^\s+.+\([^()]+:\d+:\d+\)\s*$/.test(record.msg) || /^\s+at\s+\S+:\d+:\d+\s*$/.test(record.msg);
466
+ }
467
+ function attachExpoErrorContext(all, matched) {
468
+ return matched.map((record) => {
469
+ if (record.src !== "metro" || record.raw !== true || record.level !== "error" && record.level !== "fatal" || typeof record.msg !== "string") return record;
470
+ const index = all.findIndex((entry) => entry.ts === record.ts && entry.src === record.src && entry.msg === record.msg && entry.event === record.event);
471
+ if (index === -1) return record;
472
+ const context = [];
473
+ for (let i = index + 1; i < all.length; i += 1) {
474
+ const next = all[i];
475
+ if (next.src !== "metro") continue;
476
+ if (!isExpoErrorContext(next, record.event)) break;
477
+ context.push(next.msg);
478
+ }
479
+ return context.length > 0 ? {
480
+ ...record,
481
+ msg: [record.msg, ...context].join("\n")
482
+ } : record;
483
+ });
484
+ }
485
+ function includeBareErrorContext(all, matched, launchTs, sinceTs) {
486
+ if (!matched.some((record) => record.src === "client" && (record.level === "error" || record.level === "fatal"))) return matched;
487
+ const renderedContext = [];
488
+ for (const record of all) {
489
+ if (record.src !== "client" || record.event !== "client_symbolication") continue;
490
+ const ts = tsOf(record);
491
+ if (launchTs !== null && (ts === null || ts <= launchTs)) continue;
492
+ if (sinceTs !== void 0 && (ts === null || ts < sinceTs)) continue;
493
+ renderedContext.push({
494
+ ...record,
495
+ msg: `Metro symbolication context (not correlated to a specific client error)\n${String(record.msg ?? "")}`,
496
+ errorContext: true
497
+ });
498
+ }
499
+ return sortByTs([...matched, ...renderedContext]);
500
+ }
501
+ function launchMarkersBySlot(records) {
502
+ const markers = /* @__PURE__ */ new Map();
503
+ for (const record of records) {
504
+ if (record.marker !== true || record.src === "metro") continue;
505
+ const ts = tsOf(record);
506
+ const slot = record.slot ?? "default";
507
+ if (ts !== null && ts > (markers.get(slot) ?? -Infinity)) markers.set(slot, ts);
508
+ }
509
+ return markers;
510
+ }
511
+ function queryLogs({ slot, dir, records, sources, minLevel, since, grep, tail, errorsOnly, errorContext, now } = {}) {
512
+ const all = (records ?? readLogRecords(dir)).filter((record) => slot === void 0 || (record.slot ?? "default") === slot);
513
+ if (all.length === 0) return [];
514
+ const { launchTs, bundleTs } = errorsOnly ? markerWindow(all) : {
515
+ launchTs: null,
516
+ bundleTs: null
517
+ };
518
+ const criteria = buildCriteria({
519
+ slot,
520
+ sources,
521
+ minLevel,
522
+ since,
523
+ grep,
524
+ errorsOnly,
525
+ now,
526
+ markerTs: launchTs === null ? void 0 : launchTs,
527
+ bundleMarkerTs: bundleTs === null ? void 0 : bundleTs
528
+ });
529
+ const slotMarkers = launchMarkersBySlot(all);
530
+ let matched = all.filter((record) => recordMatches(record, {
531
+ ...criteria,
532
+ markerTs: record.src === "metro" || record.src === "client" ? criteria.markerTs : slotMarkers.get(record.slot ?? "default")
533
+ }));
534
+ if (typeof tail === "number" && tail >= 0 && matched.length > tail) matched = matched.slice(matched.length - tail);
535
+ return errorContext ? includeBareErrorContext(all, attachExpoErrorContext(all, matched), launchTs, criteria.sinceTs) : matched;
536
+ }
537
+ function tailRead(prev, size) {
538
+ const state = prev && typeof prev.offset === "number" ? prev : {
539
+ offset: 0,
540
+ partial: ""
541
+ };
542
+ if (size < state.offset) return {
543
+ start: 0,
544
+ prev: {
545
+ offset: 0,
546
+ partial: ""
547
+ }
548
+ };
549
+ return {
550
+ start: state.offset,
551
+ prev: state
552
+ };
553
+ }
554
+ function advanceTail(prev, chunk, size) {
555
+ const lines = ((prev && typeof prev.partial === "string" ? prev.partial : "") + (chunk || "")).split("\n");
556
+ const partial = lines.pop();
557
+ const records = [];
558
+ for (const line of lines) {
559
+ const record = parseNdjsonLine(line);
560
+ if (record) records.push(record);
561
+ }
562
+ return {
563
+ state: {
564
+ offset: size,
565
+ partial
566
+ },
567
+ records
568
+ };
569
+ }
570
+ function followLogs({ dir, onRecord, criteria = {}, intervalMs = 500, offsets = null }) {
571
+ const state = /* @__PURE__ */ new Map();
572
+ const decoders = /* @__PURE__ */ new Map();
573
+ for (const [name, size] of Object.entries(offsets || fileSizes(dir))) {
574
+ state.set(name, {
575
+ offset: size,
576
+ partial: ""
577
+ });
578
+ decoders.set(name, new StringDecoder("utf8"));
579
+ }
580
+ function emit(records) {
581
+ for (const record of records) if (recordMatches(record, criteria)) onRecord(record);
582
+ }
583
+ function drainRotated(name, entry) {
584
+ const path = rotatedLogPath(join(dir, name));
585
+ let size;
586
+ try {
587
+ size = statSync(path).size;
588
+ } catch {
589
+ return;
590
+ }
591
+ const decoder = decoders.get(name) ?? new StringDecoder("utf8");
592
+ emit(advanceTail(entry, readChunk(path, decoder, entry.offset, size) + decoder.end(), size).records);
593
+ }
594
+ function pollFile(name) {
595
+ const path = join(dir, name);
596
+ const size = statSync(path).size;
597
+ const entry = state.get(name);
598
+ if (entry && size < entry.offset) drainRotated(name, entry);
599
+ const { start, prev } = tailRead(entry, size);
600
+ if (prev !== entry) decoders.set(name, new StringDecoder("utf8"));
601
+ if (!decoders.has(name)) decoders.set(name, new StringDecoder("utf8"));
602
+ const next = advanceTail(prev, readChunk(path, decoders.get(name), start, size), size);
603
+ state.set(name, next.state);
604
+ emit(next.records);
605
+ }
606
+ function poll() {
607
+ for (const name of logFiles(dir)) try {
608
+ pollFile(name);
609
+ } catch {}
610
+ }
611
+ const timer = setInterval(poll, intervalMs);
612
+ return function stop() {
613
+ clearInterval(timer);
614
+ };
615
+ }
616
+ function readChunk(path, decoder, start, size) {
617
+ if (size <= start) return "";
618
+ const fd = openSync(path, "r");
619
+ try {
620
+ const buf = Buffer.allocUnsafe(size - start);
621
+ const read = readSync(fd, buf, 0, size - start, start);
622
+ return decoder.write(buf.subarray(0, read));
623
+ } finally {
624
+ closeSync(fd);
625
+ }
626
+ }
627
+ function tsOf(record) {
628
+ return typeof record?.ts === "number" && Number.isFinite(record.ts) ? record.ts : null;
629
+ }
630
+ function sortByTs(records) {
631
+ return records.toSorted((a, b) => {
632
+ const ta = tsOf(a);
633
+ const tb = tsOf(b);
634
+ if (ta === null && tb === null) return 0;
635
+ if (ta === null) return 1;
636
+ if (tb === null) return -1;
637
+ return ta - tb;
638
+ });
639
+ }
640
+ //#endregion
641
+ //#region state/settings-registry.ts
642
+ const ANDROID_COMPILER_CACHE_CHOICES = [
643
+ "auto",
644
+ "ccache",
645
+ "cas",
646
+ "none"
647
+ ];
648
+ const ANDROID_PCH_CHOICES = [
649
+ "auto",
650
+ "on",
651
+ "off"
652
+ ];
653
+ const TUNNEL_MODES = [
654
+ "auto",
655
+ "off",
656
+ "expo",
657
+ "cloudflared",
658
+ "ngrok"
659
+ ];
660
+ const SETTING_SCOPES = [
661
+ "machine",
662
+ "workspace",
663
+ "repo",
664
+ "committed"
665
+ ];
666
+ const REMOTE_DEVICE_BACKENDS = ["proxy", "eas"];
667
+ const IOS_SIMULATOR_APPS = [
668
+ "xcode",
669
+ "siniulator",
670
+ "stim-desktop"
671
+ ];
672
+ const PROJECT = [
673
+ "workspace",
674
+ "repo",
675
+ "committed"
676
+ ];
677
+ const EVERY = [
678
+ "machine",
679
+ "workspace",
680
+ "repo",
681
+ "committed"
682
+ ];
683
+ const MACHINE = ["machine"];
684
+ const STRING = { kind: "string" };
685
+ const PATH = { kind: "path" };
686
+ const ABSOLUTE_PATH = {
687
+ kind: "path",
688
+ absolute: true
689
+ };
690
+ const RELATIVE_PATH = {
691
+ kind: "path",
692
+ relative: true
693
+ };
694
+ const BOOLEAN = { kind: "boolean" };
695
+ const OBJECT = { kind: "object" };
696
+ const REMOTE = {
697
+ kind: "choice",
698
+ choices: REMOTE_DEVICE_BACKENDS
699
+ };
700
+ const PARKED_MAX = {
701
+ kind: "number",
702
+ integer: true,
703
+ minimum: 0
704
+ };
705
+ const CAPACITY = {
706
+ kind: "number",
707
+ integer: true,
708
+ minimum: 0
709
+ };
710
+ const GIGABYTES = {
711
+ kind: "number",
712
+ minimum: 0
713
+ };
714
+ function optimization(key, description, type = BOOLEAN) {
715
+ return {
716
+ key: `optimizations.${key}`,
717
+ type,
718
+ scopes: EVERY,
719
+ description,
720
+ ...type.kind === "boolean" ? { default: true } : {}
721
+ };
722
+ }
723
+ const SETTINGS = [
724
+ optimization("buildCache", "Read and write native build artifacts"),
725
+ optimization("remoteBuildCache", "Use remote artifact providers"),
726
+ optimization("releaseBundleSwap", "Swap current JS into cached release builds"),
727
+ optimization("metroSharedCache", "Append Stim's shared Metro cache store"),
728
+ optimization("metroWarmup", "Prefetch the development bundle during ios and android"),
729
+ optimization("ios.compilationCache", "Xcode compilation caching"),
730
+ {
731
+ key: "optimizations.ios.swiftCompilationCache",
732
+ type: BOOLEAN,
733
+ scopes: EVERY,
734
+ description: "Swift compilation caching; unset detects toolchain support"
735
+ },
736
+ optimization("ios.prefixMapping", "Clang source and DerivedData prefix mapping"),
737
+ {
738
+ ...optimization("android.compilerCache", "Android compiler cache backend", {
739
+ kind: "choice",
740
+ choices: ANDROID_COMPILER_CACHE_CHOICES
741
+ }),
742
+ default: "auto"
743
+ },
744
+ {
745
+ ...optimization("android.casToolchain", "Absolute path to the Android CAS toolchain manifest", ABSOLUTE_PATH),
746
+ env: "STIM_ANDROID_CAS_TOOLCHAIN"
747
+ },
748
+ {
749
+ ...optimization("android.pch", "Android precompiled headers", {
750
+ kind: "choice",
751
+ choices: ANDROID_PCH_CHOICES
752
+ }),
753
+ default: "auto"
754
+ },
755
+ optimization("android.gradleBuildCache", "Gradle build cache"),
756
+ optimization("android.targetAbiOnly", "Narrow Debug builds to the device ABI"),
757
+ {
758
+ key: "ios.deviceType",
759
+ type: STRING,
760
+ scopes: PROJECT,
761
+ description: "Simulator model for owned simulators"
762
+ },
763
+ {
764
+ key: "ios.runtime",
765
+ type: STRING,
766
+ scopes: PROJECT,
767
+ description: "iOS runtime owned simulators are created on"
768
+ },
769
+ {
770
+ key: "ios.configuration",
771
+ type: STRING,
772
+ scopes: PROJECT,
773
+ default: "Debug",
774
+ description: "Xcode configuration to build, such as Debug or Release"
775
+ },
776
+ {
777
+ key: "ios.remote",
778
+ type: REMOTE,
779
+ scopes: PROJECT,
780
+ description: "Default remote backend for iOS"
781
+ },
782
+ {
783
+ key: "ios.simslimProfile",
784
+ type: RELATIVE_PATH,
785
+ scopes: PROJECT,
786
+ description: "SimSlim JSON profile under the app"
787
+ },
788
+ {
789
+ key: "ios.signingIdentity",
790
+ type: STRING,
791
+ scopes: PROJECT,
792
+ description: "Keychain identity used to re-seal a device build"
793
+ },
794
+ {
795
+ key: "ios.signingIdentitySha1",
796
+ type: {
797
+ kind: "string",
798
+ pattern: "^[0-9A-Fa-f]{40}$",
799
+ patternHelp: "a 40-character hex SHA-1 hash"
800
+ },
801
+ scopes: PROJECT,
802
+ description: "SHA-1 of the signing identity, when two share a name"
803
+ },
804
+ {
805
+ key: "ios.lanHost",
806
+ type: {
807
+ kind: "string",
808
+ pattern: "^(?!-)[A-Za-z0-9-]+(?:\\.(?!-)[A-Za-z0-9-]+)*$",
809
+ patternHelp: "a bare address or hostname, never a URL, scheme, or port"
810
+ },
811
+ scopes: PROJECT,
812
+ description: "Address a phone uses to reach this workspace's Metro"
813
+ },
814
+ {
815
+ key: "android.systemImage",
816
+ type: STRING,
817
+ scopes: PROJECT,
818
+ description: "SDK system image for owned AVDs"
819
+ },
820
+ {
821
+ key: "android.dataPartitionSizeGb",
822
+ type: {
823
+ kind: "number",
824
+ integer: true,
825
+ minimum: 6,
826
+ maximum: 16384
827
+ },
828
+ scopes: PROJECT,
829
+ default: 8,
830
+ description: "Data partition size of a new owned AVD, in GiB"
831
+ },
832
+ {
833
+ key: "android.avdConfigFile",
834
+ type: RELATIVE_PATH,
835
+ scopes: PROJECT,
836
+ description: "AVD config.ini fragment under the app"
837
+ },
838
+ {
839
+ key: "android.avdConfig",
840
+ type: OBJECT,
841
+ scopes: PROJECT,
842
+ description: "Validated AVD config values"
843
+ },
844
+ {
845
+ key: "android.variant",
846
+ type: STRING,
847
+ scopes: PROJECT,
848
+ description: "Gradle build variant"
849
+ },
850
+ {
851
+ key: "android.keystore",
852
+ type: PATH,
853
+ scopes: PROJECT,
854
+ description: "Keystore a re-packed release APK is signed with"
855
+ },
856
+ {
857
+ key: "android.keystorePassword",
858
+ type: STRING,
859
+ scopes: PROJECT,
860
+ sensitive: true,
861
+ description: "Keystore password, or an apksigner env: or file: reference"
862
+ },
863
+ {
864
+ key: "android.remote",
865
+ type: REMOTE,
866
+ scopes: PROJECT,
867
+ description: "Default remote backend for Android"
868
+ },
869
+ {
870
+ key: "metro.tunnel",
871
+ type: {
872
+ kind: "choice",
873
+ choices: TUNNEL_MODES
874
+ },
875
+ scopes: PROJECT,
876
+ default: "auto",
877
+ description: "How a remote device reaches Metro"
878
+ },
879
+ {
880
+ key: "metro.ngrokUrl",
881
+ type: STRING,
882
+ scopes: PROJECT,
883
+ description: "Stable ngrok URL for the managed tunnel"
884
+ },
885
+ {
886
+ key: "metro.publicUrl",
887
+ type: STRING,
888
+ scopes: PROJECT,
889
+ description: "Existing public Metro URL"
890
+ },
891
+ {
892
+ key: "metro.warmupUrl.ios",
893
+ type: { kind: "bundle-url" },
894
+ scopes: PROJECT,
895
+ description: "Bundle URL `stim ios` prefetches to warm Metro"
896
+ },
897
+ {
898
+ key: "metro.warmupUrl.android",
899
+ type: { kind: "bundle-url" },
900
+ scopes: PROJECT,
901
+ description: "Bundle URL `stim android` prefetches to warm Metro"
902
+ },
903
+ {
904
+ key: "metro.idleStopMinutes",
905
+ type: {
906
+ kind: "number",
907
+ integer: true,
908
+ minimum: 0
909
+ },
910
+ scopes: PROJECT,
911
+ default: 60,
912
+ description: "Minutes without a bundle request, client log or Stim command before the dev server stops; 0 never"
913
+ },
914
+ {
915
+ key: "worktree.exclude",
916
+ type: { kind: "strings" },
917
+ scopes: ["repo", "committed"],
918
+ committedAt: "repository",
919
+ description: "Ignored paths `worktree warm` skips"
920
+ },
921
+ {
922
+ key: "worktree.defaultBranch",
923
+ type: STRING,
924
+ scopes: ["repo", "committed"],
925
+ committedAt: "repository",
926
+ description: "Branch `worktree warm --refresh` expects the source checkout on"
927
+ },
928
+ {
929
+ key: "cache.provider",
930
+ type: STRING,
931
+ scopes: PROJECT,
932
+ description: "Second-tier cache provider module"
933
+ },
934
+ {
935
+ key: "cache.options",
936
+ type: OBJECT,
937
+ scopes: PROJECT,
938
+ description: "Options passed to the cache provider"
939
+ },
940
+ {
941
+ key: "iosSimulatorApp",
942
+ type: {
943
+ kind: "choice",
944
+ choices: IOS_SIMULATOR_APPS
945
+ },
946
+ scopes: MACHINE,
947
+ default: "xcode",
948
+ description: "App that displays an owned iOS simulator"
949
+ },
950
+ {
951
+ key: "concurrency.maxBuilds",
952
+ type: CAPACITY,
953
+ scopes: MACHINE,
954
+ default: 0,
955
+ env: "STIM_MAX_BUILDS",
956
+ description: "Concurrent native builds; 0 means no limit"
957
+ },
958
+ {
959
+ key: "concurrency.maxDevices",
960
+ type: CAPACITY,
961
+ scopes: MACHINE,
962
+ default: 0,
963
+ env: "STIM_MAX_DEVICES",
964
+ description: "Booted owned devices; 0 means no limit"
965
+ },
966
+ {
967
+ key: "budget.minFreeDiskGb",
968
+ type: GIGABYTES,
969
+ scopes: MACHINE,
970
+ default: 20,
971
+ env: "STIM_BUDGET_MIN_FREE_DISK_GB",
972
+ scopedHomeValue: 0,
973
+ description: "Free disk, in GB, ios, android and start reclaim toward; 0 reclaims only below the hard floor"
974
+ },
975
+ {
976
+ key: "budget.hardFloorDiskGb",
977
+ type: GIGABYTES,
978
+ scopes: MACHINE,
979
+ default: 5,
980
+ env: "STIM_BUDGET_HARD_FLOOR_DISK_GB",
981
+ scopedHomeValue: 0,
982
+ description: "Free disk, in GB, below which ios, android and start refuse with STIM_LOW_DISK; 0 never refuses"
983
+ },
984
+ {
985
+ key: "budget.maxCommittedMemoryGb",
986
+ type: GIGABYTES,
987
+ scopes: MACHINE,
988
+ env: "STIM_BUDGET_MAX_COMMITTED_MEMORY_GB",
989
+ scopedHomeValue: 0,
990
+ description: "Estimated memory, in GB, of live environments before idle ones are reclaimed; unset is 60% of RAM, 0 is off"
991
+ },
992
+ {
993
+ key: "budget.maxLiveWorkspaces",
994
+ type: CAPACITY,
995
+ scopes: MACHINE,
996
+ env: "STIM_BUDGET_MAX_LIVE_WORKSPACES",
997
+ scopedHomeValue: 0,
998
+ description: "Live workspaces before idle ones are reclaimed; unset or 0 means no limit"
999
+ },
1000
+ {
1001
+ key: "pool.iosParkedMax",
1002
+ type: PARKED_MAX,
1003
+ scopes: MACHINE,
1004
+ default: 3,
1005
+ env: "STIM_POOL_IOS_PARKED_MAX",
1006
+ scopedHomeValue: 0,
1007
+ description: "Parked simulators kept for adoption; 0 turns parking off"
1008
+ },
1009
+ {
1010
+ key: "pool.androidParkedMax",
1011
+ type: PARKED_MAX,
1012
+ scopes: MACHINE,
1013
+ default: 3,
1014
+ env: "STIM_POOL_ANDROID_PARKED_MAX",
1015
+ scopedHomeValue: 0,
1016
+ description: "Parked emulators kept for adoption; 0 turns parking off"
1017
+ },
1018
+ {
1019
+ key: "caches.buildCache",
1020
+ type: ABSOLUTE_PATH,
1021
+ scopes: MACHINE,
1022
+ env: "STIM_BUILD_CACHE",
1023
+ description: "Absolute path of the shared build cache"
1024
+ },
1025
+ {
1026
+ key: "caches.metroCache",
1027
+ type: ABSOLUTE_PATH,
1028
+ scopes: MACHINE,
1029
+ env: "STIM_METRO_CACHE",
1030
+ description: "Absolute parent path of the Metro transform caches"
1031
+ },
1032
+ {
1033
+ key: "tempDir",
1034
+ type: ABSOLUTE_PATH,
1035
+ scopes: MACHINE,
1036
+ env: "STIM_TMPDIR",
1037
+ description: "Absolute directory for large temporary copies"
1038
+ }
1039
+ ];
1040
+ const BY_KEY = new Map(SETTINGS.map((setting) => [setting.key, setting]));
1041
+ function settingDefinition(key) {
1042
+ return BY_KEY.get(key) ?? null;
1043
+ }
1044
+ function isLayeredSetting(setting) {
1045
+ return setting.scopes.some((scope) => scope !== "machine");
1046
+ }
1047
+ const SETTING_GROUPS = [...new Set(SETTINGS.filter(isLayeredSetting).flatMap((setting) => setting.key.split(".").slice(0, -1).map((_, index, parts) => parts.slice(0, index + 1).join("."))))];
1048
+ function isPlainObject(value) {
1049
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1050
+ }
1051
+ function bundleUrlAccepts(value, key) {
1052
+ if (typeof value !== "string" || !/^(https?:\/\/|\/(?!\/))/.test(value) || /[\s\\#]/.test(value)) return false;
1053
+ try {
1054
+ const url = new URL(value, "http://localhost");
1055
+ return /\.bundle\/*$/.test(url.pathname) && url.searchParams.get("platform") === key.split(".").at(-1);
1056
+ } catch {
1057
+ return false;
1058
+ }
1059
+ }
1060
+ function expectedShape(type) {
1061
+ switch (type.kind) {
1062
+ case "boolean": return "true or false";
1063
+ case "string": return "a string";
1064
+ case "path": return "a string path";
1065
+ case "strings": return "an array of strings";
1066
+ case "number": return "a number";
1067
+ case "choice": return `one of: ${type.choices.join(", ")}`;
1068
+ case "object": return "an object";
1069
+ case "bundle-url": return "an HTTP(S) URL or /path ending in .bundle with a matching platform query and no fragment";
1070
+ }
1071
+ }
1072
+ function acceptsShape(setting, value) {
1073
+ const { type } = setting;
1074
+ switch (type.kind) {
1075
+ case "boolean": return typeof value === "boolean";
1076
+ case "string":
1077
+ case "path": return typeof value === "string";
1078
+ case "strings": return Array.isArray(value) && value.every((entry) => typeof entry === "string");
1079
+ case "number": return typeof value === "number";
1080
+ case "choice": return typeof value === "string" && type.choices.includes(value);
1081
+ case "object": return isPlainObject(value);
1082
+ case "bundle-url": return bundleUrlAccepts(value, setting.key);
1083
+ }
1084
+ }
1085
+ function numberBounds(type) {
1086
+ const noun = type.integer ? "a whole number" : "a number";
1087
+ if (type.minimum !== void 0 && type.maximum !== void 0) return `${noun} from ${type.minimum} through ${type.maximum}`;
1088
+ if (type.minimum !== void 0) return `${noun}, ${type.minimum} or more`;
1089
+ return noun;
1090
+ }
1091
+ function settingValueError(setting, value) {
1092
+ const { type } = setting;
1093
+ if (!acceptsShape(setting, value)) return type.kind === "number" ? numberBounds(type) : expectedShape(type);
1094
+ if (type.kind === "number") {
1095
+ const number = value;
1096
+ if (!Number.isFinite(number) || type.integer && !Number.isSafeInteger(number) || type.minimum !== void 0 && number < type.minimum || type.maximum !== void 0 && number > type.maximum) return numberBounds(type);
1097
+ }
1098
+ if (type.kind === "path") {
1099
+ const path = value;
1100
+ if (type.absolute && !isAbsolute(path)) return "an absolute path";
1101
+ if (type.relative && (path.trim() === "" || isAbsolute(path))) return "a relative path inside the app directory";
1102
+ }
1103
+ if (type.kind === "string" && type.pattern && !new RegExp(type.pattern).test(value)) return type.patternHelp ?? `a string matching ${type.pattern}`;
1104
+ return null;
1105
+ }
1106
+ const SENSITIVE_REFERENCE = /^(env|file):\S/;
1107
+ function isSensitiveReference(value) {
1108
+ return typeof value === "string" && SENSITIVE_REFERENCE.test(value);
1109
+ }
1110
+ const JSON_ENCODED_KINDS = [
1111
+ "boolean",
1112
+ "number",
1113
+ "strings",
1114
+ "object"
1115
+ ];
1116
+ /**
1117
+ * Coerces raw text (a CLI argument or an environment variable) through a setting's
1118
+ * registry type, the same way for both sources. Types whose values are not written
1119
+ * as bare text (boolean, number, strings, object) are read as JSON; a value that
1120
+ * fails to parse is left as the original string so `settingValueError` reports it.
1121
+ */
1122
+ function coerceSettingText(setting, raw) {
1123
+ if (!JSON_ENCODED_KINDS.includes(setting.type.kind)) return raw;
1124
+ try {
1125
+ return JSON.parse(raw);
1126
+ } catch {
1127
+ return raw;
1128
+ }
1129
+ }
1130
+ //#endregion
1131
+ //#region state/settings-schema.ts
1132
+ const SETTINGS_SCHEMA_FILE = "settings.schema.json";
1133
+ const SETTINGS_SCHEMA_URL = `https://unpkg.com/stim/dist/${SETTINGS_SCHEMA_FILE}`;
1134
+ function leafSchema(setting, scope) {
1135
+ const { type } = setting;
1136
+ const schema = { description: setting.description };
1137
+ switch (type.kind) {
1138
+ case "string":
1139
+ schema.type = "string";
1140
+ if (type.pattern) schema.pattern = type.pattern;
1141
+ break;
1142
+ case "path":
1143
+ case "bundle-url":
1144
+ schema.type = "string";
1145
+ break;
1146
+ case "strings":
1147
+ schema.type = "array";
1148
+ schema.items = { type: "string" };
1149
+ break;
1150
+ case "number":
1151
+ schema.type = type.integer ? "integer" : "number";
1152
+ if (type.minimum !== void 0) schema.minimum = type.minimum;
1153
+ if (type.maximum !== void 0) schema.maximum = type.maximum;
1154
+ break;
1155
+ case "boolean":
1156
+ schema.type = "boolean";
1157
+ break;
1158
+ case "choice":
1159
+ schema.type = "string";
1160
+ schema.enum = [...type.choices];
1161
+ break;
1162
+ case "object": schema.type = "object";
1163
+ }
1164
+ if (setting.sensitive && scope === "committed") schema.pattern = "^(env|file):\\S";
1165
+ if (setting.default !== void 0) schema.default = setting.default;
1166
+ schema["x-stim"] = {
1167
+ key: setting.key,
1168
+ kind: type.kind,
1169
+ scopes: [...setting.scopes],
1170
+ ...type.kind === "path" && type.absolute ? { absolute: true } : {},
1171
+ ...type.kind === "path" && type.relative ? { relative: true } : {},
1172
+ ...setting.env ? { env: setting.env } : {},
1173
+ ...setting.sensitive ? { sensitive: true } : {},
1174
+ ...setting.committedAt ? { committedAt: setting.committedAt } : {}
1175
+ };
1176
+ return schema;
1177
+ }
1178
+ function objectSchema(settings, scope, closed) {
1179
+ const root = {
1180
+ type: "object",
1181
+ properties: {},
1182
+ additionalProperties: !closed
1183
+ };
1184
+ for (const setting of settings) {
1185
+ const parts = setting.key.split(".");
1186
+ let node = root;
1187
+ for (const part of parts.slice(0, -1)) {
1188
+ const properties = node.properties;
1189
+ properties[part] ??= {
1190
+ type: "object",
1191
+ properties: {},
1192
+ additionalProperties: false
1193
+ };
1194
+ node = properties[part];
1195
+ }
1196
+ node.properties[parts.at(-1)] = leafSchema(setting, scope);
1197
+ }
1198
+ return root;
1199
+ }
1200
+ /**
1201
+ * The published settings schema. The root describes a committed `.stim.json`;
1202
+ * `$defs.machine` describes the settings keys of `$STIM_HOME/config.json`.
1203
+ * Every leaf carries `x-stim` with its dotted key, kind, writable scopes,
1204
+ * environment override, and sensitivity.
1205
+ */
1206
+ function settingsJsonSchema() {
1207
+ const committed = objectSchema(SETTINGS.filter((setting) => setting.scopes.includes("committed")), "committed", true);
1208
+ committed.properties.$schema = { type: "string" };
1209
+ return {
1210
+ $schema: "https://json-schema.org/draft/2020-12/schema",
1211
+ $id: SETTINGS_SCHEMA_URL,
1212
+ title: "Stim settings",
1213
+ description: "Settings in a committed .stim.json. $defs.machine lists the machine settings in $STIM_HOME/config.json.",
1214
+ ...committed,
1215
+ $defs: { machine: objectSchema(SETTINGS.filter((setting) => setting.scopes.includes("machine")), "machine", false) }
1216
+ };
1217
+ }
1218
+ //#endregion
1219
+ //#region state/status.ts
1220
+ const BUILD_PHASES = [
1221
+ "prepare",
1222
+ "cache-lookup",
1223
+ "wait",
1224
+ "prebuild",
1225
+ "pods",
1226
+ "compile",
1227
+ "install",
1228
+ "launch"
1229
+ ];
1230
+ //#endregion
1231
+ //#region state/workspace-state.ts
1232
+ const IDLE_STOP_KEY = "devServerStop";
1233
+ function readIdleStop(state) {
1234
+ const record = state?.[IDLE_STOP_KEY];
1235
+ if (record?.reason !== "idle" || typeof record.at !== "string" || typeof record.idleMinutes !== "number") return null;
1236
+ return {
1237
+ reason: "idle",
1238
+ at: record.at,
1239
+ idleMinutes: record.idleMinutes
1240
+ };
1241
+ }
1242
+ function readWorkspaceState(root) {
1243
+ return readJsonObject(workspaceStateFile(root));
1244
+ }
1245
+ function lastUseFrom(state, logMtimes) {
1246
+ const candidates = [
1247
+ Date.parse(String(state?.lastUsedAt ?? "")),
1248
+ Date.parse(String(state?.lastBuild?.startedAt ?? "")),
1249
+ Date.parse(String(state?.supervisor?.startedAt ?? "")),
1250
+ ...logMtimes
1251
+ ].filter(Number.isFinite);
1252
+ return candidates.length ? Math.max(...candidates) : NaN;
1253
+ }
1254
+ function workspaceLastUsed(root) {
1255
+ const logs = workspaceLogsDir(root);
1256
+ let mtimes = [];
1257
+ try {
1258
+ mtimes = readdirSync(logs).flatMap((name) => {
1259
+ try {
1260
+ return [statSync(join(logs, name)).mtimeMs];
1261
+ } catch {
1262
+ return [];
1263
+ }
1264
+ });
1265
+ } catch {}
1266
+ return lastUseFrom(readWorkspaceState(root), mtimes);
1267
+ }
1268
+ //#endregion
1269
+ export { ANDROID_COMPILER_CACHE_CHOICES, ANDROID_PCH_CHOICES, BUILD_PHASES, ERROR_SOURCES, IDLE_STOP_KEY, IOS_SIMULATOR_APPS, LEVELS, REMOTE_DEVICE_BACKENDS, SETTINGS, SETTINGS_SCHEMA_FILE, SETTINGS_SCHEMA_URL, SETTING_GROUPS, SETTING_SCOPES, SOURCES, TUNNEL_MODES, acceptsShape, advanceTail, allMetroPorts, attachExpoErrorContext, buildCriteria, coerceSettingText, compileGrep, configCorruptRepair, configLockPath, createdDevicesFile, createdDevicesLock, easMachineStateRoot, easSessionLedgerFile, easSessionLedgerLock, emulatorLogFile, expectedShape, fileSizes, findEnclosingWorktreeRoot, findProjectByMetroPort, followLogs, getConcurrencyLimits, getConfigPath, getProject, getProjectSetting, getProjectSettings, getRepoSettings, isJsonObject, isLayeredSetting, isPathPrefix, isSensitiveReference, lastUseFrom, levelRank, loadConfig, logFiles, markerWindow, parseNdjsonLine, parseNdjsonText, parseSince, queryLogs, readCreatedDevices, readEasSessionLedger, readIdleStop, readJsonFile, readJsonObject, readLogRecords, readNdjsonGenerations, readWorkspaceState, recordMatches, settingDefinition, settingValueError, settingsJsonSchema, sharedCcache, sharedCompilationCache, sharedGradle, sharedPods, sortByTs, supervisorLogFile, supervisorPidFile, tailRead, validEasSessionClaim, withConfigLock, workspaceDerivedData, workspaceGradleBuild, workspaceLastUsed, workspaceLogErrorIndex, workspaceLogsDir, workspaceMetadataFile, workspaceStateFile, workspaceStateLock };