@tilt-launcher/sidecar 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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/client.d.ts +59 -0
- package/dist/client.js +150 -0
- package/dist/tilt-sidecar +0 -0
- package/package.json +58 -0
- package/src/client.ts +238 -0
- package/src/main.ts +442 -0
- package/src/rpc.ts +83 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Eric
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# @tilt-launcher/sidecar
|
|
2
|
+
|
|
3
|
+
Standalone sidecar process + typed client for the [Tilt Launcher SDK](../sdk). Communicates via JSON-RPC 2.0 over stdio — use it from any runtime or language.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @tilt-launcher/sidecar
|
|
9
|
+
# or
|
|
10
|
+
npm install @tilt-launcher/sidecar
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This installs both the **`tilt-sidecar` binary** and the **typed TypeScript client**. The SDK (`@tilt-launcher/sdk`) is included as a dependency — no need to install it separately.
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createSidecarClient } from '@tilt-launcher/sidecar';
|
|
19
|
+
|
|
20
|
+
const sidecar = createSidecarClient();
|
|
21
|
+
await sidecar.ready();
|
|
22
|
+
|
|
23
|
+
// Everything is typed — same API as the SDK
|
|
24
|
+
const config = await sidecar.getConfig();
|
|
25
|
+
const status = await sidecar.getStatus();
|
|
26
|
+
|
|
27
|
+
await sidecar.startEnv('my-app');
|
|
28
|
+
|
|
29
|
+
sidecar.onStatusUpdate((update) => {
|
|
30
|
+
console.log('Status changed:', update);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
sidecar.onLogDelta((delta) => {
|
|
34
|
+
console.log('New logs:', delta);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Clean up
|
|
38
|
+
sidecar.close();
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Why use the sidecar instead of the SDK directly?
|
|
42
|
+
|
|
43
|
+
| | SDK | Sidecar |
|
|
44
|
+
| ----------------- | ------------------------------------------------ | ----------------------------- |
|
|
45
|
+
| **Use when** | Your app is Bun/Node and you want direct control | You want a managed subprocess |
|
|
46
|
+
| **Process model** | In-process | Separate binary |
|
|
47
|
+
| **Language** | TypeScript only | Any (JSON-RPC over stdio) |
|
|
48
|
+
| **Lifecycle** | You manage polling and cleanup | Sidecar handles it |
|
|
49
|
+
| **Best for** | Libraries, tight integration | Apps, CLIs, multi-language |
|
|
50
|
+
|
|
51
|
+
## API
|
|
52
|
+
|
|
53
|
+
### `createSidecarClient(options?)`
|
|
54
|
+
|
|
55
|
+
Spawns the sidecar and returns a typed client.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const sidecar = createSidecarClient({
|
|
59
|
+
binPath: '/path/to/tilt-sidecar', // optional, auto-detected
|
|
60
|
+
env: { TILT_LAUNCHER_CONFIG: '/custom/config.json' }, // optional
|
|
61
|
+
readyTimeoutMs: 15000, // optional, default 10s
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Commands
|
|
66
|
+
|
|
67
|
+
| Method | Returns |
|
|
68
|
+
| ------------------------------ | ------------------------------------------------------ |
|
|
69
|
+
| `ready()` | `Promise<void>` — resolves when sidecar is initialized |
|
|
70
|
+
| `getConfig()` | `Promise<Config>` |
|
|
71
|
+
| `saveConfig(config)` | `Promise<Result>` |
|
|
72
|
+
| `getStatus()` | `Promise<StatusUpdate>` |
|
|
73
|
+
| `getLogs(envId)` | `Promise<{ envLogs, resourceLogs }>` |
|
|
74
|
+
| `startEnv(envId)` | `Promise<Result>` |
|
|
75
|
+
| `stopEnv(envId)` | `Promise<Result>` |
|
|
76
|
+
| `restartEnv(envId)` | `Promise<Result>` |
|
|
77
|
+
| `triggerResource(envId, name)` | `Promise<Result>` |
|
|
78
|
+
| `enableResource(envId, name)` | `Promise<Result>` |
|
|
79
|
+
| `disableResource(envId, name)` | `Promise<Result>` |
|
|
80
|
+
| `discoverResources(input)` | `Promise<DiscoverResult>` |
|
|
81
|
+
| `getHomeDir()` | `Promise<string>` |
|
|
82
|
+
| `classifyTiltfilePath(path)` | `Promise<PickedTiltfile>` |
|
|
83
|
+
| `readDir(dirPath)` | `Promise<ReadDirResult>` |
|
|
84
|
+
| `close()` | Kills the sidecar process |
|
|
85
|
+
|
|
86
|
+
### Events
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
sidecar.onStatusUpdate((update: StatusUpdate) => { ... });
|
|
90
|
+
sidecar.onLogDelta((delta: LogDelta) => { ... });
|
|
91
|
+
sidecar.onConfigUpdated((config: Config) => { ... });
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Types
|
|
95
|
+
|
|
96
|
+
All types are re-exported from `@tilt-launcher/sdk`:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import type { Config, StatusUpdate, LogDelta, ResourceRow } from '@tilt-launcher/sidecar';
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Using the binary directly
|
|
103
|
+
|
|
104
|
+
The sidecar binary can be used from any language via JSON-RPC 2.0 over stdin/stdout:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Spawn it
|
|
108
|
+
./node_modules/.bin/tilt-sidecar
|
|
109
|
+
|
|
110
|
+
# Send a request (newline-delimited JSON)
|
|
111
|
+
{"jsonrpc":"2.0","id":1,"method":"getStatus","params":{}}
|
|
112
|
+
|
|
113
|
+
# Receive a response
|
|
114
|
+
{"jsonrpc":"2.0","id":1,"result":{"envs":{}}}
|
|
115
|
+
|
|
116
|
+
# Receive push notifications (no id)
|
|
117
|
+
{"jsonrpc":"2.0","method":"statusUpdate","params":{...}}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Requirements
|
|
121
|
+
|
|
122
|
+
- `tilt` must be on `$PATH`
|
|
123
|
+
- macOS or Linux (no Windows support yet)
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed JSON-RPC client for the Tilt Launcher sidecar.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { createSidecarClient } from '@tilt-launcher/sidecar/client';
|
|
6
|
+
*
|
|
7
|
+
* const sidecar = createSidecarClient();
|
|
8
|
+
* await sidecar.ready();
|
|
9
|
+
*
|
|
10
|
+
* const config = await sidecar.getConfig();
|
|
11
|
+
* const status = await sidecar.getStatus();
|
|
12
|
+
* sidecar.onStatusUpdate((update) => console.log(update));
|
|
13
|
+
*
|
|
14
|
+
* sidecar.close();
|
|
15
|
+
*/
|
|
16
|
+
import type { Config, DiscoverResult, LogDelta, PickedTiltfile, ReadDirResult, StatusUpdate } from '@tilt-launcher/sdk';
|
|
17
|
+
export type { Config, DiscoverResult, LogDelta, PickedTiltfile, ReadDirResult, StatusUpdate };
|
|
18
|
+
type Result = {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
error?: string;
|
|
21
|
+
};
|
|
22
|
+
export interface SidecarClient {
|
|
23
|
+
/** Wait for the sidecar to emit its `ready` notification. */
|
|
24
|
+
ready(): Promise<void>;
|
|
25
|
+
getConfig(): Promise<Config>;
|
|
26
|
+
saveConfig(config: Config): Promise<Result>;
|
|
27
|
+
getStatus(): Promise<StatusUpdate>;
|
|
28
|
+
getLogs(envId: string): Promise<{
|
|
29
|
+
envLogs: string[];
|
|
30
|
+
resourceLogs: Record<string, string[]>;
|
|
31
|
+
}>;
|
|
32
|
+
startEnv(envId: string): Promise<Result>;
|
|
33
|
+
stopEnv(envId: string): Promise<Result>;
|
|
34
|
+
restartEnv(envId: string): Promise<Result>;
|
|
35
|
+
triggerResource(envId: string, resourceName: string): Promise<Result>;
|
|
36
|
+
enableResource(envId: string, resourceName: string): Promise<Result>;
|
|
37
|
+
disableResource(envId: string, resourceName: string): Promise<Result>;
|
|
38
|
+
discoverResources(input: {
|
|
39
|
+
tiltfilePath: string;
|
|
40
|
+
tiltPort: number;
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
}): Promise<DiscoverResult>;
|
|
43
|
+
getHomeDir(): Promise<string>;
|
|
44
|
+
classifyTiltfilePath(filePath: string): Promise<PickedTiltfile>;
|
|
45
|
+
readDir(dirPath: string): Promise<ReadDirResult>;
|
|
46
|
+
onStatusUpdate(listener: (update: StatusUpdate) => void): void;
|
|
47
|
+
onLogDelta(listener: (delta: LogDelta) => void): void;
|
|
48
|
+
onConfigUpdated(listener: (config: Config) => void): void;
|
|
49
|
+
close(): void;
|
|
50
|
+
}
|
|
51
|
+
export interface CreateSidecarOptions {
|
|
52
|
+
/** Path to the tilt-sidecar binary. Auto-detected if not provided. */
|
|
53
|
+
binPath?: string;
|
|
54
|
+
/** Environment variables to pass to the sidecar process. */
|
|
55
|
+
env?: Record<string, string>;
|
|
56
|
+
/** Timeout for the ready() call in ms. Default: 10000 */
|
|
57
|
+
readyTimeoutMs?: number;
|
|
58
|
+
}
|
|
59
|
+
export declare function createSidecarClient(options?: CreateSidecarOptions): SidecarClient;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
function findSidecarBin() {
|
|
6
|
+
const candidates = [
|
|
7
|
+
join(process.cwd(), "node_modules", ".bin", "tilt-sidecar"),
|
|
8
|
+
join(process.cwd(), "packages", "sidecar", "dist", "tilt-sidecar")
|
|
9
|
+
];
|
|
10
|
+
for (const c of candidates) {
|
|
11
|
+
if (existsSync(c))
|
|
12
|
+
return c;
|
|
13
|
+
}
|
|
14
|
+
return "tilt-sidecar";
|
|
15
|
+
}
|
|
16
|
+
function createSidecarClient(options) {
|
|
17
|
+
const bin = options?.binPath ?? findSidecarBin();
|
|
18
|
+
const readyTimeoutMs = options?.readyTimeoutMs ?? 1e4;
|
|
19
|
+
const proc = spawn(bin, [], {
|
|
20
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
21
|
+
env: { ...process.env, ...options?.env }
|
|
22
|
+
});
|
|
23
|
+
let nextId = 1;
|
|
24
|
+
const pending = new Map;
|
|
25
|
+
const handlers = {};
|
|
26
|
+
let readyResolve = null;
|
|
27
|
+
let readyReject = null;
|
|
28
|
+
const readyPromise = new Promise((resolve, reject) => {
|
|
29
|
+
readyResolve = resolve;
|
|
30
|
+
readyReject = reject;
|
|
31
|
+
});
|
|
32
|
+
const readyTimer = setTimeout(() => {
|
|
33
|
+
if (readyReject) {
|
|
34
|
+
readyReject(new Error("Sidecar did not become ready within timeout"));
|
|
35
|
+
readyReject = null;
|
|
36
|
+
}
|
|
37
|
+
}, readyTimeoutMs);
|
|
38
|
+
let buffer = "";
|
|
39
|
+
const stdout = proc.stdout;
|
|
40
|
+
if (!stdout)
|
|
41
|
+
throw new Error("Sidecar stdout not available");
|
|
42
|
+
stdout.setEncoding("utf-8");
|
|
43
|
+
stdout.on("data", (chunk) => {
|
|
44
|
+
buffer += chunk;
|
|
45
|
+
const lines = buffer.split(`
|
|
46
|
+
`);
|
|
47
|
+
buffer = lines.pop() ?? "";
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
if (!line.trim())
|
|
50
|
+
continue;
|
|
51
|
+
try {
|
|
52
|
+
const msg = JSON.parse(line);
|
|
53
|
+
if ("id" in msg) {
|
|
54
|
+
const waiter = pending.get(msg.id);
|
|
55
|
+
if (waiter) {
|
|
56
|
+
pending.delete(msg.id);
|
|
57
|
+
if (msg.error) {
|
|
58
|
+
waiter.reject(new Error(msg.error.message));
|
|
59
|
+
} else {
|
|
60
|
+
waiter.resolve(msg.result);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} else if ("method" in msg) {
|
|
64
|
+
const params = msg.params;
|
|
65
|
+
switch (msg.method) {
|
|
66
|
+
case "ready":
|
|
67
|
+
clearTimeout(readyTimer);
|
|
68
|
+
if (readyResolve) {
|
|
69
|
+
readyResolve();
|
|
70
|
+
readyResolve = null;
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
case "statusUpdate":
|
|
74
|
+
handlers.onStatusUpdate?.(params);
|
|
75
|
+
break;
|
|
76
|
+
case "logDelta":
|
|
77
|
+
handlers.onLogDelta?.(params);
|
|
78
|
+
break;
|
|
79
|
+
case "configUpdated":
|
|
80
|
+
handlers.onConfigUpdated?.(params);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
function rpc(method, params) {
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const id = nextId++;
|
|
90
|
+
pending.set(id, { resolve, reject });
|
|
91
|
+
const line = JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} });
|
|
92
|
+
const stdin = proc.stdin;
|
|
93
|
+
if (!stdin) {
|
|
94
|
+
pending.delete(id);
|
|
95
|
+
reject(new Error("Sidecar stdin not available"));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
stdin.write(line + `
|
|
99
|
+
`);
|
|
100
|
+
setTimeout(() => {
|
|
101
|
+
if (pending.has(id)) {
|
|
102
|
+
pending.delete(id);
|
|
103
|
+
reject(new Error(`RPC timeout: ${method}`));
|
|
104
|
+
}
|
|
105
|
+
}, 60000);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
ready: () => readyPromise,
|
|
110
|
+
getConfig: () => rpc("getConfig"),
|
|
111
|
+
saveConfig: (config) => rpc("saveConfig", { config }),
|
|
112
|
+
getStatus: () => rpc("getStatus"),
|
|
113
|
+
getLogs: (envId) => rpc("getLogs", { envId }),
|
|
114
|
+
startEnv: (envId) => rpc("startEnv", { envId }),
|
|
115
|
+
stopEnv: (envId) => rpc("stopEnv", { envId }),
|
|
116
|
+
restartEnv: (envId) => rpc("restartEnv", { envId }),
|
|
117
|
+
triggerResource: (envId, resourceName) => rpc("triggerResource", { envId, resourceName }),
|
|
118
|
+
enableResource: (envId, resourceName) => rpc("enableResource", { envId, resourceName }),
|
|
119
|
+
disableResource: (envId, resourceName) => rpc("disableResource", { envId, resourceName }),
|
|
120
|
+
discoverResources: (input) => {
|
|
121
|
+
const params = {
|
|
122
|
+
tiltfilePath: input.tiltfilePath,
|
|
123
|
+
tiltPort: input.tiltPort
|
|
124
|
+
};
|
|
125
|
+
if (input.timeoutMs != null)
|
|
126
|
+
params.timeoutMs = input.timeoutMs;
|
|
127
|
+
return rpc("discoverResources", params);
|
|
128
|
+
},
|
|
129
|
+
getHomeDir: () => rpc("getHomeDir"),
|
|
130
|
+
classifyTiltfilePath: (filePath) => rpc("classifyTiltfilePath", { filePath }),
|
|
131
|
+
readDir: (dirPath) => rpc("readDir", { dirPath }),
|
|
132
|
+
onStatusUpdate: (listener) => {
|
|
133
|
+
handlers.onStatusUpdate = listener;
|
|
134
|
+
},
|
|
135
|
+
onLogDelta: (listener) => {
|
|
136
|
+
handlers.onLogDelta = listener;
|
|
137
|
+
},
|
|
138
|
+
onConfigUpdated: (listener) => {
|
|
139
|
+
handlers.onConfigUpdated = listener;
|
|
140
|
+
},
|
|
141
|
+
close: () => {
|
|
142
|
+
clearTimeout(readyTimer);
|
|
143
|
+
proc.stdin?.end();
|
|
144
|
+
proc.kill("SIGTERM");
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export {
|
|
149
|
+
createSidecarClient
|
|
150
|
+
};
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tilt-launcher/sidecar",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Standalone sidecar binary + typed client for the Tilt Launcher SDK — JSON-RPC 2.0 over stdio",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/client.ts",
|
|
7
|
+
"types": "src/client.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"tilt-sidecar": "dist/tilt-sidecar"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"bun": "./src/client.ts",
|
|
14
|
+
"import": "./dist/client.js",
|
|
15
|
+
"types": "./dist/client.d.ts"
|
|
16
|
+
},
|
|
17
|
+
"./client": {
|
|
18
|
+
"bun": "./src/client.ts",
|
|
19
|
+
"import": "./dist/client.js",
|
|
20
|
+
"types": "./dist/client.d.ts"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"dist",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"dev": "bun run src/main.ts",
|
|
31
|
+
"build": "bun build src/main.ts --compile --outfile dist/tilt-sidecar && bun build src/client.ts --outdir dist --target node && tsc -p tsconfig.build.json",
|
|
32
|
+
"check": "tsc --noEmit -p tsconfig.json",
|
|
33
|
+
"prepublishOnly": "bun run check && bun run build"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@tilt-launcher/sdk": "^1.2.0"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"tilt",
|
|
40
|
+
"tiltfile",
|
|
41
|
+
"sidecar",
|
|
42
|
+
"json-rpc",
|
|
43
|
+
"launcher"
|
|
44
|
+
],
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "https://github.com/m4ttheweric/tilt-launcher",
|
|
49
|
+
"directory": "packages/sidecar"
|
|
50
|
+
},
|
|
51
|
+
"os": [
|
|
52
|
+
"darwin",
|
|
53
|
+
"linux"
|
|
54
|
+
],
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=18"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed JSON-RPC client for the Tilt Launcher sidecar.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { createSidecarClient } from '@tilt-launcher/sidecar/client';
|
|
6
|
+
*
|
|
7
|
+
* const sidecar = createSidecarClient();
|
|
8
|
+
* await sidecar.ready();
|
|
9
|
+
*
|
|
10
|
+
* const config = await sidecar.getConfig();
|
|
11
|
+
* const status = await sidecar.getStatus();
|
|
12
|
+
* sidecar.onStatusUpdate((update) => console.log(update));
|
|
13
|
+
*
|
|
14
|
+
* sidecar.close();
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import type { Config, DiscoverResult, LogDelta, PickedTiltfile, ReadDirResult, StatusUpdate } from '@tilt-launcher/sdk';
|
|
21
|
+
|
|
22
|
+
export type { Config, DiscoverResult, LogDelta, PickedTiltfile, ReadDirResult, StatusUpdate };
|
|
23
|
+
|
|
24
|
+
type Result = { ok: boolean; error?: string };
|
|
25
|
+
|
|
26
|
+
interface SidecarNotificationHandlers {
|
|
27
|
+
onStatusUpdate?: (update: StatusUpdate) => void;
|
|
28
|
+
onLogDelta?: (delta: LogDelta) => void;
|
|
29
|
+
onConfigUpdated?: (config: Config) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SidecarClient {
|
|
33
|
+
/** Wait for the sidecar to emit its `ready` notification. */
|
|
34
|
+
ready(): Promise<void>;
|
|
35
|
+
|
|
36
|
+
// ── Config ──
|
|
37
|
+
getConfig(): Promise<Config>;
|
|
38
|
+
saveConfig(config: Config): Promise<Result>;
|
|
39
|
+
|
|
40
|
+
// ── Status & Logs ──
|
|
41
|
+
getStatus(): Promise<StatusUpdate>;
|
|
42
|
+
getLogs(envId: string): Promise<{ envLogs: string[]; resourceLogs: Record<string, string[]> }>;
|
|
43
|
+
|
|
44
|
+
// ── Lifecycle ──
|
|
45
|
+
startEnv(envId: string): Promise<Result>;
|
|
46
|
+
stopEnv(envId: string): Promise<Result>;
|
|
47
|
+
restartEnv(envId: string): Promise<Result>;
|
|
48
|
+
|
|
49
|
+
// ── Resource control ──
|
|
50
|
+
triggerResource(envId: string, resourceName: string): Promise<Result>;
|
|
51
|
+
enableResource(envId: string, resourceName: string): Promise<Result>;
|
|
52
|
+
disableResource(envId: string, resourceName: string): Promise<Result>;
|
|
53
|
+
|
|
54
|
+
// ── Discovery ──
|
|
55
|
+
discoverResources(input: { tiltfilePath: string; tiltPort: number; timeoutMs?: number }): Promise<DiscoverResult>;
|
|
56
|
+
|
|
57
|
+
// ── Filesystem ──
|
|
58
|
+
getHomeDir(): Promise<string>;
|
|
59
|
+
classifyTiltfilePath(filePath: string): Promise<PickedTiltfile>;
|
|
60
|
+
readDir(dirPath: string): Promise<ReadDirResult>;
|
|
61
|
+
|
|
62
|
+
// ── Events ──
|
|
63
|
+
onStatusUpdate(listener: (update: StatusUpdate) => void): void;
|
|
64
|
+
onLogDelta(listener: (delta: LogDelta) => void): void;
|
|
65
|
+
onConfigUpdated(listener: (config: Config) => void): void;
|
|
66
|
+
|
|
67
|
+
// ── Lifecycle ──
|
|
68
|
+
close(): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CreateSidecarOptions {
|
|
72
|
+
/** Path to the tilt-sidecar binary. Auto-detected if not provided. */
|
|
73
|
+
binPath?: string;
|
|
74
|
+
/** Environment variables to pass to the sidecar process. */
|
|
75
|
+
env?: Record<string, string>;
|
|
76
|
+
/** Timeout for the ready() call in ms. Default: 10000 */
|
|
77
|
+
readyTimeoutMs?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function findSidecarBin(): string {
|
|
81
|
+
// Check if installed as npm dependency (bin is linked)
|
|
82
|
+
const candidates = [
|
|
83
|
+
// Installed via npm — bin is in node_modules/.bin/
|
|
84
|
+
join(process.cwd(), 'node_modules', '.bin', 'tilt-sidecar'),
|
|
85
|
+
// Monorepo — workspace build
|
|
86
|
+
join(process.cwd(), 'packages', 'sidecar', 'dist', 'tilt-sidecar'),
|
|
87
|
+
];
|
|
88
|
+
for (const c of candidates) {
|
|
89
|
+
if (existsSync(c)) return c;
|
|
90
|
+
}
|
|
91
|
+
// Fallback: assume it's on PATH
|
|
92
|
+
return 'tilt-sidecar';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createSidecarClient(options?: CreateSidecarOptions): SidecarClient {
|
|
96
|
+
const bin = options?.binPath ?? findSidecarBin();
|
|
97
|
+
const readyTimeoutMs = options?.readyTimeoutMs ?? 10_000;
|
|
98
|
+
|
|
99
|
+
const proc: ChildProcess = spawn(bin, [], {
|
|
100
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
101
|
+
env: { ...process.env, ...options?.env },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
let nextId = 1;
|
|
105
|
+
const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
|
106
|
+
const handlers: SidecarNotificationHandlers = {};
|
|
107
|
+
let readyResolve: (() => void) | null = null;
|
|
108
|
+
let readyReject: ((e: Error) => void) | null = null;
|
|
109
|
+
const readyPromise = new Promise<void>((resolve, reject) => {
|
|
110
|
+
readyResolve = resolve;
|
|
111
|
+
readyReject = reject;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const readyTimer = setTimeout(() => {
|
|
115
|
+
if (readyReject) {
|
|
116
|
+
readyReject(new Error('Sidecar did not become ready within timeout'));
|
|
117
|
+
readyReject = null;
|
|
118
|
+
}
|
|
119
|
+
}, readyTimeoutMs);
|
|
120
|
+
|
|
121
|
+
// Read stdout
|
|
122
|
+
let buffer = '';
|
|
123
|
+
const stdout = proc.stdout;
|
|
124
|
+
if (!stdout) throw new Error('Sidecar stdout not available');
|
|
125
|
+
stdout.setEncoding('utf-8');
|
|
126
|
+
stdout.on('data', (chunk: string) => {
|
|
127
|
+
buffer += chunk;
|
|
128
|
+
const lines = buffer.split('\n');
|
|
129
|
+
buffer = lines.pop() ?? '';
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
if (!line.trim()) continue;
|
|
132
|
+
try {
|
|
133
|
+
const msg = JSON.parse(line);
|
|
134
|
+
if ('id' in msg) {
|
|
135
|
+
const waiter = pending.get(msg.id);
|
|
136
|
+
if (waiter) {
|
|
137
|
+
pending.delete(msg.id);
|
|
138
|
+
if (msg.error) {
|
|
139
|
+
waiter.reject(new Error(msg.error.message));
|
|
140
|
+
} else {
|
|
141
|
+
waiter.resolve(msg.result);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} else if ('method' in msg) {
|
|
145
|
+
const params = msg.params;
|
|
146
|
+
switch (msg.method) {
|
|
147
|
+
case 'ready':
|
|
148
|
+
clearTimeout(readyTimer);
|
|
149
|
+
if (readyResolve) {
|
|
150
|
+
readyResolve();
|
|
151
|
+
readyResolve = null;
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
case 'statusUpdate':
|
|
155
|
+
handlers.onStatusUpdate?.(params as StatusUpdate);
|
|
156
|
+
break;
|
|
157
|
+
case 'logDelta':
|
|
158
|
+
handlers.onLogDelta?.(params as LogDelta);
|
|
159
|
+
break;
|
|
160
|
+
case 'configUpdated':
|
|
161
|
+
handlers.onConfigUpdated?.(params as Config);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
/* malformed */
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
function rpc<T>(method: string, params?: Record<string, unknown>): Promise<T> {
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
const id = nextId++;
|
|
174
|
+
pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
|
|
175
|
+
const line = JSON.stringify({ jsonrpc: '2.0', id, method, params: params ?? {} });
|
|
176
|
+
const stdin = proc.stdin;
|
|
177
|
+
if (!stdin) {
|
|
178
|
+
pending.delete(id);
|
|
179
|
+
reject(new Error('Sidecar stdin not available'));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
stdin.write(line + '\n');
|
|
183
|
+
setTimeout(() => {
|
|
184
|
+
if (pending.has(id)) {
|
|
185
|
+
pending.delete(id);
|
|
186
|
+
reject(new Error(`RPC timeout: ${method}`));
|
|
187
|
+
}
|
|
188
|
+
}, 60_000);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
ready: () => readyPromise,
|
|
194
|
+
|
|
195
|
+
getConfig: () => rpc('getConfig'),
|
|
196
|
+
saveConfig: (config) => rpc('saveConfig', { config }),
|
|
197
|
+
|
|
198
|
+
getStatus: () => rpc('getStatus'),
|
|
199
|
+
getLogs: (envId) => rpc('getLogs', { envId }),
|
|
200
|
+
|
|
201
|
+
startEnv: (envId) => rpc('startEnv', { envId }),
|
|
202
|
+
stopEnv: (envId) => rpc('stopEnv', { envId }),
|
|
203
|
+
restartEnv: (envId) => rpc('restartEnv', { envId }),
|
|
204
|
+
|
|
205
|
+
triggerResource: (envId, resourceName) => rpc('triggerResource', { envId, resourceName }),
|
|
206
|
+
enableResource: (envId, resourceName) => rpc('enableResource', { envId, resourceName }),
|
|
207
|
+
disableResource: (envId, resourceName) => rpc('disableResource', { envId, resourceName }),
|
|
208
|
+
|
|
209
|
+
discoverResources: (input) => {
|
|
210
|
+
const params: Record<string, unknown> = {
|
|
211
|
+
tiltfilePath: input.tiltfilePath,
|
|
212
|
+
tiltPort: input.tiltPort,
|
|
213
|
+
};
|
|
214
|
+
if (input.timeoutMs != null) params.timeoutMs = input.timeoutMs;
|
|
215
|
+
return rpc('discoverResources', params);
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
getHomeDir: () => rpc('getHomeDir'),
|
|
219
|
+
classifyTiltfilePath: (filePath) => rpc('classifyTiltfilePath', { filePath }),
|
|
220
|
+
readDir: (dirPath) => rpc('readDir', { dirPath }),
|
|
221
|
+
|
|
222
|
+
onStatusUpdate: (listener) => {
|
|
223
|
+
handlers.onStatusUpdate = listener;
|
|
224
|
+
},
|
|
225
|
+
onLogDelta: (listener) => {
|
|
226
|
+
handlers.onLogDelta = listener;
|
|
227
|
+
},
|
|
228
|
+
onConfigUpdated: (listener) => {
|
|
229
|
+
handlers.onConfigUpdated = listener;
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
close: () => {
|
|
233
|
+
clearTimeout(readyTimer);
|
|
234
|
+
proc.stdin?.end();
|
|
235
|
+
proc.kill('SIGTERM');
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Tilt Launcher Sidecar — JSON-RPC server over stdin/stdout.
|
|
4
|
+
*
|
|
5
|
+
* Wraps TiltManagerSDK and exposes every SDK feature as a JSON-RPC method.
|
|
6
|
+
* Push notifications (statusUpdate, logDelta, configUpdated) are emitted
|
|
7
|
+
* as JSON-RPC notifications on stdout.
|
|
8
|
+
*
|
|
9
|
+
* Protocol: newline-delimited JSON-RPC 2.0.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { lstatSync, readdirSync, realpathSync, statSync, readlinkSync } from 'node:fs';
|
|
14
|
+
import { basename, dirname, join, relative } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { TiltManagerSDK } from '@tilt-launcher/sdk';
|
|
17
|
+
import type { Config, StatusUpdate, LogDelta, PickedTiltfile, DirEntry } from '@tilt-launcher/sdk';
|
|
18
|
+
import {
|
|
19
|
+
parseRequest,
|
|
20
|
+
successResponse,
|
|
21
|
+
errorResponse,
|
|
22
|
+
notification,
|
|
23
|
+
RPC_METHOD_NOT_FOUND,
|
|
24
|
+
RPC_INTERNAL_ERROR,
|
|
25
|
+
} from './rpc.ts';
|
|
26
|
+
|
|
27
|
+
// ── Path helpers ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** Expand leading ~ to the user's home directory */
|
|
30
|
+
function expandHome(p: string): string {
|
|
31
|
+
if (p === '~') return homedir();
|
|
32
|
+
if (p.startsWith('~/')) return join(homedir(), p.slice(2));
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Config persistence ────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const CONFIG_DIR = join(homedir(), '.config', 'tilt-launcher');
|
|
39
|
+
const CONFIG_PATH = process.env.TILT_LAUNCHER_CONFIG || join(CONFIG_DIR, 'config.json');
|
|
40
|
+
|
|
41
|
+
function slugify(value: string): string {
|
|
42
|
+
return value
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.trim()
|
|
45
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
46
|
+
.replace(/^-+|-+$/g, '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeConfig(raw: Config): Config {
|
|
50
|
+
const used = new Set<string>();
|
|
51
|
+
const environments = (raw.environments ?? []).map((env, idx) => {
|
|
52
|
+
const id = env.id && env.id.length > 0 ? env.id : slugify(env.name || `env-${idx + 1}`) || `env-${idx + 1}`;
|
|
53
|
+
let unique = id;
|
|
54
|
+
let suffix = 2;
|
|
55
|
+
while (used.has(unique)) {
|
|
56
|
+
unique = `${id}-${suffix++}`;
|
|
57
|
+
}
|
|
58
|
+
used.add(unique);
|
|
59
|
+
return {
|
|
60
|
+
...env,
|
|
61
|
+
id: unique,
|
|
62
|
+
external: env.external ?? false,
|
|
63
|
+
description: env.description ?? '',
|
|
64
|
+
selectedResources: env.selectedResources ?? [],
|
|
65
|
+
cachedResources: env.cachedResources ?? [],
|
|
66
|
+
serviceMapping: env.serviceMapping,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
return { themeMode: raw.themeMode ?? 'system', environments };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Validate that no two environments share the same tiltPort. */
|
|
73
|
+
function ensureUniquePorts(cfg: Config): string | null {
|
|
74
|
+
const seen = new Map<number, string>();
|
|
75
|
+
for (const env of cfg.environments) {
|
|
76
|
+
const owner = seen.get(env.tiltPort);
|
|
77
|
+
if (owner) return `Port ${env.tiltPort} is used by both ${owner} and ${env.name}.`;
|
|
78
|
+
seen.set(env.tiltPort, env.name);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function loadConfig(): Config {
|
|
84
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
85
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
86
|
+
const fallback: Config = { environments: [] };
|
|
87
|
+
const normalized = normalizeConfig(fallback);
|
|
88
|
+
writeConfig(normalized);
|
|
89
|
+
return normalized;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) as Config;
|
|
93
|
+
return normalizeConfig(parsed);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
const backupPath = `${CONFIG_PATH}.bak`;
|
|
96
|
+
console.error(`Failed to parse config, backing up to ${backupPath}:`, e);
|
|
97
|
+
try {
|
|
98
|
+
renameSync(CONFIG_PATH, backupPath);
|
|
99
|
+
} catch {
|
|
100
|
+
/* backup failed */
|
|
101
|
+
}
|
|
102
|
+
const fallback: Config = { environments: [] };
|
|
103
|
+
const normalized = normalizeConfig(fallback);
|
|
104
|
+
writeConfig(normalized);
|
|
105
|
+
return normalized;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function writeConfig(config: Config): void {
|
|
110
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
111
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── SDK instance ──────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
let config: Config = loadConfig();
|
|
117
|
+
let configSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
118
|
+
|
|
119
|
+
const sdk = new TiltManagerSDK(config, {
|
|
120
|
+
onStatusUpdate: (update: StatusUpdate) => {
|
|
121
|
+
emit('statusUpdate', update);
|
|
122
|
+
},
|
|
123
|
+
onLogDelta: (delta: LogDelta) => {
|
|
124
|
+
emit('logDelta', delta);
|
|
125
|
+
},
|
|
126
|
+
onConfigMutated: (mutated: Config) => {
|
|
127
|
+
config = mutated;
|
|
128
|
+
if (configSaveTimer) clearTimeout(configSaveTimer);
|
|
129
|
+
configSaveTimer = setTimeout(() => {
|
|
130
|
+
writeConfig(config);
|
|
131
|
+
emit('configUpdated', config);
|
|
132
|
+
}, 5000);
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
sdk.startPolling(5000);
|
|
137
|
+
|
|
138
|
+
// ── Output ────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
function emit(method: string, params: unknown): void {
|
|
141
|
+
const line = notification(method, params);
|
|
142
|
+
process.stdout.write(line + '\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function respond(line: string): void {
|
|
146
|
+
process.stdout.write(line + '\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Filesystem helpers ────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* macOS NSOpenPanel resolves POSIX symlinks before returning paths, so
|
|
153
|
+
* the file path is often the real file rather than the symlink the user
|
|
154
|
+
* navigated to. Given the real path, scan common workspace roots for a
|
|
155
|
+
* symlink whose realpath matches, then remap the path through it.
|
|
156
|
+
*
|
|
157
|
+
* Ported from Electron shell's findSymlinkFor().
|
|
158
|
+
*/
|
|
159
|
+
function findSymlinkFor(realFilePath: string): string | null {
|
|
160
|
+
const realDir = dirname(realFilePath);
|
|
161
|
+
const filename = basename(realFilePath);
|
|
162
|
+
|
|
163
|
+
const scanRoots = [
|
|
164
|
+
dirname(realDir), // siblings of the real directory
|
|
165
|
+
join(homedir(), 'Documents', 'GitHub'),
|
|
166
|
+
join(homedir(), 'Documents', 'Projects'),
|
|
167
|
+
join(homedir(), 'repos'),
|
|
168
|
+
join(homedir(), 'projects'),
|
|
169
|
+
join(homedir(), 'src'),
|
|
170
|
+
join(homedir(), 'dev'),
|
|
171
|
+
join(homedir(), 'code'),
|
|
172
|
+
join(homedir(), 'workspace'),
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
for (const scanRoot of scanRoots) {
|
|
176
|
+
if (!existsSync(scanRoot)) continue;
|
|
177
|
+
try {
|
|
178
|
+
for (const entry of readdirSync(scanRoot)) {
|
|
179
|
+
const entryPath = join(scanRoot, entry);
|
|
180
|
+
try {
|
|
181
|
+
const entryStat = lstatSync(entryPath);
|
|
182
|
+
if (!entryStat.isSymbolicLink()) continue;
|
|
183
|
+
const entryReal = realpathSync(entryPath);
|
|
184
|
+
// Directory symlink whose target is realDir → remap file path through it
|
|
185
|
+
if (entryReal === realDir) return join(entryPath, filename);
|
|
186
|
+
// File symlink pointing directly to our file
|
|
187
|
+
if (entryReal === realFilePath) return entryPath;
|
|
188
|
+
// Symlink whose target is an ancestor of realDir → remap deeper path
|
|
189
|
+
const rel = relative(entryReal, realFilePath);
|
|
190
|
+
if (!rel.startsWith('..')) return join(entryPath, rel);
|
|
191
|
+
} catch {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Classify a Tiltfile path — detect symlinks with reverse-mapping support.
|
|
204
|
+
* Matches Electron shell's classifyTiltfilePath() behavior.
|
|
205
|
+
*/
|
|
206
|
+
function classifyTiltfilePath(filePath: string): PickedTiltfile {
|
|
207
|
+
const resolved = expandHome(filePath);
|
|
208
|
+
|
|
209
|
+
// Case 1: path is itself a symlink
|
|
210
|
+
try {
|
|
211
|
+
if (lstatSync(resolved).isSymbolicLink()) {
|
|
212
|
+
return { path: resolved, isSymlink: true, realPath: realpathSync(resolved) };
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
/* ignore */
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Case 2: macOS resolved the symlink — try to reverse-map back to the symlink
|
|
219
|
+
const symlinkPath = findSymlinkFor(resolved);
|
|
220
|
+
if (symlinkPath) {
|
|
221
|
+
return { path: symlinkPath, isSymlink: true, realPath: resolved };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { path: resolved, isSymlink: false };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Read directory contents. Matches Electron shell behavior:
|
|
229
|
+
* - Filters out dotfiles (entries starting with '.')
|
|
230
|
+
* - Sorts directories before files, then alphabetical
|
|
231
|
+
* - Resolves symlink targets
|
|
232
|
+
*/
|
|
233
|
+
function readDir(dirPath: string): { ok: boolean; path: string; entries: DirEntry[]; error?: string } {
|
|
234
|
+
try {
|
|
235
|
+
const resolved = expandHome(dirPath);
|
|
236
|
+
const rawNames = readdirSync(resolved);
|
|
237
|
+
const entries: DirEntry[] = [];
|
|
238
|
+
|
|
239
|
+
for (const name of rawNames) {
|
|
240
|
+
if (name.startsWith('.')) continue; // Filter dotfiles
|
|
241
|
+
|
|
242
|
+
const fullPath = join(resolved, name);
|
|
243
|
+
try {
|
|
244
|
+
const lstats = lstatSync(fullPath);
|
|
245
|
+
const isSymlink = lstats.isSymbolicLink();
|
|
246
|
+
let isDirectory = lstats.isDirectory();
|
|
247
|
+
let isFile = lstats.isFile();
|
|
248
|
+
let symlinkTarget: string | undefined;
|
|
249
|
+
let realPath: string | undefined;
|
|
250
|
+
|
|
251
|
+
if (isSymlink) {
|
|
252
|
+
try {
|
|
253
|
+
symlinkTarget = readlinkSync(fullPath);
|
|
254
|
+
} catch {
|
|
255
|
+
/* ignore */
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
realPath = realpathSync(fullPath);
|
|
259
|
+
const resolvedStat = statSync(fullPath); // follows the symlink
|
|
260
|
+
isDirectory = resolvedStat.isDirectory();
|
|
261
|
+
isFile = resolvedStat.isFile();
|
|
262
|
+
} catch {
|
|
263
|
+
isFile = true; /* broken symlink */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
entries.push({ name, isDirectory, isFile, isSymlink, symlinkTarget, realPath });
|
|
268
|
+
} catch {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Sort: directories first, then alphabetical
|
|
274
|
+
entries.sort((a, b) => {
|
|
275
|
+
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
|
276
|
+
return a.name.localeCompare(b.name);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return { ok: true, path: resolved, entries };
|
|
280
|
+
} catch (e) {
|
|
281
|
+
return { ok: false, path: dirPath, entries: [], error: e instanceof Error ? e.message : String(e) };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Method dispatch ───────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
type Handler = (params: Record<string, unknown>) => unknown | Promise<unknown>;
|
|
288
|
+
|
|
289
|
+
const methods: Record<string, Handler> = {
|
|
290
|
+
// Config
|
|
291
|
+
getConfig: () => config,
|
|
292
|
+
|
|
293
|
+
saveConfig: (params) => {
|
|
294
|
+
const next = params.config as Config;
|
|
295
|
+
const normalized = normalizeConfig(next);
|
|
296
|
+
|
|
297
|
+
// Validate unique ports (matches Electron behavior)
|
|
298
|
+
const conflict = ensureUniquePorts(normalized);
|
|
299
|
+
if (conflict) return { ok: false, error: conflict };
|
|
300
|
+
|
|
301
|
+
config = normalized;
|
|
302
|
+
sdk.setConfig(config);
|
|
303
|
+
writeConfig(config);
|
|
304
|
+
emit('configUpdated', config);
|
|
305
|
+
return { ok: true };
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
// Status & Logs
|
|
309
|
+
getStatus: () => sdk.currentStatusUpdate(),
|
|
310
|
+
|
|
311
|
+
getLogs: (params) => {
|
|
312
|
+
const envId = params.envId as string;
|
|
313
|
+
return sdk.getEnvLogs(envId);
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
// Lifecycle
|
|
317
|
+
startEnv: (params) => {
|
|
318
|
+
const envId = params.envId as string;
|
|
319
|
+
return sdk.startEnv(envId);
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
stopEnv: (params) => {
|
|
323
|
+
const envId = params.envId as string;
|
|
324
|
+
return sdk.stopEnv(envId);
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
restartEnv: (params) => {
|
|
328
|
+
const envId = params.envId as string;
|
|
329
|
+
return sdk.restartEnv(envId);
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
// Resource control
|
|
333
|
+
triggerResource: async (params) => {
|
|
334
|
+
const envId = params.envId as string;
|
|
335
|
+
const resourceName = params.resourceName as string;
|
|
336
|
+
return sdk.triggerResource(envId, resourceName);
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
enableResource: async (params) => {
|
|
340
|
+
const envId = params.envId as string;
|
|
341
|
+
const resourceName = params.resourceName as string;
|
|
342
|
+
return sdk.enableResource(envId, resourceName);
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
disableResource: async (params) => {
|
|
346
|
+
const envId = params.envId as string;
|
|
347
|
+
const resourceName = params.resourceName as string;
|
|
348
|
+
return sdk.disableResource(envId, resourceName);
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
// Discovery
|
|
352
|
+
discoverResources: async (params) => {
|
|
353
|
+
const input: { tiltfilePath: string; tiltPort: number; timeoutMs?: number } = {
|
|
354
|
+
tiltfilePath: params.tiltfilePath as string,
|
|
355
|
+
tiltPort: params.tiltPort as number,
|
|
356
|
+
};
|
|
357
|
+
if (params.timeoutMs != null) input.timeoutMs = params.timeoutMs as number;
|
|
358
|
+
return sdk.discoverResources(input);
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
// Filesystem
|
|
362
|
+
classifyTiltfilePath: (params) => {
|
|
363
|
+
return classifyTiltfilePath(params.filePath as string);
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
getHomeDir: () => homedir(),
|
|
367
|
+
|
|
368
|
+
readDir: (params) => {
|
|
369
|
+
return readDir(params.dirPath as string);
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
// Login item (stub — platform-specific, handled by shells directly)
|
|
373
|
+
getLoginItem: () => ({ openAtLogin: false }),
|
|
374
|
+
setLoginItem: () => ({ ok: true }),
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// ── Request handler ───────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
async function handleLine(line: string): Promise<void> {
|
|
380
|
+
const trimmed = line.trim();
|
|
381
|
+
if (!trimmed) return;
|
|
382
|
+
|
|
383
|
+
const parsed = parseRequest(trimmed);
|
|
384
|
+
|
|
385
|
+
// If parseRequest returned an error response, send it
|
|
386
|
+
if ('error' in parsed && parsed.error) {
|
|
387
|
+
respond(JSON.stringify(parsed));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const request = parsed as { id: number | string; method: string; params: Record<string, unknown> };
|
|
392
|
+
const handler = methods[request.method];
|
|
393
|
+
|
|
394
|
+
if (!handler) {
|
|
395
|
+
respond(errorResponse(request.id, RPC_METHOD_NOT_FOUND, `Method not found: ${request.method}`));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const result = await handler(request.params ?? {});
|
|
401
|
+
respond(successResponse(request.id, result));
|
|
402
|
+
} catch (e) {
|
|
403
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
404
|
+
respond(errorResponse(request.id, RPC_INTERNAL_ERROR, message));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ── stdin reader ──────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
let buffer = '';
|
|
411
|
+
|
|
412
|
+
process.stdin.setEncoding('utf-8');
|
|
413
|
+
process.stdin.on('data', (chunk: string) => {
|
|
414
|
+
buffer += chunk;
|
|
415
|
+
const lines = buffer.split('\n');
|
|
416
|
+
buffer = lines.pop() ?? '';
|
|
417
|
+
for (const line of lines) {
|
|
418
|
+
void handleLine(line);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
process.stdin.on('end', () => {
|
|
423
|
+
// stdin closed — clean shutdown.
|
|
424
|
+
// Match Electron behavior: do NOT stop running Tilt environments.
|
|
425
|
+
// Quitting the launcher should leave Tilt processes running.
|
|
426
|
+
sdk.stopPolling();
|
|
427
|
+
process.exit(0);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Handle signals gracefully — same policy: don't kill Tilt processes.
|
|
431
|
+
process.on('SIGTERM', () => {
|
|
432
|
+
sdk.stopPolling();
|
|
433
|
+
process.exit(0);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
process.on('SIGINT', () => {
|
|
437
|
+
sdk.stopPolling();
|
|
438
|
+
process.exit(0);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// Signal that sidecar is ready
|
|
442
|
+
emit('ready', { version: '1.2.0' });
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-RPC 2.0 protocol handler for the sidecar.
|
|
3
|
+
*
|
|
4
|
+
* Handles: parsing requests from stdin, serializing responses to stdout,
|
|
5
|
+
* and emitting push notifications.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── Types ─────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface JsonRpcRequest {
|
|
11
|
+
jsonrpc: '2.0';
|
|
12
|
+
id: number | string;
|
|
13
|
+
method: string;
|
|
14
|
+
params?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface JsonRpcResponse {
|
|
18
|
+
jsonrpc: '2.0';
|
|
19
|
+
id: number | string;
|
|
20
|
+
result?: unknown;
|
|
21
|
+
error?: { code: number; message: string; data?: unknown };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface JsonRpcNotification {
|
|
25
|
+
jsonrpc: '2.0';
|
|
26
|
+
method: string;
|
|
27
|
+
params?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Standard JSON-RPC error codes
|
|
31
|
+
export const RPC_PARSE_ERROR = -32700;
|
|
32
|
+
export const RPC_INVALID_REQUEST = -32600;
|
|
33
|
+
export const RPC_METHOD_NOT_FOUND = -32601;
|
|
34
|
+
export const RPC_INTERNAL_ERROR = -32603;
|
|
35
|
+
|
|
36
|
+
// ── Parsing ───────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export function parseRequest(line: string): JsonRpcRequest | JsonRpcResponse {
|
|
39
|
+
let parsed: unknown;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(line);
|
|
42
|
+
} catch {
|
|
43
|
+
return {
|
|
44
|
+
jsonrpc: '2.0',
|
|
45
|
+
id: 0,
|
|
46
|
+
error: { code: RPC_PARSE_ERROR, message: 'Parse error: invalid JSON' },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const obj = parsed as Record<string, unknown>;
|
|
51
|
+
|
|
52
|
+
if (obj.jsonrpc !== '2.0' || typeof obj.method !== 'string' || obj.id == null) {
|
|
53
|
+
return {
|
|
54
|
+
jsonrpc: '2.0',
|
|
55
|
+
id: (obj.id as number | string) ?? 0,
|
|
56
|
+
error: { code: RPC_INVALID_REQUEST, message: 'Invalid Request: missing jsonrpc, method, or id' },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
jsonrpc: '2.0',
|
|
62
|
+
id: obj.id as number | string,
|
|
63
|
+
method: obj.method as string,
|
|
64
|
+
params: (obj.params as Record<string, unknown>) ?? {},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Serialization ─────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export function successResponse(id: number | string, result: unknown): string {
|
|
71
|
+
const resp: JsonRpcResponse = { jsonrpc: '2.0', id, result };
|
|
72
|
+
return JSON.stringify(resp);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function errorResponse(id: number | string, code: number, message: string, data?: unknown): string {
|
|
76
|
+
const resp: JsonRpcResponse = { jsonrpc: '2.0', id, error: { code, message, data } };
|
|
77
|
+
return JSON.stringify(resp);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function notification(method: string, params: unknown): string {
|
|
81
|
+
const notif: JsonRpcNotification = { jsonrpc: '2.0', method, params };
|
|
82
|
+
return JSON.stringify(notif);
|
|
83
|
+
}
|