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/src/cli.js ADDED
@@ -0,0 +1,342 @@
1
+ /**
2
+ * cli.js — `sidebranch init | start | stop | clean | doctor`
3
+ */
4
+
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import readline from "node:readline";
8
+
9
+ import { loadConfig, CONFIG_FILENAME, DEFAULTS, projectDataDir } from "./config.js";
10
+ import * as gitops from "./gitops.js";
11
+ import { Manager } from "./manager.js";
12
+ import { Daemon } from "./daemon.js";
13
+ import { readRecord, writeRecord, clearRecord, stopDaemon, daemonFilePath } from "./daemonfile.js";
14
+
15
+ const HELP = `sidebranch — local PR review sidecar
16
+
17
+ Usage:
18
+ sidebranch init Write a starter ${CONFIG_FILENAME} in this repo
19
+ sidebranch start [--port] Start the daemon (default port 49400)
20
+ sidebranch stop Stop the daemon running for this repo
21
+ sidebranch clean [--pane a|b] [--yes]
22
+ Remove stale pane worktrees for this repo
23
+ sidebranch doctor Check environment and configuration
24
+ sidebranch help
25
+
26
+ Then add to your app (dev builds only):
27
+ <script src="http://localhost:49400/widget.js" defer></script>
28
+ `;
29
+
30
+ export async function main(argv) {
31
+ const [cmd, ...rest] = argv;
32
+ switch (cmd) {
33
+ case "init": return init();
34
+ case "start": return start(parseFlags(rest));
35
+ case "stop": return stop();
36
+ case "clean": return clean(parseCleanFlags(rest));
37
+ case "doctor": return doctor();
38
+ case "help":
39
+ case undefined:
40
+ process.stdout.write(HELP);
41
+ return 0;
42
+ default:
43
+ process.stderr.write(`Unknown command: ${cmd}\n\n${HELP}`);
44
+ return 1;
45
+ }
46
+ }
47
+
48
+ function parseFlags(rest) {
49
+ const flags = { port: 49400 };
50
+ for (let i = 0; i < rest.length; i++) {
51
+ if (rest[i] === "--port") flags.port = Number.parseInt(rest[++i], 10);
52
+ }
53
+ if (!Number.isInteger(flags.port) || flags.port < 1024 || flags.port > 65000) {
54
+ throw new Error("--port must be an integer between 1024 and 65000");
55
+ }
56
+ return flags;
57
+ }
58
+
59
+ async function init() {
60
+ const root = await gitops.repoRoot(process.cwd());
61
+ const file = path.join(root, CONFIG_FILENAME);
62
+ try {
63
+ await fs.access(file);
64
+ process.stdout.write(`${CONFIG_FILENAME} already exists at ${root}\n`);
65
+ return 0;
66
+ } catch { /* create it */ }
67
+ const starter = {
68
+ dev: DEFAULTS.dev,
69
+ install: DEFAULTS.install,
70
+ ready: { path: "/" },
71
+ };
72
+ await fs.writeFile(file, JSON.stringify(starter, null, 2) + "\n");
73
+ process.stdout.write(
74
+ `Wrote ${file}\n\n` +
75
+ `Edit "dev" and "install" for your stack. Examples:\n` +
76
+ ` Next.js { "dev": "npm run dev", "install": "npm install" }\n` +
77
+ ` Vite { "dev": "npx vite --port {port}", "install": "pnpm install" }\n` +
78
+ ` Python { "dev": "python3 -m http.server {port}", "install": "" }\n\n` +
79
+ `Then run: npx sidebranch start\n`
80
+ );
81
+ return 0;
82
+ }
83
+
84
+ async function start({ port }) {
85
+ const root = await gitops.repoRoot(process.cwd());
86
+
87
+ // Two daemons for one repo would fight over the same pane worktrees, and
88
+ // the loser's failure mode is a confusing git lock error rather than
89
+ // anything that names the real cause. Refuse up front instead.
90
+ const existing = await readRecord(root);
91
+ if (existing?.running) {
92
+ process.stderr.write(
93
+ `A sidebranch daemon is already running for this repo.\n` +
94
+ ` pid ${existing.pid}\n` +
95
+ ` port ${existing.port} (http://localhost:${existing.port}/shell)\n\n` +
96
+ `Use that one, or run \`sidebranch stop\` first.\n`
97
+ );
98
+ return 1;
99
+ }
100
+ if (existing && !existing.running) {
101
+ process.stdout.write(
102
+ `Clearing a stale daemon record (pid ${existing.pid}${existing.pidAlive ? ", not answering" : ", gone"}).\n`
103
+ );
104
+ await clearRecord(root);
105
+ }
106
+
107
+ const config = await loadConfig(root);
108
+ const manager = new Manager({ repoRoot: root, config });
109
+ const daemon = new Daemon({ manager, port });
110
+ await daemon.start();
111
+ await writeRecord(root, { port });
112
+
113
+ process.stdout.write(
114
+ `sidebranch daemon running\n` +
115
+ ` repo ${root}\n` +
116
+ ` worktrees ${projectDataDir(root)}\n` +
117
+ ` bound http://127.0.0.1:${port} (loopback only)\n` +
118
+ ` pid ${process.pid} (\`sidebranch stop\` from any terminal)\n\n` +
119
+ `Add to your app (dev only):\n` +
120
+ ` <script src="http://localhost:${port}/widget.js" defer></script>\n\n` +
121
+ `Compare view: open via the widget, or http://localhost:${port}/shell\n`
122
+ );
123
+
124
+ let shuttingDown = false;
125
+ const shutdown = async () => {
126
+ if (shuttingDown) return; // a second ^C must not race the first
127
+ shuttingDown = true;
128
+ process.stdout.write("\nShutting down panes…\n");
129
+ await daemon.stop().catch(() => {});
130
+ // Only ever clear our own record: if this process somehow outlived its
131
+ // record and another daemon has since claimed the repo, that daemon's
132
+ // record is not ours to delete.
133
+ await clearRecord(root, { onlyIfPid: process.pid });
134
+ process.stdout.write(
135
+ "Panes remain on disk for next time. Run `npx sidebranch clean` to tear down worktrees you no longer need.\n"
136
+ );
137
+ process.exit(0);
138
+ };
139
+ process.on("SIGINT", shutdown);
140
+ process.on("SIGTERM", shutdown);
141
+ return new Promise(() => {}); // run until signaled
142
+ }
143
+
144
+ /**
145
+ * Stop the daemon serving this repo.
146
+ *
147
+ * SIGTERM only — the daemon's own handler is what stops pane dev servers
148
+ * cleanly, so escalating to SIGKILL would orphan exactly the child processes
149
+ * this command exists to clean up.
150
+ */
151
+ async function stop() {
152
+ const root = await gitops.repoRoot(process.cwd());
153
+ const record = await readRecord(root);
154
+
155
+ if (!record) {
156
+ process.stdout.write(`No sidebranch daemon recorded for ${root}\n`);
157
+ return 0;
158
+ }
159
+ if (!record.running) {
160
+ // The pid is gone, or something else answers on that port now. Either
161
+ // way the record is a lie; clear it rather than signalling a stranger.
162
+ await clearRecord(root);
163
+ process.stdout.write(
164
+ record.pidAlive
165
+ ? `Cleared a stale record: pid ${record.pid} is alive but is not a sidebranch daemon.\n`
166
+ : `Cleared a stale record: pid ${record.pid} is no longer running.\n`
167
+ );
168
+ return 0;
169
+ }
170
+
171
+ process.stdout.write(`Stopping sidebranch daemon (pid ${record.pid}, port ${record.port})…\n`);
172
+ const result = await stopDaemon(record);
173
+ if (!result.ok) {
174
+ process.stderr.write(
175
+ `Daemon ${record.pid} did not exit within 10s. It may be mid-install.\n` +
176
+ `Leaving it alone rather than forcing it — check on it, or kill ${record.pid} yourself.\n`
177
+ );
178
+ return 1;
179
+ }
180
+ await clearRecord(root);
181
+ process.stdout.write(
182
+ result.alreadyGone
183
+ ? "Daemon was already gone; record cleared.\n"
184
+ : "Stopped. Pane worktrees remain on disk — `sidebranch clean` removes them.\n"
185
+ );
186
+ return 0;
187
+ }
188
+
189
+ function parseCleanFlags(rest) {
190
+ const flags = { pane: null, yes: false };
191
+ for (let i = 0; i < rest.length; i++) {
192
+ if (rest[i] === "--pane") flags.pane = rest[++i];
193
+ else if (rest[i] === "--yes" || rest[i] === "-y") flags.yes = true;
194
+ }
195
+ return flags;
196
+ }
197
+
198
+ /**
199
+ * Panes are worktrees that outlive the daemon — this walks the on-disk pane
200
+ * directories for the current repo and cross-references `git worktree list`
201
+ * to report what's there, without requiring a running daemon.
202
+ */
203
+ async function findPanes(root, paneFilter) {
204
+ const panesRoot = path.join(projectDataDir(root), "panes");
205
+ let entries;
206
+ try {
207
+ entries = await fs.readdir(panesRoot, { withFileTypes: true });
208
+ } catch (err) {
209
+ if (err.code === "ENOENT") return [];
210
+ throw err;
211
+ }
212
+ const worktrees = await gitops.listWorktrees(root);
213
+ const rows = await Promise.all(
214
+ entries
215
+ .filter((e) => e.isDirectory())
216
+ .map(async (e) => {
217
+ const dir = path.join(panesRoot, e.name);
218
+ const wt = await gitops.findWorktree(worktrees, dir);
219
+ const label = wt ? (wt.branch ?? `detached @ ${(wt.head ?? "").slice(0, 7)}`) : "not a worktree";
220
+ return { id: e.name, dir, tracked: Boolean(wt), label };
221
+ })
222
+ );
223
+ return paneFilter ? rows.filter((r) => r.id === paneFilter) : rows;
224
+ }
225
+
226
+ async function clean({ pane, yes }) {
227
+ const root = await gitops.repoRoot(process.cwd());
228
+
229
+ // clean used to be blind here, and the README had to carry the warning in
230
+ // prose. Removing a worktree out from under a running dev server leaves a
231
+ // process serving a directory that no longer exists — refuse instead.
232
+ const record = await readRecord(root);
233
+ if (record?.running) {
234
+ process.stderr.write(
235
+ `A sidebranch daemon is running for this repo (pid ${record.pid}, port ${record.port}).\n` +
236
+ `Removing its worktrees now would leave pane dev servers running against\n` +
237
+ `deleted directories. Run \`sidebranch stop\` first.\n`
238
+ );
239
+ return 1;
240
+ }
241
+
242
+ const rows = await findPanes(root, pane);
243
+
244
+ if (pane && rows.length === 0) {
245
+ process.stderr.write(`No pane "${pane}" found for ${root}\n`);
246
+ return 1;
247
+ }
248
+ if (rows.length === 0) {
249
+ process.stdout.write(`No panes found for ${root}\n`);
250
+ return 0;
251
+ }
252
+
253
+ process.stdout.write(`Panes for ${root}:\n`);
254
+ for (const r of rows) process.stdout.write(` ${r.id} ${r.label} ${r.dir}\n`);
255
+ process.stdout.write(
256
+ "\nThis removes the worktree(s) listed above; nothing else is touched.\n\n"
257
+ );
258
+
259
+ if (!yes) {
260
+ if (!process.stdin.isTTY) {
261
+ process.stderr.write(`Re-run with --yes to remove ${rows.length === 1 ? "this pane" : "these panes"}.\n`);
262
+ return 1;
263
+ }
264
+ const ok = await confirm(`Remove ${rows.length} pane${rows.length === 1 ? "" : "s"}? [y/N] `);
265
+ if (!ok) {
266
+ process.stdout.write("Aborted.\n");
267
+ return 1;
268
+ }
269
+ }
270
+
271
+ let failures = 0;
272
+ for (const r of rows) {
273
+ try {
274
+ if (r.tracked) await gitops.removeWorktree(root, r.dir);
275
+ else await fs.rm(r.dir, { recursive: true, force: true });
276
+ process.stdout.write(`removed ${r.id} (${r.label})\n`);
277
+ } catch (err) {
278
+ failures++;
279
+ process.stderr.write(`failed to remove ${r.id}: ${err.message}\n`);
280
+ }
281
+ }
282
+ return failures === 0 ? 0 : 1;
283
+ }
284
+
285
+ function confirm(question) {
286
+ return new Promise((resolve) => {
287
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
288
+ rl.question(question, (answer) => {
289
+ rl.close();
290
+ resolve(/^y(es)?$/i.test(answer.trim()));
291
+ });
292
+ });
293
+ }
294
+
295
+ async function doctor() {
296
+ const checks = [];
297
+ const push = (name, ok, note = "") => checks.push({ name, ok, note });
298
+
299
+ try {
300
+ const root = await gitops.repoRoot(process.cwd());
301
+ push("git repository", true, root);
302
+ try {
303
+ const cfg = await loadConfig(root);
304
+ push(`${CONFIG_FILENAME}`, true, `dev="${cfg.dev}" install="${cfg.install}"`);
305
+ } catch (e) {
306
+ push(CONFIG_FILENAME, false, e.message);
307
+ }
308
+ } catch {
309
+ push("git repository", false, "run inside a git repo");
310
+ }
311
+ push("node version", Number(process.versions.node.split(".")[0]) >= 20, process.versions.node);
312
+
313
+ try {
314
+ const root = await gitops.repoRoot(process.cwd());
315
+ const record = await readRecord(root);
316
+ if (!record) push("daemon", true, "not running");
317
+ else if (record.running) push("daemon", true, `running — pid ${record.pid}, port ${record.port}`);
318
+ else {
319
+ // Not a failure: a stale record is self-healing, both `start` and
320
+ // `stop` clear it. Say so rather than reporting a scary FAIL.
321
+ push("daemon", true, `stale record (pid ${record.pid} ${record.pidAlive ? "not answering" : "gone"}) — \`sidebranch stop\` clears it`);
322
+ }
323
+ } catch { /* not a git repo; already reported above */ }
324
+
325
+ try {
326
+ const root = await gitops.repoRoot(process.cwd());
327
+ const paneRoot = path.join(projectDataDir(root), "panes") + path.sep;
328
+ const stale = [];
329
+ for (const w of await gitops.listWorktrees(root)) {
330
+ if (!w.path.startsWith(paneRoot)) continue;
331
+ const gone = await fs.access(w.path).then(() => false, () => true);
332
+ if (gone) stale.push(path.basename(w.path));
333
+ }
334
+ // Informational, not a failure: ensurePane prunes and recreates these.
335
+ if (stale.length) push("pane worktrees", true, `stale registration for pane ${stale.join(", ")} — auto-healed on next use`);
336
+ } catch { /* not a git repo; already reported above */ }
337
+
338
+ for (const c of checks) {
339
+ process.stdout.write(`${c.ok ? " ok " : "FAIL"} ${c.name}${c.note ? ` — ${c.note}` : ""}\n`);
340
+ }
341
+ return checks.every((c) => c.ok) ? 0 : 1;
342
+ }
package/src/config.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * config.js — project configuration (.sidebranch.json at the repo root).
3
+ *
4
+ * The config is authored by the repo owner and is trusted exactly like the
5
+ * repo's own package.json scripts: it can name commands to run in dev, and
6
+ * nothing else. It cannot change the daemon's bind address, disable auth,
7
+ * or widen origins — those are not configuration, they are invariants.
8
+ */
9
+
10
+ import fs from "node:fs/promises";
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+
14
+ import { DEFAULT_LOCKFILES } from "./install.js";
15
+
16
+ export const CONFIG_FILENAME = ".sidebranch.json";
17
+
18
+ export const DEFAULTS = {
19
+ dev: "npm run dev", // command to start the app in a worktree
20
+ install: "npm install", // run when lockfiles change
21
+ ready: { path: "/", statuses: null }, // readiness probe; null = any HTTP answer
22
+ panes: 2, // review worktrees kept warm
23
+ basePort: 4410, // first port tried for pane servers
24
+ lockfiles: DEFAULT_LOCKFILES, // manifests that trigger reinstall
25
+ copy: [".env", ".env.local"], // untracked files copied from main tree into new worktrees
26
+ env: {}, // extra env vars injected into the pane dev command
27
+ widget: true, // set false to make /widget.js serve a no-op
28
+ frameProxy: true, // set false to disable the compare view's header-stripping view ports
29
+ };
30
+
31
+ // Env names sidebranch owns and injects itself (see processes.js). Config
32
+ // `env` can override anything *except* these — letting a project clobber PORT
33
+ // would break the per-pane port injection the whole tool is built on.
34
+ export const RESERVED_ENV = new Set(["PORT", "BROWSER", "FORCE_COLOR", "SIDEBRANCH"]);
35
+
36
+ export async function loadConfig(repoRoot) {
37
+ const file = path.join(repoRoot, CONFIG_FILENAME);
38
+ let raw = {};
39
+ try {
40
+ raw = JSON.parse(await fs.readFile(file, "utf8"));
41
+ } catch (err) {
42
+ if (err.code !== "ENOENT") {
43
+ throw new Error(`Could not parse ${CONFIG_FILENAME}: ${err.message}`);
44
+ }
45
+ }
46
+ return normalize(raw);
47
+ }
48
+
49
+ export function normalize(raw) {
50
+ const cfg = { ...DEFAULTS, ...raw };
51
+ cfg.ready = { ...DEFAULTS.ready, ...(raw.ready || {}) };
52
+ if (typeof cfg.dev !== "string" || !cfg.dev.trim()) throw new Error(`"dev" must be a command string`);
53
+ if (typeof cfg.install !== "string") throw new Error(`"install" must be a command string`);
54
+ cfg.panes = clampInt(cfg.panes, 1, 4, DEFAULTS.panes);
55
+ cfg.basePort = clampInt(cfg.basePort, 1024, 65000, DEFAULTS.basePort);
56
+ if (!Array.isArray(cfg.lockfiles)) cfg.lockfiles = DEFAULT_LOCKFILES;
57
+ if (!Array.isArray(cfg.copy)) cfg.copy = DEFAULTS.copy;
58
+ cfg.copy = cfg.copy.filter((f) => typeof f === "string" && !f.includes("..") && !path.isAbsolute(f));
59
+ cfg.env = normalizeEnv(cfg.env);
60
+ cfg.widget = cfg.widget !== false;
61
+ cfg.frameProxy = cfg.frameProxy !== false;
62
+ if (cfg.ready.statuses !== null && !Array.isArray(cfg.ready.statuses)) cfg.ready.statuses = null;
63
+ if (typeof cfg.ready.path !== "string" || !cfg.ready.path.startsWith("/")) cfg.ready.path = "/";
64
+ return cfg;
65
+ }
66
+
67
+ /**
68
+ * Coerce a config `env` block into a clean { NAME: "value" } map.
69
+ *
70
+ * - Keys must be POSIX-portable env names (`[A-Za-z_][A-Za-z0-9_]*`); anything
71
+ * else is dropped rather than passed to spawn where it could misbehave.
72
+ * - Reserved names sidebranch injects itself (PORT etc.) are dropped so config
73
+ * can never break port injection.
74
+ * - String values pass through **including the empty string** — setting a var
75
+ * to "" is a deliberate, supported way to unset an inherited value (e.g.
76
+ * GOOGLE_APPLICATION_CREDENTIALS="" to fall back to machine ADC).
77
+ * - Numbers/booleans are coerced to strings for author convenience; objects,
78
+ * arrays, null, and values containing NUL are dropped (spawn can't take them).
79
+ */
80
+ export function normalizeEnv(raw) {
81
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return {};
82
+ const out = {};
83
+ for (const [key, val] of Object.entries(raw)) {
84
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
85
+ if (RESERVED_ENV.has(key)) continue;
86
+ let s;
87
+ if (typeof val === "string") s = val;
88
+ else if (typeof val === "number" || typeof val === "boolean") s = String(val);
89
+ else continue;
90
+ if (s.includes("\0")) continue;
91
+ out[key] = s;
92
+ }
93
+ return out;
94
+ }
95
+
96
+ function clampInt(v, min, max, dflt) {
97
+ const n = Number.parseInt(v, 10);
98
+ if (!Number.isInteger(n)) return dflt;
99
+ return Math.min(max, Math.max(min, n));
100
+ }
101
+
102
+ /** Where sidebranch keeps worktrees and runtime state, outside the repo. */
103
+ export function dataDir() {
104
+ return process.env.SIDEBRANCH_HOME || path.join(os.homedir(), ".sidebranch");
105
+ }
106
+
107
+ export function projectDataDir(repoRoot) {
108
+ const slug = path.basename(repoRoot).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
109
+ const hash = simpleHash(repoRoot).slice(0, 8);
110
+ return path.join(dataDir(), "projects", `${slug}-${hash}`);
111
+ }
112
+
113
+ function simpleHash(s) {
114
+ let h = 2166136261;
115
+ for (let i = 0; i < s.length; i++) {
116
+ h ^= s.charCodeAt(i);
117
+ h = Math.imul(h, 16777619);
118
+ }
119
+ return (h >>> 0).toString(16).padStart(8, "0");
120
+ }