From 6244112a8c0eedb186e8b4c3deb268e8781111e7 Mon Sep 17 00:00:00 2001 From: ml Date: Wed, 26 Aug 2026 22:41:13 +0200 Subject: [PATCH] feat: podman support --- Makefile | 7 +- docs/quickstart.md | 34 +++++ install.sh | 12 +- late-podman | 277 +++++++++++++++++++++++++++++++++++++++ test/late-podman-test.sh | 114 ++++++++++++++++ 5 files changed, 442 insertions(+), 2 deletions(-) create mode 100755 late-podman create mode 100755 test/late-podman-test.sh diff --git a/Makefile b/Makefile index 6f6fa17..e679b2f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test clean install run help +.PHONY: build test test-podman clean install run help # Project variables BINARY_NAME=late @@ -17,6 +17,10 @@ build: ## Build the late binary test: ## Run tests for the entire project @echo "Running tests..." @go test -v -race ./... + @./test/late-podman-test.sh + +test-podman: ## Test the Podman launcher without requiring Podman + @./test/late-podman-test.sh clean: ## Remove build artifacts @echo "Cleaning..." @@ -26,6 +30,7 @@ install: build ## Build and install the binary to your Go bin path @echo "Installing to ~/.local/bin/late..." @go build ${LDFLAGS} -o bin/${BINARY_NAME} ./cmd/late @mv bin/${BINARY_NAME} ~/.local/bin/late + @install -m 0755 late-podman ~/.local/bin/late-podman run: build ## Build and run the project @./bin/${BINARY_NAME} diff --git a/docs/quickstart.md b/docs/quickstart.md index 058f11f..577b04d 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -243,6 +243,40 @@ You can also create an `.llmignore` file alongside your `.gitignore` to specific | `--append-system-prompt "..."` | Append text to the system prompt (e.g. further instructions) | | `--enable-images` | Treat models as supporting images (for none llama.cpp servers) | +## Podman compatibility command + +On Linux systems with rootless Podman, `late-podman` runs Late in a glibc-based +development image and mounts the current directory at `/workspace`: + +```bash +late-podman --image registry.example/my-project-dev +``` + +The image must contain Bash and every language or SDK required by the project. +If no `--image` is supplied, Late automatically searches for configuration in the following order: +1. `.late/podman-image` +2. `.devcontainer/devcontainer.json` (or `.devcontainer.json`) — reads `image` or `build.dockerfile`, `postCreateCommand`, and `containerEnv` +3. Interactive terminal prompt + +Use `--exec` to prepare the container before Late starts. It is a Bash command, +may be repeated, and Late starts only if every command succeeds: + +```bash +late-podman --image fedora:latest \ + --exec "dnf install -y golang nodejs npm" \ + --exec "npm install" \ + -- --continue +``` + +If your project contains a `.devcontainer/devcontainer.json`, any `postCreateCommand` commands are run automatically before `--exec` commands. + +Alpine and other musl-based images are unsupported. The launcher uses rootless +Podman, does not relabel the host workspace, and does not mount the host home or +container socket. It uses the host network so model servers listening on +`localhost`, including loopback-only servers, remain reachable with the same +Late configuration. Consequently, processes inside the development container +can also reach other services listening on the host. + ## Sessions Late automatically saves your session history. Resume or manage sessions: diff --git a/install.sh b/install.sh index abc2193..34d4384 100644 --- a/install.sh +++ b/install.sh @@ -48,6 +48,13 @@ chmod +x "$INSTALL_DIR/late.tmp" # Rename atomically to avoid workspace pollution or half-written binaries mv "$INSTALL_DIR/late.tmp" "$INSTALL_DIR/late" +if [ "$OS" = "linux" ]; then + echo "=> Installing late-podman..." + curl -sfL https://raw.githubusercontent.com/mlhher/late-cli/main/late-podman -o "$INSTALL_DIR/late-podman.tmp" + chmod +x "$INSTALL_DIR/late-podman.tmp" + mv "$INSTALL_DIR/late-podman.tmp" "$INSTALL_DIR/late-podman" +fi + echo "=> Success! Late is installed at $INSTALL_DIR/late" # Crucial friction check: Is it actually in their PATH? @@ -61,4 +68,7 @@ if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then echo "Then restart your terminal or run: source ~/.bashrc (or ~/.zshrc)" else echo "=> Run 'late' to get started." -fi \ No newline at end of file + if [ "$OS" = "linux" ]; then + echo "=> Run 'late-podman --image IMAGE' to use a development container." + fi +fi diff --git a/late-podman b/late-podman new file mode 100755 index 0000000..4e03598 --- /dev/null +++ b/late-podman @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: late-podman [--image IMAGE] [--exec COMMAND] [--] [LATE_ARGS...] + +Run Late in a rootless Podman container with the current directory mounted at +/workspace. The image must be Linux, glibc-based, and contain bash plus the +development tools required by the project. + +Options: + --image IMAGE OCI image to run (otherwise read .late/podman-image) + --exec COMMAND Run a Bash command before Late (may be specified repeatedly) + -h, --help Show this help + +When no image is configured, an interactive prompt offers to save the selected +image in .late/podman-image. +EOF +} + +die() { + printf 'late-podman: %s\n' "$*" >&2 + exit 1 +} + +image="" +startup_command="" +late_args=() + +while (($#)); do + case "$1" in + --image) + (($# >= 2)) || die "--image requires an argument" + image=$2 + shift 2 + ;; + --image=*) + image=${1#*=} + [[ -n "$image" ]] || die "--image requires an argument" + shift + ;; + --exec) + (($# >= 2)) || die "--exec requires an argument" + if [[ -n "$startup_command" ]]; then + startup_command+=$'\n' + fi + startup_command+=$2 + shift 2 + ;; + --exec=*) + command_value=${1#*=} + [[ -n "$command_value" ]] || die "--exec requires an argument" + if [[ -n "$startup_command" ]]; then + startup_command+=$'\n' + fi + startup_command+=$command_value + shift + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + late_args+=("$@") + break + ;; + *) + late_args+=("$1") + shift + ;; + esac +done + +[[ $(uname -s) == Linux ]] || die "only Linux hosts are supported" +command -v podman >/dev/null 2>&1 || die "Podman is required but was not found in PATH" + +late_binary=$(command -v late 2>/dev/null || true) +[[ -n "$late_binary" && -x "$late_binary" ]] || die "the late binary was not found in PATH" +late_binary=$(readlink -f "$late_binary") + +workspace=$(pwd -P) +image_file="$workspace/.late/podman-image" +devcontainer_env=() +devcontainer_post_create="" + +find_devcontainer_file() { + local root="$1" + if [[ -f "$root/.devcontainer/devcontainer.json" ]]; then + printf '%s\n' "$root/.devcontainer/devcontainer.json" + elif [[ -f "$root/.devcontainer.json" ]]; then + printf '%s\n' "$root/.devcontainer.json" + elif [[ -d "$root/.devcontainer" ]]; then + local found + found=$(find "$root/.devcontainer" -mindepth 2 -maxdepth 2 -name "devcontainer.json" 2>/dev/null | sort | head -n 1 || true) + if [[ -n "$found" ]]; then + printf '%s\n' "$found" + fi + fi +} + +devcontainer_file=$(find_devcontainer_file "$workspace") + +if [[ -n "$devcontainer_file" ]]; then + devcontainer_dir=$(dirname "$devcontainer_file") + if command -v python3 >/dev/null 2>&1; then + eval "$(python3 -c ' +import sys, re, json, os, shlex + +path = sys.argv[1] +with open(path, "r", encoding="utf-8") as f: + text = f.read() + +out = [] +i = 0 +in_str = False +in_block = False +while i < len(text): + if in_block: + if text[i:i+2] == "*/": + in_block = False + i += 2 + else: + i += 1 + continue + if in_str: + out.append(text[i]) + if text[i] == "\\" and i + 1 < len(text): + out.append(text[i+1]) + i += 2 + continue + if text[i] == "\"": + in_str = False + i += 1 + continue + if text[i:i+2] == "//": + while i < len(text) and text[i] != "\n": + i += 1 + continue + if text[i:i+2] == "/*": + in_block = True + i += 2 + continue + if text[i] == "\"": + in_str = True + out.append(text[i]) + i += 1 + continue + out.append(text[i]) + i += 1 +cleaned = "".join(out) +cleaned = re.sub(r",\s*([\]}])", r"\1", cleaned) + +try: + data = json.loads(cleaned) +except Exception as e: + sys.exit(0) + +img = data.get("image", "") +build = data.get("build", {}) +dockerfile = "" +context = "" +if isinstance(build, str): + dockerfile = build +elif isinstance(build, dict): + dockerfile = build.get("dockerfile", "") + context = build.get("context", "") +elif "dockerfile" in data: + dockerfile = data.get("dockerfile", "") + +pcc = data.get("postCreateCommand", "") +post_create = "" +if isinstance(pcc, str): + post_create = pcc +elif isinstance(pcc, list): + post_create = " ".join(shlex.quote(str(x)) for x in pcc) +elif isinstance(pcc, dict): + post_create = "\n".join(str(v) for v in pcc.values()) + +print(f"dc_image={shlex.quote(img)}") +print(f"dc_dockerfile={shlex.quote(dockerfile)}") +print(f"dc_context={shlex.quote(context)}") +print(f"devcontainer_post_create={shlex.quote(post_create)}") + +cenv = data.get("containerEnv", {}) +if isinstance(cenv, dict): + for k, v in cenv.items(): + kv = f"{k}={v}" + print(f"devcontainer_env+=({shlex.quote(kv)})") +' "$devcontainer_file" 2>/dev/null || true)" + fi +fi + +if [[ -z "$image" && -f "$image_file" ]]; then + IFS= read -r image < "$image_file" || true + [[ -n "$image" ]] || die "$image_file is empty" +fi + +if [[ -z "$image" && -n "${dc_image:-}" ]]; then + image="$dc_image" +elif [[ -z "$image" && -n "${dc_dockerfile:-}" ]]; then + dc_df_path="$devcontainer_dir/$dc_dockerfile" + if [[ ! -f "$dc_df_path" ]]; then + dc_df_path="$workspace/$dc_dockerfile" + fi + [[ -f "$dc_df_path" ]] || die "Dockerfile not found: $dc_dockerfile" + + dc_ctx_path="$devcontainer_dir" + if [[ -n "${dc_context:-}" ]]; then + dc_ctx_path="$devcontainer_dir/$dc_context" + fi + + clean_name=$(basename "$workspace" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9_.-' '_') + image="localhost/late-devcontainer-${clean_name}:latest" + echo "=> Building devcontainer image from $dc_df_path..." + podman build -t "$image" -f "$dc_df_path" "$dc_ctx_path" +fi + +if [[ -z "$image" ]]; then + [[ -t 0 && -t 1 ]] || die "no image configured; use --image IMAGE, create .devcontainer/devcontainer.json, or create .late/podman-image" + read -r -p "Development container image: " image "$image_file" + printf 'Saved %s\n' "$image_file" + fi +fi + +if [[ -n "$devcontainer_post_create" ]]; then + if [[ -n "$startup_command" ]]; then + startup_command="$devcontainer_post_create"$'\n'"$startup_command" + else + startup_command="$devcontainer_post_create" + fi +fi + +run_args=(run --rm --network=host --security-opt label=disable) +[[ -t 0 ]] && run_args+=(-i) +[[ -t 1 ]] && run_args+=(-t) + +run_args+=( + --mount "type=bind,src=$workspace,target=/workspace" + --workdir /workspace + --mount "type=bind,src=$late_binary,target=/usr/local/bin/late,ro=true" + --volume late-podman-sessions:/root/.local/share/late + --entrypoint bash + -e TERM="${TERM:-xterm-256color}" +) + +for env_var in "${devcontainer_env[@]}"; do + run_args+=(-e "$env_var") +done + +config_home=${XDG_CONFIG_HOME:-${HOME:?HOME is not set}/.config} +if [[ -d "$config_home/late" ]]; then + run_args+=(--mount "type=bind,src=$config_home/late,target=/root/.config/late,ro=true") +fi + +for variable in OPENAI_BASE_URL OPENAI_API_KEY OPENAI_MODEL LATE_SUBAGENT_BASE_URL LATE_SUBAGENT_API_KEY LATE_SUBAGENT_MODEL COLORTERM LANG LC_ALL; do + if [[ -v "$variable" ]]; then + run_args+=(-e "$variable=${!variable}") + fi +done + +container_script='set -e +if [[ -n "$1" ]]; then + eval "$1" +fi +shift +exec /usr/local/bin/late "$@"' + +exec podman "${run_args[@]}" "$image" -lc "$container_script" late-podman "$startup_command" "${late_args[@]}" diff --git a/test/late-podman-test.sh b/test/late-podman-test.sh new file mode 100755 index 0000000..a356572 --- /dev/null +++ b/test/late-podman-test.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +TEST_ROOT=$(mktemp -d) +trap 'rm -rf "$TEST_ROOT"' EXIT + +mkdir -p "$TEST_ROOT/bin" "$TEST_ROOT/project/.late" "$TEST_ROOT/config/late" +cp "$ROOT/late-podman" "$TEST_ROOT/bin/late-podman" +printf '#!/usr/bin/env bash\nexit 0\n' > "$TEST_ROOT/bin/late" +chmod +x "$TEST_ROOT/bin/late" "$TEST_ROOT/bin/late-podman" + +cat > "$TEST_ROOT/bin/podman" <<'EOF' +#!/usr/bin/env bash +printf '%s\0' "$@" > "$PODMAN_CAPTURE" +EOF +chmod +x "$TEST_ROOT/bin/podman" + +assert_arg() { + local expected=$1 + grep -Fx -- "$expected" "$TEST_ROOT/args" >/dev/null || { + printf 'missing Podman argument: %s\n' "$expected" >&2 + exit 1 + } +} + +printf '%s\n' 'registry.example/dev:1' > "$TEST_ROOT/project/.late/podman-image" +( + cd "$TEST_ROOT/project" + PATH="$TEST_ROOT/bin:$PATH" \ + XDG_CONFIG_HOME="$TEST_ROOT/config" \ + PODMAN_CAPTURE="$TEST_ROOT/capture" \ + OPENAI_BASE_URL='http://localhost:8080' \ + late-podman --exec 'dnf install -y golang' --exec 'npm ci' -- --continue 'two words' +) + +tr '\0' '\n' < "$TEST_ROOT/capture" > "$TEST_ROOT/args" +assert_arg run +assert_arg --rm +assert_arg '--network=host' +assert_arg 'label=disable' +assert_arg "type=bind,src=$TEST_ROOT/project,target=/workspace" +assert_arg 'registry.example/dev:1' +assert_arg 'OPENAI_BASE_URL=http://localhost:8080' +assert_arg $'dnf install -y golang\nnpm ci' +assert_arg --continue +assert_arg 'two words' + +# Test devcontainer.json support +mkdir -p "$TEST_ROOT/dc-project/.devcontainer" +cat > "$TEST_ROOT/dc-project/.devcontainer/devcontainer.json" <<'EOF' +{ + "name": "Node Dev Container", + // Image with comment + "image": "ghcr.io/devcontainers/javascript-node:20", + "containerEnv": { + "NODE_ENV": "development", + "CUSTOM_PORT": "4000", + }, + "postCreateCommand": [ + "npm", + "install" + ], +} +EOF + +( + cd "$TEST_ROOT/dc-project" + PATH="$TEST_ROOT/bin:$PATH" \ + XDG_CONFIG_HOME="$TEST_ROOT/config" \ + PODMAN_CAPTURE="$TEST_ROOT/capture-dc" \ + late-podman --exec 'npm run build' -- --prompt 'hello' +) + +tr '\0' '\n' < "$TEST_ROOT/capture-dc" > "$TEST_ROOT/args" +assert_arg 'ghcr.io/devcontainers/javascript-node:20' +assert_arg 'NODE_ENV=development' +assert_arg 'CUSTOM_PORT=4000' +assert_arg $'npm install\nnpm run build' +assert_arg --prompt +assert_arg 'hello' + +# Test precedence: .late/podman-image over devcontainer.json +mkdir -p "$TEST_ROOT/dc-project/.late" +printf '%s\n' 'override.example/app:latest' > "$TEST_ROOT/dc-project/.late/podman-image" +( + cd "$TEST_ROOT/dc-project" + PATH="$TEST_ROOT/bin:$PATH" \ + XDG_CONFIG_HOME="$TEST_ROOT/config" \ + PODMAN_CAPTURE="$TEST_ROOT/capture-override" \ + late-podman +) +tr '\0' '\n' < "$TEST_ROOT/capture-override" > "$TEST_ROOT/args" +assert_arg 'override.example/app:latest' + +# Test precedence: CLI --image over everything +( + cd "$TEST_ROOT/dc-project" + PATH="$TEST_ROOT/bin:$PATH" \ + XDG_CONFIG_HOME="$TEST_ROOT/config" \ + PODMAN_CAPTURE="$TEST_ROOT/capture-cli" \ + late-podman --image 'cli.example/app:1' +) +tr '\0' '\n' < "$TEST_ROOT/capture-cli" > "$TEST_ROOT/args" +assert_arg 'cli.example/app:1' + +if PATH="$TEST_ROOT/bin:$PATH" HOME="$TEST_ROOT" PODMAN_CAPTURE="$TEST_ROOT/capture" \ + "$TEST_ROOT/bin/late-podman" --image 2> "$TEST_ROOT/error"; then + echo 'expected a missing --image value to fail' >&2 + exit 1 +fi +grep -F -- '--image requires an argument' "$TEST_ROOT/error" >/dev/null + +echo 'late-podman tests passed'