@tilt-launcher/sdk 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,771 @@
1
+ // src/tiltManagerSDK.ts
2
+ import { spawn } from "node:child_process";
3
+ import http from "node:http";
4
+ import https from "node:https";
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname } from "node:path";
7
+
8
+ class TiltManagerSDK {
9
+ config;
10
+ maxLogLines;
11
+ onStatus;
12
+ onStatusUpdate;
13
+ onLogDelta;
14
+ onConfigMutated;
15
+ emittedEnvLogIndex = new Map;
16
+ emittedResourceLogIndex = new Map;
17
+ processes = new Map;
18
+ logs = new Map;
19
+ startTimes = new Map;
20
+ discoveredResources = new Map;
21
+ tiltPortReachable = new Map;
22
+ healthByKey = new Map;
23
+ newResourceCount = new Map;
24
+ resourceLogProcesses = new Map;
25
+ resourceLogs = new Map;
26
+ pollHandle = null;
27
+ wsConnections = new Map;
28
+ wsReconnectTimers = new Map;
29
+ wsEnabled = false;
30
+ constructor(config, options) {
31
+ this.config = config;
32
+ this.maxLogLines = options?.maxLogLines ?? 800;
33
+ this.onStatus = options?.onStatus;
34
+ this.onStatusUpdate = options?.onStatusUpdate;
35
+ this.onLogDelta = options?.onLogDelta;
36
+ this.onConfigMutated = options?.onConfigMutated;
37
+ }
38
+ setConfig(next) {
39
+ this.config = next;
40
+ }
41
+ currentStatusSnapshot() {
42
+ const envs = {};
43
+ for (const env of this.config.environments) {
44
+ envs[env.id] = {
45
+ status: this.getEnvState(env),
46
+ logs: this.logs.get(env.id) ?? [],
47
+ resourceLogs: this.getResourceLogsForEnv(env.id),
48
+ tiltPort: env.tiltPort,
49
+ uptime: this.startTimes.has(env.id) ? Date.now() - (this.startTimes.get(env.id) ?? Date.now()) : null,
50
+ newResources: this.newResourceCount.get(env.id) ?? 0,
51
+ resources: this.getDisplayRows(env)
52
+ };
53
+ }
54
+ return { envs };
55
+ }
56
+ currentStatusUpdate() {
57
+ const envs = {};
58
+ for (const env of this.config.environments) {
59
+ envs[env.id] = {
60
+ status: this.getEnvState(env),
61
+ tiltPort: env.tiltPort,
62
+ uptime: this.startTimes.has(env.id) ? Date.now() - (this.startTimes.get(env.id) ?? Date.now()) : null,
63
+ newResources: this.newResourceCount.get(env.id) ?? 0,
64
+ resources: this.getDisplayRows(env)
65
+ };
66
+ }
67
+ return { envs };
68
+ }
69
+ getEnvLogs(envId) {
70
+ return {
71
+ envLogs: this.logs.get(envId) ?? [],
72
+ resourceLogs: this.getResourceLogsForEnv(envId)
73
+ };
74
+ }
75
+ startPolling(intervalMs = 5000) {
76
+ this.wsEnabled = true;
77
+ if (this.pollHandle)
78
+ clearInterval(this.pollHandle);
79
+ this.pollHandle = setInterval(() => {
80
+ this.pollTiltState();
81
+ }, intervalMs);
82
+ this.pollTiltState();
83
+ }
84
+ stopPolling() {
85
+ this.wsEnabled = false;
86
+ if (!this.pollHandle)
87
+ return;
88
+ clearInterval(this.pollHandle);
89
+ this.pollHandle = null;
90
+ for (const envId of this.wsConnections.keys()) {
91
+ this.disconnectWebSocket(envId);
92
+ }
93
+ }
94
+ startEnv(envId) {
95
+ const env = this.envById(envId);
96
+ if (!env)
97
+ return { ok: false, error: "Unknown environment." };
98
+ if (env.external)
99
+ return { ok: false, error: "Cannot start an external environment." };
100
+ const state = this.getEnvState(env);
101
+ if (state === "running" || state === "starting")
102
+ return { ok: false, error: "Environment already active." };
103
+ this.appendLog(env.id, `[launcher] Starting ${env.name}...`);
104
+ this.appendLog(env.id, `[launcher] tilt up -f ${env.tiltfile} --port ${env.tiltPort}`);
105
+ const child = spawn("tilt", ["up", "-f", env.tiltfile, "--port", String(env.tiltPort)], {
106
+ cwd: env.repoDir,
107
+ detached: true,
108
+ stdio: ["ignore", "pipe", "pipe"],
109
+ env: { ...process.env, PWD: env.repoDir }
110
+ });
111
+ child.unref();
112
+ child.stdout?.on("data", (chunk) => {
113
+ for (const line of chunk.toString().split(`
114
+ `).filter(Boolean))
115
+ this.appendLog(env.id, line);
116
+ });
117
+ child.stderr?.on("data", (chunk) => {
118
+ for (const line of chunk.toString().split(`
119
+ `).filter(Boolean))
120
+ this.appendLog(env.id, line);
121
+ });
122
+ child.on("close", (code) => {
123
+ this.appendLog(env.id, `[launcher] Process exited with code ${code ?? 0}`);
124
+ this.processes.delete(env.id);
125
+ this.emitStatus();
126
+ });
127
+ child.on("error", (error) => {
128
+ this.appendLog(env.id, `[launcher] ${error.message}`);
129
+ this.processes.delete(env.id);
130
+ this.emitStatus();
131
+ });
132
+ this.processes.set(env.id, child);
133
+ this.startTimes.set(env.id, Date.now());
134
+ this.emitStatus();
135
+ return { ok: true };
136
+ }
137
+ stopEnv(envId) {
138
+ const env = this.envById(envId);
139
+ if (!env)
140
+ return { ok: false, error: "Unknown environment." };
141
+ this.appendLog(env.id, `[launcher] Stopping ${env.name}...`);
142
+ const tracked = this.processes.get(env.id);
143
+ if (tracked) {
144
+ try {
145
+ tracked.kill("SIGTERM");
146
+ } catch {}
147
+ }
148
+ const cwd = env.repoDir || homedir();
149
+ this.runCommand("tilt", ["down", "--port", String(env.tiltPort)], cwd);
150
+ this.processes.delete(env.id);
151
+ this.startTimes.delete(env.id);
152
+ this.tiltPortReachable.delete(env.id);
153
+ this.discoveredResources.delete(env.id);
154
+ for (const key of this.healthByKey.keys()) {
155
+ if (key.startsWith(`${env.id}:`))
156
+ this.healthByKey.delete(key);
157
+ }
158
+ this.disconnectWebSocket(env.id);
159
+ this.stopResourceLogStreams(env.id);
160
+ this.emitStatus();
161
+ return { ok: true };
162
+ }
163
+ restartEnv(envId) {
164
+ const env = this.envById(envId);
165
+ if (env?.external)
166
+ return { ok: false, error: "Cannot restart an external environment." };
167
+ const stopped = this.stopEnv(envId);
168
+ if (!stopped.ok)
169
+ return stopped;
170
+ return this.startEnv(envId);
171
+ }
172
+ async triggerResource(envId, resourceName) {
173
+ return await this.runResourceCommand(envId, ["trigger", resourceName]);
174
+ }
175
+ async enableResource(envId, resourceName) {
176
+ return await this.runResourceCommand(envId, ["enable", resourceName]);
177
+ }
178
+ async disableResource(envId, resourceName) {
179
+ return await this.runResourceCommand(envId, ["disable", resourceName]);
180
+ }
181
+ async discoverResources(input) {
182
+ const repoDir = dirname(input.tiltfilePath);
183
+ const tiltfile = basename(input.tiltfilePath);
184
+ const timeoutMs = input.timeoutMs ?? 30000;
185
+ const logsOut = [];
186
+ let discoveryProc;
187
+ try {
188
+ discoveryProc = spawn("tilt", ["up", "-f", tiltfile, "--port", String(input.tiltPort)], {
189
+ cwd: repoDir,
190
+ stdio: ["ignore", "pipe", "pipe"],
191
+ env: { ...process.env, PWD: repoDir }
192
+ });
193
+ } catch (error) {
194
+ const message = error instanceof Error ? error.message : "Unknown process launch error";
195
+ return {
196
+ ok: false,
197
+ resources: [],
198
+ logs: logsOut,
199
+ error: `Failed to start Tilt for discovery: ${message}`
200
+ };
201
+ }
202
+ let spawnError = null;
203
+ discoveryProc.stdout?.on("data", (chunk) => {
204
+ logsOut.push(...chunk.toString().split(`
205
+ `).filter(Boolean));
206
+ });
207
+ discoveryProc.stderr?.on("data", (chunk) => {
208
+ logsOut.push(...chunk.toString().split(`
209
+ `).filter(Boolean));
210
+ });
211
+ discoveryProc.once("error", (error) => {
212
+ spawnError = error;
213
+ logsOut.push(`[launcher] ${error.message}`);
214
+ });
215
+ const startedAt = Date.now();
216
+ let resources = null;
217
+ while (Date.now() - startedAt < timeoutMs) {
218
+ if (spawnError)
219
+ break;
220
+ await new Promise((resolve) => setTimeout(resolve, 2000));
221
+ const env = {
222
+ id: "discovery",
223
+ name: "Discovery",
224
+ repoDir,
225
+ tiltfile,
226
+ tiltPort: input.tiltPort,
227
+ selectedResources: [],
228
+ cachedResources: []
229
+ };
230
+ resources = await this.readTiltResources(env);
231
+ if (resources && resources.length > 0)
232
+ break;
233
+ }
234
+ if (spawnError) {
235
+ return {
236
+ ok: false,
237
+ resources: [],
238
+ logs: logsOut,
239
+ error: `Failed to start Tilt for discovery: ${spawnError?.message ?? "Unknown error"}`
240
+ };
241
+ }
242
+ this.runCommand("tilt", ["down", "--port", String(input.tiltPort)], repoDir);
243
+ if (discoveryProc.pid) {
244
+ try {
245
+ process.kill(-discoveryProc.pid, "SIGTERM");
246
+ } catch {
247
+ discoveryProc.kill("SIGTERM");
248
+ }
249
+ }
250
+ if (!resources || resources.length === 0) {
251
+ return {
252
+ ok: false,
253
+ resources: [],
254
+ logs: logsOut,
255
+ error: "No resources found. The Tiltfile may have only defined the Tiltfile itself, or it failed to start within the discovery timeout."
256
+ };
257
+ }
258
+ return { ok: true, resources, logs: logsOut };
259
+ }
260
+ emitStatus() {
261
+ this.onStatus?.(this.currentStatusSnapshot());
262
+ if (this.onStatusUpdate) {
263
+ this.onStatusUpdate(this.currentStatusUpdate());
264
+ }
265
+ if (this.onLogDelta) {
266
+ const envLogs = {};
267
+ const resourceLogs = {};
268
+ let hasData = false;
269
+ for (const env of this.config.environments) {
270
+ const allEnvLines = this.logs.get(env.id) ?? [];
271
+ const prevEnv = this.emittedEnvLogIndex.get(env.id) ?? 0;
272
+ if (allEnvLines.length > prevEnv) {
273
+ envLogs[env.id] = allEnvLines.slice(prevEnv);
274
+ this.emittedEnvLogIndex.set(env.id, allEnvLines.length);
275
+ hasData = true;
276
+ }
277
+ }
278
+ for (const [key, lines] of this.resourceLogs) {
279
+ const prev = this.emittedResourceLogIndex.get(key) ?? 0;
280
+ if (lines.length > prev) {
281
+ resourceLogs[key] = lines.slice(prev);
282
+ this.emittedResourceLogIndex.set(key, lines.length);
283
+ hasData = true;
284
+ }
285
+ }
286
+ if (hasData) {
287
+ this.onLogDelta({ envLogs, resourceLogs });
288
+ }
289
+ }
290
+ }
291
+ appendLog(envId, line) {
292
+ const existing = this.logs.get(envId) ?? [];
293
+ existing.push(line);
294
+ this.logs.set(envId, existing.slice(-this.maxLogLines));
295
+ }
296
+ getResourceLogsForEnv(envId) {
297
+ const result = {};
298
+ const prefix = `${envId}:`;
299
+ for (const [key, lines] of this.resourceLogs) {
300
+ if (key.startsWith(prefix)) {
301
+ result[key.slice(prefix.length)] = lines;
302
+ }
303
+ }
304
+ return result;
305
+ }
306
+ startResourceLogStreams(env) {
307
+ const resources = this.discoveredResources.get(env.id) ?? [];
308
+ for (const resource of resources) {
309
+ const key = `${env.id}:${resource.name}`;
310
+ if (this.resourceLogProcesses.has(key))
311
+ continue;
312
+ const child = spawn("tilt", ["logs", "-f", resource.name, "--port", String(env.tiltPort)], {
313
+ cwd: env.repoDir,
314
+ stdio: ["ignore", "pipe", "pipe"],
315
+ env: { ...process.env, PWD: env.repoDir }
316
+ });
317
+ const appendResourceLog = (line) => {
318
+ const existing = this.resourceLogs.get(key) ?? [];
319
+ existing.push(line);
320
+ if (existing.length > this.maxLogLines) {
321
+ this.resourceLogs.set(key, existing.slice(-this.maxLogLines));
322
+ } else {
323
+ this.resourceLogs.set(key, existing);
324
+ }
325
+ };
326
+ child.stdout?.on("data", (chunk) => {
327
+ for (const line of chunk.toString().split(`
328
+ `).filter(Boolean))
329
+ appendResourceLog(line);
330
+ });
331
+ child.stderr?.on("data", (chunk) => {
332
+ for (const line of chunk.toString().split(`
333
+ `).filter(Boolean))
334
+ appendResourceLog(line);
335
+ });
336
+ child.on("close", () => {
337
+ this.resourceLogProcesses.delete(key);
338
+ });
339
+ child.on("error", () => {
340
+ this.resourceLogProcesses.delete(key);
341
+ });
342
+ this.resourceLogProcesses.set(key, child);
343
+ }
344
+ }
345
+ stopResourceLogStreams(envId) {
346
+ const prefix = `${envId}:`;
347
+ for (const [key, proc] of this.resourceLogProcesses) {
348
+ if (key.startsWith(prefix)) {
349
+ try {
350
+ proc.kill("SIGTERM");
351
+ } catch {}
352
+ this.resourceLogProcesses.delete(key);
353
+ this.resourceLogs.delete(key);
354
+ }
355
+ }
356
+ }
357
+ envById(envId) {
358
+ return this.config.environments.find((env) => env.id === envId);
359
+ }
360
+ getEnvState(env) {
361
+ const resources = this.discoveredResources.get(env.id) ?? [];
362
+ if (resources.some((resource) => resource.runtimeStatus === "ok"))
363
+ return "running";
364
+ if (this.tiltPortReachable.get(env.id))
365
+ return "running";
366
+ if (env.external)
367
+ return "stopped";
368
+ const proc = this.processes.get(env.id);
369
+ if (proc && proc.exitCode === null && !proc.killed)
370
+ return "starting";
371
+ return "stopped";
372
+ }
373
+ parseEndpoint(endpoint) {
374
+ if (!endpoint)
375
+ return {};
376
+ try {
377
+ const url = new URL(endpoint);
378
+ return {
379
+ protocol: url.protocol,
380
+ hostname: url.hostname,
381
+ port: Number(url.port || (url.protocol === "https:" ? 443 : 80)),
382
+ path: url.pathname || "/"
383
+ };
384
+ } catch {
385
+ return {};
386
+ }
387
+ }
388
+ absoluteEndpoint(endpoint, env) {
389
+ if (!endpoint)
390
+ return;
391
+ try {
392
+ return new URL(endpoint).toString();
393
+ } catch {
394
+ try {
395
+ return new URL(endpoint, `http://localhost:${env.tiltPort}`).toString();
396
+ } catch {
397
+ return;
398
+ }
399
+ }
400
+ }
401
+ categoryFor(resource) {
402
+ if (resource.category)
403
+ return resource.category;
404
+ if (resource.runtimeStatus === "not_applicable")
405
+ return "on-demand";
406
+ return "services";
407
+ }
408
+ async runCommand(command, args, cwd) {
409
+ return await new Promise((resolve) => {
410
+ const child = spawn(command, args, { cwd, env: { ...process.env, PWD: cwd } });
411
+ let output = "";
412
+ child.stdout.on("data", (chunk) => {
413
+ output += chunk.toString();
414
+ });
415
+ child.stderr.on("data", (chunk) => {
416
+ output += chunk.toString();
417
+ });
418
+ child.on("close", (code) => resolve({ code: code ?? 1, output }));
419
+ child.on("error", (err) => resolve({ code: 1, output: `${output}
420
+ ${err.message}` }));
421
+ });
422
+ }
423
+ async runResourceCommand(envId, args) {
424
+ const env = this.envById(envId);
425
+ if (!env)
426
+ return { ok: false, error: "Unknown environment." };
427
+ const command = ["tilt", ...args, "--port", String(env.tiltPort)].join(" ");
428
+ this.appendLog(env.id, `[launcher] ${command}`);
429
+ const cwd = env.repoDir || homedir();
430
+ const result = await this.runCommand("tilt", [...args, "--port", String(env.tiltPort)], cwd);
431
+ if (result.code !== 0) {
432
+ const detail2 = result.output.trim();
433
+ if (detail2)
434
+ this.appendLog(env.id, detail2);
435
+ this.emitStatus();
436
+ return { ok: false, error: detail2 || `Command failed: ${command}` };
437
+ }
438
+ const detail = result.output.trim();
439
+ if (detail)
440
+ this.appendLog(env.id, detail);
441
+ await this.pollTiltState();
442
+ return { ok: true };
443
+ }
444
+ parseUIResource(item, env) {
445
+ const endpoint = this.absoluteEndpoint(item.status?.endpointLinks?.[0]?.url, env);
446
+ const parsedEndpoint = this.parseEndpoint(endpoint);
447
+ const labels = item.metadata?.labels ? Object.keys(item.metadata.labels) : [];
448
+ const disableState = (item.status?.disableStatus?.state ?? "").toLowerCase();
449
+ const isDisabled = disableState === "disabled" || disableState === "pending";
450
+ const runtimeStatus = isDisabled ? "disabled" : item.status?.runtimeStatus ?? "unknown";
451
+ const resourceKind = runtimeStatus === "not_applicable" || runtimeStatus === "disabled" ? "cmd" : runtimeStatus === "ok" || runtimeStatus === "pending" || runtimeStatus === "error" ? "serve" : "unknown";
452
+ const buildHistory = item.status?.buildHistory;
453
+ const lastBuild = buildHistory?.[0];
454
+ const lastBuildDuration = lastBuild?.startTime && lastBuild?.finishTime ? (new Date(lastBuild.finishTime).getTime() - new Date(lastBuild.startTime).getTime()) / 1000 : undefined;
455
+ const waiting = item.status?.waiting;
456
+ const rawConditions = item.status?.conditions;
457
+ const conditions = rawConditions?.map((c) => ({
458
+ type: c.type ?? "",
459
+ status: c.status ?? "",
460
+ ...c.lastTransitionTime != null ? { lastTransitionTime: c.lastTransitionTime } : {}
461
+ }));
462
+ const rawPid = item.status?.localResourceInfo?.pid;
463
+ const pid = rawPid ? Number(rawPid) : undefined;
464
+ return {
465
+ name: item.metadata?.name ?? "unknown",
466
+ label: item.metadata?.name ?? "unknown",
467
+ category: labels[0] ?? "services",
468
+ type: item.status?.specs?.[0]?.type ?? "unknown",
469
+ endpoint,
470
+ port: parsedEndpoint.port,
471
+ path: parsedEndpoint.path,
472
+ runtimeStatus,
473
+ isDisabled,
474
+ resourceKind,
475
+ updateStatus: item.status?.updateStatus,
476
+ waitingReason: waiting?.reason,
477
+ waitingOn: waiting?.on?.map((ref) => ref.name ?? "").filter(Boolean),
478
+ lastDeployTime: item.status?.lastDeployTime,
479
+ lastBuildDuration,
480
+ lastBuildError: lastBuild?.error,
481
+ hasPendingChanges: item.status?.hasPendingChanges,
482
+ triggerMode: item.status?.triggerMode,
483
+ queued: item.status?.queued,
484
+ order: item.status?.order,
485
+ pid,
486
+ conditions
487
+ };
488
+ }
489
+ async readTiltResources(env) {
490
+ const result = await this.runCommand("tilt", ["get", "uiresources", "-o", "json", "--port", String(env.tiltPort)], env.repoDir);
491
+ if (result.code !== 0)
492
+ return null;
493
+ try {
494
+ const parsed = JSON.parse(result.output);
495
+ return (parsed.items ?? []).filter((item) => item.metadata?.name && item.metadata.name !== "(Tiltfile)").map((item) => this.parseUIResource(item, env));
496
+ } catch {
497
+ return null;
498
+ }
499
+ }
500
+ async tryConnect(hostname, port, path = "/", protocol = "http:") {
501
+ return await new Promise((resolve) => {
502
+ const client = protocol === "https:" ? https : http;
503
+ const request = client.request({
504
+ hostname,
505
+ port,
506
+ path,
507
+ method: "GET",
508
+ timeout: 1500
509
+ }, (response) => {
510
+ response.resume();
511
+ resolve(true);
512
+ });
513
+ request.on("error", () => resolve(false));
514
+ request.on("timeout", () => {
515
+ request.destroy();
516
+ resolve(false);
517
+ });
518
+ request.end();
519
+ });
520
+ }
521
+ runtimeHealth(resource) {
522
+ if (resource.isDisabled)
523
+ return "unknown";
524
+ const runtime = (resource.runtimeStatus ?? "").toLowerCase();
525
+ if (runtime === "ok")
526
+ return "up";
527
+ if (runtime === "not_applicable" || runtime === "")
528
+ return "unknown";
529
+ return "down";
530
+ }
531
+ async computeHealth(resource, env) {
532
+ const runtimeDerived = this.runtimeHealth(resource);
533
+ if (resource.isDisabled)
534
+ return runtimeDerived;
535
+ if (runtimeDerived === "up" || runtimeDerived === "down")
536
+ return runtimeDerived;
537
+ const parsed = this.parseEndpoint(resource.endpoint);
538
+ const path = parsed.path ?? resource.path ?? "/";
539
+ const protocol = parsed.protocol === "https:" ? "https:" : "http:";
540
+ if (parsed.hostname === "localhost" && parsed.port === env.tiltPort) {
541
+ return runtimeDerived;
542
+ }
543
+ if (parsed.hostname && parsed.port) {
544
+ const direct = await this.tryConnect(parsed.hostname, parsed.port, path, protocol);
545
+ if (direct)
546
+ return "up";
547
+ if (parsed.hostname === "localhost") {
548
+ const loopback = await this.tryConnect("127.0.0.1", parsed.port, path, protocol) || await this.tryConnect("::1", parsed.port, path, protocol);
549
+ if (loopback)
550
+ return "up";
551
+ }
552
+ return runtimeDerived;
553
+ }
554
+ if (!resource.port)
555
+ return runtimeDerived;
556
+ const ok = await this.tryConnect("127.0.0.1", resource.port, path, protocol) || await this.tryConnect("::1", resource.port, path, protocol);
557
+ if (ok)
558
+ return "up";
559
+ return runtimeDerived;
560
+ }
561
+ getDisplayRows(env) {
562
+ const selected = env.selectedResources ?? [];
563
+ const discovered = this.discoveredResources.get(env.id) ?? [];
564
+ const cached = env.cachedResources ?? [];
565
+ const byName = new Map;
566
+ for (const resource of cached)
567
+ byName.set(resource.name, resource);
568
+ for (const resource of discovered)
569
+ byName.set(resource.name, resource);
570
+ return selected.map((name) => {
571
+ const key = `${env.id}:${name}`;
572
+ const found = byName.get(name);
573
+ if (!found) {
574
+ return {
575
+ key,
576
+ name,
577
+ label: name,
578
+ category: "services",
579
+ runtimeStatus: "missing",
580
+ isDisabled: false,
581
+ health: "missing",
582
+ exists: false,
583
+ error: `Resource '${name}' not found in Tiltfile output.`,
584
+ resourceKind: "unknown"
585
+ };
586
+ }
587
+ return {
588
+ key,
589
+ name: found.name,
590
+ label: found.label || found.name,
591
+ category: this.categoryFor(found),
592
+ endpoint: found.endpoint,
593
+ port: found.port,
594
+ path: found.path,
595
+ runtimeStatus: found.runtimeStatus ?? "unknown",
596
+ isDisabled: found.isDisabled ?? false,
597
+ health: this.healthByKey.get(key) ?? "unknown",
598
+ exists: true,
599
+ resourceKind: found.resourceKind ?? "unknown",
600
+ updateStatus: found.updateStatus,
601
+ waitingReason: found.waitingReason,
602
+ waitingOn: found.waitingOn,
603
+ lastDeployTime: found.lastDeployTime,
604
+ lastBuildDuration: found.lastBuildDuration,
605
+ lastBuildError: found.lastBuildError,
606
+ hasPendingChanges: found.hasPendingChanges,
607
+ triggerMode: found.triggerMode,
608
+ queued: found.queued,
609
+ order: found.order,
610
+ pid: found.pid,
611
+ conditions: found.conditions
612
+ };
613
+ });
614
+ }
615
+ async connectWebSocket(env) {
616
+ if (this.wsConnections.has(env.id))
617
+ return;
618
+ const tokenUrl = `http://127.0.0.1:${env.tiltPort}/api/websocket_token`;
619
+ let token;
620
+ try {
621
+ const resp = await fetch(tokenUrl);
622
+ if (!resp.ok)
623
+ return;
624
+ token = (await resp.text()).trim();
625
+ } catch {
626
+ return;
627
+ }
628
+ const wsUrl = `ws://127.0.0.1:${env.tiltPort}/ws/view?token=${token}`;
629
+ const ws = new WebSocket(wsUrl);
630
+ ws.onopen = () => {
631
+ this.appendLog(env.id, "[launcher] WebSocket connected");
632
+ };
633
+ ws.onmessage = (event) => {
634
+ try {
635
+ const data = JSON.parse(typeof event.data === "string" ? event.data : event.data.toString());
636
+ this.handleWSMessage(env, data);
637
+ } catch {}
638
+ };
639
+ ws.onerror = () => {};
640
+ ws.onclose = () => {
641
+ this.wsConnections.delete(env.id);
642
+ if (this.wsEnabled && this.processes.has(env.id)) {
643
+ const timer = setTimeout(() => {
644
+ this.wsReconnectTimers.delete(env.id);
645
+ this.connectWebSocket(env);
646
+ }, 3000);
647
+ this.wsReconnectTimers.set(env.id, timer);
648
+ }
649
+ };
650
+ this.wsConnections.set(env.id, ws);
651
+ }
652
+ disconnectWebSocket(envId) {
653
+ const timer = this.wsReconnectTimers.get(envId);
654
+ if (timer) {
655
+ clearTimeout(timer);
656
+ this.wsReconnectTimers.delete(envId);
657
+ }
658
+ const ws = this.wsConnections.get(envId);
659
+ if (ws) {
660
+ try {
661
+ ws.close();
662
+ } catch {}
663
+ this.wsConnections.delete(envId);
664
+ }
665
+ }
666
+ handleWSMessage(env, data) {
667
+ if (data.uiResources && Array.isArray(data.uiResources)) {
668
+ const incoming = data.uiResources.filter((item) => item.metadata?.name && item.metadata.name !== "(Tiltfile)").map((item) => this.parseUIResource(item, env));
669
+ if (incoming.length > 0) {
670
+ const existing = this.discoveredResources.get(env.id) ?? [];
671
+ const byName = new Map;
672
+ for (const r of existing)
673
+ byName.set(r.name, r);
674
+ for (const r of incoming)
675
+ byName.set(r.name, r);
676
+ const merged = Array.from(byName.values());
677
+ this.discoveredResources.set(env.id, merged);
678
+ this.tiltPortReachable.set(env.id, true);
679
+ const selected = new Set(env.selectedResources ?? []);
680
+ if (env.external) {
681
+ env.selectedResources = merged.map((r) => r.name);
682
+ this.newResourceCount.set(env.id, 0);
683
+ this.onConfigMutated?.(this.config);
684
+ } else {
685
+ this.newResourceCount.set(env.id, merged.filter((r) => !selected.has(r.name)).length);
686
+ }
687
+ env.cachedResources = [...merged];
688
+ for (const resource of incoming) {
689
+ const key = `${env.id}:${resource.name}`;
690
+ this.healthByKey.set(key, this.runtimeHealth(resource));
691
+ }
692
+ }
693
+ }
694
+ if (data.logList?.segments && Array.isArray(data.logList.segments)) {
695
+ const spans = data.logList.spans;
696
+ for (const seg of data.logList.segments) {
697
+ const text = seg.text?.trimEnd();
698
+ if (!text)
699
+ continue;
700
+ const manifestName = seg.spanId && spans?.[seg.spanId]?.manifestName;
701
+ if (manifestName && manifestName !== "(Tiltfile)") {
702
+ const key = `${env.id}:${manifestName}`;
703
+ const existing = this.resourceLogs.get(key) ?? [];
704
+ existing.push(text);
705
+ if (existing.length > this.maxLogLines) {
706
+ this.resourceLogs.set(key, existing.slice(-this.maxLogLines));
707
+ } else {
708
+ this.resourceLogs.set(key, existing);
709
+ }
710
+ }
711
+ this.appendLog(env.id, text);
712
+ }
713
+ }
714
+ this.emitStatus();
715
+ }
716
+ async pollTiltState() {
717
+ for (const env of this.config.environments) {
718
+ const ws = this.wsConnections.get(env.id);
719
+ if (ws && ws.readyState === WebSocket.OPEN) {
720
+ const mergedByName2 = new Map;
721
+ for (const resource of this.discoveredResources.get(env.id) ?? [])
722
+ mergedByName2.set(resource.name, resource);
723
+ for (const resourceName of env.selectedResources ?? []) {
724
+ const resource = mergedByName2.get(resourceName);
725
+ if (!resource?.endpoint)
726
+ continue;
727
+ const key = `${env.id}:${resource.name}`;
728
+ this.healthByKey.set(key, await this.computeHealth(resource, env));
729
+ }
730
+ continue;
731
+ }
732
+ const tiltIsReachable = await this.tryConnect("127.0.0.1", env.tiltPort, "/") || await this.tryConnect("::1", env.tiltPort, "/");
733
+ this.tiltPortReachable.set(env.id, tiltIsReachable);
734
+ if (tiltIsReachable && !this.wsConnections.has(env.id) && this.wsEnabled) {
735
+ this.connectWebSocket(env);
736
+ }
737
+ const resources = await this.readTiltResources(env);
738
+ if (resources) {
739
+ this.discoveredResources.set(env.id, resources);
740
+ const selected = new Set(env.selectedResources ?? []);
741
+ if (env.external) {
742
+ env.selectedResources = resources.map((r) => r.name);
743
+ this.newResourceCount.set(env.id, 0);
744
+ this.onConfigMutated?.(this.config);
745
+ } else {
746
+ this.newResourceCount.set(env.id, resources.filter((resource) => !selected.has(resource.name)).length);
747
+ }
748
+ env.cachedResources = [...resources];
749
+ if (tiltIsReachable && !this.wsConnections.has(env.id)) {
750
+ this.startResourceLogStreams(env);
751
+ }
752
+ }
753
+ const mergedByName = new Map;
754
+ for (const resource of env.cachedResources ?? [])
755
+ mergedByName.set(resource.name, resource);
756
+ for (const resource of this.discoveredResources.get(env.id) ?? [])
757
+ mergedByName.set(resource.name, resource);
758
+ for (const resourceName of env.selectedResources ?? []) {
759
+ const resource = mergedByName.get(resourceName);
760
+ if (!resource)
761
+ continue;
762
+ const key = `${env.id}:${resource.name}`;
763
+ this.healthByKey.set(key, await this.computeHealth(resource, env));
764
+ }
765
+ }
766
+ this.emitStatus();
767
+ }
768
+ }
769
+ export {
770
+ TiltManagerSDK
771
+ };