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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 beingmechon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,173 @@
1
+ <img src="logo-wordmark.svg" alt="Stackdeck" height="52">
2
+
3
+ **Your projects folder, as a control panel.**
4
+
5
+ <img src="docs/screenshot.png" alt="The Stackdeck board: sections of services with live status, a parallel worktree instance of orders-api running a feature branch on its own port" width="100%">
6
+
7
+ *(Fictional demo data — spin it up yourself with `./scripts/demo.sh`.)*
8
+
9
+ A browser dashboard for the services you run every day in local development.
10
+ Stackdeck scans your projects folder, figures out how to run each repo, and
11
+ gives you Start / Kill / restart buttons, live logs, and a **git branch
12
+ dropdown** that checks out the branch before launching. Zero dependencies —
13
+ one Node process (>= 18.13), one HTML page, binds to 127.0.0.1.
14
+
15
+ *Spiritual successor to [hotel](https://github.com/typicode/hotel)
16
+ (unmaintained since 2019), with the git-awareness of
17
+ [portree](https://github.com/fairy-pitta/portree) and the multi-project scope
18
+ neither has.*
19
+
20
+ ## Quick start
21
+
22
+ ```bash
23
+ git clone https://github.com/beingmechon/stackdeck && cd stackdeck
24
+ node bin/stackdeck.js # starts the daemon, opens http://localhost:8899
25
+ ```
26
+
27
+ Or link it onto your PATH:
28
+
29
+ ```bash
30
+ npm link # then: stackdeck | stackdeck status | stackdeck logs api
31
+ ```
32
+
33
+ ## What it does
34
+
35
+ - **Discovers your projects** — scans your project roots (default `~/Projects`),
36
+ detects git repos + current branch, and infers a run command per repo
37
+ (pnpm/npm/yarn scripts, Makefile targets, Python entrypoints with uv
38
+ detection, docker-compose, cargo, go).
39
+ - **Services** — a project you've promoted to a runnable recipe: directory +
40
+ command + port + env. Start / Kill / restart from the browser or CLI, live
41
+ ANSI-colored logs (also on disk), status by pid *and* port — services
42
+ started outside Stackdeck show as `external` and can be killed too.
43
+ Killing the daemon never kills your services: pids persist to disk and a
44
+ restarted daemon re-adopts them (still killable, logs resume on restart).
45
+ - **Git-aware** — a branch dropdown per service; picking a branch runs
46
+ `git checkout` before start, refused while the tree is dirty. Dirty repos
47
+ are badged.
48
+ - **Organize the board** — drag rows between your own sections, per-section
49
+ *start all* / *stop all*, collapse/expand, hide/unhide anything, categorize
50
+ the project list.
51
+ - **Stacks that start in order** — give a service `dependsOn` and it starts
52
+ after its dependencies are *ready* (a log-line regex via `readyWhen`, an
53
+ HTTP check, or its port opening). Section **start all** is
54
+ dependency-ordered; cycles fail loudly.
55
+ - **Crash handling** — `restart: on-failure` restarts with exponential
56
+ backoff (5 tries, resets after a minute of clean uptime); unexpected exits
57
+ show a `crashed` badge and fire a browser notification. Manual stops never
58
+ auto-restart.
59
+ - **Port squatter eviction** — if another process holds a service's port,
60
+ Start tells you the pid and offers to kill it and take the port.
61
+ - ***.localhost domains** — a built-in reverse proxy maps
62
+ `<service>.localhost` → its port, and `<branch>.<service>.localhost` →
63
+ a running worktree instance (whose port you didn't pick — that's when
64
+ stable names matter most). WebSockets included. Browsers resolve
65
+ `*.localhost` natively: no /etc/hosts, no PAC files. Port 80 where
66
+ binding is permitted, `:8880` otherwise.
67
+ - **Multi-process repos** — a Procfile, docker-compose services, or pnpm
68
+ workspace packages turn one repo into a section of services with one click.
69
+ - **Parallel branches (worktrees)** — pick a branch and hit ⧉: the service
70
+ runs that branch in its own git worktree with a free port injected as
71
+ `$PORT`, side by side with the main checkout. Made for the
72
+ several-agents-on-several-branches workflow. Instances survive daemon
73
+ restarts (re-adopted like services) and worktrees are reused across runs.
74
+ - **CLI parity** — `stackdeck status|start|stop|restart|logs <name>` talks to
75
+ the same daemon.
76
+ - **MCP server** — `stackdeck mcp` exposes list/start/stop/restart/logs as
77
+ MCP tools over stdio, so AI coding agents can manage (and debug against)
78
+ your dev environment. Zero-dependency, wraps the same authenticated API.
79
+ - **Dev tools** — detects databases and brokers installed on your machine
80
+ (PostgreSQL, MySQL/MariaDB, MongoDB, Redis/Valkey, Elasticsearch/OpenSearch,
81
+ RabbitMQ, NATS, MinIO, Temporal, ClickHouse, Mailpit, Memcached) and
82
+ one-click configures them as ordinary foreground services — managed child,
83
+ streamed logs, clean kill. Data directories are resolved from standard
84
+ locations (or self-initialized under `~/.local/share/stackdeck/`); no Docker,
85
+ no launchd/systemd indirection.
86
+
87
+ ## Configuration
88
+
89
+ Everything lives in one JSON file — `~/.config/stackdeck/config.json`
90
+ (or `$STACKDECK_HOME/config.json`); logs sit next to it. Every field is
91
+ editable from the UI; the interesting ones:
92
+
93
+ ```jsonc
94
+ {
95
+ "port": 8899,
96
+ "projectRoots": ["~/Projects", "~/work"], // folders to scan
97
+ "categoryOrder": ["Products", "Tools"], // display order of project categories
98
+ "projectCategories": { "my-repo": "Products" },
99
+ "groups": ["Stack A"], // your service sections
100
+ "services": [
101
+ {
102
+ "name": "api",
103
+ "dir": "~/Projects/my-app",
104
+ "command": "pnpm run dev",
105
+ "port": 3001, // for status detection + <name>.localhost
106
+ "env": { "DEBUG": "1" },
107
+ "group": "Stack A",
108
+ "dependsOn": ["db"], // started (and ready) first
109
+ "readyWhen": { "log": "Listening on" }, // or { "http": "http://…/health" }; default: port opens
110
+ "restart": "on-failure" // exponential backoff, 5 tries
111
+ }
112
+ ]
113
+ }
114
+ ```
115
+
116
+ ## Security model
117
+
118
+ The daemon executes shell commands by design, and **localhost is not a
119
+ security boundary** — so the API is gated by a per-install secret
120
+ (`<state dir>/secret`, mode 0600). The web page receives the token by being
121
+ served from disk by the daemon itself; the CLI reads it as the same user.
122
+ Every `/api/*` call without it gets a 401 (only the bare `/api/ping`
123
+ liveness check is open). State files are 0600 in a 0700 directory.
124
+
125
+ Layered on top: the daemon binds to 127.0.0.1 only, pins the `Host` header
126
+ (DNS-rebinding defense), accepts only `application/json` POSTs (a cross-origin
127
+ JSON POST forces a CORS preflight, which is never answered; `text/plain`
128
+ sneak-POSTs are rejected), rejects foreign `Origin` headers, validates
129
+ service/section names, ports, dirs, and env server-side, limits request bodies
130
+ to 1MB, and restricts the folder browser to your home directory and configured
131
+ roots. Config writes are atomic; a corrupt config is set aside, never fatal.
132
+ Logs rotate at 5MB per service. Do not port-forward the daemon off your machine.
133
+
134
+ The `*.localhost` proxy is unauthenticated by design (your browser needs it),
135
+ but it only forwards to the loopback ports of services you configured —
136
+ nothing else is reachable through it. Note that it makes those services
137
+ reachable at *guessable names* from any site you visit (same-origin policy
138
+ still blocks reading responses); that's roughly the exposure a guessable
139
+ port already had, but if a dev service has destructive unauthenticated
140
+ endpoints, don't give it a port in Stackdeck.
141
+
142
+ Stackdeck is **Unix-only** (macOS/Linux): it relies on bash, process groups,
143
+ and lsof/ss. The `.env` loader is intentionally minimal — flat `KEY=value`
144
+ lines, quotes and `#` comments handled, no interpolation or multiline values.
145
+
146
+ ## Notes that will save you a debugging session
147
+
148
+ - Services spawn through a **non-login** shell with the PATH of your login
149
+ shell resolved once at daemon start — so nvm/homebrew/uv tools are found
150
+ even when the daemon was launched from a GUI, and profile files can't
151
+ reorder your toolchain per-spawn.
152
+ - The HTTP server accepts headers up to 256KB, because browsers hoard
153
+ localhost cookies across every dev app you've ever run, and Node's 16KB
154
+ default silently breaks localhost apps (431s / dropped connections).
155
+ - Kill stops the whole process group; stragglers get SIGKILL after 5s.
156
+
157
+ ## macOS app (optional)
158
+
159
+ ```bash
160
+ ./scripts/macos-app.sh # builds ~/Applications/Stackdeck.app from this checkout
161
+ ```
162
+
163
+ Double-click to start the daemon and open the board. Linux users: `stackdeck`
164
+ in a terminal, or add a systemd user unit for `node server.js`.
165
+
166
+ ## Status
167
+
168
+ Early. Solid daily driver, small codebase (~1 file each side), no tests yet.
169
+ Issues and PRs welcome.
170
+
171
+ ## License
172
+
173
+ MIT
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * stackdeck CLI — same daemon as the web board.
4
+ *
5
+ * stackdeck start the daemon (if down) and open the board
6
+ * stackdeck daemon run the daemon in the foreground
7
+ * stackdeck status list services and their state
8
+ * stackdeck start <name> start a service
9
+ * stackdeck stop <name> stop a service
10
+ * stackdeck restart <name> restart a service
11
+ * stackdeck logs <name> stream a service's logs (Ctrl-C to quit)
12
+ * stackdeck mcp run an MCP server over stdio (for AI agents)
13
+ */
14
+ "use strict";
15
+ const path = require("path");
16
+ const fs = require("fs");
17
+ const os = require("os");
18
+ const { spawn, execFileSync } = require("child_process");
19
+
20
+ const SERVER = path.join(__dirname, "..", "server.js");
21
+
22
+ // The API requires the per-install secret; same-user processes read it from disk.
23
+ function stateHome() {
24
+ if (process.env.STACKDECK_HOME)
25
+ return process.env.STACKDECK_HOME.replace(/^~(?=\/|$)/, os.homedir());
26
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
27
+ return path.join(xdg, "stackdeck");
28
+ }
29
+ const token = () => { try { return fs.readFileSync(path.join(stateHome(), "secret"), "utf8").trim(); } catch { return ""; } };
30
+ const authHeaders = () => ({ "X-Stackdeck-Token": token() });
31
+
32
+ // Same port resolution as the daemon: env override, else config, else default.
33
+ function resolvePort() {
34
+ const env = Number(process.env.STACKDECK_PORT);
35
+ if (Number.isInteger(env) && env >= 1 && env <= 65535) return env;
36
+ try {
37
+ const p = JSON.parse(fs.readFileSync(path.join(stateHome(), "config.json"), "utf8")).port;
38
+ if (Number.isInteger(p) && p >= 1 && p <= 65535) return p;
39
+ } catch {}
40
+ return 8899;
41
+ }
42
+ const PORT = resolvePort();
43
+ const BASE = `http://127.0.0.1:${PORT}`;
44
+
45
+ async function up() {
46
+ try { const r = await fetch(`${BASE}/api/ping`); return r.ok; } catch { return false; }
47
+ }
48
+
49
+ async function ensureDaemon() {
50
+ if (await up()) return;
51
+ // The daemon's own stderr (crash guards, request errors) goes to a log —
52
+ // safety nets that report to /dev/null are worse than no nets.
53
+ let out = "ignore";
54
+ try {
55
+ fs.mkdirSync(path.join(stateHome(), "logs"), { recursive: true, mode: 0o700 });
56
+ out = fs.openSync(path.join(stateHome(), "logs", "daemon.log"), "a");
57
+ } catch {}
58
+ const child = spawn(process.execPath, [SERVER], { detached: true, stdio: ["ignore", out, out] });
59
+ child.unref();
60
+ for (let i = 0; i < 40; i++) {
61
+ if (await up()) return;
62
+ await new Promise((ok) => setTimeout(ok, 250));
63
+ }
64
+ console.error("daemon did not come up — try: stackdeck daemon");
65
+ process.exit(1);
66
+ }
67
+
68
+ async function post(pathname, body) {
69
+ const r = await fetch(BASE + pathname, {
70
+ method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify(body),
71
+ });
72
+ const j = await r.json().catch(() => ({}));
73
+ if (!r.ok) { console.error("error:", j.error || r.statusText); process.exit(1); }
74
+ return j;
75
+ }
76
+
77
+ function openBrowser(url) {
78
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
79
+ try { execFileSync(cmd, [url], { stdio: "ignore" }); } catch { console.log(url); }
80
+ }
81
+
82
+ (async () => {
83
+ const [cmd, name] = process.argv.slice(2);
84
+
85
+ if (!cmd || cmd === "up" || cmd === "open") {
86
+ await ensureDaemon();
87
+ console.log(`stackdeck · ${BASE}`);
88
+ openBrowser(BASE);
89
+ return;
90
+ }
91
+ if (cmd === "daemon") {
92
+ require(SERVER);
93
+ return;
94
+ }
95
+ if (cmd === "status") {
96
+ await ensureDaemon();
97
+ const r = await fetch(`${BASE}/api/services`, { headers: authHeaders() });
98
+ const d = await r.json().catch(() => ({}));
99
+ if (!r.ok) { console.error("error:", d.error || r.statusText); process.exit(1); }
100
+ for (const s of d.services) {
101
+ const state = s.running ? (s.managed ? "up" : "up (external)") : "down";
102
+ console.log(`${s.running ? "●" : "○"} ${s.name.padEnd(16)} ${state.padEnd(14)} :${s.port ?? "—"} ${s.branch ?? ""}`);
103
+ }
104
+ return;
105
+ }
106
+ if (["start", "stop", "restart"].includes(cmd)) {
107
+ if (!name) { console.error(`usage: stackdeck ${cmd} <service>`); process.exit(1); }
108
+ await ensureDaemon();
109
+ await post(`/api/${cmd}`, { name });
110
+ console.log(`${name}: ${cmd} ok`);
111
+ return;
112
+ }
113
+ if (cmd === "logs") {
114
+ if (!name) { console.error("usage: stackdeck logs <service>"); process.exit(1); }
115
+ await ensureDaemon();
116
+ const res = await fetch(`${BASE}/api/logs?name=${encodeURIComponent(name)}`, { headers: authHeaders() });
117
+ if (!res.ok) { console.error("error:", (await res.json()).error); process.exit(1); }
118
+ let buf = "";
119
+ for await (const chunk of res.body) {
120
+ buf += chunk.toString();
121
+ const lines = buf.split("\n");
122
+ buf = lines.pop(); // keep any partial SSE frame for the next chunk
123
+ for (const line of lines)
124
+ if (line.startsWith("data: ")) { try { console.log(JSON.parse(line.slice(6))); } catch {} }
125
+ }
126
+ return;
127
+ }
128
+ if (cmd === "mcp") {
129
+ await ensureDaemon();
130
+ runMcp();
131
+ return;
132
+ }
133
+ console.error("unknown command:", cmd);
134
+ process.exit(1);
135
+ })();
136
+
137
+ /* ---------- MCP server (stdio, zero-dep) ----------
138
+ Exposes the daemon to AI agents: list services, start/stop/restart, read
139
+ logs. Newline-delimited JSON-RPC 2.0, MCP protocol 2025-03-26. */
140
+ function runMcp() {
141
+ const TOOLS = [
142
+ { name: "list_services", description: "List all Stackdeck services with running state, port, pid, git branch, and dirty flag.",
143
+ inputSchema: { type: "object", properties: {} } },
144
+ { name: "start_service", description: "Start a service by name (starts its dependencies first). Optional branch to check out.",
145
+ inputSchema: { type: "object", properties: { name: { type: "string" }, branch: { type: "string" } }, required: ["name"] } },
146
+ { name: "stop_service", description: "Stop (kill) a service by name.",
147
+ inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] } },
148
+ { name: "restart_service", description: "Restart a service by name.",
149
+ inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] } },
150
+ { name: "get_logs", description: "Return the most recent log lines for a service (from its on-disk log).",
151
+ inputSchema: { type: "object", properties: { name: { type: "string" }, lines: { type: "number", description: "default 100" } }, required: ["name"] } },
152
+ ];
153
+ async function callTool(name, args) {
154
+ if (name === "list_services") {
155
+ const r = await fetch(`${BASE}/api/services`, { headers: authHeaders() });
156
+ const d = await r.json();
157
+ if (!r.ok) throw new Error(d.error || r.statusText);
158
+ return d.services.map((s) => ({
159
+ name: s.name, running: s.running, managed: s.managed, pid: s.pid, port: s.port ?? null,
160
+ branch: s.branch, dirty: s.dirty, group: s.group ?? null, command: s.command, dir: s.dir,
161
+ }));
162
+ }
163
+ if (["start_service", "stop_service", "restart_service"].includes(name)) {
164
+ const ep = { start_service: "start", stop_service: "stop", restart_service: "restart" }[name];
165
+ const r = await fetch(`${BASE}/api/${ep}`, {
166
+ method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() },
167
+ body: JSON.stringify({ name: args.name, branch: args.branch }),
168
+ });
169
+ const d = await r.json();
170
+ if (!r.ok) throw new Error(d.error || r.statusText);
171
+ return d;
172
+ }
173
+ if (name === "get_logs") {
174
+ const n = Math.min(Math.max(Number(args.lines) || 100, 1), 2000);
175
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(args.name)) throw new Error("bad service name");
176
+ const file = path.join(stateHome(), "logs", `${args.name}.log`);
177
+ let text = "";
178
+ try { text = fs.readFileSync(file, "utf8"); } catch { throw new Error(`no logs for '${args.name}'`); }
179
+ return text.split("\n").slice(-n).join("\n");
180
+ }
181
+ throw new Error(`unknown tool: ${name}`);
182
+ }
183
+ let pending = 0, stdinDone = false;
184
+ const maybeExit = () => { if (stdinDone && pending === 0) process.exit(0); };
185
+ const reply = (id, result, error) => {
186
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, ...(error ? { error: { code: -32000, message: error } } : { result }) }) + "\n");
187
+ };
188
+ let buf = "";
189
+ process.stdin.on("data", (chunk) => {
190
+ buf += chunk.toString("utf8");
191
+ const linesIn = buf.split("\n");
192
+ buf = linesIn.pop();
193
+ for (const line of linesIn) {
194
+ if (!line.trim()) continue;
195
+ let msg;
196
+ try { msg = JSON.parse(line); } catch { continue; }
197
+ if (msg.method === "initialize")
198
+ reply(msg.id, { protocolVersion: "2025-03-26", capabilities: { tools: {} },
199
+ serverInfo: { name: "stackdeck", version: "0.3.0" } });
200
+ else if (msg.method === "tools/list") reply(msg.id, { tools: TOOLS });
201
+ else if (msg.method === "tools/call") {
202
+ pending++; // dispatched concurrently: a slow call must not delay (or exit before) queued ones
203
+ callTool(msg.params.name, msg.params.arguments || {})
204
+ .then((out) => reply(msg.id, { content: [{ type: "text", text: typeof out === "string" ? out : JSON.stringify(out, null, 2) }] }))
205
+ .catch((e) => reply(msg.id, { content: [{ type: "text", text: `error: ${e.message}` }], isError: true }))
206
+ .finally(() => { pending--; maybeExit(); });
207
+ }
208
+ else if (msg.id !== undefined) reply(msg.id, {}); // politely ack anything else with an id
209
+ }
210
+ });
211
+ // Exit when the host closes the pipe — but only after in-flight calls finish.
212
+ process.stdin.on("end", () => { stdinDone = true; setImmediate(maybeExit); });
213
+ }