A local web app to define, run, stop, and restart shell commands and command groups. Logs are streamed live and persisted in SQLite.
Stack: Next.js 16 (App Router), React 19, Tailwind 4, TypeScript. Prefer interface for object shapes; use type for unions, function types, and generics.
Security: This app is for local use only (e.g.
localhost). Do not expose it to the network without adding authentication.
npm install
npm run devOpen http://localhost:3000. After code changes, run npm run dev to verify the app and server still work.
Prerequisites: Node.js 18+ and npm.
| Step | Command | Description |
|---|---|---|
| Install deps | npm install |
Install dependencies. |
| Build | npm run build |
Production build (Next.js + server). |
| Run (dev) | npm run dev |
Dev server with Socket.IO at http://localhost:3000 (or PORT); uses polling then WebSocket. |
| Run (prod) | npm start |
Production server; requires npm run build first. |
- Port: Default is
3000. Override withPORT=1337 npm start(ornpm run dev). - Data: SQLite DB and data dir are created at
./data/under the current working directory when the app starts. Run the app from the project root sodata/launcher.dblives in the repo (or setcwdwhen running as a daemon).
To keep the launcher running in the background and survive logouts, use one of the following.
Runs as your user; no root required.
-
Build the app (from the project directory):
cd /path/to/launcher npm install npm run build -
Create a user systemd unit (e.g.
~/.config/systemd/user/launcher.service):[Unit] Description=Launcher (browser-based command runner) After=network.target [Service] Type=simple WorkingDirectory=/path/to/launcher ExecStart=/usr/bin/env npm start Restart=on-failure RestartSec=5 # Optional: port (default 3000) Environment=PORT=3000 [Install] WantedBy=default.target
Replace
/path/to/launcherwith the real path to the project (e.g./home/you/Dev/launcher). -
Enable and start (user service):
systemctl --user daemon-reload systemctl --user enable launcher systemctl --user start launcher -
Useful commands:
systemctl --user status launcher # status systemctl --user stop launcher # stop systemctl --user restart launcher # restart after code/build changes journalctl --user -u launcher -f # follow logs
-
Long-running without login: If you want the service to run when no one is logged in (e.g. on a headless server), enable lingering for your user:
loginctl enable-linger $USER
Then the user service will start at boot and keep running after you log out.
Runs as a system-wide service (requires root to install).
-
Create
/etc/systemd/system/launcher.service:[Unit] Description=Launcher (browser-based command runner) After=network.target [Service] Type=simple User=youruser WorkingDirectory=/path/to/launcher ExecStart=/usr/bin/env npm start Restart=on-failure RestartSec=5 Environment=PORT=3000 [Install] WantedBy=multi-user.target
Replace
youruserand/path/to/launcherwith the user that owns the app and the project path. -
Enable and start:
sudo systemctl daemon-reload sudo systemctl enable launcher sudo systemctl start launcher sudo systemctl status launcher
If you prefer PM2:
npm install -g pm2
cd /path/to/launcher
npm run build
pm2 start npm --name launcher -- start
pm2 save
pm2 startup # optional: run the command it prints to start on bootLogs: pm2 logs launcher. Restart: pm2 restart launcher.
- Runtime: All process spawning and SQLite run in Node.js (not Edge). Route Handlers use the default Node runtime;
child_processandbetter-sqlite3are only used there. - Frontend data: When you run the custom server (
npm run dev), the UI uses Socket.IO for real-time list updates and actions (transports: polling, then WebSocket). If Socket.IO is unavailable, the client falls back to HTTP (fetch for lists and action endpoints). - Process tracking: In-memory map
pid → { childProcess, commandId, runId }inlib/process-manager/state.tsfor stop/restart. After a server restart the map is empty; “stop” can still work by callingprocess.kill(pid, 'SIGTERM')using the PID stored in the DB for that run. - Log streaming: Server-Sent Events (SSE) from Route Handlers: one stream per run. Stdout/stderr are streamed to the SSE response and appended to SQLite so logs are both live and persisted.
- Group runs: Commands in a group start in parallel. When any process exits with non-zero (or errors), the server kills all other processes in that group and marks the group run as failed.
┌─────────────────────────────────────────────────────────────────┐
│ Web UI: Commands │ Groups │ Log viewer │
└───────────────────────────┬─────────────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────────────┐
│ Next.js API: CRUD (commands/groups) │ Run/Stop/Restart │ SSE │
└───────────────────────────┬─────────────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────────────┐
│ Data: SQLite DB │ In-memory PID map │
└───────────────────────────┬─────────────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────────────┐
│ Child processes (spawned by process manager) │
└─────────────────────────────────────────────────────────────────┘
- commands —
id,name,command(text),cwd,env(JSON),created_at,updated_at. Optional:last_run_at,last_exit_code. - runs —
id,command_id,pid(nullable until spawned),started_at,finished_at,exit_code,status(running|success|failed|killed). One row per execution. - log_chunks —
id,run_id,stream_type(stdout|stderr),content,created_at. Append-only. - groups —
id,name,created_at. - group_commands —
group_id,command_id,sort_order. Membership and display order; execution is parallel. - group_runs —
id,group_id,started_at,finished_at,status. Tracks group run for “all failed” behavior.
The DB file is created at ./data/launcher.db (or as configured); data/ is created if missing. Tables are created on app startup via a DB init module used by API routes.
| Method | Path | Description |
|---|---|---|
| GET | /api/commands |
List commands (with last run info). |
| POST | /api/commands |
Create command (body: name, command, cwd, env). |
| GET | /api/commands/[id] |
One command + last run. |
| PATCH | /api/commands/[id] |
Update command. |
| DELETE | /api/commands/[id] |
Delete command. |
| POST | /api/commands/[id]/run |
Start command; create run; spawn process; return run_id, pid. |
| POST | /api/commands/[id]/stop |
Stop by runId or running run for command; kill via PID; update run. |
| POST | /api/commands/[id]/restart |
Stop current run (if any) then start again. |
| GET | /api/groups |
List groups with member command ids (and optional last group run). |
| POST | /api/groups |
Create group (body: name). |
| PATCH | /api/groups/[id] |
Update group name. |
| PUT | /api/groups/[id]/commands |
Set members (body: commandIds[]). |
| DELETE | /api/groups/[id] |
Delete group. |
| POST | /api/groups/[id]/run |
Run all member commands in parallel; create group_run and runs; on first failure, kill others and set status. |
| GET | /api/runs/[runId]/logs |
Fetch persisted log chunks (paginated or full). |
| GET | /api/runs/[runId]/logs/stream |
SSE stream for that run; pipe stdout/stderr to SSE and append to log_chunks; optional “run finished” event. |
All handlers that use the DB or child_process live under app/api/ and use the shared DB helper and process manager module.
- Spawn:
spawn(command, args, { cwd, env: { ...process.env, ...commandEnv } }). Thecommandis parsed (e.g. shell for pipes/redirects); see “Open decisions” below. - PID map: Store
run_id,command_id,ChildProcess; on exit, updateruns(finished_at, exit_code, status) and remove from map. - Group run: For each command in the group, spawn and record run_id; on any non-zero exit, kill remaining processes for that group run and update all runs and group_run status.
- Logging: On each stdout/stderr
dataevent, (1) append a row tolog_chunks, (2) if there’s an active SSE writer for that run_id, write the chunk. Use a maprunId → writer[]so multiple clients can subscribe to the same run.
- Layout: Sidebar or tabs for Commands, Groups, and optionally “Running” (or show running state in Commands/Groups).
- Commands: List (name, command truncated, cwd, last run, status), actions: Run, Stop, Restart, Edit, Delete, View logs. “Add command” form: name, command, cwd, env (key/value or text area).
- Groups: List groups; create (name); edit: add/remove/reorder saved commands; Run group; show “running” and “one failed → all stopped”.
- Running: Show which commands are running (e.g. via
GET /api/commandsorGET /api/runs?status=running, or optional SSE). - Logs: For a run, open SSE to
GET /api/runs/[runId]/logs/streamand append to a log viewer; when finished, allow viewing from DB viaGET /api/runs/[runId]/logs.
| Package | Version | Usage |
|---|---|---|
| next | 16.1.6 | App Router, Route Handlers in app/api/. Use Node runtime (default) for routes that use child_process or Knex. Next.js 16 docs. |
| react / react-dom | 19.2.x | UI; use Server/Client Components as needed. |
| knex | ^3.1.0 | Query builder and migrations; SQLite via better-sqlite3. Single shared DB via getDb(); schema in lib/db/migrations/. Knex. |
| better-sqlite3 | ^12.6.2 | SQLite driver used by Knex. API. |
| @types/better-sqlite3 | ^7.6.13 | TypeScript types for better-sqlite3. |
| zod | ^4.3.6 | Request body validation in Route Handlers: z.object({ ... }).parse(await request.json()). zod. |
| tailwindcss / @tailwindcss/postcss | ^4.2.0 | Styling. Tailwind v4. |
SSE uses native TransformStream and Response with text/event-stream (no extra package).
- Intended for local use only (e.g.
localhost). No auth in scope by default. - Validate
cwdto prevent escaping (e.g. resolve to real path and ensure it’s under an allowed base). - Sanitize or restrict
commandas needed; env vars from the DB are applied as-is.
lib/db/—connection.ts,migrations/(Knex),queries/(commands, groups, runs, group-commands, group-runs, log-chunks),types.ts(row interfaces),knex-types.d.ts(Knex table augmentation),facade.ts. Single shared DB via Knex; schema via migrations.lib/process-manager/—state.ts(static singleton), spawn, stop, kill, log,facade.ts. PID map, group run, log piping to DB and SSE.lib/actions/— command/group server actions,result-factory.ts,facade.ts.lib/ws-action-handlers/—types.ts(CommandAction, GroupAction enums), command/group handlers, reply.lib/ws-broadcast/— Socket.IO list push, log stream, message factory, clients.context/ws/— React context provider, adapters (action sender, lists fetcher), lists-update-subject, HTTP fallback for actions and initial load.app/api/— Route Handlers:commands/,commands/[id]/, run/stop/restart;groups/,groups/[id]/, commands, run;runs/[runId]/logs/,runs/[runId]/logs/stream/.app/page.tsx— single dashboard with tabs (Commands | Groups).app/error.tsx,app/not-found.tsx,app/global-error.tsx,app/loading.tsxfor error and loading states.- Components:
CommandList/(useCommandList hook, container index, CommandListView),GroupList/(useGroupList, index, GroupListView),CommandForm,GroupForm,LogViewer,RunControls,shared/ConnectionStatus.
- DB and schema — better-sqlite3,
lib/db.ts, tables. - Commands CRUD API — commands and runs (create run row on start; update on exit).
- Process manager — Spawn, PID map, stdout/stderr → DB; wire run/stop/restart to API.
- Log streaming — SSE endpoint; persist chunks in process manager.
- Groups API and group run — Parallel start; on first failure kill all; update runs and group_runs.
- Frontend — Commands list/form, run/stop/restart, last run and running state, log viewer.
- Frontend groups — Group list, create/edit, run group, group run status.
- Env vars — Ensure form and API pass
envthrough to spawn. - Polish — Validation (e.g. zod), error messages, loading states, confirm stop/restart.
- Command parsing: Use
child_process.spawnwith first token as executable and rest as args, or run in a shell (sh -c '...') for pipes/redirects. Shell is more flexible; recommend shell for this launcher and document the choice. - Log chunk size: Append per
dataevent vs buffering (e.g. 4KB); per-event is simpler to start with. - DB file location: e.g.
./data/launcher.dbunder project root; createdata/if missing.