diff --git a/.env.example b/.env.example index 464f2ac1..4ed6acb1 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,11 @@ CIX_BOOTSTRAP_ADMIN_PASSWORD=change-me-on-first-login # ── Networking + storage ────────────────────────────────────────────────── CIX_PORT=21847 +# Interface to listen on. Unset (the default) means every interface, which is +# what a container needs. Set to 127.0.0.1 to make the server reachable only +# from this machine — the right choice for a desktop install, where exposing a +# code index to the whole LAN is rarely intended. Bare address, no port. +# CIX_BIND_ADDR=127.0.0.1 CIX_CHROMA_PERSIST_DIR=~/.cix/data/chroma CIX_SQLITE_PATH=~/.cix/data/sqlite/projects.db CIX_GGUF_CACHE_DIR=~/.cix/data/models diff --git a/.github/workflows/release-mac.yml b/.github/workflows/release-mac.yml new file mode 100644 index 00000000..d9f1557d --- /dev/null +++ b/.github/workflows/release-mac.yml @@ -0,0 +1,164 @@ +name: "Release: macOS app" + +# Triggered by macOS-app tags (e.g. `mac/v0.1.0`). This is a third, independent +# tag stream alongside `server/v*` and `cli/v*`, and it releases exactly one +# thing: the menu bar app, ~4 MB, holding a single executable. +# +# The server it manages is NOT built here. It ships from the server/v* tag +# stream — the same tag and the same workflow run that publishes the Docker +# images (see release-server.yml's macos-runtime job) — so a Mac install and a +# container on the same version are the same server. cix.app downloads it into +# ~/.cix/runtime/ and updates it independently of itself. +# +# That means this workflow no longer needs a server/v* or cli/v* tag to be +# reachable: nothing here is stamped with them. +on: + push: + tags: + - "mac/v*" + workflow_dispatch: + inputs: + ref: + description: "Ref to build (branch, tag, or SHA)" + required: true + version: + description: "App version, without the mac/v prefix (e.g. 0.1.0)" + required: true + +permissions: + contents: write + +jobs: + build: + name: Build cix.app + DMG + # The bundled llama-server is macOS arm64 only (upstream ships no macOS + # x86_64 asset), so the whole app is Apple Silicon only and must be built + # natively. macos-latest is arm64. + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + + - name: Resolve version + id: ver + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + MAC_VERSION="${{ github.event.inputs.version }}" + else + MAC_VERSION="${GITHUB_REF_NAME#mac/v}" + fi + echo "mac=$MAC_VERSION" >> "$GITHUB_OUTPUT" + echo "app=$MAC_VERSION" + + - name: Set up Go + uses: actions/setup-go@v7 + with: + # Only the CLI module is built here — the launcher lives in it. + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: Build cix.app + env: + MAC_VERSION: ${{ steps.ver.outputs.mac }} + run: mac/scripts/build-app.sh + + - name: Verify the bundle + run: | + set -euo pipefail + APP=mac/dist/cix.app + + # Ad-hoc signatures do not satisfy Gatekeeper, but they do have to be + # internally consistent — --strict is what catches a bundle whose + # sealed resources drifted from what is on disk. + codesign --verify --strict --verbose=2 "$APP" + + # Reports the launcher's own version, and that no runtime is installed + # — correct on a build machine, where the two halves have not met yet. + "$APP/Contents/MacOS/cix-launcher" -report + + # The app must contain exactly one executable. A leftover cix-server + # or llama/ from a pre-split build would be sealed into the signature + # and shipped as ~90 MB nobody uses. + found="$(find "$APP/Contents/MacOS" -type f | wc -l | tr -d ' ')" + if [ "$found" != "1" ]; then + echo "::error title=Unexpected bundle contents::Contents/MacOS holds $found files, expected only cix-launcher" + find "$APP/Contents/MacOS" -type f + exit 1 + fi + + - name: Build DMG + env: + MAC_VERSION: ${{ steps.ver.outputs.mac }} + run: mac/scripts/make-dmg.sh + + - name: Compute checksums + working-directory: mac/dist + # The updater verifies the download against this file. Without a + # Developer ID signature it is the only integrity check there is. + run: shasum -a 256 ./*.dmg > checksums.txt + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: cix-macos-arm64 + path: | + mac/dist/*.dmg + mac/dist/checksums.txt + + - name: Create GitHub release + if: github.event_name == 'push' + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + with: + name: "macOS app ${{ steps.ver.outputs.mac }}" + files: | + mac/dist/*.dmg + mac/dist/checksums.txt + generate_release_notes: true + # Server releases own the "latest" pointer — the Docker image is the + # primary deliverable of this project. Mac installs filter by the + # `mac/` tag prefix. + make_latest: "false" + body: | + ## cix for macOS — Apple Silicon + + A menu bar app for a local cix server: start and stop it, see what + its embedding provider is doing, and open the dashboard. Everything + runs on your machine. + + ### Install + + 1. Download the `.dmg` below and open it. + 2. Drag **cix.app** onto **Applications**. + 3. Open it from Applications. + + On first launch the app downloads the server itself — `cix-server`, + the `cix` CLI and a Metal-accelerated `llama-server`, about 40 MB — + from the newest [server release](https://github.com/dvcdsys/code-index/releases?q=server%2Fv). + That is the same build the Docker images are cut from, and it + updates on its own schedule: a new server does not need a new app. + + ### First launch will be blocked — this is expected + + cix is open source and is **not** signed with a paid Apple Developer + certificate, so macOS refuses the first launch with "cix cannot be + verified". + + To allow it: **System Settings → Privacy & Security**, scroll to + **Security**, then click **Open Anyway** next to the message about cix. + + On macOS 15 and later the old right-click → Open shortcut no longer + works — you have to use System Settings. You only need to do this + once per installed version. + + ### Verify the download + + ```bash + shasum -a 256 -c checksums.txt + ``` + + Requires macOS 13 or later on Apple Silicon. There is no Intel build: + upstream llama.cpp publishes no macOS x86_64 release asset. diff --git a/.github/workflows/release-server.yml b/.github/workflows/release-server.yml index c981fd67..f4d89202 100644 --- a/.github/workflows/release-server.yml +++ b/.github/workflows/release-server.yml @@ -156,6 +156,88 @@ jobs: openapi=doc tags: ${{ steps.tags.outputs.tags }} + macos-runtime: + name: Build macOS runtime (arm64) + # The same server, packaged for a Mac instead of a container: cix-server, + # the cix CLI and a Metal llama-server, installed by cix.app into + # ~/.cix/runtime//. It ships from THIS tag, not from mac/v*, so a + # Mac and a container on the same version are running the same server. + # + # macos-latest is arm64, and it has to be: upstream llama.cpp publishes one + # macOS asset, macos-arm64, so there is no x86_64 build to make. + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + # Full history: the CLI version comes from `git describe` against a + # tag that is not the tag being built. + fetch-depth: 0 + + - name: Resolve versions + id: ver + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + SERVER_VERSION="${{ github.event.inputs.version }}" + else + SERVER_VERSION="${GITHUB_REF_NAME#server/}" + fi + # The Docker tags are v-prefixed; the runtime version is not. It + # becomes a directory name under ~/.cix/runtime/ and is compared + # against the tag stream with the prefix already stripped. + SERVER_VERSION="${SERVER_VERSION#v}" + + CLI_TAG="$(git describe --tags --match 'cli/v*' --abbrev=0 2>/dev/null || true)" + if [ -z "$CLI_TAG" ]; then + echo "::error title=No CLI version::The runtime bundles the cix CLI, and no cli/v* tag is reachable from this commit — it would ship stamped 0.0.0-dev. Cut server/v* tags on main." + exit 1 + fi + + { + echo "server=$SERVER_VERSION" + echo "cli=${CLI_TAG#cli/v}" + } >> "$GITHUB_OUTPUT" + + echo "server=$SERVER_VERSION cli=$CLI_TAG" + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: server/go.mod + cache-dependency-path: | + server/go.sum + cli/go.sum + + - name: Set up Node + # The server binary embeds the built dashboard via go:embed, so this + # needs the frontend toolchain even though nothing here is JavaScript. + uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + cache-dependency-path: server/dashboard/package-lock.json + + - name: Build the runtime + env: + SERVER_VERSION: ${{ steps.ver.outputs.server }} + CLI_VERSION: ${{ steps.ver.outputs.cli }} + # The script round-trips the tarball it writes before returning: + # extract, codesign --verify --strict every Mach-O, check llama-server's + # @rpath dependencies, and exec the server. That check is in the script + # rather than here so local builds get it too, and because the failure + # it catches is silent — a signature the kernel rejects is SIGKILL with + # empty stderr. + run: mac/scripts/build-runtime.sh + + - name: Upload the runtime + uses: actions/upload-artifact@v7 + with: + name: cix-runtime-darwin-arm64 + path: mac/dist/*.tar.gz + prune: name: Prune stale Docker Hub tags needs: [docker-cpu, docker-cuda] @@ -178,7 +260,10 @@ jobs: release: name: Publish release - needs: [docker-cpu, docker-cuda] + # macos-runtime is a hard dependency, not a nicety: cix.app installs its + # server from this release's assets, so a release published without the + # runtime attached is one no Mac can install or update to. + needs: [docker-cpu, docker-cuda, macos-runtime] runs-on: ubuntu-latest # Skip GitHub release creation on manual rebuilds — the release # already exists for the version being rebuilt. @@ -191,11 +276,30 @@ jobs: id: ver run: echo "version=${GITHUB_REF_NAME#server/}" >> "$GITHUB_OUTPUT" + - name: Download the macOS runtime + uses: actions/download-artifact@v8 + with: + name: cix-runtime-darwin-arm64 + path: dist + + - name: Compute checksums + working-directory: dist + # cix.app verifies its download against this file. Without a Developer + # ID signature it is the only integrity check there is. + # + # sha256sum, not shasum: this job runs on ubuntu, and that is what + # release-cli.yml uses there. The macOS jobs use shasum because macOS + # ships no sha256sum. Same output format either way. + run: sha256sum ./*.tar.gz > checksums.txt + - name: Create GitHub release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: "Server ${{ steps.ver.outputs.version }}" generate_release_notes: true + files: | + dist/*.tar.gz + dist/checksums.txt body: | ## Docker Images @@ -204,6 +308,17 @@ jobs: | CPU (multi-arch) | `dvcdsys/code-index:${{ steps.ver.outputs.version }}` | | CUDA 12.8 | `dvcdsys/code-index:${{ steps.ver.outputs.version }}-cu128` | + ## macOS (Apple Silicon) + + The same server for a Mac, installed and managed by + [cix.app](https://github.com/dvcdsys/code-index/releases?q=mac%2Fv). + The app downloads it on its own — the tarball is attached so the + checksums cover it, not because it needs downloading by hand. + + | Asset | Contents | + |---|---| + | `cix-runtime-*-darwin-arm64.tar.gz` | `cix-server`, the `cix` CLI, Metal `llama-server` | + site-version: name: Check the site advertises this release needs: [docker-cpu, docker-cuda] diff --git a/.gitignore b/.gitignore index 8fa5f157..58efa0e7 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,13 @@ cli/dist/ # Server build artifacts + runtime logs server/dist/ server/exec.log + +# macOS app build artifacts (mac/scripts/build-app.sh, make-dmg.sh). +# The committed inputs live in mac/Resources/ and mac/Info.plist.in; everything +# under mac/dist/ is generated. Disk images are never tracked — the release +# workflow publishes them as GitHub release assets. +mac/dist/ +*.dmg # Root scratch dir only — anchored: a bare "tmp" would silently ignore any # nested file/dir named tmp (same pitfall as the docs/ pattern below). /tmp/ diff --git a/cli/go.mod b/cli/go.mod index effec5c1..0af244a5 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -3,6 +3,7 @@ module github.com/dvcdsys/code-index/cli go 1.25.12 require ( + fyne.io/systray v1.12.2 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 @@ -33,6 +34,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect diff --git a/cli/go.sum b/cli/go.sum index 1916907e..33a492b3 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -1,3 +1,5 @@ +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -41,6 +43,8 @@ github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJ github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= diff --git a/cli/internal/client/client.go b/cli/internal/client/client.go index b60beb4d..b7d0085d 100644 --- a/cli/internal/client/client.go +++ b/cli/internal/client/client.go @@ -254,9 +254,60 @@ func (c *Client) Status() (*StatusResponse, error) { return &status, nil } +// StatusResponse mirrors GET /api/v1/status. +// +// The first block is always present. The version-check block is present only +// when the server was started with the version-check service wired +// (CIX_VERSION_CHECK_ENABLED); when it is absent the four fields stay at their +// zero values, and `UpdateAvailable == false` is then "unknown", not "current". +// Branch on `VersionCheck != nil` before trusting it. +// +// LatestVersion and ReleaseURL are pointers because the server emits JSON null +// for them until a check has actually succeeded — an empty string would be +// indistinguishable from "checked, and there is no newer release". +// +// The exact wire shape is pinned by a twin golden fixture: status_test.go here +// and server/internal/httpapi/status_contract_test.go there. The two modules +// cannot import each other, so those fixtures are the contract — change one and +// the other fails, forcing both sides to move in the same PR. type StatusResponse struct { - Status string `json:"status"` - ModelLoaded bool `json:"model_loaded"` - Projects int `json:"projects"` - ActiveIndexingJobs int `json:"active_indexing_jobs"` + Status string `json:"status"` + Backend string `json:"backend"` + ServerVersion string `json:"server_version"` + APIVersion string `json:"api_version"` + ModelLoaded bool `json:"model_loaded"` + EmbeddingModel string `json:"embedding_model"` + EmbeddingProvider string `json:"embedding_provider"` + EmbeddingProviderManagesProcess bool `json:"embedding_provider_manages_process"` + Projects int `json:"projects"` + ActiveIndexingJobs int `json:"active_indexing_jobs"` + + UpdateAvailable bool `json:"update_available"` + LatestVersion *string `json:"latest_version"` + ReleaseURL *string `json:"release_url"` + VersionCheck *VersionCheckStatus `json:"version_check"` +} + +// VersionCheckStatus is the nested `version_check` object. +type VersionCheckStatus struct { + Enabled bool `json:"enabled"` + Error *string `json:"error"` + // CheckedAt is RFC3339, or nil when no check has completed yet. + CheckedAt *string `json:"checked_at"` +} + +// EmbeddingsHealthy reports whether the embedding provider should be shown as +// working. +// +// ModelLoaded alone is not that answer. The server computes it under a 500 ms +// deadline, and for HTTP providers (openai, voyage) there is no local process +// whose liveness it could reflect — EmbeddingProviderManagesProcess is false +// there, and treating a slow or skipped probe as "down" would show a permanent +// red dot for a provider that is fine. Only a managed local process +// (llama-server) can meaningfully be reported as not loaded. +func (s *StatusResponse) EmbeddingsHealthy() bool { + if !s.EmbeddingProviderManagesProcess { + return true + } + return s.ModelLoaded } diff --git a/cli/internal/client/status_test.go b/cli/internal/client/status_test.go new file mode 100644 index 00000000..3d90f1a2 --- /dev/null +++ b/cli/internal/client/status_test.go @@ -0,0 +1,103 @@ +package client + +import ( + "encoding/json" + "testing" +) + +// Twin of server/internal/httpapi/status_contract_test.go. The two modules +// cannot import each other, so these byte-identical goldens are the contract +// for GET /api/v1/status: change a field name or type on either side and one of +// the two test files fails, forcing both to move in the same PR. +// +// Key order is alphabetical because the server builds the response as a +// map[string]any and encoding/json sorts map keys. Order does not matter for +// decoding — it is kept identical to make the twin obvious on inspection. +const ( + wantStatusWire = `{"active_indexing_jobs":0,"api_version":"v1","backend":"go","embedding_model":"awhiteside/CodeRankEmbed-Q8_0-GGUF","embedding_provider":"","embedding_provider_manages_process":false,"model_loaded":false,"projects":0,"server_version":"1.2.3","status":"ok"}` + + wantStatusWithVersionCheckWire = `{"active_indexing_jobs":0,"api_version":"v1","backend":"go","embedding_model":"awhiteside/CodeRankEmbed-Q8_0-GGUF","embedding_provider":"","embedding_provider_manages_process":false,"latest_version":null,"model_loaded":false,"projects":0,"release_url":null,"server_version":"1.2.3","status":"ok","update_available":false,"version_check":{"checked_at":null,"enabled":true,"error":null}}` +) + +func TestStatusResponse_DecodesServerContract(t *testing.T) { + var got StatusResponse + if err := json.Unmarshal([]byte(wantStatusWire), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + want := StatusResponse{ + Status: "ok", + Backend: "go", + ServerVersion: "1.2.3", + APIVersion: "v1", + EmbeddingModel: "awhiteside/CodeRankEmbed-Q8_0-GGUF", + } + if got != want { + t.Errorf("decoded status drifted from the server contract:\n got: %+v\nwant: %+v\n"+ + "→ update server/internal/httpapi/server.go and its twin fixture in the same PR.", got, want) + } + + // A server with the version check switched off sends none of these. The + // nil pointers are what tells a caller "unknown" apart from "up to date". + if got.VersionCheck != nil { + t.Errorf("VersionCheck = %+v, want nil when the server omits the block", got.VersionCheck) + } +} + +func TestStatusResponse_DecodesVersionCheckBlock(t *testing.T) { + var got StatusResponse + if err := json.Unmarshal([]byte(wantStatusWithVersionCheckWire), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.VersionCheck == nil { + t.Fatal("VersionCheck = nil, want the decoded block") + } + if !got.VersionCheck.Enabled { + t.Error("VersionCheck.Enabled = false, want true") + } + // Before the first successful poll the server sends nulls, not empty + // strings — that distinction is the whole reason these are pointers. + if got.VersionCheck.CheckedAt != nil { + t.Errorf("VersionCheck.CheckedAt = %q, want nil before the first check", *got.VersionCheck.CheckedAt) + } + if got.VersionCheck.Error != nil { + t.Errorf("VersionCheck.Error = %q, want nil", *got.VersionCheck.Error) + } + if got.LatestVersion != nil || got.ReleaseURL != nil { + t.Errorf("LatestVersion/ReleaseURL = %v/%v, want nil before the first check", got.LatestVersion, got.ReleaseURL) + } + if got.UpdateAvailable { + t.Error("UpdateAvailable = true, want false") + } +} + +func TestEmbeddingsHealthy(t *testing.T) { + tests := []struct { + name string + managesProcess bool + modelLoaded bool + want bool + }{ + // llama-server: the only case where "not loaded" is real information. + {"managed process, loaded", true, true, true}, + {"managed process, not loaded", true, false, false}, + // openai / voyage: there is no local process to be down, and the + // server's Ready() probe runs under a 500 ms deadline it can lose. + // Reporting red here would mean a permanently red dot on a provider + // that works — which is exactly what the dashboard avoids doing. + {"http provider, loaded", false, true, true}, + {"http provider, not loaded", false, false, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := &StatusResponse{ + EmbeddingProviderManagesProcess: tc.managesProcess, + ModelLoaded: tc.modelLoaded, + } + if got := s.EmbeddingsHealthy(); got != tc.want { + t.Errorf("EmbeddingsHealthy() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/cli/internal/release/release.go b/cli/internal/release/release.go new file mode 100644 index 00000000..34f849e9 --- /dev/null +++ b/cli/internal/release/release.go @@ -0,0 +1,177 @@ +package release + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// DefaultRepo is the GitHub repository releases are published to. +const DefaultRepo = "dvcdsys/code-index" + +// Release is one published release of the requested tag stream. +type Release struct { + // Version is the tag with its stream prefix stripped: "0.3.1", not + // "mac/v0.3.1". + Version string + TagName string + HTMLURL string + Assets []Asset +} + +// Asset is a downloadable file attached to a release. +type Asset struct { + Name string + URL string + Size int64 +} + +// AssetByName returns the named asset. The DMG and checksums.txt are located by +// name rather than by position, because the release workflow's upload order is +// not a contract. +func (r Release) AssetByName(name string) (Asset, bool) { + for _, a := range r.Assets { + if a.Name == name { + return a, true + } + } + return Asset{}, false +} + +// AssetBySuffix returns the first asset whose name ends in suffix — used for +// the DMG, whose filename carries the version. +func (r Release) AssetBySuffix(suffix string) (Asset, bool) { + for _, a := range r.Assets { + if strings.HasSuffix(a.Name, suffix) { + return a, true + } + } + return Asset{}, false +} + +// Client queries the GitHub releases API. +// +// Unauthenticated, which caps it at 60 requests per hour per IP — shared with +// anything else on the machine doing the same. That is why every response's +// ETag is kept and replayed: a 304 does not count against the limit, so an app +// that checks on every menu open costs nothing on the days there is no release. +type Client struct { + HTTP *http.Client + BaseURL string + Repo string + + // TagPrefix selects the stream, e.g. "mac/v". + TagPrefix string + + // ETag is sent as If-None-Match and updated from each 200 response. Callers + // persist it between runs. + ETag string +} + +func New(repo, tagPrefix string) *Client { + return &Client{ + HTTP: &http.Client{Timeout: 20 * time.Second}, + BaseURL: "https://api.github.com", + Repo: repo, + TagPrefix: tagPrefix, + } +} + +// ErrNotModified is returned when GitHub answers 304, meaning the cached result +// is still current. +var ErrNotModified = fmt.Errorf("not modified") + +// Latest returns the highest-versioned published release of the stream. +// +// Returns a zero Release and no error when the stream has no releases yet — +// that is a normal state for a new tag stream, not a failure. +func (c *Client) Latest(ctx context.Context) (Release, error) { + releases, err := c.list(ctx) + if err != nil { + return Release{}, err + } + + var best Release + for _, rel := range releases { + if best.Version != "" && CompareSemver(rel.Version, best.Version) <= 0 { + continue + } + best = rel + } + return best, nil +} + +// list fetches the published releases of this stream, newest page first. +func (c *Client) list(ctx context.Context) ([]Release, error) { + url := fmt.Sprintf("%s/repos/%s/releases?per_page=30", c.BaseURL, c.Repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if c.ETag != "" { + req.Header.Set("If-None-Match", c.ETag) + } + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusNotModified: + return nil, ErrNotModified + case http.StatusOK: + case http.StatusForbidden, http.StatusTooManyRequests: + // Distinguished from a generic failure because it is self-healing and + // the caller should stay quiet about it rather than alarm the user. + return nil, fmt.Errorf("github rate limit reached (resets hourly)") + default: + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("github returned %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + var raw []struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + Size int64 `json:"size"` + } `json:"assets"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&raw); err != nil { + return nil, fmt.Errorf("decode releases: %w", err) + } + c.ETag = resp.Header.Get("ETag") + + var out []Release + for _, r := range raw { + if r.Draft || r.Prerelease { + continue + } + version, ok := strings.CutPrefix(r.TagName, c.TagPrefix) + if !ok { + continue + } + // Belt and braces: drop anything still shaped like a prerelease or a + // build-metadata tag even when GitHub did not flag it. + if strings.ContainsAny(version, "-+") { + continue + } + rel := Release{Version: version, TagName: r.TagName, HTMLURL: r.HTMLURL} + for _, a := range r.Assets { + rel.Assets = append(rel.Assets, Asset{Name: a.Name, URL: a.URL, Size: a.Size}) + } + out = append(out, rel) + } + return out, nil +} diff --git a/cli/internal/release/release_test.go b/cli/internal/release/release_test.go new file mode 100644 index 00000000..ebd1ec07 --- /dev/null +++ b/cli/internal/release/release_test.go @@ -0,0 +1,162 @@ +package release + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCompareSemver(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"1.2.3", "1.2.3", 0}, + {"1.2.4", "1.2.3", 1}, + {"1.2.3", "1.2.4", -1}, + {"1.10.0", "1.9.0", 1}, // numeric, not lexicographic + {"2.0.0", "1.99.99", 1}, + {"1.2", "1.2.0", 0}, // missing components are zero + {"1.2.1", "1.2", 1}, + } + for _, tc := range tests { + if got := CompareSemver(tc.a, tc.b); got != tc.want { + t.Errorf("CompareSemver(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want) + } + } +} + +func TestIsNewer(t *testing.T) { + if !IsNewer("0.3.0", "0.3.1") { + t.Error("0.3.1 should be newer than 0.3.0") + } + if IsNewer("0.3.1", "0.3.1") { + t.Error("a version is not newer than itself") + } + if IsNewer("0.4.0", "0.3.9") { + t.Error("an older release should not be offered") + } + if IsNewer("0.3.0", "") { + t.Error("an empty latest is not an update") + } + // Deliberately unlike the server's version-check, which treats an + // unparseable current version as "anything is newer". That is right for a + // banner and wrong for something that replaces the running application: a + // development build is usually newer than the last release, so offering to + // overwrite it would be a downgrade wearing an update's clothes. + for _, current := range []string{"", "dev", "0.3.0-dev", "0.12.4-129-g8abef0b"} { + if IsNewer(current, "9.9.9") { + t.Errorf("IsNewer(%q, \"9.9.9\") = true; an unidentifiable build must not be replaced", current) + } + } + // A "v" prefix on the installed version is tolerated. + if !IsNewer("v0.3.0", "0.3.1") { + t.Error("a leading v on the current version should be ignored") + } +} + +const releasesJSON = `[ + {"tag_name":"mac/v0.3.0","html_url":"https://example.test/0.3.0","draft":false,"prerelease":false, + "assets":[{"name":"cix-0.3.0-arm64.dmg","browser_download_url":"https://example.test/a.dmg","size":123}, + {"name":"checksums.txt","browser_download_url":"https://example.test/c.txt","size":45}]}, + {"tag_name":"mac/v0.4.0","html_url":"https://example.test/0.4.0","draft":true,"prerelease":false,"assets":[]}, + {"tag_name":"mac/v0.3.5","html_url":"https://example.test/0.3.5","draft":false,"prerelease":true,"assets":[]}, + {"tag_name":"mac/v0.3.2-rc1","html_url":"https://example.test/rc","draft":false,"prerelease":false,"assets":[]}, + {"tag_name":"server/v9.9.9","html_url":"https://example.test/server","draft":false,"prerelease":false,"assets":[]}, + {"tag_name":"mac/v0.3.1","html_url":"https://example.test/0.3.1","draft":false,"prerelease":false, + "assets":[{"name":"cix-0.3.1-arm64.dmg","browser_download_url":"https://example.test/b.dmg","size":999}]} +]` + +func TestLatestFiltersTheStream(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", `W/"abc123"`) + w.Write([]byte(releasesJSON)) + })) + defer srv.Close() + + c := New(DefaultRepo, "mac/v") + c.BaseURL = srv.URL + + got, err := c.Latest(context.Background()) + if err != nil { + t.Fatalf("Latest: %v", err) + } + // 0.4.0 is a draft, 0.3.5 a prerelease, 0.3.2-rc1 carries a prerelease + // suffix GitHub did not flag, and server/v9.9.9 is a different stream + // entirely — sharing one repository with three tag streams is exactly why + // the prefix filter exists. + if got.Version != "0.3.1" { + t.Errorf("Version = %q, want 0.3.1", got.Version) + } + if got.TagName != "mac/v0.3.1" { + t.Errorf("TagName = %q, want mac/v0.3.1", got.TagName) + } + if c.ETag != `W/"abc123"` { + t.Errorf("ETag = %q, want it captured for the next request", c.ETag) + } + if _, ok := got.AssetBySuffix(".dmg"); !ok { + t.Error("the DMG asset should be findable by suffix") + } +} + +func TestLatestSendsAndHonoursETag(t *testing.T) { + var sawIfNoneMatch string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawIfNoneMatch = r.Header.Get("If-None-Match") + w.WriteHeader(http.StatusNotModified) + })) + defer srv.Close() + + c := New(DefaultRepo, "mac/v") + c.BaseURL = srv.URL + c.ETag = `W/"cached"` + + _, err := c.Latest(context.Background()) + if !errors.Is(err, ErrNotModified) { + t.Errorf("Latest() = %v, want ErrNotModified", err) + } + // The whole point: a 304 does not count against the 60-per-hour + // unauthenticated limit, so checking often is free on quiet days. + if sawIfNoneMatch != `W/"cached"` { + t.Errorf("If-None-Match = %q, want the cached ETag", sawIfNoneMatch) + } + if c.ETag != `W/"cached"` { + t.Errorf("ETag = %q; a 304 must not clear it", c.ETag) + } +} + +func TestLatestOnEmptyStream(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte(`[{"tag_name":"server/v1.0.0","draft":false,"prerelease":false,"assets":[]}]`)) + })) + defer srv.Close() + + c := New(DefaultRepo, "mac/v") + c.BaseURL = srv.URL + + // A stream with no releases yet is a normal state for a new tag stream, not + // an error — the app should stay quiet, not report a failure. + got, err := c.Latest(context.Background()) + if err != nil { + t.Fatalf("Latest on an empty stream returned an error: %v", err) + } + if got.Version != "" { + t.Errorf("Version = %q, want empty", got.Version) + } +} + +func TestLatestRateLimit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + c := New(DefaultRepo, "mac/v") + c.BaseURL = srv.URL + + if _, err := c.Latest(context.Background()); err == nil { + t.Fatal("a 403 should be reported as an error") + } +} diff --git a/cli/internal/release/semver.go b/cli/internal/release/semver.go new file mode 100644 index 00000000..521ebb46 --- /dev/null +++ b/cli/internal/release/semver.go @@ -0,0 +1,101 @@ +// Package release finds the newest published release of a tagged stream on +// GitHub. +// +// This is a deliberate port of server/internal/versioncheck, not an import: the +// server and the CLI are separate modules and that package lives under the +// server's internal/, so it is structurally unreachable from here. The +// filtering and comparison rules are kept identical so a tag is judged newer by +// the same standard on both sides, and the tests below mirror that package's. +// +// One rule is deliberately NOT shared. versioncheck.isNewer treats a `-dev` +// build as "anything is newer", which is right for a dashboard banner nudging +// an operator onto a release. It is wrong for something that replaces the +// running application: a development build is usually newer than the last +// release, and offering to overwrite it with an older one is a regression +// disguised as an update. Callers here skip the check for dev builds instead. +package release + +import ( + "strconv" + "strings" +) + +// CompareSemver compares two MAJOR.MINOR.PATCH strings numerically, returning +// -1, 0 or 1. Non-numeric components fall back to a lexicographic comparison — +// a safety net, since the release filter rejects anything that is not plain +// numeric to begin with. +func CompareSemver(a, b string) int { + pa := strings.Split(a, ".") + pb := strings.Split(b, ".") + n := max(len(pa), len(pb)) + + for i := range n { + ai, aOK := 0, true + bi, bOK := 0, true + if i < len(pa) { + ai, aOK = atoi(pa[i]) + } + if i < len(pb) { + bi, bOK = atoi(pb[i]) + } + if aOK && bOK { + if ai != bi { + if ai < bi { + return -1 + } + return 1 + } + continue + } + var as, bs string + if i < len(pa) { + as = pa[i] + } + if i < len(pb) { + bs = pb[i] + } + if as != bs { + if as < bs { + return -1 + } + return 1 + } + } + return 0 +} + +// IsNewer reports whether latest is a strictly newer version than current. +// +// Unlike the server's equivalent, an unparseable current version is NOT treated +// as "anything is newer" — see the package comment. Here it means "do not +// offer", because the only safe answer when we cannot tell which build is newer +// is to leave the installed one alone. +func IsNewer(current, latest string) bool { + if latest == "" { + return false + } + cur := strings.TrimPrefix(current, "v") + if cur == "" || !looksNumeric(cur) { + return false + } + return CompareSemver(latest, cur) > 0 +} + +func atoi(s string) (int, bool) { + n, err := strconv.Atoi(s) + return n, err == nil +} + +// looksNumeric reports whether every dot-separated component parses as a +// number — i.e. the string is a plain MAJOR.MINOR.PATCH with no suffix. +func looksNumeric(s string) bool { + if s == "" { + return false + } + for part := range strings.SplitSeq(s, ".") { + if _, ok := atoi(part); !ok { + return false + } + } + return true +} diff --git a/cli/internal/watcher/watcher_test.go b/cli/internal/watcher/watcher_test.go index ec02e1a0..57113b2e 100644 --- a/cli/internal/watcher/watcher_test.go +++ b/cli/internal/watcher/watcher_test.go @@ -124,10 +124,32 @@ func newIndexServer(t *testing.T, dir string) (*httptest.Server, *serverCalls) { // waitForCalls polls the mock server counters until BeginIndex has been called // at least target times, or the deadline is reached. func waitForCalls(calls *serverCalls, target int) { - deadline := time.Now().Add(2 * time.Second) + waitForCounter(calls, target, func(c *serverCalls) int { return c.Begin }) +} + +// waitForFinish waits for FinishIndex, which is what a test asserting on +// calls.Finish actually needs. +// +// Waiting on BeginIndex and then asserting on Finish is a race, and it is the +// one this package kept losing on CI: they are two sequential HTTP calls from +// the indexer goroutine, so the window between them is small but real. It shows +// up as "expected FinishIndex to be called" on a test that took 0.02s — not a +// timeout, an assertion made too early. +func waitForFinish(calls *serverCalls, target int) { + waitForCounter(calls, target, func(c *serverCalls) int { return c.Finish }) +} + +// waitForCounter polls until a counter reaches target, or gives up. +// +// The deadline is generous on purpose. It exits the instant the condition holds, +// so a long limit costs nothing when things work and is the difference between a +// green run and a red one on a cold CI runner — which is where this package has +// failed, never locally. +func waitForCounter(calls *serverCalls, target int, get func(*serverCalls) int) { + deadline := time.Now().Add(15 * time.Second) for time.Now().Before(deadline) { calls.mu.Lock() - count := calls.Begin + count := get(calls) calls.mu.Unlock() if count >= target { return @@ -382,17 +404,10 @@ func TestFlushChanges_TriggersIncrementalReindex(t *testing.T) { w.pendingChanges[filepath.Join(dir, "main.go")] = true w.flushChanges() - // Wait for indexing to complete (it runs in a goroutine now) - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - calls.mu.Lock() - count := calls.Begin - calls.mu.Unlock() - if count >= 1 { - break - } - time.Sleep(10 * time.Millisecond) - } + // Indexing runs in a goroutine, and it ends with FinishIndex — so that is + // what to wait for. Waiting for BeginIndex and then asserting on Finish is + // the race this test used to lose on CI. + waitForFinish(calls, 1) calls.mu.Lock() defer calls.mu.Unlock() @@ -470,7 +485,7 @@ func TestFlushChanges_RecoveryAfterServerDown(t *testing.T) { w2.pendingChanges[filePath] = true w2.flushChanges() - waitForCalls(calls, 1) + waitForFinish(calls, 1) calls.mu.Lock() defer calls.mu.Unlock() @@ -492,7 +507,7 @@ func TestTriggerFullReindex_CallsAPI(t *testing.T) { w.triggerFullReindex() - waitForCalls(calls, 1) + waitForFinish(calls, 1) calls.mu.Lock() defer calls.mu.Unlock() @@ -664,17 +679,12 @@ func TestDebounce_MultipleEventsOnce(t *testing.T) { w.trackChange(filepath.Join(dir, "b.go")) } - // Poll until the debounce timer fires, with a generous deadline. - deadline := time.Now().Add(time.Duration(w.debounceMS*10) * time.Millisecond) - for time.Now().Before(deadline) { - calls.mu.Lock() - n := calls.Begin - calls.mu.Unlock() - if n >= 1 { - break - } - time.Sleep(10 * time.Millisecond) - } + // Wait for the debounce timer to fire. Ten times the debounce interval reads + // like a generous margin and is 800ms — which a cold CI runner loses. The + // property under test is that five events collapse into one flush, not how + // fast the flush arrives, so the wait is bounded by patience rather than by + // the interval. + waitForCalls(calls, 1) calls.mu.Lock() defer calls.mu.Unlock() diff --git a/cli/launcher/bundle_darwin.go b/cli/launcher/bundle_darwin.go new file mode 100644 index 00000000..ba3824cc --- /dev/null +++ b/cli/launcher/bundle_darwin.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// bundle describes the .app this launcher is running from. +// +// The .app contains the launcher and nothing else that runs. cix-server, the +// cix CLI and llama-server live in ~/.cix/runtime/ — see runtime_darwin.go for +// why they were moved out and how they are managed. +// +// cix.app/Contents/ +// MacOS/ cix-launcher +// Resources/ cix.icns cixTemplate.png cixTemplate@2x.png +type bundle struct { + Root string // …/cix.app + MacOS string // …/cix.app/Contents/MacOS + Resources string // …/cix.app/Contents/Resources +} + +// errNotBundled is returned when the launcher runs from a plain directory +// (`go run ./launcher`, `go build -o /tmp/x`) rather than from inside a .app. +var errNotBundled = errors.New("not running from an application bundle") + +// locateBundle derives the bundle layout from the running executable. +// +// os.Executable() is resolved through symlinks deliberately: /usr/local/bin/cix +// is a symlink into the bundle (so it follows updates), and a future +// launcher symlink would otherwise resolve the layout relative to /usr/local/bin. +func locateBundle() (bundle, error) { + exe, err := os.Executable() + if err != nil { + return bundle{}, err + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + + macOS := filepath.Dir(exe) + contents := filepath.Dir(macOS) + root := filepath.Dir(contents) + + if filepath.Base(macOS) != "MacOS" || filepath.Base(contents) != "Contents" || !strings.HasSuffix(root, ".app") { + return bundle{}, errNotBundled + } + + return bundle{ + Root: root, + MacOS: macOS, + Resources: filepath.Join(contents, "Resources"), + }, nil +} + +// isTranslocated reports whether macOS is running the bundle from a read-only +// randomised copy instead of where the user put it. +// +// Gatekeeper does this to any quarantined, unsigned-by-Developer-ID app opened +// from outside /Applications — typically straight out of ~/Downloads. The app +// appears to work, but the bundle path is a throwaway, so anything the launcher +// writes into it (or any launchd job pointing at it) breaks the moment the +// translocated copy is reaped. Detect it and say so, rather than half-working. +func isTranslocated(b bundle) bool { + return strings.Contains(b.Root, "/AppTranslocation/") +} + +// binaryVersion runs ` ` and returns its first line of output. +// +// Both bundled binaries answer a version flag and exit immediately +// (cix-server -v, cix --version), so the timeout is a guard against a wedged +// exec rather than an expected wait. +func binaryVersion(bin, flag string) string { + if _, err := os.Stat(bin); err != nil { + return "missing" + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, bin, flag).Output() + if err != nil { + return "unknown" + } + + line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n") + if line == "" { + return "unknown" + } + return line +} diff --git a/cli/launcher/dialog_darwin.go b/cli/launcher/dialog_darwin.go new file mode 100644 index 00000000..0509d685 --- /dev/null +++ b/cli/launcher/dialog_darwin.go @@ -0,0 +1,173 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + "time" +) + +// osascript is the dialog mechanism for the whole launcher. +// +// The menu-bar library (Phase 2) has no dialog API, and pulling in a second GUI +// toolkit to draw three alerts would double the bundle for no gain. AppleScript +// alerts are native, need no linkage, and survive the app being LSUIElement. +// +// Two rules, both load-bearing: +// - Every string that reaches AppleScript goes through quoteAS. Text here is +// not always ours — email addresses, server errors and file paths all end up +// in dialogs, and an unescaped quote turns a message into a syntax error at +// best and an injected statement at worst. +// - Every invocation is bounded. A modal that never returns would wedge the +// launcher with no window to close, because there is no Dock icon. + +// quoteAS renders a Go string as an AppleScript string literal. +func quoteAS(s string) string { + r := strings.NewReplacer(`\`, `\\`, `"`, `\"`) + // AppleScript has no escape for a literal newline inside a quoted string; + // concatenating `return` is the idiomatic way to express one. + parts := strings.Split(s, "\n") + for i, p := range parts { + parts[i] = `"` + r.Replace(p) + `"` + } + return strings.Join(parts, " & return & ") +} + +// dialogIcon is the POSIX path to the icon dialogs are drawn with. Set once at +// startup from the bundle; empty when the launcher runs outside a .app. +var dialogIcon string + +// alert shows a modal informational dialog and blocks until it is dismissed. +// +// `display dialog` rather than the more obvious `display alert`, for one +// reason: an alert is drawn with the icon of the process that ran the script, +// which here is osascript — so the app's own dialogs came up wearing a generic +// folder icon. `display dialog` takes an explicit icon. The cost is that the +// title is a window title instead of bold body text; the icon is worth more. +// It is also the primitive Phase 2 needs anyway, since only `display dialog` +// supports `default answer` for text input. +func alert(title, message string) error { + var script string + if dialogIcon != "" { + script = fmt.Sprintf( + `display dialog %s with title %s with icon POSIX file %s buttons {"OK"} default button "OK"`, + quoteAS(message), quoteAS(title), quoteAS(dialogIcon), + ) + } else { + script = fmt.Sprintf( + `display alert %s message %s as informational buttons {"OK"} default button "OK"`, + quoteAS(title), quoteAS(message), + ) + } + return runOsascript(2*time.Minute, script) +} + +// errCancelled is returned when the user dismissed a dialog instead of +// answering it. osascript reports this as exit status 1 — the same status as a +// real failure — so it has to be told apart from the error text. +var errCancelled = errors.New("cancelled by user") + +// isUserCancelled recognises AppleScript's user-cancelled error. +// +// Match the OSStatus, not the sentence. The message is localised: on this +// project's own development Mac, running a British locale, osascript says "User +// cancelled" with two Ls, while the American spelling has one — so a string +// match on either is wrong somewhere, and was. -128 (userCanceledErr) is a +// number and says the same thing in every language. +func isUserCancelled(stderr string) bool { + return strings.Contains(stderr, "(-128)") +} + +// prompt asks for one line of text. Returns errCancelled if the user cancels. +func prompt(title, message, defaultAnswer string) (string, error) { + script := fmt.Sprintf( + `display dialog %s with title %s default answer %s %s buttons {"Cancel", "OK"} default button "OK" cancel button "Cancel"`, + quoteAS(message), quoteAS(title), quoteAS(defaultAnswer), iconClause(), + ) + out, err := outputOsascript(5*time.Minute, script) + if err != nil { + return "", err + } + // osascript prints `button returned:OK, text returned:`. The text is + // last, and a value containing ", text returned:" is not reachable because + // the field is single-line — so cutting on the marker is safe. + _, answer, ok := strings.Cut(out, "text returned:") + if !ok { + return "", fmt.Errorf("unexpected osascript output: %q", out) + } + return strings.TrimSpace(answer), nil +} + +// confirm shows a two-button question. Returns false when the user declines. +func confirm(title, message, okLabel string) (bool, error) { + return ask(title, message, okLabel, "Cancel") +} + +// ask shows a two-button question with both labels spelled out. +// +// Separate from confirm because not every choice has a "cancel" side. "Take +// Over" versus "Leave It Alone" are two real options, and labelling the second +// one Cancel would imply it does nothing — when in fact it decides how the app +// behaves from then on. +// +// yesLabel is the default button. Dismissing the dialog counts as no. +func ask(title, message, yesLabel, noLabel string) (bool, error) { + script := fmt.Sprintf( + `display dialog %s with title %s %s buttons {%s, %s} default button %s cancel button %s`, + quoteAS(message), quoteAS(title), iconClause(), + quoteAS(noLabel), quoteAS(yesLabel), quoteAS(yesLabel), quoteAS(noLabel), + ) + if _, err := outputOsascript(5*time.Minute, script); err != nil { + if errors.Is(err, errCancelled) { + return false, nil + } + return false, err + } + return true, nil +} + +func iconClause() string { + if dialogIcon == "" { + return "" + } + return "with icon POSIX file " + quoteAS(dialogIcon) +} + +func outputOsascript(timeout time.Duration, script string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, "osascript", "-e", script) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return "", fmt.Errorf("osascript timed out after %s", timeout) + } + if isUserCancelled(stderr.String()) { + return "", errCancelled + } + return "", fmt.Errorf("osascript: %w: %s", err, strings.TrimSpace(stderr.String())) + } + return stdout.String(), nil +} + +func runOsascript(timeout time.Duration, script string) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "osascript", "-e", script) + out, err := cmd.CombinedOutput() + if err != nil { + if ctx.Err() != nil { + return fmt.Errorf("osascript timed out after %s", timeout) + } + return fmt.Errorf("osascript: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/cli/launcher/dots_darwin.go b/cli/launcher/dots_darwin.go new file mode 100644 index 00000000..99181259 --- /dev/null +++ b/cli/launcher/dots_darwin.go @@ -0,0 +1,107 @@ +package main + +import ( + "bytes" + "image" + "image/color" + "image/png" + "math" + "sync" +) + +// Status dots for the menu rows. +// +// A coloured dot beside a row is how macOS status apps show liveness, and it +// carries the state without spending menu width on words like "— ready". That +// matters here: an NSMenu is as wide as its widest row, so every character in a +// status string is width the model name does not get. +// +// These are NOT template images. A template image is recoloured by macOS from +// its alpha channel alone, which would turn every dot the same colour and erase +// the only thing they say. The menu-bar glyph is a template image; these are +// not, deliberately. +// +// Generated rather than shipped as files: they are three flat circles, and a +// generator is smaller than the PNGs plus the build-script lines to copy them. + +// Colours picked to stay legible on both light and dark menu backgrounds, and +// to survive the most common form of colour blindness by differing in lightness +// as well as hue — a green/red pair alone would not. +var ( + dotGreen = color.NRGBA{R: 0x30, G: 0xB0, B: 0x50, A: 0xFF} + dotAmber = color.NRGBA{R: 0xE0, G: 0x94, B: 0x1B, A: 0xFF} + dotRed = color.NRGBA{R: 0xD7, G: 0x3E, B: 0x2C, A: 0xFF} // the CIX brand red + dotGrey = color.NRGBA{R: 0x8E, G: 0x8E, B: 0x93, A: 0xFF} + + // dotBlank is a fully transparent dot used purely as a spacer. AppKit + // indents a menu item's title by its image width, so a row without an image + // starts further left than its neighbours and the group reads as ragged. + // An invisible image of the same size keeps the titles on one edge. + dotBlank = color.NRGBA{} +) + +// dotSize is the rendered pixel size. 24 px at @2x renders as a 12 pt dot, +// which is the size AppKit gives a menu item image next to body text. +const dotSize = 24 + +var ( + dotOnce sync.Once + dotCache map[color.NRGBA][]byte +) + +// dotPNG returns a PNG of a filled circle in c, encoded once and reused. +func dotPNG(c color.NRGBA) []byte { + dotOnce.Do(func() { + dotCache = map[color.NRGBA][]byte{} + for _, col := range []color.NRGBA{dotGreen, dotAmber, dotRed, dotGrey, dotBlank} { + dotCache[col] = renderDot(col) + } + }) + if b, ok := dotCache[c]; ok { + return b + } + return renderDot(c) +} + +func renderDot(c color.NRGBA) []byte { + img := image.NewNRGBA(image.Rect(0, 0, dotSize, dotSize)) + centre := float64(dotSize-1) / 2 + // Leave a pixel of margin so the antialiased edge is not clipped by the + // image bounds, which reads as a flat-sided circle at this size. + radius := centre - 1 + + for y := range dotSize { + for x := range dotSize { + dx := float64(x) - centre + dy := float64(y) - centre + d := math.Hypot(dx, dy) + // Coverage over a one-pixel band at the edge: cheap antialiasing, + // and at 24 px the difference between this and a hard edge is the + // difference between a circle and a cog. + var alpha float64 + switch { + case d <= radius-0.5: + alpha = 1 + case d >= radius+0.5: + alpha = 0 + default: + alpha = radius + 0.5 - d + } + if alpha <= 0 { + continue + } + img.SetNRGBA(x, y, color.NRGBA{ + R: c.R, G: c.G, B: c.B, + A: uint8(math.Round(alpha * float64(c.A))), + }) + } + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + // Encoding a fixed-size in-memory NRGBA image cannot fail; a nil icon + // simply leaves the menu row without one. + return nil + } + return buf.Bytes() +} diff --git a/cli/launcher/env_darwin.go b/cli/launcher/env_darwin.go new file mode 100644 index 00000000..84cf63e6 --- /dev/null +++ b/cli/launcher/env_darwin.go @@ -0,0 +1,187 @@ +package main + +import ( + "bufio" + "fmt" + "net" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// The launcher keeps server configuration in ~/.cix/server.env, deliberately +// NOT in the repo-root .env that install-server.sh uses. A developer checkout +// and an installed .app can then coexist on one machine without fighting over +// one file — which matters because they also share a launchd label. +// +// The file is the single source of truth for the server process: the launchd +// plist carries no configuration, the wrapper script just sources this file. +// Editing it and restarting is enough, with no plist regeneration. + +const ( + defaultServerPort = 21847 + envFileMode = 0o600 +) + +func cixHome() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".cix"), nil +} + +func serverEnvPath() (string, error) { + dir, err := cixHome() + if err != nil { + return "", err + } + return filepath.Join(dir, "server.env"), nil +} + +// readServerEnv parses ~/.cix/server.env into a map. +// +// The parser is deliberately small: this file is written by us and consumed by +// our own wrapper (`set -a; source`), so it never has to cope with the full +// range of shell syntax. It does strip matching quotes, because the writer adds +// them. +func readServerEnv() (map[string]string, error) { + path, err := serverEnvPath() + if err != nil { + return nil, err + } + return readEnvFile(path) +} + +// readEnvFile parses any KEY=value file in the same dialect. Used for our own +// server.env and, during a takeover, for the .env an install-server.sh wrapper +// sources — which is written by a different tool and may quote differently. +func readEnvFile(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + vars := map[string]string{} + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(strings.TrimPrefix(key, "export ")) + value = strings.TrimSpace(value) + if len(value) >= 2 { + if (value[0] == '\'' && value[len(value)-1] == '\'') || + (value[0] == '"' && value[len(value)-1] == '"') { + value = value[1 : len(value)-1] + } + } + // Undo the writer's escaping of an embedded single quote. + vars[key] = strings.ReplaceAll(value, `'\''`, `'`) + } + if err := sc.Err(); err != nil { + return nil, err + } + return vars, nil +} + +// writeServerEnv writes the file at 0600 — it holds an API key and a bootstrap +// password. Written via a temp file + rename so a crash mid-write cannot leave +// the server with a half-parsed configuration. +func writeServerEnv(vars map[string]string) error { + path, err := serverEnvPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + + keys := make([]string, 0, len(vars)) + for k := range vars { + keys = append(keys, k) + } + sort.Strings(keys) + + var sb strings.Builder + sb.WriteString("# Generated by cix.app. Sourced by ~/.cix/launchd/run-cix-server.sh.\n") + sb.WriteString("# Contains an API key and the bootstrap password: keep at mode 0600.\n") + for _, k := range keys { + fmt.Fprintf(&sb, "%s='%s'\n", k, strings.ReplaceAll(vars[k], `'`, `'\''`)) + } + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(sb.String()), envFileMode); err != nil { + return err + } + // WriteFile honours the mode only when it creates the file; an existing + // temp file from a previous crash would keep its old mode. + if err := os.Chmod(tmp, envFileMode); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return err + } + return nil +} + +// serverPort returns the port the server is configured to listen on. +func serverPort(vars map[string]string) int { + if raw, ok := vars["CIX_PORT"]; ok { + if n, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil && n > 0 && n < 65536 { + return n + } + } + return defaultServerPort +} + +// Bind addresses the network toggle switches between. +// +// The server's own default is an empty CIX_BIND_ADDR, meaning every interface — +// that is what containers need and every existing deployment already has, so it +// stays. A desktop install is a different situation: the app writes loopback at +// first run, because "everyone on the café Wi-Fi can query my code index" is +// rarely what someone installing a menu bar app had in mind. +const ( + bindLocalOnly = "127.0.0.1" + bindAllInterfaces = "0.0.0.0" +) + +// isLocalOnly reports whether the configured bind address is loopback. +// +// An absent CIX_BIND_ADDR is NOT local-only: it is the server's all-interfaces +// default. Reading it the other way would show a reassuring "local only" on +// exactly the installs that are exposed. +func isLocalOnly(vars map[string]string) bool { + addr := strings.TrimSpace(vars["CIX_BIND_ADDR"]) + if addr == "" { + return false + } + if ip := net.ParseIP(addr); ip != nil { + return ip.IsLoopback() + } + return strings.EqualFold(addr, "localhost") +} + +// dashboardURL is built from the server.env port, never from the CLI config. +// The CLI's default server may legitimately point at a remote cix — opening +// that in a browser when the user clicked "Open Dashboard" on a local menu bar +// item would be wrong, and confusing in exactly the case where the local server +// is down. +func dashboardURL(vars map[string]string) string { + return fmt.Sprintf("http://localhost:%d/dashboard", serverPort(vars)) +} + +func localBaseURL(vars map[string]string) string { + return fmt.Sprintf("http://localhost:%d", serverPort(vars)) +} diff --git a/cli/launcher/firstrun_darwin.go b/cli/launcher/firstrun_darwin.go new file mode 100644 index 00000000..1879567e --- /dev/null +++ b/cli/launcher/firstrun_darwin.go @@ -0,0 +1,205 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net/mail" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/dvcdsys/code-index/cli/internal/client" + "github.com/dvcdsys/code-index/cli/internal/config" +) + +// First-run setup. +// +// This exists because of a hard server-side constraint, not for polish: +// cix-server refuses to start against an empty database unless both +// CIX_BOOTSTRAP_ADMIN_EMAIL and CIX_BOOTSTRAP_ADMIN_PASSWORD are set +// (server/cmd/cix-server/bootstrap.go, `case count == 0`). It will not invent +// an admin account silently. A drag-installed app with no configuration can +// therefore never start its own server, so the wizard is load-bearing. + +const bootstrapServerName = "local" + +// needsFirstRun reports whether ~/.cix/server.env is missing. +func needsFirstRun() bool { + path, err := serverEnvPath() + if err != nil { + return false + } + _, err = os.Stat(path) + return errors.Is(err, os.ErrNotExist) +} + +// runFirstRun walks the user through creating the admin account, writes +// server.env, and registers the server with the CLI. +// +// Returns errCancelled if the user backs out, which is not an error condition — +// the app stays running with Start disabled until they complete setup. +func runFirstRun(u *updater) error { + intro := "cix needs an administrator account before it can start.\n\n" + + "Enter the email address to sign in with. A password will be generated for you, " + + "and you will be asked to change it the first time you log in.\n\n" + + "Setup then downloads the cix server itself — around 40 MB — which takes a moment." + + email, err := prompt("Set up cix", intro, "") + if err != nil { + return err + } + email = strings.TrimSpace(email) + for { + if _, err := mail.ParseAddress(email); err == nil && email != "" { + break + } + email, err = prompt("Set up cix", "That does not look like an email address. Try again:", email) + if err != nil { + return err + } + email = strings.TrimSpace(email) + } + + // Before anything is written: the runtime is the one step here that can fail + // for reasons outside this machine, and a setup that wrote server.env and a + // launchd agent pointing at a server that was never downloaded would look + // complete and be broken. + if err := ensureRuntime(u, logProgress); err != nil { + return fmt.Errorf("could not install the cix server: %w", err) + } + + password, err := generatePassword() + if err != nil { + return fmt.Errorf("generate password: %w", err) + } + apiKey, err := generateAPIKey() + if err != nil { + return fmt.Errorf("generate api key: %w", err) + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + dataDir := filepath.Join(home, ".cix", "data") + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return err + } + + vars := map[string]string{ + "CIX_BOOTSTRAP_ADMIN_EMAIL": email, + "CIX_BOOTSTRAP_ADMIN_PASSWORD": password, + // Imported as an "env-bootstrap" legacy key when the admin is created + // on a fresh DB (bootstrap.go), so the CLI works from the first boot + // without anyone visiting the dashboard to mint one. + "CIX_API_KEY": apiKey, + "CIX_PORT": strconv.Itoa(defaultServerPort), + "CIX_DATA_DIR": dataDir, + "CIX_SQLITE_PATH": filepath.Join(dataDir, "cix.db"), + // Loopback by default, unlike the server's own all-interfaces default. + // A container has to be reachable from outside itself; a desktop app + // does not, and exposing a code index to the local network is a choice + // someone should make on purpose. The menu has a toggle for it. + "CIX_BIND_ADDR": bindLocalOnly, + // The .app owns updating itself. Leaving the server's own check on + // would mean two different components offering the user two different + // "update available" prompts for two different tag streams. + "CIX_VERSION_CHECK_ENABLED": "false", + } + if err := writeServerEnv(vars); err != nil { + return fmt.Errorf("write server.env: %w", err) + } + + if err := writeLaunchdFiles(false); err != nil { + return fmt.Errorf("write launchd files: %w", err) + } + if err := startServer(); err != nil { + return fmt.Errorf("start server: %w", err) + } + + // Register with the CLI so `cix` works out of the box. Failure here is not + // fatal — the server is up and the dashboard works; only the terminal + // client is left unconfigured, and the message says so. + cliNote := "" + if err := registerWithCLI(localBaseURL(vars), apiKey); err != nil { + cliNote = fmt.Sprintf("\n\nThe cix command line client could not be configured automatically (%v). "+ + "The server itself is unaffected.", err) + } + + waitNote := "" + if err := waitForHealth(localBaseURL(vars), 90*time.Second); err != nil { + // Not an error: a cold start loads an embedding model and can take + // minutes, in silence. Saying so beats a spinner that gives up. + waitNote = "\n\nThe server is still starting. First boot downloads and loads the embedding " + + "model, which can take a few minutes; the menu bar will show it as running when it is ready." + } + + return alert("cix is set up", fmt.Sprintf( + "Sign in at %s\n\nEmail:\n%s\n\nTemporary password:\n%s\n\n"+ + "You will be asked to change this password on first login.%s%s", + dashboardURL(vars), email, password, waitNote, cliNote)) +} + +// registerWithCLI adds (or updates) the local server in ~/.cix/config.yaml. +// +// It does not touch default_server when one is already set: a user whose CLI +// points at a remote cix should not have it silently repointed at localhost by +// installing an app. +func registerWithCLI(baseURL, apiKey string) error { + if err := config.SetServerURL(bootstrapServerName, baseURL); err != nil { + return err + } + if err := config.SetServerKey(bootstrapServerName, apiKey); err != nil { + return err + } + cfg, err := config.Load() + if err != nil { + return err + } + if cfg.DefaultServer == "" { + return config.SetDefaultServer(bootstrapServerName) + } + return nil +} + +func waitForHealth(baseURL string, timeout time.Duration) error { + c := client.New(baseURL, "") + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if err := c.Health(); err == nil { + return nil + } + time.Sleep(time.Second) + } + return fmt.Errorf("server did not answer /health within %s", timeout) +} + +// generateAPIKey mints a key in the server's own format: "cix_" plus 43 +// base64url characters (32 random bytes). The format is not cosmetic — the +// server's ImportLegacy path stores this verbatim, and apikeys.PrefixDisplayLen +// assumes the prefix shape when rendering keys in the dashboard. +func generateAPIKey() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return "cix_" + base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +// generatePassword returns a 24-character base64url password. +// +// It is shown once, in a dialog, and the server forces a change at first login, +// so readability matters more than memorability. base64url avoids the quoting +// problem entirely: the value is written into a shell-sourced env file, and a +// generated password containing a quote would break the wrapper script. +func generatePassword() (string, error) { + var b [18]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} diff --git a/cli/launcher/launchd_darwin.go b/cli/launcher/launchd_darwin.go new file mode 100644 index 00000000..7d92be54 --- /dev/null +++ b/cli/launcher/launchd_darwin.go @@ -0,0 +1,333 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// launchd control for the cix-server agent. +// +// The label, paths and helper semantics deliberately match install-server.sh +// (:43-47, :211-270) so a machine never ends up with two competing definitions +// of "the cix server agent". The one divergence is KeepAlive — see writePlist. +// +// The hard-won rule from the installer, which applies verbatim here: no +// launchctl exit code is worth trusting. `bootout` returns while the job is +// still draining out of the domain, and the legacy `load` exits 0 even when it +// failed. Every step asks launchd whether the label is really there. + +const ( + launchdLabel = "com.cix.server" + + // Provenance marker written into the wrapper script. Its absence is how we + // recognise a wrapper generated by install-server.sh — same label, but + // pointing at a repo checkout rather than at this bundle. + managedByMarker = "# managed-by: cix.app" +) + +func launchdDomain() string { return fmt.Sprintf("gui/%d", os.Getuid()) } + +func launchdTarget() string { return launchdDomain() + "/" + launchdLabel } + +func plistPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "LaunchAgents", launchdLabel+".plist"), nil +} + +func wrapperPath() (string, error) { + dir, err := cixHome() + if err != nil { + return "", err + } + return filepath.Join(dir, "launchd", "run-cix-server.sh"), nil +} + +func logDir() (string, error) { + dir, err := cixHome() + if err != nil { + return "", err + } + return filepath.Join(dir, "logs"), nil +} + +// runLaunchctl runs one launchctl subcommand with a deadline. launchctl is +// normally instantaneous, so the timeout is a guard against a wedged domain +// rather than an expected wait. +func runLaunchctl(args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "launchctl", args...).CombinedOutput() + return string(out), err +} + +// launchdLoaded reports whether the agent is present in the user domain. +// `launchctl print` is the only trustworthy check available. +func launchdLoaded() bool { + _, err := runLaunchctl("print", launchdTarget()) + return err == nil +} + +// launchdPID returns the pid of the running job, or 0 when the job is +// registered but has no process — never started, crashed, or on its way out. +func launchdPID() int { + out, err := runLaunchctl("print", launchdTarget()) + if err != nil { + return 0 + } + for line := range strings.SplitSeq(out, "\n") { + line = strings.TrimSpace(line) + if rest, ok := strings.CutPrefix(line, "pid = "); ok { + if pid, err := strconv.Atoi(strings.TrimSpace(rest)); err == nil { + return pid + } + } + } + return 0 +} + +// launchdBootout removes the job and waits for it to actually leave the domain. +// Bootstrapping the same label inside the drain window fails with an +// "Input/output error" that reads like a permissions problem. +func launchdBootout() error { + _, _ = runLaunchctl("bootout", launchdTarget()) + for range 50 { + if !launchdLoaded() { + return nil + } + time.Sleep(200 * time.Millisecond) + } + return fmt.Errorf("launchd job %s did not leave the domain", launchdLabel) +} + +// launchdBootstrap loads the agent, draining any previous instance first. The +// whole cycle is retried: a domain that was busy on the first attempt is +// usually ready on the second. +func launchdBootstrap() error { + path, err := plistPath() + if err != nil { + return err + } + var lastOut string + for range 3 { + if err := launchdBootout(); err != nil { + return err + } + out, err := runLaunchctl("bootstrap", launchdDomain(), path) + lastOut = out + if err == nil && launchdLoaded() { + return nil + } + time.Sleep(time.Second) + } + return fmt.Errorf("could not bootstrap %s: %s", launchdLabel, strings.TrimSpace(lastOut)) +} + +// startServer brings the server up. +// +// With KeepAlive=false the job may be loaded but idle, so "start" is two steps: +// make sure the label is in the domain, then kickstart the process. kickstart +// on an already-running job is a no-op restart, which is the right behaviour +// for a menu item that means "make it run". +func startServer() error { + if !launchdLoaded() { + if err := launchdBootstrap(); err != nil { + return err + } + } + if out, err := runLaunchctl("kickstart", launchdTarget()); err != nil { + return fmt.Errorf("kickstart: %s", strings.TrimSpace(out)) + } + return nil +} + +// stopServer sends SIGTERM to the running process and leaves the job loaded. +// +// Not `bootout`: the server handles SIGTERM and drains, and booting the label +// out would put every subsequent Start through the drain race for no gain. The +// job staying loaded-but-idle is exactly what KeepAlive=false is for. +func stopServer() error { + if !launchdLoaded() { + return nil + } + out, err := runLaunchctl("kill", "SIGTERM", launchdTarget()) + if err != nil { + // "No such process" is success from the user's point of view: the job + // is loaded, nothing is running, which is what they asked for. + if strings.Contains(out, "No such process") || launchdPID() == 0 { + return nil + } + return fmt.Errorf("kill: %s", strings.TrimSpace(out)) + } + return nil +} + +// writeLaunchdFiles regenerates the wrapper and the plist. +// +// Called on every launch. It no longer has to chase a moving bundle — the +// wrapper execs ~/.cix/runtime/current/cix-server, a path that survives both an +// app that was moved and a runtime that was updated — but it is still what puts +// the files back if something else overwrote them. +func writeLaunchdFiles(runAtLoad bool) error { + server, err := runtimeServerPath() + if err != nil { + return err + } + wrapper, err := wrapperPath() + if err != nil { + return err + } + plist, err := plistPath() + if err != nil { + return err + } + logs, err := logDir() + if err != nil { + return err + } + envPath, err := serverEnvPath() + if err != nil { + return err + } + + for _, dir := range []string{filepath.Dir(wrapper), filepath.Dir(plist), logs} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + + // The wrapper holds all the configuration indirection so the plist stays + // static: launchd only re-reads a plist at bootstrap, but the wrapper is + // read afresh on every start, so editing server.env and restarting is + // enough. CIX_LLAMA_BIN_DIR is not set — cix-server resolves llama/ next to + // its own executable, and inside the runtime directory that is already + // correct, through the symlink or resolved past it. + // + // It execs the `current` symlink rather than the versioned directory behind + // it. That is the whole point of the symlink: a runtime update swaps where + // it points and this file does not change, so no plist rewrite and no + // bootout/bootstrap cycle is needed to pick up a new server. + script := fmt.Sprintf(`#!/usr/bin/env bash +%s +# Generated by cix.app — launchd entry point for cix-server. +# Regenerated on every app launch; edit ~/.cix/server.env instead. +set -euo pipefail +set -a +source %q +set +a +exec %q +`, managedByMarker, envPath, server) + + if err := os.WriteFile(wrapper, []byte(script), 0o755); err != nil { + return err + } + if err := os.Chmod(wrapper, 0o755); err != nil { + return err + } + + runAtLoadXML := "" + if runAtLoad { + runAtLoadXML = "" + } + + // KeepAlive=false, a deliberate divergence from install-server.sh. + // With KeepAlive=true, Stop has to be a bootout (launchd would restart + // anything it merely killed), which drags the drain race onto the hot path + // of a menu click. The trade is that a crashed server is not resurrected — + // acceptable when a menu bar item shows it as stopped within five seconds. + doc := fmt.Sprintf(` + + + Label%s + ProgramArguments + %s + RunAtLoad%s + KeepAlive + StandardOutPath%s + StandardErrorPath%s + +`, launchdLabel, wrapper, runAtLoadXML, + filepath.Join(logs, "cix-server.log"), + filepath.Join(logs, "cix-server.err")) + + return os.WriteFile(plist, []byte(doc), 0o644) +} + +// autostartEnabled reads RunAtLoad back out of the installed plist. +// +// The plist is regenerated on every app launch and on every Start, so this is +// what stops that regeneration from quietly resetting the user's autostart +// choice. Absent plist means absent choice, which is off. (The menu toggle that +// sets it is Phase 3; this reader is what makes adding it safe.) +func autostartEnabled() bool { + path, err := plistPath() + if err != nil { + return false + } + data, err := os.ReadFile(path) + if err != nil { + return false + } + // Deliberately not a plist parser: we generate this file ourselves, on one + // line, in a known shape. A foreign plist is handled by foreignAgent, which + // takes the app out of the business of writing it at all. + body := string(data) + idx := strings.Index(body, "RunAtLoad") + if idx < 0 { + return false + } + rest := body[idx+len("RunAtLoad"):] + return strings.HasPrefix(strings.TrimSpace(rest), "") +} + +// setAutostart flips RunAtLoad and reloads the agent. +// +// The reload is not optional and cannot be skipped by kickstarting instead: +// launchd reads a plist exactly once, when the job is bootstrapped into the +// domain. A rewritten plist with the job still loaded changes nothing at all +// until the next login — which is precisely the setting being changed, so the +// user would find out months later. +// +// Rebootstrapping restarts a running server. That is why the caller restores +// the process afterwards rather than this function pretending the cost is zero. +func setAutostart(enabled bool) error { + if err := writeLaunchdFiles(enabled); err != nil { + return err + } + return launchdBootstrap() +} + +// foreignAgent reports whether a launchd agent under our label exists but was +// set up by something else — in practice install-server.sh, which uses the same +// label (:44) but points it at a repo checkout (:351). +// +// Clobbering it would silently repoint a developer's working install at the +// bundle. The app goes observe-only instead: status, provider and dashboard +// still work over HTTP, but Start/Stop are disabled and labelled. +func foreignAgent() bool { + plist, err := plistPath() + if err != nil { + return false + } + if _, err := os.Stat(plist); err != nil { + return false // nothing there at all; ours to create + } + wrapper, err := wrapperPath() + if err != nil { + return false + } + data, err := os.ReadFile(wrapper) + if err != nil { + // A plist exists but its wrapper does not, or is unreadable: not ours. + return true + } + return !strings.Contains(string(data), managedByMarker) +} diff --git a/cli/launcher/logging_darwin.go b/cli/launcher/logging_darwin.go new file mode 100644 index 00000000..578d94a7 --- /dev/null +++ b/cli/launcher/logging_darwin.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// A file log, next to the server's own logs. +// +// The launcher has no terminal — it is an LSUIElement app — so anything it +// writes to stderr is lost. That is fine for chatter and not fine for the +// detail behind a failure, which is exactly what someone needs when the menu +// says "could not start the server" and nothing else. +// +// It is also the sink for output that must NOT reach a dialog. `cix-server +// -reset-password` answers an unknown address by listing every account in the +// database (resetpassword.go: "existing users: …"). That is a reasonable thing +// to print for an operator who already holds the DB file; putting it in a GUI +// alert would turn a typo into an account enumeration anyone standing behind +// the user can read. + +var ( + logMu sync.Mutex + logFile *os.File +) + +func launcherLogPath() (string, error) { + dir, err := logDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "launcher.log"), nil +} + +// logf appends a timestamped line. Best effort: a launcher that cannot write +// its log still has a job to do, so failures here are silent by design. +func logf(format string, args ...any) { + logMu.Lock() + defer logMu.Unlock() + + if logFile == nil { + path, err := launcherLogPath() + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return + } + logFile = f + } + + fmt.Fprintf(logFile, "%s %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...)) +} diff --git a/cli/launcher/main_darwin.go b/cli/launcher/main_darwin.go new file mode 100644 index 00000000..d8b2c880 --- /dev/null +++ b/cli/launcher/main_darwin.go @@ -0,0 +1,188 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// cix-launcher — the executable behind cix.app. +// +// A menu bar item showing whether the local cix server is running and what its +// embedding provider is doing, with start/stop and a dashboard link. It is an +// LSUIElement app: no Dock icon, no windows, no application menu. +func main() { + showVersion := flag.Bool("v", false, "print version and exit") + report := flag.Bool("report", false, "print a bundle report to stdout and exit, instead of showing the menu") + flag.Parse() + + if *showVersion { + fmt.Printf("cix-launcher %s\n", version) + return + } + + b, err := locateBundle() + if err != nil { + // Running outside a .app is a developer scenario (`go run ./launcher`), + // not a user-facing error — there is no bundle to manage, so say so on + // the terminal that is definitely attached and stop. + fmt.Fprintf(os.Stderr, "cix-launcher %s: %v\n", version, err) + os.Exit(1) + } + + if icon := filepath.Join(b.Resources, "cix.icns"); fileExists(icon) { + dialogIcon = icon + } + + // -report is the scriptable path: CI verifies a freshly built bundle with + // it, and it never opens a window. + if *report { + fmt.Println(bundleReport(b)) + return + } + + // One updater for the process: it owns the cached ETag and the release + // client, and both the runtime installer and the menu's update check need + // them. Two of these would mean two ETags racing over one preferences file. + u := newUpdater(b) + + if isTranslocated(b) { + // Gatekeeper is running the app from a randomised read-only copy, which + // it does to any quarantined app opened from outside /Applications. + // Everything appears to work until the copy is reaped, taking the + // launchd job's target with it — so refuse rather than half-work. + _ = alert("Move cix to Applications", + "macOS is running cix from a temporary copy, so it cannot manage a server.\n\n"+ + "Move cix.app to your Applications folder and open it from there.") + os.Exit(1) + } + + stripQuarantine(b) + + // Order matters here. A machine that already runs cix from a checkout has a + // launchd agent under our label and a server holding our port, so the + // first-run wizard must never get a look at it — it would set up a second + // server that cannot bind, against a second, empty database. + switch { + case foreignAgent(): + // Asks once, remembers the answer, and defaults to leaving it alone. + // When the user declines, the app stays in observe-only mode: status + // and the dashboard work, Start/Stop do not. + // + // No runtime is installed on this path. Observing somebody else's server + // needs no binaries of our own, and downloading 90 MB to watch an + // install the user asked us not to touch would be presumptuous. The one + // feature that does need it — password reset — offers the download when + // it is used. + handleForeignAgent(u) + + case needsFirstRun(): + if err := runFirstRun(u); err != nil { + if errors.Is(err, errCancelled) { + // Setup is resumable: the app stays in the menu bar with Start + // disabled, and the next launch offers the wizard again. + _ = alert("Setup cancelled", + "cix has not been set up yet, so the server cannot start.\n\n"+ + "Quit and reopen cix when you want to finish setting it up.") + } else { + logf("first-run setup failed: %v", err) + _ = alert("Setup failed", fmt.Sprintf("cix could not complete first-time setup.\n\n%v", err)) + } + } + + default: + // A configured install with no runtime is the first launch after + // upgrading from a version that carried its server inside the bundle: + // the old app is gone, and with it the binary the launchd wrapper was + // pointing at. Say so before spending a minute on a download, because + // otherwise this is a menu bar app that appears to do nothing at all. + // + // Not said when a local tarball is supplied: there is no download to + // warn about, and this is the path a developer takes on every build. + if !runtimeReady() && os.Getenv("CIX_RUNTIME_TARBALL") == "" { + _ = alert("cix needs to finish updating", + "cix now keeps its server outside the application, so it updates without restarting.\n\n"+ + "It will download that part now — around 40 MB, once. The menu bar icon appears when it is done.") + } + if err := ensureRuntime(u, logProgress); err != nil { + logf("could not install the runtime: %v", err) + _ = alert("cix could not install its server", + fmt.Sprintf("%v\n\nThe menu bar app still works, but the server cannot start until this succeeds.", err)) + break + } + // Only after the runtime exists: pointing the launchd wrapper at a + // binary that is not there would break a working install rather than + // leave it alone. + // + // Nothing is started here. An app update no longer stops the server — + // the bundle holds none of what it runs — so there is no interrupted + // state to resume, and starting a server the user had deliberately + // stopped would be the app overriding them. + if err := writeLaunchdFiles(autostartEnabled()); err != nil { + logf("could not refresh launchd files: %v", err) + } + } + + runMenu(b, u) +} + +// logProgress is the progress sink for work that happens before the menu bar +// item exists. There is nowhere to show it, so it goes in the log — which is +// where anyone investigating a slow first launch will look. +func logProgress(msg string) { logf("%s", msg) } + +// stripQuarantine clears com.apple.quarantine from the whole bundle, once. +// +// Not cosmetic: the nested llama-server inherits the quarantine flag from the +// disk image, and macOS then SIGKILLs it on exec with EMPTY STDERR. From the +// supervisor's side that is indistinguishable from a crash, which is precisely +// the failure server/Makefile documents for stale signatures. Best-effort — on +// a read-only volume there is nothing to do and nothing to report. +func stripQuarantine(b bundle) { + _ = exec.Command("xattr", "-dr", "com.apple.quarantine", b.Root).Run() +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// bundleReport prints what this installation actually consists of: the app, and +// whichever runtime it is pointing at. +// +// The runtime half asks the binaries for their own versions rather than +// trusting runtime.json, because the difference is the point — it catches a +// runtime assembled from stale dist/ artefacts, which is the failure this +// pipeline is most likely to produce. CI runs this against a freshly built app, +// where the answer is "no runtime", and that is correct: the two halves of a +// release are built separately and only meet on a user's machine. +func bundleReport(b bundle) string { + var sb strings.Builder + + fmt.Fprintf(&sb, "Launcher: cix-launcher %s\n", version) + fmt.Fprintf(&sb, "Bundle: %s\n", b.Root) + + if !runtimeReady() { + sb.WriteString("Runtime: not installed\n") + return sb.String() + } + + dir, _ := runtimeCurrentDir() + fmt.Fprintf(&sb, "Runtime: %s (%s)\n", currentRuntimeVersion(), dir) + fmt.Fprintf(&sb, "Server: %s\n", binaryVersion(filepath.Join(dir, "cix-server"), "-v")) + fmt.Fprintf(&sb, "CLI: %s\n", binaryVersion(filepath.Join(dir, "cix"), "--version")) + + llama := filepath.Join(dir, "llama") + if _, err := os.Stat(llama); err == nil { + info, _ := readRuntimeInfo() + fmt.Fprintf(&sb, "Embeddings: llama-server %s (Metal)\n", info.LlamaVersion) + } else { + fmt.Fprintf(&sb, "Embeddings: MISSING — %s not found\n", llama) + } + + return sb.String() +} diff --git a/cli/launcher/main_other.go b/cli/launcher/main_other.go new file mode 100644 index 00000000..53bae654 --- /dev/null +++ b/cli/launcher/main_other.go @@ -0,0 +1,19 @@ +//go:build !darwin + +package main + +import ( + "fmt" + "os" +) + +// cix-launcher wraps macOS-specific plumbing (menu bar, launchd, osascript, +// codesign) and has no meaning on other platforms. It still has to *compile* +// everywhere: ci-cli.yml runs `go build ./...` and `go vet ./...` on +// ubuntu-latest, and a package with no buildable files there fails with +// "build constraints exclude all Go files" — which reads like a broken repo +// rather than a deliberate platform restriction. +func main() { + fmt.Fprintf(os.Stderr, "cix-launcher %s: macOS only — use `cix` and `cix-server` directly on this platform.\n", displayVersion()) + os.Exit(1) +} diff --git a/cli/launcher/menu_darwin.go b/cli/launcher/menu_darwin.go new file mode 100644 index 00000000..4ab0df14 --- /dev/null +++ b/cli/launcher/menu_darwin.go @@ -0,0 +1,466 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "fyne.io/systray" +) + +// The menu bar UI. +// +// systray.Run takes over the calling goroutine and must own the main one — on +// macOS the status item lives on the AppKit main thread. Everything else here +// runs in goroutines and only touches systray through its setters, which are +// safe to call from anywhere. +// +// Layout follows the platform rather than inventing one: disabled rows carrying +// state with a coloured indicator, then actions, then a checkbox for the one +// setting, then Quit. Every row is width-capped (see maxRowRunes) because an +// NSMenu is as wide as its widest row. + +type menu struct { + bundle bundle + poll *poller + stop chan struct{} + + statusItem *systray.MenuItem + embeddingsItem *systray.MenuItem + modelItem *systray.MenuItem + startStopItem *systray.MenuItem + dashboardItem *systray.MenuItem + autostartItem *systray.MenuItem + networkItem *systray.MenuItem + resetPWItem *systray.MenuItem + updateItem *systray.MenuItem + + updater *updater + + // detail holds the submenu rows under the server row. They are created + // once and retitled on every render: systray has no way to remove an item, + // so rebuilding the submenu per update would grow it without bound. + detail [detailRows]*systray.MenuItem +} + +// detailRows is the fixed number of submenu slots. Rows with nothing to say are +// hidden rather than left blank. +const detailRows = 7 + +func runMenu(b bundle, u *updater) { + m := &menu{bundle: b, poll: newPoller(), stop: make(chan struct{}), updater: u} + systray.Run(m.onReady, m.onExit) +} + +// setProgress puts a word next to the menu bar icon while something slow is +// happening, and clears it when passed "". +// +// The alternative was a modal dialog, and it is the wrong shape: a runtime +// download takes tens of seconds, during which a menu bar app that shows nothing +// reads as hung and one that blocks with an alert cannot be dismissed. AppKit +// draws this text beside the template icon, so it is visible without being in +// the way. +func (m *menu) setProgress(msg string) { + systray.SetTitle(msg) + if msg != "" { + logf("%s", msg) + } +} + +func (m *menu) onReady() { + if icon, err := os.ReadFile(filepath.Join(m.bundle.Resources, "cixTemplate@2x.png")); err == nil { + // SetTemplateIcon, not SetIcon: macOS recolours a template image for + // dark mode, for a tinted menu bar and for the pressed state, using + // only its alpha channel. A coloured icon here is a smudge at 18 px and + // unreadable in dark mode. + systray.SetTemplateIcon(icon, icon) + } else { + systray.SetTitle("cix") + } + // No tooltips anywhere, including on the status item itself. AppKit's are + // not usable here: once any one of them has appeared, every subsequent one + // shows with no delay at all, and they are positioned against the element + // rather than the pointer. Neither has an API — changing either means + // giving every row a custom NSView with its own tracking area, i.e. writing + // the menu in Objective-C instead of using systray. Everything a tooltip + // would have said is in the details submenu, where the timing and placement + // are the system's own and correct. + // + // The empty second argument to AddMenuItem is that tooltip. Leave it empty. + + // The server row is the one enabled row in the status group, because it + // carries the details submenu — a parent has to be enabled for macOS to + // open its submenu. The disclosure arrow reads as "there is more here" + // rather than "this does something", which is what it is. + m.statusItem = systray.AddMenuItem("cix-server: …", "") + for i := range m.detail { + m.detail[i] = m.statusItem.AddSubMenuItem("", "") + m.detail[i].Disable() + } + + m.embeddingsItem = systray.AddMenuItem("Embeddings: …", "") + m.embeddingsItem.Disable() + m.modelItem = systray.AddMenuItem("", "") + m.modelItem.Disable() + m.modelItem.Hide() + + systray.AddSeparator() + m.startStopItem = systray.AddMenuItem("Start Server", "") + m.dashboardItem = systray.AddMenuItem("Open Dashboard", "") + + systray.AddSeparator() + m.autostartItem = systray.AddMenuItemCheckbox("Start at Login", "", false) + m.networkItem = systray.AddMenuItemCheckbox("Allow Network Access", "", false) + m.resetPWItem = systray.AddMenuItem("Reset Password…", "") + + systray.AddSeparator() + m.updateItem = systray.AddMenuItem("Check for Updates…", "") + + systray.AddSeparator() + // Spelled out rather than left to a tooltip. Quit here closes the menu bar + // app and leaves the launchd agent running, which is the opposite of what + // Quit means in most menu bar apps — a surprise worth 22 characters. + quitItem := systray.AddMenuItem("Quit (server keeps running)", "") + + go m.poll.run(m.stop) + go m.watch() + + // A quiet background check. It only speaks up when there is something to + // offer, and it is throttled and ETag-cached, so an app left open all day + // costs a handful of 304s. + go m.checkForUpdates(false) + + go func() { + for { + select { + case <-m.startStopItem.ClickedCh: + go m.toggleServer() + case <-m.dashboardItem.ClickedCh: + go m.openDashboard() + case <-m.autostartItem.ClickedCh: + go m.toggleAutostart() + case <-m.networkItem.ClickedCh: + go m.toggleNetworkAccess() + case <-m.resetPWItem.ClickedCh: + go m.resetPasswordFlow() + case <-m.updateItem.ClickedCh: + go m.checkForUpdates(true) + case <-quitItem.ClickedCh: + systray.Quit() + return + } + } + }() +} + +func (m *menu) onExit() { + close(m.stop) +} + +// watch redraws the menu whenever the poller reports a change. +func (m *menu) watch() { + for { + select { + case <-m.stop: + return + case <-m.poll.changed: + m.render(m.poll.snapshotNow()) + } + } +} + +func (m *menu) render(s snapshot) { + m.statusItem.SetTitle(s.ServerLine()) + m.statusItem.SetIcon(dotPNG(s.ServerDot())) + m.renderDetail(s) + + m.embeddingsItem.SetTitle(s.EmbeddingsLine()) + m.embeddingsItem.SetIcon(dotPNG(s.EmbeddingsDot())) + + if line := s.ModelLine(); line != "" { + m.modelItem.SetTitle(line) + // A transparent spacer, not a missing icon: AppKit indents a title by + // its image width, so without one this row would start left of the two + // above it and the group would read as ragged. + m.modelItem.SetIcon(dotPNG(dotBlank)) + m.modelItem.Show() + } else { + m.modelItem.Hide() + } + + switch { + case !s.Managed: + // Another installation owns the launchd label. Showing an enabled + // Start button that would fight it — or worse, silently repoint it — is + // the wrong behaviour; observing is useful, interfering is not. + m.startStopItem.SetTitle("Start Server") + m.startStopItem.Disable() + case s.State == stateRunning: + m.startStopItem.SetTitle("Stop Server") + m.startStopItem.Enable() + case s.State == stateStarting: + m.startStopItem.SetTitle("Starting…") + m.startStopItem.Disable() + default: + m.startStopItem.SetTitle("Start Server") + m.startStopItem.Enable() + } + + if s.State == stateRunning { + m.dashboardItem.Enable() + } else { + m.dashboardItem.Disable() + } + + if s.LocalOnly { + m.networkItem.Uncheck() + } else { + m.networkItem.Check() + } + if s.Autostart { + m.autostartItem.Check() + } else { + m.autostartItem.Uncheck() + } + + if s.Managed { + m.networkItem.Enable() + m.autostartItem.Enable() + m.resetPWItem.Enable() + } else { + // These live in files this app does not own — except the password + // reset, which only needs the database and is therefore still useful + // against an install-server.sh deployment. + m.networkItem.Disable() + m.autostartItem.Disable() + m.resetPWItem.Enable() + } +} + +// toggleAutostart flips RunAtLoad on the launchd agent. +// +// launchd reads a plist only when the job is bootstrapped, so the change needs +// a full unload/load cycle — which stops a running server. Rather than leave it +// down, or pretend the cost is zero, this restores whatever state the server +// was in. +func (m *menu) toggleAutostart() { + s := m.poll.snapshotNow() + if !s.Managed { + return + } + enable := !s.Autostart + + if err := setAutostart(enable); err != nil { + _ = alert("cix", fmt.Sprintf("Could not change the start-at-login setting.\n\n%v", err)) + m.render(m.poll.snapshotNow()) + return + } + + // bootstrap does not start the job unless RunAtLoad fired, so a server that + // was running before the reload has to be put back. + if s.State == stateRunning { + if err := startServer(); err != nil { + _ = alert("cix", fmt.Sprintf("The setting was changed, but the server could not be restarted.\n\n%v", err)) + } + } + m.poll.refresh() +} + +// checkForUpdates looks for a newer release and, if the user agrees, installs +// it. `explicit` distinguishes the menu item from the background check: a +// background check that finds nothing says nothing. +func (m *menu) checkForUpdates(explicit bool) { + av := m.updater.check(explicit) + if !av.any() { + if explicit { + _ = alert("cix is up to date", fmt.Sprintf( + "You are running cix %s with %s.", displayVersion(), strings.ToLower(runtimeSummary()))) + } + return + } + + // The app and the server are separate releases and feel completely + // different to update: the server restarts and the app never goes away, the + // app closes and reopens while the server keeps running. Naming which one + // is happening is the difference between an expected restart and an + // unexplained one. + var what, effect string + switch { + case av.App.Version == "": + what = fmt.Sprintf("cix server %s is available. You are running %s.", av.Runtime.Version, currentRuntimeVersion()) + effect = "The server will be updated and restarted. This app stays open, and if the new server does not start, cix goes back to the current one." + case av.Runtime.Version == "": + what = fmt.Sprintf("cix %s is available. You are running %s.", av.App.Version, displayVersion()) + effect = "cix will close and reopen. The server keeps running throughout." + default: + what = fmt.Sprintf("cix %s and cix server %s are available.", av.App.Version, av.Runtime.Version) + effect = "The server will be updated and restarted, then cix will close and reopen." + } + + ok, err := ask("An update is available", + fmt.Sprintf("%s\n\nEverything is downloaded and checked before anything is replaced. %s", what, effect), + "Update Now", "Not Now") + if err != nil || !ok { + return + } + + // "Is there a process", not "is it answering". A server still loading its + // embedding model has a pid and no /health yet; treating that as stopped + // would skip the shutdown and swap the runtime under a live cix-server, + // leaving it with a llama sidecar from a different version. + wasRunning := m.poll.snapshotNow().PID != 0 + + quit, err := m.updater.install(av, wasRunning, m.setProgress) + if err != nil { + logf("update failed: %v", err) + _ = alert("Could not install the update", err.Error()) + m.poll.refresh() + return + } + if !quit { + // Server-only update: the app is not going anywhere, so it has to say + // the update finished. The launcher path says nothing here because it + // is about to disappear and reappear, which speaks for itself. + m.poll.refresh() + _ = alert("cix server updated", fmt.Sprintf("The cix server is now running %s.", av.Runtime.Version)) + return + } + + // From here the swap helper owns the outcome: it waits for this process to + // exit before moving anything, so quitting is the last required step. + systray.Quit() +} + +// renderDetail fills the submenu under the server row. Slots with nothing to +// say are hidden, so the submenu never shows an empty line. +func (m *menu) renderDetail(s snapshot) { + lines := [detailRows]string{ + s.DetailProcess(), + s.DetailPort(), + s.DetailNetwork(), + s.DetailModel(), + s.DetailVersion(), + // What is installed, as opposed to what the running server reports. The + // row above is empty whenever the server is not answering — which is + // exactly when someone wants to know which server is on disk. + runtimeSummary(), + s.DetailManaged(), + } + for i, line := range lines { + if line == "" { + m.detail[i].Hide() + continue + } + m.detail[i].SetTitle(line) + m.detail[i].Show() + } +} + +func (m *menu) toggleServer() { + s := m.poll.snapshotNow() + if !s.Managed { + return + } + + var err error + if s.State == stateRunning { + err = stopServer() + } else { + // Rewrite the wrapper before starting. It is generated, not edited, and + // something else may have replaced it — install-server.sh most obviously. + if err = writeLaunchdFiles(autostartEnabled()); err == nil { + err = startServer() + } + } + if err != nil { + verb := "start" + if s.State == stateRunning { + verb = "stop" + } + _ = alert("cix", fmt.Sprintf("Could not %s the server.\n\n%v", verb, err)) + } + m.poll.refresh() +} + +// toggleNetworkAccess switches CIX_BIND_ADDR between loopback and all +// interfaces, then restarts the server so the change takes effect. +// +// Widening access asks first. Nothing else in this menu changes what the +// machine exposes to the network, and a checkbox is an easy thing to hit by +// accident; narrowing access needs no confirmation because it can only be safe. +func (m *menu) toggleNetworkAccess() { + vars, err := readServerEnv() + if err != nil { + _ = alert("cix", "cix is not set up yet.") + return + } + + wasRunning := m.poll.snapshotNow().State == stateRunning + local := isLocalOnly(vars) + + if local { + ok, err := confirm("Allow access from your network?", + fmt.Sprintf("cix will accept connections from any machine that can reach this Mac on port %d, "+ + "instead of only from this Mac.\n\n"+ + "Accounts and API keys still apply — this does not disable authentication — but the login page "+ + "and the API become reachable from your network.\n\n"+ + "The server will restart.", serverPort(vars)), + "Allow") + if err != nil || !ok { + // Put the checkbox back: the click already toggled it visually. + m.render(m.poll.snapshotNow()) + return + } + vars["CIX_BIND_ADDR"] = bindAllInterfaces + } else { + vars["CIX_BIND_ADDR"] = bindLocalOnly + } + + if err := writeServerEnv(vars); err != nil { + _ = alert("cix", fmt.Sprintf("Could not save the setting.\n\n%v", err)) + m.render(m.poll.snapshotNow()) + return + } + + // The bind address is read once, when the process starts, so the setting + // means nothing until the server is restarted. Saying "saved" without doing + // that would be a lie the user only discovers when it does not work. + if wasRunning { + if err := restartServer(); err != nil { + _ = alert("cix", fmt.Sprintf("The setting was saved, but the server could not be restarted.\n\n%v", err)) + } + } + m.poll.refresh() +} + +// restartServer stops the server, waits for the process to actually go away, +// and starts it again. The wait matters: kickstarting while the old process +// still holds the listening socket produces a server that exits immediately +// with "address already in use". +func restartServer() error { + if err := stopServer(); err != nil { + return err + } + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + if launchdPID() == 0 { + break + } + time.Sleep(250 * time.Millisecond) + } + return startServer() +} + +func (m *menu) openDashboard() { + vars, err := readServerEnv() + if err != nil { + _ = alert("cix", "cix is not set up yet.") + return + } + if err := exec.Command("open", dashboardURL(vars)).Run(); err != nil { + _ = alert("cix", fmt.Sprintf("Could not open the dashboard.\n\n%v", err)) + } +} diff --git a/cli/launcher/prefs_darwin.go b/cli/launcher/prefs_darwin.go new file mode 100644 index 00000000..c308e891 --- /dev/null +++ b/cli/launcher/prefs_darwin.go @@ -0,0 +1,88 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// Launcher preferences: ~/.cix/launcher.json. +// +// Only decisions the user should not be asked twice about. Everything that +// configures the server lives in server.env, and everything about the launchd +// job lives in the plist; this file exists so the app can remember an answer, +// not so it can grow a second configuration system. + +type prefs struct { + // Takeover records what the user chose when an agent installed by + // install-server.sh was found under the same launchd label: "takeover" or + // "keep". Empty means the question has not been asked yet. + Takeover string `json:"takeover,omitempty"` + + // UpdateETag and RuntimeETag are GitHub's ETags from the last listing of the + // mac/v* and server/v* streams. Replaying one turns an unchanged check into + // a 304, which does not count against the unauthenticated hourly limit — so + // they are worth surviving a restart. Two streams, two ETags: the app and + // the server it manages are released separately. + UpdateETag string `json:"update_etag,omitempty"` + RuntimeETag string `json:"runtime_etag,omitempty"` +} + +// There used to be a third field here, restart_server_after_update, carrying one +// bit across a launcher swap: the process that stopped the server was replaced +// before the one that should start it again existed. Moving the runtime out of +// the bundle removed the reason for it — an app update no longer touches the +// server at all, and a runtime update restarts it within a single process. An +// old file that still carries the key is simply ignored. + +const ( + takeoverAdopt = "takeover" + takeoverKeep = "keep" +) + +func prefsPath() (string, error) { + dir, err := cixHome() + if err != nil { + return "", err + } + return filepath.Join(dir, "launcher.json"), nil +} + +// loadPrefs returns the stored preferences, or zero values when the file is +// absent or unreadable. A corrupt prefs file must not stop the app starting: +// the worst case is being asked a question again. +func loadPrefs() prefs { + path, err := prefsPath() + if err != nil { + return prefs{} + } + data, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + logf("could not read %s: %v", path, err) + } + return prefs{} + } + var p prefs + if err := json.Unmarshal(data, &p); err != nil { + logf("could not parse %s: %v — treating as empty", path, err) + return prefs{} + } + return p +} + +func savePrefs(p prefs) error { + path, err := prefsPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o600) +} diff --git a/cli/launcher/resetpw_darwin.go b/cli/launcher/resetpw_darwin.go new file mode 100644 index 00000000..df004120 --- /dev/null +++ b/cli/launcher/resetpw_darwin.go @@ -0,0 +1,160 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// Password recovery from the menu. +// +// This drives `cix-server -reset-password `, which opens the SQLite +// database directly — no HTTP, no auth — so possession of the database file is +// the authorization boundary. The server does NOT need to be stopped for it. + +// resetPasswordFlow prompts for an address, runs the reset, and shows the +// generated password. +func (m *menu) resetPasswordFlow() { + vars, err := readServerEnv() + if err != nil { + _ = alert("cix", "cix is not set up yet.") + return + } + + email, err := prompt("Reset password", + "Enter the email address of the account to reset.\n\n"+ + "A new temporary password will be generated. Any active sessions for the "+ + "account are signed out, and the next login forces a password change.", + vars["CIX_BOOTSTRAP_ADMIN_EMAIL"]) + if err != nil { + return // cancelled, or the dialog failed and already reported + } + email = strings.TrimSpace(email) + if email == "" { + return + } + + // The reset runs cix-server against the database directly, so it needs the + // runtime. In observe-only mode — where another installation owns the agent + // and we never installed one — this is the only feature that does, so the + // download is offered here rather than forced at startup. + if !runtimeReady() { + ok, err := confirm("Download the cix server?", + "Resetting a password runs cix-server against the database directly, and it is not "+ + "installed on this Mac yet.\n\nDownloading it is about 40 MB and changes nothing "+ + "about the installation you already have.", + "Download") + if err != nil || !ok { + return + } + if err := ensureRuntime(m.updater, m.setProgress); err != nil { + _ = alert("Could not install the cix server", err.Error()) + return + } + m.setProgress("") + } + + server, err := runtimeServerPath() + if err != nil { + _ = alert("cix", fmt.Sprintf("Could not locate the cix server.\n\n%v", err)) + return + } + + password, err := runResetPassword(server, vars, email) + if err != nil { + _ = alert("Could not reset the password", err.Error()) + return + } + + _ = alert("Password reset", fmt.Sprintf( + "Account:\n%s\n\nTemporary password:\n%s\n\n"+ + "You will be asked to change it at the next sign-in. Other sessions for this "+ + "account have been signed out.", email, password)) +} + +// runResetPassword executes the reset and returns the generated password. +// +// The returned error is safe to display. The command's own error output is not: +// on an unknown address it lists every account in the database +// (resetpassword.go, "existing users: …"). That is defensible for an operator +// at a terminal who already holds the DB file, and indefensible in a GUI alert, +// where a typo would enumerate accounts to whoever is looking at the screen. So +// the full output goes to the log and the dialog gets one sentence. +func runResetPassword(server string, vars map[string]string, email string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, server, "-reset-password", email) + + // The subprocess reads config from the environment, so it must see the same + // database the server uses. Without these it would fall back to the default + // path and refuse — or worse, on an older build, act on the wrong database. + cmd.Env = append(os.Environ(), + "CIX_SQLITE_PATH="+vars["CIX_SQLITE_PATH"], + "CIX_DATA_DIR="+vars["CIX_DATA_DIR"], + ) + + // Empty stdin, not the terminal's: the command reads a password from a pipe + // when one is attached and generates one otherwise. /dev/null reads as an + // empty first line, which it treats as "generate" — the branch we want, and + // the only one that gives us a password to show. + devNull, err := os.Open(os.DevNull) + if err != nil { + return "", fmt.Errorf("open %s: %w", os.DevNull, err) + } + defer devNull.Close() + cmd.Stdin = devNull + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + logf("reset-password for %q failed: %v; stderr: %s", email, err, strings.TrimSpace(stderr.String())) + if ctx.Err() != nil { + return "", fmt.Errorf("the reset command did not finish in time") + } + // Matched against the server's wording, and deliberately answered with + // less than it said. + if strings.Contains(stderr.String(), "no user with email") { + return "", fmt.Errorf("No account with that email address.") + } + if strings.Contains(stderr.String(), "database not found") { + return "", fmt.Errorf("The database could not be found. Check CIX_SQLITE_PATH in ~/.cix/server.env.") + } + return "", fmt.Errorf("The password could not be reset. See ~/.cix/logs/launcher.log for details.") + } + + password, ok := parseTemporaryPassword(stdout.String()) + if !ok { + // The reset itself succeeded — the command exited zero — so saying it + // failed would be wrong and would invite a second, pointless attempt. + logf("reset-password for %q succeeded but no password line was found in: %s", email, stdout.String()) + return "", fmt.Errorf("The password was reset, but cix could not read the new password from the output. " + + "See ~/.cix/logs/launcher.log.") + } + logf("reset-password for %q succeeded", email) + return password, nil +} + +// parseTemporaryPassword pulls the password out of the command's stdout. +// +// The contract is one line, "Temporary password: ", printed only when +// the password was generated rather than piped in (resetpassword.go). Matching +// the prefix rather than a line index keeps this working when the surrounding +// lines change — the DISABLED-account warning, for one, is conditional. +func parseTemporaryPassword(stdout string) (string, bool) { + const marker = "Temporary password: " + for line := range strings.SplitSeq(stdout, "\n") { + if rest, ok := strings.CutPrefix(strings.TrimSpace(line), marker); ok { + if pw := strings.TrimSpace(rest); pw != "" { + return pw, true + } + } + } + return "", false +} diff --git a/cli/launcher/runtime_darwin.go b/cli/launcher/runtime_darwin.go new file mode 100644 index 00000000..924656ad --- /dev/null +++ b/cli/launcher/runtime_darwin.go @@ -0,0 +1,568 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/dvcdsys/code-index/cli/internal/release" +) + +// The cix runtime: cix-server, the cix CLI, and the Metal llama-server they +// depend on. It is not inside the .app. +// +// Layout +// ------ +// +// ~/.cix/runtime/ +// 0.12.8/ cix-server cix llama/… runtime.json +// 0.12.7/ the version this one replaced, kept for rollback +// current -> 0.12.8 +// +// Versioned by the SERVER, from the server/v* tag stream — the same tag and the +// same workflow run that publishes the Docker images. A Mac install on 0.12.8 +// and a container on 0.12.8 are the same server. The app is versioned +// separately, on mac/v*, because it changes for different reasons; the two +// update independently and neither waits for the other. +// +// Why outside the bundle +// ---------------------- +// The runtime is 90% of the weight and nearly all of the churn. With it inside, +// every server update replaced a 102 MB application through a trampoline that +// had to quit the launcher, move the bundle aside, move the new one in, and +// reopen. Out here, an update is a download and a rename, with the app still +// running. +// +// Why a symlink rather than a fixed directory +// ------------------------------------------- +// Renaming a symlink over another is atomic, so there is no window in which +// `current` points at half a runtime — and it is reversible in microseconds, +// which is what makes the automatic rollback below possible without a second +// download. The launchd wrapper execs the `current` path, so a swap needs no +// plist rewrite and no bootout/bootstrap cycle either. +// +// The symlink target is relative (`0.5.0`, not an absolute path) so the tree +// survives being moved with the home directory. + +const ( + runtimeLinkName = "current" + runtimeManifestName = "runtime.json" + + // The directory inside the tarball, and the asset's name. Both are set by + // mac/scripts/build-runtime.sh; they are a format, not a convention. + runtimeDirPrefix = "cix-runtime-" + runtimeAssetSuffix = "-darwin-arm64.tar.gz" +) + +// runtimeInfo is runtime.json, written by build-runtime.sh. +// +// Reading a manifest rather than exec'ing each binary with -v is what lets the +// menu show what is installed without three process spawns per open — and it is +// the only place the llama version is recorded at all. There is no separate +// runtime version: the runtime IS the server, so the directory it lives in is +// named for ServerVersion. +type runtimeInfo struct { + ServerVersion string `json:"server_version"` + CLIVersion string `json:"cli_version"` + LlamaVersion string `json:"llama_version"` + Platform string `json:"platform"` +} + +func runtimeRoot() (string, error) { + dir, err := cixHome() + if err != nil { + return "", err + } + return filepath.Join(dir, "runtime"), nil +} + +// runtimeCurrentDir returns the symlink path itself, deliberately unresolved. +// +// Everything that has to survive a version swap — the launchd wrapper, the +// /usr/local/bin/cix symlink — must point here rather than at a versioned +// directory, or it would pin itself to the version that happened to be current +// when it was written. +func runtimeCurrentDir() (string, error) { + root, err := runtimeRoot() + if err != nil { + return "", err + } + return filepath.Join(root, runtimeLinkName), nil +} + +func runtimeServerPath() (string, error) { + dir, err := runtimeCurrentDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "cix-server"), nil +} + +func runtimeCLIPath() (string, error) { + dir, err := runtimeCurrentDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "cix"), nil +} + +func runtimeVersionDir(version string) (string, error) { + root, err := runtimeRoot() + if err != nil { + return "", err + } + return filepath.Join(root, version), nil +} + +// currentRuntimeVersion reads the symlink, returning "" when nothing is +// installed. It reads the link rather than the manifest so that a runtime whose +// manifest is missing or unreadable is still identifiable. +func currentRuntimeVersion() string { + link, err := runtimeCurrentDir() + if err != nil { + return "" + } + target, err := os.Readlink(link) + if err != nil { + return "" + } + return filepath.Base(target) +} + +// runtimeReady reports whether the current runtime is usable — the symlink +// resolves and the server binary is actually there. Both halves matter: a +// symlink pointing at a pruned directory reads as installed until something +// tries to exec it. +func runtimeReady() bool { + server, err := runtimeServerPath() + if err != nil { + return false + } + info, err := os.Stat(server) + return err == nil && !info.IsDir() +} + +// readRuntimeInfo loads the manifest of the current runtime. +func readRuntimeInfo() (runtimeInfo, error) { + dir, err := runtimeCurrentDir() + if err != nil { + return runtimeInfo{}, err + } + data, err := os.ReadFile(filepath.Join(dir, runtimeManifestName)) + if err != nil { + return runtimeInfo{}, err + } + var info runtimeInfo + if err := json.Unmarshal(data, &info); err != nil { + return runtimeInfo{}, err + } + return info, nil +} + +// runtimeAssetName is the tarball attached to server release `version`. +func runtimeAssetName(version string) string { + return runtimeDirPrefix + version + runtimeAssetSuffix +} + +// runtimeVersionFromTarball recovers the version from an asset filename — +// cix-runtime-0.12.8-darwin-arm64.tar.gz → 0.12.8. Used only for the local +// CIX_RUNTIME_TARBALL path; a downloaded release carries its version in the +// release itself. +func runtimeVersionFromTarball(path string) string { + name := filepath.Base(path) + name = strings.TrimSuffix(name, runtimeAssetSuffix) + if v, ok := strings.CutPrefix(name, runtimeDirPrefix); ok && v != "" { + return v + } + return "local" +} + +// installRuntime downloads, verifies and unpacks the runtime for a release, +// leaving it staged in its versioned directory. It does NOT touch `current` — +// activateRuntime does that, separately, so that everything fallible happens +// while the running system is still untouched. +func installRuntime(rel release.Release, progress func(string)) error { + dest, err := runtimeVersionDir(rel.Version) + if err != nil { + return err + } + + asset, ok := rel.AssetByName(runtimeAssetName(rel.Version)) + if !ok { + // Fall back to matching by shape. A release whose tarball was named + // from a differently-formatted version string is still installable, and + // failing on the filename would be pedantry. + asset, ok = rel.AssetBySuffix(runtimeAssetSuffix) + } + if !ok { + return fmt.Errorf("release %s has no runtime attached", rel.TagName) + } + sums, ok := rel.AssetByName("checksums.txt") + if !ok { + return fmt.Errorf("release %s has no checksums.txt attached", rel.TagName) + } + + cacheDir, err := updatesCacheDir() + if err != nil { + return err + } + if err := os.MkdirAll(cacheDir, 0o700); err != nil { + return err + } + defer os.RemoveAll(cacheDir) + + // GitHub reports the asset size, but a release built by hand or served by a + // test harness may not. "(0 MB)" would be worse than saying nothing. + if asset.Size > 0 { + progress(fmt.Sprintf("Downloading the cix runtime (%d MB)…", (asset.Size+(1<<19))>>20)) + } else { + progress("Downloading the cix runtime…") + } + + tarball := filepath.Join(cacheDir, asset.Name) + if err := download(asset.URL, tarball); err != nil { + return fmt.Errorf("could not download the runtime: %w", err) + } + sumsPath := filepath.Join(cacheDir, "checksums.txt") + if err := download(sums.URL, sumsPath); err != nil { + return fmt.Errorf("could not download the checksums: %w", err) + } + if err := verifyChecksum(tarball, sumsPath, asset.Name); err != nil { + logf("checksum verification failed for %s: %v", asset.Name, err) + return fmt.Errorf("the downloaded runtime failed its checksum check and was discarded.\n\n%v", err) + } + + progress("Installing the cix runtime…") + return unpackRuntime(tarball, dest) +} + +// unpackRuntime extracts a runtime tarball into dest, then proves the result is +// runnable before letting anything point at it. +// +// Extraction goes to a sibling temp directory and is renamed into place, so an +// interrupted install can never leave a half-populated version directory that +// looks complete to currentRuntimeVersion. +func unpackRuntime(tarball, dest string) error { + root := filepath.Dir(dest) + if err := os.MkdirAll(root, 0o755); err != nil { + return err + } + + staging, err := os.MkdirTemp(root, ".staging-") + if err != nil { + return err + } + defer os.RemoveAll(staging) + + if err := extractTarGz(tarball, staging); err != nil { + return fmt.Errorf("could not unpack the runtime: %w", err) + } + + // The archive carries one top-level directory. Descend into it rather than + // trusting its name: the version in the filename and the version in the + // release tag have been the same so far, but nothing enforces it. + entries, err := os.ReadDir(staging) + if err != nil { + return err + } + tree := staging + if len(entries) == 1 && entries[0].IsDir() && strings.HasPrefix(entries[0].Name(), runtimeDirPrefix) { + tree = filepath.Join(staging, entries[0].Name()) + } + + // Quarantine is not applied to files this process downloads over HTTP, but + // it costs nothing to be certain: a quarantined llama-server is SIGKILLed on + // exec with EMPTY STDERR, which is indistinguishable from a crash and has + // already cost this project a debugging session (see server/Makefile). + _ = exec.Command("xattr", "-cr", tree).Run() + + if err := verifyRuntimeTree(tree); err != nil { + return err + } + + // Replace atomically-ish: the old directory is moved aside, the new one + // renamed in, and only then is the old one deleted. A reinstall of the + // version currently in use is the case this protects. + old := "" + if _, err := os.Stat(dest); err == nil { + old = dest + ".replaced" + os.RemoveAll(old) + if err := os.Rename(dest, old); err != nil { + return err + } + } + if err := os.Rename(tree, dest); err != nil { + if old != "" { + os.Rename(old, dest) + } + return err + } + if old != "" { + os.RemoveAll(old) + } + return nil +} + +// verifyRuntimeTree checks an unpacked runtime the way a stranger would. +// +// codesign --verify says the signature is well-formed; only exec proves the +// kernel agrees, and that is the check that matters, because the failure it +// catches — an ad-hoc signature the kernel rejects — kills the process with no +// output at all. +func verifyRuntimeTree(tree string) error { + for _, rel := range []string{"cix-server", "cix", filepath.Join("llama", "llama-server")} { + path := filepath.Join(tree, rel) + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("the runtime is incomplete: %s is missing", rel) + } + if out, err := exec.Command("codesign", "--verify", "--strict", path).CombinedOutput(); err != nil { + return fmt.Errorf("%s failed signature verification and was discarded: %s", + rel, strings.TrimSpace(string(out))) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, filepath.Join(tree, "cix-server"), "-v").CombinedOutput() + if err != nil { + return fmt.Errorf("the downloaded server does not run on this Mac: %v %s", err, strings.TrimSpace(string(out))) + } + logf("runtime staged at %s reports: %s", tree, strings.TrimSpace(string(out))) + return nil +} + +// extractTarGz unpacks a gzipped tar into dir. +// +// Written against archive/tar rather than shelling out to /usr/bin/tar for one +// reason that matters: path containment. A tar member named ../../something is +// a valid archive and a directory traversal, and the check belongs where the +// paths are joined. +func extractTarGz(path, dir string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + + target, err := safeJoin(dir, hdr.Name) + if err != nil { + return err + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + // The execute bit is the payload here — cix-server, cix and + // llama-server all arrive as 0755 and are useless without it. + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode).Perm()) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + case tar.TypeSymlink: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + // Containment applies to what the link points at as well: a symlink + // escaping the tree would let a later member be written outside it. + if _, err := safeJoin(dir, filepath.Join(filepath.Dir(hdr.Name), hdr.Linkname)); err != nil { + return fmt.Errorf("archive symlink escapes the destination: %s -> %s", hdr.Name, hdr.Linkname) + } + os.Remove(target) + if err := os.Symlink(hdr.Linkname, target); err != nil { + return err + } + default: + // Device nodes, fifos, hard links: nothing this archive should + // contain, so refuse rather than silently skip. + return fmt.Errorf("unexpected entry type %q in the runtime archive: %s", hdr.Typeflag, hdr.Name) + } + } +} + +// safeJoin resolves name under dir and refuses anything that would land outside +// it — the tar equivalent of zip-slip. +// +// An absolute member name is refused rather than quietly re-rooted the way tar +// does it. filepath.Join would swallow the leading slash and produce a path +// inside dir, so this is not a containment hole; it is an archive that is not +// the one build-runtime.sh writes, and reading it as if it were is how a real +// difference gets ignored. +func safeJoin(dir, name string) (string, error) { + if filepath.IsAbs(name) { + return "", fmt.Errorf("archive entry has an absolute path: %s", name) + } + clean := filepath.Clean(filepath.Join(dir, name)) + if clean != dir && !strings.HasPrefix(clean, dir+string(os.PathSeparator)) { + return "", fmt.Errorf("archive entry escapes the destination: %s", name) + } + return clean, nil +} + +// activateRuntime points `current` at a version and returns the version it +// replaced, so the caller can put it back. +// +// The rename is what makes this safe: rename(2) over an existing symlink is +// atomic, so there is no instant at which `current` is missing or dangling. +func activateRuntime(version string) (previous string, err error) { + root, err := runtimeRoot() + if err != nil { + return "", err + } + dir := filepath.Join(root, version) + if _, err := os.Stat(dir); err != nil { + return "", fmt.Errorf("runtime %s is not installed", version) + } + + previous = currentRuntimeVersion() + + tmp := filepath.Join(root, ".current.new") + os.Remove(tmp) + if err := os.Symlink(version, tmp); err != nil { + return previous, err + } + if err := os.Rename(tmp, filepath.Join(root, runtimeLinkName)); err != nil { + os.Remove(tmp) + return previous, err + } + logf("runtime %s activated (was %q)", version, previous) + return previous, nil +} + +// pruneRuntimes deletes every installed runtime except the named ones. +// +// The previous version is deliberately among them: it is what an automatic +// rollback swaps back to, and keeping it costs ~90 MB against having to +// re-download during an incident. +func pruneRuntimes(keep ...string) { + root, err := runtimeRoot() + if err != nil { + return + } + entries, err := os.ReadDir(root) + if err != nil { + return + } + kept := map[string]bool{} + for _, k := range keep { + if k != "" { + kept[k] = true + } + } + for _, e := range entries { + name := e.Name() + if name == runtimeLinkName || kept[name] || strings.HasPrefix(name, ".") { + continue + } + if err := os.RemoveAll(filepath.Join(root, name)); err != nil { + logf("could not prune runtime %s: %v", name, err) + continue + } + logf("pruned runtime %s", name) + } +} + +// ensureRuntime installs the runtime when none is present. +// +// Called before anything that needs a server binary. A missing runtime is the +// normal state of a fresh install and of the first launch after upgrading from +// a version that carried its runtime inside the bundle. +func ensureRuntime(u *updater, progress func(string)) error { + if runtimeReady() { + return nil + } + + // Development seam, and the only way to install a runtime for a build that + // has no release behind it. Same shape as CIX_UPDATE_BASE_URL: unset in + // every normal run, and nothing here needs configuring. + if local := os.Getenv("CIX_RUNTIME_TARBALL"); local != "" { + logf("installing runtime from CIX_RUNTIME_TARBALL=%s", local) + // Named from the tarball rather than from the app: the two versions are + // unrelated now, and build-runtime.sh puts the server version in the + // filename precisely so it can be read back here. + v := runtimeVersionFromTarball(local) + dest, err := runtimeVersionDir(v) + if err != nil { + return err + } + if err := unpackRuntime(local, dest); err != nil { + return err + } + _, err = activateRuntime(v) + return err + } + + // The newest published server, not one pinned to this app's version. They + // are separate release streams on purpose: a server release should reach a + // Mac the same day it reaches Docker Hub, without waiting for the app to be + // re-tagged for it. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + rel, err := u.runtime.Latest(ctx) + if err != nil { + return fmt.Errorf("could not find a cix server to install: %w", err) + } + if rel.Version == "" { + return fmt.Errorf("no published cix server release was found to install") + } + if err := installRuntime(rel, progress); err != nil { + return err + } + if _, err := activateRuntime(rel.Version); err != nil { + return err + } + return nil +} + +// runtimeSummary is the line the details submenu shows. Kept here rather than in +// status_darwin.go because it is about what is installed, not about what the +// server is currently doing — which is why it still says something useful when +// the server is stopped. +func runtimeSummary() string { + v := currentRuntimeVersion() + if v == "" { + return "Server: not installed" + } + if info, err := readRuntimeInfo(); err == nil && info.LlamaVersion != "" { + return fmt.Sprintf("Server %s (llama %s)", v, info.LlamaVersion) + } + return "Server " + v +} diff --git a/cli/launcher/runtime_darwin_test.go b/cli/launcher/runtime_darwin_test.go new file mode 100644 index 00000000..6e7d4332 --- /dev/null +++ b/cli/launcher/runtime_darwin_test.go @@ -0,0 +1,468 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/dvcdsys/code-index/cli/internal/release" +) + +// The runtime is downloaded from the internet and unpacked into the user's home +// directory, and the symlink swap is what a failed update has to reverse. Both +// are worth testing without a network, a signed binary, or a real release. + +func TestSafeJoinRejectsEscapes(t *testing.T) { + dir := t.TempDir() + + for _, name := range []string{ + "../escape", + "a/../../escape", + "/absolute", + "cix-runtime-1.0.0/../../escape", + } { + if got, err := safeJoin(dir, name); err == nil { + t.Errorf("safeJoin(%q) = %q, want an error", name, got) + } + } + + for _, name := range []string{"cix-server", "llama/llama-server", "./cix"} { + if _, err := safeJoin(dir, name); err != nil { + t.Errorf("safeJoin(%q) returned %v, want it accepted", name, err) + } + } +} + +// writeTarGz builds an archive in memory. Each entry is name → content; a name +// ending in "/" becomes a directory. +func writeTarGz(t *testing.T, path string, entries map[string]string, modes map[string]int64) { + t.Helper() + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + for name, content := range entries { + hdr := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))} + if m, ok := modes[name]; ok { + hdr.Mode = m + } + if len(name) > 0 && name[len(name)-1] == '/' { + hdr.Typeflag = tar.TypeDir + hdr.Mode = 0o755 + hdr.Size = 0 + } else { + hdr.Typeflag = tar.TypeReg + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if hdr.Typeflag == tar.TypeReg { + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + t.Fatal(err) + } +} + +// A tarball is an untrusted input even when we produced the one that normally +// arrives: the checksum proves it matches the release, and the release is served +// over the same channel as everything else. Containment is checked here rather +// than assumed. +func TestExtractTarGzRefusesTraversal(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "evil.tar.gz") + writeTarGz(t, archive, map[string]string{"../escaped": "owned"}, nil) + + dest := filepath.Join(dir, "out") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatal(err) + } + if err := extractTarGz(archive, dest); err == nil { + t.Fatal("extractTarGz accepted an entry escaping the destination") + } + if _, err := os.Stat(filepath.Join(dir, "escaped")); err == nil { + t.Fatal("extractTarGz wrote outside the destination directory") + } +} + +// The execute bit is the payload: a cix-server extracted at 0644 is a runtime +// that installs cleanly and cannot be started. +func TestExtractTarGzPreservesExecuteBit(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "runtime.tar.gz") + writeTarGz(t, + archive, + map[string]string{ + "cix-runtime-1.0.0/": "", + "cix-runtime-1.0.0/cix-server": "#!/bin/sh\n", + "cix-runtime-1.0.0/runtime.json": `{"runtime_version":"1.0.0"}`, + "cix-runtime-1.0.0/llama/llama-server": "#!/bin/sh\n", + }, + map[string]int64{ + "cix-runtime-1.0.0/cix-server": 0o755, + "cix-runtime-1.0.0/llama/llama-server": 0o755, + }) + + dest := filepath.Join(dir, "out") + if err := extractTarGz(archive, dest); err != nil { + t.Fatal(err) + } + + for _, rel := range []string{"cix-server", "llama/llama-server"} { + info, err := os.Stat(filepath.Join(dest, "cix-runtime-1.0.0", rel)) + if err != nil { + t.Fatalf("%s: %v", rel, err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Errorf("%s extracted as %v, want it executable", rel, info.Mode().Perm()) + } + } + + info, err := os.Stat(filepath.Join(dest, "cix-runtime-1.0.0", "runtime.json")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o111 != 0 { + t.Errorf("runtime.json extracted as %v, want it non-executable", info.Mode().Perm()) + } +} + +// installRuntimeDir fakes an installed version, without the signed binaries a +// real one carries. +func installRuntimeDir(t *testing.T, home, version string) string { + t.Helper() + dir := filepath.Join(home, ".cix", "runtime", version) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "cix-server"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + return dir +} + +func TestActivateRuntimeSwapsAndReports(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + installRuntimeDir(t, home, "1.0.0") + installRuntimeDir(t, home, "1.1.0") + + if v := currentRuntimeVersion(); v != "" { + t.Fatalf("currentRuntimeVersion() = %q before any activation, want empty", v) + } + if runtimeReady() { + t.Fatal("runtimeReady() is true with no current symlink") + } + + previous, err := activateRuntime("1.0.0") + if err != nil { + t.Fatal(err) + } + if previous != "" { + t.Errorf("first activation reported previous = %q, want empty", previous) + } + if got := currentRuntimeVersion(); got != "1.0.0" { + t.Fatalf("currentRuntimeVersion() = %q, want 1.0.0", got) + } + if !runtimeReady() { + t.Fatal("runtimeReady() is false after activating a complete runtime") + } + + // The swap has to work over an existing symlink — that is the update path, + // and os.Symlink alone would fail with EEXIST. + previous, err = activateRuntime("1.1.0") + if err != nil { + t.Fatal(err) + } + if previous != "1.0.0" { + t.Errorf("activateRuntime reported previous = %q, want 1.0.0", previous) + } + if got := currentRuntimeVersion(); got != "1.1.0" { + t.Fatalf("currentRuntimeVersion() = %q, want 1.1.0", got) + } + + // Rollback is the same operation in reverse, which is the point of using a + // symlink at all. + if _, err := activateRuntime(previous); err != nil { + t.Fatal(err) + } + if got := currentRuntimeVersion(); got != "1.0.0" { + t.Fatalf("after rollback currentRuntimeVersion() = %q, want 1.0.0", got) + } + + // The link must stay relative, so the tree survives a moved home directory. + link, _ := runtimeCurrentDir() + target, err := os.Readlink(link) + if err != nil { + t.Fatal(err) + } + if filepath.IsAbs(target) { + t.Errorf("current -> %q is absolute, want a relative target", target) + } +} + +func TestActivateRuntimeRefusesMissingVersion(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + installRuntimeDir(t, home, "1.0.0") + + if _, err := activateRuntime("1.0.0"); err != nil { + t.Fatal(err) + } + if _, err := activateRuntime("9.9.9"); err == nil { + t.Fatal("activateRuntime accepted a version that is not installed") + } + // A refused activation must not have disturbed the working one. + if got := currentRuntimeVersion(); got != "1.0.0" { + t.Fatalf("currentRuntimeVersion() = %q after a failed activation, want 1.0.0", got) + } +} + +func TestPruneRuntimesKeepsCurrentAndPrevious(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + for _, v := range []string{"1.0.0", "1.1.0", "1.2.0", "1.3.0"} { + installRuntimeDir(t, home, v) + } + if _, err := activateRuntime("1.3.0"); err != nil { + t.Fatal(err) + } + + pruneRuntimes("1.3.0", "1.2.0") + + root := filepath.Join(home, ".cix", "runtime") + for _, v := range []string{"1.3.0", "1.2.0"} { + if _, err := os.Stat(filepath.Join(root, v)); err != nil { + t.Errorf("%s was pruned, want it kept: %v", v, err) + } + } + for _, v := range []string{"1.0.0", "1.1.0"} { + if _, err := os.Stat(filepath.Join(root, v)); err == nil { + t.Errorf("%s survived the prune, want it removed", v) + } + } + // Pruning must never take the symlink with it. + if got := currentRuntimeVersion(); got != "1.3.0" { + t.Fatalf("currentRuntimeVersion() = %q after pruning, want 1.3.0", got) + } +} + +func TestRuntimeAssetName(t *testing.T) { + if got, want := runtimeAssetName("0.12.8"), "cix-runtime-0.12.8-darwin-arm64.tar.gz"; got != want { + t.Errorf("runtimeAssetName() = %q, want %q", got, want) + } + // The round trip matters: the local-tarball path names the install directory + // by reading the version back out of the filename. + if got, want := runtimeVersionFromTarball("/tmp/cix-runtime-0.12.8-darwin-arm64.tar.gz"), "0.12.8"; got != want { + t.Errorf("runtimeVersionFromTarball() = %q, want %q", got, want) + } + if got := runtimeVersionFromTarball("/tmp/something-else.tar.gz"); got != "local" { + t.Errorf("runtimeVersionFromTarball() on an unrecognised name = %q, want local", got) + } +} + +func TestRuntimeSummaryWithoutInstall(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if got, want := runtimeSummary(), "Server: not installed"; got != want { + t.Errorf("runtimeSummary() = %q, want %q", got, want) + } +} + +// fakeRuntimeTarball builds a runtime that is structurally real: three ad-hoc +// signed Mach-O executables and a manifest, packed the way build-runtime.sh +// packs one. +// +// /bin/echo stands in for the binaries because the checks being exercised are +// real ones — codesign --verify --strict, and an actual exec of `cix-server -v` +// — and stubbing them out would test nothing. A copy of a system binary can be +// re-signed ad-hoc; the SIP-protected original could not. +func fakeRuntimeTarball(t *testing.T, dir, version string) string { + t.Helper() + + tree := filepath.Join(dir, runtimeDirPrefix+version) + if err := os.MkdirAll(filepath.Join(tree, "llama"), 0o755); err != nil { + t.Fatal(err) + } + for _, rel := range []string{"cix-server", "cix", "llama/llama-server"} { + dst := filepath.Join(tree, rel) + src, err := os.ReadFile("/bin/echo") + if err != nil { + t.Skipf("cannot read /bin/echo: %v", err) + } + if err := os.WriteFile(dst, src, 0o755); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("codesign", "--force", "--sign", "-", dst).CombinedOutput(); err != nil { + t.Skipf("codesign unavailable: %v %s", err, out) + } + } + manifest := `{"server_version":"` + version + `","cli_version":"8.8.8","llama_version":"bTEST","platform":"darwin-arm64"}` + if err := os.WriteFile(filepath.Join(tree, runtimeManifestName), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + + tarball := filepath.Join(dir, runtimeAssetName(version)) + cmd := exec.Command("tar", "-czf", tarball, "-C", dir, runtimeDirPrefix+version) + cmd.Env = append(os.Environ(), "COPYFILE_DISABLE=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("tar: %v %s", err, out) + } + return tarball +} + +func sha256File(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +// serveRelease publishes a tarball and its checksums the way a GitHub release +// does, and returns the Release the updater would have been handed. +func serveRelease(t *testing.T, version, tarball string, corruptChecksum bool) release.Release { + t.Helper() + + sum := sha256File(t, tarball) + if corruptChecksum { + sum = strings.Repeat("0", 64) + } + sums := sum + " ./" + filepath.Base(tarball) + "\n" + + mux := http.NewServeMux() + mux.HandleFunc("/runtime", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, tarball) + }) + mux.HandleFunc("/checksums", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, sums) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + return release.Release{ + Version: version, + TagName: serverTagPrefix + version, + Assets: []release.Asset{ + {Name: runtimeAssetName(version), URL: srv.URL + "/runtime"}, + {Name: "checksums.txt", URL: srv.URL + "/checksums"}, + }, + } +} + +// The whole install path, end to end: download, checksum, unpack, strip +// quarantine, verify every signature, exec the server, then activate. +func TestInstallRuntimeEndToEnd(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tarball := fakeRuntimeTarball(t, t.TempDir(), "1.0.0") + rel := serveRelease(t, "1.0.0", tarball, false) + + if err := installRuntime(rel, func(string) {}); err != nil { + t.Fatalf("installRuntime: %v", err) + } + // Installing must not switch to it: everything fallible happens before the + // running system is touched, and the swap is a separate, reversible step. + if v := currentRuntimeVersion(); v != "" { + t.Fatalf("installRuntime activated %q on its own", v) + } + + if _, err := activateRuntime("1.0.0"); err != nil { + t.Fatal(err) + } + if !runtimeReady() { + t.Fatal("runtimeReady() is false after a successful install") + } + + info, err := readRuntimeInfo() + if err != nil { + t.Fatalf("readRuntimeInfo: %v", err) + } + if info.ServerVersion != "1.0.0" || info.LlamaVersion != "bTEST" { + t.Errorf("manifest = %+v, want server 1.0.0 / llama bTEST", info) + } + if got, want := runtimeSummary(), "Server 1.0.0 (llama bTEST)"; got != want { + t.Errorf("runtimeSummary() = %q, want %q", got, want) + } + + // The download cache is temporary by design — a 35 MB tarball has no reason + // to outlive the install that used it. + cache, _ := updatesCacheDir() + if _, err := os.Stat(cache); err == nil { + t.Errorf("%s survived the install, want it removed", cache) + } +} + +// Without a Developer ID signature the checksum is the entire integrity story, +// so a mismatch has to stop the install rather than warn about it. +func TestInstallRuntimeRefusesBadChecksum(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tarball := fakeRuntimeTarball(t, t.TempDir(), "1.0.0") + rel := serveRelease(t, "1.0.0", tarball, true) + + if err := installRuntime(rel, func(string) {}); err == nil { + t.Fatal("installRuntime accepted a tarball that failed its checksum") + } + if _, err := os.Stat(filepath.Join(home, ".cix", "runtime", "1.0.0")); err == nil { + t.Error("a version directory was created despite the failed checksum") + } +} + +// An incomplete runtime must be rejected before anything can point at it — +// codesign --verify passes on the files that are present, so completeness is a +// separate check and worth its own test. +func TestUnpackRuntimeRejectsIncompleteTree(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + staging := t.TempDir() + tarball := fakeRuntimeTarball(t, staging, "1.0.0") + // Repack without the CLI. + tree := filepath.Join(staging, runtimeDirPrefix+"1.0.0") + if err := os.Remove(filepath.Join(tree, "cix")); err != nil { + t.Fatal(err) + } + cmd := exec.Command("tar", "-czf", tarball, "-C", staging, runtimeDirPrefix+"1.0.0") + cmd.Env = append(os.Environ(), "COPYFILE_DISABLE=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("tar: %v %s", err, out) + } + + dest := filepath.Join(home, ".cix", "runtime", "1.0.0") + err := unpackRuntime(tarball, dest) + if err == nil { + t.Fatal("unpackRuntime accepted a runtime with no cix CLI") + } + if !strings.Contains(err.Error(), "incomplete") { + t.Errorf("error = %q, want it to say the runtime is incomplete", err) + } + if _, statErr := os.Stat(dest); statErr == nil { + t.Error("the version directory was created despite the incomplete tree") + } +} diff --git a/cli/launcher/status_darwin.go b/cli/launcher/status_darwin.go new file mode 100644 index 00000000..846f05d3 --- /dev/null +++ b/cli/launcher/status_darwin.go @@ -0,0 +1,414 @@ +package main + +import ( + "fmt" + "image/color" + "strings" + "sync" + "time" + + "github.com/dvcdsys/code-index/cli/internal/client" +) + +// The launcher reads the server's state from two independent sources, because +// neither alone is sufficient: +// +// - launchd knows whether the job is loaded and whether it has a pid. It does +// NOT know whether the process is serving: cix-server can spend minutes +// loading an embedding model before it answers anything. +// - /health and /api/v1/status know whether it is serving, and what the +// embedding provider is doing. They cannot distinguish "stopped" from +// "starting", and cannot tell us anything at all when the job was never +// loaded. +// +// Together they separate the three states a user actually cares about: +// stopped, starting, running. + +type runState int + +const ( + stateStopped runState = iota + stateStarting + stateRunning +) + +func (s runState) String() string { + switch s { + case stateRunning: + return "Running" + case stateStarting: + return "Starting…" + default: + return "Stopped" + } +} + +type snapshot struct { + State runState + PID int + Port int + Status *client.StatusResponse + + // EmbeddingsOK is debounced — see poller.pollStatus. + EmbeddingsOK bool + + // Managed is false when the launchd agent belongs to install-server.sh + // rather than to this app; Start/Stop are then disabled. + Managed bool + + // LocalOnly reflects CIX_BIND_ADDR: true when the server is bound to + // loopback and therefore unreachable from other machines. + LocalOnly bool + + // Autostart reflects RunAtLoad in the installed plist — read back from the + // file rather than remembered, so the menu cannot drift from what launchd + // will actually do at the next login. + Autostart bool +} + +// providerLabel turns the wire provider kind into something a person can act +// on. +// +// The only translation is "ollama", and it is not cosmetic. In this codebase +// that kind IS the bundled llama-server supervisor — see +// server/internal/embeddings/provider/ollama/provider.go, which spawns +// llama-server and hardcodes ManagesProcess: true. No Ollama is involved and +// none can be: the HTTP providers (openai, voyage) are a different kind +// entirely. The name is historical. Printing it verbatim in a menu bar tells +// the user they are running software they have not installed, and sends them +// looking for an Ollama that is not there. +// +// The dashboard shows the raw kind, and that is fine — it is a diagnostic +// surface for someone who knows the provider model. This is not. +func providerLabel(kind string) string { + switch kind { + case "ollama": + return "llama.cpp (bundled)" + case "": + return "unknown" + default: + return kind + } +} + +// maxRowRunes caps every menu row. +// +// An NSMenu is exactly as wide as its widest row, so an untruncated model id +// (`ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF`, 41 characters) stretched the +// whole menu to fit one line nobody needs to read in full. Capping every row at +// the same width makes the menu a predictable size instead of a function of +// whichever model happens to be configured; the full value goes in the details +// submenu. +const maxRowRunes = 34 + +// ellipsize shortens s to at most maxRunes, cutting from the middle. +// +// Middle rather than tail because these values are qualified names — +// "awhiteside/CodeRankEmbed-Q8_0-GGUF" — where both ends carry information and +// the middle is the least missed. Tail truncation would leave every Hugging +// Face model rendered as its owner. +func ellipsize(s string, maxRunes int) string { + r := []rune(s) + if len(r) <= maxRunes || maxRunes < 3 { + return s + } + keep := maxRunes - 1 // one rune for the ellipsis + head := (keep + 1) / 2 + tail := keep - head + return string(r[:head]) + "…" + string(r[len(r)-tail:]) +} + +// row renders "label + value", ellipsizing the value so the whole row fits. +func row(label, value string) string { + return label + ellipsize(value, maxRowRunes-len([]rune(label))) +} + +// EmbeddingsLine renders the provider row of the menu. +// +// Readiness is carried by the row's dot, not by a "— ready" suffix: the words +// cost menu width that the model name needs, and a coloured indicator is how +// macOS status apps say this. +func (s snapshot) EmbeddingsLine() string { + if s.State != stateRunning || s.Status == nil { + return "Embeddings: unknown" + } + return row("Embeddings: ", providerLabel(s.Status.EmbeddingProvider)) +} + +// EmbeddingsDot is the indicator colour for the provider row. +func (s snapshot) EmbeddingsDot() color.NRGBA { + switch { + case s.State != stateRunning || s.Status == nil: + return dotGrey + case s.EmbeddingsOK: + return dotGreen + default: + return dotRed + } +} + +// ServerDot is the indicator colour for the server row. +func (s snapshot) ServerDot() color.NRGBA { + switch s.State { + case stateRunning: + return dotGreen + case stateStarting: + return dotAmber + default: + return dotRed + } +} + +// ServerLine renders the top row of the menu. +func (s snapshot) ServerLine() string { + switch s.State { + case stateRunning: + return fmt.Sprintf("cix-server: Running (:%d)", s.Port) + case stateStarting: + return "cix-server: Starting…" + default: + if !s.Managed { + // "(managed externally)" spelled out is 40 characters and would set + // the width of the whole menu on its own. The details submenu explains. + return "cix-server: Stopped (external)" + } + return "cix-server: Stopped" + } +} + +// Detail rows — the information the truncated rows cannot carry, shown in a +// submenu on the server row rather than in tooltips. +// +// Native menu-item tooltips were tried and removed. Two AppKit behaviours make +// them unusable here and neither has an API: once any tooltip in the app has +// appeared, every subsequent one shows with no delay at all; and they are +// positioned against the menu item rather than the pointer. Fixing either means +// giving every row a custom NSView with its own tracking area — that is, +// writing the menu in Objective-C instead of using systray. A submenu gets +// native timing and placement for free. + +// DetailProcess reports the running process, or that there is none. +func (s snapshot) DetailProcess() string { + if s.PID == 0 { + return "Process: not running" + } + return fmt.Sprintf("Process: %d", s.PID) +} + +func (s snapshot) DetailPort() string { + return fmt.Sprintf("Port: %d", s.Port) +} + +// DetailNetwork states the exposure in plain terms. "127.0.0.1" is precise and +// means nothing to most people; "this Mac only" is the fact they care about. +func (s snapshot) DetailNetwork() string { + if s.LocalOnly { + return "Network: this Mac only" + } + return "Network: reachable from your network" +} + +// DetailModel is the full, untruncated model id — the value the row had to cut. +func (s snapshot) DetailModel() string { + if name := s.ModelName(); name != "" { + return "Model: " + name + } + return "" +} + +func (s snapshot) DetailVersion() string { + if s.Status == nil || s.Status.ServerVersion == "" { + return "" + } + return "Server " + s.Status.ServerVersion +} + +// DetailManaged explains a disabled Start/Stop, which is otherwise inexplicable. +func (s snapshot) DetailManaged() string { + if s.Managed { + return "" + } + return "Managed by install-server.sh" +} + +// ModelLine renders the model row, or an empty string when there is nothing to +// say — the menu hides the item rather than showing "Model: unknown". +func (s snapshot) ModelLine() string { + if s.State != stateRunning || s.Status == nil || s.Status.EmbeddingModel == "" { + return "" + } + // The server reports the provider's fingerprint ID, which is prefixed with + // the provider kind ("ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF"). The row + // above already names the provider, and the prefix is the same misleading + // name providerLabel exists to avoid — so show the model alone. + return row("Model: ", s.ModelName()) +} + +// ModelName is the untruncated model, for the tooltip. +func (s snapshot) ModelName() string { + if s.Status == nil { + return "" + } + // The server reports the provider's fingerprint ID, which is prefixed with + // the provider kind ("ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF"). The row + // above already names the provider, and the prefix is the same misleading + // name providerLabel exists to avoid — so show the model alone. + model := s.Status.EmbeddingModel + if _, rest, ok := strings.Cut(model, ":"); ok && rest != "" { + model = rest + } + return model +} + +type poller struct { + mu sync.RWMutex + snap snapshot + + // notReadyStrikes counts consecutive /status polls reporting the embedding + // provider as not ready. The server computes model_loaded under a 500 ms + // deadline it can legitimately lose under load, so a single false is not + // evidence of anything; flipping the menu on it produces a row that + // flickers between ready and not-ready while nothing is wrong. + notReadyStrikes int + + changed chan struct{} +} + +const notReadyStrikesRequired = 2 + +func newPoller() *poller { + return &poller{changed: make(chan struct{}, 1)} +} + +func (p *poller) snapshotNow() snapshot { + p.mu.RLock() + defer p.mu.RUnlock() + return p.snap +} + +// notify wakes the menu without ever blocking the poll loop; a pending +// notification already means "re-read the snapshot". +func (p *poller) notify() { + select { + case p.changed <- struct{}{}: + default: + } +} + +// run polls until stop is closed. Health is cheap and unauthenticated, so it +// runs often; /status needs the API key and returns far more than the menu +// changes, so it runs at a third of the rate. +func (p *poller) run(stop <-chan struct{}) { + const ( + healthEvery = 5 * time.Second + statusEvery = 15 * time.Second + ) + + p.pollHealth() + p.pollStatus() + p.notify() + + healthTick := time.NewTicker(healthEvery) + statusTick := time.NewTicker(statusEvery) + defer healthTick.Stop() + defer statusTick.Stop() + + for { + select { + case <-stop: + return + case <-healthTick.C: + p.pollHealth() + p.notify() + case <-statusTick.C: + p.pollStatus() + p.notify() + } + } +} + +// refresh forces an immediate poll — used right after Start or Stop, so the +// menu reflects the click instead of waiting out the tick. +func (p *poller) refresh() { + p.pollHealth() + p.pollStatus() + p.notify() +} + +func (p *poller) pollHealth() { + vars, _ := readServerEnv() // nil map on first run; serverPort falls back + port := serverPort(vars) + managed := !foreignAgent() + + loaded := launchdLoaded() + pid := 0 + if loaded { + pid = launchdPID() + } + + healthy := client.New(localBaseURL(vars), "").Health() == nil + + state := stateStopped + switch { + case healthy: + state = stateRunning + case pid != 0: + // A live process that is not answering yet: cold start, loading the + // model. Reporting "Stopped" here is what makes people restart a server + // that was two minutes from being ready. + state = stateStarting + } + + p.mu.Lock() + p.snap.State = state + p.snap.PID = pid + p.snap.Port = port + p.snap.Managed = managed + p.snap.LocalOnly = isLocalOnly(vars) + p.snap.Autostart = autostartEnabled() + if state != stateRunning { + // Provider information from a server that is no longer answering is + // stale by definition. + p.snap.Status = nil + p.snap.EmbeddingsOK = false + p.notReadyStrikes = 0 + } + p.mu.Unlock() +} + +func (p *poller) pollStatus() { + vars, err := readServerEnv() + if err != nil { + return + } + key := vars["CIX_API_KEY"] + if key == "" { + return + } + + st, err := client.New(localBaseURL(vars), key).Status() + if err != nil { + return + } + p.applyStatus(st) +} + +// applyStatus folds one /status response into the snapshot, applying the +// not-ready debounce. Split out from the fetch so the transition rules are +// testable without a server. +func (p *poller) applyStatus(st *client.StatusResponse) { + healthy := st.EmbeddingsHealthy() + + p.mu.Lock() + defer p.mu.Unlock() + p.snap.Status = st + if healthy { + p.notReadyStrikes = 0 + p.snap.EmbeddingsOK = true + return + } + p.notReadyStrikes++ + if p.notReadyStrikes >= notReadyStrikesRequired { + p.snap.EmbeddingsOK = false + } +} diff --git a/cli/launcher/status_darwin_test.go b/cli/launcher/status_darwin_test.go new file mode 100644 index 00000000..af515508 --- /dev/null +++ b/cli/launcher/status_darwin_test.go @@ -0,0 +1,489 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" + + "github.com/dvcdsys/code-index/cli/internal/client" +) + +func TestProviderLabel(t *testing.T) { + tests := map[string]string{ + // The bundled llama-server supervisor. Showing the raw kind here would + // tell the user they are running Ollama, which is not installed and + // never was — see the comment on providerLabel. + "ollama": "llama.cpp (bundled)", + "openai": "openai", + "voyage": "voyage", + "": "unknown", + } + for kind, want := range tests { + if got := providerLabel(kind); got != want { + t.Errorf("providerLabel(%q) = %q, want %q", kind, got, want) + } + } +} + +func TestSnapshotLines(t *testing.T) { + running := snapshot{ + State: stateRunning, + Port: 21847, + Managed: true, + Status: &client.StatusResponse{ + EmbeddingProvider: "ollama", + EmbeddingModel: "ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF", + }, + EmbeddingsOK: true, + } + + if got, want := running.ServerLine(), "cix-server: Running (:21847)"; got != want { + t.Errorf("ServerLine() = %q, want %q", got, want) + } + // Readiness is on the dot, not in the text: the words "— ready" cost menu + // width, and an NSMenu is as wide as its widest row. + if got, want := running.EmbeddingsLine(), "Embeddings: llama.cpp (bundled)"; got != want { + t.Errorf("EmbeddingsLine() = %q, want %q", got, want) + } + // The provider prefix is stripped — the row above already names the + // provider, and repeating the misleading kind defeats providerLabel — and + // the remainder is middle-truncated to the width cap. + if got, want := running.ModelLine(), "Model: awhiteside/Co…bed-Q8_0-GGUF"; got != want { + t.Errorf("ModelLine() = %q, want %q", got, want) + } + if got, want := running.ModelName(), "awhiteside/CodeRankEmbed-Q8_0-GGUF"; got != want { + t.Errorf("ModelName() = %q, want %q (the details submenu keeps the full id)", got, want) + } +} + +func TestSnapshotLines_NotRunning(t *testing.T) { + // A cold start loads the embedding model in silence for up to a few + // minutes. Calling that "Stopped" is what makes people kill and restart a + // server that was nearly ready, so it gets its own state. + starting := snapshot{State: stateStarting, Managed: true} + if got, want := starting.ServerLine(), "cix-server: Starting…"; got != want { + t.Errorf("ServerLine() = %q, want %q", got, want) + } + // Provider details from a server that is not answering are stale by + // definition, so no row claims otherwise. + if got, want := starting.ModelLine(), ""; got != want { + t.Errorf("ModelLine() = %q, want %q (hidden)", got, want) + } + if got, want := starting.EmbeddingsLine(), "Embeddings: unknown"; got != want { + t.Errorf("EmbeddingsLine() = %q, want %q", got, want) + } + + // An agent installed by install-server.sh owns the same launchd label. The + // app observes it but must not offer to drive it. Abbreviated in the row + // because spelled out it is 40 characters and would set the menu's width on + // its own; the details submenu carries the explanation. + external := snapshot{State: stateStopped, Managed: false} + if got, want := external.ServerLine(), "cix-server: Stopped (external)"; got != want { + t.Errorf("ServerLine() = %q, want %q", got, want) + } + // A disabled Start/Stop is otherwise inexplicable, so the reason is in the + // details submenu. + if !strings.Contains(external.DetailManaged(), "install-server.sh") { + t.Errorf("DetailManaged() should name the external installer, got %q", external.DetailManaged()) + } +} + +func TestDetailRows(t *testing.T) { + // The details submenu replaced menu-item tooltips: once any AppKit tooltip + // has shown, every later one appears with no delay, and they are positioned + // against the item rather than the pointer. Neither has an API. + s := snapshot{ + State: stateRunning, PID: 4242, Port: 21847, Managed: true, LocalOnly: true, + Status: &client.StatusResponse{ + ServerVersion: "0.12.4", + EmbeddingModel: "ollama:awhiteside/CodeRankEmbed-Q8_0-GGUF", + }, + } + if got, want := s.DetailProcess(), "Process: 4242"; got != want { + t.Errorf("DetailProcess() = %q, want %q", got, want) + } + if got, want := s.DetailPort(), "Port: 21847"; got != want { + t.Errorf("DetailPort() = %q, want %q", got, want) + } + // "127.0.0.1" is precise and means nothing to most people; the exposure is + // what they need to know. + if got, want := s.DetailNetwork(), "Network: this Mac only"; got != want { + t.Errorf("DetailNetwork() = %q, want %q", got, want) + } + // The full id, which the truncated row could not carry — the whole reason + // this submenu exists. + if got, want := s.DetailModel(), "Model: awhiteside/CodeRankEmbed-Q8_0-GGUF"; got != want { + t.Errorf("DetailModel() = %q, want %q", got, want) + } + if got, want := s.DetailVersion(), "Server 0.12.4"; got != want { + t.Errorf("DetailVersion() = %q, want %q", got, want) + } + if got := s.DetailManaged(); got != "" { + t.Errorf("DetailManaged() = %q, want empty for an app-managed agent", got) + } + + // Rows with nothing to say are hidden, never blank. + stopped := snapshot{State: stateStopped, Port: 21847, Managed: true} + if got, want := stopped.DetailProcess(), "Process: not running"; got != want { + t.Errorf("DetailProcess() = %q, want %q", got, want) + } + if stopped.DetailModel() != "" || stopped.DetailVersion() != "" { + t.Error("model and version rows should be empty when the server is not answering") + } + exposed := snapshot{State: stateRunning, Port: 21847, Managed: true, LocalOnly: false} + if got, want := exposed.DetailNetwork(), "Network: reachable from your network"; got != want { + t.Errorf("DetailNetwork() = %q, want %q", got, want) + } +} + +func TestPollerDebouncesNotReady(t *testing.T) { + // model_loaded is computed server-side under a 500 ms deadline it can + // legitimately lose under load. One false is not evidence; flipping the + // menu row on it produces a flicker while nothing is wrong. + p := newPoller() + p.snap.EmbeddingsOK = true + + notReady := &client.StatusResponse{ + EmbeddingProvider: "ollama", + EmbeddingProviderManagesProcess: true, + ModelLoaded: false, + } + + p.applyStatus(notReady) + if !p.snapshotNow().EmbeddingsOK { + t.Error("EmbeddingsOK flipped after a single not-ready poll; want it held until the second") + } + + p.applyStatus(notReady) + if p.snapshotNow().EmbeddingsOK { + t.Error("EmbeddingsOK still true after two consecutive not-ready polls") + } + + // One good poll clears the count, so a later single miss cannot flip it. + p.applyStatus(&client.StatusResponse{ + EmbeddingProvider: "ollama", + EmbeddingProviderManagesProcess: true, + ModelLoaded: true, + }) + if !p.snapshotNow().EmbeddingsOK { + t.Fatal("EmbeddingsOK false after a ready poll") + } + p.applyStatus(notReady) + if !p.snapshotNow().EmbeddingsOK { + t.Error("a single not-ready poll after a ready one flipped the row") + } +} + +func TestPollerTrustsHTTPProviders(t *testing.T) { + // openai / voyage have no local process to be down, and the server skips + // the liveness probe for them. Debouncing to "not ready" there would show a + // permanently red row for a provider that works. + p := newPoller() + http := &client.StatusResponse{ + EmbeddingProvider: "voyage", + EmbeddingProviderManagesProcess: false, + ModelLoaded: false, + } + p.applyStatus(http) + p.applyStatus(http) + p.applyStatus(http) + if !p.snapshotNow().EmbeddingsOK { + t.Error("EmbeddingsOK = false for an HTTP provider; want true regardless of model_loaded") + } +} + +func TestEllipsize(t *testing.T) { + tests := []struct { + in string + max int + want string + }{ + {"short", 10, "short"}, + {"exactly-10", 10, "exactly-10"}, + // Middle, not tail: both ends of a qualified name carry information, + // and tail truncation renders every Hugging Face model as its owner. + {"awhiteside/CodeRankEmbed-Q8_0-GGUF", 27, "awhiteside/Co…d-Q8_0-GGUF"}, + {"abcdefghij", 5, "abc…j"}, + // Multi-byte input must be cut on rune boundaries, not bytes. + {"привіт-світе-довгий-рядок", 10, "привіт…ядок"}, + } + for _, tc := range tests { + got := ellipsize(tc.in, tc.max) + if len([]rune(got)) > tc.max && len([]rune(tc.in)) > tc.max { + t.Errorf("ellipsize(%q, %d) = %q, %d runes — over the cap", tc.in, tc.max, got, len([]rune(got))) + } + if !utf8.ValidString(got) { + t.Errorf("ellipsize(%q, %d) produced invalid UTF-8: %q", tc.in, tc.max, got) + } + } +} + +func TestRowsFitTheWidthCap(t *testing.T) { + // An NSMenu is exactly as wide as its widest row, so a long model id used + // to stretch the whole menu. Every row must stay inside the cap. + s := snapshot{ + State: stateRunning, + Port: 21847, + Managed: true, + Status: &client.StatusResponse{ + EmbeddingProvider: "ollama", + EmbeddingModel: "ollama:some-extremely-long-organisation/an-even-longer-model-name-v2", + }, + EmbeddingsOK: true, + } + for name, line := range map[string]string{ + "ServerLine": s.ServerLine(), + "EmbeddingsLine": s.EmbeddingsLine(), + "ModelLine": s.ModelLine(), + } { + if n := len([]rune(line)); n > maxRowRunes { + t.Errorf("%s = %q is %d runes, over the %d cap", name, line, n, maxRowRunes) + } + } + // The untruncated value is still available for the details submenu. + if got, want := s.ModelName(), "some-extremely-long-organisation/an-even-longer-model-name-v2"; got != want { + t.Errorf("ModelName() = %q, want %q", got, want) + } +} + +func TestDots(t *testing.T) { + running := snapshot{State: stateRunning, Status: &client.StatusResponse{}, EmbeddingsOK: true} + if running.ServerDot() != dotGreen || running.EmbeddingsDot() != dotGreen { + t.Error("a running server with a ready provider should be green on both rows") + } + // Starting is amber, not red: a cold start loading a model is working, and + // red is what makes people kill it. + if (snapshot{State: stateStarting}).ServerDot() != dotAmber { + t.Error("starting should be amber") + } + if (snapshot{State: stateStopped}).ServerDot() != dotRed { + t.Error("stopped should be red") + } + // Nothing is known about the provider when the server is down; grey says + // "unknown", red would claim it is broken. + if (snapshot{State: stateStopped}).EmbeddingsDot() != dotGrey { + t.Error("provider state should be grey when the server is not answering") + } + if len(dotPNG(dotGreen)) == 0 { + t.Error("dotPNG returned no bytes") + } +} + +func TestIsLocalOnly(t *testing.T) { + // An ABSENT CIX_BIND_ADDR is the server's all-interfaces default, not + // loopback. Reading it the other way would show a reassuring "local only" + // on exactly the installs that are exposed. + tests := []struct { + vars map[string]string + want bool + }{ + {map[string]string{}, false}, + {map[string]string{"CIX_BIND_ADDR": ""}, false}, + {map[string]string{"CIX_BIND_ADDR": "0.0.0.0"}, false}, + {map[string]string{"CIX_BIND_ADDR": "192.168.1.5"}, false}, + {map[string]string{"CIX_BIND_ADDR": "127.0.0.1"}, true}, + {map[string]string{"CIX_BIND_ADDR": " 127.0.0.1 "}, true}, + {map[string]string{"CIX_BIND_ADDR": "::1"}, true}, + {map[string]string{"CIX_BIND_ADDR": "localhost"}, true}, + } + for _, tc := range tests { + if got := isLocalOnly(tc.vars); got != tc.want { + t.Errorf("isLocalOnly(%v) = %v, want %v", tc.vars, got, tc.want) + } + } +} + +func TestParseTemporaryPassword(t *testing.T) { + // The real success output. The password line is conditional — it is only + // printed when the password was generated rather than piped in — and the + // DISABLED warning is conditional too, so matching the prefix rather than a + // line index is what keeps this working when the surrounding lines change. + out := "Password reset for admin@example.com (admin).\n" + + "Temporary password: mKq7RtVbn3XpZs4Ldy8W\n" + + "All sessions for this account were revoked; the next login forces a password change.\n" + pw, ok := parseTemporaryPassword(out) + if !ok || pw != "mKq7RtVbn3XpZs4Ldy8W" { + t.Errorf("parseTemporaryPassword() = %q, %v; want the generated password", pw, ok) + } + + // With a DISABLED account the command appends a warning after the password. + withWarning := out + "WARNING: this account is DISABLED — it still cannot log in.\n" + if pw, ok := parseTemporaryPassword(withWarning); !ok || pw != "mKq7RtVbn3XpZs4Ldy8W" { + t.Errorf("parseTemporaryPassword() with trailing warning = %q, %v", pw, ok) + } + + // A piped-in password produces no line at all. Reporting some other line as + // the password would be worse than reporting nothing. + piped := "Password reset for admin@example.com (admin).\n" + + "All sessions for this account were revoked; the next login forces a password change.\n" + if pw, ok := parseTemporaryPassword(piped); ok { + t.Errorf("parseTemporaryPassword() = %q, true; want no match when none was generated", pw) + } + if _, ok := parseTemporaryPassword(""); ok { + t.Error("parseTemporaryPassword(\"\") reported a match") + } +} + +func TestPrefsRoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + // Nothing recorded yet: the question has not been asked. + if got := loadPrefs(); got.Takeover != "" { + t.Errorf("loadPrefs() on a fresh home = %+v, want zero", got) + } + if err := savePrefs(prefs{Takeover: takeoverKeep}); err != nil { + t.Fatalf("savePrefs: %v", err) + } + if got := loadPrefs(); got.Takeover != takeoverKeep { + t.Errorf("loadPrefs() = %+v, want Takeover=%q", got, takeoverKeep) + } + + // A corrupt file must not stop the app starting; the worst case is being + // asked the question again. + path, err := prefsPath() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + if got := loadPrefs(); got.Takeover != "" { + t.Errorf("loadPrefs() on corrupt file = %+v, want zero", got) + } +} + +func TestReadEnvFileDialects(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + // install-server.sh writes a file this parser also has to read during a + // takeover, and it does not use our quoting. + body := "# comment\n\nCIX_PORT=21847\nCIX_API_KEY=\"cix_quoted\"\nexport CIX_DATA_DIR='/tmp/data'\nBAD_LINE\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + got, err := readEnvFile(path) + if err != nil { + t.Fatalf("readEnvFile: %v", err) + } + want := map[string]string{ + "CIX_PORT": "21847", + "CIX_API_KEY": "cix_quoted", + "CIX_DATA_DIR": "/tmp/data", + } + for k, v := range want { + if got[k] != v { + t.Errorf("readEnvFile()[%q] = %q, want %q", k, got[k], v) + } + } + if _, ok := got["BAD_LINE"]; ok { + t.Error("a line without = should be skipped, not stored") + } +} + +func TestIsUserCancelled(t *testing.T) { + // The message is localised and the spelling differs between locales — this + // project's own development Mac runs en-GB and emits "cancelled", while the + // American spelling has one L. Matching either sentence is wrong somewhere, + // and was: every dialog dismissal in the app was being reported as a + // failure. The OSStatus is the same number in every language. + cancelled := []string{ + `0:778: execution error: User cancelled. (-128)`, + `0:112: execution error: User canceled. (-128)`, + `0:50: execution error: User canceled the operation. (-128)`, + } + for _, s := range cancelled { + if !isUserCancelled(s) { + t.Errorf("isUserCancelled(%q) = false, want true", s) + } + } + + notCancelled := []string{ + "", + `0:10: syntax error: Expected end of line but found identifier. (-2741)`, + `execution error: Finder got an error: Can't get disk "cix". (-1728)`, + } + for _, s := range notCancelled { + if isUserCancelled(s) { + t.Errorf("isUserCancelled(%q) = true, want false", s) + } + } +} + +func TestChecksumFor(t *testing.T) { + // The release workflow generates this with `shasum -a 256 ./*.dmg`, which + // records a leading "./" — a plain equality check on the filename would + // find nothing and abort every update. + sums := "" + + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./cix-0.3.1-arm64.dmg\n" + + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 ./checksums-other.txt\n" + + got, err := checksumFor(sums, "cix-0.3.1-arm64.dmg") + if err != nil { + t.Fatalf("checksumFor: %v", err) + } + if got != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" { + t.Errorf("checksumFor() = %q", got) + } + + // A name that is not listed must be an error, never a silent pass: the + // checksum is the only integrity check an unnotarised download gets. + if _, err := checksumFor(sums, "cix-9.9.9-arm64.dmg"); err == nil { + t.Error("checksumFor() on a missing entry returned no error") + } + if _, err := checksumFor("", "anything.dmg"); err == nil { + t.Error("checksumFor() on empty input returned no error") + } +} + +func TestVerifyChecksum(t *testing.T) { + dir := t.TempDir() + payload := filepath.Join(dir, "cix-0.3.1-arm64.dmg") + if err := os.WriteFile(payload, []byte("pretend disk image"), 0o600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte("pretend disk image")) + sums := filepath.Join(dir, "checksums.txt") + if err := os.WriteFile(sums, fmt.Appendf(nil, "%x ./cix-0.3.1-arm64.dmg\n", sum), 0o600); err != nil { + t.Fatal(err) + } + + if err := verifyChecksum(payload, sums, "cix-0.3.1-arm64.dmg"); err != nil { + t.Errorf("verifyChecksum on a matching file = %v, want nil", err) + } + + // A truncated or tampered download must not install. + if err := os.WriteFile(payload, []byte("pretend disk imag"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifyChecksum(payload, sums, "cix-0.3.1-arm64.dmg"); err == nil { + t.Error("verifyChecksum accepted a file whose contents changed") + } +} + +func TestParseMountPoint(t *testing.T) { + // hdiutil prints one line per partition; the volume is on the last one. + out := "/dev/disk4 \tGUID_partition_scheme \t\n" + + "/dev/disk4s1 \tApple_HFS \t/Volumes/cix\n" + if got, want := parseMountPoint(out), "/Volumes/cix"; got != want { + t.Errorf("parseMountPoint() = %q, want %q", got, want) + } + if got := parseMountPoint("/dev/disk9\tGUID_partition_scheme\t\n"); got != "" { + t.Errorf("parseMountPoint() with no volume = %q, want empty", got) + } +} + +func TestCheckWritable(t *testing.T) { + if err := checkWritable(t.TempDir()); err != nil { + t.Errorf("checkWritable on a temp dir = %v, want nil", err) + } + // The point of the preflight: find out before spending a download, while + // the installed app is still untouched and the advice can be "drag the new + // one over the old one". + if err := checkWritable("/usr/bin"); err == nil { + t.Skip("running as root, or /usr/bin is writable — preflight cannot be exercised") + } +} diff --git a/cli/launcher/swap.sh b/cli/launcher/swap.sh new file mode 100644 index 00000000..e8b1b47b --- /dev/null +++ b/cli/launcher/swap.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# swap.sh — replace a running .app with a staged copy, then reopen it. +# +# Embedded into cix-launcher and written to a temp file at update time. It has +# to be a separate process for one reason: the application being replaced is the +# one running the update, and a process cannot outlive the deletion of its own +# bundle to reopen it. +# +# $1 pid of the launcher to wait for +# $2 live bundle (/Applications/cix.app) +# $3 staged bundle (/Applications/.cix.app.new) +# $4 log file +# +# Deliberately /bin/bash, not /usr/bin/env bash: this runs detached, after the +# app is gone, with whatever environment launchd or the shell left behind. The +# system bash is the one path guaranteed to exist. +set -uo pipefail + +LAUNCHER_PID="${1:?pid required}" +LIVE="${2:?live bundle required}" +STAGED="${3:?staged bundle required}" +LOG="${4:-/dev/null}" + +log() { printf '%s swap: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$LOG"; } + +log "waiting for launcher pid $LAUNCHER_PID to exit" +# Bounded: a launcher that never exits must not leave a script polling forever, +# and swapping the bundle out from under a running process is how you get a +# half-updated app. +for _ in $(seq 1 120); do + kill -0 "$LAUNCHER_PID" 2>/dev/null || break + sleep 0.5 +done +if kill -0 "$LAUNCHER_PID" 2>/dev/null; then + log "launcher still running after 60s — aborting, nothing was changed" + exit 1 +fi + +if [ ! -d "$STAGED" ]; then + log "staged bundle missing at $STAGED — aborting" + exit 1 +fi + +OLD="${LIVE}.old" +rm -rf "$OLD" + +# Move the live app aside rather than deleting it. If the second move fails — +# a full disk, a permissions change between the preflight and now — there is +# still a complete application on disk to put back, which "rm -rf then copy" +# would not leave. +if [ -d "$LIVE" ]; then + if ! mv "$LIVE" "$OLD"; then + log "could not move $LIVE aside — aborting, the installed app is untouched" + exit 1 + fi +fi + +if ! mv "$STAGED" "$LIVE"; then + log "could not move the staged bundle into place; restoring the previous app" + mv "$OLD" "$LIVE" || log "RESTORE FAILED — the previous app is at $OLD" + exit 1 +fi + +rm -rf "$OLD" +log "replaced $LIVE" + +# Reopen. -n would start a second instance; the old one is gone, so a plain +# open is what puts the user back where they were. +open "$LIVE" || log "could not reopen $LIVE" +log "done" diff --git a/cli/launcher/takeover_darwin.go b/cli/launcher/takeover_darwin.go new file mode 100644 index 00000000..14b09890 --- /dev/null +++ b/cli/launcher/takeover_darwin.go @@ -0,0 +1,238 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// Coexisting with install-server.sh. +// +// Both use the launchd label com.cix.server — install-server.sh:44 — and it +// points at a repo checkout (:351). So on a machine where someone already runs +// cix from a clone, this app finds an agent it did not create, pointing at a +// server it does not own, holding the port it would use. +// +// Silently rewriting that agent would repoint a working development install at +// the bundle without telling anyone, and re-running install-server.sh would +// silently take it back. Both directions are surprising. So the app asks once, +// remembers the answer, and defaults to leaving it alone. + +// handleForeignAgent asks (once) what to do about an agent this app did not +// create, and acts on the answer. Returns true when the app now owns the agent. +func handleForeignAgent(u *updater) bool { + p := loadPrefs() + + if p.Takeover == "" { + existing := describeForeignAgent() + adopt, err := ask("Another cix installation was found", + "A cix-server background agent is already installed on this Mac, set up by "+ + "install-server.sh rather than by this app.\n\n"+ + existing+"\n\n"+ + "cix.app can take it over and manage it from the menu bar, or leave it "+ + "exactly as it is and only show its status.\n\n"+ + "If you take it over, re-running install-server.sh will claim it back.", + "Take Over", "Leave It Alone") + if err != nil { + // Undecided is not a decision: ask again next launch rather than + // recording a preference the user never expressed. + logf("takeover prompt failed or was dismissed: %v", err) + return false + } + p.Takeover = takeoverKeep + if adopt { + p.Takeover = takeoverAdopt + } + if err := savePrefs(p); err != nil { + logf("could not save the takeover decision: %v", err) + } + } + + if p.Takeover != takeoverAdopt { + logf("leaving the existing launchd agent alone (observe-only)") + return false + } + + if err := adoptForeignAgent(u); err != nil { + logf("takeover failed: %v", err) + _ = alert("Could not take over the existing installation", + fmt.Sprintf("%v\n\nThe existing installation was left untouched. "+ + "cix will show its status but will not manage it.", err)) + // Forget the decision so a fixed environment can be retried, rather + // than leaving the app permanently stuck between two modes. + p.Takeover = "" + _ = savePrefs(p) + return false + } + return true +} + +// adoptForeignAgent migrates the existing configuration, backs up what it +// replaces, and installs this app's wrapper and plist. +func adoptForeignAgent(u *updater) error { + envPath, err := foreignEnvPath() + if err != nil { + return err + } + + // The wrapper written below execs ~/.cix/runtime/current/cix-server, so the + // runtime has to exist before the old agent is unloaded. Doing it first also + // means a failed download costs nothing: the existing installation is still + // running its own server, untouched. + if err := ensureRuntime(u, logProgress); err != nil { + return fmt.Errorf("could not install the cix server: %w", err) + } + + // Migrate first. Taking over an agent and then pointing it at a fresh, + // empty database would look like the takeover destroyed the user's index. + if envPath != "" { + if err := migrateForeignEnv(envPath); err != nil { + return fmt.Errorf("could not read the existing configuration at %s: %w", envPath, err) + } + } else if needsFirstRun() { + return errors.New("the existing agent's configuration file could not be located, " + + "so its database and API key cannot be carried over") + } + + if err := backupForeignAgent(); err != nil { + // Refuse rather than proceed: without a backup the previous setup is + // not recoverable, and this is the one step that overwrites it. + return fmt.Errorf("could not back up the existing agent: %w", err) + } + + if err := launchdBootout(); err != nil { + return fmt.Errorf("could not unload the existing agent: %w", err) + } + if err := writeLaunchdFiles(autostartEnabled()); err != nil { + return err + } + if err := launchdBootstrap(); err != nil { + return err + } + logf("took over the launchd agent previously managed by install-server.sh") + return nil +} + +// foreignEnvPath extracts the env file the existing wrapper sources. +// +// install-server.sh generates `source ""` into its wrapper (:775-784). +// Parsing that beats guessing at a location, because the path points into +// whichever checkout the operator installed from. +func foreignEnvPath() (string, error) { + wrapper, err := wrapperPath() + if err != nil { + return "", err + } + data, err := os.ReadFile(wrapper) + if err != nil { + return "", nil // no wrapper to read; caller decides whether that is fatal + } + for line := range strings.SplitSeq(string(data), "\n") { + line = strings.TrimSpace(line) + rest, ok := strings.CutPrefix(line, "source ") + if !ok { + rest, ok = strings.CutPrefix(line, ". ") + } + if !ok { + continue + } + path := strings.Trim(strings.TrimSpace(rest), `"'`) + if path != "" { + return path, nil + } + } + return "", nil +} + +// migrateForeignEnv copies the settings that identify the server's data into +// our own server.env. +// +// Only these four keys. The rest of an install-server.sh .env is that +// installation's business — model tuning, GPU layers, tunnel credentials — and +// copying it wholesale would silently import settings the user never chose for +// this app. These four are the ones that decide *which server* it is. +func migrateForeignEnv(path string) error { + src, err := readEnvFile(path) + if err != nil { + return err + } + + dst, err := readServerEnv() + if err != nil { + dst = map[string]string{} + } + for _, key := range []string{"CIX_PORT", "CIX_API_KEY", "CIX_SQLITE_PATH", "CIX_DATA_DIR"} { + if v, ok := src[key]; ok && strings.TrimSpace(v) != "" { + dst[key] = v + } + } + // The app owns updating itself; leaving the server's own version check on + // would mean two components offering two different update prompts. + dst["CIX_VERSION_CHECK_ENABLED"] = "false" + + logf("migrated %d settings from %s", len(dst), path) + return writeServerEnv(dst) +} + +// backupForeignAgent copies the plist and wrapper aside before they are +// overwritten, so the previous setup can be restored by hand. +func backupForeignAgent() error { + home, err := cixHome() + if err != nil { + return err + } + stamp := time.Now().Format("20060102-150405") + dir := filepath.Join(home, "backup", "install-server-"+stamp) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + + plist, err := plistPath() + if err != nil { + return err + } + wrapper, err := wrapperPath() + if err != nil { + return err + } + for _, src := range []string{plist, wrapper} { + data, err := os.ReadFile(src) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return err + } + if err := os.WriteFile(filepath.Join(dir, filepath.Base(src)), data, 0o600); err != nil { + return err + } + } + logf("backed up the previous agent to %s", dir) + return nil +} + +// describeForeignAgent renders what was found, for the prompt. Being concrete +// is what lets someone recognise their own installation instead of guessing. +func describeForeignAgent() string { + var parts []string + if envPath, _ := foreignEnvPath(); envPath != "" { + parts = append(parts, "Configuration: "+envPath) + } + if wrapper, err := wrapperPath(); err == nil { + if data, err := os.ReadFile(wrapper); err == nil { + for line := range strings.SplitSeq(string(data), "\n") { + if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "exec "); ok { + parts = append(parts, "Server: "+strings.Trim(strings.TrimSpace(rest), `"'`)) + break + } + } + } + } + if len(parts) == 0 { + return "Its configuration could not be read." + } + return strings.Join(parts, "\n") +} diff --git a/cli/launcher/update_darwin.go b/cli/launcher/update_darwin.go new file mode 100644 index 00000000..0722a596 --- /dev/null +++ b/cli/launcher/update_darwin.go @@ -0,0 +1,541 @@ +package main + +import ( + "context" + "crypto/sha256" + _ "embed" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/dvcdsys/code-index/cli/internal/client" + "github.com/dvcdsys/code-index/cli/internal/release" +) + +// Self-update. +// +// A release has two halves, published together under one mac/vX.Y.Z tag and +// updated independently: +// +// - The runtime — cix-server, the cix CLI, llama-server — installed into +// ~/.cix/runtime// and switched on by moving a symlink. The app +// keeps running throughout, and a runtime that does not come back up is +// rolled back automatically. +// - The launcher — the .app itself — which can only be replaced by a detached +// helper that waits for this process to exit, because a process cannot +// overwrite its own signed executable and survive. +// +// In the normal case both change at once and both are done, runtime first: it +// is the reversible half, so a failure there costs nothing, whereas the launcher +// swap ends this process. +// +// The runtime carries the CLI rather than the app, so `cix` on PATH is a symlink +// into ~/.cix/runtime/current and follows updates without /usr/local being +// touched. + +//go:embed swap.sh +var swapScript []byte + +// The two streams this app watches. They are separate releases of separate +// things and neither waits for the other: a server release reaches a Mac the +// same day it reaches Docker Hub, and the app ships when the app changes. +const ( + macTagPrefix = "mac/v" // cix.app — the launcher + serverTagPrefix = "server/v" // the runtime, same tag as the Docker images +) + +// updateCheckInterval throttles the automatic check. The requests are nearly +// free — a cached ETag makes a no-change check a 304, which does not count +// against GitHub's unauthenticated hourly limit — but the limit is per IP and +// shared with everything else on the machine, and this is now two requests +// rather than one, so there is no reason to spend them more often than this. +const updateCheckInterval = 30 * time.Minute + +type updater struct { + bundle bundle + + app *release.Client + runtime *release.Client + + lastCheck time.Time + + // The newest release seen on each stream, whether or not it is newer than + // what is installed. Kept so a 304 — which carries no body — leaves the + // previous answer standing instead of reading as "nothing published". + seenApp release.Release + seenRuntime release.Release +} + +// available is what a check turned up: the halves that are behind, if any. A +// zero Release means that half is current. +type available struct { + App release.Release + Runtime release.Release +} + +func (a available) any() bool { return a.App.Version != "" || a.Runtime.Version != "" } + +func newUpdater(b bundle) *updater { + p := loadPrefs() + u := &updater{ + bundle: b, + app: release.New(release.DefaultRepo, macTagPrefix), + runtime: release.New(release.DefaultRepo, serverTagPrefix), + } + u.app.ETag = p.UpdateETag + u.runtime.ETag = p.RuntimeETag + + // Test seam. The update path replaces the running application and restarts + // the server, so the only way to know it works is to run it — against a + // local server, with locally built artefacts, rather than by publishing a + // release and hoping. Unset in every normal run; nothing here needs + // configuring. + if base := os.Getenv("CIX_UPDATE_BASE_URL"); base != "" { + trimmed := strings.TrimRight(base, "/") + u.app.BaseURL = trimmed + u.runtime.BaseURL = trimmed + logf("update base URL overridden to %s (CIX_UPDATE_BASE_URL)", trimmed) + } + return u +} + +func updatesCacheDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "Caches", "cix", "updates"), nil +} + +// check asks GitHub about both streams, at most once per interval unless forced. +func (u *updater) check(force bool) available { + if force || time.Since(u.lastCheck) >= updateCheckInterval { + u.lastCheck = time.Now() + u.seenApp = u.refresh("app", u.app, u.seenApp, func(p *prefs, etag string) { p.UpdateETag = etag }) + u.seenRuntime = u.refresh("runtime", u.runtime, u.seenRuntime, func(p *prefs, etag string) { p.RuntimeETag = etag }) + } + + var av available + // A development build reports version "dev", which IsNewer refuses to + // compare — so an unstamped launcher never offers to "update" itself to a + // release that is probably older than the tree it was built from. Its + // runtime is a separate question and is still offered. + if release.IsNewer(version, u.seenApp.Version) { + av.App = u.seenApp + } + if release.IsNewer(currentRuntimeVersion(), u.seenRuntime.Version) { + av.Runtime = u.seenRuntime + } + return av +} + +// refresh polls one stream, keeping the previous answer when nothing came back. +func (u *updater) refresh(what string, c *release.Client, previous release.Release, setETag func(*prefs, string)) release.Release { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + rel, err := c.Latest(ctx) + switch { + case errors.Is(err, release.ErrNotModified): + return previous + case err != nil: + logf("%s update check failed: %v", what, err) + return previous + } + + if etag := c.ETag; etag != "" { + p := loadPrefs() + before := p + setETag(&p, etag) + if p != before { + if err := savePrefs(p); err != nil { + logf("could not persist the %s update ETag: %v", what, err) + } + } + } + return rel +} + +// install applies whichever halves are behind, runtime first. +// +// Runtime first because it is the reversible one: a failure there leaves the +// app untouched and the old server running, whereas the launcher swap ends this +// process. Returns quit=true when the launcher was staged — from that point the +// detached helper owns the outcome and waits for this process to exit. +func (u *updater) install(av available, serverWasRunning bool, progress func(string)) (quit bool, err error) { + if av.Runtime.Version != "" { + if err := u.updateRuntime(av.Runtime, serverWasRunning, progress); err != nil { + return false, err + } + } + if av.App.Version == "" { + return false, nil + } + if err := u.updateLauncher(av.App, progress); err != nil { + return false, err + } + return true, nil +} + +// updateRuntime installs the new runtime and switches to it, putting the old one +// back if the server does not survive. +// +// Ordering is the whole design: everything that can fail — download, checksum, +// unpack, signature check, a test exec — happens before the running server is +// touched. Only then is the symlink moved, and moving it back is a rename. +func (u *updater) updateRuntime(rel release.Release, serverWasRunning bool, progress func(string)) error { + defer progress("") + + if err := installRuntime(rel, progress); err != nil { + return err + } + + // Stopped first, deliberately. The running server's llama-server sidecar is + // resolved relative to its own executable, so a swap underneath a live + // process would leave the two halves of one runtime disagreeing about which + // version they are. + if serverWasRunning { + progress("Restarting the cix server…") + if err := stopServer(); err != nil { + logf("could not stop the server before the runtime swap: %v", err) + } + waitForServerStop(20 * time.Second) + } + + previous, err := activateRuntime(rel.Version) + if err != nil { + return fmt.Errorf("could not switch to the new runtime: %w", err) + } + + if serverWasRunning { + vars, _ := readServerEnv() + if err := startServer(); err != nil || !serverSurvivedRestart(localBaseURL(vars), runtimeHealthWindow) { + logf("runtime %s did not come back up (start error: %v); rolling back to %q", rel.Version, err, previous) + return u.rollbackRuntime(previous, rel.Version) + } + } + + // Keep the version just replaced. It is what a rollback needs, and ~90 MB is + // a cheap insurance premium against having to download during an incident. + pruneRuntimes(rel.Version, previous) + logf("runtime updated to %s", rel.Version) + return nil +} + +// runtimeHealthWindow is how long a freshly swapped server gets to answer. +// +// Not a deadline for "is it working" — a cold start loads an embedding model and +// can legitimately take minutes. It is a deadline for deciding, and the decision +// falls back to whether the process is still alive. See serverSurvivedRestart. +const runtimeHealthWindow = 45 * time.Second + +// rollbackRuntime returns to the previous runtime after a failed update. +func (u *updater) rollbackRuntime(previous, failed string) error { + if previous == "" || previous == failed { + // Nothing to go back to — a first install that will not run. Leave the + // directory in place: it is the only copy, and re-downloading it to + // investigate would be worse than 90 MB. + return fmt.Errorf("the new server (%s) did not start, and there is no previous version to fall back to.\n\n"+ + "See ~/.cix/logs/cix-server.err.", failed) + } + + _ = stopServer() + waitForServerStop(20 * time.Second) + + if _, err := activateRuntime(previous); err != nil { + return fmt.Errorf("the new server (%s) did not start, and cix could not switch back to %s: %v", + failed, previous, err) + } + if err := startServer(); err != nil { + return fmt.Errorf("the new server (%s) did not start. cix switched back to %s, "+ + "but could not restart it: %v", failed, previous, err) + } + + // Drop the version that just failed, and with it whatever else was lying + // around: the machine is back on a version known to work, and there is + // nothing to fall back FROM any more. Keeping a runtime that has been + // demonstrated not to start is 90 MB and an entry in ~/.cix/runtime that + // invites someone to point `current` at it by hand. + pruneRuntimes(previous) + + return fmt.Errorf("the new server (%s) did not start, so cix went back to %s.\n\n"+ + "The update was not applied. See ~/.cix/logs/cix-server.err.", failed, previous) +} + +// serverSurvivedRestart decides whether a just-started server is alive. +// +// Answering "healthy within the window" alone would be wrong: a cold start loads +// an embedding model and stays silent for minutes, and rolling a good runtime +// back for that would be worse than the bug it guards against. A process that +// has exited, on the other hand, is unambiguous — KeepAlive is false, so nothing +// restarts it and a crash leaves no pid behind. +func serverSurvivedRestart(baseURL string, timeout time.Duration) bool { + c := client.New(baseURL, "") + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if c.Health() == nil { + return true + } + time.Sleep(time.Second) + } + return launchdPID() != 0 +} + +// updateLauncher downloads, verifies and stages the new .app, then hands over to +// the swap script. The caller quits immediately afterwards. +// +// Every step before the swap is reversible: a failure leaves the installed app +// exactly as it was, which is why the staging directory is built and validated +// in full before anything live is touched. +// +// The server is not stopped for this, and used to be. Nothing a running server +// touches is inside the bundle any more — the binary, its llama sidecar and the +// launchd wrapper all live under ~/.cix — so replacing the .app is invisible to +// it. Updating the app no longer interrupts indexing. +func (u *updater) updateLauncher(rel release.Release, progress func(string)) error { + defer progress("") + progress("Downloading the cix update…") + + dmgAsset, ok := rel.AssetBySuffix(".dmg") + if !ok { + return fmt.Errorf("release %s has no disk image attached", rel.TagName) + } + sumsAsset, ok := rel.AssetByName("checksums.txt") + if !ok { + return fmt.Errorf("release %s has no checksums.txt attached", rel.TagName) + } + + // Preflight before spending a download. Writing into the bundle's parent is + // what the swap ultimately needs; find out now, while the app is untouched + // and the message can still be "reinstall from the DMG". + parent := filepath.Dir(u.bundle.Root) + if err := checkWritable(parent); err != nil { + return fmt.Errorf("cix cannot update itself because %s is not writable by you.\n\n"+ + "Download the new version and drag it over the old one instead.", parent) + } + + cacheDir, err := updatesCacheDir() + if err != nil { + return err + } + if err := os.MkdirAll(cacheDir, 0o700); err != nil { + return err + } + defer os.RemoveAll(cacheDir) + + dmgPath := filepath.Join(cacheDir, dmgAsset.Name) + if err := download(dmgAsset.URL, dmgPath); err != nil { + return fmt.Errorf("could not download the update: %w", err) + } + sumsPath := filepath.Join(cacheDir, "checksums.txt") + if err := download(sumsAsset.URL, sumsPath); err != nil { + return fmt.Errorf("could not download the checksums: %w", err) + } + + if err := verifyChecksum(dmgPath, sumsPath, dmgAsset.Name); err != nil { + // With no Developer ID signature this checksum is the entire integrity + // story, so a mismatch is fatal and loud rather than a warning. + logf("checksum verification failed for %s: %v", dmgAsset.Name, err) + return fmt.Errorf("the downloaded update failed its checksum check and was discarded.\n\n%v", err) + } + + staged := filepath.Join(parent, ".cix.app.new") + os.RemoveAll(staged) + if err := stageFromDMG(dmgPath, staged); err != nil { + os.RemoveAll(staged) + return err + } + + return u.launchSwap(staged) +} + +// launchSwap writes the swap script to a temp file and starts it detached. +func (u *updater) launchSwap(staged string) error { + dir, err := os.MkdirTemp("", "cix-update-") + if err != nil { + return err + } + script := filepath.Join(dir, "swap.sh") + if err := os.WriteFile(script, swapScript, 0o755); err != nil { + return err + } + + logPath, err := launcherLogPath() + if err != nil { + logPath = os.DevNull + } + + cmd := exec.Command("/bin/bash", script, + fmt.Sprint(os.Getpid()), u.bundle.Root, staged, logPath) + // Its own session, so it is not taken down with the launcher it is waiting + // for — which is the entire job. + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return fmt.Errorf("could not start the update helper: %w", err) + } + // Release the child rather than waiting: this process is about to exit and + // the helper must outlive it. + _ = cmd.Process.Release() + logf("update staged at %s; swap helper started, quitting", staged) + return nil +} + +// stageFromDMG mounts the image, copies the app out, and validates it. +func stageFromDMG(dmgPath, staged string) error { + out, err := exec.Command("hdiutil", "attach", "-nobrowse", "-readonly", "-noautoopen", dmgPath).CombinedOutput() + if err != nil { + return fmt.Errorf("could not open the downloaded disk image: %s", strings.TrimSpace(string(out))) + } + mount := parseMountPoint(string(out)) + if mount == "" { + return fmt.Errorf("could not determine where the disk image was mounted") + } + defer exec.Command("hdiutil", "detach", mount, "-force", "-quiet").Run() + + src := filepath.Join(mount, "cix.app") + if _, err := os.Stat(src); err != nil { + return fmt.Errorf("the disk image does not contain cix.app") + } + + // ditto, not cp -R: it preserves the extended attributes and metadata a + // code signature is sealed over. cp -R drops some of them, and the bundle + // then fails the verification two lines below. + if out, err := exec.Command("ditto", src, staged).CombinedOutput(); err != nil { + return fmt.Errorf("could not copy the new version: %s", strings.TrimSpace(string(out))) + } + + // Quarantine came from the download; left in place, the nested llama-server + // is SIGKILLed on exec with empty stderr. + _ = exec.Command("xattr", "-cr", staged).Run() + + if out, err := exec.Command("codesign", "--verify", "--strict", staged).CombinedOutput(); err != nil { + return fmt.Errorf("the downloaded application failed signature verification and was discarded: %s", + strings.TrimSpace(string(out))) + } + return nil +} + +// parseMountPoint pulls /Volumes/... out of hdiutil attach's output. +func parseMountPoint(out string) string { + var mount string + for line := range strings.SplitSeq(out, "\n") { + if i := strings.Index(line, "/Volumes/"); i >= 0 { + mount = strings.TrimSpace(line[i:]) + } + } + return mount +} + +func download(url, dest string) error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s returned %s", url, resp.Status) + } + + f, err := os.Create(dest) + if err != nil { + return err + } + defer f.Close() + if _, err := io.Copy(f, resp.Body); err != nil { + return err + } + return f.Sync() +} + +// verifyChecksum compares the file against its line in a shasum-format list. +// +// Worth being explicit about what this does and does not prove: it establishes +// that the bytes on disk are the bytes the release lists, which catches a +// truncated or corrupted download. It is not a trust anchor — checksums.txt +// arrives over the same channel as the image, so anyone able to replace one can +// replace the other. Without a Developer ID signature there is nothing better +// available here; a detached signature over the checksums would be. +func verifyChecksum(path, sumsPath, name string) error { + sums, err := os.ReadFile(sumsPath) + if err != nil { + return err + } + want, err := checksumFor(string(sums), name) + if err != nil { + return err + } + + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, want) { + return fmt.Errorf("expected %s, got %s", want, got) + } + return nil +} + +// checksumFor finds a file's hash in shasum output. +// +// The name is matched against the last path component because the release +// workflow generates the file with `shasum -a 256 ./*.dmg`, which records +// "./cix-0.3.1-arm64.dmg" — a leading ./ that a plain equality check would miss. +func checksumFor(sums, name string) (string, error) { + for line := range strings.SplitSeq(sums, "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) < 2 { + continue + } + if filepath.Base(fields[len(fields)-1]) == name { + return fields[0], nil + } + } + return "", fmt.Errorf("checksums.txt has no entry for %s", name) +} + +// checkWritable reports whether the current user can create files in dir. +// +// Deliberately not answered by escalating: an unsigned application asking for +// an administrator password in order to overwrite itself is indistinguishable +// from malware, and teaching users to approve that is worse than an update that +// asks them to drag an icon. +func checkWritable(dir string) error { + f, err := os.CreateTemp(dir, ".cix-write-test-") + if err != nil { + return err + } + name := f.Name() + f.Close() + return os.Remove(name) +} + +func waitForServerStop(timeout time.Duration) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if launchdPID() == 0 { + return + } + time.Sleep(250 * time.Millisecond) + } +} diff --git a/cli/launcher/version.go b/cli/launcher/version.go new file mode 100644 index 00000000..75efa34a --- /dev/null +++ b/cli/launcher/version.go @@ -0,0 +1,23 @@ +package main + +// version is stamped at build time with -ldflags "-X main.version=X.Y.Z". +// +// It tracks the `mac/vX.Y.Z` tag stream, NOT `server/v*` or `cli/v*`: the .app +// is released independently of the two binaries it bundles, and the self-updater +// (Phase 4) compares this value against the `mac/v*` releases on GitHub. +// +// The literal "dev" is meaningful, not just a placeholder — an unstamped build +// must never offer itself an "update" to a published release that is probably +// older than the working tree it was built from. +var version = "dev" + +// isDevBuild reports whether this binary was built without a release stamp. +func isDevBuild() bool { return version == "dev" || version == "" } + +// displayVersion renders the version for human-facing text. +func displayVersion() string { + if isDevBuild() { + return "development build" + } + return "v" + version +} diff --git a/doc/CONFIG_REFERENCE.md b/doc/CONFIG_REFERENCE.md index 3494b00c..4a4eae58 100644 --- a/doc/CONFIG_REFERENCE.md +++ b/doc/CONFIG_REFERENCE.md @@ -31,6 +31,7 @@ the DB. | Variable | Default | Description | |---|---|---| | `CIX_PORT` | `21847` | Listen port (both Docker images bake this in). | +| `CIX_BIND_ADDR` | — | Interface to listen on, as a bare address with no port. Empty means every interface, which is what a container needs; set `127.0.0.1` to make the server reachable only from the machine it runs on. The macOS app writes `127.0.0.1` at first run and exposes a menu toggle. | | `CIX_SQLITE_PATH` | `/data/sqlite/projects.db` | SQLite path. Suffixed with the model-safe name on open. | | `CIX_CHROMA_PERSIST_DIR` | `/data/chroma` | Vector store directory. | | `CIX_GGUF_CACHE_DIR` | `/data/models` | Where downloaded GGUF files live. | diff --git a/doc/MACOS_APP.md b/doc/MACOS_APP.md new file mode 100644 index 00000000..7f92f361 --- /dev/null +++ b/doc/MACOS_APP.md @@ -0,0 +1,372 @@ +# cix for macOS + +`cix.app` is a drag-to-install menu bar app for Apple Silicon that runs a local +cix server — the server itself, the `cix` CLI and a Metal-accelerated +`llama-server` for embeddings. + +The app sits in the menu bar and shows whether the server is running and what +its embedding provider is doing, with start/stop and a link to the dashboard. +There is no Dock icon and no window. + +It keeps itself up to date, checks its downloads, and never asks for an +administrator password to do it. + +The download is small — about 4 MB — because the server is not inside it. The +app fetches that part on first launch and keeps it in `~/.cix/runtime/`, which +is what lets it update the server without restarting itself, and roll back +automatically if a new one will not start. + +The server it installs is the same build the Docker images are cut from, with +the same version number, and it is released on its own schedule. A new cix +server reaches your Mac without waiting for a new version of this app. + +## Requirements + +- macOS 13 (Ventura) or later +- Apple Silicon (M1 or newer) + +There is no Intel build. Upstream llama.cpp publishes a single macOS release +asset, `macos-arm64`; pairing an x86_64 server binary with an arm64 +`llama-server` produces a bundle that installs cleanly and then fails at the +first embedding, so `mac/scripts/build-app.sh` refuses to build one. + +## Install + +1. Download `cix--arm64.dmg` from the + [releases page](https://github.com/dvcdsys/code-index/releases) and open it. +2. Drag **cix.app** onto **Applications**. +3. Open it from Applications — not from the mounted disk image. + +Verify the download first if you like: + +```bash +shasum -a 256 -c checksums.txt +``` + +### The first launch is blocked, and that is expected + +macOS will refuse to open the app the first time, reporting that it "cannot be +verified" or "is damaged". Neither is true. cix is open source and is signed +**ad-hoc** rather than with a paid Apple Developer certificate, so it has no +Gatekeeper trust. + +To allow it: + +1. **System Settings → Privacy & Security** +2. Scroll down to **Security**. There is a message about cix being blocked. +3. Click **Open Anyway** and confirm. + +This is once per installed version. + +On macOS 15 and later, right-clicking the app and choosing **Open** no longer +works as a shortcut for this — the System Settings route is the only one. + +### Why not Homebrew? + +`brew install --cask` is not an option for this app. Homebrew ends support for +casks that fail Gatekeeper on 2026-09-01 and is removing the `--no-quarantine` +flag that used to work around it. Distributing through a cask would mean paying +for a Developer ID, which this project does not do. + +### Open the app from /Applications, not from the disk image + +If a quarantined app is opened from anywhere other than `/Applications`, +Gatekeeper runs it from a randomised read-only copy ("App Translocation"). It +appears to work, but the bundle path is temporary, so anything written into it — +or any background job pointing at it — breaks when the copy is discarded. The +launcher detects this and asks you to move the app rather than half-working. + +## What is inside + +The app holds one executable: + +``` +cix.app/Contents/ + Info.plist + MacOS/ + cix-launcher the menu bar app + Resources/ + cix.icns app icon + cixTemplate.png menu-bar glyph (and @2x) +``` + +Everything it runs lives outside it, under your home directory: + +``` +~/.cix/runtime/ + 0.12.8/ cix-server cix llama/ runtime.json + 0.12.7/ the version this one replaced, kept for rollback + current -> 0.12.8 +``` + +Those are *server* versions — the same ones on Docker Hub. The app has its own, +smaller version, and the two have nothing to do with each other. + +`llama/` sits next to `cix-server` because `cix-server` looks for +`llama-server` at `/llama`, so keeping them siblings means +`CIX_LLAMA_BIN_DIR` never has to be set. They are downloaded, checked and +installed as one thing — a llama version is part of a server release, not +something tracked separately. + +`current` is a symlink, and that is the whole trick: updating the server means +extracting the new one beside the old and renaming the symlink, which is atomic +and instantly reversible. Nothing has to quit, and the version that was working +five seconds ago is still on disk. + +To see what is installed: + +```bash +/Applications/cix.app/Contents/MacOS/cix-launcher -report +``` + +It asks each binary for its own version rather than reading the manifest, which +is what catches a runtime that is not what it claims to be. + +## First run + +The very first launch asks for an email address, downloads the runtime (about +40 MB), then generates a password and an API key, starts the server, and shows +you the credentials. You will be asked to change the password when you first +sign in. + +The download happens before anything is written. A setup that had created an +account and a background agent pointing at a server that was never downloaded +would look finished and be broken. + +This step is not decoration. `cix-server` refuses to start against an empty +database unless it is told which admin account to create — it will not invent +one silently — so an app with no configuration could never start its own server. + +What it writes: + +| Path | Contents | +|---|---| +| `~/.cix/server.env` | port, data paths, API key, bootstrap credentials (mode 0600) | +| `~/.cix/runtime/` | the server, the CLI and llama-server; one directory per version | +| `~/.cix/data/` | SQLite database and the index | +| `~/.cix/launchd/run-cix-server.sh` | launchd entry point; sources `server.env` | +| `~/Library/LaunchAgents/com.cix.server.plist` | the launchd agent | +| `~/.cix/config.yaml` | a `local` server entry, so the `cix` CLI works | +| `~/.cix/launcher.json` | remembered answers, currently only the takeover choice | +| `~/.cix/logs/launcher.log` | the app's own log (mode 0600) | + +`server.env` is the single source of truth for the server process — the plist +carries no configuration. To change a setting, edit that file and use +**Stop Server** then **Start Server**. + +The CLI's default server is left alone if you already had one. Installing an app +should not silently repoint a `cix` command that was talking to a remote server. + +macOS will show a notification saying `run-cix-server.sh` can run in the +background, with a link to Login Items & Extensions. That is expected: it is how +macOS announces any newly registered background agent. + +## The menu + +``` +● cix-server: Running (:21847) ▸ Process: 78903 +● Embeddings: llama.cpp (bundled) Port: 21847 + Model: awhiteside/Co…bed-Q8_0-GGUF Network: this Mac only +───────────── Model: awhiteside/CodeRankEmbed-Q8_0-GGUF +Stop Server Server 0.12.8 +Open Dashboard Server 0.12.8 (llama b10238) +───────────── +Start at Login ✓ +Allow Network Access ✓ +Reset Password… +───────────── +Check for Updates… +───────────── +Quit (server keeps running) +``` + +The dot carries the state: green running, amber starting, red stopped, grey +unknown. Rows are truncated so the menu stays a predictable width instead of +being as wide as whichever model happens to be configured; the submenu on the +server row holds the full values and the things that do not fit — the process +id, the port, the network exposure, the untruncated model name and which server +is installed. The last two rows differ on purpose: the first is what the running +server reports over HTTP, the second is what is on disk — and only the second +still says anything when the server is not running, which is when it matters. + +There are no tooltips anywhere in this app, deliberately. AppKit's have two +behaviours that cannot be changed through any API: once one of them has +appeared, every subsequent one shows with no delay at all, and they are placed +against the element rather than the pointer. A submenu gets native timing and +placement for free. + +**Starting…** is its own state, distinct from Running and Stopped. A cold start +loads the embedding model and can take anywhere from 30 seconds to several +minutes, in silence — a server showing "Starting…" is working, not stuck. + +### Start at Login + +Whether launchd starts the server when you log in. Off after installation. + +Toggling it reloads the launchd agent, which stops the server for a moment — +launchd reads a job's configuration only when the job is loaded, so there is no +way to change this in place. The app restarts the server if it was running, so +the interruption is a few seconds and nothing else. + +This is `RunAtLoad` in the plist, and the menu reads it back from the file +rather than remembering it, so the checkbox cannot drift from what launchd will +actually do. + +### Reset Password… + +Generates a new temporary password for an account and signs out its other +sessions; the next sign-in forces a change. The server does not need to be +stopped — this opens the database directly, so holding the database file is the +authorisation. + +The underlying command prints every account in the database when it does not +recognise an address, which is reasonable at a terminal and not something to put +on screen. The dialog says only "No account with that email address."; the full +output goes to `~/.cix/logs/launcher.log` (mode 0600). + +### Check for Updates… + +cix watches two release streams — `mac/v*` for the app, `server/v*` for the +server — when it starts and at most every 30 minutes after that, and only speaks +up when one of them has something. The menu item does the same check +immediately. + +The two update independently, and the dialog says which one is happening, +because they feel completely different. + +**The server** — with the CLI and llama-server — is downloaded from its release, +checked against `checksums.txt`, unpacked beside the version in use, +signature-verified and test-run, all before anything live is touched. Only then +is the running server stopped, the `current` symlink moved, and the server +started again. The app stays open throughout; the menu bar item shows what it is +doing. + +If the new server does not come back, cix moves the symlink back and starts the +old one, without asking and without downloading anything. "Does not come back" +means the process exited — not that `/health` was slow, because a cold start +loads an embedding model and can legitimately take minutes. + +**The app** is replaced whole, by a detached helper that waits for the launcher +to quit and then swaps the bundle. A process cannot overwrite its own signed +executable and survive, so this half does mean cix closes and reopens. The +server is *not* stopped for it: nothing a running server touches is inside the +bundle any more. + +When both have something, the server goes first — it is the reversible one. + +If the folder containing cix.app is not writable by you, the app half stops +before downloading anything and tells you to install the new version by hand. It +will not ask for an administrator password — an unsigned app requesting admin +rights to overwrite itself is exactly what malware looks like, and it is not a +habit worth teaching. + +> The checksum proves the download arrived intact. It is not a trust anchor: +> `checksums.txt` travels the same path as the image, so anyone who can replace +> one can replace the other. Without a Developer ID signature there is nothing +> stronger available here — a detached signature over the checksums would be +> the next step. + +### Allow Network Access + +Off, the server binds to `127.0.0.1` and only this Mac can reach it. On, it +binds to every interface and any machine that can reach this Mac on its port can +too — useful for querying your index from a laptop or a phone on the same +network, and not something to leave on by accident. Turning it **on** asks for +confirmation; turning it off does not. + +Accounts and API keys apply either way; this does not disable authentication. It +decides whether the login page and the API are reachable at all. + +The setting is `CIX_BIND_ADDR` in `server.env`, and it is read once at process +start, so changing it restarts the server. + +> The server's own default (and every container's) is to bind all interfaces. +> The app deliberately differs: a fresh desktop install starts loopback-only. +> An install that predates this setting keeps whatever it had — the app does not +> silently narrow a server you already rely on. + +**Quit cix** quits the menu bar app only. The server is a launchd agent and +keeps running; use **Stop Server** first if you want it down. + +**Open Dashboard** always targets the port in `server.env`, never the CLI's +configured server — that one may legitimately point at a remote cix. + +### If you already use install-server.sh + +Both use the same launchd label, `com.cix.server`, and `install-server.sh` +points it at a repo checkout. So on a machine that already runs cix from a +clone, the app finds an agent it did not create, pointing at a server it does +not own, holding the port it would use. + +It asks once what to do, shows you the paths it found, and remembers the answer +in `~/.cix/launcher.json`: + +- **Leave It Alone** — observe-only. Status, the provider row and the dashboard + link keep working over HTTP; Start/Stop, autostart and the network toggle are + disabled. The app is then useful alongside a development checkout instead of + fighting it. No runtime is downloaded on this path — watching someone else's + server needs no binaries of our own. Password reset does, because it opens the + database directly, so it offers the download the first time you use it. +- **Take Over** — the app copies the port, API key and database paths out of + the `.env` the old wrapper sourced, backs the old plist and wrapper up under + `~/.cix/backup/`, and installs its own. Re-running `install-server.sh` will + claim the agent back. + +Only those four settings are migrated. The rest of an `install-server.sh` `.env` +— model tuning, GPU layers, tunnel credentials — belongs to that installation, +and importing it wholesale would apply settings you never chose for this app. + +The first-run wizard never runs while a foreign agent is present: it would set +up a second server that cannot bind the port, against a second, empty database. + +## Using the CLI + +Put it on your `PATH` as a **symlink** into the runtime, so it keeps following +updates: + +```bash +ln -sf ~/.cix/runtime/current/cix /usr/local/bin/cix +``` + +Point it at `current`, not at a version directory: `current` is what moves when +the runtime is updated, and old versions are eventually deleted. + +The CLI ships with the server rather than inside the app because it speaks to a +specific server's API, and pinning the two together is the point. + +### Forgotten password, from a terminal + +The menu's **Reset Password…** does this for you. The same thing by hand: + +```bash +~/.cix/runtime/current/cix-server -reset-password you@example.com +``` + +It prints a generated temporary password. Point it at the same `CIX_DATA_DIR` / +`CIX_SQLITE_PATH` the server uses, or it will not find the database: + +```bash +set -a; source ~/.cix/server.env; set +a +~/.cix/runtime/current/cix-server -reset-password you@example.com +``` + +## Building it yourself + +```bash +SERVER_VERSION=0.0.0-dev mac/scripts/build-runtime.sh +MAC_VERSION=0.1.0-dev mac/scripts/build-app.sh +MAC_VERSION=0.1.0-dev mac/scripts/make-dmg.sh +``` + +See [`mac/README.md`](../mac/README.md) for the build pipeline, the signing +order and why each step is the way it is. + +## Uninstalling + +```bash +launchctl bootout "gui/$(id -u)/com.cix.server" +rm -f ~/Library/LaunchAgents/com.cix.server.plist ~/.cix/launchd/run-cix-server.sh +rm -rf /Applications/cix.app +rm -f /usr/local/bin/cix # if you created the symlink +rm -rf ~/.cix # config, runtime, database and index data +``` diff --git a/mac/Info.plist.in b/mac/Info.plist.in new file mode 100644 index 00000000..8dc7ff2b --- /dev/null +++ b/mac/Info.plist.in @@ -0,0 +1,72 @@ + + + + + + CFBundleName + cix + CFBundleDisplayName + cix + CFBundleIdentifier + com.cix.launcher + CFBundleExecutable + cix-launcher + + CFBundleIconFile + cix + CFBundlePackageType + APPL + CFBundleSignature + ???? + CFBundleInfoDictionaryVersion + 6.0 + CFBundleShortVersionString + @SHORT_VERSION@ + CFBundleVersion + @BUNDLE_VERSION@ + LSMinimumSystemVersion + 13.0 + LSUIElement + + LSApplicationCategoryType + public.app-category.developer-tools + NSHighResolutionCapable + + NSHumanReadableCopyright + MIT licensed. https://github.com/dvcdsys/code-index + + diff --git a/mac/README.md b/mac/README.md new file mode 100644 index 00000000..74e34465 --- /dev/null +++ b/mac/README.md @@ -0,0 +1,250 @@ +# mac/ — the cix.app build pipeline + +Everything needed to turn the repo into a signed, drag-to-install macOS app. +User-facing installation and usage documentation lives in +[`doc/MACOS_APP.md`](../doc/MACOS_APP.md); this file is about the build. + +``` +mac/ + Info.plist.in bundle metadata template (placeholder tokens) + Resources/ icon set + DMG artwork — see Resources/README.md + scripts/common.sh shared version derivation + signing helpers + scripts/build-runtime.sh package cix-server + cix + llama/ as a tarball + scripts/build-app.sh assemble + sign mac/dist/cix.app (launcher only) + scripts/sign-app.sh ad-hoc codesign + scripts/make-dmg.sh wrap the app in a drag-to-Applications DMG + dist/ build output (gitignored) +``` + +## Two artefacts, two release streams + +The Mac gets two things, and they are released separately because they are +separate things: + +| Asset | Stream | Contents | Size | +|---|---|---|---| +| `cix--arm64.dmg` | `mac/v*` | the app — `cix-launcher` and its icons, nothing else | ~4 MB | +| `cix-runtime--darwin-arm64.tar.gz` | `server/v*` | `cix-server`, the `cix` CLI, `llama/` | ~35 MB | + +The runtime **is** the server, so it carries the server's version and ships from +the server's tag — the same `server/vX.Y.Z` and the same workflow run that +publishes the Docker images (`release-server.yml`, job `macos-runtime`). A Mac +install on 0.12.8 and a container on 0.12.8 are the same server. That is also +why llama has no version of its own here: a llama bump is a server release, as +it has always been. + +The app installs the runtime into `~/.cix/runtime//` and points a +`current` symlink at it. `cli/launcher/runtime_darwin.go` documents the design; +the short version is that a server update becomes a download and a `rename`, +with the app still running and the previous version kept for rollback. + +The two update independently and neither waits for the other: a server release +reaches a Mac the day it reaches Docker Hub, and the app ships when the app +changes. + +## Build + +```bash +SERVER_VERSION=0.0.0-dev mac/scripts/build-runtime.sh +MAC_VERSION=0.1.0-dev mac/scripts/build-app.sh +MAC_VERSION=0.1.0-dev mac/scripts/make-dmg.sh +``` + +Both default their versions from `git describe`, so the arguments are only +needed when you care what the result is labelled. + +| Variable | Used by | Meaning | +|---|---|---| +| `SERVER_VERSION` | runtime | the runtime's version (default: nearest `server/v*` tag) | +| `CLI_VERSION` | runtime | stamped into `cix` (default: nearest `cli/v*` tag) | +| `MAC_VERSION` | app | the app's version, from the `mac/vX.Y.Z` tag | +| `OUT_DIR` | both | build directory (default `mac/dist`) | +| `SKIP_SERVER_BUILD` | runtime | `1` reuses an existing `server/dist` bundle — much faster when iterating | +| `SKIP_VERIFY` | runtime | `1` skips the tarball round trip; do not use for a release | +| `SKIP_SIGN` | app | `1` skips codesign; the result **will not run** | + +`make-dmg.sh` additionally takes `DMG_LAYOUT` — see "Disk image" below. + +`build-runtime.sh` unpacks the tarball it has just written into a temp +directory and checks it as a stranger would: `codesign --verify --strict` on +every Mach-O, `otool -L` for llama-server's `@rpath` dependencies, and an actual +`cix-server -v`. That check is in the script rather than in a workflow step so a +local build gets it too, and because the failure it catches is silent — a +signature the kernel rejects means SIGKILL with empty stderr, indistinguishable +from a crash. + +It also refuses to ship a payload that does not match its label: if the +extracted server reports a different version from `SERVER_VERSION`, the build +fails. That is usually `SKIP_SERVER_BUILD=1` against a stale `server/dist`. + +## Testing a runtime install without a release + +`CIX_RUNTIME_TARBALL` points the app at a local tarball instead of GitHub. It is +the only way to install a runtime for a build that has no release behind it: + +```bash +CIX_RUNTIME_TARBALL="$PWD/mac/dist/cix-runtime-0.0.0-dev-darwin-arm64.tar.gz" \ + open mac/dist/cix.app +``` + +The install directory is named from the tarball's filename, so it lands in +`~/.cix/runtime/0.0.0-dev/`. + +Update and rollback need a release to update *to*. Build a second runtime at a +higher version, serve it over HTTP with a `checksums.txt` beside it, and point +`CIX_UPDATE_BASE_URL` at a local stand-in for the GitHub API — it overrides the +base URL for **both** streams. Note that the updater filters out any version +containing `-` or `+`, so test versions have to be plain semver — `0.13.0`, not +`0.13.0-test`. + +Rollback is worth exercising deliberately, and a runtime whose `cix-server` is a +signed copy of `/bin/echo` is the cheapest way to do it: it passes every static +check and exits the moment launchd runs it, which is exactly the failure the +rollback exists for. + +Release builds pass the versions in rather than deriving them, because +`git describe` is not reliable on this repo: the three tag streams interleave, +and at least one shipped server tag sits on a commit reachable from no branch. +CI additionally fails the build outright if no `server/v*` and `cli/v*` tag are +reachable from the tagged commit. + +## Three tag streams + +`server/v*`, `cli/v*` and `mac/v*` are released independently. The app version +describes what the app does; the server it manages carries the server version. + +`Info.plist` deliberately records neither the server nor the llama version. It +used to carry `CIXServerVersion`, `CIXCLIVersion` and `CIXLlamaVersion`, and +with the runtime outside the bundle those would be claims about somebody else's +files — wrong the first time either half is updated on its own. What is actually +installed lives in `~/.cix/runtime/current/runtime.json`. + +`mac/v*` no longer needs a `server/v*` tag reachable, because nothing in the app +is stamped from one. `server/v*` still must be cut on `main`, and its +`macos-runtime` job needs a reachable `cli/v*` — the runtime bundles the CLI and +will not ship it stamped `0.0.0-dev`. + +## Signing: ad-hoc, bottom up + +There is no paid Apple Developer membership, so there is no identity to sign +with and nothing to notarize. Ad-hoc signing (`codesign --sign -`) is still +mandatory — on Apple Silicon the kernel refuses to run an unsigned executable at +all. It gets the code running; it does not satisfy Gatekeeper, hence the +first-launch instructions shipped in the DMG. + +`sign-app.sh` signs the launcher and then the bundle; `build-runtime.sh` signs +the runtime's dylibs first, then its executables. Two things about both are not +stylistic: + +- **`xattr -cr` runs first, on every build.** `server/Makefile` records the + failure this avoids: on macOS 26, amfid `SIGKILL`s an ad-hoc-signed binary + whose linked dylibs carry a stale signature or a `com.apple.provenance` + extended attribute — with *empty stderr*. The process simply dies. Every `cp` + into the staging tree recreates those conditions. +- **Dylibs before the executables that load them.** `llama-server` links its + ~35 dylibs by `@rpath`; a dylib whose signature is stale relative to the + executable loading it fails at dyld time, *after* `codesign` has reported + success on the executable itself. This is why `--deep` is not used anywhere + here — Apple deprecated it, and explicit bottom-up ordering is verifiable at + each step. + +`--options runtime` is deliberately not used: the hardened runtime only pays off +alongside notarization, and it risks breaking `llama-server`. + +## Disk image + +The image carries exactly two visible items — `cix.app` and an `/Applications` +symlink — over the designed background, with the red installer icon as the +volume icon. + +A third item, a `READ ME FIRST.txt` carrying the Gatekeeper instructions, was +tried and removed. The block happens *after* the user has dragged the app to +Applications and ejected the image, so the file is on screen exactly when it is +not needed and gone when it is. Those instructions belong in the release body, +next to the download button, and in `doc/MACOS_APP.md`. + +Getting the window laid out means creating a read-write image, mounting it, and +driving Finder over AppleScript — Finder stores the layout in the volume's +`.DS_Store`, which cannot be written into a compressed read-only image +afterwards. That needs a GUI session, which a CI runner may not have, and an +automation-consent prompt would *hang* the build rather than fail it. So the +step runs under a 90-second watchdog and `DMG_LAYOUT` decides what its failure +means: + +| value | behaviour | +|---|---| +| `auto` (default) | try; on failure warn (a `::warning::` under Actions) and ship a valid but unstyled image | +| `require` | try; on failure abort the release | +| `off` | skip the Finder pass entirely | + +Once a release has proved the runner can do it, `require` is the right setting. + +Three details that cost real debugging time: + +- **The volume icon must be installed after the Finder pass**, not staged up + front. Staging it looks like it works — `hdiutil` copies the file, `SetFile` + sets the flag — and then Finder removes both while laying out the window, and + the image ships with a generic disk icon. +- **Finder's item `position` is the top-left of the cell**, not its centre, and + a cell is the icon size plus its label (~124 px at icon size 104). +- **Positions below y≈68 are not clamped, they translate everything.** Asking + for y=22 moved *every* item down by 46 px, sliding the app and the + Applications symlink off the arrow they are aligned with. Both of these only + matter if someone adds an item — worth knowing before trying. + +The volume name is a constant `cix` with no version in it: the layout is stored +per volume and the AppleScript addresses the disk by name, so a version-stamped +volume would make both version-specific for nothing. The version is in the +filename. + +The app is copied with `ditto`, not `cp -R`. `cp -R` drops extended attributes +and signature-relevant metadata, which turns a verified bundle into one that +fails `codesign --verify --strict` after the round trip through the image. + +## Icons + +Real artwork, not placeholders — see [`Resources/README.md`](Resources/README.md) +for the full set, the brand palette and the rules of use. In short: + +- `Resources/cix.iconset/` → `iconutil` → `Contents/Resources/cix.icns` (app) +- `Resources/cix-installer.iconset/` → `iconutil` → the DMG volume icon +- `Resources/menubar/cixTemplate-{18,36}.png` → `Contents/Resources/cixTemplate{,@2x}.png` +- `Resources/dmg/dmg-background{,@2x}.png` → the disk image window background + +The `.icns` files are built rather than committed, so the PNG iconsets stay the +single source of truth. Replacing the artwork means dropping in files with the +same names — no code changes. + +Cream is the app, red is the installer; that inversion is the only thing telling +the two apart in a Downloads folder. The menu-bar glyphs must stay template +images (pure black plus alpha): macOS recolours them for dark mode and for the +pressed state and reads nothing but the alpha channel, so a coloured PNG renders +as a smudge. + +## Apple Silicon only + +Upstream llama.cpp publishes one macOS asset, `macos-arm64`, and +`server/scripts/fetch-llama.sh` refuses anything else. An x86_64 server binary +paired with an arm64 `llama-server` assembles cleanly, signs cleanly, and dies +at the first embedding request — so both build scripts check `uname -m` and stop +with an explanation instead. + +## A server-side bug the split surfaced + +`cix-server` persists its embedding provider config to the database on first +boot and treats the stored blob as authoritative from then on. Two of its fields +are derived from the process rather than chosen by anyone: `bin_dir`, which is +`filepath.Dir(os.Executable())/llama`, and `socket_path`, which is +`/cix-llama-.sock`. + +Frozen at first boot, `bin_dir` means every later boot launches the +`llama-server` of whichever install wrote the row — so the app would keep +running the *previous* runtime's sidecar after an update, and stop working +entirely once that version was pruned. A frozen `socket_path` defeats the +uniqueness it exists for: a new server can find an orphaned `llama-server` +already bound to that name and talk to it instead of spawning its own. + +`embeddings.RefreshOllamaSidecarPaths` re-derives both at boot +(`server/cmd/cix-server/main.go`). Everything a person can choose — the model, +the tuning — still comes from the database untouched. This is not macOS-specific: +the same thing happens to a container whose image layout changes. diff --git a/mac/Resources/README.md b/mac/Resources/README.md new file mode 100644 index 00000000..74f053fa --- /dev/null +++ b/mac/Resources/README.md @@ -0,0 +1,107 @@ +# cix · CodeIndeX — macOS icon set + +Icon assets for the cix desktop app, generated from the design source +`CodeIndeX Icons.dc.html`. Brand palette (from the CIX logo): cream `#F1E6CF` / +`#F7EEDC`, red `#D73E2C`, dark red `#7A2A1E`, ink `#1F140F`. + +The mark is a blocky, pixel-grid magnifier — same block-and-trace construction +as the CIX logotype. It simplifies as it gets smaller: at 128 px+ it carries the +offset "trace" outline and three code lines, at 64 px the outline drops, at +32/16 px only the ring and handle remain. + +> Two adaptations were made when importing this set into the repo. The export +> pipeline could not write `@` into filenames, so retina files arrived as +> `-2x.png`; they have been renamed to `@2x.png`, which is what `iconutil` +> requires. And the menu-bar wiring below was written for AppKit/Swift; this app +> is Go, so the equivalent is `systray.SetTemplateIcon(png, png)` — the +> "must be a template image" rule is unchanged and just as load-bearing. + +Nothing here is used at build time by name-lookup magic: `mac/scripts/build-app.sh` +and `mac/scripts/make-dmg.sh` name these paths explicitly, so replacing the +artwork is a matter of dropping in files with the same names. + +--- + +## 1. `cix.iconset/` — application icon + +The app's own icon: cream background, red magnifier. This is what the user sees +in Finder, in Get Info, and on the mounted disk image. + +| file | px | notes | +|---|---|---| +| `icon_512x512@2x.png` | 1024 | full detail: trace outline + 3 code lines | +| `icon_512x512.png`, `icon_256x256@2x.png` | 512 | full detail | +| `icon_256x256.png`, `icon_128x128@2x.png` | 256 | full detail | +| `icon_128x128.png` | 128 | full detail | +| `icon_32x32@2x.png` | 64 | outline dropped, 2 code lines | +| `icon_32x32.png`, `icon_16x16@2x.png` | 32 | ring + handle only, pixel-snapped | +| `icon_16x16.png` | 16 | ring + handle only, pixel-snapped | + +`build-app.sh` runs `iconutil -c icns` on this directory and installs the result +as `cix.app/Contents/Resources/cix.icns`, matching +`CFBundleIconFile` = `cix` in `mac/Info.plist.in`. + +Do **not** add your own rounded corners, shadow, or padding — the squircle, the +1-px ink border and the safe margin are already baked in at every size. + +## 2. `cix-installer.iconset/` — installer / disk-image icon + +Inverted colourway: red field, cream magnifier, blocky `+` in the bottom-right +corner. In Finder and in Downloads the installer must never be mistaken for the +app itself, so it is a colour inversion rather than a different drawing. + +`make-dmg.sh` converts it and installs it as the volume icon +(`.VolumeIcon.icns` plus the Finder custom-icon flag). + +## 3. `menubar/` — menu bar (status bar) icon + +Monochrome **template** images for the status item — the icon that sits in the +top bar next to the clock. Pure black + alpha; macOS itself inverts it for dark +menu bars and tints it while the menu is open. + +| file | use | +|---|---| +| `cixTemplate-18.png` | @1x — the standard 18×18 status-item glyph | +| `cixTemplate-36.png` | @2x for the 18 px glyph | +| `cixTemplate-44.png` | @2x for a 22 px glyph (roomier bar layouts) | +| `cixTemplate-88.png` | @4x / source for tracing a vector template | + +The 18 and 36 px files are copied into the bundle; 44 and 88 are kept here as +part of the source set. + +Never recolour the glyph in code, and never use the red app icon in the menu +bar — at 18 px colour turns to mush and breaks dark mode. + +## 4. `dmg/` — disk image window background + +`dmg-background.png` (640×420) and `@2x` (1280×840): cream sheet, red rule at +the top, CIX wordmark, and a blocky arrow pointing right — from `cix.app` +toward `Applications`. Icon placeholders are intentionally **not** drawn; +Finder puts the real icons on top. + +The layout the arrow points at, implemented in `make-dmg.sh`: + +- window content size 640×420 +- `cix.app` centre at **(175, 250)** +- `Applications` symlink centre at **(465, 250)** +- icon size 104, arranged by position, toolbar/sidebar/status bar hidden + +## 5. `web/` — not imported + +The archive also contains favicons and an animated installer icon for the +download page. Those belong to `site/`, not to the app bundle, and were left out +of this directory so that everything here is something the build actually +consumes. + +--- + +## Rules of use + +1. **Cream = the app, red = the installer.** Never swap them; that distinction + is the only thing separating the two files in a Downloads folder. +2. **Menu bar is always the black template**, never the coloured icon. +3. Keep the pixel grid: scale only by whole factors (×2, ×4). Smooth-scaling the + small sizes destroys the blocky construction. +4. Nothing here needs an extra shadow, gradient, gloss, or corner mask. +5. Need a size that is not here? Regenerate from the design source rather than + upscaling a PNG. diff --git a/mac/Resources/cix-installer.iconset/icon_128x128.png b/mac/Resources/cix-installer.iconset/icon_128x128.png new file mode 100644 index 00000000..cd33fd52 Binary files /dev/null and b/mac/Resources/cix-installer.iconset/icon_128x128.png differ diff --git a/mac/Resources/cix-installer.iconset/icon_128x128@2x.png b/mac/Resources/cix-installer.iconset/icon_128x128@2x.png new file mode 100644 index 00000000..b3d11d82 Binary files /dev/null and b/mac/Resources/cix-installer.iconset/icon_128x128@2x.png differ diff --git a/mac/Resources/cix-installer.iconset/icon_16x16.png b/mac/Resources/cix-installer.iconset/icon_16x16.png new file mode 100644 index 00000000..a1446333 Binary files /dev/null and b/mac/Resources/cix-installer.iconset/icon_16x16.png differ diff --git a/mac/Resources/cix-installer.iconset/icon_16x16@2x.png b/mac/Resources/cix-installer.iconset/icon_16x16@2x.png new file mode 100644 index 00000000..a41219d0 Binary files /dev/null and b/mac/Resources/cix-installer.iconset/icon_16x16@2x.png differ diff --git a/mac/Resources/cix-installer.iconset/icon_256x256.png b/mac/Resources/cix-installer.iconset/icon_256x256.png new file mode 100644 index 00000000..b3d11d82 Binary files /dev/null and b/mac/Resources/cix-installer.iconset/icon_256x256.png differ diff --git a/mac/Resources/cix-installer.iconset/icon_256x256@2x.png b/mac/Resources/cix-installer.iconset/icon_256x256@2x.png new file mode 100644 index 00000000..c513a343 Binary files /dev/null and b/mac/Resources/cix-installer.iconset/icon_256x256@2x.png differ diff --git a/mac/Resources/cix-installer.iconset/icon_32x32.png b/mac/Resources/cix-installer.iconset/icon_32x32.png new file mode 100644 index 00000000..a41219d0 Binary files /dev/null and b/mac/Resources/cix-installer.iconset/icon_32x32.png differ diff --git a/mac/Resources/cix-installer.iconset/icon_32x32@2x.png b/mac/Resources/cix-installer.iconset/icon_32x32@2x.png new file mode 100644 index 00000000..d6351b48 Binary files /dev/null and b/mac/Resources/cix-installer.iconset/icon_32x32@2x.png differ diff --git a/mac/Resources/cix-installer.iconset/icon_512x512.png b/mac/Resources/cix-installer.iconset/icon_512x512.png new file mode 100644 index 00000000..c513a343 Binary files /dev/null and b/mac/Resources/cix-installer.iconset/icon_512x512.png differ diff --git a/mac/Resources/cix-installer.iconset/icon_512x512@2x.png b/mac/Resources/cix-installer.iconset/icon_512x512@2x.png new file mode 100644 index 00000000..b38417c4 Binary files /dev/null and b/mac/Resources/cix-installer.iconset/icon_512x512@2x.png differ diff --git a/mac/Resources/cix.iconset/icon_128x128.png b/mac/Resources/cix.iconset/icon_128x128.png new file mode 100644 index 00000000..0b57332e Binary files /dev/null and b/mac/Resources/cix.iconset/icon_128x128.png differ diff --git a/mac/Resources/cix.iconset/icon_128x128@2x.png b/mac/Resources/cix.iconset/icon_128x128@2x.png new file mode 100644 index 00000000..3f3e5dff Binary files /dev/null and b/mac/Resources/cix.iconset/icon_128x128@2x.png differ diff --git a/mac/Resources/cix.iconset/icon_16x16.png b/mac/Resources/cix.iconset/icon_16x16.png new file mode 100644 index 00000000..d3bd7d89 Binary files /dev/null and b/mac/Resources/cix.iconset/icon_16x16.png differ diff --git a/mac/Resources/cix.iconset/icon_16x16@2x.png b/mac/Resources/cix.iconset/icon_16x16@2x.png new file mode 100644 index 00000000..9cc22e7a Binary files /dev/null and b/mac/Resources/cix.iconset/icon_16x16@2x.png differ diff --git a/mac/Resources/cix.iconset/icon_256x256.png b/mac/Resources/cix.iconset/icon_256x256.png new file mode 100644 index 00000000..3f3e5dff Binary files /dev/null and b/mac/Resources/cix.iconset/icon_256x256.png differ diff --git a/mac/Resources/cix.iconset/icon_256x256@2x.png b/mac/Resources/cix.iconset/icon_256x256@2x.png new file mode 100644 index 00000000..d8deceec Binary files /dev/null and b/mac/Resources/cix.iconset/icon_256x256@2x.png differ diff --git a/mac/Resources/cix.iconset/icon_32x32.png b/mac/Resources/cix.iconset/icon_32x32.png new file mode 100644 index 00000000..9cc22e7a Binary files /dev/null and b/mac/Resources/cix.iconset/icon_32x32.png differ diff --git a/mac/Resources/cix.iconset/icon_32x32@2x.png b/mac/Resources/cix.iconset/icon_32x32@2x.png new file mode 100644 index 00000000..0b2896d6 Binary files /dev/null and b/mac/Resources/cix.iconset/icon_32x32@2x.png differ diff --git a/mac/Resources/cix.iconset/icon_512x512.png b/mac/Resources/cix.iconset/icon_512x512.png new file mode 100644 index 00000000..d8deceec Binary files /dev/null and b/mac/Resources/cix.iconset/icon_512x512.png differ diff --git a/mac/Resources/cix.iconset/icon_512x512@2x.png b/mac/Resources/cix.iconset/icon_512x512@2x.png new file mode 100644 index 00000000..84ca551d Binary files /dev/null and b/mac/Resources/cix.iconset/icon_512x512@2x.png differ diff --git a/mac/Resources/dmg/dmg-background.png b/mac/Resources/dmg/dmg-background.png new file mode 100644 index 00000000..4d5a2a84 Binary files /dev/null and b/mac/Resources/dmg/dmg-background.png differ diff --git a/mac/Resources/dmg/dmg-background@2x.png b/mac/Resources/dmg/dmg-background@2x.png new file mode 100644 index 00000000..aa6f791b Binary files /dev/null and b/mac/Resources/dmg/dmg-background@2x.png differ diff --git a/mac/Resources/menubar/cixTemplate-18.png b/mac/Resources/menubar/cixTemplate-18.png new file mode 100644 index 00000000..2f04998d Binary files /dev/null and b/mac/Resources/menubar/cixTemplate-18.png differ diff --git a/mac/Resources/menubar/cixTemplate-36.png b/mac/Resources/menubar/cixTemplate-36.png new file mode 100644 index 00000000..0b3deb63 Binary files /dev/null and b/mac/Resources/menubar/cixTemplate-36.png differ diff --git a/mac/Resources/menubar/cixTemplate-44.png b/mac/Resources/menubar/cixTemplate-44.png new file mode 100644 index 00000000..7a08e070 Binary files /dev/null and b/mac/Resources/menubar/cixTemplate-44.png differ diff --git a/mac/Resources/menubar/cixTemplate-88.png b/mac/Resources/menubar/cixTemplate-88.png new file mode 100644 index 00000000..181e3e54 Binary files /dev/null and b/mac/Resources/menubar/cixTemplate-88.png differ diff --git a/mac/scripts/build-app.sh b/mac/scripts/build-app.sh new file mode 100755 index 00000000..b4e70e5a --- /dev/null +++ b/mac/scripts/build-app.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# build-app.sh — assemble mac/dist/cix.app. +# +# Usage: mac/scripts/build-app.sh +# +# Environment (all optional; CI sets the version explicitly): +# MAC_VERSION version of the .app itself, from the mac/vX.Y.Z tag +# OUT_DIR build directory (default: mac/dist) +# SKIP_SIGN "1" skips codesign (leaves an unrunnable bundle; debug only) +# +# The .app contains the launcher and nothing else that runs. cix-server, the cix +# CLI and llama-server are built and packaged by build-runtime.sh, published +# alongside this bundle under the same tag, and installed into ~/.cix/runtime/ +# by the app itself — see cli/launcher/runtime_darwin.go for why. +# +# One consequence worth stating: this bundle carries no server version, so +# Info.plist records none. The truth about what is installed lives in the +# runtime's own runtime.json, which can change without this app changing. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=mac/scripts/common.sh +source "$REPO_ROOT/mac/scripts/common.sh" +cd "$REPO_ROOT" + +# The launcher itself is architecture-agnostic Go, but the app it manages is +# Apple Silicon only, so a bundle built anywhere else could never do its job. +require_apple_silicon "build-app" + +OUT_DIR="${OUT_DIR:-$REPO_ROOT/mac/dist}" +APP="$OUT_DIR/cix.app" +MAC_VERSION="${MAC_VERSION:-dev}" + +echo "build-app: app=$MAC_VERSION" + +# --- 1. launcher ------------------------------------------------------------ +# -w without -s: govulncheck -mode=binary needs the Go symbol table. Same +# reasoning, and same flags, as .github/workflows/release-cli.yml. +echo "build-app: building cix-launcher" +mkdir -p "$OUT_DIR/stage" +(cd cli && go build \ + -trimpath \ + -ldflags "-w -X 'main.version=${MAC_VERSION}'" \ + -o "$OUT_DIR/stage/cix-launcher" ./launcher) + +# --- 2. assemble ------------------------------------------------------------ +# Rebuild the bundle from scratch. `cp` merges into an existing tree, so an +# incremental assembly accumulates artefacts from every previous build — which +# now includes the server, CLI and llama/ this bundle no longer ships. +echo "build-app: assembling $APP" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + +cp "$OUT_DIR/stage/cix-launcher" "$APP/Contents/MacOS/cix-launcher" +rm -rf "$OUT_DIR/stage" + +# The app icon is built from the iconset rather than committed as a binary, so +# the PNGs stay the single source of truth. CFBundleIconFile in Info.plist.in +# names this file without its extension, as macOS expects. +iconutil -c icns mac/Resources/cix.iconset -o "$APP/Contents/Resources/cix.icns" + +# Menu-bar glyphs: 1x and 2x of the 18 px status item. These must stay template +# images — pure black plus alpha — because macOS recolours them for dark mode +# and for the pressed state and ignores everything but the alpha channel. +cp mac/Resources/menubar/cixTemplate-18.png "$APP/Contents/Resources/cixTemplate.png" +cp mac/Resources/menubar/cixTemplate-36.png "$APP/Contents/Resources/cixTemplate@2x.png" + +sed \ + -e "s|@SHORT_VERSION@|${MAC_VERSION}|g" \ + -e "s|@BUNDLE_VERSION@|${MAC_VERSION}|g" \ + mac/Info.plist.in > "$APP/Contents/Info.plist" + +# Catch an unsubstituted token before it ships as a literal @TOKEN@ version. +if grep -q '@[A-Z_]*@' "$APP/Contents/Info.plist"; then + echo "build-app: unsubstituted token left in Info.plist:" >&2 + grep -n '@[A-Z_]*@' "$APP/Contents/Info.plist" >&2 + exit 1 +fi +plutil -lint "$APP/Contents/Info.plist" + +# Classic-era metadata. LaunchServices no longer requires PkgInfo, but codesign +# and some archive tooling still expect it beside Info.plist, and it costs 8 bytes. +printf 'APPL????' > "$APP/Contents/PkgInfo" + +# --- 3. sign ---------------------------------------------------------------- +if [[ "${SKIP_SIGN:-0}" == "1" ]]; then + echo "build-app: SKIP_SIGN=1 — bundle is UNSIGNED and will be killed on launch" +else + mac/scripts/sign-app.sh "$APP" +fi + +echo "build-app: ready — $APP" +du -sh "$APP" | sed 's/^/build-app: size /' diff --git a/mac/scripts/build-runtime.sh b/mac/scripts/build-runtime.sh new file mode 100755 index 00000000..f8a326b4 --- /dev/null +++ b/mac/scripts/build-runtime.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# build-runtime.sh — package the cix runtime: the server, the CLI, and the +# Metal llama-server they need. +# +# Usage: mac/scripts/build-runtime.sh +# +# Environment (all optional; CI sets the versions explicitly): +# SERVER_VERSION the runtime's version (default: nearest server/v* tag) +# CLI_VERSION stamped into cix (default: nearest cli/v* tag) +# OUT_DIR build directory (default: mac/dist) +# SKIP_SERVER_BUILD "1" reuses an existing server/dist bundle +# SKIP_VERIFY "1" skips the extract-and-check round trip (not advised) +# +# The runtime IS the server, so it carries the server's version and ships from +# the server's tag stream — the same `server/vX.Y.Z` tag that publishes the +# Docker images, built by the same workflow run. A Mac install and a container +# on the same version are the same server. The app has its own `mac/v*` stream +# and its own version, because it is a different thing that changes for +# different reasons. +# +# Why this is not inside the .app +# ------------------------------- +# It used to be. An app carrying all four binaries meant every server update +# replaced a 102 MB application through a swap trampoline that had to quit the +# launcher, move the bundle aside, move the new one in, and reopen itself. The +# runtime is 90% of that weight and nearly all of the churn. Split out, it is a +# payload the launcher installs into ~/.cix/runtime// and swaps by +# renaming a symlink, without the app going anywhere. +# +# Why the CLI travels here and not in the .app +# -------------------------------------------- +# It speaks a specific server's API, so pinning the two together is the point. +# /usr/local/bin/cix is a symlink into ~/.cix/runtime/current/, which means it +# follows updates without anyone touching /usr/local. +# +# Why llama travels with the server +# --------------------------------- +# cix-server resolves its sidecar at filepath.Dir(os.Executable())/llama. Ship +# them as siblings and CIX_LLAMA_BIN_DIR never has to be set — the same +# invariant server/Makefile's bundle target already relies on. Separating them +# would mean carrying that environment variable forever. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=mac/scripts/common.sh +source "$REPO_ROOT/mac/scripts/common.sh" +cd "$REPO_ROOT" + +require_apple_silicon "build-runtime" + +OUT_DIR="${OUT_DIR:-$REPO_ROOT/mac/dist}" +SERVER_BUNDLE="$REPO_ROOT/server/dist/cix-darwin-arm64" + +SERVER_VERSION="${SERVER_VERSION:-$(describe_or_dev 'server/v*' 'server/v')}" +CLI_VERSION="${CLI_VERSION:-$(describe_or_dev 'cli/v*' 'cli/v')}" +LLAMA_VERSION="$(read_llama_version "$REPO_ROOT")" + +# The directory name inside the tarball is also the name the launcher strips on +# extraction, and the version in it becomes the directory under +# ~/.cix/runtime/. Both are part of the format, not cosmetic. +RUNTIME_NAME="cix-runtime-$SERVER_VERSION" +STAGE="$OUT_DIR/runtime/$RUNTIME_NAME" +TARBALL="$OUT_DIR/$RUNTIME_NAME-darwin-arm64.tar.gz" + +echo "build-runtime: server=$SERVER_VERSION cli=$CLI_VERSION llama=$LLAMA_VERSION" + +# --- 1. server + bundled llama --------------------------------------------- +# LLAMA_STRICT=1: a release must not accept an unpinned upstream asset. See the +# strict-mode notes in server/scripts/fetch-llama.sh. +if [[ "${SKIP_SERVER_BUILD:-0}" == "1" ]]; then + echo "build-runtime: SKIP_SERVER_BUILD=1 — reusing $SERVER_BUNDLE" + [[ -x "$SERVER_BUNDLE/cix-server" ]] || { echo "build-runtime: no prebuilt server at $SERVER_BUNDLE" >&2; exit 1; } +else + # `make bundle` → `build` → `dashboard-build` runs npm scripts but never + # installs. On a clean checkout (which is every CI run) that surfaces as a + # missing-binary error from npm rather than "you need to install deps". + if [[ ! -d server/dashboard/node_modules ]]; then + echo "build-runtime: installing dashboard dependencies" + make -C server dashboard-deps + fi + make -C server bundle SERVER_VERSION="$SERVER_VERSION" LLAMA_STRICT=1 +fi + +# --- 2. cix CLI ------------------------------------------------------------- +# -w without -s: govulncheck -mode=binary needs the Go symbol table. Same +# reasoning, and same flags, as .github/workflows/release-cli.yml. +echo "build-runtime: building cix CLI" +mkdir -p "$OUT_DIR/stage" +(cd cli && go build \ + -trimpath \ + -ldflags "-w -X 'github.com/dvcdsys/code-index/cli/cmd.Version=${CLI_VERSION}'" \ + -o "$OUT_DIR/stage/cix" .) + +# --- 3. assemble ------------------------------------------------------------ +# Rebuild from scratch. `cp` merges into an existing tree, so an incremental +# assembly accumulates artefacts from every previous build — the exact failure +# server/Makefile's `bundle` target had to fix for llama/. +echo "build-runtime: assembling $STAGE" +rm -rf "$OUT_DIR/runtime" +mkdir -p "$STAGE" + +cp "$SERVER_BUNDLE/cix-server" "$STAGE/cix-server" +cp -R "$SERVER_BUNDLE/llama" "$STAGE/llama" +cp "$OUT_DIR/stage/cix" "$STAGE/cix" +rm -rf "$OUT_DIR/stage" + +# The manifest is what the menu reads to show what is installed. The alternative +# — exec'ing each binary with -v — costs three process spawns per menu open and +# still cannot report the llama version, which no binary here prints in a form +# worth parsing. +# +# Checked at the source rather than linted afterwards: every value below is +# interpolated straight into JSON, so the thing worth rejecting is a version +# string carrying a quote or a backslash, not a malformed file. `git describe` +# output and the Makefile pin are the only inputs, and neither should ever +# contain anything outside this set. +for v in "$SERVER_VERSION" "$CLI_VERSION" "$LLAMA_VERSION"; do + if [[ ! "$v" =~ ^[A-Za-z0-9._+-]+$ ]]; then + echo "build-runtime: version string is not safe to embed in JSON: '$v'" >&2 + exit 1 + fi +done + +cat > "$STAGE/runtime.json" <&2 + exit 1 +fi + +# --- 6. round trip ---------------------------------------------------------- +# The riskiest step in this whole pipeline is the one that looks like it cannot +# fail: does an ad-hoc signature survive tar → extract? If it does not, nothing +# says so — the binary is SIGKILLed on exec with empty stderr, indistinguishable +# from a crash. So unpack what was just written and check it as a stranger would. +if [[ "${SKIP_VERIFY:-0}" == "1" ]]; then + echo "build-runtime: SKIP_VERIFY=1 — tarball NOT round-tripped" +else + echo "build-runtime: verifying the tarball round trip" + CHECK_DIR="$(mktemp -d)" + trap 'rm -rf "$CHECK_DIR"' EXIT + + tar -xzf "$TARBALL" -C "$CHECK_DIR" + EXTRACTED="$CHECK_DIR/$RUNTIME_NAME" + + for target in cix-server cix llama/llama-server; do + codesign --verify --strict --verbose=2 "$EXTRACTED/$target" + done + for lib in "$EXTRACTED"/llama/*.dylib; do + codesign --verify --strict "$lib" + done + + check_llama_rpath_deps "$EXTRACTED/llama" + + # Actually run one. codesign --verify checks the signature is well-formed; + # only exec proves the kernel agrees, which is the thing that fails silently. + got="$("$EXTRACTED/cix-server" -v)" + echo "build-runtime: extracted server reports: $got" + case "$got" in + *"$SERVER_VERSION"*) ;; + *) + echo "build-runtime: extracted server reports '$got', expected $SERVER_VERSION" >&2 + echo "build-runtime: the payload does not match its label — $SERVER_BUNDLE is stale (a SKIP_SERVER_BUILD=1 build against an older commit?). Rebuild it." >&2 + exit 1 + ;; + esac + + "$EXTRACTED/cix" --version >/dev/null +fi + +echo "build-runtime: ready — $TARBALL" +du -sh "$TARBALL" | sed 's/^/build-runtime: size /' diff --git a/mac/scripts/common.sh b/mac/scripts/common.sh new file mode 100644 index 00000000..8a2dfa12 --- /dev/null +++ b/mac/scripts/common.sh @@ -0,0 +1,100 @@ +# shellcheck shell=bash +# common.sh — shared ground for the mac build scripts. Sourced, never executed. +# +# build-app.sh and build-runtime.sh produce two halves of one release: they are +# published under the same mac/vX.Y.Z tag and the launcher assumes they agree +# about which server, CLI and llama went into them. Deriving those versions +# twice, in two scripts, is how they would quietly stop agreeing — so it happens +# here, once. + +# --- host check ------------------------------------------------------------- +# Upstream llama.cpp publishes one macOS asset, macos-arm64, and +# server/scripts/fetch-llama.sh hard-refuses anything else. Building the Go +# binaries for x86_64 and pairing them with an arm64 llama-server produces a +# tree that assembles cleanly, signs cleanly, and dies at the first embedding — +# so refuse up front, where the message can say why. +require_apple_silicon() { + local who="${1:-build}" + if [[ "$(uname -s)" != "Darwin" ]]; then + echo "$who: macOS only (got $(uname -s))" >&2 + exit 1 + fi + if [[ "$(uname -m)" != "arm64" ]]; then + echo "$who: Apple Silicon only — upstream llama.cpp ships no macOS x86_64 asset (got $(uname -m))" >&2 + exit 1 + fi +} + +# --- versions --------------------------------------------------------------- +# Versions are passed in by CI rather than derived here because `git describe` +# cannot be trusted on this repo: the tag streams interleave, and server/v0.12.8 +# sits on a commit reachable from no branch. These fallbacks exist for local +# builds, where being obviously a development build is the correct answer. +describe_or_dev() { + local pattern="$1" prefix="$2" v + v="$(git describe --tags --match "$pattern" 2>/dev/null | sed "s|^$prefix||")" || true + printf '%s' "${v:-0.0.0-dev}" +} + +# The llama version is not a git tag — it is pinned in the Makefile that fetches +# the upstream release, which is the only place that knows what was downloaded. +read_llama_version() { + local repo_root="$1" v + v="$(sed -n 's/^LLAMA_VERSION[[:space:]]*?=[[:space:]]*//p' "$repo_root/server/Makefile" | head -n1)" + if [[ -z "$v" ]]; then + echo "could not read LLAMA_VERSION from $repo_root/server/Makefile" >&2 + exit 1 + fi + printf '%s' "$v" +} + +# --- signing ---------------------------------------------------------------- +# Ad-hoc (`--sign -`) is not a Gatekeeper credential and never will be without a +# paid Developer ID. It is still mandatory: on Apple Silicon the kernel refuses +# to run an executable carrying no signature at all. +sign_adhoc() { + local target="$1" + [[ -e "$target" ]] || { echo "sign: missing signing target: $target" >&2; exit 1; } + codesign --force --sign - "$target" +} + +# Sign a directory of Mach-O code bottom-up: libraries first, then the +# executables that load them. +# +# llama-server links its dylibs by @rpath. If a dylib's signature is stale +# relative to the executable loading it, the load fails at dyld time — after +# codesign has already reported success on the executable itself. Signing in +# this order is what prevents that, and it is why --deep (deprecated, and +# unreliable for loose executables outside nested bundles) is not used anywhere +# in this pipeline. +sign_llama_dir() { + local dir="$1" lib + local -a dylibs + shopt -s nullglob + dylibs=("$dir"/*.dylib) + shopt -u nullglob + if [[ ${#dylibs[@]} -eq 0 ]]; then + echo "sign: no dylibs found under $dir — the tree is incomplete" >&2 + exit 1 + fi + echo "sign: signing ${#dylibs[@]} dylib(s) in $(basename "$dir")" + for lib in "${dylibs[@]}"; do + sign_adhoc "$lib" + done + sign_adhoc "$dir/llama-server" +} + +# --- dependency check ------------------------------------------------------- +# Every @rpath dependency of llama-server must sit beside it. A missing one only +# fails at dyld load time, on the user's machine, with an abort — which is how +# the b10238 library-layout change was found. +check_llama_rpath_deps() { + local dir="$1" dep missing=0 + for dep in $(otool -L "$dir/llama-server" | awk '/@rpath\//{sub(/^.*@rpath\//,"",$1); print $1}'); do + if [[ ! -e "$dir/$dep" ]]; then + echo "llama-server dependency not present: $dep" >&2 + missing=1 + fi + done + return "$missing" +} diff --git a/mac/scripts/make-dmg.sh b/mac/scripts/make-dmg.sh new file mode 100755 index 00000000..80f75a90 --- /dev/null +++ b/mac/scripts/make-dmg.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# make-dmg.sh — wrap a built cix.app in a drag-to-Applications disk image. +# +# Usage: mac/scripts/make-dmg.sh [path/to/cix.app] +# +# Environment: +# MAC_VERSION version string for the DMG filename (default: dev) +# OUT_DIR output directory (default: mac/dist) +# DMG_LAYOUT auto (default) | require | off — see "Window layout" below +# +# The image carries three things beyond the app itself: the installer icon as +# the volume icon, the designed window background, and a Finder layout that +# puts cix.app and the Applications symlink where the background's arrow points. +# +# Two visible items, and deliberately no third. A "READ ME FIRST.txt" with the +# Gatekeeper instructions was tried and removed: the block happens after the +# user has dragged the app to Applications and ejected the image, so the file is +# on screen exactly when it is not needed and gone when it is. Those +# instructions belong where people actually are at that moment — the release +# body next to the download button, and doc/MACOS_APP.md. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT_DIR="${OUT_DIR:-$REPO_ROOT/mac/dist}" +APP="${1:-$OUT_DIR/cix.app}" +MAC_VERSION="${MAC_VERSION:-dev}" +DMG="$OUT_DIR/cix-$MAC_VERSION-arm64.dmg" +DMG_LAYOUT="${DMG_LAYOUT:-auto}" + +# The volume name is deliberately constant, with no version in it. Finder stores +# the window layout in a .DS_Store keyed to this volume, and the AppleScript +# below has to address `disk "cix"` by name — a version-stamped volume would +# make both version-specific for no benefit. The version lives in the filename, +# where people actually look for it. +VOLNAME="cix" + +# Layout from mac/Resources/README.md — these coordinates are where the arrow in +# the background image points. Changing one without the other breaks the design. +WIN_W=640 +WIN_H=420 +ICON_SIZE=104 +APP_X=175 +APP_Y=250 +LINK_X=465 +LINK_Y=250 +# Before adding an item here, two Finder behaviours, both measured rather than +# documented: `position` is the TOP-LEFT of the item cell (icon size plus label, +# ~124 px), not its centre; and a y below roughly 68 is not clamped for that one +# item — Finder translates EVERY item down by the difference, sliding the app +# and the symlink off the arrow they are aligned with. +# +# Finder's window `bounds` are {left, top, right, bottom} on screen, so the +# right/bottom edges are origin+size — not the size itself. Getting this wrong +# yields a window smaller than the background, which Finder then crops. +WIN_LEFT=160 +WIN_TOP=120 +WIN_RIGHT=$((WIN_LEFT + WIN_W)) +WIN_BOTTOM=$((WIN_TOP + WIN_H)) + +if [[ ! -d "$APP" ]]; then + echo "make-dmg: no such bundle: $APP" >&2 + exit 1 +fi +case "$DMG_LAYOUT" in + auto|require|off) ;; + *) echo "make-dmg: DMG_LAYOUT must be auto, require or off (got '$DMG_LAYOUT')" >&2; exit 2 ;; +esac + +STAGE="$(mktemp -d -t cix-dmg-XXXXXX)" +RW_DMG="$(mktemp -u -t cix-dmg-rw-XXXXXX).dmg" +ATTACHED_DEV="" +cleanup() { + [[ -n "$ATTACHED_DEV" ]] && hdiutil detach "$ATTACHED_DEV" -force -quiet 2>/dev/null || true + rm -rf "$STAGE" + rm -f "$RW_DMG" +} +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# Stage the volume contents +# --------------------------------------------------------------------------- + +# ditto, not cp -R: it preserves extended attributes and signature-relevant +# metadata. cp -R silently drops some of it, which turns a verified bundle into +# one that fails `codesign --verify --strict` after the round trip. +ditto "$APP" "$STAGE/cix.app" +ln -s /Applications "$STAGE/Applications" + +mkdir -p "$STAGE/.background" +cp "$REPO_ROOT/mac/Resources/dmg/dmg-background.png" "$STAGE/.background/dmg-background.png" +cp "$REPO_ROOT/mac/Resources/dmg/dmg-background@2x.png" "$STAGE/.background/dmg-background@2x.png" + +# The volume icon is installed later, after Finder has finished with the volume +# — see the note above the "Volume icon" step below. + + +# --------------------------------------------------------------------------- +# Build a read-write image, dress it, then compress +# --------------------------------------------------------------------------- +# The layout has to be applied to a mounted, writable volume: Finder stores it +# in the volume's .DS_Store, which cannot be written into a compressed +# read-only image after the fact. +echo "make-dmg: creating writable image" +hdiutil create \ + -volname "$VOLNAME" \ + -srcfolder "$STAGE" \ + -fs HFS+ \ + -format UDRW \ + -quiet \ + "$RW_DMG" + +echo "make-dmg: mounting" +ATTACH_OUT="$(hdiutil attach -readwrite -noverify -noautoopen "$RW_DMG")" +ATTACHED_DEV="$(printf '%s\n' "$ATTACH_OUT" | awk '/^\/dev\// { print $1; exit }')" +MOUNT_POINT="$(printf '%s\n' "$ATTACH_OUT" | sed -n 's|.*\(/Volumes/.*\)$|\1|p' | tail -1)" +if [[ -z "$ATTACHED_DEV" || -z "$MOUNT_POINT" ]]; then + echo "make-dmg: could not determine the mount point:" >&2 + printf '%s\n' "$ATTACH_OUT" >&2 + exit 1 +fi +echo "make-dmg: mounted $ATTACHED_DEV at $MOUNT_POINT" + +# --- Window layout ---------------------------------------------------------- +# This is the one step that needs Finder, and Finder needs a real GUI session. +# On a CI runner that may be unavailable, or blocked by an automation-consent +# prompt that nobody is there to answer — which would hang the build rather +# than fail it. So: run it under a watchdog, and treat the outcome according to +# DMG_LAYOUT. +# +# auto try; on failure warn and ship an unstyled but valid DMG (default) +# require try; on failure abort the build +# off skip entirely +layout_applied=0 +if [[ "$DMG_LAYOUT" == "off" ]]; then + echo "make-dmg: DMG_LAYOUT=off — skipping Finder layout" +else + echo "make-dmg: applying Finder layout" + read -r -d '' LAYOUT_SCRIPT </tmp/cix-dmg-layout.log 2>&1 & + osa_pid=$! + ( sleep 90; kill -9 "$osa_pid" 2>/dev/null ) & + watchdog_pid=$! + wait "$osa_pid" + osa_rc=$? + kill "$watchdog_pid" 2>/dev/null + wait "$watchdog_pid" 2>/dev/null + set -e + + if [[ $osa_rc -eq 0 ]]; then + layout_applied=1 + echo "make-dmg: layout applied" + else + echo "make-dmg: WARNING — Finder layout failed (exit $osa_rc). The DMG is valid but unstyled." >&2 + sed 's/^/make-dmg: /' /tmp/cix-dmg-layout.log >&2 || true + # Make the degradation visible in the run summary, not just buried in a + # log nobody opens when the job is green. + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then + echo "::warning title=DMG shipped unstyled::Finder layout failed on the runner; the disk image has no background or icon positions." + fi + if [[ "$DMG_LAYOUT" == "require" ]]; then + echo "make-dmg: DMG_LAYOUT=require — aborting" >&2 + exit 1 + fi + fi +fi + +# --- Volume icon ------------------------------------------------------------ +# The red installer colourway, so the mounted disk and the app inside it are +# never confused for each other in Finder. +# +# This has to happen AFTER the Finder layout, not in the staging directory. +# Staging it up front looks like it works — hdiutil copies the file and SetFile +# sets the flag — but Finder then removes both while laying the window out, and +# the finished image ships with a generic disk icon. Verified by bisecting the +# steps: create/attach/SetFile/detach/convert preserves the icon; adding the +# Finder pass is what loses it. +echo "make-dmg: installing volume icon" +iconutil -c icns "$REPO_ROOT/mac/Resources/cix-installer.iconset" -o "$MOUNT_POINT/.VolumeIcon.icns" +# Without the custom-icon flag, .VolumeIcon.icns is just a hidden file. +SetFile -a C "$MOUNT_POINT" + +sync +echo "make-dmg: unmounting" +hdiutil detach "$ATTACHED_DEV" -quiet +ATTACHED_DEV="" + +echo "make-dmg: compressing" +rm -f "$DMG" +hdiutil convert "$RW_DMG" -format UDZO -imagekey zlib-level=9 -quiet -o "$DMG" + +echo "make-dmg: verifying image" +hdiutil verify "$DMG" + +# Ad-hoc sign the image too. It buys no Gatekeeper trust, but it makes tampering +# after publication detectable with codesign rather than only by checksum. +codesign --force --sign - "$DMG" + +if [[ $layout_applied -eq 1 ]]; then + echo "make-dmg: ok (styled) — $DMG" +else + echo "make-dmg: ok (unstyled) — $DMG" +fi +shasum -a 256 "$DMG" diff --git a/mac/scripts/sign-app.sh b/mac/scripts/sign-app.sh new file mode 100755 index 00000000..2458f73e --- /dev/null +++ b/mac/scripts/sign-app.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# sign-app.sh — ad-hoc sign cix.app, innermost code first. +# +# Usage: mac/scripts/sign-app.sh +# +# Why ad-hoc and not Developer ID +# ------------------------------- +# This is an open-source project with no paid Apple Developer membership, so +# there is no identity to sign with and nothing to notarize. Ad-hoc signing +# (`--sign -`) is still mandatory: on Apple Silicon every executable must carry +# *some* valid signature or the kernel refuses to run it outright. Ad-hoc gets +# the code running; it does not satisfy Gatekeeper, which is why the DMG ships +# with first-run instructions. +# +# Why not --deep +# -------------- +# `codesign --deep` is deprecated by Apple and unreliable in general. It is not +# needed here at all now that the bundle holds a single executable — the ordered +# dylib-then-executable signing this used to do moved to build-runtime.sh, which +# is where the dylibs went. +# +# Why xattr -cr first +# ------------------- +# server/Makefile records the failure this prevents: on macOS 26, amfid SIGKILLs +# an ad-hoc-signed binary carrying a stale signature or a com.apple.provenance +# xattr, and it does so with EMPTY STDERR — the process just dies. Every `cp` +# into the staging tree recreates those conditions, so the strip has to happen +# on every build, not once at install time. +set -euo pipefail + +APP="${1:-}" +if [[ -z "$APP" ]]; then + echo "usage: $(basename "$0") " >&2 + exit 2 +fi +if [[ ! -d "$APP" ]]; then + echo "sign-app: no such bundle: $APP" >&2 + exit 1 +fi + +echo "sign-app: stripping extended attributes from $APP" +xattr -cr "$APP" + +MACOS_DIR="$APP/Contents/MacOS" + +# 1. The executable. +LAUNCHER="$MACOS_DIR/cix-launcher" +[[ -e "$LAUNCHER" ]] || { echo "sign-app: missing signing target: $LAUNCHER" >&2; exit 1; } + +# The bundle should carry exactly one executable. Anything else means a stale +# tree — most likely a cix-server, cix or llama/ left over from a build that +# predates the runtime split, which would then be sealed into the signature and +# shipped as ~90 MB of dead weight. +shopt -s nullglob extglob +extra=("$MACOS_DIR"/!(cix-launcher)) +shopt -u nullglob extglob +if [[ ${#extra[@]} -gt 0 ]]; then + echo "sign-app: unexpected files in Contents/MacOS — this bundle was not built from scratch:" >&2 + printf ' %s\n' "${extra[@]}" >&2 + exit 1 +fi + +echo "sign-app: signing cix-launcher" +codesign --force --sign - "$LAUNCHER" + +# 2. The bundle last — this seals the executable and the resources into +# Contents/_CodeSignature. +echo "sign-app: signing bundle" +codesign --force --sign - "$APP" + +# --strict is the point of this step: the lenient default accepts a bundle whose +# sealed resources no longer match what is on disk, which is exactly the state a +# partially re-copied bundle ends up in. +echo "sign-app: verifying" +codesign --verify --strict --verbose=2 "$APP" + +echo "sign-app: ok" diff --git a/server/Makefile b/server/Makefile index 8b9a95f3..96a4a879 100644 --- a/server/Makefile +++ b/server/Makefile @@ -23,7 +23,29 @@ GOFLAGS ?= IMAGE_REPO ?= dvcdsys/code-index IMAGE_TAG ?= go-cu128 -VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo "0.0.0-dev") + +# Version stamped into the binary (-X main.version) and into Docker images. +# +# The tag stream must be filtered. `git describe --tags` without --match picks +# whichever namespace happens to be nearest, and this repo has three of them +# (server/v*, cli/v*, mac/v*) — on develop it resolved to a CLI tag, so every +# locally built *server* image was labelled with a *CLI* version. CI never hit +# this (release-server.yml passes VERSION= explicitly from the tag), which is +# why it went unnoticed; `make build` / `bundle` / `docker-build-cuda*` did. +# +# Note the tag must be reachable from HEAD for describe to see it. Release +# workflows always pass the version in explicitly rather than deriving it. +SERVER_VERSION ?= $(shell git describe --tags --match 'server/v*' 2>/dev/null | sed 's|^server/v||') +ifeq ($(strip $(SERVER_VERSION)),) +SERVER_VERSION := 0.0.0-dev +endif +VERSION ?= $(SERVER_VERSION) + +# -w drops DWARF only. Do NOT add -s: govulncheck -mode=binary needs the Go +# symbol table for symbol-level analysis; on a stripped binary it falls back to +# module-level reporting and flags advisories for packages that are not linked. +# Same rationale, and same flags, as server/Dockerfile:98-106 and release-cli.yml. +LDFLAGS ?= -w -X main.version=$(SERVER_VERSION) # Floating dev tag — overwritten on every `docker-build-cuda-dev` push. Used # by the operator to deploy "current local work" onto the RTX 3090 box for @@ -51,9 +73,9 @@ DASHBOARD_OUT := $(ROOT)/internal/httpapi/dashboard/dist help: @echo "Targets:" - @echo " build — dashboard-build + go build cmd/cix-server into dist/$(BUNDLE_NAME)/cix-server" + @echo " build — dashboard-build + go build cmd/cix-server into dist/$(BUNDLE_NAME)/cix-server (stamps version $(SERVER_VERSION))" @echo " test — go test ./... (no llama-server required)" - @echo " fetch-llama — download + SHA256-verify llama.cpp $(LLAMA_VERSION) for $(OS)-$(ARCH)" + @echo " fetch-llama — download + SHA256-verify llama.cpp $(LLAMA_VERSION) for $(OS)-$(ARCH) (LLAMA_STRICT=1 to require a recorded checksum)" @echo " bundle — build + fetch-llama, assemble dist/$(BUNDLE_NAME)/ tree" @echo " run — bundle + launch cix-server (reads .env from repo root, sets CIX_LLAMA_BIN_DIR)" @echo " test-gate — run the Phase 3 parity gate (requires fetch-llama + GGUF)" @@ -74,18 +96,28 @@ help: # `go build` embeds internal/httpapi/dashboard/dist/ via go:embed — the # dashboard must be built first so the binary picks up fresh assets. +# +# The ldflags are not optional cosmetics: without them `cix-server -v` reports +# the zero value of main.version, so every non-Docker build (make bundle, the +# macOS .app, install-server.sh --mode native) shipped an unidentifiable binary. build: dashboard-build mkdir -p $(BUNDLE_DIR) - $(GO) build $(GOFLAGS) -o $(BUNDLE_DIR)/cix-server ./cmd/cix-server + $(GO) build $(GOFLAGS) -trimpath -ldflags "$(LDFLAGS)" -o $(BUNDLE_DIR)/cix-server ./cmd/cix-server test: $(GO) test ./... +# LLAMA_STRICT=1 turns the first-run "record the checksum" convenience into a +# hard failure. Release builds set it so an upstream asset that was swapped +# after the pin was recorded cannot be silently shipped. +LLAMA_STRICT ?= 0 + fetch-llama: LLAMA_VERSION=$(LLAMA_VERSION) \ LLAMA_REPO=$(LLAMA_REPO) \ LLAMA_OS=$(OS) \ LLAMA_ARCH=$(ARCH) \ + LLAMA_STRICT=$(LLAMA_STRICT) \ DEST_DIR=$(LLAMA_DIR) \ CHECKSUMS_FILE=$(ROOT)/scripts/llama-checksums.txt \ $(ROOT)/scripts/fetch-llama.sh diff --git a/server/cmd/cix-server/main.go b/server/cmd/cix-server/main.go index c015a75c..ef808fc5 100644 --- a/server/cmd/cix-server/main.go +++ b/server/cmd/cix-server/main.go @@ -248,6 +248,22 @@ func run() error { hasProv = true } + // Two fields of a persisted ollama config describe where this process is + // and what pid it has, not what the user chose, and both were frozen at + // first boot. Re-derive them — see RefreshOllamaSidecarPaths for what goes + // wrong otherwise, which is an installation that keeps launching a + // llama-server from a directory that no longer exists. + if hasProv && persistedProv.Kind == provider.KindOllama { + refreshed, rerr := embeddings.RefreshOllamaSidecarPaths(cfg, persistedProv.Config) + if rerr != nil { + // Malformed JSON, which the Build below will report properly. Not + // worth failing here over. + logger.Warn("could not refresh the embedding sidecar paths; using the stored config unchanged", "err", rerr) + } else { + persistedProv.Config = refreshed + } + } + var embedSvc *embeddings.Service if !cfg.EmbeddingsEnabled || !hasProv { // Legacy / disabled path — fall back to env-only ollama wiring. @@ -618,7 +634,7 @@ func run() error { }) srv := &http.Server{ - Addr: fmt.Sprintf(":%d", cfg.Port), + Addr: cfg.ListenAddr(), Handler: handler, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, diff --git a/server/internal/config/config.go b/server/internal/config/config.go index b496b951..7e08c152 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -5,6 +5,7 @@ package config import ( "fmt" + "net" "os" "path/filepath" "runtime" @@ -25,8 +26,16 @@ type Config struct { // CIX_AUTH_DISABLED=true (and also requires CIX_API_KEY to be empty). // Replaces the older "empty API key = no auth" implicit bypass; the new // behaviour fails loud when CIX_API_KEY is missing without the flag. - AuthDisabled bool - Port int + AuthDisabled bool + Port int + // BindAddr is the interface the HTTP server listens on. Empty (the + // default) means every interface, which is what a container needs and what + // every existing deployment already gets — so the default must not change. + // + // Set it to 127.0.0.1 to make the server reachable only from the machine it + // runs on. That is the useful setting for a desktop install, where "the + // whole LAN can reach my code index" is rarely what was intended. + BindAddr string EmbeddingModel string ChromaPersistDir string SQLitePath string @@ -254,6 +263,7 @@ func Load() (*Config, error) { return nil, err } c.Port = port + c.BindAddr = strings.TrimSpace(getenv("CIX_BIND_ADDR", "")) maxFileSize, err := getenvInt("CIX_MAX_FILE_SIZE", 524288) if err != nil { @@ -490,9 +500,57 @@ func (c *Config) Validate() error { if c.LlamaStartupSec <= 0 { return fmt.Errorf("CIX_LLAMA_STARTUP_TIMEOUT=%d, must be positive", c.LlamaStartupSec) } + if err := validateBindAddr(c.BindAddr); err != nil { + return err + } + return nil +} + +// validateBindAddr rejects the two shapes people actually get wrong: a URL, and +// a host:port pair. Both bind successfully as a *hostname* on some systems and +// then fail to resolve, producing a server that is silently unreachable rather +// than one that refused to start. +// +// Hostnames are otherwise allowed through — "localhost" is a legitimate and +// probably the most common value. +func validateBindAddr(addr string) error { + if addr == "" { + return nil + } + if strings.Contains(addr, "://") { + return fmt.Errorf("CIX_BIND_ADDR=%q is a URL; use a bare address such as 127.0.0.1 or 0.0.0.0", addr) + } + // A colon is legal in an IPv6 literal and nowhere else here. + if strings.Contains(addr, ":") && net.ParseIP(addr) == nil { + return fmt.Errorf("CIX_BIND_ADDR=%q includes a port or is not a valid IPv6 address; set the port with CIX_PORT", addr) + } return nil } +// ListenAddr is the address the HTTP server binds to. +// +// An empty BindAddr yields ":port" — every interface. That is the historical +// behaviour and what a container requires, so it stays the default; narrowing +// it is opt-in. +func (c *Config) ListenAddr() string { + if c.BindAddr == "" { + return ":" + strconv.Itoa(c.Port) + } + return net.JoinHostPort(c.BindAddr, strconv.Itoa(c.Port)) +} + +// LocalOnly reports whether the configured bind address is loopback — i.e. the +// server is reachable only from this machine. +func (c *Config) LocalOnly() bool { + if c.BindAddr == "" { + return false + } + if ip := net.ParseIP(c.BindAddr); ip != nil { + return ip.IsLoopback() + } + return strings.EqualFold(c.BindAddr, "localhost") +} + // defaultDataDir returns the platform-specific root for runtime data // (SQLite + chromem-go). Used to build defaults for CIX_SQLITE_PATH and // CIX_CHROMA_PERSIST_DIR when neither env var is set. diff --git a/server/internal/config/config_test.go b/server/internal/config/config_test.go index 58415a84..1b28f10c 100644 --- a/server/internal/config/config_test.go +++ b/server/internal/config/config_test.go @@ -284,3 +284,67 @@ func unsetAll(t *testing.T) { osUnsetenv(k) } } + +func TestListenAddr(t *testing.T) { + tests := []struct { + name string + bind string + port int + want string + }{ + // The empty default must keep producing ":port". Every existing + // deployment — every container in particular — depends on binding all + // interfaces, so narrowing this is opt-in and never implicit. + {"default is all interfaces", "", 21847, ":21847"}, + {"loopback", "127.0.0.1", 21847, "127.0.0.1:21847"}, + {"explicit all interfaces", "0.0.0.0", 8080, "0.0.0.0:8080"}, + {"hostname", "localhost", 21847, "localhost:21847"}, + // JoinHostPort brackets IPv6; a naive host+":"+port would produce + // "::1:21847", which net.Listen reads as a different address entirely. + {"ipv6 loopback is bracketed", "::1", 21847, "[::1]:21847"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := &Config{BindAddr: tc.bind, Port: tc.port} + if got := c.ListenAddr(); got != tc.want { + t.Errorf("ListenAddr() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestLocalOnly(t *testing.T) { + tests := map[string]bool{ + "": false, // all interfaces + "0.0.0.0": false, + "192.168.1.5": false, + "127.0.0.1": true, + "127.0.0.2": true, // the whole 127/8 block is loopback + "::1": true, + "localhost": true, + "LOCALHOST": true, + } + for bind, want := range tests { + if got := (&Config{BindAddr: bind}).LocalOnly(); got != want { + t.Errorf("LocalOnly() for %q = %v, want %v", bind, got, want) + } + } +} + +func TestValidateBindAddr(t *testing.T) { + valid := []string{"", "127.0.0.1", "0.0.0.0", "localhost", "::1", "192.168.1.5"} + for _, addr := range valid { + if err := validateBindAddr(addr); err != nil { + t.Errorf("validateBindAddr(%q) = %v, want nil", addr, err) + } + } + // The two mistakes worth catching: both bind as a hostname on some systems + // and then fail to resolve, giving a server that is silently unreachable + // instead of one that refused to start. + invalid := []string{"http://127.0.0.1", "https://cix.local", "127.0.0.1:21847", "localhost:8080"} + for _, addr := range invalid { + if err := validateBindAddr(addr); err == nil { + t.Errorf("validateBindAddr(%q) = nil, want an error", addr) + } + } +} diff --git a/server/internal/embeddings/service.go b/server/internal/embeddings/service.go index 13d7060a..7fe8927c 100644 --- a/server/internal/embeddings/service.go +++ b/server/internal/embeddings/service.go @@ -189,6 +189,43 @@ func BuildOllamaConfigFromEnv(cfg *config.Config) ([]byte, error) { return json.Marshal(c) } +// RefreshOllamaSidecarPaths re-derives the two ollama config fields that +// describe THIS process rather than the user's choices, and returns the updated +// blob. +// +// The persisted blob is authoritative for everything a person can choose — the +// model, the context size, the GPU layers — and that must not change. It cannot +// be authoritative for these two: +// +// - bin_dir comes from filepath.Dir(os.Executable())/llama, so it names the +// directory the server was installed in when the row was first written. +// Move or upgrade the installation and every later boot keeps launching the +// old llama-server — until that directory is deleted, at which point +// embeddings stop working with no configuration having changed. The macOS +// app, which installs each release into its own versioned directory, hits +// this on the first update. +// - socket_path is /cix-llama-.sock, and the pid is the one from +// first boot. Persisting it defeats the uniqueness it exists for: every +// later boot reuses that one name, so a server can find an orphaned +// llama-server already bound to it and talk to that instead of spawning its +// own. +// +// Both are already documented as deployment-level and are deliberately absent +// from the dashboard's edit schema (see the ollama factory's SchemaJSON) — this +// is what makes that true on the second boot as well as the first. +// +// The result is not written back to the database. The stored row stays the +// record of what was chosen; this is only what the running process uses. +func RefreshOllamaSidecarPaths(cfg *config.Config, blob []byte) ([]byte, error) { + var c ollama.Config + if err := json.Unmarshal(blob, &c); err != nil { + return nil, err + } + c.BinDir = cfg.LlamaBinDir + c.SocketPath = cfg.LlamaSocketPath + return json.Marshal(c) +} + // EnvSecrets returns the production SecretLookup: os.LookupEnv. main.go // and the admin handlers pass it to provider.Build / Service.SwitchProvider. func EnvSecrets() provider.SecretLookup { return envSecrets } diff --git a/server/internal/embeddings/sidecar_paths_test.go b/server/internal/embeddings/sidecar_paths_test.go new file mode 100644 index 00000000..69efc6df --- /dev/null +++ b/server/internal/embeddings/sidecar_paths_test.go @@ -0,0 +1,78 @@ +package embeddings + +import ( + "encoding/json" + "testing" + + "github.com/dvcdsys/code-index/server/internal/config" + "github.com/dvcdsys/code-index/server/internal/embeddings/provider/ollama" +) + +// The persisted ollama blob froze two values at first boot that describe the +// installation rather than the user's choices. Re-deriving them is what keeps +// an upgraded or relocated install pointing at the llama-server it actually +// shipped with — and what stops a second server adopting an orphaned sidecar +// through a recycled socket name. +func TestRefreshOllamaSidecarPaths(t *testing.T) { + stored, err := json.Marshal(ollama.Config{ + Model: "awhiteside/CodeRankEmbed-Q8_0-GGUF", + BinDir: "/Users/someone/.cix/runtime/0.5.0/llama", + SocketPath: "/tmp/cix-llama-4242.sock", + Transport: "unix", + CacheDir: "/Users/someone/Library/Caches/cix/models", + CtxSize: 4096, + NGpuLayers: -1, + NThreads: 7, + BatchSize: 2048, + CacheRAMMiB: 0, + StartupSec: 300, + }) + if err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + LlamaBinDir: "/Users/someone/.cix/runtime/0.6.0/llama", + LlamaSocketPath: "/tmp/cix-llama-9001.sock", + } + + out, err := RefreshOllamaSidecarPaths(cfg, stored) + if err != nil { + t.Fatal(err) + } + + var got ollama.Config + if err := json.Unmarshal(out, &got); err != nil { + t.Fatal(err) + } + + if got.BinDir != cfg.LlamaBinDir { + t.Errorf("bin_dir = %q, want %q", got.BinDir, cfg.LlamaBinDir) + } + if got.SocketPath != cfg.LlamaSocketPath { + t.Errorf("socket_path = %q, want %q", got.SocketPath, cfg.LlamaSocketPath) + } + + // Everything a person can choose has to survive untouched. Overwriting the + // model would silently change the embedding identity and force a reindex of + // every project; overwriting the tuning would discard dashboard edits on + // every boot. + if got.Model != "awhiteside/CodeRankEmbed-Q8_0-GGUF" { + t.Errorf("model = %q, want it unchanged", got.Model) + } + if got.CtxSize != 4096 || got.NGpuLayers != -1 || got.NThreads != 7 || got.BatchSize != 2048 { + t.Errorf("tuning changed: %+v", got) + } + if got.Transport != "unix" || got.CacheDir != "/Users/someone/Library/Caches/cix/models" { + t.Errorf("transport/cache_dir changed: transport=%q cache_dir=%q", got.Transport, got.CacheDir) + } + if got.StartupSec != 300 { + t.Errorf("startup_sec = %d, want 300", got.StartupSec) + } +} + +func TestRefreshOllamaSidecarPathsRejectsMalformedBlob(t *testing.T) { + if _, err := RefreshOllamaSidecarPaths(&config.Config{}, []byte("not json")); err == nil { + t.Fatal("RefreshOllamaSidecarPaths accepted a malformed blob") + } +} diff --git a/server/internal/httpapi/status_contract_test.go b/server/internal/httpapi/status_contract_test.go new file mode 100644 index 00000000..bade7b87 --- /dev/null +++ b/server/internal/httpapi/status_contract_test.go @@ -0,0 +1,83 @@ +package httpapi + +import ( + "net/http/httptest" + "testing" + + "github.com/dvcdsys/code-index/server/internal/versioncheck" +) + +// The goldens below pin the exact wire JSON of GET /api/v1/status. The CLI and +// the macOS launcher live in a SEPARATE module (github.com/dvcdsys/code-index/ +// cli) that cannot import this one, so the contract is held by twin fixtures: +// identical goldens live in cli/internal/client/status_test.go. Rename, retype +// or drop a field on either side and one of these tests fails, forcing the +// other module to be updated in the same PR. +// +// Key order here is alphabetical because GetStatus builds a map[string]any and +// encoding/json sorts map keys. That is stable, so the bytes can be pinned. +// +// Two goldens, because the response has two shapes. The version-check fields +// are folded in only when Deps.VersionCheck is wired; a consumer that assumes +// they are always present will read `update_available: false` as "you are up to +// date" on a server where the check is simply switched off. +const ( + wantStatusWire = `{"active_indexing_jobs":0,"api_version":"v1","backend":"go","embedding_model":"awhiteside/CodeRankEmbed-Q8_0-GGUF","embedding_provider":"","embedding_provider_manages_process":false,"model_loaded":false,"projects":0,"server_version":"1.2.3","status":"ok"}` + + wantStatusWithVersionCheckWire = `{"active_indexing_jobs":0,"api_version":"v1","backend":"go","embedding_model":"awhiteside/CodeRankEmbed-Q8_0-GGUF","embedding_provider":"","embedding_provider_manages_process":false,"latest_version":null,"model_loaded":false,"projects":0,"release_url":null,"server_version":"1.2.3","status":"ok","update_available":false,"version_check":{"checked_at":null,"enabled":true,"error":null}}` +) + +func statusServer(vc *versioncheck.Service) *Server { + // Deliberately minimal Deps: a nil DB makes the two counts 0 and a nil + // EmbeddingSvc makes the provider fields their zero values, so the body is + // deterministic without standing up a database or a llama sidecar. + return &Server{Deps: Deps{ + ServerVersion: "1.2.3", + APIVersion: "v1", + Backend: "go", + EmbeddingModel: "awhiteside/CodeRankEmbed-Q8_0-GGUF", + VersionCheck: vc, + }} +} + +func TestGetStatus_WireContract(t *testing.T) { + rec := httptest.NewRecorder() + statusServer(nil).GetStatus(rec, httptest.NewRequest("GET", "/api/v1/status", nil)) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200", rec.Code) + } + got := trimTrailingNewline(rec.Body.String()) + if got != wantStatusWire { + t.Errorf("/status wire shape drifted from the CLI contract:\n got: %s\nwant: %s\n"+ + "→ update cli/internal/client/client.go and its twin fixture in the same PR.", got, wantStatusWire) + } +} + +func TestGetStatus_WireContract_WithVersionCheck(t *testing.T) { + // Never Run(), so the snapshot is the constructor's initial state: enabled, + // nothing checked yet. That is exactly the shape a client sees during the + // first minute of every server's life (the poller has a 60s initial delay), + // so it is worth pinning rather than only the post-check shape. + vc := versioncheck.New(versioncheck.Config{ + Enabled: true, + Repo: "dvcdsys/code-index", + CurrentVersion: "1.2.3", + }, nil) + + rec := httptest.NewRecorder() + statusServer(vc).GetStatus(rec, httptest.NewRequest("GET", "/api/v1/status", nil)) + + got := trimTrailingNewline(rec.Body.String()) + if got != wantStatusWithVersionCheckWire { + t.Errorf("/status version-check shape drifted from the CLI contract:\n got: %s\nwant: %s\n"+ + "→ update cli/internal/client/client.go and its twin fixture in the same PR.", got, wantStatusWithVersionCheckWire) + } +} + +func trimTrailingNewline(s string) string { + for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { + s = s[:len(s)-1] + } + return s +} diff --git a/server/scripts/fetch-llama.sh b/server/scripts/fetch-llama.sh index 76673114..b2a30bae 100755 --- a/server/scripts/fetch-llama.sh +++ b/server/scripts/fetch-llama.sh @@ -9,6 +9,7 @@ # LLAMA_ARCH — "arm64" (Phase 3 only supports arm64) # DEST_DIR — target directory for the slimmed binary set # CHECKSUMS_FILE — path to scripts/llama-checksums.txt +# LLAMA_STRICT — "1" to require a pre-recorded checksum (default "0") # # First-run bootstrap flow # ------------------------ @@ -16,11 +17,20 @@ # for the asset is unknown. Rather than fail, we compute the SHA256 after the # download and APPEND it to CHECKSUMS_FILE, printing a very visible message. # The expectation is that the contributor then commits that checksum file -# update in the same PR that bumps LLAMA_VERSION — downstream CI fails hard -# if the asset's SHA256 does not match an existing line. +# update in the same PR that bumps LLAMA_VERSION. # # Every subsequent run on the same LLAMA_VERSION uses the recorded checksum # as the authoritative verifier; mismatches fail. +# +# Strict mode (LLAMA_STRICT=1) +# ---------------------------- +# Record-on-first-run is the right behaviour for a developer bootstrapping a +# bump, and the wrong behaviour for a build that ships. In a release build a +# missing checksum row means "trust whatever the network just handed us" — +# the recorded value is derived from the download itself, so it verifies +# nothing. Release workflows set LLAMA_STRICT=1 so an unpinned LLAMA_VERSION +# fails the build instead, and the fix is to run `make fetch-llama` locally +# and commit the resulting llama-checksums.txt line. set -euo pipefail @@ -30,6 +40,7 @@ set -euo pipefail : "${LLAMA_ARCH:?LLAMA_ARCH is required}" : "${DEST_DIR:?DEST_DIR is required}" : "${CHECKSUMS_FILE:?CHECKSUMS_FILE is required}" +: "${LLAMA_STRICT:=0}" if [[ "$LLAMA_OS" != "darwin" || "$LLAMA_ARCH" != "arm64" ]]; then echo "fetch-llama.sh: only darwin-arm64 is supported in Phase 3 (got $LLAMA_OS-$LLAMA_ARCH)" >&2 @@ -41,6 +52,30 @@ fi ASSET="llama-${LLAMA_VERSION}-bin-macos-arm64.tar.gz" URL="https://github.com/${LLAMA_REPO}/releases/download/${LLAMA_VERSION}/${ASSET}" +# Look the pin up BEFORE downloading, so strict mode fails in a second rather +# than after pulling ~50 MB it is going to reject anyway. +EXPECTED_SHA="" +if [[ -f "$CHECKSUMS_FILE" ]]; then + EXPECTED_SHA=$(awk -v a="$ASSET" '$2 == a { print $1 }' "$CHECKSUMS_FILE" || true) +fi + +if [[ "$LLAMA_STRICT" == "1" && -z "$EXPECTED_SHA" ]]; then + cat >&2 <