stackdeck 0.3.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/server.js ADDED
@@ -0,0 +1,1466 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Stackdeck daemon — your projects folder, as a control panel.
4
+ *
5
+ * Zero dependencies (Node >= 18.13 builtins only). Binds to 127.0.0.1.
6
+ *
7
+ * node server.js run in foreground
8
+ * stackdeck (CLI) start daemon + open the board
9
+ *
10
+ * State lives in $STACKDECK_HOME, else $XDG_CONFIG_HOME/stackdeck
11
+ * (default ~/.config/stackdeck):
12
+ * config.json services, sections, project roots, categories
13
+ * logs/ one log file per service + the daemon's own log
14
+ */
15
+ "use strict";
16
+ // Last-resort guards: a bug on one request path must never kill the board.
17
+ process.on("uncaughtException", (e) => console.error("uncaught exception:", e));
18
+ process.on("unhandledRejection", (e) => console.error("unhandled rejection:", e));
19
+ const http = require("http");
20
+ const fs = require("fs");
21
+ const path = require("path");
22
+ const os = require("os");
23
+ const { spawn, execFileSync } = require("child_process");
24
+
25
+ const VERSION = "0.3.0";
26
+ const ROOT = __dirname;
27
+
28
+ /* ---------- state directory & config ---------- */
29
+
30
+ // Only "~" and "~/…" are home shorthand; "~user" is left untouched.
31
+ const expand = (p) => {
32
+ if (!p) return p;
33
+ if (p === "~") return os.homedir();
34
+ if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
35
+ return p;
36
+ };
37
+ // Locale-independent sort, so listings are identical on every machine.
38
+ const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
39
+
40
+ function resolveHome() {
41
+ if (process.env.STACKDECK_HOME) return expand(process.env.STACKDECK_HOME);
42
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
43
+ const home = path.join(xdg, "stackdeck");
44
+ // One-time migration from pre-rename (devboard) and pre-0.1 (~/.devboard) locations.
45
+ for (const legacy of [path.join(xdg, "devboard"), path.join(os.homedir(), ".devboard")]) {
46
+ if (fs.existsSync(home) || !fs.existsSync(path.join(legacy, "config.json"))) continue;
47
+ try { fs.mkdirSync(path.dirname(home), { recursive: true }); fs.renameSync(legacy, home); }
48
+ catch { return legacy; } // cross-device or permission issue: stay on the old path
49
+ }
50
+ return home;
51
+ }
52
+
53
+ const HOME_DIR = resolveHome();
54
+ const CONFIG_PATH = path.join(HOME_DIR, "config.json");
55
+ const LOG_DIR = path.join(HOME_DIR, "logs");
56
+ // 0700: config holds env vars (often API keys) and logs hold whatever
57
+ // services print — none of it is for other users on a shared machine.
58
+ fs.mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
59
+ try { fs.chmodSync(HOME_DIR, 0o700); fs.chmodSync(LOG_DIR, 0o700); } catch {}
60
+
61
+ // Names end up in log-file paths and in the UI's inline handlers — the strict
62
+ // charset (enforced at API *and* load time) is a security invariant, not taste.
63
+ const validSvcName = (n) => typeof n === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(n);
64
+ const validLabel = (n) => typeof n === "string" && n.length >= 1 && n.length <= 64 && !/[<>"'\\\/\n]/.test(n);
65
+
66
+ // A corrupt config must not brick the daemon: set it aside and start fresh.
67
+ let cfg = {};
68
+ if (fs.existsSync(CONFIG_PATH)) {
69
+ try {
70
+ cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
71
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) throw new Error("not an object");
72
+ } catch (e) {
73
+ const broken = `${CONFIG_PATH}.broken-${Date.now()}`;
74
+ fs.renameSync(CONFIG_PATH, broken);
75
+ console.error(`config.json is invalid (${e.message}) — moved to ${broken}, starting with defaults`);
76
+ cfg = {};
77
+ }
78
+ }
79
+ // Normalize every field we index into; old or hand-edited configs stay safe.
80
+ cfg.port = Number.isInteger(cfg.port) && cfg.port >= 1 && cfg.port <= 65535 ? cfg.port : 8899;
81
+ cfg.services = Array.isArray(cfg.services)
82
+ ? cfg.services.filter((s) => {
83
+ const ok = s && typeof s === "object" && validSvcName(s.name);
84
+ if (!ok && s && s.name) console.error(`dropping service with invalid name from config: ${JSON.stringify(s.name)}`);
85
+ return ok;
86
+ }) : [];
87
+ cfg.groups = Array.isArray(cfg.groups) ? cfg.groups.filter(validLabel) : [];
88
+ cfg.projectRoots = Array.isArray(cfg.projectRoots) && cfg.projectRoots.length ? cfg.projectRoots : ["~/Projects"];
89
+
90
+ // Atomic write: a crash mid-save must never truncate the config.
91
+ const saveCfg = () => {
92
+ const tmp = `${CONFIG_PATH}.tmp`;
93
+ fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
94
+ fs.renameSync(tmp, CONFIG_PATH);
95
+ };
96
+ if (!fs.existsSync(CONFIG_PATH)) saveCfg();
97
+ try { fs.chmodSync(CONFIG_PATH, 0o600); } catch {} // tighten pre-existing files too
98
+
99
+ const findSvc = (name) => cfg.services.find((s) => s.name === name);
100
+ const svcDir = (s) => expand(s.dir);
101
+
102
+ /* ---------- auth token ----------
103
+ localhost is not a security boundary: any local process can reach this
104
+ port, and this daemon executes shell commands. A per-install secret
105
+ (0600, same-user readable) gates every API call. The web page receives
106
+ it by being served from disk by this same daemon. */
107
+ const crypto = require("crypto");
108
+ const SECRET_PATH = path.join(HOME_DIR, "secret");
109
+ let TOKEN = "";
110
+ try { TOKEN = fs.readFileSync(SECRET_PATH, "utf8").trim(); } catch {}
111
+ if (!TOKEN || TOKEN.length < 32) {
112
+ TOKEN = crypto.randomBytes(24).toString("hex");
113
+ fs.writeFileSync(SECRET_PATH, TOKEN + "\n", { mode: 0o600 });
114
+ }
115
+ function tokenOk(t) {
116
+ if (typeof t !== "string" || !t) return false;
117
+ const a = Buffer.from(t), b = Buffer.from(TOKEN);
118
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
119
+ }
120
+
121
+ /* ---------- environment for spawned services ---------- */
122
+
123
+ // GUI launches (Finder, systemd user units) hand daemons a bare PATH with no
124
+ // nvm/homebrew/uv. We want the user's interactive-shell PATH — that's where
125
+ // version managers initialize (.zshrc/.bashrc; plain -lc never reads them,
126
+ // and a login-only PATH can rank a stale system toolchain first).
127
+ //
128
+ // Resolution is ASYNC with a disk cache: an interactive shell under launchd
129
+ // can hang until its timeout, and the daemon must never block startup on it.
130
+ const { execFile } = require("child_process");
131
+ const SHELLPATH_CACHE = path.join(HOME_DIR, "shellpath");
132
+ let SHELL_PATH = process.env.PATH;
133
+ try {
134
+ const cached = fs.readFileSync(SHELLPATH_CACHE, "utf8").trim();
135
+ if (cached.split(":").length > 3) SHELL_PATH = cached;
136
+ } catch {}
137
+ (function refreshShellPath(i = 0) {
138
+ const shell = process.env.SHELL || "/bin/bash";
139
+ const flags = ["-ilc", "-lc"];
140
+ if (i >= flags.length) return;
141
+ // Interactive shells print noise (macOS "Restored session: …"), so the
142
+ // value is fenced with markers instead of trusting raw stdout.
143
+ execFile(shell, [flags[i], 'printf "__SD__%s__SD__" "$PATH"'], { timeout: 8000 }, (err, out) => {
144
+ const m = (out || "").toString().match(/__SD__([^]*?)__SD__/);
145
+ if (m && m[1].split(":").length > 3) {
146
+ SHELL_PATH = m[1];
147
+ for (const k of Object.keys(WHICH_CACHE)) delete WHICH_CACHE[k]; // re-detect tools with the real PATH
148
+ try { fs.writeFileSync(SHELLPATH_CACHE, SHELL_PATH + "\n", { mode: 0o600 }); } catch {}
149
+ } else refreshShellPath(i + 1);
150
+ });
151
+ })();
152
+
153
+ /* ---------- helpers ---------- */
154
+
155
+ function git(dir, ...args) {
156
+ try {
157
+ return { ok: true, out: execFileSync("git", ["-C", dir, ...args], { timeout: 8000 }).toString().trim() };
158
+ } catch (e) {
159
+ return { ok: false, out: (e.stderr || e.message || "").toString().trim() };
160
+ }
161
+ }
162
+
163
+ // One lsof/ss call covering ALL listeners, cached ~2.5s — the UI polls every
164
+ // service's port every 5s, and per-port subprocess spawns block the event loop.
165
+ let portCache = { t: 0, map: new Map() };
166
+ function listeningMap() {
167
+ if (Date.now() - portCache.t < 2500) return portCache.map;
168
+ const map = new Map();
169
+ try { // macOS + most Linux
170
+ const out = execFileSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-Fpn"], { timeout: 4000 }).toString();
171
+ let pid = null;
172
+ for (const line of out.split("\n")) {
173
+ if (line.startsWith("p")) pid = Number(line.slice(1));
174
+ else if (line.startsWith("n")) {
175
+ const m = line.match(/:(\d+)$/);
176
+ if (m && pid && !map.has(Number(m[1]))) map.set(Number(m[1]), pid);
177
+ }
178
+ }
179
+ } catch {
180
+ try { // Linux fallback (iproute2)
181
+ const out = execFileSync("ss", ["-ltnpH"], { timeout: 4000 }).toString();
182
+ for (const line of out.split("\n")) {
183
+ const m = line.match(/[:\]](\d+)\s.*pid=(\d+)/);
184
+ if (m && !map.has(Number(m[1]))) map.set(Number(m[1]), Number(m[2]));
185
+ }
186
+ } catch {}
187
+ }
188
+ portCache = { t: Date.now(), map };
189
+ return map;
190
+ }
191
+ const bustPortCache = () => { portCache.t = 0; };
192
+ function portPid(port) {
193
+ if (!port) return null;
194
+ return listeningMap().get(Number(port)) ?? null;
195
+ }
196
+
197
+ const MAX_BUF = 2000;
198
+ const nul = () => Object.create(null); // user input indexes these: no prototype keys
199
+ const procs = nul(); // name -> { child, startedAt, branch, stopping }
200
+ const buffers = nul(); // name -> [lines]
201
+ const clients = nul(); // name -> Set<res> (SSE)
202
+
203
+ /* Pids persist to disk so a restarted daemon re-adopts services it started
204
+ (children are detached on purpose: killing the daemon must not kill your
205
+ dev servers). Adopted services can't stream logs, but show as running and
206
+ can be killed — same as any external process, minus the guesswork. */
207
+ const PROCS_PATH = path.join(HOME_DIR, "procs.json");
208
+ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
209
+ const instances = nul(); // "svc@branch" -> { svc, branch, dir, port, startedAt, child? OR pid (adopted) }
210
+ const extKills = nul(); // name -> { pid, at } — recently killed externals, to spot supervisor resurrection
211
+ const instLive = (i) => (i.child ? i.child.exitCode === null : alive(i.pid));
212
+ const instPid = (i) => (i.child ? i.child.pid : i.pid);
213
+ const adopted = nul();
214
+ try {
215
+ const saved = JSON.parse(fs.readFileSync(PROCS_PATH, "utf8"));
216
+ const svc = saved.services || saved; // pre-0.3 format was a flat map of services
217
+ for (const [n, v] of Object.entries(svc || {}))
218
+ if (v && Number.isInteger(v.pid) && alive(v.pid)) adopted[n] = v;
219
+ for (const [k, v] of Object.entries(saved.instances || {}))
220
+ if (v && Number.isInteger(v.pid) && alive(v.pid)) instances[k] = v; // adopted instance: pid, no child
221
+ } catch {}
222
+ function saveProcs() {
223
+ const out = { services: {}, instances: {} };
224
+ for (const [n, p] of Object.entries(procs))
225
+ if (p.child.exitCode === null && p.child.pid) out.services[n] = { pid: p.child.pid, startedAt: p.startedAt, branch: p.branch };
226
+ for (const [n, v] of Object.entries(adopted)) if (!(n in out.services) && alive(v.pid)) out.services[n] = v;
227
+ for (const [k, i] of Object.entries(instances))
228
+ if (instLive(i)) out.instances[k] = { pid: instPid(i), svc: i.svc, branch: i.branch, dir: i.dir, port: i.port, startedAt: i.startedAt };
229
+ try { fs.writeFileSync(PROCS_PATH, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 }); } catch {}
230
+ }
231
+ function adoptedPid(name) {
232
+ const a = adopted[name];
233
+ if (!a) return null;
234
+ if (alive(a.pid)) return a.pid;
235
+ delete adopted[name];
236
+ return null;
237
+ }
238
+
239
+ // Disk logs: one append stream per service, rotated at 5MB (one .1 backup) so
240
+ // a chatty service can't eat the disk.
241
+ const logStreams = nul();
242
+ const logWrites = nul();
243
+ const MAX_LOG_BYTES = 5 * 1024 * 1024;
244
+ function logStream(name) {
245
+ if (!logStreams[name]) {
246
+ logStreams[name] = fs.createWriteStream(path.join(LOG_DIR, `${name}.log`), { flags: "a", mode: 0o600 });
247
+ logStreams[name].on("error", () => { delete logStreams[name]; }); // disk full etc. must not kill the daemon
248
+ }
249
+ return logStreams[name];
250
+ }
251
+ function maybeRotate(name) {
252
+ try {
253
+ const f = path.join(LOG_DIR, `${name}.log`);
254
+ if (fs.statSync(f).size > MAX_LOG_BYTES) {
255
+ logStreams[name]?.end();
256
+ delete logStreams[name];
257
+ fs.renameSync(f, `${f}.1`);
258
+ }
259
+ } catch {}
260
+ }
261
+ function pushLog(name, chunk) {
262
+ const lines = chunk.toString().split("\n").filter((l) => l.length);
263
+ const buf = (buffers[name] = buffers[name] || []);
264
+ for (const l of lines) {
265
+ buf.push(l);
266
+ if (buf.length > MAX_BUF) buf.shift();
267
+ }
268
+ logStream(name).write(chunk);
269
+ if ((logWrites[name] = (logWrites[name] || 0) + 1) % 500 === 0) maybeRotate(name);
270
+ notifyLogWaiters(name, lines); // readiness checks watch the live stream
271
+ for (const res of clients[name] || []) {
272
+ try { for (const l of lines) res.write(`data: ${JSON.stringify(l)}\n\n`); } catch {}
273
+ }
274
+ }
275
+
276
+ // Git state per directory, cached ~10s: three synchronous git spawns per
277
+ // service per 5s poll would stall the event loop (and every SSE stream).
278
+ const gitCache = new Map(); // dir -> { t, isGit, branch, branches, dirty }
279
+ function gitInfo(dir) {
280
+ const c = gitCache.get(dir);
281
+ if (c && Date.now() - c.t < 10000) return c;
282
+ const isGit = fs.existsSync(path.join(dir, ".git"));
283
+ const info = { t: Date.now(), isGit, branch: null, branches: [], dirty: false };
284
+ if (isGit) {
285
+ info.branch = gitBranchFast(dir) || "(detached)";
286
+ info.branches = (git(dir, "for-each-ref", "refs/heads", "--format=%(refname:short)").out || "").split("\n").filter(Boolean);
287
+ info.dirty = (git(dir, "status", "--porcelain").out || "") !== "";
288
+ }
289
+ gitCache.set(dir, info);
290
+ return info;
291
+ }
292
+
293
+ function serviceState(s) {
294
+ const dir = svcDir(s);
295
+ const p = procs[s.name];
296
+ const managedUp = p && p.child.exitCode === null;
297
+ const pid = managedUp ? p.child.pid : (portPid(s.port) ?? adoptedPid(s.name));
298
+ const rk = extKills[s.name];
299
+ const resurrected = !!(rk && !managedUp && pid && pid !== rk.pid && Date.now() - rk.at < 120000);
300
+ const g = gitInfo(dir);
301
+ return {
302
+ ...s,
303
+ running: managedUp || pid !== null,
304
+ managed: !!managedUp,
305
+ pid,
306
+ startedBranch: p ? p.branch : null,
307
+ startedAt: p ? p.startedAt : null,
308
+ lastExit: lastExit[s.name] || null,
309
+ resurrected,
310
+ restartPending: !!(restarts[s.name] && restarts[s.name].timer && !managedUp && restarts[s.name].n > 0 && restarts[s.name].n <= 5),
311
+ branch: g.branch, branches: g.branches, dirty: g.dirty, isGit: g.isGit,
312
+ };
313
+ }
314
+
315
+ /* ---------- project discovery ---------- */
316
+
317
+ let projCache = { t: 0, data: null };
318
+
319
+ // Fast branch read (no git spawn): parse .git/HEAD directly. Handles the
320
+ // worktree/submodule case where .git is a "gitdir: <path>" file.
321
+ function gitBranchFast(dir) {
322
+ try {
323
+ let g = path.join(dir, ".git");
324
+ if (fs.statSync(g).isFile()) {
325
+ const m = fs.readFileSync(g, "utf8").match(/^gitdir:\s*(.+?)\s*$/m);
326
+ if (!m) return null;
327
+ g = path.resolve(dir, m[1]);
328
+ }
329
+ const head = fs.readFileSync(path.join(g, "HEAD"), "utf8").trim();
330
+ return head.startsWith("ref: refs/heads/") ? head.slice("ref: refs/heads/".length) : "(detached)";
331
+ } catch { return null; }
332
+ }
333
+
334
+ /**
335
+ * Infer how to run a project. Priority: files that encode the author's intent
336
+ * (bin/dev, Makefile, justfile, Taskfile) → language manifests with their own
337
+ * toolchain detection → docker-compose as the last resort.
338
+ */
339
+ function inferCommand(dir) {
340
+ const has = (f) => fs.existsSync(path.join(dir, f));
341
+ const read = (f) => { try { return fs.readFileSync(path.join(dir, f), "utf8"); } catch { return ""; } };
342
+ // Tolerant JSON: one malformed manifest must not abort the remaining
343
+ // detectors. Parse as-is FIRST — comment-stripping breaks every URL
344
+ // ("https://…") in valid JSON — and only fall back to stripping for
345
+ // jsonc-style files.
346
+ const jread = (f) => {
347
+ const raw = read(f);
348
+ try { return JSON.parse(raw) || {}; } catch {}
349
+ try { return JSON.parse(raw.replace(/^\s*\/\/[^\n]*/gm, "")) || {}; } catch { return {}; }
350
+ };
351
+ const DEV_TARGETS = ["dev", "start", "run", "serve", "up"];
352
+ try {
353
+ /* -- author intent ------------------------------------------------ */
354
+ if (has("bin/dev")) return "bin/dev"; // Rails 7+ and friends
355
+ if (has("Makefile")) {
356
+ const mk = read("Makefile");
357
+ for (const t of DEV_TARGETS) if (new RegExp(`^${t}:`, "m").test(mk)) return `make ${t}`;
358
+ }
359
+ for (const jf of ["justfile", "Justfile", ".justfile"]) if (has(jf)) {
360
+ const j = read(jf);
361
+ for (const t of DEV_TARGETS) if (new RegExp(`^${t}[\\s:]`, "m").test(j)) return `just ${t}`;
362
+ }
363
+ for (const tf of ["Taskfile.yml", "Taskfile.yaml", "taskfile.yml"]) if (has(tf)) {
364
+ const y = read(tf);
365
+ for (const t of DEV_TARGETS) if (new RegExp(`^ ${t}:`, "m").test(y)) return `task ${t}`;
366
+ }
367
+
368
+ /* -- JavaScript / TypeScript -------------------------------------- */
369
+ if (has("package.json")) {
370
+ const pkg = jread("package.json");
371
+ const runner =
372
+ has("bun.lockb") || has("bun.lock") ? "bun" :
373
+ has("pnpm-lock.yaml") || has("pnpm-workspace.yaml") ? "pnpm" :
374
+ has("yarn.lock") ? "yarn" : "npm";
375
+ for (const s of ["dev", "start", "serve", "develop"])
376
+ if (pkg.scripts && pkg.scripts[s]) return `${runner} run ${s}`;
377
+ }
378
+ for (const dj of ["deno.json", "deno.jsonc"]) if (has(dj)) {
379
+ const tasks = jread(dj).tasks || {};
380
+ for (const t of DEV_TARGETS) if (tasks[t]) return `deno task ${t}`;
381
+ }
382
+
383
+ /* -- Python --------------------------------------------------------
384
+ Toolchain from lockfiles, entry point from convention. */
385
+ if (has("pyproject.toml") || has("requirements.txt") || has("Pipfile") || has("setup.py")) {
386
+ const toml = read("pyproject.toml");
387
+ const runner =
388
+ has("uv.lock") ? "uv run" :
389
+ /\[tool\.poetry\]/.test(toml) ? "poetry run" :
390
+ has("Pipfile") ? "pipenv run" :
391
+ has("pdm.lock") ? "pdm run" : null;
392
+ // A declared console script is the author's entry point — use it.
393
+ const scriptSec = toml.match(/\[(?:project|tool\.poetry)\.scripts\]\s*\n\s*([A-Za-z0-9_-]+)\s*=/);
394
+ if (runner && scriptSec) return `${runner} ${scriptSec[1]}`;
395
+ const py = runner ? `${runner} python` : "python3";
396
+ if (has("manage.py")) return `${py} manage.py runserver`; // Django
397
+ for (const m of ["main.py", "app.py", "server.py", "run.py", "api.py",
398
+ "src/main.py", "app/main.py"])
399
+ if (has(m)) return `${py} ${m}`;
400
+ }
401
+
402
+ /* -- systems & compiled ------------------------------------------- */
403
+ if (has("Cargo.toml")) return "cargo run";
404
+ if (has("go.mod")) return "go run .";
405
+ if (has("build.zig")) return "zig build run";
406
+ if (has("Package.swift")) return "swift run";
407
+ if ([".csproj", ".fsproj"].some((ext) => { try { return fs.readdirSync(dir).some((f) => f.endsWith(ext)); } catch { return false; } }))
408
+ return "dotnet run";
409
+
410
+ /* -- JVM ------------------------------------------------------------ */
411
+ if (has("gradlew") || has("build.gradle") || has("build.gradle.kts")) {
412
+ const g = read("build.gradle") + read("build.gradle.kts");
413
+ const w = has("gradlew") ? "./gradlew" : "gradle";
414
+ return /spring-boot/.test(g) ? `${w} bootRun` : `${w} run`;
415
+ }
416
+ if (has("pom.xml")) {
417
+ const w = has("mvnw") ? "./mvnw" : "mvn";
418
+ if (/spring-boot/.test(read("pom.xml"))) return `${w} spring-boot:run`;
419
+ }
420
+
421
+ /* -- Ruby / PHP / Elixir / Dart ------------------------------------ */
422
+ if (has("Gemfile")) {
423
+ if (has("config/application.rb")) return "bin/rails server"; // Rails
424
+ if (has("config.ru")) return "bundle exec rackup";
425
+ }
426
+ if (has("artisan")) return "php artisan serve"; // Laravel
427
+ if (has("composer.json")) {
428
+ const scripts = jread("composer.json").scripts || {};
429
+ for (const t of ["dev", "start", "serve"]) if (scripts[t]) return `composer run ${t}`;
430
+ if (has("index.php")) return "php -S localhost:8080";
431
+ }
432
+ if (has("mix.exs")) return /phoenix/.test(read("mix.exs")) ? "mix phx.server" : "mix run --no-halt";
433
+ if (has("pubspec.yaml")) return /^\s{2}flutter\s*:/m.test(read("pubspec.yaml")) ? "flutter run" : "dart run";
434
+ if (has("stack.yaml")) return "stack run";
435
+
436
+ /* -- containers last: only when nothing language-level matched ----- */
437
+ if (has("docker-compose.yml") || has("docker-compose.yaml") || has("compose.yml") || has("compose.yaml"))
438
+ return "docker compose up";
439
+ } catch {}
440
+ return null;
441
+ }
442
+
443
+ /**
444
+ * Multi-process repos: a Procfile, docker-compose services, or pnpm workspace
445
+ * packages mean one repo is really several services. Returns [{name, command,
446
+ * dir}] when there's more than one, else null.
447
+ */
448
+ function detectProcs(dir) {
449
+ const has = (f) => fs.existsSync(path.join(dir, f));
450
+ const read = (f) => { try { return fs.readFileSync(path.join(dir, f), "utf8"); } catch { return ""; } };
451
+ try {
452
+ for (const pf of ["Procfile.dev", "Procfile"]) if (has(pf)) {
453
+ const procs = [];
454
+ for (const line of read(pf).split("\n")) {
455
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.+?)\s*$/);
456
+ if (m && !m[2].startsWith("#")) procs.push({ name: m[1], command: m[2], dir });
457
+ }
458
+ if (procs.length > 1) return procs;
459
+ }
460
+ if (has("pnpm-workspace.yaml")) {
461
+ const procs = [];
462
+ const globs = [...read("pnpm-workspace.yaml").matchAll(/^\s*-\s*['"]?([^'"\s#]+)/gm)].map((m) => m[1]);
463
+ for (const g of globs) {
464
+ const bases = g.endsWith("/*") ? (() => {
465
+ try {
466
+ return fs.readdirSync(path.join(dir, g.slice(0, -2)), { withFileTypes: true })
467
+ .filter((e) => e.isDirectory()).map((e) => path.join(g.slice(0, -2), e.name));
468
+ } catch { return []; }
469
+ })() : [g];
470
+ for (const rel of bases) {
471
+ try {
472
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, rel, "package.json"), "utf8"));
473
+ const script = pkg.scripts && (pkg.scripts.dev ? "dev" : pkg.scripts.start ? "start" : null);
474
+ if (script && pkg.name) procs.push({ name: path.basename(rel), command: `pnpm -F ${pkg.name} ${script}`, dir });
475
+ } catch {}
476
+ }
477
+ }
478
+ if (procs.length > 1) return procs;
479
+ }
480
+ for (const cf of ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]) if (has(cf)) {
481
+ const procs = [];
482
+ let inServices = false;
483
+ for (const line of read(cf).split("\n")) {
484
+ if (/^services:\s*$/.test(line)) { inServices = true; continue; }
485
+ if (inServices && /^[A-Za-z#]/.test(line)) inServices = false; // dedent = section over
486
+ const m = inServices && line.match(/^ {2}([A-Za-z0-9._-]+):\s*$/);
487
+ if (m) procs.push({ name: m[1], command: `docker compose up ${m[1]}`, dir });
488
+ }
489
+ if (procs.length > 1) return procs;
490
+ }
491
+ } catch {}
492
+ return null;
493
+ }
494
+
495
+ function scanProjects() {
496
+ const out = [];
497
+ const excluded = new Set((cfg.excludes || []).map(expand));
498
+ const pc = cfg.projectCategories || {};
499
+ const entry = (dir, name, root, catKeys, isGit, cmd) => ({
500
+ name,
501
+ dir,
502
+ root,
503
+ isGit,
504
+ branch: gitBranchFast(dir),
505
+ suggestedCommand: cmd,
506
+ procs: detectProcs(dir),
507
+ configured: cfg.services.some((s) => svcDir(s) === dir || svcDir(s).startsWith(dir + path.sep)),
508
+ category: catKeys.map((k) => pc[k]).find(Boolean) || "Uncategorized",
509
+ });
510
+ const lsDirs = (p) => {
511
+ try { return fs.readdirSync(p, { withFileTypes: true }).filter((x) => x.isDirectory() && !x.name.startsWith(".")); }
512
+ catch { return []; }
513
+ };
514
+ for (const root of cfg.projectRoots.map(expand)) {
515
+ for (const e of lsDirs(root)) {
516
+ const dir = path.join(root, e.name);
517
+ if (excluded.has(dir)) continue;
518
+ // A real project: it's a git repo or we know how to run it.
519
+ const isGit = fs.existsSync(path.join(dir, ".git"));
520
+ const cmd = inferCommand(dir);
521
+ if (isGit || cmd) {
522
+ out.push(entry(dir, e.name, root, [e.name], isGit, cmd));
523
+ continue;
524
+ }
525
+ // Container folder: not a repo itself, but may hold repos one level
526
+ // down (e.g. work/team-api). Surface those instead, inheriting the
527
+ // container's category unless mapped individually.
528
+ const subs = lsDirs(dir)
529
+ .map((s) => {
530
+ const sdir = path.join(dir, s.name);
531
+ if (excluded.has(sdir)) return null;
532
+ const sGit = fs.existsSync(path.join(sdir, ".git"));
533
+ const sCmd = inferCommand(sdir);
534
+ return sGit || sCmd ? { sdir, sGit, sCmd } : null;
535
+ })
536
+ .filter(Boolean);
537
+ if (subs.length) {
538
+ for (const { sdir, sGit, sCmd } of subs) {
539
+ const sub = path.basename(sdir);
540
+ out.push(entry(sdir, `${e.name}/${sub}`, root, [`${e.name}/${sub}`, sub, e.name], sGit, sCmd));
541
+ }
542
+ } else {
543
+ out.push(entry(dir, e.name, root, [e.name], false, null)); // plain folder, listed as-is
544
+ }
545
+ }
546
+ }
547
+ out.sort((a, b) => (b.suggestedCommand ? 1 : 0) - (a.suggestedCommand ? 1 : 0) || byName(a.name, b.name));
548
+ return out;
549
+ }
550
+
551
+ /* ---------- dev-tool (infra) detection ----------
552
+ DBngin-style: databases and brokers run as ordinary foreground services —
553
+ managed child, streamed logs, clean kill — no daemons, no Docker required. */
554
+
555
+ const DATA_HOME = path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"), "stackdeck");
556
+ const firstDir = (cands) => cands.map(expand).find((c) => c && fs.existsSync(c)) || null;
557
+ const own = (sub) => `mkdir -p '${DATA_HOME}/${sub}' && `; // self-initializing data dir (quoted: spaces in $HOME)
558
+
559
+ const INFRA = () => [
560
+ { name: "postgres", title: "PostgreSQL", bin: "postgres", port: 5432,
561
+ command: (() => {
562
+ const d = firstDir([process.env.PGDATA,
563
+ "/opt/homebrew/var/postgresql@18", "/opt/homebrew/var/postgresql@17", "/opt/homebrew/var/postgresql@16",
564
+ "/opt/homebrew/var/postgresql@15", "/opt/homebrew/var/postgresql@14", "/opt/homebrew/var/postgres",
565
+ "/usr/local/var/postgresql@17", "/usr/local/var/postgresql@16", "/usr/local/var/postgres",
566
+ "/var/lib/postgresql/data", "/var/lib/postgres/data"]);
567
+ return d ? `postgres -D '${d}'` : "postgres -D <your-data-dir> # run initdb first";
568
+ })() },
569
+ { name: "mysql", title: "MySQL", bin: "mysqld", port: 3306, command: "mysqld" },
570
+ { name: "mariadb", title: "MariaDB", bin: "mariadbd", port: 3306, command: "mariadbd" },
571
+ { name: "mongodb", title: "MongoDB", bin: "mongod", port: 27017,
572
+ command: (() => {
573
+ const d = firstDir(["/opt/homebrew/var/mongodb", "/usr/local/var/mongodb", "/var/lib/mongodb"]);
574
+ return d ? `mongod --dbpath '${d}'` : `${own("mongodb")}mongod --dbpath '${DATA_HOME}/mongodb'`;
575
+ })() },
576
+ { name: "redis", title: "Redis", bin: "redis-server", port: 6379, command: "redis-server" },
577
+ { name: "valkey", title: "Valkey", bin: "valkey-server", port: 6379, command: "valkey-server" },
578
+ { name: "memcached", title: "Memcached", bin: "memcached", port: 11211, command: "memcached -v" },
579
+ { name: "elasticsearch", title: "Elasticsearch", bin: "elasticsearch", port: 9200, command: "elasticsearch" },
580
+ { name: "opensearch", title: "OpenSearch", bin: "opensearch", port: 9200, command: "opensearch" },
581
+ { name: "rabbitmq", title: "RabbitMQ", bin: "rabbitmq-server", port: 5672, command: "rabbitmq-server" },
582
+ { name: "nats", title: "NATS", bin: "nats-server", port: 4222, command: "nats-server" },
583
+ { name: "minio", title: "MinIO", bin: "minio", port: 9000, command: `${own("minio")}minio server '${DATA_HOME}/minio'` },
584
+ { name: "temporal", title: "Temporal (dev)", bin: "temporal", port: 7233, command: "temporal server start-dev" },
585
+ { name: "clickhouse", title: "ClickHouse", bin: "clickhouse", port: 8123, command: "clickhouse server" },
586
+ { name: "mailpit", title: "Mailpit", bin: "mailpit", port: 8025, command: "mailpit" },
587
+ ];
588
+
589
+ const WHICH_CACHE = nul();
590
+ function which(bin) {
591
+ if (bin in WHICH_CACHE) return WHICH_CACHE[bin];
592
+ try {
593
+ WHICH_CACHE[bin] = execFileSync("bash", ["-c", `command -v ${bin}`],
594
+ { env: { ...process.env, PATH: SHELL_PATH }, timeout: 3000 }).toString().trim() || null;
595
+ } catch { WHICH_CACHE[bin] = null; }
596
+ return WHICH_CACHE[bin];
597
+ }
598
+
599
+ /* ---------- actions ---------- */
600
+
601
+ // Minimal .env parser: KEY=value lines, optional `export `, quotes stripped,
602
+ // #-comments ignored. No interpolation — this is a loader, not a shell.
603
+ function parseEnvFile(file) {
604
+ const out = {};
605
+ try {
606
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
607
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
608
+ if (!m || m[2].startsWith("#")) continue;
609
+ let v = m[2];
610
+ const q = v.match(/^(['"])(.*?)\1/); // quoted value; anything after the close quote is ignored
611
+ out[m[1]] = q ? q[2] : v.replace(/\s+#.*$/, "");
612
+ }
613
+ } catch {}
614
+ return out;
615
+ }
616
+
617
+ async function startService(s, branch, force) {
618
+ const dir = svcDir(s);
619
+ if (procs[s.name] && procs[s.name].child.exitCode === null)
620
+ return { code: 409, error: `${s.name} is already running (managed)`, already: true };
621
+ if (adoptedPid(s.name))
622
+ return { code: 409, error: `${s.name} is already running (from a previous daemon) — Kill it first`, already: true };
623
+ bustPortCache(); // the busy check must not act on stale data
624
+ let busy = portPid(s.port);
625
+ if (busy && force) { // user confirmed: evict the squatter, then take the port
626
+ try { process.kill(busy, "SIGTERM"); } catch {}
627
+ for (let i = 0; i < 15 && busy; i++) {
628
+ await new Promise((ok) => setTimeout(ok, 200));
629
+ bustPortCache();
630
+ busy = portPid(s.port);
631
+ }
632
+ if (busy) return { code: 409, error: `pid ${busy} did not release port ${s.port}` };
633
+ pushLog(s.name, `[stackdeck] killed external process squatting port ${s.port}\n`);
634
+ }
635
+ if (busy) return { code: 409, error: `port ${s.port} is busy (pid ${busy}, external) — kill it first`, busyPid: busy };
636
+ if (!fs.existsSync(dir)) return { code: 400, error: `directory not found: ${dir}` };
637
+
638
+ if (branch) {
639
+ // Only known local branches: a value like "-f" must never reach git argv.
640
+ const g = gitInfo(dir);
641
+ if (!g.isGit) return { code: 400, error: "not a git repository" };
642
+ if (!g.branches.includes(branch)) return { code: 400, error: `unknown branch '${branch}'` };
643
+ if (branch !== g.branch) {
644
+ if ((git(dir, "status", "--porcelain").out || "") !== "")
645
+ return { code: 409, error: `cannot switch to '${branch}': working tree has uncommitted changes` };
646
+ const co = git(dir, "checkout", branch);
647
+ if (!co.ok) return { code: 500, error: `git checkout ${branch} failed: ${co.out}` };
648
+ gitCache.delete(dir);
649
+ pushLog(s.name, `[stackdeck] checked out branch '${branch}'\n`);
650
+ }
651
+ }
652
+
653
+ // Env precedence: daemon env < <dir>/.env (auto-loaded; set envFile:false
654
+ // to skip, or a path to use a different file) < the service's own env.
655
+ const envFile = s.envFile === false ? null : path.join(dir, s.envFile || ".env");
656
+ const fileEnv = envFile && fs.existsSync(envFile) ? parseEnvFile(envFile) : {};
657
+ const n = Object.keys(fileEnv).length;
658
+ if (n) pushLog(s.name, `[stackdeck] loaded ${path.basename(envFile)} (${n} vars)\n`);
659
+
660
+ // Non-login, non-interactive shell: inherits the daemon's resolved PATH and
661
+ // avoids surprises from profile files reordering toolchains.
662
+ const child = spawn("bash", ["-c", s.command], {
663
+ cwd: dir,
664
+ env: { ...process.env, PATH: SHELL_PATH, ...fileEnv, ...(s.env || {}) },
665
+ detached: true,
666
+ stdio: ["ignore", "pipe", "pipe"],
667
+ });
668
+ child.stdout.on("data", (d) => pushLog(s.name, d));
669
+ child.stderr.on("data", (d) => pushLog(s.name, d));
670
+ // A spawn 'error' (cwd vanished, bash missing) is an event, not an exception —
671
+ // unhandled it would take down the whole daemon.
672
+ child.on("error", (e) => {
673
+ pushLog(s.name, `[stackdeck] failed to start: ${e.message}\n`);
674
+ delete procs[s.name];
675
+ saveProcs();
676
+ });
677
+ const rec = { child, startedAt: Date.now(), branch: branch || null, stopping: false };
678
+ child.on("exit", (code, sig) => {
679
+ pushLog(s.name, `[stackdeck] exited (code=${code} signal=${sig})\n`);
680
+ lastExit[s.name] = { code, sig, at: Date.now(), expected: rec.stopping };
681
+ delete procs[s.name];
682
+ saveProcs();
683
+ maybeAutoRestart(s, rec, code);
684
+ });
685
+ procs[s.name] = rec;
686
+ delete lastExit[s.name];
687
+ const rt = restarts[s.name];
688
+ if (rt) { clearTimeout(rt.timer); if (!rt.auto) restarts[s.name] = { n: 0 }; }
689
+ saveProcs();
690
+ pushLog(s.name, `[stackdeck] started: ${s.command} (pid ${child.pid})\n`);
691
+ return { code: 200, ok: true, pid: child.pid };
692
+ }
693
+
694
+ /* restart: "on-failure" — exponential backoff, attempts reset after a minute
695
+ of clean uptime; manual stops never trigger it. */
696
+ const lastExit = nul(); // name -> { code, sig, at, expected }
697
+ const restarts = nul(); // name -> { n, timer, auto }
698
+ function maybeAutoRestart(s, rec, code) {
699
+ if (s.restart !== "on-failure" || rec.stopping || code === 0) return;
700
+ const uptime = Date.now() - rec.startedAt;
701
+ const r = (restarts[s.name] = restarts[s.name] || { n: 0 });
702
+ if (uptime > 60000) r.n = 0;
703
+ if (r.n >= 5) {
704
+ pushLog(s.name, `[stackdeck] crashed ${r.n} times — giving up (edit the service to reset)\n`);
705
+ return;
706
+ }
707
+ const delay = Math.min(30000, 1000 * 2 ** r.n);
708
+ r.n += 1;
709
+ pushLog(s.name, `[stackdeck] crashed (code=${code}) — restarting in ${delay / 1000}s (attempt ${r.n}/5)\n`);
710
+ r.timer = setTimeout(async () => {
711
+ r.auto = true;
712
+ const cur = findSvc(s.name);
713
+ if (cur && !(procs[s.name] && procs[s.name].child.exitCode === null)) await startService(cur, rec.branch);
714
+ r.auto = false;
715
+ }, delay);
716
+ r.timer.unref();
717
+ }
718
+
719
+ /* Readiness: a service is "ready" when its readyWhen condition holds —
720
+ { "log": "<regex>" } (matched against live output), { "http": "<url>" }
721
+ (2xx/3xx), or, by default, its port accepting connections. */
722
+ const readyWaiters = nul(); // name -> [{ regex, resolve }]
723
+ function notifyLogWaiters(name, lines) {
724
+ const ws = readyWaiters[name];
725
+ if (!ws || !ws.length) return;
726
+ for (const w of [...ws]) {
727
+ if (lines.some((l) => w.regex.test(l))) {
728
+ w.resolve(true);
729
+ ws.splice(ws.indexOf(w), 1);
730
+ }
731
+ }
732
+ }
733
+ async function waitReady(s, timeoutMs = 60000) {
734
+ const until = Date.now() + timeoutMs;
735
+ const rw = s.readyWhen || {};
736
+ if (rw.log) {
737
+ let regex;
738
+ try { regex = new RegExp(rw.log); } catch { return { ok: false, why: `bad readyWhen.log regex` }; }
739
+ if ((buffers[s.name] || []).slice(-200).some((l) => regex.test(l))) return { ok: true };
740
+ return await new Promise((resolve) => {
741
+ const w = { regex, resolve: () => resolve({ ok: true }) };
742
+ (readyWaiters[s.name] = readyWaiters[s.name] || []).push(w);
743
+ setTimeout(() => {
744
+ const ws = readyWaiters[s.name] || [];
745
+ const i = ws.indexOf(w);
746
+ if (i >= 0) { ws.splice(i, 1); resolve({ ok: false, why: "log pattern not seen in time" }); }
747
+ }, timeoutMs).unref();
748
+ });
749
+ }
750
+ if (rw.http) {
751
+ while (Date.now() < until) {
752
+ const ok = await new Promise((resolve) => {
753
+ try {
754
+ const mod = rw.http.startsWith("https:") ? require("https") : http;
755
+ const req = mod.get(rw.http, { timeout: 2000 }, (r) => { r.resume(); resolve(r.statusCode < 400); });
756
+ req.on("error", () => resolve(false));
757
+ req.on("timeout", () => { req.destroy(); resolve(false); });
758
+ } catch { resolve(false); }
759
+ });
760
+ if (ok) return { ok: true };
761
+ if (!(procs[s.name] && procs[s.name].child.exitCode === null)) return { ok: false, why: "process exited" };
762
+ await new Promise((r) => setTimeout(r, 500));
763
+ }
764
+ return { ok: false, why: "http check never passed" };
765
+ }
766
+ if (s.port) {
767
+ while (Date.now() < until) {
768
+ bustPortCache();
769
+ if (portPid(s.port)) return { ok: true };
770
+ if (!(procs[s.name] && procs[s.name].child.exitCode === null)) return { ok: false, why: "process exited" };
771
+ await new Promise((r) => setTimeout(r, 500));
772
+ }
773
+ return { ok: false, why: `port ${s.port} never opened` };
774
+ }
775
+ await new Promise((r) => setTimeout(r, 1000)); // no signal at all: brief grace
776
+ return { ok: true };
777
+ }
778
+
779
+ /* Dependency-ordered startup: topo-sort dependsOn, start each level, wait for
780
+ readiness before starting dependents. Cycles fail loudly. */
781
+ async function startWithDeps(names, force) {
782
+ const results = {};
783
+ const visiting = new Set(), done = new Set();
784
+ const startOne = async (name, chain) => {
785
+ if (done.has(name)) return true;
786
+ if (visiting.has(name)) { results[name] = { error: `dependency cycle: ${[...chain, name].join(" → ")}` }; return false; }
787
+ visiting.add(name);
788
+ const s = findSvc(name);
789
+ if (!s) { results[name] = { error: "unknown service" }; visiting.delete(name); return false; }
790
+ for (const dep of s.dependsOn || []) {
791
+ if (!(await startOne(dep, [...chain, name]))) {
792
+ results[name] = results[name] || { error: `dependency '${dep}' failed` };
793
+ visiting.delete(name);
794
+ return false;
795
+ }
796
+ }
797
+ const alreadyUp = (procs[name] && procs[name].child.exitCode === null) || portPid(s.port) || adoptedPid(name);
798
+ if (!alreadyUp) {
799
+ const r = await startService(s, undefined, force);
800
+ if (r.error && !r.already) { results[name] = { error: r.error }; visiting.delete(name); return false; }
801
+ const ready = await waitReady(s);
802
+ if (!ready.ok) { results[name] = { error: `started but not ready: ${ready.why}` }; visiting.delete(name); return false; }
803
+ results[name] = { ok: true, started: true };
804
+ } else {
805
+ results[name] = { ok: true, alreadyRunning: true };
806
+ }
807
+ visiting.delete(name);
808
+ done.add(name);
809
+ return true;
810
+ };
811
+ for (const n of names) await startOne(n, []);
812
+ return results;
813
+ }
814
+
815
+ async function stopService(s) {
816
+ const rt = restarts[s.name];
817
+ if (rt) { clearTimeout(rt.timer); rt.n = 0; } // a manual stop cancels pending auto-restarts
818
+ const p = procs[s.name];
819
+ if (p && p.child.exitCode === null) {
820
+ p.stopping = true; // suppresses crash notification + on-failure restart
821
+ const child = p.child, pid = child.pid;
822
+ try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch {} }
823
+ // Escalate only if OUR child is still alive — never fire a blind kill at a
824
+ // pid that may have been reused by an unrelated process.
825
+ setTimeout(() => {
826
+ if (child.exitCode === null) { try { process.kill(-pid, "SIGKILL"); } catch {} }
827
+ }, 5000).unref();
828
+ return { code: 200, ok: true, stopped: pid };
829
+ }
830
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
831
+ const adPid = adoptedPid(s.name);
832
+ if (adPid) { // started by a previous daemon instance: we know its group
833
+ try { process.kill(-adPid, "SIGTERM"); } catch { try { process.kill(adPid, "SIGTERM"); } catch {} }
834
+ delete adopted[s.name];
835
+ saveProcs();
836
+ bustPortCache();
837
+ pushLog(s.name, `[stackdeck] stopped pid ${adPid} (adopted from a previous daemon)\n`);
838
+ return { code: 200, ok: true, stopped: adPid, adopted: true };
839
+ }
840
+ bustPortCache();
841
+ const ext = portPid(s.port);
842
+ if (ext) {
843
+ // External processes may be supervised (brew services/launchd/systemd):
844
+ // verify the kill sticks, escalate politely, and report resurrection
845
+ // honestly instead of letting the row quietly turn green again.
846
+ try { process.kill(ext, "SIGTERM"); } catch (e) { return { code: 500, error: `kill ${ext} failed: ${e.message}` }; }
847
+ let now = ext;
848
+ for (let i = 0; i < 8 && now === ext; i++) { await sleep(250); bustPortCache(); now = portPid(s.port); }
849
+ if (now === ext) { // SIGTERM ignored — postgres, for one, "smart-waits"; SIGINT is its fast shutdown
850
+ try { process.kill(ext, "SIGINT"); } catch {}
851
+ for (let i = 0; i < 8 && now === ext; i++) { await sleep(250); bustPortCache(); now = portPid(s.port); }
852
+ }
853
+ if (now === ext)
854
+ return { code: 409, error: `pid ${ext} ignored SIGTERM and SIGINT — it looks like a system-managed daemon; stop it with its own manager (e.g. brew services stop …)` };
855
+ pushLog(s.name, `[stackdeck] killed external pid ${ext} on port ${s.port}\n`);
856
+ extKills[s.name] = { pid: ext, at: Date.now() };
857
+ if (now) { // something already took the port back: a supervisor restarted it
858
+ pushLog(s.name, `[stackdeck] a supervisor restarted it as pid ${now}\n`);
859
+ return { code: 200, ok: true, stopped: ext, resurrected: now,
860
+ note: `${s.name}: killed pid ${ext}, but a supervisor (launchd/brew?) restarted it as pid ${now} — stop it with its own manager instead` };
861
+ }
862
+ return { code: 200, ok: true, stopped: ext, external: true };
863
+ }
864
+ return { code: 409, error: `${s.name} is not running` };
865
+ }
866
+
867
+ async function restartService(s) {
868
+ const wasBranch = procs[s.name] ? procs[s.name].branch : null;
869
+ const r = await stopService(s);
870
+ if (r.error && r.code !== 409) return r;
871
+ for (let i = 0; i < 30; i++) { // wait up to ~6s for the port/process to clear
872
+ const p = procs[s.name];
873
+ bustPortCache();
874
+ if ((!p || p.child.exitCode !== null) && !portPid(s.port) && !adoptedPid(s.name)) break;
875
+ await new Promise((ok) => setTimeout(ok, 200));
876
+ }
877
+ return startService(s, wasBranch);
878
+ }
879
+
880
+ /* ---------- worktree instances ----------
881
+ Run ANOTHER branch of a service in parallel: a git worktree gives it its
882
+ own checkout, a free port is injected as $PORT. Instances are ephemeral
883
+ (they do not survive daemon restarts) and live under the data dir. */
884
+ function freePortFrom(base) {
885
+ bustPortCache();
886
+ const taken = new Set(listeningMap().keys());
887
+ for (const i of Object.values(instances)) if (instLive(i) && i.port) taken.add(i.port); // not yet bound ≠ free
888
+ for (const s of cfg.services) if (s.port) taken.add(Number(s.port));
889
+ for (let p = base; p < base + 200; p++) if (!taken.has(p)) return p;
890
+ return null;
891
+ }
892
+ function startWorktree(s, branch) {
893
+ const mainDir = svcDir(s);
894
+ const g = gitInfo(mainDir);
895
+ if (!g.isGit) return { code: 400, error: "not a git repository" };
896
+ if (!g.branches.includes(branch)) return { code: 400, error: `unknown branch '${branch}'` };
897
+ const safeBranch = branch.replace(/[^A-Za-z0-9._-]+/g, "-");
898
+ const key = `${s.name}@${safeBranch}`; // also the log-file name — must stay path-safe
899
+ if (instances[key] && instLive(instances[key]))
900
+ return { code: 409, error: `${key} is already running`, already: true };
901
+ const wtDir = path.join(DATA_HOME, "worktrees", s.name, safeBranch);
902
+ if (!fs.existsSync(wtDir)) {
903
+ fs.mkdirSync(path.dirname(wtDir), { recursive: true });
904
+ let r = git(mainDir, "worktree", "add", wtDir, branch);
905
+ if (!r.ok) { // a hand-deleted worktree leaves stale metadata that blocks re-adding
906
+ git(mainDir, "worktree", "prune");
907
+ r = git(mainDir, "worktree", "add", wtDir, branch);
908
+ }
909
+ if (!r.ok) {
910
+ try { fs.rmdirSync(path.dirname(wtDir)); } catch {} // only removes if empty
911
+ return { code: 500, error: `git worktree add: ${r.out.split("\n").pop()}` };
912
+ }
913
+ }
914
+ const port = s.port ? freePortFrom(Number(s.port) + 1) : null;
915
+ // .env is usually gitignored, so a fresh worktree has none — fall back to
916
+ // the main checkout's. (That's the ".env collision" this feature exists for.)
917
+ let envFile = null;
918
+ if (s.envFile !== false) {
919
+ const rel = s.envFile || ".env";
920
+ envFile = fs.existsSync(path.join(wtDir, rel)) ? path.join(wtDir, rel)
921
+ : fs.existsSync(path.join(mainDir, rel)) ? path.join(mainDir, rel) : null;
922
+ }
923
+ const fileEnv = envFile ? parseEnvFile(envFile) : {};
924
+ if (envFile && envFile.startsWith(mainDir)) pushLog(key, `[stackdeck] using .env from the main checkout\n`);
925
+ const child = spawn("bash", ["-c", s.command], {
926
+ cwd: wtDir,
927
+ env: { ...process.env, PATH: SHELL_PATH, ...fileEnv, ...(s.env || {}), ...(port ? { PORT: String(port) } : {}) },
928
+ detached: true,
929
+ stdio: ["ignore", "pipe", "pipe"],
930
+ });
931
+ child.stdout.on("data", (d) => pushLog(key, d));
932
+ child.stderr.on("data", (d) => pushLog(key, d));
933
+ child.on("error", (e) => { pushLog(key, `[stackdeck] failed to start: ${e.message}\n`); delete instances[key]; saveProcs(); });
934
+ child.on("exit", (code, sig) => { pushLog(key, `[stackdeck] exited (code=${code} signal=${sig})\n`); delete instances[key]; saveProcs(); });
935
+ instances[key] = { svc: s.name, branch, dir: wtDir, port, child, startedAt: Date.now() };
936
+ saveProcs();
937
+ pushLog(key, `[stackdeck] worktree instance: branch '${branch}'${port ? `, PORT=${port}` : ""}, ${wtDir}\n`);
938
+ return { code: 200, ok: true, key, port, pid: child.pid };
939
+ }
940
+
941
+ /* ---------- http ---------- */
942
+
943
+ const MAX_BODY = 1024 * 1024;
944
+ // Collect Buffers and decode ONCE — per-chunk decoding corrupts multi-byte
945
+ // characters (emoji, non-Latin scripts) that land on a chunk boundary.
946
+ const readBody = (req) => new Promise((resolve) => {
947
+ const chunks = [];
948
+ let len = 0;
949
+ req.on("data", (c) => {
950
+ chunks.push(c);
951
+ len += c.length;
952
+ if (len > MAX_BODY) { resolve({}); req.destroy(); }
953
+ });
954
+ req.on("end", () => {
955
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); } catch { resolve({}); }
956
+ });
957
+ req.on("error", () => resolve({}));
958
+ });
959
+ const validEnv = (v) => v && typeof v === "object" && !Array.isArray(v) &&
960
+ Object.entries(v).every(([k, val]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k) && typeof val === "string");
961
+ const json = (res, code, obj) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); };
962
+
963
+ const envPort = Number(process.env.STACKDECK_PORT);
964
+ const PORT = Number.isInteger(envPort) && envPort >= 1 && envPort <= 65535 ? envPort : cfg.port;
965
+ const ALLOWED_HOSTS = new Set([`localhost:${PORT}`, `127.0.0.1:${PORT}`, `[::1]:${PORT}`]);
966
+
967
+ // maxHeaderSize: browsers can carry >16KB of localhost cookies from other dev
968
+ // apps, which would hit Node's default header limit and 431 every request.
969
+ const handle = async (req, res) => {
970
+ const url = new URL(req.url, "http://localhost");
971
+
972
+ // This daemon executes shell commands, so it must only answer its own page
973
+ // and local tools — never a random website the user has open:
974
+ // 1. Host pinning: defeats DNS-rebinding (attacker.com resolving to 127.0.0.1).
975
+ // 2. JSON-only POSTs: a cross-origin JSON POST triggers a CORS preflight,
976
+ // which we never answer, so browsers block it. text/plain sneak-POSTs
977
+ // are rejected here.
978
+ // 3. Origin check: if a browser attaches an Origin, it must be ours.
979
+ if (!ALLOWED_HOSTS.has(req.headers.host)) return json(res, 403, { error: "unrecognized Host" });
980
+ if (req.method === "POST") {
981
+ const ct = (req.headers["content-type"] || "").split(";")[0].trim();
982
+ if (ct !== "application/json") return json(res, 415, { error: "content-type must be application/json" });
983
+ const origin = req.headers.origin;
984
+ if (origin && origin !== `http://${req.headers.host}`) return json(res, 403, { error: "cross-origin request rejected" });
985
+ }
986
+
987
+ if (req.method === "GET" && url.pathname === "/") {
988
+ res.writeHead(200, { "Content-Type": "text/html" });
989
+ return res.end(fs.readFileSync(path.join(ROOT, "index.html"), "utf8").replace("__STACKDECK_TOKEN__", TOKEN));
990
+ }
991
+
992
+ // Everything under /api needs the per-install token (header, or ?t= for
993
+ // EventSource which cannot set headers). Only the bare liveness ping is open.
994
+ if (req.method === "GET" && url.pathname === "/api/ping")
995
+ return json(res, 200, { ok: true, version: VERSION });
996
+ if (url.pathname.startsWith("/api/")) {
997
+ const t = req.headers["x-stackdeck-token"] || url.searchParams.get("t");
998
+ if (!tokenOk(t)) return json(res, 401, { error: "missing or invalid token — reload the page" });
999
+ }
1000
+
1001
+ if (req.method === "GET" && url.pathname === "/api/meta")
1002
+ return json(res, 200, {
1003
+ version: VERSION,
1004
+ home: os.homedir(),
1005
+ configPath: CONFIG_PATH,
1006
+ logDir: LOG_DIR,
1007
+ projectRoots: cfg.projectRoots,
1008
+ categoryOrder: cfg.categoryOrder || [],
1009
+ excludes: cfg.excludes || [],
1010
+ proxyPort: PROXY_PORT,
1011
+ });
1012
+
1013
+ if (req.method === "POST" && url.pathname === "/api/config") {
1014
+ // UI-editable settings. Only whitelisted fields; strings trimmed, empties dropped.
1015
+ const b = await readBody(req);
1016
+ const strList = (v) => Array.isArray(v) ? v.map((x) => String(x).trim()).filter(Boolean) : null;
1017
+ const roots = strList(b.projectRoots);
1018
+ if (roots) {
1019
+ if (!roots.length) return json(res, 400, { error: "at least one project root is required" });
1020
+ const missing = roots.filter((r) => !fs.existsSync(expand(r)));
1021
+ if (missing.length) return json(res, 400, { error: `not a directory: ${missing.join(", ")}` });
1022
+ cfg.projectRoots = roots;
1023
+ }
1024
+ const order = strList(b.categoryOrder);
1025
+ if (order !== null) cfg.categoryOrder = order;
1026
+ const excludes = strList(b.excludes);
1027
+ if (excludes !== null) cfg.excludes = excludes;
1028
+ saveCfg();
1029
+ projCache.t = 0;
1030
+ return json(res, 200, { ok: true });
1031
+ }
1032
+
1033
+ if (req.method === "GET" && url.pathname === "/api/fs") {
1034
+ // List subdirectories — powers the folder browser. Browsing is limited to
1035
+ // the home directory and configured roots; the daemon user can read more,
1036
+ // but the API shouldn't hand it out.
1037
+ const p = path.resolve(expand(url.searchParams.get("path") || "~"));
1038
+ const allowedUnder = [os.homedir(), ...cfg.projectRoots.map((r) => path.resolve(expand(r)))];
1039
+ if (!allowedUnder.some((base) => p === base || p.startsWith(base + path.sep)))
1040
+ return json(res, 403, { error: "browsing is limited to your home directory and project roots" });
1041
+ try {
1042
+ const dirs = fs.readdirSync(p, { withFileTypes: true })
1043
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
1044
+ .map((e) => e.name)
1045
+ .sort(byName);
1046
+ return json(res, 200, { path: p, parent: path.dirname(p), dirs });
1047
+ } catch (e) {
1048
+ return json(res, 400, { error: e.message });
1049
+ }
1050
+ }
1051
+
1052
+ if (req.method === "GET" && url.pathname === "/api/services")
1053
+ return json(res, 200, {
1054
+ groups: cfg.groups,
1055
+ hiddenGroups: cfg.hiddenGroups || [],
1056
+ hiddenCategories: cfg.hiddenCategories || [],
1057
+ services: cfg.services.map(serviceState),
1058
+ instances: Object.entries(instances)
1059
+ .filter(([, i]) => instLive(i))
1060
+ .map(([key, i]) => ({ key, svc: i.svc, branch: i.branch, port: i.port, pid: instPid(i), startedAt: i.startedAt, adopted: !i.child })),
1061
+ });
1062
+
1063
+ if (req.method === "POST" && url.pathname === "/api/worktree/start") {
1064
+ const { name, branch } = await readBody(req);
1065
+ const s = findSvc(name);
1066
+ if (!s) return json(res, 404, { error: "unknown service" });
1067
+ if (typeof branch !== "string" || !branch) return json(res, 400, { error: "branch required" });
1068
+ const r = startWorktree(s, branch);
1069
+ return json(res, r.code, r);
1070
+ }
1071
+
1072
+ if (req.method === "POST" && url.pathname === "/api/worktree/stop") {
1073
+ const { key } = await readBody(req);
1074
+ const i = instances[key];
1075
+ if (!i || !instLive(i)) return json(res, 404, { error: "no such running instance" });
1076
+ const pid = instPid(i);
1077
+ try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch {} }
1078
+ if (i.child) {
1079
+ const child = i.child;
1080
+ setTimeout(() => { if (child.exitCode === null) { try { process.kill(-pid, "SIGKILL"); } catch {} } }, 5000).unref();
1081
+ } else { delete instances[key]; saveProcs(); }
1082
+ return json(res, 200, { ok: true, stopped: pid });
1083
+ }
1084
+
1085
+ if (req.method === "POST" && url.pathname === "/api/worktree/remove") {
1086
+ // Delete a stopped instance's worktree checkout (git metadata included).
1087
+ const { name, branch } = await readBody(req);
1088
+ const s = findSvc(name);
1089
+ if (!s) return json(res, 404, { error: "unknown service" });
1090
+ const safeBranch = String(branch || "").replace(/[^A-Za-z0-9._-]+/g, "-");
1091
+ if (!safeBranch || safeBranch === "." || safeBranch === "..")
1092
+ return json(res, 400, { error: "bad branch name" });
1093
+ const key = `${name}@${safeBranch}`;
1094
+ if (instances[key] && instLive(instances[key])) return json(res, 409, { error: "stop the instance first" });
1095
+ delete instances[key];
1096
+ const wtDir = path.join(DATA_HOME, "worktrees", name, safeBranch);
1097
+ const r = git(svcDir(s), "worktree", "remove", "--force", wtDir);
1098
+ git(svcDir(s), "worktree", "prune");
1099
+ saveProcs();
1100
+ if (!r.ok && fs.existsSync(wtDir)) return json(res, 500, { error: `git worktree remove: ${r.out.split("\n").pop()}` });
1101
+ return json(res, 200, { ok: true });
1102
+ }
1103
+
1104
+ if (req.method === "GET" && url.pathname === "/api/infra")
1105
+ return json(res, 200, INFRA().map((t) => ({
1106
+ name: t.name, title: t.title, port: t.port, command: t.command,
1107
+ found: !!which(t.bin),
1108
+ runningPid: portPid(t.port),
1109
+ configured: cfg.services.some((s) => s.name === t.name),
1110
+ })).filter((t) => t.found));
1111
+
1112
+ if (req.method === "GET" && url.pathname === "/api/projects") {
1113
+ if (!projCache.data || Date.now() - projCache.t > 30000 || url.searchParams.has("fresh"))
1114
+ projCache = { t: Date.now(), data: scanProjects() };
1115
+ return json(res, 200, projCache.data);
1116
+ }
1117
+
1118
+ if (req.method === "POST" && url.pathname === "/api/start") {
1119
+ const { name, branch, force } = await readBody(req);
1120
+ const s = findSvc(name);
1121
+ if (!s) return json(res, 404, { error: "unknown service" });
1122
+ if (Array.isArray(s.dependsOn) && s.dependsOn.length) {
1123
+ const dep = await startWithDeps(s.dependsOn, false);
1124
+ const failed = Object.entries(dep).find(([, v]) => v.error);
1125
+ if (failed) return json(res, 409, { error: `dependency '${failed[0]}': ${failed[1].error}` });
1126
+ }
1127
+ const r = await startService(s, branch, force === true);
1128
+ return json(res, r.code, r);
1129
+ }
1130
+
1131
+ if (req.method === "POST" && url.pathname === "/api/start-all") {
1132
+ // Dependency-ordered bulk start for one section (null group = Ungrouped).
1133
+ const { group } = await readBody(req);
1134
+ const names = cfg.services
1135
+ .filter((s) => (s.group || null) === (group || null) && !s.hidden)
1136
+ .map((s) => s.name);
1137
+ if (!names.length) return json(res, 400, { error: "nothing to start" });
1138
+ const results = await startWithDeps(names, false);
1139
+ return json(res, 200, { results });
1140
+ }
1141
+
1142
+ if (req.method === "POST" && url.pathname === "/api/stop") {
1143
+ const { name } = await readBody(req);
1144
+ const s = findSvc(name);
1145
+ if (!s) return json(res, 404, { error: "unknown service" });
1146
+ const r = await stopService(s);
1147
+ return json(res, r.code, r);
1148
+ }
1149
+
1150
+ if (req.method === "POST" && url.pathname === "/api/restart") {
1151
+ const { name } = await readBody(req);
1152
+ const s = findSvc(name);
1153
+ if (!s) return json(res, 404, { error: "unknown service" });
1154
+ const r = await restartService(s);
1155
+ return json(res, r.code, r);
1156
+ }
1157
+
1158
+ if (req.method === "POST" && url.pathname === "/api/hide") {
1159
+ const { type, name, hidden } = await readBody(req);
1160
+ if (type === "service") {
1161
+ const s = findSvc(name);
1162
+ if (!s) return json(res, 404, { error: "unknown service" });
1163
+ if (hidden) s.hidden = true; else delete s.hidden;
1164
+ } else if (type === "group" || type === "category") {
1165
+ const key = type === "group" ? "hiddenGroups" : "hiddenCategories";
1166
+ const list = new Set(cfg[key] || []);
1167
+ hidden ? list.add(name) : list.delete(name);
1168
+ cfg[key] = [...list];
1169
+ } else return json(res, 400, { error: "bad type" });
1170
+ saveCfg();
1171
+ return json(res, 200, { ok: true });
1172
+ }
1173
+
1174
+ if (req.method === "POST" && url.pathname === "/api/group") {
1175
+ const { name } = await readBody(req);
1176
+ const n = (name || "").trim();
1177
+ if (!validLabel(n)) return json(res, 400, { error: "section name: 1–64 chars, no quotes/slashes/angle brackets" });
1178
+ if (cfg.groups.includes(n)) return json(res, 409, { error: "section already exists" });
1179
+ cfg.groups.push(n);
1180
+ saveCfg();
1181
+ return json(res, 200, { ok: true });
1182
+ }
1183
+
1184
+ if (req.method === "POST" && url.pathname === "/api/group/rename") {
1185
+ const { name, newName } = await readBody(req);
1186
+ const n = (newName || "").trim();
1187
+ if (!cfg.groups.includes(name)) return json(res, 404, { error: "unknown section" });
1188
+ if (!validLabel(n)) return json(res, 400, { error: "section name: 1–64 chars, no quotes/slashes/angle brackets" });
1189
+ if (n !== name && cfg.groups.includes(n)) return json(res, 409, { error: "a section with that name already exists" });
1190
+ cfg.groups = cfg.groups.map((g) => (g === name ? n : g));
1191
+ for (const s of cfg.services) if (s.group === name) s.group = n;
1192
+ cfg.hiddenGroups = (cfg.hiddenGroups || []).map((g) => (g === name ? n : g));
1193
+ saveCfg();
1194
+ return json(res, 200, { ok: true });
1195
+ }
1196
+
1197
+ if (req.method === "POST" && url.pathname === "/api/group/delete") {
1198
+ const { name } = await readBody(req);
1199
+ if (!cfg.groups.includes(name)) return json(res, 404, { error: "unknown section" });
1200
+ cfg.groups = cfg.groups.filter((g) => g !== name);
1201
+ for (const s of cfg.services) if (s.group === name) delete s.group;
1202
+ saveCfg();
1203
+ return json(res, 200, { ok: true });
1204
+ }
1205
+
1206
+ if (req.method === "POST" && url.pathname === "/api/move") {
1207
+ const { name, group, before } = await readBody(req);
1208
+ const s = findSvc(name);
1209
+ if (!s) return json(res, 404, { error: "unknown service" });
1210
+ if (group && !cfg.groups.includes(group)) return json(res, 404, { error: "unknown section" });
1211
+ cfg.services = cfg.services.filter((x) => x.name !== name);
1212
+ if (group) s.group = group; else delete s.group;
1213
+ const beforeIdx = before ? cfg.services.findIndex((x) => x.name === before) : -1;
1214
+ if (beforeIdx >= 0) cfg.services.splice(beforeIdx, 0, s);
1215
+ else {
1216
+ let last = -1;
1217
+ cfg.services.forEach((x, i) => { if ((x.group || null) === (group || null)) last = i; });
1218
+ cfg.services.splice(last + 1, 0, s);
1219
+ }
1220
+ saveCfg();
1221
+ return json(res, 200, { ok: true });
1222
+ }
1223
+
1224
+ if (req.method === "POST" && url.pathname === "/api/category") {
1225
+ const { name } = await readBody(req);
1226
+ const n = (name || "").trim();
1227
+ if (!validLabel(n)) return json(res, 400, { error: "category name: 1–64 chars, no quotes/slashes/angle brackets" });
1228
+ cfg.categoryOrder = cfg.categoryOrder || [];
1229
+ if (n === "Uncategorized" || cfg.categoryOrder.includes(n)) return json(res, 409, { error: "category already exists" });
1230
+ cfg.categoryOrder.push(n);
1231
+ saveCfg();
1232
+ projCache.t = 0;
1233
+ return json(res, 200, { ok: true });
1234
+ }
1235
+
1236
+ if (req.method === "POST" && url.pathname === "/api/category/delete") {
1237
+ const { name } = await readBody(req);
1238
+ cfg.categoryOrder = (cfg.categoryOrder || []).filter((c) => c !== name);
1239
+ for (const k of Object.keys(cfg.projectCategories || {}))
1240
+ if (cfg.projectCategories[k] === name) delete cfg.projectCategories[k];
1241
+ cfg.hiddenCategories = (cfg.hiddenCategories || []).filter((c) => c !== name);
1242
+ saveCfg();
1243
+ projCache.t = 0;
1244
+ return json(res, 200, { ok: true });
1245
+ }
1246
+
1247
+ if (req.method === "POST" && url.pathname === "/api/project/category") {
1248
+ // Assign a project to a category (null/absent = back to Uncategorized).
1249
+ const { name, category } = await readBody(req);
1250
+ if (typeof name !== "string" || !name || name.length > 128) return json(res, 400, { error: "bad project name" });
1251
+ cfg.projectCategories = cfg.projectCategories || {};
1252
+ if (category) {
1253
+ if (!validLabel(category)) return json(res, 400, { error: "bad category name" });
1254
+ cfg.projectCategories[name] = category;
1255
+ cfg.categoryOrder = cfg.categoryOrder || [];
1256
+ if (!cfg.categoryOrder.includes(category)) cfg.categoryOrder.push(category);
1257
+ } else delete cfg.projectCategories[name];
1258
+ saveCfg();
1259
+ projCache.t = 0;
1260
+ return json(res, 200, { ok: true });
1261
+ }
1262
+
1263
+ if (req.method === "POST" && url.pathname === "/api/service/rename") {
1264
+ const { name, newName } = await readBody(req);
1265
+ const s = findSvc(name);
1266
+ if (!s) return json(res, 404, { error: "unknown service" });
1267
+ const n = (newName || "").trim();
1268
+ if (!validSvcName(n)) return json(res, 400, { error: "service name must be letters/digits/dot/dash/underscore, max 64 chars" });
1269
+ if (n !== name && findSvc(n)) return json(res, 409, { error: "a service with that name already exists" });
1270
+ s.name = n;
1271
+ // Carry live runtime state across the rename (works mid-run). The disk
1272
+ // log follows too, so history doesn't split across two files.
1273
+ if (logStreams[name]) { logStreams[name].end(); delete logStreams[name]; }
1274
+ if (n !== name) {
1275
+ try { fs.renameSync(path.join(LOG_DIR, `${name}.log`), path.join(LOG_DIR, `${n}.log`)); } catch {}
1276
+ for (const map of [procs, buffers, clients, adopted, logWrites])
1277
+ if (name in map) { map[n] = map[name]; delete map[name]; }
1278
+ saveProcs();
1279
+ }
1280
+ saveCfg();
1281
+ return json(res, 200, { ok: true });
1282
+ }
1283
+
1284
+ if (req.method === "POST" && url.pathname === "/api/service") {
1285
+ const b = await readBody(req);
1286
+ if (!b.name || !b.dir || !b.command) return json(res, 400, { error: "name, dir, command are required" });
1287
+ if (!validSvcName(b.name))
1288
+ return json(res, 400, { error: "service name must be letters/digits/dot/dash/underscore, max 64 chars" });
1289
+ let s = findSvc(b.name);
1290
+ if (s && ((procs[s.name] && procs[s.name].child.exitCode === null) || adoptedPid(s.name)))
1291
+ return json(res, 409, { error: "stop the service before editing it" });
1292
+ if (typeof b.dir !== "string" || b.dir.length > 512) return json(res, 400, { error: "bad dir" });
1293
+ let dirStat = null;
1294
+ try { dirStat = fs.statSync(expand(b.dir.trim())); } catch {}
1295
+ if (!dirStat || !dirStat.isDirectory()) return json(res, 400, { error: `not a directory: ${b.dir}` });
1296
+ if (typeof b.command !== "string" || b.command.length > 4096) return json(res, 400, { error: "bad command" });
1297
+ if (b.env !== undefined && !validEnv(b.env)) return json(res, 400, { error: "env must be a flat object of string values with valid names" });
1298
+ if (b.port !== undefined && b.port !== null && b.port !== "") {
1299
+ const p = Number(b.port);
1300
+ if (!Number.isInteger(p) || p < 1 || p > 65535) return json(res, 400, { error: "port must be 1–65535" });
1301
+ b.port = p;
1302
+ } else b.port = undefined;
1303
+ if (!s) { s = { name: b.name }; cfg.services.push(s); }
1304
+ s.dir = b.dir.trim(); s.command = b.command.trim();
1305
+ s.port = b.port;
1306
+ s.env = b.env !== undefined ? b.env : (s.env || {});
1307
+ if (b.envFile !== undefined) { // false disables .env auto-load; string picks a file
1308
+ if (b.envFile === false || typeof b.envFile === "string") s.envFile = b.envFile;
1309
+ else delete s.envFile;
1310
+ }
1311
+ if (b.restart !== undefined) {
1312
+ if (b.restart === "on-failure") s.restart = "on-failure"; else delete s.restart;
1313
+ const rt = restarts[s.name]; if (rt) { clearTimeout(rt.timer); rt.n = 0; } // editing resets give-up state
1314
+ }
1315
+ if (b.dependsOn !== undefined) {
1316
+ const deps = Array.isArray(b.dependsOn) ? b.dependsOn.filter(validSvcName).filter((d) => d !== b.name) : [];
1317
+ if (deps.length) s.dependsOn = deps; else delete s.dependsOn;
1318
+ }
1319
+ if (b.readyWhen !== undefined) {
1320
+ const rw = b.readyWhen;
1321
+ if (rw && typeof rw === "object" && (typeof rw.log === "string" || typeof rw.http === "string")) {
1322
+ if (typeof rw.log === "string") { try { new RegExp(rw.log); } catch { return json(res, 400, { error: "readyWhen.log is not a valid regex" }); } }
1323
+ if (typeof rw.http === "string" && rw.http && !/^https?:\/\//.test(rw.http))
1324
+ return json(res, 400, { error: "readyWhen.http must start with http:// or https://" });
1325
+ s.readyWhen = { ...(typeof rw.log === "string" && rw.log ? { log: rw.log } : {}), ...(typeof rw.http === "string" && rw.http ? { http: rw.http } : {}) };
1326
+ if (!Object.keys(s.readyWhen).length) delete s.readyWhen;
1327
+ } else delete s.readyWhen;
1328
+ }
1329
+ if (b.group !== undefined) {
1330
+ if (b.group && cfg.groups.includes(b.group)) s.group = b.group;
1331
+ else delete s.group;
1332
+ }
1333
+ saveCfg();
1334
+ projCache.t = 0; // "configured" flags may have changed
1335
+ return json(res, 200, serviceState(s));
1336
+ }
1337
+
1338
+ if (req.method === "POST" && url.pathname === "/api/service/delete") {
1339
+ const { name } = await readBody(req);
1340
+ const s = findSvc(name);
1341
+ if (!s) return json(res, 404, { error: "unknown service" });
1342
+ if (procs[name] && procs[name].child.exitCode === null) return json(res, 409, { error: "stop it first" });
1343
+ cfg.services = cfg.services.filter((x) => x.name !== name);
1344
+ logStreams[name]?.end();
1345
+ delete logStreams[name]; delete buffers[name]; delete logWrites[name]; delete adopted[name];
1346
+ saveProcs();
1347
+ saveCfg();
1348
+ projCache.t = 0; // "configured" flags may have changed
1349
+ return json(res, 200, { ok: true });
1350
+ }
1351
+
1352
+ if (req.method === "GET" && url.pathname === "/api/logs") {
1353
+ const name = url.searchParams.get("name");
1354
+ if (!findSvc(name) && !instances[name] && !buffers[name]) return json(res, 404, { error: "unknown service" });
1355
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
1356
+ res.write(":connected\n\n"); // SSE comment: flushes headers even when the buffer is empty
1357
+ for (const l of (buffers[name] || []).slice(-300)) res.write(`data: ${JSON.stringify(l)}\n\n`);
1358
+ // Capture the Set itself: after a service rename the map key changes, and
1359
+ // a close handler holding the old name would throw on a missing entry.
1360
+ const set = (clients[name] = clients[name] || new Set());
1361
+ set.add(res);
1362
+ res.on("error", () => set.delete(res)); // client vanished mid-write
1363
+ req.on("close", () => set.delete(res));
1364
+ return;
1365
+ }
1366
+
1367
+ json(res, 404, { error: "not found" });
1368
+ };
1369
+
1370
+ // maxHeaderSize: browsers can carry >16KB of localhost cookies from other dev apps.
1371
+ const server = http.createServer({ maxHeaderSize: 262144 }, (req, res) => {
1372
+ handle(req, res).catch((e) => {
1373
+ console.error("request error:", e);
1374
+ try { json(res, 500, { error: `internal error: ${e.message}` }); } catch {}
1375
+ });
1376
+ });
1377
+
1378
+ server.on("error", (e) => {
1379
+ console.error(e.code === "EADDRINUSE"
1380
+ ? `port ${PORT} is already in use — is another stackdeck daemon running?`
1381
+ : `server error: ${e.message}`);
1382
+ process.exit(1);
1383
+ });
1384
+
1385
+ server.listen(PORT, "127.0.0.1", () =>
1386
+ console.log(`stackdeck v${VERSION} · http://localhost:${PORT} · config ${CONFIG_PATH}`)
1387
+ );
1388
+
1389
+ /* ---------- *.localhost reverse proxy ----------
1390
+ Browsers resolve *.localhost to loopback natively — no /etc/hosts, no PAC
1391
+ file. <service>.localhost → 127.0.0.1:<service port>. Unauthenticated by
1392
+ design (it only forwards to ports you configured); WebSockets pass through.
1393
+ Port 80 usually needs privileges (or is taken); we fall back to 8880. */
1394
+ const net = require("net");
1395
+ let PROXY_PORT = null;
1396
+ const proxyTargetPort = (hostHeader) => {
1397
+ // Browsers lowercase hosts; curl and code may not — normalize.
1398
+ const m = /^([a-z0-9._-]+)\.localhost(?::\d+)?$/.exec((hostHeader || "").toLowerCase());
1399
+ if (!m) return null;
1400
+ const label = m[1];
1401
+ const svcByName = (n) => cfg.services.find((x) => x.name.toLowerCase() === n);
1402
+ const instByKey = (k) => {
1403
+ for (const [key, i] of Object.entries(instances))
1404
+ if (key.toLowerCase() === k && instLive(i) && i.port) return i;
1405
+ return null;
1406
+ };
1407
+ // 1. plain service: orders-api.localhost
1408
+ const svc = svcByName(label);
1409
+ if (svc && svc.port) return svc.port;
1410
+ // 2. worktree instance: feature-x.orders-api.localhost (branch.service)
1411
+ const dot = label.lastIndexOf(".");
1412
+ if (dot > 0) {
1413
+ const i = instByKey(`${label.slice(dot + 1)}@${label.slice(0, dot)}`);
1414
+ if (i) return i.port;
1415
+ }
1416
+ // 3. single-label form: orders-api--feature-x.localhost
1417
+ const dash = label.indexOf("--");
1418
+ if (dash > 0) {
1419
+ const i = instByKey(`${label.slice(0, dash)}@${label.slice(dash + 2)}`);
1420
+ if (i) return i.port;
1421
+ }
1422
+ return null;
1423
+ };
1424
+ const proxy = http.createServer({ maxHeaderSize: 262144 }, (req, res) => {
1425
+ const port = proxyTargetPort(req.headers.host);
1426
+ if (!port) { res.writeHead(404, { "Content-Type": "text/plain" }); return res.end("stackdeck: no service with that name (or it has no port)\n"); }
1427
+ // Host is rewritten: dev servers (Vite post-CVE, others) reject unknown hosts.
1428
+ const headers = { ...req.headers, host: `127.0.0.1:${port}` };
1429
+ // host "localhost" + autoSelectFamily: dev servers bind IPv4 or IPv6-only
1430
+ // (Vite uses [::1]) — try both families.
1431
+ const up = http.request({ host: "localhost", autoSelectFamily: true, port, path: req.url, method: req.method, headers, timeout: 30000 }, (upRes) => {
1432
+ res.writeHead(upRes.statusCode, upRes.headers);
1433
+ upRes.pipe(res);
1434
+ });
1435
+ up.on("timeout", () => up.destroy(new Error("upstream timed out")));
1436
+ up.on("error", (e) => { try { res.writeHead(502, { "Content-Type": "text/plain" }); res.end(`stackdeck proxy: ${e.message}\n`); } catch {} });
1437
+ req.pipe(up);
1438
+ });
1439
+ proxy.on("upgrade", (req, socket, head) => {
1440
+ const port = proxyTargetPort(req.headers.host);
1441
+ if (!port) return socket.destroy();
1442
+ const up = net.connect({ port, host: "localhost", autoSelectFamily: true }, () => {
1443
+ let raw = `${req.method} ${req.url} HTTP/1.1\r\n`;
1444
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
1445
+ const h = req.rawHeaders[i].toLowerCase() === "host" ? `127.0.0.1:${port}` : req.rawHeaders[i + 1];
1446
+ raw += `${req.rawHeaders[i]}: ${h}\r\n`;
1447
+ }
1448
+ up.write(raw + "\r\n");
1449
+ if (head && head.length) up.write(head);
1450
+ socket.pipe(up).pipe(socket);
1451
+ });
1452
+ up.on("error", () => socket.destroy());
1453
+ socket.on("error", () => up.destroy());
1454
+ });
1455
+ (function listenProxy(ports) {
1456
+ if (!ports.length) { console.error("proxy: no port available — *.localhost domains disabled"); return; }
1457
+ const p = ports[0];
1458
+ proxy.once("error", (e) => {
1459
+ if (e.code === "EADDRINUSE" || e.code === "EACCES") listenProxy(ports.slice(1));
1460
+ else console.error(`proxy error: ${e.message}`);
1461
+ });
1462
+ proxy.listen(p, "127.0.0.1", () => {
1463
+ PROXY_PORT = p;
1464
+ console.log(`*.localhost proxy on :${p}`);
1465
+ });
1466
+ })([80, 8880]);