From f2a094d01327b48feedb88857c7336d6a43d3974 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Sat, 3 Oct 2026 19:29:17 -0700 Subject: [PATCH 1/3] Set up picker shortcuts during Homebrew installation --- .goreleaser.yml | 20 +++ docs/image-picker-shell-setup.md | 10 ++ docs/releasing.md | 10 ++ scripts/homebrew_picker_test.go | 231 ++++++++++++++++++++++++++++++ scripts/release_artifacts_test.go | 4 + 5 files changed, 275 insertions(+) create mode 100644 scripts/homebrew_picker_test.go diff --git a/.goreleaser.yml b/.goreleaser.yml index 8de009dc7..1e169f173 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -112,5 +112,25 @@ homebrew_casks: bash: "completions/openai.bash" zsh: "completions/openai.zsh" fish: "completions/openai.fish" + hooks: + post: + install: | + # Homebrew saves these before filtering the caller's environment. + # Pass literal argv values: system_command's env: escapes spaces. + setup_env = %w[ZDOTDIR XDG_CONFIG_HOME SUDO_USER].map do |name| + value = ENV["HOMEBREW_#{name}"] || ENV[name] + "#{name}=#{value}" if value + end.compact + begin + system_command "/usr/bin/env", + args: setup_env + ["#{staged_path}/openai", "@completion", "--install-picker", "--automatic"], + sudo: false, + must_succeed: false, + timeout: 10, + print_stdout: true, + print_stderr: true + rescue Timeout::Error + warn "Tab shortcut setup timed out; installation will continue." + end manpages: - man/man1/openai.1.gz diff --git a/docs/image-picker-shell-setup.md b/docs/image-picker-shell-setup.md index 085368a30..9ead90b50 100644 --- a/docs/image-picker-shell-setup.md +++ b/docs/image-picker-shell-setup.md @@ -24,6 +24,16 @@ cleans up its uncommitted files when I/O returns. If the process exits first, recovery or staging files can remain, as with an interrupted explicit installation. At most one setup worker runs in a CLI process. +Homebrew also attempts setup during cask installation and upgrades, so eligible +users can load the shortcut in a new terminal before their first interactive CLI +run. The hook uses the newly staged executable as the installing user and keeps +any earlier opt-out. It restores Homebrew's saved `ZDOTDIR` and `XDG_CONFIG_HOME` +for the setup command; zsh still requires an exported absolute `ZDOTDIR`. +The hook allows ten seconds for setup; Homebrew may take additional time to +terminate a timed-out child. Diagnostics remain visible, but an unsuccessful or +timed-out setup does not fail the cask installation. Uninstall does not edit +profiles or change shell preferences; remove the shortcut with the command below before uninstalling if desired. + To configure or remove persistent shortcuts explicitly: ```sh diff --git a/docs/releasing.md b/docs/releasing.md index 75f9581ff..028561376 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -11,6 +11,16 @@ The reviewed checksums cover Linux and macOS hosts on x86_64 and arm64. The host architecture does not limit the Linux, macOS, and Windows release targets configured in `.goreleaser.yml`. +The Homebrew cask's post-install hook runs the staged binary's existing +`@completion --install-picker --automatic` action during install and upgrade. +It restores Homebrew's saved shell configuration locations and sudo marker only +for that child, uses no sudo, and tolerates setup failure or its ten-second +timeout. User interruption still propagates. There is no uninstall +hook, so upgrades do not remove setup or record an opt-out. The shared setup +routine owns profile changes and consent; see [shell setup](image-picker-shell-setup.md) +for eligibility, activation and removal. This hook runs on the user's machine, +not while GoReleaser generates the cask or publishes release artifacts. + Linux packages install one fish startup file at `/usr/share/fish/vendor_conf.d/openai-picker.fish`. It activates at the first prompt after user configuration, respects opt-out and custom bindings, and only diff --git a/scripts/homebrew_picker_test.go b/scripts/homebrew_picker_test.go new file mode 100644 index 000000000..ce0f47b8f --- /dev/null +++ b/scripts/homebrew_picker_test.go @@ -0,0 +1,231 @@ +//go:build !windows + +package scripts_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +func TestHomebrewPickerInstallHook(t *testing.T) { + config := readReleaseYAML[goReleaserConfig](t, filepath.Join("..", ".goreleaser.yml")) + if len(config.HomebrewCasks) != 1 { + t.Fatal("expected one Homebrew cask") + } + hooks := config.HomebrewCasks[0].Hooks + if len(hooks.Pre) != 0 || len(hooks.Post) != 1 || hooks.Post["install"] == "" { + t.Fatal("picker setup must only run after installation; uninstall hooks also run during upgrades") + } + ruby, err := exec.LookPath("ruby") + if err != nil { + t.Skip("Ruby is required to execute the Homebrew hook fixture") + } + + for _, environment := range []string{"filtered", "saved_precedence", "unfiltered", "unset"} { + for _, setupExit := range []int{0, 17} { + t.Run(fmt.Sprintf("%s/setup_exit_%d", environment, setupExit), func(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "user home") + if err := os.Mkdir(home, 0o700); err != nil { + t.Fatal(err) + } + profile := filepath.Join(home, ".zshrc") + profileContents := []byte("# existing profile remains owned by the CLI setup routine\n") + if err := os.WriteFile(profile, profileContents, 0o600); err != nil { + t.Fatal(err) + } + hookPath := filepath.Join(root, "hook.rb") + if err := os.WriteFile(hookPath, []byte(hooks.Post["install"]), 0o600); err != nil { + t.Fatal(err) + } + logPath := filepath.Join(root, "calls.jsonl") + config := filepath.Join(home, "custom config $(literal)") + zdotdir := filepath.Join(home, "zsh's startup") + optOut := filepath.Join(home, ".openai", "shell", "image-picker.tab-off-zsh") + if err := os.MkdirAll(filepath.Dir(optOut), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(optOut, nil, 0o600); err != nil { + t.Fatal(err) + } + otherBin := filepath.Join(root, "other bin") + if err := os.Mkdir(otherBin, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(otherBin, "openai"), []byte("#!/bin/sh\nexit 91\n"), 0o700); err != nil { + t.Fatal(err) + } + versions := []string{"1.0", "1.1", "1.1", "1.0"} // Install, upgrade, repeat, rollback. + for _, version := range versions { + staged := filepath.Join(root, "Caskroom user's $(literal) files", version) + if err := os.MkdirAll(staged, 0o700); err != nil { + t.Fatal(err) + } + fixture := "#!" + ruby + "\n" + homebrewPickerFixture + if err := os.WriteFile(filepath.Join(staged, "openai"), []byte(fixture), 0o700); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + command := exec.CommandContext(ctx, ruby, "-e", homebrewPickerHookHarness, hookPath, staged) + command.Env = []string{ + "PATH=" + otherBin + ":/usr/bin:/bin", + "HOME=" + home, + "SHELL=/bin/zsh", + "PICKER_TEST_LOG=" + logPath, + fmt.Sprintf("PICKER_TEST_EXIT=%d", setupExit), + } + switch environment { + case "filtered", "saved_precedence": + command.Env = append(command.Env, "HOMEBREW_ZDOTDIR="+zdotdir, "HOMEBREW_XDG_CONFIG_HOME="+config, + "HOMEBREW_SUDO_USER=fixture-user") + if environment == "saved_precedence" { + command.Env = append(command.Env, "ZDOTDIR=wrong", "XDG_CONFIG_HOME=wrong", "SUDO_USER=wrong") + } + case "unfiltered": + command.Env = append(command.Env, "ZDOTDIR="+zdotdir, "XDG_CONFIG_HOME="+config, "SUDO_USER=fixture-user") + } + var stdout, stderr bytes.Buffer + command.Stdout, command.Stderr = &stdout, &stderr + err := command.Run() + cancel() + if err != nil { + t.Fatalf("hook must not abort installation when setup exits %d: %v; %s", setupExit, err, stderr.String()) + } + if stdout.String() != "setup output\n" || stderr.String() != "setup diagnostic\n" { + t.Fatalf("hook output: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + calls := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(calls) != len(versions) { + t.Fatalf("setup calls = %d, want one for each installation", len(calls)) + } + for index, line := range calls { + var call struct { + Args []string `json:"args"` + Executable string `json:"executable"` + Home string `json:"home"` + Shell string `json:"shell"` + UID int `json:"uid"` + Zdotdir string `json:"zdotdir"` + Config string `json:"config"` + SudoUser string `json:"sudo_user"` + Stdin string `json:"stdin"` + } + if err := json.Unmarshal([]byte(line), &call); err != nil { + t.Fatal(err) + } + if !slices.Equal(call.Args, []string{"@completion", "--install-picker", "--automatic"}) { + t.Errorf("setup arguments = %q", call.Args) + } + wantExecutable := filepath.Join(root, "Caskroom user's $(literal) files", versions[index], "openai") + if environment == "unset" { + if call.Zdotdir != "" || call.Config != "" || call.SudoUser != "" { + t.Errorf("hook invented unset configuration: %+v", call) + } + } else if call.Zdotdir != zdotdir || call.Config != config || call.SudoUser != "fixture-user" { + t.Errorf("hook lost shell configuration or sudo marker: %+v", call) + } + if call.Executable != wantExecutable || call.Home != home || call.Shell != "/bin/zsh" || call.UID != os.Geteuid() || call.Stdin != "" { + t.Errorf("hook changed setup executable, user, environment or stdin: %+v", call) + } + } + marker, err := os.ReadFile(optOut) + if err != nil || len(marker) != 0 { + t.Fatalf("hook changed existing opt-out: %q, %v", marker, err) + } + contents, err := os.ReadFile(profile) + if err != nil || string(contents) != string(profileContents) { + t.Fatalf("packaging hook edited the profile outside the shared setup routine: %q, %v", contents, err) + } + }) + } + } +} + +// Homebrew raises on timeout even with must_succeed: false. Only that optional +// failure is swallowed; cancellation and unexpected hook errors still abort. +func TestHomebrewPickerHookExceptions(t *testing.T) { + config := readReleaseYAML[goReleaserConfig](t, filepath.Join("..", ".goreleaser.yml")) + if len(config.HomebrewCasks) != 1 { + t.Fatal("expected one Homebrew cask") + } + ruby, err := exec.LookPath("ruby") + if err != nil { + t.Skip("Ruby is required to execute the Homebrew hook fixture") + } + for _, failure := range []string{"Timeout::Error", "Interrupt", "RuntimeError"} { + t.Run(failure, func(t *testing.T) { + home := t.TempDir() + hook := filepath.Join(home, "hook.rb") + if err := os.WriteFile(hook, []byte(config.HomebrewCasks[0].Hooks.Post["install"]), 0o600); err != nil { + t.Fatal(err) + } + command := exec.CommandContext(t.Context(), ruby, "-e", ` +require "timeout" +def staged_path; "/synthetic/cask"; end +def system_command(*args, **options) + raise "optional setup must have a bounded wait" unless options[:timeout] && options[:timeout] > 0 && options[:timeout] <= 10 + raise Object.const_get(ARGV.fetch(1)) +end +eval(File.read(ARGV.fetch(0)), binding, ARGV.fetch(0)) +puts "installation continued" +`, hook, failure) + command.Env = []string{"HOME=" + home, "PATH=/usr/bin:/bin"} + var stdout, stderr bytes.Buffer + command.Stdout, command.Stderr = &stdout, &stderr + err := command.Run() + if failure == "Timeout::Error" { + if err != nil || stdout.String() != "installation continued\n" || stderr.String() != "Tab shortcut setup timed out; installation will continue.\n" { + t.Fatalf("timeout did not remain optional: %v stdout=%q stderr=%q", err, stdout.String(), stderr.String()) + } + } else if err == nil || stdout.Len() != 0 || !strings.Contains(stderr.String(), failure) { + t.Fatalf("hook swallowed %s: %v stdout=%q stderr=%q", failure, err, stdout.String(), stderr.String()) + } + }) + } +} + +// Evaluate the real cask hook using the documented system_command call shape. +// This is a command-contract fixture, not a real Homebrew install or CLI setup. +// Profiles and consent are only inspected here; setup behavior has separate tests. +const homebrewPickerHookHarness = ` +require "open3" +def staged_path + ARGV.fetch(1) +end +def system_command(executable, args: [], env: {}, sudo: false, must_succeed: true, + print_stdout: false, print_stderr: true, timeout: nil) + raise "picker setup must not use sudo" if sudo + raise "optional setup must have a bounded wait" unless timeout && timeout > 0 && timeout <= 10 + out, err, status = Open3.capture3(env, executable, *args, stdin_data: "") + $stdout.write(out) if print_stdout + $stderr.write(err) if print_stderr + raise "setup failed" if must_succeed && !status.success? +end +eval(File.read(ARGV.fetch(0)), binding, ARGV.fetch(0)) +` + +const homebrewPickerFixture = ` +require "json" +File.open(ENV.fetch("PICKER_TEST_LOG"), "a") do |file| + file.puts JSON.generate(args: ARGV, executable: $0, home: ENV["HOME"], + shell: ENV["SHELL"], uid: Process.euid, stdin: STDIN.read, + zdotdir: ENV["ZDOTDIR"], config: ENV["XDG_CONFIG_HOME"], sudo_user: ENV["SUDO_USER"]) +end +puts "setup output" +warn "setup diagnostic" +exit Integer(ENV.fetch("PICKER_TEST_EXIT")) +` diff --git a/scripts/release_artifacts_test.go b/scripts/release_artifacts_test.go index 266a0274e..007bed0ec 100644 --- a/scripts/release_artifacts_test.go +++ b/scripts/release_artifacts_test.go @@ -604,5 +604,9 @@ type goReleaserConfig struct { } `yaml:"repository"` Completions map[string]string `yaml:"completions"` Manpages []string `yaml:"manpages"` + Hooks struct { + Pre map[string]string `yaml:"pre"` + Post map[string]string `yaml:"post"` + } `yaml:"hooks"` } `yaml:"homebrew_casks"` } From 7597c4062c9a429999fb6e26442d2520c2a26f5e Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Sat, 3 Oct 2026 20:22:10 -0700 Subject: [PATCH 2/3] Skip Homebrew picker setup in CI --- .goreleaser.yml | 38 ++++++++++++++++------------ docs/image-picker-shell-setup.md | 4 +++ docs/releasing.md | 6 ++--- scripts/homebrew_picker_test.go | 43 ++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 19 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 1e169f173..20e584d04 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -115,22 +115,28 @@ homebrew_casks: hooks: post: install: | - # Homebrew saves these before filtering the caller's environment. - # Pass literal argv values: system_command's env: escapes spaces. - setup_env = %w[ZDOTDIR XDG_CONFIG_HOME SUDO_USER].map do |name| - value = ENV["HOMEBREW_#{name}"] || ENV[name] - "#{name}=#{value}" if value - end.compact - begin - system_command "/usr/bin/env", - args: setup_env + ["#{staged_path}/openai", "@completion", "--install-picker", "--automatic"], - sudo: false, - must_succeed: false, - timeout: 10, - print_stdout: true, - print_stderr: true - rescue Timeout::Error - warn "Tab shortcut setup timed out; installation will continue." + # Match first-use setup for CI markers available after Homebrew filtering. + ci = %w[CI GITHUB_ACTIONS GITLAB_CI TF_BUILD BUILDKITE JENKINS_URL TEAMCITY_VERSION].any? do |name| + !["", "false", "0"].include?(ENV[name].to_s.b.downcase) + end + unless ci + # Homebrew saves these before filtering the caller's environment. + # Pass literal argv values: system_command's env: escapes spaces. + setup_env = %w[ZDOTDIR XDG_CONFIG_HOME SUDO_USER].map do |name| + value = ENV["HOMEBREW_#{name}"] || ENV[name] + "#{name}=#{value}" if value + end.compact + begin + system_command "/usr/bin/env", + args: setup_env + ["#{staged_path}/openai", "@completion", "--install-picker", "--automatic"], + sudo: false, + must_succeed: false, + timeout: 10, + print_stdout: true, + print_stderr: true + rescue Timeout::Error + warn "Tab shortcut setup timed out; installation will continue." + end end manpages: - man/man1/openai.1.gz diff --git a/docs/image-picker-shell-setup.md b/docs/image-picker-shell-setup.md index 9ead90b50..1fab13c3d 100644 --- a/docs/image-picker-shell-setup.md +++ b/docs/image-picker-shell-setup.md @@ -29,6 +29,10 @@ users can load the shortcut in a new terminal before their first interactive CLI run. The hook uses the newly staged executable as the installing user and keeps any earlier opt-out. It restores Homebrew's saved `ZDOTDIR` and `XDG_CONFIG_HOME` for the setup command; zsh still requires an exported absolute `ZDOTDIR`. +The hook skips setup when a recognized CI marker is enabled in Homebrew's +environment. Set `CI=1` for automated installations; Homebrew preserves this +marker when filtering the environment. Empty, `false` (case-insensitive), and +`0` values do not enable a marker. The hook allows ten seconds for setup; Homebrew may take additional time to terminate a timed-out child. Diagnostics remain visible, but an unsuccessful or timed-out setup does not fail the cask installation. Uninstall does not edit diff --git a/docs/releasing.md b/docs/releasing.md index 028561376..30f7040a4 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -13,9 +13,9 @@ configured in `.goreleaser.yml`. The Homebrew cask's post-install hook runs the staged binary's existing `@completion --install-picker --automatic` action during install and upgrade. -It restores Homebrew's saved shell configuration locations and sudo marker only -for that child, uses no sudo, and tolerates setup failure or its ten-second -timeout. User interruption still propagates. There is no uninstall +It skips detected CI environments and restores Homebrew's saved shell +configuration locations and sudo marker only for that child, uses no sudo, +and tolerates setup failure or its ten-second timeout. User interruption still propagates. There is no uninstall hook, so upgrades do not remove setup or record an opt-out. The shared setup routine owns profile changes and consent; see [shell setup](image-picker-shell-setup.md) for eligibility, activation and removal. This hook runs on the user's machine, diff --git a/scripts/homebrew_picker_test.go b/scripts/homebrew_picker_test.go index ce0f47b8f..4190d01ac 100644 --- a/scripts/homebrew_picker_test.go +++ b/scripts/homebrew_picker_test.go @@ -155,6 +155,49 @@ func TestHomebrewPickerInstallHook(t *testing.T) { } } +// CI installation must not launch setup at all, even when shell targeting would +// otherwise be eligible. False values match first-use setup's existing contract. +func TestHomebrewPickerHookSkipsCI(t *testing.T) { + config := readReleaseYAML[goReleaserConfig](t, filepath.Join("..", ".goreleaser.yml")) + if len(config.HomebrewCasks) != 1 { + t.Fatal("expected one Homebrew cask") + } + ruby, err := exec.LookPath("ruby") + if err != nil { + t.Skip("Ruby is required to execute the Homebrew hook fixture") + } + home := t.TempDir() + hook := filepath.Join(home, "hook.rb") + if err := os.WriteFile(hook, []byte(config.HomebrewCasks[0].Hooks.Post["install"]), 0o600); err != nil { + t.Fatal(err) + } + for _, name := range []string{"CI", "GITHUB_ACTIONS", "GITLAB_CI", "TF_BUILD", "BUILDKITE", "JENKINS_URL", "TEAMCITY_VERSION"} { + for _, value := range []string{"true", "1", "https://ci.example.invalid", "FALSE", "false", "0", "", " false ", string([]byte{0xff})} { + t.Run(name+"="+value, func(t *testing.T) { + command := exec.CommandContext(t.Context(), ruby, "-e", ` +require "timeout" +def staged_path; "/synthetic/cask"; end +def system_command(*args, **options); puts "setup invoked"; end +eval(File.read(ARGV.fetch(0)), binding, ARGV.fetch(0)) +puts "installation continued" +`, hook) + command.Env = []string{"HOME=" + home, "PATH=/usr/bin:/bin", "SHELL=/bin/zsh", "HOMEBREW_ZDOTDIR=" + home, "CI=false", name + "=" + value} + // Setting another marker must still skip when CI itself is false. + var stdout, stderr bytes.Buffer + command.Stdout, command.Stderr = &stdout, &stderr + err := command.Run() + want := "installation continued\n" + if value == "" || value == "0" || strings.EqualFold(value, "false") { + want = "setup invoked\n" + want + } + if err != nil || stdout.String() != want || stderr.Len() != 0 { + t.Fatalf("CI hook dispatch: %v stdout=%q stderr=%q, want %q", err, stdout.String(), stderr.String(), want) + } + }) + } + } +} + // Homebrew raises on timeout even with must_succeed: false. Only that optional // failure is swallowed; cancellation and unexpected hook errors still abort. func TestHomebrewPickerHookExceptions(t *testing.T) { From d9af20344e10e09cd77d93a656cd0586059dee15 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Sat, 3 Oct 2026 23:27:59 -0700 Subject: [PATCH 3/3] Keep Homebrew picker setup on first run --- .goreleaser.yml | 26 --- docs/image-picker-shell-setup.md | 17 +- docs/releasing.md | 12 +- scripts/homebrew_picker_test.go | 274 ------------------------------ scripts/release_artifacts_test.go | 3 + 5 files changed, 10 insertions(+), 322 deletions(-) delete mode 100644 scripts/homebrew_picker_test.go diff --git a/.goreleaser.yml b/.goreleaser.yml index 20e584d04..8de009dc7 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -112,31 +112,5 @@ homebrew_casks: bash: "completions/openai.bash" zsh: "completions/openai.zsh" fish: "completions/openai.fish" - hooks: - post: - install: | - # Match first-use setup for CI markers available after Homebrew filtering. - ci = %w[CI GITHUB_ACTIONS GITLAB_CI TF_BUILD BUILDKITE JENKINS_URL TEAMCITY_VERSION].any? do |name| - !["", "false", "0"].include?(ENV[name].to_s.b.downcase) - end - unless ci - # Homebrew saves these before filtering the caller's environment. - # Pass literal argv values: system_command's env: escapes spaces. - setup_env = %w[ZDOTDIR XDG_CONFIG_HOME SUDO_USER].map do |name| - value = ENV["HOMEBREW_#{name}"] || ENV[name] - "#{name}=#{value}" if value - end.compact - begin - system_command "/usr/bin/env", - args: setup_env + ["#{staged_path}/openai", "@completion", "--install-picker", "--automatic"], - sudo: false, - must_succeed: false, - timeout: 10, - print_stdout: true, - print_stderr: true - rescue Timeout::Error - warn "Tab shortcut setup timed out; installation will continue." - end - end manpages: - man/man1/openai.1.gz diff --git a/docs/image-picker-shell-setup.md b/docs/image-picker-shell-setup.md index 1fab13c3d..37ef69495 100644 --- a/docs/image-picker-shell-setup.md +++ b/docs/image-picker-shell-setup.md @@ -24,19 +24,10 @@ cleans up its uncommitted files when I/O returns. If the process exits first, recovery or staging files can remain, as with an interrupted explicit installation. At most one setup worker runs in a CLI process. -Homebrew also attempts setup during cask installation and upgrades, so eligible -users can load the shortcut in a new terminal before their first interactive CLI -run. The hook uses the newly staged executable as the installing user and keeps -any earlier opt-out. It restores Homebrew's saved `ZDOTDIR` and `XDG_CONFIG_HOME` -for the setup command; zsh still requires an exported absolute `ZDOTDIR`. -The hook skips setup when a recognized CI marker is enabled in Homebrew's -environment. Set `CI=1` for automated installations; Homebrew preserves this -marker when filtering the environment. Empty, `false` (case-insensitive), and -`0` values do not enable a marker. -The hook allows ten seconds for setup; Homebrew may take additional time to -terminate a timed-out child. Diagnostics remain visible, but an unsuccessful or -timed-out setup does not fail the cask installation. Uninstall does not edit -profiles or change shell preferences; remove the shortcut with the command below before uninstalling if desired. +Homebrew installations use the same first-run setup described above. Installing, +upgrading, or uninstalling the cask does not edit shell profiles or change picker +preferences. After an eligible interactive `openai` run, open a new terminal to +load the shortcut. To configure or remove persistent shortcuts explicitly: diff --git a/docs/releasing.md b/docs/releasing.md index 30f7040a4..57656ab84 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -11,15 +11,9 @@ The reviewed checksums cover Linux and macOS hosts on x86_64 and arm64. The host architecture does not limit the Linux, macOS, and Windows release targets configured in `.goreleaser.yml`. -The Homebrew cask's post-install hook runs the staged binary's existing -`@completion --install-picker --automatic` action during install and upgrade. -It skips detected CI environments and restores Homebrew's saved shell -configuration locations and sudo marker only for that child, uses no sudo, -and tolerates setup failure or its ten-second timeout. User interruption still propagates. There is no uninstall -hook, so upgrades do not remove setup or record an opt-out. The shared setup -routine owns profile changes and consent; see [shell setup](image-picker-shell-setup.md) -for eligibility, activation and removal. This hook runs on the user's machine, -not while GoReleaser generates the cask or publishes release artifacts. +The Homebrew cask installs the binary, ordinary completions, and manual without +running picker setup hooks. Homebrew users receive the existing first-run setup; +see [shell setup](image-picker-shell-setup.md) for eligibility and explicit setup. Linux packages install one fish startup file at `/usr/share/fish/vendor_conf.d/openai-picker.fish`. It activates at the first diff --git a/scripts/homebrew_picker_test.go b/scripts/homebrew_picker_test.go deleted file mode 100644 index 4190d01ac..000000000 --- a/scripts/homebrew_picker_test.go +++ /dev/null @@ -1,274 +0,0 @@ -//go:build !windows - -package scripts_test - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "slices" - "strings" - "testing" - "time" -) - -func TestHomebrewPickerInstallHook(t *testing.T) { - config := readReleaseYAML[goReleaserConfig](t, filepath.Join("..", ".goreleaser.yml")) - if len(config.HomebrewCasks) != 1 { - t.Fatal("expected one Homebrew cask") - } - hooks := config.HomebrewCasks[0].Hooks - if len(hooks.Pre) != 0 || len(hooks.Post) != 1 || hooks.Post["install"] == "" { - t.Fatal("picker setup must only run after installation; uninstall hooks also run during upgrades") - } - ruby, err := exec.LookPath("ruby") - if err != nil { - t.Skip("Ruby is required to execute the Homebrew hook fixture") - } - - for _, environment := range []string{"filtered", "saved_precedence", "unfiltered", "unset"} { - for _, setupExit := range []int{0, 17} { - t.Run(fmt.Sprintf("%s/setup_exit_%d", environment, setupExit), func(t *testing.T) { - root := t.TempDir() - home := filepath.Join(root, "user home") - if err := os.Mkdir(home, 0o700); err != nil { - t.Fatal(err) - } - profile := filepath.Join(home, ".zshrc") - profileContents := []byte("# existing profile remains owned by the CLI setup routine\n") - if err := os.WriteFile(profile, profileContents, 0o600); err != nil { - t.Fatal(err) - } - hookPath := filepath.Join(root, "hook.rb") - if err := os.WriteFile(hookPath, []byte(hooks.Post["install"]), 0o600); err != nil { - t.Fatal(err) - } - logPath := filepath.Join(root, "calls.jsonl") - config := filepath.Join(home, "custom config $(literal)") - zdotdir := filepath.Join(home, "zsh's startup") - optOut := filepath.Join(home, ".openai", "shell", "image-picker.tab-off-zsh") - if err := os.MkdirAll(filepath.Dir(optOut), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(optOut, nil, 0o600); err != nil { - t.Fatal(err) - } - otherBin := filepath.Join(root, "other bin") - if err := os.Mkdir(otherBin, 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(otherBin, "openai"), []byte("#!/bin/sh\nexit 91\n"), 0o700); err != nil { - t.Fatal(err) - } - versions := []string{"1.0", "1.1", "1.1", "1.0"} // Install, upgrade, repeat, rollback. - for _, version := range versions { - staged := filepath.Join(root, "Caskroom user's $(literal) files", version) - if err := os.MkdirAll(staged, 0o700); err != nil { - t.Fatal(err) - } - fixture := "#!" + ruby + "\n" + homebrewPickerFixture - if err := os.WriteFile(filepath.Join(staged, "openai"), []byte(fixture), 0o700); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) - command := exec.CommandContext(ctx, ruby, "-e", homebrewPickerHookHarness, hookPath, staged) - command.Env = []string{ - "PATH=" + otherBin + ":/usr/bin:/bin", - "HOME=" + home, - "SHELL=/bin/zsh", - "PICKER_TEST_LOG=" + logPath, - fmt.Sprintf("PICKER_TEST_EXIT=%d", setupExit), - } - switch environment { - case "filtered", "saved_precedence": - command.Env = append(command.Env, "HOMEBREW_ZDOTDIR="+zdotdir, "HOMEBREW_XDG_CONFIG_HOME="+config, - "HOMEBREW_SUDO_USER=fixture-user") - if environment == "saved_precedence" { - command.Env = append(command.Env, "ZDOTDIR=wrong", "XDG_CONFIG_HOME=wrong", "SUDO_USER=wrong") - } - case "unfiltered": - command.Env = append(command.Env, "ZDOTDIR="+zdotdir, "XDG_CONFIG_HOME="+config, "SUDO_USER=fixture-user") - } - var stdout, stderr bytes.Buffer - command.Stdout, command.Stderr = &stdout, &stderr - err := command.Run() - cancel() - if err != nil { - t.Fatalf("hook must not abort installation when setup exits %d: %v; %s", setupExit, err, stderr.String()) - } - if stdout.String() != "setup output\n" || stderr.String() != "setup diagnostic\n" { - t.Fatalf("hook output: stdout=%q stderr=%q", stdout.String(), stderr.String()) - } - } - data, err := os.ReadFile(logPath) - if err != nil { - t.Fatal(err) - } - calls := strings.Split(strings.TrimSpace(string(data)), "\n") - if len(calls) != len(versions) { - t.Fatalf("setup calls = %d, want one for each installation", len(calls)) - } - for index, line := range calls { - var call struct { - Args []string `json:"args"` - Executable string `json:"executable"` - Home string `json:"home"` - Shell string `json:"shell"` - UID int `json:"uid"` - Zdotdir string `json:"zdotdir"` - Config string `json:"config"` - SudoUser string `json:"sudo_user"` - Stdin string `json:"stdin"` - } - if err := json.Unmarshal([]byte(line), &call); err != nil { - t.Fatal(err) - } - if !slices.Equal(call.Args, []string{"@completion", "--install-picker", "--automatic"}) { - t.Errorf("setup arguments = %q", call.Args) - } - wantExecutable := filepath.Join(root, "Caskroom user's $(literal) files", versions[index], "openai") - if environment == "unset" { - if call.Zdotdir != "" || call.Config != "" || call.SudoUser != "" { - t.Errorf("hook invented unset configuration: %+v", call) - } - } else if call.Zdotdir != zdotdir || call.Config != config || call.SudoUser != "fixture-user" { - t.Errorf("hook lost shell configuration or sudo marker: %+v", call) - } - if call.Executable != wantExecutable || call.Home != home || call.Shell != "/bin/zsh" || call.UID != os.Geteuid() || call.Stdin != "" { - t.Errorf("hook changed setup executable, user, environment or stdin: %+v", call) - } - } - marker, err := os.ReadFile(optOut) - if err != nil || len(marker) != 0 { - t.Fatalf("hook changed existing opt-out: %q, %v", marker, err) - } - contents, err := os.ReadFile(profile) - if err != nil || string(contents) != string(profileContents) { - t.Fatalf("packaging hook edited the profile outside the shared setup routine: %q, %v", contents, err) - } - }) - } - } -} - -// CI installation must not launch setup at all, even when shell targeting would -// otherwise be eligible. False values match first-use setup's existing contract. -func TestHomebrewPickerHookSkipsCI(t *testing.T) { - config := readReleaseYAML[goReleaserConfig](t, filepath.Join("..", ".goreleaser.yml")) - if len(config.HomebrewCasks) != 1 { - t.Fatal("expected one Homebrew cask") - } - ruby, err := exec.LookPath("ruby") - if err != nil { - t.Skip("Ruby is required to execute the Homebrew hook fixture") - } - home := t.TempDir() - hook := filepath.Join(home, "hook.rb") - if err := os.WriteFile(hook, []byte(config.HomebrewCasks[0].Hooks.Post["install"]), 0o600); err != nil { - t.Fatal(err) - } - for _, name := range []string{"CI", "GITHUB_ACTIONS", "GITLAB_CI", "TF_BUILD", "BUILDKITE", "JENKINS_URL", "TEAMCITY_VERSION"} { - for _, value := range []string{"true", "1", "https://ci.example.invalid", "FALSE", "false", "0", "", " false ", string([]byte{0xff})} { - t.Run(name+"="+value, func(t *testing.T) { - command := exec.CommandContext(t.Context(), ruby, "-e", ` -require "timeout" -def staged_path; "/synthetic/cask"; end -def system_command(*args, **options); puts "setup invoked"; end -eval(File.read(ARGV.fetch(0)), binding, ARGV.fetch(0)) -puts "installation continued" -`, hook) - command.Env = []string{"HOME=" + home, "PATH=/usr/bin:/bin", "SHELL=/bin/zsh", "HOMEBREW_ZDOTDIR=" + home, "CI=false", name + "=" + value} - // Setting another marker must still skip when CI itself is false. - var stdout, stderr bytes.Buffer - command.Stdout, command.Stderr = &stdout, &stderr - err := command.Run() - want := "installation continued\n" - if value == "" || value == "0" || strings.EqualFold(value, "false") { - want = "setup invoked\n" + want - } - if err != nil || stdout.String() != want || stderr.Len() != 0 { - t.Fatalf("CI hook dispatch: %v stdout=%q stderr=%q, want %q", err, stdout.String(), stderr.String(), want) - } - }) - } - } -} - -// Homebrew raises on timeout even with must_succeed: false. Only that optional -// failure is swallowed; cancellation and unexpected hook errors still abort. -func TestHomebrewPickerHookExceptions(t *testing.T) { - config := readReleaseYAML[goReleaserConfig](t, filepath.Join("..", ".goreleaser.yml")) - if len(config.HomebrewCasks) != 1 { - t.Fatal("expected one Homebrew cask") - } - ruby, err := exec.LookPath("ruby") - if err != nil { - t.Skip("Ruby is required to execute the Homebrew hook fixture") - } - for _, failure := range []string{"Timeout::Error", "Interrupt", "RuntimeError"} { - t.Run(failure, func(t *testing.T) { - home := t.TempDir() - hook := filepath.Join(home, "hook.rb") - if err := os.WriteFile(hook, []byte(config.HomebrewCasks[0].Hooks.Post["install"]), 0o600); err != nil { - t.Fatal(err) - } - command := exec.CommandContext(t.Context(), ruby, "-e", ` -require "timeout" -def staged_path; "/synthetic/cask"; end -def system_command(*args, **options) - raise "optional setup must have a bounded wait" unless options[:timeout] && options[:timeout] > 0 && options[:timeout] <= 10 - raise Object.const_get(ARGV.fetch(1)) -end -eval(File.read(ARGV.fetch(0)), binding, ARGV.fetch(0)) -puts "installation continued" -`, hook, failure) - command.Env = []string{"HOME=" + home, "PATH=/usr/bin:/bin"} - var stdout, stderr bytes.Buffer - command.Stdout, command.Stderr = &stdout, &stderr - err := command.Run() - if failure == "Timeout::Error" { - if err != nil || stdout.String() != "installation continued\n" || stderr.String() != "Tab shortcut setup timed out; installation will continue.\n" { - t.Fatalf("timeout did not remain optional: %v stdout=%q stderr=%q", err, stdout.String(), stderr.String()) - } - } else if err == nil || stdout.Len() != 0 || !strings.Contains(stderr.String(), failure) { - t.Fatalf("hook swallowed %s: %v stdout=%q stderr=%q", failure, err, stdout.String(), stderr.String()) - } - }) - } -} - -// Evaluate the real cask hook using the documented system_command call shape. -// This is a command-contract fixture, not a real Homebrew install or CLI setup. -// Profiles and consent are only inspected here; setup behavior has separate tests. -const homebrewPickerHookHarness = ` -require "open3" -def staged_path - ARGV.fetch(1) -end -def system_command(executable, args: [], env: {}, sudo: false, must_succeed: true, - print_stdout: false, print_stderr: true, timeout: nil) - raise "picker setup must not use sudo" if sudo - raise "optional setup must have a bounded wait" unless timeout && timeout > 0 && timeout <= 10 - out, err, status = Open3.capture3(env, executable, *args, stdin_data: "") - $stdout.write(out) if print_stdout - $stderr.write(err) if print_stderr - raise "setup failed" if must_succeed && !status.success? -end -eval(File.read(ARGV.fetch(0)), binding, ARGV.fetch(0)) -` - -const homebrewPickerFixture = ` -require "json" -File.open(ENV.fetch("PICKER_TEST_LOG"), "a") do |file| - file.puts JSON.generate(args: ARGV, executable: $0, home: ENV["HOME"], - shell: ENV["SHELL"], uid: Process.euid, stdin: STDIN.read, - zdotdir: ENV["ZDOTDIR"], config: ENV["XDG_CONFIG_HOME"], sudo_user: ENV["SUDO_USER"]) -end -puts "setup output" -warn "setup diagnostic" -exit Integer(ENV.fetch("PICKER_TEST_EXIT")) -` diff --git a/scripts/release_artifacts_test.go b/scripts/release_artifacts_test.go index 007bed0ec..9c18b000c 100644 --- a/scripts/release_artifacts_test.go +++ b/scripts/release_artifacts_test.go @@ -443,6 +443,9 @@ func TestGoReleaserPreservesArtifactsWithoutApplicationHooks(t *testing.T) { t.Fatal("Homebrew cask publishing configuration was removed") } cask := config.HomebrewCasks[0] + if len(cask.Hooks.Pre) != 0 || len(cask.Hooks.Post) != 0 { + t.Fatal("Homebrew cask must leave picker setup to the existing first-run path") + } if cask.Repository.Name != "homebrew-tools" || cask.Repository.Token != "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" { t.Errorf("Homebrew repository configuration = %+v", cask.Repository) }