Architecture
A deep dive into how LaunchPad is built -- from the localhost monolith pattern to the four-layer component stack, data model, security boundaries, and deployment topology.
System Overview
LaunchPad is a localhost monolith: a single Next.js 15 (App Router) process that serves both the React frontend and API routes, with SQLite as the sole persistence layer and macOS system utilities (launchctl, plutil, osascript) as the integration surface.
A separate lightweight menubar process communicates with the main server over HTTP on 127.0.0.1. There are no cloud services, no external databases, no network exposure, and no multi-user concerns. The entire system runs on one machine for one person.
The guiding architectural principle is maximal simplicity for a local tool: a single deployable process, a single database file, and zero configuration beyond npm install && npm start.
macOS System ┌──────────────────────────────────────────────────────────────────────┐ │ │ │ ┌──────────────┐ HTTP (localhost:24680) ┌──────────────────────┐ │ │ │ │<───────────────────────>│ │ │ │ │ Browser │ │ Next.js Server │ │ │ │ (Safari/ │ HTML/JSON │ (App Router) │ │ │ │ Chrome) │ │ │ │ │ │ │ │ ┌────────────────┐ │ │ │ └──────────────┘ │ │ API Routes │ │ │ │ │ │ (/api/*) │ │ │ │ ┌──────────────┐ HTTP (localhost:24680) │ └───────┬────────┘ │ │ │ │ │<───────────────────────>│ │ │ │ │ │ Menubar │ GET /api/status │ ┌───────┴────────┐ │ │ │ │ Process │ │ │ Service Layer │ │ │ │ │ (tray) │ │ │ (lib/) │ │ │ │ │ │ │ └───────┬────────┘ │ │ │ └──────────────┘ └──────────┼───────────┘ │ │ │ │ │ ┌────────────────────────┼──────┐ │ │ │ │ │ │ │ ┌────▼─────┐ ┌──────────┐ ┌▼─────────────┐│ │ │ │ │ │ │ ││ │ │ SQLite │ │launchctl │ │ Filesystem ││ │ │ (.db) │ │ (CLI) │ │ (plists, ││ │ │ │ │ │ │ logs) ││ │ └──────────┘ └──────────┘ └──────────────┘│ │ │ │ ┌──────────┐ ┌──────────┐ │ │ │ osascript │ │ plutil │ │ │ │(notif.) │ │ (lint) │ │ │ └──────────┘ └──────────┘ │ │ │ └──────────────────────────────────────────────────────────────────────┘
External interfaces
| Interface | Protocol | Direction | Purpose |
|---|---|---|---|
| Browser | HTTP (localhost) | Bidirectional | User interacts with React UI, which calls API routes |
| Menubar Process | HTTP (localhost) | Menubar -> Server | Polls /api/status for job health summary |
| launchctl | CLI (child_process) | Server -> macOS | Bootstrap/bootout/list/kickstart jobs |
| plutil | CLI (child_process) | Server -> macOS | Validate generated plist XML |
| osascript | CLI (child_process) | Server -> macOS | Fire macOS Notification Center alerts |
| Filesystem | Node.js fs | Bidirectional | Read/write plist files, read stdout/stderr log files |
| SQLite | better-sqlite3 via Drizzle | Bidirectional | All persistent application state |
| WebAuthn | Browser API | Server <-> Browser | Touch ID biometric verification for destructive ops |
Layer Architecture
The codebase is organized into four distinct layers, each with a clear responsibility boundary. Dependencies flow downward only -- the presentation layer never touches the filesystem directly, and the data layer knows nothing about React.
┌─────────────────────────────────────────────────────────────────────────┐ │ PRESENTATION LAYER │ │ │ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐ │ │ │ App Shell │ │ Dashboard │ │ Job List / │ │ Settings │ │ │ │ (layout, │ │ Overview │ │ Detail / │ │ Page │ │ │ │ sidebar, │ │ Page │ │ Create Form │ │ │ │ │ │ header) │ │ │ │ │ │ │ │ │ └──────┬───────┘ └──────┬───────┘ └───────┬───────┘ └──────┬───────┘ │ │ │ │ │ │ │ │ ┌──────┴────────────────┴─────────────────┴────────────────┴───────┐ │ │ │ Shared UI Components │ │ │ │ StatusBadge DataTable CommandPalette LogViewer ScheduleBuilder│ │ │ │ TimelineStrip TagPicker FolderTree ConfirmDialog Toast │ │ │ └──────────────────────────────┬────────────────────────────────────┘ │ │ │ │ ├─────────────────────────────────┼───────────────────────────────────────┤ │ STATE LAYER │ │ │ │ │ ┌──────────────────────────────┴────────────────────────────────────┐ │ │ │ Zustand Stores │ │ │ │ useJobStore useAlertStore useSettingsStore useUIStore │ │ │ └──────────────────────────────┬────────────────────────────────────┘ │ │ │ │ │ ┌──────────────────────────────┴────────────────────────────────────┐ │ │ │ React Query / SWR │ │ │ │ Server state: job list, execution logs, discovery results │ │ │ │ Polling: 30s default, configurable │ │ │ └──────────────────────────────┬────────────────────────────────────┘ │ │ │ │ ├─────────────────────────────────┼───────────────────────────────────────┤ │ APPLICATION LAYER (API Routes) │ │ │ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │ /api/jobs │ │/api/discover│ │ /api/alerts│ │/api/settings│ │ │ │ CRUD + │ │ scan + │ │ list + │ │ get + put │ │ │ │ actions │ │ reconcile │ │ mark read │ │ │ │ │ └──────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ │ │ │ │ │ │ ├─────────┴──────────────┴──────────────┴──────────────┴──────────────────┤ │ SERVICE LAYER (lib/) │ │ │ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────────┐│ │ │ Discovery │ │ Plist Engine │ │ Schedule Translator ││ │ │ Service │ │ │ │ ││ │ │ - scanDirs() │ │ - generate() │ │ - visualToLaunchd() ││ │ │ - parsePlist() │ │ - parse() │ │ - cronToLaunchd() ││ │ │ - reconcile() │ │ - validate() │ │ - nlpToLaunchd() ││ │ └──────────┬────────┘ └────────┬──────────┘ └────────────┬───────────┘│ │ ┌──────────┴────────┐ ┌───────┴───────────┐ ┌────────────┴───────────┐│ │ │ Launchctl │ │ Log Capture │ │ Notification ││ │ │ Wrapper │ │ Service │ │ Service ││ │ │ - bootstrap() │ │ - pollLogs() │ │ - sendNative() ││ │ │ - bootout() │ │ - captureRun() │ │ - createAlert() ││ │ │ - list() │ │ - prune() │ │ - checkFailures() ││ │ └──────────┬────────┘ └────────┬──────────┘ └────────────┬───────────┘│ │ │ │ │ │ ├─────────────┴───────────────────┴──────────────────────────┴────────────┤ │ DATA / SYSTEM LAYER │ │ │ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────────┐│ │ │ Drizzle ORM │ │ child_process │ │ Node.js fs ││ │ │ (SQLite) │ │ (execSync/exec) │ │ (read/write) ││ │ │ │ │ │ │ ││ │ │ Schema, queries, │ │ launchctl, │ │ Plists in ││ │ │ migrations │ │ plutil, │ │ ~/Library/LaunchAgents/ ││ │ │ │ │ osascript │ │ Logs in ││ │ │ ~/.launchpad/ │ │ │ │ ~/Library/Logs/ ││ │ │ launchpad.db │ │ │ │ LaunchPad/ ││ │ └──────────────────┘ └──────────────────┘ └──────────────────────────┘│ └─────────────────────────────────────────────────────────────────────────┘
Layer responsibilities
| Layer | Responsibility | Key Technologies |
|---|---|---|
| Presentation | React pages, shared UI components, routing | Next.js App Router, Tailwind CSS, React Server Components |
| State | Client-side state (selections, filters, UI toggles) and server state cache | Zustand with persist middleware, SWR / React Query |
| Application | HTTP endpoints for all CRUD operations and system actions | Next.js Route Handlers, Server Actions |
| Service | Core business logic: discovery, plist generation, schedule translation, log capture, notifications | Pure TypeScript modules in lib/ |
| Data / System | Persistence (SQLite), macOS CLI integration, filesystem I/O | Drizzle ORM, better-sqlite3, child_process, Node.js fs |
Core Components
Discovery Service
On app startup and on-demand, the Discovery Service scans plist directories, parses each file, cross-references with launchctl list, and synchronizes everything to SQLite.
Trigger: App startup OR user clicks "Refresh" OR POST /api/discover
│
▼
┌─────────────────────────────────────────────────┐
│ 1. Scan filesystem │
│ readdir("~/Library/LaunchAgents/") │
│ readdir("/Library/LaunchAgents/") │
│ Filter: *.plist files only │
└────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ 2. Parse each plist │
│ For each .plist file: │
│ - Read XML content │
│ - Parse with plist npm package │
│ - Extract: Label, ProgramArguments, │
│ Schedule, Triggers, Paths, KeepAlive │
│ - Handle malformed files gracefully │
└────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ 3. Query launchctl │
│ exec("launchctl list") │
│ Parse: { label, pid, lastExitStatus } │
└────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ 4. Merge & reconcile │
│ - Match plist label to launchctl entry │
│ - Determine: loaded/unloaded, running/idle │
│ - Sync to SQLite (insert, update, or mark │
│ as "missing" if plist deleted externally) │
└────────────────────┬────────────────────────────┘
│
▼
Return { found, new, updated, missing, errors }
Plist Engine
The Plist Engine is responsible for generating valid plist XML from a job configuration object, parsing existing plist files into structured data, and validating output via plutil -lint. It uses the plist npm package, which handles both XML and binary plist formats.
generate(config)-- builds a complete plist XML string from a job config object, including theLaunchPadManagedmarker keyparse(xml)-- reads plist XML or binary and returns a typed JavaScript objectvalidate(path)-- shells out toplutil -lint <path>and returns pass/fail with error detailstoPlistXML(obj)-- converts a plain object to plist XML string for preview
Schedule Translator
Converts between three input formats (visual builder state, cron expressions, natural language) and launchd's native schedule keys (StartCalendarInterval and StartInterval). All conversions are bidirectional so the three input modes stay in sync.
| Function | Input | Output |
|---|---|---|
visualToLaunchd() | Day/hour/minute selections | StartCalendarInterval array |
cronToLaunchd() | 5-field cron expression | StartCalendarInterval or StartInterval |
nlpToLaunchd() | Natural language string | Parsed schedule config |
launchdToCron() | Launchd schedule config | Cron expression string |
nextNRuns(config, n) | Any schedule config | Array of next N scheduled timestamps |
Launchctl Wrapper
A type-safe wrapper around all launchctl subcommands. Every call goes through this module so that macOS version differences (legacy load/unload vs. modern bootstrap/bootout) are abstracted away.
// Key exports from the launchctl wrapper
export async function bootstrap(plistPath: string): Promise<void>
export async function bootout(label: string): Promise<void>
export async function list(): Promise<LaunchctlEntry[]>
export async function kickstart(label: string): Promise<void>
export async function print(label: string): Promise<LaunchctlDetail>
// All commands use execSync with:
// - Timeout: 10 seconds
// - Error capture and typed error codes
// - Domain prefix: gui/<uid>
// - Fallback: load/unload for older macOS versions
Log Capture Service
A background polling loop (configurable, default 30 seconds) that detects job completions, reads log files, and stores execution records in SQLite.
Background Polling Loop (every 30s)
For each managed job:
1. Read launchctl list to get current PID + exit code
2. If exit code changed since last poll:
a. Execution completed
b. Read StandardOutPath file
c. Read StandardErrorPath file
d. Compute duration
e. INSERT execution_log record into SQLite
{ job_id, started_at, ended_at, exit_code,
stdout (truncated at 1MB), stderr, duration_ms }
f. If exit_code != 0:
-> Trigger notification flow
3. If PID exists and wasn't running before:
a. Record execution start time in memory
Log directory structure:
~/Library/Logs/LaunchPad/
├── com.launchpad.backup.stdout.log
├── com.launchpad.backup.stderr.log
├── com.launchpad.api-healthcheck.stdout.log
└── com.launchpad.api-healthcheck.stderr.log
Notification Service
Detects failures from execution records, checks per-job notification preferences, fires macOS native notifications via osascript, and creates in-app alert records.
sendNative(title, message)-- fires a macOS Notification Center alert usingosascript -e 'display notification ...'createAlert(jobId, exitCode, stderr)-- inserts an alert record into SQLite with the first 100 characters of stderrcheckFailures(jobId)-- evaluates per-job notification preferences (every failure, after N consecutive, or disabled) before triggering
Data Layer
All persistent state lives in a single SQLite file at ~/.launchpad/launchpad.db, accessed through Drizzle ORM with the better-sqlite3 driver. The schema consists of six tables.
┌──────────────────────┐ ┌──────────────────────┐
│ folders │ │ tags │
├──────────────────────┤ ├──────────────────────┤
│ id INTEGER PK│ │ id INTEGER PK │
│ name TEXT │ │ name TEXT │
│ parent_id INTEGER FK│────┐ │ color TEXT │
│ position INTEGER │ │ │ created_at DATETIME │
│ created_at DATETIME │ │ └──────────┬───────────┘
│ updated_at DATETIME │<───┘ │
└──────────┬───────────┘ │
│ │
│ 1:N │ M:N
│ │
▼ ▼
┌──────────────────────────────────┐ ┌──────────────────┐
│ jobs │ │ job_tags │
├──────────────────────────────────┤ ├──────────────────┤
│ id INTEGER PK │ │ job_id INT FK │
│ label TEXT UNIQUE │ │ tag_id INT FK │
│ display_name TEXT │ └──────────────────┘
│ program_path TEXT │
│ program_args TEXT (JSON) │
│ schedule_type TEXT │ "calendar" | "interval" | "event_only"
│ schedule_config TEXT (JSON) │
│ triggers_config TEXT (JSON) │
│ is_managed BOOLEAN │ true = created by LaunchPad
│ is_enabled BOOLEAN │
│ plist_path TEXT │
│ folder_id INTEGER FK │──> folders.id
│ status TEXT │ "loaded" | "unloaded" | "missing" | "error"
│ notify_pref TEXT │ "every" | "after_n" | "disabled"
│ created_at DATETIME │
│ archived_at DATETIME │ Non-null = soft deleted
└──────────────┬───────────────────┘
│
│ 1:N
▼
┌──────────────────────────────────┐
│ execution_logs │
├──────────────────────────────────┤
│ id INTEGER PK │
│ job_id INTEGER FK │──> jobs.id
│ started_at DATETIME │
│ ended_at DATETIME │
│ duration_ms INTEGER │
│ exit_code INTEGER │
│ stdout TEXT │ Truncated at 1MB
│ stderr TEXT │ Truncated at 1MB
│ created_at DATETIME │
└──────────────────────────────────┘
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ alerts │ │ settings │
├──────────────────────────────────┤ ├──────────────────────────────────┤
│ id INTEGER PK │ │ key TEXT PK │
│ job_id INTEGER FK │ │ value TEXT (JSON) │
│ type TEXT │ │ updated_at DATETIME │
│ message TEXT │ └──────────────────────────────────┘
│ exit_code INTEGER │
│ stderr_excerpt TEXT │
│ is_read BOOLEAN │
│ created_at DATETIME │
└──────────────────────────────────┘
Indexes
| Table | Index | Columns | Purpose |
|---|---|---|---|
jobs | idx_jobs_label | label | Lookup by launchd label (unique) |
jobs | idx_jobs_folder | folder_id | Filter jobs by folder |
jobs | idx_jobs_status | status, is_enabled | Filter by status |
execution_logs | idx_execlog_job_time | job_id, started_at DESC | Job history timeline |
execution_logs | idx_execlog_exit | exit_code, started_at DESC | Find failures |
alerts | idx_alerts_unread | is_read, created_at DESC | Unread alert list |
Query performance
| Query | Frequency | Expected Time |
|---|---|---|
| List all active jobs with folder and tags | Every page load | < 20ms |
| Get job by label | Every launchctl operation | < 5ms |
| Insert execution log | Every job completion | < 10ms |
| Last N executions for a job | Job detail view | < 15ms |
| Count failures in last 24h | Dashboard, menubar | < 10ms |
| Full-text search across jobs | Search / Cmd+K | < 30ms |
| Prune old execution logs | Daily background task | < 500ms |
Security Model
LaunchPad is a localhost-only tool for a single user. The security perimeter is the local machine itself. The primary threats are accidental destructive actions (deleting a critical job) and command injection through unsanitized inputs. There is no network attacker model because the server never binds to a public interface.
Network isolation
┌──────────────────────────────────────────┐ │ 127.0.0.1:24680 ONLY │ │ │ │ Next.js server configuration: │ │ hostname: "127.0.0.1" │ │ (NOT "0.0.0.0", NOT "localhost") │ │ │ │ Firewall: No inbound rules needed │ │ TLS: Not required (loopback only) │ │ CORS: Not configured (same-origin) │ └──────────────────────────────────────────┘
Touch ID / WebAuthn
Destructive operations require biometric verification via the Web Authentication API. A 5-minute session cooldown (configurable) prevents repeated prompts during active management sessions.
Browser ──────── WebAuthn API ──────── macOS Secure Enclave
│
1. Server generates challenge │
2. Browser calls navigator.credentials.get()│
3. macOS prompts Touch ID / password │
4. Signed assertion returned to server │
5. Server verifies assertion │
6. Operation proceeds │
Session cooldown: 5 minutes (configurable)
Stored as: server-side session timestamp
Globally toggleable in settings
Fallback (no Touch ID sensor):
macOS password prompt via LocalAuthentication framework
Input sanitization
| Input | Risk | Mitigation |
|---|---|---|
| Job label | Used in plist filename and launchctl commands | Regex: /^[a-zA-Z0-9._-]+$/, max 255 chars |
| Program path | Passed to ProgramArguments[0] | Validate path exists, no shell metacharacters, absolute path required |
| Program args | Array elements in plist | Each arg validated individually, never concatenated into shell string |
| Env variables | Key-value pairs in plist | Keys: alphanumeric + underscore. Values: arbitrary text (safe in plist XML) |
| Working dir | Path in plist | Validate path exists and is a directory |
| Watch paths | Paths in plist | Validate format, warn if path does not exist |
System job protection
- Jobs in
/Library/LaunchAgents/are always markedis_managed = false - Jobs in
~/Library/LaunchAgents/not created by LaunchPad are markedis_managed = false - All API write endpoints check
is_managed = truebefore proceeding - LaunchPad-created plists include a custom key:
<key>LaunchPadManaged</key><true/>
Data Flows
Job creation flow
The full lifecycle of creating a new managed job, from form submission to launchd registration.
User fills form ──> React form state (Zustand)
│
▼
POST /api/jobs
│
▼
┌──────────────────────┐
│ Validate inputs │
│ - label uniqueness │
│ - path existence │
│ - schedule validity │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Schedule Translator │
│ Convert to │
│ StartCalendar- │
│ Interval or │
│ StartInterval │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Plist Engine │
│ - Build job config │
│ - Generate XML │
│ - Validate w/plutil │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Write plist file │
│ ~/Library/ │
│ LaunchAgents/ │
│ com.launchpad. │
│ <label>.plist │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ launchctl bootstrap │
│ gui/<uid> <path> │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ SQLite INSERT │
│ job record with │
│ config, folder, tags│
└──────────┬───────────┘
│
▼
Return job ID + status
UI updates (SWR revalidation)
Failure notification flow
Execution with exit_code != 0 detected (from log capture)
│
▼
┌─────────────────────────────────────────┐
│ Check per-job notification preferences │
│ - "every failure" (default) │
│ - "after N consecutive failures" │
│ - "disabled" │
└──────────────────┬──────────────────────┘
│
┌─────────┴────────────┐
│ │
Should notify Skip notification
│
▼
┌─────────────────────────────────────────┐
│ 1. Create alert record in SQLite │
│ { job_id, type: "failure", │
│ stderr_excerpt (100 chars) } │
└──────────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 2. Send macOS native notification │
│ osascript: "LaunchPad: Job Failed" │
│ Body: label + exit code + stderr │
└──────────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 3. UI updates via SWR revalidation │
│ - Bell icon badge increments │
│ - Dashboard "Failed (24h)" updates │
│ - Job list status badge turns red │
└─────────────────────────────────────────┘
Real-time status polling
Two independent polling loops keep the dashboard and menubar in sync with system state.
Browser (React) ──> SWR / React Query
│
│ Every 30 seconds (configurable):
│
├──> GET /api/jobs?status=true
│ Returns: jobs with current launchctl status
│
├──> GET /api/alerts?unread=true
│ Returns: unread alert count
│
└──> GET /api/jobs/:id/logs?since=<timestamp>
Returns: new execution records since last poll
(only on job detail page)
Menubar Process ──> Every 60 seconds:
│
└──> GET /api/status
Returns: { total, running, failed24h, disabled }
Menubar icon color:
green = failed24h === 0
yellow = warnings
red = failed24h > 0
Event System
Beyond time-based scheduling, LaunchPad leverages launchd's native event-driven triggers. These are configured through the Job Builder UI and mapped directly to plist keys by the Plist Engine.
| Trigger | Plist Key | Behavior |
|---|---|---|
| WatchPaths | WatchPaths | Job fires when any listed file or directory is modified |
| QueueDirectories | QueueDirectories | Job fires when items appear in a directory; job is expected to process and remove them |
| StartOnMount | StartOnMount | Job fires when a volume (USB drive, network share) is mounted |
| RunAtLoad | RunAtLoad | Job executes immediately when loaded (at login) |
| KeepAlive | KeepAlive | Restart based on conditions: always, on crash, on network change, on path state, or on successful exit |
Event triggers and time-based schedules can be combined. A job can have both a recurring schedule and a file watch trigger. The UI clearly indicates when multiple trigger types are active and prevents conflicting configurations (e.g., StartInterval + StartCalendarInterval simultaneously).
Path-based triggers (WatchPaths, QueueDirectories) validate that paths exist at configuration time. A missing path produces a warning but does not block saving -- the path may be created before the job fires.
Deployment
LaunchPad deploys as two LaunchAgent processes on the local machine, plus a SQLite database file and a log directory.
macOS User Session LaunchAgent: com.launchpad.server ┌────────────────────────────────────────────────────┐ │ RunAtLoad: true │ │ KeepAlive: true │ │ Program: /usr/local/bin/node │ │ Args: [node, .next/standalone/server.js] │ │ Port: 24680 (env: LAUNCHPAD_PORT) │ │ Stdout: ~/Library/Logs/LaunchPad/server.log │ │ Stderr: ~/Library/Logs/LaunchPad/server.err │ └────────────────────────────────────────────────────┘ LaunchAgent: com.launchpad.menubar ┌────────────────────────────────────────────────────┐ │ RunAtLoad: true │ │ KeepAlive: true │ │ Program: menubar/LaunchPadMenu │ │ Env: LAUNCHPAD_URL=http://127.0.0.1:24680 │ └────────────────────────────────────────────────────┘ Data locations: ┌────────────────────────────────────────────────────┐ │ Database: ~/.launchpad/launchpad.db │ │ Logs: ~/Library/Logs/LaunchPad/ │ │ Plists: ~/Library/LaunchAgents/com.launchpad.* │ └────────────────────────────────────────────────────┘
Setup process
- Clone repository and run
npm install npm run buildproduces standalone Next.js outputnpm run setupcreates directories, runs Drizzle migrations, generates and installs LaunchAgent plists, and loads both agents vialaunchctl bootstrap- Dashboard available at
http://127.0.0.1:24680
Environment variables
| Variable | Default | Description |
|---|---|---|
LAUNCHPAD_PORT | 24680 | Server port |
LAUNCHPAD_DB_PATH | ~/.launchpad/launchpad.db | SQLite database file path |
LAUNCHPAD_LOG_DIR | ~/Library/Logs/LaunchPad | Directory for managed job logs |
LAUNCHPAD_POLL_INTERVAL | 30000 | Status polling interval in milliseconds |
LAUNCHPAD_LOG_RETENTION_DAYS | 90 | Days to keep execution history |
LAUNCHPAD_TOUCH_ID_COOLDOWN | 300 | Touch ID session cooldown in seconds |
NODE_ENV | production | Node environment |
Design Decisions
Key architectural decisions (ADRs) and the reasoning behind them.
Next.js monolith over separate frontend + backend
A single Next.js 15 App Router process serves both the React UI and API Route Handlers. For a single-user local tool, the complexity of CORS, a second language, or a second process is not justified. Server Components reduce client-side JavaScript, and API routes have direct access to child_process and the filesystem.
SQLite over PostgreSQL
A single file at ~/.launchpad/launchpad.db -- zero configuration, zero running processes. Expected data volume (30-100 jobs, thousands of execution logs) is trivial for SQLite. WAL mode provides crash safety. Backup is literally copying one file.
Drizzle ORM over Prisma
No binary generation (Prisma Client is ~15MB), better SQLite support, SQL-like API that maps cleanly to the mental model, and direct access to better-sqlite3 for raw queries when needed.
Polling over WebSockets
SWR 30-second polling is sufficient for a single-user local tool. Simpler implementation, works with standard HTTP caching, and SWR handles deduplication and focus-based revalidation. SSE is used only for the live log tailing endpoint.
File-based log capture over real-time piping
Uses launchd's native StandardOutPath/StandardErrorPath file redirection. No wrapper scripts, no IPC complexity. Log files persist even if LaunchPad is not running.
Separate menubar process
The menubar is a pure consumer of /api/status. Keeping it separate means the main server stays lean, the menubar can be built and updated independently, and if it crashes, the server and dashboard continue working.
Jobs are archived (set archived_at) rather than hard-deleted. This preserves execution history and allows a 30-second undo toast before permanent removal. The trade-off is slightly more complex queries (must filter on archived_at IS NULL).
Directory Structure
launchpad/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── layout.tsx # Root layout (sidebar, header, providers)
│ │ ├── page.tsx # Dashboard overview (/)
│ │ ├── jobs/
│ │ │ ├── page.tsx # Job list view (/jobs)
│ │ │ ├── new/page.tsx # Job creation form (/jobs/new)
│ │ │ └── [id]/
│ │ │ ├── page.tsx # Job detail view (/jobs/:id)
│ │ │ └── edit/page.tsx # Job edit form (/jobs/:id/edit)
│ │ ├── settings/page.tsx # Settings page (/settings)
│ │ ├── api/
│ │ │ ├── jobs/ # Job CRUD + actions
│ │ │ ├── discover/ # Scan + reconcile
│ │ │ ├── folders/ # Folder CRUD
│ │ │ ├── tags/ # Tag CRUD
│ │ │ ├── alerts/ # Alert management
│ │ │ ├── settings/ # Settings get/put
│ │ │ ├── status/ # Menubar health summary
│ │ │ └── health/ # Server health check
│ │ └── globals.css # Tailwind + design tokens
│ │
│ ├── components/ # React components
│ │ ├── ui/ # Primitives (button, badge, input...)
│ │ ├── layout/ # Sidebar, header, folder tree
│ │ ├── dashboard/ # Summary cards, timeline, activity
│ │ ├── jobs/ # Job table, form, detail panel
│ │ ├── schedule/ # Visual builder, cron, NLP input
│ │ ├── triggers/ # Watch paths, keep-alive config
│ │ ├── logs/ # Execution timeline, log viewer
│ │ └── alerts/ # Alert dropdown, bell icon
│ │
│ ├── lib/ # Core business logic
│ │ ├── db/ # Drizzle schema, queries, migrations
│ │ ├── engine/ # Discovery, plist, launchctl wrapper
│ │ ├── schedule/ # Schedule translation + next runs
│ │ ├── logs/ # Log capture, pruning, live tail
│ │ ├── notify/ # macOS notifications, alert checker
│ │ └── auth/ # WebAuthn + session management
│ │
│ ├── hooks/ # React hooks (SWR, keyboard, polling)
│ └── store/ # Zustand stores
│
├── menubar/ # Separate menubar process
├── scripts/ # Setup, uninstall, migrate
├── drizzle/ # Migration files
├── next.config.ts
├── tailwind.config.ts
└── package.json