.PHONY: dev dev-lan prod stop restart logs down check _check load-test bundle-budget fmt deploy doctor hero build adr adr-check adr-index adr-renumber adr-import verifier BUN := $(HOME)/.bun/bin/bun TSX := packages/server/node_modules/.bin/tsx AUTONOMOS := $(TSX) packages/cli/src/index.ts DEPLOY_HOST ?= $(or $(HOST),$(shell grep -s '^DEPLOY_HOST=' .env | cut -d= -f2)) DEPLOY_PATH ?= ~/autonomOS PROD_PORT ?= 3100 NO_MIGRATE ?= # Interface the prod server binds, for a ONE-OFF override: # make deploy BIND_HOST=127.0.0.1 # Empty = the server's default: all interfaces (reachable over the network — # Tailscale / IAP / SSH — which is how this is normally deployed). The auth # token gates what you can do once connected; the network layer gates who # reaches the port. Set BIND_HOST=127.0.0.1 only to RESTRICT to loopback, e.g. # a box you reach exclusively through an SSH tunnel. # # For a persistent restriction, prefer `AUTONOMOS_HOST=127.0.0.1` in THAT box's # .env instead of this. The service wrapper runs `tsx --env-file=/.env`, # so the server reads it on every start — it survives `install-service --force`, # whereas BIND_HOST bakes `--host` into the service file, which a later reinstall # without it would drop. # # Deliberately NOT named HOST: $(HOST) is already an alias for DEPLOY_HOST above, # so `HOST=x make deploy` would try to deploy TO a host named "x". # # Strip an inline `# comment` and any padding: this value lands in command-prefix # position below (`HOST=$(BIND_HOST) bash …`), where a stray `#` would comment # out the rest of the recipe line and make `make prod` a SILENT no-op — a deploy # that reports success and changes nothing. `-f2-` keeps values containing `=`. # (the \# is escaped for make — an unescaped # would comment out the rest of # this line, including the closing paren.) BIND_HOST ?= $(strip $(shell grep -s '^BIND_HOST=' .env | cut -d= -f2- | cut -d'\#' -f1)) # ── dev: isolated per worktree ─────────────────── # Ports are derived from the directory path hash so each worktree gets unique ports. # Override manually: make dev DEV_API_PORT=3101 DEV_VITE_PORT=5173 DEV_PORT_HASH := $(shell printf '%s' "$(CURDIR)" | cksum | cut -d' ' -f1) DEV_API_PORT ?= $(shell echo $$(( 3200 + $(DEV_PORT_HASH) % 800 ))) DEV_VITE_PORT ?= $(shell echo $$(( 5200 + $(DEV_PORT_HASH) % 800 ))) DEV_CONFIG_DIR ?= $(CURDIR)/.autonomos-dev # THIS MACHINE ONLY by default (Terry, 2026-10-01). The dev API server always # binds loopback (vite proxies to it); DEV_HOST is what vite listens on. Reach # the dev dashboard from another device only by opting in: `make dev-lan`. # Anyone on the network who can reach the vite port gets the dev server's # files and its proxy to the API, so do that only on a network you trust. DEV_HOST ?= 127.0.0.1 dev: @mkdir -p $(DEV_CONFIG_DIR) @lsof -ti:$(DEV_API_PORT) -sTCP:LISTEN | xargs kill -9 2>/dev/null || true @lsof -ti:$(DEV_VITE_PORT) -sTCP:LISTEN | xargs kill -9 2>/dev/null || true @echo "Dev server: API=:$(DEV_API_PORT) Vite=:$(DEV_VITE_PORT) Config=$(DEV_CONFIG_DIR)" @# Loud whenever vite isn't on loopback, however DEV_HOST was set (make @# dev-lan, the command line, or a DEV_HOST exported in the shell). @case "$(DEV_HOST)" in 127.0.0.1|localhost|::1) ;; *) echo "⚠ DEV_HOST=$(DEV_HOST): the dev dashboard (and its proxy to the API) is reachable by anyone on this network." ;; esac @cd packages/server && PORT=$(DEV_API_PORT) AUTONOMOS_HOST=127.0.0.1 AUTONOMOS_CONFIG_DIR=$(DEV_CONFIG_DIR) CORS_ORIGIN=http://localhost:$(DEV_VITE_PORT) ../../$(TSX) watch --env-file=../../.env src/index.ts & @sleep 2 @cd packages/dashboard && DEV_HOST=$(DEV_HOST) VITE_API_PORT=$(DEV_API_PORT) $(BUN) vite --host $(DEV_HOST) --port $(DEV_VITE_PORT) # The explicit opt-in for using the dev dashboard from another device. dev-lan: @$(MAKE) dev DEV_HOST=0.0.0.0 # ── prod: build + OS-native daemon on :3100 ─────── # Supervised by launchd (macOS) / systemd-user (Linux) via # scripts/install-prod-service.sh — NOT pm2. The script auto-migrates an # existing pm2-managed autonomos (set NO_MIGRATE=1 to skip) and is idempotent, # so re-running picks up new source. # # Runs synchronously and verifies the daemon came up. (Don't run `make prod` # from inside an agent session the daemon spawned — the supervisor restart # would kill that PTY mid-run. Use a plain shell or `make deploy`.) prod: build @echo "Handing off to OS-native supervisor (launchd/systemd-user)..." @PORT=$(PROD_PORT) NO_MIGRATE=$(NO_MIGRATE) HOST='$(BIND_HOST)' bash scripts/install-prod-service.sh # ── build: deps + channel server + dashboard, NO service operations ── # Factored out of `prod` so the source-mode updater (ADR-077) can rebuild a # managed clone after a tag checkout without touching the supervisor — the # updater owns the restart (out-of-process, health-gated). Everything here # must stay side-effect-free with respect to the running daemon. build: @$(BUN) install @# Security floors (ADR-137): a plain install keeps a nested copy the new @# lockfile no longer lists, so a security bump can miss the package that @# loads it (measured, V12b: an upgraded clone still served ws 8.19 through @# @hono/node-ws). Relinks with --force --frozen-lockfile ONLY when a floor @# is unmet, then fails the build if it still is. Every upgrade path runs @# this target. @BUN=$(BUN) $(TSX) scripts/check-security-floors.ts @bash scripts/ensure-node-pty.sh @echo "Building channel server..." @# Deps INLINED (no --packages=external): the release tarball carries no @# node_modules resolvable from channel-server/, so external specifiers @# crashed the bridge with ERR_MODULE_NOT_FOUND before the MCP initialize @# response — every agent's autonomos MCP dead fleet-wide on bundle @# installs while the daemon (whose own bundle inlines the same deps) @# looked healthy (#376). Only ws's OPTIONAL native accelerators stay @# external: ws require()s bufferutil/utf-8-validate in try/catch and @# falls back to its JS implementations when absent — inlining them would @# fail the build; leaving them external is ws's supported shape. @# The createRequire banner is load-bearing: inlined CJS deps (ws) call @# require() for node builtins, and esbuild's ESM output otherwise shims @# require to a throw ("Dynamic require of events is not supported"). @bunx esbuild packages/server/src/channel-server/index.ts --bundle --platform=node --format=esm --outfile=packages/server/src/channel-server/dist.mjs --external:bufferutil --external:utf-8-validate --banner:js="import { createRequire as __csCreateRequire } from 'node:module'; const require = __csCreateRequire(import.meta.url);" --log-level=warning @echo "Removing any stale embedded dashboard (hosted server serves packages/dashboard/dist; _embedded_dashboard is a binary-build artifact only)..." @rm -rf packages/server/src/_embedded_dashboard @echo "Building dashboard..." @# Build into dist.next and swap (ADR-105): on a source install the OLD @# daemon keeps serving packages/dashboard/dist throughout `autonomos @# upgrade`'s minutes-long rebuild, and vite empties its outDir first — a @# browser refresh mid-build would 404 every asset. The swap window is two @# renames. @cd packages/dashboard && rm -rf dist.next dist.prev && $(BUN) vite build --outDir dist.next --emptyOutDir && { [ ! -d dist ] || mv dist dist.prev; } && mv dist.next dist && rm -rf dist.prev # ── doctor: preflight checks (node-pty ABI vs runtime node) ── # Run standalone to diagnose/repair a crash-loop after a node upgrade. doctor: @bash scripts/ensure-node-pty.sh # ── stop / restart / logs ───────────────────────── # stop: service-aware — stops via the supervisor so launchd KeepAlive / # systemd Restart don't immediately revive it (a bare SIGTERM would bounce). stop: @$(AUTONOMOS) stop restart: @$(AUTONOMOS) restart logs: @$(AUTONOMOS) logs --lines 50 # ── down: stop everything ──────────────────────── # Removes the OS-native service entirely (stop + delete unit), then frees any # dev ports. down: @$(AUTONOMOS) uninstall-service 2>/dev/null || true @lsof -ti:$(DEV_API_PORT) -sTCP:LISTEN | xargs kill -9 2>/dev/null || true @lsof -ti:$(DEV_VITE_PORT) -sTCP:LISTEN | xargs kill -9 2>/dev/null || true @echo "Stopped (service removed; dev API=:$(DEV_API_PORT) Vite=:$(DEV_VITE_PORT))." # ── deploy: rsync + prod on remote ─────────────── # # Configure per machine: # .env: DEPLOY_HOST=forge # Or inline: make deploy DEPLOY_HOST=forge # deploy: @[ -n "$(DEPLOY_HOST)" ] || { echo "Error: Set DEPLOY_HOST in .env or pass it: make deploy DEPLOY_HOST=forge"; exit 1; } @echo "⚠️ make deploy is a private dev tool, NOT a supported install shape (ADR-077)." @echo " It rsyncs a mutable working tree with no git history — no provenance, no" @echo " 'autonomos upgrade', no rollback. For a managed deployment use:" @echo " scripts/install-source.sh on the target (clone-at-tag, upgradeable)." @echo "Deploying to $(DEPLOY_HOST):$(DEPLOY_PATH)..." rsync -avz --delete \ --exclude node_modules \ --exclude .env \ --exclude dist \ --exclude .git \ --exclude _embedded_dashboard \ --exclude .autonomos-bin \ ./ $(DEPLOY_HOST):$(DEPLOY_PATH)/ @echo "Installing bun (if needed)..." ssh $(DEPLOY_HOST) 'export PATH=$$HOME/.bun/bin:$$PATH && command -v bun >/dev/null || { curl -fsSL https://bun.sh/install | bash && export PATH=$$HOME/.bun/bin:$$PATH; }' @echo "Installing dependencies..." ssh $(DEPLOY_HOST) 'cd $(DEPLOY_PATH) && export PATH=$$HOME/.bun/bin:$$PATH && bun install' @echo "Building and starting on $(DEPLOY_HOST) (launchd/systemd-user, auto-migrates pm2)..." @# Forward BIND_HOST only when set locally. Passing an empty BIND_HOST= would @# override (and blank out) the remote's own .env value, silently flipping a @# deliberately-exposed box back to loopback and cutting off browser access. ssh $(DEPLOY_HOST) 'cd $(DEPLOY_PATH) && export PATH=$$HOME/.local/bin:$$HOME/.bun/bin:$$PATH && make prod $(if $(BIND_HOST),BIND_HOST=$(BIND_HOST),)' # ── fmt: auto-fix lint + formatting ───────────── fmt: npx biome check --write --unsafe packages/ # ── check: lint + typecheck + test ─────────────── # Per-test backstop (OUTSIDE `ifndef CI` on purpose — it must apply in CI, # which sets CI=true): a test stuck on an await fails at 5 min, NAMED, instead # of silently holding the run until the CI job timeout. 5 min sits above every # real-agent suite's own diagnostic budget (agent-spawn-prompt waits 180s in a # 200s describe), so it never pre-empts their better failure messages. It can # NOT catch a synchronous block (the event loop is frozen); the CI job's # timeout-minutes is the backstop for that. NODE_TEST_TIMEOUT := --test-timeout=300000 # Exit once the last test finishes, even if a test file leaked a live handle # (socket, server, timer). Without it a finished run can idle forever: a gate's # runner sat at 0% CPU for 48 min holding the machine-wide slot. NODE_TEST_FORCE_EXIT := --test-force-exit # Local runs cap test fan-out at half the cores. Uncapped, one run forks about # one process per core, and a few agents' gates at once saturated the box (load # avg 24-35), slowing the live server and causing timing-only flakes. CI sets # CI=true, so its command line is unchanged. ifndef CI LOCAL_TEST_CAP := $(shell node -e "process.stdout.write(String(Math.max(1, Math.floor(require('os').availableParallelism() / 2))))" 2>/dev/null) NODE_TEST_CONCURRENCY := $(if $(LOCAL_TEST_CAP),--test-concurrency=$(LOCAL_TEST_CAP)) VITEST_MAX_WORKERS := $(if $(LOCAL_TEST_CAP),--maxWorkers=$(LOCAL_TEST_CAP)) endif # Test suites run with git's location variables STRIPPED. Inside a git hook # (the pre-push gate) git exports GIT_DIR (for a linked worktree, its # .git/worktrees/), and any fixture `git init`/`config` then targets the # REAL repository: one fixture `git init` flipped the shared repo to # core.bare=true and broke every worktree (#321, again on 2026-10-01). No suite # needs the hook's repo location; cwd discovery still works without it. GIT_CLEAN_ENV := env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_PREFIX -u GIT_NAMESPACE # Every full local run takes the ONE machine-wide slot (scripts/ci-gate-lock.sh), # whoever starts it: the pre-push gate, an agent's `make check`, an # AUTONOMOS_INTEGRATION=1 run, `make load-test`. Overlapping full runs saturated # the box (load 24-35, later 600+ with a load rig on top) and slowed the live # server. The lock is re-entrant, so the gate (which already holds it) passes # straight through. CI runs one job per runner and skips it. check: ifdef CI $(MAKE) _check else scripts/ci-gate-lock.sh $(MAKE) _check endif # First step: a worktree installed while a stale node-pty sorted first in the # store has a 644 spawn-helper, and every PTY test then fails with # "posix_spawnp failed." ensure-node-pty.sh heals it (idempotent). _check: @bash scripts/ensure-node-pty.sh $(TSX) scripts/decisions.ts check npx biome check packages/ packages/dashboard/node_modules/.bin/tsc --build $(TSX) scripts/check-dashboard-dist.ts $(TSX) scripts/build-verifier.ts --check $(GIT_CLEAN_ENV) env -u AUTONOMOS_LOAD_TEST $(TSX) --import ./scripts/test-home-sentinel.ts --test $(NODE_TEST_CONCURRENCY) $(NODE_TEST_TIMEOUT) $(NODE_TEST_FORCE_EXIT) packages/server/src/__tests__/*.test.ts packages/cli/src/__tests__/*.test.ts scripts/*.test.ts cd packages/dashboard && $(GIT_CLEAN_ENV) node_modules/.bin/vitest run $(VITEST_MAX_WORKERS) # N-agent statusline load guard (CI: the `Load` workflow). Locally it takes the # machine-wide slot, and the test aborts itself if the box's load climbs. load-test: scripts/ci-gate-lock.sh env AUTONOMOS_LOAD_TEST=1 $(GIT_CLEAN_ENV) $(TSX) --test --test-timeout=600000 packages/server/src/__tests__/statusline-load.test.ts # ── verifier: the single-file provenance verifier install.sh runs (ADR-126) ── # Rebuilds scripts/verify-provenance.mjs and re-pins its sha256 in install.sh. # `make check` fails when either is stale — commit both files together. verifier: $(TSX) scripts/build-verifier.ts # Dashboard bundle-size ratchet (also a CI step): build, then compare the # gzipped JS/CSS against packages/dashboard/perf-budget.json. bundle-budget: @bun --filter @autonomos/dashboard build >/dev/null $(TSX) scripts/check-bundle-budget.ts # ── adr: architectural decision records, one file each (docs/decisions/) ─────── # `make adr NEW="Title"` allocates the next free number across origin/main AND open # PRs (via gh, when available) and writes a template. `make adr-index` prints the # index table; it is never committed (ADR-157). # See docs/decisions/README.md. Arguments are read by the shell as "$$NEW" (make # exports command-line variables), so backticks and quotes in a title survive. adr: @test -n "$$NEW" || { echo 'usage: make adr NEW="Short decision title"'; exit 2; } $(TSX) scripts/decisions.ts new "$$NEW" adr-check: $(TSX) scripts/decisions.ts check adr-index: @$(TSX) scripts/decisions.ts index adr-renumber: @test -n "$$FILE" || { echo 'usage: make adr-renumber FILE=docs/decisions/ADR-NNN-slug.md'; exit 2; } $(TSX) scripts/decisions.ts renumber "$$FILE" adr-import: $(TSX) scripts/decisions.ts import "$${REF:-HEAD}" # ── hero: regenerate the README hero screenshot (docs/assets/hero.png) ─────────────── # Boots an isolated demo instance (own config dir + fake HOME + ephemeral port, # never :3100), stages the multi-agent scene, and captures via headless Chrome. # RE-RUN THIS AFTER ANY DASHBOARD UI CHANGE so the README hero stays accurate. # See packages/dashboard/scripts/capture-hero.ts for prerequisites + details. # The README hero capture uses NO real login (#551). On macOS the kernel # enforces it: the run is sandboxed away from ~/.codex, ~/.gemini, the Claude # credentials file, the keychain and the `security` CLI. hero: cd packages/dashboard && if [ "$$(uname -s)" = Darwin ]; then \ sandbox-exec -D HOME="$$HOME" -f scripts/hero-no-credentials.sb $(BUN) run capture-hero; \ else $(BUN) run capture-hero; fi