From 74e35181587d96d195830db4d7f7650607cdab46 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 12:36:41 +0900 Subject: [PATCH 1/4] Add install.sh uninstall support (#1750) --- changelog.d/unreleased/1750.added.md | 16 +++++ install.sh | 68 +++++++++++++++++++++ tests/CodeIndex.Tests/InstallScriptTests.cs | 29 +++++++++ 3 files changed, 113 insertions(+) create mode 100644 changelog.d/unreleased/1750.added.md diff --git a/changelog.d/unreleased/1750.added.md b/changelog.d/unreleased/1750.added.md new file mode 100644 index 0000000000..1908579ee0 --- /dev/null +++ b/changelog.d/unreleased/1750.added.md @@ -0,0 +1,16 @@ +--- +category: added +issues: + - 1750 +affected: + - install.sh + - README.md +--- + +## English + +- **Added installer uninstall mode (#1750)** — `install.sh --uninstall` removes files installed next to the `cdidx` binary and `--purge-cache` can also remove the per-user cdidx cache, while documenting what remains manual. + +## 日本語 + +- **インストーラーに uninstall モードを追加しました (#1750)** — `install.sh --uninstall` は `cdidx` バイナリ横に配置されたファイルを削除し、`--purge-cache` ではユーザー単位の cdidx キャッシュも削除できます。手動で残るものも明示します。 diff --git a/install.sh b/install.sh index cd30c8960d..336b145973 100755 --- a/install.sh +++ b/install.sh @@ -9,6 +9,7 @@ # bash ./install.sh --self-test-local-mirror [--self-test-allow-overwrite] [vX.Y.Z] # bash ./install.sh --reinstall-real vX.Y.Z # bash ./install.sh --doctor [vX.Y.Z] +# bash ./install.sh --uninstall [--purge-cache] # # Optional env vars / 任意環境変数: # CDIDX_GITHUB_BASE_URL Release download base URL override @@ -92,6 +93,7 @@ SELF_TEST_ALLOW_OVERWRITE=0 EXISTING_BIN="" EXISTING_VERSION="" EXPLICIT_VERSION_REQUESTED=0 +PURGE_CACHE_ON_UNINSTALL=0 # --- Helpers / ヘルパー --- @@ -1069,6 +1071,54 @@ check_path() { esac } +uninstall_cdidx() { + info "cdidx uninstaller" + acquire_install_lock + + local removed=0 + local path + for path in \ + "${INSTALL_DIR}/${BINARY_NAME}" \ + "${INSTALL_DIR}/version.json" \ + "${INSTALL_DIR}/libe_sqlite3.so" \ + "${INSTALL_DIR}/libe_sqlite3.dylib" \ + "${INSTALL_DIR}/LICENSE" \ + "${INSTALL_DIR}/COMMERCIAL_LICENSE.md" \ + "${INSTALL_DIR}/INTEGRATION_POLICY.md" \ + "${INSTALL_DIR}/TRADEMARKS.md" \ + "${INSTALL_DIR}/MANIFEST.sha256"; do + if [ -e "$path" ]; then + rm -f "$path" + info "Removed ${path}" + removed=1 + fi + done + + if [ -d "${INSTALL_DIR}/LICENSES" ]; then + rm -rf "${INSTALL_DIR}/LICENSES" + info "Removed ${INSTALL_DIR}/LICENSES" + removed=1 + fi + + if [ "$PURGE_CACHE_ON_UNINSTALL" = "1" ]; then + local cache_dir="${XDG_CACHE_HOME:-${HOME}/.cache}/cdidx" + if [ -d "$cache_dir" ]; then + rm -rf "$cache_dir" + info "Removed ${cache_dir}" + removed=1 + fi + fi + + if [ "$removed" = "0" ]; then + warn "No cdidx install files were found under ${INSTALL_DIR}." + fi + + echo "" + info "Uninstall complete." + echo "Not removed: project-local .cdidx/ directories, shell profile PATH edits, shell completion scripts, or global-tool installs managed by dotnet/Homebrew." + echo "To remove cached update metadata too, rerun with --uninstall --purge-cache." +} + report_local_mirror_start_failure() { local local_mirror_port="$1" local local_mirror_log="$2" @@ -1809,6 +1859,24 @@ if [ "${CDIDX_INSTALL_SH_LIB_ONLY:-0}" != "1" ]; then shift run_doctor "${1:-}" ;; + --uninstall) + shift + while [ $# -gt 0 ]; do + case "$1" in + --purge-cache) + PURGE_CACHE_ON_UNINSTALL=1 + shift + ;; + --*) + error "Unknown uninstall option: $1" + ;; + *) + error "--uninstall does not accept a version argument." + ;; + esac + done + uninstall_cdidx + ;; *) main "$@" ;; diff --git a/tests/CodeIndex.Tests/InstallScriptTests.cs b/tests/CodeIndex.Tests/InstallScriptTests.cs index 37e84fcfea..67e65cf97f 100644 --- a/tests/CodeIndex.Tests/InstallScriptTests.cs +++ b/tests/CodeIndex.Tests/InstallScriptTests.cs @@ -24,6 +24,35 @@ public void Dispose() TestProjectHelper.DeleteDirectory(_tempRoot); } + [Fact] + public void Uninstall_RemovesInstalledPayloadAndLeavesProjectData() + { + if (OperatingSystem.IsWindows()) + return; + + var installDir = Path.Combine(_tempRoot, "uninstall_bin"); + Directory.CreateDirectory(installDir); + File.WriteAllText(Path.Combine(installDir, "cdidx"), "#!/usr/bin/env bash\n"); + File.WriteAllText(Path.Combine(installDir, "version.json"), "{}"); + File.WriteAllText(Path.Combine(installDir, "libe_sqlite3.so"), ""); + Directory.CreateDirectory(Path.Combine(installDir, "LICENSES")); + File.WriteAllText(Path.Combine(installDir, "LICENSES", "Apache-2.0.txt"), ""); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + "uninstall_cdidx", + new Dictionary + { + ["CDIDX_INSTALL_DIR"] = installDir, + }); + + Assert.Equal(0, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Contains("Uninstall complete", stdout); + Assert.False(File.Exists(Path.Combine(installDir, "cdidx"))); + Assert.False(File.Exists(Path.Combine(installDir, "version.json"))); + Assert.False(Directory.Exists(Path.Combine(installDir, "LICENSES"))); + } + [Theory] [InlineData("linux", "x64", "linux-x64", "libe_sqlite3.so")] [InlineData("osx", "arm64", "osx-arm64", "libe_sqlite3.dylib")] From 940e7f3630441f54f2b35745ba7e29313b91460d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 12:37:33 +0900 Subject: [PATCH 2/4] Add workspace version pin checks (#1752) --- DEVELOPER_GUIDE.md | 8 ++ changelog.d/unreleased/1752.added.md | 16 ++++ src/CodeIndex/Cli/ProgramRunner.cs | 109 +++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 changelog.d/unreleased/1752.added.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 02f3798d65..3a0464217d 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -121,6 +121,14 @@ Do not add mutable static caches, shared `StringBuilder` instances, reused `Matc `status --check` keeps the DB/worktree checksum comparison in `IndexFreshnessChecker`, but the user-facing age hint threshold is resolved in `QueryCommandRunner`: CLI `--stale-after ` wins over `CDIDX_STALE_AFTER`, which wins over `.cdidxrc.json`'s `stale_after`, then the 24-hour default. Supported duration suffixes are `m`, `h`, and `d`. JSON output includes `stale_after_seconds` and `index_age_seconds` only for `--check`, so clients can confirm which threshold was applied without inferring it from text. +### Workspace version pinning + +On startup, `cdidx` walks up from the current directory looking for `.cdidx-version`. The first non-empty line is treated as the required CLI version for that workspace. A mismatch prints a warning and continues by default; `--strict-version` or `CDIDX_STRICT_VERSION=1` turns the mismatch into exit code `64` (`EX_USAGE`). This check is advisory and does not rewrite the file. Use it to keep teams on the same binary when index contracts or query behavior differ between releases. + +### Release freshness and upgrade checks + +`cdidx --check-updates` and `cdidx status --check-updates` query the GitHub latest-release endpoint through `UpdateChecker`, using the same 24-hour cache and `CDIDX_DISABLE_UPDATE_CHECK=1` opt-out as the `--version` hint. `cdidx upgrade --check-only` reuses that check. `cdidx upgrade` is intentionally a thin wrapper around the signed release installer: it downloads `install.sh`, verifies the current binary directory is writable, sets `CDIDX_INSTALL_DIR` to that directory, and runs the installer for the latest release. + ### Degradation reason codes Readiness degradation reason codes are centralized in `DegradationReasonCodes`. Add new codes there with human text, a recommended action, and an alternative action before emitting them from readers, CLI, or MCP payloads. diff --git a/changelog.d/unreleased/1752.added.md b/changelog.d/unreleased/1752.added.md new file mode 100644 index 0000000000..b1d85b728c --- /dev/null +++ b/changelog.d/unreleased/1752.added.md @@ -0,0 +1,16 @@ +--- +category: added +issues: + - 1752 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Added workspace version pin warnings (#1752)** — `.cdidx-version` now warns when the running CLI version differs from the workspace pin, and `--strict-version` / `CDIDX_STRICT_VERSION=1` can turn the mismatch into exit code 64. + +## 日本語 + +- **ワークスペースのバージョン固定警告を追加しました (#1752)** — `.cdidx-version` と実行中 CLI のバージョンが異なる場合に警告し、`--strict-version` / `CDIDX_STRICT_VERSION=1` で不一致を exit code 64 にできます。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 41ba6419e8..d2ebed58b3 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -72,10 +72,18 @@ internal static int Run( using var metricsSession = MetricsSink.TryStart(metricsPath); TryConsumeDebugUnsafeFlag(ref args); + if (!TryConsumeStrictVersionFlag(ref args, out var strictVersion, out var strictVersionError)) + { + CommandErrorWriter.Write(StripErrorPrefix(strictVersionError), "use `--strict-version` without a value."); + return CommandExitCodes.InvalidArgument; + } using var jsonAnsiScope = ConsoleUi.SuppressAnsiForJsonOutput(ContainsJsonOutputFlag(args)); var commandStopwatch = Stopwatch.StartNew(); var commandStartTimestamp = DateTimeOffset.UtcNow; + var versionPinExit = CheckWorkspaceVersionPin(appVersion, configStartDirectory ?? Environment.CurrentDirectory, strictVersion); + if (versionPinExit != CommandExitCodes.Success) + return versionPinExit; if (args.Length == 0 || args[0] is "--help" or "-h") { @@ -643,6 +651,107 @@ internal static bool TryConsumeDebugUnsafeFlag(ref string[] args) return seen; } + internal static bool TryConsumeStrictVersionFlag(ref string[] args, out bool strictVersion, out string error) + { + strictVersion = IsTruthyEnvironmentVariable("CDIDX_STRICT_VERSION"); + error = string.Empty; + if (args.Length == 0) + return true; + + var kept = new List(args.Length); + var passthrough = false; + foreach (var arg in args) + { + if (passthrough) + { + kept.Add(arg); + continue; + } + if (arg == "--") + { + passthrough = true; + kept.Add(arg); + continue; + } + if (arg == "--strict-version") + { + strictVersion = true; + continue; + } + if (arg.StartsWith("--strict-version=", StringComparison.Ordinal)) + { + error = "Error: --strict-version does not accept a value."; + return false; + } + kept.Add(arg); + } + + args = kept.ToArray(); + return true; + } + + private static int CheckWorkspaceVersionPin(string appVersion, string startDirectory, bool strictVersion) + { + var pinPath = FindWorkspaceVersionPin(startDirectory); + if (pinPath == null) + return CommandExitCodes.Success; + + string required; + try + { + required = File.ReadLines(pinPath).FirstOrDefault(line => !string.IsNullOrWhiteSpace(line))?.Trim() ?? ""; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: could not read .cdidx-version at {pinPath}: {ex.Message}"); + return CommandExitCodes.Success; + } + + if (string.IsNullOrWhiteSpace(required) || VersionsMatch(required, appVersion)) + return CommandExitCodes.Success; + + var message = $"workspace requires cdidx v{NormalizeVersion(required)}, but this binary is v{NormalizeVersion(appVersion)} ({pinPath})."; + if (!strictVersion) + { + Console.Error.WriteLine($"Warning: {message}"); + return CommandExitCodes.Success; + } + + Console.Error.WriteLine($"Error: {message}"); + Console.Error.WriteLine("Hint: rerun without --strict-version to warn only, or install the pinned cdidx version for this workspace."); + return CommandExitCodes.ExUsage; + } + + internal static string? FindWorkspaceVersionPin(string startDirectory) + { + var current = Path.GetFullPath(startDirectory); + if (File.Exists(current)) + current = Path.GetDirectoryName(current) ?? current; + + while (!string.IsNullOrWhiteSpace(current)) + { + var candidate = Path.Combine(current, ".cdidx-version"); + if (File.Exists(candidate)) + return candidate; + + var parent = Directory.GetParent(current); + if (parent == null) + return null; + current = parent.FullName; + } + + return null; + } + + private static bool VersionsMatch(string required, string actual) + => string.Equals(NormalizeVersion(required), NormalizeVersion(actual), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeVersion(string value) + { + var trimmed = value.Trim(); + return trimmed.StartsWith('v') || trimmed.StartsWith('V') ? trimmed[1..] : trimmed; + } + // Strip `--metrics ` / `--metrics=` from the global args before subcommand // parsing so any command (CLI or MCP) inherits the same JSONL metrics sink without // each subcommand re-declaring the flag. Falls back to the CDIDX_METRICS env var when From ea530350c275753f7d764d4af294091fad15cbe6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 12:37:48 +0900 Subject: [PATCH 3/4] Add update and upgrade commands (#1744, #1748) --- README.md | 15 +- changelog.d/unreleased/1744.added.md | 17 +++ changelog.d/unreleased/1748.added.md | 18 +++ src/CodeIndex/Cli/CliFlagSchema.cs | 6 +- src/CodeIndex/Cli/ConsoleUi.cs | 6 +- src/CodeIndex/Cli/ProgramRunner.cs | 149 ++++++++++++++++++++ src/CodeIndex/Cli/QueryCommandRunner.cs | 9 ++ src/CodeIndex/Cli/UpdateChecker.cs | 53 +++++++ tests/CodeIndex.Tests/ProgramRunnerTests.cs | 69 +++++++++ 9 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 changelog.d/unreleased/1744.added.md create mode 100644 changelog.d/unreleased/1748.added.md diff --git a/README.md b/README.md index 70986480ae..0c986526bc 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,20 @@ file completion. | Drift checks | `cdidx diff ` compares schema, file, symbol, and reference deltas with stable exit codes: `0` identical, `1` drift, `2` schema mismatch, `3` unreadable DB. | | Extensibility and feedback | Post-extraction hooks from `~/.config/cdidx/hooks/*.dll` or `CDIDX_HOOKS_DIR` can enrich symbols and references. `cdidx suggestions` lists, inspects, and exports local suggestion history, with fuzzy MCP suggestion deduplication controlled by CLI, env, or `.cdidxrc.json`. | | Language coverage | 78 detected languages, with symbol and graph support where available. | -| Updates | `cdidx --version` checks GitHub releases at most once per day and appends a newer-release hint when one is available. Set `CDIDX_DISABLE_UPDATE_CHECK=1` to suppress the check. | +| Updates | `cdidx --version` checks GitHub releases at most once per day and appends a newer-release hint when one is available. Use `cdidx --check-updates` or `cdidx status --check-updates` for an explicit freshness check, and `cdidx upgrade` to reinstall the latest GitHub release via `install.sh`. Set `CDIDX_DISABLE_UPDATE_CHECK=1` to suppress checks. | + +### Upgrade and uninstall + +`cdidx upgrade --check-only` reports whether a newer GitHub release is available. `cdidx upgrade` downloads the current `install.sh`, refuses unwritable install directories, and reruns the installer with `CDIDX_INSTALL_DIR` pointed at the current binary directory. + +Direct `install.sh` installs can be removed with: + +```bash +bash ./install.sh --uninstall +bash ./install.sh --uninstall --purge-cache +``` + +The uninstaller removes files placed next to the `cdidx` binary and can optionally remove `~/.cache/cdidx`. It does not remove project `.cdidx/` directories, shell profile PATH edits, shell completion scripts, Homebrew installs, or .NET global-tool installs. The documented `status --json` trust contract covers these fields: diff --git a/changelog.d/unreleased/1744.added.md b/changelog.d/unreleased/1744.added.md new file mode 100644 index 0000000000..4011c5ba17 --- /dev/null +++ b/changelog.d/unreleased/1744.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 1744 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - README.md +--- + +## English + +- **Added `cdidx upgrade` for release self-upgrades (#1744)** — `upgrade` checks GitHub release freshness, supports `--check-only`, refuses unwritable install directories, and reruns the signed `install.sh` installer for the latest release. + +## 日本語 + +- **リリース自己更新用の `cdidx upgrade` を追加しました (#1744)** — `upgrade` は GitHub release の鮮度を確認し、`--check-only` をサポートし、書き込み不能なインストール先を拒否したうえで最新リリース向けに署名済み `install.sh` を再実行します。 diff --git a/changelog.d/unreleased/1748.added.md b/changelog.d/unreleased/1748.added.md new file mode 100644 index 0000000000..a3001e6b54 --- /dev/null +++ b/changelog.d/unreleased/1748.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 1748 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/UpdateChecker.cs + - README.md +--- + +## English + +- **Added explicit update checks (#1748)** — `cdidx --check-updates` and `cdidx status --check-updates` now use the cached GitHub latest-release check so operators can ask for version freshness without running an upgrade. + +## 日本語 + +- **明示的な更新確認を追加しました (#1748)** — `cdidx --check-updates` と `cdidx status --check-updates` がキャッシュ付き GitHub latest-release 確認を使うようになり、upgrade せずにバージョン鮮度を確認できます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 60c68433ea..6008bcf574 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -54,7 +54,7 @@ internal static class CliFlagSchema [ "index", "backfill-fold", "optimize", "search", "definition", "references", "callers", "callees", "symbols", "files", "find", "excerpt", "map", "inspect", "outline", "status", - "validate", "deps", "impact", "unused", "hotspots", "languages", "batch", "mcp", "completions", "db", "vacuum", "report", "license", + "validate", "deps", "impact", "unused", "hotspots", "languages", "batch", "mcp", "completions", "db", "vacuum", "report", "license", "upgrade", ]; // Commands that accept the `--` end-of-options marker so a user can pass a literal @@ -244,6 +244,10 @@ private static IReadOnlyList BuildAll() new() { Name = "--stale-after", ValuePlaceholder = "", Description = "Status: freshness age threshold (e.g. 30m, 2h, 7d)", Commands = Set("status") }, new() { Name = "--explain", ValuePlaceholder = "", Description = "Explain one status readiness field", Commands = Set("status") }, new() { Name = "--log-path", Description = "Print the active persistent log directory", Commands = Set("status") }, + new() { Name = "--check-updates", Description = "Check whether a newer cdidx release is available", Commands = Set("status", "upgrade") }, + new() { Name = "--check-only", Description = "Upgrade: only report whether an upgrade is available", Commands = Set("upgrade") }, + new() { Name = "--channel", ValuePlaceholder = "", Description = "Upgrade channel selector (reserved)", Commands = Set("upgrade") }, + new() { Name = "--prerelease", Description = "Upgrade: include prerelease versions (reserved)", Commands = Set("upgrade") }, new() { Name = "--integrity-check", Description = "Run PRAGMA integrity_check on the database", Commands = Set("db") }, new() { Name = "--rebuild", Description = "Delete existing DB and rebuild from scratch", Commands = Set("index") }, new() { Name = "--optimize", Description = "Optimize the existing FTS5 table without scanning files", Commands = Set("index") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 7c902e73eb..85521ebcb5 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -80,7 +80,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("map", "cdidx map [--db ] [--json] [--verbose] [--limit ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes]"), ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--verbose] [--limit ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]"), ("outline", "cdidx outline [--db ] [--json] [--verbose]"), - ("status", "cdidx status [--db ] [--json] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path]"), + ("status", "cdidx status [--db ] [--json] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--check-updates]"), ("db", "cdidx db --integrity-check [--db ] [--json]"), ("diff", "cdidx diff [--json] [--summary-only] [--detailed] [--limit ]"), ("report", "cdidx report --output [--db ] [--json] [--log-lines ] [--no-log] [--include-args]"), @@ -95,6 +95,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("mcp", "cdidx mcp [--db ]"), ("completions", "cdidx completions "), ("--completions", "cdidx --completions "), + ("upgrade", "cdidx upgrade [--check-only]"), ("license", "cdidx license"), ]; @@ -659,6 +660,7 @@ void WriteHelpLine(string line = "") Console.WriteLine(" inspect Bundle definition, graph, and nearby symbol context"); Console.WriteLine(" outline Show a file outline ordered by line, start column, kind, and name"); Console.WriteLine(" status Show database statistics; add --check for freshness, --explain for readiness, or --log-path for logs"); + Console.WriteLine(" upgrade Check for and install the latest release via install.sh"); Console.WriteLine(" db --integrity-check Run SQLite `PRAGMA integrity_check` and report findings"); Console.WriteLine(" diff Compare two index databases; exit 0 identical, 1 drift, 2 schema mismatch, 3 unreadable"); Console.WriteLine(" report --output Build a redacted crash-repro tarball (.tgz) for bug reports"); @@ -1030,7 +1032,7 @@ private static int DamerauLevenshteinDistance(string s, string t) [ "index", "backfill-fold", "optimize", "search", "definition", "references", "callers", "callees", "symbols", "files", "find", "excerpt", "map", "inspect", "outline", "status", - "validate", "deps", "impact", "unused", "hotspots", "languages", "batch", "mcp", "completions", "db", "vacuum", "report", "license", + "validate", "deps", "impact", "unused", "hotspots", "languages", "batch", "mcp", "completions", "db", "vacuum", "report", "license", "upgrade", ]; /// diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index d2ebed58b3..2330cfcc8b 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -102,6 +102,14 @@ internal static int Run( return versionExitCode; } + if (args[0] == "--check-updates") + { + var updateExitCode = RunCheckUpdates(args[1..], jsonOptions, appVersion); + GlobalToolLog.Info($"command_complete exit_code={updateExitCode} check_updates=true"); + EmitCommandMetric("check-updates", args, commandStartTimestamp, commandStopwatch, updateExitCode); + return updateExitCode; + } + if (args[0] is "--license" or "license") { if (args[0] == "license" && args.Length > 1 && ArgHelper.WantsHelp(args.AsSpan(1))) @@ -213,6 +221,7 @@ internal static int Run( { exitCode = commandName switch { + "upgrade" => RunUpgrade(subArgs, jsonOptions, appVersion), "index" => IndexCommandRunner.Run(subArgs, jsonOptions), "diff" => DiffCommandRunner.Run(subArgs, jsonOptions), "hooks" => HookCommandRunner.Run(subArgs, jsonOptions), @@ -1595,6 +1604,146 @@ internal static bool TryConsumeAuditLogFlags(ref string[] args, out AuditLogOpti internal readonly record struct AuditLogOptions(string? Path, long MaxBytes, bool IncludeValues); + internal static int RunCheckUpdates(string[] cmdArgs, JsonSerializerOptions jsonOptions, string appVersion) + { + var wantsJson = false; + foreach (var arg in cmdArgs) + { + if (arg == "--json") + { + wantsJson = true; + continue; + } + Console.Error.WriteLine($"Error: --check-updates does not accept '{arg}'."); + Console.Error.WriteLine("Hint: use `cdidx --check-updates` or `cdidx --check-updates --json`."); + return CommandExitCodes.UsageError; + } + + var result = UpdateChecker.Check(appVersion); + if (wantsJson) + { + Console.WriteLine(JsonSerializer.Serialize(result, jsonOptions)); + return CommandExitCodes.Success; + } + + if (result.UpdateAvailable && result.LatestVersion != null) + Console.WriteLine($"A newer cdidx release is available: {result.LatestVersion} (current: {result.CurrentVersion})."); + else if (result.Error != null) + Console.WriteLine($"Could not check for updates; using cached release metadata if available (current: {result.CurrentVersion})."); + else + Console.WriteLine($"cdidx is up to date (current: {result.CurrentVersion})."); + return CommandExitCodes.Success; + } + + internal static int RunUpgrade(string[] cmdArgs, JsonSerializerOptions jsonOptions, string appVersion) + { + var checkOnly = false; + var wantsJson = false; + foreach (var arg in cmdArgs) + { + if (arg == "--check-only") + { + checkOnly = true; + continue; + } + if (arg == "--json") + { + wantsJson = true; + continue; + } + if (arg is "--channel" or "--prerelease" || arg.StartsWith("--channel=", StringComparison.Ordinal)) + { + Console.Error.WriteLine("Error: upgrade channels and prerelease upgrades are not supported yet."); + Console.Error.WriteLine("Hint: rerun `install.sh` with an explicit release tag if you need a non-latest version."); + return CommandExitCodes.UsageError; + } + Console.Error.WriteLine($"Error: upgrade does not accept '{arg}'."); + Console.Error.WriteLine("Hint: use `cdidx upgrade` or `cdidx upgrade --check-only`."); + return CommandExitCodes.UsageError; + } + + var result = UpdateChecker.Check(appVersion); + if (checkOnly || !result.UpdateAvailable || result.LatestVersion == null) + { + if (wantsJson) + Console.WriteLine(JsonSerializer.Serialize(result, jsonOptions)); + else if (result.UpdateAvailable && result.LatestVersion != null) + Console.WriteLine($"A newer cdidx release is available: {result.LatestVersion} (current: {result.CurrentVersion})."); + else + Console.WriteLine($"cdidx is up to date (current: {result.CurrentVersion})."); + return CommandExitCodes.Success; + } + + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + { + Console.Error.WriteLine("Error: cdidx upgrade currently requires a POSIX shell installer on Linux or macOS."); + Console.Error.WriteLine("Hint: download the latest release asset manually, or rerun install.sh from a shell environment."); + return CommandExitCodes.FeatureUnavailable; + } + + var installDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (!CanWriteDirectory(installDir)) + { + Console.Error.WriteLine($"Error: install directory is not writable: {installDir}"); + Console.Error.WriteLine("Hint: rerun with permissions that can write this directory, or reinstall cdidx into a per-user directory."); + return CommandExitCodes.UsageError; + } + + var scriptPath = Path.Combine(Path.GetTempPath(), $"cdidx-install-{Guid.NewGuid():N}.sh"); + try + { + using (var client = new HttpClient { Timeout = TimeSpan.FromSeconds(20) }) + { + var script = client.GetStringAsync("https://raw.githubusercontent.com/Widthdom/CodeIndex/main/install.sh") + .GetAwaiter() + .GetResult(); + File.WriteAllText(scriptPath, script); + } + + var startInfo = new ProcessStartInfo("bash", $"{QuoteShellArg(scriptPath)} {QuoteShellArg(result.LatestVersion)}") + { + UseShellExecute = false, + }; + startInfo.Environment["CDIDX_INSTALL_DIR"] = installDir; + var process = Process.Start(startInfo); + if (process == null) + { + Console.Error.WriteLine("Error: failed to start install.sh for upgrade."); + return CommandExitCodes.DatabaseError; + } + process.WaitForExit(); + return process.ExitCode; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: upgrade failed before install.sh completed ({ex.GetType().Name}: {ex.Message})."); + Console.Error.WriteLine("Hint: rerun `install.sh` manually for the desired release."); + return CommandExitCodes.DatabaseError; + } + finally + { + try { File.Delete(scriptPath); } catch { } + } + } + + private static bool CanWriteDirectory(string directory) + { + try + { + Directory.CreateDirectory(directory); + var probe = Path.Combine(directory, $".cdidx-write-test-{Guid.NewGuid():N}"); + File.WriteAllText(probe, ""); + File.Delete(probe); + return true; + } + catch + { + return false; + } + } + + private static string QuoteShellArg(string value) + => "'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; // `--version` is now build-aware so dev builds from main are not // indistinguishable from tagged releases in bug reports (#1550). Human diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index dc12eefc54..10e8304a96 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -171,6 +171,7 @@ private sealed record StatusReadinessField( "--with-paths", "--bytes", "--profile", + "--check-updates", ]; private static readonly HashSet InlineValueOptions = new(ValueTakingOptions.Concat(["--json"]), StringComparer.Ordinal); @@ -2127,6 +2128,9 @@ private static bool LooksLikeCsharpTopLevelStatements(OutlineResult outline, str public static int RunStatus(string[] cmdArgs, JsonSerializerOptions jsonOptions, string? appVersion = null) { + var checkUpdates = cmdArgs.Contains("--check-updates", StringComparer.Ordinal); + if (checkUpdates) + cmdArgs = cmdArgs.Where(arg => !string.Equals(arg, "--check-updates", StringComparison.Ordinal)).ToArray(); var previewOptionError = ValidatePreviewOptions("status", cmdArgs, allowMaxLineWidth: false, allowFocusOptions: false); if (previewOptionError != null) { @@ -2212,6 +2216,9 @@ public static int RunStatus(string[] cmdArgs, JsonSerializerOptions jsonOptions, } if (appVersion != null) status.Version = appVersion; + var updateResult = checkUpdates && appVersion != null + ? UpdateChecker.Check(appVersion) + : null; // Build one-line summary for AI orientation / AI向けの1行サマリーを構築 var topLangs = status.Languages.OrderByDescending(kv => kv.Value).Take(3).Select(kv => kv.Key); @@ -2263,6 +2270,8 @@ public static int RunStatus(string[] cmdArgs, JsonSerializerOptions jsonOptions, Console.WriteLine(); if (status.Version != null) Console.WriteLine(ConsoleUi.FormatSummaryLine("Version", $"cdidx v{status.Version}")); + if (updateResult?.UpdateAvailable == true && updateResult.LatestVersion != null) + Console.WriteLine(ConsoleUi.FormatSummaryLine("Update", $"cdidx v{updateResult.LatestVersion} is available.")); Console.WriteLine(ConsoleUi.FormatSummaryLine("Files", $"{status.Files:N0}")); Console.WriteLine(ConsoleUi.FormatSummaryLine("Chunks", $"{status.Chunks:N0}")); Console.WriteLine(ConsoleUi.FormatSummaryLine("Symbols", $"{status.Symbols:N0}")); diff --git a/src/CodeIndex/Cli/UpdateChecker.cs b/src/CodeIndex/Cli/UpdateChecker.cs index 576517046b..d076e2cd63 100644 --- a/src/CodeIndex/Cli/UpdateChecker.cs +++ b/src/CodeIndex/Cli/UpdateChecker.cs @@ -18,6 +18,52 @@ internal static class UpdateChecker DateTimeOffset.UtcNow, FetchLatestReleaseTagAsync); + internal static UpdateCheckResult Check(string currentVersion) + => Check( + currentVersion, + ResolveDefaultCachePath(), + DateTimeOffset.UtcNow, + FetchLatestReleaseTagAsync); + + internal static UpdateCheckResult Check( + string currentVersion, + string cachePath, + DateTimeOffset now, + Func> fetchLatestReleaseTagAsync) + { + if (IsDisabled()) + return new UpdateCheckResult(currentVersion, null, false, false, "disabled"); + + var cache = ReadCache(cachePath); + var fromCache = cache is not null && now - cache.CheckedAt < CacheTtl; + string? latestTag = fromCache ? cache!.LatestTag : null; + string? error = null; + + if (!fromCache) + { + try + { + latestTag = fetchLatestReleaseTagAsync(CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + catch (Exception ex) + { + latestTag = cache?.LatestTag; + error = ex.GetType().Name; + } + + TryWriteCache(cachePath, new UpdateCheckCache(now, latestTag)); + } + + return new UpdateCheckResult( + currentVersion, + latestTag, + IsNewerRelease(latestTag, currentVersion), + fromCache, + error); + } + internal static string? GetNewerReleaseHint( string currentVersion, string cachePath, @@ -166,3 +212,10 @@ private static bool TryParseVersion(string value, out Version version) private sealed record UpdateCheckCache(DateTimeOffset CheckedAt, string? LatestTag); } + +internal sealed record UpdateCheckResult( + string CurrentVersion, + string? LatestVersion, + bool UpdateAvailable, + bool FromCache, + string? Error); diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index 6015d36e46..000532a534 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -173,6 +173,75 @@ public void Run_OperationCanceledException_ReturnsCancelledExitCode() Assert.StartsWith("Error: command cancelled before it could complete.", trimmed); } + [Fact] + public void Run_WorkspaceVersionPinMismatch_WarnsByDefault() + { + var projectRoot = TestProjectHelper.CreateTempProject("version-pin-warn"); + try + { + File.WriteAllText(Path.Combine(projectRoot, ".cdidx-version"), "9.9.9\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["--version", "--json"], + appVersion: "1.10.0", + configStartDirectory: projectRoot)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Contains("\"version\":\"1.10.0\"", stdout); + Assert.Contains("workspace requires cdidx v9.9.9", stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_WorkspaceVersionPinMismatch_StrictFailsBeforeCommand() + { + var projectRoot = TestProjectHelper.CreateTempProject("version-pin-strict"); + try + { + File.WriteAllText(Path.Combine(projectRoot, ".cdidx-version"), "9.9.9\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["--strict-version", "--version"], + appVersion: "1.10.0", + configStartDirectory: projectRoot)); + + Assert.Equal(CommandExitCodes.ExUsage, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("workspace requires cdidx v9.9.9", stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void UpdateChecker_Check_ReportsNewerRelease() + { + var cachePath = Path.Combine(Path.GetTempPath(), $"cdidx_update_check_{Guid.NewGuid():N}.json"); + try + { + var result = UpdateChecker.Check( + "1.10.0", + cachePath, + DateTimeOffset.Parse("2026-01-01T00:00:00Z"), + _ => Task.FromResult("v1.11.0")); + + Assert.True(result.UpdateAvailable); + Assert.Equal("v1.11.0", result.LatestVersion); + Assert.False(result.FromCache); + } + finally + { + if (File.Exists(cachePath)) + File.Delete(cachePath); + } + } + [Theory] [InlineData("~/cdidx-logs", "cdidx-logs")] [InlineData("$HOME/cdidx-logs", "cdidx-logs")] From 49dae96f162e37b2df4a2ef4ae3eadb8775d751f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 12:55:17 +0900 Subject: [PATCH 4/4] Fix update check cache and upgrade alias (#1744, #1748, #1750) --- src/CodeIndex/Cli/ProgramRunner.cs | 2 +- src/CodeIndex/Cli/UpdateChecker.cs | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 711ca8f763..6a64ae44aa 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -1642,7 +1642,7 @@ internal static int RunUpgrade(string[] cmdArgs, JsonSerializerOptions jsonOptio var wantsJson = false; foreach (var arg in cmdArgs) { - if (arg == "--check-only") + if (arg is "--check-only" or "--check-updates") { checkOnly = true; continue; diff --git a/src/CodeIndex/Cli/UpdateChecker.cs b/src/CodeIndex/Cli/UpdateChecker.cs index c813d0dd9a..f04026a287 100644 --- a/src/CodeIndex/Cli/UpdateChecker.cs +++ b/src/CodeIndex/Cli/UpdateChecker.cs @@ -141,10 +141,13 @@ private static string FormatHint(string latestTag) private static string ResolveDefaultCachePath() { - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - var root = string.IsNullOrWhiteSpace(localAppData) - ? Path.Combine(Path.GetTempPath(), "cdidx") - : Path.Combine(localAppData, "cdidx"); + var xdgCacheHome = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var root = !string.IsNullOrWhiteSpace(xdgCacheHome) + ? Path.Combine(xdgCacheHome, "cdidx") + : !string.IsNullOrWhiteSpace(home) + ? Path.Combine(home, ".cache", "cdidx") + : Path.Combine(Path.GetTempPath(), "cdidx"); return Path.Combine(root, "update-check.json"); }