Merge branch 'cto/ELF-164-cleanup-paperclip' into cto/ELF-169-cli-documentation
This commit is contained in:
commit
cbe13f2bfd
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@ -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 }}
|
||||
|
||||
@ -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 <noreply@paperclip.ing>
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
443
cmd/hatch/cli.go
Normal file
443
cmd/hatch/cli.go
Normal file
@ -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 <url> Send a request to an endpoint and store it
|
||||
// hatch inspect <endpoint> Fetch requests as JSON
|
||||
// hatch search <endpoint> Search captured traffic
|
||||
// hatch replay <request-id> Replay a request
|
||||
// hatch mock set <endpoint> Configure mock
|
||||
// hatch doc generate <endpoint> 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 <command> [options]
|
||||
|
||||
Commands:
|
||||
serve Start the server (default)
|
||||
capture <url> Send a request to an endpoint and store it
|
||||
inspect <endpoint> Fetch requests as JSON
|
||||
search <endpoint> Search captured traffic
|
||||
replay <request-id> Replay a request
|
||||
mock set <endpoint> Configure mock response
|
||||
doc generate <endpoint> Output OpenAPI spec
|
||||
version Print version
|
||||
|
||||
Run 'hatch <command> -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 <url> [-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 <url> [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 <endpoint> [-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 <endpoint> [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 <endpoint> -query <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 <endpoint> [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 <request-id> -endpoint <endpoint> -target <url>
|
||||
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 <request-id> [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 <endpoint> -status <code> [-body BODY] [-header KEY:VALUE]
|
||||
func cmdMock(args []string) {
|
||||
if len(args) < 1 || args[0] != "set" {
|
||||
fmt.Fprintf(os.Stderr, "Usage: hatch mock set <endpoint> [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 <endpoint> [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 <endpoint>
|
||||
func cmdDoc(args []string) {
|
||||
if len(args) < 1 || args[0] != "generate" {
|
||||
fmt.Fprintf(os.Stderr, "Usage: hatch doc generate <endpoint>\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 <endpoint>\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
|
||||
}
|
||||
285
cmd/hatch/cli_test.go
Normal file
285
cmd/hatch/cli_test.go
Normal file
@ -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"])
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
24
scripts/check-paperclip.sh
Executable file
24
scripts/check-paperclip.sh
Executable file
@ -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."
|
||||
Loading…
Reference in New Issue
Block a user