diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index bc7c6eef49..d093aaf26a 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -79,11 +79,11 @@ Desktop signing is automatic. Complete macOS/Windows signing and notarization se ## Runtime resolution -In `apps/cli/src/runtime.ts`, the CLI locates its bundled runtime via: +The published npm shim (`apps/cli/bin/native-shim.js`) locates its bundled runtime via: 1. `KANDEV_BUNDLE_DIR` env var (set by Homebrew wrapper, used by tests). 2. Installed `@kdlbs/runtime-{platform}` npm package via `require.resolve()`. -3. `--runtime-version ` cache fallback (debug only — downloads from GitHub). +3. The Homebrew/manual install path execs `bin/kandev` directly. (`--runtime-version` is rejected by the native launcher.) ## Runtime helper binary checklist diff --git a/AGENTS.md b/AGENTS.md index 85f08462ea..d7dccc8ec7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ apps/ ├── backend/ # Go backend (orchestrator, lifecycle, agentctl, WS gateway) ├── web/ # Vite/React SPA frontend (Go boot payload + WS + Zustand) ├── desktop/ # Tauri desktop shell around the native runtime -├── cli/ # CLI tool (TypeScript) +├── cli/ # npm publishing shim only (4-line exec of the Go binary) └── packages/ # Shared packages/types ``` @@ -17,6 +17,8 @@ apps/ - **Package manager**: `pnpm` workspace (run from `apps/`, not repo root) - **Backend**: Go with Make (`make -C apps/backend test|lint|build`) +- **Launcher**: all four launch modes (`dev`, `start`, `run`, `service`) go through the native Go binary at `apps/backend/bin/kandev`; the launcher package is `apps/backend/internal/launcher`. `make dev` execs a copy of that binary (`bin/kandev-launcher`). +- **CLI**: `apps/cli` is the npm-published shim only (`bin/cli.js` + `bin/native-shim.js`); its tests run on the Node built-in test runner (`node --test`). - **Frontend**: Vite/React SPA (`cd apps && pnpm --filter @kandev/web dev|build:vite|lint`; for direct web typecheck use `cd apps/web && pnpm run typecheck`) - **Desktop**: Tauri shell (`cd apps && pnpm --filter @kandev/desktop build|e2e`; Rust tests from `apps/desktop/src-tauri`) - **UI**: Shadcn components via `@kandev/ui` @@ -202,7 +204,7 @@ Contract authority is intentionally split by implementation boundary: frontend a Runtime feature toggles add a SQLite-backed override tier managed through `Settings > System > Feature Toggles`. Effective values use this precedence: explicit environment variable > SQLite override > profile default. The typed runtime flag registry lives in `apps/backend/internal/runtimeflags/registry.go`; each registration owns the public metadata, environment variable, config reader, and config applier. Do not add parallel per-flag maps or switches. -Profile selection: `KANDEV_E2E_MOCK=true` → `e2e`, `KANDEV_DEBUG_DEV_MODE=true` or `KANDEV_DEBUG_PPROF_ENABLED=true` → `dev`, otherwise `prod`. `apps/cli/src/dev.ts` and `apps/web/e2e/fixtures/backend.ts` set only the selector — they no longer hardcode the underlying values. +Profile selection: `KANDEV_E2E_MOCK=true` → `e2e`, `KANDEV_DEBUG_DEV_MODE=true` or `KANDEV_DEBUG_PPROF_ENABLED=true` → `dev`, otherwise `prod`. The Go dev launcher (`apps/backend/internal/launcher/dev.go`) and `apps/web/e2e/fixtures/backend.ts` set only the selector — they no longer hardcode the underlying values. For any task that adds, rolls out, promotes, graduates, or removes a release toggle, use `/runtime-feature-flags`. That skill contains the file-by-file checklist, disabled-path requirements, test commands, promotion procedure, and retired-identity removal steps; do not rely on an agent discovering an ADR or public docs. In brief: merge risky features off in every shipped profile, enable a selected install with an admin override or explicit environment, restart and test, then change `prod:` to `"true"` for the all-user release while retaining the registry entry as a kill-switch. Remove the live flag after the feature is permanent, move its key and environment variable to the append-only retired identities in `runtimeflags/registry.go`, and never reuse either identity. Completeness tests cover the registry/profile/frontend contracts. Runtime overrides and restart support are documented in `docs/decisions/0018-runtime-settings-overrides.md` and `docs/decisions/0019-restart-supervisor.md`. diff --git a/Makefile b/Makefile index d2b611c7e3..f8227641e1 100644 --- a/Makefile +++ b/Makefile @@ -18,9 +18,13 @@ MAKE := make ifeq ($(OS),Windows_NT) RM = cmd /c del /s /q RMDIR = cmd /c rmdir /s /q + # Go emits a `.exe` for GOOS=windows regardless of the calling shell, so + # native cmd/PowerShell AND Git Bash both need it (mirrors apps/backend/Makefile). + EXE = .exe else RM = rm -f RMDIR = rm -rf + EXE = endif # stderr redirect for $(shell ...) probes. Keyed on $(OS)$(MSYSTEM) rather than @@ -89,9 +93,9 @@ help: @echo "Development Commands:" @echo " bootstrap Install mise tools, workspace deps, and git hooks" @echo " bootstrap-e2e Bootstrap plus Playwright browser/system deps" - @echo " dev Run backend + web via local CLI (auto ports)" + @echo " dev Run backend + web via the native Go launcher (auto ports)" @echo " dev PORT=38430 WEB_PORT=37430 PORT beats KANDEV_BACKEND_PORT/KANDEV_PORT, WEB_PORT beats KANDEV_WEB_PORT" - @echo " dev DEV_ARGS='--verbose' Pass extra flags through to the dev CLI" + @echo " dev DEV_ARGS='--verbose' Pass extra flags through to the native launcher" @echo " dev-prod-db Run dev mode against the production db at ~/.kandev" @echo " dev-backend Run backend in development mode (port 38429)" @echo " dev-web Run web app in development mode (port 37429)" @@ -189,14 +193,12 @@ endif .PHONY: dev dev: doctor - @echo "Building remote agentctl helpers..." - @$(MAKE) -C $(BACKEND_DIR) build-agentctl-remote -ifeq ($(OS),Windows_NT) - @echo "Building winjob (Ctrl-C-safe wrapper for Windows)..." - @$(MAKE) -C $(BACKEND_DIR) build-winjob -endif - @echo "Launching via CLI$(if $(DEV_FLAGS_DISPLAY), ($(DEV_FLAGS_DISPLAY)), (auto ports))..." - @cd $(APPS_DIR) && $(PNPM) -C cli dev -- dev $(DEV_FLAGS) + # POSIX-only (cp/exec): on Windows run from Git Bash/MSYS, like `make start`. + @echo "Building dev launcher..." + @$(MAKE) -C $(BACKEND_DIR) build-kandev + @cp $(BACKEND_DIR)/bin/kandev$(EXE) $(BACKEND_DIR)/bin/kandev-launcher$(EXE) + @echo "Launching via native Go launcher$(if $(DEV_FLAGS_DISPLAY), ($(DEV_FLAGS_DISPLAY)), (auto ports))..." + @exec $(BACKEND_DIR)/bin/kandev-launcher$(EXE) dev $(DEV_FLAGS) .PHONY: dev-prod-db dev-prod-db: export KANDEV_DATABASE_PATH := $(HOME)/.kandev/data/kandev.db @@ -654,7 +656,9 @@ typecheck-web: .PHONY: typecheck typecheck: @printf "$(CYAN)Type-checking all apps...$(RESET)\n" - @cd $(APPS_DIR) && $(PNPM) -r exec tsc -p tsconfig.json --noEmit + # apps/cli has no tsconfig (publish-only shim), so the workspace must be + # listed explicitly — add new TypeScript packages here. + @cd $(APPS_DIR) && $(PNPM) -r --filter @kandev/web --filter @kandev/desktop --filter @kandev/theme --filter @kandev/types --filter @kandev/ui exec tsc -p tsconfig.json --noEmit # # Cleanup diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index f3aab55ff3..2aacbb2dc4 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -289,9 +289,9 @@ When a PR fixup touches backend code, run the CI-style changed-file linter local ```bash golangci-lint run ./... --new-from-rev="" --timeout=5m ``` - ## Further scoped notes +- `internal/launcher/` — native launcher owning every entrypoint (`dev`, `start`, `run`, `service`); `dev` runs `make -C apps/backend dev` with Vite as a supervised child, state under `/.kandev-dev/`. The root `make dev` prebuilds only the copied launcher; the backend dev target builds the native agentctl and a linux/amd64 helper when the host is not Linux/amd64 (`docs/plans/go-dev-launcher/`). - `internal/agentctl/AGENTS.md` — agentctl server route groups, adapter model, ACP protocol - `internal/agentctl/server/api/AGENTS.md` — reverse-proxy body rewriting (`Accept-Encoding`), iframe-blocking header stripping - `internal/integrations/AGENTS.md` — playbook for adding a new third-party integration (Jira/Linear pattern) diff --git a/apps/backend/Makefile b/apps/backend/Makefile index 9a400e6cc2..c9bd15c78b 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -138,12 +138,27 @@ COMMIT := $(shell git rev-parse --short HEAD $(NULL_REDIR) || echo unknown) LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME) +# Development only needs the native agentctl plus the linux/amd64 helper when +# the control plane is not linux/amd64. A same-platform Linux/amd64 remote can +# use the native helper through AgentctlResolver's dev fallback. +DEV_HOST_GOOS := $(shell $(GO) env GOHOSTOS $(NULL_REDIR)) +DEV_HOST_GOARCH := $(shell $(GO) env GOHOSTARCH $(NULL_REDIR)) +ifeq ($(DEV_HOST_GOOS),linux) +ifeq ($(DEV_HOST_GOARCH),amd64) + DEV_AGENTCTL_TARGETS := build-agentctl +else + DEV_AGENTCTL_TARGETS := build-agentctl build-agentctl-linux +endif +else + DEV_AGENTCTL_TARGETS := build-agentctl build-agentctl-linux +endif + # Force the application module's Go version; go run otherwise selects the # analyzer module's minimum toolchain before go/packages loads this module. DEADCODE_VERSION ?= v0.48.0 DEADCODE_GO_TOOLCHAIN ?= go1.26.0 -.PHONY: all build build-runtime build-all build-agentctl build-agentctl-remote build-agentctl-linux build-agentctl-linux-arm64 build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview build-winjob e2e-plugin-package clean run dev start-debug test test-e2e test-sprites-e2e test-lifecycle-goleak lint deadcode fmt vet check-make-shells help +.PHONY: all build build-dev build-runtime build-kandev build-all build-agentctl build-agentctl-dev build-agentctl-remote build-agentctl-linux build-agentctl-linux-arm64 build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview build-winjob e2e-plugin-package clean run dev start-debug test test-e2e test-sprites-e2e test-lifecycle-goleak lint deadcode fmt vet check-make-shells help ## Default target all: build @@ -163,7 +178,9 @@ build-mock-agent: ## Build the winjob helper — wraps a subprocess in a Windows Job Object so ## Ctrl-C at the top of the chain reliably terminates the entire subtree ## (kandev, agentctl, etc.) instead of leaking them. No-op passthrough on Unix. -## See apps/cli/src/dev.ts for how the CLI launcher uses it. +## The native Go launcher replaces this on the `make dev` path with its own +## process-group handling (internal/launcher/process_group_windows.go); the +## helper stays for any other consumer. build-winjob: @echo "Building winjob..." -@$(MKDIR) $(BUILD_DIR) @@ -182,10 +199,20 @@ build-acpdbg: ## Build the complete development binary set. build: build-runtime build-mock-agent build-acpdbg build-winjob +## Build the lean development binary set. Unlike `build`, this does not build +## every cross-platform remote helper; dev needs the host helper and, for a +## host other than Linux/amd64, one linux/amd64 helper. +build-dev: build-kandev build-agentctl-dev build-mock-agent build-acpdbg build-winjob + ## Build only the binaries shipped in a runtime bundle. Always includes remote ## agentctl helpers because SSH/Docker/Sprites executors upload or bind-mount ## platform-specific sidecars into remote environments. -build-runtime: build-agentctl build-agentctl-remote +build-runtime: build-agentctl build-agentctl-remote build-kandev + +## Build the native kandev binary used both by runtime bundles and by the +## copied make-dev launcher. Keep this separate from build-runtime so the +## launcher prebuild does not pull in agentctl helpers it does not use yet. +build-kandev: @echo "Building $(BINARY_NAME)..." -@$(MKDIR) $(BUILD_DIR) $(CGO_PREFIX) $(GO) build $(GOFLAGS) -tags fts5 -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)$(EXE) ./cmd/kandev @@ -201,6 +228,9 @@ build-agentctl: -@$(MKDIR) $(BUILD_DIR) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl$(EXE) ./cmd/agentctl +## Build only the agentctl helpers needed by the development profile. +build-agentctl-dev: $(DEV_AGENTCTL_TARGETS) + ## Build all remote agentctl helpers used by SSH/Docker/Sprites executors. build-agentctl-remote: build-agentctl-linux build-agentctl-linux-arm64 build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 @@ -274,7 +304,7 @@ run: build $(RUN_BIN) __backend ## Run in development mode (with pprof enabled) -dev: build +dev: build-dev @echo "Running $(BINARY_NAME) in development mode..." @$(DEV_ENV) $(EXEC) $(RUN_BIN) __backend diff --git a/apps/backend/internal/launcher/backup.go b/apps/backend/internal/launcher/backup.go new file mode 100644 index 0000000000..c0d413beaa --- /dev/null +++ b/apps/backend/internal/launcher/backup.go @@ -0,0 +1,139 @@ +package launcher + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// Prefix for dev-prod-db automatic snapshots. Distinct from the backend's +// "kandev-*" auto-snapshots and "manual-*" user snapshots so the families +// don't interfere with each other's retention policies. +const ( + backupPrefix = "dev-prod-db-" + backupSuffix = ".db" + maxBackups = 5 +) + +// isProductionDb reports whether dbPath points to a non-dev database that +// should be backed up before running dev mode. Dev-isolated databases live +// under /.kandev-dev/ and are considered disposable. +func isProductionDb(dbPath string) bool { + normalized := filepath.Clean(dbPath) + segments := strings.Split(normalized, string(os.PathSeparator)) + for _, segment := range segments { + if segment == devKandevDotdir { + return false + } + } + return true +} + +// backupProductionDb copies the database at dbPath to +// /.kandev/data/backups/ before dev mode runs against it, keeping +// only the maxBackups newest snapshots. It returns the created backup path, +// or "" with no error when the source DB does not exist. +// +// Backups are always placed under /.kandev/data/backups/ even when +// dbPath points elsewhere. This matches the dev-prod-db default flow; custom +// KANDEV_DATABASE_PATH values are advanced usage where the user is responsible +// for backup location. +// +// homeDir and now are injectable for tests: an explicit timestamp makes +// back-to-back calls produce distinct filenames and mtimes without sleeping. +func backupProductionDb(dbPath, homeDir string, now time.Time) (string, error) { + src, err := os.Open(dbPath) + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("open database for backup: %w", err) + } + defer func() { _ = src.Close() }() + + backupDir := filepath.Join(homeDir, ".kandev", "data", "backups") + if err := os.MkdirAll(backupDir, 0o700); err != nil { + return "", fmt.Errorf("create backup directory: %w", err) + } + // Harden in case a wider-masked directory already exists; snapshot names + // are user-local and must not be world-readable. + _ = os.Chmod(backupDir, 0o700) + + name := backupPrefix + backupTimestamp(now) + backupSuffix + dest := filepath.Join(backupDir, name) + // Stream the copy so a multi-GB production database never has to fit in + // memory, matching the TypeScript launcher's copyFileSync. + dst, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return "", fmt.Errorf("create backup file: %w", err) + } + if _, err := io.Copy(dst, src); err != nil { + _ = dst.Close() + _ = os.Remove(dest) + return "", fmt.Errorf("copy database to backup: %w", err) + } + if err := dst.Close(); err != nil { + _ = os.Remove(dest) + return "", fmt.Errorf("finalize backup file: %w", err) + } + // Stamp both atime and mtime so pruning (which sorts by mtime) is + // deterministic. + if err := os.Chtimes(dest, now, now); err != nil { + _ = os.Remove(dest) + return "", fmt.Errorf("stamp backup mtime: %w", err) + } + + pruneBackups(backupDir) + + return dest, nil +} + +// backupTimestamp renders RFC3339 with separators stripped, matching the +// TypeScript launcher's toISOString().replace(/[:.]/g, ""). +func backupTimestamp(now time.Time) string { + raw := now.UTC().Format("2006-01-02T15:04:05.000Z") + raw = strings.ReplaceAll(raw, ":", "") + return strings.ReplaceAll(raw, ".", "") +} + +// pruneBackups keeps only the maxBackups newest dev-prod-db-*.db files in dir +// by mtime, deleting older ones. Non-matching files are left untouched. +func pruneBackups(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + type backupFile struct { + path string + mtime time.Time + } + var files []backupFile + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasPrefix(name, backupPrefix) || !strings.HasSuffix(name, backupSuffix) { + continue + } + fullPath := filepath.Join(dir, name) + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, backupFile{path: fullPath, mtime: info.ModTime()}) + } + if len(files) <= maxBackups { + return + } + sort.Slice(files, func(i, j int) bool { return files[i].mtime.After(files[j].mtime) }) + for _, f := range files[maxBackups:] { + // Non-critical: don't fail the launch if one old backup can't be removed. + _ = os.Remove(f.path) + } +} diff --git a/apps/backend/internal/launcher/backup_test.go b/apps/backend/internal/launcher/backup_test.go new file mode 100644 index 0000000000..e369de11f5 --- /dev/null +++ b/apps/backend/internal/launcher/backup_test.go @@ -0,0 +1,179 @@ +package launcher + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestIsProductionDb(t *testing.T) { + devCases := []string{ + "/repo/.kandev-dev/data/kandev.db", + "/.kandev-dev/kandev.db", + filepath.Join("repo", ".kandev-dev", "data", "kandev.db"), + } + for _, dbPath := range devCases { + if isProductionDb(dbPath) { + t.Fatalf("isProductionDb(%q) = true, want false (dev-isolated)", dbPath) + } + } + + prodCases := []string{ + "/home/user/.kandev/data/kandev.db", + "/tmp/custom.db", + "/data/kandev.db", + "/repo/.kandev/data/kandev.db", + } + for _, dbPath := range prodCases { + if !isProductionDb(dbPath) { + t.Fatalf("isProductionDb(%q) = false, want true", dbPath) + } + } +} + +func TestBackupProductionDbSkipsMissingSource(t *testing.T) { + path, err := backupProductionDb(filepath.Join(t.TempDir(), "missing.db"), t.TempDir(), time.Now()) + if err != nil { + t.Fatal(err) + } + if path != "" { + t.Fatalf("backupProductionDb(missing) = %q, want empty path", path) + } +} + +func TestBackupProductionDbPropagatesSourceErrors(t *testing.T) { + // A regular file used as a directory component yields ENOTDIR (not + // ErrNotExist), which must fail the backup rather than being treated as a + // missing database. + file := filepath.Join(t.TempDir(), "not-a-dir") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + path, err := backupProductionDb(filepath.Join(file, "kandev.db"), t.TempDir(), time.Now()) + if err == nil { + t.Fatal("backupProductionDb() = nil error for an unreadable source") + } + if path != "" { + t.Fatalf("backupProductionDb(unreadable) = %q, want empty path", path) + } +} + +func TestBackupProductionDbCreatesStampedSnapshot(t *testing.T) { + home := t.TempDir() + dbPath := filepath.Join(home, ".kandev", "data", "kandev.db") + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dbPath, []byte("original-db-content"), 0o600); err != nil { + t.Fatal(err) + } + + stamp := time.Date(2026, 8, 7, 12, 30, 45, 123_000_000, time.UTC) + created, err := backupProductionDb(dbPath, home, stamp) + if err != nil { + t.Fatal(err) + } + + wantName := backupPrefix + "2026-08-07T123045123Z" + backupSuffix + wantPath := filepath.Join(home, ".kandev", "data", "backups", wantName) + if created != wantPath { + t.Fatalf("backup path = %q, want %q", created, wantPath) + } + data, err := os.ReadFile(created) + if err != nil { + t.Fatal(err) + } + if string(data) != "original-db-content" { + t.Fatalf("backup content = %q", data) + } + info, err := os.Stat(created) + if err != nil { + t.Fatal(err) + } + if !info.ModTime().Equal(stamp) { + t.Fatalf("backup mtime = %v, want %v", info.ModTime(), stamp) + } + backupDir := filepath.Dir(created) + dirInfo, err := os.Stat(backupDir) + if err != nil { + t.Fatal(err) + } + if got := dirInfo.Mode().Perm(); got != 0o700 { + t.Fatalf("backup dir mode = %#o, want 0700", got) + } +} + +func TestBackupProductionDbPrunesToFiveNewest(t *testing.T) { + home := t.TempDir() + dbPath := filepath.Join(home, "kandev.db") + if err := os.WriteFile(dbPath, []byte("db"), 0o600); err != nil { + t.Fatal(err) + } + + base := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC) + for i := 0; i < 6; i++ { + if _, err := backupProductionDb(dbPath, home, base.Add(time.Duration(i)*time.Second)); err != nil { + t.Fatal(err) + } + } + + backupDir := filepath.Join(home, ".kandev", "data", "backups") + entries, err := os.ReadDir(backupDir) + if err != nil { + t.Fatal(err) + } + var kept []string + for _, entry := range entries { + if !entry.IsDir() && strings.HasPrefix(entry.Name(), backupPrefix) && strings.HasSuffix(entry.Name(), backupSuffix) { + kept = append(kept, entry.Name()) + } + } + if len(kept) != maxBackups { + t.Fatalf("kept %d backups, want %d: %v", len(kept), maxBackups, kept) + } + // The newest five survive; the first (oldest) is pruned. + for _, name := range kept { + if strings.Contains(name, "000000Z") { + t.Fatalf("oldest backup %q was not pruned", name) + } + } +} + +func TestBackupProductionDbPruneLeavesOtherFamilies(t *testing.T) { + home := t.TempDir() + dbPath := filepath.Join(home, "kandev.db") + if err := os.WriteFile(dbPath, []byte("db"), 0o600); err != nil { + t.Fatal(err) + } + + backupDir := filepath.Join(home, ".kandev", "data", "backups") + if err := os.MkdirAll(backupDir, 0o755); err != nil { + t.Fatal(err) + } + foreign := []string{ + "kandev-20260807T000000Z.db", + "manual-20260807T000000Z.db", + "dev-prod-db-notes.txt", + "readme.md", + } + for _, name := range foreign { + if err := os.WriteFile(filepath.Join(backupDir, name), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + + base := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC) + for i := 0; i < 6; i++ { + if _, err := backupProductionDb(dbPath, home, base.Add(time.Duration(i)*time.Second)); err != nil { + t.Fatal(err) + } + } + + for _, name := range foreign { + if _, err := os.Stat(filepath.Join(backupDir, name)); err != nil { + t.Fatalf("pruning removed foreign file %q", name) + } + } +} diff --git a/apps/backend/internal/launcher/cli/help.go b/apps/backend/internal/launcher/cli/help.go index 67d8e4928b..b62df80c42 100644 --- a/apps/backend/internal/launcher/cli/help.go +++ b/apps/backend/internal/launcher/cli/help.go @@ -4,14 +4,18 @@ const helpText = `kandev launcher Usage: kandev run [--port ] [--verbose] [--debug] + kandev dev [--port ] kandev start [--port ] [--verbose] [--debug] kandev [--port ] [--verbose] [--debug] + kandev --dev [--port ] kandev service install|uninstall|start|stop|restart|status|logs [--system] Options: + dev Use local repo for dev (Go backend + Vite dev server). start Use local production build. run Use installed runtime bundle (default). service Manage kandev as an OS service. + --dev Alias for "dev". --version, -V Print CLI version and exit. --port Port for the Go backend. Alias for --backend-port. --verbose, -v Show info logs on stdout (also retained in the backend file). @@ -21,6 +25,9 @@ Options: Advanced: --backend-port Same as --port. + --web-internal-port Override the internal dev web port. The Go backend + reverse-proxies to it in dev; start/run serve from Go. + Also reads KANDEV_WEB_PORT. ` func Help() string { diff --git a/apps/backend/internal/launcher/cli/help_test.go b/apps/backend/internal/launcher/cli/help_test.go index 681ccbfa1f..3c85d23253 100644 --- a/apps/backend/internal/launcher/cli/help_test.go +++ b/apps/backend/internal/launcher/cli/help_test.go @@ -5,13 +5,18 @@ import ( "testing" ) -func TestHelpDoesNotExposeHiddenBackendMode(t *testing.T) { +func TestHelpDescribesPublicCommands(t *testing.T) { help := Help() - if !strings.Contains(help, "kandev run") { - t.Fatalf("help does not describe public run command:\n%s", help) - } - if strings.Contains(help, "kandev dev") || strings.Contains(help, "--dev") { - t.Fatalf("help exposes unsupported dev mode:\n%s", help) + for _, want := range []string{ + "kandev run", + "kandev dev", + "kandev start", + "--dev", + "--web-internal-port", + } { + if !strings.Contains(help, want) { + t.Fatalf("help does not describe %q:\n%s", want, help) + } } if strings.Contains(help, "__backend") { t.Fatalf("help exposes hidden backend mode:\n%s", help) diff --git a/apps/backend/internal/launcher/constants.go b/apps/backend/internal/launcher/constants.go index 8e02d7c8d4..96b1e7218f 100644 --- a/apps/backend/internal/launcher/constants.go +++ b/apps/backend/internal/launcher/constants.go @@ -8,11 +8,18 @@ import ( const ( defaultBackendPort = 38429 + defaultWebPort = 37429 defaultAgentctlPort = 39429 healthTimeoutReleaseMS = 45000 + healthTimeoutDevMS = 600000 randomPortMin = 10000 randomPortMax = 60000 + + // devKandevDotdir roots all `make dev` state inside the repo so dev mode + // never mutates the user's production ~/.kandev and so `make clean-db` + // (which removes .kandev-dev/) matches what `make dev` writes. + devKandevDotdir = ".kandev-dev" ) func resolveHomeDir() string { @@ -36,3 +43,25 @@ func resolveDatabasePath() string { } return filepath.Join(resolveDataDir(), "kandev.db") } + +func devKandevHome(repoRoot string) string { + return filepath.Join(repoRoot, devKandevDotdir) +} + +// userHomeDir returns os.UserHomeDir() with a relative fallback, mirroring +// resolveHomeDir's error handling without consulting KANDEV_HOME_DIR. +func userHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "." + } + return home +} + +// kandevTasksDir locates the task-workspace root. The real user home is used +// rather than the ambient KANDEV_HOME_DIR: inside a task workspace that env +// var belongs to the parent backend, and the tasks directory always lives +// under the user's own home regardless of any per-process home override. +func kandevTasksDir() string { + return filepath.Join(userHomeDir(), ".kandev", "tasks") +} diff --git a/apps/backend/internal/launcher/dev.go b/apps/backend/internal/launcher/dev.go new file mode 100644 index 0000000000..5463cc7a2a --- /dev/null +++ b/apps/backend/internal/launcher/dev.go @@ -0,0 +1,173 @@ +package launcher + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" +) + +var ( + waitForURLFn = waitForURLReady + openBrowserFn = openBrowser + startProcessFn = startProcess +) + +// runDev launches the dev stack: backend via `make -C apps/backend dev` (so a +// Settings-triggered restart rebuilds from source) plus the Vite dev server. +// All dev state lives under /.kandev-dev/ so `make dev` never +// mutates the user's production ~/.kandev. +func runDev(ctx context.Context, opts Options) int { + cfg, code := devLaunchConfigFor(opts) + if code != 0 { + return code + } + if err := backupProductionDBIfNeeded(cfg.dbPath); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + logStartup("dev mode: using local repo", cfg.ports, cfg.dbPath, cfg.logLevel) + + ignoreBrokenPipeSignal() + setLauncherShutdownDebug(cfg.debug || os.Getenv("KANDEV_SHUTDOWN_DEBUG") == "1") + supervisor := newSupervisorFn() + attachSignalsFn(supervisor) + + healthToken, err := newHealthToken() + if err != nil { + supervisor.shutdown("health token failure") + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + env := backendEnv(cfg.ports, cfg.logLevel, resolveConsoleLogLevel(opts), opts.Debug, healthToken, cfg.extra) + backend, dumpLogs, err := launchBackendFn(backendLaunchConfig{ + Command: "make", + Args: []string{"-C", "apps/backend", "dev"}, + CWD: cfg.repoRoot, + Env: env, + Quiet: false, + Ports: cfg.ports, + Mode: "dev", + HomeDir: devKandevHome(cfg.repoRoot), + Supervisor: supervisor, + }) + if err != nil { + supervisor.shutdown("backend launch failure") + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + + fmt.Println("[kandev] starting backend...") + if err := waitForHealthFn(ctx, cfg.ports.BackendURL, backend, healthTimeout(healthTimeoutDevMS), healthToken, dumpLogs); err != nil { + supervisor.shutdown("backend health failure") + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + fmt.Printf("[kandev] backend ready at %s\n", cfg.ports.BackendURL) + + webURL := fmt.Sprintf("http://localhost:%d", cfg.ports.WebPort) + fmt.Println("[kandev] starting web...") + webProc, err := launchWebChild(cfg.repoRoot, cfg.ports, supervisor) + if err != nil { + supervisor.shutdown("web launch failure") + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + if err := waitForURLFn(ctx, webURL, webProc, healthTimeout(healthTimeoutDevMS)); err != nil { + supervisor.shutdown("web readiness failure") + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + + if opts.Headless { + fmt.Printf("[kandev] ready (headless) at %s\n", cfg.ports.BackendURL) + return waitForAppExit(supervisor, backend, webProc) + } + fmt.Println("[kandev] open: " + cfg.ports.BackendURL) + openBrowserFn(cfg.ports.BackendURL) + return waitForAppExit(supervisor, backend, webProc) +} + +type devLaunchConfig struct { + repoRoot string + ports portConfig + dbPath string + extra []string + logLevel string + debug bool +} + +// devLaunchConfigFor resolves everything runDev needs before any process is +// spawned: repo root, dev ports, and the dev backend env. It returns a +// nonzero exit code (having printed the error) when any step fails. +func devLaunchConfigFor(opts Options) (devLaunchConfig, int) { + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return devLaunchConfig{}, 2 + } + repoRoot, err := findRepoRoot(cwd) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return devLaunchConfig{}, 2 + } + backendPort, err := resolvePorts(opts) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return devLaunchConfig{}, 2 + } + webPort, err := resolveWebPort(opts) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return devLaunchConfig{}, 2 + } + ports, err := pickDevPorts(backendPort, backendPortSource(opts), webPort, webPortSource(opts), true) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return devLaunchConfig{}, 1 + } + dbPath, extra := resolveDevBackendEnv(repoRoot) + return devLaunchConfig{ + repoRoot: repoRoot, + ports: ports, + dbPath: dbPath, + extra: extra, + logLevel: resolveLogLevel(opts), + debug: opts.Debug, + }, 0 +} + +// backupProductionDBIfNeeded snapshots the resolved DB before dev mode runs +// against it, unless it already lives under a .kandev-dev segment. +func backupProductionDBIfNeeded(dbPath string) error { + if !isProductionDb(dbPath) { + return nil + } + backupPath, err := backupProductionDb(dbPath, userHomeDir(), time.Now()) + if err != nil { + // Abort rather than continue: the backup exists precisely to protect + // the production db before dev mode touches it. Proceeding on failure + // would remove the safety guarantee that justified the guard. + return fmt.Errorf("failed to back up production db (%v); aborting dev startup", err) + } + if backupPath != "" { + fmt.Printf("[kandev] backed up production db → %s\n", filepath.Base(backupPath)) + } + return nil +} + +// launchWebChild starts the Vite dev server under the supervisor, joining the +// same process group so Ctrl-C terminates the whole tree. +func launchWebChild(repoRoot string, ports portConfig, supervisor *processSupervisor) (*managedProcess, error) { + webCmd, webArgs := webCommand() + webEnv, err := buildWebEnv(ports, true) + if err != nil { + return nil, err + } + webProc, _, err := startProcessFn(webCmd, webArgs, repoRoot, webEnv, false, "web", supervisor) + if err != nil { + return nil, err + } + return webProc, nil +} diff --git a/apps/backend/internal/launcher/dev_test.go b/apps/backend/internal/launcher/dev_test.go new file mode 100644 index 0000000000..1701754881 --- /dev/null +++ b/apps/backend/internal/launcher/dev_test.go @@ -0,0 +1,352 @@ +package launcher + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" +) + +func itoa(n int) string { return strconv.Itoa(n) } + +var errTestHealth = errors.New("test health failure") + +func TestRunDevLaunchesBackendThenWebAndOpensBrowser(t *testing.T) { + repo := makeRepoTree(t) + t.Chdir(repo) + t.Setenv("KANDEV_TASK_ID", "") + t.Setenv("KANDEV_DATABASE_PATH", "") + t.Setenv("KANDEV_HOME_DIR", "") + + oldNewSupervisor := newSupervisorFn + oldAttachSignals := attachSignalsFn + oldLaunchBackend := launchBackendFn + oldWaitForHealth := waitForHealthFn + oldWaitForURL := waitForURLFn + oldOpenBrowser := openBrowserFn + oldStartProcess := startProcessFn + t.Cleanup(func() { + newSupervisorFn = oldNewSupervisor + attachSignalsFn = oldAttachSignals + launchBackendFn = oldLaunchBackend + waitForHealthFn = oldWaitForHealth + waitForURLFn = oldWaitForURL + openBrowserFn = oldOpenBrowser + startProcessFn = oldStartProcess + }) + + var events []string + var backendCfg backendLaunchConfig + var webCmd, webArgs []string + var webCWD, webEnvJoined string + var webLabel string + newSupervisorFn = func() *processSupervisor { + events = append(events, "new-supervisor") + return newSupervisor() + } + attachSignalsFn = func(_ *processSupervisor) { + events = append(events, "attach-signals") + } + launchBackendFn = func(cfg backendLaunchConfig) (*restartableBackend, func(), error) { + backendCfg = cfg + events = append(events, "launch-backend") + exitCh := make(chan int, 1) + exitCh <- 0 + return &restartableBackend{exitCh: exitCh}, func() {}, nil + } + waitForHealthFn = func(_ context.Context, url string, _ childState, _ time.Duration, _ string, _ func()) error { + events = append(events, "wait-health:"+url) + return nil + } + waitForURLFn = func(_ context.Context, url string, _ childState, _ time.Duration) error { + events = append(events, "wait-url:"+url) + return nil + } + openBrowserFn = func(url string) { + events = append(events, "open:"+url) + } + startProcessFn = func(cmd string, args []string, cwd string, env []string, quiet bool, label string, _ *processSupervisor) (*managedProcess, func(), error) { + webCmd = append([]string{cmd}, args...) + webArgs = args + webLabel = label + webCWD = cwd + webEnvJoined = strings.Join(env, "\n") + events = append(events, "start-web:"+label) + return &managedProcess{}, func() {}, nil + } + + code := runDev(context.Background(), Options{Command: CommandDev}) + if code != 0 { + t.Fatalf("runDev() = %d, want 0", code) + } + want := []string{ + "new-supervisor", + "attach-signals", + "launch-backend", + "wait-health:http://localhost:" + itoa(backendCfg.Ports.BackendPort), + "start-web:web", + "wait-url:http://localhost:" + itoa(backendCfg.Ports.WebPort), + "open:http://localhost:" + itoa(backendCfg.Ports.BackendPort), + } + if !reflect.DeepEqual(events, want) { + t.Fatalf("events = %v, want %v", events, want) + } + if backendCfg.Command != "make" || !reflect.DeepEqual(backendCfg.Args, []string{"-C", "apps/backend", "dev"}) { + t.Fatalf("backend child = %q %v, want make -C apps/backend dev", backendCfg.Command, backendCfg.Args) + } + if backendCfg.CWD != repo { + t.Fatalf("backend cwd = %q, want %q", backendCfg.CWD, repo) + } + if backendCfg.HomeDir != filepath.Join(repo, ".kandev-dev") { + t.Fatalf("backend supervisor home = %q, want %q", backendCfg.HomeDir, filepath.Join(repo, ".kandev-dev")) + } + if backendCfg.Mode != "dev" { + t.Fatalf("backend mode = %q, want dev", backendCfg.Mode) + } + + backendEnv := strings.Join(backendCfg.Env, "\n") + for _, expected := range []string{ + "KANDEV_WEB_INTERNAL_URL=http://localhost:" + itoa(backendCfg.Ports.WebPort), + "KANDEV_DEBUG_DEV_MODE=true", + "KANDEV_HOME_DIR=" + filepath.Join(repo, ".kandev-dev"), + "KANDEV_DATABASE_PATH=", + } { + if !strings.Contains(backendEnv, expected) { + t.Fatalf("backend environment missing %q:\n%s", expected, backendEnv) + } + } + + if webCWD != repo { + t.Fatalf("web cwd = %q, want %q", webCWD, repo) + } + if webLabel != "web" { + t.Fatalf("web label = %q, want web", webLabel) + } + if !reflect.DeepEqual(webArgs, []string{"-C", "apps", "--filter", "@kandev/web", "dev"}) { + t.Fatalf("web args = %v, want the Vite dev command", webArgs) + } + for _, expected := range []string{ + "KANDEV_API_BASE_URL=http://localhost:" + itoa(backendCfg.Ports.BackendPort), + "PORT=" + itoa(backendCfg.Ports.WebPort), + "HOSTNAME=127.0.0.1", + "KANDEV_DEBUG=true", + "VITE_KANDEV_DEBUG=true", + } { + if !strings.Contains(webEnvJoined, expected) { + t.Fatalf("web environment missing %q:\n%s", expected, webEnvJoined) + } + } + if strings.Contains(webEnvJoined, "VITE_KANDEV_API_PORT") { + t.Fatalf("web environment leaked VITE_KANDEV_API_PORT:\n%s", webEnvJoined) + } + if webCmd[0] == "cmd.exe" { + t.Fatalf("web command = %v, want a plain pnpm invocation on this platform", webCmd) + } +} + +func TestRunDevHeadlessSkipsBrowser(t *testing.T) { + repo := makeRepoTree(t) + t.Chdir(repo) + t.Setenv("KANDEV_TASK_ID", "") + t.Setenv("KANDEV_DATABASE_PATH", "") + t.Setenv("KANDEV_HOME_DIR", "") + + oldLaunchBackend := launchBackendFn + oldWaitForHealth := waitForHealthFn + oldWaitForURL := waitForURLFn + oldOpenBrowser := openBrowserFn + oldStartProcess := startProcessFn + oldAttachSignals := attachSignalsFn + t.Cleanup(func() { + launchBackendFn = oldLaunchBackend + waitForHealthFn = oldWaitForHealth + waitForURLFn = oldWaitForURL + openBrowserFn = oldOpenBrowser + startProcessFn = oldStartProcess + attachSignalsFn = oldAttachSignals + }) + + browserOpened := false + attachSignalsFn = func(_ *processSupervisor) {} + launchBackendFn = func(cfg backendLaunchConfig) (*restartableBackend, func(), error) { + exitCh := make(chan int, 1) + exitCh <- 0 + return &restartableBackend{exitCh: exitCh}, func() {}, nil + } + waitForHealthFn = func(_ context.Context, _ string, _ childState, _ time.Duration, _ string, _ func()) error { + return nil + } + waitForURLFn = func(_ context.Context, _ string, _ childState, _ time.Duration) error { + return nil + } + openBrowserFn = func(_ string) { browserOpened = true } + startProcessFn = func(_ string, _ []string, _ string, _ []string, _ bool, _ string, _ *processSupervisor) (*managedProcess, func(), error) { + return &managedProcess{}, func() {}, nil + } + + code := runDev(context.Background(), Options{Command: CommandDev, Headless: true}) + if code != 0 { + t.Fatalf("runDev(headless) = %d, want 0", code) + } + if browserOpened { + t.Fatal("runDev(headless) opened the browser") + } +} + +func TestRunDevAbortsOnBackupFailureBeforeLaunch(t *testing.T) { + repo := makeRepoTree(t) + t.Chdir(repo) + t.Setenv("KANDEV_TASK_ID", "") + // A path without a .kandev-dev segment is treated as production; making it + // a directory forces the copy to fail so the abort path runs. + t.Setenv("HOME", t.TempDir()) + dbDir := t.TempDir() + t.Setenv("KANDEV_DATABASE_PATH", filepath.Join(dbDir, "kandev.db")) + if err := os.Mkdir(filepath.Join(dbDir, "kandev.db"), 0o700); err != nil { + t.Fatal(err) + } + + oldLaunchBackend := launchBackendFn + t.Cleanup(func() { launchBackendFn = oldLaunchBackend }) + launched := false + launchBackendFn = func(_ backendLaunchConfig) (*restartableBackend, func(), error) { + launched = true + return nil, nil, nil + } + + code := runDev(context.Background(), Options{Command: CommandDev}) + if code != 1 { + t.Fatalf("runDev() = %d, want 1", code) + } + if launched { + t.Fatal("backend launched despite a failed production-db backup") + } +} + +func TestRunDevShutsDownTreeOnBackendHealthFailure(t *testing.T) { + repo := makeRepoTree(t) + t.Chdir(repo) + t.Setenv("KANDEV_TASK_ID", "") + t.Setenv("KANDEV_DATABASE_PATH", "") + t.Setenv("KANDEV_HOME_DIR", "") + + oldLaunchBackend := launchBackendFn + oldWaitForHealth := waitForHealthFn + oldWaitForURL := waitForURLFn + oldOpenBrowser := openBrowserFn + oldStartProcess := startProcessFn + oldAttachSignals := attachSignalsFn + oldStatusOutput := launcherStatusOutput + t.Cleanup(func() { + launchBackendFn = oldLaunchBackend + waitForHealthFn = oldWaitForHealth + waitForURLFn = oldWaitForURL + openBrowserFn = oldOpenBrowser + startProcessFn = oldStartProcess + attachSignalsFn = oldAttachSignals + launcherStatusOutput = oldStatusOutput + }) + + var output strings.Builder + launcherStatusOutput = &output + + browserOpened := false + attachSignalsFn = func(_ *processSupervisor) {} + launchBackendFn = func(_ backendLaunchConfig) (*restartableBackend, func(), error) { + return &restartableBackend{exitCh: make(chan int, 1)}, func() {}, nil + } + waitForHealthFn = func(_ context.Context, _ string, _ childState, _ time.Duration, _ string, _ func()) error { + return errTestHealth + } + waitForURLFn = func(_ context.Context, _ string, _ childState, _ time.Duration) error { + t.Fatal("waitForURL called after backend health failure") + return nil + } + openBrowserFn = func(_ string) { browserOpened = true } + startProcessFn = func(_ string, _ []string, _ string, _ []string, _ bool, _ string, _ *processSupervisor) (*managedProcess, func(), error) { + t.Fatal("web child launched after backend health failure") + return nil, nil, nil + } + + code := runDev(context.Background(), Options{Command: CommandDev}) + if code != 1 { + t.Fatalf("runDev() = %d, want 1", code) + } + if !strings.Contains(output.String(), "graceful shutdown started") { + t.Fatalf("supervisor shutdown not triggered on health failure:\n%s", output.String()) + } + if browserOpened { + t.Fatal("browser opened despite health failure") + } +} + +func TestRunDevShutsDownTreeWhenWebChildExits(t *testing.T) { + repo := makeRepoTree(t) + t.Chdir(repo) + t.Setenv("KANDEV_TASK_ID", "") + t.Setenv("KANDEV_DATABASE_PATH", "") + t.Setenv("KANDEV_HOME_DIR", "") + + oldLaunchBackend := launchBackendFn + oldWaitForHealth := waitForHealthFn + oldWaitForURL := waitForURLFn + oldOpenBrowser := openBrowserFn + oldStartProcess := startProcessFn + oldAttachSignals := attachSignalsFn + oldStatusOutput := launcherStatusOutput + t.Cleanup(func() { + launchBackendFn = oldLaunchBackend + waitForHealthFn = oldWaitForHealth + waitForURLFn = oldWaitForURL + openBrowserFn = oldOpenBrowser + startProcessFn = oldStartProcess + attachSignalsFn = oldAttachSignals + launcherStatusOutput = oldStatusOutput + }) + + var output strings.Builder + launcherStatusOutput = &output + + attachSignalsFn = func(_ *processSupervisor) {} + // The backend stays alive; only the web child exits. waitForAppExit must + // select the web child and take the launcher down with its exit code. + launchBackendFn = func(_ backendLaunchConfig) (*restartableBackend, func(), error) { + return &restartableBackend{exitCh: make(chan int, 1)}, func() {}, nil + } + waitForHealthFn = func(_ context.Context, _ string, _ childState, _ time.Duration, _ string, _ func()) error { + return nil + } + waitForURLFn = func(_ context.Context, _ string, _ childState, _ time.Duration) error { + return nil + } + openBrowserFn = func(_ string) {} + webDone := make(chan struct{}) + startProcessFn = func(_ string, _ []string, _ string, _ []string, _ bool, label string, _ *processSupervisor) (*managedProcess, func(), error) { + return &managedProcess{label: label, exited: true, exitCode: 7, done: webDone}, func() {}, nil + } + // The web child has already exited: a pre-closed done channel is the + // signal waitForAppExit selects on. + close(webDone) + + code := runDev(context.Background(), Options{Command: CommandDev, Headless: true}) + if code != 7 { + t.Fatalf("runDev() = %d, want the web child's exit code 7", code) + } + if !strings.Contains(output.String(), "reason=web exit") { + t.Fatalf("supervisor not shut down for the web child exit:\n%s", output.String()) + } +} + +func TestRunDevFailsWhenRepoRootNotFound(t *testing.T) { + t.Chdir(t.TempDir()) + + code := runDev(context.Background(), Options{Command: CommandDev}) + if code != 2 { + t.Fatalf("runDev() = %d, want 2", code) + } +} diff --git a/apps/backend/internal/launcher/devpaths.go b/apps/backend/internal/launcher/devpaths.go new file mode 100644 index 0000000000..483e9074a1 --- /dev/null +++ b/apps/backend/internal/launcher/devpaths.go @@ -0,0 +1,92 @@ +package launcher + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// findRepoRoot returns the nearest ancestor of startDir that contains both +// apps/backend and apps/web, also handling a start directory that *is* +// /apps (dev is commonly invoked from apps/). It returns an error, not +// a fallback path, when no such ancestor exists: silently running dev against +// the wrong tree would be worse than failing loudly. +func findRepoRoot(startDir string) (string, error) { + current := filepath.Clean(startDir) + for { + if filepath.Base(current) == "apps" && + exists(filepath.Join(current, "backend")) && + exists(filepath.Join(current, "web")) { + return filepath.Dir(current), nil + } + if exists(filepath.Join(current, "apps", "backend")) && + exists(filepath.Join(current, "apps", "web")) { + return current, nil + } + parent := filepath.Dir(current) + if parent == current { + return "", fmt.Errorf( + "unable to locate repo root for dev (no ancestor of %s contains apps/backend and apps/web); run from the repository", startDir) + } + current = parent + } +} + +// isInsideKandevTask reports whether the current process looks like it was +// spawned inside a kandev-created task workspace. Two signals: +// 1. The parent kandev backend exports KANDEV_TASK_ID into every task shell. +// 2. Task worktrees live under ~/.kandev/tasks/. +// +// The path-prefix fallback is a defensive secondary signal for nested shells +// where KANDEV_TASK_ID was stripped. It is case-sensitive and does not +// resolve symlinks, so a realpath'd repoRoot may miss a symlinked HOME on +// macOS/Windows. KANDEV_TASK_ID remains the primary guarantee. +func isInsideKandevTask(repoRoot string) bool { + if os.Getenv("KANDEV_TASK_ID") != "" { + return true + } + return strings.HasPrefix(repoRoot, kandevTasksDir()+string(os.PathSeparator)) +} + +// resolveDevBackendEnv computes the dev-mode backend env. Dev mode always +// roots kandev under /.kandev-dev so state is isolated from the user's +// production ~/.kandev and so `make clean-db` (which removes .kandev-dev/) +// matches what `make dev` writes. +// +// When invoked from inside a kandev task workspace, any KANDEV_DATABASE_PATH +// is assumed to be leaked from the parent backend and is ignored. In a normal +// shell, an explicit KANDEV_DATABASE_PATH is honored as an escape hatch. +// +// The returned dbPath is display-only; the backend derives its own DB path +// from KANDEV_HOME_DIR via resolveDatabasePath(). Both resolve to the same +// location. +func resolveDevBackendEnv(repoRoot string) (dbPath string, extra []string) { + devHome := devKandevHome(repoRoot) + devDBPath := filepath.Join(devHome, "data", "kandev.db") + + // Profile-selector only: the backend reads profiles.yaml at startup and + // applies the matching dev: values (mock agent, pprof, feature flags, + // etc.) to its own env. The launcher must not restate them — profiles.yaml + // at the repo root is the single source of truth. + baseExtra := []string{"KANDEV_DEBUG_DEV_MODE=true"} + + if isInsideKandevTask(repoRoot) { + fmt.Println("[kandev] task workspace detected → using local dev state") + return devDBPath, append(baseExtra, + "KANDEV_HOME_DIR="+devHome, + // Clear a parent-leaked DB path so the backend uses the + // HomeDir-derived default. + "KANDEV_DATABASE_PATH=", + ) + } + + if override := strings.TrimSpace(os.Getenv("KANDEV_DATABASE_PATH")); override != "" { + return override, append(baseExtra, "KANDEV_DATABASE_PATH="+override) + } + + return devDBPath, append(baseExtra, + "KANDEV_HOME_DIR="+devHome, + "KANDEV_DATABASE_PATH=", + ) +} diff --git a/apps/backend/internal/launcher/devpaths_test.go b/apps/backend/internal/launcher/devpaths_test.go new file mode 100644 index 0000000000..bde739abbc --- /dev/null +++ b/apps/backend/internal/launcher/devpaths_test.go @@ -0,0 +1,195 @@ +package launcher + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func makeRepoTree(t *testing.T) string { + t.Helper() + root := t.TempDir() + for _, dir := range []string{"apps/backend", "apps/web"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o700); err != nil { + t.Fatal(err) + } + } + return root +} + +func TestFindRepoRootFromNestedWorkingDirectory(t *testing.T) { + root := makeRepoTree(t) + + got, err := findRepoRoot(filepath.Join(root, "apps", "backend", "internal", "launcher")) + if err != nil { + t.Fatal(err) + } + if got != root { + t.Fatalf("findRepoRoot = %q, want %q", got, root) + } +} + +func TestFindRepoRootFromRepoRootItself(t *testing.T) { + root := makeRepoTree(t) + + got, err := findRepoRoot(root) + if err != nil { + t.Fatal(err) + } + if got != root { + t.Fatalf("findRepoRoot = %q, want %q", got, root) + } +} + +func TestFindRepoRootFromAppsDirectory(t *testing.T) { + root := makeRepoTree(t) + + got, err := findRepoRoot(filepath.Join(root, "apps")) + if err != nil { + t.Fatal(err) + } + if got != root { + t.Fatalf("findRepoRoot = %q, want %q", got, root) + } +} + +func TestFindRepoRootRequiresBothApps(t *testing.T) { + onlyBackend := t.TempDir() + if err := os.MkdirAll(filepath.Join(onlyBackend, "apps", "backend"), 0o700); err != nil { + t.Fatal(err) + } + if _, err := findRepoRoot(onlyBackend); err == nil { + t.Fatal("findRepoRoot accepted a tree with only apps/backend") + } +} + +func TestFindRepoRootFailsOutsideRepository(t *testing.T) { + if _, err := findRepoRoot(t.TempDir()); err == nil { + t.Fatal("findRepoRoot accepted an empty tree") + } +} + +func TestIsInsideKandevTaskSignals(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("KANDEV_TASK_ID", "") + + repoInTasks := filepath.Join(home, ".kandev", "tasks", "ws", "repo") + if !isInsideKandevTask(repoInTasks) { + t.Fatalf("isInsideKandevTask(%q) = false, want true for a repo under ~/.kandev/tasks", repoInTasks) + } + + if isInsideKandevTask(filepath.Join(home, "projects", "repo")) { + t.Fatal("isInsideKandevTask = true for a repo outside the tasks dir") + } + + tasksDir := filepath.Join(home, ".kandev", "tasks") + if isInsideKandevTask(tasksDir) { + t.Fatal("isInsideKandevTask = true for the tasks dir itself (no child segment)") + } + + t.Setenv("KANDEV_TASK_ID", "task-123") + if !isInsideKandevTask(filepath.Join(home, "projects", "repo")) { + t.Fatal("isInsideKandevTask = false with KANDEV_TASK_ID set") + } +} + +func devEnvToMap(extra []string) map[string]string { + out := map[string]string{} + for _, item := range extra { + key, value, ok := strings.Cut(item, "=") + if ok { + out[key] = value + } + } + return out +} + +func TestResolveDevBackendEnvDefaultUsesRepoLocalHome(t *testing.T) { + repo := makeRepoTree(t) + t.Setenv("KANDEV_TASK_ID", "") + t.Setenv("KANDEV_DATABASE_PATH", "") + + dbPath, extra := resolveDevBackendEnv(repo) + env := devEnvToMap(extra) + wantHome := filepath.Join(repo, ".kandev-dev") + wantDB := filepath.Join(wantHome, "data", "kandev.db") + + if dbPath != wantDB { + t.Fatalf("dbPath = %q, want %q", dbPath, wantDB) + } + if env["KANDEV_HOME_DIR"] != wantHome { + t.Fatalf("KANDEV_HOME_DIR = %q, want %q", env["KANDEV_HOME_DIR"], wantHome) + } + if env["KANDEV_DATABASE_PATH"] != "" { + t.Fatalf("KANDEV_DATABASE_PATH = %q, want empty (derive from home)", env["KANDEV_DATABASE_PATH"]) + } + if env["KANDEV_DEBUG_DEV_MODE"] != "true" { + t.Fatalf("KANDEV_DEBUG_DEV_MODE = %q, want true", env["KANDEV_DEBUG_DEV_MODE"]) + } +} + +func TestResolveDevBackendEnvHonorsExplicitDatabasePath(t *testing.T) { + repo := makeRepoTree(t) + t.Setenv("KANDEV_TASK_ID", "") + t.Setenv("KANDEV_DATABASE_PATH", "/custom/kandev.db") + + dbPath, extra := resolveDevBackendEnv(repo) + env := devEnvToMap(extra) + + if dbPath != "/custom/kandev.db" { + t.Fatalf("dbPath = %q, want the explicit override", dbPath) + } + if env["KANDEV_DATABASE_PATH"] != "/custom/kandev.db" { + t.Fatalf("KANDEV_DATABASE_PATH = %q", env["KANDEV_DATABASE_PATH"]) + } + if _, ok := env["KANDEV_HOME_DIR"]; ok { + t.Fatal("explicit override must not set KANDEV_HOME_DIR") + } + if env["KANDEV_DEBUG_DEV_MODE"] != "true" { + t.Fatalf("KANDEV_DEBUG_DEV_MODE = %q, want true", env["KANDEV_DEBUG_DEV_MODE"]) + } +} + +func TestResolveDevBackendEnvIgnoresWhitespaceOnlyOverride(t *testing.T) { + repo := makeRepoTree(t) + t.Setenv("KANDEV_TASK_ID", "") + t.Setenv("KANDEV_DATABASE_PATH", " ") + + dbPath, extra := resolveDevBackendEnv(repo) + env := devEnvToMap(extra) + wantHome := filepath.Join(repo, ".kandev-dev") + + if dbPath != filepath.Join(wantHome, "data", "kandev.db") { + t.Fatalf("dbPath = %q, want the repo-local dev db for a blank override", dbPath) + } + if env["KANDEV_HOME_DIR"] != wantHome { + t.Fatalf("KANDEV_HOME_DIR = %q, want %q", env["KANDEV_HOME_DIR"], wantHome) + } + if env["KANDEV_DATABASE_PATH"] != "" { + t.Fatalf("KANDEV_DATABASE_PATH = %q, want empty", env["KANDEV_DATABASE_PATH"]) + } +} + +func TestResolveDevBackendEnvClearsLeakedPathInTaskWorkspace(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("KANDEV_TASK_ID", "") + t.Setenv("KANDEV_DATABASE_PATH", "/leaked/from/parent/kandev.db") + + repo := filepath.Join(home, ".kandev", "tasks", "ws", "repo") + dbPath, extra := resolveDevBackendEnv(repo) + env := devEnvToMap(extra) + wantHome := filepath.Join(repo, ".kandev-dev") + + if dbPath != filepath.Join(wantHome, "data", "kandev.db") { + t.Fatalf("dbPath = %q, want the repo-local dev db", dbPath) + } + if env["KANDEV_HOME_DIR"] != wantHome { + t.Fatalf("KANDEV_HOME_DIR = %q, want %q", env["KANDEV_HOME_DIR"], wantHome) + } + if env["KANDEV_DATABASE_PATH"] != "" { + t.Fatalf("KANDEV_DATABASE_PATH = %q, want empty (parent-leaked path cleared)", env["KANDEV_DATABASE_PATH"]) + } +} diff --git a/apps/backend/internal/launcher/env.go b/apps/backend/internal/launcher/env.go index 9c6e2e86f9..37bd000104 100644 --- a/apps/backend/internal/launcher/env.go +++ b/apps/backend/internal/launcher/env.go @@ -18,12 +18,15 @@ func newHealthToken() (string, error) { return hex.EncodeToString(token), nil } -func backendEnv(ports portConfig, logLevel, consoleLogLevel string, debug bool, healthToken string) []string { +func backendEnv(ports portConfig, logLevel, consoleLogLevel string, debug bool, healthToken string, extra []string) []string { env := os.Environ() env = upsertEnv(env, "KANDEV_SERVER_PORT", fmt.Sprint(ports.BackendPort)) env = upsertEnv(env, "KANDEV_AGENT_STANDALONE_PORT", fmt.Sprint(ports.AgentctlPort)) env = upsertEnv(env, "KANDEV_DATABASE_PATH", resolveDatabasePath()) env = upsertEnv(env, "KANDEV_DESKTOP_HEALTH_TOKEN", healthToken) + if ports.WebPort != 0 { + env = upsertEnv(env, "KANDEV_WEB_INTERNAL_URL", fmt.Sprintf("http://localhost:%d", ports.WebPort)) + } if logLevel != "" { env = upsertEnv(env, "KANDEV_LOG_LEVEL", logLevel) } @@ -33,6 +36,12 @@ func backendEnv(ports portConfig, logLevel, consoleLogLevel string, debug bool, env = upsertEnv(env, "KANDEV_DEBUG_PPROF_ENABLED", "true") env = setEnvIfUnset(env, "KANDEV_WEB_TITLE_PREFIX", "Debug") } + for _, item := range extra { + key, value, ok := strings.Cut(item, "=") + if ok { + env = upsertEnv(env, key, value) + } + } return env } diff --git a/apps/backend/internal/launcher/launcher.go b/apps/backend/internal/launcher/launcher.go index 460665ad2e..483c26aa3a 100644 --- a/apps/backend/internal/launcher/launcher.go +++ b/apps/backend/internal/launcher/launcher.go @@ -39,6 +39,8 @@ func Run(args []string, build BuildInfo) int { return runStart(ctx, opts) case CommandRun: return runInstalled(ctx, opts) + case CommandDev: + return runDev(ctx, opts) } fmt.Fprintf(os.Stderr, "[kandev] native launcher command %q is not implemented yet\n", opts.Command) return 1 diff --git a/apps/backend/internal/launcher/options.go b/apps/backend/internal/launcher/options.go index 38899ef646..c96eda1a59 100644 --- a/apps/backend/internal/launcher/options.go +++ b/apps/backend/internal/launcher/options.go @@ -11,11 +11,13 @@ type Command string const ( CommandRun Command = "run" CommandStart Command = "start" + CommandDev Command = "dev" ) type Options struct { Command Command BackendPort int + WebPort int Verbose bool Debug bool ShowVersion bool @@ -40,6 +42,9 @@ func parseArgs(argv []string) (Options, error) { } i = next } + if opts.WebPort != 0 && opts.Command != CommandDev { + return opts, ParseError{Message: "--web-internal-port only applies to dev mode"} + } return opts, nil } @@ -67,6 +72,8 @@ func parseCommandArg(arg string, opts *Options) bool { opts.Command = CommandRun case string(CommandStart): opts.Command = CommandStart + case string(CommandDev): + opts.Command = CommandDev default: return false } @@ -85,6 +92,8 @@ func parseBooleanFlag(arg string, opts *Options) bool { opts.Debug = true case "--headless", "--no-browser": opts.Headless = true + case "--dev": + opts.Command = CommandDev default: return false } @@ -105,11 +114,38 @@ func parsePortArg(argv []string, i int, opts *Options) (int, bool, error) { return i, true, parseBackendPortValue("--port", strings.TrimPrefix(arg, "--port="), opts) case strings.HasPrefix(arg, "--backend-port="): return i, true, parseBackendPortValue("--backend-port", strings.TrimPrefix(arg, "--backend-port="), opts) + case arg == "--web-internal-port": + next, err := parseWebPort(argv, i, opts) + return next, true, err + case strings.HasPrefix(arg, "--web-internal-port="): + return i, true, parseWebPortValue("--web-internal-port", strings.TrimPrefix(arg, "--web-internal-port="), opts) + case arg == "--web-port" || strings.HasPrefix(arg, "--web-port="): + return i, true, ParseError{Message: "--web-port has been removed; use --web-internal-port for dev mode"} default: return i, false, nil } } +func parseWebPort(argv []string, i int, opts *Options) (int, error) { + v, err := takeValue(argv, i, argv[i]) + if err != nil { + return i, err + } + if err := parseWebPortValue(argv[i], v, opts); err != nil { + return i, err + } + return i + 1, nil +} + +func parseWebPortValue(flag, value string, opts *Options) error { + port, err := parsePort(value, flag) + if err != nil { + return err + } + opts.WebPort = port + return nil +} + func parseBackendPort(argv []string, i int, opts *Options) (int, error) { v, err := takeValue(argv, i, argv[i]) if err != nil { diff --git a/apps/backend/internal/launcher/options_test.go b/apps/backend/internal/launcher/options_test.go index dc91fa4488..90fb5a7040 100644 --- a/apps/backend/internal/launcher/options_test.go +++ b/apps/backend/internal/launcher/options_test.go @@ -23,20 +23,71 @@ func TestParseArgsRejectsInvalidPort(t *testing.T) { } func TestParseArgsRejectsRemovedWebPort(t *testing.T) { - _, err := parseArgs([]string{"--web-port", "12345"}) - if err == nil { - t.Fatal("expected error") - } -} - -func TestParseArgsRejectsUnsupportedDevMode(t *testing.T) { - for _, argv := range [][]string{{"dev"}, {"--dev"}} { + for _, argv := range [][]string{ + {"--web-port", "12345"}, + {"--web-port=12345"}, + } { _, err := parseArgs(argv) if err == nil { t.Fatalf("parseArgs(%v) returned nil error", argv) } - if _, ok := err.(ParseError); !ok { - t.Fatalf("parseArgs(%v) error = %T, want ParseError", argv, err) + if err.Error() != "--web-port has been removed; use --web-internal-port for dev mode" { + t.Fatalf("parseArgs(%v) error = %q", argv, err) + } + } +} + +func TestParseArgsAcceptsDevCommand(t *testing.T) { + for _, argv := range [][]string{{"dev"}, {"--dev"}} { + opts, err := parseArgs(argv) + if err != nil { + t.Fatalf("parseArgs(%v) = %v", argv, err) + } + if opts.Command != CommandDev { + t.Fatalf("parseArgs(%v) Command = %q, want %q", argv, opts.Command, CommandDev) + } + } +} + +func TestParseArgsAcceptsWebInternalPort(t *testing.T) { + for _, argv := range [][]string{ + {"dev", "--web-internal-port", "37430"}, + {"dev", "--web-internal-port=37430"}, + } { + opts, err := parseArgs(argv) + if err != nil { + t.Fatalf("parseArgs(%v) = %v", argv, err) + } + if opts.Command != CommandDev || opts.WebPort != 37430 { + t.Fatalf("parseArgs(%v) = %+v", argv, opts) + } + } +} + +func TestParseArgsRejectsWebInternalPortOutsideDevMode(t *testing.T) { + for _, argv := range [][]string{ + {"--web-internal-port", "37430"}, + {"run", "--web-internal-port=37430"}, + {"start", "--web-internal-port", "37430"}, + } { + _, err := parseArgs(argv) + if err == nil { + t.Fatalf("parseArgs(%v) returned nil error", argv) + } + if err.Error() != "--web-internal-port only applies to dev mode" { + t.Fatalf("parseArgs(%v) error = %q", argv, err) + } + } +} + +func TestParseArgsRejectsInvalidWebPort(t *testing.T) { + for _, argv := range [][]string{ + {"dev", "--web-internal-port", "70000"}, + {"dev", "--web-internal-port=0"}, + {"dev", "--web-internal-port"}, + } { + if _, err := parseArgs(argv); err == nil { + t.Fatalf("parseArgs(%v) returned nil error", argv) } } } diff --git a/apps/backend/internal/launcher/ports.go b/apps/backend/internal/launcher/ports.go index ce9c43d040..7548f075af 100644 --- a/apps/backend/internal/launcher/ports.go +++ b/apps/backend/internal/launcher/ports.go @@ -11,6 +11,7 @@ import ( type portConfig struct { BackendPort int + WebPort int AgentctlPort int BackendURL string } @@ -19,6 +20,8 @@ const ( backendPortFlag = "--port" backendPortEnv = "KANDEV_BACKEND_PORT" legacyBackendPortEnv = "KANDEV_PORT" + webPortFlag = "--web-internal-port" + webPortEnv = "KANDEV_WEB_PORT" ) func resolvePorts(opts Options) (int, error) { @@ -37,6 +40,15 @@ func resolvePorts(opts Options) (int, error) { return backend, nil } +// resolveWebPort resolves the dev-mode web port: the --web-internal-port flag +// beats KANDEV_WEB_PORT, and 0 means "unset". +func resolveWebPort(opts Options) (int, error) { + if opts.WebPort != 0 { + return opts.WebPort, nil + } + return envPort(webPortEnv) +} + func backendPortSource(opts Options) string { if opts.BackendPort != 0 { return backendPortFlag @@ -50,6 +62,16 @@ func backendPortSource(opts Options) string { return "" } +func webPortSource(opts Options) string { + if opts.WebPort != 0 { + return webPortFlag + } + if _, ok := os.LookupEnv(webPortEnv); ok { + return webPortEnv + } + return "" +} + func envPort(name string) (int, error) { raw, ok := os.LookupEnv(name) if !ok { @@ -62,7 +84,19 @@ func envPort(name string) (int, error) { return n, nil } +// pickPorts resolves the start/run port pair (backend + agentctl). The web +// port selection is dev-only: with wantWeb=false it is skipped entirely, so a +// busy web port can never fail (or slow down) a release launch. func pickPorts(backendPort int, source string) (portConfig, error) { + return pickDevPorts(backendPort, source, 0, "", false) +} + +// pickDevPorts resolves the backend, web, and agentctl ports. Explicitly +// requested ports must be bindable or the launch fails hard naming the port +// and its source (flag vs. env); omitted ports fall back to the preferred +// value, then a random free port. The ports are guaranteed distinct. Web +// selection only runs when wantWeb is set (dev mode). +func pickDevPorts(backendPort int, backendSource string, webPort int, webSource string, wantWeb bool) (portConfig, error) { used := map[int]bool{} backend := backendPort if backend == 0 { @@ -73,18 +107,43 @@ func pickPorts(backendPort int, source string) (portConfig, error) { backend = p } else if !canBind(backend) { sourceSuffix := "" - if source != "" { - sourceSuffix = fmt.Sprintf(" from %s", source) + if backendSource != "" { + sourceSuffix = fmt.Sprintf(" from %s", backendSource) } return portConfig{}, fmt.Errorf("backend port %d%s is already in use", backend, sourceSuffix) } used[backend] = true + + web := 0 + if wantWeb { + switch { + case webPort == 0: + p, err := pickAvailablePortExcept(defaultWebPort, used) + if err != nil { + return portConfig{}, err + } + web = p + case used[webPort]: + return portConfig{}, fmt.Errorf("web port %d conflicts with the backend port", webPort) + case !canBind(webPort): + sourceSuffix := "" + if webSource != "" { + sourceSuffix = fmt.Sprintf(" from %s", webSource) + } + return portConfig{}, fmt.Errorf("web port %d%s is already in use", webPort, sourceSuffix) + default: + web = webPort + } + used[web] = true + } + agentctl, err := pickAvailablePortExcept(defaultAgentctlPort, used) if err != nil { return portConfig{}, err } return portConfig{ BackendPort: backend, + WebPort: web, AgentctlPort: agentctl, BackendURL: fmt.Sprintf("http://localhost:%d", backend), }, nil diff --git a/apps/backend/internal/launcher/ports_test.go b/apps/backend/internal/launcher/ports_test.go index 878c486197..4de7c49911 100644 --- a/apps/backend/internal/launcher/ports_test.go +++ b/apps/backend/internal/launcher/ports_test.go @@ -2,6 +2,7 @@ package launcher import ( "net" + "os" "strconv" "strings" "testing" @@ -51,3 +52,146 @@ func TestBackendPortSourceUsesLauncherPrecedence(t *testing.T) { t.Fatalf("backendPortSource(empty specific env) = %q, want KANDEV_BACKEND_PORT", got) } } + +func TestResolveWebPortPrecedence(t *testing.T) { + // Unset first: an empty KANDEV_WEB_PORT is a value and errors like the + // backend port env vars do. + old, hadOld := os.LookupEnv("KANDEV_WEB_PORT") + if hadOld { + t.Cleanup(func() { t.Setenv("KANDEV_WEB_PORT", old) }) + } else { + t.Cleanup(func() { _ = os.Unsetenv("KANDEV_WEB_PORT") }) + } + _ = os.Unsetenv("KANDEV_WEB_PORT") + + if got, err := resolveWebPort(Options{}); err != nil || got != 0 { + t.Fatalf("resolveWebPort(unset) = %d, %v, want 0", got, err) + } + + t.Setenv("KANDEV_WEB_PORT", "37001") + if got, err := resolveWebPort(Options{}); err != nil || got != 37001 { + t.Fatalf("resolveWebPort(env) = %d, %v, want 37001", got, err) + } + if got, err := resolveWebPort(Options{WebPort: 37430}); err != nil || got != 37430 { + t.Fatalf("resolveWebPort(flag) = %d, %v, want 37430", got, err) + } + if got := webPortSource(Options{WebPort: 37430}); got != "--web-internal-port" { + t.Fatalf("webPortSource(flag) = %q", got) + } + if got := webPortSource(Options{}); got != "KANDEV_WEB_PORT" { + t.Fatalf("webPortSource(env) = %q", got) + } + t.Setenv("KANDEV_WEB_PORT", "") + if got := webPortSource(Options{}); got != "KANDEV_WEB_PORT" { + t.Fatalf("webPortSource(empty env) = %q, want the env var name", got) + } + if _, err := resolveWebPort(Options{}); err == nil { + t.Fatal("resolveWebPort(empty env) = nil error, want a ParseError") + } +} + +func TestPickDevPortsDefaultsToDistinctPreferredPorts(t *testing.T) { + cfg, err := pickDevPorts(0, "", 0, "", true) + if err != nil { + t.Fatal(err) + } + seen := map[int]bool{cfg.BackendPort: true, cfg.WebPort: true, cfg.AgentctlPort: true} + if len(seen) != 3 { + t.Fatalf("ports not distinct: %+v", cfg) + } + // The preferred defaults are used when bindable; a busy default falls back + // to a random free port. Assert the default when it is free so the test is + // meaningful on machines where the ports are available. + if canBind(defaultBackendPort) && cfg.BackendPort != defaultBackendPort { + t.Fatalf("backend port = %d, want %d", cfg.BackendPort, defaultBackendPort) + } + if canBind(defaultWebPort) && cfg.WebPort != defaultWebPort { + t.Fatalf("web port = %d, want %d", cfg.WebPort, defaultWebPort) + } +} + +func TestPickDevPortsRejectsOccupiedExplicitWebPort(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + + port := ln.Addr().(*net.TCPAddr).Port + _, err = pickDevPorts(0, "", port, "--web-internal-port", true) + if err == nil { + t.Fatalf("pickDevPorts succeeded while the requested web port was occupied") + } + if !strings.Contains(err.Error(), "web port "+strconv.Itoa(port)) { + t.Fatalf("error = %v, want the occupied web port", err) + } + if !strings.Contains(err.Error(), "from --web-internal-port") { + t.Fatalf("error = %v, want the configuration source", err) + } +} + +func TestPickDevPortsBackendAndWebStayDistinct(t *testing.T) { + cfg, err := pickDevPorts(0, "", 0, "", true) + if err != nil { + t.Fatal(err) + } + // If the default web port collided with the backend port, pickAvailablePortExcept + // must have shifted it rather than returning a duplicate. + if cfg.WebPort == cfg.BackendPort { + t.Fatalf("web port %d collides with backend port %d", cfg.WebPort, cfg.BackendPort) + } + if cfg.AgentctlPort == cfg.BackendPort || cfg.AgentctlPort == cfg.WebPort { + t.Fatalf("agentctl port %d collides with another service port: %+v", cfg.AgentctlPort, cfg) + } +} + +func TestPickDevPortsRejectsWebPortDuplicatingBackendPort(t *testing.T) { + // Pick a free port and request it for both services: the explicit web + // port must be rejected rather than silently assigned twice. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + + cfg, err := pickDevPorts(port, "--port", port, "--web-internal-port", true) + if err == nil { + t.Fatalf("pickDevPorts accepted duplicate backend/web port: %+v", cfg) + } + if !strings.Contains(err.Error(), "conflicts with the backend port") { + t.Fatalf("error = %v, want a backend-port conflict message", err) + } +} + +func TestPickPortsWithoutWebPortMatchesStartSemantics(t *testing.T) { + cfg, err := pickPorts(0, "") + if err != nil { + t.Fatal(err) + } + if cfg.WebPort != 0 { + t.Fatalf("pickPorts set WebPort = %d, want 0 for start/run", cfg.WebPort) + } +} + +func TestPickPortsIgnoresOccupiedWebPort(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + webPort := ln.Addr().(*net.TCPAddr).Port + + // start/run never use a web port, so an occupied one must not influence + // port selection (pickDevPorts with wantWeb=false skips the probe). + cfg, err := pickPorts(0, "") + if err != nil { + t.Fatalf("pickPorts failed with an occupied web port: %v", err) + } + if cfg.WebPort != 0 { + t.Fatalf("pickPorts set WebPort = %d, want 0", cfg.WebPort) + } + if cfg.BackendPort == webPort || cfg.AgentctlPort == webPort { + t.Fatalf("pickPorts collided with the occupied web port %d: %+v", webPort, cfg) + } +} diff --git a/apps/backend/internal/launcher/process.go b/apps/backend/internal/launcher/process.go index bc8caa7051..5d12142817 100644 --- a/apps/backend/internal/launcher/process.go +++ b/apps/backend/internal/launcher/process.go @@ -414,10 +414,30 @@ func waitForManagedProcessKillDone(done <-chan struct{}, timeout time.Duration) } } -func waitForAppExit(supervisor *processSupervisor, backend *restartableBackend) int { - code := <-backend.exitCh - supervisor.shutdown("backend exit") - return code +// waitForAppExit blocks until the backend (or, in dev, any extra supervised +// child such as the Vite dev server) exits, then shuts the whole tree down +// and returns that child's exit code — a dead frontend must take the launcher +// down with it, matching the spec's "if either child exits" contract. Codes +// below zero mean the child was killed by a signal; treat those as 0 like the +// TypeScript launcher's `signal ? 0 : code`. +func waitForAppExit(supervisor *processSupervisor, backend *restartableBackend, extra ...*managedProcess) int { + if len(extra) == 0 { + code := <-backend.exitCh + supervisor.shutdown("backend exit") + return code + } + select { + case code := <-backend.exitCh: + supervisor.shutdown("backend exit") + return code + case <-extra[0].done: + _, code := extra[0].Exited() + supervisor.shutdown(extra[0].label + " exit") + if code < 0 { + return 0 + } + return code + } } func logForcedShutdownComplete(duration time.Duration, results []managedProcessShutdownResult) { diff --git a/apps/backend/internal/launcher/start.go b/apps/backend/internal/launcher/start.go index a061146661..4beef554dc 100644 --- a/apps/backend/internal/launcher/start.go +++ b/apps/backend/internal/launcher/start.go @@ -97,10 +97,18 @@ func runManagedApp(ctx context.Context, cfg managedAppConfig) int { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } - env := backendEnv(cfg.Ports, cfg.LogLevel, resolveConsoleLogLevel(cfg.Opts), cfg.Opts.Debug, healthToken) - backend, dumpLogs, err := launchBackendFn( - cfg.Backend, []string{"__backend"}, cfg.BackendCWD, env, false, cfg.Ports, cfg.Mode, supervisor, - ) + env := backendEnv(cfg.Ports, cfg.LogLevel, resolveConsoleLogLevel(cfg.Opts), cfg.Opts.Debug, healthToken, nil) + backend, dumpLogs, err := launchBackendFn(backendLaunchConfig{ + Command: cfg.Backend, + Args: []string{"__backend"}, + CWD: cfg.BackendCWD, + Env: env, + Quiet: false, + Ports: cfg.Ports, + Mode: cfg.Mode, + HomeDir: resolveHomeDir(), + Supervisor: supervisor, + }) if err != nil { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 @@ -142,7 +150,7 @@ func openBrowser(url string) { var cmd *exec.Cmd switch { case os.Getenv("OS") == "Windows_NT": - cmd = exec.Command("cmd.exe", "/c", "start", "", url) + cmd = exec.Command(cmdExe, "/c", "start", "", url) case runtime.GOOS == "darwin": cmd = exec.Command("open", url) default: diff --git a/apps/backend/internal/launcher/start_test.go b/apps/backend/internal/launcher/start_test.go index d2f79fd4b1..0a04bf0b93 100644 --- a/apps/backend/internal/launcher/start_test.go +++ b/apps/backend/internal/launcher/start_test.go @@ -42,7 +42,12 @@ func TestResolveLogThresholdsByMode(t *testing.T) { } func TestBackendEnvCarriesIndependentThresholds(t *testing.T) { - env := backendEnv(portConfig{BackendPort: 1234, AgentctlPort: 5678}, "debug", "warn", true, "health-token") + // The surrounding task environment may set dev-mode selectors. start/run + // inherit the ambient env untouched (matching the TypeScript process.env + // spread) but must never introduce their own selectors or a web URL. + t.Setenv("KANDEV_DEBUG_DEV_MODE", "true") + t.Setenv("KANDEV_WEB_INTERNAL_URL", "http://localhost:9999") + env := backendEnv(portConfig{BackendPort: 1234, AgentctlPort: 5678}, "debug", "warn", true, "health-token", nil) joined := strings.Join(env, "\n") for _, expected := range []string{ "KANDEV_LOG_LEVEL=debug", @@ -54,12 +59,37 @@ func TestBackendEnvCarriesIndependentThresholds(t *testing.T) { t.Fatalf("backend environment missing %q", expected) } } + // Inherited selectors pass through unchanged, but no web port is present + // so backendEnv must not synthesize a URL for it. + if !strings.Contains(joined, "KANDEV_WEB_INTERNAL_URL=http://localhost:9999") { + t.Fatalf("backend environment dropped an inherited selector:\n%s", joined) + } + if strings.Contains(joined, "KANDEV_WEB_INTERNAL_URL=http://localhost:5678") { + t.Fatal("backend environment introduced a web internal URL without a web port") + } +} + +func TestBackendEnvEmitsWebInternalURLForDevPorts(t *testing.T) { + env := backendEnv(portConfig{BackendPort: 1234, WebPort: 5678, AgentctlPort: 9876}, "info", "warn", false, "token", []string{"KANDEV_DEBUG_DEV_MODE=true", "KANDEV_HOME_DIR=/dev/home"}) + joined := strings.Join(env, "\n") + for _, expected := range []string{ + "KANDEV_WEB_INTERNAL_URL=http://localhost:5678", + "KANDEV_DEBUG_DEV_MODE=true", + "KANDEV_HOME_DIR=/dev/home", + } { + if !strings.Contains(joined, expected) { + t.Fatalf("backend environment missing %q", expected) + } + } + if strings.Contains(joined, "KANDEV_WEB_INTERNAL_URL=http://localhost:0") { + t.Fatalf("backend environment emitted an empty web internal URL:\n%s", joined) + } } func TestBackendEnvDebugDefaultsToDebugTitle(t *testing.T) { unsetEnvForTest(t, "KANDEV_WEB_TITLE_PREFIX") - env := backendEnv(portConfig{BackendPort: 1234, AgentctlPort: 5678}, "debug", "warn", true, "health-token") + env := backendEnv(portConfig{BackendPort: 1234, AgentctlPort: 5678}, "debug", "warn", true, "health-token", nil) if got := envValue(env, "KANDEV_WEB_TITLE_PREFIX"); got != "Debug" { t.Fatalf("KANDEV_WEB_TITLE_PREFIX = %q for debug launch; want %q", got, "Debug") } @@ -68,7 +98,7 @@ func TestBackendEnvDebugDefaultsToDebugTitle(t *testing.T) { func TestBackendEnvDebugPreservesExplicitTitle(t *testing.T) { t.Setenv("KANDEV_WEB_TITLE_PREFIX", "Custom") - env := backendEnv(portConfig{BackendPort: 1234, AgentctlPort: 5678}, "debug", "warn", true, "health-token") + env := backendEnv(portConfig{BackendPort: 1234, AgentctlPort: 5678}, "debug", "warn", true, "health-token", nil) if got := envValue(env, "KANDEV_WEB_TITLE_PREFIX"); got != "Custom" { t.Fatalf("KANDEV_WEB_TITLE_PREFIX = %q for explicit debug launch; want %q", got, "Custom") } @@ -149,23 +179,16 @@ func TestRunManagedAppAttachesSignalsBeforeBackendLaunch(t *testing.T) { var events []string var launchedQuiet bool var launchedHealthToken string + var launchedHomeDir string var waitedHealthToken string newSupervisorFn = func() *processSupervisor { events = append(events, "new-supervisor") return newSupervisor() } - launchBackendFn = func( - _ string, - _ []string, - _ string, - env []string, - quiet bool, - _ portConfig, - _ string, - _ *processSupervisor, - ) (*restartableBackend, func(), error) { - launchedQuiet = quiet - for _, item := range env { + launchBackendFn = func(cfg backendLaunchConfig) (*restartableBackend, func(), error) { + launchedQuiet = cfg.Quiet + launchedHomeDir = cfg.HomeDir + for _, item := range cfg.Env { if strings.HasPrefix(item, "KANDEV_DESKTOP_HEALTH_TOKEN=") { launchedHealthToken = strings.TrimPrefix(item, "KANDEV_DESKTOP_HEALTH_TOKEN=") } @@ -206,6 +229,9 @@ func TestRunManagedAppAttachesSignalsBeforeBackendLaunch(t *testing.T) { if launchedQuiet { t.Fatal("backend stdout was not forwarded") } + if launchedHomeDir != os.Getenv("KANDEV_HOME_DIR") { + t.Fatalf("supervisor home dir = %q, want the resolved home %q", launchedHomeDir, os.Getenv("KANDEV_HOME_DIR")) + } if launchedHealthToken == "" || launchedHealthToken != waitedHealthToken { t.Fatalf("health token launched=%q waited=%q, want one shared non-empty token", launchedHealthToken, waitedHealthToken) } diff --git a/apps/backend/internal/launcher/supervisor.go b/apps/backend/internal/launcher/supervisor.go index 9880d58a44..01f3a3a400 100644 --- a/apps/backend/internal/launcher/supervisor.go +++ b/apps/backend/internal/launcher/supervisor.go @@ -37,23 +37,40 @@ type restartableBackend struct { dumpLogs func() } -func launchRestartableBackend(command string, args []string, cwd string, env []string, quiet bool, ports portConfig, mode string, supervisor *processSupervisor) (*restartableBackend, func(), error) { - homeDir := resolveHomeDir() - supervisorEnv, socket, manifestPath, err := prepareSupervisorEnv(env, homeDir) +// backendLaunchConfig carries everything launchRestartableBackend needs. The +// supervisor home directory is an explicit field, not an environment read: +// dev roots it under /.kandev-dev/supervisor/ while start/run pass +// resolveHomeDir(), and resolving it internally would let an ambient +// KANDEV_HOME_DIR (the parent backend's, inside a Kandev task workspace) +// redirect the dev supervisor state. +type backendLaunchConfig struct { + Command string + Args []string + CWD string + Env []string + Quiet bool + Ports portConfig + Mode string + HomeDir string + Supervisor *processSupervisor +} + +func launchRestartableBackend(cfg backendLaunchConfig) (*restartableBackend, func(), error) { + supervisorEnv, socket, manifestPath, err := prepareSupervisorEnv(cfg.Env, cfg.HomeDir) if err != nil { return nil, nil, err } - manifest := buildManifest(command, args, cwd, supervisorEnv, homeDir, ports.BackendPort, mode) + manifest := buildManifest(cfg.Command, cfg.Args, cfg.CWD, supervisorEnv, cfg.HomeDir, cfg.Ports.BackendPort, cfg.Mode) if err := writeManifest(manifest, manifestPath); err != nil { return nil, nil, err } backend := &restartableBackend{ - command: command, - args: args, - cwd: cwd, + command: cfg.Command, + args: cfg.Args, + cwd: cfg.CWD, env: supervisorEnv, - quiet: quiet, - supervisor: supervisor, + quiet: cfg.Quiet, + supervisor: cfg.Supervisor, exitCh: make(chan int, 1), } if err := backend.start(); err != nil { diff --git a/apps/backend/internal/launcher/webchild.go b/apps/backend/internal/launcher/webchild.go new file mode 100644 index 0000000000..bb7682a59c --- /dev/null +++ b/apps/backend/internal/launcher/webchild.go @@ -0,0 +1,115 @@ +package launcher + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "runtime" + "time" +) + +// goosWindows is runtime.GOOS's value for Windows, hoisted so tests and the +// Windows shim branch share one spelling. cmdExe is the cmd.exe launcher used +// to run .cmd shims and to open URLs on Windows. +const ( + goosWindows = "windows" + cmdExe = "cmd.exe" +) + +// webCommand returns the command and args that start the Vite dev server. +// On Windows, pnpm installs as a .cmd shim that cannot be spawned directly +// (Node's spawn ignores PATHEXT, and direct .cmd spawning returns EINVAL on +// modern Node), so it goes through cmd.exe /c like the TypeScript launcher's +// resolveWindowsShim. The webCommandFor indirection lets tests exercise the +// Windows branch on any platform. +func webCommand() (string, []string) { + return webCommandFor(runtime.GOOS) +} + +func webCommandFor(goos string) (string, []string) { + args := []string{"-C", "apps", "--filter", "@kandev/web", "dev"} + if goos == goosWindows { + return cmdExe, append([]string{"/c", "pnpm"}, args...) + } + return "pnpm", args +} + +// buildWebEnv assembles the Vite dev-server environment. Browser traffic +// enters through the Go backend in every mode, so frontend code should use +// same-origin API/WS URLs: KANDEV_API_BASE_URL points the web app at the +// backend port. +// +// Any inherited VITE_KANDEV_API_PORT is explicitly removed so a host-level +// variable (from a .env file, Docker env, or CI) cannot leak through and +// reintroduce cross-origin browser API calls. +func buildWebEnv(ports portConfig, debug bool) ([]string, error) { + if ports.WebPort == 0 { + return nil, fmt.Errorf("web env requires a web port") + } + env := os.Environ() + env = upsertEnv(env, "KANDEV_API_BASE_URL", ports.BackendURL) + env = upsertEnv(env, "PORT", fmt.Sprint(ports.WebPort)) + env = upsertEnv(env, "HOSTNAME", "127.0.0.1") + env = removeEnv(env, "VITE_KANDEV_API_PORT") + if debug { + env = upsertEnv(env, "KANDEV_DEBUG", "true") + env = upsertEnv(env, "VITE_KANDEV_DEBUG", "true") + } + return env, nil +} + +// removeEnv drops every item with the given key from the environment slice. +func removeEnv(env []string, key string) []string { + prefix := key + "=" + out := env[:0] + for _, item := range env { + if len(item) >= len(prefix) && item[:len(prefix)] == prefix { + continue + } + out = append(out, item) + } + return out +} + +// waitForURLReady polls url until any HTTP response arrives. Unlike +// waitForHealth it requires no launcher health token: the Vite dev server is +// not a Kandev process and does not emit one. Any HTTP status counts as +// ready; a connection error just keeps polling. If the child exits first the +// wait fails fast with the child's exit code. +func waitForURLReady(ctx context.Context, url string, proc childState, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for ctx.Err() == nil { + if exited, code := proc.Exited(); exited { + return fmt.Errorf("web process exited (code %d) before URL became reachable", code) + } + if urlResponds(ctx, url) { + return nil + } + select { + case <-ctx.Done(): + case <-time.After(healthPollInterval): + } + } + return fmt.Errorf("web URL readiness timed out after %s at %s", timeout, url) +} + +// urlResponds reports whether a single GET to url produces any HTTP response. +// The body is drained and closed so the connection can be reused by the next +// poll. +func urlResponds(ctx context.Context, url string) bool { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false + } + resp, err := healthProbeClient.Do(req) + if err != nil { + return false + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, resp.Body) + return true +} diff --git a/apps/backend/internal/launcher/webchild_test.go b/apps/backend/internal/launcher/webchild_test.go new file mode 100644 index 0000000000..b373b2ba0b --- /dev/null +++ b/apps/backend/internal/launcher/webchild_test.go @@ -0,0 +1,128 @@ +package launcher + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestWebCommandUnix(t *testing.T) { + cmd, args := webCommandFor("linux") + if cmd != "pnpm" { + t.Fatalf("command = %q, want pnpm", cmd) + } + want := []string{"-C", "apps", "--filter", "@kandev/web", "dev"} + if len(args) != len(want) { + t.Fatalf("args = %v, want %v", args, want) + } + for i := range want { + if args[i] != want[i] { + t.Fatalf("args = %v, want %v", args, want) + } + } +} + +func TestWebCommandWindowsUsesCmdExeShim(t *testing.T) { + cmd, args := webCommandFor("windows") + if cmd != "cmd.exe" { + t.Fatalf("command = %q, want cmd.exe", cmd) + } + want := []string{"/c", "pnpm", "-C", "apps", "--filter", "@kandev/web", "dev"} + if len(args) != len(want) { + t.Fatalf("args = %v, want %v", args, want) + } + for i := range want { + if args[i] != want[i] { + t.Fatalf("args = %v, want %v", args, want) + } + } +} + +func TestBuildWebEnvSetsDevServerConfiguration(t *testing.T) { + t.Setenv("VITE_KANDEV_API_PORT", "9999") + t.Setenv("PORT", "8080") + + env, err := buildWebEnv(portConfig{ + BackendPort: 38429, + WebPort: 37429, + BackendURL: "http://localhost:38429", + }, true) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(env, "\n") + for _, expected := range []string{ + "KANDEV_API_BASE_URL=http://localhost:38429", + "PORT=37429", + "HOSTNAME=127.0.0.1", + "KANDEV_DEBUG=true", + "VITE_KANDEV_DEBUG=true", + } { + if !strings.Contains(joined, expected) { + t.Fatalf("web environment missing %q", expected) + } + } + if strings.Contains(joined, "VITE_KANDEV_API_PORT") { + t.Fatalf("web environment leaked inherited VITE_KANDEV_API_PORT:\n%s", joined) + } + if strings.Contains(joined, "PORT=8080\n") || !strings.Contains(joined, "PORT=37429") { + t.Fatalf("web environment did not override inherited PORT:\n%s", joined) + } +} + +func TestBuildWebEnvRequiresWebPort(t *testing.T) { + if _, err := buildWebEnv(portConfig{BackendPort: 38429, BackendURL: "http://localhost:38429"}, false); err == nil { + t.Fatal("buildWebEnv() = nil error without a web port") + } +} + +func TestWaitForURLReadyAcceptsAnyHTTPResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("vite fallback")) + })) + t.Cleanup(srv.Close) + + err := waitForURLReady(context.Background(), srv.URL, fakeChild{}, 5*time.Second) + if err != nil { + t.Fatalf("waitForURLReady() = %v, want nil for any HTTP response", err) + } +} + +func TestWaitForURLReadyTimesOutWithURLNamed(t *testing.T) { + err := waitForURLReady(context.Background(), "http://127.0.0.1:1", fakeChild{}, 300*time.Millisecond) + if err == nil { + t.Fatal("waitForURLReady() = nil, want timeout error") + } + if !strings.Contains(err.Error(), "timed out") || !strings.Contains(err.Error(), "http://127.0.0.1:1") { + t.Fatalf("error = %v, want a timeout naming the URL", err) + } +} + +func TestWaitForURLReadyFailsFastWhenChildExited(t *testing.T) { + err := waitForURLReady( + context.Background(), + "http://127.0.0.1:1", + fakeChild{exited: true, code: 7}, + time.Minute, + ) + if err == nil { + t.Fatal("waitForURLReady() = nil, want exit error") + } + if !strings.Contains(err.Error(), "code 7") { + t.Fatalf("error = %v, want the child exit code", err) + } +} + +func TestWaitForURLReadyHonorsContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := waitForURLReady(ctx, "http://127.0.0.1:1", fakeChild{}, time.Minute) + if err == nil { + t.Fatal("waitForURLReady() = nil, want cancellation error") + } +} diff --git a/apps/cli/README_internal.md b/apps/cli/README_internal.md index c1ac77db42..e4dccc78fb 100644 --- a/apps/cli/README_internal.md +++ b/apps/cli/README_internal.md @@ -66,15 +66,14 @@ For desktop release builds, `scripts/release/prepare-desktop-runtime.sh` extract ## CLI Options -| Option | Description | -| -------------------------- | ---------------------------------------------------------- | -| `--version`, `-V` | Print CLI version and exit | -| `--port`, `--backend-port` | Backend port | -| `--web-internal-port` | Override internal Vite dev web port | -| `--verbose`, `-v` | Show info logs | -| `--debug` | Show debug logs + agent message dumps | -| `--runtime-version ` | **Advanced/debug**: download a specific GitHub runtime tag | -| `--help`, `-h` | Show help | +| Option | Description | +| -------------------------- | --------------------------------------------------- | +| `--version`, `-V` | Print CLI version and exit | +| `--port`, `--backend-port` | Backend port | +| `--web-internal-port` | Override internal Vite dev web port (dev mode only) | +| `--verbose`, `-v` | Show info logs | +| `--debug` | Show debug logs + agent message dumps | +| `--help`, `-h` | Show help | ## Updates (package-manager owned) @@ -107,14 +106,7 @@ If the runtime package or bundle is missing, the shim prints an actionable runti ## Local Development -```bash -# Run CLI in dev mode (uses tsx) -pnpm -C apps/cli dev - -# Run with arguments -pnpm -C apps/cli dev -- start -pnpm -C apps/cli dev -- --port 9000 -``` +This package is a publish-only shim; there is no TypeScript source anymore. The native Go launcher owns every launch mode (`dev`, `start`, `run`, `service`), so `make dev` and friends exec `apps/backend/bin/kandev` directly. ## Release @@ -156,7 +148,7 @@ Versioning: | Windows (x64) | `@kdlbs/runtime-win32-x64` | `kandev-windows-x64.tar.gz` | | Windows (ARM64) | Falls back to `windows-x64` (x64 emulation) | | -Note: `platform.ts` uses internal naming (`macos`, `windows`); npm `os` field uses npm conventions (`darwin`, `win32`). `runtime.ts` maps between the two. +Note: the shim's platform naming (`macos`, `windows` internally) differs from the npm `os` field conventions (`darwin`, `win32`); `platformPackage()` in `native-shim.js` maps between the two. ## npm requirements diff --git a/apps/cli/bin/native-shim.test.mjs b/apps/cli/bin/native-shim.test.mjs index 13b61c13fd..d91b53f670 100644 --- a/apps/cli/bin/native-shim.test.mjs +++ b/apps/cli/bin/native-shim.test.mjs @@ -1,7 +1,8 @@ +import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, it } from "node:test"; import shim from "./native-shim.js"; @@ -25,14 +26,14 @@ describe("native npm shim", () => { }); it("maps supported platforms to runtime packages", () => { - expect(platformPackage("linux", "x64")).toBe("@kdlbs/runtime-linux-x64"); - expect(platformPackage("darwin", "arm64")).toBe("@kdlbs/runtime-darwin-arm64"); - expect(platformPackage("win32", "x64")).toBe("@kdlbs/runtime-win32-x64"); + assert.equal(platformPackage("linux", "x64"), "@kdlbs/runtime-linux-x64"); + assert.equal(platformPackage("darwin", "arm64"), "@kdlbs/runtime-darwin-arm64"); + assert.equal(platformPackage("win32", "x64"), "@kdlbs/runtime-win32-x64"); }); it("uses exe suffix on Windows only", () => { - expect(binaryName("kandev", "win32")).toBe("kandev.exe"); - expect(binaryName("kandev", "linux")).toBe("kandev"); + assert.equal(binaryName("kandev", "win32"), "kandev.exe"); + assert.equal(binaryName("kandev", "linux"), "kandev"); }); it("resolves KANDEV_BUNDLE_DIR before npm packages", () => { @@ -42,8 +43,8 @@ describe("native npm shim", () => { throw new Error("should not resolve npm package"); }); - expect(runtime.bundleDir).toBe(tmpDir); - expect(runtime.executable).toBe(path.join(tmpDir, "bin", "kandev")); + assert.equal(runtime.bundleDir, tmpDir); + assert.equal(runtime.executable, path.join(tmpDir, "bin", "kandev")); }); it("resolves the installed runtime package", () => { @@ -53,13 +54,13 @@ describe("native npm shim", () => { const runtime = resolveRuntime({}, () => pkgJSON); - expect(runtime.bundleDir).toBe(tmpDir); + assert.equal(runtime.bundleDir, tmpDir); }); it("rejects bundles without the native kandev binary", () => { fs.mkdirSync(path.join(tmpDir, "bin"), { recursive: true }); fs.writeFileSync(path.join(tmpDir, "bin", "agentctl"), "fake"); - expect(() => validateRuntime(tmpDir)).toThrow(/Kandev native binary not found/); + assert.throws(() => validateRuntime(tmpDir), /Kandev native binary not found/); }); }); diff --git a/apps/cli/package.json b/apps/cli/package.json index 3f7a277d92..22247c8222 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -28,21 +28,9 @@ "@kdlbs/runtime-darwin-arm64": "0.0.0-bootstrap", "@kdlbs/runtime-win32-x64": "0.0.0-bootstrap" }, - "devDependencies": { - "@types/node": "^20", - "esbuild": "^0.27.0", - "tar": "^7.5.19", - "tree-kill": "^1.2.2", - "tsx": "^4.15.7", - "typescript": "^5", - "vitest": "^4.1.10" - }, "scripts": { - "dev": "tsx src/cli.ts", "build": "node -c bin/cli.js && node -c bin/native-shim.js", - "bundle": "pnpm build", - "start": "node bin/cli.js", - "test": "vitest run", + "test": "node --test \"bin/*.test.mjs\"", "prepublishOnly": "pnpm build" } } diff --git a/apps/cli/src/args.test.ts b/apps/cli/src/args.test.ts deleted file mode 100644 index aae0dce8df..0000000000 --- a/apps/cli/src/args.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseArgs, ParseError, resolvePorts } from "./args"; - -describe("parseArgs", () => { - it("defaults to the run command with no args", () => { - const { options, showHelp } = parseArgs([]); - expect(options.command).toBe("run"); - expect(showHelp).toBe(false); - }); - - it("parses --port and --port= as the backend port", () => { - expect(parseArgs(["--port", "3000"]).options.backendPort).toBe(3000); - expect(parseArgs(["--port=3000"]).options.backendPort).toBe(3000); - }); - - it("parses --backend-port the same as --port (no deprecation note)", () => { - expect(parseArgs(["--backend-port=3447"]).options.backendPort).toBe(3447); - }); - - it("parses --web-internal-port for dev mode", () => { - const r = parseArgs(["dev", "--web-internal-port", "12345"]); - expect(r.options.webPort).toBe(12345); - }); - - it("rejects --web-internal-port outside dev mode", () => { - expect(() => parseArgs(["--web-internal-port", "12345"])).toThrow( - /--web-internal-port only applies to dev mode/, - ); - }); - - it("rejects removed --web-port", () => { - expect(() => parseArgs(["--web-port=8080"])).toThrow(/--web-port has been removed/); - }); - - it("reports --help via showHelp without exiting", () => { - expect(parseArgs(["--help"]).showHelp).toBe(true); - }); - - it("throws ParseError when a value-taking flag has no value", () => { - expect(() => parseArgs(["--port"])).toThrow(ParseError); - expect(() => parseArgs(["--port"])).toThrow(/--port requires a value/); - }); - - it("throws ParseError when the next token is another flag", () => { - expect(() => parseArgs(["--port", "--debug"])).toThrow(/--port requires a value/); - }); - - it("throws ParseError on a non-numeric port value", () => { - expect(() => parseArgs(["--port=abc"])).toThrow(/--port value must be an integer/); - }); - - it("throws ParseError on a non-integer port value", () => { - expect(() => parseArgs(["--port=3000.5"])).toThrow(/--port value must be an integer/); - }); - - it.each(["0", "-1", "65536"])("throws ParseError on out-of-range port %s", (val) => { - expect(() => parseArgs([`--port=${val}`])).toThrow(/--port value must be an integer between/); - }); - - it("throws ParseError on empty --port=", () => { - expect(() => parseArgs(["--port="])).toThrow(/--port value must be an integer/); - }); - - it("sets showVersion for --version and -V flags", () => { - expect(parseArgs(["--version"]).options.showVersion).toBe(true); - expect(parseArgs(["-V"]).options.showVersion).toBe(true); - }); - - it("parses --headless and --no-browser as headless", () => { - expect(parseArgs(["--headless"]).options.headless).toBe(true); - expect(parseArgs(["--no-browser"]).options.headless).toBe(true); - expect(parseArgs([]).options.headless).toBeUndefined(); - }); - - it("parses --runtime-version as runtimeVersion", () => { - expect(parseArgs(["--runtime-version", "v0.16.0"]).options.runtimeVersion).toBe("v0.16.0"); - expect(parseArgs(["--runtime-version=v0.16.0"]).options.runtimeVersion).toBe("v0.16.0"); - }); - - it("throws ParseError on empty --runtime-version=", () => { - expect(() => parseArgs(["--runtime-version="])).toThrow(/--runtime-version requires a value/); - }); - - it("throws ParseError when --runtime-version has no value", () => { - expect(() => parseArgs(["--runtime-version"])).toThrow(ParseError); - expect(() => parseArgs(["--runtime-version"])).toThrow(/--runtime-version requires a value/); - }); - - it("throws ParseError when the next token after --runtime-version is another flag", () => { - expect(() => parseArgs(["--runtime-version", "--debug"])).toThrow( - /--runtime-version requires a value/, - ); - }); -}); - -describe("resolvePorts", () => { - it("returns undefined for both ports when nothing is set", () => { - const r = resolvePorts({ command: "run" }, {} as NodeJS.ProcessEnv); - expect(r).toEqual({ backendPort: undefined, webPort: undefined }); - }); - - it("CLI backendPort wins over env vars", () => { - const r = resolvePorts({ command: "start", backendPort: 3000 }, { - KANDEV_BACKEND_PORT: "4000", - KANDEV_PORT: "5000", - } as NodeJS.ProcessEnv); - expect(r.backendPort).toBe(3000); - expect(r.backendPortSource).toBe("--port"); - }); - - it("KANDEV_BACKEND_PORT wins over KANDEV_PORT (more specific env wins)", () => { - const r = resolvePorts({ command: "run" }, { - KANDEV_PORT: "5555", - KANDEV_BACKEND_PORT: "6666", - } as NodeJS.ProcessEnv); - expect(r.backendPort).toBe(6666); - expect(r.backendPortSource).toBe("KANDEV_BACKEND_PORT"); - }); - - it("falls back to KANDEV_PORT when KANDEV_BACKEND_PORT is not set", () => { - const r = resolvePorts({ command: "run" }, { KANDEV_PORT: "5555" } as NodeJS.ProcessEnv); - expect(r.backendPort).toBe(5555); - expect(r.backendPortSource).toBe("KANDEV_PORT"); - }); - - it("KANDEV_WEB_PORT sets the internal web port in dev", () => { - const r = resolvePorts({ command: "dev" }, { KANDEV_WEB_PORT: "8080" } as NodeJS.ProcessEnv); - expect(r.webPort).toBe(8080); - }); - - it("KANDEV_WEB_PORT is ignored outside dev", () => { - const r = resolvePorts({ command: "run" }, { KANDEV_WEB_PORT: "8080" } as NodeJS.ProcessEnv); - expect(r).toEqual({ backendPort: undefined, webPort: undefined }); - }); - - it("--port maps to backend in every command (including dev)", () => { - const r = resolvePorts({ command: "dev", backendPort: 3447 }, {} as NodeJS.ProcessEnv); - expect(r).toEqual({ - backendPort: 3447, - backendPortSource: "--port", - webPort: undefined, - }); - }); - - it("throws ParseError when KANDEV_PORT is not a number", () => { - expect(() => - resolvePorts({ command: "run" }, { KANDEV_PORT: "abc" } as NodeJS.ProcessEnv), - ).toThrow(ParseError); - }); - - it("throws ParseError when KANDEV_PORT is a float", () => { - expect(() => - resolvePorts({ command: "run" }, { KANDEV_PORT: "3000.5" } as NodeJS.ProcessEnv), - ).toThrow(/KANDEV_PORT must be an integer/); - }); - - it.each(["0", "-1", "65536"])("throws ParseError when KANDEV_PORT is out-of-range %s", (val) => { - expect(() => - resolvePorts({ command: "run" }, { KANDEV_PORT: val } as NodeJS.ProcessEnv), - ).toThrow(/KANDEV_PORT must be an integer between/); - }); - - it.each(["KANDEV_PORT", "KANDEV_BACKEND_PORT"])( - "throws ParseError when %s is set to empty string", - (name) => { - expect(() => resolvePorts({ command: "run" }, { [name]: "" } as NodeJS.ProcessEnv)).toThrow( - new RegExp(`${name} must be an integer`), - ); - }, - ); - - it("throws ParseError when KANDEV_WEB_PORT is empty in dev", () => { - expect(() => - resolvePorts({ command: "dev" }, { KANDEV_WEB_PORT: "" } as NodeJS.ProcessEnv), - ).toThrow(/KANDEV_WEB_PORT must be an integer/); - }); -}); diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts deleted file mode 100644 index 9c499976d2..0000000000 --- a/apps/cli/src/args.ts +++ /dev/null @@ -1,159 +0,0 @@ -export type Command = "run" | "dev" | "start"; - -export type BackendPortSource = "--port" | "KANDEV_BACKEND_PORT" | "KANDEV_PORT"; - -export type CliOptions = { - command: Command; - runtimeVersion?: string; - backendPort?: number; - webPort?: number; - verbose?: boolean; - debug?: boolean; - showVersion?: boolean; - /** Skip browser open + interactive prompts. Set by systemd/launchd units. */ - headless?: boolean; -}; - -export type ParseResult = { - options: CliOptions; - showHelp: boolean; -}; - -export class ParseError extends Error {} - -export function parseArgs(argv: string[]): ParseResult { - const opts: CliOptions = { command: "run" }; - let showHelp = false; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === "--help" || arg === "-h") { - showHelp = true; - continue; - } - if (arg === "--version" || arg === "-V") { - opts.showVersion = true; - continue; - } - if (arg === "dev" || arg === "run" || arg === "start") { - opts.command = arg; - continue; - } - if (arg === "--runtime-version") { - opts.runtimeVersion = takeValue(argv, i, "--runtime-version"); - i += 1; - continue; - } - if (arg.startsWith("--runtime-version=")) { - const value = arg.slice("--runtime-version=".length); - if (value.length === 0) throw new ParseError("--runtime-version requires a value"); - opts.runtimeVersion = value; - continue; - } - if (arg === "--dev") { - opts.command = "dev"; - continue; - } - // --port is an alias for --backend-port (the user-facing port in run/start). - if (arg === "--port" || arg === "--backend-port") { - opts.backendPort = parsePort(takeValue(argv, i, arg), arg); - i += 1; - continue; - } - if (arg.startsWith("--port=") || arg.startsWith("--backend-port=")) { - const flag = arg.startsWith("--port=") ? "--port" : "--backend-port"; - opts.backendPort = parsePort(arg.slice(flag.length + 1), flag); - continue; - } - if (arg === "--web-internal-port") { - opts.webPort = parsePort(takeValue(argv, i, "--web-internal-port"), "--web-internal-port"); - i += 1; - continue; - } - if (arg.startsWith("--web-internal-port=")) { - opts.webPort = parsePort(arg.slice("--web-internal-port=".length), "--web-internal-port"); - continue; - } - if (arg === "--web-port" || arg.startsWith("--web-port=")) { - throw new ParseError("--web-port has been removed; use --web-internal-port for dev mode"); - } - if (arg === "--verbose" || arg === "-v") { - opts.verbose = true; - continue; - } - if (arg === "--debug") { - opts.debug = true; - continue; - } - if (arg === "--headless" || arg === "--no-browser") { - opts.headless = true; - continue; - } - } - if (opts.webPort !== undefined && opts.command !== "dev") { - throw new ParseError("--web-internal-port only applies to dev mode"); - } - return { options: opts, showHelp }; -} - -function takeValue(argv: string[], i: number, flag: string): string { - const v = argv[i + 1]; - if (v === undefined || v.startsWith("-")) { - throw new ParseError(`${flag} requires a value`); - } - return v; -} - -function parsePort(raw: string, flag: string): number { - const n = Number(raw); - if (raw === "" || !Number.isInteger(n) || n < 1 || n > 65535) { - throw new ParseError(`${flag} value must be an integer between 1 and 65535, got "${raw}"`); - } - return n; -} - -export type ResolvedPorts = { - backendPort?: number; - backendPortSource?: BackendPortSource; - webPort?: number; -}; - -// CLI flags beat env vars; KANDEV_PORT is an alias for KANDEV_BACKEND_PORT. -export function resolvePorts(options: CliOptions, env: NodeJS.ProcessEnv): ResolvedPorts { - if (options.webPort !== undefined && options.command !== "dev") { - throw new ParseError("--web-internal-port only applies to dev mode"); - } - - let backendPort: number | undefined; - let backendPortSource: BackendPortSource | undefined; - if (options.backendPort !== undefined) { - backendPort = options.backendPort; - backendPortSource = "--port"; - } else { - backendPort = envPort(env, "KANDEV_BACKEND_PORT"); - if (backendPort !== undefined) { - backendPortSource = "KANDEV_BACKEND_PORT"; - } else { - backendPort = envPort(env, "KANDEV_PORT"); - if (backendPort !== undefined) { - backendPortSource = "KANDEV_PORT"; - } - } - } - - return { - backendPort, - ...(backendPortSource ? { backendPortSource } : {}), - webPort: - options.command === "dev" ? (options.webPort ?? envPort(env, "KANDEV_WEB_PORT")) : undefined, - }; -} - -function envPort(env: NodeJS.ProcessEnv, name: string): number | undefined { - const val = env[name]; - if (val === undefined) return undefined; - const n = Number(val); - if (val === "" || !Number.isInteger(n) || n < 1 || n > 65535) { - throw new ParseError(`${name} must be an integer between 1 and 65535, got "${val}"`); - } - return n; -} diff --git a/apps/cli/src/backup.test.ts b/apps/cli/src/backup.test.ts deleted file mode 100644 index f91b80b912..0000000000 --- a/apps/cli/src/backup.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { backupProductionDb, isProductionDb } from "./backup"; - -describe("isProductionDb", () => { - it("returns false for dev-isolated paths under .kandev-dev", () => { - expect(isProductionDb("/repo/.kandev-dev/data/kandev.db")).toBe(false); - expect(isProductionDb("/.kandev-dev/kandev.db")).toBe(false); - }); - - it("returns true for production and custom paths", () => { - expect(isProductionDb("/home/user/.kandev/data/kandev.db")).toBe(true); - expect(isProductionDb("/tmp/custom.db")).toBe(true); - expect(isProductionDb("/data/kandev.db")).toBe(true); - }); -}); - -describe("backupProductionDb", () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-backup-test-")); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it("returns null when the db does not exist", () => { - const result = backupProductionDb(path.join(tmpDir, "missing.db")); - expect(result).toBeNull(); - }); - - it("creates a backup and keeps only the 5 newest", () => { - const dbPath = path.join(tmpDir, ".kandev", "data", "kandev.db"); - fs.mkdirSync(path.dirname(dbPath), { recursive: true }); - fs.writeFileSync(dbPath, "original-db-content"); - - // Create 6 backups with explicit per-iteration timestamps so both the - // filename clock and mtime advance deterministically - no spin loop, no - // dependence on Date.now() resolution. - for (let i = 0; i < 6; i++) { - const stamp = new Date(1_700_000_000_000 + i * 1000); - const result = backupProductionDb(dbPath, tmpDir, stamp); - expect(result).not.toBeNull(); - } - - const backupDir = path.join(tmpDir, ".kandev", "data", "backups"); - const files = fs - .readdirSync(backupDir) - .filter((f) => f.startsWith("dev-prod-db-") && f.endsWith(".db")) - .sort(); - - expect(files.length).toBe(5); - }); -}); diff --git a/apps/cli/src/backup.ts b/apps/cli/src/backup.ts deleted file mode 100644 index f77aeb20fd..0000000000 --- a/apps/cli/src/backup.ts +++ /dev/null @@ -1,101 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -// Prefix for dev-prod-db automatic snapshots. Distinct from the backend's -// "kandev-*" auto-snapshots and "manual-*" user snapshots so the families -// don't interfere with each other's retention policies. -const BACKUP_PREFIX = "dev-prod-db-"; -const BACKUP_SUFFIX = ".db"; -const MAX_BACKUPS = 5; - -/** - * Returns true if the given dbPath points to a non-dev database that should - * be backed up before running dev mode. Dev-isolated databases live under - * /.kandev-dev/ and are considered disposable. - */ -export function isProductionDb(dbPath: string): boolean { - const normalized = path.normalize(dbPath); - const segments = normalized.split(path.sep).filter(Boolean); - return !segments.includes(".kandev-dev"); -} - -/** - * Backs up the database at dbPath to /backups/ before dev mode - * runs against it. Keeps only the newest MAX_BACKUPS snapshots. - * - * Returns the path of the created backup, or null if the DB didn't exist - * or no backup was needed. - * - * The optional `homeDir` parameter is exposed for tests so they can redirect - * the backup location without mocking os.homedir(). The optional `now` - * parameter lets tests pass an explicit timestamp so back-to-back calls in - * the same millisecond produce distinct filenames + mtimes without sleeping. - */ -export function backupProductionDb(dbPath: string, homeDir?: string, now?: Date): string | null { - if (!fs.existsSync(dbPath)) { - return null; - } - - const root = homeDir ?? os.homedir(); - // Backups are always placed under ~/.kandev/data/backups/ (or the - // caller-supplied homeDir in tests), even when dbPath points elsewhere. - // This matches the dev-prod-db default flow; custom KANDEV_DATABASE_PATH - // values are advanced usage where the user is responsible for backup location. - const dataDir = path.join(root, ".kandev", "data"); - const backupDir = path.join(dataDir, "backups"); - fs.mkdirSync(backupDir, { recursive: true }); - - const stamp = now ?? new Date(); - const ts = stamp.toISOString().replace(/[:.]/g, ""); - const name = `${BACKUP_PREFIX}${ts}${BACKUP_SUFFIX}`; - const dest = path.join(backupDir, name); - - fs.copyFileSync(dbPath, dest); - // Stamp both atime + mtime so pruning (which sorts by mtime) is deterministic. - fs.utimesSync(dest, stamp, stamp); - - pruneBackups(backupDir, MAX_BACKUPS); - - return dest; -} - -/** - * Keeps only the `keep` newest dev-prod-db backup files in `dir`, deleting - * older ones. Non-matching files are left untouched. - */ -function pruneBackups(dir: string, keep: number): void { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - - const files = entries - .filter((e) => e.isFile() && e.name.startsWith(BACKUP_PREFIX) && e.name.endsWith(BACKUP_SUFFIX)) - .map((e) => { - const fullPath = path.join(dir, e.name); - try { - const stat = fs.statSync(fullPath); - return { path: fullPath, mtime: stat.mtime }; - } catch { - return null; - } - }) - .filter((f): f is { path: string; mtime: Date } => f !== null); - - if (files.length <= keep) { - return; - } - - files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); - - for (const f of files.slice(keep)) { - try { - fs.unlinkSync(f.path); - } catch { - // Non-critical: don't fail the launch if one old backup can't be removed. - } - } -} diff --git a/apps/cli/src/bundle.test.ts b/apps/cli/src/bundle.test.ts deleted file mode 100644 index 5d1b97a625..0000000000 --- a/apps/cli/src/bundle.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { findBundleRoot } from "./bundle"; - -describe("findBundleRoot", () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-bundle-test-")); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it("returns kandev/ subdir when it exists", () => { - const kandevDir = path.join(tmpDir, "kandev"); - fs.mkdirSync(kandevDir); - expect(findBundleRoot(tmpDir)).toBe(kandevDir); - }); - - it("returns cacheDir itself when kandev/ does not exist", () => { - expect(findBundleRoot(tmpDir)).toBe(tmpDir); - }); -}); diff --git a/apps/cli/src/bundle.ts b/apps/cli/src/bundle.ts deleted file mode 100644 index fc736d670b..0000000000 --- a/apps/cli/src/bundle.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as tar from "tar"; -import fs from "node:fs"; -import path from "node:path"; - -export function extractTarGz(archivePath: string, destDir: string): void { - tar.extract({ file: archivePath, cwd: destDir, sync: true }); -} - -export function ensureExtracted(archivePath: string, destDir: string): void { - const marker = path.join(destDir, ".extracted"); - if (fs.existsSync(marker)) { - return; - } - extractTarGz(archivePath, destDir); - fs.writeFileSync(marker, ""); -} - -export function findBundleRoot(cacheDir: string): string { - const candidate = path.join(cacheDir, "kandev"); - if (fs.existsSync(candidate)) { - return candidate; - } - return cacheDir; -} diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts deleted file mode 100644 index 4a82a92920..0000000000 --- a/apps/cli/src/cli.ts +++ /dev/null @@ -1,165 +0,0 @@ -import path from "node:path"; -import fs from "node:fs"; - -import pkg from "../package.json"; -import { parseArgs, ParseError, resolvePorts } from "./args"; -import { runDev } from "./dev"; -import { runRelease } from "./run"; -import { runServiceCommand } from "./service"; -import { warnIfStaleServiceUnit } from "./service/stale_check"; -import { runStart } from "./start"; -import { ensureValidPort } from "./ports"; - -function printHelp() { - console.log(`kandev launcher - -Usage: - kandev run [--port ] [--verbose] [--debug] - kandev dev [--port ] - kandev start [--port ] [--verbose] [--debug] - kandev [--port ] [--verbose] [--debug] - kandev --dev [--port ] - kandev service install|uninstall|start|stop|restart|status|logs [--system] - -Examples: - kandev - kandev run - kandev --dev - kandev dev - kandev start - kandev --version - kandev --port 3000 - kandev --debug - -Options: - dev Use local repo for dev (Go backend + Vite dev server). - start Use local production build (Go backend serves Vite dist). - run Use installed runtime bundle (default). - service Manage kandev as an OS service (systemd / launchd). - Run 'kandev service --help' for details. - --dev Alias for "dev". - --version, -V Print CLI version and exit. - --port Port for the Go backend (the URL kandev opens on in - start/run). Alias for --backend-port. Also reads - KANDEV_PORT or KANDEV_BACKEND_PORT. - --verbose, -v Show backend info logs on stdout (also retained in its file). - --debug Retain backend debug logs in its file + agent message dumps. - --headless Skip opening the browser. Used by service units. - --help, -h Show help. - -Advanced: - --backend-port Same as --port. - --web-internal-port Override the internal dev web port. The Go backend - reverse-proxies to it in dev; start/run serve from Go. - Also reads KANDEV_WEB_PORT. - --runtime-version Download and use a specific release tag instead of - the installed runtime. For debugging only. - Example: kandev --runtime-version v0.16.0 -`); -} - -function findRepoRoot(startDir: string): string | null { - let current = path.resolve(startDir); - while (true) { - if (current.endsWith(`${path.sep}apps`)) { - const backendInApps = path.join(current, "backend"); - const webInApps = path.join(current, "web"); - if (fs.existsSync(backendInApps) && fs.existsSync(webInApps)) { - return path.dirname(current); - } - } - const backendDir = path.join(current, "apps", "backend"); - const webDir = path.join(current, "apps", "web"); - if (fs.existsSync(backendDir) && fs.existsSync(webDir)) { - return current; - } - const parent = path.dirname(current); - if (parent === current) { - return null; - } - current = parent; - } -} - -async function main(): Promise { - // The `service` subcommand has its own argv parser (own subcommands, flags). - // Handle it before the top-level flag parser to keep the two parsers isolated. - if (process.argv[2] === "service") { - await runServiceCommand(process.argv.slice(3)); - return; - } - - const { options, showHelp } = parseArgs(process.argv.slice(2)); - - if (options.showVersion) { - console.log(pkg.version); - return; - } - - if (showHelp) { - printHelp(); - return; - } - - const resolved = resolvePorts(options, process.env); - const backendPort = ensureValidPort(resolved.backendPort, "backend port"); - const webPort = - options.command === "dev" ? ensureValidPort(resolved.webPort, "web port") : undefined; - - // After an npm/brew upgrade, paths baked into an installed service unit may - // be stale. Warn once before launch so the user knows to re-run install. - // Skip when invoked from a service unit (--headless) — the warning would - // land in journalctl/launchd logs and pile up on every restart. - if (!options.headless) { - warnIfStaleServiceUnit(); - } - - if (options.command === "dev") { - const repoRoot = findRepoRoot(process.cwd()); - if (!repoRoot) { - throw new Error("Unable to locate repo root for dev. Run from the repo."); - } - await runDev({ - repoRoot, - backendPort, - backendPortSource: resolved.backendPortSource, - webPort, - }); - return; - } - - if (options.command === "start") { - const repoRoot = findRepoRoot(process.cwd()); - if (!repoRoot) { - throw new Error("Unable to locate repo root for start. Run from the repo."); - } - await runStart({ - repoRoot, - backendPort, - backendPortSource: resolved.backendPortSource, - verbose: options.verbose, - debug: options.debug, - headless: options.headless, - }); - return; - } - - await runRelease({ - runtimeVersion: options.runtimeVersion, - backendPort, - backendPortSource: resolved.backendPortSource, - verbose: options.verbose, - debug: options.debug, - headless: options.headless, - }); -} - -main().catch((err) => { - if (err instanceof ParseError) { - console.error(`[kandev] ${err.message}`); - console.error("[kandev] run --help for usage"); - process.exit(2); - } - console.error(`[kandev] ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); -}); diff --git a/apps/cli/src/constants.test.ts b/apps/cli/src/constants.test.ts deleted file mode 100644 index 7df977abbd..0000000000 --- a/apps/cli/src/constants.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -import { - KANDEV_HOME_DIR, - resolveCacheDir, - resolveDataDir, - resolveDatabasePath, - resolveKandevHomeDir, -} from "./constants"; - -describe("runtime data paths", () => { - it("defaults to the user's kandev home", () => { - expect(resolveKandevHomeDir({})).toBe(KANDEV_HOME_DIR); - }); - - it("honors KANDEV_HOME_DIR for service-managed launches", () => { - const env = { KANDEV_HOME_DIR: "/srv/kandev-test" }; - - expect(resolveKandevHomeDir(env)).toBe("/srv/kandev-test"); - expect(resolveCacheDir(env)).toBe(path.join("/srv/kandev-test", "bin")); - expect(resolveDataDir(env)).toBe(path.join("/srv/kandev-test", "data")); - expect(resolveDatabasePath(env)).toBe(path.join("/srv/kandev-test", "data", "kandev.db")); - }); - - it("lets an explicit KANDEV_DATABASE_PATH override the home-derived path", () => { - expect( - resolveDatabasePath({ - KANDEV_HOME_DIR: "/srv/kandev-test", - KANDEV_DATABASE_PATH: "/tmp/custom.db", - }), - ).toBe("/tmp/custom.db"); - }); -}); diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts deleted file mode 100644 index 1c84ce7f0d..0000000000 --- a/apps/cli/src/constants.ts +++ /dev/null @@ -1,53 +0,0 @@ -import os from "node:os"; -import path from "node:path"; - -// Default service ports (will auto-fallback if busy). -// Clustered near the web port (37429) to avoid collisions with commonly used -// ports (8080, 9090, 9999, etc.) while keeping the numbers memorable. -export const DEFAULT_BACKEND_PORT = 38429; -export const DEFAULT_WEB_PORT = 37429; -export const DEFAULT_AGENTCTL_PORT = 39429; - -// Random fallback range for port selection. -export const RANDOM_PORT_MIN = 10000; -export const RANDOM_PORT_MAX = 60000; -export const RANDOM_PORT_RETRIES = 10; - -// Backend healthcheck timeout during startup. -export const HEALTH_TIMEOUT_MS_RELEASE = 45000; -export const HEALTH_TIMEOUT_MS_DEV = 600000; - -// Kandev root directory. Single source of truth for the dotdir name and -// everything derived from it (data, tasks, bin). Dev mode uses a separate -// root under the repo (see DEV_KANDEV_DOTDIR). -export const KANDEV_DOTDIR = ".kandev"; -export const KANDEV_HOME_DIR = path.join(os.homedir(), KANDEV_DOTDIR); -export const KANDEV_TASKS_DIR = path.join(KANDEV_HOME_DIR, "tasks"); - -// Local user cache/data directories for release bundles and DB. -export const CACHE_DIR = path.join(KANDEV_HOME_DIR, "bin"); -export const DATA_DIR = path.join(KANDEV_HOME_DIR, "data"); - -export function resolveKandevHomeDir(env: NodeJS.ProcessEnv = process.env): string { - return env.KANDEV_HOME_DIR?.trim() || KANDEV_HOME_DIR; -} - -export function resolveDataDir(env: NodeJS.ProcessEnv = process.env): string { - return path.join(resolveKandevHomeDir(env), "data"); -} - -export function resolveCacheDir(env: NodeJS.ProcessEnv = process.env): string { - return path.join(resolveKandevHomeDir(env), "bin"); -} - -export function resolveDatabasePath(env: NodeJS.ProcessEnv = process.env): string { - return env.KANDEV_DATABASE_PATH?.trim() || path.join(resolveDataDir(env), "kandev.db"); -} - -// Dev-mode root: an isolated kandev home inside the repo so that running -// `make dev` from inside a kandev-spawned task workspace does not touch the -// user's production state. -export const DEV_KANDEV_DOTDIR = ".kandev-dev"; -export function devKandevHome(repoRoot: string): string { - return path.join(repoRoot, DEV_KANDEV_DOTDIR); -} diff --git a/apps/cli/src/dev.test.ts b/apps/cli/src/dev.test.ts deleted file mode 100644 index 544965c7f3..0000000000 --- a/apps/cli/src/dev.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import path from "node:path"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { resolveDevBackendEnv } from "./dev"; - -describe("resolveDevBackendEnv", () => { - const originalTaskId = process.env.KANDEV_TASK_ID; - const originalDbPath = process.env.KANDEV_DATABASE_PATH; - const originalMock = process.env.KANDEV_MOCK_AGENT; - - beforeEach(() => { - delete process.env.KANDEV_TASK_ID; - delete process.env.KANDEV_DATABASE_PATH; - delete process.env.KANDEV_MOCK_AGENT; - vi.spyOn(console, "log").mockImplementation(() => {}); - }); - - afterEach(() => { - restoreEnv("KANDEV_TASK_ID", originalTaskId); - restoreEnv("KANDEV_DATABASE_PATH", originalDbPath); - restoreEnv("KANDEV_MOCK_AGENT", originalMock); - vi.restoreAllMocks(); - }); - - it("defaults to /.kandev-dev/data/kandev.db in a plain shell", () => { - const repoRoot = "/tmp/repo"; - const { dbPath, extra } = resolveDevBackendEnv(repoRoot); - - expect(dbPath).toBe(path.join(repoRoot, ".kandev-dev", "data", "kandev.db")); - expect(extra.KANDEV_HOME_DIR).toBe(path.join(repoRoot, ".kandev-dev")); - expect(extra.KANDEV_DATABASE_PATH).toBe(""); - }); - - it("honors an explicit KANDEV_DATABASE_PATH override in a plain shell", () => { - process.env.KANDEV_DATABASE_PATH = "/tmp/custom.db"; - - const { dbPath, extra } = resolveDevBackendEnv("/tmp/repo"); - - expect(dbPath).toBe("/tmp/custom.db"); - expect(extra.KANDEV_DATABASE_PATH).toBe("/tmp/custom.db"); - expect(extra.KANDEV_HOME_DIR).toBeUndefined(); - }); - - it("ignores a leaked KANDEV_DATABASE_PATH when running inside a kandev task", () => { - process.env.KANDEV_TASK_ID = "fake-task-id"; - process.env.KANDEV_DATABASE_PATH = "/parent/kandev.db"; - const repoRoot = "/tmp/repo"; - - const { dbPath, extra } = resolveDevBackendEnv(repoRoot); - - expect(dbPath).toBe(path.join(repoRoot, ".kandev-dev", "data", "kandev.db")); - expect(extra.KANDEV_HOME_DIR).toBe(path.join(repoRoot, ".kandev-dev")); - expect(extra.KANDEV_DATABASE_PATH).toBe(""); - }); -}); - -function restoreEnv(name: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[name]; - } else { - process.env[name] = value; - } -} diff --git a/apps/cli/src/dev.ts b/apps/cli/src/dev.ts deleted file mode 100644 index b9849859e7..0000000000 --- a/apps/cli/src/dev.ts +++ /dev/null @@ -1,212 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -import type { BackendPortSource } from "./args"; -import { backupProductionDb, isProductionDb } from "./backup"; -import { devKandevHome, HEALTH_TIMEOUT_MS_DEV } from "./constants"; -import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health"; -import { isInsideKandevTask } from "./kandev-env"; -import { createProcessSupervisor } from "./process"; -import { - buildBackendEnv, - buildWebEnv, - createOutputRingBuffer, - logStartupInfo, - pickPorts, - resolveBackendLogLevels, -} from "./shared"; -import { launchRestartableBackend } from "./supervisor/backend"; -import { launchWebApp, openBrowser } from "./web"; - -export type DevOptions = { - repoRoot: string; - backendPort?: number; - backendPortSource?: BackendPortSource; - webPort?: number; -}; - -export async function runDev({ - repoRoot, - backendPort, - backendPortSource, - webPort, -}: DevOptions): Promise { - const ports = await pickPorts({ backendPort, backendPortSource, webPort }); - const { dbPath, extra } = resolveDevBackendEnv(repoRoot); - - if (isProductionDb(dbPath)) { - try { - const backupPath = backupProductionDb(dbPath); - if (backupPath) { - const name = path.basename(backupPath); - console.log(`[kandev] backed up production db → ${name}`); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - // Abort rather than continue: the backup exists precisely to protect the - // production db before dev mode touches it. Proceeding on failure would - // remove the safety guarantee that justified introducing this guard. - throw new Error(`failed to back up production db (${message}); aborting dev startup`); - } - } - - const levels = resolveBackendLogLevels(); - const backendEnv = buildBackendEnv({ - ports, - logLevel: levels.file, - consoleLogLevel: levels.console, - extra, - }); - const webEnv = buildWebEnv({ ports, debug: true }); - const logLevel = levels.file; - const webUrl = `http://localhost:${ports.webPort}`; - const appUrl = ports.backendUrl; - - logStartupInfo({ - header: "dev mode: using local repo", - ports, - dbPath, - logLevel, - }); - - const supervisor = createProcessSupervisor(); - supervisor.attachSignalHandlers(); - - const { cmd: backendCmd, args: backendArgs } = withWinjobWrap(repoRoot, "make", [ - "-C", - path.join("apps", "backend"), - "dev", - ]); - const output = createOutputRingBuffer(process.stdout); - const backend = await launchRestartableBackend({ - command: backendCmd, - args: backendArgs, - cwd: repoRoot, - env: backendEnv, - homeDir: backendEnv.KANDEV_HOME_DIR ?? devKandevHome(repoRoot), - ports, - mode: "dev", - stdio: ["ignore", "pipe", "inherit"], - supervisor, - onProcess: (proc) => output.attach(proc.stdout), - }); - - const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_DEV); - console.log("[kandev] starting backend..."); - await waitForHealth( - ports.backendUrl, - backend.proc, - healthTimeoutMs, - backendEnv.KANDEV_DESKTOP_HEALTH_TOKEN, - () => { - const buffered = output.read(); - if (buffered.trim().length === 0) return; - console.error("[kandev] --- backend stdout (last captured output) ---"); - console.error(buffered.trimEnd()); - console.error("[kandev] --- end backend stdout ---"); - }, - ); - console.log(`[kandev] backend ready at ${ports.backendUrl}`); - - console.log("[kandev] starting web..."); - const webProc = launchWebApp({ - command: "pnpm", - args: ["-C", "apps", "--filter", "@kandev/web", "dev"], - cwd: repoRoot, - env: webEnv, - supervisor, - label: "web", - }); - await waitForUrlReady(webUrl, webProc, healthTimeoutMs); - console.log(`[kandev] open: ${appUrl}`); - openBrowser(appUrl); -} - -type DevBackendEnv = { - dbPath: string; - extra: Record; -}; - -// Computes the dev-mode backend env. Dev mode always roots kandev under -// /.kandev-dev so state is isolated from the user's production ~/.kandev -// and so `make clean-db` (which removes .kandev-dev/) matches what `make dev` -// writes. -// -// When invoked from inside a kandev task workspace, any KANDEV_DATABASE_PATH -// is assumed to be leaked from the parent backend and is ignored. In a normal -// shell, an explicit KANDEV_DATABASE_PATH is honored as an escape hatch. -export function resolveDevBackendEnv(repoRoot: string): DevBackendEnv { - // Profile-selector only: the backend reads profiles.yaml at startup - // and applies the matching `dev:` values (mock agent, pprof, - // feature flags, etc.) to its own env. We don't repeat those here — - // profiles.yaml at the repo root is the single source of truth. - // See docs/decisions/0007-runtime-feature-flags.md. - const baseExtra: Record = { - KANDEV_DEBUG_DEV_MODE: "true", - }; - const devHome = devKandevHome(repoRoot); - // Display only; the backend derives its own DB path from KANDEV_HOME_DIR - // via ResolvedDataDir(). Both resolve to the same location. - const devDbPath = path.join(devHome, "data", "kandev.db"); - - if (isInsideKandevTask(repoRoot)) { - console.log("[kandev] task workspace detected → using local dev state"); - return { - dbPath: devDbPath, - extra: { - ...baseExtra, - KANDEV_HOME_DIR: devHome, - // Clear a parent-leaked DB path so the backend uses the HomeDir-derived default. - KANDEV_DATABASE_PATH: "", - }, - }; - } - - const override = process.env.KANDEV_DATABASE_PATH; - if (override) { - return { - dbPath: override, - extra: { ...baseExtra, KANDEV_DATABASE_PATH: override }, - }; - } - return { - dbPath: devDbPath, - extra: { - ...baseExtra, - KANDEV_HOME_DIR: devHome, - KANDEV_DATABASE_PATH: "", - }, - }; -} - -// withWinjobWrap on Windows prepends apps/backend/bin/winjob.exe to a spawn -// command so the child runs inside a Job Object configured with -// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. That makes "kill the whole backend -// subtree" a kernel-level guarantee tied to winjob's process exit, instead of -// relying on the bash → make → pnpm → node → make → sh → kandev signal chain -// (which drops Ctrl-C at multiple links because MSYS bash, native Win32 -// processes, and Node disagree on signal propagation semantics). -// -// On Unix this is a passthrough — POSIX process groups already give us -// reliable cascading termination. -// -// If the winjob binary isn't built yet (the user ran `make dev` before -// `make -C apps/backend build-winjob`), we fall back to a direct spawn and -// emit a one-line note. The supervisor's tree-kill still handles the happy -// path; users only notice the gap if Ctrl-C drops mid-chain. -function withWinjobWrap( - repoRoot: string, - cmd: string, - args: string[], -): { cmd: string; args: string[] } { - if (process.platform !== "win32") return { cmd, args }; - const winjob = path.join(repoRoot, "apps", "backend", "bin", "winjob.exe"); - if (!fs.existsSync(winjob)) { - console.warn( - `[kandev] winjob.exe not built — Ctrl-C may leak processes on Windows. ` + - `Run \`make -C apps/backend build-winjob\` once to enable.`, - ); - return { cmd, args }; - } - return { cmd: winjob, args: [cmd, ...args] }; -} diff --git a/apps/cli/src/github.test.ts b/apps/cli/src/github.test.ts deleted file mode 100644 index f064a21bbc..0000000000 --- a/apps/cli/src/github.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { readSha256 } from "./github"; - -describe("readSha256", () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-sha-test-")); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it("returns null when file does not exist", () => { - expect(readSha256(path.join(tmpDir, "nonexistent.sha256"))).toBeNull(); - }); - - it("reads a plain hash", () => { - const shaPath = path.join(tmpDir, "test.sha256"); - fs.writeFileSync(shaPath, "abc123def456"); - expect(readSha256(shaPath)).toBe("abc123def456"); - }); - - it("reads hash from sha256sum format (hash + filename)", () => { - const shaPath = path.join(tmpDir, "test.sha256"); - fs.writeFileSync(shaPath, "abc123def456 kandev-macos-arm64.tar.gz\n"); - expect(readSha256(shaPath)).toBe("abc123def456"); - }); - - it("lowercases uppercase hash", () => { - const shaPath = path.join(tmpDir, "test.sha256"); - fs.writeFileSync(shaPath, "ABC123DEF456"); - expect(readSha256(shaPath)).toBe("abc123def456"); - }); - - it("trims whitespace", () => { - const shaPath = path.join(tmpDir, "test.sha256"); - fs.writeFileSync(shaPath, " abc123def456 \n"); - expect(readSha256(shaPath)).toBe("abc123def456"); - }); - - it("returns null for empty file", () => { - const shaPath = path.join(tmpDir, "test.sha256"); - fs.writeFileSync(shaPath, ""); - expect(readSha256(shaPath)).toBeNull(); - }); -}); diff --git a/apps/cli/src/github.ts b/apps/cli/src/github.ts deleted file mode 100644 index 1324494878..0000000000 --- a/apps/cli/src/github.ts +++ /dev/null @@ -1,259 +0,0 @@ -import crypto from "node:crypto"; -import fs from "node:fs"; -import https from "node:https"; -import path from "node:path"; - -// Allow overriding the GitHub repo for forks/testing. -const OWNER = process.env.KANDEV_GITHUB_OWNER || "kdlbs"; -const REPO = process.env.KANDEV_GITHUB_REPO || "kandev"; -const WEB_BASE = `https://github.com/${OWNER}/${REPO}`; - -export type ReleaseInfo = { - tag_name: string; -}; - -function authHeaders(): Record { - if (process.env.KANDEV_GITHUB_TOKEN) { - return { Authorization: `Bearer ${process.env.KANDEV_GITHUB_TOKEN}` }; - } - return {}; -} - -/** - * Resolve the latest release tag by following the redirect from - * github.com/{owner}/{repo}/releases/latest. - * - * Uses github.com (not api.github.com) so it is not subject to - * the REST API rate limit (60 req/hour per IP). - */ -function resolveLatestTag(): Promise { - const url = `${WEB_BASE}/releases/latest`; - return new Promise((resolve, reject) => { - const req = https.get( - url, - { - headers: { "User-Agent": "kandev-npx", ...authHeaders() }, - // Do not follow redirects — we just need the Location header. - followRedirect: false, - } as https.RequestOptions, - (res) => { - // Drain the response body to free the socket. - res.resume(); - - if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) { - // Location: https://github.com/{owner}/{repo}/releases/tag/v0.2 - const match = res.headers.location.match(/\/releases\/tag\/(.+)$/); - if (match) { - return resolve(match[1]); - } - return reject(new Error(`Could not parse tag from redirect: ${res.headers.location}`)); - } - - // GitHub returns 200 if there's only one release (no redirect). - // In that case we need to parse the page — but this is uncommon. - // Fall back to a HEAD request on the resolved URL. - if (res.statusCode === 200 && res.headers.location) { - const match = res.headers.location.match(/\/releases\/tag\/(.+)$/); - if (match) return resolve(match[1]); - } - - reject(new Error(`Failed to resolve latest release (HTTP ${res.statusCode})`)); - }, - ); - req.setTimeout(5000, () => { - req.destroy(new Error("Request timed out resolving latest release")); - }); - req.on("error", reject); - }); -} - -/** - * Verify that a specific release tag exists. - */ -function verifyTagExists(tag: string): Promise { - const url = `${WEB_BASE}/releases/tag/${tag}`; - return new Promise((resolve, reject) => { - const req = https.request( - url, - { - method: "HEAD", - headers: { "User-Agent": "kandev-npx", ...authHeaders() }, - }, - (res) => { - res.resume(); - // GitHub returns 200 for the tag page, or 302 redirect to the tag page. - if (res.statusCode === 200 || res.statusCode === 301 || res.statusCode === 302) { - return resolve(); - } - reject(new Error(`Release tag '${tag}' not found (HTTP ${res.statusCode})`)); - }, - ); - req.setTimeout(5000, () => { - req.destroy(new Error(`Request timed out verifying tag '${tag}'`)); - }); - req.on("error", reject); - req.end(); - }); -} - -/** - * Get release info. Uses github.com web URLs (not api.github.com) - * to avoid REST API rate limits. - */ -export async function getRelease(version?: string): Promise { - if (version) { - await verifyTagExists(version); - return { tag_name: version }; - } - const tag = await resolveLatestTag(); - return { tag_name: tag }; -} - -// -- Asset downloading -------------------------------------------------------- - -export function readSha256(pathToSha: string): string | null { - if (!fs.existsSync(pathToSha)) { - return null; - } - const content = fs.readFileSync(pathToSha, "utf8").trim(); - const first = content.split(/\s+/)[0]; - return first?.toLowerCase() || null; -} - -function downloadFile( - url: string, - destPath: string, - expectedSha256?: string | null, - onProgress?: (downloaded: number, total: number) => void, -): Promise { - const tempPath = `${destPath}.tmp`; - return new Promise((resolve, reject) => { - const file = fs.createWriteStream(tempPath); - const hash = crypto.createHash("sha256"); - - const cleanup = () => { - try { - fs.unlinkSync(tempPath); - } catch {} - }; - - const handleResponse = (res: import("node:http").IncomingMessage) => { - // Follow redirects (GitHub returns 302 to signed S3/CDN URL). - // Strip auth header on redirect to avoid S3 rejecting it. - if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) { - const redirectReq = https.get( - res.headers.location, - { headers: { "User-Agent": "kandev-npx" } }, - handleResponse, - ); - redirectReq.setTimeout(30000, () => { - redirectReq.destroy(new Error(`Request timed out downloading ${url}`)); - }); - redirectReq.on("error", (err) => { - file.close(); - cleanup(); - reject(err); - }); - return; - } - - if (res.statusCode !== 200) { - file.close(); - cleanup(); - return reject(new Error(`HTTP ${res.statusCode} downloading ${url}`)); - } - const totalSize = parseInt(res.headers["content-length"] || "0", 10); - let downloadedSize = 0; - res.on("data", (chunk) => { - downloadedSize += chunk.length; - hash.update(chunk); - if (onProgress) onProgress(downloadedSize, totalSize); - }); - res.pipe(file); - file.on("finish", () => { - file.close(); - const actualSha256 = hash.digest("hex"); - if (expectedSha256 && actualSha256 !== expectedSha256) { - cleanup(); - return reject( - new Error(`Checksum mismatch: expected ${expectedSha256}, got ${actualSha256}`), - ); - } - try { - fs.renameSync(tempPath, destPath); - resolve(destPath); - } catch (err) { - cleanup(); - reject(err); - } - }); - }; - - const req = https.get( - url, - { - headers: { - "User-Agent": "kandev-npx", - ...authHeaders(), - }, - }, - handleResponse, - ); - req.setTimeout(30000, () => { - req.destroy(new Error(`Request timed out downloading ${url}`)); - }); - req.on("error", (err) => { - file.close(); - cleanup(); - reject(err); - }); - }); -} - -/** - * Ensure a release asset is downloaded and cached. - * - * Downloads directly from github.com/{owner}/{repo}/releases/download/{tag}/{asset} - * instead of the API, avoiding rate limits. - */ -export async function ensureAsset( - tag: string, - assetName: string, - cacheDir: string, - onProgress?: (downloaded: number, total: number) => void, -): Promise { - fs.mkdirSync(cacheDir, { recursive: true }); - const destPath = path.join(cacheDir, assetName); - const shaPath = `${destPath}.sha256`; - - // Download sha256 checksum if not already cached. - let expectedSha = readSha256(shaPath); - if (!expectedSha) { - const shaUrl = `${WEB_BASE}/releases/download/${tag}/${assetName}.sha256`; - try { - await downloadFile(shaUrl, shaPath); - expectedSha = readSha256(shaPath); - } catch { - // sha256 file may not exist for this release — continue without it. - } - } - - // Return cached tarball if it exists and checksum matches. - if (fs.existsSync(destPath)) { - if (!expectedSha) { - return destPath; - } - const hash = crypto.createHash("sha256"); - const file = fs.readFileSync(destPath); - hash.update(file); - if (hash.digest("hex") === expectedSha) { - return destPath; - } - fs.unlinkSync(destPath); - } - - // Download the asset. - const assetUrl = `${WEB_BASE}/releases/download/${tag}/${assetName}`; - await downloadFile(assetUrl, destPath, expectedSha, onProgress); - return destPath; -} diff --git a/apps/cli/src/health.test.ts b/apps/cli/src/health.test.ts deleted file mode 100644 index 8683b03de2..0000000000 --- a/apps/cli/src/health.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { waitForHealth, waitForUrlReady } from "./health"; - -type ProcState = { exitCode: number | null }; - -const fetchMock = vi.fn(); - -describe("waitForUrlReady", () => { - beforeEach(() => { - fetchMock.mockReset(); - vi.stubGlobal("fetch", fetchMock); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("returns once web URL is reachable", async () => { - const proc: ProcState = { exitCode: null }; - fetchMock - .mockRejectedValueOnce(new Error("ECONNREFUSED")) - .mockResolvedValue({ status: 404 } as Response); - - await expect(waitForUrlReady("http://localhost:3000", proc, 1000)).resolves.toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it("fails when process exits before URL becomes reachable", async () => { - const proc: ProcState = { exitCode: 1 }; - fetchMock.mockRejectedValue(new Error("ECONNREFUSED")); - - await expect(waitForUrlReady("http://localhost:3000", proc, 1000)).rejects.toThrow( - "Web process exited before URL became reachable", - ); - }); - - it("fails on timeout when URL stays unreachable", async () => { - const proc: ProcState = { exitCode: null }; - fetchMock.mockRejectedValue(new Error("ECONNREFUSED")); - - await expect(waitForUrlReady("http://localhost:3000", proc, 120)).rejects.toThrow( - "Web URL readiness timed out", - ); - }); -}); - -describe("waitForHealth", () => { - beforeEach(() => { - fetchMock.mockReset(); - vi.stubGlobal("fetch", fetchMock); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("returns once /health responds ok", async () => { - const proc: ProcState = { exitCode: null }; - fetchMock.mockRejectedValueOnce(new Error("ECONNREFUSED")).mockResolvedValue({ - ok: true, - headers: new Headers({ "X-Kandev-Desktop-Health-Token": "expected" }), - } as Response); - - await expect( - waitForHealth("http://localhost:8080", proc, 1000, "expected"), - ).resolves.toBeUndefined(); - }); - - it("ignores a successful response with a missing or mismatched launch token", async () => { - const proc: ProcState = { exitCode: null }; - fetchMock - .mockResolvedValueOnce({ ok: true, headers: new Headers() } as Response) - .mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "X-Kandev-Desktop-Health-Token": "wrong" }), - } as Response) - .mockResolvedValue({ - ok: true, - headers: new Headers({ "X-Kandev-Desktop-Health-Token": "expected" }), - } as Response); - - await expect( - waitForHealth("http://localhost:8080", proc, 1500, "expected"), - ).resolves.toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(3); - }); - - it("reports a different process when successful responses never match the launch token", async () => { - const proc: ProcState = { exitCode: null }; - fetchMock.mockResolvedValue({ ok: true, headers: new Headers() } as Response); - - await expect(waitForHealth("http://localhost:38429", proc, 120, "expected")).rejects.toThrow( - "Backend port 38429 answered a health check from a different process " + - "(missing/mismatched launcher token). Another Kandev instance may already own it, " + - "or the runtime bundle predates v0.66.0.", - ); - }); - - it("includes the exit code and invokes onFailure when backend exits early", async () => { - const proc: ProcState = { exitCode: 2 }; - fetchMock.mockRejectedValue(new Error("ECONNREFUSED")); - const onFailure = vi.fn(); - - await expect(waitForHealth("http://localhost:8080", proc, 1000, "", onFailure)).rejects.toThrow( - "Backend exited (code 2) before healthcheck passed", - ); - expect(onFailure).toHaveBeenCalledTimes(1); - }); - - it("timeout error mentions the health URL and the env override, and invokes onFailure", async () => { - const proc: ProcState = { exitCode: null }; - fetchMock.mockRejectedValue(new Error("ECONNREFUSED")); - const onFailure = vi.fn(); - - await expect(waitForHealth("http://localhost:8080", proc, 120, "", onFailure)).rejects.toThrow( - /http:\/\/localhost:8080\/health.*KANDEV_HEALTH_TIMEOUT_MS/s, - ); - expect(onFailure).toHaveBeenCalledTimes(1); - }); -}); diff --git a/apps/cli/src/health.ts b/apps/cli/src/health.ts deleted file mode 100644 index e4d9bbc4ce..0000000000 --- a/apps/cli/src/health.ts +++ /dev/null @@ -1,101 +0,0 @@ -export function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Resolves the health check timeout, allowing override via environment variable. - * - * The KANDEV_HEALTH_TIMEOUT_MS environment variable can override the default - * timeout for waiting on backend health checks. This is useful for slower - * machines or debugging scenarios where the backend takes longer to start. - * - * @param defaultMs - Default timeout in milliseconds if env var is not set - * @returns The resolved timeout in milliseconds - */ -export function resolveHealthTimeoutMs(defaultMs: number): number { - const raw = process.env.KANDEV_HEALTH_TIMEOUT_MS; - if (!raw) { - return defaultMs; - } - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) { - return defaultMs; - } - return Math.floor(parsed); -} - -export async function waitForHealth( - baseUrl: string, - proc: { exitCode: number | null }, - timeoutMs: number, - expectedToken = "", - onFailure?: () => void, -): Promise { - const deadline = Date.now() + timeoutMs; - const healthUrl = `${baseUrl}/health`; - let sawForeignProcessResponse = false; - while (Date.now() < deadline) { - if (proc.exitCode !== null) { - onFailure?.(); - throw new Error(`Backend exited (code ${proc.exitCode}) before healthcheck passed`); - } - try { - const res = await fetch(healthUrl); - if (res.ok && expectedToken !== "" && !matchesHealthToken(res, expectedToken)) { - sawForeignProcessResponse = true; - } - if (res.ok && matchesHealthToken(res, expectedToken)) { - return; - } - } catch { - // ignore until timeout - } - await delay(300); - } - onFailure?.(); - if (sawForeignProcessResponse) { - throw new Error( - `Backend port ${healthPort(baseUrl)} answered a health check from a different process ` + - `(missing/mismatched launcher token). Another Kandev instance may already own it, ` + - `or the runtime bundle predates v0.66.0.`, - ); - } - throw new Error( - `Backend healthcheck timed out after ${timeoutMs}ms at ${healthUrl}. ` + - `If your machine is slow on first run (antivirus scan, cold disk), ` + - `set KANDEV_HEALTH_TIMEOUT_MS=120000 and retry.`, - ); -} - -function healthPort(baseUrl: string): string { - try { - return new URL(baseUrl).port || baseUrl; - } catch { - return baseUrl; - } -} - -function matchesHealthToken(res: Response, expectedToken: string): boolean { - return expectedToken === "" || res.headers.get("X-Kandev-Desktop-Health-Token") === expectedToken; -} - -export async function waitForUrlReady( - url: string, - proc: { exitCode: number | null }, - timeoutMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (proc.exitCode !== null) { - throw new Error("Web process exited before URL became reachable"); - } - try { - await fetch(url); - return; - } catch { - // ignore until timeout - } - await delay(300); - } - throw new Error(`Web URL readiness timed out after ${timeoutMs}ms (${url})`); -} diff --git a/apps/cli/src/kandev-env.test.ts b/apps/cli/src/kandev-env.test.ts deleted file mode 100644 index ca4f369619..0000000000 --- a/apps/cli/src/kandev-env.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import os from "node:os"; -import path from "node:path"; - -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { KANDEV_TASKS_DIR } from "./constants"; -import { isInsideKandevTask } from "./kandev-env"; - -describe("isInsideKandevTask", () => { - const originalTaskId = process.env.KANDEV_TASK_ID; - - beforeEach(() => { - delete process.env.KANDEV_TASK_ID; - }); - - afterEach(() => { - if (originalTaskId === undefined) { - delete process.env.KANDEV_TASK_ID; - } else { - process.env.KANDEV_TASK_ID = originalTaskId; - } - }); - - it("returns true when KANDEV_TASK_ID is set regardless of repo path", () => { - process.env.KANDEV_TASK_ID = "some-id"; - expect(isInsideKandevTask("/any/path")).toBe(true); - }); - - it("returns true for a repoRoot under the kandev tasks dir", () => { - const repoRoot = path.join(KANDEV_TASKS_DIR, "task-123", "kandev"); - expect(isInsideKandevTask(repoRoot)).toBe(true); - }); - - it("returns false for a repoRoot outside the kandev tasks dir", () => { - expect(isInsideKandevTask("/home/user/projects/kandev")).toBe(false); - }); - - it("does not match a sibling directory that shares the 'tasks' prefix", () => { - // Boundary: ~/.kandev/tasks-extra/... must NOT be treated as a task workspace. - // The `path.sep` suffix on KANDEV_TASKS_DIR is the only guard against this. - const adjacent = path.join(os.homedir(), ".kandev", "tasks-extra", "proj"); - expect(isInsideKandevTask(adjacent)).toBe(false); - }); - - it("does not match the tasks dir itself without a subdirectory", () => { - expect(isInsideKandevTask(KANDEV_TASKS_DIR)).toBe(false); - }); -}); diff --git a/apps/cli/src/kandev-env.ts b/apps/cli/src/kandev-env.ts deleted file mode 100644 index e948eb8664..0000000000 --- a/apps/cli/src/kandev-env.ts +++ /dev/null @@ -1,24 +0,0 @@ -import path from "node:path"; - -import { KANDEV_TASKS_DIR } from "./constants"; - -/** - * Returns true when the current process looks like it was spawned inside a - * kandev-created task workspace. Two signals: - * 1. The parent kandev backend exports KANDEV_TASK_ID into every task shell. - * 2. Task worktrees live under ~/.kandev/tasks/ (see KANDEV_TASKS_DIR). - * - * Used by dev mode to auto-isolate the backend onto a local dev root so that - * `make dev` never mutates the user's production state. - * - * Note: the path-prefix fallback is a defensive secondary signal for nested - * shells where KANDEV_TASK_ID was stripped. It is case-sensitive and does not - * resolve symlinks, so a realpath'd repoRoot may miss a symlinked HOME on - * macOS / Windows. KANDEV_TASK_ID remains the primary guarantee. - */ -export function isInsideKandevTask(repoRoot: string): boolean { - if (process.env.KANDEV_TASK_ID) { - return true; - } - return repoRoot.startsWith(KANDEV_TASKS_DIR + path.sep); -} diff --git a/apps/cli/src/platform.ts b/apps/cli/src/platform.ts deleted file mode 100644 index 3e1102a5d8..0000000000 --- a/apps/cli/src/platform.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { execSync } from "node:child_process"; - -const isWindows = process.platform === "win32"; - -export type PlatformArch = "x64" | "arm64"; -export type PlatformDir = "linux-x64" | "linux-arm64" | "macos-x64" | "macos-arm64" | "windows-x64"; - -export function getBinaryName(base: string): string { - return isWindows ? `${base}.exe` : base; -} - -function getEffectiveArch(): PlatformArch { - const platform = process.platform; - const nodeArch = process.arch; - if (platform === "darwin") { - if (nodeArch === "arm64") return "arm64"; - try { - const translated = execSync("sysctl -in sysctl.proc_translated", { - encoding: "utf8", - }).trim(); - if (translated === "1") return "arm64"; - } catch {} - return "x64"; - } - if (/arm/i.test(nodeArch)) return "arm64"; - if (platform === "win32") { - const pa = process.env.PROCESSOR_ARCHITECTURE || ""; - const paw = process.env.PROCESSOR_ARCHITEW6432 || ""; - if (/arm/i.test(pa) || /arm/i.test(paw)) return "arm64"; - } - return "x64"; -} - -export function getPlatformDir(): PlatformDir { - const platform = process.platform; - const arch = getEffectiveArch(); - if (platform === "linux" && arch === "x64") return "linux-x64"; - if (platform === "linux" && arch === "arm64") return "linux-arm64"; - if (platform === "darwin" && arch === "x64") return "macos-x64"; - if (platform === "darwin" && arch === "arm64") return "macos-arm64"; - if (platform === "win32" && arch === "x64") return "windows-x64"; - // Windows ARM64 runs x64 binaries via emulation — no native arm64 build yet - if (platform === "win32" && arch === "arm64") return "windows-x64"; - throw new Error(`Unsupported platform: ${platform}-${arch}`); -} diff --git a/apps/cli/src/ports.test.ts b/apps/cli/src/ports.test.ts deleted file mode 100644 index 3d15b2f24f..0000000000 --- a/apps/cli/src/ports.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import net from "node:net"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { - assertPortAvailable, - ensureValidPort, - pickAvailablePortExcluding, - __testing, -} from "./ports"; - -describe("ensureValidPort", () => { - it("returns undefined for undefined input", () => { - expect(ensureValidPort(undefined, "test")).toBeUndefined(); - }); - - it("returns the port for valid values", () => { - expect(ensureValidPort(8080, "test")).toBe(8080); - }); - - it("accepts port 1 (minimum)", () => { - expect(ensureValidPort(1, "test")).toBe(1); - }); - - it("accepts port 65535 (maximum)", () => { - expect(ensureValidPort(65535, "test")).toBe(65535); - }); - - it("throws for port 0", () => { - expect(() => ensureValidPort(0, "backend")).toThrow( - "backend must be an integer between 1 and 65535", - ); - }); - - it("throws for port above 65535", () => { - expect(() => ensureValidPort(65536, "web")).toThrow( - "web must be an integer between 1 and 65535", - ); - }); - - it("throws for negative port", () => { - expect(() => ensureValidPort(-1, "test")).toThrow(); - }); - - it("throws for floating point port", () => { - expect(() => ensureValidPort(8080.5, "test")).toThrow(); - }); - - it("throws for NaN", () => { - expect(() => ensureValidPort(NaN, "test")).toThrow(); - }); -}); - -function listenOn(host: string): Promise<{ server: net.Server; port: number }> { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.on("error", reject); - server.listen(0, host, () => { - const addr = server.address(); - if (addr && typeof addr === "object") { - resolve({ server, port: addr.port }); - } else { - reject(new Error("no address")); - } - }); - }); -} - -function closeServer(server: net.Server): Promise { - return new Promise((resolve) => server.close(() => resolve())); -} - -async function findReportedAvailablePort(): Promise { - for (let attempt = 0; attempt < 10; attempt += 1) { - const { server, port } = await listenOn("127.0.0.1"); - await closeServer(server); - if (await __testing.isPortAvailable(port)) return port; - } - throw new Error("Unable to find a port reported available"); -} - -describe("isPortAvailable", () => { - const servers: net.Server[] = []; - - afterEach(async () => { - while (servers.length) { - const s = servers.pop(); - if (s) await closeServer(s); - } - }); - - it("returns false when a server is listening on the IPv4 loopback", async () => { - const { server, port } = await listenOn("127.0.0.1"); - servers.push(server); - expect(await __testing.isPortAvailable(port)).toBe(false); - }); - - it("returns true for a port that nothing is bound to", async () => { - const { server, port } = await listenOn("127.0.0.1"); - await closeServer(server); - // Port may be in TIME_WAIT briefly, but bind-probe with SO_REUSEADDR - // (Node default on POSIX) treats that as available. - expect(await __testing.isPortAvailable(port)).toBe(true); - }); -}); - -describe("isPortInUse", () => { - it("returns false within the timeout when the host black-holes packets", async () => { - // 192.0.2.0/24 is TEST-NET-1 (RFC 5737): traffic to it is reliably - // dropped, simulating the WSL2 mirrored-mode SYN black-hole. Without the - // timeout in isPortInUse, this connect would hang for ~75 seconds. - const start = Date.now(); - const result = await __testing.isPortInUse(38429, "192.0.2.1", 200); - const elapsed = Date.now() - start; - expect(result).toBe(false); - expect(elapsed).toBeLessThan(1000); - }); -}); - -describe("pickAvailablePortExcluding", () => { - it("returns the preferred port when it is free", async () => { - const port = await findReportedAvailablePort(); - expect(await pickAvailablePortExcluding(port, new Set())).toBe(port); - }); - - it("skips an excluded preferred port", async () => { - const preferred = await findReportedAvailablePort(); - const picked = await pickAvailablePortExcluding(preferred, new Set([preferred]), 5); - - expect(picked).not.toBe(preferred); - expect(picked).toBeGreaterThan(0); - }); - - it("returns a fallback port when the preferred port is taken", async () => { - const { server, port } = await listenOn("127.0.0.1"); - try { - const picked = await pickAvailablePortExcluding(port, new Set(), 5); - expect(picked).not.toBe(port); - expect(picked).toBeGreaterThan(0); - } finally { - await closeServer(server); - } - }); -}); - -describe("assertPortAvailable", () => { - it("rejects an occupied explicit port with its configuration source", async () => { - const { server, port } = await listenOn("127.0.0.1"); - try { - await expect(assertPortAvailable(port, "KANDEV_BACKEND_PORT")).rejects.toThrow( - `Backend port ${port} from KANDEV_BACKEND_PORT is already in use`, - ); - } finally { - await closeServer(server); - } - }); -}); diff --git a/apps/cli/src/ports.ts b/apps/cli/src/ports.ts deleted file mode 100644 index 175f9ab019..0000000000 --- a/apps/cli/src/ports.ts +++ /dev/null @@ -1,159 +0,0 @@ -import crypto from "node:crypto"; -import net from "node:net"; - -import { RANDOM_PORT_MAX, RANDOM_PORT_MIN, RANDOM_PORT_RETRIES } from "./constants"; - -const CONNECT_PROBE_TIMEOUT_MS = 500; - -export function ensureValidPort(port: number | undefined, name: string): number | undefined { - if (port === undefined) { - return undefined; - } - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - throw new Error(`${name} must be an integer between 1 and 65535`); - } - return port; -} - -/** - * Tries to connect to a port on the given host. Returns true if something - * is already listening (i.e. the port is in use). Returns false on connection - * refused, on any other socket error, or when the probe takes longer than - * `timeoutMs` — under WSL2 mirrored networking the kernel can silently drop - * the SYN to an unbound loopback port, so an unbounded connect would hang - * forever and deadlock isPortAvailable. - * - * Used alongside canBindPort because it detects ports where a listener is - * bound with SO_REUSEADDR (Node's default on macOS), which a bind-only check - * would miss. - */ -function isPortInUse( - port: number, - host: string, - timeoutMs = CONNECT_PROBE_TIMEOUT_MS, -): Promise { - return new Promise((resolve) => { - const socket = net.createConnection({ port, host }); - let settled = false; - const done = (v: boolean) => { - if (settled) return; - settled = true; - socket.destroy(); - resolve(v); - }; - socket.once("connect", () => done(true)); - socket.once("error", () => done(false)); - setTimeout(() => done(false), timeoutMs).unref(); - }); -} - -/** - * Tries to bind a port on the given host. Returns true if the bind succeeds - * (port is free). Closes immediately on success. - * - * This catches Windows "phantom" port reservations: Hyper-V/WSL silently - * reserve random port ranges at boot that don't appear in netstat or via a - * connect probe (nothing is listening, so connect-check thinks the port is - * free) — but bind fails with "Only one usage of each socket address". A - * connect-only check causes kandev's backend to choose a reserved port and - * then die when it tries to actually listen on it. - */ -function canBindPort(port: number, host: string): Promise { - return new Promise((resolve) => { - const server = net.createServer(); - server.once("error", () => resolve(false)); - server.listen(port, host, () => { - server.close(() => resolve(true)); - }); - }); -} - -/** - * Checks if a port is available by probing both IPv4 and IPv6 loopback. - * - * Uses BOTH a connect check and a bind check: - * - connect: detects ports where a listener is bound with SO_REUSEADDR - * (Node's default on macOS — bind-only check would falsely succeed) - * - bind: detects Windows phantom reservations (Hyper-V/WSL) and - * ports in TIME_WAIT that connect-only check misses - * - * The port is available IFF nobody answers a connect AND a fresh bind - * succeeds — covers macOS, Linux, and Windows. - */ -async function isPortAvailable(port: number): Promise { - // Run connect probes first, then the bind probe — they cannot share the - // port concurrently. On loopback, server.listen() completes in the kernel - // before a connect SYN to the same address is processed, so a concurrent - // canBindPort+isPortInUse pair can answer each other and report a free - // port as occupied. Sequencing keeps the bind probe's temporary listener - // out of the connect probes' view. - const [v4InUse, v6InUse] = await Promise.all([ - isPortInUse(port, "127.0.0.1"), - isPortInUse(port, "::1"), - ]); - if (v4InUse || v6InUse) return false; - return canBindPort(port, "127.0.0.1"); -} - -export async function assertPortAvailable(port: number, source?: string): Promise { - if (await isPortAvailable(port)) return; - const sourceSuffix = source ? ` from ${source}` : ""; - throw new Error(`Backend port ${port}${sourceSuffix} is already in use`); -} - -async function reserveSpecificPort(port: number, host = "127.0.0.1"): Promise { - return new Promise((resolve) => { - const server = net.createServer(); - server.on("error", () => resolve(null)); - server.listen(port, host, () => resolve(server)); - }); -} - -export async function pickAvailablePortExcluding( - preferred: number, - excludedPorts: ReadonlySet, - retries = RANDOM_PORT_RETRIES, -): Promise { - if (!excludedPorts.has(preferred) && (await isPortAvailable(preferred))) { - return preferred; - } - for (let i = 0; i < retries; i += 1) { - const candidate = crypto.randomInt(RANDOM_PORT_MIN, RANDOM_PORT_MAX + 1); - if (!excludedPorts.has(candidate) && (await isPortAvailable(candidate))) { - return candidate; - } - } - throw new Error(`Unable to find a free port after ${retries + 1} attempts`); -} - -export async function pickAndReservePort( - preferred: number, - retries = RANDOM_PORT_RETRIES, -): Promise<{ port: number; release: () => Promise }> { - if (await isPortAvailable(preferred)) { - const reservedPreferred = await reserveSpecificPort(preferred); - if (reservedPreferred) { - return { - port: preferred, - release: () => new Promise((resolve) => reservedPreferred.close(() => resolve())), - }; - } - } - - for (let i = 0; i < retries; i += 1) { - const candidate = crypto.randomInt(RANDOM_PORT_MIN, RANDOM_PORT_MAX + 1); - if (!(await isPortAvailable(candidate))) continue; - const reserved = await reserveSpecificPort(candidate); - if (reserved) { - return { - port: candidate, - release: () => new Promise((resolve) => reserved.close(() => resolve())), - }; - } - } - - throw new Error(`Unable to reserve a free port after ${retries + 1} attempts`); -} - -// Exported for tests only. -export const __testing = { isPortInUse, isPortAvailable }; diff --git a/apps/cli/src/process.test.ts b/apps/cli/src/process.test.ts deleted file mode 100644 index 3e50a37f1b..0000000000 --- a/apps/cli/src/process.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterAll, describe, expect, it } from "vitest"; - -import { __resetBrokenPipeGuard, ignoreBrokenPipe } from "./process"; - -function makeError(code: string, message: string): NodeJS.ErrnoException { - return Object.assign(new Error(message), { code }); -} - -function lastErrorListener(stream: NodeJS.WriteStream): (err: NodeJS.ErrnoException) => void { - const listeners = stream.listeners("error"); - return listeners[listeners.length - 1] as (err: NodeJS.ErrnoException) => void; -} - -describe("ignoreBrokenPipe", () => { - // Capture the handler we install so we can both exercise and clean it up. - let installed: ((err: NodeJS.ErrnoException) => void) | undefined; - - afterAll(() => { - if (installed) { - process.stdout.removeListener("error", installed); - process.stderr.removeListener("error", installed); - } - // Reset the module guard alongside listener removal so a later - // ignoreBrokenPipe() reattaches instead of hitting a no-op with no - // listeners present (order-independent for other tests in this worker). - __resetBrokenPipeGuard(); - }); - - it("attaches an error handler to stdout and stderr", () => { - const stdoutBefore = process.stdout.listeners("error").length; - const stderrBefore = process.stderr.listeners("error").length; - - ignoreBrokenPipe(); - - expect(process.stdout.listeners("error").length).toBe(stdoutBefore + 1); - expect(process.stderr.listeners("error").length).toBe(stderrBefore + 1); - - // stdout and stderr share the same handler reference. - installed = lastErrorListener(process.stdout); - expect(lastErrorListener(process.stderr)).toBe(installed); - }); - - it("swallows EPIPE errors without throwing", () => { - const handler = lastErrorListener(process.stdout); - expect(() => handler(makeError("EPIPE", "write EPIPE"))).not.toThrow(); - }); - - it("rethrows non-EPIPE errors", () => { - const handler = lastErrorListener(process.stdout); - expect(() => handler(makeError("EACCES", "boom"))).toThrow("boom"); - }); - - it("is idempotent across repeated calls", () => { - const stdoutCount = process.stdout.listeners("error").length; - const stderrCount = process.stderr.listeners("error").length; - - ignoreBrokenPipe(); - ignoreBrokenPipe(); - - expect(process.stdout.listeners("error").length).toBe(stdoutCount); - expect(process.stderr.listeners("error").length).toBe(stderrCount); - }); -}); diff --git a/apps/cli/src/process.ts b/apps/cli/src/process.ts deleted file mode 100644 index 6d6d460506..0000000000 --- a/apps/cli/src/process.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { ChildProcess } from "node:child_process"; -import kill from "tree-kill"; - -export type ChildLike = { pid?: number | undefined }; - -const SHUTDOWN_TIMEOUT_MS = 10000; - -export function createProcessSupervisor(): { - children: ChildLike[]; - shutdown: (reason: string) => Promise; - attachSignalHandlers: () => void; -} { - let shutdownPromise: Promise | null = null; - const children: ChildLike[] = []; - - const shutdown = async (reason: string) => { - // If already shutting down, wait for the existing shutdown to complete - if (shutdownPromise) { - return shutdownPromise; - } - - console.log(`[kandev] shutting down (${reason})...`); - - // Wait for all child processes to actually exit, not just for signal to be sent - shutdownPromise = Promise.all( - children - .filter((child) => child.pid) - .map((child) => waitForProcessExit(child, SHUTDOWN_TIMEOUT_MS)), - ).then(() => {}); - - return shutdownPromise; - }; - - const onSignal = (signal: NodeJS.Signals) => { - void shutdown(`signal ${signal}`).then(() => process.exit(0)); - }; - - const attachSignalHandlers = () => { - // Ctrl-C sends SIGINT to the whole process group, so the parent (make/shell) - // exits and closes the pipe our stdout/stderr write to. Any console.log during - // shutdown then triggers an EPIPE 'error' event with no listener, which Node - // treats as fatal and crashes us before children are gracefully terminated. - // Swallow EPIPE so shutdown can finish. - ignoreBrokenPipe(); - process.on("SIGINT", onSignal); - // SIGTERM is not available on Windows — only attach where supported - if (process.platform !== "win32") { - process.on("SIGTERM", onSignal); - } - }; - - return { children, shutdown, attachSignalHandlers }; -} - -let brokenPipeGuarded = false; - -/** - * Install error handlers on stdout/stderr that swallow EPIPE (broken pipe). - * Idempotent: only attaches once per process. - */ -export function ignoreBrokenPipe(): void { - if (brokenPipeGuarded) return; - brokenPipeGuarded = true; - const onPipeError = (err: NodeJS.ErrnoException) => { - if (err.code === "EPIPE") return; - throw err; - }; - process.stdout.on("error", onPipeError); - process.stderr.on("error", onPipeError); -} - -/** - * Test-only: resets the broken-pipe guard so a later `ignoreBrokenPipe()` call - * reattaches listeners. Keeps the module guard consistent with suites that - * remove the listeners they installed. - */ -export function __resetBrokenPipeGuard(): void { - brokenPipeGuarded = false; -} - -/** - * Terminate a process and wait for it to exit. - * On Unix: sends SIGTERM, falls back to SIGKILL after timeout. - * On Windows: tree-kill uses taskkill (no SIGTERM/SIGKILL distinction). - */ -function waitForProcessExit(child: ChildLike, timeoutMs: number): Promise { - const isWindows = process.platform === "win32"; - - return new Promise((resolve) => { - const pid = child.pid as number; - - // Check if this is a ChildProcess with exit event support - const proc = child as ChildProcess; - const hasExitEvent = typeof proc.on === "function" && typeof proc.exitCode !== "undefined"; - - // If process already exited, resolve immediately - if (hasExitEvent && proc.exitCode !== null) { - resolve(); - return; - } - - let resolved = false; - const done = () => { - if (resolved) return; - resolved = true; - resolve(); - }; - - // Set up timeout for force-kill fallback - const timeout = setTimeout(() => { - console.log(`[kandev] process ${pid} did not exit in time, force killing`); - // On Windows, tree-kill always force-kills (no signal distinction) - // On Unix, escalate to SIGKILL - kill(pid, isWindows ? undefined : "SIGKILL", done); - }, timeoutMs); - - // Listen for exit event if available - if (hasExitEvent) { - proc.once("exit", () => { - clearTimeout(timeout); - done(); - }); - } - - // Graceful termination: SIGTERM on Unix, default kill on Windows - kill(pid, isWindows ? undefined : "SIGTERM", (err) => { - // If kill fails (process already gone), we're done - if (err) { - clearTimeout(timeout); - done(); - } - // If no exit event support, resolve after a brief delay - // (tree-kill callback fires when signal sent, not when process exits) - if (!hasExitEvent) { - // For non-ChildProcess objects, we can't wait for exit event - // Just wait for the timeout - } - }); - }); -} diff --git a/apps/cli/src/release-config.test.ts b/apps/cli/src/release-config.test.ts deleted file mode 100644 index bbeb68df90..0000000000 --- a/apps/cli/src/release-config.test.ts +++ /dev/null @@ -1,557 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; - -const repoRoot = resolve(__dirname, "../../.."); -const externalPnpmWorkflowFiles = new Set([".github/workflows/notify-docs.yml"]); - -function readRepoFile(path: string): string { - return readFileSync(resolve(repoRoot, path), "utf8"); -} - -function runtimePackagesIn(source: string): string[] { - return [...new Set(source.match(/@kdlbs\/runtime-[a-z0-9-]+/g) ?? [])].sort(); -} - -function workflowFiles(): string[] { - const workflowDir = resolve(repoRoot, ".github/workflows"); - return readdirSync(workflowDir) - .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml")) - .map((name) => `.github/workflows/${name}`); -} - -function extractDockerPnpmVersion(dockerfile: string): string | undefined { - return dockerfile.match(/^ARG PNPM_VERSION=([0-9]+\.[0-9]+\.[0-9]+)$/m)?.[1]; -} - -function extractDockerNodeMajor(dockerfile: string): string | undefined { - return dockerfile.match(/^ARG NODE_MAJOR=([0-9]+)$/m)?.[1]; -} - -function extractPackageManagerPnpmVersion(packageJSON: string): string { - const packageManager = (JSON.parse(packageJSON) as { packageManager?: string }).packageManager; - const version = packageManager?.match(/^pnpm@([0-9]+\.[0-9]+\.[0-9]+)$/)?.[1]; - expect(version, "apps/package.json: packageManager must pin pnpm").toBeDefined(); - if (version === undefined) { - throw new Error("apps/package.json: packageManager must pin pnpm"); - } - return version; -} - -function indentation(line: string): number { - return line.search(/\S/); -} - -function isBlankOrComment(line: string): boolean { - const trimmed = line.trim(); - return trimmed.length === 0 || trimmed.startsWith("#"); -} - -function parseVersionLine(line: string): string | undefined { - const match = line.match(/^\s*version:\s*(?:"([^"]+)"|'([^']+)'|([^"'\s#]+))\s*(?:#.*)?$/); - return match?.[1] ?? match?.[2] ?? match?.[3]; -} - -function findVersionInWithBlock( - lines: string[], - start: number, - withIndent: number, -): string | undefined { - for (let index = start; index < lines.length; index += 1) { - const line = lines[index]; - if (isBlankOrComment(line)) { - continue; - } - if (indentation(line) <= withIndent) { - return undefined; - } - - const version = parseVersionLine(line); - if (version !== undefined) { - return version; - } - } - - return undefined; -} - -function findPnpmSetupVersion( - lines: string[], - start: number, - stepIndent: number, -): string | undefined { - for (let index = start; index < lines.length; index += 1) { - const line = lines[index]; - if (isBlankOrComment(line)) { - continue; - } - if (indentation(line) <= stepIndent) { - return undefined; - } - - const withMatch = line.match(/^(\s*)with:\s*(?:#.*)?$/); - if (withMatch !== null) { - return findVersionInWithBlock(lines, index + 1, withMatch[1].length); - } - } - - return undefined; -} - -function matchPnpmSetupUsesLine(line: string): RegExpMatchArray | null { - const match = line.match( - /^(\s*)(?:-\s*)?uses:\s*["']?pnpm\/action-setup@([^"'\s#]+)["']?\s*(#.*)?$/, - ); - if (match === null) return null; - const ref = match[2]; - const comment = match[3] ?? ""; - const versionTagPattern = /^v[0-9]+(?:\.[0-9]+\.[0-9]+)?$/; - const versionTag = versionTagPattern.test(ref); - const shaPinned = - /^[0-9a-f]{40}$/.test(ref) && /^#\s*v[0-9]+(?:\.[0-9]+\.[0-9]+)?$/.test(comment); - expect( - versionTag || shaPinned, - "pnpm/action-setup must use a version tag or a 40-character SHA with the version tag in a comment", - ).toBe(true); - return match; -} - -function findStepIndent(lines: string[], usesLineIndex: number, usesIndent: number): number { - if (lines[usesLineIndex].trimStart().startsWith("- ")) { - return usesIndent; - } - - for (let index = usesLineIndex - 1; index >= 0; index -= 1) { - const line = lines[index]; - if (isBlankOrComment(line)) { - continue; - } - - const lineIndent = indentation(line); - if (lineIndent < usesIndent && line.slice(lineIndent).startsWith("- ")) { - return lineIndent; - } - } - - return Math.max(0, usesIndent - 2); -} - -function extractWorkflowPnpmVersions(workflow: string): Array { - const lines = workflow.split(/\r?\n/); - const versions: Array = []; - - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]; - const setupMatch = matchPnpmSetupUsesLine(line); - if (setupMatch !== null) { - const stepIndent = findStepIndent(lines, index, setupMatch[1].length); - versions.push(findPnpmSetupVersion(lines, index + 1, stepIndent)); - } - } - - return versions; -} - -function assertWorkflowPnpmVersions(file: string, expectedVersion?: string): number { - const versions = extractWorkflowPnpmVersions(readRepoFile(file)); - for (const version of versions) { - if (version === undefined) { - expect(version, `${file}: pnpm/action-setup step is missing a version pin`).toBeDefined(); - continue; - } - - if (expectedVersion !== undefined) { - expect(version, `${file}: pnpm/action-setup version must match apps/package.json`).toBe( - expectedVersion, - ); - } - } - - return versions.length; -} - -describe("release runtime tooling configuration", () => { - it("rejects malformed SHA version comments for pnpm/action-setup", () => { - expect(() => - matchPnpmSetupUsesLine( - "uses: pnpm/action-setup@0123456789abcdef0123456789abcdef01234567 # v4-old", - ), - ).toThrow(/40-character SHA with the version tag/); - }); - - it("pins runtime tooling consistently across Docker and GitHub Actions", () => { - const packagePnpmVersion = extractPackageManagerPnpmVersion(readRepoFile("apps/package.json")); - const dockerfile = readRepoFile("Dockerfile"); - const universalDockerfile = readRepoFile("Dockerfile.universal"); - const dockerPnpmVersion = extractDockerPnpmVersion(dockerfile); - const universalDockerPnpmVersion = extractDockerPnpmVersion(universalDockerfile); - const dockerNodeMajor = extractDockerNodeMajor(dockerfile); - - expect(dockerfile).not.toContain("pnpm@latest"); - expect(dockerNodeMajor, "Dockerfile: NODE_MAJOR must stay on the release Node line").toBe("24"); - expect(dockerfile).toContain("AS apt-keys"); - expect(dockerfile).toContain( - "COPY --from=apt-keys /nodesource.gpg /usr/share/keyrings/nodesource.gpg", - ); - expect(dockerfile).toContain( - "COPY --from=apt-keys /microsoft.gpg /usr/share/keyrings/microsoft.gpg", - ); - expect(dockerfile).toContain("https://deb.nodesource.com/node_${NODE_MAJOR}.x"); - expect(dockerfile).toContain("/etc/apt/sources.list.d/azure-cli.sources"); - expect(dockerfile).not.toContain("InstallAzureCLIDeb"); - expect(dockerfile).not.toContain(" gnupg \\"); - expect(dockerfile).toMatch( - /rm -f[\s\\]+\/etc\/apt\/sources\.list\.d\/nodesource\.list[\s\\]+\/etc\/apt\/sources\.list\.d\/azure-cli\.sources[\s\\]+\/usr\/share\/keyrings\/nodesource\.gpg[\s\\]+\/usr\/share\/keyrings\/microsoft\.gpg/, - ); - expect(dockerfile).toMatch(/\bnodejs\b/); - expect(dockerfile).toMatch(/\bazure-cli\b/); - if (dockerPnpmVersion !== undefined) { - expect(dockerPnpmVersion, "Dockerfile: PNPM_VERSION must match apps/package.json").toBe( - packagePnpmVersion, - ); - } - expect( - universalDockerPnpmVersion, - "Dockerfile.universal: PNPM_VERSION must match apps/package.json", - ).toBe(packagePnpmVersion); - expect(universalDockerfile).not.toContain("pnpm@latest"); - expect(universalDockerfile).not.toContain("corepack enable"); - expect(universalDockerfile).not.toContain("corepack prepare"); - expect(universalDockerfile).toContain( - 'npm install -g --prefix /usr/local "pnpm@${PNPM_VERSION}"', - ); - - const workflowSetupCount = workflowFiles().reduce((count, file) => { - const expectedVersion = externalPnpmWorkflowFiles.has(file) ? undefined : packagePnpmVersion; - return count + assertWorkflowPnpmVersions(file, expectedVersion); - }, 0); - expect(workflowSetupCount).toBeGreaterThan(0); - }); -}); - -describe("release npm publishing", () => { - it("accepts either GitHub release assets or local nightly assets", () => { - const script = readRepoFile("scripts/release/publish-npm.sh"); - - for (const flag of ["--version", "--dist-tag", "--release-tag", "--assets-dir"]) { - expect(script).toContain(flag); - } - expect(script).toContain('DIST_TAG=""'); - expect(script).toContain('case "$DIST_TAG" in'); - expect(script).toContain("latest)"); - expect(script).toContain("nightly)"); - expect(script).toContain('if [[ -n "$RELEASE_TAG" && -n "$SOURCE_ASSETS_DIR" ]]'); - expect(script).toContain('elif [[ -z "$RELEASE_TAG" && -z "$SOURCE_ASSETS_DIR" ]]'); - expect(script).toContain('elif [[ "$DIST_TAG" == "latest" && -z "$RELEASE_TAG" ]]'); - expect(script).toContain('elif [[ "$DIST_TAG" == "nightly" && -z "$SOURCE_ASSETS_DIR" ]]'); - expect(script).toContain('ASSETS_DIR="$SOURCE_ASSETS_DIR"'); - }); - - it("publishes all packages under the requested dist-tag and pins the launcher version", () => { - const script = readRepoFile("scripts/release/publish-npm.sh"); - const packages = readRepoFile("scripts/release/npm-packages.sh"); - - const loopStart = script.indexOf('for pkg in "${RUNTIME_PACKAGES[@]}"; do'); - const loopEnd = script.indexOf("\ndone", loopStart); - expect(loopStart).toBeGreaterThanOrEqual(0); - expect(loopEnd).toBeGreaterThan(loopStart); - const runtimePublishLoop = script.slice(loopStart, loopEnd); - - expect(runtimePublishLoop).toContain( - 'npm publish --access public --provenance --tag "$DIST_TAG"', - ); - const mainPublish = script.slice(script.indexOf("Publishing kandev@$VERSION")); - expect(mainPublish).toContain('npm publish --access public --provenance --tag "$DIST_TAG"'); - expect(script).toContain("pkg.version = version;"); - expect(script).toContain('CLI_PACKAGE_BACKUP="$WORK_DIR/cli-package.json"'); - expect(script).toContain('cp "$CLI_PACKAGE_BACKUP" "$CLI_PACKAGE_JSON"'); - for (const packageName of [ - "@kdlbs/runtime-linux-x64", - "@kdlbs/runtime-linux-arm64", - "@kdlbs/runtime-darwin-x64", - "@kdlbs/runtime-darwin-arm64", - "@kdlbs/runtime-win32-x64", - ]) { - expect(packages).toContain(`"${packageName}"`); - } - expect(packages).toContain('NIGHTLY_PACKAGES=("kandev" "${RUNTIME_PACKAGES[@]}")'); - expect(script).toContain('source "$ROOT_DIR/scripts/release/npm-packages.sh"'); - }); - - it("keeps every runtime package consumer aligned with the publishing inventory", () => { - const inventory = runtimePackagesIn(readRepoFile("scripts/release/npm-packages.sh")); - expect(inventory.length).toBeGreaterThan(0); - - for (const path of [ - "apps/cli/src/runtime.ts", - "apps/cli/bin/native-shim.js", - "apps/cli/package.json", - ]) { - expect(runtimePackagesIn(readRepoFile(path)), path).toEqual(inventory); - } - expect(readRepoFile("scripts/release/package-npm-runtime.sh")).toContain( - 'source "$ROOT_DIR/scripts/release/npm-packages.sh"', - ); - expect(readRepoFile("scripts/release/publish-npm.test.mjs")).toContain("loadRuntimeInventory"); - }); - - it("only treats an existing nightly version as idempotent when the nightly tag matches", () => { - const script = readRepoFile("scripts/release/publish-npm.sh"); - - expect(script).toContain('npm_view_version "${pkg}@${VERSION}"'); - expect(script).toContain('npm_view_version "${pkg}@${DIST_TAG}"'); - expect(script).toContain('if [[ "$DIST_TAG" == "nightly" ]]'); - expect(script).toContain('if [[ "$tagged_version" != "$VERSION" ]]'); - expect(script).toContain("refusing idempotent success"); - expect(script).toContain("EPUBLISHCONFLICT"); - expect(script).toContain("treated as idempotent success"); - }); -}); - -describe("release desktop artifacts", () => { - const desktopPlatforms = ["macos-arm64", "macos-x64", "linux-x64", "linux-arm64", "windows-x64"]; - - function releaseWorkflow(): string { - return readRepoFile(".github/workflows/release.yml"); - } - - it("builds desktop artifacts for every supported runtime platform", () => { - const workflow = releaseWorkflow(); - - expect(workflow).toMatch(/\n build-desktop:\n/); - expect(workflow).toContain("needs: [prepare, build-bundles]"); - expect(workflow).toContain("scripts/release/prepare-desktop-runtime.sh"); - expect(workflow).toContain('--platform "${{ matrix.platform }}"'); - expect(workflow).toContain( - 'rustup toolchain install stable --profile minimal --target "${{ matrix.rust_target }}"', - ); - expect(workflow).toMatch(/Swatinem\/rust-cache@[0-9a-f]{40}\s+# v2/); - - for (const platform of desktopPlatforms) { - expect(workflow, `release.yml must include desktop platform ${platform}`).toContain( - `platform: ${platform}`, - ); - } - }); - - it("publishes desktop artifacts while leaving npm and Homebrew tied to runtime tarballs", () => { - const workflow = releaseWorkflow(); - const checksumScript = readRepoFile("scripts/release/write-sha256.sh"); - const verifyAssetsScript = readRepoFile("scripts/release/verify-desktop-assets.sh"); - const publishNpmScript = readRepoFile("scripts/release/publish-npm.sh"); - const homebrewScript = readRepoFile("scripts/release/update-homebrew-tap.sh"); - - expect(workflow).toContain( - "needs: [prepare, build-bundles, build-desktop, docker-universal-manifest]", - ); - expect(workflow).toContain("pattern: desktop-*"); - expect(workflow).toContain("scripts/release/verify-desktop-assets.sh"); - expect(workflow).toContain("dist/release-assets/kandev-desktop-*"); - expect(workflow).toContain('scripts/release/write-sha256.sh "$dest" "$dest.sha256"'); - expect(workflow).not.toContain('shasum -a 256 "$(basename "$dest")"'); - expect(checksumScript).toContain("command -v shasum"); - expect(checksumScript).toContain("command -v sha256sum"); - expect(verifyAssetsScript).toContain("command -v shasum"); - expect(verifyAssetsScript).toContain("command -v sha256sum"); - - expect(publishNpmScript).toContain('asset="kandev-${platform}.tar.gz"'); - expect(publishNpmScript).not.toContain("kandev-desktop-"); - expect(homebrewScript).toContain('local sha_file="kandev-${platform}.tar.gz.sha256"'); - expect(homebrewScript).not.toContain("kandev-desktop-"); - }); - - it("keeps the generated tap formula on the installed runtime smoke contract", () => { - const formula = readRepoFile("scripts/release/kandev.rb"); - const updater = readRepoFile("scripts/release/update-homebrew-tap.sh"); - const versionDeclaration = 'version "__VERSION__"'; - - expect(formula).toContain( - 'assert_equal "v#{version}", shell_output("#{bin}/kandev --version").strip', - ); - expect(formula).toContain('spawn bin/"kandev", "--headless", "--port", port.to_s'); - expect(formula).toContain("/health"); - expect(formula).toContain('"status":"ok"'); - expect(formula).toContain("Kandev"); - expect(formula).toContain(versionDeclaration); - expect(formula.indexOf(versionDeclaration)).toBeLessThan( - formula.indexOf('license "AGPL-3.0-only"'), - ); - expect(updater).toContain('-e "s|__VERSION__|$VERSION|g"'); - }); - - it("bumps desktop package and Tauri versions during release preparation", () => { - const workflow = releaseWorkflow(); - - expect(workflow).toContain('(cd apps/desktop && npm version --no-git-tag-version "$NEXT")'); - expect(workflow).toContain("tauriConfig.version = next"); - expect(workflow).toContain('cargoToml.replace(/^version = ".*"$/m, `version = "${next}"`)'); - expect(workflow).toContain('name = "kandev-desktop"'); - expect(workflow).toContain("apps/desktop/src-tauri/tauri.conf.json"); - expect(workflow).toContain("apps/desktop/src-tauri/Cargo.toml"); - expect(workflow).toContain("apps/desktop/src-tauri/Cargo.lock"); - }); - - it("pins linux x64 desktop release builds to Ubuntu 22.04", () => { - const workflow = releaseWorkflow(); - - expect(workflow).toContain(`- os: ubuntu-22.04 - platform: linux-x64 - goos: linux - goarch: amd64`); - expect(workflow).toContain(`- platform: linux-x64 - os: ubuntu-22.04 - rust_target: x86_64-unknown-linux-gnu - tauri_bundles: appimage,deb,rpm`); - }); - - it("builds and publishes a complete signed Tauri updater feed", () => { - const workflow = releaseWorkflow(); - const tauriConfig = JSON.parse(readRepoFile("apps/desktop/src-tauri/tauri.conf.json")); - const manifestScript = readRepoFile("scripts/release/updater-manifest.mjs"); - const verifyAssetsScript = readRepoFile("scripts/release/verify-desktop-assets.sh"); - - expect(tauriConfig.bundle.createUpdaterArtifacts).toBe(false); - expect(workflow).toContain( - "TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}", - ); - expect(workflow).toContain( - "TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}", - ); - expect(workflow).toContain('createUpdaterArtifacts":"v1Compatible"'); - expect(workflow).toContain("scripts/release/updater-manifest.mjs generate"); - expect(workflow).toContain("scripts/release/updater-manifest.mjs verify"); - expect(workflow).toContain("dist/release-assets/latest.json"); - - for (const target of [ - "darwin-aarch64", - "darwin-x86_64", - "linux-aarch64", - "linux-x86_64", - "windows-x86_64", - ]) { - expect(manifestScript, `updater manifest must include ${target}`).toContain(`"${target}"`); - } - - for (const extension of ["*.app.tar.gz", "*.AppImage.tar.gz", "*.nsis.zip", "*.sig"]) { - expect(workflow, `desktop collector must retain ${extension}`).toContain( - `-name '${extension}'`, - ); - expect(verifyAssetsScript, `asset verification must recognize ${extension}`).toContain( - extension, - ); - } - }); - - it("keeps updater signing independent from platform identity signing", () => { - const workflow = releaseWorkflow(); - const signingDocs = readRepoFile("docs/desktop-tauri-signing.md"); - const updaterSigningScript = readRepoFile("scripts/release/updater-signing-ready.sh"); - - for (const key of ["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"]) { - expect(workflow).toContain(key); - expect(signingDocs).toContain(key); - } - expect(signingDocs).toContain("distinct from macOS and Windows code-signing credentials"); - expect(signingDocs).toContain("Installers remain available"); - expect(signingDocs).toContain("`latest.json` is not"); - expect(workflow).toContain('bash "$helper" "${{ matrix.platform }}"'); - expect(updaterSigningScript).not.toContain("MACOS_SIGNING_ENABLED"); - expect(updaterSigningScript).not.toContain("WINDOWS_SIGNING_ENABLED"); - expect(signingDocs).toContain("Missing OS credentials do not block Tauri-signed updater"); - expect(workflow).not.toContain( - "Refusing to build an updater artifact that is not also signed for its operating system.", - ); - }); - - it("builds unsigned desktop releases when signing inputs are incomplete", () => { - const workflow = releaseWorkflow(); - const signingDocs = readRepoFile("docs/desktop-tauri-signing.md"); - const tauriConfig = readRepoFile("apps/desktop/src-tauri/tauri.conf.json"); - const windowsSignScript = readRepoFile("apps/desktop/src-tauri/windows-sign.ps1"); - - expect(workflow).not.toContain("allow_unsigned_desktop"); - expect(workflow).not.toContain("ALLOW_UNSIGNED_DESKTOP"); - expect(workflow).toContain("desktop_validation_only"); - expect(workflow).toContain("ref: ${{ needs.prepare.outputs.ref }}"); - expect(workflow).toContain("persist-credentials: false"); - expect(workflow).toContain("Desktop validation summary"); - expect(workflow).toContain("No release PR, tag, GitHub release, public container tags"); - expect(workflow).toMatch( - /github\.event_name == 'workflow_dispatch' &&\s+inputs\.channel == 'stable' &&\s+!inputs\.dry_run &&\s+!inputs\.desktop_validation_only/, - ); - expect(workflow).toContain('if [ "$DESKTOP_VALIDATION_ONLY" = "true" ]; then'); - expect(workflow).toContain("scripts/release/desktop-signing-ready.sh macos"); - expect(workflow).toContain("scripts/release/desktop-signing-ready.sh windows"); - expect(workflow).toContain("MACOS_SIGNING_ENABLED=false"); - expect(workflow).toContain("MACOS_SIGNING_ENABLED=true"); - expect(workflow).toContain("WINDOWS_SIGNING_ENABLED=false"); - expect(workflow).toContain("WINDOWS_SIGNING_ENABLED=true"); - expect(workflow).toContain( - "macOS signing/notarization inputs are incomplete; building unsigned desktop artifact.", - ); - expect(workflow).toContain( - "Windows signing inputs are incomplete; building unsigned desktop artifact.", - ); - expect(workflow).toContain("Add unsigned desktop warning to release notes"); - expect(workflow).toContain( - "desktop installers in this release are unsigned development builds.", - ); - expect(workflow).toContain("docker-amd64:"); - expect(workflow).toContain("docker-universal-manifest:"); - expect(workflow).toContain("unset APPLE_CERTIFICATE"); - expect(workflow).toContain("unset APPLE_ID"); - expect(workflow).toContain("unset WINDOWS_CERTIFICATE"); - expect(workflow).toContain("unset WINDOWS_CERTIFICATE_PASSWORD"); - expect(workflow).toContain("unset WINDOWS_TIMESTAMP_URL"); - expect(workflow).toContain("unset WINDOWS_SIGNTOOL_PATH"); - - for (const key of [ - "APPLE_CERTIFICATE", - "APPLE_CERTIFICATE_PASSWORD", - "KEYCHAIN_PASSWORD", - "APPLE_SIGNING_IDENTITY", - "APPLE_API_KEY_P8", - "APPLE_API_KEY_PATH", - "APPLE_TEAM_ID", - ]) { - expect(workflow, `release.yml must wire ${key}`).toContain(key); - } - - for (const key of ["WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD", "signtool sign"]) { - expect(workflow, `release.yml must wire ${key}`).toContain(key); - } - expect(workflow).toContain("Invoke-SignTool"); - expect(workflow).toContain("$LASTEXITCODE -ne 0"); - expect(workflow).toContain("Sign macOS desktop runtime binaries"); - expect(workflow).toContain("resources/kandev/bin"); - expect(workflow).toContain( - "for binary in kandev agentctl agentctl-darwin-arm64 agentctl-darwin-amd64; do", - ); - expect(workflow).toContain( - 'codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY"', - ); - expect(workflow).toContain("codesign --verify --strict"); - expect(workflow).toContain("Sign Windows desktop runtime binaries"); - expect(workflow).toContain('foreach ($binary in @("kandev.exe", "agentctl.exe"))'); - expect(workflow).toContain("Remove-Item -LiteralPath $certificatePath"); - - expect(tauriConfig).toContain('"publisher": "Kandev"'); - expect(tauriConfig).toContain('"timestampUrl"'); - expect(tauriConfig).toContain('"timestampUrl": "https://timestamp.digicert.com"'); - expect(tauriConfig).toContain('"signCommand"'); - expect(tauriConfig).toContain("windows-sign.ps1"); - expect(tauriConfig).not.toContain('"csp": null'); - expect(windowsSignScript).toContain('"https://timestamp.digicert.com"'); - expect(windowsSignScript).toContain('WINDOWS_SIGNING_ENABLED -eq "false"'); - expect(windowsSignScript).not.toContain("ALLOW_UNSIGNED_DESKTOP"); - expect(windowsSignScript).toContain("Skipping Windows signing for unsigned desktop artifact"); - expect(windowsSignScript).toContain("Remove-Item -LiteralPath $certificatePath"); - expect(signingDocs).toContain("signs desktop artifacts opportunistically"); - expect(signingDocs).toContain("Missing or incomplete inputs do not block the release"); - expect(signingDocs).not.toContain("allow_unsigned_desktop"); - expect(signingDocs).toContain("desktop_validation_only"); - expect(signingDocs).toContain("does not publish a GitHub release"); - expect(signingDocs).toContain("public container tags"); - expect(signingDocs).toContain("Ubuntu 22.04"); - }); -}); diff --git a/apps/cli/src/run.test.ts b/apps/cli/src/run.test.ts deleted file mode 100644 index d07723de1b..0000000000 --- a/apps/cli/src/run.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { EventEmitter } from "node:events"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { getBinaryName } from "./platform"; -import { - attachRingBuffer, - buildReleaseLaunchOptions, - cleanOldReleases, - findCachedRelease, -} from "./run"; - -// Mock CACHE_DIR to use a temp directory. -const mockCacheDir = { value: "" }; -vi.mock("./constants", () => ({ - get CACHE_DIR() { - return mockCacheDir.value; - }, - resolveCacheDir: () => mockCacheDir.value, - DATA_DIR: "/tmp/kandev-test-data", - resolveDataDir: () => "/tmp/kandev-test-data", - resolveDatabasePath: () => "/tmp/kandev-test-data/kandev.db", - DEFAULT_BACKEND_PORT: 38429, - DEFAULT_WEB_PORT: 37429, - DEFAULT_AGENTCTL_PORT: 39429, - HEALTH_TIMEOUT_MS_RELEASE: 15000, - RANDOM_PORT_MIN: 10000, - RANDOM_PORT_MAX: 60000, - RANDOM_PORT_RETRIES: 10, -})); - -const PLATFORM = "macos-arm64"; - -function createFakeCachedRelease(cacheDir: string, tag: string, platform: string) { - const binDir = path.join(cacheDir, tag, platform, "kandev", "bin"); - fs.mkdirSync(binDir, { recursive: true }); - // findCachedRelease looks up the binary by host-platform name (kandev on - // Unix, kandev.exe on Windows) via getBinaryName, so the fixture must match - // or the lookup never resolves on Windows. - fs.writeFileSync(path.join(binDir, getBinaryName("kandev")), "fake-binary"); -} - -describe("findCachedRelease", () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-test-")); - mockCacheDir.value = tmpDir; - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - describe("with specific version", () => { - it("returns cache info when version exists", () => { - createFakeCachedRelease(tmpDir, "v0.2", PLATFORM); - const result = findCachedRelease(PLATFORM, "v0.2"); - expect(result).not.toBeNull(); - expect(result!.tag).toBe("v0.2"); - expect(result!.cacheDir).toBe(path.join(tmpDir, "v0.2", PLATFORM)); - }); - - it("returns null when version does not exist", () => { - const result = findCachedRelease(PLATFORM, "v9.9"); - expect(result).toBeNull(); - }); - - it("returns null when tag dir exists but no binary", () => { - fs.mkdirSync(path.join(tmpDir, "v0.1", PLATFORM), { recursive: true }); - const result = findCachedRelease(PLATFORM, "v0.1"); - expect(result).toBeNull(); - }); - }); - - describe("without version (latest)", () => { - it("picks the highest semver tag", () => { - createFakeCachedRelease(tmpDir, "v0.1", PLATFORM); - createFakeCachedRelease(tmpDir, "v0.9", PLATFORM); - createFakeCachedRelease(tmpDir, "v1.2", PLATFORM); - const result = findCachedRelease(PLATFORM); - expect(result).not.toBeNull(); - expect(result!.tag).toBe("v1.2"); - }); - - it("sorts major version correctly: v2.0 > v1.9", () => { - createFakeCachedRelease(tmpDir, "v1.9", PLATFORM); - createFakeCachedRelease(tmpDir, "v2.0", PLATFORM); - const result = findCachedRelease(PLATFORM); - expect(result!.tag).toBe("v2.0"); - }); - - it("sorts numerically not lexicographically: v1.10 > v1.9", () => { - createFakeCachedRelease(tmpDir, "v1.9", PLATFORM); - createFakeCachedRelease(tmpDir, "v1.10", PLATFORM); - const result = findCachedRelease(PLATFORM); - expect(result!.tag).toBe("v1.10"); - }); - - it("skips tags without extracted binary", () => { - createFakeCachedRelease(tmpDir, "v0.1", PLATFORM); - // v0.2 exists as dir but no binary inside - fs.mkdirSync(path.join(tmpDir, "v0.2", PLATFORM), { recursive: true }); - const result = findCachedRelease(PLATFORM); - expect(result!.tag).toBe("v0.1"); - }); - - it("returns null when cache dir does not exist", () => { - mockCacheDir.value = path.join(tmpDir, "nonexistent"); - const result = findCachedRelease(PLATFORM); - expect(result).toBeNull(); - }); - - it("returns null when cache dir is empty", () => { - const result = findCachedRelease(PLATFORM); - expect(result).toBeNull(); - }); - - it("ignores non-v prefixed directories", () => { - fs.mkdirSync(path.join(tmpDir, "latest", PLATFORM), { recursive: true }); - fs.mkdirSync(path.join(tmpDir, "temp", PLATFORM), { recursive: true }); - const result = findCachedRelease(PLATFORM); - expect(result).toBeNull(); - }); - - it("picks valid release when highest tag has no binary", () => { - createFakeCachedRelease(tmpDir, "v0.1", PLATFORM); - createFakeCachedRelease(tmpDir, "v0.2", PLATFORM); - // v1.0 dir exists but is empty - fs.mkdirSync(path.join(tmpDir, "v1.0", PLATFORM), { recursive: true }); - const result = findCachedRelease(PLATFORM); - expect(result!.tag).toBe("v0.2"); - }); - }); -}); - -describe("cleanOldReleases", () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-clean-test-")); - mockCacheDir.value = tmpDir; - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it("keeps the 2 most recent tags and removes the rest", () => { - for (const tag of ["v0.1", "v0.2", "v0.3", "v0.4"]) { - fs.mkdirSync(path.join(tmpDir, tag), { recursive: true }); - } - cleanOldReleases("v0.4"); - const remaining = fs.readdirSync(tmpDir).sort(); - expect(remaining).toEqual(["v0.3", "v0.4"]); - }); - - it("does nothing when 2 or fewer tags exist", () => { - fs.mkdirSync(path.join(tmpDir, "v0.1"), { recursive: true }); - fs.mkdirSync(path.join(tmpDir, "v0.2"), { recursive: true }); - cleanOldReleases("v0.2"); - const remaining = fs.readdirSync(tmpDir).sort(); - expect(remaining).toEqual(["v0.1", "v0.2"]); - }); - - it("always keeps currentTag even if not in top 2 by semver", () => { - for (const tag of ["v0.1", "v1.0", "v2.0"]) { - fs.mkdirSync(path.join(tmpDir, tag), { recursive: true }); - } - // currentTag is v0.1 (old), but top 2 by semver are v2.0, v1.0 - cleanOldReleases("v0.1"); - const remaining = fs.readdirSync(tmpDir).sort(); - expect(remaining).toEqual(["v0.1", "v1.0", "v2.0"]); - }); - - it("does nothing when cache dir does not exist", () => { - mockCacheDir.value = path.join(tmpDir, "nonexistent"); - expect(() => cleanOldReleases("v1.0")).not.toThrow(); - }); - - it("ignores non-v prefixed directories", () => { - fs.mkdirSync(path.join(tmpDir, "latest"), { recursive: true }); - fs.mkdirSync(path.join(tmpDir, "temp"), { recursive: true }); - for (const tag of ["v0.1", "v0.2", "v0.3"]) { - fs.mkdirSync(path.join(tmpDir, tag), { recursive: true }); - } - cleanOldReleases("v0.3"); - const remaining = fs.readdirSync(tmpDir).sort(); - expect(remaining).toEqual(["latest", "temp", "v0.2", "v0.3"]); - }); - - it("sorts numerically: keeps v1.10 over v1.9 when both are old", () => { - for (const tag of ["v1.1", "v1.9", "v1.10", "v2.0"]) { - fs.mkdirSync(path.join(tmpDir, tag), { recursive: true }); - } - cleanOldReleases("v2.0"); - const remaining = fs.readdirSync(tmpDir).sort(); - expect(remaining).toEqual(["v1.10", "v2.0"]); - }); -}); - -describe("attachRingBuffer", () => { - it("accumulates data chunks as utf8", () => { - const stream = new EventEmitter() as EventEmitter & NodeJS.ReadableStream; - const read = attachRingBuffer(stream, 1024); - stream.emit("data", Buffer.from("hello ")); - stream.emit("data", "world"); - expect(read()).toBe("hello world"); - }); - - it("trims old data once maxChars is exceeded, keeping the tail", () => { - const stream = new EventEmitter() as EventEmitter & NodeJS.ReadableStream; - const read = attachRingBuffer(stream, 10); - stream.emit("data", "0123456789"); - stream.emit("data", "ABCDE"); - const buf = read(); - expect(buf.length).toBeLessThanOrEqual(10); - expect(buf.endsWith("ABCDE")).toBe(true); - expect(buf).not.toContain("0123"); - }); - - it("returns empty string when stream is null", () => { - const read = attachRingBuffer(null); - expect(read()).toBe(""); - }); -}); - -describe("buildReleaseLaunchOptions", () => { - it("preserves the explicit backend port source for release launches", () => { - expect( - buildReleaseLaunchOptions({ - runtimeVersion: "v1.2.3", - backendPort: 18080, - backendPortSource: "KANDEV_BACKEND_PORT", - verbose: true, - debug: true, - }), - ).toEqual({ - runtimeVersion: "v1.2.3", - backendPort: 18080, - backendPortSource: "KANDEV_BACKEND_PORT", - verbose: true, - debug: true, - }); - }); -}); diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts deleted file mode 100644 index b51565e4fe..0000000000 --- a/apps/cli/src/run.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { spawn } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; - -import type { BackendPortSource } from "./args"; -import { ensureExtracted, findBundleRoot } from "./bundle"; -import { - DEFAULT_AGENTCTL_PORT, - DEFAULT_BACKEND_PORT, - HEALTH_TIMEOUT_MS_RELEASE, - resolveCacheDir, - resolveDataDir, - resolveDatabasePath, - resolveKandevHomeDir, -} from "./constants"; -import { ensureAsset, getRelease } from "./github"; -import { resolveHealthTimeoutMs, waitForHealth } from "./health"; -import { getBinaryName, getPlatformDir } from "./platform"; -import { sortVersionsDesc } from "./version"; -import { createProcessSupervisor } from "./process"; -import { resolveRuntime, validateBundle } from "./runtime"; -import { - buildBackendEnv, - createOutputRingBuffer, - logStartupInfo, - pickBackendPorts, - resolveBackendLogLevels, -} from "./shared"; -import { launchRestartableBackend } from "./supervisor/backend"; -import { openBrowser } from "./web"; - -export type RunOptions = { - runtimeVersion?: string; - backendPort?: number; - backendPortSource?: BackendPortSource; - /** Show backend info logs on stdout */ - verbose?: boolean; - /** Retain backend debug logs in its file + agent message dumps */ - debug?: boolean; - /** Skip browser open. Set by service units. */ - headless?: boolean; -}; - -type PreparedBundle = { - bundleDir: string; - backendBin: string; - backendUrl: string; - backendEnv: NodeJS.ProcessEnv; - releaseTag: string; - agentctlPort: number; - dbPath: string; - logLevel: string; -}; - -/** - * Find a cached release binary to use when GitHub is unreachable. - * If version is specified, checks that exact tag. Otherwise, picks - * the highest semver tag available in the cache. - */ -export function findCachedRelease( - platformDir: string, - version?: string, -): { cacheDir: string; tag: string } | null { - const rootCacheDir = resolveCacheDir(); - if (version) { - const cacheDir = path.join(rootCacheDir, version, platformDir); - const bundleDir = path.join(cacheDir, "kandev"); - const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); - if (fs.existsSync(backendBin)) { - return { cacheDir, tag: version }; - } - return null; - } - - // No version specified — scan for cached tags and pick the latest. - if (!fs.existsSync(rootCacheDir)) return null; - - const entries = fs.readdirSync(rootCacheDir).filter((d) => d.startsWith("v")); - if (entries.length === 0) return null; - - const sorted = sortVersionsDesc(entries); - - for (const tag of sorted) { - const cacheDir = path.join(rootCacheDir, tag, platformDir); - const bundleDir = path.join(cacheDir, "kandev"); - const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); - if (fs.existsSync(backendBin)) { - return { cacheDir, tag }; - } - } - - return null; -} - -/** - * Remove old cached releases, keeping only the 2 most recent tags. - * Runs after a successful download so we don't accumulate stale versions. - * The previous version is kept as a fallback for offline use. - */ -export function cleanOldReleases(currentTag: string) { - const rootCacheDir = resolveCacheDir(); - try { - if (!fs.existsSync(rootCacheDir)) return; - const entries = fs.readdirSync(rootCacheDir).filter((d) => d.startsWith("v")); - if (entries.length <= 2) return; - - const sorted = sortVersionsDesc(entries); - - // Always keep currentTag + the next most recent. - const keep = new Set([currentTag, sorted[0], sorted[1]]); - for (const entry of entries) { - if (!keep.has(entry)) { - fs.rmSync(path.join(rootCacheDir, entry), { recursive: true, force: true }); - } - } - } catch { - // Non-critical — don't fail the launch if cleanup errors. - } -} - -/** - * Download a specific release version into the local cache. - * Only used when --runtime-version is given explicitly. - */ -async function downloadRuntimeVersion(runtimeVersion: string): Promise { - const platformDir = getPlatformDir(); - const release = await getRelease(runtimeVersion); - const tag = release.tag_name; - const assetName = `kandev-${platformDir}.tar.gz`; - const cacheDir = path.join(resolveCacheDir(), tag, platformDir); - - const archivePath = await ensureAsset(tag, assetName, cacheDir, (downloaded, total) => { - const percent = total ? Math.round((downloaded / total) * 100) : 0; - const mb = (downloaded / (1024 * 1024)).toFixed(1); - const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?"; - process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`); - }); - process.stderr.write("\n"); - - ensureExtracted(archivePath, cacheDir); - cleanOldReleases(tag); - return tag; -} - -async function prepareBundleForLaunch({ - runtimeVersion, - backendPort, - backendPortSource, - verbose = false, - debug = false, -}: RunOptions): Promise { - let bundleDir: string; - let releaseTag: string; - - if (runtimeVersion) { - // Explicit version: ensure it is in the cache (downloading if needed), then resolve. - const platformDir = getPlatformDir(); - const cached = findCachedRelease(platformDir, runtimeVersion); - let tag: string; - if (cached) { - tag = cached.tag; - bundleDir = findBundleRoot(cached.cacheDir); - } else { - try { - tag = await downloadRuntimeVersion(runtimeVersion); - const cacheDir = path.join(resolveCacheDir(), tag, platformDir); - bundleDir = findBundleRoot(cacheDir); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - throw new Error( - `Failed to fetch runtime version ${runtimeVersion}.\n` + - ` Reason: ${reason}\n` + - ` Run kandev once while online to cache a release for offline use.`, - ); - } - } - // Validate the resolved bundle has all required binaries before launching. - validateBundle(bundleDir); - releaseTag = tag; - } else { - // Default path: resolve from KANDEV_BUNDLE_DIR or installed npm runtime package. - const resolved = resolveRuntime(); - bundleDir = resolved.bundleDir; - // Use KANDEV_VERSION if set (e.g. by Homebrew wrapper), otherwise show source. - releaseTag = process.env.KANDEV_VERSION ?? `(${resolved.source})`; - } - - const ports = await pickBackendPorts({ backendPort, backendPortSource }); - const actualBackendPort = ports.backendPort; - const agentctlPort = ports.agentctlPort; - const backendUrl = ports.backendUrl; - const levels = resolveBackendLogLevels({ verbose, debug }); - const logLevel = levels.file; - - const dataDir = resolveDataDir(); - fs.mkdirSync(dataDir, { recursive: true }); - const dbPath = resolveDatabasePath(); - - const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); - - const backendEnv = buildBackendEnv({ - ports: { - backendPort: actualBackendPort, - agentctlPort, - backendUrl, - }, - logLevel, - consoleLogLevel: levels.console, - webProxy: false, - extra: { - KANDEV_DATABASE_PATH: dbPath, - ...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true", KANDEV_DEBUG_PPROF_ENABLED: "true" } : {}), - }, - }); - - return { - bundleDir, - backendBin, - backendUrl, - backendEnv, - releaseTag, - agentctlPort, - dbPath, - logLevel, - }; -} - -export function buildReleaseLaunchOptions({ - runtimeVersion, - backendPort, - backendPortSource, - verbose = false, - debug = false, -}: RunOptions): RunOptions { - return { - runtimeVersion, - backendPort, - backendPortSource, - verbose, - debug, - }; -} - -/** - * Attach a ring buffer to a readable stream, keeping roughly the last `maxChars` - * characters. Note: the limit is measured in JS string length (UTF-16 code units), - * not bytes — fine for log output which is overwhelmingly ASCII. - */ -export function attachRingBuffer( - stream: NodeJS.ReadableStream | null, - maxChars = 64 * 1024, -): () => string { - const capture = createOutputRingBuffer(null, maxChars); - capture.attach(stream); - return capture.read; -} - -async function launchBundle(prepared: PreparedBundle): Promise<{ - supervisor: ReturnType; - backendProc: ReturnType; - dumpBackendLogs: () => void; -}> { - logStartupInfo({ - header: `release: ${prepared.releaseTag}`, - ports: { - backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT), - agentctlPort: prepared.agentctlPort, - backendUrl: prepared.backendUrl, - }, - dbPath: prepared.dbPath, - logLevel: prepared.logLevel, - }); - - const supervisor = createProcessSupervisor(); - supervisor.attachSignalHandlers(); - const output = createOutputRingBuffer(process.stdout); - - const backend = await launchRestartableBackend({ - command: prepared.backendBin, - args: [], - cwd: path.dirname(prepared.backendBin), - env: prepared.backendEnv, - homeDir: resolveKandevHomeDir(), - ports: { - backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT), - agentctlPort: prepared.agentctlPort, - backendUrl: prepared.backendUrl, - }, - mode: "run", - stdio: ["ignore", "pipe", "inherit"], - supervisor, - onProcess: (proc) => output.attach(proc.stdout), - }); - const backendProc = backend.proc; - - let dumped = false; - const dumpBackendLogs = (): void => { - if (dumped) return; - dumped = true; - const buffered = output.read(); - if (buffered.trim().length === 0) return; - console.error("[kandev] --- backend stdout (last captured output) ---"); - console.error(buffered.trimEnd()); - console.error("[kandev] --- end backend stdout ---"); - }; - - return { supervisor, backendProc, dumpBackendLogs }; -} - -export async function runRelease({ - runtimeVersion, - backendPort, - backendPortSource, - verbose = false, - debug = false, - headless = false, -}: RunOptions): Promise { - const prepared = await prepareBundleForLaunch( - buildReleaseLaunchOptions({ - runtimeVersion, - backendPort, - backendPortSource, - verbose, - debug, - }), - ); - const { backendProc, dumpBackendLogs } = await launchBundle(prepared); - const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_RELEASE); - console.log("[kandev] starting backend..."); - await waitForHealth( - prepared.backendUrl, - backendProc, - healthTimeoutMs, - prepared.backendEnv.KANDEV_DESKTOP_HEALTH_TOKEN, - dumpBackendLogs, - ); - console.log(`[kandev] backend ready at ${prepared.backendUrl}`); - - if (headless) { - console.log(`[kandev] ready (headless) at ${prepared.backendUrl}`); - return; - } - console.log("[kandev] open: " + prepared.backendUrl); - openBrowser(prepared.backendUrl); -} diff --git a/apps/cli/src/runtime.test.ts b/apps/cli/src/runtime.test.ts deleted file mode 100644 index 18a168d4fc..0000000000 --- a/apps/cli/src/runtime.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import fs from "node:fs"; -import Module from "node:module"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { resolveRuntime, validateBundle } from "./runtime"; - -// Keep getPlatformDir deterministic across test environments. -vi.mock("./platform", () => ({ - getPlatformDir: () => "macos-arm64" as const, - getBinaryName: (base: string) => base, -})); - -function createFakeBundle(dir: string) { - fs.mkdirSync(path.join(dir, "bin"), { recursive: true }); - fs.writeFileSync(path.join(dir, "bin", "kandev"), "fake"); - fs.writeFileSync(path.join(dir, "bin", "agentctl"), "fake"); - fs.mkdirSync(path.join(dir, "web"), { recursive: true }); - fs.writeFileSync(path.join(dir, "web", "index.html"), '
'); - fs.mkdirSync(path.join(dir, "web", "assets"), { recursive: true }); - fs.writeFileSync(path.join(dir, "web", "assets", "index.js"), "fake"); -} - -describe("validateBundle", () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-validate-")); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it("passes for a complete bundle", () => { - createFakeBundle(tmpDir); - expect(() => validateBundle(tmpDir)).not.toThrow(); - }); - - it("throws when backend binary is missing", () => { - fs.mkdirSync(path.join(tmpDir, "bin"), { recursive: true }); - fs.writeFileSync(path.join(tmpDir, "bin", "agentctl"), "fake"); - fs.mkdirSync(path.join(tmpDir, "web"), { recursive: true }); - fs.writeFileSync(path.join(tmpDir, "web", "index.html"), ""); - expect(() => validateBundle(tmpDir)).toThrow(/Backend binary not found/); - }); - - it("throws when agentctl is missing", () => { - fs.mkdirSync(path.join(tmpDir, "bin"), { recursive: true }); - fs.writeFileSync(path.join(tmpDir, "bin", "kandev"), "fake"); - fs.mkdirSync(path.join(tmpDir, "web"), { recursive: true }); - fs.writeFileSync(path.join(tmpDir, "web", "index.html"), ""); - expect(() => validateBundle(tmpDir)).toThrow(/agentctl binary not found/); - }); - - it("does not require external web assets", () => { - fs.mkdirSync(path.join(tmpDir, "bin"), { recursive: true }); - fs.writeFileSync(path.join(tmpDir, "bin", "kandev"), "fake"); - fs.writeFileSync(path.join(tmpDir, "bin", "agentctl"), "fake"); - expect(() => validateBundle(tmpDir)).not.toThrow(); - }); - - it("does not require a Node web server in the runtime bundle", () => { - createFakeBundle(tmpDir); - expect(() => validateBundle(tmpDir)).not.toThrow(); - }); -}); - -describe("resolveRuntime", () => { - let tmpDir: string; - const origEnv = { ...process.env }; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-runtime-")); - // Clear relevant env vars before each test - delete process.env.KANDEV_BUNDLE_DIR; - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - // Restore the original env exactly. Don't `delete process.env.KANDEV_BUNDLE_DIR` - // afterwards — if a developer had that env var set when the suite started, - // it's already in origEnv and should remain. - process.env = { ...origEnv }; - }); - - describe("KANDEV_BUNDLE_DIR (step 1)", () => { - it("resolves from KANDEV_BUNDLE_DIR when set and valid", () => { - const bundleDir = path.join(tmpDir, "bundle"); - createFakeBundle(bundleDir); - process.env.KANDEV_BUNDLE_DIR = bundleDir; - - const result = resolveRuntime(); - expect(result.source).toBe("env"); - expect(result.bundleDir).toBe(bundleDir); - }); - - it("throws when KANDEV_BUNDLE_DIR points to an invalid bundle", () => { - const bundleDir = path.join(tmpDir, "empty-bundle"); - fs.mkdirSync(bundleDir); - process.env.KANDEV_BUNDLE_DIR = bundleDir; - - expect(() => resolveRuntime()).toThrow(/Backend binary not found/); - }); - }); - - describe("installed npm runtime package (step 2)", () => { - // Step 2 normally relies on require.resolve('@kdlbs/runtime-darwin-arm64') - // succeeding because the npm package is installed alongside the kandev - // package. In this test environment that package isn't present, so we - // intercept Node's module resolver to point at a fake bundle inside tmpDir. - function mockResolve(target: string): void { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const ModuleAny = Module as unknown as { _resolveFilename: any }; - vi.spyOn(ModuleAny, "_resolveFilename").mockImplementationOnce(() => target); - } - - it("resolves from the installed npm runtime package", () => { - const pkgRoot = path.join(tmpDir, "npm-runtime"); - createFakeBundle(pkgRoot); - // require.resolve('/package.json') normally returns the path to the - // package.json inside node_modules. Point it at our fake bundle instead. - const pkgJsonPath = path.join(pkgRoot, "package.json"); - fs.writeFileSync(pkgJsonPath, "{}"); - mockResolve(pkgJsonPath); - - const result = resolveRuntime(); - expect(result.source).toBe("npm"); - expect(result.bundleDir).toBe(pkgRoot); - }); - - it("surfaces validateBundle errors when the npm package is installed but corrupt", () => { - const pkgRoot = path.join(tmpDir, "broken-npm-runtime"); - fs.mkdirSync(pkgRoot); - // package.json present (so require.resolve succeeds), but no bin/ or web/ - const pkgJsonPath = path.join(pkgRoot, "package.json"); - fs.writeFileSync(pkgJsonPath, "{}"); - mockResolve(pkgJsonPath); - - expect(() => resolveRuntime()).toThrow(/Backend binary not found/); - }); - }); - - describe("no runtime found", () => { - it("throws an actionable error message mentioning install paths", () => { - // Ensure require.resolve fails so we reach the "no runtime found" path. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const ModuleAny = Module as unknown as { _resolveFilename: any }; - vi.spyOn(ModuleAny, "_resolveFilename").mockImplementationOnce(() => { - const err = new Error("Cannot find module") as Error & { code: string }; - err.code = "MODULE_NOT_FOUND"; - throw err; - }); - - let error: Error | null = null; - try { - resolveRuntime(); - } catch (e) { - error = e as Error; - } - expect(error).not.toBeNull(); - expect(error!.message).toMatch(/npx kandev@latest/); - expect(error!.message).toMatch(/brew install/); - expect(error!.message).toMatch(/--runtime-version/); - }); - }); -}); diff --git a/apps/cli/src/runtime.ts b/apps/cli/src/runtime.ts deleted file mode 100644 index 48183abe3b..0000000000 --- a/apps/cli/src/runtime.ts +++ /dev/null @@ -1,78 +0,0 @@ -import path from "node:path"; -import fs from "node:fs"; - -import { getBinaryName, getPlatformDir, type PlatformDir } from "./platform"; - -const PLATFORM_TO_NPM_PACKAGE: Record = { - "linux-x64": "@kdlbs/runtime-linux-x64", - "linux-arm64": "@kdlbs/runtime-linux-arm64", - "macos-x64": "@kdlbs/runtime-darwin-x64", - "macos-arm64": "@kdlbs/runtime-darwin-arm64", - "windows-x64": "@kdlbs/runtime-win32-x64", -}; - -export type RuntimeSource = "env" | "npm"; - -export type ResolvedRuntime = { - bundleDir: string; - source: RuntimeSource; -}; - -/** - * Resolve the runtime bundle directory using a two-step priority chain: - * - * 1. KANDEV_BUNDLE_DIR env var — set by the Homebrew formula wrapper and - * useful for local testing. Skips all other resolution. - * 2. Installed npm runtime package — looks for @kdlbs/runtime-{platform} - * in node_modules via Node module resolution. Works after - * `npx kandev@latest` or `npm install -g kandev` (requires npm 7+). - * - * The explicit `--runtime-version ` download path is handled directly - * in run.ts (which manages the GitHub download + cache itself); it does - * not flow through this function. - * - * Throws with an actionable error message if no runtime is found. - */ -export function resolveRuntime(): ResolvedRuntime { - const envBundleDir = process.env.KANDEV_BUNDLE_DIR; - if (envBundleDir) { - validateBundle(envBundleDir); - return { bundleDir: envBundleDir, source: "env" }; - } - - const platformDir = getPlatformDir(); - const packageName = PLATFORM_TO_NPM_PACKAGE[platformDir]; - let pkgJsonPath: string | null = null; - try { - pkgJsonPath = require.resolve(`${packageName}/package.json`); - } catch { - // MODULE_NOT_FOUND — npm runtime package is not installed. Fall through - // to the actionable error below. - } - if (pkgJsonPath) { - // The package IS installed. If validateBundle throws here, the bundle is - // present but corrupt — surface the error rather than the generic - // "no runtime found" message below. - const packageRoot = path.dirname(pkgJsonPath); - validateBundle(packageRoot); - return { bundleDir: packageRoot, source: "npm" }; - } - - throw new Error( - `No Kandev runtime found for ${platformDir}.\n` + - ` Install via npm (requires npm 7+): npx kandev@latest\n` + - ` Install via Homebrew: brew install kdlbs/kandev/kandev\n` + - ` Download a specific version (debug): kandev --runtime-version `, - ); -} - -export function validateBundle(bundleDir: string): void { - const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); - if (!fs.existsSync(backendBin)) { - throw new Error(`Backend binary not found in bundle at ${bundleDir}`); - } - const agentctlBin = path.join(bundleDir, "bin", getBinaryName("agentctl")); - if (!fs.existsSync(agentctlBin)) { - throw new Error(`agentctl binary not found in bundle at ${bundleDir}`); - } -} diff --git a/apps/cli/src/service/args.test.ts b/apps/cli/src/service/args.test.ts deleted file mode 100644 index 6d85f7a215..0000000000 --- a/apps/cli/src/service/args.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { ParseError } from "../args"; -import { parseServiceArgs } from "./args"; - -describe("parseServiceArgs", () => { - it("returns showHelp when invoked with no args", () => { - const r = parseServiceArgs([]); - expect(r.showHelp).toBe(true); - }); - - it("returns showHelp on --help with no action", () => { - expect(parseServiceArgs(["--help"]).showHelp).toBe(true); - expect(parseServiceArgs(["-h"]).showHelp).toBe(true); - }); - - it("parses each valid action", () => { - for (const action of [ - "install", - "uninstall", - "start", - "stop", - "restart", - "status", - "logs", - "config", - ] as const) { - expect(parseServiceArgs([action]).action).toBe(action); - } - }); - - it("throws ParseError on unknown action", () => { - expect(() => parseServiceArgs(["nope"])).toThrow(ParseError); - expect(() => parseServiceArgs(["nope"])).toThrow(/unknown service action/); - }); - - it("parses --system on install", () => { - expect(parseServiceArgs(["install", "--system"]).system).toBe(true); - expect(parseServiceArgs(["install"]).system).toBeUndefined(); - }); - - it("parses --port", () => { - expect(parseServiceArgs(["install", "--port", "9000"]).port).toBe(9000); - expect(parseServiceArgs(["install", "--port=9000"]).port).toBe(9000); - }); - - it("rejects invalid --port values", () => { - expect(() => parseServiceArgs(["install", "--port=abc"])).toThrow(/must be an integer/); - expect(() => parseServiceArgs(["install", "--port=0"])).toThrow(/between 1 and 65535/); - expect(() => parseServiceArgs(["install", "--port=65536"])).toThrow(/between 1 and 65535/); - }); - - it("parses --home-dir", () => { - expect(parseServiceArgs(["install", "--home-dir", "/srv/kandev"]).homeDir).toBe("/srv/kandev"); - expect(parseServiceArgs(["install", "--home-dir=/srv/kandev"]).homeDir).toBe("/srv/kandev"); - }); - - it("rejects empty --home-dir", () => { - expect(() => parseServiceArgs(["install", "--home-dir="])).toThrow(/--home-dir requires/); - }); - - it("parses --no-boot-start", () => { - expect(parseServiceArgs(["install", "--no-boot-start"]).noBootStart).toBe(true); - }); - - it("parses --follow on logs", () => { - expect(parseServiceArgs(["logs", "-f"]).follow).toBe(true); - expect(parseServiceArgs(["logs", "--follow"]).follow).toBe(true); - }); - - it("throws ParseError on unknown flag", () => { - expect(() => parseServiceArgs(["install", "--nope"])).toThrow(/unknown flag/); - }); - - it("rejects --follow for actions other than logs", () => { - expect(() => parseServiceArgs(["start", "--follow"])).toThrow( - /--follow only applies to 'kandev service logs'/, - ); - expect(() => parseServiceArgs(["status", "-f"])).toThrow( - /--follow only applies to 'kandev service logs'/, - ); - // happy path - expect(parseServiceArgs(["logs", "-f"]).follow).toBe(true); - }); - - it("rejects install-only flags on non-install actions", () => { - expect(() => parseServiceArgs(["stop", "--port=9000"])).toThrow( - /--port only applies to 'kandev service install'/, - ); - expect(() => parseServiceArgs(["restart", "--home-dir=/x"])).toThrow( - /--home-dir only applies to 'kandev service install'/, - ); - expect(() => parseServiceArgs(["status", "--no-boot-start"])).toThrow( - /--no-boot-start only applies to 'kandev service install'/, - ); - // happy path - expect(parseServiceArgs(["install", "--port=9000"]).port).toBe(9000); - }); - - it("parses hidden self-update helper flags", () => { - const args = parseServiceArgs(["self-update", "--intent", "/tmp/intent.json", "--dry-run"]); - expect(args.action).toBe("self-update"); - expect(args.intent).toBe("/tmp/intent.json"); - expect(args.dryRun).toBe(true); - }); - - it("requires --intent for self-update", () => { - expect(() => parseServiceArgs(["self-update"])).toThrow(/requires --intent/); - }); - - it("allows --help for self-update without --intent", () => { - const r = parseServiceArgs(["self-update", "--help"]); - expect(r.action).toBe("self-update"); - expect(r.showHelp).toBe(true); - }); - - it("rejects self-update-only flags elsewhere", () => { - expect(() => parseServiceArgs(["install", "--intent=/tmp/intent.json"])).toThrow( - /--intent only applies/, - ); - expect(() => parseServiceArgs(["install", "--dry-run"])).toThrow(/--dry-run only applies/); - }); -}); diff --git a/apps/cli/src/service/args.ts b/apps/cli/src/service/args.ts deleted file mode 100644 index ff9130f623..0000000000 --- a/apps/cli/src/service/args.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { ParseError } from "../args"; - -export type ServiceAction = - | "install" - | "uninstall" - | "start" - | "stop" - | "restart" - | "status" - | "logs" - | "config" - | "self-update"; - -export type ServiceArgs = { - action: ServiceAction; - /** Install as a system unit/daemon instead of a per-user one. */ - system?: boolean; - /** Backend port to bake into the unit (passed via KANDEV_SERVER_PORT). */ - port?: number; - /** Override KANDEV_HOME_DIR baked into the unit. */ - homeDir?: string; - /** Skip the Linux enable-linger prompt during install. */ - noBootStart?: boolean; - /** Tail logs (only valid with `logs`). */ - follow?: boolean; - /** Print help and exit. */ - showHelp?: boolean; - /** Intent JSON path consumed by the hidden self-update helper. */ - intent?: string; - /** Print the self-update command plan without mutating the install. */ - dryRun?: boolean; -}; - -const VALID_ACTIONS = new Set([ - "install", - "uninstall", - "start", - "stop", - "restart", - "status", - "logs", - "config", - "self-update", -]); - -export function parseServiceArgs(argv: string[]): ServiceArgs { - if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") { - return { action: "install", showHelp: true }; - } - const head = argv[0]; - if (!VALID_ACTIONS.has(head as ServiceAction)) { - throw new ParseError( - `unknown service action "${head}". expected one of: ${[...VALID_ACTIONS].join(", ")}`, - ); - } - const out: ServiceArgs = { action: head as ServiceAction }; - for (let i = 1; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === "--help" || arg === "-h") { - out.showHelp = true; - continue; - } - if (arg === "--system") { - out.system = true; - continue; - } - if (arg === "--no-boot-start") { - out.noBootStart = true; - continue; - } - if (arg === "-f" || arg === "--follow") { - out.follow = true; - continue; - } - if (arg === "--dry-run") { - out.dryRun = true; - continue; - } - if (arg === "--intent") { - out.intent = takeValue(argv, i, "--intent"); - i += 1; - continue; - } - if (arg.startsWith("--intent=")) { - const value = arg.slice("--intent=".length); - if (value.length === 0) throw new ParseError("--intent requires a value"); - out.intent = value; - continue; - } - if (arg === "--port") { - out.port = parsePort(takeValue(argv, i, "--port"), "--port"); - i += 1; - continue; - } - if (arg.startsWith("--port=")) { - out.port = parsePort(arg.slice("--port=".length), "--port"); - continue; - } - if (arg === "--home-dir") { - out.homeDir = takeValue(argv, i, "--home-dir"); - i += 1; - continue; - } - if (arg.startsWith("--home-dir=")) { - const value = arg.slice("--home-dir=".length); - if (value.length === 0) throw new ParseError("--home-dir requires a value"); - out.homeDir = value; - continue; - } - throw new ParseError(`unknown flag "${arg}" for kandev service ${head}`); - } - validateActionFlags(out); - return out; -} - -/** - * Reject flag combinations that silently no-op so the user gets immediate - * feedback instead of a successful command that ignored their input. The - * matrix is small enough that explicit checks beat a generic flag-applicability - * table. - */ -function validateActionFlags(args: ServiceArgs): void { - if (args.follow && args.action !== "logs") { - throw new ParseError(`--follow only applies to 'kandev service logs', not '${args.action}'`); - } - if (args.dryRun && args.action !== "self-update") { - throw new ParseError( - `--dry-run only applies to 'kandev service self-update', not '${args.action}'`, - ); - } - if (args.intent && args.action !== "self-update") { - throw new ParseError( - `--intent only applies to 'kandev service self-update', not '${args.action}'`, - ); - } - if (args.action === "self-update" && !args.showHelp && !args.intent) { - throw new ParseError("kandev service self-update requires --intent "); - } - const installOnly: Array = ["port", "homeDir", "noBootStart"]; - if (args.action !== "install") { - for (const flag of installOnly) { - if (args[flag] !== undefined) { - const display = - flag === "homeDir" - ? "--home-dir" - : flag === "noBootStart" - ? "--no-boot-start" - : `--${flag}`; - throw new ParseError( - `${display} only applies to 'kandev service install', not '${args.action}'`, - ); - } - } - } -} - -function takeValue(argv: string[], i: number, flag: string): string { - const v = argv[i + 1]; - if (v === undefined || v.startsWith("-")) { - throw new ParseError(`${flag} requires a value`); - } - return v; -} - -function parsePort(raw: string, flag: string): number { - const n = Number(raw); - if (raw === "" || !Number.isInteger(n) || n < 1 || n > 65535) { - throw new ParseError(`${flag} value must be an integer between 1 and 65535, got "${raw}"`); - } - return n; -} diff --git a/apps/cli/src/service/config.ts b/apps/cli/src/service/config.ts deleted file mode 100644 index 567fb4a378..0000000000 --- a/apps/cli/src/service/config.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { execFileSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; - -import { DEFAULT_BACKEND_PORT } from "../constants"; -import type { ServiceArgs } from "./args"; -import { - captureLauncher, - LAUNCHD_LABEL, - linuxSystemUnitPath, - linuxUserUnitPath, - macosSystemPlistPath, - macosUserPlistPath, - resolveHomeDir, - resolveLogDir, - resolveServiceUser, - SERVICE_NAME, -} from "./paths"; -import { looksLikeManagedUnit } from "./templates"; - -/** - * Print a human-readable summary of what kandev knows about the local - * service install: paths, env vars, whether a unit exists, whether it's - * active. Used for "why isn't it running?" diagnosis without needing to - * remember the right systemctl / launchctl invocation. - */ -export function printServiceConfig(args: ServiceArgs): void { - const isSystem = !!args.system; - const launcher = captureLauncher(); - const homeDir = resolveHomeDir(args.homeDir, isSystem); - const logDir = resolveLogDir(homeDir); - - console.log("=== kandev service config ==="); - console.log(`platform: ${process.platform}`); - console.log(`mode: ${isSystem ? "system" : "user"}`); - console.log(`launcher kind: ${launcher.kind}`); - console.log(`node path: ${launcher.nodePath}`); - console.log(`cli entry: ${launcher.cliEntry}`); - if (launcher.bundleDir) console.log(`bundle dir: ${launcher.bundleDir}`); - if (launcher.version) console.log(`version: ${launcher.version}`); - console.log(""); - console.log(`KANDEV_HOME_DIR: ${homeDir}`); - console.log(`log dir: ${logDir}`); - console.log(`port: ${args.port ?? `(default ${DEFAULT_BACKEND_PORT})`}`); - if (isSystem) { - console.log(`run as user: ${resolveServiceUser(true)}`); - } - console.log(""); - - if (process.platform === "linux") { - printLinuxUnit(isSystem); - } else if (process.platform === "darwin") { - printMacosUnit(isSystem); - } else { - console.log(`unit: (unsupported on ${process.platform})`); - } -} - -function printLinuxUnit(isSystem: boolean): void { - const unitPath = isSystem ? linuxSystemUnitPath() : linuxUserUnitPath(); - console.log(`unit path: ${unitPath}`); - const present = fs.existsSync(unitPath); - console.log(`installed: ${present ? "yes" : "no"}`); - if (present) { - const content = safeRead(unitPath); - console.log(`managed by us: ${content && looksLikeManagedUnit(content) ? "yes" : "no"}`); - } - const active = systemctlIsActive(isSystem); - if (active !== null) console.log(`active state: ${active}`); -} - -function printMacosUnit(isSystem: boolean): void { - const plistPath = isSystem ? macosSystemPlistPath() : macosUserPlistPath(); - console.log(`plist path: ${plistPath}`); - const present = fs.existsSync(plistPath); - console.log(`installed: ${present ? "yes" : "no"}`); - if (present) { - const content = safeRead(plistPath); - console.log(`managed by us: ${content && looksLikeManagedUnit(content) ? "yes" : "no"}`); - } - console.log(`loaded: ${launchctlIsLoaded(isSystem) ? "yes" : "no"}`); -} - -function safeRead(p: string): string | null { - try { - return fs.readFileSync(p, "utf8"); - } catch { - return null; - } -} - -function systemctlIsActive(isSystem: boolean): string | null { - try { - const args = isSystem ? ["is-active", SERVICE_NAME] : ["--user", "is-active", SERVICE_NAME]; - const out = execFileSync("systemctl", args, { encoding: "utf8" }); - return out.trim(); - } catch (err: unknown) { - // is-active returns nonzero for inactive/failed/unknown — read the output anyway. - // execFileSync attaches stdout to the thrown error; guard with a type check - // instead of an unsafe cast so this keeps working if the error shape changes. - if (err !== null && typeof err === "object" && "stdout" in err) { - const { stdout } = err as { stdout?: Buffer | string }; - if (stdout) return String(stdout).trim(); - } - return null; - } -} - -function launchctlIsLoaded(isSystem: boolean): boolean { - try { - const domain = isSystem ? "system" : `gui/${os.userInfo().uid}`; - execFileSync("launchctl", ["print", `${domain}/${LAUNCHD_LABEL}`], { stdio: "ignore" }); - return true; - } catch { - return false; - } -} diff --git a/apps/cli/src/service/health_check.test.ts b/apps/cli/src/service/health_check.test.ts deleted file mode 100644 index e1fb12195f..0000000000 --- a/apps/cli/src/service/health_check.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { waitForServiceHealth } from "./health_check"; - -describe("waitForServiceHealth", () => { - const originalFetch = global.fetch; - - const originalTimeout = process.env.KANDEV_HEALTH_TIMEOUT_MS; - - beforeEach(() => { - // The error message in waitForServiceHealth suggests setting this env var - // for slow first-launch scenarios. If a developer has it exported in their - // shell, the timeout test would block waiting for the overridden deadline - // instead of the 30s default we're asserting against. - delete process.env.KANDEV_HEALTH_TIMEOUT_MS; - vi.useFakeTimers(); - vi.spyOn(process.stderr, "write").mockImplementation(() => true); - }); - - afterEach(() => { - if (originalTimeout === undefined) delete process.env.KANDEV_HEALTH_TIMEOUT_MS; - else process.env.KANDEV_HEALTH_TIMEOUT_MS = originalTimeout; - vi.useRealTimers(); - global.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - it("resolves once /health responds with 200", async () => { - let calls = 0; - global.fetch = vi.fn(async () => { - calls += 1; - if (calls < 3) throw new Error("ECONNREFUSED"); - return new Response("ok", { status: 200 }); - }) as unknown as typeof fetch; - - const dump = vi.fn(); - const p = waitForServiceHealth(38429, dump); - // Advance through 2 failed polls + 1 success - await vi.advanceTimersByTimeAsync(3000); - await p; - expect(calls).toBeGreaterThanOrEqual(3); - expect(dump).not.toHaveBeenCalled(); - }); - - it("times out and dumps logs when /health never responds", async () => { - global.fetch = vi.fn(async () => { - throw new Error("ECONNREFUSED"); - }) as unknown as typeof fetch; - - const dump = vi.fn(); - const p = waitForServiceHealth(38429, dump); - const expected = expect(p).rejects.toThrow(/never responded/); - // Push past the 30s deadline - await vi.advanceTimersByTimeAsync(31_000); - await expected; - expect(dump).toHaveBeenCalledTimes(1); - }); - - it("passes an AbortSignal so a hung fetch can't outrun the deadline", async () => { - let lastSignal: AbortSignal | undefined; - global.fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { - lastSignal = init?.signal ?? undefined; - return new Response("ok", { status: 200 }); - }) as unknown as typeof fetch; - - const dump = vi.fn(); - const p = waitForServiceHealth(undefined, dump); - await vi.advanceTimersByTimeAsync(500); - await p; - // The exact timeout value is an implementation detail; what matters is - // that some abortable signal is passed through. - expect(lastSignal).toBeInstanceOf(AbortSignal); - }); - - it("uses the default port when none is given", async () => { - const seenUrls: string[] = []; - global.fetch = vi.fn(async (url: string | URL | Request) => { - seenUrls.push(typeof url === "string" ? url : url.toString()); - return new Response("ok", { status: 200 }); - }) as unknown as typeof fetch; - - const dump = vi.fn(); - const p = waitForServiceHealth(undefined, dump); - await vi.advanceTimersByTimeAsync(500); - await p; - expect(seenUrls[0]).toContain(":38429/health"); - }); -}); diff --git a/apps/cli/src/service/health_check.ts b/apps/cli/src/service/health_check.ts deleted file mode 100644 index dffeb0e114..0000000000 --- a/apps/cli/src/service/health_check.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; - -import { DEFAULT_BACKEND_PORT } from "../constants"; -import { delay, resolveHealthTimeoutMs } from "../health"; - -export type LogDumper = () => void; - -const DEFAULT_TIMEOUT_MS = 30_000; -const POLL_INTERVAL_MS = 500; -// Per-request timeout. Without this, undici's default headersTimeout (5min) -// can stall a single fetch — e.g. TCP accepted but the backend hangs before -// writing response headers — and silently overrun the outer deadline. -const REQUEST_TIMEOUT_MS = 2_000; - -/** - * Poll the kandev /health endpoint to confirm the freshly-installed service - * actually came up. On success, the user gets immediate confirmation; on - * failure, we dump the tail of the service logs so the diagnosis isn't a - * scavenger hunt across `journalctl` / `tail`. - * - * Timeout is fixed at 30s — long enough to absorb a slow first launch - * (binary unpacking, sqlite migration), short enough to fail fast when the - * unit is genuinely broken. - */ -export async function waitForServiceHealth( - port: number | undefined, - dumpLogs: LogDumper, -): Promise { - const url = `http://localhost:${port ?? DEFAULT_BACKEND_PORT}/health`; - const timeoutMs = resolveHealthTimeoutMs(DEFAULT_TIMEOUT_MS); - const deadline = Date.now() + timeoutMs; - process.stderr.write(`[kandev] waiting for service to be healthy at ${url}\n`); - - while (Date.now() < deadline) { - try { - const res = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); - if (res.ok) { - process.stderr.write(`[kandev] service is healthy\n`); - return; - } - } catch { - // not up yet (or per-request timeout fired); keep polling - } - await delay(POLL_INTERVAL_MS); - } - - process.stderr.write(`[kandev] service did not become healthy within ${timeoutMs}ms\n`); - process.stderr.write(`[kandev] dumping recent service logs:\n`); - dumpLogs(); - throw new Error( - "kandev service was installed but the /health endpoint never responded. " + - "Inspect the logs above and re-run 'kandev service install' once fixed. " + - "If the service needs more time to come up, set KANDEV_HEALTH_TIMEOUT_MS=120000.", - ); -} - -/** Dump the last N lines of a systemd unit's logs via journalctl. */ -export function dumpJournalctlLogs(opts: { unit: string; isSystem: boolean; lines: number }): void { - const args = opts.isSystem - ? ["-u", opts.unit, "-n", String(opts.lines), "--no-pager"] - : ["--user-unit", opts.unit, "-n", String(opts.lines), "--no-pager"]; - try { - spawnSync("journalctl", args, { stdio: "inherit" }); - } catch (err) { - // journalctl may be unavailable in containerized or stripped-down setups. - // We're already in a failure path; don't compound it with a thrown error. - process.stderr.write( - `[kandev] (could not run journalctl: ${err instanceof Error ? err.message : String(err)})\n`, - ); - } -} - -/** Dump the last N lines of launchd-managed log files via `tail`. */ -export function dumpLaunchdLogs(opts: { logDir: string; lines: number }): void { - const candidates = ["service.err", "service.out"] - .map((name) => path.join(opts.logDir, name)) - .filter((p) => fs.existsSync(p)); - if (candidates.length === 0) { - process.stderr.write(`[kandev] (no logs found in ${opts.logDir})\n`); - return; - } - try { - spawnSync("tail", ["-n", String(opts.lines), ...candidates], { stdio: "inherit" }); - } catch (err) { - process.stderr.write( - `[kandev] (could not run tail: ${err instanceof Error ? err.message : String(err)})\n`, - ); - } -} diff --git a/apps/cli/src/service/index.ts b/apps/cli/src/service/index.ts deleted file mode 100644 index 8eec834644..0000000000 --- a/apps/cli/src/service/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { parseServiceArgs } from "./args"; -import { printServiceConfig } from "./config"; -import { runLinuxService } from "./linux"; -import { runMacosService } from "./macos"; -import { runSelfUpdateCommand } from "./self_update"; - -export function printServiceHelp(): void { - console.log(`kandev service — install kandev as an OS-managed service - -Usage: - kandev service install [--system] [--port ] [--home-dir ] [--no-boot-start] - kandev service uninstall [--system] - kandev service start|stop|restart|status [--system] - kandev service logs [-f] [--system] - kandev service config [--system] - -Modes: - default User-level service. - Linux: ~/.config/systemd/user/kandev.service - macOS: ~/Library/LaunchAgents/com.kdlbs.kandev.plist - Runs as the current user. On Linux, only starts at boot - if 'loginctl enable-linger ' has been run. - --system System-level service. Requires sudo. - Linux: /etc/systemd/system/kandev.service - macOS: /Library/LaunchDaemons/com.kdlbs.kandev.plist - Starts at boot regardless of login state. - -Flags: - --port Backend port baked into the unit (KANDEV_SERVER_PORT). - --home-dir KANDEV_HOME_DIR baked into the unit. - Defaults: ~/.kandev (user), /var/lib/kandev (system). - --no-boot-start (Linux user mode) Skip the enable-linger hint. - -f, --follow (logs) Stream logs instead of dumping the tail. - -Updates: - After 'npm update -g kandev' or 'brew upgrade kandev', re-run - 'kandev service install' to refresh paths in the unit file. -`); -} - -export async function runServiceCommand(argv: string[]): Promise { - const args = parseServiceArgs(argv); - if (args.showHelp) { - printServiceHelp(); - return; - } - // 'config' is read-only and identical across platforms — handle here so we - // don't need to duplicate it in both linux.ts and macos.ts. - if (args.action === "config") { - printServiceConfig(args); - return; - } - // Hidden helper entrypoint used by the backend self-update endpoint. It is - // intentionally absent from help output. - if (args.action === "self-update") { - runSelfUpdateCommand(args); - return; - } - switch (process.platform) { - case "linux": - return runLinuxService(args); - case "darwin": - return runMacosService(args); - default: - throw new Error( - `kandev service is not yet supported on ${process.platform}. ` + - `Currently supported: linux (systemd), darwin (launchd).`, - ); - } -} diff --git a/apps/cli/src/service/install_helpers.test.ts b/apps/cli/src/service/install_helpers.test.ts deleted file mode 100644 index 00bdd3e321..0000000000 --- a/apps/cli/src/service/install_helpers.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { writeUnitFile } from "./install_helpers"; - -const MANAGED_HEADER = "# managed by kandev — regenerated by `kandev service install`\n"; - -describe("writeUnitFile", () => { - let tmpDir: string; - let target: string; - let logs: string[]; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-svc-")); - target = path.join(tmpDir, "kandev.service"); - logs = []; - vi.spyOn(console, "log").mockImplementation((msg: unknown) => { - logs.push(String(msg)); - }); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - vi.restoreAllMocks(); - }); - - it("returns 'created' when target does not exist", () => { - const outcome = writeUnitFile(target, `${MANAGED_HEADER}body=v1\n`); - expect(outcome).toBe("created"); - expect(fs.readFileSync(target, "utf8")).toBe(`${MANAGED_HEADER}body=v1\n`); - expect(logs.some((l) => l.includes("wrote"))).toBe(true); - }); - - it("returns 'unchanged' and does not rewrite when content is identical", () => { - fs.writeFileSync(target, `${MANAGED_HEADER}body=v1\n`); - const mtimeBefore = fs.statSync(target).mtimeMs; - // Wait a tick so any rewrite would change mtime - const outcome = writeUnitFile(target, `${MANAGED_HEADER}body=v1\n`); - expect(outcome).toBe("unchanged"); - expect(fs.statSync(target).mtimeMs).toBe(mtimeBefore); - expect(logs.some((l) => l.includes("up to date"))).toBe(true); - }); - - it("returns 'updated' when existing file is managed but content differs", () => { - fs.writeFileSync(target, `${MANAGED_HEADER}body=v1\n`); - const outcome = writeUnitFile(target, `${MANAGED_HEADER}body=v2\n`); - expect(outcome).toBe("updated"); - expect(fs.readFileSync(target, "utf8")).toBe(`${MANAGED_HEADER}body=v2\n`); - expect(logs.some((l) => l.includes("updated"))).toBe(true); - // No backup for managed updates - expect(fs.existsSync(`${target}.bak`)).toBe(false); - }); - - it("returns 'replaced-foreign' and backs up when existing file is not managed", () => { - fs.writeFileSync(target, "some other tool's unit\n[Service]\nExecStart=/bin/false\n"); - const outcome = writeUnitFile(target, `${MANAGED_HEADER}body=v2\n`); - expect(outcome).toBe("replaced-foreign"); - expect(fs.readFileSync(target, "utf8")).toBe(`${MANAGED_HEADER}body=v2\n`); - expect(fs.readFileSync(`${target}.bak`, "utf8")).toBe( - "some other tool's unit\n[Service]\nExecStart=/bin/false\n", - ); - expect(logs.some((l) => l.includes("WARNING"))).toBe(true); - }); -}); diff --git a/apps/cli/src/service/install_helpers.ts b/apps/cli/src/service/install_helpers.ts deleted file mode 100644 index adbd00bf59..0000000000 --- a/apps/cli/src/service/install_helpers.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; - -import { looksLikeManagedUnit } from "./templates"; - -/** Cheap PATH lookup; returns true if `cmd` resolves via `which`. */ -export function commandExists(cmd: string): boolean { - const res = spawnSync("which", [cmd], { stdio: "ignore" }); - return res.status === 0; -} - -export type WriteOutcome = "created" | "updated" | "unchanged" | "replaced-foreign"; - -/** - * Write `content` to `targetPath` with idempotent + foreign-file handling. - * - * - Missing file → created (no warning) - * - Existing managed file, same content → unchanged (no write, no warning) - * - Existing managed file, different content → updated (overwrite, brief log) - * - Existing file that doesn't look managed → replaced-foreign (overwrite, - * loud warning so the user notices we clobbered something) - * - * The "managed" check looks for kandev's marker substring; users who hand-edit - * past the marker lose the no-op shortcut but still get an "updated" log line, - * which is the expected workflow. - */ -export function writeUnitFile(targetPath: string, content: string): WriteOutcome { - if (!fs.existsSync(targetPath)) { - fs.writeFileSync(targetPath, content, { mode: 0o644 }); - console.log(`[kandev] wrote ${targetPath}`); - return "created"; - } - - const existing = fs.readFileSync(targetPath, "utf8"); - if (existing === content) { - console.log(`[kandev] ${targetPath} is already up to date`); - return "unchanged"; - } - - if (!looksLikeManagedUnit(existing)) { - console.log( - `[kandev] WARNING: ${targetPath} exists but doesn't look like a kandev-managed file.`, - ); - console.log(`[kandev] The existing file will be replaced. A backup is saved alongside.`); - fs.copyFileSync(targetPath, `${targetPath}.bak`); - fs.writeFileSync(targetPath, content, { mode: 0o644 }); - console.log(`[kandev] replaced ${targetPath} (backup: ${targetPath}.bak)`); - return "replaced-foreign"; - } - - fs.writeFileSync(targetPath, content, { mode: 0o644 }); - console.log(`[kandev] updated ${targetPath}`); - return "updated"; -} diff --git a/apps/cli/src/service/launchctl.test.ts b/apps/cli/src/service/launchctl.test.ts deleted file mode 100644 index bdb20a2b09..0000000000 --- a/apps/cli/src/service/launchctl.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { - bootoutAndWait, - bootstrapWithRetry, - reloadService, - type LaunchctlResult, -} from "./launchctl"; - -type Call = { args: string[]; stdio: string }; - -function recordingRun(statusFor: (call: Call, index: number) => number | null): { - run: (args: string[], stdio: "inherit" | "ignore") => LaunchctlResult; - calls: Call[]; -} { - const calls: Call[] = []; - const run = (args: string[], stdio: "inherit" | "ignore"): LaunchctlResult => { - const call = { args, stdio }; - const status = statusFor(call, calls.length); - calls.push(call); - return { status }; - }; - return { run, calls }; -} - -describe("bootoutAndWait", () => { - it("polls `print` until the job is gone before returning", () => { - // print returns 0 (still loaded) twice, then non-zero (gone). - let printCount = 0; - const { run, calls } = recordingRun((call) => { - if (call.args[0] === "bootout") return 0; - if (call.args[0] === "print") { - printCount += 1; - return printCount <= 2 ? 0 : 1; - } - return 0; - }); - const sleep = vi.fn(); - - bootoutAndWait("gui/501/com.kdlbs.kandev", { run, sleep }); - - expect(calls[0].args).toEqual(["bootout", "gui/501/com.kdlbs.kandev"]); - expect(calls.filter((c) => c.args[0] === "print")).toHaveLength(3); - expect(sleep).toHaveBeenCalledTimes(2); - }); - - it("returns immediately when nothing is loaded", () => { - const { run, calls } = recordingRun((call) => (call.args[0] === "print" ? 1 : 0)); - const sleep = vi.fn(); - - bootoutAndWait("gui/501/com.kdlbs.kandev", { run, sleep }); - - expect(calls.filter((c) => c.args[0] === "print")).toHaveLength(1); - expect(sleep).not.toHaveBeenCalled(); - }); -}); - -describe("bootstrapWithRetry", () => { - it("retries through the transient EIO and succeeds", () => { - // Reproduces the launchd teardown race: bootstrap returns 5 twice, then 0. - let bootstrapCount = 0; - const { run, calls } = recordingRun(() => { - bootstrapCount += 1; - return bootstrapCount < 3 ? 5 : 0; - }); - const sleep = vi.fn(); - - expect(() => - bootstrapWithRetry("gui/501", "/Users/a/Library/LaunchAgents/x.plist", { run, sleep }), - ).not.toThrow(); - expect(calls).toHaveLength(3); - expect(sleep).toHaveBeenCalledTimes(2); - }); - - it("throws with the last exit code after exhausting retries", () => { - const { run, calls } = recordingRun(() => 5); - const sleep = vi.fn(); - - expect(() => - bootstrapWithRetry("gui/501", "/Users/a/Library/LaunchAgents/x.plist", { run, sleep }), - ).toThrow(/failed with code 5/); - expect(calls).toHaveLength(5); - }); - - it("succeeds on the first attempt without sleeping", () => { - const { run, calls } = recordingRun(() => 0); - const sleep = vi.fn(); - - bootstrapWithRetry("gui/501", "/x.plist", { run, sleep }); - - expect(calls).toHaveLength(1); - expect(sleep).not.toHaveBeenCalled(); - }); -}); - -describe("reloadService", () => { - it("waits for teardown, then bootstraps — surviving an EIO mid-reload", () => { - // Live-service reload: print stays loaded once, bootstrap hits EIO once. - let printCount = 0; - let bootstrapCount = 0; - const { run, calls } = recordingRun((call) => { - if (call.args[0] === "print") { - printCount += 1; - return printCount <= 1 ? 0 : 1; - } - if (call.args[0] === "bootstrap") { - bootstrapCount += 1; - return bootstrapCount < 2 ? 5 : 0; - } - return 0; - }); - const sleep = vi.fn(); - - reloadService("gui/501/com.kdlbs.kandev", "gui/501", "/x.plist", { run, sleep }); - - const order = calls.map((c) => c.args[0]); - expect(order[0]).toBe("bootout"); - // bootout precedes the first bootstrap - expect(order.indexOf("bootout")).toBeLessThan(order.indexOf("bootstrap")); - expect(calls.filter((c) => c.args[0] === "bootstrap")).toHaveLength(2); - }); -}); diff --git a/apps/cli/src/service/launchctl.ts b/apps/cli/src/service/launchctl.ts deleted file mode 100644 index 34de23af97..0000000000 --- a/apps/cli/src/service/launchctl.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { spawnSync } from "node:child_process"; - -/** - * launchd's `bootout` is asynchronous: the command returns as soon as launchd - * accepts the request, but the job (and, for a live service, its process) may - * still be tearing down. Issuing `bootstrap` for the same label before that - * teardown finishes races launchd and surfaces as: - * - * Bootstrap failed: 5: Input/output error - * - * This is invisible on a fresh install (nothing to tear down) and in unit tests - * (no live launchctl), but it reliably breaks self-update — there the service is - * actively running when `kandev service install` reloads it. The helpers below - * make the bootout→bootstrap dance robust: wait for the old job to actually - * disappear, then retry the bootstrap through the brief residual EIO window. - */ - -export type LaunchctlResult = { status: number | null }; -export type LaunchctlStdio = "inherit" | "ignore"; -export type LaunchctlRunner = (args: string[], stdio: LaunchctlStdio) => LaunchctlResult; -export type SleepFn = (ms: number) => void; - -export type LaunchctlDeps = { - run?: LaunchctlRunner; - sleep?: SleepFn; -}; - -const BOOTOUT_POLL_INTERVAL_MS = 100; -const BOOTOUT_POLL_ATTEMPTS = 50; // ~5s ceiling waiting for teardown -const BOOTSTRAP_MAX_ATTEMPTS = 5; -const BOOTSTRAP_RETRY_BASE_MS = 300; - -export function spawnLaunchctl(args: string[], stdio: LaunchctlStdio): LaunchctlResult { - const res = spawnSync("launchctl", args, { stdio }); - return { status: res.status }; -} - -// Block the current thread for `ms`. The launchctl orchestration runs in the -// synchronous `service install`/`start`/`restart` paths, so we can't await a -// timer — Atomics.wait gives a dependency-free blocking sleep. -export function sleepSync(ms: number): void { - if (ms <= 0) return; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} - -function isLoaded(target: string, run: LaunchctlRunner): boolean { - // `launchctl print ` exits non-zero once the label is gone. - return run(["print", target], "ignore").status === 0; -} - -/** - * Boot out a launchd job and wait until launchd has fully removed it. A no-op - * (returns promptly) when the label isn't loaded, so fresh installs aren't - * slowed down. - */ -export function bootoutAndWait(target: string, deps: LaunchctlDeps = {}): void { - const run = deps.run ?? spawnLaunchctl; - const sleep = deps.sleep ?? sleepSync; - run(["bootout", target], "ignore"); - for (let attempt = 0; attempt < BOOTOUT_POLL_ATTEMPTS; attempt += 1) { - if (!isLoaded(target, run)) { - return; - } - sleep(BOOTOUT_POLL_INTERVAL_MS); - } -} - -/** - * Bootstrap a launchd job, retrying through the transient EIO that launchd - * returns while a previous instance of the same label finishes tearing down. - * Throws with the last exit code if every attempt fails. - */ -export function bootstrapWithRetry( - domain: string, - plistPath: string, - deps: LaunchctlDeps = {}, -): void { - const run = deps.run ?? spawnLaunchctl; - const sleep = deps.sleep ?? sleepSync; - let lastStatus: number | null = null; - for (let attempt = 1; attempt <= BOOTSTRAP_MAX_ATTEMPTS; attempt += 1) { - lastStatus = run(["bootstrap", domain, plistPath], "inherit").status; - if (lastStatus === 0) { - return; - } - if (attempt < BOOTSTRAP_MAX_ATTEMPTS) { - sleep(BOOTSTRAP_RETRY_BASE_MS * attempt); - } - } - throw new Error(`launchctl bootstrap ${domain} ${plistPath} failed with code ${lastStatus}`); -} - -/** - * Reload a launchd job: fully unload (waiting for teardown) then bootstrap with - * retry. This is the safe sequence whenever the target might currently be - * running — installs that refresh a live service, and `start`. - */ -export function reloadService( - target: string, - domain: string, - plistPath: string, - deps: LaunchctlDeps = {}, -): void { - bootoutAndWait(target, deps); - bootstrapWithRetry(domain, plistPath, deps); -} diff --git a/apps/cli/src/service/linux.ts b/apps/cli/src/service/linux.ts deleted file mode 100644 index 465bd37e58..0000000000 --- a/apps/cli/src/service/linux.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { execFileSync, spawnSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; - -import type { ServiceArgs } from "./args"; -import { dumpJournalctlLogs, waitForServiceHealth } from "./health_check"; -import { commandExists, writeUnitFile } from "./install_helpers"; -import { - buildServiceInstallMetadata, - serviceMetadataPath, - writeServiceInstallMetadata, -} from "./metadata"; -import { - captureLauncher, - currentUsername, - linuxUserUnitDir, - LINUX_SYSTEM_UNIT_DIR, - resolveHomeDir, - resolveLogDir, - resolveServiceUser, - SERVICE_NAME, -} from "./paths"; -import { renderSystemdUnit } from "./templates"; - -type Ctx = { - args: ServiceArgs; - systemctlArgs: string[]; - unitPath: string; - isSystem: boolean; -}; - -function makeCtx(args: ServiceArgs): Ctx { - const isSystem = !!args.system; - const unitDir = isSystem ? LINUX_SYSTEM_UNIT_DIR : linuxUserUnitDir(); - const systemctlArgs = isSystem ? [] : ["--user"]; - return { - args, - systemctlArgs, - unitPath: path.join(unitDir, `${SERVICE_NAME}.service`), - isSystem, - }; -} - -export async function runLinuxService(args: ServiceArgs): Promise { - if (!commandExists("systemctl")) { - throw new Error("systemctl not found. Linux service install requires systemd."); - } - const ctx = makeCtx(args); - switch (args.action) { - case "install": - return installAsync(ctx); - case "uninstall": - return uninstall(ctx); - case "start": - return runSystemctl(ctx, ["start", SERVICE_NAME]); - case "stop": - return runSystemctl(ctx, ["stop", SERVICE_NAME]); - case "restart": - return runSystemctl(ctx, ["restart", SERVICE_NAME]); - case "status": - return runSystemctl(ctx, ["status", SERVICE_NAME], { allowFailure: true }); - case "logs": - return showLogs(ctx); - case "config": - // Handled by the dispatcher in index.ts before reaching the platform layer. - throw new Error("unreachable: config action handled in service/index.ts"); - case "self-update": - // Handled by the dispatcher in index.ts before reaching the platform layer. - throw new Error("unreachable: self-update action handled in service/index.ts"); - default: { - const _exhaustive: never = args.action; - throw new Error(`unhandled service action: ${_exhaustive as string}`); - } - } -} - -async function installAsync(ctx: Ctx): Promise { - installSync(ctx); - await waitForServiceHealth(ctx.args.port, () => - dumpJournalctlLogs({ unit: SERVICE_NAME, isSystem: ctx.isSystem, lines: 50 }), - ); -} - -function installSync(ctx: Ctx): void { - const launcher = captureLauncher(); - const homeDir = resolveHomeDir(ctx.args.homeDir, ctx.isSystem); - const logDir = resolveLogDir(homeDir); - const metadataPath = serviceMetadataPath(homeDir); - const mode = ctx.isSystem ? "system" : "user"; - const systemUser = ctx.isSystem ? resolveServiceUser(true) : undefined; - const unit = renderSystemdUnit({ - launcher, - homeDir, - logDir, - port: ctx.args.port, - systemUser, - mode, - serviceMetadataPath: metadataPath, - }); - - fs.mkdirSync(path.dirname(ctx.unitPath), { recursive: true }); - const outcome = writeUnitFile(ctx.unitPath, unit); - writeServiceInstallMetadata( - metadataPath, - buildServiceInstallMetadata({ - manager: "systemd", - mode, - launcher, - homeDir, - logDir, - servicePath: ctx.unitPath, - port: ctx.args.port, - systemUser, - }), - ); - - runSystemctl(ctx, ["daemon-reload"]); - // Always run enable --now so 'install' is fully idempotent: if the user - // manually disabled or stopped the service, re-running install brings it - // back online without changing the unit file. - runSystemctl(ctx, ["enable", "--now", SERVICE_NAME]); - console.log( - outcome === "unchanged" - ? "[kandev] service is enabled and running" - : "[kandev] service enabled and started", - ); - - if (!ctx.isSystem && !ctx.args.noBootStart) { - maybePromptLinger(); - } - - printPostInstallHints(ctx); -} - -function uninstall(ctx: Ctx): void { - // Disable and stop, ignoring failures since the unit may already be stopped. - runSystemctl(ctx, ["disable", "--now", SERVICE_NAME], { allowFailure: true }); - if (fs.existsSync(ctx.unitPath)) { - fs.unlinkSync(ctx.unitPath); - console.log(`[kandev] removed ${ctx.unitPath}`); - } else { - console.log(`[kandev] no unit file at ${ctx.unitPath}`); - } - runSystemctl(ctx, ["daemon-reload"], { allowFailure: true }); -} - -function showLogs(ctx: Ctx): void { - const journalArgs: string[] = ctx.isSystem ? ["-u", SERVICE_NAME] : ["--user-unit", SERVICE_NAME]; - if (ctx.args.follow) journalArgs.push("-f"); - else journalArgs.push("-n", "200", "--no-pager"); - - const res = spawnSync("journalctl", journalArgs, { stdio: "inherit" }); - if (res.status !== 0 && !ctx.args.follow) { - throw new Error(`journalctl exited with code ${res.status}`); - } -} - -function runSystemctl(ctx: Ctx, args: string[], opts: { allowFailure?: boolean } = {}): void { - const argv = [...ctx.systemctlArgs, ...args]; - const res = spawnSync("systemctl", argv, { stdio: "inherit" }); - if (res.status !== 0 && !opts.allowFailure) { - throw new Error(`systemctl ${argv.join(" ")} failed with code ${res.status}`); - } -} - -function lingerEnabled(user: string): boolean { - try { - const out = execFileSync("loginctl", ["show-user", user, "--property=Linger"], { - encoding: "utf8", - }); - return out.trim().toLowerCase() === "linger=yes"; - } catch { - // loginctl may not be available or user record missing — assume off. - return false; - } -} - -function maybePromptLinger(): void { - const user = currentUsername(); - if (lingerEnabled(user)) { - console.log("[kandev] enable-linger already active — kandev will start at boot"); - return; - } - console.log(""); - console.log("[kandev] User services only run while you're logged in."); - console.log("[kandev] To start kandev at boot, run:"); - console.log(`[kandev] sudo loginctl enable-linger ${user}`); - console.log("[kandev] (Pass --no-boot-start to skip this notice next time.)"); -} - -function printPostInstallHints(ctx: Ctx): void { - const ctl = ctx.isSystem ? "sudo systemctl" : "systemctl --user"; - const journal = ctx.isSystem ? "sudo journalctl" : "journalctl --user-unit"; - console.log(""); - console.log("[kandev] Useful commands:"); - console.log(`[kandev] ${ctl} status ${SERVICE_NAME}`); - console.log(`[kandev] ${ctl} restart ${SERVICE_NAME}`); - console.log(`[kandev] ${journal} ${ctx.isSystem ? "-u " : ""}${SERVICE_NAME} -f`); -} diff --git a/apps/cli/src/service/macos.test.ts b/apps/cli/src/service/macos.test.ts deleted file mode 100644 index 3f140b9a27..0000000000 --- a/apps/cli/src/service/macos.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { readInstalledLogPaths } from "./macos"; -import { renderLaunchdPlist } from "./templates"; - -describe("readInstalledLogPaths", () => { - let tmp: string; - let plistPath: string; - - beforeEach(() => { - tmp = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-macos-")); - plistPath = path.join(tmp, "com.kdlbs.kandev.plist"); - }); - - afterEach(() => { - fs.rmSync(tmp, { recursive: true, force: true }); - }); - - it("returns null when the plist does not exist", () => { - expect(readInstalledLogPaths(plistPath)).toBeNull(); - }); - - it("returns null when the plist exists but lacks the keys", () => { - fs.writeFileSync(plistPath, ""); - expect(readInstalledLogPaths(plistPath)).toBeNull(); - }); - - it("decodes XML-escaped paths so they match real filesystem locations", () => { - // Regression: renderLaunchdPlist runs values through escapeXml, so paths - // containing &, <, etc. are stored escaped in the plist. Without the - // inverse decode, readInstalledLogPaths would return `/home/a&b/...` - // which never matches the actual `/home/a&b/...` file on disk. - const plist = renderLaunchdPlist({ - launcher: { - nodePath: "/usr/local/bin/node", - cliEntry: "/usr/local/lib/node_modules/kandev/bin/cli.js", - kind: "npm", - }, - homeDir: "/home/john&doe/.kandev", - logDir: "/home/john&doe/.kandev/logs", - mode: "user", - }); - fs.writeFileSync(plistPath, plist); - - const installed = readInstalledLogPaths(plistPath); - expect(installed).toEqual({ - out: "/home/john&doe/.kandev/logs/service.out", - err: "/home/john&doe/.kandev/logs/service.err", - }); - }); - - it("reads back the same paths the renderer wrote (round-trip with custom home dir)", () => { - // Regression for the showLogs-with-custom-home-dir bug: showLogs has no - // access to args.homeDir (install-only), so it must derive log paths - // from the installed plist or it'll look in the default directory while - // logs accumulate at the custom one. - const plist = renderLaunchdPlist({ - launcher: { - nodePath: "/usr/local/bin/node", - cliEntry: "/usr/local/lib/node_modules/kandev/bin/cli.js", - kind: "npm", - }, - homeDir: "/custom/install/path", - logDir: "/custom/install/path/logs", - mode: "user", - }); - fs.writeFileSync(plistPath, plist); - - const installed = readInstalledLogPaths(plistPath); - expect(installed).toEqual({ - out: "/custom/install/path/logs/service.out", - err: "/custom/install/path/logs/service.err", - }); - }); -}); diff --git a/apps/cli/src/service/macos.ts b/apps/cli/src/service/macos.ts deleted file mode 100644 index 1392565684..0000000000 --- a/apps/cli/src/service/macos.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import type { ServiceArgs } from "./args"; -import { dumpLaunchdLogs, waitForServiceHealth } from "./health_check"; -import { commandExists, writeUnitFile } from "./install_helpers"; -import { reloadService } from "./launchctl"; -import { - buildServiceInstallMetadata, - serviceMetadataPath, - writeServiceInstallMetadata, -} from "./metadata"; -import { - captureLauncher, - LAUNCHD_LABEL, - macosUserAgentDir, - MACOS_SYSTEM_DAEMON_DIR, - resolveHomeDir, - resolveLogDir, - resolveServiceUser, -} from "./paths"; -import { renderLaunchdPlist } from "./templates"; - -type Ctx = { - args: ServiceArgs; - plistPath: string; - isSystem: boolean; - /** launchctl domain target, e.g. gui/501 or system. */ - domain: string; - /** Full launchctl service target, e.g. gui/501/com.kdlbs.kandev. */ - target: string; -}; - -function makeCtx(args: ServiceArgs): Ctx { - const isSystem = !!args.system; - const dir = isSystem ? MACOS_SYSTEM_DAEMON_DIR : macosUserAgentDir(); - const uid = os.userInfo().uid; - const domain = isSystem ? "system" : `gui/${uid}`; - return { - args, - plistPath: path.join(dir, `${LAUNCHD_LABEL}.plist`), - isSystem, - domain, - target: `${domain}/${LAUNCHD_LABEL}`, - }; -} - -export async function runMacosService(args: ServiceArgs): Promise { - if (!commandExists("launchctl")) { - throw new Error("launchctl not found. macOS service install requires launchd."); - } - const ctx = makeCtx(args); - switch (args.action) { - case "install": - return installAsync(ctx); - case "uninstall": - return uninstall(ctx); - case "start": - return startService(ctx); - case "stop": - return stopService(ctx); - case "restart": - return restartService(ctx); - case "status": - return showStatus(ctx); - case "logs": - return showLogs(ctx); - case "config": - // Handled by the dispatcher in index.ts before reaching the platform layer. - throw new Error("unreachable: config action handled in service/index.ts"); - case "self-update": - // Handled by the dispatcher in index.ts before reaching the platform layer. - throw new Error("unreachable: self-update action handled in service/index.ts"); - default: { - const _exhaustive: never = args.action; - throw new Error(`unhandled service action: ${_exhaustive as string}`); - } - } -} - -async function installAsync(ctx: Ctx): Promise { - const { logDir } = installSync(ctx); - await waitForServiceHealth(ctx.args.port, () => dumpLaunchdLogs({ logDir, lines: 50 })); -} - -function installSync(ctx: Ctx): { logDir: string } { - const launcher = captureLauncher(); - const homeDir = resolveHomeDir(ctx.args.homeDir, ctx.isSystem); - const logDir = resolveLogDir(homeDir); - const metadataPath = serviceMetadataPath(homeDir); - const mode = ctx.isSystem ? "system" : "user"; - const systemUser = ctx.isSystem ? resolveServiceUser(true) : undefined; - fs.mkdirSync(logDir, { recursive: true }); - - const plist = renderLaunchdPlist({ - launcher, - homeDir, - logDir, - port: ctx.args.port, - systemUser, - mode, - serviceMetadataPath: metadataPath, - }); - - fs.mkdirSync(path.dirname(ctx.plistPath), { recursive: true }); - const outcome = writeUnitFile(ctx.plistPath, plist); - writeServiceInstallMetadata( - metadataPath, - buildServiceInstallMetadata({ - manager: "launchd", - mode, - launcher, - homeDir, - logDir, - servicePath: ctx.plistPath, - port: ctx.args.port, - systemUser, - }), - ); - - // launchctl bootstrap fails if the label is already loaded — bootout first - // (ignoring its error if nothing was loaded). This means 'install' is - // idempotent: re-running it reloads the unit even if the file is unchanged, - // which is how we recover from a user manually unloading the service. - // - // `reloadService` waits for bootout's async teardown before bootstrapping and - // retries the transient EIO ("Bootstrap failed: 5") that launchd returns while - // a still-running instance is torn down — the failure that broke self-update, - // where this install runs against a live service. See launchctl.ts. - reloadService(ctx.target, ctx.domain, ctx.plistPath); - runLaunchctl(["enable", ctx.target], { allowFailure: true }); - console.log( - outcome === "unchanged" - ? "[kandev] service is loaded and running" - : "[kandev] service loaded and started", - ); - - printPostInstallHints(ctx, logDir); - return { logDir }; -} - -function uninstall(ctx: Ctx): void { - runLaunchctl(["bootout", ctx.target], { allowFailure: true }); - if (fs.existsSync(ctx.plistPath)) { - fs.unlinkSync(ctx.plistPath); - console.log(`[kandev] removed ${ctx.plistPath}`); - } else { - console.log(`[kandev] no plist at ${ctx.plistPath}`); - } -} - -// `bootstrap` loads the job (start) and `bootout` fully unloads it (stop). -// We use these instead of `kickstart` + `kill` because the plist sets -// `KeepAlive=true` — under KeepAlive, `kill SIGTERM` does not stop the -// service: launchd just respawns it seconds later. Only `bootout` removes -// the job from launchd's supervision. -// -// start/restart both have to handle two pre-states — job loaded vs not — -// so each begins with a bootout-then-bootstrap dance similar to installSync. -function startService(ctx: Ctx): void { - // Idempotent: if the label is already loaded, bootstrap would fail. Bootout - // first (waiting for teardown) so start works whether the service was - // previously running or stopped. - reloadService(ctx.target, ctx.domain, ctx.plistPath); -} - -function stopService(ctx: Ctx): void { - runLaunchctl(["bootout", ctx.target], { allowFailure: true }); -} - -// `kickstart -k` atomically kills and restarts a loaded service. If the job -// was previously stopped (bootout'd), the target no longer exists in the -// launchd domain and kickstart fails — fall back to bootstrap to reload it. -function restartService(ctx: Ctx): void { - const res = spawnSync("launchctl", ["kickstart", "-k", ctx.target], { stdio: "inherit" }); - if (res.status === 0) return; - // kickstart fails when the job isn't loaded — reload it (waiting for any - // residual teardown and retrying the transient bootstrap EIO). - reloadService(ctx.target, ctx.domain, ctx.plistPath); -} - -function showStatus(ctx: Ctx): void { - const res = spawnSync("launchctl", ["print", ctx.target], { - stdio: "inherit", - }); - if (res.status !== 0) { - console.log(`[kandev] service not loaded in ${ctx.domain}`); - } -} - -function showLogs(ctx: Ctx): void { - // Pull the log paths from the *installed* plist rather than recomputing - // from defaults — `--home-dir` is install-only, so a user who installed - // with a custom home dir would otherwise see "no logs yet" at the wrong - // location while logs accumulate at the real path. - const installed = readInstalledLogPaths(ctx.plistPath); - const homeDir = resolveHomeDir(ctx.args.homeDir, ctx.isSystem); - const fallbackDir = resolveLogDir(homeDir); - const outPath = installed?.out ?? path.join(fallbackDir, "service.out"); - const errPath = installed?.err ?? path.join(fallbackDir, "service.err"); - - const tailArgs: string[] = ctx.args.follow ? ["-f", "-n", "200"] : ["-n", "200"]; - const targets = [outPath, errPath].filter((p) => fs.existsSync(p)); - if (targets.length === 0) { - const checkedDir = installed ? path.dirname(installed.err) : fallbackDir; - console.log(`[kandev] no logs yet at ${checkedDir}`); - return; - } - spawnSync("tail", [...tailArgs, ...targets], { stdio: "inherit" }); -} - -/** - * Pull the literal StandardOutPath / StandardErrorPath values out of an - * installed plist. Plist XML is rigidly formatted by our renderer, so a - * regex match is enough — avoids pulling in a plist parser for two strings. - * Returns null when the plist is missing or doesn't contain the keys. - */ -export function readInstalledLogPaths(plistPath: string): { out: string; err: string } | null { - let content: string; - try { - content = fs.readFileSync(plistPath, "utf8"); - } catch { - return null; - } - const outMatch = /StandardOutPath<\/key>\s*([^<]+)<\/string>/.exec(content); - const errMatch = /StandardErrorPath<\/key>\s*([^<]+)<\/string>/.exec(content); - if (!outMatch || !errMatch) return null; - // `renderLaunchdPlist` runs values through `escapeXml`, so a path containing - // `&`, `<`, etc. is stored escaped in the plist. Decode before returning so - // the caller can stat/tail the actual file on disk. - return { out: unescapeXml(outMatch[1]), err: unescapeXml(errMatch[1]) }; -} - -function unescapeXml(value: string): string { - return value - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, "&"); // last — must not re-decode a `&amp;`-style sequence -} - -function runLaunchctl(args: string[], opts: { allowFailure?: boolean } = {}): void { - const res = spawnSync("launchctl", args, { stdio: "inherit" }); - if (res.status !== 0 && !opts.allowFailure) { - throw new Error(`launchctl ${args.join(" ")} failed with code ${res.status}`); - } -} - -function printPostInstallHints(ctx: Ctx, logDir: string): void { - console.log(""); - console.log("[kandev] Useful commands:"); - console.log(`[kandev] launchctl print ${ctx.target}`); - console.log(`[kandev] kandev service restart${ctx.isSystem ? " --system" : ""}`); - console.log(`[kandev] tail -f ${path.join(logDir, "service.err")}`); -} diff --git a/apps/cli/src/service/metadata.test.ts b/apps/cli/src/service/metadata.test.ts deleted file mode 100644 index afcfde1c93..0000000000 --- a/apps/cli/src/service/metadata.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -import { - buildServiceInstallMetadata, - serviceMetadataPath, - writeServiceInstallMetadata, -} from "./metadata"; - -describe("service metadata", () => { - it("builds the install metadata written beside service units", () => { - const metadata = buildServiceInstallMetadata({ - manager: "systemd", - mode: "user", - launcher: { - nodePath: "/usr/bin/node", - cliEntry: "/usr/lib/node_modules/kandev/bin/cli.js", - kind: "npm", - }, - homeDir: "/home/alice/.kandev", - logDir: "/home/alice/.kandev/logs", - servicePath: "/home/alice/.config/systemd/user/kandev.service", - port: 38429, - now: new Date("2026-05-29T00:00:00.000Z"), - }); - - expect(metadata).toMatchObject({ - version: 1, - manager: "systemd", - mode: "user", - kind: "npm", - home_dir: "/home/alice/.kandev", - service_path: "/home/alice/.config/systemd/user/kandev.service", - node_path: "/usr/bin/node", - cli_entry: "/usr/lib/node_modules/kandev/bin/cli.js", - port: 38429, - installed_at: "2026-05-29T00:00:00.000Z", - }); - }); - - it("writes metadata JSON under /service/install.json", () => { - const tmp = fs.mkdtempSync(path.join(process.cwd(), "metadata-test-")); - try { - const target = serviceMetadataPath(tmp); - writeServiceInstallMetadata(target, { - version: 1, - manager: "launchd", - mode: "user", - kind: "homebrew", - home_dir: tmp, - log_dir: path.join(tmp, "logs"), - service_path: "/Users/alice/Library/LaunchAgents/com.kdlbs.kandev.plist", - node_path: "/opt/homebrew/bin/node", - cli_entry: "/opt/homebrew/opt/kandev/libexec/cli/bin/cli.js", - installed_at: "2026-05-29T00:00:00.000Z", - }); - - const parsed = JSON.parse(fs.readFileSync(target, "utf8")) as { manager: string }; - expect(parsed.manager).toBe("launchd"); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/cli/src/service/metadata.ts b/apps/cli/src/service/metadata.ts deleted file mode 100644 index 01c19173e6..0000000000 --- a/apps/cli/src/service/metadata.ts +++ /dev/null @@ -1,76 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -import type { LauncherInfo } from "./paths"; - -export type ServiceManager = "systemd" | "launchd"; -export type ServiceMode = "user" | "system"; - -export type ServiceInstallMetadata = { - version: 1; - manager: ServiceManager; - mode: ServiceMode; - kind: LauncherInfo["kind"]; - home_dir: string; - log_dir: string; - service_path: string; - node_path: string; - cli_entry: string; - bundle_dir?: string; - launcher_version?: string; - port?: number; - system_user?: string; - installed_at: string; -}; - -export type BuildServiceInstallMetadataInput = { - manager: ServiceManager; - mode: ServiceMode; - launcher: LauncherInfo; - homeDir: string; - logDir: string; - servicePath: string; - port?: number; - systemUser?: string; - now?: Date; -}; - -export function serviceMetadataPath(homeDir: string): string { - return path.join(homeDir, "service", "install.json"); -} - -export function buildServiceInstallMetadata( - input: BuildServiceInstallMetadataInput, -): ServiceInstallMetadata { - const out: ServiceInstallMetadata = { - version: 1, - manager: input.manager, - mode: input.mode, - kind: input.launcher.kind, - home_dir: input.homeDir, - log_dir: input.logDir, - service_path: input.servicePath, - node_path: input.launcher.nodePath, - cli_entry: input.launcher.cliEntry, - installed_at: (input.now ?? new Date()).toISOString(), - }; - if (input.launcher.bundleDir) out.bundle_dir = input.launcher.bundleDir; - if (input.launcher.version) out.launcher_version = input.launcher.version; - if (input.port !== undefined) out.port = input.port; - if (input.systemUser) out.system_user = input.systemUser; - return out; -} - -export function writeServiceInstallMetadata( - metadataPath: string, - metadata: ServiceInstallMetadata, -): void { - const dir = path.dirname(metadataPath); - // `mode` on mkdir/writeFile only applies when the path is created. chmod after - // so a pre-existing service dir / install.json is tightened to owner-only too - // (it can hold launcher paths and the metadata that gates self-update). - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - fs.chmodSync(dir, 0o700); - fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 }); - fs.chmodSync(metadataPath, 0o600); -} diff --git a/apps/cli/src/service/paths.test.ts b/apps/cli/src/service/paths.test.ts deleted file mode 100644 index d614952d02..0000000000 --- a/apps/cli/src/service/paths.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { captureLauncher, homebrewShimPath, resolveHomeDir, resolveServiceUser } from "./paths"; - -const originalArgv = [...process.argv]; -const originalBundleDir = process.env.KANDEV_BUNDLE_DIR; -const originalVersion = process.env.KANDEV_VERSION; - -describe("resolveServiceUser", () => { - const originalSudoUser = process.env.SUDO_USER; - - beforeEach(() => { - delete process.env.SUDO_USER; - }); - - afterEach(() => { - if (originalSudoUser === undefined) delete process.env.SUDO_USER; - else process.env.SUDO_USER = originalSudoUser; - vi.restoreAllMocks(); - }); - - it("returns the current user for user-mode installs", () => { - vi.spyOn(os, "userInfo").mockReturnValue({ - username: "alice", - uid: 1000, - gid: 1000, - shell: null, - homedir: "/home/alice", - }); - process.env.SUDO_USER = "bob"; // ignored in user mode - expect(resolveServiceUser(false)).toBe("alice"); - }); - - it("prefers SUDO_USER for system-mode installs", () => { - vi.spyOn(os, "userInfo").mockReturnValue({ - username: "root", - uid: 0, - gid: 0, - shell: null, - homedir: "/root", - }); - process.env.SUDO_USER = "alice"; - expect(resolveServiceUser(true)).toBe("alice"); - }); - - it("falls back to current user when SUDO_USER is not set", () => { - vi.spyOn(os, "userInfo").mockReturnValue({ - username: "root", - uid: 0, - gid: 0, - shell: null, - homedir: "/root", - }); - expect(resolveServiceUser(true)).toBe("root"); - }); - - it("ignores SUDO_USER=root and falls through to current user", () => { - // sudo never sets SUDO_USER=root in practice, but defend against it - // so we don't accidentally pin the daemon to root by accident. - vi.spyOn(os, "userInfo").mockReturnValue({ - username: "alice", - uid: 1000, - gid: 1000, - shell: null, - homedir: "/home/alice", - }); - process.env.SUDO_USER = "root"; - expect(resolveServiceUser(true)).toBe("alice"); - }); -}); - -describe("captureLauncher", () => { - afterEach(() => { - process.argv.splice(0, process.argv.length, ...originalArgv); - if (originalBundleDir === undefined) delete process.env.KANDEV_BUNDLE_DIR; - else process.env.KANDEV_BUNDLE_DIR = originalBundleDir; - if (originalVersion === undefined) delete process.env.KANDEV_VERSION; - else process.env.KANDEV_VERSION = originalVersion; - vi.restoreAllMocks(); - }); - - it("resolves npm global bin symlinks before detecting the install kind", () => { - delete process.env.KANDEV_BUNDLE_DIR; - delete process.env.KANDEV_VERSION; - process.argv[1] = "/tmp/kandev-test/npm-global/bin/kandev"; - vi.spyOn(fs, "existsSync").mockReturnValue(true); - vi.spyOn(fs, "realpathSync").mockReturnValue( - "/tmp/kandev-test/npm-global/lib/node_modules/kandev/bin/cli.js", - ); - - expect(captureLauncher()).toMatchObject({ - cliEntry: "/tmp/kandev-test/npm-global/lib/node_modules/kandev/bin/cli.js", - kind: "npm", - }); - }); -}); - -describe("resolveHomeDir", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("returns an absolute override as-is (no tilde)", () => { - expect(resolveHomeDir("/srv/kandev", false)).toBe("/srv/kandev"); - }); - - it("expands a leading ~/ to os.homedir()", () => { - vi.spyOn(os, "homedir").mockReturnValue("/home/alice"); - expect(resolveHomeDir("~/kandev", false)).toBe("/home/alice/kandev"); - expect(resolveHomeDir("~/srv/nested", false)).toBe("/home/alice/srv/nested"); - }); - - it("expands a bare ~ to os.homedir()", () => { - vi.spyOn(os, "homedir").mockReturnValue("/home/alice"); - expect(resolveHomeDir("~", false)).toBe("/home/alice"); - }); - - it("does not expand a tilde that isn't at the start", () => { - expect(resolveHomeDir("/srv/~/data", false)).toBe("/srv/~/data"); - }); - - it("falls back to /var/lib/kandev for system mode with no override", () => { - expect(resolveHomeDir(undefined, true)).toBe("/var/lib/kandev"); - }); - - it("falls back to ~/.kandev for user mode with no override", () => { - // KANDEV_HOME_DIR is the constant from ../constants, computed at import - // time. We just check it ends with .kandev and starts with the home dir. - const result = resolveHomeDir(undefined, false); - expect(result.endsWith(".kandev")).toBe(true); - }); -}); - -describe("homebrewShimPath", () => { - it("derives the floating bin shim from a linuxbrew Cellar cli.js path", () => { - expect( - homebrewShimPath("/home/linuxbrew/.linuxbrew/Cellar/kandev/0.52.0/libexec/cli/bin/cli.js"), - ).toBe("/home/linuxbrew/.linuxbrew/bin/kandev"); - }); - - it("derives the floating bin shim from an Apple-silicon Cellar path", () => { - expect(homebrewShimPath("/opt/homebrew/Cellar/kandev/1.2.3/libexec/cli/bin/cli.js")).toBe( - "/opt/homebrew/bin/kandev", - ); - }); - - it("returns undefined for a non-Cellar path (npm install)", () => { - expect(homebrewShimPath("/usr/local/lib/node_modules/kandev/bin/cli.js")).toBeUndefined(); - }); -}); - -describe("captureLauncher shim resolution", () => { - const originalArgv = process.argv; - - afterEach(() => { - process.argv = originalArgv; - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - }); - - it("adopts the floating shim when it exists on disk", () => { - const cliEntry = "/opt/homebrew/Cellar/kandev/1.2.3/libexec/cli/bin/cli.js"; - const shim = "/opt/homebrew/bin/kandev"; - process.argv = ["/path/to/node", cliEntry]; - vi.stubEnv("KANDEV_BUNDLE_DIR", "/opt/homebrew/Cellar/kandev/1.2.3/libexec"); - // Both the cli.js entry and the shim resolve on disk. - vi.spyOn(fs, "existsSync").mockImplementation((p) => p === cliEntry || p === shim); - vi.spyOn(fs, "realpathSync").mockReturnValue(cliEntry); - - const info = captureLauncher(); - expect(info.kind).toBe("homebrew"); - expect(info.shimPath).toBe(shim); - }); - - it("falls back to version-pinned paths when the shim is absent on disk", () => { - const cliEntry = "/opt/homebrew/Cellar/kandev/1.2.3/libexec/cli/bin/cli.js"; - process.argv = ["/path/to/node", cliEntry]; - vi.stubEnv("KANDEV_BUNDLE_DIR", "/opt/homebrew/Cellar/kandev/1.2.3/libexec"); - // The cli.js entry resolves, but the floating shim does not exist. - vi.spyOn(fs, "existsSync").mockImplementation((p) => p === cliEntry); - vi.spyOn(fs, "realpathSync").mockReturnValue(cliEntry); - - const info = captureLauncher(); - expect(info.kind).toBe("homebrew"); - expect(info.shimPath).toBeUndefined(); - }); - - it("classifies a non-Cellar bundle as a local checkout install", () => { - const cliEntry = "/Users/alice/src/kandev/dist/kandev/cli/bin/cli.js"; - process.argv = ["/path/to/node", cliEntry]; - vi.stubEnv("KANDEV_BUNDLE_DIR", "/Users/alice/src/kandev/dist/kandev"); - vi.spyOn(fs, "existsSync").mockImplementation((p) => p === cliEntry); - vi.spyOn(fs, "realpathSync").mockReturnValue(cliEntry); - - const info = captureLauncher(); - expect(info.kind).toBe("local"); - expect(info.bundleDir).toBe("/Users/alice/src/kandev/dist/kandev"); - expect(info.shimPath).toBeUndefined(); - }); -}); diff --git a/apps/cli/src/service/paths.ts b/apps/cli/src/service/paths.ts deleted file mode 100644 index f5114034b3..0000000000 --- a/apps/cli/src/service/paths.ts +++ /dev/null @@ -1,188 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { KANDEV_HOME_DIR } from "../constants"; - -/** Service unit/plist locations. Single source of truth for where kandev */ -/** writes/reads its unit files — linux.ts, macos.ts, config.ts, stale_check.ts */ -/** all consume these. Exposed as functions (not eager constants) so tests can */ -/** mock `os.homedir()` between cases. */ -export const SERVICE_NAME = "kandev"; -export const LAUNCHD_LABEL = "com.kdlbs.kandev"; - -export const LINUX_SYSTEM_UNIT_DIR = "/etc/systemd/system"; -export const MACOS_SYSTEM_DAEMON_DIR = "/Library/LaunchDaemons"; - -export function linuxUserUnitDir(): string { - return path.join(os.homedir(), ".config", "systemd", "user"); -} -export function linuxUserUnitPath(): string { - return path.join(linuxUserUnitDir(), `${SERVICE_NAME}.service`); -} -export function linuxSystemUnitPath(): string { - return path.join(LINUX_SYSTEM_UNIT_DIR, `${SERVICE_NAME}.service`); -} - -export function macosUserAgentDir(): string { - return path.join(os.homedir(), "Library", "LaunchAgents"); -} -export function macosUserPlistPath(): string { - return path.join(macosUserAgentDir(), `${LAUNCHD_LABEL}.plist`); -} -export function macosSystemPlistPath(): string { - return path.join(MACOS_SYSTEM_DAEMON_DIR, `${LAUNCHD_LABEL}.plist`); -} - -export type LauncherKind = "homebrew" | "npm" | "npx" | "local" | "unknown"; - -export type LauncherInfo = { - /** Absolute path to node executable (process.execPath at install time). */ - nodePath: string; - /** Absolute path to the cli.js entry point. */ - cliEntry: string; - /** Best-guess of how kandev was installed. Used to seed env vars. */ - kind: LauncherKind; - /** KANDEV_BUNDLE_DIR if set (Homebrew and local checkout installs set this). */ - bundleDir?: string; - /** KANDEV_VERSION if set (Homebrew sets this). */ - version?: string; - /** - * Absolute path to the floating Homebrew launcher shim - * (`/bin/kandev`), set only for Homebrew installs where the shim - * exists on disk. When present, the unit is rendered to exec this shim - * instead of the version-pinned Cellar node + cli.js, so it survives - * `brew upgrade`. See {@link homebrewShimPath}. - */ - shimPath?: string; -}; - -// Homebrew is POSIX-only, so the Cellar segment is a hardcoded "/Cellar/" -// rather than `path.sep`-based. On a Windows CI runner `path.sep` would be -// "\\", which would never match a POSIX Cellar path and silently break shim -// derivation (and its tests). -const HOMEBREW_CELLAR_SEGMENT = "/Cellar/"; - -/** - * Derive the floating Homebrew launcher shim from a Cellar-installed cli.js path. - * - * Homebrew installs the CLI under `/Cellar/kandev//...` and - * symlinks a version-independent shim at `/bin/kandev`. That shim sets - * KANDEV_BUNDLE_DIR / KANDEV_VERSION itself and execs cli.js via the floating - * `opt/node` symlink, so it keeps working after `brew upgrade` deletes the old - * Cellar dir. Returns undefined when `cliEntry` isn't a Cellar layout (npm / - * unknown installs), so callers fall back to the version-pinned paths. - */ -export function homebrewShimPath(cliEntry: string): string | undefined { - const idx = cliEntry.indexOf(HOMEBREW_CELLAR_SEGMENT); - if (idx === -1) return undefined; - const prefix = cliEntry.slice(0, idx); - // Homebrew layout is POSIX; use path.posix.join so the result keeps forward - // slashes regardless of the host the install/tests run on. - return path.posix.join(prefix, "bin", SERVICE_NAME); -} - -/** - * Snapshot the current invocation so the service unit can faithfully reproduce it. - * - * The unit file hard-codes absolute paths because systemd/launchd start with an - * empty PATH and may not see the user's `node` or `kandev` shim. By recording - * `process.execPath` (node) and the resolved CLI entry at install time we avoid - * any PATH lookups at service-run time. - */ -export function captureLauncher(): LauncherInfo { - const nodePath = process.execPath; - const cliEntry = resolveCliEntry(); - const bundleDir = process.env.KANDEV_BUNDLE_DIR; - const version = process.env.KANDEV_VERSION; - const kind: LauncherKind = bundleDir - ? homebrewShimPath(cliEntry) - ? "homebrew" - : "local" - : looksLikeNpxEntry(cliEntry) - ? "npx" - : cliEntry.includes(`${path.sep}node_modules${path.sep}`) - ? "npm" - : "unknown"; - // For Homebrew installs, prefer the floating bin shim so the unit survives - // `brew upgrade` (which deletes the versioned Cellar dir baked into nodePath - // /cliEntry). Only adopt it when the shim actually exists on disk; otherwise - // fall back to the version-pinned paths below. - let shimPath: string | undefined; - if (kind === "homebrew") { - const candidate = homebrewShimPath(cliEntry); - if (candidate && fs.existsSync(candidate)) shimPath = candidate; - } - return { nodePath, cliEntry, kind, bundleDir, version, shimPath }; -} - -function looksLikeNpxEntry(cliEntry: string): boolean { - return cliEntry.includes(`${path.sep}_npx${path.sep}`); -} - -function resolveCliEntry(): string { - const argvEntry = process.argv[1]; - if (argvEntry && fs.existsSync(argvEntry)) { - return path.resolve(fs.realpathSync(argvEntry)); - } - throw new Error( - "could not resolve the kandev CLI entry path from process.argv[1]; " + - "rerun via the kandev binary", - ); -} - -/** Resolve the home directory used for the unit's KANDEV_HOME_DIR env. */ -export function resolveHomeDir(override: string | undefined, runAsRoot: boolean): string { - if (override) { - // Node's path.resolve doesn't expand `~`; users often type `--home-dir ~/foo` - // (especially via shell escapes that defer expansion), so do it ourselves. - const expanded = - override === "~" || override.startsWith(`~${path.sep}`) - ? path.join(os.homedir(), override.slice(1)) - : override; - return path.resolve(expanded); - } - if (runAsRoot) { - // System units default to /var/lib/kandev so root-owned data lives outside any - // single user's $HOME (where it would be unreachable to other users). - return "/var/lib/kandev"; - } - return KANDEV_HOME_DIR; -} - -/** Absolute path to the log directory used by the unit for stdout/stderr. */ -export function resolveLogDir(homeDir: string): string { - return path.join(homeDir, "logs"); -} - -/** Current username (the EUID the CLI is running as). */ -export function currentUsername(): string { - return os.userInfo().username; -} - -/** - * Resolve which user the service should run as. - * - * For user-mode installs this is always the current user (matters for hints - * printed back to the user, not for the unit itself — user units don't set - * `User=`). - * - * For system-mode installs the CLI is typically invoked via sudo, which makes - * `os.userInfo().username` resolve to `root`. We prefer `SUDO_USER` so the - * daemon runs as the human who installed it (with access to their `~/.kandev`, - * git config, agent CLI credentials, etc) rather than as root. - * - * If the user genuinely wants a root-owned daemon they can run sudo with - * `-E` stripped or pass `--run-as root` (future flag) — but the common case - * (`sudo kandev service install --system`) gets the safe default. - */ -export function resolveServiceUser(isSystem: boolean): string { - if (!isSystem) { - return currentUsername(); - } - const sudoUser = process.env.SUDO_USER; - if (sudoUser && sudoUser !== "root") { - return sudoUser; - } - return currentUsername(); -} diff --git a/apps/cli/src/service/self_update.test.ts b/apps/cli/src/service/self_update.test.ts deleted file mode 100644 index 2f23fe275e..0000000000 --- a/apps/cli/src/service/self_update.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -import { describe, expect, it, vi } from "vitest"; - -import { planSelfUpdate, runSelfUpdateCommand, type SelfUpdateIntent } from "./self_update"; - -function intent(kind: "homebrew" | "npm" | "npx" = "npm"): SelfUpdateIntent { - return { - version: 1, - target_tag: "v1.2.3", - target_version: "1.2.3", - latest_url: "https://example/v1.2.3", - created_at: "2026-05-29T00:00:00.000Z", - install: { - version: 1, - manager: "systemd", - mode: "user", - kind, - home_dir: "/home/alice/.kandev", - log_dir: "/home/alice/.kandev/logs", - service_path: "/home/alice/.config/systemd/user/kandev.service", - node_path: "/usr/bin/node", - cli_entry: "/usr/lib/node_modules/kandev/bin/cli.js", - port: 38429, - installed_at: "2026-05-29T00:00:00.000Z", - }, - }; -} - -describe("planSelfUpdate", () => { - it("plans npm upgrade, service reinstall, then user-service restart", () => { - expect(planSelfUpdate(intent("npm"), { platform: "linux" })).toEqual([ - { command: "npm", args: ["install", "-g", "--prefix", "/usr", "kandev@1.2.3"] }, - { - command: "/usr/bin/node", - args: [ - "/usr/lib/node_modules/kandev/bin/cli.js", - "service", - "install", - "--home-dir", - "/home/alice/.kandev", - "--port", - "38429", - ], - }, - { command: "systemctl", args: ["--user", "restart", "kandev"] }, - ]); - }); - - it("installs an exact npm nightly prerelease target", () => { - const nightly = intent("npm"); - nightly.target_tag = "v0.82.1-nightly.shaabc123def456"; - nightly.target_version = "0.82.1-nightly.shaabc123def456"; - - expect(planSelfUpdate(nightly, { platform: "linux" })[0]).toEqual({ - command: "npm", - args: ["install", "-g", "--prefix", "/usr", "kandev@0.82.1-nightly.shaabc123def456"], - }); - }); - - it("keeps npm updates inside the original global prefix", () => { - const npm = intent("npm"); - npm.install.cli_entry = "/tmp/kandev-test/npm-global/lib/node_modules/kandev/bin/cli.js"; - - expect(planSelfUpdate(npm, { platform: "linux" })[0]).toEqual({ - command: "npm", - args: ["install", "-g", "--prefix", "/tmp/kandev-test/npm-global", "kandev@1.2.3"], - }); - }); - - it("falls back to npm's configured prefix when the cli path is non-standard", () => { - const npm = intent("npm"); - npm.install.cli_entry = "/opt/kandev/bin/cli.js"; - - expect(planSelfUpdate(npm, { platform: "linux" })[0]).toEqual({ - command: "npm", - args: ["install", "-g", "kandev@1.2.3"], - }); - }); - - it("plans homebrew upgrade then reinstall via the kandev wrapper (not node )", () => { - const brew = intent("homebrew"); - // A real Homebrew install records a version-pinned Cellar cli_entry. - brew.install.cli_entry = "/opt/homebrew/Cellar/kandev/1.2.2/libexec/cli/bin/cli.js"; - brew.install.node_path = "/opt/homebrew/bin/node"; - - const commands = planSelfUpdate(brew, { platform: "linux" }); - - expect(commands[0]).toEqual({ command: "brew", args: ["upgrade", "kandev"] }); - // Step 2 must use the upgraded `kandev` wrapper (version-stable, re-sets - // KANDEV_BUNDLE_DIR), never `node` on the stale version-pinned cli_entry. - expect(commands[1]).toEqual({ - command: "kandev", - args: ["service", "install", "--home-dir", "/home/alice/.kandev", "--port", "38429"], - }); - expect(commands[1].command).not.toBe("/opt/homebrew/bin/node"); - }); - - it("plans npx reinstall without mutating global npm packages", () => { - const commands = planSelfUpdate(intent("npx"), { platform: "linux" }); - expect(commands[0]).toEqual({ - command: "npx", - args: [ - "-y", - "kandev@1.2.3", - "service", - "install", - "--home-dir", - "/home/alice/.kandev", - "--port", - "38429", - ], - }); - }); - - it("reinstalls an exact npx nightly prerelease target", () => { - const nightly = intent("npx"); - nightly.target_tag = "v0.82.1-nightly.shaabc123def456"; - nightly.target_version = "0.82.1-nightly.shaabc123def456"; - - expect(planSelfUpdate(nightly, { platform: "linux" })[0]).toEqual({ - command: "npx", - args: [ - "-y", - "kandev@0.82.1-nightly.shaabc123def456", - "service", - "install", - "--home-dir", - "/home/alice/.kandev", - "--port", - "38429", - ], - }); - }); - - it("plans launchd restart on macOS", () => { - const mac = intent("homebrew"); - mac.install.manager = "launchd"; - mac.install.service_path = "/Users/alice/Library/LaunchAgents/com.kdlbs.kandev.plist"; - mac.install.home_dir = "/Users/alice/.kandev"; - mac.install.log_dir = "/Users/alice/.kandev/logs"; - - const commands = planSelfUpdate(mac, { platform: "darwin", uid: 501 }); - expect(commands.at(-1)).toEqual({ - command: "launchctl", - args: ["kickstart", "-k", "gui/501/com.kdlbs.kandev"], - }); - }); -}); - -describe("runSelfUpdateCommand", () => { - it("does not run commands in dry-run mode", () => { - const tmp = fs.mkdtempSync(path.join(process.cwd(), "self-update-test-")); - const intentPath = path.join(tmp, "intent.json"); - fs.writeFileSync(intentPath, JSON.stringify(intent("npm"))); - const runner = vi.fn(); - const log = vi.spyOn(console, "log").mockImplementation(() => undefined); - try { - runSelfUpdateCommand({ action: "self-update", intent: intentPath, dryRun: true }, runner); - expect(runner).not.toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith(expect.stringContaining('"dry_run": true')); - } finally { - log.mockRestore(); - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - function readLog(logDir: string): string { - const file = fs.readdirSync(logDir).find((name) => name.startsWith("self-update-")); - expect(file, "a self-update log file should be written").toBeTruthy(); - return fs.readFileSync(path.join(logDir, file as string), "utf8"); - } - - it("tees each command, its output, and exit status to a log file", () => { - const tmp = fs.mkdtempSync(path.join(process.cwd(), "self-update-test-")); - const logDir = path.join(tmp, "logs"); - const it_ = intent("npm"); - it_.install.log_dir = logDir; - const intentPath = path.join(tmp, "intent.json"); - fs.writeFileSync(intentPath, JSON.stringify(it_)); - const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - const runner = vi.fn().mockReturnValue({ status: 0, stdout: Buffer.from("ok output") }); - try { - runSelfUpdateCommand({ action: "self-update", intent: intentPath }, runner); - const contents = readLog(logDir); - expect(contents).toContain("npm install -g"); - expect(contents).toContain("service install"); - expect(contents).toContain("ok output"); - expect(contents).toContain("exit 0"); - expect(contents).toContain("self-update completed successfully"); - } finally { - stdout.mockRestore(); - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("logs the failing command and its code before throwing", () => { - const tmp = fs.mkdtempSync(path.join(process.cwd(), "self-update-test-")); - const logDir = path.join(tmp, "logs"); - const it_ = intent("npm"); - it_.install.log_dir = logDir; - const intentPath = path.join(tmp, "intent.json"); - fs.writeFileSync(intentPath, JSON.stringify(it_)); - const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - const runner = vi.fn().mockReturnValue({ status: 7, stderr: Buffer.from("npm boom") }); - try { - expect(() => - runSelfUpdateCommand({ action: "self-update", intent: intentPath }, runner), - ).toThrow(/failed with code 7/); - const contents = readLog(logDir); - expect(contents).toContain("npm boom"); - expect(contents).toContain("exited with code 7"); - expect(contents).not.toContain("self-update completed successfully"); - } finally { - stdout.mockRestore(); - stderr.mockRestore(); - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/cli/src/service/self_update.ts b/apps/cli/src/service/self_update.ts deleted file mode 100644 index 4aa6feac8b..0000000000 --- a/apps/cli/src/service/self_update.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import type { ServiceArgs } from "./args"; -import type { ServiceInstallMetadata } from "./metadata"; -import { LAUNCHD_LABEL, SERVICE_NAME } from "./paths"; - -export type SelfUpdateIntent = { - version: 1; - target_tag: string; - target_version: string; - latest_url?: string; - install: ServiceInstallMetadata; - created_at: string; -}; - -export type PlannedCommand = { - command: string; - args: string[]; -}; - -export type PlanSelfUpdateOptions = { - platform?: NodeJS.Platform; - uid?: number; -}; - -export type CommandResult = Pick, "status" | "error"> & { - stdout?: Buffer | string | null; - stderr?: Buffer | string | null; -}; - -export type CommandRunner = (command: string, args: string[]) => CommandResult; - -export function readSelfUpdateIntent(intentPath: string): SelfUpdateIntent { - return JSON.parse(fs.readFileSync(intentPath, "utf8")) as SelfUpdateIntent; -} - -export function planSelfUpdate( - intent: SelfUpdateIntent, - opts: PlanSelfUpdateOptions = {}, -): PlannedCommand[] { - const platform = opts.platform ?? process.platform; - const target = npmVersion(intent.target_version || intent.target_tag); - const install = intent.install; - const installArgs = serviceInstallArgs(install); - const commands: PlannedCommand[] = []; - - if (install.kind === "homebrew") { - // `brew upgrade` installs the tap formula's current version; it can't be - // pinned to intent.target_version the way npm/npx can (Homebrew has no - // stable "install version X" without a separate versioned formula). In - // practice both target_version and the formula derive from the same latest - // GitHub release, so they match. If the formula lags, the restarted backend - // reports the older version and the frontend progress poll (which waits for - // info.version === target_version) times out gracefully rather than - // reporting a false success. - commands.push({ command: "brew", args: ["upgrade", "kandev"] }); - // Re-run service install via the upgraded `kandev` wrapper (resolved on - // PATH), NOT `node `. Homebrew installs into version-pinned - // Cellar dirs, so the recorded cli_entry still points at the OLD bundle - // after `brew upgrade`; worse, invoking node on it directly runs without - // KANDEV_BUNDLE_DIR/KANDEV_VERSION, so the install is mis-detected as - // kind "unknown" and the regenerated unit loses the bundle env — the - // restarted backend then can't find its runtime and the service stays down. - // The wrapper is version-stable and re-sets the bundle env. (Mirrors the - // manual `kandev service install` we already document for Homebrew.) - commands.push({ command: "kandev", args: installArgs }); - } else if (install.kind === "npm") { - commands.push({ command: "npm", args: npmInstallArgs(install.cli_entry, target) }); - commands.push({ command: install.node_path, args: [install.cli_entry, ...installArgs] }); - } else if (install.kind === "npx") { - commands.push({ command: "npx", args: ["-y", `kandev@${target}`, ...installArgs] }); - } else { - throw new Error(`unsupported install kind "${install.kind}"`); - } - - commands.push(restartCommand(install, platform, opts.uid)); - return commands; -} - -export function runSelfUpdateCommand( - args: ServiceArgs, - runner: CommandRunner = spawnCommand, -): void { - if (!args.intent) { - throw new Error("kandev service self-update requires --intent "); - } - const intent = readSelfUpdateIntent(args.intent); - const commands = planSelfUpdate(intent); - if (args.dryRun || process.env.KANDEV_E2E_MOCK === "true") { - console.log( - JSON.stringify( - { - dry_run: !!args.dryRun, - fake: process.env.KANDEV_E2E_MOCK === "true", - target_version: intent.target_version, - commands, - }, - null, - 2, - ), - ); - return; - } - const log = openSelfUpdateLog(intent); - try { - log?.line(`self-update target ${intent.target_tag} (${intent.target_version})`); - log?.line(`install kind=${intent.install.kind} manager=${intent.install.manager}`); - log?.line(`planned ${commands.length} command(s):`); - commands.forEach((step, i) => - log?.line(` [${i + 1}/${commands.length}] ${formatCommand(step)}`), - ); - runSelfUpdateSteps(commands, runner, log); - log?.line("self-update completed successfully"); - } finally { - log?.close(); - } -} - -function runSelfUpdateSteps( - commands: PlannedCommand[], - runner: CommandRunner, - log: SelfUpdateLog | null, -): void { - for (const [i, step] of commands.entries()) { - log?.line(`\n[${i + 1}/${commands.length}] $ ${formatCommand(step)}`); - const res = runner(step.command, step.args); - teeCommandOutput(log, res); - if (res.error) { - log?.line(`! spawn error: ${res.error.message}`); - throw res.error; - } - if (res.status !== 0) { - log?.line(`! exited with code ${res.status}`); - throw new Error(`${formatCommand(step)} failed with code ${res.status}`); - } - log?.line(" exit 0"); - } -} - -function formatCommand(step: PlannedCommand): string { - return [step.command, ...step.args].join(" "); -} - -function serviceInstallArgs(install: ServiceInstallMetadata): string[] { - const args = ["service", "install"]; - if (install.mode === "system") args.push("--system"); - args.push("--home-dir", install.home_dir); - if (install.port !== undefined) { - args.push("--port", String(install.port)); - } - return args; -} - -function npmInstallArgs(cliEntry: string, target: string): string[] { - const args = ["install", "-g"]; - const prefix = npmPrefixFromCliEntry(cliEntry); - if (prefix) { - args.push("--prefix", prefix); - } - args.push(`kandev@${target}`); - return args; -} - -function npmPrefixFromCliEntry(cliEntry: string): string | undefined { - const marker = `${path.sep}lib${path.sep}node_modules${path.sep}kandev${path.sep}`; - const index = cliEntry.indexOf(marker); - if (index < 0) { - return undefined; - } - return index === 0 ? path.sep : cliEntry.slice(0, index); -} - -function restartCommand( - install: ServiceInstallMetadata, - platform: NodeJS.Platform, - uid?: number, -): PlannedCommand { - if (platform === "linux") { - return install.mode === "system" - ? { command: "systemctl", args: ["restart", SERVICE_NAME] } - : { command: "systemctl", args: ["--user", "restart", SERVICE_NAME] }; - } - if (platform === "darwin") { - // Resolve the uid lazily inside the darwin branch — Linux never needs it, so - // os.userInfo() shouldn't run there. - const resolvedUid = uid ?? os.userInfo().uid; - const domain = install.mode === "system" ? "system" : `gui/${resolvedUid}`; - return { command: "launchctl", args: ["kickstart", "-k", `${domain}/${LAUNCHD_LABEL}`] }; - } - throw new Error(`unsupported platform "${platform}"`); -} - -function npmVersion(versionOrTag: string): string { - const stripped = versionOrTag.replace(/^v/, ""); - return stripped || "latest"; -} - -function spawnCommand(command: string, args: string[]): CommandResult { - // Capture stdout/stderr (rather than inheriting) so the self-update log can - // tee each step's output. The helper runs detached under launchd/systemd, so - // its console output would otherwise go nowhere visible — the log file is the - // only forensic trail when an update fails mid-flight. maxBuffer is bumped - // well above the 1 MiB default because `npm install -g` is chatty. - return spawnSync(command, args, { maxBuffer: 64 * 1024 * 1024 }); -} - -type SelfUpdateLog = { - path: string; - line: (message: string) => void; - raw: (chunk: Buffer | string) => void; - close: () => void; -}; - -/** - * Open a timestamped log file under the service's log dir for this self-update - * run. The helper is spawned outside the service process (so a restart doesn't - * kill it mid-update), which means it has no service log of its own — without - * this file a failed update on macOS/launchd is nearly invisible. Best-effort: - * if the log dir can't be created we return null and the update still runs. - */ -function openSelfUpdateLog(intent: SelfUpdateIntent): SelfUpdateLog | null { - const dir = intent.install.log_dir || logDirFromHome(intent.install.home_dir); - if (!dir) { - return null; - } - try { - fs.mkdirSync(dir, { recursive: true }); - // Self-update logs can contain install paths/env; keep the dir owner-only - // even if it pre-existed with looser perms. - fs.chmodSync(dir, 0o700); - const stamp = new Date().toISOString().replace(/[:.]/g, "-"); - const filePath = path.join(dir, `self-update-${stamp}.log`); - const fd = fs.openSync(filePath, "a"); - const write = (chunk: Buffer | string): void => { - try { - // The `Buffer | string` union matches neither writeSync overload - // directly; the cast picks one — both write the runtime value correctly. - fs.writeSync(fd, chunk as string); - } catch { - // A write failure mid-update must not abort the update itself. - } - }; - process.stdout.write(`[kandev] self-update log: ${filePath}\n`); - write(`# kandev self-update ${new Date().toISOString()}\n`); - return { - path: filePath, - line: (message) => write(`${message}\n`), - raw: (chunk) => write(chunk), - close: () => { - try { - fs.closeSync(fd); - } catch { - // already closed / never opened - } - }, - }; - } catch { - return null; - } -} - -function logDirFromHome(homeDir: string): string { - return homeDir ? path.join(homeDir, "logs") : ""; -} - -// Mirror a command's captured output to both the helper log and the process's -// own stdout/stderr, so whatever does manage to capture the helper's streams -// (e.g. launchd's StandardOut/ErrorPath, systemd's journal) still sees it. -function teeCommandOutput(log: SelfUpdateLog | null, res: CommandResult): void { - if (res.stdout && res.stdout.length) { - process.stdout.write(res.stdout); - log?.raw(res.stdout); - } - if (res.stderr && res.stderr.length) { - process.stderr.write(res.stderr); - log?.raw(res.stderr); - } -} diff --git a/apps/cli/src/service/stale_check.test.ts b/apps/cli/src/service/stale_check.test.ts deleted file mode 100644 index d5b06da907..0000000000 --- a/apps/cli/src/service/stale_check.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { detectStaleServiceUnit } from "./stale_check"; - -// Stable values; the actual node + cli paths the renderer sees would normally -// come from process.execPath / process.argv[1], which point to whatever's -// running vitest. We override them in tests to make assertions deterministic. -const NODE_PATH = "/usr/local/bin/node"; -const CLI_ENTRY = path.resolve(__dirname, "../../bin/cli.js"); - -describe("detectStaleServiceUnit", () => { - let tmpHome: string; - let unitPath: string; - - beforeEach(() => { - tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-stale-")); - vi.spyOn(os, "homedir").mockReturnValue(tmpHome); - vi.spyOn(process, "execPath", "get").mockReturnValue(NODE_PATH); - // argv[1] must exist on disk — captureLauncher() validates via existsSync. - const realArgv = [...process.argv]; - realArgv[1] = CLI_ENTRY; - vi.spyOn(process, "argv", "get").mockReturnValue(realArgv); - - if (process.platform === "linux") { - unitPath = path.join(tmpHome, ".config", "systemd", "user", "kandev.service"); - } else if (process.platform === "darwin") { - unitPath = path.join(tmpHome, "Library", "LaunchAgents", "com.kdlbs.kandev.plist"); - } else { - unitPath = ""; - } - }); - - afterEach(() => { - fs.rmSync(tmpHome, { recursive: true, force: true }); - vi.restoreAllMocks(); - }); - - it("returns null when no unit file is installed", () => { - expect(detectStaleServiceUnit()).toBeNull(); - }); - - it("returns null when unit references the current paths", () => { - if (!unitPath) return; // unsupported platform — skip - fs.mkdirSync(path.dirname(unitPath), { recursive: true }); - fs.writeFileSync( - unitPath, - `# managed by kandev\nExecStart=${NODE_PATH} ${CLI_ENTRY} --headless\n`, - ); - expect(detectStaleServiceUnit()).toBeNull(); - }); - - it("returns a warning when the cli entry no longer matches", () => { - if (!unitPath) return; - fs.mkdirSync(path.dirname(unitPath), { recursive: true }); - fs.writeFileSync( - unitPath, - `# managed by kandev\nExecStart=${NODE_PATH} /old/path/cli.js --headless\n`, - ); - const msg = detectStaleServiceUnit(); - expect(msg).not.toBeNull(); - expect(msg).toMatch(/service install/); - }); - - it("returns null for non-managed files at the same path", () => { - if (!unitPath) return; - fs.mkdirSync(path.dirname(unitPath), { recursive: true }); - fs.writeFileSync(unitPath, `[Unit]\nDescription=Something else\n`); - expect(detectStaleServiceUnit()).toBeNull(); - }); -}); diff --git a/apps/cli/src/service/stale_check.ts b/apps/cli/src/service/stale_check.ts deleted file mode 100644 index 8b15a3a646..0000000000 --- a/apps/cli/src/service/stale_check.ts +++ /dev/null @@ -1,72 +0,0 @@ -import fs from "node:fs"; - -import { captureLauncher, linuxUserUnitPath, macosUserPlistPath } from "./paths"; -import { looksLikeManagedUnit } from "./templates"; - -/** - * Locations of the user-mode service unit we manage. We skip system-mode - * locations because (a) they require sudo to fix anyway, (b) reading from - * arbitrary `/etc` paths from a regular launch is noisier than worth it. - */ -function userModeUnitPath(): string | null { - if (process.platform === "linux") return linuxUserUnitPath(); - if (process.platform === "darwin") return macosUserPlistPath(); - return null; -} - -/** - * Check whether an installed user-mode service unit still references the - * current invocation's paths. - * - * Called once per interactive `kandev` start (skipped for `kandev service ...` - * commands and headless service runs). The check is intentionally cheap and - * silent on the happy path — only emits when a problem is detected — so it's - * fine to run unconditionally. - * - * Returns the warning message to print, or null when there's nothing to say. - */ -export function detectStaleServiceUnit(): string | null { - const unitPath = userModeUnitPath(); - if (!unitPath) return null; - - let content: string; - try { - content = fs.readFileSync(unitPath, "utf8"); - } catch { - return null; // no unit installed - } - if (!looksLikeManagedUnit(content)) return null; - - let launcher: ReturnType; - try { - launcher = captureLauncher(); - } catch { - // captureLauncher throws when process.argv[1] is missing — extremely rare, - // but if it happens we can't tell whether the unit is stale or not. - return null; - } - // Stale = the unit's hard-coded paths no longer match the running binary. - // We match on substring rather than parsing the unit so the check works for - // both systemd Environment= lines and plist entries. - const nodeFresh = content.includes(launcher.nodePath); - const cliFresh = content.includes(launcher.cliEntry); - if (nodeFresh && cliFresh) return null; - - return ( - `Your installed kandev service unit (${unitPath}) references paths that\n` + - ` no longer match this binary. This usually happens after upgrading via\n` + - ` npm or Homebrew. Re-run 'kandev service install' to refresh the unit.` - ); -} - -/** Convenience: detect + print to stderr in one call. Safe to call from cli.ts. */ -export function warnIfStaleServiceUnit(): void { - try { - const msg = detectStaleServiceUnit(); - if (msg) { - process.stderr.write(`[kandev] notice: ${msg}\n`); - } - } catch { - // Never let a diagnostic check break startup. - } -} diff --git a/apps/cli/src/service/templates.test.ts b/apps/cli/src/service/templates.test.ts deleted file mode 100644 index 69725a01f9..0000000000 --- a/apps/cli/src/service/templates.test.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { LauncherInfo } from "./paths"; -import { renderLaunchdPlist, renderSystemdUnit } from "./templates"; - -const NPM_LAUNCHER: LauncherInfo = { - nodePath: "/usr/local/bin/node", - cliEntry: "/usr/local/lib/node_modules/kandev/bin/cli.js", - kind: "npm", -}; - -const BREW_LAUNCHER: LauncherInfo = { - nodePath: "/opt/homebrew/bin/node", - cliEntry: "/opt/homebrew/opt/kandev/libexec/cli/bin/cli.js", - kind: "homebrew", - bundleDir: "/opt/homebrew/opt/kandev/libexec", - version: "0.49.0", -}; - -// Versioned bin dir typical of per-user node managers (fnm/nvm/asdf/volta/mise). -const FNM_LAUNCHER: LauncherInfo = { - nodePath: "/home/alice/.local/share/fnm/node-versions/v24.14.0/installation/bin/node", - cliEntry: - "/home/alice/.local/share/fnm/node-versions/v24.14.0/installation/lib/node_modules/kandev/bin/cli.js", - kind: "npm", -}; - -// A Homebrew launcher whose floating shim has been resolved. nodePath/cliEntry -// still point at the versioned Cellar paths (as captured at install time), but -// shimPath is the version-independent `/bin/kandev` symlink that -// survives `brew upgrade`. See issue #1162. -const SHIM_LAUNCHER: LauncherInfo = { - nodePath: "/home/linuxbrew/.linuxbrew/Cellar/node/26.0.0/bin/node", - cliEntry: "/home/linuxbrew/.linuxbrew/Cellar/kandev/0.52.0/libexec/cli/bin/cli.js", - kind: "homebrew", - bundleDir: "/home/linuxbrew/.linuxbrew/Cellar/kandev/0.52.0/libexec", - version: "0.52.0", - shimPath: "/home/linuxbrew/.linuxbrew/bin/kandev", -}; - -describe("renderSystemdUnit", () => { - it("renders a user unit with absolute paths and --headless", () => { - const unit = renderSystemdUnit({ - launcher: NPM_LAUNCHER, - homeDir: "/home/alice/.kandev", - logDir: "/home/alice/.kandev/logs", - mode: "user", - }); - expect(unit).toContain( - "ExecStart=/usr/local/bin/node /usr/local/lib/node_modules/kandev/bin/cli.js --headless", - ); - expect(unit).toContain("Environment=KANDEV_HOME_DIR=/home/alice/.kandev"); - expect(unit).toContain("WantedBy=default.target"); - expect(unit).not.toContain("User="); - expect(unit).not.toContain("KANDEV_BUNDLE_DIR"); - expect(unit).not.toContain("KANDEV_SERVER_PORT"); - }); - - it("prepends %h/.local/bin to PATH for user-mode units", () => { - const unit = renderSystemdUnit({ - launcher: NPM_LAUNCHER, - homeDir: "/home/alice/.kandev", - logDir: "/home/alice/.kandev/logs", - mode: "user", - }); - expect(unit).toMatch( - /^Environment=PATH=%h\/\.local\/bin:%h\/\.bun\/bin:%h\/\.opencode\/bin:\/usr\/local\/bin:\/usr\/bin:\/bin:\/opt\/homebrew\/bin:\/home\/linuxbrew\/\.linuxbrew\/bin$/m, - ); - }); - - it("includes %h/.bun/bin in PATH for user-mode units so Bun-global agent CLIs (e.g. omp) resolve", () => { - const unit = renderSystemdUnit({ - launcher: NPM_LAUNCHER, - homeDir: "/home/alice/.kandev", - logDir: "/home/alice/.kandev/logs", - mode: "user", - }); - expect(unit).toContain("%h/.bun/bin"); - }); - - it("includes run-as user agent bin dirs in PATH for system-mode units", () => { - const unit = renderSystemdUnit({ - launcher: NPM_LAUNCHER, - homeDir: "/var/lib/kandev", - logDir: "/var/lib/kandev/logs", - mode: "system", - systemUser: "alice", - }); - expect(unit).toMatch( - /^Environment=PATH=%h\/\.local\/bin:%h\/\.bun\/bin:%h\/\.opencode\/bin:\/usr\/local\/bin/m, - ); - }); - - it("sets WantedBy=multi-user.target and User= for system mode", () => { - const unit = renderSystemdUnit({ - launcher: NPM_LAUNCHER, - homeDir: "/var/lib/kandev", - logDir: "/var/lib/kandev/logs", - mode: "system", - systemUser: "alice", - }); - expect(unit).toContain("WantedBy=multi-user.target"); - expect(unit).toContain("User=alice"); - }); - - it("bakes in Homebrew env vars when present on launcher", () => { - const unit = renderSystemdUnit({ - launcher: BREW_LAUNCHER, - homeDir: "/home/alice/.kandev", - logDir: "/home/alice/.kandev/logs", - mode: "user", - }); - expect(unit).toContain("Environment=KANDEV_BUNDLE_DIR=/opt/homebrew/opt/kandev/libexec"); - expect(unit).toContain("Environment=KANDEV_VERSION=0.49.0"); - }); - - it("bakes in KANDEV_SERVER_PORT when port is set", () => { - const unit = renderSystemdUnit({ - launcher: NPM_LAUNCHER, - homeDir: "/home/alice/.kandev", - logDir: "/home/alice/.kandev/logs", - mode: "user", - port: 9000, - }); - expect(unit).toContain("Environment=KANDEV_SERVER_PORT=9000"); - }); - - it("bakes service-state env when metadata path is set", () => { - const unit = renderSystemdUnit({ - launcher: NPM_LAUNCHER, - homeDir: "/home/alice/.kandev", - logDir: "/home/alice/.kandev/logs", - mode: "user", - serviceMetadataPath: "/home/alice/.kandev/service/install.json", - } as Parameters[0]); - expect(unit).toContain("Environment=KANDEV_RUNNING_AS_SERVICE=true"); - expect(unit).toContain("Environment=KANDEV_SERVICE_MODE=user"); - expect(unit).toContain("Environment=KANDEV_SERVICE_MANAGER=systemd"); - expect(unit).toContain("Environment=KANDEV_INSTALL_KIND=npm"); - expect(unit).toContain( - "Environment=KANDEV_SERVICE_METADATA=/home/alice/.kandev/service/install.json", - ); - }); - - it("prepends launcher node bin dir to PATH so npx-based agents resolve under fnm/nvm/asdf/volta/mise", () => { - const unit = renderSystemdUnit({ - launcher: FNM_LAUNCHER, - homeDir: "/home/alice/.kandev", - logDir: "/home/alice/.kandev/logs", - mode: "user", - }); - expect(unit).toContain( - "Environment=PATH=/home/alice/.local/share/fnm/node-versions/v24.14.0/installation/bin:%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", - ); - }); - - it("does not duplicate node bin dir when it is already on the system PATH", () => { - const unit = renderSystemdUnit({ - launcher: NPM_LAUNCHER, - homeDir: "/home/alice/.kandev", - logDir: "/home/alice/.kandev/logs", - mode: "user", - }); - // /usr/local/bin already in the base service PATH — dirname(nodePath) must not double it. - expect(unit).toContain( - "Environment=PATH=%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", - ); - expect(unit).not.toContain("/usr/local/bin:/usr/local/bin"); - }); - - it("prepends node bin dir for system-mode units too", () => { - const unit = renderSystemdUnit({ - launcher: FNM_LAUNCHER, - homeDir: "/var/lib/kandev", - logDir: "/var/lib/kandev/logs", - mode: "system", - systemUser: "alice", - }); - expect(unit).toContain( - "Environment=PATH=/home/alice/.local/share/fnm/node-versions/v24.14.0/installation/bin:%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", - ); - }); - - it("does not prepend '.' when nodePath has no POSIX separator (Windows-style or bare filename)", () => { - // path.dirname('C:\\...\\node.exe') === '.' on POSIX. Without the - // isAbsolute guard, this would put CWD first in the daemon's PATH — - // a privilege-escalation footgun the systemd unit must not introduce. - const unit = renderSystemdUnit({ - launcher: { - nodePath: 'C:\\Program Files\\node "Node"\\node.exe', - cliEntry: "/home/alice/cli.js", - kind: "unknown", - }, - homeDir: "/home/alice/.kandev", - logDir: "/home/alice/.kandev/logs", - mode: "user", - }); - expect(unit).not.toMatch(/^Environment=PATH=\.:/m); - expect(unit).toContain( - "Environment=PATH=%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", - ); - }); - - it("quotes ExecStart paths that contain spaces", () => { - const unit = renderSystemdUnit({ - launcher: { - nodePath: "/Library/Application Support/node", - cliEntry: "/Library/Application Support/kandev/cli.js", - kind: "unknown", - }, - homeDir: "/home/alice/.kandev", - logDir: "/home/alice/.kandev/logs", - mode: "user", - }); - expect(unit).toContain( - `ExecStart="/Library/Application Support/node" "/Library/Application Support/kandev/cli.js" --headless`, - ); - }); - - // Regression: https://github.com/kdlbs/kandev/issues/1162 — the unit must not - // bake version-pinned Cellar paths for Homebrew installs, or it crash-loops - // after `brew upgrade` deletes the old Cellar dir. - it("uses the floating Homebrew shim in ExecStart when shimPath is set", () => { - const unit = renderSystemdUnit({ - launcher: SHIM_LAUNCHER, - homeDir: "/home/alice/.kandev", - logDir: "/home/alice/.kandev/logs", - mode: "user", - }); - expect(unit).toContain("ExecStart=/home/linuxbrew/.linuxbrew/bin/kandev --headless"); - // No version-pinned Cellar paths anywhere in the unit — this single check - // covers both the kandev cli.js path and the versioned node bin dir, since - // any `/Cellar/node/...` or `/Cellar/kandev/...` path contains "/Cellar/". - expect(unit).not.toContain("/Cellar/"); - }); - - it("drops KANDEV_BUNDLE_DIR / KANDEV_VERSION and the versioned node bin dir when using the shim", () => { - const unit = renderSystemdUnit({ - launcher: SHIM_LAUNCHER, - homeDir: "/home/alice/.kandev", - logDir: "/home/alice/.kandev/logs", - mode: "user", - }); - expect(unit).not.toContain("KANDEV_BUNDLE_DIR"); - expect(unit).not.toContain("KANDEV_VERSION"); - // PATH must fall back to the static base path, not the versioned Cellar node - // bin. The shim's own bin dir (/home/linuxbrew/.linuxbrew/bin) is already in - // the base path, so it is not duplicated. - expect(unit).toContain( - "Environment=PATH=%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", - ); - }); - - it("keeps versioned node + cli.js ExecStart for npm installs (no shim)", () => { - const unit = renderSystemdUnit({ - launcher: NPM_LAUNCHER, - homeDir: "/home/alice/.kandev", - logDir: "/home/alice/.kandev/logs", - mode: "user", - }); - expect(unit).toContain( - "ExecStart=/usr/local/bin/node /usr/local/lib/node_modules/kandev/bin/cli.js --headless", - ); - }); - - it("prepends the shim's bin dir to PATH for a custom Homebrew prefix not in the base PATH", () => { - const unit = renderSystemdUnit({ - launcher: { - nodePath: "/home/me/.brew/Cellar/node/26.0.0/bin/node", - cliEntry: "/home/me/.brew/Cellar/kandev/0.52.0/libexec/cli/bin/cli.js", - kind: "homebrew", - shimPath: "/home/me/.brew/bin/kandev", - }, - homeDir: "/home/me/.kandev", - logDir: "/home/me/.kandev/logs", - mode: "user", - }); - // Custom prefix's bin dir is prepended so npm/npx resolve, even though it - // isn't one of the hardcoded default prefixes. - expect(unit).toContain( - "Environment=PATH=/home/me/.brew/bin:%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:", - ); - expect(unit).not.toContain("/Cellar/"); - }); -}); - -describe("renderLaunchdPlist", () => { - it("renders a user-agent plist with KeepAlive and --headless", () => { - const plist = renderLaunchdPlist({ - launcher: NPM_LAUNCHER, - homeDir: "/Users/alice/.kandev", - logDir: "/Users/alice/.kandev/logs", - mode: "user", - }); - expect(plist).toContain("com.kdlbs.kandev"); - expect(plist).toContain("/usr/local/bin/node"); - expect(plist).toContain("--headless"); - expect(plist).toContain("KeepAlive"); - expect(plist).toContain("RunAtLoad"); - expect(plist).toContain("/Users/alice/.kandev/logs/service.err"); - expect(plist).toContain("KANDEV_HOME_DIR"); - expect(plist).not.toContain("KANDEV_BUNDLE_DIR"); - }); - - it("prepends launcher node bin dir to PATH for plists too (fnm/nvm/asdf/volta/mise)", () => { - const plist = renderLaunchdPlist({ - launcher: { - nodePath: "/Users/alice/.volta/tools/image/node/24.14.0/bin/node", - cliEntry: "/Users/alice/.volta/tools/image/packages/kandev/bin/cli.js", - kind: "npm", - }, - homeDir: "/Users/alice/.kandev", - logDir: "/Users/alice/.kandev/logs", - mode: "user", - }); - expect(plist).toMatch( - /PATH<\/key>\s*\/Users\/alice\/\.volta\/tools\/image\/node\/24\.14\.0\/bin:[^<]+\/\.local\/bin:[^<]+\/\.bun\/bin:\/opt\/homebrew\/bin:\/usr\/local\/bin:\/usr\/bin:\/bin<\/string>/, - ); - }); - - it("does not duplicate node bin dir in plist PATH when already present", () => { - const plist = renderLaunchdPlist({ - launcher: BREW_LAUNCHER, - homeDir: "/Users/alice/.kandev", - logDir: "/Users/alice/.kandev/logs", - mode: "user", - }); - // /opt/homebrew/bin already in LAUNCHD_USER_PATH — must not be doubled. - expect(plist).toMatch( - /PATH<\/key>\s*[^<]+\/\.local\/bin:[^<]+\/\.bun\/bin:\/opt\/homebrew\/bin:\/usr\/local\/bin:\/usr\/bin:\/bin<\/string>/, - ); - expect(plist).not.toContain("/opt/homebrew/bin:/opt/homebrew/bin"); - }); - - it("prepends $HOME/.local/bin to PATH for user-mode plists", () => { - const plist = renderLaunchdPlist({ - launcher: NPM_LAUNCHER, - homeDir: "/Users/alice/.kandev", - logDir: "/Users/alice/.kandev/logs", - mode: "user", - }); - expect(plist).toMatch( - /PATH<\/key>\s*[^<]+\/\.local\/bin:[^<]+\/\.bun\/bin:\/opt\/homebrew\/bin:\/usr\/local\/bin:\/usr\/bin:\/bin<\/string>/, - ); - }); - - it("includes $HOME/.bun/bin in PATH for user-mode plists so Bun-global agent CLIs (e.g. omp) resolve", () => { - const plist = renderLaunchdPlist({ - launcher: NPM_LAUNCHER, - homeDir: "/Users/alice/.kandev", - logDir: "/Users/alice/.kandev/logs", - mode: "user", - }); - expect(plist).toMatch(/PATH<\/key>\s*[^<]*\/\.bun\/bin:/); - }); - - it("prepends node bin dir for system-mode LaunchDaemons too", () => { - const plist = renderLaunchdPlist({ - launcher: { - nodePath: "/Users/alice/.volta/tools/image/node/24.14.0/bin/node", - cliEntry: "/Users/alice/.volta/tools/image/packages/kandev/bin/cli.js", - kind: "npm", - }, - homeDir: "/Library/Application Support/kandev", - logDir: "/Library/Logs/kandev", - mode: "system", - systemUser: "_kandev", - }); - expect(plist).toContain( - "PATH\n /Users/alice/.volta/tools/image/node/24.14.0/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin", - ); - }); - - it("omits $HOME/.local/bin from PATH for system-mode plists", () => { - const plist = renderLaunchdPlist({ - launcher: NPM_LAUNCHER, - homeDir: "/Library/Application Support/kandev", - logDir: "/Library/Logs/kandev", - mode: "system", - systemUser: "_kandev", - }); - expect(plist).not.toContain("/.local/bin"); - expect(plist).toContain( - "PATH\n /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin", - ); - }); - - it("does not prepend '.' to plist PATH when nodePath has no POSIX separator", () => { - const plist = renderLaunchdPlist({ - launcher: { - nodePath: "C:\\Program Files\\node\\node.exe", - cliEntry: "/home/alice/cli.js", - kind: "unknown", - }, - homeDir: "/Users/alice/.kandev", - logDir: "/Users/alice/.kandev/logs", - mode: "user", - }); - expect(plist).not.toMatch(/PATH<\/key>\s*\.:/); - expect(plist).toMatch( - /PATH<\/key>\s*[^<]+\/\.local\/bin:[^<]+\/\.bun\/bin:\/opt\/homebrew\/bin:\/usr\/local\/bin:\/usr\/bin:\/bin<\/string>/, - ); - }); - - it("escapes XML special characters in paths", () => { - const plist = renderLaunchdPlist({ - launcher: { - nodePath: "/path/with/<&'\">/node", - cliEntry: "/path/cli.js", - kind: "unknown", - }, - homeDir: "/Users/alice/.kandev", - logDir: "/Users/alice/.kandev/logs", - mode: "user", - }); - expect(plist).toContain("/path/with/<&'">/node"); - expect(plist).not.toContain("<&'\""); - }); - - it("includes Homebrew env vars when present", () => { - const plist = renderLaunchdPlist({ - launcher: BREW_LAUNCHER, - homeDir: "/Users/alice/.kandev", - logDir: "/Users/alice/.kandev/logs", - mode: "user", - }); - expect(plist).toContain("KANDEV_BUNDLE_DIR"); - expect(plist).toContain("/opt/homebrew/opt/kandev/libexec"); - expect(plist).toContain("KANDEV_VERSION"); - }); - - it("bakes service-state env when metadata path is set", () => { - const plist = renderLaunchdPlist({ - launcher: NPM_LAUNCHER, - homeDir: "/Users/alice/.kandev", - logDir: "/Users/alice/.kandev/logs", - mode: "user", - serviceMetadataPath: "/Users/alice/.kandev/service/install.json", - } as Parameters[0]); - expect(plist).toContain("KANDEV_RUNNING_AS_SERVICE"); - expect(plist).toContain("true"); - expect(plist).toContain("KANDEV_SERVICE_MODE"); - expect(plist).toContain("user"); - expect(plist).toContain("KANDEV_SERVICE_MANAGER"); - expect(plist).toContain("launchd"); - expect(plist).toContain("KANDEV_INSTALL_KIND"); - expect(plist).toContain("npm"); - expect(plist).toContain("KANDEV_SERVICE_METADATA"); - expect(plist).toContain("/Users/alice/.kandev/service/install.json"); - }); - - it("quotes Environment= lines when value contains a space (greptile P1 regression)", () => { - const unit = renderSystemdUnit({ - launcher: NPM_LAUNCHER, - homeDir: "/home/john doe/.kandev", - logDir: "/home/john doe/.kandev/logs", - mode: "user", - }); - // The whole assignment must be wrapped, not just the value. - expect(unit).toContain('Environment="KANDEV_HOME_DIR=/home/john doe/.kandev"'); - // PATH always contains colons but no spaces — should NOT be quoted. - expect(unit).toMatch( - /^Environment=PATH=%h\/\.local\/bin:%h\/\.bun\/bin:%h\/\.opencode\/bin:\/usr\/local\/bin/m, - ); - }); - - it("escapes backslash + double-quote in Environment= and ExecStart values", () => { - const unit = renderSystemdUnit({ - launcher: { - nodePath: 'C:\\Program Files\\node "Node"\\node.exe', - cliEntry: "/home/alice/cli.js", - kind: "unknown", - }, - homeDir: "C:\\Program Files\\kandev", - logDir: "/var/log", - mode: "user", - }); - // Backslashes doubled, quotes escaped, whole thing wrapped. - expect(unit).toContain('Environment="KANDEV_HOME_DIR=C:\\\\Program Files\\\\kandev"'); - expect(unit).toContain( - 'ExecStart="C:\\\\Program Files\\\\node \\"Node\\"\\\\node.exe" /home/alice/cli.js --headless', - ); - }); - - it("emits UserName for system mode with systemUser", () => { - const plist = renderLaunchdPlist({ - launcher: NPM_LAUNCHER, - homeDir: "/var/lib/kandev", - logDir: "/var/lib/kandev/logs", - mode: "system", - systemUser: "alice", - }); - expect(plist).toContain("UserName"); - expect(plist).toContain("alice"); - }); - - it("omits UserName for user mode", () => { - const plist = renderLaunchdPlist({ - launcher: NPM_LAUNCHER, - homeDir: "/Users/alice/.kandev", - logDir: "/Users/alice/.kandev/logs", - mode: "user", - }); - expect(plist).not.toContain("UserName"); - }); - - // Regression: https://github.com/kdlbs/kandev/issues/1162 - it("uses the floating Homebrew shim in ProgramArguments when shimPath is set", () => { - const plist = renderLaunchdPlist({ - launcher: SHIM_LAUNCHER, - homeDir: "/Users/alice/.kandev", - logDir: "/Users/alice/.kandev/logs", - mode: "user", - }); - expect(plist).toContain("/home/linuxbrew/.linuxbrew/bin/kandev"); - expect(plist).toContain("--headless"); - expect(plist).not.toContain("/Cellar/"); - expect(plist).not.toContain("KANDEV_BUNDLE_DIR"); - expect(plist).not.toContain("KANDEV_VERSION"); - }); -}); diff --git a/apps/cli/src/service/templates.ts b/apps/cli/src/service/templates.ts deleted file mode 100644 index 48d7ebd309..0000000000 --- a/apps/cli/src/service/templates.ts +++ /dev/null @@ -1,259 +0,0 @@ -import os from "node:os"; -import path from "node:path"; - -import type { LauncherInfo } from "./paths"; - -export type UnitInputs = { - launcher: LauncherInfo; - homeDir: string; - logDir: string; - /** When set, baked into the unit as KANDEV_SERVER_PORT. */ - port?: number; - /** - * User the service should run as. Set for system-mode installs so the - * daemon doesn't run as root (systemd `User=` / launchd `UserName=`). - * User-mode units leave this unset — they run as the installing user. - */ - systemUser?: string; - /** Mode controls WantedBy and User= directives. */ - mode: "user" | "system"; - /** Absolute path to service install metadata, baked into managed units. */ - serviceMetadataPath?: string; -}; - -// Service PATH includes common per-user agent install dirs so user-installed -// CLIs (npm user prefix, pipx, fnm, Bun globals like oh-my-pi/omp, OpenCode's -// installer, etc.) are discoverable. System-mode units still run as the -// configured User=, so %h resolves to that user's home directory. -const SYSTEMD_SYSTEM_PATH = - "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin"; -const SYSTEMD_AGENT_BIN_PATH = `%h/.local/bin:%h/.bun/bin:%h/.opencode/bin`; -const SYSTEMD_SERVICE_PATH = `${SYSTEMD_AGENT_BIN_PATH}:${SYSTEMD_SYSTEM_PATH}`; -const LAUNCHD_SYSTEM_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; -const launchdUserPath = (): string => - `${os.homedir()}/.local/bin:${os.homedir()}/.bun/bin:${LAUNCHD_SYSTEM_PATH}`; - -// Prepend the launcher node's bin dir so `npm`/`npx` resolve under per-user -// node managers (fnm, nvm, asdf, volta, mise), where node lives in a versioned -// subdirectory not covered by the system PATH. ExecStart already points at this -// node, so its parent dir is exactly where the matching npm/npx live — without -// this, npx-based ACP agents (claude, codex, opencode) fail to spawn. -// -// The isAbsolute guard exists because `path.dirname` on POSIX returns `'.'` -// for a path with no `/` separator (e.g. a Windows-style `C:\…\node.exe` -// passed through). Prepending `.` would put CWD ahead of system dirs in the -// daemon's PATH — a privilege-escalation footgun. Production callers always -// pass `process.execPath` (absolute on Linux/macOS), so the guard only kicks -// in for non-POSIX or relative inputs from future callers / tests. -function pathWithNodeBinDir(basePath: string, nodePath: string): string { - const nodeBinDir = path.dirname(nodePath); - if (!path.isAbsolute(nodeBinDir)) return basePath; - const parts = basePath.split(":"); - if (parts.includes(nodeBinDir)) return basePath; - return `${nodeBinDir}:${basePath}`; -} - -/** - * Marker substring baked into every unit/plist kandev writes. Used to safely - * detect "this file is ours, overwriting is fine" vs "user has put something - * else here, warn loudly before replacing". - */ -export const MANAGED_MARKER_TEXT = "managed by kandev"; -const SYSTEMD_MARKER = `# ${MANAGED_MARKER_TEXT} — regenerated by \`kandev service install\``; -const PLIST_MARKER = ``; - -export function looksLikeManagedUnit(content: string): boolean { - return content.includes(MANAGED_MARKER_TEXT); -} - -/** - * Render a systemd unit file for kandev. - * - * The unit hard-codes absolute paths so it works without a user PATH. We pass - * `--headless` so the daemon doesn't try to open a browser. - * - * For Homebrew installs the launcher carries a resolved `shimPath` — the - * floating `/bin/kandev` shim that survives `brew upgrade`. When set we - * exec that shim and omit KANDEV_BUNDLE_DIR / KANDEV_VERSION and the - * version-pinned node bin dir from PATH, because the shim supplies all three - * itself. Otherwise (npm / unknown installs) we fall back to the version-pinned - * node + cli.js and surface KANDEV_BUNDLE_DIR / KANDEV_VERSION only when present. - */ -export function renderSystemdUnit(input: UnitInputs): string { - const shimPath = input.launcher.shimPath; - const basePath = SYSTEMD_SERVICE_PATH; - // For the shim, prepend its own bin dir (the Homebrew prefix's `bin`, where - // node/npm/npx live) so npx-based agents resolve even when the prefix isn't - // one of the hardcoded defaults. pathWithNodeBinDir dedupes when it already is. - const pathValue = pathWithNodeBinDir(basePath, shimPath ?? input.launcher.nodePath); - const env: string[] = [ - envLine("KANDEV_HOME_DIR", input.homeDir), - envLine("KANDEV_LOG_LEVEL", "info"), - envLine("PATH", pathValue), - ]; - if (input.port !== undefined) { - env.push(envLine("KANDEV_SERVER_PORT", String(input.port))); - } - if (!shimPath && input.launcher.bundleDir) { - env.push(envLine("KANDEV_BUNDLE_DIR", input.launcher.bundleDir)); - } - if (!shimPath && input.launcher.version) { - env.push(envLine("KANDEV_VERSION", input.launcher.version)); - } - if (input.serviceMetadataPath) { - env.push(...serviceEnvLines(input, "systemd")); - } - - const wantedBy = input.mode === "system" ? "multi-user.target" : "default.target"; - const userLine = input.mode === "system" && input.systemUser ? `User=${input.systemUser}\n` : ""; - - const exec = shimPath - ? `${quoteForUnit(shimPath)} --headless` - : `${quoteForUnit(input.launcher.nodePath)} ${quoteForUnit(input.launcher.cliEntry)} --headless`; - - return `${SYSTEMD_MARKER} -[Unit] -Description=Kandev autonomous agent platform -Documentation=https://github.com/kdlbs/kandev -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -ExecStart=${exec} -${userLine}${env.join("\n")} -Restart=on-failure -RestartSec=5s -KillMode=mixed -TimeoutStopSec=30s - -[Install] -WantedBy=${wantedBy} -`; -} - -/** - * Render a launchd plist for kandev. - * - * launchd has no equivalent to systemd's enable-linger: a user agent only runs - * while the user is logged in. For a Mac-as-server scenario, use --system to - * get a LaunchDaemon that runs at boot regardless of login. - */ -export function renderLaunchdPlist(input: UnitInputs): string { - const shimPath = input.launcher.shimPath; - const basePath = input.mode === "system" ? LAUNCHD_SYSTEM_PATH : launchdUserPath(); - // See renderSystemdUnit: prepend the shim's own bin dir so npm/npx resolve. - const pathValue = pathWithNodeBinDir(basePath, shimPath ?? input.launcher.nodePath); - const envEntries: Array<[string, string]> = [ - ["KANDEV_HOME_DIR", input.homeDir], - ["KANDEV_LOG_LEVEL", "info"], - ["PATH", pathValue], - ]; - if (input.port !== undefined) { - envEntries.push(["KANDEV_SERVER_PORT", String(input.port)]); - } - if (!shimPath && input.launcher.bundleDir) { - envEntries.push(["KANDEV_BUNDLE_DIR", input.launcher.bundleDir]); - } - if (!shimPath && input.launcher.version) { - envEntries.push(["KANDEV_VERSION", input.launcher.version]); - } - if (input.serviceMetadataPath) { - envEntries.push(...serviceEnvEntries(input, "launchd")); - } - - const envXml = envEntries - .map(([k, v]) => ` ${escapeXml(k)}\n ${escapeXml(v)}`) - .join("\n"); - - const args = shimPath - ? [shimPath, "--headless"] - : [input.launcher.nodePath, input.launcher.cliEntry, "--headless"]; - const argsXml = args.map((a) => ` ${escapeXml(a)}`).join("\n"); - - // For system-mode LaunchDaemons, run as a specific user instead of root. - // For user agents this directive is omitted — the agent already runs as - // the loading user. - const userBlock = - input.mode === "system" && input.systemUser - ? ` UserName\n ${escapeXml(input.systemUser)}\n` - : ""; - - return ` -${PLIST_MARKER} - - - - Label - com.kdlbs.kandev - ProgramArguments - -${argsXml} - - EnvironmentVariables - -${envXml} - -${userBlock} RunAtLoad - - KeepAlive - - StandardOutPath - ${escapeXml(`${input.logDir}/service.out`)} - StandardErrorPath - ${escapeXml(`${input.logDir}/service.err`)} - WorkingDirectory - ${escapeXml(input.homeDir)} - - -`; -} - -function escapeXml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -// systemd unit "Environment=" lines can contain spaces by wrapping in double quotes. -// ExecStart needs special handling for spaces too — paths with spaces must be -// double-quoted per systemd.unit(5). Escape backslashes before double-quotes so -// a literal `\` in a path is preserved instead of merging with the following -// character into an escape sequence. -function quoteForUnit(value: string): string { - if (!/[\s"\\]/.test(value)) return value; - const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - return `"${escaped}"`; -} - -// Render a systemd `Environment=KEY=value` line, quoting the whole assignment -// when the value contains whitespace, double-quote, or backslash. Without -// quoting, systemd splits the line on the first space and treats subsequent -// tokens as separate KEY=value pairs — which silently corrupts paths like -// `/home/john doe/.kandev`. -function envLine(key: string, value: string): string { - if (!/[\s"\\]/.test(value)) return `Environment=${key}=${value}`; - const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - return `Environment="${key}=${escaped}"`; -} - -function serviceEnvLines(input: UnitInputs, manager: "systemd" | "launchd"): string[] { - return serviceEnvEntries(input, manager).map(([key, value]) => envLine(key, value)); -} - -function serviceEnvEntries( - input: UnitInputs, - manager: "systemd" | "launchd", -): Array<[string, string]> { - if (!input.serviceMetadataPath) return []; - return [ - ["KANDEV_RUNNING_AS_SERVICE", "true"], - ["KANDEV_SERVICE_MODE", input.mode], - ["KANDEV_SERVICE_MANAGER", manager], - ["KANDEV_INSTALL_KIND", input.launcher.kind], - ["KANDEV_SERVICE_METADATA", input.serviceMetadataPath], - ]; -} diff --git a/apps/cli/src/shared.test.ts b/apps/cli/src/shared.test.ts deleted file mode 100644 index 36760b5f4b..0000000000 --- a/apps/cli/src/shared.test.ts +++ /dev/null @@ -1,442 +0,0 @@ -import os from "node:os"; -import net from "node:net"; -import { PassThrough } from "node:stream"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { DEFAULT_AGENTCTL_PORT, DEFAULT_WEB_PORT } from "./constants"; -import { __testing as portsTesting } from "./ports"; -import { - buildBackendEnv, - buildWebEnv, - createOutputRingBuffer, - listHostNetworkAddresses, - logStartupInfo, - networkUrlsForPort, - pickPorts, - pickBackendPorts, - resolveBackendLogLevels, - type PortConfig, -} from "./shared"; - -const ports: PortConfig = { - backendPort: 38429, - webPort: 37429, - agentctlPort: 36429, - backendUrl: "http://localhost:38429", -}; - -describe("buildWebEnv", () => { - const originalViteApiPort = process.env.VITE_KANDEV_API_PORT; - afterEach(() => { - if (originalViteApiPort === undefined) { - delete process.env.VITE_KANDEV_API_PORT; - } else { - process.env.VITE_KANDEV_API_PORT = originalViteApiPort; - } - }); - - it("does not set browser API port env vars in dev mode", () => { - const env = buildWebEnv({ ports }); - - expect(env.VITE_KANDEV_API_PORT).toBeUndefined(); - expect(env.NODE_ENV).not.toBe("production"); - }); - - it("does not set browser API port env vars in production single-port mode", () => { - // The Go backend serves the SPA, so the client must use same-origin. - // A non-empty value would cause the client to build cross-origin URLs like - // `https://host:38429/...` that aren't reachable behind a reverse proxy. - const env = buildWebEnv({ ports, production: true }); - - expect(env.VITE_KANDEV_API_PORT).toBeUndefined(); - expect(env.NODE_ENV).toBe("production"); - }); - - it("strips host-level browser API port env vars in production", () => { - // If an operator has the var in their shell/Docker env, the process.env - // spread would otherwise leak it into the web process and reintroduce - // the cross-origin URL problem this fix addresses. - process.env.VITE_KANDEV_API_PORT = "99999"; - - const env = buildWebEnv({ ports, production: true }); - - expect(env.VITE_KANDEV_API_PORT).toBeUndefined(); - }); - - it("always sets KANDEV_API_BASE_URL for SSR fetches", () => { - expect(buildWebEnv({ ports }).KANDEV_API_BASE_URL).toBe("http://localhost:38429"); - expect(buildWebEnv({ ports, production: true }).KANDEV_API_BASE_URL).toBe( - "http://localhost:38429", - ); - }); - - it("enables debug flag when requested", () => { - expect(buildWebEnv({ ports, debug: true }).VITE_KANDEV_DEBUG).toBe("true"); - expect(buildWebEnv({ ports, debug: true }).KANDEV_DEBUG).toBe("true"); - expect(buildWebEnv({ ports }).VITE_KANDEV_DEBUG).toBeUndefined(); - expect(buildWebEnv({ ports }).KANDEV_DEBUG).toBeUndefined(); - }); -}); - -describe("buildBackendEnv", () => { - it("points backend non-API routes at the dev web server by default", () => { - const env = buildBackendEnv({ ports }); - - expect(env.KANDEV_WEB_INTERNAL_URL).toBe("http://localhost:37429"); - }); - - it("omits the web proxy in production single-process mode", () => { - const env = buildBackendEnv({ ports, webProxy: false }); - - expect(env.KANDEV_WEB_INTERNAL_URL).toBeUndefined(); - }); - - it("generates a fresh backend health token for each launch environment", () => { - const first = buildBackendEnv({ ports }).KANDEV_DESKTOP_HEALTH_TOKEN; - const second = buildBackendEnv({ ports }).KANDEV_DESKTOP_HEALTH_TOKEN; - - expect(first).toMatch(/^[0-9a-f]{64}$/); - expect(second).toMatch(/^[0-9a-f]{64}$/); - expect(second).not.toBe(first); - }); - - it("uses a caller-provided backend health token", () => { - const env = buildBackendEnv({ ports, healthToken: "launch-owned-token" }); - - expect(env.KANDEV_DESKTOP_HEALTH_TOKEN).toBe("launch-owned-token"); - }); - - it("uses independent file and console thresholds", () => { - expect(resolveBackendLogLevels({ env: {} })).toEqual({ file: "info", console: "warn" }); - expect(resolveBackendLogLevels({ debug: true, env: {} })).toEqual({ - file: "debug", - console: "warn", - }); - expect(resolveBackendLogLevels({ verbose: true, env: {} })).toEqual({ - file: "info", - console: "info", - }); - expect(resolveBackendLogLevels({ debug: true, env: { KANDEV_LOG_LEVEL: "error" } })).toEqual({ - file: "error", - console: "warn", - }); - }); - - it("tees backend output while retaining a bounded startup tail", () => { - const stream = new PassThrough(); - let forwarded = ""; - const capture = createOutputRingBuffer( - { - write: (chunk) => { - forwarded += chunk.toString(); - return true; - }, - }, - 5, - ); - capture.attach(stream); - stream.write("hello"); - stream.write("world"); - expect(forwarded).toBe("helloworld"); - expect(capture.read()).toBe("world"); - }); -}); - -describe("explicit backend port selection", () => { - it("rejects an occupied port before selecting the other services", async () => { - const server = net.createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - throw new Error("test server did not expose a port"); - } - - try { - await expect( - pickBackendPorts({ backendPort: address.port, backendPortSource: "KANDEV_PORT" }), - ).rejects.toThrow(`Backend port ${address.port} from KANDEV_PORT is already in use`); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - } - }); - - it("does not reuse an explicit backend port for the automatic web port", async (ctx) => { - if (!(await portsTesting.isPortAvailable(DEFAULT_WEB_PORT))) { - ctx.skip(); - return; - } - - const resolved = await pickPorts({ - backendPort: DEFAULT_WEB_PORT, - backendPortSource: "--port", - }); - - expect(resolved.webPort).not.toBe(DEFAULT_WEB_PORT); - }); - - it("does not reuse an explicit backend port for the automatic agentctl port", async (ctx) => { - if (!(await portsTesting.isPortAvailable(DEFAULT_AGENTCTL_PORT))) { - ctx.skip(); - return; - } - - const resolved = await pickBackendPorts({ - backendPort: DEFAULT_AGENTCTL_PORT, - backendPortSource: "--port", - }); - - expect(resolved.agentctlPort).not.toBe(DEFAULT_AGENTCTL_PORT); - }); -}); - -describe("listHostNetworkAddresses", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("returns a deduplicated list of non-internal addresses", () => { - vi.spyOn(os, "networkInterfaces").mockReturnValue({ - eth0: [ - { - address: "10.0.0.1", - netmask: "255.0.0.0", - family: "IPv4", - mac: "aa:bb:cc:dd:ee:ff", - internal: false, - cidr: "10.0.0.1/8", - }, - ], - eth1: [ - { - address: "10.0.0.1", - netmask: "255.0.0.0", - family: "IPv4", - mac: "11:22:33:44:55:66", - internal: false, - cidr: "10.0.0.1/8", - }, - ], - }); - expect(listHostNetworkAddresses()).toEqual(["10.0.0.1"]); - }); - - it("excludes internal loopback addresses", () => { - vi.spyOn(os, "networkInterfaces").mockReturnValue({ - lo: [ - { - address: "127.0.0.1", - netmask: "255.0.0.0", - family: "IPv4", - mac: "00:00:00:00:00:00", - internal: true, - cidr: "127.0.0.1/8", - }, - ], - eth0: [ - { - address: "10.0.0.5", - netmask: "255.0.0.0", - family: "IPv4", - mac: "aa:bb:cc:dd:ee:ff", - internal: false, - cidr: "10.0.0.5/8", - }, - ], - }); - expect(listHostNetworkAddresses()).toEqual(["10.0.0.5"]); - }); - - it("excludes IPv4 link-local (169.254.0.0/16) addresses", () => { - vi.spyOn(os, "networkInterfaces").mockReturnValue({ - eth0: [ - { - address: "169.254.83.107", - netmask: "255.255.0.0", - family: "IPv4", - mac: "aa:bb:cc:dd:ee:ff", - internal: false, - cidr: "169.254.83.107/16", - }, - { - address: "192.168.1.34", - netmask: "255.255.252.0", - family: "IPv4", - mac: "aa:bb:cc:dd:ee:ff", - internal: false, - cidr: "192.168.1.34/22", - }, - ], - }); - expect(listHostNetworkAddresses()).toEqual(["192.168.1.34"]); - }); - - it("excludes the full IPv6 link-local range (fe80::/10, covers fe80–febf)", () => { - // In practice OS stacks only assign fe80::/64, but per RFC 4291 the link- - // local range is fe80::/10 — anything from fe80:: through febf:ffff:... is - // link-local and never reachable from a remote machine. - vi.spyOn(os, "networkInterfaces").mockReturnValue({ - eth0: [ - { - address: "fe80::abcd", - netmask: "ffff:ffff:ffff:ffff::", - family: "IPv6", - mac: "aa:bb:cc:dd:ee:ff", - internal: false, - cidr: "fe80::abcd/64", - scopeid: 2, - }, - { - address: "fea0::1", - netmask: "ffff:ffff:ffff:ffff::", - family: "IPv6", - mac: "aa:bb:cc:dd:ee:ff", - internal: false, - cidr: "fea0::1/64", - scopeid: 2, - }, - { - address: "FEBF::1", - netmask: "ffff:ffff:ffff:ffff::", - family: "IPv6", - mac: "aa:bb:cc:dd:ee:ff", - internal: false, - cidr: "FEBF::1/64", - scopeid: 2, - }, - { - address: "fec0::1", - netmask: "ffff:ffff:ffff:ffff::", - family: "IPv6", - mac: "aa:bb:cc:dd:ee:ff", - internal: false, - cidr: "fec0::1/64", - scopeid: 0, - }, - { - address: "2001:db8::1", - netmask: "ffff:ffff:ffff:ffff::", - family: "IPv6", - mac: "aa:bb:cc:dd:ee:ff", - internal: false, - cidr: "2001:db8::1/64", - scopeid: 0, - }, - ], - }); - // fe80::abcd, fea0::1, FEBF::1 are link-local (excluded). - // fec0::1 was deprecated site-local (kept — outside fe80::/10). - // 2001:db8::1 is global (kept). - expect(listHostNetworkAddresses()).toEqual(["fec0::1", "2001:db8::1"]); - }); -}); - -describe("networkUrlsForPort", () => { - it("formats IPv4 hosts without brackets", () => { - expect(networkUrlsForPort(38429, ["192.168.1.34", "100.94.173.104"])).toEqual([ - "http://192.168.1.34:38429", - "http://100.94.173.104:38429", - ]); - }); - - it("wraps IPv6 hosts in brackets per RFC 3986", () => { - expect(networkUrlsForPort(38429, ["2001:db8::1"])).toEqual(["http://[2001:db8::1]:38429"]); - }); - - it("returns an empty list when there are no hosts", () => { - expect(networkUrlsForPort(38429, [])).toEqual([]); - }); -}); - -describe("logStartupInfo", () => { - let log: ReturnType>; - - beforeEach(() => { - log = vi.spyOn(console, "log").mockImplementation(() => {}); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - function mockTwoInterfaces() { - vi.spyOn(os, "networkInterfaces").mockReturnValue({ - eth0: [ - { - address: "192.168.1.34", - netmask: "255.255.252.0", - family: "IPv4", - mac: "aa:bb:cc:dd:ee:ff", - internal: false, - cidr: "192.168.1.34/22", - }, - ], - tailscale0: [ - { - address: "100.94.173.104", - netmask: "255.255.255.255", - family: "IPv4", - mac: "00:00:00:00:00:00", - internal: false, - cidr: "100.94.173.104/32", - }, - ], - }); - } - - it("prints the backend URL and network URLs by default (start/run mode)", () => { - mockTwoInterfaces(); - logStartupInfo({ header: "test", ports }); - const lines = log.mock.calls.map((c) => c.join(" ")); - expect(lines).toContain("[kandev] url: http://localhost:38429"); - expect(lines).toContain("[kandev] network: http://192.168.1.34:38429"); - expect(lines).toContain("[kandev] network: http://100.94.173.104:38429"); - // Internal-only ports must not appear — the user opens exactly one URL. - expect(lines.some((l) => l.includes(":37429"))).toBe(false); - expect(lines.some((l) => l.includes("agentctl"))).toBe(false); - }); - - it('prints the web URL and network URLs when primary="web" is explicitly requested', () => { - mockTwoInterfaces(); - logStartupInfo({ header: "test", ports, primary: "web" }); - const lines = log.mock.calls.map((c) => c.join(" ")); - expect(lines).toContain("[kandev] url: http://localhost:37429"); - expect(lines).toContain("[kandev] network: http://192.168.1.34:37429"); - expect(lines).toContain("[kandev] network: http://100.94.173.104:37429"); - // Explicit web-primary mode is for diagnostics; normal dev/start modes - // use the backend URL as the browser entrypoint. - expect(lines.some((l) => l === "[kandev] url: http://localhost:38429")).toBe(false); - expect(lines.some((l) => l.includes("network:") && l.includes(":38429"))).toBe(false); - }); - - it("still emits the mcp endpoint pointing at the backend port", () => { - vi.spyOn(os, "networkInterfaces").mockReturnValue({}); - logStartupInfo({ header: "test", ports, primary: "web" }); - const lines = log.mock.calls.map((c) => c.join(" ")); - expect(lines).toContain("[kandev] mcp: http://localhost:38429/mcp"); - }); - - it("emits no network lines when only loopback interfaces exist", () => { - vi.spyOn(os, "networkInterfaces").mockReturnValue({ - lo: [ - { - address: "127.0.0.1", - netmask: "255.0.0.0", - family: "IPv4", - mac: "00:00:00:00:00:00", - internal: true, - cidr: "127.0.0.1/8", - }, - ], - }); - - logStartupInfo({ header: "test", ports }); - - const lines = log.mock.calls.map((c) => c.join(" ")); - expect(lines.some((l) => l.includes("network:"))).toBe(false); - }); -}); diff --git a/apps/cli/src/shared.ts b/apps/cli/src/shared.ts deleted file mode 100644 index 94a3451a10..0000000000 --- a/apps/cli/src/shared.ts +++ /dev/null @@ -1,329 +0,0 @@ -/** - * Shared utilities for CLI commands (dev, start, run). - * - * This module extracts common patterns used across different launch modes - * to reduce duplication and ensure consistent behavior. - */ - -import type { ChildProcess } from "node:child_process"; -import crypto from "node:crypto"; -import type { BackendPortSource } from "./args"; -import os from "node:os"; - -import { DEFAULT_AGENTCTL_PORT, DEFAULT_BACKEND_PORT, DEFAULT_WEB_PORT } from "./constants"; -import { assertPortAvailable, pickAvailablePortExcluding } from "./ports"; -import { createProcessSupervisor } from "./process"; - -export type PortConfig = { - backendPort: number; - webPort?: number; - agentctlPort: number; - backendUrl: string; -}; - -export type PortSelection = { - backendPort?: number; - backendPortSource?: BackendPortSource; - webPort?: number; -}; - -/** - * Picks available ports for all services, using provided values or finding free ports. - * - * @param options - Optional preferred service ports and backend-port source - * @param options.backendPort - Optional preferred backend port - * @param options.webPort - Optional preferred web port - * @returns Resolved ports for all services - */ -export async function pickPorts({ - backendPort, - backendPortSource, - webPort, -}: PortSelection = {}): Promise { - if (backendPort !== undefined) { - await assertPortAvailable(backendPort, backendPortSource); - } - const selectedPorts = new Set(); - const resolvedBackendPort = - backendPort ?? (await pickAvailablePortExcluding(DEFAULT_BACKEND_PORT, selectedPorts)); - selectedPorts.add(resolvedBackendPort); - - const resolvedWebPort = - webPort ?? (await pickAvailablePortExcluding(DEFAULT_WEB_PORT, selectedPorts)); - if (selectedPorts.has(resolvedWebPort)) { - throw new Error(`Web port ${resolvedWebPort} conflicts with another selected service port`); - } - selectedPorts.add(resolvedWebPort); - - const agentctlPort = await pickAvailablePortExcluding(DEFAULT_AGENTCTL_PORT, selectedPorts); - - return { - backendPort: resolvedBackendPort, - webPort: resolvedWebPort, - agentctlPort, - backendUrl: `http://localhost:${resolvedBackendPort}`, - }; -} - -export async function pickBackendPorts({ - backendPort, - backendPortSource, -}: Pick = {}): Promise { - if (backendPort !== undefined) { - await assertPortAvailable(backendPort, backendPortSource); - } - const selectedPorts = new Set(); - const resolvedBackendPort = - backendPort ?? (await pickAvailablePortExcluding(DEFAULT_BACKEND_PORT, selectedPorts)); - selectedPorts.add(resolvedBackendPort); - const agentctlPort = await pickAvailablePortExcluding(DEFAULT_AGENTCTL_PORT, selectedPorts); - - return { - backendPort: resolvedBackendPort, - agentctlPort, - backendUrl: `http://localhost:${resolvedBackendPort}`, - }; -} - -export type BackendEnvOptions = { - ports: PortConfig; - /** Log level: debug, info, warn, error (default: info) */ - logLevel?: string; - /** Backend stdout threshold. Normal/debug use warn; verbose uses info. */ - consoleLogLevel?: "warn" | "info"; - /** Route browser pages through an external dev web server. Production serves the SPA from Go. */ - webProxy?: boolean; - /** Additional environment variables to merge */ - extra?: Record; - /** Health token owned by the launcher for this backend process */ - healthToken?: string; -}; - -/** - * Builds environment variables for the backend process. - * - * @param options - Configuration options for the backend environment - * @returns Environment object for the backend process - */ -export function buildBackendEnv(options: BackendEnvOptions): NodeJS.ProcessEnv { - const { - ports, - logLevel, - consoleLogLevel = "warn", - webProxy = true, - extra, - healthToken, - } = options; - if (webProxy && ports.webPort === undefined) { - throw new Error("webProxy requires a web port"); - } - const env: NodeJS.ProcessEnv = { - ...process.env, - KANDEV_SERVER_PORT: String(ports.backendPort), - ...(webProxy ? { KANDEV_WEB_INTERNAL_URL: `http://localhost:${ports.webPort}` } : {}), - KANDEV_AGENT_STANDALONE_PORT: String(ports.agentctlPort), - ...(logLevel ? { KANDEV_LOG_LEVEL: logLevel } : {}), - KANDEV_CONSOLE_LOG_LEVEL: consoleLogLevel, - ...extra, - KANDEV_DESKTOP_HEALTH_TOKEN: healthToken ?? crypto.randomBytes(32).toString("hex"), - }; - if (!webProxy) { - delete env.KANDEV_WEB_INTERNAL_URL; - } - return env; -} - -export function resolveBackendLogLevels({ - verbose = false, - debug = false, - env = process.env, -}: { verbose?: boolean; debug?: boolean; env?: NodeJS.ProcessEnv } = {}): { - file: string; - console: "warn" | "info"; -} { - return { - file: env.KANDEV_LOG_LEVEL?.trim() || (debug ? "debug" : "info"), - console: verbose ? "info" : "warn", - }; -} - -export function createOutputRingBuffer( - sink: Pick | null, - maxChars = 64 * 1024, -): { attach: (stream: NodeJS.ReadableStream | null) => void; read: () => string } { - let buffer = ""; - const attach = (stream: NodeJS.ReadableStream | null): void => { - stream?.on("data", (chunk: Buffer | string) => { - sink?.write(chunk); - buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8"); - if (buffer.length > maxChars) { - buffer = buffer.slice(buffer.length - maxChars); - } - }); - }; - return { attach, read: () => buffer }; -} - -export type WebEnvOptions = { - ports: PortConfig; - /** Set NODE_ENV to production */ - production?: boolean; - /** Enable debug mode */ - debug?: boolean; -}; - -/** - * Builds environment variables for the web process. - * - * @param options - Configuration options for the web environment - * @returns Environment object for the web process - */ -export function buildWebEnv(options: WebEnvOptions): NodeJS.ProcessEnv { - const { ports, production = false, debug = false } = options; - if (ports.webPort === undefined) { - throw new Error("web env requires a web port"); - } - - const env: NodeJS.ProcessEnv = { - ...process.env, - // Internal Vite/server tooling config. Browser traffic enters through Go - // in every mode so frontend code should use same-origin API/WS URLs. - KANDEV_API_BASE_URL: ports.backendUrl, - PORT: String(ports.webPort), - HOSTNAME: "127.0.0.1", - }; - - if (production) { - (env as Record).NODE_ENV = "production"; - } - // Explicitly unset so a host-level VITE_KANDEV_API_PORT (from a .env file, - // Docker env, or CI variable) cannot leak through the process.env spread and - // reintroduce cross-origin browser API calls. - delete env.VITE_KANDEV_API_PORT; - - if (debug) { - env.KANDEV_DEBUG = "true"; - env.VITE_KANDEV_DEBUG = "true"; - } - - return env; -} - -/** - * Returns the host's non-loopback, non-internal IPv4/IPv6 addresses. Used to - * print LAN / Tailscale / SSH-forwarded browser URLs in the startup banner. - */ -export function listHostNetworkAddresses(): string[] { - const v4: string[] = []; - const v6: string[] = []; - const seen = new Set(); - const interfaces = os.networkInterfaces(); - for (const addrs of Object.values(interfaces)) { - if (!addrs) continue; - for (const addr of addrs) { - if (addr.internal) continue; - // Skip link-local IPv6 (fe80::/10) and link-local IPv4 (169.254.0.0/16, - // RFC 3927) — neither is reachable from a remote machine, and the - // 169.254 range in particular is what Hyper-V assigns to its phantom - // WSL adapter, which clutters the startup output. The regex covers the - // full /10 (fe80::–febf::); OS stacks only assign fe80::/64 in practice - // but a stricter check is the same effort and removes the surprise. - if (addr.family === "IPv6" && /^fe[89ab]/i.test(addr.address)) continue; - if (addr.family === "IPv4" && addr.address.startsWith("169.254.")) continue; - if (seen.has(addr.address)) continue; - seen.add(addr.address); - if (addr.family === "IPv4") v4.push(addr.address); - else v6.push(addr.address); - } - } - // IPv4 first — LAN + Tailscale IPv4 are what people usually want. - return [...v4, ...v6]; -} - -export type StartupInfoOptions = { - /** Mode header line, e.g. "dev mode: using local repo" or "release: v0.0.12 (github latest)" */ - header: string; - ports: PortConfig; - /** - * Which port a user actually opens in a browser. Normal `dev`/`start`/`run` - * use the Go backend as the entrypoint; `primary: "web"` is retained for - * diagnostics. Network URLs are only listed under this port — the other one - * is internal-only. Defaults to "backend". - */ - primary?: "backend" | "web"; - /** Database file path */ - dbPath?: string; - /** Log level being used */ - logLevel?: string; -}; - -/** - * Logs a unified startup info block to the console. - * - * Shows only the URL the user actually opens. The other web/Vite port and the - * agentctl port are internal plumbing and would only mislead. Below the URL, - * lists the same port on each non-loopback interface (LAN, Tailscale) so a - * user opening the app remotely sees the right address. - */ -export function logStartupInfo(options: StartupInfoOptions): void { - const { header, ports, primary = "backend", dbPath, logLevel } = options; - let primaryPort = ports.backendPort; - if (primary === "web") { - if (ports.webPort === undefined) { - throw new Error("web startup info requires a web port"); - } - primaryPort = ports.webPort; - } - const primaryUrl = `http://localhost:${primaryPort}`; - const networkHosts = listHostNetworkAddresses(); - - console.log(`[kandev] ${header}`); - console.log("[kandev] url:", primaryUrl); - for (const url of networkUrlsForPort(primaryPort, networkHosts)) { - console.log("[kandev] network:", url); - } - console.log("[kandev] mcp:", `${ports.backendUrl}/mcp`); - if (dbPath) { - console.log("[kandev] db:", dbPath); - } - if (logLevel) { - console.log("[kandev] log level:", logLevel); - } -} - -/** - * Builds `http://:` URLs from a list of host addresses, wrapping - * IPv6 addresses in brackets per RFC 3986. - */ -export function networkUrlsForPort(port: number, hosts: string[]): string[] { - return hosts.map((host) => { - const formatted = host.includes(":") ? `[${host}]` : host; - return `http://${formatted}:${port}`; - }); -} - -/** - * Attaches a standardized exit handler to a backend process. - * - * When the backend exits, this handler logs the exit reason and triggers - * a graceful shutdown of all supervised processes. If the process was - * killed by a signal, it exits with code 0; otherwise it uses the - * process exit code (defaulting to 1). - * - * @param backendProc - The backend child process - * @param supervisor - The process supervisor managing child processes - */ -export function attachBackendExitHandler( - backendProc: ChildProcess, - supervisor: ReturnType, - options: { shouldShutdown?: () => boolean } = {}, -): void { - backendProc.on("exit", (code, signal) => { - console.error(`[kandev] backend exited (code=${code}, signal=${signal})`); - if (options.shouldShutdown && !options.shouldShutdown()) { - return; - } - const exitCode = signal ? 0 : (code ?? 1); - void supervisor.shutdown("backend exit").then(() => process.exit(exitCode)); - }); -} diff --git a/apps/cli/src/start.ts b/apps/cli/src/start.ts deleted file mode 100644 index 92a393de22..0000000000 --- a/apps/cli/src/start.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Production start command for running local builds. - * - * This module implements the `kandev start` command, which runs the locally - * built backend binary in production mode. Unlike `kandev dev` - * which uses hot-reloading, this runs the optimized production builds. - * - * Prerequisites: - * - Backend must be built: `make build-backend` - * - Or simply: `make build` - */ - -import fs from "node:fs"; -import path from "node:path"; - -import type { BackendPortSource } from "./args"; -import { - HEALTH_TIMEOUT_MS_RELEASE, - resolveDataDir, - resolveDatabasePath, - resolveKandevHomeDir, -} from "./constants"; -import { resolveHealthTimeoutMs, waitForHealth } from "./health"; -import { getBinaryName } from "./platform"; -import { createProcessSupervisor } from "./process"; -import { - buildBackendEnv, - createOutputRingBuffer, - logStartupInfo, - pickBackendPorts, - resolveBackendLogLevels, -} from "./shared"; -import { launchRestartableBackend } from "./supervisor/backend"; -import { openBrowser } from "./web"; - -export type StartOptions = { - /** Path to the repository root directory */ - repoRoot: string; - /** Optional preferred backend port (finds available port if not specified) */ - backendPort?: number; - backendPortSource?: BackendPortSource; - /** Show backend info logs on stdout */ - verbose?: boolean; - /** Retain backend debug logs in its file + agent message dumps */ - debug?: boolean; - /** Skip browser open. Set by service units and preview environments. */ - headless?: boolean; -}; - -/** - * Runs the application in production mode using local builds. - * - * This function: - * 1. Validates that build artifacts exist - * 2. Picks available ports for all services - * 3. Starts the backend binary (with warn log level for clean output) - * 4. Waits for the backend to be healthy before announcing readiness - * - * @param options - Configuration for the start command - * @throws Error if backend binary is not found - */ -export async function runStart({ - repoRoot, - backendPort, - backendPortSource, - verbose = false, - debug = false, - headless = false, -}: StartOptions): Promise { - const ports = await pickBackendPorts({ backendPort, backendPortSource }); - - const backendBin = path.join(repoRoot, "apps", "backend", "bin", getBinaryName("kandev")); - if (!fs.existsSync(backendBin)) { - throw new Error("Backend binary not found. Run `make build` first."); - } - - // File logs retain info by default. Verbose only changes the stdout sink; - // debug lowers the file threshold while stdout remains warn+. - fs.mkdirSync(resolveDataDir(), { recursive: true }); - // The data dir holds the SQLite DB; keep it owner-only even if it pre-existed - // with a looser umask-derived mode. - fs.chmodSync(resolveDataDir(), 0o700); - const dbPath = resolveDatabasePath(); - const levels = resolveBackendLogLevels({ verbose, debug }); - const logLevel = levels.file; - const backendEnv = buildBackendEnv({ - ports, - logLevel, - consoleLogLevel: levels.console, - webProxy: false, - extra: { - KANDEV_DATABASE_PATH: dbPath, - ...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true", KANDEV_DEBUG_PPROF_ENABLED: "true" } : {}), - }, - }); - - logStartupInfo({ - header: "start mode: using local build", - ports, - dbPath, - logLevel, - }); - - const supervisor = createProcessSupervisor(); - supervisor.attachSignalHandlers(); - - const output = createOutputRingBuffer(process.stdout); - const backend = await launchRestartableBackend({ - command: backendBin, - args: [], - cwd: path.dirname(backendBin), - env: backendEnv, - homeDir: resolveKandevHomeDir(), - ports, - mode: "start", - stdio: ["ignore", "pipe", "inherit"], - supervisor, - onProcess: (proc) => output.attach(proc.stdout), - }); - - const healthTimeoutMs = resolveHealthTimeoutMs(HEALTH_TIMEOUT_MS_RELEASE); - console.log("[kandev] starting backend..."); - await waitForHealth( - ports.backendUrl, - backend.proc, - healthTimeoutMs, - backendEnv.KANDEV_DESKTOP_HEALTH_TOKEN, - () => { - const buffered = output.read(); - if (buffered.trim().length === 0) return; - console.error("[kandev] --- backend stdout (last captured output) ---"); - console.error(buffered.trimEnd()); - console.error("[kandev] --- end backend stdout ---"); - }, - ); - console.log(`[kandev] backend ready at ${ports.backendUrl}`); - - console.log("[kandev] open: " + ports.backendUrl); - if (headless) { - console.log(`[kandev] ready (headless) at ${ports.backendUrl}`); - return; - } - openBrowser(ports.backendUrl); -} diff --git a/apps/cli/src/supervisor/backend.test.ts b/apps/cli/src/supervisor/backend.test.ts deleted file mode 100644 index 12b68fb72a..0000000000 --- a/apps/cli/src/supervisor/backend.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldUseSupervisor } from "./backend"; - -describe("backend supervisor launch policy", () => { - it("uses the supervisor by default", () => { - expect(shouldUseSupervisor({})).toBe(true); - }); - - it("can be disabled with KANDEV_NO_SUPERVISOR", () => { - expect(shouldUseSupervisor({ KANDEV_NO_SUPERVISOR: "true" })).toBe(false); - expect(shouldUseSupervisor({ KANDEV_NO_SUPERVISOR: "1" })).toBe(true); - }); -}); diff --git a/apps/cli/src/supervisor/backend.ts b/apps/cli/src/supervisor/backend.ts deleted file mode 100644 index 1bed7e0346..0000000000 --- a/apps/cli/src/supervisor/backend.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { spawn, spawnSync, type ChildProcess, type StdioOptions } from "node:child_process"; -import path from "node:path"; - -import type { PortConfig } from "../shared"; -import { attachBackendExitHandler } from "../shared"; -import type { ChildLike, createProcessSupervisor } from "../process"; -import { buildLaunchManifest, writeLaunchManifest } from "./manifest"; -import { manifestPath, prepareSupervisorDir, socketPath } from "./paths"; -import { createRestartableChild } from "./child"; -import { startControlServer, type ControlServer } from "./control"; - -export type BackendSupervisorHandle = { - proc: ChildProcess; - control: ControlServer | null; - env: NodeJS.ProcessEnv; -}; - -export async function launchRestartableBackend({ - command, - args, - cwd, - env, - homeDir, - ports, - mode, - stdio, - supervisor, - onProcess, -}: { - command: string; - args: string[]; - cwd: string; - env: NodeJS.ProcessEnv; - homeDir: string; - ports: PortConfig; - mode: string; - stdio: StdioOptions; - supervisor: ReturnType; - onProcess?: (proc: ChildProcess) => void; -}): Promise { - if (!shouldUseSupervisor(env)) { - const proc = spawn(command, args, { cwd, env, stdio }); - onProcess?.(proc); - supervisor.children.push(proc as ChildLike); - attachBackendExitHandler(proc, supervisor); - return { proc, control: null, env }; - } - - const supervisorEnv = withSupervisorEnv(env, homeDir); - const manifest = buildLaunchManifest({ - backend_executable: resolveExecutable(command), - argv: args, - cwd, - env: supervisorEnv, - home_dir: homeDir, - port: ports.backendPort, - mode, - }); - writeLaunchManifest(manifest, supervisorEnv.KANDEV_SUPERVISOR_MANIFEST); - - const child = createRestartableChild(manifest, { stdio, extraEnv: supervisorEnv }); - let restarting = false; - const attachExit = (proc: ChildProcess) => { - attachBackendExitHandler(proc, supervisor, { - shouldShutdown: () => !restarting, - }); - }; - const proc = child.start(); - onProcess?.(proc); - supervisor.children.push(proc as ChildLike); - attachExit(proc); - - const control = await startControlServer(supervisorEnv.KANDEV_SUPERVISOR_SOCKET, async () => { - restarting = true; - try { - const next = await child.restart(); - onProcess?.(next); - supervisor.children.push(next as ChildLike); - attachExit(next); - } finally { - restarting = false; - } - }); - - return { proc, control, env: supervisorEnv }; -} - -export function shouldUseSupervisor(env: NodeJS.ProcessEnv = process.env): boolean { - return env.KANDEV_NO_SUPERVISOR !== "true"; -} - -export function withSupervisorEnv( - env: NodeJS.ProcessEnv, - homeDir: string, -): NodeJS.ProcessEnv & { - KANDEV_SUPERVISOR_SOCKET: string; - KANDEV_SUPERVISOR_MANIFEST: string; - KANDEV_RESTART_ADAPTER: string; -} { - prepareSupervisorDir(homeDir); - return { - ...env, - KANDEV_SUPERVISOR_SOCKET: socketPath(homeDir), - KANDEV_SUPERVISOR_MANIFEST: manifestPath(homeDir), - KANDEV_RESTART_ADAPTER: "supervisor", - }; -} - -function resolveExecutable(command: string): string { - if (path.isAbsolute(command)) return command; - const found = spawnSync(process.platform === "win32" ? "where" : "which", [command], { - encoding: "utf8", - }); - const first = found.stdout - ?.split(/\r?\n/) - .map((line) => line.trim()) - .find(Boolean); - if (!first) { - throw new Error(`Unable to resolve executable ${command}`); - } - return first; -} diff --git a/apps/cli/src/supervisor/child.ts b/apps/cli/src/supervisor/child.ts deleted file mode 100644 index c37caefca2..0000000000 --- a/apps/cli/src/supervisor/child.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { spawn, type ChildProcess, type StdioOptions } from "node:child_process"; -import kill from "tree-kill"; - -import type { LaunchManifest } from "./manifest"; - -const RESTART_TIMEOUT_MS = 10000; - -export type RestartableChild = { - current: () => ChildProcess | null; - start: () => ChildProcess; - restart: () => Promise; - stop: () => Promise; -}; - -export function createRestartableChild( - manifest: LaunchManifest, - options: { stdio?: StdioOptions; extraEnv?: NodeJS.ProcessEnv } = {}, -): RestartableChild { - let child: ChildProcess | null = null; - - const start = () => { - child = spawn(manifest.backend_executable, manifest.argv, { - cwd: manifest.cwd, - env: { - ...process.env, - ...manifest.env, - ...options.extraEnv, - }, - stdio: options.stdio ?? "inherit", - }); - return child; - }; - - const stop = async () => { - if (!child?.pid || child.exitCode !== null) return; - await terminate(child, RESTART_TIMEOUT_MS); - }; - - const restart = async () => { - await stop(); - return start(); - }; - - return { - current: () => child, - start, - restart, - stop, - }; -} - -function terminate(proc: ChildProcess, timeoutMs: number): Promise { - return new Promise((resolve) => { - const pid = proc.pid; - if (!pid) { - resolve(); - return; - } - let done = false; - const finish = () => { - if (done) return; - done = true; - clearTimeout(timeout); - resolve(); - }; - const timeout = setTimeout(() => { - kill(pid, process.platform === "win32" ? undefined : "SIGKILL", finish); - }, timeoutMs); - proc.once("exit", finish); - kill(pid, process.platform === "win32" ? undefined : "SIGTERM", (err) => { - if (err) finish(); - }); - }); -} diff --git a/apps/cli/src/supervisor/control.test.ts b/apps/cli/src/supervisor/control.test.ts deleted file mode 100644 index 057015919a..0000000000 --- a/apps/cli/src/supervisor/control.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; -import { requestRestart, startControlServer } from "./control"; - -const tmpDirs: string[] = []; - -afterEach(() => { - for (const dir of tmpDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("supervisor control protocol", () => { - it("accepts restart requests and calls the restart handler asynchronously", async () => { - let calls = 0; - const socket = testSocketPath(); - const server = await startControlServer(socket, async () => { - calls += 1; - }); - - const response = await requestRestart(socket); - await waitFor(() => calls === 1); - await server.close(); - - expect(response).toEqual({ accepted: true, message: "Restart accepted" }); - }); - - it("rejects concurrent restart requests while one is already running", async () => { - let calls = 0; - let releaseRestart!: () => void; - let markRestartStarted!: () => void; - const restartStarted = new Promise((resolve) => { - markRestartStarted = resolve; - }); - const restartReleased = new Promise((resolve) => { - releaseRestart = resolve; - }); - const socket = testSocketPath(); - const server = await startControlServer(socket, async () => { - calls += 1; - markRestartStarted(); - await restartReleased; - }); - - const first = requestRestart(socket); - await restartStarted; - const second = await requestRestart(socket); - releaseRestart(); - const firstResponse = await first; - await server.close(); - - expect(firstResponse).toEqual({ accepted: true, message: "Restart accepted" }); - expect(second).toEqual({ accepted: false, message: "Restart already in progress" }); - expect(calls).toBe(1); - }); -}); - -function testSocketPath(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-supervisor-control-")); - tmpDirs.push(dir); - if (process.platform === "win32") { - return `\\\\.\\pipe\\kandev-test-${process.pid}-${Date.now()}`; - } - return path.join(dir, "control.sock"); -} - -async function waitFor(predicate: () => boolean): Promise { - const deadline = Date.now() + 1000; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error("condition not met"); -} diff --git a/apps/cli/src/supervisor/control.ts b/apps/cli/src/supervisor/control.ts deleted file mode 100644 index f74c1bd572..0000000000 --- a/apps/cli/src/supervisor/control.ts +++ /dev/null @@ -1,111 +0,0 @@ -import net from "node:net"; -import fs from "node:fs"; - -export type ControlRequest = { - action: "restart"; -}; - -export type ControlResponse = { - accepted: boolean; - message: string; -}; - -export type ControlServer = { - close: () => Promise; -}; - -export function startControlServer( - socket: string, - onRestart: () => Promise, -): Promise { - let restartInProgress = false; - const scheduleRestart = (): boolean => { - if (restartInProgress) return false; - restartInProgress = true; - setTimeout(() => { - void onRestart().finally(() => { - restartInProgress = false; - }); - }, 0); - return true; - }; - - if (process.platform !== "win32" && fs.existsSync(socket)) { - fs.unlinkSync(socket); - } - const server = net.createServer((conn) => { - let buf = ""; - conn.setEncoding("utf8"); - conn.on("data", (chunk) => { - buf += chunk; - if (!buf.includes("\n")) return; - const line = buf.slice(0, buf.indexOf("\n")); - void handleControlLine(line, scheduleRestart) - .then((resp) => { - conn.end(`${JSON.stringify(resp)}\n`); - }) - .catch((err) => { - conn.end( - `${JSON.stringify({ - accepted: false, - message: err instanceof Error ? err.message : String(err), - })}\n`, - ); - }); - }); - }); - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(socket, () => { - server.off("error", reject); - resolve({ - close: () => - new Promise((closeResolve, closeReject) => { - server.close((err) => { - if (process.platform !== "win32" && fs.existsSync(socket)) { - fs.unlinkSync(socket); - } - if (err) closeReject(err); - else closeResolve(); - }); - }), - }); - }); - }); -} - -export function requestRestart(socket: string): Promise { - return new Promise((resolve, reject) => { - const conn = net.createConnection(socket); - let buf = ""; - conn.setEncoding("utf8"); - conn.on("connect", () => { - conn.write(`${JSON.stringify({ action: "restart" satisfies ControlRequest["action"] })}\n`); - }); - conn.on("data", (chunk) => { - buf += chunk; - }); - conn.on("error", reject); - conn.on("end", () => { - try { - resolve(JSON.parse(buf.trim()) as ControlResponse); - } catch (err) { - reject(err); - } - }); - }); -} - -async function handleControlLine( - line: string, - scheduleRestart: () => boolean, -): Promise { - const req = JSON.parse(line) as ControlRequest; - if (req.action !== "restart") { - return { accepted: false, message: `unsupported action ${String(req.action)}` }; - } - if (!scheduleRestart()) { - return { accepted: false, message: "Restart already in progress" }; - } - return { accepted: true, message: "Restart accepted" }; -} diff --git a/apps/cli/src/supervisor/manifest.test.ts b/apps/cli/src/supervisor/manifest.test.ts deleted file mode 100644 index 6c851b748a..0000000000 --- a/apps/cli/src/supervisor/manifest.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; -import { - allowedEnv, - buildLaunchManifest, - readLaunchManifest, - writeLaunchManifest, -} from "./manifest"; - -const tmpDirs: string[] = []; - -afterEach(() => { - for (const dir of tmpDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("supervisor launch manifest", () => { - it("writes structured launch data with an allowlisted env", () => { - const home = tempDir(); - const manifest = buildLaunchManifest({ - backend_executable: "/bin/kandev", - argv: ["--serve"], - cwd: home, - env: { - KANDEV_HOME_DIR: home, - KANDEV_SERVER_PORT: "38429", - KANDEV_DESKTOP_HEALTH_TOKEN: "health-token", - KANDEV_WEB_TITLE_PREFIX: "Preview", - SECRET_TOKEN: "do-not-write", - }, - home_dir: home, - port: 38429, - mode: "cli", - now: new Date("2026-06-14T18:00:00Z"), - }); - const target = path.join(home, "supervisor", "launch.json"); - - writeLaunchManifest(manifest, target); - - expect(readLaunchManifest(target)).toEqual(manifest); - expect(readLaunchManifest(target).env).toEqual({ - KANDEV_HOME_DIR: home, - KANDEV_SERVER_PORT: "38429", - KANDEV_DESKTOP_HEALTH_TOKEN: "health-token", - KANDEV_WEB_TITLE_PREFIX: "Preview", - }); - if (process.platform !== "win32") { - expect((fs.statSync(target).mode & 0o777).toString(8)).toBe("600"); - } - }); - - it("rejects relative executable, cwd, and home paths", () => { - const base = { - backend_executable: "/bin/kandev", - argv: [] as string[], - cwd: "/tmp", - env: {}, - home_dir: "/tmp/kandev", - port: 38429, - mode: "cli", - }; - - expect(() => buildLaunchManifest({ ...base, backend_executable: "kandev" })).toThrow( - /backend_executable/, - ); - expect(() => buildLaunchManifest({ ...base, cwd: "relative" })).toThrow(/cwd/); - expect(() => buildLaunchManifest({ ...base, home_dir: "relative" })).toThrow(/home_dir/); - }); - - it("does not copy arbitrary environment variables", () => { - expect( - allowedEnv({ - KANDEV_HOME_DIR: "/tmp/kandev", - KANDEV_CONSOLE_LOG_LEVEL: "warn", - KANDEV_DESKTOP_HEALTH_TOKEN: "health-token", - KANDEV_WEB_TITLE_PREFIX: "Dev", - AWS_SECRET_ACCESS_KEY: "secret", - GITHUB_TOKEN: "secret", - }), - ).toEqual({ - KANDEV_HOME_DIR: "/tmp/kandev", - KANDEV_CONSOLE_LOG_LEVEL: "warn", - KANDEV_DESKTOP_HEALTH_TOKEN: "health-token", - KANDEV_WEB_TITLE_PREFIX: "Dev", - }); - }); -}); - -function tempDir(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-supervisor-")); - tmpDirs.push(dir); - return dir; -} diff --git a/apps/cli/src/supervisor/manifest.ts b/apps/cli/src/supervisor/manifest.ts deleted file mode 100644 index d830b902f4..0000000000 --- a/apps/cli/src/supervisor/manifest.ts +++ /dev/null @@ -1,94 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -import { manifestPath, prepareSupervisorDir } from "./paths"; - -export type LaunchManifest = { - version: 1; - backend_executable: string; - argv: string[]; - cwd: string; - env: Record; - home_dir: string; - port: number; - mode: string; - created_at: string; -}; - -export type LaunchManifestInput = Omit & { - env: NodeJS.ProcessEnv; - now?: Date; -}; - -const ENV_ALLOWLIST = new Set([ - "KANDEV_HOME_DIR", - "KANDEV_DATABASE_PATH", - "KANDEV_SERVER_PORT", - "KANDEV_DESKTOP_HEALTH_TOKEN", - "KANDEV_WEB_INTERNAL_URL", - "KANDEV_AGENT_STANDALONE_PORT", - "KANDEV_LOG_LEVEL", - "KANDEV_CONSOLE_LOG_LEVEL", - "KANDEV_WEB_TITLE_PREFIX", - "KANDEV_DEBUG_DEV_MODE", - "KANDEV_DEBUG_AGENT_MESSAGES", - "KANDEV_DEBUG_PPROF_ENABLED", - "KANDEV_E2E_MOCK", - "KANDEV_MOCK_AGENT", - "KANDEV_MOCK_GITHUB", - "KANDEV_MOCK_JIRA", - "KANDEV_MOCK_LINEAR", - "KANDEV_SUPERVISOR_SOCKET", - "KANDEV_SUPERVISOR_MANIFEST", - "KANDEV_RESTART_ADAPTER", -]); - -export function buildLaunchManifest(input: LaunchManifestInput): LaunchManifest { - if (!path.isAbsolute(input.backend_executable)) { - throw new Error("backend_executable must be absolute"); - } - if (!path.isAbsolute(input.cwd)) { - throw new Error("cwd must be absolute"); - } - if (!path.isAbsolute(input.home_dir)) { - throw new Error("home_dir must be absolute"); - } - return { - version: 1, - backend_executable: input.backend_executable, - argv: [...input.argv], - cwd: input.cwd, - env: allowedEnv(input.env), - home_dir: input.home_dir, - port: input.port, - mode: input.mode, - created_at: (input.now ?? new Date()).toISOString(), - }; -} - -export function writeLaunchManifest( - manifest: LaunchManifest, - targetPath = manifestPath(manifest.home_dir), -): string { - prepareSupervisorDir(manifest.home_dir); - fs.writeFileSync(targetPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); - if (process.platform !== "win32") { - fs.chmodSync(targetPath, 0o600); - } - return targetPath; -} - -export function readLaunchManifest(targetPath: string): LaunchManifest { - return JSON.parse(fs.readFileSync(targetPath, "utf8")) as LaunchManifest; -} - -export function allowedEnv(env: NodeJS.ProcessEnv): Record { - const out: Record = {}; - for (const key of ENV_ALLOWLIST) { - const value = env[key]; - if (value !== undefined) { - out[key] = value; - } - } - return out; -} diff --git a/apps/cli/src/supervisor/paths.ts b/apps/cli/src/supervisor/paths.ts deleted file mode 100644 index 3c92f04b8a..0000000000 --- a/apps/cli/src/supervisor/paths.ts +++ /dev/null @@ -1,29 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -import { resolveKandevHomeDir } from "../constants"; - -export function supervisorDir(homeDir = resolveKandevHomeDir()): string { - return path.join(homeDir, "supervisor"); -} - -export function manifestPath(homeDir = resolveKandevHomeDir()): string { - return path.join(supervisorDir(homeDir), "launch.json"); -} - -export function socketPath(homeDir = resolveKandevHomeDir()): string { - if (process.platform === "win32") { - const safe = homeDir.replace(/[^a-zA-Z0-9_.-]/g, "-"); - return `\\\\.\\pipe\\kandev-${safe}-supervisor`; - } - return path.join(supervisorDir(homeDir), "control.sock"); -} - -export function prepareSupervisorDir(homeDir = resolveKandevHomeDir()): string { - const dir = supervisorDir(homeDir); - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - if (process.platform !== "win32") { - fs.chmodSync(dir, 0o700); - } - return dir; -} diff --git a/apps/cli/src/version.test.ts b/apps/cli/src/version.test.ts deleted file mode 100644 index 1f556c7d6a..0000000000 --- a/apps/cli/src/version.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { compareVersions, parseVersion, sortVersionsDesc } from "./version"; - -describe("parseVersion", () => { - it("parses simple version", () => { - const v = parseVersion("1.2.3"); - expect(v.segments).toEqual([1, 2, 3]); - expect(v.raw).toBe("1.2.3"); - }); - - it("strips v prefix", () => { - expect(parseVersion("v1.2").segments).toEqual([1, 2]); - }); - - it("handles single segment", () => { - expect(parseVersion("3").segments).toEqual([3]); - }); - - it("handles two segments", () => { - expect(parseVersion("v0.9").segments).toEqual([0, 9]); - }); - - it("returns empty segments for non-numeric input", () => { - expect(parseVersion("latest").segments).toEqual([]); - }); - - it("returns empty segments for partially numeric input", () => { - expect(parseVersion("1.2.beta").segments).toEqual([]); - }); -}); - -describe("compareVersions", () => { - it("returns 0 for equal versions", () => { - expect(compareVersions("1.0.0", "1.0.0")).toBe(0); - }); - - it("returns 1 when a > b (patch)", () => { - expect(compareVersions("1.0.1", "1.0.0")).toBe(1); - }); - - it("returns -1 when a < b (patch)", () => { - expect(compareVersions("1.0.0", "1.0.1")).toBe(-1); - }); - - it("returns 1 when a > b (minor)", () => { - expect(compareVersions("1.1.0", "1.0.0")).toBe(1); - }); - - it("returns 1 when a > b (major)", () => { - expect(compareVersions("2.0.0", "1.9.9")).toBe(1); - }); - - it("strips v prefix", () => { - expect(compareVersions("v1.2.0", "1.2.0")).toBe(0); - }); - - it("strips v prefix on both sides", () => { - expect(compareVersions("v2.0.0", "v1.0.0")).toBe(1); - }); - - it("treats missing segments as 0", () => { - expect(compareVersions("1.0", "1.0.0")).toBe(0); - }); - - it("compares different length versions", () => { - expect(compareVersions("1.0.1", "1.0")).toBe(1); - }); - - it("handles single-segment versions", () => { - expect(compareVersions("2", "1")).toBe(1); - }); - - it("handles single vs multi-segment", () => { - expect(compareVersions("1", "1.0.0")).toBe(0); - }); -}); - -describe("sortVersionsDesc", () => { - it("sorts versions newest first", () => { - expect(sortVersionsDesc(["v0.1", "v1.2", "v0.9"])).toEqual(["v1.2", "v0.9", "v0.1"]); - }); - - it("sorts major versions correctly", () => { - expect(sortVersionsDesc(["v1.9", "v2.0"])).toEqual(["v2.0", "v1.9"]); - }); - - it("sorts numerically not lexicographically", () => { - expect(sortVersionsDesc(["v1.9", "v1.10", "v1.2"])).toEqual(["v1.10", "v1.9", "v1.2"]); - }); - - it("handles single element", () => { - expect(sortVersionsDesc(["v1.0"])).toEqual(["v1.0"]); - }); - - it("handles empty array", () => { - expect(sortVersionsDesc([])).toEqual([]); - }); - - it("does not mutate the original array", () => { - const input = ["v0.2", "v0.1"]; - sortVersionsDesc(input); - expect(input).toEqual(["v0.2", "v0.1"]); - }); -}); diff --git a/apps/cli/src/version.ts b/apps/cli/src/version.ts deleted file mode 100644 index b4413c6a0e..0000000000 --- a/apps/cli/src/version.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Centralized version parsing and comparison for the CLI. - * - * Version format: optional "v" prefix + dot-separated numeric segments. - * Examples: "v1.2", "0.3.0", "v2.0.1", "1" - */ - -export type ParsedVersion = { - raw: string; - segments: number[]; -}; - -/** - * Parse a version string into numeric segments. - * Strips leading "v" prefix if present. - * - * "v1.2.3" -> { raw: "v1.2.3", segments: [1, 2, 3] } - * "0.3" -> { raw: "0.3", segments: [0, 3] } - * "bad" -> { raw: "bad", segments: [] } - */ -export function parseVersion(version: string): ParsedVersion { - const cleaned = String(version).replace(/^v/, ""); - const parts = cleaned.split("."); - const segments: number[] = []; - for (const part of parts) { - const n = parseInt(part, 10); - if (Number.isNaN(n)) return { raw: version, segments: [] }; - segments.push(n); - } - return { raw: version, segments }; -} - -/** - * Compare two version strings. - * Returns 1 if a > b, -1 if a < b, 0 if equal. - * Missing segments are treated as 0 (e.g. "1.0" == "1.0.0"). - */ -export function compareVersions(a: string, b: string): number { - const pa = parseVersion(a).segments; - const pb = parseVersion(b).segments; - const len = Math.max(pa.length, pb.length); - for (let i = 0; i < len; i++) { - const av = pa[i] ?? 0; - const bv = pb[i] ?? 0; - if (av > bv) return 1; - if (av < bv) return -1; - } - return 0; -} - -/** - * Sort version strings in descending order (newest first). - * Non-parseable strings sort to the end. - */ -export function sortVersionsDesc(versions: string[]): string[] { - return [...versions].sort((a, b) => compareVersions(b, a)); -} diff --git a/apps/cli/src/web.test.ts b/apps/cli/src/web.test.ts deleted file mode 100644 index 8031568b90..0000000000 --- a/apps/cli/src/web.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { EventEmitter } from "node:events"; -import { PassThrough } from "node:stream"; -import type { ChildProcess } from "node:child_process"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const spawnMock = vi.fn(); - -vi.mock("node:child_process", () => ({ - spawn: (...args: unknown[]) => spawnMock(...args), -})); - -import { launchWebApp } from "./web"; - -type Supervisor = { - children: Array<{ pid?: number }>; - shutdown: (reason: string) => Promise; - attachSignalHandlers: () => void; -}; - -class FakeChildProcess extends EventEmitter { - pid = 12345; - stderr = new PassThrough(); -} - -function createSupervisor(): Supervisor { - return { - children: [], - shutdown: vi.fn().mockResolvedValue(undefined), - attachSignalHandlers: vi.fn(), - }; -} - -describe("launchWebApp", () => { - beforeEach(() => { - spawnMock.mockReset(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("does not auto-open browser as a side effect", () => { - const proc = new FakeChildProcess() as unknown as ChildProcess; - spawnMock.mockReturnValue(proc); - const supervisor = createSupervisor(); - - const launched = launchWebApp({ - command: "node", - args: ["server.js"], - cwd: "/tmp", - env: process.env, - supervisor: supervisor as any, - label: "web", - }); - - expect(launched).toBe(proc); - expect(supervisor.children).toContain(proc); - expect(spawnMock).toHaveBeenCalledTimes(1); - }); - - it("pipes stderr in quiet mode", () => { - const proc = new FakeChildProcess() as unknown as ChildProcess; - const pipeSpy = vi.spyOn(proc.stderr as PassThrough, "pipe"); - spawnMock.mockReturnValue(proc); - const supervisor = createSupervisor(); - - launchWebApp({ - command: "node", - args: ["server.js"], - cwd: "/tmp", - env: process.env, - supervisor: supervisor as any, - label: "web", - quiet: true, - }); - - expect(spawnMock).toHaveBeenCalledWith("node", ["server.js"], { - cwd: "/tmp", - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - }); - expect(pipeSpy).toHaveBeenCalledWith(process.stderr); - }); - - it("shuts down supervisor when web process exits", async () => { - const proc = new FakeChildProcess() as unknown as ChildProcess; - spawnMock.mockReturnValue(proc); - const supervisor = createSupervisor(); - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); - - launchWebApp({ - command: "node", - args: ["server.js"], - cwd: "/tmp", - env: process.env, - supervisor: supervisor as any, - label: "web", - }); - - proc.emit("exit", 2, null); - await Promise.resolve(); - await Promise.resolve(); - - expect(supervisor.shutdown).toHaveBeenCalledWith("web exit"); - expect(consoleErrorSpy).toHaveBeenCalledWith("[kandev] web exited (code=2, signal=null)"); - }); -}); diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts deleted file mode 100644 index 5aa387780e..0000000000 --- a/apps/cli/src/web.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { spawn, type ChildProcess, type StdioOptions } from "node:child_process"; -import { readFileSync } from "node:fs"; - -import { createProcessSupervisor } from "./process"; - -// Node CLIs that install as .cmd shims on Windows. Anything in this set goes -// through cmd.exe /c on win32 so we can spawn the shim safely: -// - Node's spawn doesn't apply PATHEXT, so spawn("pnpm", …) hits ENOENT. -// - spawn("pnpm.cmd", …) directly returns EINVAL on Node 21+ (the -// CVE-2024-27980 fix forbids direct .bat/.cmd spawning). -// - spawn(…, {shell:true}) works but trips DEP0190 (the args-aren't-escaped -// security warning) and forks an extra cmd.exe per spawn. -// Going through cmd.exe explicitly is the cleanest option: no warning, no -// EINVAL, and the args (-C, --filter, identifier-like strings) contain no -// cmd metacharacters that need extra escaping. -const WIN_SHIM_COMMANDS = new Set(["pnpm", "npm", "npx", "yarn"]); - -function resolveWindowsShim(command: string, args: string[]): { command: string; args: string[] } { - if (process.platform !== "win32") return { command, args }; - if (!WIN_SHIM_COMMANDS.has(command)) return { command, args }; - return { command: "cmd.exe", args: ["/c", command, ...args] }; -} - -let _isWSL: boolean | undefined; -function isWSL(): boolean { - if (_isWSL === undefined) { - try { - _isWSL = readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft"); - } catch { - _isWSL = false; - } - } - return _isWSL; -} - -export type WebLaunchOptions = { - command: string; - args: string[]; - cwd: string; - env: NodeJS.ProcessEnv; - supervisor: ReturnType; - label: string; - /** Suppress stdout/stderr output */ - quiet?: boolean; -}; - -export function openBrowser(url: string) { - if (process.env.KANDEV_NO_BROWSER === "1") { - return; - } - const useCmd = process.platform === "win32" || isWSL(); - const opener = process.platform === "darwin" ? "open" : useCmd ? "cmd.exe" : "xdg-open"; - const args = useCmd ? ["/c", "start", "", url] : [url]; - try { - const child = spawn(opener, args, { stdio: "ignore", detached: true }); - child.on("error", () => {}); // ignore async spawn errors (e.g. xdg-open missing) - child.unref(); - } catch { - // ignore browser launch errors - } -} - -export function launchWebApp({ - command, - args, - cwd, - env, - supervisor, - label, - quiet = false, -}: WebLaunchOptions): ChildProcess { - const stdio: StdioOptions = quiet ? ["ignore", "pipe", "pipe"] : "inherit"; - const resolved = resolveWindowsShim(command, args); - const proc = spawn(resolved.command, resolved.args, { cwd, env, stdio }); - supervisor.children.push(proc); - - // In quiet mode, only forward stderr - if (quiet && proc.stderr) { - proc.stderr.pipe(process.stderr); - } - - proc.on("exit", (code, signal) => { - console.error(`[kandev] ${label} exited (code=${code}, signal=${signal})`); - const exitCode = signal ? 0 : (code ?? 1); - void supervisor.shutdown(`${label} exit`).then(() => process.exit(exitCode)); - }); - - return proc; -} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json deleted file mode 100644 index 5202bf5ef6..0000000000 --- a/apps/cli/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "rootDir": "src", - "outDir": "dist", - "esModuleInterop": true, - "resolveJsonModule": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true - }, - "include": ["src/**/*.ts"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts deleted file mode 100644 index 8dc0e15879..0000000000 --- a/apps/cli/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - include: ["src/**/*.test.ts", "bin/**/*.test.mjs"], - }, -}); diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index f227944c45..8f9ed59504 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -80,28 +80,6 @@ importers: '@kdlbs/runtime-win32-x64': specifier: 0.0.0-bootstrap version: 0.0.0-bootstrap - devDependencies: - '@types/node': - specifier: ^20 - version: 20.19.28 - esbuild: - specifier: ^0.27.0 - version: 0.27.2 - tar: - specifier: ^7.5.19 - version: 7.5.21 - tree-kill: - specifier: ^1.2.2 - version: 1.2.2 - tsx: - specifier: ^4.15.7 - version: 4.21.0 - typescript: - specifier: ^5 - version: 5.9.3 - vitest: - specifier: ^4.1.10 - version: 4.1.10(@types/node@20.19.28)(happy-dom@20.8.9)(msw@2.12.7(@types/node@20.19.28)(typescript@5.9.3))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) desktop: devDependencies: @@ -1414,10 +1392,6 @@ packages: '@types/node': optional: true - '@isaacs/fs-minipass@4.0.1': - resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} - engines: {node: '>=18.0.0'} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -3707,10 +3681,6 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - chownr@3.0.0: - resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} - engines: {node: '>=18'} - class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -5354,14 +5324,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - - minizlib@3.1.0: - resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} - engines: {node: '>= 18'} - mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -6175,10 +6137,6 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - tar@7.5.21: - resolution: {integrity: sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==} - engines: {node: '>=18'} - tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -6221,10 +6179,6 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -6565,10 +6519,6 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yallist@5.0.0: - resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} - engines: {node: '>=18'} - yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -7535,10 +7485,6 @@ snapshots: optionalDependencies: '@types/node': 20.19.28 - '@isaacs/fs-minipass@4.0.1': - dependencies: - minipass: 7.1.3 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -9800,8 +9746,6 @@ snapshots: character-reference-invalid@2.0.1: {} - chownr@3.0.0: {} - class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -11654,12 +11598,6 @@ snapshots: minimist@1.2.8: {} - minipass@7.1.3: {} - - minizlib@3.1.0: - dependencies: - minipass: 7.1.3 - mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -12748,14 +12686,6 @@ snapshots: tapable@2.3.0: {} - tar@7.5.21: - dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.3 - minizlib: 3.1.0 - yallist: 5.0.0 - tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -12793,8 +12723,6 @@ snapshots: dependencies: tldts: 7.0.19 - tree-kill@1.2.2: {} - trim-lines@3.0.1: {} trough@2.2.0: {} @@ -13087,8 +13015,6 @@ snapshots: yallist@3.1.1: {} - yallist@5.0.0: {} - yaml@2.9.0: {} yargs-parser@21.1.1: {} diff --git a/docs/decisions/0007-runtime-feature-flags.md b/docs/decisions/0007-runtime-feature-flags.md index 4e0bc065f7..bd8dc256e7 100644 --- a/docs/decisions/0007-runtime-feature-flags.md +++ b/docs/decisions/0007-runtime-feature-flags.md @@ -9,7 +9,7 @@ Kandev ships a single binary (npm/Homebrew/GitHub release) plus its Next.js web app from one repo. Two related problems were driving scope every time we cut a release: 1. **In-progress features leak into production.** Large multi-PR work (Office, ADR-0004, ADR-0005) stays in `main` for weeks before it is user-ready. Releasing in the middle exposes half-finished surfaces to users. -2. **Dev / e2e configuration is scattered.** `KANDEV_MOCK_AGENT`, `KANDEV_DEBUG_DEV_MODE`, `KANDEV_FEATURES_OFFICE`, `AGENTCTL_AUTO_APPROVE_PERMISSIONS`, `KANDEV_PLAN_COALESCE_WINDOW_MS`, etc. live as hardcoded literals in `apps/cli/src/dev.ts` and `apps/web/e2e/fixtures/backend.ts`. To answer "what does e2e mode actually set?" a contributor has to grep both files. Knobs drift between dev and e2e for no reason other than "nobody updated both places." +2. **Dev / e2e configuration is scattered.** `KANDEV_MOCK_AGENT`, `KANDEV_DEBUG_DEV_MODE`, `KANDEV_FEATURES_OFFICE`, `AGENTCTL_AUTO_APPROVE_PERMISSIONS`, `KANDEV_PLAN_COALESCE_WINDOW_MS`, etc. live as hardcoded literals in the Go dev launcher (`apps/backend/internal/launcher/dev.go`) and `apps/web/e2e/fixtures/backend.ts`. To answer "what does e2e mode actually set?" a contributor has to grep both files. Knobs drift between dev and e2e for no reason other than "nobody updated both places." We needed: @@ -57,7 +57,7 @@ Top-level sections are purely for human readability (the loader walks every leaf | signal | profile | who sets it | |---|---|---| | `KANDEV_E2E_MOCK=true` | `e2e` | `apps/web/e2e/fixtures/backend.ts` | -| `KANDEV_DEBUG_DEV_MODE=true` | `dev` | `apps/cli/src/dev.ts`, the `make dev` backend target | +| `KANDEV_DEBUG_DEV_MODE=true` | `dev` | `apps/backend/internal/launcher/dev.go` | | (neither) | `prod` | the default for any other launch | `KANDEV_DEBUG_PPROF_ENABLED` is a legacy debug behavior variable. It enables @@ -95,7 +95,7 @@ A self-hoster setting `KANDEV_FEATURES_OFFICE=true` in their k8s manifest beats ### Launchers -- `apps/cli/src/dev.ts` sets only `KANDEV_DEBUG_DEV_MODE=true` — the *selector*. profiles.yaml's `dev:` column supplies the rest. +- `apps/backend/internal/launcher/dev.go` sets only `KANDEV_DEBUG_DEV_MODE=true` — the *selector*. profiles.yaml's `dev:` column supplies the rest. - `apps/web/e2e/fixtures/backend.ts` sets only `KANDEV_E2E_MOCK=true` — the selector for e2e. profiles.yaml's `e2e:` column supplies the rest. Per-host paths (DB, worktree base, Docker binaries) stay in the fixture because they're runtime-computed, not environmental. ## Consequences @@ -175,5 +175,5 @@ Same pattern — pick the section that matches the knob's purpose (`mocks`, `deb - `apps/web/lib/state/slices/features/` — Zustand slice - `apps/web/hooks/domains/features/use-feature.ts` — client hook - `apps/web/app/office/layout.tsx` — page-level `notFound()` gating -- `apps/cli/src/dev.ts` — sets only the `KANDEV_DEBUG_DEV_MODE` selector +- `apps/backend/internal/launcher/dev.go` — sets only the `KANDEV_DEBUG_DEV_MODE` selector - `apps/web/e2e/fixtures/backend.ts` — sets only the `KANDEV_E2E_MOCK` selector + per-host paths diff --git a/docs/decisions/2026-08-08-go-launcher-owns-all-launch-modes.md b/docs/decisions/2026-08-08-go-launcher-owns-all-launch-modes.md new file mode 100644 index 0000000000..f23ef7f1fe --- /dev/null +++ b/docs/decisions/2026-08-08-go-launcher-owns-all-launch-modes.md @@ -0,0 +1,53 @@ +# ADR-2026-08-08-go-launcher-owns-all-launch-modes: The Go launcher owns every entrypoint; apps/cli is a publish-only shim + +**Status:** accepted +**Date:** 2026-08-08 +**Area:** backend, cli, infra + +## Context + +The TypeScript launcher under `apps/cli/src/**` shrank target by target as the Go launcher +(`apps/backend/internal/launcher/`) took over: `service` first, then `start`/`run`, and finally +`dev` — the last consumer of the entire `src/` tree. Every launcher behavior fix had been landing +twice (PR #2394 hardened port ownership in both TypeScript and Go in one PR), and `make dev`'s +`bash → make → pnpm → node → make → sh → kandev` chain forced the winjob Job-Object wrapper on +Windows. With `dev` moved, `apps/cli/src/**` was unpublished dead code (the npm package already +shipped only `bin/cli.js` + `bin/native-shim.js`) and was deleted in full. This change completes +the boundary that the earlier migrations were building toward. + +## Decision + +All four launch modes (`dev`, `start`, `run`, `service`) are owned by the native Go binary at +`apps/backend/bin/kandev`; new launcher features and behavior changes are implemented in +`apps/backend/internal/launcher/` (or `internal/launcher/cli/` for CLI-surface text), never in +Node. `apps/cli` is a publish-only npm shim: its `files` are exactly the two shim modules plus +package metadata, it has no TypeScript source and no devDependencies, and its test runs on the +Node built-in test runner (`node --test`). `make dev` execs a copy of the freshly built binary +(`apps/backend/bin/kandev-launcher`, git-ignored) because the supervised child `make -C +apps/backend dev` rebuilds `bin/kandev` underneath the running launcher. Node remains required for +exactly three things: the Vite dev server (the web build), the published npm shim, and repo +tooling (prettier, commitlint, Playwright). + +## Consequences + +- Launcher behavior exists once; port preflight, health tokens, Ctrl-C handling, and the restart + supervisor no longer have parallel implementations. +- Windows `make dev` no longer needs winjob — the launcher's `CREATE_NEW_PROCESS_GROUP` + + `taskkill /F /T` handling covers the dev path, though real Windows Ctrl-C still needs manual + verification because CI does not run a full `make dev`. +- The npm `kandev` package keeps its published surface byte-identical (`name`, `version`, `bin`, + `files`, `main`, `engines`, `optionalDependencies`), so the release flow is untouched. +- Backend restarts from the UI rebuild from source in dev because the child command stays + `make -C apps/backend dev`; `bin/kandev-launcher` is a build artifact, not a second shipped + binary. + +## Alternatives Considered + +- **Re-exec `self __backend` for dev** (what `start` does): simpler, but silently drops + rebuild-on-restart, which is the point of the restart button in dev. Rejected. +- **Exec `bin/kandev` directly in `make dev`**: the child `make` rebuilds that file underneath + the running launcher; the running process survives on Unix (go build unlinks first) but the + rebuild fails outright on Windows. The one-`cp` indirection removes the hazard on every + platform. Rejected. +- **Keeping `apps/cli/src` as the dev rollback**: it was the safety net during the cutover but + became pure dead weight once `make dev` was proven on the Go path. Deleted in the same change. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 18bfdeaa60..317c3ce754 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -140,6 +140,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-08-workspace-scoped-task-create-workflow-memory | [Remember Task-Create Workflows Per Workspace](2026-08-08-workspace-scoped-task-create-workflow-memory.md) | accepted | backend, frontend | 2026-08-08 | | 2026-08-08-owned-temp-artifact-cleanup | [Clean only registered Kandev temporary artifacts](2026-08-08-owned-temp-artifact-cleanup.md) | accepted | backend, frontend, infra, security | 2026-08-08 | | 2026-08-08-task-owned-worktree-lifetime | [Keep Worktree Ownership at the Task Lifecycle](2026-08-08-task-owned-worktree-lifetime.md) | accepted | backend, frontend | 2026-08-08 | +| 2026-08-08-go-launcher-owns-all-launch-modes | [The Go Launcher Owns Every Entrypoint; apps/cli Is a Publish-Only Shim](2026-08-08-go-launcher-owns-all-launch-modes.md) | accepted | backend, cli, infra | 2026-08-08 | | 2026-08-09-bind-automation-mutations-to-event-targets | [Bind Automation Mutations to Event Targets](2026-08-09-bind-automation-mutations-to-event-targets.md) | accepted | backend, agentctl, protocol, security, workflow | 2026-08-09 | | 2026-08-09-exclusive-runtime-state-ownership | [Lock Runtime State Before Backend Startup](2026-08-09-exclusive-runtime-state-ownership.md) | accepted | backend, infra | 2026-08-09 | | 2026-08-10-debug-launcher-profile-selection | [Keep Debug Launches on the Production Profile](2026-08-10-debug-launcher-profile-selection.md) | accepted | backend, frontend, cli | 2026-08-10 | diff --git a/docs/plans/go-dev-launcher/plan.md b/docs/plans/go-dev-launcher/plan.md new file mode 100644 index 0000000000..97a5c17ab5 --- /dev/null +++ b/docs/plans/go-dev-launcher/plan.md @@ -0,0 +1,153 @@ +--- +status: implemented +created: 2026-08-07 +spec: "../../specs/go-dev-launcher/spec.md" +--- + +# Plan: Go dev launcher and minimal Node surface + +## Current state + +`apps/backend/internal/launcher` already implements everything `dev` needs except the +dev-specific pieces. Reusable as-is: + +| Concern | Location | +| --- | --- | +| Arg parsing, `ParseError`, port flags | `options.go` | +| Port selection, explicit-port preflight, `canBind` | `ports.go` | +| Health probe with launcher token, foreign-process detection | `health.go` | +| Process supervision, groups, Ctrl-C, force-kill, ring-buffered output | `process.go`, `process_group_{unix,windows}.go` | +| Restart control socket + launch manifest | `supervisor.go` | +| Backend env assembly | `env.go` | +| Startup banner, browser open, `waitForAppExit` | `start.go` | + +Two details matter for the design: + +- `supervisor.go:169` `allowedSupervisorEnv` already allowlists + `KANDEV_WEB_INTERNAL_URL` and `KANDEV_DEBUG_DEV_MODE`. No change needed there. +- `supervisor.go:41` `launchRestartableBackend` calls `resolveHomeDir()` internally, + reading the launcher's own environment. Dev needs a repo-local home, so this must + become an explicit parameter (task 05). + +Gaps, all dev-only: + +1. No `dev` command, no `--web-internal-port`, no `WebPort` in `portConfig`. +2. No repo-root discovery, `.kandev-dev` home, or task-workspace detection. +3. No production-DB backup. +4. No Vite child launch or token-less URL readiness probe. + +## Approach + +Port `dev` into the Go launcher in three waves: pure helpers first (independently +testable, no ordering constraints between them), then command wiring and orchestration, +then the Makefile cutover and the TypeScript deletion. + +The deletion is deliberately last and in its own task. Until `make dev` is proven on the +Go path, `apps/cli/src/**` is the rollback. + +### Design decisions + +**Backend child stays `make -C apps/backend dev`.** Re-execing `self __backend` (what +`start` does) would be simpler but would silently drop rebuild-on-restart, which is the +point of the restart button in dev. Keeping `make` preserves it. + +**`make dev` execs a copy of the binary.** Because launcher and backend are one binary, +the child `make` rebuilds `bin/kandev` underneath the running launcher. On Linux/macOS +`go build` unlinks before writing so the running process survives; on Windows the delete +fails and the rebuild errors. Exec'ing `bin/kandev-launcher` (a copy made after `build`) +removes the hazard on every platform for one `cp`. + +**Vite readiness is a separate probe.** `waitForHealth` requires the launcher health +token by design (PR #2394 made a token mismatch a hard failure). Vite emits no such +token, so it needs its own probe that accepts any HTTP response. Do not weaken +`waitForHealth` to accommodate it. + +## Dependency order + +```text +wave 1 task-01 dev paths task-02 db backup task-03 web child + │ │ │ +wave 2 └────────┬──────────────┴──────────────────────┘ + ├── task-04 dev command wiring + └── task-05 runDev orchestration (depends on 01–04) + │ +wave 3 ┌─────────────────────┼─────────────────────┐ + task-06 Makefile task-07 delete TS task-08 docs + (depends on 06) +``` + +Wave 1 tasks touch disjoint new files and are parallel-safe. Wave 3's task-07 must follow +task-06, because deleting `apps/cli/src` before the Makefile switches would break +`make dev` outright. + +## Tasks + +| ID | Title | Wave | Status | +| --- | --- | --- | --- | +| 01 | Dev paths, repo root, and dev backend env | 1 | done | +| 02 | Production database backup in Go | 1 | done | +| 03 | Vite dev-server child and URL readiness | 1 | done | +| 04 | `dev` command, web port, and dev env wiring | 2 | done | +| 05 | `runDev` orchestration | 2 | done | +| 06 | Makefile cutover to the Go dev launcher | 3 | done | +| 07 | Delete `apps/cli/src` and drop CLI devDependencies | 3 | done | +| 08 | Documentation and guidance updates | 3 | done | + +## Likely files + +New under `apps/backend/internal/launcher/`: +`devpaths.go`, `devpaths_test.go`, `backup.go`, `backup_test.go`, `webchild.go`, +`webchild_test.go`, `dev.go`, `dev_test.go`. + +Modified: `options.go`, `options_test.go`, `ports.go`, `ports_test.go`, `env.go`, +`env_test.go`, `supervisor.go`, `supervisor_test.go`, `launcher.go`, `cli/help.go`, +`constants.go`. + +Root: `Makefile`, `apps/backend/Makefile`, `apps/cli/package.json`, +`apps/cli/bin/native-shim.test.mjs`, `apps/package.json`, +`.github/workflows/frontend-tests.yml`, `CLAUDE.md`, +`docs/remote-cloud-environment.md`. + +Deleted: `apps/cli/src/**`, `apps/cli/tsconfig.json`, `apps/cli/vitest.config.ts`. + +## Risks + +- **Windows dev has no CI coverage.** `make test-windows` runs a unit subset, not a real + `make dev`. The winjob removal and the `cmd.exe /c pnpm` shim need manual verification + on Windows before merge, or the winjob line in `make dev` should stay until someone + can test. Call this out in the PR. +- **`pnpm -r exec tsc` breaks** once `apps/cli/tsconfig.json` is gone (Makefile:650). + Task 07 must convert `make typecheck` to explicit `--filter` arguments. +- **`knip` dead-code detection** may flag the trimmed `apps/cli` differently. Run + `pnpm run dead-code` as part of task 07. +- **Release flow reads `apps/cli/package.json`** in three places + (`release.yml:224,251,381`, `publish-npm.sh:134,259`). Task 07 must not touch `name`, + `version`, `bin`, `files`, or `optionalDependencies`. +- **`.kandev-dev` vs. inherited `KANDEV_HOME_DIR`.** The launcher's own + `resolveHomeDir()` reads the ambient env; inside a Kandev task workspace that env is + the parent backend's. Task 05's explicit-homeDir refactor is what prevents the dev + supervisor directory from landing in the parent's home. + +## Verification + +Per-task commands are in the task files. Before opening the PR: + +```bash +make -C apps/backend test +make -C apps/backend lint +cd apps && pnpm --filter kandev test +``` + +Manual, on the branch, since no automated test covers the real launch: + +```bash +make dev # backend + Vite come up, browser opens, Ctrl-C leaves nothing +make dev PORT=38500 WEB_PORT=37500 +make dev-prod-db # verify a dev-prod-db-*.db lands in ~/.kandev/data/backups/ +``` + +Confirm no stray processes after Ctrl-C: + +```bash +pgrep -af 'kandev|vite' || echo clean +``` diff --git a/docs/plans/go-dev-launcher/task-01-dev-paths.md b/docs/plans/go-dev-launcher/task-01-dev-paths.md new file mode 100644 index 0000000000..1dae043bc2 --- /dev/null +++ b/docs/plans/go-dev-launcher/task-01-dev-paths.md @@ -0,0 +1,64 @@ +--- +id: "01-dev-paths" +title: "Dev paths, repo root, and dev backend env" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/go-dev-launcher/spec.md" +--- + +# Task 01: Dev paths, repo root, and dev backend env + +Port the pure path/env logic from `apps/cli/src/{constants,kandev-env,dev}.ts` into Go. +No process spawning in this task. + +## Acceptance + +- `findRepoRoot(startDir)` returns the nearest ancestor containing both `apps/backend` + and `apps/web`, also handling a start directory that *is* `.../apps`; it returns an + error (not a fallback path) when no such ancestor exists. +- `resolveDevBackendEnv(repoRoot)` reproduces the three-way rule in + `apps/cli/src/dev.ts:125-167`: inside a task workspace it sets + `KANDEV_HOME_DIR=/.kandev-dev` and clears `KANDEV_DATABASE_PATH`; outside + one an explicit `KANDEV_DATABASE_PATH` wins; otherwise it uses the `.kandev-dev` + default. `KANDEV_DEBUG_DEV_MODE=true` is set in all three branches, and the reported + display DB path matches what the backend will derive. +- `isInsideKandevTask(repoRoot)` is true when `KANDEV_TASK_ID` is set, or when + `repoRoot` is under `/.kandev/tasks/`. + +## Verification + +Use TDD. Add `apps/backend/internal/launcher/devpaths_test.go` covering each branch with +`t.TempDir()`-rooted fake trees (never the real filesystem root — see +`apps/backend/CLAUDE.md`, "Path/security tests"), then: + +~~~bash +cd apps/backend && go test ./internal/launcher/ -run 'TestFindRepoRoot|TestResolveDevBackendEnv|TestIsInsideKandevTask' -race +~~~ + +## Files + +- `apps/backend/internal/launcher/devpaths.go` (new) +- `apps/backend/internal/launcher/devpaths_test.go` (new) +- `apps/backend/internal/launcher/constants.go` — add `devKandevDotdir = ".kandev-dev"`, + `devKandevHome(repoRoot)`, `kandevTasksDir()`, and `healthTimeoutDevMS = 600000` + alongside the existing `healthTimeoutReleaseMS`. + +## Inputs + +- `apps/cli/src/dev.ts:112-167` — `resolveDevBackendEnv`, including the comments + explaining why dev always roots under `.kandev-dev` and why a task-workspace + `KANDEV_DATABASE_PATH` is treated as leaked. Carry that reasoning across. +- `apps/cli/src/kandev-env.ts` — note its caveat that the path-prefix check is a + defensive secondary signal, is case-sensitive, and does not resolve symlinks; + `KANDEV_TASK_ID` remains the primary guarantee. +- `apps/cli/src/constants.ts:47-56` — `DEV_KANDEV_DOTDIR`, `devKandevHome`. +- Existing `apps/backend/internal/launcher/constants.go` for the resolve-* naming style. + +## Risks + +- Do not change `resolveHomeDir` / `resolveDataDir` / `resolveDatabasePath`; `start`, + `run`, and `service` all depend on their current env-reading behavior. +- `os.UserHomeDir()` can fail; mirror the existing `.kandev` relative fallback rather + than panicking. diff --git a/docs/plans/go-dev-launcher/task-02-db-backup.md b/docs/plans/go-dev-launcher/task-02-db-backup.md new file mode 100644 index 0000000000..5f81232150 --- /dev/null +++ b/docs/plans/go-dev-launcher/task-02-db-backup.md @@ -0,0 +1,55 @@ +--- +id: "02-db-backup" +title: "Production database backup in Go" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/go-dev-launcher/spec.md" +--- + +# Task 02: Production database backup in Go + +Port `apps/cli/src/backup.ts` so `make dev-prod-db` stays safe on the Go path. + +## Acceptance + +- `isProductionDb(path)` is false only when the normalized path contains a + `.kandev-dev` segment; everything else is treated as production. +- `backupProductionDb(dbPath, homeDir, now)` copies the DB to + `/.kandev/data/backups/dev-prod-db-.db`, stamps + the file's mtime to `now` so pruning is deterministic, and returns the created path; + it returns an empty path and no error when the source DB does not exist. +- Pruning keeps the five newest `dev-prod-db-*.db` files by mtime and never touches + files outside that prefix/suffix pair (notably the backend's own `kandev-*` and + `manual-*` snapshot families). + +## Verification + +Use TDD. Add `apps/backend/internal/launcher/backup_test.go` with an injectable +`homeDir` and `now` so back-to-back calls produce distinct names without sleeping, then: + +~~~bash +cd apps/backend && go test ./internal/launcher/ -run 'TestIsProductionDb|TestBackupProductionDb|TestPruneBackups' -race +~~~ + +## Files + +- `apps/backend/internal/launcher/backup.go` (new) +- `apps/backend/internal/launcher/backup_test.go` (new) + +## Inputs + +- `apps/cli/src/backup.ts` — the reference implementation, including the comment + explaining why backups always land under `~/.kandev/data/backups/` even when `dbPath` + points elsewhere (custom `KANDEV_DATABASE_PATH` is advanced usage where the user owns + the backup location). +- `apps/cli/src/backup.test.ts` — existing cases worth mirroring. + +## Risks + +- Failing to remove one old backup must not fail the launch; failing to *create* the + backup must. Keep the two error paths distinct. +- A plain file copy of a live SQLite DB is what the TypeScript version does and what + this task reproduces; do not silently upgrade it to `VACUUM INTO` here — that is the + backend's boot-time backup path and a different guarantee. diff --git a/docs/plans/go-dev-launcher/task-03-web-child.md b/docs/plans/go-dev-launcher/task-03-web-child.md new file mode 100644 index 0000000000..f0ef0c36ad --- /dev/null +++ b/docs/plans/go-dev-launcher/task-03-web-child.md @@ -0,0 +1,60 @@ +--- +id: "03-web-child" +title: "Vite dev-server child and URL readiness" +status: done +wave: 1 +depends_on: ["04-dev-command"] +plan: "plan.md" +spec: "../../specs/go-dev-launcher/spec.md" +--- + +# Task 03: Vite dev-server child and URL readiness + +Port `apps/cli/src/web.ts`'s launch path plus the token-less readiness probe. + +## Acceptance + +- `webCommand()` returns `pnpm` with args `-C apps --filter @kandev/web dev` on Unix and + `cmd.exe /c pnpm ...` on Windows, and the Windows branch is exercised by a test that + does not require running on Windows. +- `buildWebEnv(ports, debug)` sets `KANDEV_API_BASE_URL` to the backend URL, `PORT` to + the chosen web port, `HOSTNAME=127.0.0.1`, and (when debug) `KANDEV_DEBUG=true` plus + `VITE_KANDEV_DEBUG=true`; it removes any inherited `VITE_KANDEV_API_PORT` from the + child environment. +- `waitForURLReady(ctx, url, proc, timeout)` succeeds on any HTTP response, fails fast + with the child's exit code if the child dies first, and times out with a message + naming the URL and timeout. It does not consult the launcher health token. + +## Verification + +Use TDD. Add `apps/backend/internal/launcher/webchild_test.go`; drive the readiness probe +against an `httptest.Server` and a fake `childState`, and prefer `testing/synctest` over +`time.Sleep` for the timeout case (see `apps/backend/CLAUDE.md`, "Testing"). Then: + +~~~bash +cd apps/backend && go test ./internal/launcher/ -run 'TestWebCommand|TestBuildWebEnv|TestWaitForURLReady' -race +~~~ + +## Files + +- `apps/backend/internal/launcher/webchild.go` (new) +- `apps/backend/internal/launcher/webchild_test.go` (new) + +## Inputs + +- `apps/cli/src/web.ts:1-24` — `WIN_SHIM_COMMANDS` / `resolveWindowsShim` and the comment + explaining why `cmd.exe /c` is the right call on Windows (no PATHEXT for `spawn`, + EINVAL on direct `.cmd` spawn since Node 21, DEP0190 with `shell:true`). The Node + specifics no longer apply, but the `.cmd`-shim resolution problem does. +- `apps/cli/src/shared.ts:136-165` — `buildWebEnv`, including why + `VITE_KANDEV_API_PORT` is explicitly deleted. +- `apps/cli/src/health.ts` — `waitForUrlReady` semantics. +- `apps/backend/internal/launcher/health.go` — reuse `childState`, `healthPollInterval`, + and the bounded `healthProbeClient` pattern rather than introducing new ones. + +## Risks + +- Reuse `startProcess` from `process.go` for spawning so the Vite child joins the same + supervisor and process group; do not call `exec.Command` directly. +- Do not relax `waitForHealth`'s token check to share code with this probe. PR #2394 + made a token mismatch a hard failure on purpose. diff --git a/docs/plans/go-dev-launcher/task-04-dev-command.md b/docs/plans/go-dev-launcher/task-04-dev-command.md new file mode 100644 index 0000000000..c121cfac99 --- /dev/null +++ b/docs/plans/go-dev-launcher/task-04-dev-command.md @@ -0,0 +1,67 @@ +--- +id: "04-dev-command" +title: "dev command, web port, and dev env wiring" +status: done +wave: 2 +depends_on: ["01-dev-paths"] +plan: "plan.md" +spec: "../../specs/go-dev-launcher/spec.md" +--- + +# Task 04: `dev` command, web port, and dev env wiring + +Teach the launcher's arg parser, port picker, and env builder about dev mode. + +## Acceptance + +- `parseArgs` accepts `dev` and `--dev` (→ `CommandDev`), `--web-internal-port ` and + `--web-internal-port=`, and rejects `--web-port` with the existing message + "`--web-port` has been removed; use `--web-internal-port` for dev mode" and + `--web-internal-port` outside dev mode with "only applies to dev mode". Port values + keep the 1–65535 validation and the existing `ParseError` exit code 2. +- `pickDevPorts` resolves the web port from the flag then `KANDEV_WEB_PORT`, defaults to + `37429`, and guarantees backend, web, and agentctl ports are mutually distinct. An + explicitly requested but occupied web port is a hard error naming the port and whether + it came from the flag or the env var, matching the backend-port preflight from + PR #2394. +- `backendEnv` sets `KANDEV_WEB_INTERNAL_URL=http://localhost:` when a web port + is present and `KANDEV_DEBUG_DEV_MODE=true` in dev mode, and leaves `start`/`run` + output byte-identical to today when it is not. +- `cli.Help()` documents `dev` and `--web-internal-port`. + +## Verification + +Use TDD across the existing suites, then: + +~~~bash +cd apps/backend && go test ./internal/launcher/ -race +~~~ + +## Files + +- `apps/backend/internal/launcher/options.go`, `options_test.go` +- `apps/backend/internal/launcher/ports.go`, `ports_test.go` +- `apps/backend/internal/launcher/env.go`, `env_test.go` +- `apps/backend/internal/launcher/cli/help.go` +- `apps/backend/internal/launcher/launcher.go` — dispatch `CommandDev` to `runDev` + (a stub returning "not implemented" is acceptable here; task 05 fills it in). + +## Inputs + +- `apps/cli/src/args.ts:22-138` — the exact flag surface and error strings to preserve. +- `apps/cli/src/cli.ts:13-58` — the help text wording for `dev` and + `--web-internal-port`. +- `apps/backend/internal/launcher/ports.go:65-91` — `pickPorts` and the `used` map; + extend rather than fork it. +- `apps/backend/internal/launcher/supervisor.go:169-190` — `allowedSupervisorEnv` + already allowlists `KANDEV_WEB_INTERNAL_URL` and `KANDEV_DEBUG_DEV_MODE`; confirm, do + not duplicate. + +## Risks + +- `portConfig.WebPort` must be optional. `start` and `run` have no web port and + `backendEnv` must not emit an empty `KANDEV_WEB_INTERNAL_URL` for them — the + TypeScript version explicitly deletes the key in that case + (`apps/cli/src/shared.ts:85-87`). +- Watch the Go complexity limits in `apps/backend/.golangci.yml`; `parseArgAt` is + already a dispatch chain, so add a `parseWebPortArg` helper rather than growing it. diff --git a/docs/plans/go-dev-launcher/task-05-run-dev.md b/docs/plans/go-dev-launcher/task-05-run-dev.md new file mode 100644 index 0000000000..f3bc6eb0d0 --- /dev/null +++ b/docs/plans/go-dev-launcher/task-05-run-dev.md @@ -0,0 +1,71 @@ +--- +id: "05-run-dev" +title: "runDev orchestration" +status: done +wave: 2 +depends_on: ["01-dev-paths", "02-db-backup", "03-web-child", "04-dev-command"] +plan: "plan.md" +spec: "../../specs/go-dev-launcher/spec.md" +--- + +# Task 05: `runDev` orchestration + +Assemble tasks 01–04 into the working `kandev dev` command. + +## Acceptance + +- `runDev` performs, in order: repo-root discovery → dev port selection → dev backend env + resolution → production-DB backup (abort on failure) → startup banner → supervisor and + signal handlers → backend launch → health wait (600s dev timeout, dumping captured + backend output on failure) → Vite launch → URL readiness wait → browser open → + `waitForAppExit`. Every failure path shuts the supervisor down before returning. +- The backend child is `make -C apps/backend dev` with CWD at the repo root, launched + through `launchRestartableBackend` so the restart control socket and launch manifest + behave exactly as they do in `start`. +- `launchRestartableBackend` takes the supervisor home directory as an explicit + parameter instead of calling `resolveHomeDir()` internally, so dev's supervisor state + lands under `/.kandev-dev/supervisor/` and not in the ambient + `KANDEV_HOME_DIR` (which, inside a Kandev task workspace, is the parent backend's). + `runStart` and `runInstalled` pass `resolveHomeDir()` and are behaviorally unchanged. +- `--headless` skips the browser open, as in `start`. + +## Verification + +Use TDD with the existing seam pattern in `start.go:12-21` (package-level function vars +swapped in tests) so `runDev` is exercised without spawning real processes. Then: + +~~~bash +cd apps/backend && go test ./internal/launcher/ -race && go build ./... && golangci-lint run ./internal/launcher/... --timeout=5m +~~~ + +## Files + +- `apps/backend/internal/launcher/dev.go` (new) +- `apps/backend/internal/launcher/dev_test.go` (new) +- `apps/backend/internal/launcher/supervisor.go`, `supervisor_test.go` — homeDir + parameter +- `apps/backend/internal/launcher/start.go`, `start_test.go` — updated call site +- `apps/backend/internal/launcher/run.go` — updated call site +- `apps/backend/internal/launcher/launcher.go` — real `CommandDev` dispatch + +## Inputs + +- `apps/cli/src/dev.ts:26-110` — the orchestration order and the exact log lines + (`[kandev] starting backend...`, `[kandev] backend ready at ...`, + `[kandev] starting web...`, `[kandev] open: ...`). Keep them; people grep for these. +- `apps/backend/internal/launcher/start.go:86-124` — `runManagedApp`, the closest + existing shape. Consider whether `runDev` can reuse it with a web-child hook rather + than duplicating the backend/health/exit sequence. +- `docs/decisions/0019-restart-supervisor.md` — the manifest and control-socket contract. + +## Risks + +- `launchRestartableBackend` already takes eight parameters; adding `homeDir` pushes it + past readability and the lint limits. Introduce a `backendLaunchConfig` struct in the + same change rather than appending an argument. +- The dev backend child is `make`, not the launcher binary, so `dumpLogs` captures + make's output too. Verify the failure dump is still readable when the Go build itself + fails — that is the most common dev startup failure and the output must make it + obvious. +- Do **not** re-exec `self __backend` for dev. That would drop rebuild-on-restart; see + the plan's design decisions. diff --git a/docs/plans/go-dev-launcher/task-06-makefile-cutover.md b/docs/plans/go-dev-launcher/task-06-makefile-cutover.md new file mode 100644 index 0000000000..13b932616e --- /dev/null +++ b/docs/plans/go-dev-launcher/task-06-makefile-cutover.md @@ -0,0 +1,62 @@ +--- +id: "06-makefile-cutover" +title: "Makefile cutover to the Go dev launcher" +status: done +wave: 3 +depends_on: ["05-run-dev"] +plan: "plan.md" +spec: "../../specs/go-dev-launcher/spec.md" +--- + +# Task 06: Makefile cutover to the Go dev launcher + +Point `make dev` at the native launcher and drop the Node and root winjob steps. + +## Acceptance + +- `make dev` prebuilds only `apps/backend/bin/kandev`, copies it to a distinct launcher + path, and `exec`s that copy with `dev` plus the existing `DEV_FLAGS`. The supervised + backend `dev` target builds the native agentctl and, on hosts other than Linux/amd64, + one Linux/amd64 helper. It no longer invokes `pnpm` or `tsx` directly. +- `make dev PORT=… WEB_PORT=…` still forwards `--port` / `--web-internal-port` + unchanged, and `make dev-prod-db` still exports `KANDEV_DATABASE_PATH` and delegates + to `make dev`. +- The copied launcher path is git-ignored and removed by `make clean`. +- `apps/backend/Makefile:156`'s comment no longer points at `apps/cli/src/dev.ts` for + winjob usage. + +## Verification + +~~~bash +make dev +~~~ + +Confirm by hand: backend and Vite both start, the browser opens on the backend port, the +banner shows the `.kandev-dev` DB path, and Ctrl-C leaves nothing behind +(`pgrep -af 'kandev|vite' || echo clean`). Then repeat with +`make dev PORT=38500 WEB_PORT=37500` and with `make dev-prod-db`, checking that a +`dev-prod-db-*.db` file appears in `~/.kandev/data/backups/`. + +## Files + +- `Makefile` — the `dev` target (currently lines 187-195) and `clean` +- `apps/backend/Makefile` — the lean dev build targets and `build-winjob` doc comment +- `.gitignore` — the copied launcher binary + +## Inputs + +- `Makefile:277` — `make start`'s `@exec $(BACKEND_DIR)/bin/kandev start …`, the shape + to mirror. +- `Makefile:60-62` — `DEV_PORT_FLAG`, `DEV_WEB_PORT_FLAG`, `DEV_FLAGS`, unchanged. +- `apps/backend/Makefile:30` — `EXE` is defined in the backend Makefile, not the root + one. The root `dev` target needs its own suffix handling for the `cp`, or should + delegate the copy to a backend target. + +## Risks + +- The copy exists because the child `make` rebuilds `bin/kandev` underneath the running + launcher. Do not "simplify" it away by exec'ing `bin/kandev` directly — that breaks + backend restart on Windows and is fragile everywhere. +- `build-winjob` leaving `make dev` is the one behavior change with no automated + coverage on Windows. Either verify a real Windows `make dev` Ctrl-C, or keep the + `build-winjob` line for one release and remove it in a follow-up; say which in the PR. diff --git a/docs/plans/go-dev-launcher/task-07-delete-cli-src.md b/docs/plans/go-dev-launcher/task-07-delete-cli-src.md new file mode 100644 index 0000000000..d1ace2aa0c --- /dev/null +++ b/docs/plans/go-dev-launcher/task-07-delete-cli-src.md @@ -0,0 +1,78 @@ +--- +id: "07-delete-cli-src" +title: "Delete apps/cli/src and drop CLI devDependencies" +status: done +wave: 3 +depends_on: ["06-makefile-cutover"] +plan: "plan.md" +spec: "../../specs/go-dev-launcher/spec.md" +--- + +# Task 07: Delete `apps/cli/src` and drop CLI devDependencies + +With `make dev` on the Go path, `apps/cli/src/**` has no consumer. Remove it and reduce +the package to what npm actually publishes. + +## Acceptance + +- `apps/cli/src/`, `apps/cli/tsconfig.json`, and `apps/cli/vitest.config.ts` are deleted. + What remains is `package.json` and `bin/{cli.js,native-shim.js,native-shim.test.mjs}`. +- `apps/cli/bin/native-shim.test.mjs` runs on the Node built-in test runner + (`node:test` + `node:assert`) instead of Vitest, with equivalent coverage; + `apps/cli/package.json` has no `devDependencies` and its `test` script is + `node --test "bin/*.test.mjs"` (implemented as such; a bare `node --test bin/` + fails on this Node version, and bare `node --test` picks up the file by + pattern). The `dev`, `bundle`, and `prepublishOnly` scripts are removed or + reduced to what still applies. +- `name`, `version`, `bin`, `files`, `main`, `license`, `engines`, and + `optionalDependencies` in `apps/cli/package.json` are byte-identical to before, so + `scripts/release/publish-npm.sh:134,259` and `.github/workflows/release.yml:224,251,381` + keep working. +- `make test-cli`, `make test`, and `make test-windows` pass; `make typecheck` passes + after being converted from `pnpm -r exec tsc` to explicit `--filter` arguments + (`Makefile:650`), since `apps/cli` no longer has a `tsconfig.json`. +- `cd apps && pnpm install --frozen-lockfile` succeeds with a regenerated + `apps/pnpm-lock.yaml`, and `pnpm run dead-code` reports nothing new. + +## Verification + +~~~bash +cd apps && pnpm install && pnpm --filter kandev test && pnpm run format:check && pnpm run dead-code +cd .. +make typecheck && make test-cli +~~~ + +Then confirm the published surface is unchanged: + +~~~bash +cd apps/cli && npm pack --dry-run +~~~ + +The file list must be exactly `bin/cli.js`, `bin/native-shim.js`, `package.json`. + +## Files + +- Deleted: `apps/cli/src/**` (all 20 modules, their tests, `service/`, `supervisor/`), + `apps/cli/tsconfig.json`, `apps/cli/vitest.config.ts` +- Modified: `apps/cli/package.json`, `apps/cli/bin/native-shim.test.mjs`, + `apps/pnpm-lock.yaml`, `Makefile` (`typecheck`) +- Check but likely unchanged: `apps/package.json` (`format` globs still cover `cli`), + `apps/pnpm-workspace.yaml` (keep `cli` in the workspace), + `.github/workflows/frontend-tests.yml` (the `apps/cli/**` path trigger stays valid) + +## Inputs + +- `apps/cli/package.json` — current `files`/`bin`/`optionalDependencies`. +- `apps/cli/bin/native-shim.test.mjs` — the Vitest-based tests to port. +- `scripts/release/publish-npm.sh` and `scripts/release/publish-npm.test.mjs` — what the + release flow reads from this package. + +## Risks + +- Deleting `tsconfig.json` breaks `pnpm -r exec tsc` (Makefile:650) for the whole + workspace, not just `apps/cli`. Convert that target in the same change or the + typecheck job fails. +- `esbuild`, `tar`, `tree-kill`, and `tsx` disappear from the lockfile. Re-run the full + frontend test job locally, not just the CLI one, before pushing. +- Do not remove `cli` from `apps/pnpm-workspace.yaml`; prettier's `format` script and + the shim test both still expect it to be a workspace member. diff --git a/docs/plans/go-dev-launcher/task-08-docs.md b/docs/plans/go-dev-launcher/task-08-docs.md new file mode 100644 index 0000000000..d11fd2c44e --- /dev/null +++ b/docs/plans/go-dev-launcher/task-08-docs.md @@ -0,0 +1,56 @@ +--- +id: "08-docs" +title: "Documentation and guidance updates" +status: done +wave: 3 +depends_on: ["06-makefile-cutover"] +plan: "plan.md" +spec: "../../specs/go-dev-launcher/spec.md" +--- + +# Task 08: Documentation and guidance updates + +## Acceptance + +- No doc, skill, or agent-guidance file tells a reader that `make dev` runs through + `apps/cli/src/dev.ts` or the TypeScript launcher. +- The root `CLAUDE.md` "Tooling" section states that all four launch modes (`dev`, + `start`, `run`, `service`) go through the Go binary, and that `apps/cli` is the npm + publishing shim only. +- `docs/remote-cloud-environment.md` reflects that `make dev` needs a Go toolchain plus + pnpm-for-Vite, and no longer needs `tsx`. +- `docs/specs/INDEX.md` lists the new spec. + +## Verification + +~~~bash +grep -rn -E "apps/cli/src|cli/src/dev.ts|pnpm -C cli|tsx src/cli.ts" --include='*.md' --include='Makefile' --include='*.yml' . | grep -v docs/plans/ +~~~ + +Returns nothing outside historical plan records under `docs/plans/`, which are +implementation records and are intentionally not rewritten. + +## Files + +- `CLAUDE.md` (root) — "Tooling" section +- `docs/remote-cloud-environment.md` +- `docs/specs/INDEX.md` +- `docs/public/**` — audit for launcher/dev-setup references via `/docs-maintainer` +- Any `.agents/skills/**` or `AGENTS.md` hit from the grep above + +## Inputs + +- `CLAUDE.md` "Tooling" and "Maintaining This File" sections — the latter requires this + update in the same PR as the code change. +- `apps/backend/CLAUDE.md` — check whether the launcher package warrants a mention now + that it owns every entrypoint. + +## Risks + +- `docs/plans/{diagnostic-logging,port-collision-safety,feature-toggles-restart-action}/` + reference `apps/cli/src/*` paths that will no longer exist. These are records of + completed work, not live guidance — leave them alone and say so in the PR rather than + rewriting history. +- Consider whether "the Go launcher owns every entrypoint; `apps/cli` is a publishing + shim" is a durable enough boundary to warrant an ADR via `/record`. It arguably is, + since it constrains where future launcher features may be implemented. diff --git a/docs/public/architecture.md b/docs/public/architecture.md index fa39d76e78..99d13eb92d 100644 --- a/docs/public/architecture.md +++ b/docs/public/architecture.md @@ -31,9 +31,8 @@ flowchart LR These adjacent launch layers have different jobs: -- `apps/cli/src/` supplies the TypeScript development supervisor used by `make dev`. +- `apps/backend/internal/launcher/` implements all four launch modes — `dev` (supervised `make -C apps/backend dev` backend + Vite dev-server child, state under `/.kandev-dev/`), installed `run`, `start`, and service behavior. - The published `apps/cli/bin/` npm shim selects the matching `@kdlbs/runtime-*` package and starts its native `kandev` binary. -- `apps/backend/internal/launcher/` implements installed `run`, `start`, and service behavior. - `apps/desktop/` is a Tauri shell. Rust starts its bundled `kandev --headless` on an owned loopback origin and owns native windows, external links, notifications, and updates. The product UI is still the backend-served SPA. The root build embeds generated web assets in the Go binary. `make dev` instead starts Vite and configures Go to proxy it, so HTTP, API, and WebSocket traffic still enters through Go. diff --git a/docs/public/cli.md b/docs/public/cli.md index 42148ecaa1..0b3e5321ed 100644 --- a/docs/public/cli.md +++ b/docs/public/cli.md @@ -166,23 +166,24 @@ These commands and options describe the installed native launcher. Unknown argum ### `dev` and the internal web port are source-checkout options -The repository's TypeScript launcher supports hot-reload development with this logical CLI syntax: +The native launcher supports hot-reload development with this CLI syntax: ```text kandev dev [--port ] [--web-internal-port ] kandev --dev [--port ] [--web-internal-port ] ``` -Run its normal setup path from the repository root with `make dev`. To pass the internal-port override directly, invoke the same package script used by that Make target: +Run its normal setup path from the repository root with `make dev`. To pass the internal-port override directly, invoke the launcher binary: ```bash -cd apps -pnpm -C cli dev -- dev --web-internal-port 37430 +make dev WEB_PORT=37430 +# or, after `make -C apps/backend build`: +apps/backend/bin/kandev dev --web-internal-port 37430 ``` -`--web-internal-port` accepts an integer from `1` through `65535`, including the `--web-internal-port=` form. It controls the Vite development server that the Go backend reverse-proxies to; `--port` continues to control the backend URL. The flag is valid only with `dev` or `--dev`. `KANDEV_WEB_PORT` is its environment equivalent in dev mode and is ignored by `run` and `start`. Without either override, the source launcher prefers web port `37429` and selects a fallback if that port is unavailable. +`--web-internal-port` accepts an integer from `1` through `65535`, including the `--web-internal-port=` form. It controls the Vite development server that the Go backend reverse-proxies to; `--port` continues to control the backend URL. The flag is valid only with `dev` or `--dev`. `KANDEV_WEB_PORT` is its environment equivalent in dev mode and is ignored by `run` and `start`. Without either override, the launcher prefers web port `37429` and selects a fallback if that port is unavailable. -The old `--web-port` spelling has been removed, not retained as an alias. The TypeScript launcher rejects both `--web-port ` and `--web-port=` and directs callers to `--web-internal-port`; the native release launcher rejects both web-port spellings because it serves embedded web assets and has no separate web process. +The old `--web-port` spelling has been removed, not retained as an alias. The launcher rejects both `--web-port ` and `--web-port=` and directs callers to `--web-internal-port`; outside dev mode the release launcher rejects both web-port spellings because it serves embedded web assets and has no separate web process. ### `start` is for a source build @@ -199,7 +200,7 @@ Development hot reload is also checkout-only: make dev ``` -`make dev` builds the remote `agentctl` helpers and invokes the repository's TypeScript development launcher. The `kandev dev` syntax above belongs to that source launcher; installing the npm or Homebrew release does not install it as a second runtime mode. +`make dev` builds the launcher plus the host `agentctl` and, on hosts other than Linux/amd64, one Linux/amd64 helper. It then invokes the repository's native Go launcher. The `kandev dev` syntax above belongs to that source launcher; installing the npm or Homebrew release does not install it as a second runtime mode. ### OS service commands diff --git a/docs/public/coverage.json b/docs/public/coverage.json index 72232b93b4..6010a35171 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -6,7 +6,7 @@ "audiences": ["user", "operator"], "stability": "stable", "docs": ["index", "use-kandev", "cli", "desktop-app", "run-as-a-service"], - "sources": ["apps/cli/bin/native-shim.js", "apps/cli/src/cli.ts", "apps/desktop/src-tauri/src/lib.rs"], + "sources": ["apps/cli/bin/native-shim.js", "apps/backend/internal/launcher/launcher.go", "apps/desktop/src-tauri/src/lib.rs"], "tests": ["apps/cli/bin/native-shim.test.mjs", "apps/desktop/e2e/desktop-launch-smoke.test.mjs"] }, { @@ -330,8 +330,8 @@ "audiences": ["operator"], "stability": "stable", "docs": ["docker", "k8s", "run-as-a-service", "windows-support", "operations"], - "sources": ["Dockerfile", "apps/cli/src/service/index.ts"], - "tests": ["apps/web/e2e/tests/docker/docker-launch.spec.ts", "apps/cli/src/service/install_helpers.test.ts"] + "sources": ["Dockerfile", "apps/backend/internal/launcher/service.go"], + "tests": ["apps/web/e2e/tests/docker/docker-launch.spec.ts"] }, { "id": "office-mode", diff --git a/docs/remote-cloud-environment.md b/docs/remote-cloud-environment.md index ac14507638..0dcc03e2e8 100644 --- a/docs/remote-cloud-environment.md +++ b/docs/remote-cloud-environment.md @@ -38,7 +38,7 @@ Without these, `tsc` fails with missing module errors for `@/generated/release-n ## Running dev mode -`make dev` from the repo root builds the backend and starts both the Go backend (port 38429) and Vite frontend (port 37429). The CLI launcher sets `KANDEV_DEBUG_DEV_MODE=true` which activates the `dev` profile from `profiles.yaml`, enabling mock agent and other dev conveniences. No external services (database, message queue, Docker) are needed — SQLite is embedded and the event bus runs in-memory. +`make dev` from the repo root builds the backend and starts both the Go backend (port 38429) and Vite frontend (port 37429). It needs a Go toolchain plus `pnpm` (for the Vite dev server); the `tsx`/TypeScript launcher is gone — all launch modes (`dev`, `start`, `run`, `service`) go through the native Go launcher (`apps/backend/bin/kandev`). The launcher sets `KANDEV_DEBUG_DEV_MODE=true` which activates the `dev` profile from `profiles.yaml`, enabling mock agent and other dev conveniences. No external services (database, message queue, Docker) are needed — SQLite is embedded and the event bus runs in-memory. ## Key commands @@ -55,6 +55,6 @@ All documented in the root `Makefile`: These apply to Cursor Cloud, Codex, and similar Firecracker-backed sandboxes: - **tool PATH**: `scripts/bootstrap-dev-env` activates mise for its own process. New shells should run `eval "$(mise activate bash)"` or use a shell profile that activates mise so `node`, `pnpm`, `go`, `golangci-lint`, and `pre-commit` resolve from the pinned toolchain. -- **First page load**: `make dev` compiles pages on first visit via Turbopack — expect ~25 s for the initial load. -- **CLI test flake**: `src/ports.test.ts > isPortInUse > returns false within the timeout when the host black-holes packets` fails because `192.0.2.1` behaves differently inside Firecracker. This is a known environment-specific flake, not a code issue. +- **First page load**: `make dev` compiles pages on first visit via the Vite dev server — expect ~25 s for the initial load. +- **CLI shim tests**: `apps/cli` tests run under the Node built-in test runner (`node --test`); the old Vitest-based `src/` tree was deleted when `make dev` moved to the Go launcher. - **Playwright browser installation**: `playwright install chromium` hangs during zip extraction (Node.js io_uring incompatibility with the Firecracker kernel). Workaround: `scripts/install-playwright-browsers.sh` runs the install with a timeout, then falls back to extracting the already-downloaded zips with `unzip`. `scripts/bootstrap-dev-env --with-e2e` calls this automatically. System deps can be installed via `playwright install-deps chromium`. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index e627672d25..80ac5cc67c 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -53,6 +53,7 @@ Product-wide capabilities that are not tied to a single feature area. | [diagnostic-logging](platform/diagnostic-logging.md) | approved | | [provider-error-recovery](platform/provider-error-recovery.md) | draft | | [health-endpoint-version](health-endpoint-version/spec.md) | building | +| [go-dev-launcher](go-dev-launcher/spec.md) | draft | ## tasks/ — task & workflow model diff --git a/docs/specs/go-dev-launcher/spec.md b/docs/specs/go-dev-launcher/spec.md new file mode 100644 index 0000000000..c81409aca6 --- /dev/null +++ b/docs/specs/go-dev-launcher/spec.md @@ -0,0 +1,198 @@ +--- +status: draft +created: 2026-08-07 +--- + +# Go dev launcher and minimal Node surface + +## Why + +`make dev` is the last Kandev entrypoint that runs through the TypeScript launcher. +`make start` (Makefile:277) and every `make service-*` target already exec the native Go +binary at `apps/backend/bin/kandev`; only `make dev` still shells out to +`pnpm -C cli dev -- dev` (Makefile:195), which runs `tsx src/cli.ts`. + +That single call site keeps roughly 3,000 lines of TypeScript alive under +`apps/cli/src/**` — `dev.ts`, plus `run.ts`, `start.ts`, `service/`, and `supervisor/`, +which are already dead code because their Go equivalents (`launcher.runInstalled`, +`launcher.runStart`, `launcher.runService`, `launcher/supervisor.go`) shipped and took +over the Makefile targets. None of that TypeScript is published: `apps/cli/package.json` +declares `files: ["bin/cli.js", "bin/native-shim.js"]`, so the npm `kandev` package is +already a four-line Node shim that execs the Go binary. The `src/` tree exists solely to +serve `make dev`. + +The cost of keeping it is not just lines. The dev launch chain today is +`bash → make → pnpm → node → make → sh → kandev`, and that chain is why +`apps/backend/cmd/winjob` exists at all: MSYS bash, native Win32 processes, and Node +disagree on signal propagation, so Ctrl-C leaks processes on Windows unless the backend +is wrapped in a Job Object (`apps/cli/src/dev.ts:169-199`). The Go launcher already +solves this generically with `CREATE_NEW_PROCESS_GROUP` plus `taskkill /F /T` +(`launcher/process_group_windows.go`), so moving `dev` to Go removes the winjob +dependency from the dev path rather than reimplementing it. + +Behavior also drifts between the two implementations. PR #2394 ("harden launcher port +ownership") had to land the same health-token and explicit-port-preflight fix twice — +once in `apps/cli/src/{args,health,ports,run,shared,dev}.ts` and once in +`apps/backend/internal/launcher/{env,health,ports,run,start}.go`. Every launcher change +pays that double-implementation tax until `dev` moves. + +## What + +### `kandev dev` becomes a native launcher command + +The Go launcher gains a third command alongside `run` and `start`. It accepts the same +surface the TypeScript `dev` accepted: + +| Input | Behavior | +| --- | --- | +| `dev`, `--dev` | Select dev mode. | +| `--port`, `--backend-port`, `KANDEV_BACKEND_PORT`, `KANDEV_PORT` | Backend port. Existing preflight applies: an explicitly requested occupied port is a hard error naming the port and its source. | +| `--web-internal-port`, `KANDEV_WEB_PORT` | Internal Vite port. Rejected outside dev mode. | +| `--web-port` | Rejected with the existing "use `--web-internal-port` for dev mode" message. | +| `--verbose`, `--debug`, `--headless`, `--no-browser` | Unchanged meaning. | + +Omitted ports keep the current preferred-then-random selection (`38429` backend, +`37429` web, `39429` agentctl), and the web and agentctl ports must not collide with the +backend port or with each other. + +### Dev state stays isolated from production + +Dev mode roots Kandev at `/.kandev-dev` so `make dev` never mutates the user's +`~/.kandev` state and so `make clean-db` (which removes `.kandev-dev/`) matches what +`make dev` writes. The repo root is discovered by walking up from the working directory +for a parent containing both `apps/backend` and `apps/web`; failing to find one is a +usage error, not a silent fallback. + +`KANDEV_DATABASE_PATH` resolution keeps its current three-way rule: + +- Inside a Kandev task workspace — signalled by `KANDEV_TASK_ID` being set, or by the + repo root living under `~/.kandev/tasks/` — any inherited `KANDEV_DATABASE_PATH` is + assumed to be leaked from the parent backend and is cleared, so the backend derives + its DB from `KANDEV_HOME_DIR`. The launcher prints that it detected a task workspace. +- Otherwise an explicit `KANDEV_DATABASE_PATH` is honored as an escape hatch. +- Otherwise the DB is `/.kandev-dev/data/kandev.db`. + +Dev mode always sets `KANDEV_DEBUG_DEV_MODE=true`, which is the profile selector; the +concrete dev defaults stay in `profiles.yaml`. The launcher must not restate them. + +### Production databases are backed up before dev touches them + +When the resolved DB path is not under a `.kandev-dev` segment, the launcher snapshots it +to `~/.kandev/data/backups/dev-prod-db-.db` before starting the backend, +retaining the five newest such snapshots and leaving other backup families +(`kandev-*`, `manual-*`) untouched. This is what makes `make dev-prod-db` safe. + +A backup failure aborts startup with a non-zero exit. The guard exists precisely to +protect production state before dev mode touches it; continuing past a failed backup +would remove the guarantee that justified it. + +### The Vite dev server is a supervised child + +The launcher starts the frontend with `pnpm -C apps --filter @kandev/web dev`, passing +`PORT`, `HOSTNAME=127.0.0.1`, and `KANDEV_API_BASE_URL`, setting `KANDEV_DEBUG=true` and +`VITE_KANDEV_DEBUG=true`, and explicitly removing any inherited `VITE_KANDEV_API_PORT` so +a host `.env`, Docker env, or CI variable cannot reintroduce cross-origin browser API +calls. On Windows, `pnpm` is invoked through `cmd.exe /c` because Node-style `.cmd` shims +cannot be spawned directly. + +Browser traffic still enters through the Go backend, which reverse-proxies to the Vite +port via `KANDEV_WEB_INTERNAL_URL`. The launcher waits for the backend health endpoint +(dev timeout: 600s, versus 45s for release) and then for the Vite URL to answer, then +opens the backend URL. The Vite readiness probe accepts any HTTP response and does not +require the launcher health token — Vite is not a Kandev process and does not emit one. + +If either child exits, the launcher shuts the whole tree down and exits with the child's +code, matching current behavior. + +### Backend restart from the UI still rebuilds + +The restart supervisor (ADR 0019) writes a launch manifest and listens on a control +socket so Settings can restart the backend. In dev that restart must rebuild from source, +which is what makes the affordance useful after editing Go code. The dev launcher +therefore keeps `make -C apps/backend dev` as the backend child command rather than +re-execing itself. + +That creates a constraint the TypeScript launcher never had: the launcher and the backend +are the same binary (`bin/kandev`, dispatched by the `__backend` argument), so a rebuild +would overwrite the running launcher's own executable — which fails outright on Windows. +`make dev` therefore execs a copy of the freshly built binary from a distinct path, and +the copy supervises `make -C apps/backend dev`. The copy is a build artifact, not a +second shipped binary. + +### `make dev` stops depending on Node and winjob + +```makefile +dev: doctor + @$(MAKE) -C $(BACKEND_DIR) build-kandev + @cp bin/kandev → bin/kandev-launcher + @exec $(BACKEND_DIR)/bin/kandev-launcher dev $(DEV_FLAGS) +``` + +`build-kandev` is the only root prebuild: the copied supervisor binary does not need +agentctl helpers before it starts the supervised child. The child's `make -C apps/backend +dev` runs `dev: build-dev`, which builds the native agentctl and, on hosts other than +Linux/amd64, one `linux/amd64` helper, plus mock-agent, acpdbg, and winjob. Full +four-platform helper builds remain under `build-runtime` for release and service bundles. + +`build-winjob` leaves the dev path: the Go launcher's process-group handling replaces it, +and the root `dev` target no longer invokes the winjob build step. `cmd/winjob` itself +stays in the tree for any other consumer. The lean `dev: build-dev` target still builds +winjob once because the backend's Windows process handling remains part of the local +development binary set. + +### `apps/cli` shrinks to the published shim + +After `dev` lands in Go, `apps/cli/src/**` has no consumer and is deleted in full, +together with `tsconfig.json` and `vitest.config.ts`. What remains is exactly what npm +publishes plus its test: + +```text +apps/cli/ +├── package.json +└── bin/ + ├── cli.js # 4-line exec of the native binary + ├── native-shim.js # platform-package resolution + └── native-shim.test.mjs +``` + +`native-shim.test.mjs` moves from Vitest to the Node built-in test runner, so `apps/cli` +needs no `devDependencies` at all and `make test-cli` becomes `node --test`. The package +keeps its `name`, `version`, `bin`, `files`, and `optionalDependencies` untouched: the +npm release flow (`scripts/release/publish-npm.sh`, `.github/workflows/release.yml`) +reads and bumps `apps/cli/package.json` and must keep working unchanged. + +Node remains required for exactly three things, and this spec does not attempt to remove +them: building and serving the web app (Vite), the published npm shim, and repo tooling +(prettier, commitlint, Playwright). That is the floor. + +## Non-goals + +- Removing Node from the web build or from `apps/web`. Vite is the dev server. +- Changing what the npm `kandev` package ships or how it resolves `@kdlbs/runtime-*`. +- Removing `apps/backend/cmd/winjob` from the repository. +- Changing `make start`, `make service-*`, or the desktop/Tauri launch path, all of + which already use the Go binary. +- Changing E2E backend spawning, which already execs `KANDEV_BIN __backend` directly + (`apps/web/e2e/fixtures/backend.ts:287`). + +## Failure modes + +| Condition | Expected behavior | +| --- | --- | +| Repo root not found from CWD | Exit 2 with a message telling the user to run from the repo. | +| Explicit backend or web port occupied | Exit 1 naming the port and its source (flag vs. env). No browser is opened. | +| Production DB backup fails | Exit 1 before the backend starts; the message names the DB path. | +| Backend fails health within 600s | Print the captured backend output, shut down the tree, exit 1. | +| Vite never becomes ready | Shut down the tree, exit 1. | +| Either child exits | Shut down the tree; exit with that child's code, or 0 if it was signalled. | +| `pnpm` missing | Surface the spawn error rather than hanging on the readiness probe. | +| Ctrl-C | Whole tree terminates, including on Windows, without winjob. | + +## Persistence guarantees + +- `.kandev-dev/` is the only state `make dev` writes, unless the user explicitly sets + `KANDEV_DATABASE_PATH` outside a task workspace. +- `dev-prod-db-*.db` snapshots are retained five deep in `~/.kandev/data/backups/` and + never prune other backup families. +- The supervisor manifest and control socket live under + `/supervisor/` at mode `0600`, unchanged from `start`. diff --git a/docs/specs/native-kandev-cli/spec.md b/docs/specs/native-kandev-cli/spec.md index 2477a27460..5e6c79b9a9 100644 --- a/docs/specs/native-kandev-cli/spec.md +++ b/docs/specs/native-kandev-cli/spec.md @@ -16,7 +16,7 @@ Users start Kandev from the terminal through `kandev`, but the current launcher - `kandev` remains the public command for Homebrew, npm/npx, global npm installs, service units, and local development. - Homebrew and release bundle installs provide a native `bin/kandev` executable that can launch Kandev without executing the TypeScript CLI bundle. - The native `kandev` executable supports the public launcher commands users need after the web runtime merge: default run, `run`, `start`, `service`, `--help`, `--version`, `--port`, `--backend-port`, `--verbose`, `--debug`, and `--headless`. -- Native `dev` mode is deferred until it can be ported with parity; the native launcher does not advertise or accept `dev`/`--dev`. +- Native `dev` mode is supported for source checkouts through the Go launcher (`kandev dev` / `--dev`); installed release entrypoints do not expose it — it requires the repository checkout that `make dev` provides. - The native launcher starts the backend as a supervised child process by re-executing the same `bin/kandev` binary in a hidden backend mode. - The hidden backend mode is not a public command and is not shown in normal help output. - Backend restarts restart only the backend child process; the launcher/supervisor remains alive unless the shutdown policy requires the whole app to exit. @@ -174,7 +174,7 @@ Transitions: - **GIVEN** Kandev is installed from Homebrew, **WHEN** the user runs `kandev --help`, **THEN** the help output describes the public launcher commands and does not show `__backend`. - **GIVEN** Kandev is installed from Homebrew, **WHEN** the user runs `kandev --version`, **THEN** the command prints the installed Kandev version without executing the Node CLI bundle. - **GIVEN** a valid release bundle, **WHEN** the user runs `kandev --headless`, **THEN** the native launcher starts `kandev __backend`, waits for the backend to serve API and SPA routes, and prints the backend URL. -- **GIVEN** a local checkout with built backend and web artifacts, **WHEN** the user runs `make start`, **THEN** the Makefile launches through `apps/backend/bin/kandev start` and does not invoke `pnpm -C cli dev -- start`. +- **GIVEN** a local checkout with built backend and web artifacts, **WHEN** the user runs `make start`, **THEN** the Makefile launches through `apps/backend/bin/kandev start` and does not invoke the TypeScript CLI launcher (`kandev dev` likewise goes through the same Go binary). - **GIVEN** the backend has requested a restart through the restart adapter, **WHEN** the launcher receives the restart request, **THEN** only the `kandev __backend` child process is replaced. - **GIVEN** the user presses Ctrl-C while Kandev is running, **WHEN** the launcher handles the signal, **THEN** it terminates the backend child process before exiting. - **GIVEN** a new service install on Linux or macOS, **WHEN** the user runs `kandev service install`, **THEN** the generated service unit executes the public `kandev` launcher path.