diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87e9b493..30226cab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,7 +117,7 @@ jobs: uses: ./.github/actions/setup-goreleaser - name: Verify local and historical release preflight - run: go test ./scripts -run '^Test(Local|Historical)ReleasePreflight$' -count=1 + run: go test ./scripts -run '^Test((Local|Historical)ReleasePreflight|LinuxFishPickerPackageArtifacts)$' -count=1 env: GORELEASER_EXECUTABLE: ${{ steps.goreleaser.outputs.executable }} @@ -127,12 +127,14 @@ jobs: go run ./cmd/openai/main.go @completion bash > completions/openai.bash go run ./cmd/openai/main.go @completion zsh > completions/openai.zsh go run ./cmd/openai/main.go @completion fish > completions/openai.fish + mkdir -p completions/picker + go run ./scripts/render-linux-picker > completions/picker/openai.fish go run ./cmd/openai/main.go @manpages -o man - for directory in completions man man/man1; do + for directory in completions completions/picker man man/man1; do [[ -d "$directory" && ! -L "$directory" ]] || exit 1 done - for path in completions/openai.bash completions/openai.zsh completions/openai.fish man/man1/openai.1.gz; do + for path in completions/openai.bash completions/openai.zsh completions/openai.fish completions/picker/openai.fish man/man1/openai.1.gz; do [[ -f "$path" && ! -L "$path" ]] || exit 1 size="$(wc -c < "$path")" [[ "$size" -gt 0 && "$size" -le 10485760 ]] || exit 1 @@ -143,6 +145,7 @@ jobs: '/completions/openai.bash' \ '/completions/openai.zsh' \ '/completions/openai.fish' \ + '/completions/picker/openai.fish' \ '/man/man1/openai.1.gz' \ >> "$exclude_file" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 00c41d89..c09b634d 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -76,6 +76,11 @@ jobs: go run ./cmd/openai/main.go @completion bash > completions/openai.bash go run ./cmd/openai/main.go @completion zsh > completions/openai.zsh go run ./cmd/openai/main.go @completion fish > completions/openai.fish + # Historical tags did not ship the Linux fish picker asset. + if grep -Fq 'src: completions/picker/openai.fish' .goreleaser.yml; then + mkdir -p completions/picker + go run ./scripts/render-linux-picker > completions/picker/openai.fish + fi go run ./cmd/openai/main.go @manpages -o man - name: Stage isolated release inputs uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -85,6 +90,7 @@ jobs: completions/openai.bash completions/openai.zsh completions/openai.fish + completions/picker/openai.fish man/man1/openai.1.gz if-no-files-found: error retention-days: 1 @@ -169,12 +175,27 @@ jobs: exit 1 fi + inputs=(completions/openai.bash completions/openai.zsh completions/openai.fish man/man1/openai.1.gz) + package_picker=false + if grep -Fq 'src: completions/picker/openai.fish' .goreleaser.yml; then + package_picker=true + inputs+=(completions/picker/openai.fish) + fi + while IFS= read -r -d '' path; do relative="${path#"$RELEASE_INPUT_DIR"/}" case "$relative" in completions|man|man/man1) [[ -d "$path" && ! -L "$path" ]] || exit 1 ;; + completions/picker) + [[ "$package_picker" == true && -d "$path" && ! -L "$path" ]] || exit 1 + ;; + completions/picker/openai.fish) + [[ "$package_picker" == true && -f "$path" && ! -L "$path" ]] || exit 1 + size="$(wc -c < "$path")" + [[ "$size" -gt 0 && "$size" -le 10485760 ]] || exit 1 + ;; completions/openai.bash|completions/openai.zsh|completions/openai.fish|man/man1/openai.1.gz) [[ -f "$path" && ! -L "$path" ]] || exit 1 size="$(wc -c < "$path")" @@ -190,22 +211,18 @@ jobs: esac done < <(find "$RELEASE_INPUT_DIR" -mindepth 1 -print0) - for path in completions/openai.bash completions/openai.zsh completions/openai.fish man/man1/openai.1.gz; do + for path in "${inputs[@]}"; do [[ -f "$RELEASE_INPUT_DIR/$path" && ! -L "$RELEASE_INPUT_DIR/$path" ]] || exit 1 done mkdir -p completions man/man1 - for path in completions/openai.bash completions/openai.zsh completions/openai.fish man/man1/openai.1.gz; do + if [[ "$package_picker" == true ]]; then mkdir -p completions/picker; fi + for path in "${inputs[@]}"; do install -m 0644 "$RELEASE_INPUT_DIR/$path" "$path" done exclude_file="$(git rev-parse --git-path info/exclude)" - printf '%s\n' \ - '/completions/openai.bash' \ - '/completions/openai.zsh' \ - '/completions/openai.fish' \ - '/man/man1/openai.1.gz' \ - >> "$exclude_file" + printf '/%s\n' "${inputs[@]}" >> "$exclude_file" - name: Create release app token for this repo id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 diff --git a/.gitignore b/.gitignore index 684073f4..d61fafb6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist/ /completions/openai.bash /completions/openai.zsh /completions/openai.fish +/completions/picker/openai.fish /man/man1/openai.1.gz /openai *.exe diff --git a/.goreleaser.yml b/.goreleaser.yml index 00f3e4f7..8de009dc 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -87,6 +87,10 @@ nfpms: contents: - src: man/man1/*.1.gz dst: /usr/share/man/man1/ + - src: completions/picker/openai.fish + dst: /usr/share/fish/vendor_conf.d/openai-picker.fish + file_info: + mode: 0644 homebrew_casks: - name: openai repository: diff --git a/docs/releasing.md b/docs/releasing.md index bafe78e1..75f9581f 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -11,6 +11,20 @@ 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`. +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 +runs when `/usr/bin/openai` is the selected executable, including after PATH +changes. Ordinary completion remains under the user's existing shell setup. +The package manager owns and removes the file; installation does not edit user +profiles. Bash and zsh retain their existing first-run setup. See +[shell shortcuts](image-picker-shell-setup.md). + +`go run ./scripts/render-linux-picker` generates the fish asset at build time. +Release inputs include it only when the checked-out tag's package configuration +references it, preserving older tags' original inputs. The configured +`termux.deb` format still requires an Android build. + Shell completions and man pages are generated in a separate, read-only job before the protected publishing job receives repository-write tokens or signing and notarization credentials. GoReleaser packages those prebuilt files without diff --git a/internal/autocomplete/picker_fish_package.go b/internal/autocomplete/picker_fish_package.go new file mode 100644 index 00000000..4d668b13 --- /dev/null +++ b/internal/autocomplete/picker_fish_package.go @@ -0,0 +1,25 @@ +package autocomplete + +import "strings" + +// FishPackagePickerScript renders the Linux package's vendor_conf.d script. +// It checks consent at activation and the selected executable at every Tab press. +func FishPackagePickerScript() ([]byte, error) { + return fishPackagePickerScript("/usr/bin/openai") +} + +func fishPackagePickerScript(executable string) ([]byte, error) { + guard, err := autoCompleteFS.ReadFile("shellscripts/fish_package_guard.fish") + if err != nil { + return nil, err + } + binary := quotePickerPath(CompletionStyleFish, executable) + check := strings.ReplaceAll(string(guard), "__OPENAI_PACKAGE_EXECUTABLE__", binary) + // The system test supports -ef on older fish versions too. Its absolute + // path keeps automatic activation independent of other executables on PATH. + picker, err := renderPickerCompletionWithGuard(CompletionStyleFish, "openai", "test -x /usr/bin/test; and command /usr/bin/test (command -s openai) -ef "+binary+" 2>/dev/null") + if err != nil { + return nil, err + } + return renderPickerStartup(PickerInstallation{Shell: CompletionStyleFish}, "", picker, check), nil +} diff --git a/internal/autocomplete/picker_fish_package_test.go b/internal/autocomplete/picker_fish_package_test.go new file mode 100644 index 00000000..d66d53eb --- /dev/null +++ b/internal/autocomplete/picker_fish_package_test.go @@ -0,0 +1,191 @@ +package autocomplete + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestFishPackagePickerFirstPrompt(t *testing.T) { + fish, err := exec.LookPath("fish") + if err != nil { + if strings.Contains(os.Getenv("OPENAI_CLI_REQUIRE_NATIVE_SHELLS"), "fish") { + t.Fatal(err) + } + t.Skip("fish is unavailable") + } + expect, err := exec.LookPath("expect") + if err != nil { + if strings.Contains(os.Getenv("OPENAI_CLI_REQUIRE_NATIVE_SHELLS"), "fish") { + t.Fatal(err) + } + t.Skip("expect is required for native fish startup") + } + for _, scenario := range []struct { + name, setup string + disabled bool + }{ + {"enabled", "", false}, + {"stable opt-out", `touch "$HOME/.openai/shell/image-picker.tab-off-fish"`, true}, + {"stable dangling opt-out", `ln -s "$HOME/missing" "$HOME/.openai/shell/image-picker.tab-off-fish"`, true}, + {"legacy opt-out", `touch "$HOME/.config/openai/image-picker.json.tab-off-fish"`, true}, + {"legacy dangling opt-out", `ln -s "$HOME/missing" "$HOME/.config/openai/image-picker.json.tab-off-fish"`, true}, + {"late XDG opt-out", "set -gx XDG_CONFIG_HOME $HOME/alternate\ntouch \"$XDG_CONFIG_HOME/openai/image-picker.json.tab-off-fish\"", true}, + {"stable opt-out after XDG change", "touch \"$HOME/.openai/shell/image-picker.tab-off-fish\"\nset -gx XDG_CONFIG_HOME $HOME/alternate", true}, + {"disabled in config", "openai_picker_disable", true}, + {"different executable", "set -gx PATH $HOME/other $PATH", true}, + {"different executable after prompt", "", false}, + {"function shadows executable", "function openai; end", true}, + {"PATH-selected test is ignored", "", false}, + {"relative home", "set -gx HOME relative", true}, + {"same executable through symlink", "rm \"$HOME/other/openai\"\nln -s \"$HOME/openai\" \"$HOME/other/openai\"\nset -gx PATH $HOME/other $PATH", false}, + {"repeated source", `source "$PICKER_TEST_PACKAGE"`, false}, + {"personal then package", "source \"$PICKER_TEST_PERSONAL\"\nsource \"$PICKER_TEST_PACKAGE\"", false}, + {"package then personal", `source "$PICKER_TEST_PERSONAL"`, false}, + } { + t.Run(scenario.name, func(t *testing.T) { + home := t.TempDir() + for _, directory := range []string{".openai/shell", ".config/openai", "alternate/openai", "other"} { + require.NoError(t, os.MkdirAll(filepath.Join(home, directory), 0700)) + } + fixture := "#!/bin/sh\nif [ \"$1\" = __complete ]; then printf 'generate\\n'; exit 0; fi\nprintf 'launch\\n' >>\"$PICKER_TEST_RESULT\"\nprintf '\\nPICKER_LAUNCHED\\n'\n" + binary := filepath.Join(home, "openai") + require.NoError(t, os.WriteFile(binary, []byte(fixture), 0700)) + require.NoError(t, os.WriteFile(filepath.Join(home, "other", "openai"), []byte("#!/bin/sh\nexit 99\n"), 0700)) + if scenario.name == "PATH-selected test is ignored" { + fixture := "#!/bin/sh\nprintf 'unexpected\\n' >>\"$HOME/identity-calls\"\nexit 99\n" + require.NoError(t, os.WriteFile(filepath.Join(home, "test"), []byte(fixture), 0700)) + } + packageScript, err := fishPackagePickerScript(binary) + require.NoError(t, err) + if runtime.GOOS == "darwin" { + // The asset targets Linux; macOS keeps its system test in /bin. + packageScript = []byte(strings.ReplaceAll(string(packageScript), "/usr/bin/test", "/bin/test")) + } + personalScript, err := renderInstalledPicker(PickerInstallation{Shell: CompletionStyleFish, OptOutPath: filepath.Join(home, ".openai", "shell", "image-picker.tab-off-fish")}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(home, "package.fish"), packageScript, 0600)) + require.NoError(t, os.WriteFile(filepath.Join(home, "personal.fish"), personalScript, 0600)) + startup := `source "$PICKER_TEST_PACKAGE" +if set -q __openai_picker_modes; printf 'early\n' >>"$PICKER_TEST_RESULT"; end +fish_default_key_bindings +function custom_tab; printf 'fallback\n' >>"$PICKER_TEST_RESULT"; end +bind \t custom_tab +function fish_prompt + if not set -q __package_test_prompt_seen + set -g __package_test_prompt_seen 1 + if set -q __openai_picker_modes[1]; printf 'active\n'; else; printf 'off\n'; end >>"$PICKER_TEST_RESULT" + if functions -q __openai_picker_install_on_prompt; printf 'pending\n' >>"$PICKER_TEST_RESULT"; end + end + printf 'PICKER_TEST> ' +end +` + scenario.setup + "\n" + require.NoError(t, os.WriteFile(filepath.Join(home, "startup.fish"), []byte(startup), 0600)) + driver := `set timeout 8 +spawn -noecho $env(PICKER_TEST_SHELL) --no-config --interactive --init-command {source "$PICKER_TEST_STARTUP"} +expect_before { + -exact "\033\1330c" {send -- "\033\133?1;2c"; exp_continue} + -exact "\033\1336n" {send -- "\033\1331;1R"; exp_continue} +} +expect_after timeout { puts stderr "fish package probe timed out"; exit 1 } +expect -exact "PICKER_TEST> " +` + // Exercise Tab at the very first prompt: a nested prompt callback + // would leave this command on the ordinary custom-binding path. + changedExecutable := scenario.name == "different executable after prompt" + if changedExecutable { + driver += `send -- {set -gx PATH "$HOME/other" $PATH} +send -- "\r" +expect -exact "PICKER_TEST> " +` + } + if !scenario.disabled && !changedExecutable { + driver += `send -- "openai images generate\t" +expect -exact "PICKER_LAUNCHED\r\n" +expect -exact "PICKER_TEST> " +send -- "\025other\t\025printf 'FALLBACK_DONE\\n'\r" +expect -exact "FALLBACK_DONE\r\n" +expect -exact "PICKER_TEST> " +send -- "openai_picker_disable; printf 'DISABLED\\n'\r" +expect -exact "DISABLED\r\n" +expect -exact "PICKER_TEST> " +` + } + driver += `send -- "openai images generate\t\025printf 'RESTORED\\n'\r" +expect -exact "RESTORED\r\n" +expect -exact "PICKER_TEST> " +` + if changedExecutable { + driver += `send -- "openai_picker_disable\r" +expect -exact "PICKER_TEST> " +` + } + driver += `send -- {if set -q __openai_picker_modes[1]; printf 'reactivated\n' >>"$PICKER_TEST_RESULT"; end; if functions -q __openai_picker_install_on_prompt; printf 'pending\n' >>"$PICKER_TEST_RESULT"; end} +send -- "\r" +expect -exact "PICKER_TEST> " +send -- "exit\r" +expect eof +` + require.NoError(t, os.WriteFile(filepath.Join(home, "driver.exp"), []byte(driver), 0600)) + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + command := exec.CommandContext(ctx, expect, filepath.Join(home, "driver.exp")) + command.Dir = home + command.Env = []string{ + "HOME=" + home, "PATH=" + home + ":" + os.Getenv("PATH"), "TERM=xterm-256color", "LC_ALL=C", + "PICKER_TEST_SHELL=" + fish, "PICKER_TEST_STARTUP=" + filepath.Join(home, "startup.fish"), + "PICKER_TEST_PACKAGE=" + filepath.Join(home, "package.fish"), "PICKER_TEST_PERSONAL=" + filepath.Join(home, "personal.fish"), + "PICKER_TEST_RESULT=" + filepath.Join(home, "result"), + } + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) + result, err := os.ReadFile(filepath.Join(home, "result")) + require.NoError(t, err) + want := "active\nlaunch\nfallback\nfallback\n" + if scenario.disabled { + want = "off\nfallback\n" + } else if changedExecutable { + want = "active\nfallback\n" + } + require.Equal(t, want, string(result), string(output)) + if scenario.name == "PATH-selected test is ignored" { + _, err := os.Stat(filepath.Join(home, "identity-calls")) + require.ErrorIs(t, err, os.ErrNotExist, "startup and Tab must ignore a PATH-selected test executable") + } + }) + } +} + +func TestFishPackagePreservesOrdinaryCompletions(t *testing.T) { + fish, err := exec.LookPath("fish") + if err != nil { + if strings.Contains(os.Getenv("OPENAI_CLI_REQUIRE_NATIVE_SHELLS"), "fish") { + t.Fatal(err) + } + t.Skip("fish is unavailable") + } + home := t.TempDir() + script, err := fishPackagePickerScript(filepath.Join(home, "package-openai")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(home, "package.fish"), script, 0600)) + fixture := "#!/bin/sh\nprintf '%s\\n' \"$*\" >>\"$PICKER_TEST_CALLS\"\nprintf 'unrelated-completion\\n'\n" + require.NoError(t, os.WriteFile(filepath.Join(home, "openai"), []byte(fixture), 0700)) + command := exec.CommandContext(t.Context(), fish, "--no-config", "-c", ` +complete -c openai -f -a preserved-completion +source "$HOME/package.fish" +complete -C 'openai ' +`) + command.Env = []string{"HOME=" + home, "PATH=" + home + ":" + os.Getenv("PATH"), "PICKER_TEST_CALLS=" + filepath.Join(home, "calls")} + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) + require.Equal(t, "preserved-completion\n", string(output)) + _, err = os.Stat(filepath.Join(home, "calls")) + require.ErrorIs(t, err, os.ErrNotExist, "package sourcing must not route ordinary completion to another executable") +} diff --git a/internal/autocomplete/picker_install.go b/internal/autocomplete/picker_install.go index 85842909..8bb6439a 100644 --- a/internal/autocomplete/picker_install.go +++ b/internal/autocomplete/picker_install.go @@ -512,6 +512,12 @@ func renderInstalledPicker(options PickerInstallation) ([]byte, error) { if err != nil { return nil, err } + return renderPickerStartup(options, completion, picker, ""), nil +} + +// Package startup leaves ordinary completion to the user's existing setup. +// Both personal and package scripts share activation and disable handling. +func renderPickerStartup(options PickerInstallation, completion, picker, fishPromptGuard string) []byte { preamble, marker := "", "" switch options.Shell { case CompletionStyleZsh: @@ -539,12 +545,12 @@ function openai_picker_disable end ` + declined + `function __openai_picker_install_on_prompt --on-event fish_prompt functions --erase __openai_picker_install_on_prompt -` + declined + picker + ` +` + declined + fishPromptGuard + picker + ` if set -q __openai_picker_modes[1]; set -gx OPENAI_PICKER_INTEGRATION fish; end end ` } - return []byte(pickerScriptHeader + preamble + completion + "\n" + picker + marker), nil + return []byte(pickerScriptHeader + preamble + completion + "\n" + picker + marker) } func openPickerDirectory(ctx context.Context, path string, create bool) (*os.Root, error) { diff --git a/internal/autocomplete/picker_script.go b/internal/autocomplete/picker_script.go index d5564f40..ed3365d7 100644 --- a/internal/autocomplete/picker_script.go +++ b/internal/autocomplete/picker_script.go @@ -8,6 +8,12 @@ import ( // Picker hooks share the completion script distribution, but are opt-in because // they change an interactive key's behavior. They never edit startup files. func renderPickerCompletion(shell CompletionStyle, appName string) (string, error) { + return renderPickerCompletionWithGuard(shell, appName, "") +} + +// A package hook checks its executable again at each Tab press. Personal hooks +// retain their existing behavior of following the selected command on PATH. +func renderPickerCompletionWithGuard(shell CompletionStyle, appName, fishCommandCondition string) (string, error) { if appName == "" { return "", fmt.Errorf("a command name is required for picker integration") } @@ -34,5 +40,10 @@ func renderPickerCompletion(shell CompletionStyle, appName string) (string, erro if err != nil { return "", err } - return strings.ReplaceAll(string(script), "__APPNAME__", appName), nil + guard := "" + if fishCommandCondition != "" { + guard = "; and " + fishCommandCondition + } + content := strings.ReplaceAll(string(script), "__FISH_PICKER_COMMAND_GUARD__", guard) + return strings.ReplaceAll(content, "__APPNAME__", appName), nil } diff --git a/internal/autocomplete/shellscripts/fish_package_guard.fish b/internal/autocomplete/shellscripts/fish_package_guard.fish new file mode 100644 index 00000000..7865806e --- /dev/null +++ b/internal/autocomplete/shellscripts/fish_package_guard.fish @@ -0,0 +1,15 @@ +# Run inside the shared first-prompt handler, after PATH and user settings load. +set -q HOME[1]; and string match --quiet '/*' -- "$HOME"; or return +test "$(type --type openai 2>/dev/null)" = file; or return +command -sq openai; and test -x /usr/bin/test; and command /usr/bin/test (command -s openai) -ef __OPENAI_PACKAGE_EXECUTABLE__ 2>/dev/null; or return + +set -l config "$HOME/.config" +if set -q XDG_CONFIG_HOME[1]; and test -n "$XDG_CONFIG_HOME" + set config "$XDG_CONFIG_HOME" +end +# Honor shared consent and any unmigrated preference in the current legacy root. +for marker in "$HOME/.openai/shell/image-picker.tab-off-fish" "$config/openai/image-picker.json.tab-off-fish" + if test -e "$marker"; or test -L "$marker" + return + end +end diff --git a/internal/autocomplete/shellscripts/fish_picker.fish b/internal/autocomplete/shellscripts/fish_picker.fish index 96c065f5..f1b1a1b8 100644 --- a/internal/autocomplete/shellscripts/fish_picker.fish +++ b/internal/autocomplete/shellscripts/fish_picker.fish @@ -93,7 +93,7 @@ for mode in default insert # command substitution would also trim newlines in the editor buffer. set -l buffer (commandline --current-buffer | string collect --no-trim-newlines) set -l app (string escape --style=regex -- '__APPNAME__') - if test "$$active_name" = 1; and isatty stdin; and isatty stdout; and isatty stderr; and test "$TERM" != dumb; and test (commandline --cursor) -eq (math (string length -- "$buffer") - 1); and string match --quiet --regex -- "\\A[ \\t]*"$app"[ \\t]+images[ \\t]+generate[ \\t]*\\n\\z" "$buffer"; and test "$(type --type __APPNAME__ 2>/dev/null)" = file + if test "$$active_name" = 1; and isatty stdin; and isatty stdout; and isatty stderr; and test "$TERM" != dumb; and test (commandline --cursor) -eq (math (string length -- "$buffer") - 1); and string match --quiet --regex -- "\\A[ \\t]*"$app"[ \\t]+images[ \\t]+generate[ \\t]*\\n\\z" "$buffer"; and test "$(type --type __APPNAME__ 2>/dev/null)" = file__FISH_PICKER_COMMAND_GUARD__ # The current command resolves to an external executable. printf '\n' env OPENAI_PICKER_SHELL=fish __APPNAME__ images generate diff --git a/internal/releaseworkflow/workflow_test.go b/internal/releaseworkflow/workflow_test.go index af6cb33b..ca08cc67 100644 --- a/internal/releaseworkflow/workflow_test.go +++ b/internal/releaseworkflow/workflow_test.go @@ -28,6 +28,7 @@ var releaseInputPaths = []string{ "completions/openai.bash", "completions/openai.zsh", "completions/openai.fish", + "completions/picker/openai.fish", "man/man1/openai.1.gz", } @@ -204,6 +205,7 @@ func TestReleaseInputsUseAnIsolatedUnprivilegedJob(t *testing.T) { "@completion bash > completions/openai.bash", "@completion zsh > completions/openai.zsh", "@completion fish > completions/openai.fish", + "go run ./scripts/render-linux-picker > completions/picker/openai.fish", "@manpages -o man", } { if !strings.Contains(generate.Run, fragment) { @@ -394,13 +396,42 @@ func TestReleaseInputVerifierRejectsUnsafeArtifacts(t *testing.T) { } _, verify := requireStep(t, readWorkflow(t, "publish-release.yml").Jobs["goreleaser"], "Verify isolated release inputs") + releaseConfig, err := os.ReadFile(filepath.Join("..", "..", ".goreleaser.yml")) + if err != nil { + t.Fatal(err) + } tests := []struct { - name string - mutate func(t *testing.T, root string) - valid bool + name string + mutate func(t *testing.T, root string) + valid bool + historical bool }{ {name: "exact passive artifacts", valid: true}, + {name: "historical inputs", historical: true, valid: true}, + {name: "historical unexpected picker", historical: true, mutate: func(t *testing.T, root string) { + writeReleaseTestFile(t, filepath.Join(root, "completions/picker/openai.fish"), []byte("unexpected")) + }}, + {name: "missing fish picker", mutate: func(t *testing.T, root string) { + if err := os.Remove(filepath.Join(root, "completions/picker/openai.fish")); err != nil { + t.Fatal(err) + } + }}, + {name: "symlinked fish picker", mutate: func(t *testing.T, root string) { + path := filepath.Join(root, "completions/picker/openai.fish") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(root, releaseInputPaths[0]), path); err != nil { + t.Fatal(err) + } + }}, + {name: "empty fish picker", mutate: func(t *testing.T, root string) { + writeReleaseTestFile(t, filepath.Join(root, "completions/picker/openai.fish"), nil) + }}, + {name: "oversized fish picker", mutate: func(t *testing.T, root string) { + writeReleaseTestFile(t, filepath.Join(root, "completions/picker/openai.fish"), bytes.Repeat([]byte("x"), 10*1024*1024+1)) + }}, {name: "unexpected workflow file", mutate: func(t *testing.T, root string) { writeReleaseTestFile(t, filepath.Join(root, ".goreleaser.yml"), []byte("before: compromised")) }}, @@ -447,7 +478,13 @@ func TestReleaseInputVerifierRejectsUnsafeArtifacts(t *testing.T) { t.Parallel() root := filepath.Join(t.TempDir(), "inputs") - for _, path := range releaseInputPaths { + inputs := releaseInputPaths + config := releaseConfig + if test.historical { + inputs = []string{"completions/openai.bash", "completions/openai.zsh", "completions/openai.fish", "man/man1/openai.1.gz"} + config = []byte("version: 2\n") + } + for _, path := range inputs { writeReleaseTestFile(t, filepath.Join(root, path), []byte("safe passive release input")) } if test.mutate != nil { @@ -457,6 +494,15 @@ func TestReleaseInputVerifierRejectsUnsafeArtifacts(t *testing.T) { if output, err := exec.Command("git", "init", "--quiet", workspace).CombinedOutput(); err != nil { t.Fatalf("initialize isolated publisher checkout: %v\n%s", err, output) } + writeReleaseTestFile(t, filepath.Join(workspace, ".goreleaser.yml"), config) + for _, args := range [][]string{ + {"add", ".goreleaser.yml"}, + {"-c", "user.name=Release Fixture", "-c", "user.email=fixture@example.invalid", "commit", "--quiet", "-m", "trusted release config"}, + } { + if output, err := exec.Command("git", append([]string{"-C", workspace}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("prepare trusted publisher config: %v\n%s", err, output) + } + } command := exec.Command("bash", "-c", verify.Run) command.Dir = workspace command.Env = append(os.Environ(), "RELEASE_INPUT_DIR="+root) @@ -467,8 +513,15 @@ func TestReleaseInputVerifierRejectsUnsafeArtifacts(t *testing.T) { if !test.valid && err == nil { t.Fatalf("unsafe release inputs were accepted:\n%s", output) } - if test.valid { + if !test.valid { for _, path := range releaseInputPaths { + if _, err := os.Lstat(filepath.Join(workspace, path)); !os.IsNotExist(err) { + t.Errorf("invalid input was copied before verification completed: %s, %v", path, err) + } + } + } + if test.valid { + for _, path := range inputs { if _, err := os.Stat(filepath.Join(workspace, path)); err != nil { t.Errorf("verified release input %s was not staged: %v", path, err) } @@ -579,6 +632,9 @@ func TestCISnapshotGeneratesInputsBeforeGoReleaser(t *testing.T) { if !strings.Contains(generate.Run, "@manpages -o man") { t.Fatal("CI snapshot no longer generates release manpages") } + if !strings.Contains(generate.Run, "go run ./scripts/render-linux-picker > completions/picker/openai.fish") { + t.Fatal("CI snapshot does not generate the Linux fish picker") + } for _, path := range releaseInputPaths { if !strings.Contains(generate.Run, "'/"+path+"'") { t.Errorf("CI must ignore only exact validated generated input %q in its ephemeral checkout", path) diff --git a/internal/terminalimage/kitty_output_session_unix_test.go b/internal/terminalimage/kitty_output_session_unix_test.go index 6ab2ab40..d9876ac5 100644 --- a/internal/terminalimage/kitty_output_session_unix_test.go +++ b/internal/terminalimage/kitty_output_session_unix_test.go @@ -207,6 +207,28 @@ func TestKittyCloseReapsStoppedIdleWorkers(t *testing.T) { if err := worker.command.Process.Signal(syscall.SIGSTOP); err != nil { t.Fatal(err) } + // Sending SIGSTOP does not confirm the worker has stopped. Closing its + // lifeline first can let it exit normally before the signal takes effect. + deadline := time.Now().Add(5 * time.Second) + for { + var status syscall.WaitStatus + pid, err := syscall.Wait4(worker.command.Process.Pid, &status, syscall.WUNTRACED|syscall.WNOHANG, nil) + if err != nil && !errors.Is(err, syscall.EINTR) { + t.Fatal(err) + } + if pid != 0 && err == nil { + // Go's BSD WaitStatus.Stopped excludes SIGSTOP. With no + // WCONTINUED requested, require its exact stop notification. + if status != syscall.WaitStatus(syscall.SIGSTOP<<8|0x7f) { + t.Fatalf("worker did not stop: %v", status) + } + break + } + if time.Now().After(deadline) { + t.Fatal("worker did not acknowledge SIGSTOP") + } + time.Sleep(time.Millisecond) + } } started := time.Now() if err := session.Close(); err == nil { diff --git a/scripts/generate-release-artifacts b/scripts/generate-release-artifacts index be499635..51661f81 100755 --- a/scripts/generate-release-artifacts +++ b/scripts/generate-release-artifacts @@ -24,8 +24,9 @@ for credential in "${credentials[@]}"; do fi done -mkdir -p completions +mkdir -p completions/picker for shell in bash zsh fish; do go run ./cmd/openai/main.go @completion "$shell" > "completions/openai.$shell" done +go run ./scripts/render-linux-picker > completions/picker/openai.fish go run ./cmd/openai/main.go @manpages -o man diff --git a/scripts/linux_fish_picker_test.go b/scripts/linux_fish_picker_test.go new file mode 100644 index 00000000..b9193155 --- /dev/null +++ b/scripts/linux_fish_picker_test.go @@ -0,0 +1,124 @@ +//go:build !windows + +package scripts_test + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestLinuxFishPickerPackageArtifacts(t *testing.T) { + goreleaser := verifiedReleaseExecutable(t) + config, err := os.ReadFile("../.goreleaser.yml") + if err != nil { + t.Fatal(err) + } + _, packaging, ok := strings.Cut(string(config), "nfpms:\n") + if !ok { + t.Fatal("missing package configuration") + } + packaging, _, ok = strings.Cut(packaging, "homebrew_casks:\n") + if !ok { + t.Fatal("missing package section boundary") + } + root := t.TempDir() + const picker = "# synthetic fish package integration\n" + files := map[string]string{ + "go.mod": "module example.com/package-fixture\n\ngo 1.25.0\n", + "main.go": "package main\nfunc main() {}\n", + ".gitignore": "dist/\n", + ".goreleaser.yml": "version: 2\nproject_name: openai\nrelease:\n disable: true\nbuilds:\n - main: .\n goos: [linux]\n goarch: [amd64]\n env: [CGO_ENABLED=0]\nnfpms:\n" + packaging, + "man/man1/openai.1.gz": "synthetic manual", + "completions/picker/openai.fish": picker, + } + for relative, data := range files { + path := filepath.Join(root, relative) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatal(err) + } + } + runHistoricalFixtureCommand(t, root, "git", "init", "--quiet") + runHistoricalFixtureCommand(t, root, "git", "remote", "add", "origin", "https://github.com/example/package-fixture.git") + runHistoricalFixtureCommand(t, root, "git", "add", ".") + runHistoricalFixtureCommand(t, root, "git", "-c", "user.name=Package Fixture", "-c", "user.email=fixture@example.invalid", "commit", "--quiet", "-m", "synthetic package fixture") + command := exec.Command(goreleaser, "release", "--snapshot", "--clean", "--skip=publish") + command.Dir = root + for _, variable := range os.Environ() { + name, _, _ := strings.Cut(variable, "=") + if !slices.Contains(releaseCredentials, name) { + command.Env = append(command.Env, variable) + } + } + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("synthetic package build failed: %v\n%s", err, output) + } + for _, pattern := range []string{"*.apk", "*.deb", "*.rpm", "*.pkg.tar.zst"} { + paths, err := filepath.Glob(filepath.Join(root, "dist", pattern)) + if err != nil || len(paths) != 1 { + t.Fatalf("package %s = %v, error %v; expected one Linux amd64 artifact", pattern, paths, err) + } + } + // APK uses separate gzip/tar members for package control scripts and files. + // Inspect actual nFPM output, including ownership and absence of install hooks. + apks, _ := filepath.Glob(filepath.Join(root, "dist", "*.apk")) + archive, err := os.ReadFile(apks[0]) + if err != nil { + t.Fatal(err) + } + archiveBuffer := bytes.NewBuffer(archive) + installed := 0 + for archiveBuffer.Len() > 0 { + compressed, err := gzip.NewReader(archiveBuffer) + if err != nil { + t.Fatal(err) + } + compressed.Multistream(false) + reader := tar.NewReader(compressed) + for { + header, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + if strings.Contains(header.Name, "install") || strings.Contains(header.Name, "upgrade") || strings.Contains(header.Name, "deinstall") { + t.Errorf("unexpected package lifecycle hook: %s", header.Name) + } + if strings.HasPrefix(header.Name, "usr/share/openai/") { + t.Errorf("unnecessary secondary picker file: %s", header.Name) + } + if header.Name != "usr/share/fish/vendor_conf.d/openai-picker.fish" { + continue + } + installed++ + data, err := io.ReadAll(reader) + if err != nil || string(data) != picker { + t.Fatalf("packaged picker = %q, error = %v", data, err) + } + if header.Typeflag != tar.TypeReg || header.Mode != 0644 || header.Uid != 0 || header.Gid != 0 { + t.Errorf("picker must be a root-owned regular file with mode 0644: %+v", header) + } + } + if _, err := io.Copy(io.Discard, compressed); err != nil { + t.Fatal(err) + } + if err := compressed.Close(); err != nil { + t.Fatal(err) + } + } + if installed != 1 { + t.Fatalf("installed picker files = %d, want exactly one", installed) + } +} diff --git a/scripts/release_artifacts_test.go b/scripts/release_artifacts_test.go index 72d798b2..266a0274 100644 --- a/scripts/release_artifacts_test.go +++ b/scripts/release_artifacts_test.go @@ -45,6 +45,7 @@ func TestGenerateReleaseArtifacts(t *testing.T) { "run ./cmd/openai/main.go @completion bash", "run ./cmd/openai/main.go @completion zsh", "run ./cmd/openai/main.go @completion fish", + "run ./scripts/render-linux-picker", "run ./cmd/openai/main.go @manpages -o man", } if !slices.Equal(gotCalls, wantCalls) { @@ -60,6 +61,9 @@ func TestGenerateReleaseArtifacts(t *testing.T) { t.Errorf("%s completion = %q, want %q", shell, got, want) } } + if data, err := os.ReadFile(filepath.Join(root, "completions", "picker", "openai.fish")); err != nil || string(data) != "synthetic fish picker\n" { + t.Fatalf("Linux fish picker output = %q, error = %v", data, err) + } if _, err := os.Stat(filepath.Join(root, "man", "man1", "openai.1.gz")); err != nil { t.Errorf("man page was not generated at its existing archive path: %v", err) } @@ -89,25 +93,28 @@ func TestGenerateReleaseArtifactsRejectsEveryCredential(t *testing.T) { } func TestGenerateReleaseArtifactsStopsAfterFailure(t *testing.T) { - _, logPath, command := newReleaseArtifactFixture(t) - command.Env = append(command.Env, "RELEASE_GO_FAILURE=run ./cmd/openai/main.go @completion zsh") - - output, err := command.CombinedOutput() - if err == nil { - t.Fatalf("generator accepted failed completion generation; output:\n%s", output) - } - - calls, err := os.ReadFile(logPath) - if err != nil { - t.Fatal(err) - } - gotCalls := strings.Split(strings.TrimSpace(string(calls)), "\n") - wantCalls := []string{ + calls := []string{ "run ./cmd/openai/main.go @completion bash", "run ./cmd/openai/main.go @completion zsh", - } - if !slices.Equal(gotCalls, wantCalls) { - t.Errorf("Go calls after failure = %v, want %v", gotCalls, wantCalls) + "run ./cmd/openai/main.go @completion fish", + "run ./scripts/render-linux-picker", + } + for _, index := range []int{1, 3} { + t.Run(calls[index], func(t *testing.T) { + _, logPath, command := newReleaseArtifactFixture(t) + command.Env = append(command.Env, "RELEASE_GO_FAILURE="+calls[index]) + if output, err := command.CombinedOutput(); err == nil { + t.Fatalf("generator accepted failed artifact generation; output:\n%s", output) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + got, want := strings.Split(strings.TrimSpace(string(data)), "\n"), calls[:index+1] + if !slices.Equal(got, want) { + t.Errorf("Go calls after failure = %v, want %v", got, want) + } + }) } } @@ -124,9 +131,10 @@ func TestLocalReleasePreflight(t *testing.T) { } root := t.TempDir() files := map[string]string{ - ".gitignore": string(ignore), - "scripts/generate-release-artifacts": string(generator), - "go.mod": "module example.com/local-release\n\ngo 1.25.0\n", + ".gitignore": string(ignore), + "scripts/generate-release-artifacts": string(generator), + "go.mod": "module example.com/local-release\n\ngo 1.25.0\n", + "scripts/render-linux-picker/main.go": "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"synthetic fish picker\") }\n", "cmd/openai/main.go": `package main import ( @@ -212,8 +220,10 @@ archives: for _, unexpected := range []string{ "completions/unexpected.sh", + "completions/picker/unexpected.fish", "man/man1/unexpected.1.gz", "nested/completions/unrelated.txt", + "nested/completions/picker/openai.fish", } { path := filepath.Join(root, filepath.FromSlash(unexpected)) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { @@ -397,8 +407,8 @@ func TestCIReleasePreflightUsesVerifiedExecutable(t *testing.T) { if !(install < preflight && preflight < generate) { t.Errorf("CI release steps are out of order: install=%d preflight=%d generate=%d", install, preflight, generate) } - if !strings.Contains(job.Steps[preflight].Run, "Test(Local|Historical)ReleasePreflight") { - t.Errorf("CI does not execute both real local and historical-tag release preflights") + if !strings.Contains(job.Steps[preflight].Run, "Test((Local|Historical)ReleasePreflight|LinuxFishPickerPackageArtifacts)") { + t.Errorf("CI does not execute real local/historical preflights and Linux fish packaging") } if got, want := job.Steps[preflight].Env["GORELEASER_EXECUTABLE"], "${{ steps.goreleaser.outputs.executable }}"; got != want { t.Errorf("CI release preflights executable = %q, want reviewed verified binary %q", got, want) @@ -492,6 +502,10 @@ printf '%s\n' "$*" >> "$RELEASE_GO_LOG" if [[ "$*" == "${RELEASE_GO_FAILURE:-}" ]]; then exit 23 fi +if [[ "$2" == "./scripts/render-linux-picker" ]]; then + printf 'synthetic fish picker\n' + exit 0 +fi case "$3" in @completion) printf 'completion:%s\n' "$4" ;; @manpages) diff --git a/scripts/render-linux-picker/main.go b/scripts/render-linux-picker/main.go new file mode 100644 index 00000000..97cc4f81 --- /dev/null +++ b/scripts/render-linux-picker/main.go @@ -0,0 +1,20 @@ +// Render the package-owned fish startup file without initializing the CLI. +package main + +import ( + "fmt" + "os" + + "github.com/openai/openai-cli/internal/autocomplete" +) + +func main() { + content, err := autocomplete.FishPackagePickerScript() + if err == nil { + _, err = os.Stdout.Write(content) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +}