From 8e388c03ae03d9a58b4680aac42957371775894a Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Tue, 27 Jan 2026 21:41:25 -0800 Subject: [PATCH 1/2] fix: version check --- install.sh | 3 + scripts/bump-versions.py | 307 +++++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 15 +- src/config/mod.rs | 14 ++ src/main.rs | 3 +- 5 files changed, 338 insertions(+), 4 deletions(-) create mode 100755 scripts/bump-versions.py diff --git a/install.sh b/install.sh index 665003a..7b2b1ef 100755 --- a/install.sh +++ b/install.sh @@ -579,6 +579,9 @@ setup_path() { local should_setup=0 if [[ $SETUP_PATH -eq 1 ]]; then should_setup=1 + elif [[ ! -t 0 ]]; then + # Non-interactive (piped) - default to yes + should_setup=1 elif prompt_path_setup "$bin_dir"; then should_setup=1 fi diff --git a/scripts/bump-versions.py b/scripts/bump-versions.py new file mode 100755 index 0000000..862eb7c --- /dev/null +++ b/scripts/bump-versions.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +""" +Bump versions.lock with latest tags from component repos. + +Usage: + ./scripts/bump-versions.py # Interactive mode + ./scripts/bump-versions.py --channel staging # Update staging channel + ./scripts/bump-versions.py --channel stable # Update stable channel + ./scripts/bump-versions.py --all # Update all components + ./scripts/bump-versions.py --component middleman # Update specific component +""" + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +REPO_ROOT = SCRIPT_DIR.parent +VERSIONS_LOCK = REPO_ROOT / "versions.lock" + +# Component name in versions.lock -> (repo directory name, repo path relative to parent) +COMPONENT_REPOS = { + "cli_version": ("cli", REPO_ROOT), + "livedocs": ("sdk", REPO_ROOT.parent / "sdk"), + "livedocs-jedi": ("jedi", REPO_ROOT.parent / "jedi"), + "middleman": ("middleman", REPO_ROOT.parent / "middleman"), +} + + +def run_git(repo_path: Path, *args) -> str: + """Run a git command in the given repo and return stdout.""" + result = subprocess.run( + ["git", *args], + cwd=repo_path, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"git {' '.join(args)} failed in {repo_path}: {result.stderr}") + return result.stdout.strip() + + +def fetch_tags(repo_path: Path) -> None: + """Fetch latest tags from remote.""" + subprocess.run( + ["git", "fetch", "--tags", "--quiet"], + cwd=repo_path, + capture_output=True, + ) + + +def is_prerelease(tag: str) -> bool: + """Check if a tag is a pre-release version.""" + return bool(re.search(r"-(rc|alpha|beta|dev|pre)", tag, re.IGNORECASE)) + + +def parse_version(tag: str) -> tuple: + """Parse a version tag into sortable components. + + Returns a tuple that sorts correctly for semver with pre-release handling. + Pre-release versions sort before their release counterpart. + """ + # Strip 'v' prefix + version = tag[1:] if tag.startswith("v") else tag + + # Split into base version and pre-release + match = re.match(r"(\d+)\.(\d+)\.(\d+)(?:-(rc|alpha|beta|dev|pre)\.?(\d+)?)?", version, re.IGNORECASE) + if not match: + return (0, 0, 0, 0, 0) + + major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3)) + prerelease_type = match.group(4) + prerelease_num = int(match.group(5)) if match.group(5) else 0 + + # For sorting: stable versions get a high prerelease "type" number + # so they sort after all pre-releases of the same base version + if prerelease_type: + type_order = {"dev": 1, "alpha": 2, "beta": 3, "pre": 4, "rc": 5}.get(prerelease_type.lower(), 0) + else: + type_order = 100 # Stable releases sort last (highest) + + return (major, minor, patch, type_order, prerelease_num) + + +def get_latest_tag(repo_path: Path, channel: str = "stable") -> str | None: + """Get the latest tag from a repo for the given channel. + + Args: + repo_path: Path to the git repo + channel: 'stable' for release tags only, 'staging' for pre-release tags only + """ + try: + # Get all tags + output = run_git(repo_path, "tag") + if not output: + return None + + tags = [t.strip() for t in output.split("\n") if t.strip()] + + # Filter to only version-like tags (start with v and have numbers) + version_tags = [t for t in tags if re.match(r"v?\d+\.\d+", t)] + + if channel == "stable": + # Only stable releases (no pre-release suffix) + version_tags = [t for t in version_tags if not is_prerelease(t)] + elif channel == "staging": + # Only pre-release tags (rc, alpha, beta, etc.) + version_tags = [t for t in version_tags if is_prerelease(t)] + + if not version_tags: + return None + + # Sort by parsed version, descending + version_tags.sort(key=parse_version, reverse=True) + + return version_tags[0] + except RuntimeError: + return None + + +def strip_v_prefix(version: str) -> str: + """Remove 'v' prefix from version string.""" + return version[1:] if version.startswith("v") else version + + +def bump_patch_version(version: str) -> str: + """Bump the patch version by 1. + + Examples: + 1.2.4 -> 1.2.5 + 1.0.0-rc.24 -> 1.0.0-rc.25 + """ + version = strip_v_prefix(version) + + # Handle pre-release versions (e.g., 1.0.0-rc.24) + match = re.match(r"(\d+\.\d+\.\d+)-(rc|alpha|beta|dev|pre)\.?(\d+)?", version, re.IGNORECASE) + if match: + base, prerelease_type, prerelease_num = match.groups() + num = int(prerelease_num) + 1 if prerelease_num else 1 + return f"{base}-{prerelease_type}.{num}" + + # Handle stable versions (e.g., 1.2.4) + match = re.match(r"(\d+)\.(\d+)\.(\d+)", version) + if match: + major, minor, patch = match.groups() + return f"{major}.{minor}.{int(patch) + 1}" + + return version + + +def load_versions_lock() -> dict: + """Load and parse versions.lock.""" + with open(VERSIONS_LOCK) as f: + return json.load(f) + + +def save_versions_lock(data: dict) -> None: + """Save versions.lock with consistent formatting.""" + with open(VERSIONS_LOCK, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") + + +def print_diff(component: str, old_version: str, new_version: str) -> None: + """Print a colored diff for a version change.""" + if old_version == new_version: + print(f" {component}: {old_version} (unchanged)") + else: + print(f" {component}: \033[31m{old_version}\033[0m -> \033[32m{new_version}\033[0m") + + +def update_channel(data: dict, channel: str, components: list[str] | None = None) -> dict: + """Update versions for a channel. + + Args: + data: The versions.lock data + channel: 'staging' (pre-release tags only) or 'stable' (release tags only) + components: List of components to update, or None for all + """ + channel_data = data["channels"][channel] + + tag_type = "pre-release" if channel == "staging" else "stable" + print(f"\n\033[1mUpdating {channel} channel ({tag_type} tags):\033[0m") + + # Determine which components to update + if components is None: + components = ["cli_version", "livedocs", "livedocs-jedi", "middleman"] + + for component in components: + if component not in COMPONENT_REPOS: + print(f" \033[33mWarning: Unknown component '{component}'\033[0m") + continue + + repo_name, repo_path = COMPONENT_REPOS[component] + + if not repo_path.exists(): + print(f" \033[33mWarning: Repo not found at {repo_path}\033[0m") + continue + + # Fetch latest tags + fetch_tags(repo_path) + + # Get latest tag for this channel + latest_tag = get_latest_tag(repo_path, channel=channel) + if not latest_tag: + print(f" \033[33mWarning: No {tag_type} tags found in {repo_name}\033[0m") + continue + + new_version = strip_v_prefix(latest_tag) + + # Update the version + if component == "cli_version": + # CLI version needs to be bumped because this commit will trigger a new release + new_version = bump_patch_version(new_version) + old_version = channel_data.get("cli_version", "") + print_diff("cli_version", old_version, f"{new_version} (next)") + channel_data["cli_version"] = new_version + else: + old_version = channel_data.get("components", {}).get(component, "") + print_diff(component, old_version, new_version) + if "components" not in channel_data: + channel_data["components"] = {} + channel_data["components"][component] = new_version + + return data + + +def interactive_mode(data: dict) -> dict: + """Interactive mode for selecting what to update.""" + print("\033[1mVersions Lock Updater\033[0m") + print("\nCurrent versions:") + + for channel in ["staging", "stable"]: + ch_data = data["channels"][channel] + print(f"\n \033[1m{channel}:\033[0m") + print(f" cli_version: {ch_data.get('cli_version', 'N/A')}") + for comp, ver in ch_data.get("components", {}).items(): + print(f" {comp}: {ver}") + + print("\n\033[1mWhat would you like to update?\033[0m") + print(" 1. Staging channel (all components)") + print(" 2. Stable channel (all components)") + print(" 3. Both channels (all components)") + print(" 4. Specific component") + print(" 5. Exit") + + choice = input("\nChoice [1-5]: ").strip() + + if choice == "1": + data = update_channel(data, "staging") + elif choice == "2": + data = update_channel(data, "stable") + elif choice == "3": + data = update_channel(data, "staging") + data = update_channel(data, "stable") + elif choice == "4": + print("\nComponents: cli_version, livedocs, livedocs-jedi, middleman") + comp = input("Component name: ").strip() + ch = input("Channel (staging/stable/both) [staging]: ").strip() or "staging" + + if ch == "both": + data = update_channel(data, "staging", [comp]) + data = update_channel(data, "stable", [comp]) + else: + data = update_channel(data, ch, [comp]) + elif choice == "5": + print("Exiting without changes.") + sys.exit(0) + else: + print("Invalid choice.") + sys.exit(1) + + return data + + +def main(): + parser = argparse.ArgumentParser(description="Update versions.lock with latest tags") + parser.add_argument("--channel", choices=["staging", "stable"], help="Channel to update") + parser.add_argument("--component", help="Specific component to update") + parser.add_argument("--all", action="store_true", help="Update all components in the channel") + parser.add_argument("--dry-run", action="store_true", help="Show changes without saving") + + args = parser.parse_args() + + data = load_versions_lock() + + if args.channel: + components = [args.component] if args.component else None + data = update_channel(data, args.channel, components) + elif args.component: + # Default to staging if only component specified + data = update_channel(data, "staging", [args.component]) + else: + data = interactive_mode(data) + + if args.dry_run: + print("\n\033[33mDry run - no changes saved\033[0m") + else: + save_versions_lock(data) + print(f"\n\033[32mSaved to {VERSIONS_LOCK}\033[0m") + + +if __name__ == "__main__": + main() diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3479b51..d76646e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use clap::{Parser, Subcommand}; +use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; use crate::commands; use crate::config::{AppConfig, Paths}; @@ -9,7 +9,6 @@ use crate::util::ui::Printer; #[command( name = "livedocs", author, - version, about = "Livedocs", propagate_version = true, arg_required_else_help = true @@ -19,6 +18,18 @@ pub struct Cli { pub command: Commands, } +impl Cli { + /// Parse CLI arguments with the installed bundle version (not Cargo version). + pub fn parse_with_installed_version() -> Self { + let version = Paths::installed_version().unwrap_or_else(|| "not installed".to_string()); + // Leak the string to get a 'static lifetime (this only runs once at startup) + let version: &'static str = Box::leak(version.into_boxed_str()); + let cmd = Self::command().version(version); + let matches = cmd.get_matches(); + Self::from_arg_matches(&matches).expect("Failed to parse CLI arguments") + } +} + #[derive(Debug, Subcommand)] pub enum Commands { /// Pair this CLI with a Livedocs workspace token. diff --git a/src/config/mod.rs b/src/config/mod.rs index 054e3fb..ca675c1 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -67,6 +67,20 @@ pub struct Paths { } impl Paths { + /// Returns the installed bundle version by reading the manifest from the current symlink. + /// Returns None if no bundle is installed or the manifest cannot be read. + pub fn installed_version() -> Option { + let paths = Self::ensure().ok()?; + let bundle_dir = paths.current_bundle_dir()?; + let manifest_path = bundle_dir.join("manifest.json"); + let raw = fs::read(&manifest_path).ok()?; + let manifest: serde_json::Value = serde_json::from_slice(&raw).ok()?; + manifest + .get("cli_version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } + pub fn ensure() -> Result { let root = resolve_root_dir()?; fs::create_dir_all(&root)?; diff --git a/src/main.rs b/src/main.rs index 9556c4e..37a9611 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,6 @@ mod update; mod util; use anyhow::Result; -use clap::Parser; use tracing_subscriber::EnvFilter; #[tokio::main] @@ -18,7 +17,7 @@ async fn main() -> Result<()> { let paths = config::Paths::ensure()?; let mut config = config::AppConfig::load(&paths)?; - let args = cli::Cli::parse(); + let args = cli::Cli::parse_with_installed_version(); let ctx = cli::CommandContext::new(paths, &mut config); let result = cli::run(args, ctx).await; From 98393b25603e760d3dc4df2a9fd42e72e3434ac2 Mon Sep 17 00:00:00 2001 From: Ehsaan Date: Tue, 27 Jan 2026 21:42:37 -0800 Subject: [PATCH 2/2] fix: bump version.lock --- versions.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/versions.lock b/versions.lock index 89a3622..7f65258 100644 --- a/versions.lock +++ b/versions.lock @@ -1,7 +1,7 @@ { "channels": { "staging": { - "cli_version": "1.0.0-rc.24", + "cli_version": "1.0.0-rc.26", "python_runtime": "3.12", "components": { "livedocs": "1.0.0-rc.25", @@ -10,7 +10,7 @@ } }, "stable": { - "cli_version": "1.2.4", + "cli_version": "1.2.5", "python_runtime": "3.12", "components": { "livedocs": "1.3.2",