From 6137710c25a39a007100b8f51d2d4bacfc9aac76 Mon Sep 17 00:00:00 2001 From: jon Date: Wed, 22 Jul 2026 14:11:18 -0500 Subject: [PATCH] feat: put ncp on PATH on both platforms; rename WSL reqs to Windows Ports the ncp PATH work from upstream. Registering ncp as a shell-profile function failed three ways on Windows: the default Restricted execution policy blocks the profile itself, profiles don't exist outside PowerShell (cmd, Win+R, Task Scheduler), and the self-elevating installer writes the admin's profile. Windows now ships management/ncp.cmd and the installer appends management\ to the Machine PATH via [Environment]::SetEnvironmentVariable -- never setx, which truncates PATH at 1024 chars. A .cmd is exempt from the execution policy. Linux symlinks /usr/local/bin/ncp -> management/nexus-cli.sh, falling back to the old .bashrc function when sudo is unavailable. Also renames requirements-wsl.txt to requirements-windows.txt and purges stale WSL references, including vite.config.js's dev-server comment and controlpanel.py's "Check WSLg." error string. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 6 +- README.md | 39 +++++---- bin/panel/install.sh | 2 +- bin/restore-linux.sh | 21 +++-- bin/sync.py | 2 +- install-windows.ps1 | 80 ++++++++----------- interface/web/vite.config.js | 5 +- launch_nexus.ps1 | 2 +- management/controlpanel.py | 4 +- management/ncp.cmd | 16 ++++ management/ncp.ps1 | 6 +- management/ncp.py | 2 +- ...ements-wsl.txt => requirements-windows.txt | 0 tests/test_smoke.py | 30 +++++-- 14 files changed, 124 insertions(+), 91 deletions(-) create mode 100644 management/ncp.cmd rename requirements-wsl.txt => requirements-windows.txt (100%) mode change 100644 => 100755 diff --git a/CLAUDE.md b/CLAUDE.md index 54cafd3..cf87477 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ start --ai`), not on backend startup. **Windows (recommended):** ```powershell powershell -ExecutionPolicy Bypass -File .\install-windows.ps1 # one-time native install -.\launch_nexus.ps1 # memory :8001 + backend :8000 +ncp web # memory :8001 + backend :8000 + UI ``` **Linux full stack (dev):** @@ -80,7 +80,7 @@ cd interface/web && npm run build ## Architecture ### Python venv -All Python code runs inside `Promethean/` (a local venv). Always activate it before running backend commands: `source Promethean/bin/activate`. Dependencies are layered: `requirements-base.txt` holds the GPU-agnostic core, and a thin overlay pins the right PyTorch build for the target — `requirements-amd.txt` (ROCm), `requirements-nvidia.txt` (CUDA, generated by `bin/gen-nvidia-reqs.py`), or `requirements-wsl.txt` (CPU-only). `bin/install.sh` selects NVIDIA, AMD, or CPU/WSL requirements from the host. +All Python code runs inside `Promethean/` (a local venv). Always activate it before running backend commands: `source Promethean/bin/activate`. Dependencies are layered: `requirements-base.txt` holds the GPU-agnostic core, and a thin overlay pins the right PyTorch build for the target — `requirements-amd.txt` (ROCm), `requirements-nvidia.txt` (CUDA, generated by `bin/gen-nvidia-reqs.py`), or `requirements-windows.txt` (CPU-only). `bin/sync.py` (`requirements()`) selects NVIDIA, AMD, or CPU/Windows requirements from the host. ### Synapse Backend (`synapse/`) FastAPI app at `synapse/main.py`. Key responsibilities: @@ -125,4 +125,4 @@ Most data lands in `synapse/memory/memory.db` (SQLite, WAL mode). Tables: memory | Python dependencies (base) | `requirements-base.txt` | | Python dependencies (AMD/ROCm) | `requirements-amd.txt` | | Python dependencies (NVIDIA/CUDA) | `requirements-nvidia.txt` (generated by `bin/gen-nvidia-reqs.py`) | -| Python dependencies (WSL/CPU) | `requirements-wsl.txt` | +| Python dependencies (Windows/CPU) | `requirements-windows.txt` | diff --git a/README.md b/README.md index 84597f3..ef415e2 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ memory service, and a React frontend. No external AI provider is called. NexusOS ("Nexus") is a self-hosted assistant you actually own. All inference runs through a **locally bundled Ollama** on `localhost`; conversations, facts, and settings live in local SQLite. It ships with desktop branding (XFCE theme, -icons, boot splash) so it can be run as a full assistant environment, not just -a web app. +icons, boot splash) so it can be run as a full assistant environment on Linux, +not just a web app. - **Chat** — streaming responses from local Ollama models (SSE). - **Persistent memory** — a dedicated service auto-extracts durable facts from @@ -47,26 +47,21 @@ cd nexus-core powershell -ExecutionPolicy Bypass -File .\install-windows.ps1 ``` -> **Execution policy:** Windows blocks unsigned `.ps1` scripts by default, so -> run them with `-ExecutionPolicy Bypass` as shown (a one-run override — nothing -> permanent). Double-clicking `install-windows.ps1` or running `.\install-windows.ps1` -> bare will fail with *"running scripts is disabled on this system"*. -> The installer self-elevates (a UAC prompt will appear). -> -> To allow scripts persistently instead (then you can run `.\...ps1` directly): -> ```powershell -> Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned -> ``` - -Then double-click the **NexusOS** desktop icon (the shortcut already passes the -bypass), or launch from a shell with: +Then double-click the **NexusOS** desktop icon, or launch from a **new** shell +(PATH is read at process start, so already-open windows won't have `ncp` yet): ```powershell -powershell -ExecutionPolicy Bypass -File .\launch_nexus.ps1 +ncp web ``` +> The installer needs `-ExecutionPolicy Bypass` because Windows blocks unsigned +> `.ps1` by default; it's a one-run override, nothing permanent. It self-elevates, +> so a UAC prompt appears. `ncp` itself is `management\ncp.cmd` on the machine +> PATH — a `.cmd`, so no execution-policy change is ever needed, and it works +> from cmd and Task Scheduler as well as PowerShell. + The app opens at `:8000`; click **Start AI** to launch Ollama. The installer -uses `requirements-wsl.txt` (CPU-only, pure-Python — no ML stack, since Ollama +uses `requirements-windows.txt` (CPU-only, pure-Python — no ML stack, since Ollama does all inference over HTTP). ### Linux @@ -77,12 +72,13 @@ does all inference over HTTP). ./install.sh # 2. Launch (memory :8001, backend :8000 — backend also serves the built UI) -./launch_nexus.sh +# The install symlinks ncp into /usr/local/bin (sudo); open a new shell first. +ncp web ``` Python deps are layered: `requirements-base.txt` (GPU-agnostic core) plus one GPU overlay — `requirements-amd.txt` (ROCm) or `requirements-nvidia.txt` (CUDA). -`requirements-wsl.txt` is the standalone CPU-only runtime (no base overlay). +`requirements-windows.txt` is the standalone CPU-only runtime (no base overlay). `bin/sync.py` picks the right one for the host. `./install.sh` is also the update path — re-run it any time to pull the latest @@ -115,12 +111,14 @@ ncp chat "" # stream a reply ncp memory list | add | rm ncp playbook list | show # first playbook (*) = active system prompt ncp history [query] # recent conversations +ncp doctor # diagnostics: venv, Node, imports, Ollama, status ``` ## Architecture | Component | Location | Role | |---|---|---| +| **Promethean** (venv) | `Promethean/` | The Python venv all backend code runs in — `source Promethean/bin/activate` (Linux) / `Promethean\Scripts\python.exe` (Windows). Keeps deps out of the system Python. | | **Synapse** (backend) | `synapse/` | FastAPI app. `/chat/stream`, `/playbooks`, `/memory`, `/models`, `/conversations`, `/settings`, `/ollama`, `/icons`. Assembles the system prompt: active playbook → reference playbooks → memory facts → relevant past snippets. | | **Memory service** | `synapse/memory/` | Separate FastAPI app (:8001). `/memories/extract` uses an Ollama prompt to decide what to persist. Shares the SQLite DB with the backend. | | **Playbooks** | `synapse/playbooks/` + `data/playbooks/` | Ordered `{id}.yaml` records managed by `PlaybookManager`. | @@ -142,6 +140,7 @@ management/ nexus-cli.sh, ncp API client, control panel, desktop theme bin/ install, backup/restore, panel + provisioning scripts assets/ branding: icons, boot splash, XFCE/GTK theme data/playbooks/ active playbook YAML +Promethean/ Python venv (gitignored, built by the installer) ``` ## Configuration @@ -151,7 +150,7 @@ data/playbooks/ active playbook YAML | Ollama host | `OLLAMA_HOST` env (default `http://127.0.0.1:11434`) | | Filesystem paths | `synapse/nexus_config.py` | | Frontend API base URL | `interface/web/src/config.js` | -| Python deps | `requirements-base.txt` + amd/nvidia GPU overlay; `requirements-wsl.txt` = standalone CPU runtime | +| Python deps | `requirements-base.txt` + amd/nvidia GPU overlay; `requirements-windows.txt` = standalone CPU runtime | --- diff --git a/bin/panel/install.sh b/bin/panel/install.sh index 0eaabfc..d069d91 100644 --- a/bin/panel/install.sh +++ b/bin/panel/install.sh @@ -41,7 +41,7 @@ cp -f "$NEXUS/management/panel/genmon-16.rc" "$PANEL_CFG/genmon-16.rc" # ── Register the genmon applets into the XFCE panel ──────────────────────── # The network applet's genmon (plugin-13) was added by hand once; the Nexus # (15) and Bluetooth (16) applets are wired up programmatically via xfconf so a -# fresh install picks them up. No-op on machines without XFCE (e.g. the WSL box). +# fresh install picks them up. No-op on machines without XFCE (e.g. the Windows box). # place = before|after — where to insert relative to the network applet (13). register_genmon() { local id=$1 place=$2 anchor=13 panel=panel-1 diff --git a/bin/restore-linux.sh b/bin/restore-linux.sh index 504e768..9711ef4 100644 --- a/bin/restore-linux.sh +++ b/bin/restore-linux.sh @@ -52,11 +52,20 @@ if [ "$stage" = "runtime" ]; then echo "Warning: bin/fetch-ollama.sh not found — skipping Ollama fetch." fi - # Shell wiring: the `ncp` function and the Promethean venv alias. This is the - # only place they get registered now that bin/install.sh is gone. Guarded by a - # grep so an in-place restore is a no-op and a scratch clone can't re-point an - # already-wired ~/.bashrc at itself. - if ! grep -q "management/nexus-cli.sh" "$HOME/.bashrc" 2>/dev/null; then + # `ncp` on PATH, not a shell function: /usr/local/bin is visible to sh, cron, + # systemd units and .desktop Exec lines, none of which read ~/.bashrc. The + # Windows installer does the matching thing with management\ncp.cmd on PATH. + # Falls back to the old ~/.bashrc function when there's no sudo (a shell + # function shadows the symlink anyway, so having both is harmless). + ncp_link="/usr/local/bin/ncp" + ncp_target="$NEXUS_ROOT/management/nexus-cli.sh" + if [ "$(readlink -f "$ncp_link" 2>/dev/null)" = "$ncp_target" ]; then + echo "ncp already on PATH at $ncp_link." + elif sudo ln -sfn "$ncp_target" "$ncp_link"; then + echo "Registered ncp at $ncp_link." + elif ! grep -q "management/nexus-cli.sh" "$HOME/.bashrc" 2>/dev/null; then + # Guarded by a grep so an in-place restore is a no-op and a scratch clone + # can't re-point an already-wired ~/.bashrc at itself. cat >> "$HOME/.bashrc" </dev/null; then diff --git a/bin/sync.py b/bin/sync.py index 3e8260e..6e60441 100644 --- a/bin/sync.py +++ b/bin/sync.py @@ -69,7 +69,7 @@ def venv_python() -> Path: def requirements() -> str: """Pick the PyTorch overlay for this host.""" if os.name == "nt": - return "requirements-wsl.txt" # CPU / pure-Python, right for native Windows + return "requirements-windows.txt" # CPU / pure-Python, right for native Windows if shutil.which("nvidia-smi"): return "requirements-nvidia.txt" lspci = shutil.which("lspci") diff --git a/install-windows.ps1 b/install-windows.ps1 index 443bc30..5a878d6 100644 --- a/install-windows.ps1 +++ b/install-windows.ps1 @@ -1,7 +1,7 @@ #Requires -Version 5.1 <# .SYNOPSIS - NexusOS installer for Windows (native - no WSL). + NexusOS installer for Windows (native). .DESCRIPTION Installs Python, Node.js, and Ollama via winget, builds the Promethean virtualenv and the web UI, and drops a desktop shortcut. NexusOS then runs @@ -11,17 +11,8 @@ Run from the nexus-core directory: Right-click install-windows.ps1 -> "Run with PowerShell" - Requires Windows 10/11 with winget (App Installer). No WSL, no reboot. -.PARAMETER InvokerProfile - Internal. The PowerShell profile path of the user who launched the - installer, captured before self-elevation and passed to the elevated run. - If a standard account elevates with a DIFFERENT admin's credentials, the - elevated process sees that admin's $PROFILE - so ncp would be registered - for the wrong user. Not meant to be passed by hand. + Requires Windows 10/11 with winget (App Installer). No reboot needed. #> -param( - [string]$InvokerProfile = "" -) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" @@ -57,11 +48,7 @@ $IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIden [Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $IsAdmin) { Write-Host "Requesting administrator privileges..." -ForegroundColor Yellow - # Capture THIS user's profile path before elevating and hand it to the - # elevated run: if UAC prompts for another admin's credentials, that process - # would otherwise register ncp in the wrong user's profile. - $Invoker = $PROFILE.CurrentUserAllHosts - Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" -InvokerProfile `"$Invoker`"" -Verb RunAs + Start-Process powershell -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`"" -Verb RunAs exit } @@ -100,7 +87,7 @@ if (-not (Test-Path $VenvPy)) { if (-not (Test-Path $VenvPy)) { Write-Fail "venv creation failed at $Venv" } & $VenvPy -m pip install --upgrade pip -q -$Req = Join-Path $RepoRoot "requirements-wsl.txt" # CPU / pure-Python overlay - right for native Windows too +$Req = Join-Path $RepoRoot "requirements-windows.txt" # CPU / pure-Python overlay - right for native Windows too if (-not (Test-Path $Req)) { Write-Fail "requirements file not found: $Req" } & $VenvPy -m pip install -r $Req if ($LASTEXITCODE -ne 0) { Write-Fail "pip install failed (exit $LASTEXITCODE) - see output above" } @@ -178,41 +165,40 @@ try { } # -- ncp command --------------------------------------------------------------- -# The Linux side registers ncp in ~/.bashrc from restore-linux.sh; this is the -# same idea for PowerShell. Guarded by a marker so re-running the installer is a -# no-op instead of stacking duplicate functions. +# management\ncp.cmd goes on the machine PATH rather than a `function ncp` in the +# PowerShell profile. Three reasons the profile route kept biting: +# 1. Execution policy defaults to Restricted, which blocks the profile itself - +# so the function was never defined, and ncp.ps1 could not have run anyway. +# 2. Profiles are a PowerShell thing. cmd, Win+R, Task Scheduler and .lnk +# targets all reported "ncp is not recognized". +# 3. We are elevated here, so $PROFILE is the ADMIN's profile - the whole +# -InvokerProfile dance existed to work around that. Machine PATH has no +# such split. +# The Linux side does the matching thing: a symlink in /usr/local/bin. Write-Step "Registering the ncp command" try { - # CurrentUserAllHosts (profile.ps1), not $PROFILE: $PROFILE is host-specific, - # so ncp would exist in the console but not in the VS Code terminal or ISE. - # Prefer the invoking user's path when we were elevated - see -InvokerProfile. - $ProfilePath = if ($InvokerProfile) { $InvokerProfile } else { $PROFILE.CurrentUserAllHosts } - $ProfileDir = Split-Path -Parent $ProfilePath - if (-not (Test-Path $ProfileDir)) { - New-Item -ItemType Directory -Path $ProfileDir -Force | Out-Null - } - $Marker = "# NexusOS ncp" - $Existing = "" - if (Test-Path $ProfilePath) { - $Existing = Get-Content -Path $ProfilePath -Raw -ErrorAction SilentlyContinue - } - if ($Existing -and $Existing.Contains($Marker)) { - Write-OK "ncp already registered in $ProfilePath" + $NcpCmd = Join-Path $RepoRoot "management\ncp.cmd" + if (-not (Test-Path $NcpCmd)) { throw "ncp.cmd not found at '$NcpCmd'" } + $BinDir = Split-Path -Parent $NcpCmd + + # Machine scope needs admin, which the self-elevation above already gave us. + # Read-modify-write through .NET, NEVER setx: setx silently truncates PATH at + # 1024 characters and has permanently broken machines. + $Current = [Environment]::GetEnvironmentVariable("Path", "Machine") + $Entries = @($Current -split ';' | Where-Object { $_ -ne "" }) + if ($Entries -contains $BinDir) { + Write-OK "ncp already on the machine PATH ($BinDir)" } else { - $NcpPs1 = Join-Path $RepoRoot "management\ncp.ps1" - # Without this, a Join-Path that fails writes `function ncp { & "" }` - - # a broken profile with no error. Fail into the catch below instead. - if (-not (Test-Path $NcpPs1)) { throw "ncp.ps1 not found at '$NcpPs1'" } - # ASCII encoding: the content is ASCII anyway, and -Encoding UTF8 on - # PowerShell 5.1 can plant a BOM in the middle of an existing profile. - Add-Content -Path $ProfilePath -Encoding ASCII -Value "" - Add-Content -Path $ProfilePath -Encoding ASCII -Value $Marker - Add-Content -Path $ProfilePath -Encoding ASCII -Value "function ncp { & `"$NcpPs1`" @args }" - Write-OK "ncp registered in $ProfilePath" + $New = (@($Entries) + $BinDir) -join ';' + [Environment]::SetEnvironmentVariable("Path", $New, "Machine") + Write-OK "ncp registered on the machine PATH ($BinDir)" } + # PATH is read at process start, so this shell and any already-open one still + # do not have it. Patch ours so the rest of the installer can call ncp. + $env:Path = "$env:Path;$BinDir" } catch { - Write-Warn "Could not register ncp - add this line to your PowerShell profile:" - Write-Warn " function ncp { & '$RepoRoot\management\ncp.ps1' @args }" + Write-Warn "Could not put ncp on PATH - add this directory to PATH by hand:" + Write-Warn " $RepoRoot\management" } # -- Desktop shortcut ---------------------------------------------------------- diff --git a/interface/web/vite.config.js b/interface/web/vite.config.js index 40640d7..dc3397b 100644 --- a/interface/web/vite.config.js +++ b/interface/web/vite.config.js @@ -4,7 +4,8 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], - // host: true binds 0.0.0.0 so the Windows browser can reach the dev server - // through WSL2 (loopback-only binding isn't reliably forwarded out of WSL). + // host: true binds 0.0.0.0 so the dev server is reachable from another + // machine on the LAN, not just localhost. Dev-only - production is the built + // dist/ served by the backend on :8000. server: { host: true }, }) diff --git a/launch_nexus.ps1 b/launch_nexus.ps1 index 788e46a..6f9f865 100644 --- a/launch_nexus.ps1 +++ b/launch_nexus.ps1 @@ -1,4 +1,4 @@ -# NexusOS launcher for Windows (native, no WSL, no Vite). +# NexusOS launcher for Windows (native, no Vite). # # Single-process app: the backend on :8000 serves the built web UI itself, so # this starts only the memory service + backend, then opens the UI as an Edge diff --git a/management/controlpanel.py b/management/controlpanel.py index 7d09b0c..7226b01 100644 --- a/management/controlpanel.py +++ b/management/controlpanel.py @@ -80,7 +80,7 @@ class NexusControlPanel: self.root = root if os.name != 'nt' and not os.environ.get('DISPLAY'): - print("ERROR: No DISPLAY variable. Check WSLg.") + print("ERROR: No DISPLAY variable. Is an X session running?") sys.exit(1) self.root.title("Nexus Control Panel") @@ -91,7 +91,7 @@ class NexusControlPanel: print(f"Icon load failed: {e}") if not sys.platform.startswith("linux"): - # Keep the custom borderless titlebar on Windows/WSL, + # Keep the custom borderless titlebar on Windows, # but allow normal stacking on Linux Mint. self.root.overrideredirect(True) self.root.configure(background="#1e1e1e") diff --git a/management/ncp.cmd b/management/ncp.cmd new file mode 100644 index 0000000..7bf2388 --- /dev/null +++ b/management/ncp.cmd @@ -0,0 +1,16 @@ +@echo off +REM ncp - PATH entry point on Windows. A .cmd, not a .ps1, on purpose: PowerShell's +REM execution policy governs .ps1 only, so this keeps working under the default +REM Restricted policy and from cmd, Win+R, Task Scheduler and .lnk targets - none +REM of which load a PowerShell profile. install-windows.ps1 puts this directory on +REM the machine PATH. ncp.ps1 stays for pwsh-on-Linux, where there is no PATH shim. +REM +REM ASCII only, same rule as the .ps1 files - a test in tests/test_smoke.py enforces it. +setlocal +set "ROOT=%~dp0.." +REM System Python fallback so backup/restore work before the venv exists; those +REM delegate to the stdlib-only bin/sync.py. Mirrors the same fallback in ncp.ps1. +set "PY=%ROOT%\Promethean\Scripts\python.exe" +if not exist "%PY%" set "PY=python" +"%PY%" "%ROOT%\management\ncp.py" %* +exit /b %ERRORLEVEL% diff --git a/management/ncp.ps1 b/management/ncp.ps1 index 378f0b6..a65b784 100644 --- a/management/ncp.ps1 +++ b/management/ncp.ps1 @@ -2,8 +2,10 @@ # file the Linux shell wrapper runs; this only picks an interpreter and forwards # the arguments. # -# Windows: install-windows.ps1 registers this automatically. To do it by hand, -# or to get ncp inside PowerShell on Linux, add to $PROFILE.CurrentUserAllHosts: +# On Windows, ncp comes from management/ncp.cmd on the machine PATH instead (a +# .cmd is not subject to the execution policy and works outside PowerShell too) - +# install-windows.ps1 sets that up. This file is what gives ncp to pwsh on Linux, +# where restore-linux.sh registers it in $PROFILE.CurrentUserAllHosts as: # # function ncp { & "/management/ncp.ps1" @args } # diff --git a/management/ncp.py b/management/ncp.py index a61840b..b71f45f 100644 --- a/management/ncp.py +++ b/management/ncp.py @@ -12,7 +12,7 @@ bin/restore-linux.sh, controlpanel.py, nexus-popup.py and nexus-app.sh all keep calling what they always called. psutil does the process work (cmdlines, pattern sweeps, port owners). It is -declared in requirements-base.txt AND requirements-wsl.txt, so both boxes have +declared in requirements-base.txt AND requirements-windows.txt, so both boxes have it - but it is imported lazily so `ncp backup`/`restore` still run before the venv exists, exactly as they did when they shelled out to sync.py. """ diff --git a/requirements-wsl.txt b/requirements-windows.txt old mode 100644 new mode 100755 similarity index 100% rename from requirements-wsl.txt rename to requirements-windows.txt diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 955c41d..5ae38e8 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -54,19 +54,39 @@ def test_default_models_have_one_source_of_truth(): assert hardcoded not in ps1, f"install-windows.ps1 hardcodes {hardcoded!r}" -def test_powershell_files_stay_ascii(): +def test_windows_scripts_stay_ascii(): # PowerShell 5.1 decodes BOM-less files as ANSI: one stray Unicode dash - # eats a quote and the whole script dies at parse time. Globbed rather than - # listed by name so a newly added .ps1 is covered without editing this test. + # eats a quote and the whole script dies at parse time. cmd.exe is worse + # still - it decodes by the console codepage. Globbed rather than listed by + # name so a newly added script is covered without editing this test. skip = {"Promethean", "node_modules", ".git", "dist"} - ps1s = [p for p in REPO_ROOT.rglob("*.ps1") if not skip & set(p.parts)] - assert ps1s, "no .ps1 files found - did the Windows path move?" + ps1s = [p for pat in ("*.ps1", "*.cmd") for p in REPO_ROOT.rglob(pat) + if not skip & set(p.parts)] + assert ps1s, "no .ps1/.cmd files found - did the Windows path move?" for path in ps1s: raw = path.read_bytes() bad = [(i, b) for i, b in enumerate(raw) if b > 0x7F] assert not bad, f"{path.relative_to(REPO_ROOT)} has non-ASCII bytes at {bad[:3]}" +def test_ncp_is_registered_on_path_not_in_a_shell_profile(): + """A `function ncp` in a shell profile is invisible to cron, .desktop Exec + lines, cmd.exe and Task Scheduler - and on Windows the default Restricted + execution policy blocks the profile outright. Both installers must put ncp + on PATH; the profile wiring only survives as a no-sudo fallback.""" + linux = (REPO_ROOT / "bin" / "restore-linux.sh").read_text(encoding="utf-8") + runtime = linux.split('if [ "$stage" = "runtime" ]; then')[1].split("\n exit 0\nfi")[0] + assert "/usr/local/bin/ncp" in runtime + + ps1 = (REPO_ROOT / "install-windows.ps1").read_text(encoding="utf-8") + assert "ncp.cmd" in ps1, "installer must register the .cmd shim, not a profile function" + # setx truncates PATH at 1024 characters and has permanently broken machines. + # Comments stripped so the code that explains the ban does not trip it. + code = "\n".join(ln for ln in ps1.splitlines() if not ln.strip().startswith("#")) + assert "setx" not in code.lower() + assert 'SetEnvironmentVariable("Path"' in code + + def test_first_playbook_is_the_system_prompt(tmp_path, monkeypatch): store = PlaybookFileStore(tmp_path) store.add_playbook(PlaybookItem(id="ctx", title="Reference", goal="ref goal",