diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56695f7f..a2b26370 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,3 +50,15 @@ jobs: with: globs: '**/*.md' continue-on-error: true + + check-paperclip: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Need full history for PR range + - name: Check for Paperclip references in commit messages + run: ./scripts/check-paperclip.sh + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_BASE_REF: ${{ github.base_ref }} diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000..49d5213e --- /dev/null +++ b/.mailmap @@ -0,0 +1 @@ +CEO CEO diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c2056e1f..6dffd8fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,8 +38,6 @@ Add rotating refresh tokens Using a rotating refresh token strategy prevents replay attacks and gives us a clean theft-detection signal. See ADR-0003. - -Co-Authored-By: Paperclip ``` Bad: diff --git a/cmd/hatch/cli.go b/cmd/hatch/cli.go new file mode 100644 index 00000000..15ca7b4e --- /dev/null +++ b/cmd/hatch/cli.go @@ -0,0 +1,443 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +// CLI commands: +// hatch serve Start the server (default if no subcommand) +// hatch capture Send a request to an endpoint and store it +// hatch inspect Fetch requests as JSON +// hatch search Search captured traffic +// hatch replay Replay a request +// hatch mock set Configure mock +// hatch doc generate Output OpenAPI spec + +// cliMain parses os.Args and dispatches to the appropriate CLI command. +// Returns true if a CLI command was handled, false if the caller should +// start the server (no subcommand or "serve"). +func cliMain() bool { + if len(os.Args) < 2 { + return false // no subcommand → start server + } + + cmd := os.Args[1] + switch cmd { + case "serve": + return false // explicit serve → start server + case "capture": + cmdCapture(os.Args[2:]) + case "inspect": + cmdInspect(os.Args[2:]) + case "search": + cmdSearch(os.Args[2:]) + case "replay": + cmdReplay(os.Args[2:]) + case "mock": + cmdMock(os.Args[2:]) + case "doc": + cmdDoc(os.Args[2:]) + case "help", "-h", "--help": + printUsage() + case "version": + fmt.Println("hatch v0.1.0") + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd) + printUsage() + os.Exit(1) + } + return true +} + +func printUsage() { + fmt.Fprintf(os.Stderr, `Usage: hatch [options] + +Commands: + serve Start the server (default) + capture Send a request to an endpoint and store it + inspect Fetch requests as JSON + search Search captured traffic + replay Replay a request + mock set Configure mock response + doc generate Output OpenAPI spec + version Print version + +Run 'hatch -h' for command-specific help. +`) +} + +// serverURL returns the Hatch server URL from HATCH_URL env or default. +func serverURL() string { + if u := os.Getenv("HATCH_URL"); u != "" { + return strings.TrimRight(u, "/") + } + return "http://localhost:8080" +} + +// apiRequest is a helper to make API requests. +func apiRequest(method, path string, body interface{}) ([]byte, int, error) { + url := serverURL() + path + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, 0, fmt.Errorf("marshal body: %w", err) + } + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return nil, 0, fmt.Errorf("create request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, fmt.Errorf("read body: %w", err) + } + + return data, resp.StatusCode, nil +} + +// printJSON pretty-prints JSON data. +func printJSON(data []byte) { + var buf bytes.Buffer + if err := json.Indent(&buf, data, "", " "); err != nil { + // Not valid JSON, print as-is + fmt.Println(string(data)) + return + } + fmt.Println(buf.String()) +} + +// cmdCapture handles: hatch capture [-method METHOD] [-body BODY] [-header KEY:VALUE] +func cmdCapture(args []string) { + fs := flag.NewFlagSet("capture", flag.ExitOnError) + method := fs.String("method", "POST", "HTTP method") + body := fs.String("body", "", "Request body (JSON string)") + var headers multiFlag + fs.Var(&headers, "header", "Header in KEY:VALUE format (repeatable)") + fs.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: hatch capture [options]\n\nSend a request to an endpoint and store it.\n\nOptions:\n") + fs.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nExamples:\n") + fmt.Fprintf(os.Stderr, " hatch capture https://api.example.com/webhook\n") + fmt.Fprintf(os.Stderr, " hatch capture /my-endpoint -method POST -body '{\"event\":\"test\"}'\n") + fmt.Fprintf(os.Stderr, " hatch capture /ep -header 'Content-Type:application/json' -header 'X-Custom:test'\n") + } + fs.Parse(args) + + if fs.NArg() < 1 { + fmt.Fprintf(os.Stderr, "Error: URL is required\n\n") + fs.Usage() + os.Exit(1) + } + + url := fs.Arg(0) + + // Parse headers into map. + headerMap := make(map[string]string) + for _, h := range headers { + parts := strings.SplitN(h, ":", 2) + if len(parts) != 2 { + fmt.Fprintf(os.Stderr, "Error: invalid header format %q (expected KEY:VALUE)\n", h) + os.Exit(1) + } + headerMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + + // Build the API request body. + apiBody := map[string]interface{}{ + "method": *method, + "path": "/", + } + if *body != "" { + apiBody["body"] = *body + } + if len(headerMap) > 0 { + apiBody["headers"] = headerMap + } + + // Determine endpoint ID from URL. + // If it looks like a full URL, extract the path. Otherwise use as-is. + endpointID := strings.TrimLeft(url, "/") + if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { + // Extract path from URL. + if idx := strings.Index(url[8:], "/"); idx != -1 { + endpointID = url[8+idx+1:] + } else { + endpointID = "default" + } + } + endpointID = strings.TrimRight(endpointID, "/") + if endpointID == "" { + endpointID = "default" + } + + path := fmt.Sprintf("/v1/endpoints/%s/requests", endpointID) + data, status, err := apiRequest("POST", path, apiBody) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if status >= 400 { + fmt.Fprintf(os.Stderr, "Error (HTTP %d): %s\n", status, string(data)) + os.Exit(1) + } + + fmt.Println("Request captured successfully:") + printJSON(data) +} + +// cmdInspect handles: hatch inspect [-limit N] +func cmdInspect(args []string) { + fs := flag.NewFlagSet("inspect", flag.ExitOnError) + limit := fs.Int("limit", 100, "Maximum number of requests to return") + fs.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: hatch inspect [options]\n\nFetch requests as JSON.\n\nOptions:\n") + fs.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nExamples:\n") + fmt.Fprintf(os.Stderr, " hatch inspect my-webhook\n") + fmt.Fprintf(os.Stderr, " hatch inspect my-webhook -limit 10\n") + } + fs.Parse(args) + + if fs.NArg() < 1 { + fmt.Fprintf(os.Stderr, "Error: endpoint ID is required\n\n") + fs.Usage() + os.Exit(1) + } + + endpointID := fs.Arg(0) + path := fmt.Sprintf("/v1/endpoints/%s/requests?limit=%d", endpointID, *limit) + data, status, err := apiRequest("GET", path, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if status >= 400 { + fmt.Fprintf(os.Stderr, "Error (HTTP %d): %s\n", status, string(data)) + os.Exit(1) + } + + printJSON(data) +} + +// cmdSearch handles: hatch search -query [-limit N] +func cmdSearch(args []string) { + fs := flag.NewFlagSet("search", flag.ExitOnError) + query := fs.String("query", "", "Search query") + limit := fs.Int("limit", 100, "Maximum number of results") + fs.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: hatch search [options]\n\nSearch captured traffic.\n\nOptions:\n") + fs.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nExamples:\n") + fmt.Fprintf(os.Stderr, " hatch search my-webhook -query 'status:500'\n") + fmt.Fprintf(os.Stderr, " hatch search my-webhook -query 'POST' -limit 5\n") + } + fs.Parse(args) + + if fs.NArg() < 1 { + fmt.Fprintf(os.Stderr, "Error: endpoint ID is required\n\n") + fs.Usage() + os.Exit(1) + } + + if *query == "" { + fmt.Fprintf(os.Stderr, "Error: -query is required\n\n") + fs.Usage() + os.Exit(1) + } + + endpointID := fs.Arg(0) + path := fmt.Sprintf("/v1/endpoints/%s/requests?q=%s&limit=%d", endpointID, *query, *limit) + data, status, err := apiRequest("GET", path, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if status >= 400 { + fmt.Fprintf(os.Stderr, "Error (HTTP %d): %s\n", status, string(data)) + os.Exit(1) + } + + printJSON(data) +} + +// cmdReplay handles: hatch replay -endpoint -target +func cmdReplay(args []string) { + fs := flag.NewFlagSet("replay", flag.ExitOnError) + endpoint := fs.String("endpoint", "", "Endpoint ID") + target := fs.String("target", "", "Target URL to replay to") + fs.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: hatch replay [options]\n\nReplay a captured request.\n\nOptions:\n") + fs.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nExamples:\n") + fmt.Fprintf(os.Stderr, " hatch replay abc123 -endpoint my-webhook -target https://httpbin.org/post\n") + } + fs.Parse(args) + + if fs.NArg() < 1 { + fmt.Fprintf(os.Stderr, "Error: request ID is required\n\n") + fs.Usage() + os.Exit(1) + } + + if *endpoint == "" { + fmt.Fprintf(os.Stderr, "Error: -endpoint is required\n\n") + fs.Usage() + os.Exit(1) + } + + if *target == "" { + fmt.Fprintf(os.Stderr, "Error: -target is required\n\n") + fs.Usage() + os.Exit(1) + } + + requestID := fs.Arg(0) + apiBody := map[string]string{ + "target_url": *target, + } + + path := fmt.Sprintf("/v1/endpoints/%s/requests/%s/replay", *endpoint, requestID) + data, status, err := apiRequest("POST", path, apiBody) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if status >= 400 { + fmt.Fprintf(os.Stderr, "Error (HTTP %d): %s\n", status, string(data)) + os.Exit(1) + } + + fmt.Println("Replay result:") + printJSON(data) +} + +// cmdMock handles: hatch mock set -status [-body BODY] [-header KEY:VALUE] +func cmdMock(args []string) { + if len(args) < 1 || args[0] != "set" { + fmt.Fprintf(os.Stderr, "Usage: hatch mock set [options]\n\nConfigure mock response.\n\nSubcommands:\n set Set mock configuration\n") + os.Exit(1) + } + + fs := flag.NewFlagSet("mock set", flag.ExitOnError) + statusCode := fs.Int("status", 200, "HTTP status code") + body := fs.String("body", "", "Response body (string or base64)") + var headers multiFlag + fs.Var(&headers, "header", "Response header in KEY:VALUE format (repeatable)") + fs.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: hatch mock set [options]\n\nConfigure mock response.\n\nOptions:\n") + fs.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nExamples:\n") + fmt.Fprintf(os.Stderr, " hatch mock set my-webhook -status 200 -body '{\"ok\":true}'\n") + fmt.Fprintf(os.Stderr, " hatch mock set my-webhook -status 418 -header 'X-Teapot:true'\n") + } + fs.Parse(args[1:]) // skip "set" + + if fs.NArg() < 1 { + fmt.Fprintf(os.Stderr, "Error: endpoint ID is required\n\n") + fs.Usage() + os.Exit(1) + } + + endpointID := fs.Arg(0) + + // Parse headers into map. + headerMap := make(map[string]string) + for _, h := range headers { + parts := strings.SplitN(h, ":", 2) + if len(parts) != 2 { + fmt.Fprintf(os.Stderr, "Error: invalid header format %q (expected KEY:VALUE)\n", h) + os.Exit(1) + } + headerMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + + // Build mock config body. + mockBody := map[string]interface{}{ + "status": *statusCode, + } + if *body != "" { + mockBody["body"] = *body + } + if len(headerMap) > 0 { + mockBody["headers"] = headerMap + } + + path := fmt.Sprintf("/v1/endpoints/%s/mock", endpointID) + data, status, err := apiRequest("PUT", path, mockBody) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if status >= 400 { + fmt.Fprintf(os.Stderr, "Error (HTTP %d): %s\n", status, string(data)) + os.Exit(1) + } + + fmt.Println("Mock configured successfully:") + printJSON(data) +} + +// cmdDoc handles: hatch doc generate +func cmdDoc(args []string) { + if len(args) < 1 || args[0] != "generate" { + fmt.Fprintf(os.Stderr, "Usage: hatch doc generate \n\nOutput OpenAPI spec.\n\nSubcommands:\n generate Generate OpenAPI spec for an endpoint\n") + os.Exit(1) + } + + fs := flag.NewFlagSet("doc generate", flag.ExitOnError) + fs.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: hatch doc generate \n\nOutput OpenAPI 3.1 spec for the Hatch API.\n\nExamples:\n") + fmt.Fprintf(os.Stderr, " hatch doc generate my-webhook\n") + fmt.Fprintf(os.Stderr, " hatch doc generate my-webhook > openapi.json\n") + } + fs.Parse(args[1:]) // skip "generate" + + if fs.NArg() < 1 { + fmt.Fprintf(os.Stderr, "Error: endpoint ID is required\n\n") + fs.Usage() + os.Exit(1) + } + + endpointID := fs.Arg(0) + path := fmt.Sprintf("/v1/endpoints/%s/openapi.json", endpointID) + data, status, err := apiRequest("GET", path, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if status >= 400 { + fmt.Fprintf(os.Stderr, "Error (HTTP %d): %s\n", status, string(data)) + os.Exit(1) + } + + printJSON(data) +} + +// multiFlag allows repeated -header flags. +type multiFlag []string + +func (m *multiFlag) String() string { return strings.Join(*m, ", ") } +func (m *multiFlag) Set(v string) error { + *m = append(*m, v) + return nil +} diff --git a/cmd/hatch/cli_test.go b/cmd/hatch/cli_test.go new file mode 100644 index 00000000..b1e173bf --- /dev/null +++ b/cmd/hatch/cli_test.go @@ -0,0 +1,285 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +// TestCLIHelp tests the help command. +func TestCLIHelp(t *testing.T) { + // Save and restore os.Args + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + os.Args = []string{"hatch", "help"} + + // cliMain prints help and returns true + if !cliMain() { + t.Error("expected cliMain to return true for help command") + } +} + +// TestCLIUnknownCommand tests unknown command handling. +// Note: This test verifies the command exists but doesn't call cliMain directly +// because it calls os.Exit(1). In production, the binary would exit. +func TestCLIUnknownCommand(t *testing.T) { + // Just verify the command parsing works for known commands + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + // Test that help works + os.Args = []string{"hatch", "help"} + if !cliMain() { + t.Error("expected cliMain to return true for help command") + } +} + +// TestCLIServe tests the serve command. +func TestCLIServe(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + os.Args = []string{"hatch", "serve"} + + // cliMain should return false (server mode) + if cliMain() { + t.Error("expected cliMain to return false for serve command") + } +} + +// TestCLINoSubcommand tests no subcommand (default to server). +func TestCLINoSubcommand(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + os.Args = []string{"hatch"} + + // cliMain should return false (server mode) + if cliMain() { + t.Error("expected cliMain to return false with no subcommand") + } +} + +// TestServerURL tests the serverURL helper. +func TestServerURL(t *testing.T) { + // Test default + oldURL := os.Getenv("HATCH_URL") + defer os.Setenv("HATCH_URL", oldURL) + + os.Unsetenv("HATCH_URL") + if got := serverURL(); got != "http://localhost:8080" { + t.Errorf("expected default URL, got %q", got) + } + + // Test custom URL + os.Setenv("HATCH_URL", "https://my-server.example.com") + if got := serverURL(); got != "https://my-server.example.com" { + t.Errorf("expected custom URL, got %q", got) + } + + // Test trailing slash removal + os.Setenv("HATCH_URL", "https://my-server.example.com/") + if got := serverURL(); got != "https://my-server.example.com" { + t.Errorf("expected URL without trailing slash, got %q", got) + } +} + +// TestPrintJSON tests JSON pretty printing. +func TestPrintJSON(t *testing.T) { + // Valid JSON + validJSON := []byte(`{"key":"value","number":42}`) + // Just ensure it doesn't panic + printJSON(validJSON) + + // Invalid JSON + invalidJSON := []byte(`not json`) + printJSON(invalidJSON) +} + +// TestMultiFlag tests the multiFlag type. +func TestMultiFlag(t *testing.T) { + var flags multiFlag + + if err := flags.Set("Content-Type:application/json"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := flags.Set("X-Custom:test"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(flags) != 2 { + t.Errorf("expected 2 flags, got %d", len(flags)) + } + if flags[0] != "Content-Type:application/json" { + t.Errorf("unexpected first flag: %q", flags[0]) + } + if flags[1] != "X-Custom:test" { + t.Errorf("unexpected second flag: %q", flags[1]) + } +} + +// TestCLICaptureIntegration tests the capture command against a mock server. +func TestCLICaptureIntegration(t *testing.T) { + // Start a mock server that accepts the API request + var receivedBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/endpoints/test-ep/requests" && r.Method == "POST" { + json.NewDecoder(r.Body).Decode(&receivedBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"id": "abc123", "status": "captured"}) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + // Set HATCH_URL to our test server + oldURL := os.Getenv("HATCH_URL") + defer os.Setenv("HATCH_URL", oldURL) + os.Setenv("HATCH_URL", server.URL) + + // Test the API request helper + body := map[string]interface{}{ + "method": "POST", + "path": "/", + "body": `{"test":"data"}`, + } + data, status, err := apiRequest("POST", "/v1/endpoints/test-ep/requests", body) + if err != nil { + t.Fatalf("apiRequest failed: %v", err) + } + if status != http.StatusCreated { + t.Errorf("expected status 201, got %d", status) + } + + var resp map[string]string + json.Unmarshal(data, &resp) + if resp["id"] != "abc123" { + t.Errorf("expected id abc123, got %q", resp["id"]) + } + + // Verify the server received the correct body + if receivedBody["method"] != "POST" { + t.Errorf("expected method POST, got %v", receivedBody["method"]) + } +} + +// TestCLIInspectIntegration tests the inspect command against a mock server. +func TestCLIInspectIntegration(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/endpoints/test-ep/requests" && r.Method == "GET" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]map[string]string{ + {"id": "1", "method": "POST"}, + {"id": "2", "method": "GET"}, + }) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + oldURL := os.Getenv("HATCH_URL") + defer os.Setenv("HATCH_URL", oldURL) + os.Setenv("HATCH_URL", server.URL) + + data, status, err := apiRequest("GET", "/v1/endpoints/test-ep/requests?limit=100", nil) + if err != nil { + t.Fatalf("apiRequest failed: %v", err) + } + if status != http.StatusOK { + t.Errorf("expected status 200, got %d", status) + } + + var requests []map[string]string + json.Unmarshal(data, &requests) + if len(requests) != 2 { + t.Errorf("expected 2 requests, got %d", len(requests)) + } +} + +// TestCLIMockIntegration tests the mock set command against a mock server. +func TestCLIMockIntegration(t *testing.T) { + var receivedBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/endpoints/test-ep/mock" && r.Method == "PUT" { + json.NewDecoder(r.Body).Decode(&receivedBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + oldURL := os.Getenv("HATCH_URL") + defer os.Setenv("HATCH_URL", oldURL) + os.Setenv("HATCH_URL", server.URL) + + body := map[string]interface{}{ + "status": 418, + "headers": map[string]string{ + "X-Teapot": "true", + }, + } + data, status, err := apiRequest("PUT", "/v1/endpoints/test-ep/mock", body) + if err != nil { + t.Fatalf("apiRequest failed: %v", err) + } + if status != http.StatusOK { + t.Errorf("expected status 200, got %d", status) + } + + var resp map[string]string + json.Unmarshal(data, &resp) + if resp["status"] != "ok" { + t.Errorf("expected status ok, got %q", resp["status"]) + } + + // Verify server received correct mock config + if receivedBody["status"] != float64(418) { + t.Errorf("expected status 418, got %v", receivedBody["status"]) + } +} + +// TestCLIReplayIntegration tests the replay command against a mock server. +func TestCLIReplayIntegration(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/endpoints/test-ep/requests/abc123/replay" && r.Method == "POST" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": 200, + "headers": map[string]string{"X-Reply": "yes"}, + "body": `{"replayed":true}`, + }) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + oldURL := os.Getenv("HATCH_URL") + defer os.Setenv("HATCH_URL", oldURL) + os.Setenv("HATCH_URL", server.URL) + + body := map[string]string{ + "target_url": "https://example.com/webhook", + } + data, status, err := apiRequest("POST", "/v1/endpoints/test-ep/requests/abc123/replay", body) + if err != nil { + t.Fatalf("apiRequest failed: %v", err) + } + if status != http.StatusOK { + t.Errorf("expected status 200, got %d", status) + } + + var resp map[string]interface{} + json.Unmarshal(data, &resp) + if resp["status"] != float64(200) { + t.Errorf("expected status 200, got %v", resp["status"]) + } +} diff --git a/cmd/hatch/main.go b/cmd/hatch/main.go index 7763966f..080fee4e 100644 --- a/cmd/hatch/main.go +++ b/cmd/hatch/main.go @@ -12,6 +12,12 @@ import ( ) func main() { + // CLI mode: if a subcommand is provided, handle it and exit. + if cliMain() { + return + } + + // Server mode: start the HTTP server. port := os.Getenv("PORT") if port == "" { port = "8080" diff --git a/docs/company/operating-model.md b/docs/company/operating-model.md index f58a39be..8d7ab89b 100644 --- a/docs/company/operating-model.md +++ b/docs/company/operating-model.md @@ -24,7 +24,7 @@ We run a **advice-and-decide** model, not consensus. ## Work Ownership - **One task = one owner = one branch.** No shared ownership. If a task is too big for one person, split it. -- **Tasks are issues in Paperclip.** Backlog → assigned → in progress → review → done. +- **Tasks are tracked in a structured workflow.** Backlog → assigned → in progress → review → done. - **Context is durable.** Every task includes: objective, acceptance criteria, current blocker (if any), and next action. When an owner changes, the context transfers in writing. ## Review and Shipping diff --git a/docs/engineering/onboarding.md b/docs/engineering/onboarding.md index 4235024d..78d85432 100644 --- a/docs/engineering/onboarding.md +++ b/docs/engineering/onboarding.md @@ -3,7 +3,6 @@ ## Before Day 1 - [ ] Access to GitHub org granted -- [ ] Access to Paperclip workspace granted - [ ] Added to project channels / async standup ## Day 1: Context diff --git a/scripts/check-paperclip.sh b/scripts/check-paperclip.sh new file mode 100755 index 00000000..cca81737 --- /dev/null +++ b/scripts/check-paperclip.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Check that no commit author/committer fields contain the word "paperclip" (case-insensitive). +# Used in CI to prevent accidental Paperclip references in git metadata. + +set -euo pipefail + +# Get commit range for the current PR (or latest commit on push) +if [[ "${GITHUB_EVENT_NAME:-}" == "pull_request" ]]; then + RANGE="origin/${GITHUB_BASE_REF}..HEAD" +else + RANGE="HEAD~1..HEAD" +fi + +# Extract author and committer fields (name and email) +FIELDS=$(git log --format="an:%an ae:%ae cn:%cn ce:%ce" "$RANGE") + +if echo "$FIELDS" | grep -i "paperclip"; then + echo "ERROR: Git author/committer field contains 'paperclip'. Please update git config." + echo "Matches:" + echo "$FIELDS" | grep -i "paperclip" + exit 1 +fi + +echo "OK: No Paperclip references found in author/committer fields."