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; }