feat: add native kandev launcher (#1391)
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+24
-1602
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package backendapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
+1
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package backendapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package backendapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package backendapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package backendapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package backendapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package backendapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package backendapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package backendapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package backendapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package backendapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
+1
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package backendapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package backendapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+6
-7
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
@@ -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?
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user