sidebranch 0.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/CHANGELOG.md +83 -0
- package/LICENSE +28 -0
- package/README.md +368 -0
- package/SECURITY.md +161 -0
- package/bin/sidebranch.js +12 -0
- package/package.json +49 -0
- package/src/assets/boot-tag.js +20 -0
- package/src/assets/geist-pixel.LICENSE.txt +133 -0
- package/src/assets/geist-pixel.woff2 +0 -0
- package/src/assets/shell.html +930 -0
- package/src/assets/widget-core.js +898 -0
- package/src/cli.js +342 -0
- package/src/config.js +120 -0
- package/src/daemon.js +323 -0
- package/src/daemonfile.js +156 -0
- package/src/gitops.js +264 -0
- package/src/install.js +115 -0
- package/src/manager.js +260 -0
- package/src/processes.js +250 -0
- package/src/proxy.js +136 -0
- package/src/security.js +123 -0
package/src/daemon.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon.js — the sidecar HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Invariants (not configurable):
|
|
5
|
+
* - Binds 127.0.0.1 only.
|
|
6
|
+
* - Rejects any request whose peer socket is not loopback.
|
|
7
|
+
* - Rejects any request whose Host header is not loopback (DNS rebinding).
|
|
8
|
+
* - Rejects any request whose Origin is present and not loopback.
|
|
9
|
+
* - Every /api/* request requires the session bearer token.
|
|
10
|
+
* - Serves only embedded assets; no filesystem paths are derived from URLs.
|
|
11
|
+
* - /handshake and the three assets are unauthenticated by necessity: they
|
|
12
|
+
* bootstrap the token. Everything under /api/* requires it.
|
|
13
|
+
* - Responds with strict security headers; the shell page carries a CSP.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import http from "node:http";
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
generateToken, tokenMatches,
|
|
23
|
+
isLoopbackAddress, isAllowedHostHeader, isAllowedOrigin,
|
|
24
|
+
isValidPort,
|
|
25
|
+
} from "./security.js";
|
|
26
|
+
|
|
27
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const ASSETS = path.join(__dirname, "assets");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The /api/* contract version, reported by GET /handshake.
|
|
32
|
+
*
|
|
33
|
+
* The npm package and the browser extension update on different clocks and
|
|
34
|
+
* will drift. Bump this ONLY when a change would break an older widget —
|
|
35
|
+
* adding a field to a response is not a break, changing or removing one is.
|
|
36
|
+
*/
|
|
37
|
+
export const API_VERSION = 1;
|
|
38
|
+
|
|
39
|
+
const VERSION = JSON.parse(
|
|
40
|
+
await fs.readFile(new URL("../package.json", import.meta.url), "utf8")
|
|
41
|
+
).version;
|
|
42
|
+
|
|
43
|
+
export class Daemon {
|
|
44
|
+
constructor({ manager, port = 49400 }) {
|
|
45
|
+
this.manager = manager;
|
|
46
|
+
this.port = port;
|
|
47
|
+
// The shell frames panes from this origin, so panes are probed against it.
|
|
48
|
+
this.manager.daemonPort = port;
|
|
49
|
+
this.token = generateToken();
|
|
50
|
+
this.sseClients = new Set();
|
|
51
|
+
this.server = null;
|
|
52
|
+
|
|
53
|
+
this.manager.on("event", (ev) => this.broadcast(ev));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async start() {
|
|
57
|
+
// The widget ships as core + a boot that supplies credentials; the tag
|
|
58
|
+
// channel's boot is the one with placeholders in it. Concatenated here
|
|
59
|
+
// rather than at request time so a malformed asset fails at startup.
|
|
60
|
+
const [core, bootTag] = await Promise.all([
|
|
61
|
+
fs.readFile(path.join(ASSETS, "widget-core.js"), "utf8"),
|
|
62
|
+
fs.readFile(path.join(ASSETS, "boot-tag.js"), "utf8"),
|
|
63
|
+
]);
|
|
64
|
+
this.widgetSrc = `${core}\n${bootTag}`;
|
|
65
|
+
this.shellSrc = await fs.readFile(path.join(ASSETS, "shell.html"), "utf8");
|
|
66
|
+
this.fontSrc = await fs.readFile(path.join(ASSETS, "geist-pixel.woff2"));
|
|
67
|
+
|
|
68
|
+
this.server = http.createServer((req, res) => {
|
|
69
|
+
this.handle(req, res).catch((err) => {
|
|
70
|
+
if (!res.headersSent) json(res, 500, { error: err.message, code: err.code ?? "EINTERNAL" });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
// Refuse to ever listen on a non-loopback interface.
|
|
74
|
+
await new Promise((resolve, reject) => {
|
|
75
|
+
this.server.once("error", reject);
|
|
76
|
+
this.server.listen(this.port, "127.0.0.1", resolve);
|
|
77
|
+
});
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async stop() {
|
|
82
|
+
for (const res of this.sseClients) res.end();
|
|
83
|
+
await new Promise((r) => this.server?.close(r));
|
|
84
|
+
await this.manager.shutdown();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
broadcast(ev) {
|
|
88
|
+
const line = `data: ${JSON.stringify(ev)}\n\n`;
|
|
89
|
+
for (const res of this.sseClients) res.write(line);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ------------------------------ gatekeeping ----------------------------- */
|
|
93
|
+
|
|
94
|
+
gate(req, res) {
|
|
95
|
+
const peer = req.socket.remoteAddress;
|
|
96
|
+
if (!isLoopbackAddress(peer)) {
|
|
97
|
+
res.writeHead(403).end();
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
if (!isAllowedHostHeader(req.headers.host)) {
|
|
101
|
+
json(res, 403, { error: "Host not allowed", code: "EHOST" });
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const origin = req.headers.origin;
|
|
105
|
+
if (!isAllowedOrigin(origin)) {
|
|
106
|
+
json(res, 403, { error: "Origin not allowed", code: "EORIGIN" });
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
// CORS: only ever reflect loopback origins. Everyone else gets nothing.
|
|
110
|
+
if (origin) {
|
|
111
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
112
|
+
res.setHeader("Vary", "Origin");
|
|
113
|
+
res.setHeader("Access-Control-Allow-Headers", "authorization, content-type");
|
|
114
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
115
|
+
res.setHeader("Access-Control-Max-Age", "600");
|
|
116
|
+
}
|
|
117
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
118
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
119
|
+
res.setHeader("Cross-Origin-Resource-Policy", "same-site");
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
authed(req) {
|
|
124
|
+
const h = req.headers.authorization || "";
|
|
125
|
+
const m = /^Bearer\s+([a-f0-9]{64})$/i.exec(h);
|
|
126
|
+
return m ? tokenMatches(this.token, m[1]) : false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* -------------------------------- routing ------------------------------- */
|
|
130
|
+
|
|
131
|
+
async handle(req, res) {
|
|
132
|
+
if (!this.gate(req, res)) return;
|
|
133
|
+
if (req.method === "OPTIONS") return res.writeHead(204).end();
|
|
134
|
+
|
|
135
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
136
|
+
const route = `${req.method} ${url.pathname}`;
|
|
137
|
+
|
|
138
|
+
// -- Assets (no token needed to fetch; tokens are *delivered* by these,
|
|
139
|
+
// and delivery is safe because the gate above already guarantees a
|
|
140
|
+
// loopback peer, loopback Host, and loopback-or-absent Origin). A
|
|
141
|
+
// cross-origin remote page cannot read these responses: fetch() is
|
|
142
|
+
// blocked by CORS above, and <script src> keeps the token inside the
|
|
143
|
+
// widget's closure where the embedding page cannot reach it.
|
|
144
|
+
if (route === "GET /widget.js") return this.serveWidget(res);
|
|
145
|
+
if (route === "GET /shell") return this.serveShell(res);
|
|
146
|
+
// Unauthenticated for the same reason as the two above, plus a harder
|
|
147
|
+
// constraint: a browser's @font-face fetch can't carry an Authorization
|
|
148
|
+
// header at all, so this could never be gated by the bearer token even
|
|
149
|
+
// if we wanted it to be. Unlike widget.js/shell (no-store — they embed a
|
|
150
|
+
// token that rotates every run), this file never changes for a given
|
|
151
|
+
// version of the tool, so it's cached aggressively.
|
|
152
|
+
if (route === "GET /geist-pixel.woff2") return this.serveFont(res);
|
|
153
|
+
if (route === "GET /handshake") return this.serveHandshake(res);
|
|
154
|
+
if (route === "GET /healthz") return json(res, 200, { ok: true });
|
|
155
|
+
|
|
156
|
+
if (!url.pathname.startsWith("/api/")) return json(res, 404, { error: "Not found" });
|
|
157
|
+
if (!this.authed(req)) return json(res, 401, { error: "Missing or invalid token", code: "EAUTH" });
|
|
158
|
+
|
|
159
|
+
if (route === "GET /api/state") return json(res, 200, await this.manager.state());
|
|
160
|
+
if (route === "GET /api/events") return this.serveEvents(req, res);
|
|
161
|
+
const logMatch = req.method === "GET" && /^\/api\/pane\/([^/]+)\/log$/.exec(url.pathname);
|
|
162
|
+
if (logMatch) {
|
|
163
|
+
const info = this.manager.getPaneLog(logMatch[1]);
|
|
164
|
+
if (!info) return json(res, 404, { error: "Unknown pane", code: "EBADPANE" });
|
|
165
|
+
return json(res, 200, info);
|
|
166
|
+
}
|
|
167
|
+
if (route === "POST /api/fetch") {
|
|
168
|
+
await this.manager.fetch();
|
|
169
|
+
return json(res, 200, { ok: true });
|
|
170
|
+
}
|
|
171
|
+
if (route === "POST /api/pane") {
|
|
172
|
+
const body = await readJson(req);
|
|
173
|
+
const { pane, branch, discard } = body ?? {};
|
|
174
|
+
try {
|
|
175
|
+
const info = await this.manager.ensurePane(String(pane), String(branch), { discard: discard === true });
|
|
176
|
+
return json(res, 200, info);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
const status = err.code === "EBADREF" || err.code === "EBADPANE" ? 400
|
|
179
|
+
: err.code === "EDIRTY" || err.code === "EBUSYTREE" ? 409 : 500;
|
|
180
|
+
return json(res, status, { error: err.message, code: err.code ?? "EINTERNAL" });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (route === "POST /api/pane/stop") {
|
|
184
|
+
const { pane } = (await readJson(req)) ?? {};
|
|
185
|
+
await this.manager.stopPane(String(pane));
|
|
186
|
+
return json(res, 200, { ok: true });
|
|
187
|
+
}
|
|
188
|
+
if (route === "POST /api/pane/destroy") {
|
|
189
|
+
const { pane } = (await readJson(req)) ?? {};
|
|
190
|
+
await this.manager.destroyPane(String(pane));
|
|
191
|
+
return json(res, 200, { ok: true });
|
|
192
|
+
}
|
|
193
|
+
return json(res, 404, { error: "Not found" });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
serveWidget(res) {
|
|
197
|
+
res.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
198
|
+
res.setHeader("Cache-Control", "no-store");
|
|
199
|
+
if (!this.manager.config.widget) {
|
|
200
|
+
return res.end("/* sidebranch widget disabled via .sidebranch.json */\n");
|
|
201
|
+
}
|
|
202
|
+
const src = this.widgetSrc
|
|
203
|
+
.replaceAll("__SIDEBRANCH_TOKEN__", this.token)
|
|
204
|
+
.replaceAll("__SIDEBRANCH_PORT__", String(this.port));
|
|
205
|
+
res.end(src);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Credential bootstrap for callers that cannot consume a rendered template
|
|
210
|
+
* — i.e. the browser extension, whose content script ships `widget-core.js`
|
|
211
|
+
* in its own package because Manifest V3 forbids executing remotely-fetched
|
|
212
|
+
* code, and so has nowhere for a substituted token to arrive.
|
|
213
|
+
*
|
|
214
|
+
* This discloses nothing that `GET /widget.js` does not already disclose:
|
|
215
|
+
* any caller that clears the gate above can read the token straight out of
|
|
216
|
+
* the widget response body today. SECURITY.md's "token delivery is
|
|
217
|
+
* unauthenticated by necessity" covers both; this is the same disclosure
|
|
218
|
+
* with an honest shape instead of a string-substituted one.
|
|
219
|
+
*
|
|
220
|
+
* Deliberately NOT under /api/*, because the bearer check there is exactly
|
|
221
|
+
* what this endpoint exists to bootstrap.
|
|
222
|
+
*/
|
|
223
|
+
serveHandshake(res) {
|
|
224
|
+
res.setHeader("Cache-Control", "no-store");
|
|
225
|
+
json(res, 200, {
|
|
226
|
+
token: this.token,
|
|
227
|
+
port: this.port,
|
|
228
|
+
// Honors `"widget": false` in .sidebranch.json, which the tag channel
|
|
229
|
+
// honors by serving a no-op body. The extension has to be told.
|
|
230
|
+
widget: this.manager.config.widget === true,
|
|
231
|
+
version: VERSION,
|
|
232
|
+
// Bumped only on a breaking change to the shapes below /api/*. The
|
|
233
|
+
// extension refuses to render on a mismatch rather than half-working
|
|
234
|
+
// against a daemon it doesn't understand.
|
|
235
|
+
apiVersion: API_VERSION,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
serveShell(res) {
|
|
240
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
241
|
+
res.setHeader("Cache-Control", "no-store");
|
|
242
|
+
// The shell embeds iframes of pane dev servers (loopback http origins)
|
|
243
|
+
// and talks only to this daemon. CSP pins both facts.
|
|
244
|
+
res.setHeader(
|
|
245
|
+
"Content-Security-Policy",
|
|
246
|
+
[
|
|
247
|
+
"default-src 'none'",
|
|
248
|
+
"script-src 'unsafe-inline'",
|
|
249
|
+
"style-src 'unsafe-inline'",
|
|
250
|
+
"connect-src http://localhost:* http://127.0.0.1:*",
|
|
251
|
+
"frame-src http://localhost:* http://127.0.0.1:*",
|
|
252
|
+
"font-src http://localhost:* http://127.0.0.1:*",
|
|
253
|
+
"img-src data:",
|
|
254
|
+
"base-uri 'none'",
|
|
255
|
+
"form-action 'none'",
|
|
256
|
+
].join("; ")
|
|
257
|
+
);
|
|
258
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
259
|
+
const src = this.shellSrc
|
|
260
|
+
.replaceAll("__SIDEBRANCH_TOKEN__", this.token)
|
|
261
|
+
.replaceAll("__SIDEBRANCH_PORT__", String(this.port));
|
|
262
|
+
res.end(src);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
serveFont(res) {
|
|
266
|
+
res.setHeader("Content-Type", "font/woff2");
|
|
267
|
+
// No token, no per-run state in this file — safe to cache hard, unlike
|
|
268
|
+
// widget.js/shell above.
|
|
269
|
+
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
270
|
+
res.end(this.fontSrc);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
serveEvents(req, res) {
|
|
274
|
+
res.writeHead(200, {
|
|
275
|
+
"Content-Type": "text/event-stream",
|
|
276
|
+
"Cache-Control": "no-store",
|
|
277
|
+
Connection: "keep-alive",
|
|
278
|
+
});
|
|
279
|
+
res.write(`data: ${JSON.stringify({ type: "hello", at: Date.now() })}\n\n`);
|
|
280
|
+
this.sseClients.add(res);
|
|
281
|
+
const ping = setInterval(() => res.write(": ping\n\n"), 20_000);
|
|
282
|
+
req.on("close", () => {
|
|
283
|
+
clearInterval(ping);
|
|
284
|
+
this.sseClients.delete(res);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* --------------------------------- helpers -------------------------------- */
|
|
290
|
+
|
|
291
|
+
function json(res, status, obj) {
|
|
292
|
+
const body = JSON.stringify(obj);
|
|
293
|
+
res.writeHead(status, {
|
|
294
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
295
|
+
"Content-Length": Buffer.byteLength(body),
|
|
296
|
+
"Cache-Control": "no-store",
|
|
297
|
+
});
|
|
298
|
+
res.end(body);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function readJson(req, limit = 64 * 1024) {
|
|
302
|
+
return new Promise((resolve, reject) => {
|
|
303
|
+
let size = 0;
|
|
304
|
+
const chunks = [];
|
|
305
|
+
req.on("data", (c) => {
|
|
306
|
+
size += c.length;
|
|
307
|
+
if (size > limit) {
|
|
308
|
+
reject(Object.assign(new Error("Body too large"), { code: "EBODY" }));
|
|
309
|
+
req.destroy();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
chunks.push(c);
|
|
313
|
+
});
|
|
314
|
+
req.on("end", () => {
|
|
315
|
+
if (chunks.length === 0) return resolve(null);
|
|
316
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
|
|
317
|
+
catch { reject(Object.assign(new Error("Invalid JSON body"), { code: "EBODY" })); }
|
|
318
|
+
});
|
|
319
|
+
req.on("error", reject);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export { isValidPort };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemonfile.js — the record of "is a daemon running for this repo?".
|
|
3
|
+
*
|
|
4
|
+
* Until this existed, nothing tracked live daemons: `clean` could remove the
|
|
5
|
+
* worktree out from under a running dev server, `start` could launch a second
|
|
6
|
+
* daemon fighting the first over the same panes, and there was no `stop` at
|
|
7
|
+
* all. One small JSON file per project answers all three.
|
|
8
|
+
*
|
|
9
|
+
* The file lives beside the panes it describes, in `projectDataDir(repo)`, so
|
|
10
|
+
* it is scoped per repo exactly like the worktrees are — two projects each
|
|
11
|
+
* running a daemon is normal and must keep working.
|
|
12
|
+
*
|
|
13
|
+
* A PID on disk is a claim, not a fact: the process may have been SIGKILLed
|
|
14
|
+
* without cleanup, and the OS may since have recycled its pid onto something
|
|
15
|
+
* else entirely. So `readRecord()` never trusts the file alone — it proves
|
|
16
|
+
* liveness two ways (the pid exists, *and* something answers /healthz on the
|
|
17
|
+
* recorded port) and reports which of those hold. Acting on a stale record is
|
|
18
|
+
* how a tool ends up killing an unrelated process.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs/promises";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
import { projectDataDir } from "./config.js";
|
|
25
|
+
import { isValidPort } from "./security.js";
|
|
26
|
+
|
|
27
|
+
const FILENAME = "daemon.json";
|
|
28
|
+
|
|
29
|
+
export function daemonFilePath(repoRoot) {
|
|
30
|
+
return path.join(projectDataDir(repoRoot), FILENAME);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Record this process as the daemon for `repoRoot`. */
|
|
34
|
+
export async function writeRecord(repoRoot, { port }) {
|
|
35
|
+
const file = daemonFilePath(repoRoot);
|
|
36
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
37
|
+
const record = {
|
|
38
|
+
pid: process.pid,
|
|
39
|
+
port,
|
|
40
|
+
repo: repoRoot,
|
|
41
|
+
startedAt: Date.now(),
|
|
42
|
+
version: 1,
|
|
43
|
+
};
|
|
44
|
+
// Write-then-rename so a crash mid-write can't leave a truncated file that
|
|
45
|
+
// every later command has to defend against parsing.
|
|
46
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
47
|
+
await fs.writeFile(tmp, JSON.stringify(record, null, 2) + "\n");
|
|
48
|
+
await fs.rename(tmp, file);
|
|
49
|
+
return record;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Remove the record, if it is ours. Never throws. */
|
|
53
|
+
export async function clearRecord(repoRoot, { onlyIfPid = null } = {}) {
|
|
54
|
+
const file = daemonFilePath(repoRoot);
|
|
55
|
+
try {
|
|
56
|
+
if (onlyIfPid !== null) {
|
|
57
|
+
const raw = JSON.parse(await fs.readFile(file, "utf8"));
|
|
58
|
+
if (raw?.pid !== onlyIfPid) return false;
|
|
59
|
+
}
|
|
60
|
+
await fs.unlink(file);
|
|
61
|
+
return true;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read the record and establish how much of it is still true.
|
|
69
|
+
*
|
|
70
|
+
* Returns null when there is no record at all. Otherwise:
|
|
71
|
+
* { pid, port, startedAt, pidAlive, healthy, running }
|
|
72
|
+
*
|
|
73
|
+
* `running` is the only field callers should branch on for "is a sidebranch
|
|
74
|
+
* daemon serving this repo right now". It requires *both* proofs: a live pid
|
|
75
|
+
* rules out a record left behind by a killed process, and a /healthz answer
|
|
76
|
+
* rules out a recycled pid belonging to some unrelated program. Either alone
|
|
77
|
+
* is a way to mistake a stranger's process for our own.
|
|
78
|
+
*/
|
|
79
|
+
export async function readRecord(repoRoot) {
|
|
80
|
+
let raw;
|
|
81
|
+
try {
|
|
82
|
+
raw = JSON.parse(await fs.readFile(daemonFilePath(repoRoot), "utf8"));
|
|
83
|
+
} catch {
|
|
84
|
+
return null; // absent, unreadable, or corrupt — all mean "no daemon"
|
|
85
|
+
}
|
|
86
|
+
if (!Number.isInteger(raw?.pid) || raw.pid <= 0 || !isValidPort(raw?.port)) return null;
|
|
87
|
+
|
|
88
|
+
const pidAlive = isPidAlive(raw.pid);
|
|
89
|
+
const healthy = pidAlive ? await probeHealth(raw.port) : false;
|
|
90
|
+
return {
|
|
91
|
+
pid: raw.pid,
|
|
92
|
+
port: raw.port,
|
|
93
|
+
startedAt: typeof raw.startedAt === "number" ? raw.startedAt : null,
|
|
94
|
+
pidAlive,
|
|
95
|
+
healthy,
|
|
96
|
+
running: pidAlive && healthy,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Signal 0 tests for existence and permission without delivering a signal. */
|
|
101
|
+
export function isPidAlive(pid) {
|
|
102
|
+
try {
|
|
103
|
+
process.kill(pid, 0);
|
|
104
|
+
return true;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
// EPERM means it exists but belongs to another user — still alive, and
|
|
107
|
+
// definitely not ours to signal.
|
|
108
|
+
return err.code === "EPERM";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Ask the recorded port whether a sidebranch daemon answers there.
|
|
114
|
+
*
|
|
115
|
+
* `/healthz` is unauthenticated and passes the loopback gate from this
|
|
116
|
+
* process, so this needs no token. A non-sidebranch server on that port
|
|
117
|
+
* answers something that isn't `{ok:true}`, which is exactly the case this
|
|
118
|
+
* is here to catch.
|
|
119
|
+
*/
|
|
120
|
+
export async function probeHealth(port, { timeoutMs = 1500 } = {}) {
|
|
121
|
+
const ac = new AbortController();
|
|
122
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
123
|
+
try {
|
|
124
|
+
const res = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: ac.signal });
|
|
125
|
+
if (!res.ok) return false;
|
|
126
|
+
const body = await res.json();
|
|
127
|
+
return body?.ok === true;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
} finally {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* SIGTERM the daemon and wait for it to actually go away.
|
|
137
|
+
*
|
|
138
|
+
* Deliberately never escalates to SIGKILL: the daemon's SIGTERM handler is
|
|
139
|
+
* what shuts down pane dev servers cleanly, and killing it outright would
|
|
140
|
+
* orphan those children — the exact mess this file exists to prevent. If it
|
|
141
|
+
* won't exit, say so and let the user decide.
|
|
142
|
+
*/
|
|
143
|
+
export async function stopDaemon(record, { timeoutMs = 10_000, pollMs = 100 } = {}) {
|
|
144
|
+
try {
|
|
145
|
+
process.kill(record.pid, "SIGTERM");
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (err.code === "ESRCH") return { ok: true, alreadyGone: true };
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
const deadline = Date.now() + timeoutMs;
|
|
151
|
+
while (Date.now() < deadline) {
|
|
152
|
+
if (!isPidAlive(record.pid)) return { ok: true, alreadyGone: false };
|
|
153
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
154
|
+
}
|
|
155
|
+
return { ok: false, alreadyGone: false };
|
|
156
|
+
}
|