From fc4bf8a40c85c7b7963cbc82bc3df4cf577b3035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 8 Jun 2026 19:30:21 +0100 Subject: [PATCH] feat: add checkout service make targets (#1311) --- Makefile | 78 +++++++++++++++++++ .../internal/system/updates/install_state.go | 1 + .../system/updates/install_state_test.go | 41 ++++++++++ apps/cli/src/service/paths.test.ts | 13 ++++ apps/cli/src/service/paths.ts | 8 +- docs/run-as-a-service.md | 29 +++++++ 6 files changed, 167 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 0ce6ea0339..864c488070 100644 --- a/Makefile +++ b/Makefile @@ -30,6 +30,15 @@ YELLOW := \033[33m MAGENTA := \033[35m VERBOSE ?= 0 +NODE ?= $(shell command -v node 2>/dev/null || echo node) +SERVICE_CLI := $(CURDIR)/dist/kandev/cli/bin/cli.js +SERVICE_BUNDLE_DIR := $(CURDIR)/dist/kandev +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_PORT_FLAG := $(if $(PORT),--port $(PORT),) +SERVICE_HOME_DIR_FLAG := $(if $(HOME_DIR),--home-dir "$(HOME_DIR)",) +SERVICE_NO_BOOT_START_FLAG := $(if $(filter 1 true yes,$(NO_BOOT_START)),--no-boot-start,) +SERVICE_INSTALL_FLAGS := $(SERVICE_PORT_FLAG) $(SERVICE_HOME_DIR_FLAG) $(SERVICE_NO_BOOT_START_FLAG) # Phase headers define phase @@ -65,6 +74,20 @@ help: @echo " start-verbose Start in production mode with info logs from backend + web" @echo " start VERBOSE=1 Same as start-verbose" @echo "" + @echo "Service Commands:" + @echo " service-install Install deps, build current checkout, install user service" + @echo " service-install-system Install deps, build current checkout, install system service" + @echo " service-status Show current user service status" + @echo " service-logs Show current user service logs" + @echo " service-logs-follow Follow current user service logs" + @echo " service-start Start current user service" + @echo " service-stop Stop current user service" + @echo " service-restart Restart current user service" + @echo " service-uninstall Uninstall current user service" + @echo " service-config Show service launcher/config paths" + @echo " service-install PORT=3000 HOME_DIR=/path Optional install overrides" + @echo " service-install NO_BOOT_START=1 Skip Linux user-service boot hint" + @echo "" @echo "Build Commands:" @echo " build Build backend and web app" @echo " build-backend Build backend binary" @@ -189,6 +212,61 @@ start-verbose: start-debug: @$(MAKE) start DEBUG=1 +# +# Service +# + +.PHONY: service-bundle +service-bundle: install build + $(call phase,Packaging Service Bundle) + @scripts/release/package-web.sh + @scripts/release/package-cli.sh + @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 -f "$(BACKEND_DIR)/bin/agentctl-linux-amd64" || $(MAKE) -C $(BACKEND_DIR) build-agentctl-linux + @$(RMDIR) "$(SERVICE_BUNDLE_DIR)/bin" + @mkdir -p "$(SERVICE_BUNDLE_DIR)/bin" + @cp "$(BACKEND_DIR)/bin/kandev" "$(BACKEND_DIR)/bin/agentctl" "$(BACKEND_DIR)/bin/agentctl-linux-amd64" "$(SERVICE_BUNDLE_DIR)/bin/" + @scripts/release/package-bundle.sh + $(call success,Service bundle packaged at $(SERVICE_BUNDLE_DIR)) + +.PHONY: service-cli-check +service-cli-check: + @test -f "$(SERVICE_CLI)" || { echo "Missing $(SERVICE_CLI). Run 'make service-install' first."; exit 1; } + +.PHONY: service-install +service-install: service-bundle + @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service install $(SERVICE_INSTALL_FLAGS) + +.PHONY: service-install-system +service-install-system: service-bundle + @sudo env $(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service install --system $(SERVICE_INSTALL_FLAGS) + +.PHONY: service-uninstall service-start service-stop service-restart service-status service-logs service-logs-follow service-config +service-uninstall: service-cli-check + @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service uninstall + +service-start: service-cli-check + @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service start + +service-stop: service-cli-check + @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service stop + +service-restart: service-cli-check + @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service restart + +service-status: service-cli-check + @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service status + +service-logs: service-cli-check + @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service logs + +service-logs-follow: service-cli-check + @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service logs -f + +service-config: service-cli-check + @$(SERVICE_ENV) "$(NODE)" "$(SERVICE_CLI)" service config + .PHONY: build-backend build-backend: @printf "$(CYAN)Building backend...$(RESET)\n" diff --git a/apps/backend/internal/system/updates/install_state.go b/apps/backend/internal/system/updates/install_state.go index 4ec7af1a8f..2590da0d2c 100644 --- a/apps/backend/internal/system/updates/install_state.go +++ b/apps/backend/internal/system/updates/install_state.go @@ -25,6 +25,7 @@ const ( installKindHomebrew = "homebrew" installKindNPM = "npm" installKindNPX = "npx" + installKindLocal = "local" manualServiceInstall = "kandev service install" manualServiceRestart = "kandev service restart" ) diff --git a/apps/backend/internal/system/updates/install_state_test.go b/apps/backend/internal/system/updates/install_state_test.go index b1e80b6d28..2f13c87cd8 100644 --- a/apps/backend/internal/system/updates/install_state_test.go +++ b/apps/backend/internal/system/updates/install_state_test.go @@ -114,6 +114,47 @@ func TestService_GetForeignServiceDisablesApply(t *testing.T) { } } +func TestService_GetLocalBundleServiceDisablesApply(t *testing.T) { + homeDir := t.TempDir() + metadataPath, _ := writeServiceInstallForTest(t, homeDir, serviceInstallMetadata{ + Manager: "systemd", + Mode: "user", + Kind: installKindLocal, + HomeDir: homeDir, + LogDir: filepath.Join(homeDir, "logs"), + ServicePath: filepath.Join(homeDir, "kandev.service"), + NodePath: "/usr/local/bin/node", + CLIEntry: "/Users/alice/src/kandev/dist/kandev/cli/bin/cli.js", + BundleDir: "/Users/alice/src/kandev/dist/kandev", + }) + t.Setenv(envRunningAsService, "true") + t.Setenv(envServiceMode, "user") + t.Setenv(envServiceManager, "systemd") + t.Setenv(envInstallKind, installKindLocal) + t.Setenv(envServiceMetadata, metadataPath) + + svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithHomeDir(homeDir)) + resp, err := svc.Get() + if err != nil { + t.Fatalf("Get: %v", err) + } + if !resp.Install.ManagedService { + t.Fatalf("expected service to be recognised as managed") + } + if resp.ApplySupported { + t.Fatalf("ApplySupported=true for local bundle service") + } + if resp.ApplyUnsupportedReason == "" { + t.Fatalf("expected unsupported reason for local bundle service") + } + if !hasString(resp.ManualCommands, "kandev service install") { + t.Fatalf("manual commands = %v, want service install command", resp.ManualCommands) + } + if !hasString(resp.ManualCommands, "kandev service restart") { + t.Fatalf("manual commands = %v, want service restart command", resp.ManualCommands) + } +} + func TestManualCommandsNPXHasNoDuplicateBinaryName(t *testing.T) { cmds := manualCommands(InstallStateResponse{Kind: installKindNPX, Mode: installModeUser}, "v1.2.3") if !hasString(cmds, "npx -y kandev@1.2.3 service install") { diff --git a/apps/cli/src/service/paths.test.ts b/apps/cli/src/service/paths.test.ts index 9741aa6c94..d614952d02 100644 --- a/apps/cli/src/service/paths.test.ts +++ b/apps/cli/src/service/paths.test.ts @@ -187,4 +187,17 @@ describe("captureLauncher shim resolution", () => { expect(info.kind).toBe("homebrew"); expect(info.shimPath).toBeUndefined(); }); + + it("classifies a non-Cellar bundle as a local checkout install", () => { + const cliEntry = "/Users/alice/src/kandev/dist/kandev/cli/bin/cli.js"; + process.argv = ["/path/to/node", cliEntry]; + vi.stubEnv("KANDEV_BUNDLE_DIR", "/Users/alice/src/kandev/dist/kandev"); + vi.spyOn(fs, "existsSync").mockImplementation((p) => p === cliEntry); + vi.spyOn(fs, "realpathSync").mockReturnValue(cliEntry); + + const info = captureLauncher(); + expect(info.kind).toBe("local"); + expect(info.bundleDir).toBe("/Users/alice/src/kandev/dist/kandev"); + expect(info.shimPath).toBeUndefined(); + }); }); diff --git a/apps/cli/src/service/paths.ts b/apps/cli/src/service/paths.ts index 4a76270170..f5114034b3 100644 --- a/apps/cli/src/service/paths.ts +++ b/apps/cli/src/service/paths.ts @@ -34,7 +34,7 @@ export function macosSystemPlistPath(): string { return path.join(MACOS_SYSTEM_DAEMON_DIR, `${LAUNCHD_LABEL}.plist`); } -export type LauncherKind = "homebrew" | "npm" | "npx" | "unknown"; +export type LauncherKind = "homebrew" | "npm" | "npx" | "local" | "unknown"; export type LauncherInfo = { /** Absolute path to node executable (process.execPath at install time). */ @@ -43,7 +43,7 @@ export type LauncherInfo = { cliEntry: string; /** Best-guess of how kandev was installed. Used to seed env vars. */ kind: LauncherKind; - /** KANDEV_BUNDLE_DIR if set (Homebrew sets this). */ + /** KANDEV_BUNDLE_DIR if set (Homebrew and local checkout installs set this). */ bundleDir?: string; /** KANDEV_VERSION if set (Homebrew sets this). */ version?: string; @@ -96,7 +96,9 @@ export function captureLauncher(): LauncherInfo { const bundleDir = process.env.KANDEV_BUNDLE_DIR; const version = process.env.KANDEV_VERSION; const kind: LauncherKind = bundleDir - ? "homebrew" + ? homebrewShimPath(cliEntry) + ? "homebrew" + : "local" : looksLikeNpxEntry(cliEntry) ? "npx" : cliEntry.includes(`${path.sep}node_modules${path.sep}`) diff --git a/docs/run-as-a-service.md b/docs/run-as-a-service.md index 698a6f3821..35f59cd876 100644 --- a/docs/run-as-a-service.md +++ b/docs/run-as-a-service.md @@ -18,6 +18,35 @@ sudo kandev service install --system After install, kandev is reachable at `http://localhost:38429` (or `--port ` if you passed it). +## Run the Current Checkout as a Service + +When developing from a cloned repo, use the root Make targets instead of the globally installed `kandev` binary. They install dependencies, build the currently checked-out branch, assemble a local release-style bundle under `dist/kandev`, and install the service with `KANDEV_BUNDLE_DIR` pointing at that bundle. + +```bash +git checkout my-branch +make service-install +``` + +Useful targets: + +```bash +make service-install # user service from the current checkout +make service-install PORT=3000 +make service-install HOME_DIR=/path/to/kandev-home +make service-install NO_BOOT_START=1 +make service-install-system # system service install; other targets below use the user service +make service-status +make service-logs +make service-logs-follow +make service-start +make service-stop +make service-restart +make service-uninstall +make service-config +``` + +The service runs the built snapshot in `dist/kandev`, not live source files. After switching branches or changing code, rerun `make service-install` to rebuild and refresh the service unit. Checkout-based services are marked as a local install kind, so the System → Updates page will not offer one-click npm/Homebrew self-update; update by rebuilding from the desired branch. + ## User Mode vs `--system` Mode | | **User mode** (default) | **`--system` mode** |