feat: add native kandev launcher (#1391)

This commit is contained in:
Carlos Florêncio
2026-06-20 19:40:15 +01:00
committed by GitHub
parent 2b93545a4d
commit da45983c4c
99 changed files with 5240 additions and 2044 deletions
+5 -42
View File
@@ -6,8 +6,7 @@ name: release
# Flow (one click → everything below): # Flow (one click → everything below):
# 1. prepare — bump version, regen CHANGELOG, open + merge release PR, push tag vX.Y.Z # 1. prepare — bump version, regen CHANGELOG, open + merge release PR, push tag vX.Y.Z
# 2. build-web — build Vite SPA bundle (shared across arches) # 2. build-web — build Vite SPA bundle (shared across arches)
# 3. build-cli — build + esbuild-bundle CLI (shared across arches) # 3. build-bundles — assemble native bin/ tarballs for 5 platforms
# 4. build-bundles — assemble bin/ + web/ + cli/ tarballs for 5 platforms
# 5. docker-{amd64,arm64} — native per-arch image builds (no QEMU), consuming # 5. docker-{amd64,arm64} — native per-arch image builds (no QEMU), consuming
# the prebuilt linux bundles from step 4 # the prebuilt linux bundles from step 4
# 6. docker-manifest — stitch the two arch digests into the user-facing # 6. docker-manifest — stitch the two arch digests into the user-facing
@@ -281,35 +280,9 @@ jobs:
path: apps/web/dist path: apps/web/dist
include-hidden-files: true include-hidden-files: true
build-cli:
name: Build & bundle CLI
needs: prepare
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.tag }}
- uses: pnpm/action-setup@v5
with:
version: "9.15.9"
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: apps/pnpm-lock.yaml
- name: Install dependencies
run: pnpm -C apps install --frozen-lockfile
- name: Build and bundle CLI
run: bash scripts/release/package-cli.sh
- uses: actions/upload-artifact@v5
with:
name: cli-bundle
path: dist/kandev/cli
build-bundles: build-bundles:
name: Build platform bundle (${{ matrix.platform }}) name: Build platform bundle (${{ matrix.platform }})
needs: [prepare, build-web, build-cli] needs: [prepare, build-web]
if: ${{ !inputs.dry_run }} if: ${{ !inputs.dry_run }}
strategy: strategy:
matrix: matrix:
@@ -352,10 +325,6 @@ jobs:
with: with:
name: web-bundle name: web-bundle
path: apps/backend/internal/webapp/embedded/generated path: apps/backend/internal/webapp/embedded/generated
- uses: actions/download-artifact@v5
with:
name: cli-bundle
path: dist/kandev/cli
- name: Install cross-compilation tools - name: Install cross-compilation tools
if: matrix.cc if: matrix.cc
@@ -403,18 +372,12 @@ jobs:
- name: Package bundle - name: Package bundle
run: | run: |
rm -rf dist/kandev/bin dist/kandev/web rm -rf dist/kandev
mkdir -p dist/kandev/bin mkdir -p dist/kandev/bin
EXT=${{ matrix.goos == 'windows' && '.exe' || '' }} EXT=${{ matrix.goos == 'windows' && '.exe' || '' }}
cp apps/backend/bin/kandev${EXT} dist/kandev/bin/ cp apps/backend/bin/kandev${EXT} dist/kandev/bin/
cp apps/backend/bin/agentctl${EXT} dist/kandev/bin/ cp apps/backend/bin/agentctl${EXT} dist/kandev/bin/
cp apps/backend/bin/agentctl-linux-amd64 dist/kandev/bin/ cp apps/backend/bin/agentctl-linux-amd64 dist/kandev/bin/
# cli/ was already placed by the build-cli job (downloaded above).
# actions/upload-artifact + download-artifact zip-roundtrip strips the
# executable bit, so re-apply it. The Homebrew formula's wrapper at
# bin/kandev does `exec libexec/cli/bin/cli.js`, which fails with
# "Permission denied" if this isn't +x.
chmod +x dist/kandev/cli/bin/cli.js
cd dist cd dist
tar -czf "kandev-${{ matrix.platform }}.tar.gz" kandev tar -czf "kandev-${{ matrix.platform }}.tar.gz" kandev
shasum -a 256 "kandev-${{ matrix.platform }}.tar.gz" > "kandev-${{ matrix.platform }}.tar.gz.sha256" shasum -a 256 "kandev-${{ matrix.platform }}.tar.gz" > "kandev-${{ matrix.platform }}.tar.gz.sha256"
@@ -429,7 +392,7 @@ jobs:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Docker base image — split per arch onto native runners, then stitched into # Docker base image — split per arch onto native runners, then stitched into
# a multi-arch manifest list. Each arch consumes the matching linux bundle # a multi-arch manifest list. Each arch consumes the matching linux bundle
# produced by build-bundles, so no Go/Next.js/pnpm work happens inside the # produced by build-bundles, so no Go/Vite/pnpm work happens inside the
# Docker build (the Dockerfile is reduced to apt-get + COPY). # Docker build (the Dockerfile is reduced to apt-get + COPY).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
docker-amd64: docker-amd64:
@@ -459,7 +422,7 @@ jobs:
set -euo pipefail set -euo pipefail
mkdir -p ctx/bundle mkdir -p ctx/bundle
# --strip-components=1 drops the top-level `kandev/` dir inside the # --strip-components=1 drops the top-level `kandev/` dir inside the
# tarball so ctx/bundle/ ends up with bin/, web/, cli/ directly. # tarball so ctx/bundle/ ends up with bin/ directly.
tar -xzf dist/bundle/kandev-linux-x64.tar.gz --strip-components=1 -C ctx/bundle tar -xzf dist/bundle/kandev-linux-x64.tar.gz --strip-components=1 -C ctx/bundle
cp docker-entrypoint.sh ctx/ cp docker-entrypoint.sh ctx/
+11 -25
View File
@@ -3,7 +3,7 @@
# Single-stage build that consumes prebuilt artifacts. The release workflow # Single-stage build that consumes prebuilt artifacts. The release workflow
# (.github/workflows/release.yml) extracts the per-arch release bundle # (.github/workflows/release.yml) extracts the per-arch release bundle
# (`kandev-linux-{x64,arm64}.tar.gz`) into the build context, then this file # (`kandev-linux-{x64,arm64}.tar.gz`) into the build context, then this file
# just COPYs the binaries + web standalone + CLI into the runtime layout. # just COPYs the native binaries into the runtime layout.
# #
# Building this file outside CI (manual `docker build .`) will fail because # Building this file outside CI (manual `docker build .`) will fail because
# the `bundle/` directory isn't present in the build context. To build # the `bundle/` directory isn't present in the build context. To build
@@ -14,12 +14,7 @@
# Run: # Run:
# docker run -p 38429:38429 -v kandev-data:/data ghcr.io/kdlbs/kandev:latest # docker run -p 38429:38429 -v kandev-data:/data ghcr.io/kdlbs/kandev:latest
ARG PNPM_VERSION=9.15.9 FROM debian:bookworm-slim
FROM node:24-bookworm-slim
ARG PNPM_VERSION
LABEL org.opencontainers.image.pnpm-version="${PNPM_VERSION}"
# Install runtime dependencies. gh is included because the GitHub integration # Install runtime dependencies. gh is included because the GitHub integration
# (PR review, webhooks) shells out to it for auth fallback when GITHUB_TOKEN # (PR review, webhooks) shells out to it for auth fallback when GITHUB_TOKEN
@@ -41,10 +36,10 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install apprise PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install apprise
# Replace the node base image's default user so we own uid 1000. # Create the runtime user with uid 1000.
# Home is placed under /data so agent CLI auth state (gh, claude, codex, auggie, # Home is placed under /data so agent CLI auth state (gh, claude, codex, auggie,
# copilot, amp, ...) lives on the PV and survives pod restarts and image upgrades. # copilot, amp, ...) lives on the PV and survives pod restarts and image upgrades.
RUN userdel -r node && groupadd -r kandev && useradd -r -g kandev -u 1000 -d /data/home -M kandev RUN groupadd -r kandev && useradd -r -g kandev -u 1000 -d /data/home -M kandev
# Install azure-devops extension under the runtime user's Azure config dir on /data. # Install azure-devops extension under the runtime user's Azure config dir on /data.
RUN mkdir -p /data/home/.azure && \ RUN mkdir -p /data/home/.azure && \
@@ -53,18 +48,13 @@ RUN mkdir -p /data/home/.azure && \
# Create app directory structure matching what `kandev start` expects: # Create app directory structure matching what `kandev start` expects:
# /app/apps/backend/bin/kandev # /app/apps/backend/bin/kandev
# /app/apps/web/.next/standalone/web/server.js RUN mkdir -p /app/apps/backend/bin /data/worktrees
RUN mkdir -p /app/apps/backend/bin /app/apps/web/.next/standalone /usr/local/lib/kandev-cli /data/worktrees
# Build context layout (prepared by the release workflow from the extracted # Build context layout (prepared by the release workflow from the extracted
# bundle tarball): # bundle tarball):
# bundle/bin/kandev - native Go backend (per-arch) # bundle/bin/kandev - native Go backend (per-arch)
# bundle/bin/agentctl - native agentctl (per-arch) # bundle/bin/agentctl - native agentctl (per-arch)
# bundle/bin/agentctl-linux-amd64 - amd64 agentctl helper (always amd64) # bundle/bin/agentctl-linux-amd64 - amd64 agentctl helper (always amd64)
# bundle/web/... - Next.js standalone + static + public
# bundle/cli/bin/cli.js - CLI entrypoint (calls cli.bundle.js)
# bundle/cli/dist/cli.bundle.js - self-contained CLI bundle (deps inlined)
# bundle/cli/package.json - package metadata (version, etc.)
# #
# The bundle's `bin/agentctl-linux-amd64` variant is bind-mounted into # The bundle's `bin/agentctl-linux-amd64` variant is bind-mounted into
# Docker-executor sandboxes by the lifecycle manager; ship it next to kandev # Docker-executor sandboxes by the lifecycle manager; ship it next to kandev
@@ -72,20 +62,16 @@ RUN mkdir -p /app/apps/backend/bin /app/apps/web/.next/standalone /usr/local/lib
COPY bundle/bin/kandev /app/apps/backend/bin/kandev COPY bundle/bin/kandev /app/apps/backend/bin/kandev
COPY bundle/bin/agentctl-linux-amd64 /app/apps/backend/bin/agentctl-linux-amd64 COPY bundle/bin/agentctl-linux-amd64 /app/apps/backend/bin/agentctl-linux-amd64
COPY bundle/bin/agentctl /usr/local/bin/agentctl COPY bundle/bin/agentctl /usr/local/bin/agentctl
COPY bundle/web/ /app/apps/web/.next/standalone/
COPY bundle/cli/ /usr/local/lib/kandev-cli/
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Re-apply executable bits stripped by tar/COPY edge cases, then link the # Re-apply executable bits stripped by tar/COPY edge cases, then link the native
# CLI launcher onto PATH. The CLI's deps are inlined into dist/cli.bundle.js # launcher onto PATH.
# by the release bundle step, so no `npm install` is needed here.
RUN chmod +x \ RUN chmod +x \
/app/apps/backend/bin/kandev \ /app/apps/backend/bin/kandev \
/app/apps/backend/bin/agentctl-linux-amd64 \ /app/apps/backend/bin/agentctl-linux-amd64 \
/usr/local/bin/agentctl \ /usr/local/bin/agentctl \
/usr/local/lib/kandev-cli/bin/cli.js \
/usr/local/bin/docker-entrypoint.sh && \ /usr/local/bin/docker-entrypoint.sh && \
ln -s /usr/local/lib/kandev-cli/bin/cli.js /usr/local/bin/kandev && \ ln -s /app/apps/backend/bin/kandev /usr/local/bin/kandev && \
chown -R kandev:kandev /app /data chown -R kandev:kandev /app /data
# Kandev home directory (DB, worktrees, sessions, repos) # Kandev home directory (DB, worktrees, sessions, repos)
@@ -105,10 +91,10 @@ ENV KANDEV_NO_BROWSER=1 \
WORKDIR /app WORKDIR /app
# Only the backend port is exposed — it reverse-proxies the Next.js frontend # Only the backend port is exposed; the Go backend serves API, WebSocket, and
# (which listens on 37429 internally), so users hit a single port. # embedded SPA assets on the same listener.
EXPOSE 38429 EXPOSE 38429
# tini as PID 1 for signal handling; entrypoint handles privilege drop # tini as PID 1 for signal handling; entrypoint handles privilege drop
ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"] ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"]
CMD ["kandev", "start", "--backend-port", "38429", "--web-port", "37429", "--verbose"] CMD ["kandev", "start", "--backend-port", "38429", "--verbose"]
+3 -16
View File
@@ -12,9 +12,7 @@ class Kandev < Formula
depends_on "go" => :build depends_on "go" => :build
depends_on "pnpm" => :build depends_on "pnpm" => :build
depends_on "node"
uses_from_macos "rsync" => :build
uses_from_macos "sqlite" uses_from_macos "sqlite"
def install def install
@@ -22,8 +20,7 @@ class Kandev < Formula
system "pnpm", "-C", "apps", "install", "--frozen-lockfile" system "pnpm", "-C", "apps", "install", "--frozen-lockfile"
system "pnpm", "-C", "apps", "--filter", "@kandev/web", "build" system "pnpm", "-C", "apps", "--filter", "@kandev/web", "build"
system "./scripts/release/package-web.sh" system "make", "sync-embedded-web"
system "./scripts/release/package-cli.sh"
bundle = buildpath/"dist/kandev" bundle = buildpath/"dist/kandev"
(bundle/"bin").mkpath (bundle/"bin").mkpath
@@ -32,6 +29,7 @@ class Kandev < Formula
# kandev backend needs cgo for mattn/go-sqlite3. # kandev backend needs cgo for mattn/go-sqlite3.
with_env(CGO_ENABLED: "1") do with_env(CGO_ENABLED: "1") do
system "go", "build", system "go", "build",
"-tags", "fts5",
*std_go_args(ldflags: "-s -w -X main.Version=#{version}", *std_go_args(ldflags: "-s -w -X main.Version=#{version}",
output: bundle/"bin/kandev"), output: bundle/"bin/kandev"),
"./cmd/kandev" "./cmd/kandev"
@@ -47,20 +45,9 @@ class Kandev < Formula
system "./scripts/release/package-bundle.sh" system "./scripts/release/package-bundle.sh"
# The Next.js standalone tracer pulls platform-tagged native modules
# into the bundle, including musl-libc variants of sharp, @swc/core,
# and lightningcss that brew linkage --test flags on glibc-only
# Linuxbrew. Strip them — rm_r handles both directory trees (the
# current shape) and bare .node files (in case a future bundler
# version inlines them). The exist? guard handles the glob
# returning a parent dir and its musl-named children together —
# removing the parent makes the child paths vanish before we get
# to them.
bundle.glob("web/**/*musl*").each { |p| rm_r(p) if p.exist? }
libexec.install Dir[bundle/"*"] libexec.install Dir[bundle/"*"]
(bin/"kandev").write_env_script libexec/"cli/bin/cli.js", (bin/"kandev").write_env_script libexec/"bin/kandev",
KANDEV_BUNDLE_DIR: libexec.to_s, KANDEV_BUNDLE_DIR: libexec.to_s,
KANDEV_VERSION: version.to_s KANDEV_VERSION: version.to_s
end end
+15 -16
View File
@@ -32,7 +32,7 @@ MAGENTA := \033[35m
VERBOSE ?= 0 VERBOSE ?= 0
NODE ?= $(shell command -v node 2>/dev/null || echo node) NODE ?= $(shell command -v node 2>/dev/null || echo node)
SERVICE_CLI := $(CURDIR)/dist/kandev/cli/bin/cli.js SERVICE_LAUNCHER := $(CURDIR)/dist/kandev/bin/kandev
SERVICE_BUNDLE_DIR := $(CURDIR)/dist/kandev SERVICE_BUNDLE_DIR := $(CURDIR)/dist/kandev
SERVICE_VERSION := $(shell git rev-parse --short HEAD 2>/dev/null || echo dev) SERVICE_VERSION := $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
SERVICE_ENV := KANDEV_BUNDLE_DIR="$(SERVICE_BUNDLE_DIR)" KANDEV_VERSION="$(SERVICE_VERSION)" SERVICE_ENV := KANDEV_BUNDLE_DIR="$(SERVICE_BUNDLE_DIR)" KANDEV_VERSION="$(SERVICE_VERSION)"
@@ -65,11 +65,11 @@ help:
@echo "Development Commands:" @echo "Development Commands:"
@echo " bootstrap Install mise tools, workspace deps, and git hooks" @echo " bootstrap Install mise tools, workspace deps, and git hooks"
@echo " bootstrap-e2e Bootstrap plus Playwright browser/system deps" @echo " bootstrap-e2e Bootstrap plus Playwright browser/system deps"
@echo " dev Run backend + web via CLI (auto ports)" @echo " dev Run backend + web via local CLI (auto ports)"
@echo " dev-prod-db Run dev mode against the production db at ~/.kandev" @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-backend Run backend in development mode (port 38429)"
@echo " dev-web Run web app in development mode (port 37429)" @echo " dev-web Run web app in development mode (port 37429)"
@echo " dev Note: Uses apps/cli launcher (auto ports)" @echo " dev Note: Uses local development launcher (auto ports)"
@echo " doctor Idempotently wire up pre-commit hooks (runs automatically before dev)" @echo " doctor Idempotently wire up pre-commit hooks (runs automatically before dev)"
@echo "" @echo ""
@echo "Production Commands:" @echo "Production Commands:"
@@ -214,7 +214,7 @@ start:
@$(MAKE) -s build-backend-quiet @$(MAKE) -s build-backend-quiet
$(call success,Build complete) $(call success,Build complete)
$(call phase,Starting Server) $(call phase,Starting Server)
@cd $(APPS_DIR) && $(PNPM) -C cli dev -- start $(if $(filter 1 true yes,$(VERBOSE)),--verbose,) $(if $(filter 1 true yes,$(DEBUG)),--debug,) @$(BACKEND_DIR)/bin/kandev start $(if $(filter 1 true yes,$(VERBOSE)),--verbose,) $(if $(filter 1 true yes,$(DEBUG)),--debug,)
.PHONY: start-verbose .PHONY: start-verbose
start-verbose: start-verbose:
@@ -231,7 +231,6 @@ start-debug:
.PHONY: service-bundle .PHONY: service-bundle
service-bundle: install build service-bundle: install build
$(call phase,Packaging Service Bundle) $(call phase,Packaging Service Bundle)
@scripts/release/package-cli.sh
@test -n "$(SERVICE_BUNDLE_DIR)" || { echo "SERVICE_BUNDLE_DIR is empty; aborting."; exit 1; } @test -n "$(SERVICE_BUNDLE_DIR)" || { echo "SERVICE_BUNDLE_DIR is empty; aborting."; exit 1; }
@test "$(SERVICE_BUNDLE_DIR)" != "/" || { echo "SERVICE_BUNDLE_DIR must not be /; aborting."; exit 1; } @test "$(SERVICE_BUNDLE_DIR)" != "/" || { echo "SERVICE_BUNDLE_DIR must not be /; aborting."; exit 1; }
@test -f "$(BACKEND_DIR)/bin/agentctl-linux-amd64" || $(MAKE) -C $(BACKEND_DIR) build-agentctl-linux @test -f "$(BACKEND_DIR)/bin/agentctl-linux-amd64" || $(MAKE) -C $(BACKEND_DIR) build-agentctl-linux
@@ -243,40 +242,40 @@ service-bundle: install build
.PHONY: service-cli-check .PHONY: service-cli-check
service-cli-check: service-cli-check:
@test -f "$(SERVICE_CLI)" || { echo "Missing $(SERVICE_CLI). Run 'make service-install' first."; exit 1; } @test -f "$(SERVICE_LAUNCHER)" || { echo "Missing $(SERVICE_LAUNCHER). Run 'make service-install' first."; exit 1; }
.PHONY: service-install .PHONY: service-install
service-install: service-bundle service-install: service-bundle
@$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service install $(SERVICE_INSTALL_FLAGS) @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service install $(SERVICE_INSTALL_FLAGS)
.PHONY: service-install-system .PHONY: service-install-system
service-install-system: service-bundle service-install-system: service-bundle
@sudo env $(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service install --system $(SERVICE_INSTALL_FLAGS) @sudo env $(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service install --system $(SERVICE_INSTALL_FLAGS)
.PHONY: service-uninstall service-start service-stop service-restart service-status service-logs service-logs-follow service-config .PHONY: service-uninstall service-start service-stop service-restart service-status service-logs service-logs-follow service-config
service-uninstall: service-cli-check service-uninstall: service-cli-check
@$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service uninstall @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service uninstall
service-start: service-cli-check service-start: service-cli-check
@$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service start @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service start
service-stop: service-cli-check service-stop: service-cli-check
@$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service stop @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service stop
service-restart: service-cli-check service-restart: service-cli-check
@$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service restart @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service restart
service-status: service-cli-check service-status: service-cli-check
@$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service status @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service status
service-logs: service-cli-check service-logs: service-cli-check
@$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service logs @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service logs
service-logs-follow: service-cli-check service-logs-follow: service-cli-check
@$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service logs -f @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service logs -f
service-config: service-cli-check service-config: service-cli-check
@$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service config @$(SERVICE_ENV) "$(SERVICE_LAUNCHER)" service config
.PHONY: build-backend .PHONY: build-backend
build-backend: build-backend:
+3 -3
View File
@@ -126,17 +126,17 @@ clean:
## Run the application ## Run the application
run: build run: build
@echo "Running $(BINARY_NAME)..." @echo "Running $(BINARY_NAME)..."
./$(BUILD_DIR)/$(BINARY_NAME)$(EXE) ./$(BUILD_DIR)/$(BINARY_NAME)$(EXE) __backend
## Run in development mode (with pprof enabled) ## Run in development mode (with pprof enabled)
dev: build dev: build
@echo "Running $(BINARY_NAME) in development mode..." @echo "Running $(BINARY_NAME) in development mode..."
@KANDEV_DEBUG_PPROF_ENABLED=true exec ./$(BUILD_DIR)/$(BINARY_NAME)$(EXE) @KANDEV_DEBUG_PPROF_ENABLED=true exec ./$(BUILD_DIR)/$(BINARY_NAME)$(EXE) __backend
## Run in debug mode (pprof + debug logging) ## Run in debug mode (pprof + debug logging)
start-debug: build start-debug: build
@echo "Running $(BINARY_NAME) in debug mode (pprof + debug logging)..." @echo "Running $(BINARY_NAME) in debug mode (pprof + debug logging)..."
@KANDEV_DEBUG_PPROF_ENABLED=true KANDEV_LOG_LEVEL=debug exec ./$(BUILD_DIR)/$(BINARY_NAME)$(EXE) @KANDEV_DEBUG_PPROF_ENABLED=true KANDEV_LOG_LEVEL=debug exec ./$(BUILD_DIR)/$(BINARY_NAME)$(EXE) __backend
## Run tests ## Run tests
test: test:
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
package main
import (
"testing"
"github.com/kandev/kandev/internal/backendapp"
"github.com/kandev/kandev/internal/launcher"
)
func TestDispatchesHiddenBackendMode(t *testing.T) {
backendCalled := false
launcherCalled := false
code := dispatch([]string{"__backend", "--version"}, buildInfo{Version: "test"}, func(args []string, build backendapp.BuildInfo) int {
backendCalled = true
if len(args) != 1 || args[0] != "--version" {
t.Fatalf("backend args = %v, want [--version]", args)
}
if build.Version != "test" {
t.Fatalf("backend build = %+v", build)
}
return 7
}, func(args []string, build launcher.BuildInfo) int {
launcherCalled = true
return 0
})
if code != 7 {
t.Fatalf("exit code = %d, want 7", code)
}
if !backendCalled {
t.Fatal("backend runner was not called")
}
if launcherCalled {
t.Fatal("launcher runner was called for hidden backend mode")
}
}
func TestDispatchDefaultsToLauncherMode(t *testing.T) {
backendCalled := false
launcherCalled := false
code := dispatch([]string{"--help"}, buildInfo{Commit: "abc"}, func(args []string, build backendapp.BuildInfo) int {
backendCalled = true
return 0
}, func(args []string, build launcher.BuildInfo) int {
launcherCalled = true
if len(args) != 1 || args[0] != "--help" {
t.Fatalf("launcher args = %v, want [--help]", args)
}
if build.Commit != "abc" {
t.Fatalf("launcher build = %+v", build)
}
return 3
})
if code != 3 {
t.Fatalf("exit code = %d, want 3", code)
}
if backendCalled {
t.Fatal("backend runner was called for public launcher mode")
}
if !launcherCalled {
t.Fatal("launcher runner was not called")
}
}
+12 -18
View File
@@ -141,25 +141,11 @@ func buildWeb(ctx context.Context, skipInstall bool) error {
return nil return nil
} }
// buildCLI creates the bundled TypeScript launcher used by release-style
// installs. Preview sprites run through this launcher so their backend is
// managed by the same restart supervisor as normal kandev starts.
func buildCLI(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "bash", "scripts/release/package-cli.sh")
cmd.Dir = repoRootDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("package cli: %w", err)
}
return nil
}
// packageBundle creates a tar.gz matching the Docker container layout: // packageBundle creates a tar.gz matching the Docker container layout:
// //
// app/apps/backend/bin/{kandev,agentctl,mock-agent} // app/apps/backend/bin/{kandev,agentctl,mock-agent}
// app/apps/web/dist/ (Vite SPA assets) // app/apps/web/dist/ (Vite SPA assets)
// usr/local/lib/kandev-cli/ (CLI launcher bundle) // usr/local/bin/kandev (native launcher symlink)
func packageBundle(binDir, tarPath string) error { func packageBundle(binDir, tarPath string) error {
f, err := os.Create(tarPath) f, err := os.Create(tarPath)
if err != nil { if err != nil {
@@ -193,9 +179,8 @@ func packageBundle(binDir, tarPath string) error {
return fmt.Errorf("add web dist: %w", err) return fmt.Errorf("add web dist: %w", err)
} }
cliDir := filepath.Join(repoRootDir, "dist", "kandev", "cli") if err := addSymlinkToTar(tw, filepath.Join("usr", "local", "bin", "kandev"), filepath.Join("/", "app", "apps", "backend", "bin", "kandev")); err != nil {
if err := addDirToTar(tw, cliDir, filepath.Join("usr", "local", "lib", "kandev-cli")); err != nil { return fmt.Errorf("add kandev symlink: %w", err)
return fmt.Errorf("add cli: %w", err)
} }
// Close in order: tar → gzip → file (flush compressed data). // Close in order: tar → gzip → file (flush compressed data).
@@ -232,6 +217,15 @@ func viteIndexHasEntrypoint(indexHTML string) bool {
return false return false
} }
func addSymlinkToTar(tw *tar.Writer, name, target string) error {
return tw.WriteHeader(&tar.Header{
Typeflag: tar.TypeSymlink,
Name: name,
Linkname: target,
Mode: 0o755,
})
}
func addFileToTar(tw *tar.Writer, src, dst string, mode fs.FileMode) error { func addFileToTar(tw *tar.Writer, src, dst string, mode fs.FileMode) error {
data, err := os.ReadFile(src) data, err := os.ReadFile(src)
if err != nil { if err != nil {
-5
View File
@@ -85,11 +85,6 @@ func deployArtifacts(ctx context.Context, binDir, tarPath, spritesToken, spriteN
return "", fmt.Errorf("build web: %w", err) return "", fmt.Errorf("build web: %w", err)
} }
fmt.Fprintln(os.Stderr, "building cli launcher...")
if err := buildCLI(ctx); err != nil {
return "", fmt.Errorf("build cli: %w", err)
}
fmt.Fprintln(os.Stderr, "packaging bundle...") fmt.Fprintln(os.Stderr, "packaging bundle...")
if err := packageBundle(binDir, tarPath); err != nil { if err := packageBundle(binDir, tarPath); err != nil {
return "", fmt.Errorf("package bundle: %w", err) return "", fmt.Errorf("package bundle: %w", err)
+4 -8
View File
@@ -9,7 +9,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/kandev/kandev/internal/common/ports"
sprites "github.com/superfly/sprites-go" sprites "github.com/superfly/sprites-go"
) )
@@ -104,11 +103,10 @@ func buildExtractScript(backendPort int) string {
tar -xzf /tmp/kandev-preview.tar.gz -C / tar -xzf /tmp/kandev-preview.tar.gz -C /
chmod +x /app/apps/backend/bin/kandev \ chmod +x /app/apps/backend/bin/kandev \
/app/apps/backend/bin/agentctl \ /app/apps/backend/bin/agentctl \
/app/apps/backend/bin/mock-agent \ /app/apps/backend/bin/mock-agent
/usr/local/lib/kandev-cli/bin/cli.js
ln -sf /app/apps/backend/bin/agentctl /usr/local/bin/agentctl ln -sf /app/apps/backend/bin/agentctl /usr/local/bin/agentctl
ln -sf /app/apps/backend/bin/mock-agent /usr/local/bin/mock-agent ln -sf /app/apps/backend/bin/mock-agent /usr/local/bin/mock-agent
ln -sf /usr/local/lib/kandev-cli/bin/cli.js /usr/local/bin/kandev ln -sf /app/apps/backend/bin/kandev /usr/local/bin/kandev
# Reset data directory on each deploy so the DB starts fresh (preview env only). # Reset data directory on each deploy so the DB starts fresh (preview env only).
rm -rf /data rm -rf /data
mkdir -p /data /var/log mkdir -p /data /var/log
@@ -122,14 +120,13 @@ mkdir -p /data
# Kill any agentctl orphans from previous runs. # Kill any agentctl orphans from previous runs.
pkill -f agentctl || true pkill -f agentctl || true
# Kill any stale web (node) process from older preview deployments.
pkill -f '/app/apps/web/.*/server.js' || true
sleep 1 sleep 1
cd /app cd /app
export KANDEV_HOME_DIR=/data export KANDEV_HOME_DIR=/data
export KANDEV_DOCKER_ENABLED=false export KANDEV_DOCKER_ENABLED=false
export KANDEV_LOG_LEVEL=info export KANDEV_LOG_LEVEL=info
export KANDEV_WEB_DIST_DIR=/app/apps/web/dist
# Preview mode: only register the mock agent, suppress real agent discovery. # Preview mode: only register the mock agent, suppress real agent discovery.
export KANDEV_MOCK_AGENT=only export KANDEV_MOCK_AGENT=only
# The preview service runs from /app, so the backend's relative dist probes do # The preview service runs from /app, so the backend's relative dist probes do
@@ -139,12 +136,11 @@ export KANDEV_WEB_DIST_DIR=/app/apps/web/dist
# Launch through the CLI so the backend runs under the restart supervisor. # Launch through the CLI so the backend runs under the restart supervisor.
exec kandev start \ exec kandev start \
--backend-port %d \ --backend-port %d \
--web-internal-port %d \
--verbose \ --verbose \
--headless \ --headless \
> /var/log/kandev.log 2>&1 > /var/log/kandev.log 2>&1
STARTSCRIPT STARTSCRIPT
chmod +x /app/start-kandev.sh`, backendPort, ports.Web) chmod +x /app/start-kandev.sh`, backendPort)
} }
// deployService stops any running kandev service, registers (or updates) its // deployService stops any running kandev service, registers (or updates) its
+2 -2
View File
@@ -20,8 +20,8 @@ func TestBuildExtractScript(t *testing.T) {
if !strings.Contains(script, "KANDEV_WEB_DIST_DIR=/app/apps/web/dist") { if !strings.Contains(script, "KANDEV_WEB_DIST_DIR=/app/apps/web/dist") {
t.Errorf("expected KANDEV_WEB_DIST_DIR to point at packaged Vite dist") t.Errorf("expected KANDEV_WEB_DIST_DIR to point at packaged Vite dist")
} }
if !strings.Contains(script, "ln -sf /usr/local/lib/kandev-cli/bin/cli.js /usr/local/bin/kandev") { if !strings.Contains(script, "ln -sf /app/apps/backend/bin/kandev /usr/local/bin/kandev") {
t.Errorf("expected kandev cli symlink in script") t.Errorf("expected native kandev symlink in script")
} }
if !strings.Contains(script, "exec kandev start") { if !strings.Contains(script, "exec kandev start") {
t.Errorf("expected script to launch through kandev start") t.Errorf("expected script to launch through kandev start")
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"fmt" "fmt"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"reflect" "reflect"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"net" "net"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"net/http" "net/http"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"fmt" "fmt"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
settingsstore "github.com/kandev/kandev/internal/agent/settings/store" settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package main package backendapp
import ( import (
"context" "context"
+49
View File
@@ -0,0 +1,49 @@
package launcher
import (
"fmt"
"os"
"path/filepath"
"runtime"
)
type runtimeBundle struct {
Dir string
Launcher string
Source string
}
func resolveRuntimeBundle() (runtimeBundle, error) {
dir := os.Getenv("KANDEV_BUNDLE_DIR")
if dir == "" {
return runtimeBundle{}, fmt.Errorf("no Kandev runtime found; KANDEV_BUNDLE_DIR is not set")
}
return validateRuntimeBundle(dir, "env")
}
func validateRuntimeBundle(dir, source string) (runtimeBundle, error) {
launcher := filepath.Join(dir, "bin", executableName("kandev"))
if !exists(launcher) {
return runtimeBundle{}, fmt.Errorf("launcher binary not found in bundle at %s", launcher)
}
agentctl := filepath.Join(dir, "bin", executableName("agentctl"))
if !exists(agentctl) {
return runtimeBundle{}, fmt.Errorf("agentctl binary not found in bundle at %s", agentctl)
}
agentctlLinuxAMD64 := filepath.Join(dir, "bin", "agentctl-linux-amd64")
if requiresAgentctlLinuxAMD64(runtime.GOOS, runtime.GOARCH) && !exists(agentctlLinuxAMD64) {
return runtimeBundle{}, fmt.Errorf("agentctl linux/amd64 helper not found in bundle at %s", agentctlLinuxAMD64)
}
return runtimeBundle{Dir: dir, Launcher: launcher, Source: source}, nil
}
func requiresAgentctlLinuxAMD64(goos, goarch string) bool {
return goos != "linux" || goarch != "amd64"
}
func executableName(name string) string {
if os.PathSeparator == '\\' {
return name + ".exe"
}
return name
}
@@ -0,0 +1,56 @@
package launcher
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestValidateRuntimeBundleAcceptsSingleBinaryLayout(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "bin", "kandev"))
writeFile(t, filepath.Join(dir, "bin", "agentctl"))
if requiresAgentctlLinuxAMD64(runtime.GOOS, runtime.GOARCH) {
writeFile(t, filepath.Join(dir, "bin", "agentctl-linux-amd64"))
}
bundle, err := validateRuntimeBundle(dir, "test")
if err != nil {
t.Fatal(err)
}
if bundle.Launcher != filepath.Join(dir, "bin", "kandev") {
t.Fatalf("Launcher = %q", bundle.Launcher)
}
}
func TestValidateRuntimeBundleRejectsMissingLauncher(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "bin", "agentctl"))
if _, err := validateRuntimeBundle(dir, "test"); err == nil {
t.Fatal("expected error")
}
}
func TestRequiresAgentctlLinuxAMD64(t *testing.T) {
if requiresAgentctlLinuxAMD64("linux", "amd64") {
t.Fatal("linux/amd64 should use the native agentctl binary")
}
if !requiresAgentctlLinuxAMD64("linux", "arm64") {
t.Fatal("linux/arm64 should require the linux/amd64 helper")
}
if !requiresAgentctlLinuxAMD64("darwin", "arm64") {
t.Fatal("non-linux hosts should require the linux/amd64 helper")
}
}
func writeFile(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,28 @@
package cli
const helpText = `kandev launcher
Usage:
kandev run [--port <port>] [--verbose] [--debug]
kandev start [--port <port>] [--verbose] [--debug]
kandev [--port <port>] [--verbose] [--debug]
kandev service install|uninstall|start|stop|restart|status|logs [--system]
Options:
start Use local production build.
run Use installed runtime bundle (default).
service Manage kandev as an OS service.
--version, -V Print CLI version and exit.
--port Port for the Go backend. Alias for --backend-port.
--verbose, -v Show info logs from backend.
--debug Show debug logs + agent message dumps.
--headless Skip opening the browser. Used by service units.
--help, -h Show help.
Advanced:
--backend-port Same as --port.
`
func Help() string {
return helpText
}
@@ -0,0 +1,19 @@
package cli
import (
"strings"
"testing"
)
func TestHelpDoesNotExposeHiddenBackendMode(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)
}
if strings.Contains(help, "__backend") {
t.Fatalf("help exposes hidden backend mode:\n%s", help)
}
}
@@ -0,0 +1,38 @@
package launcher
import (
"os"
"path/filepath"
"strings"
)
const (
defaultBackendPort = 38429
defaultAgentctlPort = 39429
healthTimeoutReleaseMS = 45000
randomPortMin = 10000
randomPortMax = 60000
)
func resolveHomeDir() string {
if v := strings.TrimSpace(os.Getenv("KANDEV_HOME_DIR")); v != "" {
return v
}
home, err := os.UserHomeDir()
if err != nil {
return ".kandev"
}
return filepath.Join(home, ".kandev")
}
func resolveDataDir() string {
return filepath.Join(resolveHomeDir(), "data")
}
func resolveDatabasePath() string {
if v := strings.TrimSpace(os.Getenv("KANDEV_DATABASE_PATH")); v != "" {
return v
}
return filepath.Join(resolveDataDir(), "kandev.db")
}
@@ -0,0 +1,41 @@
package launcher
import (
"fmt"
"os"
"path/filepath"
)
func ensureDataDir() error {
dir := resolveDataDir()
if err := rejectSymlinkComponents(dir); err != nil {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
if err := os.Chmod(dir, 0o700); err != nil {
return err
}
return nil
}
func rejectSymlinkComponents(target string) error {
abs, err := filepath.Abs(target)
if err != nil {
return err
}
for current := abs; ; current = filepath.Dir(current) {
info, err := os.Lstat(current)
if err == nil && info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("refusing to use symlinked data path component: %s", current)
}
if err != nil && !os.IsNotExist(err) {
return err
}
parent := filepath.Dir(current)
if parent == current {
return nil
}
}
}
@@ -0,0 +1,27 @@
package launcher
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestRejectSymlinkComponentsRejectsParentSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink permissions vary on Windows")
}
root := t.TempDir()
realDir := filepath.Join(root, "real")
if err := os.MkdirAll(realDir, 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "link")
if err := os.Symlink(realDir, link); err != nil {
t.Fatal(err)
}
if err := rejectSymlinkComponents(filepath.Join(link, "data")); err == nil {
t.Fatal("expected symlink component to be rejected")
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package launcher implements the native kandev terminal entrypoint.
//
// Exit codes follow the usual CLI convention: 0 means success, 2 means usage
// or parse errors, and 1 means runtime failures after arguments are accepted.
package launcher
+32
View File
@@ -0,0 +1,32 @@
package launcher
import (
"fmt"
"os"
)
func backendEnv(ports portConfig, logLevel string, debug bool) []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())
if logLevel != "" {
env = upsertEnv(env, "KANDEV_LOG_LEVEL", logLevel)
}
if debug {
env = upsertEnv(env, "KANDEV_DEBUG_AGENT_MESSAGES", "true")
env = upsertEnv(env, "KANDEV_DEBUG_PPROF_ENABLED", "true")
}
return env
}
func upsertEnv(env []string, key, value string) []string {
prefix := key + "="
for i, item := range env {
if len(item) >= len(prefix) && item[:len(prefix)] == prefix {
env[i] = prefix + value
return env
}
}
return append(env, prefix+value)
}
+50
View File
@@ -0,0 +1,50 @@
package launcher
import (
"fmt"
"net/http"
"os"
"strconv"
"time"
)
type childState interface {
Exited() (bool, int)
}
func healthTimeout(defaultMS int) time.Duration {
raw := os.Getenv("KANDEV_HEALTH_TIMEOUT_MS")
if raw == "" {
return time.Duration(defaultMS) * time.Millisecond
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return time.Duration(defaultMS) * time.Millisecond
}
return time.Duration(n) * time.Millisecond
}
func waitForHealth(baseURL string, proc childState, timeout time.Duration, onFailure func()) error {
deadline := time.Now().Add(timeout)
healthURL := baseURL + "/health"
for time.Now().Before(deadline) {
if exited, code := proc.Exited(); exited {
if onFailure != nil {
onFailure()
}
return fmt.Errorf("backend exited (code %d) before healthcheck passed", code)
}
resp, err := http.Get(healthURL) //nolint:gosec,noctx
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
}
time.Sleep(300 * time.Millisecond)
}
if onFailure != nil {
onFailure()
}
return fmt.Errorf("backend healthcheck timed out after %s at %s", timeout, healthURL)
}
@@ -0,0 +1,41 @@
package launcher
import (
"fmt"
"os"
"github.com/kandev/kandev/internal/launcher/cli"
)
type BuildInfo struct {
Version string
Commit string
BuildTime string
}
func Run(args []string, build BuildInfo) int {
if len(args) > 0 && args[0] == "service" {
return runService(args[1:], build)
}
opts, err := parseArgs(args)
if err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 2
}
if opts.ShowVersion {
fmt.Println(build.Version)
return 0
}
if opts.ShowHelp {
fmt.Print(cli.Help())
return 0
}
switch opts.Command {
case CommandStart:
return runStart(opts)
case CommandRun:
return runInstalled(opts)
}
fmt.Fprintf(os.Stderr, "[kandev] native launcher command %q is not implemented yet\n", opts.Command)
return 1
}
+146
View File
@@ -0,0 +1,146 @@
package launcher
import (
"fmt"
"strconv"
"strings"
)
type Command string
const (
CommandRun Command = "run"
CommandStart Command = "start"
)
type Options struct {
Command Command
BackendPort int
Verbose bool
Debug bool
ShowVersion bool
Headless bool
ShowHelp bool
}
type ParseError struct {
Message string
}
func (e ParseError) Error() string {
return e.Message
}
func parseArgs(argv []string) (Options, error) {
opts := Options{Command: CommandRun}
for i := 0; i < len(argv); i++ {
next, err := parseArgAt(argv, i, &opts)
if err != nil {
return opts, err
}
i = next
}
return opts, nil
}
func parseArgAt(argv []string, i int, opts *Options) (int, error) {
arg := argv[i]
if parseSimpleArg(arg, opts) {
return i, nil
}
if isRuntimeVersionArg(arg) {
return i, ParseError{Message: "--runtime-version is not supported by the native launcher"}
}
if next, ok, err := parsePortArg(argv, i, opts); ok || err != nil {
return next, err
}
return i, ParseError{Message: fmt.Sprintf("unknown argument %q", arg)}
}
func parseSimpleArg(arg string, opts *Options) bool {
return parseCommandArg(arg, opts) || parseBooleanFlag(arg, opts)
}
func parseCommandArg(arg string, opts *Options) bool {
switch arg {
case "run":
opts.Command = CommandRun
case string(CommandStart):
opts.Command = CommandStart
default:
return false
}
return true
}
func parseBooleanFlag(arg string, opts *Options) bool {
switch arg {
case "--help", "-h", "help":
opts.ShowHelp = true
case "--version", "-V":
opts.ShowVersion = true
case "--verbose", "-v":
opts.Verbose = true
case "--debug":
opts.Debug = true
case "--headless", "--no-browser":
opts.Headless = true
default:
return false
}
return true
}
func isRuntimeVersionArg(arg string) bool {
return arg == "--runtime-version" || strings.HasPrefix(arg, "--runtime-version=")
}
func parsePortArg(argv []string, i int, opts *Options) (int, bool, error) {
arg := argv[i]
switch {
case arg == "--port" || arg == "--backend-port":
next, err := parseBackendPort(argv, i, opts)
return next, true, err
case strings.HasPrefix(arg, "--port="):
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)
default:
return i, false, nil
}
}
func parseBackendPort(argv []string, i int, opts *Options) (int, error) {
v, err := takeValue(argv, i, argv[i])
if err != nil {
return i, err
}
if err := parseBackendPortValue(argv[i], v, opts); err != nil {
return i, err
}
return i + 1, nil
}
func parseBackendPortValue(flag, value string, opts *Options) error {
port, err := parsePort(value, flag)
if err != nil {
return err
}
opts.BackendPort = port
return nil
}
func takeValue(argv []string, i int, flag string) (string, error) {
if i+1 >= len(argv) || strings.HasPrefix(argv[i+1], "-") {
return "", ParseError{Message: fmt.Sprintf("%s requires a value", flag)}
}
return argv[i+1], nil
}
func parsePort(raw, flag string) (int, error) {
n, err := strconv.Atoi(raw)
if raw == "" || err != nil || n < 1 || n > 65535 {
return 0, ParseError{Message: fmt.Sprintf("%s value must be an integer between 1 and 65535, got %q", flag, raw)}
}
return n, nil
}
@@ -0,0 +1,59 @@
package launcher
import "testing"
func TestParseArgsStartPortsAndHeadless(t *testing.T) {
opts, err := parseArgs([]string{"start", "--port", "1234", "--headless"})
if err != nil {
t.Fatal(err)
}
if opts.Command != CommandStart {
t.Fatalf("Command = %q, want %q", opts.Command, CommandStart)
}
if opts.BackendPort != 1234 || !opts.Headless {
t.Fatalf("parsed options = %+v", opts)
}
}
func TestParseArgsRejectsInvalidPort(t *testing.T) {
_, err := parseArgs([]string{"--port", "70000"})
if err == nil {
t.Fatal("expected error")
}
}
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"}} {
_, 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)
}
}
}
func TestParseArgsRejectsUnsupportedRuntimeVersion(t *testing.T) {
for _, argv := range [][]string{
{"--runtime-version", "v1.2.3"},
{"--runtime-version=v1.2.3"},
{"--runtime-version"},
{"--runtime-version="},
} {
_, err := parseArgs(argv)
if err == nil {
t.Fatalf("parseArgs(%v) returned nil error", argv)
}
if err.Error() != "--runtime-version is not supported by the native launcher" {
t.Fatalf("parseArgs(%v) error = %q", argv, err)
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package launcher
import (
"fmt"
"math/rand"
"net"
"os"
"strconv"
"time"
)
type portConfig struct {
BackendPort int
AgentctlPort int
BackendURL string
}
func resolvePorts(opts Options) (int, error) {
backend := opts.BackendPort
if backend == 0 {
if p, err := envPort("KANDEV_BACKEND_PORT"); err != nil {
return 0, err
} else if p != 0 {
backend = p
} else if p, err := envPort("KANDEV_PORT"); err != nil {
return 0, err
} else {
backend = p
}
}
return backend, nil
}
func envPort(name string) (int, error) {
raw, ok := os.LookupEnv(name)
if !ok {
return 0, nil
}
n, err := strconv.Atoi(raw)
if raw == "" || err != nil || n < 1 || n > 65535 {
return 0, ParseError{Message: fmt.Sprintf("%s must be an integer between 1 and 65535, got %q", name, raw)}
}
return n, nil
}
func pickPorts(backendPort int) (portConfig, error) {
used := map[int]bool{}
backend := backendPort
if backend == 0 {
p, err := pickAvailablePortExcept(defaultBackendPort, used)
if err != nil {
return portConfig{}, err
}
backend = p
}
used[backend] = true
agentctl, err := pickAvailablePortExcept(defaultAgentctlPort, used)
if err != nil {
return portConfig{}, err
}
return portConfig{
BackendPort: backend,
AgentctlPort: agentctl,
BackendURL: fmt.Sprintf("http://localhost:%d", backend),
}, nil
}
func pickAvailablePortExcept(preferred int, used map[int]bool) (int, error) {
if canBind(preferred) {
if !used[preferred] {
return preferred, nil
}
}
r := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < 10; i++ {
candidate := randomPortMin + r.Intn(randomPortMax-randomPortMin+1)
if !used[candidate] && canBind(candidate) {
return candidate, nil
}
}
return 0, fmt.Errorf("unable to find a free port")
}
func canBind(port int) bool {
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
return false
}
_ = ln.Close()
return true
}
@@ -0,0 +1,13 @@
package launcher
import "testing"
func TestPickAvailablePortExceptSkipsUsedPreferredPort(t *testing.T) {
port, err := pickAvailablePortExcept(defaultBackendPort, map[int]bool{defaultBackendPort: true})
if err != nil {
t.Fatal(err)
}
if port == defaultBackendPort {
t.Fatalf("picked reserved preferred port %d", port)
}
}
+151
View File
@@ -0,0 +1,151 @@
package launcher
import (
"fmt"
"os"
"os/exec"
"os/signal"
"sync"
"syscall"
)
const capturedOutputLimit = 64 * 1024
type processSupervisor struct {
mu sync.Mutex
children []*managedProcess
}
type managedProcess struct {
cmd *exec.Cmd
exitCode int
exited bool
mu sync.Mutex
done chan struct{}
}
func newSupervisor() *processSupervisor {
return &processSupervisor{}
}
func (s *processSupervisor) add(proc *managedProcess) {
s.mu.Lock()
defer s.mu.Unlock()
s.children = append(s.children, proc)
}
func (s *processSupervisor) shutdown(reason string) {
fmt.Printf("[kandev] shutting down (%s)...\n", reason)
s.mu.Lock()
children := append([]*managedProcess(nil), s.children...)
s.mu.Unlock()
for _, child := range children {
child.kill()
}
}
func (s *processSupervisor) attachSignals() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
go func() {
sig := <-ch
s.shutdown("signal " + sig.String())
os.Exit(0)
}()
}
func startProcess(command string, args []string, cwd string, env []string, quiet bool, label string, supervisor *processSupervisor) (*managedProcess, func(), error) {
cmd := exec.Command(command, args...)
cmd.Dir = cwd
cmd.Env = env
cmd.Stdin = nil
configureManagedProcess(cmd)
stdout := newLimitedBuffer(capturedOutputLimit)
if quiet {
cmd.Stdout = stdout
cmd.Stderr = os.Stderr
} else {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
if err := cmd.Start(); err != nil {
return nil, nil, err
}
proc := &managedProcess{cmd: cmd, done: make(chan struct{})}
supervisor.add(proc)
go func() {
err := cmd.Wait()
code := 0
if err != nil {
code = 1
if exitErr, ok := err.(*exec.ExitError); ok {
code = exitErr.ExitCode()
}
}
proc.mu.Lock()
proc.exitCode = code
proc.exited = true
proc.mu.Unlock()
close(proc.done)
if label != "" && code != 0 {
fmt.Fprintf(os.Stderr, "[kandev] %s exited (code=%d)\n", label, code)
}
}()
return proc, func() {
snapshot := stdout.Bytes()
if len(snapshot) == 0 {
return
}
fmt.Fprintln(os.Stderr, "[kandev] --- backend stdout (last captured output) ---")
_, _ = os.Stderr.Write(snapshot)
fmt.Fprintln(os.Stderr, "[kandev] --- end backend stdout ---")
}, nil
}
type limitedBuffer struct {
mu sync.Mutex
limit int
buf []byte
}
func newLimitedBuffer(limit int) *limitedBuffer {
return &limitedBuffer{limit: limit}
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
b.buf = append(b.buf, p...)
if len(b.buf) > b.limit {
b.buf = append([]byte(nil), b.buf[len(b.buf)-b.limit:]...)
}
return len(p), nil
}
func (b *limitedBuffer) Bytes() []byte {
b.mu.Lock()
defer b.mu.Unlock()
return append([]byte(nil), b.buf...)
}
func (p *managedProcess) Exited() (bool, int) {
p.mu.Lock()
defer p.mu.Unlock()
return p.exited, p.exitCode
}
func (p *managedProcess) kill() {
if p.cmd.Process == nil {
return
}
if err := killManagedProcessGroup(p.cmd.Process.Pid); err != nil {
_ = p.cmd.Process.Kill()
}
<-p.done
}
func waitForAppExit(supervisor *processSupervisor, backend *restartableBackend) int {
code := <-backend.exitCh
supervisor.shutdown("backend exit")
return code
}
@@ -0,0 +1,15 @@
//go:build !linux && !darwin
package launcher
import (
"errors"
"os/exec"
)
func configureManagedProcess(_ *exec.Cmd) {
}
func killManagedProcessGroup(_ int) error {
return errors.New("process groups are not supported on this platform")
}
@@ -0,0 +1,16 @@
//go:build linux || darwin
package launcher
import (
"os/exec"
"syscall"
)
func configureManagedProcess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
func killManagedProcessGroup(pid int) error {
return syscall.Kill(-pid, syscall.SIGKILL)
}
@@ -0,0 +1,17 @@
//go:build linux || darwin
package launcher
import (
"os/exec"
"testing"
)
func TestConfigureManagedProcessCreatesProcessGroup(t *testing.T) {
cmd := exec.Command("kandev")
configureManagedProcess(cmd)
if cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setpgid {
t.Fatalf("configureManagedProcess should set Setpgid")
}
}
@@ -0,0 +1,33 @@
package launcher
import (
"bytes"
"testing"
)
func TestLimitedBufferKeepsOnlyTail(t *testing.T) {
buf := newLimitedBuffer(5)
if _, err := buf.Write([]byte("hello")); err != nil {
t.Fatal(err)
}
if _, err := buf.Write([]byte("world")); err != nil {
t.Fatal(err)
}
if got := string(buf.Bytes()); got != "world" {
t.Fatalf("buffer tail = %q, want world", got)
}
}
func TestLimitedBufferBytesReturnsSnapshot(t *testing.T) {
buf := newLimitedBuffer(10)
if _, err := buf.Write([]byte("abc")); err != nil {
t.Fatal(err)
}
snapshot := buf.Bytes()
snapshot[0] = 'x'
if !bytes.Equal(buf.Bytes(), []byte("abc")) {
t.Fatalf("mutating snapshot changed buffer: %q", buf.Bytes())
}
}
+44
View File
@@ -0,0 +1,44 @@
package launcher
import (
"fmt"
"os"
"path/filepath"
)
func runInstalled(opts Options) int {
backendPort, err := resolvePorts(opts)
if err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 2
}
ports, err := pickPorts(backendPort)
if err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
bundle, err := resolveRuntimeBundle()
if err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
if err := ensureDataDir(); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
logLevel := resolveLogLevel(opts)
releaseTag := os.Getenv("KANDEV_VERSION")
if releaseTag == "" {
releaseTag = "(" + bundle.Source + ")"
}
return launchManaged(managedAppConfig{
Header: "release: " + releaseTag,
Mode: "run",
Backend: bundle.Launcher,
BackendCWD: filepath.Dir(bundle.Launcher),
Ports: ports,
LogLevel: logLevel,
Opts: opts,
})
}
+609
View File
@@ -0,0 +1,609 @@
package launcher
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strings"
)
type serviceArgs struct {
Action string
System bool
Port int
HomeDir string
NoBootStart bool
Follow bool
ShowHelp bool
}
const (
actionInstall = "install"
actionUninstall = "uninstall"
actionRestart = "restart"
actionConfig = "config"
actionLogs = "logs"
actionStatus = "status"
actionStop = "stop"
flagHelp = "--help"
goosLinux = "linux"
goosDarwin = "darwin"
managedMarker = "managed by kandev"
serviceUnitName = "kandev.service"
)
const serviceHelp = `kandev service — install kandev as an OS-managed service
Usage:
kandev service install [--system] [--port <port>] [--home-dir <path>] [--no-boot-start]
kandev service uninstall [--system]
kandev service start|stop|restart|status [--system]
kandev service logs [-f] [--system]
kandev service config [--system]
`
var (
executeServiceCommand = defaultRunCommand
servicePrintln = func(message string) { fmt.Println(message) }
)
func runService(argv []string, build BuildInfo) int {
args, err := parseServiceArgs(argv)
if err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 2
}
if args.ShowHelp {
fmt.Print(serviceHelp)
return 0
}
if args.Action == actionConfig {
printServiceConfig(args)
return 0
}
switch runtime.GOOS {
case goosLinux:
return runLinuxService(args, build)
case goosDarwin:
return runLaunchdService(args, build)
default:
fmt.Fprintf(os.Stderr, "[kandev] service is not supported on %s\n", runtime.GOOS)
return 1
}
}
func parseServiceArgs(argv []string) (serviceArgs, error) {
if len(argv) == 0 || argv[0] == flagHelp || argv[0] == "-h" {
return serviceArgs{Action: actionInstall, ShowHelp: true}, nil
}
action := argv[0]
if !validServiceAction(action) {
return serviceArgs{}, ParseError{Message: fmt.Sprintf("unknown service action %q", action)}
}
out := serviceArgs{Action: action}
for i := 1; i < len(argv); i++ {
next, err := parseServiceFlag(argv, i, action, &out)
if err != nil {
return out, err
}
i = next
}
return out, nil
}
func validServiceAction(action string) bool {
switch action {
case actionInstall, actionUninstall, string(CommandStart), actionStop, actionRestart, actionStatus, actionLogs, actionConfig:
return true
default:
return false
}
}
func parseServiceFlag(argv []string, i int, action string, out *serviceArgs) (int, error) {
arg := argv[i]
switch {
case arg == flagHelp || arg == "-h":
out.ShowHelp = true
case arg == "--system":
out.System = true
case arg == "--no-boot-start":
out.NoBootStart = true
case arg == "-f" || arg == "--follow":
out.Follow = true
case arg == "--port":
return parseServicePort(argv, i, out)
case strings.HasPrefix(arg, "--port="):
return i, parseServicePortValue(strings.TrimPrefix(arg, "--port="), out)
case arg == "--home-dir":
return parseServiceHomeDir(argv, i, out)
case strings.HasPrefix(arg, "--home-dir="):
out.HomeDir = expandHome(strings.TrimPrefix(arg, "--home-dir="))
default:
return i, ParseError{Message: fmt.Sprintf("unknown flag %q for kandev service %s", arg, action)}
}
return i, nil
}
func parseServicePort(argv []string, i int, out *serviceArgs) (int, error) {
v, err := takeValue(argv, i, "--port")
if err != nil {
return i, err
}
if err := parseServicePortValue(v, out); err != nil {
return i, err
}
return i + 1, nil
}
func parseServicePortValue(value string, out *serviceArgs) error {
port, err := parsePort(value, "--port")
if err != nil {
return err
}
out.Port = port
return nil
}
func parseServiceHomeDir(argv []string, i int, out *serviceArgs) (int, error) {
v, err := takeValue(argv, i, "--home-dir")
if err != nil {
return i, err
}
out.HomeDir = expandHome(v)
return i + 1, nil
}
func runLinuxService(args serviceArgs, build BuildInfo) int {
unitPath := linuxUnitPath(args.System)
switch args.Action {
case actionInstall:
return installSystemd(args, build, unitPath)
case actionUninstall:
_ = runCommand("systemctl", append(systemctlScope(args.System), "disable", "--now", serviceUnitName)...)
_ = os.Remove(unitPath)
_ = runCommand("systemctl", append(systemctlScope(args.System), "daemon-reload")...)
return 0
case string(CommandStart), actionStop, actionRestart, actionStatus:
if err := runCommand("systemctl", append(systemctlScope(args.System), args.Action, serviceUnitName)...); err != nil {
return 1
}
return 0
case actionLogs:
if err := runCommand("journalctl", buildJournalArgs(args)...); err != nil {
return 1
}
return 0
}
return 1
}
func buildJournalArgs(args serviceArgs) []string {
journalArgs := []string{"-n", "200", "--no-pager"}
if args.System {
journalArgs = append([]string{"-u", serviceUnitName}, journalArgs...)
} else {
journalArgs = append([]string{"--user-unit", serviceUnitName}, journalArgs...)
}
if args.Follow {
journalArgs = journalArgs[:len(journalArgs)-1]
journalArgs = append(journalArgs, "-f")
}
return journalArgs
}
func installSystemd(args serviceArgs, build BuildInfo, unitPath string) int {
self, err := executablePath()
if err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
homeDir := serviceHomeDir(args)
logDir := filepath.Join(homeDir, "logs")
if err := os.MkdirAll(filepath.Dir(unitPath), 0o755); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
unit := renderSystemdUnit(nativeServiceUnitInput{
Executable: self,
HomeDir: homeDir,
LogDir: logDir,
Port: args.Port,
System: args.System,
SystemUser: serviceUser(args.System),
BundleDir: serviceBundleDir(self),
Version: serviceVersion(build.Version),
})
if err := backupUnmanagedServiceFile(unitPath); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
if err := runCommand("systemctl", append(systemctlScope(args.System), "daemon-reload")...); err != nil {
return 1
}
if args.NoBootStart {
if err := runCommand("systemctl", append(systemctlScope(args.System), string(CommandStart), serviceUnitName)...); err != nil {
return 1
}
servicePrintln("[kandev] service installed and started (boot-start disabled)")
return 0
}
if err := runCommand("systemctl", append(systemctlScope(args.System), "enable", "--now", serviceUnitName)...); err != nil {
return 1
}
servicePrintln("[kandev] service enabled and started")
return 0
}
func runLaunchdService(args serviceArgs, build BuildInfo) int {
plistPath := launchdPlistPath(args.System)
target := "gui/" + fmt.Sprint(os.Getuid()) + "/com.kdlbs.kandev"
domain := "gui/" + fmt.Sprint(os.Getuid())
if args.System {
target = "system/com.kdlbs.kandev"
domain = "system"
}
switch args.Action {
case actionInstall:
return installLaunchd(args, build, plistPath, target, domain)
case "uninstall":
_ = runCommand("launchctl", "bootout", target)
_ = os.Remove(plistPath)
return 0
case "start":
if err := runCommand("launchctl", "kickstart", target); err != nil {
return runCommandExit("launchctl", "bootstrap", domain, plistPath)
}
return 0
case "stop":
_ = runCommand("launchctl", "bootout", target)
return 0
case actionRestart:
if err := runCommand("launchctl", "kickstart", "-k", target); err != nil {
_ = runCommand("launchctl", "bootout", target)
return runCommandExit("launchctl", "bootstrap", domain, plistPath)
}
return 0
case "status":
return runCommandExit("launchctl", "print", target)
case "logs":
logDir := filepath.Join(serviceHomeDir(args), "logs")
tailArgs := []string{"-n", "200", filepath.Join(logDir, "service.out"), filepath.Join(logDir, "service.err")}
if args.Follow {
tailArgs = []string{"-f", "-n", "200", filepath.Join(logDir, "service.out"), filepath.Join(logDir, "service.err")}
}
return runCommandExit("tail", tailArgs...)
}
return 1
}
func installLaunchd(args serviceArgs, build BuildInfo, plistPath, target, domain string) int {
self, err := executablePath()
if err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
homeDir := serviceHomeDir(args)
logDir := filepath.Join(homeDir, "logs")
if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
if err := os.MkdirAll(logDir, 0o755); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
plist := renderLaunchdPlist(nativeServiceUnitInput{
Executable: self,
HomeDir: homeDir,
LogDir: logDir,
Port: args.Port,
System: args.System,
SystemUser: serviceUser(args.System),
NoBootStart: args.NoBootStart,
BundleDir: serviceBundleDir(self),
Version: serviceVersion(build.Version),
})
if err := backupUnmanagedServiceFile(plistPath); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
_ = runCommand("launchctl", "bootout", target)
if err := runCommand("launchctl", "bootstrap", domain, plistPath); err != nil {
return 1
}
_ = runCommand("launchctl", "enable", target)
if args.NoBootStart {
if err := runCommand("launchctl", "kickstart", target); err != nil {
return 1
}
servicePrintln("[kandev] service loaded and started (boot-start disabled)")
return 0
}
servicePrintln("[kandev] service loaded, enabled, and started")
return 0
}
type nativeServiceUnitInput struct {
Executable string
HomeDir string
LogDir string
Port int
System bool
SystemUser string
NoBootStart bool
BundleDir string
Version string
}
func renderSystemdUnit(input nativeServiceUnitInput) string {
env := []string{
serviceEnvLine("KANDEV_HOME_DIR", input.HomeDir),
serviceEnvLine("KANDEV_LOG_LEVEL", "info"),
serviceEnvLineAllowSpecifiers("PATH", "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin:%h/.local/bin:%h/.bun/bin:%h/.opencode/bin"),
}
if input.Port != 0 {
env = append(env, serviceEnvLine("KANDEV_SERVER_PORT", fmt.Sprint(input.Port)))
}
if input.BundleDir != "" {
env = append(env, serviceEnvLine("KANDEV_BUNDLE_DIR", input.BundleDir))
}
if input.Version != "" {
env = append(env, serviceEnvLine("KANDEV_VERSION", input.Version))
}
userLine := ""
wantedBy := "default.target"
if input.System {
wantedBy = "multi-user.target"
if input.SystemUser != "" {
userLine = "User=" + input.SystemUser + "\n"
}
}
return "# managed by kandev — regenerated by `kandev service install`\n" +
"[Unit]\nDescription=Kandev autonomous agent platform\nAfter=network-online.target\nWants=network-online.target\n\n" +
"[Service]\nType=simple\nExecStart=" + quoteForUnit(input.Executable) + " --headless\n" +
userLine + strings.Join(env, "\n") + "\nRestart=on-failure\nRestartSec=5s\nKillMode=mixed\nTimeoutStopSec=30s\n\n" +
"[Install]\nWantedBy=" + wantedBy + "\n"
}
func renderLaunchdPlist(input nativeServiceUnitInput) string {
envEntries := [][2]string{{"KANDEV_HOME_DIR", input.HomeDir}, {"KANDEV_LOG_LEVEL", "info"}, {"PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"}}
if input.Port != 0 {
envEntries = append(envEntries, [2]string{"KANDEV_SERVER_PORT", fmt.Sprint(input.Port)})
}
if input.BundleDir != "" {
envEntries = append(envEntries, [2]string{"KANDEV_BUNDLE_DIR", input.BundleDir})
}
if input.Version != "" {
envEntries = append(envEntries, [2]string{"KANDEV_VERSION", input.Version})
}
var envXML strings.Builder
for _, entry := range envEntries {
envXML.WriteString(" <key>" + escapeXML(entry[0]) + "</key>\n <string>" + escapeXML(entry[1]) + "</string>\n")
}
userBlock := ""
if input.System && input.SystemUser != "" {
userBlock = " <key>UserName</key>\n <string>" + escapeXML(input.SystemUser) + "</string>\n"
}
return `<?xml version="1.0" encoding="UTF-8"?>
<!-- managed by kandev — regenerated by ` + "`kandev service install`" + ` -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.kdlbs.kandev</string>
<key>ProgramArguments</key>
<array>
<string>` + escapeXML(input.Executable) + `</string>
<string>--headless</string>
</array>
<key>EnvironmentVariables</key>
<dict>
` + envXML.String() + ` </dict>
` + userBlock + ` <key>RunAtLoad</key>
` + plistBool(!input.NoBootStart) + `
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>` + escapeXML(filepath.Join(input.LogDir, "service.out")) + `</string>
<key>StandardErrorPath</key>
<string>` + escapeXML(filepath.Join(input.LogDir, "service.err")) + `</string>
<key>WorkingDirectory</key>
<string>` + escapeXML(input.HomeDir) + `</string>
</dict>
</plist>
`
}
func plistBool(value bool) string {
if value {
return "<true/>"
}
return "<false/>"
}
func serviceEnvLine(key, value string) string {
return "Environment=" + quoteForUnit(key+"="+value)
}
func serviceEnvLineAllowSpecifiers(key, value string) string {
return "Environment=" + quoteForUnitAllowSpecifiers(key+"="+value)
}
func quoteForUnit(value string) string {
return quoteForUnitValue(value, true)
}
func quoteForUnitAllowSpecifiers(value string) string {
return quoteForUnitValue(value, false)
}
func quoteForUnitValue(value string, escapeSystemdSpecifiers bool) string {
chars := " \\\"$"
if escapeSystemdSpecifiers {
chars += "%"
}
if !strings.ContainsAny(value, chars) {
return value
}
replacements := []string{"\\", "\\\\", "$", "$$", "\"", "\\\""}
if escapeSystemdSpecifiers {
replacements = append(replacements, "%", "%%")
}
escaped := strings.NewReplacer(replacements...).Replace(value)
return `"` + escaped + `"`
}
func serviceBundleDir(executable string) string {
if v := os.Getenv("KANDEV_BUNDLE_DIR"); v != "" {
return v
}
return filepath.Dir(filepath.Dir(executable))
}
func serviceVersion(buildVersion string) string {
if v := os.Getenv("KANDEV_VERSION"); v != "" {
return v
}
return buildVersion
}
func backupUnmanagedServiceFile(path string) error {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.IsDir() {
return fmt.Errorf("%s is a directory", path)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
if strings.Contains(string(data), managedMarker) {
return nil
}
backupPath := path + ".bak"
if err := os.WriteFile(backupPath, data, info.Mode().Perm()); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "[kandev] WARNING: existing unmanaged service file backed up to %s\n", backupPath)
return nil
}
func escapeXML(value string) string {
replacer := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
return replacer.Replace(value)
}
func linuxUnitPath(system bool) string {
if system {
return "/etc/systemd/system/kandev.service"
}
return filepath.Join(mustHomeDir(), ".config", "systemd", "user", serviceUnitName)
}
func launchdPlistPath(system bool) string {
if system {
return "/Library/LaunchDaemons/com.kdlbs.kandev.plist"
}
return filepath.Join(mustHomeDir(), "Library", "LaunchAgents", "com.kdlbs.kandev.plist")
}
func systemctlScope(system bool) []string {
if system {
return nil
}
return []string{"--user"}
}
func serviceHomeDir(args serviceArgs) string {
if args.HomeDir != "" {
return args.HomeDir
}
if args.System {
return "/var/lib/kandev"
}
return resolveHomeDir()
}
func serviceUser(system bool) string {
if !system {
return ""
}
if sudo := os.Getenv("SUDO_USER"); sudo != "" && sudo != "root" {
return sudo
}
u, err := user.Current()
if err != nil {
return ""
}
return u.Username
}
func printServiceConfig(args serviceArgs) {
fmt.Println("manager:", runtime.GOOS)
fmt.Println("mode:", map[bool]string{true: "system", false: "user"}[args.System])
fmt.Println("home:", serviceHomeDir(args))
if runtime.GOOS == goosLinux {
fmt.Println("unit:", linuxUnitPath(args.System))
}
if runtime.GOOS == goosDarwin {
fmt.Println("plist:", launchdPlistPath(args.System))
}
}
func expandHome(path string) string {
if path == "~" {
return mustHomeDir()
}
if strings.HasPrefix(path, "~/") {
return filepath.Join(mustHomeDir(), strings.TrimPrefix(path, "~/"))
}
abs, err := filepath.Abs(path)
if err != nil {
return path
}
return abs
}
func mustHomeDir() string {
home, err := os.UserHomeDir()
if err != nil {
return "."
}
return home
}
func runCommand(name string, args ...string) error {
return executeServiceCommand(name, args...)
}
func defaultRunCommand(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
func runCommandExit(name string, args ...string) int {
if err := runCommand(name, args...); err != nil {
return 1
}
return 0
}
@@ -0,0 +1,324 @@
package launcher
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestRenderSystemdUnitExecsNativeKandev(t *testing.T) {
unit := renderSystemdUnit(nativeServiceUnitInput{
Executable: "/opt/kandev/bin/kandev",
HomeDir: "/home/alice/.kandev",
LogDir: "/home/alice/.kandev/logs",
Port: 1234,
})
if !strings.Contains(unit, "ExecStart=/opt/kandev/bin/kandev --headless") {
t.Fatalf("unit does not exec native kandev:\n%s", unit)
}
if strings.Contains(unit, "cli.js") || strings.Contains(unit, "node ") {
t.Fatalf("unit contains Node CLI launcher:\n%s", unit)
}
if !strings.Contains(unit, "Environment=KANDEV_SERVER_PORT=1234") {
t.Fatalf("unit missing port env:\n%s", unit)
}
}
func TestRenderSystemdUnitIncludesBundleMetadata(t *testing.T) {
unit := renderSystemdUnit(nativeServiceUnitInput{
Executable: "/opt/kandev/bin/kandev",
HomeDir: "/home/alice/.kandev",
LogDir: "/home/alice/.kandev/logs",
BundleDir: "/opt/kandev",
Version: "1.2.3",
})
if !strings.Contains(unit, "Environment=KANDEV_BUNDLE_DIR=/opt/kandev") {
t.Fatalf("unit missing bundle dir env:\n%s", unit)
}
if !strings.Contains(unit, "Environment=KANDEV_VERSION=1.2.3") {
t.Fatalf("unit missing version env:\n%s", unit)
}
}
func TestRenderLaunchdPlistExecsNativeKandev(t *testing.T) {
plist := renderLaunchdPlist(nativeServiceUnitInput{
Executable: "/opt/kandev/bin/kandev",
HomeDir: "/Users/alice/.kandev",
LogDir: "/Users/alice/.kandev/logs",
})
if !strings.Contains(plist, "<string>/opt/kandev/bin/kandev</string>") {
t.Fatalf("plist does not exec native kandev:\n%s", plist)
}
if strings.Contains(plist, "cli.js") {
t.Fatalf("plist contains Node CLI launcher:\n%s", plist)
}
if !strings.Contains(plist, "<key>RunAtLoad</key>\n <true/>") {
t.Fatalf("plist should start at load by default:\n%s", plist)
}
}
func TestRenderLaunchdPlistIncludesBundleMetadata(t *testing.T) {
plist := renderLaunchdPlist(nativeServiceUnitInput{
Executable: "/opt/kandev/bin/kandev",
HomeDir: "/Users/alice/.kandev",
LogDir: "/Users/alice/.kandev/logs",
BundleDir: "/opt/kandev",
Version: "1.2.3",
})
if !strings.Contains(plist, "<key>KANDEV_BUNDLE_DIR</key>\n <string>/opt/kandev</string>") {
t.Fatalf("plist missing bundle dir env:\n%s", plist)
}
if !strings.Contains(plist, "<key>KANDEV_VERSION</key>\n <string>1.2.3</string>") {
t.Fatalf("plist missing version env:\n%s", plist)
}
}
func TestRenderLaunchdPlistCanDisableBootStart(t *testing.T) {
plist := renderLaunchdPlist(nativeServiceUnitInput{
Executable: "/opt/kandev/bin/kandev",
HomeDir: "/Users/alice/.kandev",
LogDir: "/Users/alice/.kandev/logs",
NoBootStart: true,
})
if !strings.Contains(plist, "<key>RunAtLoad</key>\n <false/>") {
t.Fatalf("plist should not start at load with no boot start:\n%s", plist)
}
}
func TestInstallSystemdMessagesDistinguishBootStart(t *testing.T) {
tests := []struct {
name string
noBootStart bool
wantMessage string
wantEnableNow bool
wantStartAction bool
}{
{
name: "default install",
wantMessage: "[kandev] service enabled and started",
wantEnableNow: true,
},
{
name: "no boot start",
noBootStart: true,
wantMessage: "[kandev] service installed and started (boot-start disabled)",
wantStartAction: true,
},
}
originalExecutablePath := executablePath
originalExecuteServiceCommand := executeServiceCommand
originalServicePrintln := servicePrintln
t.Cleanup(func() {
executablePath = originalExecutablePath
executeServiceCommand = originalExecuteServiceCommand
servicePrintln = originalServicePrintln
})
executablePath = func() (string, error) {
return "/opt/kandev/bin/kandev", nil
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var commands []string
var messages []string
executeServiceCommand = func(name string, args ...string) error {
commands = append(commands, name+" "+strings.Join(args, " "))
return nil
}
servicePrintln = func(message string) {
messages = append(messages, message)
}
tmp := t.TempDir()
code := installSystemd(
serviceArgs{
Action: actionInstall,
HomeDir: filepath.Join(tmp, "home"),
NoBootStart: tt.noBootStart,
},
BuildInfo{Version: "test"},
filepath.Join(tmp, "kandev.service"),
)
if code != 0 {
t.Fatalf("installSystemd() = %d, want 0", code)
}
if !slices.Equal(messages, []string{tt.wantMessage}) {
t.Fatalf("messages = %v, want %q", messages, tt.wantMessage)
}
gotEnableNow := slices.Contains(commands, "systemctl --user enable --now kandev.service")
if gotEnableNow != tt.wantEnableNow {
t.Fatalf("enable --now command presence = %v, want %v; commands=%v", gotEnableNow, tt.wantEnableNow, commands)
}
gotStartAction := slices.Contains(commands, "systemctl --user start kandev.service")
if gotStartAction != tt.wantStartAction {
t.Fatalf("start command presence = %v, want %v; commands=%v", gotStartAction, tt.wantStartAction, commands)
}
})
}
}
func TestInstallLaunchdMessagesDistinguishBootStart(t *testing.T) {
tests := []struct {
name string
noBootStart bool
wantMessage string
wantKickstart bool
}{
{
name: "default install",
wantMessage: "[kandev] service loaded, enabled, and started",
wantKickstart: false,
},
{
name: "no boot start",
noBootStart: true,
wantMessage: "[kandev] service loaded and started (boot-start disabled)",
wantKickstart: true,
},
}
originalExecutablePath := executablePath
originalExecuteServiceCommand := executeServiceCommand
originalServicePrintln := servicePrintln
t.Cleanup(func() {
executablePath = originalExecutablePath
executeServiceCommand = originalExecuteServiceCommand
servicePrintln = originalServicePrintln
})
executablePath = func() (string, error) {
return "/opt/kandev/bin/kandev", nil
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var commands []string
var messages []string
executeServiceCommand = func(name string, args ...string) error {
commands = append(commands, name+" "+strings.Join(args, " "))
return nil
}
servicePrintln = func(message string) {
messages = append(messages, message)
}
tmp := t.TempDir()
code := installLaunchd(
serviceArgs{
Action: actionInstall,
HomeDir: filepath.Join(tmp, "home"),
NoBootStart: tt.noBootStart,
},
BuildInfo{Version: "test"},
filepath.Join(tmp, "com.kdlbs.kandev.plist"),
"gui/501/com.kdlbs.kandev",
"gui/501",
)
if code != 0 {
t.Fatalf("installLaunchd() = %d, want 0", code)
}
if !slices.Equal(messages, []string{tt.wantMessage}) {
t.Fatalf("messages = %v, want %q", messages, tt.wantMessage)
}
gotKickstart := slices.Contains(commands, "launchctl kickstart gui/501/com.kdlbs.kandev")
if gotKickstart != tt.wantKickstart {
t.Fatalf("kickstart command presence = %v, want %v; commands=%v", gotKickstart, tt.wantKickstart, commands)
}
})
}
}
func TestBuildJournalArgs(t *testing.T) {
tests := []struct {
name string
args serviceArgs
want []string
}{
{
name: "user logs",
args: serviceArgs{Action: actionLogs},
want: []string{"--user-unit", "kandev.service", "-n", "200", "--no-pager"},
},
{
name: "user logs follow keeps line count",
args: serviceArgs{Action: actionLogs, Follow: true},
want: []string{"--user-unit", "kandev.service", "-n", "200", "-f"},
},
{
name: "system logs",
args: serviceArgs{Action: actionLogs, System: true},
want: []string{"-u", "kandev.service", "-n", "200", "--no-pager"},
},
{
name: "system logs follow keeps line count",
args: serviceArgs{Action: actionLogs, System: true, Follow: true},
want: []string{"-u", "kandev.service", "-n", "200", "-f"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildJournalArgs(tt.args)
if !slices.Equal(got, tt.want) {
t.Fatalf("buildJournalArgs() = %v, want %v", got, tt.want)
}
})
}
}
func TestQuoteForUnitEscapesSystemdSpecials(t *testing.T) {
got := serviceEnvLine("KANDEV_HOME_DIR", `/tmp/$USER/kandev%data`)
want := `Environment="KANDEV_HOME_DIR=/tmp/$$USER/kandev%%data"`
if got != want {
t.Fatalf("serviceEnvLine() = %q, want %q", got, want)
}
}
func TestServiceEnvLineAllowSpecifiersPreservesSystemdSpecifiers(t *testing.T) {
got := serviceEnvLineAllowSpecifiers("PATH", `%h/.local/bin:$PATH`)
want := `Environment="PATH=%h/.local/bin:$$PATH"`
if got != want {
t.Fatalf("serviceEnvLineAllowSpecifiers() = %q, want %q", got, want)
}
}
func TestBackupUnmanagedServiceFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "kandev.service")
original := "custom unit"
if err := os.WriteFile(path, []byte(original), 0o640); err != nil {
t.Fatal(err)
}
if err := backupUnmanagedServiceFile(path); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path + ".bak")
if err != nil {
t.Fatal(err)
}
if string(data) != original {
t.Fatalf("backup = %q, want %q", data, original)
}
}
func TestBackupUnmanagedServiceFileSkipsManagedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "kandev.service")
if err := os.WriteFile(path, []byte("# managed by kandev\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := backupUnmanagedServiceFile(path); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) {
t.Fatalf("managed file should not create backup, stat err=%v", err)
}
}
+133
View File
@@ -0,0 +1,133 @@
package launcher
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
)
var (
executablePath = os.Executable
launchManaged = runManagedApp
)
func runStart(opts Options) int {
backendPort, err := resolvePorts(opts)
if err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 2
}
ports, err := pickPorts(backendPort)
if err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
if err := ensureDataDir(); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
logLevel := resolveLogLevel(opts)
self, err := executablePath()
if err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
return launchManaged(managedAppConfig{
Header: "start mode: using local build",
Mode: "start",
Backend: self,
BackendCWD: filepath.Dir(self),
Ports: ports,
LogLevel: logLevel,
Opts: opts,
})
}
type managedAppConfig struct {
Header string
Mode string
Backend string
BackendCWD string
Ports portConfig
LogLevel string
Opts Options
}
func resolveLogLevel(opts Options) string {
if logLevel := os.Getenv("KANDEV_LOG_LEVEL"); logLevel != "" {
return logLevel
}
switch {
case opts.Debug:
return "debug"
case opts.Verbose:
return "info"
default:
return "warn"
}
}
func runManagedApp(cfg managedAppConfig) int {
logStartup(cfg.Header, cfg.Ports, resolveDatabasePath(), cfg.LogLevel)
supervisor := newSupervisor()
supervisor.attachSignals()
showOutput := cfg.Opts.Verbose || cfg.Opts.Debug
backend, dumpLogs, err := launchRestartableBackend(cfg.Backend, []string{"__backend"}, cfg.BackendCWD, backendEnv(cfg.Ports, cfg.LogLevel, cfg.Opts.Debug), !showOutput, cfg.Ports, cfg.Mode, supervisor)
if err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
fmt.Println("[kandev] starting backend...")
if err := waitForHealth(cfg.Ports.BackendURL, backend, healthTimeout(healthTimeoutReleaseMS), 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)
if cfg.Opts.Headless {
fmt.Printf("[kandev] ready (headless) at %s\n", cfg.Ports.BackendURL)
return waitForAppExit(supervisor, backend)
}
fmt.Println("[kandev] open: " + cfg.Ports.BackendURL)
openBrowser(cfg.Ports.BackendURL)
return waitForAppExit(supervisor, backend)
}
func logStartup(header string, ports portConfig, dbPath, logLevel string) {
fmt.Println("[kandev] " + header)
fmt.Println("[kandev] url:", ports.BackendURL)
fmt.Println("[kandev] mcp:", ports.BackendURL+"/mcp")
if dbPath != "" {
fmt.Println("[kandev] db:", dbPath)
}
if logLevel != "" {
fmt.Println("[kandev] log level:", logLevel)
}
}
func openBrowser(url string) {
if os.Getenv("KANDEV_NO_BROWSER") == "1" {
return
}
var cmd *exec.Cmd
switch {
case os.Getenv("OS") == "Windows_NT":
cmd = exec.Command("cmd.exe", "/c", "start", "", url)
case runtime.GOOS == "darwin":
cmd = exec.Command("open", url)
default:
cmd = exec.Command("xdg-open", url)
}
_ = cmd.Start()
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
@@ -0,0 +1,47 @@
package launcher
import (
"path/filepath"
"testing"
)
func TestRunStartUsesSelfExecutableAndBackendCWD(t *testing.T) {
oldExecutablePath := executablePath
oldLaunchManaged := launchManaged
t.Cleanup(func() {
executablePath = oldExecutablePath
launchManaged = oldLaunchManaged
})
exe := filepath.Join(t.TempDir(), "bin", "kandev")
executablePath = func() (string, error) {
return exe, nil
}
var got managedAppConfig
launchManaged = func(cfg managedAppConfig) int {
got = cfg
return 42
}
t.Setenv("KANDEV_HOME_DIR", t.TempDir())
code := runStart(Options{Command: CommandStart, BackendPort: 48123, Headless: true})
if code != 42 {
t.Fatalf("runStart() = %d, want 42", code)
}
if got.Backend != exe {
t.Fatalf("Backend = %q, want %q", got.Backend, exe)
}
if got.BackendCWD != filepath.Dir(exe) {
t.Fatalf("BackendCWD = %q, want %q", got.BackendCWD, filepath.Dir(exe))
}
if got.Mode != "start" {
t.Fatalf("Mode = %q, want start", got.Mode)
}
if got.Ports.BackendPort != 48123 {
t.Fatalf("BackendPort = %d, want 48123", got.Ports.BackendPort)
}
if !got.Opts.Headless {
t.Fatal("expected Headless option to be preserved")
}
}
@@ -0,0 +1,254 @@
package launcher
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"sync"
"time"
)
type launchManifest struct {
Version int `json:"version"`
BackendExecutable string `json:"backend_executable"`
Argv []string `json:"argv"`
CWD string `json:"cwd"`
Env map[string]string `json:"env"`
HomeDir string `json:"home_dir"`
Port int `json:"port"`
Mode string `json:"mode"`
CreatedAt string `json:"created_at"`
}
type restartableBackend struct {
mu sync.Mutex
restartMu sync.Mutex
command string
args []string
cwd string
env []string
quiet bool
supervisor *processSupervisor
current *managedProcess
exitCh chan int
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)
if err != nil {
return nil, nil, err
}
manifest := buildManifest(command, args, cwd, supervisorEnv, homeDir, ports.BackendPort, mode)
if err := writeManifest(manifest, manifestPath); err != nil {
return nil, nil, err
}
backend := &restartableBackend{
command: command,
args: args,
cwd: cwd,
env: supervisorEnv,
quiet: quiet,
supervisor: supervisor,
exitCh: make(chan int, 1),
}
if err := backend.start(); err != nil {
return nil, nil, err
}
if err := startControlServer(socket, backend.restart); err != nil {
backend.mu.Lock()
current := backend.current
backend.current = nil
backend.mu.Unlock()
if current != nil {
current.kill()
}
return nil, nil, err
}
return backend, func() {
backend.mu.Lock()
dump := backend.dumpLogs
backend.mu.Unlock()
if dump != nil {
dump()
}
}, nil
}
func (b *restartableBackend) start() error {
proc, dump, err := startProcess(b.command, b.args, b.cwd, b.env, b.quiet, "backend", b.supervisor)
if err != nil {
return err
}
b.mu.Lock()
b.current = proc
b.dumpLogs = dump
b.mu.Unlock()
go func() {
<-proc.done
_, code := proc.Exited()
b.mu.Lock()
current := b.current == proc
b.mu.Unlock()
if current {
select {
case b.exitCh <- code:
default:
}
}
}()
return nil
}
func (b *restartableBackend) restart() {
b.restartMu.Lock()
defer b.restartMu.Unlock()
b.mu.Lock()
current := b.current
b.current = nil
b.mu.Unlock()
if current != nil {
current.kill()
}
if err := b.start(); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] backend restart failed: "+err.Error())
b.notifyExit(1)
}
}
func (b *restartableBackend) notifyExit(code int) {
select {
case b.exitCh <- code:
default:
}
}
func (b *restartableBackend) Exited() (bool, int) {
b.mu.Lock()
current := b.current
b.mu.Unlock()
if current == nil {
return false, 0
}
return current.Exited()
}
func prepareSupervisorEnv(env []string, homeDir string) ([]string, string, string, error) {
dir := filepath.Join(homeDir, "supervisor")
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, "", "", err
}
_ = os.Chmod(dir, 0o700)
socket := filepath.Join(dir, "control.sock")
manifest := filepath.Join(dir, "launch.json")
env = upsertEnv(env, "KANDEV_SUPERVISOR_SOCKET", socket)
env = upsertEnv(env, "KANDEV_SUPERVISOR_MANIFEST", manifest)
env = upsertEnv(env, "KANDEV_RESTART_ADAPTER", "supervisor")
return env, socket, manifest, nil
}
func buildManifest(command string, args []string, cwd string, env []string, homeDir string, port int, mode string) launchManifest {
return launchManifest{
Version: 1,
BackendExecutable: command,
Argv: append([]string(nil), args...),
CWD: cwd,
Env: allowedSupervisorEnv(env),
HomeDir: homeDir,
Port: port,
Mode: mode,
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
}
func allowedSupervisorEnv(env []string) map[string]string {
allow := map[string]bool{
"KANDEV_HOME_DIR": true,
"KANDEV_DATABASE_PATH": true,
"KANDEV_SERVER_PORT": true,
"KANDEV_WEB_INTERNAL_URL": true,
"KANDEV_AGENT_STANDALONE_PORT": true,
"KANDEV_LOG_LEVEL": true,
"KANDEV_DEBUG_DEV_MODE": true,
"KANDEV_DEBUG_AGENT_MESSAGES": true,
"KANDEV_DEBUG_PPROF_ENABLED": true,
"KANDEV_E2E_MOCK": true,
"KANDEV_MOCK_AGENT": true,
"KANDEV_MOCK_GITHUB": true,
"KANDEV_MOCK_JIRA": true,
"KANDEV_MOCK_LINEAR": true,
"KANDEV_SUPERVISOR_SOCKET": true,
"KANDEV_SUPERVISOR_MANIFEST": true,
"KANDEV_RESTART_ADAPTER": true,
}
out := map[string]string{}
for _, item := range env {
for key := range allow {
prefix := key + "="
if len(item) >= len(prefix) && item[:len(prefix)] == prefix {
out[key] = item[len(prefix):]
break
}
}
}
return out
}
func writeManifest(manifest launchManifest, targetPath string) error {
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(targetPath, data, 0o600)
}
func startControlServer(socket string, onRestart func()) error {
ln, err := listenControlSocket(socket)
if err != nil {
return err
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go handleControlConn(conn, onRestart)
}
}()
return nil
}
func listenControlSocket(socket string) (net.Listener, error) {
_ = os.Remove(socket)
ln, err := net.Listen("unix", socket)
if err != nil {
return nil, err
}
if err := os.Chmod(socket, 0o600); err != nil {
_ = ln.Close()
return nil, err
}
return ln, nil
}
func handleControlConn(conn net.Conn, onRestart func()) {
defer func() { _ = conn.Close() }()
line, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
return
}
var req map[string]string
resp := map[string]interface{}{"accepted": false, "message": "unsupported action"}
if json.Unmarshal(line, &req) == nil && req["action"] == "restart" {
go onRestart()
resp = map[string]interface{}{"accepted": true, "message": "Restart accepted"}
}
_ = json.NewEncoder(conn).Encode(resp)
}
@@ -0,0 +1,92 @@
package launcher
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
)
func TestBuildManifestUsesSameBinaryBackendMode(t *testing.T) {
env := []string{
"KANDEV_SERVER_PORT=1234",
"UNRELATED=value",
}
manifest := buildManifest("/opt/kandev/bin/kandev", []string{"__backend"}, "/opt/kandev/bin", env, "/tmp/home", 1234, "run")
if manifest.BackendExecutable != "/opt/kandev/bin/kandev" {
t.Fatalf("BackendExecutable = %q", manifest.BackendExecutable)
}
if len(manifest.Argv) != 1 || manifest.Argv[0] != "__backend" {
t.Fatalf("Argv = %v, want [__backend]", manifest.Argv)
}
if _, ok := manifest.Env["UNRELATED"]; ok {
t.Fatalf("manifest contains unrelated env: %+v", manifest.Env)
}
if manifest.Env["KANDEV_SERVER_PORT"] != "1234" {
t.Fatalf("KANDEV_SERVER_PORT = %q", manifest.Env["KANDEV_SERVER_PORT"])
}
}
func TestPrepareSupervisorEnvWritesExpectedPaths(t *testing.T) {
home := t.TempDir()
env, socket, manifest, err := prepareSupervisorEnv(nil, home)
if err != nil {
t.Fatal(err)
}
if socket != filepath.Join(home, "supervisor", "control.sock") {
t.Fatalf("socket = %q", socket)
}
if manifest != filepath.Join(home, "supervisor", "launch.json") {
t.Fatalf("manifest = %q", manifest)
}
got := allowedSupervisorEnv(env)
if got["KANDEV_RESTART_ADAPTER"] != "supervisor" {
t.Fatalf("restart adapter env = %+v", got)
}
}
func TestListenControlSocketUsesOwnerOnlyMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix sockets are not supported on windows")
}
dir, err := os.MkdirTemp("", "kandev-sock-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
socket := filepath.Join(dir, "control.sock")
ln, err := listenControlSocket(socket)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
info, err := os.Stat(socket)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("socket mode = %#o, want 0600", got)
}
}
func TestRestartFailureNotifiesLauncherExit(t *testing.T) {
backend := &restartableBackend{
command: filepath.Join(t.TempDir(), "missing-kandev"),
supervisor: newSupervisor(),
exitCh: make(chan int, 1),
}
backend.restart()
select {
case code := <-backend.exitCh:
if code == 0 {
t.Fatal("restart failure reported successful exit")
}
case <-time.After(500 * time.Millisecond):
t.Fatal("restart failure did not notify launcher exit")
}
}
-8
View File
@@ -27,14 +27,6 @@ The package manager owns the runtime version. `kandev@X.Y.Z` ships with the matc
- **Update via npm/npx**: `npx kandev@latest` or `npm install -g kandev@latest` - **Update via npm/npx**: `npx kandev@latest` or `npm install -g kandev@latest`
- **Print CLI version**: `kandev --version` - **Print CLI version**: `kandev --version`
### Advanced: pin a specific runtime tag
`--runtime-version <tag>` downloads a specific GitHub release runtime instead of using the installed one. For debugging compatibility issues only:
```bash
kandev --runtime-version v0.16.0
```
## Run as a Service ## Run as a Service
Install kandev as a systemd (Linux) or launchd (macOS) service so it auto-starts and stays running across reboots: Install kandev as a systemd (Linux) or launchd (macOS) service so it auto-starts and stays running across reboots:
+22 -36
View File
@@ -5,19 +5,16 @@
```mermaid ```mermaid
flowchart TD flowchart TD
A["kandev"] --> B["CLI parses arguments"] A["kandev"] --> B["CLI parses arguments"]
B --> C{"Command?"} B --> C{"Install channel?"}
C -->|run / default| D["resolveRuntime()"] C -->|Homebrew / manual| H["bin/kandev native launcher"]
C -->|dev| F["Use local repo"] C -->|npm / npx| D["bin/cli.js native shim"]
C -->|start| G["Use local build"]
D --> D1{"KANDEV_BUNDLE_DIR set?"} D --> D1{"KANDEV_BUNDLE_DIR set?"}
D1 -->|yes| D2["Use env bundle (Homebrew)"] D1 -->|yes| D2["Use env bundle (Homebrew)"]
D1 -->|no| D3["require.resolve('@kdlbs/runtime-{platform}')"] D1 -->|no| D3["require.resolve('@kdlbs/runtime-{platform}')"]
D3 -->|hit| D4["Use installed npm bundle"] D3 -->|hit| D4["Use installed npm bundle"]
D3 -->|miss| D5{"--runtime-version given?"} D3 -->|miss| D5["Throw: 'No runtime found'"]
D5 -->|yes| D6["Download from GitHub Releases (cache)"]
D5 -->|no| D7["Throw: 'No runtime found'"]
D2 --> E["Start Go backend serving static SPA"] D2 --> E["Start Go backend serving static SPA"]
D4 --> E D4 --> E
@@ -34,9 +31,9 @@ This package provides the `kandev` CLI launcher. The runtime bundle (Go backend,
Two install channels share the same release artifacts: Two install channels share the same release artifacts:
- **npm/npx**: `kandev@X.Y.Z` declares `optionalDependencies` for `@kdlbs/runtime-{platform}@X.Y.Z`. npm 7+ filters by `os`/`cpu` and installs only the matching one. - **npm/npx**: `kandev@X.Y.Z` declares `optionalDependencies` for `@kdlbs/runtime-{platform}@X.Y.Z`. npm 7+ filters by `os`/`cpu` and installs only the matching one.
- **Homebrew**: `kdlbs/homebrew-kandev` formula downloads the GitHub release tarball into the Cellar and sets `KANDEV_BUNDLE_DIR`. - **Homebrew**: `kdlbs/homebrew-kandev` formula downloads the GitHub release tarball into the Cellar and installs `bin/kandev` as the public command.
Both channels resolve to the same runtime bundle layout (`bin/kandev`, `bin/agentctl`, `web/index.html` plus assets). The CLI launcher (`runtime.ts`) abstracts over them. Both channels resolve to a native runtime bundle. Homebrew/manual bundles contain `bin/kandev` and `bin/agentctl`; npm runtime packages also include the platform-matched binaries. The public command remains `kandev`; the hidden backend mode is `kandev __backend`.
## Artifact shapes ## Artifact shapes
@@ -45,21 +42,14 @@ The GitHub release bundle and the npm runtime package are **different shapes** b
``` ```
# GitHub release bundle (used by Homebrew + manual installs) # GitHub release bundle (used by Homebrew + manual installs)
kandev/ kandev/
├── bin/{kandev,agentctl} └── bin/{kandev,agentctl,agentctl-linux-amd64}
├── web/index.html
├── web/assets/
└── cli/
├── bin/cli.js # #!/usr/bin/env node + chmod +x
└── dist/cli.bundle.js # esbuild single-file bundle (deps inlined)
# npm runtime package (@kdlbs/runtime-{platform}) # npm runtime package (@kdlbs/runtime-{platform})
@kdlbs/runtime-{platform}/ @kdlbs/runtime-{platform}/
├── bin/{kandev,agentctl} └── bin/{kandev,agentctl,agentctl-linux-amd64}
├── web/index.html
└── web/assets/
``` ```
The `cli/` directory is only in the GitHub bundle (Homebrew needs the launcher). For npm installs, the main `kandev` package already provides the CLI; the runtime package only ships the binaries + web assets. For npm installs, the main `kandev` package provides only a tiny Node bin shim that execs `bin/kandev` from the platform runtime package.
## Commands ## Commands
@@ -75,7 +65,7 @@ The `cli/` directory is only in the GitHub bundle (Homebrew needs the launcher).
| -------------------------- | ---------------------------------------------------------- | | -------------------------- | ---------------------------------------------------------- |
| `--version`, `-V` | Print CLI version and exit | | `--version`, `-V` | Print CLI version and exit |
| `--port`, `--backend-port` | Backend port | | `--port`, `--backend-port` | Backend port |
| `--web-internal-port` | Override internal dev web port | | `--web-internal-port` | Override internal Vite dev web port |
| `--verbose`, `-v` | Show info logs | | `--verbose`, `-v` | Show info logs |
| `--debug` | Show debug logs + agent message dumps | | `--debug` | Show debug logs + agent message dumps |
| `--runtime-version <tag>` | **Advanced/debug**: download a specific GitHub runtime tag | | `--runtime-version <tag>` | **Advanced/debug**: download a specific GitHub runtime tag |
@@ -93,23 +83,22 @@ npx kandev@latest # always pulls latest
There is no `KANDEV_NO_UPDATE_PROMPT` or `KANDEV_SKIP_UPDATE` env var anymore — they're gone with the self-updater. There is no `KANDEV_NO_UPDATE_PROMPT` or `KANDEV_SKIP_UPDATE` env var anymore — they're gone with the self-updater.
## Bundling for Homebrew ## npm shim
The Homebrew bundle uses `dist/cli.bundle.js`, an esbuild single-file output that inlines `tree-kill`, `tar`, and other Node deps. Required because the Cellar has no `node_modules`. The npm package uses the unbundled `dist/cli.js` and resolves deps via npm. The npm package has no runtime dependencies beyond its platform optional runtime package. `bin/cli.js` resolves `KANDEV_BUNDLE_DIR` first, then the matching `@kdlbs/runtime-*` package, and execs the native `bin/kandev` binary with the original arguments.
```bash ```bash
pnpm -C apps/cli build # produces dist/cli.js pnpm -C apps/cli build # syntax-checks the shim
pnpm -C apps/cli bundle # produces dist/cli.bundle.js (~212KB) pnpm -C apps/cli test # tests runtime resolution
``` ```
Smoke test the bundle in a clean directory: Smoke test the bundle in a clean directory:
```bash ```bash
mkdir /tmp/smoke && cd /tmp/smoke KANDEV_BUNDLE_DIR=/path/to/dist/kandev node /path/to/apps/cli/bin/cli.js --help
node /path/to/apps/cli/dist/cli.bundle.js --help
``` ```
If `Cannot find module 'tree-kill'` appears, the bundle is broken. If the runtime package or bundle is missing, the shim prints an actionable runtime-resolution error.
## Local Development ## Local Development
@@ -144,15 +133,12 @@ Versioning:
## Environment Overrides ## Environment Overrides
| Variable | Description | | Variable | Description |
| ------------------------------------- | --------------------------------------------------------------------- | | ------------------------------------- | -------------------------------------------------------------- |
| `KANDEV_BUNDLE_DIR` | Force runtime bundle location (set by Homebrew wrapper, tests) | | `KANDEV_BUNDLE_DIR` | Force runtime bundle location (set by Homebrew wrapper, tests) |
| `KANDEV_PORT` / `KANDEV_BACKEND_PORT` | Backend port | | `KANDEV_PORT` / `KANDEV_BACKEND_PORT` | Backend port |
| `KANDEV_WEB_PORT` | Internal Next.js port | | `KANDEV_WEB_PORT` | Internal Vite dev web port override |
| `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout (ms) | | `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout (ms) |
| `KANDEV_GITHUB_OWNER` | Override GitHub repo owner (default: `kdlbs`) for `--runtime-version` |
| `KANDEV_GITHUB_REPO` | Override GitHub repo name (default: `kandev`) for `--runtime-version` |
| `KANDEV_GITHUB_TOKEN` | Optional GitHub token for rate limits |
## Supported Platforms ## Supported Platforms
+3 -1
View File
@@ -1,3 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
require("../dist/cli.js"); const { run } = require("./native-shim");
process.exitCode = run(process.argv.slice(2));
+94
View File
@@ -0,0 +1,94 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const PLATFORM_PACKAGES = {
"linux-x64": "@kdlbs/runtime-linux-x64",
"linux-arm64": "@kdlbs/runtime-linux-arm64",
"darwin-x64": "@kdlbs/runtime-darwin-x64",
"darwin-arm64": "@kdlbs/runtime-darwin-arm64",
"win32-x64": "@kdlbs/runtime-win32-x64",
};
function platformPackage(platform = process.platform, arch = process.arch) {
const packageName = PLATFORM_PACKAGES[`${platform}-${arch}`];
if (!packageName) {
throw new Error(`Unsupported Kandev platform: ${platform}-${arch}`);
}
return packageName;
}
function binaryName(name, platform = process.platform) {
return platform === "win32" ? `${name}.exe` : name;
}
function resolveRuntime(env = process.env, resolver = require.resolve) {
if (env.KANDEV_BUNDLE_DIR) {
return validateRuntime(env.KANDEV_BUNDLE_DIR);
}
const packageName = platformPackage();
let packageJSON;
try {
packageJSON = resolver(`${packageName}/package.json`);
} catch (error) {
const message =
`No Kandev runtime package found for ${process.platform}-${process.arch}.\n` +
`Install with npm 7+ so optional dependencies are installed, or use Homebrew.`;
const runtimeError = new Error(message);
runtimeError.cause = error;
throw runtimeError;
}
return validateRuntime(path.dirname(packageJSON));
}
function validateRuntime(bundleDir) {
const executable = path.join(bundleDir, "bin", binaryName("kandev"));
if (!fs.existsSync(executable)) {
throw new Error(`Kandev native binary not found at ${executable}`);
}
const agentctl = path.join(bundleDir, "bin", binaryName("agentctl"));
if (!fs.existsSync(agentctl)) {
throw new Error(`agentctl binary not found at ${agentctl}`);
}
return { bundleDir, executable };
}
function run(argv, env = process.env) {
let runtime;
try {
runtime = resolveRuntime(env);
} catch (error) {
console.error(`[kandev] ${error.message}`);
return 1;
}
const child = spawnSync(runtime.executable, argv, {
stdio: "inherit",
env: {
...env,
KANDEV_BUNDLE_DIR: runtime.bundleDir,
},
});
if (child.error) {
console.error(`[kandev] failed to launch native binary: ${child.error.message}`);
return 1;
}
if (child.signal) {
return 1;
}
return child.status === null ? 1 : child.status;
}
module.exports = {
binaryName,
platformPackage,
resolveRuntime,
run,
validateRuntime,
};
+65
View File
@@ -0,0 +1,65 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import shim from "./native-shim.js";
const { binaryName, platformPackage, resolveRuntime, validateRuntime } = shim;
function createBundle(dir) {
fs.mkdirSync(path.join(dir, "bin"), { recursive: true });
fs.writeFileSync(path.join(dir, "bin", "kandev"), "fake");
fs.writeFileSync(path.join(dir, "bin", "agentctl"), "fake");
}
describe("native npm shim", () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-native-shim-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
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");
});
it("uses exe suffix on Windows only", () => {
expect(binaryName("kandev", "win32")).toBe("kandev.exe");
expect(binaryName("kandev", "linux")).toBe("kandev");
});
it("resolves KANDEV_BUNDLE_DIR before npm packages", () => {
createBundle(tmpDir);
const runtime = resolveRuntime({ KANDEV_BUNDLE_DIR: tmpDir }, () => {
throw new Error("should not resolve npm package");
});
expect(runtime.bundleDir).toBe(tmpDir);
expect(runtime.executable).toBe(path.join(tmpDir, "bin", "kandev"));
});
it("resolves the installed runtime package", () => {
createBundle(tmpDir);
const pkgJSON = path.join(tmpDir, "package.json");
fs.writeFileSync(pkgJSON, "{}");
const runtime = resolveRuntime({}, () => pkgJSON);
expect(runtime.bundleDir).toBe(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/);
});
});
+8 -10
View File
@@ -13,10 +13,10 @@
"bin": { "bin": {
"kandev": "bin/cli.js" "kandev": "bin/cli.js"
}, },
"main": "dist/cli.js", "main": "bin/native-shim.js",
"files": [ "files": [
"bin", "bin/cli.js",
"dist" "bin/native-shim.js"
], ],
"engines": { "engines": {
"npm": ">=7" "npm": ">=7"
@@ -28,22 +28,20 @@
"@kdlbs/runtime-darwin-arm64": "0.0.0-bootstrap", "@kdlbs/runtime-darwin-arm64": "0.0.0-bootstrap",
"@kdlbs/runtime-win32-x64": "0.0.0-bootstrap" "@kdlbs/runtime-win32-x64": "0.0.0-bootstrap"
}, },
"dependencies": {
"tar": "^7.5.11",
"tree-kill": "^1.2.2"
},
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"esbuild": "^0.24.0", "esbuild": "^0.24.0",
"tar": "^7.5.11",
"tree-kill": "^1.2.2",
"tsx": "^4.15.7", "tsx": "^4.15.7",
"typescript": "^5", "typescript": "^5",
"vitest": "^1.6.0" "vitest": "^1.6.0"
}, },
"scripts": { "scripts": {
"dev": "tsx src/cli.ts", "dev": "tsx src/cli.ts",
"build": "tsc -p tsconfig.json", "build": "node -c bin/cli.js && node -c bin/native-shim.js",
"bundle": "esbuild dist/cli.js --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.js", "bundle": "pnpm build",
"start": "node dist/cli.js", "start": "node bin/cli.js",
"test": "vitest run", "test": "vitest run",
"prepublishOnly": "pnpm build" "prepublishOnly": "pnpm build"
} }
+18 -16
View File
@@ -4,10 +4,9 @@ import { parseArgs, ParseError, resolvePorts } from "./args";
describe("parseArgs", () => { describe("parseArgs", () => {
it("defaults to the run command with no args", () => { it("defaults to the run command with no args", () => {
const { options, showHelp, deprecatedFlags } = parseArgs([]); const { options, showHelp } = parseArgs([]);
expect(options.command).toBe("run"); expect(options.command).toBe("run");
expect(showHelp).toBe(false); expect(showHelp).toBe(false);
expect(deprecatedFlags).toEqual([]);
}); });
it("parses --port and --port=<n> as the backend port", () => { it("parses --port and --port=<n> as the backend port", () => {
@@ -17,24 +16,21 @@ describe("parseArgs", () => {
it("parses --backend-port the same as --port (no deprecation note)", () => { it("parses --backend-port the same as --port (no deprecation note)", () => {
expect(parseArgs(["--backend-port=3447"]).options.backendPort).toBe(3447); expect(parseArgs(["--backend-port=3447"]).options.backendPort).toBe(3447);
expect(parseArgs(["--backend-port=3447"]).deprecatedFlags).toEqual([]);
}); });
it("parses --web-internal-port without a deprecation note", () => { it("parses --web-internal-port for dev mode", () => {
const r = parseArgs(["--web-internal-port", "12345"]); const r = parseArgs(["dev", "--web-internal-port", "12345"]);
expect(r.options.webPort).toBe(12345); expect(r.options.webPort).toBe(12345);
expect(r.deprecatedFlags).toEqual([]);
}); });
it("records --web-port as deprecated", () => { it("rejects --web-internal-port outside dev mode", () => {
const r = parseArgs(["--web-port=8080"]); expect(() => parseArgs(["--web-internal-port", "12345"])).toThrow(
expect(r.options.webPort).toBe(8080); /--web-internal-port only applies to dev mode/,
expect(r.deprecatedFlags).toEqual(["--web-port"]); );
}); });
it("dedupes deprecated flags across repeats", () => { it("rejects removed --web-port", () => {
const r = parseArgs(["--web-port=1", "--web-port=2"]); expect(() => parseArgs(["--web-port=8080"])).toThrow(/--web-port has been removed/);
expect(r.deprecatedFlags).toEqual(["--web-port"]);
}); });
it("reports --help via showHelp without exiting", () => { it("reports --help via showHelp without exiting", () => {
@@ -130,9 +126,9 @@ describe("resolvePorts", () => {
expect(r.webPort).toBe(8080); expect(r.webPort).toBe(8080);
}); });
it("KANDEV_WEB_PORT sets the internal web port in run/start (backend stays undefined)", () => { it("KANDEV_WEB_PORT is ignored outside dev", () => {
const r = resolvePorts({ command: "run" }, { KANDEV_WEB_PORT: "8080" } as NodeJS.ProcessEnv); const r = resolvePorts({ command: "run" }, { KANDEV_WEB_PORT: "8080" } as NodeJS.ProcessEnv);
expect(r).toEqual({ backendPort: undefined, webPort: 8080 }); expect(r).toEqual({ backendPort: undefined, webPort: undefined });
}); });
it("--port maps to backend in every command (including dev)", () => { it("--port maps to backend in every command (including dev)", () => {
@@ -158,7 +154,7 @@ describe("resolvePorts", () => {
).toThrow(/KANDEV_PORT must be an integer between/); ).toThrow(/KANDEV_PORT must be an integer between/);
}); });
it.each(["KANDEV_PORT", "KANDEV_BACKEND_PORT", "KANDEV_WEB_PORT"])( it.each(["KANDEV_PORT", "KANDEV_BACKEND_PORT"])(
"throws ParseError when %s is set to empty string", "throws ParseError when %s is set to empty string",
(name) => { (name) => {
expect(() => resolvePorts({ command: "run" }, { [name]: "" } as NodeJS.ProcessEnv)).toThrow( expect(() => resolvePorts({ command: "run" }, { [name]: "" } as NodeJS.ProcessEnv)).toThrow(
@@ -166,4 +162,10 @@ describe("resolvePorts", () => {
); );
}, },
); );
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/);
});
}); });
+11 -18
View File
@@ -15,8 +15,6 @@ export type CliOptions = {
export type ParseResult = { export type ParseResult = {
options: CliOptions; options: CliOptions;
showHelp: boolean; showHelp: boolean;
/** Deprecated flags seen on the command line. cli.ts emits warnings after parsing. */
deprecatedFlags: string[];
}; };
export class ParseError extends Error {} export class ParseError extends Error {}
@@ -24,10 +22,6 @@ export class ParseError extends Error {}
export function parseArgs(argv: string[]): ParseResult { export function parseArgs(argv: string[]): ParseResult {
const opts: CliOptions = { command: "run" }; const opts: CliOptions = { command: "run" };
let showHelp = false; let showHelp = false;
const deprecatedFlags: string[] = [];
const noteDeprecated = (flag: string) => {
if (!deprecatedFlags.includes(flag)) deprecatedFlags.push(flag);
};
for (let i = 0; i < argv.length; i += 1) { for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]; const arg = argv[i];
if (arg === "--help" || arg === "-h") { if (arg === "--help" || arg === "-h") {
@@ -77,16 +71,8 @@ export function parseArgs(argv: string[]): ParseResult {
opts.webPort = parsePort(arg.slice("--web-internal-port=".length), "--web-internal-port"); opts.webPort = parsePort(arg.slice("--web-internal-port=".length), "--web-internal-port");
continue; continue;
} }
if (arg === "--web-port") { if (arg === "--web-port" || arg.startsWith("--web-port=")) {
opts.webPort = parsePort(takeValue(argv, i, "--web-port"), "--web-port"); throw new ParseError("--web-port has been removed; use --web-internal-port for dev mode");
noteDeprecated("--web-port");
i += 1;
continue;
}
if (arg.startsWith("--web-port=")) {
opts.webPort = parsePort(arg.slice("--web-port=".length), "--web-port");
noteDeprecated("--web-port");
continue;
} }
if (arg === "--verbose" || arg === "-v") { if (arg === "--verbose" || arg === "-v") {
opts.verbose = true; opts.verbose = true;
@@ -101,7 +87,10 @@ export function parseArgs(argv: string[]): ParseResult {
continue; continue;
} }
} }
return { options: opts, showHelp, deprecatedFlags }; 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 { function takeValue(argv: string[], i: number, flag: string): string {
@@ -127,10 +116,14 @@ export type ResolvedPorts = {
// CLI flags beat env vars; KANDEV_PORT is an alias for KANDEV_BACKEND_PORT. // CLI flags beat env vars; KANDEV_PORT is an alias for KANDEV_BACKEND_PORT.
export function resolvePorts(options: CliOptions, env: NodeJS.ProcessEnv): ResolvedPorts { 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");
}
return { return {
backendPort: backendPort:
options.backendPort ?? envPort(env, "KANDEV_BACKEND_PORT") ?? envPort(env, "KANDEV_PORT"), options.backendPort ?? envPort(env, "KANDEV_BACKEND_PORT") ?? envPort(env, "KANDEV_PORT"),
webPort: options.webPort ?? envPort(env, "KANDEV_WEB_PORT"), webPort:
options.command === "dev" ? (options.webPort ?? envPort(env, "KANDEV_WEB_PORT")) : undefined,
}; };
} }
+3 -9
View File
@@ -52,7 +52,6 @@ Advanced:
--web-internal-port Override the internal dev web port. The Go backend --web-internal-port Override the internal dev web port. The Go backend
reverse-proxies to it in dev; start/run serve from Go. reverse-proxies to it in dev; start/run serve from Go.
Also reads KANDEV_WEB_PORT. Also reads KANDEV_WEB_PORT.
--web-port Deprecated alias for --web-internal-port.
--runtime-version <tag> Download and use a specific release tag instead of --runtime-version <tag> Download and use a specific release tag instead of
the installed runtime. For debugging only. the installed runtime. For debugging only.
Example: kandev --runtime-version v0.16.0 Example: kandev --runtime-version v0.16.0
@@ -90,7 +89,7 @@ async function main(): Promise<void> {
return; return;
} }
const { options, showHelp, deprecatedFlags } = parseArgs(process.argv.slice(2)); const { options, showHelp } = parseArgs(process.argv.slice(2));
if (options.showVersion) { if (options.showVersion) {
console.log(pkg.version); console.log(pkg.version);
@@ -102,13 +101,10 @@ async function main(): Promise<void> {
return; return;
} }
for (const flag of deprecatedFlags) {
process.stderr.write(`[kandev] ${flag} is deprecated; use --web-internal-port\n`);
}
const resolved = resolvePorts(options, process.env); const resolved = resolvePorts(options, process.env);
const backendPort = ensureValidPort(resolved.backendPort, "backend port"); const backendPort = ensureValidPort(resolved.backendPort, "backend port");
const webPort = ensureValidPort(resolved.webPort, "web 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 // 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. // be stale. Warn once before launch so the user knows to re-run install.
@@ -135,7 +131,6 @@ async function main(): Promise<void> {
await runStart({ await runStart({
repoRoot, repoRoot,
backendPort, backendPort,
webPort,
verbose: options.verbose, verbose: options.verbose,
debug: options.debug, debug: options.debug,
headless: options.headless, headless: options.headless,
@@ -146,7 +141,6 @@ async function main(): Promise<void> {
await runRelease({ await runRelease({
runtimeVersion: options.runtimeVersion, runtimeVersion: options.runtimeVersion,
backendPort, backendPort,
webPort,
verbose: options.verbose, verbose: options.verbose,
debug: options.debug, debug: options.debug,
headless: options.headless, headless: options.headless,
+1 -12
View File
@@ -6,7 +6,6 @@ import { ensureExtracted, findBundleRoot } from "./bundle";
import { import {
DEFAULT_AGENTCTL_PORT, DEFAULT_AGENTCTL_PORT,
DEFAULT_BACKEND_PORT, DEFAULT_BACKEND_PORT,
DEFAULT_WEB_PORT,
HEALTH_TIMEOUT_MS_RELEASE, HEALTH_TIMEOUT_MS_RELEASE,
resolveCacheDir, resolveCacheDir,
resolveDataDir, resolveDataDir,
@@ -14,7 +13,7 @@ import {
resolveKandevHomeDir, resolveKandevHomeDir,
} from "./constants"; } from "./constants";
import { ensureAsset, getRelease } from "./github"; import { ensureAsset, getRelease } from "./github";
import { resolveHealthTimeoutMs, waitForHealth, waitForUrlReady } from "./health"; import { resolveHealthTimeoutMs, waitForHealth } from "./health";
import { getBinaryName, getPlatformDir } from "./platform"; import { getBinaryName, getPlatformDir } from "./platform";
import { sortVersionsDesc } from "./version"; import { sortVersionsDesc } from "./version";
import { pickAvailablePort } from "./ports"; import { pickAvailablePort } from "./ports";
@@ -27,7 +26,6 @@ import { openBrowser } from "./web";
export type RunOptions = { export type RunOptions = {
runtimeVersion?: string; runtimeVersion?: string;
backendPort?: number; backendPort?: number;
webPort?: number;
/** Show info logs from backend + web */ /** Show info logs from backend + web */
verbose?: boolean; verbose?: boolean;
/** Show debug logs + agent message dumps */ /** Show debug logs + agent message dumps */
@@ -42,7 +40,6 @@ type PreparedBundle = {
backendUrl: string; backendUrl: string;
backendEnv: NodeJS.ProcessEnv; backendEnv: NodeJS.ProcessEnv;
releaseTag: string; releaseTag: string;
webPort: number;
agentctlPort: number; agentctlPort: number;
dbPath: string; dbPath: string;
logLevel: string; logLevel: string;
@@ -142,7 +139,6 @@ async function downloadRuntimeVersion(runtimeVersion: string): Promise<string> {
async function prepareBundleForLaunch({ async function prepareBundleForLaunch({
runtimeVersion, runtimeVersion,
backendPort, backendPort,
webPort,
verbose = false, verbose = false,
debug = false, debug = false,
}: RunOptions): Promise<PreparedBundle> { }: RunOptions): Promise<PreparedBundle> {
@@ -183,7 +179,6 @@ async function prepareBundleForLaunch({
} }
const actualBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT)); const actualBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT));
const actualWebPort = webPort ?? (await pickAvailablePort(DEFAULT_WEB_PORT));
const agentctlPort = await pickAvailablePort(DEFAULT_AGENTCTL_PORT); const agentctlPort = await pickAvailablePort(DEFAULT_AGENTCTL_PORT);
const backendUrl = `http://localhost:${actualBackendPort}`; const backendUrl = `http://localhost:${actualBackendPort}`;
const showOutput = verbose || debug; const showOutput = verbose || debug;
@@ -199,7 +194,6 @@ async function prepareBundleForLaunch({
const backendEnv = buildBackendEnv({ const backendEnv = buildBackendEnv({
ports: { ports: {
backendPort: actualBackendPort, backendPort: actualBackendPort,
webPort: actualWebPort,
agentctlPort, agentctlPort,
backendUrl, backendUrl,
}, },
@@ -217,7 +211,6 @@ async function prepareBundleForLaunch({
backendUrl, backendUrl,
backendEnv, backendEnv,
releaseTag, releaseTag,
webPort: actualWebPort,
agentctlPort, agentctlPort,
dbPath, dbPath,
logLevel, logLevel,
@@ -253,7 +246,6 @@ async function launchBundle(prepared: PreparedBundle): Promise<{
header: `release: ${prepared.releaseTag}`, header: `release: ${prepared.releaseTag}`,
ports: { ports: {
backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT), backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT),
webPort: prepared.webPort,
agentctlPort: prepared.agentctlPort, agentctlPort: prepared.agentctlPort,
backendUrl: prepared.backendUrl, backendUrl: prepared.backendUrl,
}, },
@@ -272,7 +264,6 @@ async function launchBundle(prepared: PreparedBundle): Promise<{
homeDir: resolveKandevHomeDir(), homeDir: resolveKandevHomeDir(),
ports: { ports: {
backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT), backendPort: Number(prepared.backendEnv.KANDEV_SERVER_PORT),
webPort: prepared.webPort,
agentctlPort: prepared.agentctlPort, agentctlPort: prepared.agentctlPort,
backendUrl: prepared.backendUrl, backendUrl: prepared.backendUrl,
}, },
@@ -300,7 +291,6 @@ async function launchBundle(prepared: PreparedBundle): Promise<{
export async function runRelease({ export async function runRelease({
runtimeVersion, runtimeVersion,
backendPort, backendPort,
webPort,
verbose = false, verbose = false,
debug = false, debug = false,
headless = false, headless = false,
@@ -308,7 +298,6 @@ export async function runRelease({
const prepared = await prepareBundleForLaunch({ const prepared = await prepareBundleForLaunch({
runtimeVersion, runtimeVersion,
backendPort, backendPort,
webPort,
verbose, verbose,
debug, debug,
}); });
+25 -2
View File
@@ -14,7 +14,7 @@ import { createProcessSupervisor } from "./process";
export type PortConfig = { export type PortConfig = {
backendPort: number; backendPort: number;
webPort: number; webPort?: number;
agentctlPort: number; agentctlPort: number;
backendUrl: string; backendUrl: string;
}; };
@@ -39,6 +39,17 @@ export async function pickPorts(backendPort?: number, webPort?: number): Promise
}; };
} }
export async function pickBackendPorts(backendPort?: number): Promise<PortConfig> {
const resolvedBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT));
const agentctlPort = await pickAvailablePort(DEFAULT_AGENTCTL_PORT);
return {
backendPort: resolvedBackendPort,
agentctlPort,
backendUrl: `http://localhost:${resolvedBackendPort}`,
};
}
export type BackendEnvOptions = { export type BackendEnvOptions = {
ports: PortConfig; ports: PortConfig;
/** Log level: debug, info, warn, error (default: info) */ /** Log level: debug, info, warn, error (default: info) */
@@ -57,6 +68,9 @@ export type BackendEnvOptions = {
*/ */
export function buildBackendEnv(options: BackendEnvOptions): NodeJS.ProcessEnv { export function buildBackendEnv(options: BackendEnvOptions): NodeJS.ProcessEnv {
const { ports, logLevel, webProxy = true, extra } = options; const { ports, logLevel, webProxy = true, extra } = options;
if (webProxy && ports.webPort === undefined) {
throw new Error("webProxy requires a web port");
}
const env: NodeJS.ProcessEnv = { const env: NodeJS.ProcessEnv = {
...process.env, ...process.env,
KANDEV_SERVER_PORT: String(ports.backendPort), KANDEV_SERVER_PORT: String(ports.backendPort),
@@ -87,6 +101,9 @@ export type WebEnvOptions = {
*/ */
export function buildWebEnv(options: WebEnvOptions): NodeJS.ProcessEnv { export function buildWebEnv(options: WebEnvOptions): NodeJS.ProcessEnv {
const { ports, production = false, debug = false } = options; const { ports, production = false, debug = false } = options;
if (ports.webPort === undefined) {
throw new Error("web env requires a web port");
}
const env: NodeJS.ProcessEnv = { const env: NodeJS.ProcessEnv = {
...process.env, ...process.env,
@@ -171,7 +188,13 @@ export type StartupInfoOptions = {
*/ */
export function logStartupInfo(options: StartupInfoOptions): void { export function logStartupInfo(options: StartupInfoOptions): void {
const { header, ports, primary = "backend", dbPath, logLevel } = options; const { header, ports, primary = "backend", dbPath, logLevel } = options;
const primaryPort = primary === "web" ? ports.webPort : ports.backendPort; 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 primaryUrl = `http://localhost:${primaryPort}`;
const networkHosts = listHostNetworkAddresses(); const networkHosts = listHostNetworkAddresses();
+2 -5
View File
@@ -22,7 +22,7 @@ import {
import { resolveHealthTimeoutMs, waitForHealth } from "./health"; import { resolveHealthTimeoutMs, waitForHealth } from "./health";
import { getBinaryName } from "./platform"; import { getBinaryName } from "./platform";
import { createProcessSupervisor } from "./process"; import { createProcessSupervisor } from "./process";
import { buildBackendEnv, logStartupInfo, pickPorts } from "./shared"; import { buildBackendEnv, logStartupInfo, pickBackendPorts } from "./shared";
import { launchRestartableBackend } from "./supervisor/backend"; import { launchRestartableBackend } from "./supervisor/backend";
import { openBrowser } from "./web"; import { openBrowser } from "./web";
@@ -31,8 +31,6 @@ export type StartOptions = {
repoRoot: string; repoRoot: string;
/** Optional preferred backend port (finds available port if not specified) */ /** Optional preferred backend port (finds available port if not specified) */
backendPort?: number; backendPort?: number;
/** Optional preferred web port (finds available port if not specified) */
webPort?: number;
/** Show info logs from backend + web */ /** Show info logs from backend + web */
verbose?: boolean; verbose?: boolean;
/** Show debug logs + agent message dumps */ /** Show debug logs + agent message dumps */
@@ -56,12 +54,11 @@ export type StartOptions = {
export async function runStart({ export async function runStart({
repoRoot, repoRoot,
backendPort, backendPort,
webPort,
verbose = false, verbose = false,
debug = false, debug = false,
headless = false, headless = false,
}: StartOptions): Promise<void> { }: StartOptions): Promise<void> {
const ports = await pickPorts(backendPort, webPort); const ports = await pickBackendPorts(backendPort);
const backendBin = path.join(repoRoot, "apps", "backend", "bin", getBinaryName("kandev")); const backendBin = path.join(repoRoot, "apps", "backend", "bin", getBinaryName("kandev"));
if (!fs.existsSync(backendBin)) { if (!fs.existsSync(backendBin)) {
+1 -1
View File
@@ -3,6 +3,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
environment: "node", environment: "node",
include: ["src/**/*.test.ts"], include: ["src/**/*.test.ts", "bin/**/*.test.mjs"],
}, },
}); });
+6 -7
View File
@@ -29,13 +29,6 @@ importers:
version: 5.9.3 version: 5.9.3
cli: cli:
dependencies:
tar:
specifier: ^7.5.11
version: 7.5.13
tree-kill:
specifier: ^1.2.2
version: 1.2.2
optionalDependencies: optionalDependencies:
'@kdlbs/runtime-darwin-arm64': '@kdlbs/runtime-darwin-arm64':
specifier: 0.0.0-bootstrap specifier: 0.0.0-bootstrap
@@ -59,6 +52,12 @@ importers:
esbuild: esbuild:
specifier: ^0.24.0 specifier: ^0.24.0
version: 0.24.2 version: 0.24.2
tar:
specifier: ^7.5.11
version: 7.5.13
tree-kill:
specifier: ^1.2.2
version: 1.2.2
tsx: tsx:
specifier: ^4.15.7 specifier: ^4.15.7
version: 4.21.0 version: 4.21.0
+1 -1
View File
@@ -156,7 +156,7 @@ function spawnBackendProcess(
debug: boolean, debug: boolean,
port: number, port: number,
): ChildProcess { ): ChildProcess {
const proc = spawn(KANDEV_BIN, [], { const proc = spawn(KANDEV_BIN, ["__backend"], {
env: env as unknown as NodeJS.ProcessEnv, env: env as unknown as NodeJS.ProcessEnv,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
detached: true, detached: true,
@@ -55,6 +55,31 @@ async function waitForSessionEnvironmentId(
throw new Error(`session for profile ${agentProfileId} did not get environment id: ${details}`); throw new Error(`session for profile ${agentProfileId} did not get environment id: ${details}`);
} }
async function pollSessionsForEnvironmentInheritance(
apiClient: InstanceType<typeof import("../../helpers/api-client").ApiClient>,
taskId: string,
sourceProfileId: string,
targetProfileId: string,
timeoutMs = 30_000,
) {
const start = Date.now();
let latestSessions: Awaited<ReturnType<typeof pollSessions>> = [];
while (Date.now() - start < timeoutMs) {
const { sessions } = await apiClient.listTaskSessions(taskId);
latestSessions = sessions;
const sourceSession = sessions.find((s) => s.agent_profile_id === sourceProfileId);
const targetSession = sessions.find((s) => s.agent_profile_id === targetProfileId);
if (
sourceSession?.task_environment_id &&
targetSession?.task_environment_id === sourceSession.task_environment_id
) {
return sessions;
}
await new Promise((r) => setTimeout(r, 500));
}
return latestSessions;
}
test.describe("Workflow agent profile switching", () => { test.describe("Workflow agent profile switching", () => {
test("manual step move creates new session with step's agent profile", async ({ test("manual step move creates new session with step's agent profile", async ({
apiClient, apiClient,
@@ -402,10 +427,17 @@ test.describe("Workflow agent profile switching", () => {
// Move to Step2 // Move to Step2
await apiClient.moveTask(task.id, workflow.id, step2.id); await apiClient.moveTask(task.id, workflow.id, step2.id);
const step2EnvironmentId = await waitForSessionEnvironmentId(apiClient, task.id, profileB.id); // Wait for the second session and the async environment handoff.
const { sessions: finalSessions } = await apiClient.listTaskSessions(task.id); const finalSessions = await pollSessionsForEnvironmentInheritance(
apiClient,
task.id,
profileA.id,
profileB.id,
30_000,
);
const step2Session = finalSessions.find((s) => s.agent_profile_id === profileB.id); const step2Session = finalSessions.find((s) => s.agent_profile_id === profileB.id);
expect(step2Session).toBeDefined(); expect(step2Session).toBeDefined();
const step2EnvironmentId = step2Session?.task_environment_id;
expect(step2EnvironmentId).toBe(step1EnvironmentId); expect(step2EnvironmentId).toBe(step1EnvironmentId);
}); });
+5 -40
View File
@@ -16,11 +16,9 @@ flowchart TB
resolve --> envvar["KANDEV_BUNDLE_DIR<br/>(Homebrew, tests)"] resolve --> envvar["KANDEV_BUNDLE_DIR<br/>(Homebrew, tests)"]
resolve --> npmpkg["@kdlbs/runtime-{platform}<br/>(npm/npx)"] resolve --> npmpkg["@kdlbs/runtime-{platform}<br/>(npm/npx)"]
resolve --> cache["~/.kandev/bin cache<br/>(--runtime-version only)"]
envvar --> supervisor envvar --> supervisor
npmpkg --> supervisor npmpkg --> supervisor
cache --> supervisor
makedev --> supervisor makedev --> supervisor
binary --> supervisor binary --> supervisor
@@ -89,26 +87,12 @@ kandev run
``` ```
**What happens:** **What happens:**
1. Resolves the runtime bundle (KANDEV_BUNDLE_DIR → npm package → cache). 1. Resolves the runtime bundle (KANDEV_BUNDLE_DIR → npm package).
2. Starts the backend server. 2. Starts the backend server.
3. Waits for backend health check. 3. Waits for backend health check.
4. Starts the web app. 4. Serves the embedded web UI from the backend.
5. Opens browser when ready. 5. Opens browser when ready.
### `kandev dev`
Runs the application in development mode with hot-reloading. Requires a local repository checkout.
```bash
# From the repo root or any subdirectory
kandev dev
```
**What happens:**
1. Locates the repo root (looks for `apps/backend` and `apps/web`).
2. Runs `make dev` for the backend (Go with hot-reload).
3. Runs `pnpm dev` for the web app (Vite dev server).
### `kandev start` ### `kandev start`
Runs the application using the local production backend binary. Requires running Runs the application using the local production backend binary. Requires running
@@ -125,11 +109,9 @@ kandev start
|--------|-------------|---------| |--------|-------------|---------|
| `--version`, `-V` | Print CLI version and exit | `kandev --version` | | `--version`, `-V` | Print CLI version and exit | `kandev --version` |
| `--port <port>` | Backend port (alias: `--backend-port`) | `--port 3000` | | `--port <port>` | Backend port (alias: `--backend-port`) | `--port 3000` |
| `--web-internal-port <port>` | Override internal web app port | `--web-internal-port 13000` | | `--verbose`, `-v` | Show info logs from backend | `--verbose` |
| `--verbose`, `-v` | Show info logs from backend + web | `--verbose` |
| `--debug` | Show debug logs + agent message dumps | `--debug` | | `--debug` | Show debug logs + agent message dumps | `--debug` |
| `--help`, `-h` | Show help | `--help` | | `--help`, `-h` | Show help | `--help` |
| `--runtime-version <tag>` | **Advanced/debug only**: download a specific runtime tag from GitHub releases instead of using the installed runtime | `--runtime-version v0.16.0` |
### Examples ### Examples
@@ -137,14 +119,9 @@ kandev start
# Print CLI version # Print CLI version
kandev --version kandev --version
# Custom ports # Custom port
kandev --port 18080 --web-internal-port 13000 kandev --port 18080
# Dev mode
kandev dev --port 18080
# Force a specific runtime version (debug)
kandev --runtime-version v0.16.0
``` ```
## Port Selection ## Port Selection
@@ -167,8 +144,6 @@ If the default port is in use, the CLI finds the next available port automatical
| `KANDEV_PORT` / `KANDEV_BACKEND_PORT` | Backend port (CLI flag wins) | | `KANDEV_PORT` / `KANDEV_BACKEND_PORT` | Backend port (CLI flag wins) |
| `KANDEV_WEB_PORT` | Internal web app port | | `KANDEV_WEB_PORT` | Internal web app port |
| `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout (ms) | | `KANDEV_HEALTH_TIMEOUT_MS` | Override health check timeout (ms) |
| `KANDEV_GITHUB_OWNER` / `KANDEV_GITHUB_REPO` | Override GitHub repo for `--runtime-version` downloads |
| `KANDEV_GITHUB_TOKEN` | GitHub token for `--runtime-version` API access |
## Makefile Integration ## Makefile Integration
@@ -223,15 +198,6 @@ Increase the health check timeout:
KANDEV_HEALTH_TIMEOUT_MS=60000 kandev KANDEV_HEALTH_TIMEOUT_MS=60000 kandev
``` ```
### Dev Mode: "Unable to locate repo root"
Run from within the kandev repository:
```bash
cd /path/to/kandev
kandev dev
```
### Start Mode: "Backend binary not found" ### Start Mode: "Backend binary not found"
Build the project first: Build the project first:
@@ -245,7 +211,6 @@ kandev start
| Path | Contents | | Path | Contents |
|------|----------| |------|----------|
| `~/.kandev/bin/` | Cached downloads from `--runtime-version` (debug only) |
| `~/.kandev/data/` | SQLite database and app data | | `~/.kandev/data/` | SQLite database and app data |
| Homebrew Cellar | Installed runtime (when installed via brew) | | Homebrew Cellar | Installed runtime (when installed via brew) |
| `<npm cache>/node_modules/@kdlbs/runtime-{platform}/` | Installed runtime (when installed via npm/npx) | | `<npm cache>/node_modules/@kdlbs/runtime-{platform}/` | Installed runtime (when installed via npm/npx) |
@@ -34,9 +34,9 @@ message debug logs; agent message logs are not a separate top-level toggle.
V1 toggle changes require restart. Restart is launcher/supervisor mediated: V1 toggle changes require restart. Restart is launcher/supervisor mediated:
- In CLI-managed modes (`kandev start`, `kandev run`, `kandev dev`), the Go - In CLI-managed modes (`kandev start`, `kandev run`), the Go backend requests
backend requests restart from the Node launcher, and the launcher restarts restart from the native launcher, and the launcher restarts its backend
its backend and web children. child.
- In service-managed modes, restart delegates to systemd or launchd when the - In service-managed modes, restart delegates to systemd or launchd when the
backend can prove it is safe. backend can prove it is safe.
- If no supported supervisor is available, the UI shows manual restart - If no supported supervisor is available, the UI shows manual restart
+3 -3
View File
@@ -96,7 +96,7 @@ kandev service config [--system]
If Kandev is running as a user-mode service installed by `kandev service install`, open **Settings → System → Updates** and use **Apply update** when it appears. The button is shown only when the backend can prove it is running from a kandev-managed service unit/plist with valid service metadata. If Kandev is running as a user-mode service installed by `kandev service install`, open **Settings → System → Updates** and use **Apply update** when it appears. The button is shown only when the backend can prove it is running from a kandev-managed service unit/plist with valid service metadata.
Both `npm` and Homebrew install kandev under a versioned directory (e.g. `node_modules/kandev/0.49.0/`, `Cellar/kandev/0.49.0/`). Manual upgrades replace those paths, so the unit file must be refreshed afterward. Both npm runtime packages and Homebrew install kandev under versioned package-manager paths. Manual upgrades replace those paths, so the unit file must be refreshed afterward.
**Manual fix:** re-run install. It's idempotent. **Manual fix:** re-run install. It's idempotent.
@@ -105,7 +105,7 @@ npm i -g kandev@latest # or: brew upgrade kandev
kandev service install # rewrites the unit with the new paths kandev service install # rewrites the unit with the new paths
``` ```
If you launch `kandev` interactively after an upgrade, it will detect a stale unit and print a one-line reminder. You can also check with `kandev service config` — the `cli entry:` and `node path:` lines tell you the paths that *would* be baked in by the next install. If you launch `kandev` interactively after an upgrade, it will detect a stale unit and print a one-line reminder. You can also check with `kandev service config` to see the paths that would be baked in by the next install.
System services (`kandev service install --system`) do not expose UI self-update. Update them from a privileged shell, then re-run `sudo kandev service install --system`. System services (`kandev service install --system`) do not expose UI self-update. Update them from a privileged shell, then re-run `sudo kandev service install --system`.
@@ -139,7 +139,7 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/bin/node /usr/local/lib/node_modules/kandev/bin/cli.js --headless ExecStart=/usr/local/lib/node_modules/@kdlbs/runtime-linux-x64/bin/kandev --headless
Environment=KANDEV_HOME_DIR=/home/alice/.kandev Environment=KANDEV_HOME_DIR=/home/alice/.kandev
Environment=KANDEV_LOG_LEVEL=info Environment=KANDEV_LOG_LEVEL=info
Environment=PATH=%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin Environment=PATH=%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin
+1
View File
@@ -107,6 +107,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
|---|---| |---|---|
| [improve-kandev](improve-kandev/spec.md) | draft | | [improve-kandev](improve-kandev/spec.md) | draft |
| [homebrew-core](homebrew-core/spec.md) | draft | | [homebrew-core](homebrew-core/spec.md) | draft |
| [native-kandev-cli](native-kandev-cli/spec.md) | draft |
| [public-share-links](public-share-links/spec.md) | draft | | [public-share-links](public-share-links/spec.md) | draft |
| [ssh-executor](ssh-executor/spec.md) | draft | | [ssh-executor](ssh-executor/spec.md) | draft |
| [cli-mode-parity](cli-mode-parity/spec.md) | draft | | [cli-mode-parity](cli-mode-parity/spec.md) | draft |
+4 -4
View File
@@ -16,17 +16,17 @@ Kandev is currently installable via `brew install kdlbs/kandev/kandev` from the
## What ## What
- Author a homebrew-core-compliant `Formula/kandev.rb` that **builds entirely from source** — Go backend (cgo + sqlite via `mattn/go-sqlite3`), Vite web bundle, and the TypeScript CLI esbuild bundle. - Author a homebrew-core-compliant `Formula/kandev.rb` that **builds entirely from source** — native Go `kandev` launcher/backend binary (cgo + sqlite via `mattn/go-sqlite3`), agentctl helper binaries, and the Vite web bundle embedded into the Go binary.
- Source comes from the GitHub-generated tag archive (`https://github.com/kdlbs/kandev/archive/refs/tags/vX.Y.Z.tar.gz`); per-release sha256 is captured at submission/bump time. - Source comes from the GitHub-generated tag archive (`https://github.com/kdlbs/kandev/archive/refs/tags/vX.Y.Z.tar.gz`); per-release sha256 is captured at submission/bump time.
- Build deps: `go => :build`, `pnpm => :build`. Runtime dep: `node` (CLI launcher). `uses_from_macos "sqlite"` (cgo sqlite3 link) ships with macOS and is only installed via Homebrew on Linux. - Build deps: `go => :build`, `pnpm => :build`. Runtime dep: none for Node; `uses_from_macos "sqlite"` (cgo sqlite3 link) ships with macOS and is only installed via Homebrew on Linux.
- Install layout: `libexec/{bin,web,cli}` plus a single `bin/kandev` wrapper produced by `write_env_script`, setting `KANDEV_BUNDLE_DIR=<libexec>` and `KANDEV_VERSION=<version>`. This is the contract `apps/cli/src/runtime.ts` already honors. - Install layout: `libexec/bin` plus a single `bin/kandev` wrapper produced by `write_env_script`, setting `KANDEV_BUNDLE_DIR=<libexec>` and `KANDEV_VERSION=<version>`. The native launcher uses these values to find helper binaries and log the installed release version.
- `livecheck do; url :stable; regex(/^v?(\d+(?:\.\d+)+)$/i); end` — Git strategy (the default for `url :stable`) checks tags directly and avoids the GitHub API rate limit. - `livecheck do; url :stable; regex(/^v?(\d+(?:\.\d+)+)$/i); end` — Git strategy (the default for `url :stable`) checks tags directly and avoids the GitHub API rate limit.
- Test block: spawns `libexec/bin/kandev` with an isolated `KANDEV_HOME_DIR` and random `KANDEV_SERVER_PORT`, polls `/api/v1/system/health` until the response contains `"healthy"` (60s timeout), then shuts the backend down. Also asserts the formula `version` appears in `bin/kandev --version`. - Test block: spawns `libexec/bin/kandev` with an isolated `KANDEV_HOME_DIR` and random `KANDEV_SERVER_PORT`, polls `/api/v1/system/health` until the response contains `"healthy"` (60s timeout), then shuts the backend down. Also asserts the formula `version` appears in `bin/kandev --version`.
- Tap (`kdlbs/homebrew-kandev`) and its update script (`scripts/release/update-homebrew-tap.sh`) stay untouched — it remains the binary-install fast path; the homebrew-core formula is a parallel, source-built distribution. - Tap (`kdlbs/homebrew-kandev`) and its update script (`scripts/release/update-homebrew-tap.sh`) stay untouched — it remains the binary-install fast path; the homebrew-core formula is a parallel, source-built distribution.
## Scenarios ## Scenarios
- **GIVEN** the homebrew-core PR is merged, **WHEN** a macOS user runs `brew install kandev`, **THEN** Homebrew downloads the source tarball, compiles the Go binaries, builds the Next.js + CLI bundles, installs them under `Cellar/kandev/X.Y.Z/{bin,libexec}`, and `kandev --help` prints "kandev launcher". - **GIVEN** the homebrew-core PR is merged, **WHEN** a macOS user runs `brew install kandev`, **THEN** Homebrew downloads the source tarball, builds the Vite web assets, syncs them into the Go embed directory, compiles the Go binaries, installs them under `Cellar/kandev/X.Y.Z/{bin,libexec}`, and `kandev --help` prints "kandev launcher".
- **GIVEN** a new kandev release `vX.Y.Z` is tagged, **WHEN** Homebrew's auto-bump worker runs, **THEN** `livecheck` resolves the new tag from GitHub Releases and a bump PR is opened against the formula. - **GIVEN** a new kandev release `vX.Y.Z` is tagged, **WHEN** Homebrew's auto-bump worker runs, **THEN** `livecheck` resolves the new tag from GitHub Releases and a bump PR is opened against the formula.
- **GIVEN** a maintainer reviews the PR, **WHEN** they run `brew install --build-from-source kandev` locally, **THEN** the build completes without network or sandbox failures and `brew test kandev` passes. - **GIVEN** a maintainer reviews the PR, **WHEN** they run `brew install --build-from-source kandev` locally, **THEN** the build completes without network or sandbox failures and `brew test kandev` passes.
+363
View File
@@ -0,0 +1,363 @@
---
spec: native-kandev-cli
created: 2026-06-15
status: draft
---
# Implementation Plan: Native Kandev CLI
## Overview
Refactor the current backend command into a callable backend app, then make `apps/backend/cmd/kandev` a unified native executable that dispatches public launcher commands by default and hidden backend mode via `__backend`. Port the existing TypeScript launcher behavior into Go in phases, starting with `start` so `make start` stops depending on `pnpm`/`tsx`, then `run`, restart supervision, services, `dev`, release packaging, and finally the npm shim.
---
## Backend
### Backend App Extraction
Files:
- `apps/backend/cmd/kandev/main.go`
- `apps/backend/internal/backendapp/app.go` (new)
Changes:
- Move the current server startup logic from `cmd/kandev/main.go` into `internal/backendapp`.
- Expose `func Run(args []string) int` or equivalent.
- Use a private `flag.FlagSet` inside backend mode rather than package-global `flag.CommandLine`, so launcher parsing and backend parsing cannot conflict.
- Preserve current backend flags: `-port`, `-log-level`, `-help`, `-version`.
- Keep build metadata (`Version`, `Commit`, `BuildTime`) available to both launcher and backend mode.
Reason:
- A single `bin/kandev` binary needs to run either launcher code or backend server code without building a second backend artifact.
### Unified Entrypoint
Files:
- `apps/backend/cmd/kandev/main.go`
Changes:
- Replace direct backend startup with dispatch:
```go
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
if len(args) > 0 && args[0] == "__backend" {
return backendapp.Run(args[1:])
}
return launcher.Run(args)
}
```
- Keep `__backend` hidden from public help.
- Add tests for dispatch if `run` can be tested without launching the server.
Reason:
- The public executable remains `kandev`, while the launcher can supervise a restartable backend child by spawning itself.
### Launcher Package
Files:
- `apps/backend/internal/launcher/cli`
- `apps/backend/internal/launcher/ports`
- `apps/backend/internal/launcher/paths`
- `apps/backend/internal/launcher/env`
- `apps/backend/internal/launcher/bundle`
- `apps/backend/internal/launcher/health`
- `apps/backend/internal/launcher/process`
- `apps/backend/internal/launcher/supervisor`
- `apps/backend/internal/launcher/service`
Changes:
- Port current behavior from `apps/cli/src/args.ts`, `ports.ts`, `constants.ts`, `shared.ts`, `health.ts`, `web.ts`, `process.ts`, and `supervisor/*`.
- Keep packages narrow to satisfy backend lint limits.
- Use standard library APIs first:
- `net.Listen("tcp", "127.0.0.1:0")` for free ports.
- `net/http` for health polling.
- `os/exec` for child processes.
- `os/signal` and platform-specific process group handling for shutdown.
Reason:
- This replaces the Node launcher logic while preserving behavior.
### `start` Mode
Files:
- `apps/backend/internal/launcher/start`
- `Makefile`
Changes:
- Detect repo root using current `findRepoRoot` semantics.
- Resolve local backend by using `os.Executable()` and spawning `<self> __backend`.
- Start backend and wait for health; the backend serves the embedded Vite SPA.
- Update `make start` to invoke `apps/backend/bin/kandev start`.
Reason:
- This is the quickest user-visible reduction in Node launcher dependency for local production startup.
### `run` Mode
Files:
- `apps/backend/internal/launcher/run`
- `apps/backend/internal/launcher/bundle`
- `.github/workflows/release.yml`
Changes:
- Validate release bundle layout with one `bin/kandev` executable.
- Resolve runtime via `KANDEV_BUNDLE_DIR` and explicit bundle context.
- Spawn backend using `<bundle>/bin/kandev __backend`.
- Preserve quiet backend log buffering and verbose/debug/headless behavior.
- Decide separately whether to port `--runtime-version`; if deferred, return a clear unsupported/deprecated error.
Reason:
- This replaces the installed runtime launch path used by Homebrew/manual bundles.
### Restart Supervisor
Files:
- `apps/backend/internal/launcher/supervisor`
- existing backend restart adapter call sites, if they assume manifest shape
Changes:
- Port manifest/control/socket behavior from `apps/cli/src/supervisor/*`.
- Manifest records:
- `backend_executable`: absolute path to `bin/kandev`
- `argv`: `["__backend"]`
- backend env allowlist
- mode, port, cwd, home dir
- Restart control restarts only the backend child process.
Reason:
- Preserves ADR 0019 while avoiding a separate backend binary.
### Service Management
Files:
- `apps/backend/internal/launcher/service`
- `apps/backend/internal/launcher/service/systemd`
- `apps/backend/internal/launcher/service/launchd`
- `Makefile` service targets
Changes:
- Port service argument parsing, help, config output, metadata, systemd unit rendering, launchd plist rendering, install/uninstall/status/logs/restart.
- Generated units execute public `kandev`.
- Service launch path starts backend through hidden child mode.
- Preserve stale-service detection using installed metadata.
Reason:
- Services are part of the public launcher surface and must stop depending on the Node CLI.
### Dev Mode
Files:
- `apps/backend/internal/launcher/dev`
- `Makefile`
Changes:
- Port repo-root detection, dev env, production DB backup guard, and task-workspace detection.
- Prefer spawning `<self> __backend` with dev env if it preserves current behavior.
- Keep `pnpm --filter @kandev/web dev` for the web dev server.
- Preserve Windows `winjob` wrapping if still needed.
- Switch `make dev` only after `dev` parity is proven.
Reason:
- Dev mode is user-facing for contributors but still intentionally depends on pnpm/Node for the web dev server.
---
## Frontend
No web UI changes are required by this spec.
---
## Packaging
### Release Bundles
Files:
- `.github/workflows/release.yml`
- `scripts/release/package-bundle.sh`
- release packaging scripts for runtime npm packages
Changes:
- Package one primary native executable:
```text
kandev/
├── bin/kandev
├── bin/agentctl
└── bin/agentctl-linux-amd64
```
- Stop requiring `dist/kandev/cli` for Homebrew; platform bundles contain native `bin/` artifacts with web assets embedded in `bin/kandev`.
- Add smoke tests for `bin/kandev --help`, `bin/kandev --version`, `bin/kandev run --headless`, and `bin/kandev service --help`.
### npm/npx Shim
Files:
- `apps/cli/bin/cli.js`
- `apps/cli/package.json`
- possibly a small `apps/cli/src` shim test if TypeScript remains
Changes:
- Replace full launcher implementation with a small JS shim.
- Shim maps platform/arch to `@kdlbs/runtime-*`.
- Shim resolves the runtime package and execs `bin/kandev(.exe)` with inherited stdio.
- Remove runtime dependencies from the published npm package; keep old TS launcher dependencies as dev-only test/build dependencies until the legacy source is deleted.
---
## Docs
Files:
- `apps/cli/README_internal.md`
- `apps/cli/README.md`
- `docs/specs/INDEX.md`
- root/scoped `AGENTS.md` if package layout guidance changes
Changes:
- Document that `kandev` is native for Homebrew/release bundles.
- Document that npm/npx still uses Node as the package dispatch mechanism.
- Remove stale references to Homebrew requiring `dist/cli.bundle.js`.
---
## Tests
- **What:** public launcher help hides internal backend mode.
**File:** `apps/backend/internal/launcher/cli/*_test.go`
**How:** table-driven parser/help test.
- **What:** `kandev __backend` dispatches to backend mode.
**File:** `apps/backend/cmd/kandev/*_test.go` or `apps/backend/internal/backendapp/*_test.go`
**How:** unit test around dispatchable function; avoid starting the full server where possible.
- **What:** port/env precedence matches current CLI behavior.
**File:** `apps/backend/internal/launcher/env/*_test.go`, `apps/backend/internal/launcher/ports/*_test.go`
**How:** table-driven unit tests for flags and env vars.
- **What:** `start` runs the native backend with embedded or filesystem Vite assets.
**File:** `apps/backend/internal/launcher/start/*_test.go`
**How:** temp directory fixtures for repo roots, `apps/web/dist`, and embedded-asset fallback.
- **What:** bundle validation accepts the single-binary layout.
**File:** `apps/backend/internal/launcher/bundle/*_test.go`
**How:** temp bundle fixtures with missing-artifact cases.
- **What:** health failures surface captured backend output.
**File:** `apps/backend/internal/launcher/health/*_test.go` or `run/*_test.go`
**How:** `httptest` and short-lived helper process.
- **What:** supervisor manifest restarts same binary with `__backend`.
**File:** `apps/backend/internal/launcher/supervisor/*_test.go`
**How:** golden manifest test and restart child test with helper executable.
- **What:** service units execute public `kandev`.
**File:** `apps/backend/internal/launcher/service/systemd/*_test.go`, `apps/backend/internal/launcher/service/launchd/*_test.go`
**How:** golden file tests for rendered unit/plist.
- **What:** npm shim resolves runtime package and execs native `bin/kandev`.
**File:** `apps/cli/*`
**How:** fixture `node_modules/@kdlbs/runtime-*` package and mocked child process spawn.
---
## E2E Tests
No browser E2E test is required because the feature has no web UI surface.
Add release/local smoke coverage instead:
- **Scenario:** GIVEN a local production build, WHEN `make start` runs, THEN it uses native `apps/backend/bin/kandev start`.
**File:** release smoke script or backend integration test.
**What to verify:** process starts and health endpoints become ready.
- **Scenario:** GIVEN a bundle fixture, WHEN `bin/kandev run --headless` runs, THEN backend and web become ready.
**File:** release smoke script.
**What to verify:** health endpoint and ready output.
---
## Implementation Waves
Wave 1: Backend extraction and dispatch
- Refactor backend app into callable package.
- Add hidden `__backend` dispatch.
- Add launcher skeleton and parser/help tests.
Wave 2: Shared launcher primitives and `start`
- Port env, ports, paths, health, browser/process helpers.
- Implement `start`.
- Switch `make start`.
Wave 3: Installed runtime `run`
- Port bundle validation and run launch path.
- Add same-binary backend spawning.
- Add bundle smoke tests.
Wave 4: Supervisor and services
- Port restart supervisor.
- Port systemd/launchd service management.
- Add golden tests.
Wave 5: Dev mode
- Port `dev`.
- Switch `make dev` only after parity is confirmed.
Wave 6: Packaging cutover
- Update release bundle shape.
- Convert npm package to thin shim.
- Remove old Node launcher bundle from Homebrew path.
- Update docs.
Wave 7: Cleanup and verification
- Delete obsolete TS launcher modules.
- Run format, tests, lint, and release smoke tests.
---
## Open Questions
- Use `__backend` argv token or `KANDEV_INTERNAL_ROLE=backend` env for hidden backend mode? Recommendation: `__backend`.
- Preserve `--runtime-version <tag>` in the first Go port, or defer/deprecate it?
- Ship old Node CLI fallback for one release while Homebrew/npm packaging switches over?
+155
View File
@@ -0,0 +1,155 @@
---
status: draft
created: 2026-06-15
owner: tbd
---
# Native Kandev CLI
## Why
Users start Kandev from the terminal through `kandev`, but the current launcher is a Node.js/TypeScript program even when Kandev is installed from Homebrew or run from a local production build. This adds a runtime dependency and extra moving parts to the most basic startup path. Users should keep the same `kandev` command while the launcher becomes a native executable.
## What
- `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`.
- 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.
- npm/npx continues to expose the `kandev` command through the `kandev` npm package. The npm package may use a minimal JavaScript shim, but that shim only resolves the platform runtime package and execs its native `bin/kandev`.
- `make start` launches through the native `apps/backend/bin/kandev start` path after building local artifacts.
- Existing service installs continue to be managed by `kandev service ...`; newly generated service units execute the public `kandev` launcher path.
- Startup output continues to show the URL users should open, the MCP URL, database path when applicable, and log level when applicable.
- Production `run` and `start` do not execute a Node.js web runtime; the Go backend serves the embedded Vite SPA assets.
## API surface
### Public CLI
```text
kandev [--port <port>] [--verbose] [--debug] [--headless]
kandev run [--port <port>] [--verbose] [--debug] [--headless]
kandev start [--port <port>] [--verbose] [--debug] [--headless]
kandev service install|uninstall|start|stop|restart|status|logs|config [--system]
kandev --version
kandev --help
```
Port flags and environment variables:
- `--port` and `--backend-port` select the public backend port.
- `KANDEV_PORT` and `KANDEV_BACKEND_PORT` provide backend port defaults.
- There is no production web port flag or environment variable. The Go backend serves the embedded SPA on the backend port.
- `--runtime-version` is not supported by the native launcher and is rejected as a usage error.
Logging/debug flags:
- `--verbose` shows backend info logs.
- `--debug` shows debug logs and enables current debug environment behavior.
- `--headless` skips browser opening and prints the ready URL.
### Hidden backend mode
The native launcher has a private backend mode:
```text
kandev __backend [backend flags]
```
This mode starts the backend server and is invoked only by the launcher/supervisor. It is intentionally hidden from public help output. Existing backend diagnostic flags may remain available in this mode for tests and direct diagnostics.
### Runtime bundle
Release bundles expose this layout:
```text
kandev/
├── bin/kandev
├── bin/agentctl
└── bin/agentctl-linux-amd64
```
`bin/kandev` is both the public launcher and the hidden backend-mode executable.
### Supervisor manifest
When backend restart supervision is enabled, the launch manifest records the same executable plus hidden backend argv:
```json
{
"version": 1,
"backend_executable": "/absolute/path/to/bin/kandev",
"argv": ["__backend"],
"cwd": "/absolute/path/to/bin",
"env": {
"KANDEV_SERVER_PORT": "38429",
"KANDEV_AGENT_STANDALONE_PORT": "39429",
"KANDEV_RESTART_ADAPTER": "supervisor"
},
"home_dir": "/absolute/path/to/home",
"port": 38429,
"mode": "run",
"created_at": "timestamp"
}
```
## State machine
Launcher process lifecycle:
- `idle`: command parsed but no children started.
- `backend-starting`: launcher has spawned `kandev __backend` and is waiting for backend health.
- `ready`: backend is healthy and serving API, WebSocket, and SPA routes; browser may be opened unless headless.
- `restarting-backend`: supervisor received a restart request and is replacing only the backend child.
- `shutting-down`: launcher is terminating child processes after signal, service stop, or child exit policy.
- `failed`: startup failed and the launcher exits non-zero after surfacing the actionable error.
Transitions:
- `idle` -> `backend-starting`: user runs `kandev`, `kandev run`, `kandev start`, or service unit starts.
- `backend-starting` -> `failed`: backend exits or health timeout expires.
- `backend-starting` -> `ready`: backend health endpoint reports ready.
- `ready` -> `restarting-backend`: backend restart adapter requests restart.
- `restarting-backend` -> `ready`: replacement backend becomes healthy.
- any active state -> `shutting-down`: signal, service stop, or non-restartable child exit.
## Failure modes
- If a required backend artifact is missing in `start` mode, `kandev start` exits non-zero with a message that tells the user to run `make build`.
- If the runtime bundle is missing required files in `run` mode, `kandev run` exits non-zero and names the missing artifact.
- If the backend does not become healthy before timeout, the launcher exits non-zero and includes captured backend output when running in quiet mode.
- If the backend child exits unexpectedly after startup, the launcher either restarts it through the supervisor path when the exit is restart-controlled or shuts down the app with the backend exit code.
- If npm/npx optional runtime resolution fails, the JavaScript shim exits non-zero with an actionable message explaining that the platform runtime package is missing.
## Persistence guarantees
- Service installation metadata remains durable across restarts and upgrades.
- Supervisor manifest/control files remain under the configured Kandev home directory and are overwritten on each launch with the current executable path and backend argv.
- The launcher does not persist child process state beyond the existing supervisor manifest. After a launcher restart, it starts a fresh backend child.
- User data, database files, task worktrees, and backend-managed persistence remain owned by existing backend persistence behavior; the launcher migration does not change their retention.
## Scenarios
- **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** 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.
- **GIVEN** a user runs `npx kandev@latest --help`, **WHEN** npm has installed the platform runtime package, **THEN** the npm shim execs the runtime package's native `bin/kandev` and the user sees the same public help output.
## Out of scope
- Making `npx` itself Node-free.
- Introducing `kandevctl` as a user-facing command.
- Requiring a separate shipped backend binary such as `kandev-backend`.
- Changing task, workflow, agent, or integration behavior after the backend has started.
## Open questions
- Should hidden backend mode be selected by the `__backend` argv token or by `KANDEV_INTERNAL_ROLE=backend`? Recommendation: `__backend`.
- Should `--runtime-version <tag>` be ported in a later native launcher implementation or remain deprecated?
- Should one release ship both the old Node CLI bundle and the native `bin/kandev` for compatibility while Homebrew/npm packaging switches over?
+1 -1
View File
@@ -105,7 +105,7 @@ Each Playwright worker spawns an isolated `kandev` backend that also serves the
- `KANDEV_DOCKER_ENABLED=false` — no Docker - `KANDEV_DOCKER_ENABLED=false` — no Docker
- `KANDEV_WORKTREE_ENABLED=false` — no worktrees - `KANDEV_WORKTREE_ENABLED=false` — no worktrees
- `GH_TOKEN` / `GITHUB_TOKEN` stripped — prevents accidental real API calls - `GH_TOKEN` / `GITHUB_TOKEN` stripped — prevents accidental real API calls
3. Spawns `apps/backend/bin/kandev` and waits for `/health` to return 200 3. Spawns `apps/backend/bin/kandev __backend` and waits for `/health` to return 200
**Frontend:** **Frontend:**
1. Sets `KANDEV_WEB_DIST_DIR` to `apps/web/dist` 1. Sets `KANDEV_WEB_DIST_DIR` to `apps/web/dist`
+1 -6
View File
@@ -48,7 +48,7 @@ RUN pnpm install --frozen-lockfile
# Copy full source for build # Copy full source for build
COPY apps/ ./ COPY apps/ ./
# Build web app (produces .next/standalone/ needed by E2E fixture) # Build web app (produces Vite dist/ used by the E2E fixture)
RUN pnpm --filter @kandev/web build RUN pnpm --filter @kandev/web build
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -79,11 +79,6 @@ COPY --from=go-builder /out/mock-agent ./apps/backend/bin/mock-agent
# agentctl must be in PATH — kandev spawns it as a subprocess # agentctl must be in PATH — kandev spawns it as a subprocess
COPY --from=go-builder /out/agentctl /usr/local/bin/agentctl COPY --from=go-builder /out/agentctl /usr/local/bin/agentctl
# Create static/public symlinks for standalone server (mirrors global-setup.ts)
RUN mkdir -p /app/apps/web/.next/standalone/web/.next && \
ln -sf /app/apps/web/.next/static /app/apps/web/.next/standalone/web/.next/static && \
ln -sf /app/apps/web/public /app/apps/web/.next/standalone/web/public
# Store Playwright browsers in a shared location accessible by all users # Store Playwright browsers in a shared location accessible by all users
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
+66 -17
View File
@@ -11,6 +11,7 @@ from typing import Any
MAX_INLINE_FINDINGS = 20 MAX_INLINE_FINDINGS = 20
MAX_COMMENT_BODY = 60000 MAX_COMMENT_BODY = 60000
OMISSION_NOTICE_RESERVE = 256
NO_FINDINGS_MARKER = "<!-- opencode-review:no-findings -->" NO_FINDINGS_MARKER = "<!-- opencode-review:no-findings -->"
DIAGNOSTIC_MARKER = "<!-- opencode-review:diagnostic -->" DIAGNOSTIC_MARKER = "<!-- opencode-review:diagnostic -->"
FALLBACK_MARKER = "<!-- opencode-review:fallback-findings -->" FALLBACK_MARKER = "<!-- opencode-review:fallback-findings -->"
@@ -25,12 +26,20 @@ def required_env(name: str) -> str:
def gh_api(args: list[str]) -> subprocess.CompletedProcess[str]: def gh_api(args: list[str]) -> subprocess.CompletedProcess[str]:
gh_bin = os.environ.get("GH_BIN", "gh") gh_bin = os.environ.get("GH_BIN", "gh")
return subprocess.run( try:
[gh_bin, "api", *args], return subprocess.run(
text=True, [gh_bin, "api", *args],
capture_output=True, text=True,
check=False, capture_output=True,
) check=False,
)
except FileNotFoundError as exc:
return subprocess.CompletedProcess(
args=[gh_bin, "api", *args],
returncode=127,
stdout="",
stderr=f"Binary not found: {exc}",
)
def list_issue_comments() -> list[dict[str, Any]]: def list_issue_comments() -> list[dict[str, Any]]:
@@ -102,8 +111,12 @@ def parse_findings(raw: str) -> tuple[list[Any] | None, str | None]:
match = re.search(r"<opencode_findings>\s*(.*?)\s*</opencode_findings>", raw, re.S) match = re.search(r"<opencode_findings>\s*(.*?)\s*</opencode_findings>", raw, re.S)
if not match: if not match:
return None, "No parseable findings block was found." return None, "No parseable findings block was found."
payload_text = match.group(1).strip()
fenced = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", payload_text, re.S)
if fenced:
payload_text = fenced.group(1).strip()
try: try:
payload = json.loads(match.group(1)) payload = json.loads(payload_text)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
return None, f"Findings block contained invalid JSON: {exc}" return None, f"Findings block contained invalid JSON: {exc}"
if not isinstance(payload, list): if not isinstance(payload, list):
@@ -118,7 +131,7 @@ def diagnostic_body(reason: str, status: int, stdout: str, stderr: str) -> str:
DIAGNOSTIC_MARKER, DIAGNOSTIC_MARKER,
"**OpenCode review diagnostic**", "**OpenCode review diagnostic**",
"", "",
"OpenCode did not produce parseable findings, so the review check failed instead of silently skipping comments.", "OpenCode did not produce parseable findings, so the workflow posted this diagnostic instead of silently skipping comments.",
"", "",
f"OpenCode model: `{model}`", f"OpenCode model: `{model}`",
f"Commit: `{short_sha}`", f"Commit: `{short_sha}`",
@@ -148,6 +161,7 @@ def no_findings_body() -> str:
def fallback_body(rejected: list[dict[str, Any]], overflow: list[dict[str, Any]]) -> tuple[str, int, int]: def fallback_body(rejected: list[dict[str, Any]], overflow: list[dict[str, Any]]) -> tuple[str, int, int]:
model = required_env("OPENCODE_MODEL") model = required_env("OPENCODE_MODEL")
short_sha = required_env("HEAD_SHA")[:12] short_sha = required_env("HEAD_SHA")[:12]
body_limit = MAX_COMMENT_BODY - OMISSION_NOTICE_RESERVE
body = "\n".join( body = "\n".join(
[ [
FALLBACK_MARKER, FALLBACK_MARKER,
@@ -167,7 +181,7 @@ def fallback_body(rejected: list[dict[str, Any]], overflow: list[dict[str, Any]]
if not findings: if not findings:
return return
heading = f"\n\n## {title}" heading = f"\n\n## {title}"
if len(body) + len(heading) > MAX_COMMENT_BODY: if len(body) + len(heading) > body_limit:
omitted += len(findings) omitted += len(findings)
return return
body += heading body += heading
@@ -180,7 +194,7 @@ def fallback_body(rejected: list[dict[str, Any]], overflow: list[dict[str, Any]]
str(finding["body"]).strip(), str(finding["body"]).strip(),
] ]
) )
if len(body) + len(section) > MAX_COMMENT_BODY: if len(body) + len(section) > body_limit:
omitted += 1 omitted += 1
continue continue
body += section body += section
@@ -191,8 +205,9 @@ def fallback_body(rejected: list[dict[str, Any]], overflow: list[dict[str, Any]]
if omitted: if omitted:
notice = f"\n\n_Additional fallback findings omitted from this comment due to GitHub body size limits: {omitted}._" notice = f"\n\n_Additional fallback findings omitted from this comment due to GitHub body size limits: {omitted}._"
if len(body) + len(notice) <= MAX_COMMENT_BODY: if len(notice) > OMISSION_NOTICE_RESERVE:
body += notice notice = notice[: OMISSION_NOTICE_RESERVE - 3].rstrip() + "..."
body += notice
return body, included, omitted return body, included, omitted
@@ -204,7 +219,16 @@ def normalize_finding(value: Any, changed_paths: set[str]) -> dict[str, Any] | N
line = value.get("line") line = value.get("line")
title = value.get("title") title = value.get("title")
body = value.get("body") body = value.get("body")
if not isinstance(path, str) or not isinstance(line, int): if not isinstance(path, str):
print(f"Skipped OpenCode finding with invalid path value: {path}")
return None
if isinstance(line, bool):
print(f"Skipped OpenCode finding with invalid line value: {path}:{line}")
return None
if isinstance(line, float) and line.is_integer():
line = int(line)
if not isinstance(line, int):
print(f"Skipped OpenCode finding with invalid line value: {path}:{line}")
return None return None
if not isinstance(title, str) or not isinstance(body, str): if not isinstance(title, str) or not isinstance(body, str):
return None return None
@@ -246,7 +270,16 @@ def post_inline_finding(finding: dict[str, Any]) -> tuple[bool, str]:
return False, result.stderr.strip() return False, result.stderr.strip()
def handle_diagnostic(args: argparse.Namespace, reason: str, status: int, stdout: str, stderr: str, raw: str) -> int: def handle_diagnostic(
args: argparse.Namespace,
reason: str,
status: int,
stdout: str,
stderr: str,
raw: str,
*,
fail_check: bool,
) -> int:
append_summary( append_summary(
args.summary, args.summary,
"OpenCode review diagnostic", "OpenCode review diagnostic",
@@ -261,7 +294,7 @@ def handle_diagnostic(args: argparse.Namespace, reason: str, status: int, stdout
) )
upsert_issue_comment(DIAGNOSTIC_MARKER, diagnostic_body(reason, status, stdout, stderr)) upsert_issue_comment(DIAGNOSTIC_MARKER, diagnostic_body(reason, status, stdout, stderr))
print(f"OpenCode did not produce parseable findings: {reason}", file=sys.stderr) print(f"OpenCode did not produce parseable findings: {reason}", file=sys.stderr)
return 1 return 1 if fail_check else 0
def post_findings(args: argparse.Namespace) -> int: def post_findings(args: argparse.Namespace) -> int:
@@ -271,11 +304,27 @@ def post_findings(args: argparse.Namespace) -> int:
status = read_status(args.opencode_status) status = read_status(args.opencode_status)
if status != 0: if status != 0:
return handle_diagnostic(args, f"OpenCode exited with status {status}.", status, stdout, stderr, raw) return handle_diagnostic(
args,
f"OpenCode exited with status {status}.",
status,
stdout,
stderr,
raw,
fail_check=True,
)
findings, error = parse_findings(raw) findings, error = parse_findings(raw)
if error is not None or findings is None: if error is not None or findings is None:
return handle_diagnostic(args, error or "Unknown parser error.", status, stdout, stderr, raw) return handle_diagnostic(
args,
error or "Unknown parser error.",
status,
stdout,
stderr,
raw,
fail_check=False,
)
if not findings: if not findings:
posted_no_findings = upsert_issue_comment(NO_FINDINGS_MARKER, no_findings_body()) posted_no_findings = upsert_issue_comment(NO_FINDINGS_MARKER, no_findings_body())
+54 -6
View File
@@ -78,11 +78,12 @@ class OpenCodeReviewScriptTest(unittest.TestCase):
output: str, output: str,
inline_fail: bool = False, inline_fail: bool = False,
issue_comment_fail: bool = False, issue_comment_fail: bool = False,
gh_bin: Path | None = None,
) -> subprocess.CompletedProcess[str]: ) -> subprocess.CompletedProcess[str]:
self.output_path.write_text(output, encoding="utf-8") self.output_path.write_text(output, encoding="utf-8")
env = { env = {
**os.environ, **os.environ,
"GH_BIN": str(self.fake_gh), "GH_BIN": str(gh_bin or self.fake_gh),
"GH_CALLS": str(self.calls_path), "GH_CALLS": str(self.calls_path),
"GITHUB_REPOSITORY": "kdlbs/kandev", "GITHUB_REPOSITORY": "kdlbs/kandev",
"PR_NUMBER": "42", "PR_NUMBER": "42",
@@ -130,10 +131,10 @@ class OpenCodeReviewScriptTest(unittest.TestCase):
bodies.append(call[index + 1][len("body=") :]) bodies.append(call[index + 1][len("body=") :])
return bodies return bodies
def test_missing_findings_block_fails_and_posts_diagnostic(self) -> None: def test_missing_findings_block_posts_diagnostic_without_failing(self) -> None:
result = self.run_script(output="OpenCode refused to read external_directory (/tmp/*)\n") result = self.run_script(output="OpenCode refused to read external_directory (/tmp/*)\n")
self.assertNotEqual(result.returncode, 0) self.assertEqual(result.returncode, 0)
self.assertIn("did not produce parseable findings", result.stderr) self.assertIn("did not produce parseable findings", result.stderr)
self.assertTrue( self.assertTrue(
any("<!-- opencode-review:diagnostic -->" in body for body in self.bodies()), any("<!-- opencode-review:diagnostic -->" in body for body in self.bodies()),
@@ -141,14 +142,29 @@ class OpenCodeReviewScriptTest(unittest.TestCase):
) )
self.assertIn("No parseable findings block", self.summary_path.read_text()) self.assertIn("No parseable findings block", self.summary_path.read_text())
def test_invalid_findings_json_fails_and_posts_diagnostic(self) -> None: def test_invalid_findings_json_posts_diagnostic_without_failing(self) -> None:
result = self.run_script(output="<opencode_findings>{}</opencode_findings>\n") result = self.run_script(output="<opencode_findings>{}</opencode_findings>\n")
self.assertNotEqual(result.returncode, 0) self.assertEqual(result.returncode, 0)
self.assertIn("did not produce parseable findings", result.stderr) self.assertIn("did not produce parseable findings", result.stderr)
self.assertTrue(any("<!-- opencode-review:diagnostic -->" in body for body in self.bodies())) self.assertTrue(any("<!-- opencode-review:diagnostic -->" in body for body in self.bodies()))
self.assertIn("not an array", self.summary_path.read_text()) self.assertIn("not an array", self.summary_path.read_text())
def test_fenced_json_findings_are_accepted(self) -> None:
result = self.run_script(output="<opencode_findings>```json\n[]\n```</opencode_findings>\n")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(any("<!-- opencode-review:no-findings -->" in body for body in self.bodies()))
def test_missing_gh_binary_reports_gracefully(self) -> None:
result = self.run_script(
output="<opencode_findings>[]</opencode_findings>\n",
gh_bin=self.workdir / "missing-gh",
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("Binary not found", result.stderr)
def test_valid_empty_array_posts_no_findings_comment(self) -> None: def test_valid_empty_array_posts_no_findings_comment(self) -> None:
result = self.run_script(output="<opencode_findings>[]</opencode_findings>\n") result = self.run_script(output="<opencode_findings>[]</opencode_findings>\n")
@@ -174,6 +190,34 @@ class OpenCodeReviewScriptTest(unittest.TestCase):
self.assertTrue(any("<!-- opencode-review:fallback-findings -->" in body for body in bodies)) self.assertTrue(any("<!-- opencode-review:fallback-findings -->" in body for body in bodies))
self.assertTrue(any("src/app.ts:99" in body for body in bodies)) self.assertTrue(any("src/app.ts:99" in body for body in bodies))
def test_integral_float_line_values_are_normalized(self) -> None:
result = self.run_script(
output=textwrap.dedent(
"""\
<opencode_findings>
[{"path":"src/app.ts","line":99.0,"title":"Bad line","body":"This line moved."}]
</opencode_findings>
"""
),
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(any("-F" in call and "line=99" in call for call in self.read_calls()))
def test_invalid_path_values_are_logged(self) -> None:
result = self.run_script(
output=textwrap.dedent(
"""\
<opencode_findings>
[{"path":42,"line":99,"title":"Bad line","body":"This line moved."}]
</opencode_findings>
"""
),
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("invalid path value", result.stdout)
def test_inline_findings_beyond_limit_are_preserved_in_fallback_comment(self) -> None: def test_inline_findings_beyond_limit_are_preserved_in_fallback_comment(self) -> None:
findings = [ findings = [
{"path": "src/app.ts", "line": index + 1, "title": f"Finding {index + 1}", "body": "body"} {"path": "src/app.ts", "line": index + 1, "title": f"Finding {index + 1}", "body": "body"}
@@ -212,8 +256,12 @@ class OpenCodeReviewScriptTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
bodies = self.bodies() bodies = self.bodies()
self.assertTrue(any("Additional fallback findings omitted" in body for body in bodies)) self.assertTrue(any("Additional fallback findings omitted" in body for body in bodies))
self.assertTrue(all(len(body) <= 60000 for body in bodies))
summary = self.summary_path.read_text() summary = self.summary_path.read_text()
self.assertRegex(summary, r"Fallback findings included in comment: `1[0-9]`") included_match = re.search(r"Fallback findings included in comment: `([1-9][0-9]*)`", summary)
self.assertIsNotNone(included_match, summary)
fallback = next(body for body in bodies if "<!-- opencode-review:fallback-findings -->" in body)
self.assertEqual(fallback.count("### src/app.ts:"), int(included_match.group(1)))
omitted_match = re.search(r"Fallback findings omitted from comment: `([1-9][0-9]*)`", summary) omitted_match = re.search(r"Fallback findings omitted from comment: `([1-9][0-9]*)`", summary)
self.assertIsNotNone(omitted_match, summary) self.assertIsNotNone(omitted_match, summary)
+4 -10
View File
@@ -6,9 +6,6 @@ class Kandev < Formula
license "AGPL-3.0-only" license "AGPL-3.0-only"
version "__VERSION__" version "__VERSION__"
# Node is required for the CLI launcher.
depends_on "node"
on_macos do on_macos do
if Hardware::CPU.arm? if Hardware::CPU.arm?
url "__GITHUB_BASE__/kandev-macos-arm64.tar.gz" url "__GITHUB_BASE__/kandev-macos-arm64.tar.gz"
@@ -31,13 +28,10 @@ class Kandev < Formula
def install def install
libexec.install Dir["*"] libexec.install Dir["*"]
# cli/bin/cli.js has #!/usr/bin/env node shebang. (bin/"kandev").write_env_script # Create a stable wrapper at $HOMEBREW_PREFIX/bin/kandev that points at the
# creates a wrapper at $HOMEBREW_PREFIX/bin/kandev that sets KANDEV_BUNDLE_DIR # native launcher in the Cellar and sets the bundle/version env it uses to
# (so the CLI launcher finds bin/ and web/ in the Cellar) and KANDEV_VERSION # find bin/.
# (read by run.ts at startup so the launcher logs "release: X.Y.Z" instead of (bin/"kandev").write_env_script libexec/"bin/kandev",
# "release: (env)"). Calling write_env_script on the bin directory itself would
# name the wrapper after the target's basename (cli.js), giving the wrong name.
(bin/"kandev").write_env_script libexec/"cli/bin/cli.js",
KANDEV_BUNDLE_DIR: libexec.to_s, KANDEV_BUNDLE_DIR: libexec.to_s,
KANDEV_VERSION: version.to_s KANDEV_VERSION: version.to_s
end end
+15 -6
View File
@@ -1,17 +1,26 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Finalize the dist/kandev/ release layout from already-built pieces. # Finalize the dist/kandev/ release layout from already-built pieces.
# Caller must have run, in this order: # Caller must have run, in this order:
# - scripts/release/package-cli.sh (produces dist/kandev/cli/) # - Vite assets synced into apps/backend/internal/webapp/embedded/generated
# - go build ./cmd/{kandev,agentctl} -o dist/kandev/bin/... with Vite # - go build ./cmd/{kandev,agentctl} -o dist/kandev/bin/...
# assets synced into the backend embed package before building kandev. # After this: dist/kandev/bin is ready to install or tar.
# After this: dist/kandev/{bin,cli} is ready to install or tar.
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BUNDLE="$ROOT_DIR/dist/kandev" BUNDLE="$ROOT_DIR/dist/kandev"
if [ ! -f "$BUNDLE/cli/bin/cli.js" ]; then if [ ! -f "$BUNDLE/bin/kandev" ] && [ ! -f "$BUNDLE/bin/kandev.exe" ]; then
echo "Missing $BUNDLE/cli/bin/cli.js; run scripts/release/package-cli.sh first" >&2 echo "Missing native launcher in $BUNDLE/bin; build cmd/kandev first" >&2
exit 1
fi
if [ ! -f "$BUNDLE/bin/agentctl" ] && [ ! -f "$BUNDLE/bin/agentctl.exe" ]; then
echo "Missing agentctl in $BUNDLE/bin; build cmd/agentctl first" >&2
exit 1
fi
if [ ! -f "$BUNDLE/bin/agentctl-linux-amd64" ]; then
echo "Missing linux/amd64 agentctl helper in $BUNDLE/bin; build cmd/agentctl for linux/amd64 first" >&2
exit 1 exit 1
fi fi
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env bash
# Build the CLI and copy artifacts into dist/kandev/cli/ for inclusion in release bundles.
#
# The CLI bundle (dist/cli.bundle.js) is a single-file esbuild output with all Node
# dependencies inlined. This is required for Homebrew installs where there is no
# node_modules directory in the Cellar.
#
# Output layout inside dist/kandev/cli/:
# bin/cli.js - Node entrypoint (requires #!/usr/bin/env node shebang + chmod +x)
# dist/cli.bundle.js - Self-contained bundle with inlined deps (used by Homebrew)
# package.json - Package metadata (used to read version at runtime)
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CLI_DIR="$ROOT_DIR/apps/cli"
OUT_DIR="$ROOT_DIR/dist/kandev/cli"
echo "Building CLI for release bundle..."
# Build TypeScript
(cd "$ROOT_DIR/apps" && pnpm --filter kandev build)
echo " TypeScript build complete"
# Bundle with esbuild (inlines tree-kill and all other deps)
(cd "$ROOT_DIR/apps" && pnpm --filter kandev bundle)
echo " esbuild bundle complete"
# Copy artifacts into dist/kandev/cli/
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR/bin" "$OUT_DIR/dist"
# Write a bin/cli.js that points at the bundled dist (for Homebrew/manual installs
# where there is no node_modules to resolve the unbundled dist/cli.js's deps).
# The npm package's bin/cli.js is different — it requires the unbundled dist/cli.js
# because tree-kill etc. are installed normally via npm.
cat > "$OUT_DIR/bin/cli.js" <<'EOF'
#!/usr/bin/env node
require("../dist/cli.bundle.js");
EOF
chmod +x "$OUT_DIR/bin/cli.js"
cp "$CLI_DIR/dist/cli.bundle.js" "$OUT_DIR/dist/cli.bundle.js"
cp "$CLI_DIR/package.json" "$OUT_DIR/package.json"
echo "CLI artifacts packaged at $OUT_DIR"
echo " bin/cli.js (entrypoint, requires dist/cli.bundle.js, chmod +x)"
echo " dist/cli.bundle.js (self-contained bundle, deps inlined)"
echo " package.json"
+3 -5
View File
@@ -2,7 +2,7 @@
# Generate per-platform npm runtime packages from built dist/release-assets. # Generate per-platform npm runtime packages from built dist/release-assets.
# #
# Each platform bundle (kandev-{platform}.tar.gz) is repackaged as an npm package # Each platform bundle (kandev-{platform}.tar.gz) is repackaged as an npm package
# containing only bin/ and web/ (no cli/). These are the @kdlbs/runtime-* packages # containing the native bin/ directory. These are the @kdlbs/runtime-* packages
# that the main kandev npm package declares as optionalDependencies. # that the main kandev npm package declares as optionalDependencies.
# #
# Usage: # Usage:
@@ -70,7 +70,7 @@ for platform in linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64; do
rm -rf "$pkg_out" rm -rf "$pkg_out"
mkdir -p "$pkg_out" mkdir -p "$pkg_out"
# Extract only bin/ and web/ from the archive (skip cli/) # Extract the native runtime bundle from the archive.
local_tmp="$pkg_out/.extract_tmp" local_tmp="$pkg_out/.extract_tmp"
mkdir -p "$local_tmp" mkdir -p "$local_tmp"
tar -xzf "$archive" -C "$local_tmp" tar -xzf "$archive" -C "$local_tmp"
@@ -83,7 +83,6 @@ for platform in linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64; do
fi fi
cp -R "$bundle_root/bin" "$pkg_out/bin" cp -R "$bundle_root/bin" "$pkg_out/bin"
cp -R "$bundle_root/web" "$pkg_out/web"
rm -rf "$local_tmp" rm -rf "$local_tmp"
os_field="${PLATFORM_TO_OS[$platform]}" os_field="${PLATFORM_TO_OS[$platform]}"
@@ -106,8 +105,7 @@ for platform in linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64; do
"os": $os_field, "os": $os_field,
"cpu": $cpu_field, "cpu": $cpu_field,
"files": [ "files": [
"bin", "bin"
"web"
] ]
} }
EOF EOF