@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,924 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import http from 'node:http';
3
+ import https from 'node:https';
4
+ import { homedir } from 'node:os';
5
+ import { basename, dirname } from 'node:path';
6
+ import type {
7
+ CachedResource,
8
+ Config,
9
+ DiscoverResult,
10
+ Environment,
11
+ LogDelta,
12
+ ResourceRow,
13
+ StatusResponse,
14
+ StatusUpdate,
15
+ } from './types.ts';
16
+
17
+ type EnvState = 'running' | 'starting' | 'stopped';
18
+
19
+ interface TiltManagerSDKOptions {
20
+ maxLogLines?: number;
21
+ /** @deprecated Use onStatusUpdate + onLogDelta instead */
22
+ onStatus?: (snapshot: StatusResponse) => void;
23
+ /** Push status updates (resources / env state — no logs) */
24
+ onStatusUpdate?: (update: StatusUpdate) => void;
25
+ /** Push incremental log appends */
26
+ onLogDelta?: (delta: LogDelta) => void;
27
+ /** Called when the SDK mutates config in-memory (e.g. cachedResources for external envs). */
28
+ onConfigMutated?: (config: Config) => void;
29
+ }
30
+
31
+ export class TiltManagerSDK {
32
+ private config: Config;
33
+ private readonly maxLogLines: number;
34
+ private readonly onStatus: ((snapshot: StatusResponse) => void) | undefined;
35
+ private readonly onStatusUpdate: ((update: StatusUpdate) => void) | undefined;
36
+ private readonly onLogDelta: ((delta: LogDelta) => void) | undefined;
37
+ private readonly onConfigMutated: ((config: Config) => void) | undefined;
38
+ /** Tracks how many env log lines have been emitted per env */
39
+ private readonly emittedEnvLogIndex = new Map<string, number>();
40
+ /** Tracks how many resource log lines have been emitted per key */
41
+ private readonly emittedResourceLogIndex = new Map<string, number>();
42
+
43
+ private readonly processes = new Map<string, ChildProcess>();
44
+ private readonly logs = new Map<string, string[]>();
45
+ private readonly startTimes = new Map<string, number>();
46
+ private readonly discoveredResources = new Map<string, CachedResource[]>();
47
+ private readonly tiltPortReachable = new Map<string, boolean>();
48
+ private readonly healthByKey = new Map<string, ResourceRow['health']>();
49
+ private readonly newResourceCount = new Map<string, number>();
50
+ private readonly resourceLogProcesses = new Map<string, ChildProcess>();
51
+ private readonly resourceLogs = new Map<string, string[]>();
52
+ private pollHandle: NodeJS.Timeout | null = null;
53
+ // WebSocket streaming
54
+ private readonly wsConnections = new Map<string, WebSocket>();
55
+ private readonly wsReconnectTimers = new Map<string, NodeJS.Timeout>();
56
+ private wsEnabled = false;
57
+
58
+ constructor(config: Config, options?: TiltManagerSDKOptions) {
59
+ this.config = config;
60
+ this.maxLogLines = options?.maxLogLines ?? 800;
61
+ this.onStatus = options?.onStatus;
62
+ this.onStatusUpdate = options?.onStatusUpdate;
63
+ this.onLogDelta = options?.onLogDelta;
64
+ this.onConfigMutated = options?.onConfigMutated;
65
+ }
66
+
67
+ setConfig(next: Config): void {
68
+ this.config = next;
69
+ }
70
+
71
+ /** Full snapshot including all logs — used for initial fetch or legacy compatibility */
72
+ currentStatusSnapshot(): StatusResponse {
73
+ const envs: StatusResponse['envs'] = {};
74
+ for (const env of this.config.environments) {
75
+ envs[env.id] = {
76
+ status: this.getEnvState(env),
77
+ logs: this.logs.get(env.id) ?? [],
78
+ resourceLogs: this.getResourceLogsForEnv(env.id),
79
+ tiltPort: env.tiltPort,
80
+ uptime: this.startTimes.has(env.id) ? Date.now() - (this.startTimes.get(env.id) ?? Date.now()) : null,
81
+ newResources: this.newResourceCount.get(env.id) ?? 0,
82
+ resources: this.getDisplayRows(env),
83
+ };
84
+ }
85
+ return { envs };
86
+ }
87
+
88
+ /** Log-free status snapshot for all environments */
89
+ currentStatusUpdate(): StatusUpdate {
90
+ const envs: StatusUpdate['envs'] = {};
91
+ for (const env of this.config.environments) {
92
+ envs[env.id] = {
93
+ status: this.getEnvState(env),
94
+ tiltPort: env.tiltPort,
95
+ uptime: this.startTimes.has(env.id) ? Date.now() - (this.startTimes.get(env.id) ?? Date.now()) : null,
96
+ newResources: this.newResourceCount.get(env.id) ?? 0,
97
+ resources: this.getDisplayRows(env),
98
+ };
99
+ }
100
+ return { envs };
101
+ }
102
+
103
+ /** Full log snapshot for a single environment */
104
+ getEnvLogs(envId: string): { envLogs: string[]; resourceLogs: Record<string, string[]> } {
105
+ return {
106
+ envLogs: this.logs.get(envId) ?? [],
107
+ resourceLogs: this.getResourceLogsForEnv(envId),
108
+ };
109
+ }
110
+
111
+ startPolling(intervalMs = 5000): void {
112
+ this.wsEnabled = true;
113
+ if (this.pollHandle) clearInterval(this.pollHandle);
114
+ this.pollHandle = setInterval(() => {
115
+ void this.pollTiltState();
116
+ }, intervalMs);
117
+ void this.pollTiltState();
118
+ }
119
+
120
+ stopPolling(): void {
121
+ this.wsEnabled = false;
122
+ if (!this.pollHandle) return;
123
+ clearInterval(this.pollHandle);
124
+ this.pollHandle = null;
125
+ // Disconnect all WebSockets
126
+ for (const envId of this.wsConnections.keys()) {
127
+ this.disconnectWebSocket(envId);
128
+ }
129
+ }
130
+
131
+ startEnv(envId: string): { ok: boolean; error?: string } {
132
+ const env = this.envById(envId);
133
+ if (!env) return { ok: false, error: 'Unknown environment.' };
134
+ if (env.external) return { ok: false, error: 'Cannot start an external environment.' };
135
+ const state = this.getEnvState(env);
136
+ if (state === 'running' || state === 'starting') return { ok: false, error: 'Environment already active.' };
137
+
138
+ this.appendLog(env.id, `[launcher] Starting ${env.name}...`);
139
+ this.appendLog(env.id, `[launcher] tilt up -f ${env.tiltfile} --port ${env.tiltPort}`);
140
+ const child = spawn('tilt', ['up', '-f', env.tiltfile, '--port', String(env.tiltPort)], {
141
+ cwd: env.repoDir,
142
+ detached: true,
143
+ stdio: ['ignore', 'pipe', 'pipe'],
144
+ env: { ...process.env, PWD: env.repoDir },
145
+ });
146
+ child.unref();
147
+ child.stdout?.on('data', (chunk: Buffer) => {
148
+ for (const line of chunk.toString().split('\n').filter(Boolean)) this.appendLog(env.id, line);
149
+ });
150
+ child.stderr?.on('data', (chunk: Buffer) => {
151
+ for (const line of chunk.toString().split('\n').filter(Boolean)) this.appendLog(env.id, line);
152
+ });
153
+ child.on('close', (code) => {
154
+ this.appendLog(env.id, `[launcher] Process exited with code ${code ?? 0}`);
155
+ this.processes.delete(env.id);
156
+ this.emitStatus();
157
+ });
158
+ child.on('error', (error: Error) => {
159
+ this.appendLog(env.id, `[launcher] ${error.message}`);
160
+ this.processes.delete(env.id);
161
+ this.emitStatus();
162
+ });
163
+ this.processes.set(env.id, child);
164
+ this.startTimes.set(env.id, Date.now());
165
+ this.emitStatus();
166
+ return { ok: true };
167
+ }
168
+
169
+ stopEnv(envId: string): { ok: boolean; error?: string } {
170
+ const env = this.envById(envId);
171
+ if (!env) return { ok: false, error: 'Unknown environment.' };
172
+ this.appendLog(env.id, `[launcher] Stopping ${env.name}...`);
173
+
174
+ const tracked = this.processes.get(env.id);
175
+ if (tracked) {
176
+ try {
177
+ tracked.kill('SIGTERM');
178
+ } catch {
179
+ // already stopped
180
+ }
181
+ }
182
+ const cwd = env.repoDir || homedir();
183
+ void this.runCommand('tilt', ['down', '--port', String(env.tiltPort)], cwd);
184
+ this.processes.delete(env.id);
185
+ this.startTimes.delete(env.id);
186
+ this.tiltPortReachable.delete(env.id);
187
+ this.discoveredResources.delete(env.id);
188
+ // Clear stale health entries for this env
189
+ for (const key of this.healthByKey.keys()) {
190
+ if (key.startsWith(`${env.id}:`)) this.healthByKey.delete(key);
191
+ }
192
+ this.disconnectWebSocket(env.id);
193
+ this.stopResourceLogStreams(env.id);
194
+ this.emitStatus();
195
+ return { ok: true };
196
+ }
197
+
198
+ restartEnv(envId: string): { ok: boolean; error?: string } {
199
+ const env = this.envById(envId);
200
+ if (env?.external) return { ok: false, error: 'Cannot restart an external environment.' };
201
+ const stopped = this.stopEnv(envId);
202
+ if (!stopped.ok) return stopped;
203
+ return this.startEnv(envId);
204
+ }
205
+
206
+ async triggerResource(envId: string, resourceName: string): Promise<{ ok: boolean; error?: string }> {
207
+ return await this.runResourceCommand(envId, ['trigger', resourceName]);
208
+ }
209
+
210
+ async enableResource(envId: string, resourceName: string): Promise<{ ok: boolean; error?: string }> {
211
+ return await this.runResourceCommand(envId, ['enable', resourceName]);
212
+ }
213
+
214
+ async disableResource(envId: string, resourceName: string): Promise<{ ok: boolean; error?: string }> {
215
+ return await this.runResourceCommand(envId, ['disable', resourceName]);
216
+ }
217
+
218
+ async discoverResources(input: {
219
+ tiltfilePath: string;
220
+ tiltPort: number;
221
+ timeoutMs?: number;
222
+ }): Promise<DiscoverResult> {
223
+ const repoDir = dirname(input.tiltfilePath);
224
+ const tiltfile = basename(input.tiltfilePath);
225
+ const timeoutMs = input.timeoutMs ?? 30000;
226
+ const logsOut: string[] = [];
227
+
228
+ let discoveryProc: ChildProcess;
229
+ try {
230
+ discoveryProc = spawn('tilt', ['up', '-f', tiltfile, '--port', String(input.tiltPort)], {
231
+ cwd: repoDir,
232
+ stdio: ['ignore', 'pipe', 'pipe'],
233
+ env: { ...process.env, PWD: repoDir },
234
+ });
235
+ } catch (error) {
236
+ const message = error instanceof Error ? error.message : 'Unknown process launch error';
237
+ return {
238
+ ok: false,
239
+ resources: [],
240
+ logs: logsOut,
241
+ error: `Failed to start Tilt for discovery: ${message}`,
242
+ };
243
+ }
244
+ let spawnError: Error | null = null;
245
+
246
+ discoveryProc.stdout?.on('data', (chunk: Buffer) => {
247
+ logsOut.push(...chunk.toString().split('\n').filter(Boolean));
248
+ });
249
+ discoveryProc.stderr?.on('data', (chunk: Buffer) => {
250
+ logsOut.push(...chunk.toString().split('\n').filter(Boolean));
251
+ });
252
+ discoveryProc.once('error', (error: Error) => {
253
+ spawnError = error;
254
+ logsOut.push(`[launcher] ${error.message}`);
255
+ });
256
+
257
+ const startedAt = Date.now();
258
+ let resources: CachedResource[] | null = null;
259
+ while (Date.now() - startedAt < timeoutMs) {
260
+ if (spawnError) break;
261
+ await new Promise((resolve) => setTimeout(resolve, 2000));
262
+ const env: Environment = {
263
+ id: 'discovery',
264
+ name: 'Discovery',
265
+ repoDir,
266
+ tiltfile,
267
+ tiltPort: input.tiltPort,
268
+ selectedResources: [],
269
+ cachedResources: [],
270
+ };
271
+ resources = await this.readTiltResources(env);
272
+ if (resources && resources.length > 0) break;
273
+ }
274
+
275
+ if (spawnError) {
276
+ return {
277
+ ok: false,
278
+ resources: [],
279
+ logs: logsOut,
280
+ error: `Failed to start Tilt for discovery: ${(spawnError as Error)?.message ?? 'Unknown error'}`,
281
+ };
282
+ }
283
+
284
+ void this.runCommand('tilt', ['down', '--port', String(input.tiltPort)], repoDir);
285
+ if (discoveryProc.pid) {
286
+ try {
287
+ process.kill(-discoveryProc.pid, 'SIGTERM');
288
+ } catch {
289
+ discoveryProc.kill('SIGTERM');
290
+ }
291
+ }
292
+
293
+ if (!resources || resources.length === 0) {
294
+ return {
295
+ ok: false,
296
+ resources: [],
297
+ logs: logsOut,
298
+ error:
299
+ 'No resources found. The Tiltfile may have only defined the Tiltfile itself, or it failed to start within the discovery timeout.',
300
+ };
301
+ }
302
+
303
+ return { ok: true, resources, logs: logsOut };
304
+ }
305
+
306
+ private emitStatus(): void {
307
+ // Legacy full-snapshot callback
308
+ this.onStatus?.(this.currentStatusSnapshot());
309
+
310
+ // New split callbacks
311
+ if (this.onStatusUpdate) {
312
+ this.onStatusUpdate(this.currentStatusUpdate());
313
+ }
314
+ if (this.onLogDelta) {
315
+ const envLogs: LogDelta['envLogs'] = {};
316
+ const resourceLogs: LogDelta['resourceLogs'] = {};
317
+ let hasData = false;
318
+
319
+ for (const env of this.config.environments) {
320
+ const allEnvLines = this.logs.get(env.id) ?? [];
321
+ const prevEnv = this.emittedEnvLogIndex.get(env.id) ?? 0;
322
+ if (allEnvLines.length > prevEnv) {
323
+ envLogs[env.id] = allEnvLines.slice(prevEnv);
324
+ this.emittedEnvLogIndex.set(env.id, allEnvLines.length);
325
+ hasData = true;
326
+ }
327
+ }
328
+
329
+ for (const [key, lines] of this.resourceLogs) {
330
+ const prev = this.emittedResourceLogIndex.get(key) ?? 0;
331
+ if (lines.length > prev) {
332
+ resourceLogs[key] = lines.slice(prev);
333
+ this.emittedResourceLogIndex.set(key, lines.length);
334
+ hasData = true;
335
+ }
336
+ }
337
+
338
+ if (hasData) {
339
+ this.onLogDelta({ envLogs, resourceLogs });
340
+ }
341
+ }
342
+ }
343
+
344
+ private appendLog(envId: string, line: string): void {
345
+ const existing = this.logs.get(envId) ?? [];
346
+ existing.push(line);
347
+ this.logs.set(envId, existing.slice(-this.maxLogLines));
348
+ }
349
+
350
+ private getResourceLogsForEnv(envId: string): Record<string, string[]> {
351
+ const result: Record<string, string[]> = {};
352
+ const prefix = `${envId}:`;
353
+ for (const [key, lines] of this.resourceLogs) {
354
+ if (key.startsWith(prefix)) {
355
+ result[key.slice(prefix.length)] = lines;
356
+ }
357
+ }
358
+ return result;
359
+ }
360
+
361
+ private startResourceLogStreams(env: Environment): void {
362
+ const resources = this.discoveredResources.get(env.id) ?? [];
363
+ for (const resource of resources) {
364
+ const key = `${env.id}:${resource.name}`;
365
+ if (this.resourceLogProcesses.has(key)) continue; // already streaming
366
+
367
+ const child = spawn('tilt', ['logs', '-f', resource.name, '--port', String(env.tiltPort)], {
368
+ cwd: env.repoDir,
369
+ stdio: ['ignore', 'pipe', 'pipe'],
370
+ env: { ...process.env, PWD: env.repoDir },
371
+ });
372
+
373
+ const appendResourceLog = (line: string): void => {
374
+ const existing = this.resourceLogs.get(key) ?? [];
375
+ existing.push(line);
376
+ if (existing.length > this.maxLogLines) {
377
+ this.resourceLogs.set(key, existing.slice(-this.maxLogLines));
378
+ } else {
379
+ this.resourceLogs.set(key, existing);
380
+ }
381
+ };
382
+
383
+ child.stdout?.on('data', (chunk: Buffer) => {
384
+ for (const line of chunk.toString().split('\n').filter(Boolean)) appendResourceLog(line);
385
+ });
386
+ child.stderr?.on('data', (chunk: Buffer) => {
387
+ for (const line of chunk.toString().split('\n').filter(Boolean)) appendResourceLog(line);
388
+ });
389
+ child.on('close', () => {
390
+ this.resourceLogProcesses.delete(key);
391
+ });
392
+ child.on('error', () => {
393
+ this.resourceLogProcesses.delete(key);
394
+ });
395
+
396
+ this.resourceLogProcesses.set(key, child);
397
+ }
398
+ }
399
+
400
+ private stopResourceLogStreams(envId: string): void {
401
+ const prefix = `${envId}:`;
402
+ for (const [key, proc] of this.resourceLogProcesses) {
403
+ if (key.startsWith(prefix)) {
404
+ try {
405
+ proc.kill('SIGTERM');
406
+ } catch {
407
+ /* already dead */
408
+ }
409
+ this.resourceLogProcesses.delete(key);
410
+ this.resourceLogs.delete(key);
411
+ }
412
+ }
413
+ }
414
+
415
+ private envById(envId: string): Environment | undefined {
416
+ return this.config.environments.find((env) => env.id === envId);
417
+ }
418
+
419
+ private getEnvState(env: Environment): EnvState {
420
+ const resources = this.discoveredResources.get(env.id) ?? [];
421
+ if (resources.some((resource) => resource.runtimeStatus === 'ok')) return 'running';
422
+ if (this.tiltPortReachable.get(env.id)) return 'running';
423
+ // External envs have no tracked process — status is purely port-based
424
+ if (env.external) return 'stopped';
425
+ const proc = this.processes.get(env.id);
426
+ if (proc && proc.exitCode === null && !proc.killed) return 'starting';
427
+ return 'stopped';
428
+ }
429
+
430
+ private parseEndpoint(endpoint?: string): { protocol?: string; hostname?: string; port?: number; path?: string } {
431
+ if (!endpoint) return {};
432
+ try {
433
+ const url = new URL(endpoint);
434
+ return {
435
+ protocol: url.protocol,
436
+ hostname: url.hostname,
437
+ port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)),
438
+ path: url.pathname || '/',
439
+ };
440
+ } catch {
441
+ return {};
442
+ }
443
+ }
444
+
445
+ private absoluteEndpoint(endpoint: string | undefined, env: Environment): string | undefined {
446
+ if (!endpoint) return undefined;
447
+ try {
448
+ return new URL(endpoint).toString();
449
+ } catch {
450
+ try {
451
+ return new URL(endpoint, `http://localhost:${env.tiltPort}`).toString();
452
+ } catch {
453
+ return undefined;
454
+ }
455
+ }
456
+ }
457
+
458
+ private categoryFor(resource: CachedResource): string {
459
+ if (resource.category) return resource.category;
460
+ if (resource.runtimeStatus === 'not_applicable') return 'on-demand';
461
+ return 'services';
462
+ }
463
+
464
+ private async runCommand(command: string, args: string[], cwd: string): Promise<{ code: number; output: string }> {
465
+ return await new Promise((resolve) => {
466
+ const child = spawn(command, args, { cwd, env: { ...process.env, PWD: cwd } });
467
+ let output = '';
468
+ child.stdout.on('data', (chunk: Buffer) => {
469
+ output += chunk.toString();
470
+ });
471
+ child.stderr.on('data', (chunk: Buffer) => {
472
+ output += chunk.toString();
473
+ });
474
+ child.on('close', (code) => resolve({ code: code ?? 1, output }));
475
+ child.on('error', (err: Error) => resolve({ code: 1, output: `${output}\n${err.message}` }));
476
+ });
477
+ }
478
+
479
+ private async runResourceCommand(envId: string, args: string[]): Promise<{ ok: boolean; error?: string }> {
480
+ const env = this.envById(envId);
481
+ if (!env) return { ok: false, error: 'Unknown environment.' };
482
+ const command = ['tilt', ...args, '--port', String(env.tiltPort)].join(' ');
483
+ this.appendLog(env.id, `[launcher] ${command}`);
484
+ const cwd = env.repoDir || homedir();
485
+ const result = await this.runCommand('tilt', [...args, '--port', String(env.tiltPort)], cwd);
486
+ if (result.code !== 0) {
487
+ const detail = result.output.trim();
488
+ if (detail) this.appendLog(env.id, detail);
489
+ this.emitStatus();
490
+ return { ok: false, error: detail || `Command failed: ${command}` };
491
+ }
492
+ const detail = result.output.trim();
493
+ if (detail) this.appendLog(env.id, detail);
494
+ await this.pollTiltState();
495
+ return { ok: true };
496
+ }
497
+
498
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
499
+ private parseUIResource(item: any, env: Environment): CachedResource {
500
+ const endpoint = this.absoluteEndpoint(item.status?.endpointLinks?.[0]?.url, env);
501
+ const parsedEndpoint = this.parseEndpoint(endpoint);
502
+ const labels = item.metadata?.labels ? Object.keys(item.metadata.labels) : [];
503
+ const disableState = (item.status?.disableStatus?.state ?? '').toLowerCase();
504
+ const isDisabled = disableState === 'disabled' || disableState === 'pending';
505
+ const runtimeStatus = isDisabled ? 'disabled' : (item.status?.runtimeStatus ?? 'unknown');
506
+ const resourceKind =
507
+ runtimeStatus === 'not_applicable' || runtimeStatus === 'disabled'
508
+ ? ('cmd' as const)
509
+ : runtimeStatus === 'ok' || runtimeStatus === 'pending' || runtimeStatus === 'error'
510
+ ? ('serve' as const)
511
+ : ('unknown' as const);
512
+
513
+ // Build history
514
+ const buildHistory = item.status?.buildHistory as
515
+ | Array<{
516
+ startTime?: string;
517
+ finishTime?: string;
518
+ error?: string;
519
+ }>
520
+ | undefined;
521
+ const lastBuild = buildHistory?.[0];
522
+ const lastBuildDuration =
523
+ lastBuild?.startTime && lastBuild?.finishTime
524
+ ? (new Date(lastBuild.finishTime).getTime() - new Date(lastBuild.startTime).getTime()) / 1000
525
+ : undefined;
526
+
527
+ // Waiting state
528
+ const waiting = item.status?.waiting as
529
+ | {
530
+ reason?: string;
531
+ on?: Array<{ name?: string }>;
532
+ }
533
+ | undefined;
534
+
535
+ // Conditions
536
+ const rawConditions = item.status?.conditions as
537
+ | Array<{
538
+ type?: string;
539
+ status?: string;
540
+ lastTransitionTime?: string;
541
+ }>
542
+ | undefined;
543
+ const conditions = rawConditions?.map((c) => ({
544
+ type: c.type ?? '',
545
+ status: c.status ?? '',
546
+ ...(c.lastTransitionTime != null ? { lastTransitionTime: c.lastTransitionTime } : {}),
547
+ }));
548
+
549
+ // PID (comes as a string in WebSocket, number in CLI)
550
+ const rawPid = item.status?.localResourceInfo?.pid;
551
+ const pid = rawPid ? Number(rawPid) : undefined;
552
+
553
+ return {
554
+ name: item.metadata?.name ?? 'unknown',
555
+ label: item.metadata?.name ?? 'unknown',
556
+ category: labels[0] ?? 'services',
557
+ type: item.status?.specs?.[0]?.type ?? 'unknown',
558
+ endpoint,
559
+ port: parsedEndpoint.port,
560
+ path: parsedEndpoint.path,
561
+ runtimeStatus,
562
+ isDisabled,
563
+ resourceKind,
564
+ updateStatus: item.status?.updateStatus,
565
+ waitingReason: waiting?.reason,
566
+ waitingOn: waiting?.on?.map((ref: { name?: string }) => ref.name ?? '').filter(Boolean),
567
+ lastDeployTime: item.status?.lastDeployTime,
568
+ lastBuildDuration,
569
+ lastBuildError: lastBuild?.error,
570
+ hasPendingChanges: item.status?.hasPendingChanges,
571
+ triggerMode: item.status?.triggerMode,
572
+ queued: item.status?.queued,
573
+ order: item.status?.order,
574
+ pid,
575
+ conditions,
576
+ };
577
+ }
578
+
579
+ private async readTiltResources(env: Environment): Promise<CachedResource[] | null> {
580
+ const result = await this.runCommand(
581
+ 'tilt',
582
+ ['get', 'uiresources', '-o', 'json', '--port', String(env.tiltPort)],
583
+ env.repoDir,
584
+ );
585
+ if (result.code !== 0) return null;
586
+ try {
587
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
588
+ const parsed = JSON.parse(result.output) as { items?: any[] };
589
+ return (parsed.items ?? [])
590
+ .filter((item) => item.metadata?.name && item.metadata.name !== '(Tiltfile)')
591
+ .map((item) => this.parseUIResource(item, env));
592
+ } catch {
593
+ return null;
594
+ }
595
+ }
596
+
597
+ private async tryConnect(
598
+ hostname: string,
599
+ port: number,
600
+ path = '/',
601
+ protocol: 'http:' | 'https:' = 'http:',
602
+ ): Promise<boolean> {
603
+ return await new Promise((resolve) => {
604
+ const client = protocol === 'https:' ? https : http;
605
+ const request = client.request(
606
+ {
607
+ hostname,
608
+ port,
609
+ path,
610
+ method: 'GET',
611
+ timeout: 1500,
612
+ },
613
+ (response) => {
614
+ response.resume();
615
+ resolve(true);
616
+ },
617
+ );
618
+ request.on('error', () => resolve(false));
619
+ request.on('timeout', () => {
620
+ request.destroy();
621
+ resolve(false);
622
+ });
623
+ request.end();
624
+ });
625
+ }
626
+
627
+ private runtimeHealth(resource: CachedResource): ResourceRow['health'] {
628
+ if (resource.isDisabled) return 'unknown';
629
+ const runtime = (resource.runtimeStatus ?? '').toLowerCase();
630
+ if (runtime === 'ok') return 'up';
631
+ if (runtime === 'not_applicable' || runtime === '') return 'unknown';
632
+ return 'down';
633
+ }
634
+
635
+ private async computeHealth(resource: CachedResource, env: Environment): Promise<ResourceRow['health']> {
636
+ const runtimeDerived = this.runtimeHealth(resource);
637
+ if (resource.isDisabled) return runtimeDerived;
638
+
639
+ // Trust Tilt's runtime status — if Tilt says "ok", it's up.
640
+ // Probes only supplement by upgrading 'unknown' to 'up', never downgrade.
641
+ if (runtimeDerived === 'up' || runtimeDerived === 'down') return runtimeDerived;
642
+
643
+ const parsed = this.parseEndpoint(resource.endpoint);
644
+ const path = parsed.path ?? resource.path ?? '/';
645
+ const protocol = parsed.protocol === 'https:' ? 'https:' : 'http:';
646
+
647
+ // Tilt often exposes resource links through its own dashboard proxy port.
648
+ // Probing that port only confirms Tilt is up, not the underlying service.
649
+ if (parsed.hostname === 'localhost' && parsed.port === env.tiltPort) {
650
+ return runtimeDerived;
651
+ }
652
+
653
+ if (parsed.hostname && parsed.port) {
654
+ const direct = await this.tryConnect(parsed.hostname, parsed.port, path, protocol);
655
+ if (direct) return 'up';
656
+ if (parsed.hostname === 'localhost') {
657
+ const loopback =
658
+ (await this.tryConnect('127.0.0.1', parsed.port, path, protocol)) ||
659
+ (await this.tryConnect('::1', parsed.port, path, protocol));
660
+ if (loopback) return 'up';
661
+ }
662
+ return runtimeDerived;
663
+ }
664
+
665
+ if (!resource.port) return runtimeDerived;
666
+ const ok =
667
+ (await this.tryConnect('127.0.0.1', resource.port, path, protocol)) ||
668
+ (await this.tryConnect('::1', resource.port, path, protocol));
669
+ if (ok) return 'up';
670
+ return runtimeDerived;
671
+ }
672
+
673
+ private getDisplayRows(env: Environment): ResourceRow[] {
674
+ const selected = env.selectedResources ?? [];
675
+ const discovered = this.discoveredResources.get(env.id) ?? [];
676
+ const cached = env.cachedResources ?? [];
677
+ const byName = new Map<string, CachedResource>();
678
+ for (const resource of cached) byName.set(resource.name, resource);
679
+ for (const resource of discovered) byName.set(resource.name, resource);
680
+
681
+ return selected.map((name) => {
682
+ const key = `${env.id}:${name}`;
683
+ const found = byName.get(name);
684
+ if (!found) {
685
+ return {
686
+ key,
687
+ name,
688
+ label: name,
689
+ category: 'services',
690
+ runtimeStatus: 'missing',
691
+ isDisabled: false,
692
+ health: 'missing',
693
+ exists: false,
694
+ error: `Resource '${name}' not found in Tiltfile output.`,
695
+ resourceKind: 'unknown',
696
+ };
697
+ }
698
+ return {
699
+ key,
700
+ name: found.name,
701
+ label: found.label || found.name,
702
+ category: this.categoryFor(found),
703
+ endpoint: found.endpoint,
704
+ port: found.port,
705
+ path: found.path,
706
+ runtimeStatus: found.runtimeStatus ?? 'unknown',
707
+ isDisabled: found.isDisabled ?? false,
708
+ health: this.healthByKey.get(key) ?? 'unknown',
709
+ exists: true,
710
+ resourceKind: found.resourceKind ?? 'unknown',
711
+ updateStatus: found.updateStatus,
712
+ waitingReason: found.waitingReason,
713
+ waitingOn: found.waitingOn,
714
+ lastDeployTime: found.lastDeployTime,
715
+ lastBuildDuration: found.lastBuildDuration,
716
+ lastBuildError: found.lastBuildError,
717
+ hasPendingChanges: found.hasPendingChanges,
718
+ triggerMode: found.triggerMode,
719
+ queued: found.queued,
720
+ order: found.order,
721
+ pid: found.pid,
722
+ conditions: found.conditions,
723
+ };
724
+ });
725
+ }
726
+
727
+ // ─── WebSocket streaming ──────────────────────────────────────────────────
728
+
729
+ private async connectWebSocket(env: Environment): Promise<void> {
730
+ if (this.wsConnections.has(env.id)) return; // already connected
731
+
732
+ const tokenUrl = `http://127.0.0.1:${env.tiltPort}/api/websocket_token`;
733
+ let token: string;
734
+ try {
735
+ const resp = await fetch(tokenUrl);
736
+ if (!resp.ok) return;
737
+ token = (await resp.text()).trim();
738
+ } catch {
739
+ return; // Tilt not ready yet
740
+ }
741
+
742
+ const wsUrl = `ws://127.0.0.1:${env.tiltPort}/ws/view?token=${token}`;
743
+ const ws = new WebSocket(wsUrl);
744
+
745
+ ws.onopen = () => {
746
+ this.appendLog(env.id, '[launcher] WebSocket connected');
747
+ };
748
+
749
+ ws.onmessage = (event: MessageEvent) => {
750
+ try {
751
+ const data = JSON.parse(typeof event.data === 'string' ? event.data : event.data.toString());
752
+ this.handleWSMessage(env, data);
753
+ } catch {
754
+ /* ignore malformed messages */
755
+ }
756
+ };
757
+
758
+ ws.onerror = () => {
759
+ // Will trigger onclose
760
+ };
761
+
762
+ ws.onclose = () => {
763
+ this.wsConnections.delete(env.id);
764
+ // Auto-reconnect if still polling and env is active
765
+ if (this.wsEnabled && this.processes.has(env.id)) {
766
+ const timer = setTimeout(() => {
767
+ this.wsReconnectTimers.delete(env.id);
768
+ void this.connectWebSocket(env);
769
+ }, 3000);
770
+ this.wsReconnectTimers.set(env.id, timer);
771
+ }
772
+ };
773
+
774
+ this.wsConnections.set(env.id, ws);
775
+ }
776
+
777
+ private disconnectWebSocket(envId: string): void {
778
+ const timer = this.wsReconnectTimers.get(envId);
779
+ if (timer) {
780
+ clearTimeout(timer);
781
+ this.wsReconnectTimers.delete(envId);
782
+ }
783
+ const ws = this.wsConnections.get(envId);
784
+ if (ws) {
785
+ try {
786
+ ws.close();
787
+ } catch {
788
+ /* already closed */
789
+ }
790
+ this.wsConnections.delete(envId);
791
+ }
792
+ }
793
+
794
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
795
+ private handleWSMessage(env: Environment, data: any): void {
796
+ // Parse uiResources from initial or delta messages
797
+ if (data.uiResources && Array.isArray(data.uiResources)) {
798
+ const incoming = (data.uiResources as Array<{ metadata?: { name?: string } }>)
799
+ .filter((item) => item.metadata?.name && item.metadata.name !== '(Tiltfile)')
800
+ .map((item) => this.parseUIResource(item, env));
801
+
802
+ if (incoming.length > 0) {
803
+ // Merge by name: update existing resources, add new ones, keep resources
804
+ // not present in this delta (WS deltas may only contain changed resources)
805
+ const existing = this.discoveredResources.get(env.id) ?? [];
806
+ const byName = new Map<string, CachedResource>();
807
+ for (const r of existing) byName.set(r.name, r);
808
+ for (const r of incoming) byName.set(r.name, r);
809
+ const merged = Array.from(byName.values());
810
+
811
+ this.discoveredResources.set(env.id, merged);
812
+ this.tiltPortReachable.set(env.id, true);
813
+
814
+ const selected = new Set(env.selectedResources ?? []);
815
+ // External envs auto-select all discovered resources (no manual discovery step)
816
+ if (env.external) {
817
+ env.selectedResources = merged.map((r) => r.name);
818
+ this.newResourceCount.set(env.id, 0);
819
+ this.onConfigMutated?.(this.config);
820
+ } else {
821
+ this.newResourceCount.set(env.id, merged.filter((r) => !selected.has(r.name)).length);
822
+ }
823
+ env.cachedResources = [...merged];
824
+
825
+ // Derive health from runtimeStatus (no need for HTTP probes when using WS)
826
+ for (const resource of incoming) {
827
+ const key = `${env.id}:${resource.name}`;
828
+ this.healthByKey.set(key, this.runtimeHealth(resource));
829
+ }
830
+ }
831
+ }
832
+
833
+ // Parse structured logs from logList
834
+ if (data.logList?.segments && Array.isArray(data.logList.segments)) {
835
+ const spans = data.logList.spans as Record<string, { manifestName?: string }> | undefined;
836
+ for (const seg of data.logList.segments as Array<{ spanId?: string; text?: string }>) {
837
+ const text = seg.text?.trimEnd();
838
+ if (!text) continue;
839
+
840
+ // Map spanId → resource name via spans map
841
+ const manifestName = seg.spanId && spans?.[seg.spanId]?.manifestName;
842
+ if (manifestName && manifestName !== '(Tiltfile)') {
843
+ const key = `${env.id}:${manifestName}`;
844
+ const existing = this.resourceLogs.get(key) ?? [];
845
+ existing.push(text);
846
+ if (existing.length > this.maxLogLines) {
847
+ this.resourceLogs.set(key, existing.slice(-this.maxLogLines));
848
+ } else {
849
+ this.resourceLogs.set(key, existing);
850
+ }
851
+ }
852
+
853
+ // Also append to env-level logs
854
+ this.appendLog(env.id, text);
855
+ }
856
+ }
857
+
858
+ this.emitStatus();
859
+ }
860
+
861
+ // ─── Polling (fallback) ──────────────────────────────────────────────────
862
+
863
+ private async pollTiltState(): Promise<void> {
864
+ for (const env of this.config.environments) {
865
+ // If WebSocket is connected, skip CLI polling for this env
866
+ const ws = this.wsConnections.get(env.id);
867
+ if (ws && ws.readyState === WebSocket.OPEN) {
868
+ // Still probe health for services with endpoints (WS gives runtimeStatus-based health,
869
+ // but endpoint probing validates actual reachability)
870
+ const mergedByName = new Map<string, CachedResource>();
871
+ for (const resource of this.discoveredResources.get(env.id) ?? []) mergedByName.set(resource.name, resource);
872
+ for (const resourceName of env.selectedResources ?? []) {
873
+ const resource = mergedByName.get(resourceName);
874
+ if (!resource?.endpoint) continue;
875
+ const key = `${env.id}:${resource.name}`;
876
+ this.healthByKey.set(key, await this.computeHealth(resource, env));
877
+ }
878
+ continue;
879
+ }
880
+
881
+ // No WebSocket — fall back to CLI polling
882
+ const tiltIsReachable =
883
+ (await this.tryConnect('127.0.0.1', env.tiltPort, '/')) || (await this.tryConnect('::1', env.tiltPort, '/'));
884
+ this.tiltPortReachable.set(env.id, tiltIsReachable);
885
+
886
+ // Try to establish WebSocket when we first detect the port is reachable
887
+ if (tiltIsReachable && !this.wsConnections.has(env.id) && this.wsEnabled) {
888
+ void this.connectWebSocket(env);
889
+ }
890
+
891
+ const resources = await this.readTiltResources(env);
892
+ if (resources) {
893
+ this.discoveredResources.set(env.id, resources);
894
+ const selected = new Set(env.selectedResources ?? []);
895
+ // External envs auto-select all discovered resources (no manual discovery step)
896
+ if (env.external) {
897
+ env.selectedResources = resources.map((r) => r.name);
898
+ this.newResourceCount.set(env.id, 0);
899
+ this.onConfigMutated?.(this.config);
900
+ } else {
901
+ this.newResourceCount.set(env.id, resources.filter((resource) => !selected.has(resource.name)).length);
902
+ }
903
+ env.cachedResources = [...resources];
904
+
905
+ // Start per-resource log streams for running envs (only if no WS)
906
+ if (tiltIsReachable && !this.wsConnections.has(env.id)) {
907
+ this.startResourceLogStreams(env);
908
+ }
909
+ }
910
+
911
+ // Always probe selected services from merged cached/discovered data
912
+ const mergedByName = new Map<string, CachedResource>();
913
+ for (const resource of env.cachedResources ?? []) mergedByName.set(resource.name, resource);
914
+ for (const resource of this.discoveredResources.get(env.id) ?? []) mergedByName.set(resource.name, resource);
915
+ for (const resourceName of env.selectedResources ?? []) {
916
+ const resource = mergedByName.get(resourceName);
917
+ if (!resource) continue;
918
+ const key = `${env.id}:${resource.name}`;
919
+ this.healthByKey.set(key, await this.computeHealth(resource, env));
920
+ }
921
+ }
922
+ this.emitStatus();
923
+ }
924
+ }