stim 1.0.0 → 1.1.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 +22 -10
- package/dist/android-CC5bUer7.mjs +204 -0
- package/dist/android-DnZlT3aU.mjs +2279 -0
- package/dist/android-cas-DGlTStWu.mjs +1078 -0
- package/dist/android-cas-compiler.mjs +1 -1
- package/dist/android-xmyGBNy5.mjs +665 -0
- package/dist/app-install-oZIQRbdD.mjs +1141 -0
- package/dist/build-lock-vE45ZzJZ.mjs +645 -0
- package/dist/build-slots-CgacA9zQ.mjs +152 -0
- package/dist/{cache-manifest-4tH64LQ9.mjs → cache-manifest-oeH18ERr.mjs} +2 -2
- package/dist/cache-manifest.mjs +1 -1
- package/dist/cli.mjs +24 -27166
- package/dist/collector-run.mjs +6 -4
- package/dist/command-output-8wHMEde0.mjs +289 -0
- package/dist/{config-7kmtuhO2.mjs → config-D7uu8Gbq.mjs} +43 -12
- package/dist/deps-64wVnpZc.mjs +435 -0
- package/dist/dev-client-Bvky1hya.mjs +557 -0
- package/dist/device-DJKv5Qej.mjs +250 -0
- package/dist/device-lease-D-G3SgVe.mjs +335 -0
- package/dist/device-pool-BlHXnYYs.mjs +408 -0
- package/dist/device-remote-CCgkG82J.mjs +1209 -0
- package/dist/doctor-BRF9aAJ3.mjs +530 -0
- package/dist/doctor-DlVxTtMq.mjs +1137 -0
- package/dist/error-diagnostics-B5IJwv3-.mjs +636 -0
- package/dist/gc-DhXdgSdt.mjs +1651 -0
- package/dist/guide-DrhrDBo5.mjs +4319 -0
- package/dist/ios-BZxKPuqP.mjs +382 -0
- package/dist/ios-CK1Vrk6Q.mjs +2721 -0
- package/dist/ios-device-CYbuRNXR.mjs +81 -0
- package/dist/ios-device-CdSm0GDw.mjs +413 -0
- package/dist/logs-Dxoy16Qv.mjs +176 -0
- package/dist/logs-query-oqn47Ffx.mjs +273 -0
- package/dist/metro-B01H3EuD.mjs +184 -0
- package/dist/metro-store-CDDZHtPl.mjs +92 -0
- package/dist/ownership-B9QIdQOw.mjs +475 -0
- package/dist/ownership-D8HmSv2E.mjs +60 -0
- package/dist/ownership-claim-CeJLEpjD.mjs +569 -0
- package/dist/{project-Dit1CckW.mjs → project-COj6nzzh.mjs} +12 -3
- package/dist/reclaim-BPvN2HlP.mjs +347 -0
- package/dist/reload-DYZEBbZy.mjs +332 -0
- package/dist/remote-cache-DOi5-zLj.mjs +934 -0
- package/dist/{server-bare-BmTVVmBB.mjs → server-bare-BYHqEeyw.mjs} +3 -2
- package/dist/{server-expo-CHJe1r6j.mjs → server-expo-BEMr_m6p.mjs} +5 -4
- package/dist/server-expo-rb0k-0w7.mjs +2 -0
- package/dist/settings-BSwrY4FZ.mjs +687 -0
- package/dist/spawn-entry-nCE6m770.mjs +14 -0
- package/dist/start-ByCylYtB.mjs +822 -0
- package/dist/{state-2nush3MK.mjs → state-IYEY-zi8.mjs} +21 -22
- package/dist/stats-DZ4Q5J-7.mjs +62 -0
- package/dist/stats-wqxPm7Tv.mjs +307 -0
- package/dist/status-Dia0XTqG.mjs +363 -0
- package/dist/stop-BApyaF21.mjs +591 -0
- package/dist/stop-D6o5zAdm.mjs +3 -0
- package/dist/supervisor-run.d.mts +0 -1
- package/dist/supervisor-run.mjs +4 -4
- package/dist/workspace-process-lock-CxOO9-pf.mjs +47 -0
- package/dist/worktree-Dx3ZC5lS.mjs +539 -0
- package/dist/worktree-DxEmUI_n.mjs +1322 -0
- package/dist/xcode-CDB6fytw.mjs +1705 -0
- package/package.json +5 -5
- 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,250 @@
|
|
|
1
|
+
import { a as findProjectRoot } from "./project-COj6nzzh.mjs";
|
|
2
|
+
import { r as clockTime } from "./command-output-8wHMEde0.mjs";
|
|
3
|
+
import { f as takeLease, l as releaseWorkspaceLeases, n as fileLeaseIo, o as parseLeaseDuration } from "./device-lease-D-G3SgVe.mjs";
|
|
4
|
+
import { d as waitForDevice, s as parseDeviceWait, t as selectFromPool } from "./device-pool-BlHXnYYs.mjs";
|
|
5
|
+
import { E as physicalDeviceModel, O as probeEmulatorSerial, _ as listAdbDevices, b as memoizeEmulatorProbe, i as androidPoolNoCandidatesRefusal, j as resolvePhysicalDevice, r as androidPoolCandidates } from "./android-xmyGBNy5.mjs";
|
|
6
|
+
import { c as listIosDevices, o as iosPoolCandidates, s as iosPoolNoCandidatesRefusal, u as resolveIosPhysicalDevice } from "./ios-device-CdSm0GDw.mjs";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
//#region src/commands/device.ts
|
|
9
|
+
const DEFAULT_FOR = "5m";
|
|
10
|
+
const DEFAULT_DEPS = {
|
|
11
|
+
findProjectRoot,
|
|
12
|
+
listIosDevices,
|
|
13
|
+
listAdbDevices,
|
|
14
|
+
physicalDeviceModel,
|
|
15
|
+
probeEmulatorSerial,
|
|
16
|
+
takeLease,
|
|
17
|
+
releaseLeases: releaseWorkspaceLeases,
|
|
18
|
+
waitForDevice,
|
|
19
|
+
selectFromPool,
|
|
20
|
+
io: fileLeaseIo,
|
|
21
|
+
now: () => Date.now(),
|
|
22
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
23
|
+
out: (line) => console.log(line),
|
|
24
|
+
note: (line) => console.error(line)
|
|
25
|
+
};
|
|
26
|
+
function grantLine(facts, durationText) {
|
|
27
|
+
return `locked ${facts.deviceName ? `${facts.deviceName} (${facts.id})` : facts.id} for ${facts.holder} until ${clockTime(facts.expiresAt)} (${durationText}). Renew: stim device lock ${facts.platform} --for ${durationText}. Release: stim device unlock.`;
|
|
28
|
+
}
|
|
29
|
+
function releasedLine(lease) {
|
|
30
|
+
return `unlocked ${lease.deviceName ? `${lease.deviceName} (${lease.id})` : lease.id} -- the ${lease.platform} lease ran until ${clockTime(lease.expiresAt)}.`;
|
|
31
|
+
}
|
|
32
|
+
function readPlatform(value) {
|
|
33
|
+
return value === "ios" || value === "android" ? value : null;
|
|
34
|
+
}
|
|
35
|
+
function resolveDevice(platform, requested, d) {
|
|
36
|
+
if (platform === "ios") {
|
|
37
|
+
const resolved = resolveIosPhysicalDevice(requested, d.listIosDevices());
|
|
38
|
+
if (!resolved.udid) return {
|
|
39
|
+
code: "STIM_NO_DEVICE",
|
|
40
|
+
message: resolved.error,
|
|
41
|
+
remedy: resolved.remedy ?? null
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
id: resolved.udid,
|
|
45
|
+
deviceName: resolved.name ?? resolved.udid
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const resolved = resolvePhysicalDevice(requested, d.listAdbDevices(), d.probeEmulatorSerial);
|
|
49
|
+
if (!resolved.serial) return {
|
|
50
|
+
code: "STIM_NO_DEVICE",
|
|
51
|
+
message: resolved.error,
|
|
52
|
+
remedy: resolved.remedy ?? null
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
id: resolved.serial,
|
|
56
|
+
deviceName: d.physicalDeviceModel(resolved.serial) ?? resolved.serial
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function isFailure(value) {
|
|
60
|
+
return "code" in value;
|
|
61
|
+
}
|
|
62
|
+
async function poolDevice(platform, idLabel, waitSeconds, deadline, root, d) {
|
|
63
|
+
const isEmulator = memoizeEmulatorProbe(d.probeEmulatorSerial);
|
|
64
|
+
const pooled = await d.selectFromPool({
|
|
65
|
+
root,
|
|
66
|
+
platform,
|
|
67
|
+
idLabel,
|
|
68
|
+
list: () => platform === "ios" ? iosPoolCandidates(d.listIosDevices()).map((entry) => ({
|
|
69
|
+
id: entry.udid,
|
|
70
|
+
name: entry.name
|
|
71
|
+
})) : androidPoolCandidates(d.listAdbDevices(), isEmulator).map((entry) => ({ id: entry.serial })),
|
|
72
|
+
noCandidates: () => {
|
|
73
|
+
const resolved = platform === "ios" ? iosPoolNoCandidatesRefusal(d.listIosDevices()) : androidPoolNoCandidatesRefusal(d.listAdbDevices(), isEmulator);
|
|
74
|
+
return {
|
|
75
|
+
message: resolved.error,
|
|
76
|
+
remedy: resolved.remedy
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
waitSeconds,
|
|
80
|
+
deadline,
|
|
81
|
+
now: d.now,
|
|
82
|
+
sleep: d.sleep,
|
|
83
|
+
warn: (line) => d.note(chalk.yellow(line)),
|
|
84
|
+
io: d.io
|
|
85
|
+
});
|
|
86
|
+
if (pooled.status === "refused") return {
|
|
87
|
+
code: pooled.refusal.code,
|
|
88
|
+
message: pooled.refusal.message,
|
|
89
|
+
remedy: pooled.refusal.remedy,
|
|
90
|
+
...pooled.refusal.lease === null ? {} : { lease: pooled.refusal.lease }
|
|
91
|
+
};
|
|
92
|
+
const name = pooled.candidate.name ?? (platform === "ios" ? pooled.candidate.id : d.physicalDeviceModel(pooled.candidate.id));
|
|
93
|
+
return {
|
|
94
|
+
id: pooled.candidate.id,
|
|
95
|
+
deviceName: name ?? pooled.candidate.id
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async function runLock(platformArg, idArg, opts = {}, overrides = {}) {
|
|
99
|
+
const d = {
|
|
100
|
+
...DEFAULT_DEPS,
|
|
101
|
+
...overrides
|
|
102
|
+
};
|
|
103
|
+
const json = Boolean(opts.json);
|
|
104
|
+
const report = (failure) => {
|
|
105
|
+
d.note(chalk.red(`${failure.code}: ${failure.message}`));
|
|
106
|
+
if (failure.remedy) d.note(chalk.dim(failure.remedy));
|
|
107
|
+
if (json) d.out(JSON.stringify({
|
|
108
|
+
code: failure.code,
|
|
109
|
+
message: failure.message,
|
|
110
|
+
remedy: failure.remedy,
|
|
111
|
+
...failure.lease === void 0 ? {} : { lease: failure.lease }
|
|
112
|
+
}));
|
|
113
|
+
return failure;
|
|
114
|
+
};
|
|
115
|
+
const platform = readPlatform(platformArg);
|
|
116
|
+
if (!platform) return report({
|
|
117
|
+
code: "STIM_BAD_ARG",
|
|
118
|
+
message: `\`stim device lock\` takes ios or android, not ${JSON.stringify(platformArg)}.`,
|
|
119
|
+
remedy: "Run `stim device lock ios` or `stim device lock android`."
|
|
120
|
+
});
|
|
121
|
+
const root = d.findProjectRoot(process.cwd());
|
|
122
|
+
if (!root) return report({
|
|
123
|
+
code: "STIM_NO_PROJECT",
|
|
124
|
+
message: "Not in a React Native project (no package.json found).",
|
|
125
|
+
remedy: "Run this from the app directory -- the one holding package.json."
|
|
126
|
+
});
|
|
127
|
+
const durationText = String(opts.for ?? DEFAULT_FOR).trim();
|
|
128
|
+
const duration = parseLeaseDuration(durationText);
|
|
129
|
+
if ("error" in duration) return report({
|
|
130
|
+
code: "STIM_BAD_ARG",
|
|
131
|
+
message: duration.error,
|
|
132
|
+
remedy: "Pass a whole number of seconds or minutes from 10s to 30m, e.g. --for 90s or --for 10m."
|
|
133
|
+
});
|
|
134
|
+
const wait = parseDeviceWait(opts.wait);
|
|
135
|
+
if ("error" in wait) return report({
|
|
136
|
+
code: "STIM_BAD_ARG",
|
|
137
|
+
message: wait.error,
|
|
138
|
+
remedy: "Pass a whole number of seconds, e.g. --wait 90. `--wait 0` refuses a leased device at once."
|
|
139
|
+
});
|
|
140
|
+
const idLabel = platform === "ios" ? "udid" : "serial";
|
|
141
|
+
const deadline = d.now() + wait.seconds * 1e3;
|
|
142
|
+
const lastLine = { at: null };
|
|
143
|
+
const device = idArg ? resolveDevice(platform, idArg, d) : await poolDevice(platform, idLabel, wait.seconds, deadline, root, d);
|
|
144
|
+
if (isFailure(device)) return report(device);
|
|
145
|
+
for (;;) {
|
|
146
|
+
const outcome = await d.waitForDevice({
|
|
147
|
+
root,
|
|
148
|
+
platform,
|
|
149
|
+
id: device.id,
|
|
150
|
+
idLabel,
|
|
151
|
+
waitSeconds: wait.seconds,
|
|
152
|
+
now: d.now,
|
|
153
|
+
sleep: d.sleep,
|
|
154
|
+
warn: (line) => d.note(chalk.yellow(line)),
|
|
155
|
+
io: d.io,
|
|
156
|
+
deadline,
|
|
157
|
+
lastLine
|
|
158
|
+
});
|
|
159
|
+
if (outcome.status === "refused") return report({
|
|
160
|
+
...outcome.refusal,
|
|
161
|
+
lease: outcome.refusal.lease
|
|
162
|
+
});
|
|
163
|
+
const taken = d.takeLease({
|
|
164
|
+
root,
|
|
165
|
+
platform,
|
|
166
|
+
id: device.id,
|
|
167
|
+
deviceName: device.deviceName,
|
|
168
|
+
kind: "declared",
|
|
169
|
+
durationMs: duration.ms
|
|
170
|
+
}, d.io);
|
|
171
|
+
if (taken.status === "unreadable") return report({
|
|
172
|
+
code: "STIM_DEVICE_BUSY",
|
|
173
|
+
message: `The lease file at ${taken.path} does not parse, so Stim cannot tell who holds that device.`,
|
|
174
|
+
remedy: `Read ${taken.path} and, once you know no run depends on it, delete it. \`stim gc\` reports it on every run.`,
|
|
175
|
+
lease: {
|
|
176
|
+
platform: null,
|
|
177
|
+
id: null,
|
|
178
|
+
deviceName: null,
|
|
179
|
+
holder: null,
|
|
180
|
+
expiresAt: null
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
if (taken.status === "held") continue;
|
|
184
|
+
const facts = {
|
|
185
|
+
platform,
|
|
186
|
+
id: taken.lease.id,
|
|
187
|
+
deviceName: taken.lease.deviceName,
|
|
188
|
+
holder: taken.lease.holder,
|
|
189
|
+
kind: "declared",
|
|
190
|
+
grantedAt: taken.lease.grantedAt,
|
|
191
|
+
expiresAt: taken.lease.expiresAt,
|
|
192
|
+
leaseSeconds: Math.round(duration.ms / 1e3)
|
|
193
|
+
};
|
|
194
|
+
if (json) d.out(JSON.stringify(facts));
|
|
195
|
+
else d.out(chalk.green(grantLine(facts, durationText)));
|
|
196
|
+
return facts;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function runUnlock(platformArg, opts = {}, overrides = {}) {
|
|
200
|
+
const d = {
|
|
201
|
+
...DEFAULT_DEPS,
|
|
202
|
+
...overrides
|
|
203
|
+
};
|
|
204
|
+
const json = Boolean(opts.json);
|
|
205
|
+
const report = (failure) => {
|
|
206
|
+
d.note(chalk.red(`${failure.code}: ${failure.message}`));
|
|
207
|
+
if (failure.remedy) d.note(chalk.dim(failure.remedy));
|
|
208
|
+
if (json) d.out(JSON.stringify({
|
|
209
|
+
code: failure.code,
|
|
210
|
+
message: failure.message,
|
|
211
|
+
remedy: failure.remedy
|
|
212
|
+
}));
|
|
213
|
+
return failure;
|
|
214
|
+
};
|
|
215
|
+
const platform = platformArg === void 0 ? null : readPlatform(platformArg);
|
|
216
|
+
if (platformArg !== void 0 && !platform) return report({
|
|
217
|
+
code: "STIM_BAD_ARG",
|
|
218
|
+
message: `\`stim device unlock\` takes ios or android, not ${JSON.stringify(platformArg)}.`,
|
|
219
|
+
remedy: "Run `stim device unlock` for every lease this workspace holds, or name one platform."
|
|
220
|
+
});
|
|
221
|
+
const root = d.findProjectRoot(process.cwd());
|
|
222
|
+
if (!root) return report({
|
|
223
|
+
code: "STIM_NO_PROJECT",
|
|
224
|
+
message: "Not in a React Native project (no package.json found).",
|
|
225
|
+
remedy: "Run this from the app directory -- the one holding package.json."
|
|
226
|
+
});
|
|
227
|
+
const released = d.releaseLeases(root, { platform }, d.io);
|
|
228
|
+
if (released.length === 0) d.note(chalk.dim(`No ${platform ? `${platform} ` : ""}device lease to release for ${root}. \`stim status\` lists every lease on this machine.`));
|
|
229
|
+
if (json) d.out(JSON.stringify(released.map((lease) => ({
|
|
230
|
+
...lease,
|
|
231
|
+
holder: root
|
|
232
|
+
}))));
|
|
233
|
+
else for (const lease of released) d.out(chalk.green(releasedLine(lease)));
|
|
234
|
+
return released;
|
|
235
|
+
}
|
|
236
|
+
function registerDevice(program, deps = {}) {
|
|
237
|
+
const device = program.command("device").description("Lease a connected physical device to this workspace, and give it back");
|
|
238
|
+
device.command("lock").argument("<platform>", "ios or android").argument("[id]", "the UDID or serial to lease; without one, the first free connected device is used").description("Lease a connected physical device to this workspace for a declared time, so another workspace's `--device` run waits instead of installing over it. Nothing but this command and a `--device` run moves the expiry.").option("--for <duration>", `How long to hold it: a whole number of seconds or minutes from 10s to 30m (default ${DEFAULT_FOR})`).option("--wait <seconds>", "How long to wait for a device another workspace holds, before refusing with STIM_DEVICE_BUSY (default 60, 0 refuses at once)").option("--json", "print the lease as JSON on stdout").action(async (platform, id, opts) => {
|
|
239
|
+
if ("code" in await runLock(platform, id, opts, deps)) process.exit(1);
|
|
240
|
+
});
|
|
241
|
+
device.command("unlock").argument("[platform]", "ios or android; without one, every lease this workspace holds is released").description("Release the device lease or leases this workspace holds. Releasing nothing is not an error.").option("--json", "print the released leases as JSON on stdout").action(async (platform, opts) => {
|
|
242
|
+
const result = await runUnlock(platform, opts, deps);
|
|
243
|
+
if (!Array.isArray(result)) process.exit(1);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
function deviceCommand(program) {
|
|
247
|
+
registerDevice(program);
|
|
248
|
+
}
|
|
249
|
+
//#endregion
|
|
250
|
+
export { deviceCommand as default, grantLine, registerDevice, releasedLine, runLock, runUnlock };
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { k as withDirLock, u as getConfigDir } from "./config-D7uu8Gbq.mjs";
|
|
2
|
+
import { _ as writeWorkspaceState, o as clearWorkspaceStateKeys, p as readWorkspaceState } from "./state-IYEY-zi8.mjs";
|
|
3
|
+
import { a as segment } from "./build-lock-vE45ZzJZ.mjs";
|
|
4
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
const DEFAULT_LEASE_MS = 3e5;
|
|
8
|
+
const LEASE_SUFFIX = ".json";
|
|
9
|
+
const LOCK_SUFFIX = ".lock";
|
|
10
|
+
const DURATION = /^[1-9][0-9]*(s|m)$/;
|
|
11
|
+
function deviceLocksDir() {
|
|
12
|
+
return join(getConfigDir(), "device-locks");
|
|
13
|
+
}
|
|
14
|
+
function leaseStem(platform, id) {
|
|
15
|
+
return `${segment(platform)}-${segment(id)}`;
|
|
16
|
+
}
|
|
17
|
+
function deviceLeasePath(platform, id) {
|
|
18
|
+
return join(deviceLocksDir(), `${leaseStem(platform, id)}${LEASE_SUFFIX}`);
|
|
19
|
+
}
|
|
20
|
+
function deviceLeaseLockPath(platform, id) {
|
|
21
|
+
return join(deviceLocksDir(), `${leaseStem(platform, id)}${LOCK_SUFFIX}`);
|
|
22
|
+
}
|
|
23
|
+
const fileLeaseIo = {
|
|
24
|
+
now: () => Date.now(),
|
|
25
|
+
readLease(path) {
|
|
26
|
+
try {
|
|
27
|
+
return readFileSync(path, "utf-8");
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
writeLease(path, body) {
|
|
33
|
+
mkdirSync(deviceLocksDir(), { recursive: true });
|
|
34
|
+
const tmp = `${path}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
35
|
+
writeFileSync(tmp, body);
|
|
36
|
+
try {
|
|
37
|
+
renameSync(tmp, path);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
rmSync(tmp, { force: true });
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
removeLease(path) {
|
|
44
|
+
rmSync(path, { force: true });
|
|
45
|
+
},
|
|
46
|
+
listLeaseNames() {
|
|
47
|
+
try {
|
|
48
|
+
return readdirSync(deviceLocksDir());
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
withLeaseLock(lockPath, fn) {
|
|
54
|
+
return withDirLock(lockPath, fn, { ensureParent: () => mkdirSync(deviceLocksDir(), { recursive: true }) });
|
|
55
|
+
},
|
|
56
|
+
readHolder(root) {
|
|
57
|
+
return parseWorkspaceLeases(readWorkspaceState(root)?.deviceLeases);
|
|
58
|
+
},
|
|
59
|
+
writeHolder(root, leases) {
|
|
60
|
+
if (Object.keys(leases).length === 0) clearWorkspaceStateKeys(root, ["deviceLeases"]);
|
|
61
|
+
else writeWorkspaceState(root, { deviceLeases: leases });
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
function text(value) {
|
|
65
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
66
|
+
}
|
|
67
|
+
function isPlatform(value) {
|
|
68
|
+
return value === "ios" || value === "android";
|
|
69
|
+
}
|
|
70
|
+
function parseLease(raw) {
|
|
71
|
+
if (typeof raw !== "string") return null;
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(raw);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
79
|
+
const entry = parsed;
|
|
80
|
+
if (entry.version !== 1 || !isPlatform(entry.platform)) return null;
|
|
81
|
+
const id = text(entry.id);
|
|
82
|
+
const holder = text(entry.holder);
|
|
83
|
+
const token = text(entry.token);
|
|
84
|
+
const expiresAt = text(entry.expiresAt);
|
|
85
|
+
if (!id || !holder || !token || !expiresAt || !Number.isFinite(Date.parse(expiresAt))) return null;
|
|
86
|
+
return {
|
|
87
|
+
version: 1,
|
|
88
|
+
platform: entry.platform,
|
|
89
|
+
id,
|
|
90
|
+
deviceName: text(entry.deviceName),
|
|
91
|
+
holder,
|
|
92
|
+
token,
|
|
93
|
+
grantedAt: text(entry.grantedAt),
|
|
94
|
+
expiresAt
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function parseWorkspaceLeases(value) {
|
|
98
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
99
|
+
const leases = {};
|
|
100
|
+
for (const [platform, entry] of Object.entries(value)) {
|
|
101
|
+
if (!isPlatform(platform) || !entry || typeof entry !== "object") continue;
|
|
102
|
+
const record = entry;
|
|
103
|
+
const id = text(record.id);
|
|
104
|
+
const token = text(record.token);
|
|
105
|
+
if (!id || !token) continue;
|
|
106
|
+
leases[platform] = {
|
|
107
|
+
id,
|
|
108
|
+
token,
|
|
109
|
+
kind: record.kind === "run" ? "run" : "declared"
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return leases;
|
|
113
|
+
}
|
|
114
|
+
function leaseIsExpired(lease, now) {
|
|
115
|
+
return Date.parse(lease.expiresAt) <= now;
|
|
116
|
+
}
|
|
117
|
+
function parseLeaseDuration(value) {
|
|
118
|
+
const raw = String(value ?? "").trim();
|
|
119
|
+
const match = DURATION.exec(raw);
|
|
120
|
+
if (!match) return { error: `--for must be a whole number of seconds or minutes, such as 90s or 5m (got "${raw}").` };
|
|
121
|
+
const ms = Number(raw.slice(0, -1)) * (match[1] === "s" ? 1e3 : 6e4);
|
|
122
|
+
if (ms < 1e4 || ms > 18e5) return { error: `--for must be between 10s and 30m (got "${raw}").` };
|
|
123
|
+
return { ms };
|
|
124
|
+
}
|
|
125
|
+
function listLeaseFiles(io = fileLeaseIo) {
|
|
126
|
+
const dir = deviceLocksDir();
|
|
127
|
+
const entries = [];
|
|
128
|
+
for (const name of io.listLeaseNames().toSorted()) {
|
|
129
|
+
if (!name.endsWith(LEASE_SUFFIX)) continue;
|
|
130
|
+
const path = join(dir, name);
|
|
131
|
+
const lease = parseLease(io.readLease(path));
|
|
132
|
+
const stem = name.slice(0, -5);
|
|
133
|
+
const cut = stem.indexOf("-");
|
|
134
|
+
entries.push({
|
|
135
|
+
path,
|
|
136
|
+
name,
|
|
137
|
+
platform: lease?.platform ?? (cut > 0 ? stem.slice(0, cut) : stem),
|
|
138
|
+
id: lease?.id ?? (cut > 0 ? stem.slice(cut + 1) : null),
|
|
139
|
+
lease
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return entries;
|
|
143
|
+
}
|
|
144
|
+
function writeLease(lease, io) {
|
|
145
|
+
io.writeLease(deviceLeasePath(lease.platform, lease.id), `${JSON.stringify(lease, null, 2)}\n`);
|
|
146
|
+
return lease;
|
|
147
|
+
}
|
|
148
|
+
function takeLease({ root, platform, id, deviceName = null, kind, durationMs = DEFAULT_LEASE_MS }, io = fileLeaseIo) {
|
|
149
|
+
const previous = io.readHolder(root)[platform];
|
|
150
|
+
if (previous && previous.id !== id) releaseHeld(root, platform, previous, io);
|
|
151
|
+
for (const entry of listLeaseFiles(io)) {
|
|
152
|
+
const other = entry.lease;
|
|
153
|
+
if (!other || other.platform !== platform || other.holder !== root || other.id === id) continue;
|
|
154
|
+
releaseByRoot(root, platform, other.id, io);
|
|
155
|
+
}
|
|
156
|
+
const declared = previous?.kind === "declared" && previous.id === id;
|
|
157
|
+
const path = deviceLeasePath(platform, id);
|
|
158
|
+
const result = io.withLeaseLock(deviceLeaseLockPath(platform, id), () => {
|
|
159
|
+
const now = io.now();
|
|
160
|
+
const raw = io.readLease(path);
|
|
161
|
+
const current = parseLease(raw);
|
|
162
|
+
if (raw !== null && !current) return {
|
|
163
|
+
status: "unreadable",
|
|
164
|
+
path
|
|
165
|
+
};
|
|
166
|
+
const asked = now + durationMs;
|
|
167
|
+
if (current && !leaseIsExpired(current, now)) {
|
|
168
|
+
if (current.holder !== root) return {
|
|
169
|
+
status: "held",
|
|
170
|
+
lease: current
|
|
171
|
+
};
|
|
172
|
+
const floor = declared && kind === "run" ? Math.max(asked, Date.parse(current.expiresAt)) : asked;
|
|
173
|
+
return {
|
|
174
|
+
status: "set",
|
|
175
|
+
lease: writeLease({
|
|
176
|
+
...current,
|
|
177
|
+
deviceName: deviceName ?? current.deviceName,
|
|
178
|
+
expiresAt: new Date(floor).toISOString()
|
|
179
|
+
}, io)
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const expiresAt = new Date(asked).toISOString();
|
|
183
|
+
return {
|
|
184
|
+
status: "taken",
|
|
185
|
+
lease: writeLease({
|
|
186
|
+
version: 1,
|
|
187
|
+
platform,
|
|
188
|
+
id,
|
|
189
|
+
deviceName,
|
|
190
|
+
holder: root,
|
|
191
|
+
token: randomBytes(12).toString("hex"),
|
|
192
|
+
grantedAt: new Date(now).toISOString(),
|
|
193
|
+
expiresAt
|
|
194
|
+
}, io)
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
if (result.status === "taken" || result.status === "set") {
|
|
198
|
+
const recorded = result.status === "set" && declared ? "declared" : kind;
|
|
199
|
+
io.writeHolder(root, {
|
|
200
|
+
...io.readHolder(root),
|
|
201
|
+
[platform]: {
|
|
202
|
+
id,
|
|
203
|
+
token: result.lease.token,
|
|
204
|
+
kind: recorded
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
function raiseLease({ root, platform, minMs }, io = fileLeaseIo) {
|
|
211
|
+
const record = io.readHolder(root)[platform];
|
|
212
|
+
if (!record) return { status: "none" };
|
|
213
|
+
const path = deviceLeasePath(platform, record.id);
|
|
214
|
+
return io.withLeaseLock(deviceLeaseLockPath(platform, record.id), () => {
|
|
215
|
+
const current = parseLease(io.readLease(path));
|
|
216
|
+
if (!current || current.token !== record.token) return {
|
|
217
|
+
status: "lost",
|
|
218
|
+
lease: current
|
|
219
|
+
};
|
|
220
|
+
const floor = io.now() + minMs;
|
|
221
|
+
if (Date.parse(current.expiresAt) >= floor) return {
|
|
222
|
+
status: "raised",
|
|
223
|
+
lease: current
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
status: "raised",
|
|
227
|
+
lease: writeLease({
|
|
228
|
+
...current,
|
|
229
|
+
expiresAt: new Date(floor).toISOString()
|
|
230
|
+
}, io)
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function summarize(lease) {
|
|
235
|
+
return {
|
|
236
|
+
platform: lease.platform,
|
|
237
|
+
id: lease.id,
|
|
238
|
+
deviceName: lease.deviceName,
|
|
239
|
+
expiresAt: lease.expiresAt
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function releaseHeld(root, platform, record, io) {
|
|
243
|
+
const path = deviceLeasePath(platform, record.id);
|
|
244
|
+
const released = io.withLeaseLock(deviceLeaseLockPath(platform, record.id), () => {
|
|
245
|
+
const current = parseLease(io.readLease(path));
|
|
246
|
+
if (!current || current.token !== record.token) return null;
|
|
247
|
+
io.removeLease(path);
|
|
248
|
+
return current;
|
|
249
|
+
});
|
|
250
|
+
const held = io.readHolder(root);
|
|
251
|
+
if (held[platform]?.token === record.token) {
|
|
252
|
+
delete held[platform];
|
|
253
|
+
io.writeHolder(root, held);
|
|
254
|
+
}
|
|
255
|
+
return released;
|
|
256
|
+
}
|
|
257
|
+
function releaseByRoot(root, platform, id, io) {
|
|
258
|
+
const path = deviceLeasePath(platform, id);
|
|
259
|
+
return io.withLeaseLock(deviceLeaseLockPath(platform, id), () => {
|
|
260
|
+
const current = parseLease(io.readLease(path));
|
|
261
|
+
if (!current || current.holder !== root) return null;
|
|
262
|
+
io.removeLease(path);
|
|
263
|
+
return current;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function releaseRunLease({ root, platform }, io = fileLeaseIo) {
|
|
267
|
+
const record = io.readHolder(root)[platform];
|
|
268
|
+
if (!record || record.kind !== "run") return null;
|
|
269
|
+
const released = releaseHeld(root, platform, record, io);
|
|
270
|
+
return released ? summarize(released) : null;
|
|
271
|
+
}
|
|
272
|
+
function releaseWorkspaceLeases(root, { platform = null } = {}, io = fileLeaseIo) {
|
|
273
|
+
const released = [];
|
|
274
|
+
for (const [name, record] of Object.entries(io.readHolder(root))) {
|
|
275
|
+
if (!isPlatform(name) || platform && name !== platform) continue;
|
|
276
|
+
const lease = releaseHeld(root, name, record, io);
|
|
277
|
+
if (lease) released.push(summarize(lease));
|
|
278
|
+
}
|
|
279
|
+
for (const entry of listLeaseFiles(io)) {
|
|
280
|
+
const lease = entry.lease;
|
|
281
|
+
if (!lease || lease.holder !== root) continue;
|
|
282
|
+
if (platform && lease.platform !== platform) continue;
|
|
283
|
+
const byRoot = releaseByRoot(root, lease.platform, lease.id, io);
|
|
284
|
+
if (byRoot) released.push(summarize(byRoot));
|
|
285
|
+
}
|
|
286
|
+
return released;
|
|
287
|
+
}
|
|
288
|
+
function removeExpiredLease(entry, io = fileLeaseIo) {
|
|
289
|
+
const lease = entry.lease;
|
|
290
|
+
if (!lease) return false;
|
|
291
|
+
return io.withLeaseLock(deviceLeaseLockPath(lease.platform, lease.id), () => {
|
|
292
|
+
const current = parseLease(io.readLease(entry.path));
|
|
293
|
+
if (!current || !leaseIsExpired(current, io.now())) return false;
|
|
294
|
+
io.removeLease(entry.path);
|
|
295
|
+
return true;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
function selectPoolDevice({ candidates, leases, held = null, now }) {
|
|
299
|
+
const ordered = candidates.toSorted((a, b) => {
|
|
300
|
+
const left = a.id.toLowerCase();
|
|
301
|
+
const right = b.id.toLowerCase();
|
|
302
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
303
|
+
});
|
|
304
|
+
if (held) {
|
|
305
|
+
const mine = ordered.find((candidate) => candidate.id === held);
|
|
306
|
+
return mine ? {
|
|
307
|
+
status: "selected",
|
|
308
|
+
candidate: mine
|
|
309
|
+
} : {
|
|
310
|
+
status: "held-disconnected",
|
|
311
|
+
id: held
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
if (ordered.length === 0) return { status: "none" };
|
|
315
|
+
const byId = new Map(leases.map((lease) => [lease.id, lease]));
|
|
316
|
+
const holders = [];
|
|
317
|
+
for (const candidate of ordered) {
|
|
318
|
+
const lease = byId.get(candidate.id);
|
|
319
|
+
if (!lease || leaseIsExpired(lease, now)) return {
|
|
320
|
+
status: "selected",
|
|
321
|
+
candidate
|
|
322
|
+
};
|
|
323
|
+
holders.push({
|
|
324
|
+
id: lease.id,
|
|
325
|
+
holder: lease.holder,
|
|
326
|
+
expiresAt: lease.expiresAt
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
status: "busy",
|
|
331
|
+
holders
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
//#endregion
|
|
335
|
+
export { parseLease as a, releaseRunLease as c, selectPoolDevice as d, takeLease as f, listLeaseFiles as i, releaseWorkspaceLeases as l, fileLeaseIo as n, parseLeaseDuration as o, leaseIsExpired as r, raiseLease as s, deviceLeasePath as t, removeExpiredLease as u };
|