From 50a2d887fd684db3d0110ade7ba24b4fa7e81a60 Mon Sep 17 00:00:00 2001 From: Emanuel Casco Date: Tue, 5 May 2026 10:13:15 +0200 Subject: [PATCH] Prepare automated releases --- .gitignore | 7 + LICENSE | 21 ++ Makefile | 22 ++ README.md | 4 +- cmd/ocgo/detach_unix.go | 9 + cmd/ocgo/detach_windows.go | 7 + cmd/ocgo/main.go | 601 +++++++++++++++++++++++++++++++++++++ go.mod | 10 + go.sum | 10 + scripts/release.sh | 161 ++++++++++ 10 files changed, 850 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 cmd/ocgo/detach_unix.go create mode 100644 cmd/ocgo/detach_windows.go create mode 100644 cmd/ocgo/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100755 scripts/release.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e03304 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +dist/ +*.exe +*.pid +.env +.tmp/ +oc-go-cc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dc80d6a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Emanuel Casco + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b8b535f --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +.PHONY: build run test clean install release + +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) + +build: + go build -ldflags "-X main.version=$(VERSION)" -o bin/ocgo ./cmd/ocgo + +run: + go run ./cmd/ocgo + +test: + go test ./... + +clean: + rm -rf bin + +install: build + install -m 0755 bin/ocgo $(HOME)/go/bin/ocgo + +release: + @[ -n "$(TAG)" ] || (echo "Usage: make release TAG=v0.1.0" && exit 1) + ./scripts/release.sh "$(TAG)" diff --git a/README.md b/README.md index 4f0e625..87e354a 100644 --- a/README.md +++ b/README.md @@ -226,10 +226,10 @@ gh auth login Release a new version: ```bash -HOMEBREW_TAP_REPO=YOUR_GITHUB_USER/homebrew-tap make release TAG=v0.1.0 +make release TAG=v0.1.0 ``` -Optionally set `GITHUB_REPOSITORY=owner/repo` if the script cannot infer it from `origin`. +By default, releases are published to `emanuelcasco/ocgo` and the Homebrew formula is pushed to `emanuelcasco/homebrew-tap`. You can override those with `GITHUB_REPOSITORY=owner/repo` and `HOMEBREW_TAP_REPO=owner/homebrew-tap`. The script builds macOS/Linux `amd64` and `arm64` archives, uploads them to GitHub Releases, and commits `Formula/ocgo.rb` to the tap repo. diff --git a/cmd/ocgo/detach_unix.go b/cmd/ocgo/detach_unix.go new file mode 100644 index 0000000..56f1b57 --- /dev/null +++ b/cmd/ocgo/detach_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package main + +import "syscall" + +func detachedAttrs() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setsid: true} +} diff --git a/cmd/ocgo/detach_windows.go b/cmd/ocgo/detach_windows.go new file mode 100644 index 0000000..6416c94 --- /dev/null +++ b/cmd/ocgo/detach_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package main + +import "syscall" + +func detachedAttrs() *syscall.SysProcAttr { return nil } diff --git a/cmd/ocgo/main.go b/cmd/ocgo/main.go new file mode 100644 index 0000000..3817839 --- /dev/null +++ b/cmd/ocgo/main.go @@ -0,0 +1,601 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" +) + +const ( + appName = "ocgo" + defaultHost = "127.0.0.1" + defaultPort = 3456 + openAIURL = "https://opencode.ai/zen/go/v1/chat/completions" +) + +var version = "dev" + +type Config struct { + APIKey string `json:"api_key"` + Host string `json:"host"` + Port int `json:"port"` +} + +type AnthropicRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System json.RawMessage `json:"system,omitempty"` + Messages []AMessage `json:"messages"` + Stream bool `json:"stream,omitempty"` + Tools []ATool `json:"tools,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` +} + +type AMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +type ATool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema json.RawMessage `json:"input_schema,omitempty"` +} + +type OAIRequest struct { + Model string `json:"model"` + Messages []OAIMessage `json:"messages"` + Stream bool `json:"stream,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Tools []OAITool `json:"tools,omitempty"` +} + +type OAIMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []OAIToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type OAITool struct { + Type string `json:"type"` + Function OAIFunction `json:"function"` +} + +type OAIFunction struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` +} + +type OAIToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function OAICallFunction `json:"function"` +} + +type OAICallFunction struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +func main() { + root := &cobra.Command{Use: appName, Short: "Run Claude Code with OpenCode Go", Version: version} + root.AddCommand(setupCmd(), listCmd(), launchCmd(), serveCmd(), stopCmd(), statusCmd()) + if err := root.Execute(); err != nil { + os.Exit(1) + } +} + +func setupCmd() *cobra.Command { + var key string + cmd := &cobra.Command{ + Use: "setup", + Short: "Save your OpenCode Go API key", + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(key) == "" { + key = os.Getenv("OCGO_API_KEY") + } + if strings.TrimSpace(key) == "" { + fmt.Print("OpenCode Go API key: ") + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && line == "" { + return err + } + key = line + } + cfg := Config{APIKey: strings.TrimSpace(key), Host: defaultHost, Port: defaultPort} + if cfg.APIKey == "" { + return errors.New("API key cannot be empty") + } + return saveConfig(cfg) + }, + } + cmd.Flags().StringVar(&key, "api-key", "", "OpenCode Go API key") + return cmd +} + +func listCmd() *cobra.Command { + return &cobra.Command{Use: "list", Aliases: []string{"ls", "models"}, Short: "List OpenCode Go models", Run: func(cmd *cobra.Command, args []string) { + fmt.Println("OpenCode Go models:") + for _, m := range []string{"glm-5.1", "glm-5", "kimi-k2.6", "kimi-k2.5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "deepseek-v4-pro", "deepseek-v4-flash", "qwen3.6-plus", "qwen3.5-plus"} { + fmt.Printf(" %s\n", m) + } + }} +} + +func launchCmd() *cobra.Command { + var model string + var yes bool + cmd := &cobra.Command{Use: "launch", Short: "Launch tools through ocgo"} + claude := &cobra.Command{Use: "claude [-- claude args...]", Short: "Launch Claude Code through OpenCode Go", Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + base := fmt.Sprintf("http://%s:%d", cfg.Host, cfg.Port) + serverCmd, err := startLaunchServer(base) + if err != nil { + return err + } + if serverCmd != nil { + defer stopManagedServer(serverCmd) + } + claudeArgs := append([]string{}, args...) + if yes { + claudeArgs = append([]string{"--dangerously-skip-permissions"}, claudeArgs...) + } + bin, err := exec.LookPath("claude") + if err != nil { + return fmt.Errorf("claude not found in PATH: %w", err) + } + c := exec.Command(bin, claudeArgs...) + c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr + c.Env = append(os.Environ(), "ANTHROPIC_BASE_URL="+base, "ANTHROPIC_AUTH_TOKEN=unused") + if model != "" { + c.Env = append(c.Env, "ANTHROPIC_MODEL="+model, "ANTHROPIC_SMALL_FAST_MODEL="+model) + } + return c.Run() + }} + claude.Flags().StringVar(&model, "model", "", "OpenCode Go model ID") + claude.Flags().BoolVar(&yes, "yes", false, "Allow Claude Code to skip permission prompts") + cmd.AddCommand(claude) + return cmd +} + +func serveCmd() *cobra.Command { + var background bool + cmd := &cobra.Command{Use: "serve", Short: "Start local Anthropic-compatible proxy", RunE: func(cmd *cobra.Command, args []string) error { + if background { + return startBackground() + } + cfg, err := loadConfig() + if err != nil { + return err + } + return runServer(cfg) + }} + cmd.Flags().BoolVarP(&background, "background", "b", false, "Run proxy in the background") + return cmd +} + +func stopCmd() *cobra.Command { + return &cobra.Command{Use: "stop", Short: "Stop background proxy", RunE: func(cmd *cobra.Command, args []string) error { + pid, err := readPID() + if err != nil { + cfg, cfgErr := loadConfig() + if cfgErr != nil { + return errors.New("proxy is not running") + } + pid, err = findListenerPID(cfg.Port) + if err != nil { + return errors.New("proxy is not running") + } + } + p, err := os.FindProcess(pid) + if err != nil { + return err + } + _ = os.Remove(pidFile()) + if err := p.Kill(); err != nil { + return err + } + fmt.Printf("Stopped proxy process %d\n", pid) + return nil + }} +} + +func statusCmd() *cobra.Command { + return &cobra.Command{Use: "status", Short: "Show proxy status", Run: func(cmd *cobra.Command, args []string) { + cfg, err := loadConfig() + if err != nil || !healthy(fmt.Sprintf("http://%s:%d", cfg.Host, cfg.Port)) { + fmt.Println("Proxy is not running") + return + } + if pid, err := readPID(); err == nil { + fmt.Printf("Proxy is running on %s:%d (PID %d)\n", cfg.Host, cfg.Port, pid) + return + } + fmt.Printf("Proxy is running on %s:%d (no ocgo PID file)\n", cfg.Host, cfg.Port) + }} +} + +func runServer(cfg Config) error { + if err := os.MkdirAll(configDir(), 0755); err == nil { + _ = os.WriteFile(pidFile(), []byte(fmt.Sprint(os.Getpid())), 0644) + defer os.Remove(pidFile()) + } + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok\n")) }) + mux.HandleFunc("/v1/messages/count_tokens", countTokens) + mux.HandleFunc("/v1/messages", func(w http.ResponseWriter, r *http.Request) { proxyMessages(w, r, cfg) }) + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + fmt.Printf("ocgo proxy listening on http://%s\n", addr) + return http.ListenAndServe(addr, mux) +} + +func proxyMessages(w http.ResponseWriter, r *http.Request, cfg Config) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var ar AnthropicRequest + if err := json.NewDecoder(r.Body).Decode(&ar); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + or := convertRequest(ar) + body, _ := json.Marshal(or) + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, openAIURL, bytes.NewReader(body)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + req.Header.Set("Content-Type", "application/json") + resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + return + } + if ar.Stream { + streamAnthropic(w, resp.Body, or.Model) + return + } + writeAnthropicResponse(w, resp.Body, or.Model) +} + +func convertRequest(ar AnthropicRequest) OAIRequest { + model := ar.Model + if model == "" || strings.HasPrefix(model, "claude-") { + model = "kimi-k2.6" + } + out := OAIRequest{Model: model, Stream: ar.Stream, MaxTokens: ar.MaxTokens, Temperature: ar.Temperature, TopP: ar.TopP} + if sys := systemText(ar.System); sys != "" { + out.Messages = append(out.Messages, OAIMessage{Role: "system", Content: sys}) + } + for _, m := range ar.Messages { + out.Messages = append(out.Messages, contentToOpenAI(m)...) + } + for _, t := range ar.Tools { + out.Tools = append(out.Tools, OAITool{Type: "function", Function: OAIFunction{Name: t.Name, Description: t.Description, Parameters: t.InputSchema}}) + } + return out +} + +func contentToOpenAI(m AMessage) []OAIMessage { + var s string + if json.Unmarshal(m.Content, &s) == nil { + return []OAIMessage{{Role: m.Role, Content: s}} + } + var blocks []map[string]json.RawMessage + if json.Unmarshal(m.Content, &blocks) != nil { + return []OAIMessage{{Role: m.Role, Content: string(m.Content)}} + } + var text strings.Builder + var calls []OAIToolCall + var toolMsgs []OAIMessage + for _, b := range blocks { + var typ string + _ = json.Unmarshal(b["type"], &typ) + switch typ { + case "text": + var v string + _ = json.Unmarshal(b["text"], &v) + text.WriteString(v) + case "tool_use": + var id, name string + _ = json.Unmarshal(b["id"], &id) + _ = json.Unmarshal(b["name"], &name) + args := "{}" + if raw := b["input"]; len(raw) > 0 { + args = string(raw) + } + calls = append(calls, OAIToolCall{ID: id, Type: "function", Function: OAICallFunction{Name: name, Arguments: args}}) + case "tool_result": + var id string + _ = json.Unmarshal(b["tool_use_id"], &id) + toolMsgs = append(toolMsgs, OAIMessage{Role: "tool", ToolCallID: id, Content: blockText(b["content"])}) + } + } + if len(calls) > 0 { + return []OAIMessage{{Role: "assistant", Content: text.String(), ToolCalls: calls}} + } + if len(toolMsgs) > 0 { + return toolMsgs + } + return []OAIMessage{{Role: m.Role, Content: text.String()}} +} + +func systemText(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + return blockText(raw) +} + +func blockText(raw json.RawMessage) string { + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + var blocks []map[string]json.RawMessage + if json.Unmarshal(raw, &blocks) != nil { + return string(raw) + } + var b strings.Builder + for _, x := range blocks { + var t string + if json.Unmarshal(x["text"], &t) == nil { + b.WriteString(t) + } + } + return b.String() +} + +func streamAnthropic(w http.ResponseWriter, body io.Reader, model string) { + w.Header().Set("Content-Type", "text/event-stream") + flusher, _ := w.(http.Flusher) + fmt.Fprintf(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"ocgo\",\"type\":\"message\",\"role\":\"assistant\",\"model\":%q,\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}\n\n", model) + fmt.Fprint(w, "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n") + if flusher != nil { + flusher.Flush() + } + s := bufio.NewScanner(body) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "[DONE]" { + break + } + if delta := openAITextDelta([]byte(data)); delta != "" { + b, _ := json.Marshal(delta) + fmt.Fprintf(w, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":%s}}\n\n", b) + if flusher != nil { + flusher.Flush() + } + } + } + fmt.Fprint(w, "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n") + fmt.Fprint(w, "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":0}}\n\n") + fmt.Fprint(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n") +} + +func openAITextDelta(data []byte) string { + var v struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + _ = json.Unmarshal(data, &v) + if len(v.Choices) == 0 { + return "" + } + return v.Choices[0].Delta.Content +} + +func writeAnthropicResponse(w http.ResponseWriter, body io.Reader, model string) { + var v struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + _ = json.NewDecoder(body).Decode(&v) + text := "" + if len(v.Choices) > 0 { + text = v.Choices[0].Message.Content + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "ocgo", "type": "message", "role": "assistant", "model": model, "content": []map[string]string{{"type": "text", "text": text}}, "stop_reason": "end_turn", "usage": map[string]int{"input_tokens": 0, "output_tokens": 0}}) +} + +func countTokens(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]int{"input_tokens": 0}) +} + +func ensureServer(base string) error { + if healthy(base) { + return nil + } + if err := startBackground(); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for ctx.Err() == nil { + if healthy(base) { + return nil + } + time.Sleep(200 * time.Millisecond) + } + return errors.New("proxy did not start") +} + +func startLaunchServer(base string) (*exec.Cmd, error) { + if healthy(base) { + return nil, nil + } + cmd, err := startServerProcess(false) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for ctx.Err() == nil { + if healthy(base) { + return cmd, nil + } + time.Sleep(200 * time.Millisecond) + } + stopManagedServer(cmd) + return nil, errors.New("proxy did not start") +} + +func stopManagedServer(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + _ = os.Remove(pidFile()) +} + +func healthy(base string) bool { + c := http.Client{Timeout: 500 * time.Millisecond} + resp, err := c.Get(base + "/health") + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == 200 +} + +func startBackground() error { + _, err := startServerProcess(true) + return err +} + +func startServerProcess(detached bool) (*exec.Cmd, error) { + bin, err := os.Executable() + if err != nil { + return nil, err + } + if err := os.MkdirAll(configDir(), 0755); err != nil { + return nil, err + } + args := []string{"serve"} + cmd := exec.Command(bin, args...) + logf, err := os.OpenFile(filepath.Join(configDir(), "ocgo.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return nil, err + } + cmd.Stdout, cmd.Stderr = logf, logf + cmd.Stdin = nil + if detached && runtime.GOOS != "windows" { + cmd.SysProcAttr = detachedAttrs() + } + if err := cmd.Start(); err != nil { + _ = logf.Close() + return nil, err + } + return cmd, nil +} + +func configDir() string { home, _ := os.UserHomeDir(); return filepath.Join(home, ".config", "ocgo") } +func configFile() string { return filepath.Join(configDir(), "config.json") } +func pidFile() string { return filepath.Join(configDir(), "ocgo.pid") } + +func saveConfig(cfg Config) error { + if err := os.MkdirAll(configDir(), 0755); err != nil { + return err + } + b, _ := json.MarshalIndent(cfg, "", " ") + if err := os.WriteFile(configFile(), append(b, '\n'), 0600); err != nil { + return err + } + fmt.Printf("Saved config to %s\n", configFile()) + return nil +} + +func loadConfig() (Config, error) { + cfg := Config{Host: defaultHost, Port: defaultPort, APIKey: os.Getenv("OCGO_API_KEY")} + b, err := os.ReadFile(configFile()) + if err == nil { + _ = json.Unmarshal(b, &cfg) + } + if cfg.APIKey == "" { + return cfg, errors.New("missing API key; run: ocgo setup") + } + if cfg.Host == "" { + cfg.Host = defaultHost + } + if cfg.Port == 0 { + cfg.Port = defaultPort + } + return cfg, nil +} + +func readPID() (int, error) { + b, err := os.ReadFile(pidFile()) + if err != nil { + return 0, err + } + var pid int + _, err = fmt.Sscan(string(b), &pid) + return pid, err +} + +func findListenerPID(port int) (int, error) { + if port == 0 { + return 0, errors.New("missing port") + } + out, err := exec.Command("lsof", "-nP", "-tiTCP:"+strconv.Itoa(port), "-sTCP:LISTEN").Output() + if err != nil { + return 0, err + } + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + pid, err := strconv.Atoi(line) + if err == nil && pid > 0 { + return pid, nil + } + } + return 0, errors.New("no listener found") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..afaa9a1 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module ocgo + +go 1.22 + +require github.com/spf13/cobra v1.8.1 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..912390a --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..b425dfb --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_NAME="${APP_NAME:-ocgo}" +CMD_PATH="${CMD_PATH:-./cmd/ocgo}" +TAG="${1:-${TAG:-}}" + +if [[ -z "$TAG" ]]; then + echo "Usage: $0 v0.1.0" + echo " or: TAG=v0.1.0 make release" + exit 1 +fi + +VERSION="${TAG#v}" +REPO="${GITHUB_REPOSITORY:-emanuelcasco/ocgo}" +if [[ -z "$REPO" ]]; then + origin_url="$(git config --get remote.origin.url || true)" + if [[ "$origin_url" =~ github.com[:/]([^/]+)/([^/.]+)(\.git)?$ ]]; then + REPO="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" + else + echo "Set GITHUB_REPOSITORY=owner/repo, or configure a GitHub origin remote." + exit 1 + fi +fi + +HOMEBREW_TAP_REPO="${HOMEBREW_TAP_REPO:-emanuelcasco/homebrew-tap}" + +if ! command -v gh >/dev/null 2>&1; then + echo "GitHub CLI is required: brew install gh && gh auth login" + exit 1 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "GitHub CLI is not authenticated. Run: gh auth login" + exit 1 +fi + +if ! command -v go >/dev/null 2>&1; then + echo "Go is required." + exit 1 +fi + +if ! git diff --quiet || ! git diff --cached --quiet; then + echo "Working tree has uncommitted changes. Commit or stash them first." + exit 1 +fi + +# Verify the project builds/tests before tagging. +go test ./... + +if ! git rev-parse "$TAG" >/dev/null 2>&1; then + git tag -a "$TAG" -m "$TAG" +fi + +git push origin "$TAG" + +DIST_DIR="dist" +rm -rf "$DIST_DIR" +mkdir -p "$DIST_DIR" + +build_one() { + local goos="$1" + local goarch="$2" + local arch_name="$goarch" + if [[ "$goarch" == "amd64" ]]; then + arch_name="x86_64" + fi + + local dir="$DIST_DIR/${APP_NAME}_${VERSION}_${goos}_${arch_name}" + mkdir -p "$dir" + + local bin="$APP_NAME" + if [[ "$goos" == "windows" ]]; then + bin="$APP_NAME.exe" + fi + + echo "Building $goos/$goarch..." + CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \ + go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$dir/$bin" "$CMD_PATH" + + cp README.md "$dir/" 2>/dev/null || true + cp LICENSE "$dir/" 2>/dev/null || true + + tar -C "$DIST_DIR" -czf "$dir.tar.gz" "$(basename "$dir")" + rm -rf "$dir" +} + +build_one darwin amd64 +build_one darwin arm64 +build_one linux amd64 +build_one linux arm64 + +( + cd "$DIST_DIR" + shasum -a 256 *.tar.gz > checksums.txt +) + +if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + echo "GitHub release $TAG already exists; uploading artifacts with --clobber." + gh release upload "$TAG" "$DIST_DIR"/*.tar.gz "$DIST_DIR/checksums.txt" --repo "$REPO" --clobber +else + gh release create "$TAG" "$DIST_DIR"/*.tar.gz "$DIST_DIR/checksums.txt" \ + --repo "$REPO" \ + --title "$TAG" \ + --generate-notes +fi + +# Update Homebrew tap formula to install the macOS artifacts. +TAP_TMP="$(mktemp -d)" +trap 'rm -rf "$TAP_TMP"' EXIT + +gh repo clone "$HOMEBREW_TAP_REPO" "$TAP_TMP" -- --quiet +mkdir -p "$TAP_TMP/Formula" + +DARWIN_ARM_SHA="$(shasum -a 256 "$DIST_DIR/${APP_NAME}_${VERSION}_darwin_arm64.tar.gz" | awk '{print $1}')" +DARWIN_AMD_SHA="$(shasum -a 256 "$DIST_DIR/${APP_NAME}_${VERSION}_darwin_x86_64.tar.gz" | awk '{print $1}')" + +cat > "$TAP_TMP/Formula/${APP_NAME}.rb" <