← All work

Zashiki Warashi

A house spirit for the localhost pile

Live
Platform
macOS-first
Catalog
Machine-local SQLite
Rust tests
44
License
MIT
Releases
Unsigned (Gatekeeper)
Cloud
None
Role · Solo (product, Rust/Tauri, React, OSS)Timeline · 2026-presentRead · ~18 min (skim or deep)
  • Tauri 2
  • Rust
  • React
  • SQLite
  • Docker Compose

90-second story

AI-assisted coding leaves a pile of local repos that are hard to remember and operate. I built Zashiki Warashi as a macOS control plane that observes existing projects instead of taxing them with required YAML. Tauri 2 + Rust own FS, processes, Docker, and SQLite. React only renders and invokes. One-button start/stop uses the login shell and process groups so PATH and children match Terminal. Compose peek, port remaps, and deep-links into Cursor, Finder, and Compass glue tools you already have. Live as MIT OSS with unsigned GitHub Releases; Gatekeeper friction is documented on purpose.

The story

AI tooling made it cheap to spin up another Nest app, another Compose stack, another half-finished experiment. The hard part became Monday morning: which folder was that project in, how do I start it, who owns 5432, and where is the Mongo URI.

The problem: Docker Desktop, Terminal, Raycast, and Finder each solve a slice. None of them remember your pile, start it with one click, peek DB endpoints, and hand you Compass without spelunking.

Constraints I designed around:

  • macOS GUI apps often lack the interactive PATH (fnm, nvm, Homebrew Docker).
  • Closing the control plane must not kill running projects.
  • Do not invent a required per-repo config tax. Observe what is already there.
  • Do not replace Docker Desktop, Portainer, or a query editor. Glue those tools.
  • No cloud sync, accounts, or secrets stored in SQLite.

Approach: what I considered

  • Required zashiki.toml in every repoRejected

    Another framework tax. Breaks the "works with existing repos immediately" promise.

  • Electron, all TypeScriptRejected

    Heavier binary; weaker fit for process groups, FS, and OS deep-links I needed in Rust.

  • Observe + machine-local catalog (Tauri 2)Chosen

    Infer start commands and compose info. Overrides live in app data. Native enough for lifecycle.

Named after the 座敷童子: a house spirit that looks after the place. The product job is the same. Watch the house. Do not rebuild it.

How it works

In one sentence: You add or scan local folders into a machine-local catalog; the app infers how to start each project, runs start/stop through your login shell in a new process group, peeks Compose/DB info from files and Docker, and deep-links into tools already on the Mac.

The flow in plain language

This is what happens on a useful Monday:

  1. Add or scan projects. Point at a folder, or set scan roots in Settings. Candidates are folders with package.json, Compose, Cargo, Go, Makefile, or .git.
  2. Infer a start command. Prefer npm run dev, then npm start, then docker compose up, then Make/Cargo/Go. You can override; the override stays in app SQLite, not in the repo.
  3. Hit Start. The Rust backend runs the command via $SHELL -lc so your normal PATH applies. The child becomes a new process group. PID and process group id are persisted.
  4. Databases come along when Compose says so. Nested compose (root and depth 2) can bring DB-like services up with --wait. Stop app does not stop those DBs unless you use the Stack panel.
  5. Peek the stack. Host, port, user, and db from compose/env. Copy a URI. Open Compass for Mongo. Passwords are derived at runtime and masked; they are not stored in SQLite.
  6. Port conflicts get a choice. Stop the occupant, or remap. Remaps live in app-data compose overrides by default so the repo stays clean.
  7. Open tools you already use. Finder, Cursor, Compass. Coffee keep-awake wraps caffeinate and pmset when you need the lid closed without killing the pile.

If that makes sense, the diagram below shows where each piece sits. The chapters after explain how inference, lifecycle, stack peek, and snappy select work.

Why this diagram: Commands are a thin IPC boundary. Services own lifecycle and compose. Repositories own SQLite only.

Layer map: React never touches Docker or SQLite
  1. 1. Select a catalog project

    Sidebar paints from memory. Stack peek reads compose files only on the hot path.

  2. 2. Resolve start command

    Override from SQLite, else inferred from package.json / compose / Make / Cargo / Go.

  3. 3. Spawn via login shell

    $SHELL -lc in a new process group. Stdio lands in app-data logs.

  4. 4. Bring DB services if needed

    Nested compose up -d --wait for DB-like services only.

  5. 5. Reconcile ports if occupied

    Stop occupant or remap into app-data overrides + spawn env.

  6. 6. Peek and deep-link

    Copy URI, open Compass / Cursor / Finder when you need them.

One Monday start mapped to the steps above

Observe, don’t tax

The daily pain is remembering projects that already exist. A new required config file would only help the repos I wrote after adopting the tool. That is the wrong direction for AI-generated piles.

Inference walks common signals and stops at the first good start command:

infer.rs
pub fn infer_start_command(project_path: &Path) -> Option<String> {
  if let Some(cmd) = infer_from_package_json(project_path) {
      return Some(cmd);
  }
  if primary_compose_file(project_path).is_some() {
      return Some("docker compose up".to_string());
  }
  if let Some(cmd) = infer_from_makefile(project_path) {
      return Some(cmd);
  }
  if project_path.join("Cargo.toml").is_file() {
      return Some("cargo run".to_string());
  }
  if project_path.join("go.mod").is_file() {
      return Some("go run .".to_string());
  }
  None
}
Infer start from files already in the repo; never invent a required format

Overrides are first-class. Exotic setups get a command stored in the app DB. The repo stays untouched unless the user later opts into writing remaps into .env or compose.

Login shell and process groups

Abstract: GUI-launched apps on macOS often miss fnm/nvm/Homebrew PATH. Start commands must look like Terminal. Stop must kill the whole tree, and reopen must not trust a recycled PID alone.

Spawn goes through the user’s shell with -lc, then joins a new process group before detach:

process_service.rs
fn spawn_login_shell(
  cwd: &str,
  command: &str,
  log_path: Option<&Path>,
) -> Result<(i32, i32), AppError> {
  let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
  let mut child = Command::new(&shell);
  child.args(["-lc", command]).current_dir(cwd).stdin(Stdio::null());
  apply_log_stdio(&mut child, log_path)?;

  #[cfg(unix)]
  unsafe {
      child.pre_exec(|| {
          if libc::setpgid(0, 0) != 0 {
              return Err(std::io::Error::last_os_error());
          }
          Ok(())
      });
  }

  let child = child.spawn()?;
  let pid = child.id() as i32;
  std::mem::forget(child); // process must outlive the app
  Ok((pid, pid))
}
Login shell spawn + process group so stop can signal the whole tree

Stop sends SIGTERM to the process group, then SIGKILL. Rehydrate requires the PID to be alive and getpgid(pid) to match the stored group. PID reuse after reboot is a real failure mode if you only check kill(pid, 0).

Stdout and stderr go to {app_data}/logs/{project_id}/current.log, rotated on each start. An in-app tailer emits events. Pipes would die when the window closes; file-backed logs survive.

How to run project commands

  • Spawn npm/docker with the GUI app PATHRejected

    Breaks every machine using fnm, nvm, or Homebrew Docker outside a login shell.

  • Always $SHELL -lc, even for docker ps on selectRejected

    Correct PATH, but about 0.3 to 1s tax on every project click. Too slow for the hot path.

  • Login shell for user start/stop; cached docker bin for statusChosen

    PATH for projects. Fast compose/status without paying zsh startup every click.

Stack peek and port remaps

Abstract: Many AI scaffolds put Compose under local/ and collide on 5432 / 27017 / 6379. V1 peeks and remaps; it does not provision databases or become a Docker GUI.

Compose discovery walks the project root and depth 2. On Start, only DB-like services get docker compose -f … up -d --wait. The Stack panel offers explicit Up/Stop for those services. Non-DB compose services stay out of the auto path.

Port conflicts offer two exits:

  1. Stop the occupant (another catalog project, a Docker container, or a native process).
  2. Remap. Write {app_data}/compose-overrides/{id}.yml, record port_overrides in SQLite, and inject rewritten DATABASE_URL (and friends) at spawn time.

An optional checkbox can write into the repo .env or compose. Default is leave the repo alone so Terminal and Zashiki do not fight over who owns the source of truth.

Where remaps live

  • Always rewrite the repo compose / .envRejected

    Mutates AI-generated trees by default. Surprises git status and teammates.

  • Remap in app data; optional repo writeChosen

    Safe default. Terminal may still see stale ports unless the user opts in.

Connection URIs are derived at peek time from compose and env. Passwords are not persisted. The UI masks secrets. Safer if app data is ever inspected or synced.

Snappy select

Abstract: Selecting a project must feel instant. Docker and login-shell work belong in the background, not on the paint path.

Early builds paid $SHELL -lc 'docker compose ps' on every select. Zsh startup plus Compose made the inspector feel sticky. The fix was a hard rule:

  • Selection paints from memory in the same frame (sidebar, inspector chrome, log chrome).
  • peek_project_stack reads files only. No Docker on that path.
  • Running dots refresh in the background after select.
  • Resolve docker once at startup via the login shell; reuse the path.
docker_bin.rs
static DOCKER_BIN: OnceLock<Option<PathBuf>> = OnceLock::new();

pub fn resolve_docker_bin() -> Option<PathBuf> {
  DOCKER_BIN
      .get_or_init(|| {
          let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
          let output = Command::new(&shell)
              .args(["-lc", "command -v docker"])
              .stdin(Stdio::null())
              .output()
              .ok()?;
          if !output.status.success() {
              return None;
          }
          let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
          if path.is_empty() {
              return None;
          }
          Some(PathBuf::from(path))
      })
      .clone()
}
Resolve docker once; never pay login-shell tax on every status call

Process-spawning Tauri commands are async plus spawn_blocking so the UI thread stays free. Stale stack or logs from the previous project must clear on id change (or remount with key={projectId}).

Layout uses react-resizable-panels with Cursor-style sidebar / inspector / logs splits, persisted in localStorage, not SQLite. Transient settings and scan results are overlays so they do not steal pane height.

What I took away

Zashiki Warashi is live as MIT OSS. Download the macOS build from GitHub Releases. The public landing is zashiki.anireco.app. Builds are not notarized yet; Gatekeeper needs right-click Open or xattr. That friction is documented on purpose.

What I keep coming back to:

  1. Observe before you tax. A catalog that works on existing repos beats a prettier YAML that nobody will adopt.
  2. Match Terminal for spawn; optimize status separately. Login shell is correct for user commands and wrong as a per-click tax.
  3. Processes must outlive the dashboard. Process groups, file-backed logs, and pid+pgid rehydrate are the product, not polish.
  4. Glue beats replacement. Cursor, Finder, Compass, Docker Desktop already exist. Deep-link and peek; do not rebuild them.
  5. Honesty ships. Unsigned releases and Coffee’s admin prompt for lid-close are real constraints. Naming them builds trust faster than fake social proof.

Next when it earns the cost: Apple notarization and a Homebrew cask. Until then, stars, issues, and daily use matter more than a paywall.