simdeck 0.1.0 → 0.1.3

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.
@@ -0,0 +1,762 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFile } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { promisify } from "node:util";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+ const packageRoot = path.resolve(
12
+ path.dirname(fileURLToPath(import.meta.url)),
13
+ "..",
14
+ );
15
+ const defaultConfigPath = path.join(
16
+ os.homedir(),
17
+ ".simdeck",
18
+ "studio-provider.json",
19
+ );
20
+ const defaultWorkRoot = path.join(os.homedir(), ".simdeck", "studio-provider");
21
+ const defaultLocalUrl = "http://127.0.0.1:4310";
22
+
23
+ const command = process.argv[2] || "";
24
+
25
+ if (isMainModule()) {
26
+ try {
27
+ if (command === "connect") {
28
+ await connect(parseArgs(process.argv.slice(3)));
29
+ } else if (command === "run") {
30
+ await run(parseArgs(process.argv.slice(3)));
31
+ } else if (command === "status") {
32
+ await status(parseArgs(process.argv.slice(3)));
33
+ } else {
34
+ usage();
35
+ process.exit(command ? 2 : 0);
36
+ }
37
+ } catch (error) {
38
+ console.error(`[simdeck-provider] ${describeError(error)}`);
39
+ process.exit(1);
40
+ }
41
+ }
42
+
43
+ async function connect(args) {
44
+ const studioUrl = requiredArg(args, "studio-url").replace(/\/$/, "");
45
+ const hostId = requiredArg(args, "host-id");
46
+ const hostToken = requiredArg(args, "host-token");
47
+ const configPath = args["config"] || defaultConfigPath;
48
+ const config = {
49
+ createdAt: new Date().toISOString(),
50
+ hostId,
51
+ hostToken,
52
+ studioUrl,
53
+ workRoot: args["work-root"] || defaultWorkRoot,
54
+ };
55
+ await writeJsonFile(configPath, config, 0o600);
56
+ console.log(`Saved SimDeck Studio provider config to ${configPath}`);
57
+ console.log("Run `simdeck provider run` to start the provider.");
58
+ }
59
+
60
+ async function status(args) {
61
+ const config = await loadConfig(args);
62
+ const local = await localProviderMetadata(config).catch((error) => ({
63
+ ok: false,
64
+ error: describeError(error),
65
+ }));
66
+ console.log(JSON.stringify({ config: redactConfig(config), local }, null, 2));
67
+ }
68
+
69
+ async function run(args) {
70
+ const config = await loadConfig(args);
71
+ config.localUrl = (
72
+ args["local-url"] ||
73
+ config.localUrl ||
74
+ defaultLocalUrl
75
+ ).replace(/\/$/, "");
76
+ config.localToken = args["local-token"] || config.localToken || "";
77
+ config.maxCapacity = clampCapacity(
78
+ Number(args["max-capacity"] || config.maxCapacity || 1),
79
+ );
80
+ config.workRoot = args["work-root"] || config.workRoot || defaultWorkRoot;
81
+ config.simulatorTemplateName =
82
+ args["simulator-template"] ||
83
+ config.simulatorTemplateName ||
84
+ "iPhone 17 Pro";
85
+ config.pollIntervalMs = Number(args["poll-interval-ms"] || 750);
86
+ config.heartbeatIntervalMs = Number(args["heartbeat-interval-ms"] || 15000);
87
+ config.proxyTimeoutMs = Number(args["proxy-timeout-ms"] || 25000);
88
+ config.videoCodec = args["video-codec"] || config.videoCodec || "software";
89
+ config.streamQuality =
90
+ args["stream-quality"] || config.streamQuality || "smooth";
91
+
92
+ await fs.promises.mkdir(config.workRoot, { recursive: true });
93
+ const state = {
94
+ activeRequests: new Set(),
95
+ activeSessions: new Map(),
96
+ inFlightAllocations: 0,
97
+ lastHeartbeatAt: 0,
98
+ stopped: false,
99
+ };
100
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
101
+ process.once(signal, () => {
102
+ state.stopped = true;
103
+ });
104
+ }
105
+
106
+ await ensureDaemon(config);
107
+ await heartbeat(config, state, true);
108
+ console.log(
109
+ `[simdeck-provider] online as ${config.hostId} for ${config.studioUrl}`,
110
+ );
111
+
112
+ while (!state.stopped) {
113
+ try {
114
+ if (Date.now() - state.lastHeartbeatAt >= config.heartbeatIntervalMs) {
115
+ await heartbeat(config, state, false);
116
+ }
117
+ await Promise.all([pollJob(config, state), pollRpc(config, state)]);
118
+ } catch (error) {
119
+ console.error(`[simdeck-provider] ${describeError(error)}`);
120
+ await sleep(1000);
121
+ }
122
+ await sleep(config.pollIntervalMs);
123
+ }
124
+
125
+ await Promise.allSettled(state.activeRequests);
126
+ await heartbeat(config, state, false, "draining");
127
+ }
128
+
129
+ async function ensureDaemon(config) {
130
+ const status = await daemonStatus().catch(() => null);
131
+ if (daemonLooksUsable(status, config)) {
132
+ config.localUrl = status.httpUrl.replace(/\/$/, "");
133
+ config.localToken = status.accessToken;
134
+ return status;
135
+ }
136
+ const args = [
137
+ "daemon",
138
+ status ? "restart" : "start",
139
+ "--port",
140
+ String(new URL(config.localUrl).port || 4310),
141
+ "--bind",
142
+ "127.0.0.1",
143
+ "--video-codec",
144
+ config.videoCodec,
145
+ "--stream-quality",
146
+ config.streamQuality,
147
+ ];
148
+ await execFileAsync(simdeckBinary(), args, { timeout: 120000 });
149
+ const next = await daemonStatus();
150
+ config.localUrl = next.httpUrl.replace(/\/$/, "");
151
+ config.localToken = next.accessToken;
152
+ return next;
153
+ }
154
+
155
+ async function daemonStatus() {
156
+ const { stdout } = await execFileAsync(
157
+ simdeckBinary(),
158
+ ["daemon", "status"],
159
+ {
160
+ timeout: 15000,
161
+ },
162
+ );
163
+ const parsed = JSON.parse(stdout);
164
+ return parsed.daemon || parsed;
165
+ }
166
+
167
+ function daemonLooksUsable(status, config) {
168
+ if (!status?.httpUrl || !status?.accessToken) {
169
+ return false;
170
+ }
171
+ return status.httpUrl.replace(/\/$/, "") === config.localUrl;
172
+ }
173
+
174
+ async function heartbeat(config, state, first, statusOverride) {
175
+ const metadata = await localProviderMetadata(config).catch((error) => ({
176
+ capabilities: { error: describeError(error) },
177
+ ok: false,
178
+ }));
179
+ await studioJson(config, "/api/actions/provider-hosts/heartbeat", {
180
+ activeSessionCount: state.activeSessions.size,
181
+ capabilities: metadata.capabilities,
182
+ hostId: config.hostId,
183
+ hostToken: config.hostToken,
184
+ maxCapacity: config.maxCapacity,
185
+ simulatorTemplateName: config.simulatorTemplateName,
186
+ status: statusOverride || (metadata.ok ? "online" : "draining"),
187
+ });
188
+ state.lastHeartbeatAt = Date.now();
189
+ if (!metadata.ok && first) {
190
+ throw new Error("Local SimDeck daemon is not healthy.");
191
+ }
192
+ }
193
+
194
+ async function pollJob(config, state) {
195
+ if (
196
+ state.activeSessions.size + state.inFlightAllocations >=
197
+ config.maxCapacity
198
+ ) {
199
+ return;
200
+ }
201
+ const response = await studioJson(
202
+ config,
203
+ "/api/actions/provider-hosts/jobs/next",
204
+ {
205
+ hostId: config.hostId,
206
+ hostToken: config.hostToken,
207
+ },
208
+ );
209
+ if (!response?.job) {
210
+ return;
211
+ }
212
+ const task = handleJob(config, state, response.job).catch((error) => {
213
+ console.error(
214
+ `[simdeck-provider] job ${response.job.id} failed: ${describeError(error)}`,
215
+ );
216
+ });
217
+ state.activeRequests.add(task);
218
+ task.finally(() => state.activeRequests.delete(task));
219
+ }
220
+
221
+ async function handleJob(config, state, job) {
222
+ if (job.type === "allocate") {
223
+ if (
224
+ state.activeSessions.size + state.inFlightAllocations >=
225
+ config.maxCapacity
226
+ ) {
227
+ return;
228
+ }
229
+ state.inFlightAllocations += 1;
230
+ try {
231
+ await allocateSession(config, state, job);
232
+ } finally {
233
+ state.inFlightAllocations = Math.max(0, state.inFlightAllocations - 1);
234
+ }
235
+ } else if (job.type === "release") {
236
+ await releaseSession(config, state, job);
237
+ } else {
238
+ await completeJob(config, job.id, {
239
+ error: `Unsupported provider job type: ${job.type}`,
240
+ status: "failed",
241
+ });
242
+ }
243
+ }
244
+
245
+ async function allocateSession(config, state, job) {
246
+ const payload = job.payload || {};
247
+ const templateName =
248
+ payload.simulatorTemplateName ||
249
+ config.simulatorTemplateName ||
250
+ "iPhone 17 Pro";
251
+ let udid = "";
252
+ try {
253
+ const template = await ensureTemplateSimulator(templateName);
254
+ const sessionName = `SimDeck ${payload.sessionId || job.sessionId}`;
255
+ udid = await cloneSimulator(template.udid, sessionName);
256
+ state.activeSessions.set(job.sessionId, { udid });
257
+ await bootSimulator(config, udid);
258
+ if (payload.artifactId) {
259
+ const appPath = await downloadAndExtractArtifact(config, job.sessionId);
260
+ await localJson(
261
+ config,
262
+ `/api/simulators/${encodeURIComponent(udid)}/install`,
263
+ {
264
+ appPath,
265
+ },
266
+ );
267
+ }
268
+ if (payload.bundleId) {
269
+ await localJson(
270
+ config,
271
+ `/api/simulators/${encodeURIComponent(udid)}/launch`,
272
+ {
273
+ bundleId: payload.bundleId,
274
+ },
275
+ );
276
+ }
277
+ const simulator = await simulatorByUdid(config, udid);
278
+ await completeJob(config, job.id, {
279
+ activeSessionCount: state.activeSessions.size,
280
+ runtimeName: simulator?.runtimeName,
281
+ simulatorName: simulator?.name || sessionName,
282
+ simulatorUdid: udid,
283
+ status: "completed",
284
+ });
285
+ console.log(`[simdeck-provider] allocated ${udid} for ${job.sessionId}`);
286
+ } catch (error) {
287
+ if (udid) {
288
+ await deleteSimulator(udid).catch(() => {});
289
+ state.activeSessions.delete(job.sessionId);
290
+ }
291
+ await completeJob(config, job.id, {
292
+ activeSessionCount: state.activeSessions.size,
293
+ error: describeError(error),
294
+ status: "failed",
295
+ });
296
+ }
297
+ }
298
+
299
+ async function releaseSession(config, state, job) {
300
+ const payload = job.payload || {};
301
+ const udid =
302
+ payload.simulatorUdid ||
303
+ state.activeSessions.get(job.sessionId)?.udid ||
304
+ "";
305
+ if (udid) {
306
+ await localJson(
307
+ config,
308
+ `/api/simulators/${encodeURIComponent(udid)}/shutdown`,
309
+ {},
310
+ ).catch(() => {});
311
+ await deleteSimulator(udid);
312
+ }
313
+ state.activeSessions.delete(job.sessionId);
314
+ await completeJob(config, job.id, {
315
+ activeSessionCount: state.activeSessions.size,
316
+ status: "completed",
317
+ });
318
+ console.log(`[simdeck-provider] released ${job.sessionId}`);
319
+ }
320
+
321
+ async function pollRpc(config, state) {
322
+ const response = await studioJson(
323
+ config,
324
+ "/api/actions/provider-hosts/rpc/next",
325
+ {
326
+ hostId: config.hostId,
327
+ hostToken: config.hostToken,
328
+ },
329
+ );
330
+ if (!response?.request) {
331
+ return;
332
+ }
333
+ const task = handleRpc(config, response.request).catch((error) => {
334
+ console.error(
335
+ `[simdeck-provider] rpc ${response.request.id} failed: ${describeError(error)}`,
336
+ );
337
+ });
338
+ state.activeRequests.add(task);
339
+ task.finally(() => state.activeRequests.delete(task));
340
+ }
341
+
342
+ async function handleRpc(config, request) {
343
+ if (isWebSocketUpgradeRequest(request)) {
344
+ await completeRpc(config, request.id, {
345
+ responseBodyBase64: Buffer.from(
346
+ "Studio provider RPC does not tunnel WebSocket upgrade requests.",
347
+ ).toString("base64"),
348
+ responseHeaders: { "content-type": "text/plain; charset=utf-8" },
349
+ responseStatus: 426,
350
+ });
351
+ return;
352
+ }
353
+ try {
354
+ await completeRpc(
355
+ config,
356
+ request.id,
357
+ await proxyLocalRequest(config, request),
358
+ );
359
+ } catch (error) {
360
+ await completeRpc(config, request.id, { error: describeError(error) });
361
+ }
362
+ }
363
+
364
+ async function proxyLocalRequest(config, request) {
365
+ const target = new URL(request.path, `${config.localUrl}/`);
366
+ if (!target.searchParams.has("simdeckToken")) {
367
+ target.searchParams.set("simdeckToken", config.localToken);
368
+ }
369
+ const headers = new Headers(request.headers || {});
370
+ headers.set("x-simdeck-token", config.localToken);
371
+ headers.delete("host");
372
+ headers.delete("content-length");
373
+ const response = await fetch(target, {
374
+ body: request.bodyBase64
375
+ ? Buffer.from(request.bodyBase64, "base64")
376
+ : undefined,
377
+ headers,
378
+ method: request.method,
379
+ signal: AbortSignal.timeout(config.proxyTimeoutMs),
380
+ });
381
+ const responseHeaders = {};
382
+ for (const [name, value] of response.headers.entries()) {
383
+ const lower = name.toLowerCase();
384
+ if (
385
+ lower === "connection" ||
386
+ lower === "content-encoding" ||
387
+ lower === "content-length" ||
388
+ lower === "transfer-encoding"
389
+ ) {
390
+ continue;
391
+ }
392
+ responseHeaders[name] = value;
393
+ }
394
+ return {
395
+ responseBodyBase64: Buffer.from(await response.arrayBuffer()).toString(
396
+ "base64",
397
+ ),
398
+ responseHeaders,
399
+ responseStatus: response.status,
400
+ };
401
+ }
402
+
403
+ async function completeJob(config, jobId, body) {
404
+ return studioJson(config, "/api/actions/provider-hosts/jobs/complete", {
405
+ ...body,
406
+ hostId: config.hostId,
407
+ hostToken: config.hostToken,
408
+ jobId,
409
+ });
410
+ }
411
+
412
+ async function completeRpc(config, requestId, body) {
413
+ return studioJson(config, "/api/actions/provider-hosts/rpc/complete", {
414
+ ...body,
415
+ hostId: config.hostId,
416
+ hostToken: config.hostToken,
417
+ requestId,
418
+ });
419
+ }
420
+
421
+ async function localProviderMetadata(config) {
422
+ const [health, simulators] = await Promise.all([
423
+ localGet(config, "/api/health"),
424
+ localGet(config, "/api/simulators"),
425
+ ]);
426
+ return {
427
+ capabilities: {
428
+ health,
429
+ simulators:
430
+ simulators.simulators?.map((simulator) => ({
431
+ isBooted: simulator.isBooted,
432
+ name: simulator.name,
433
+ runtimeName: simulator.runtimeName,
434
+ udid: simulator.udid,
435
+ })) ?? [],
436
+ },
437
+ ok: Boolean(health?.ok),
438
+ };
439
+ }
440
+
441
+ async function ensureTemplateSimulator(templateName) {
442
+ const inventory = await simulatorInventory();
443
+ const exact = inventory.devices.find(
444
+ (device) => device.name === templateName && device.isAvailable !== false,
445
+ );
446
+ if (exact) {
447
+ return exact;
448
+ }
449
+ const runtime =
450
+ inventory.runtimes.find(
451
+ (candidate) => candidate.isAvailable && candidate.platform === "iOS",
452
+ ) || inventory.runtimes.find((candidate) => candidate.isAvailable);
453
+ if (!runtime) {
454
+ throw new Error("No available iOS simulator runtime was found.");
455
+ }
456
+ const deviceType =
457
+ inventory.deviceTypes.find(
458
+ (candidate) => candidate.name === templateName,
459
+ ) ||
460
+ inventory.deviceTypes.find((candidate) =>
461
+ candidate.name.includes("iPhone 17 Pro"),
462
+ ) ||
463
+ inventory.deviceTypes.find((candidate) =>
464
+ candidate.name.includes("iPhone"),
465
+ );
466
+ if (!deviceType) {
467
+ throw new Error(`No simulator device type was found for ${templateName}.`);
468
+ }
469
+ const udid = (
470
+ await execText("xcrun", [
471
+ "simctl",
472
+ "create",
473
+ templateName,
474
+ deviceType.identifier,
475
+ runtime.identifier,
476
+ ])
477
+ ).trim();
478
+ return {
479
+ isAvailable: true,
480
+ name: templateName,
481
+ runtimeName: runtime.name,
482
+ udid,
483
+ };
484
+ }
485
+
486
+ async function simulatorInventory() {
487
+ const [devicesJson, deviceTypesJson, runtimesJson] = await Promise.all([
488
+ execJson("xcrun", ["simctl", "list", "-j", "devices"]),
489
+ execJson("xcrun", ["simctl", "list", "-j", "devicetypes"]),
490
+ execJson("xcrun", ["simctl", "list", "-j", "runtimes"]),
491
+ ]);
492
+ const devices = [];
493
+ for (const [runtimeName, runtimeDevices] of Object.entries(
494
+ devicesJson.devices || {},
495
+ )) {
496
+ for (const device of runtimeDevices || []) {
497
+ devices.push({ ...device, runtimeName });
498
+ }
499
+ }
500
+ return {
501
+ deviceTypes: deviceTypesJson.devicetypes || [],
502
+ devices,
503
+ runtimes: runtimesJson.runtimes || [],
504
+ };
505
+ }
506
+
507
+ async function cloneSimulator(templateUdid, name) {
508
+ return (
509
+ await execText("xcrun", ["simctl", "clone", templateUdid, name])
510
+ ).trim();
511
+ }
512
+
513
+ async function deleteSimulator(udid) {
514
+ await execFileAsync("xcrun", ["simctl", "delete", udid], { timeout: 60000 });
515
+ }
516
+
517
+ async function bootSimulator(config, udid) {
518
+ await localJson(
519
+ config,
520
+ `/api/simulators/${encodeURIComponent(udid)}/boot`,
521
+ {},
522
+ );
523
+ await execFileAsync("xcrun", ["simctl", "bootstatus", udid, "-b"], {
524
+ timeout: 600000,
525
+ });
526
+ }
527
+
528
+ async function simulatorByUdid(config, udid) {
529
+ const list = await localGet(config, "/api/simulators");
530
+ return list.simulators?.find((simulator) => simulator.udid === udid) || null;
531
+ }
532
+
533
+ async function downloadAndExtractArtifact(config, sessionId) {
534
+ const sessionRoot = path.join(config.workRoot, "sessions", sessionId);
535
+ await fs.promises.rm(sessionRoot, { force: true, recursive: true });
536
+ await fs.promises.mkdir(sessionRoot, { recursive: true });
537
+ const zipPath = path.join(sessionRoot, "artifact.zip");
538
+ const response = await fetch(
539
+ `${config.studioUrl}/api/actions/provider-hosts/sessions/${encodeURIComponent(sessionId)}/artifact`,
540
+ {
541
+ headers: {
542
+ "x-simdeck-host-id": config.hostId,
543
+ "x-simdeck-host-token": config.hostToken,
544
+ },
545
+ },
546
+ );
547
+ if (!response.ok) {
548
+ throw new Error(`Artifact download failed: HTTP ${response.status}`);
549
+ }
550
+ await fs.promises.writeFile(
551
+ zipPath,
552
+ Buffer.from(await response.arrayBuffer()),
553
+ );
554
+ await execFileAsync("ditto", ["-x", "-k", zipPath, sessionRoot], {
555
+ timeout: 120000,
556
+ });
557
+ const appPath = await findAppBundle(sessionRoot);
558
+ if (!appPath) {
559
+ throw new Error("Artifact did not contain an .app bundle.");
560
+ }
561
+ return appPath;
562
+ }
563
+
564
+ async function findAppBundle(root) {
565
+ const entries = await fs.promises.readdir(root, { withFileTypes: true });
566
+ for (const entry of entries) {
567
+ const full = path.join(root, entry.name);
568
+ if (entry.isDirectory() && entry.name.endsWith(".app")) {
569
+ return full;
570
+ }
571
+ if (entry.isDirectory()) {
572
+ const nested = await findAppBundle(full);
573
+ if (nested) {
574
+ return nested;
575
+ }
576
+ }
577
+ }
578
+ return null;
579
+ }
580
+
581
+ async function localGet(config, path) {
582
+ const target = new URL(path, `${config.localUrl}/`);
583
+ target.searchParams.set("simdeckToken", config.localToken);
584
+ const response = await fetch(target, {
585
+ headers: { "x-simdeck-token": config.localToken },
586
+ });
587
+ if (!response.ok) {
588
+ throw new Error(
589
+ `Local SimDeck GET ${path} failed: HTTP ${response.status}`,
590
+ );
591
+ }
592
+ return response.json();
593
+ }
594
+
595
+ async function localJson(config, path, body) {
596
+ const target = new URL(path, `${config.localUrl}/`);
597
+ target.searchParams.set("simdeckToken", config.localToken);
598
+ const response = await fetch(target, {
599
+ body: JSON.stringify(body),
600
+ headers: {
601
+ "content-type": "application/json",
602
+ "x-simdeck-token": config.localToken,
603
+ },
604
+ method: "POST",
605
+ });
606
+ if (!response.ok) {
607
+ throw new Error(
608
+ `Local SimDeck POST ${path} failed: HTTP ${response.status}`,
609
+ );
610
+ }
611
+ return response.json();
612
+ }
613
+
614
+ async function studioJson(config, path, body) {
615
+ const response = await fetch(`${config.studioUrl}${path}`, {
616
+ body: JSON.stringify(body),
617
+ headers: { "content-type": "application/json" },
618
+ method: "POST",
619
+ });
620
+ if (!response.ok) {
621
+ const message = await response.text().catch(() => "");
622
+ throw new Error(
623
+ `Studio request ${path} failed: HTTP ${response.status}${message ? `: ${message}` : ""}`,
624
+ );
625
+ }
626
+ return response.json();
627
+ }
628
+
629
+ async function loadConfig(args) {
630
+ const configPath = args["config"] || defaultConfigPath;
631
+ let config = {};
632
+ try {
633
+ config = JSON.parse(await fs.promises.readFile(configPath, "utf8"));
634
+ } catch {
635
+ config = {};
636
+ }
637
+ config.studioUrl = (args["studio-url"] || config.studioUrl || "").replace(
638
+ /\/$/,
639
+ "",
640
+ );
641
+ config.hostId = args["host-id"] || config.hostId || "";
642
+ config.hostToken = args["host-token"] || config.hostToken || "";
643
+ if (!config.studioUrl || !config.hostId || !config.hostToken) {
644
+ throw new Error(
645
+ "Missing provider config. Run `simdeck provider connect --studio-url ... --host-id ... --host-token ...` first.",
646
+ );
647
+ }
648
+ return config;
649
+ }
650
+
651
+ function simdeckBinary() {
652
+ if (process.env.SIMDECK_BINARY) {
653
+ return process.env.SIMDECK_BINARY;
654
+ }
655
+ const sourceBinary = path.join(packageRoot, "build", "simdeck");
656
+ if (fs.existsSync(sourceBinary)) {
657
+ return sourceBinary;
658
+ }
659
+ return path.join(packageRoot, "build", "simdeck-bin");
660
+ }
661
+
662
+ async function execJson(command, args) {
663
+ return JSON.parse(await execText(command, args));
664
+ }
665
+
666
+ async function execText(command, args) {
667
+ const { stdout } = await execFileAsync(command, args, {
668
+ maxBuffer: 16 * 1024 * 1024,
669
+ timeout: 120000,
670
+ });
671
+ return stdout;
672
+ }
673
+
674
+ async function writeJsonFile(file, value, mode) {
675
+ await fs.promises.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
676
+ await fs.promises.writeFile(file, `${JSON.stringify(value, null, 2)}\n`, {
677
+ mode,
678
+ });
679
+ await fs.promises.chmod(file, mode);
680
+ }
681
+
682
+ function clampCapacity(value) {
683
+ if (!Number.isFinite(value)) {
684
+ return 1;
685
+ }
686
+ return Math.min(16, Math.max(1, Math.floor(value)));
687
+ }
688
+
689
+ function parseArgs(argv) {
690
+ const args = {};
691
+ for (let index = 0; index < argv.length; index += 1) {
692
+ const item = argv[index];
693
+ if (!item.startsWith("--")) {
694
+ continue;
695
+ }
696
+ const key = item.slice(2);
697
+ const next = argv[index + 1];
698
+ if (!next || next.startsWith("--")) {
699
+ args[key] = "1";
700
+ continue;
701
+ }
702
+ args[key] = next;
703
+ index += 1;
704
+ }
705
+ return args;
706
+ }
707
+
708
+ function requiredArg(args, name) {
709
+ const value = args[name];
710
+ if (!value) {
711
+ throw new Error(`--${name} is required.`);
712
+ }
713
+ return value;
714
+ }
715
+
716
+ function redactConfig(config) {
717
+ return { ...config, hostToken: config.hostToken ? "[redacted]" : "" };
718
+ }
719
+
720
+ function isWebSocketUpgradeRequest(request) {
721
+ const headers = new Headers(request.headers || {});
722
+ return (
723
+ headers.get("upgrade")?.toLowerCase() === "websocket" ||
724
+ headers
725
+ .get("connection")
726
+ ?.toLowerCase()
727
+ .split(",")
728
+ .some((value) => value.trim() === "upgrade") === true
729
+ );
730
+ }
731
+
732
+ function describeError(error) {
733
+ if (error instanceof Error) {
734
+ return error.cause instanceof Error
735
+ ? `${error.message}: ${error.cause.message}`
736
+ : error.message;
737
+ }
738
+ return String(error);
739
+ }
740
+
741
+ function sleep(ms) {
742
+ return new Promise((resolve) => setTimeout(resolve, ms));
743
+ }
744
+
745
+ function usage() {
746
+ console.log(`Usage:
747
+ simdeck provider connect --studio-url URL --host-id ID --host-token TOKEN
748
+ simdeck provider run [--config PATH] [--max-capacity N]
749
+ simdeck provider status [--config PATH]`);
750
+ }
751
+
752
+ function isMainModule() {
753
+ return process.argv[1] === fileURLToPath(import.meta.url);
754
+ }
755
+
756
+ export {
757
+ cloneSimulator,
758
+ ensureTemplateSimulator,
759
+ isWebSocketUpgradeRequest,
760
+ parseArgs,
761
+ redactConfig,
762
+ };