From 43c2a502f7abf29b8abf01af44db40251623d06b Mon Sep 17 00:00:00 2001 From: Emasoft <713559+Emasoft@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:16:02 +0200 Subject: [PATCH 1/4] feat: OTP gate for dangerous commands + install-dev.sh (split from #127) Split out of PR #127 per owner review (5716775820): the OTP permission mode (`-force-revaluate-dangerous-commands`, OTP registry, otp_code bash parameter, parse-fallback hard-block exemption) and install-dev.sh live here so PR #127 can stay focused on streaming/retry. Requires the dedicated testing/benchmarking pass requested in the review before it is ready. --- cmd/late/main.go | 17 +- cmd/late/main_test.go | 22 ++ install-dev.sh | 96 +++++ internal/common/interfaces.go | 1 + internal/tool/ast/policy.go | 15 +- internal/tool/ast_bridge.go | 7 + internal/tool/implementations.go | 5 +- internal/tool/otp.go | 74 ++++ internal/tool/otp_test.go | 192 ++++++++++ internal/tool/parse_fallback.go | 107 ++++++ internal/tool/parse_fallback_test.go | 172 +++++++++ internal/tui/interactions.go | 8 + internal/tui/revaluate.go | 74 ++++ internal/tui/revaluate_test.go | 518 +++++++++++++++++++++++++++ 14 files changed, 1300 insertions(+), 8 deletions(-) create mode 100755 install-dev.sh create mode 100644 internal/tool/otp.go create mode 100644 internal/tool/otp_test.go create mode 100644 internal/tool/parse_fallback.go create mode 100644 internal/tool/parse_fallback_test.go create mode 100644 internal/tui/revaluate.go create mode 100644 internal/tui/revaluate_test.go diff --git a/cmd/late/main.go b/cmd/late/main.go index d312724a..eb8445df 100644 --- a/cmd/late/main.go +++ b/cmd/late/main.go @@ -34,6 +34,17 @@ import ( "golang.org/x/term" ) +// forceRevaluateUsage is the -h description of +// -force-revaluate-dangerous-commands. +// +// IMPORTANT: this string must contain no back-quoted word. flag.PrintDefaults +// renders the first back-quoted word of a usage string as the flag's value +// name, which would advertise the flag as taking an argument. The OTP code is +// never passed on the CLI: late generates a random single-use code at runtime +// and hands it to the agent in the tool-result block message; the agent +// re-runs the command passing it in the bash tool's otp_code parameter. +const forceRevaluateUsage = "Unsupervised execution, but the first attempt to run a potentially dangerous command is blocked; late issues the agent a random single-use OTP code, bound to that exact command, which it must pass in the bash tool's otp_code parameter to re-run." + // pluginInlineTool adapts a plugin.InlineTool (defined in internal/plugin/tools.go) // into a common.Tool so the CLI's session registry can dispatch invocations to // plugin-declared runners. It exists because upstream repurposed @@ -90,6 +101,7 @@ func main() { appendSystemPromptReq := flag.String("append-system-prompt", "", "Append text to the system prompt after processing") versionReq := flag.Bool("version", false, "Show version") unsupervisedReq := flag.Bool("i-promise-i-have-backups-and-will-not-file-issues", false, "Unsupported: Execute all tools without supervision. Do not use this, bad things will happen. You have been warned.") + forceRevaluateReq := flag.Bool("force-revaluate-dangerous-commands", false, forceRevaluateUsage) enableImagesReq := flag.Bool("enable-images", false, "Force enable support for image attachments for unsupported servers.") continueReq := flag.Bool("continue", false, "Load and start the latest session") showCWDReq := flag.Bool("show-cwd", true, "Show current working directory in status bar") @@ -653,9 +665,12 @@ func main() { // Create context with InputProvider ctx := context.WithValue(context.Background(), common.InputProviderKey, tui.NewTUIInputProvider(p)) - if *unsupervisedReq { + if *unsupervisedReq || *forceRevaluateReq { ctx = context.WithValue(ctx, common.SkipConfirmationKey, true) } + if *forceRevaluateReq { + ctx = context.WithValue(ctx, common.ForceRevaluateKey, true) + } rootAgent.SetContext(ctx) // Set middlewares (see buildMiddlewares for ordering rationale). diff --git a/cmd/late/main_test.go b/cmd/late/main_test.go index 3b89c29c..0f91e3d6 100644 --- a/cmd/late/main_test.go +++ b/cmd/late/main_test.go @@ -2,10 +2,12 @@ package main import ( "encoding/json" + "flag" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -392,3 +394,23 @@ func TestRunBootstrap_DynamicLogitBias(t *testing.T) { t.Errorf("user bias 999 bled into subagentClient: %v", subBiases) } } + +// TestForceRevaluateUsageRendersWithoutValueName guards the -h output of +// -force-revaluate-dangerous-commands: the usage string must contain no +// back-quoted word, because flag.UnquoteUsage turns the first back-quoted +// word into the flag's value name and PrintDefaults would then render the +// boolean flag as taking an argument (e.g. "-force-revaluate-dangerous-commands otp_code"), +// wrongly implying the OTP is passed on the CLI. The +// OTP is generated by late at runtime and delivered to the agent in the +// block message; it is never a flag argument. +func TestForceRevaluateUsageRendersWithoutValueName(t *testing.T) { + if strings.ContainsRune(forceRevaluateUsage, '`') { + t.Fatalf("forceRevaluateUsage must not contain backquotes (flag.UnquoteUsage would render the quoted word as the flag's value name): %q", forceRevaluateUsage) + } + fs := flag.NewFlagSet("usage-test", flag.ContinueOnError) + fs.Bool("force-revaluate-dangerous-commands", false, forceRevaluateUsage) + name, _ := flag.UnquoteUsage(fs.Lookup("force-revaluate-dangerous-commands")) + if name != "" { + t.Errorf("expected no rendered value name for this boolean flag, got %q (help would show -force-revaluate-dangerous-commands %s)", name, name) + } +} diff --git a/install-dev.sh b/install-dev.sh new file mode 100755 index 00000000..43fbd086 --- /dev/null +++ b/install-dev.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# install-dev.sh — replace the installed `late` command with a symlink into +# this repository's locally built binary, so every `make build` updates the +# command in place. Nothing is copied. +# +# Link target: +# - with Homebrew present (and late not brew-managed): brew's bin dir +# - without Homebrew: the first bin dir that is already on PATH and that we +# can write to or create, in this order: ~/.local/bin, ~/bin, +# /usr/local/bin. If none is on PATH, the script falls back to +# ~/.local/bin and tells you to add it to your PATH. +# +# Usage: +# brew uninstall late # only when late is brew-managed +# ./install-dev.sh # optionally: LATE_DEV_VERSION=x ./install-dev.sh +# +# Go back to brew: +# rm "$(brew --prefix)/bin/late" && brew install late +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$SCRIPT_DIR" +DEV_LINK="$REPO/bin/late" + +# Refuse to fight an active brew install: replacing brew's symlink out from +# under it would leave brew's link tracking desynced. +if command -v brew >/dev/null 2>&1; then + if brew list --formula 2>/dev/null | grep -qx 'late' || brew list --cask 2>/dev/null | grep -qx 'late'; then + echo "Error: late is still installed via brew — run: brew uninstall late" >&2 + exit 1 + fi + BIN_DIR="$(brew --prefix)/bin" +else + # No Homebrew: pick the first bin dir that is already on PATH and that we + # can write to (or create), so `late` can be invoked from anywhere. + BIN_DIR="" + for candidate in "$HOME/.local/bin" "$HOME/bin" /usr/local/bin; do + if [[ ":$PATH:" != *":$candidate:"* ]]; then + continue + fi + if [[ -d "$candidate" && -w "$candidate" ]]; then + BIN_DIR="$candidate" + break + fi + if [[ ! -e "$candidate" ]]; then + mkdir -p "$candidate" + BIN_DIR="$candidate" + break + fi + done + if [[ -z "$BIN_DIR" ]]; then + # Nothing suitable on PATH: default to ~/.local/bin (XDG convention) + # and remind the user to add it below. + BIN_DIR="$HOME/.local/bin" + mkdir -p "$BIN_DIR" + fi +fi + +echo "=> Building late from $REPO..." +if [[ -n "${LATE_DEV_VERSION:-}" ]]; then + make -C "$REPO" build VERSION="$LATE_DEV_VERSION" +else + make -C "$REPO" build +fi + +# Never clobber a real binary: the destination must be a symlink (a previous +# dev install) or absent. Anything else gets removed by hand, deliberately. +if [[ -e "$BIN_DIR/late" && ! -L "$BIN_DIR/late" ]]; then + echo "Error: $BIN_DIR/late already exists and is not a symlink." >&2 + echo "Remove or rename it manually, then re-run this script." >&2 + exit 1 +fi + +echo "=> Symlinking $BIN_DIR/late -> $DEV_LINK" +ln -sfn "$DEV_LINK" "$BIN_DIR/late" + +# Friction checks: the link must be reachable and must not be shadowed by +# another `late` sitting earlier on PATH. +resolved="$(command -v late 2>/dev/null || true)" +if [[ -z "$resolved" ]]; then + echo "" >&2 + echo "⚠️ 'late' is not on your PATH — add this to your ~/.zshrc or ~/.bashrc:" >&2 + echo " export PATH=\"\$PATH:$BIN_DIR\"" >&2 + exit 1 +fi +if [[ "$resolved" != "$BIN_DIR/late" ]]; then + echo "" >&2 + echo "⚠️ 'late' currently resolves to: $resolved" >&2 + echo " which shadows $BIN_DIR/late — adjust your PATH order or remove" >&2 + echo " that binary, then re-run this script." >&2 + exit 1 +fi + +echo "=> Success! 'late' now resolves to:" +command -v late +late -version diff --git a/internal/common/interfaces.go b/internal/common/interfaces.go index f9c1dfca..84ec56f0 100644 --- a/internal/common/interfaces.go +++ b/internal/common/interfaces.go @@ -112,6 +112,7 @@ const ( OrchestratorIDKey contextKey = "orchestrator_id" SkipConfirmationKey contextKey = "skip_confirmation" ToolApprovalKey contextKey = "tool_approval" + ForceRevaluateKey contextKey = "force_revaluate" ) // MainAgentID is the orchestrator ID of the root/main agent. diff --git a/internal/tool/ast/policy.go b/internal/tool/ast/policy.go index 6602495e..6c387352 100644 --- a/internal/tool/ast/policy.go +++ b/internal/tool/ast/policy.go @@ -1,9 +1,16 @@ package ast import ( - "fmt" + "errors" "strings" ) + +// BlockReasonCD is the hard-block message returned for `cd` usage. +const BlockReasonCD = "Do not use `cd` to change directories. Use the `cwd` parameter in the shell tool instead." + +// BlockReasonRedirect is the hard-block message returned for unsafe output redirection. +const BlockReasonRedirect = "Output redirection (>) is blocked. Use `write_file` or `target_edit` to modify files." + // tier2Commands is the set of commands that have mandatory subcommands. // The AST adapters should emit compound command keys (e.g. "git log", "go mod") // for these commands to maintain fine-grained allow-list granularity. @@ -63,8 +70,7 @@ func (p *PolicyEngine) Decide(ir ParsedIR) Decision { if hasRisk(ir, ReasonCd) { d.IsBlocked = true d.NeedsConfirmation = true - d.BlockReason = fmt.Errorf( - "Do not use `cd` to change directories. Use the `cwd` parameter in the shell tool instead.") + d.BlockReason = errors.New(BlockReasonCD) return d } @@ -72,8 +78,7 @@ func (p *PolicyEngine) Decide(ir ParsedIR) Decision { if hasRisk(ir, ReasonRedirect) { d.IsBlocked = true d.NeedsConfirmation = true - d.BlockReason = fmt.Errorf( - "Output redirection (>) is blocked. Use `write_file` or `target_edit` to modify files.") + d.BlockReason = errors.New(BlockReasonRedirect) return d } diff --git a/internal/tool/ast_bridge.go b/internal/tool/ast_bridge.go index c29014a2..0dca460b 100644 --- a/internal/tool/ast_bridge.go +++ b/internal/tool/ast_bridge.go @@ -113,6 +113,13 @@ func newASTAnalyzer(platform ast.Platform, cwd string, allowed map[string]map[st func (a *astAnalyzer) Analyze(command string) CommandAnalysis { ir, err := a.parser.Parse(command) if err != nil { + // Fail closed on any parse error — and keep hard blocks hard: scan + // the raw command for the policy's hard-block signatures (cd, + // unsafe output redirects) so unparseable input cannot slip past + // them (e.g. into the force-revaluate OTP flow). + if blockErr := parseErrorHardBlock(command); blockErr != nil { + return CommandAnalysis{IsBlocked: true, NeedsConfirmation: true, BlockReason: blockErr} + } // Fail closed on any parse error. return CommandAnalysis{NeedsConfirmation: true} } diff --git a/internal/tool/implementations.go b/internal/tool/implementations.go index 876b0c87..2d26fa65 100644 --- a/internal/tool/implementations.go +++ b/internal/tool/implementations.go @@ -323,7 +323,8 @@ func (t ShellTool) Parameters() json.RawMessage { "type": "object", "properties": { "command": { "type": "string", "description": "The full %s command to execute." }, - "cwd": { "type": "string", "description": "Working directory for execution. Use this instead of 'cd' commands to change directories." } + "cwd": { "type": "string", "description": "Working directory for execution. Use this instead of 'cd' commands to change directories." }, + "otp_code": { "type": "string", "description": "One-time code required to re-run a command that was blocked by the -force-revaluate-dangerous-commands re-evaluation gate. Re-run the exact same command passing the issued OTP code here; codes are single-use and bound to the exact command string." } }, "required": ["command"] }`, shellDisplayName())) @@ -411,7 +412,7 @@ func (t ShellTool) Execute(ctx context.Context, args json.RawMessage) (string, e if orchestratorID := common.GetOrchestratorID(ctx); strings.Contains(strings.ToLower(orchestratorID), "coder") { sandwich = "\n\n=========================================\nSYSTEM DIRECTIVE:\nYou just encountered an error. If fixing this requires modifying components or architecture you were not explicitly instructed to edit, YOU MUST ABORT AND RETURN TO THE MAIN AGENT.\n=========================================" } - + if exitErr, ok := err.(*exec.ExitError); ok { return fmt.Sprintf("Command failed with exit code %d\n%s%s", exitErr.ExitCode(), finalOutput, sandwich), nil } diff --git a/internal/tool/otp.go b/internal/tool/otp.go new file mode 100644 index 00000000..3a6c06af --- /dev/null +++ b/internal/tool/otp.go @@ -0,0 +1,74 @@ +package tool + +import ( + cryptorand "crypto/rand" + "fmt" + "math/big" + "sync" + "time" +) + +var ( + otpMu sync.Mutex + otpCodes = make(map[string]string) // exact command parameter value -> pending OTP +) + +// GenerateOTPCode returns a cryptographically random 7-digit code +// (zero-padded, e.g. "0042319"), drawn uniformly from 0..9999999 using +// crypto/rand (the system entropy source). +func GenerateOTPCode() string { + n, err := cryptorand.Int(cryptorand.Reader, big.NewInt(10_000_000)) + if err != nil { + // System entropy unavailable: fall back to a time-derived value + // rather than failing open and auto-approving a dangerous command. + return fmt.Sprintf("%07d", time.Now().UnixNano()%10_000_000) + } + return fmt.Sprintf("%07d", n.Int64()) +} + +// IssueOTP returns the OTP pending for the exact command string, generating +// and storing a fresh one if none is pending. Re-attempts of the same +// command while the code is pending re-present the same code. +func IssueOTP(command string) string { + otpMu.Lock() + defer otpMu.Unlock() + if code, ok := otpCodes[command]; ok { + return code + } + code := GenerateOTPCode() + otpCodes[command] = code + return code +} + +// ConsumeOTP validates code against the pending OTP for the exact command +// string (byte-for-byte; any difference, even whitespace, is a different +// command). On success the OTP is deleted (single use) and true is returned. +func ConsumeOTP(command, code string) bool { + otpMu.Lock() + defer otpMu.Unlock() + pending, ok := otpCodes[command] + if !ok || pending != code { + return false + } + delete(otpCodes, command) + return true +} + +// ResetOTPRegistry drops all pending OTPs. Called on conversation reset and +// implicitly at process exit (end of agent session). +func ResetOTPRegistry() { + otpMu.Lock() + defer otpMu.Unlock() + otpCodes = make(map[string]string) +} + +// OTPRevaluateMessage renders the block message returned to the LLM agent +// when a dangerous command is first attempted under +// -force-revaluate-dangerous-commands. +func OTPRevaluateMessage(code string) string { + return fmt.Sprintf("Late detected that you want to execute a command potentially dangerous and destructive. Please re-evaluate your command to ensure it is safe for the current system and environment, verify the assumptions and the paths directly to avoid mistakes like symlinks or forgotten stashes, consider all the consequences, direct and indirect, and all the potential issues that the command can cause. If after this evaluation you'll decide to execute the command, run the command again with the following OTP code passed as the `otp_code` tool parameter: %s", code) +} + +// ResetConversationState implements common.ConversationResetter: pending +// OTP codes must not carry into a new conversation. +func (t *ShellTool) ResetConversationState() { ResetOTPRegistry() } diff --git a/internal/tool/otp_test.go b/internal/tool/otp_test.go new file mode 100644 index 00000000..303f0f3e --- /dev/null +++ b/internal/tool/otp_test.go @@ -0,0 +1,192 @@ +package tool + +import ( + "encoding/json" + "sync" + "sync/atomic" + "testing" +) + +func TestGenerateOTPCodeFormatAndEntropy(t *testing.T) { + seen := make(map[string]struct{}) + for i := 0; i < 200; i++ { + code := GenerateOTPCode() + if len(code) != 7 { + t.Fatalf("expected 7-character code, got %q (length %d)", code, len(code)) + } + for _, r := range code { + if r < '0' || r > '9' { + t.Fatalf("expected only digits, got %q", code) + } + } + seen[code] = struct{}{} + } + if len(seen) <= 1 { + t.Fatalf("expected more than one distinct code in 200 draws, got %d", len(seen)) + } +} + +func TestIssueOTPIsStableWhilePending(t *testing.T) { + ResetOTPRegistry() + t.Cleanup(func() { ResetOTPRegistry() }) + + command := "rm -rf build" + first := IssueOTP(command) + if len(first) != 7 { + t.Fatalf("expected 7-character pending code, got %q", first) + } + second := IssueOTP(command) + if second != first { + t.Fatalf("expected pending code to be stable, got %q then %q", first, second) + } + + ResetOTPRegistry() + afterReset := IssueOTP(command) + if len(afterReset) != 7 { + t.Fatalf("expected fresh 7-character code after reset, got %q", afterReset) + } +} + +func TestConsumeOTPSingleUse(t *testing.T) { + ResetOTPRegistry() + t.Cleanup(func() { ResetOTPRegistry() }) + + command := "rm -rf x" + code := IssueOTP(command) + + if !ConsumeOTP(command, code) { + t.Fatalf("expected first consume with correct code to succeed") + } + if ConsumeOTP(command, code) { + t.Fatalf("expected second consume with same code to fail (single use)") + } +} + +func TestConsumeOTPWrongCodeKeepsPending(t *testing.T) { + ResetOTPRegistry() + t.Cleanup(func() { ResetOTPRegistry() }) + + command := "rm -rf x" + pending := IssueOTP(command) + + if ConsumeOTP(command, "0000001") { + t.Fatalf("expected consume with wrong code to fail") + } + if stillPending := IssueOTP(command); stillPending != pending { + t.Fatalf("expected pending code %q to survive a wrong attempt, got %q", pending, stillPending) + } + if !ConsumeOTP(command, pending) { + t.Fatalf("expected correct code to consume after a wrong attempt") + } +} + +func TestConsumeOTPCommandStringMustMatchExactly(t *testing.T) { + ResetOTPRegistry() + t.Cleanup(func() { ResetOTPRegistry() }) + + keyCommand := "rm -rf x" + validCode := IssueOTP(keyCommand) + + variants := []struct { + name string + command string + }{ + {"double space after first token", "rm -rf x"}, + {"double space before last token", "rm -rf x"}, + {"leading space", " rm -rf x"}, + {"trailing space", "rm -rf x "}, + {"appended otp flag", "rm -rf x -otp-code 1234567"}, + } + for _, tc := range variants { + if ConsumeOTP(tc.command, validCode) { + t.Fatalf("%s: expected consume to fail for command %q", tc.name, tc.command) + } + } + + if !ConsumeOTP(keyCommand, validCode) { + t.Fatalf("expected original command %q to still consume after failed variants", keyCommand) + } + if ConsumeOTP("totally different", validCode) { + t.Fatalf("expected consume to fail for a command that never had an OTP") + } +} + +func TestConsumeOTPUnknownCommandFails(t *testing.T) { + ResetOTPRegistry() + t.Cleanup(func() { ResetOTPRegistry() }) + + if ConsumeOTP("git push --force-with-lease", "1234567") { + t.Fatalf("expected consume for command without a pending OTP to fail") + } +} + +func TestResetOTPRegistryClearsPending(t *testing.T) { + ResetOTPRegistry() + t.Cleanup(func() { ResetOTPRegistry() }) + + firstCommand := "git push --force" + secondCommand := "kubectl delete namespace prod" + firstOld := IssueOTP(firstCommand) + secondOld := IssueOTP(secondCommand) + + ResetOTPRegistry() + + firstNew := IssueOTP(firstCommand) + secondNew := IssueOTP(secondCommand) + if len(firstNew) != 7 || len(secondNew) != 7 { + t.Fatalf("expected fresh 7-character codes after reset, got %q and %q", firstNew, secondNew) + } + if ConsumeOTP(firstCommand, firstOld) { + t.Fatalf("expected old code for %q to be rejected after reset", firstCommand) + } + if ConsumeOTP(secondCommand, secondOld) { + t.Fatalf("expected old code for %q to be rejected after reset", secondCommand) + } +} + +func TestConsumeOTPConcurrentSingleUse(t *testing.T) { + ResetOTPRegistry() + t.Cleanup(func() { ResetOTPRegistry() }) + + command := "rm -rf race" + code := IssueOTP(command) + + const goroutines = 50 + var successes atomic.Int64 + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + if ConsumeOTP(command, code) { + successes.Add(1) + } + }() + } + wg.Wait() + + if got := successes.Load(); got != 1 { + t.Fatalf("expected exactly 1 successful consume across %d goroutines, got %d", goroutines, got) + } +} + +func TestShellToolParametersIncludeOTPCode(t *testing.T) { + params := (&ShellTool{}).Parameters() + if !json.Valid(params) { + t.Fatalf("Parameters() is not valid JSON: %s", string(params)) + } + + var schema struct { + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` + } + if err := json.Unmarshal(params, &schema); err != nil { + t.Fatalf("failed to unmarshal Parameters(): %v", err) + } + if _, ok := schema.Properties["otp_code"]; !ok { + t.Fatalf("expected otp_code property in Parameters(), got: %s", string(params)) + } + if len(schema.Required) != 1 || schema.Required[0] != "command" { + t.Fatalf("expected required to be exactly [\"command\"], got %v", schema.Required) + } +} diff --git a/internal/tool/parse_fallback.go b/internal/tool/parse_fallback.go new file mode 100644 index 00000000..ef655786 --- /dev/null +++ b/internal/tool/parse_fallback.go @@ -0,0 +1,107 @@ +package tool + +import ( + "errors" + "regexp" + + "late/internal/tool/ast" +) + +// cdPattern matches a standalone `cd` word anywhere in a raw command string. +// It is compiled once at package level. The match is intentionally lexical +// (no quoting or AST awareness), so it can over-match — e.g. a path segment +// like "foo.cd/bar" or a quoted "cd" argument. Over-matching is acceptable: +// this scan only ever runs on input the shell AST parser could not read, +// where failing closed is the safe choice. +var cdPattern = regexp.MustCompile(`\bcd\b`) + +// parseErrorHardBlock reports whether an unparseable command still matches one +// of the hard-block signatures (cd usage, unsafe output redirection) and, if +// so, returns the same block error the AST policy would produce for a +// parseable command. +// +// The AST parser (mvdan.cc/sh) fails on truncated or malformed input (e.g. a +// trailing "&&"), which previously downgraded such commands to plain "needs +// confirmation" — letting commands that visibly contain an output redirection +// or `cd` reach the force-revaluate OTP flow (and, after OTP approval, fail +// open into the shell). The scan below is intentionally conservative and may +// over-block edge cases (quoted operators, paths containing "cd"): it only +// ever runs on input the parser could not read, where failing closed is the +// safe choice. +func parseErrorHardBlock(command string) error { + // 1. cd usage → hard block. Checked first, mirroring the policy order + // (ast.PolicyEngine.Decide evaluates cd before redirects). + if cdPattern.MatchString(command) { + return errors.New(ast.BlockReasonCD) + } + + // 2. Unsafe output redirect → hard block. Scan every '>' in the raw + // command. Classification is driven entirely by the text after the + // operator, so fd prefixes (2>, 1>, 3>) need no special handling — + // they are just a '>' preceded by digits. + for i := 0; i < len(command); i++ { + if command[i] != '>' { + continue + } + + // Operator: ">>" (append), ">|" (clobber) or ">" (truncate). + opLen := 1 + if i+1 < len(command) && (command[i+1] == '>' || command[i+1] == '|') { + opLen = 2 + } + + if !isSafeRedirectTarget(redirectTargetAfter(command, i+opLen)) { + return errors.New(ast.BlockReasonRedirect) + } + + // Skip past the full operator so ">>"/">|" is not re-examined. + i += opLen - 1 + } + + return nil +} + +// redirectTargetAfter extracts the redirect target starting at index start +// (immediately after the '>' operator): leading spaces and tabs are skipped +// and the target runs until the next whitespace character or the end of the +// string. An operator at the end of the command yields an empty target. +func redirectTargetAfter(command string, start int) string { + i := start + for i < len(command) && isSpaceByte(command[i]) { + i++ + } + j := i + for j < len(command) && !isSpaceByte(command[j]) { + j++ + } + return command[i:j] +} + +// isSafeRedirectTarget reports whether a lexically extracted redirect target +// is safe to allow without a parsed AST. Safe targets are the static device +// paths the AST policy accepts (mirroring the semantics of the unexported +// ast.unixIsSafeRedirectTarget) and numeric fd duplications such as "2>&1", +// ">&2" or "1>&2" ("&" followed by one or more digits). An empty target +// (operator at end of string) or any dynamic/quoted/plain-path target is +// never safe. +func isSafeRedirectTarget(target string) bool { + switch target { + case "/dev/null", "/dev/stdout", "/dev/stderr": + return true + } + if len(target) > 1 && target[0] == '&' { + for i := 1; i < len(target); i++ { + if target[i] < '0' || target[i] > '9' { + return false + } + } + return true + } + return false +} + +// isSpaceByte reports whether c is one of the whitespace bytes recognized by +// the lexical redirect scan (spaces and tabs). +func isSpaceByte(c byte) bool { + return c == ' ' || c == '\t' +} diff --git a/internal/tool/parse_fallback_test.go b/internal/tool/parse_fallback_test.go new file mode 100644 index 00000000..187787d9 --- /dev/null +++ b/internal/tool/parse_fallback_test.go @@ -0,0 +1,172 @@ +package tool + +import ( + "encoding/json" + "fmt" + "path/filepath" + "runtime" + "strings" + "testing" + + "late/internal/tool/ast" +) + +// TestParseErrorHardBlock unit-tests the pure lexical fallback that runs on +// commands the AST parser could not read. It must keep hard-blocking the +// policy's hard-block signatures (cd usage, unsafe output redirection) while +// leaving everything else to the soft fail-closed confirmation path. +func TestParseErrorHardBlock(t *testing.T) { + tests := []struct { + name string + command string + wantErr bool + wantMsg string // substring expected in the error; "" when wantErr is false + }{ + { + name: "cd with trailing &&", + command: "cd /tmp &&", + wantErr: true, + wantMsg: "change directories", + }, + { + name: "truncate redirect with trailing &&", + command: "echo hi > /tmp/f &&", + wantErr: true, + wantMsg: "Output redirection (>) is blocked", + }, + { + name: "append redirect with trailing &&", + command: "echo hi >> /tmp/f &&", + wantErr: true, + wantMsg: "Output redirection (>) is blocked", + }, + { + name: "clobber-all redirect with trailing &&", + command: "echo hi &>/tmp/f &&", + wantErr: true, + wantMsg: "Output redirection (>) is blocked", + }, + { + name: "stderr redirect with trailing &&", + command: "echo hi 2> /tmp/f &&", + wantErr: true, + wantMsg: "Output redirection (>) is blocked", + }, + { + name: "noclobber redirect with trailing &&", + command: "echo hi >| /tmp/f &&", + wantErr: true, + wantMsg: "Output redirection (>) is blocked", + }, + { + name: "fd duplication is safe", + command: "ls 2>&1 &&", + wantErr: false, + }, + { + name: "fd duplication to stderr is safe", + command: "ls >&2 &&", + wantErr: false, + }, + { + name: "dev null target is safe", + command: "ls 2>/dev/null &&", + wantErr: false, + }, + { + name: "dev stderr target is safe", + command: "ls > /dev/stderr &&", + wantErr: false, + }, + { + name: "unterminated quote has no hard-block signatures", + command: `echo "unterminated`, + wantErr: false, + }, + { + name: "empty command", + command: "", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := parseErrorHardBlock(tt.command) + if (err != nil) != tt.wantErr { + t.Fatalf("parseErrorHardBlock(%q) error = %v, wantErr %v", tt.command, err, tt.wantErr) + } + if err == nil { + return + } + if tt.wantMsg != "" && !strings.Contains(err.Error(), tt.wantMsg) { + t.Errorf("parseErrorHardBlock(%q) error = %q, want to contain %q", tt.command, err.Error(), tt.wantMsg) + } + }) + } + + // Lock single-sourcing: the fallback must return the exact policy + // constants, not a diverging copy of the messages. + cdErr := parseErrorHardBlock("cd /tmp &&") + if cdErr == nil || cdErr.Error() != ast.BlockReasonCD { + t.Errorf("parseErrorHardBlock(\"cd /tmp &&\") = %v, want error exactly equal to ast.BlockReasonCD (%q)", cdErr, ast.BlockReasonCD) + } + redirectErr := parseErrorHardBlock("echo hi > /tmp/f &&") + if redirectErr == nil || redirectErr.Error() != ast.BlockReasonRedirect { + t.Errorf("parseErrorHardBlock(\"echo hi > /tmp/f &&\") = %v, want error exactly equal to ast.BlockReasonRedirect (%q)", redirectErr, ast.BlockReasonRedirect) + } +} + +// TestValidateBashCommand_ParseErrorKeepsHardBlocks exercises the integration +// path: unparseable commands flow through ShellTool.ValidateBashCommand / +// IsCommandBlocked / RequiresConfirmation, where hard-block signatures must +// still hard-block while unsigned input keeps the soft fail-closed contract +// (needs confirmation, not blocked). +func TestValidateBashCommand_ParseErrorKeepsHardBlocks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("parse-error fallback targets the Unix (mvdan/sh) analyzer; Windows uses the PowerShell analyzer") + } + + tmpDir := t.TempDir() + tool := ShellTool{} + + // Hard blocks survive the parse-error path. + redirectCmd := fmt.Sprintf("echo hi > %s/f &&", filepath.ToSlash(tmpDir)) + err := tool.ValidateBashCommand(redirectCmd, tmpDir) + if err == nil { + t.Fatalf("ValidateBashCommand(%q) error = nil, want output redirection hard block", redirectCmd) + } + if !strings.Contains(err.Error(), "Output redirection (>) is blocked") { + t.Errorf("ValidateBashCommand(%q) error = %q, want to contain %q", redirectCmd, err.Error(), "Output redirection (>) is blocked") + } + + err = tool.ValidateBashCommand("cd /tmp &&", tmpDir) + if err == nil { + t.Fatal("ValidateBashCommand(\"cd /tmp &&\") error = nil, want cd hard block") + } + if !strings.Contains(err.Error(), "change directories") { + t.Errorf("ValidateBashCommand(\"cd /tmp &&\") error = %q, want to contain %q", err.Error(), "change directories") + } + + // Soft fail-closed contract preserved for unparseable input without + // hard-block signatures: not blocked, but confirmation required. + blocked, err := tool.IsCommandBlocked("ls 2>&1 &&", tmpDir) + if err != nil { + t.Fatalf("IsCommandBlocked(\"ls 2>&1 &&\") error = %v", err) + } + if blocked { + t.Error("IsCommandBlocked(\"ls 2>&1 &&\") = true, want false (fd duplication is a safe redirect)") + } + if !tool.RequiresConfirmation(json.RawMessage(`{"command": "ls 2>&1 &&"}`)) { + t.Error("RequiresConfirmation(\"ls 2>&1 &&\") = false, want true (unparseable input must fail closed to confirmation)") + } + + // Unterminated quote: unparseable but carries no hard-block signatures. + blocked, err = tool.IsCommandBlocked(`echo "unterminated`, tmpDir) + if err != nil { + t.Fatalf("IsCommandBlocked(unterminated quote) error = %v", err) + } + if blocked { + t.Error(`IsCommandBlocked("echo \"unterminated") = true, want false (no hard-block signatures)`) + } +} diff --git a/internal/tui/interactions.go b/internal/tui/interactions.go index 3e70b5e4..8aeeb886 100644 --- a/internal/tui/interactions.go +++ b/internal/tui/interactions.go @@ -92,6 +92,14 @@ func TUIConfirmMiddleware(messenger Messenger, reg *common.ToolRegistry) common. // Mark approved context if unsupervised or explicitly whitelisted if skip, ok := ctx.Value(common.SkipConfirmationKey).(bool); ok && skip { if !(runtime.GOOS == "windows" && tc.Function.Name == "bash") { + // -force-revaluate-dangerous-commands: gate dangerous bash + // commands behind a single-use OTP instead of auto-approving. + if gateCtx, gateTC, blockMsg, handled := handleForceRevaluate(ctx, reg, tc); handled { + if blockMsg != "" { + return blockMsg, nil + } + return next(gateCtx, gateTC) + } ctx = context.WithValue(ctx, common.ToolApprovalKey, true) } } else if reg != nil { diff --git a/internal/tui/revaluate.go b/internal/tui/revaluate.go new file mode 100644 index 00000000..d7b1e771 --- /dev/null +++ b/internal/tui/revaluate.go @@ -0,0 +1,74 @@ +package tui + +import ( + "context" + "encoding/json" + "late/internal/client" + "late/internal/common" + "late/internal/tool" +) + +// handleForceRevaluate implements the -force-revaluate-dangerous-commands +// OTP gate for the bash tool inside the skip-confirmation branch of +// TUIConfirmMiddleware (i.e. only where unsupervised mode would otherwise +// auto-approve the call; the Windows bash carve-out never reaches it). +// +// Return values: +// - handled == false: the gate does not apply (flag off, not bash, hard +// refusal, or safe command); caller proceeds with the ORIGINAL ctx/tc +// exactly as before this feature existed, preserving every hard refusal. +// - handled == true && blockMsg != "": the call is blocked; caller must +// return (blockMsg, nil) WITHOUT calling next, so the message becomes +// the tool result the LLM sees. No ToolApprovalKey is stamped. +// - handled == true && blockMsg == "": the call is approved; caller must +// call next with the returned ctx (ToolApprovalKey already stamped) and +// the returned tc, which is UNCHANGED - the command parameter never +// carries the OTP, so no argument rewriting is ever needed. +func handleForceRevaluate(ctx context.Context, reg *common.ToolRegistry, tc client.ToolCall) (newCtx context.Context, newTC client.ToolCall, blockMsg string, handled bool) { + newCtx, newTC = ctx, tc + if enabled, ok := ctx.Value(common.ForceRevaluateKey).(bool); !ok || !enabled { + return + } + if reg == nil || tc.Function.Name != "bash" { + return + } + t := reg.Get(tc.Function.Name) + bashTool, isShell := t.(*tool.ShellTool) + if !isShell { + return + } + + var params struct { + Command string `json:"command"` + Cwd string `json:"cwd"` + OTPCode string `json:"otp_code"` + } + if err := json.Unmarshal([]byte(tc.Function.Arguments), ¶ms); err != nil { + return // let the normal flow surface malformed arguments + } + + // HARD REFUSALS ARE PRESERVED: anything the executor itself would refuse + // must keep flowing to the normal path and fail exactly as in yolo mode. + // The OTP gate applies only to commands yolo would have executed. + if params.Cwd != "" && !tool.IsSafePath(params.Cwd) { + return // refused in ShellTool.Execute: cwd outside allowed directory + } + if err := bashTool.ValidateBashCommand(params.Command, params.Cwd); err != nil { + return // bash search gate (grep/rg/find...) and AST hard blocks (cd, redirects) + } + + if !bashTool.RequiresConfirmation(json.RawMessage(tc.Function.Arguments)) { + return // safe command: auto-approve as before (any otp_code is ignored) + } + + // Dangerous command: a valid, unconsumed OTP bound to this EXACT + // command string is required. + if params.OTPCode != "" && tool.ConsumeOTP(params.Command, params.OTPCode) { + approved := context.WithValue(ctx, common.ToolApprovalKey, true) + return approved, tc, "", true + } + + // Blocked: issue (or re-present) the pending OTP for this command. + otpCode := tool.IssueOTP(params.Command) + return ctx, tc, tool.OTPRevaluateMessage(otpCode), true +} diff --git a/internal/tui/revaluate_test.go b/internal/tui/revaluate_test.go new file mode 100644 index 00000000..abf327ce --- /dev/null +++ b/internal/tui/revaluate_test.go @@ -0,0 +1,518 @@ +package tui + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + + "late/internal/client" + "late/internal/common" + "late/internal/tool" +) + +// --------------------------------------------------------------------------- +// Test harness +// +// The bash gate under test consults three kinds of ambient state: +// 1. the AST analyzer's allow-lists, read from ./.late/allowed_commands.json +// (relative to the process CWD) and from the OS config dir (derived from +// $HOME on darwin/windows, $XDG_CONFIG_HOME on linux), +// 2. the package-global pending-OTP registry in internal/tool, +// 3. the LATE_BASH_GATE environment variable (search-command gate level). +// +// isolateTestEnv points HOME and the process working directory at a fresh +// temp dir so no real allow-list can mark commands as safe (a dangerous `rm` +// therefore always prompts), pins the search gate to its default "enforce" +// level, and drops all pending OTPs so tests cannot leak codes into each +// other. +func isolateTestEnv(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("bash-specific gate tests") + } + + origWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("LATE_BASH_GATE", "enforce") + if err := os.Chdir(tmp); err != nil { + t.Fatalf("failed to chdir into temp dir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(origWd) + tool.ResetOTPRegistry() + }) + tool.ResetOTPRegistry() + return tmp +} + +func newBashRegistry(t *testing.T) *common.ToolRegistry { + t.Helper() + reg := common.NewToolRegistry() + reg.Register(&tool.ShellTool{}) + return reg +} + +type bashArgs struct { + Command string `json:"command"` + Cwd string `json:"cwd"` + OTPCode string `json:"otp_code"` +} + +func bashCall(t *testing.T, args bashArgs) client.ToolCall { + t.Helper() + raw, err := json.Marshal(args) + if err != nil { + t.Fatalf("failed to marshal bash arguments: %v", err) + } + return client.ToolCall{ + Type: "function", + Function: client.FunctionCall{ + Name: "bash", + Arguments: string(raw), + }, + } +} + +// forceRevaluateCtx simulates an unsupervised run started with +// -force-revaluate-dangerous-commands. +func forceRevaluateCtx() context.Context { + ctx := context.Background() + ctx = context.WithValue(ctx, common.SkipConfirmationKey, true) + ctx = context.WithValue(ctx, common.ForceRevaluateKey, true) + return ctx +} + +var otpCodePattern = regexp.MustCompile(`\b\d{7}\b`) + +// extractOTP asserts the message contains exactly one 7-digit code and +// returns it. +func extractOTP(t *testing.T, msg string) string { + t.Helper() + matches := otpCodePattern.FindAllString(msg, -1) + if len(matches) != 1 { + t.Fatalf("expected exactly one 7-digit code in message, got %d: %q", len(matches), msg) + } + if len(matches[0]) != 7 { + t.Fatalf("expected a 7-digit code, got %q", matches[0]) + } + return matches[0] +} + +// dangerousCommand returns a command the policy always prompts for (rm), +// targeting a path inside the isolated temp cwd. +func dangerousCommand(tmp string) string { + return "rm -rf " + filepath.Join(tmp, "build") +} + +// --------------------------------------------------------------------------- +// Gate activation +// --------------------------------------------------------------------------- + +func TestHandleForceRevaluate_InactiveWithoutFlag(t *testing.T) { + tmp := isolateTestEnv(t) + reg := newBashRegistry(t) + + // Unsupervised mode alone (no -force-revaluate-dangerous-commands) must + // keep the pre-existing yolo behaviour: the gate never engages. + ctx := context.WithValue(context.Background(), common.SkipConfirmationKey, true) + + _, _, blockMsg, handled := handleForceRevaluate(ctx, reg, bashCall(t, bashArgs{Command: dangerousCommand(tmp)})) + if handled { + t.Fatalf("expected gate to be inactive without ForceRevaluateKey, got handled=true blockMsg=%q", blockMsg) + } +} + +func TestHandleForceRevaluate_InactiveForNonBash(t *testing.T) { + isolateTestEnv(t) + + // Registry deliberately contains only a non-shell tool; the call targets + // "other", which is not registered at all. + reg := common.NewToolRegistry() + reg.Register(&tool.ReadFileTool{}) + + tc := client.ToolCall{ + Type: "function", + Function: client.FunctionCall{ + Name: "other", + Arguments: `{"command": "rm -rf build"}`, + }, + } + + _, _, blockMsg, handled := handleForceRevaluate(forceRevaluateCtx(), reg, tc) + if handled { + t.Fatalf("expected gate to be inactive for non-bash tool calls, got handled=true blockMsg=%q", blockMsg) + } +} + +// --------------------------------------------------------------------------- +// Middleware end-to-end +// --------------------------------------------------------------------------- + +func TestHandleForceRevaluate_BlocksDangerousCommandFirstAttempt(t *testing.T) { + tmp := isolateTestEnv(t) + messenger := &mockMessenger{} + reg := newBashRegistry(t) + + nextCalls := 0 + next := func(ctx context.Context, tc client.ToolCall) (string, error) { + nextCalls++ + return "ok", nil + } + runner := TUIConfirmMiddleware(messenger, reg)(next) + + tc := bashCall(t, bashArgs{Command: dangerousCommand(tmp)}) + + result, err := runner(forceRevaluateCtx(), tc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "re-evaluate") { + t.Errorf("expected block message to ask for re-evaluation, got %q", result) + } + if code := extractOTP(t, result); len(code) != 7 { + t.Errorf("expected a 7-digit OTP code in the block message, got %q", code) + } + if nextCalls != 0 { + t.Errorf("expected next NOT to be called while blocked, got %d calls", nextCalls) + } + if messenger.confirmCalled { + t.Errorf("expected no interactive confirmation request, but one was recorded") + } +} + +func TestHandleForceRevaluate_PendingCodeIsStable(t *testing.T) { + tmp := isolateTestEnv(t) + messenger := &mockMessenger{} + reg := newBashRegistry(t) + + next := func(ctx context.Context, tc client.ToolCall) (string, error) { + t.Errorf("next must not be called while the command stays blocked") + return "ok", nil + } + runner := TUIConfirmMiddleware(messenger, reg)(next) + + tc := bashCall(t, bashArgs{Command: dangerousCommand(tmp)}) + ctx := forceRevaluateCtx() + + first, err := runner(ctx, tc) + if err != nil { + t.Fatalf("unexpected error on first attempt: %v", err) + } + second, err := runner(ctx, tc) + if err != nil { + t.Fatalf("unexpected error on second attempt: %v", err) + } + + firstCode := extractOTP(t, first) + secondCode := extractOTP(t, second) + if firstCode != secondCode { + t.Errorf("expected the pending code to be re-presented, got %q then %q", firstCode, secondCode) + } +} + +func TestHandleForceRevaluate_ValidOTPRetryExecutes(t *testing.T) { + tmp := isolateTestEnv(t) + messenger := &mockMessenger{} + reg := newBashRegistry(t) + + command := dangerousCommand(tmp) + tc := bashCall(t, bashArgs{Command: command}) + ctx := forceRevaluateCtx() + + nextCalls := 0 + var nextCtx context.Context + var nextTC client.ToolCall + next := func(ctx context.Context, tc client.ToolCall) (string, error) { + nextCalls++ + nextCtx, nextTC = ctx, tc + return "ok", nil + } + runner := TUIConfirmMiddleware(messenger, reg)(next) + + blocked, err := runner(ctx, tc) + if err != nil { + t.Fatalf("unexpected error on first attempt: %v", err) + } + code := extractOTP(t, blocked) + + retry := bashCall(t, bashArgs{Command: command, OTPCode: code}) + result, err := runner(ctx, retry) + if err != nil { + t.Fatalf("unexpected error on retry: %v", err) + } + if result != "ok" { + t.Errorf("expected next's result to be forwarded, got %q", result) + } + if nextCalls != 1 { + t.Fatalf("expected next to be called exactly once, got %d calls", nextCalls) + } + if nextTC.Function.Arguments != retry.Function.Arguments { + t.Errorf("expected tool call arguments to pass through unchanged, got %q want %q", nextTC.Function.Arguments, retry.Function.Arguments) + } + var passed bashArgs + if err := json.Unmarshal([]byte(nextTC.Function.Arguments), &passed); err != nil { + t.Fatalf("failed to unmarshal forwarded arguments: %v", err) + } + if passed.Command != command { + t.Errorf("expected forwarded command to be byte-identical to the original, got %q want %q", passed.Command, command) + } + approved, ok := nextCtx.Value(common.ToolApprovalKey).(bool) + if !ok || !approved { + t.Errorf("expected ToolApprovalKey to be true in the context passed to next") + } + if messenger.confirmCalled { + t.Errorf("expected no interactive confirmation request, but one was recorded") + } +} + +// --------------------------------------------------------------------------- +// OTP lifecycle +// --------------------------------------------------------------------------- + +func TestHandleForceRevaluate_OTPSingleUse(t *testing.T) { + tmp := isolateTestEnv(t) + reg := newBashRegistry(t) + ctx := forceRevaluateCtx() + command := dangerousCommand(tmp) + + _, _, blockMsg, handled := handleForceRevaluate(ctx, reg, bashCall(t, bashArgs{Command: command})) + if !handled || blockMsg == "" { + t.Fatalf("expected first attempt to be blocked with a message, got handled=%v blockMsg=%q", handled, blockMsg) + } + code := extractOTP(t, blockMsg) + + // Valid OTP approves the call: handled with an empty block message. + _, _, approveMsg, approved := handleForceRevaluate(ctx, reg, bashCall(t, bashArgs{Command: command, OTPCode: code})) + if !approved || approveMsg != "" { + t.Fatalf("expected valid OTP to approve the command, got approved=%v blockMsg=%q", approved, approveMsg) + } + + // The consumed OTP must not work again: the command is blocked once more + // and a DIFFERENT code is issued for the next attempt. + _, _, reblockMsg, rehandled := handleForceRevaluate(ctx, reg, bashCall(t, bashArgs{Command: command, OTPCode: code})) + if !rehandled { + t.Fatalf("expected gate to handle the re-used OTP attempt") + } + if reblockMsg == "" { + t.Fatalf("expected re-used OTP to be rejected (command blocked again)") + } + fresh := extractOTP(t, reblockMsg) + if fresh == code { + t.Errorf("expected a fresh code after consumption, got the same code %q", code) + } +} + +func TestHandleForceRevaluate_WrongOTPRepresentsPendingCode(t *testing.T) { + tmp := isolateTestEnv(t) + reg := newBashRegistry(t) + ctx := forceRevaluateCtx() + command := dangerousCommand(tmp) + + _, _, blockMsg, handled := handleForceRevaluate(ctx, reg, bashCall(t, bashArgs{Command: command})) + if !handled || blockMsg == "" { + t.Fatalf("expected first attempt to be blocked with a message, got handled=%v blockMsg=%q", handled, blockMsg) + } + pending := extractOTP(t, blockMsg) + + const wrongCode = "0000001" + _, _, wrongMsg, wrongHandled := handleForceRevaluate(ctx, reg, bashCall(t, bashArgs{Command: command, OTPCode: wrongCode})) + if !wrongHandled || wrongMsg == "" { + t.Fatalf("expected wrong OTP attempt to stay blocked, got handled=%v blockMsg=%q", wrongHandled, wrongMsg) + } + if !strings.Contains(wrongMsg, pending) { + t.Errorf("expected block message to re-present the pending code %q, got %q", pending, wrongMsg) + } + if pending != wrongCode && strings.Contains(wrongMsg, wrongCode) { + t.Errorf("expected the wrong code %q not to be echoed back, got %q", wrongCode, wrongMsg) + } + + // A wrong attempt must not burn the pending code: the correct one still + // consumes afterwards. + _, _, approveMsg, approved := handleForceRevaluate(ctx, reg, bashCall(t, bashArgs{Command: command, OTPCode: pending})) + if !approved || approveMsg != "" { + t.Fatalf("expected correct pending code to consume after a wrong attempt, got approved=%v blockMsg=%q", approved, approveMsg) + } +} + +func TestHandleForceRevaluate_WhitespaceChangeIsDifferentCommand(t *testing.T) { + tmp := isolateTestEnv(t) + reg := newBashRegistry(t) + ctx := forceRevaluateCtx() + command := dangerousCommand(tmp) + + _, _, blockMsg, handled := handleForceRevaluate(ctx, reg, bashCall(t, bashArgs{Command: command})) + if !handled || blockMsg == "" { + t.Fatalf("expected first attempt to be blocked with a message, got handled=%v blockMsg=%q", handled, blockMsg) + } + code := extractOTP(t, blockMsg) + + // A double space makes it a DIFFERENT command string: the code issued for + // the original command must not approve it. + altered := "rm -rf " + filepath.Join(tmp, "build") + _, _, alteredMsg, alteredHandled := handleForceRevaluate(ctx, reg, bashCall(t, bashArgs{Command: altered, OTPCode: code})) + if !alteredHandled { + t.Fatalf("expected gate to handle the whitespace-changed command") + } + if alteredMsg == "" { + t.Fatalf("expected whitespace-changed command to stay blocked even with the issued code") + } + fresh := extractOTP(t, alteredMsg) + if fresh == code { + t.Errorf("expected a fresh code for the altered command, got the same code %q", code) + } +} + +// --------------------------------------------------------------------------- +// Safe commands and preserved hard refusals +// --------------------------------------------------------------------------- + +func TestHandleForceRevaluate_SafeCommandPassesThrough(t *testing.T) { + isolateTestEnv(t) + reg := newBashRegistry(t) + ctx := forceRevaluateCtx() + + // Empirically verified: with clean allow-lists `echo hello` is a built-in + // safe command (RequiresConfirmation == false). + const safeCommand = "echo hello" + + _, _, blockMsg, handled := handleForceRevaluate(ctx, reg, bashCall(t, bashArgs{Command: safeCommand})) + if handled { + t.Fatalf("expected safe command to pass through untouched, got handled=true blockMsg=%q", blockMsg) + } + + // A stray otp_code on the safe path must be ignored entirely: no approval + // flow, and no pending OTP is consumed by the pass-through. + code := tool.IssueOTP(safeCommand) + _, _, strayMsg, strayHandled := handleForceRevaluate(ctx, reg, bashCall(t, bashArgs{Command: safeCommand, OTPCode: code})) + if strayHandled { + t.Fatalf("expected safe command with stray otp_code to still pass through, got blockMsg=%q", strayMsg) + } + if !tool.ConsumeOTP(safeCommand, code) { + t.Fatalf("expected the stray otp_code to NOT be consumed by the safe pass-through (it should still be pending)") + } + if tool.ConsumeOTP(safeCommand, code) { + t.Fatalf("expected the code to be single-use once explicitly consumed") + } +} + +func TestHandleForceRevaluate_HardRefusalsPreserved(t *testing.T) { + tmp := isolateTestEnv(t) + reg := newBashRegistry(t) + ctx := forceRevaluateCtx() + + // A cwd sibling of the temp cwd: IsSafePath rejects it (verified + // empirically) because it escapes the process working directory. + unsafeCwd := filepath.Join(tmp, "..", "late-outside-cwd") + + cases := []struct { + name string + args bashArgs + }{ + {"bash search gate (grep)", bashArgs{Command: "grep -r foo ."}}, + {"AST hard block (cd)", bashArgs{Command: "cd " + tmp}}, + {"AST hard block (redirect)", bashArgs{Command: "echo hi > " + filepath.Join(tmp, "f")}}, + {"malformed command with redirect (parse error)", bashArgs{Command: "echo hi > " + filepath.Join(tmp, "f") + " &&"}}, + {"dangerous command with unsafe cwd", bashArgs{Command: dangerousCommand(tmp), Cwd: unsafeCwd}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, blockMsg, handled := handleForceRevaluate(ctx, reg, bashCall(t, tc.args)) + if handled { + t.Fatalf("expected hard refusal to keep flowing to the normal path untouched, got handled=true blockMsg=%q", blockMsg) + } + }) + } +} + +// TestHandleForceRevaluate_UnparseableRedirectNeverGetsOTP pins the regression +// fixed by hard-blocking unparseable commands that carry a hard-block +// signature. A trailing "&&" makes the command a shell syntax error, which +// used to downgrade it to plain "needs confirmation" and let the +// force-revaluate gate issue an OTP for it — so after OTP approval the command +// would fail open into the shell. The agent must NEVER see the OTP message for +// a hard-blocked command, in any form, even when it sends an otp_code: the +// parse-error hard block in ValidateBashCommand makes the gate's preservation +// check reject the command before any OTP logic, so the call keeps flowing to +// the normal path, where Execute fails closed with the plain block message. +func TestHandleForceRevaluate_UnparseableRedirectNeverGetsOTP(t *testing.T) { + tmp := isolateTestEnv(t) + reg := newBashRegistry(t) + ctx := forceRevaluateCtx() + + // Genuinely unparseable: the trailing "&&" is a shell syntax error, yet + // the raw text still carries an output redirect to a plain path. + command := "echo hi > " + filepath.Join(tmp, "f") + " &&" + + // First attempt: the hard refusal must be preserved — the gate does not + // handle the call, so no OTP is issued and no message is produced. + _, _, blockMsg, handled := handleForceRevaluate(ctx, reg, bashCall(t, bashArgs{Command: command})) + if handled { + t.Fatalf("expected unparseable redirect command to keep its hard refusal, got handled=true blockMsg=%q", blockMsg) + } + if blockMsg != "" { + t.Fatalf("expected empty block message when the gate does not handle the call, got %q", blockMsg) + } + + // Second attempt WITH an otp_code: still no OTP flow — the code must be + // ignored entirely, never consumed, and the call must stay unhandled. + _, _, blockMsg2, handled2 := handleForceRevaluate(ctx, reg, bashCall(t, bashArgs{Command: command, OTPCode: "1234567"})) + if handled2 { + t.Fatalf("expected unparseable redirect command to stay unhandled even with an otp_code, got handled=true blockMsg2=%q", blockMsg2) + } + if blockMsg2 != "" { + t.Fatalf("expected empty block message when an otp_code is offered to a hard-blocked command, got %q", blockMsg2) + } + + // Executor-boundary guarantee: the same command fails closed at the + // executor with the plain redirect block message (mirroring Execute's + // first check), so the re-run on the normal path can never execute it. + bashTool, ok := reg.Get("bash").(*tool.ShellTool) + if !ok { + t.Fatalf("expected the registry to hold a *tool.ShellTool under \"bash\"") + } + if err := bashTool.ValidateBashCommand(command, ""); err == nil { + t.Fatalf("ValidateBashCommand(%q) error = nil, want output redirection hard block", command) + } else if !strings.Contains(err.Error(), "Output redirection (>) is blocked") { + t.Errorf("ValidateBashCommand(%q) error = %q, want it to contain %q", command, err.Error(), "Output redirection (>) is blocked") + } + // When blocked, the returned error is the block reason (the same one + // Execute surfaces via WrapError), and it must be the plain redirect + // message — never the OTP re-evaluate text. + blocked, blockReason := bashTool.IsCommandBlocked(command, "") + if !blocked { + t.Fatalf("IsCommandBlocked(%q) = false, want true", command) + } + if blockReason == nil || !strings.Contains(blockReason.Error(), "Output redirection (>) is blocked") { + t.Errorf("IsCommandBlocked(%q) block reason = %v, want the output redirection block message", command, blockReason) + } +} + +// --------------------------------------------------------------------------- +// Conversation reset +// --------------------------------------------------------------------------- + +func TestShellToolResetConversationStateClearsOTPs(t *testing.T) { + isolateTestEnv(t) + + command := "rm -rf reset-state-probe" + code := tool.IssueOTP(command) + if len(code) != 7 { + t.Fatalf("expected a 7-digit pending code, got %q", code) + } + + (&tool.ShellTool{}).ResetConversationState() + + if tool.ConsumeOTP(command, code) { + t.Fatalf("expected pending OTP to be cleared by ResetConversationState") + } +} From ca50afebce2dd3e7920de9d7416d5053d606fbb2 Mon Sep 17 00:00:00 2001 From: Emasoft <713559+Emasoft@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:20:16 +0200 Subject: [PATCH 2/4] feat(local): multi-source interactive installer (dev/pinned/fork/upstream/official) Rewrite install-dev.sh as a smart multi-source installer and add a make install-dev target. Five sources, each idempotent (every run converges to its state): - local-dev build current branch -> SYMLINK to /bin/late (every make build updates the command in place) - pinned build current branch -> fixed COPY (dev tracking stops) - fork-main build Emasoft/late-cli@main from a codeload tarball -> COPY - upstream-main build mlhher/late-cli@main from a codeload tarball -> COPY - official run upstream install.sh (latest stable); upstream owns placement, --target is not applicable - check detection report only (read-only) Autodetection report printed on every run: platform -> GOOS/GOARCH, current install classification + version, repo branch/dirty state, remote main SHAs, upstream latest release tag, brew formula state, target dir selection + PATH membership. Conflict auto-solving: symlink<->copy transitions archive the displaced binary as .bak- (never overwritten) with a printed revert hint; brew-managed installs warn and gate behind --yes in non-interactive mode; post-install verification checks the installed shape, version, and shadowing with PATH guidance. Non-interactive flags (any position): --yes, --dry-run (plan only; the repo build still runs but target changes and downloads are skipped), --target DIR. Interactive numbered menu annotates each option with detected state ([CURRENT], SHAs, release tag); invalid input re-prompts, q quits; piped stdin takes a single choice. Tarball builds use -trimpath so reinstalls of the same commit are byte-identical. make install-dev runs ./install-dev.sh. --- Makefile | 5 +- install-dev.sh | 890 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 812 insertions(+), 83 deletions(-) diff --git a/Makefile b/Makefile index a8b83396..e62107f3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test test-podman vet lint vulncheck check clean install run help +.PHONY: build test test-podman vet lint vulncheck check clean install install-dev run help # Project variables BINARY_NAME=late @@ -46,5 +46,8 @@ install: build ## Build and install the binary to your Go bin path @mv bin/${BINARY_NAME} ~/.local/bin/late @install -m 0755 late-podman ~/.local/bin/late-podman +install-dev: ## Interactive multi-source installer (dev/pinned/fork/upstream/official) + @./install-dev.sh + run: build ## Build and run the project @./bin/${BINARY_NAME} diff --git a/install-dev.sh b/install-dev.sh index 43fbd086..d9b41dc1 100755 --- a/install-dev.sh +++ b/install-dev.sh @@ -1,96 +1,822 @@ #!/usr/bin/env bash -# install-dev.sh — replace the installed `late` command with a symlink into -# this repository's locally built binary, so every `make build` updates the -# command in place. Nothing is copied. +# install-dev.sh — smart multi-source installer for the `late` command. # -# Link target: -# - with Homebrew present (and late not brew-managed): brew's bin dir -# - without Homebrew: the first bin dir that is already on PATH and that we -# can write to or create, in this order: ~/.local/bin, ~/bin, -# /usr/local/bin. If none is on PATH, the script falls back to -# ~/.local/bin and tells you to add it to your PATH. +# Sources (each idempotent — every run converges to the chosen state): +# local-dev build the current branch of this repo and make the +# `late` command a SYMLINK to /bin/late, so every +# `make build` updates the command in place. +# pinned build the current branch and install a fixed COPY +# (rebuilds no longer update the command until re-run). +# fork-main build origin/main (Emasoft/late-cli) from a codeload +# tarball and install a COPY. +# upstream-main build upstream/main (mlhher/late-cli) from a codeload +# tarball and install a COPY. +# official run upstream's own installer (latest stable release). +# Upstream owns placement — `--target` is not applicable. +# check print the detection report only (read-only). +# +# Every run prints a detection report first and a shadowing + version +# verification after each install. Displaced binaries are archived as +# .bak-YYYYmmddHHMMSS (never overwritten) with a printed revert +# hint, so switching between dev/symlink and pinned/copy modes is safe. # # Usage: -# brew uninstall late # only when late is brew-managed -# ./install-dev.sh # optionally: LATE_DEV_VERSION=x ./install-dev.sh +# ./install-dev.sh # interactive menu (needs a TTY) +# ./install-dev.sh [flags] # non-interactive +# ./install-dev.sh --dry-run pinned # print the plan, mutate nothing +# +# Flags (any position): --yes --dry-run --target DIR --help # -# Go back to brew: -# rm "$(brew --prefix)/bin/late" && brew install late +# Go back to a brew-managed `late` at any time: +# brew uninstall late 2>/dev/null; brew install late set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO="$SCRIPT_DIR" DEV_LINK="$REPO/bin/late" -# Refuse to fight an active brew install: replacing brew's symlink out from -# under it would leave brew's link tracking desynced. -if command -v brew >/dev/null 2>&1; then - if brew list --formula 2>/dev/null | grep -qx 'late' || brew list --cask 2>/dev/null | grep -qx 'late'; then - echo "Error: late is still installed via brew — run: brew uninstall late" >&2 - exit 1 - fi - BIN_DIR="$(brew --prefix)/bin" -else - # No Homebrew: pick the first bin dir that is already on PATH and that we - # can write to (or create), so `late` can be invoked from anywhere. - BIN_DIR="" - for candidate in "$HOME/.local/bin" "$HOME/bin" /usr/local/bin; do - if [[ ":$PATH:" != *":$candidate:"* ]]; then - continue - fi - if [[ -d "$candidate" && -w "$candidate" ]]; then - BIN_DIR="$candidate" - break +FORK_REPO="Emasoft/late-cli" +UPSTREAM_REPO="mlhher/late-cli" +OFFICIAL_URL="https://raw.githubusercontent.com/${UPSTREAM_REPO}/main/install.sh" + +ASSUME_YES=0 +DRY_RUN=0 +TARGET_OVERRIDE="" +SOURCE="" +LATE_TMP="" +BAK_HINTS="" + +# Detection state +GOOS="" +GOARCH="" +CUR_PATH="" +CUR_CLASS="not installed" +CUR_VERSION="" +REPO_BRANCH="unknown" +REPO_SHA="unknown" +REPO_DIRTY="clean" +FORK_SHA="unknown" +UPSTREAM_SHA="unknown" +RELEASE_TAG="unknown" +BREW_PRESENT="no" +BREW_LATE="no" +BREW_PREFIX="" +TARGET_DIR="" +TARGET_NOTE="" +TARGET_ON_PATH="no" + +err() { echo "Error: $*" >&2; } +warn() { echo "⚠️ $*" >&2; } +info() { echo "=> $*"; } +die() { err "$*"; exit 1; } + +usage() { + cat </bin/late + pinned build current branch, install a fixed COPY + fork-main build ${FORK_REPO}@main from a tarball, install a COPY + upstream-main build ${UPSTREAM_REPO}@main from a tarball, install a COPY + official run upstream's installer (latest stable release); + upstream owns placement — --target is not applicable + check print the detection report and exit (read-only) + help show this help + +Flags (any position): + --yes skip confirmations + --dry-run print the plan, mutate nothing (the repo build still runs; + it writes only bin/late inside the repo) + --target DIR override the install directory (not applicable to 'official') + --help show this help + +With no SOURCE and a TTY on stdin an interactive menu is shown; without a +TTY one choice is read from a single stdin line. + +Every install archives a displaced binary as .bak- +(never overwritten) and prints a revert hint. +EOF +} + +# --- detection helpers ----------------------------------------------------- + +detect_goos() { + case "$(uname -s)" in + Darwin) printf '%s\n' "darwin" ;; + Linux) printf '%s\n' "linux" ;; + MINGW*|MSYS*|CYGWIN*) printf '%s\n' "windows" ;; + *) printf '%s\n' "unknown" ;; + esac +} + +detect_goarch() { + case "$(uname -m)" in + arm64|aarch64) printf '%s\n' "arm64" ;; + x86_64|amd64) printf '%s\n' "amd64" ;; + *) printf '%s\n' "unknown" ;; + esac +} + +# resolve_path PATH — follow a symlink chain (best effort, max 10 hops) +# and print the final path. +resolve_path() { + local p="$1" t n=0 d + while [ -L "$p" ] && [ "$n" -lt 10 ]; do + t="$(readlink "$p")" || return 1 + case "$t" in + /*) p="$t" ;; + *) p="$(dirname "$p")/$t" ;; + esac + n=$((n + 1)) + done + case "$p" in + *..*) + if d="$(cd "$(dirname "$p")" 2>/dev/null && pwd -P)"; then + p="${d}/$(basename "$p")" + fi + ;; + esac + printf '%s\n' "$p" +} + +binary_sha() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + fi +} + +# --- detection report ------------------------------------------------------ + +# pick_target_dir — choose TARGET_DIR (report only; never mutates). +pick_target_dir() { + if [ -n "$TARGET_OVERRIDE" ]; then + TARGET_DIR="$TARGET_OVERRIDE" + TARGET_NOTE="(from --target)" + if [ ! -d "$TARGET_DIR" ]; then + TARGET_NOTE="(from --target; will be created)" + fi + return 0 + fi + local candidate + for candidate in /opt/homebrew/bin "$HOME/.local/bin" /usr/local/bin; do + if [ "$candidate" = "/opt/homebrew/bin" ] && [ ! -d "$candidate" ]; then + continue + fi + if [ -d "$candidate" ]; then + if [ -w "$candidate" ]; then + TARGET_DIR="$candidate" + TARGET_NOTE="" + return 0 + fi + continue + fi + if [ "$candidate" = "$HOME/.local/bin" ]; then + TARGET_DIR="$candidate" + TARGET_NOTE="(will be created)" + return 0 + fi + done + die "no usable target directory found (tried /opt/homebrew/bin, ~/.local/bin, /usr/local/bin) — use --target DIR" +} + +# ensure_target_dir — create TARGET_DIR when an install really needs it. +ensure_target_dir() { + if [ "$DRY_RUN" -eq 1 ]; then + return 0 + fi + if [ -d "$TARGET_DIR" ]; then + return 0 + fi + if ! mkdir -p "$TARGET_DIR"; then + die "cannot create target directory ${TARGET_DIR}" + fi + info "Created target directory ${TARGET_DIR}" +} + +run_detection() { + GOOS="$(detect_goos)" + GOARCH="$(detect_goarch)" + + REPO_BRANCH="$(git -C "$REPO" branch --show-current 2>/dev/null || true)" + if [ -z "$REPO_BRANCH" ]; then REPO_BRANCH="unknown"; fi + REPO_SHA="$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || true)" + if [ -z "$REPO_SHA" ]; then REPO_SHA="unknown"; fi + if [ -n "$(git -C "$REPO" status --porcelain 2>/dev/null || true)" ]; then + REPO_DIRTY="dirty" + else + REPO_DIRTY="clean" + fi + + FORK_SHA="$(git -C "$REPO" ls-remote origin main 2>/dev/null \ + | awk 'NR==1 {print substr($1, 1, 7)}' || true)" + if [ -z "$FORK_SHA" ]; then FORK_SHA="unknown"; fi + UPSTREAM_SHA="$(git -C "$REPO" ls-remote upstream main 2>/dev/null \ + | awk 'NR==1 {print substr($1, 1, 7)}' || true)" + if [ -z "$UPSTREAM_SHA" ]; then UPSTREAM_SHA="unknown"; fi + + if command -v curl >/dev/null 2>&1; then + RELEASE_TAG="$(curl -sfL --max-time 10 \ + "https://api.github.com/repos/${UPSTREAM_REPO}/releases/latest" 2>/dev/null \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + | head -n 1 || true)" + fi + if [ -z "$RELEASE_TAG" ]; then RELEASE_TAG="unknown"; fi + + if command -v brew >/dev/null 2>&1; then + BREW_PRESENT="yes" + BREW_PREFIX="$(brew --prefix 2>/dev/null || true)" + if brew list --formula 2>/dev/null | grep -qx late; then + BREW_LATE="yes" + fi + fi + + CUR_PATH="$(command -v late 2>/dev/null || true)" + CUR_CLASS="not installed" + CUR_VERSION="" + if [ -n "$CUR_PATH" ]; then + if [ -L "$CUR_PATH" ]; then + local real + real="$(resolve_path "$CUR_PATH" || true)" + if [ -z "$real" ]; then real="?"; fi + case "$real" in + "$REPO"|"$REPO"/*) CUR_CLASS="symlink into THIS repo (branch ${REPO_BRANCH})" ;; + *) CUR_CLASS="symlink elsewhere -> ${real}" ;; + esac + else + if [ -n "$BREW_PREFIX" ]; then + case "$CUR_PATH" in + "${BREW_PREFIX}"/*) CUR_CLASS="regular file in brew prefix" ;; + *) CUR_CLASS="regular file elsewhere" ;; + esac + else + CUR_CLASS="regular file elsewhere" + fi + fi + if [ -x "$CUR_PATH" ]; then + CUR_VERSION="$("$CUR_PATH" -version 2>/dev/null | head -n 1 || true)" + fi + fi + + pick_target_dir + case ":$PATH:" in + *":$TARGET_DIR:"*) TARGET_ON_PATH="yes" ;; + *) TARGET_ON_PATH="no" ;; + esac + + echo "== late installer — detection ==" + printf "Platform: %s %s -> GOOS=%s GOARCH=%s\n" "$(uname -s)" "$(uname -m)" "$GOOS" "$GOARCH" + if [ -n "$CUR_PATH" ]; then + printf "Current install: %s\n" "$CUR_PATH" + printf " %s\n" "$CUR_CLASS" + if [ -n "$CUR_VERSION" ]; then + printf " version: %s\n" "$CUR_VERSION" + fi + else + echo "Current install: not installed" + fi + printf "Repo: %s (branch %s, %s @ %s)\n" "$REPO" "$REPO_BRANCH" "$REPO_DIRTY" "$REPO_SHA" + printf "Remotes: fork %s@main = %s | upstream %s@main = %s\n" "$FORK_REPO" "$FORK_SHA" "$UPSTREAM_REPO" "$UPSTREAM_SHA" + printf "Upstream release: latest stable = %s\n" "$RELEASE_TAG" + if [ "$BREW_PRESENT" = "yes" ]; then + if [ "$BREW_LATE" = "yes" ]; then + echo "Brew: late IS brew-managed ('brew upgrade late' may overwrite this install)" + else + echo "Brew: late not brew-managed (prefix ${BREW_PREFIX})" + fi + else + echo "Brew: not installed" + fi + if [ -n "$TARGET_NOTE" ]; then + printf "Target dir: %s %s\n" "$TARGET_DIR" "$TARGET_NOTE" + else + printf "Target dir: %s\n" "$TARGET_DIR" + fi + if [ "$TARGET_ON_PATH" = "yes" ]; then + echo "PATH: target dir is on PATH" + else + echo "PATH: target dir is NOT on PATH (add it or 'late' will not be found)" + fi + echo "================================" +} + +# --- build / install helpers ---------------------------------------------- + +require_go() { + if ! command -v go >/dev/null 2>&1; then + die "go is required for this source but was not found in PATH (the 'official' source needs no local build)" + fi +} + +build_repo() { + if [ "$DRY_RUN" -eq 1 ]; then + info "Building (dry-run allows the build — it writes only bin/late inside the repo)..." + else + info "Building late from ${REPO}..." + fi + if command -v make >/dev/null 2>&1 && [ -f "$REPO/Makefile" ]; then + if [ -n "${LATE_DEV_VERSION:-}" ]; then + if ! make -C "$REPO" build VERSION="$LATE_DEV_VERSION"; then + die "make build failed" + fi + else + if ! make -C "$REPO" build; then + die "make build failed" + fi + fi + else + if ! (cd "$REPO" && go build -o bin/late ./cmd/late); then + die "go build failed" + fi + fi + if [ ! -f "$DEV_LINK" ]; then + die "build did not produce ${DEV_LINK}" + fi +} + +backup_path() { + local p="$1" ts cand n=2 + ts="$(date +%Y%m%d%H%M%S)" + cand="${p}.bak-${ts}" + while [ -e "$cand" ] || [ -L "$cand" ]; do + cand="${p}.bak-${ts}-${n}" + n=$((n + 1)) + done + printf '%s\n' "$cand" +} + +# archive_displaced PATH — move an existing file/symlink aside to a +# timestamped .bak (never overwrites) and print a revert hint. +archive_displaced() { + local p="$1" bak + bak="$(backup_path "$p")" + mv "$p" "$bak" + warn "Archived ${p} -> ${bak}" + echo " Revert hint: mv \"${bak}\" \"${p}\"" + BAK_HINTS="${BAK_HINTS}${BAK_HINTS:+; }${bak}" +} + +install_symlink() { + local dest="$TARGET_DIR/late" + if [ -d "$dest" ] && [ ! -L "$dest" ]; then + die "refusing to replace directory ${dest}" + fi + if [ -e "$dest" ] && [ ! -L "$dest" ]; then + archive_displaced "$dest" + info "The command tracks this repo's rebuilds again (dev mode)." + fi + ln -sfn "$DEV_LINK" "$dest" + info "Installed symlink ${dest} -> ${DEV_LINK}" +} + +install_copy() { + local src="$1" note="$2" dest="$TARGET_DIR/late" + if [ -d "$dest" ] && [ ! -L "$dest" ]; then + die "refusing to replace directory ${dest}" + fi + if [ -L "$dest" ]; then + archive_displaced "$dest" + warn "$note" + fi + cp -f "$src" "$dest" + chmod 0755 "$dest" + info "Installed copy ${dest}" +} + +# target_state_desc — human description of what TARGET_DIR/late currently is. +target_state_desc() { + local dest="$TARGET_DIR/late" real + if [ -L "$dest" ]; then + real="$(resolve_path "$dest" 2>/dev/null || true)" + if [ -z "$real" ]; then real="?"; fi + case "$real" in + "$REPO"|"$REPO"/*) printf '%s\n' "symlink into this repo" ;; + *) printf '%s\n' "symlink -> ${real}" ;; + esac + elif [ -d "$dest" ]; then + printf '%s\n' "a directory (refusing to touch)" + elif [ -e "$dest" ]; then + if [ -n "$BREW_PREFIX" ]; then + case "$dest" in + "${BREW_PREFIX}"/*) printf '%s\n' "a regular file (in brew prefix)" ;; + *) printf '%s\n' "a regular file" ;; + esac + else + printf '%s\n' "a regular file" + fi + else + printf '%s\n' "absent" + fi +} + +plan_header() { + echo "" + echo "PLAN (dry-run — nothing will be changed) — source: $1" +} + +plan_target_line() { + echo " - target ${TARGET_DIR}/late: currently $(target_state_desc)" +} + +plan_backup_line() { + local mode="$1" dest="$TARGET_DIR/late" real + if [ -f "$dest" ] && [ ! -L "$dest" ]; then + echo " - would archive it to $(backup_path "$dest") and print a revert hint" + return 0 + fi + if [ -L "$dest" ]; then + real="$(resolve_path "$dest" 2>/dev/null || true)" + if [ -z "$real" ]; then real="?"; fi + case "$real" in + "$REPO"|"$REPO"/*) + if [ "$mode" = "copy" ]; then + echo " - would archive the dev symlink to $(backup_path "$dest") and print a revert hint" + else + echo " - already a symlink into this repo — re-pointing is a no-op" fi - if [[ ! -e "$candidate" ]]; then - mkdir -p "$candidate" - BIN_DIR="$candidate" - break + ;; + *) + if [ "$mode" = "copy" ]; then + echo " - would archive the symlink (-> ${real}) to $(backup_path "$dest")" + else + echo " - would replace the symlink (currently -> ${real})" fi + ;; + esac + fi +} + +plan_brew_line() { + if [ "$BREW_LATE" = "yes" ]; then + echo " - WARNING: late is brew-managed; a future 'brew upgrade late' may overwrite this install" + fi +} + +# guard_brew — warn and gate installs when late is brew-managed. +guard_brew() { + local answer + if [ "$BREW_LATE" != "yes" ]; then + return 0 + fi + echo "" >&2 + warn "late is currently installed via brew — a future 'brew upgrade late' may overwrite this install." >&2 + if [ "$ASSUME_YES" -eq 1 ]; then + return 0 + fi + if [ -t 0 ]; then + printf " Proceed anyway? [y/N] " >&2 + IFS= read -r answer || answer="" + case "$answer" in + y|Y|yes|Yes|YES) return 0 ;; + *) die "aborted by user (use --yes to skip this prompt)" ;; + esac + else + die "late is brew-managed — re-run with --yes to proceed anyway, or 'brew uninstall late' first" + fi +} + +post_verify() { + local mode="$1" dest="$TARGET_DIR/late" resolved v + echo "" + echo "== verification ==" + if [ "$mode" = "official" ]; then + resolved="$(command -v late 2>/dev/null || true)" + if [ -z "$resolved" ]; then + warn "'late' is not on your PATH after the official installer — check the installer output above." + return 0 + fi + echo "late resolves to: ${resolved}" + v="$(late -version 2>/dev/null | head -n 1 || true)" + if [ -n "$v" ]; then + echo "version: ${v}" + fi + return 0 + fi + if [ "$mode" = "symlink" ]; then + if [ ! -L "$dest" ]; then + err "verification failed: expected a symlink at ${dest}" + return 1 + fi + echo "symlink: ${dest} -> $(readlink "$dest")" + else + if [ ! -f "$dest" ] || [ -L "$dest" ]; then + err "verification failed: expected a regular file at ${dest}" + return 1 + fi + echo "copy: ${dest} (sha256 $(binary_sha "$dest" || true))" + fi + if [ -x "$dest" ]; then + v="$("$dest" -version 2>/dev/null | head -n 1 || true)" + if [ -n "$v" ]; then + echo "version: ${v}" + fi + fi + resolved="$(command -v late 2>/dev/null || true)" + if [ -z "$resolved" ]; then + warn "'late' is not on your PATH — add this to your ~/.zshrc or ~/.bashrc:" + echo " export PATH=\"\$PATH:${TARGET_DIR}\"" + elif [ "$resolved" != "$dest" ]; then + warn "'late' currently resolves to: ${resolved}" + warn "it shadows ${dest} — adjust your PATH order or remove that binary." + else + echo "late resolves to: ${resolved} (no shadowing)" + fi +} + +# --- install options ------------------------------------------------------- + +opt_local_dev() { + require_go + echo "" + info "Source: local-dev — build ${REPO_BRANCH} @ ${REPO_SHA}, install as symlink" + build_repo + if [ "$DRY_RUN" -eq 1 ]; then + plan_header "local-dev" + plan_target_line + echo " - would replace it with a SYMLINK -> ${DEV_LINK}" + plan_backup_line "symlink" + plan_brew_line + return 0 + fi + guard_brew + install_symlink + post_verify "symlink" +} + +opt_pinned() { + require_go + echo "" + info "Source: pinned — build ${REPO_BRANCH} @ ${REPO_SHA}, install as fixed copy" + build_repo + if [ "$DRY_RUN" -eq 1 ]; then + plan_header "pinned" + plan_target_line + echo " - would replace it with a COPY of ${DEV_LINK}" + plan_backup_line "copy" + plan_brew_line + return 0 + fi + guard_brew + install_copy "$DEV_LINK" \ + "Dev tracking stops: rebuilds of this repo no longer update the command; re-run the installer to update." + post_verify "copy" +} + +opt_tarball() { + local slug="$1" label="$2" note="$3" url root d + require_go + echo "" + info "Source: ${label} — build ${slug}@main, install as copy" + if [ "$DRY_RUN" -eq 1 ]; then + plan_header "$label" + echo " - would download https://codeload.github.com/${slug}/tar.gz/refs/heads/main to a temp dir" + echo " - would extract it and build ./cmd/late with go (GOOS=${GOOS} GOARCH=${GOARCH})" + plan_target_line + echo " - would install the built binary as a COPY to ${TARGET_DIR}/late" + plan_backup_line "copy" + plan_brew_line + return 0 + fi + guard_brew + url="https://codeload.github.com/${slug}/tar.gz/refs/heads/main" + LATE_TMP="$(mktemp -d "${TMPDIR:-/tmp}/late-install.XXXXXX")" + info "Downloading ${url}" + if ! curl -sfL "$url" -o "${LATE_TMP}/src.tar.gz"; then + die "download failed: ${url}" + fi + if ! tar -xzf "${LATE_TMP}/src.tar.gz" -C "$LATE_TMP"; then + die "failed to extract the ${slug} tarball" + fi + root="" + if [ -d "${LATE_TMP}/cmd/late" ]; then + root="$LATE_TMP" + else + for d in "$LATE_TMP"/*/; do + if [ -d "${d}cmd/late" ]; then + root="${d%/}" + break + fi + done + fi + if [ -z "$root" ]; then + die "tarball from ${slug} contains no cmd/late — refusing to install" + fi + info "Building ${slug}@main in $(basename "$root") (GOOS=${GOOS} GOARCH=${GOARCH})" + if ! ( + cd "$root" || exit 1 + # Tarballs carry no .git (no VCS stamping needed). If go.sum is absent + # (it is normally tracked), let go resolve checksums into the temp copy. + if [ -f go.mod ] && [ ! -f go.sum ]; then + export GOFLAGS="-mod=mod" + fi + if [ "$GOOS" != "unknown" ] && [ "$GOARCH" != "unknown" ]; then + export GOOS="$GOOS" GOARCH="$GOARCH" + fi + # -trimpath strips the random mktemp dir from the binary so two builds + # of the same commit are byte-identical (idempotent reinstalls). + go build -trimpath -o "${LATE_TMP}/late" ./cmd/late + ); then + die "build of ${slug}@main failed — refusing to install" + fi + install_copy "${LATE_TMP}/late" "$note" + post_verify "copy" +} + +opt_official() { + echo "" + info "Source: official — upstream installer (${OFFICIAL_URL})" + if [ -n "$TARGET_OVERRIDE" ]; then + die "--target is not applicable to 'official' (upstream owns placement)" + fi + if [ "$DRY_RUN" -eq 1 ]; then + plan_header "official" + echo " - would fetch and execute: ${OFFICIAL_URL}" + echo " - upstream owns placement and conflict logic; --target is not applicable" + plan_brew_line + return 0 + fi + info "Running upstream installer (latest stable ${RELEASE_TAG})..." + if ! curl -sfL "$OFFICIAL_URL" | bash -s --; then + die "official installer failed" + fi + post_verify "official" +} + +# --- menu ------------------------------------------------------------------ + +print_menu() { + local cur="" + case "$CUR_CLASS" in + "symlink into THIS repo"*) cur=" [CURRENT]" ;; + esac + echo "" + echo "Select an install source for \`late\`:" + printf " [1] local unstable dev — build current branch (%s @ %s), symlink%s\n" "$REPO_BRANCH" "$REPO_SHA" "$cur" + printf " [2] pinned stable — build current branch, fixed copy\n" + printf " [3] fork main (unstable) — build %s@main (%s), copy\n" "$FORK_REPO" "$FORK_SHA" + printf " [4] upstream main (unstable) — build %s@main (%s), copy\n" "$UPSTREAM_REPO" "$UPSTREAM_SHA" + printf " [5] official installer — upstream script (latest stable %s)\n" "$RELEASE_TAG" + echo " [6] check only" + echo " q quit" +} + +choice_to_source() { + case "$1" in + 1|local-dev) printf '%s\n' "local-dev" ;; + 2|pinned) printf '%s\n' "pinned" ;; + 3|fork-main) printf '%s\n' "fork-main" ;; + 4|upstream-main) printf '%s\n' "upstream-main" ;; + 5|official) printf '%s\n' "official" ;; + 6|check) printf '%s\n' "check" ;; + *) printf '%s\n' "" ;; + esac +} + +is_quit() { + case "$1" in + q|Q|quit|Quit|QUIT) return 0 ;; + *) return 1 ;; + esac +} + +# menu_pick — print the menu and set SOURCE; re-prompts on a TTY, reads a +# single line from stdin otherwise. +menu_pick() { + local choice source + print_menu + if [ -t 0 ]; then + while :; do + printf "Choice: " + if ! IFS= read -r choice; then + echo "" + die "no selection made" + fi + if is_quit "$choice"; then + echo "Bye." + exit 0 + fi + source="$(choice_to_source "$choice")" + if [ -n "$source" ]; then + SOURCE="$source" + return 0 + fi + echo "Invalid choice: ${choice} — enter 1-6 (or q to quit)." done - if [[ -z "$BIN_DIR" ]]; then - # Nothing suitable on PATH: default to ~/.local/bin (XDG convention) - # and remind the user to add it below. - BIN_DIR="$HOME/.local/bin" - mkdir -p "$BIN_DIR" - fi -fi - -echo "=> Building late from $REPO..." -if [[ -n "${LATE_DEV_VERSION:-}" ]]; then - make -C "$REPO" build VERSION="$LATE_DEV_VERSION" -else - make -C "$REPO" build -fi - -# Never clobber a real binary: the destination must be a symlink (a previous -# dev install) or absent. Anything else gets removed by hand, deliberately. -if [[ -e "$BIN_DIR/late" && ! -L "$BIN_DIR/late" ]]; then - echo "Error: $BIN_DIR/late already exists and is not a symlink." >&2 - echo "Remove or rename it manually, then re-run this script." >&2 - exit 1 -fi - -echo "=> Symlinking $BIN_DIR/late -> $DEV_LINK" -ln -sfn "$DEV_LINK" "$BIN_DIR/late" - -# Friction checks: the link must be reachable and must not be shadowed by -# another `late` sitting earlier on PATH. -resolved="$(command -v late 2>/dev/null || true)" -if [[ -z "$resolved" ]]; then - echo "" >&2 - echo "⚠️ 'late' is not on your PATH — add this to your ~/.zshrc or ~/.bashrc:" >&2 - echo " export PATH=\"\$PATH:$BIN_DIR\"" >&2 - exit 1 -fi -if [[ "$resolved" != "$BIN_DIR/late" ]]; then - echo "" >&2 - echo "⚠️ 'late' currently resolves to: $resolved" >&2 - echo " which shadows $BIN_DIR/late — adjust your PATH order or remove" >&2 - echo " that binary, then re-run this script." >&2 - exit 1 -fi - -echo "=> Success! 'late' now resolves to:" -command -v late -late -version + fi + IFS= read -r choice || choice="" + if [ -z "$choice" ]; then + die "no selection made on stdin — pass a source (local-dev|pinned|fork-main|upstream-main|official|check) as an argument" + fi + if is_quit "$choice"; then + exit 0 + fi + source="$(choice_to_source "$choice")" + if [ -z "$source" ]; then + die "invalid choice: ${choice}" + fi + SOURCE="$source" +} + +confirm_or_die() { + local answer + if [ "$ASSUME_YES" -eq 1 ]; then + return 0 + fi + if [ ! -t 0 ]; then + return 0 + fi + printf "Proceed with '%s' on %s/late? [y/N] " "$SOURCE" "$TARGET_DIR" + IFS= read -r answer || answer="" + case "$answer" in + y|Y|yes|Yes|YES) return 0 ;; + *) die "aborted by user (use --yes to skip confirmations)" ;; + esac +} + +cleanup() { + if [ -n "$LATE_TMP" ] && [ -d "$LATE_TMP" ]; then + rm -rf -- "$LATE_TMP" + fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +parse_args() { + local positional_count=0 + while [ $# -gt 0 ]; do + case "$1" in + --yes) + ASSUME_YES=1 + ;; + --dry-run) + DRY_RUN=1 + ;; + --target) + if [ $# -lt 2 ]; then + die "--target requires a directory argument" + fi + TARGET_OVERRIDE="$2" + shift + ;; + --target=*) + TARGET_OVERRIDE="${1#--target=}" + if [ -z "$TARGET_OVERRIDE" ]; then + die "--target requires a directory argument" + fi + ;; + --help|-h|help) + SOURCE="help" + ;; + check|local-dev|pinned|fork-main|upstream-main|official) + positional_count=$((positional_count + 1)) + if [ "$positional_count" -gt 1 ]; then + die "only one SOURCE argument is allowed (got another: $1)" + fi + SOURCE="$1" + ;; + *) + die "unknown argument: $1 (try --help)" + ;; + esac + shift + done +} + +main() { + parse_args "$@" + if [ "$SOURCE" = "help" ]; then + usage + return 0 + fi + run_detection + if [ -z "$SOURCE" ]; then + menu_pick + fi + if [ "$SOURCE" = "check" ]; then + return 0 + fi + confirm_or_die + ensure_target_dir + case "$SOURCE" in + local-dev) opt_local_dev ;; + pinned) opt_pinned ;; + fork-main) opt_tarball "$FORK_REPO" "fork-main" \ + "Dev tracking stops: this snapshot of ${FORK_REPO}@main does not follow local rebuilds." ;; + upstream-main) opt_tarball "$UPSTREAM_REPO" "upstream-main" \ + "Dev tracking stops: this snapshot of ${UPSTREAM_REPO}@main does not follow local rebuilds." ;; + official) opt_official ;; + *) die "unhandled source: ${SOURCE}" ;; + esac + if [ -n "$BAK_HINTS" ]; then + echo "" + echo "Backups created this run: ${BAK_HINTS}" + fi +} + +main "$@" From 21a1c2c7639d5a878e4008fc852934ffba1a7109 Mon Sep 17 00:00:00 2001 From: Emasoft <713559+Emasoft@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:49:48 +0200 Subject: [PATCH 3/4] feat(local): headless --choice N for install-dev.sh Dev boxes and CI need a hands-off installer: --choice N selects the menu entry, implies --yes and never prompts (root is refused, targets are validated, previous binaries are archived). README Development section documents the strictly-dev nature and the headless mode. --- README.md | 20 +++++++++ install-dev.sh | 111 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1ecaf3f0..509d5879 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,26 @@ export OPENAI_MODEL="model-name" --- +## Development + +### Development installer (`install-dev.sh`) +`./install-dev.sh` is a strictly **development** installer: it builds from a git +source and can install the unstable `main` branches. It is interactive by +default and autodetects platform, current install, remotes and conflicts. + +Headless (dev boxes / CI): +```bash +./install-dev.sh --choice 1 # local unstable dev (current branch, symlink) +./install-dev.sh --choice 6 # detection report only +./install-dev.sh --choice 2 --target /tmp/bin --dry-run +``` +`--choice N` maps to the menu entries (1 local-dev, 2 pinned, 3 fork-main, +4 upstream-main, 5 official, 6 check), implies `--yes`, and requires no human +supervision. Safety: the previous binary is archived as `.bak-`, +brew-owned installs warn, running as root is refused. + +--- + ## License Built to create engineering leverage, not to supply free infrastructure for AI startups. diff --git a/install-dev.sh b/install-dev.sh index d9b41dc1..36a86975 100755 --- a/install-dev.sh +++ b/install-dev.sh @@ -23,9 +23,15 @@ # Usage: # ./install-dev.sh # interactive menu (needs a TTY) # ./install-dev.sh [flags] # non-interactive +# ./install-dev.sh --choice N [flags] # headless: menu entry N, implies --yes # ./install-dev.sh --dry-run pinned # print the plan, mutate nothing # -# Flags (any position): --yes --dry-run --target DIR --help +# Flags (any position): --yes --dry-run --target DIR --choice N --help +# +# Headless mode (--choice N, 1..6 = the menu entries) implies --yes and +# never shows the menu or prompts; running as root is refused (set +# LATE_INSTALL_ALLOW_ROOT=1 to override). The 'official' source may still +# prompt — the upstream script owns it. # # Go back to a brew-managed `late` at any time: # brew uninstall late 2>/dev/null; brew install late @@ -43,6 +49,7 @@ ASSUME_YES=0 DRY_RUN=0 TARGET_OVERRIDE="" SOURCE="" +CHOICE="" LATE_TMP="" BAK_HINTS="" @@ -70,6 +77,28 @@ warn() { echo "⚠️ $*" >&2; } info() { echo "=> $*"; } die() { err "$*"; exit 1; } +# usage_error — bad usage: print the error plus the full usage, exit 2. +usage_error() { + err "$*" + usage >&2 + exit 2 +} + +# arg_error — bad usage without the full usage block, exit 2. +arg_error() { + err "$*" + exit 2 +} + +# refuse_root — the installer is user-level; refuse accidental root runs +# (dev boxes and containers often run as root by default). +refuse_root() { + if [ "$(id -u)" -eq 0 ] && [ "${LATE_INSTALL_ALLOW_ROOT:-0}" != "1" ]; then + err "late installer is user-level; set LATE_INSTALL_ALLOW_ROOT=1 to override" + exit 2 + fi +} + usage() { cat <.bak- (never overwritten) and prints a revert hint. @@ -747,6 +783,36 @@ trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM +# validate_choice N — --choice must be an integer in 1..6 (menu entries). +validate_choice() { + case "$1" in + ''|*[!0-9]*) + usage_error "--choice expects an integer 1-6 (got: '$1')" ;; + esac + if [ "$1" -lt 1 ] || [ "$1" -gt 6 ]; then + usage_error "--choice expects an integer 1-6 (got: $1)" + fi +} + +# validate_target_override — fool-proofing for an explicit --target: it must +# be an absolute path and must not name a system directory (/usr/local/bin +# stays allowed). +validate_target_override() { + [ -n "$TARGET_OVERRIDE" ] || return 0 + case "$TARGET_OVERRIDE" in + /*) ;; + *) arg_error "--target must be an absolute path (got: ${TARGET_OVERRIDE})" ;; + esac + local norm="$TARGET_OVERRIDE" + while [ "$norm" != "/" ] && [ "${norm%/}" != "$norm" ]; do + norm="${norm%/}" + done + case "$norm" in + /|/bin|/sbin|/etc|/usr) + arg_error "refusing --target ${TARGET_OVERRIDE}: system directory (use e.g. /usr/local/bin or ~/.local/bin)" ;; + esac +} + parse_args() { local positional_count=0 while [ $# -gt 0 ]; do @@ -770,7 +836,34 @@ parse_args() { die "--target requires a directory argument" fi ;; - --help|-h|help) + --choice) + if [ "$positional_count" -gt 0 ]; then + arg_error "pass either an option name or --choice N" + fi + if [ $# -lt 2 ]; then + usage_error "--choice requires a number argument (1-6)" + fi + validate_choice "$2" + CHOICE="$2" + shift + ;; + --choice=*) + if [ "$positional_count" -gt 0 ]; then + arg_error "pass either an option name or --choice N" + fi + CHOICE="${1#--choice=}" + if [ -z "$CHOICE" ]; then + usage_error "--choice requires a number argument (1-6)" + fi + validate_choice "$CHOICE" + ;; + --help|-h) + SOURCE="help" + ;; + help) + if [ -n "$CHOICE" ]; then + arg_error "pass either an option name or --choice N" + fi SOURCE="help" ;; check|local-dev|pinned|fork-main|upstream-main|official) @@ -778,6 +871,9 @@ parse_args() { if [ "$positional_count" -gt 1 ]; then die "only one SOURCE argument is allowed (got another: $1)" fi + if [ -n "$CHOICE" ]; then + arg_error "pass either an option name or --choice N" + fi SOURCE="$1" ;; *) @@ -789,11 +885,20 @@ parse_args() { } main() { + refuse_root parse_args "$@" + validate_target_override if [ "$SOURCE" = "help" ]; then usage return 0 fi + if [ -n "$CHOICE" ]; then + # Headless mode: --choice N selects the menu entry, implies --yes and + # never shows the menu or prompts. 'official' may still prompt because + # the upstream script owns its interaction. + ASSUME_YES=1 + SOURCE="$(choice_to_source "$CHOICE")" + fi run_detection if [ -z "$SOURCE" ]; then menu_pick From 042f8a83de400490dbc02a4ab247fa6e53b36729 Mon Sep 17 00:00:00 2001 From: Emasoft <713559+Emasoft@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:40:15 +0200 Subject: [PATCH 4/4] feat(local): package-manager/podman detection, late-podman parity, uninstall mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install-dev.sh rev 3: - Detection additions (check + pre-install report): package managers reported ALL found (brew, apt-get, dnf, pacman, zypper, apk, npm — comma list), podman binary presence + version (`podman --version`), and late-podman installed state (target dir + ~/.local/bin, classified as symlink/regular file/brew-managed/our copy). - late-podman install parity with `make install` (which ships both `late` and `late-podman` to ~/.local/bin): local-dev installs a SYMLINK to /late-podman, pinned installs a COPY of /late-podman, fork-main/upstream-main copy it from the extracted tarball root (skipped with a warning if the tarball lacks it). Same .bak- archiving on mode transitions, plan lines in --dry-run, and a printed note that late-podman's runtime requires a Linux host + podman. - New `uninstall` subcommand (name-invoked only, NOT in the --choice menu; --choice + uninstall -> usage error): ./install-dev.sh uninstall [--purge] [--with-deps] [--yes] [--dry-run] [--target DIR] - removes the installed `late` (symlink or regular file) in the target dir; nothing inside the brew prefix is ever deleted — `brew uninstall late` is printed as advice instead; a symlink into a different repo is removed while that repo is kept - removes `late-podman` in the target dir / ~/.local/bin only when it is our own copy (symlink into this repo, sha256 match with /late-podman, or an older launcher recognized by its usage header); foreign files are left untouched - removes all late.bak-* / late-podman.bak-* archives in the target dir - --purge additionally deletes user data (each path printed first, guarded against non-"late" basenames): the config dir exactly as the Go code computes it (internal/pathutil.LateConfigDir = os.UserConfigDir()/late — ~/Library/Application Support/late on darwin, honoring XDG_CONFIG_HOME on Linux; contains config.json, mcp_config.json, plugins/ and the skills/ dir, which lives inside the config dir) and the data dir ~/.local/share/late (internal/pathutil.LateSessionDir parent). Interactive runs confirm y/N (default N); headless runs require --yes. - --with-deps PRINTS the exact package-manager removal commands for late-relevant deps (podman, go; e.g. `brew uninstall podman`, `sudo apt remove podman`) after checking PM ownership — never executes them (they may be shared with other projects); documented in --help - idempotent: an already-uninstalled system prints "nothing to uninstall" and exits 0; --dry-run prints every planned removal without executing - README Development installer section: uninstall mode, purge semantics, deps printed not removed, late-podman parity, podman/Linux runtime note. Shellcheck note: the fork/upstream tarball build now passes GOOS/GOARCH to go via env-prefix instead of `export` inside the subshell, so the script's host GOOS stays unmodified (shellcheck SC2030/SC2031 clean). Paths: install-dev.sh, README.md. --- README.md | 46 ++++ install-dev.sh | 661 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 694 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 509d5879..6c5e2911 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,52 @@ Headless (dev boxes / CI): supervision. Safety: the previous binary is archived as `.bak-`, brew-owned installs warn, running as root is refused. +The detection report also lists every package manager found (brew, apt-get, +dnf, pacman, zypper, apk, npm), whether `podman` is installed (with its +version), and whether the `late-podman` launcher is already installed. + +**`late-podman` parity:** `local-dev`, `pinned`, `fork-main` and +`upstream-main` also install the `late-podman` launcher into the target dir — +a symlink for `local-dev`, a copy for the others — matching `make install`, +which ships both commands, with the same `.bak-` archiving on +transitions. Note that `late-podman`'s *runtime* requires a **Linux host with +podman**: on macOS the launcher refuses to run (`only Linux hosts are +supported`); it is meant for Linux machines or Linux containers. + +**Uninstalling:** `./install-dev.sh uninstall` removes what the installer +manages (name-invoked only — it is deliberately *not* in the `--choice` +menu, because it is destructive): + +```bash +./install-dev.sh uninstall --yes # remove late + late-podman + .bak archives +./install-dev.sh uninstall --purge --dry-run # preview, including user-data removal +./install-dev.sh uninstall --purge --yes # also delete user data +./install-dev.sh uninstall --with-deps # print (never run) dependency removal commands +``` + +Semantics: + +* Removes the installed `late` (symlink or copy) in the target dir and + `late-podman` in the target dir / `~/.local/bin` — but only the installer's + own copies (a symlink into this repo, or a copy matching this repo's + launcher). Files inside the brew prefix are **never** deleted; the script + prints `brew uninstall late` as advice instead. A symlink pointing into a + different repo is removed while that repo is kept. +* All `late.bak-` / `late-podman.bak-` archives in the + target dir are removed too. +* `--purge` additionally deletes **user data**: the late config dir + (`~/Library/Application Support/late` on macOS; `~/.config/late` on Linux, + honoring `XDG_CONFIG_HOME` — contains `config.json`, `mcp_config.json`, + `plugins/` and `skills/`) and the data dir (`~/.local/share/late`, session + history). Every path is printed before deletion; interactive runs confirm + with y/N (default **N**), headless runs require `--yes`. +* `--with-deps` **prints** the exact package-manager removal commands for + late-relevant dependencies (`podman`, `go` — e.g. `brew uninstall podman`, + `sudo apt remove podman`). It never executes them: those packages are + often shared with other projects. +* Idempotent: uninstalling an already-uninstalled system prints + "nothing to uninstall" and exits 0. + --- ## License diff --git a/install-dev.sh b/install-dev.sh index 36a86975..15f75f60 100755 --- a/install-dev.sh +++ b/install-dev.sh @@ -14,19 +14,30 @@ # official run upstream's own installer (latest stable release). # Upstream owns placement — `--target` is not applicable. # check print the detection report only (read-only). +# uninstall remove what this installer manages (name-invoked only; +# see UNINSTALL below — never offered in the menu). # -# Every run prints a detection report first and a shadowing + version -# verification after each install. Displaced binaries are archived as -# .bak-YYYYmmddHHMMSS (never overwritten) with a printed revert -# hint, so switching between dev/symlink and pinned/copy modes is safe. +# local-dev/pinned/fork-main/upstream-main also install the `late-podman` +# launcher into the target dir with the same parity as `late` (symlink for +# local-dev, copy otherwise — matching `make install`). late-podman's +# runtime requires a Linux host and podman; it refuses to run elsewhere. +# +# Every run prints a detection report first (platform, current install, +# package managers, podman, late-podman, remotes, brew, target) and a +# shadowing + version verification after each install. Displaced binaries +# are archived as .bak-YYYYmmddHHMMSS (never overwritten) with a +# printed revert hint, so switching between dev/symlink and pinned/copy +# modes is safe. # # Usage: # ./install-dev.sh # interactive menu (needs a TTY) # ./install-dev.sh [flags] # non-interactive # ./install-dev.sh --choice N [flags] # headless: menu entry N, implies --yes # ./install-dev.sh --dry-run pinned # print the plan, mutate nothing +# ./install-dev.sh uninstall [--purge] [--with-deps] [--yes] [--dry-run] # -# Flags (any position): --yes --dry-run --target DIR --choice N --help +# Flags (any position): --yes --dry-run --target DIR --choice N +# --purge --with-deps --help # # Headless mode (--choice N, 1..6 = the menu entries) implies --yes and # never shows the menu or prompts; running as root is refused (set @@ -50,8 +61,11 @@ DRY_RUN=0 TARGET_OVERRIDE="" SOURCE="" CHOICE="" +PURGE=0 +WITH_DEPS=0 LATE_TMP="" BAK_HINTS="" +UNI_PLAN="" # Detection state GOOS="" @@ -72,6 +86,20 @@ TARGET_DIR="" TARGET_NOTE="" TARGET_ON_PATH="no" +# Package managers / podman / late-podman detection state +PM_BREW="no" +PM_APT="no" +PM_DNF="no" +PM_PACMAN="no" +PM_ZYPPER="no" +PM_APK="no" +PM_NPM="no" +PM_LINE="none found" +PODMAN_PRESENT="no" +PODMAN_PATH="" +PODMAN_VERSION="" +LATE_PODMAN_REPORT="not installed" + err() { echo "Error: $*" >&2; } warn() { echo "⚠️ $*" >&2; } info() { echo "=> $*"; } @@ -114,6 +142,7 @@ Sources: official run upstream's installer (latest stable release); upstream owns placement — --target is not applicable check print the detection report and exit (read-only) + uninstall remove what this installer manages (see UNINSTALL below) help show this help Flags (any position): @@ -125,6 +154,14 @@ Flags (any position): 3 fork-main, 4 upstream-main, 5 official, 6 check); implies --yes, never shows the menu or prompts. 'official' may still prompt — the upstream script owns it. + --purge (uninstall only) also delete late user data: the config dir + (config.json, mcp_config.json, plugins/ and skills/) and the + data dir (~/.local/share/late). Every path is printed first. + Interactive: asks y/N (default N); headless: requires --yes. + --with-deps (uninstall only) print the exact package-manager removal + commands for late-relevant dependencies (podman, go). + Commands are PRINTED, never executed — they may be shared + with other projects. --help show this help With no SOURCE and a TTY on stdin an interactive menu is shown; without a @@ -135,6 +172,34 @@ Running as root is refused (set LATE_INSTALL_ALLOW_ROOT=1 to override). Every install archives a displaced binary as .bak- (never overwritten) and prints a revert hint. + +UNINSTALL + ./install-dev.sh uninstall [--purge] [--with-deps] [--yes] [--dry-run] [--target DIR] + + Removes what this installer manages: + - the installed \`late\` in the target dir (symlink or regular file); + brew-managed files are NEVER deleted — 'brew uninstall late' is printed + as advice instead + - \`late-podman\` in the target dir / ~/.local/bin, but only our own copies + (a symlink into this repo, or a copy matching this repo's launcher); + brew-managed files are never deleted + - all late.bak- / late-podman.bak- archives + - a symlink into a DIFFERENT repo is removed, that repo itself is kept + + --purge additionally deletes user data (each path is printed first): + - the config dir — on macOS: ~/Library/Application Support/late + (on Linux: ~/.config/late, honoring XDG_CONFIG_HOME); contains + config.json, mcp_config.json, plugins/ and skills/ + - the data dir — ~/.local/share/late (session history) + Interactive runs confirm with y/N (default N); headless runs require --yes. + + --with-deps prints package-manager removal commands for podman and go + (e.g. 'brew uninstall podman', 'sudo apt remove podman'). It NEVER runs + them. + + Idempotent: uninstalling an already-uninstalled system prints + "nothing to uninstall" and exits 0. 'uninstall' is name-invoked only — + it is NOT part of the --choice menu (destructive). EOF } @@ -187,6 +252,52 @@ binary_sha() { fi } +# --- package managers / podman detection ------------------------------------ + +# detect_package_managers — report EVERY package manager found (brew, +# apt-get, dnf, pacman, zypper, apk, npm) as a comma list in PM_LINE. +detect_package_managers() { + PM_BREW="no" + PM_APT="no" + PM_DNF="no" + PM_PACMAN="no" + PM_ZYPPER="no" + PM_APK="no" + PM_NPM="no" + if command -v brew >/dev/null 2>&1; then PM_BREW="yes"; fi + if command -v apt-get >/dev/null 2>&1; then PM_APT="yes"; fi + if command -v dnf >/dev/null 2>&1; then PM_DNF="yes"; fi + if command -v pacman >/dev/null 2>&1; then PM_PACMAN="yes"; fi + if command -v zypper >/dev/null 2>&1; then PM_ZYPPER="yes"; fi + if command -v apk >/dev/null 2>&1; then PM_APK="yes"; fi + if command -v npm >/dev/null 2>&1; then PM_NPM="yes"; fi + local out="" + if [ "$PM_BREW" = "yes" ]; then out="${out:+${out}, }brew"; fi + if [ "$PM_APT" = "yes" ]; then out="${out:+${out}, }apt-get"; fi + if [ "$PM_DNF" = "yes" ]; then out="${out:+${out}, }dnf"; fi + if [ "$PM_PACMAN" = "yes" ]; then out="${out:+${out}, }pacman"; fi + if [ "$PM_ZYPPER" = "yes" ]; then out="${out:+${out}, }zypper"; fi + if [ "$PM_APK" = "yes" ]; then out="${out:+${out}, }apk"; fi + if [ "$PM_NPM" = "yes" ]; then out="${out:+${out}, }npm"; fi + if [ -n "$out" ]; then + PM_LINE="$out" + else + PM_LINE="none found" + fi +} + +# detect_podman — binary present? version? +detect_podman() { + PODMAN_PRESENT="no" + PODMAN_PATH="" + PODMAN_VERSION="" + PODMAN_PATH="$(command -v podman 2>/dev/null || true)" + if [ -n "$PODMAN_PATH" ]; then + PODMAN_PRESENT="yes" + PODMAN_VERSION="$(podman --version 2>/dev/null | head -n 1 || true)" + fi +} + # --- detection report ------------------------------------------------------ # pick_target_dir — choose TARGET_DIR (report only; never mutates). @@ -272,6 +383,9 @@ run_detection() { fi fi + detect_package_managers + detect_podman + CUR_PATH="$(command -v late 2>/dev/null || true)" CUR_CLASS="not installed" CUR_VERSION="" @@ -300,6 +414,7 @@ run_detection() { fi pick_target_dir + detect_late_podman case ":$PATH:" in *":$TARGET_DIR:"*) TARGET_ON_PATH="yes" ;; *) TARGET_ON_PATH="no" ;; @@ -328,6 +443,13 @@ run_detection() { else echo "Brew: not installed" fi + printf "Package managers: %s\n" "$PM_LINE" + if [ "$PODMAN_PRESENT" = "yes" ]; then + printf "Podman: %s (%s)\n" "${PODMAN_VERSION:-present}" "$PODMAN_PATH" + else + echo "Podman: not installed" + fi + printf "late-podman: %s\n" "$LATE_PODMAN_REPORT" if [ -n "$TARGET_NOTE" ]; then printf "Target dir: %s %s\n" "$TARGET_DIR" "$TARGET_NOTE" else @@ -424,6 +546,48 @@ install_copy() { info "Installed copy ${dest}" } +# install_podman_helper — install the `late-podman` launcher into the target +# dir with the same parity rules as `late`: symlink for local-dev, copy for +# the pinned/tarball sources (mirrors `make install`, which ships both). +# Same .bak- archiving on mode transitions. A missing source +# (e.g. an old tarball without the launcher) is skipped with a warning. +install_podman_helper() { + local mode="$1" src="$2" dest="$TARGET_DIR/late-podman" + if [ ! -f "$src" ]; then + warn "no late-podman launcher at ${src} — skipping the late-podman install" + return 0 + fi + if [ -d "$dest" ] && [ ! -L "$dest" ]; then + die "refusing to replace directory ${dest}" + fi + if [ "$mode" = "symlink" ]; then + if [ -e "$dest" ] && [ ! -L "$dest" ]; then + archive_displaced "$dest" + fi + ln -sfn "$src" "$dest" + info "Installed symlink ${dest} -> ${src}" + else + if [ -L "$dest" ]; then + archive_displaced "$dest" + fi + cp -f "$src" "$dest" + chmod 0755 "$dest" + info "Installed copy ${dest}" + fi +} + +# podman_runtime_note — late-podman's runtime requirements, printed with +# every late-podman install. +podman_runtime_note() { + info "Note: late-podman runs Late in a rootless Podman container — it requires a Linux host and podman." + if [ "$GOOS" != "linux" ]; then + warn "This host is $(uname -s): late-podman refuses to run here; it is meant for Linux hosts (or Linux containers)." + fi + if [ "$PODMAN_PRESENT" != "yes" ]; then + warn "podman was not detected in PATH — late-podman needs it on the Linux host it runs on." + fi +} + # target_state_desc — human description of what TARGET_DIR/late currently is. target_state_desc() { local dest="$TARGET_DIR/late" real @@ -493,6 +657,16 @@ plan_brew_line() { fi } +plan_podman_line() { + local mode="$1" dest="$TARGET_DIR/late-podman" + if [ "$mode" = "symlink" ]; then + echo " - would also install late-podman as a SYMLINK -> ${REPO}/late-podman at ${dest}" + else + echo " - would also install late-podman as a COPY at ${dest}" + fi + echo " - late-podman's runtime requires a Linux host + podman (it refuses to run on $(uname -s))" +} + # guard_brew — warn and gate installs when late is brew-managed. guard_brew() { local answer @@ -552,6 +726,13 @@ post_verify() { echo "version: ${v}" fi fi + if [ -L "$TARGET_DIR/late-podman" ]; then + echo "late-podman: ${TARGET_DIR}/late-podman -> $(readlink "$TARGET_DIR/late-podman")" + elif [ -e "$TARGET_DIR/late-podman" ]; then + echo "late-podman: copy at ${TARGET_DIR}/late-podman" + else + warn "late-podman was not installed (launcher missing in the source?)" + fi resolved="$(command -v late 2>/dev/null || true)" if [ -z "$resolved" ]; then warn "'late' is not on your PATH — add this to your ~/.zshrc or ~/.bashrc:" @@ -576,11 +757,14 @@ opt_local_dev() { plan_target_line echo " - would replace it with a SYMLINK -> ${DEV_LINK}" plan_backup_line "symlink" + plan_podman_line "symlink" plan_brew_line return 0 fi guard_brew install_symlink + install_podman_helper "symlink" "$REPO/late-podman" + podman_runtime_note post_verify "symlink" } @@ -594,12 +778,15 @@ opt_pinned() { plan_target_line echo " - would replace it with a COPY of ${DEV_LINK}" plan_backup_line "copy" + plan_podman_line "copy" plan_brew_line return 0 fi guard_brew install_copy "$DEV_LINK" \ "Dev tracking stops: rebuilds of this repo no longer update the command; re-run the installer to update." + install_podman_helper "copy" "$REPO/late-podman" + podman_runtime_note post_verify "copy" } @@ -615,6 +802,7 @@ opt_tarball() { plan_target_line echo " - would install the built binary as a COPY to ${TARGET_DIR}/late" plan_backup_line "copy" + plan_podman_line "copy" plan_brew_line return 0 fi @@ -650,16 +838,21 @@ opt_tarball() { if [ -f go.mod ] && [ ! -f go.sum ]; then export GOFLAGS="-mod=mod" fi - if [ "$GOOS" != "unknown" ] && [ "$GOARCH" != "unknown" ]; then - export GOOS="$GOOS" GOARCH="$GOARCH" - fi + # Pass the platform to go via env-prefix (never mutating the parent + # script's GOOS/GOARCH, which stay the detected host values). # -trimpath strips the random mktemp dir from the binary so two builds # of the same commit are byte-identical (idempotent reinstalls). - go build -trimpath -o "${LATE_TMP}/late" ./cmd/late + if [ "$GOOS" != "unknown" ] && [ "$GOARCH" != "unknown" ]; then + env GOOS="$GOOS" GOARCH="$GOARCH" go build -trimpath -o "${LATE_TMP}/late" ./cmd/late + else + go build -trimpath -o "${LATE_TMP}/late" ./cmd/late + fi ); then die "build of ${slug}@main failed — refusing to install" fi install_copy "${LATE_TMP}/late" "$note" + install_podman_helper "copy" "${root}/late-podman" + podman_runtime_note post_verify "copy" } @@ -683,6 +876,418 @@ opt_official() { post_verify "official" } +# --- uninstall --------------------------------------------------------------- +# Name-invoked only (never in the --choice menu — destructive). Removes what +# THIS installer manages: the target-dir `late`, our own `late-podman` copies +# (target dir / ~/.local/bin), and all .bak- archives. Never +# deletes anything inside the brew prefix (prints 'brew uninstall late' as +# advice instead) and never touches package-manager-installed dependencies. + +# late_config_dir — the exact dir the Go code uses (pathutil.LateConfigDir = +# os.UserConfigDir()/late). Go ignores XDG_CONFIG_HOME on darwin and honors +# it on Linux — replicate that faithfully here. +late_config_dir() { + case "$GOOS" in + darwin) printf '%s\n' "${HOME}/Library/Application Support/late" ;; + *) printf '%s\n' "${XDG_CONFIG_HOME:-${HOME}/.config}/late" ;; + esac +} + +# late_data_dir — parent of pathutil.LateSessionDir (~/.local/share/late). +late_data_dir() { + printf '%s\n' "${HOME}/.local/share/late" +} + +# uni_purge_guard — fool-proofing for rm -rf on user-data dirs: the basename +# must be exactly "late" and the path must never be $HOME or /. +uni_purge_guard() { + local base + case "$1" in + ""|"/"|"$HOME"|"$HOME"/) die "refusing suspicious purge path: ${1}" ;; + esac + base="$(basename "$1")" + case "$base" in + late) return 0 ;; + *) die "refusing suspicious purge path (unexpected basename): ${1}" ;; + esac +} + +# uni_confirm DESC — y/N confirm (default N) for destructive uninstall steps. +# --yes short-circuits to yes; a non-TTY stdin yields "no" (the caller turns +# that into a clear refusal instead of deleting anything silently). +uni_confirm() { + local answer + if [ "$ASSUME_YES" -eq 1 ]; then + return 0 + fi + if [ -t 0 ]; then + printf "Proceed with %s? [y/N] " "$1" + IFS= read -r answer || answer="" + case "$answer" in + y|Y|yes|Yes|YES) return 0 ;; + *) return 1 ;; + esac + fi + return 1 +} + +# uni_late_kind — classify TARGET_DIR/late (same taxonomy as the detection +# report). Prints one of: absent | directory | regular-brew | regular | +# symlink-this-repo | symlink-other | symlink-broken. Sets UNI_LATE_REAL. +UNI_LATE_REAL="" +uni_late_kind() { + UNI_LATE_REAL="" + local dest="$TARGET_DIR/late" real + if [ -L "$dest" ]; then + real="$(resolve_path "$dest" 2>/dev/null || true)" + UNI_LATE_REAL="$real" + if [ -z "$real" ]; then + printf '%s\n' "symlink-broken" + return 0 + fi + case "$real" in + "$REPO"|"$REPO"/*) printf '%s\n' "symlink-this-repo" ;; + *) printf '%s\n' "symlink-other" ;; + esac + return 0 + fi + if [ -d "$dest" ]; then printf '%s\n' "directory"; return 0; fi + if [ -e "$dest" ]; then + if [ -n "$BREW_PREFIX" ]; then + case "$dest" in + "${BREW_PREFIX}"/*) printf '%s\n' "regular-brew"; return 0 ;; + esac + fi + printf '%s\n' "regular" + return 0 + fi + printf '%s\n' "absent" +} + +# uni_podman_kind PATH — classify a late-podman candidate. Prints one of: +# absent | directory | brew | ours-symlink | foreign-symlink | ours-copy | +# foreign. "Ours" = a symlink into this repo, or a regular file whose bytes +# match this repo's launcher (or an older launcher revision, recognized by +# its usage header). +uni_podman_kind() { + local p="$1" real a b + if [ -L "$p" ]; then + real="$(resolve_path "$p" 2>/dev/null || true)" + case "$real" in + "$REPO"/late-podman) printf '%s\n' "ours-symlink" ;; + *) printf '%s\n' "foreign-symlink" ;; + esac + return 0 + fi + if [ -d "$p" ]; then printf '%s\n' "directory"; return 0; fi + if [ -e "$p" ]; then + if [ -n "$BREW_PREFIX" ]; then + case "$p" in + "${BREW_PREFIX}"/*) printf '%s\n' "brew"; return 0 ;; + esac + fi + a="$(binary_sha "$p" 2>/dev/null || true)" + b="$(binary_sha "$REPO/late-podman" 2>/dev/null || true)" + if [ -n "$a" ] && [ -n "$b" ] && [ "$a" = "$b" ]; then + printf '%s\n' "ours-copy" + return 0 + fi + if grep -q 'Usage: late-podman' "$p" 2>/dev/null; then + printf '%s\n' "ours-copy" + return 0 + fi + printf '%s\n' "foreign" + return 0 + fi + printf '%s\n' "absent" +} + +# describe_late_podman PATH — one-line description for the detection report. +describe_late_podman() { + local p="$1" kind real + kind="$(uni_podman_kind "$p")" + case "$kind" in + ours-symlink) + real="$(resolve_path "$p" 2>/dev/null || true)" + printf '%s\n' "${p} (symlink -> ${real:-?}, our symlink)" + ;; + foreign-symlink) + real="$(resolve_path "$p" 2>/dev/null || true)" + printf '%s\n' "${p} (symlink -> ${real:-?})" + ;; + directory) + printf '%s\n' "${p} (a directory — unexpected)" + ;; + brew) + printf '%s\n' "${p} (regular file, brew-managed)" + ;; + ours-copy) + printf '%s\n' "${p} (regular file, our copy)" + ;; + foreign) + printf '%s\n' "${p} (regular file)" + ;; + esac +} + +# detect_late_podman — is late-podman installed? Check the target dir and +# ~/.local/bin (used by the detection report). +detect_late_podman() { + LATE_PODMAN_REPORT="not installed" + local report="" p desc + local p1="$TARGET_DIR/late-podman" p2="$HOME/.local/bin/late-podman" + local paths=("$p1") + if [ "$p2" != "$p1" ]; then paths+=("$p2"); fi + for p in "${paths[@]}"; do + if [ ! -L "$p" ] && [ ! -e "$p" ]; then continue; fi + desc="$(describe_late_podman "$p")" + if [ -z "$report" ]; then + report="$desc" + else + report="${report}"$'\n'" ${desc}" + fi + done + if [ -n "$report" ]; then + LATE_PODMAN_REPORT="$report" + fi +} + +# pm_owns PM PKG — exit 0 when the package manager owns the package. +# (Read-only queries only; nothing is ever removed here.) +pm_owns() { + local pm="$1" pkg="$2" + case "$pm" in + brew) + if brew list --formula 2>/dev/null | grep -qx "$pkg"; then return 0; fi + ;; + apt-get) + if command -v dpkg-query >/dev/null 2>&1; then + if dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "install ok installed"; then return 0; fi + fi + ;; + dnf|zypper) + if command -v rpm >/dev/null 2>&1; then + if rpm -q "$pkg" >/dev/null 2>&1; then return 0; fi + fi + ;; + pacman) + if pacman -Q "$pkg" >/dev/null 2>&1; then return 0; fi + ;; + apk) + if apk info -e "$pkg" >/dev/null 2>&1; then return 0; fi + ;; + esac + return 1 +} + +# uni_dep_hint NAME — print the exact removal command per detected package +# manager that owns NAME (npm is skipped: neither podman nor go are npm +# packages). Prints a fallback note when no detected PM owns it. +uni_dep_hint() { + local name="$1" found=0 pm pkg + for pm in brew apt-get dnf pacman zypper apk; do + case "$pm" in + brew) if [ "$PM_BREW" != "yes" ]; then continue; fi ;; + apt-get) if [ "$PM_APT" != "yes" ]; then continue; fi ;; + dnf) if [ "$PM_DNF" != "yes" ]; then continue; fi ;; + pacman) if [ "$PM_PACMAN" != "yes" ]; then continue; fi ;; + zypper) if [ "$PM_ZYPPER" != "yes" ]; then continue; fi ;; + apk) if [ "$PM_APK" != "yes" ]; then continue; fi ;; + esac + case "$pm" in + brew) pkg="$name" ;; + apt-get) case "$name" in go) pkg="golang-go" ;; *) pkg="$name" ;; esac ;; + dnf) case "$name" in go) pkg="golang" ;; *) pkg="$name" ;; esac ;; + *) pkg="$name" ;; + esac + if pm_owns "$pm" "$pkg"; then + case "$pm" in + brew) echo " brew-managed: brew uninstall ${name}" ;; + apt-get) echo " apt-get-managed: sudo apt remove ${pkg}" ;; + dnf) echo " dnf-managed: sudo dnf remove ${pkg}" ;; + pacman) echo " pacman-managed: sudo pacman -Rns ${pkg}" ;; + zypper) echo " zypper-managed: sudo zypper remove ${pkg}" ;; + apk) echo " apk-managed: sudo apk del ${pkg}" ;; + esac + found=1 + fi + done + if [ "$found" -eq 0 ]; then + echo " managed outside the detected package managers — remove manually if desired" + fi +} + +# uni_dep_hints — --with-deps: PRINT (never execute) removal commands for +# late-relevant dependencies (podman, go). They may be shared with other +# projects, so the script never runs them. +uni_dep_hints() { + local go_path + echo "" + echo "== dependencies (NOT removed — commands printed only; they may be shared with other projects) ==" + if [ "$PODMAN_PRESENT" = "yes" ]; then + echo " podman: ${PODMAN_PATH} (${PODMAN_VERSION:-version unknown})" + uni_dep_hint "podman" + else + echo " podman: not installed — nothing to remove" + fi + go_path="$(command -v go 2>/dev/null || true)" + if [ -n "$go_path" ]; then + echo " go: ${go_path}" + uni_dep_hint "go" + else + echo " go: not installed — nothing to remove" + fi + echo "== end of dependency report ==" +} + +# uni_print_plan VERB — print the tab-separated plan entries. +uni_print_plan() { + local verb="$1" kind path detail + while IFS=$'\t' read -r kind path detail; do + if [ -z "$kind" ]; then continue; fi + case "$kind" in + rm) echo " - ${verb} remove ${path} (${detail})" ;; + rmdir) echo " - ${verb} delete ${path} (${detail})" ;; + brew) echo " - KEEP ${path} — ${detail}" ;; + note) echo " - ${path}: ${detail}" ;; + esac + done <