diff --git a/.env.example b/.env.example index d247b7f..16f4b53 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,15 @@ -OLLAMA_URL=http://localhost:11434 +# 127.0.0.1 on purpose (not `localhost`): on Windows `localhost` can resolve +# to ::1 first while the containers publish IPv4-only ports — including when +# docker runs inside WSL2 — which hangs the HTTP clients. +OLLAMA_URL=http://127.0.0.1:11434 EMBED_MODEL=bge-m3 EMBED_DIM=1024 -QDRANT_URL=http://localhost:6333 +QDRANT_URL=http://127.0.0.1:6333 QDRANT_COLLECTION_CODE=code_chunks QDRANT_COLLECTION_EPISODES=episodes -FALKOR_HOST=localhost +FALKOR_HOST=127.0.0.1 FALKOR_PORT=6379 FALKOR_GRAPH=code_graph diff --git a/README.md b/README.md index 227d433..ec91b2f 100644 --- a/README.md +++ b/README.md @@ -109,14 +109,16 @@ from the `main` URL above before running it. The installer fetches the CLI, drops `docker-compose.yml` into `~/.code-memory/`, starts **FalkorDB** + **Qdrant**, pulls **`bge-m3`**, and wires the **Claude Code** + **OpenCode** plugins. Idempotent — safe to re-run. +**Docker Desktop is not required.** Any working engine is used as-is; when none is found the installer offers to provision one — **docker-ce inside WSL2** on Windows, **Colima** on macOS — with confirmation at each step. See [Docker without Docker Desktop](#docker-without-docker-desktop). + ### ✅ Verify ```bash code-memory --version -code-memory health +code-memory update --check ``` -`health` should print 🟢 for FalkorDB, Qdrant, and Ollama. Then point it at a repo: +`update --check` should list `Docker: FalkorDB (running)` and `Docker: Qdrant (running)` — `(running (via WSL))` on Windows without Docker Desktop — plus your Ollama models. Then point it at a repo: ```bash code-memory ingest . @@ -127,7 +129,7 @@ code-memory ingest . | | | | ---------------------------- | --------------------------------------------------------------------- | | **Python 3.11+** | Orchestrator + CLI runtime | -| **Docker + Compose v2** | FalkorDB (`:6379`) + Qdrant (`:6333`) | +| **Any Docker engine + Compose v2** | FalkorDB (`:6379`) + Qdrant (`:6333`) — docker-ce in WSL2 (Windows), Colima (macOS), docker-ce (Linux), or Docker Desktop. See [Docker without Docker Desktop](#docker-without-docker-desktop) | | **Ollama** | Long-running embedding daemon (keeps `bge-m3` warm) | | **Disk ~3 GB · RAM 8 GB+** | 16 GB+ recommended for large repos | | **OS** | macOS / Linux / Windows (WSL2 **or** native PowerShell) | @@ -153,6 +155,8 @@ curl -fsSL https://raw.githubusercontent.com/fmflurry/code-memory/main/install.s | `--no-claude` | Skip Claude Code marketplace + plugin | | `--no-opencode` | Skip OpenCode plugin | | `--no-mcp` | Skip Claude Code MCP server registration | +| `--colima` | macOS: install Colima + docker CLI if no engine found (non-interactive friendly) | +| `--docker-ce` | Linux: install docker-ce via get.docker.com if no engine found | **Windows** — `iex` doesn't accept args, so download then run: @@ -233,7 +237,7 @@ Edit `~/.code-memory/.env` (or `%USERPROFILE%\.code-memory\.env`) to point at re FALKOR_HOST=falkor.internal FALKOR_PORT=6379 QDRANT_URL=https://qdrant.internal:6333 -OLLAMA_HOST=http://ollama.internal:11434 +OLLAMA_URL=http://ollama.internal:11434 ``` Re-run the one-liner with `--no-docker --no-ollama` (or `-NoDocker -NoOllama` on Windows). @@ -268,6 +272,70 @@ cd code-memory --- +## Docker without Docker Desktop + +code-memory needs a docker engine only to run two containers (FalkorDB + Qdrant). **Any engine works** — if Docker Desktop is already on the machine it keeps working unchanged — but you don't need its license: the recommended free setups are **docker-ce inside WSL2** on Windows and **Colima** on macOS. Everything below is what the one-liner installers automate for you (with confirmation at each step). + +Detection order everywhere (installers and `code-memory update`): a working `docker` on PATH first, then — Windows only — `wsl -e docker`. Pin a specific distro with `CODEMEMORY_WSL_DISTRO=`. + +### 🪟 Windows — docker-ce in WSL2 + +The one-liner installer (`install.ps1`) offers to provision this automatically when no engine is found. Manual recap: + +```powershell +wsl --install # elevated terminal; reboot; create your Linux user +# inside the distro (or via wsl -u root): +wsl -u root sh -c "printf '[boot]\nsystemd=true\n' >> /etc/wsl.conf" # if systemd is off +wsl --shutdown # WARNING: terminates ALL running WSL sessions +wsl -u root sh -c "curl -fsSL https://get.docker.com | sh" +wsl -u root sh -c "usermod -aG docker $(wsl -e whoami); systemctl enable --now docker" +``` + +- **How commands are routed:** code-memory calls `docker` on the Windows PATH when it answers, otherwise `wsl -e docker`. No TCP exposure of the daemon, no extra config. +- **Ports:** containers published inside WSL2 reach Windows at `127.0.0.1:6333/6379` through WSL's localhost forwarding. If they don't, check `%USERPROFILE%\.wslconfig` — `localhostForwarding` must not be `false`; with `networkingMode=mirrored` make sure the firewall allows those ports. +- **Keepalive (important):** WSL2 shuts the VM down about a minute after the last session detaches — even with systemd services running — which stops dockerd and the containers. Whenever docker is reached through WSL (freshly provisioned or pre-existing), the installer offers a Startup-folder entry (`code-memory-wsl-docker.vbs`, no elevation needed — `schtasks /SC ONLOGON` requires admin) that holds a hidden persistent session (`wsl.exe -e sleep infinity`), keeping the VM — and dockerd — alive; `restart: unless-stopped` brings the `cm-*` containers back whenever dockerd (re)starts. Disable it by deleting that file from `shell:startup`. Without it, any `wsl` command revives everything in a few seconds, but the MCP server/hooks would see connection-refused in between. +- Prefer `127.0.0.1` over `localhost` in `.env` — Windows may resolve `localhost` to IPv6 `::1` while the ports are IPv4-only. + +### 🍎 macOS — Colima + +```bash +brew install colima docker docker-compose +mkdir -p ~/.docker/cli-plugins +ln -sfn "$(brew --prefix)/opt/docker-compose/bin/docker-compose" ~/.docker/cli-plugins/docker-compose +colima start # big repos: colima start --memory 8 +brew services start colima # optional: auto-start at login +``` + +The symlink exposes brew's compose v2 as the `docker compose` subcommand (code-memory always calls that form). Docker Desktop, if present and running, is used as-is instead. + +### 🐧 Linux — docker-ce + +```bash +curl -fsSL https://get.docker.com | sudo sh +sudo usermod -aG docker "$USER" # re-login (or `newgrp docker`) afterwards +sudo systemctl enable --now docker +``` + +### 🔀 Switching engines (existing installs) + +Moving an existing install off Docker Desktop (e.g. to docker-ce in WSL2): + +1. Stop Docker Desktop, make the new engine reachable, then run `code-memory update` (or re-run the installer) — commands are re-routed automatically. +2. On Windows, `update` also rewrites legacy `localhost` defaults in `~/.code-memory/.env` to `127.0.0.1` (previous file kept as `.env.bak`). Old installs shipped `localhost`, which resolves to IPv6 `::1` and can't reach the IPv4-only WSL-forwarded ports. +3. **The code index does not follow** — it lives in docker volumes owned by the old engine. The simplest path is re-ingesting (`code-memory ingest `); the index is fully rebuilt from your sources, and the episodes/claims SQLite files live on the local disk, unaffected. To carry the Qdrant/FalkorDB volumes over instead, export them with the **old** engine running, then import with the **new** one (the backup dir must be visible to both engines): + + ```bash + for v in qdrant_data falkor_data; do + docker run --rm -v code-memory_$v:/from -v "$HOME/cm-backup:/backup" alpine tar czf /backup/$v.tgz -C /from . + done + # switch engines, `docker compose up -d` once so the volumes exist, then: + for v in qdrant_data falkor_data; do + docker run --rm -v code-memory_$v:/to -v "$HOME/cm-backup:/backup" alpine tar xzf /backup/$v.tgz -C /to + done + ``` + +--- + ## Updating After the initial install, never run the one-liner again — use the built-in updater: @@ -286,7 +354,7 @@ code-memory update --bleeding # install CLI from git+main instead of PyPI | Component | Detection | Refresh action | | --------------------------- | ------------------------------------------------------ | ------------------------------------------------- | | **CLI** | `sys.prefix` (uv tool / pipx / pip / editable) | upgrade via the same channel | -| **FalkorDB + Qdrant** | `~/.code-memory/docker/docker-compose.yml` or running | `docker compose pull && up -d` | +| **FalkorDB + Qdrant** | `~/.code-memory/docker/docker-compose.yml` or running | `docker compose pull && up -d` (native docker or `wsl -e docker`) | | **Ollama models** | present in `ollama list` (`bge-m3`, `gemma2:9b`, …) | `ollama pull ` (only for already-pulled) | | **Claude Code plugin** | `claude plugin list \| grep code-memory` | `claude plugin install … --force` | | **OpenCode plugin** | `npm ls -g code-memory-opencode` | `npm i -g code-memory-opencode` | @@ -792,7 +860,9 @@ code-memory ingest /path/to/repo --full The Compose file ships a CPU TEI image by default so you can smoke-test the wiring on a laptop. To enable CUDA on a Linux + NVIDIA host, uncomment the `deploy.resources.reservations.devices` block in -`docker/docker-compose.yml`. +`docker/docker-compose.yml`. Note: GPU passthrough is not available under +Colima on macOS; TEI-CUDA needs Linux, or WSL2 with the NVIDIA container +toolkit installed inside the distro. Expected impact on a ~17k-file C# monorepo (~134k chunks): @@ -1032,6 +1102,8 @@ What the one-liner actually puts on your machine. Full install commands are in t ### Runtime services (Docker) +Run on any engine — docker-ce in WSL2, Colima, docker-ce, or Docker Desktop ([details](#docker-without-docker-desktop)): + | Service | Image | Purpose | | ------------- | ----------------------------- | ---------------------------------------------------------- | | FalkorDB | `falkordb/falkordb:latest` | Redis-protocol graph DB — call/import graph, claim store | diff --git a/install.ps1 b/install.ps1 index f871ae1..ebaa468 100644 --- a/install.ps1 +++ b/install.ps1 @@ -4,10 +4,12 @@ .DESCRIPTION No `git clone` required. Installs the `code-memory` CLI via `uv`, drops - infra files into $HOME\.code-memory\, waits for Docker + Ollama, - pulls the bge-m3 embedding model (and optionally gemma2:9b for claim - extraction), and wires up the Claude Code plugin + MCP server. - Optionally installs the OpenCode plugin from npm. + infra files into $HOME\.code-memory\, starts FalkorDB + Qdrant on any + working docker engine — native (Docker Desktop, ...) or docker-ce inside + WSL2 via `wsl -e docker`, offering to provision the latter when nothing + is found (Docker Desktop NOT required) — pulls the bge-m3 embedding model + (and optionally gemma2:9b for claim extraction), and wires up the Claude + Code plugin + MCP server. Optionally installs the OpenCode plugin from npm. Interactive by default. Pass -Yes to accept defaults; pass any -No* switch to skip a step; pass -NonInteractive to refuse all prompts. @@ -145,6 +147,148 @@ function Wait-ForCmd([string]$Cmd, [string]$Label, [string]$Url) { return $true } +# ---------- docker resolution (Docker Desktop NOT required) ---------- +# Any working engine is accepted, probed in order: +# 1. `docker` on PATH with a live daemon (Docker Desktop, docker-ce, ...) +# 2. docker-ce inside the default WSL2 distro, reached via `wsl -e docker` +# Sets $script:DockerKind to native|wsl|daemon-down|none and returns the argv +# prefix array (or $null). WSL_UTF8=1 keeps wsl.exe diagnostics decodable +# (they are UTF-16LE by default). +function Resolve-DockerCmd { + $env:WSL_UTF8 = '1' + if (Test-Cmd 'docker') { + Invoke-NativeQuiet { docker info } + if ($LASTEXITCODE -eq 0) { $script:DockerKind = 'native'; return ,@('docker') } + } + if (Test-Cmd 'wsl') { + Invoke-NativeQuiet { wsl -e docker info } + if ($LASTEXITCODE -eq 0) { $script:DockerKind = 'wsl'; return ,@('wsl','-e','docker') } + } + $script:DockerKind = if (Test-Cmd 'docker') { 'daemon-down' } else { 'none' } + return $null +} + +# Translate a Windows path for the resolved docker; identity when native. +# Relative paths resolve against the shared cwd (WSL mirrors it under /mnt), +# so only absolute paths need wslpath. +function ConvertTo-DockerPath([string]$WinPath) { + if ($script:DockerKind -ne 'wsl') { return $WinPath } + if (-not [System.IO.Path]::IsPathRooted($WinPath)) { return ($WinPath -replace '\\','/') } + $p = (& wsl -e wslpath -a "$WinPath" 2>$null) + if ($LASTEXITCODE -eq 0 -and $p) { return "$p".Trim() } + return '/mnt/' + $WinPath.Substring(0,1).ToLower() + ($WinPath.Substring(2) -replace '\\','/') +} + +# Run docker through the resolved prefix: Invoke-Docker compose ... up -d +function Invoke-Docker { + param([Parameter(ValueFromRemainingArguments)]$Rest) + $exe = $script:DockerCmd[0] + $pre = @($script:DockerCmd | Select-Object -Skip 1) + & $exe @pre @Rest +} + +# Keep the WSL VM (and dockerd) alive. WSL2 shuts the VM down ~1 min after +# the last session detaches — even with systemd services running — taking +# dockerd and the cm-* containers with it; the runtime then sees +# connection-refused. A Startup-folder wscript entry (no elevation needed — +# `schtasks /SC ONLOGON` requires admin; window style 0 = fully hidden) +# holds a persistent `wsl -e sleep infinity` session, and +# restart:unless-stopped brings the containers back whenever dockerd +# (re)starts. Idempotent: skips silently when already installed. +function Install-WslKeepalive { + if ($script:KeepaliveOffered) { return } + $script:KeepaliveOffered = $true + $vbs = Join-Path ([Environment]::GetFolderPath('Startup')) 'code-memory-wsl-docker.vbs' + if (Test-Path $vbs) { Ok "WSL keepalive already installed"; return } + if (-not (Ask-YesNo "Auto-start a hidden WSL keepalive at logon so dockerd stays up?" "Y")) { + Warn "without it, WSL idle-shutdown stops dockerd (and the containers) between sessions" + return + } + @( + "' code-memory: keep the WSL VM (and dockerd) alive in the background." + "' WSL2 shuts the VM down ~1 min after the last session detaches, taking" + "' the FalkorDB/Qdrant containers with it. Remove this file to disable." + 'CreateObject("Wscript.Shell").Run "wsl.exe -e sleep infinity", 0, False' + ) | Set-Content -Path $vbs -Encoding ASCII + Ok "keepalive installed: $vbs" + Dim "remove later by deleting that file" + # Cover the current session too — the Startup entry only fires at next logon. + Start-Process -FilePath 'wsl.exe' -ArgumentList '-e','sleep','infinity' -WindowStyle Hidden +} + +# Provision docker-ce inside the default WSL2 distro — the Desktop-free path. +# Every mutating step asks first. Returns $true once `wsl -e docker info` works. +function Install-DockerInWsl { + if (-not (Test-Interactive)) { + Warn "non-interactive: skipping docker-ce provisioning. Manual steps:" + Dim " wsl --install (elevated terminal, reboot, create your Linux user)" + Dim " wsl -u root sh -c 'curl -fsSL https://get.docker.com | sh'" + Dim " wsl -u root sh -c 'usermod -aG docker ; systemctl enable --now docker'" + return $false + } + + # 1. A WSL distro must exist and boot. + Invoke-NativeQuiet { wsl -e true } + while ($LASTEXITCODE -ne 0) { + Warn "WSL is not ready (not installed, or no distro yet)." + Dim "In an ELEVATED terminal run: wsl --install" + Dim "Reboot, let the distro create your Linux user, then come back here." + Write-Host "? Press Enter to re-check (or 'skip'): " -ForegroundColor Yellow -NoNewline + $ans = Read-Host + if ($ans -eq 'skip') { return $false } + Invoke-NativeQuiet { wsl -e true } + } + + # 2. docker-ce needs WSL2 (real kernel), not WSL1. + $kernel = ((& wsl -e uname -r 2>$null) -join '').Trim() + if ($kernel -notmatch 'microsoft-standard|WSL2') { + Warn "Default distro looks like WSL1 (kernel: $kernel); docker-ce needs WSL2." + Dim "Convert it: wsl --set-version 2 then re-run this installer." + return $false + } + + # 3. systemd so dockerd starts with the distro. + Invoke-NativeQuiet { wsl -e test -d /run/systemd/system } + if ($LASTEXITCODE -ne 0) { + Warn "systemd is not enabled in the distro (needed so dockerd starts automatically)." + if (-not (Ask-YesNo "Enable systemd in /etc/wsl.conf? This runs 'wsl --shutdown', terminating ALL running WSL sessions" "Y")) { return $false } + & wsl -u root sh -c "grep -qs '^systemd=true' /etc/wsl.conf || printf '[boot]\nsystemd=true\n' >> /etc/wsl.conf" + & wsl --shutdown + Invoke-NativeQuiet { wsl -e test -d /run/systemd/system } + if ($LASTEXITCODE -ne 0) { Warn "systemd still not active after restart — aborting provisioning"; return $false } + Ok "systemd enabled" + } + + # 4. docker-ce via the official convenience script. + Invoke-NativeQuiet { wsl -e docker --version } + if ($LASTEXITCODE -ne 0) { + if (-not (Ask-YesNo "Install docker-ce inside the distro via get.docker.com?" "Y")) { return $false } + Invoke-NativeVisible { wsl -u root sh -c "curl -fsSL https://get.docker.com | sh" } + if ($LASTEXITCODE -ne 0) { + Warn "docker-ce install failed." + Dim "Corporate proxy? Set HTTP_PROXY/HTTPS_PROXY inside the distro and re-run." + return $false + } + } else { + Ok "docker CLI already present in the distro" + } + + # 5. Non-root access + start on boot. Terminate so group membership applies. + $wu = ((& wsl -e whoami) -join '').Trim() + Invoke-NativeVisible { wsl -u root sh -c "usermod -aG docker $wu; systemctl enable --now docker" } + $distro = ((& wsl -l -q) | Where-Object { $_ -and "$_".Trim() } | Select-Object -First 1) + if ($distro) { Invoke-NativeQuiet { wsl --terminate "$distro".Trim() } } + + # 6. Verify end-to-end. + Invoke-NativeQuiet { wsl -e docker info } + if ($LASTEXITCODE -ne 0) { Warn "docker daemon still not reachable via 'wsl -e docker'"; return $false } + Ok "docker-ce running inside WSL2" + + # 7. Keep the VM (and the freshly enabled dockerd) alive across sessions. + Install-WslKeepalive + return $true +} + # ---------- 1. uv ---------- Step "Ensuring uv is installed" if (Test-Cmd 'uv') { @@ -206,27 +350,62 @@ if ($doDocker -and -not $Yes -and (Test-Interactive)) { } if ($doDocker) { Step "Starting FalkorDB + Qdrant" - if (Wait-ForCmd 'docker' 'Docker Desktop' 'https://www.docker.com/products/docker-desktop') { - # ensure daemon up - Invoke-NativeQuiet { docker info } - if ($LASTEXITCODE -ne 0) { - Warn "Docker CLI present but daemon not running. Start Docker Desktop." - if (Test-Interactive) { - Write-Host "? Press Enter once the daemon is up (or 'skip'): " -ForegroundColor Yellow -NoNewline - $ans = Read-Host - if ($ans -eq 'skip') { $doDocker = $false } - } + $script:DockerCmd = Resolve-DockerCmd + + if (-not $script:DockerCmd -and $script:DockerKind -eq 'daemon-down') { + Warn "Docker CLI found but no daemon reachable." + Dim "Start it: Docker Desktop if that's what you use, or (WSL2 docker-ce) wsl -e sudo systemctl start docker" + if (Test-Interactive) { + Write-Host "? Press Enter once the daemon is up (or 'skip'): " -ForegroundColor Yellow -NoNewline + $ans = Read-Host + if ($ans -ne 'skip') { $script:DockerCmd = Resolve-DockerCmd } } - if ($doDocker) { - Invoke-NativeVisible { docker compose -f (Join-Path $HomeDir 'docker/docker-compose.yml') --project-directory $HomeDir up -d } - if ($LASTEXITCODE -ne 0) { - Warn "docker compose up failed" - } else { - Ok "containers up" - Dim "FalkorDB browser: http://localhost:3000" - Dim "Qdrant dashboard: http://localhost:6333/dashboard" + } + + if (-not $script:DockerCmd -and $script:DockerKind -eq 'none') { + Warn "No docker found (native or WSL). Docker Desktop is NOT required." + if (Ask-YesNo "Provision docker-ce inside your WSL2 distro now? (recommended)" "Y") { + if (Install-DockerInWsl) { $script:DockerCmd = Resolve-DockerCmd } + } + if (-not $script:DockerCmd) { + Dim "Options:" + Dim " a) WSL2 + docker-ce: wsl --install (elevated, reboot) then re-run this installer" + Dim " b) any other docker engine (Docker Desktop, Rancher Desktop, ...), then re-run" + } + } + + if ($script:DockerCmd) { + $composeArg = ConvertTo-DockerPath (Join-Path $HomeDir 'docker/docker-compose.yml') + $projArg = ConvertTo-DockerPath $HomeDir + Invoke-NativeVisible { Invoke-Docker compose -f $composeArg --project-directory $projArg -p code-memory up -d } + if ($LASTEXITCODE -ne 0) { + Warn "docker compose up failed" + } else { + Ok "containers up$(if ($script:DockerKind -eq 'wsl') { ' (via WSL2)' })" + if ($script:DockerKind -eq 'wsl') { + # Ports published inside WSL2 must reach Windows through localhost + # forwarding — verify end-to-end instead of assuming. + $reachable = $false + foreach ($i in 1..5) { + try { + Invoke-WebRequest -Uri 'http://127.0.0.1:6333/readyz' -TimeoutSec 5 -UseBasicParsing | Out-Null + $reachable = $true; break + } catch { Start-Sleep -Seconds 2 } + } + if ($reachable) { + Ok "Qdrant reachable from Windows at 127.0.0.1:6333" + } else { + Warn "Containers are up in WSL but 127.0.0.1:6333 is not reachable from Windows." + Dim "Check %USERPROFILE%\.wslconfig: localhostForwarding must not be false;" + Dim "with networkingMode=mirrored, make sure the host firewall allows the ports." + } } + Dim "FalkorDB browser: http://localhost:3000" + Dim "Qdrant dashboard: http://localhost:6333/dashboard" } + # Docker reached through WSL — with or without provisioning — needs the + # keepalive, or idle-shutdown takes the containers down between sessions. + if ($script:DockerKind -eq 'wsl') { Install-WslKeepalive } } else { Warn "docker step skipped" } diff --git a/install.sh b/install.sh index 635199d..ab4e7c5 100755 --- a/install.sh +++ b/install.sh @@ -8,12 +8,15 @@ # With flags (non-interactive mode): # curl -fsSL .../install.sh | bash -s -- \ # --yes --no-docker --no-ollama --no-claude --no-opencode --no-mcp --no-claims +# Force Desktop-free provisioning when no docker is found: +# --colima (macOS) | --docker-ce (Linux) # # What it does (idempotent): # 1. installs `uv` if missing (provides `uvx` + `uv tool`) # 2. installs the `code-memory` CLI via `uv tool install --from git+` # 3. drops docker-compose.yml + .env into $HOME/.code-memory/ -# 4. waits for Docker, starts FalkorDB + Qdrant +# 4. starts FalkorDB + Qdrant on any working docker engine — Docker Desktop +# is NOT required; offers Colima (macOS) / docker-ce (Linux) when none found # 5. waits for Ollama, pulls bge-m3 (+ optional gemma2:9b for claims) # 6. (optional, default Y) registers Claude Code plugin + MCP # 7. (optional, default N) installs OpenCode plugin @@ -35,6 +38,7 @@ WANT_CLAUDE="" WANT_OPENCODE="" WANT_MCP="" WANT_CLAIMS="" # pull gemma2:9b for claim extraction +FORCE_PROVISION="" # colima | dockerce — install a Desktop-free engine if none found ASSUME_YES=0 NON_INTERACTIVE=0 @@ -44,6 +48,8 @@ for arg in "$@"; do --non-interactive) NON_INTERACTIVE=1 ;; --docker) WANT_DOCKER=1 ;; --no-docker) WANT_DOCKER=0 ;; + --colima) FORCE_PROVISION="colima"; WANT_DOCKER=1 ;; + --docker-ce) FORCE_PROVISION="dockerce"; WANT_DOCKER=1 ;; --ollama) WANT_OLLAMA=1 ;; --no-ollama) WANT_OLLAMA=0 ;; --claude) WANT_CLAUDE=1 ;; @@ -112,6 +118,55 @@ pause_until_present() { return 0 } +# ---------- docker helpers (Docker Desktop NOT required) ---------- +# Any working engine is accepted: docker-ce, Colima, Docker Desktop, ... +# DOCKER may become "sudo docker" right after a Linux docker-ce install, +# where the docker group membership only applies after re-login. +DOCKER="docker" +docker_ok() { have docker && $DOCKER info >/dev/null 2>&1; } + +# macOS: Colima — free, lightweight daemon in a Lima VM, standard docker CLI. +provision_colima() { + if ! have brew; then + warn "Homebrew is required to install Colima: https://brew.sh" + return 1 + fi + step "Installing Colima + Docker CLI via Homebrew (no Docker Desktop needed)" + brew install colima docker docker-compose || return 1 + # Expose brew's compose v2 binary as the `docker compose` CLI plugin — + # the brew-documented way; code-memory always calls the plugin form. + mkdir -p "$HOME/.docker/cli-plugins" + ln -sfn "$(brew --prefix)/opt/docker-compose/bin/docker-compose" "$HOME/.docker/cli-plugins/docker-compose" + colima start || return 1 + dim "big repos: give the VM more RAM later with — colima stop && colima start --memory 8" + if ask_yn "Auto-start Colima at login (brew services)?" "Y"; then + brew services start colima || warn "brew services start colima failed" + fi + docker_ok +} + +# Linux: docker-ce via the official convenience script. +provision_dockerce() { + have curl || { warn "curl is required to fetch get.docker.com"; return 1; } + step "Installing docker-ce (get.docker.com, needs sudo)" + if ! curl -fsSL https://get.docker.com | sudo sh; then + warn "docker-ce install failed." + dim "Corporate proxy? Set HTTP_PROXY/HTTPS_PROXY and re-run." + return 1 + fi + sudo usermod -aG docker "$USER" || true + sudo systemctl enable --now docker || true + if docker info >/dev/null 2>&1; then + return 0 + fi + if sudo docker info >/dev/null 2>&1; then + DOCKER="sudo docker" + warn "docker group membership applies after re-login (or: newgrp docker) — using 'sudo docker' for this run" + return 0 + fi + return 1 +} + # ---------- 1. uv ---------- step "Ensuring uv is installed" if have uv; then @@ -151,23 +206,67 @@ if [ -z "$WANT_DOCKER" ]; then fi if [ "$WANT_DOCKER" -eq 1 ]; then step "Starting FalkorDB + Qdrant" - if pause_until_present docker "Docker" "https://www.docker.com/products/docker-desktop"; then - # ensure daemon up - if ! docker info >/dev/null 2>&1; then - warn "Docker CLI present but daemon not running. Start Docker Desktop." - if interactive; then - printf '%s?%s Press Enter once the daemon is up (or %sskip%s): ' "$YEL" "$RST" "$DIM" "$RST" - local_ans="" - IFS= read -r local_ans <"$TTY_IN" || local_ans="" - [ "$local_ans" = "skip" ] && WANT_DOCKER=0 - fi + OS="$(uname -s)" + + # Forced Desktop-free provisioning (--colima / --docker-ce), works + # non-interactively for CI-style installs. + if ! docker_ok && [ "$FORCE_PROVISION" = "colima" ]; then provision_colima || true; fi + if ! docker_ok && [ "$FORCE_PROVISION" = "dockerce" ]; then provision_dockerce || true; fi + + # CLI present but daemon unreachable — help start whichever engine this is. + if ! docker_ok && have docker; then + case "$OS" in + Darwin) + if have colima; then + if ask_yn "Docker daemon not running. Start it via 'colima start'?" "Y"; then colima start || true; fi + elif [ -d /Applications/Docker.app ]; then + warn "Docker CLI present but daemon not running. Start Docker Desktop." + else + warn "Docker CLI present but no daemon." + if ask_yn "Install + start Colima to provide one? (no Docker Desktop needed)" "Y"; then provision_colima || true; fi + fi + ;; + *) + warn "Docker CLI present but daemon not running." + dim "Try: sudo systemctl start docker" + ;; + esac + if ! docker_ok && interactive; then + printf '%s?%s Press Enter once the daemon is up (or %sskip%s): ' "$YEL" "$RST" "$DIM" "$RST" + _ans="" + IFS= read -r _ans <"$TTY_IN" || _ans="" + [ "$_ans" = "skip" ] && WANT_DOCKER=0 fi - if [ "$WANT_DOCKER" -eq 1 ]; then - docker compose -f "$HOME_DIR/docker/docker-compose.yml" --project-directory "$HOME_DIR" up -d - ok "containers up" - dim "FalkorDB browser: http://localhost:3000" - dim "Qdrant dashboard: http://localhost:6333/dashboard" + fi + + # No docker at all — offer the Desktop-free engine for this OS. + if [ "$WANT_DOCKER" -eq 1 ] && ! have docker; then + case "$OS" in + Darwin) + if ask_yn "No docker found. Install Colima + Docker CLI via Homebrew? (no Docker Desktop needed)" "Y"; then + provision_colima || true + fi + ;; + *) + if ask_yn "No docker found. Install docker-ce via get.docker.com (needs sudo)?" "Y"; then + provision_dockerce || true + fi + ;; + esac + if ! have docker; then + pause_until_present docker "A docker engine (Colima on macOS, docker-ce on Linux, or Docker Desktop)" \ + "$REPO_URL#docker-without-docker-desktop" || WANT_DOCKER=0 fi + fi + + if [ "$WANT_DOCKER" -eq 1 ] && docker_ok; then + $DOCKER compose -f "$HOME_DIR/docker/docker-compose.yml" --project-directory "$HOME_DIR" -p code-memory up -d + ok "containers up" + dim "FalkorDB browser: http://localhost:3000" + dim "Qdrant dashboard: http://localhost:6333/dashboard" + elif [ "$WANT_DOCKER" -eq 1 ]; then + warn "no working docker daemon — docker step skipped" + dim "see README § Docker without Docker Desktop" else warn "docker step skipped" fi diff --git a/plugins/claude-code/scripts/lib/memory.js b/plugins/claude-code/scripts/lib/memory.js index 62fc4e2..7f5c792 100644 --- a/plugins/claude-code/scripts/lib/memory.js +++ b/plugins/claude-code/scripts/lib/memory.js @@ -117,18 +117,67 @@ function _ingestLockLive(resolvedRoot, slug) { } } +/** + * Resolve a windowless Python launcher on Windows. + * + * The `code-memory` command is a uv/pip **console-subsystem** shim (a uv + * *trampoline* under uv installs). When spawned detached, the trampoline + * re-launches `python.exe` — also console-subsystem — which allocates a + * console window. Node's `windowsHide` only applies to the trampoline it + * spawns, not to the interpreter the trampoline re-launches, so a cmd window + * still flashes for every detached hook call. + * + * `pythonw.exe` is the GUI-subsystem interpreter: the OS never allocates a + * console for it, regardless of `detached`/`windowsHide`. Under Node's + * `stdio: "ignore"` it still receives valid NUL std handles, so `--json` + * commands keep working (their output is discarded anyway). Returns the + * pythonw path or null (→ fall back to the console shim). + * + * Resolution order: `CODE_MEMORY_PYTHONW` env override, then the uv tool + * venv (`%APPDATA%/uv/tools/<*code-memory*>/Scripts/pythonw.exe`). + */ +function _windowlessPythonw() { + if (process.platform !== "win32") return null; + const override = process.env.CODE_MEMORY_PYTHONW; + try { + if (override && fs.existsSync(override)) return override; + } catch { + /* ignore */ + } + const appdata = process.env.APPDATA; + if (!appdata) return null; + try { + const toolsDir = nodePath.join(appdata, "uv", "tools"); + for (const name of fs.readdirSync(toolsDir)) { + if (!/code[-_]memory/i.test(name)) continue; + const pyw = nodePath.join(toolsDir, name, "Scripts", "pythonw.exe"); + if (fs.existsSync(pyw)) return pyw; + } + } catch { + /* ignore — fall back to console shim */ + } + return null; +} + /** * Spawn detached fire-and-forget. Parent exits immediately. * stdout/stderr ignored. Used when the hook must not block. + * + * On Windows, routes through pythonw.exe (see _windowlessPythonw) so no + * console window is created; falls back to the `binary` shim elsewhere. */ function spawnDetached(binary, args, opts = {}) { const { spawn } = require("node:child_process"); const { cwd, env } = opts; + const pythonw = _windowlessPythonw(); + const cmd = pythonw || binary; + const argv = pythonw ? ["-m", "code_memory.cli", ...args] : args; try { - const child = spawn(binary, args, { + const child = spawn(cmd, argv, { cwd, env: env || process.env, detached: true, + windowsHide: true, stdio: "ignore", }); child.unref(); diff --git a/plugins/claude-code/scripts/on-post-tool.js b/plugins/claude-code/scripts/on-post-tool.js index 66011dd..3fd6fc0 100755 --- a/plugins/claude-code/scripts/on-post-tool.js +++ b/plugins/claude-code/scripts/on-post-tool.js @@ -77,6 +77,7 @@ function pickPath(obj) { const worker = path.join(__dirname, "resolver-debounce.js"); const child = spawn(process.execPath, [worker, cwd], { detached: true, + windowsHide: true, stdio: "ignore", env: process.env, }); diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f6b6efe..913d0ec 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -123,6 +123,52 @@ function Prompt-YesNo($question, $defaultYes) { return ($ans.ToLower() -in @('y', 'yes')) } +# ---------- docker resolution (Docker Desktop NOT required) ---------- +# Any working engine is accepted, probed in order: `docker` on PATH with a +# live daemon (Docker Desktop, docker-ce, ...), then docker-ce inside the +# default WSL2 distro via `wsl -e docker`. Sets $script:DockerKind to +# native|wsl|daemon-down|none and returns the argv prefix array (or $null). +# No provisioning here — the zero-clone installer (root install.ps1) has it. +function Resolve-DockerCmd { + $env:WSL_UTF8 = '1' + if (Test-Cmd 'docker') { + Invoke-NativeQuiet { docker info } + if ($LASTEXITCODE -eq 0) { $script:DockerKind = 'native'; return ,@('docker') } + } + if (Test-Cmd 'wsl') { + Invoke-NativeQuiet { wsl -e docker info } + if ($LASTEXITCODE -eq 0) { $script:DockerKind = 'wsl'; return ,@('wsl','-e','docker') } + } + $script:DockerKind = if (Test-Cmd 'docker') { 'daemon-down' } else { 'none' } + return $null +} + +# Run docker through the resolved prefix: Invoke-Docker compose ... up -d +# Relative paths (docker/docker-compose.yml) work through WSL unchanged — +# wsl.exe starts in the /mnt/... equivalent of the Windows cwd. +function Invoke-Docker { + param([Parameter(ValueFromRemainingArguments)]$Rest) + $exe = $script:DockerCmd[0] + $pre = @($script:DockerCmd | Select-Object -Skip 1) + & $exe @pre @Rest +} + +# Compose project label via plain-JSON `docker inspect` — not the Go template +# form, whose inner quotes are an argument-reparsing hazard through wsl.exe. +function Get-CmProjectLabel([string]$Name) { + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = 'SilentlyContinue' + try { + $json = (Invoke-Docker inspect $Name 2>$null) -join "`n" + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($json)) { return $null } + return (ConvertFrom-Json $json)[0].Config.Labels.'com.docker.compose.project' + } catch { + return $null + } finally { + $ErrorActionPreference = $prevEAP + } +} + $projectRoot = (Resolve-Path "$PSScriptRoot/..").Path Set-Location $projectRoot @@ -146,10 +192,26 @@ foreach ($candidate in @('python', 'python3', 'py')) { if (-not $pythonBin) { Die "Python 3.11+ not found. Install from https://www.python.org/." } if (-not $NoDocker) { - if (-not (Test-Cmd 'docker')) { Die "Docker not found. Install Docker Desktop: https://www.docker.com/." } - Invoke-NativeQuiet { docker compose version } + $script:DockerCmd = Resolve-DockerCmd + if (-not $script:DockerCmd) { + if ($script:DockerKind -eq 'daemon-down') { + Die "Docker CLI found but no daemon reachable. Start it (Docker Desktop, or WSL2 docker-ce: wsl -e sudo systemctl start docker) and re-run." + } + Die "No working docker daemon found (tried 'docker' and 'wsl -e docker'). Any engine works — docker-ce in WSL2, Colima, Docker Desktop. Provision one via the one-liner installer (install.ps1) or README section 'Docker without Docker Desktop'." + } + Invoke-NativeQuiet { Invoke-Docker compose version } if ($LASTEXITCODE -ne 0) { Die "Docker Compose v2 not available (need 'docker compose')." } - Ok "Docker present" + Ok "Docker present$(if ($script:DockerKind -eq 'wsl') { ' (via WSL2)' })" + if ($script:DockerKind -eq 'wsl') { + # WSL2 idle-shutdown stops dockerd (and the containers) ~1 min after the + # last session detaches. The one-liner installer sets up a hidden + # Startup-folder keepalive; just warn here if it's missing. + $vbs = Join-Path ([Environment]::GetFolderPath('Startup')) 'code-memory-wsl-docker.vbs' + if (-not (Test-Path $vbs)) { + Warn "no WSL keepalive found — dockerd will stop when WSL idles between sessions." + Dim "Install it via the one-liner installer (install.ps1), or see README section 'Docker without Docker Desktop'." + } + } } if (-not $NoOllama) { @@ -318,15 +380,15 @@ if (-not $NoDocker) { # data volumes, namespaced as _falkor_data, stay attached); fall # back to a stable name for fresh installs. This keeps install and # `code-memory update` on one project without ever orphaning indexed data. - $CmProject = (& docker inspect -f '{{ index .Config.Labels "com.docker.compose.project" }}' cm-falkordb 2>$null) - if (-not $CmProject) { $CmProject = (& docker inspect -f '{{ index .Config.Labels "com.docker.compose.project" }}' cm-qdrant 2>$null) } + $CmProject = Get-CmProjectLabel 'cm-falkordb' + if (-not $CmProject) { $CmProject = Get-CmProjectLabel 'cm-qdrant' } if (-not $CmProject) { $CmProject = "code-memory" } $CmProject = "$CmProject".Trim() - & docker compose -p $CmProject -f docker/docker-compose.yml up -d --remove-orphans + Invoke-Docker compose -p $CmProject -f docker/docker-compose.yml up -d --remove-orphans if ($LASTEXITCODE -ne 0) { Warn "compose up hit a container-name conflict — removing stale cm-* containers and retrying (named volumes persist)" - Invoke-NativeQuiet { docker rm -f cm-falkordb cm-qdrant cm-tei } - & docker compose -p $CmProject -f docker/docker-compose.yml up -d --remove-orphans + Invoke-NativeQuiet { Invoke-Docker rm -f cm-falkordb cm-qdrant cm-tei } + Invoke-Docker compose -p $CmProject -f docker/docker-compose.yml up -d --remove-orphans if ($LASTEXITCODE -ne 0) { Die "docker compose up failed" } } Ok "Containers up (project: $CmProject)" diff --git a/scripts/install.sh b/scripts/install.sh index 125fd20..8604fe9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -103,7 +103,12 @@ done [ -n "$PYTHON_BIN" ] || die "Python 3.11+ not found. Install from https://www.python.org/." if [ "$SKIP_DOCKER" -eq 0 ]; then - have docker || die "Docker not found. Install Docker Desktop: https://www.docker.com/" + # Any engine works — Docker Desktop is NOT required: docker-ce (Linux: + # get.docker.com), Colima (macOS: brew install colima docker docker-compose), + # or Docker Desktop. The zero-clone installer (install.sh at the repo root) + # can provision Colima/docker-ce; see README § Docker without Docker Desktop. + have docker || die "Docker not found. Any engine works: docker-ce (Linux), Colima (macOS), or Docker Desktop. See README § Docker without Docker Desktop." + docker info >/dev/null 2>&1 || die "Docker CLI present but daemon not reachable — colima start (macOS) / sudo systemctl start docker (Linux) / start Docker Desktop." docker compose version >/dev/null 2>&1 || die "Docker Compose v2 not found (need 'docker compose')." ok "Docker $(docker --version | awk '{print $3}' | tr -d ,)" fi diff --git a/src/code_memory/_docker.py b/src/code_memory/_docker.py new file mode 100644 index 0000000..900ce0f --- /dev/null +++ b/src/code_memory/_docker.py @@ -0,0 +1,168 @@ +"""Docker command resolution — no Docker Desktop required. + +The updater shells out to ``docker`` (compose pull/up, inspect, rm). That +works with any engine whose CLI is on PATH — Docker Desktop, docker-ce, +Colima — but on Windows the recommended Desktop-free setup is docker-ce +running *inside* WSL2, where no ``docker.exe`` exists on the Windows side. +There the CLI is still one hop away: ``wsl -e docker ...``. + +This module resolves, once per process, how to reach a working daemon: + +1. ``docker`` on PATH with a live daemon (``docker info``) — native. Docker + Desktop and friends stay first-class. +2. Windows only: ``wsl -e docker info`` — docker-ce inside the default WSL2 + distro (or ``CODEMEMORY_WSL_DISTRO`` to pin one). + +When commands run through WSL, Windows paths passed as arguments (compose +``-f`` / ``--project-directory``) must be translated to ``/mnt/c/...`` — +:func:`to_docker_path` does that via ``wslpath`` with a pure-Python +fallback. Paths *read back from docker* (compose labels) are already +daemon-side; validate those with :func:`docker_path_exists`, never with +``Path.exists()`` on the Windows side. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +Kind = Literal["native", "wsl", "daemon-down", "none"] + +# `docker info` answers in well under a second against a live daemon; the +# generous ceilings cover Docker Desktop mid-start (native) and a cold-boot +# of the WSL2 VM (wsl) without hanging the updater forever. +_NATIVE_TIMEOUT = 15.0 +_WSL_TIMEOUT = 20.0 +_AUX_TIMEOUT = 10.0 + +_DETAIL_DAEMON_DOWN = ( + "docker CLI found but no daemon reachable — start it " + "(WSL2: any `wsl` command boots the VM; macOS: `colima start`; " + "or start Docker Desktop if that's what you use)" +) +_DETAIL_NONE = ( + "no docker found (native or WSL) — " + "see README § Docker without Docker Desktop" +) + + +@dataclass(frozen=True) +class DockerResolution: + argv: tuple[str, ...] | None + kind: Kind + detail: str + + +_CACHED: DockerResolution | None = None +_PATH_CACHE: dict[str, str] = {} + + +def _is_windows() -> bool: + return sys.platform == "win32" + + +def _wsl_base() -> tuple[str, ...]: + distro = os.environ.get("CODEMEMORY_WSL_DISTRO", "").strip() + return ("wsl", "-d", distro) if distro else ("wsl",) + + +def _run_quiet(cmd: list[str], *, timeout: float) -> subprocess.CompletedProcess[str] | None: + """Run a probe command; None on missing binary, timeout, or spawn error. + + ``WSL_UTF8=1`` keeps wsl.exe's own diagnostics decodable (they are + UTF-16LE by default); harmless for non-wsl commands. + """ + resolved = shutil.which(cmd[0]) + if resolved is None: + return None + try: + return subprocess.run( + [resolved, *cmd[1:]], + capture_output=True, + text=True, + timeout=timeout, + env={**os.environ, "WSL_UTF8": "1"}, + ) + except (subprocess.TimeoutExpired, OSError): + return None + + +def _ok(cmd: list[str], *, timeout: float) -> bool: + p = _run_quiet(cmd, timeout=timeout) + return p is not None and p.returncode == 0 + + +def _resolve() -> DockerResolution: + native_cli = shutil.which("docker") is not None + if native_cli and _ok(["docker", "info"], timeout=_NATIVE_TIMEOUT): + return DockerResolution(("docker",), "native", "docker (native daemon)") + + if _is_windows(): + prefix = (*_wsl_base(), "-e", "docker") + if shutil.which("wsl") and _ok([*prefix, "info"], timeout=_WSL_TIMEOUT): + return DockerResolution(prefix, "wsl", f"docker via WSL (`{' '.join(prefix)}`)") + + if native_cli: + return DockerResolution(None, "daemon-down", _DETAIL_DAEMON_DOWN) + return DockerResolution(None, "none", _DETAIL_NONE) + + +def resolve_docker(*, refresh: bool = False) -> DockerResolution: + """How to reach a working docker daemon, cached per process.""" + global _CACHED + if _CACHED is None or refresh: + _CACHED = _resolve() + return _CACHED + + +def docker_argv() -> list[str] | None: + """Argv prefix for docker commands (``[..., "compose", ...]``), or None.""" + res = resolve_docker() + return list(res.argv) if res.argv else None + + +def to_docker_path(path: str | Path) -> str: + """Translate a Windows path for the resolved docker; identity when native. + + Only for paths that originate on this machine's filesystem. Paths read + back from docker (compose labels) are already daemon-side — pass those + verbatim and check them with :func:`docker_path_exists`. + """ + raw = str(path) + if resolve_docker().kind != "wsl": + return raw + hit = _PATH_CACHE.get(raw) + if hit is not None: + return hit + p = _run_quiet([*_wsl_base(), "-e", "wslpath", "-a", raw], timeout=_AUX_TIMEOUT) + if p is not None and p.returncode == 0 and p.stdout.strip(): + out = p.stdout.strip() + else: + out = _mnt_fallback(raw) + _PATH_CACHE[raw] = out + return out + + +def _mnt_fallback(raw: str) -> str: + # C:\Users\x -> /mnt/c/Users/x — enough for the default automount root. + if len(raw) >= 2 and raw[1] == ":": + return "/mnt/" + raw[0].lower() + raw[2:].replace("\\", "/") + return raw.replace("\\", "/") + + +def docker_path_exists(path: str | Path) -> bool: + """Existence check on the daemon's side of the filesystem boundary.""" + if resolve_docker().kind != "wsl": + return Path(path).exists() + return _ok([*_wsl_base(), "-e", "test", "-e", str(path)], timeout=_AUX_TIMEOUT) + + +def _reset_cache() -> None: + global _CACHED + _CACHED = None + _PATH_CACHE.clear() diff --git a/src/code_memory/_proc.py b/src/code_memory/_proc.py new file mode 100644 index 0000000..1ebf6fe --- /dev/null +++ b/src/code_memory/_proc.py @@ -0,0 +1,49 @@ +"""Process-spawn helpers. + +code-memory shells out constantly — git plumbing, ``schtasks``, and +self-invocations of its own CLI — and it does so from long-lived processes +that have **no console of their own**: the MCP server (launched hidden by the +coding agent) and the ``pythonw.exe`` watcher. On Windows, when a +console-subsystem child is spawned by a parent that has no console, the OS +allocates a brand-new console window for it — a visible ``cmd`` flash for every +single call. Multiply that by a file-watcher firing on every save and it reads +as "cmd windows popping up constantly". + +``CREATE_NO_WINDOW`` tells Windows not to allocate that console. Rather than +thread the flag through every scattered ``subprocess`` call site, we install it +once as the process-wide default at each entry point (see the callers of +:func:`install_windows_no_window_default`). No-op on POSIX. +""" + +from __future__ import annotations + +import subprocess +import sys + +_INSTALLED = False + + +def install_windows_no_window_default() -> None: + """Make every ``subprocess`` spawn in this process windowless on Windows. + + Wraps :class:`subprocess.Popen` so each child inherits + ``CREATE_NO_WINDOW`` unless the caller explicitly asked for other + creation flags. Idempotent and a no-op off Windows. Safe to call from + multiple entry points. + """ + global _INSTALLED + if _INSTALLED or sys.platform != "win32": + return + + no_window = subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined] + orig_init = subprocess.Popen.__init__ + + def _init(self, *args, **kwargs): # type: ignore[no-untyped-def] + # Only inject when the caller hasn't set its own creation flags, so we + # never clobber an intentional CREATE_NEW_CONSOLE / DETACHED_PROCESS. + if not kwargs.get("creationflags"): + kwargs["creationflags"] = no_window + orig_init(self, *args, **kwargs) + + subprocess.Popen.__init__ = _init # type: ignore[assignment,method-assign] + _INSTALLED = True diff --git a/src/code_memory/cli.py b/src/code_memory/cli.py index a530ea9..f8a348c 100644 --- a/src/code_memory/cli.py +++ b/src/code_memory/cli.py @@ -13,6 +13,7 @@ from dataclasses import asdict as _asdict from ._console import _force_utf8_console +from ._proc import install_windows_no_window_default from .config import CONFIG, detect_project_slug from .episodic import Episode from .graph import FalkorStore @@ -20,6 +21,9 @@ from .orchestrator import git_delta as _git_delta _force_utf8_console() +# Suppress console-window flashes from git / self-invocations spawned by +# console-less CLI processes (notably the pythonw watcher). No-op off Windows. +install_windows_no_window_default() def _graph_for(project: str | None) -> FalkorStore: diff --git a/src/code_memory/mcp_server.py b/src/code_memory/mcp_server.py index 8b243f2..36f1c87 100644 --- a/src/code_memory/mcp_server.py +++ b/src/code_memory/mcp_server.py @@ -49,10 +49,19 @@ from .orchestrator import Pipeline, Retriever from .orchestrator.pipeline import IngestMode +from ._proc import install_windows_no_window_default + log = logging.getLogger("codememory.mcp") SERVER_NAME = "code-memory" +# Suppress console-window flashes from git / schtasks / self-invocations +# spawned by this server. The MCP server is launched hidden (no console) by the +# coding agent, so any console-subsystem child would otherwise pop a fresh cmd +# window. Must run before the first subprocess call below (the startup slug +# probe on the next lines shells out to git). No-op off Windows. +install_windows_no_window_default() + def _resolved_default_slug() -> str: """Best-effort project slug at server startup. diff --git a/src/code_memory/sync/autostart/schtasks.py b/src/code_memory/sync/autostart/schtasks.py index f39e22a..9b369a8 100644 --- a/src/code_memory/sync/autostart/schtasks.py +++ b/src/code_memory/sync/autostart/schtasks.py @@ -9,6 +9,7 @@ import getpass import subprocess +import sys import tempfile from pathlib import Path from xml.sax.saxutils import escape @@ -18,13 +19,30 @@ TASK_FOLDER = "CodeMemory\\Watch" +def _windowless_watcher_command(repo: Path) -> list[str]: + """Watcher command that launches without a visible console window. + + ``watcher_command`` resolves the ``code-memory`` console-subsystem shim. + When Task Scheduler fires it under an interactive logon token, Windows + allocates a console, so a cmd window flashes (and can linger on error) for + every watched repo at logon. Re-point the command at ``pythonw.exe`` — the + GUI-subsystem interpreter that runs with no console — invoking the same CLI + module. Falls back to the default console command when ``pythonw.exe`` is + not found next to the running interpreter (e.g. non-standard installs). + """ + pythonw = Path(sys.executable).with_name("pythonw.exe") + if pythonw.exists(): + return [str(pythonw), "-m", "code_memory.cli", "watch", str(repo)] + return watcher_command(repo) + + class SchtasksAdapter: def _task_name(self, repo: Path) -> str: return f"{TASK_FOLDER}\\{repo_label(repo)}" def install(self, repo: Path) -> AutostartStatus: repo = Path(repo).resolve() - argv = watcher_command(repo) + argv = _windowless_watcher_command(repo) exe = argv[0] args = " ".join(_quote_win(a) for a in argv[1:]) user = getpass.getuser() diff --git a/src/code_memory/updater.py b/src/code_memory/updater.py index e01be0e..f4b4556 100644 --- a/src/code_memory/updater.py +++ b/src/code_memory/updater.py @@ -23,6 +23,7 @@ import importlib.util import json import os +import posixpath import shutil import subprocess import sys @@ -33,6 +34,7 @@ import httpx from . import __version__ as _LOCAL_VERSION +from ._docker import _is_windows, docker_argv, docker_path_exists, resolve_docker, to_docker_path PYPI_PACKAGE = "flurryx-code-memory" LEGACY_PACKAGE = "code-memory" # historical dist name still in older uv-tool venvs @@ -102,8 +104,18 @@ def _run(cmd: list[str], *, check: bool = False, capture: bool = True) -> subpro if os.name == "nt" and resolved.lower().endswith((".cmd", ".bat")): comspec = os.environ.get("COMSPEC", "cmd.exe") args = [comspec, "/c", *args] + # Decode child output as UTF-8, never the Windows ANSI code page: claude + # prints ❯, docker/wsl emit UTF-8 — cp1252 raises in the reader thread and + # leaves stdout=None. WSL_UTF8 keeps wsl.exe's own diagnostics decodable + # (UTF-16LE otherwise); harmless for every other child. return subprocess.run( - args, check=check, capture_output=capture, text=True, env={**os.environ} + args, + check=check, + capture_output=capture, + text=True, + encoding="utf-8", + errors="replace", + env={**os.environ, "WSL_UTF8": "1"}, ) @@ -190,35 +202,50 @@ def _docker_compose_present() -> bool: def _docker_running(service: str) -> bool: - if not _have("docker"): + argv = docker_argv() + if argv is None: return False - p = _run(["docker", "ps", "--format", "{{.Names}}"]) + p = _run([*argv, "ps", "--format", "{{.Names}}"]) if p.returncode != 0: return False return any(service in name for name in p.stdout.splitlines()) -def _running_compose_file() -> Path | None: +def _inspect_labels(name: str) -> dict[str, str]: + """Labels of a container via plain-JSON ``docker inspect``. + + Deliberately not ``inspect -f '{{ index ... }}'``: the Go template needs + inner double quotes, and wsl.exe re-parses argument strings — plain JSON + output removes that whole class of quoting bug for the WSL fallback. + """ + argv = docker_argv() + if argv is None: + return {} + p = _run([*argv, "inspect", name]) + if p.returncode != 0: + return {} + try: + return json.loads(p.stdout)[0].get("Config", {}).get("Labels") or {} + except Exception: # noqa: BLE001 + return {} + + +def _running_compose_file() -> str | None: """Probe live containers for the compose file that owns them. Handles dev installs whose compose file lives in the repo, not under ``~/.code-memory/docker/``. Returns the first compose path found among ``cm-falkordb`` / ``cm-qdrant`` containers, or None. + + The returned string is the raw label value — a path on the *daemon's* + side of the filesystem boundary (e.g. ``/mnt/c/...`` when compose last + ran through WSL). Pass it back to docker verbatim; never resolve it + with ``Path`` on the Windows side. """ - if not _have("docker"): - return None for name in ("cm-falkordb", "cm-qdrant"): - p = _run([ - "docker", - "inspect", - "-f", - "{{ index .Config.Labels \"com.docker.compose.project.config_files\" }}", - name, - ]) - if p.returncode == 0: - path = p.stdout.strip() - if path and Path(path).exists(): - return Path(path) + path = _inspect_labels(name).get("com.docker.compose.project.config_files", "").strip() + if path and docker_path_exists(path): + return path return None @@ -230,20 +257,10 @@ def _owning_compose_project() -> str | None: guessing a name from a directory basename (which is what caused the "container name already in use" conflict). """ - if not _have("docker"): - return None for name in ("cm-falkordb", "cm-qdrant"): - p = _run([ - "docker", - "inspect", - "-f", - "{{ index .Config.Labels \"com.docker.compose.project\" }}", - name, - ]) - if p.returncode == 0: - proj = p.stdout.strip() - if proj: - return proj + proj = _inspect_labels(name).get("com.docker.compose.project", "").strip() + if proj: + return proj return None @@ -255,9 +272,10 @@ def _remove_conflicting_containers() -> None: container squatted the name). Named volumes (falkor_data, qdrant_data, tei_data) survive ``rm``, so indexed data is preserved. """ - if not _have("docker"): + argv = docker_argv() + if argv is None: return - _run(["docker", "rm", "-f", *COMPOSE_CONTAINERS]) + _run([*argv, "rm", "-f", *COMPOSE_CONTAINERS]) def _claude_plugin_present() -> bool: @@ -299,6 +317,7 @@ def build_plan() -> UpdatePlan: falkor_running = _docker_running("falkor") qdrant_running = _docker_running("qdrant") compose_here = _docker_compose_present() + via_wsl = " (via WSL)" if resolve_docker().kind == "wsl" else "" plan.components = [ ComponentState( @@ -311,12 +330,12 @@ def build_plan() -> UpdatePlan: ComponentState( name="Docker: FalkorDB", present=compose_here or falkor_running, - detail="running" if falkor_running else ("compose present" if compose_here else "stopped"), + detail=f"running{via_wsl}" if falkor_running else ("compose present" if compose_here else "stopped"), ), ComponentState( name="Docker: Qdrant", present=compose_here or qdrant_running, - detail="running" if qdrant_running else ("compose present" if compose_here else "stopped"), + detail=f"running{via_wsl}" if qdrant_running else ("compose present" if compose_here else "stopped"), ), ] @@ -392,15 +411,24 @@ def upgrade_cli(method: InstallMethod, *, bleeding: bool = False) -> tuple[bool, def upgrade_docker_images() -> tuple[bool, str]: - if not _have("docker"): - return False, "docker not on PATH" - compose = CODEMEMORY_HOME / "docker" / "docker-compose.yml" - if not compose.exists(): + argv = docker_argv() + if argv is None: + return False, resolve_docker().detail + local = CODEMEMORY_HOME / "docker" / "docker-compose.yml" + if local.exists(): + # Our path, our side of the boundary: translate for the daemon. + compose = to_docker_path(local) + project_dir = to_docker_path(local.parent) + else: live = _running_compose_file() if live is None: return False, "no compose file at ~/.code-memory/ and no running cm-* containers" + # Label path is already daemon-side — verbatim, dirname on its rules. compose = live - project_dir = compose.parent + if resolve_docker().kind == "wsl": + project_dir = posixpath.dirname(compose) + else: + project_dir = str(Path(compose).parent) # Pin the project name so naming never depends on the directory basename. # Prefer the project the running containers already belong to — that is the @@ -409,9 +437,9 @@ def upgrade_docker_images() -> tuple[bool, str]: project = _owning_compose_project() or COMPOSE_PROJECT base = [ - "docker", "compose", - "-f", str(compose), - "--project-directory", str(project_dir), + *argv, "compose", + "-f", compose, + "--project-directory", project_dir, "-p", project, ] @@ -431,6 +459,52 @@ def upgrade_docker_images() -> tuple[bool, str]: return up_retry.returncode == 0, f"compose recreated after conflict (project={project}, {compose})" +# Legacy .env.example defaults that hang on Windows: `localhost` resolves to +# ::1 first while the containers publish IPv4-only ports — including through +# WSL2's localhost forwarding. Only these exact lines are rewritten; anything +# hand-edited (remote hosts, custom ports) is left alone. +_ENV_LOCALHOST_FIXES = { + "OLLAMA_URL=http://localhost:11434": "OLLAMA_URL=http://127.0.0.1:11434", + "QDRANT_URL=http://localhost:6333": "QDRANT_URL=http://127.0.0.1:6333", + "FALKOR_HOST=localhost": "FALKOR_HOST=127.0.0.1", +} + + +def migrate_env_localhost(env_path: Path | None = None) -> tuple[bool, str]: + """Rewrite legacy ``localhost`` defaults in ``~/.code-memory/.env``. + + Windows-only migration (the IPv6 hang is a Windows resolver behavior; + unix installs are left untouched). The pre-rewrite file is kept as + ``.env.bak``. Returns ``(changed, detail)``. + """ + if not _is_windows(): + return False, "not Windows — .env left alone" + path = env_path if env_path is not None else CODEMEMORY_HOME / ".env" + if not path.exists(): + return False, "no .env to migrate" + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + return False, f".env unreadable: {exc}" + + changed = False + out: list[str] = [] + for line in text.splitlines(): + fixed = _ENV_LOCALHOST_FIXES.get(line.strip()) + if fixed is not None: + out.append(fixed) + changed = True + else: + out.append(line) + if not changed: + return False, "no legacy localhost defaults" + + path.with_name(".env.bak").write_text(text, encoding="utf-8") + trailer = "\n" if text.endswith("\n") else "" + path.write_text("\n".join(out) + trailer, encoding="utf-8") + return True, "rewrote localhost -> 127.0.0.1 (backup: .env.bak)" + + def upgrade_ollama_model(model: str) -> tuple[bool, str]: if not _have("ollama"): return False, "ollama not on PATH" @@ -492,6 +566,10 @@ def run_update( return _run_full_installer() rc = 0 + changed, detail = migrate_env_localhost() + if changed: + print(f" Config: {detail}") + if behind_cli: ok, detail = upgrade_cli(plan.install_method, bleeding=bleeding) print(f" CLI upgrade: {'ok' if ok else 'FAILED'} — {detail}") diff --git a/tests/test_docker_resolve.py b/tests/test_docker_resolve.py new file mode 100644 index 0000000..a2185a7 --- /dev/null +++ b/tests/test_docker_resolve.py @@ -0,0 +1,251 @@ +"""Tests for the docker-command resolution helper (``code_memory._docker``). + +All subprocess activity is mocked — no docker, WSL, or network needed. +Guards the Desktop-free contract: + +1. A working native ``docker`` wins on every OS (Docker Desktop stays + first-class). +2. On Windows, a dead/missing native CLI falls back to ``wsl -e docker``; + POSIX never probes wsl. +3. Failures (missing binary, non-zero exit, timeout) degrade to + ``daemon-down`` / ``none`` — never an exception. +4. Resolution is cached per process; ``_reset_cache`` re-probes. +5. Path translation: identity when native, ``wslpath -a`` when wsl, with a + pure-Python ``/mnt/c/...`` fallback. +""" + +from __future__ import annotations + +import subprocess +from types import SimpleNamespace + +import pytest + +from code_memory import _docker + + +@pytest.fixture(autouse=True) +def fresh_cache(monkeypatch: pytest.MonkeyPatch): + _docker._reset_cache() + monkeypatch.delenv("CODEMEMORY_WSL_DISTRO", raising=False) + yield + _docker._reset_cache() + + +class FakeProc: + """Route shutil.which / subprocess.run through canned behaviors.""" + + def __init__( + self, + *, + on_path: set[str], + ok_cmds: set[tuple[str, ...]], + timeout_cmds: set[tuple[str, ...]] = frozenset(), + stdout: dict[tuple[str, ...], str] | None = None, + ): + self.on_path = on_path + self.ok_cmds = ok_cmds + self.timeout_cmds = timeout_cmds + self.stdout = stdout or {} + self.run_calls: list[tuple[str, ...]] = [] + + def which(self, name: str) -> str | None: + return f"/fake/{name}" if name in self.on_path else None + + def run(self, argv, **kwargs): + # argv[0] is the which()-resolved path; normalize back to the name. + key = (argv[0].rsplit("/", 1)[-1], *argv[1:]) + self.run_calls.append(key) + if key in self.timeout_cmds: + raise subprocess.TimeoutExpired(cmd=list(key), timeout=kwargs.get("timeout", 0)) + rc = 0 if key in self.ok_cmds else 1 + return SimpleNamespace(returncode=rc, stdout=self.stdout.get(key, ""), stderr="") + + def install(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_docker.shutil, "which", self.which) + monkeypatch.setattr(_docker.subprocess, "run", self.run) + + +def _set_windows(monkeypatch: pytest.MonkeyPatch, value: bool) -> None: + monkeypatch.setattr(_docker, "_is_windows", lambda: value) + + +# --------------------------------------------------------------------------- +# Resolution +# --------------------------------------------------------------------------- + + +def test_native_docker_healthy(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeProc(on_path={"docker"}, ok_cmds={("docker", "info")}) + fake.install(monkeypatch) + _set_windows(monkeypatch, True) + + res = _docker.resolve_docker() + assert res.argv == ("docker",) + assert res.kind == "native" + assert _docker.docker_argv() == ["docker"] + + +def test_wsl_fallback_on_windows(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeProc( + on_path={"docker", "wsl"}, + ok_cmds={("wsl", "-e", "docker", "info")}, + ) + fake.install(monkeypatch) + _set_windows(monkeypatch, True) + + res = _docker.resolve_docker() + assert res.argv == ("wsl", "-e", "docker") + assert res.kind == "wsl" + + +def test_wsl_fallback_when_no_native_cli(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeProc(on_path={"wsl"}, ok_cmds={("wsl", "-e", "docker", "info")}) + fake.install(monkeypatch) + _set_windows(monkeypatch, True) + + res = _docker.resolve_docker() + assert res.argv == ("wsl", "-e", "docker") + assert res.kind == "wsl" + + +def test_no_wsl_probe_on_posix(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeProc( + on_path={"docker", "wsl"}, + ok_cmds={("wsl", "-e", "docker", "info")}, # would succeed if probed + ) + fake.install(monkeypatch) + _set_windows(monkeypatch, False) + + res = _docker.resolve_docker() + assert res.kind == "daemon-down" + assert res.argv is None + assert not any(call[0] == "wsl" for call in fake.run_calls) + + +def test_daemon_down_detail_is_actionable(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeProc(on_path={"docker"}, ok_cmds=set()) + fake.install(monkeypatch) + _set_windows(monkeypatch, False) + + res = _docker.resolve_docker() + assert res.kind == "daemon-down" + assert "colima start" in res.detail + + +def test_nothing_found(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeProc(on_path=set(), ok_cmds=set()) + fake.install(monkeypatch) + _set_windows(monkeypatch, True) + + res = _docker.resolve_docker() + assert res.kind == "none" + assert res.argv is None + assert _docker.docker_argv() is None + + +def test_timeout_is_not_a_crash(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeProc( + on_path={"docker"}, + ok_cmds=set(), + timeout_cmds={("docker", "info")}, + ) + fake.install(monkeypatch) + _set_windows(monkeypatch, False) + + res = _docker.resolve_docker() + assert res.kind == "daemon-down" + + +def test_distro_override(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CODEMEMORY_WSL_DISTRO", "Ubuntu-24.04") + fake = FakeProc( + on_path={"wsl"}, + ok_cmds={("wsl", "-d", "Ubuntu-24.04", "-e", "docker", "info")}, + ) + fake.install(monkeypatch) + _set_windows(monkeypatch, True) + + res = _docker.resolve_docker() + assert res.argv == ("wsl", "-d", "Ubuntu-24.04", "-e", "docker") + + +def test_resolution_is_cached(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeProc(on_path={"docker"}, ok_cmds={("docker", "info")}) + fake.install(monkeypatch) + _set_windows(monkeypatch, False) + + _docker.resolve_docker() + calls_after_first = len(fake.run_calls) + _docker.resolve_docker() + _docker.docker_argv() + assert len(fake.run_calls) == calls_after_first + + _docker._reset_cache() + _docker.resolve_docker() + assert len(fake.run_calls) > calls_after_first + + +# --------------------------------------------------------------------------- +# Path translation +# --------------------------------------------------------------------------- + + +def _resolve_as_wsl(monkeypatch: pytest.MonkeyPatch, fake: FakeProc) -> None: + fake.ok_cmds.add(("wsl", "-e", "docker", "info")) + fake.on_path.add("wsl") + fake.install(monkeypatch) + _set_windows(monkeypatch, True) + assert _docker.resolve_docker().kind == "wsl" + + +def test_to_docker_path_identity_when_native(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeProc(on_path={"docker"}, ok_cmds={("docker", "info")}) + fake.install(monkeypatch) + _set_windows(monkeypatch, True) + + assert _docker.to_docker_path(r"C:\Users\x\.code-memory") == r"C:\Users\x\.code-memory" + + +def test_to_docker_path_uses_wslpath(monkeypatch: pytest.MonkeyPatch) -> None: + win = r"C:\Users\x\.code-memory\docker\docker-compose.yml" + wslpath_cmd = ("wsl", "-e", "wslpath", "-a", win) + fake = FakeProc( + on_path=set(), + ok_cmds={wslpath_cmd}, + stdout={wslpath_cmd: "/mnt/c/Users/x/.code-memory/docker/docker-compose.yml\n"}, + ) + _resolve_as_wsl(monkeypatch, fake) + + assert _docker.to_docker_path(win) == "/mnt/c/Users/x/.code-memory/docker/docker-compose.yml" + # Second lookup served from the cache — no extra wslpath spawn. + n = len(fake.run_calls) + _docker.to_docker_path(win) + assert len(fake.run_calls) == n + + +def test_to_docker_path_fallback_when_wslpath_fails(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeProc(on_path=set(), ok_cmds=set()) + _resolve_as_wsl(monkeypatch, fake) + + assert _docker.to_docker_path(r"C:\Users\x\.code-memory") == "/mnt/c/Users/x/.code-memory" + + +def test_docker_path_exists_via_wsl_test(monkeypatch: pytest.MonkeyPatch) -> None: + daemon_side = "/mnt/c/Users/x/.code-memory/docker/docker-compose.yml" + fake = FakeProc(on_path=set(), ok_cmds={("wsl", "-e", "test", "-e", daemon_side)}) + _resolve_as_wsl(monkeypatch, fake) + + assert _docker.docker_path_exists(daemon_side) is True + assert _docker.docker_path_exists("/mnt/c/absent") is False + + +def test_docker_path_exists_native(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeProc(on_path={"docker"}, ok_cmds={("docker", "info")}) + fake.install(monkeypatch) + _set_windows(monkeypatch, False) + + real = tmp_path / "compose.yml" + real.write_text("services: {}\n") + assert _docker.docker_path_exists(real) is True + assert _docker.docker_path_exists(tmp_path / "absent.yml") is False diff --git a/tests/test_updater_docker.py b/tests/test_updater_docker.py new file mode 100644 index 0000000..b41f1ea --- /dev/null +++ b/tests/test_updater_docker.py @@ -0,0 +1,298 @@ +"""Tests for the updater's docker wiring on top of ``code_memory._docker``. + +Everything is mocked at the ``updater._run`` / resolution boundary — no +docker needed. Guards the WSL-fallback contract: + +1. With a wsl resolution, compose gets the ``wsl -e docker`` prefix and the + ``-f`` / ``--project-directory`` paths are translated. +2. With a native resolution, Windows paths pass through untouched. +3. A compose path read back from a container label is daemon-side already: + passed verbatim (no re-translation), dirname computed posix-style. +4. No resolution → ``(False, )`` without running compose. +5. ``_owning_compose_project`` / ``_running_compose_file`` parse plain-JSON + ``docker inspect`` output (no Go templates). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from code_memory import updater +from code_memory._docker import DockerResolution + +WSL_RES = DockerResolution(("wsl", "-e", "docker"), "wsl", "docker via WSL") +NATIVE_RES = DockerResolution(("docker",), "native", "docker (native daemon)") +NONE_RES = DockerResolution(None, "none", "no docker found — see README") + + +class RunRecorder: + def __init__(self, responses: dict[tuple[str, ...], tuple[int, str]] | None = None): + self.calls: list[list[str]] = [] + self.responses = responses or {} + + def __call__(self, cmd, **kwargs): + self.calls.append(list(cmd)) + rc, out = self.responses.get(tuple(cmd), (0, "")) + return SimpleNamespace(returncode=rc, stdout=out, stderr="") + + +def _wire( + monkeypatch: pytest.MonkeyPatch, + *, + resolution: DockerResolution, + run: RunRecorder, + home: Path | None = None, +) -> None: + monkeypatch.setattr(updater, "resolve_docker", lambda **kw: resolution) + monkeypatch.setattr( + updater, "docker_argv", lambda: list(resolution.argv) if resolution.argv else None + ) + monkeypatch.setattr(updater, "_run", run) + if home is not None: + monkeypatch.setattr(updater, "CODEMEMORY_HOME", home) + + +def _make_home_compose(tmp_path: Path) -> Path: + docker_dir = tmp_path / "docker" + docker_dir.mkdir() + (docker_dir / "docker-compose.yml").write_text("services: {}\n") + return tmp_path + + +# --------------------------------------------------------------------------- +# upgrade_docker_images +# --------------------------------------------------------------------------- + + +def test_compose_via_wsl_translates_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = _make_home_compose(tmp_path) + run = RunRecorder() + _wire(monkeypatch, resolution=WSL_RES, run=run, home=home) + translated: list[str] = [] + + def fake_translate(p) -> str: + translated.append(str(p)) + return "/mnt/fake/" + Path(p).name + + monkeypatch.setattr(updater, "to_docker_path", fake_translate) + monkeypatch.setattr(updater, "_owning_compose_project", lambda: None) + + ok, msg = updater.upgrade_docker_images() + + assert ok, msg + pull = run.calls[0] + assert pull[:3] == ["wsl", "-e", "docker"] + assert pull[3] == "compose" + assert pull[pull.index("-f") + 1] == "/mnt/fake/docker-compose.yml" + assert pull[pull.index("--project-directory") + 1] == "/mnt/fake/docker" + assert len(translated) == 2 # -f and --project-directory, nothing else + + +def test_compose_native_keeps_windows_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = _make_home_compose(tmp_path) + run = RunRecorder() + _wire(monkeypatch, resolution=NATIVE_RES, run=run, home=home) + monkeypatch.setattr(updater, "to_docker_path", lambda p: str(p)) + monkeypatch.setattr(updater, "_owning_compose_project", lambda: "code-memory") + + ok, msg = updater.upgrade_docker_images() + + assert ok, msg + pull = run.calls[0] + assert pull[0] == "docker" + assert pull[pull.index("-f") + 1] == str(home / "docker" / "docker-compose.yml") + assert pull[pull.index("-p") + 1] == "code-memory" + + +def test_label_compose_path_passed_verbatim(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + label_path = "/mnt/c/_git/code-memory/docker/docker-compose.yml" + run = RunRecorder() + _wire(monkeypatch, resolution=WSL_RES, run=run, home=tmp_path) # no compose in home + + def must_not_translate(p) -> str: + raise AssertionError(f"daemon-side path re-translated: {p}") + + monkeypatch.setattr(updater, "to_docker_path", must_not_translate) + monkeypatch.setattr(updater, "_running_compose_file", lambda: label_path) + monkeypatch.setattr(updater, "_owning_compose_project", lambda: "code-memory") + + ok, msg = updater.upgrade_docker_images() + + assert ok, msg + pull = run.calls[0] + assert pull[pull.index("-f") + 1] == label_path + assert pull[pull.index("--project-directory") + 1] == "/mnt/c/_git/code-memory/docker" + + +def test_no_resolution_short_circuits(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + run = RunRecorder() + _wire(monkeypatch, resolution=NONE_RES, run=run, home=tmp_path) + + ok, msg = updater.upgrade_docker_images() + + assert ok is False + assert msg == NONE_RES.detail + assert run.calls == [] + + +def test_conflict_recovery_reuses_prefix(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + home = _make_home_compose(tmp_path) + + class FlakyRun(RunRecorder): + def __call__(self, cmd, **kwargs): + self.calls.append(list(cmd)) + # First `up` hits the name conflict; the retry succeeds. + if "up" in cmd and sum("up" in c for c in self.calls) == 1: + return SimpleNamespace(returncode=1, stdout="", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + run = FlakyRun() + _wire(monkeypatch, resolution=WSL_RES, run=run, home=home) + monkeypatch.setattr(updater, "to_docker_path", lambda p: str(p)) + monkeypatch.setattr(updater, "_owning_compose_project", lambda: None) + + ok, _ = updater.upgrade_docker_images() + + assert ok + rm = next(c for c in run.calls if "rm" in c) + assert rm[:3] == ["wsl", "-e", "docker"] + assert set(updater.COMPOSE_CONTAINERS) <= set(rm) + + +# --------------------------------------------------------------------------- +# Plain-JSON inspect parsing +# --------------------------------------------------------------------------- + + +def _inspect_payload(labels: dict[str, str]) -> str: + return json.dumps([{"Config": {"Labels": labels}}]) + + +def test_owning_project_from_json_inspect(monkeypatch: pytest.MonkeyPatch) -> None: + payload = _inspect_payload({"com.docker.compose.project": "my-proj"}) + run = RunRecorder({("wsl", "-e", "docker", "inspect", "cm-falkordb"): (0, payload)}) + _wire(monkeypatch, resolution=WSL_RES, run=run) + + assert updater._owning_compose_project() == "my-proj" + # No Go-template flag anywhere in the invocation. + assert all("-f" not in c for c in run.calls) + + +def test_owning_project_falls_through_to_qdrant(monkeypatch: pytest.MonkeyPatch) -> None: + payload = _inspect_payload({"com.docker.compose.project": "other"}) + run = RunRecorder( + { + ("docker", "inspect", "cm-falkordb"): (1, ""), + ("docker", "inspect", "cm-qdrant"): (0, payload), + } + ) + _wire(monkeypatch, resolution=NATIVE_RES, run=run) + + assert updater._owning_compose_project() == "other" + + +def test_running_compose_file_checks_daemon_side(monkeypatch: pytest.MonkeyPatch) -> None: + label_path = "/mnt/c/x/docker-compose.yml" + payload = _inspect_payload({"com.docker.compose.project.config_files": label_path}) + run = RunRecorder({("wsl", "-e", "docker", "inspect", "cm-falkordb"): (0, payload)}) + _wire(monkeypatch, resolution=WSL_RES, run=run) + checked: list[str] = [] + + def fake_exists(p) -> bool: + checked.append(str(p)) + return True + + monkeypatch.setattr(updater, "docker_path_exists", fake_exists) + + assert updater._running_compose_file() == label_path + assert checked == [label_path] + + +def test_running_compose_file_none_when_label_path_gone(monkeypatch: pytest.MonkeyPatch) -> None: + payload = _inspect_payload({"com.docker.compose.project.config_files": "/mnt/c/gone.yml"}) + run = RunRecorder( + { + ("docker", "inspect", "cm-falkordb"): (0, payload), + ("docker", "inspect", "cm-qdrant"): (0, payload), + } + ) + _wire(monkeypatch, resolution=NATIVE_RES, run=run) + monkeypatch.setattr(updater, "docker_path_exists", lambda p: False) + + assert updater._running_compose_file() is None + + +def test_inspect_labels_bad_json_is_empty(monkeypatch: pytest.MonkeyPatch) -> None: + run = RunRecorder({("docker", "inspect", "cm-falkordb"): (0, "not json")}) + _wire(monkeypatch, resolution=NATIVE_RES, run=run) + + assert updater._inspect_labels("cm-falkordb") == {} + + +# --------------------------------------------------------------------------- +# Legacy .env localhost migration +# --------------------------------------------------------------------------- + +LEGACY_ENV = ( + "OLLAMA_URL=http://localhost:11434\n" + "EMBED_MODEL=bge-m3\n" + "QDRANT_URL=http://localhost:6333\n" + "# a comment about FALKOR_HOST=localhost that must survive\n" + "FALKOR_HOST=localhost\n" + "FALKOR_PORT=6379\n" +) + + +def test_migrate_env_rewrites_legacy_defaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(updater, "_is_windows", lambda: True) + env = tmp_path / ".env" + env.write_text(LEGACY_ENV, encoding="utf-8") + + changed, detail = updater.migrate_env_localhost(env) + + assert changed, detail + text = env.read_text(encoding="utf-8") + assert "OLLAMA_URL=http://127.0.0.1:11434" in text + assert "QDRANT_URL=http://127.0.0.1:6333" in text + assert "FALKOR_HOST=127.0.0.1" in text + assert "# a comment about FALKOR_HOST=localhost that must survive" in text + assert "FALKOR_PORT=6379" in text + assert (tmp_path / ".env.bak").read_text(encoding="utf-8") == LEGACY_ENV + + +def test_migrate_env_leaves_custom_values_alone(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(updater, "_is_windows", lambda: True) + env = tmp_path / ".env" + custom = "QDRANT_URL=https://qdrant.internal:6333\nFALKOR_HOST=falkor.internal\n" + env.write_text(custom, encoding="utf-8") + + changed, _ = updater.migrate_env_localhost(env) + + assert changed is False + assert env.read_text(encoding="utf-8") == custom + assert not (tmp_path / ".env.bak").exists() + + +def test_migrate_env_noop_on_unix(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(updater, "_is_windows", lambda: False) + env = tmp_path / ".env" + env.write_text(LEGACY_ENV, encoding="utf-8") + + changed, detail = updater.migrate_env_localhost(env) + + assert changed is False + assert "not Windows" in detail + assert env.read_text(encoding="utf-8") == LEGACY_ENV + + +def test_migrate_env_missing_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(updater, "_is_windows", lambda: True) + + changed, detail = updater.migrate_env_localhost(tmp_path / ".env") + + assert changed is False + assert "no .env" in detail