From 958a511345b08dcee45f176b2249000fc0ff1888 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Thu, 30 Jul 2026 00:05:32 +0200 Subject: [PATCH] docs+cli: finish the install-script update contract The update handoff moved out of the product on every platform, but two loose ends were left behind. README was actively WRONG, not merely incomplete. It still documented in-place updates for macOS/Linux, and it promised "The MCP server also checks for updates on startup and notifies on the first tool call" -- a claim that died with the MCP update thread and then the daemon's curl check. The section now states the one contract that holds everywhere (`update` validates flags and prints the install script command), why it works that way (on Windows a running image cannot replace itself; on POSIX a deliberate choice, because an in-process updater is structurally a downloader), and the affirmative property that replaced it: cbm makes no network request of its own accord and the archives carry no download URLs at all. `uninstall` now REPORTS the install script it finds beside the binary, printing the path and the `rm` command, and says "would leave" under --dry-run. It does not delete it. install.sh places itself there so `update` has something to hand off to, but that file may equally be the user's own copy, a symlink into a checkout, or package-manager managed -- an uninstaller must not delete a file it cannot prove it owns, and one printed line leaves the decision where it belongs. Windows checks install.ps1 too and tolerates either path separator. Signed-off-by: Martin Vogel --- README.md | 18 +++++++++++------- src/cli/cli.c | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index da8f24ba5..b542178a0 100644 --- a/README.md +++ b/README.md @@ -155,24 +155,26 @@ Watcher registration is controlled separately by `auto_watch` (default `true`). ### Keeping Up to Date -**macOS / Linux:** +**Updates run from the install script on every platform, not from inside the running binary.** `codebase-memory-mcp update` validates your flags and then prints the exact command to run: ```bash -codebase-memory-mcp update +# macOS / Linux +bash "/install.sh" ``` -**Windows** — updates run from `install.ps1`, not from the running binary, because a running executable cannot replace its own image on Windows. `codebase-memory-mcp update` prints the exact command; it is: - ```powershell +# Windows powershell -ExecutionPolicy Bypass -File "\install.ps1" ``` -`install.ps1` is idempotent, so re-running it *is* the update: it stops the daemon, retires the running binary, installs the new one, and cleans up. `install.ps1` sits next to the binary in your install directory. If PowerShell refuses to run it because the file came from the internet, `Unblock-File` it first. +The install script is placed next to the binary at install time, so the printed path resolves beside the executable. It is idempotent, so re-running it *is* the update: it stops the daemon, retires the running binary, installs the new one, and cleans up. + +Why it works this way. On Windows it is a hard requirement — a running executable cannot replace its own image, so the swap has to happen from a process that is not the binary being replaced. On macOS and Linux it is a deliberate choice: an in-process updater is structurally a downloader (fetch an archive, verify it, unpack it, mark a file executable, run it), and shipping that composite in every binary to serve a command most people run a handful of times is a poor trade. The release archives now carry no download URLs at all, and **cbm makes no network request of its own accord** — it does not check for new versions in the background, and nothing phones home. You find out about releases from the install script, your package manager, or GitHub. + +If PowerShell refuses to run the script because the file came from the internet, `Unblock-File` it first. Installed through **npm or pip**? Update with your package manager on every platform (`npm install -g codebase-memory-mcp@latest` / `pip install -U codebase-memory-mcp`). -The MCP server also checks for updates on startup and notifies on the first tool call if a newer release is available. - ### Uninstall ```bash @@ -181,6 +183,8 @@ codebase-memory-mcp uninstall Removes owned agent config entries, skills, hooks, instructions, and the installed binary. Existing graph indexes are listed and deleted only after confirmation. +The install script placed beside the binary is **reported, not deleted** — uninstall prints its path and the `rm` command for it. It is left alone on purpose: it may be your own copy, a symlink into a checkout, or managed by a package manager, and an uninstaller should not delete a file it cannot prove it owns. + ## Features ### Graph & analysis diff --git a/src/cli/cli.c b/src/cli/cli.c index bbc9d5d05..8010e2258 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -10596,6 +10596,56 @@ typedef struct { bool dry_run; } cli_uninstall_activation_t; +/* Report — never delete — the updater script sitting next to the binary. + * + * install.sh/install.ps1 copies itself beside the executable so `update` has + * something to hand off to. Uninstall does NOT remove it, deliberately: we + * cannot prove we own that file. The user may have put their own copy there, it + * may be a symlink into a checkout, or a package manager may manage it. Deleting + * a file we merely expect to find is how an uninstaller eats something it + * shouldn't. Telling the user the exact path costs one line and leaves the + * decision with them. */ +static void cli_uninstall_report_leftover_installer(const char *bin_path, bool dry_run) { + if (!bin_path || !bin_path[0]) { + return; + } + const char *slash = strrchr(bin_path, '/'); +#ifdef _WIN32 + const char *backslash = strrchr(bin_path, '\\'); + if (backslash && (!slash || backslash > slash)) { + slash = backslash; + } + static const char *const installer_names[] = {"install.ps1", "install.sh"}; +#else + static const char *const installer_names[] = {"install.sh"}; +#endif + if (!slash || slash == bin_path) { + return; + } + size_t dir_len = (size_t)(slash - bin_path); + for (size_t i = 0; i < sizeof(installer_names) / sizeof(installer_names[0]); i++) { + char installer_path[CLI_BUF_1K]; + int written = snprintf(installer_path, sizeof(installer_path), "%.*s/%s", (int)dir_len, + bin_path, installer_names[i]); + if (written <= 0 || (size_t)written >= sizeof(installer_path)) { + continue; + } + struct stat installer_status; + if (stat(installer_path, &installer_status) != 0) { + continue; + } + if (dry_run) { + printf("Would leave %s in place (remove it yourself if you want it gone).\n", + installer_path); + } else { + printf("\nLeft in place: %s\n" + " This is the updater; uninstall does not delete it because it cannot\n" + " prove it owns it. To remove it as well:\n\n rm \"%s\"\n", + installer_path, installer_path); + } + } +} + /* Uninstall is an activation too: removing the executable or its indexes * while a daemon generation is starting/running would leave live sessions on * a partially removed installation. Keep every filesystem mutation inside @@ -10650,6 +10700,7 @@ static int cli_uninstall_activate(void *opaque) { if (activation->binary_exists) { printf("Removed %s\n", activation->bin_path); } + cli_uninstall_report_leftover_installer(activation->bin_path, activation->dry_run); return CLI_OK; }