Spin up a complete local development environment for a whole product — from a single config file.
Running a real product locally usually means a checklist no one enjoys: clone five repos, start Postgres, install the backend's dependencies, run the API, npm install the web app, start it, maybe boot a mobile app, and wire up the handful of cloud services you can't run locally. tarjan turns that checklist into one command.
tarjan upIt checks the tools you need, clones every repo into a fresh workspace, generates a VS Code workspace so all the repos open in one window, then starts every service in dependency order — each gated on a health check — and streams their logs. Ctrl+C tears the whole thing down cleanly.
Think Terraform / Ansible / .NET Aspire, but for your local dev environment.
Status: early MVP. The core lifecycle (check → clone → workspace → orchestrate → teardown) works end-to-end. See Roadmap.
📖 Landing page and full docs live in
website/(Next.js + Fumadocs, deployed to GitHub Pages).
tarjan is written in Go on purpose — the whole point is that running our tool should require almost nothing:
| Goal | How Go delivers |
|---|---|
| Minimal install | Ships as a single static binary. No runtime, no node_modules, no .NET SDK just to launch the launcher. |
| Mac + Windows + Linux | One cross-compiled binary per platform; users never need a compiler. |
| Fast startup | Native binary, millisecond cold start — not a JVM/Node/.NET warm-up. |
| Built for orchestration | Goroutines make "start N services, watch their health, multiplex their logs" natural. |
The product you bring up can need .NET, npm, Docker, whatever — those get installed lazily, per environment. But the launcher itself stays dependency-free.
macOS / Linux / WSL:
curl -fsSL https://raw.githubusercontent.com/stevenzg/tarjan/main/install.sh | bashWindows PowerShell:
irm https://raw.githubusercontent.com/stevenzg/tarjan/main/install.ps1 | iexThe script detects your OS/arch, downloads the matching binary from the
latest release, verifies
its checksum, and puts tarjan on your PATH. Pin a version with
VERSION=v0.1.0. No GitHub credential is needed; a GH_TOKEN /
GITHUB_TOKEN is used when present to raise API rate limits (useful on CI).
Prefer not to pipe to a shell? Download a binary directly from the releases page, or build it yourself:
# With Go 1.25+
go install github.com/stevenzg/tarjan@latest # installs the `tarjan` binary
# or from a clone
git clone https://github.com/stevenzg/tarjan
cd tarjan
make build # -> ./bin/tarjan (with version info)Check your install with tarjan version, and update in place with
tarjan upgrade (add --check to only see whether a newer release exists).
tarjan up also prints a one-line notice when a newer release is available
(at most once a day; silence it with TARJAN_NO_UPDATE_CHECK=1).
tarjan init # writes a starter tarjan.yaml (or `tarjan init --star` for Starlark)
$EDITOR tarjan.yaml # point it at your repos and services
tarjan up # clone, check tools, generate workspace, start everythingOther commands:
tarjan upgrade # update tarjan to the latest release (--check to only look)
tarjan doctor # check required tools are installed & up to date
tarjan doctor --install # ...and install the missing ones (opt-in)
tarjan doctor api # ...checking only the toolchain the api service needs
tarjan validate # parse the config and print the service start order
tarjan pull # git pull every cloned repo in the current workspace
tarjan pull 0.1.0 # ...in the named "<name>-0.1.0" workspace
tarjan logs # list services with captured logs
tarjan logs api -f # follow a service's log
tarjan status # live service status (ready/starting) of a running env
tarjan status --watch # ...refreshed every second (lite dashboard)
tarjan ui # full-screen dashboard: logs + restart/reload keys
tarjan restart api # restart one service in a running env, in place
tarjan reload # reconcile a running env to the edited config
tarjan exec api -- npm test # run a command in a service's dir + environment
tarjan exec api # ...or open a shell in its context
tarjan down # stop an environment started elsewhere
tarjan workspace --open # (re)generate the VS Code workspace and open it
tarjan up --no-start # just clone + prepare the workspace, don't run services
tarjan up --workspace ./dir # reuse a specific workspace instead of a fresh one
tarjan up --version 0.1.0 # use a named, reusable workspace (<name>-<version>)
tarjan up api # start just this service (+ its dependencies)
tarjan up api web # ...or several — positional args, same as --only
tarjan up --only api # the flag form of the above
tarjan up --profile frontend # activate a profile group (see Profiles below)
tarjan up --only web --no-deps # exactly one service, skip dependency expansionBy default each tarjan up materialises a fresh, timestamped workspace under workspaceRoot (e.g. ~/tarjan/myproduct/20260628-150405/), so every run starts from a clean slate and old runs stay around for reference.
Set a top-level version: (or pass tarjan up --version <label>) to instead use a named, reusable workspace at <workspaceRoot>/<name>-<version> (e.g. ~/tarjan/myproduct/myproduct-0.1.0/). Repeated up runs of the same version reuse that directory — already-cloned repos and finished setup steps are skipped — so it comes back up fast. Use different versions (--version pr-42) to keep parallel environments side by side. --workspace ./dir still overrides with an explicit path.
Parallel workspaces coexist on disk, but only one environment runs at a time: services publish fixed host ports (5432:5432, http://localhost:8080, …) that every workspace shares, so bringing up a second environment collides with the first ("address already in use" — the already-running check only guards re-up of the same workspace). Switch by stopping one and starting the other; because clones and setup are reused, the swap is quick:
tarjan up --version feat-a # work on feature A
# ...later: Ctrl+C (or `tarjan down`), then
tarjan up --version feat-b # feature B — its workspace comes back fastname: myproduct
workspaceRoot: ~/tarjan/myproduct # each `up` creates a fresh dir under here
requires: # tools that must exist before starting
- name: git
- name: docker
optional: true # warn instead of fail if missing
- name: node
minVersion: "20"
mise: node@20 # versioned runtime → mise (with --install)
- name: psql
package: # OS client → host package manager
apt: postgresql-client
brew: libpq
repos: # cloned into the workspace
- name: api
url: https://github.com/your-org/api.git
branch: main
- name: web
url: https://github.com/your-org/web.git
dir: frontend/web # optional custom checkout path
services: # started in dependency order
- name: postgres
docker: # run as a container...
image: postgres:16
ports: ["5432:5432"]
env: { POSTGRES_PASSWORD: dev }
health:
tcp: "localhost:5432" # wait until the port accepts connections
- name: api
workdir: api # ...or as a local process in a repo
setup: ["dotnet restore"] # one-shot, runs once per workspace
command: "dotnet run"
env:
ConnectionStrings__Default: "Host=localhost;Database=app;Username=postgres;Password=dev"
dependsOn: [postgres]
health:
http: "http://localhost:8080/health"
- name: web
workdir: frontend/web
setup: ["npm install"]
command: "npm run dev"
dependsOn: [api]
workspace:
vscode: true # generate <name>.code-workspaceTool (requires[])
| Field | Meaning |
|---|---|
name |
Executable looked up on PATH. |
minVersion |
Minimum acceptable version (compared as major.minor.patch). |
versionCommand |
How to probe the version (default <name> --version). |
mise |
Install a versioned runtime via the mise version manager. A mise spec (dotnet@10) or a bare name (node, versioned from minVersion). tarjan installs mise itself if absent and puts its shims on PATH. |
package |
Install an OS client via the host's auto-detected package manager (apt/brew/dnf/pacman/apk/zypper/choco/scoop/winget). One package name, or a map keyed by manager when it differs ({apt: postgresql-client, brew: libpq}). |
install |
Bespoke install command (escape hatch) — a single string, or a per-OS map (darwin/linux/windows). |
installHint |
Free-text pointer shown when the tool is missing and no provider above is set. |
optional |
Missing → warning instead of error. |
services |
Scope the tool to the services that need it: it is checked only when one of them is in the run's selection. A tool with no services is a baseline — always checked. |
Declare what, not how-per-OS. mise handles versioned language runtimes
(dotnet, node, python, go, java, flutter…) and package handles OS client tools
(psql, redis-cli…), so the same config brings a machine up to spec on any OS —
and covers new languages/clients you add later without hand-writing per-OS
commands. When several are set on one tool, the most explicit wins:
install > mise > package (use install to override a provider for one
awkward tool).
Scoped to the selection. A partial run only demands the toolchains it will
actually use: a tool tagged services: [postgres] is skipped when no selected
service (dependencies included) is postgres. So tarjan up web doesn't fail
on a missing backend toolchain, and tarjan doctor <service> answers "what does
this service need on my machine?" before you start it. Untagged tools are the
baseline every run checks.
Per-repo versions. A repo that pins its own tool versions — a mise.toml or
.tool-versions in its root — owns them: before a service's setup/command runs,
tarjan runs mise install in its workdir, so each repo gets exactly the runtime
it declares and different repos can use different versions of the same tool
side by side (mise resolves the version per directory). The requires: entries
are then just the environment baseline for repos that pin nothing. Keep the pin
in the repo, not in tarjan.yaml: the same file also drives CI, Docker builds and
IDEs, so a runtime upgrade is one PR in the repo that owns it.
Tool installation is opt-in: tarjan never installs anything without
--install (on tarjan up or tarjan doctor). Without it, a missing tool fails
with the exact command it would run, so nothing happens to your machine
behind your back. A new contributor gets a working toolchain with:
tarjan doctor --install # install whatever's missing, then you're ready
tarjan upAgent fallback (--ai). For the long tail of tools no provider covers, add
--ai to --install: tarjan shells out to an agent CLI (the Claude
CLI by default) to install what the
deterministic providers couldn't, then re-checks. tarjan never embeds an agent —
it just drives whatever CLI is on PATH, so the default path stays fast,
offline, and dependency-free; the agent runs only when you ask for it.
tarjan doctor --install --ai # deterministic first, agent for the restOverride the CLI or its flags with TARJAN_AI_CLI / TARJAN_AI_ARGS (the prompt
is fed on stdin). Note the Claude CLI's bypassPermissions mode is refused when
running as root, so the fallback targets a normal (non-root) developer machine.
Repo (repos[]): name, url, branch (optional), dir (optional checkout path).
Service (services[])
| Field | Meaning |
|---|---|
name |
Unique service name. |
kind |
service (default, long-running) or job (run-to-completion; see below). |
workdir |
Working directory inside the workspace (usually a cloned repo). |
setup |
One-shot commands run once per workspace before first start. |
command |
Long-running command (ignored when docker is set). |
docker |
Run as a container: image (pull) or build (build from source), plus ports, env, volumes, args, and command (override the image's CMD). |
external |
A cloud/remote dependency tarjan does not start — only health-probes for reachability (see below). |
env |
Extra environment variables for setup + command. |
envFile |
.env files (config-dir-relative or absolute) loaded for this service. |
dependsOn |
Services that must be healthy first. |
health |
Readiness probe: tcp, http, or command (+ timeout, interval). |
optional |
Failure → warning instead of aborting the whole up. |
restart |
Crash policy: no (default), on-failure, or always. |
maxRestarts |
Cap on automatic restarts (default 5; 0 = unlimited). |
watch |
Live-restart on file change: paths (relative to workdir) + debounce. |
tarjan topologically sorts services by dependsOn (and rejects cycles). Independent services start concurrently; each waits only for its own dependencies to become healthy, so a five-service environment doesn't pay for a strict serial boot.
One config can serve every shape of work. A service or repo with no profiles
is always included; one with profiles is included only when a matching
profile is active (--profile), it's named as a positional argument / --only,
or it's pulled in as a dependency.
Name services positionally to start just those — tarjan up web is the same
as tarjan up --only web, and the two combine. Either way a service's
dependsOn are pulled in too (disable with --no-deps), so you get the service
and everything it needs from one word.
services:
- { name: api, command: "...", profiles: [] } # always on (core)
- { name: web, command: "...", dependsOn: [api], profiles: [frontend, full] }
- { name: app, command: "...", dependsOn: [api], profiles: [mobile, full] }tarjan up # api only (the always-on core)
tarjan up web # web + its dependency api (positional selection)
tarjan up web app # web + app + api
tarjan up --profile frontend # api + web
tarjan up --profile full # api + web + app
tarjan up --only web # the flag form of `tarjan up web`
tarjan validate web # preview a selection without running it
tarjan validate --profile full # ...or preview a whole profileProfiles also gate repos, so a frontend-only run won't clone the backend or
mobile repositories.
Not everything is a long-running server. A kind: job runs once and its
dependents wait for it to exit 0 — completion-gated, not health-gated. This
is the shape for database migrations, seed data, and ETL/ML pipeline steps.
services:
- { name: db, docker: { image: postgres:16, ports: ["5432:5432"] }, health: { tcp: "localhost:5432" } }
- name: migrate
kind: job
command: "alembic upgrade head"
dependsOn: [db] # runs after db is healthy
- name: api
command: "uvicorn app:api"
dependsOn: [migrate] # starts only after migrate exits 0A failed job (non-zero exit) fails the run instead of starting its dependents.
Chain jobs with dependsOn to express a pipeline (extract → transform → load).
A docker service usually pulls a published image. When the service is one of
your repos — it ships a Dockerfile, not a registry image — give it a build
context instead and tarjan builds the image (the equivalent of docker compose build) before running it:
services:
- name: api
docker:
build:
context: orame-service # a cloned repo (relative to the workspace)
dockerfile: Dockerfile # optional (relative to context)
args: { TARGET: dev } # optional --build-arg values
ports: ["8080:8080"]
env: { ASPNETCORE_ENVIRONMENT: Development }
health: { http: "http://localhost:8080/health/ready" }The image is built once per workspace (alongside setup), so a fresh tarjan up rebuilds — picking up source changes — while in-workspace restarts reuse it.
The derived tag is tarjan-<config>-<service>:dev; set image: alongside build:
to name the tag yourself. This lets tarjan bring up a whole backend whose services
are built from sibling checkouts, with no published images required.
When an image bundles more than one entrypoint (say it runs a migration step
then the server), override its default CMD with command to run just the
part you want — the rest of the stack handles migrations separately:
- name: agents
docker:
build: { context: orame-agents }
ports: ["8001:8000"]
command: ["uvicorn", "orame_agents.main:app", "--host", "0.0.0.0", "--port", "8000"]Keep secrets out of both git and the config with .env files. A global
envFile applies to every service; a per-service envFile applies to one.
Precedence, lowest to highest:
process env < global envFile < service envFile < inline env:
envFile: [.env.shared] # top-level: loaded for every service
services:
- name: api
envFile: [.env.api] # service-specific
env: { PORT: "8080" } # inline wins over filesPaths are resolved relative to the config directory (or absolute). Commit a
.env.example and git-ignore the real .env*; a missing referenced file is a
clear error, not a silent skip.
Real products often need things that don't run locally — a managed database, a SaaS API, a service that only lives in a dev cluster. tarjan models these without trying to run them:
-
External dependencies (
external: true): not started, only health-probed for reachability. Local servicesdependsOnthem, so if the cloud DB is down you find out before everything else boots — andtarjan statusshows them asexternal. -
Tunnels are just services. A
kubectl port-forwardorssh -Lis a long-running command that holds a port open — model it as a normal service withrestart: alwaysand ahealth.tcpon the local port; dependents wait for the tunnel to be up. -
Lifecycle hooks run global one-shot commands around the environment:
hooks: preUp: ["gcloud auth login", "fetch-secrets > .env.cloud"] # before services postDown: ["echo done"] # after stop
See examples/hybrid-cloud.yaml for all three together.
Some pieces of a product shouldn't — or can't — run on your laptop: a GPU
trainer, a heavy build, a service you keep on a shared dev box. Declare a
remote and point a service at it with remote:; everything else — the
dependency graph, health gating, restart policies, captured logs, reload —
works exactly as it does locally.
remotes:
devbox:
host: dev.example.com # or an alias from your ~/.ssh/config
user: steven # optional
identityFile: ~/.ssh/id_ed25519 # optional
# workspaceRoot: tarjan/shop # where repos clone on the remote (default)
# forward: true # tunnel ports back to localhost (default)
services:
- name: trainer # (A) a process, run over ssh on the remote
remote: devbox
workdir: trainer # resolves under the remote's workspaceRoot
setup: ["pip install -r requirements.txt"]
command: "python serve.py"
health: { http: "http://localhost:9000/health" }
- name: db # (B) a container, on the remote Docker daemon
remote: devbox
docker: { image: postgres:16, ports: ["5432:5432"] }
health: { tcp: "localhost:5432" }Two execution modes, chosen automatically:
- (A) A process runs over
ssh. Itssetupandcommandrun on the remote host, inworkspaceRoot/<workdir>, with the service'senv/envFileexported into the remote shell. tarjan clones the repos those services need onto the remote for you. - (B) A
dockerservice runs on the remote's Docker daemon (viaDOCKER_HOST=ssh://…).imageis pulled there; abuild:context ships from your machine to the remote daemon — no published image required.
Ports keep working at localhost. For each remote service, tarjan opens an
SSH tunnel (ssh -L) that forwards its published/health ports back to the same
localhost port. So local dependents' dependsOn, localhost health checks,
and your browser all keep hitting localhost — no config changes. Disable
per-remote with forward: false.
tarjan shells out to your system ssh (like it does for git/docker), so
your ssh config, agent, keys and known_hosts all apply — no keys live in
tarjan.yaml. Requirements & limits: the remote needs git/docker for what
it runs; auth should be non-interactive (key/agent); watch live-restart isn't
supported for remote services; and clean remote teardown relies on the SSH
session ending, so remote commands should honour SIGTERM/SIGHUP. tarjan status
tags each remote service with @<remote>. See
examples/remote.yaml.
- Crash isolation + restart. A service that exits is handled per its
restartpolicy (with exponential backoff) — it does not tear the rest of the environment down.on-failurerestarts only on a non-zero exit;alwaysrestarts on any exit. - Live restart on change. With
watch, edits under the given paths restart just that service (these restarts don't count againstmaxRestarts). - Captured logs. Every service's output is streamed to the terminal
(prefixed, colored) and persisted to
<workspace>/.tarjan/logs/<service>.log, readable later withtarjan logs <service>(-fto follow). - Control from another terminal. A running
tarjan upexposes a loopback, token-protected control endpoint (recorded in.tarjan/control.json), sotarjan restart <service>restarts one service in place andtarjan statusshows live readiness — without interrupting the rest of the environment. - Live reload. Edit
tarjan.yaml, runtarjan reload, and the running environment reconciles to it: added services start, removed ones stop, and changed ones restart with their new spec — the rest keep running. The new config is validated before anything changes, and a changed service is fully stopped before its replacement starts (so it never collides with its own port). Unchanged services aren't touched.
See examples/ for microservices, full-stack, and backend+frontend+app setups.
YAML is the default and right for most setups. When a config has logic —
many near-identical services, per-environment conditionals, computed ports/URLs
— a static file means copy-paste and drift. For those, write tarjan.star in
Starlark (a small, sandboxed
Python-like language) instead. It produces the same config, so every feature
above works unchanged; the interpreter is pure Go, so tarjan stays a single binary
with no extra toolchain for you to install.
# tarjan.star — N near-identical services become a loop, not N copy-pasted blocks
services = [service(name = "db", docker = docker(image = "postgres:16", ports = ["5432:5432"]),
health = health(tcp = "localhost:5432"))]
for i, name in enumerate(["catalog", "orders", "gateway"]):
services.append(service(
name = name, workdir = name, command = "go run ./cmd/server",
env = {"PORT": str(8080 + i)}, depends_on = ["db"], restart = "on-failure",
))
tarjan = config(name = "shop", services = services, workspace = workspace(vscode = True))tarjan auto-detects tarjan.star (preferred) or tarjan.yaml. The builtins mirror the
YAML schema: config, repo, tool, service, docker, health, watch,
hooks, workspace (kwargs are snake_case, e.g. depends_on, min_version).
The script must assign its result to a top-level tarjan. tarjan reload works with
Starlark too. See examples/microservices.star.
A repository can carry its own tarjan config in a .tarjan/ directory at its
root — .tarjan/tarjan.star, .tarjan/tarjan.yaml or .tarjan/tarjan.yml, the
same schema as a top-level config. It describes how to run that repo (its
required tools, databases, jobs, services), versioned alongside the code that
needs it. It works in two ways:
Run a repo in place. Inside a checkout that has a .tarjan/ config, just:
cd myrepo
tarjan up # also: validate, status, logs, exec, reload, down …No separate workspace is materialised — the checkout is the workspace.
Services run against your working tree (an empty workdir means the repo
root), and runtime files (logs, state) live under the same .tarjan/
directory; add them to the repo's .gitignore:
.tarjan/logs/
.tarjan/state.json
.tarjan/control.json
.tarjan/setup-*Compose repos that bring their own config. When a top-level config clones a
repo that carries a .tarjan/ config, the repo's required tools and services
are merged into the run automatically — the orchestrating config only has to
say which repos make up the product, not how to run each one:
# tarjan.yaml — the product config stays a repo list
name: shop
repos:
- name: api # has .tarjan/tarjan.yaml → contributes postgres, migrate, server…
url: https://github.com/your-org/api.git
- name: web # has .tarjan/tarjan.yaml → contributes its dev server
url: https://github.com/your-org/web.gitMerged services are rebased onto the repo's checkout: a relative workdir
(or none) resolves inside the repo, as do docker.build.context and envFile
paths. A repo service may dependsOn services defined by the parent config or
by other repos; the combined config is validated as a whole after merging. If
the parent config defines a service with the same name, the parent wins —
so the orchestrating config can override a repo's defaults (e.g. share one
postgres). Tools required by repo configs are checked (and with --install,
installed) like the parent's own. tarjan reload re-reads repo configs too.
tarjan up
├─ check required tools (presence + minVersion)
├─ create fresh workspace: <workspaceRoot>/<timestamp>/
├─ git clone every repo into it
├─ write <name>.code-workspace (all repos, one IDE window)
└─ launch every service concurrently; each one:
waits for its dependencies to be healthy
→ run setup (once) → start (process or docker) → probe health
→ supervise: restart on crash (policy) or on file change (watch)
stream + persist logs · Ctrl+C → reverse-order graceful shutdown
Local processes run in their own process group so shutdown reliably stops the
whole tree (shell + children); docker services are stopped via docker stop.
make build # -> ./bin/tarjan
make test # go test ./...
make lint # golangci-lint
make check # fmt-check + vet + lint + test (what CI runs)
make hooks # install the local pre-commit hook (one-time)Quality is enforced at two levels:
- Local git hooks (
.githooks/, enabled viamake hooks): a fast, zero-extra-dependencypre-commitgate —gofmt,go vet,go test, andgolangci-lintif it's installed — plus acommit-msghook enforcing Conventional Commits (feat:,fix:,docs:, …). Bypass deliberately withgit commit --no-verify. - CI (
.github/workflows/ci.yml): the authoritative gate on every push and PR —gofmtcheck,go vet,golangci-lint,go test -race, and a cross-compile matrix (macOS/Linux/Windows). Local hooks can be skipped; CI can't.
Linters are configured in .golangci.yml.
Done: concurrent dependency-ordered start · crash isolation + restart policies ·
file-watch live restart · captured logs + tarjan logs · profiles & selective
start (positional tarjan up <service> / --only / --profile) · update cloned
repos in place (tarjan pull) · opt-in tool install (tarjan doctor --install) ·
hybrid local+cloud (external dependencies, tunnels, lifecycle hooks) · env files
& secrets out of git · run-to-completion jobs · tarjan restart + live status ·
live config reload (tarjan reload) · config in code (Starlark tarjan.star) ·
full-screen dashboard (tarjan ui) · run a command in a service's context
(tarjan exec) · remote (SSH) targets — process over ssh and docker on a remote
daemon, with auto port-forwarding.
This roadmap is complete. Possible future work: non-SSH remote targets
(Kubernetes / other cluster backends), watch live-restart for remote services.
Contributions are welcome! See CONTRIBUTING.md for the dev
setup, the local gate (make check), and the commit-message convention. For
security issues, please follow SECURITY.md rather than opening a
public issue.
MIT — see LICENSE.