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

InterfaceProtocolDirectionPurpose
BrowserHTTP (localhost)BidirectionalUser interacts with React UI, which calls API routes
Menubar ProcessHTTP (localhost)Menubar -> ServerPolls /api/status for job health summary
launchctlCLI (child_process)Server -> macOSBootstrap/bootout/list/kickstart jobs
plutilCLI (child_process)Server -> macOSValidate generated plist XML
osascriptCLI (child_process)Server -> macOSFire macOS Notification Center alerts
FilesystemNode.js fsBidirectionalRead/write plist files, read stdout/stderr log files
SQLitebetter-sqlite3 via DrizzleBidirectionalAll persistent application state
WebAuthnBrowser APIServer <-> BrowserTouch 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

LayerResponsibilityKey Technologies
PresentationReact pages, shared UI components, routingNext.js App Router, Tailwind CSS, React Server Components
StateClient-side state (selections, filters, UI toggles) and server state cacheZustand with persist middleware, SWR / React Query
ApplicationHTTP endpoints for all CRUD operations and system actionsNext.js Route Handlers, Server Actions
ServiceCore business logic: discovery, plist generation, schedule translation, log capture, notificationsPure TypeScript modules in lib/
Data / SystemPersistence (SQLite), macOS CLI integration, filesystem I/ODrizzle 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 the LaunchPadManaged marker key
  • parse(xml) -- reads plist XML or binary and returns a typed JavaScript object
  • validate(path) -- shells out to plutil -lint <path> and returns pass/fail with error details
  • toPlistXML(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.

FunctionInputOutput
visualToLaunchd()Day/hour/minute selectionsStartCalendarInterval array
cronToLaunchd()5-field cron expressionStartCalendarInterval or StartInterval
nlpToLaunchd()Natural language stringParsed schedule config
launchdToCron()Launchd schedule configCron expression string
nextNRuns(config, n)Any schedule configArray 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.

lib/engine/launchctl.ts
// 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 using osascript -e 'display notification ...'
  • createAlert(jobId, exitCode, stderr) -- inserts an alert record into SQLite with the first 100 characters of stderr
  • checkFailures(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

TableIndexColumnsPurpose
jobsidx_jobs_labellabelLookup by launchd label (unique)
jobsidx_jobs_folderfolder_idFilter jobs by folder
jobsidx_jobs_statusstatus, is_enabledFilter by status
execution_logsidx_execlog_job_timejob_id, started_at DESCJob history timeline
execution_logsidx_execlog_exitexit_code, started_at DESCFind failures
alertsidx_alerts_unreadis_read, created_at DESCUnread alert list

Query performance

QueryFrequencyExpected Time
List all active jobs with folder and tagsEvery page load< 20ms
Get job by labelEvery launchctl operation< 5ms
Insert execution logEvery job completion< 10ms
Last N executions for a jobJob detail view< 15ms
Count failures in last 24hDashboard, menubar< 10ms
Full-text search across jobsSearch / Cmd+K< 30ms
Prune old execution logsDaily 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

InputRiskMitigation
Job labelUsed in plist filename and launchctl commandsRegex: /^[a-zA-Z0-9._-]+$/, max 255 chars
Program pathPassed to ProgramArguments[0]Validate path exists, no shell metacharacters, absolute path required
Program argsArray elements in plistEach arg validated individually, never concatenated into shell string
Env variablesKey-value pairs in plistKeys: alphanumeric + underscore. Values: arbitrary text (safe in plist XML)
Working dirPath in plistValidate path exists and is a directory
Watch pathsPaths in plistValidate format, warn if path does not exist

System job protection

  • Jobs in /Library/LaunchAgents/ are always marked is_managed = false
  • Jobs in ~/Library/LaunchAgents/ not created by LaunchPad are marked is_managed = false
  • All API write endpoints check is_managed = true before 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.

TriggerPlist KeyBehavior
WatchPathsWatchPathsJob fires when any listed file or directory is modified
QueueDirectoriesQueueDirectoriesJob fires when items appear in a directory; job is expected to process and remove them
StartOnMountStartOnMountJob fires when a volume (USB drive, network share) is mounted
RunAtLoadRunAtLoadJob executes immediately when loaded (at login)
KeepAliveKeepAliveRestart 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 validation

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

  1. Clone repository and run npm install
  2. npm run build produces standalone Next.js output
  3. npm run setup creates directories, runs Drizzle migrations, generates and installs LaunchAgent plists, and loads both agents via launchctl bootstrap
  4. Dashboard available at http://127.0.0.1:24680

Environment variables

VariableDefaultDescription
LAUNCHPAD_PORT24680Server port
LAUNCHPAD_DB_PATH~/.launchpad/launchpad.dbSQLite database file path
LAUNCHPAD_LOG_DIR~/Library/Logs/LaunchPadDirectory for managed job logs
LAUNCHPAD_POLL_INTERVAL30000Status polling interval in milliseconds
LAUNCHPAD_LOG_RETENTION_DAYS90Days to keep execution history
LAUNCHPAD_TOUCH_ID_COOLDOWN300Touch ID session cooldown in seconds
NODE_ENVproductionNode 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.

Soft delete by default

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

Project layout
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