Mac Cloud Access Station — a macOS desktop app for managing database tunnels to AWS across projects. SSH bastions today, AWS SSM Session Manager as the migration target.
See docs/bastion-manager-spec.md for the full
architecture and the phased roadmap; §14 tracks per-phase progress in detail.
Phase 1 — core switcher (SSH). Verified end-to-end against real AWS.
- SQLite config store (
better-sqlite3), projects + environments, seeded with one example project (dev/prod) on first run. TunnelManagerbuilt on theConnectionStrategyseam, withSshStrategylive andSsmStrategystubbed for Phase 3.- Per-environment state machine (stopped → starting_bastion → bastion_ready → tunneling → connected / error), PID tracking, and kill-all-tunnels on quit.
- TCP health check on the local port.
- Two-pane React UI: project/environment list with phase glyphs + SSH/SSM badges, environment detail with controls, live telemetry, and a log console.
Phase 2 — hybrid ports + secrets. Typechecks and lints clean, packages, and boots; not yet exercised live against AWS.
- Pin/un-pin singleton on 5432.
ConfigStore.effectivePort(env)is the one source of truth for which port an environment is on. Pinning un-pins the previous holder and restarts live tunnels onto the right port; a TCP guard refuses to pin when 5432 is held by a non-managed process. - Multi-tunnel coexistence — dev on 5432 and prod on 5433 side by side, falling out of the effective-port model.
- Secrets Manager → connection string. Builds
postgresql://…@localhost:<effectivePort>/<db>from the RDS-standard secret JSON. Clipboard-only, auto-clears after ~30s, never persisted, logged, or returned raw over IPC. - "Check account" —
sts get-caller-identityagainst the project's resolved profile, so you can see which account you're about to touch. - UI: pin star + toggle, "Copy connection string", and an account card.
Phase 3 — project selector, capabilities, key custody. Typechecks and lints clean and packages; the AWS-side account plumbing (§3.1 of the Phase 3 plan) is operator work that hasn't been done yet, so key custody is unexercised.
- Capability registry (
src/shared/capabilities.ts) — the catalog lives in code; theproject_capabilitytable only records which projects have which enabled. The renderer renders the intersection, so a DB row naming a capability this binary doesn't have is ignored rather than fatal. - Project selector + capability tabs. The left rail is now scoped to one
project at a time; selection persists in
ui_stateacross restarts. - Global 5432 chip in the top bar. Because
pin_stateis one row app-wide, the pin holder may belong to a project you aren't looking at — the chip always names its true owner, cross-project, in a warning tone when it's elsewhere. - Key custody without keys on disk.
ssh_key_secret_idon an environment wins overssh_key_path.KeyAgentServicefetches the private key from Secrets Manager, pipes it into an app-ownedssh-agentviassh-add -t <ttl> -(stdin, never a file), andSshStrategyruns against that agent withIdentitiesOnly=yesso it can't fall back to~/.ssh. Agents are reaped on tunnel stop and on quit. - Secrets panel — a read-only second capability, deliberately narrow: it copies the connection string for environments that have a secret, and does not browse or edit Secrets Manager.
Not yet (later phases): SSM strategy, generic command runner, runbooks.
- Node 22+ (Electron 43 requires it). Installed here via
brew install node@22. See.nvmrc.
Node 22 is keg-only, so point at it explicitly (or add it to PATH):
export PATH="/opt/homebrew/opt/node@22/bin:$PATH"
npm startThe first npm start rebuilds the native better-sqlite3 module against
Electron's ABI. If it ever gets out of sync (e.g. after upgrading Electron):
npx electron-rebuild -f -w better-sqlite3You don't need VS Code open to use this. Package it and drop it in
/Applications:
export PATH="/opt/homebrew/opt/node@22/bin:$PATH"
npm run app # package, then replace /Applications/MCAStation.appFirst launch only: right-click → Open to clear Gatekeeper (the build is unsigned). After that it's Spotlight-launchable like anything else.
The SQLite path comes from app.getPath('userData'), which resolves the same
for packaged and dev builds — your config carries over rather than starting
fresh.
Note on packaging: the Vite plugin only copies .vite/ and package.json into
the bundle, but better-sqlite3 is deliberately external (a .node binary
can't be bundled). Before this phase the packaged app therefore launched
straight into Cannot find module 'better-sqlite3' while npm start worked —
dev resolved it from the repo's own node_modules. A packageAfterCopy hook
in forge.config.ts now copies the module in with its Electron-ABI build, and
fails the build if the binary is missing rather than shipping a broken app.
src/
shared/ types + IPC contract (spoken by both processes)
main/ privileged backend — the only place that spawns ssh/aws
config/ SQLite: db bootstrap, ConfigStore, first-run seed
tunnel/ ConnectionStrategy, SshStrategy, SsmStrategy(stub),
TunnelManager, awsService, secret masking
health/ HealthChecker (TCP probe)
ipc/ registerHandlers — wires the contract to the services
preload/ contextBridge — exposes only window.bastion to the UI
renderer/ React UI (App, EnvironmentForm, PhaseGlyph)
The SQLite database lives at:
~/Library/Application Support/MCAStation/config.db
Nothing sensitive is stored there — no passwords, no secret values, no bastion
IPs (those are fetched live). secret_id is only a pointer into Secrets
Manager.