Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
307 changes: 307 additions & 0 deletions scripts/bump-versions.py
Original file line number Diff line number Diff line change
@@ -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()
15 changes: 13 additions & 2 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -9,7 +9,6 @@ use crate::util::ui::Printer;
#[command(
name = "livedocs",
author,
version,
about = "Livedocs",
propagate_version = true,
arg_required_else_help = true
Expand All @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<Self> {
let root = resolve_root_dir()?;
fs::create_dir_all(&root)?;
Expand Down
3 changes: 1 addition & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ mod update;
mod util;

use anyhow::Result;
use clap::Parser;
use tracing_subscriber::EnvFilter;

#[tokio::main]
Expand All @@ -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;
Expand Down
Loading