@tilt-launcher/sdk 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,58 @@ npm install @tilt-launcher/sdk
12
12
 
13
13
  ## Quick Start
14
14
 
15
+ ### One-Shot Query (CLI tools, CI, health checks)
16
+
17
+ ```ts
18
+ import { queryTilt } from '@tilt-launcher/sdk';
19
+
20
+ const status = await queryTilt(10350);
21
+ if (!status.allHealthy) {
22
+ for (const err of status.errors) {
23
+ console.error(`${err.name}: ${err.lastBuildError ?? err.runtimeStatus}`);
24
+ }
25
+ process.exit(1);
26
+ }
27
+ console.log(`All ${status.healthy.length} resources healthy`);
28
+ ```
29
+
30
+ ### Streaming (dashboards, monitoring)
31
+
32
+ ```ts
33
+ import { watchTilt } from '@tilt-launcher/sdk';
34
+
35
+ const stop = await watchTilt(10350, (event) => {
36
+ for (const r of event.resources) {
37
+ if (r.runtimeStatus === 'error') console.error(`${r.name} failed!`);
38
+ }
39
+ for (const log of event.logs) {
40
+ console.log(`[${log.resourceName ?? 'system'}] ${log.text}`);
41
+ }
42
+ });
43
+
44
+ // Stop watching when done
45
+ setTimeout(stop, 60_000);
46
+ ```
47
+
48
+ ### Full Client (multiple operations)
49
+
50
+ ```ts
51
+ import { TiltClient } from '@tilt-launcher/sdk';
52
+
53
+ const tilt = new TiltClient(10350);
54
+
55
+ if (await tilt.isReachable()) {
56
+ const resources = await tilt.getResources();
57
+ const status = await tilt.getStatus();
58
+ const myService = await tilt.getResource('my-service');
59
+ await tilt.triggerResource('my-service');
60
+ }
61
+
62
+ tilt.close();
63
+ ```
64
+
65
+ ### Full Manager (multi-env orchestration)
66
+
15
67
  ```ts
16
68
  import { TiltManagerSDK } from '@tilt-launcher/sdk';
17
69
  import type { Config, StatusUpdate, LogDelta } from '@tilt-launcher/sdk';
@@ -31,34 +83,35 @@ const config: Config = {
31
83
  };
32
84
 
33
85
  const sdk = new TiltManagerSDK(config, {
34
- onStatusUpdate: (update: StatusUpdate) => {
35
- console.log('Status:', update.envs);
36
- },
37
- onLogDelta: (delta: LogDelta) => {
38
- console.log('Logs:', delta);
39
- },
86
+ onStatusUpdate: (update: StatusUpdate) => console.log('Status:', update.envs),
87
+ onLogDelta: (delta: LogDelta) => console.log('Logs:', delta),
40
88
  });
41
89
 
42
- // Start polling the Tilt API
43
90
  sdk.startPolling(5000);
44
-
45
- // Start an environment
46
91
  await sdk.startEnv('my-app');
92
+ ```
93
+
94
+ ## API
47
95
 
48
- // Get current status
49
- const status = sdk.currentStatusUpdate();
96
+ ### `queryTilt(port, options?)` → `Promise<TiltStatus>`
50
97
 
51
- // Control resources
52
- await sdk.triggerResource('my-app', 'web-server');
53
- await sdk.disableResource('my-app', 'slow-service');
54
- await sdk.enableResource('my-app', 'slow-service');
98
+ One-shot: fetch all resources and return a structured status summary. No cleanup needed.
55
99
 
56
- // Stop
57
- await sdk.stopEnv('my-app');
58
- sdk.stopPolling();
59
- ```
100
+ ### `watchTilt(port, callback, options?)` → `Promise<() => void>`
60
101
 
61
- ## API
102
+ Stream resource + log updates via WebSocket. Returns an unsubscribe function.
103
+
104
+ ### `TiltClient`
105
+
106
+ | Method | Description |
107
+ | ----------------------- | ----------------------------------------- |
108
+ | `isReachable()` | Check if Tilt is running at this port |
109
+ | `getResources()` | Fetch all resource statuses |
110
+ | `getStatus()` | Fetch resources as a `TiltStatus` summary |
111
+ | `getResource(name)` | Get a single resource by name |
112
+ | `triggerResource(name)` | Trigger a resource update |
113
+ | `watch(callback)` | Subscribe to WebSocket updates |
114
+ | `close()` | Close all connections |
62
115
 
63
116
  ### `TiltManagerSDK`
64
117
 
@@ -77,28 +130,24 @@ sdk.stopPolling();
77
130
  | `startPolling(intervalMs)` | Start polling Tilt APIs |
78
131
  | `stopPolling()` | Stop polling |
79
132
 
80
- ### Types
81
-
82
- - `Config` — app configuration with environment list
83
- - `Environment` — a single Tilt environment definition
84
- - `StatusUpdate` — full status snapshot (all envs + resources)
85
- - `ResourceRow` — individual resource status (health, pid, labels, etc.)
86
- - `LogDelta` — incremental log update
87
- - `DiscoverResult` — result of resource discovery
88
- - `LauncherBridge` — abstract UI bridge interface
89
-
90
- ### Callbacks
133
+ ### Key Types
91
134
 
92
- | Callback | Fires when |
93
- | ----------------- | -------------------------------------------------------- |
94
- | `onStatusUpdate` | Tilt API poll returns new data |
95
- | `onLogDelta` | New log lines arrive via WebSocket |
96
- | `onConfigMutated` | SDK internally modifies config (e.g., caching resources) |
135
+ | Type | Description |
136
+ | ---------------- | ----------------------------------------------------- |
137
+ | `TiltResource` | Single resource from `TiltClient` (name, status, etc) |
138
+ | `TiltStatus` | Status summary: resources, errors, healthy, pending |
139
+ | `TiltWatchEvent` | WebSocket event: resources + logs |
140
+ | `ResourceRow` | Resource from `TiltManagerSDK` (includes health) |
141
+ | `StatusUpdate` | Full status snapshot from `TiltManagerSDK` |
142
+ | `LogDelta` | Incremental log update |
143
+ | `Config` | App configuration with environment list |
144
+ | `Environment` | Single Tilt environment definition |
145
+ | `LauncherBridge` | Abstract UI bridge interface |
97
146
 
98
147
  ## Requirements
99
148
 
100
- - `tilt` must be on `$PATH`
101
149
  - Node.js ≥ 18 or Bun
150
+ - `tilt` on `$PATH` (only required for `TiltManagerSDK`; `TiltClient` uses HTTP)
102
151
 
103
152
  ## License
104
153
 
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './types.ts';
2
2
  export { type LauncherBridge } from './bridge.ts';
3
3
  export { TiltManagerSDK } from './tiltManagerSDK.ts';
4
+ export { TiltClient, queryTilt, watchTilt, type TiltResource, type TiltStatus, type TiltWatchEvent, type TiltWatchCallback, type TiltClientOptions, } from './tiltClient.ts';
package/dist/index.js CHANGED
@@ -766,6 +766,269 @@ ${err.message}` }));
766
766
  this.emitStatus();
767
767
  }
768
768
  }
769
+
770
+ // src/tiltClient.ts
771
+ function parseEndpoint(endpoint) {
772
+ if (!endpoint)
773
+ return {};
774
+ try {
775
+ const url = new URL(endpoint);
776
+ return {
777
+ port: Number(url.port || (url.protocol === "https:" ? 443 : 80)),
778
+ path: url.pathname || "/"
779
+ };
780
+ } catch {
781
+ return {};
782
+ }
783
+ }
784
+ function parseUIResource(item, tiltPort) {
785
+ const endpointUrl = item.status?.endpointLinks?.[0]?.url;
786
+ let endpoint;
787
+ if (endpointUrl) {
788
+ try {
789
+ endpoint = new URL(endpointUrl).toString();
790
+ } catch {
791
+ try {
792
+ endpoint = new URL(endpointUrl, `http://localhost:${tiltPort}`).toString();
793
+ } catch {
794
+ endpoint = undefined;
795
+ }
796
+ }
797
+ }
798
+ const parsed = parseEndpoint(endpoint);
799
+ const labels = item.metadata?.labels ? Object.keys(item.metadata.labels) : [];
800
+ const disableState = (item.status?.disableStatus?.state ?? "").toLowerCase();
801
+ const isDisabled = disableState === "disabled" || disableState === "pending";
802
+ const runtimeStatus = isDisabled ? "disabled" : item.status?.runtimeStatus ?? "unknown";
803
+ const resourceKind = runtimeStatus === "not_applicable" || runtimeStatus === "disabled" ? "cmd" : runtimeStatus === "ok" || runtimeStatus === "pending" || runtimeStatus === "error" ? "serve" : "unknown";
804
+ const buildHistory = item.status?.buildHistory;
805
+ const lastBuild = buildHistory?.[0];
806
+ const lastBuildDuration = lastBuild?.startTime && lastBuild?.finishTime ? (new Date(lastBuild.finishTime).getTime() - new Date(lastBuild.startTime).getTime()) / 1000 : undefined;
807
+ const waiting = item.status?.waiting;
808
+ const rawConditions = item.status?.conditions;
809
+ const conditions = rawConditions?.map((c) => ({
810
+ type: c.type ?? "",
811
+ status: c.status ?? "",
812
+ ...c.lastTransitionTime != null ? { lastTransitionTime: c.lastTransitionTime } : {}
813
+ }));
814
+ const rawPid = item.status?.localResourceInfo?.pid;
815
+ return {
816
+ name: item.metadata?.name ?? "unknown",
817
+ label: item.metadata?.name ?? "unknown",
818
+ category: labels[0] ?? "services",
819
+ type: item.status?.specs?.[0]?.type ?? "unknown",
820
+ endpoint,
821
+ port: parsed.port,
822
+ resourceKind,
823
+ runtimeStatus,
824
+ isDisabled,
825
+ updateStatus: item.status?.updateStatus,
826
+ waitingReason: waiting?.reason,
827
+ waitingOn: waiting?.on?.map((ref) => ref.name ?? "").filter(Boolean),
828
+ lastDeployTime: item.status?.lastDeployTime,
829
+ lastBuildDuration,
830
+ lastBuildError: lastBuild?.error,
831
+ hasPendingChanges: item.status?.hasPendingChanges,
832
+ triggerMode: item.status?.triggerMode,
833
+ queued: item.status?.queued,
834
+ pid: rawPid ? Number(rawPid) : undefined,
835
+ conditions
836
+ };
837
+ }
838
+ function buildStatus(resources) {
839
+ const errors = resources.filter((r) => r.runtimeStatus === "error");
840
+ const healthy = resources.filter((r) => r.runtimeStatus === "ok");
841
+ const pending = resources.filter((r) => r.runtimeStatus === "pending");
842
+ const active = resources.filter((r) => !r.isDisabled && r.runtimeStatus !== "not_applicable");
843
+ return {
844
+ resources,
845
+ errors,
846
+ healthy,
847
+ pending,
848
+ allHealthy: active.length > 0 && errors.length === 0 && pending.length === 0
849
+ };
850
+ }
851
+
852
+ class TiltClient {
853
+ baseUrl;
854
+ port;
855
+ timeoutMs;
856
+ ws = null;
857
+ watchCallback = null;
858
+ reconnectTimer = null;
859
+ closed = false;
860
+ constructor(port, options) {
861
+ const host = options?.host ?? "127.0.0.1";
862
+ this.port = port;
863
+ this.baseUrl = `http://${host}:${port}`;
864
+ this.timeoutMs = options?.timeoutMs ?? 5000;
865
+ }
866
+ async isReachable() {
867
+ try {
868
+ const resp = await fetch(`${this.baseUrl}/api/websocket_token`, {
869
+ signal: AbortSignal.timeout(this.timeoutMs)
870
+ });
871
+ return resp.ok;
872
+ } catch {
873
+ return false;
874
+ }
875
+ }
876
+ async getResources() {
877
+ const resp = await fetch(`${this.baseUrl}/api/v1alpha1/uiresources`, {
878
+ signal: AbortSignal.timeout(this.timeoutMs)
879
+ });
880
+ if (!resp.ok) {
881
+ throw new Error(`Tilt API error: ${resp.status} ${resp.statusText}`);
882
+ }
883
+ const data = await resp.json();
884
+ return (data.items ?? []).filter((item) => item.metadata?.name && item.metadata.name !== "(Tiltfile)").map((item) => parseUIResource(item, this.port));
885
+ }
886
+ async getStatus() {
887
+ const resources = await this.getResources();
888
+ return buildStatus(resources);
889
+ }
890
+ async getResource(name) {
891
+ const resources = await this.getResources();
892
+ return resources.find((r) => r.name === name) ?? null;
893
+ }
894
+ async triggerResource(name) {
895
+ const resp = await fetch(`${this.baseUrl}/api/trigger`, {
896
+ method: "POST",
897
+ headers: { "Content-Type": "application/json" },
898
+ body: JSON.stringify({ manifest_names: [name], build_reason: 16 }),
899
+ signal: AbortSignal.timeout(this.timeoutMs)
900
+ });
901
+ if (!resp.ok) {
902
+ throw new Error(`Tilt trigger error: ${resp.status} ${resp.statusText}`);
903
+ }
904
+ }
905
+ async watch(callback) {
906
+ this.watchCallback = callback;
907
+ await this.connectWS();
908
+ return () => {
909
+ this.watchCallback = null;
910
+ this.disconnectWS();
911
+ };
912
+ }
913
+ close() {
914
+ this.closed = true;
915
+ this.watchCallback = null;
916
+ this.disconnectWS();
917
+ }
918
+ async connectWS() {
919
+ if (this.ws)
920
+ return;
921
+ const tokenResp = await fetch(`${this.baseUrl}/api/websocket_token`, {
922
+ signal: AbortSignal.timeout(this.timeoutMs)
923
+ });
924
+ if (!tokenResp.ok)
925
+ throw new Error("Could not get WebSocket token from Tilt");
926
+ const token = (await tokenResp.text()).trim();
927
+ const wsUrl = `ws://127.0.0.1:${this.port}/ws/view?token=${token}`;
928
+ const ws = new WebSocket(wsUrl);
929
+ ws.onmessage = (event) => {
930
+ try {
931
+ const data = JSON.parse(typeof event.data === "string" ? event.data : event.data.toString());
932
+ this.handleWSMessage(data);
933
+ } catch {}
934
+ };
935
+ ws.onerror = () => {};
936
+ ws.onclose = () => {
937
+ this.ws = null;
938
+ if (this.watchCallback && !this.closed) {
939
+ this.reconnectTimer = setTimeout(() => {
940
+ this.reconnectTimer = null;
941
+ this.connectWS();
942
+ }, 3000);
943
+ }
944
+ };
945
+ this.ws = ws;
946
+ }
947
+ disconnectWS() {
948
+ if (this.reconnectTimer) {
949
+ clearTimeout(this.reconnectTimer);
950
+ this.reconnectTimer = null;
951
+ }
952
+ if (this.ws) {
953
+ try {
954
+ this.ws.close();
955
+ } catch {}
956
+ this.ws = null;
957
+ }
958
+ }
959
+ handleWSMessage(data) {
960
+ if (!this.watchCallback)
961
+ return;
962
+ const resources = [];
963
+ const logs = [];
964
+ if (data.uiResources && Array.isArray(data.uiResources)) {
965
+ for (const item of data.uiResources) {
966
+ if (item.metadata?.name && item.metadata.name !== "(Tiltfile)") {
967
+ resources.push(parseUIResource(item, this.port));
968
+ }
969
+ }
970
+ }
971
+ if (data.logList?.segments && Array.isArray(data.logList.segments)) {
972
+ const spans = data.logList.spans;
973
+ for (const seg of data.logList.segments) {
974
+ const text = seg.text?.trimEnd();
975
+ if (!text)
976
+ continue;
977
+ const resourceName = seg.spanId && spans?.[seg.spanId]?.manifestName;
978
+ logs.push({
979
+ resourceName: resourceName && resourceName !== "(Tiltfile)" ? resourceName : undefined,
980
+ text
981
+ });
982
+ }
983
+ }
984
+ if (resources.length > 0 || logs.length > 0) {
985
+ this.watchCallback({ resources, logs });
986
+ }
987
+ }
988
+ }
989
+ async function queryTilt(port, options) {
990
+ const client = new TiltClient(port, options);
991
+ try {
992
+ return await client.getStatus();
993
+ } finally {
994
+ client.close();
995
+ }
996
+ }
997
+ async function watchTilt(port, callback, options) {
998
+ const client = new TiltClient(port, options);
999
+ const unsub = await client.watch(callback);
1000
+ return () => {
1001
+ unsub();
1002
+ client.close();
1003
+ };
1004
+ }
1005
+ function tiltResourceToCached(r) {
1006
+ return {
1007
+ name: r.name,
1008
+ label: r.label,
1009
+ category: r.category,
1010
+ type: r.type,
1011
+ endpoint: r.endpoint,
1012
+ port: r.port,
1013
+ runtimeStatus: r.runtimeStatus,
1014
+ isDisabled: r.isDisabled,
1015
+ resourceKind: r.resourceKind,
1016
+ updateStatus: r.updateStatus,
1017
+ waitingReason: r.waitingReason,
1018
+ waitingOn: r.waitingOn,
1019
+ lastDeployTime: r.lastDeployTime,
1020
+ lastBuildDuration: r.lastBuildDuration,
1021
+ lastBuildError: r.lastBuildError,
1022
+ hasPendingChanges: r.hasPendingChanges,
1023
+ triggerMode: r.triggerMode,
1024
+ queued: r.queued,
1025
+ pid: r.pid,
1026
+ conditions: r.conditions
1027
+ };
1028
+ }
769
1029
  export {
770
- TiltManagerSDK
1030
+ watchTilt,
1031
+ queryTilt,
1032
+ TiltManagerSDK,
1033
+ TiltClient
771
1034
  };
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Standalone Tilt API client.
3
+ *
4
+ * Connects directly to a running Tilt instance via its HTTP/WebSocket API.
5
+ * No config file, no process management — just point at a port and query.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { TiltClient } from '@tilt-launcher/sdk';
10
+ *
11
+ * const tilt = new TiltClient(10350);
12
+ * const resources = await tilt.getResources();
13
+ * const failing = resources.filter(r => r.runtimeStatus === 'error');
14
+ * tilt.close();
15
+ * ```
16
+ */
17
+ import type { CachedResource } from './types.ts';
18
+ export interface TiltResource {
19
+ /** Resource name as defined in Tiltfile */
20
+ name: string;
21
+ /** Display label */
22
+ label: string;
23
+ /** Tilt category grouping */
24
+ category: string;
25
+ /** Resource type from spec (e.g. "local", "docker_compose") */
26
+ type: string;
27
+ /** Public endpoint URL if exposed */
28
+ endpoint?: string | undefined;
29
+ /** Port number extracted from endpoint */
30
+ port?: number | undefined;
31
+ /** Kind of resource: long-running service, one-shot command, or unknown */
32
+ resourceKind: 'serve' | 'cmd' | 'unknown';
33
+ /** Tilt runtime status: ok, pending, error, not_applicable, disabled */
34
+ runtimeStatus: string;
35
+ /** Whether the resource is disabled */
36
+ isDisabled: boolean;
37
+ /** Update status: ok, pending, error, not_applicable */
38
+ updateStatus?: string | undefined;
39
+ /** If waiting, why */
40
+ waitingReason?: string | undefined;
41
+ /** Resources this one is waiting on */
42
+ waitingOn?: string[] | undefined;
43
+ /** ISO timestamp of last deploy */
44
+ lastDeployTime?: string | undefined;
45
+ /** Duration of last build in seconds */
46
+ lastBuildDuration?: number | undefined;
47
+ /** Error message from last build, if any */
48
+ lastBuildError?: string | undefined;
49
+ /** Whether there are pending file changes */
50
+ hasPendingChanges?: boolean | undefined;
51
+ /** Trigger mode (0 = auto, 1 = manual) */
52
+ triggerMode?: number | undefined;
53
+ /** Whether a trigger is queued */
54
+ queued?: boolean | undefined;
55
+ /** PID of local resource process */
56
+ pid?: number | undefined;
57
+ /** Tilt conditions array */
58
+ conditions?: Array<{
59
+ type: string;
60
+ status: string;
61
+ lastTransitionTime?: string;
62
+ }> | undefined;
63
+ }
64
+ export interface TiltStatus {
65
+ /** All resources discovered in this Tilt instance */
66
+ resources: TiltResource[];
67
+ /** Resources currently in error state */
68
+ errors: TiltResource[];
69
+ /** Resources currently running OK */
70
+ healthy: TiltResource[];
71
+ /** Resources pending/building */
72
+ pending: TiltResource[];
73
+ /** Whether all non-disabled resources are healthy */
74
+ allHealthy: boolean;
75
+ }
76
+ export interface TiltWatchEvent {
77
+ /** Updated resource statuses */
78
+ resources: TiltResource[];
79
+ /** Log lines from this update (spanId-tagged) */
80
+ logs: Array<{
81
+ resourceName?: string | undefined;
82
+ text: string;
83
+ }>;
84
+ }
85
+ export type TiltWatchCallback = (event: TiltWatchEvent) => void;
86
+ export interface TiltClientOptions {
87
+ /** Hostname of the Tilt instance. Default: '127.0.0.1' */
88
+ host?: string;
89
+ /** Timeout for HTTP requests in ms. Default: 5000 */
90
+ timeoutMs?: number;
91
+ }
92
+ export declare class TiltClient {
93
+ private readonly baseUrl;
94
+ private readonly port;
95
+ private readonly timeoutMs;
96
+ private ws;
97
+ private watchCallback;
98
+ private reconnectTimer;
99
+ private closed;
100
+ constructor(port: number, options?: TiltClientOptions);
101
+ /** Check if Tilt is reachable at this port. */
102
+ isReachable(): Promise<boolean>;
103
+ /** Fetch all resources from this Tilt instance. */
104
+ getResources(): Promise<TiltResource[]>;
105
+ /** Fetch resources and return a structured status summary. */
106
+ getStatus(): Promise<TiltStatus>;
107
+ /** Get a single resource by name. */
108
+ getResource(name: string): Promise<TiltResource | null>;
109
+ /** Trigger a resource update (equivalent to pressing the trigger button in Tilt UI). */
110
+ triggerResource(name: string): Promise<void>;
111
+ /**
112
+ * Watch for resource updates via WebSocket.
113
+ * Returns an unsubscribe function.
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * const stop = await tilt.watch((event) => {
118
+ * for (const r of event.resources) {
119
+ * if (r.runtimeStatus === 'error') console.error(`${r.name} failed!`);
120
+ * }
121
+ * });
122
+ * // Later:
123
+ * stop();
124
+ * ```
125
+ */
126
+ watch(callback: TiltWatchCallback): Promise<() => void>;
127
+ /** Close all connections. */
128
+ close(): void;
129
+ private connectWS;
130
+ private disconnectWS;
131
+ private handleWSMessage;
132
+ }
133
+ /**
134
+ * One-shot query: get the current status of all resources from a Tilt instance.
135
+ *
136
+ * @example
137
+ * ```ts
138
+ * import { queryTilt } from '@tilt-launcher/sdk';
139
+ *
140
+ * const status = await queryTilt(10350);
141
+ * if (!status.allHealthy) {
142
+ * for (const err of status.errors) {
143
+ * console.error(`${err.name}: ${err.lastBuildError ?? err.runtimeStatus}`);
144
+ * }
145
+ * process.exit(1);
146
+ * }
147
+ * ```
148
+ */
149
+ export declare function queryTilt(port: number, options?: TiltClientOptions): Promise<TiltStatus>;
150
+ /**
151
+ * Stream resource updates from a Tilt instance.
152
+ * Returns an unsubscribe function.
153
+ *
154
+ * @example
155
+ * ```ts
156
+ * import { watchTilt } from '@tilt-launcher/sdk';
157
+ *
158
+ * const stop = await watchTilt(10350, (event) => {
159
+ * console.log(`${event.resources.length} resources updated`);
160
+ * });
161
+ *
162
+ * // Stop watching after 30s
163
+ * setTimeout(stop, 30_000);
164
+ * ```
165
+ */
166
+ export declare function watchTilt(port: number, callback: TiltWatchCallback, options?: TiltClientOptions): Promise<() => void>;
167
+ /** @internal Convert TiltResource to CachedResource for backward compatibility */
168
+ export declare function tiltResourceToCached(r: TiltResource): CachedResource;
@@ -0,0 +1,265 @@
1
+ // src/tiltClient.ts
2
+ function parseEndpoint(endpoint) {
3
+ if (!endpoint)
4
+ return {};
5
+ try {
6
+ const url = new URL(endpoint);
7
+ return {
8
+ port: Number(url.port || (url.protocol === "https:" ? 443 : 80)),
9
+ path: url.pathname || "/"
10
+ };
11
+ } catch {
12
+ return {};
13
+ }
14
+ }
15
+ function parseUIResource(item, tiltPort) {
16
+ const endpointUrl = item.status?.endpointLinks?.[0]?.url;
17
+ let endpoint;
18
+ if (endpointUrl) {
19
+ try {
20
+ endpoint = new URL(endpointUrl).toString();
21
+ } catch {
22
+ try {
23
+ endpoint = new URL(endpointUrl, `http://localhost:${tiltPort}`).toString();
24
+ } catch {
25
+ endpoint = undefined;
26
+ }
27
+ }
28
+ }
29
+ const parsed = parseEndpoint(endpoint);
30
+ const labels = item.metadata?.labels ? Object.keys(item.metadata.labels) : [];
31
+ const disableState = (item.status?.disableStatus?.state ?? "").toLowerCase();
32
+ const isDisabled = disableState === "disabled" || disableState === "pending";
33
+ const runtimeStatus = isDisabled ? "disabled" : item.status?.runtimeStatus ?? "unknown";
34
+ const resourceKind = runtimeStatus === "not_applicable" || runtimeStatus === "disabled" ? "cmd" : runtimeStatus === "ok" || runtimeStatus === "pending" || runtimeStatus === "error" ? "serve" : "unknown";
35
+ const buildHistory = item.status?.buildHistory;
36
+ const lastBuild = buildHistory?.[0];
37
+ const lastBuildDuration = lastBuild?.startTime && lastBuild?.finishTime ? (new Date(lastBuild.finishTime).getTime() - new Date(lastBuild.startTime).getTime()) / 1000 : undefined;
38
+ const waiting = item.status?.waiting;
39
+ const rawConditions = item.status?.conditions;
40
+ const conditions = rawConditions?.map((c) => ({
41
+ type: c.type ?? "",
42
+ status: c.status ?? "",
43
+ ...c.lastTransitionTime != null ? { lastTransitionTime: c.lastTransitionTime } : {}
44
+ }));
45
+ const rawPid = item.status?.localResourceInfo?.pid;
46
+ return {
47
+ name: item.metadata?.name ?? "unknown",
48
+ label: item.metadata?.name ?? "unknown",
49
+ category: labels[0] ?? "services",
50
+ type: item.status?.specs?.[0]?.type ?? "unknown",
51
+ endpoint,
52
+ port: parsed.port,
53
+ resourceKind,
54
+ runtimeStatus,
55
+ isDisabled,
56
+ updateStatus: item.status?.updateStatus,
57
+ waitingReason: waiting?.reason,
58
+ waitingOn: waiting?.on?.map((ref) => ref.name ?? "").filter(Boolean),
59
+ lastDeployTime: item.status?.lastDeployTime,
60
+ lastBuildDuration,
61
+ lastBuildError: lastBuild?.error,
62
+ hasPendingChanges: item.status?.hasPendingChanges,
63
+ triggerMode: item.status?.triggerMode,
64
+ queued: item.status?.queued,
65
+ pid: rawPid ? Number(rawPid) : undefined,
66
+ conditions
67
+ };
68
+ }
69
+ function buildStatus(resources) {
70
+ const errors = resources.filter((r) => r.runtimeStatus === "error");
71
+ const healthy = resources.filter((r) => r.runtimeStatus === "ok");
72
+ const pending = resources.filter((r) => r.runtimeStatus === "pending");
73
+ const active = resources.filter((r) => !r.isDisabled && r.runtimeStatus !== "not_applicable");
74
+ return {
75
+ resources,
76
+ errors,
77
+ healthy,
78
+ pending,
79
+ allHealthy: active.length > 0 && errors.length === 0 && pending.length === 0
80
+ };
81
+ }
82
+
83
+ class TiltClient {
84
+ baseUrl;
85
+ port;
86
+ timeoutMs;
87
+ ws = null;
88
+ watchCallback = null;
89
+ reconnectTimer = null;
90
+ closed = false;
91
+ constructor(port, options) {
92
+ const host = options?.host ?? "127.0.0.1";
93
+ this.port = port;
94
+ this.baseUrl = `http://${host}:${port}`;
95
+ this.timeoutMs = options?.timeoutMs ?? 5000;
96
+ }
97
+ async isReachable() {
98
+ try {
99
+ const resp = await fetch(`${this.baseUrl}/api/websocket_token`, {
100
+ signal: AbortSignal.timeout(this.timeoutMs)
101
+ });
102
+ return resp.ok;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+ async getResources() {
108
+ const resp = await fetch(`${this.baseUrl}/api/v1alpha1/uiresources`, {
109
+ signal: AbortSignal.timeout(this.timeoutMs)
110
+ });
111
+ if (!resp.ok) {
112
+ throw new Error(`Tilt API error: ${resp.status} ${resp.statusText}`);
113
+ }
114
+ const data = await resp.json();
115
+ return (data.items ?? []).filter((item) => item.metadata?.name && item.metadata.name !== "(Tiltfile)").map((item) => parseUIResource(item, this.port));
116
+ }
117
+ async getStatus() {
118
+ const resources = await this.getResources();
119
+ return buildStatus(resources);
120
+ }
121
+ async getResource(name) {
122
+ const resources = await this.getResources();
123
+ return resources.find((r) => r.name === name) ?? null;
124
+ }
125
+ async triggerResource(name) {
126
+ const resp = await fetch(`${this.baseUrl}/api/trigger`, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify({ manifest_names: [name], build_reason: 16 }),
130
+ signal: AbortSignal.timeout(this.timeoutMs)
131
+ });
132
+ if (!resp.ok) {
133
+ throw new Error(`Tilt trigger error: ${resp.status} ${resp.statusText}`);
134
+ }
135
+ }
136
+ async watch(callback) {
137
+ this.watchCallback = callback;
138
+ await this.connectWS();
139
+ return () => {
140
+ this.watchCallback = null;
141
+ this.disconnectWS();
142
+ };
143
+ }
144
+ close() {
145
+ this.closed = true;
146
+ this.watchCallback = null;
147
+ this.disconnectWS();
148
+ }
149
+ async connectWS() {
150
+ if (this.ws)
151
+ return;
152
+ const tokenResp = await fetch(`${this.baseUrl}/api/websocket_token`, {
153
+ signal: AbortSignal.timeout(this.timeoutMs)
154
+ });
155
+ if (!tokenResp.ok)
156
+ throw new Error("Could not get WebSocket token from Tilt");
157
+ const token = (await tokenResp.text()).trim();
158
+ const wsUrl = `ws://127.0.0.1:${this.port}/ws/view?token=${token}`;
159
+ const ws = new WebSocket(wsUrl);
160
+ ws.onmessage = (event) => {
161
+ try {
162
+ const data = JSON.parse(typeof event.data === "string" ? event.data : event.data.toString());
163
+ this.handleWSMessage(data);
164
+ } catch {}
165
+ };
166
+ ws.onerror = () => {};
167
+ ws.onclose = () => {
168
+ this.ws = null;
169
+ if (this.watchCallback && !this.closed) {
170
+ this.reconnectTimer = setTimeout(() => {
171
+ this.reconnectTimer = null;
172
+ this.connectWS();
173
+ }, 3000);
174
+ }
175
+ };
176
+ this.ws = ws;
177
+ }
178
+ disconnectWS() {
179
+ if (this.reconnectTimer) {
180
+ clearTimeout(this.reconnectTimer);
181
+ this.reconnectTimer = null;
182
+ }
183
+ if (this.ws) {
184
+ try {
185
+ this.ws.close();
186
+ } catch {}
187
+ this.ws = null;
188
+ }
189
+ }
190
+ handleWSMessage(data) {
191
+ if (!this.watchCallback)
192
+ return;
193
+ const resources = [];
194
+ const logs = [];
195
+ if (data.uiResources && Array.isArray(data.uiResources)) {
196
+ for (const item of data.uiResources) {
197
+ if (item.metadata?.name && item.metadata.name !== "(Tiltfile)") {
198
+ resources.push(parseUIResource(item, this.port));
199
+ }
200
+ }
201
+ }
202
+ if (data.logList?.segments && Array.isArray(data.logList.segments)) {
203
+ const spans = data.logList.spans;
204
+ for (const seg of data.logList.segments) {
205
+ const text = seg.text?.trimEnd();
206
+ if (!text)
207
+ continue;
208
+ const resourceName = seg.spanId && spans?.[seg.spanId]?.manifestName;
209
+ logs.push({
210
+ resourceName: resourceName && resourceName !== "(Tiltfile)" ? resourceName : undefined,
211
+ text
212
+ });
213
+ }
214
+ }
215
+ if (resources.length > 0 || logs.length > 0) {
216
+ this.watchCallback({ resources, logs });
217
+ }
218
+ }
219
+ }
220
+ async function queryTilt(port, options) {
221
+ const client = new TiltClient(port, options);
222
+ try {
223
+ return await client.getStatus();
224
+ } finally {
225
+ client.close();
226
+ }
227
+ }
228
+ async function watchTilt(port, callback, options) {
229
+ const client = new TiltClient(port, options);
230
+ const unsub = await client.watch(callback);
231
+ return () => {
232
+ unsub();
233
+ client.close();
234
+ };
235
+ }
236
+ function tiltResourceToCached(r) {
237
+ return {
238
+ name: r.name,
239
+ label: r.label,
240
+ category: r.category,
241
+ type: r.type,
242
+ endpoint: r.endpoint,
243
+ port: r.port,
244
+ runtimeStatus: r.runtimeStatus,
245
+ isDisabled: r.isDisabled,
246
+ resourceKind: r.resourceKind,
247
+ updateStatus: r.updateStatus,
248
+ waitingReason: r.waitingReason,
249
+ waitingOn: r.waitingOn,
250
+ lastDeployTime: r.lastDeployTime,
251
+ lastBuildDuration: r.lastBuildDuration,
252
+ lastBuildError: r.lastBuildError,
253
+ hasPendingChanges: r.hasPendingChanges,
254
+ triggerMode: r.triggerMode,
255
+ queued: r.queued,
256
+ pid: r.pid,
257
+ conditions: r.conditions
258
+ };
259
+ }
260
+ export {
261
+ watchTilt,
262
+ tiltResourceToCached,
263
+ queryTilt,
264
+ TiltClient
265
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tilt-launcher/sdk",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "SDK for managing Tilt dev environments — start, stop, monitor, and control resources",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -19,7 +19,7 @@
19
19
  "README.md"
20
20
  ],
21
21
  "scripts": {
22
- "build": "bun build src/index.ts src/types.ts src/bridge.ts src/tiltManagerSDK.ts --outdir dist --target node && tsc -p tsconfig.build.json",
22
+ "build": "bun build src/index.ts src/types.ts src/bridge.ts src/tiltManagerSDK.ts src/tiltClient.ts --outdir dist --target node && tsc -p tsconfig.build.json",
23
23
  "check": "tsc --noEmit -p tsconfig.json",
24
24
  "prepublishOnly": "bun run check && bun run build"
25
25
  },
package/src/index.ts CHANGED
@@ -1,3 +1,13 @@
1
1
  export * from './types.ts';
2
2
  export { type LauncherBridge } from './bridge.ts';
3
3
  export { TiltManagerSDK } from './tiltManagerSDK.ts';
4
+ export {
5
+ TiltClient,
6
+ queryTilt,
7
+ watchTilt,
8
+ type TiltResource,
9
+ type TiltStatus,
10
+ type TiltWatchEvent,
11
+ type TiltWatchCallback,
12
+ type TiltClientOptions,
13
+ } from './tiltClient.ts';
@@ -0,0 +1,474 @@
1
+ /**
2
+ * Standalone Tilt API client.
3
+ *
4
+ * Connects directly to a running Tilt instance via its HTTP/WebSocket API.
5
+ * No config file, no process management — just point at a port and query.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { TiltClient } from '@tilt-launcher/sdk';
10
+ *
11
+ * const tilt = new TiltClient(10350);
12
+ * const resources = await tilt.getResources();
13
+ * const failing = resources.filter(r => r.runtimeStatus === 'error');
14
+ * tilt.close();
15
+ * ```
16
+ */
17
+
18
+ import type { CachedResource } from './types.ts';
19
+
20
+ // ── Types ────────────────────────────────────────────────────────────────
21
+
22
+ export interface TiltResource {
23
+ /** Resource name as defined in Tiltfile */
24
+ name: string;
25
+ /** Display label */
26
+ label: string;
27
+ /** Tilt category grouping */
28
+ category: string;
29
+ /** Resource type from spec (e.g. "local", "docker_compose") */
30
+ type: string;
31
+ /** Public endpoint URL if exposed */
32
+ endpoint?: string | undefined;
33
+ /** Port number extracted from endpoint */
34
+ port?: number | undefined;
35
+ /** Kind of resource: long-running service, one-shot command, or unknown */
36
+ resourceKind: 'serve' | 'cmd' | 'unknown';
37
+ /** Tilt runtime status: ok, pending, error, not_applicable, disabled */
38
+ runtimeStatus: string;
39
+ /** Whether the resource is disabled */
40
+ isDisabled: boolean;
41
+ /** Update status: ok, pending, error, not_applicable */
42
+ updateStatus?: string | undefined;
43
+ /** If waiting, why */
44
+ waitingReason?: string | undefined;
45
+ /** Resources this one is waiting on */
46
+ waitingOn?: string[] | undefined;
47
+ /** ISO timestamp of last deploy */
48
+ lastDeployTime?: string | undefined;
49
+ /** Duration of last build in seconds */
50
+ lastBuildDuration?: number | undefined;
51
+ /** Error message from last build, if any */
52
+ lastBuildError?: string | undefined;
53
+ /** Whether there are pending file changes */
54
+ hasPendingChanges?: boolean | undefined;
55
+ /** Trigger mode (0 = auto, 1 = manual) */
56
+ triggerMode?: number | undefined;
57
+ /** Whether a trigger is queued */
58
+ queued?: boolean | undefined;
59
+ /** PID of local resource process */
60
+ pid?: number | undefined;
61
+ /** Tilt conditions array */
62
+ conditions?: Array<{ type: string; status: string; lastTransitionTime?: string }> | undefined;
63
+ }
64
+
65
+ export interface TiltStatus {
66
+ /** All resources discovered in this Tilt instance */
67
+ resources: TiltResource[];
68
+ /** Resources currently in error state */
69
+ errors: TiltResource[];
70
+ /** Resources currently running OK */
71
+ healthy: TiltResource[];
72
+ /** Resources pending/building */
73
+ pending: TiltResource[];
74
+ /** Whether all non-disabled resources are healthy */
75
+ allHealthy: boolean;
76
+ }
77
+
78
+ export interface TiltWatchEvent {
79
+ /** Updated resource statuses */
80
+ resources: TiltResource[];
81
+ /** Log lines from this update (spanId-tagged) */
82
+ logs: Array<{ resourceName?: string | undefined; text: string }>;
83
+ }
84
+
85
+ export type TiltWatchCallback = (event: TiltWatchEvent) => void;
86
+
87
+ // ── Internal helpers ─────────────────────────────────────────────────────
88
+
89
+ function parseEndpoint(endpoint?: string): { port?: number; path?: string } {
90
+ if (!endpoint) return {};
91
+ try {
92
+ const url = new URL(endpoint);
93
+ return {
94
+ port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)),
95
+ path: url.pathname || '/',
96
+ };
97
+ } catch {
98
+ return {};
99
+ }
100
+ }
101
+
102
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
103
+ function parseUIResource(item: any, tiltPort: number): TiltResource {
104
+ const endpointUrl = item.status?.endpointLinks?.[0]?.url as string | undefined;
105
+ let endpoint: string | undefined;
106
+ if (endpointUrl) {
107
+ try {
108
+ endpoint = new URL(endpointUrl).toString();
109
+ } catch {
110
+ try {
111
+ endpoint = new URL(endpointUrl, `http://localhost:${tiltPort}`).toString();
112
+ } catch {
113
+ endpoint = undefined;
114
+ }
115
+ }
116
+ }
117
+
118
+ const parsed = parseEndpoint(endpoint);
119
+ const labels = item.metadata?.labels ? Object.keys(item.metadata.labels) : [];
120
+ const disableState = (item.status?.disableStatus?.state ?? '').toLowerCase();
121
+ const isDisabled = disableState === 'disabled' || disableState === 'pending';
122
+ const runtimeStatus = isDisabled ? 'disabled' : (item.status?.runtimeStatus ?? 'unknown');
123
+ const resourceKind =
124
+ runtimeStatus === 'not_applicable' || runtimeStatus === 'disabled'
125
+ ? ('cmd' as const)
126
+ : runtimeStatus === 'ok' || runtimeStatus === 'pending' || runtimeStatus === 'error'
127
+ ? ('serve' as const)
128
+ : ('unknown' as const);
129
+
130
+ // Build history
131
+ const buildHistory = item.status?.buildHistory as
132
+ | Array<{ startTime?: string; finishTime?: string; error?: string }>
133
+ | undefined;
134
+ const lastBuild = buildHistory?.[0];
135
+ const lastBuildDuration =
136
+ lastBuild?.startTime && lastBuild?.finishTime
137
+ ? (new Date(lastBuild.finishTime).getTime() - new Date(lastBuild.startTime).getTime()) / 1000
138
+ : undefined;
139
+
140
+ // Waiting
141
+ const waiting = item.status?.waiting as { reason?: string; on?: Array<{ name?: string }> } | undefined;
142
+
143
+ // Conditions
144
+ const rawConditions = item.status?.conditions as
145
+ | Array<{ type?: string; status?: string; lastTransitionTime?: string }>
146
+ | undefined;
147
+ const conditions = rawConditions?.map((c) => ({
148
+ type: c.type ?? '',
149
+ status: c.status ?? '',
150
+ ...(c.lastTransitionTime != null ? { lastTransitionTime: c.lastTransitionTime } : {}),
151
+ }));
152
+
153
+ const rawPid = item.status?.localResourceInfo?.pid;
154
+
155
+ return {
156
+ name: item.metadata?.name ?? 'unknown',
157
+ label: item.metadata?.name ?? 'unknown',
158
+ category: labels[0] ?? 'services',
159
+ type: item.status?.specs?.[0]?.type ?? 'unknown',
160
+ endpoint,
161
+ port: parsed.port,
162
+ resourceKind,
163
+ runtimeStatus,
164
+ isDisabled,
165
+ updateStatus: item.status?.updateStatus,
166
+ waitingReason: waiting?.reason,
167
+ waitingOn: waiting?.on?.map((ref: { name?: string }) => ref.name ?? '').filter(Boolean),
168
+ lastDeployTime: item.status?.lastDeployTime,
169
+ lastBuildDuration,
170
+ lastBuildError: lastBuild?.error,
171
+ hasPendingChanges: item.status?.hasPendingChanges,
172
+ triggerMode: item.status?.triggerMode,
173
+ queued: item.status?.queued,
174
+ pid: rawPid ? Number(rawPid) : undefined,
175
+ conditions,
176
+ };
177
+ }
178
+
179
+ function buildStatus(resources: TiltResource[]): TiltStatus {
180
+ const errors = resources.filter((r) => r.runtimeStatus === 'error');
181
+ const healthy = resources.filter((r) => r.runtimeStatus === 'ok');
182
+ const pending = resources.filter((r) => r.runtimeStatus === 'pending');
183
+ const active = resources.filter((r) => !r.isDisabled && r.runtimeStatus !== 'not_applicable');
184
+ return {
185
+ resources,
186
+ errors,
187
+ healthy,
188
+ pending,
189
+ allHealthy: active.length > 0 && errors.length === 0 && pending.length === 0,
190
+ };
191
+ }
192
+
193
+ // ── TiltClient ───────────────────────────────────────────────────────────
194
+
195
+ export interface TiltClientOptions {
196
+ /** Hostname of the Tilt instance. Default: '127.0.0.1' */
197
+ host?: string;
198
+ /** Timeout for HTTP requests in ms. Default: 5000 */
199
+ timeoutMs?: number;
200
+ }
201
+
202
+ export class TiltClient {
203
+ private readonly baseUrl: string;
204
+ private readonly port: number;
205
+ private readonly timeoutMs: number;
206
+ private ws: WebSocket | null = null;
207
+ private watchCallback: TiltWatchCallback | null = null;
208
+ private reconnectTimer: NodeJS.Timeout | null = null;
209
+ private closed = false;
210
+
211
+ constructor(port: number, options?: TiltClientOptions) {
212
+ const host = options?.host ?? '127.0.0.1';
213
+ this.port = port;
214
+ this.baseUrl = `http://${host}:${port}`;
215
+ this.timeoutMs = options?.timeoutMs ?? 5000;
216
+ }
217
+
218
+ /** Check if Tilt is reachable at this port. */
219
+ async isReachable(): Promise<boolean> {
220
+ try {
221
+ const resp = await fetch(`${this.baseUrl}/api/websocket_token`, {
222
+ signal: AbortSignal.timeout(this.timeoutMs),
223
+ });
224
+ return resp.ok;
225
+ } catch {
226
+ return false;
227
+ }
228
+ }
229
+
230
+ /** Fetch all resources from this Tilt instance. */
231
+ async getResources(): Promise<TiltResource[]> {
232
+ const resp = await fetch(`${this.baseUrl}/api/v1alpha1/uiresources`, {
233
+ signal: AbortSignal.timeout(this.timeoutMs),
234
+ });
235
+ if (!resp.ok) {
236
+ throw new Error(`Tilt API error: ${resp.status} ${resp.statusText}`);
237
+ }
238
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
239
+ const data = (await resp.json()) as { items?: any[] };
240
+ return (data.items ?? [])
241
+ .filter((item: { metadata?: { name?: string } }) => item.metadata?.name && item.metadata.name !== '(Tiltfile)')
242
+ .map((item: unknown) => parseUIResource(item, this.port));
243
+ }
244
+
245
+ /** Fetch resources and return a structured status summary. */
246
+ async getStatus(): Promise<TiltStatus> {
247
+ const resources = await this.getResources();
248
+ return buildStatus(resources);
249
+ }
250
+
251
+ /** Get a single resource by name. */
252
+ async getResource(name: string): Promise<TiltResource | null> {
253
+ const resources = await this.getResources();
254
+ return resources.find((r) => r.name === name) ?? null;
255
+ }
256
+
257
+ /** Trigger a resource update (equivalent to pressing the trigger button in Tilt UI). */
258
+ async triggerResource(name: string): Promise<void> {
259
+ const resp = await fetch(`${this.baseUrl}/api/trigger`, {
260
+ method: 'POST',
261
+ headers: { 'Content-Type': 'application/json' },
262
+ body: JSON.stringify({ manifest_names: [name], build_reason: 16 /* manual */ }),
263
+ signal: AbortSignal.timeout(this.timeoutMs),
264
+ });
265
+ if (!resp.ok) {
266
+ throw new Error(`Tilt trigger error: ${resp.status} ${resp.statusText}`);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Watch for resource updates via WebSocket.
272
+ * Returns an unsubscribe function.
273
+ *
274
+ * @example
275
+ * ```ts
276
+ * const stop = await tilt.watch((event) => {
277
+ * for (const r of event.resources) {
278
+ * if (r.runtimeStatus === 'error') console.error(`${r.name} failed!`);
279
+ * }
280
+ * });
281
+ * // Later:
282
+ * stop();
283
+ * ```
284
+ */
285
+ async watch(callback: TiltWatchCallback): Promise<() => void> {
286
+ this.watchCallback = callback;
287
+ await this.connectWS();
288
+ return () => {
289
+ this.watchCallback = null;
290
+ this.disconnectWS();
291
+ };
292
+ }
293
+
294
+ /** Close all connections. */
295
+ close(): void {
296
+ this.closed = true;
297
+ this.watchCallback = null;
298
+ this.disconnectWS();
299
+ }
300
+
301
+ // ── WebSocket internals ──────────────────────────────────────────────
302
+
303
+ private async connectWS(): Promise<void> {
304
+ if (this.ws) return;
305
+
306
+ const tokenResp = await fetch(`${this.baseUrl}/api/websocket_token`, {
307
+ signal: AbortSignal.timeout(this.timeoutMs),
308
+ });
309
+ if (!tokenResp.ok) throw new Error('Could not get WebSocket token from Tilt');
310
+ const token = (await tokenResp.text()).trim();
311
+
312
+ const wsUrl = `ws://127.0.0.1:${this.port}/ws/view?token=${token}`;
313
+ const ws = new WebSocket(wsUrl);
314
+
315
+ ws.onmessage = (event: MessageEvent) => {
316
+ try {
317
+ const data = JSON.parse(typeof event.data === 'string' ? event.data : event.data.toString());
318
+ this.handleWSMessage(data);
319
+ } catch {
320
+ /* ignore malformed */
321
+ }
322
+ };
323
+
324
+ ws.onerror = () => {
325
+ /* triggers onclose */
326
+ };
327
+
328
+ ws.onclose = () => {
329
+ this.ws = null;
330
+ if (this.watchCallback && !this.closed) {
331
+ this.reconnectTimer = setTimeout(() => {
332
+ this.reconnectTimer = null;
333
+ void this.connectWS();
334
+ }, 3000);
335
+ }
336
+ };
337
+
338
+ this.ws = ws;
339
+ }
340
+
341
+ private disconnectWS(): void {
342
+ if (this.reconnectTimer) {
343
+ clearTimeout(this.reconnectTimer);
344
+ this.reconnectTimer = null;
345
+ }
346
+ if (this.ws) {
347
+ try {
348
+ this.ws.close();
349
+ } catch {
350
+ /* already closed */
351
+ }
352
+ this.ws = null;
353
+ }
354
+ }
355
+
356
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
357
+ private handleWSMessage(data: any): void {
358
+ if (!this.watchCallback) return;
359
+
360
+ const resources: TiltResource[] = [];
361
+ const logs: Array<{ resourceName?: string | undefined; text: string }> = [];
362
+
363
+ // Parse resources
364
+ if (data.uiResources && Array.isArray(data.uiResources)) {
365
+ for (const item of data.uiResources) {
366
+ if (item.metadata?.name && item.metadata.name !== '(Tiltfile)') {
367
+ resources.push(parseUIResource(item, this.port));
368
+ }
369
+ }
370
+ }
371
+
372
+ // Parse logs
373
+ if (data.logList?.segments && Array.isArray(data.logList.segments)) {
374
+ const spans = data.logList.spans as Record<string, { manifestName?: string }> | undefined;
375
+ for (const seg of data.logList.segments as Array<{ spanId?: string; text?: string }>) {
376
+ const text = seg.text?.trimEnd();
377
+ if (!text) continue;
378
+ const resourceName = seg.spanId && spans?.[seg.spanId]?.manifestName;
379
+ logs.push({
380
+ resourceName: resourceName && resourceName !== '(Tiltfile)' ? resourceName : undefined,
381
+ text,
382
+ });
383
+ }
384
+ }
385
+
386
+ if (resources.length > 0 || logs.length > 0) {
387
+ this.watchCallback({ resources, logs });
388
+ }
389
+ }
390
+ }
391
+
392
+ // ── Convenience functions ────────────────────────────────────────────────
393
+
394
+ /**
395
+ * One-shot query: get the current status of all resources from a Tilt instance.
396
+ *
397
+ * @example
398
+ * ```ts
399
+ * import { queryTilt } from '@tilt-launcher/sdk';
400
+ *
401
+ * const status = await queryTilt(10350);
402
+ * if (!status.allHealthy) {
403
+ * for (const err of status.errors) {
404
+ * console.error(`${err.name}: ${err.lastBuildError ?? err.runtimeStatus}`);
405
+ * }
406
+ * process.exit(1);
407
+ * }
408
+ * ```
409
+ */
410
+ export async function queryTilt(port: number, options?: TiltClientOptions): Promise<TiltStatus> {
411
+ const client = new TiltClient(port, options);
412
+ try {
413
+ return await client.getStatus();
414
+ } finally {
415
+ client.close();
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Stream resource updates from a Tilt instance.
421
+ * Returns an unsubscribe function.
422
+ *
423
+ * @example
424
+ * ```ts
425
+ * import { watchTilt } from '@tilt-launcher/sdk';
426
+ *
427
+ * const stop = await watchTilt(10350, (event) => {
428
+ * console.log(`${event.resources.length} resources updated`);
429
+ * });
430
+ *
431
+ * // Stop watching after 30s
432
+ * setTimeout(stop, 30_000);
433
+ * ```
434
+ */
435
+ export async function watchTilt(
436
+ port: number,
437
+ callback: TiltWatchCallback,
438
+ options?: TiltClientOptions,
439
+ ): Promise<() => void> {
440
+ const client = new TiltClient(port, options);
441
+ const unsub = await client.watch(callback);
442
+ return () => {
443
+ unsub();
444
+ client.close();
445
+ };
446
+ }
447
+
448
+ // ── Adapter: TiltResource → CachedResource (for TiltManagerSDK compat) ──
449
+
450
+ /** @internal Convert TiltResource to CachedResource for backward compatibility */
451
+ export function tiltResourceToCached(r: TiltResource): CachedResource {
452
+ return {
453
+ name: r.name,
454
+ label: r.label,
455
+ category: r.category,
456
+ type: r.type,
457
+ endpoint: r.endpoint,
458
+ port: r.port,
459
+ runtimeStatus: r.runtimeStatus,
460
+ isDisabled: r.isDisabled,
461
+ resourceKind: r.resourceKind,
462
+ updateStatus: r.updateStatus,
463
+ waitingReason: r.waitingReason,
464
+ waitingOn: r.waitingOn,
465
+ lastDeployTime: r.lastDeployTime,
466
+ lastBuildDuration: r.lastBuildDuration,
467
+ lastBuildError: r.lastBuildError,
468
+ hasPendingChanges: r.hasPendingChanges,
469
+ triggerMode: r.triggerMode,
470
+ queued: r.queued,
471
+ pid: r.pid,
472
+ conditions: r.conditions,
473
+ };
474
+ }