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/gitops.js ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * gitops.js — every git interaction in the tool.
3
+ *
4
+ * Rules:
5
+ * - execFile with argument arrays only. There is no shell anywhere in
6
+ * this file, so metacharacters in branch names cannot become commands.
7
+ * - Ref names are validated twice: our conservative isSafeRefName(),
8
+ * then git's own `check-ref-format --branch`.
9
+ * - Review worktrees are treated as append-only environments: they are
10
+ * never expected to be dirty. If one is dirty (someone hand-edited it),
11
+ * we refuse to touch it unless the caller passes { discard: true }.
12
+ * Nothing in this tool ever stashes or resets the user's primary
13
+ * working tree — we never even run commands against it beyond reads.
14
+ */
15
+
16
+ import { execFile } from "node:child_process";
17
+ import { promisify } from "node:util";
18
+ import path from "node:path";
19
+ import fsp from "node:fs/promises";
20
+
21
+ import { isSafeRefName } from "./security.js";
22
+
23
+ const execFileP = promisify(execFile);
24
+
25
+ const GIT_ENV = {
26
+ ...process.env,
27
+ GIT_TERMINAL_PROMPT: "0", // never hang on credential prompts
28
+ GIT_CONFIG_NOSYSTEM: process.env.GIT_CONFIG_NOSYSTEM ?? "",
29
+ };
30
+
31
+ async function git(cwd, args, opts = {}) {
32
+ try {
33
+ const { stdout } = await execFileP("git", args, {
34
+ cwd,
35
+ env: GIT_ENV,
36
+ maxBuffer: 10 * 1024 * 1024,
37
+ timeout: opts.timeout ?? 120_000,
38
+ windowsHide: true,
39
+ });
40
+ return stdout;
41
+ } catch (err) {
42
+ const msg = (err.stderr || err.message || "git error").toString().trim();
43
+ const e = new Error(msg);
44
+ e.code = "EGIT";
45
+ throw e;
46
+ }
47
+ }
48
+
49
+ /** Throws unless `name` is a plausible, safe branch name per git itself. */
50
+ export async function assertValidBranchName(repo, name) {
51
+ if (!isSafeRefName(name)) {
52
+ const e = new Error(`Invalid branch name: ${JSON.stringify(String(name).slice(0, 80))}`);
53
+ e.code = "EBADREF";
54
+ throw e;
55
+ }
56
+ await git(repo, ["check-ref-format", "--branch", name]);
57
+ }
58
+
59
+ export async function repoRoot(dir) {
60
+ return (await git(dir, ["rev-parse", "--show-toplevel"])).trim();
61
+ }
62
+
63
+ export async function currentBranch(dir) {
64
+ return (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
65
+ }
66
+
67
+ export async function headCommit(dir) {
68
+ const out = await git(dir, ["log", "-1", "--format=%H%x1f%h%x1f%ci%x1f%s"]);
69
+ const [sha, short, date, subject] = out.trim().split("\x1f");
70
+ return { sha, short, date, subject };
71
+ }
72
+
73
+ export async function isClean(dir) {
74
+ const out = await git(dir, ["status", "--porcelain"]);
75
+ return out.trim() === "";
76
+ }
77
+
78
+ /** True if the repo is mid-merge/rebase/etc. — refuse to operate. */
79
+ export async function inProgressOperation(dir) {
80
+ const out = await git(dir, [
81
+ "rev-parse", "--git-path", "MERGE_HEAD",
82
+ "--git-path", "REBASE_HEAD",
83
+ "--git-path", "CHERRY_PICK_HEAD",
84
+ ]);
85
+ const gitDirPaths = out.trim().split("\n");
86
+ const fs = await import("node:fs");
87
+ return gitDirPaths.some((p) => fs.existsSync(path.resolve(dir, p)));
88
+ }
89
+
90
+ /**
91
+ * List local and remote branches, most recently committed first.
92
+ * Remote refs are reported without the remote prefix, deduped against
93
+ * local branches.
94
+ */
95
+ export async function listBranches(repo) {
96
+ const fmt = "%(refname)%1f%(refname:short)%1f%(committerdate:iso-strict)%1f%(objectname:short)%1f%(subject)";
97
+ const out = await git(repo, [
98
+ "for-each-ref", "--sort=-committerdate", `--format=${fmt.replaceAll("%1f", "%01")}`,
99
+ "refs/heads", "refs/remotes",
100
+ ]);
101
+ const seen = new Map();
102
+ for (const line of out.split("\n")) {
103
+ if (!line.trim()) continue;
104
+ const [refname, short, date, sha, subject] = line.split("\x01");
105
+ if (short.endsWith("/HEAD")) continue;
106
+ let name = short;
107
+ let remote = null;
108
+ if (refname.startsWith("refs/remotes/")) {
109
+ const parts = short.split("/");
110
+ remote = parts.shift();
111
+ name = parts.join("/");
112
+ }
113
+ if (!isSafeRefName(name)) continue; // skip anything we would refuse to check out
114
+ const existing = seen.get(name);
115
+ if (!existing) {
116
+ seen.set(name, { name, sha, date, subject, local: !remote, remotes: remote ? [remote] : [] });
117
+ } else if (remote) {
118
+ existing.remotes.push(remote);
119
+ } else {
120
+ existing.local = true;
121
+ existing.sha = sha;
122
+ existing.date = date;
123
+ existing.subject = subject;
124
+ }
125
+ }
126
+ return [...seen.values()];
127
+ }
128
+
129
+ export async function fetchAll(repo) {
130
+ await git(repo, ["fetch", "--all", "--prune"], { timeout: 180_000 });
131
+ }
132
+
133
+ /* ------------------------------- worktrees ------------------------------- */
134
+
135
+ export async function listWorktrees(repo) {
136
+ const out = await git(repo, ["worktree", "list", "--porcelain"]);
137
+ const items = [];
138
+ let cur = null;
139
+ for (const line of out.split("\n")) {
140
+ if (line.startsWith("worktree ")) {
141
+ if (cur) items.push(cur);
142
+ cur = { path: line.slice(9), branch: null, head: null, detached: false };
143
+ } else if (cur && line.startsWith("HEAD ")) {
144
+ cur.head = line.slice(5);
145
+ } else if (cur && line.startsWith("branch ")) {
146
+ cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
147
+ } else if (cur && line === "detached") {
148
+ cur.detached = true;
149
+ }
150
+ }
151
+ if (cur) items.push(cur);
152
+ return items;
153
+ }
154
+
155
+ /**
156
+ * Resolve `dir` through the filesystem (symlinks and all — notably macOS's
157
+ * /var -> /private/var) so paths built from os.tmpdir() compare equal to
158
+ * what `git worktree list` reports. A deleted directory can't be realpathed
159
+ * directly, so walk up to the deepest ancestor that still exists, realpath
160
+ * that, and re-append the missing tail — otherwise a stale registration for
161
+ * a removed worktree silently fails to match on macOS tmp paths, and the
162
+ * heal in ensurePane never fires.
163
+ */
164
+ async function resolvedPath(dir) {
165
+ const abs = path.resolve(dir);
166
+ let base = abs;
167
+ const missing = [];
168
+ for (;;) {
169
+ try {
170
+ return path.join(await fsp.realpath(base), ...missing.reverse());
171
+ } catch {
172
+ const parent = path.dirname(base);
173
+ if (parent === base) return abs;
174
+ missing.push(path.basename(base));
175
+ base = parent;
176
+ }
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Find the `git worktree list` entry for `dir`, if any. Realpath-safe (see
182
+ * `resolvedPath`) — a plain path.resolve() comparison silently fails to
183
+ * match on macOS whenever the caller's path crosses the /var symlink.
184
+ */
185
+ export async function findWorktree(worktrees, dir) {
186
+ const target = await resolvedPath(dir);
187
+ for (const w of worktrees) {
188
+ if ((await resolvedPath(w.path)) === target) return w;
189
+ }
190
+ return null;
191
+ }
192
+
193
+ /**
194
+ * Create a review worktree at `dir` checked out to `branch`.
195
+ * If the branch only exists on a remote, a local tracking branch is created.
196
+ * Uses --detach + switch so a branch already checked out elsewhere (e.g. the
197
+ * user's own working tree) doesn't block worktree creation.
198
+ */
199
+ export async function addWorktree(repo, dir, branch) {
200
+ await assertValidBranchName(repo, branch);
201
+ await git(repo, ["worktree", "add", "--detach", "--", dir]);
202
+ await checkoutInWorktree(repo, dir, branch);
203
+ }
204
+
205
+ export async function removeWorktree(repo, dir) {
206
+ await git(repo, ["worktree", "remove", "--force", "--", dir]);
207
+ }
208
+
209
+ /**
210
+ * Clear registrations whose directories no longer exist on disk. Metadata
211
+ * only — prune never touches a working tree, ours or the user's.
212
+ */
213
+ export async function pruneWorktrees(repo) {
214
+ await git(repo, ["worktree", "prune"]);
215
+ }
216
+
217
+ /**
218
+ * Point an existing review worktree at `branch`.
219
+ * Review worktrees are never hand-edited, so a dirty tree here means
220
+ * something unexpected happened; refuse unless the caller opts into
221
+ * discarding, and even then only files inside the *review* tree are touched.
222
+ */
223
+ export async function checkoutInWorktree(repo, dir, branch, { discard = false } = {}) {
224
+ await assertValidBranchName(repo, branch);
225
+ if (await inProgressOperation(dir)) {
226
+ const e = new Error("Worktree has an in-progress merge/rebase; resolve or recreate it.");
227
+ e.code = "EBUSYTREE";
228
+ throw e;
229
+ }
230
+ if (!(await isClean(dir))) {
231
+ if (!discard) {
232
+ const e = new Error(
233
+ "Review worktree has uncommitted changes (it should never be edited by hand). " +
234
+ "Re-run with discard=true to reset it, or clean it up manually."
235
+ );
236
+ e.code = "EDIRTY";
237
+ throw e;
238
+ }
239
+ await git(dir, ["reset", "--hard"]);
240
+ await git(dir, ["clean", "-fd"]);
241
+ }
242
+
243
+ const localExists = (await git(dir, ["branch", "--list", "--format=%(refname:short)", branch]))
244
+ .split("\n").map((s) => s.trim()).includes(branch);
245
+
246
+ if (localExists) {
247
+ // Branch may be checked out in the user's main tree; review trees track
248
+ // the same commit in detached mode in that case rather than stealing it.
249
+ try {
250
+ await git(dir, ["switch", "--no-guess", branch]);
251
+ } catch (err) {
252
+ if (/already used by worktree|already checked out/i.test(err.message)) {
253
+ await git(dir, ["switch", "--detach", branch]);
254
+ } else {
255
+ throw err;
256
+ }
257
+ }
258
+ } else {
259
+ // Creates a local branch tracking the remote one (guess mode), which is
260
+ // exactly the PR-review case.
261
+ await git(dir, ["switch", branch]);
262
+ }
263
+ return headCommit(dir);
264
+ }
package/src/install.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * install.js — decide when a checkout requires re-installing dependencies,
3
+ * and run the project's configured install command when it does.
4
+ *
5
+ * Framework-agnostic: we don't know or care what the package manager is.
6
+ * We hash a set of well-known dependency manifests (extendable via config)
7
+ * and run `config.install` only when the combined hash changes. The install
8
+ * command itself comes from the project's own config file, which is the
9
+ * same trust level as its package.json scripts.
10
+ */
11
+
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { spawn } from "node:child_process";
16
+
17
+ export const DEFAULT_LOCKFILES = [
18
+ "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock",
19
+ "requirements.txt", "poetry.lock", "uv.lock", "Pipfile.lock",
20
+ "Gemfile.lock", "go.sum", "Cargo.lock", "composer.lock", "mix.lock",
21
+ ];
22
+
23
+ /** Stable hash of all present lockfiles in a worktree (name + content). */
24
+ export async function lockfileHash(dir, lockfiles = DEFAULT_LOCKFILES) {
25
+ const h = crypto.createHash("sha256");
26
+ let any = false;
27
+ for (const name of [...lockfiles].sort()) {
28
+ const p = path.join(dir, name);
29
+ try {
30
+ const buf = await fs.readFile(p);
31
+ any = true;
32
+ h.update(name);
33
+ h.update("\x00");
34
+ h.update(buf);
35
+ h.update("\x00");
36
+ } catch {
37
+ /* file absent — fine */
38
+ }
39
+ }
40
+ return any ? h.digest("hex") : "no-lockfiles";
41
+ }
42
+
43
+ /**
44
+ * Run the configured install command inside a worktree.
45
+ * The command is split into argv ourselves (no shell) — quoting in the
46
+ * config supports simple "word word" splitting only, which covers every
47
+ * real install command and avoids handing config a shell.
48
+ *
49
+ * `ring`, if supplied, accumulates raw stdout/stderr chunks (the caller owns
50
+ * it and can inspect it later, e.g. to serve a pane's full install log) and
51
+ * also backs the tail baked into a failure's error message, so "exited with
52
+ * code 1" becomes "exited with code 1: npm ERR! 404 Not Found — ..." without
53
+ * requiring a caller to go re-run the command by hand to see why.
54
+ */
55
+ export function runInstall(dir, installCmd, { onOutput, ring = [], timeoutMs = 15 * 60_000 } = {}) {
56
+ const argv = splitCommand(installCmd);
57
+ if (argv.length === 0) return Promise.resolve({ skipped: true });
58
+ return new Promise((resolve, reject) => {
59
+ const child = spawn(argv[0], argv.slice(1), {
60
+ cwd: dir,
61
+ env: { ...process.env, CI: "1" },
62
+ stdio: ["ignore", "pipe", "pipe"],
63
+ shell: false,
64
+ windowsHide: true,
65
+ });
66
+ const timer = setTimeout(() => {
67
+ child.kill("SIGKILL");
68
+ reject(new Error(`Install timed out after ${timeoutMs / 1000}s`));
69
+ }, timeoutMs);
70
+ const forward = (buf) => {
71
+ const text = buf.toString();
72
+ pushRing(ring, text);
73
+ onOutput?.(text);
74
+ };
75
+ child.stdout.on("data", forward);
76
+ child.stderr.on("data", forward);
77
+ child.on("error", (err) => { clearTimeout(timer); reject(err); });
78
+ child.on("exit", (code) => {
79
+ clearTimeout(timer);
80
+ if (code === 0) return resolve({ skipped: false });
81
+ const tail = tailText(ring);
82
+ reject(new Error(`Install command exited with code ${code}${tail ? `: ${tail}` : ""}`));
83
+ });
84
+ });
85
+ }
86
+
87
+ /** Bounded ring buffer: push a chunk, drop the oldest once over `max` entries. */
88
+ export function pushRing(ring, chunk, max = 400) {
89
+ ring.push(chunk);
90
+ if (ring.length > max) ring.shift();
91
+ }
92
+
93
+ /**
94
+ * Join a ring buffer's captured chunks and return the trailing `maxChars`,
95
+ * with newlines/runs of whitespace collapsed to " › " so a multi-line tool
96
+ * error still reads as one line for inline display (e.g. a status bubble).
97
+ * Callers that want the untouched, multi-line log (e.g. a log-viewing
98
+ * endpoint) should read the ring directly instead of going through this.
99
+ */
100
+ export function tailText(ring, maxChars = 300) {
101
+ const joined = ring.join("").trim();
102
+ if (!joined) return "";
103
+ const slice = joined.length > maxChars ? joined.slice(-maxChars) : joined;
104
+ return slice.trim().replace(/\s*\n\s*/g, " › ");
105
+ }
106
+
107
+ /** Minimal argv splitter: whitespace-separated, with double-quote grouping. */
108
+ export function splitCommand(cmd) {
109
+ if (typeof cmd !== "string") return [];
110
+ const out = [];
111
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
112
+ let m;
113
+ while ((m = re.exec(cmd))) out.push(m[1] ?? m[2] ?? m[3]);
114
+ return out;
115
+ }
package/src/manager.js ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * manager.js — orchestration layer.
3
+ *
4
+ * A "pane" is a persistent review environment: one worktree + one dev
5
+ * server on its own port. Panes are reused across branch switches; the
6
+ * expensive parts (worktree creation, dependency install) happen only when
7
+ * needed. The user's own working tree is read-only territory: we list its
8
+ * branches and copy configured env files out of it, and that is all.
9
+ */
10
+
11
+ import fs from "node:fs/promises";
12
+ import path from "node:path";
13
+ import { EventEmitter } from "node:events";
14
+
15
+ import * as gitops from "./gitops.js";
16
+ import { lockfileHash, runInstall } from "./install.js";
17
+ import { allocatePort, DevServer } from "./processes.js";
18
+ import { FrameProxy } from "./proxy.js";
19
+ import { projectDataDir } from "./config.js";
20
+
21
+ export class Manager extends EventEmitter {
22
+ constructor({ repoRoot, config }) {
23
+ super();
24
+ this.repoRoot = repoRoot;
25
+ this.config = config;
26
+ // Set by the Daemon once it knows its own port. Panes need it only to
27
+ // answer "would this app let the shell embed it?" — see probeFraming().
28
+ this.daemonPort = null;
29
+ this.dataDir = projectDataDir(repoRoot);
30
+ this.panes = new Map(); // id -> pane
31
+ this.takenPorts = new Set();
32
+ this.queue = Promise.resolve(); // serialize mutating operations
33
+ }
34
+
35
+ emitEvent(type, payload = {}) {
36
+ this.emit("event", { type, at: Date.now(), ...payload });
37
+ }
38
+
39
+ /** Serialize mutations so concurrent widget clicks can't interleave git ops. */
40
+ run(fn) {
41
+ const next = this.queue.then(fn, fn);
42
+ this.queue = next.catch(() => {});
43
+ return next;
44
+ }
45
+
46
+ paneDir(id) {
47
+ return path.join(this.dataDir, "panes", id);
48
+ }
49
+
50
+ async state() {
51
+ const [branch, head, branches] = await Promise.all([
52
+ gitops.currentBranch(this.repoRoot),
53
+ gitops.headCommit(this.repoRoot),
54
+ gitops.listBranches(this.repoRoot),
55
+ ]);
56
+ return {
57
+ repo: this.repoRoot,
58
+ main: { branch, head },
59
+ branches,
60
+ panes: [...this.panes.values()].map(paneInfo),
61
+ };
62
+ }
63
+
64
+ async fetch() {
65
+ this.emitEvent("fetch:start");
66
+ await gitops.fetchAll(this.repoRoot);
67
+ this.emitEvent("fetch:done");
68
+ }
69
+
70
+ /**
71
+ * Ensure a pane exists and is serving `branch`. This is the core loop:
72
+ * worktree → checkout → conditional install → (re)start server → ready.
73
+ */
74
+ ensurePane(id, branch, { discard = false } = {}) {
75
+ if (!/^[ab12]$/.test(id)) throw Object.assign(new Error("Pane id must be one of a/b"), { code: "EBADPANE" });
76
+ return this.run(async () => {
77
+ await gitops.assertValidBranchName(this.repoRoot, branch);
78
+ let pane = this.panes.get(id);
79
+ const dir = pane?.dir ?? this.paneDir(id);
80
+ const dirExists = await fs.access(dir).then(() => true, () => false);
81
+
82
+ if (!pane || !dirExists) {
83
+ await fs.mkdir(path.dirname(dir), { recursive: true });
84
+ const worktrees = await gitops.listWorktrees(this.repoRoot);
85
+ let existing = await gitops.findWorktree(worktrees, dir);
86
+ // Registered but gone from disk (a crash, a hand-run rm -rf): without
87
+ // healing, git runs with a missing cwd and dies with a misleading
88
+ // "spawn git ENOENT". Prune the stale registration and recreate.
89
+ if (existing && !dirExists) {
90
+ this.emitEvent("pane:healing", { pane: id });
91
+ await gitops.pruneWorktrees(this.repoRoot);
92
+ existing = null;
93
+ }
94
+ if (!existing) {
95
+ this.emitEvent("pane:creating", { pane: id, branch });
96
+ await gitops.addWorktree(this.repoRoot, dir, branch);
97
+ await this.copyEnvFiles(dir);
98
+ }
99
+ if (pane && !dirExists) {
100
+ // The old server's cwd is gone; so is node_modules. Start over.
101
+ await pane.server?.stop().catch(() => {});
102
+ await pane.viewProxy?.stop().catch(() => {});
103
+ if (pane.server) this.takenPorts.delete(pane.server.port);
104
+ if (pane.viewProxy) this.takenPorts.delete(pane.viewProxy.port);
105
+ pane.server = null;
106
+ pane.viewProxy = null;
107
+ pane.installedHash = null;
108
+ }
109
+ if (!pane) {
110
+ pane = {
111
+ id, dir, branch: null, head: null,
112
+ server: null, installedHash: null, status: "new", error: null,
113
+ installLog: [], // raw install stdout/stderr, retained for GET /api/pane/:id/log
114
+ };
115
+ this.panes.set(id, pane);
116
+ }
117
+ }
118
+
119
+ try {
120
+ pane.error = null;
121
+ pane.status = "switching";
122
+ this.emitEvent("pane:switching", { pane: id, branch });
123
+ pane.head = await gitops.checkoutInWorktree(this.repoRoot, pane.dir, branch, { discard });
124
+ pane.branch = branch;
125
+
126
+ const hash = await lockfileHash(pane.dir, this.config.lockfiles);
127
+ const needsInstall = hash !== pane.installedHash;
128
+ if (needsInstall) {
129
+ pane.status = "installing";
130
+ pane.installLog = [];
131
+ this.emitEvent("pane:installing", { pane: id, branch });
132
+ await runInstall(pane.dir, this.config.install, {
133
+ ring: pane.installLog,
134
+ onOutput: (chunk) => this.emitEvent("pane:install-output", { pane: id, chunk: chunk.slice(0, 2000) }),
135
+ });
136
+ pane.installedHash = hash;
137
+ }
138
+
139
+ if (!pane.server) {
140
+ const port = await allocatePort(this.config.basePort, this.takenPorts);
141
+ this.takenPorts.add(port);
142
+ pane.server = new DevServer({
143
+ command: this.config.dev,
144
+ cwd: pane.dir,
145
+ port,
146
+ env: this.config.env,
147
+ readyPath: this.config.ready.path,
148
+ readyStatuses: this.config.ready.statuses,
149
+ daemonPort: this.daemonPort,
150
+ });
151
+ }
152
+ if (!pane.viewProxy && this.config.frameProxy) {
153
+ const viewPort = await allocatePort(this.config.basePort, this.takenPorts);
154
+ this.takenPorts.add(viewPort);
155
+ pane.viewProxy = await new FrameProxy({ port: viewPort, targetPort: pane.server.port }).start();
156
+ }
157
+
158
+ pane.status = "starting";
159
+ this.emitEvent("pane:starting", { pane: id, branch, port: pane.server.port });
160
+ if (pane.server.state === "ready" && !needsInstall) {
161
+ // Dev server keeps running; its watcher sees the checkout. HMR or
162
+ // full reload is the frontend's job — nothing for us to do.
163
+ } else if (pane.server.state === "ready" && needsInstall) {
164
+ await pane.server.restart();
165
+ } else {
166
+ await pane.server.start();
167
+ }
168
+
169
+ pane.status = "ready";
170
+ this.emitEvent("pane:ready", { pane: id, branch, port: pane.server.port });
171
+ return paneInfo(pane);
172
+ } catch (err) {
173
+ pane.status = "error";
174
+ pane.error = err.message;
175
+ this.emitEvent("pane:error", { pane: id, branch, error: err.message, code: err.code });
176
+ throw err;
177
+ }
178
+ });
179
+ }
180
+
181
+ async stopPane(id) {
182
+ return this.run(async () => {
183
+ const pane = this.panes.get(id);
184
+ if (!pane) return;
185
+ await pane.server?.stop();
186
+ await pane.viewProxy?.stop();
187
+ if (pane.server) this.takenPorts.delete(pane.server.port);
188
+ if (pane.viewProxy) this.takenPorts.delete(pane.viewProxy.port);
189
+ pane.server = null;
190
+ pane.viewProxy = null;
191
+ pane.status = "stopped";
192
+ this.emitEvent("pane:stopped", { pane: id });
193
+ });
194
+ }
195
+
196
+ async destroyPane(id) {
197
+ await this.stopPane(id);
198
+ return this.run(async () => {
199
+ const pane = this.panes.get(id);
200
+ if (!pane) return;
201
+ try { await gitops.removeWorktree(this.repoRoot, pane.dir); } catch { /* best effort */ }
202
+ this.panes.delete(id);
203
+ this.emitEvent("pane:destroyed", { pane: id });
204
+ });
205
+ }
206
+
207
+ async shutdown() {
208
+ for (const id of this.panes.keys()) {
209
+ const pane = this.panes.get(id);
210
+ await pane.server?.stop().catch(() => {});
211
+ await pane.viewProxy?.stop().catch(() => {});
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Full-detail diagnostics for a pane: raw install output plus the dev
217
+ * server's own stdout/stderr ring, both untouched (real newlines kept)
218
+ * unlike the collapsed single-line tail baked into `pane.error`. Returns
219
+ * null for an unknown pane so the route can 404 instead of guessing.
220
+ */
221
+ getPaneLog(id, { maxChars = 20_000 } = {}) {
222
+ const pane = this.panes.get(id);
223
+ if (!pane) return null;
224
+ const clip = (s) => (s.length > maxChars ? s.slice(-maxChars) : s);
225
+ return {
226
+ pane: id,
227
+ status: pane.status,
228
+ error: pane.error,
229
+ install: clip(pane.installLog.join("")),
230
+ server: clip((pane.server?.logRing ?? []).join("")),
231
+ };
232
+ }
233
+
234
+ /** Copy configured untracked files (e.g. .env) from the main tree. */
235
+ async copyEnvFiles(dir) {
236
+ for (const rel of this.config.copy) {
237
+ const src = path.join(this.repoRoot, rel);
238
+ const dst = path.join(dir, rel);
239
+ try {
240
+ await fs.mkdir(path.dirname(dst), { recursive: true });
241
+ await fs.copyFile(src, dst);
242
+ } catch { /* absent — fine */ }
243
+ }
244
+ }
245
+ }
246
+
247
+ function paneInfo(p) {
248
+ return {
249
+ id: p.id,
250
+ branch: p.branch,
251
+ head: p.head,
252
+ status: p.status,
253
+ error: p.error,
254
+ port: p.server?.port ?? null,
255
+ serverState: p.server?.state ?? "stopped",
256
+ framing: p.server?.framing ?? null,
257
+ url: p.server ? `http://localhost:${p.server.port}/` : null,
258
+ viewUrl: p.viewProxy ? `http://localhost:${p.viewProxy.port}/` : null,
259
+ };
260
+ }