Case study
Zashiki Warashi
A house spirit for the localhost pile
- Platform
- macOS-first
- Catalog
- Machine-local SQLite
- Rust tests
- 44
- License
- MIT
- Releases
- Unsigned (Gatekeeper)
- Cloud
- None
- 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:
- 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. - Infer a start command. Prefer
npm run dev, thennpm start, thendocker compose up, then Make/Cargo/Go. You can override; the override stays in app SQLite, not in the repo. - Hit Start. The Rust backend runs the command via
$SHELL -lcso your normal PATH applies. The child becomes a new process group. PID and process group id are persisted. - 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. - 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.
- Port conflicts get a choice. Stop the occupant, or remap. Remaps live in app-data compose overrides by default so the repo stays clean.
- Open tools you already use. Finder, Cursor, Compass. Coffee keep-awake wraps
caffeinateandpmsetwhen 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.
UI
React + panes
Render + typed invoke
IPC
Tauri commands
Parse, call service, map errors
Services
Catalog / Process
Infer, spawn, stop
Stack / Ports
Compose peek, remaps
Open / Coffee
Deep-links, keep-awake
Machine
SQLite
Catalog + run state
Login shell
User PATH for start
Docker CLI
Cached bin path
Why this diagram: Commands are a thin IPC boundary. Services own lifecycle and compose. Repositories own SQLite only.
1. Select a catalog project
Sidebar paints from memory. Stack peek reads compose files only on the hot path.
2. Resolve start command
Override from SQLite, else inferred from package.json / compose / Make / Cargo / Go.
3. Spawn via login shell
$SHELL -lc in a new process group. Stdio lands in app-data logs.
4. Bring DB services if needed
Nested compose up -d --wait for DB-like services only.
5. Reconcile ports if occupied
Stop occupant or remap into app-data overrides + spawn env.
6. Peek and deep-link
Copy URI, open Compass / Cursor / Finder when you need them.
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:
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
}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:
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))
}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:
- Stop the occupant (another catalog project, a Docker container, or a native process).
- Remap. Write
{app_data}/compose-overrides/{id}.yml, recordport_overridesin SQLite, and inject rewrittenDATABASE_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_stackreads files only. No Docker on that path.- Running dots refresh in the background after select.
- Resolve
dockeronce at startup via the login shell; reuse the path.
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()
}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:
- Observe before you tax. A catalog that works on existing repos beats a prettier YAML that nobody will adopt.
- Match Terminal for spawn; optimize status separately. Login shell is correct for user commands and wrong as a per-click tax.
- Processes must outlive the dashboard. Process groups, file-backed logs, and pid+pgid rehydrate are the product, not polish.
- Glue beats replacement. Cursor, Finder, Compass, Docker Desktop already exist. Deep-link and peek; do not rebuild them.
- 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.